Просмотр исходного кода

break out handlers into separate package

Mike 2 лет назад
Родитель
Сommit
81200e70f4
100 измененных файлов с 6974 добавлено и 7699 удалено
  1. 46 33
      .mockery.yaml
  2. 2 1
      cmd/config_generator/main.go
  3. 46 43
      cmd/server/main.go
  4. 54 49
      foodgroup/auth.go
  5. 171 148
      foodgroup/auth_test.go
  6. 38 38
      foodgroup/buddy.go
  7. 34 0
      foodgroup/buddy_test.go
  8. 126 0
      foodgroup/chat.go
  9. 143 0
      foodgroup/chat_nav.go
  10. 198 0
      foodgroup/chat_nav_test.go
  11. 64 63
      foodgroup/chat_test.go
  12. 296 0
      foodgroup/feedbag.go
  13. 246 245
      foodgroup/feedbag_test.go
  14. 255 0
      foodgroup/icbm.go
  15. 178 177
      foodgroup/icbm_test.go
  16. 173 0
      foodgroup/locate.go
  17. 118 118
      foodgroup/locate_test.go
  18. 19 19
      foodgroup/mock_chat_message_relayer_test.go
  19. 20 20
      foodgroup/mock_chat_registry_test.go
  20. 24 24
      foodgroup/mock_feedbag_manager_test.go
  21. 14 14
      foodgroup/mock_message_relayer_test.go
  22. 2 2
      foodgroup/mock_profile_manager_test.go
  23. 2 2
      foodgroup/mock_session_manager_test.go
  24. 2 2
      foodgroup/mock_user_manager_manager_test.go
  25. 486 0
      foodgroup/oservice.go
  26. 258 259
      foodgroup/oservice_test.go
  27. 19 15
      foodgroup/test_helpers.go
  28. 87 0
      foodgroup/types.go
  29. 0 33
      handler/buddy_test.go
  30. 0 125
      handler/chat.go
  31. 0 142
      handler/chat_nav.go
  32. 0 197
      handler/chat_nav_test.go
  33. 0 297
      handler/feedbag.go
  34. 0 255
      handler/icbm.go
  35. 0 171
      handler/locate.go
  36. 0 478
      handler/oservice.go
  37. 0 54
      handler/types.go
  38. 0 16
      oscar/fixtures/blocked_user.sql
  39. 0 36
      server/alert.go
  40. 0 105
      server/alert_test.go
  41. 0 368
      server/auth_mock_test.go
  42. 0 50
      server/bos_router.go
  43. 0 151
      server/bos_router_test.go
  44. 0 124
      server/bos_service_test.go
  45. 0 130
      server/bucp.go
  46. 0 100
      server/bucp_test.go
  47. 0 43
      server/buddy.go
  48. 0 84
      server/buddy_mock_test.go
  49. 0 112
      server/buddy_test.go
  50. 0 50
      server/chat.go
  51. 0 100
      server/chat_mock_test.go
  52. 0 70
      server/chat_nav.go
  53. 0 203
      server/chat_nav_mock_test.go
  54. 0 169
      server/chat_nav_test.go
  55. 0 25
      server/chat_service_router.go
  56. 0 76
      server/chat_service_router_test.go
  57. 0 109
      server/chat_service_test.go
  58. 0 124
      server/chat_test.go
  59. 0 202
      server/connection.go
  60. 0 171
      server/connection_test.go
  61. 0 114
      server/feedbag.go
  62. 0 415
      server/feedbag_mock_test.go
  63. 0 312
      server/feedbag_test.go
  64. 4 3
      server/http/mgmt_api.go
  65. 2 1
      server/http/mgmt_api_test.go
  66. 2 2
      server/http/mock_user_manager_test.go
  67. 0 83
      server/icbm.go
  68. 0 255
      server/icbm_mock_test.go
  69. 0 214
      server/icbm_test.go
  70. 0 81
      server/locate.go
  71. 0 287
      server/locate_mock_test.go
  72. 0 248
      server/locate_test.go
  73. 26 15
      server/oscar/bos.go
  74. 124 0
      server/oscar/bos_test.go
  75. 98 0
      server/oscar/bucp.go
  76. 104 0
      server/oscar/bucp_test.go
  77. 23 27
      server/oscar/chat.go
  78. 106 0
      server/oscar/chat_test.go
  79. 239 0
      server/oscar/connection.go
  80. 173 0
      server/oscar/connection_test.go
  81. 36 0
      server/oscar/handler/alert.go
  82. 45 0
      server/oscar/handler/alert_test.go
  83. 40 0
      server/oscar/handler/buddy.go
  84. 58 0
      server/oscar/handler/buddy_test.go
  85. 47 0
      server/oscar/handler/chat.go
  86. 66 0
      server/oscar/handler/chat_nav.go
  87. 154 0
      server/oscar/handler/chat_nav_test.go
  88. 79 0
      server/oscar/handler/chat_test.go
  89. 127 0
      server/oscar/handler/feedbag.go
  90. 343 0
      server/oscar/handler/feedbag_test.go
  91. 90 0
      server/oscar/handler/icbm.go
  92. 192 0
      server/oscar/handler/icbm_test.go
  93. 88 0
      server/oscar/handler/locate.go
  94. 243 0
      server/oscar/handler/locate_test.go
  95. 84 0
      server/oscar/handler/mock_buddy_test.go
  96. 203 0
      server/oscar/handler/mock_chat_nav_test.go
  97. 100 0
      server/oscar/handler/mock_chat_test.go
  98. 415 0
      server/oscar/handler/mock_feedbag_test.go
  99. 255 0
      server/oscar/handler/mock_icbm_test.go
  100. 287 0
      server/oscar/handler/mock_locate_test.go

+ 46 - 33
.mockery.yaml

@@ -3,64 +3,77 @@ dir: "{{.InterfaceDir}}"
 mockname: "mock{{.InterfaceName}}"
 inpackage: True
 packages:
-  github.com/mk6i/retro-aim-server/server:
+  github.com/mk6i/retro-aim-server/server/oscar:
     interfaces:
-      AuthHandler:
+      AuthService:
         config:
-          filename: "auth_mock_test.go"
-      BuddyHandler:
+          filename: "mock_auth_test.go"
+      Handler:
         config:
-          filename: "buddy_mock_test.go"
-      ChatHandler:
+          filename: "mock_handler_test.go"
+      UserManager:
+        config:
+          filename: "mock_user_manager_test.go"
+      OnlineNotifier:
+        config:
+          filename: "mock_online_notifier_test.go"
+  github.com/mk6i/retro-aim-server/server/http:
+    interfaces:
+      UserManager:
+        config:
+          filename: "mock_user_manager_test.go"
+  github.com/mk6i/retro-aim-server/server/oscar/handler:
+    interfaces:
+      ResponseWriter:
         config:
-          filename: "chat_mock_test.go"
-      ChatNavHandler:
+          filename: "mock_response_writer_test.go"
+      ChatService:
         config:
-          filename: "chat_nav_mock_test.go"
-      FeedbagHandler:
+          filename: "mock_chat_test.go"
+      ChatNavService:
         config:
-          filename: "feedbag_mock_test.go"
-      ICBMHandler:
+          filename: "mock_chat_nav_test.go"
+      FeedbagService:
         config:
-          filename: "icbm_mock_test.go"
-      LocateHandler:
+          filename: "mock_feedbag_test.go"
+      ICBMService:
         config:
-          filename: "locate_mock_test.go"
-      OServiceHandler:
+          filename: "mock_icbm_test.go"
+      OServiceService:
         config:
-          filename: "oservice_mock_test.go"
-      OServiceBOSHandler:
+          filename: "mock_oservice_test.go"
+      OServiceBOSService:
         config:
-          filename: "oservice_bos_mock_test.go"
-      OServiceChatHandler:
+          filename: "mock_oservice_bos_test.go"
+      OServiceChatService:
         config:
-          filename: "oservice_chat_mock_test.go"
-      Router:
+          filename: "mock_oservice_chat_test.go"
+      LocateService:
         config:
-          filename: "router_mock_test.go"
-      UserManager:
+          filename: "mock_locate_test.go"
+      BuddyService:
         config:
-          filename: "user_manager_mock_test.go"
-  github.com/mk6i/retro-aim-server/handler:
+          filename: "mock_buddy_test.go"
+  github.com/mk6i/retro-aim-server/foodgroup:
     interfaces:
       FeedbagManager:
         config:
-          filename: "feedbag_manager_mock_test.go"
+          filename: "mock_feedbag_manager_test.go"
       UserManager:
         config:
-          filename: "user_manager_manager_mock_test.go"
+          filename: "mock_user_manager_manager_test.go"
       ProfileManager:
         config:
-          filename: "profile_manager_mock_test.go"
+          filename: "mock_profile_manager_test.go"
       SessionManager:
         config:
-          filename: "session_manager_mock_test.go"
+          filename: "mock_session_manager_test.go"
       MessageRelayer:
         config:
-          filename: "message_relayer_mock_test.go"
+          filename: "mock_message_relayer_test.go"
       ChatMessageRelayer:
         config:
-          filename: "chat_message_relayer_mock_test.go"
+          filename: "mock_chat_message_relayer_test.go"
       ChatRegistry:
         config:
-          filename: "chat_registry_test.go"
+          filename: "mock_chat_registry_test.go"

+ 2 - 1
cmd/config_generator/main.go

@@ -11,8 +11,9 @@ import (
 	"reflect"
 	"strings"
 
-	"github.com/mitchellh/go-wordwrap"
 	"github.com/mk6i/retro-aim-server/config"
+
+	"github.com/mitchellh/go-wordwrap"
 )
 
 var platformKeywords = map[string]struct {

+ 46 - 43
cmd/server/main.go

@@ -7,11 +7,14 @@ import (
 	"sync"
 
 	"github.com/mk6i/retro-aim-server/config"
-	"github.com/mk6i/retro-aim-server/handler"
+	"github.com/mk6i/retro-aim-server/foodgroup"
+	"github.com/mk6i/retro-aim-server/server/http"
+	"github.com/mk6i/retro-aim-server/server/oscar"
+	"github.com/mk6i/retro-aim-server/server/oscar/handler"
+	"github.com/mk6i/retro-aim-server/server/oscar/middleware"
 	"github.com/mk6i/retro-aim-server/state"
 
 	"github.com/kelseyhightower/envconfig"
-	"github.com/mk6i/retro-aim-server/server"
 )
 
 func main() {
@@ -28,7 +31,7 @@ func main() {
 		os.Exit(1)
 	}
 
-	logger := server.NewLogger(cfg)
+	logger := middleware.NewLogger(cfg)
 	sessionManager := state.NewInMemorySessionManager(logger)
 	chatRegistry := state.NewChatRegistry()
 
@@ -36,63 +39,63 @@ func main() {
 	wg.Add(4)
 
 	go func() {
-		server.StartManagementAPI(feedbagStore, logger)
+		http.StartManagementAPI(feedbagStore, logger)
 		wg.Done()
 	}()
 	go func(logger *slog.Logger) {
 		logger = logger.With("svc", "BOS")
-		authHandler := handler.NewAuthService(cfg, sessionManager, sessionManager, feedbagStore, feedbagStore, chatRegistry)
-		buddyHandler := handler.NewBuddyService()
-		oserviceHandler := handler.NewOServiceService(cfg, sessionManager, feedbagStore)
-		oserviceBOSHandler := handler.NewOServiceServiceForBOS(*oserviceHandler, chatRegistry)
-		locateHandler := handler.NewLocateService(sessionManager, feedbagStore, feedbagStore)
-		newChatSessMgr := func() handler.SessionManager { return state.NewInMemorySessionManager(logger) }
-		chatNavHandler := handler.NewChatNavService(logger, chatRegistry, state.NewChatRoom, newChatSessMgr)
-		feedbagHandler := handler.NewFeedbagService(sessionManager, feedbagStore)
-		icbmHandler := handler.NewICBMService(sessionManager, feedbagStore)
+		authService := foodgroup.NewAuthService(cfg, sessionManager, sessionManager, feedbagStore, feedbagStore, chatRegistry)
+		buddyService := foodgroup.NewBuddyService()
+		oServiceService := foodgroup.NewOServiceService(cfg, sessionManager, feedbagStore)
+		oServiceServiceForBOS := foodgroup.NewOServiceServiceForBOS(*oServiceService, chatRegistry)
+		locateService := foodgroup.NewLocateService(sessionManager, feedbagStore, feedbagStore)
+		newChatSessMgr := func() foodgroup.SessionManager { return state.NewInMemorySessionManager(logger) }
+		chatNavService := foodgroup.NewChatNavService(logger, chatRegistry, state.NewChatRoom, newChatSessMgr)
+		feedbagService := foodgroup.NewFeedbagService(sessionManager, feedbagStore)
+		icbmService := foodgroup.NewICBMService(sessionManager, feedbagStore)
 
-		server.BOSService{
-			AuthHandler:        authHandler,
-			OServiceBOSHandler: oserviceBOSHandler,
-			Config:             cfg,
-			Logger:             logger,
-			Router: server.BOSRouter{
-				AlertRouter:       server.NewAlertRouter(logger),
-				BuddyRouter:       server.NewBuddyRouter(logger, buddyHandler),
-				ChatNavRouter:     server.NewChatNavRouter(chatNavHandler, logger),
-				FeedbagRouter:     server.NewFeedbagRouter(logger, feedbagHandler),
-				ICBMRouter:        server.NewICBMRouter(logger, icbmHandler),
-				LocateRouter:      server.NewLocateRouter(locateHandler, logger),
-				OServiceBOSRouter: server.NewOServiceRouterForBOS(logger, oserviceHandler, oserviceBOSHandler),
-			},
+		oscar.BOSServer{
+			AuthService: authService,
+			Config:      cfg,
+			Handler: handler.NewBOSRouter(handler.Handlers{
+				AlertHandler:       handler.NewAlertHandler(logger),
+				BuddyHandler:       handler.NewBuddyHandler(logger, buddyService),
+				ChatNavHandler:     handler.NewChatNavHandler(chatNavService, logger),
+				FeedbagHandler:     handler.NewFeedbagHandler(logger, feedbagService),
+				ICBMHandler:        handler.NewICBMHandler(logger, icbmService),
+				LocateHandler:      handler.NewLocateHandler(locateService, logger),
+				OServiceBOSHandler: handler.NewOServiceHandlerForBOS(logger, oServiceService, oServiceServiceForBOS),
+			}),
+			Logger:         logger,
+			OnlineNotifier: oServiceServiceForBOS,
 		}.Start()
 		wg.Done()
 	}(logger)
 	go func(logger *slog.Logger) {
 		logger = logger.With("svc", "CHAT")
-		authHandler := handler.NewAuthService(cfg, sessionManager, sessionManager, feedbagStore, feedbagStore, chatRegistry)
-		oserviceHandler := handler.NewOServiceService(cfg, sessionManager, feedbagStore)
-		chatHandler := handler.NewChatService(chatRegistry)
-		oserviceChatHandler := handler.NewOServiceServiceForChat(*oserviceHandler, chatRegistry)
+		authService := foodgroup.NewAuthService(cfg, sessionManager, sessionManager, feedbagStore, feedbagStore, chatRegistry)
+		oServiceService := foodgroup.NewOServiceService(cfg, sessionManager, feedbagStore)
+		chatService := foodgroup.NewChatService(chatRegistry)
+		oServiceServiceForChat := foodgroup.NewOServiceServiceForChat(*oServiceService, chatRegistry)
 
-		server.ChatService{
-			AuthHandler:         authHandler,
-			Config:              cfg,
-			Logger:              logger,
-			OServiceChatHandler: oserviceChatHandler,
-			Router: server.ChatServiceRouter{
-				ChatRouter:         server.NewChatRouter(logger, chatHandler),
-				OServiceChatRouter: server.NewOServiceRouterForChat(logger, oserviceHandler, oserviceChatHandler),
-			},
+		oscar.ChatServer{
+			AuthService: authService,
+			Config:      cfg,
+			Handler: handler.NewChatRouter(handler.Handlers{
+				ChatHandler:         handler.NewChatHandler(logger, chatService),
+				OServiceChatHandler: handler.NewOServiceHandlerForChat(logger, oServiceService, oServiceServiceForChat),
+			}),
+			Logger:         logger,
+			OnlineNotifier: oServiceServiceForChat,
 		}.Start()
 		wg.Done()
 	}(logger)
 	go func(logger *slog.Logger) {
 		logger = logger.With("svc", "AUTH")
-		authHandler := handler.NewAuthService(cfg, sessionManager, nil, feedbagStore, feedbagStore, chatRegistry)
+		authHandler := foodgroup.NewAuthService(cfg, sessionManager, nil, feedbagStore, feedbagStore, chatRegistry)
 
-		server.BUCPAuthService{
-			AuthHandler: authHandler,
+		oscar.BUCPAuthService{
+			AuthService: authHandler,
 			Config:      cfg,
 			Logger:      logger,
 		}.Start()

+ 54 - 49
handler/auth.go → foodgroup/auth.go

@@ -1,14 +1,15 @@
-package handler
+package foodgroup
 
 import (
 	"bytes"
 	"context"
 	"errors"
 
-	"github.com/google/uuid"
 	"github.com/mk6i/retro-aim-server/config"
-	"github.com/mk6i/retro-aim-server/oscar"
 	"github.com/mk6i/retro-aim-server/state"
+	"github.com/mk6i/retro-aim-server/wire"
+
+	"github.com/google/uuid"
 )
 
 // NewAuthService creates a new instance of AuthService.
@@ -35,12 +36,16 @@ type AuthService struct {
 
 // RetrieveChatSession returns a chat room session. Return nil if the session
 // does not exist.
-func (s AuthService) RetrieveChatSession(chatID string, sessionID string) (*state.Session, error) {
-	_, chatSessMgr, err := s.chatRegistry.Retrieve(chatID)
+func (s AuthService) RetrieveChatSession(loginCookie []byte) (*state.Session, error) {
+	c := chatLoginCookie{}
+	if err := wire.Unmarshal(&c, bytes.NewBuffer(loginCookie)); err != nil {
+		return nil, err
+	}
+	_, chatSessMgr, err := s.chatRegistry.Retrieve(c.Cookie)
 	if err != nil {
 		return nil, err
 	}
-	return chatSessMgr.(SessionManager).RetrieveSession(sessionID), nil
+	return chatSessMgr.(SessionManager).RetrieveSession(c.SessID), nil
 }
 
 // RetrieveBOSSession returns a user's session. Return nil if the session does
@@ -61,8 +66,8 @@ func (s AuthService) Signout(ctx context.Context, sess *state.Session) error {
 
 // SignoutChat removes user from chat room and notifies remaining participants
 // of their departure. If user is the last to leave, the chat room is deleted.
-func (s AuthService) SignoutChat(ctx context.Context, sess *state.Session, chatID string) error {
-	chatRoom, chatSessMgr, err := s.chatRegistry.Retrieve(chatID)
+func (s AuthService) SignoutChat(ctx context.Context, sess *state.Session) error {
+	chatRoom, chatSessMgr, err := s.chatRegistry.Retrieve(sess.ChatRoomCookie())
 	if err != nil {
 		return err
 	}
@@ -74,17 +79,17 @@ func (s AuthService) SignoutChat(ctx context.Context, sess *state.Session, chatI
 	return nil
 }
 
-// BUCPChallengeRequestHandler satisfies the client request for a random auth
-// key. It returns SNAC oscar.BUCPChallengeResponse. If the screen name is
+// BUCPChallengeRequest satisfies the client request for a random auth
+// key. It returns SNAC wire.BUCPChallengeResponse. If the screen name is
 // recognized as a valid user, the response contains the account's auth key,
 // which salts the user's MD5 password hash. If the account is invalid, an
-// error code is set in TLV oscar.TLVErrorSubcode. If login credentials are
+// error code is set in TLV wire.TLVErrorSubcode. If login credentials are
 // invalid and app config DisableAuth is true, a stub auth key is generated and
 // a successful challenge response is returned.
-func (s AuthService) BUCPChallengeRequestHandler(bodyIn oscar.SNAC_0x17_0x06_BUCPChallengeRequest, newUUIDFn func() uuid.UUID) (oscar.SNACMessage, error) {
-	screenName, exists := bodyIn.String(oscar.TLVScreenName)
+func (s AuthService) BUCPChallengeRequest(bodyIn wire.SNAC_0x17_0x06_BUCPChallengeRequest, newUUIDFn func() uuid.UUID) (wire.SNACMessage, error) {
+	screenName, exists := bodyIn.String(wire.TLVScreenName)
 	if !exists {
-		return oscar.SNACMessage{}, errors.New("screen name doesn't exist in tlv")
+		return wire.SNACMessage{}, errors.New("screen name doesn't exist in tlv")
 	}
 
 	var authKey string
@@ -92,7 +97,7 @@ func (s AuthService) BUCPChallengeRequestHandler(bodyIn oscar.SNAC_0x17_0x06_BUC
 	u, err := s.userManager.User(screenName)
 	switch {
 	case err != nil:
-		return oscar.SNACMessage{}, err
+		return wire.SNACMessage{}, err
 	case u != nil:
 		// user lookup succeeded
 		authKey = u.AuthKey
@@ -101,47 +106,47 @@ func (s AuthService) BUCPChallengeRequestHandler(bodyIn oscar.SNAC_0x17_0x06_BUC
 		authKey = newUUIDFn().String()
 	default:
 		// can't find user, return login error
-		snacFrameOut := oscar.SNACFrame{
-			FoodGroup: oscar.BUCP,
-			SubGroup:  oscar.BUCPLoginResponse,
+		snacFrameOut := wire.SNACFrame{
+			FoodGroup: wire.BUCP,
+			SubGroup:  wire.BUCPLoginResponse,
 		}
-		snacPayloadOut := oscar.SNAC_0x17_0x03_BUCPLoginResponse{}
-		snacPayloadOut.Append(oscar.NewTLV(oscar.TLVErrorSubcode, uint16(0x01)))
-		return oscar.SNACMessage{
+		snacPayloadOut := wire.SNAC_0x17_0x03_BUCPLoginResponse{}
+		snacPayloadOut.Append(wire.NewTLV(wire.TLVErrorSubcode, uint16(0x01)))
+		return wire.SNACMessage{
 			Frame: snacFrameOut,
 			Body:  snacPayloadOut,
 		}, nil
 	}
 
-	return oscar.SNACMessage{
-		Frame: oscar.SNACFrame{
-			FoodGroup: oscar.BUCP,
-			SubGroup:  oscar.BUCPChallengeResponse,
+	return wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.BUCP,
+			SubGroup:  wire.BUCPChallengeResponse,
 		},
-		Body: oscar.SNAC_0x17_0x07_BUCPChallengeResponse{
+		Body: wire.SNAC_0x17_0x07_BUCPChallengeResponse{
 			AuthKey: authKey,
 		},
 	}, nil
 }
 
-// BUCPLoginRequestHandler verifies user credentials. Upon successful login, a
+// BUCPLoginRequest verifies user credentials. Upon successful login, a
 // session is created.
 // If login credentials are invalid and app config DisableAuth is true, a stub
 // user is created and login continues as normal. DisableAuth allows you to
 // skip the account creation procedure, which simplifies the login flow during
 // development.
 // If login is successful, the SNAC TLV list contains the BOS server address
-// (oscar.TLVReconnectHere) and an authorization cookie
-// (oscar.TLVAuthorizationCookie). Else, an error code is set
-// (oscar.TLVErrorSubcode).
-func (s AuthService) BUCPLoginRequestHandler(bodyIn oscar.SNAC_0x17_0x02_BUCPLoginRequest, newUUIDFn func() uuid.UUID, newUserFn func(screenName string) (state.User, error)) (oscar.SNACMessage, error) {
-	screenName, found := bodyIn.String(oscar.TLVScreenName)
+// (wire.TLVReconnectHere) and an authorization cookie
+// (wire.TLVAuthorizationCookie). Else, an error code is set
+// (wire.TLVErrorSubcode).
+func (s AuthService) BUCPLoginRequest(bodyIn wire.SNAC_0x17_0x02_BUCPLoginRequest, newUUIDFn func() uuid.UUID, newUserFn func(screenName string) (state.User, error)) (wire.SNACMessage, error) {
+	screenName, found := bodyIn.String(wire.TLVScreenName)
 	if !found {
-		return oscar.SNACMessage{}, errors.New("screen name doesn't exist in tlv")
+		return wire.SNACMessage{}, errors.New("screen name doesn't exist in tlv")
 	}
-	md5Hash, found := bodyIn.Slice(oscar.TLVPasswordHash)
+	md5Hash, found := bodyIn.Slice(wire.TLVPasswordHash)
 	if !found {
-		return oscar.SNACMessage{}, errors.New("password hash doesn't exist in tlv")
+		return wire.SNACMessage{}, errors.New("password hash doesn't exist in tlv")
 	}
 
 	loginOK := false
@@ -149,7 +154,7 @@ func (s AuthService) BUCPLoginRequestHandler(bodyIn oscar.SNAC_0x17_0x02_BUCPLog
 	u, err := s.userManager.User(screenName)
 	switch {
 	case err != nil:
-		return oscar.SNACMessage{}, err
+		return wire.SNACMessage{}, err
 	case u != nil && bytes.Equal(u.PassHash, md5Hash):
 		// password check succeeded
 		loginOK = true
@@ -157,33 +162,33 @@ func (s AuthService) BUCPLoginRequestHandler(bodyIn oscar.SNAC_0x17_0x02_BUCPLog
 		// login failed but let them in anyway
 		user, err := newUserFn(screenName)
 		if err != nil {
-			return oscar.SNACMessage{}, err
+			return wire.SNACMessage{}, err
 		}
 		if err := s.userManager.InsertUser(user); err != nil {
-			return oscar.SNACMessage{}, err
+			return wire.SNACMessage{}, err
 		}
 		loginOK = true
 	}
 
-	snacPayloadOut := oscar.SNAC_0x17_0x03_BUCPLoginResponse{}
-	snacPayloadOut.Append(oscar.NewTLV(oscar.TLVScreenName, screenName))
+	snacPayloadOut := wire.SNAC_0x17_0x03_BUCPLoginResponse{}
+	snacPayloadOut.Append(wire.NewTLV(wire.TLVScreenName, screenName))
 
 	if loginOK {
 		sess := s.sessionManager.AddSession(newUUIDFn().String(), screenName)
-		snacPayloadOut.AppendList([]oscar.TLV{
-			oscar.NewTLV(oscar.TLVReconnectHere, config.Address(s.config.OSCARHost, s.config.BOSPort)),
-			oscar.NewTLV(oscar.TLVAuthorizationCookie, sess.ID()),
+		snacPayloadOut.AppendList([]wire.TLV{
+			wire.NewTLV(wire.TLVReconnectHere, config.Address(s.config.OSCARHost, s.config.BOSPort)),
+			wire.NewTLV(wire.TLVAuthorizationCookie, sess.ID()),
 		})
 	} else {
-		snacPayloadOut.AppendList([]oscar.TLV{
-			oscar.NewTLV(oscar.TLVErrorSubcode, uint16(0x01)),
+		snacPayloadOut.AppendList([]wire.TLV{
+			wire.NewTLV(wire.TLVErrorSubcode, uint16(0x01)),
 		})
 	}
 
-	return oscar.SNACMessage{
-		Frame: oscar.SNACFrame{
-			FoodGroup: oscar.BUCP,
-			SubGroup:  oscar.BUCPLoginResponse,
+	return wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.BUCP,
+			SubGroup:  wire.BUCPLoginResponse,
 		},
 		Body: snacPayloadOut,
 	}, nil

+ 171 - 148
handler/auth_test.go → foodgroup/auth_test.go

@@ -1,18 +1,20 @@
-package handler
+package foodgroup
 
 import (
+	"bytes"
 	"io"
 	"testing"
 
-	"github.com/google/uuid"
 	"github.com/mk6i/retro-aim-server/config"
-	"github.com/mk6i/retro-aim-server/oscar"
 	"github.com/mk6i/retro-aim-server/state"
+	"github.com/mk6i/retro-aim-server/wire"
+
+	"github.com/google/uuid"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/mock"
 )
 
-func TestAuthService_BUCPLoginRequestHandler(t *testing.T) {
+func TestAuthService_BUCPLoginRequest(t *testing.T) {
 	sessUUID := uuid.UUID{1, 2, 3}
 	user := state.User{
 		ScreenName: "screen_name",
@@ -27,14 +29,14 @@ func TestAuthService_BUCPLoginRequestHandler(t *testing.T) {
 		// cfg is the app configuration
 		cfg config.Config
 		// inputSNAC is the SNAC sent from the client to the server
-		inputSNAC oscar.SNAC_0x17_0x02_BUCPLoginRequest
+		inputSNAC wire.SNAC_0x17_0x02_BUCPLoginRequest
 		// mockParams is the list of params sent to mocks that satisfy this
 		// method's dependencies
 		mockParams mockParams
 		// newUserFn is the function that registers a new user account
 		newUserFn func(screenName string) (state.User, error)
 		// expectOutput is the SNAC sent from the server to client
-		expectOutput oscar.SNACMessage
+		expectOutput wire.SNACMessage
 		// wantErr is the error we expect from the method
 		wantErr error
 	}{
@@ -44,11 +46,11 @@ func TestAuthService_BUCPLoginRequestHandler(t *testing.T) {
 				OSCARHost: "127.0.0.1",
 				BOSPort:   1234,
 			},
-			inputSNAC: oscar.SNAC_0x17_0x02_BUCPLoginRequest{
-				TLVRestBlock: oscar.TLVRestBlock{
-					TLVList: oscar.TLVList{
-						oscar.NewTLV(oscar.TLVPasswordHash, user.PassHash),
-						oscar.NewTLV(oscar.TLVScreenName, user.ScreenName),
+			inputSNAC: wire.SNAC_0x17_0x02_BUCPLoginRequest{
+				TLVRestBlock: wire.TLVRestBlock{
+					TLVList: wire.TLVList{
+						wire.NewTLV(wire.TLVPasswordHash, user.PassHash),
+						wire.NewTLV(wire.TLVScreenName, user.ScreenName),
 					},
 				},
 			},
@@ -71,17 +73,17 @@ func TestAuthService_BUCPLoginRequestHandler(t *testing.T) {
 					},
 				},
 			},
-			expectOutput: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.BUCP,
-					SubGroup:  oscar.BUCPLoginResponse,
+			expectOutput: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.BUCP,
+					SubGroup:  wire.BUCPLoginResponse,
 				},
-				Body: oscar.SNAC_0x17_0x03_BUCPLoginResponse{
-					TLVRestBlock: oscar.TLVRestBlock{
-						TLVList: oscar.TLVList{
-							oscar.NewTLV(oscar.TLVScreenName, user.ScreenName),
-							oscar.NewTLV(oscar.TLVReconnectHere, "127.0.0.1:1234"),
-							oscar.NewTLV(oscar.TLVAuthorizationCookie, sessUUID.String()),
+				Body: wire.SNAC_0x17_0x03_BUCPLoginResponse{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLV(wire.TLVScreenName, user.ScreenName),
+							wire.NewTLV(wire.TLVReconnectHere, "127.0.0.1:1234"),
+							wire.NewTLV(wire.TLVAuthorizationCookie, sessUUID.String()),
 						},
 					},
 				},
@@ -94,11 +96,11 @@ func TestAuthService_BUCPLoginRequestHandler(t *testing.T) {
 				BOSPort:     1234,
 				DisableAuth: true,
 			},
-			inputSNAC: oscar.SNAC_0x17_0x02_BUCPLoginRequest{
-				TLVRestBlock: oscar.TLVRestBlock{
-					TLVList: oscar.TLVList{
-						oscar.NewTLV(oscar.TLVPasswordHash, user.PassHash),
-						oscar.NewTLV(oscar.TLVScreenName, user.ScreenName),
+			inputSNAC: wire.SNAC_0x17_0x02_BUCPLoginRequest{
+				TLVRestBlock: wire.TLVRestBlock{
+					TLVList: wire.TLVList{
+						wire.NewTLV(wire.TLVPasswordHash, user.PassHash),
+						wire.NewTLV(wire.TLVScreenName, user.ScreenName),
 					},
 				},
 			},
@@ -129,17 +131,17 @@ func TestAuthService_BUCPLoginRequestHandler(t *testing.T) {
 			newUserFn: func(screenName string) (state.User, error) {
 				return user, nil
 			},
-			expectOutput: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.BUCP,
-					SubGroup:  oscar.BUCPLoginResponse,
+			expectOutput: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.BUCP,
+					SubGroup:  wire.BUCPLoginResponse,
 				},
-				Body: oscar.SNAC_0x17_0x03_BUCPLoginResponse{
-					TLVRestBlock: oscar.TLVRestBlock{
-						TLVList: oscar.TLVList{
-							oscar.NewTLV(oscar.TLVScreenName, user.ScreenName),
-							oscar.NewTLV(oscar.TLVReconnectHere, "127.0.0.1:1234"),
-							oscar.NewTLV(oscar.TLVAuthorizationCookie, sessUUID.String()),
+				Body: wire.SNAC_0x17_0x03_BUCPLoginResponse{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLV(wire.TLVScreenName, user.ScreenName),
+							wire.NewTLV(wire.TLVReconnectHere, "127.0.0.1:1234"),
+							wire.NewTLV(wire.TLVAuthorizationCookie, sessUUID.String()),
 						},
 					},
 				},
@@ -152,11 +154,11 @@ func TestAuthService_BUCPLoginRequestHandler(t *testing.T) {
 				BOSPort:     1234,
 				DisableAuth: true,
 			},
-			inputSNAC: oscar.SNAC_0x17_0x02_BUCPLoginRequest{
-				TLVRestBlock: oscar.TLVRestBlock{
-					TLVList: oscar.TLVList{
-						oscar.NewTLV(oscar.TLVPasswordHash, []byte("bad-password-hash")),
-						oscar.NewTLV(oscar.TLVScreenName, user.ScreenName),
+			inputSNAC: wire.SNAC_0x17_0x02_BUCPLoginRequest{
+				TLVRestBlock: wire.TLVRestBlock{
+					TLVList: wire.TLVList{
+						wire.NewTLV(wire.TLVPasswordHash, []byte("bad-password-hash")),
+						wire.NewTLV(wire.TLVScreenName, user.ScreenName),
 					},
 				},
 			},
@@ -187,17 +189,17 @@ func TestAuthService_BUCPLoginRequestHandler(t *testing.T) {
 			newUserFn: func(screenName string) (state.User, error) {
 				return user, nil
 			},
-			expectOutput: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.BUCP,
-					SubGroup:  oscar.BUCPLoginResponse,
+			expectOutput: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.BUCP,
+					SubGroup:  wire.BUCPLoginResponse,
 				},
-				Body: oscar.SNAC_0x17_0x03_BUCPLoginResponse{
-					TLVRestBlock: oscar.TLVRestBlock{
-						TLVList: oscar.TLVList{
-							oscar.NewTLV(oscar.TLVScreenName, user.ScreenName),
-							oscar.NewTLV(oscar.TLVReconnectHere, "127.0.0.1:1234"),
-							oscar.NewTLV(oscar.TLVAuthorizationCookie, sessUUID.String()),
+				Body: wire.SNAC_0x17_0x03_BUCPLoginResponse{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLV(wire.TLVScreenName, user.ScreenName),
+							wire.NewTLV(wire.TLVReconnectHere, "127.0.0.1:1234"),
+							wire.NewTLV(wire.TLVAuthorizationCookie, sessUUID.String()),
 						},
 					},
 				},
@@ -208,11 +210,11 @@ func TestAuthService_BUCPLoginRequestHandler(t *testing.T) {
 			cfg: config.Config{
 				DisableAuth: true,
 			},
-			inputSNAC: oscar.SNAC_0x17_0x02_BUCPLoginRequest{
-				TLVRestBlock: oscar.TLVRestBlock{
-					TLVList: oscar.TLVList{
-						oscar.NewTLV(oscar.TLVPasswordHash, []byte("bad-password-hash")),
-						oscar.NewTLV(oscar.TLVScreenName, user.ScreenName),
+			inputSNAC: wire.SNAC_0x17_0x02_BUCPLoginRequest{
+				TLVRestBlock: wire.TLVRestBlock{
+					TLVList: wire.TLVList{
+						wire.NewTLV(wire.TLVPasswordHash, []byte("bad-password-hash")),
+						wire.NewTLV(wire.TLVScreenName, user.ScreenName),
 					},
 				},
 			},
@@ -236,11 +238,11 @@ func TestAuthService_BUCPLoginRequestHandler(t *testing.T) {
 			cfg: config.Config{
 				DisableAuth: true,
 			},
-			inputSNAC: oscar.SNAC_0x17_0x02_BUCPLoginRequest{
-				TLVRestBlock: oscar.TLVRestBlock{
-					TLVList: oscar.TLVList{
-						oscar.NewTLV(oscar.TLVPasswordHash, []byte("bad-password-hash")),
-						oscar.NewTLV(oscar.TLVScreenName, user.ScreenName),
+			inputSNAC: wire.SNAC_0x17_0x02_BUCPLoginRequest{
+				TLVRestBlock: wire.TLVRestBlock{
+					TLVList: wire.TLVList{
+						wire.NewTLV(wire.TLVPasswordHash, []byte("bad-password-hash")),
+						wire.NewTLV(wire.TLVScreenName, user.ScreenName),
 					},
 				},
 			},
@@ -271,11 +273,11 @@ func TestAuthService_BUCPLoginRequestHandler(t *testing.T) {
 				OSCARHost: "127.0.0.1",
 				BOSPort:   1234,
 			},
-			inputSNAC: oscar.SNAC_0x17_0x02_BUCPLoginRequest{
-				TLVRestBlock: oscar.TLVRestBlock{
-					TLVList: oscar.TLVList{
-						oscar.NewTLV(oscar.TLVPasswordHash, []byte("bad_password")),
-						oscar.NewTLV(oscar.TLVScreenName, user.ScreenName),
+			inputSNAC: wire.SNAC_0x17_0x02_BUCPLoginRequest{
+				TLVRestBlock: wire.TLVRestBlock{
+					TLVList: wire.TLVList{
+						wire.NewTLV(wire.TLVPasswordHash, []byte("bad_password")),
+						wire.NewTLV(wire.TLVScreenName, user.ScreenName),
 					},
 				},
 			},
@@ -289,16 +291,16 @@ func TestAuthService_BUCPLoginRequestHandler(t *testing.T) {
 					},
 				},
 			},
-			expectOutput: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.BUCP,
-					SubGroup:  oscar.BUCPLoginResponse,
+			expectOutput: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.BUCP,
+					SubGroup:  wire.BUCPLoginResponse,
 				},
-				Body: oscar.SNAC_0x17_0x03_BUCPLoginResponse{
-					TLVRestBlock: oscar.TLVRestBlock{
-						TLVList: oscar.TLVList{
-							oscar.NewTLV(oscar.TLVScreenName, user.ScreenName),
-							oscar.NewTLV(oscar.TLVErrorSubcode, uint16(0x01)),
+				Body: wire.SNAC_0x17_0x03_BUCPLoginResponse{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLV(wire.TLVScreenName, user.ScreenName),
+							wire.NewTLV(wire.TLVErrorSubcode, uint16(0x01)),
 						},
 					},
 				},
@@ -306,11 +308,11 @@ func TestAuthService_BUCPLoginRequestHandler(t *testing.T) {
 		},
 		{
 			name: "login fails on user manager lookup",
-			inputSNAC: oscar.SNAC_0x17_0x02_BUCPLoginRequest{
-				TLVRestBlock: oscar.TLVRestBlock{
-					TLVList: oscar.TLVList{
-						oscar.NewTLV(oscar.TLVPasswordHash, user.PassHash),
-						oscar.NewTLV(oscar.TLVScreenName, user.ScreenName),
+			inputSNAC: wire.SNAC_0x17_0x02_BUCPLoginRequest{
+				TLVRestBlock: wire.TLVRestBlock{
+					TLVList: wire.TLVList{
+						wire.NewTLV(wire.TLVPasswordHash, user.PassHash),
+						wire.NewTLV(wire.TLVScreenName, user.ScreenName),
 					},
 				},
 			},
@@ -355,14 +357,14 @@ func TestAuthService_BUCPLoginRequestHandler(t *testing.T) {
 			fnNewUUID := func() uuid.UUID {
 				return sessUUID
 			}
-			outputSNAC, err := svc.BUCPLoginRequestHandler(tc.inputSNAC, fnNewUUID, tc.newUserFn)
+			outputSNAC, err := svc.BUCPLoginRequest(tc.inputSNAC, fnNewUUID, tc.newUserFn)
 			assert.ErrorIs(t, err, tc.wantErr)
 			assert.Equal(t, tc.expectOutput, outputSNAC)
 		})
 	}
 }
 
-func TestAuthService_BUCPChallengeRequestHandler(t *testing.T) {
+func TestAuthService_BUCPChallengeRequest(t *testing.T) {
 	sessUUID := uuid.UUID{1, 2, 3}
 	cases := []struct {
 		// name is the unit test name
@@ -370,12 +372,12 @@ func TestAuthService_BUCPChallengeRequestHandler(t *testing.T) {
 		// cfg is the app configuration
 		cfg config.Config
 		// inputSNAC is the SNAC sent from the client to the server
-		inputSNAC oscar.SNAC_0x17_0x06_BUCPChallengeRequest
+		inputSNAC wire.SNAC_0x17_0x06_BUCPChallengeRequest
 		// mockParams is the list of params sent to mocks that satisfy this
 		// method's dependencies
 		mockParams mockParams
 		// expectOutput is the SNAC sent from the server to client
-		expectOutput oscar.SNACMessage
+		expectOutput wire.SNACMessage
 		// wantErr is the error we expect from the method
 		wantErr error
 	}{
@@ -385,10 +387,10 @@ func TestAuthService_BUCPChallengeRequestHandler(t *testing.T) {
 				OSCARHost: "127.0.0.1",
 				BOSPort:   1234,
 			},
-			inputSNAC: oscar.SNAC_0x17_0x06_BUCPChallengeRequest{
-				TLVRestBlock: oscar.TLVRestBlock{
-					TLVList: oscar.TLVList{
-						oscar.NewTLV(oscar.TLVScreenName, "sn_user_a"),
+			inputSNAC: wire.SNAC_0x17_0x06_BUCPChallengeRequest{
+				TLVRestBlock: wire.TLVRestBlock{
+					TLVList: wire.TLVList{
+						wire.NewTLV(wire.TLVScreenName, "sn_user_a"),
 					},
 				},
 			},
@@ -405,12 +407,12 @@ func TestAuthService_BUCPChallengeRequestHandler(t *testing.T) {
 					},
 				},
 			},
-			expectOutput: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.BUCP,
-					SubGroup:  oscar.BUCPChallengeResponse,
+			expectOutput: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.BUCP,
+					SubGroup:  wire.BUCPChallengeResponse,
 				},
-				Body: oscar.SNAC_0x17_0x07_BUCPChallengeResponse{
+				Body: wire.SNAC_0x17_0x07_BUCPChallengeResponse{
 					AuthKey: "auth_key_user_a",
 				},
 			},
@@ -422,10 +424,10 @@ func TestAuthService_BUCPChallengeRequestHandler(t *testing.T) {
 				BOSPort:     1234,
 				DisableAuth: true,
 			},
-			inputSNAC: oscar.SNAC_0x17_0x06_BUCPChallengeRequest{
-				TLVRestBlock: oscar.TLVRestBlock{
-					TLVList: oscar.TLVList{
-						oscar.NewTLV(oscar.TLVScreenName, "sn_user_b"),
+			inputSNAC: wire.SNAC_0x17_0x06_BUCPChallengeRequest{
+				TLVRestBlock: wire.TLVRestBlock{
+					TLVList: wire.TLVList{
+						wire.NewTLV(wire.TLVScreenName, "sn_user_b"),
 					},
 				},
 			},
@@ -439,12 +441,12 @@ func TestAuthService_BUCPChallengeRequestHandler(t *testing.T) {
 					},
 				},
 			},
-			expectOutput: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.BUCP,
-					SubGroup:  oscar.BUCPChallengeResponse,
+			expectOutput: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.BUCP,
+					SubGroup:  wire.BUCPChallengeResponse,
 				},
-				Body: oscar.SNAC_0x17_0x07_BUCPChallengeResponse{
+				Body: wire.SNAC_0x17_0x07_BUCPChallengeResponse{
 					AuthKey: sessUUID.String(),
 				},
 			},
@@ -455,10 +457,10 @@ func TestAuthService_BUCPChallengeRequestHandler(t *testing.T) {
 				OSCARHost: "127.0.0.1",
 				BOSPort:   1234,
 			},
-			inputSNAC: oscar.SNAC_0x17_0x06_BUCPChallengeRequest{
-				TLVRestBlock: oscar.TLVRestBlock{
-					TLVList: oscar.TLVList{
-						oscar.NewTLV(oscar.TLVScreenName, "sn_user_b"),
+			inputSNAC: wire.SNAC_0x17_0x06_BUCPChallengeRequest{
+				TLVRestBlock: wire.TLVRestBlock{
+					TLVList: wire.TLVList{
+						wire.NewTLV(wire.TLVScreenName, "sn_user_b"),
 					},
 				},
 			},
@@ -472,15 +474,15 @@ func TestAuthService_BUCPChallengeRequestHandler(t *testing.T) {
 					},
 				},
 			},
-			expectOutput: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.BUCP,
-					SubGroup:  oscar.BUCPLoginResponse,
+			expectOutput: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.BUCP,
+					SubGroup:  wire.BUCPLoginResponse,
 				},
-				Body: oscar.SNAC_0x17_0x03_BUCPLoginResponse{
-					TLVRestBlock: oscar.TLVRestBlock{
-						TLVList: oscar.TLVList{
-							oscar.NewTLV(oscar.TLVErrorSubcode, uint16(0x01)),
+				Body: wire.SNAC_0x17_0x03_BUCPLoginResponse{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLV(wire.TLVErrorSubcode, uint16(0x01)),
 						},
 					},
 				},
@@ -488,10 +490,10 @@ func TestAuthService_BUCPChallengeRequestHandler(t *testing.T) {
 		},
 		{
 			name: "login fails on user manager lookup",
-			inputSNAC: oscar.SNAC_0x17_0x06_BUCPChallengeRequest{
-				TLVRestBlock: oscar.TLVRestBlock{
-					TLVList: oscar.TLVList{
-						oscar.NewTLV(oscar.TLVScreenName, "sn_user_b"),
+			inputSNAC: wire.SNAC_0x17_0x06_BUCPChallengeRequest{
+				TLVRestBlock: wire.TLVRestBlock{
+					TLVList: wire.TLVList{
+						wire.NewTLV(wire.TLVScreenName, "sn_user_b"),
 					},
 				},
 			},
@@ -524,7 +526,7 @@ func TestAuthService_BUCPChallengeRequestHandler(t *testing.T) {
 			fnNewUUID := func() uuid.UUID {
 				return sessUUID
 			}
-			outputSNAC, err := svc.BUCPChallengeRequestHandler(tc.inputSNAC, fnNewUUID)
+			outputSNAC, err := svc.BUCPChallengeRequest(tc.inputSNAC, fnNewUUID)
 			assert.ErrorIs(t, err, tc.wantErr)
 			assert.Equal(t, tc.expectOutput, outputSNAC)
 		})
@@ -532,9 +534,16 @@ func TestAuthService_BUCPChallengeRequestHandler(t *testing.T) {
 }
 
 func TestAuthService_RetrieveChatSession_HappyPath(t *testing.T) {
-	chatID := "chat-1234"
+	cookie := "chat-1234"
 	sess := newTestSession("screen-name", sessOptCannedID)
 
+	c := chatLoginCookie{
+		Cookie: cookie,
+		SessID: sess.ID(),
+	}
+	buf := &bytes.Buffer{}
+	assert.NoError(t, wire.Marshal(c, buf))
+
 	sessionManager := newMockSessionManager(t)
 	sessionManager.EXPECT().
 		RetrieveSession(sess.ID()).
@@ -542,35 +551,49 @@ func TestAuthService_RetrieveChatSession_HappyPath(t *testing.T) {
 
 	chatRegistry := newMockChatRegistry(t)
 	chatRegistry.EXPECT().
-		Retrieve(chatID).
+		Retrieve(cookie).
 		Return(state.ChatRoom{}, sessionManager, nil)
 
 	svc := NewAuthService(config.Config{}, nil, nil, nil, nil, chatRegistry)
 
-	have, err := svc.RetrieveChatSession(chatID, sess.ID())
+	have, err := svc.RetrieveChatSession(buf.Bytes())
 	assert.NoError(t, err)
 	assert.Equal(t, sess, have)
 }
 
 func TestAuthService_RetrieveChatSession_ChatNotFound(t *testing.T) {
-	chatID := "chat-1234"
+	cookie := "chat-1234"
 	sess := newTestSession("screen-name", sessOptCannedID)
 
+	c := chatLoginCookie{
+		Cookie: cookie,
+		SessID: sess.ID(),
+	}
+	loginCookie := &bytes.Buffer{}
+	assert.NoError(t, wire.Marshal(c, loginCookie))
+
 	chatRegistry := newMockChatRegistry(t)
 	chatRegistry.EXPECT().
-		Retrieve(chatID).
+		Retrieve(cookie).
 		Return(state.ChatRoom{}, nil, state.ErrChatRoomNotFound)
 
 	svc := NewAuthService(config.Config{}, nil, nil, nil, nil, chatRegistry)
 
-	_, err := svc.RetrieveChatSession(chatID, sess.ID())
+	_, err := svc.RetrieveChatSession(loginCookie.Bytes())
 	assert.ErrorIs(t, err, state.ErrChatRoomNotFound)
 }
 
 func TestAuthService_RetrieveChatSession_SessionNotFound(t *testing.T) {
-	chatID := "chat-1234"
+	cookie := "chat-1234"
 	sess := newTestSession("screen-name", sessOptCannedID)
 
+	c := chatLoginCookie{
+		Cookie: cookie,
+		SessID: sess.ID(),
+	}
+	buf := &bytes.Buffer{}
+	assert.NoError(t, wire.Marshal(c, buf))
+
 	sessionManager := newMockSessionManager(t)
 	sessionManager.EXPECT().
 		RetrieveSession(sess.ID()).
@@ -578,12 +601,12 @@ func TestAuthService_RetrieveChatSession_SessionNotFound(t *testing.T) {
 
 	chatRegistry := newMockChatRegistry(t)
 	chatRegistry.EXPECT().
-		Retrieve(chatID).
+		Retrieve(cookie).
 		Return(state.ChatRoom{}, sessionManager, nil)
 
 	svc := NewAuthService(config.Config{}, nil, nil, nil, nil, chatRegistry)
 
-	have, err := svc.RetrieveChatSession(chatID, sess.ID())
+	have, err := svc.RetrieveChatSession(buf.Bytes())
 	assert.NoError(t, err)
 	assert.Nil(t, have)
 }
@@ -619,7 +642,7 @@ func TestAuthService_RetrieveBOSSession_SessionNotFound(t *testing.T) {
 }
 
 func TestAuthService_SignoutChat(t *testing.T) {
-	sess := newTestSession("", sessOptCannedSignonTime)
+	sess := newTestSession("", sessOptCannedSignonTime, sessOptChatRoomCookie("the-chat-cookie"))
 
 	tests := []struct {
 		// name is the unit test name
@@ -645,13 +668,13 @@ func TestAuthService_SignoutChat(t *testing.T) {
 					broadcastExceptParams: broadcastExceptParams{
 						{
 							except: sess,
-							message: oscar.SNACMessage{
-								Frame: oscar.SNACFrame{
-									FoodGroup: oscar.Chat,
-									SubGroup:  oscar.ChatUsersLeft,
+							message: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.Chat,
+									SubGroup:  wire.ChatUsersLeft,
 								},
-								Body: oscar.SNAC_0x0E_0x04_ChatUsersLeft{
-									Users: []oscar.TLVUserInfo{
+								Body: wire.SNAC_0x0E_0x04_ChatUsersLeft{
+									Users: []wire.TLVUserInfo{
 										sess.TLVUserInfo(),
 									},
 								},
@@ -684,13 +707,13 @@ func TestAuthService_SignoutChat(t *testing.T) {
 					broadcastExceptParams: broadcastExceptParams{
 						{
 							except: sess,
-							message: oscar.SNACMessage{
-								Frame: oscar.SNACFrame{
-									FoodGroup: oscar.Chat,
-									SubGroup:  oscar.ChatUsersLeft,
+							message: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.Chat,
+									SubGroup:  wire.ChatUsersLeft,
 								},
-								Body: oscar.SNAC_0x0E_0x04_ChatUsersLeft{
-									Users: []oscar.TLVUserInfo{
+								Body: wire.SNAC_0x0E_0x04_ChatUsersLeft{
+									Users: []wire.TLVUserInfo{
 										sess.TLVUserInfo(),
 									},
 								},
@@ -753,7 +776,7 @@ func TestAuthService_SignoutChat(t *testing.T) {
 
 			svc := NewAuthService(config.Config{}, nil, nil, nil, nil, chatRegistry)
 
-			err := svc.SignoutChat(nil, tt.userSession, tt.chatRoom.Cookie)
+			err := svc.SignoutChat(nil, tt.userSession)
 			assert.ErrorIs(t, err, tt.wantErr)
 		})
 	}
@@ -801,13 +824,13 @@ func TestAuthService_Signout(t *testing.T) {
 					broadcastToScreenNamesParams: broadcastToScreenNamesParams{
 						{
 							screenNames: []string{"friend1", "friend2"},
-							message: oscar.SNACMessage{
-								Frame: oscar.SNACFrame{
-									FoodGroup: oscar.Buddy,
-									SubGroup:  oscar.BuddyDeparted,
+							message: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.Buddy,
+									SubGroup:  wire.BuddyDeparted,
 								},
-								Body: oscar.SNAC_0x03_0x0C_BuddyDeparted{
-									TLVUserInfo: oscar.TLVUserInfo{
+								Body: wire.SNAC_0x03_0x0C_BuddyDeparted{
+									TLVUserInfo: wire.TLVUserInfo{
 										ScreenName:   sess.ScreenName(),
 										WarningLevel: sess.Warning(),
 									},

+ 38 - 38
handler/buddy.go → foodgroup/buddy.go

@@ -1,10 +1,10 @@
-package handler
+package foodgroup
 
 import (
 	"context"
 
-	"github.com/mk6i/retro-aim-server/oscar"
 	"github.com/mk6i/retro-aim-server/state"
+	"github.com/mk6i/retro-aim-server/wire"
 )
 
 // NewBuddyService creates a new instance of BuddyService.
@@ -12,7 +12,7 @@ func NewBuddyService() *BuddyService {
 	return &BuddyService{}
 }
 
-// BuddyService provides functionality for the buddy food group, which sends
+// BuddyService provides functionality for the Buddy food group, which sends
 // clients notifications about the state of users on their buddy list. The food
 // group is used by old versions of AIM not currently supported by Retro Aim
 // Server. BuddyService just exists to satisfy AIM 5.x's buddy rights requests.
@@ -20,21 +20,21 @@ func NewBuddyService() *BuddyService {
 type BuddyService struct {
 }
 
-// RightsQueryHandler returns buddy list service parameters.
-func (s BuddyService) RightsQueryHandler(_ context.Context, frameIn oscar.SNACFrame) oscar.SNACMessage {
-	return oscar.SNACMessage{
-		Frame: oscar.SNACFrame{
-			FoodGroup: oscar.Buddy,
-			SubGroup:  oscar.BuddyRightsReply,
+// RightsQuery returns buddy list service parameters.
+func (s BuddyService) RightsQuery(_ context.Context, frameIn wire.SNACFrame) wire.SNACMessage {
+	return wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Buddy,
+			SubGroup:  wire.BuddyRightsReply,
 			RequestID: frameIn.RequestID,
 		},
-		Body: oscar.SNAC_0x03_0x03_BuddyRightsReply{
-			TLVRestBlock: oscar.TLVRestBlock{
-				TLVList: oscar.TLVList{
-					oscar.NewTLV(oscar.BuddyTLVTagsParmMaxBuddies, uint16(100)),
-					oscar.NewTLV(oscar.BuddyTLVTagsParmMaxWatchers, uint16(100)),
-					oscar.NewTLV(oscar.BuddyTLVTagsParmMaxIcqBroad, uint16(100)),
-					oscar.NewTLV(oscar.BuddyTLVTagsParmMaxTempBuddies, uint16(100)),
+		Body: wire.SNAC_0x03_0x03_BuddyRightsReply{
+			TLVRestBlock: wire.TLVRestBlock{
+				TLVList: wire.TLVList{
+					wire.NewTLV(wire.BuddyTLVTagsParmMaxBuddies, uint16(100)),
+					wire.NewTLV(wire.BuddyTLVTagsParmMaxWatchers, uint16(100)),
+					wire.NewTLV(wire.BuddyTLVTagsParmMaxIcqBroad, uint16(100)),
+					wire.NewTLV(wire.BuddyTLVTagsParmMaxTempBuddies, uint16(100)),
 				},
 			},
 		},
@@ -47,12 +47,12 @@ func broadcastArrival(ctx context.Context, sess *state.Session, messageRelayer M
 		return err
 	}
 
-	messageRelayer.RelayToScreenNames(ctx, screenNames, oscar.SNACMessage{
-		Frame: oscar.SNACFrame{
-			FoodGroup: oscar.Buddy,
-			SubGroup:  oscar.BuddyArrived,
+	messageRelayer.RelayToScreenNames(ctx, screenNames, wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Buddy,
+			SubGroup:  wire.BuddyArrived,
 		},
-		Body: oscar.SNAC_0x03_0x0B_BuddyArrived{
+		Body: wire.SNAC_0x03_0x0B_BuddyArrived{
 			TLVUserInfo: sess.TLVUserInfo(),
 		},
 	})
@@ -66,13 +66,13 @@ func broadcastDeparture(ctx context.Context, sess *state.Session, messageRelayer
 		return err
 	}
 
-	messageRelayer.RelayToScreenNames(ctx, screenNames, oscar.SNACMessage{
-		Frame: oscar.SNACFrame{
-			FoodGroup: oscar.Buddy,
-			SubGroup:  oscar.BuddyDeparted,
+	messageRelayer.RelayToScreenNames(ctx, screenNames, wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Buddy,
+			SubGroup:  wire.BuddyDeparted,
 		},
-		Body: oscar.SNAC_0x03_0x0C_BuddyDeparted{
-			TLVUserInfo: oscar.TLVUserInfo{
+		Body: wire.SNAC_0x03_0x0C_BuddyDeparted{
+			TLVUserInfo: wire.TLVUserInfo{
 				// don't include the TLV block, otherwise the AIM client fails
 				// to process the block event
 				ScreenName:   sess.ScreenName(),
@@ -85,25 +85,25 @@ func broadcastDeparture(ctx context.Context, sess *state.Session, messageRelayer
 }
 
 func unicastArrival(ctx context.Context, from *state.Session, to *state.Session, messageRelayer MessageRelayer) {
-	messageRelayer.RelayToScreenName(ctx, to.ScreenName(), oscar.SNACMessage{
-		Frame: oscar.SNACFrame{
-			FoodGroup: oscar.Buddy,
-			SubGroup:  oscar.BuddyArrived,
+	messageRelayer.RelayToScreenName(ctx, to.ScreenName(), wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Buddy,
+			SubGroup:  wire.BuddyArrived,
 		},
-		Body: oscar.SNAC_0x03_0x0B_BuddyArrived{
+		Body: wire.SNAC_0x03_0x0B_BuddyArrived{
 			TLVUserInfo: from.TLVUserInfo(),
 		},
 	})
 }
 
 func unicastDeparture(ctx context.Context, from *state.Session, to *state.Session, messageRelayer MessageRelayer) {
-	messageRelayer.RelayToScreenName(ctx, to.ScreenName(), oscar.SNACMessage{
-		Frame: oscar.SNACFrame{
-			FoodGroup: oscar.Buddy,
-			SubGroup:  oscar.BuddyDeparted,
+	messageRelayer.RelayToScreenName(ctx, to.ScreenName(), wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Buddy,
+			SubGroup:  wire.BuddyDeparted,
 		},
-		Body: oscar.SNAC_0x03_0x0C_BuddyDeparted{
-			TLVUserInfo: oscar.TLVUserInfo{
+		Body: wire.SNAC_0x03_0x0C_BuddyDeparted{
+			TLVUserInfo: wire.TLVUserInfo{
 				// don't include the TLV block, otherwise the AIM client fails
 				// to process the block event
 				ScreenName:   from.ScreenName(),

+ 34 - 0
foodgroup/buddy_test.go

@@ -0,0 +1,34 @@
+package foodgroup
+
+import (
+	"testing"
+
+	"github.com/mk6i/retro-aim-server/wire"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestBuddyService_RightsQuery(t *testing.T) {
+	svc := NewBuddyService()
+
+	want := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Buddy,
+			SubGroup:  wire.BuddyRightsReply,
+			RequestID: 1234,
+		},
+		Body: wire.SNAC_0x03_0x03_BuddyRightsReply{
+			TLVRestBlock: wire.TLVRestBlock{
+				TLVList: wire.TLVList{
+					wire.NewTLV(wire.BuddyTLVTagsParmMaxBuddies, uint16(100)),
+					wire.NewTLV(wire.BuddyTLVTagsParmMaxWatchers, uint16(100)),
+					wire.NewTLV(wire.BuddyTLVTagsParmMaxIcqBroad, uint16(100)),
+					wire.NewTLV(wire.BuddyTLVTagsParmMaxTempBuddies, uint16(100)),
+				},
+			},
+		},
+	}
+	have := svc.RightsQuery(nil, wire.SNACFrame{RequestID: 1234})
+
+	assert.Equal(t, want, have)
+}

+ 126 - 0
foodgroup/chat.go

@@ -0,0 +1,126 @@
+package foodgroup
+
+import (
+	"context"
+
+	"github.com/mk6i/retro-aim-server/state"
+	"github.com/mk6i/retro-aim-server/wire"
+)
+
+// NewChatService creates a new instance of ChatService.
+func NewChatService(chatRegistry ChatRegistry) *ChatService {
+	return &ChatService{
+		chatRegistry: chatRegistry,
+	}
+}
+
+// ChatService provides functionality for the Chat food group, which is
+// responsible for sending and receiving chat messages.
+type ChatService struct {
+	chatRegistry ChatRegistry
+}
+
+// ChannelMsgToHost relays wire.ChatChannelMsgToClient SNAC sent from a user
+// to the other chat room participants. It returns the same
+// wire.ChatChannelMsgToClient message back to the user if the chat reflection
+// TLV flag is set, otherwise return nil.
+func (s ChatService) ChannelMsgToHost(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x0E_0x05_ChatChannelMsgToHost) (*wire.SNACMessage, error) {
+	frameOut := wire.SNACFrame{
+		FoodGroup: wire.Chat,
+		SubGroup:  wire.ChatChannelMsgToClient,
+	}
+	bodyOut := wire.SNAC_0x0E_0x06_ChatChannelMsgToClient{
+		Cookie:  inBody.Cookie,
+		Channel: inBody.Channel,
+		TLVRestBlock: wire.TLVRestBlock{
+			TLVList: inBody.TLVList,
+		},
+	}
+	bodyOut.Append(wire.NewTLV(wire.ChatTLVSenderInformation, sess.TLVUserInfo()))
+
+	_, chatSessMgr, err := s.chatRegistry.Retrieve(sess.ChatRoomCookie())
+	if err != nil {
+		return nil, err
+	}
+
+	// send message to all the participants except sender
+	chatSessMgr.(ChatMessageRelayer).RelayToAllExcept(ctx, sess, wire.SNACMessage{
+		Frame: frameOut,
+		Body:  bodyOut,
+	})
+
+	var ret *wire.SNACMessage
+	if _, ackMsg := inBody.Slice(wire.ChatTLVEnableReflectionFlag); ackMsg {
+		// reflect the message back to the sender
+		ret = &wire.SNACMessage{
+			Frame: frameOut,
+			Body:  bodyOut,
+		}
+		ret.Frame.RequestID = inFrame.RequestID
+	}
+
+	return ret, nil
+}
+
+func setOnlineChatUsers(ctx context.Context, sess *state.Session, chatMessageRelayer ChatMessageRelayer) {
+	snacPayloadOut := wire.SNAC_0x0E_0x03_ChatUsersJoined{}
+	sessions := chatMessageRelayer.AllSessions()
+
+	for _, uSess := range sessions {
+		snacPayloadOut.Users = append(snacPayloadOut.Users, uSess.TLVUserInfo())
+	}
+
+	chatMessageRelayer.RelayToScreenName(ctx, sess.ScreenName(), wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Chat,
+			SubGroup:  wire.ChatUsersJoined,
+		},
+		Body: snacPayloadOut,
+	})
+}
+
+func alertUserJoined(ctx context.Context, sess *state.Session, chatMessageRelayer ChatMessageRelayer) {
+	chatMessageRelayer.RelayToAllExcept(ctx, sess, wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Chat,
+			SubGroup:  wire.ChatUsersJoined,
+		},
+		Body: wire.SNAC_0x0E_0x03_ChatUsersJoined{
+			Users: []wire.TLVUserInfo{
+				sess.TLVUserInfo(),
+			},
+		},
+	})
+}
+
+func alertUserLeft(ctx context.Context, sess *state.Session, chatMessageRelayer ChatMessageRelayer) {
+	chatMessageRelayer.RelayToAllExcept(ctx, sess, wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Chat,
+			SubGroup:  wire.ChatUsersLeft,
+		},
+		Body: wire.SNAC_0x0E_0x04_ChatUsersLeft{
+			Users: []wire.TLVUserInfo{
+				sess.TLVUserInfo(),
+			},
+		},
+	})
+}
+
+func sendChatRoomInfoUpdate(ctx context.Context, sess *state.Session, chatMessageRelayer ChatMessageRelayer, room state.ChatRoom) {
+	chatMessageRelayer.RelayToScreenName(ctx, sess.ScreenName(), wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Chat,
+			SubGroup:  wire.ChatRoomInfoUpdate,
+		},
+		Body: wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate{
+			Exchange:       room.Exchange,
+			Cookie:         room.Cookie,
+			InstanceNumber: room.InstanceNumber,
+			DetailLevel:    room.DetailLevel,
+			TLVBlock: wire.TLVBlock{
+				TLVList: room.TLVList(),
+			},
+		},
+	})
+}

+ 143 - 0
foodgroup/chat_nav.go

@@ -0,0 +1,143 @@
+package foodgroup
+
+import (
+	"context"
+	"errors"
+
+	"log/slog"
+
+	"github.com/mk6i/retro-aim-server/state"
+	"github.com/mk6i/retro-aim-server/wire"
+)
+
+// NewChatNavService creates a new instance of NewChatNavService.
+func NewChatNavService(logger *slog.Logger, chatRegistry *state.ChatRegistry, newChatRoom func() state.ChatRoom, newChatSessMgr func() SessionManager) *ChatNavService {
+	return &ChatNavService{
+		logger:         logger,
+		chatRegistry:   chatRegistry,
+		newChatRoom:    newChatRoom,
+		newChatSessMgr: newChatSessMgr,
+	}
+}
+
+// ChatNavService provides functionality for the ChatNav food group, which
+// handles chat room creation and serving chat room metadata.
+type ChatNavService struct {
+	logger         *slog.Logger
+	chatRegistry   *state.ChatRegistry
+	newChatRoom    func() state.ChatRoom
+	newChatSessMgr func() SessionManager
+}
+
+// RequestChatRights returns SNAC wire.ChatNavNavInfo, which contains chat
+// navigation service parameters and limits.
+func (s ChatNavService) RequestChatRights(_ context.Context, inFrame wire.SNACFrame) wire.SNACMessage {
+	return wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.ChatNav,
+			SubGroup:  wire.ChatNavNavInfo,
+			RequestID: inFrame.RequestID,
+		},
+		Body: wire.SNAC_0x0D_0x09_ChatNavNavInfo{
+			TLVRestBlock: wire.TLVRestBlock{
+				TLVList: wire.TLVList{
+					wire.NewTLV(wire.ChatNavTLVMaxConcurrentRooms, uint8(10)),
+					wire.NewTLV(wire.ChatNavTLVExchangeInfo, wire.SNAC_0x0D_0x09_TLVExchangeInfo{
+						Identifier: 4,
+						TLVBlock: wire.TLVBlock{
+							TLVList: wire.TLVList{
+								wire.NewTLV(wire.ChatNavTLVClassPerms, uint16(0x0010)),
+								wire.NewTLV(wire.ChatNavTLVFlags, uint16(15)),
+								wire.NewTLV(wire.ChatNavTLVRoomName, "default exchange"),
+								wire.NewTLV(wire.ChatNavTLVCreatePerms, uint8(2)),
+								wire.NewTLV(wire.ChatNavTLVCharSet1, "us-ascii"),
+								wire.NewTLV(wire.ChatNavTLVLang1, "en"),
+								wire.NewTLV(wire.ChatNavTLVCharSet2, "us-ascii"),
+								wire.NewTLV(wire.ChatNavTLVLang2, "en"),
+							},
+						},
+					}),
+				},
+			},
+		},
+	}
+}
+
+// CreateRoom creates a chat room with the current user as the first
+// participant. It returns SNAC wire.ChatNavNavInfo, which contains metadata
+// for the chat room.
+func (s ChatNavService) CreateRoom(_ context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate) (wire.SNACMessage, error) {
+	name, hasName := inBody.String(wire.ChatTLVRoomName)
+	if !hasName {
+		return wire.SNACMessage{}, errors.New("unable to find chat name")
+	}
+
+	room := s.newChatRoom()
+	room.DetailLevel = inBody.DetailLevel
+	room.Exchange = inBody.Exchange
+	room.InstanceNumber = inBody.InstanceNumber
+	room.Name = name
+
+	chatSessMgr := s.newChatSessMgr()
+
+	s.chatRegistry.Register(room, chatSessMgr)
+
+	// add user to chat room
+	chatSess := chatSessMgr.AddSession(sess.ID(), sess.ScreenName())
+	chatSess.SetChatRoomCookie(room.Cookie)
+
+	return wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.ChatNav,
+			SubGroup:  wire.ChatNavNavInfo,
+			RequestID: inFrame.RequestID,
+		},
+		Body: wire.SNAC_0x0D_0x09_ChatNavNavInfo{
+			TLVRestBlock: wire.TLVRestBlock{
+				TLVList: wire.TLVList{
+					wire.NewTLV(wire.ChatNavRequestRoomInfo, wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate{
+						Exchange:       inBody.Exchange,
+						Cookie:         room.Cookie,
+						InstanceNumber: inBody.InstanceNumber,
+						DetailLevel:    inBody.DetailLevel,
+						TLVBlock: wire.TLVBlock{
+							TLVList: room.TLVList(),
+						},
+					}),
+				},
+			},
+		},
+	}, nil
+}
+
+// RequestRoomInfo returns wire.ChatNavNavInfo, which contains metadata for
+// the chat room specified in the inFrame.Cookie.
+func (s ChatNavService) RequestRoomInfo(_ context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x0D_0x04_ChatNavRequestRoomInfo) (wire.SNACMessage, error) {
+	room, _, err := s.chatRegistry.Retrieve(inBody.Cookie)
+	if err != nil {
+		return wire.SNACMessage{}, err
+	}
+
+	return wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.ChatNav,
+			SubGroup:  wire.ChatNavNavInfo,
+			RequestID: inFrame.RequestID,
+		},
+		Body: wire.SNAC_0x0D_0x09_ChatNavNavInfo{
+			TLVRestBlock: wire.TLVRestBlock{
+				TLVList: wire.TLVList{
+					wire.NewTLV(wire.ChatNavRequestRoomInfo, wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate{
+						Exchange:       room.Exchange,
+						Cookie:         room.Cookie,
+						InstanceNumber: room.InstanceNumber,
+						DetailLevel:    room.DetailLevel,
+						TLVBlock: wire.TLVBlock{
+							TLVList: room.TLVList(),
+						},
+					}),
+				},
+			},
+		},
+	}, nil
+}

+ 198 - 0
foodgroup/chat_nav_test.go

@@ -0,0 +1,198 @@
+package foodgroup
+
+import (
+	"context"
+	"testing"
+	"time"
+
+	"github.com/mk6i/retro-aim-server/state"
+	"github.com/mk6i/retro-aim-server/wire"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestChatNavService_CreateRoom(t *testing.T) {
+	bosSess := newTestSession("user-screen-name", sessOptCannedID)
+	chatSess := &state.Session{}
+
+	chatRegistry := state.NewChatRegistry()
+
+	sessionManager := newMockSessionManager(t)
+	sessionManager.EXPECT().AddSession(bosSess.ID(), bosSess.ScreenName()).
+		Return(chatSess)
+
+	newChatRoom := func() state.ChatRoom {
+		return state.ChatRoom{
+			Cookie:     "dummy-cookie",
+			CreateTime: time.UnixMilli(0),
+		}
+	}
+	newChatSessMgr := func() SessionManager {
+		return sessionManager
+	}
+
+	inFrame := wire.SNACFrame{
+		RequestID: 1234,
+	}
+	inBody := wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate{
+		Exchange:       1,
+		Cookie:         "create", // actual canned value sent by AIM client
+		InstanceNumber: 2,
+		DetailLevel:    3,
+		TLVBlock: wire.TLVBlock{
+			TLVList: wire.TLVList{
+				wire.NewTLV(wire.ChatTLVRoomName, "the-chat-room-name"),
+			},
+		},
+	}
+
+	svc := NewChatNavService(nil, chatRegistry, newChatRoom, newChatSessMgr)
+	outputSNAC, err := svc.CreateRoom(context.Background(), bosSess, inFrame, inBody)
+	assert.NoError(t, err)
+
+	assert.Equal(t, chatSess.ChatRoomCookie(), newChatRoom().Cookie)
+
+	expectChatRoom := state.ChatRoom{
+		Cookie:         "dummy-cookie",
+		CreateTime:     time.UnixMilli(0),
+		DetailLevel:    3,
+		Exchange:       1,
+		InstanceNumber: 2,
+		Name:           "the-chat-room-name",
+	}
+	chatRoom, _, err := chatRegistry.Retrieve("dummy-cookie")
+	assert.NoError(t, err)
+
+	// assert the user session is linked to the chat room
+	assert.Equal(t, expectChatRoom, chatRoom)
+
+	expectSNAC := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.ChatNav,
+			SubGroup:  wire.ChatNavNavInfo,
+			RequestID: 1234,
+		},
+		Body: wire.SNAC_0x0D_0x09_ChatNavNavInfo{
+			TLVRestBlock: wire.TLVRestBlock{
+				TLVList: wire.TLVList{
+					wire.NewTLV(
+						wire.ChatNavRequestRoomInfo,
+						wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate{
+							Exchange:       chatRoom.Exchange,
+							Cookie:         chatRoom.Cookie,
+							InstanceNumber: chatRoom.InstanceNumber,
+							DetailLevel:    chatRoom.DetailLevel,
+							TLVBlock: wire.TLVBlock{
+								TLVList: chatRoom.TLVList(),
+							},
+						},
+					),
+				},
+			},
+		},
+	}
+
+	assert.Equal(t, expectSNAC, outputSNAC)
+}
+
+func TestChatNavService_RequestRoomInfo(t *testing.T) {
+	tests := []struct {
+		name     string
+		chatRoom state.ChatRoom
+		// inputSNAC is the SNAC sent from the client to the server
+		inputSNAC wire.SNACMessage
+		want      wire.SNACMessage
+		wantErr   error
+	}{
+		{
+			name: "request room info",
+			chatRoom: state.ChatRoom{
+				Cookie:         "the-chat-cookie",
+				DetailLevel:    2,
+				Exchange:       4,
+				InstanceNumber: 8,
+			},
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					RequestID: 1234,
+				},
+				Body: wire.SNAC_0x0D_0x04_ChatNavRequestRoomInfo{
+					Cookie: "the-chat-cookie",
+				},
+			},
+			want: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.ChatNav,
+					SubGroup:  wire.ChatNavNavInfo,
+					RequestID: 1234,
+				},
+				Body: wire.SNAC_0x0D_0x09_ChatNavNavInfo{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLV(0x04, wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate{
+								Cookie:         "the-chat-cookie",
+								DetailLevel:    2,
+								Exchange:       4,
+								InstanceNumber: 8,
+								TLVBlock: wire.TLVBlock{
+									TLVList: state.ChatRoom{Cookie: "the-chat-cookie"}.TLVList(),
+								},
+							}),
+						},
+					},
+				},
+			},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			svc := NewChatNavService(nil, state.NewChatRegistry(), nil, nil)
+			svc.chatRegistry.Register(tt.chatRoom, nil)
+			got, err := svc.RequestRoomInfo(nil, tt.inputSNAC.Frame,
+				tt.inputSNAC.Body.(wire.SNAC_0x0D_0x04_ChatNavRequestRoomInfo))
+			assert.ErrorIs(t, err, tt.wantErr)
+			if tt.wantErr != nil {
+				return
+			}
+			assert.Equal(t, tt.want, got)
+		})
+	}
+}
+
+func TestChatNavService_RequestChatRights(t *testing.T) {
+	svc := NewChatNavService(nil, nil, nil, nil)
+
+	have := svc.RequestChatRights(nil, wire.SNACFrame{RequestID: 1234})
+
+	want := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.ChatNav,
+			SubGroup:  wire.ChatNavNavInfo,
+			RequestID: 1234,
+		},
+		Body: wire.SNAC_0x0D_0x09_ChatNavNavInfo{
+			TLVRestBlock: wire.TLVRestBlock{
+				TLVList: wire.TLVList{
+					wire.NewTLV(wire.ChatNavTLVMaxConcurrentRooms, uint8(10)),
+					wire.NewTLV(wire.ChatNavTLVExchangeInfo, wire.SNAC_0x0D_0x09_TLVExchangeInfo{
+						Identifier: 4,
+						TLVBlock: wire.TLVBlock{
+							TLVList: wire.TLVList{
+								wire.NewTLV(wire.ChatNavTLVClassPerms, uint16(0x0010)),
+								wire.NewTLV(wire.ChatNavTLVFlags, uint16(15)),
+								wire.NewTLV(wire.ChatNavTLVRoomName, "default exchange"),
+								wire.NewTLV(wire.ChatNavTLVCreatePerms, uint8(2)),
+								wire.NewTLV(wire.ChatNavTLVCharSet1, "us-ascii"),
+								wire.NewTLV(wire.ChatNavTLVLang1, "en"),
+								wire.NewTLV(wire.ChatNavTLVCharSet2, "us-ascii"),
+								wire.NewTLV(wire.ChatNavTLVLang2, "en"),
+							},
+						},
+					}),
+				},
+			},
+		},
+	}
+
+	assert.Equal(t, want, have)
+}

+ 64 - 63
handler/chat_test.go → foodgroup/chat_test.go

@@ -1,51 +1,52 @@
-package handler
+package foodgroup
 
 import (
 	"context"
 	"testing"
 
-	"github.com/mk6i/retro-aim-server/oscar"
 	"github.com/mk6i/retro-aim-server/state"
+	"github.com/mk6i/retro-aim-server/wire"
+
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/mock"
 )
 
-func TestChatService_ChannelMsgToHostHandler(t *testing.T) {
+func TestChatService_ChannelMsgToHost(t *testing.T) {
 	cases := []struct {
 		// name is the unit test name
 		name string
 		// userSession is the session of the user sending the chat message
 		userSession *state.Session
 		// inputSNAC is the SNAC sent by the sender client
-		inputSNAC oscar.SNACMessage
+		inputSNAC wire.SNACMessage
 		// mockParams is the list of params sent to mocks that satisfy this
 		// method's dependencies
 		mockParams mockParams
 		// expectSNACToParticipants is the message the server broadcast to chat
 		// room participants (except the sender)
-		expectSNACToParticipants oscar.SNACMessage
-		expectOutput             *oscar.SNACMessage
+		expectSNACToParticipants wire.SNACMessage
+		expectOutput             *wire.SNACMessage
 		wantErr                  error
 	}{
 		{
 			name: "send chat room message, expect acknowledgement to sender client",
 			userSession: newTestSession("user_sending_chat_msg", sessOptCannedSignonTime,
-				sessOptChatID("the-chat-id")),
-			inputSNAC: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
+				sessOptChatRoomCookie("the-chat-cookie")),
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
 					RequestID: 1234,
 				},
-				Body: oscar.SNAC_0x0E_0x05_ChatChannelMsgToHost{
+				Body: wire.SNAC_0x0E_0x05_ChatChannelMsgToHost{
 					Cookie:  1234,
 					Channel: 14,
-					TLVRestBlock: oscar.TLVRestBlock{
-						TLVList: oscar.TLVList{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
 							{
-								Tag:   oscar.ChatTLVPublicWhisperFlag,
+								Tag:   wire.ChatTLVPublicWhisperFlag,
 								Value: []byte{},
 							},
 							{
-								Tag:   oscar.ChatTLVEnableReflectionFlag,
+								Tag:   wire.ChatTLVEnableReflectionFlag,
 								Value: []byte{},
 							},
 						},
@@ -55,42 +56,42 @@ func TestChatService_ChannelMsgToHostHandler(t *testing.T) {
 			mockParams: mockParams{
 				chatRegistryParams: chatRegistryParams{
 					chatRegistryRetrieveParams: chatRegistryRetrieveParams{
-						chatID: "the-chat-id",
+						cookie: "the-chat-cookie",
 					},
 				},
 			},
-			expectSNACToParticipants: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.Chat,
-					SubGroup:  oscar.ChatChannelMsgToClient,
+			expectSNACToParticipants: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Chat,
+					SubGroup:  wire.ChatChannelMsgToClient,
 				},
-				Body: oscar.SNAC_0x0E_0x06_ChatChannelMsgToClient{
+				Body: wire.SNAC_0x0E_0x06_ChatChannelMsgToClient{
 					Cookie:  1234,
 					Channel: 14,
-					TLVRestBlock: oscar.TLVRestBlock{
-						TLVList: oscar.TLVList{
-							oscar.NewTLV(oscar.ChatTLVPublicWhisperFlag, []byte{}),
-							oscar.NewTLV(oscar.ChatTLVEnableReflectionFlag, []byte{}),
-							oscar.NewTLV(oscar.ChatTLVSenderInformation,
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLV(wire.ChatTLVPublicWhisperFlag, []byte{}),
+							wire.NewTLV(wire.ChatTLVEnableReflectionFlag, []byte{}),
+							wire.NewTLV(wire.ChatTLVSenderInformation,
 								newTestSession("user_sending_chat_msg", sessOptCannedSignonTime).TLVUserInfo()),
 						},
 					},
 				},
 			},
-			expectOutput: &oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.Chat,
-					SubGroup:  oscar.ChatChannelMsgToClient,
+			expectOutput: &wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Chat,
+					SubGroup:  wire.ChatChannelMsgToClient,
 					RequestID: 1234,
 				},
-				Body: oscar.SNAC_0x0E_0x06_ChatChannelMsgToClient{
+				Body: wire.SNAC_0x0E_0x06_ChatChannelMsgToClient{
 					Cookie:  1234,
 					Channel: 14,
-					TLVRestBlock: oscar.TLVRestBlock{
-						TLVList: oscar.TLVList{
-							oscar.NewTLV(oscar.ChatTLVPublicWhisperFlag, []byte{}),
-							oscar.NewTLV(oscar.ChatTLVEnableReflectionFlag, []byte{}),
-							oscar.NewTLV(oscar.ChatTLVSenderInformation, newTestSession("user_sending_chat_msg", sessOptCannedSignonTime).TLVUserInfo()),
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLV(wire.ChatTLVPublicWhisperFlag, []byte{}),
+							wire.NewTLV(wire.ChatTLVEnableReflectionFlag, []byte{}),
+							wire.NewTLV(wire.ChatTLVSenderInformation, newTestSession("user_sending_chat_msg", sessOptCannedSignonTime).TLVUserInfo()),
 						},
 					},
 				},
@@ -99,18 +100,18 @@ func TestChatService_ChannelMsgToHostHandler(t *testing.T) {
 		{
 			name: "send chat room message, don't expect acknowledgement to sender client",
 			userSession: newTestSession("user_sending_chat_msg", sessOptCannedSignonTime,
-				sessOptChatID("the-chat-id")),
-			inputSNAC: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
+				sessOptChatRoomCookie("the-chat-cookie")),
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
 					RequestID: 1234,
 				},
-				Body: oscar.SNAC_0x0E_0x05_ChatChannelMsgToHost{
+				Body: wire.SNAC_0x0E_0x05_ChatChannelMsgToHost{
 					Cookie:  1234,
 					Channel: 14,
-					TLVRestBlock: oscar.TLVRestBlock{
-						TLVList: oscar.TLVList{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
 							{
-								Tag:   oscar.ChatTLVPublicWhisperFlag,
+								Tag:   wire.ChatTLVPublicWhisperFlag,
 								Value: []byte{},
 							},
 						},
@@ -120,22 +121,22 @@ func TestChatService_ChannelMsgToHostHandler(t *testing.T) {
 			mockParams: mockParams{
 				chatRegistryParams: chatRegistryParams{
 					chatRegistryRetrieveParams: chatRegistryRetrieveParams{
-						chatID: "the-chat-id",
+						cookie: "the-chat-cookie",
 					},
 				},
 			},
-			expectSNACToParticipants: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.Chat,
-					SubGroup:  oscar.ChatChannelMsgToClient,
+			expectSNACToParticipants: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Chat,
+					SubGroup:  wire.ChatChannelMsgToClient,
 				},
-				Body: oscar.SNAC_0x0E_0x06_ChatChannelMsgToClient{
+				Body: wire.SNAC_0x0E_0x06_ChatChannelMsgToClient{
 					Cookie:  1234,
 					Channel: 14,
-					TLVRestBlock: oscar.TLVRestBlock{
-						TLVList: oscar.TLVList{
-							oscar.NewTLV(oscar.ChatTLVPublicWhisperFlag, []byte{}),
-							oscar.NewTLV(oscar.ChatTLVSenderInformation,
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLV(wire.ChatTLVPublicWhisperFlag, []byte{}),
+							wire.NewTLV(wire.ChatTLVSenderInformation,
 								newTestSession("user_sending_chat_msg", sessOptCannedSignonTime).TLVUserInfo()),
 						},
 					},
@@ -145,18 +146,18 @@ func TestChatService_ChannelMsgToHostHandler(t *testing.T) {
 		{
 			name: "send chat room message, fail due to missing chat room",
 			userSession: newTestSession("user_sending_chat_msg", sessOptCannedSignonTime,
-				sessOptChatID("the-chat-id")),
-			inputSNAC: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
+				sessOptChatRoomCookie("the-chat-cookie")),
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
 					RequestID: 1234,
 				},
-				Body: oscar.SNAC_0x0E_0x05_ChatChannelMsgToHost{
+				Body: wire.SNAC_0x0E_0x05_ChatChannelMsgToHost{
 					Cookie:  1234,
 					Channel: 14,
-					TLVRestBlock: oscar.TLVRestBlock{
-						TLVList: oscar.TLVList{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
 							{
-								Tag:   oscar.ChatTLVPublicWhisperFlag,
+								Tag:   wire.ChatTLVPublicWhisperFlag,
 								Value: []byte{},
 							},
 						},
@@ -166,7 +167,7 @@ func TestChatService_ChannelMsgToHostHandler(t *testing.T) {
 			mockParams: mockParams{
 				chatRegistryParams: chatRegistryParams{
 					chatRegistryRetrieveParams: chatRegistryRetrieveParams{
-						chatID: "the-chat-id",
+						cookie: "the-chat-cookie",
 						err:    state.ErrChatRoomNotFound,
 					},
 				},
@@ -185,12 +186,12 @@ func TestChatService_ChannelMsgToHostHandler(t *testing.T) {
 
 			chatRegistry := newMockChatRegistry(t)
 			chatRegistry.EXPECT().
-				Retrieve(tc.mockParams.chatRegistryRetrieveParams.chatID).
+				Retrieve(tc.mockParams.chatRegistryRetrieveParams.cookie).
 				Return(state.ChatRoom{}, chatSessMgr, tc.mockParams.chatRegistryRetrieveParams.err)
 
 			svc := NewChatService(chatRegistry)
-			outputSNAC, err := svc.ChannelMsgToHostHandler(context.Background(), tc.userSession, tc.inputSNAC.Frame,
-				tc.inputSNAC.Body.(oscar.SNAC_0x0E_0x05_ChatChannelMsgToHost))
+			outputSNAC, err := svc.ChannelMsgToHost(context.Background(), tc.userSession, tc.inputSNAC.Frame,
+				tc.inputSNAC.Body.(wire.SNAC_0x0E_0x05_ChatChannelMsgToHost))
 			assert.ErrorIs(t, err, tc.wantErr)
 			assert.Equal(t, tc.expectOutput, outputSNAC)
 		})

+ 296 - 0
foodgroup/feedbag.go

@@ -0,0 +1,296 @@
+package foodgroup
+
+import (
+	"context"
+	"time"
+
+	"github.com/mk6i/retro-aim-server/state"
+	"github.com/mk6i/retro-aim-server/wire"
+)
+
+// NewFeedbagService creates a new instance of FeedbagService.
+func NewFeedbagService(messageRelayer MessageRelayer, feedbagManager FeedbagManager) FeedbagService {
+	return FeedbagService{
+		messageRelayer: messageRelayer,
+		feedbagManager: feedbagManager,
+	}
+}
+
+// FeedbagService provides functionality for the Feedbag food group, which
+// handles buddy list management.
+type FeedbagService struct {
+	messageRelayer MessageRelayer
+	feedbagManager FeedbagManager
+}
+
+// RightsQuery returns SNAC wire.FeedbagRightsReply, which contains Feedbag
+// food group settings for the current user. The values within the SNAC are not
+// well understood but seem to make the AIM client happy.
+func (s FeedbagService) RightsQuery(_ context.Context, inFrame wire.SNACFrame) wire.SNACMessage {
+	return wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Feedbag,
+			SubGroup:  wire.FeedbagRightsReply,
+			RequestID: inFrame.RequestID,
+		},
+		Body: wire.SNAC_0x13_0x03_FeedbagRightsReply{
+			TLVRestBlock: wire.TLVRestBlock{
+				TLVList: wire.TLVList{
+					wire.NewTLV(wire.FeedbagRightsMaxItemAttrs, uint16(200)),
+					wire.NewTLV(wire.FeedbagRightsMaxItemsByClass, []uint16{
+						0x3D, // max num of contacts
+						0x3D, // max num of groups
+						0x64, // max visible contacts
+						0x64, // max invisible contacts
+						0x01, // max vis/invis bitmasks
+						0x01, // max presense info fields
+						0x32, // limit for item type 06
+						0x00, // limit for item type 07
+						0x00, // limit for item type 08
+						0x03, // limit for item type 09
+						0x00, // limit for item type 0a
+						0x00, // limit for item type 0b
+						0x00, // limit for item type 0c
+						0x80, // limit for item type 0d
+						0xFF, // max ignore list entries
+						0x14, // limit for item type 0f
+						0xC8, // limit for item 10
+						0x01, // limit for item 11
+						0x00, // limit for item 12
+						0x01, // limit for item 13
+						0x00, // limit for item 14
+					}),
+					wire.NewTLV(wire.FeedbagRightsMaxClientItems, uint16(200)),
+					wire.NewTLV(wire.FeedbagRightsMaxItemNameLen, uint16(200)),
+					wire.NewTLV(wire.FeedbagRightsMaxRecentBuddies, uint16(200)),
+					wire.NewTLV(wire.FeedbagRightsInteractionBuddies, uint16(200)),
+					wire.NewTLV(wire.FeedbagRightsInteractionHalfLife, uint16(200)),
+					wire.NewTLV(wire.FeedbagRightsInteractionMaxScore, uint16(200)),
+					wire.NewTLV(wire.FeedbagRightsMaxBuddiesPerGroup, uint16(200)),
+					wire.NewTLV(wire.FeedbagRightsMaxMegaBots, uint16(200)),
+					wire.NewTLV(wire.FeedbagRightsMaxSmartGroups, uint16(100)),
+				},
+			},
+		},
+	}
+}
+
+// Query fetches the user's feedbag (aka buddy list). It returns
+// wire.FeedbagReply, which contains feedbag entries.
+func (s FeedbagService) Query(_ context.Context, sess *state.Session, inFrame wire.SNACFrame) (wire.SNACMessage, error) {
+	fb, err := s.feedbagManager.Feedbag(sess.ScreenName())
+	if err != nil {
+		return wire.SNACMessage{}, err
+	}
+
+	lm := time.UnixMilli(0)
+
+	if len(fb) > 0 {
+		lm, err = s.feedbagManager.FeedbagLastModified(sess.ScreenName())
+		if err != nil {
+			return wire.SNACMessage{}, err
+		}
+	}
+
+	return wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Feedbag,
+			SubGroup:  wire.FeedbagReply,
+			RequestID: inFrame.RequestID,
+		},
+		Body: wire.SNAC_0x13_0x06_FeedbagReply{
+			Version:    0,
+			Items:      fb,
+			LastUpdate: uint32(lm.Unix()),
+		},
+	}, nil
+}
+
+// QueryIfModified fetches the user's feedbag (aka buddy list). It returns
+// wire.FeedbagReplyNotModified if the feedbag was last modified before
+// inBody.LastUpdate, else return wire.FeedbagReply, which contains feedbag
+// entries.
+func (s FeedbagService) QueryIfModified(_ context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x05_FeedbagQueryIfModified) (wire.SNACMessage, error) {
+	fb, err := s.feedbagManager.Feedbag(sess.ScreenName())
+	if err != nil {
+		return wire.SNACMessage{}, err
+	}
+
+	lm := time.UnixMilli(0)
+
+	if len(fb) > 0 {
+		lm, err = s.feedbagManager.FeedbagLastModified(sess.ScreenName())
+		if err != nil {
+			return wire.SNACMessage{}, err
+		}
+		if lm.Before(time.Unix(int64(inBody.LastUpdate), 0)) {
+			return wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Feedbag,
+					SubGroup:  wire.FeedbagReplyNotModified,
+					RequestID: inFrame.RequestID,
+				},
+				Body: wire.SNAC_0x13_0x05_FeedbagQueryIfModified{
+					LastUpdate: uint32(lm.Unix()),
+					Count:      uint8(len(fb)),
+				},
+			}, nil
+		}
+	}
+
+	return wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Feedbag,
+			SubGroup:  wire.FeedbagReply,
+			RequestID: inFrame.RequestID,
+		},
+		Body: wire.SNAC_0x13_0x06_FeedbagReply{
+			Version:    0,
+			Items:      fb,
+			LastUpdate: uint32(lm.Unix()),
+		},
+	}, nil
+}
+
+// InsertItem adds items to the user's feedbag (aka buddy list). Sends user
+// buddy arrival notifications for each online & visible buddy added to the
+// feedbag. Sends a buddy departure notification to blocked buddies if current
+// user is visible. It returns wire.FeedbagStatus, which contains insert
+// confirmation.
+func (s FeedbagService) InsertItem(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x08_FeedbagInsertItem) (wire.SNACMessage, error) {
+	for _, item := range inBody.Items {
+		// don't let users block themselves, it causes the AIM client to go
+		// into a weird state.
+		if item.ClassID == 3 && item.Name == sess.ScreenName() {
+			return wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Feedbag,
+					SubGroup:  wire.FeedbagErr,
+					RequestID: inFrame.RequestID,
+				},
+				Body: wire.SNACError{
+					Code: wire.ErrorCodeNotSupportedByHost,
+				},
+			}, nil
+		}
+	}
+
+	if err := s.feedbagManager.FeedbagUpsert(sess.ScreenName(), inBody.Items); err != nil {
+		return wire.SNACMessage{}, nil
+	}
+
+	for _, item := range inBody.Items {
+		switch item.ClassID {
+		case wire.FeedbagClassIdBuddy, wire.FeedbagClassIDPermit: // add new buddy
+			buddy := s.messageRelayer.RetrieveByScreenName(item.Name)
+			if buddy == nil || buddy.Invisible() {
+				continue
+			}
+			unicastArrival(ctx, buddy, sess, s.messageRelayer)
+		case wire.FeedbagClassIDDeny: // block buddy
+			if sess.Invisible() {
+				continue // user's offline, don't send departure notification
+			}
+			blockedSess := s.messageRelayer.RetrieveByScreenName(item.Name)
+			if blockedSess == nil {
+				continue // blocked buddy is offline, nothing to do here
+			}
+			// alert blocked buddy that current user is offline
+			unicastDeparture(ctx, sess, blockedSess, s.messageRelayer)
+		}
+	}
+
+	snacPayloadOut := wire.SNAC_0x13_0x0E_FeedbagStatus{}
+	for range inBody.Items {
+		snacPayloadOut.Results = append(snacPayloadOut.Results, 0x0000)
+	}
+
+	return wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Feedbag,
+			SubGroup:  wire.FeedbagStatus,
+			RequestID: inFrame.RequestID,
+		},
+		Body: snacPayloadOut,
+	}, nil
+}
+
+// UpdateItem updates items in the user's feedbag (aka buddy list). Sends user
+// buddy arrival notifications for each online & visible buddy added to the
+// feedbag. It returns wire.FeedbagStatus, which contains update confirmation.
+func (s FeedbagService) UpdateItem(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x09_FeedbagUpdateItem) (wire.SNACMessage, error) {
+	if err := s.feedbagManager.FeedbagUpsert(sess.ScreenName(), inBody.Items); err != nil {
+		return wire.SNACMessage{}, nil
+	}
+
+	for _, item := range inBody.Items {
+		switch item.ClassID {
+		case wire.FeedbagClassIdBuddy, wire.FeedbagClassIDPermit:
+			buddy := s.messageRelayer.RetrieveByScreenName(item.Name)
+			if buddy == nil || buddy.Invisible() {
+				continue
+			}
+			unicastArrival(ctx, buddy, sess, s.messageRelayer)
+		}
+	}
+
+	snacPayloadOut := wire.SNAC_0x13_0x0E_FeedbagStatus{}
+	for range inBody.Items {
+		snacPayloadOut.Results = append(snacPayloadOut.Results, 0x0000)
+	}
+
+	return wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Feedbag,
+			SubGroup:  wire.FeedbagStatus,
+			RequestID: inFrame.RequestID,
+		},
+		Body: snacPayloadOut,
+	}, nil
+}
+
+// DeleteItem removes items from feedbag (aka buddy list). Sends user buddy
+// arrival notifications for each online & visible buddy added to the feedbag.
+// Sends buddy arrival notifications to each unblocked buddy if current user is
+// visible. It returns wire.FeedbagStatus, which contains update confirmation.
+func (s FeedbagService) DeleteItem(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x0A_FeedbagDeleteItem) (wire.SNACMessage, error) {
+	if err := s.feedbagManager.FeedbagDelete(sess.ScreenName(), inBody.Items); err != nil {
+		return wire.SNACMessage{}, err
+	}
+
+	for _, item := range inBody.Items {
+		if item.ClassID == wire.FeedbagClassIDDeny {
+			unblockedSess := s.messageRelayer.RetrieveByScreenName(item.Name)
+			if unblockedSess == nil {
+				continue // unblocked user is offline, nothing to do here
+			}
+			if !sess.Invisible() {
+				// alert unblocked user that current user is online
+				unicastArrival(ctx, sess, unblockedSess, s.messageRelayer)
+			}
+			if !unblockedSess.Invisible() {
+				// alert current user that unblocked user is online
+				unicastArrival(ctx, unblockedSess, sess, s.messageRelayer)
+			}
+		}
+	}
+
+	snacPayloadOut := wire.SNAC_0x13_0x0E_FeedbagStatus{}
+	for range inBody.Items {
+		snacPayloadOut.Results = append(snacPayloadOut.Results, 0x0000) // success by default
+	}
+
+	return wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Feedbag,
+			SubGroup:  wire.FeedbagStatus,
+			RequestID: inFrame.RequestID,
+		},
+		Body: snacPayloadOut,
+	}, nil
+}
+
+// StartCluster exists to capture the SNAC input in unit tests to verify it's
+// correctly unmarshalled.
+func (s FeedbagService) StartCluster(context.Context, wire.SNACFrame, wire.SNAC_0x13_0x11_FeedbagStartCluster) {
+}

Разница между файлами не показана из-за своего большого размера
+ 246 - 245
foodgroup/feedbag_test.go


+ 255 - 0
foodgroup/icbm.go

@@ -0,0 +1,255 @@
+package foodgroup
+
+import (
+	"context"
+
+	"github.com/mk6i/retro-aim-server/state"
+	"github.com/mk6i/retro-aim-server/wire"
+)
+
+const (
+	evilDelta     = uint16(100)
+	evilDeltaAnon = uint16(30)
+)
+
+// NewICBMService returns a new instance of ICBMService.
+func NewICBMService(messageRelayer MessageRelayer, feedbagManager FeedbagManager) *ICBMService {
+	return &ICBMService{messageRelayer: messageRelayer, feedbagManager: feedbagManager}
+}
+
+// ICBMService provides functionality for the ICBM food group, which is
+// responsible for sending and receiving instant messages and associated
+// functionality such as warning, typing events, etc.
+type ICBMService struct {
+	messageRelayer MessageRelayer
+	feedbagManager FeedbagManager
+}
+
+// ParameterQuery returns ICBM service parameters.
+func (s ICBMService) ParameterQuery(_ context.Context, inFrame wire.SNACFrame) wire.SNACMessage {
+	return wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.ICBM,
+			SubGroup:  wire.ICBMParameterReply,
+			RequestID: inFrame.RequestID,
+		},
+		Body: wire.SNAC_0x04_0x05_ICBMParameterReply{
+			MaxSlots:             100,
+			ICBMFlags:            3,
+			MaxIncomingICBMLen:   512,
+			MaxSourceEvil:        999,
+			MaxDestinationEvil:   999,
+			MinInterICBMInterval: 0,
+		},
+	}
+}
+
+// ChannelMsgToHost relays the instant message SNAC wire.ICBMChannelMsgToHost
+// from the sender to the intended recipient. It returns wire.ICBMHostAck if
+// the wire.ICBMChannelMsgToHost message contains a request acknowledgement
+// flag.
+func (s ICBMService) ChannelMsgToHost(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x06_ICBMChannelMsgToHost) (*wire.SNACMessage, error) {
+	blocked, err := s.feedbagManager.BlockedState(sess.ScreenName(), inBody.ScreenName)
+	if err != nil {
+		return nil, err
+	}
+
+	if blocked != state.BlockedNo {
+		code := wire.ErrorCodeNotLoggedOn
+		if blocked == state.BlockedA {
+			code = wire.ErrorCodeInLocalPermitDeny
+		}
+		return &wire.SNACMessage{
+			Frame: wire.SNACFrame{
+				FoodGroup: wire.ICBM,
+				SubGroup:  wire.ICBMErr,
+				RequestID: inFrame.RequestID,
+			},
+			Body: wire.SNACError{
+				Code: code,
+			},
+		}, nil
+	}
+
+	recipSess := s.messageRelayer.RetrieveByScreenName(inBody.ScreenName)
+	if recipSess == nil {
+		return &wire.SNACMessage{
+			Frame: wire.SNACFrame{
+				FoodGroup: wire.ICBM,
+				SubGroup:  wire.ICBMErr,
+				RequestID: inFrame.RequestID,
+			},
+			Body: wire.SNACError{
+				Code: wire.ErrorCodeNotLoggedOn,
+			},
+		}, nil
+	}
+
+	clientIM := wire.SNAC_0x04_0x07_ICBMChannelMsgToClient{
+		Cookie:    inBody.Cookie,
+		ChannelID: inBody.ChannelID,
+		TLVUserInfo: wire.TLVUserInfo{
+			ScreenName:   sess.ScreenName(),
+			WarningLevel: sess.Warning(),
+		},
+		TLVRestBlock: wire.TLVRestBlock{
+			TLVList: wire.TLVList{
+				{
+					Tag:   0x0B,
+					Value: []byte{},
+				},
+			},
+		},
+	}
+	// copy over TLVs from sender SNAC to recipient SNAC verbatim. this
+	// includes ICBMTLVTagRequestHostAck, which is ignored by the client, as
+	// far as I can tell.
+	clientIM.AppendList(inBody.TLVRestBlock.TLVList)
+
+	s.messageRelayer.RelayToScreenName(ctx, recipSess.ScreenName(), wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.ICBM,
+			SubGroup:  wire.ICBMChannelMsgToClient,
+		},
+		Body: clientIM,
+	})
+
+	if _, requestedConfirmation := inBody.TLVRestBlock.Slice(wire.ICBMTLVTagRequestHostAck); !requestedConfirmation {
+		// don't ack message
+		return nil, nil
+	}
+
+	// ack message back to sender
+	return &wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.ICBM,
+			SubGroup:  wire.ICBMHostAck,
+			RequestID: inFrame.RequestID,
+		},
+		Body: wire.SNAC_0x04_0x0C_ICBMHostAck{
+			Cookie:     inBody.Cookie,
+			ChannelID:  inBody.ChannelID,
+			ScreenName: inBody.ScreenName,
+		},
+	}, nil
+}
+
+// ClientEvent relays SNAC wire.ICBMClientEvent typing events from the
+// sender to the recipient.
+func (s ICBMService) ClientEvent(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x14_ICBMClientEvent) error {
+	blocked, err := s.feedbagManager.BlockedState(sess.ScreenName(), inBody.ScreenName)
+
+	switch {
+	case err != nil:
+		return err
+	case blocked != state.BlockedNo:
+		return nil
+	default:
+		s.messageRelayer.RelayToScreenName(ctx, inBody.ScreenName, wire.SNACMessage{
+			Frame: wire.SNACFrame{
+				FoodGroup: wire.ICBM,
+				SubGroup:  wire.ICBMClientEvent,
+				RequestID: inFrame.RequestID,
+			},
+			Body: wire.SNAC_0x04_0x14_ICBMClientEvent{
+				Cookie:     inBody.Cookie,
+				ChannelID:  inBody.ChannelID,
+				ScreenName: sess.ScreenName(),
+				Event:      inBody.Event,
+			},
+		})
+		return nil
+	}
+}
+
+// EvilRequest handles user warning (a.k.a evil) notifications. It receives
+// wire.ICBMEvilRequest warning SNAC, increments the warned user's warning
+// level, and sends the warned user a notification informing them that they
+// have been warned. The user may choose to warn anonymously or
+// non-anonymously. It returns SNAC wire.ICBMEvilReply to confirm that the
+// warning was sent. Users may not warn themselves or warn users they have
+// blocked or are blocked by.
+func (s ICBMService) EvilRequest(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x08_ICBMEvilRequest) (wire.SNACMessage, error) {
+	// don't let users warn themselves, it causes the AIM client to go into a
+	// weird state.
+	if inBody.ScreenName == sess.ScreenName() {
+		return wire.SNACMessage{
+			Frame: wire.SNACFrame{
+				FoodGroup: wire.ICBM,
+				SubGroup:  wire.ICBMErr,
+				RequestID: inFrame.RequestID,
+			},
+			Body: wire.SNACError{
+				Code: wire.ErrorCodeNotSupportedByHost,
+			},
+		}, nil
+	}
+
+	blocked, err := s.feedbagManager.BlockedState(sess.ScreenName(), inBody.ScreenName)
+	if err != nil {
+		return wire.SNACMessage{}, nil
+	}
+	if blocked != state.BlockedNo {
+		return wire.SNACMessage{
+			Frame: wire.SNACFrame{
+				FoodGroup: wire.ICBM,
+				SubGroup:  wire.ICBMErr,
+				RequestID: inFrame.RequestID,
+			},
+			Body: wire.SNACError{
+				Code: wire.ErrorCodeNotLoggedOn,
+			},
+		}, nil
+	}
+
+	recipSess := s.messageRelayer.RetrieveByScreenName(inBody.ScreenName)
+	if recipSess == nil {
+		return wire.SNACMessage{}, nil
+	}
+
+	increase := evilDelta
+	if inBody.SendAs == 1 {
+		increase = evilDeltaAnon
+	}
+	recipSess.IncrementWarning(increase)
+
+	var notif any
+	if inBody.SendAs == 0 {
+		notif = wire.SNAC_0x01_0x10_OServiceEvilNotification{
+			NewEvil: recipSess.Warning(),
+			TLVUserInfo: wire.TLVUserInfo{
+				ScreenName:   sess.ScreenName(),
+				WarningLevel: recipSess.Warning(),
+			},
+		}
+	} else {
+		notif = wire.SNAC_0x01_0x10_OServiceEvilNotificationAnon{
+			NewEvil: recipSess.Warning(),
+		}
+	}
+
+	s.messageRelayer.RelayToScreenName(ctx, recipSess.ScreenName(), wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.OService,
+			SubGroup:  wire.OServiceEvilNotification,
+		},
+		Body: notif,
+	})
+
+	// inform the warned user's buddies that their warning level has increased
+	if err := broadcastArrival(ctx, recipSess, s.messageRelayer, s.feedbagManager); err != nil {
+		return wire.SNACMessage{}, nil
+	}
+
+	return wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.ICBM,
+			SubGroup:  wire.ICBMEvilReply,
+			RequestID: inFrame.RequestID,
+		},
+		Body: wire.SNAC_0x04_0x09_ICBMEvilReply{
+			EvilDeltaApplied: increase,
+			UpdatedEvilValue: recipSess.Warning(),
+		},
+	}, nil
+}

+ 178 - 177
handler/icbm_test.go → foodgroup/icbm_test.go

@@ -1,15 +1,16 @@
-package handler
+package foodgroup
 
 import (
 	"testing"
 
-	"github.com/mk6i/retro-aim-server/oscar"
 	"github.com/mk6i/retro-aim-server/state"
+	"github.com/mk6i/retro-aim-server/wire"
+
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/mock"
 )
 
-func TestICBMService_ChannelMsgToHostHandler(t *testing.T) {
+func TestICBMService_ChannelMsgToHost(t *testing.T) {
 	cases := []struct {
 		// name is the unit test name
 		name string
@@ -21,66 +22,66 @@ func TestICBMService_ChannelMsgToHostHandler(t *testing.T) {
 		senderSession    *state.Session
 		recipientSession *state.Session
 		// inputSNAC is the SNAC sent by the sender client
-		inputSNAC oscar.SNACMessage
+		inputSNAC wire.SNACMessage
 		// expectSNACToClient is the SNAC sent from the server to the
 		// recipient client
-		expectSNACToClient oscar.SNACMessage
+		expectSNACToClient wire.SNACMessage
 		// inputSNAC is the SNAC frame sent from the server to the recipient
 		// client
-		expectOutput *oscar.SNACMessage
+		expectOutput *wire.SNACMessage
 	}{
 		{
 			name:             "transmit message from sender to recipient, ack message back to sender",
 			blockedState:     state.BlockedNo,
 			senderSession:    newTestSession("sender-screen-name", sessOptWarning(10)),
 			recipientSession: newTestSession("recipient-screen-name", sessOptWarning(20)),
-			inputSNAC: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
 					RequestID: 1234,
 				},
-				Body: oscar.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+				Body: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
 					ScreenName: "recipient-screen-name",
-					TLVRestBlock: oscar.TLVRestBlock{
-						TLVList: oscar.TLVList{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
 							{
-								Tag:   oscar.ICBMTLVTagRequestHostAck,
+								Tag:   wire.ICBMTLVTagRequestHostAck,
 								Value: []byte{},
 							},
 						},
 					},
 				},
 			},
-			expectSNACToClient: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.ICBM,
-					SubGroup:  oscar.ICBMChannelMsgToClient,
+			expectSNACToClient: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.ICBM,
+					SubGroup:  wire.ICBMChannelMsgToClient,
 				},
-				Body: oscar.SNAC_0x04_0x07_ICBMChannelMsgToClient{
-					TLVUserInfo: oscar.TLVUserInfo{
+				Body: wire.SNAC_0x04_0x07_ICBMChannelMsgToClient{
+					TLVUserInfo: wire.TLVUserInfo{
 						ScreenName:   "sender-screen-name",
 						WarningLevel: 10,
 					},
-					TLVRestBlock: oscar.TLVRestBlock{
-						TLVList: oscar.TLVList{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
 							{
-								Tag:   oscar.ICBMTLVTagsWantEvents,
+								Tag:   wire.ICBMTLVTagsWantEvents,
 								Value: []byte{},
 							},
 							{
-								Tag:   oscar.ICBMTLVTagRequestHostAck,
+								Tag:   wire.ICBMTLVTagRequestHostAck,
 								Value: []byte{},
 							},
 						},
 					},
 				},
 			},
-			expectOutput: &oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.ICBM,
-					SubGroup:  oscar.ICBMHostAck,
+			expectOutput: &wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.ICBM,
+					SubGroup:  wire.ICBMHostAck,
 					RequestID: 1234,
 				},
-				Body: oscar.SNAC_0x04_0x0C_ICBMHostAck{
+				Body: wire.SNAC_0x04_0x0C_ICBMHostAck{
 					ScreenName: "recipient-screen-name",
 				},
 			},
@@ -90,31 +91,31 @@ func TestICBMService_ChannelMsgToHostHandler(t *testing.T) {
 			blockedState:     state.BlockedNo,
 			senderSession:    newTestSession("sender-screen-name", sessOptWarning(10)),
 			recipientSession: newTestSession("recipient-screen-name", sessOptWarning(20)),
-			inputSNAC: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
 					RequestID: 1234,
 				},
-				Body: oscar.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+				Body: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
 					ScreenName: "recipient-screen-name",
-					TLVRestBlock: oscar.TLVRestBlock{
-						TLVList: oscar.TLVList{},
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{},
 					},
 				},
 			},
-			expectSNACToClient: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.ICBM,
-					SubGroup:  oscar.ICBMChannelMsgToClient,
+			expectSNACToClient: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.ICBM,
+					SubGroup:  wire.ICBMChannelMsgToClient,
 				},
-				Body: oscar.SNAC_0x04_0x07_ICBMChannelMsgToClient{
-					TLVUserInfo: oscar.TLVUserInfo{
+				Body: wire.SNAC_0x04_0x07_ICBMChannelMsgToClient{
+					TLVUserInfo: wire.TLVUserInfo{
 						ScreenName:   "sender-screen-name",
 						WarningLevel: 10,
 					},
-					TLVRestBlock: oscar.TLVRestBlock{
-						TLVList: oscar.TLVList{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
 							{
-								Tag:   oscar.ICBMTLVTagsWantEvents,
+								Tag:   wire.ICBMTLVTagsWantEvents,
 								Value: []byte{},
 							},
 						},
@@ -128,30 +129,30 @@ func TestICBMService_ChannelMsgToHostHandler(t *testing.T) {
 			blockedState:     state.BlockedA,
 			senderSession:    newTestSession("sender-screen-name", sessOptWarning(10)),
 			recipientSession: newTestSession("recipient-screen-name", sessOptWarning(20)),
-			inputSNAC: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
 					RequestID: 1234,
 				},
-				Body: oscar.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+				Body: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
 					ScreenName: "recipient-screen-name",
-					TLVRestBlock: oscar.TLVRestBlock{
-						TLVList: oscar.TLVList{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
 							{
-								Tag:   oscar.ICBMTLVTagRequestHostAck,
+								Tag:   wire.ICBMTLVTagRequestHostAck,
 								Value: []byte{},
 							},
 						},
 					},
 				},
 			},
-			expectOutput: &oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.ICBM,
-					SubGroup:  oscar.ICBMErr,
+			expectOutput: &wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.ICBM,
+					SubGroup:  wire.ICBMErr,
 					RequestID: 1234,
 				},
-				Body: oscar.SNACError{
-					Code: oscar.ErrorCodeInLocalPermitDeny,
+				Body: wire.SNACError{
+					Code: wire.ErrorCodeInLocalPermitDeny,
 				},
 			},
 		},
@@ -160,30 +161,30 @@ func TestICBMService_ChannelMsgToHostHandler(t *testing.T) {
 			blockedState:     state.BlockedB,
 			senderSession:    newTestSession("sender-screen-name", sessOptWarning(10)),
 			recipientSession: newTestSession("recipient-screen-name", sessOptWarning(20)),
-			inputSNAC: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
 					RequestID: 1234,
 				},
-				Body: oscar.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+				Body: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
 					ScreenName: "recipient-screen-name",
-					TLVRestBlock: oscar.TLVRestBlock{
-						TLVList: oscar.TLVList{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
 							{
-								Tag:   oscar.ICBMTLVTagRequestHostAck,
+								Tag:   wire.ICBMTLVTagRequestHostAck,
 								Value: []byte{},
 							},
 						},
 					},
 				},
 			},
-			expectOutput: &oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.ICBM,
-					SubGroup:  oscar.ICBMErr,
+			expectOutput: &wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.ICBM,
+					SubGroup:  wire.ICBMErr,
 					RequestID: 1234,
 				},
-				Body: oscar.SNACError{
-					Code: oscar.ErrorCodeNotLoggedOn,
+				Body: wire.SNACError{
+					Code: wire.ErrorCodeNotLoggedOn,
 				},
 			},
 		},
@@ -192,30 +193,30 @@ func TestICBMService_ChannelMsgToHostHandler(t *testing.T) {
 			blockedState:     state.BlockedNo,
 			senderSession:    newTestSession("sender-screen-name", sessOptWarning(10)),
 			recipientSession: nil,
-			inputSNAC: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
 					RequestID: 1234,
 				},
-				Body: oscar.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+				Body: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
 					ScreenName: "recipient-screen-name",
-					TLVRestBlock: oscar.TLVRestBlock{
-						TLVList: oscar.TLVList{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
 							{
-								Tag:   oscar.ICBMTLVTagRequestHostAck,
+								Tag:   wire.ICBMTLVTagRequestHostAck,
 								Value: []byte{},
 							},
 						},
 					},
 				},
 			},
-			expectOutput: &oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.ICBM,
-					SubGroup:  oscar.ICBMErr,
+			expectOutput: &wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.ICBM,
+					SubGroup:  wire.ICBMErr,
 					RequestID: 1234,
 				},
-				Body: oscar.SNACError{
-					Code: oscar.ErrorCodeNotLoggedOn,
+				Body: wire.SNACError{
+					Code: wire.ErrorCodeNotLoggedOn,
 				},
 			},
 		},
@@ -229,12 +230,12 @@ func TestICBMService_ChannelMsgToHostHandler(t *testing.T) {
 			feedbagManager := newMockFeedbagManager(t)
 			feedbagManager.EXPECT().
 				BlockedState(tc.senderSession.ScreenName(),
-					tc.inputSNAC.Body.(oscar.SNAC_0x04_0x06_ICBMChannelMsgToHost).ScreenName).
+					tc.inputSNAC.Body.(wire.SNAC_0x04_0x06_ICBMChannelMsgToHost).ScreenName).
 				Return(tc.blockedState, nil).
 				Maybe()
 			messageRelayer := newMockMessageRelayer(t)
 			messageRelayer.EXPECT().
-				RetrieveByScreenName(tc.inputSNAC.Body.(oscar.SNAC_0x04_0x06_ICBMChannelMsgToHost).ScreenName).
+				RetrieveByScreenName(tc.inputSNAC.Body.(wire.SNAC_0x04_0x06_ICBMChannelMsgToHost).ScreenName).
 				Return(tc.recipientSession).
 				Maybe()
 			if tc.recipientSession != nil {
@@ -246,8 +247,8 @@ func TestICBMService_ChannelMsgToHostHandler(t *testing.T) {
 			// send input SNAC
 			//
 			svc := NewICBMService(messageRelayer, feedbagManager)
-			outputSNAC, err := svc.ChannelMsgToHostHandler(nil, tc.senderSession, tc.inputSNAC.Frame,
-				tc.inputSNAC.Body.(oscar.SNAC_0x04_0x06_ICBMChannelMsgToHost))
+			outputSNAC, err := svc.ChannelMsgToHost(nil, tc.senderSession, tc.inputSNAC.Frame,
+				tc.inputSNAC.Body.(wire.SNAC_0x04_0x06_ICBMChannelMsgToHost))
 			assert.NoError(t, err)
 			//
 			// verify output
@@ -257,7 +258,7 @@ func TestICBMService_ChannelMsgToHostHandler(t *testing.T) {
 	}
 }
 
-func TestICBMService_ClientEventHandler(t *testing.T) {
+func TestICBMService_ClientEvent(t *testing.T) {
 	cases := []struct {
 		// name is the unit test name
 		name string
@@ -266,33 +267,33 @@ func TestICBMService_ClientEventHandler(t *testing.T) {
 		// senderScreenName is the screen name of the user sending the event
 		senderScreenName string
 		// inputSNAC is the SNAC sent by the sender client
-		inputSNAC oscar.SNACMessage
+		inputSNAC wire.SNACMessage
 		// expectSNACToClient is the SNAC sent from the server to the
 		// recipient client
-		expectSNACToClient oscar.SNACMessage
+		expectSNACToClient wire.SNACMessage
 	}{
 		{
 			name:             "transmit message from sender to recipient",
 			blockedState:     state.BlockedNo,
 			senderScreenName: "sender-screen-name",
-			inputSNAC: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
 					RequestID: 1234,
 				},
-				Body: oscar.SNAC_0x04_0x14_ICBMClientEvent{
+				Body: wire.SNAC_0x04_0x14_ICBMClientEvent{
 					Cookie:     12345678,
 					ChannelID:  42,
 					ScreenName: "recipient-screen-name",
 					Event:      12,
 				},
 			},
-			expectSNACToClient: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.ICBM,
-					SubGroup:  oscar.ICBMClientEvent,
+			expectSNACToClient: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.ICBM,
+					SubGroup:  wire.ICBMClientEvent,
 					RequestID: 1234,
 				},
-				Body: oscar.SNAC_0x04_0x14_ICBMClientEvent{
+				Body: wire.SNAC_0x04_0x14_ICBMClientEvent{
 					Cookie:     12345678,
 					ChannelID:  42,
 					ScreenName: "sender-screen-name",
@@ -304,11 +305,11 @@ func TestICBMService_ClientEventHandler(t *testing.T) {
 			name:             "don't transmit message from sender to recipient because sender has blocked recipient",
 			blockedState:     state.BlockedA,
 			senderScreenName: "sender-screen-name",
-			inputSNAC: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
 					RequestID: 1234,
 				},
-				Body: oscar.SNAC_0x04_0x14_ICBMClientEvent{
+				Body: wire.SNAC_0x04_0x14_ICBMClientEvent{
 					ScreenName: "recipient-screen-name",
 				},
 			},
@@ -322,13 +323,13 @@ func TestICBMService_ClientEventHandler(t *testing.T) {
 			//
 			feedbagManager := newMockFeedbagManager(t)
 			feedbagManager.EXPECT().
-				BlockedState(tc.senderScreenName, tc.inputSNAC.Body.(oscar.SNAC_0x04_0x14_ICBMClientEvent).ScreenName).
+				BlockedState(tc.senderScreenName, tc.inputSNAC.Body.(wire.SNAC_0x04_0x14_ICBMClientEvent).ScreenName).
 				Return(tc.blockedState, nil).
 				Maybe()
 			messageRelayer := newMockMessageRelayer(t)
 			if tc.blockedState == state.BlockedNo {
 				messageRelayer.EXPECT().
-					RelayToScreenName(mock.Anything, tc.inputSNAC.Body.(oscar.SNAC_0x04_0x14_ICBMClientEvent).ScreenName,
+					RelayToScreenName(mock.Anything, tc.inputSNAC.Body.(wire.SNAC_0x04_0x14_ICBMClientEvent).ScreenName,
 						tc.expectSNACToClient)
 			}
 			//
@@ -336,13 +337,13 @@ func TestICBMService_ClientEventHandler(t *testing.T) {
 			//
 			senderSession := newTestSession(tc.senderScreenName)
 			svc := NewICBMService(messageRelayer, feedbagManager)
-			assert.NoError(t, svc.ClientEventHandler(nil, senderSession, tc.inputSNAC.Frame,
-				tc.inputSNAC.Body.(oscar.SNAC_0x04_0x14_ICBMClientEvent)))
+			assert.NoError(t, svc.ClientEvent(nil, senderSession, tc.inputSNAC.Frame,
+				tc.inputSNAC.Body.(wire.SNAC_0x04_0x14_ICBMClientEvent)))
 		})
 	}
 }
 
-func TestICBMService_EvilRequestHandler(t *testing.T) {
+func TestICBMService_EvilRequest(t *testing.T) {
 	cases := []struct {
 		// name is the unit test name
 		name string
@@ -358,55 +359,55 @@ func TestICBMService_EvilRequestHandler(t *testing.T) {
 		// recipientBuddies is a list of the recipient's buddies that get
 		// updated warning level
 		recipientBuddies []string
-		broadcastMessage oscar.SNACMessage
+		broadcastMessage wire.SNACMessage
 		// inputSNAC is the SNAC sent by the sender client
-		inputSNAC oscar.SNACMessage
+		inputSNAC wire.SNACMessage
 		// expectSNACToClient is the SNAC sent from the server to the
 		// recipient client
-		expectSNACToClient oscar.SNACMessage
+		expectSNACToClient wire.SNACMessage
 
-		expectOutput oscar.SNACMessage
+		expectOutput wire.SNACMessage
 	}{
 		{
 			name:                "transmit anonymous warning from sender to recipient",
 			blockedState:        state.BlockedNo,
 			senderSession:       newTestSession("sender-screen-name"),
 			recipientScreenName: "recipient-screen-name",
-			broadcastMessage: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.Buddy,
-					SubGroup:  oscar.BuddyArrived,
+			broadcastMessage: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Buddy,
+					SubGroup:  wire.BuddyArrived,
 				},
-				Body: oscar.SNAC_0x03_0x0B_BuddyArrived{
+				Body: wire.SNAC_0x03_0x0B_BuddyArrived{
 					TLVUserInfo: newTestSession("recipient-screen-name", sessOptCannedSignonTime, sessOptWarning(evilDeltaAnon)).TLVUserInfo(),
 				},
 			},
 			recipientBuddies: []string{"buddy1", "buddy2"},
-			inputSNAC: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
 					RequestID: 1234,
 				},
-				Body: oscar.SNAC_0x04_0x08_ICBMEvilRequest{
+				Body: wire.SNAC_0x04_0x08_ICBMEvilRequest{
 					SendAs:     1, // make it anonymous
 					ScreenName: "recipient-screen-name",
 				},
 			},
-			expectSNACToClient: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.OService,
-					SubGroup:  oscar.OServiceEvilNotification,
+			expectSNACToClient: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.OService,
+					SubGroup:  wire.OServiceEvilNotification,
 				},
-				Body: oscar.SNAC_0x01_0x10_OServiceEvilNotificationAnon{
+				Body: wire.SNAC_0x01_0x10_OServiceEvilNotificationAnon{
 					NewEvil: evilDeltaAnon,
 				},
 			},
-			expectOutput: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.ICBM,
-					SubGroup:  oscar.ICBMEvilReply,
+			expectOutput: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.ICBM,
+					SubGroup:  wire.ICBMEvilReply,
 					RequestID: 1234,
 				},
-				Body: oscar.SNAC_0x04_0x09_ICBMEvilReply{
+				Body: wire.SNAC_0x04_0x09_ICBMEvilReply{
 					EvilDeltaApplied: 30,
 					UpdatedEvilValue: 30,
 				},
@@ -418,44 +419,44 @@ func TestICBMService_EvilRequestHandler(t *testing.T) {
 			senderSession:       newTestSession("sender-screen-name"),
 			recipientScreenName: "recipient-screen-name",
 			recipientBuddies:    []string{"buddy1", "buddy2"},
-			broadcastMessage: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.Buddy,
-					SubGroup:  oscar.BuddyArrived,
+			broadcastMessage: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Buddy,
+					SubGroup:  wire.BuddyArrived,
 				},
-				Body: oscar.SNAC_0x03_0x0B_BuddyArrived{
+				Body: wire.SNAC_0x03_0x0B_BuddyArrived{
 					TLVUserInfo: newTestSession("recipient-screen-name", sessOptCannedSignonTime, sessOptWarning(evilDelta)).TLVUserInfo(),
 				},
 			},
-			inputSNAC: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
 					RequestID: 1234,
 				},
-				Body: oscar.SNAC_0x04_0x08_ICBMEvilRequest{
+				Body: wire.SNAC_0x04_0x08_ICBMEvilRequest{
 					SendAs:     0, // make it identified
 					ScreenName: "recipient-screen-name",
 				},
 			},
-			expectSNACToClient: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.OService,
-					SubGroup:  oscar.OServiceEvilNotification,
+			expectSNACToClient: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.OService,
+					SubGroup:  wire.OServiceEvilNotification,
 				},
-				Body: oscar.SNAC_0x01_0x10_OServiceEvilNotification{
+				Body: wire.SNAC_0x01_0x10_OServiceEvilNotification{
 					NewEvil: evilDelta,
-					TLVUserInfo: oscar.TLVUserInfo{
+					TLVUserInfo: wire.TLVUserInfo{
 						ScreenName:   "sender-screen-name",
 						WarningLevel: 100,
 					},
 				},
 			},
-			expectOutput: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.ICBM,
-					SubGroup:  oscar.ICBMEvilReply,
+			expectOutput: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.ICBM,
+					SubGroup:  wire.ICBMEvilReply,
 					RequestID: 1234,
 				},
-				Body: oscar.SNAC_0x04_0x09_ICBMEvilReply{
+				Body: wire.SNAC_0x04_0x09_ICBMEvilReply{
 					EvilDeltaApplied: 100,
 					UpdatedEvilValue: 100,
 				},
@@ -467,23 +468,23 @@ func TestICBMService_EvilRequestHandler(t *testing.T) {
 			senderSession:       newTestSession("sender-screen-name"),
 			recipientScreenName: "recipient-screen-name",
 			recipientBuddies:    []string{"buddy1", "buddy2"},
-			inputSNAC: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
 					RequestID: 1234,
 				},
-				Body: oscar.SNAC_0x04_0x08_ICBMEvilRequest{
+				Body: wire.SNAC_0x04_0x08_ICBMEvilRequest{
 					SendAs:     0, // make it identified
 					ScreenName: "recipient-screen-name",
 				},
 			},
-			expectOutput: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.ICBM,
-					SubGroup:  oscar.ICBMErr,
+			expectOutput: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.ICBM,
+					SubGroup:  wire.ICBMErr,
 					RequestID: 1234,
 				},
-				Body: oscar.SNACError{
-					Code: oscar.ErrorCodeNotLoggedOn,
+				Body: wire.SNACError{
+					Code: wire.ErrorCodeNotLoggedOn,
 				},
 			},
 		},
@@ -493,23 +494,23 @@ func TestICBMService_EvilRequestHandler(t *testing.T) {
 			senderSession:       newTestSession("sender-screen-name"),
 			recipientScreenName: "recipient-screen-name",
 			recipientBuddies:    []string{"buddy1", "buddy2"},
-			inputSNAC: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
 					RequestID: 1234,
 				},
-				Body: oscar.SNAC_0x04_0x08_ICBMEvilRequest{
+				Body: wire.SNAC_0x04_0x08_ICBMEvilRequest{
 					SendAs:     0, // make it identified
 					ScreenName: "recipient-screen-name",
 				},
 			},
-			expectOutput: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.ICBM,
-					SubGroup:  oscar.ICBMErr,
+			expectOutput: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.ICBM,
+					SubGroup:  wire.ICBMErr,
 					RequestID: 1234,
 				},
-				Body: oscar.SNACError{
-					Code: oscar.ErrorCodeNotLoggedOn,
+				Body: wire.SNACError{
+					Code: wire.ErrorCodeNotLoggedOn,
 				},
 			},
 		},
@@ -517,23 +518,23 @@ func TestICBMService_EvilRequestHandler(t *testing.T) {
 			name:                "don't let users block themselves",
 			senderSession:       newTestSession("sender-screen-name"),
 			recipientScreenName: "sender-screen-name",
-			inputSNAC: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
 					RequestID: 1234,
 				},
-				Body: oscar.SNAC_0x04_0x08_ICBMEvilRequest{
+				Body: wire.SNAC_0x04_0x08_ICBMEvilRequest{
 					SendAs:     0, // make it identified
 					ScreenName: "sender-screen-name",
 				},
 			},
-			expectOutput: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.ICBM,
-					SubGroup:  oscar.ICBMErr,
+			expectOutput: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.ICBM,
+					SubGroup:  wire.ICBMErr,
 					RequestID: 1234,
 				},
-				Body: oscar.SNACError{
-					Code: oscar.ErrorCodeNotSupportedByHost,
+				Body: wire.SNACError{
+					Code: wire.ErrorCodeNotSupportedByHost,
 				},
 			},
 		},
@@ -570,25 +571,25 @@ func TestICBMService_EvilRequestHandler(t *testing.T) {
 			//
 			senderSession := newTestSession(tc.senderSession.ScreenName())
 			svc := NewICBMService(messageRelayer, feedbagManager)
-			outputSNAC, err := svc.EvilRequestHandler(nil, senderSession, tc.inputSNAC.Frame,
-				tc.inputSNAC.Body.(oscar.SNAC_0x04_0x08_ICBMEvilRequest))
+			outputSNAC, err := svc.EvilRequest(nil, senderSession, tc.inputSNAC.Frame,
+				tc.inputSNAC.Body.(wire.SNAC_0x04_0x08_ICBMEvilRequest))
 			assert.NoError(t, err)
 			assert.Equal(t, tc.expectOutput, outputSNAC)
 		})
 	}
 }
 
-func TestICBMService_ParameterQueryHandler(t *testing.T) {
+func TestICBMService_ParameterQuery(t *testing.T) {
 	svc := NewICBMService(nil, nil)
 
-	have := svc.ParameterQueryHandler(nil, oscar.SNACFrame{RequestID: 1234})
-	want := oscar.SNACMessage{
-		Frame: oscar.SNACFrame{
-			FoodGroup: oscar.ICBM,
-			SubGroup:  oscar.ICBMParameterReply,
+	have := svc.ParameterQuery(nil, wire.SNACFrame{RequestID: 1234})
+	want := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.ICBM,
+			SubGroup:  wire.ICBMParameterReply,
 			RequestID: 1234,
 		},
-		Body: oscar.SNAC_0x04_0x05_ICBMParameterReply{
+		Body: wire.SNAC_0x04_0x05_ICBMParameterReply{
 			MaxSlots:             100,
 			ICBMFlags:            3,
 			MaxIncomingICBMLen:   512,

+ 173 - 0
foodgroup/locate.go

@@ -0,0 +1,173 @@
+package foodgroup
+
+import (
+	"context"
+
+	"github.com/mk6i/retro-aim-server/state"
+	"github.com/mk6i/retro-aim-server/wire"
+)
+
+// NewLocateService creates a new instance of LocateService.
+func NewLocateService(messageRelayer MessageRelayer, feedbagManager FeedbagManager, profileManager ProfileManager) LocateService {
+	return LocateService{
+		sessionManager: messageRelayer,
+		feedbagManager: feedbagManager,
+		profileManager: profileManager,
+	}
+}
+
+// LocateService provides functionality for the Locate food group, which is
+// responsible for user profiles, user info lookups, directory information, and
+// keyword lookups.
+type LocateService struct {
+	sessionManager MessageRelayer
+	feedbagManager FeedbagManager
+	profileManager ProfileManager
+}
+
+// RightsQuery returns SNAC wire.LocateRightsReply, which contains Locate food
+// group settings for the current user.
+func (s LocateService) RightsQuery(_ context.Context, inFrame wire.SNACFrame) wire.SNACMessage {
+	return wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Locate,
+			SubGroup:  wire.LocateRightsReply,
+			RequestID: inFrame.RequestID,
+		},
+		Body: wire.SNAC_0x02_0x03_LocateRightsReply{
+			TLVRestBlock: wire.TLVRestBlock{
+				TLVList: wire.TLVList{
+					// these are arbitrary values--AIM clients seem to perform
+					// OK with them
+					wire.NewTLV(wire.LocateTLVTagsRightsMaxSigLen, uint16(1000)),
+					wire.NewTLV(wire.LocateTLVTagsRightsMaxCapabilitiesLen, uint16(1000)),
+					wire.NewTLV(wire.LocateTLVTagsRightsMaxFindByEmailList, uint16(1000)),
+					wire.NewTLV(wire.LocateTLVTagsRightsMaxCertsLen, uint16(1000)),
+					wire.NewTLV(wire.LocateTLVTagsRightsMaxMaxShortCapabilities, uint16(1000)),
+				},
+			},
+		},
+	}
+}
+
+// SetInfo sets the user's profile or away message.
+func (s LocateService) SetInfo(ctx context.Context, sess *state.Session, inBody wire.SNAC_0x02_0x04_LocateSetInfo) error {
+	// update profile
+	if profile, hasProfile := inBody.String(wire.LocateTLVTagsInfoSigData); hasProfile {
+		if err := s.profileManager.SetProfile(sess.ScreenName(), profile); err != nil {
+			return err
+		}
+	}
+
+	// broadcast away message change to buddies
+	if awayMsg, hasAwayMsg := inBody.String(wire.LocateTLVTagsInfoUnavailableData); hasAwayMsg {
+		sess.SetAwayMessage(awayMsg)
+		if err := broadcastArrival(ctx, sess, s.sessionManager, s.feedbagManager); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+// UserInfoQuery2 fetches display information about an arbitrary user (not the
+// current user). It returns wire.LocateUserInfoReply, which contains the
+// profile, if requested, and/or the away message, if requested. This is a v2
+// of UserInfoQuery.
+func (s LocateService) UserInfoQuery2(_ context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x02_0x15_LocateUserInfoQuery2) (wire.SNACMessage, error) {
+	blocked, err := s.feedbagManager.BlockedState(sess.ScreenName(), inBody.ScreenName)
+	switch {
+	case err != nil:
+		return wire.SNACMessage{}, err
+	case blocked != state.BlockedNo:
+		return wire.SNACMessage{
+			Frame: wire.SNACFrame{
+				FoodGroup: wire.Locate,
+				SubGroup:  wire.LocateErr,
+				RequestID: inFrame.RequestID,
+			},
+			Body: wire.SNACError{
+				Code: wire.ErrorCodeNotLoggedOn,
+			},
+		}, nil
+	}
+
+	buddySess := s.sessionManager.RetrieveByScreenName(inBody.ScreenName)
+	if buddySess == nil {
+		return wire.SNACMessage{
+			Frame: wire.SNACFrame{
+				FoodGroup: wire.Locate,
+				SubGroup:  wire.LocateErr,
+				RequestID: inFrame.RequestID,
+			},
+			Body: wire.SNACError{
+				Code: wire.ErrorCodeNotLoggedOn,
+			},
+		}, nil
+	}
+
+	var list wire.TLVList
+
+	if inBody.RequestProfile() {
+		profile, err := s.profileManager.Profile(inBody.ScreenName)
+		if err != nil {
+			return wire.SNACMessage{}, err
+		}
+		list.AppendList([]wire.TLV{
+			wire.NewTLV(wire.LocateTLVTagsInfoSigMime, `text/aolrtf; charset="us-ascii"`),
+			wire.NewTLV(wire.LocateTLVTagsInfoSigData, profile),
+		})
+	}
+
+	if inBody.RequestAwayMessage() {
+		list.AppendList([]wire.TLV{
+			wire.NewTLV(wire.LocateTLVTagsInfoUnavailableMime, `text/aolrtf; charset="us-ascii"`),
+			wire.NewTLV(wire.LocateTLVTagsInfoUnavailableData, buddySess.AwayMessage()),
+		})
+	}
+
+	return wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Locate,
+			SubGroup:  wire.LocateUserInfoReply,
+			RequestID: inFrame.RequestID,
+		},
+		Body: wire.SNAC_0x02_0x06_LocateUserInfoReply{
+			TLVUserInfo: buddySess.TLVUserInfo(),
+			LocateInfo: wire.TLVRestBlock{
+				TLVList: list,
+			},
+		},
+	}, nil
+}
+
+// SetDirInfo sets directory information for current user (first name, last
+// name, etc). This method does nothing and exists to placate the AIM client.
+// It returns wire.LocateSetDirReply with a canned success message.
+func (s LocateService) SetDirInfo(_ context.Context, inFrame wire.SNACFrame) wire.SNACMessage {
+	return wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Locate,
+			SubGroup:  wire.LocateSetDirReply,
+			RequestID: inFrame.RequestID,
+		},
+		Body: wire.SNAC_0x02_0x0A_LocateSetDirReply{
+			Result: 1,
+		},
+	}
+}
+
+// SetKeywordInfo sets profile keywords and interests. This method does nothing
+// and exists to placate the AIM client. It returns wire.LocateSetKeywordReply
+// with a canned success message.
+func (s LocateService) SetKeywordInfo(_ context.Context, inFrame wire.SNACFrame) wire.SNACMessage {
+	return wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Locate,
+			SubGroup:  wire.LocateSetKeywordReply,
+			RequestID: inFrame.RequestID,
+		},
+		Body: wire.SNAC_0x02_0x10_LocateSetKeywordReply{
+			Unknown: 1,
+		},
+	}
+}

+ 118 - 118
handler/locate_test.go → foodgroup/locate_test.go

@@ -1,4 +1,4 @@
-package handler
+package foodgroup
 
 import (
 	"context"
@@ -6,12 +6,12 @@ import (
 
 	"github.com/stretchr/testify/mock"
 
-	"github.com/mk6i/retro-aim-server/oscar"
 	"github.com/mk6i/retro-aim-server/state"
+	"github.com/mk6i/retro-aim-server/wire"
 	"github.com/stretchr/testify/assert"
 )
 
-func TestLocateService_UserInfoQuery2Handler(t *testing.T) {
+func TestLocateService_UserInfoQuery2(t *testing.T) {
 	cases := []struct {
 		// name is the unit test name
 		name string
@@ -21,9 +21,9 @@ func TestLocateService_UserInfoQuery2Handler(t *testing.T) {
 		// userSession is the session of the user requesting user info
 		userSession *state.Session
 		// inputSNAC is the SNAC sent from client to server
-		inputSNAC oscar.SNACMessage
+		inputSNAC wire.SNACMessage
 		// expectOutput is the SNAC sent from the server to client
-		expectOutput oscar.SNACMessage
+		expectOutput wire.SNACMessage
 	}{
 		{
 			name: "request user info, expect user info response",
@@ -49,27 +49,27 @@ func TestLocateService_UserInfoQuery2Handler(t *testing.T) {
 				},
 			},
 			userSession: newTestSession("user_screen_name"),
-			inputSNAC: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
 					RequestID: 1234,
 				},
-				Body: oscar.SNAC_0x02_0x15_LocateUserInfoQuery2{
+				Body: wire.SNAC_0x02_0x15_LocateUserInfoQuery2{
 					Type2:      0,
 					ScreenName: "requested-user",
 				},
 			},
-			expectOutput: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.Locate,
-					SubGroup:  oscar.LocateUserInfoReply,
+			expectOutput: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Locate,
+					SubGroup:  wire.LocateUserInfoReply,
 					RequestID: 1234,
 				},
-				Body: oscar.SNAC_0x02_0x06_LocateUserInfoReply{
+				Body: wire.SNAC_0x02_0x06_LocateUserInfoReply{
 					TLVUserInfo: newTestSession("requested-user",
 						sessOptCannedSignonTime,
 						sessOptCannedAwayMessage).
 						TLVUserInfo(),
-					LocateInfo: oscar.TLVRestBlock{},
+					LocateInfo: wire.TLVRestBlock{},
 				},
 			},
 		},
@@ -105,31 +105,31 @@ func TestLocateService_UserInfoQuery2Handler(t *testing.T) {
 				},
 			},
 			userSession: newTestSession("user_screen_name"),
-			inputSNAC: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
 					RequestID: 1234,
 				},
-				Body: oscar.SNAC_0x02_0x15_LocateUserInfoQuery2{
+				Body: wire.SNAC_0x02_0x15_LocateUserInfoQuery2{
 					// 2048 is a dummy to make sure bitmask check works
-					Type2:      oscar.LocateType2Sig | 2048,
+					Type2:      wire.LocateType2Sig | 2048,
 					ScreenName: "requested-user",
 				},
 			},
-			expectOutput: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.Locate,
-					SubGroup:  oscar.LocateUserInfoReply,
+			expectOutput: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Locate,
+					SubGroup:  wire.LocateUserInfoReply,
 					RequestID: 1234,
 				},
-				Body: oscar.SNAC_0x02_0x06_LocateUserInfoReply{
+				Body: wire.SNAC_0x02_0x06_LocateUserInfoReply{
 					TLVUserInfo: newTestSession("requested-user",
 						sessOptCannedSignonTime,
 						sessOptCannedAwayMessage).
 						TLVUserInfo(),
-					LocateInfo: oscar.TLVRestBlock{
-						TLVList: oscar.TLVList{
-							oscar.NewTLV(oscar.LocateTLVTagsInfoSigMime, `text/aolrtf; charset="us-ascii"`),
-							oscar.NewTLV(oscar.LocateTLVTagsInfoSigData, "this is my profile!"),
+					LocateInfo: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLV(wire.LocateTLVTagsInfoSigMime, `text/aolrtf; charset="us-ascii"`),
+							wire.NewTLV(wire.LocateTLVTagsInfoSigData, "this is my profile!"),
 						},
 					},
 				},
@@ -167,31 +167,31 @@ func TestLocateService_UserInfoQuery2Handler(t *testing.T) {
 				},
 			},
 			userSession: newTestSession("user_screen_name"),
-			inputSNAC: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
 					RequestID: 1234,
 				},
-				Body: oscar.SNAC_0x02_0x15_LocateUserInfoQuery2{
+				Body: wire.SNAC_0x02_0x15_LocateUserInfoQuery2{
 					// 2048 is a dummy to make sure bitmask check works
-					Type2:      oscar.LocateType2Sig | 2048,
+					Type2:      wire.LocateType2Sig | 2048,
 					ScreenName: "requested-user",
 				},
 			},
-			expectOutput: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.Locate,
-					SubGroup:  oscar.LocateUserInfoReply,
+			expectOutput: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Locate,
+					SubGroup:  wire.LocateUserInfoReply,
 					RequestID: 1234,
 				},
-				Body: oscar.SNAC_0x02_0x06_LocateUserInfoReply{
+				Body: wire.SNAC_0x02_0x06_LocateUserInfoReply{
 					TLVUserInfo: newTestSession("requested-user",
 						sessOptCannedSignonTime,
 						sessOptCannedAwayMessage).
 						TLVUserInfo(),
-					LocateInfo: oscar.TLVRestBlock{
-						TLVList: oscar.TLVList{
-							oscar.NewTLV(oscar.LocateTLVTagsInfoSigMime, `text/aolrtf; charset="us-ascii"`),
-							oscar.NewTLV(oscar.LocateTLVTagsInfoSigData, "this is my profile!"),
+					LocateInfo: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLV(wire.LocateTLVTagsInfoSigMime, `text/aolrtf; charset="us-ascii"`),
+							wire.NewTLV(wire.LocateTLVTagsInfoSigData, "this is my profile!"),
 						},
 					},
 				},
@@ -221,31 +221,31 @@ func TestLocateService_UserInfoQuery2Handler(t *testing.T) {
 				},
 			},
 			userSession: newTestSession("user_screen_name"),
-			inputSNAC: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
 					RequestID: 1234,
 				},
-				Body: oscar.SNAC_0x02_0x15_LocateUserInfoQuery2{
+				Body: wire.SNAC_0x02_0x15_LocateUserInfoQuery2{
 					// 2048 is a dummy to make sure bitmask check works
-					Type2:      oscar.LocateType2Unavailable | 2048,
+					Type2:      wire.LocateType2Unavailable | 2048,
 					ScreenName: "requested-user",
 				},
 			},
-			expectOutput: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.Locate,
-					SubGroup:  oscar.LocateUserInfoReply,
+			expectOutput: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Locate,
+					SubGroup:  wire.LocateUserInfoReply,
 					RequestID: 1234,
 				},
-				Body: oscar.SNAC_0x02_0x06_LocateUserInfoReply{
+				Body: wire.SNAC_0x02_0x06_LocateUserInfoReply{
 					TLVUserInfo: newTestSession("requested-user",
 						sessOptCannedSignonTime,
 						sessOptCannedAwayMessage).
 						TLVUserInfo(),
-					LocateInfo: oscar.TLVRestBlock{
-						TLVList: oscar.TLVList{
-							oscar.NewTLV(oscar.LocateTLVTagsInfoUnavailableMime, `text/aolrtf; charset="us-ascii"`),
-							oscar.NewTLV(oscar.LocateTLVTagsInfoUnavailableData, "this is my away message!"),
+					LocateInfo: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLV(wire.LocateTLVTagsInfoUnavailableMime, `text/aolrtf; charset="us-ascii"`),
+							wire.NewTLV(wire.LocateTLVTagsInfoUnavailableData, "this is my away message!"),
 						},
 					},
 				},
@@ -265,22 +265,22 @@ func TestLocateService_UserInfoQuery2Handler(t *testing.T) {
 				},
 			},
 			userSession: newTestSession("user_screen_name"),
-			inputSNAC: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
 					RequestID: 1234,
 				},
-				Body: oscar.SNAC_0x02_0x15_LocateUserInfoQuery2{
+				Body: wire.SNAC_0x02_0x15_LocateUserInfoQuery2{
 					ScreenName: "requested-user",
 				},
 			},
-			expectOutput: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.Locate,
-					SubGroup:  oscar.LocateErr,
+			expectOutput: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Locate,
+					SubGroup:  wire.LocateErr,
 					RequestID: 1234,
 				},
-				Body: oscar.SNACError{
-					Code: oscar.ErrorCodeNotLoggedOn,
+				Body: wire.SNACError{
+					Code: wire.ErrorCodeNotLoggedOn,
 				},
 			},
 		},
@@ -306,22 +306,22 @@ func TestLocateService_UserInfoQuery2Handler(t *testing.T) {
 				},
 			},
 			userSession: newTestSession("user_screen_name"),
-			inputSNAC: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
 					RequestID: 1234,
 				},
-				Body: oscar.SNAC_0x02_0x15_LocateUserInfoQuery2{
+				Body: wire.SNAC_0x02_0x15_LocateUserInfoQuery2{
 					ScreenName: "non_existent_requested_user",
 				},
 			},
-			expectOutput: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.Locate,
-					SubGroup:  oscar.LocateErr,
+			expectOutput: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Locate,
+					SubGroup:  wire.LocateErr,
 					RequestID: 1234,
 				},
-				Body: oscar.SNACError{
-					Code: oscar.ErrorCodeNotLoggedOn,
+				Body: wire.SNACError{
+					Code: wire.ErrorCodeNotLoggedOn,
 				},
 			},
 		},
@@ -348,25 +348,25 @@ func TestLocateService_UserInfoQuery2Handler(t *testing.T) {
 					Return(val.result, val.err)
 			}
 			svc := NewLocateService(messageRelayer, feedbagManager, profileManager)
-			outputSNAC, err := svc.UserInfoQuery2Handler(context.Background(), tc.userSession, tc.inputSNAC.Frame,
-				tc.inputSNAC.Body.(oscar.SNAC_0x02_0x15_LocateUserInfoQuery2))
+			outputSNAC, err := svc.UserInfoQuery2(context.Background(), tc.userSession, tc.inputSNAC.Frame,
+				tc.inputSNAC.Body.(wire.SNAC_0x02_0x15_LocateUserInfoQuery2))
 			assert.NoError(t, err)
 			assert.Equal(t, tc.expectOutput, outputSNAC)
 		})
 	}
 }
 
-func TestLocateService_SetKeywordInfoHandler(t *testing.T) {
+func TestLocateService_SetKeywordInfo(t *testing.T) {
 	svc := NewLocateService(nil, nil, nil)
 
-	outputSNAC := svc.SetKeywordInfoHandler(nil, oscar.SNACFrame{RequestID: 1234})
-	expectSNAC := oscar.SNACMessage{
-		Frame: oscar.SNACFrame{
-			FoodGroup: oscar.Locate,
-			SubGroup:  oscar.LocateSetKeywordReply,
+	outputSNAC := svc.SetKeywordInfo(nil, wire.SNACFrame{RequestID: 1234})
+	expectSNAC := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Locate,
+			SubGroup:  wire.LocateSetKeywordReply,
 			RequestID: 1234,
 		},
-		Body: oscar.SNAC_0x02_0x10_LocateSetKeywordReply{
+		Body: wire.SNAC_0x02_0x10_LocateSetKeywordReply{
 			Unknown: 1,
 		},
 	}
@@ -374,17 +374,17 @@ func TestLocateService_SetKeywordInfoHandler(t *testing.T) {
 	assert.Equal(t, expectSNAC, outputSNAC)
 }
 
-func TestLocateService_SetDirInfoHandler(t *testing.T) {
+func TestLocateService_SetDirInfo(t *testing.T) {
 	svc := NewLocateService(nil, nil, nil)
 
-	outputSNAC := svc.SetDirInfoHandler(nil, oscar.SNACFrame{RequestID: 1234})
-	expectSNAC := oscar.SNACMessage{
-		Frame: oscar.SNACFrame{
-			FoodGroup: oscar.Locate,
-			SubGroup:  oscar.LocateSetDirReply,
+	outputSNAC := svc.SetDirInfo(nil, wire.SNACFrame{RequestID: 1234})
+	expectSNAC := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Locate,
+			SubGroup:  wire.LocateSetDirReply,
 			RequestID: 1234,
 		},
-		Body: oscar.SNAC_0x02_0x0A_LocateSetDirReply{
+		Body: wire.SNAC_0x02_0x0A_LocateSetDirReply{
 			Result: 1,
 		},
 	}
@@ -392,14 +392,14 @@ func TestLocateService_SetDirInfoHandler(t *testing.T) {
 	assert.Equal(t, expectSNAC, outputSNAC)
 }
 
-func TestLocateService_SetInfoHandler(t *testing.T) {
+func TestLocateService_SetInfo(t *testing.T) {
 	tests := []struct {
 		// name is the unit test name
 		name string
 		// userSession is the session of the user setting info
 		userSession *state.Session
 		// inBody is the message sent from client to server
-		inBody oscar.SNAC_0x02_0x04_LocateSetInfo
+		inBody wire.SNAC_0x02_0x04_LocateSetInfo
 		// mockParams is the list of params sent to mocks that satisfy this
 		// method's dependencies
 		mockParams mockParams
@@ -408,10 +408,10 @@ func TestLocateService_SetInfoHandler(t *testing.T) {
 		{
 			name:        "set profile",
 			userSession: newTestSession("test-user"),
-			inBody: oscar.SNAC_0x02_0x04_LocateSetInfo{
-				TLVRestBlock: oscar.TLVRestBlock{
-					TLVList: oscar.TLVList{
-						oscar.NewTLV(oscar.LocateTLVTagsInfoSigData, "profile-result"),
+			inBody: wire.SNAC_0x02_0x04_LocateSetInfo{
+				TLVRestBlock: wire.TLVRestBlock{
+					TLVList: wire.TLVList{
+						wire.NewTLV(wire.LocateTLVTagsInfoSigData, "profile-result"),
 					},
 				},
 			},
@@ -419,10 +419,10 @@ func TestLocateService_SetInfoHandler(t *testing.T) {
 		{
 			name:        "set away message",
 			userSession: newTestSession("user_screen_name"),
-			inBody: oscar.SNAC_0x02_0x04_LocateSetInfo{
-				TLVRestBlock: oscar.TLVRestBlock{
-					TLVList: oscar.TLVList{
-						oscar.NewTLV(oscar.LocateTLVTagsInfoUnavailableData, "this is my away message!"),
+			inBody: wire.SNAC_0x02_0x04_LocateSetInfo{
+				TLVRestBlock: wire.TLVRestBlock{
+					TLVList: wire.TLVList{
+						wire.NewTLV(wire.LocateTLVTagsInfoUnavailableData, "this is my away message!"),
 					},
 				},
 			},
@@ -431,12 +431,12 @@ func TestLocateService_SetInfoHandler(t *testing.T) {
 					broadcastToScreenNamesParams: broadcastToScreenNamesParams{
 						{
 							screenNames: []string{"friend1", "friend2"},
-							message: oscar.SNACMessage{
-								Frame: oscar.SNACFrame{
-									FoodGroup: oscar.Buddy,
-									SubGroup:  oscar.BuddyArrived,
+							message: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.Buddy,
+									SubGroup:  wire.BuddyArrived,
 								},
-								Body: oscar.SNAC_0x03_0x0B_BuddyArrived{
+								Body: wire.SNAC_0x03_0x0B_BuddyArrived{
 									TLVUserInfo: newTestSession("user_screen_name", sessOptAwayMessage("this is my away message!")).TLVUserInfo(),
 								},
 							},
@@ -471,35 +471,35 @@ func TestLocateService_SetInfoHandler(t *testing.T) {
 					Return(params.users, nil)
 			}
 			profileManager := newMockProfileManager(t)
-			if msg, hasProf := tt.inBody.String(oscar.LocateTLVTagsInfoSigData); hasProf {
+			if msg, hasProf := tt.inBody.String(wire.LocateTLVTagsInfoSigData); hasProf {
 				profileManager.EXPECT().
 					SetProfile(tt.userSession.ScreenName(), msg).
 					Return(nil)
 			}
 			svc := NewLocateService(messageRelayer, feedbagManager, profileManager)
-			assert.Equal(t, tt.wantErr, svc.SetInfoHandler(nil, tt.userSession, tt.inBody))
+			assert.Equal(t, tt.wantErr, svc.SetInfo(nil, tt.userSession, tt.inBody))
 		})
 	}
 }
 
-func TestLocateService_RightsQueryHandler(t *testing.T) {
+func TestLocateService_RightsQuery(t *testing.T) {
 	svc := NewLocateService(nil, nil, nil)
 
-	outputSNAC := svc.RightsQueryHandler(nil, oscar.SNACFrame{RequestID: 1234})
-	expectSNAC := oscar.SNACMessage{
-		Frame: oscar.SNACFrame{
-			FoodGroup: oscar.Locate,
-			SubGroup:  oscar.LocateRightsReply,
+	outputSNAC := svc.RightsQuery(nil, wire.SNACFrame{RequestID: 1234})
+	expectSNAC := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Locate,
+			SubGroup:  wire.LocateRightsReply,
 			RequestID: 1234,
 		},
-		Body: oscar.SNAC_0x02_0x03_LocateRightsReply{
-			TLVRestBlock: oscar.TLVRestBlock{
-				TLVList: oscar.TLVList{
-					oscar.NewTLV(oscar.LocateTLVTagsRightsMaxSigLen, uint16(1000)),
-					oscar.NewTLV(oscar.LocateTLVTagsRightsMaxCapabilitiesLen, uint16(1000)),
-					oscar.NewTLV(oscar.LocateTLVTagsRightsMaxFindByEmailList, uint16(1000)),
-					oscar.NewTLV(oscar.LocateTLVTagsRightsMaxCertsLen, uint16(1000)),
-					oscar.NewTLV(oscar.LocateTLVTagsRightsMaxMaxShortCapabilities, uint16(1000)),
+		Body: wire.SNAC_0x02_0x03_LocateRightsReply{
+			TLVRestBlock: wire.TLVRestBlock{
+				TLVList: wire.TLVList{
+					wire.NewTLV(wire.LocateTLVTagsRightsMaxSigLen, uint16(1000)),
+					wire.NewTLV(wire.LocateTLVTagsRightsMaxCapabilitiesLen, uint16(1000)),
+					wire.NewTLV(wire.LocateTLVTagsRightsMaxFindByEmailList, uint16(1000)),
+					wire.NewTLV(wire.LocateTLVTagsRightsMaxCertsLen, uint16(1000)),
+					wire.NewTLV(wire.LocateTLVTagsRightsMaxMaxShortCapabilities, uint16(1000)),
 				},
 			},
 		},

+ 19 - 19
handler/chat_message_relayer_mock_test.go → foodgroup/mock_chat_message_relayer_test.go

@@ -1,14 +1,14 @@
-// Code generated by mockery v2.38.0. DO NOT EDIT.
+// Code generated by mockery v2.40.1. DO NOT EDIT.
 
-package handler
+package foodgroup
 
 import (
 	context "context"
 
-	oscar "github.com/mk6i/retro-aim-server/oscar"
+	state "github.com/mk6i/retro-aim-server/state"
 	mock "github.com/stretchr/testify/mock"
 
-	state "github.com/mk6i/retro-aim-server/state"
+	wire "github.com/mk6i/retro-aim-server/wire"
 )
 
 // mockChatMessageRelayer is an autogenerated mock type for the ChatMessageRelayer type
@@ -72,7 +72,7 @@ func (_c *mockChatMessageRelayer_AllSessions_Call) RunAndReturn(run func() []*st
 }
 
 // RelayToAllExcept provides a mock function with given fields: ctx, except, msg
-func (_m *mockChatMessageRelayer) RelayToAllExcept(ctx context.Context, except *state.Session, msg oscar.SNACMessage) {
+func (_m *mockChatMessageRelayer) RelayToAllExcept(ctx context.Context, except *state.Session, msg wire.SNACMessage) {
 	_m.Called(ctx, except, msg)
 }
 
@@ -84,14 +84,14 @@ type mockChatMessageRelayer_RelayToAllExcept_Call struct {
 // RelayToAllExcept is a helper method to define mock.On call
 //   - ctx context.Context
 //   - except *state.Session
-//   - msg oscar.SNACMessage
+//   - msg wire.SNACMessage
 func (_e *mockChatMessageRelayer_Expecter) RelayToAllExcept(ctx interface{}, except interface{}, msg interface{}) *mockChatMessageRelayer_RelayToAllExcept_Call {
 	return &mockChatMessageRelayer_RelayToAllExcept_Call{Call: _e.mock.On("RelayToAllExcept", ctx, except, msg)}
 }
 
-func (_c *mockChatMessageRelayer_RelayToAllExcept_Call) Run(run func(ctx context.Context, except *state.Session, msg oscar.SNACMessage)) *mockChatMessageRelayer_RelayToAllExcept_Call {
+func (_c *mockChatMessageRelayer_RelayToAllExcept_Call) Run(run func(ctx context.Context, except *state.Session, msg wire.SNACMessage)) *mockChatMessageRelayer_RelayToAllExcept_Call {
 	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(context.Context), args[1].(*state.Session), args[2].(oscar.SNACMessage))
+		run(args[0].(context.Context), args[1].(*state.Session), args[2].(wire.SNACMessage))
 	})
 	return _c
 }
@@ -101,13 +101,13 @@ func (_c *mockChatMessageRelayer_RelayToAllExcept_Call) Return() *mockChatMessag
 	return _c
 }
 
-func (_c *mockChatMessageRelayer_RelayToAllExcept_Call) RunAndReturn(run func(context.Context, *state.Session, oscar.SNACMessage)) *mockChatMessageRelayer_RelayToAllExcept_Call {
+func (_c *mockChatMessageRelayer_RelayToAllExcept_Call) RunAndReturn(run func(context.Context, *state.Session, wire.SNACMessage)) *mockChatMessageRelayer_RelayToAllExcept_Call {
 	_c.Call.Return(run)
 	return _c
 }
 
 // RelayToScreenName provides a mock function with given fields: ctx, screenName, msg
-func (_m *mockChatMessageRelayer) RelayToScreenName(ctx context.Context, screenName string, msg oscar.SNACMessage) {
+func (_m *mockChatMessageRelayer) RelayToScreenName(ctx context.Context, screenName string, msg wire.SNACMessage) {
 	_m.Called(ctx, screenName, msg)
 }
 
@@ -119,14 +119,14 @@ type mockChatMessageRelayer_RelayToScreenName_Call struct {
 // RelayToScreenName is a helper method to define mock.On call
 //   - ctx context.Context
 //   - screenName string
-//   - msg oscar.SNACMessage
+//   - msg wire.SNACMessage
 func (_e *mockChatMessageRelayer_Expecter) RelayToScreenName(ctx interface{}, screenName interface{}, msg interface{}) *mockChatMessageRelayer_RelayToScreenName_Call {
 	return &mockChatMessageRelayer_RelayToScreenName_Call{Call: _e.mock.On("RelayToScreenName", ctx, screenName, msg)}
 }
 
-func (_c *mockChatMessageRelayer_RelayToScreenName_Call) Run(run func(ctx context.Context, screenName string, msg oscar.SNACMessage)) *mockChatMessageRelayer_RelayToScreenName_Call {
+func (_c *mockChatMessageRelayer_RelayToScreenName_Call) Run(run func(ctx context.Context, screenName string, msg wire.SNACMessage)) *mockChatMessageRelayer_RelayToScreenName_Call {
 	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(context.Context), args[1].(string), args[2].(oscar.SNACMessage))
+		run(args[0].(context.Context), args[1].(string), args[2].(wire.SNACMessage))
 	})
 	return _c
 }
@@ -136,13 +136,13 @@ func (_c *mockChatMessageRelayer_RelayToScreenName_Call) Return() *mockChatMessa
 	return _c
 }
 
-func (_c *mockChatMessageRelayer_RelayToScreenName_Call) RunAndReturn(run func(context.Context, string, oscar.SNACMessage)) *mockChatMessageRelayer_RelayToScreenName_Call {
+func (_c *mockChatMessageRelayer_RelayToScreenName_Call) RunAndReturn(run func(context.Context, string, wire.SNACMessage)) *mockChatMessageRelayer_RelayToScreenName_Call {
 	_c.Call.Return(run)
 	return _c
 }
 
 // RelayToScreenNames provides a mock function with given fields: ctx, screenNames, msg
-func (_m *mockChatMessageRelayer) RelayToScreenNames(ctx context.Context, screenNames []string, msg oscar.SNACMessage) {
+func (_m *mockChatMessageRelayer) RelayToScreenNames(ctx context.Context, screenNames []string, msg wire.SNACMessage) {
 	_m.Called(ctx, screenNames, msg)
 }
 
@@ -154,14 +154,14 @@ type mockChatMessageRelayer_RelayToScreenNames_Call struct {
 // RelayToScreenNames is a helper method to define mock.On call
 //   - ctx context.Context
 //   - screenNames []string
-//   - msg oscar.SNACMessage
+//   - msg wire.SNACMessage
 func (_e *mockChatMessageRelayer_Expecter) RelayToScreenNames(ctx interface{}, screenNames interface{}, msg interface{}) *mockChatMessageRelayer_RelayToScreenNames_Call {
 	return &mockChatMessageRelayer_RelayToScreenNames_Call{Call: _e.mock.On("RelayToScreenNames", ctx, screenNames, msg)}
 }
 
-func (_c *mockChatMessageRelayer_RelayToScreenNames_Call) Run(run func(ctx context.Context, screenNames []string, msg oscar.SNACMessage)) *mockChatMessageRelayer_RelayToScreenNames_Call {
+func (_c *mockChatMessageRelayer_RelayToScreenNames_Call) Run(run func(ctx context.Context, screenNames []string, msg wire.SNACMessage)) *mockChatMessageRelayer_RelayToScreenNames_Call {
 	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(context.Context), args[1].([]string), args[2].(oscar.SNACMessage))
+		run(args[0].(context.Context), args[1].([]string), args[2].(wire.SNACMessage))
 	})
 	return _c
 }
@@ -171,7 +171,7 @@ func (_c *mockChatMessageRelayer_RelayToScreenNames_Call) Return() *mockChatMess
 	return _c
 }
 
-func (_c *mockChatMessageRelayer_RelayToScreenNames_Call) RunAndReturn(run func(context.Context, []string, oscar.SNACMessage)) *mockChatMessageRelayer_RelayToScreenNames_Call {
+func (_c *mockChatMessageRelayer_RelayToScreenNames_Call) RunAndReturn(run func(context.Context, []string, wire.SNACMessage)) *mockChatMessageRelayer_RelayToScreenNames_Call {
 	_c.Call.Return(run)
 	return _c
 }

+ 20 - 20
handler/chat_registry_test.go → foodgroup/mock_chat_registry_test.go

@@ -1,6 +1,6 @@
-// Code generated by mockery v2.38.0. DO NOT EDIT.
+// Code generated by mockery v2.40.1. DO NOT EDIT.
 
-package handler
+package foodgroup
 
 import (
 	state "github.com/mk6i/retro-aim-server/state"
@@ -54,9 +54,9 @@ func (_c *mockChatRegistry_Register_Call) RunAndReturn(run func(state.ChatRoom,
 	return _c
 }
 
-// Remove provides a mock function with given fields: chatID
-func (_m *mockChatRegistry) Remove(chatID string) {
-	_m.Called(chatID)
+// Remove provides a mock function with given fields: cookie
+func (_m *mockChatRegistry) Remove(cookie string) {
+	_m.Called(cookie)
 }
 
 // mockChatRegistry_Remove_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Remove'
@@ -65,12 +65,12 @@ type mockChatRegistry_Remove_Call struct {
 }
 
 // Remove is a helper method to define mock.On call
-//   - chatID string
-func (_e *mockChatRegistry_Expecter) Remove(chatID interface{}) *mockChatRegistry_Remove_Call {
-	return &mockChatRegistry_Remove_Call{Call: _e.mock.On("Remove", chatID)}
+//   - cookie string
+func (_e *mockChatRegistry_Expecter) Remove(cookie interface{}) *mockChatRegistry_Remove_Call {
+	return &mockChatRegistry_Remove_Call{Call: _e.mock.On("Remove", cookie)}
 }
 
-func (_c *mockChatRegistry_Remove_Call) Run(run func(chatID string)) *mockChatRegistry_Remove_Call {
+func (_c *mockChatRegistry_Remove_Call) Run(run func(cookie string)) *mockChatRegistry_Remove_Call {
 	_c.Call.Run(func(args mock.Arguments) {
 		run(args[0].(string))
 	})
@@ -87,9 +87,9 @@ func (_c *mockChatRegistry_Remove_Call) RunAndReturn(run func(string)) *mockChat
 	return _c
 }
 
-// Retrieve provides a mock function with given fields: chatID
-func (_m *mockChatRegistry) Retrieve(chatID string) (state.ChatRoom, interface{}, error) {
-	ret := _m.Called(chatID)
+// Retrieve provides a mock function with given fields: cookie
+func (_m *mockChatRegistry) Retrieve(cookie string) (state.ChatRoom, interface{}, error) {
+	ret := _m.Called(cookie)
 
 	if len(ret) == 0 {
 		panic("no return value specified for Retrieve")
@@ -99,16 +99,16 @@ func (_m *mockChatRegistry) Retrieve(chatID string) (state.ChatRoom, interface{}
 	var r1 interface{}
 	var r2 error
 	if rf, ok := ret.Get(0).(func(string) (state.ChatRoom, interface{}, error)); ok {
-		return rf(chatID)
+		return rf(cookie)
 	}
 	if rf, ok := ret.Get(0).(func(string) state.ChatRoom); ok {
-		r0 = rf(chatID)
+		r0 = rf(cookie)
 	} else {
 		r0 = ret.Get(0).(state.ChatRoom)
 	}
 
 	if rf, ok := ret.Get(1).(func(string) interface{}); ok {
-		r1 = rf(chatID)
+		r1 = rf(cookie)
 	} else {
 		if ret.Get(1) != nil {
 			r1 = ret.Get(1).(interface{})
@@ -116,7 +116,7 @@ func (_m *mockChatRegistry) Retrieve(chatID string) (state.ChatRoom, interface{}
 	}
 
 	if rf, ok := ret.Get(2).(func(string) error); ok {
-		r2 = rf(chatID)
+		r2 = rf(cookie)
 	} else {
 		r2 = ret.Error(2)
 	}
@@ -130,12 +130,12 @@ type mockChatRegistry_Retrieve_Call struct {
 }
 
 // Retrieve is a helper method to define mock.On call
-//   - chatID string
-func (_e *mockChatRegistry_Expecter) Retrieve(chatID interface{}) *mockChatRegistry_Retrieve_Call {
-	return &mockChatRegistry_Retrieve_Call{Call: _e.mock.On("Retrieve", chatID)}
+//   - cookie string
+func (_e *mockChatRegistry_Expecter) Retrieve(cookie interface{}) *mockChatRegistry_Retrieve_Call {
+	return &mockChatRegistry_Retrieve_Call{Call: _e.mock.On("Retrieve", cookie)}
 }
 
-func (_c *mockChatRegistry_Retrieve_Call) Run(run func(chatID string)) *mockChatRegistry_Retrieve_Call {
+func (_c *mockChatRegistry_Retrieve_Call) Run(run func(cookie string)) *mockChatRegistry_Retrieve_Call {
 	_c.Call.Run(func(args mock.Arguments) {
 		run(args[0].(string))
 	})

+ 24 - 24
handler/feedbag_manager_mock_test.go → foodgroup/mock_feedbag_manager_test.go

@@ -1,14 +1,14 @@
-// Code generated by mockery v2.38.0. DO NOT EDIT.
+// Code generated by mockery v2.40.1. DO NOT EDIT.
 
-package handler
+package foodgroup
 
 import (
-	oscar "github.com/mk6i/retro-aim-server/oscar"
-	mock "github.com/stretchr/testify/mock"
-
 	state "github.com/mk6i/retro-aim-server/state"
+	mock "github.com/stretchr/testify/mock"
 
 	time "time"
+
+	wire "github.com/mk6i/retro-aim-server/wire"
 )
 
 // mockFeedbagManager is an autogenerated mock type for the FeedbagManager type
@@ -198,23 +198,23 @@ func (_c *mockFeedbagManager_Buddies_Call) RunAndReturn(run func(string) ([]stri
 }
 
 // Feedbag provides a mock function with given fields: screenName
-func (_m *mockFeedbagManager) Feedbag(screenName string) ([]oscar.FeedbagItem, error) {
+func (_m *mockFeedbagManager) Feedbag(screenName string) ([]wire.FeedbagItem, error) {
 	ret := _m.Called(screenName)
 
 	if len(ret) == 0 {
 		panic("no return value specified for Feedbag")
 	}
 
-	var r0 []oscar.FeedbagItem
+	var r0 []wire.FeedbagItem
 	var r1 error
-	if rf, ok := ret.Get(0).(func(string) ([]oscar.FeedbagItem, error)); ok {
+	if rf, ok := ret.Get(0).(func(string) ([]wire.FeedbagItem, error)); ok {
 		return rf(screenName)
 	}
-	if rf, ok := ret.Get(0).(func(string) []oscar.FeedbagItem); ok {
+	if rf, ok := ret.Get(0).(func(string) []wire.FeedbagItem); ok {
 		r0 = rf(screenName)
 	} else {
 		if ret.Get(0) != nil {
-			r0 = ret.Get(0).([]oscar.FeedbagItem)
+			r0 = ret.Get(0).([]wire.FeedbagItem)
 		}
 	}
 
@@ -245,18 +245,18 @@ func (_c *mockFeedbagManager_Feedbag_Call) Run(run func(screenName string)) *moc
 	return _c
 }
 
-func (_c *mockFeedbagManager_Feedbag_Call) Return(_a0 []oscar.FeedbagItem, _a1 error) *mockFeedbagManager_Feedbag_Call {
+func (_c *mockFeedbagManager_Feedbag_Call) Return(_a0 []wire.FeedbagItem, _a1 error) *mockFeedbagManager_Feedbag_Call {
 	_c.Call.Return(_a0, _a1)
 	return _c
 }
 
-func (_c *mockFeedbagManager_Feedbag_Call) RunAndReturn(run func(string) ([]oscar.FeedbagItem, error)) *mockFeedbagManager_Feedbag_Call {
+func (_c *mockFeedbagManager_Feedbag_Call) RunAndReturn(run func(string) ([]wire.FeedbagItem, error)) *mockFeedbagManager_Feedbag_Call {
 	_c.Call.Return(run)
 	return _c
 }
 
 // FeedbagDelete provides a mock function with given fields: screenName, items
-func (_m *mockFeedbagManager) FeedbagDelete(screenName string, items []oscar.FeedbagItem) error {
+func (_m *mockFeedbagManager) FeedbagDelete(screenName string, items []wire.FeedbagItem) error {
 	ret := _m.Called(screenName, items)
 
 	if len(ret) == 0 {
@@ -264,7 +264,7 @@ func (_m *mockFeedbagManager) FeedbagDelete(screenName string, items []oscar.Fee
 	}
 
 	var r0 error
-	if rf, ok := ret.Get(0).(func(string, []oscar.FeedbagItem) error); ok {
+	if rf, ok := ret.Get(0).(func(string, []wire.FeedbagItem) error); ok {
 		r0 = rf(screenName, items)
 	} else {
 		r0 = ret.Error(0)
@@ -280,14 +280,14 @@ type mockFeedbagManager_FeedbagDelete_Call struct {
 
 // FeedbagDelete is a helper method to define mock.On call
 //   - screenName string
-//   - items []oscar.FeedbagItem
+//   - items []wire.FeedbagItem
 func (_e *mockFeedbagManager_Expecter) FeedbagDelete(screenName interface{}, items interface{}) *mockFeedbagManager_FeedbagDelete_Call {
 	return &mockFeedbagManager_FeedbagDelete_Call{Call: _e.mock.On("FeedbagDelete", screenName, items)}
 }
 
-func (_c *mockFeedbagManager_FeedbagDelete_Call) Run(run func(screenName string, items []oscar.FeedbagItem)) *mockFeedbagManager_FeedbagDelete_Call {
+func (_c *mockFeedbagManager_FeedbagDelete_Call) Run(run func(screenName string, items []wire.FeedbagItem)) *mockFeedbagManager_FeedbagDelete_Call {
 	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(string), args[1].([]oscar.FeedbagItem))
+		run(args[0].(string), args[1].([]wire.FeedbagItem))
 	})
 	return _c
 }
@@ -297,7 +297,7 @@ func (_c *mockFeedbagManager_FeedbagDelete_Call) Return(_a0 error) *mockFeedbagM
 	return _c
 }
 
-func (_c *mockFeedbagManager_FeedbagDelete_Call) RunAndReturn(run func(string, []oscar.FeedbagItem) error) *mockFeedbagManager_FeedbagDelete_Call {
+func (_c *mockFeedbagManager_FeedbagDelete_Call) RunAndReturn(run func(string, []wire.FeedbagItem) error) *mockFeedbagManager_FeedbagDelete_Call {
 	_c.Call.Return(run)
 	return _c
 }
@@ -359,7 +359,7 @@ func (_c *mockFeedbagManager_FeedbagLastModified_Call) RunAndReturn(run func(str
 }
 
 // FeedbagUpsert provides a mock function with given fields: screenName, items
-func (_m *mockFeedbagManager) FeedbagUpsert(screenName string, items []oscar.FeedbagItem) error {
+func (_m *mockFeedbagManager) FeedbagUpsert(screenName string, items []wire.FeedbagItem) error {
 	ret := _m.Called(screenName, items)
 
 	if len(ret) == 0 {
@@ -367,7 +367,7 @@ func (_m *mockFeedbagManager) FeedbagUpsert(screenName string, items []oscar.Fee
 	}
 
 	var r0 error
-	if rf, ok := ret.Get(0).(func(string, []oscar.FeedbagItem) error); ok {
+	if rf, ok := ret.Get(0).(func(string, []wire.FeedbagItem) error); ok {
 		r0 = rf(screenName, items)
 	} else {
 		r0 = ret.Error(0)
@@ -383,14 +383,14 @@ type mockFeedbagManager_FeedbagUpsert_Call struct {
 
 // FeedbagUpsert is a helper method to define mock.On call
 //   - screenName string
-//   - items []oscar.FeedbagItem
+//   - items []wire.FeedbagItem
 func (_e *mockFeedbagManager_Expecter) FeedbagUpsert(screenName interface{}, items interface{}) *mockFeedbagManager_FeedbagUpsert_Call {
 	return &mockFeedbagManager_FeedbagUpsert_Call{Call: _e.mock.On("FeedbagUpsert", screenName, items)}
 }
 
-func (_c *mockFeedbagManager_FeedbagUpsert_Call) Run(run func(screenName string, items []oscar.FeedbagItem)) *mockFeedbagManager_FeedbagUpsert_Call {
+func (_c *mockFeedbagManager_FeedbagUpsert_Call) Run(run func(screenName string, items []wire.FeedbagItem)) *mockFeedbagManager_FeedbagUpsert_Call {
 	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(string), args[1].([]oscar.FeedbagItem))
+		run(args[0].(string), args[1].([]wire.FeedbagItem))
 	})
 	return _c
 }
@@ -400,7 +400,7 @@ func (_c *mockFeedbagManager_FeedbagUpsert_Call) Return(_a0 error) *mockFeedbagM
 	return _c
 }
 
-func (_c *mockFeedbagManager_FeedbagUpsert_Call) RunAndReturn(run func(string, []oscar.FeedbagItem) error) *mockFeedbagManager_FeedbagUpsert_Call {
+func (_c *mockFeedbagManager_FeedbagUpsert_Call) RunAndReturn(run func(string, []wire.FeedbagItem) error) *mockFeedbagManager_FeedbagUpsert_Call {
 	_c.Call.Return(run)
 	return _c
 }

+ 14 - 14
handler/message_relayer_mock_test.go → foodgroup/mock_message_relayer_test.go

@@ -1,14 +1,14 @@
-// Code generated by mockery v2.38.0. DO NOT EDIT.
+// Code generated by mockery v2.40.1. DO NOT EDIT.
 
-package handler
+package foodgroup
 
 import (
 	context "context"
 
-	oscar "github.com/mk6i/retro-aim-server/oscar"
+	state "github.com/mk6i/retro-aim-server/state"
 	mock "github.com/stretchr/testify/mock"
 
-	state "github.com/mk6i/retro-aim-server/state"
+	wire "github.com/mk6i/retro-aim-server/wire"
 )
 
 // mockMessageRelayer is an autogenerated mock type for the MessageRelayer type
@@ -25,7 +25,7 @@ func (_m *mockMessageRelayer) EXPECT() *mockMessageRelayer_Expecter {
 }
 
 // RelayToScreenName provides a mock function with given fields: ctx, screenName, msg
-func (_m *mockMessageRelayer) RelayToScreenName(ctx context.Context, screenName string, msg oscar.SNACMessage) {
+func (_m *mockMessageRelayer) RelayToScreenName(ctx context.Context, screenName string, msg wire.SNACMessage) {
 	_m.Called(ctx, screenName, msg)
 }
 
@@ -37,14 +37,14 @@ type mockMessageRelayer_RelayToScreenName_Call struct {
 // RelayToScreenName is a helper method to define mock.On call
 //   - ctx context.Context
 //   - screenName string
-//   - msg oscar.SNACMessage
+//   - msg wire.SNACMessage
 func (_e *mockMessageRelayer_Expecter) RelayToScreenName(ctx interface{}, screenName interface{}, msg interface{}) *mockMessageRelayer_RelayToScreenName_Call {
 	return &mockMessageRelayer_RelayToScreenName_Call{Call: _e.mock.On("RelayToScreenName", ctx, screenName, msg)}
 }
 
-func (_c *mockMessageRelayer_RelayToScreenName_Call) Run(run func(ctx context.Context, screenName string, msg oscar.SNACMessage)) *mockMessageRelayer_RelayToScreenName_Call {
+func (_c *mockMessageRelayer_RelayToScreenName_Call) Run(run func(ctx context.Context, screenName string, msg wire.SNACMessage)) *mockMessageRelayer_RelayToScreenName_Call {
 	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(context.Context), args[1].(string), args[2].(oscar.SNACMessage))
+		run(args[0].(context.Context), args[1].(string), args[2].(wire.SNACMessage))
 	})
 	return _c
 }
@@ -54,13 +54,13 @@ func (_c *mockMessageRelayer_RelayToScreenName_Call) Return() *mockMessageRelaye
 	return _c
 }
 
-func (_c *mockMessageRelayer_RelayToScreenName_Call) RunAndReturn(run func(context.Context, string, oscar.SNACMessage)) *mockMessageRelayer_RelayToScreenName_Call {
+func (_c *mockMessageRelayer_RelayToScreenName_Call) RunAndReturn(run func(context.Context, string, wire.SNACMessage)) *mockMessageRelayer_RelayToScreenName_Call {
 	_c.Call.Return(run)
 	return _c
 }
 
 // RelayToScreenNames provides a mock function with given fields: ctx, screenNames, msg
-func (_m *mockMessageRelayer) RelayToScreenNames(ctx context.Context, screenNames []string, msg oscar.SNACMessage) {
+func (_m *mockMessageRelayer) RelayToScreenNames(ctx context.Context, screenNames []string, msg wire.SNACMessage) {
 	_m.Called(ctx, screenNames, msg)
 }
 
@@ -72,14 +72,14 @@ type mockMessageRelayer_RelayToScreenNames_Call struct {
 // RelayToScreenNames is a helper method to define mock.On call
 //   - ctx context.Context
 //   - screenNames []string
-//   - msg oscar.SNACMessage
+//   - msg wire.SNACMessage
 func (_e *mockMessageRelayer_Expecter) RelayToScreenNames(ctx interface{}, screenNames interface{}, msg interface{}) *mockMessageRelayer_RelayToScreenNames_Call {
 	return &mockMessageRelayer_RelayToScreenNames_Call{Call: _e.mock.On("RelayToScreenNames", ctx, screenNames, msg)}
 }
 
-func (_c *mockMessageRelayer_RelayToScreenNames_Call) Run(run func(ctx context.Context, screenNames []string, msg oscar.SNACMessage)) *mockMessageRelayer_RelayToScreenNames_Call {
+func (_c *mockMessageRelayer_RelayToScreenNames_Call) Run(run func(ctx context.Context, screenNames []string, msg wire.SNACMessage)) *mockMessageRelayer_RelayToScreenNames_Call {
 	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(context.Context), args[1].([]string), args[2].(oscar.SNACMessage))
+		run(args[0].(context.Context), args[1].([]string), args[2].(wire.SNACMessage))
 	})
 	return _c
 }
@@ -89,7 +89,7 @@ func (_c *mockMessageRelayer_RelayToScreenNames_Call) Return() *mockMessageRelay
 	return _c
 }
 
-func (_c *mockMessageRelayer_RelayToScreenNames_Call) RunAndReturn(run func(context.Context, []string, oscar.SNACMessage)) *mockMessageRelayer_RelayToScreenNames_Call {
+func (_c *mockMessageRelayer_RelayToScreenNames_Call) RunAndReturn(run func(context.Context, []string, wire.SNACMessage)) *mockMessageRelayer_RelayToScreenNames_Call {
 	_c.Call.Return(run)
 	return _c
 }

+ 2 - 2
handler/profile_manager_mock_test.go → foodgroup/mock_profile_manager_test.go

@@ -1,6 +1,6 @@
-// Code generated by mockery v2.38.0. DO NOT EDIT.
+// Code generated by mockery v2.40.1. DO NOT EDIT.
 
-package handler
+package foodgroup
 
 import mock "github.com/stretchr/testify/mock"
 

+ 2 - 2
handler/session_manager_mock_test.go → foodgroup/mock_session_manager_test.go

@@ -1,6 +1,6 @@
-// Code generated by mockery v2.38.0. DO NOT EDIT.
+// Code generated by mockery v2.40.1. DO NOT EDIT.
 
-package handler
+package foodgroup
 
 import (
 	state "github.com/mk6i/retro-aim-server/state"

+ 2 - 2
handler/user_manager_manager_mock_test.go → foodgroup/mock_user_manager_manager_test.go

@@ -1,6 +1,6 @@
-// Code generated by mockery v2.38.0. DO NOT EDIT.
+// Code generated by mockery v2.40.1. DO NOT EDIT.
 
-package handler
+package foodgroup
 
 import (
 	state "github.com/mk6i/retro-aim-server/state"

+ 486 - 0
foodgroup/oservice.go

@@ -0,0 +1,486 @@
+package foodgroup
+
+import (
+	"bytes"
+	"context"
+	"errors"
+	"fmt"
+	"time"
+
+	"github.com/mk6i/retro-aim-server/config"
+	"github.com/mk6i/retro-aim-server/state"
+	"github.com/mk6i/retro-aim-server/wire"
+)
+
+// NewOServiceService creates a new instance of OServiceService.
+func NewOServiceService(cfg config.Config, messageRelayer MessageRelayer, feedbagManager FeedbagManager) *OServiceService {
+	return &OServiceService{cfg: cfg, messageRelayer: messageRelayer, feedbagManager: feedbagManager}
+}
+
+// OServiceService provides functionality for the OService food group, which
+// provides an assortment of services useful across multiple food groups.
+type OServiceService struct {
+	cfg            config.Config
+	feedbagManager FeedbagManager
+	messageRelayer MessageRelayer
+}
+
+// ClientVersions informs the server what food group versions the client
+// supports and returns to the client what food group versions it supports.
+// This method simply regurgitates versions supplied by the client in inBody
+// back to the client in a OServiceHostVersions SNAC. The server doesn't
+// attempt to accommodate any particular food group version. The server
+// implicitly accommodates any food group version for Windows AIM clients 5.x.
+// It returns SNAC wire.OServiceHostVersions containing the server's supported
+// food group versions.
+func (s OServiceService) ClientVersions(_ context.Context, frame wire.SNACFrame, inBody wire.SNAC_0x01_0x17_OServiceClientVersions) wire.SNACMessage {
+	return wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.OService,
+			SubGroup:  wire.OServiceHostVersions,
+			RequestID: frame.RequestID,
+		},
+		Body: wire.SNAC_0x01_0x18_OServiceHostVersions{
+			Versions: inBody.Versions,
+		},
+	}
+}
+
+// RateParamsQuery returns SNAC rate limits.
+// The purpose of this method is to provide information about rate limits that
+// can be enforced on the server side. The response consists of two main parts:
+// rate classes and rate groups. Rate classes define limits based on specific
+// parameters, while rate groups associate these limits with relevant SNAC
+// types.
+//
+// Note: The current implementation does not enforce server-side rate limiting.
+// Instead, the provided values inform the client about the recommended
+// client-side rate limits.
+//
+// It returns SNAC wire.OServiceRateParamsReply containing rate limits for
+// sending Instant Messages (IMs) and chat messages. More refined limits may be
+// added in the future if/when server rate limiting is implemented.
+func (s OServiceService) RateParamsQuery(_ context.Context, inFrame wire.SNACFrame) wire.SNACMessage {
+	return wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.OService,
+			SubGroup:  wire.OServiceRateParamsReply,
+			RequestID: inFrame.RequestID,
+		},
+		Body: wire.SNAC_0x01_0x07_OServiceRateParamsReply{
+			RateClasses: []struct {
+				ID              uint16
+				WindowSize      uint32
+				ClearLevel      uint32
+				AlertLevel      uint32
+				LimitLevel      uint32
+				DisconnectLevel uint32
+				CurrentLevel    uint32
+				MaxLevel        uint32
+				LastTime        uint32
+				CurrentState    uint8
+			}{
+				{
+					ID:              0x01,
+					WindowSize:      0x0050,
+					ClearLevel:      0x09C4,
+					AlertLevel:      0x07D0,
+					LimitLevel:      0x05DC,
+					DisconnectLevel: 0x0320,
+					CurrentLevel:    0x0D69,
+					MaxLevel:        0x1770,
+					LastTime:        0x0000,
+					CurrentState:    0x0,
+				},
+			},
+			RateGroups: []struct {
+				ID    uint16
+				Pairs []struct {
+					FoodGroup uint16
+					SubGroup  uint16
+				} `count_prefix:"uint16"`
+			}{
+				{
+					ID: 1,
+					Pairs: []struct {
+						FoodGroup uint16
+						SubGroup  uint16
+					}{
+						{
+							FoodGroup: wire.Buddy,
+							SubGroup:  wire.BuddyRightsQuery,
+						},
+						{
+							FoodGroup: wire.Chat,
+							SubGroup:  wire.ChatChannelMsgToHost,
+						},
+						{
+							FoodGroup: wire.ChatNav,
+							SubGroup:  wire.ChatNavRequestChatRights,
+						},
+						{
+							FoodGroup: wire.ChatNav,
+							SubGroup:  wire.ChatNavRequestRoomInfo,
+						},
+						{
+							FoodGroup: wire.ChatNav,
+							SubGroup:  wire.ChatNavCreateRoom,
+						},
+						{
+							FoodGroup: wire.Feedbag,
+							SubGroup:  wire.FeedbagRightsQuery,
+						},
+						{
+							FoodGroup: wire.Feedbag,
+							SubGroup:  wire.FeedbagQuery,
+						},
+						{
+							FoodGroup: wire.Feedbag,
+							SubGroup:  wire.FeedbagQueryIfModified,
+						},
+						{
+							FoodGroup: wire.Feedbag,
+							SubGroup:  wire.FeedbagUse,
+						},
+						{
+							FoodGroup: wire.Feedbag,
+							SubGroup:  wire.FeedbagInsertItem,
+						},
+						{
+							FoodGroup: wire.Feedbag,
+							SubGroup:  wire.FeedbagUpdateItem,
+						},
+						{
+							FoodGroup: wire.Feedbag,
+							SubGroup:  wire.FeedbagDeleteItem,
+						},
+						{
+							FoodGroup: wire.Feedbag,
+							SubGroup:  wire.FeedbagStartCluster,
+						},
+						{
+							FoodGroup: wire.Feedbag,
+							SubGroup:  wire.FeedbagEndCluster,
+						},
+						{
+							FoodGroup: wire.ICBM,
+							SubGroup:  wire.ICBMAddParameters,
+						},
+						{
+							FoodGroup: wire.ICBM,
+							SubGroup:  wire.ICBMParameterQuery,
+						},
+						{
+							FoodGroup: wire.ICBM,
+							SubGroup:  wire.ICBMChannelMsgToHost,
+						},
+						{
+							FoodGroup: wire.ICBM,
+							SubGroup:  wire.ICBMEvilRequest,
+						},
+						{
+							FoodGroup: wire.ICBM,
+							SubGroup:  wire.ICBMClientErr,
+						},
+						{
+							FoodGroup: wire.ICBM,
+							SubGroup:  wire.ICBMClientEvent,
+						},
+						{
+							FoodGroup: wire.Locate,
+							SubGroup:  wire.LocateRightsQuery,
+						},
+						{
+							FoodGroup: wire.Locate,
+							SubGroup:  wire.LocateSetInfo,
+						},
+						{
+							FoodGroup: wire.Locate,
+							SubGroup:  wire.LocateSetDirInfo,
+						},
+						{
+							FoodGroup: wire.Locate,
+							SubGroup:  wire.LocateGetDirInfo,
+						},
+						{
+							FoodGroup: wire.Locate,
+							SubGroup:  wire.LocateSetKeywordInfo,
+						},
+						{
+							FoodGroup: wire.Locate,
+							SubGroup:  wire.LocateUserInfoQuery2,
+						},
+						{
+							FoodGroup: wire.OService,
+							SubGroup:  wire.OServiceServiceRequest,
+						},
+						{
+							FoodGroup: wire.OService,
+							SubGroup:  wire.OServiceClientOnline,
+						},
+						{
+							FoodGroup: wire.OService,
+							SubGroup:  wire.OServiceRateParamsQuery,
+						},
+						{
+							FoodGroup: wire.OService,
+							SubGroup:  wire.OServiceRateParamsSubAdd,
+						},
+						{
+							FoodGroup: wire.OService,
+							SubGroup:  wire.OServiceUserInfoQuery,
+						},
+						{
+							FoodGroup: wire.OService,
+							SubGroup:  wire.OServiceIdleNotification,
+						},
+						{
+							FoodGroup: wire.OService,
+							SubGroup:  wire.OServiceClientVersions,
+						},
+						{
+							FoodGroup: wire.OService,
+							SubGroup:  wire.OServiceSetUserInfoFields,
+						},
+					},
+				},
+			},
+		},
+	}
+}
+
+// UserInfoQuery returns SNAC wire.OServiceUserInfoUpdate containing
+// the user's info.
+func (s OServiceService) UserInfoQuery(_ context.Context, sess *state.Session, inFrame wire.SNACFrame) wire.SNACMessage {
+	return wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.OService,
+			SubGroup:  wire.OServiceUserInfoUpdate,
+			RequestID: inFrame.RequestID,
+		},
+		Body: wire.SNAC_0x01_0x0F_OServiceUserInfoUpdate{
+			TLVUserInfo: sess.TLVUserInfo(),
+		},
+	}
+}
+
+// SetUserInfoFields sets the user's visibility status to visible or invisible.
+// The visibility status is set according to the inFrame TLV entry under key
+// wire.OServiceUserInfoStatus. If the value is 0x0000, set invisible. If set
+// to 0x0100, set invisible. Else, return an error for any other value.
+// It returns SNAC wire.OServiceUserInfoUpdate containing the user's info.
+func (s OServiceService) SetUserInfoFields(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x01_0x1E_OServiceSetUserInfoFields) (wire.SNACMessage, error) {
+	if status, hasStatus := inBody.Uint32(wire.OServiceUserInfoStatus); hasStatus {
+		switch status {
+		case 0x0000:
+			sess.SetInvisible(false)
+			if err := broadcastArrival(ctx, sess, s.messageRelayer, s.feedbagManager); err != nil {
+				return wire.SNACMessage{}, err
+			}
+		case 0x0100:
+			sess.SetInvisible(true)
+			if err := broadcastDeparture(ctx, sess, s.messageRelayer, s.feedbagManager); err != nil {
+				return wire.SNACMessage{}, err
+			}
+		default:
+			return wire.SNACMessage{}, fmt.Errorf("don't know what to do with status %d", status)
+		}
+	}
+	return wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.OService,
+			SubGroup:  wire.OServiceUserInfoUpdate,
+			RequestID: inFrame.RequestID,
+		},
+		Body: wire.SNAC_0x01_0x0F_OServiceUserInfoUpdate{
+			TLVUserInfo: sess.TLVUserInfo(),
+		},
+	}, nil
+}
+
+// IdleNotification sets the user idle time.
+// Set session idle time to the value of bodyIn.IdleTime. Return a user arrival
+// message to all users who have this user on their buddy list.
+func (s OServiceService) IdleNotification(ctx context.Context, sess *state.Session, bodyIn wire.SNAC_0x01_0x11_OServiceIdleNotification) error {
+	if bodyIn.IdleTime == 0 {
+		sess.UnsetIdle()
+	} else {
+		sess.SetIdle(time.Duration(bodyIn.IdleTime) * time.Second)
+	}
+	return broadcastArrival(ctx, sess, s.messageRelayer, s.feedbagManager)
+}
+
+// RateParamsSubAdd exists to capture the SNAC input in unit tests to
+// verify it's correctly unmarshalled.
+func (s OServiceService) RateParamsSubAdd(context.Context, wire.SNAC_0x01_0x08_OServiceRateParamsSubAdd) {
+}
+
+// NewOServiceServiceForBOS creates a new instance of OServiceServiceForBOS.
+func NewOServiceServiceForBOS(oserviceService OServiceService, cr *state.ChatRegistry) *OServiceServiceForBOS {
+	return &OServiceServiceForBOS{
+		OServiceService: oserviceService,
+		chatRegistry:    cr,
+	}
+}
+
+// OServiceServiceForBOS provides functionality for the OService food group
+// running on the BOS server.
+type OServiceServiceForBOS struct {
+	OServiceService
+	chatRegistry *state.ChatRegistry
+}
+
+// chatLoginCookie represents credentials used to authenticate a user chat
+// session.
+type chatLoginCookie struct {
+	Cookie string `len_prefix:"uint8"`
+	SessID string `len_prefix:"uint16"`
+}
+
+// ServiceRequest configures food group settings for the current user.
+// This method only provides services for the Chat food group; return
+// wire.ErrUnsupportedFoodGroup for any other food group. When the chat
+// food group is specified in inFrame, add user to the chat room specified by
+// TLV 0x01. It returns SNAC wire.OServiceServiceResponse containing
+// metadata the client needs to connect to the chat service and join the chat
+// room.
+func (s OServiceServiceForBOS) ServiceRequest(_ context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x01_0x04_OServiceServiceRequest) (wire.SNACMessage, error) {
+	if inBody.FoodGroup != wire.Chat {
+		err := fmt.Errorf("%w. food group: %s", wire.ErrUnsupportedFoodGroup, wire.FoodGroupName(inBody.FoodGroup))
+		return wire.SNACMessage{}, err
+	}
+
+	roomMeta, ok := inBody.Slice(0x01)
+	if !ok {
+		return wire.SNACMessage{}, errors.New("missing room info")
+	}
+
+	roomSNAC := wire.SNAC_0x01_0x04_TLVRoomInfo{}
+	if err := wire.Unmarshal(&roomSNAC, bytes.NewBuffer(roomMeta)); err != nil {
+		return wire.SNACMessage{}, err
+	}
+
+	room, chatSessMgr, err := s.chatRegistry.Retrieve(roomSNAC.Cookie)
+	if err != nil {
+		return wire.SNACMessage{}, err
+	}
+	chatSess := chatSessMgr.(SessionManager).AddSession(sess.ID(), sess.ScreenName())
+	chatSess.SetChatRoomCookie(room.Cookie)
+
+	return wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.OService,
+			SubGroup:  wire.OServiceServiceResponse,
+			RequestID: inFrame.RequestID,
+		},
+		Body: wire.SNAC_0x01_0x05_OServiceServiceResponse{
+			TLVRestBlock: wire.TLVRestBlock{
+				TLVList: wire.TLVList{
+					wire.NewTLV(wire.OServiceTLVTagsReconnectHere, config.Address(s.cfg.OSCARHost, s.cfg.ChatPort)),
+					wire.NewTLV(wire.OServiceTLVTagsLoginCookie, chatLoginCookie{
+						Cookie: room.Cookie,
+						SessID: sess.ID(),
+					}),
+					wire.NewTLV(wire.OServiceTLVTagsGroupID, wire.Chat),
+					wire.NewTLV(wire.OServiceTLVTagsSSLCertName, ""),
+					wire.NewTLV(wire.OServiceTLVTagsSSLState, uint8(0x00)),
+				},
+			},
+		},
+	}, nil
+}
+
+// HostOnline initiates the BOS protocol sequence.
+// It returns SNAC wire.OServiceHostOnline containing the list food groups
+// supported by the BOS service.
+func (s OServiceServiceForBOS) HostOnline() wire.SNACMessage {
+	return wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.OService,
+			SubGroup:  wire.OServiceHostOnline,
+		},
+		Body: wire.SNAC_0x01_0x03_OServiceHostOnline{
+			FoodGroups: []uint16{
+				wire.Alert,
+				wire.Buddy,
+				wire.ChatNav,
+				wire.Feedbag,
+				wire.ICBM,
+				wire.Locate,
+				wire.OService,
+			},
+		},
+	}
+}
+
+// ClientOnline runs when the current user is ready to join.
+// It performs the following sequence of actions:
+//   - Announce current user's arrival to users who have the current user on
+//     their buddy list.
+//   - Send current user its buddy list
+func (s OServiceServiceForBOS) ClientOnline(ctx context.Context, _ wire.SNAC_0x01_0x02_OServiceClientOnline, sess *state.Session) error {
+	if err := broadcastArrival(ctx, sess, s.messageRelayer, s.feedbagManager); err != nil {
+		return err
+	}
+	buddies, err := s.feedbagManager.Buddies(sess.ScreenName())
+	if err != nil {
+		return err
+	}
+	for _, screenName := range buddies {
+		buddy := s.messageRelayer.RetrieveByScreenName(screenName)
+		if buddy == nil || buddy.Invisible() {
+			continue
+		}
+		unicastArrival(ctx, buddy, sess, s.messageRelayer)
+	}
+	return nil
+}
+
+// NewOServiceServiceForChat creates a new instance of OServiceServiceForChat.
+func NewOServiceServiceForChat(oserviceService OServiceService, chatRegistry *state.ChatRegistry) *OServiceServiceForChat {
+	return &OServiceServiceForChat{
+		OServiceService: oserviceService,
+		chatRegistry:    chatRegistry,
+	}
+}
+
+// OServiceServiceForChat provides functionality for the OService food group
+// running on the Chat server.
+type OServiceServiceForChat struct {
+	OServiceService
+	chatRegistry *state.ChatRegistry
+}
+
+// HostOnline initiates the Chat protocol sequence.
+// It returns SNAC wire.OServiceHostOnline containing the list of food groups
+// supported by the Chat service.
+func (s OServiceServiceForChat) HostOnline() wire.SNACMessage {
+	return wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.OService,
+			SubGroup:  wire.OServiceHostOnline,
+		},
+		Body: wire.SNAC_0x01_0x03_OServiceHostOnline{
+			FoodGroups: []uint16{
+				wire.OService,
+				wire.Chat,
+			},
+		},
+	}
+}
+
+// ClientOnline runs when the current user is ready to join the chat.
+// Trigger the following actions:
+//   - Send current user the chat room metadata
+//   - Announce current user's arrival to other chat room participants
+//   - Send current user the chat room participant list
+func (s OServiceServiceForChat) ClientOnline(ctx context.Context, sess *state.Session) error {
+	room, chatSessMgr, err := s.chatRegistry.Retrieve(sess.ChatRoomCookie())
+	if err != nil {
+		return err
+	}
+	sendChatRoomInfoUpdate(ctx, sess, chatSessMgr.(ChatMessageRelayer), room)
+	alertUserJoined(ctx, sess, chatSessMgr.(ChatMessageRelayer))
+	setOnlineChatUsers(ctx, sess, chatSessMgr.(ChatMessageRelayer))
+	return nil
+}

+ 258 - 259
handler/oservice_test.go → foodgroup/oservice_test.go

@@ -1,4 +1,4 @@
-package handler
+package foodgroup
 
 import (
 	"testing"
@@ -7,13 +7,12 @@ import (
 	"github.com/mk6i/retro-aim-server/config"
 	"github.com/stretchr/testify/mock"
 
-	"github.com/mk6i/retro-aim-server/oscar"
-	"github.com/mk6i/retro-aim-server/server"
 	"github.com/mk6i/retro-aim-server/state"
+	"github.com/mk6i/retro-aim-server/wire"
 	"github.com/stretchr/testify/assert"
 )
 
-func TestOServiceServiceForBOS_ServiceRequestHandler(t *testing.T) {
+func TestOServiceServiceForBOS_ServiceRequest(t *testing.T) {
 	cases := []struct {
 		// name is the unit test name
 		name string
@@ -25,25 +24,25 @@ func TestOServiceServiceForBOS_ServiceRequestHandler(t *testing.T) {
 		// info
 		userSession *state.Session
 		// inputSNAC is the SNAC sent by the sender client
-		inputSNAC oscar.SNACMessage
+		inputSNAC wire.SNACMessage
 		// expectSNACFrame is the SNAC frame sent from the server to the recipient
 		// client
-		expectOutput oscar.SNACMessage
+		expectOutput wire.SNACMessage
 		// expectErr is the expected error returned by the router
 		expectErr error
 	}{
 		{
 			name:        "request info for ICBM service, return invalid SNAC err",
 			userSession: newTestSession("user_screen_name"),
-			inputSNAC: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
 					RequestID: 1234,
 				},
-				Body: oscar.SNAC_0x01_0x04_OServiceServiceRequest{
-					FoodGroup: oscar.ICBM,
+				Body: wire.SNAC_0x01_0x04_OServiceServiceRequest{
+					FoodGroup: wire.ICBM,
 				},
 			},
-			expectErr: server.ErrUnsupportedSubGroup,
+			expectErr: wire.ErrUnsupportedFoodGroup,
 		},
 		{
 			name: "request info for connecting to chat room, return chat service and chat room metadata",
@@ -60,40 +59,40 @@ func TestOServiceServiceForBOS_ServiceRequestHandler(t *testing.T) {
 				Name:           "my new chat",
 			},
 			userSession: newTestSession("user_screen_name", sessOptCannedID),
-			inputSNAC: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
 					RequestID: 1234,
 				},
-				Body: oscar.SNAC_0x01_0x04_OServiceServiceRequest{
-					FoodGroup: oscar.Chat,
-					TLVRestBlock: oscar.TLVRestBlock{
-						TLVList: oscar.TLVList{
-							oscar.NewTLV(0x01, oscar.SNAC_0x01_0x04_TLVRoomInfo{
+				Body: wire.SNAC_0x01_0x04_OServiceServiceRequest{
+					FoodGroup: wire.Chat,
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLV(0x01, wire.SNAC_0x01_0x04_TLVRoomInfo{
 								Exchange:       8,
-								Cookie:         []byte("the-chat-cookie"),
+								Cookie:         "the-chat-cookie",
 								InstanceNumber: 16,
 							}),
 						},
 					},
 				},
 			},
-			expectOutput: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.OService,
-					SubGroup:  oscar.OServiceServiceResponse,
+			expectOutput: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.OService,
+					SubGroup:  wire.OServiceServiceResponse,
 					RequestID: 1234,
 				},
-				Body: oscar.SNAC_0x01_0x05_OServiceServiceResponse{
-					TLVRestBlock: oscar.TLVRestBlock{
-						TLVList: oscar.TLVList{
-							oscar.NewTLV(oscar.OServiceTLVTagsReconnectHere, "127.0.0.1:1234"),
-							oscar.NewTLV(oscar.OServiceTLVTagsLoginCookie, server.ChatCookie{
-								Cookie: []byte("the-chat-cookie"),
-								SessID: "user-userSession-id",
+				Body: wire.SNAC_0x01_0x05_OServiceServiceResponse{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLV(wire.OServiceTLVTagsReconnectHere, "127.0.0.1:1234"),
+							wire.NewTLV(wire.OServiceTLVTagsLoginCookie, chatLoginCookie{
+								Cookie: "the-chat-cookie",
+								SessID: "user-session-id",
 							}),
-							oscar.NewTLV(oscar.OServiceTLVTagsGroupID, oscar.Chat),
-							oscar.NewTLV(oscar.OServiceTLVTagsSSLCertName, ""),
-							oscar.NewTLV(oscar.OServiceTLVTagsSSLState, uint8(0x00)),
+							wire.NewTLV(wire.OServiceTLVTagsGroupID, wire.Chat),
+							wire.NewTLV(wire.OServiceTLVTagsSSLCertName, ""),
+							wire.NewTLV(wire.OServiceTLVTagsSSLState, uint8(0x00)),
 						},
 					},
 				},
@@ -107,17 +106,17 @@ func TestOServiceServiceForBOS_ServiceRequestHandler(t *testing.T) {
 			},
 			chatRoom:    nil,
 			userSession: newTestSession("user_screen_name", sessOptCannedID),
-			inputSNAC: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
 					RequestID: 1234,
 				},
-				Body: oscar.SNAC_0x01_0x04_OServiceServiceRequest{
-					FoodGroup: oscar.Chat,
-					TLVRestBlock: oscar.TLVRestBlock{
-						TLVList: oscar.TLVList{
-							oscar.NewTLV(0x01, oscar.SNAC_0x01_0x04_TLVRoomInfo{
+				Body: wire.SNAC_0x01_0x04_OServiceServiceRequest{
+					FoodGroup: wire.Chat,
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLV(0x01, wire.SNAC_0x01_0x04_TLVRoomInfo{
 								Exchange:       8,
-								Cookie:         []byte("the-chat-cookie"),
+								Cookie:         "the-chat-cookie",
 								InstanceNumber: 16,
 							}),
 						},
@@ -150,14 +149,14 @@ func TestOServiceServiceForBOS_ServiceRequestHandler(t *testing.T) {
 				cfg: tc.cfg,
 			}, chatRegistry)
 
-			outputSNAC, err := svc.ServiceRequestHandler(nil, tc.userSession, tc.inputSNAC.Frame,
-				tc.inputSNAC.Body.(oscar.SNAC_0x01_0x04_OServiceServiceRequest))
+			outputSNAC, err := svc.ServiceRequest(nil, tc.userSession, tc.inputSNAC.Frame,
+				tc.inputSNAC.Body.(wire.SNAC_0x01_0x04_OServiceServiceRequest))
 			assert.ErrorIs(t, err, tc.expectErr)
 			if tc.expectErr != nil {
 				return
 			}
 			// assert the user session is linked to the chat room
-			assert.Equal(t, chatSess.ChatID(), tc.chatRoom.Cookie)
+			assert.Equal(t, chatSess.ChatRoomCookie(), tc.chatRoom.Cookie)
 			//
 			// verify output
 			//
@@ -166,21 +165,21 @@ func TestOServiceServiceForBOS_ServiceRequestHandler(t *testing.T) {
 	}
 }
 
-func TestSetUserInfoFieldsHandler(t *testing.T) {
+func TestSetUserInfoFields(t *testing.T) {
 	cases := []struct {
 		// name is the unit test name
 		name string
 		// userSession is the session of the user whose info is being set
 		userSession *state.Session
 		// inputSNAC is the SNAC sent from the client to the server
-		inputSNAC oscar.SNACMessage
+		inputSNAC wire.SNACMessage
 		// expectOutput is the SNAC reply sent from the server back to the
 		// client
-		expectOutput oscar.SNACMessage
+		expectOutput wire.SNACMessage
 		// broadcastMessage is the arrival/departure message sent to buddies
 		broadcastMessage []struct {
 			recipients []string
-			msg        oscar.SNACMessage
+			msg        wire.SNACMessage
 		}
 		// interestedUserLookups contains all the users who have this user on
 		// their buddy list
@@ -191,40 +190,40 @@ func TestSetUserInfoFieldsHandler(t *testing.T) {
 		{
 			name:        "set user status to visible",
 			userSession: newTestSession("user_screen_name"),
-			inputSNAC: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
 					RequestID: 1234,
 				},
-				Body: oscar.SNAC_0x01_0x1E_OServiceSetUserInfoFields{
-					TLVRestBlock: oscar.TLVRestBlock{
-						TLVList: oscar.TLVList{
-							oscar.NewTLV(oscar.OServiceUserInfoStatus, uint32(0x0000)),
+				Body: wire.SNAC_0x01_0x1E_OServiceSetUserInfoFields{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLV(wire.OServiceUserInfoStatus, uint32(0x0000)),
 						},
 					},
 				},
 			},
-			expectOutput: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.OService,
-					SubGroup:  oscar.OServiceUserInfoUpdate,
+			expectOutput: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.OService,
+					SubGroup:  wire.OServiceUserInfoUpdate,
 					RequestID: 1234,
 				},
-				Body: oscar.SNAC_0x01_0x0F_OServiceUserInfoUpdate{
+				Body: wire.SNAC_0x01_0x0F_OServiceUserInfoUpdate{
 					TLVUserInfo: newTestSession("user_screen_name").TLVUserInfo(),
 				},
 			},
 			broadcastMessage: []struct {
 				recipients []string
-				msg        oscar.SNACMessage
+				msg        wire.SNACMessage
 			}{
 				{
 					recipients: []string{"friend1", "friend2"},
-					msg: oscar.SNACMessage{
-						Frame: oscar.SNACFrame{
-							FoodGroup: oscar.Buddy,
-							SubGroup:  oscar.BuddyArrived,
+					msg: wire.SNACMessage{
+						Frame: wire.SNACFrame{
+							FoodGroup: wire.Buddy,
+							SubGroup:  wire.BuddyArrived,
 						},
-						Body: oscar.SNAC_0x03_0x0B_BuddyArrived{
+						Body: wire.SNAC_0x03_0x0B_BuddyArrived{
 							TLVUserInfo: newTestSession("user_screen_name").TLVUserInfo(),
 						},
 					},
@@ -237,41 +236,41 @@ func TestSetUserInfoFieldsHandler(t *testing.T) {
 		{
 			name:        "set user status to invisible",
 			userSession: newTestSession("user_screen_name"),
-			inputSNAC: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
 					RequestID: 1234,
 				},
-				Body: oscar.SNAC_0x01_0x1E_OServiceSetUserInfoFields{
-					TLVRestBlock: oscar.TLVRestBlock{
-						TLVList: oscar.TLVList{
-							oscar.NewTLV(oscar.OServiceUserInfoStatus, uint32(0x0100)),
+				Body: wire.SNAC_0x01_0x1E_OServiceSetUserInfoFields{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLV(wire.OServiceUserInfoStatus, uint32(0x0100)),
 						},
 					},
 				},
 			},
-			expectOutput: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.OService,
-					SubGroup:  oscar.OServiceUserInfoUpdate,
+			expectOutput: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.OService,
+					SubGroup:  wire.OServiceUserInfoUpdate,
 					RequestID: 1234,
 				},
-				Body: oscar.SNAC_0x01_0x0F_OServiceUserInfoUpdate{
+				Body: wire.SNAC_0x01_0x0F_OServiceUserInfoUpdate{
 					TLVUserInfo: newTestSession("user_screen_name", sessOptInvisible).TLVUserInfo(),
 				},
 			},
 			broadcastMessage: []struct {
 				recipients []string
-				msg        oscar.SNACMessage
+				msg        wire.SNACMessage
 			}{
 				{
 					recipients: []string{"friend1", "friend2"},
-					msg: oscar.SNACMessage{
-						Frame: oscar.SNACFrame{
-							FoodGroup: oscar.Buddy,
-							SubGroup:  oscar.BuddyDeparted,
+					msg: wire.SNACMessage{
+						Frame: wire.SNACFrame{
+							FoodGroup: wire.Buddy,
+							SubGroup:  wire.BuddyDeparted,
 						},
-						Body: oscar.SNAC_0x03_0x0C_BuddyDeparted{
-							TLVUserInfo: oscar.TLVUserInfo{
+						Body: wire.SNAC_0x03_0x0C_BuddyDeparted{
+							TLVUserInfo: wire.TLVUserInfo{
 								ScreenName:   "user_screen_name",
 								WarningLevel: 0,
 							},
@@ -305,8 +304,8 @@ func TestSetUserInfoFieldsHandler(t *testing.T) {
 			// send input SNAC
 			//
 			svc := NewOServiceService(config.Config{}, messageRelayer, feedbagManager)
-			outputSNAC, err := svc.SetUserInfoFieldsHandler(nil, tc.userSession, tc.inputSNAC.Frame,
-				tc.inputSNAC.Body.(oscar.SNAC_0x01_0x1E_OServiceSetUserInfoFields))
+			outputSNAC, err := svc.SetUserInfoFields(nil, tc.userSession, tc.inputSNAC.Frame,
+				tc.inputSNAC.Body.(wire.SNAC_0x01_0x1E_OServiceSetUserInfoFields))
 			assert.ErrorIs(t, err, tc.expectErr)
 			if tc.expectErr != nil {
 				return
@@ -319,17 +318,17 @@ func TestSetUserInfoFieldsHandler(t *testing.T) {
 	}
 }
 
-func TestOServiceService_RateParamsQueryHandler(t *testing.T) {
+func TestOServiceService_RateParamsQuery(t *testing.T) {
 	svc := NewOServiceService(config.Config{}, nil, nil)
 
-	have := svc.RateParamsQueryHandler(nil, oscar.SNACFrame{RequestID: 1234})
-	want := oscar.SNACMessage{
-		Frame: oscar.SNACFrame{
-			FoodGroup: oscar.OService,
-			SubGroup:  oscar.OServiceRateParamsReply,
+	have := svc.RateParamsQuery(nil, wire.SNACFrame{RequestID: 1234})
+	want := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.OService,
+			SubGroup:  wire.OServiceRateParamsReply,
 			RequestID: 1234,
 		},
-		Body: oscar.SNAC_0x01_0x07_OServiceRateParamsReply{
+		Body: wire.SNAC_0x01_0x07_OServiceRateParamsReply{
 			RateClasses: []struct {
 				ID              uint16
 				WindowSize      uint32
@@ -369,140 +368,140 @@ func TestOServiceService_RateParamsQueryHandler(t *testing.T) {
 						SubGroup  uint16
 					}{
 						{
-							FoodGroup: oscar.Buddy,
-							SubGroup:  oscar.BuddyRightsQuery,
+							FoodGroup: wire.Buddy,
+							SubGroup:  wire.BuddyRightsQuery,
 						},
 						{
-							FoodGroup: oscar.Chat,
-							SubGroup:  oscar.ChatChannelMsgToHost,
+							FoodGroup: wire.Chat,
+							SubGroup:  wire.ChatChannelMsgToHost,
 						},
 						{
-							FoodGroup: oscar.ChatNav,
-							SubGroup:  oscar.ChatNavRequestChatRights,
+							FoodGroup: wire.ChatNav,
+							SubGroup:  wire.ChatNavRequestChatRights,
 						},
 						{
-							FoodGroup: oscar.ChatNav,
-							SubGroup:  oscar.ChatNavRequestRoomInfo,
+							FoodGroup: wire.ChatNav,
+							SubGroup:  wire.ChatNavRequestRoomInfo,
 						},
 						{
-							FoodGroup: oscar.ChatNav,
-							SubGroup:  oscar.ChatNavCreateRoom,
+							FoodGroup: wire.ChatNav,
+							SubGroup:  wire.ChatNavCreateRoom,
 						},
 						{
-							FoodGroup: oscar.Feedbag,
-							SubGroup:  oscar.FeedbagRightsQuery,
+							FoodGroup: wire.Feedbag,
+							SubGroup:  wire.FeedbagRightsQuery,
 						},
 						{
-							FoodGroup: oscar.Feedbag,
-							SubGroup:  oscar.FeedbagQuery,
+							FoodGroup: wire.Feedbag,
+							SubGroup:  wire.FeedbagQuery,
 						},
 						{
-							FoodGroup: oscar.Feedbag,
-							SubGroup:  oscar.FeedbagQueryIfModified,
+							FoodGroup: wire.Feedbag,
+							SubGroup:  wire.FeedbagQueryIfModified,
 						},
 						{
-							FoodGroup: oscar.Feedbag,
-							SubGroup:  oscar.FeedbagUse,
+							FoodGroup: wire.Feedbag,
+							SubGroup:  wire.FeedbagUse,
 						},
 						{
-							FoodGroup: oscar.Feedbag,
-							SubGroup:  oscar.FeedbagInsertItem,
+							FoodGroup: wire.Feedbag,
+							SubGroup:  wire.FeedbagInsertItem,
 						},
 						{
-							FoodGroup: oscar.Feedbag,
-							SubGroup:  oscar.FeedbagUpdateItem,
+							FoodGroup: wire.Feedbag,
+							SubGroup:  wire.FeedbagUpdateItem,
 						},
 						{
-							FoodGroup: oscar.Feedbag,
-							SubGroup:  oscar.FeedbagDeleteItem,
+							FoodGroup: wire.Feedbag,
+							SubGroup:  wire.FeedbagDeleteItem,
 						},
 						{
-							FoodGroup: oscar.Feedbag,
-							SubGroup:  oscar.FeedbagStartCluster,
+							FoodGroup: wire.Feedbag,
+							SubGroup:  wire.FeedbagStartCluster,
 						},
 						{
-							FoodGroup: oscar.Feedbag,
-							SubGroup:  oscar.FeedbagEndCluster,
+							FoodGroup: wire.Feedbag,
+							SubGroup:  wire.FeedbagEndCluster,
 						},
 						{
-							FoodGroup: oscar.ICBM,
-							SubGroup:  oscar.ICBMAddParameters,
+							FoodGroup: wire.ICBM,
+							SubGroup:  wire.ICBMAddParameters,
 						},
 						{
-							FoodGroup: oscar.ICBM,
-							SubGroup:  oscar.ICBMParameterQuery,
+							FoodGroup: wire.ICBM,
+							SubGroup:  wire.ICBMParameterQuery,
 						},
 						{
-							FoodGroup: oscar.ICBM,
-							SubGroup:  oscar.ICBMChannelMsgToHost,
+							FoodGroup: wire.ICBM,
+							SubGroup:  wire.ICBMChannelMsgToHost,
 						},
 						{
-							FoodGroup: oscar.ICBM,
-							SubGroup:  oscar.ICBMEvilRequest,
+							FoodGroup: wire.ICBM,
+							SubGroup:  wire.ICBMEvilRequest,
 						},
 						{
-							FoodGroup: oscar.ICBM,
-							SubGroup:  oscar.ICBMClientErr,
+							FoodGroup: wire.ICBM,
+							SubGroup:  wire.ICBMClientErr,
 						},
 						{
-							FoodGroup: oscar.ICBM,
-							SubGroup:  oscar.ICBMClientEvent,
+							FoodGroup: wire.ICBM,
+							SubGroup:  wire.ICBMClientEvent,
 						},
 						{
-							FoodGroup: oscar.Locate,
-							SubGroup:  oscar.LocateRightsQuery,
+							FoodGroup: wire.Locate,
+							SubGroup:  wire.LocateRightsQuery,
 						},
 						{
-							FoodGroup: oscar.Locate,
-							SubGroup:  oscar.LocateSetInfo,
+							FoodGroup: wire.Locate,
+							SubGroup:  wire.LocateSetInfo,
 						},
 						{
-							FoodGroup: oscar.Locate,
-							SubGroup:  oscar.LocateSetDirInfo,
+							FoodGroup: wire.Locate,
+							SubGroup:  wire.LocateSetDirInfo,
 						},
 						{
-							FoodGroup: oscar.Locate,
-							SubGroup:  oscar.LocateGetDirInfo,
+							FoodGroup: wire.Locate,
+							SubGroup:  wire.LocateGetDirInfo,
 						},
 						{
-							FoodGroup: oscar.Locate,
-							SubGroup:  oscar.LocateSetKeywordInfo,
+							FoodGroup: wire.Locate,
+							SubGroup:  wire.LocateSetKeywordInfo,
 						},
 						{
-							FoodGroup: oscar.Locate,
-							SubGroup:  oscar.LocateUserInfoQuery2,
+							FoodGroup: wire.Locate,
+							SubGroup:  wire.LocateUserInfoQuery2,
 						},
 						{
-							FoodGroup: oscar.OService,
-							SubGroup:  oscar.OServiceServiceRequest,
+							FoodGroup: wire.OService,
+							SubGroup:  wire.OServiceServiceRequest,
 						},
 						{
-							FoodGroup: oscar.OService,
-							SubGroup:  oscar.OServiceClientOnline,
+							FoodGroup: wire.OService,
+							SubGroup:  wire.OServiceClientOnline,
 						},
 						{
-							FoodGroup: oscar.OService,
-							SubGroup:  oscar.OServiceRateParamsQuery,
+							FoodGroup: wire.OService,
+							SubGroup:  wire.OServiceRateParamsQuery,
 						},
 						{
-							FoodGroup: oscar.OService,
-							SubGroup:  oscar.OServiceRateParamsSubAdd,
+							FoodGroup: wire.OService,
+							SubGroup:  wire.OServiceRateParamsSubAdd,
 						},
 						{
-							FoodGroup: oscar.OService,
-							SubGroup:  oscar.OServiceUserInfoQuery,
+							FoodGroup: wire.OService,
+							SubGroup:  wire.OServiceUserInfoQuery,
 						},
 						{
-							FoodGroup: oscar.OService,
-							SubGroup:  oscar.OServiceIdleNotification,
+							FoodGroup: wire.OService,
+							SubGroup:  wire.OServiceIdleNotification,
 						},
 						{
-							FoodGroup: oscar.OService,
-							SubGroup:  oscar.OServiceClientVersions,
+							FoodGroup: wire.OService,
+							SubGroup:  wire.OServiceClientVersions,
 						},
 						{
-							FoodGroup: oscar.OService,
-							SubGroup:  oscar.OServiceSetUserInfoFields,
+							FoodGroup: wire.OService,
+							SubGroup:  wire.OServiceSetUserInfoFields,
 						},
 					},
 				},
@@ -513,121 +512,121 @@ func TestOServiceService_RateParamsQueryHandler(t *testing.T) {
 	assert.Equal(t, want, have)
 }
 
-func TestOServiceServiceForBOS_WriteOServiceHostOnline(t *testing.T) {
+func TestOServiceServiceForBOS_OServiceHostOnline(t *testing.T) {
 	svc := NewOServiceServiceForBOS(*NewOServiceService(config.Config{}, nil, nil), nil)
 
-	want := oscar.SNACMessage{
-		Frame: oscar.SNACFrame{
-			FoodGroup: oscar.OService,
-			SubGroup:  oscar.OServiceHostOnline,
+	want := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.OService,
+			SubGroup:  wire.OServiceHostOnline,
 		},
-		Body: oscar.SNAC_0x01_0x03_OServiceHostOnline{
+		Body: wire.SNAC_0x01_0x03_OServiceHostOnline{
 			FoodGroups: []uint16{
-				oscar.Alert,
-				oscar.Buddy,
-				oscar.ChatNav,
-				oscar.Feedbag,
-				oscar.ICBM,
-				oscar.Locate,
-				oscar.OService,
+				wire.Alert,
+				wire.Buddy,
+				wire.ChatNav,
+				wire.Feedbag,
+				wire.ICBM,
+				wire.Locate,
+				wire.OService,
 			},
 		},
 	}
 
-	have := svc.WriteOServiceHostOnline()
+	have := svc.HostOnline()
 	assert.Equal(t, want, have)
 }
 
-func TestOServiceServiceForChat_WriteOServiceHostOnline(t *testing.T) {
+func TestOServiceServiceForChat_OServiceHostOnline(t *testing.T) {
 	svc := NewOServiceServiceForChat(*NewOServiceService(config.Config{}, nil, nil), nil)
 
-	want := oscar.SNACMessage{
-		Frame: oscar.SNACFrame{
-			FoodGroup: oscar.OService,
-			SubGroup:  oscar.OServiceHostOnline,
+	want := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.OService,
+			SubGroup:  wire.OServiceHostOnline,
 		},
-		Body: oscar.SNAC_0x01_0x03_OServiceHostOnline{
+		Body: wire.SNAC_0x01_0x03_OServiceHostOnline{
 			FoodGroups: []uint16{
-				oscar.OService,
-				oscar.Chat,
+				wire.OService,
+				wire.Chat,
 			},
 		},
 	}
 
-	have := svc.WriteOServiceHostOnline()
+	have := svc.HostOnline()
 	assert.Equal(t, want, have)
 }
 
-func TestOServiceService_ClientVersionsHandler(t *testing.T) {
+func TestOServiceService_ClientVersions(t *testing.T) {
 	svc := NewOServiceService(config.Config{}, nil, nil)
 
-	want := oscar.SNACMessage{
-		Frame: oscar.SNACFrame{
-			FoodGroup: oscar.OService,
-			SubGroup:  oscar.OServiceHostVersions,
+	want := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.OService,
+			SubGroup:  wire.OServiceHostVersions,
 			RequestID: 1234,
 		},
-		Body: oscar.SNAC_0x01_0x18_OServiceHostVersions{
+		Body: wire.SNAC_0x01_0x18_OServiceHostVersions{
 			Versions: []uint16{5, 6, 7, 8},
 		},
 	}
 
-	have := svc.ClientVersionsHandler(nil, oscar.SNACFrame{
+	have := svc.ClientVersions(nil, wire.SNACFrame{
 		RequestID: 1234,
-	}, oscar.SNAC_0x01_0x17_OServiceClientVersions{
+	}, wire.SNAC_0x01_0x17_OServiceClientVersions{
 		Versions: []uint16{5, 6, 7, 8},
 	})
 
 	assert.Equal(t, want, have)
 }
 
-func TestOServiceService_UserInfoQueryHandler(t *testing.T) {
+func TestOServiceService_UserInfoQuery(t *testing.T) {
 	svc := NewOServiceService(config.Config{}, nil, nil)
 	sess := newTestSession("test-user")
 
-	want := oscar.SNACMessage{
-		Frame: oscar.SNACFrame{
-			FoodGroup: oscar.OService,
-			SubGroup:  oscar.OServiceUserInfoUpdate,
+	want := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.OService,
+			SubGroup:  wire.OServiceUserInfoUpdate,
 			RequestID: 1234,
 		},
-		Body: oscar.SNAC_0x01_0x0F_OServiceUserInfoUpdate{
+		Body: wire.SNAC_0x01_0x0F_OServiceUserInfoUpdate{
 			TLVUserInfo: sess.TLVUserInfo(),
 		},
 	}
 
-	have := svc.UserInfoQueryHandler(nil, sess, oscar.SNACFrame{RequestID: 1234})
+	have := svc.UserInfoQuery(nil, sess, wire.SNACFrame{RequestID: 1234})
 
 	assert.Equal(t, want, have)
 }
 
-func TestOServiceService_IdleNotificationHandler(t *testing.T) {
+func TestOServiceService_IdleNotification(t *testing.T) {
 	tests := []struct {
 		name   string
 		sess   *state.Session
-		bodyIn oscar.SNAC_0x01_0x11_OServiceIdleNotification
+		bodyIn wire.SNAC_0x01_0x11_OServiceIdleNotification
 		// recipientScreenName is the screen name of the user receiving the IM
 		recipientScreenName string
 		// recipientBuddies is a list of the recipient's buddies that get
 		// updated warning level
 		recipientBuddies []string
-		broadcastMessage oscar.SNACMessage
+		broadcastMessage wire.SNACMessage
 		wantErr          error
 	}{
 		{
 			name: "set idle from active",
 			sess: newTestSession("test-user"),
-			bodyIn: oscar.SNAC_0x01_0x11_OServiceIdleNotification{
+			bodyIn: wire.SNAC_0x01_0x11_OServiceIdleNotification{
 				IdleTime: 90,
 			},
 			recipientScreenName: "test-user",
 			recipientBuddies:    []string{"buddy1", "buddy2"},
-			broadcastMessage: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.Buddy,
-					SubGroup:  oscar.BuddyArrived,
+			broadcastMessage: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Buddy,
+					SubGroup:  wire.BuddyArrived,
 				},
-				Body: oscar.SNAC_0x03_0x0B_BuddyArrived{
+				Body: wire.SNAC_0x03_0x0B_BuddyArrived{
 					TLVUserInfo: newTestSession("test-user", sessOptIdle(90*time.Second)).TLVUserInfo(),
 				},
 			},
@@ -635,17 +634,17 @@ func TestOServiceService_IdleNotificationHandler(t *testing.T) {
 		{
 			name: "set active from idle",
 			sess: newTestSession("test-user", sessOptIdle(90*time.Second)),
-			bodyIn: oscar.SNAC_0x01_0x11_OServiceIdleNotification{
+			bodyIn: wire.SNAC_0x01_0x11_OServiceIdleNotification{
 				IdleTime: 0,
 			},
 			recipientScreenName: "test-user",
 			recipientBuddies:    []string{"buddy1", "buddy2"},
-			broadcastMessage: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.Buddy,
-					SubGroup:  oscar.BuddyArrived,
+			broadcastMessage: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Buddy,
+					SubGroup:  wire.BuddyArrived,
 				},
-				Body: oscar.SNAC_0x03_0x0B_BuddyArrived{
+				Body: wire.SNAC_0x03_0x0B_BuddyArrived{
 					TLVUserInfo: newTestSession("test-user").TLVUserInfo(),
 				},
 			},
@@ -665,13 +664,13 @@ func TestOServiceService_IdleNotificationHandler(t *testing.T) {
 
 			svc := NewOServiceService(config.Config{}, messageRelayer, feedbagManager)
 
-			haveErr := svc.IdleNotificationHandler(nil, tt.sess, tt.bodyIn)
+			haveErr := svc.IdleNotification(nil, tt.sess, tt.bodyIn)
 			assert.ErrorIs(t, tt.wantErr, haveErr)
 		})
 	}
 }
 
-func TestOServiceServiceForBOS_ClientOnlineHandler(t *testing.T) {
+func TestOServiceServiceForBOS_ClientOnline(t *testing.T) {
 	type buddiesLookupParams []struct {
 		screenName string
 		buddies    []string
@@ -684,7 +683,7 @@ func TestOServiceServiceForBOS_ClientOnlineHandler(t *testing.T) {
 		sess *state.Session
 		// bodyIn is the SNAC body sent from the arriving user's client to the
 		// server
-		bodyIn oscar.SNAC_0x01_0x02_OServiceClientOnline
+		bodyIn wire.SNAC_0x01_0x02_OServiceClientOnline
 		// buddyLookupParams contains params for looking up arriving user's
 		// buddies
 		buddyLookupParams buddiesLookupParams
@@ -707,7 +706,7 @@ func TestOServiceServiceForBOS_ClientOnlineHandler(t *testing.T) {
 		{
 			name:   "notify arriving user's buddies of its arrival and populate the arriving user's buddy list",
 			sess:   newTestSession("test-user"),
-			bodyIn: oscar.SNAC_0x01_0x02_OServiceClientOnline{},
+			bodyIn: wire.SNAC_0x01_0x02_OServiceClientOnline{},
 			interestedUsersParams: interestedUsersParams{
 				{
 					screenName: "test-user",
@@ -717,12 +716,12 @@ func TestOServiceServiceForBOS_ClientOnlineHandler(t *testing.T) {
 			broadcastToScreenNamesParams: broadcastToScreenNamesParams{
 				{
 					screenNames: []string{"buddy1", "buddy2", "buddy3", "buddy4"},
-					message: oscar.SNACMessage{
-						Frame: oscar.SNACFrame{
-							FoodGroup: oscar.Buddy,
-							SubGroup:  oscar.BuddyArrived,
+					message: wire.SNACMessage{
+						Frame: wire.SNACFrame{
+							FoodGroup: wire.Buddy,
+							SubGroup:  wire.BuddyArrived,
 						},
-						Body: oscar.SNAC_0x03_0x0B_BuddyArrived{
+						Body: wire.SNAC_0x03_0x0B_BuddyArrived{
 							TLVUserInfo: newTestSession("test-user").TLVUserInfo(),
 						},
 					},
@@ -747,24 +746,24 @@ func TestOServiceServiceForBOS_ClientOnlineHandler(t *testing.T) {
 			sendToScreenNameParams: sendToScreenNameParams{
 				{
 					screenName: "test-user",
-					message: oscar.SNACMessage{
-						Frame: oscar.SNACFrame{
-							FoodGroup: oscar.Buddy,
-							SubGroup:  oscar.BuddyArrived,
+					message: wire.SNACMessage{
+						Frame: wire.SNACFrame{
+							FoodGroup: wire.Buddy,
+							SubGroup:  wire.BuddyArrived,
 						},
-						Body: oscar.SNAC_0x03_0x0B_BuddyArrived{
+						Body: wire.SNAC_0x03_0x0B_BuddyArrived{
 							TLVUserInfo: newTestSession("buddy1").TLVUserInfo(),
 						},
 					},
 				},
 				{
 					screenName: "test-user",
-					message: oscar.SNACMessage{
-						Frame: oscar.SNACFrame{
-							FoodGroup: oscar.Buddy,
-							SubGroup:  oscar.BuddyArrived,
+					message: wire.SNACMessage{
+						Frame: wire.SNACFrame{
+							FoodGroup: wire.Buddy,
+							SubGroup:  wire.BuddyArrived,
 						},
-						Body: oscar.SNAC_0x03_0x0B_BuddyArrived{
+						Body: wire.SNAC_0x03_0x0B_BuddyArrived{
 							TLVUserInfo: newTestSession("buddy3").TLVUserInfo(),
 						},
 					},
@@ -805,15 +804,15 @@ func TestOServiceServiceForBOS_ClientOnlineHandler(t *testing.T) {
 				messageRelayer: messageRelayer,
 			}, nil)
 
-			haveErr := svc.ClientOnlineHandler(nil, tt.bodyIn, tt.sess)
+			haveErr := svc.ClientOnline(nil, tt.bodyIn, tt.sess)
 			assert.ErrorIs(t, tt.wantErr, haveErr)
 		})
 	}
 }
 
-func TestOServiceServiceForChat_ClientOnlineHandler(t *testing.T) {
-	chatter1 := newTestSession("chatter-1", sessOptChatID("the-cookie"))
-	chatter2 := newTestSession("chatter-2", sessOptChatID("the-cookie"))
+func TestOServiceServiceForChat_ClientOnline(t *testing.T) {
+	chatter1 := newTestSession("chatter-1", sessOptChatRoomCookie("the-cookie"))
+	chatter2 := newTestSession("chatter-2", sessOptChatRoomCookie("the-cookie"))
 	chatRoom := state.ChatRoom{
 		Cookie:         "the-cookie",
 		DetailLevel:    1,
@@ -825,11 +824,11 @@ func TestOServiceServiceForChat_ClientOnlineHandler(t *testing.T) {
 	type participantsParams []*state.Session
 	type broadcastExcept []struct {
 		sess    *state.Session
-		message oscar.SNACMessage
+		message wire.SNACMessage
 	}
 	type sendToScreenNameParams []struct {
 		screenName string
-		message    oscar.SNACMessage
+		message    wire.SNACMessage
 	}
 
 	tests := []struct {
@@ -839,7 +838,7 @@ func TestOServiceServiceForChat_ClientOnlineHandler(t *testing.T) {
 		joiningChatter *state.Session
 		// bodyIn is the SNAC body sent from the arriving user's client to the
 		// server
-		bodyIn oscar.SNAC_0x01_0x02_OServiceClientOnline
+		bodyIn wire.SNAC_0x01_0x02_OServiceClientOnline
 		// participantsParams contains all the chat room participants
 		participantsParams participantsParams
 		// broadcastExcept contains params for broadcasting chat arrival to all
@@ -853,17 +852,17 @@ func TestOServiceServiceForChat_ClientOnlineHandler(t *testing.T) {
 		{
 			name:           "upon joining, send chat room metadata and participant list to joining user; alert arrival to existing participants",
 			joiningChatter: chatter1,
-			bodyIn:         oscar.SNAC_0x01_0x02_OServiceClientOnline{},
+			bodyIn:         wire.SNAC_0x01_0x02_OServiceClientOnline{},
 			broadcastExcept: broadcastExcept{
 				{
 					sess: chatter1,
-					message: oscar.SNACMessage{
-						Frame: oscar.SNACFrame{
-							FoodGroup: oscar.Chat,
-							SubGroup:  oscar.ChatUsersJoined,
+					message: wire.SNACMessage{
+						Frame: wire.SNACFrame{
+							FoodGroup: wire.Chat,
+							SubGroup:  wire.ChatUsersJoined,
 						},
-						Body: oscar.SNAC_0x0E_0x03_ChatUsersJoined{
-							Users: []oscar.TLVUserInfo{
+						Body: wire.SNAC_0x0E_0x03_ChatUsersJoined{
+							Users: []wire.TLVUserInfo{
 								chatter1.TLVUserInfo(),
 							},
 						},
@@ -877,17 +876,17 @@ func TestOServiceServiceForChat_ClientOnlineHandler(t *testing.T) {
 			sendToScreenNameParams: sendToScreenNameParams{
 				{
 					screenName: chatter1.ScreenName(),
-					message: oscar.SNACMessage{
-						Frame: oscar.SNACFrame{
-							FoodGroup: oscar.Chat,
-							SubGroup:  oscar.ChatRoomInfoUpdate,
+					message: wire.SNACMessage{
+						Frame: wire.SNACFrame{
+							FoodGroup: wire.Chat,
+							SubGroup:  wire.ChatRoomInfoUpdate,
 						},
-						Body: oscar.SNAC_0x0E_0x02_ChatRoomInfoUpdate{
+						Body: wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate{
 							Exchange:       chatRoom.Exchange,
 							Cookie:         chatRoom.Cookie,
 							InstanceNumber: chatRoom.InstanceNumber,
 							DetailLevel:    chatRoom.DetailLevel,
-							TLVBlock: oscar.TLVBlock{
+							TLVBlock: wire.TLVBlock{
 								TLVList: chatRoom.TLVList(),
 							},
 						},
@@ -895,13 +894,13 @@ func TestOServiceServiceForChat_ClientOnlineHandler(t *testing.T) {
 				},
 				{
 					screenName: chatter1.ScreenName(),
-					message: oscar.SNACMessage{
-						Frame: oscar.SNACFrame{
-							FoodGroup: oscar.Chat,
-							SubGroup:  oscar.ChatUsersJoined,
+					message: wire.SNACMessage{
+						Frame: wire.SNACFrame{
+							FoodGroup: wire.Chat,
+							SubGroup:  wire.ChatUsersJoined,
 						},
-						Body: oscar.SNAC_0x0E_0x03_ChatUsersJoined{
-							Users: []oscar.TLVUserInfo{
+						Body: wire.SNAC_0x0E_0x03_ChatUsersJoined{
+							Users: []wire.TLVUserInfo{
 								chatter1.TLVUserInfo(),
 								chatter2.TLVUserInfo(),
 							},
@@ -938,7 +937,7 @@ func TestOServiceServiceForChat_ClientOnlineHandler(t *testing.T) {
 				messageRelayer: chatMessageRelayer,
 			}, chatRegistry)
 
-			haveErr := svc.ClientOnlineHandler(nil, tt.joiningChatter)
+			haveErr := svc.ClientOnline(nil, tt.joiningChatter)
 			assert.ErrorIs(t, tt.wantErr, haveErr)
 		})
 	}

+ 19 - 15
handler/test_helpers.go → foodgroup/test_helpers.go

@@ -1,10 +1,10 @@
-package handler
+package foodgroup
 
 import (
 	"time"
 
-	"github.com/mk6i/retro-aim-server/oscar"
 	"github.com/mk6i/retro-aim-server/state"
+	"github.com/mk6i/retro-aim-server/wire"
 )
 
 // mockParams is a helper struct that centralizes mock function call parameters
@@ -19,12 +19,16 @@ type mockParams struct {
 	userManagerParams
 }
 
+// userManagerParams is a helper struct that contains mock parameters for
+// ChatRegistry methods
 type chatRegistryParams struct {
 	chatRegistryRetrieveParams
 }
 
+// upsertUserParams is the list of parameters passed at the mock
+// ChatRegistry.Retrieve call site
 type chatRegistryRetrieveParams struct {
-	chatID         string
+	cookie         string
 	retChatRoom    state.ChatRoom
 	retChatSessMgr any
 	err            error
@@ -113,7 +117,7 @@ type interestedUsersParams []struct {
 // FeedbagManager.FeedbagUpsert call site
 type feedbagUpsertParams []struct {
 	screenName string
-	items      []oscar.FeedbagItem
+	items      []wire.FeedbagItem
 }
 
 // buddiesParams is the list of parameters passed at the mock
@@ -127,7 +131,7 @@ type buddiesParams []struct {
 // FeedbagManager.Feedbag call site
 type feedbagParams []struct {
 	screenName string
-	results    []oscar.FeedbagItem
+	results    []wire.FeedbagItem
 }
 
 // feedbagLastModifiedParams is the list of parameters passed at the mock
@@ -141,7 +145,7 @@ type feedbagLastModifiedParams []struct {
 // FeedbagManager.FeedbagDelete call site
 type feedbagDeleteParams []struct {
 	screenName string
-	items      []oscar.FeedbagItem
+	items      []wire.FeedbagItem
 }
 
 // messageRelayerParams is a helper struct that contains mock parameters for
@@ -163,14 +167,14 @@ type retrieveByScreenNameParams []struct {
 // MessageRelayer.RelayToScreenNames call site
 type broadcastToScreenNamesParams []struct {
 	screenNames []string
-	message     oscar.SNACMessage
+	message     wire.SNACMessage
 }
 
 // sendToScreenNameParams is the list of parameters passed at the mock
 // MessageRelayer.RelayToScreenName call site
 type sendToScreenNameParams []struct {
 	screenName string
-	message    oscar.SNACMessage
+	message    wire.SNACMessage
 }
 
 // profileManagerParams is a helper struct that contains mock parameters for
@@ -205,7 +209,7 @@ type chatMessageRelayerParams struct {
 // ChatMessageRelayer.RelayToAllExcept call site
 type broadcastExceptParams []struct {
 	except  *state.Session
-	message oscar.SNACMessage
+	message wire.SNACMessage
 }
 
 // sessOptWarning sets a warning level on the session object
@@ -215,13 +219,13 @@ func sessOptWarning(level uint16) func(session *state.Session) {
 	}
 }
 
-// sessOptCannedID sets a canned session ID ("user-userSession-id") on the session
+// sessOptCannedID sets a canned session ID ("user-session-id") on the session
 // object
 func sessOptCannedID(session *state.Session) {
-	session.SetID("user-userSession-id")
+	session.SetID("user-session-id")
 }
 
-// sessOptCannedID sets a canned session ID ("user-userSession-id") on the session
+// sessOptCannedID sets a canned session ID ("user-session-id") on the session
 // object
 func sessOptID(ID string) func(session *state.Session) {
 	return func(session *state.Session) {
@@ -248,10 +252,10 @@ func sessOptCannedSignonTime(session *state.Session) {
 	session.SetSignonTime(time.UnixMilli(1696790127565))
 }
 
-// sessOptChatID sets chat ID on the session object
-func sessOptChatID(chatID string) func(session *state.Session) {
+// sessOptChatRoomCookie sets cookie on the session object
+func sessOptChatRoomCookie(cookie string) func(session *state.Session) {
 	return func(session *state.Session) {
-		session.SetChatID(chatID)
+		session.SetChatRoomCookie(cookie)
 	}
 }
 

+ 87 - 0
foodgroup/types.go

@@ -0,0 +1,87 @@
+// Package foodgroup implements OSCAR food group business logic.
+//
+// The OSCAR protocol passes messages in SNAC (Simple Network Atomic
+// Communication) format. SNAC messages are grouped by "food groups" (get it?
+// snack, snac, foodgroup...). Each food group is responsible for a discrete
+// piece of functionality, such as buddy list management (Feedbag), instant
+// messaging (ICBM), and chat messaging (Chat).
+//
+// Each food group operation is represented by a struct type. The methods
+// correspond 1:1 to each food group operation. Each food group operation is
+// typically triggered by a client request. The operation may return a
+// response. As such, methods receive client requests via SNAC frame and
+// body parameters and send responses via returned SNAC objects.
+//
+// The following is a typical food group method signature. This example
+// illustrates the ICBM ChannelMsgToHost operation.
+//
+//	ChannelMsgToHost(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x06_ICBMChannelMsgToHost) (*wire.SNACMessage, error)
+//
+// Params:
+//   - ctx context.Context is the client request context.
+//   - sess *state.Session is the client's session object.
+//   - inFrame wire.SNACFrame is the request SNAC frame that contains the food group and subgroup parameters.
+//   - inBody wire.SNAC_0x04_0x06_ICBMChannelMsgToHost contains the body of the SNAC message. In this case, it contains instant message text and metadata.
+//
+// ChannelMsgToHost optionally sends a client response by returning
+// *wire.SNACMessage. For operations that always send client responses,
+// the methods return wire.SNACMessage value types (not pointer types).
+// Methods for operations that never send client responses do not return
+// wire.SNACMessage values.
+//
+// The foodgroup package delegates responsibility for message transport, user
+// retrieval, and session management to callers via several interface types.
+package foodgroup
+
+import (
+	"context"
+	"time"
+
+	"github.com/mk6i/retro-aim-server/state"
+	"github.com/mk6i/retro-aim-server/wire"
+)
+
+type FeedbagManager interface {
+	BlockedState(screenName1, screenName2 string) (state.BlockedState, error)
+	Buddies(screenName string) ([]string, error)
+	FeedbagDelete(screenName string, items []wire.FeedbagItem) error
+	AdjacentUsers(screenName string) ([]string, error)
+	FeedbagLastModified(screenName string) (time.Time, error)
+	Feedbag(screenName string) ([]wire.FeedbagItem, error)
+	FeedbagUpsert(screenName string, items []wire.FeedbagItem) error
+}
+
+type UserManager interface {
+	User(screenName string) (*state.User, error)
+	InsertUser(u state.User) error
+}
+
+type SessionManager interface {
+	Empty() bool
+	AddSession(sessID string, screenName string) *state.Session
+	RemoveSession(sess *state.Session)
+	RetrieveSession(ID string) *state.Session
+}
+
+type ProfileManager interface {
+	Profile(screenName string) (string, error)
+	SetProfile(screenName string, body string) error
+}
+
+type MessageRelayer interface {
+	RelayToScreenNames(ctx context.Context, screenNames []string, msg wire.SNACMessage)
+	RetrieveByScreenName(screenName string) *state.Session
+	RelayToScreenName(ctx context.Context, screenName string, msg wire.SNACMessage)
+}
+
+type ChatMessageRelayer interface {
+	MessageRelayer
+	RelayToAllExcept(ctx context.Context, except *state.Session, msg wire.SNACMessage)
+	AllSessions() []*state.Session
+}
+
+type ChatRegistry interface {
+	Register(room state.ChatRoom, sessionManager any)
+	Retrieve(cookie string) (state.ChatRoom, any, error)
+	Remove(cookie string)
+}

+ 0 - 33
handler/buddy_test.go

@@ -1,33 +0,0 @@
-package handler
-
-import (
-	"testing"
-
-	"github.com/mk6i/retro-aim-server/oscar"
-	"github.com/stretchr/testify/assert"
-)
-
-func TestBuddyService_RightsQueryHandler(t *testing.T) {
-	svc := NewBuddyService()
-
-	want := oscar.SNACMessage{
-		Frame: oscar.SNACFrame{
-			FoodGroup: oscar.Buddy,
-			SubGroup:  oscar.BuddyRightsReply,
-			RequestID: 1234,
-		},
-		Body: oscar.SNAC_0x03_0x03_BuddyRightsReply{
-			TLVRestBlock: oscar.TLVRestBlock{
-				TLVList: oscar.TLVList{
-					oscar.NewTLV(oscar.BuddyTLVTagsParmMaxBuddies, uint16(100)),
-					oscar.NewTLV(oscar.BuddyTLVTagsParmMaxWatchers, uint16(100)),
-					oscar.NewTLV(oscar.BuddyTLVTagsParmMaxIcqBroad, uint16(100)),
-					oscar.NewTLV(oscar.BuddyTLVTagsParmMaxTempBuddies, uint16(100)),
-				},
-			},
-		},
-	}
-	have := svc.RightsQueryHandler(nil, oscar.SNACFrame{RequestID: 1234})
-
-	assert.Equal(t, want, have)
-}

+ 0 - 125
handler/chat.go

@@ -1,125 +0,0 @@
-package handler
-
-import (
-	"context"
-
-	"github.com/mk6i/retro-aim-server/oscar"
-	"github.com/mk6i/retro-aim-server/state"
-)
-
-// NewChatService creates a new instance of ChatService.
-func NewChatService(chatRegistry ChatRegistry) *ChatService {
-	return &ChatService{
-		chatRegistry: chatRegistry,
-	}
-}
-
-// ChatService implements chat message handling for the chat food group.
-type ChatService struct {
-	chatRegistry ChatRegistry
-}
-
-// ChannelMsgToHostHandler relays oscar.ChatChannelMsgToClient SNAC sent from a
-// user to the other chat room participants. It returns the same
-// oscar.ChatChannelMsgToClient message back to the user if the chat reflection
-// TLV flag is set, otherwise return nil.
-func (s ChatService) ChannelMsgToHostHandler(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, inBody oscar.SNAC_0x0E_0x05_ChatChannelMsgToHost) (*oscar.SNACMessage, error) {
-	frameOut := oscar.SNACFrame{
-		FoodGroup: oscar.Chat,
-		SubGroup:  oscar.ChatChannelMsgToClient,
-	}
-	bodyOut := oscar.SNAC_0x0E_0x06_ChatChannelMsgToClient{
-		Cookie:  inBody.Cookie,
-		Channel: inBody.Channel,
-		TLVRestBlock: oscar.TLVRestBlock{
-			TLVList: inBody.TLVList,
-		},
-	}
-	bodyOut.Append(oscar.NewTLV(oscar.ChatTLVSenderInformation, sess.TLVUserInfo()))
-
-	_, chatSessMgr, err := s.chatRegistry.Retrieve(sess.ChatID())
-	if err != nil {
-		return nil, err
-	}
-
-	// send message to all the participants except sender
-	chatSessMgr.(ChatMessageRelayer).RelayToAllExcept(ctx, sess, oscar.SNACMessage{
-		Frame: frameOut,
-		Body:  bodyOut,
-	})
-
-	var ret *oscar.SNACMessage
-	if _, ackMsg := inBody.Slice(oscar.ChatTLVEnableReflectionFlag); ackMsg {
-		// reflect the message back to the sender
-		ret = &oscar.SNACMessage{
-			Frame: frameOut,
-			Body:  bodyOut,
-		}
-		ret.Frame.RequestID = inFrame.RequestID
-	}
-
-	return ret, nil
-}
-
-func setOnlineChatUsers(ctx context.Context, sess *state.Session, chatMessageRelayer ChatMessageRelayer) {
-	snacPayloadOut := oscar.SNAC_0x0E_0x03_ChatUsersJoined{}
-	sessions := chatMessageRelayer.AllSessions()
-
-	for _, uSess := range sessions {
-		snacPayloadOut.Users = append(snacPayloadOut.Users, uSess.TLVUserInfo())
-	}
-
-	chatMessageRelayer.RelayToScreenName(ctx, sess.ScreenName(), oscar.SNACMessage{
-		Frame: oscar.SNACFrame{
-			FoodGroup: oscar.Chat,
-			SubGroup:  oscar.ChatUsersJoined,
-		},
-		Body: snacPayloadOut,
-	})
-}
-
-func alertUserJoined(ctx context.Context, sess *state.Session, chatMessageRelayer ChatMessageRelayer) {
-	chatMessageRelayer.RelayToAllExcept(ctx, sess, oscar.SNACMessage{
-		Frame: oscar.SNACFrame{
-			FoodGroup: oscar.Chat,
-			SubGroup:  oscar.ChatUsersJoined,
-		},
-		Body: oscar.SNAC_0x0E_0x03_ChatUsersJoined{
-			Users: []oscar.TLVUserInfo{
-				sess.TLVUserInfo(),
-			},
-		},
-	})
-}
-
-func alertUserLeft(ctx context.Context, sess *state.Session, chatMessageRelayer ChatMessageRelayer) {
-	chatMessageRelayer.RelayToAllExcept(ctx, sess, oscar.SNACMessage{
-		Frame: oscar.SNACFrame{
-			FoodGroup: oscar.Chat,
-			SubGroup:  oscar.ChatUsersLeft,
-		},
-		Body: oscar.SNAC_0x0E_0x04_ChatUsersLeft{
-			Users: []oscar.TLVUserInfo{
-				sess.TLVUserInfo(),
-			},
-		},
-	})
-}
-
-func sendChatRoomInfoUpdate(ctx context.Context, sess *state.Session, chatMessageRelayer ChatMessageRelayer, room state.ChatRoom) {
-	chatMessageRelayer.RelayToScreenName(ctx, sess.ScreenName(), oscar.SNACMessage{
-		Frame: oscar.SNACFrame{
-			FoodGroup: oscar.Chat,
-			SubGroup:  oscar.ChatRoomInfoUpdate,
-		},
-		Body: oscar.SNAC_0x0E_0x02_ChatRoomInfoUpdate{
-			Exchange:       room.Exchange,
-			Cookie:         room.Cookie,
-			InstanceNumber: room.InstanceNumber,
-			DetailLevel:    room.DetailLevel,
-			TLVBlock: oscar.TLVBlock{
-				TLVList: room.TLVList(),
-			},
-		},
-	})
-}

+ 0 - 142
handler/chat_nav.go

@@ -1,142 +0,0 @@
-package handler
-
-import (
-	"context"
-	"errors"
-
-	"log/slog"
-
-	"github.com/mk6i/retro-aim-server/oscar"
-	"github.com/mk6i/retro-aim-server/state"
-)
-
-// NewChatNavService creates a new instance of NewChatNavService.
-func NewChatNavService(logger *slog.Logger, chatRegistry *state.ChatRegistry, newChatRoom func() state.ChatRoom, newChatSessMgr func() SessionManager) *ChatNavService {
-	return &ChatNavService{
-		logger:         logger,
-		chatRegistry:   chatRegistry,
-		newChatRoom:    newChatRoom,
-		newChatSessMgr: newChatSessMgr,
-	}
-}
-
-// ChatNavService provides handlers for the ChatNav food group.
-type ChatNavService struct {
-	logger         *slog.Logger
-	chatRegistry   *state.ChatRegistry
-	newChatRoom    func() state.ChatRoom
-	newChatSessMgr func() SessionManager
-}
-
-// RequestChatRightsHandler returns SNAC oscar.ChatNavNavInfo, which contains
-// chat navigation service parameters and limits.
-func (s ChatNavService) RequestChatRightsHandler(_ context.Context, inFrame oscar.SNACFrame) oscar.SNACMessage {
-	return oscar.SNACMessage{
-		Frame: oscar.SNACFrame{
-			FoodGroup: oscar.ChatNav,
-			SubGroup:  oscar.ChatNavNavInfo,
-			RequestID: inFrame.RequestID,
-		},
-		Body: oscar.SNAC_0x0D_0x09_ChatNavNavInfo{
-			TLVRestBlock: oscar.TLVRestBlock{
-				TLVList: oscar.TLVList{
-					oscar.NewTLV(oscar.ChatNavTLVMaxConcurrentRooms, uint8(10)),
-					oscar.NewTLV(oscar.ChatNavTLVExchangeInfo, oscar.SNAC_0x0D_0x09_TLVExchangeInfo{
-						Identifier: 4,
-						TLVBlock: oscar.TLVBlock{
-							TLVList: oscar.TLVList{
-								oscar.NewTLV(oscar.ChatNavTLVClassPerms, uint16(0x0010)),
-								oscar.NewTLV(oscar.ChatNavTLVFlags, uint16(15)),
-								oscar.NewTLV(oscar.ChatNavTLVRoomName, "default exchange"),
-								oscar.NewTLV(oscar.ChatNavTLVCreatePerms, uint8(2)),
-								oscar.NewTLV(oscar.ChatNavTLVCharSet1, "us-ascii"),
-								oscar.NewTLV(oscar.ChatNavTLVLang1, "en"),
-								oscar.NewTLV(oscar.ChatNavTLVCharSet2, "us-ascii"),
-								oscar.NewTLV(oscar.ChatNavTLVLang2, "en"),
-							},
-						},
-					}),
-				},
-			},
-		},
-	}
-}
-
-// CreateRoomHandler creates a chat room with the current user as the first
-// participant. It returns SNAC oscar.ChatNavNavInfo, which contains metadata
-// for the chat room.
-func (s ChatNavService) CreateRoomHandler(_ context.Context, sess *state.Session, inFrame oscar.SNACFrame, inBody oscar.SNAC_0x0E_0x02_ChatRoomInfoUpdate) (oscar.SNACMessage, error) {
-	name, hasName := inBody.String(oscar.ChatTLVRoomName)
-	if !hasName {
-		return oscar.SNACMessage{}, errors.New("unable to find chat name")
-	}
-
-	room := s.newChatRoom()
-	room.DetailLevel = inBody.DetailLevel
-	room.Exchange = inBody.Exchange
-	room.InstanceNumber = inBody.InstanceNumber
-	room.Name = name
-
-	chatSessMgr := s.newChatSessMgr()
-
-	s.chatRegistry.Register(room, chatSessMgr)
-
-	// add user to chat room
-	chatSess := chatSessMgr.AddSession(sess.ID(), sess.ScreenName())
-	chatSess.SetChatID(room.Cookie)
-
-	return oscar.SNACMessage{
-		Frame: oscar.SNACFrame{
-			FoodGroup: oscar.ChatNav,
-			SubGroup:  oscar.ChatNavNavInfo,
-			RequestID: inFrame.RequestID,
-		},
-		Body: oscar.SNAC_0x0D_0x09_ChatNavNavInfo{
-			TLVRestBlock: oscar.TLVRestBlock{
-				TLVList: oscar.TLVList{
-					oscar.NewTLV(oscar.ChatNavRequestRoomInfo, oscar.SNAC_0x0E_0x02_ChatRoomInfoUpdate{
-						Exchange:       inBody.Exchange,
-						Cookie:         room.Cookie,
-						InstanceNumber: inBody.InstanceNumber,
-						DetailLevel:    inBody.DetailLevel,
-						TLVBlock: oscar.TLVBlock{
-							TLVList: room.TLVList(),
-						},
-					}),
-				},
-			},
-		},
-	}, nil
-}
-
-// RequestRoomInfoHandler returns oscar.ChatNavNavInfo, which contains metadata
-// for the chat room specified in the inFrame.Cookie.
-func (s ChatNavService) RequestRoomInfoHandler(_ context.Context, inFrame oscar.SNACFrame, inBody oscar.SNAC_0x0D_0x04_ChatNavRequestRoomInfo) (oscar.SNACMessage, error) {
-	room, _, err := s.chatRegistry.Retrieve(string(inBody.Cookie))
-	if err != nil {
-		return oscar.SNACMessage{}, err
-	}
-
-	return oscar.SNACMessage{
-		Frame: oscar.SNACFrame{
-			FoodGroup: oscar.ChatNav,
-			SubGroup:  oscar.ChatNavNavInfo,
-			RequestID: inFrame.RequestID,
-		},
-		Body: oscar.SNAC_0x0D_0x09_ChatNavNavInfo{
-			TLVRestBlock: oscar.TLVRestBlock{
-				TLVList: oscar.TLVList{
-					oscar.NewTLV(oscar.ChatNavRequestRoomInfo, oscar.SNAC_0x0E_0x02_ChatRoomInfoUpdate{
-						Exchange:       room.Exchange,
-						Cookie:         room.Cookie,
-						InstanceNumber: room.InstanceNumber,
-						DetailLevel:    room.DetailLevel,
-						TLVBlock: oscar.TLVBlock{
-							TLVList: room.TLVList(),
-						},
-					}),
-				},
-			},
-		},
-	}, nil
-}

+ 0 - 197
handler/chat_nav_test.go

@@ -1,197 +0,0 @@
-package handler
-
-import (
-	"context"
-	"testing"
-	"time"
-
-	"github.com/mk6i/retro-aim-server/oscar"
-	"github.com/mk6i/retro-aim-server/state"
-	"github.com/stretchr/testify/assert"
-)
-
-func TestChatNavService_CreateRoomHandler(t *testing.T) {
-	bosSess := newTestSession("user-screen-name", sessOptCannedID)
-	chatSess := &state.Session{}
-
-	chatRegistry := state.NewChatRegistry()
-
-	sessionManager := newMockSessionManager(t)
-	sessionManager.EXPECT().AddSession(bosSess.ID(), bosSess.ScreenName()).
-		Return(chatSess)
-
-	newChatRoom := func() state.ChatRoom {
-		return state.ChatRoom{
-			Cookie:     "dummy-cookie",
-			CreateTime: time.UnixMilli(0),
-		}
-	}
-	newChatSessMgr := func() SessionManager {
-		return sessionManager
-	}
-
-	inFrame := oscar.SNACFrame{
-		RequestID: 1234,
-	}
-	inBody := oscar.SNAC_0x0E_0x02_ChatRoomInfoUpdate{
-		Exchange:       1,
-		Cookie:         "create", // actual canned value sent by AIM client
-		InstanceNumber: 2,
-		DetailLevel:    3,
-		TLVBlock: oscar.TLVBlock{
-			TLVList: oscar.TLVList{
-				oscar.NewTLV(oscar.ChatTLVRoomName, "the-chat-room-name"),
-			},
-		},
-	}
-
-	svc := NewChatNavService(nil, chatRegistry, newChatRoom, newChatSessMgr)
-	outputSNAC, err := svc.CreateRoomHandler(context.Background(), bosSess, inFrame, inBody)
-	assert.NoError(t, err)
-
-	assert.Equal(t, chatSess.ChatID(), newChatRoom().Cookie)
-
-	expectChatRoom := state.ChatRoom{
-		Cookie:         "dummy-cookie",
-		CreateTime:     time.UnixMilli(0),
-		DetailLevel:    3,
-		Exchange:       1,
-		InstanceNumber: 2,
-		Name:           "the-chat-room-name",
-	}
-	chatRoom, _, err := chatRegistry.Retrieve("dummy-cookie")
-	assert.NoError(t, err)
-
-	// assert the user session is linked to the chat room
-	assert.Equal(t, expectChatRoom, chatRoom)
-
-	expectSNAC := oscar.SNACMessage{
-		Frame: oscar.SNACFrame{
-			FoodGroup: oscar.ChatNav,
-			SubGroup:  oscar.ChatNavNavInfo,
-			RequestID: 1234,
-		},
-		Body: oscar.SNAC_0x0D_0x09_ChatNavNavInfo{
-			TLVRestBlock: oscar.TLVRestBlock{
-				TLVList: oscar.TLVList{
-					oscar.NewTLV(
-						oscar.ChatNavRequestRoomInfo,
-						oscar.SNAC_0x0E_0x02_ChatRoomInfoUpdate{
-							Exchange:       chatRoom.Exchange,
-							Cookie:         chatRoom.Cookie,
-							InstanceNumber: chatRoom.InstanceNumber,
-							DetailLevel:    chatRoom.DetailLevel,
-							TLVBlock: oscar.TLVBlock{
-								TLVList: chatRoom.TLVList(),
-							},
-						},
-					),
-				},
-			},
-		},
-	}
-
-	assert.Equal(t, expectSNAC, outputSNAC)
-}
-
-func TestChatNavService_RequestRoomInfoHandler(t *testing.T) {
-	tests := []struct {
-		name     string
-		chatRoom state.ChatRoom
-		// inputSNAC is the SNAC sent from the client to the server
-		inputSNAC oscar.SNACMessage
-		want      oscar.SNACMessage
-		wantErr   error
-	}{
-		{
-			name: "request room info",
-			chatRoom: state.ChatRoom{
-				Cookie:         "the-chat-id",
-				DetailLevel:    2,
-				Exchange:       4,
-				InstanceNumber: 8,
-			},
-			inputSNAC: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					RequestID: 1234,
-				},
-				Body: oscar.SNAC_0x0D_0x04_ChatNavRequestRoomInfo{
-					Cookie: []byte(`the-chat-id`),
-				},
-			},
-			want: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.ChatNav,
-					SubGroup:  oscar.ChatNavNavInfo,
-					RequestID: 1234,
-				},
-				Body: oscar.SNAC_0x0D_0x09_ChatNavNavInfo{
-					TLVRestBlock: oscar.TLVRestBlock{
-						TLVList: oscar.TLVList{
-							oscar.NewTLV(0x04, oscar.SNAC_0x0E_0x02_ChatRoomInfoUpdate{
-								Cookie:         "the-chat-id",
-								DetailLevel:    2,
-								Exchange:       4,
-								InstanceNumber: 8,
-								TLVBlock: oscar.TLVBlock{
-									TLVList: state.ChatRoom{Cookie: "the-chat-id"}.TLVList(),
-								},
-							}),
-						},
-					},
-				},
-			},
-		},
-	}
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			svc := NewChatNavService(nil, state.NewChatRegistry(), nil, nil)
-			svc.chatRegistry.Register(tt.chatRoom, nil)
-			got, err := svc.RequestRoomInfoHandler(nil, tt.inputSNAC.Frame,
-				tt.inputSNAC.Body.(oscar.SNAC_0x0D_0x04_ChatNavRequestRoomInfo))
-			assert.ErrorIs(t, err, tt.wantErr)
-			if tt.wantErr != nil {
-				return
-			}
-			assert.Equal(t, tt.want, got)
-		})
-	}
-}
-
-func TestChatNavService_RequestChatRightsHandler(t *testing.T) {
-	svc := NewChatNavService(nil, nil, nil, nil)
-
-	have := svc.RequestChatRightsHandler(nil, oscar.SNACFrame{RequestID: 1234})
-
-	want := oscar.SNACMessage{
-		Frame: oscar.SNACFrame{
-			FoodGroup: oscar.ChatNav,
-			SubGroup:  oscar.ChatNavNavInfo,
-			RequestID: 1234,
-		},
-		Body: oscar.SNAC_0x0D_0x09_ChatNavNavInfo{
-			TLVRestBlock: oscar.TLVRestBlock{
-				TLVList: oscar.TLVList{
-					oscar.NewTLV(oscar.ChatNavTLVMaxConcurrentRooms, uint8(10)),
-					oscar.NewTLV(oscar.ChatNavTLVExchangeInfo, oscar.SNAC_0x0D_0x09_TLVExchangeInfo{
-						Identifier: 4,
-						TLVBlock: oscar.TLVBlock{
-							TLVList: oscar.TLVList{
-								oscar.NewTLV(oscar.ChatNavTLVClassPerms, uint16(0x0010)),
-								oscar.NewTLV(oscar.ChatNavTLVFlags, uint16(15)),
-								oscar.NewTLV(oscar.ChatNavTLVRoomName, "default exchange"),
-								oscar.NewTLV(oscar.ChatNavTLVCreatePerms, uint8(2)),
-								oscar.NewTLV(oscar.ChatNavTLVCharSet1, "us-ascii"),
-								oscar.NewTLV(oscar.ChatNavTLVLang1, "en"),
-								oscar.NewTLV(oscar.ChatNavTLVCharSet2, "us-ascii"),
-								oscar.NewTLV(oscar.ChatNavTLVLang2, "en"),
-							},
-						},
-					}),
-				},
-			},
-		},
-	}
-
-	assert.Equal(t, want, have)
-}

+ 0 - 297
handler/feedbag.go

@@ -1,297 +0,0 @@
-package handler
-
-import (
-	"context"
-	"time"
-
-	"github.com/mk6i/retro-aim-server/oscar"
-	"github.com/mk6i/retro-aim-server/state"
-)
-
-// NewFeedbagService creates a new instance of FeedbagService.
-func NewFeedbagService(messageRelayer MessageRelayer, feedbagManager FeedbagManager) FeedbagService {
-	return FeedbagService{
-		messageRelayer: messageRelayer,
-		feedbagManager: feedbagManager,
-	}
-}
-
-// FeedbagService provides handlers for the Feedbag food group.
-type FeedbagService struct {
-	messageRelayer MessageRelayer
-	feedbagManager FeedbagManager
-}
-
-// RightsQueryHandler returns SNAC oscar.FeedbagRightsReply, which contains
-// Feedbag food group settings for the current user. The values within the SNAC
-// are not well understood but seem to make the AIM client happy.
-func (s FeedbagService) RightsQueryHandler(_ context.Context, inFrame oscar.SNACFrame) oscar.SNACMessage {
-	return oscar.SNACMessage{
-		Frame: oscar.SNACFrame{
-			FoodGroup: oscar.Feedbag,
-			SubGroup:  oscar.FeedbagRightsReply,
-			RequestID: inFrame.RequestID,
-		},
-		Body: oscar.SNAC_0x13_0x03_FeedbagRightsReply{
-			TLVRestBlock: oscar.TLVRestBlock{
-				TLVList: oscar.TLVList{
-					oscar.NewTLV(oscar.FeedbagRightsMaxItemAttrs, uint16(200)),
-					oscar.NewTLV(oscar.FeedbagRightsMaxItemsByClass, []uint16{
-						0x3D, // max num of contacts
-						0x3D, // max num of groups
-						0x64, // max visible contacts
-						0x64, // max invisible contacts
-						0x01, // max vis/invis bitmasks
-						0x01, // max presense info fields
-						0x32, // limit for item type 06
-						0x00, // limit for item type 07
-						0x00, // limit for item type 08
-						0x03, // limit for item type 09
-						0x00, // limit for item type 0a
-						0x00, // limit for item type 0b
-						0x00, // limit for item type 0c
-						0x80, // limit for item type 0d
-						0xFF, // max ignore list entries
-						0x14, // limit for item type 0f
-						0xC8, // limit for item 10
-						0x01, // limit for item 11
-						0x00, // limit for item 12
-						0x01, // limit for item 13
-						0x00, // limit for item 14
-					}),
-					oscar.NewTLV(oscar.FeedbagRightsMaxClientItems, uint16(200)),
-					oscar.NewTLV(oscar.FeedbagRightsMaxItemNameLen, uint16(200)),
-					oscar.NewTLV(oscar.FeedbagRightsMaxRecentBuddies, uint16(200)),
-					oscar.NewTLV(oscar.FeedbagRightsInteractionBuddies, uint16(200)),
-					oscar.NewTLV(oscar.FeedbagRightsInteractionHalfLife, uint16(200)),
-					oscar.NewTLV(oscar.FeedbagRightsInteractionMaxScore, uint16(200)),
-					oscar.NewTLV(oscar.FeedbagRightsMaxBuddiesPerGroup, uint16(200)),
-					oscar.NewTLV(oscar.FeedbagRightsMaxMegaBots, uint16(200)),
-					oscar.NewTLV(oscar.FeedbagRightsMaxSmartGroups, uint16(100)),
-				},
-			},
-		},
-	}
-}
-
-// QueryHandler fetches the user's feedbag (aka buddy list). It returns
-// oscar.FeedbagReply, which contains feedbag entries.
-func (s FeedbagService) QueryHandler(_ context.Context, sess *state.Session, inFrame oscar.SNACFrame) (oscar.SNACMessage, error) {
-	fb, err := s.feedbagManager.Feedbag(sess.ScreenName())
-	if err != nil {
-		return oscar.SNACMessage{}, err
-	}
-
-	lm := time.UnixMilli(0)
-
-	if len(fb) > 0 {
-		lm, err = s.feedbagManager.FeedbagLastModified(sess.ScreenName())
-		if err != nil {
-			return oscar.SNACMessage{}, err
-		}
-	}
-
-	return oscar.SNACMessage{
-		Frame: oscar.SNACFrame{
-			FoodGroup: oscar.Feedbag,
-			SubGroup:  oscar.FeedbagReply,
-			RequestID: inFrame.RequestID,
-		},
-		Body: oscar.SNAC_0x13_0x06_FeedbagReply{
-			Version:    0,
-			Items:      fb,
-			LastUpdate: uint32(lm.Unix()),
-		},
-	}, nil
-}
-
-// QueryIfModifiedHandler fetches the user's feedbag (aka buddy list). It
-// returns oscar.FeedbagReplyNotModified if the feedbag was last modified
-// before inBody.LastUpdate, else return oscar.FeedbagReply, which contains
-// feedbag entries.
-func (s FeedbagService) QueryIfModifiedHandler(_ context.Context, sess *state.Session, inFrame oscar.SNACFrame, inBody oscar.SNAC_0x13_0x05_FeedbagQueryIfModified) (oscar.SNACMessage, error) {
-	fb, err := s.feedbagManager.Feedbag(sess.ScreenName())
-	if err != nil {
-		return oscar.SNACMessage{}, err
-	}
-
-	lm := time.UnixMilli(0)
-
-	if len(fb) > 0 {
-		lm, err = s.feedbagManager.FeedbagLastModified(sess.ScreenName())
-		if err != nil {
-			return oscar.SNACMessage{}, err
-		}
-		if lm.Before(time.Unix(int64(inBody.LastUpdate), 0)) {
-			return oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.Feedbag,
-					SubGroup:  oscar.FeedbagReplyNotModified,
-					RequestID: inFrame.RequestID,
-				},
-				Body: oscar.SNAC_0x13_0x05_FeedbagQueryIfModified{
-					LastUpdate: uint32(lm.Unix()),
-					Count:      uint8(len(fb)),
-				},
-			}, nil
-		}
-	}
-
-	return oscar.SNACMessage{
-		Frame: oscar.SNACFrame{
-			FoodGroup: oscar.Feedbag,
-			SubGroup:  oscar.FeedbagReply,
-			RequestID: inFrame.RequestID,
-		},
-		Body: oscar.SNAC_0x13_0x06_FeedbagReply{
-			Version:    0,
-			Items:      fb,
-			LastUpdate: uint32(lm.Unix()),
-		},
-	}, nil
-}
-
-// InsertItemHandler adds items to the user's feedbag (aka buddy list). Sends
-// user buddy arrival notifications for each online & visible buddy added to
-// the feedbag. Sends a buddy departure notification to blocked buddies if
-// current user is visible. It returns oscar.FeedbagStatus, which contains
-// insert confirmation.
-func (s FeedbagService) InsertItemHandler(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, inBody oscar.SNAC_0x13_0x08_FeedbagInsertItem) (oscar.SNACMessage, error) {
-	for _, item := range inBody.Items {
-		// don't let users block themselves, it causes the AIM client to go
-		// into a weird state.
-		if item.ClassID == 3 && item.Name == sess.ScreenName() {
-			return oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.Feedbag,
-					SubGroup:  oscar.FeedbagErr,
-					RequestID: inFrame.RequestID,
-				},
-				Body: oscar.SNACError{
-					Code: oscar.ErrorCodeNotSupportedByHost,
-				},
-			}, nil
-		}
-	}
-
-	if err := s.feedbagManager.FeedbagUpsert(sess.ScreenName(), inBody.Items); err != nil {
-		return oscar.SNACMessage{}, nil
-	}
-
-	for _, item := range inBody.Items {
-		switch item.ClassID {
-		case oscar.FeedbagClassIdBuddy, oscar.FeedbagClassIDPermit: // add new buddy
-			buddy := s.messageRelayer.RetrieveByScreenName(item.Name)
-			if buddy == nil || buddy.Invisible() {
-				continue
-			}
-			unicastArrival(ctx, buddy, sess, s.messageRelayer)
-		case oscar.FeedbagClassIDDeny: // block buddy
-			if sess.Invisible() {
-				continue // user's offline, don't send departure notification
-			}
-			blockedSess := s.messageRelayer.RetrieveByScreenName(item.Name)
-			if blockedSess == nil {
-				continue // blocked buddy is offline, nothing to do here
-			}
-			// alert blocked buddy that current user is offline
-			unicastDeparture(ctx, sess, blockedSess, s.messageRelayer)
-		}
-	}
-
-	snacPayloadOut := oscar.SNAC_0x13_0x0E_FeedbagStatus{}
-	for range inBody.Items {
-		snacPayloadOut.Results = append(snacPayloadOut.Results, 0x0000)
-	}
-
-	return oscar.SNACMessage{
-		Frame: oscar.SNACFrame{
-			FoodGroup: oscar.Feedbag,
-			SubGroup:  oscar.FeedbagStatus,
-			RequestID: inFrame.RequestID,
-		},
-		Body: snacPayloadOut,
-	}, nil
-}
-
-// UpdateItemHandler updates items in the user's feedbag (aka buddy list).
-// Sends user buddy arrival notifications for each online & visible buddy added
-// to the feedbag. It returns oscar.FeedbagStatus, which contains update
-// confirmation.
-func (s FeedbagService) UpdateItemHandler(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, inBody oscar.SNAC_0x13_0x09_FeedbagUpdateItem) (oscar.SNACMessage, error) {
-	if err := s.feedbagManager.FeedbagUpsert(sess.ScreenName(), inBody.Items); err != nil {
-		return oscar.SNACMessage{}, nil
-	}
-
-	for _, item := range inBody.Items {
-		switch item.ClassID {
-		case oscar.FeedbagClassIdBuddy, oscar.FeedbagClassIDPermit:
-			buddy := s.messageRelayer.RetrieveByScreenName(item.Name)
-			if buddy == nil || buddy.Invisible() {
-				continue
-			}
-			unicastArrival(ctx, buddy, sess, s.messageRelayer)
-		}
-	}
-
-	snacPayloadOut := oscar.SNAC_0x13_0x0E_FeedbagStatus{}
-	for range inBody.Items {
-		snacPayloadOut.Results = append(snacPayloadOut.Results, 0x0000)
-	}
-
-	return oscar.SNACMessage{
-		Frame: oscar.SNACFrame{
-			FoodGroup: oscar.Feedbag,
-			SubGroup:  oscar.FeedbagStatus,
-			RequestID: inFrame.RequestID,
-		},
-		Body: snacPayloadOut,
-	}, nil
-}
-
-// DeleteItemHandler removes items from feedbag (aka buddy list). Sends user
-// buddy arrival notifications for each online & visible buddy added to
-// the feedbag. Sends buddy arrival notifications to each unblocked buddy if
-// current user is visible. It returns oscar.FeedbagStatus, which contains update
-// confirmation.
-func (s FeedbagService) DeleteItemHandler(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, inBody oscar.SNAC_0x13_0x0A_FeedbagDeleteItem) (oscar.SNACMessage, error) {
-	if err := s.feedbagManager.FeedbagDelete(sess.ScreenName(), inBody.Items); err != nil {
-		return oscar.SNACMessage{}, err
-	}
-
-	for _, item := range inBody.Items {
-		if item.ClassID == oscar.FeedbagClassIDDeny {
-			unblockedSess := s.messageRelayer.RetrieveByScreenName(item.Name)
-			if unblockedSess == nil {
-				continue // unblocked user is offline, nothing to do here
-			}
-			if !sess.Invisible() {
-				// alert unblocked user that current user is online
-				unicastArrival(ctx, sess, unblockedSess, s.messageRelayer)
-			}
-			if !unblockedSess.Invisible() {
-				// alert current user that unblocked user is online
-				unicastArrival(ctx, unblockedSess, sess, s.messageRelayer)
-			}
-		}
-	}
-
-	snacPayloadOut := oscar.SNAC_0x13_0x0E_FeedbagStatus{}
-	for range inBody.Items {
-		snacPayloadOut.Results = append(snacPayloadOut.Results, 0x0000) // success by default
-	}
-
-	return oscar.SNACMessage{
-		Frame: oscar.SNACFrame{
-			FoodGroup: oscar.Feedbag,
-			SubGroup:  oscar.FeedbagStatus,
-			RequestID: inFrame.RequestID,
-		},
-		Body: snacPayloadOut,
-	}, nil
-}
-
-// StartClusterHandler exists to capture the SNAC input in unit tests to verify
-// it's correctly unmarshalled.
-func (s FeedbagService) StartClusterHandler(context.Context, oscar.SNACFrame, oscar.SNAC_0x13_0x11_FeedbagStartCluster) {
-}

+ 0 - 255
handler/icbm.go

@@ -1,255 +0,0 @@
-package handler
-
-import (
-	"context"
-
-	"github.com/mk6i/retro-aim-server/oscar"
-	"github.com/mk6i/retro-aim-server/state"
-)
-
-const (
-	evilDelta     = uint16(100)
-	evilDeltaAnon = uint16(30)
-)
-
-// NewICBMService returns a new instance of ICBMService.
-func NewICBMService(messageRelayer MessageRelayer, feedbagManager FeedbagManager) *ICBMService {
-	return &ICBMService{messageRelayer: messageRelayer, feedbagManager: feedbagManager}
-}
-
-// ICBMService implements the ICBM food group, which is responsible for sending
-// and receiving instant messages and associated functionality such as warning,
-// typing events, etc.
-type ICBMService struct {
-	messageRelayer MessageRelayer
-	feedbagManager FeedbagManager
-}
-
-// ParameterQueryHandler returns ICBM service parameters.
-func (s ICBMService) ParameterQueryHandler(_ context.Context, inFrame oscar.SNACFrame) oscar.SNACMessage {
-	return oscar.SNACMessage{
-		Frame: oscar.SNACFrame{
-			FoodGroup: oscar.ICBM,
-			SubGroup:  oscar.ICBMParameterReply,
-			RequestID: inFrame.RequestID,
-		},
-		Body: oscar.SNAC_0x04_0x05_ICBMParameterReply{
-			MaxSlots:             100,
-			ICBMFlags:            3,
-			MaxIncomingICBMLen:   512,
-			MaxSourceEvil:        999,
-			MaxDestinationEvil:   999,
-			MinInterICBMInterval: 0,
-		},
-	}
-}
-
-// ChannelMsgToHostHandler relays the instant message SNAC
-// oscar.ICBMChannelMsgToHost from the sender to the intended recipient. It
-// returns oscar.ICBMHostAck if the oscar.ICBMChannelMsgToHost message contains
-// a request acknowledgement flag.
-func (s ICBMService) ChannelMsgToHostHandler(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, inBody oscar.SNAC_0x04_0x06_ICBMChannelMsgToHost) (*oscar.SNACMessage, error) {
-	blocked, err := s.feedbagManager.BlockedState(sess.ScreenName(), inBody.ScreenName)
-	if err != nil {
-		return nil, err
-	}
-
-	if blocked != state.BlockedNo {
-		code := oscar.ErrorCodeNotLoggedOn
-		if blocked == state.BlockedA {
-			code = oscar.ErrorCodeInLocalPermitDeny
-		}
-		return &oscar.SNACMessage{
-			Frame: oscar.SNACFrame{
-				FoodGroup: oscar.ICBM,
-				SubGroup:  oscar.ICBMErr,
-				RequestID: inFrame.RequestID,
-			},
-			Body: oscar.SNACError{
-				Code: code,
-			},
-		}, nil
-	}
-
-	recipSess := s.messageRelayer.RetrieveByScreenName(inBody.ScreenName)
-	if recipSess == nil {
-		return &oscar.SNACMessage{
-			Frame: oscar.SNACFrame{
-				FoodGroup: oscar.ICBM,
-				SubGroup:  oscar.ICBMErr,
-				RequestID: inFrame.RequestID,
-			},
-			Body: oscar.SNACError{
-				Code: oscar.ErrorCodeNotLoggedOn,
-			},
-		}, nil
-	}
-
-	clientIM := oscar.SNAC_0x04_0x07_ICBMChannelMsgToClient{
-		Cookie:    inBody.Cookie,
-		ChannelID: inBody.ChannelID,
-		TLVUserInfo: oscar.TLVUserInfo{
-			ScreenName:   sess.ScreenName(),
-			WarningLevel: sess.Warning(),
-		},
-		TLVRestBlock: oscar.TLVRestBlock{
-			TLVList: oscar.TLVList{
-				{
-					Tag:   0x0B,
-					Value: []byte{},
-				},
-			},
-		},
-	}
-	// copy over TLVs from sender SNAC to recipient SNAC verbatim. this
-	// includes ICBMTLVTagRequestHostAck, which is ignored by the client, as
-	// far as I can tell.
-	clientIM.AppendList(inBody.TLVRestBlock.TLVList)
-
-	s.messageRelayer.RelayToScreenName(ctx, recipSess.ScreenName(), oscar.SNACMessage{
-		Frame: oscar.SNACFrame{
-			FoodGroup: oscar.ICBM,
-			SubGroup:  oscar.ICBMChannelMsgToClient,
-		},
-		Body: clientIM,
-	})
-
-	if _, requestedConfirmation := inBody.TLVRestBlock.Slice(oscar.ICBMTLVTagRequestHostAck); !requestedConfirmation {
-		// don't ack message
-		return nil, nil
-	}
-
-	// ack message back to sender
-	return &oscar.SNACMessage{
-		Frame: oscar.SNACFrame{
-			FoodGroup: oscar.ICBM,
-			SubGroup:  oscar.ICBMHostAck,
-			RequestID: inFrame.RequestID,
-		},
-		Body: oscar.SNAC_0x04_0x0C_ICBMHostAck{
-			Cookie:     inBody.Cookie,
-			ChannelID:  inBody.ChannelID,
-			ScreenName: inBody.ScreenName,
-		},
-	}, nil
-}
-
-// ClientEventHandler relays SNAC oscar.ICBMClientEvent typing events from the
-// sender to the recipient.
-func (s ICBMService) ClientEventHandler(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, inBody oscar.SNAC_0x04_0x14_ICBMClientEvent) error {
-	blocked, err := s.feedbagManager.BlockedState(sess.ScreenName(), inBody.ScreenName)
-
-	switch {
-	case err != nil:
-		return err
-	case blocked != state.BlockedNo:
-		return nil
-	default:
-		s.messageRelayer.RelayToScreenName(ctx, inBody.ScreenName, oscar.SNACMessage{
-			Frame: oscar.SNACFrame{
-				FoodGroup: oscar.ICBM,
-				SubGroup:  oscar.ICBMClientEvent,
-				RequestID: inFrame.RequestID,
-			},
-			Body: oscar.SNAC_0x04_0x14_ICBMClientEvent{
-				Cookie:     inBody.Cookie,
-				ChannelID:  inBody.ChannelID,
-				ScreenName: sess.ScreenName(),
-				Event:      inBody.Event,
-			},
-		})
-		return nil
-	}
-}
-
-// EvilRequestHandler handles user warning (a.k.a evil) notifications. It
-// receives oscar.ICBMEvilRequest warning SNAC, increments the warned user's
-// warning level, and sends the warned user a notification informing them that
-// they have been warned. The user may choose to warn anonymously or
-// non-anonymously. It returns SNAC oscar.ICBMEvilReply to confirm that the
-// warning was sent. Users may not warn themselves or warn users they have
-// blocked or are blocked by.
-func (s ICBMService) EvilRequestHandler(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, inBody oscar.SNAC_0x04_0x08_ICBMEvilRequest) (oscar.SNACMessage, error) {
-	// don't let users warn themselves, it causes the AIM client to go into a
-	// weird state.
-	if inBody.ScreenName == sess.ScreenName() {
-		return oscar.SNACMessage{
-			Frame: oscar.SNACFrame{
-				FoodGroup: oscar.ICBM,
-				SubGroup:  oscar.ICBMErr,
-				RequestID: inFrame.RequestID,
-			},
-			Body: oscar.SNACError{
-				Code: oscar.ErrorCodeNotSupportedByHost,
-			},
-		}, nil
-	}
-
-	blocked, err := s.feedbagManager.BlockedState(sess.ScreenName(), inBody.ScreenName)
-	if err != nil {
-		return oscar.SNACMessage{}, nil
-	}
-	if blocked != state.BlockedNo {
-		return oscar.SNACMessage{
-			Frame: oscar.SNACFrame{
-				FoodGroup: oscar.ICBM,
-				SubGroup:  oscar.ICBMErr,
-				RequestID: inFrame.RequestID,
-			},
-			Body: oscar.SNACError{
-				Code: oscar.ErrorCodeNotLoggedOn,
-			},
-		}, nil
-	}
-
-	recipSess := s.messageRelayer.RetrieveByScreenName(inBody.ScreenName)
-	if recipSess == nil {
-		return oscar.SNACMessage{}, nil
-	}
-
-	increase := evilDelta
-	if inBody.SendAs == 1 {
-		increase = evilDeltaAnon
-	}
-	recipSess.IncrementWarning(increase)
-
-	var notif any
-	if inBody.SendAs == 0 {
-		notif = oscar.SNAC_0x01_0x10_OServiceEvilNotification{
-			NewEvil: recipSess.Warning(),
-			TLVUserInfo: oscar.TLVUserInfo{
-				ScreenName:   sess.ScreenName(),
-				WarningLevel: recipSess.Warning(),
-			},
-		}
-	} else {
-		notif = oscar.SNAC_0x01_0x10_OServiceEvilNotificationAnon{
-			NewEvil: recipSess.Warning(),
-		}
-	}
-
-	s.messageRelayer.RelayToScreenName(ctx, recipSess.ScreenName(), oscar.SNACMessage{
-		Frame: oscar.SNACFrame{
-			FoodGroup: oscar.OService,
-			SubGroup:  oscar.OServiceEvilNotification,
-		},
-		Body: notif,
-	})
-
-	// inform the warned user's buddies that their warning level has increased
-	if err := broadcastArrival(ctx, recipSess, s.messageRelayer, s.feedbagManager); err != nil {
-		return oscar.SNACMessage{}, nil
-	}
-
-	return oscar.SNACMessage{
-		Frame: oscar.SNACFrame{
-			FoodGroup: oscar.ICBM,
-			SubGroup:  oscar.ICBMEvilReply,
-			RequestID: inFrame.RequestID,
-		},
-		Body: oscar.SNAC_0x04_0x09_ICBMEvilReply{
-			EvilDeltaApplied: increase,
-			UpdatedEvilValue: recipSess.Warning(),
-		},
-	}, nil
-}

+ 0 - 171
handler/locate.go

@@ -1,171 +0,0 @@
-package handler
-
-import (
-	"context"
-
-	"github.com/mk6i/retro-aim-server/oscar"
-	"github.com/mk6i/retro-aim-server/state"
-)
-
-// NewLocateService creates a new instance of LocateService.
-func NewLocateService(messageRelayer MessageRelayer, feedbagManager FeedbagManager, profileManager ProfileManager) LocateService {
-	return LocateService{
-		sessionManager: messageRelayer,
-		feedbagManager: feedbagManager,
-		profileManager: profileManager,
-	}
-}
-
-// LocateService provides handlers for the Locate food group.
-type LocateService struct {
-	sessionManager MessageRelayer
-	feedbagManager FeedbagManager
-	profileManager ProfileManager
-}
-
-// RightsQueryHandler returns SNAC oscar.LocateRightsReply, which contains
-// Locate food group settings for the current user.
-func (s LocateService) RightsQueryHandler(_ context.Context, inFrame oscar.SNACFrame) oscar.SNACMessage {
-	return oscar.SNACMessage{
-		Frame: oscar.SNACFrame{
-			FoodGroup: oscar.Locate,
-			SubGroup:  oscar.LocateRightsReply,
-			RequestID: inFrame.RequestID,
-		},
-		Body: oscar.SNAC_0x02_0x03_LocateRightsReply{
-			TLVRestBlock: oscar.TLVRestBlock{
-				TLVList: oscar.TLVList{
-					// these are arbitrary values--AIM clients seem to perform
-					// OK with them
-					oscar.NewTLV(oscar.LocateTLVTagsRightsMaxSigLen, uint16(1000)),
-					oscar.NewTLV(oscar.LocateTLVTagsRightsMaxCapabilitiesLen, uint16(1000)),
-					oscar.NewTLV(oscar.LocateTLVTagsRightsMaxFindByEmailList, uint16(1000)),
-					oscar.NewTLV(oscar.LocateTLVTagsRightsMaxCertsLen, uint16(1000)),
-					oscar.NewTLV(oscar.LocateTLVTagsRightsMaxMaxShortCapabilities, uint16(1000)),
-				},
-			},
-		},
-	}
-}
-
-// SetInfoHandler sets the user's profile or away message.
-func (s LocateService) SetInfoHandler(ctx context.Context, sess *state.Session, inBody oscar.SNAC_0x02_0x04_LocateSetInfo) error {
-	// update profile
-	if profile, hasProfile := inBody.String(oscar.LocateTLVTagsInfoSigData); hasProfile {
-		if err := s.profileManager.SetProfile(sess.ScreenName(), profile); err != nil {
-			return err
-		}
-	}
-
-	// broadcast away message change to buddies
-	if awayMsg, hasAwayMsg := inBody.String(oscar.LocateTLVTagsInfoUnavailableData); hasAwayMsg {
-		sess.SetAwayMessage(awayMsg)
-		if err := broadcastArrival(ctx, sess, s.sessionManager, s.feedbagManager); err != nil {
-			return err
-		}
-	}
-	return nil
-}
-
-// UserInfoQuery2Handler fetches display information about an arbitrary user
-// (not the current user). It returns oscar.LocateUserInfoReply, which contains
-// the profile, if requested, and/or the away message, if requested. This is a
-// v2 of the UserInfoQueryHandler handler.
-func (s LocateService) UserInfoQuery2Handler(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, inBody oscar.SNAC_0x02_0x15_LocateUserInfoQuery2) (oscar.SNACMessage, error) {
-	blocked, err := s.feedbagManager.BlockedState(sess.ScreenName(), inBody.ScreenName)
-	switch {
-	case err != nil:
-		return oscar.SNACMessage{}, err
-	case blocked != state.BlockedNo:
-		return oscar.SNACMessage{
-			Frame: oscar.SNACFrame{
-				FoodGroup: oscar.Locate,
-				SubGroup:  oscar.LocateErr,
-				RequestID: inFrame.RequestID,
-			},
-			Body: oscar.SNACError{
-				Code: oscar.ErrorCodeNotLoggedOn,
-			},
-		}, nil
-	}
-
-	buddySess := s.sessionManager.RetrieveByScreenName(inBody.ScreenName)
-	if buddySess == nil {
-		return oscar.SNACMessage{
-			Frame: oscar.SNACFrame{
-				FoodGroup: oscar.Locate,
-				SubGroup:  oscar.LocateErr,
-				RequestID: inFrame.RequestID,
-			},
-			Body: oscar.SNACError{
-				Code: oscar.ErrorCodeNotLoggedOn,
-			},
-		}, nil
-	}
-
-	var list oscar.TLVList
-
-	if inBody.RequestProfile() {
-		profile, err := s.profileManager.Profile(inBody.ScreenName)
-		if err != nil {
-			return oscar.SNACMessage{}, err
-		}
-		list.AppendList([]oscar.TLV{
-			oscar.NewTLV(oscar.LocateTLVTagsInfoSigMime, `text/aolrtf; charset="us-ascii"`),
-			oscar.NewTLV(oscar.LocateTLVTagsInfoSigData, profile),
-		})
-	}
-
-	if inBody.RequestAwayMessage() {
-		list.AppendList([]oscar.TLV{
-			oscar.NewTLV(oscar.LocateTLVTagsInfoUnavailableMime, `text/aolrtf; charset="us-ascii"`),
-			oscar.NewTLV(oscar.LocateTLVTagsInfoUnavailableData, buddySess.AwayMessage()),
-		})
-	}
-
-	return oscar.SNACMessage{
-		Frame: oscar.SNACFrame{
-			FoodGroup: oscar.Locate,
-			SubGroup:  oscar.LocateUserInfoReply,
-			RequestID: inFrame.RequestID,
-		},
-		Body: oscar.SNAC_0x02_0x06_LocateUserInfoReply{
-			TLVUserInfo: buddySess.TLVUserInfo(),
-			LocateInfo: oscar.TLVRestBlock{
-				TLVList: list,
-			},
-		},
-	}, nil
-}
-
-// SetDirInfoHandler sets directory information for current user (first name,
-// last name, etc). This method does nothing and exists to placate the AIM
-// client. It returns oscar.LocateSetDirReply with a canned success message.
-func (s LocateService) SetDirInfoHandler(_ context.Context, inFrame oscar.SNACFrame) oscar.SNACMessage {
-	return oscar.SNACMessage{
-		Frame: oscar.SNACFrame{
-			FoodGroup: oscar.Locate,
-			SubGroup:  oscar.LocateSetDirReply,
-			RequestID: inFrame.RequestID,
-		},
-		Body: oscar.SNAC_0x02_0x0A_LocateSetDirReply{
-			Result: 1,
-		},
-	}
-}
-
-// SetKeywordInfoHandler sets profile keywords and interests. This method does
-// nothing and exists to placate the AIM client. It returns
-// oscar.LocateSetKeywordReply with a canned success message.
-func (s LocateService) SetKeywordInfoHandler(_ context.Context, inFrame oscar.SNACFrame) oscar.SNACMessage {
-	return oscar.SNACMessage{
-		Frame: oscar.SNACFrame{
-			FoodGroup: oscar.Locate,
-			SubGroup:  oscar.LocateSetKeywordReply,
-			RequestID: inFrame.RequestID,
-		},
-		Body: oscar.SNAC_0x02_0x10_LocateSetKeywordReply{
-			Unknown: 1,
-		},
-	}
-}

+ 0 - 478
handler/oservice.go

@@ -1,478 +0,0 @@
-package handler
-
-import (
-	"bytes"
-	"context"
-	"errors"
-	"fmt"
-	"time"
-
-	"github.com/mk6i/retro-aim-server/config"
-	"github.com/mk6i/retro-aim-server/oscar"
-	"github.com/mk6i/retro-aim-server/server"
-	"github.com/mk6i/retro-aim-server/state"
-)
-
-// NewOServiceService creates a new instance of OServiceService.
-func NewOServiceService(cfg config.Config, messageRelayer MessageRelayer, feedbagManager FeedbagManager) *OServiceService {
-	return &OServiceService{cfg: cfg, messageRelayer: messageRelayer, feedbagManager: feedbagManager}
-}
-
-// OServiceService provides handlers for the OService food group.
-type OServiceService struct {
-	cfg            config.Config
-	feedbagManager FeedbagManager
-	messageRelayer MessageRelayer
-}
-
-// ClientVersionsHandler informs the server what food group versions the client
-// supports and returns to the client what food group versions it supports.
-// This method simply regurgitates versions supplied by the client in inBody
-// back to the client in a OServiceHostVersions SNAC. The server doesn't
-// attempt to accommodate any particular food group version. The server
-// implicitly accommodates any food group version for Windows AIM clients 5.x.
-// It returns SNAC oscar.OServiceHostVersions containing the server's supported
-// food group versions.
-func (s OServiceService) ClientVersionsHandler(_ context.Context, frame oscar.SNACFrame, inBody oscar.SNAC_0x01_0x17_OServiceClientVersions) oscar.SNACMessage {
-	return oscar.SNACMessage{
-		Frame: oscar.SNACFrame{
-			FoodGroup: oscar.OService,
-			SubGroup:  oscar.OServiceHostVersions,
-			RequestID: frame.RequestID,
-		},
-		Body: oscar.SNAC_0x01_0x18_OServiceHostVersions{
-			Versions: inBody.Versions,
-		},
-	}
-}
-
-// RateParamsQueryHandler returns SNAC rate limits.
-// The purpose of this method is to provide information about rate limits that
-// can be enforced on the server side. The response consists of two main parts:
-// rate classes and rate groups. Rate classes define limits based on specific
-// parameters, while rate groups associate these limits with relevant SNAC
-// types.
-//
-// Note: The current implementation does not enforce server-side rate limiting.
-// Instead, the provided values inform the client about the recommended
-// client-side rate limits.
-//
-// It returns SNAC oscar.OServiceRateParamsReply containing rate limits for
-// sending Instant Messages (IMs) and chat messages. More refined limits may be
-// added in the future if/when server rate limiting is implemented.
-func (s OServiceService) RateParamsQueryHandler(_ context.Context, inFrame oscar.SNACFrame) oscar.SNACMessage {
-	return oscar.SNACMessage{
-		Frame: oscar.SNACFrame{
-			FoodGroup: oscar.OService,
-			SubGroup:  oscar.OServiceRateParamsReply,
-			RequestID: inFrame.RequestID,
-		},
-		Body: oscar.SNAC_0x01_0x07_OServiceRateParamsReply{
-			RateClasses: []struct {
-				ID              uint16
-				WindowSize      uint32
-				ClearLevel      uint32
-				AlertLevel      uint32
-				LimitLevel      uint32
-				DisconnectLevel uint32
-				CurrentLevel    uint32
-				MaxLevel        uint32
-				LastTime        uint32
-				CurrentState    uint8
-			}{
-				{
-					ID:              0x01,
-					WindowSize:      0x0050,
-					ClearLevel:      0x09C4,
-					AlertLevel:      0x07D0,
-					LimitLevel:      0x05DC,
-					DisconnectLevel: 0x0320,
-					CurrentLevel:    0x0D69,
-					MaxLevel:        0x1770,
-					LastTime:        0x0000,
-					CurrentState:    0x0,
-				},
-			},
-			RateGroups: []struct {
-				ID    uint16
-				Pairs []struct {
-					FoodGroup uint16
-					SubGroup  uint16
-				} `count_prefix:"uint16"`
-			}{
-				{
-					ID: 1,
-					Pairs: []struct {
-						FoodGroup uint16
-						SubGroup  uint16
-					}{
-						{
-							FoodGroup: oscar.Buddy,
-							SubGroup:  oscar.BuddyRightsQuery,
-						},
-						{
-							FoodGroup: oscar.Chat,
-							SubGroup:  oscar.ChatChannelMsgToHost,
-						},
-						{
-							FoodGroup: oscar.ChatNav,
-							SubGroup:  oscar.ChatNavRequestChatRights,
-						},
-						{
-							FoodGroup: oscar.ChatNav,
-							SubGroup:  oscar.ChatNavRequestRoomInfo,
-						},
-						{
-							FoodGroup: oscar.ChatNav,
-							SubGroup:  oscar.ChatNavCreateRoom,
-						},
-						{
-							FoodGroup: oscar.Feedbag,
-							SubGroup:  oscar.FeedbagRightsQuery,
-						},
-						{
-							FoodGroup: oscar.Feedbag,
-							SubGroup:  oscar.FeedbagQuery,
-						},
-						{
-							FoodGroup: oscar.Feedbag,
-							SubGroup:  oscar.FeedbagQueryIfModified,
-						},
-						{
-							FoodGroup: oscar.Feedbag,
-							SubGroup:  oscar.FeedbagUse,
-						},
-						{
-							FoodGroup: oscar.Feedbag,
-							SubGroup:  oscar.FeedbagInsertItem,
-						},
-						{
-							FoodGroup: oscar.Feedbag,
-							SubGroup:  oscar.FeedbagUpdateItem,
-						},
-						{
-							FoodGroup: oscar.Feedbag,
-							SubGroup:  oscar.FeedbagDeleteItem,
-						},
-						{
-							FoodGroup: oscar.Feedbag,
-							SubGroup:  oscar.FeedbagStartCluster,
-						},
-						{
-							FoodGroup: oscar.Feedbag,
-							SubGroup:  oscar.FeedbagEndCluster,
-						},
-						{
-							FoodGroup: oscar.ICBM,
-							SubGroup:  oscar.ICBMAddParameters,
-						},
-						{
-							FoodGroup: oscar.ICBM,
-							SubGroup:  oscar.ICBMParameterQuery,
-						},
-						{
-							FoodGroup: oscar.ICBM,
-							SubGroup:  oscar.ICBMChannelMsgToHost,
-						},
-						{
-							FoodGroup: oscar.ICBM,
-							SubGroup:  oscar.ICBMEvilRequest,
-						},
-						{
-							FoodGroup: oscar.ICBM,
-							SubGroup:  oscar.ICBMClientErr,
-						},
-						{
-							FoodGroup: oscar.ICBM,
-							SubGroup:  oscar.ICBMClientEvent,
-						},
-						{
-							FoodGroup: oscar.Locate,
-							SubGroup:  oscar.LocateRightsQuery,
-						},
-						{
-							FoodGroup: oscar.Locate,
-							SubGroup:  oscar.LocateSetInfo,
-						},
-						{
-							FoodGroup: oscar.Locate,
-							SubGroup:  oscar.LocateSetDirInfo,
-						},
-						{
-							FoodGroup: oscar.Locate,
-							SubGroup:  oscar.LocateGetDirInfo,
-						},
-						{
-							FoodGroup: oscar.Locate,
-							SubGroup:  oscar.LocateSetKeywordInfo,
-						},
-						{
-							FoodGroup: oscar.Locate,
-							SubGroup:  oscar.LocateUserInfoQuery2,
-						},
-						{
-							FoodGroup: oscar.OService,
-							SubGroup:  oscar.OServiceServiceRequest,
-						},
-						{
-							FoodGroup: oscar.OService,
-							SubGroup:  oscar.OServiceClientOnline,
-						},
-						{
-							FoodGroup: oscar.OService,
-							SubGroup:  oscar.OServiceRateParamsQuery,
-						},
-						{
-							FoodGroup: oscar.OService,
-							SubGroup:  oscar.OServiceRateParamsSubAdd,
-						},
-						{
-							FoodGroup: oscar.OService,
-							SubGroup:  oscar.OServiceUserInfoQuery,
-						},
-						{
-							FoodGroup: oscar.OService,
-							SubGroup:  oscar.OServiceIdleNotification,
-						},
-						{
-							FoodGroup: oscar.OService,
-							SubGroup:  oscar.OServiceClientVersions,
-						},
-						{
-							FoodGroup: oscar.OService,
-							SubGroup:  oscar.OServiceSetUserInfoFields,
-						},
-					},
-				},
-			},
-		},
-	}
-}
-
-// UserInfoQueryHandler returns SNAC oscar.OServiceUserInfoUpdate containing
-// the user's info.
-func (s OServiceService) UserInfoQueryHandler(_ context.Context, sess *state.Session, inFrame oscar.SNACFrame) oscar.SNACMessage {
-	return oscar.SNACMessage{
-		Frame: oscar.SNACFrame{
-			FoodGroup: oscar.OService,
-			SubGroup:  oscar.OServiceUserInfoUpdate,
-			RequestID: inFrame.RequestID,
-		},
-		Body: oscar.SNAC_0x01_0x0F_OServiceUserInfoUpdate{
-			TLVUserInfo: sess.TLVUserInfo(),
-		},
-	}
-}
-
-// SetUserInfoFieldsHandler sets the user's visibility status to visible or invisible.
-// The visibility status is set according to the inFrame TLV entry under key
-// oscar.OServiceUserInfoStatus. If the value is 0x0000, set invisible. If set
-// to 0x0100, set invisible. Else, return an error for any other value.
-// It returns SNAC oscar.OServiceUserInfoUpdate containing the user's info.
-func (s OServiceService) SetUserInfoFieldsHandler(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, inBody oscar.SNAC_0x01_0x1E_OServiceSetUserInfoFields) (oscar.SNACMessage, error) {
-	if status, hasStatus := inBody.Uint32(oscar.OServiceUserInfoStatus); hasStatus {
-		switch status {
-		case 0x0000:
-			sess.SetInvisible(false)
-			if err := broadcastArrival(ctx, sess, s.messageRelayer, s.feedbagManager); err != nil {
-				return oscar.SNACMessage{}, err
-			}
-		case 0x0100:
-			sess.SetInvisible(true)
-			if err := broadcastDeparture(ctx, sess, s.messageRelayer, s.feedbagManager); err != nil {
-				return oscar.SNACMessage{}, err
-			}
-		default:
-			return oscar.SNACMessage{}, fmt.Errorf("don't know what to do with status %d", status)
-		}
-	}
-	return oscar.SNACMessage{
-		Frame: oscar.SNACFrame{
-			FoodGroup: oscar.OService,
-			SubGroup:  oscar.OServiceUserInfoUpdate,
-			RequestID: inFrame.RequestID,
-		},
-		Body: oscar.SNAC_0x01_0x0F_OServiceUserInfoUpdate{
-			TLVUserInfo: sess.TLVUserInfo(),
-		},
-	}, nil
-}
-
-// IdleNotificationHandler sets the user idle time.
-// Set session idle time to the value of bodyIn.IdleTime. Return a user arrival
-// message to all users who have this user on their buddy list.
-func (s OServiceService) IdleNotificationHandler(ctx context.Context, sess *state.Session, bodyIn oscar.SNAC_0x01_0x11_OServiceIdleNotification) error {
-	if bodyIn.IdleTime == 0 {
-		sess.UnsetIdle()
-	} else {
-		sess.SetIdle(time.Duration(bodyIn.IdleTime) * time.Second)
-	}
-	return broadcastArrival(ctx, sess, s.messageRelayer, s.feedbagManager)
-}
-
-// RateParamsSubAddHandler exists to capture the SNAC input in unit tests to
-// verify it's correctly unmarshalled.
-func (s OServiceService) RateParamsSubAddHandler(context.Context, oscar.SNAC_0x01_0x08_OServiceRateParamsSubAdd) {
-}
-
-// NewOServiceServiceForBOS creates a new instance of OServiceServiceForBOS.
-func NewOServiceServiceForBOS(oserviceService OServiceService, cr *state.ChatRegistry) *OServiceServiceForBOS {
-	return &OServiceServiceForBOS{
-		OServiceService: oserviceService,
-		chatRegistry:    cr,
-	}
-}
-
-// OServiceServiceForBOS contains handlers for the OService food group for the
-// BOS service.
-type OServiceServiceForBOS struct {
-	OServiceService
-	chatRegistry *state.ChatRegistry
-}
-
-// ServiceRequestHandler configures food group settings for the current user.
-// This method only provides services for the Chat food group; return
-// server.ErrUnsupportedSubGroup for any other food group. When the chat food
-// group is specified in inFrame, add user to the chat room specified by TLV
-// 0x01.
-// It returns SNAC oscar.OServiceServiceResponse containing metadata the client
-// needs to connect to the chat service and join the chat room.
-func (s OServiceServiceForBOS) ServiceRequestHandler(_ context.Context, sess *state.Session, inFrame oscar.SNACFrame, inBody oscar.SNAC_0x01_0x04_OServiceServiceRequest) (oscar.SNACMessage, error) {
-	if inBody.FoodGroup != oscar.Chat {
-		return oscar.SNACMessage{}, server.ErrUnsupportedSubGroup
-	}
-
-	roomMeta, ok := inBody.Slice(0x01)
-	if !ok {
-		return oscar.SNACMessage{}, errors.New("missing room info")
-	}
-
-	roomSnac := oscar.SNAC_0x01_0x04_TLVRoomInfo{}
-	if err := oscar.Unmarshal(&roomSnac, bytes.NewBuffer(roomMeta)); err != nil {
-		return oscar.SNACMessage{}, err
-	}
-
-	room, chatSessMgr, err := s.chatRegistry.Retrieve(string(roomSnac.Cookie))
-	if err != nil {
-		return oscar.SNACMessage{}, err
-	}
-	chatSess := chatSessMgr.(SessionManager).AddSession(sess.ID(), sess.ScreenName())
-	chatSess.SetChatID(room.Cookie)
-
-	return oscar.SNACMessage{
-		Frame: oscar.SNACFrame{
-			FoodGroup: oscar.OService,
-			SubGroup:  oscar.OServiceServiceResponse,
-			RequestID: inFrame.RequestID,
-		},
-		Body: oscar.SNAC_0x01_0x05_OServiceServiceResponse{
-			TLVRestBlock: oscar.TLVRestBlock{
-				TLVList: oscar.TLVList{
-					oscar.NewTLV(oscar.OServiceTLVTagsReconnectHere, config.Address(s.cfg.OSCARHost, s.cfg.ChatPort)),
-					oscar.NewTLV(oscar.OServiceTLVTagsLoginCookie, server.ChatCookie{
-						Cookie: []byte(room.Cookie),
-						SessID: sess.ID(),
-					}),
-					oscar.NewTLV(oscar.OServiceTLVTagsGroupID, oscar.Chat),
-					oscar.NewTLV(oscar.OServiceTLVTagsSSLCertName, ""),
-					oscar.NewTLV(oscar.OServiceTLVTagsSSLState, uint8(0x00)),
-				},
-			},
-		},
-	}, nil
-}
-
-// WriteOServiceHostOnline initiates the BOS protocol sequence.
-// It returns SNAC oscar.OServiceHostOnline containing the list food groups
-// supported by the BOS service.
-func (s OServiceServiceForBOS) WriteOServiceHostOnline() oscar.SNACMessage {
-	return oscar.SNACMessage{
-		Frame: oscar.SNACFrame{
-			FoodGroup: oscar.OService,
-			SubGroup:  oscar.OServiceHostOnline,
-		},
-		Body: oscar.SNAC_0x01_0x03_OServiceHostOnline{
-			FoodGroups: []uint16{
-				oscar.Alert,
-				oscar.Buddy,
-				oscar.ChatNav,
-				oscar.Feedbag,
-				oscar.ICBM,
-				oscar.Locate,
-				oscar.OService,
-			},
-		},
-	}
-}
-
-// ClientOnlineHandler runs when the current user is ready to join.
-// It performs the following sequence of actions:
-//   - Announce current user's arrival to users who have the current user on
-//     their buddy list.
-//   - Send current user its buddy list
-func (s OServiceServiceForBOS) ClientOnlineHandler(ctx context.Context, _ oscar.SNAC_0x01_0x02_OServiceClientOnline, sess *state.Session) error {
-	if err := broadcastArrival(ctx, sess, s.messageRelayer, s.feedbagManager); err != nil {
-		return err
-	}
-	buddies, err := s.feedbagManager.Buddies(sess.ScreenName())
-	if err != nil {
-		return err
-	}
-	for _, screenName := range buddies {
-		buddy := s.messageRelayer.RetrieveByScreenName(screenName)
-		if buddy == nil || buddy.Invisible() {
-			continue
-		}
-		unicastArrival(ctx, buddy, sess, s.messageRelayer)
-	}
-	return nil
-}
-
-// NewOServiceServiceForChat creates a new instance of OServiceServiceForChat.
-func NewOServiceServiceForChat(oserviceService OServiceService, chatRegistry *state.ChatRegistry) *OServiceServiceForChat {
-	return &OServiceServiceForChat{
-		OServiceService: oserviceService,
-		chatRegistry:    chatRegistry,
-	}
-}
-
-// OServiceServiceForChat contains handlers for the OService food group for the
-// Chat service.
-type OServiceServiceForChat struct {
-	OServiceService
-	chatRegistry *state.ChatRegistry
-}
-
-// WriteOServiceHostOnline initiates the Chat protocol sequence.
-// It returns SNAC oscar.OServiceHostOnline containing the list of food groups
-// supported by the Chat service.
-func (s OServiceServiceForChat) WriteOServiceHostOnline() oscar.SNACMessage {
-	return oscar.SNACMessage{
-		Frame: oscar.SNACFrame{
-			FoodGroup: oscar.OService,
-			SubGroup:  oscar.OServiceHostOnline,
-		},
-		Body: oscar.SNAC_0x01_0x03_OServiceHostOnline{
-			FoodGroups: []uint16{
-				oscar.OService,
-				oscar.Chat,
-			},
-		},
-	}
-}
-
-// ClientOnlineHandler runs when the current user is ready to join the chat.
-// Trigger the following actions:
-//   - Send current user the chat room metadata
-//   - Announce current user's arrival to other chat room participants
-//   - Send current user the chat room participant list
-func (s OServiceServiceForChat) ClientOnlineHandler(ctx context.Context, sess *state.Session) error {
-	room, chatSessMgr, err := s.chatRegistry.Retrieve(sess.ChatID())
-	if err != nil {
-		return err
-	}
-	sendChatRoomInfoUpdate(ctx, sess, chatSessMgr.(ChatMessageRelayer), room)
-	alertUserJoined(ctx, sess, chatSessMgr.(ChatMessageRelayer))
-	setOnlineChatUsers(ctx, sess, chatSessMgr.(ChatMessageRelayer))
-	return nil
-}

+ 0 - 54
handler/types.go

@@ -1,54 +0,0 @@
-package handler
-
-import (
-	"context"
-	"time"
-
-	"github.com/mk6i/retro-aim-server/oscar"
-	"github.com/mk6i/retro-aim-server/state"
-)
-
-type FeedbagManager interface {
-	BlockedState(screenName1, screenName2 string) (state.BlockedState, error)
-	Buddies(screenName string) ([]string, error)
-	FeedbagDelete(screenName string, items []oscar.FeedbagItem) error
-	AdjacentUsers(screenName string) ([]string, error)
-	FeedbagLastModified(screenName string) (time.Time, error)
-	Feedbag(screenName string) ([]oscar.FeedbagItem, error)
-	FeedbagUpsert(screenName string, items []oscar.FeedbagItem) error
-}
-
-type UserManager interface {
-	User(screenName string) (*state.User, error)
-	InsertUser(u state.User) error
-}
-
-type SessionManager interface {
-	Empty() bool
-	AddSession(sessID string, screenName string) *state.Session
-	RemoveSession(sess *state.Session)
-	RetrieveSession(ID string) *state.Session
-}
-
-type ProfileManager interface {
-	Profile(screenName string) (string, error)
-	SetProfile(screenName string, body string) error
-}
-
-type MessageRelayer interface {
-	RelayToScreenNames(ctx context.Context, screenNames []string, msg oscar.SNACMessage)
-	RetrieveByScreenName(screenName string) *state.Session
-	RelayToScreenName(ctx context.Context, screenName string, msg oscar.SNACMessage)
-}
-
-type ChatMessageRelayer interface {
-	MessageRelayer
-	RelayToAllExcept(ctx context.Context, except *state.Session, msg oscar.SNACMessage)
-	AllSessions() []*state.Session
-}
-
-type ChatRegistry interface {
-	Register(room state.ChatRoom, sessionManager any)
-	Retrieve(chatID string) (state.ChatRoom, any, error)
-	Remove(chatID string)
-}

+ 0 - 16
oscar/fixtures/blocked_user.sql

@@ -1,16 +0,0 @@
-INSERT INTO "feedbag"
-VALUES ('userA', 0, 0, 1, '', X'00C800026BEF', 1690508233);
-INSERT INTO "feedbag"
-VALUES ('userA', 0, 13852, 3, 'userB', NULL, 1691286176);
-INSERT INTO "feedbag"
-VALUES ('userA', 0, 31420, 4, '', X'00CA000104', 1691286176);
-INSERT INTO "feedbag"
-VALUES ('userA', 27631, 0, 1, 'Friends!', X'00C800020FB0', 1691286169);
-INSERT INTO "feedbag"
-VALUES ('userA', 27631, 4016, 0, 'userB', NULL, 1690508233);
-INSERT INTO "feedbag"
-VALUES ('userB', 0, 0, 1, '', X'00C800026EAA', 1690508298);
-INSERT INTO "feedbag"
-VALUES ('userB', 28330, 0, 1, 'Enemies', X'00C800021FB8', 1691180328);
-INSERT INTO "feedbag"
-VALUES ('userB', 28330, 8120, 0, 'userA', NULL, 1691180328);

+ 0 - 36
server/alert.go

@@ -1,36 +0,0 @@
-package server
-
-import (
-	"context"
-	"io"
-	"log/slog"
-
-	"github.com/mk6i/retro-aim-server/oscar"
-	"github.com/mk6i/retro-aim-server/state"
-)
-
-func NewAlertRouter(logger *slog.Logger) AlertRouter {
-	return AlertRouter{
-		routeLogger: routeLogger{
-			Logger: logger,
-		},
-	}
-}
-
-type AlertRouter struct {
-	routeLogger
-}
-
-func (rt AlertRouter) Route(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, r io.Reader, w io.Writer, sequence *uint32) error {
-	switch inFrame.SubGroup {
-	case oscar.AlertNotifyCapabilities:
-		fallthrough
-	case oscar.AlertNotifyDisplayCapabilities:
-		// just read the request to placate the client. no need to send a
-		// response.
-		rt.logRequest(ctx, inFrame, nil)
-		return nil
-	default:
-		return ErrUnsupportedSubGroup
-	}
-}

+ 0 - 105
server/alert_test.go

@@ -1,105 +0,0 @@
-package server
-
-import (
-	"bytes"
-	"context"
-	"testing"
-
-	"github.com/mk6i/retro-aim-server/config"
-	"github.com/mk6i/retro-aim-server/oscar"
-	"github.com/stretchr/testify/assert"
-)
-
-func TestAlertRouter_RouteAlert(t *testing.T) {
-	cases := []struct {
-		// name is the unit test name
-		name string
-		// input is the request payload
-		input oscar.SNACMessage
-		// output is the response payload
-		output oscar.SNACMessage
-		// handlerErr is the mocked handler error response
-		handlerErr error
-		// expectErr is the expected error returned by the router
-		expectErr error
-	}{
-		{
-			name: "receive AlertNotifyCapabilities, return no response",
-			input: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.Alert,
-					SubGroup:  oscar.AlertNotifyCapabilities,
-				},
-				Body: oscar.SNACFrame{},
-			},
-			output: oscar.SNACMessage{},
-		},
-		{
-			name: "receive AlertNotifyDisplayCapabilities, return no response",
-			input: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.Alert,
-					SubGroup:  oscar.AlertNotifyDisplayCapabilities,
-				},
-				Body: oscar.SNACFrame{},
-			},
-			output: oscar.SNACMessage{},
-		},
-		{
-			name: "receive AlertGetSubsRequest, expect ErrUnsupportedSubGroup",
-			input: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.Alert,
-					SubGroup:  oscar.AlertGetSubsRequest,
-				},
-				Body: struct{}{}, // empty SNAC
-			},
-			output:    oscar.SNACMessage{}, // empty SNAC
-			expectErr: ErrUnsupportedSubGroup,
-		},
-	}
-
-	for _, tc := range cases {
-		t.Run(tc.name, func(t *testing.T) {
-			router := NewAlertRouter(NewLogger(config.Config{}))
-
-			bufIn := &bytes.Buffer{}
-			assert.NoError(t, oscar.Marshal(tc.input.Body, bufIn))
-
-			bufOut := &bytes.Buffer{}
-			seq := uint32(1)
-
-			err := router.Route(context.Background(), nil, tc.input.Frame, nil, nil, nil)
-			assert.ErrorIs(t, err, tc.expectErr)
-			if tc.expectErr != nil {
-				return
-			}
-
-			if tc.output == (oscar.SNACMessage{}) {
-				// make sure no response was sent
-				assert.Empty(t, bufOut.Bytes())
-				return
-			}
-
-			// verify the FLAP frame
-			flap := oscar.FLAPFrame{}
-			assert.NoError(t, oscar.Unmarshal(&flap, bufOut))
-
-			// make sure the sequence number was incremented
-			assert.Equal(t, uint32(2), seq)
-
-			flapBuf, err := flap.ReadBody(bufOut)
-			assert.NoError(t, err)
-
-			// verify the SNAC frame
-			snacFrame := oscar.SNACFrame{}
-			assert.NoError(t, oscar.Unmarshal(&snacFrame, flapBuf))
-			assert.Equal(t, tc.output.Frame, snacFrame)
-
-			// verify the SNAC message
-			snacBuf := &bytes.Buffer{}
-			assert.NoError(t, oscar.Marshal(tc.output.Body, snacBuf))
-			assert.Equal(t, snacBuf.Bytes(), flapBuf.Bytes())
-		})
-	}
-}

+ 0 - 368
server/auth_mock_test.go

@@ -1,368 +0,0 @@
-// Code generated by mockery v2.38.0. DO NOT EDIT.
-
-package server
-
-import (
-	context "context"
-
-	oscar "github.com/mk6i/retro-aim-server/oscar"
-	mock "github.com/stretchr/testify/mock"
-
-	state "github.com/mk6i/retro-aim-server/state"
-
-	uuid "github.com/google/uuid"
-)
-
-// mockAuthHandler is an autogenerated mock type for the AuthHandler type
-type mockAuthHandler struct {
-	mock.Mock
-}
-
-type mockAuthHandler_Expecter struct {
-	mock *mock.Mock
-}
-
-func (_m *mockAuthHandler) EXPECT() *mockAuthHandler_Expecter {
-	return &mockAuthHandler_Expecter{mock: &_m.Mock}
-}
-
-// BUCPChallengeRequestHandler provides a mock function with given fields: bodyIn, newUUID
-func (_m *mockAuthHandler) BUCPChallengeRequestHandler(bodyIn oscar.SNAC_0x17_0x06_BUCPChallengeRequest, newUUID func() uuid.UUID) (oscar.SNACMessage, error) {
-	ret := _m.Called(bodyIn, newUUID)
-
-	if len(ret) == 0 {
-		panic("no return value specified for BUCPChallengeRequestHandler")
-	}
-
-	var r0 oscar.SNACMessage
-	var r1 error
-	if rf, ok := ret.Get(0).(func(oscar.SNAC_0x17_0x06_BUCPChallengeRequest, func() uuid.UUID) (oscar.SNACMessage, error)); ok {
-		return rf(bodyIn, newUUID)
-	}
-	if rf, ok := ret.Get(0).(func(oscar.SNAC_0x17_0x06_BUCPChallengeRequest, func() uuid.UUID) oscar.SNACMessage); ok {
-		r0 = rf(bodyIn, newUUID)
-	} else {
-		r0 = ret.Get(0).(oscar.SNACMessage)
-	}
-
-	if rf, ok := ret.Get(1).(func(oscar.SNAC_0x17_0x06_BUCPChallengeRequest, func() uuid.UUID) error); ok {
-		r1 = rf(bodyIn, newUUID)
-	} else {
-		r1 = ret.Error(1)
-	}
-
-	return r0, r1
-}
-
-// mockAuthHandler_BUCPChallengeRequestHandler_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BUCPChallengeRequestHandler'
-type mockAuthHandler_BUCPChallengeRequestHandler_Call struct {
-	*mock.Call
-}
-
-// BUCPChallengeRequestHandler is a helper method to define mock.On call
-//   - bodyIn oscar.SNAC_0x17_0x06_BUCPChallengeRequest
-//   - newUUID func() uuid.UUID
-func (_e *mockAuthHandler_Expecter) BUCPChallengeRequestHandler(bodyIn interface{}, newUUID interface{}) *mockAuthHandler_BUCPChallengeRequestHandler_Call {
-	return &mockAuthHandler_BUCPChallengeRequestHandler_Call{Call: _e.mock.On("BUCPChallengeRequestHandler", bodyIn, newUUID)}
-}
-
-func (_c *mockAuthHandler_BUCPChallengeRequestHandler_Call) Run(run func(bodyIn oscar.SNAC_0x17_0x06_BUCPChallengeRequest, newUUID func() uuid.UUID)) *mockAuthHandler_BUCPChallengeRequestHandler_Call {
-	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(oscar.SNAC_0x17_0x06_BUCPChallengeRequest), args[1].(func() uuid.UUID))
-	})
-	return _c
-}
-
-func (_c *mockAuthHandler_BUCPChallengeRequestHandler_Call) Return(_a0 oscar.SNACMessage, _a1 error) *mockAuthHandler_BUCPChallengeRequestHandler_Call {
-	_c.Call.Return(_a0, _a1)
-	return _c
-}
-
-func (_c *mockAuthHandler_BUCPChallengeRequestHandler_Call) RunAndReturn(run func(oscar.SNAC_0x17_0x06_BUCPChallengeRequest, func() uuid.UUID) (oscar.SNACMessage, error)) *mockAuthHandler_BUCPChallengeRequestHandler_Call {
-	_c.Call.Return(run)
-	return _c
-}
-
-// BUCPLoginRequestHandler provides a mock function with given fields: bodyIn, newUUID, fn
-func (_m *mockAuthHandler) BUCPLoginRequestHandler(bodyIn oscar.SNAC_0x17_0x02_BUCPLoginRequest, newUUID func() uuid.UUID, fn func(string) (state.User, error)) (oscar.SNACMessage, error) {
-	ret := _m.Called(bodyIn, newUUID, fn)
-
-	if len(ret) == 0 {
-		panic("no return value specified for BUCPLoginRequestHandler")
-	}
-
-	var r0 oscar.SNACMessage
-	var r1 error
-	if rf, ok := ret.Get(0).(func(oscar.SNAC_0x17_0x02_BUCPLoginRequest, func() uuid.UUID, func(string) (state.User, error)) (oscar.SNACMessage, error)); ok {
-		return rf(bodyIn, newUUID, fn)
-	}
-	if rf, ok := ret.Get(0).(func(oscar.SNAC_0x17_0x02_BUCPLoginRequest, func() uuid.UUID, func(string) (state.User, error)) oscar.SNACMessage); ok {
-		r0 = rf(bodyIn, newUUID, fn)
-	} else {
-		r0 = ret.Get(0).(oscar.SNACMessage)
-	}
-
-	if rf, ok := ret.Get(1).(func(oscar.SNAC_0x17_0x02_BUCPLoginRequest, func() uuid.UUID, func(string) (state.User, error)) error); ok {
-		r1 = rf(bodyIn, newUUID, fn)
-	} else {
-		r1 = ret.Error(1)
-	}
-
-	return r0, r1
-}
-
-// mockAuthHandler_BUCPLoginRequestHandler_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BUCPLoginRequestHandler'
-type mockAuthHandler_BUCPLoginRequestHandler_Call struct {
-	*mock.Call
-}
-
-// BUCPLoginRequestHandler is a helper method to define mock.On call
-//   - bodyIn oscar.SNAC_0x17_0x02_BUCPLoginRequest
-//   - newUUID func() uuid.UUID
-//   - fn func(string)(state.User , error)
-func (_e *mockAuthHandler_Expecter) BUCPLoginRequestHandler(bodyIn interface{}, newUUID interface{}, fn interface{}) *mockAuthHandler_BUCPLoginRequestHandler_Call {
-	return &mockAuthHandler_BUCPLoginRequestHandler_Call{Call: _e.mock.On("BUCPLoginRequestHandler", bodyIn, newUUID, fn)}
-}
-
-func (_c *mockAuthHandler_BUCPLoginRequestHandler_Call) Run(run func(bodyIn oscar.SNAC_0x17_0x02_BUCPLoginRequest, newUUID func() uuid.UUID, fn func(string) (state.User, error))) *mockAuthHandler_BUCPLoginRequestHandler_Call {
-	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(oscar.SNAC_0x17_0x02_BUCPLoginRequest), args[1].(func() uuid.UUID), args[2].(func(string) (state.User, error)))
-	})
-	return _c
-}
-
-func (_c *mockAuthHandler_BUCPLoginRequestHandler_Call) Return(_a0 oscar.SNACMessage, _a1 error) *mockAuthHandler_BUCPLoginRequestHandler_Call {
-	_c.Call.Return(_a0, _a1)
-	return _c
-}
-
-func (_c *mockAuthHandler_BUCPLoginRequestHandler_Call) RunAndReturn(run func(oscar.SNAC_0x17_0x02_BUCPLoginRequest, func() uuid.UUID, func(string) (state.User, error)) (oscar.SNACMessage, error)) *mockAuthHandler_BUCPLoginRequestHandler_Call {
-	_c.Call.Return(run)
-	return _c
-}
-
-// RetrieveBOSSession provides a mock function with given fields: sessionID
-func (_m *mockAuthHandler) RetrieveBOSSession(sessionID string) (*state.Session, error) {
-	ret := _m.Called(sessionID)
-
-	if len(ret) == 0 {
-		panic("no return value specified for RetrieveBOSSession")
-	}
-
-	var r0 *state.Session
-	var r1 error
-	if rf, ok := ret.Get(0).(func(string) (*state.Session, error)); ok {
-		return rf(sessionID)
-	}
-	if rf, ok := ret.Get(0).(func(string) *state.Session); ok {
-		r0 = rf(sessionID)
-	} else {
-		if ret.Get(0) != nil {
-			r0 = ret.Get(0).(*state.Session)
-		}
-	}
-
-	if rf, ok := ret.Get(1).(func(string) error); ok {
-		r1 = rf(sessionID)
-	} else {
-		r1 = ret.Error(1)
-	}
-
-	return r0, r1
-}
-
-// mockAuthHandler_RetrieveBOSSession_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RetrieveBOSSession'
-type mockAuthHandler_RetrieveBOSSession_Call struct {
-	*mock.Call
-}
-
-// RetrieveBOSSession is a helper method to define mock.On call
-//   - sessionID string
-func (_e *mockAuthHandler_Expecter) RetrieveBOSSession(sessionID interface{}) *mockAuthHandler_RetrieveBOSSession_Call {
-	return &mockAuthHandler_RetrieveBOSSession_Call{Call: _e.mock.On("RetrieveBOSSession", sessionID)}
-}
-
-func (_c *mockAuthHandler_RetrieveBOSSession_Call) Run(run func(sessionID string)) *mockAuthHandler_RetrieveBOSSession_Call {
-	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(string))
-	})
-	return _c
-}
-
-func (_c *mockAuthHandler_RetrieveBOSSession_Call) Return(_a0 *state.Session, _a1 error) *mockAuthHandler_RetrieveBOSSession_Call {
-	_c.Call.Return(_a0, _a1)
-	return _c
-}
-
-func (_c *mockAuthHandler_RetrieveBOSSession_Call) RunAndReturn(run func(string) (*state.Session, error)) *mockAuthHandler_RetrieveBOSSession_Call {
-	_c.Call.Return(run)
-	return _c
-}
-
-// RetrieveChatSession provides a mock function with given fields: chatID, sessionID
-func (_m *mockAuthHandler) RetrieveChatSession(chatID string, sessionID string) (*state.Session, error) {
-	ret := _m.Called(chatID, sessionID)
-
-	if len(ret) == 0 {
-		panic("no return value specified for RetrieveChatSession")
-	}
-
-	var r0 *state.Session
-	var r1 error
-	if rf, ok := ret.Get(0).(func(string, string) (*state.Session, error)); ok {
-		return rf(chatID, sessionID)
-	}
-	if rf, ok := ret.Get(0).(func(string, string) *state.Session); ok {
-		r0 = rf(chatID, sessionID)
-	} else {
-		if ret.Get(0) != nil {
-			r0 = ret.Get(0).(*state.Session)
-		}
-	}
-
-	if rf, ok := ret.Get(1).(func(string, string) error); ok {
-		r1 = rf(chatID, sessionID)
-	} else {
-		r1 = ret.Error(1)
-	}
-
-	return r0, r1
-}
-
-// mockAuthHandler_RetrieveChatSession_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RetrieveChatSession'
-type mockAuthHandler_RetrieveChatSession_Call struct {
-	*mock.Call
-}
-
-// RetrieveChatSession is a helper method to define mock.On call
-//   - chatID string
-//   - sessionID string
-func (_e *mockAuthHandler_Expecter) RetrieveChatSession(chatID interface{}, sessionID interface{}) *mockAuthHandler_RetrieveChatSession_Call {
-	return &mockAuthHandler_RetrieveChatSession_Call{Call: _e.mock.On("RetrieveChatSession", chatID, sessionID)}
-}
-
-func (_c *mockAuthHandler_RetrieveChatSession_Call) Run(run func(chatID string, sessionID string)) *mockAuthHandler_RetrieveChatSession_Call {
-	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(string), args[1].(string))
-	})
-	return _c
-}
-
-func (_c *mockAuthHandler_RetrieveChatSession_Call) Return(_a0 *state.Session, _a1 error) *mockAuthHandler_RetrieveChatSession_Call {
-	_c.Call.Return(_a0, _a1)
-	return _c
-}
-
-func (_c *mockAuthHandler_RetrieveChatSession_Call) RunAndReturn(run func(string, string) (*state.Session, error)) *mockAuthHandler_RetrieveChatSession_Call {
-	_c.Call.Return(run)
-	return _c
-}
-
-// Signout provides a mock function with given fields: ctx, sess
-func (_m *mockAuthHandler) Signout(ctx context.Context, sess *state.Session) error {
-	ret := _m.Called(ctx, sess)
-
-	if len(ret) == 0 {
-		panic("no return value specified for Signout")
-	}
-
-	var r0 error
-	if rf, ok := ret.Get(0).(func(context.Context, *state.Session) error); ok {
-		r0 = rf(ctx, sess)
-	} else {
-		r0 = ret.Error(0)
-	}
-
-	return r0
-}
-
-// mockAuthHandler_Signout_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Signout'
-type mockAuthHandler_Signout_Call struct {
-	*mock.Call
-}
-
-// Signout is a helper method to define mock.On call
-//   - ctx context.Context
-//   - sess *state.Session
-func (_e *mockAuthHandler_Expecter) Signout(ctx interface{}, sess interface{}) *mockAuthHandler_Signout_Call {
-	return &mockAuthHandler_Signout_Call{Call: _e.mock.On("Signout", ctx, sess)}
-}
-
-func (_c *mockAuthHandler_Signout_Call) Run(run func(ctx context.Context, sess *state.Session)) *mockAuthHandler_Signout_Call {
-	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(context.Context), args[1].(*state.Session))
-	})
-	return _c
-}
-
-func (_c *mockAuthHandler_Signout_Call) Return(_a0 error) *mockAuthHandler_Signout_Call {
-	_c.Call.Return(_a0)
-	return _c
-}
-
-func (_c *mockAuthHandler_Signout_Call) RunAndReturn(run func(context.Context, *state.Session) error) *mockAuthHandler_Signout_Call {
-	_c.Call.Return(run)
-	return _c
-}
-
-// SignoutChat provides a mock function with given fields: ctx, sess, chatID
-func (_m *mockAuthHandler) SignoutChat(ctx context.Context, sess *state.Session, chatID string) error {
-	ret := _m.Called(ctx, sess, chatID)
-
-	if len(ret) == 0 {
-		panic("no return value specified for SignoutChat")
-	}
-
-	var r0 error
-	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, string) error); ok {
-		r0 = rf(ctx, sess, chatID)
-	} else {
-		r0 = ret.Error(0)
-	}
-
-	return r0
-}
-
-// mockAuthHandler_SignoutChat_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SignoutChat'
-type mockAuthHandler_SignoutChat_Call struct {
-	*mock.Call
-}
-
-// SignoutChat is a helper method to define mock.On call
-//   - ctx context.Context
-//   - sess *state.Session
-//   - chatID string
-func (_e *mockAuthHandler_Expecter) SignoutChat(ctx interface{}, sess interface{}, chatID interface{}) *mockAuthHandler_SignoutChat_Call {
-	return &mockAuthHandler_SignoutChat_Call{Call: _e.mock.On("SignoutChat", ctx, sess, chatID)}
-}
-
-func (_c *mockAuthHandler_SignoutChat_Call) Run(run func(ctx context.Context, sess *state.Session, chatID string)) *mockAuthHandler_SignoutChat_Call {
-	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(context.Context), args[1].(*state.Session), args[2].(string))
-	})
-	return _c
-}
-
-func (_c *mockAuthHandler_SignoutChat_Call) Return(_a0 error) *mockAuthHandler_SignoutChat_Call {
-	_c.Call.Return(_a0)
-	return _c
-}
-
-func (_c *mockAuthHandler_SignoutChat_Call) RunAndReturn(run func(context.Context, *state.Session, string) error) *mockAuthHandler_SignoutChat_Call {
-	_c.Call.Return(run)
-	return _c
-}
-
-// newMockAuthHandler creates a new instance of mockAuthHandler. 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 newMockAuthHandler(t interface {
-	mock.TestingT
-	Cleanup(func())
-}) *mockAuthHandler {
-	mock := &mockAuthHandler{}
-	mock.Mock.Test(t)
-
-	t.Cleanup(func() { mock.AssertExpectations(t) })
-
-	return mock
-}

+ 0 - 50
server/bos_router.go

@@ -1,50 +0,0 @@
-package server
-
-import (
-	"context"
-	"io"
-
-	"github.com/mk6i/retro-aim-server/oscar"
-	"github.com/mk6i/retro-aim-server/state"
-)
-
-// BOSRouter routes client connections to the OSCAR food group routers.
-type BOSRouter struct {
-	AlertRouter       Router
-	BuddyRouter       Router
-	ChatNavRouter     Router
-	FeedbagRouter     Router
-	ICBMRouter        Router
-	LocateRouter      Router
-	OServiceBOSRouter Router
-}
-
-// Route routes connections to the following food groups:
-// - Alert
-// - BUCP
-// - Buddy
-// - ChatNav
-// - Feedbag
-// - ICBM
-// - Locate
-// - OService
-func (rt BOSRouter) Route(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, r io.Reader, w io.Writer, sequence *uint32) error {
-	switch inFrame.FoodGroup {
-	case oscar.OService:
-		return rt.OServiceBOSRouter.Route(ctx, sess, inFrame, r, w, sequence)
-	case oscar.Locate:
-		return rt.LocateRouter.Route(ctx, sess, inFrame, r, w, sequence)
-	case oscar.Buddy:
-		return rt.BuddyRouter.Route(ctx, sess, inFrame, r, w, sequence)
-	case oscar.ICBM:
-		return rt.ICBMRouter.Route(ctx, sess, inFrame, r, w, sequence)
-	case oscar.ChatNav:
-		return rt.ChatNavRouter.Route(ctx, sess, inFrame, r, w, sequence)
-	case oscar.Feedbag:
-		return rt.FeedbagRouter.Route(ctx, sess, inFrame, r, w, sequence)
-	case oscar.Alert:
-		return rt.AlertRouter.Route(ctx, sess, inFrame, r, w, sequence)
-	default:
-		return ErrUnsupportedSubGroup
-	}
-}

+ 0 - 151
server/bos_router_test.go

@@ -1,151 +0,0 @@
-package server
-
-import (
-	"io"
-	"testing"
-
-	"github.com/mk6i/retro-aim-server/oscar"
-	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/mock"
-)
-
-func TestBOSRouter_Route(t *testing.T) {
-	tests := []struct {
-		name      string
-		frame     oscar.SNACFrame
-		newRouter func(t *testing.T) BOSRouter
-		err       error
-	}{
-		{
-			name: "OService no error",
-			frame: oscar.SNACFrame{
-				FoodGroup: oscar.OService,
-			},
-		},
-		{
-			name: "Locate no error",
-			frame: oscar.SNACFrame{
-				FoodGroup: oscar.Locate,
-			},
-		},
-		{
-			name: "Buddy no error",
-			frame: oscar.SNACFrame{
-				FoodGroup: oscar.Buddy,
-			},
-		},
-		{
-			name: "ICBM no error",
-			frame: oscar.SNACFrame{
-				FoodGroup: oscar.ICBM,
-			},
-		},
-		{
-			name: "ChatNav no error",
-			frame: oscar.SNACFrame{
-				FoodGroup: oscar.ChatNav,
-			},
-		},
-		{
-			name: "Feedbag no error",
-			frame: oscar.SNACFrame{
-				FoodGroup: oscar.Feedbag,
-			},
-		},
-		{
-			name: "Alert no error",
-			frame: oscar.SNACFrame{
-				FoodGroup: oscar.Alert,
-			},
-		},
-		{
-			name: "OService with error",
-			frame: oscar.SNACFrame{
-				FoodGroup: oscar.OService,
-			},
-			err: io.EOF,
-		},
-		{
-			name: "Locate with error",
-			frame: oscar.SNACFrame{
-				FoodGroup: oscar.Locate,
-			},
-			err: io.EOF,
-		},
-		{
-			name: "Buddy with error",
-			frame: oscar.SNACFrame{
-				FoodGroup: oscar.Buddy,
-			},
-			err: io.EOF,
-		},
-		{
-			name: "ICBM with error",
-			frame: oscar.SNACFrame{
-				FoodGroup: oscar.ICBM,
-			},
-			err: io.EOF,
-		},
-		{
-			name: "ChatNav with error",
-			frame: oscar.SNACFrame{
-				FoodGroup: oscar.ChatNav,
-			},
-			err: io.EOF,
-		},
-		{
-			name: "Feedbag with error",
-			frame: oscar.SNACFrame{
-				FoodGroup: oscar.Feedbag,
-			},
-			err: io.EOF,
-		},
-		{
-			name: "Alert with error",
-			frame: oscar.SNACFrame{
-				FoodGroup: oscar.Alert,
-			},
-			err: io.EOF,
-		},
-		{
-			name: "Chat (unsupported route), expect error",
-			frame: oscar.SNACFrame{
-				FoodGroup: oscar.Chat,
-			},
-			err: ErrUnsupportedSubGroup,
-		},
-	}
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			fnNewRouter := func() Router {
-				fgRouter := newMockRouter(t)
-				fgRouter.EXPECT().
-					Route(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
-					Return(tt.err)
-				return fgRouter
-			}
-
-			router := BOSRouter{}
-
-			switch tt.frame.FoodGroup {
-			case oscar.OService:
-				router.OServiceBOSRouter = fnNewRouter()
-			case oscar.Locate:
-				router.LocateRouter = fnNewRouter()
-			case oscar.Buddy:
-				router.BuddyRouter = fnNewRouter()
-			case oscar.ICBM:
-				router.ICBMRouter = fnNewRouter()
-			case oscar.ChatNav:
-				router.ChatNavRouter = fnNewRouter()
-			case oscar.Feedbag:
-				router.FeedbagRouter = fnNewRouter()
-			case oscar.Alert:
-				router.AlertRouter = fnNewRouter()
-			}
-
-			err := router.Route(nil, nil, tt.frame, nil, nil, nil)
-			assert.ErrorIs(t, err, tt.err)
-		})
-	}
-}

+ 0 - 124
server/bos_service_test.go

@@ -1,124 +0,0 @@
-package server
-
-import (
-	"bytes"
-	"context"
-	"io"
-	"log/slog"
-	"testing"
-
-	"github.com/mk6i/retro-aim-server/state"
-	"github.com/stretchr/testify/mock"
-
-	"github.com/mk6i/retro-aim-server/oscar"
-	"github.com/stretchr/testify/assert"
-)
-
-// pipeRWC provides a mock for ReadWriteCloser that uses pipes instead of TCP
-// connections
-type pipeRWC struct {
-	*io.PipeReader
-	*io.PipeWriter
-}
-
-func (m pipeRWC) Close() error {
-	if err := m.PipeReader.Close(); err != nil {
-		return err
-	}
-	return m.PipeWriter.Close()
-}
-
-func TestBOSService_handleNewConnection(t *testing.T) {
-	sess := state.NewSession()
-	sess.SetID("login-cookie-1234")
-
-	clientReader, serverWriter := io.Pipe()
-	serverReader, clientWriter := io.Pipe()
-
-	go func() {
-		// < receive FLAPSignonFrame
-		flap := oscar.FLAPFrame{}
-		assert.NoError(t, oscar.Unmarshal(&flap, serverReader))
-		buf, err := flap.ReadBody(serverReader)
-		assert.NoError(t, err)
-		flapSignonFrame := oscar.FLAPSignonFrame{}
-		assert.NoError(t, oscar.Unmarshal(&flapSignonFrame, buf))
-
-		// > send FLAPSignonFrame
-		flapSignonFrame = oscar.FLAPSignonFrame{
-			FLAPVersion: 1,
-		}
-		flapSignonFrame.Append(oscar.NewTLV(oscar.OServiceTLVTagsLoginCookie, []byte(sess.ID())))
-		buf = &bytes.Buffer{}
-		assert.NoError(t, oscar.Marshal(flapSignonFrame, buf))
-		flap = oscar.FLAPFrame{
-			StartMarker:   42,
-			FrameType:     oscar.FLAPFrameSignon,
-			PayloadLength: uint16(buf.Len()),
-		}
-		assert.NoError(t, oscar.Marshal(flap, serverWriter))
-		_, err = serverWriter.Write(buf.Bytes())
-		assert.NoError(t, err)
-
-		// < receive SNAC_0x01_0x03_OServiceHostOnline
-		flap = oscar.FLAPFrame{}
-		assert.NoError(t, oscar.Unmarshal(&flap, serverReader))
-		buf, err = flap.ReadBody(serverReader)
-		assert.NoError(t, err)
-		frame := oscar.SNACFrame{}
-		assert.NoError(t, oscar.Unmarshal(&frame, buf))
-		body := oscar.SNAC_0x01_0x03_OServiceHostOnline{}
-		assert.NoError(t, oscar.Unmarshal(&body, buf))
-
-		// send the first request that should get relayed to BOSRouter.Route
-		var seq uint32
-		frame = oscar.SNACFrame{
-			FoodGroup: oscar.OService,
-			SubGroup:  oscar.OServiceClientOnline,
-		}
-		assert.NoError(t, sendSNAC(frame, struct{}{}, &seq, serverWriter))
-
-		assert.NoError(t, serverWriter.Close())
-	}()
-
-	authHandler := newMockAuthHandler(t)
-	authHandler.EXPECT().
-		RetrieveBOSSession(sess.ID()).
-		Return(sess, nil)
-	authHandler.EXPECT().
-		Signout(mock.Anything, sess).
-		Return(nil)
-
-	bosHandler := newMockOServiceBOSHandler(t)
-	bosHandler.EXPECT().
-		WriteOServiceHostOnline().
-		Return(oscar.SNACMessage{
-			Frame: oscar.SNACFrame{
-				FoodGroup: oscar.OService,
-				SubGroup:  oscar.OServiceHostOnline,
-			},
-			Body: oscar.SNAC_0x01_0x03_OServiceHostOnline{},
-		})
-
-	router := newMockRouter(t)
-	router.EXPECT().
-		Route(mock.Anything, sess, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
-		Run(func(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, r io.Reader, w io.Writer, sequence *uint32) {
-			assert.Equal(t, oscar.SNACFrame{
-				FoodGroup: oscar.OService,
-				SubGroup:  oscar.OServiceClientOnline,
-			}, inFrame)
-		}).Return(nil)
-
-	rt := BOSService{
-		AuthHandler:        authHandler,
-		OServiceBOSHandler: bosHandler,
-		Router:             router,
-		Logger:             slog.Default(),
-	}
-	rwc := pipeRWC{
-		PipeReader: clientReader,
-		PipeWriter: clientWriter,
-	}
-	assert.NoError(t, rt.handleNewConnection(context.Background(), rwc))
-}

+ 0 - 130
server/bucp.go

@@ -1,130 +0,0 @@
-package server
-
-import (
-	"context"
-	"io"
-	"log/slog"
-	"net"
-	"os"
-
-	"github.com/google/uuid"
-	"github.com/mk6i/retro-aim-server/config"
-	"github.com/mk6i/retro-aim-server/oscar"
-	"github.com/mk6i/retro-aim-server/state"
-)
-
-type AuthHandler interface {
-	BUCPChallengeRequestHandler(bodyIn oscar.SNAC_0x17_0x06_BUCPChallengeRequest, newUUID func() uuid.UUID) (oscar.SNACMessage, error)
-	BUCPLoginRequestHandler(bodyIn oscar.SNAC_0x17_0x02_BUCPLoginRequest, newUUID func() uuid.UUID, fn func(screenName string) (state.User, error)) (oscar.SNACMessage, error)
-	RetrieveChatSession(chatID string, sessionID string) (*state.Session, error)
-	RetrieveBOSSession(sessionID string) (*state.Session, error)
-	Signout(ctx context.Context, sess *state.Session) error
-	SignoutChat(ctx context.Context, sess *state.Session, chatID string) error
-}
-
-// BUCPAuthService represents a service that implements the OSCAR BUCP. This is
-// the first service that the AIM client connects to in the login flow.
-type BUCPAuthService struct {
-	AuthHandler
-	config.Config
-	Logger *slog.Logger
-}
-
-// Start creates a TCP server that implements the BUCP authentication flow. It
-// validates users credentials and, upon success, provides an auth cookie and
-// hostname information for connecting to the BOS service.
-func (rt BUCPAuthService) Start() {
-	addr := config.Address("", rt.Config.BUCPPort)
-	listener, err := net.Listen("tcp", addr)
-	if err != nil {
-		rt.Logger.Error("unable to bind OSCAR server address", "err", err.Error())
-		os.Exit(1)
-	}
-	defer listener.Close()
-
-	rt.Logger.Info("starting auth service", "addr", addr)
-
-	for {
-		conn, err := listener.Accept()
-		if err != nil {
-			rt.Logger.Error(err.Error())
-			continue
-		}
-
-		go func() {
-			if err := rt.handleNewConnection(conn); err != nil {
-				rt.Logger.Info("user session failed", "err", err.Error())
-			}
-		}()
-	}
-}
-
-func (rt BUCPAuthService) handleNewConnection(rwc io.ReadWriteCloser) error {
-	defer rwc.Close()
-	seq := uint32(100)
-	if _, err := flapSignonHandshake(rwc, &seq); err != nil {
-		return err
-	}
-
-	challengeRequest := oscar.SNAC_0x17_0x06_BUCPChallengeRequest{}
-	if err := receiveSNAC(&oscar.SNACFrame{}, &challengeRequest, rwc); err != nil {
-		return err
-	}
-
-	outSNAC, err := rt.BUCPChallengeRequestHandler(challengeRequest, uuid.New)
-	if err != nil {
-		return err
-	}
-	if err := sendSNAC(outSNAC.Frame, outSNAC.Body, &seq, rwc); err != nil {
-		return err
-	}
-
-	loginRequest := oscar.SNAC_0x17_0x02_BUCPLoginRequest{}
-	if err := receiveSNAC(&oscar.SNACFrame{}, &loginRequest, rwc); err != nil {
-		return err
-	}
-
-	outSNAC, err = rt.BUCPLoginRequestHandler(loginRequest, uuid.New, state.NewStubUser)
-	if err != nil {
-		return err
-	}
-
-	return sendSNAC(outSNAC.Frame, outSNAC.Body, &seq, rwc)
-}
-
-func flapSignonHandshake(rw io.ReadWriter, sequence *uint32) (oscar.FLAPSignonFrame, error) {
-	// send FLAPFrameSignon to client
-	flap := oscar.FLAPFrame{
-		StartMarker:   42,
-		FrameType:     oscar.FLAPFrameSignon,
-		Sequence:      uint16(*sequence),
-		PayloadLength: 4, // size of FLAPSignonFrame
-	}
-	if err := oscar.Marshal(flap, rw); err != nil {
-		return oscar.FLAPSignonFrame{}, err
-	}
-	flapSignonFrameOut := oscar.FLAPSignonFrame{
-		FLAPVersion: 1,
-	}
-	if err := oscar.Marshal(flapSignonFrameOut, rw); err != nil {
-		return oscar.FLAPSignonFrame{}, err
-	}
-
-	// receive FLAPFrameSignon from client
-	flap = oscar.FLAPFrame{}
-	if err := oscar.Unmarshal(&flap, rw); err != nil {
-		return oscar.FLAPSignonFrame{}, err
-	}
-	buf, err := flap.ReadBody(rw)
-	if err != nil {
-		return oscar.FLAPSignonFrame{}, err
-	}
-	flapSignonFrameIn := oscar.FLAPSignonFrame{}
-	if err := oscar.Unmarshal(&flapSignonFrameIn, buf); err != nil {
-		return oscar.FLAPSignonFrame{}, err
-	}
-
-	*sequence++
-
-	return flapSignonFrameIn, nil
-}

+ 0 - 100
server/bucp_test.go

@@ -1,100 +0,0 @@
-package server
-
-import (
-	"bytes"
-	"io"
-	"log/slog"
-	"testing"
-
-	"github.com/mk6i/retro-aim-server/oscar"
-	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/mock"
-)
-
-func TestBUCPAuthService_handleNewConnection(t *testing.T) {
-	clientReader, serverWriter := io.Pipe()
-	serverReader, clientWriter := io.Pipe()
-
-	go func() {
-		// < receive FLAPSignonFrame
-		flap := oscar.FLAPFrame{}
-		assert.NoError(t, oscar.Unmarshal(&flap, serverReader))
-		buf, err := flap.ReadBody(serverReader)
-		assert.NoError(t, err)
-		flapSignonFrame := oscar.FLAPSignonFrame{}
-		assert.NoError(t, oscar.Unmarshal(&flapSignonFrame, buf))
-
-		// > send FLAPSignonFrame
-		flapSignonFrame = oscar.FLAPSignonFrame{
-			FLAPVersion: 1,
-		}
-		buf = &bytes.Buffer{}
-		assert.NoError(t, oscar.Marshal(flapSignonFrame, buf))
-		flap = oscar.FLAPFrame{
-			StartMarker:   42,
-			FrameType:     oscar.FLAPFrameSignon,
-			PayloadLength: uint16(buf.Len()),
-		}
-		assert.NoError(t, oscar.Marshal(flap, serverWriter))
-		_, err = serverWriter.Write(buf.Bytes())
-		assert.NoError(t, err)
-
-		// > send SNAC_0x17_0x06_BUCPChallengeRequest
-		var seq uint32
-		frame := oscar.SNACFrame{
-			FoodGroup: oscar.BUCP,
-			SubGroup:  oscar.BUCPChallengeRequest,
-		}
-		bodyIn := oscar.SNAC_0x17_0x06_BUCPChallengeRequest{}
-		assert.NoError(t, sendSNAC(frame, bodyIn, &seq, serverWriter))
-
-		// < receive SNAC_0x17_0x07_BUCPChallengeResponse
-		frame = oscar.SNACFrame{}
-		assert.NoError(t, receiveSNAC(&frame, &oscar.SNAC_0x17_0x07_BUCPChallengeResponse{}, serverReader))
-		assert.Equal(t, oscar.SNACFrame{FoodGroup: oscar.BUCP, SubGroup: oscar.BUCPChallengeResponse}, frame)
-
-		// > send SNAC_0x17_0x02_BUCPLoginRequest
-		frame = oscar.SNACFrame{
-			FoodGroup: oscar.BUCP,
-			SubGroup:  oscar.BUCPLoginRequest,
-		}
-		assert.NoError(t, sendSNAC(frame, oscar.SNAC_0x17_0x02_BUCPLoginRequest{}, &seq, serverWriter))
-
-		// < receive SNAC_0x17_0x03_BUCPLoginResponse
-		frame = oscar.SNACFrame{}
-		assert.NoError(t, receiveSNAC(&frame, &oscar.SNAC_0x17_0x03_BUCPLoginResponse{}, serverReader))
-		assert.Equal(t, oscar.SNACFrame{FoodGroup: oscar.BUCP, SubGroup: oscar.BUCPLoginResponse}, frame)
-
-		assert.NoError(t, serverWriter.Close())
-	}()
-
-	authHandler := newMockAuthHandler(t)
-	authHandler.EXPECT().
-		BUCPChallengeRequestHandler(mock.Anything, mock.Anything).
-		Return(oscar.SNACMessage{
-			Frame: oscar.SNACFrame{
-				FoodGroup: oscar.BUCP,
-				SubGroup:  oscar.BUCPChallengeResponse,
-			},
-			Body: oscar.SNAC_0x17_0x07_BUCPChallengeResponse{},
-		}, nil)
-	authHandler.EXPECT().
-		BUCPLoginRequestHandler(mock.Anything, mock.Anything, mock.Anything).
-		Return(oscar.SNACMessage{
-			Frame: oscar.SNACFrame{
-				FoodGroup: oscar.BUCP,
-				SubGroup:  oscar.BUCPLoginResponse,
-			},
-			Body: oscar.SNAC_0x17_0x03_BUCPLoginResponse{},
-		}, nil)
-
-	rt := BUCPAuthService{
-		AuthHandler: authHandler,
-		Logger:      slog.Default(),
-	}
-	rwc := pipeRWC{
-		PipeReader: clientReader,
-		PipeWriter: clientWriter,
-	}
-	assert.NoError(t, rt.handleNewConnection(rwc))
-}

+ 0 - 43
server/buddy.go

@@ -1,43 +0,0 @@
-package server
-
-import (
-	"context"
-	"io"
-	"log/slog"
-
-	"github.com/mk6i/retro-aim-server/oscar"
-	"github.com/mk6i/retro-aim-server/state"
-)
-
-type BuddyHandler interface {
-	RightsQueryHandler(ctx context.Context, inFrame oscar.SNACFrame) oscar.SNACMessage
-}
-
-func NewBuddyRouter(logger *slog.Logger, buddyHandler BuddyHandler) BuddyRouter {
-	return BuddyRouter{
-		BuddyHandler: buddyHandler,
-		routeLogger: routeLogger{
-			Logger: logger,
-		},
-	}
-}
-
-type BuddyRouter struct {
-	BuddyHandler
-	routeLogger
-}
-
-func (rt BuddyRouter) Route(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, r io.Reader, w io.Writer, sequence *uint32) error {
-	switch inFrame.SubGroup {
-	case oscar.BuddyRightsQuery:
-		inSNAC := oscar.SNAC_0x03_0x02_BuddyRightsQuery{}
-		if err := oscar.Unmarshal(&inSNAC, r); err != nil {
-			return err
-		}
-		outSNAC := rt.RightsQueryHandler(ctx, inFrame)
-		rt.logRequestAndResponse(ctx, inFrame, inSNAC, outSNAC.Frame, outSNAC.Body)
-		return sendSNAC(outSNAC.Frame, outSNAC.Body, sequence, w)
-	default:
-		return ErrUnsupportedSubGroup
-	}
-}

+ 0 - 84
server/buddy_mock_test.go

@@ -1,84 +0,0 @@
-// Code generated by mockery v2.38.0. DO NOT EDIT.
-
-package server
-
-import (
-	context "context"
-
-	oscar "github.com/mk6i/retro-aim-server/oscar"
-	mock "github.com/stretchr/testify/mock"
-)
-
-// mockBuddyHandler is an autogenerated mock type for the BuddyHandler type
-type mockBuddyHandler struct {
-	mock.Mock
-}
-
-type mockBuddyHandler_Expecter struct {
-	mock *mock.Mock
-}
-
-func (_m *mockBuddyHandler) EXPECT() *mockBuddyHandler_Expecter {
-	return &mockBuddyHandler_Expecter{mock: &_m.Mock}
-}
-
-// RightsQueryHandler provides a mock function with given fields: ctx, inFrame
-func (_m *mockBuddyHandler) RightsQueryHandler(ctx context.Context, inFrame oscar.SNACFrame) oscar.SNACMessage {
-	ret := _m.Called(ctx, inFrame)
-
-	if len(ret) == 0 {
-		panic("no return value specified for RightsQueryHandler")
-	}
-
-	var r0 oscar.SNACMessage
-	if rf, ok := ret.Get(0).(func(context.Context, oscar.SNACFrame) oscar.SNACMessage); ok {
-		r0 = rf(ctx, inFrame)
-	} else {
-		r0 = ret.Get(0).(oscar.SNACMessage)
-	}
-
-	return r0
-}
-
-// mockBuddyHandler_RightsQueryHandler_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RightsQueryHandler'
-type mockBuddyHandler_RightsQueryHandler_Call struct {
-	*mock.Call
-}
-
-// RightsQueryHandler is a helper method to define mock.On call
-//   - ctx context.Context
-//   - inFrame oscar.SNACFrame
-func (_e *mockBuddyHandler_Expecter) RightsQueryHandler(ctx interface{}, inFrame interface{}) *mockBuddyHandler_RightsQueryHandler_Call {
-	return &mockBuddyHandler_RightsQueryHandler_Call{Call: _e.mock.On("RightsQueryHandler", ctx, inFrame)}
-}
-
-func (_c *mockBuddyHandler_RightsQueryHandler_Call) Run(run func(ctx context.Context, inFrame oscar.SNACFrame)) *mockBuddyHandler_RightsQueryHandler_Call {
-	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(context.Context), args[1].(oscar.SNACFrame))
-	})
-	return _c
-}
-
-func (_c *mockBuddyHandler_RightsQueryHandler_Call) Return(_a0 oscar.SNACMessage) *mockBuddyHandler_RightsQueryHandler_Call {
-	_c.Call.Return(_a0)
-	return _c
-}
-
-func (_c *mockBuddyHandler_RightsQueryHandler_Call) RunAndReturn(run func(context.Context, oscar.SNACFrame) oscar.SNACMessage) *mockBuddyHandler_RightsQueryHandler_Call {
-	_c.Call.Return(run)
-	return _c
-}
-
-// newMockBuddyHandler creates a new instance of mockBuddyHandler. 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 newMockBuddyHandler(t interface {
-	mock.TestingT
-	Cleanup(func())
-}) *mockBuddyHandler {
-	mock := &mockBuddyHandler{}
-	mock.Mock.Test(t)
-
-	t.Cleanup(func() { mock.AssertExpectations(t) })
-
-	return mock
-}

+ 0 - 112
server/buddy_test.go

@@ -1,112 +0,0 @@
-package server
-
-import (
-	"bytes"
-	"testing"
-
-	"github.com/mk6i/retro-aim-server/config"
-	"github.com/mk6i/retro-aim-server/oscar"
-	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/mock"
-)
-
-func TestBuddyRouter_RouteBuddy(t *testing.T) {
-	cases := []struct {
-		// name is the unit test name
-		name string
-		// input is the request payload
-		input oscar.SNACMessage
-		// output is the response payload
-		output oscar.SNACMessage
-		// handlerErr is the mocked handler error response
-		handlerErr error
-		// expectErr is the expected error returned by the router
-		expectErr error
-	}{
-		{
-			name: "receive BuddyRightsQuery, return BuddyRightsReply",
-			input: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.Buddy,
-					SubGroup:  oscar.BuddyRightsQuery,
-				},
-				Body: oscar.SNAC_0x03_0x02_BuddyRightsQuery{
-					TLVRestBlock: oscar.TLVRestBlock{
-						TLVList: oscar.TLVList{
-							oscar.NewTLV(0x01, uint16(1000)),
-						},
-					},
-				},
-			},
-			output: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.Buddy,
-					SubGroup:  oscar.BuddyRightsReply,
-				},
-				Body: oscar.SNAC_0x03_0x03_BuddyRightsReply{
-					TLVRestBlock: oscar.TLVRestBlock{
-						TLVList: oscar.TLVList{
-							oscar.NewTLV(0x01, uint16(1000)),
-						},
-					},
-				},
-			},
-		},
-		{
-			name: "receive ChatRowListInfo, return ErrUnsupportedSubGroup",
-			input: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.Chat,
-					SubGroup:  oscar.ChatRowListInfo,
-				},
-				Body: struct{}{},
-			},
-			expectErr: ErrUnsupportedSubGroup,
-		},
-	}
-
-	for _, tc := range cases {
-		t.Run(tc.name, func(t *testing.T) {
-			svc := newMockBuddyHandler(t)
-			svc.EXPECT().
-				RightsQueryHandler(mock.Anything, tc.input.Frame).
-				Return(tc.output).
-				Maybe()
-
-			router := NewBuddyRouter(NewLogger(config.Config{}), svc)
-
-			bufIn := &bytes.Buffer{}
-			assert.NoError(t, oscar.Marshal(tc.input.Body, bufIn))
-
-			bufOut := &bytes.Buffer{}
-			seq := uint32(0)
-
-			err := router.Route(nil, nil, tc.input.Frame, bufIn, bufOut, &seq)
-			assert.ErrorIs(t, err, tc.expectErr)
-			if tc.expectErr != nil {
-				return
-			}
-
-			// verify the FLAP frame
-			flap := oscar.FLAPFrame{}
-			assert.NoError(t, oscar.Unmarshal(&flap, bufOut))
-
-			// make sure the sequence increments
-			assert.Equal(t, seq, uint32(1))
-			assert.Equal(t, flap.Sequence, uint16(0))
-
-			flapBuf, err := flap.ReadBody(bufOut)
-			assert.NoError(t, err)
-
-			// verify the SNAC frame
-			snacFrame := oscar.SNACFrame{}
-			assert.NoError(t, oscar.Unmarshal(&snacFrame, flapBuf))
-			assert.Equal(t, tc.output.Frame, snacFrame)
-
-			// verify the SNAC message
-			snacBuf := &bytes.Buffer{}
-			assert.NoError(t, oscar.Marshal(tc.output.Body, snacBuf))
-			assert.Equal(t, snacBuf.Bytes(), flapBuf.Bytes())
-		})
-	}
-}

+ 0 - 50
server/chat.go

@@ -1,50 +0,0 @@
-package server
-
-import (
-	"context"
-	"io"
-	"log/slog"
-
-	"github.com/mk6i/retro-aim-server/oscar"
-	"github.com/mk6i/retro-aim-server/state"
-)
-
-type ChatHandler interface {
-	ChannelMsgToHostHandler(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, inBody oscar.SNAC_0x0E_0x05_ChatChannelMsgToHost) (*oscar.SNACMessage, error)
-}
-
-func NewChatRouter(logger *slog.Logger, chatHandler ChatHandler) ChatRouter {
-	return ChatRouter{
-		ChatHandler: chatHandler,
-		routeLogger: routeLogger{
-			Logger: logger,
-		},
-	}
-}
-
-type ChatRouter struct {
-	ChatHandler
-	routeLogger
-}
-
-func (rt ChatRouter) Route(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, r io.Reader, w io.Writer, sequence *uint32) error {
-	switch inFrame.SubGroup {
-	case oscar.ChatChannelMsgToHost:
-		inBody := oscar.SNAC_0x0E_0x05_ChatChannelMsgToHost{}
-		if err := oscar.Unmarshal(&inBody, r); err != nil {
-			return err
-		}
-		outSNAC, err := rt.ChannelMsgToHostHandler(ctx, sess, inFrame, inBody)
-		if err != nil {
-			return err
-		}
-		if outSNAC == nil {
-			return nil
-		}
-		rt.Logger.InfoContext(ctx, "user sent a chat message")
-		rt.logRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
-		return sendSNAC(outSNAC.Frame, outSNAC.Body, sequence, w)
-	default:
-		return ErrUnsupportedSubGroup
-	}
-}

+ 0 - 100
server/chat_mock_test.go

@@ -1,100 +0,0 @@
-// Code generated by mockery v2.38.0. DO NOT EDIT.
-
-package server
-
-import (
-	context "context"
-
-	oscar "github.com/mk6i/retro-aim-server/oscar"
-	mock "github.com/stretchr/testify/mock"
-
-	state "github.com/mk6i/retro-aim-server/state"
-)
-
-// mockChatHandler is an autogenerated mock type for the ChatHandler type
-type mockChatHandler struct {
-	mock.Mock
-}
-
-type mockChatHandler_Expecter struct {
-	mock *mock.Mock
-}
-
-func (_m *mockChatHandler) EXPECT() *mockChatHandler_Expecter {
-	return &mockChatHandler_Expecter{mock: &_m.Mock}
-}
-
-// ChannelMsgToHostHandler provides a mock function with given fields: ctx, sess, inFrame, inBody
-func (_m *mockChatHandler) ChannelMsgToHostHandler(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, inBody oscar.SNAC_0x0E_0x05_ChatChannelMsgToHost) (*oscar.SNACMessage, error) {
-	ret := _m.Called(ctx, sess, inFrame, inBody)
-
-	if len(ret) == 0 {
-		panic("no return value specified for ChannelMsgToHostHandler")
-	}
-
-	var r0 *oscar.SNACMessage
-	var r1 error
-	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, oscar.SNACFrame, oscar.SNAC_0x0E_0x05_ChatChannelMsgToHost) (*oscar.SNACMessage, error)); ok {
-		return rf(ctx, sess, inFrame, inBody)
-	}
-	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, oscar.SNACFrame, oscar.SNAC_0x0E_0x05_ChatChannelMsgToHost) *oscar.SNACMessage); ok {
-		r0 = rf(ctx, sess, inFrame, inBody)
-	} else {
-		if ret.Get(0) != nil {
-			r0 = ret.Get(0).(*oscar.SNACMessage)
-		}
-	}
-
-	if rf, ok := ret.Get(1).(func(context.Context, *state.Session, oscar.SNACFrame, oscar.SNAC_0x0E_0x05_ChatChannelMsgToHost) error); ok {
-		r1 = rf(ctx, sess, inFrame, inBody)
-	} else {
-		r1 = ret.Error(1)
-	}
-
-	return r0, r1
-}
-
-// mockChatHandler_ChannelMsgToHostHandler_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ChannelMsgToHostHandler'
-type mockChatHandler_ChannelMsgToHostHandler_Call struct {
-	*mock.Call
-}
-
-// ChannelMsgToHostHandler is a helper method to define mock.On call
-//   - ctx context.Context
-//   - sess *state.Session
-//   - inFrame oscar.SNACFrame
-//   - inBody oscar.SNAC_0x0E_0x05_ChatChannelMsgToHost
-func (_e *mockChatHandler_Expecter) ChannelMsgToHostHandler(ctx interface{}, sess interface{}, inFrame interface{}, inBody interface{}) *mockChatHandler_ChannelMsgToHostHandler_Call {
-	return &mockChatHandler_ChannelMsgToHostHandler_Call{Call: _e.mock.On("ChannelMsgToHostHandler", ctx, sess, inFrame, inBody)}
-}
-
-func (_c *mockChatHandler_ChannelMsgToHostHandler_Call) Run(run func(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, inBody oscar.SNAC_0x0E_0x05_ChatChannelMsgToHost)) *mockChatHandler_ChannelMsgToHostHandler_Call {
-	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(context.Context), args[1].(*state.Session), args[2].(oscar.SNACFrame), args[3].(oscar.SNAC_0x0E_0x05_ChatChannelMsgToHost))
-	})
-	return _c
-}
-
-func (_c *mockChatHandler_ChannelMsgToHostHandler_Call) Return(_a0 *oscar.SNACMessage, _a1 error) *mockChatHandler_ChannelMsgToHostHandler_Call {
-	_c.Call.Return(_a0, _a1)
-	return _c
-}
-
-func (_c *mockChatHandler_ChannelMsgToHostHandler_Call) RunAndReturn(run func(context.Context, *state.Session, oscar.SNACFrame, oscar.SNAC_0x0E_0x05_ChatChannelMsgToHost) (*oscar.SNACMessage, error)) *mockChatHandler_ChannelMsgToHostHandler_Call {
-	_c.Call.Return(run)
-	return _c
-}
-
-// newMockChatHandler creates a new instance of mockChatHandler. 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 newMockChatHandler(t interface {
-	mock.TestingT
-	Cleanup(func())
-}) *mockChatHandler {
-	mock := &mockChatHandler{}
-	mock.Mock.Test(t)
-
-	t.Cleanup(func() { mock.AssertExpectations(t) })
-
-	return mock
-}

+ 0 - 70
server/chat_nav.go

@@ -1,70 +0,0 @@
-package server
-
-import (
-	"context"
-	"io"
-	"log/slog"
-
-	"github.com/mk6i/retro-aim-server/oscar"
-	"github.com/mk6i/retro-aim-server/state"
-)
-
-type ChatNavHandler interface {
-	CreateRoomHandler(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, inBody oscar.SNAC_0x0E_0x02_ChatRoomInfoUpdate) (oscar.SNACMessage, error)
-	RequestChatRightsHandler(ctx context.Context, inFrame oscar.SNACFrame) oscar.SNACMessage
-	RequestRoomInfoHandler(ctx context.Context, inFrame oscar.SNACFrame, inBody oscar.SNAC_0x0D_0x04_ChatNavRequestRoomInfo) (oscar.SNACMessage, error)
-}
-
-func NewChatNavRouter(handler ChatNavHandler, logger *slog.Logger) ChatNavRouter {
-	return ChatNavRouter{
-		ChatNavHandler: handler,
-		routeLogger: routeLogger{
-			Logger: logger,
-		},
-	}
-}
-
-type ChatNavRouter struct {
-	ChatNavHandler
-	routeLogger
-}
-
-func (rt ChatNavRouter) Route(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, r io.Reader, w io.Writer, sequence *uint32) error {
-	switch inFrame.SubGroup {
-	case oscar.ChatNavRequestChatRights:
-		outSNAC := rt.RequestChatRightsHandler(ctx, inFrame)
-		rt.logRequestAndResponse(ctx, inFrame, nil, outSNAC.Frame, outSNAC.Body)
-		return sendSNAC(outSNAC.Frame, outSNAC.Body, sequence, w)
-	case oscar.ChatNavRequestRoomInfo:
-		inBody := oscar.SNAC_0x0D_0x04_ChatNavRequestRoomInfo{}
-		if err := oscar.Unmarshal(&inBody, r); err != nil {
-			return err
-		}
-		outSNAC, err := rt.RequestRoomInfoHandler(ctx, inFrame, inBody)
-		if err != nil {
-			return err
-		}
-		rt.logRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
-		return sendSNAC(outSNAC.Frame, outSNAC.Body, sequence, w)
-	case oscar.ChatNavCreateRoom:
-		inBody := oscar.SNAC_0x0E_0x02_ChatRoomInfoUpdate{}
-		if err := oscar.Unmarshal(&inBody, r); err != nil {
-			return err
-		}
-		outSNAC, err := rt.CreateRoomHandler(ctx, sess, inFrame, inBody)
-		if err != nil {
-			return err
-		}
-		roomName, _ := inBody.String(oscar.ChatTLVRoomName)
-		rt.Logger.InfoContext(ctx, "user started a chat room", slog.String("roomName", roomName))
-		rt.logRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
-		return sendSNAC(outSNAC.Frame, outSNAC.Body, sequence, w)
-	default:
-		return ErrUnsupportedSubGroup
-	}
-}
-
-type ChatCookie struct {
-	Cookie []byte `len_prefix:"uint16"`
-	SessID string `len_prefix:"uint16"`
-}

+ 0 - 203
server/chat_nav_mock_test.go

@@ -1,203 +0,0 @@
-// Code generated by mockery v2.38.0. DO NOT EDIT.
-
-package server
-
-import (
-	context "context"
-
-	oscar "github.com/mk6i/retro-aim-server/oscar"
-	mock "github.com/stretchr/testify/mock"
-
-	state "github.com/mk6i/retro-aim-server/state"
-)
-
-// mockChatNavHandler is an autogenerated mock type for the ChatNavHandler type
-type mockChatNavHandler struct {
-	mock.Mock
-}
-
-type mockChatNavHandler_Expecter struct {
-	mock *mock.Mock
-}
-
-func (_m *mockChatNavHandler) EXPECT() *mockChatNavHandler_Expecter {
-	return &mockChatNavHandler_Expecter{mock: &_m.Mock}
-}
-
-// CreateRoomHandler provides a mock function with given fields: ctx, sess, inFrame, inBody
-func (_m *mockChatNavHandler) CreateRoomHandler(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, inBody oscar.SNAC_0x0E_0x02_ChatRoomInfoUpdate) (oscar.SNACMessage, error) {
-	ret := _m.Called(ctx, sess, inFrame, inBody)
-
-	if len(ret) == 0 {
-		panic("no return value specified for CreateRoomHandler")
-	}
-
-	var r0 oscar.SNACMessage
-	var r1 error
-	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, oscar.SNACFrame, oscar.SNAC_0x0E_0x02_ChatRoomInfoUpdate) (oscar.SNACMessage, error)); ok {
-		return rf(ctx, sess, inFrame, inBody)
-	}
-	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, oscar.SNACFrame, oscar.SNAC_0x0E_0x02_ChatRoomInfoUpdate) oscar.SNACMessage); ok {
-		r0 = rf(ctx, sess, inFrame, inBody)
-	} else {
-		r0 = ret.Get(0).(oscar.SNACMessage)
-	}
-
-	if rf, ok := ret.Get(1).(func(context.Context, *state.Session, oscar.SNACFrame, oscar.SNAC_0x0E_0x02_ChatRoomInfoUpdate) error); ok {
-		r1 = rf(ctx, sess, inFrame, inBody)
-	} else {
-		r1 = ret.Error(1)
-	}
-
-	return r0, r1
-}
-
-// mockChatNavHandler_CreateRoomHandler_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateRoomHandler'
-type mockChatNavHandler_CreateRoomHandler_Call struct {
-	*mock.Call
-}
-
-// CreateRoomHandler is a helper method to define mock.On call
-//   - ctx context.Context
-//   - sess *state.Session
-//   - inFrame oscar.SNACFrame
-//   - inBody oscar.SNAC_0x0E_0x02_ChatRoomInfoUpdate
-func (_e *mockChatNavHandler_Expecter) CreateRoomHandler(ctx interface{}, sess interface{}, inFrame interface{}, inBody interface{}) *mockChatNavHandler_CreateRoomHandler_Call {
-	return &mockChatNavHandler_CreateRoomHandler_Call{Call: _e.mock.On("CreateRoomHandler", ctx, sess, inFrame, inBody)}
-}
-
-func (_c *mockChatNavHandler_CreateRoomHandler_Call) Run(run func(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, inBody oscar.SNAC_0x0E_0x02_ChatRoomInfoUpdate)) *mockChatNavHandler_CreateRoomHandler_Call {
-	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(context.Context), args[1].(*state.Session), args[2].(oscar.SNACFrame), args[3].(oscar.SNAC_0x0E_0x02_ChatRoomInfoUpdate))
-	})
-	return _c
-}
-
-func (_c *mockChatNavHandler_CreateRoomHandler_Call) Return(_a0 oscar.SNACMessage, _a1 error) *mockChatNavHandler_CreateRoomHandler_Call {
-	_c.Call.Return(_a0, _a1)
-	return _c
-}
-
-func (_c *mockChatNavHandler_CreateRoomHandler_Call) RunAndReturn(run func(context.Context, *state.Session, oscar.SNACFrame, oscar.SNAC_0x0E_0x02_ChatRoomInfoUpdate) (oscar.SNACMessage, error)) *mockChatNavHandler_CreateRoomHandler_Call {
-	_c.Call.Return(run)
-	return _c
-}
-
-// RequestChatRightsHandler provides a mock function with given fields: ctx, inFrame
-func (_m *mockChatNavHandler) RequestChatRightsHandler(ctx context.Context, inFrame oscar.SNACFrame) oscar.SNACMessage {
-	ret := _m.Called(ctx, inFrame)
-
-	if len(ret) == 0 {
-		panic("no return value specified for RequestChatRightsHandler")
-	}
-
-	var r0 oscar.SNACMessage
-	if rf, ok := ret.Get(0).(func(context.Context, oscar.SNACFrame) oscar.SNACMessage); ok {
-		r0 = rf(ctx, inFrame)
-	} else {
-		r0 = ret.Get(0).(oscar.SNACMessage)
-	}
-
-	return r0
-}
-
-// mockChatNavHandler_RequestChatRightsHandler_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RequestChatRightsHandler'
-type mockChatNavHandler_RequestChatRightsHandler_Call struct {
-	*mock.Call
-}
-
-// RequestChatRightsHandler is a helper method to define mock.On call
-//   - ctx context.Context
-//   - inFrame oscar.SNACFrame
-func (_e *mockChatNavHandler_Expecter) RequestChatRightsHandler(ctx interface{}, inFrame interface{}) *mockChatNavHandler_RequestChatRightsHandler_Call {
-	return &mockChatNavHandler_RequestChatRightsHandler_Call{Call: _e.mock.On("RequestChatRightsHandler", ctx, inFrame)}
-}
-
-func (_c *mockChatNavHandler_RequestChatRightsHandler_Call) Run(run func(ctx context.Context, inFrame oscar.SNACFrame)) *mockChatNavHandler_RequestChatRightsHandler_Call {
-	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(context.Context), args[1].(oscar.SNACFrame))
-	})
-	return _c
-}
-
-func (_c *mockChatNavHandler_RequestChatRightsHandler_Call) Return(_a0 oscar.SNACMessage) *mockChatNavHandler_RequestChatRightsHandler_Call {
-	_c.Call.Return(_a0)
-	return _c
-}
-
-func (_c *mockChatNavHandler_RequestChatRightsHandler_Call) RunAndReturn(run func(context.Context, oscar.SNACFrame) oscar.SNACMessage) *mockChatNavHandler_RequestChatRightsHandler_Call {
-	_c.Call.Return(run)
-	return _c
-}
-
-// RequestRoomInfoHandler provides a mock function with given fields: ctx, inFrame, inBody
-func (_m *mockChatNavHandler) RequestRoomInfoHandler(ctx context.Context, inFrame oscar.SNACFrame, inBody oscar.SNAC_0x0D_0x04_ChatNavRequestRoomInfo) (oscar.SNACMessage, error) {
-	ret := _m.Called(ctx, inFrame, inBody)
-
-	if len(ret) == 0 {
-		panic("no return value specified for RequestRoomInfoHandler")
-	}
-
-	var r0 oscar.SNACMessage
-	var r1 error
-	if rf, ok := ret.Get(0).(func(context.Context, oscar.SNACFrame, oscar.SNAC_0x0D_0x04_ChatNavRequestRoomInfo) (oscar.SNACMessage, error)); ok {
-		return rf(ctx, inFrame, inBody)
-	}
-	if rf, ok := ret.Get(0).(func(context.Context, oscar.SNACFrame, oscar.SNAC_0x0D_0x04_ChatNavRequestRoomInfo) oscar.SNACMessage); ok {
-		r0 = rf(ctx, inFrame, inBody)
-	} else {
-		r0 = ret.Get(0).(oscar.SNACMessage)
-	}
-
-	if rf, ok := ret.Get(1).(func(context.Context, oscar.SNACFrame, oscar.SNAC_0x0D_0x04_ChatNavRequestRoomInfo) error); ok {
-		r1 = rf(ctx, inFrame, inBody)
-	} else {
-		r1 = ret.Error(1)
-	}
-
-	return r0, r1
-}
-
-// mockChatNavHandler_RequestRoomInfoHandler_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RequestRoomInfoHandler'
-type mockChatNavHandler_RequestRoomInfoHandler_Call struct {
-	*mock.Call
-}
-
-// RequestRoomInfoHandler is a helper method to define mock.On call
-//   - ctx context.Context
-//   - inFrame oscar.SNACFrame
-//   - inBody oscar.SNAC_0x0D_0x04_ChatNavRequestRoomInfo
-func (_e *mockChatNavHandler_Expecter) RequestRoomInfoHandler(ctx interface{}, inFrame interface{}, inBody interface{}) *mockChatNavHandler_RequestRoomInfoHandler_Call {
-	return &mockChatNavHandler_RequestRoomInfoHandler_Call{Call: _e.mock.On("RequestRoomInfoHandler", ctx, inFrame, inBody)}
-}
-
-func (_c *mockChatNavHandler_RequestRoomInfoHandler_Call) Run(run func(ctx context.Context, inFrame oscar.SNACFrame, inBody oscar.SNAC_0x0D_0x04_ChatNavRequestRoomInfo)) *mockChatNavHandler_RequestRoomInfoHandler_Call {
-	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(context.Context), args[1].(oscar.SNACFrame), args[2].(oscar.SNAC_0x0D_0x04_ChatNavRequestRoomInfo))
-	})
-	return _c
-}
-
-func (_c *mockChatNavHandler_RequestRoomInfoHandler_Call) Return(_a0 oscar.SNACMessage, _a1 error) *mockChatNavHandler_RequestRoomInfoHandler_Call {
-	_c.Call.Return(_a0, _a1)
-	return _c
-}
-
-func (_c *mockChatNavHandler_RequestRoomInfoHandler_Call) RunAndReturn(run func(context.Context, oscar.SNACFrame, oscar.SNAC_0x0D_0x04_ChatNavRequestRoomInfo) (oscar.SNACMessage, error)) *mockChatNavHandler_RequestRoomInfoHandler_Call {
-	_c.Call.Return(run)
-	return _c
-}
-
-// newMockChatNavHandler creates a new instance of mockChatNavHandler. 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 newMockChatNavHandler(t interface {
-	mock.TestingT
-	Cleanup(func())
-}) *mockChatNavHandler {
-	mock := &mockChatNavHandler{}
-	mock.Mock.Test(t)
-
-	t.Cleanup(func() { mock.AssertExpectations(t) })
-
-	return mock
-}

+ 0 - 169
server/chat_nav_test.go

@@ -1,169 +0,0 @@
-package server
-
-import (
-	"bytes"
-	"testing"
-
-	"github.com/mk6i/retro-aim-server/config"
-	"github.com/mk6i/retro-aim-server/oscar"
-	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/mock"
-)
-
-func TestChatNavRouter_RouteChatNavRouter(t *testing.T) {
-	cases := []struct {
-		// name is the unit test name
-		name string
-		// input is the request payload
-		input oscar.SNACMessage
-		// output is the response payload
-		output oscar.SNACMessage
-		// handlerErr is the mocked handler error response
-		handlerErr error
-		// expectErr is the expected error returned by the router
-		expectErr error
-	}{
-		{
-			name: "receive ChatNavRequestChatRights, return ChatNavNavInfo",
-			input: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.ChatNav,
-					SubGroup:  oscar.ChatNavRequestChatRights,
-				},
-				Body: struct{}{},
-			},
-			output: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.ChatNav,
-					SubGroup:  oscar.ChatNavNavInfo,
-				},
-				Body: oscar.SNAC_0x0D_0x09_ChatNavNavInfo{
-					TLVRestBlock: oscar.TLVRestBlock{
-						TLVList: oscar.TLVList{
-							oscar.NewTLV(0x02, uint8(10)),
-						},
-					},
-				},
-			},
-		},
-		{
-			name: "receive ChatNavRequestRoomInfo, return ChatNavNavInfo",
-			input: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.ChatNav,
-					SubGroup:  oscar.ChatNavRequestRoomInfo,
-				},
-				Body: oscar.SNAC_0x0D_0x04_ChatNavRequestRoomInfo{
-					Exchange: 1,
-				},
-			},
-			output: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.ChatNav,
-					SubGroup:  oscar.ChatNavNavInfo,
-				},
-				Body: oscar.SNAC_0x0D_0x09_ChatNavNavInfo{
-					TLVRestBlock: oscar.TLVRestBlock{
-						TLVList: oscar.TLVList{
-							oscar.NewTLV(0x02, uint8(10)),
-						},
-					},
-				},
-			},
-		},
-		{
-			name: "receive ChatNavCreateRoom, return ChatNavNavInfo",
-			input: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.ChatNav,
-					SubGroup:  oscar.ChatNavCreateRoom,
-				},
-				Body: oscar.SNAC_0x0E_0x02_ChatRoomInfoUpdate{
-					Exchange: 1,
-				},
-			},
-			output: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.ChatNav,
-					SubGroup:  oscar.ChatNavNavInfo,
-				},
-				Body: oscar.SNAC_0x0D_0x09_ChatNavNavInfo{
-					TLVRestBlock: oscar.TLVRestBlock{
-						TLVList: oscar.TLVList{
-							oscar.NewTLV(0x02, uint8(10)),
-						},
-					},
-				},
-			},
-		},
-		{
-			name: "receive ChatNavRequestOccupantList, return ErrUnsupportedSubGroup",
-			input: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.ChatNav,
-					SubGroup:  oscar.ChatNavRequestOccupantList,
-				},
-				Body: struct{}{},
-			},
-			output:    oscar.SNACMessage{},
-			expectErr: ErrUnsupportedSubGroup,
-		},
-	}
-
-	for _, tc := range cases {
-		t.Run(tc.name, func(t *testing.T) {
-			svc := newMockChatNavHandler(t)
-			svc.EXPECT().
-				RequestChatRightsHandler(mock.Anything, tc.input.Frame).
-				Return(tc.output).
-				Maybe()
-			svc.EXPECT().
-				RequestRoomInfoHandler(mock.Anything, tc.input.Frame, tc.input.Body).
-				Return(tc.output, tc.handlerErr).
-				Maybe()
-			svc.EXPECT().
-				CreateRoomHandler(mock.Anything, mock.Anything, tc.input.Frame, tc.input.Body).
-				Return(tc.output, tc.handlerErr).
-				Maybe()
-
-			router := NewChatNavRouter(svc, NewLogger(config.Config{}))
-
-			bufIn := &bytes.Buffer{}
-			assert.NoError(t, oscar.Marshal(tc.input.Body, bufIn))
-
-			bufOut := &bytes.Buffer{}
-			seq := uint32(0)
-
-			err := router.Route(nil, nil, tc.input.Frame, bufIn, bufOut, &seq)
-			assert.ErrorIs(t, err, tc.expectErr)
-			if tc.expectErr != nil {
-				return
-			}
-
-			if tc.output.Frame == (oscar.SNACFrame{}) {
-				return
-			}
-
-			// verify the FLAP frame
-			flap := oscar.FLAPFrame{}
-			assert.NoError(t, oscar.Unmarshal(&flap, bufOut))
-
-			// make sure the sequence increments
-			assert.Equal(t, seq, uint32(1))
-			assert.Equal(t, flap.Sequence, uint16(0))
-
-			flapBuf, err := flap.ReadBody(bufOut)
-			assert.NoError(t, err)
-
-			// verify the SNAC frame
-			snacFrame := oscar.SNACFrame{}
-			assert.NoError(t, oscar.Unmarshal(&snacFrame, flapBuf))
-			assert.Equal(t, tc.output.Frame, snacFrame)
-
-			// verify the SNAC message
-			snacBuf := &bytes.Buffer{}
-			assert.NoError(t, oscar.Marshal(tc.output.Body, snacBuf))
-			assert.Equal(t, snacBuf.Bytes(), flapBuf.Bytes())
-		})
-	}
-}

+ 0 - 25
server/chat_service_router.go

@@ -1,25 +0,0 @@
-package server
-
-import (
-	"context"
-	"io"
-
-	"github.com/mk6i/retro-aim-server/oscar"
-	"github.com/mk6i/retro-aim-server/state"
-)
-
-type ChatServiceRouter struct {
-	ChatRouter         Router
-	OServiceChatRouter Router
-}
-
-func (rt ChatServiceRouter) Route(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, r io.Reader, w io.Writer, sequence *uint32) error {
-	switch inFrame.FoodGroup {
-	case oscar.OService:
-		return rt.OServiceChatRouter.Route(ctx, sess, inFrame, r, w, sequence)
-	case oscar.Chat:
-		return rt.ChatRouter.Route(ctx, sess, inFrame, r, w, sequence)
-	default:
-		return ErrUnsupportedSubGroup
-	}
-}

+ 0 - 76
server/chat_service_router_test.go

@@ -1,76 +0,0 @@
-package server
-
-import (
-	"io"
-	"testing"
-
-	"github.com/mk6i/retro-aim-server/oscar"
-	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/mock"
-)
-
-func TestChatServiceRouter_Route(t *testing.T) {
-	tests := []struct {
-		name      string
-		frame     oscar.SNACFrame
-		newRouter func(t *testing.T) BOSRouter
-		err       error
-	}{
-		{
-			name: "OService no error",
-			frame: oscar.SNACFrame{
-				FoodGroup: oscar.OService,
-			},
-		},
-		{
-			name: "Chat no error",
-			frame: oscar.SNACFrame{
-				FoodGroup: oscar.Chat,
-			},
-		},
-		{
-			name: "OService with error",
-			frame: oscar.SNACFrame{
-				FoodGroup: oscar.OService,
-			},
-			err: io.EOF,
-		},
-		{
-			name: "Chat with error",
-			frame: oscar.SNACFrame{
-				FoodGroup: oscar.Chat,
-			},
-			err: io.EOF,
-		},
-		{
-			name: "ICBM (unsupported route), expect error",
-			frame: oscar.SNACFrame{
-				FoodGroup: oscar.ICBM,
-			},
-			err: ErrUnsupportedSubGroup,
-		},
-	}
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			fnNewRouter := func() Router {
-				fgRouter := newMockRouter(t)
-				fgRouter.EXPECT().
-					Route(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
-					Return(tt.err)
-				return fgRouter
-			}
-
-			router := ChatServiceRouter{}
-
-			switch tt.frame.FoodGroup {
-			case oscar.OService:
-				router.OServiceChatRouter = fnNewRouter()
-			case oscar.Chat:
-				router.ChatRouter = fnNewRouter()
-			}
-
-			err := router.Route(nil, nil, tt.frame, nil, nil, nil)
-			assert.ErrorIs(t, err, tt.err)
-		})
-	}
-}

+ 0 - 109
server/chat_service_test.go

@@ -1,109 +0,0 @@
-package server
-
-import (
-	"bytes"
-	"context"
-	"io"
-	"log/slog"
-	"testing"
-
-	"github.com/mk6i/retro-aim-server/oscar"
-	"github.com/mk6i/retro-aim-server/state"
-	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/mock"
-)
-
-func TestChatService_handleNewConnection(t *testing.T) {
-	sess := state.NewSession()
-	sess.SetID("login-cookie-1234")
-	chatCookie := "chat-cookie"
-
-	clientReader, serverWriter := io.Pipe()
-	serverReader, clientWriter := io.Pipe()
-
-	go func() {
-		// < receive FLAPSignonFrame
-		flap := oscar.FLAPFrame{}
-		assert.NoError(t, oscar.Unmarshal(&flap, serverReader))
-		buf, err := flap.ReadBody(serverReader)
-		assert.NoError(t, err)
-		flapSignonFrame := oscar.FLAPSignonFrame{}
-		assert.NoError(t, oscar.Unmarshal(&flapSignonFrame, buf))
-
-		// > send FLAPSignonFrame
-		flapSignonFrame = oscar.FLAPSignonFrame{
-			FLAPVersion: 1,
-		}
-		cookie := ChatCookie{
-			Cookie: []byte(chatCookie),
-			SessID: sess.ID(),
-		}
-		flapSignonFrame.Append(oscar.NewTLV(oscar.OServiceTLVTagsLoginCookie, cookie))
-		buf = &bytes.Buffer{}
-		assert.NoError(t, oscar.Marshal(flapSignonFrame, buf))
-		flap = oscar.FLAPFrame{
-			StartMarker:   42,
-			FrameType:     oscar.FLAPFrameSignon,
-			PayloadLength: uint16(buf.Len()),
-		}
-		assert.NoError(t, oscar.Marshal(flap, serverWriter))
-		_, err = serverWriter.Write(buf.Bytes())
-		assert.NoError(t, err)
-
-		// < receive SNAC_0x01_0x03_OServiceHostOnline
-		flap = oscar.FLAPFrame{}
-		assert.NoError(t, oscar.Unmarshal(&flap, serverReader))
-		buf, err = flap.ReadBody(serverReader)
-		assert.NoError(t, err)
-		frame := oscar.SNACFrame{}
-		assert.NoError(t, oscar.Unmarshal(&frame, buf))
-		body := oscar.SNAC_0x01_0x03_OServiceHostOnline{}
-		assert.NoError(t, oscar.Unmarshal(&body, buf))
-
-		// send the first request that should get relayed to BOSRouter.Route
-		var seq uint32
-		frame = oscar.SNACFrame{
-			FoodGroup: oscar.Chat,
-			SubGroup:  oscar.ChatNavNavInfo,
-		}
-		assert.NoError(t, sendSNAC(frame, struct{}{}, &seq, serverWriter))
-
-		assert.NoError(t, serverWriter.Close())
-	}()
-
-	authHandler := newMockAuthHandler(t)
-	authHandler.EXPECT().
-		RetrieveChatSession(chatCookie, sess.ID()).
-		Return(sess, nil)
-	authHandler.EXPECT().
-		SignoutChat(mock.Anything, sess, chatCookie).
-		Return(nil)
-
-	chatHandler := newMockOServiceChatHandler(t)
-	chatHandler.EXPECT().
-		WriteOServiceHostOnline().
-		Return(oscar.SNACMessage{
-			Frame: oscar.SNACFrame{
-				FoodGroup: oscar.OService,
-				SubGroup:  oscar.OServiceHostOnline,
-			},
-			Body: oscar.SNAC_0x01_0x03_OServiceHostOnline{},
-		})
-
-	bosRouter := newMockRouter(t)
-	bosRouter.EXPECT().
-		Route(mock.Anything, sess, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
-		Return(nil)
-
-	rt := ChatService{
-		AuthHandler:         authHandler,
-		Logger:              slog.Default(),
-		OServiceChatHandler: chatHandler,
-		Router:              bosRouter,
-	}
-	rwc := pipeRWC{
-		PipeReader: clientReader,
-		PipeWriter: clientWriter,
-	}
-	rt.handleNewConnection(context.Background(), rwc)
-}

+ 0 - 124
server/chat_test.go

@@ -1,124 +0,0 @@
-package server
-
-import (
-	"bytes"
-	"testing"
-
-	"github.com/mk6i/retro-aim-server/config"
-	"github.com/mk6i/retro-aim-server/oscar"
-	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/mock"
-)
-
-func TestChatRouter_RouteChat(t *testing.T) {
-	cases := []struct {
-		// name is the unit test name
-		name string
-		// input is the request payload
-		input oscar.SNACMessage
-		// output is the response payload
-		output *oscar.SNACMessage
-		// handlerErr is the mocked handler error response
-		handlerErr error
-		// expectErr is the expected error returned by the router
-		expectErr error
-	}{
-		{
-			name: "receive ChatChannelMsgToHost, return ChatChannelMsgToClient",
-			input: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.Chat,
-					SubGroup:  oscar.ChatChannelMsgToHost,
-				},
-				Body: oscar.SNAC_0x0E_0x05_ChatChannelMsgToHost{
-					Channel: 4,
-				},
-			},
-			output: &oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.Chat,
-					SubGroup:  oscar.ChatChannelMsgToClient,
-				},
-				Body: oscar.SNAC_0x0E_0x06_ChatChannelMsgToClient{
-					Channel: 4,
-				},
-			},
-		},
-		{
-			name: "receive ChatChannelMsgToHost, return no response",
-			input: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.Chat,
-					SubGroup:  oscar.ChatChannelMsgToHost,
-				},
-				Body: oscar.SNAC_0x0E_0x05_ChatChannelMsgToHost{
-					Channel: 4,
-				},
-			},
-			output: nil,
-		},
-		{
-			name: "receive ChatRowListInfo, return ErrUnsupportedSubGroup",
-			input: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.Chat,
-					SubGroup:  oscar.ChatRowListInfo,
-				},
-				Body: struct{}{},
-			},
-			output:    nil,
-			expectErr: ErrUnsupportedSubGroup,
-		},
-	}
-
-	for _, tc := range cases {
-		t.Run(tc.name, func(t *testing.T) {
-			svc := newMockChatHandler(t)
-			svc.EXPECT().
-				ChannelMsgToHostHandler(mock.Anything, mock.Anything, tc.input.Frame, tc.input.Body).
-				Return(tc.output, tc.handlerErr).
-				Maybe()
-
-			router := NewChatRouter(NewLogger(config.Config{}), svc)
-
-			bufIn := &bytes.Buffer{}
-			assert.NoError(t, oscar.Marshal(tc.input.Body, bufIn))
-
-			bufOut := &bytes.Buffer{}
-			seq := uint32(0)
-
-			err := router.Route(nil, nil, tc.input.Frame, bufIn, bufOut, &seq)
-			assert.ErrorIs(t, err, tc.expectErr)
-			if tc.expectErr != nil {
-				return
-			}
-
-			if tc.output == nil {
-				// make sure no response was sent
-				assert.Empty(t, bufOut.Bytes())
-				return
-			}
-
-			// verify the FLAP frame
-			flap := oscar.FLAPFrame{}
-			assert.NoError(t, oscar.Unmarshal(&flap, bufOut))
-
-			// make sure the sequence increments
-			assert.Equal(t, seq, uint32(1))
-			assert.Equal(t, flap.Sequence, uint16(0))
-
-			flapBuf, err := flap.ReadBody(bufOut)
-			assert.NoError(t, err)
-
-			// verify the SNAC frame
-			snacFrame := oscar.SNACFrame{}
-			assert.NoError(t, oscar.Unmarshal(&snacFrame, flapBuf))
-			assert.Equal(t, tc.output.Frame, snacFrame)
-
-			// verify the SNAC message
-			snacBuf := &bytes.Buffer{}
-			assert.NoError(t, oscar.Marshal(tc.output.Body, snacBuf))
-			assert.Equal(t, snacBuf.Bytes(), flapBuf.Bytes())
-		})
-	}
-}

+ 0 - 202
server/connection.go

@@ -1,202 +0,0 @@
-package server
-
-import (
-	"bytes"
-	"context"
-	"errors"
-	"fmt"
-	"io"
-	"log/slog"
-
-	"github.com/mk6i/retro-aim-server/config"
-	"github.com/mk6i/retro-aim-server/oscar"
-	"github.com/mk6i/retro-aim-server/state"
-)
-
-var (
-	ErrUnsupportedSubGroup = errors.New("unimplemented subgroup, your client version may be unsupported")
-)
-
-// Router is the interface for methods that route food group requests.
-type Router interface {
-	Route(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, r io.Reader, w io.Writer, sequence *uint32) error
-}
-
-// snacSender is the function that packages a SNAC frame and body into a FLAP
-// message and writes it to the output writer.
-type snacSender func(frame oscar.SNACFrame, body any, sequence *uint32, w io.Writer) error
-
-type incomingMessage struct {
-	flap    oscar.FLAPFrame
-	payload *bytes.Buffer
-}
-
-func sendSNAC(frame oscar.SNACFrame, body any, sequence *uint32, w io.Writer) error {
-	snacBuf := &bytes.Buffer{}
-	if err := oscar.Marshal(frame, snacBuf); err != nil {
-		return err
-	}
-	if err := oscar.Marshal(body, snacBuf); err != nil {
-		return err
-	}
-
-	flap := oscar.FLAPFrame{
-		StartMarker:   42,
-		FrameType:     oscar.FLAPFrameData,
-		Sequence:      uint16(*sequence),
-		PayloadLength: uint16(snacBuf.Len()),
-	}
-
-	if err := oscar.Marshal(flap, w); err != nil {
-		return err
-	}
-
-	expectLen := snacBuf.Len()
-	c, err := w.Write(snacBuf.Bytes())
-	if err != nil {
-		return err
-	}
-	if c != expectLen {
-		panic("did not write the expected # of bytes")
-	}
-
-	*sequence++
-	return nil
-}
-
-func receiveSNAC(frame *oscar.SNACFrame, body any, r io.Reader) error {
-	flap := oscar.FLAPFrame{}
-	if err := oscar.Unmarshal(&flap, r); err != nil {
-		return err
-	}
-	buf, err := flap.ReadBody(r)
-	if err != nil {
-		return err
-	}
-	if err := oscar.Unmarshal(frame, buf); err != nil {
-		return err
-	}
-	return oscar.Unmarshal(body, buf)
-}
-
-func sendInvalidSNACErr(frameIn oscar.SNACFrame, w io.Writer, sequence *uint32) error {
-	frameOut := oscar.SNACFrame{
-		FoodGroup: frameIn.FoodGroup,
-		SubGroup:  0x01, // error subgroup for all SNACs
-		RequestID: frameIn.RequestID,
-	}
-	bodyOut := oscar.SNACError{
-		Code: oscar.ErrorCodeInvalidSnac,
-	}
-	return sendSNAC(frameOut, bodyOut, sequence, w)
-}
-
-func consumeFLAPFrames(r io.Reader, msgCh chan incomingMessage, errCh chan error) {
-	defer close(msgCh)
-	defer close(errCh)
-
-	for {
-		in := incomingMessage{}
-		if err := oscar.Unmarshal(&in.flap, r); err != nil {
-			errCh <- err
-			return
-		}
-
-		if in.flap.FrameType == oscar.FLAPFrameData {
-			buf := make([]byte, in.flap.PayloadLength)
-			if _, err := r.Read(buf); err != nil {
-				errCh <- err
-				return
-			}
-			in.payload = bytes.NewBuffer(buf)
-		}
-
-		msgCh <- in
-	}
-}
-
-// dispatchIncomingMessages receives incoming messages and sends them to the
-// appropriate message handler. Messages from the client are sent to the
-// router. Messages relayed from the user session are forwarded to the client.
-// This function ensures that the same sequence number is incremented for both
-// types of messages. The function terminates upon receiving a connection error
-// or when the session closes.
-//
-// todo: this method has too many params and should be folded into a new type
-func dispatchIncomingMessages(ctx context.Context, sess *state.Session, seq uint32, rw io.ReadWriter, logger *slog.Logger, router Router, sendSNAC snacSender, config config.Config) error {
-	// buffered so that the go routine has room to exit
-	msgCh := make(chan incomingMessage, 1)
-	readErrCh := make(chan error, 1)
-	go consumeFLAPFrames(rw, msgCh, readErrCh)
-
-	defer func() {
-		logger.InfoContext(ctx, "user disconnected")
-	}()
-
-	for {
-		select {
-		case m, ok := <-msgCh:
-			if !ok {
-				return nil
-			}
-			switch m.flap.FrameType {
-			case oscar.FLAPFrameData:
-				inFrame := oscar.SNACFrame{}
-				if err := oscar.Unmarshal(&inFrame, m.payload); err != nil {
-					return err
-				}
-				// route a client request to the appropriate service handler. the
-				// handler may write a response to the client connection.
-				if err := router.Route(ctx, sess, inFrame, m.payload, rw, &seq); err != nil {
-					logRequestError(ctx, logger, inFrame, err)
-					if errors.Is(err, ErrUnsupportedSubGroup) {
-						if err1 := sendInvalidSNACErr(inFrame, rw, &seq); err1 != nil {
-							return errors.Join(err1, err)
-						}
-						if config.FailFast {
-							panic(err.Error())
-						}
-						break
-					}
-					return err
-				}
-			case oscar.FLAPFrameSignon:
-				return fmt.Errorf("shouldn't get FLAPFrameSignon. flap: %v", m.flap)
-			case oscar.FLAPFrameError:
-				return fmt.Errorf("got FLAPFrameError. flap: %v", m.flap)
-			case oscar.FLAPFrameSignoff:
-				logger.InfoContext(ctx, "got FLAPFrameSignoff", "flap", m.flap)
-				return nil
-			case oscar.FLAPFrameKeepAlive:
-				logger.DebugContext(ctx, "keepalive heartbeat")
-			default:
-				return fmt.Errorf("got unknown FLAP frame type. flap: %v", m.flap)
-			}
-		case m := <-sess.ReceiveMessage():
-			// forward a notification sent from another client to this client
-			if err := sendSNAC(m.Frame, m.Body, &seq, rw); err != nil {
-				logRequestError(ctx, logger, m.Frame, err)
-				return err
-			}
-			logRequest(ctx, logger, m.Frame, m.Body)
-		case <-sess.Closed():
-			// gracefully disconnect so that the client does not try to
-			// reconnect when the connection closes.
-			flap := oscar.FLAPFrame{
-				StartMarker:   42,
-				FrameType:     oscar.FLAPFrameSignoff,
-				Sequence:      uint16(seq),
-				PayloadLength: uint16(0),
-			}
-			if err := oscar.Marshal(flap, rw); err != nil {
-				return fmt.Errorf("unable to gracefully disconnect user. %w", err)
-			}
-			return nil
-		case err := <-readErrCh:
-			if !errors.Is(io.EOF, err) {
-				logger.ErrorContext(ctx, "client disconnected with error", "err", err)
-			}
-			return nil
-		}
-	}
-}

+ 0 - 171
server/connection_test.go

@@ -1,171 +0,0 @@
-package server
-
-import (
-	"bufio"
-	"bytes"
-	"context"
-	"io"
-	"sync"
-	"testing"
-
-	"github.com/mk6i/retro-aim-server/config"
-	"github.com/mk6i/retro-aim-server/oscar"
-	"github.com/mk6i/retro-aim-server/state"
-	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/mock"
-)
-
-func TestHandleChatConnection_Notification(t *testing.T) {
-
-	ctx := context.Background()
-	cfg := config.Config{}
-	logger := NewLogger(cfg)
-
-	sessionManager := state.NewInMemorySessionManager(logger)
-	sess := sessionManager.AddSession("bob-sess-id", "bob")
-
-	msgIn := []oscar.SNACMessage{
-		{
-			Frame: oscar.SNACFrame{
-				FoodGroup: oscar.Chat,
-				SubGroup:  oscar.ChatUsersJoined,
-			},
-			Body: oscar.SNAC_0x0E_0x03_ChatUsersJoined{
-				Users: []oscar.TLVUserInfo{
-					sess.TLVUserInfo(),
-				},
-			},
-		},
-		{
-			Frame: oscar.SNACFrame{
-				FoodGroup: oscar.Chat,
-				SubGroup:  oscar.ChatUsersLeft,
-			},
-			Body: oscar.SNAC_0x0E_0x03_ChatUsersJoined{
-				Users: []oscar.TLVUserInfo{},
-			},
-		},
-	}
-
-	wg := sync.WaitGroup{}
-	wg.Add(len(msgIn))
-
-	var msgOut []oscar.SNACMessage
-	alertHandler := func(frame oscar.SNACFrame, body any, sequence *uint32, w io.Writer) error {
-		msgOut = append(msgOut, oscar.SNACMessage{
-			Frame: frame,
-			Body:  body,
-		})
-		wg.Done()
-		return nil
-	}
-
-	go func() {
-		wg.Wait()
-		sess.Close()
-	}()
-
-	pr, _ := io.Pipe()
-	rw := bufio.NewReadWriter(bufio.NewReader(pr), bufio.NewWriter(&bytes.Buffer{}))
-
-	for _, msg := range msgIn {
-		sessionManager.RelayToScreenName(ctx, "bob", msg)
-	}
-
-	assert.NoError(t, dispatchIncomingMessages(ctx, sess, uint32(0), rw, logger, nil, alertHandler, config.Config{}))
-
-	assert.Equal(t, msgIn, msgOut)
-}
-
-func TestHandleChatConnection_ClientRequestFLAP(t *testing.T) {
-
-	ctx := context.Background()
-	cfg := config.Config{}
-	logger := NewLogger(cfg)
-
-	sessionManager := state.NewInMemorySessionManager(logger)
-	sess := sessionManager.AddSession("bob-sess-id", "bob")
-
-	payloads := []oscar.SNACFrame{
-		{FoodGroup: oscar.ICBM, SubGroup: oscar.ICBMChannelMsgToClient},
-		{FoodGroup: oscar.ChatNav, SubGroup: oscar.ChatNavNavInfo},
-	}
-
-	pr, pw := io.Pipe()
-	_, pw2 := io.Pipe()
-	go func() {
-		for _, buf := range payloads {
-			var seq uint32
-			sendSNAC(buf, struct{}{}, &seq, pw)
-		}
-	}()
-
-	var msgOut []oscar.SNACFrame
-	wg := sync.WaitGroup{}
-	wg.Add(len(payloads))
-
-	router := newMockRouter(t)
-	router.EXPECT().
-		Route(mock.Anything, sess, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
-		Run(func(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, r io.Reader, w io.Writer, sequence *uint32) {
-			msgOut = append(msgOut, inFrame)
-			wg.Done()
-		}).Return(nil)
-
-	alertHandler := func(frame oscar.SNACFrame, body any, sequence *uint32, w io.Writer) error {
-		return nil
-	}
-
-	rw := bufio.NewReadWriter(bufio.NewReader(pr), bufio.NewWriter(pw2))
-
-	go func() {
-		wg.Wait()
-		pw.Close()
-	}()
-
-	assert.NoError(t, dispatchIncomingMessages(ctx, sess, uint32(0), rw, logger, router, alertHandler, config.Config{}))
-
-	assert.Equal(t, payloads, msgOut)
-}
-
-func TestHandleChatConnection_SessionClosed(t *testing.T) {
-
-	ctx := context.Background()
-	cfg := config.Config{}
-	logger := NewLogger(cfg)
-
-	sessionManager := state.NewInMemorySessionManager(logger)
-	sess := sessionManager.AddSession("bob-sess-id", "bob")
-
-	router := newMockRouter(t)
-	router.EXPECT().
-		Route(mock.Anything, sess, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
-		Run(func(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, r io.Reader, w io.Writer, sequence *uint32) {
-			t.Fatal("not expecting any output")
-		}).
-		Maybe().
-		Return(nil)
-
-	alertHandler := func(frame oscar.SNACFrame, body any, sequence *uint32, w io.Writer) error {
-		t.Fatal("not expecting any output")
-		return nil
-	}
-
-	pr1, _ := io.Pipe()
-	pr2, pw2 := io.Pipe()
-
-	in := struct {
-		io.Reader
-		io.Writer
-	}{
-		Reader: pr1,
-		Writer: pw2,
-	}
-	sess.Close()
-
-	go dispatchIncomingMessages(ctx, sess, 0, in, logger, router, alertHandler, config.Config{})
-
-	flap := oscar.FLAPFrame{}
-	assert.NoError(t, oscar.Unmarshal(&flap, pr2))
-	assert.Equal(t, oscar.FLAPFrameSignoff, flap.FrameType)
-}

+ 0 - 114
server/feedbag.go

@@ -1,114 +0,0 @@
-package server
-
-import (
-	"context"
-	"io"
-	"log/slog"
-
-	"github.com/mk6i/retro-aim-server/oscar"
-	"github.com/mk6i/retro-aim-server/state"
-)
-
-type FeedbagHandler interface {
-	DeleteItemHandler(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, inBody oscar.SNAC_0x13_0x0A_FeedbagDeleteItem) (oscar.SNACMessage, error)
-	InsertItemHandler(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, inBody oscar.SNAC_0x13_0x08_FeedbagInsertItem) (oscar.SNACMessage, error)
-	QueryHandler(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame) (oscar.SNACMessage, error)
-	QueryIfModifiedHandler(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, inBody oscar.SNAC_0x13_0x05_FeedbagQueryIfModified) (oscar.SNACMessage, error)
-	RightsQueryHandler(ctx context.Context, inFrame oscar.SNACFrame) oscar.SNACMessage
-	StartClusterHandler(ctx context.Context, inFrame oscar.SNACFrame, inBody oscar.SNAC_0x13_0x11_FeedbagStartCluster)
-	UpdateItemHandler(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, inBody oscar.SNAC_0x13_0x09_FeedbagUpdateItem) (oscar.SNACMessage, error)
-}
-
-func NewFeedbagRouter(logger *slog.Logger, handler FeedbagHandler) FeedbagRouter {
-	return FeedbagRouter{
-		FeedbagHandler: handler,
-		routeLogger: routeLogger{
-			Logger: logger,
-		},
-	}
-}
-
-type FeedbagRouter struct {
-	FeedbagHandler
-	routeLogger
-}
-
-func (rt FeedbagRouter) Route(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, r io.Reader, w io.Writer, sequence *uint32) error {
-	switch inFrame.SubGroup {
-	case oscar.FeedbagRightsQuery:
-		inBody := oscar.SNAC_0x13_0x02_FeedbagRightsQuery{}
-		if err := oscar.Unmarshal(&inBody, r); err != nil {
-			return err
-		}
-		outSNAC := rt.RightsQueryHandler(ctx, inFrame)
-		rt.logRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
-		return sendSNAC(outSNAC.Frame, outSNAC.Body, sequence, w)
-	case oscar.FeedbagQuery:
-		outSNAC, err := rt.QueryHandler(ctx, sess, inFrame)
-		if err != nil {
-			return err
-		}
-		rt.logRequest(ctx, inFrame, outSNAC)
-		return sendSNAC(outSNAC.Frame, outSNAC.Body, sequence, w)
-	case oscar.FeedbagQueryIfModified:
-		inBody := oscar.SNAC_0x13_0x05_FeedbagQueryIfModified{}
-		if err := oscar.Unmarshal(&inBody, r); err != nil {
-			return err
-		}
-		outSNAC, err := rt.QueryIfModifiedHandler(ctx, sess, inFrame, inBody)
-		if err != nil {
-			return err
-		}
-		rt.logRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
-		return sendSNAC(outSNAC.Frame, outSNAC.Body, sequence, w)
-	case oscar.FeedbagUse:
-		rt.logRequest(ctx, inFrame, nil)
-		return nil
-	case oscar.FeedbagInsertItem:
-		inBody := oscar.SNAC_0x13_0x08_FeedbagInsertItem{}
-		if err := oscar.Unmarshal(&inBody, r); err != nil {
-			return err
-		}
-		outSNAC, err := rt.InsertItemHandler(ctx, sess, inFrame, inBody)
-		if err != nil {
-			return err
-		}
-		rt.logRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
-		return sendSNAC(outSNAC.Frame, outSNAC.Body, sequence, w)
-	case oscar.FeedbagUpdateItem:
-		inBody := oscar.SNAC_0x13_0x09_FeedbagUpdateItem{}
-		if err := oscar.Unmarshal(&inBody, r); err != nil {
-			return err
-		}
-		outSNAC, err := rt.UpdateItemHandler(ctx, sess, inFrame, inBody)
-		if err != nil {
-			return err
-		}
-		rt.logRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
-		return sendSNAC(outSNAC.Frame, outSNAC.Body, sequence, w)
-	case oscar.FeedbagDeleteItem:
-		inBody := oscar.SNAC_0x13_0x0A_FeedbagDeleteItem{}
-		if err := oscar.Unmarshal(&inBody, r); err != nil {
-			return err
-		}
-		outSNAC, err := rt.DeleteItemHandler(ctx, sess, inFrame, inBody)
-		if err != nil {
-			return err
-		}
-		rt.logRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
-		return sendSNAC(outSNAC.Frame, outSNAC.Body, sequence, w)
-	case oscar.FeedbagStartCluster:
-		inBody := oscar.SNAC_0x13_0x11_FeedbagStartCluster{}
-		if err := oscar.Unmarshal(&inBody, r); err != nil {
-			return err
-		}
-		rt.StartClusterHandler(ctx, inFrame, inBody)
-		rt.logRequest(ctx, inFrame, inBody)
-		return nil
-	case oscar.FeedbagEndCluster:
-		rt.logRequest(ctx, inFrame, nil)
-		return nil
-	default:
-		return ErrUnsupportedSubGroup
-	}
-}

+ 0 - 415
server/feedbag_mock_test.go

@@ -1,415 +0,0 @@
-// Code generated by mockery v2.38.0. DO NOT EDIT.
-
-package server
-
-import (
-	context "context"
-
-	oscar "github.com/mk6i/retro-aim-server/oscar"
-	mock "github.com/stretchr/testify/mock"
-
-	state "github.com/mk6i/retro-aim-server/state"
-)
-
-// mockFeedbagHandler is an autogenerated mock type for the FeedbagHandler type
-type mockFeedbagHandler struct {
-	mock.Mock
-}
-
-type mockFeedbagHandler_Expecter struct {
-	mock *mock.Mock
-}
-
-func (_m *mockFeedbagHandler) EXPECT() *mockFeedbagHandler_Expecter {
-	return &mockFeedbagHandler_Expecter{mock: &_m.Mock}
-}
-
-// DeleteItemHandler provides a mock function with given fields: ctx, sess, inFrame, inBody
-func (_m *mockFeedbagHandler) DeleteItemHandler(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, inBody oscar.SNAC_0x13_0x0A_FeedbagDeleteItem) (oscar.SNACMessage, error) {
-	ret := _m.Called(ctx, sess, inFrame, inBody)
-
-	if len(ret) == 0 {
-		panic("no return value specified for DeleteItemHandler")
-	}
-
-	var r0 oscar.SNACMessage
-	var r1 error
-	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, oscar.SNACFrame, oscar.SNAC_0x13_0x0A_FeedbagDeleteItem) (oscar.SNACMessage, error)); ok {
-		return rf(ctx, sess, inFrame, inBody)
-	}
-	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, oscar.SNACFrame, oscar.SNAC_0x13_0x0A_FeedbagDeleteItem) oscar.SNACMessage); ok {
-		r0 = rf(ctx, sess, inFrame, inBody)
-	} else {
-		r0 = ret.Get(0).(oscar.SNACMessage)
-	}
-
-	if rf, ok := ret.Get(1).(func(context.Context, *state.Session, oscar.SNACFrame, oscar.SNAC_0x13_0x0A_FeedbagDeleteItem) error); ok {
-		r1 = rf(ctx, sess, inFrame, inBody)
-	} else {
-		r1 = ret.Error(1)
-	}
-
-	return r0, r1
-}
-
-// mockFeedbagHandler_DeleteItemHandler_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteItemHandler'
-type mockFeedbagHandler_DeleteItemHandler_Call struct {
-	*mock.Call
-}
-
-// DeleteItemHandler is a helper method to define mock.On call
-//   - ctx context.Context
-//   - sess *state.Session
-//   - inFrame oscar.SNACFrame
-//   - inBody oscar.SNAC_0x13_0x0A_FeedbagDeleteItem
-func (_e *mockFeedbagHandler_Expecter) DeleteItemHandler(ctx interface{}, sess interface{}, inFrame interface{}, inBody interface{}) *mockFeedbagHandler_DeleteItemHandler_Call {
-	return &mockFeedbagHandler_DeleteItemHandler_Call{Call: _e.mock.On("DeleteItemHandler", ctx, sess, inFrame, inBody)}
-}
-
-func (_c *mockFeedbagHandler_DeleteItemHandler_Call) Run(run func(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, inBody oscar.SNAC_0x13_0x0A_FeedbagDeleteItem)) *mockFeedbagHandler_DeleteItemHandler_Call {
-	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(context.Context), args[1].(*state.Session), args[2].(oscar.SNACFrame), args[3].(oscar.SNAC_0x13_0x0A_FeedbagDeleteItem))
-	})
-	return _c
-}
-
-func (_c *mockFeedbagHandler_DeleteItemHandler_Call) Return(_a0 oscar.SNACMessage, _a1 error) *mockFeedbagHandler_DeleteItemHandler_Call {
-	_c.Call.Return(_a0, _a1)
-	return _c
-}
-
-func (_c *mockFeedbagHandler_DeleteItemHandler_Call) RunAndReturn(run func(context.Context, *state.Session, oscar.SNACFrame, oscar.SNAC_0x13_0x0A_FeedbagDeleteItem) (oscar.SNACMessage, error)) *mockFeedbagHandler_DeleteItemHandler_Call {
-	_c.Call.Return(run)
-	return _c
-}
-
-// InsertItemHandler provides a mock function with given fields: ctx, sess, inFrame, inBody
-func (_m *mockFeedbagHandler) InsertItemHandler(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, inBody oscar.SNAC_0x13_0x08_FeedbagInsertItem) (oscar.SNACMessage, error) {
-	ret := _m.Called(ctx, sess, inFrame, inBody)
-
-	if len(ret) == 0 {
-		panic("no return value specified for InsertItemHandler")
-	}
-
-	var r0 oscar.SNACMessage
-	var r1 error
-	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, oscar.SNACFrame, oscar.SNAC_0x13_0x08_FeedbagInsertItem) (oscar.SNACMessage, error)); ok {
-		return rf(ctx, sess, inFrame, inBody)
-	}
-	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, oscar.SNACFrame, oscar.SNAC_0x13_0x08_FeedbagInsertItem) oscar.SNACMessage); ok {
-		r0 = rf(ctx, sess, inFrame, inBody)
-	} else {
-		r0 = ret.Get(0).(oscar.SNACMessage)
-	}
-
-	if rf, ok := ret.Get(1).(func(context.Context, *state.Session, oscar.SNACFrame, oscar.SNAC_0x13_0x08_FeedbagInsertItem) error); ok {
-		r1 = rf(ctx, sess, inFrame, inBody)
-	} else {
-		r1 = ret.Error(1)
-	}
-
-	return r0, r1
-}
-
-// mockFeedbagHandler_InsertItemHandler_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'InsertItemHandler'
-type mockFeedbagHandler_InsertItemHandler_Call struct {
-	*mock.Call
-}
-
-// InsertItemHandler is a helper method to define mock.On call
-//   - ctx context.Context
-//   - sess *state.Session
-//   - inFrame oscar.SNACFrame
-//   - inBody oscar.SNAC_0x13_0x08_FeedbagInsertItem
-func (_e *mockFeedbagHandler_Expecter) InsertItemHandler(ctx interface{}, sess interface{}, inFrame interface{}, inBody interface{}) *mockFeedbagHandler_InsertItemHandler_Call {
-	return &mockFeedbagHandler_InsertItemHandler_Call{Call: _e.mock.On("InsertItemHandler", ctx, sess, inFrame, inBody)}
-}
-
-func (_c *mockFeedbagHandler_InsertItemHandler_Call) Run(run func(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, inBody oscar.SNAC_0x13_0x08_FeedbagInsertItem)) *mockFeedbagHandler_InsertItemHandler_Call {
-	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(context.Context), args[1].(*state.Session), args[2].(oscar.SNACFrame), args[3].(oscar.SNAC_0x13_0x08_FeedbagInsertItem))
-	})
-	return _c
-}
-
-func (_c *mockFeedbagHandler_InsertItemHandler_Call) Return(_a0 oscar.SNACMessage, _a1 error) *mockFeedbagHandler_InsertItemHandler_Call {
-	_c.Call.Return(_a0, _a1)
-	return _c
-}
-
-func (_c *mockFeedbagHandler_InsertItemHandler_Call) RunAndReturn(run func(context.Context, *state.Session, oscar.SNACFrame, oscar.SNAC_0x13_0x08_FeedbagInsertItem) (oscar.SNACMessage, error)) *mockFeedbagHandler_InsertItemHandler_Call {
-	_c.Call.Return(run)
-	return _c
-}
-
-// QueryHandler provides a mock function with given fields: ctx, sess, inFrame
-func (_m *mockFeedbagHandler) QueryHandler(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame) (oscar.SNACMessage, error) {
-	ret := _m.Called(ctx, sess, inFrame)
-
-	if len(ret) == 0 {
-		panic("no return value specified for QueryHandler")
-	}
-
-	var r0 oscar.SNACMessage
-	var r1 error
-	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, oscar.SNACFrame) (oscar.SNACMessage, error)); ok {
-		return rf(ctx, sess, inFrame)
-	}
-	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, oscar.SNACFrame) oscar.SNACMessage); ok {
-		r0 = rf(ctx, sess, inFrame)
-	} else {
-		r0 = ret.Get(0).(oscar.SNACMessage)
-	}
-
-	if rf, ok := ret.Get(1).(func(context.Context, *state.Session, oscar.SNACFrame) error); ok {
-		r1 = rf(ctx, sess, inFrame)
-	} else {
-		r1 = ret.Error(1)
-	}
-
-	return r0, r1
-}
-
-// mockFeedbagHandler_QueryHandler_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'QueryHandler'
-type mockFeedbagHandler_QueryHandler_Call struct {
-	*mock.Call
-}
-
-// QueryHandler is a helper method to define mock.On call
-//   - ctx context.Context
-//   - sess *state.Session
-//   - inFrame oscar.SNACFrame
-func (_e *mockFeedbagHandler_Expecter) QueryHandler(ctx interface{}, sess interface{}, inFrame interface{}) *mockFeedbagHandler_QueryHandler_Call {
-	return &mockFeedbagHandler_QueryHandler_Call{Call: _e.mock.On("QueryHandler", ctx, sess, inFrame)}
-}
-
-func (_c *mockFeedbagHandler_QueryHandler_Call) Run(run func(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame)) *mockFeedbagHandler_QueryHandler_Call {
-	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(context.Context), args[1].(*state.Session), args[2].(oscar.SNACFrame))
-	})
-	return _c
-}
-
-func (_c *mockFeedbagHandler_QueryHandler_Call) Return(_a0 oscar.SNACMessage, _a1 error) *mockFeedbagHandler_QueryHandler_Call {
-	_c.Call.Return(_a0, _a1)
-	return _c
-}
-
-func (_c *mockFeedbagHandler_QueryHandler_Call) RunAndReturn(run func(context.Context, *state.Session, oscar.SNACFrame) (oscar.SNACMessage, error)) *mockFeedbagHandler_QueryHandler_Call {
-	_c.Call.Return(run)
-	return _c
-}
-
-// QueryIfModifiedHandler provides a mock function with given fields: ctx, sess, inFrame, inBody
-func (_m *mockFeedbagHandler) QueryIfModifiedHandler(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, inBody oscar.SNAC_0x13_0x05_FeedbagQueryIfModified) (oscar.SNACMessage, error) {
-	ret := _m.Called(ctx, sess, inFrame, inBody)
-
-	if len(ret) == 0 {
-		panic("no return value specified for QueryIfModifiedHandler")
-	}
-
-	var r0 oscar.SNACMessage
-	var r1 error
-	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, oscar.SNACFrame, oscar.SNAC_0x13_0x05_FeedbagQueryIfModified) (oscar.SNACMessage, error)); ok {
-		return rf(ctx, sess, inFrame, inBody)
-	}
-	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, oscar.SNACFrame, oscar.SNAC_0x13_0x05_FeedbagQueryIfModified) oscar.SNACMessage); ok {
-		r0 = rf(ctx, sess, inFrame, inBody)
-	} else {
-		r0 = ret.Get(0).(oscar.SNACMessage)
-	}
-
-	if rf, ok := ret.Get(1).(func(context.Context, *state.Session, oscar.SNACFrame, oscar.SNAC_0x13_0x05_FeedbagQueryIfModified) error); ok {
-		r1 = rf(ctx, sess, inFrame, inBody)
-	} else {
-		r1 = ret.Error(1)
-	}
-
-	return r0, r1
-}
-
-// mockFeedbagHandler_QueryIfModifiedHandler_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'QueryIfModifiedHandler'
-type mockFeedbagHandler_QueryIfModifiedHandler_Call struct {
-	*mock.Call
-}
-
-// QueryIfModifiedHandler is a helper method to define mock.On call
-//   - ctx context.Context
-//   - sess *state.Session
-//   - inFrame oscar.SNACFrame
-//   - inBody oscar.SNAC_0x13_0x05_FeedbagQueryIfModified
-func (_e *mockFeedbagHandler_Expecter) QueryIfModifiedHandler(ctx interface{}, sess interface{}, inFrame interface{}, inBody interface{}) *mockFeedbagHandler_QueryIfModifiedHandler_Call {
-	return &mockFeedbagHandler_QueryIfModifiedHandler_Call{Call: _e.mock.On("QueryIfModifiedHandler", ctx, sess, inFrame, inBody)}
-}
-
-func (_c *mockFeedbagHandler_QueryIfModifiedHandler_Call) Run(run func(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, inBody oscar.SNAC_0x13_0x05_FeedbagQueryIfModified)) *mockFeedbagHandler_QueryIfModifiedHandler_Call {
-	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(context.Context), args[1].(*state.Session), args[2].(oscar.SNACFrame), args[3].(oscar.SNAC_0x13_0x05_FeedbagQueryIfModified))
-	})
-	return _c
-}
-
-func (_c *mockFeedbagHandler_QueryIfModifiedHandler_Call) Return(_a0 oscar.SNACMessage, _a1 error) *mockFeedbagHandler_QueryIfModifiedHandler_Call {
-	_c.Call.Return(_a0, _a1)
-	return _c
-}
-
-func (_c *mockFeedbagHandler_QueryIfModifiedHandler_Call) RunAndReturn(run func(context.Context, *state.Session, oscar.SNACFrame, oscar.SNAC_0x13_0x05_FeedbagQueryIfModified) (oscar.SNACMessage, error)) *mockFeedbagHandler_QueryIfModifiedHandler_Call {
-	_c.Call.Return(run)
-	return _c
-}
-
-// RightsQueryHandler provides a mock function with given fields: ctx, inFrame
-func (_m *mockFeedbagHandler) RightsQueryHandler(ctx context.Context, inFrame oscar.SNACFrame) oscar.SNACMessage {
-	ret := _m.Called(ctx, inFrame)
-
-	if len(ret) == 0 {
-		panic("no return value specified for RightsQueryHandler")
-	}
-
-	var r0 oscar.SNACMessage
-	if rf, ok := ret.Get(0).(func(context.Context, oscar.SNACFrame) oscar.SNACMessage); ok {
-		r0 = rf(ctx, inFrame)
-	} else {
-		r0 = ret.Get(0).(oscar.SNACMessage)
-	}
-
-	return r0
-}
-
-// mockFeedbagHandler_RightsQueryHandler_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RightsQueryHandler'
-type mockFeedbagHandler_RightsQueryHandler_Call struct {
-	*mock.Call
-}
-
-// RightsQueryHandler is a helper method to define mock.On call
-//   - ctx context.Context
-//   - inFrame oscar.SNACFrame
-func (_e *mockFeedbagHandler_Expecter) RightsQueryHandler(ctx interface{}, inFrame interface{}) *mockFeedbagHandler_RightsQueryHandler_Call {
-	return &mockFeedbagHandler_RightsQueryHandler_Call{Call: _e.mock.On("RightsQueryHandler", ctx, inFrame)}
-}
-
-func (_c *mockFeedbagHandler_RightsQueryHandler_Call) Run(run func(ctx context.Context, inFrame oscar.SNACFrame)) *mockFeedbagHandler_RightsQueryHandler_Call {
-	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(context.Context), args[1].(oscar.SNACFrame))
-	})
-	return _c
-}
-
-func (_c *mockFeedbagHandler_RightsQueryHandler_Call) Return(_a0 oscar.SNACMessage) *mockFeedbagHandler_RightsQueryHandler_Call {
-	_c.Call.Return(_a0)
-	return _c
-}
-
-func (_c *mockFeedbagHandler_RightsQueryHandler_Call) RunAndReturn(run func(context.Context, oscar.SNACFrame) oscar.SNACMessage) *mockFeedbagHandler_RightsQueryHandler_Call {
-	_c.Call.Return(run)
-	return _c
-}
-
-// StartClusterHandler provides a mock function with given fields: ctx, inFrame, inBody
-func (_m *mockFeedbagHandler) StartClusterHandler(ctx context.Context, inFrame oscar.SNACFrame, inBody oscar.SNAC_0x13_0x11_FeedbagStartCluster) {
-	_m.Called(ctx, inFrame, inBody)
-}
-
-// mockFeedbagHandler_StartClusterHandler_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StartClusterHandler'
-type mockFeedbagHandler_StartClusterHandler_Call struct {
-	*mock.Call
-}
-
-// StartClusterHandler is a helper method to define mock.On call
-//   - ctx context.Context
-//   - inFrame oscar.SNACFrame
-//   - inBody oscar.SNAC_0x13_0x11_FeedbagStartCluster
-func (_e *mockFeedbagHandler_Expecter) StartClusterHandler(ctx interface{}, inFrame interface{}, inBody interface{}) *mockFeedbagHandler_StartClusterHandler_Call {
-	return &mockFeedbagHandler_StartClusterHandler_Call{Call: _e.mock.On("StartClusterHandler", ctx, inFrame, inBody)}
-}
-
-func (_c *mockFeedbagHandler_StartClusterHandler_Call) Run(run func(ctx context.Context, inFrame oscar.SNACFrame, inBody oscar.SNAC_0x13_0x11_FeedbagStartCluster)) *mockFeedbagHandler_StartClusterHandler_Call {
-	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(context.Context), args[1].(oscar.SNACFrame), args[2].(oscar.SNAC_0x13_0x11_FeedbagStartCluster))
-	})
-	return _c
-}
-
-func (_c *mockFeedbagHandler_StartClusterHandler_Call) Return() *mockFeedbagHandler_StartClusterHandler_Call {
-	_c.Call.Return()
-	return _c
-}
-
-func (_c *mockFeedbagHandler_StartClusterHandler_Call) RunAndReturn(run func(context.Context, oscar.SNACFrame, oscar.SNAC_0x13_0x11_FeedbagStartCluster)) *mockFeedbagHandler_StartClusterHandler_Call {
-	_c.Call.Return(run)
-	return _c
-}
-
-// UpdateItemHandler provides a mock function with given fields: ctx, sess, inFrame, inBody
-func (_m *mockFeedbagHandler) UpdateItemHandler(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, inBody oscar.SNAC_0x13_0x09_FeedbagUpdateItem) (oscar.SNACMessage, error) {
-	ret := _m.Called(ctx, sess, inFrame, inBody)
-
-	if len(ret) == 0 {
-		panic("no return value specified for UpdateItemHandler")
-	}
-
-	var r0 oscar.SNACMessage
-	var r1 error
-	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, oscar.SNACFrame, oscar.SNAC_0x13_0x09_FeedbagUpdateItem) (oscar.SNACMessage, error)); ok {
-		return rf(ctx, sess, inFrame, inBody)
-	}
-	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, oscar.SNACFrame, oscar.SNAC_0x13_0x09_FeedbagUpdateItem) oscar.SNACMessage); ok {
-		r0 = rf(ctx, sess, inFrame, inBody)
-	} else {
-		r0 = ret.Get(0).(oscar.SNACMessage)
-	}
-
-	if rf, ok := ret.Get(1).(func(context.Context, *state.Session, oscar.SNACFrame, oscar.SNAC_0x13_0x09_FeedbagUpdateItem) error); ok {
-		r1 = rf(ctx, sess, inFrame, inBody)
-	} else {
-		r1 = ret.Error(1)
-	}
-
-	return r0, r1
-}
-
-// mockFeedbagHandler_UpdateItemHandler_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateItemHandler'
-type mockFeedbagHandler_UpdateItemHandler_Call struct {
-	*mock.Call
-}
-
-// UpdateItemHandler is a helper method to define mock.On call
-//   - ctx context.Context
-//   - sess *state.Session
-//   - inFrame oscar.SNACFrame
-//   - inBody oscar.SNAC_0x13_0x09_FeedbagUpdateItem
-func (_e *mockFeedbagHandler_Expecter) UpdateItemHandler(ctx interface{}, sess interface{}, inFrame interface{}, inBody interface{}) *mockFeedbagHandler_UpdateItemHandler_Call {
-	return &mockFeedbagHandler_UpdateItemHandler_Call{Call: _e.mock.On("UpdateItemHandler", ctx, sess, inFrame, inBody)}
-}
-
-func (_c *mockFeedbagHandler_UpdateItemHandler_Call) Run(run func(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, inBody oscar.SNAC_0x13_0x09_FeedbagUpdateItem)) *mockFeedbagHandler_UpdateItemHandler_Call {
-	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(context.Context), args[1].(*state.Session), args[2].(oscar.SNACFrame), args[3].(oscar.SNAC_0x13_0x09_FeedbagUpdateItem))
-	})
-	return _c
-}
-
-func (_c *mockFeedbagHandler_UpdateItemHandler_Call) Return(_a0 oscar.SNACMessage, _a1 error) *mockFeedbagHandler_UpdateItemHandler_Call {
-	_c.Call.Return(_a0, _a1)
-	return _c
-}
-
-func (_c *mockFeedbagHandler_UpdateItemHandler_Call) RunAndReturn(run func(context.Context, *state.Session, oscar.SNACFrame, oscar.SNAC_0x13_0x09_FeedbagUpdateItem) (oscar.SNACMessage, error)) *mockFeedbagHandler_UpdateItemHandler_Call {
-	_c.Call.Return(run)
-	return _c
-}
-
-// newMockFeedbagHandler creates a new instance of mockFeedbagHandler. 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 newMockFeedbagHandler(t interface {
-	mock.TestingT
-	Cleanup(func())
-}) *mockFeedbagHandler {
-	mock := &mockFeedbagHandler{}
-	mock.Mock.Test(t)
-
-	t.Cleanup(func() { mock.AssertExpectations(t) })
-
-	return mock
-}

+ 0 - 312
server/feedbag_test.go

@@ -1,312 +0,0 @@
-package server
-
-import (
-	"bytes"
-	"testing"
-
-	"github.com/mk6i/retro-aim-server/config"
-	"github.com/mk6i/retro-aim-server/oscar"
-	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/mock"
-)
-
-func TestFeedbagRouter_RouteFeedbag(t *testing.T) {
-	cases := []struct {
-		// name is the unit test name
-		name string
-		// input is the request payload
-		input oscar.SNACMessage
-		// output is the response payload
-		output oscar.SNACMessage
-		// handlerErr is the mocked handler error response
-		handlerErr error
-		// expectErr is the expected error returned by the router
-		expectErr error
-	}{
-		{
-			name: "receive FeedbagRightsQuery, return FeedbagRightsReply",
-			input: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.Feedbag,
-					SubGroup:  oscar.FeedbagRightsQuery,
-				},
-				Body: oscar.SNAC_0x13_0x02_FeedbagRightsQuery{
-					TLVRestBlock: oscar.TLVRestBlock{
-						TLVList: oscar.TLVList{
-							{
-								Tag:   0x01,
-								Value: []byte{1, 2, 3, 4},
-							},
-						},
-					},
-				},
-			},
-			output: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.Feedbag,
-					SubGroup:  oscar.FeedbagRightsReply,
-				},
-				Body: oscar.SNAC_0x13_0x03_FeedbagRightsReply{
-					TLVRestBlock: oscar.TLVRestBlock{
-						TLVList: oscar.TLVList{
-							{
-								Tag:   0x01,
-								Value: []byte{1, 2, 3, 4},
-							},
-						},
-					},
-				},
-			},
-		},
-		{
-			name: "receive FeedbagQuery, return FeedbagReply",
-			input: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.Feedbag,
-					SubGroup:  oscar.FeedbagQuery,
-				},
-				Body: oscar.SNAC_0x13_0x02_FeedbagRightsQuery{
-					TLVRestBlock: oscar.TLVRestBlock{
-						TLVList: oscar.TLVList{
-							{
-								Tag:   0x01,
-								Value: []byte{1, 2, 3, 4},
-							},
-						},
-					},
-				},
-			},
-			output: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.Feedbag,
-					SubGroup:  oscar.FeedbagReply,
-				},
-				Body: oscar.SNAC_0x13_0x06_FeedbagReply{
-					Version: 4,
-				},
-			},
-		},
-		{
-			name: "receive FeedbagQueryIfModified, return FeedbagRightsReply",
-			input: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.Feedbag,
-					SubGroup:  oscar.FeedbagQueryIfModified,
-				},
-				Body: oscar.SNAC_0x13_0x05_FeedbagQueryIfModified{
-					LastUpdate: 1234,
-				},
-			},
-			output: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.Feedbag,
-					SubGroup:  oscar.FeedbagReply,
-				},
-				Body: oscar.SNAC_0x13_0x06_FeedbagReply{
-					LastUpdate: 1234,
-				},
-			},
-		},
-		{
-			name: "receive FeedbagUse, return no response",
-			input: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.Feedbag,
-					SubGroup:  oscar.FeedbagUse,
-				},
-				Body: struct{}{},
-			},
-			output: oscar.SNACMessage{},
-		},
-		{
-			name: "receive FeedbagInsertItem, return FeedbagStatus",
-			input: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.Feedbag,
-					SubGroup:  oscar.FeedbagInsertItem,
-				},
-				Body: oscar.SNAC_0x13_0x08_FeedbagInsertItem{
-					Items: []oscar.FeedbagItem{
-						{
-							Name: "my-item",
-						},
-					},
-				},
-			},
-			output: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.Feedbag,
-					SubGroup:  oscar.FeedbagStatus,
-				},
-				Body: oscar.SNAC_0x13_0x0E_FeedbagStatus{
-					Results: []uint16{1234},
-				},
-			},
-		},
-		{
-			name: "receive FeedbagUpdateItem, return FeedbagStatus",
-			input: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.Feedbag,
-					SubGroup:  oscar.FeedbagUpdateItem,
-				},
-				Body: oscar.SNAC_0x13_0x09_FeedbagUpdateItem{
-					Items: []oscar.FeedbagItem{
-						{
-							Name: "my-item",
-						},
-					},
-				},
-			},
-			output: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.Feedbag,
-					SubGroup:  oscar.FeedbagStatus,
-				},
-				Body: oscar.SNAC_0x13_0x0E_FeedbagStatus{
-					Results: []uint16{1234},
-				},
-			},
-		},
-		{
-			name: "receive FeedbagDeleteItem, return FeedbagStatus",
-			input: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.Feedbag,
-					SubGroup:  oscar.FeedbagDeleteItem,
-				},
-				Body: oscar.SNAC_0x13_0x0A_FeedbagDeleteItem{
-					Items: []oscar.FeedbagItem{
-						{
-							Name: "my-item",
-						},
-					},
-				},
-			},
-			output: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.Feedbag,
-					SubGroup:  oscar.FeedbagStatus,
-				},
-				Body: oscar.SNAC_0x13_0x0E_FeedbagStatus{
-					Results: []uint16{1234},
-				},
-			},
-		},
-		{
-			name: "receive FeedbagStartCluster, return no response",
-			input: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.Feedbag,
-					SubGroup:  oscar.FeedbagStartCluster,
-				},
-				Body: oscar.SNAC_0x13_0x11_FeedbagStartCluster{
-					TLVRestBlock: oscar.TLVRestBlock{
-						TLVList: oscar.TLVList{
-							{
-								Tag:   0x01,
-								Value: []byte{1, 2, 3, 4},
-							},
-						},
-					},
-				},
-			},
-			output: oscar.SNACMessage{},
-		},
-		{
-			name: "receive FeedbagEndCluster, return no response",
-			input: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.Feedbag,
-					SubGroup:  oscar.FeedbagEndCluster,
-				},
-				Body: struct{}{},
-			},
-			output: oscar.SNACMessage{},
-		},
-		{
-			name: "receive FeedbagDeleteUser, return ErrUnsupportedSubGroup",
-			input: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.Feedbag,
-					SubGroup:  oscar.FeedbagDeleteUser,
-				},
-				Body: struct{}{},
-			},
-			output:    oscar.SNACMessage{},
-			expectErr: ErrUnsupportedSubGroup,
-		},
-	}
-
-	for _, tc := range cases {
-		t.Run(tc.name, func(t *testing.T) {
-			svc := newMockFeedbagHandler(t)
-			svc.EXPECT().
-				DeleteItemHandler(mock.Anything, mock.Anything, tc.input.Frame, tc.input.Body).
-				Return(tc.output, tc.handlerErr).
-				Maybe()
-			svc.EXPECT().
-				QueryHandler(mock.Anything, mock.Anything, tc.input.Frame).
-				Return(tc.output, tc.handlerErr).
-				Maybe()
-			svc.EXPECT().
-				QueryIfModifiedHandler(mock.Anything, mock.Anything, tc.input.Frame, tc.input.Body).
-				Return(tc.output, tc.handlerErr).
-				Maybe()
-			svc.EXPECT().
-				RightsQueryHandler(mock.Anything, tc.input.Frame).
-				Return(tc.output).
-				Maybe()
-			svc.EXPECT().
-				InsertItemHandler(mock.Anything, mock.Anything, tc.input.Frame, tc.input.Body).
-				Return(tc.output, tc.handlerErr).
-				Maybe()
-			svc.EXPECT().
-				UpdateItemHandler(mock.Anything, mock.Anything, tc.input.Frame, tc.input.Body).
-				Return(tc.output, tc.handlerErr).
-				Maybe()
-			svc.EXPECT().
-				StartClusterHandler(mock.Anything, tc.input.Frame, tc.input.Body).
-				Maybe()
-
-			router := NewFeedbagRouter(NewLogger(config.Config{}), svc)
-
-			bufIn := &bytes.Buffer{}
-			assert.NoError(t, oscar.Marshal(tc.input.Body, bufIn))
-
-			bufOut := &bytes.Buffer{}
-			seq := uint32(0)
-
-			err := router.Route(nil, nil, tc.input.Frame, bufIn, bufOut, &seq)
-			assert.ErrorIs(t, err, tc.expectErr)
-			if tc.expectErr != nil {
-				return
-			}
-
-			if tc.output.Frame == (oscar.SNACFrame{}) {
-				return
-			}
-
-			// verify the FLAP frame
-			flap := oscar.FLAPFrame{}
-			assert.NoError(t, oscar.Unmarshal(&flap, bufOut))
-
-			// make sure the sequence increments
-			assert.Equal(t, seq, uint32(1))
-			assert.Equal(t, flap.Sequence, uint16(0))
-
-			flapBuf, err := flap.ReadBody(bufOut)
-			assert.NoError(t, err)
-
-			// verify the SNAC frame
-			snacFrame := oscar.SNACFrame{}
-			assert.NoError(t, oscar.Unmarshal(&snacFrame, flapBuf))
-			assert.Equal(t, tc.output.Frame, snacFrame)
-
-			// verify the SNAC message
-			snacBuf := &bytes.Buffer{}
-			assert.NoError(t, oscar.Marshal(tc.output.Body, snacBuf))
-			assert.Equal(t, snacBuf.Bytes(), flapBuf.Bytes())
-		})
-	}
-}

+ 4 - 3
server/mgmt_api.go → server/http/mgmt_api.go

@@ -1,4 +1,4 @@
-package server
+package http
 
 import (
 	"encoding/json"
@@ -7,9 +7,10 @@ import (
 	"net/http"
 	"os"
 
-	"github.com/google/uuid"
 	"github.com/mk6i/retro-aim-server/config"
 	"github.com/mk6i/retro-aim-server/state"
+
+	"github.com/google/uuid"
 )
 
 type UserManager interface {
@@ -51,7 +52,7 @@ func (uh userHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 }
 
 // getUsers handles the GET /user endpoint.
-func (uh userHandler) getUsers(w http.ResponseWriter, r *http.Request) {
+func (uh userHandler) getUsers(w http.ResponseWriter, _ *http.Request) {
 	w.Header().Set("Content-Type", "application/json")
 	users, err := uh.AllUsers()
 	if err != nil {

+ 2 - 1
server/mgmt_api_test.go → server/http/mgmt_api_test.go

@@ -1,4 +1,4 @@
-package server
+package http
 
 import (
 	"io"
@@ -9,6 +9,7 @@ import (
 	"testing"
 
 	"github.com/mk6i/retro-aim-server/state"
+
 	"github.com/stretchr/testify/mock"
 )
 

+ 2 - 2
server/user_manager_mock_test.go → server/http/mock_user_manager_test.go

@@ -1,6 +1,6 @@
-// Code generated by mockery v2.38.0. DO NOT EDIT.
+// Code generated by mockery v2.40.1. DO NOT EDIT.
 
-package server
+package http
 
 import (
 	state "github.com/mk6i/retro-aim-server/state"

+ 0 - 83
server/icbm.go

@@ -1,83 +0,0 @@
-package server
-
-import (
-	"context"
-	"io"
-	"log/slog"
-
-	"github.com/mk6i/retro-aim-server/oscar"
-	"github.com/mk6i/retro-aim-server/state"
-)
-
-type ICBMHandler interface {
-	ChannelMsgToHostHandler(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, inBody oscar.SNAC_0x04_0x06_ICBMChannelMsgToHost) (*oscar.SNACMessage, error)
-	ClientEventHandler(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, inBody oscar.SNAC_0x04_0x14_ICBMClientEvent) error
-	EvilRequestHandler(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, inBody oscar.SNAC_0x04_0x08_ICBMEvilRequest) (oscar.SNACMessage, error)
-	ParameterQueryHandler(ctx context.Context, inFrame oscar.SNACFrame) oscar.SNACMessage
-}
-
-func NewICBMRouter(logger *slog.Logger, handler ICBMHandler) ICBMRouter {
-	return ICBMRouter{
-		ICBMHandler: handler,
-		routeLogger: routeLogger{
-			Logger: logger,
-		},
-	}
-}
-
-type ICBMRouter struct {
-	ICBMHandler
-	routeLogger
-}
-
-func (rt ICBMRouter) Route(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, r io.Reader, w io.Writer, sequence *uint32) error {
-	switch inFrame.SubGroup {
-	case oscar.ICBMAddParameters:
-		inBody := oscar.SNAC_0x04_0x02_ICBMAddParameters{}
-		rt.logRequest(ctx, inFrame, inBody)
-		return oscar.Unmarshal(&inBody, r)
-	case oscar.ICBMParameterQuery:
-		outSNAC := rt.ParameterQueryHandler(ctx, inFrame)
-		rt.logRequestAndResponse(ctx, inFrame, outSNAC, outSNAC.Frame, outSNAC.Body)
-		return sendSNAC(outSNAC.Frame, outSNAC.Body, sequence, w)
-	case oscar.ICBMChannelMsgToHost:
-		inBody := oscar.SNAC_0x04_0x06_ICBMChannelMsgToHost{}
-		if err := oscar.Unmarshal(&inBody, r); err != nil {
-			return err
-		}
-		outSNAC, err := rt.ChannelMsgToHostHandler(ctx, sess, inFrame, inBody)
-		if err != nil {
-			return err
-		}
-		rt.Logger.InfoContext(ctx, "user sent an IM", slog.String("recipient", inBody.ScreenName))
-		if outSNAC == nil {
-			return nil
-		}
-		rt.logRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
-		return sendSNAC(outSNAC.Frame, outSNAC.Body, sequence, w)
-	case oscar.ICBMEvilRequest:
-		inBody := oscar.SNAC_0x04_0x08_ICBMEvilRequest{}
-		if err := oscar.Unmarshal(&inBody, r); err != nil {
-			return err
-		}
-		outSNAC, err := rt.EvilRequestHandler(ctx, sess, inFrame, inBody)
-		if err != nil {
-			return err
-		}
-		rt.logRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
-		return sendSNAC(outSNAC.Frame, outSNAC.Body, sequence, w)
-	case oscar.ICBMClientErr:
-		inBody := oscar.SNAC_0x04_0x0B_ICBMClientErr{}
-		rt.logRequest(ctx, inFrame, inBody)
-		return oscar.Unmarshal(&inBody, r)
-	case oscar.ICBMClientEvent:
-		inBody := oscar.SNAC_0x04_0x14_ICBMClientEvent{}
-		if err := oscar.Unmarshal(&inBody, r); err != nil {
-			return err
-		}
-		rt.logRequest(ctx, inFrame, inBody)
-		return rt.ClientEventHandler(ctx, sess, inFrame, inBody)
-	default:
-		return ErrUnsupportedSubGroup
-	}
-}

+ 0 - 255
server/icbm_mock_test.go

@@ -1,255 +0,0 @@
-// Code generated by mockery v2.38.0. DO NOT EDIT.
-
-package server
-
-import (
-	context "context"
-
-	oscar "github.com/mk6i/retro-aim-server/oscar"
-	mock "github.com/stretchr/testify/mock"
-
-	state "github.com/mk6i/retro-aim-server/state"
-)
-
-// mockICBMHandler is an autogenerated mock type for the ICBMHandler type
-type mockICBMHandler struct {
-	mock.Mock
-}
-
-type mockICBMHandler_Expecter struct {
-	mock *mock.Mock
-}
-
-func (_m *mockICBMHandler) EXPECT() *mockICBMHandler_Expecter {
-	return &mockICBMHandler_Expecter{mock: &_m.Mock}
-}
-
-// ChannelMsgToHostHandler provides a mock function with given fields: ctx, sess, inFrame, inBody
-func (_m *mockICBMHandler) ChannelMsgToHostHandler(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, inBody oscar.SNAC_0x04_0x06_ICBMChannelMsgToHost) (*oscar.SNACMessage, error) {
-	ret := _m.Called(ctx, sess, inFrame, inBody)
-
-	if len(ret) == 0 {
-		panic("no return value specified for ChannelMsgToHostHandler")
-	}
-
-	var r0 *oscar.SNACMessage
-	var r1 error
-	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, oscar.SNACFrame, oscar.SNAC_0x04_0x06_ICBMChannelMsgToHost) (*oscar.SNACMessage, error)); ok {
-		return rf(ctx, sess, inFrame, inBody)
-	}
-	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, oscar.SNACFrame, oscar.SNAC_0x04_0x06_ICBMChannelMsgToHost) *oscar.SNACMessage); ok {
-		r0 = rf(ctx, sess, inFrame, inBody)
-	} else {
-		if ret.Get(0) != nil {
-			r0 = ret.Get(0).(*oscar.SNACMessage)
-		}
-	}
-
-	if rf, ok := ret.Get(1).(func(context.Context, *state.Session, oscar.SNACFrame, oscar.SNAC_0x04_0x06_ICBMChannelMsgToHost) error); ok {
-		r1 = rf(ctx, sess, inFrame, inBody)
-	} else {
-		r1 = ret.Error(1)
-	}
-
-	return r0, r1
-}
-
-// mockICBMHandler_ChannelMsgToHostHandler_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ChannelMsgToHostHandler'
-type mockICBMHandler_ChannelMsgToHostHandler_Call struct {
-	*mock.Call
-}
-
-// ChannelMsgToHostHandler is a helper method to define mock.On call
-//   - ctx context.Context
-//   - sess *state.Session
-//   - inFrame oscar.SNACFrame
-//   - inBody oscar.SNAC_0x04_0x06_ICBMChannelMsgToHost
-func (_e *mockICBMHandler_Expecter) ChannelMsgToHostHandler(ctx interface{}, sess interface{}, inFrame interface{}, inBody interface{}) *mockICBMHandler_ChannelMsgToHostHandler_Call {
-	return &mockICBMHandler_ChannelMsgToHostHandler_Call{Call: _e.mock.On("ChannelMsgToHostHandler", ctx, sess, inFrame, inBody)}
-}
-
-func (_c *mockICBMHandler_ChannelMsgToHostHandler_Call) Run(run func(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, inBody oscar.SNAC_0x04_0x06_ICBMChannelMsgToHost)) *mockICBMHandler_ChannelMsgToHostHandler_Call {
-	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(context.Context), args[1].(*state.Session), args[2].(oscar.SNACFrame), args[3].(oscar.SNAC_0x04_0x06_ICBMChannelMsgToHost))
-	})
-	return _c
-}
-
-func (_c *mockICBMHandler_ChannelMsgToHostHandler_Call) Return(_a0 *oscar.SNACMessage, _a1 error) *mockICBMHandler_ChannelMsgToHostHandler_Call {
-	_c.Call.Return(_a0, _a1)
-	return _c
-}
-
-func (_c *mockICBMHandler_ChannelMsgToHostHandler_Call) RunAndReturn(run func(context.Context, *state.Session, oscar.SNACFrame, oscar.SNAC_0x04_0x06_ICBMChannelMsgToHost) (*oscar.SNACMessage, error)) *mockICBMHandler_ChannelMsgToHostHandler_Call {
-	_c.Call.Return(run)
-	return _c
-}
-
-// ClientEventHandler provides a mock function with given fields: ctx, sess, inFrame, inBody
-func (_m *mockICBMHandler) ClientEventHandler(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, inBody oscar.SNAC_0x04_0x14_ICBMClientEvent) error {
-	ret := _m.Called(ctx, sess, inFrame, inBody)
-
-	if len(ret) == 0 {
-		panic("no return value specified for ClientEventHandler")
-	}
-
-	var r0 error
-	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, oscar.SNACFrame, oscar.SNAC_0x04_0x14_ICBMClientEvent) error); ok {
-		r0 = rf(ctx, sess, inFrame, inBody)
-	} else {
-		r0 = ret.Error(0)
-	}
-
-	return r0
-}
-
-// mockICBMHandler_ClientEventHandler_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ClientEventHandler'
-type mockICBMHandler_ClientEventHandler_Call struct {
-	*mock.Call
-}
-
-// ClientEventHandler is a helper method to define mock.On call
-//   - ctx context.Context
-//   - sess *state.Session
-//   - inFrame oscar.SNACFrame
-//   - inBody oscar.SNAC_0x04_0x14_ICBMClientEvent
-func (_e *mockICBMHandler_Expecter) ClientEventHandler(ctx interface{}, sess interface{}, inFrame interface{}, inBody interface{}) *mockICBMHandler_ClientEventHandler_Call {
-	return &mockICBMHandler_ClientEventHandler_Call{Call: _e.mock.On("ClientEventHandler", ctx, sess, inFrame, inBody)}
-}
-
-func (_c *mockICBMHandler_ClientEventHandler_Call) Run(run func(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, inBody oscar.SNAC_0x04_0x14_ICBMClientEvent)) *mockICBMHandler_ClientEventHandler_Call {
-	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(context.Context), args[1].(*state.Session), args[2].(oscar.SNACFrame), args[3].(oscar.SNAC_0x04_0x14_ICBMClientEvent))
-	})
-	return _c
-}
-
-func (_c *mockICBMHandler_ClientEventHandler_Call) Return(_a0 error) *mockICBMHandler_ClientEventHandler_Call {
-	_c.Call.Return(_a0)
-	return _c
-}
-
-func (_c *mockICBMHandler_ClientEventHandler_Call) RunAndReturn(run func(context.Context, *state.Session, oscar.SNACFrame, oscar.SNAC_0x04_0x14_ICBMClientEvent) error) *mockICBMHandler_ClientEventHandler_Call {
-	_c.Call.Return(run)
-	return _c
-}
-
-// EvilRequestHandler provides a mock function with given fields: ctx, sess, inFrame, inBody
-func (_m *mockICBMHandler) EvilRequestHandler(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, inBody oscar.SNAC_0x04_0x08_ICBMEvilRequest) (oscar.SNACMessage, error) {
-	ret := _m.Called(ctx, sess, inFrame, inBody)
-
-	if len(ret) == 0 {
-		panic("no return value specified for EvilRequestHandler")
-	}
-
-	var r0 oscar.SNACMessage
-	var r1 error
-	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, oscar.SNACFrame, oscar.SNAC_0x04_0x08_ICBMEvilRequest) (oscar.SNACMessage, error)); ok {
-		return rf(ctx, sess, inFrame, inBody)
-	}
-	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, oscar.SNACFrame, oscar.SNAC_0x04_0x08_ICBMEvilRequest) oscar.SNACMessage); ok {
-		r0 = rf(ctx, sess, inFrame, inBody)
-	} else {
-		r0 = ret.Get(0).(oscar.SNACMessage)
-	}
-
-	if rf, ok := ret.Get(1).(func(context.Context, *state.Session, oscar.SNACFrame, oscar.SNAC_0x04_0x08_ICBMEvilRequest) error); ok {
-		r1 = rf(ctx, sess, inFrame, inBody)
-	} else {
-		r1 = ret.Error(1)
-	}
-
-	return r0, r1
-}
-
-// mockICBMHandler_EvilRequestHandler_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EvilRequestHandler'
-type mockICBMHandler_EvilRequestHandler_Call struct {
-	*mock.Call
-}
-
-// EvilRequestHandler is a helper method to define mock.On call
-//   - ctx context.Context
-//   - sess *state.Session
-//   - inFrame oscar.SNACFrame
-//   - inBody oscar.SNAC_0x04_0x08_ICBMEvilRequest
-func (_e *mockICBMHandler_Expecter) EvilRequestHandler(ctx interface{}, sess interface{}, inFrame interface{}, inBody interface{}) *mockICBMHandler_EvilRequestHandler_Call {
-	return &mockICBMHandler_EvilRequestHandler_Call{Call: _e.mock.On("EvilRequestHandler", ctx, sess, inFrame, inBody)}
-}
-
-func (_c *mockICBMHandler_EvilRequestHandler_Call) Run(run func(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, inBody oscar.SNAC_0x04_0x08_ICBMEvilRequest)) *mockICBMHandler_EvilRequestHandler_Call {
-	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(context.Context), args[1].(*state.Session), args[2].(oscar.SNACFrame), args[3].(oscar.SNAC_0x04_0x08_ICBMEvilRequest))
-	})
-	return _c
-}
-
-func (_c *mockICBMHandler_EvilRequestHandler_Call) Return(_a0 oscar.SNACMessage, _a1 error) *mockICBMHandler_EvilRequestHandler_Call {
-	_c.Call.Return(_a0, _a1)
-	return _c
-}
-
-func (_c *mockICBMHandler_EvilRequestHandler_Call) RunAndReturn(run func(context.Context, *state.Session, oscar.SNACFrame, oscar.SNAC_0x04_0x08_ICBMEvilRequest) (oscar.SNACMessage, error)) *mockICBMHandler_EvilRequestHandler_Call {
-	_c.Call.Return(run)
-	return _c
-}
-
-// ParameterQueryHandler provides a mock function with given fields: ctx, inFrame
-func (_m *mockICBMHandler) ParameterQueryHandler(ctx context.Context, inFrame oscar.SNACFrame) oscar.SNACMessage {
-	ret := _m.Called(ctx, inFrame)
-
-	if len(ret) == 0 {
-		panic("no return value specified for ParameterQueryHandler")
-	}
-
-	var r0 oscar.SNACMessage
-	if rf, ok := ret.Get(0).(func(context.Context, oscar.SNACFrame) oscar.SNACMessage); ok {
-		r0 = rf(ctx, inFrame)
-	} else {
-		r0 = ret.Get(0).(oscar.SNACMessage)
-	}
-
-	return r0
-}
-
-// mockICBMHandler_ParameterQueryHandler_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ParameterQueryHandler'
-type mockICBMHandler_ParameterQueryHandler_Call struct {
-	*mock.Call
-}
-
-// ParameterQueryHandler is a helper method to define mock.On call
-//   - ctx context.Context
-//   - inFrame oscar.SNACFrame
-func (_e *mockICBMHandler_Expecter) ParameterQueryHandler(ctx interface{}, inFrame interface{}) *mockICBMHandler_ParameterQueryHandler_Call {
-	return &mockICBMHandler_ParameterQueryHandler_Call{Call: _e.mock.On("ParameterQueryHandler", ctx, inFrame)}
-}
-
-func (_c *mockICBMHandler_ParameterQueryHandler_Call) Run(run func(ctx context.Context, inFrame oscar.SNACFrame)) *mockICBMHandler_ParameterQueryHandler_Call {
-	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(context.Context), args[1].(oscar.SNACFrame))
-	})
-	return _c
-}
-
-func (_c *mockICBMHandler_ParameterQueryHandler_Call) Return(_a0 oscar.SNACMessage) *mockICBMHandler_ParameterQueryHandler_Call {
-	_c.Call.Return(_a0)
-	return _c
-}
-
-func (_c *mockICBMHandler_ParameterQueryHandler_Call) RunAndReturn(run func(context.Context, oscar.SNACFrame) oscar.SNACMessage) *mockICBMHandler_ParameterQueryHandler_Call {
-	_c.Call.Return(run)
-	return _c
-}
-
-// newMockICBMHandler creates a new instance of mockICBMHandler. 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 newMockICBMHandler(t interface {
-	mock.TestingT
-	Cleanup(func())
-}) *mockICBMHandler {
-	mock := &mockICBMHandler{}
-	mock.Mock.Test(t)
-
-	t.Cleanup(func() { mock.AssertExpectations(t) })
-
-	return mock
-}

+ 0 - 214
server/icbm_test.go

@@ -1,214 +0,0 @@
-package server
-
-import (
-	"bytes"
-	"testing"
-
-	"github.com/mk6i/retro-aim-server/config"
-	"github.com/mk6i/retro-aim-server/oscar"
-	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/mock"
-)
-
-func TestICBMRouter_RouteICBM(t *testing.T) {
-	cases := []struct {
-		// name is the unit test name
-		name string
-		// input is the request payload
-		input oscar.SNACMessage
-		// output is the response payload
-		output *oscar.SNACMessage
-		// handlerErr is the mocked handler error response
-		handlerErr error
-		// expectErr is the expected error returned by the router
-		expectErr error
-	}{
-		{
-			name: "receive ICBMAddParameters SNAC, return no response",
-			input: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.ICBM,
-					SubGroup:  oscar.ICBMAddParameters,
-				},
-				Body: oscar.SNAC_0x04_0x02_ICBMAddParameters{
-					Channel: 1,
-				},
-			},
-			output: nil,
-		},
-		{
-			name: "receive ICBMParameterQuery, return ICBMParameterReply",
-			input: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.ICBM,
-					SubGroup:  oscar.ICBMParameterQuery,
-				},
-				Body: struct{}{}, // empty SNAC
-			},
-			output: &oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.ICBM,
-					SubGroup:  oscar.ICBMParameterReply,
-				},
-				Body: oscar.SNAC_0x04_0x05_ICBMParameterReply{
-					MaxSlots: 100,
-				},
-			},
-		},
-		{
-			name: "receive ICBMChannelMsgToHost, return ICBMHostAck",
-			input: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.ICBM,
-					SubGroup:  oscar.ICBMChannelMsgToHost,
-				},
-				Body: oscar.SNAC_0x04_0x06_ICBMChannelMsgToHost{
-					ScreenName: "recipient-screen-name",
-				},
-			},
-			output: &oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.ICBM,
-					SubGroup:  oscar.ICBMHostAck,
-				},
-				Body: oscar.SNAC_0x04_0x0C_ICBMHostAck{
-					ChannelID: 4,
-				},
-			},
-		},
-		{
-			name: "receive ICBMChannelMsgToHost, return no reply",
-			input: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.ICBM,
-					SubGroup:  oscar.ICBMChannelMsgToHost,
-				},
-				Body: oscar.SNAC_0x04_0x06_ICBMChannelMsgToHost{
-					ScreenName: "recipient-screen-name",
-				},
-			},
-			output: nil,
-		},
-		{
-			name: "receive ICBMEvilRequest, return ICBMEvilReply",
-			input: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.ICBM,
-					SubGroup:  oscar.ICBMEvilRequest,
-				},
-				Body: oscar.SNAC_0x04_0x08_ICBMEvilRequest{
-					ScreenName: "recipient-screen-name",
-				},
-			},
-			output: &oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.ICBM,
-					SubGroup:  oscar.ICBMEvilReply,
-				},
-				Body: oscar.SNAC_0x04_0x09_ICBMEvilReply{
-					EvilDeltaApplied: 100,
-				},
-			},
-		},
-		{
-			name: "receive ICBMClientErr, return no response",
-			input: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.ICBM,
-					SubGroup:  oscar.ICBMClientErr,
-				},
-				Body: oscar.SNAC_0x04_0x0B_ICBMClientErr{
-					Code: 4,
-				},
-			},
-			output: nil,
-		},
-		{
-			name: "receive ICBMClientEvent, return no response",
-			input: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.ICBM,
-					SubGroup:  oscar.ICBMClientEvent,
-				},
-				Body: oscar.SNAC_0x04_0x14_ICBMClientEvent{
-					ScreenName: "recipient-screen-name",
-				},
-			},
-			output: nil,
-		},
-		{
-			name: "receive ICBMMissedCalls, expect ErrUnsupportedSubGroup",
-			input: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.ICBM,
-					SubGroup:  oscar.ICBMMissedCalls,
-				},
-				Body: struct{}{}, // empty SNAC
-			},
-			output:    nil,
-			expectErr: ErrUnsupportedSubGroup,
-		},
-	}
-
-	for _, tc := range cases {
-		t.Run(tc.name, func(t *testing.T) {
-			svc := newMockICBMHandler(t)
-			svc.EXPECT().
-				ChannelMsgToHostHandler(mock.Anything, mock.Anything, tc.input.Frame, tc.input.Body).
-				Return(tc.output, tc.handlerErr).
-				Maybe()
-			svc.EXPECT().
-				ClientEventHandler(mock.Anything, mock.Anything, tc.input.Frame, tc.input.Body).
-				Return(tc.handlerErr).
-				Maybe()
-			if tc.output != nil {
-				svc.EXPECT().
-					EvilRequestHandler(mock.Anything, mock.Anything, tc.input.Frame, tc.input.Body).
-					Return(*tc.output, tc.handlerErr).
-					Maybe()
-				svc.EXPECT().
-					ParameterQueryHandler(mock.Anything, tc.input.Frame).
-					Return(*tc.output).
-					Maybe()
-			}
-
-			router := NewICBMRouter(NewLogger(config.Config{}), svc)
-
-			bufIn := &bytes.Buffer{}
-			assert.NoError(t, oscar.Marshal(tc.input.Body, bufIn))
-
-			bufOut := &bytes.Buffer{}
-			seq := uint32(1)
-
-			err := router.Route(nil, nil, tc.input.Frame, bufIn, bufOut, &seq)
-			assert.ErrorIs(t, err, tc.expectErr)
-			if tc.expectErr != nil {
-				return
-			}
-
-			if tc.output == nil {
-				// make sure no response was sent
-				assert.Empty(t, bufOut.Bytes())
-				return
-			}
-
-			// make sure the sequence number was incremented
-			assert.Equal(t, uint32(2), seq)
-
-			// verify the FLAP frame
-			flap := oscar.FLAPFrame{}
-			assert.NoError(t, oscar.Unmarshal(&flap, bufOut))
-			assert.Equal(t, uint16(1), flap.Sequence)
-
-			// verify the SNAC frame
-			snacFrame := oscar.SNACFrame{}
-			assert.NoError(t, oscar.Unmarshal(&snacFrame, bufOut))
-			assert.Equal(t, tc.output.Frame, snacFrame)
-
-			// verify the SNAC message
-			snacBuf := &bytes.Buffer{}
-			assert.NoError(t, oscar.Marshal(tc.output.Body, snacBuf))
-			assert.Equal(t, snacBuf.Bytes(), bufOut.Bytes())
-		})
-	}
-}

+ 0 - 81
server/locate.go

@@ -1,81 +0,0 @@
-package server
-
-import (
-	"context"
-	"io"
-	"log/slog"
-
-	"github.com/mk6i/retro-aim-server/oscar"
-	"github.com/mk6i/retro-aim-server/state"
-)
-
-type LocateHandler interface {
-	RightsQueryHandler(ctx context.Context, inFrame oscar.SNACFrame) oscar.SNACMessage
-	SetDirInfoHandler(ctx context.Context, frame oscar.SNACFrame) oscar.SNACMessage
-	SetInfoHandler(ctx context.Context, sess *state.Session, inBody oscar.SNAC_0x02_0x04_LocateSetInfo) error
-	SetKeywordInfoHandler(ctx context.Context, inFrame oscar.SNACFrame) oscar.SNACMessage
-	UserInfoQuery2Handler(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, inBody oscar.SNAC_0x02_0x15_LocateUserInfoQuery2) (oscar.SNACMessage, error)
-}
-
-func NewLocateRouter(handler LocateHandler, logger *slog.Logger) LocateRouter {
-	return LocateRouter{
-		LocateHandler: handler,
-		routeLogger: routeLogger{
-			Logger: logger,
-		},
-	}
-}
-
-type LocateRouter struct {
-	LocateHandler
-	routeLogger
-}
-
-func (rt LocateRouter) Route(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, r io.Reader, w io.Writer, sequence *uint32) error {
-	switch inFrame.SubGroup {
-	case oscar.LocateRightsQuery:
-		outSNAC := rt.RightsQueryHandler(ctx, inFrame)
-		rt.logRequestAndResponse(ctx, inFrame, nil, outSNAC.Frame, outSNAC.Body)
-		return sendSNAC(outSNAC.Frame, outSNAC.Body, sequence, w)
-	case oscar.LocateSetInfo:
-		inBody := oscar.SNAC_0x02_0x04_LocateSetInfo{}
-		if err := oscar.Unmarshal(&inBody, r); err != nil {
-			return err
-		}
-		rt.logRequest(ctx, inFrame, inBody)
-		return rt.SetInfoHandler(ctx, sess, inBody)
-	case oscar.LocateSetDirInfo:
-		inBody := oscar.SNAC_0x02_0x09_LocateSetDirInfo{}
-		if err := oscar.Unmarshal(&inBody, r); err != nil {
-			return err
-		}
-		outSNAC := rt.SetDirInfoHandler(ctx, inFrame)
-		rt.logRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
-		return sendSNAC(outSNAC.Frame, outSNAC.Body, sequence, w)
-	case oscar.LocateGetDirInfo:
-		inBody := oscar.SNAC_0x02_0x0B_LocateGetDirInfo{}
-		rt.logRequest(ctx, inFrame, inBody)
-		return oscar.Unmarshal(&inBody, r)
-	case oscar.LocateSetKeywordInfo:
-		inBody := oscar.SNAC_0x02_0x0F_LocateSetKeywordInfo{}
-		if err := oscar.Unmarshal(&inBody, r); err != nil {
-			return err
-		}
-		outSNAC := rt.SetKeywordInfoHandler(ctx, inFrame)
-		rt.logRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
-		return sendSNAC(outSNAC.Frame, outSNAC.Body, sequence, w)
-	case oscar.LocateUserInfoQuery2:
-		inBody := oscar.SNAC_0x02_0x15_LocateUserInfoQuery2{}
-		if err := oscar.Unmarshal(&inBody, r); err != nil {
-			return err
-		}
-		outSNAC, err := rt.UserInfoQuery2Handler(ctx, sess, inFrame, inBody)
-		if err != nil {
-			return err
-		}
-		rt.logRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
-		return sendSNAC(outSNAC.Frame, outSNAC.Body, sequence, w)
-	default:
-		return ErrUnsupportedSubGroup
-	}
-}

+ 0 - 287
server/locate_mock_test.go

@@ -1,287 +0,0 @@
-// Code generated by mockery v2.38.0. DO NOT EDIT.
-
-package server
-
-import (
-	context "context"
-
-	oscar "github.com/mk6i/retro-aim-server/oscar"
-	mock "github.com/stretchr/testify/mock"
-
-	state "github.com/mk6i/retro-aim-server/state"
-)
-
-// mockLocateHandler is an autogenerated mock type for the LocateHandler type
-type mockLocateHandler struct {
-	mock.Mock
-}
-
-type mockLocateHandler_Expecter struct {
-	mock *mock.Mock
-}
-
-func (_m *mockLocateHandler) EXPECT() *mockLocateHandler_Expecter {
-	return &mockLocateHandler_Expecter{mock: &_m.Mock}
-}
-
-// RightsQueryHandler provides a mock function with given fields: ctx, inFrame
-func (_m *mockLocateHandler) RightsQueryHandler(ctx context.Context, inFrame oscar.SNACFrame) oscar.SNACMessage {
-	ret := _m.Called(ctx, inFrame)
-
-	if len(ret) == 0 {
-		panic("no return value specified for RightsQueryHandler")
-	}
-
-	var r0 oscar.SNACMessage
-	if rf, ok := ret.Get(0).(func(context.Context, oscar.SNACFrame) oscar.SNACMessage); ok {
-		r0 = rf(ctx, inFrame)
-	} else {
-		r0 = ret.Get(0).(oscar.SNACMessage)
-	}
-
-	return r0
-}
-
-// mockLocateHandler_RightsQueryHandler_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RightsQueryHandler'
-type mockLocateHandler_RightsQueryHandler_Call struct {
-	*mock.Call
-}
-
-// RightsQueryHandler is a helper method to define mock.On call
-//   - ctx context.Context
-//   - inFrame oscar.SNACFrame
-func (_e *mockLocateHandler_Expecter) RightsQueryHandler(ctx interface{}, inFrame interface{}) *mockLocateHandler_RightsQueryHandler_Call {
-	return &mockLocateHandler_RightsQueryHandler_Call{Call: _e.mock.On("RightsQueryHandler", ctx, inFrame)}
-}
-
-func (_c *mockLocateHandler_RightsQueryHandler_Call) Run(run func(ctx context.Context, inFrame oscar.SNACFrame)) *mockLocateHandler_RightsQueryHandler_Call {
-	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(context.Context), args[1].(oscar.SNACFrame))
-	})
-	return _c
-}
-
-func (_c *mockLocateHandler_RightsQueryHandler_Call) Return(_a0 oscar.SNACMessage) *mockLocateHandler_RightsQueryHandler_Call {
-	_c.Call.Return(_a0)
-	return _c
-}
-
-func (_c *mockLocateHandler_RightsQueryHandler_Call) RunAndReturn(run func(context.Context, oscar.SNACFrame) oscar.SNACMessage) *mockLocateHandler_RightsQueryHandler_Call {
-	_c.Call.Return(run)
-	return _c
-}
-
-// SetDirInfoHandler provides a mock function with given fields: ctx, frame
-func (_m *mockLocateHandler) SetDirInfoHandler(ctx context.Context, frame oscar.SNACFrame) oscar.SNACMessage {
-	ret := _m.Called(ctx, frame)
-
-	if len(ret) == 0 {
-		panic("no return value specified for SetDirInfoHandler")
-	}
-
-	var r0 oscar.SNACMessage
-	if rf, ok := ret.Get(0).(func(context.Context, oscar.SNACFrame) oscar.SNACMessage); ok {
-		r0 = rf(ctx, frame)
-	} else {
-		r0 = ret.Get(0).(oscar.SNACMessage)
-	}
-
-	return r0
-}
-
-// mockLocateHandler_SetDirInfoHandler_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetDirInfoHandler'
-type mockLocateHandler_SetDirInfoHandler_Call struct {
-	*mock.Call
-}
-
-// SetDirInfoHandler is a helper method to define mock.On call
-//   - ctx context.Context
-//   - frame oscar.SNACFrame
-func (_e *mockLocateHandler_Expecter) SetDirInfoHandler(ctx interface{}, frame interface{}) *mockLocateHandler_SetDirInfoHandler_Call {
-	return &mockLocateHandler_SetDirInfoHandler_Call{Call: _e.mock.On("SetDirInfoHandler", ctx, frame)}
-}
-
-func (_c *mockLocateHandler_SetDirInfoHandler_Call) Run(run func(ctx context.Context, frame oscar.SNACFrame)) *mockLocateHandler_SetDirInfoHandler_Call {
-	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(context.Context), args[1].(oscar.SNACFrame))
-	})
-	return _c
-}
-
-func (_c *mockLocateHandler_SetDirInfoHandler_Call) Return(_a0 oscar.SNACMessage) *mockLocateHandler_SetDirInfoHandler_Call {
-	_c.Call.Return(_a0)
-	return _c
-}
-
-func (_c *mockLocateHandler_SetDirInfoHandler_Call) RunAndReturn(run func(context.Context, oscar.SNACFrame) oscar.SNACMessage) *mockLocateHandler_SetDirInfoHandler_Call {
-	_c.Call.Return(run)
-	return _c
-}
-
-// SetInfoHandler provides a mock function with given fields: ctx, sess, inBody
-func (_m *mockLocateHandler) SetInfoHandler(ctx context.Context, sess *state.Session, inBody oscar.SNAC_0x02_0x04_LocateSetInfo) error {
-	ret := _m.Called(ctx, sess, inBody)
-
-	if len(ret) == 0 {
-		panic("no return value specified for SetInfoHandler")
-	}
-
-	var r0 error
-	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, oscar.SNAC_0x02_0x04_LocateSetInfo) error); ok {
-		r0 = rf(ctx, sess, inBody)
-	} else {
-		r0 = ret.Error(0)
-	}
-
-	return r0
-}
-
-// mockLocateHandler_SetInfoHandler_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetInfoHandler'
-type mockLocateHandler_SetInfoHandler_Call struct {
-	*mock.Call
-}
-
-// SetInfoHandler is a helper method to define mock.On call
-//   - ctx context.Context
-//   - sess *state.Session
-//   - inBody oscar.SNAC_0x02_0x04_LocateSetInfo
-func (_e *mockLocateHandler_Expecter) SetInfoHandler(ctx interface{}, sess interface{}, inBody interface{}) *mockLocateHandler_SetInfoHandler_Call {
-	return &mockLocateHandler_SetInfoHandler_Call{Call: _e.mock.On("SetInfoHandler", ctx, sess, inBody)}
-}
-
-func (_c *mockLocateHandler_SetInfoHandler_Call) Run(run func(ctx context.Context, sess *state.Session, inBody oscar.SNAC_0x02_0x04_LocateSetInfo)) *mockLocateHandler_SetInfoHandler_Call {
-	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(context.Context), args[1].(*state.Session), args[2].(oscar.SNAC_0x02_0x04_LocateSetInfo))
-	})
-	return _c
-}
-
-func (_c *mockLocateHandler_SetInfoHandler_Call) Return(_a0 error) *mockLocateHandler_SetInfoHandler_Call {
-	_c.Call.Return(_a0)
-	return _c
-}
-
-func (_c *mockLocateHandler_SetInfoHandler_Call) RunAndReturn(run func(context.Context, *state.Session, oscar.SNAC_0x02_0x04_LocateSetInfo) error) *mockLocateHandler_SetInfoHandler_Call {
-	_c.Call.Return(run)
-	return _c
-}
-
-// SetKeywordInfoHandler provides a mock function with given fields: ctx, inFrame
-func (_m *mockLocateHandler) SetKeywordInfoHandler(ctx context.Context, inFrame oscar.SNACFrame) oscar.SNACMessage {
-	ret := _m.Called(ctx, inFrame)
-
-	if len(ret) == 0 {
-		panic("no return value specified for SetKeywordInfoHandler")
-	}
-
-	var r0 oscar.SNACMessage
-	if rf, ok := ret.Get(0).(func(context.Context, oscar.SNACFrame) oscar.SNACMessage); ok {
-		r0 = rf(ctx, inFrame)
-	} else {
-		r0 = ret.Get(0).(oscar.SNACMessage)
-	}
-
-	return r0
-}
-
-// mockLocateHandler_SetKeywordInfoHandler_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetKeywordInfoHandler'
-type mockLocateHandler_SetKeywordInfoHandler_Call struct {
-	*mock.Call
-}
-
-// SetKeywordInfoHandler is a helper method to define mock.On call
-//   - ctx context.Context
-//   - inFrame oscar.SNACFrame
-func (_e *mockLocateHandler_Expecter) SetKeywordInfoHandler(ctx interface{}, inFrame interface{}) *mockLocateHandler_SetKeywordInfoHandler_Call {
-	return &mockLocateHandler_SetKeywordInfoHandler_Call{Call: _e.mock.On("SetKeywordInfoHandler", ctx, inFrame)}
-}
-
-func (_c *mockLocateHandler_SetKeywordInfoHandler_Call) Run(run func(ctx context.Context, inFrame oscar.SNACFrame)) *mockLocateHandler_SetKeywordInfoHandler_Call {
-	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(context.Context), args[1].(oscar.SNACFrame))
-	})
-	return _c
-}
-
-func (_c *mockLocateHandler_SetKeywordInfoHandler_Call) Return(_a0 oscar.SNACMessage) *mockLocateHandler_SetKeywordInfoHandler_Call {
-	_c.Call.Return(_a0)
-	return _c
-}
-
-func (_c *mockLocateHandler_SetKeywordInfoHandler_Call) RunAndReturn(run func(context.Context, oscar.SNACFrame) oscar.SNACMessage) *mockLocateHandler_SetKeywordInfoHandler_Call {
-	_c.Call.Return(run)
-	return _c
-}
-
-// UserInfoQuery2Handler provides a mock function with given fields: ctx, sess, inFrame, inBody
-func (_m *mockLocateHandler) UserInfoQuery2Handler(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, inBody oscar.SNAC_0x02_0x15_LocateUserInfoQuery2) (oscar.SNACMessage, error) {
-	ret := _m.Called(ctx, sess, inFrame, inBody)
-
-	if len(ret) == 0 {
-		panic("no return value specified for UserInfoQuery2Handler")
-	}
-
-	var r0 oscar.SNACMessage
-	var r1 error
-	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, oscar.SNACFrame, oscar.SNAC_0x02_0x15_LocateUserInfoQuery2) (oscar.SNACMessage, error)); ok {
-		return rf(ctx, sess, inFrame, inBody)
-	}
-	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, oscar.SNACFrame, oscar.SNAC_0x02_0x15_LocateUserInfoQuery2) oscar.SNACMessage); ok {
-		r0 = rf(ctx, sess, inFrame, inBody)
-	} else {
-		r0 = ret.Get(0).(oscar.SNACMessage)
-	}
-
-	if rf, ok := ret.Get(1).(func(context.Context, *state.Session, oscar.SNACFrame, oscar.SNAC_0x02_0x15_LocateUserInfoQuery2) error); ok {
-		r1 = rf(ctx, sess, inFrame, inBody)
-	} else {
-		r1 = ret.Error(1)
-	}
-
-	return r0, r1
-}
-
-// mockLocateHandler_UserInfoQuery2Handler_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UserInfoQuery2Handler'
-type mockLocateHandler_UserInfoQuery2Handler_Call struct {
-	*mock.Call
-}
-
-// UserInfoQuery2Handler is a helper method to define mock.On call
-//   - ctx context.Context
-//   - sess *state.Session
-//   - inFrame oscar.SNACFrame
-//   - inBody oscar.SNAC_0x02_0x15_LocateUserInfoQuery2
-func (_e *mockLocateHandler_Expecter) UserInfoQuery2Handler(ctx interface{}, sess interface{}, inFrame interface{}, inBody interface{}) *mockLocateHandler_UserInfoQuery2Handler_Call {
-	return &mockLocateHandler_UserInfoQuery2Handler_Call{Call: _e.mock.On("UserInfoQuery2Handler", ctx, sess, inFrame, inBody)}
-}
-
-func (_c *mockLocateHandler_UserInfoQuery2Handler_Call) Run(run func(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, inBody oscar.SNAC_0x02_0x15_LocateUserInfoQuery2)) *mockLocateHandler_UserInfoQuery2Handler_Call {
-	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(context.Context), args[1].(*state.Session), args[2].(oscar.SNACFrame), args[3].(oscar.SNAC_0x02_0x15_LocateUserInfoQuery2))
-	})
-	return _c
-}
-
-func (_c *mockLocateHandler_UserInfoQuery2Handler_Call) Return(_a0 oscar.SNACMessage, _a1 error) *mockLocateHandler_UserInfoQuery2Handler_Call {
-	_c.Call.Return(_a0, _a1)
-	return _c
-}
-
-func (_c *mockLocateHandler_UserInfoQuery2Handler_Call) RunAndReturn(run func(context.Context, *state.Session, oscar.SNACFrame, oscar.SNAC_0x02_0x15_LocateUserInfoQuery2) (oscar.SNACMessage, error)) *mockLocateHandler_UserInfoQuery2Handler_Call {
-	_c.Call.Return(run)
-	return _c
-}
-
-// newMockLocateHandler creates a new instance of mockLocateHandler. 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 newMockLocateHandler(t interface {
-	mock.TestingT
-	Cleanup(func())
-}) *mockLocateHandler {
-	mock := &mockLocateHandler{}
-	mock.Mock.Test(t)
-
-	t.Cleanup(func() { mock.AssertExpectations(t) })
-
-	return mock
-}

+ 0 - 248
server/locate_test.go

@@ -1,248 +0,0 @@
-package server
-
-import (
-	"bytes"
-	"testing"
-
-	"github.com/mk6i/retro-aim-server/config"
-	"github.com/mk6i/retro-aim-server/oscar"
-	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/mock"
-)
-
-func TestLocateRouter_RouteLocate(t *testing.T) {
-	cases := []struct {
-		// name is the unit test name
-		name string
-		// input is the request payload
-		input oscar.SNACMessage
-		// output is the response payload
-		output oscar.SNACMessage
-		// handlerErr is the mocked handler error response
-		handlerErr error
-		// expectErr is the expected error returned by the router
-		expectErr error
-	}{
-		{
-			name: "receive LocateRightsQuery, return LocateRightsReply",
-			input: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.Locate,
-					SubGroup:  oscar.LocateRightsQuery,
-				},
-				Body: struct{}{},
-			},
-			output: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.Locate,
-					SubGroup:  oscar.LocateRightsReply,
-				},
-				Body: oscar.SNAC_0x02_0x03_LocateRightsReply{
-					TLVRestBlock: oscar.TLVRestBlock{
-						TLVList: oscar.TLVList{
-							oscar.NewTLV(0x01, uint16(1000)),
-						},
-					},
-				},
-			},
-		},
-		{
-			name: "receive LocateSetInfo, return no response",
-			input: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.Locate,
-					SubGroup:  oscar.LocateSetInfo,
-				},
-				Body: oscar.SNAC_0x02_0x04_LocateSetInfo{
-					TLVRestBlock: oscar.TLVRestBlock{
-						TLVList: oscar.TLVList{
-							{
-								Tag:   0x01,
-								Value: []byte{1, 2, 3, 4},
-							},
-						},
-					},
-				},
-			},
-			output: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{},
-			},
-		},
-		{
-			name: "receive LocateSetDirInfo, return LocateSetDirReply",
-			input: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.Locate,
-					SubGroup:  oscar.LocateSetDirInfo,
-				},
-				Body: oscar.SNAC_0x02_0x09_LocateSetDirInfo{
-					TLVRestBlock: oscar.TLVRestBlock{
-						TLVList: oscar.TLVList{
-							{
-								Tag:   0x01,
-								Value: []byte{1, 2, 3, 4},
-							},
-						},
-					},
-				},
-			},
-			output: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.Locate,
-					SubGroup:  oscar.LocateSetDirReply,
-				},
-				Body: oscar.SNAC_0x02_0x0A_LocateSetDirReply{
-					Result: 1,
-				},
-			},
-		},
-		{
-			name: "receive LocateGetDirInfo, return no response",
-			input: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.Locate,
-					SubGroup:  oscar.LocateGetDirInfo,
-				},
-				Body: oscar.SNAC_0x02_0x0B_LocateGetDirInfo{
-					WatcherScreenNames: "screen-name",
-				},
-			},
-			output: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{},
-			},
-		},
-		{
-			name: "receive LocateSetKeywordInfo, return LocateSetKeywordReply",
-			input: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.Locate,
-					SubGroup:  oscar.LocateSetKeywordInfo,
-				},
-				Body: oscar.SNAC_0x02_0x0F_LocateSetKeywordInfo{
-					TLVRestBlock: oscar.TLVRestBlock{
-						TLVList: oscar.TLVList{
-							{
-								Tag:   0x01,
-								Value: []byte{1, 2, 3, 4},
-							},
-						},
-					},
-				},
-			},
-			output: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.Locate,
-					SubGroup:  oscar.LocateSetKeywordReply,
-				},
-				Body: oscar.SNAC_0x02_0x10_LocateSetKeywordReply{
-					Unknown: 1,
-				},
-			},
-		},
-		{
-			name: "receive LocateUserInfoQuery2, return LocateUserInfoReply",
-			input: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.Locate,
-					SubGroup:  oscar.LocateUserInfoQuery2,
-				},
-				Body: oscar.SNAC_0x02_0x15_LocateUserInfoQuery2{
-					Type2: 1,
-				},
-			},
-			output: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.Locate,
-					SubGroup:  oscar.LocateUserInfoReply,
-				},
-				Body: oscar.SNAC_0x02_0x06_LocateUserInfoReply{
-					TLVUserInfo: oscar.TLVUserInfo{
-						ScreenName: "screen-name",
-					},
-					LocateInfo: oscar.TLVRestBlock{
-						TLVList: oscar.TLVList{
-							{
-								Tag:   0x01,
-								Value: []byte{1, 2, 3, 4},
-							},
-						},
-					},
-				},
-			},
-		},
-		{
-			name: "receive LocateGetKeywordInfo, expect ErrUnsupportedSubGroup",
-			input: oscar.SNACMessage{
-				Frame: oscar.SNACFrame{
-					FoodGroup: oscar.Locate,
-					SubGroup:  oscar.LocateGetKeywordInfo,
-				},
-				Body: struct{}{}, // empty SNAC
-			},
-			output:    oscar.SNACMessage{}, // empty SNAC
-			expectErr: ErrUnsupportedSubGroup,
-		},
-	}
-
-	for _, tc := range cases {
-		t.Run(tc.name, func(t *testing.T) {
-			svc := newMockLocateHandler(t)
-			svc.EXPECT().
-				RightsQueryHandler(mock.Anything, tc.input.Frame).
-				Return(tc.output).
-				Maybe()
-			svc.EXPECT().
-				SetDirInfoHandler(mock.Anything, tc.input.Frame).
-				Return(tc.output).
-				Maybe()
-			svc.EXPECT().
-				SetInfoHandler(mock.Anything, mock.Anything, tc.input.Body).
-				Return(tc.handlerErr).
-				Maybe()
-			svc.EXPECT().
-				SetKeywordInfoHandler(mock.Anything, tc.input.Frame).
-				Return(tc.output).
-				Maybe()
-			svc.EXPECT().
-				UserInfoQuery2Handler(mock.Anything, mock.Anything, tc.input.Frame, tc.input.Body).
-				Return(tc.output, tc.handlerErr).
-				Maybe()
-
-			router := NewLocateRouter(svc, NewLogger(config.Config{}))
-
-			bufIn := &bytes.Buffer{}
-			assert.NoError(t, oscar.Marshal(tc.input.Body, bufIn))
-
-			bufOut := &bytes.Buffer{}
-			seq := uint32(1)
-
-			err := router.Route(nil, nil, tc.input.Frame, bufIn, bufOut, &seq)
-			assert.ErrorIs(t, err, tc.expectErr)
-			if tc.expectErr != nil {
-				return
-			}
-
-			if tc.output.Frame == (oscar.SNACFrame{}) {
-				return // handler doesn't return response
-			}
-
-			// make sure the sequence number was incremented
-			assert.Equal(t, uint32(2), seq)
-
-			// verify the FLAP frame
-			flap := oscar.FLAPFrame{}
-			assert.NoError(t, oscar.Unmarshal(&flap, bufOut))
-			assert.Equal(t, uint16(1), flap.Sequence)
-
-			// verify the SNAC frame
-			snacFrame := oscar.SNACFrame{}
-			assert.NoError(t, oscar.Unmarshal(&snacFrame, bufOut))
-			assert.Equal(t, tc.output.Frame, snacFrame)
-
-			// verify the SNAC message
-			snacBuf := &bytes.Buffer{}
-			assert.NoError(t, oscar.Marshal(tc.output.Body, snacBuf))
-			assert.Equal(t, snacBuf.Bytes(), bufOut.Bytes())
-		})
-	}
-}

+ 26 - 15
server/bos_service.go → server/oscar/bos.go

@@ -1,4 +1,4 @@
-package server
+package oscar
 
 import (
 	"context"
@@ -9,23 +9,30 @@ import (
 	"os"
 
 	"github.com/mk6i/retro-aim-server/config"
-	"github.com/mk6i/retro-aim-server/oscar"
+	"github.com/mk6i/retro-aim-server/wire"
 )
 
-// BOSService provides client connection lifecycle management for the BOS
+// OnlineNotifier returns a OServiceHostOnline SNAC that is sent to the client
+// at the beginning of the protocol sequence which lists all food groups
+// managed by the server.
+type OnlineNotifier interface {
+	HostOnline() wire.SNACMessage
+}
+
+// BOSServer provides client connection lifecycle management for the BOS
 // service.
-type BOSService struct {
-	AuthHandler
+type BOSServer struct {
+	AuthService
+	Handler
 	Logger *slog.Logger
-	OServiceBOSHandler
-	Router
+	OnlineNotifier
 	config.Config
 }
 
 // Start starts a TCP server and listens for connections. The initial
 // authentication handshake sequences are handled by this method. The remaining
 // requests are relayed to BOSRouter.
-func (rt BOSService) Start() {
+func (rt BOSServer) Start() {
 	addr := config.Address("", rt.Config.BOSPort)
 	listener, err := net.Listen("tcp", addr)
 	if err != nil {
@@ -53,16 +60,20 @@ func (rt BOSService) Start() {
 	}
 }
 
-func (rt BOSService) handleNewConnection(ctx context.Context, rwc io.ReadWriteCloser) error {
-	seq := uint32(100)
+func (rt BOSServer) handleNewConnection(ctx context.Context, rwc io.ReadWriteCloser) error {
+	flapc := &flapClient{
+		r:        rwc,
+		sequence: 100,
+		w:        rwc,
+	}
 
-	flap, err := flapSignonHandshake(rwc, &seq)
+	flap, err := flapc.SignonHandshake()
 	if err != nil {
 		return err
 	}
 
 	var ok bool
-	sessionID, ok := flap.Slice(oscar.OServiceTLVTagsLoginCookie)
+	sessionID, ok := flap.Slice(wire.OServiceTLVTagsLoginCookie)
 	if !ok {
 		return errors.New("unable to get session id from payload")
 	}
@@ -85,10 +96,10 @@ func (rt BOSService) handleNewConnection(ctx context.Context, rwc io.ReadWriteCl
 
 	ctx = context.WithValue(ctx, "screenName", sess.ScreenName())
 
-	msg := rt.WriteOServiceHostOnline()
-	if err := sendSNAC(msg.Frame, msg.Body, &seq, rwc); err != nil {
+	msg := rt.OnlineNotifier.HostOnline()
+	if err := flapc.SendSNAC(msg.Frame, msg.Body); err != nil {
 		return err
 	}
 
-	return dispatchIncomingMessages(ctx, sess, seq, rwc, rt.Logger, rt.Router, sendSNAC, rt.Config)
+	return dispatchIncomingMessages(ctx, sess, flapc, rwc, rt.Logger, rt.Handler, rt.Config)
 }

+ 124 - 0
server/oscar/bos_test.go

@@ -0,0 +1,124 @@
+package oscar
+
+import (
+	"bytes"
+	"context"
+	"io"
+	"log/slog"
+	"testing"
+
+	"github.com/mk6i/retro-aim-server/state"
+	"github.com/mk6i/retro-aim-server/wire"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/mock"
+)
+
+// pipeRWC provides a mock for ReadWriteCloser that uses pipes instead of TCP
+// connections
+type pipeRWC struct {
+	*io.PipeReader
+	*io.PipeWriter
+}
+
+func (m pipeRWC) Close() error {
+	if err := m.PipeReader.Close(); err != nil {
+		return err
+	}
+	return m.PipeWriter.Close()
+}
+
+func TestBOSService_handleNewConnection(t *testing.T) {
+	sess := state.NewSession()
+	sess.SetID("login-cookie-1234")
+
+	clientReader, serverWriter := io.Pipe()
+	serverReader, clientWriter := io.Pipe()
+
+	go func() {
+		// < receive FLAPSignonFrame
+		flap := wire.FLAPFrame{}
+		assert.NoError(t, wire.Unmarshal(&flap, serverReader))
+		buf, err := flap.ReadBody(serverReader)
+		assert.NoError(t, err)
+		flapSignonFrame := wire.FLAPSignonFrame{}
+		assert.NoError(t, wire.Unmarshal(&flapSignonFrame, buf))
+
+		// > send FLAPSignonFrame
+		flapSignonFrame = wire.FLAPSignonFrame{
+			FLAPVersion: 1,
+		}
+		flapSignonFrame.Append(wire.NewTLV(wire.OServiceTLVTagsLoginCookie, []byte(sess.ID())))
+		buf = &bytes.Buffer{}
+		assert.NoError(t, wire.Marshal(flapSignonFrame, buf))
+		flap = wire.FLAPFrame{
+			StartMarker:   42,
+			FrameType:     wire.FLAPFrameSignon,
+			PayloadLength: uint16(buf.Len()),
+		}
+		assert.NoError(t, wire.Marshal(flap, serverWriter))
+		_, err = serverWriter.Write(buf.Bytes())
+		assert.NoError(t, err)
+
+		// < receive SNAC_0x01_0x03_OServiceHostOnline
+		flap = wire.FLAPFrame{}
+		assert.NoError(t, wire.Unmarshal(&flap, serverReader))
+		buf, err = flap.ReadBody(serverReader)
+		assert.NoError(t, err)
+		frame := wire.SNACFrame{}
+		assert.NoError(t, wire.Unmarshal(&frame, buf))
+		body := wire.SNAC_0x01_0x03_OServiceHostOnline{}
+		assert.NoError(t, wire.Unmarshal(&body, buf))
+
+		// send the first request that should get relayed to BOSRouter.Handle
+		flapc := flapClient{
+			w: serverWriter,
+		}
+		frame = wire.SNACFrame{
+			FoodGroup: wire.OService,
+			SubGroup:  wire.OServiceClientOnline,
+		}
+		assert.NoError(t, flapc.SendSNAC(frame, struct{}{}))
+		assert.NoError(t, serverWriter.Close())
+	}()
+
+	authService := newMockAuthService(t)
+	authService.EXPECT().
+		RetrieveBOSSession(sess.ID()).
+		Return(sess, nil)
+	authService.EXPECT().
+		Signout(mock.Anything, sess).
+		Return(nil)
+
+	onlineNotifier := newMockOnlineNotifier(t)
+	onlineNotifier.EXPECT().
+		HostOnline().
+		Return(wire.SNACMessage{
+			Frame: wire.SNACFrame{
+				FoodGroup: wire.OService,
+				SubGroup:  wire.OServiceHostOnline,
+			},
+			Body: wire.SNAC_0x01_0x03_OServiceHostOnline{},
+		})
+
+	router := newMockHandler(t)
+	router.EXPECT().
+		Handle(mock.Anything, sess, mock.Anything, mock.Anything, mock.Anything).
+		Run(func(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter) {
+			assert.Equal(t, wire.SNACFrame{
+				FoodGroup: wire.OService,
+				SubGroup:  wire.OServiceClientOnline,
+			}, inFrame)
+		}).Return(nil)
+
+	rt := BOSServer{
+		AuthService:    authService,
+		Handler:        router,
+		Logger:         slog.Default(),
+		OnlineNotifier: onlineNotifier,
+	}
+	rwc := pipeRWC{
+		PipeReader: clientReader,
+		PipeWriter: clientWriter,
+	}
+	assert.NoError(t, rt.handleNewConnection(context.Background(), rwc))
+}

+ 98 - 0
server/oscar/bucp.go

@@ -0,0 +1,98 @@
+package oscar
+
+import (
+	"context"
+	"io"
+	"log/slog"
+	"net"
+	"os"
+
+	"github.com/mk6i/retro-aim-server/config"
+	"github.com/mk6i/retro-aim-server/state"
+	"github.com/mk6i/retro-aim-server/wire"
+
+	"github.com/google/uuid"
+)
+
+type AuthService interface {
+	BUCPChallengeRequest(bodyIn wire.SNAC_0x17_0x06_BUCPChallengeRequest, newUUID func() uuid.UUID) (wire.SNACMessage, error)
+	BUCPLoginRequest(bodyIn wire.SNAC_0x17_0x02_BUCPLoginRequest, newUUID func() uuid.UUID, fn func(screenName string) (state.User, error)) (wire.SNACMessage, error)
+	RetrieveChatSession(loginCookie []byte) (*state.Session, error)
+	RetrieveBOSSession(sessionID string) (*state.Session, error)
+	Signout(ctx context.Context, sess *state.Session) error
+	SignoutChat(ctx context.Context, sess *state.Session) error
+}
+
+// BUCPAuthService represents a service that implements the OSCAR BUCP. This is
+// the first service that the AIM client connects to in the login flow.
+type BUCPAuthService struct {
+	AuthService
+	config.Config
+	Logger *slog.Logger
+}
+
+// Start creates a TCP server that implements the BUCP authentication flow. It
+// validates users credentials and, upon success, provides an auth cookie and
+// hostname information for connecting to the BOS service.
+func (rt BUCPAuthService) Start() {
+	addr := config.Address("", rt.Config.BUCPPort)
+	listener, err := net.Listen("tcp", addr)
+	if err != nil {
+		rt.Logger.Error("unable to bind OSCAR server address", "err", err.Error())
+		os.Exit(1)
+	}
+	defer listener.Close()
+
+	rt.Logger.Info("starting auth service", "addr", addr)
+
+	for {
+		conn, err := listener.Accept()
+		if err != nil {
+			rt.Logger.Error(err.Error())
+			continue
+		}
+
+		go func() {
+			if err := rt.handleNewConnection(conn); err != nil {
+				rt.Logger.Info("user session failed", "err", err.Error())
+			}
+		}()
+	}
+}
+
+func (rt BUCPAuthService) handleNewConnection(rwc io.ReadWriteCloser) error {
+	flapc := flapClient{
+		r:        rwc,
+		sequence: 100,
+		w:        rwc,
+	}
+	defer rwc.Close()
+	if _, err := flapc.SignonHandshake(); err != nil {
+		return err
+	}
+
+	challengeRequest := wire.SNAC_0x17_0x06_BUCPChallengeRequest{}
+	if err := flapc.ReceiveSNAC(&wire.SNACFrame{}, &challengeRequest); err != nil {
+		return err
+	}
+
+	outSNAC, err := rt.BUCPChallengeRequest(challengeRequest, uuid.New)
+	if err != nil {
+		return err
+	}
+	if err := flapc.SendSNAC(outSNAC.Frame, outSNAC.Body); err != nil {
+		return err
+	}
+
+	loginRequest := wire.SNAC_0x17_0x02_BUCPLoginRequest{}
+	if err := flapc.ReceiveSNAC(&wire.SNACFrame{}, &loginRequest); err != nil {
+		return err
+	}
+
+	outSNAC, err = rt.BUCPLoginRequest(loginRequest, uuid.New, state.NewStubUser)
+	if err != nil {
+		return err
+	}
+
+	return flapc.SendSNAC(outSNAC.Frame, outSNAC.Body)
+}

+ 104 - 0
server/oscar/bucp_test.go

@@ -0,0 +1,104 @@
+package oscar
+
+import (
+	"bytes"
+	"io"
+	"log/slog"
+	"testing"
+
+	"github.com/mk6i/retro-aim-server/wire"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/mock"
+)
+
+func TestBUCPAuthService_handleNewConnection(t *testing.T) {
+	clientReader, serverWriter := io.Pipe()
+	serverReader, clientWriter := io.Pipe()
+
+	go func() {
+		// < receive FLAPSignonFrame
+		flap := wire.FLAPFrame{}
+		assert.NoError(t, wire.Unmarshal(&flap, serverReader))
+		buf, err := flap.ReadBody(serverReader)
+		assert.NoError(t, err)
+		flapSignonFrame := wire.FLAPSignonFrame{}
+		assert.NoError(t, wire.Unmarshal(&flapSignonFrame, buf))
+
+		// > send FLAPSignonFrame
+		flapSignonFrame = wire.FLAPSignonFrame{
+			FLAPVersion: 1,
+		}
+		buf = &bytes.Buffer{}
+		assert.NoError(t, wire.Marshal(flapSignonFrame, buf))
+		flap = wire.FLAPFrame{
+			StartMarker:   42,
+			FrameType:     wire.FLAPFrameSignon,
+			PayloadLength: uint16(buf.Len()),
+		}
+		assert.NoError(t, wire.Marshal(flap, serverWriter))
+		_, err = serverWriter.Write(buf.Bytes())
+		assert.NoError(t, err)
+
+		// > send SNAC_0x17_0x06_BUCPChallengeRequest
+		flapc := flapClient{
+			r: serverReader,
+			w: serverWriter,
+		}
+		frame := wire.SNACFrame{
+			FoodGroup: wire.BUCP,
+			SubGroup:  wire.BUCPChallengeRequest,
+		}
+		bodyIn := wire.SNAC_0x17_0x06_BUCPChallengeRequest{}
+		assert.NoError(t, flapc.SendSNAC(frame, bodyIn))
+
+		// < receive SNAC_0x17_0x07_BUCPChallengeResponse
+		frame = wire.SNACFrame{}
+		assert.NoError(t, flapc.ReceiveSNAC(&frame, &wire.SNAC_0x17_0x07_BUCPChallengeResponse{}))
+		assert.Equal(t, wire.SNACFrame{FoodGroup: wire.BUCP, SubGroup: wire.BUCPChallengeResponse}, frame)
+
+		// > send SNAC_0x17_0x02_BUCPLoginRequest
+		frame = wire.SNACFrame{
+			FoodGroup: wire.BUCP,
+			SubGroup:  wire.BUCPLoginRequest,
+		}
+		assert.NoError(t, flapc.SendSNAC(frame, wire.SNAC_0x17_0x02_BUCPLoginRequest{}))
+
+		// < receive SNAC_0x17_0x03_BUCPLoginResponse
+		frame = wire.SNACFrame{}
+		assert.NoError(t, flapc.ReceiveSNAC(&frame, &wire.SNAC_0x17_0x03_BUCPLoginResponse{}))
+		assert.Equal(t, wire.SNACFrame{FoodGroup: wire.BUCP, SubGroup: wire.BUCPLoginResponse}, frame)
+
+		assert.NoError(t, serverWriter.Close())
+	}()
+
+	authService := newMockAuthService(t)
+	authService.EXPECT().
+		BUCPChallengeRequest(mock.Anything, mock.Anything).
+		Return(wire.SNACMessage{
+			Frame: wire.SNACFrame{
+				FoodGroup: wire.BUCP,
+				SubGroup:  wire.BUCPChallengeResponse,
+			},
+			Body: wire.SNAC_0x17_0x07_BUCPChallengeResponse{},
+		}, nil)
+	authService.EXPECT().
+		BUCPLoginRequest(mock.Anything, mock.Anything, mock.Anything).
+		Return(wire.SNACMessage{
+			Frame: wire.SNACFrame{
+				FoodGroup: wire.BUCP,
+				SubGroup:  wire.BUCPLoginResponse,
+			},
+			Body: wire.SNAC_0x17_0x03_BUCPLoginResponse{},
+		}, nil)
+
+	rt := BUCPAuthService{
+		AuthService: authService,
+		Logger:      slog.Default(),
+	}
+	rwc := pipeRWC{
+		PipeReader: clientReader,
+		PipeWriter: clientWriter,
+	}
+	assert.NoError(t, rt.handleNewConnection(rwc))
+}

+ 23 - 27
server/chat_service.go → server/oscar/chat.go

@@ -1,7 +1,6 @@
-package server
+package oscar
 
 import (
-	"bytes"
 	"context"
 	"errors"
 	"io"
@@ -10,22 +9,22 @@ import (
 	"os"
 
 	"github.com/mk6i/retro-aim-server/config"
-	"github.com/mk6i/retro-aim-server/oscar"
+	"github.com/mk6i/retro-aim-server/wire"
 )
 
-// ChatService represents a service that implements a chat room session.
+// ChatServer represents a service that implements a chat room session.
 // Clients connect to this service upon creating a chat room or being invited
 // to a chat room.
-type ChatService struct {
-	AuthHandler
-	OServiceChatHandler
-	Router
-	config.Config
+type ChatServer struct {
+	AuthService
+	Handler
 	Logger *slog.Logger
+	OnlineNotifier
+	config.Config
 }
 
 // Start creates a TCP server that implements that chat flow.
-func (rt ChatService) Start() {
+func (rt ChatServer) Start() {
 	addr := config.Address("", rt.Config.ChatPort)
 	listener, err := net.Listen("tcp", addr)
 	if err != nil {
@@ -53,27 +52,24 @@ func (rt ChatService) Start() {
 	}
 }
 
-func (rt ChatService) handleNewConnection(ctx context.Context, rwc io.ReadWriteCloser) error {
-	seq := uint32(100)
-
-	flap, err := flapSignonHandshake(rwc, &seq)
+func (rt ChatServer) handleNewConnection(ctx context.Context, rwc io.ReadWriteCloser) error {
+	flapc := &flapClient{
+		r:        rwc,
+		sequence: 100,
+		w:        rwc,
+	}
+	flap, err := flapc.SignonHandshake()
 	if err != nil {
 		return err
 	}
 
 	var ok bool
-	buf, ok := flap.Slice(oscar.OServiceTLVTagsLoginCookie)
+	loginCookie, ok := flap.Slice(wire.OServiceTLVTagsLoginCookie)
 	if !ok {
-		return errors.New("unable to get session id from payload")
-	}
-
-	cookie := ChatCookie{}
-	if err := oscar.Unmarshal(&cookie, bytes.NewBuffer(buf)); err != nil {
-		return err
+		return errors.New("unable to get login cookie from payload")
 	}
-	chatID := string(cookie.Cookie)
 
-	chatSess, err := rt.RetrieveChatSession(chatID, cookie.SessID)
+	chatSess, err := rt.RetrieveChatSession(loginCookie)
 	if err != nil {
 		return err
 	}
@@ -84,16 +80,16 @@ func (rt ChatService) handleNewConnection(ctx context.Context, rwc io.ReadWriteC
 	defer func() {
 		chatSess.Close()
 		rwc.Close()
-		if err := rt.SignoutChat(ctx, chatSess, chatID); err != nil {
+		if err := rt.SignoutChat(ctx, chatSess); err != nil {
 			rt.Logger.ErrorContext(ctx, "unable to sign out user", "err", err.Error())
 		}
 	}()
 
-	msg := rt.WriteOServiceHostOnline()
-	if err := sendSNAC(msg.Frame, msg.Body, &seq, rwc); err != nil {
+	msg := rt.HostOnline()
+	if err := flapc.SendSNAC(msg.Frame, msg.Body); err != nil {
 		return err
 	}
 
 	ctx = context.WithValue(ctx, "screenName", chatSess.ScreenName())
-	return dispatchIncomingMessages(ctx, chatSess, seq, rwc, rt.Logger, rt.Router, sendSNAC, rt.Config)
+	return dispatchIncomingMessages(ctx, chatSess, flapc, rwc, rt.Logger, rt.Handler, rt.Config)
 }

+ 106 - 0
server/oscar/chat_test.go

@@ -0,0 +1,106 @@
+package oscar
+
+import (
+	"bytes"
+	"context"
+	"io"
+	"log/slog"
+	"testing"
+
+	"github.com/mk6i/retro-aim-server/state"
+	"github.com/mk6i/retro-aim-server/wire"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/mock"
+)
+
+func TestChatService_handleNewConnection(t *testing.T) {
+	sess := state.NewSession()
+	sess.SetID("session-id-1234")
+
+	clientReader, serverWriter := io.Pipe()
+	serverReader, clientWriter := io.Pipe()
+
+	go func() {
+		// < receive FLAPSignonFrame
+		flap := wire.FLAPFrame{}
+		assert.NoError(t, wire.Unmarshal(&flap, serverReader))
+		buf, err := flap.ReadBody(serverReader)
+		assert.NoError(t, err)
+		flapSignonFrame := wire.FLAPSignonFrame{}
+		assert.NoError(t, wire.Unmarshal(&flapSignonFrame, buf))
+
+		// > send FLAPSignonFrame
+		flapSignonFrame = wire.FLAPSignonFrame{
+			FLAPVersion: 1,
+		}
+		flapSignonFrame.Append(wire.NewTLV(wire.OServiceTLVTagsLoginCookie, []byte(`the-chat-login-cookie`)))
+		buf = &bytes.Buffer{}
+		assert.NoError(t, wire.Marshal(flapSignonFrame, buf))
+		flap = wire.FLAPFrame{
+			StartMarker:   42,
+			FrameType:     wire.FLAPFrameSignon,
+			PayloadLength: uint16(buf.Len()),
+		}
+		assert.NoError(t, wire.Marshal(flap, serverWriter))
+		_, err = serverWriter.Write(buf.Bytes())
+		assert.NoError(t, err)
+
+		// < receive SNAC_0x01_0x03_OServiceHostOnline
+		flap = wire.FLAPFrame{}
+		assert.NoError(t, wire.Unmarshal(&flap, serverReader))
+		buf, err = flap.ReadBody(serverReader)
+		assert.NoError(t, err)
+		frame := wire.SNACFrame{}
+		assert.NoError(t, wire.Unmarshal(&frame, buf))
+		body := wire.SNAC_0x01_0x03_OServiceHostOnline{}
+		assert.NoError(t, wire.Unmarshal(&body, buf))
+
+		// send the first request that should get relayed to BOSRouter.Handle
+		flapc := flapClient{
+			w: serverWriter,
+		}
+		frame = wire.SNACFrame{
+			FoodGroup: wire.Chat,
+			SubGroup:  wire.ChatNavNavInfo,
+		}
+		assert.NoError(t, flapc.SendSNAC(frame, struct{}{}))
+		assert.NoError(t, serverWriter.Close())
+	}()
+
+	authService := newMockAuthService(t)
+	authService.EXPECT().
+		RetrieveChatSession([]byte(`the-chat-login-cookie`)).
+		Return(sess, nil)
+	authService.EXPECT().
+		SignoutChat(mock.Anything, sess).
+		Return(nil)
+
+	onlineNotifier := newMockOnlineNotifier(t)
+	onlineNotifier.EXPECT().
+		HostOnline().
+		Return(wire.SNACMessage{
+			Frame: wire.SNACFrame{
+				FoodGroup: wire.OService,
+				SubGroup:  wire.OServiceHostOnline,
+			},
+			Body: wire.SNAC_0x01_0x03_OServiceHostOnline{},
+		})
+
+	bosRouter := newMockHandler(t)
+	bosRouter.EXPECT().
+		Handle(mock.Anything, sess, mock.Anything, mock.Anything, mock.Anything).
+		Return(nil)
+
+	rt := ChatServer{
+		AuthService:    authService,
+		Handler:        bosRouter,
+		Logger:         slog.Default(),
+		OnlineNotifier: onlineNotifier,
+	}
+	rwc := pipeRWC{
+		PipeReader: clientReader,
+		PipeWriter: clientWriter,
+	}
+	assert.NoError(t, rt.handleNewConnection(context.Background(), rwc))
+}

+ 239 - 0
server/oscar/connection.go

@@ -0,0 +1,239 @@
+package oscar
+
+import (
+	"bytes"
+	"context"
+	"errors"
+	"fmt"
+	"io"
+	"log/slog"
+
+	"github.com/mk6i/retro-aim-server/config"
+	"github.com/mk6i/retro-aim-server/server/oscar/middleware"
+	"github.com/mk6i/retro-aim-server/state"
+	"github.com/mk6i/retro-aim-server/wire"
+)
+
+type incomingMessage struct {
+	flap    wire.FLAPFrame
+	payload *bytes.Buffer
+}
+
+type flapClient struct {
+	sequence uint32
+	w        io.Writer
+	r        io.Reader
+}
+
+func (f *flapClient) SignonHandshake() (wire.FLAPSignonFrame, error) {
+	// send FLAPFrameSignon to client
+	flap := wire.FLAPFrame{
+		StartMarker:   42,
+		FrameType:     wire.FLAPFrameSignon,
+		Sequence:      uint16(f.sequence),
+		PayloadLength: 4, // size of FLAPSignonFrame
+	}
+	if err := wire.Marshal(flap, f.w); err != nil {
+		return wire.FLAPSignonFrame{}, err
+	}
+	flapSignonFrameOut := wire.FLAPSignonFrame{
+		FLAPVersion: 1,
+	}
+	if err := wire.Marshal(flapSignonFrameOut, f.w); err != nil {
+		return wire.FLAPSignonFrame{}, err
+	}
+
+	// receive FLAPFrameSignon from client
+	flap = wire.FLAPFrame{}
+	if err := wire.Unmarshal(&flap, f.r); err != nil {
+		return wire.FLAPSignonFrame{}, err
+	}
+	buf, err := flap.ReadBody(f.r)
+	if err != nil {
+		return wire.FLAPSignonFrame{}, err
+	}
+	flapSignonFrameIn := wire.FLAPSignonFrame{}
+	if err := wire.Unmarshal(&flapSignonFrameIn, buf); err != nil {
+		return wire.FLAPSignonFrame{}, err
+	}
+
+	f.sequence++
+
+	return flapSignonFrameIn, nil
+}
+
+func (f *flapClient) SendSNAC(frame wire.SNACFrame, body any) error {
+	snacBuf := &bytes.Buffer{}
+	if err := wire.Marshal(frame, snacBuf); err != nil {
+		return err
+	}
+	if err := wire.Marshal(body, snacBuf); err != nil {
+		return err
+	}
+
+	flap := wire.FLAPFrame{
+		StartMarker:   42,
+		FrameType:     wire.FLAPFrameData,
+		Sequence:      uint16(f.sequence),
+		PayloadLength: uint16(snacBuf.Len()),
+	}
+
+	if err := wire.Marshal(flap, f.w); err != nil {
+		return err
+	}
+
+	expectLen := snacBuf.Len()
+	c, err := f.w.Write(snacBuf.Bytes())
+	if err != nil {
+		return err
+	}
+	if c != expectLen {
+		panic("did not write the expected # of bytes")
+	}
+
+	f.sequence++
+	return nil
+}
+
+func (f *flapClient) ReceiveSNAC(frame *wire.SNACFrame, body any) error {
+	flap := wire.FLAPFrame{}
+	if err := wire.Unmarshal(&flap, f.r); err != nil {
+		return err
+	}
+	buf, err := flap.ReadBody(f.r)
+	if err != nil {
+		return err
+	}
+	if err := wire.Unmarshal(frame, buf); err != nil {
+		return err
+	}
+	return wire.Unmarshal(body, buf)
+}
+
+func (f *flapClient) Disconnect() error {
+	// gracefully disconnect so that the client does not try to
+	// reconnect when the connection closes.
+	flap := wire.FLAPFrame{
+		StartMarker:   42,
+		FrameType:     wire.FLAPFrameSignoff,
+		Sequence:      uint16(f.sequence),
+		PayloadLength: uint16(0),
+	}
+	return wire.Marshal(flap, f.w)
+}
+
+func sendInvalidSNACErr(frameIn wire.SNACFrame, rw ResponseWriter) error {
+	frameOut := wire.SNACFrame{
+		FoodGroup: frameIn.FoodGroup,
+		SubGroup:  0x01, // error subgroup for all SNACs
+		RequestID: frameIn.RequestID,
+	}
+	bodyOut := wire.SNACError{
+		Code: wire.ErrorCodeInvalidSnac,
+	}
+	return rw.SendSNAC(frameOut, bodyOut)
+}
+
+func consumeFLAPFrames(r io.Reader, msgCh chan incomingMessage, errCh chan error) {
+	defer close(msgCh)
+	defer close(errCh)
+
+	for {
+		in := incomingMessage{}
+		if err := wire.Unmarshal(&in.flap, r); err != nil {
+			errCh <- err
+			return
+		}
+
+		if in.flap.FrameType == wire.FLAPFrameData {
+			buf := make([]byte, in.flap.PayloadLength)
+			if _, err := r.Read(buf); err != nil {
+				errCh <- err
+				return
+			}
+			in.payload = bytes.NewBuffer(buf)
+		}
+
+		msgCh <- in
+	}
+}
+
+// dispatchIncomingMessages receives incoming messages and sends them to the
+// appropriate message handler. Messages from the client are sent to the
+// router. Messages relayed from the user session are forwarded to the client.
+// This function ensures that the same sequence number is incremented for both
+// types of messages. The function terminates upon receiving a connection error
+// or when the session closes.
+//
+// todo: this method has too many params and should be folded into a new type
+func dispatchIncomingMessages(ctx context.Context, sess *state.Session, flapc *flapClient, r io.Reader, logger *slog.Logger, router Handler, config config.Config) error {
+	// buffered so that the go routine has room to exit
+	msgCh := make(chan incomingMessage, 1)
+	readErrCh := make(chan error, 1)
+	go consumeFLAPFrames(r, msgCh, readErrCh)
+
+	defer func() {
+		logger.InfoContext(ctx, "user disconnected")
+	}()
+
+	for {
+		select {
+		case m, ok := <-msgCh:
+			if !ok {
+				return nil
+			}
+			switch m.flap.FrameType {
+			case wire.FLAPFrameData:
+				inFrame := wire.SNACFrame{}
+				if err := wire.Unmarshal(&inFrame, m.payload); err != nil {
+					return err
+				}
+				// route a client request to the appropriate service handler. the
+				// handler may write a response to the client connection.
+				if err := router.Handle(ctx, sess, inFrame, m.payload, flapc); err != nil {
+					middleware.LogRequestError(ctx, logger, inFrame, err)
+					if errors.Is(err, ErrRouteNotFound) || errors.Is(err, wire.ErrUnsupportedFoodGroup) {
+						if err1 := sendInvalidSNACErr(inFrame, flapc); err1 != nil {
+							return errors.Join(err1, err)
+						}
+						if config.FailFast {
+							panic(err.Error())
+						}
+						break
+					}
+					return err
+				}
+			case wire.FLAPFrameSignon:
+				return fmt.Errorf("shouldn't get FLAPFrameSignon. flap: %v", m.flap)
+			case wire.FLAPFrameError:
+				return fmt.Errorf("got FLAPFrameError. flap: %v", m.flap)
+			case wire.FLAPFrameSignoff:
+				logger.InfoContext(ctx, "got FLAPFrameSignoff", "flap", m.flap)
+				return nil
+			case wire.FLAPFrameKeepAlive:
+				logger.DebugContext(ctx, "keepalive heartbeat")
+			default:
+				return fmt.Errorf("got unknown FLAP frame type. flap: %v", m.flap)
+			}
+		case m := <-sess.ReceiveMessage():
+			// forward a notification sent from another client to this client
+			if err := flapc.SendSNAC(m.Frame, m.Body); err != nil {
+				middleware.LogRequestError(ctx, logger, m.Frame, err)
+				return err
+			}
+			middleware.LogRequest(ctx, logger, m.Frame, m.Body)
+		case <-sess.Closed():
+			// gracefully disconnect so that the client does not try to
+			// reconnect when the connection closes.
+			if err := flapc.Disconnect(); err != nil {
+				return fmt.Errorf("unable to gracefully disconnect user. %w", err)
+			}
+			return nil
+		case err := <-readErrCh:
+			if !errors.Is(io.EOF, err) {
+				logger.ErrorContext(ctx, "client disconnected with error", "err", err)
+			}
+			return nil
+		}
+	}
+}

+ 173 - 0
server/oscar/connection_test.go

@@ -0,0 +1,173 @@
+package oscar
+
+import (
+	"context"
+	"io"
+	"log/slog"
+	"sync"
+	"testing"
+
+	"github.com/mk6i/retro-aim-server/config"
+	"github.com/mk6i/retro-aim-server/state"
+	"github.com/mk6i/retro-aim-server/wire"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/mock"
+)
+
+func TestHandleChatConnection_MessageRelay(t *testing.T) {
+	sessionManager := state.NewInMemorySessionManager(slog.Default())
+	// add a user to session that will receive relayed messages
+	sess := sessionManager.AddSession("bob-sess-id", "bob")
+
+	// start the server connection handler in the background
+	serverReader, _ := io.Pipe()
+	clientReader, serverWriter := io.Pipe()
+	go func() {
+		flapc := &flapClient{
+			w: serverWriter,
+		}
+		err := dispatchIncomingMessages(context.Background(), sess, flapc, serverReader, slog.Default(), nil, config.Config{})
+		assert.NoError(t, err)
+	}()
+
+	inboundMsgs := []wire.SNACMessage{
+		{
+			Frame: wire.SNACFrame{
+				FoodGroup: wire.Chat,
+				SubGroup:  wire.ChatUsersJoined,
+			},
+			Body: wire.SNAC_0x0E_0x03_ChatUsersJoined{
+				Users: []wire.TLVUserInfo{
+					{
+						ScreenName: "screenname1",
+					},
+				},
+			},
+		},
+		{
+			Frame: wire.SNACFrame{
+				FoodGroup: wire.Chat,
+				SubGroup:  wire.ChatUsersLeft,
+			},
+			Body: wire.SNAC_0x0E_0x03_ChatUsersJoined{
+				Users: []wire.TLVUserInfo{
+					{
+						ScreenName: "screenname2",
+					},
+				},
+			},
+		},
+	}
+
+	// relay messages to user session
+	for _, msg := range inboundMsgs {
+		sessionManager.RelayToScreenName(context.Background(), "bob", msg)
+	}
+
+	// consume and verify the relayed messages
+	for i := 0; i < len(inboundMsgs); i++ {
+		flap := wire.FLAPFrame{}
+		assert.NoError(t, wire.Unmarshal(&flap, clientReader))
+		snac, err := flap.ReadBody(clientReader)
+		assert.NoError(t, err)
+		frame := wire.SNACFrame{}
+		assert.NoError(t, wire.Unmarshal(&frame, snac))
+		assert.Equal(t, inboundMsgs[i].Frame, frame)
+		body := wire.SNAC_0x0E_0x03_ChatUsersJoined{}
+		assert.NoError(t, wire.Unmarshal(&body, snac))
+		assert.Equal(t, inboundMsgs[i].Body, body)
+	}
+
+	// stop the session, which terminates the connection handler goroutine
+	sess.Close()
+	<-sess.Closed()
+
+	// verify the connection handler sends client disconnection message before
+	// terminating
+	flap := wire.FLAPFrame{}
+	assert.NoError(t, wire.Unmarshal(&flap, clientReader))
+	assert.Equal(t, wire.FLAPFrameSignoff, flap.FrameType)
+}
+
+func TestHandleChatConnection_ClientRequest(t *testing.T) {
+	sessionManager := state.NewInMemorySessionManager(slog.Default())
+	// add session so that the function can terminate upon closure
+	sess := sessionManager.AddSession("bob-sess-id", "bob")
+
+	inboundMsgs := []wire.SNACMessage{
+		{
+			Frame: wire.SNACFrame{
+				FoodGroup: wire.Chat,
+				SubGroup:  wire.ChatUsersJoined,
+			},
+			Body: wire.SNAC_0x0E_0x03_ChatUsersJoined{
+				Users: []wire.TLVUserInfo{
+					{
+						ScreenName: "screenname1",
+					},
+				},
+			},
+		},
+		{
+			Frame: wire.SNACFrame{
+				FoodGroup: wire.Chat,
+				SubGroup:  wire.ChatUsersLeft,
+			},
+			Body: wire.SNAC_0x0E_0x03_ChatUsersJoined{
+				Users: []wire.TLVUserInfo{
+					{
+						ScreenName: "screenname2",
+					},
+				},
+			},
+		},
+	}
+
+	wg := &sync.WaitGroup{}
+	wg.Add(len(inboundMsgs))
+
+	// set up mock handlers to receive messages and verify their contents
+	router := newMockHandler(t)
+	for _, msg := range inboundMsgs {
+		msg := msg
+		router.EXPECT().
+			Handle(mock.Anything, sess, msg.Frame, mock.Anything, mock.Anything).
+			Run(func(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter) {
+				defer wg.Done()
+				body := wire.SNAC_0x0E_0x03_ChatUsersJoined{}
+				assert.NoError(t, wire.Unmarshal(&body, r))
+				assert.Equal(t, msg.Body, body)
+			}).
+			Return(nil)
+	}
+
+	// start the server connection handler in the background
+	serverReader, clientWriter := io.Pipe()
+	clientReader, serverWriter := io.Pipe()
+	go func() {
+		flapc := &flapClient{
+			w: serverWriter,
+		}
+		assert.NoError(t, dispatchIncomingMessages(context.Background(), sess, flapc, serverReader, slog.Default(), router, config.Config{}))
+	}()
+
+	// send client messages
+	flapc := flapClient{
+		w: clientWriter,
+	}
+	for _, msg := range inboundMsgs {
+		err := flapc.SendSNAC(msg.Frame, msg.Body)
+		assert.NoError(t, err)
+	}
+	wg.Wait()
+
+	// stop the session, which terminates the connection handler goroutine
+	sess.Close()
+	<-sess.Closed()
+
+	// verify the connection handler sends client disconnection message before
+	// terminating
+	flap := wire.FLAPFrame{}
+	assert.NoError(t, wire.Unmarshal(&flap, clientReader))
+	assert.Equal(t, wire.FLAPFrameSignoff, flap.FrameType)
+}

+ 36 - 0
server/oscar/handler/alert.go

@@ -0,0 +1,36 @@
+package handler
+
+import (
+	"context"
+	"github.com/mk6i/retro-aim-server/server/oscar"
+	"io"
+	"log/slog"
+
+	"github.com/mk6i/retro-aim-server/server/oscar/middleware"
+	"github.com/mk6i/retro-aim-server/state"
+	"github.com/mk6i/retro-aim-server/wire"
+)
+
+func NewAlertHandler(logger *slog.Logger) AlertHandler {
+	return AlertHandler{
+		RouteLogger: middleware.RouteLogger{
+			Logger: logger,
+		},
+	}
+}
+
+// AlertHandler just reads the request to placate the client. No need to send a
+// response.
+type AlertHandler struct {
+	middleware.RouteLogger
+}
+
+func (rt AlertHandler) NotifyCapabilities(ctx context.Context, _ *state.Session, inFrame wire.SNACFrame, _ io.Reader, _ oscar.ResponseWriter) error {
+	rt.LogRequest(ctx, inFrame, nil)
+	return nil
+}
+
+func (rt AlertHandler) NotifyDisplayCapabilities(ctx context.Context, _ *state.Session, inFrame wire.SNACFrame, _ io.Reader, _ oscar.ResponseWriter) error {
+	rt.LogRequest(ctx, inFrame, nil)
+	return nil
+}

+ 45 - 0
server/oscar/handler/alert_test.go

@@ -0,0 +1,45 @@
+package handler
+
+import (
+	"bytes"
+	"log/slog"
+	"testing"
+
+	"github.com/mk6i/retro-aim-server/wire"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestAlertHandler_NotifyCapabilities(t *testing.T) {
+	input := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Alert,
+			SubGroup:  wire.AlertNotifyCapabilities,
+		},
+		Body: wire.SNACFrame{},
+	}
+
+	h := NewAlertHandler(slog.Default())
+
+	buf := &bytes.Buffer{}
+	assert.NoError(t, wire.Marshal(input.Body, buf))
+
+	assert.NoError(t, h.NotifyCapabilities(nil, nil, input.Frame, buf, nil))
+}
+
+func TestAlertHandler_NotifyDisplayCapabilities(t *testing.T) {
+	input := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Alert,
+			SubGroup:  wire.AlertNotifyDisplayCapabilities,
+		},
+		Body: wire.SNACFrame{},
+	}
+
+	h := NewAlertHandler(slog.Default())
+
+	buf := &bytes.Buffer{}
+	assert.NoError(t, wire.Marshal(input.Body, buf))
+
+	assert.NoError(t, h.NotifyDisplayCapabilities(nil, nil, input.Frame, buf, nil))
+}

+ 40 - 0
server/oscar/handler/buddy.go

@@ -0,0 +1,40 @@
+package handler
+
+import (
+	"context"
+	"github.com/mk6i/retro-aim-server/server/oscar"
+	"io"
+	"log/slog"
+
+	"github.com/mk6i/retro-aim-server/server/oscar/middleware"
+	"github.com/mk6i/retro-aim-server/state"
+	"github.com/mk6i/retro-aim-server/wire"
+)
+
+type BuddyService interface {
+	RightsQuery(ctx context.Context, inFrame wire.SNACFrame) wire.SNACMessage
+}
+
+func NewBuddyHandler(logger *slog.Logger, buddyService BuddyService) BuddyHandler {
+	return BuddyHandler{
+		BuddyService: buddyService,
+		RouteLogger: middleware.RouteLogger{
+			Logger: logger,
+		},
+	}
+}
+
+type BuddyHandler struct {
+	BuddyService
+	middleware.RouteLogger
+}
+
+func (rt BuddyHandler) RightsQuery(ctx context.Context, _ *state.Session, inFrame wire.SNACFrame, r io.Reader, rw oscar.ResponseWriter) error {
+	inSNAC := wire.SNAC_0x03_0x02_BuddyRightsQuery{}
+	if err := wire.Unmarshal(&inSNAC, r); err != nil {
+		return err
+	}
+	outSNAC := rt.BuddyService.RightsQuery(ctx, inFrame)
+	rt.LogRequestAndResponse(ctx, inFrame, inSNAC, outSNAC.Frame, outSNAC.Body)
+	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
+}

+ 58 - 0
server/oscar/handler/buddy_test.go

@@ -0,0 +1,58 @@
+package handler
+
+import (
+	"bytes"
+	"log/slog"
+	"testing"
+
+	"github.com/mk6i/retro-aim-server/wire"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/mock"
+)
+
+func TestBuddyHandler_RightsQuery(t *testing.T) {
+	input := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Buddy,
+			SubGroup:  wire.BuddyRightsQuery,
+		},
+		Body: wire.SNAC_0x03_0x02_BuddyRightsQuery{
+			TLVRestBlock: wire.TLVRestBlock{
+				TLVList: wire.TLVList{
+					wire.NewTLV(0x01, uint16(1000)),
+				},
+			},
+		},
+	}
+	output := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Buddy,
+			SubGroup:  wire.BuddyRightsReply,
+		},
+		Body: wire.SNAC_0x03_0x03_BuddyRightsReply{
+			TLVRestBlock: wire.TLVRestBlock{
+				TLVList: wire.TLVList{
+					wire.NewTLV(0x01, uint16(1000)),
+				},
+			},
+		},
+	}
+
+	svc := newMockBuddyService(t)
+	svc.EXPECT().
+		RightsQuery(mock.Anything, input.Frame).
+		Return(output)
+
+	h := NewBuddyHandler(slog.Default(), svc)
+
+	responseWriter := newMockResponseWriter(t)
+	responseWriter.EXPECT().
+		SendSNAC(output.Frame, output.Body).
+		Return(nil)
+
+	buf := &bytes.Buffer{}
+	assert.NoError(t, wire.Marshal(input.Body, buf))
+
+	assert.NoError(t, h.RightsQuery(nil, nil, input.Frame, buf, responseWriter))
+}

+ 47 - 0
server/oscar/handler/chat.go

@@ -0,0 +1,47 @@
+package handler
+
+import (
+	"context"
+	"github.com/mk6i/retro-aim-server/server/oscar"
+	"io"
+	"log/slog"
+
+	"github.com/mk6i/retro-aim-server/server/oscar/middleware"
+	"github.com/mk6i/retro-aim-server/state"
+	"github.com/mk6i/retro-aim-server/wire"
+)
+
+type ChatService interface {
+	ChannelMsgToHost(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x0E_0x05_ChatChannelMsgToHost) (*wire.SNACMessage, error)
+}
+
+func NewChatHandler(logger *slog.Logger, chatService ChatService) ChatHandler {
+	return ChatHandler{
+		ChatService: chatService,
+		RouteLogger: middleware.RouteLogger{
+			Logger: logger,
+		},
+	}
+}
+
+type ChatHandler struct {
+	ChatService
+	middleware.RouteLogger
+}
+
+func (rt ChatHandler) ChannelMsgToHost(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw oscar.ResponseWriter) error {
+	inBody := wire.SNAC_0x0E_0x05_ChatChannelMsgToHost{}
+	if err := wire.Unmarshal(&inBody, r); err != nil {
+		return err
+	}
+	outSNAC, err := rt.ChatService.ChannelMsgToHost(ctx, sess, inFrame, inBody)
+	if err != nil {
+		return err
+	}
+	if outSNAC == nil {
+		return nil
+	}
+	rt.Logger.InfoContext(ctx, "user sent a chat message")
+	rt.LogRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
+	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
+}

+ 66 - 0
server/oscar/handler/chat_nav.go

@@ -0,0 +1,66 @@
+package handler
+
+import (
+	"context"
+	"github.com/mk6i/retro-aim-server/server/oscar"
+	"io"
+	"log/slog"
+
+	"github.com/mk6i/retro-aim-server/server/oscar/middleware"
+	"github.com/mk6i/retro-aim-server/state"
+	"github.com/mk6i/retro-aim-server/wire"
+)
+
+type ChatNavService interface {
+	CreateRoom(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate) (wire.SNACMessage, error)
+	RequestChatRights(ctx context.Context, inFrame wire.SNACFrame) wire.SNACMessage
+	RequestRoomInfo(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x0D_0x04_ChatNavRequestRoomInfo) (wire.SNACMessage, error)
+}
+
+func NewChatNavHandler(chatNavService ChatNavService, logger *slog.Logger) ChatNavHandler {
+	return ChatNavHandler{
+		ChatNavService: chatNavService,
+		RouteLogger: middleware.RouteLogger{
+			Logger: logger,
+		},
+	}
+}
+
+type ChatNavHandler struct {
+	ChatNavService
+	middleware.RouteLogger
+}
+
+func (rt ChatNavHandler) RequestChatRights(ctx context.Context, _ *state.Session, inFrame wire.SNACFrame, _ io.Reader, rw oscar.ResponseWriter) error {
+	outSNAC := rt.ChatNavService.RequestChatRights(ctx, inFrame)
+	rt.LogRequestAndResponse(ctx, inFrame, nil, outSNAC.Frame, outSNAC.Body)
+	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
+}
+
+func (rt ChatNavHandler) RequestRoomInfo(ctx context.Context, _ *state.Session, inFrame wire.SNACFrame, r io.Reader, rw oscar.ResponseWriter) error {
+	inBody := wire.SNAC_0x0D_0x04_ChatNavRequestRoomInfo{}
+	if err := wire.Unmarshal(&inBody, r); err != nil {
+		return err
+	}
+	outSNAC, err := rt.ChatNavService.RequestRoomInfo(ctx, inFrame, inBody)
+	if err != nil {
+		return err
+	}
+	rt.LogRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
+	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
+}
+
+func (rt ChatNavHandler) CreateRoom(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw oscar.ResponseWriter) error {
+	inBody := wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate{}
+	if err := wire.Unmarshal(&inBody, r); err != nil {
+		return err
+	}
+	outSNAC, err := rt.ChatNavService.CreateRoom(ctx, sess, inFrame, inBody)
+	if err != nil {
+		return err
+	}
+	roomName, _ := inBody.String(wire.ChatTLVRoomName)
+	rt.Logger.InfoContext(ctx, "user started a chat room", slog.String("roomName", roomName))
+	rt.LogRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
+	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
+}

+ 154 - 0
server/oscar/handler/chat_nav_test.go

@@ -0,0 +1,154 @@
+package handler
+
+import (
+	"bytes"
+	"log/slog"
+	"testing"
+
+	"github.com/mk6i/retro-aim-server/wire"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/mock"
+)
+
+func TestChatNavHandler_CreateRoom(t *testing.T) {
+	input := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.ChatNav,
+			SubGroup:  wire.ChatNavCreateRoom,
+		},
+		Body: wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate{
+			Exchange: 1,
+		},
+	}
+	output := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.ChatNav,
+			SubGroup:  wire.ChatNavNavInfo,
+		},
+		Body: wire.SNAC_0x0D_0x09_ChatNavNavInfo{},
+	}
+
+	svc := newMockChatNavService(t)
+	svc.EXPECT().
+		CreateRoom(mock.Anything, mock.Anything, input.Frame, input.Body).
+		Return(output, nil)
+
+	h := NewChatNavHandler(svc, slog.Default())
+
+	ss := newMockResponseWriter(t)
+	ss.EXPECT().
+		SendSNAC(output.Frame, output.Body).
+		Return(nil)
+
+	buf := &bytes.Buffer{}
+	assert.NoError(t, wire.Marshal(input.Body, buf))
+
+	assert.NoError(t, h.CreateRoom(nil, nil, input.Frame, buf, ss))
+}
+
+func TestChatNavHandler_CreateRoom_ReadErr(t *testing.T) {
+	input := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.ChatNav,
+			SubGroup:  wire.ChatNavCreateRoom,
+		},
+		Body: wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate{
+			Exchange: 1,
+		},
+	}
+	output := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.ChatNav,
+			SubGroup:  wire.ChatNavNavInfo,
+		},
+		Body: wire.SNAC_0x0D_0x09_ChatNavNavInfo{},
+	}
+
+	svc := newMockChatNavService(t)
+	svc.EXPECT().
+		CreateRoom(mock.Anything, mock.Anything, input.Frame, input.Body).
+		Return(output, nil)
+
+	h := NewChatNavHandler(svc, slog.Default())
+
+	ss := newMockResponseWriter(t)
+	ss.EXPECT().
+		SendSNAC(output.Frame, output.Body).
+		Return(nil)
+
+	buf := &bytes.Buffer{}
+	assert.NoError(t, wire.Marshal(input.Body, buf))
+
+	assert.NoError(t, h.CreateRoom(nil, nil, input.Frame, buf, ss))
+}
+
+func TestChatNavHandler_RequestChatRights(t *testing.T) {
+	input := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.ChatNav,
+			SubGroup:  wire.ChatNavRequestChatRights,
+		},
+		Body: struct{}{},
+	}
+	output := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.ChatNav,
+			SubGroup:  wire.ChatNavNavInfo,
+		},
+		Body: wire.SNAC_0x0D_0x09_ChatNavNavInfo{},
+	}
+
+	svc := newMockChatNavService(t)
+	svc.EXPECT().
+		RequestChatRights(mock.Anything, input.Frame).
+		Return(output)
+
+	h := NewChatNavHandler(svc, slog.Default())
+
+	ss := newMockResponseWriter(t)
+	ss.EXPECT().
+		SendSNAC(output.Frame, output.Body).
+		Return(nil)
+
+	buf := &bytes.Buffer{}
+	assert.NoError(t, wire.Marshal(input.Body, buf))
+
+	assert.NoError(t, h.RequestChatRights(nil, nil, input.Frame, buf, ss))
+}
+
+func TestChatNavHandler_RequestRoomInfo(t *testing.T) {
+	input := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.ChatNav,
+			SubGroup:  wire.ChatNavRequestRoomInfo,
+		},
+		Body: wire.SNAC_0x0D_0x04_ChatNavRequestRoomInfo{
+			Exchange: 1,
+		},
+	}
+	output := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.ChatNav,
+			SubGroup:  wire.ChatNavNavInfo,
+		},
+		Body: wire.SNAC_0x0D_0x09_ChatNavNavInfo{},
+	}
+
+	svc := newMockChatNavService(t)
+	svc.EXPECT().
+		RequestRoomInfo(mock.Anything, input.Frame, input.Body).
+		Return(output, nil)
+
+	h := NewChatNavHandler(svc, slog.Default())
+
+	ss := newMockResponseWriter(t)
+	ss.EXPECT().
+		SendSNAC(output.Frame, output.Body).
+		Return(nil)
+
+	buf := &bytes.Buffer{}
+	assert.NoError(t, wire.Marshal(input.Body, buf))
+
+	assert.NoError(t, h.RequestRoomInfo(nil, nil, input.Frame, buf, ss))
+}

+ 79 - 0
server/oscar/handler/chat_test.go

@@ -0,0 +1,79 @@
+package handler
+
+import (
+	"bytes"
+	"log/slog"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/mock"
+
+	"github.com/mk6i/retro-aim-server/wire"
+)
+
+func TestChatHandler_ChannelMsgToHost_WithReflectedResponse(t *testing.T) {
+	input := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Chat,
+			SubGroup:  wire.ChatChannelMsgToHost,
+		},
+		Body: wire.SNAC_0x0E_0x05_ChatChannelMsgToHost{
+			Channel: 4,
+		},
+	}
+	output := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Chat,
+			SubGroup:  wire.ChatChannelMsgToClient,
+		},
+		Body: wire.SNAC_0x0E_0x06_ChatChannelMsgToClient{
+			Channel: 4,
+		},
+	}
+
+	svc := newMockChatService(t)
+	svc.EXPECT().
+		ChannelMsgToHost(mock.Anything, mock.Anything, input.Frame, input.Body).
+		Return(&output, nil)
+
+	h := NewChatHandler(slog.Default(), svc)
+
+	responseWriter := newMockResponseWriter(t)
+	responseWriter.EXPECT().
+		SendSNAC(output.Frame, output.Body).
+		Return(nil)
+
+	buf := &bytes.Buffer{}
+	assert.NoError(t, wire.Marshal(input.Body, buf))
+
+	assert.NoError(t, h.ChannelMsgToHost(nil, nil, input.Frame, buf, responseWriter))
+}
+
+func TestChatHandler_ChannelMsgToHost_WithoutReflectedResponse(t *testing.T) {
+	input := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Chat,
+			SubGroup:  wire.ChatChannelMsgToHost,
+		},
+		Body: wire.SNAC_0x0E_0x05_ChatChannelMsgToHost{
+			Channel: 4,
+		},
+	}
+	// nil response from handler means the response is not reflected back to
+	// the caller
+	var output *wire.SNACMessage
+
+	svc := newMockChatService(t)
+	svc.EXPECT().
+		ChannelMsgToHost(mock.Anything, mock.Anything, input.Frame, input.Body).
+		Return(output, nil)
+
+	h := NewChatHandler(slog.Default(), svc)
+
+	responseWriter := newMockResponseWriter(t) // omit mock handler call
+
+	buf := &bytes.Buffer{}
+	assert.NoError(t, wire.Marshal(input.Body, buf))
+
+	assert.NoError(t, h.ChannelMsgToHost(nil, nil, input.Frame, buf, responseWriter))
+}

+ 127 - 0
server/oscar/handler/feedbag.go

@@ -0,0 +1,127 @@
+package handler
+
+import (
+	"context"
+	"github.com/mk6i/retro-aim-server/server/oscar"
+	"io"
+	"log/slog"
+
+	"github.com/mk6i/retro-aim-server/server/oscar/middleware"
+	"github.com/mk6i/retro-aim-server/state"
+	"github.com/mk6i/retro-aim-server/wire"
+)
+
+type FeedbagService interface {
+	DeleteItem(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x0A_FeedbagDeleteItem) (wire.SNACMessage, error)
+	InsertItem(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x08_FeedbagInsertItem) (wire.SNACMessage, error)
+	Query(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame) (wire.SNACMessage, error)
+	QueryIfModified(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x05_FeedbagQueryIfModified) (wire.SNACMessage, error)
+	RightsQuery(ctx context.Context, inFrame wire.SNACFrame) wire.SNACMessage
+	StartCluster(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x11_FeedbagStartCluster)
+	UpdateItem(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x09_FeedbagUpdateItem) (wire.SNACMessage, error)
+}
+
+func NewFeedbagHandler(logger *slog.Logger, feedbagService FeedbagService) FeedbagHandler {
+	return FeedbagHandler{
+		FeedbagService: feedbagService,
+		RouteLogger: middleware.RouteLogger{
+			Logger: logger,
+		},
+	}
+}
+
+type FeedbagHandler struct {
+	FeedbagService
+	middleware.RouteLogger
+}
+
+func (h FeedbagHandler) RightsQuery(ctx context.Context, _ *state.Session, inFrame wire.SNACFrame, r io.Reader, rw oscar.ResponseWriter) error {
+	inBody := wire.SNAC_0x13_0x02_FeedbagRightsQuery{}
+	if err := wire.Unmarshal(&inBody, r); err != nil {
+		return err
+	}
+	outSNAC := h.FeedbagService.RightsQuery(ctx, inFrame)
+	h.LogRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
+	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
+}
+
+func (h FeedbagHandler) Query(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, _ io.Reader, rw oscar.ResponseWriter) error {
+	outSNAC, err := h.FeedbagService.Query(ctx, sess, inFrame)
+	if err != nil {
+		return err
+	}
+	h.LogRequest(ctx, inFrame, outSNAC)
+	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
+}
+
+func (h FeedbagHandler) QueryIfModified(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw oscar.ResponseWriter) error {
+	inBody := wire.SNAC_0x13_0x05_FeedbagQueryIfModified{}
+	if err := wire.Unmarshal(&inBody, r); err != nil {
+		return err
+	}
+	outSNAC, err := h.FeedbagService.QueryIfModified(ctx, sess, inFrame, inBody)
+	if err != nil {
+		return err
+	}
+	h.LogRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
+	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
+}
+
+func (h FeedbagHandler) Use(ctx context.Context, _ *state.Session, inFrame wire.SNACFrame, _ io.Reader, _ oscar.ResponseWriter) error {
+	h.LogRequest(ctx, inFrame, nil)
+	return nil
+}
+
+func (h FeedbagHandler) InsertItem(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw oscar.ResponseWriter) error {
+	inBody := wire.SNAC_0x13_0x08_FeedbagInsertItem{}
+	if err := wire.Unmarshal(&inBody, r); err != nil {
+		return err
+	}
+	outSNAC, err := h.FeedbagService.InsertItem(ctx, sess, inFrame, inBody)
+	if err != nil {
+		return err
+	}
+	h.LogRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
+	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
+}
+
+func (h FeedbagHandler) UpdateItem(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw oscar.ResponseWriter) error {
+	inBody := wire.SNAC_0x13_0x09_FeedbagUpdateItem{}
+	if err := wire.Unmarshal(&inBody, r); err != nil {
+		return err
+	}
+	outSNAC, err := h.FeedbagService.UpdateItem(ctx, sess, inFrame, inBody)
+	if err != nil {
+		return err
+	}
+	h.LogRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
+	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
+}
+
+func (h FeedbagHandler) DeleteItem(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw oscar.ResponseWriter) error {
+	inBody := wire.SNAC_0x13_0x0A_FeedbagDeleteItem{}
+	if err := wire.Unmarshal(&inBody, r); err != nil {
+		return err
+	}
+	outSNAC, err := h.FeedbagService.DeleteItem(ctx, sess, inFrame, inBody)
+	if err != nil {
+		return err
+	}
+	h.LogRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
+	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
+}
+
+func (h FeedbagHandler) StartCluster(ctx context.Context, _ *state.Session, inFrame wire.SNACFrame, r io.Reader, _ oscar.ResponseWriter) error {
+	inBody := wire.SNAC_0x13_0x11_FeedbagStartCluster{}
+	if err := wire.Unmarshal(&inBody, r); err != nil {
+		return err
+	}
+	h.FeedbagService.StartCluster(ctx, inFrame, inBody)
+	h.LogRequest(ctx, inFrame, inBody)
+	return nil
+}
+
+func (h FeedbagHandler) EndCluster(ctx context.Context, _ *state.Session, inFrame wire.SNACFrame, _ io.Reader, _ oscar.ResponseWriter) error {
+	h.LogRequest(ctx, inFrame, nil)
+	return nil
+}

+ 343 - 0
server/oscar/handler/feedbag_test.go

@@ -0,0 +1,343 @@
+package handler
+
+import (
+	"bytes"
+	"log/slog"
+	"testing"
+
+	"github.com/mk6i/retro-aim-server/wire"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/mock"
+)
+
+func TestFeedbagHandler_DeleteItem(t *testing.T) {
+	input := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Feedbag,
+			SubGroup:  wire.FeedbagDeleteItem,
+		},
+		Body: wire.SNAC_0x13_0x0A_FeedbagDeleteItem{
+			Items: []wire.FeedbagItem{
+				{
+					Name: "my-item",
+				},
+			},
+		},
+	}
+	output := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Feedbag,
+			SubGroup:  wire.FeedbagStatus,
+		},
+		Body: wire.SNAC_0x13_0x0E_FeedbagStatus{
+			Results: []uint16{1234},
+		},
+	}
+
+	svc := newMockFeedbagService(t)
+	svc.EXPECT().
+		DeleteItem(mock.Anything, mock.Anything, input.Frame, input.Body).
+		Return(output, nil)
+
+	h := NewFeedbagHandler(slog.Default(), svc)
+
+	responseWriter := newMockResponseWriter(t)
+	responseWriter.EXPECT().
+		SendSNAC(output.Frame, output.Body).
+		Return(nil)
+
+	buf := &bytes.Buffer{}
+	assert.NoError(t, wire.Marshal(input.Body, buf))
+
+	assert.NoError(t, h.DeleteItem(nil, nil, input.Frame, buf, responseWriter))
+}
+
+func TestFeedbagHandler_EndCluster(t *testing.T) {
+	input := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Feedbag,
+			SubGroup:  wire.FeedbagEndCluster,
+		},
+		Body: struct{}{},
+	}
+
+	svc := newMockFeedbagService(t)
+	h := NewFeedbagHandler(slog.Default(), svc)
+	responseWriter := newMockResponseWriter(t)
+
+	buf := &bytes.Buffer{}
+	assert.NoError(t, wire.Marshal(input.Body, buf))
+
+	assert.NoError(t, h.EndCluster(nil, nil, input.Frame, buf, responseWriter))
+}
+
+func TestFeedbagHandler_InsertItem(t *testing.T) {
+	input := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Feedbag,
+			SubGroup:  wire.FeedbagInsertItem,
+		},
+		Body: wire.SNAC_0x13_0x08_FeedbagInsertItem{
+			Items: []wire.FeedbagItem{
+				{
+					Name: "my-item",
+				},
+			},
+		},
+	}
+	output := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Feedbag,
+			SubGroup:  wire.FeedbagStatus,
+		},
+		Body: wire.SNAC_0x13_0x0E_FeedbagStatus{
+			Results: []uint16{1234},
+		},
+	}
+
+	svc := newMockFeedbagService(t)
+	svc.EXPECT().
+		InsertItem(mock.Anything, mock.Anything, input.Frame, input.Body).
+		Return(output, nil)
+
+	h := NewFeedbagHandler(slog.Default(), svc)
+
+	responseWriter := newMockResponseWriter(t)
+	responseWriter.EXPECT().
+		SendSNAC(output.Frame, output.Body).
+		Return(nil)
+
+	buf := &bytes.Buffer{}
+	assert.NoError(t, wire.Marshal(input.Body, buf))
+
+	assert.NoError(t, h.InsertItem(nil, nil, input.Frame, buf, responseWriter))
+}
+
+func TestFeedbagHandler_Query(t *testing.T) {
+	input := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Feedbag,
+			SubGroup:  wire.FeedbagQuery,
+		},
+		Body: wire.SNAC_0x13_0x02_FeedbagRightsQuery{
+			TLVRestBlock: wire.TLVRestBlock{
+				TLVList: wire.TLVList{
+					{
+						Tag:   0x01,
+						Value: []byte{1, 2, 3, 4},
+					},
+				},
+			},
+		},
+	}
+	output := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Feedbag,
+			SubGroup:  wire.FeedbagReply,
+		},
+		Body: wire.SNAC_0x13_0x06_FeedbagReply{
+			Version: 4,
+		},
+	}
+
+	svc := newMockFeedbagService(t)
+	svc.EXPECT().
+		Query(mock.Anything, mock.Anything, input.Frame).
+		Return(output, nil)
+
+	h := NewFeedbagHandler(slog.Default(), svc)
+
+	responseWriter := newMockResponseWriter(t)
+	responseWriter.EXPECT().
+		SendSNAC(output.Frame, output.Body).
+		Return(nil)
+
+	buf := &bytes.Buffer{}
+	assert.NoError(t, wire.Marshal(input.Body, buf))
+
+	assert.NoError(t, h.Query(nil, nil, input.Frame, buf, responseWriter))
+}
+
+func TestFeedbagHandler_QueryIfModified(t *testing.T) {
+	input := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Feedbag,
+			SubGroup:  wire.FeedbagQueryIfModified,
+		},
+		Body: wire.SNAC_0x13_0x05_FeedbagQueryIfModified{
+			LastUpdate: 1234,
+		},
+	}
+	output := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Feedbag,
+			SubGroup:  wire.FeedbagReply,
+		},
+		Body: wire.SNAC_0x13_0x06_FeedbagReply{
+			LastUpdate: 1234,
+		},
+	}
+
+	svc := newMockFeedbagService(t)
+	svc.EXPECT().
+		QueryIfModified(mock.Anything, mock.Anything, input.Frame, input.Body).
+		Return(output, nil)
+
+	h := NewFeedbagHandler(slog.Default(), svc)
+
+	responseWriter := newMockResponseWriter(t)
+	responseWriter.EXPECT().
+		SendSNAC(output.Frame, output.Body).
+		Return(nil)
+
+	buf := &bytes.Buffer{}
+	assert.NoError(t, wire.Marshal(input.Body, buf))
+
+	assert.NoError(t, h.QueryIfModified(nil, nil, input.Frame, buf, responseWriter))
+}
+
+func TestFeedbagHandler_RightsQuery(t *testing.T) {
+	input := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Feedbag,
+			SubGroup:  wire.FeedbagRightsQuery,
+		},
+		Body: wire.SNAC_0x13_0x02_FeedbagRightsQuery{
+			TLVRestBlock: wire.TLVRestBlock{
+				TLVList: wire.TLVList{
+					{
+						Tag:   0x01,
+						Value: []byte{1, 2, 3, 4},
+					},
+				},
+			},
+		},
+	}
+	output := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Feedbag,
+			SubGroup:  wire.FeedbagRightsReply,
+		},
+		Body: wire.SNAC_0x13_0x03_FeedbagRightsReply{
+			TLVRestBlock: wire.TLVRestBlock{
+				TLVList: wire.TLVList{
+					{
+						Tag:   0x01,
+						Value: []byte{1, 2, 3, 4},
+					},
+				},
+			},
+		},
+	}
+
+	svc := newMockFeedbagService(t)
+	svc.EXPECT().
+		RightsQuery(mock.Anything, input.Frame).
+		Return(output)
+
+	h := NewFeedbagHandler(slog.Default(), svc)
+
+	responseWriter := newMockResponseWriter(t)
+	responseWriter.EXPECT().
+		SendSNAC(output.Frame, output.Body).
+		Return(nil)
+
+	buf := &bytes.Buffer{}
+	assert.NoError(t, wire.Marshal(input.Body, buf))
+
+	assert.NoError(t, h.RightsQuery(nil, nil, input.Frame, buf, responseWriter))
+}
+
+func TestFeedbagHandler_StartCluster(t *testing.T) {
+	input := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Feedbag,
+			SubGroup:  wire.FeedbagStartCluster,
+		},
+		Body: wire.SNAC_0x13_0x11_FeedbagStartCluster{
+			TLVRestBlock: wire.TLVRestBlock{
+				TLVList: wire.TLVList{
+					{
+						Tag:   0x01,
+						Value: []byte{1, 2, 3, 4},
+					},
+				},
+			},
+		},
+	}
+
+	svc := newMockFeedbagService(t)
+	svc.EXPECT().
+		StartCluster(mock.Anything, input.Frame, input.Body)
+
+	h := NewFeedbagHandler(slog.Default(), svc)
+
+	responseWriter := newMockResponseWriter(t)
+
+	buf := &bytes.Buffer{}
+	assert.NoError(t, wire.Marshal(input.Body, buf))
+
+	assert.NoError(t, h.StartCluster(nil, nil, input.Frame, buf, responseWriter))
+}
+
+func TestFeedbagHandler_UpdateItem(t *testing.T) {
+	input := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Feedbag,
+			SubGroup:  wire.FeedbagUpdateItem,
+		},
+		Body: wire.SNAC_0x13_0x09_FeedbagUpdateItem{
+			Items: []wire.FeedbagItem{
+				{
+					Name: "my-item",
+				},
+			},
+		},
+	}
+	output := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Feedbag,
+			SubGroup:  wire.FeedbagStatus,
+		},
+		Body: wire.SNAC_0x13_0x0E_FeedbagStatus{
+			Results: []uint16{1234},
+		},
+	}
+
+	svc := newMockFeedbagService(t)
+	svc.EXPECT().
+		UpdateItem(mock.Anything, mock.Anything, input.Frame, input.Body).
+		Return(output, nil)
+
+	h := NewFeedbagHandler(slog.Default(), svc)
+
+	responseWriter := newMockResponseWriter(t)
+	responseWriter.EXPECT().
+		SendSNAC(output.Frame, output.Body).
+		Return(nil)
+
+	buf := &bytes.Buffer{}
+	assert.NoError(t, wire.Marshal(input.Body, buf))
+
+	assert.NoError(t, h.UpdateItem(nil, nil, input.Frame, buf, responseWriter))
+}
+
+func TestFeedbagHandler_Use(t *testing.T) {
+	input := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Feedbag,
+			SubGroup:  wire.FeedbagUse,
+		},
+		Body: struct{}{},
+	}
+
+	svc := newMockFeedbagService(t)
+	h := NewFeedbagHandler(slog.Default(), svc)
+	responseWriter := newMockResponseWriter(t)
+
+	buf := &bytes.Buffer{}
+	assert.NoError(t, wire.Marshal(input.Body, buf))
+
+	assert.NoError(t, h.Use(nil, nil, input.Frame, buf, responseWriter))
+}

+ 90 - 0
server/oscar/handler/icbm.go

@@ -0,0 +1,90 @@
+package handler
+
+import (
+	"context"
+	"github.com/mk6i/retro-aim-server/server/oscar"
+	"io"
+	"log/slog"
+
+	"github.com/mk6i/retro-aim-server/server/oscar/middleware"
+	"github.com/mk6i/retro-aim-server/state"
+	"github.com/mk6i/retro-aim-server/wire"
+)
+
+type ICBMService interface {
+	ChannelMsgToHost(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x06_ICBMChannelMsgToHost) (*wire.SNACMessage, error)
+	ClientEvent(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x14_ICBMClientEvent) error
+	EvilRequest(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x08_ICBMEvilRequest) (wire.SNACMessage, error)
+	ParameterQuery(ctx context.Context, inFrame wire.SNACFrame) wire.SNACMessage
+}
+
+func NewICBMHandler(logger *slog.Logger, icbmService ICBMService) ICBMHandler {
+	return ICBMHandler{
+		ICBMService: icbmService,
+		RouteLogger: middleware.RouteLogger{
+			Logger: logger,
+		},
+	}
+}
+
+type ICBMHandler struct {
+	ICBMService
+	middleware.RouteLogger
+}
+
+func (h ICBMHandler) AddParameters(ctx context.Context, _ *state.Session, inFrame wire.SNACFrame, r io.Reader, _ oscar.ResponseWriter) error {
+	inBody := wire.SNAC_0x04_0x02_ICBMAddParameters{}
+	h.LogRequest(ctx, inFrame, inBody)
+	return wire.Unmarshal(&inBody, r)
+}
+
+func (h ICBMHandler) ParameterQuery(ctx context.Context, _ *state.Session, inFrame wire.SNACFrame, _ io.Reader, rw oscar.ResponseWriter) error {
+	outSNAC := h.ICBMService.ParameterQuery(ctx, inFrame)
+	h.LogRequestAndResponse(ctx, inFrame, outSNAC, outSNAC.Frame, outSNAC.Body)
+	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
+}
+
+func (h ICBMHandler) ChannelMsgToHost(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw oscar.ResponseWriter) error {
+	inBody := wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{}
+	if err := wire.Unmarshal(&inBody, r); err != nil {
+		return err
+	}
+	outSNAC, err := h.ICBMService.ChannelMsgToHost(ctx, sess, inFrame, inBody)
+	if err != nil {
+		return err
+	}
+	h.Logger.InfoContext(ctx, "user sent an IM", slog.String("recipient", inBody.ScreenName))
+	if outSNAC == nil {
+		return nil
+	}
+	h.LogRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
+	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
+}
+
+func (h ICBMHandler) EvilRequest(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw oscar.ResponseWriter) error {
+	inBody := wire.SNAC_0x04_0x08_ICBMEvilRequest{}
+	if err := wire.Unmarshal(&inBody, r); err != nil {
+		return err
+	}
+	outSNAC, err := h.ICBMService.EvilRequest(ctx, sess, inFrame, inBody)
+	if err != nil {
+		return err
+	}
+	h.LogRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
+	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
+}
+
+func (h ICBMHandler) ClientErr(ctx context.Context, _ *state.Session, inFrame wire.SNACFrame, r io.Reader, _ oscar.ResponseWriter) error {
+	inBody := wire.SNAC_0x04_0x0B_ICBMClientErr{}
+	h.LogRequest(ctx, inFrame, inBody)
+	return wire.Unmarshal(&inBody, r)
+}
+
+func (h ICBMHandler) ClientEvent(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, _ oscar.ResponseWriter) error {
+	inBody := wire.SNAC_0x04_0x14_ICBMClientEvent{}
+	if err := wire.Unmarshal(&inBody, r); err != nil {
+		return err
+	}
+	h.LogRequest(ctx, inFrame, inBody)
+	return h.ICBMService.ClientEvent(ctx, sess, inFrame, inBody)
+}

+ 192 - 0
server/oscar/handler/icbm_test.go

@@ -0,0 +1,192 @@
+package handler
+
+import (
+	"bytes"
+	"log/slog"
+	"testing"
+
+	"github.com/mk6i/retro-aim-server/wire"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/mock"
+)
+
+func TestICBMHandler_AddParameters(t *testing.T) {
+	input := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.ICBM,
+			SubGroup:  wire.ICBMAddParameters,
+		},
+		Body: wire.SNAC_0x04_0x02_ICBMAddParameters{
+			Channel: 1,
+		},
+	}
+
+	svc := newMockICBMService(t)
+	h := NewICBMHandler(slog.Default(), svc)
+	responseWriter := newMockResponseWriter(t)
+
+	buf := &bytes.Buffer{}
+	assert.NoError(t, wire.Marshal(input.Body, buf))
+
+	assert.NoError(t, h.AddParameters(nil, nil, input.Frame, buf, responseWriter))
+}
+
+func TestICBMHandler_ChannelMsgToHost(t *testing.T) {
+	input := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.ICBM,
+			SubGroup:  wire.ICBMChannelMsgToHost,
+		},
+		Body: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+			ScreenName: "recipient-screen-name",
+		},
+	}
+	output := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.ICBM,
+			SubGroup:  wire.ICBMHostAck,
+		},
+		Body: wire.SNAC_0x04_0x0C_ICBMHostAck{
+			ChannelID: 4,
+		},
+	}
+
+	svc := newMockICBMService(t)
+	svc.EXPECT().
+		ChannelMsgToHost(mock.Anything, mock.Anything, input.Frame, input.Body).
+		Return(&output, nil)
+
+	h := NewICBMHandler(slog.Default(), svc)
+
+	responseWriter := newMockResponseWriter(t)
+	responseWriter.EXPECT().
+		SendSNAC(output.Frame, output.Body).
+		Return(nil)
+
+	buf := &bytes.Buffer{}
+	assert.NoError(t, wire.Marshal(input.Body, buf))
+
+	assert.NoError(t, h.ChannelMsgToHost(nil, nil, input.Frame, buf, responseWriter))
+}
+
+func TestICBMHandler_ClientErr(t *testing.T) {
+	input := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.ICBM,
+			SubGroup:  wire.ICBMClientErr,
+		},
+		Body: wire.SNAC_0x04_0x0B_ICBMClientErr{
+			Code: 4,
+		},
+	}
+
+	svc := newMockICBMService(t)
+	h := NewICBMHandler(slog.Default(), svc)
+	responseWriter := newMockResponseWriter(t)
+
+	buf := &bytes.Buffer{}
+	assert.NoError(t, wire.Marshal(input.Body, buf))
+
+	assert.NoError(t, h.ClientErr(nil, nil, input.Frame, buf, responseWriter))
+}
+
+func TestICBMHandler_ClientEvent(t *testing.T) {
+	input := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.ICBM,
+			SubGroup:  wire.ICBMClientEvent,
+		},
+		Body: wire.SNAC_0x04_0x14_ICBMClientEvent{
+			ScreenName: "recipient-screen-name",
+		},
+	}
+
+	svc := newMockICBMService(t)
+	svc.EXPECT().
+		ClientEvent(mock.Anything, mock.Anything, input.Frame, input.Body).
+		Return(nil)
+
+	h := NewICBMHandler(slog.Default(), svc)
+
+	responseWriter := newMockResponseWriter(t)
+
+	buf := &bytes.Buffer{}
+	assert.NoError(t, wire.Marshal(input.Body, buf))
+
+	assert.NoError(t, h.ClientEvent(nil, nil, input.Frame, buf, responseWriter))
+}
+
+func TestICBMHandler_EvilRequest(t *testing.T) {
+	input := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.ICBM,
+			SubGroup:  wire.ICBMEvilRequest,
+		},
+		Body: wire.SNAC_0x04_0x08_ICBMEvilRequest{
+			ScreenName: "recipient-screen-name",
+		},
+	}
+	output := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.ICBM,
+			SubGroup:  wire.ICBMEvilReply,
+		},
+		Body: wire.SNAC_0x04_0x09_ICBMEvilReply{
+			EvilDeltaApplied: 100,
+		},
+	}
+
+	svc := newMockICBMService(t)
+	svc.EXPECT().
+		EvilRequest(mock.Anything, mock.Anything, input.Frame, input.Body).
+		Return(output, nil)
+
+	h := NewICBMHandler(slog.Default(), svc)
+
+	responseWriter := newMockResponseWriter(t)
+	responseWriter.EXPECT().
+		SendSNAC(output.Frame, output.Body).
+		Return(nil)
+
+	buf := &bytes.Buffer{}
+	assert.NoError(t, wire.Marshal(input.Body, buf))
+
+	assert.NoError(t, h.EvilRequest(nil, nil, input.Frame, buf, responseWriter))
+}
+
+func TestICBMHandler_ParameterQuery(t *testing.T) {
+	input := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.ICBM,
+			SubGroup:  wire.ICBMParameterQuery,
+		},
+		Body: struct{}{}, // empty SNAC
+	}
+	output := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.ICBM,
+			SubGroup:  wire.ICBMParameterReply,
+		},
+		Body: wire.SNAC_0x04_0x05_ICBMParameterReply{
+			MaxSlots: 100,
+		},
+	}
+
+	svc := newMockICBMService(t)
+	svc.EXPECT().
+		ParameterQuery(mock.Anything, input.Frame).
+		Return(output)
+
+	h := NewICBMHandler(slog.Default(), svc)
+
+	responseWriter := newMockResponseWriter(t)
+	responseWriter.EXPECT().
+		SendSNAC(output.Frame, output.Body).
+		Return(nil)
+
+	buf := &bytes.Buffer{}
+	assert.NoError(t, wire.Marshal(input.Body, buf))
+
+	assert.NoError(t, h.ParameterQuery(nil, nil, input.Frame, buf, responseWriter))
+}

+ 88 - 0
server/oscar/handler/locate.go

@@ -0,0 +1,88 @@
+package handler
+
+import (
+	"context"
+	"github.com/mk6i/retro-aim-server/server/oscar"
+	"io"
+	"log/slog"
+
+	"github.com/mk6i/retro-aim-server/server/oscar/middleware"
+	"github.com/mk6i/retro-aim-server/state"
+	"github.com/mk6i/retro-aim-server/wire"
+)
+
+type LocateService interface {
+	RightsQuery(ctx context.Context, inFrame wire.SNACFrame) wire.SNACMessage
+	SetDirInfo(ctx context.Context, frame wire.SNACFrame) wire.SNACMessage
+	SetInfo(ctx context.Context, sess *state.Session, inBody wire.SNAC_0x02_0x04_LocateSetInfo) error
+	SetKeywordInfo(ctx context.Context, inFrame wire.SNACFrame) wire.SNACMessage
+	UserInfoQuery2(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x02_0x15_LocateUserInfoQuery2) (wire.SNACMessage, error)
+}
+
+func NewLocateHandler(locateService LocateService, logger *slog.Logger) LocateHandler {
+	return LocateHandler{
+		LocateService: locateService,
+		RouteLogger: middleware.RouteLogger{
+			Logger: logger,
+		},
+	}
+}
+
+type LocateHandler struct {
+	LocateService
+	middleware.RouteLogger
+}
+
+func (h LocateHandler) RightsQuery(ctx context.Context, _ *state.Session, inFrame wire.SNACFrame, _ io.Reader, rw oscar.ResponseWriter) error {
+	outSNAC := h.LocateService.RightsQuery(ctx, inFrame)
+	h.LogRequestAndResponse(ctx, inFrame, nil, outSNAC.Frame, outSNAC.Body)
+	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
+}
+
+func (h LocateHandler) SetInfo(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, _ oscar.ResponseWriter) error {
+	inBody := wire.SNAC_0x02_0x04_LocateSetInfo{}
+	if err := wire.Unmarshal(&inBody, r); err != nil {
+		return err
+	}
+	h.LogRequest(ctx, inFrame, inBody)
+	return h.LocateService.SetInfo(ctx, sess, inBody)
+}
+
+func (h LocateHandler) SetDirInfo(ctx context.Context, _ *state.Session, inFrame wire.SNACFrame, r io.Reader, rw oscar.ResponseWriter) error {
+	inBody := wire.SNAC_0x02_0x09_LocateSetDirInfo{}
+	if err := wire.Unmarshal(&inBody, r); err != nil {
+		return err
+	}
+	outSNAC := h.LocateService.SetDirInfo(ctx, inFrame)
+	h.LogRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
+	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
+}
+
+func (h LocateHandler) GetDirInfo(ctx context.Context, _ *state.Session, inFrame wire.SNACFrame, r io.Reader, _ oscar.ResponseWriter) error {
+	inBody := wire.SNAC_0x02_0x0B_LocateGetDirInfo{}
+	h.LogRequest(ctx, inFrame, inBody)
+	return wire.Unmarshal(&inBody, r)
+}
+
+func (h LocateHandler) SetKeywordInfo(ctx context.Context, _ *state.Session, inFrame wire.SNACFrame, r io.Reader, rw oscar.ResponseWriter) error {
+	inBody := wire.SNAC_0x02_0x0F_LocateSetKeywordInfo{}
+	if err := wire.Unmarshal(&inBody, r); err != nil {
+		return err
+	}
+	outSNAC := h.LocateService.SetKeywordInfo(ctx, inFrame)
+	h.LogRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
+	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
+}
+
+func (h LocateHandler) UserInfoQuery2(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw oscar.ResponseWriter) error {
+	inBody := wire.SNAC_0x02_0x15_LocateUserInfoQuery2{}
+	if err := wire.Unmarshal(&inBody, r); err != nil {
+		return err
+	}
+	outSNAC, err := h.LocateService.UserInfoQuery2(ctx, sess, inFrame, inBody)
+	if err != nil {
+		return err
+	}
+	h.LogRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
+	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
+}

+ 243 - 0
server/oscar/handler/locate_test.go

@@ -0,0 +1,243 @@
+package handler
+
+import (
+	"bytes"
+	"log/slog"
+	"testing"
+
+	"github.com/mk6i/retro-aim-server/wire"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/mock"
+)
+
+func TestLocateHandler_GetDirInfo(t *testing.T) {
+	input := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Locate,
+			SubGroup:  wire.LocateGetDirInfo,
+		},
+		Body: wire.SNAC_0x02_0x0B_LocateGetDirInfo{
+			WatcherScreenNames: "screen-name",
+		},
+	}
+
+	svc := newMockLocateService(t)
+	h := NewLocateHandler(svc, slog.Default())
+	responseWriter := newMockResponseWriter(t)
+
+	buf := &bytes.Buffer{}
+	assert.NoError(t, wire.Marshal(input.Body, buf))
+
+	assert.NoError(t, h.GetDirInfo(nil, nil, input.Frame, buf, responseWriter))
+}
+
+func TestLocateHandler_RightsQuery(t *testing.T) {
+	input := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Locate,
+			SubGroup:  wire.LocateRightsQuery,
+		},
+		Body: struct{}{},
+	}
+	output := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Locate,
+			SubGroup:  wire.LocateRightsReply,
+		},
+		Body: wire.SNAC_0x02_0x03_LocateRightsReply{
+			TLVRestBlock: wire.TLVRestBlock{
+				TLVList: wire.TLVList{
+					wire.NewTLV(0x01, uint16(1000)),
+				},
+			},
+		},
+	}
+
+	svc := newMockLocateService(t)
+	svc.EXPECT().
+		RightsQuery(mock.Anything, input.Frame).
+		Return(output)
+
+	h := NewLocateHandler(svc, slog.Default())
+
+	responseWriter := newMockResponseWriter(t)
+	responseWriter.EXPECT().
+		SendSNAC(output.Frame, output.Body).
+		Return(nil)
+
+	buf := &bytes.Buffer{}
+	assert.NoError(t, wire.Marshal(input.Body, buf))
+
+	assert.NoError(t, h.RightsQuery(nil, nil, input.Frame, buf, responseWriter))
+}
+
+func TestLocateHandler_SetDirInfo(t *testing.T) {
+	input := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Locate,
+			SubGroup:  wire.LocateSetDirInfo,
+		},
+		Body: wire.SNAC_0x02_0x09_LocateSetDirInfo{
+			TLVRestBlock: wire.TLVRestBlock{
+				TLVList: wire.TLVList{
+					{
+						Tag:   0x01,
+						Value: []byte{1, 2, 3, 4},
+					},
+				},
+			},
+		},
+	}
+	output := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Locate,
+			SubGroup:  wire.LocateSetDirReply,
+		},
+		Body: wire.SNAC_0x02_0x0A_LocateSetDirReply{
+			Result: 1,
+		},
+	}
+
+	svc := newMockLocateService(t)
+	svc.EXPECT().
+		SetDirInfo(mock.Anything, input.Frame).
+		Return(output)
+
+	h := NewLocateHandler(svc, slog.Default())
+
+	responseWriter := newMockResponseWriter(t)
+	responseWriter.EXPECT().
+		SendSNAC(output.Frame, output.Body).
+		Return(nil)
+
+	buf := &bytes.Buffer{}
+	assert.NoError(t, wire.Marshal(input.Body, buf))
+
+	assert.NoError(t, h.SetDirInfo(nil, nil, input.Frame, buf, responseWriter))
+}
+
+func TestLocateHandler_SetInfo(t *testing.T) {
+	input := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Locate,
+			SubGroup:  wire.LocateSetInfo,
+		},
+		Body: wire.SNAC_0x02_0x04_LocateSetInfo{
+			TLVRestBlock: wire.TLVRestBlock{
+				TLVList: wire.TLVList{
+					{
+						Tag:   0x01,
+						Value: []byte{1, 2, 3, 4},
+					},
+				},
+			},
+		},
+	}
+
+	svc := newMockLocateService(t)
+	svc.EXPECT().
+		SetInfo(mock.Anything, mock.Anything, input.Body).
+		Return(nil)
+
+	h := NewLocateHandler(svc, slog.Default())
+	responseWriter := newMockResponseWriter(t)
+
+	buf := &bytes.Buffer{}
+	assert.NoError(t, wire.Marshal(input.Body, buf))
+
+	assert.NoError(t, h.SetInfo(nil, nil, input.Frame, buf, responseWriter))
+}
+
+func TestLocateHandler_SetKeywordInfo(t *testing.T) {
+	input := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Locate,
+			SubGroup:  wire.LocateSetKeywordInfo,
+		},
+		Body: wire.SNAC_0x02_0x0F_LocateSetKeywordInfo{
+			TLVRestBlock: wire.TLVRestBlock{
+				TLVList: wire.TLVList{
+					{
+						Tag:   0x01,
+						Value: []byte{1, 2, 3, 4},
+					},
+				},
+			},
+		},
+	}
+	output := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Locate,
+			SubGroup:  wire.LocateSetKeywordReply,
+		},
+		Body: wire.SNAC_0x02_0x10_LocateSetKeywordReply{
+			Unknown: 1,
+		},
+	}
+
+	svc := newMockLocateService(t)
+	svc.EXPECT().
+		SetKeywordInfo(mock.Anything, input.Frame).
+		Return(output)
+
+	h := NewLocateHandler(svc, slog.Default())
+
+	responseWriter := newMockResponseWriter(t)
+	responseWriter.EXPECT().
+		SendSNAC(output.Frame, output.Body).
+		Return(nil)
+
+	buf := &bytes.Buffer{}
+	assert.NoError(t, wire.Marshal(input.Body, buf))
+
+	assert.NoError(t, h.SetKeywordInfo(nil, nil, input.Frame, buf, responseWriter))
+}
+
+func TestLocateHandler_UserInfoQuery2(t *testing.T) {
+	input := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Locate,
+			SubGroup:  wire.LocateUserInfoQuery2,
+		},
+		Body: wire.SNAC_0x02_0x15_LocateUserInfoQuery2{
+			Type2: 1,
+		},
+	}
+	output := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Locate,
+			SubGroup:  wire.LocateUserInfoReply,
+		},
+		Body: wire.SNAC_0x02_0x06_LocateUserInfoReply{
+			TLVUserInfo: wire.TLVUserInfo{
+				ScreenName: "screen-name",
+			},
+			LocateInfo: wire.TLVRestBlock{
+				TLVList: wire.TLVList{
+					{
+						Tag:   0x01,
+						Value: []byte{1, 2, 3, 4},
+					},
+				},
+			},
+		},
+	}
+
+	svc := newMockLocateService(t)
+	svc.EXPECT().
+		UserInfoQuery2(mock.Anything, mock.Anything, input.Frame, input.Body).
+		Return(output, nil)
+
+	h := NewLocateHandler(svc, slog.Default())
+
+	responseWriter := newMockResponseWriter(t)
+	responseWriter.EXPECT().
+		SendSNAC(output.Frame, output.Body).
+		Return(nil)
+
+	buf := &bytes.Buffer{}
+	assert.NoError(t, wire.Marshal(input.Body, buf))
+
+	assert.NoError(t, h.UserInfoQuery2(nil, nil, input.Frame, buf, responseWriter))
+}

+ 84 - 0
server/oscar/handler/mock_buddy_test.go

@@ -0,0 +1,84 @@
+// Code generated by mockery v2.40.1. DO NOT EDIT.
+
+package handler
+
+import (
+	context "context"
+
+	wire "github.com/mk6i/retro-aim-server/wire"
+	mock "github.com/stretchr/testify/mock"
+)
+
+// mockBuddyService is an autogenerated mock type for the BuddyService type
+type mockBuddyService struct {
+	mock.Mock
+}
+
+type mockBuddyService_Expecter struct {
+	mock *mock.Mock
+}
+
+func (_m *mockBuddyService) EXPECT() *mockBuddyService_Expecter {
+	return &mockBuddyService_Expecter{mock: &_m.Mock}
+}
+
+// RightsQuery provides a mock function with given fields: ctx, inFrame
+func (_m *mockBuddyService) RightsQuery(ctx context.Context, inFrame wire.SNACFrame) wire.SNACMessage {
+	ret := _m.Called(ctx, inFrame)
+
+	if len(ret) == 0 {
+		panic("no return value specified for RightsQuery")
+	}
+
+	var r0 wire.SNACMessage
+	if rf, ok := ret.Get(0).(func(context.Context, wire.SNACFrame) wire.SNACMessage); ok {
+		r0 = rf(ctx, inFrame)
+	} else {
+		r0 = ret.Get(0).(wire.SNACMessage)
+	}
+
+	return r0
+}
+
+// mockBuddyService_RightsQuery_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RightsQuery'
+type mockBuddyService_RightsQuery_Call struct {
+	*mock.Call
+}
+
+// RightsQuery is a helper method to define mock.On call
+//   - ctx context.Context
+//   - inFrame wire.SNACFrame
+func (_e *mockBuddyService_Expecter) RightsQuery(ctx interface{}, inFrame interface{}) *mockBuddyService_RightsQuery_Call {
+	return &mockBuddyService_RightsQuery_Call{Call: _e.mock.On("RightsQuery", ctx, inFrame)}
+}
+
+func (_c *mockBuddyService_RightsQuery_Call) Run(run func(ctx context.Context, inFrame wire.SNACFrame)) *mockBuddyService_RightsQuery_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(wire.SNACFrame))
+	})
+	return _c
+}
+
+func (_c *mockBuddyService_RightsQuery_Call) Return(_a0 wire.SNACMessage) *mockBuddyService_RightsQuery_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockBuddyService_RightsQuery_Call) RunAndReturn(run func(context.Context, wire.SNACFrame) wire.SNACMessage) *mockBuddyService_RightsQuery_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// newMockBuddyService creates a new instance of mockBuddyService. 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 newMockBuddyService(t interface {
+	mock.TestingT
+	Cleanup(func())
+}) *mockBuddyService {
+	mock := &mockBuddyService{}
+	mock.Mock.Test(t)
+
+	t.Cleanup(func() { mock.AssertExpectations(t) })
+
+	return mock
+}

+ 203 - 0
server/oscar/handler/mock_chat_nav_test.go

@@ -0,0 +1,203 @@
+// Code generated by mockery v2.40.1. DO NOT EDIT.
+
+package handler
+
+import (
+	context "context"
+
+	state "github.com/mk6i/retro-aim-server/state"
+	mock "github.com/stretchr/testify/mock"
+
+	wire "github.com/mk6i/retro-aim-server/wire"
+)
+
+// mockChatNavService is an autogenerated mock type for the ChatNavService type
+type mockChatNavService struct {
+	mock.Mock
+}
+
+type mockChatNavService_Expecter struct {
+	mock *mock.Mock
+}
+
+func (_m *mockChatNavService) EXPECT() *mockChatNavService_Expecter {
+	return &mockChatNavService_Expecter{mock: &_m.Mock}
+}
+
+// CreateRoom provides a mock function with given fields: ctx, sess, inFrame, inBody
+func (_m *mockChatNavService) CreateRoom(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate) (wire.SNACMessage, error) {
+	ret := _m.Called(ctx, sess, inFrame, inBody)
+
+	if len(ret) == 0 {
+		panic("no return value specified for CreateRoom")
+	}
+
+	var r0 wire.SNACMessage
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate) (wire.SNACMessage, error)); ok {
+		return rf(ctx, sess, inFrame, inBody)
+	}
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate) wire.SNACMessage); ok {
+		r0 = rf(ctx, sess, inFrame, inBody)
+	} else {
+		r0 = ret.Get(0).(wire.SNACMessage)
+	}
+
+	if rf, ok := ret.Get(1).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate) error); ok {
+		r1 = rf(ctx, sess, inFrame, inBody)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// mockChatNavService_CreateRoom_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateRoom'
+type mockChatNavService_CreateRoom_Call struct {
+	*mock.Call
+}
+
+// CreateRoom is a helper method to define mock.On call
+//   - ctx context.Context
+//   - sess *state.Session
+//   - inFrame wire.SNACFrame
+//   - inBody wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate
+func (_e *mockChatNavService_Expecter) CreateRoom(ctx interface{}, sess interface{}, inFrame interface{}, inBody interface{}) *mockChatNavService_CreateRoom_Call {
+	return &mockChatNavService_CreateRoom_Call{Call: _e.mock.On("CreateRoom", ctx, sess, inFrame, inBody)}
+}
+
+func (_c *mockChatNavService_CreateRoom_Call) Run(run func(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate)) *mockChatNavService_CreateRoom_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(*state.Session), args[2].(wire.SNACFrame), args[3].(wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate))
+	})
+	return _c
+}
+
+func (_c *mockChatNavService_CreateRoom_Call) Return(_a0 wire.SNACMessage, _a1 error) *mockChatNavService_CreateRoom_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *mockChatNavService_CreateRoom_Call) RunAndReturn(run func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate) (wire.SNACMessage, error)) *mockChatNavService_CreateRoom_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// RequestChatRights provides a mock function with given fields: ctx, inFrame
+func (_m *mockChatNavService) RequestChatRights(ctx context.Context, inFrame wire.SNACFrame) wire.SNACMessage {
+	ret := _m.Called(ctx, inFrame)
+
+	if len(ret) == 0 {
+		panic("no return value specified for RequestChatRights")
+	}
+
+	var r0 wire.SNACMessage
+	if rf, ok := ret.Get(0).(func(context.Context, wire.SNACFrame) wire.SNACMessage); ok {
+		r0 = rf(ctx, inFrame)
+	} else {
+		r0 = ret.Get(0).(wire.SNACMessage)
+	}
+
+	return r0
+}
+
+// mockChatNavService_RequestChatRights_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RequestChatRights'
+type mockChatNavService_RequestChatRights_Call struct {
+	*mock.Call
+}
+
+// RequestChatRights is a helper method to define mock.On call
+//   - ctx context.Context
+//   - inFrame wire.SNACFrame
+func (_e *mockChatNavService_Expecter) RequestChatRights(ctx interface{}, inFrame interface{}) *mockChatNavService_RequestChatRights_Call {
+	return &mockChatNavService_RequestChatRights_Call{Call: _e.mock.On("RequestChatRights", ctx, inFrame)}
+}
+
+func (_c *mockChatNavService_RequestChatRights_Call) Run(run func(ctx context.Context, inFrame wire.SNACFrame)) *mockChatNavService_RequestChatRights_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(wire.SNACFrame))
+	})
+	return _c
+}
+
+func (_c *mockChatNavService_RequestChatRights_Call) Return(_a0 wire.SNACMessage) *mockChatNavService_RequestChatRights_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockChatNavService_RequestChatRights_Call) RunAndReturn(run func(context.Context, wire.SNACFrame) wire.SNACMessage) *mockChatNavService_RequestChatRights_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// RequestRoomInfo provides a mock function with given fields: ctx, inFrame, inBody
+func (_m *mockChatNavService) RequestRoomInfo(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x0D_0x04_ChatNavRequestRoomInfo) (wire.SNACMessage, error) {
+	ret := _m.Called(ctx, inFrame, inBody)
+
+	if len(ret) == 0 {
+		panic("no return value specified for RequestRoomInfo")
+	}
+
+	var r0 wire.SNACMessage
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, wire.SNACFrame, wire.SNAC_0x0D_0x04_ChatNavRequestRoomInfo) (wire.SNACMessage, error)); ok {
+		return rf(ctx, inFrame, inBody)
+	}
+	if rf, ok := ret.Get(0).(func(context.Context, wire.SNACFrame, wire.SNAC_0x0D_0x04_ChatNavRequestRoomInfo) wire.SNACMessage); ok {
+		r0 = rf(ctx, inFrame, inBody)
+	} else {
+		r0 = ret.Get(0).(wire.SNACMessage)
+	}
+
+	if rf, ok := ret.Get(1).(func(context.Context, wire.SNACFrame, wire.SNAC_0x0D_0x04_ChatNavRequestRoomInfo) error); ok {
+		r1 = rf(ctx, inFrame, inBody)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// mockChatNavService_RequestRoomInfo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RequestRoomInfo'
+type mockChatNavService_RequestRoomInfo_Call struct {
+	*mock.Call
+}
+
+// RequestRoomInfo is a helper method to define mock.On call
+//   - ctx context.Context
+//   - inFrame wire.SNACFrame
+//   - inBody wire.SNAC_0x0D_0x04_ChatNavRequestRoomInfo
+func (_e *mockChatNavService_Expecter) RequestRoomInfo(ctx interface{}, inFrame interface{}, inBody interface{}) *mockChatNavService_RequestRoomInfo_Call {
+	return &mockChatNavService_RequestRoomInfo_Call{Call: _e.mock.On("RequestRoomInfo", ctx, inFrame, inBody)}
+}
+
+func (_c *mockChatNavService_RequestRoomInfo_Call) Run(run func(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x0D_0x04_ChatNavRequestRoomInfo)) *mockChatNavService_RequestRoomInfo_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(wire.SNACFrame), args[2].(wire.SNAC_0x0D_0x04_ChatNavRequestRoomInfo))
+	})
+	return _c
+}
+
+func (_c *mockChatNavService_RequestRoomInfo_Call) Return(_a0 wire.SNACMessage, _a1 error) *mockChatNavService_RequestRoomInfo_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *mockChatNavService_RequestRoomInfo_Call) RunAndReturn(run func(context.Context, wire.SNACFrame, wire.SNAC_0x0D_0x04_ChatNavRequestRoomInfo) (wire.SNACMessage, error)) *mockChatNavService_RequestRoomInfo_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// newMockChatNavService creates a new instance of mockChatNavService. 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 newMockChatNavService(t interface {
+	mock.TestingT
+	Cleanup(func())
+}) *mockChatNavService {
+	mock := &mockChatNavService{}
+	mock.Mock.Test(t)
+
+	t.Cleanup(func() { mock.AssertExpectations(t) })
+
+	return mock
+}

+ 100 - 0
server/oscar/handler/mock_chat_test.go

@@ -0,0 +1,100 @@
+// Code generated by mockery v2.40.1. DO NOT EDIT.
+
+package handler
+
+import (
+	context "context"
+
+	state "github.com/mk6i/retro-aim-server/state"
+	mock "github.com/stretchr/testify/mock"
+
+	wire "github.com/mk6i/retro-aim-server/wire"
+)
+
+// mockChatService is an autogenerated mock type for the ChatService type
+type mockChatService struct {
+	mock.Mock
+}
+
+type mockChatService_Expecter struct {
+	mock *mock.Mock
+}
+
+func (_m *mockChatService) EXPECT() *mockChatService_Expecter {
+	return &mockChatService_Expecter{mock: &_m.Mock}
+}
+
+// ChannelMsgToHost provides a mock function with given fields: ctx, sess, inFrame, inBody
+func (_m *mockChatService) ChannelMsgToHost(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x0E_0x05_ChatChannelMsgToHost) (*wire.SNACMessage, error) {
+	ret := _m.Called(ctx, sess, inFrame, inBody)
+
+	if len(ret) == 0 {
+		panic("no return value specified for ChannelMsgToHost")
+	}
+
+	var r0 *wire.SNACMessage
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x0E_0x05_ChatChannelMsgToHost) (*wire.SNACMessage, error)); ok {
+		return rf(ctx, sess, inFrame, inBody)
+	}
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x0E_0x05_ChatChannelMsgToHost) *wire.SNACMessage); ok {
+		r0 = rf(ctx, sess, inFrame, inBody)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*wire.SNACMessage)
+		}
+	}
+
+	if rf, ok := ret.Get(1).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x0E_0x05_ChatChannelMsgToHost) error); ok {
+		r1 = rf(ctx, sess, inFrame, inBody)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// mockChatService_ChannelMsgToHost_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ChannelMsgToHost'
+type mockChatService_ChannelMsgToHost_Call struct {
+	*mock.Call
+}
+
+// ChannelMsgToHost is a helper method to define mock.On call
+//   - ctx context.Context
+//   - sess *state.Session
+//   - inFrame wire.SNACFrame
+//   - inBody wire.SNAC_0x0E_0x05_ChatChannelMsgToHost
+func (_e *mockChatService_Expecter) ChannelMsgToHost(ctx interface{}, sess interface{}, inFrame interface{}, inBody interface{}) *mockChatService_ChannelMsgToHost_Call {
+	return &mockChatService_ChannelMsgToHost_Call{Call: _e.mock.On("ChannelMsgToHost", ctx, sess, inFrame, inBody)}
+}
+
+func (_c *mockChatService_ChannelMsgToHost_Call) Run(run func(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x0E_0x05_ChatChannelMsgToHost)) *mockChatService_ChannelMsgToHost_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(*state.Session), args[2].(wire.SNACFrame), args[3].(wire.SNAC_0x0E_0x05_ChatChannelMsgToHost))
+	})
+	return _c
+}
+
+func (_c *mockChatService_ChannelMsgToHost_Call) Return(_a0 *wire.SNACMessage, _a1 error) *mockChatService_ChannelMsgToHost_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *mockChatService_ChannelMsgToHost_Call) RunAndReturn(run func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x0E_0x05_ChatChannelMsgToHost) (*wire.SNACMessage, error)) *mockChatService_ChannelMsgToHost_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// newMockChatService creates a new instance of mockChatService. 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 newMockChatService(t interface {
+	mock.TestingT
+	Cleanup(func())
+}) *mockChatService {
+	mock := &mockChatService{}
+	mock.Mock.Test(t)
+
+	t.Cleanup(func() { mock.AssertExpectations(t) })
+
+	return mock
+}

+ 415 - 0
server/oscar/handler/mock_feedbag_test.go

@@ -0,0 +1,415 @@
+// Code generated by mockery v2.40.1. DO NOT EDIT.
+
+package handler
+
+import (
+	context "context"
+
+	state "github.com/mk6i/retro-aim-server/state"
+	mock "github.com/stretchr/testify/mock"
+
+	wire "github.com/mk6i/retro-aim-server/wire"
+)
+
+// 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 with given fields: ctx, sess, inFrame, inBody
+func (_m *mockFeedbagService) DeleteItem(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x0A_FeedbagDeleteItem) (wire.SNACMessage, error) {
+	ret := _m.Called(ctx, sess, inFrame, inBody)
+
+	if len(ret) == 0 {
+		panic("no return value specified for DeleteItem")
+	}
+
+	var r0 wire.SNACMessage
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x13_0x0A_FeedbagDeleteItem) (wire.SNACMessage, error)); ok {
+		return rf(ctx, sess, inFrame, inBody)
+	}
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x13_0x0A_FeedbagDeleteItem) wire.SNACMessage); ok {
+		r0 = rf(ctx, sess, inFrame, inBody)
+	} else {
+		r0 = ret.Get(0).(wire.SNACMessage)
+	}
+
+	if rf, ok := ret.Get(1).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x13_0x0A_FeedbagDeleteItem) error); ok {
+		r1 = rf(ctx, sess, 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
+//   - sess *state.Session
+//   - inFrame wire.SNACFrame
+//   - inBody wire.SNAC_0x13_0x0A_FeedbagDeleteItem
+func (_e *mockFeedbagService_Expecter) DeleteItem(ctx interface{}, sess interface{}, inFrame interface{}, inBody interface{}) *mockFeedbagService_DeleteItem_Call {
+	return &mockFeedbagService_DeleteItem_Call{Call: _e.mock.On("DeleteItem", ctx, sess, inFrame, inBody)}
+}
+
+func (_c *mockFeedbagService_DeleteItem_Call) Run(run func(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x0A_FeedbagDeleteItem)) *mockFeedbagService_DeleteItem_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(*state.Session), args[2].(wire.SNACFrame), args[3].(wire.SNAC_0x13_0x0A_FeedbagDeleteItem))
+	})
+	return _c
+}
+
+func (_c *mockFeedbagService_DeleteItem_Call) Return(_a0 wire.SNACMessage, _a1 error) *mockFeedbagService_DeleteItem_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *mockFeedbagService_DeleteItem_Call) RunAndReturn(run func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x13_0x0A_FeedbagDeleteItem) (wire.SNACMessage, error)) *mockFeedbagService_DeleteItem_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// InsertItem provides a mock function with given fields: ctx, sess, inFrame, inBody
+func (_m *mockFeedbagService) InsertItem(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x08_FeedbagInsertItem) (wire.SNACMessage, error) {
+	ret := _m.Called(ctx, sess, inFrame, inBody)
+
+	if len(ret) == 0 {
+		panic("no return value specified for InsertItem")
+	}
+
+	var r0 wire.SNACMessage
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x13_0x08_FeedbagInsertItem) (wire.SNACMessage, error)); ok {
+		return rf(ctx, sess, inFrame, inBody)
+	}
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x13_0x08_FeedbagInsertItem) wire.SNACMessage); ok {
+		r0 = rf(ctx, sess, inFrame, inBody)
+	} else {
+		r0 = ret.Get(0).(wire.SNACMessage)
+	}
+
+	if rf, ok := ret.Get(1).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x13_0x08_FeedbagInsertItem) error); ok {
+		r1 = rf(ctx, sess, inFrame, inBody)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// mockFeedbagService_InsertItem_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'InsertItem'
+type mockFeedbagService_InsertItem_Call struct {
+	*mock.Call
+}
+
+// InsertItem is a helper method to define mock.On call
+//   - ctx context.Context
+//   - sess *state.Session
+//   - inFrame wire.SNACFrame
+//   - inBody wire.SNAC_0x13_0x08_FeedbagInsertItem
+func (_e *mockFeedbagService_Expecter) InsertItem(ctx interface{}, sess interface{}, inFrame interface{}, inBody interface{}) *mockFeedbagService_InsertItem_Call {
+	return &mockFeedbagService_InsertItem_Call{Call: _e.mock.On("InsertItem", ctx, sess, inFrame, inBody)}
+}
+
+func (_c *mockFeedbagService_InsertItem_Call) Run(run func(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x08_FeedbagInsertItem)) *mockFeedbagService_InsertItem_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(*state.Session), args[2].(wire.SNACFrame), args[3].(wire.SNAC_0x13_0x08_FeedbagInsertItem))
+	})
+	return _c
+}
+
+func (_c *mockFeedbagService_InsertItem_Call) Return(_a0 wire.SNACMessage, _a1 error) *mockFeedbagService_InsertItem_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *mockFeedbagService_InsertItem_Call) RunAndReturn(run func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x13_0x08_FeedbagInsertItem) (wire.SNACMessage, error)) *mockFeedbagService_InsertItem_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// Query provides a mock function with given fields: ctx, sess, inFrame
+func (_m *mockFeedbagService) Query(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame) (wire.SNACMessage, error) {
+	ret := _m.Called(ctx, sess, inFrame)
+
+	if len(ret) == 0 {
+		panic("no return value specified for Query")
+	}
+
+	var r0 wire.SNACMessage
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNACFrame) (wire.SNACMessage, error)); ok {
+		return rf(ctx, sess, inFrame)
+	}
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNACFrame) wire.SNACMessage); ok {
+		r0 = rf(ctx, sess, inFrame)
+	} else {
+		r0 = ret.Get(0).(wire.SNACMessage)
+	}
+
+	if rf, ok := ret.Get(1).(func(context.Context, *state.Session, wire.SNACFrame) error); ok {
+		r1 = rf(ctx, sess, 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
+//   - sess *state.Session
+//   - inFrame wire.SNACFrame
+func (_e *mockFeedbagService_Expecter) Query(ctx interface{}, sess interface{}, inFrame interface{}) *mockFeedbagService_Query_Call {
+	return &mockFeedbagService_Query_Call{Call: _e.mock.On("Query", ctx, sess, inFrame)}
+}
+
+func (_c *mockFeedbagService_Query_Call) Run(run func(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame)) *mockFeedbagService_Query_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(*state.Session), args[2].(wire.SNACFrame))
+	})
+	return _c
+}
+
+func (_c *mockFeedbagService_Query_Call) Return(_a0 wire.SNACMessage, _a1 error) *mockFeedbagService_Query_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *mockFeedbagService_Query_Call) RunAndReturn(run func(context.Context, *state.Session, wire.SNACFrame) (wire.SNACMessage, error)) *mockFeedbagService_Query_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// QueryIfModified provides a mock function with given fields: ctx, sess, inFrame, inBody
+func (_m *mockFeedbagService) QueryIfModified(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x05_FeedbagQueryIfModified) (wire.SNACMessage, error) {
+	ret := _m.Called(ctx, sess, inFrame, inBody)
+
+	if len(ret) == 0 {
+		panic("no return value specified for QueryIfModified")
+	}
+
+	var r0 wire.SNACMessage
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x13_0x05_FeedbagQueryIfModified) (wire.SNACMessage, error)); ok {
+		return rf(ctx, sess, inFrame, inBody)
+	}
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x13_0x05_FeedbagQueryIfModified) wire.SNACMessage); ok {
+		r0 = rf(ctx, sess, inFrame, inBody)
+	} else {
+		r0 = ret.Get(0).(wire.SNACMessage)
+	}
+
+	if rf, ok := ret.Get(1).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x13_0x05_FeedbagQueryIfModified) error); ok {
+		r1 = rf(ctx, sess, 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
+//   - sess *state.Session
+//   - inFrame wire.SNACFrame
+//   - inBody wire.SNAC_0x13_0x05_FeedbagQueryIfModified
+func (_e *mockFeedbagService_Expecter) QueryIfModified(ctx interface{}, sess interface{}, inFrame interface{}, inBody interface{}) *mockFeedbagService_QueryIfModified_Call {
+	return &mockFeedbagService_QueryIfModified_Call{Call: _e.mock.On("QueryIfModified", ctx, sess, inFrame, inBody)}
+}
+
+func (_c *mockFeedbagService_QueryIfModified_Call) Run(run func(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x05_FeedbagQueryIfModified)) *mockFeedbagService_QueryIfModified_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(*state.Session), args[2].(wire.SNACFrame), args[3].(wire.SNAC_0x13_0x05_FeedbagQueryIfModified))
+	})
+	return _c
+}
+
+func (_c *mockFeedbagService_QueryIfModified_Call) Return(_a0 wire.SNACMessage, _a1 error) *mockFeedbagService_QueryIfModified_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *mockFeedbagService_QueryIfModified_Call) RunAndReturn(run func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x13_0x05_FeedbagQueryIfModified) (wire.SNACMessage, error)) *mockFeedbagService_QueryIfModified_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// RightsQuery provides a mock function with given fields: ctx, inFrame
+func (_m *mockFeedbagService) RightsQuery(ctx context.Context, inFrame wire.SNACFrame) wire.SNACMessage {
+	ret := _m.Called(ctx, inFrame)
+
+	if len(ret) == 0 {
+		panic("no return value specified for RightsQuery")
+	}
+
+	var r0 wire.SNACMessage
+	if rf, ok := ret.Get(0).(func(context.Context, wire.SNACFrame) wire.SNACMessage); ok {
+		r0 = rf(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) {
+		run(args[0].(context.Context), args[1].(wire.SNACFrame))
+	})
+	return _c
+}
+
+func (_c *mockFeedbagService_RightsQuery_Call) Return(_a0 wire.SNACMessage) *mockFeedbagService_RightsQuery_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockFeedbagService_RightsQuery_Call) RunAndReturn(run func(context.Context, wire.SNACFrame) wire.SNACMessage) *mockFeedbagService_RightsQuery_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// StartCluster provides a mock function with given fields: ctx, inFrame, inBody
+func (_m *mockFeedbagService) StartCluster(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x11_FeedbagStartCluster) {
+	_m.Called(ctx, inFrame, inBody)
+}
+
+// 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
+//   - inFrame wire.SNACFrame
+//   - inBody wire.SNAC_0x13_0x11_FeedbagStartCluster
+func (_e *mockFeedbagService_Expecter) StartCluster(ctx interface{}, inFrame interface{}, inBody interface{}) *mockFeedbagService_StartCluster_Call {
+	return &mockFeedbagService_StartCluster_Call{Call: _e.mock.On("StartCluster", ctx, inFrame, inBody)}
+}
+
+func (_c *mockFeedbagService_StartCluster_Call) Run(run func(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x11_FeedbagStartCluster)) *mockFeedbagService_StartCluster_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(wire.SNACFrame), args[2].(wire.SNAC_0x13_0x11_FeedbagStartCluster))
+	})
+	return _c
+}
+
+func (_c *mockFeedbagService_StartCluster_Call) Return() *mockFeedbagService_StartCluster_Call {
+	_c.Call.Return()
+	return _c
+}
+
+func (_c *mockFeedbagService_StartCluster_Call) RunAndReturn(run func(context.Context, wire.SNACFrame, wire.SNAC_0x13_0x11_FeedbagStartCluster)) *mockFeedbagService_StartCluster_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// UpdateItem provides a mock function with given fields: ctx, sess, inFrame, inBody
+func (_m *mockFeedbagService) UpdateItem(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x09_FeedbagUpdateItem) (wire.SNACMessage, error) {
+	ret := _m.Called(ctx, sess, inFrame, inBody)
+
+	if len(ret) == 0 {
+		panic("no return value specified for UpdateItem")
+	}
+
+	var r0 wire.SNACMessage
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x13_0x09_FeedbagUpdateItem) (wire.SNACMessage, error)); ok {
+		return rf(ctx, sess, inFrame, inBody)
+	}
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x13_0x09_FeedbagUpdateItem) wire.SNACMessage); ok {
+		r0 = rf(ctx, sess, inFrame, inBody)
+	} else {
+		r0 = ret.Get(0).(wire.SNACMessage)
+	}
+
+	if rf, ok := ret.Get(1).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x13_0x09_FeedbagUpdateItem) error); ok {
+		r1 = rf(ctx, sess, inFrame, inBody)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// mockFeedbagService_UpdateItem_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateItem'
+type mockFeedbagService_UpdateItem_Call struct {
+	*mock.Call
+}
+
+// UpdateItem is a helper method to define mock.On call
+//   - ctx context.Context
+//   - sess *state.Session
+//   - inFrame wire.SNACFrame
+//   - inBody wire.SNAC_0x13_0x09_FeedbagUpdateItem
+func (_e *mockFeedbagService_Expecter) UpdateItem(ctx interface{}, sess interface{}, inFrame interface{}, inBody interface{}) *mockFeedbagService_UpdateItem_Call {
+	return &mockFeedbagService_UpdateItem_Call{Call: _e.mock.On("UpdateItem", ctx, sess, inFrame, inBody)}
+}
+
+func (_c *mockFeedbagService_UpdateItem_Call) Run(run func(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x09_FeedbagUpdateItem)) *mockFeedbagService_UpdateItem_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(*state.Session), args[2].(wire.SNACFrame), args[3].(wire.SNAC_0x13_0x09_FeedbagUpdateItem))
+	})
+	return _c
+}
+
+func (_c *mockFeedbagService_UpdateItem_Call) Return(_a0 wire.SNACMessage, _a1 error) *mockFeedbagService_UpdateItem_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *mockFeedbagService_UpdateItem_Call) RunAndReturn(run func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x13_0x09_FeedbagUpdateItem) (wire.SNACMessage, error)) *mockFeedbagService_UpdateItem_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// 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
+}

+ 255 - 0
server/oscar/handler/mock_icbm_test.go

@@ -0,0 +1,255 @@
+// Code generated by mockery v2.40.1. DO NOT EDIT.
+
+package handler
+
+import (
+	context "context"
+
+	state "github.com/mk6i/retro-aim-server/state"
+	mock "github.com/stretchr/testify/mock"
+
+	wire "github.com/mk6i/retro-aim-server/wire"
+)
+
+// mockICBMService is an autogenerated mock type for the ICBMService type
+type mockICBMService struct {
+	mock.Mock
+}
+
+type mockICBMService_Expecter struct {
+	mock *mock.Mock
+}
+
+func (_m *mockICBMService) EXPECT() *mockICBMService_Expecter {
+	return &mockICBMService_Expecter{mock: &_m.Mock}
+}
+
+// ChannelMsgToHost provides a mock function with given fields: ctx, sess, inFrame, inBody
+func (_m *mockICBMService) ChannelMsgToHost(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x06_ICBMChannelMsgToHost) (*wire.SNACMessage, error) {
+	ret := _m.Called(ctx, sess, inFrame, inBody)
+
+	if len(ret) == 0 {
+		panic("no return value specified for ChannelMsgToHost")
+	}
+
+	var r0 *wire.SNACMessage
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x04_0x06_ICBMChannelMsgToHost) (*wire.SNACMessage, error)); ok {
+		return rf(ctx, sess, inFrame, inBody)
+	}
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x04_0x06_ICBMChannelMsgToHost) *wire.SNACMessage); ok {
+		r0 = rf(ctx, sess, inFrame, inBody)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*wire.SNACMessage)
+		}
+	}
+
+	if rf, ok := ret.Get(1).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x04_0x06_ICBMChannelMsgToHost) error); ok {
+		r1 = rf(ctx, sess, inFrame, inBody)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// mockICBMService_ChannelMsgToHost_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ChannelMsgToHost'
+type mockICBMService_ChannelMsgToHost_Call struct {
+	*mock.Call
+}
+
+// ChannelMsgToHost is a helper method to define mock.On call
+//   - ctx context.Context
+//   - sess *state.Session
+//   - inFrame wire.SNACFrame
+//   - inBody wire.SNAC_0x04_0x06_ICBMChannelMsgToHost
+func (_e *mockICBMService_Expecter) ChannelMsgToHost(ctx interface{}, sess interface{}, inFrame interface{}, inBody interface{}) *mockICBMService_ChannelMsgToHost_Call {
+	return &mockICBMService_ChannelMsgToHost_Call{Call: _e.mock.On("ChannelMsgToHost", ctx, sess, inFrame, inBody)}
+}
+
+func (_c *mockICBMService_ChannelMsgToHost_Call) Run(run func(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x06_ICBMChannelMsgToHost)) *mockICBMService_ChannelMsgToHost_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(*state.Session), args[2].(wire.SNACFrame), args[3].(wire.SNAC_0x04_0x06_ICBMChannelMsgToHost))
+	})
+	return _c
+}
+
+func (_c *mockICBMService_ChannelMsgToHost_Call) Return(_a0 *wire.SNACMessage, _a1 error) *mockICBMService_ChannelMsgToHost_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *mockICBMService_ChannelMsgToHost_Call) RunAndReturn(run func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x04_0x06_ICBMChannelMsgToHost) (*wire.SNACMessage, error)) *mockICBMService_ChannelMsgToHost_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// ClientEvent provides a mock function with given fields: ctx, sess, inFrame, inBody
+func (_m *mockICBMService) ClientEvent(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x14_ICBMClientEvent) error {
+	ret := _m.Called(ctx, sess, inFrame, inBody)
+
+	if len(ret) == 0 {
+		panic("no return value specified for ClientEvent")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x04_0x14_ICBMClientEvent) error); ok {
+		r0 = rf(ctx, sess, inFrame, inBody)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// mockICBMService_ClientEvent_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ClientEvent'
+type mockICBMService_ClientEvent_Call struct {
+	*mock.Call
+}
+
+// ClientEvent is a helper method to define mock.On call
+//   - ctx context.Context
+//   - sess *state.Session
+//   - inFrame wire.SNACFrame
+//   - inBody wire.SNAC_0x04_0x14_ICBMClientEvent
+func (_e *mockICBMService_Expecter) ClientEvent(ctx interface{}, sess interface{}, inFrame interface{}, inBody interface{}) *mockICBMService_ClientEvent_Call {
+	return &mockICBMService_ClientEvent_Call{Call: _e.mock.On("ClientEvent", ctx, sess, inFrame, inBody)}
+}
+
+func (_c *mockICBMService_ClientEvent_Call) Run(run func(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x14_ICBMClientEvent)) *mockICBMService_ClientEvent_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(*state.Session), args[2].(wire.SNACFrame), args[3].(wire.SNAC_0x04_0x14_ICBMClientEvent))
+	})
+	return _c
+}
+
+func (_c *mockICBMService_ClientEvent_Call) Return(_a0 error) *mockICBMService_ClientEvent_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockICBMService_ClientEvent_Call) RunAndReturn(run func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x04_0x14_ICBMClientEvent) error) *mockICBMService_ClientEvent_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// EvilRequest provides a mock function with given fields: ctx, sess, inFrame, inBody
+func (_m *mockICBMService) EvilRequest(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x08_ICBMEvilRequest) (wire.SNACMessage, error) {
+	ret := _m.Called(ctx, sess, inFrame, inBody)
+
+	if len(ret) == 0 {
+		panic("no return value specified for EvilRequest")
+	}
+
+	var r0 wire.SNACMessage
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x04_0x08_ICBMEvilRequest) (wire.SNACMessage, error)); ok {
+		return rf(ctx, sess, inFrame, inBody)
+	}
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x04_0x08_ICBMEvilRequest) wire.SNACMessage); ok {
+		r0 = rf(ctx, sess, inFrame, inBody)
+	} else {
+		r0 = ret.Get(0).(wire.SNACMessage)
+	}
+
+	if rf, ok := ret.Get(1).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x04_0x08_ICBMEvilRequest) error); ok {
+		r1 = rf(ctx, sess, inFrame, inBody)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// mockICBMService_EvilRequest_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EvilRequest'
+type mockICBMService_EvilRequest_Call struct {
+	*mock.Call
+}
+
+// EvilRequest is a helper method to define mock.On call
+//   - ctx context.Context
+//   - sess *state.Session
+//   - inFrame wire.SNACFrame
+//   - inBody wire.SNAC_0x04_0x08_ICBMEvilRequest
+func (_e *mockICBMService_Expecter) EvilRequest(ctx interface{}, sess interface{}, inFrame interface{}, inBody interface{}) *mockICBMService_EvilRequest_Call {
+	return &mockICBMService_EvilRequest_Call{Call: _e.mock.On("EvilRequest", ctx, sess, inFrame, inBody)}
+}
+
+func (_c *mockICBMService_EvilRequest_Call) Run(run func(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x08_ICBMEvilRequest)) *mockICBMService_EvilRequest_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(*state.Session), args[2].(wire.SNACFrame), args[3].(wire.SNAC_0x04_0x08_ICBMEvilRequest))
+	})
+	return _c
+}
+
+func (_c *mockICBMService_EvilRequest_Call) Return(_a0 wire.SNACMessage, _a1 error) *mockICBMService_EvilRequest_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *mockICBMService_EvilRequest_Call) RunAndReturn(run func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x04_0x08_ICBMEvilRequest) (wire.SNACMessage, error)) *mockICBMService_EvilRequest_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// ParameterQuery provides a mock function with given fields: ctx, inFrame
+func (_m *mockICBMService) ParameterQuery(ctx context.Context, inFrame wire.SNACFrame) wire.SNACMessage {
+	ret := _m.Called(ctx, inFrame)
+
+	if len(ret) == 0 {
+		panic("no return value specified for ParameterQuery")
+	}
+
+	var r0 wire.SNACMessage
+	if rf, ok := ret.Get(0).(func(context.Context, wire.SNACFrame) wire.SNACMessage); ok {
+		r0 = rf(ctx, inFrame)
+	} else {
+		r0 = ret.Get(0).(wire.SNACMessage)
+	}
+
+	return r0
+}
+
+// mockICBMService_ParameterQuery_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ParameterQuery'
+type mockICBMService_ParameterQuery_Call struct {
+	*mock.Call
+}
+
+// ParameterQuery is a helper method to define mock.On call
+//   - ctx context.Context
+//   - inFrame wire.SNACFrame
+func (_e *mockICBMService_Expecter) ParameterQuery(ctx interface{}, inFrame interface{}) *mockICBMService_ParameterQuery_Call {
+	return &mockICBMService_ParameterQuery_Call{Call: _e.mock.On("ParameterQuery", ctx, inFrame)}
+}
+
+func (_c *mockICBMService_ParameterQuery_Call) Run(run func(ctx context.Context, inFrame wire.SNACFrame)) *mockICBMService_ParameterQuery_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(wire.SNACFrame))
+	})
+	return _c
+}
+
+func (_c *mockICBMService_ParameterQuery_Call) Return(_a0 wire.SNACMessage) *mockICBMService_ParameterQuery_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockICBMService_ParameterQuery_Call) RunAndReturn(run func(context.Context, wire.SNACFrame) wire.SNACMessage) *mockICBMService_ParameterQuery_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// newMockICBMService creates a new instance of mockICBMService. 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 newMockICBMService(t interface {
+	mock.TestingT
+	Cleanup(func())
+}) *mockICBMService {
+	mock := &mockICBMService{}
+	mock.Mock.Test(t)
+
+	t.Cleanup(func() { mock.AssertExpectations(t) })
+
+	return mock
+}

+ 287 - 0
server/oscar/handler/mock_locate_test.go

@@ -0,0 +1,287 @@
+// Code generated by mockery v2.40.1. DO NOT EDIT.
+
+package handler
+
+import (
+	context "context"
+
+	state "github.com/mk6i/retro-aim-server/state"
+	mock "github.com/stretchr/testify/mock"
+
+	wire "github.com/mk6i/retro-aim-server/wire"
+)
+
+// mockLocateService is an autogenerated mock type for the LocateService type
+type mockLocateService struct {
+	mock.Mock
+}
+
+type mockLocateService_Expecter struct {
+	mock *mock.Mock
+}
+
+func (_m *mockLocateService) EXPECT() *mockLocateService_Expecter {
+	return &mockLocateService_Expecter{mock: &_m.Mock}
+}
+
+// RightsQuery provides a mock function with given fields: ctx, inFrame
+func (_m *mockLocateService) RightsQuery(ctx context.Context, inFrame wire.SNACFrame) wire.SNACMessage {
+	ret := _m.Called(ctx, inFrame)
+
+	if len(ret) == 0 {
+		panic("no return value specified for RightsQuery")
+	}
+
+	var r0 wire.SNACMessage
+	if rf, ok := ret.Get(0).(func(context.Context, wire.SNACFrame) wire.SNACMessage); ok {
+		r0 = rf(ctx, inFrame)
+	} else {
+		r0 = ret.Get(0).(wire.SNACMessage)
+	}
+
+	return r0
+}
+
+// mockLocateService_RightsQuery_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RightsQuery'
+type mockLocateService_RightsQuery_Call struct {
+	*mock.Call
+}
+
+// RightsQuery is a helper method to define mock.On call
+//   - ctx context.Context
+//   - inFrame wire.SNACFrame
+func (_e *mockLocateService_Expecter) RightsQuery(ctx interface{}, inFrame interface{}) *mockLocateService_RightsQuery_Call {
+	return &mockLocateService_RightsQuery_Call{Call: _e.mock.On("RightsQuery", ctx, inFrame)}
+}
+
+func (_c *mockLocateService_RightsQuery_Call) Run(run func(ctx context.Context, inFrame wire.SNACFrame)) *mockLocateService_RightsQuery_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(wire.SNACFrame))
+	})
+	return _c
+}
+
+func (_c *mockLocateService_RightsQuery_Call) Return(_a0 wire.SNACMessage) *mockLocateService_RightsQuery_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockLocateService_RightsQuery_Call) RunAndReturn(run func(context.Context, wire.SNACFrame) wire.SNACMessage) *mockLocateService_RightsQuery_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// SetDirInfo provides a mock function with given fields: ctx, frame
+func (_m *mockLocateService) SetDirInfo(ctx context.Context, frame wire.SNACFrame) wire.SNACMessage {
+	ret := _m.Called(ctx, frame)
+
+	if len(ret) == 0 {
+		panic("no return value specified for SetDirInfo")
+	}
+
+	var r0 wire.SNACMessage
+	if rf, ok := ret.Get(0).(func(context.Context, wire.SNACFrame) wire.SNACMessage); ok {
+		r0 = rf(ctx, frame)
+	} else {
+		r0 = ret.Get(0).(wire.SNACMessage)
+	}
+
+	return r0
+}
+
+// mockLocateService_SetDirInfo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetDirInfo'
+type mockLocateService_SetDirInfo_Call struct {
+	*mock.Call
+}
+
+// SetDirInfo is a helper method to define mock.On call
+//   - ctx context.Context
+//   - frame wire.SNACFrame
+func (_e *mockLocateService_Expecter) SetDirInfo(ctx interface{}, frame interface{}) *mockLocateService_SetDirInfo_Call {
+	return &mockLocateService_SetDirInfo_Call{Call: _e.mock.On("SetDirInfo", ctx, frame)}
+}
+
+func (_c *mockLocateService_SetDirInfo_Call) Run(run func(ctx context.Context, frame wire.SNACFrame)) *mockLocateService_SetDirInfo_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(wire.SNACFrame))
+	})
+	return _c
+}
+
+func (_c *mockLocateService_SetDirInfo_Call) Return(_a0 wire.SNACMessage) *mockLocateService_SetDirInfo_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockLocateService_SetDirInfo_Call) RunAndReturn(run func(context.Context, wire.SNACFrame) wire.SNACMessage) *mockLocateService_SetDirInfo_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// SetInfo provides a mock function with given fields: ctx, sess, inBody
+func (_m *mockLocateService) SetInfo(ctx context.Context, sess *state.Session, inBody wire.SNAC_0x02_0x04_LocateSetInfo) error {
+	ret := _m.Called(ctx, sess, inBody)
+
+	if len(ret) == 0 {
+		panic("no return value specified for SetInfo")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNAC_0x02_0x04_LocateSetInfo) error); ok {
+		r0 = rf(ctx, sess, inBody)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// mockLocateService_SetInfo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetInfo'
+type mockLocateService_SetInfo_Call struct {
+	*mock.Call
+}
+
+// SetInfo is a helper method to define mock.On call
+//   - ctx context.Context
+//   - sess *state.Session
+//   - inBody wire.SNAC_0x02_0x04_LocateSetInfo
+func (_e *mockLocateService_Expecter) SetInfo(ctx interface{}, sess interface{}, inBody interface{}) *mockLocateService_SetInfo_Call {
+	return &mockLocateService_SetInfo_Call{Call: _e.mock.On("SetInfo", ctx, sess, inBody)}
+}
+
+func (_c *mockLocateService_SetInfo_Call) Run(run func(ctx context.Context, sess *state.Session, inBody wire.SNAC_0x02_0x04_LocateSetInfo)) *mockLocateService_SetInfo_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(*state.Session), args[2].(wire.SNAC_0x02_0x04_LocateSetInfo))
+	})
+	return _c
+}
+
+func (_c *mockLocateService_SetInfo_Call) Return(_a0 error) *mockLocateService_SetInfo_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockLocateService_SetInfo_Call) RunAndReturn(run func(context.Context, *state.Session, wire.SNAC_0x02_0x04_LocateSetInfo) error) *mockLocateService_SetInfo_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// SetKeywordInfo provides a mock function with given fields: ctx, inFrame
+func (_m *mockLocateService) SetKeywordInfo(ctx context.Context, inFrame wire.SNACFrame) wire.SNACMessage {
+	ret := _m.Called(ctx, inFrame)
+
+	if len(ret) == 0 {
+		panic("no return value specified for SetKeywordInfo")
+	}
+
+	var r0 wire.SNACMessage
+	if rf, ok := ret.Get(0).(func(context.Context, wire.SNACFrame) wire.SNACMessage); ok {
+		r0 = rf(ctx, inFrame)
+	} else {
+		r0 = ret.Get(0).(wire.SNACMessage)
+	}
+
+	return r0
+}
+
+// mockLocateService_SetKeywordInfo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetKeywordInfo'
+type mockLocateService_SetKeywordInfo_Call struct {
+	*mock.Call
+}
+
+// SetKeywordInfo is a helper method to define mock.On call
+//   - ctx context.Context
+//   - inFrame wire.SNACFrame
+func (_e *mockLocateService_Expecter) SetKeywordInfo(ctx interface{}, inFrame interface{}) *mockLocateService_SetKeywordInfo_Call {
+	return &mockLocateService_SetKeywordInfo_Call{Call: _e.mock.On("SetKeywordInfo", ctx, inFrame)}
+}
+
+func (_c *mockLocateService_SetKeywordInfo_Call) Run(run func(ctx context.Context, inFrame wire.SNACFrame)) *mockLocateService_SetKeywordInfo_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(wire.SNACFrame))
+	})
+	return _c
+}
+
+func (_c *mockLocateService_SetKeywordInfo_Call) Return(_a0 wire.SNACMessage) *mockLocateService_SetKeywordInfo_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockLocateService_SetKeywordInfo_Call) RunAndReturn(run func(context.Context, wire.SNACFrame) wire.SNACMessage) *mockLocateService_SetKeywordInfo_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// UserInfoQuery2 provides a mock function with given fields: ctx, sess, inFrame, inBody
+func (_m *mockLocateService) UserInfoQuery2(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x02_0x15_LocateUserInfoQuery2) (wire.SNACMessage, error) {
+	ret := _m.Called(ctx, sess, inFrame, inBody)
+
+	if len(ret) == 0 {
+		panic("no return value specified for UserInfoQuery2")
+	}
+
+	var r0 wire.SNACMessage
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x02_0x15_LocateUserInfoQuery2) (wire.SNACMessage, error)); ok {
+		return rf(ctx, sess, inFrame, inBody)
+	}
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x02_0x15_LocateUserInfoQuery2) wire.SNACMessage); ok {
+		r0 = rf(ctx, sess, inFrame, inBody)
+	} else {
+		r0 = ret.Get(0).(wire.SNACMessage)
+	}
+
+	if rf, ok := ret.Get(1).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x02_0x15_LocateUserInfoQuery2) error); ok {
+		r1 = rf(ctx, sess, inFrame, inBody)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// mockLocateService_UserInfoQuery2_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UserInfoQuery2'
+type mockLocateService_UserInfoQuery2_Call struct {
+	*mock.Call
+}
+
+// UserInfoQuery2 is a helper method to define mock.On call
+//   - ctx context.Context
+//   - sess *state.Session
+//   - inFrame wire.SNACFrame
+//   - inBody wire.SNAC_0x02_0x15_LocateUserInfoQuery2
+func (_e *mockLocateService_Expecter) UserInfoQuery2(ctx interface{}, sess interface{}, inFrame interface{}, inBody interface{}) *mockLocateService_UserInfoQuery2_Call {
+	return &mockLocateService_UserInfoQuery2_Call{Call: _e.mock.On("UserInfoQuery2", ctx, sess, inFrame, inBody)}
+}
+
+func (_c *mockLocateService_UserInfoQuery2_Call) Run(run func(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x02_0x15_LocateUserInfoQuery2)) *mockLocateService_UserInfoQuery2_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(*state.Session), args[2].(wire.SNACFrame), args[3].(wire.SNAC_0x02_0x15_LocateUserInfoQuery2))
+	})
+	return _c
+}
+
+func (_c *mockLocateService_UserInfoQuery2_Call) Return(_a0 wire.SNACMessage, _a1 error) *mockLocateService_UserInfoQuery2_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *mockLocateService_UserInfoQuery2_Call) RunAndReturn(run func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x02_0x15_LocateUserInfoQuery2) (wire.SNACMessage, error)) *mockLocateService_UserInfoQuery2_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// newMockLocateService creates a new instance of mockLocateService. 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 newMockLocateService(t interface {
+	mock.TestingT
+	Cleanup(func())
+}) *mockLocateService {
+	mock := &mockLocateService{}
+	mock.Mock.Test(t)
+
+	t.Cleanup(func() { mock.AssertExpectations(t) })
+
+	return mock
+}

Некоторые файлы не были показаны из-за большого количества измененных файлов