Selaa lähdekoodia

complete TOC2 implementation

so far

exchanging IMs works

so far

adding groups works

add buddies works

tests

fix SetPDMode

fix removeBuddy2

fix NewBuddies, AddPermit2, AddDeny2, RemovePermit2, RemoveDeny2, SetPDMode

fix messasging in toc2 miranda

implement INSERTED2 and DELETED2

support typing events

test TOC2 Signon

boost test coverage

refactor SendIMEnc

remove CONFIG2 chunking, moar test covoerage

replace TOC version bitmap with separate boolean vars

moar tests

fix initFLAP bug

restrict concurrent sessions to TOC2 only

Only append MultiConnFlagsRecentClient for toc2_login and toc2_signon;
toc_signon (TOC1) no longer requests multi-conn, so TOC1 stays
single-session. Update Signon tests so TOC1 cases do not expect the
multi-conn TLV.

send UPDATE_BUDDY2 for TOC2 on buddy departure

log screen name on FLAP init

use code 913 as general error code

use more appropriate error code for incorrect passsword when changing password

simplify empty command check

clean up redundant comment

additional TOC handler documentation
Mike 5 kuukautta sitten
vanhempi
commit
5df28c11a0

+ 2 - 0
cmd/server/factory.go

@@ -5,6 +5,7 @@ import (
 	"errors"
 	"fmt"
 	"log/slog"
+	"math/rand"
 	"os"
 	"strings"
 	"time"
@@ -487,6 +488,7 @@ func TOC(deps Container) *toc.Server {
 			SNACRateLimits:    deps.snacRateLimits,
 			HTTPIPRateLimiter: toc.NewIPRateLimiter(rate.Every(1*time.Minute), 10, 1*time.Minute),
 			SessionRetriever:  deps.inMemorySessionManager,
+			RandIntn:          rand.Intn,
 		},
 		toc.NewIPRateLimiter(rate.Every(1*time.Minute), 10, 1*time.Minute),
 		deps.icbmSvc.RestoreWarningLevel,

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 809 - 59
server/toc/cmd_client.go


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 2180 - 133
server/toc/cmd_client_test.go


+ 211 - 62
server/toc/cmd_server.go

@@ -6,6 +6,7 @@ import (
 	"encoding/base64"
 	"errors"
 	"fmt"
+	"log/slog"
 	"net"
 	"strings"
 	"time"
@@ -17,8 +18,10 @@ import (
 )
 
 var (
-	cmdInternalSvcErr    = fmt.Sprintf("ERROR:%s:internal server error", wire.TOCErrorAuthUnknownError) // jgk: should this be a SubErrorCode?
-	rateLimitExceededErr = "ERROR:903"
+	// cmdInternalSvcErr indicates a general failure. Use wire.TOCErrorAdminProcessingRequest
+	// error code as this is the closest applicable code as per TiK, Tameclone, phptoclib.
+	cmdInternalSvcErr    = "ERROR:" + wire.TOCErrorAdminProcessingRequest + ":internal server error"
+	rateLimitExceededErr = "ERROR:" + wire.TOCErrorGeneralRateLimitHit
 	errDisconnect        = errors.New("got booted by another session")
 )
 
@@ -42,15 +45,23 @@ func (s OSCARProxy) RecvBOS(ctx context.Context, me *state.SessionInstance, chat
 			case wire.SNAC_0x03_0x0B_BuddyArrived:
 				sendOrCancel(ctx, ch, s.UpdateBuddyArrival(v, me))
 			case wire.SNAC_0x03_0x0C_BuddyDeparted:
-				sendOrCancel(ctx, ch, s.UpdateBuddyDeparted(v))
+				sendOrCancel(ctx, ch, s.UpdateBuddyDeparted(v, me))
 			case wire.SNAC_0x04_0x07_ICBMChannelMsgToClient:
 				sendOrCancel(ctx, ch, s.IMIn(ctx, chatRegistry, me, v))
 			case wire.SNAC_0x01_0x10_OServiceEvilNotification:
 				sendOrCancel(ctx, ch, s.Eviled(v))
 			case wire.SNAC_0x04_0x14_ICBMClientEvent:
-				if hasFlag(me.TocVersion(), state.SupportsTOC2Enhanced) {
+				if me.IsTOC2() {
 					sendOrCancel(ctx, ch, s.ClientEvent(v))
 				}
+			case wire.SNAC_0x13_0x09_FeedbagUpdateItem:
+				if me.IsTOC2() {
+					sendOrCancel(ctx, ch, s.Inserted2(ctx, me, v))
+				}
+			case wire.SNAC_0x13_0x0A_FeedbagDeleteItem:
+				if me.IsTOC2() {
+					sendOrCancel(ctx, ch, s.Deleted2(ctx, me, v))
+				}
 
 			default:
 				s.Logger.DebugContext(ctx, fmt.Sprintf("unsupported snac. foodgroup: %s subgroup: %s",
@@ -172,14 +183,22 @@ func (s OSCARProxy) Eviled(snac wire.SNAC_0x01_0x10_OServiceEvilNotification) []
 	return []string{fmt.Sprintf("EVILED:%s:%s", warning, who)}
 }
 
-// IMIn handles the IM_IN and IM_IN_ENC2 TOC commands.
+// IMIn handles incoming ICBM channel messages and returns one of: IM_IN (TOC1), IM_IN2 or
+// IM_IN_ENC2 (TOC2), or for rendezvous channel CHAT_INVITE or RVOUS_PROPOSE.
 //
-// From the TiK documentation:
+// From the TiK documentation (TOC1 IM_IN):
 //
 //	Receive an IM from someone. Everything after the third colon is the
 //	incoming message, including other colons.
 //
+// TOC2 clients receive IM_IN2 (same structure as IM_IN with an extra field) or
+// IM_IN_ENC2 when the client supports encoded messages (BlueTOC/BizTOCSock documentation).
+// For ICBM rendezvous (chat invite, file transfer), this returns CHAT_INVITE or RVOUS_PROPOSE
+// instead (see convertICBMRendezvous).
+//
 // Command syntax: IM_IN:<Source User>:<Auto Response T/F?>:<Message>
+// Command syntax: IM_IN2:<Source User>:<Auto Response T/F?>:<Whisper?>:<Message>
+// Command syntax: IM_IN_ENC2:<User>:<Auto>:<???>:<???>:<User Class>:<???>:<???>:<Language>:<Message>
 func (s OSCARProxy) IMIn(ctx context.Context, chatRegistry *ChatRegistry, me *state.SessionInstance, snac wire.SNAC_0x04_0x07_ICBMChannelMsgToClient) []string {
 	switch snac.ChannelID {
 	case wire.ICBMChannelIM:
@@ -194,7 +213,6 @@ func (s OSCARProxy) IMIn(ctx context.Context, chatRegistry *ChatRegistry, me *st
 
 // convertICBMInstantMsg converts an ICBM instant message SNAC to a TOC IM_IN or TOC2 IM_IN2, or TOC2Enhanced IM_IN_ENC2 response.
 func (s OSCARProxy) convertICBMInstantMsg(ctx context.Context, me *state.SessionInstance, snac wire.SNAC_0x04_0x07_ICBMChannelMsgToClient) string {
-	fmt.Println(("jgk: convertICBMInstantMsg"))
 	buf, ok := snac.TLVRestBlock.Bytes(wire.ICBMTLVAOLIMData)
 	if !ok {
 		return s.runtimeErr(ctx, errors.New("TLVRestBlock.Bytes: missing wire.ICBMTLVAOLIMData"))[0]
@@ -209,24 +227,23 @@ func (s OSCARProxy) convertICBMInstantMsg(ctx context.Context, me *state.Session
 		autoResp = "T"
 	}
 
-	if hasFlag(me.TocVersion(), state.SupportsTOC2Enhanced) {
-		// IM_IN_ENC2:<user>:<auto>:<???>:<???>:<buddy status>:<???>:<???>:en:<message>
+	if me.SupportsTOC2MsgEnc() {
 		uFlags, hasVal := snac.TLVUserInfo.TLVList.Uint16BE(wire.OServiceUserInfoUserFlags)
 		if !hasVal {
-			// todo: handle if this tlv doesn't exist for some reason
-			fmt.Println("no has val")
+			s.Logger.DebugContext(ctx, "missing wire.OServiceUserInfoUserFlags in ICBM message")
 			return ""
 		}
 		ucArray := userClassString(uFlags, snac.IsAway())
-		uc := strings.Join(ucArray[:], "")
-		return fmt.Sprintf("IM_IN_ENC2:%s:%s:::%s:::en:%s", snac.ScreenName, autoResp, uc, txt)
+		// from a packet dump found in this russian zine: https://xn--lcss68aj21b.xn--w8je.xn--tckwe/books/xakep/spec65.pdf
+		// interesting that "L" is a value, not sure what it's for.
+		return fmt.Sprintf("IM_IN_ENC2:%s:%s:F:T:%s:F:L:en:%s", snac.ScreenName, autoResp, ucArray, txt)
 	}
 
-	cmdSuffix := ""
-	if (me.TocVersion() & state.SupportsTOC2) == state.SupportsTOC2 {
-		cmdSuffix = "2"
+	if me.IsTOC2() {
+		return fmt.Sprintf("IM_IN2:%s:%s:%s:%s", snac.ScreenName, autoResp, "F", txt)
 	}
-	return fmt.Sprintf("IM_IN%s:%s:%s:%s", cmdSuffix, snac.ScreenName, autoResp, txt)
+
+	return fmt.Sprintf("IM_IN:%s:%s:%s", snac.ScreenName, autoResp, txt)
 }
 
 // convertICBMRendezvous converts an ICBM rendezvous SNAC to a TOC response.
@@ -332,9 +349,11 @@ func (s OSCARProxy) convertICBMRendezvous(ctx context.Context, chatRegistry *Cha
 //			- 'U' - The user has set their unavailable flag.
 //
 // Command syntax: UPDATE_BUDDY:<Buddy User>:<Online? T/F>:<Evil Amount>:<Signon Time>:<IdleTime>:<UC>
+//
+// For TOC2 this sends UPDATE_BUDDY2 with the same fields (plus a trailing field). When
+// the buddy has capabilities, BUDDY_CAPS2 is also sent (see userInfoToBuddyCaps).
 func (s OSCARProxy) UpdateBuddyArrival(snac wire.SNAC_0x03_0x0B_BuddyArrived, me *state.SessionInstance) []string {
-
-	return []string{userInfoToUpdateBuddy(snac.TLVUserInfo, me), userInfoToBuddyCaps(snac.TLVUserInfo, me)}
+	return []string{userInfoToUpdateBuddy(snac.TLVUserInfo, me), userInfoToBuddyCaps(snac.TLVUserInfo, me, s.Logger)}
 }
 
 // UpdateBuddyDeparted handles the UPDATE_BUDDY TOC command for buddy departure events.
@@ -358,10 +377,144 @@ func (s OSCARProxy) UpdateBuddyArrival(snac wire.SNAC_0x03_0x0B_BuddyArrived, me
 //			- 'U' - The user has set their unavailable flag.
 //
 // Command syntax: UPDATE_BUDDY:<Buddy User>:<Online? T/F>:<Evil Amount>:<Signon Time>:<IdleTime>:<UC>
-func (s OSCARProxy) UpdateBuddyDeparted(snac wire.SNAC_0x03_0x0C_BuddyDeparted) []string {
+// TOC2 uses UPDATE_BUDDY2 with the same fields.
+func (s OSCARProxy) UpdateBuddyDeparted(snac wire.SNAC_0x03_0x0C_BuddyDeparted, me *state.SessionInstance) []string {
+	if me.IsTOC2() {
+		return []string{fmt.Sprintf("UPDATE_BUDDY2:%s:F:0:0:0:   :", snac.ScreenName)}
+	}
 	return []string{fmt.Sprintf("UPDATE_BUDDY:%s:F:0:0:0:   ", snac.ScreenName)}
 }
 
+// Inserted2 handles the INSERTED2 TOC2 server-to-client notifications.
+//
+// From the BlueTOC documentation:
+//
+//	Sent whenever the buddy list is modified from a different location (e.g. logged
+//	in twice). Dynamic updates when items are added to the buddy list.
+//
+//	INSERTED2:g:<group name>
+//	  A new group has been added to the buddy list.
+//
+//	INSERTED2:b:<alias>:<username>:<group>
+//	  A new screenname has been added.
+//
+//	INSERTED2:d:<username>
+//	  Somebody has been added to the deny list.
+//
+//	INSERTED2:p:<username>
+//	  Somebody has been added to the permit list.
+//
+// Inserted2 is invoked when this session receives FeedbagUpdateItem (e.g. list
+// modified from another client). The feedbag is queried when adding buddies to
+// resolve GroupID to group name; buddy alias comes from TLV.
+func (s OSCARProxy) Inserted2(ctx context.Context, me *state.SessionInstance, snac wire.SNAC_0x13_0x09_FeedbagUpdateItem) []string {
+	var out []string
+	groupNameByID := make(map[uint16]string)
+	hasBuddy := false
+	for _, item := range snac.Items {
+		if item.ClassID == wire.FeedbagClassIdBuddy {
+			hasBuddy = true
+			break
+		}
+	}
+	if hasBuddy {
+		fb, err := s.FeedbagManager.Feedbag(ctx, me.IdentScreenName())
+		if err != nil {
+			s.Logger.DebugContext(ctx, "Inserted2: feedbag lookup failed", "err", err)
+			return nil
+		}
+		for _, item := range fb {
+			if item.ClassID == wire.FeedbagClassIdGroup {
+				groupNameByID[item.GroupID] = item.Name
+			}
+		}
+	}
+	for _, item := range snac.Items {
+		switch item.ClassID {
+		case wire.FeedbagClassIdGroup:
+			out = append(out, fmt.Sprintf("INSERTED2:g:%s", item.Name))
+		case wire.FeedbagClassIdBuddy:
+			group := groupNameByID[item.GroupID]
+			if group == "" {
+				group = "Buddies"
+			}
+			alias := ""
+			if b, ok := item.Bytes(wire.FeedbagAttributesAlias); ok {
+				alias = string(b)
+			}
+			out = append(out, fmt.Sprintf("INSERTED2:b:%s:%s:%s", alias, item.Name, group))
+		case wire.FeedbagClassIDDeny:
+			out = append(out, fmt.Sprintf("INSERTED2:d:%s", item.Name))
+		case wire.FeedbagClassIDPermit:
+			out = append(out, fmt.Sprintf("INSERTED2:p:%s", item.Name))
+		}
+	}
+	return out
+}
+
+// Deleted2 handles the DELETED2 TOC2 server-to-client notifications.
+//
+// From the BlueTOC documentation:
+//
+//	Sent whenever the buddy list is modified from a different location. Dynamic
+//	updates when items are removed from the buddy list.
+//
+//	DELETED2:g:<group name>
+//	  A group has been deleted from the buddy list.
+//
+//	DELETED2:b:<username>:<group>
+//	  A user has been deleted from the buddy list.
+//
+//	DELETED2:d:<username>
+//	  A user has been removed from the deny list.
+//
+//	DELETED2:p:<username>
+//	  A user has been removed from the permit list.
+//
+// Deleted2 is invoked when this session receives FeedbagDeleteItem (e.g. list
+// modified from another client). The feedbag is queried when deleting buddies to
+// resolve GroupID to group name.
+func (s OSCARProxy) Deleted2(ctx context.Context, me *state.SessionInstance, snac wire.SNAC_0x13_0x0A_FeedbagDeleteItem) []string {
+	var out []string
+	groupNameByID := make(map[uint16]string)
+	hasBuddy := false
+	for _, item := range snac.Items {
+		if item.ClassID == wire.FeedbagClassIdBuddy {
+			hasBuddy = true
+			break
+		}
+	}
+	if hasBuddy {
+		fb, err := s.FeedbagManager.Feedbag(ctx, me.IdentScreenName())
+		if err != nil {
+			s.Logger.DebugContext(ctx, "Deleted2: feedbag lookup failed", "err", err)
+			return nil
+		}
+		for _, item := range fb {
+			if item.ClassID == wire.FeedbagClassIdGroup {
+				groupNameByID[item.GroupID] = item.Name
+			}
+		}
+	}
+	for _, item := range snac.Items {
+		switch item.ClassID {
+		case wire.FeedbagClassIdGroup:
+			out = append(out, fmt.Sprintf("DELETED2:g:%s", item.Name))
+		case wire.FeedbagClassIdBuddy:
+			group := groupNameByID[item.GroupID]
+			if group == "" {
+				group = "Buddies"
+			}
+			out = append(out, fmt.Sprintf("DELETED2:b:%s:%s", item.Name, group))
+		case wire.FeedbagClassIDDeny:
+			out = append(out, fmt.Sprintf("DELETED2:d:%s", item.Name))
+		case wire.FeedbagClassIDPermit:
+			out = append(out, fmt.Sprintf("DELETED2:p:%s", item.Name))
+		}
+	}
+	return out
+}
+
 // ClientEvent handles the CLIENT_EVENT2 TOC2 command.
 //
 // From BizTOCSock documentation:
@@ -372,7 +525,7 @@ func (s OSCARProxy) UpdateBuddyDeparted(snac wire.SNAC_0x03_0x0C_BuddyDeparted)
 //  recording..." If it were one, it would probably be code 3.
 
 //	0 = User is doing nothing
-//	1 = User has enterted Text
+//	1 = User has entered text
 //	2 = User is currently typing
 //
 // Command syntax: CLIENT_EVENT2:<Buddy User>:<Typing Status>
@@ -381,7 +534,7 @@ func (s OSCARProxy) ClientEvent(snac wire.SNAC_0x04_0x14_ICBMClientEvent) []stri
 }
 
 // userClassString generates the 3-character user class (UC) string based on user flags and away status.
-func userClassString(uFlags uint16, isAway bool) [3]string {
+func userClassString(uFlags uint16, isAway bool) string {
 	uc := [3]string{" ", " ", " "}
 
 	if hasFlag(uFlags, wire.OServiceUserFlagAOL) {
@@ -401,7 +554,8 @@ func userClassString(uFlags uint16, isAway bool) [3]string {
 	if isAway {
 		uc[2] = "U"
 	}
-	return uc
+
+	return strings.Join(uc[:], "")
 }
 
 func sendOrCancel(ctx context.Context, ch chan<- []string, msg []string) {
@@ -413,37 +567,21 @@ func sendOrCancel(ctx context.Context, ch chan<- []string, msg []string) {
 	}
 }
 
-// '''''''BUDDY_CAPS2''''''''
-
-// '[BUDDY_CAPS2] [User] [Cap 1, Cap 2, Cap3, etc]
-
-// 'These are the buddies capabilities, such as Chat, Live Video, Direct Connect, etc.
-// 'These are sent with every UPDATE_BUDDY2. Meaning, if a user updates to where they
-// 'can use Direct Connect, you will get sent both packets.
-
-// 'Example: BUDDY_CAPS2:Bizkit047:0,105,1FF,1,101,102,
-// wire.OServiceUserInfoOscarCaps
-
 // userInfoToUpdateBuddy creates an UPDATE_BUDDY or UPDATE_BUDDY2 server reply from a User
 // Info TLV.
 func userInfoToUpdateBuddy(snac wire.TLVUserInfo, me *state.SessionInstance) string {
 	online, _ := snac.Uint32BE(wire.OServiceUserInfoSignonTOD)
 	idle, _ := snac.Uint16BE(wire.OServiceUserInfoIdleTime)
 
-	uFlags, hasVal := snac.TLVList.Uint16BE(wire.OServiceUserInfoUserFlags)
-	if !hasVal {
-		// todo: handle if this tlv doesn't exist for some reason
-		return ""
-	}
-	ucArray := userClassString(uFlags, snac.IsAway())
-	uc := strings.Join(ucArray[:], "")
-
+	uFlags, _ := snac.TLVList.Uint16BE(wire.OServiceUserInfoUserFlags)
+	uc := userClassString(uFlags, snac.IsAway())
 	warning := fmt.Sprintf("%d", snac.WarningLevel/10)
-	cmd := "UPDATE_BUDDY"
-	if hasFlag(me.TocVersion(), state.SupportsTOC2) {
-		cmd = "UPDATE_BUDDY2"
+
+	if me.IsTOC2() {
+		return fmt.Sprintf("UPDATE_BUDDY2:%s:%s:%s:%d:%d:%s:", snac.ScreenName, "T", warning, online, idle, uc)
 	}
-	return fmt.Sprintf("%s:%s:%s:%s:%d:%d:%s", cmd, snac.ScreenName, "T", warning, online, idle, uc)
+
+	return fmt.Sprintf("UPDATE_BUDDY:%s:%s:%s:%d:%d:%s", snac.ScreenName, "T", warning, online, idle, uc)
 }
 
 // hasFlag checks if a specific flag is set in the bitmask.
@@ -451,24 +589,35 @@ func hasFlag[T ~uint16 | ~uint8](bitmask, flag T) bool {
 	return (bitmask & flag) == flag
 }
 
-// userInfoToBuddyCaps creates a BUDDY_CAPS2 server reply from a User Info TLV.
-func userInfoToBuddyCaps(snac wire.TLVUserInfo, me *state.SessionInstance) string {
-	if hasFlag(me.TocVersion(), state.SupportsTOC) {
+// userInfoToBuddyCaps creates a BUDDY_CAPS2 server-to-client message from a User Info TLV.
+//
+// From the BizTOCSock documentation:
+//
+//	These are the buddies capabilities, such as Chat, Live Video, Direct Connect, etc.
+//	They are sent with every UPDATE_BUDDY2. If a user updates to where they can use
+//	Direct Connect, you will get sent both packets.
+//
+// Format: BUDDY_CAPS2:<User>:<Cap1>,<Cap2>,...
+func userInfoToBuddyCaps(snac wire.TLVUserInfo, me *state.SessionInstance, logger *slog.Logger) string {
+	if !me.IsTOC2() {
 		return ""
 	}
-	clientCaps := ""
-	if b, hasCaps := snac.TLVList.Bytes(wire.OServiceUserInfoOscarCaps); hasCaps {
-		if len(b)%16 != 0 {
-			// todo: capability list must be array of 16-byte values
-		}
-		var capStrings []string
-		for i := 0; i < len(b); i += 16 {
-			var c [16]byte
-			copy(c[:], b[i:i+16])
-			uid := uuid.UUID(c)
-			capStrings = append(capStrings, uid.String())
-		}
-		clientCaps = strings.Join(capStrings, ",")
+	b, hasCaps := snac.TLVList.Bytes(wire.OServiceUserInfoOscarCaps)
+	if !hasCaps {
+		logger.DebugContext(context.Background(), "userInfoToBuddyCaps: no buddy caps found")
+		return ""
+	}
+	if len(b)%16 != 0 {
+		logger.DebugContext(context.Background(), "userInfoToBuddyCaps: buddy caps length not divisible by 16")
+		return ""
+	}
+	var capStrings []string
+	for i := 0; i < len(b); i += 16 {
+		var c [16]byte
+		copy(c[:], b[i:i+16])
+		uid := uuid.UUID(c)
+		capStrings = append(capStrings, uid.String())
 	}
+	clientCaps := strings.Join(capStrings, ",")
 	return fmt.Sprintf("BUDDY_CAPS2:%s:%s", snac.ScreenName, clientCaps)
 }

+ 646 - 5
server/toc/cmd_server_test.go

@@ -342,6 +342,75 @@ func TestOSCARProxy_RecvBOS_IMIn(t *testing.T) {
 			},
 			wantCmd: []string{"IM_IN:them:T:hello world!"},
 		},
+		{
+			name: "send IM - TOC2 (IM_IN2)",
+			me:   newTestSession("me", func(i *state.SessionInstance) { i.SetTOC2(false) }),
+			givenMsg: wire.SNACMessage{
+				Body: wire.SNAC_0x04_0x07_ICBMChannelMsgToClient{
+					ChannelID: wire.ICBMChannelIM,
+					TLVUserInfo: wire.TLVUserInfo{
+						ScreenName: "them",
+					},
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.ICBMTLVAOLIMData, []wire.ICBMCh1Fragment{
+								{ID: 0x5, Version: 0x1, Payload: []uint8{0x1, 0x1, 0x2}},
+								{ID: 0x1, Version: 0x1, Payload: []uint8{0x0, 0x0, 0x0, 0x0, 'h', 'e', 'l', 'l', 'o', ' ', 'w', 'o', 'r', 'l', 'd', '!'}},
+							}),
+						},
+					},
+				},
+			},
+			wantCmd: []string{"IM_IN2:them:F:F:hello world!"},
+		},
+		{
+			name: "send IM - TOC2 with encoded messaging (IM_IN_ENC2)",
+			me:   newTestSession("me", func(i *state.SessionInstance) { i.SetTOC2(true) }),
+			givenMsg: wire.SNACMessage{
+				Body: wire.SNAC_0x04_0x07_ICBMChannelMsgToClient{
+					ChannelID: wire.ICBMChannelIM,
+					TLVUserInfo: wire.TLVUserInfo{
+						ScreenName: "them",
+						TLVBlock: wire.TLVBlock{
+							TLVList: wire.TLVList{
+								wire.NewTLVBE(wire.OServiceUserInfoUserFlags, wire.OServiceUserFlagOSCARFree),
+							},
+						},
+					},
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.ICBMTLVAOLIMData, []wire.ICBMCh1Fragment{
+								{ID: 0x5, Version: 0x1, Payload: []uint8{0x1, 0x1, 0x2}},
+								{ID: 0x1, Version: 0x1, Payload: []uint8{0x0, 0x0, 0x0, 0x0, 'h', 'e', 'l', 'l', 'o', ' ', 'w', 'o', 'r', 'l', 'd', '!'}},
+							}),
+						},
+					},
+				},
+			},
+			wantCmd: []string{"IM_IN_ENC2:them:F:F:T: O :F:L:en:hello world!"},
+		},
+		{
+			name: "send IM - TOC2 encoded messaging missing OServiceUserInfoUserFlags returns empty",
+			me:   newTestSession("me", func(i *state.SessionInstance) { i.SetTOC2(true) }),
+			givenMsg: wire.SNACMessage{
+				Body: wire.SNAC_0x04_0x07_ICBMChannelMsgToClient{
+					ChannelID: wire.ICBMChannelIM,
+					TLVUserInfo: wire.TLVUserInfo{
+						ScreenName: "them",
+						// no TLVList / OServiceUserInfoUserFlags
+					},
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.ICBMTLVAOLIMData, []wire.ICBMCh1Fragment{
+								{ID: 0x5, Version: 0x1, Payload: []uint8{0x1, 0x1, 0x2}},
+								{ID: 0x1, Version: 0x1, Payload: []uint8{0x0, 0x0, 0x0, 0x0, 'h', 'e', 'l', 'l', 'o', ' ', 'w', 'o', 'r', 'l', 'd', '!'}},
+							}),
+						},
+					},
+				},
+			},
+			wantCmd: []string{""},
+		},
 		{
 			name: "send chat invitation",
 			me:   newTestSession("me"),
@@ -462,7 +531,7 @@ func TestOSCARProxy_RecvBOS_UpdateBuddyArrival(t *testing.T) {
 					},
 				},
 			},
-			wantCmd: []string{"UPDATE_BUDDY:me:T:0:1234:5678: O "},
+			wantCmd: []string{"UPDATE_BUDDY:me:T:0:1234:5678: O ", ""},
 		},
 		{
 			name: "send buddy arrival - buddy warned 10%",
@@ -482,7 +551,7 @@ func TestOSCARProxy_RecvBOS_UpdateBuddyArrival(t *testing.T) {
 					},
 				},
 			},
-			wantCmd: []string{"UPDATE_BUDDY:me:T:10:1234:5678: O "},
+			wantCmd: []string{"UPDATE_BUDDY:me:T:10:1234:5678: O ", ""},
 		},
 		{
 			name: "send buddy arrival - buddy away",
@@ -502,7 +571,179 @@ func TestOSCARProxy_RecvBOS_UpdateBuddyArrival(t *testing.T) {
 					},
 				},
 			},
-			wantCmd: []string{"UPDATE_BUDDY:me:T:0:1234:5678: OU"},
+			wantCmd: []string{"UPDATE_BUDDY:me:T:0:1234:5678: OU", ""},
+		},
+		{
+			name: "send buddy arrival - user class AOL (userClassString uc[0])",
+			me:   newTestSession("me"),
+			givenMsg: wire.SNACMessage{
+				Body: wire.SNAC_0x03_0x0B_BuddyArrived{
+					TLVUserInfo: wire.TLVUserInfo{
+						ScreenName:   "me",
+						WarningLevel: 0,
+						TLVBlock: wire.TLVBlock{
+							TLVList: wire.TLVList{
+								wire.NewTLVBE(wire.OServiceUserInfoSignonTOD, uint32(1234)),
+								wire.NewTLVBE(wire.OServiceUserInfoIdleTime, uint16(5678)),
+								wire.NewTLVBE(wire.OServiceUserInfoUserFlags, wire.OServiceUserFlagAOL),
+							},
+						},
+					},
+				},
+			},
+			wantCmd: []string{"UPDATE_BUDDY:me:T:0:1234:5678:A  ", ""},
+		},
+		{
+			name: "send buddy arrival - user class Administrator (userClassString uc[1])",
+			me:   newTestSession("me"),
+			givenMsg: wire.SNACMessage{
+				Body: wire.SNAC_0x03_0x0B_BuddyArrived{
+					TLVUserInfo: wire.TLVUserInfo{
+						ScreenName:   "me",
+						WarningLevel: 0,
+						TLVBlock: wire.TLVBlock{
+							TLVList: wire.TLVList{
+								wire.NewTLVBE(wire.OServiceUserInfoSignonTOD, uint32(1234)),
+								wire.NewTLVBE(wire.OServiceUserInfoIdleTime, uint16(5678)),
+								wire.NewTLVBE(wire.OServiceUserInfoUserFlags, wire.OServiceUserFlagAdministrator),
+							},
+						},
+					},
+				},
+			},
+			wantCmd: []string{"UPDATE_BUDDY:me:T:0:1234:5678: A ", ""},
+		},
+		{
+			name: "send buddy arrival - user class Wireless (userClassString uc[1])",
+			me:   newTestSession("me"),
+			givenMsg: wire.SNACMessage{
+				Body: wire.SNAC_0x03_0x0B_BuddyArrived{
+					TLVUserInfo: wire.TLVUserInfo{
+						ScreenName:   "me",
+						WarningLevel: 0,
+						TLVBlock: wire.TLVBlock{
+							TLVList: wire.TLVList{
+								wire.NewTLVBE(wire.OServiceUserInfoSignonTOD, uint32(1234)),
+								wire.NewTLVBE(wire.OServiceUserInfoIdleTime, uint16(5678)),
+								wire.NewTLVBE(wire.OServiceUserInfoUserFlags, wire.OServiceUserFlagWireless),
+							},
+						},
+					},
+				},
+			},
+			wantCmd: []string{"UPDATE_BUDDY:me:T:0:1234:5678: C ", ""},
+		},
+		{
+			name: "send buddy arrival - user class Unconfirmed (userClassString uc[1])",
+			me:   newTestSession("me"),
+			givenMsg: wire.SNACMessage{
+				Body: wire.SNAC_0x03_0x0B_BuddyArrived{
+					TLVUserInfo: wire.TLVUserInfo{
+						ScreenName:   "me",
+						WarningLevel: 0,
+						TLVBlock: wire.TLVBlock{
+							TLVList: wire.TLVList{
+								wire.NewTLVBE(wire.OServiceUserInfoSignonTOD, uint32(1234)),
+								wire.NewTLVBE(wire.OServiceUserInfoIdleTime, uint16(5678)),
+								wire.NewTLVBE(wire.OServiceUserInfoUserFlags, wire.OServiceUserFlagUnconfirmed),
+							},
+						},
+					},
+				},
+			},
+			wantCmd: []string{"UPDATE_BUDDY:me:T:0:1234:5678: U ", ""},
+		},
+		{
+			name: "send buddy arrival - TOC2 no caps (userInfoToBuddyCaps returns empty when no OServiceUserInfoOscarCaps TLV)",
+			me:   newTestSession("me", func(i *state.SessionInstance) { i.SetTOC2(false) }),
+			givenMsg: wire.SNACMessage{
+				Body: wire.SNAC_0x03_0x0B_BuddyArrived{
+					TLVUserInfo: wire.TLVUserInfo{
+						ScreenName:   "buddy",
+						WarningLevel: 0,
+						TLVBlock: wire.TLVBlock{
+							TLVList: wire.TLVList{
+								wire.NewTLVBE(wire.OServiceUserInfoSignonTOD, uint32(1234)),
+								wire.NewTLVBE(wire.OServiceUserInfoIdleTime, uint16(5678)),
+								wire.NewTLVBE(wire.OServiceUserInfoUserFlags, wire.OServiceUserFlagOSCARFree),
+							},
+						},
+					},
+				},
+			},
+			wantCmd: []string{"UPDATE_BUDDY2:buddy:T:0:1234:5678: O :", ""},
+		},
+		{
+			name: "send buddy arrival - TOC2 caps length not divisible by 16 (userInfoToBuddyCaps returns empty)",
+			me:   newTestSession("me", func(i *state.SessionInstance) { i.SetTOC2(false) }),
+			givenMsg: wire.SNACMessage{
+				Body: wire.SNAC_0x03_0x0B_BuddyArrived{
+					TLVUserInfo: wire.TLVUserInfo{
+						ScreenName:   "buddy",
+						WarningLevel: 0,
+						TLVBlock: wire.TLVBlock{
+							TLVList: wire.TLVList{
+								wire.NewTLVBE(wire.OServiceUserInfoSignonTOD, uint32(1234)),
+								wire.NewTLVBE(wire.OServiceUserInfoIdleTime, uint16(5678)),
+								wire.NewTLVBE(wire.OServiceUserInfoUserFlags, wire.OServiceUserFlagOSCARFree),
+								// Invalid: 8 bytes, not divisible by 16
+								wire.NewTLVBE(wire.OServiceUserInfoOscarCaps, []byte{0x55, 0x0e, 0x84, 0x00, 0xe2, 0x9b, 0x41, 0xd4}),
+							},
+						},
+					},
+				},
+			},
+			wantCmd: []string{"UPDATE_BUDDY2:buddy:T:0:1234:5678: O :", ""},
+		},
+		{
+			name: "send buddy arrival - TOC2 with one capability (userInfoToBuddyCaps formats caps as UUIDs)",
+			me:   newTestSession("me", func(i *state.SessionInstance) { i.SetTOC2(false) }),
+			givenMsg: wire.SNACMessage{
+				Body: wire.SNAC_0x03_0x0B_BuddyArrived{
+					TLVUserInfo: wire.TLVUserInfo{
+						ScreenName:   "buddy",
+						WarningLevel: 0,
+						TLVBlock: wire.TLVBlock{
+							TLVList: wire.TLVList{
+								wire.NewTLVBE(wire.OServiceUserInfoSignonTOD, uint32(1234)),
+								wire.NewTLVBE(wire.OServiceUserInfoIdleTime, uint16(5678)),
+								wire.NewTLVBE(wire.OServiceUserInfoUserFlags, wire.OServiceUserFlagOSCARFree),
+								// One 16-byte cap: UUID 550e8400-e29b-41d4-a716-446655440000
+								wire.NewTLVBE(wire.OServiceUserInfoOscarCaps, []byte{
+									0x55, 0x0e, 0x84, 0x00, 0xe2, 0x9b, 0x41, 0xd4,
+									0xa7, 0x16, 0x44, 0x66, 0x55, 0x44, 0x00, 0x00,
+								}),
+							},
+						},
+					},
+				},
+			},
+			wantCmd: []string{"UPDATE_BUDDY2:buddy:T:0:1234:5678: O :", "BUDDY_CAPS2:buddy:550e8400-e29b-41d4-a716-446655440000"},
+		},
+		{
+			name: "send buddy arrival - TOC2 with two capabilities",
+			me:   newTestSession("me", func(i *state.SessionInstance) { i.SetTOC2(false) }),
+			givenMsg: wire.SNACMessage{
+				Body: wire.SNAC_0x03_0x0B_BuddyArrived{
+					TLVUserInfo: wire.TLVUserInfo{
+						ScreenName:   "buddy",
+						WarningLevel: 0,
+						TLVBlock: wire.TLVBlock{
+							TLVList: wire.TLVList{
+								wire.NewTLVBE(wire.OServiceUserInfoSignonTOD, uint32(1234)),
+								wire.NewTLVBE(wire.OServiceUserInfoIdleTime, uint16(5678)),
+								wire.NewTLVBE(wire.OServiceUserInfoUserFlags, wire.OServiceUserFlagOSCARFree),
+								// Two 16-byte caps
+								wire.NewTLVBE(wire.OServiceUserInfoOscarCaps, []byte{
+									0x55, 0x0e, 0x84, 0x00, 0xe2, 0x9b, 0x41, 0xd4, 0xa7, 0x16, 0x44, 0x66, 0x55, 0x44, 0x00, 0x00,
+									0x74, 0x8f, 0x24, 0x20, 0x62, 0x87, 0x11, 0xd1, 0x82, 0x22, 0x44, 0x45, 0x53, 0x54, 0x00, 0x00,
+								}),
+							},
+						},
+					},
+				},
+			},
+			wantCmd: []string{"UPDATE_BUDDY2:buddy:T:0:1234:5678: O :", "BUDDY_CAPS2:buddy:550e8400-e29b-41d4-a716-446655440000,748f2420-6287-11d1-8222-444553540000"},
 		},
 	}
 
@@ -526,7 +767,10 @@ func TestOSCARProxy_RecvBOS_UpdateBuddyArrival(t *testing.T) {
 			assert.Equal(t, state.SessSendOK, status)
 
 			gotCmd := <-ch
-			assert.Equal(t, tc.wantCmd[0], gotCmd[0])
+			assert.Len(t, gotCmd, len(tc.wantCmd), "UpdateBuddyArrival returns UPDATE_BUDDY line and BUDDY_CAPS2 line (empty for non-TOC2)")
+			for i, want := range tc.wantCmd {
+				assert.Equal(t, want, gotCmd[i])
+			}
 
 			cancel()
 			wg.Wait()
@@ -546,7 +790,7 @@ func TestOSCARProxy_RecvBOS_UpdateBuddyDeparted(t *testing.T) {
 		wantCmd []string
 	}{
 		{
-			name: "send buddy departure",
+			name: "send buddy departure TOC1",
 			me:   newTestSession("me"),
 			givenMsg: wire.SNACMessage{
 				Body: wire.SNAC_0x03_0x0C_BuddyDeparted{
@@ -557,6 +801,18 @@ func TestOSCARProxy_RecvBOS_UpdateBuddyDeparted(t *testing.T) {
 			},
 			wantCmd: []string{"UPDATE_BUDDY:me:F:0:0:0:   "},
 		},
+		{
+			name: "send buddy departure TOC2",
+			me:   newTestSession("me", func(i *state.SessionInstance) { i.SetTOC2(true) }),
+			givenMsg: wire.SNACMessage{
+				Body: wire.SNAC_0x03_0x0C_BuddyDeparted{
+					TLVUserInfo: wire.TLVUserInfo{
+						ScreenName: "me",
+					},
+				},
+			},
+			wantCmd: []string{"UPDATE_BUDDY2:me:F:0:0:0:   :"},
+		},
 	}
 
 	for _, tc := range cases {
@@ -587,9 +843,394 @@ func TestOSCARProxy_RecvBOS_UpdateBuddyDeparted(t *testing.T) {
 	}
 }
 
+func TestOSCARProxy_RecvBOS_ClientEvent(t *testing.T) {
+	cases := []struct {
+		name     string
+		me       *state.SessionInstance
+		givenMsg wire.SNACMessage
+		wantCmd  []string
+	}{
+		{
+			name: "TOC2 client receives CLIENT_EVENT2 (typing event)",
+			me:   newTestSession("me", func(i *state.SessionInstance) { i.SetTOC2(false) }),
+			givenMsg: wire.SNACMessage{
+				Body: wire.SNAC_0x04_0x14_ICBMClientEvent{
+					ScreenName: "buddy",
+					Event:      1, // typing
+				},
+			},
+			wantCmd: []string{"CLIENT_EVENT2:buddy:1"},
+		},
+		{
+			name: "TOC2 client receives CLIENT_EVENT2 event 0 (idle)",
+			me:   newTestSession("me", func(i *state.SessionInstance) { i.SetTOC2(false) }),
+			givenMsg: wire.SNACMessage{
+				Body: wire.SNAC_0x04_0x14_ICBMClientEvent{
+					ScreenName: "alice",
+					Event:      0,
+				},
+			},
+			wantCmd: []string{"CLIENT_EVENT2:alice:0"},
+		},
+		{
+			name: "TOC2 client receives CLIENT_EVENT2 event 2 (entered text)",
+			me:   newTestSession("me", func(i *state.SessionInstance) { i.SetTOC2(false) }),
+			givenMsg: wire.SNACMessage{
+				Body: wire.SNAC_0x04_0x14_ICBMClientEvent{
+					ScreenName: "bob",
+					Event:      2,
+				},
+			},
+			wantCmd: []string{"CLIENT_EVENT2:bob:2"},
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			ctx, cancel := context.WithCancel(context.Background())
+
+			svc := testOSCARProxy(t)
+
+			ch := make(chan []string)
+			wg := &sync.WaitGroup{}
+			wg.Add(1)
+
+			go func() {
+				defer wg.Done()
+				err := svc.RecvBOS(ctx, tc.me, NewChatRegistry(), ch)
+				assert.NoError(t, err)
+			}()
+
+			status := tc.me.RelayMessageToInstance(tc.givenMsg)
+			assert.Equal(t, state.SessSendOK, status)
+
+			gotCmd := <-ch
+			assert.Equal(t, tc.wantCmd, gotCmd)
+
+			cancel()
+			wg.Wait()
+		})
+	}
+}
+
 func TestOSCARProxy_RecvBOS_Signout(t *testing.T) {
 }
 
+func TestOSCARProxy_Inserted2(t *testing.T) {
+	ctx := context.Background()
+	me := newTestSession("me")
+
+	buddyWithAlias := wire.FeedbagItem{
+		ItemID: 1, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "alice",
+		TLVLBlock: wire.TLVLBlock{},
+	}
+	buddyWithAlias.Append(wire.NewTLVBE(wire.FeedbagAttributesAlias, []byte("Alice N.")))
+
+	cases := []struct {
+		name       string
+		snac       wire.SNAC_0x13_0x09_FeedbagUpdateItem
+		wantCmd    []string
+		mockParams mockParams
+	}{
+		{
+			name: "one buddy - group name from feedbag",
+			snac: wire.SNAC_0x13_0x09_FeedbagUpdateItem{
+				Items: []wire.FeedbagItem{
+					{ItemID: 1, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "alice"},
+				},
+			},
+			wantCmd: []string{"INSERTED2:b::alice:Work"},
+			mockParams: mockParams{
+				feedBagParams: feedBagParams{
+					feedbagParams: feedbagParams{
+						{screenName: state.NewIdentScreenName("me"), results: []wire.FeedbagItem{{ItemID: 1, ClassID: wire.FeedbagClassIdGroup, GroupID: 100, Name: "Work"}}, err: nil},
+					},
+				},
+			},
+		},
+		{
+			name: "one buddy with alias",
+			snac: wire.SNAC_0x13_0x09_FeedbagUpdateItem{
+				Items: []wire.FeedbagItem{buddyWithAlias},
+			},
+			wantCmd: []string{"INSERTED2:b:Alice N.:alice:Work"},
+			mockParams: mockParams{
+				feedBagParams: feedBagParams{
+					feedbagParams: feedbagParams{
+						{screenName: state.NewIdentScreenName("me"), results: []wire.FeedbagItem{{ItemID: 1, ClassID: wire.FeedbagClassIdGroup, GroupID: 100, Name: "Work"}}, err: nil},
+					},
+				},
+			},
+		},
+		{
+			name: "one buddy - unknown group ID uses Buddies",
+			snac: wire.SNAC_0x13_0x09_FeedbagUpdateItem{
+				Items: []wire.FeedbagItem{
+					{ItemID: 1, ClassID: wire.FeedbagClassIdBuddy, GroupID: 999, Name: "bob"},
+				},
+			},
+			wantCmd: []string{"INSERTED2:b::bob:Buddies"},
+			mockParams: mockParams{
+				feedBagParams: feedBagParams{
+					feedbagParams: feedbagParams{
+						{screenName: state.NewIdentScreenName("me"), results: []wire.FeedbagItem{}, err: nil},
+					},
+				},
+			},
+		},
+		{
+			name: "two buddies same group",
+			snac: wire.SNAC_0x13_0x09_FeedbagUpdateItem{
+				Items: []wire.FeedbagItem{
+					{ItemID: 1, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "alice"},
+					{ItemID: 2, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "bob"},
+				},
+			},
+			wantCmd: []string{"INSERTED2:b::alice:Friends", "INSERTED2:b::bob:Friends"},
+			mockParams: mockParams{
+				feedBagParams: feedBagParams{
+					feedbagParams: feedbagParams{
+						{screenName: state.NewIdentScreenName("me"), results: []wire.FeedbagItem{{ItemID: 1, ClassID: wire.FeedbagClassIdGroup, GroupID: 100, Name: "Friends"}}, err: nil},
+					},
+				},
+			},
+		},
+		{
+			name: "group added",
+			snac: wire.SNAC_0x13_0x09_FeedbagUpdateItem{
+				Items: []wire.FeedbagItem{
+					{ItemID: 1, ClassID: wire.FeedbagClassIdGroup, GroupID: 100, Name: "NewGroup"},
+				},
+			},
+			wantCmd:    []string{"INSERTED2:g:NewGroup"},
+			mockParams: mockParams{},
+		},
+		{
+			name: "permit and deny added",
+			snac: wire.SNAC_0x13_0x09_FeedbagUpdateItem{
+				Items: []wire.FeedbagItem{
+					{ItemID: 1, ClassID: wire.FeedbagClassIDPermit, GroupID: 0, Name: "alice"},
+					{ItemID: 2, ClassID: wire.FeedbagClassIDDeny, GroupID: 0, Name: "bob"},
+				},
+			},
+			wantCmd:    []string{"INSERTED2:p:alice", "INSERTED2:d:bob"},
+			mockParams: mockParams{},
+		},
+		{
+			name: "all four types - group buddy deny permit",
+			snac: wire.SNAC_0x13_0x09_FeedbagUpdateItem{
+				Items: []wire.FeedbagItem{
+					{ItemID: 1, ClassID: wire.FeedbagClassIdGroup, GroupID: 200, Name: "NewGroup"},
+					{ItemID: 2, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "alice"},
+					{ItemID: 3, ClassID: wire.FeedbagClassIDDeny, GroupID: 0, Name: "blockedUser"},
+					{ItemID: 4, ClassID: wire.FeedbagClassIDPermit, GroupID: 0, Name: "allowedUser"},
+				},
+			},
+			wantCmd: []string{"INSERTED2:g:NewGroup", "INSERTED2:b::alice:Buddies", "INSERTED2:d:blockedUser", "INSERTED2:p:allowedUser"},
+			mockParams: mockParams{
+				feedBagParams: feedBagParams{
+					feedbagParams: feedbagParams{
+						{screenName: state.NewIdentScreenName("me"), results: []wire.FeedbagItem{{ItemID: 1, ClassID: wire.FeedbagClassIdGroup, GroupID: 100, Name: "Buddies"}}, err: nil},
+					},
+				},
+			},
+		},
+		{
+			name: "feedbag lookup fails",
+			snac: wire.SNAC_0x13_0x09_FeedbagUpdateItem{
+				Items: []wire.FeedbagItem{
+					{ItemID: 1, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "alice"},
+				},
+			},
+			wantCmd: nil,
+			mockParams: mockParams{
+				feedBagParams: feedBagParams{
+					feedbagParams: feedbagParams{
+						{screenName: state.NewIdentScreenName("me"), results: nil, err: assert.AnError},
+					},
+				},
+			},
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			fbMgr := newMockFeedbagManager(t)
+			for _, params := range tc.mockParams.feedBagParams.feedbagParams {
+				fbMgr.EXPECT().
+					Feedbag(mock.Anything, params.screenName).
+					Return(params.results, params.err)
+			}
+
+			svc := OSCARProxy{
+				Logger:         slog.Default(),
+				FeedbagManager: fbMgr,
+			}
+			got := svc.Inserted2(ctx, me, tc.snac)
+			assert.Equal(t, tc.wantCmd, got)
+		})
+	}
+}
+
+func TestOSCARProxy_Deleted2(t *testing.T) {
+	ctx := context.Background()
+	me := newTestSession("me")
+
+	cases := []struct {
+		name       string
+		snac       wire.SNAC_0x13_0x0A_FeedbagDeleteItem
+		wantCmd    []string
+		mockParams mockParams
+	}{
+		{
+			name: "one buddy removed",
+			snac: wire.SNAC_0x13_0x0A_FeedbagDeleteItem{
+				Items: []wire.FeedbagItem{
+					{ItemID: 1, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "alice"},
+				},
+			},
+			wantCmd: []string{"DELETED2:b:alice:Buddies"},
+			mockParams: mockParams{
+				feedBagParams: feedBagParams{
+					feedbagParams: feedbagParams{
+						{screenName: state.NewIdentScreenName("me"), results: []wire.FeedbagItem{{ItemID: 1, ClassID: wire.FeedbagClassIdGroup, GroupID: 100, Name: "Buddies"}}, err: nil},
+					},
+				},
+			},
+		},
+		{
+			name: "two buddies removed",
+			snac: wire.SNAC_0x13_0x0A_FeedbagDeleteItem{
+				Items: []wire.FeedbagItem{
+					{ItemID: 1, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "alice"},
+					{ItemID: 2, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "bob"},
+				},
+			},
+			wantCmd: []string{"DELETED2:b:alice:Friends", "DELETED2:b:bob:Friends"},
+			mockParams: mockParams{
+				feedBagParams: feedBagParams{
+					feedbagParams: feedbagParams{
+						{screenName: state.NewIdentScreenName("me"), results: []wire.FeedbagItem{{ItemID: 1, ClassID: wire.FeedbagClassIdGroup, GroupID: 100, Name: "Friends"}}, err: nil},
+					},
+				},
+			},
+		},
+		{
+			name: "one buddy - unknown group ID uses Buddies",
+			snac: wire.SNAC_0x13_0x0A_FeedbagDeleteItem{
+				Items: []wire.FeedbagItem{
+					{ItemID: 1, ClassID: wire.FeedbagClassIdBuddy, GroupID: 999, Name: "alice"},
+				},
+			},
+			wantCmd: []string{"DELETED2:b:alice:Buddies"},
+			mockParams: mockParams{
+				feedBagParams: feedBagParams{
+					feedbagParams: feedbagParams{
+						{screenName: state.NewIdentScreenName("me"), results: []wire.FeedbagItem{}, err: nil},
+					},
+				},
+			},
+		},
+		{
+			name: "permit and deny removed",
+			snac: wire.SNAC_0x13_0x0A_FeedbagDeleteItem{
+				Items: []wire.FeedbagItem{
+					{ItemID: 1, ClassID: wire.FeedbagClassIDPermit, GroupID: 0, Name: "alice"},
+					{ItemID: 2, ClassID: wire.FeedbagClassIDDeny, GroupID: 0, Name: "bob"},
+				},
+			},
+			wantCmd:    []string{"DELETED2:p:alice", "DELETED2:d:bob"},
+			mockParams: mockParams{},
+		},
+		{
+			name: "group deleted",
+			snac: wire.SNAC_0x13_0x0A_FeedbagDeleteItem{
+				Items: []wire.FeedbagItem{
+					{ItemID: 1, ClassID: wire.FeedbagClassIdGroup, GroupID: 100, Name: "Work"},
+				},
+			},
+			wantCmd:    []string{"DELETED2:g:Work"},
+			mockParams: mockParams{},
+		},
+		{
+			name: "mix of buddy permit and deny emitted",
+			snac: wire.SNAC_0x13_0x0A_FeedbagDeleteItem{
+				Items: []wire.FeedbagItem{
+					{ItemID: 1, ClassID: wire.FeedbagClassIDPermit, GroupID: 0, Name: "permitUser"},
+					{ItemID: 2, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "buddyUser"},
+				},
+			},
+			wantCmd: []string{"DELETED2:p:permitUser", "DELETED2:b:buddyUser:Work"},
+			mockParams: mockParams{
+				feedBagParams: feedBagParams{
+					feedbagParams: feedbagParams{
+						{screenName: state.NewIdentScreenName("me"), results: []wire.FeedbagItem{{ItemID: 1, ClassID: wire.FeedbagClassIdGroup, GroupID: 100, Name: "Work"}}, err: nil},
+					},
+				},
+			},
+		},
+		{
+			name: "all four types - group buddy deny permit",
+			snac: wire.SNAC_0x13_0x0A_FeedbagDeleteItem{
+				Items: []wire.FeedbagItem{
+					{ItemID: 1, ClassID: wire.FeedbagClassIdGroup, GroupID: 200, Name: "OldGroup"},
+					{ItemID: 2, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "alice"},
+					{ItemID: 3, ClassID: wire.FeedbagClassIDDeny, GroupID: 0, Name: "blockedUser"},
+					{ItemID: 4, ClassID: wire.FeedbagClassIDPermit, GroupID: 0, Name: "allowedUser"},
+				},
+			},
+			wantCmd: []string{"DELETED2:g:OldGroup", "DELETED2:b:alice:Buddies", "DELETED2:d:blockedUser", "DELETED2:p:allowedUser"},
+			mockParams: mockParams{
+				feedBagParams: feedBagParams{
+					feedbagParams: feedbagParams{
+						{screenName: state.NewIdentScreenName("me"), results: []wire.FeedbagItem{{ItemID: 1, ClassID: wire.FeedbagClassIdGroup, GroupID: 100, Name: "Buddies"}}, err: nil},
+					},
+				},
+			},
+		},
+		{
+			name:       "empty items",
+			snac:       wire.SNAC_0x13_0x0A_FeedbagDeleteItem{Items: nil},
+			wantCmd:    nil,
+			mockParams: mockParams{},
+		},
+		{
+			name: "feedbag lookup fails",
+			snac: wire.SNAC_0x13_0x0A_FeedbagDeleteItem{
+				Items: []wire.FeedbagItem{
+					{ItemID: 1, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "alice"},
+				},
+			},
+			wantCmd: nil,
+			mockParams: mockParams{
+				feedBagParams: feedBagParams{
+					feedbagParams: feedbagParams{
+						{screenName: state.NewIdentScreenName("me"), results: nil, err: assert.AnError},
+					},
+				},
+			},
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			fbMgr := newMockFeedbagManager(t)
+			for _, params := range tc.mockParams.feedBagParams.feedbagParams {
+				fbMgr.EXPECT().
+					Feedbag(mock.Anything, params.screenName).
+					Return(params.results, params.err)
+			}
+
+			svc := OSCARProxy{
+				Logger:         slog.Default(),
+				FeedbagManager: fbMgr,
+			}
+			got := svc.Deleted2(ctx, me, tc.snac)
+			assert.Equal(t, tc.wantCmd, got)
+		})
+	}
+}
+
 func testOSCARProxy(t *testing.T) OSCARProxy {
 	buddyService := newMockBuddyService(t)
 	buddyService.EXPECT().

+ 44 - 0
server/toc/helpers_test.go

@@ -81,6 +81,12 @@ type channelMsgToHostParamsICBM []struct {
 	err     error
 }
 
+type clientEventParams []struct {
+	sender state.IdentScreenName
+	inBody wire.SNAC_0x04_0x14_ICBMClientEvent
+	err    error
+}
+
 type evilRequestParams []struct {
 	me     state.IdentScreenName
 	inBody wire.SNAC_0x04_0x08_ICBMEvilRequest
@@ -90,6 +96,7 @@ type evilRequestParams []struct {
 
 type icbmParams struct {
 	channelMsgToHostParamsICBM
+	clientEventParams
 	evilRequestParams
 }
 
@@ -263,6 +270,16 @@ type tocConfigParams struct {
 
 type feedBagParams struct {
 	useFeedbagParams
+	feedbagParams
+	feedbagServiceUseParams
+	feedbagServiceUpsertItemParams
+	feedbagServiceDeleteItemParams
+}
+
+// feedbagServiceUseParams is the list of parameters for each expected
+// FeedbagService.Use call (e.g. for TOC2 Signon).
+type feedbagServiceUseParams []struct {
+	err error
 }
 
 type useFeedbagParams []struct {
@@ -270,6 +287,33 @@ type useFeedbagParams []struct {
 	err error
 }
 
+// feedbagParams is the list of parameters passed at the mock
+// FeedbagManager.Feedbag call site (e.g. for NewBuddies).
+type feedbagParams []struct {
+	screenName state.IdentScreenName
+	results    []wire.FeedbagItem
+	err        error
+}
+
+// feedbagServiceUpsertItemParams is the list of parameters for each expected
+// FeedbagService.UpsertItem call. Frame is the expected SNACFrame, items is the
+// exact slice of feedbag items (order of params = order of calls).
+type feedbagServiceUpsertItemParams []struct {
+	frame wire.SNACFrame
+	items []wire.FeedbagItem
+	msg   *wire.SNACMessage
+	err   error
+}
+
+// feedbagServiceDeleteItemParams is the list of parameters for each expected
+// FeedbagService.DeleteItem call.
+type feedbagServiceDeleteItemParams []struct {
+	frame  wire.SNACFrame
+	inBody wire.SNAC_0x13_0x0A_FeedbagDeleteItem
+	msg    *wire.SNACMessage
+	err    error
+}
+
 type mockParams struct {
 	adminParams
 	authParams

+ 32 - 34
server/toc/server.go

@@ -341,7 +341,7 @@ func (s *Server) dispatchFLAP(ctx context.Context, conn net.Conn) error {
 
 	ctx = context.WithValue(ctx, "ip", conn.RemoteAddr().String())
 
-	clientFlap, err := s.initFLAP(conn)
+	clientFlap, err := s.initFLAP(ctx, conn)
 	if err != nil {
 		return err
 	}
@@ -459,10 +459,7 @@ func (s *Server) runClientCommands(ctx context.Context, doAsync func(f func() er
 			}
 
 			msg := s.bosProxy.RecvClientCmd(ctx, sessBOS, chatRegistry, clientFrame.Payload, toCh, doAsync)
-			// jgk: checking for empty string in slice. This works because for now we will never
-			// send more than one response if element 0 is empty.
-			// should i be iterating all elements and filering out empty strings instead?
-			if len(msg) > 0 && len(msg[0]) > 0 {
+			if len(msg) > 0 {
 				select {
 				case toCh <- msg:
 				case <-ctx.Done():
@@ -485,7 +482,6 @@ func (s *Server) sendToClient(ctx context.Context, toClient <-chan []string, cli
 				if err := clientFlap.SendDataFrame([]byte(m)); err != nil {
 					return fmt.Errorf("clientFlap.SendDataFrame: %w", err)
 				}
-				// jgk: need to clean up, server response debug doesn't work?
 				if s.logger.Enabled(ctx, slog.LevelDebug) {
 					s.logger.DebugContext(ctx, "server response", "command", m)
 				} else {
@@ -510,46 +506,41 @@ func (s *Server) login(ctx context.Context, clientFlap *wire.FlapClient) (*state
 		return nil, fmt.Errorf("clientFlap.ReceiveFLAP: %w", err)
 	}
 
-	cmd := clientFrame.Payload
-	var args []byte
+	sessBOS, reply := s.bosProxy.Signon(ctx, clientFrame.Payload)
 
-	if idx := bytes.IndexByte(clientFrame.Payload, ' '); idx > -1 {
-		cmd, args = clientFrame.Payload[:idx], clientFrame.Payload[idx:]
-	}
-	var tocVersion state.TOCVersion
-	if string(cmd) == "toc_signon" {
-		tocVersion = state.SupportsTOC
-	} else if string(cmd) == "toc2_signon" {
-		tocVersion = state.SupportsTOC2
-	} else if string(cmd) == "toc2_login" {
-		tocVersion = state.SupportsTOC2 | state.SupportsTOC2Enhanced
-	} else {
-		return nil, errors.New("expected one of toc_signon, toc2_signon, toc2_login")
-	}
-
-	sessBOS, reply := s.bosProxy.Signon(ctx, args, tocVersion)
-	sessBOS.SetTocVersion(tocVersion)
 	for _, m := range reply {
 		if err := clientFlap.SendDataFrame([]byte(m)); err != nil {
 			return nil, fmt.Errorf("clientFlap.SendDataFrame: %w", err)
 		}
 	}
+
 	return sessBOS, nil
 }
 
 // initFLAP sets up a new FLAP connection. It returns a flap client if the
-// connection successfully initialized.
-func (s *Server) initFLAP(rw io.ReadWriter) (*wire.FlapClient, error) {
-	buf := make([]byte, 10)
+// connection successfully initialized. It accepts either "FLAPON\n\n" or
+// "FLAPON\r\n\r\n".
+func (s *Server) initFLAP(ctx context.Context, rw io.ReadWriter) (*wire.FlapClient, error) {
+	buf := make([]byte, 8)
 
-	count, err := rw.Read(buf)
+	_, err := io.ReadFull(rw, buf)
 	if err != nil {
-		return nil, fmt.Errorf("rw.Read: %w", err)
+		return nil, fmt.Errorf("io.ReadFull: %w", err)
 	}
-
-	header := string(buf[:count])
-	if !(header == "FLAPON\n\n" || header == "FLAPON\r\n\r\n") {
-		return nil, fmt.Errorf("expected FLAPON, got %X", buf)
+	if string(buf[:6]) != "FLAPON" {
+		return nil, fmt.Errorf("expected FLAPON, got %s", buf)
+	}
+	if buf[6] == '\r' && buf[7] == '\n' {
+		crlf := make([]byte, 2)
+		_, err := io.ReadFull(rw, crlf)
+		if err != nil {
+			return nil, fmt.Errorf("io.ReadFull: %w", err)
+		}
+		if crlf[0] != '\r' || crlf[1] != '\n' {
+			return nil, fmt.Errorf("expected \\r\\n after FLAPON\\r\\n, got %s", crlf)
+		}
+	} else if buf[6] != '\n' || buf[7] != '\n' {
+		return nil, fmt.Errorf("expected FLAPON then \\n\\n or \\r\\n\\r\\n, got %s", buf)
 	}
 
 	clientFlap := wire.NewFlapClient(0, rw, rw)
@@ -557,9 +548,16 @@ func (s *Server) initFLAP(rw io.ReadWriter) (*wire.FlapClient, error) {
 	if err := clientFlap.SendSignonFrame(nil); err != nil {
 		return nil, fmt.Errorf("clientFlap.SendSignonFrame: %w", err)
 	}
-	if _, err := clientFlap.ReceiveSignonFrame(); err != nil {
+
+	frame, err := clientFlap.ReceiveSignonFrame()
+	if err != nil {
 		return nil, fmt.Errorf("clientFlap.ReceiveSignonFrame: %w", err)
 	}
+	if sn, hasSn := frame.String(0x01); hasSn {
+		s.logger.DebugContext(ctx, "new connection", "screen_name", sn)
+	} else {
+		s.logger.DebugContext(ctx, "new connection from unknown screen name")
+	}
 
 	return clientFlap, nil
 }

+ 1 - 1
server/toc/types.go

@@ -136,7 +136,7 @@ type SessionRetriever interface {
 }
 
 type OSCARProxyer interface {
-	Signon(ctx context.Context, args []byte, tocVersion state.TOCVersion) (*state.Session, []string)
+	Signon(ctx context.Context, args []byte, isTOC1 bool) (*state.Session, []string)
 	RecvBOS(ctx context.Context, sessBOS *state.Session, chatRegistry *ChatRegistry, msgCh chan<- []string) error
 	RecvClientCmd(ctx context.Context, sessBOS *state.Session, chatRegistry *ChatRegistry, payload []byte, toCh chan<- []string, doAsync func(f func() error)) []string
 	NewServeMux() http.Handler

+ 16 - 19
state/session.go

@@ -841,18 +841,6 @@ func (s *Session) userInfo() wire.TLVList {
 	return tlvs
 }
 
-// TOCVersion is a bitmask that indicates the TOC protocol versions a client supports.
-type TOCVersion uint8
-
-const (
-	// SupportsTOC indicates client supports TOC protocol
-	SupportsTOC TOCVersion = 1 << iota
-	// SupportsTOC2 indicates client supports TOC2 protocol
-	SupportsTOC2
-	// SupportsTOC2Enhanced indicates client supports TOC2 Enhanced protocol
-	SupportsTOC2Enhanced
-)
-
 // SessionInstance represents a single client connection instance within a user's
 // session. Multiple SessionInstance objects can belong to the same Session,
 // allowing a user to maintain concurrent connections from different clients or
@@ -885,7 +873,8 @@ type SessionInstance struct {
 	capabilities      [][16]byte
 	foodGroupVersions [wire.MDir + 1]uint16
 	multiConnFlag     wire.MultiConnFlag
-	tocVersion        TOCVersion
+	toc2              bool
+	toc2MsgEnc        bool
 
 	// Per-session state
 	idle              bool
@@ -948,18 +937,26 @@ func (s *SessionInstance) SetClientID(clientID string) {
 	s.clientID = clientID
 }
 
-// SetTocVersion sets the session TOC version
-func (s *SessionInstance) SetTocVersion(tocVersion TOCVersion) {
+// SetTOC2 sets this instance to TOC2. supportsTOC2MsgEnc is true for toc2_login (encoded messaging), false for toc2_signon.
+func (s *SessionInstance) SetTOC2(supportsTOC2MsgEnc bool) {
 	s.mutex.Lock()
 	defer s.mutex.Unlock()
-	s.tocVersion = tocVersion
+	s.toc2 = true
+	s.toc2MsgEnc = supportsTOC2MsgEnc
+}
+
+// IsTOC2 returns true when the client is TOC2 (with or without encoded messaging).
+func (s *SessionInstance) IsTOC2() bool {
+	s.mutex.RLock()
+	defer s.mutex.RUnlock()
+	return s.toc2
 }
 
-// TocVersion returns session TOC version
-func (s *SessionInstance) TocVersion() (tocVersion TOCVersion) {
+// SupportsTOC2MsgEnc returns true only when TOC2 with encoded messaging (toc2_login).
+func (s *SessionInstance) SupportsTOC2MsgEnc() bool {
 	s.mutex.RLock()
 	defer s.mutex.RUnlock()
-	return s.tocVersion
+	return s.toc2MsgEnc
 }
 
 //

+ 0 - 1
wire/frames.go

@@ -128,7 +128,6 @@ func (f *FlapClient) SendSignonFrame(tlvs []TLV) error {
 }
 
 // ReceiveSignonFrame receives a signon FLAP response message.
-// jgk: biztocsock says for toc2 this frame should contain the len and username. do we need to validate that for some reason?
 func (f *FlapClient) ReceiveSignonFrame() (FLAPSignonFrame, error) {
 	flap := FLAPFrame{}
 	if err := UnmarshalBE(&flap, f.r); err != nil {

Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä