Преглед на файлове

initial toc2 implementation

Josh Knight преди 11 месеца
родител
ревизия
f5d139c62c

+ 6 - 0
.mockery.yaml

@@ -207,6 +207,12 @@ packages:
       SessionRetriever:
         config:
           filename: "mock_session_retriever_test.go"
+      FeedbagService:
+        config:
+          filename: "mock_feedbag_service_test.go"
+      FeedbagManager:
+        config:
+          filename: "mock_feedbag_manager_test.go"
   github.com/mk6i/open-oscar-server/server/kerberos:
     interfaces:
       AuthService:

+ 12 - 3
cmd/server/factory.go

@@ -472,9 +472,18 @@ func TOC(deps Container) *toc.Server {
 				deps.inMemorySessionManager,
 				deps.inMemorySessionManager,
 			),
-			TOCConfigStore:    deps.sqLiteUserStore,
-			ChatService:       foodgroup.NewChatService(deps.chatSessionManager),
-			ChatNavService:    foodgroup.NewChatNavService(logger, deps.sqLiteUserStore),
+			TOCConfigStore: deps.sqLiteUserStore,
+			ChatService:    foodgroup.NewChatService(deps.chatSessionManager),
+			ChatNavService: foodgroup.NewChatNavService(logger, deps.sqLiteUserStore),
+			FeedbagManager: deps.sqLiteUserStore,
+			FeedbagService: foodgroup.NewFeedbagService(
+				logger,
+				deps.inMemorySessionManager,
+				deps.sqLiteUserStore,
+				deps.sqLiteUserStore,
+				deps.sqLiteUserStore,
+				deps.inMemorySessionManager,
+			),
 			SNACRateLimits:    deps.snacRateLimits,
 			HTTPIPRateLimiter: toc.NewIPRateLimiter(rate.Every(1*time.Minute), 10, 1*time.Minute),
 			SessionRetriever:  deps.inMemorySessionManager,

+ 362 - 90
server/toc/cmd_client.go

@@ -130,6 +130,8 @@ type OSCARProxy struct {
 	PermitDenyService PermitDenyService
 	TOCConfigStore    TOCConfigStore
 	SessionRetriever  SessionRetriever
+	FeedbagService    FeedbagService
+	FeedbagManager    FeedbagManager
 	SNACRateLimits    wire.SNACRateLimits
 	HTTPIPRateLimiter *IPRateLimiter
 }
@@ -148,9 +150,9 @@ func (s OSCARProxy) RecvClientCmd(
 	sessBOS *state.SessionInstance,
 	chatRegistry *ChatRegistry,
 	payload []byte,
-	toCh chan<- []byte,
+	toCh chan<- []string,
 	doAsync func(f func() error),
-) (reply string) {
+) (replies []string) {
 
 	cmd := payload
 	var args []byte
@@ -193,7 +195,7 @@ func (s OSCARProxy) RecvClientCmd(
 		return s.FormatNickname(ctx, sessBOS, args)
 	case "toc_chat_join", "toc_chat_accept":
 		var chatID int
-		var msg string
+		var msg []string
 
 		if string(cmd) == "toc_chat_join" {
 			chatID, msg = s.ChatJoin(ctx, sessBOS, chatRegistry, args)
@@ -201,7 +203,7 @@ func (s OSCARProxy) RecvClientCmd(
 			chatID, msg = s.ChatAccept(ctx, sessBOS, chatRegistry, args)
 		}
 
-		if msg == cmdInternalSvcErr {
+		if msg[0] == cmdInternalSvcErr {
 			return msg
 		}
 
@@ -236,10 +238,16 @@ func (s OSCARProxy) RecvClientCmd(
 		return s.RvousAccept(ctx, sessBOS, args)
 	case "toc_rvous_cancel":
 		return s.RvousCancel(ctx, sessBOS, args)
+	case "toc2_set_pdmode":
+		return s.SetPDMode(ctx, sessBOS, args)
+	case "toc2_send_im_enc":
+		return s.SendIMEnc(ctx, sessBOS, args)
+	case "toc2_remove_buddy":
+		return s.RemoveBuddy2(ctx, sessBOS, args)
 	}
 
 	s.Logger.ErrorContext(ctx, fmt.Sprintf("unsupported TOC command %s", cmd))
-	return cmdInternalSvcErr
+	return []string{cmdInternalSvcErr}
 }
 
 // AddBuddy handles the toc_add_buddy TOC command.
@@ -249,7 +257,7 @@ func (s OSCARProxy) RecvClientCmd(
 //	Add buddies to your buddy list. This does not change your saved config.
 //
 // Command syntax: toc_add_buddy <Buddy User 1> [<Buddy User2> [<Buddy User 3> [...]]]
-func (s OSCARProxy) AddBuddy(ctx context.Context, me *state.SessionInstance, args []byte) string {
+func (s OSCARProxy) AddBuddy(ctx context.Context, me *state.SessionInstance, args []byte) []string {
 	if msg, isLimited := s.checkRateLimit(ctx, me, wire.Buddy, wire.BuddyAddBuddies); isLimited {
 		return msg
 	}
@@ -270,7 +278,7 @@ func (s OSCARProxy) AddBuddy(ctx context.Context, me *state.SessionInstance, arg
 		return s.runtimeErr(ctx, fmt.Errorf("BuddyService.AddBuddies: %w", err))
 	}
 
-	return ""
+	return []string{}
 }
 
 // AddPermit handles the toc_add_permit TOC command.
@@ -283,7 +291,7 @@ func (s OSCARProxy) AddBuddy(ctx context.Context, me *state.SessionInstance, arg
 //	arguments does nothing and your permit list remains the same.
 //
 // Command syntax: toc_add_permit [ <User 1> [<User 2> [...]]]
-func (s OSCARProxy) AddPermit(ctx context.Context, me *state.SessionInstance, args []byte) string {
+func (s OSCARProxy) AddPermit(ctx context.Context, me *state.SessionInstance, args []byte) []string {
 	if msg, isLimited := s.checkRateLimit(ctx, me, wire.PermitDeny, wire.PermitDenyAddDenyListEntries); isLimited {
 		return msg
 	}
@@ -303,7 +311,7 @@ func (s OSCARProxy) AddPermit(ctx context.Context, me *state.SessionInstance, ar
 	if err := s.PermitDenyService.AddPermListEntries(ctx, me, snac); err != nil {
 		return s.runtimeErr(ctx, fmt.Errorf("PermitDenyService.AddPermListEntries: %w", err))
 	}
-	return ""
+	return []string{}
 }
 
 // AddDeny handles the toc_add_deny TOC command.
@@ -316,7 +324,7 @@ func (s OSCARProxy) AddPermit(ctx context.Context, me *state.SessionInstance, ar
 //	does nothing and your deny list remains unchanged.
 //
 // Command syntax: toc_add_deny [ <User 1> [<User 2> [...]]]
-func (s OSCARProxy) AddDeny(ctx context.Context, me *state.SessionInstance, args []byte) string {
+func (s OSCARProxy) AddDeny(ctx context.Context, me *state.SessionInstance, args []byte) []string {
 	if msg, isLimited := s.checkRateLimit(ctx, me, wire.PermitDeny, wire.PermitDenyAddDenyListEntries); isLimited {
 		return msg
 	}
@@ -336,7 +344,7 @@ func (s OSCARProxy) AddDeny(ctx context.Context, me *state.SessionInstance, args
 	if err := s.PermitDenyService.AddDenyListEntries(ctx, me, snac); err != nil {
 		return s.runtimeErr(ctx, fmt.Errorf("PermitDenyService.AddDenyListEntries: %w", err))
 	}
-	return ""
+	return []string{}
 }
 
 // ChangePassword handles the toc_change_passwd TOC command.
@@ -347,7 +355,7 @@ func (s OSCARProxy) AddDeny(ctx context.Context, me *state.SessionInstance, args
 //	sent back to the client.
 //
 // Command syntax: toc_change_passwd <existing_passwd> <new_passwd>
-func (s OSCARProxy) ChangePassword(ctx context.Context, me *state.SessionInstance, args []byte) string {
+func (s OSCARProxy) ChangePassword(ctx context.Context, me *state.SessionInstance, args []byte) []string {
 	if msg, isLimited := s.checkRateLimit(ctx, me, wire.Admin, wire.AdminInfoChangeRequest); isLimited {
 		return msg
 	}
@@ -383,15 +391,15 @@ func (s OSCARProxy) ChangePassword(ctx context.Context, me *state.SessionInstanc
 	if ok {
 		switch code {
 		case wire.AdminInfoErrorInvalidPasswordLength:
-			return "ERROR:911"
+			return []string{"ERROR:" + wire.TOCErrorAdminInvalidInput}
 		case wire.AdminInfoErrorValidatePassword:
-			return "ERROR:912"
+			return []string{"ERROR:" + wire.TOCErrorAdminInvalidAccount} // jgk: is this correct error code?
 		default:
-			return "ERROR:913"
+			return []string{"ERROR:" + wire.TOCErrorAdminProcessingRequest}
 		}
 	}
 
-	return "ADMIN_PASSWD_STATUS:0"
+	return []string{"ADMIN_PASSWD_STATUS:0"}
 }
 
 // ChatAccept handles the toc_chat_accept TOC command.
@@ -407,7 +415,7 @@ func (s OSCARProxy) ChatAccept(
 	me *state.SessionInstance,
 	chatRegistry *ChatRegistry,
 	args []byte,
-) (int, string) {
+) (int, []string) {
 
 	var chatIDStr string
 
@@ -510,7 +518,7 @@ func (s OSCARProxy) ChatAccept(
 
 	chatRegistry.RegisterSess(chatID, chatSess)
 
-	return chatID, fmt.Sprintf("CHAT_JOIN:%d:%s", chatID, roomName)
+	return chatID, []string{fmt.Sprintf("CHAT_JOIN:%d:%s", chatID, roomName)}
 }
 
 // ChatInvite handles the toc_chat_invite TOC command.
@@ -521,7 +529,7 @@ func (s OSCARProxy) ChatAccept(
 //	Remember to quote and encode the invite message.
 //
 // Command syntax: toc_chat_invite <Chat Room ID> <Invite Msg> <buddy1> [<buddy2> [<buddy3> [...]]]
-func (s OSCARProxy) ChatInvite(ctx context.Context, me *state.SessionInstance, chatRegistry *ChatRegistry, args []byte) string {
+func (s OSCARProxy) ChatInvite(ctx context.Context, me *state.SessionInstance, chatRegistry *ChatRegistry, args []byte) []string {
 	var chatRoomIDStr, msg string
 
 	users, err := parseArgs(args, &chatRoomIDStr, &msg)
@@ -572,7 +580,7 @@ func (s OSCARProxy) ChatInvite(ctx context.Context, me *state.SessionInstance, c
 		}
 	}
 
-	return ""
+	return []string{}
 }
 
 // ChatJoin handles the toc_chat_join TOC command.
@@ -594,7 +602,7 @@ func (s OSCARProxy) ChatJoin(
 	me *state.SessionInstance,
 	chatRegistry *ChatRegistry,
 	args []byte,
-) (int, string) {
+) (int, []string) {
 	var exchangeStr, roomName string
 
 	if _, err := parseArgs(args, &exchangeStr, &roomName); err != nil {
@@ -699,7 +707,7 @@ func (s OSCARProxy) ChatJoin(
 	chatID := chatRegistry.Add(roomInfo)
 	chatRegistry.RegisterSess(chatID, chatSess)
 
-	return chatID, fmt.Sprintf("CHAT_JOIN:%d:%s", chatID, roomName)
+	return chatID, []string{fmt.Sprintf("CHAT_JOIN:%d:%s", chatID, roomName)}
 }
 
 // ChatLeave handles the toc_chat_leave TOC command.
@@ -709,7 +717,7 @@ func (s OSCARProxy) ChatJoin(
 //	Leave the chat room.
 //
 // Command syntax: toc_chat_leave <Chat Room ID>
-func (s OSCARProxy) ChatLeave(ctx context.Context, chatRegistry *ChatRegistry, args []byte) string {
+func (s OSCARProxy) ChatLeave(ctx context.Context, chatRegistry *ChatRegistry, args []byte) []string {
 	var chatIDStr string
 
 	if _, err := parseArgs(args, &chatIDStr); err != nil {
@@ -732,7 +740,7 @@ func (s OSCARProxy) ChatLeave(ctx context.Context, chatRegistry *ChatRegistry, a
 
 	chatRegistry.RemoveSess(chatID)
 
-	return fmt.Sprintf("CHAT_LEFT:%d", chatID)
+	return []string{fmt.Sprintf("CHAT_LEFT:%d", chatID)}
 }
 
 // ChatSend handles the toc_chat_send TOC command.
@@ -745,7 +753,7 @@ func (s OSCARProxy) ChatLeave(ctx context.Context, chatRegistry *ChatRegistry, a
 //	and encode the message.
 //
 // Command syntax: toc_chat_send <Chat Room ID> <Message>
-func (s OSCARProxy) ChatSend(ctx context.Context, chatRegistry *ChatRegistry, args []byte) string {
+func (s OSCARProxy) ChatSend(ctx context.Context, chatRegistry *ChatRegistry, args []byte) []string {
 	var chatIDStr, msg string
 
 	if _, err := parseArgs(args, &chatIDStr, &msg); err != nil {
@@ -813,7 +821,7 @@ func (s OSCARProxy) ChatSend(ctx context.Context, chatRegistry *ChatRegistry, ar
 			return s.runtimeErr(ctx, fmt.Errorf("wire.UnmarshalBE: %w", err))
 		}
 
-		return fmt.Sprintf("CHAT_IN:%d:%s:F:%s", chatID, userInfo.ScreenName, reflectMsg)
+		return []string{fmt.Sprintf("CHAT_IN:%d:%s:F:%s", chatID, userInfo.ScreenName, reflectMsg)}
 	default:
 		return s.runtimeErr(ctx, errors.New("ChatService.ChannelMsgToHost: unexpected response"))
 	}
@@ -830,7 +838,7 @@ func (s OSCARProxy) ChatSend(ctx context.Context, chatRegistry *ChatRegistry, ar
 //	be displayed in the chat room UI.
 //
 // Command syntax: toc_chat_whisper <Chat Room ID> <dst_user> <Message>
-func (s OSCARProxy) ChatWhisper(ctx context.Context, chatRegistry *ChatRegistry, args []byte) string {
+func (s OSCARProxy) ChatWhisper(ctx context.Context, chatRegistry *ChatRegistry, args []byte) []string {
 	var chatIDStr, recip, msg string
 
 	if _, err := parseArgs(args, &chatIDStr, &recip, &msg); err != nil {
@@ -869,7 +877,7 @@ func (s OSCARProxy) ChatWhisper(ctx context.Context, chatRegistry *ChatRegistry,
 		return s.runtimeErr(ctx, fmt.Errorf("ChatService.ChannelMsgToHost: %w", err))
 	}
 
-	return ""
+	return []string{}
 }
 
 // Evil handles the toc_evil TOC command.
@@ -881,8 +889,10 @@ func (s OSCARProxy) ChatWhisper(ctx context.Context, chatRegistry *ChatRegistry,
 //	people who have recently sent you ims. The higher someones evil level, the
 //	slower they can send message.
 //
+// An ERROR message will be sent back to the client if the warning was unsuccessful.
+//
 // Command syntax: toc_evil <User> <norm|anon>
-func (s OSCARProxy) Evil(ctx context.Context, me *state.SessionInstance, args []byte) string {
+func (s OSCARProxy) Evil(ctx context.Context, me *state.SessionInstance, args []byte) []string {
 	if errMsg, isLimited := s.checkRateLimit(ctx, me, wire.ICBM, wire.ICBMEvilRequest); isLimited {
 		return errMsg
 	}
@@ -913,14 +923,13 @@ func (s OSCARProxy) Evil(ctx context.Context, me *state.SessionInstance, args []
 
 	switch v := response.Body.(type) {
 	case wire.SNAC_0x04_0x09_ICBMEvilReply:
-		return ""
+		return []string{}
 	case wire.SNACError:
 		s.Logger.InfoContext(ctx, "unable to warn user", "code", v.Code)
+		return []string{"ERROR:" + wire.TOCErrorGeneralWarningUserNotAvailable}
 	default:
 		return s.runtimeErr(ctx, errors.New("unexpected response"))
 	}
-
-	return ""
 }
 
 // FormatNickname handles the toc_format_nickname TOC command.
@@ -930,8 +939,13 @@ func (s OSCARProxy) Evil(ctx context.Context, me *state.SessionInstance, args []
 //	Reformat a user's nickname. An ADMIN_NICK_STATUS or ERROR message will be
 //	sent back to the client.
 //
+// From the BizTOCSock documentation:
+//
+//	[NICK is also sent with ADMIN_NICK_STATUS. This gets called [...] whenever
+//	you do a format change.
+//
 // Command syntax: toc_format_nickname <new_format>
-func (s OSCARProxy) FormatNickname(ctx context.Context, me *state.SessionInstance, args []byte) string {
+func (s OSCARProxy) FormatNickname(ctx context.Context, me *state.SessionInstance, args []byte) []string {
 	if errMsg, isLimited := s.checkRateLimit(ctx, me, wire.Admin, wire.AdminInfoChangeRequest); isLimited {
 		return errMsg
 	}
@@ -967,13 +981,17 @@ func (s OSCARProxy) FormatNickname(ctx context.Context, me *state.SessionInstanc
 	if ok {
 		switch code {
 		case wire.AdminInfoErrorInvalidNickNameLength, wire.AdminInfoErrorInvalidNickName:
-			return "ERROR:911"
+			return []string{"ERROR:" + wire.TOCErrorAdminInvalidInput}
 		default:
-			return "ERROR:913"
+			return []string{"ERROR:" + wire.TOCErrorAdminProcessingRequest}
 		}
 	}
+	val, hasVal := replyBody.TLVBlock.String(wire.AdminTLVScreenNameFormatted)
+	if !hasVal {
+		return s.runtimeErr(ctx, fmt.Errorf("AdminService.InfoChangeRequest: missing AdminTLVScreenNameFormatted %v", replyBody))
+	}
 
-	return "ADMIN_NICK_STATUS:0"
+	return []string{"ADMIN_NICK_STATUS:0", "NICK:" + val}
 }
 
 // GetDirSearchURL handles the toc_dir_search TOC command.
@@ -992,9 +1010,9 @@ func (s OSCARProxy) FormatNickname(ctx context.Context, me *state.SessionInstanc
 //	Returns either a GOTO_URL or ERROR msg.
 //
 // Command syntax: toc_dir_search <info information>
-func (s OSCARProxy) GetDirSearchURL(ctx context.Context, me *state.SessionInstance, args []byte) string {
+func (s OSCARProxy) GetDirSearchURL(ctx context.Context, me *state.SessionInstance, args []byte) []string {
 	if status := me.Session().EvaluateRateLimit(time.Now(), 1); status == wire.RateLimitStatusLimited {
-		return rateLimitExceededErr
+		return []string{rateLimitExceededErr}
 	}
 
 	var info string
@@ -1040,7 +1058,7 @@ func (s OSCARProxy) GetDirSearchURL(ctx context.Context, me *state.SessionInstan
 	}
 	p.Add("cookie", cookie)
 
-	return fmt.Sprintf("GOTO_URL:search results:dir_search?%s", p.Encode())
+	return []string{fmt.Sprintf("GOTO_URL:search results:dir_search?%s", p.Encode())}
 }
 
 // GetDirURL handles the toc_get_dir TOC command.
@@ -1050,9 +1068,9 @@ func (s OSCARProxy) GetDirSearchURL(ctx context.Context, me *state.SessionInstan
 //	Gets a user's dir info a GOTO_URL or ERROR message will be sent back to the client.
 //
 // Command syntax: toc_get_dir <username>
-func (s OSCARProxy) GetDirURL(ctx context.Context, me *state.SessionInstance, args []byte) string {
+func (s OSCARProxy) GetDirURL(ctx context.Context, me *state.SessionInstance, args []byte) []string {
 	if status := me.Session().EvaluateRateLimit(time.Now(), 1); status == wire.RateLimitStatusLimited {
-		return rateLimitExceededErr
+		return []string{rateLimitExceededErr}
 	}
 
 	var user string
@@ -1070,7 +1088,7 @@ func (s OSCARProxy) GetDirURL(ctx context.Context, me *state.SessionInstance, ar
 	p.Add("cookie", cookie)
 	p.Add("user", user)
 
-	return fmt.Sprintf("GOTO_URL:directory info:dir_info?%s", p.Encode())
+	return []string{fmt.Sprintf("GOTO_URL:directory info:dir_info?%s", p.Encode())}
 }
 
 // GetInfoURL handles the toc_get_info TOC command.
@@ -1080,9 +1098,9 @@ func (s OSCARProxy) GetDirURL(ctx context.Context, me *state.SessionInstance, ar
 //	Gets a user's info a GOTO_URL or ERROR message will be sent back to the client.
 //
 // Command syntax: toc_get_info <username>
-func (s OSCARProxy) GetInfoURL(ctx context.Context, me *state.SessionInstance, args []byte) string {
+func (s OSCARProxy) GetInfoURL(ctx context.Context, me *state.SessionInstance, args []byte) []string {
 	if status := me.Session().EvaluateRateLimit(time.Now(), 1); status == wire.RateLimitStatusLimited {
-		return rateLimitExceededErr
+		return []string{rateLimitExceededErr}
 	}
 
 	var user string
@@ -1101,7 +1119,7 @@ func (s OSCARProxy) GetInfoURL(ctx context.Context, me *state.SessionInstance, a
 	p.Add("from", me.IdentScreenName().String())
 	p.Add("user", user)
 
-	return fmt.Sprintf("GOTO_URL:profile:info?%s", p.Encode())
+	return []string{fmt.Sprintf("GOTO_URL:profile:info?%s", p.Encode())}
 }
 
 // GetStatus handles the toc_get_status TOC command.
@@ -1113,7 +1131,7 @@ func (s OSCARProxy) GetInfoURL(ctx context.Context, me *state.SessionInstance, a
 //	guy appears to be online.
 //
 // Command syntax: toc_get_status <screenname>
-func (s OSCARProxy) GetStatus(ctx context.Context, me *state.SessionInstance, args []byte) string {
+func (s OSCARProxy) GetStatus(ctx context.Context, me *state.SessionInstance, args []byte) []string {
 	if errMsg, isLimited := s.checkRateLimit(ctx, me, wire.Locate, wire.LocateUserInfoQuery); isLimited {
 		return errMsg
 	}
@@ -1136,12 +1154,12 @@ func (s OSCARProxy) GetStatus(ctx context.Context, me *state.SessionInstance, ar
 	switch v := info.Body.(type) {
 	case wire.SNACError:
 		if v.Code == wire.ErrorCodeNotLoggedOn {
-			return fmt.Sprintf("ERROR:901:%s", them)
+			return []string{fmt.Sprintf("ERROR:%s:%s", wire.TOCErrorGeneralUserNotAvailable, them)}
 		} else {
 			return s.runtimeErr(ctx, fmt.Errorf("LocateService.UserInfoQuery error code: %d", v.Code))
 		}
 	case wire.SNAC_0x02_0x06_LocateUserInfoReply:
-		return userInfoToUpdateBuddy(v.TLVUserInfo)
+		return []string{userInfoToUpdateBuddy(v.TLVUserInfo, me)}
 	default:
 		return s.runtimeErr(ctx, fmt.Errorf("AdminService.InfoChangeRequest: unexpected response type %v", v))
 	}
@@ -1162,14 +1180,18 @@ func (s OSCARProxy) GetStatus(ctx context.Context, me *state.SessionInstance, ar
 // implemented.
 //
 // Command syntax: toc_init_done
-func (s OSCARProxy) InitDone(ctx context.Context, instance *state.SessionInstance) string {
+func (s OSCARProxy) InitDone(ctx context.Context, instance *state.SessionInstance) []string {
+	err := s.FeedbagManager.UseFeedbag(ctx, instance.IdentScreenName())
+	if err != nil {
+		return s.runtimeErr(ctx, fmt.Errorf("FeedbagManager.UseFeedbag: %w", err))
+	}
 	if errMsg, isLimited := s.checkRateLimit(ctx, instance, wire.OService, wire.OServiceClientOnline); isLimited {
 		return errMsg
 	}
 	if err := s.OServiceService.ClientOnline(ctx, wire.BOS, wire.SNAC_0x01_0x02_OServiceClientOnline{}, instance); err != nil {
 		return s.runtimeErr(ctx, fmt.Errorf("OServiceServiceBOS.ClientOnliney: %w", err))
 	}
-	return ""
+	return []string{}
 }
 
 // RemoveBuddy handles the toc_remove_buddy TOC command.
@@ -1179,7 +1201,7 @@ func (s OSCARProxy) InitDone(ctx context.Context, instance *state.SessionInstanc
 //	Remove buddies from your buddy list. This does not change your saved config.
 //
 // Command syntax: toc_remove_buddy <Buddy User 1> [<Buddy User2> [<Buddy User 3> [...]]]
-func (s OSCARProxy) RemoveBuddy(ctx context.Context, me *state.SessionInstance, args []byte) string {
+func (s OSCARProxy) RemoveBuddy(ctx context.Context, me *state.SessionInstance, args []byte) []string {
 	if errMsg, isLimited := s.checkRateLimit(ctx, me, wire.Buddy, wire.BuddyDelBuddies); isLimited {
 		return errMsg
 	}
@@ -1199,7 +1221,7 @@ func (s OSCARProxy) RemoveBuddy(ctx context.Context, me *state.SessionInstance,
 	if err := s.BuddyService.DelBuddies(ctx, me, snac); err != nil {
 		return s.runtimeErr(ctx, fmt.Errorf("BuddyService.DelBuddies: %w", err))
 	}
-	return ""
+	return []string{}
 }
 
 // RvousAccept handles the toc_rvous_accept TOC command.
@@ -1214,7 +1236,7 @@ func (s OSCARProxy) RemoveBuddy(ctx context.Context, me *state.SessionInstance,
 // passed in the TiK client, the reference implementation.
 //
 // Command syntax: toc_rvous_accept <nick> <cookie> <service>
-func (s OSCARProxy) RvousAccept(ctx context.Context, me *state.SessionInstance, args []byte) string {
+func (s OSCARProxy) RvousAccept(ctx context.Context, me *state.SessionInstance, args []byte) []string {
 	if errMsg, isLimited := s.checkRateLimit(ctx, me, wire.ICBM, wire.ICBMChannelMsgToHost); isLimited {
 		return errMsg
 	}
@@ -1256,7 +1278,7 @@ func (s OSCARProxy) RvousAccept(ctx context.Context, me *state.SessionInstance,
 		return s.runtimeErr(ctx, fmt.Errorf("ICBMService.ChannelMsgToHost: %w", err))
 	}
 
-	return ""
+	return []string{}
 }
 
 // RvousCancel handles the toc_rvous_cancel TOC command.
@@ -1271,7 +1293,7 @@ func (s OSCARProxy) RvousAccept(ctx context.Context, me *state.SessionInstance,
 // passed in the TiK client, the reference implementation.
 //
 // Command syntax: toc_rvous_cancel <nick> <cookie> <service>
-func (s OSCARProxy) RvousCancel(ctx context.Context, me *state.SessionInstance, args []byte) string {
+func (s OSCARProxy) RvousCancel(ctx context.Context, me *state.SessionInstance, args []byte) []string {
 	if errMsg, isLimited := s.checkRateLimit(ctx, me, wire.ICBM, wire.ICBMChannelMsgToHost); isLimited {
 		return errMsg
 	}
@@ -1318,7 +1340,115 @@ func (s OSCARProxy) RvousCancel(ctx context.Context, me *state.SessionInstance,
 		return s.runtimeErr(ctx, fmt.Errorf("ICBMService.ChannelMsgToHost: %w", err))
 	}
 
-	return ""
+	return []string{}
+}
+
+// SetPDMode handles the toc2_set_pdmode TOC2 command.
+//
+
+// Command syntax: toc2_set_pdmode <mode>
+func (s OSCARProxy) SetPDMode(ctx context.Context, me *state.SessionInstance, args []byte) []string {
+	if errMsg, isLimited := s.checkRateLimit(ctx, me, wire.OService, wire.OServiceIdleNotification); isLimited {
+		return errMsg
+	}
+
+	var pdModeStr string
+
+	if _, err := parseArgs(args, &pdModeStr); err != nil {
+		return s.runtimeErr(ctx, fmt.Errorf("parseArgs: %w", err))
+	}
+
+	mode, err := strconv.Atoi(pdModeStr)
+	if err != nil {
+		return s.runtimeErr(ctx, fmt.Errorf("strconv.Atoi: %w", err))
+	}
+
+	if mode < 0 || mode > 4 {
+		return s.runtimeErr(ctx, errors.New("invalid pd mode specified"))
+	}
+
+	snac := wire.SNAC_0x13_0x09_FeedbagUpdateItem{
+		Items: []wire.FeedbagItem{
+			{
+				ClassID: wire.FeedbagClassIdPdinfo,
+				TLVLBlock: wire.TLVLBlock{
+					TLVList: wire.TLVList{
+						wire.NewTLVBE(wire.FeedbagAttributesPdMode, uint8(mode)),
+					},
+				},
+			},
+		},
+	}
+
+	if _, err := s.FeedbagService.UpsertItem(ctx, me, wire.SNACFrame{}, snac.Items); err != nil {
+		return s.runtimeErr(ctx, fmt.Errorf("FeedbagManager.UpsertItem: %w", err))
+	}
+
+	return []string{}
+}
+
+// RemoveBuddy2 handles the toc2_remove_buddy command.
+//
+// From the TOC2 docs by Jeffrey Rosen
+//
+// You can remove multiple names in the same group using the syntax <screenname> <screenname> <group>.
+//
+// toc2_remove_buddy <screenname> [screenname] ... [screenname] <group>
+
+func (s OSCARProxy) RemoveBuddy2(ctx context.Context, me *state.SessionInstance, args []byte) []string {
+	if errMsg, isLimited := s.checkRateLimit(ctx, me, wire.Locate, wire.LocateSetInfo); isLimited {
+		return errMsg
+	}
+
+	params, err := parseArgs(args)
+	if err != nil {
+		return s.runtimeErr(ctx, fmt.Errorf("parseArgs: %w", err))
+	}
+	if len(params) < 2 {
+		return s.runtimeErr(ctx, fmt.Errorf("misssing params"))
+	}
+	return []string{}
+}
+
+// SendIMEnc handles the toc2_send_im_enc TOC2 command. The Destination user, Message, and Auto are extracted and
+// the regular SendIM handler is called
+//
+// From the BlueTOC source code:
+//
+//	Sends an encoded instant message to a user
+//	Internally, this uses the "TOC3" encoded message format
+//	This encoded message version supports a few more variables as well as encoding
+//
+// Command syntax: toc2_send_im_enc <Destination user> "F" <Encoding> <Language> <Message> [auto]
+func (s OSCARProxy) SendIMEnc(ctx context.Context, sender *state.SessionInstance, args []byte) []string {
+	var recip, msg string
+	autoReply := []string{}
+
+	parsedArgs, err := parseArgs(args)
+	if err != nil || len(parsedArgs) < 5 {
+		return s.runtimeErr(ctx, fmt.Errorf("SendIMEnc: invalid args: %w", err))
+	}
+	recip = parsedArgs[0]
+	msg = parsedArgs[4]
+	if len(parsedArgs) > 5 {
+		autoReply = append(autoReply, parsedArgs[5])
+	}
+
+	// Manually build args byte slice for SendIM: <Destination User> <Message> [auto]
+	sendIMArgs := []string{recip, msg}
+	if len(autoReply) > 0 && autoReply[0] == "auto" {
+		sendIMArgs = append(sendIMArgs, "auto")
+	}
+	// Join arguments with spaces and convert to []byte
+	sendIMArgsStr := ""
+	for i, v := range sendIMArgs {
+		if i > 0 {
+			sendIMArgsStr += " "
+		}
+		sendIMArgsStr += v
+	}
+	sendIMArgsBytes := []byte(sendIMArgsStr)
+	return s.SendIM(ctx, sender, sendIMArgsBytes)
 }
 
 // SendIM handles the toc_send_im TOC command.
@@ -1330,7 +1460,7 @@ func (s OSCARProxy) RvousCancel(ctx context.Context, me *state.SessionInstance,
 //	flag will be turned on for the IM.
 //
 // Command syntax: toc_send_im <Destination User> <Message> [auto]
-func (s OSCARProxy) SendIM(ctx context.Context, sender *state.SessionInstance, args []byte) string {
+func (s OSCARProxy) SendIM(ctx context.Context, sender *state.SessionInstance, args []byte) []string {
 	if msg, isLimited := s.checkRateLimit(ctx, sender, wire.ICBM, wire.ICBMChannelMsgToHost); isLimited {
 		return msg
 	}
@@ -1369,10 +1499,10 @@ func (s OSCARProxy) SendIM(ctx context.Context, sender *state.SessionInstance, a
 		return s.runtimeErr(ctx, fmt.Errorf("ICBMService.ChannelMsgToHost: %w", err))
 	}
 
-	return ""
+	return []string{}
 }
 
-// SetAway handles the toc_chat_join TOC command.
+// SetAway handles the toc_set_away TOC command.
 //
 // From the TiK documentation:
 //
@@ -1382,7 +1512,7 @@ func (s OSCARProxy) SendIM(ctx context.Context, sender *state.SessionInstance, a
 //	information.
 //
 // Command syntax: toc_set_away [<away message>]
-func (s OSCARProxy) SetAway(ctx context.Context, me *state.SessionInstance, args []byte) string {
+func (s OSCARProxy) SetAway(ctx context.Context, me *state.SessionInstance, args []byte) []string {
 	if errMsg, isLimited := s.checkRateLimit(ctx, me, wire.Locate, wire.LocateSetInfo); isLimited {
 		return errMsg
 	}
@@ -1409,7 +1539,7 @@ func (s OSCARProxy) SetAway(ctx context.Context, me *state.SessionInstance, args
 		return s.runtimeErr(ctx, fmt.Errorf("LocateService.SetInfo: %w", err))
 	}
 
-	return ""
+	return []string{}
 }
 
 // SetCaps handles the toc_set_caps TOC command.
@@ -1424,7 +1554,7 @@ func (s OSCARProxy) SetAway(ctx context.Context, me *state.SessionInstance, args
 // chat.
 //
 // Command syntax: toc_set_caps [ <Capability 1> [<Capability 2> [...]]]
-func (s OSCARProxy) SetCaps(ctx context.Context, me *state.SessionInstance, args []byte) string {
+func (s OSCARProxy) SetCaps(ctx context.Context, me *state.SessionInstance, args []byte) []string {
 	if errMsg, isLimited := s.checkRateLimit(ctx, me, wire.Locate, wire.LocateSetInfo); isLimited {
 		return errMsg
 	}
@@ -1458,7 +1588,7 @@ func (s OSCARProxy) SetCaps(ctx context.Context, me *state.SessionInstance, args
 		return s.runtimeErr(ctx, fmt.Errorf("LocateService.SetInfo: %w", err))
 	}
 
-	return ""
+	return []string{}
 }
 
 // SetConfig handles the toc_set_config TOC command.
@@ -1486,9 +1616,9 @@ func (s OSCARProxy) SetCaps(ctx context.Context, me *state.SessionInstance, args
 // the config as received from the client.
 //
 // Command syntax: toc_set_config <Config Info>
-func (s OSCARProxy) SetConfig(ctx context.Context, me *state.SessionInstance, args []byte) string {
+func (s OSCARProxy) SetConfig(ctx context.Context, me *state.SessionInstance, args []byte) []string {
 	if status := me.Session().EvaluateRateLimit(time.Now(), 1); status == wire.RateLimitStatusLimited {
-		return rateLimitExceededErr
+		return []string{rateLimitExceededErr}
 	}
 
 	// most TOC clients don't quote the config info argument, despite what the
@@ -1507,7 +1637,7 @@ func (s OSCARProxy) SetConfig(ctx context.Context, me *state.SessionInstance, ar
 		return s.runtimeErr(ctx, fmt.Errorf("TOCConfigStore.SaveTOCConfig: %w", err))
 	}
 
-	return ""
+	return []string{}
 }
 
 // SetDir handles the toc_set_dir TOC command.
@@ -1525,7 +1655,7 @@ func (s OSCARProxy) SetConfig(ctx context.Context, me *state.SessionInstance, ar
 // The fields "email" and "allow web searches" are ignored by this method.
 //
 // Command syntax: toc_set_dir <info information>
-func (s OSCARProxy) SetDir(ctx context.Context, me *state.SessionInstance, args []byte) string {
+func (s OSCARProxy) SetDir(ctx context.Context, me *state.SessionInstance, args []byte) []string {
 	if errMsg, isLimited := s.checkRateLimit(ctx, me, wire.Locate, wire.LocateSetDirInfo); isLimited {
 		return errMsg
 	}
@@ -1565,7 +1695,7 @@ func (s OSCARProxy) SetDir(ctx context.Context, me *state.SessionInstance, args
 		return s.runtimeErr(ctx, fmt.Errorf("LocateService.SetDirInfo: %w", err))
 	}
 
-	return ""
+	return []string{}
 }
 
 // SetIdle handles the toc_set_idle TOC command.
@@ -1578,7 +1708,7 @@ func (s OSCARProxy) SetDir(ctx context.Context, me *state.SessionInstance, args
 //	incrementing this number, so do not repeatedly call with new idle times.
 //
 // Command syntax: toc_set_idle <idle secs>
-func (s OSCARProxy) SetIdle(ctx context.Context, me *state.SessionInstance, args []byte) string {
+func (s OSCARProxy) SetIdle(ctx context.Context, me *state.SessionInstance, args []byte) []string {
 	if errMsg, isLimited := s.checkRateLimit(ctx, me, wire.OService, wire.OServiceIdleNotification); isLimited {
 		return errMsg
 	}
@@ -1601,7 +1731,7 @@ func (s OSCARProxy) SetIdle(ctx context.Context, me *state.SessionInstance, args
 		return s.runtimeErr(ctx, fmt.Errorf("OServiceServiceBOS.IdleNotification: %w", err))
 	}
 
-	return ""
+	return []string{}
 }
 
 // SetInfo handles the toc_set_info TOC command.
@@ -1611,7 +1741,7 @@ func (s OSCARProxy) SetIdle(ctx context.Context, me *state.SessionInstance, args
 //	Set the LOCATE user information. This is basic HTML. Remember to encode the info.
 //
 // Command syntax: toc_set_info <info information>
-func (s OSCARProxy) SetInfo(ctx context.Context, me *state.SessionInstance, args []byte) string {
+func (s OSCARProxy) SetInfo(ctx context.Context, me *state.SessionInstance, args []byte) []string {
 	if errMsg, isLimited := s.checkRateLimit(ctx, me, wire.Locate, wire.LocateSetInfo); isLimited {
 		return errMsg
 	}
@@ -1634,10 +1764,10 @@ func (s OSCARProxy) SetInfo(ctx context.Context, me *state.SessionInstance, args
 		return s.runtimeErr(ctx, fmt.Errorf("LocateService.SetInfo: %w", err))
 	}
 
-	return ""
+	return []string{}
 }
 
-// Signon handles the toc_signon TOC command.
+// Signon handles the toc_signon and toc2_login TOC commands.
 //
 // From the TiK documentation:
 //
@@ -1659,16 +1789,18 @@ func (s OSCARProxy) SetInfo(ctx context.Context, me *state.SessionInstance, args
 //	The Roasting String is Tic/Toc.
 //
 // Command syntax: toc_signon <authorizer host> <authorizer port> <User Name> <Password> <language> <version>
-func (s OSCARProxy) Signon(ctx context.Context, args []byte) (*state.SessionInstance, []string) {
+//
+// Command syntax: toc2_login <authorizer host> <authorizer port> <User Name> <Password> <language> <version> <unknown> <code>
+func (s OSCARProxy) Signon(ctx context.Context, args []byte, tocVersion state.TOCVersion) (*state.SessionInstance, []string) {
 	var userName, password string
 
 	if _, err := parseArgs(args, nil, nil, &userName, &password); err != nil {
-		return nil, []string{s.runtimeErr(ctx, fmt.Errorf("parseArgs: %w", err))}
+		return nil, s.runtimeErr(ctx, fmt.Errorf("parseArgs: %w", err))
 	}
 
 	passwordHash, err := hex.DecodeString(password[2:])
 	if err != nil {
-		return nil, []string{s.runtimeErr(ctx, fmt.Errorf("hex.DecodeString: %w", err))}
+		return nil, s.runtimeErr(ctx, fmt.Errorf("hex.DecodeString: %w", err))
 	}
 
 	signonFrame := wire.FLAPSignonFrame{}
@@ -1677,17 +1809,17 @@ func (s OSCARProxy) Signon(ctx context.Context, args []byte) (*state.SessionInst
 
 	block, err := s.AuthService.FLAPLogin(ctx, signonFrame, "")
 	if err != nil {
-		return nil, []string{s.runtimeErr(ctx, fmt.Errorf("AuthService.FLAPLogin: %w", err))}
+		return nil, s.runtimeErr(ctx, fmt.Errorf("AuthService.FLAPLogin: %w", err))
 	}
 
 	if block.HasTag(wire.LoginTLVTagsErrorSubcode) {
 		s.Logger.DebugContext(ctx, "login failed")
-		return nil, []string{"ERROR:980"} // bad username/password
+		return nil, []string{"ERROR:" + wire.TOCErrorAuthIncorrectNickOrPassword}
 	}
 
 	authCookie, ok := block.Bytes(wire.OServiceTLVTagsLoginCookie)
 	if !ok {
-		return nil, []string{s.runtimeErr(ctx, fmt.Errorf("unable to get session id from payload"))}
+		return nil, s.runtimeErr(ctx, fmt.Errorf("unable to get session id from payload"))
 	}
 
 	// todo: naming for cookie: login cookie, server cookie, or auth cookie?
@@ -1695,25 +1827,165 @@ func (s OSCARProxy) Signon(ctx context.Context, args []byte) (*state.SessionInst
 
 	sess, err := s.AuthService.RegisterBOSSession(ctx, serverCookie)
 	if err != nil {
-		return nil, []string{s.runtimeErr(ctx, fmt.Errorf("AuthService.RegisterBOSSession: %w", err))}
+		return nil, s.runtimeErr(ctx, fmt.Errorf("AuthService.RegisterBOSSession: %w", err))
 	}
 
 	// set chat capability so that... tk
 	sess.SetCaps([][16]byte{wire.CapChat})
 
 	if err := s.BuddyListRegistry.RegisterBuddyList(ctx, sess.IdentScreenName()); err != nil {
-		return nil, []string{s.runtimeErr(ctx, fmt.Errorf("BuddyListRegistry.RegisterBuddyList: %w", err))}
+		return nil, s.runtimeErr(ctx, fmt.Errorf("BuddyListRegistry.RegisterBuddyList: %w", err))
 	}
 
 	u, err := s.TOCConfigStore.User(ctx, sess.IdentScreenName())
 	if err != nil {
-		return nil, []string{s.runtimeErr(ctx, fmt.Errorf("TOCConfigStore.User: %w", err))}
+		return nil, s.runtimeErr(ctx, fmt.Errorf("TOCConfigStore.User: %w", err))
 	}
 	if u == nil {
-		return nil, []string{s.runtimeErr(ctx, fmt.Errorf("TOCConfigStore.User: user not found"))}
+		return nil, s.runtimeErr(ctx, fmt.Errorf("TOCConfigStore.User: user not found"))
+	}
+
+	if (tocVersion & state.SupportsTOC) != 0 {
+		return sess, []string{"SIGN_ON:TOC1.0", fmt.Sprintf("CONFIG:%s", u.TOCConfig), fmt.Sprintf("NICK:%s", sess.DisplayScreenName().String())}
+	}
+
+	fb, err := s.FeedbagManager.Feedbag(ctx, sess.IdentScreenName())
+	if err != nil {
+		return sess, s.runtimeErr(ctx, fmt.Errorf("FeedbagManager.Feedbag: %w", err))
+	}
+
+	signon := []string{"SIGN_ON:TOC2.0", fmt.Sprintf("NICK:%s", sess.DisplayScreenName().String())}
+	config, err := buildToc2Config(fb)
+	if err != nil {
+		return sess, s.runtimeErr(ctx, fmt.Errorf("buildToc2Config: %w", err))
+	}
+	return sess, append(signon, config...)
+}
+
+// buildToc2Config constructs configuration for CONFIG2
+func buildToc2Config(fb []wire.FeedbagItem) ([]string, error) {
+	config := []string{}
+
+	type buddy struct {
+		name  string
+		alias string
+		note  string
+	}
+	type group struct {
+		name    string
+		buddies map[uint16]buddy
+		order   []uint16
+	}
+	type feedbag struct {
+		order  []uint16
+		groups map[uint16]group
+	}
+	buddylist := feedbag{
+		groups: make(map[uint16]group),
+	}
+
+	for _, item := range fb {
+		if item.ClassID == wire.FeedbagClassIdGroup {
+			// root group contains the order of all other groups
+			if item.GroupID == 0 {
+				val, hasVal := item.Uint16SliceBE(wire.FeedbagAttributesOrder)
+				if !hasVal {
+					return []string{}, fmt.Errorf("root group missing order attribute")
+				}
+				buddylist.order = val
+				continue
+			}
+
+			group := group{
+				name:    item.Name,
+				buddies: make(map[uint16]buddy),
+			}
+			// order will be empty if the group is empty
+			val, _ := item.Uint16SliceBE(wire.FeedbagAttributesOrder)
+			group.order = val
+			buddylist.groups[item.GroupID] = group
+		}
+		if item.ClassID == wire.FeedbagClassIdBuddy {
+			buddy := buddy{
+				name: item.Name,
+			}
+			if val, hasVal := item.String(wire.FeedbagAttributesNote); hasVal {
+				buddy.note = val
+			}
+			if val, hasVal := item.String(wire.FeedbagAttributesAlias); hasVal {
+				buddy.alias = val
+			}
+			buddylist.groups[item.GroupID].buddies[item.ItemID] = buddy
+		}
+		if item.ClassID == wire.FeedbagClassIDDeny {
+			config = append(config, "d:"+item.Name+"\n")
+		}
+		if item.ClassID == wire.FeedbagClassIDPermit {
+			config = append(config, "p:"+item.Name+"\n")
+		}
+		if item.ClassID == wire.FeedbagClassIdPdinfo {
+			val, hasVal := item.Uint8(wire.FeedbagAttributesPdMode)
+			if hasVal {
+				config = append(config, fmt.Sprintf("m:%d\n", val))
+			}
+		}
+	}
+
+	for _, gid := range buddylist.order {
+		group := buddylist.groups[gid]
+		config = append(config, "g:"+group.name+"\n")
+
+		for _, bid := range group.order {
+			buddy := group.buddies[bid]
+			tmpLine := "b:" + buddy.name
+			if buddy.alias != "" {
+				tmpLine += ":" + buddy.alias
+			}
+			if buddy.note != "" {
+				tmpLine += ":::::" + buddy.note
+			}
+			config = append(config, tmpLine+"\n")
+		}
+	}
+	config = append(config, "done:\n")
+
+	// todo: these are documented in BizTOCSock as being sent in the CONFIG2 command,
+	// but are not currently implemented
+	//  25: - Recent user, followed by an unknown random or incrementing number
+	//  M: - Unknown
+	//  20:, 26:, 29: - Unknown
+
+	return buildConfigCommands(config)
+}
+
+// buildConfigCommands takes the given input config and splits it to meet the TOC maximumm allowed
+// size of 8000 bytes. The 'CONFIG2:' command is prepended to each chunk.
+func buildConfigCommands(config []string) ([]string, error) {
+	const maxSize = 8000
+
+	var configChunks []string
+	var chunk strings.Builder
+
+	for _, line := range config {
+		if len(line) > maxSize {
+			// todo: for now just skip entries that are too large to fit into a single CONFIG2 command
+			continue
+		}
+
+		if len(line)+chunk.Len() > maxSize {
+			configChunks = append(configChunks, "CONFIG2:"+chunk.String())
+			chunk.Reset()
+		}
+
+		_, err := chunk.WriteString(line)
+		if err != nil {
+			return []string{}, fmt.Errorf("buildConfigCommands chunk.WriteString: %w", err)
+		}
 	}
 
-	return sess, []string{"SIGN_ON:TOC1.0", fmt.Sprintf("CONFIG:%s", u.TOCConfig)}
+	configChunks = append(configChunks, "CONFIG2:"+chunk.String())
+
+	return configChunks, nil
 }
 
 // Signout terminates a TOC session. It sends departure notifications to
@@ -1778,26 +2050,26 @@ func parseArgs(payload []byte, args ...*string) (varArgs []string, err error) {
 
 // runtimeErr is a convenience function that logs an error and returns a TOC
 // internal server error.
-func (s OSCARProxy) runtimeErr(ctx context.Context, err error) string {
+func (s OSCARProxy) runtimeErr(ctx context.Context, err error) []string {
 	s.Logger.ErrorContext(ctx, "internal service error", "err", err.Error())
-	return cmdInternalSvcErr
+	return []string{cmdInternalSvcErr}
 }
 
-func (s OSCARProxy) checkRateLimit(ctx context.Context, sender *state.SessionInstance, foodGroup uint16, subGroup uint16) (string, bool) {
+func (s OSCARProxy) checkRateLimit(ctx context.Context, sender *state.SessionInstance, foodGroup uint16, subGroup uint16) ([]string, bool) {
 	rateClassID, ok := s.SNACRateLimits.RateClassLookup(foodGroup, subGroup)
 	if !ok {
 		s.Logger.ErrorContext(ctx, "rate limit not found, allowing request through")
-		return "", false
+		return []string{}, false
 	}
 
 	if status := sender.Session().EvaluateRateLimit(time.Now(), rateClassID); status == wire.RateLimitStatusLimited {
 		s.Logger.DebugContext(ctx, "(toc) rate limit exceeded, dropping SNAC",
 			"foodgroup", wire.FoodGroupName(foodGroup),
 			"subgroup", wire.SubGroupName(foodGroup, subGroup))
-		return rateLimitExceededErr, true
+		return []string{rateLimitExceededErr}, true
 	}
 
-	return "", false
+	return []string{}, false
 }
 
 // unescape removes escaping from the following TOC characters: \ { } ( ) [ ] $ "

Файловите разлики са ограничени, защото са твърде много
+ 142 - 102
server/toc/cmd_client_test.go


+ 154 - 42
server/toc/cmd_server.go

@@ -17,7 +17,7 @@ import (
 )
 
 var (
-	cmdInternalSvcErr    = "ERROR:989:internal server error"
+	cmdInternalSvcErr    = fmt.Sprintf("ERROR:%s:internal server error", wire.TOCErrorAuthUnknownError) // jgk: should this be a SubErrorCode?
 	rateLimitExceededErr = "ERROR:903"
 	errDisconnect        = errors.New("got booted by another session")
 )
@@ -25,7 +25,7 @@ var (
 // RecvBOS routes incoming SNAC messages from the BOS server to their
 // corresponding TOC handlers. It ignores any SNAC messages for which there is
 // no TOC response.
-func (s OSCARProxy) RecvBOS(ctx context.Context, me *state.SessionInstance, chatRegistry *ChatRegistry, ch chan<- []byte) error {
+func (s OSCARProxy) RecvBOS(ctx context.Context, me *state.SessionInstance, chatRegistry *ChatRegistry, ch chan<- []string) error {
 	for {
 		select {
 		case <-ctx.Done():
@@ -40,13 +40,18 @@ func (s OSCARProxy) RecvBOS(ctx context.Context, me *state.SessionInstance, chat
 		case snac := <-me.ReceiveMessage():
 			switch v := snac.Body.(type) {
 			case wire.SNAC_0x03_0x0B_BuddyArrived:
-				sendOrCancel(ctx, ch, s.UpdateBuddyArrival(v))
+				sendOrCancel(ctx, ch, s.UpdateBuddyArrival(v, me))
 			case wire.SNAC_0x03_0x0C_BuddyDeparted:
 				sendOrCancel(ctx, ch, s.UpdateBuddyDeparted(v))
 			case wire.SNAC_0x04_0x07_ICBMChannelMsgToClient:
-				sendOrCancel(ctx, ch, s.IMIn(ctx, chatRegistry, v))
+				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) {
+					sendOrCancel(ctx, ch, s.ClientEvent(v))
+				}
+
 			default:
 				s.Logger.DebugContext(ctx, fmt.Sprintf("unsupported snac. foodgroup: %s subgroup: %s",
 					wire.FoodGroupName(snac.Frame.FoodGroup),
@@ -59,7 +64,7 @@ func (s OSCARProxy) RecvBOS(ctx context.Context, me *state.SessionInstance, chat
 // RecvChat routes incoming SNAC messages from the chat server to their
 // corresponding TOC handlers. It ignores any SNAC messages for which there is
 // no TOC response.
-func (s OSCARProxy) RecvChat(ctx context.Context, me *state.SessionInstance, chatID int, ch chan<- []byte) {
+func (s OSCARProxy) RecvChat(ctx context.Context, me *state.SessionInstance, chatID int, ch chan<- []string) {
 	for {
 		select {
 		case <-ctx.Done():
@@ -90,7 +95,7 @@ func (s OSCARProxy) RecvChat(ctx context.Context, me *state.SessionInstance, cha
 //	A chat message was sent in a chat room.
 //
 // Command syntax: CHAT_IN:<Chat Room Id>:<Source User>:<Whisper? T/F>:<Message>
-func (s OSCARProxy) ChatIn(ctx context.Context, snac wire.SNAC_0x0E_0x06_ChatChannelMsgToClient, chatID int) string {
+func (s OSCARProxy) ChatIn(ctx context.Context, snac wire.SNAC_0x0E_0x06_ChatChannelMsgToClient, chatID int) []string {
 	b, ok := snac.Bytes(wire.ChatTLVSenderInformation)
 	if !ok {
 		return s.runtimeErr(ctx, errors.New("snac.Bytes: missing wire.ChatTLVSenderInformation"))
@@ -112,7 +117,7 @@ func (s OSCARProxy) ChatIn(ctx context.Context, snac wire.SNAC_0x0E_0x06_ChatCha
 		return s.runtimeErr(ctx, fmt.Errorf("wire.UnmarshalChatMessageText: %w", err))
 	}
 
-	return fmt.Sprintf("CHAT_IN:%d:%s:F:%s", chatID, u.ScreenName, text)
+	return []string{fmt.Sprintf("CHAT_IN:%d:%s:F:%s", chatID, u.ScreenName, text)}
 }
 
 // ChatUpdateBuddyArrived handles the CHAT_UPDATE_BUDDY TOC command for chat
@@ -125,12 +130,12 @@ func (s OSCARProxy) ChatIn(ctx context.Context, snac wire.SNAC_0x0E_0x06_ChatCha
 //	room.
 //
 // Command syntax: CHAT_UPDATE_BUDDY:<Chat Room Id>:<Inside? T/F>:<User 1>:<User 2>...
-func (s OSCARProxy) ChatUpdateBuddyArrived(snac wire.SNAC_0x0E_0x03_ChatUsersJoined, chatID int) string {
+func (s OSCARProxy) ChatUpdateBuddyArrived(snac wire.SNAC_0x0E_0x03_ChatUsersJoined, chatID int) []string {
 	users := make([]string, 0, len(snac.Users))
 	for _, u := range snac.Users {
 		users = append(users, u.ScreenName)
 	}
-	return fmt.Sprintf("CHAT_UPDATE_BUDDY:%d:T:%s", chatID, strings.Join(users, ":"))
+	return []string{fmt.Sprintf("CHAT_UPDATE_BUDDY:%d:T:%s", chatID, strings.Join(users, ":"))}
 }
 
 // ChatUpdateBuddyLeft handles the CHAT_UPDATE_BUDDY TOC command for chat
@@ -143,12 +148,12 @@ func (s OSCARProxy) ChatUpdateBuddyArrived(snac wire.SNAC_0x0E_0x03_ChatUsersJoi
 //	room.
 //
 // Command syntax: CHAT_UPDATE_BUDDY:<Chat Room Id>:<Inside? T/F>:<User 1>:<User 2>...
-func (s OSCARProxy) ChatUpdateBuddyLeft(snac wire.SNAC_0x0E_0x04_ChatUsersLeft, chatID int) string {
+func (s OSCARProxy) ChatUpdateBuddyLeft(snac wire.SNAC_0x0E_0x04_ChatUsersLeft, chatID int) []string {
 	users := make([]string, 0, len(snac.Users))
 	for _, u := range snac.Users {
 		users = append(users, u.ScreenName)
 	}
-	return fmt.Sprintf("CHAT_UPDATE_BUDDY:%d:F:%s", chatID, strings.Join(users, ":"))
+	return []string{fmt.Sprintf("CHAT_UPDATE_BUDDY:%d:F:%s", chatID, strings.Join(users, ":"))}
 }
 
 // Eviled handles the EVILED TOC command.
@@ -158,16 +163,16 @@ func (s OSCARProxy) ChatUpdateBuddyLeft(snac wire.SNAC_0x0E_0x04_ChatUsersLeft,
 //	The user was just eviled.
 //
 // Command syntax: EVILED:<new evil>:<name of eviler, blank if anonymous>
-func (s OSCARProxy) Eviled(snac wire.SNAC_0x01_0x10_OServiceEvilNotification) string {
+func (s OSCARProxy) Eviled(snac wire.SNAC_0x01_0x10_OServiceEvilNotification) []string {
 	warning := fmt.Sprintf("%d", snac.NewEvil/10)
 	who := ""
 	if snac.Snitcher != nil {
 		who = snac.Snitcher.ScreenName
 	}
-	return fmt.Sprintf("EVILED:%s:%s", warning, who)
+	return []string{fmt.Sprintf("EVILED:%s:%s", warning, who)}
 }
 
-// IMIn handles the IM_IN TOC command.
+// IMIn handles the IM_IN and IM_IN_ENC2 TOC commands.
 //
 // From the TiK documentation:
 //
@@ -175,27 +180,28 @@ func (s OSCARProxy) Eviled(snac wire.SNAC_0x01_0x10_OServiceEvilNotification) st
 //	incoming message, including other colons.
 //
 // Command syntax: IM_IN:<Source User>:<Auto Response T/F?>:<Message>
-func (s OSCARProxy) IMIn(ctx context.Context, chatRegistry *ChatRegistry, snac wire.SNAC_0x04_0x07_ICBMChannelMsgToClient) string {
+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:
-		return s.convertICBMInstantMsg(ctx, snac)
+		return []string{s.convertICBMInstantMsg(ctx, me, snac)}
 	case wire.ICBMChannelRendezvous:
-		return s.convertICBMRendezvous(ctx, chatRegistry, snac)
+		return []string{s.convertICBMRendezvous(ctx, chatRegistry, snac)}
 	default:
 		s.Logger.DebugContext(ctx, "received unsupported ICBM channel message", "channel_id", snac.ChannelID)
-		return ""
+		return []string{}
 	}
 }
 
-// convertICBMInstantMsg converts an ICBM instant message SNAC to a TOC IM_IN response.
-func (s OSCARProxy) convertICBMInstantMsg(ctx context.Context, snac wire.SNAC_0x04_0x07_ICBMChannelMsgToClient) string {
+// 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"))
+		return s.runtimeErr(ctx, errors.New("TLVRestBlock.Bytes: missing wire.ICBMTLVAOLIMData"))[0]
 	}
 	txt, err := wire.UnmarshalICBMMessageText(buf)
 	if err != nil {
-		return s.runtimeErr(ctx, fmt.Errorf("wire.UnmarshalICBMMessageText: %w", err))
+		return s.runtimeErr(ctx, fmt.Errorf("wire.UnmarshalICBMMessageText: %w", err))[0]
 	}
 
 	autoResp := "F"
@@ -203,7 +209,24 @@ func (s OSCARProxy) convertICBMInstantMsg(ctx context.Context, snac wire.SNAC_0x
 		autoResp = "T"
 	}
 
-	return fmt.Sprintf("IM_IN:%s:%s:%s", snac.ScreenName, autoResp, txt)
+	if hasFlag(me.TocVersion(), state.SupportsTOC2Enhanced) {
+		// IM_IN_ENC2:<user>:<auto>:<???>:<???>:<buddy status>:<???>:<???>:en:<message>
+		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")
+			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)
+	}
+
+	cmdSuffix := ""
+	if (me.TocVersion() & state.SupportsTOC2) == state.SupportsTOC2 {
+		cmdSuffix = "2"
+	}
+	return fmt.Sprintf("IM_IN%s:%s:%s:%s", cmdSuffix, snac.ScreenName, autoResp, txt)
 }
 
 // convertICBMRendezvous converts an ICBM rendezvous SNAC to a TOC response.
@@ -213,11 +236,11 @@ func (s OSCARProxy) convertICBMInstantMsg(ctx context.Context, snac wire.SNAC_0x
 func (s OSCARProxy) convertICBMRendezvous(ctx context.Context, chatRegistry *ChatRegistry, snac wire.SNAC_0x04_0x07_ICBMChannelMsgToClient) string {
 	rdinfo, has := snac.TLVRestBlock.Bytes(wire.ICBMTLVData)
 	if !has {
-		return s.runtimeErr(ctx, errors.New("TLVRestBlock.Bytes: missing rendezvous block"))
+		return s.runtimeErr(ctx, errors.New("TLVRestBlock.Bytes: missing rendezvous block"))[0]
 	}
 	frag := wire.ICBMCh2Fragment{}
 	if err := wire.UnmarshalBE(&frag, bytes.NewReader(rdinfo)); err != nil {
-		return s.runtimeErr(ctx, fmt.Errorf("wire.UnmarshalBE: %w", err))
+		return s.runtimeErr(ctx, fmt.Errorf("wire.UnmarshalBE: %w", err))[0]
 	}
 
 	if frag.Type != wire.ICBMRdvMessagePropose {
@@ -229,22 +252,22 @@ func (s OSCARProxy) convertICBMRendezvous(ctx context.Context, chatRegistry *Cha
 	case wire.CapChat:
 		prompt, ok := frag.Bytes(wire.ICBMRdvTLVTagsInvitation)
 		if !ok {
-			return s.runtimeErr(ctx, errors.New("frag.Bytes: missing chat invite prompt"))
+			return s.runtimeErr(ctx, errors.New("frag.Bytes: missing chat invite prompt"))[0]
 		}
 
 		svcData, ok := frag.Bytes(wire.ICBMRdvTLVTagsSvcData)
 		if !ok || svcData == nil {
-			return s.runtimeErr(ctx, errors.New("frag.Bytes: missing room info"))
+			return s.runtimeErr(ctx, errors.New("frag.Bytes: missing room info"))[0]
 		}
 
 		roomInfo := wire.ICBMRoomInfo{}
 		if err := wire.UnmarshalBE(&roomInfo, bytes.NewReader(svcData)); err != nil {
-			return s.runtimeErr(ctx, fmt.Errorf("wire.UnmarshalBE: %w", err))
+			return s.runtimeErr(ctx, fmt.Errorf("wire.UnmarshalBE: %w", err))[0]
 		}
 
 		cookie := strings.Split(roomInfo.Cookie, "-") // make this safe
 		if len(cookie) < 3 {
-			return s.runtimeErr(ctx, errors.New("roomInfo.Cookie: malformed cookie, could not get room name"))
+			return s.runtimeErr(ctx, errors.New("roomInfo.Cookie: malformed cookie, could not get room name"))[0]
 		}
 
 		roomName := cookie[2]
@@ -309,8 +332,9 @@ 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>
-func (s OSCARProxy) UpdateBuddyArrival(snac wire.SNAC_0x03_0x0B_BuddyArrived) string {
-	return userInfoToUpdateBuddy(snac.TLVUserInfo)
+func (s OSCARProxy) UpdateBuddyArrival(snac wire.SNAC_0x03_0x0B_BuddyArrived, me *state.SessionInstance) []string {
+
+	return []string{userInfoToUpdateBuddy(snac.TLVUserInfo, me), userInfoToBuddyCaps(snac.TLVUserInfo, me)}
 }
 
 // UpdateBuddyDeparted handles the UPDATE_BUDDY TOC command for buddy departure events.
@@ -334,29 +358,117 @@ func (s OSCARProxy) UpdateBuddyArrival(snac wire.SNAC_0x03_0x0B_BuddyArrived) st
 //			- '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 {
-	return fmt.Sprintf("UPDATE_BUDDY:%s:F:0:0:0:   ", snac.ScreenName)
+func (s OSCARProxy) UpdateBuddyDeparted(snac wire.SNAC_0x03_0x0C_BuddyDeparted) []string {
+	return []string{fmt.Sprintf("UPDATE_BUDDY:%s:F:0:0:0:   ", snac.ScreenName)}
+}
+
+// ClientEvent handles the CLIENT_EVENT2 TOC2 command.
+//
+// From BizTOCSock documentation:
+//
+//	I discovered this a while ago, but this is the typing status of a user in IMs, much
+//  like what AIM does. It is only sent while you're currently being IMed by someone.
+//  There are only three codes as I know of, but I believe there is one for "User is
+//  recording..." If it were one, it would probably be code 3.
+
+//	0 = User is doing nothing
+//	1 = User has enterted Text
+//	2 = User is currently typing
+//
+// Command syntax: CLIENT_EVENT2:<Buddy User>:<Typing Status>
+func (s OSCARProxy) ClientEvent(snac wire.SNAC_0x04_0x14_ICBMClientEvent) []string {
+	return []string{fmt.Sprintf("CLIENT_EVENT2:%s:%d", snac.ScreenName, snac.Event)}
+}
+
+// userClassString generates the 3-character user class (UC) string based on user flags and away status.
+func userClassString(uFlags uint16, isAway bool) [3]string {
+	uc := [3]string{" ", " ", " "}
+
+	if hasFlag(uFlags, wire.OServiceUserFlagAOL) {
+		uc[0] = "A"
+	}
+
+	if hasFlag(uFlags, wire.OServiceUserFlagAdministrator) {
+		uc[1] = "A"
+	} else if hasFlag(uFlags, wire.OServiceUserFlagWireless) {
+		uc[1] = "C"
+	} else if hasFlag(uFlags, wire.OServiceUserFlagUnconfirmed) {
+		uc[1] = "U"
+	} else if hasFlag(uFlags, wire.OServiceUserFlagOSCARFree) {
+		uc[1] = "O"
+	}
+
+	if isAway {
+		uc[2] = "U"
+	}
+	return uc
 }
 
-func sendOrCancel(ctx context.Context, ch chan<- []byte, msg string) {
+func sendOrCancel(ctx context.Context, ch chan<- []string, msg []string) {
 	select {
 	case <-ctx.Done():
 		return
-	case ch <- []byte(msg):
+	case ch <- msg:
 		return
 	}
 }
 
-// userInfoToUpdateBuddy creates an UPDATE_BUDDY server reply from a User
+// '''''''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) string {
+func userInfoToUpdateBuddy(snac wire.TLVUserInfo, me *state.SessionInstance) string {
 	online, _ := snac.Uint32BE(wire.OServiceUserInfoSignonTOD)
 	idle, _ := snac.Uint16BE(wire.OServiceUserInfoIdleTime)
-	uc := [3]string{" ", "O", " "}
-	if snac.IsAway() {
-		uc[2] = "U"
+
+	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[:], "")
+
 	warning := fmt.Sprintf("%d", snac.WarningLevel/10)
-	class := strings.Join(uc[:], "")
-	return fmt.Sprintf("UPDATE_BUDDY:%s:%s:%s:%d:%d:%s", snac.ScreenName, "T", warning, online, idle, class)
+	cmd := "UPDATE_BUDDY"
+	if hasFlag(me.TocVersion(), state.SupportsTOC2) {
+		cmd = "UPDATE_BUDDY2"
+	}
+	return fmt.Sprintf("%s:%s:%s:%s:%d:%d:%s", cmd, snac.ScreenName, "T", warning, online, idle, uc)
+}
+
+// hasFlag checks if a specific flag is set in the bitmask.
+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) {
+		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, ",")
+	}
+	return fmt.Sprintf("BUDDY_CAPS2:%s:%s", snac.ScreenName, clientCaps)
 }

+ 37 - 35
server/toc/cmd_server_test.go

@@ -25,7 +25,7 @@ func TestOSCARProxy_RecvBOS_ChatIn(t *testing.T) {
 		// givenMsg is the incoming SNAC
 		givenMsg wire.SNACMessage
 		// wantCmd is the expected TOC response
-		wantCmd []byte
+		wantCmd string
 	}{
 		{
 			name:   "send chat message",
@@ -47,7 +47,7 @@ func TestOSCARProxy_RecvBOS_ChatIn(t *testing.T) {
 					},
 				},
 			},
-			wantCmd: []byte("CHAT_IN:0:them:F:<p>hello world!</p>"),
+			wantCmd: "CHAT_IN:0:them:F:<p>hello world!</p>",
 		},
 	}
 
@@ -59,7 +59,7 @@ func TestOSCARProxy_RecvBOS_ChatIn(t *testing.T) {
 				Logger: slog.Default(),
 			}
 
-			ch := make(chan []byte)
+			ch := make(chan []string)
 			wg := &sync.WaitGroup{}
 			wg.Add(1)
 
@@ -72,7 +72,7 @@ func TestOSCARProxy_RecvBOS_ChatIn(t *testing.T) {
 			assert.Equal(t, state.SessSendOK, status)
 
 			gotCmd := <-ch
-			assert.Equal(t, string(tc.wantCmd), string(gotCmd))
+			assert.Equal(t, tc.wantCmd, gotCmd[0])
 
 			cancel()
 			wg.Wait()
@@ -91,7 +91,7 @@ func TestOSCARProxy_RecvBOS_ChatUpdateBuddyArrived(t *testing.T) {
 		// givenMsg is the incoming SNAC
 		givenMsg wire.SNACMessage
 		// wantCmd is the expected TOC response
-		wantCmd []byte
+		wantCmd []string
 	}{
 		{
 			name: "send chat participant arrival",
@@ -104,7 +104,7 @@ func TestOSCARProxy_RecvBOS_ChatUpdateBuddyArrived(t *testing.T) {
 					},
 				},
 			},
-			wantCmd: []byte("CHAT_UPDATE_BUDDY:0:T:user1:user2"),
+			wantCmd: []string{"CHAT_UPDATE_BUDDY:0:T:user1:user2"},
 		},
 	}
 
@@ -116,7 +116,7 @@ func TestOSCARProxy_RecvBOS_ChatUpdateBuddyArrived(t *testing.T) {
 				Logger: slog.Default(),
 			}
 
-			ch := make(chan []byte)
+			ch := make(chan []string)
 			wg := &sync.WaitGroup{}
 			wg.Add(1)
 
@@ -129,7 +129,7 @@ func TestOSCARProxy_RecvBOS_ChatUpdateBuddyArrived(t *testing.T) {
 			assert.Equal(t, state.SessSendOK, status)
 
 			gotCmd := <-ch
-			assert.Equal(t, string(tc.wantCmd), string(gotCmd))
+			assert.Equal(t, tc.wantCmd[0], gotCmd[0])
 
 			cancel()
 			wg.Wait()
@@ -148,7 +148,7 @@ func TestOSCARProxy_RecvBOS_ChatUpdateBuddyLeft(t *testing.T) {
 		// givenMsg is the incoming SNAC
 		givenMsg wire.SNACMessage
 		// wantCmd is the expected TOC response
-		wantCmd []byte
+		wantCmd []string
 	}{
 		{
 			name: "send chat participant departure",
@@ -161,7 +161,7 @@ func TestOSCARProxy_RecvBOS_ChatUpdateBuddyLeft(t *testing.T) {
 					},
 				},
 			},
-			wantCmd: []byte("CHAT_UPDATE_BUDDY:0:F:user1:user2"),
+			wantCmd: []string{"CHAT_UPDATE_BUDDY:0:F:user1:user2"},
 		},
 	}
 
@@ -173,7 +173,7 @@ func TestOSCARProxy_RecvBOS_ChatUpdateBuddyLeft(t *testing.T) {
 				Logger: slog.Default(),
 			}
 
-			ch := make(chan []byte)
+			ch := make(chan []string)
 			wg := &sync.WaitGroup{}
 			wg.Add(1)
 
@@ -186,7 +186,7 @@ func TestOSCARProxy_RecvBOS_ChatUpdateBuddyLeft(t *testing.T) {
 			assert.Equal(t, state.SessSendOK, status)
 
 			gotCmd := <-ch
-			assert.Equal(t, string(tc.wantCmd), string(gotCmd))
+			assert.Equal(t, tc.wantCmd[0], gotCmd[0])
 
 			cancel()
 			wg.Wait()
@@ -205,7 +205,7 @@ func TestOSCARProxy_RecvBOS_Eviled(t *testing.T) {
 		// chatRegistry is the chat registry for the current session
 		chatRegistry *ChatRegistry
 		// wantCmd is the expected TOC response
-		wantCmd []byte
+		wantCmd []string
 	}{
 		{
 			name: "anonymous warning - 10%",
@@ -215,7 +215,7 @@ func TestOSCARProxy_RecvBOS_Eviled(t *testing.T) {
 					NewEvil: 100,
 				},
 			},
-			wantCmd: []byte("EVILED:10:"),
+			wantCmd: []string{"EVILED:10:"},
 		},
 		{
 			name: "normal warning - 10%",
@@ -232,7 +232,7 @@ func TestOSCARProxy_RecvBOS_Eviled(t *testing.T) {
 					},
 				},
 			},
-			wantCmd: []byte("EVILED:10:them"),
+			wantCmd: []string{"EVILED:10:them"},
 		},
 	}
 
@@ -242,7 +242,7 @@ func TestOSCARProxy_RecvBOS_Eviled(t *testing.T) {
 
 			svc := testOSCARProxy(t)
 
-			ch := make(chan []byte)
+			ch := make(chan []string)
 			wg := &sync.WaitGroup{}
 			wg.Add(1)
 
@@ -256,7 +256,7 @@ func TestOSCARProxy_RecvBOS_Eviled(t *testing.T) {
 			assert.Equal(t, state.SessSendOK, status)
 
 			gotCmd := <-ch
-			assert.Equal(t, string(tc.wantCmd), string(gotCmd))
+			assert.Equal(t, tc.wantCmd[0], gotCmd[0])
 
 			cancel()
 			wg.Wait()
@@ -273,7 +273,7 @@ func TestOSCARProxy_RecvBOS_IMIn(t *testing.T) {
 		// givenMsg is the incoming SNAC
 		givenMsg wire.SNACMessage
 		// wantCmd is the expected TOC response
-		wantCmd []byte
+		wantCmd []string
 	}{
 		{
 			name: "send IM",
@@ -306,7 +306,7 @@ func TestOSCARProxy_RecvBOS_IMIn(t *testing.T) {
 					},
 				},
 			},
-			wantCmd: []byte("IM_IN:them:F:hello world!"),
+			wantCmd: []string{"IM_IN:them:F:hello world!"},
 		},
 		{
 			name: "send IM - auto-response",
@@ -340,7 +340,7 @@ func TestOSCARProxy_RecvBOS_IMIn(t *testing.T) {
 					},
 				},
 			},
-			wantCmd: []byte("IM_IN:them:T:hello world!"),
+			wantCmd: []string{"IM_IN:them:T:hello world!"},
 		},
 		{
 			name: "send chat invitation",
@@ -370,7 +370,7 @@ func TestOSCARProxy_RecvBOS_IMIn(t *testing.T) {
 					},
 				},
 			},
-			wantCmd: []byte("CHAT_INVITE:the room:0:them:join my chat!"),
+			wantCmd: []string{"CHAT_INVITE:the room:0:them:join my chat!"},
 		},
 		{
 			name: "receive file transfer rendezvous IM",
@@ -401,7 +401,7 @@ func TestOSCARProxy_RecvBOS_IMIn(t *testing.T) {
 					},
 				},
 			},
-			wantCmd: []byte("RVOUS_PROPOSE:them:09461343-4C7F-11D1-8222-444553540000:aGFoYWhhaGE=:1:129.168.0.1:129.168.0.2:129.168.0.3:4000:10001:bG9s"),
+			wantCmd: []string{"RVOUS_PROPOSE:them:09461343-4C7F-11D1-8222-444553540000:aGFoYWhhaGE=:1:129.168.0.1:129.168.0.2:129.168.0.3:4000:10001:bG9s"},
 		},
 	}
 
@@ -411,7 +411,7 @@ func TestOSCARProxy_RecvBOS_IMIn(t *testing.T) {
 
 			svc := testOSCARProxy(t)
 
-			ch := make(chan []byte)
+			ch := make(chan []string)
 			wg := &sync.WaitGroup{}
 			wg.Add(1)
 
@@ -425,7 +425,7 @@ func TestOSCARProxy_RecvBOS_IMIn(t *testing.T) {
 			assert.Equal(t, state.SessSendOK, status)
 
 			gotCmd := <-ch
-			assert.Equal(t, string(tc.wantCmd), string(gotCmd))
+			assert.Equal(t, tc.wantCmd[0], gotCmd[0])
 
 			cancel()
 			wg.Wait()
@@ -442,7 +442,7 @@ func TestOSCARProxy_RecvBOS_UpdateBuddyArrival(t *testing.T) {
 		// givenMsg is the incoming SNAC
 		givenMsg wire.SNACMessage
 		// wantCmd is the expected TOC response
-		wantCmd []byte
+		wantCmd []string
 	}{
 		{
 			name: "send buddy arrival - buddy online",
@@ -456,12 +456,13 @@ func TestOSCARProxy_RecvBOS_UpdateBuddyArrival(t *testing.T) {
 							TLVList: wire.TLVList{
 								wire.NewTLVBE(wire.OServiceUserInfoSignonTOD, uint32(1234)),
 								wire.NewTLVBE(wire.OServiceUserInfoIdleTime, uint16(5678)),
+								wire.NewTLVBE(wire.OServiceUserInfoUserFlags, wire.OServiceUserFlagOSCARFree),
 							},
 						},
 					},
 				},
 			},
-			wantCmd: []byte("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%",
@@ -475,12 +476,13 @@ func TestOSCARProxy_RecvBOS_UpdateBuddyArrival(t *testing.T) {
 							TLVList: wire.TLVList{
 								wire.NewTLVBE(wire.OServiceUserInfoSignonTOD, uint32(1234)),
 								wire.NewTLVBE(wire.OServiceUserInfoIdleTime, uint16(5678)),
+								wire.NewTLVBE(wire.OServiceUserInfoUserFlags, wire.OServiceUserFlagOSCARFree),
 							},
 						},
 					},
 				},
 			},
-			wantCmd: []byte("UPDATE_BUDDY:me:T:10:1234:5678: O "),
+			wantCmd: []string{"UPDATE_BUDDY:me:T:10:1234:5678: O "},
 		},
 		{
 			name: "send buddy arrival - buddy away",
@@ -494,13 +496,13 @@ func TestOSCARProxy_RecvBOS_UpdateBuddyArrival(t *testing.T) {
 							TLVList: wire.TLVList{
 								wire.NewTLVBE(wire.OServiceUserInfoSignonTOD, uint32(1234)),
 								wire.NewTLVBE(wire.OServiceUserInfoIdleTime, uint16(5678)),
-								wire.NewTLVBE(wire.OServiceUserInfoUserFlags, wire.OServiceUserFlagUnavailable),
+								wire.NewTLVBE(wire.OServiceUserInfoUserFlags, wire.OServiceUserFlagOSCARFree|wire.OServiceUserFlagUnavailable),
 							},
 						},
 					},
 				},
 			},
-			wantCmd: []byte("UPDATE_BUDDY:me:T:0:1234:5678: OU"),
+			wantCmd: []string{"UPDATE_BUDDY:me:T:0:1234:5678: OU"},
 		},
 	}
 
@@ -510,7 +512,7 @@ func TestOSCARProxy_RecvBOS_UpdateBuddyArrival(t *testing.T) {
 
 			svc := testOSCARProxy(t)
 
-			ch := make(chan []byte)
+			ch := make(chan []string)
 			wg := &sync.WaitGroup{}
 			wg.Add(1)
 
@@ -524,7 +526,7 @@ func TestOSCARProxy_RecvBOS_UpdateBuddyArrival(t *testing.T) {
 			assert.Equal(t, state.SessSendOK, status)
 
 			gotCmd := <-ch
-			assert.Equal(t, string(tc.wantCmd), string(gotCmd))
+			assert.Equal(t, tc.wantCmd[0], gotCmd[0])
 
 			cancel()
 			wg.Wait()
@@ -541,7 +543,7 @@ func TestOSCARProxy_RecvBOS_UpdateBuddyDeparted(t *testing.T) {
 		// givenMsg is the incoming SNAC
 		givenMsg wire.SNACMessage
 		// wantCmd is the expected TOC response
-		wantCmd []byte
+		wantCmd []string
 	}{
 		{
 			name: "send buddy departure",
@@ -553,7 +555,7 @@ func TestOSCARProxy_RecvBOS_UpdateBuddyDeparted(t *testing.T) {
 					},
 				},
 			},
-			wantCmd: []byte("UPDATE_BUDDY:me:F:0:0:0:   "),
+			wantCmd: []string{"UPDATE_BUDDY:me:F:0:0:0:   "},
 		},
 	}
 
@@ -563,7 +565,7 @@ func TestOSCARProxy_RecvBOS_UpdateBuddyDeparted(t *testing.T) {
 
 			svc := testOSCARProxy(t)
 
-			ch := make(chan []byte)
+			ch := make(chan []string)
 			wg := &sync.WaitGroup{}
 			wg.Add(1)
 
@@ -577,7 +579,7 @@ func TestOSCARProxy_RecvBOS_UpdateBuddyDeparted(t *testing.T) {
 			assert.Equal(t, state.SessSendOK, status)
 
 			gotCmd := <-ch
-			assert.Equal(t, string(tc.wantCmd), string(gotCmd))
+			assert.Equal(t, tc.wantCmd[0], gotCmd[0])
 
 			cancel()
 			wg.Wait()

+ 14 - 3
server/toc/helpers_test.go

@@ -94,9 +94,10 @@ type icbmParams struct {
 }
 
 type clientOnlineParams []struct {
-	body wire.SNAC_0x01_0x02_OServiceClientOnline
-	me   state.IdentScreenName
-	err  error
+	body    wire.SNAC_0x01_0x02_OServiceClientOnline
+	me      state.IdentScreenName
+	service uint16
+	err     error
 }
 
 type idleNotificationParams []struct {
@@ -260,6 +261,15 @@ type tocConfigParams struct {
 	userParams
 }
 
+type feedBagParams struct {
+	useFeedbagParams
+}
+
+type useFeedbagParams []struct {
+	me  state.IdentScreenName
+	err error
+}
+
 type mockParams struct {
 	adminParams
 	authParams
@@ -272,6 +282,7 @@ type mockParams struct {
 	icbmParams
 	locateParams
 	oServiceParams
+	feedBagParams
 	permitDenyParams
 	sessionRetrieverParams
 	tocConfigParams

+ 165 - 0
server/toc/mock_feedbag_manager_test.go

@@ -0,0 +1,165 @@
+// Code generated by mockery; DO NOT EDIT.
+// github.com/vektra/mockery
+// template: testify
+
+package toc
+
+import (
+	"context"
+
+	"github.com/mk6i/open-oscar-server/state"
+	"github.com/mk6i/open-oscar-server/wire"
+	mock "github.com/stretchr/testify/mock"
+)
+
+// newMockFeedbagManager creates a new instance of mockFeedbagManager. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
+// The first argument is typically a *testing.T value.
+func newMockFeedbagManager(t interface {
+	mock.TestingT
+	Cleanup(func())
+}) *mockFeedbagManager {
+	mock := &mockFeedbagManager{}
+	mock.Mock.Test(t)
+
+	t.Cleanup(func() { mock.AssertExpectations(t) })
+
+	return mock
+}
+
+// mockFeedbagManager is an autogenerated mock type for the FeedbagManager type
+type mockFeedbagManager struct {
+	mock.Mock
+}
+
+type mockFeedbagManager_Expecter struct {
+	mock *mock.Mock
+}
+
+func (_m *mockFeedbagManager) EXPECT() *mockFeedbagManager_Expecter {
+	return &mockFeedbagManager_Expecter{mock: &_m.Mock}
+}
+
+// Feedbag provides a mock function for the type mockFeedbagManager
+func (_mock *mockFeedbagManager) Feedbag(ctx context.Context, screenName state.IdentScreenName) ([]wire.FeedbagItem, error) {
+	ret := _mock.Called(ctx, screenName)
+
+	if len(ret) == 0 {
+		panic("no return value specified for Feedbag")
+	}
+
+	var r0 []wire.FeedbagItem
+	var r1 error
+	if returnFunc, ok := ret.Get(0).(func(context.Context, state.IdentScreenName) ([]wire.FeedbagItem, error)); ok {
+		return returnFunc(ctx, screenName)
+	}
+	if returnFunc, ok := ret.Get(0).(func(context.Context, state.IdentScreenName) []wire.FeedbagItem); ok {
+		r0 = returnFunc(ctx, screenName)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).([]wire.FeedbagItem)
+		}
+	}
+	if returnFunc, ok := ret.Get(1).(func(context.Context, state.IdentScreenName) error); ok {
+		r1 = returnFunc(ctx, screenName)
+	} else {
+		r1 = ret.Error(1)
+	}
+	return r0, r1
+}
+
+// mockFeedbagManager_Feedbag_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Feedbag'
+type mockFeedbagManager_Feedbag_Call struct {
+	*mock.Call
+}
+
+// Feedbag is a helper method to define mock.On call
+//   - ctx context.Context
+//   - screenName state.IdentScreenName
+func (_e *mockFeedbagManager_Expecter) Feedbag(ctx interface{}, screenName interface{}) *mockFeedbagManager_Feedbag_Call {
+	return &mockFeedbagManager_Feedbag_Call{Call: _e.mock.On("Feedbag", ctx, screenName)}
+}
+
+func (_c *mockFeedbagManager_Feedbag_Call) Run(run func(ctx context.Context, screenName state.IdentScreenName)) *mockFeedbagManager_Feedbag_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		var arg0 context.Context
+		if args[0] != nil {
+			arg0 = args[0].(context.Context)
+		}
+		var arg1 state.IdentScreenName
+		if args[1] != nil {
+			arg1 = args[1].(state.IdentScreenName)
+		}
+		run(
+			arg0,
+			arg1,
+		)
+	})
+	return _c
+}
+
+func (_c *mockFeedbagManager_Feedbag_Call) Return(feedbagItems []wire.FeedbagItem, err error) *mockFeedbagManager_Feedbag_Call {
+	_c.Call.Return(feedbagItems, err)
+	return _c
+}
+
+func (_c *mockFeedbagManager_Feedbag_Call) RunAndReturn(run func(ctx context.Context, screenName state.IdentScreenName) ([]wire.FeedbagItem, error)) *mockFeedbagManager_Feedbag_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// UseFeedbag provides a mock function for the type mockFeedbagManager
+func (_mock *mockFeedbagManager) UseFeedbag(ctx context.Context, screenName state.IdentScreenName) error {
+	ret := _mock.Called(ctx, screenName)
+
+	if len(ret) == 0 {
+		panic("no return value specified for UseFeedbag")
+	}
+
+	var r0 error
+	if returnFunc, ok := ret.Get(0).(func(context.Context, state.IdentScreenName) error); ok {
+		r0 = returnFunc(ctx, screenName)
+	} else {
+		r0 = ret.Error(0)
+	}
+	return r0
+}
+
+// mockFeedbagManager_UseFeedbag_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UseFeedbag'
+type mockFeedbagManager_UseFeedbag_Call struct {
+	*mock.Call
+}
+
+// UseFeedbag is a helper method to define mock.On call
+//   - ctx context.Context
+//   - screenName state.IdentScreenName
+func (_e *mockFeedbagManager_Expecter) UseFeedbag(ctx interface{}, screenName interface{}) *mockFeedbagManager_UseFeedbag_Call {
+	return &mockFeedbagManager_UseFeedbag_Call{Call: _e.mock.On("UseFeedbag", ctx, screenName)}
+}
+
+func (_c *mockFeedbagManager_UseFeedbag_Call) Run(run func(ctx context.Context, screenName state.IdentScreenName)) *mockFeedbagManager_UseFeedbag_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		var arg0 context.Context
+		if args[0] != nil {
+			arg0 = args[0].(context.Context)
+		}
+		var arg1 state.IdentScreenName
+		if args[1] != nil {
+			arg1 = args[1].(state.IdentScreenName)
+		}
+		run(
+			arg0,
+			arg1,
+		)
+	})
+	return _c
+}
+
+func (_c *mockFeedbagManager_UseFeedbag_Call) Return(err error) *mockFeedbagManager_UseFeedbag_Call {
+	_c.Call.Return(err)
+	return _c
+}
+
+func (_c *mockFeedbagManager_UseFeedbag_Call) RunAndReturn(run func(ctx context.Context, screenName state.IdentScreenName) error) *mockFeedbagManager_UseFeedbag_Call {
+	_c.Call.Return(run)
+	return _c
+}

+ 643 - 0
server/toc/mock_feedbag_service_test.go

@@ -0,0 +1,643 @@
+// Code generated by mockery; DO NOT EDIT.
+// github.com/vektra/mockery
+// template: testify
+
+package toc
+
+import (
+	"context"
+
+	"github.com/mk6i/open-oscar-server/state"
+	"github.com/mk6i/open-oscar-server/wire"
+	mock "github.com/stretchr/testify/mock"
+)
+
+// newMockFeedbagService creates a new instance of mockFeedbagService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
+// The first argument is typically a *testing.T value.
+func newMockFeedbagService(t interface {
+	mock.TestingT
+	Cleanup(func())
+}) *mockFeedbagService {
+	mock := &mockFeedbagService{}
+	mock.Mock.Test(t)
+
+	t.Cleanup(func() { mock.AssertExpectations(t) })
+
+	return mock
+}
+
+// mockFeedbagService is an autogenerated mock type for the FeedbagService type
+type mockFeedbagService struct {
+	mock.Mock
+}
+
+type mockFeedbagService_Expecter struct {
+	mock *mock.Mock
+}
+
+func (_m *mockFeedbagService) EXPECT() *mockFeedbagService_Expecter {
+	return &mockFeedbagService_Expecter{mock: &_m.Mock}
+}
+
+// DeleteItem provides a mock function for the type mockFeedbagService
+func (_mock *mockFeedbagService) DeleteItem(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x0A_FeedbagDeleteItem) (*wire.SNACMessage, error) {
+	ret := _mock.Called(ctx, instance, inFrame, inBody)
+
+	if len(ret) == 0 {
+		panic("no return value specified for DeleteItem")
+	}
+
+	var r0 *wire.SNACMessage
+	var r1 error
+	if returnFunc, ok := ret.Get(0).(func(context.Context, *state.SessionInstance, wire.SNACFrame, wire.SNAC_0x13_0x0A_FeedbagDeleteItem) (*wire.SNACMessage, error)); ok {
+		return returnFunc(ctx, instance, inFrame, inBody)
+	}
+	if returnFunc, ok := ret.Get(0).(func(context.Context, *state.SessionInstance, wire.SNACFrame, wire.SNAC_0x13_0x0A_FeedbagDeleteItem) *wire.SNACMessage); ok {
+		r0 = returnFunc(ctx, instance, inFrame, inBody)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*wire.SNACMessage)
+		}
+	}
+	if returnFunc, ok := ret.Get(1).(func(context.Context, *state.SessionInstance, wire.SNACFrame, wire.SNAC_0x13_0x0A_FeedbagDeleteItem) error); ok {
+		r1 = returnFunc(ctx, instance, inFrame, inBody)
+	} else {
+		r1 = ret.Error(1)
+	}
+	return r0, r1
+}
+
+// mockFeedbagService_DeleteItem_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteItem'
+type mockFeedbagService_DeleteItem_Call struct {
+	*mock.Call
+}
+
+// DeleteItem is a helper method to define mock.On call
+//   - ctx context.Context
+//   - instance *state.SessionInstance
+//   - inFrame wire.SNACFrame
+//   - inBody wire.SNAC_0x13_0x0A_FeedbagDeleteItem
+func (_e *mockFeedbagService_Expecter) DeleteItem(ctx interface{}, instance interface{}, inFrame interface{}, inBody interface{}) *mockFeedbagService_DeleteItem_Call {
+	return &mockFeedbagService_DeleteItem_Call{Call: _e.mock.On("DeleteItem", ctx, instance, inFrame, inBody)}
+}
+
+func (_c *mockFeedbagService_DeleteItem_Call) Run(run func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x0A_FeedbagDeleteItem)) *mockFeedbagService_DeleteItem_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		var arg0 context.Context
+		if args[0] != nil {
+			arg0 = args[0].(context.Context)
+		}
+		var arg1 *state.SessionInstance
+		if args[1] != nil {
+			arg1 = args[1].(*state.SessionInstance)
+		}
+		var arg2 wire.SNACFrame
+		if args[2] != nil {
+			arg2 = args[2].(wire.SNACFrame)
+		}
+		var arg3 wire.SNAC_0x13_0x0A_FeedbagDeleteItem
+		if args[3] != nil {
+			arg3 = args[3].(wire.SNAC_0x13_0x0A_FeedbagDeleteItem)
+		}
+		run(
+			arg0,
+			arg1,
+			arg2,
+			arg3,
+		)
+	})
+	return _c
+}
+
+func (_c *mockFeedbagService_DeleteItem_Call) Return(sNACMessage *wire.SNACMessage, err error) *mockFeedbagService_DeleteItem_Call {
+	_c.Call.Return(sNACMessage, err)
+	return _c
+}
+
+func (_c *mockFeedbagService_DeleteItem_Call) RunAndReturn(run func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x0A_FeedbagDeleteItem) (*wire.SNACMessage, error)) *mockFeedbagService_DeleteItem_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// EndCluster provides a mock function for the type mockFeedbagService
+func (_mock *mockFeedbagService) EndCluster(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame) {
+	_mock.Called(ctx, instance, inFrame)
+	return
+}
+
+// mockFeedbagService_EndCluster_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EndCluster'
+type mockFeedbagService_EndCluster_Call struct {
+	*mock.Call
+}
+
+// EndCluster is a helper method to define mock.On call
+//   - ctx context.Context
+//   - instance *state.SessionInstance
+//   - inFrame wire.SNACFrame
+func (_e *mockFeedbagService_Expecter) EndCluster(ctx interface{}, instance interface{}, inFrame interface{}) *mockFeedbagService_EndCluster_Call {
+	return &mockFeedbagService_EndCluster_Call{Call: _e.mock.On("EndCluster", ctx, instance, inFrame)}
+}
+
+func (_c *mockFeedbagService_EndCluster_Call) Run(run func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame)) *mockFeedbagService_EndCluster_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		var arg0 context.Context
+		if args[0] != nil {
+			arg0 = args[0].(context.Context)
+		}
+		var arg1 *state.SessionInstance
+		if args[1] != nil {
+			arg1 = args[1].(*state.SessionInstance)
+		}
+		var arg2 wire.SNACFrame
+		if args[2] != nil {
+			arg2 = args[2].(wire.SNACFrame)
+		}
+		run(
+			arg0,
+			arg1,
+			arg2,
+		)
+	})
+	return _c
+}
+
+func (_c *mockFeedbagService_EndCluster_Call) Return() *mockFeedbagService_EndCluster_Call {
+	_c.Call.Return()
+	return _c
+}
+
+func (_c *mockFeedbagService_EndCluster_Call) RunAndReturn(run func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame)) *mockFeedbagService_EndCluster_Call {
+	_c.Run(run)
+	return _c
+}
+
+// Query provides a mock function for the type mockFeedbagService
+func (_mock *mockFeedbagService) Query(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame) (wire.SNACMessage, error) {
+	ret := _mock.Called(ctx, instance, inFrame)
+
+	if len(ret) == 0 {
+		panic("no return value specified for Query")
+	}
+
+	var r0 wire.SNACMessage
+	var r1 error
+	if returnFunc, ok := ret.Get(0).(func(context.Context, *state.SessionInstance, wire.SNACFrame) (wire.SNACMessage, error)); ok {
+		return returnFunc(ctx, instance, inFrame)
+	}
+	if returnFunc, ok := ret.Get(0).(func(context.Context, *state.SessionInstance, wire.SNACFrame) wire.SNACMessage); ok {
+		r0 = returnFunc(ctx, instance, inFrame)
+	} else {
+		r0 = ret.Get(0).(wire.SNACMessage)
+	}
+	if returnFunc, ok := ret.Get(1).(func(context.Context, *state.SessionInstance, wire.SNACFrame) error); ok {
+		r1 = returnFunc(ctx, instance, inFrame)
+	} else {
+		r1 = ret.Error(1)
+	}
+	return r0, r1
+}
+
+// mockFeedbagService_Query_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Query'
+type mockFeedbagService_Query_Call struct {
+	*mock.Call
+}
+
+// Query is a helper method to define mock.On call
+//   - ctx context.Context
+//   - instance *state.SessionInstance
+//   - inFrame wire.SNACFrame
+func (_e *mockFeedbagService_Expecter) Query(ctx interface{}, instance interface{}, inFrame interface{}) *mockFeedbagService_Query_Call {
+	return &mockFeedbagService_Query_Call{Call: _e.mock.On("Query", ctx, instance, inFrame)}
+}
+
+func (_c *mockFeedbagService_Query_Call) Run(run func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame)) *mockFeedbagService_Query_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		var arg0 context.Context
+		if args[0] != nil {
+			arg0 = args[0].(context.Context)
+		}
+		var arg1 *state.SessionInstance
+		if args[1] != nil {
+			arg1 = args[1].(*state.SessionInstance)
+		}
+		var arg2 wire.SNACFrame
+		if args[2] != nil {
+			arg2 = args[2].(wire.SNACFrame)
+		}
+		run(
+			arg0,
+			arg1,
+			arg2,
+		)
+	})
+	return _c
+}
+
+func (_c *mockFeedbagService_Query_Call) Return(sNACMessage wire.SNACMessage, err error) *mockFeedbagService_Query_Call {
+	_c.Call.Return(sNACMessage, err)
+	return _c
+}
+
+func (_c *mockFeedbagService_Query_Call) RunAndReturn(run func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame) (wire.SNACMessage, error)) *mockFeedbagService_Query_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// QueryIfModified provides a mock function for the type mockFeedbagService
+func (_mock *mockFeedbagService) QueryIfModified(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x05_FeedbagQueryIfModified) (wire.SNACMessage, error) {
+	ret := _mock.Called(ctx, instance, inFrame, inBody)
+
+	if len(ret) == 0 {
+		panic("no return value specified for QueryIfModified")
+	}
+
+	var r0 wire.SNACMessage
+	var r1 error
+	if returnFunc, ok := ret.Get(0).(func(context.Context, *state.SessionInstance, wire.SNACFrame, wire.SNAC_0x13_0x05_FeedbagQueryIfModified) (wire.SNACMessage, error)); ok {
+		return returnFunc(ctx, instance, inFrame, inBody)
+	}
+	if returnFunc, ok := ret.Get(0).(func(context.Context, *state.SessionInstance, wire.SNACFrame, wire.SNAC_0x13_0x05_FeedbagQueryIfModified) wire.SNACMessage); ok {
+		r0 = returnFunc(ctx, instance, inFrame, inBody)
+	} else {
+		r0 = ret.Get(0).(wire.SNACMessage)
+	}
+	if returnFunc, ok := ret.Get(1).(func(context.Context, *state.SessionInstance, wire.SNACFrame, wire.SNAC_0x13_0x05_FeedbagQueryIfModified) error); ok {
+		r1 = returnFunc(ctx, instance, inFrame, inBody)
+	} else {
+		r1 = ret.Error(1)
+	}
+	return r0, r1
+}
+
+// mockFeedbagService_QueryIfModified_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'QueryIfModified'
+type mockFeedbagService_QueryIfModified_Call struct {
+	*mock.Call
+}
+
+// QueryIfModified is a helper method to define mock.On call
+//   - ctx context.Context
+//   - instance *state.SessionInstance
+//   - inFrame wire.SNACFrame
+//   - inBody wire.SNAC_0x13_0x05_FeedbagQueryIfModified
+func (_e *mockFeedbagService_Expecter) QueryIfModified(ctx interface{}, instance interface{}, inFrame interface{}, inBody interface{}) *mockFeedbagService_QueryIfModified_Call {
+	return &mockFeedbagService_QueryIfModified_Call{Call: _e.mock.On("QueryIfModified", ctx, instance, inFrame, inBody)}
+}
+
+func (_c *mockFeedbagService_QueryIfModified_Call) Run(run func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x05_FeedbagQueryIfModified)) *mockFeedbagService_QueryIfModified_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		var arg0 context.Context
+		if args[0] != nil {
+			arg0 = args[0].(context.Context)
+		}
+		var arg1 *state.SessionInstance
+		if args[1] != nil {
+			arg1 = args[1].(*state.SessionInstance)
+		}
+		var arg2 wire.SNACFrame
+		if args[2] != nil {
+			arg2 = args[2].(wire.SNACFrame)
+		}
+		var arg3 wire.SNAC_0x13_0x05_FeedbagQueryIfModified
+		if args[3] != nil {
+			arg3 = args[3].(wire.SNAC_0x13_0x05_FeedbagQueryIfModified)
+		}
+		run(
+			arg0,
+			arg1,
+			arg2,
+			arg3,
+		)
+	})
+	return _c
+}
+
+func (_c *mockFeedbagService_QueryIfModified_Call) Return(sNACMessage wire.SNACMessage, err error) *mockFeedbagService_QueryIfModified_Call {
+	_c.Call.Return(sNACMessage, err)
+	return _c
+}
+
+func (_c *mockFeedbagService_QueryIfModified_Call) RunAndReturn(run func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x05_FeedbagQueryIfModified) (wire.SNACMessage, error)) *mockFeedbagService_QueryIfModified_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// RespondAuthorizeToHost provides a mock function for the type mockFeedbagService
+func (_mock *mockFeedbagService) RespondAuthorizeToHost(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x1A_FeedbagRespondAuthorizeToHost) error {
+	ret := _mock.Called(ctx, instance, inFrame, inBody)
+
+	if len(ret) == 0 {
+		panic("no return value specified for RespondAuthorizeToHost")
+	}
+
+	var r0 error
+	if returnFunc, ok := ret.Get(0).(func(context.Context, *state.SessionInstance, wire.SNACFrame, wire.SNAC_0x13_0x1A_FeedbagRespondAuthorizeToHost) error); ok {
+		r0 = returnFunc(ctx, instance, inFrame, inBody)
+	} else {
+		r0 = ret.Error(0)
+	}
+	return r0
+}
+
+// mockFeedbagService_RespondAuthorizeToHost_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RespondAuthorizeToHost'
+type mockFeedbagService_RespondAuthorizeToHost_Call struct {
+	*mock.Call
+}
+
+// RespondAuthorizeToHost is a helper method to define mock.On call
+//   - ctx context.Context
+//   - instance *state.SessionInstance
+//   - inFrame wire.SNACFrame
+//   - inBody wire.SNAC_0x13_0x1A_FeedbagRespondAuthorizeToHost
+func (_e *mockFeedbagService_Expecter) RespondAuthorizeToHost(ctx interface{}, instance interface{}, inFrame interface{}, inBody interface{}) *mockFeedbagService_RespondAuthorizeToHost_Call {
+	return &mockFeedbagService_RespondAuthorizeToHost_Call{Call: _e.mock.On("RespondAuthorizeToHost", ctx, instance, inFrame, inBody)}
+}
+
+func (_c *mockFeedbagService_RespondAuthorizeToHost_Call) Run(run func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x1A_FeedbagRespondAuthorizeToHost)) *mockFeedbagService_RespondAuthorizeToHost_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		var arg0 context.Context
+		if args[0] != nil {
+			arg0 = args[0].(context.Context)
+		}
+		var arg1 *state.SessionInstance
+		if args[1] != nil {
+			arg1 = args[1].(*state.SessionInstance)
+		}
+		var arg2 wire.SNACFrame
+		if args[2] != nil {
+			arg2 = args[2].(wire.SNACFrame)
+		}
+		var arg3 wire.SNAC_0x13_0x1A_FeedbagRespondAuthorizeToHost
+		if args[3] != nil {
+			arg3 = args[3].(wire.SNAC_0x13_0x1A_FeedbagRespondAuthorizeToHost)
+		}
+		run(
+			arg0,
+			arg1,
+			arg2,
+			arg3,
+		)
+	})
+	return _c
+}
+
+func (_c *mockFeedbagService_RespondAuthorizeToHost_Call) Return(err error) *mockFeedbagService_RespondAuthorizeToHost_Call {
+	_c.Call.Return(err)
+	return _c
+}
+
+func (_c *mockFeedbagService_RespondAuthorizeToHost_Call) RunAndReturn(run func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x1A_FeedbagRespondAuthorizeToHost) error) *mockFeedbagService_RespondAuthorizeToHost_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// RightsQuery provides a mock function for the type mockFeedbagService
+func (_mock *mockFeedbagService) RightsQuery(ctx context.Context, inFrame wire.SNACFrame) wire.SNACMessage {
+	ret := _mock.Called(ctx, inFrame)
+
+	if len(ret) == 0 {
+		panic("no return value specified for RightsQuery")
+	}
+
+	var r0 wire.SNACMessage
+	if returnFunc, ok := ret.Get(0).(func(context.Context, wire.SNACFrame) wire.SNACMessage); ok {
+		r0 = returnFunc(ctx, inFrame)
+	} else {
+		r0 = ret.Get(0).(wire.SNACMessage)
+	}
+	return r0
+}
+
+// mockFeedbagService_RightsQuery_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RightsQuery'
+type mockFeedbagService_RightsQuery_Call struct {
+	*mock.Call
+}
+
+// RightsQuery is a helper method to define mock.On call
+//   - ctx context.Context
+//   - inFrame wire.SNACFrame
+func (_e *mockFeedbagService_Expecter) RightsQuery(ctx interface{}, inFrame interface{}) *mockFeedbagService_RightsQuery_Call {
+	return &mockFeedbagService_RightsQuery_Call{Call: _e.mock.On("RightsQuery", ctx, inFrame)}
+}
+
+func (_c *mockFeedbagService_RightsQuery_Call) Run(run func(ctx context.Context, inFrame wire.SNACFrame)) *mockFeedbagService_RightsQuery_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		var arg0 context.Context
+		if args[0] != nil {
+			arg0 = args[0].(context.Context)
+		}
+		var arg1 wire.SNACFrame
+		if args[1] != nil {
+			arg1 = args[1].(wire.SNACFrame)
+		}
+		run(
+			arg0,
+			arg1,
+		)
+	})
+	return _c
+}
+
+func (_c *mockFeedbagService_RightsQuery_Call) Return(sNACMessage wire.SNACMessage) *mockFeedbagService_RightsQuery_Call {
+	_c.Call.Return(sNACMessage)
+	return _c
+}
+
+func (_c *mockFeedbagService_RightsQuery_Call) RunAndReturn(run func(ctx context.Context, inFrame wire.SNACFrame) wire.SNACMessage) *mockFeedbagService_RightsQuery_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// StartCluster provides a mock function for the type mockFeedbagService
+func (_mock *mockFeedbagService) StartCluster(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x11_FeedbagStartCluster) {
+	_mock.Called(ctx, instance, inFrame, inBody)
+	return
+}
+
+// mockFeedbagService_StartCluster_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StartCluster'
+type mockFeedbagService_StartCluster_Call struct {
+	*mock.Call
+}
+
+// StartCluster is a helper method to define mock.On call
+//   - ctx context.Context
+//   - instance *state.SessionInstance
+//   - inFrame wire.SNACFrame
+//   - inBody wire.SNAC_0x13_0x11_FeedbagStartCluster
+func (_e *mockFeedbagService_Expecter) StartCluster(ctx interface{}, instance interface{}, inFrame interface{}, inBody interface{}) *mockFeedbagService_StartCluster_Call {
+	return &mockFeedbagService_StartCluster_Call{Call: _e.mock.On("StartCluster", ctx, instance, inFrame, inBody)}
+}
+
+func (_c *mockFeedbagService_StartCluster_Call) Run(run func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x11_FeedbagStartCluster)) *mockFeedbagService_StartCluster_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		var arg0 context.Context
+		if args[0] != nil {
+			arg0 = args[0].(context.Context)
+		}
+		var arg1 *state.SessionInstance
+		if args[1] != nil {
+			arg1 = args[1].(*state.SessionInstance)
+		}
+		var arg2 wire.SNACFrame
+		if args[2] != nil {
+			arg2 = args[2].(wire.SNACFrame)
+		}
+		var arg3 wire.SNAC_0x13_0x11_FeedbagStartCluster
+		if args[3] != nil {
+			arg3 = args[3].(wire.SNAC_0x13_0x11_FeedbagStartCluster)
+		}
+		run(
+			arg0,
+			arg1,
+			arg2,
+			arg3,
+		)
+	})
+	return _c
+}
+
+func (_c *mockFeedbagService_StartCluster_Call) Return() *mockFeedbagService_StartCluster_Call {
+	_c.Call.Return()
+	return _c
+}
+
+func (_c *mockFeedbagService_StartCluster_Call) RunAndReturn(run func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x11_FeedbagStartCluster)) *mockFeedbagService_StartCluster_Call {
+	_c.Run(run)
+	return _c
+}
+
+// UpsertItem provides a mock function for the type mockFeedbagService
+func (_mock *mockFeedbagService) UpsertItem(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, items []wire.FeedbagItem) (*wire.SNACMessage, error) {
+	ret := _mock.Called(ctx, instance, inFrame, items)
+
+	if len(ret) == 0 {
+		panic("no return value specified for UpsertItem")
+	}
+
+	var r0 *wire.SNACMessage
+	var r1 error
+	if returnFunc, ok := ret.Get(0).(func(context.Context, *state.SessionInstance, wire.SNACFrame, []wire.FeedbagItem) (*wire.SNACMessage, error)); ok {
+		return returnFunc(ctx, instance, inFrame, items)
+	}
+	if returnFunc, ok := ret.Get(0).(func(context.Context, *state.SessionInstance, wire.SNACFrame, []wire.FeedbagItem) *wire.SNACMessage); ok {
+		r0 = returnFunc(ctx, instance, inFrame, items)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*wire.SNACMessage)
+		}
+	}
+	if returnFunc, ok := ret.Get(1).(func(context.Context, *state.SessionInstance, wire.SNACFrame, []wire.FeedbagItem) error); ok {
+		r1 = returnFunc(ctx, instance, inFrame, items)
+	} else {
+		r1 = ret.Error(1)
+	}
+	return r0, r1
+}
+
+// mockFeedbagService_UpsertItem_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpsertItem'
+type mockFeedbagService_UpsertItem_Call struct {
+	*mock.Call
+}
+
+// UpsertItem is a helper method to define mock.On call
+//   - ctx context.Context
+//   - instance *state.SessionInstance
+//   - inFrame wire.SNACFrame
+//   - items []wire.FeedbagItem
+func (_e *mockFeedbagService_Expecter) UpsertItem(ctx interface{}, instance interface{}, inFrame interface{}, items interface{}) *mockFeedbagService_UpsertItem_Call {
+	return &mockFeedbagService_UpsertItem_Call{Call: _e.mock.On("UpsertItem", ctx, instance, inFrame, items)}
+}
+
+func (_c *mockFeedbagService_UpsertItem_Call) Run(run func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, items []wire.FeedbagItem)) *mockFeedbagService_UpsertItem_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		var arg0 context.Context
+		if args[0] != nil {
+			arg0 = args[0].(context.Context)
+		}
+		var arg1 *state.SessionInstance
+		if args[1] != nil {
+			arg1 = args[1].(*state.SessionInstance)
+		}
+		var arg2 wire.SNACFrame
+		if args[2] != nil {
+			arg2 = args[2].(wire.SNACFrame)
+		}
+		var arg3 []wire.FeedbagItem
+		if args[3] != nil {
+			arg3 = args[3].([]wire.FeedbagItem)
+		}
+		run(
+			arg0,
+			arg1,
+			arg2,
+			arg3,
+		)
+	})
+	return _c
+}
+
+func (_c *mockFeedbagService_UpsertItem_Call) Return(sNACMessage *wire.SNACMessage, err error) *mockFeedbagService_UpsertItem_Call {
+	_c.Call.Return(sNACMessage, err)
+	return _c
+}
+
+func (_c *mockFeedbagService_UpsertItem_Call) RunAndReturn(run func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, items []wire.FeedbagItem) (*wire.SNACMessage, error)) *mockFeedbagService_UpsertItem_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// Use provides a mock function for the type mockFeedbagService
+func (_mock *mockFeedbagService) Use(ctx context.Context, instance *state.SessionInstance) error {
+	ret := _mock.Called(ctx, instance)
+
+	if len(ret) == 0 {
+		panic("no return value specified for Use")
+	}
+
+	var r0 error
+	if returnFunc, ok := ret.Get(0).(func(context.Context, *state.SessionInstance) error); ok {
+		r0 = returnFunc(ctx, instance)
+	} else {
+		r0 = ret.Error(0)
+	}
+	return r0
+}
+
+// mockFeedbagService_Use_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Use'
+type mockFeedbagService_Use_Call struct {
+	*mock.Call
+}
+
+// Use is a helper method to define mock.On call
+//   - ctx context.Context
+//   - instance *state.SessionInstance
+func (_e *mockFeedbagService_Expecter) Use(ctx interface{}, instance interface{}) *mockFeedbagService_Use_Call {
+	return &mockFeedbagService_Use_Call{Call: _e.mock.On("Use", ctx, instance)}
+}
+
+func (_c *mockFeedbagService_Use_Call) Run(run func(ctx context.Context, instance *state.SessionInstance)) *mockFeedbagService_Use_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		var arg0 context.Context
+		if args[0] != nil {
+			arg0 = args[0].(context.Context)
+		}
+		var arg1 *state.SessionInstance
+		if args[1] != nil {
+			arg1 = args[1].(*state.SessionInstance)
+		}
+		run(
+			arg0,
+			arg1,
+		)
+	})
+	return _c
+}
+
+func (_c *mockFeedbagService_Use_Call) Return(err error) *mockFeedbagService_Use_Call {
+	_c.Call.Return(err)
+	return _c
+}
+
+func (_c *mockFeedbagService_Use_Call) RunAndReturn(run func(ctx context.Context, instance *state.SessionInstance) error) *mockFeedbagService_Use_Call {
+	_c.Call.Return(run)
+	return _c
+}

+ 41 - 27
server/toc/server.go

@@ -403,7 +403,7 @@ func (s *Server) handleTOCRequest(
 	}
 
 	// TOC response queue
-	msgCh := make(chan []byte, 1)
+	msgCh := make(chan []string, 1)
 
 	g, ctx := errgroup.WithContext(ctx)
 
@@ -436,7 +436,7 @@ func (s *Server) handleTOCRequest(
 	return g.Wait()
 }
 
-func (s *Server) runClientCommands(ctx context.Context, doAsync func(f func() error), sessBOS *state.SessionInstance, chatRegistry *ChatRegistry, clientFlap *wire.FlapClient, toCh chan<- []byte) error {
+func (s *Server) runClientCommands(ctx context.Context, doAsync func(f func() error), sessBOS *state.SessionInstance, chatRegistry *ChatRegistry, clientFlap *wire.FlapClient, toCh chan<- []string) error {
 	for {
 		clientFrame, err := clientFlap.ReceiveFLAP()
 		if err != nil {
@@ -459,9 +459,12 @@ func (s *Server) runClientCommands(ctx context.Context, doAsync func(f func() er
 			}
 
 			msg := s.bosProxy.RecvClientCmd(ctx, sessBOS, chatRegistry, clientFrame.Payload, toCh, doAsync)
-			if len(msg) > 0 {
+			// 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 {
 				select {
-				case toCh <- []byte(msg):
+				case toCh <- msg:
 				case <-ctx.Done():
 					return nil
 				}
@@ -472,24 +475,27 @@ func (s *Server) runClientCommands(ctx context.Context, doAsync func(f func() er
 	}
 }
 
-func (s *Server) sendToClient(ctx context.Context, toClient <-chan []byte, clientFlap *wire.FlapClient) error {
+func (s *Server) sendToClient(ctx context.Context, toClient <-chan []string, clientFlap *wire.FlapClient) error {
 	for {
 		select {
 		case <-ctx.Done():
 			return nil
-		case msg := <-toClient:
-			if err := clientFlap.SendDataFrame(msg); err != nil {
-				return fmt.Errorf("clientFlap.SendDataFrame: %w", err)
-			}
-			if s.logger.Enabled(ctx, slog.LevelDebug) {
-				s.logger.DebugContext(ctx, "server response", "command", msg)
-			} else {
-				// just log the command, omit params
-				idx := len(msg)
-				if col := bytes.IndexByte(msg, ':'); col > -1 {
-					idx = col
+		case msgs := <-toClient:
+			for _, m := range msgs {
+				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 {
+					// just log the command, omit params
+					idx := len(m)
+					if col := bytes.IndexByte([]byte(m), ':'); col > -1 {
+						idx = col
+					}
+					s.logger.InfoContext(ctx, "server response", "command", m[0:idx])
 				}
-				s.logger.InfoContext(ctx, "server response", "command", msg[0:idx])
 			}
 		}
 	}
@@ -510,32 +516,40 @@ func (s *Server) login(ctx context.Context, clientFlap *wire.FlapClient) (*state
 	if idx := bytes.IndexByte(clientFrame.Payload, ' '); idx > -1 {
 		cmd, args = clientFrame.Payload[:idx], clientFrame.Payload[idx:]
 	}
-	if string(cmd) != "toc_signon" {
-		return nil, errors.New("expected toc_signon")
+	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)
+	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) {
-	expected := "FLAPON\r\n\r\n"
-	buf := make([]byte, len(expected))
+	buf := make([]byte, 10)
 
-	_, err := io.ReadFull(rw, buf)
+	count, err := rw.Read(buf)
 	if err != nil {
-		return nil, fmt.Errorf("io.ReadFull: %w", err)
+		return nil, fmt.Errorf("rw.Read: %w", err)
 	}
-	if expected != string(buf) {
-		return nil, fmt.Errorf("expected FLAPON, got %s", buf)
+
+	header := string(buf[:count])
+	if !(header == "FLAPON\n\n" || header == "FLAPON\r\n\r\n") {
+		return nil, fmt.Errorf("expected FLAPON, got %X", buf)
 	}
 
 	clientFlap := wire.NewFlapClient(0, rw, rw)

+ 28 - 0
server/toc/types.go

@@ -2,6 +2,7 @@ package toc
 
 import (
 	"context"
+	"net/http"
 
 	"github.com/google/uuid"
 
@@ -89,6 +90,26 @@ type TOCConfigStore interface {
 	User(ctx context.Context, screenName state.IdentScreenName) (*state.User, error)
 }
 
+type FeedbagManager interface {
+	// Feedbag fetches the contents of a user's feedbag for CONFIG2
+	Feedbag(ctx context.Context, screenName state.IdentScreenName) ([]wire.FeedbagItem, error)
+	UseFeedbag(ctx context.Context, screenName state.IdentScreenName) error
+}
+
+// FeedbagService defines the interface for managing server-side buddy lists
+// (feedbag) and related operations.
+type FeedbagService interface {
+	DeleteItem(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x0A_FeedbagDeleteItem) (*wire.SNACMessage, error)
+	Query(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame) (wire.SNACMessage, error)
+	QueryIfModified(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x05_FeedbagQueryIfModified) (wire.SNACMessage, error)
+	RespondAuthorizeToHost(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x1A_FeedbagRespondAuthorizeToHost) error
+	RightsQuery(ctx context.Context, inFrame wire.SNACFrame) wire.SNACMessage
+	StartCluster(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x11_FeedbagStartCluster)
+	EndCluster(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame)
+	UpsertItem(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, items []wire.FeedbagItem) (*wire.SNACMessage, error)
+	Use(ctx context.Context, instance *state.SessionInstance) error
+}
+
 // CookieBaker defines methods for issuing and verifying AIM authentication tokens ("cookies").
 // These tokens are used for authenticating client sessions with AIM services.
 type CookieBaker interface {
@@ -113,3 +134,10 @@ type SessionRetriever interface {
 	// are active instances with complete signon.
 	RetrieveSession(screenName state.IdentScreenName) *state.Session
 }
+
+type OSCARProxyer interface {
+	Signon(ctx context.Context, args []byte, tocVersion state.TOCVersion) (*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
+}

+ 27 - 0
state/session.go

@@ -841,6 +841,18 @@ 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
@@ -873,6 +885,7 @@ type SessionInstance struct {
 	capabilities      [][16]byte
 	foodGroupVersions [wire.MDir + 1]uint16
 	multiConnFlag     wire.MultiConnFlag
+	tocVersion        TOCVersion
 
 	// Per-session state
 	idle              bool
@@ -935,6 +948,20 @@ func (s *SessionInstance) SetClientID(clientID string) {
 	s.clientID = clientID
 }
 
+// SetTocVersion sets the session TOC version
+func (s *SessionInstance) SetTocVersion(tocVersion TOCVersion) {
+	s.mutex.Lock()
+	defer s.mutex.Unlock()
+	s.tocVersion = tocVersion
+}
+
+// TocVersion returns session TOC version
+func (s *SessionInstance) TocVersion() (tocVersion TOCVersion) {
+	s.mutex.RLock()
+	defer s.mutex.RUnlock()
+	return s.tocVersion
+}
+
 //
 // Status / Availability
 //

+ 1 - 0
wire/frames.go

@@ -128,6 +128,7 @@ 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 {

+ 55 - 0
wire/snacs.go

@@ -2668,3 +2668,58 @@ type ICQDCInfo struct {
 	LastExtStatusUpdateTime uint32
 	Unknown                 uint16
 }
+
+//
+// TOC Error Codes
+//
+
+const (
+	// General Errors
+	TOCErrorGeneralUserNotAvailable        = "901" // followed by screenname
+	TOCErrorGeneralWarningUserNotAvailable = "902" // followed by screenname
+	TOCErrorGeneralRateLimitHit            = "903"
+	// Admin Errors
+	TOCErrorAdminInvalidInput       = "911"
+	TOCErrorAdminInvalidAccount     = "912"
+	TOCErrorAdminProcessingRequest  = "913"
+	TOCErrorAdminServiceUnavailable = "914"
+	// Chat Errors
+	TOCErrorChatInChatroomUnavailble = "950" // followed by chatroom
+	// IM & Info Errors
+	TOCErrorIMSendingMessagesTooFast = "960" // followed by screenname
+	TOCErrorIMMissedMessageTooBig    = "961" // followed by screenname
+	TOCErrorIMMissedMessageTooFast   = "962" // followed by screenname
+	// DirErrors
+	TOCErrorDirFailure               = "970"
+	TOCErrorDirTooManyMatches        = "971"
+	TOCErrorDirNeedMoreQualifiers    = "972"
+	TOCErrorDirServiceUnavailable    = "973"
+	TOCErrorDirEmailLookupRestricted = "974"
+	TOCErrorDirKeywordIgnored        = "975"
+	TOCErrorDirNoKeywords            = "976"
+	TOCErrorDirLanguageNotSupported  = "977"
+	TOCErrorDirCountryNotSupported   = "978"
+	TOCErrorDirFailureUnknown        = "979" // followed by error msg (or a SubErrorCode?)
+	// Auth Errors
+	TOCErrorAuthIncorrectNickOrPassword                    = "980"
+	TOCErrorAuthServiceUnavailable                         = "981"
+	TOCErrorAuthWarningTooHigh                             = "982"
+	TOCErrorAuthRatedFromLogin                             = "983"
+	TOCErrorAuthUnknownError                               = "989" // followed by SubErrorCode
+	TOCErrorAuthUnknownSubErrorSignOnTooSoon               = "0"
+	TOCErrorAuthUnknownSubErrorInvalidScreennameOrPassword = "7"
+	TOCErrorAuthUnknownSubErrorSuspendedAccount            = "17"
+)
+
+//
+// TOC2 Error Codes
+//
+
+const (
+	// Chat Error Codes
+	TO2CErrorChatJoinError            = "951" // followed by chatroom:SubErrorCode
+	TOC2ErrorChatJoinSubErrorReset    = "-1"
+	TOC2ErrorChatJoinSubErrorChatFull = "24"
+	TOC2ErrorChatJoinSubErrorRated    = "48"
+	TOC2ErrorChatJoinSubErrorEjected  = "74"
+)

+ 21 - 0
wire/tlv.go

@@ -246,3 +246,24 @@ func (s *TLVList) uint32(tag uint16, order binary.ByteOrder) (uint32, bool) {
 	}
 	return 0, false
 }
+
+// Uint16SliceBE retrieves a slice of 16-bit unsigned integer values from the TLVList
+// associated with the specified tag, interpreting the bytes in big-endian format.
+//
+// If the specificed tag is found, the function returns the associated value as a
+// uint16 slice and true. If the tag is not found, the function returns an empty
+// slice and false.
+func (s *TLVList) Uint16SliceBE(tag uint16) ([]uint16, bool) {
+	for _, tlv := range *s {
+		if tag == tlv.Tag {
+			outputLen := len(tlv.Value) / 2
+			outputSlice := make([]uint16, outputLen)
+			for i := range outputLen {
+				chunk := tlv.Value[i*2 : (i*2)+2]
+				outputSlice[i] = binary.BigEndian.Uint16(chunk)
+			}
+			return outputSlice, true
+		}
+	}
+	return []uint16{}, false
+}

+ 22 - 0
wire/tlv_test.go

@@ -292,6 +292,28 @@ func TestTLVList_Getters(t *testing.T) {
 			},
 			panic: true,
 		},
+		{
+			name: "given a TLV of big-endian uint16 slice, expect found value",
+			given: []TLV{
+				NewTLVBE(200, []uint16{12, 34, 56}),
+			},
+			lookup: func(l TLVList) (any, bool) {
+				return l.Uint16SliceBE(200)
+			},
+			expect: []uint16{12, 34, 56},
+			found:  true,
+		},
+		{
+			name: "given a TLV of big-endian uint16 slice, expect not found value",
+			given: []TLV{
+				NewTLVBE(200, []uint16{12, 34, 56}),
+			},
+			lookup: func(l TLVList) (any, bool) {
+				return l.Uint16SliceBE(50)
+			},
+			expect: []uint16{},
+			found:  false,
+		},
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {

Някои файлове не бяха показани, защото твърде много файлове са промени