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

split out foodgroup handlers into handlers package

Mike 2 лет назад
Родитель
Сommit
aa0c507ea4
53 измененных файлов с 4642 добавлено и 3633 удалено
  1. 3 0
      .mockery.yaml
  2. 35 5
      cmd/main.go
  3. 235 0
      handler/auth.go
  4. 274 0
      handler/auth_test.go
  5. 124 0
      handler/buddy.go
  6. 135 0
      handler/chat.go
  7. 120 0
      handler/chat_nav.go
  8. 99 0
      handler/chat_nav_test.go
  9. 138 0
      handler/chat_test.go
  10. 237 0
      handler/feedbag.go
  11. 572 0
      handler/feedbag_test.go
  12. 227 0
      handler/icbm.go
  13. 524 0
      handler/icbm_test.go
  14. 147 0
      handler/locate.go
  15. 271 0
      handler/locate_test.go
  16. 271 0
      handler/oservice.go
  17. 736 0
      handler/oservice_test.go
  18. 18 9
      handler/test_helpers.go
  19. 12 0
      oscar/snacs.go
  20. 0 225
      server/bucp.go
  21. 0 319
      server/bucp_test.go
  22. 2 115
      server/buddy.go
  23. 1 1
      server/buddy_mock.go
  24. 2 126
      server/chat.go
  25. 1 1
      server/chat_mock.go
  26. 2 113
      server/chat_nav.go
  27. 1 1
      server/chat_nav_mock.go
  28. 1 93
      server/chat_nav_test.go
  29. 160 0
      server/chat_registry_mock.go
  30. 1 1
      server/chat_session_manager_mock.go
  31. 0 129
      server/chat_test.go
  32. 79 20
      server/connection.go
  33. 3 233
      server/feedbag.go
  34. 1 1
      server/feedbag_manager_mock.go
  35. 1 1
      server/feedbag_mock.go
  36. 0 16
      server/feedbag_store.go
  37. 0 563
      server/feedbag_test.go
  38. 2 221
      server/icbm.go
  39. 1 1
      server/icbm_mock.go
  40. 0 515
      server/icbm_test.go
  41. 2 138
      server/locate.go
  42. 1 1
      server/locate_mock.go
  43. 0 263
      server/locate_test.go
  44. 17 282
      server/oservice.go
  45. 15 19
      server/oservice_bos_mock.go
  46. 15 19
      server/oservice_chat_mock.go
  47. 1 1
      server/oservice_mock.go
  48. 2 141
      server/oservice_test.go
  49. 1 1
      server/profile_manager_mock.go
  50. 18 58
      server/router.go
  51. 1 1
      server/session_manager_mock.go
  52. 5 0
      server/types.go
  53. 128 0
      server/user_manager_manager_mock.go

+ 3 - 0
.mockery.yaml

@@ -44,3 +44,6 @@ packages:
       ChatSessionManager:
       ChatSessionManager:
         config:
         config:
           filename: "chat_session_manager_mock.go"
           filename: "chat_session_manager_mock.go"
+      UserManager:
+        config:
+          filename: "user_manager_manager_mock.go"

+ 35 - 5
cmd/main.go

@@ -2,6 +2,7 @@ package main
 
 
 import (
 import (
 	"fmt"
 	"fmt"
+	"github.com/mkaminski/goaim/handler"
 	"os"
 	"os"
 	"sync"
 	"sync"
 
 
@@ -35,17 +36,46 @@ func main() {
 		wg.Done()
 		wg.Done()
 	}()
 	}()
 	go func() {
 	go func() {
-		router := server.NewBOSServiceRouter(logger, cfg, fm, sm, cr, fm)
-		server.ListenBOS(cfg, router, logger.With("svc", "BOS"))
+		authHandler := handler.NewAuthService(sm, fm, fm, cfg)
+		buddyHandler := handler.NewBuddyService()
+		oserviceHandler := handler.NewOServiceService(cfg, sm, fm)
+		oserviceBOSHandler := handler.NewOServiceServiceForBOS(*oserviceHandler, cr)
+		locateHandler := handler.NewLocateService(sm, fm, fm)
+		chatNavHandler := handler.NewChatNavService(logger, cr)
+		feedbagHandler := handler.NewFeedbagService(sm, fm)
+		icbmHandler := handler.NewICBMService(sm, fm)
+
+		router := server.BOSServiceRouter{
+			AlertRouter:       server.NewAlertRouter(logger),
+			AuthHandler:       authHandler,
+			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),
+			Cfg:               cfg,
+			RouteLogger: server.RouteLogger{
+				Logger: logger,
+			},
+			NewChatSessMgr: func() server.ChatSessionManager { return server.NewSessionManager(logger) },
+		}
+
+		server.ListenBOS(cfg, router, authHandler, logger.With("svc", "BOS"))
 		wg.Done()
 		wg.Done()
 	}()
 	}()
 	go func() {
 	go func() {
-		router := server.NewChatServiceRouter(logger, cfg, fm, sm)
-		server.ListenChat(cfg, router, cr, logger.With("svc", "CHAT"))
+		authHandler := handler.NewAuthService(sm, fm, fm, cfg)
+		oserviceHandler := handler.NewOServiceService(cfg, sm, fm)
+		chatHandler := handler.NewChatService()
+		oserviceChatHandler := handler.NewOServiceServiceForChat(*oserviceHandler)
+		router := server.NewChatServiceRouter(logger, cfg, oserviceHandler, *chatHandler, oserviceChatHandler)
+		server.ListenChat(cfg, router, cr, authHandler, logger.With("svc", "CHAT"))
 		wg.Done()
 		wg.Done()
 	}()
 	}()
 	go func() {
 	go func() {
-		server.ListenBUCPLogin(cfg, err, logger, sm, fm)
+		authHandler := handler.NewAuthService(sm, fm, fm, cfg)
+		server.ListenBUCPLogin(cfg, err, logger, authHandler)
 		wg.Done()
 		wg.Done()
 	}()
 	}()
 
 

+ 235 - 0
handler/auth.go

@@ -0,0 +1,235 @@
+package handler
+
+import (
+	"bytes"
+	"context"
+	"errors"
+	"fmt"
+	"github.com/google/uuid"
+	"github.com/mkaminski/goaim/oscar"
+	"github.com/mkaminski/goaim/server"
+	"io"
+	"net"
+)
+
+func NewAuthService(sm server.SessionManager, fm server.FeedbagManager, um server.UserManager, cfg server.Config) *AuthService {
+	return &AuthService{
+		sm:  sm,
+		fm:  fm,
+		um:  um,
+		cfg: cfg,
+	}
+}
+
+func newStubUser(screenName string) (server.User, error) {
+	u := server.User{ScreenName: screenName}
+
+	uid, err := uuid.NewRandom()
+	if err != nil {
+		return u, err
+	}
+	u.AuthKey = uid.String()
+
+	if err := u.HashPassword("welcome1"); err != nil {
+		return u, err
+	}
+	return u, u.HashPassword("welcome1")
+}
+
+type AuthService struct {
+	sm  server.SessionManager
+	fm  server.FeedbagManager
+	um  server.UserManager
+	cfg server.Config
+}
+
+func (s AuthService) Signout(ctx context.Context, sess *server.Session) error {
+	if err := broadcastDeparture(ctx, sess, s.sm, s.fm); err != nil {
+		return err
+	}
+	s.sm.Remove(sess)
+	return nil
+}
+
+func (s AuthService) SignoutChat(ctx context.Context, cr *server.ChatRegistry, chatRoom server.ChatRoom, chatSessManager server.ChatSessionManager, sess *server.Session) {
+	alertUserLeft(ctx, sess, chatSessManager)
+	chatSessManager.Remove(sess)
+	cr.MaybeRemoveRoom(chatRoom.Cookie)
+}
+
+func (s AuthService) VerifyLogin(conn net.Conn) (*server.Session, uint32, error) {
+	seq := uint32(100)
+
+	flap, err := s.SendAndReceiveSignonFrame(conn, &seq)
+	if err != nil {
+		return nil, 0, err
+	}
+
+	var ok bool
+	ID, ok := flap.GetSlice(oscar.OServiceTLVTagsLoginCookie)
+	if !ok {
+		return nil, 0, errors.New("unable to get session id from payload")
+	}
+
+	sess, ok := s.sm.Retrieve(string(ID))
+	if !ok {
+		return nil, 0, fmt.Errorf("unable to find session by id %s", ID)
+	}
+
+	return sess, seq, nil
+}
+
+func (s AuthService) SendAndReceiveSignonFrame(rw io.ReadWriter, sequence *uint32) (oscar.FlapSignonFrame, error) {
+	flapFrameOut := oscar.FlapFrame{
+		StartMarker:   42,
+		FrameType:     oscar.FlapFrameSignon,
+		Sequence:      uint16(*sequence),
+		PayloadLength: 4, // size of FlapSignonFrame
+	}
+	if err := oscar.Marshal(flapFrameOut, 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
+	flapFrameIn := oscar.FlapFrame{}
+	if err := oscar.Unmarshal(&flapFrameIn, rw); err != nil {
+		return oscar.FlapSignonFrame{}, err
+	}
+	b := make([]byte, flapFrameIn.PayloadLength)
+	if _, err := rw.Read(b); err != nil {
+		return oscar.FlapSignonFrame{}, err
+	}
+	flapSignonFrameIn := oscar.FlapSignonFrame{}
+	if err := oscar.Unmarshal(&flapSignonFrameIn, bytes.NewBuffer(b)); err != nil {
+		return oscar.FlapSignonFrame{}, err
+	}
+
+	*sequence++
+
+	return flapSignonFrameIn, nil
+}
+
+func (s AuthService) VerifyChatLogin(rw io.ReadWriter) (*server.ChatCookie, uint32, error) {
+	seq := uint32(100)
+
+	flap, err := s.SendAndReceiveSignonFrame(rw, &seq)
+	if err != nil {
+		return nil, 0, err
+	}
+
+	var ok bool
+	buf, ok := flap.GetSlice(oscar.OServiceTLVTagsLoginCookie)
+	if !ok {
+		return nil, 0, errors.New("unable to get session id from payload")
+	}
+
+	cookie := server.ChatCookie{}
+	err = oscar.Unmarshal(&cookie, bytes.NewBuffer(buf))
+
+	return &cookie, seq, err
+}
+
+func (s AuthService) ReceiveAndSendAuthChallenge(snacPayloadIn oscar.SNAC_0x17_0x06_BUCPChallengeRequest, newUUID func() uuid.UUID) (oscar.XMessage, error) {
+	screenName, exists := snacPayloadIn.GetString(oscar.TLVScreenName)
+	if !exists {
+		return oscar.XMessage{}, errors.New("screen name doesn't exist in tlv")
+	}
+
+	var authKey string
+
+	u, err := s.um.GetUser(screenName)
+	switch {
+	case err != nil:
+		return oscar.XMessage{}, err
+	case u != nil:
+		// user lookup succeeded
+		authKey = u.AuthKey
+	case s.cfg.DisableAuth:
+		// can't find user, generate stub auth key
+		authKey = newUUID().String()
+	default:
+		// can't find user, return login error
+		snacFrameOut := oscar.SnacFrame{
+			FoodGroup: oscar.BUCP,
+			SubGroup:  oscar.BUCPLoginResponse,
+		}
+		snacPayloadOut := oscar.SNAC_0x17_0x03_BUCPLoginResponse{}
+		snacPayloadOut.AddTLV(oscar.NewTLV(oscar.TLVErrorSubcode, uint16(0x01)))
+		return oscar.XMessage{
+			SnacFrame: snacFrameOut,
+			SnacOut:   snacPayloadOut,
+		}, nil
+	}
+
+	return oscar.XMessage{
+		SnacFrame: oscar.SnacFrame{
+			FoodGroup: oscar.BUCP,
+			SubGroup:  oscar.BUCPChallengeResponse,
+		},
+		SnacOut: oscar.SNAC_0x17_0x07_BUCPChallengeResponse{
+			AuthKey: authKey,
+		},
+	}, nil
+}
+
+func (s AuthService) ReceiveAndSendBUCPLoginRequest(snacPayloadIn oscar.SNAC_0x17_0x02_BUCPLoginRequest, newUUID func() uuid.UUID) (oscar.XMessage, error) {
+
+	screenName, found := snacPayloadIn.GetString(oscar.TLVScreenName)
+	if !found {
+		return oscar.XMessage{}, errors.New("screen name doesn't exist in tlv")
+	}
+	md5Hash, found := snacPayloadIn.GetSlice(oscar.TLVPasswordHash)
+	if !found {
+		return oscar.XMessage{}, errors.New("password hash doesn't exist in tlv")
+	}
+
+	loginOK := false
+
+	u, err := s.um.GetUser(screenName)
+	switch {
+	case err != nil:
+		return oscar.XMessage{}, err
+	case u != nil && bytes.Equal(u.PassHash, md5Hash):
+		// password check succeeded
+		loginOK = true
+	case s.cfg.DisableAuth:
+		// login failed but let them in anyway
+		newUser, err := newStubUser(screenName)
+		if err != nil {
+			return oscar.XMessage{}, err
+		}
+		if err := s.um.UpsertUser(newUser); err != nil {
+			return oscar.XMessage{}, err
+		}
+		loginOK = true
+	}
+
+	snacPayloadOut := oscar.SNAC_0x17_0x03_BUCPLoginResponse{}
+	snacPayloadOut.AddTLV(oscar.NewTLV(oscar.TLVScreenName, screenName))
+
+	if loginOK {
+		sess := s.sm.NewSessionWithSN(newUUID().String(), screenName)
+		snacPayloadOut.AddTLVList([]oscar.TLV{
+			oscar.NewTLV(oscar.TLVReconnectHere, server.Address(s.cfg.OSCARHost, s.cfg.BOSPort)),
+			oscar.NewTLV(oscar.TLVAuthorizationCookie, sess.ID()),
+		})
+	} else {
+		snacPayloadOut.AddTLVList([]oscar.TLV{
+			oscar.NewTLV(oscar.TLVErrorSubcode, uint16(0x01)),
+		})
+	}
+
+	return oscar.XMessage{
+		SnacFrame: oscar.SnacFrame{
+			FoodGroup: oscar.BUCP,
+			SubGroup:  oscar.BUCPLoginResponse,
+		},
+		SnacOut: snacPayloadOut,
+	}, nil
+}

+ 274 - 0
handler/auth_test.go

@@ -0,0 +1,274 @@
+package handler
+
+import (
+	"github.com/google/uuid"
+	"github.com/mkaminski/goaim/oscar"
+	"github.com/mkaminski/goaim/server"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/mock"
+	"testing"
+)
+
+func TestReceiveAndSendBUCPLoginRequest(t *testing.T) {
+	userGoodPwd := server.User{
+		ScreenName: "sn_user_a",
+		AuthKey:    "auth_key_user",
+	}
+	assert.NoError(t, userGoodPwd.HashPassword("good_pwd"))
+	userBadPwd := userGoodPwd
+	assert.NoError(t, userBadPwd.HashPassword("bad_pwd"))
+
+	cases := []struct {
+		name        string
+		cfg         server.Config
+		userInDB    server.User
+		sessionUUID uuid.UUID
+		inputSNAC   oscar.SNAC_0x17_0x02_BUCPLoginRequest
+		// expectOutput is the SNAC payload sent from the server to the
+		// recipient client
+		expectOutput oscar.XMessage
+	}{
+		{
+			name: "login with valid password, expect OK login response",
+			cfg: server.Config{
+				OSCARHost: "127.0.0.1",
+				BOSPort:   1234,
+			},
+			userInDB:    userGoodPwd,
+			sessionUUID: uuid.UUID{1, 2, 3},
+			inputSNAC: oscar.SNAC_0x17_0x02_BUCPLoginRequest{
+				TLVRestBlock: oscar.TLVRestBlock{
+					TLVList: oscar.TLVList{
+						oscar.NewTLV(oscar.TLVPasswordHash, userGoodPwd.PassHash),
+						oscar.NewTLV(oscar.TLVScreenName, userGoodPwd.ScreenName),
+					},
+				},
+			},
+			expectOutput: oscar.XMessage{
+				SnacFrame: oscar.SnacFrame{
+					FoodGroup: oscar.BUCP,
+					SubGroup:  oscar.BUCPLoginResponse,
+				},
+				SnacOut: oscar.SNAC_0x17_0x03_BUCPLoginResponse{
+					TLVRestBlock: oscar.TLVRestBlock{
+						TLVList: oscar.TLVList{
+							oscar.NewTLV(oscar.TLVScreenName, userGoodPwd.ScreenName),
+							oscar.NewTLV(oscar.TLVReconnectHere, "127.0.0.1:1234"),
+							oscar.NewTLV(oscar.TLVAuthorizationCookie, uuid.UUID{1, 2, 3}.String()),
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "login with bad password, expect OK login response (Cfg.DisableAuth=true)",
+			cfg: server.Config{
+				OSCARHost:   "127.0.0.1",
+				BOSPort:     1234,
+				DisableAuth: true,
+			},
+			userInDB:    userGoodPwd,
+			sessionUUID: uuid.UUID{1, 2, 3},
+			inputSNAC: oscar.SNAC_0x17_0x02_BUCPLoginRequest{
+				TLVRestBlock: oscar.TLVRestBlock{
+					TLVList: oscar.TLVList{
+						oscar.NewTLV(oscar.TLVPasswordHash, userBadPwd.PassHash),
+						oscar.NewTLV(oscar.TLVScreenName, userBadPwd.ScreenName),
+					},
+				},
+			},
+			expectOutput: oscar.XMessage{
+				SnacFrame: oscar.SnacFrame{
+					FoodGroup: oscar.BUCP,
+					SubGroup:  oscar.BUCPLoginResponse,
+				},
+				SnacOut: oscar.SNAC_0x17_0x03_BUCPLoginResponse{
+					TLVRestBlock: oscar.TLVRestBlock{
+						TLVList: oscar.TLVList{
+							oscar.NewTLV(oscar.TLVScreenName, userBadPwd.ScreenName),
+							oscar.NewTLV(oscar.TLVReconnectHere, "127.0.0.1:1234"),
+							oscar.NewTLV(oscar.TLVAuthorizationCookie, uuid.UUID{1, 2, 3}.String()),
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "login with bad password, expect failed login response (Cfg.DisableAuth=false)",
+			cfg: server.Config{
+				OSCARHost: "127.0.0.1",
+				BOSPort:   1234,
+			},
+			userInDB:    userGoodPwd,
+			sessionUUID: uuid.UUID{1, 2, 3},
+			inputSNAC: oscar.SNAC_0x17_0x02_BUCPLoginRequest{
+				TLVRestBlock: oscar.TLVRestBlock{
+					TLVList: oscar.TLVList{
+						oscar.NewTLV(oscar.TLVPasswordHash, userBadPwd.PassHash),
+						oscar.NewTLV(oscar.TLVScreenName, userBadPwd.ScreenName),
+					},
+				},
+			},
+			expectOutput: oscar.XMessage{
+				SnacFrame: oscar.SnacFrame{
+					FoodGroup: oscar.BUCP,
+					SubGroup:  oscar.BUCPLoginResponse,
+				},
+				SnacOut: oscar.SNAC_0x17_0x03_BUCPLoginResponse{
+					TLVRestBlock: oscar.TLVRestBlock{
+						TLVList: oscar.TLVList{
+							oscar.NewTLV(oscar.TLVScreenName, userBadPwd.ScreenName),
+							oscar.NewTLV(oscar.TLVErrorSubcode, uint16(0x01)),
+						},
+					},
+				},
+			},
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			sess := newTestSession(tc.userInDB.ScreenName, sessOptID(tc.sessionUUID.String()))
+			um := server.NewMockUserManager(t)
+			um.EXPECT().
+				GetUser(tc.userInDB.ScreenName).
+				Return(&userGoodPwd, nil).
+				Maybe()
+			um.EXPECT().
+				UpsertUser(mock.Anything).
+				Return(nil).
+				Maybe()
+			sm := server.NewMockSessionManager(t)
+			sm.EXPECT().
+				NewSessionWithSN(tc.sessionUUID.String(), tc.userInDB.ScreenName).
+				Return(sess).
+				Maybe()
+			svc := AuthService{
+				cfg: tc.cfg,
+				sm:  sm,
+				um:  um,
+			}
+			fnNewUUID := func() uuid.UUID {
+				return tc.sessionUUID
+			}
+			outputSNAC, err := svc.ReceiveAndSendBUCPLoginRequest(tc.inputSNAC, fnNewUUID)
+			assert.NoError(t, err)
+			assert.Equal(t, tc.expectOutput, outputSNAC)
+		})
+	}
+}
+
+func TestReceiveAndSendAuthChallenge(t *testing.T) {
+	cases := []struct {
+		name         string
+		cfg          server.Config
+		userInDB     *server.User
+		fnNewUUID    uuid.UUID
+		inputSNAC    oscar.SNAC_0x17_0x06_BUCPChallengeRequest
+		expectOutput oscar.XMessage
+	}{
+		{
+			name: "login with valid username, expect OK login response",
+			cfg: server.Config{
+				OSCARHost: "127.0.0.1",
+				BOSPort:   1234,
+			},
+			userInDB: &server.User{
+				ScreenName: "sn_user_a",
+				AuthKey:    "auth_key_user_a",
+			},
+			fnNewUUID: uuid.UUID{1, 2, 3},
+			inputSNAC: oscar.SNAC_0x17_0x06_BUCPChallengeRequest{
+				TLVRestBlock: oscar.TLVRestBlock{
+					TLVList: oscar.TLVList{
+						oscar.NewTLV(oscar.TLVScreenName, "sn_user_a"),
+					},
+				},
+			},
+			expectOutput: oscar.XMessage{
+				SnacFrame: oscar.SnacFrame{
+					FoodGroup: oscar.BUCP,
+					SubGroup:  oscar.BUCPChallengeResponse,
+				},
+				SnacOut: oscar.SNAC_0x17_0x07_BUCPChallengeResponse{
+					AuthKey: "auth_key_user_a",
+				},
+			},
+		},
+		{
+			name: "login with invalid username, expect OK login response (Cfg.DisableAuth=true)",
+			cfg: server.Config{
+				OSCARHost:   "127.0.0.1",
+				BOSPort:     1234,
+				DisableAuth: true,
+			},
+			userInDB:  nil,
+			fnNewUUID: uuid.UUID{1, 2, 3},
+			inputSNAC: oscar.SNAC_0x17_0x06_BUCPChallengeRequest{
+				TLVRestBlock: oscar.TLVRestBlock{
+					TLVList: oscar.TLVList{
+						oscar.NewTLV(oscar.TLVScreenName, "sn_user_b"),
+					},
+				},
+			},
+			expectOutput: oscar.XMessage{
+				SnacFrame: oscar.SnacFrame{
+					FoodGroup: oscar.BUCP,
+					SubGroup:  oscar.BUCPChallengeResponse,
+				},
+				SnacOut: oscar.SNAC_0x17_0x07_BUCPChallengeResponse{
+					AuthKey: uuid.UUID{1, 2, 3}.String(),
+				},
+			},
+		},
+		{
+			name: "login with invalid username, expect failed login response (Cfg.DisableAuth=false)",
+			cfg: server.Config{
+				OSCARHost: "127.0.0.1",
+				BOSPort:   1234,
+			},
+			userInDB:  nil,
+			fnNewUUID: uuid.UUID{1, 2, 3},
+			inputSNAC: oscar.SNAC_0x17_0x06_BUCPChallengeRequest{
+				TLVRestBlock: oscar.TLVRestBlock{
+					TLVList: oscar.TLVList{
+						oscar.NewTLV(oscar.TLVScreenName, "sn_user_b"),
+					},
+				},
+			},
+			expectOutput: oscar.XMessage{
+				SnacFrame: oscar.SnacFrame{
+					FoodGroup: oscar.BUCP,
+					SubGroup:  oscar.BUCPLoginResponse,
+				},
+				SnacOut: oscar.SNAC_0x17_0x03_BUCPLoginResponse{
+					TLVRestBlock: oscar.TLVRestBlock{
+						TLVList: oscar.TLVList{
+							oscar.NewTLV(oscar.TLVErrorSubcode, uint16(0x01)),
+						},
+					},
+				},
+			},
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			um := server.NewMockUserManager(t)
+			um.EXPECT().
+				GetUser(string(tc.inputSNAC.TLVList[0].Val)).
+				Return(tc.userInDB, nil).
+				Maybe()
+			svc := AuthService{
+				cfg: tc.cfg,
+				um:  um,
+			}
+			fnNewUUID := func() uuid.UUID {
+				return tc.fnNewUUID
+			}
+			outputSNAC, err := svc.ReceiveAndSendAuthChallenge(tc.inputSNAC, fnNewUUID)
+			assert.NoError(t, err)
+			assert.Equal(t, tc.expectOutput, outputSNAC)
+		})
+	}
+}

+ 124 - 0
handler/buddy.go

@@ -0,0 +1,124 @@
+package handler
+
+import (
+	"context"
+	"github.com/mkaminski/goaim/oscar"
+	"github.com/mkaminski/goaim/server"
+)
+
+func NewBuddyService() *BuddyService {
+	return &BuddyService{}
+}
+
+type BuddyService struct {
+}
+
+func (s BuddyService) RightsQueryHandler(context.Context) oscar.XMessage {
+	return oscar.XMessage{
+		SnacFrame: oscar.SnacFrame{
+			FoodGroup: oscar.BUDDY,
+			SubGroup:  oscar.BuddyRightsReply,
+		},
+		SnacOut: oscar.SNAC_0x03_0x03_BuddyRightsReply{
+			TLVRestBlock: oscar.TLVRestBlock{
+				TLVList: oscar.TLVList{
+					oscar.NewTLV(0x01, uint16(100)),
+					oscar.NewTLV(0x02, uint16(100)),
+					oscar.NewTLV(0x03, uint16(100)),
+					oscar.NewTLV(0x04, uint16(100)),
+				},
+			},
+		},
+	}
+}
+
+func broadcastArrival(ctx context.Context, sess *server.Session, sm server.SessionManager, fm server.FeedbagManager) error {
+	screenNames, err := fm.InterestedUsers(sess.ScreenName())
+	if err != nil {
+		return err
+	}
+
+	sm.BroadcastToScreenNames(ctx, screenNames, oscar.XMessage{
+		SnacFrame: oscar.SnacFrame{
+			FoodGroup: oscar.BUDDY,
+			SubGroup:  oscar.BuddyArrived,
+		},
+		SnacOut: oscar.SNAC_0x03_0x0A_BuddyArrived{
+			TLVUserInfo: oscar.TLVUserInfo{
+				ScreenName:   sess.ScreenName(),
+				WarningLevel: sess.Warning(),
+				TLVBlock: oscar.TLVBlock{
+					TLVList: sess.UserInfo(),
+				},
+			},
+		},
+	})
+
+	return nil
+}
+
+func broadcastDeparture(ctx context.Context, sess *server.Session, sm server.SessionManager, fm server.FeedbagManager) error {
+	screenNames, err := fm.InterestedUsers(sess.ScreenName())
+	if err != nil {
+		return err
+	}
+
+	sm.BroadcastToScreenNames(ctx, screenNames, oscar.XMessage{
+		SnacFrame: oscar.SnacFrame{
+			FoodGroup: oscar.BUDDY,
+			SubGroup:  oscar.BuddyDeparted,
+		},
+		SnacOut: oscar.SNAC_0x03_0x0B_BuddyDeparted{
+			TLVUserInfo: oscar.TLVUserInfo{
+				ScreenName:   sess.ScreenName(),
+				WarningLevel: sess.Warning(),
+			},
+		},
+	})
+
+	return nil
+}
+
+func unicastArrival(ctx context.Context, srcScreenName, destScreenName string, sm server.SessionManager) {
+	sess := sm.RetrieveByScreenName(srcScreenName)
+	switch {
+	case sess == nil:
+		fallthrough
+	case sess.Invisible(): // don't tell user this buddy is online
+		return
+	}
+	sm.SendToScreenName(ctx, destScreenName, oscar.XMessage{
+		SnacFrame: oscar.SnacFrame{
+			FoodGroup: oscar.BUDDY,
+			SubGroup:  oscar.BuddyArrived,
+		},
+		SnacOut: oscar.SNAC_0x03_0x0A_BuddyArrived{
+			TLVUserInfo: sess.TLVUserInfo(),
+		},
+	})
+}
+
+func unicastDeparture(ctx context.Context, srcScreenName, destScreenName string, sm server.SessionManager) {
+	sess := sm.RetrieveByScreenName(srcScreenName)
+	switch {
+	case sess == nil:
+		fallthrough
+	case sess.Invisible(): // don't tell user this buddy is online
+		return
+	}
+
+	sm.SendToScreenName(ctx, destScreenName, oscar.XMessage{
+		SnacFrame: oscar.SnacFrame{
+			FoodGroup: oscar.BUDDY,
+			SubGroup:  oscar.BuddyDeparted,
+		},
+		SnacOut: oscar.SNAC_0x03_0x0B_BuddyDeparted{
+			TLVUserInfo: oscar.TLVUserInfo{
+				// don't include the TLV block, otherwise the AIM client fails
+				// to process the block event
+				ScreenName:   sess.ScreenName(),
+				WarningLevel: sess.Warning(),
+			},
+		},
+	})
+}

+ 135 - 0
handler/chat.go

@@ -0,0 +1,135 @@
+package handler
+
+import (
+	"context"
+	"github.com/mkaminski/goaim/oscar"
+	"github.com/mkaminski/goaim/server"
+)
+
+func NewChatService() *ChatService {
+	return &ChatService{}
+}
+
+type ChatService struct {
+}
+
+func (s ChatService) ChannelMsgToHostHandler(ctx context.Context, sess *server.Session, chatSessMgr server.ChatSessionManager, snacPayloadIn oscar.SNAC_0x0E_0x05_ChatChannelMsgToHost) (*oscar.XMessage, error) {
+	snacFrameOut := oscar.SnacFrame{
+		FoodGroup: oscar.CHAT,
+		SubGroup:  oscar.ChatChannelMsgToClient,
+	}
+	snacPayloadOut := oscar.SNAC_0x0E_0x06_ChatChannelMsgToClient{
+		Cookie:  snacPayloadIn.Cookie,
+		Channel: snacPayloadIn.Channel,
+		TLVRestBlock: oscar.TLVRestBlock{
+			TLVList: snacPayloadIn.TLVList,
+		},
+	}
+	snacPayloadOut.AddTLV(
+		oscar.NewTLV(oscar.ChatTLVSenderInformation, oscar.TLVUserInfo{
+			ScreenName:   sess.ScreenName(),
+			WarningLevel: sess.Warning(),
+			TLVBlock: oscar.TLVBlock{
+				TLVList: sess.UserInfo(),
+			},
+		}),
+	)
+
+	// send message to all the participants except sender
+	chatSessMgr.BroadcastExcept(ctx, sess, oscar.XMessage{
+		SnacFrame: snacFrameOut,
+		SnacOut:   snacPayloadOut,
+	})
+
+	var ret *oscar.XMessage
+	if _, ackMsg := snacPayloadIn.GetTLV(oscar.ChatTLVEnableReflectionFlag); ackMsg {
+		// reflect the message back to the sender
+		ret = &oscar.XMessage{
+			SnacFrame: snacFrameOut,
+			SnacOut:   snacPayloadOut,
+		}
+	}
+
+	return ret, nil
+}
+
+func setOnlineChatUsers(ctx context.Context, sess *server.Session, chatSessMgr server.ChatSessionManager) {
+	snacPayloadOut := oscar.SNAC_0x0E_0x03_ChatUsersJoined{}
+	sessions := chatSessMgr.Participants()
+
+	for _, uSess := range sessions {
+		snacPayloadOut.Users = append(snacPayloadOut.Users, oscar.TLVUserInfo{
+			ScreenName:   uSess.ScreenName(),
+			WarningLevel: uSess.Warning(),
+			TLVBlock: oscar.TLVBlock{
+				TLVList: uSess.UserInfo(),
+			},
+		})
+	}
+
+	chatSessMgr.SendToScreenName(ctx, sess.ScreenName(), oscar.XMessage{
+		SnacFrame: oscar.SnacFrame{
+			FoodGroup: oscar.CHAT,
+			SubGroup:  oscar.ChatUsersJoined,
+		},
+		SnacOut: snacPayloadOut,
+	})
+}
+
+func alertUserJoined(ctx context.Context, sess *server.Session, chatSessMgr server.ChatSessionManager) {
+	chatSessMgr.BroadcastExcept(ctx, sess, oscar.XMessage{
+		SnacFrame: oscar.SnacFrame{
+			FoodGroup: oscar.CHAT,
+			SubGroup:  oscar.ChatUsersJoined,
+		},
+		SnacOut: oscar.SNAC_0x0E_0x03_ChatUsersJoined{
+			Users: []oscar.TLVUserInfo{
+				{
+					ScreenName:   sess.ScreenName(),
+					WarningLevel: sess.Warning(),
+					TLVBlock: oscar.TLVBlock{
+						TLVList: sess.UserInfo(),
+					},
+				},
+			},
+		},
+	})
+}
+
+func alertUserLeft(ctx context.Context, sess *server.Session, chatSessMgr server.ChatSessionManager) {
+	chatSessMgr.BroadcastExcept(ctx, sess, oscar.XMessage{
+		SnacFrame: oscar.SnacFrame{
+			FoodGroup: oscar.CHAT,
+			SubGroup:  oscar.ChatUsersLeft,
+		},
+		SnacOut: oscar.SNAC_0x0E_0x04_ChatUsersLeft{
+			Users: []oscar.TLVUserInfo{
+				{
+					ScreenName:   sess.ScreenName(),
+					WarningLevel: sess.Warning(),
+					TLVBlock: oscar.TLVBlock{
+						TLVList: sess.UserInfo(),
+					},
+				},
+			},
+		},
+	})
+}
+
+func sendChatRoomInfoUpdate(ctx context.Context, sess *server.Session, chatSessMgr server.ChatSessionManager, room server.ChatRoom) {
+	chatSessMgr.SendToScreenName(ctx, sess.ScreenName(), oscar.XMessage{
+		SnacFrame: oscar.SnacFrame{
+			FoodGroup: oscar.CHAT,
+			SubGroup:  oscar.ChatRoomInfoUpdate,
+		},
+		SnacOut: oscar.SNAC_0x0E_0x02_ChatRoomInfoUpdate{
+			Exchange:       4,
+			Cookie:         room.Cookie,
+			InstanceNumber: 100,
+			DetailLevel:    2,
+			TLVBlock: oscar.TLVBlock{
+				TLVList: room.TLVList(),
+			},
+		},
+	})
+}

+ 120 - 0
handler/chat_nav.go

@@ -0,0 +1,120 @@
+package handler
+
+import (
+	"context"
+	"errors"
+	"github.com/mkaminski/goaim/oscar"
+	"github.com/mkaminski/goaim/server"
+	"log/slog"
+)
+
+func NewChatNavService(logger *slog.Logger, cr *server.ChatRegistry) *ChatNavService {
+	return &ChatNavService{Logger: logger, cr: cr}
+}
+
+type ChatNavService struct {
+	Logger *slog.Logger
+	cr     *server.ChatRegistry
+}
+
+func (s ChatNavService) RequestChatRightsHandler(context.Context) oscar.XMessage {
+	return oscar.XMessage{
+		SnacFrame: oscar.SnacFrame{
+			FoodGroup: oscar.CHAT_NAV,
+			SubGroup:  oscar.ChatNavNavInfo,
+		},
+		SnacOut: oscar.SNAC_0x0D_0x09_ChatNavNavInfo{
+			TLVRestBlock: oscar.TLVRestBlock{
+				TLVList: oscar.TLVList{
+					oscar.NewTLV(0x02, uint8(10)),
+					oscar.NewTLV(0x03, oscar.SNAC_0x0D_0x09_TLVExchangeInfo{
+						Identifier: 4,
+						TLVBlock: oscar.TLVBlock{
+							TLVList: oscar.TLVList{
+								oscar.NewTLV(0x0002, uint16(0x0010)),
+								oscar.NewTLV(0x00c9, uint16(15)),
+								oscar.NewTLV(0x00d3, "default Exchange"),
+								oscar.NewTLV(0x00d5, uint8(2)),
+								oscar.NewTLV(0xd6, "us-ascii"),
+								oscar.NewTLV(0xd7, "en"),
+								oscar.NewTLV(0xd8, "us-ascii"),
+								oscar.NewTLV(0xd9, "en"),
+							},
+						},
+					}),
+				},
+			},
+		},
+	}
+}
+
+func (s ChatNavService) CreateRoomHandler(ctx context.Context, sess *server.Session, newChatRoom func() server.ChatRoom, newChatSessMgr func() server.ChatSessionManager, snacPayloadIn oscar.SNAC_0x0E_0x02_ChatRoomInfoUpdate) (oscar.XMessage, error) {
+	name, hasName := snacPayloadIn.GetString(oscar.ChatTLVRoomName)
+	if !hasName {
+		return oscar.XMessage{}, errors.New("unable to find chat name")
+	}
+
+	room := newChatRoom()
+	room.DetailLevel = snacPayloadIn.DetailLevel
+	room.Exchange = snacPayloadIn.Exchange
+	room.InstanceNumber = snacPayloadIn.InstanceNumber
+	room.Name = name
+
+	chatSessMgr := newChatSessMgr()
+
+	s.cr.Register(room, chatSessMgr)
+
+	// add user to chat room
+	chatSessMgr.NewSessionWithSN(sess.ID(), sess.ScreenName())
+
+	return oscar.XMessage{
+		SnacFrame: oscar.SnacFrame{
+			FoodGroup: oscar.CHAT_NAV,
+			SubGroup:  oscar.ChatNavNavInfo,
+		},
+		SnacOut: oscar.SNAC_0x0D_0x09_ChatNavNavInfo{
+			TLVRestBlock: oscar.TLVRestBlock{
+				TLVList: oscar.TLVList{
+					oscar.NewTLV(oscar.ChatNavTLVRoomInfo, oscar.SNAC_0x0E_0x02_ChatRoomInfoUpdate{
+						Exchange:       snacPayloadIn.Exchange,
+						Cookie:         room.Cookie,
+						InstanceNumber: snacPayloadIn.InstanceNumber,
+						DetailLevel:    snacPayloadIn.DetailLevel,
+						TLVBlock: oscar.TLVBlock{
+							TLVList: room.TLVList(),
+						},
+					}),
+				},
+			},
+		},
+	}, nil
+}
+
+func (s ChatNavService) RequestRoomInfoHandler(_ context.Context, snacPayloadIn oscar.SNAC_0x0D_0x04_ChatNavRequestRoomInfo) (oscar.XMessage, error) {
+	room, _, err := s.cr.Retrieve(string(snacPayloadIn.Cookie))
+	if err != nil {
+		return oscar.XMessage{}, err
+	}
+
+	return oscar.XMessage{
+		SnacFrame: oscar.SnacFrame{
+			FoodGroup: oscar.CHAT_NAV,
+			SubGroup:  oscar.ChatNavNavInfo,
+		},
+		SnacOut: oscar.SNAC_0x0D_0x09_ChatNavNavInfo{
+			TLVRestBlock: oscar.TLVRestBlock{
+				TLVList: oscar.TLVList{
+					oscar.NewTLV(0x04, oscar.SNAC_0x0E_0x02_ChatRoomInfoUpdate{
+						Exchange:       4,
+						Cookie:         room.Cookie,
+						InstanceNumber: 100,
+						DetailLevel:    2,
+						TLVBlock: oscar.TLVBlock{
+							TLVList: room.TLVList(),
+						},
+					}),
+				},
+			},
+		},
+	}, nil
+}

+ 99 - 0
handler/chat_nav_test.go

@@ -0,0 +1,99 @@
+package handler
+
+import (
+	"context"
+	"github.com/mkaminski/goaim/oscar"
+	"github.com/mkaminski/goaim/server"
+	"github.com/stretchr/testify/assert"
+	"testing"
+	"time"
+)
+
+func TestSendAndReceiveCreateRoom(t *testing.T) {
+	//
+	// build dependencies
+	//
+	userSess := newTestSession("user-screen-name", sessOptCannedID)
+
+	cr := server.NewChatRegistry()
+
+	sm := server.NewMockChatSessionManager(t)
+	sm.EXPECT().NewSessionWithSN(userSess.ID(), userSess.ScreenName()).
+		Return(&server.Session{})
+
+	chatSessMgrFactory := func() server.ChatSessionManager {
+		return sm
+	}
+
+	newChatRoom := func() server.ChatRoom {
+		return server.ChatRoom{
+			Cookie:     "dummy-cookie",
+			CreateTime: time.UnixMilli(0),
+		}
+	}
+
+	//
+	// send input SNAC
+	//
+	inputSNAC := 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 := ChatNavService{
+		cr: cr,
+	}
+	outputSNAC, err := svc.CreateRoomHandler(context.Background(), userSess, newChatRoom, chatSessMgrFactory, inputSNAC)
+	assert.NoError(t, err)
+
+	//
+	// verify chat room created by handler
+	//
+	expectChatRoom := server.ChatRoom{
+		Cookie:         "dummy-cookie",
+		CreateTime:     time.UnixMilli(0),
+		DetailLevel:    3,
+		Exchange:       1,
+		InstanceNumber: 2,
+		Name:           "the-chat-room-name",
+	}
+	chatRoom, _, err := cr.Retrieve("dummy-cookie")
+	assert.NoError(t, err)
+	assert.Equal(t, expectChatRoom, chatRoom)
+
+	//
+	// send input SNAC
+	//
+	expectSNAC := oscar.XMessage{
+		SnacFrame: oscar.SnacFrame{
+			FoodGroup: oscar.CHAT_NAV,
+			SubGroup:  oscar.ChatNavNavInfo,
+		},
+		SnacOut: oscar.SNAC_0x0D_0x09_ChatNavNavInfo{
+			TLVRestBlock: oscar.TLVRestBlock{
+				TLVList: oscar.TLVList{
+					oscar.NewTLV(
+						oscar.ChatNavTLVRoomInfo,
+						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)
+}

+ 138 - 0
handler/chat_test.go

@@ -0,0 +1,138 @@
+package handler
+
+import (
+	"context"
+	"github.com/mkaminski/goaim/oscar"
+	"github.com/mkaminski/goaim/server"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/mock"
+	"testing"
+)
+
+func TestSendAndReceiveChatChannelMsgToHost(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 *server.Session
+		// inputSNAC is the SNAC sent by the sender client
+		inputSNAC oscar.SNAC_0x0E_0x05_ChatChannelMsgToHost
+		// expectSNACToParticipants is the message the server broadcast to chat
+		// room participants (except the sender)
+		expectSNACToParticipants oscar.XMessage
+		expectOutput             *oscar.XMessage
+	}{
+		{
+			name:        "send chat room message, expect acknowledgement to sender client",
+			userSession: newTestSession("user_sending_chat_msg", sessOptCannedSignonTime),
+			inputSNAC: oscar.SNAC_0x0E_0x05_ChatChannelMsgToHost{
+				Cookie:  1234,
+				Channel: 14,
+				TLVRestBlock: oscar.TLVRestBlock{
+					TLVList: oscar.TLVList{
+						{
+							TType: oscar.ChatTLVPublicWhisperFlag,
+							Val:   []byte{},
+						},
+						{
+							TType: oscar.ChatTLVEnableReflectionFlag,
+							Val:   []byte{},
+						},
+					},
+				},
+			},
+			expectSNACToParticipants: oscar.XMessage{
+				SnacFrame: oscar.SnacFrame{
+					FoodGroup: oscar.CHAT,
+					SubGroup:  oscar.ChatChannelMsgToClient,
+				},
+				SnacOut: oscar.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()),
+						},
+					},
+				},
+			},
+			expectOutput: &oscar.XMessage{
+				SnacFrame: oscar.SnacFrame{
+					FoodGroup: oscar.CHAT,
+					SubGroup:  oscar.ChatChannelMsgToClient,
+				},
+				SnacOut: oscar.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()),
+						},
+					},
+				},
+			},
+		},
+		{
+			name:        "send chat room message, don't expect acknowledgement to sender client",
+			userSession: newTestSession("user_sending_chat_msg", sessOptCannedSignonTime),
+			inputSNAC: oscar.SNAC_0x0E_0x05_ChatChannelMsgToHost{
+				Cookie:  1234,
+				Channel: 14,
+				TLVRestBlock: oscar.TLVRestBlock{
+					TLVList: oscar.TLVList{
+						{
+							TType: oscar.ChatTLVPublicWhisperFlag,
+							Val:   []byte{},
+						},
+					},
+				},
+			},
+			expectSNACToParticipants: oscar.XMessage{
+				SnacFrame: oscar.SnacFrame{
+					FoodGroup: oscar.CHAT,
+					SubGroup:  oscar.ChatChannelMsgToClient,
+				},
+				SnacOut: oscar.SNAC_0x0E_0x06_ChatChannelMsgToClient{
+					Cookie:  1234,
+					Channel: 14,
+					TLVRestBlock: oscar.TLVRestBlock{
+						TLVList: oscar.TLVList{
+							oscar.NewTLV(oscar.ChatTLVPublicWhisperFlag, []byte{}),
+							oscar.NewTLV(oscar.ChatTLVSenderInformation,
+								newTestSession("user_sending_chat_msg", sessOptCannedSignonTime).TLVUserInfo()),
+						},
+					},
+				},
+			},
+			expectOutput: &oscar.XMessage{},
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			//
+			// initialize dependencies
+			//
+			crm := server.NewMockChatSessionManager(t)
+			crm.EXPECT().
+				BroadcastExcept(mock.Anything, tc.userSession, tc.expectSNACToParticipants)
+			//
+			// send input SNAC
+			//
+			svc := ChatService{}
+			outputSNAC, err := svc.ChannelMsgToHostHandler(context.Background(), tc.userSession, crm, tc.inputSNAC)
+			assert.NoError(t, err)
+
+			if tc.expectOutput.SnacFrame == (oscar.SnacFrame{}) {
+				return // handler doesn't return response
+			}
+
+			assert.Equal(t, tc.expectOutput, outputSNAC)
+		})
+	}
+}

+ 237 - 0
handler/feedbag.go

@@ -0,0 +1,237 @@
+package handler
+
+import (
+	"context"
+	"github.com/mkaminski/goaim/oscar"
+	"github.com/mkaminski/goaim/server"
+	"time"
+)
+
+func NewFeedbagService(sm server.SessionManager, fm server.FeedbagManager) *FeedbagService {
+	return &FeedbagService{sm: sm, fm: fm}
+}
+
+type FeedbagService struct {
+	sm server.SessionManager
+	fm server.FeedbagManager
+}
+
+func (s FeedbagService) RightsQueryHandler(context.Context) oscar.XMessage {
+	return oscar.XMessage{
+		SnacFrame: oscar.SnacFrame{
+			FoodGroup: oscar.FEEDBAG,
+			SubGroup:  oscar.FeedbagRightsReply,
+		},
+		SnacOut: oscar.SNAC_0x13_0x03_FeedbagRightsReply{
+			TLVRestBlock: oscar.TLVRestBlock{
+				TLVList: oscar.TLVList{
+					oscar.NewTLV(0x03, uint16(200)),
+					oscar.NewTLV(0x04, []uint16{
+						0x3D,
+						0x3D,
+						0x64,
+						0x64,
+						0x01,
+						0x01,
+						0x32,
+						0x00,
+						0x00,
+						0x03,
+						0x00,
+						0x00,
+						0x00,
+						0x80,
+						0xFF,
+						0x14,
+						0xC8,
+						0x01,
+						0x00,
+						0x01,
+						0x00,
+					}),
+					oscar.NewTLV(0x05, uint16(200)),
+					oscar.NewTLV(0x06, uint16(200)),
+					oscar.NewTLV(0x07, uint16(200)),
+					oscar.NewTLV(0x08, uint16(200)),
+					oscar.NewTLV(0x09, uint16(200)),
+					oscar.NewTLV(0x0A, uint16(200)),
+					oscar.NewTLV(0x0C, uint16(200)),
+					oscar.NewTLV(0x0D, uint16(200)),
+					oscar.NewTLV(0x0E, uint16(100)),
+				},
+			},
+		},
+	}
+}
+
+func (s FeedbagService) QueryHandler(_ context.Context, sess *server.Session) (oscar.XMessage, error) {
+	fb, err := s.fm.Retrieve(sess.ScreenName())
+	if err != nil {
+		return oscar.XMessage{}, err
+	}
+
+	lm := time.UnixMilli(0)
+
+	if len(fb) > 0 {
+		lm, err = s.fm.LastModified(sess.ScreenName())
+		if err != nil {
+			return oscar.XMessage{}, err
+		}
+	}
+
+	return oscar.XMessage{
+		SnacFrame: oscar.SnacFrame{
+			FoodGroup: oscar.FEEDBAG,
+			SubGroup:  oscar.FeedbagReply,
+		},
+		SnacOut: oscar.SNAC_0x13_0x06_FeedbagReply{
+			Version:    0,
+			Items:      fb,
+			LastUpdate: uint32(lm.Unix()),
+		},
+	}, nil
+}
+
+func (s FeedbagService) QueryIfModifiedHandler(_ context.Context, sess *server.Session, snacPayloadIn oscar.SNAC_0x13_0x05_FeedbagQueryIfModified) (oscar.XMessage, error) {
+	fb, err := s.fm.Retrieve(sess.ScreenName())
+	if err != nil {
+		return oscar.XMessage{}, err
+	}
+
+	lm := time.UnixMilli(0)
+
+	if len(fb) > 0 {
+		lm, err = s.fm.LastModified(sess.ScreenName())
+		if err != nil {
+			return oscar.XMessage{}, err
+		}
+		if lm.Before(time.Unix(int64(snacPayloadIn.LastUpdate), 0)) {
+			return oscar.XMessage{
+				SnacFrame: oscar.SnacFrame{
+					FoodGroup: oscar.FEEDBAG,
+					SubGroup:  oscar.FeedbagReplyNotModified,
+				},
+				SnacOut: oscar.SNAC_0x13_0x05_FeedbagQueryIfModified{
+					LastUpdate: uint32(lm.Unix()),
+					Count:      uint8(len(fb)),
+				},
+			}, nil
+		}
+	}
+
+	return oscar.XMessage{
+		SnacFrame: oscar.SnacFrame{
+			FoodGroup: oscar.FEEDBAG,
+			SubGroup:  oscar.FeedbagReply,
+		},
+		SnacOut: oscar.SNAC_0x13_0x06_FeedbagReply{
+			Version:    0,
+			Items:      fb,
+			LastUpdate: uint32(lm.Unix()),
+		},
+	}, nil
+}
+
+func (s FeedbagService) InsertItemHandler(ctx context.Context, sess *server.Session, snacPayloadIn oscar.SNAC_0x13_0x08_FeedbagInsertItem) (oscar.XMessage, error) {
+	for _, item := range snacPayloadIn.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.XMessage{
+				SnacFrame: oscar.SnacFrame{
+					FoodGroup: oscar.FEEDBAG,
+					SubGroup:  oscar.FeedbagErr,
+				},
+				SnacOut: oscar.SnacError{
+					Code: oscar.ErrorCodeNotSupportedByHost,
+				},
+			}, nil
+		}
+	}
+
+	if err := s.fm.Upsert(sess.ScreenName(), snacPayloadIn.Items); err != nil {
+		return oscar.XMessage{}, nil
+	}
+
+	for _, item := range snacPayloadIn.Items {
+		switch item.ClassID {
+		case oscar.FeedbagClassIdBuddy, oscar.FeedbagClassIDPermit: // add new buddy
+			unicastArrival(ctx, item.Name, sess.ScreenName(), s.sm)
+		case oscar.FeedbagClassIDDeny: // block buddy
+			// notify this user that buddy is offline
+			unicastDeparture(ctx, item.Name, sess.ScreenName(), s.sm)
+			// notify former buddy that this user is offline
+			unicastDeparture(ctx, sess.ScreenName(), item.Name, s.sm)
+		}
+	}
+
+	snacPayloadOut := oscar.SNAC_0x13_0x0E_FeedbagStatus{}
+	for range snacPayloadIn.Items {
+		snacPayloadOut.Results = append(snacPayloadOut.Results, 0x0000)
+	}
+
+	return oscar.XMessage{
+		SnacFrame: oscar.SnacFrame{
+			FoodGroup: oscar.FEEDBAG,
+			SubGroup:  oscar.FeedbagStatus,
+		},
+		SnacOut: snacPayloadOut,
+	}, nil
+}
+
+func (s FeedbagService) UpdateItemHandler(ctx context.Context, sess *server.Session, snacPayloadIn oscar.SNAC_0x13_0x09_FeedbagUpdateItem) (oscar.XMessage, error) {
+	if err := s.fm.Upsert(sess.ScreenName(), snacPayloadIn.Items); err != nil {
+		return oscar.XMessage{}, nil
+	}
+
+	for _, item := range snacPayloadIn.Items {
+		switch item.ClassID {
+		case oscar.FeedbagClassIdBuddy, oscar.FeedbagClassIDPermit:
+			unicastArrival(ctx, item.Name, sess.ScreenName(), s.sm)
+		}
+	}
+
+	snacPayloadOut := oscar.SNAC_0x13_0x0E_FeedbagStatus{}
+	for range snacPayloadIn.Items {
+		snacPayloadOut.Results = append(snacPayloadOut.Results, 0x0000)
+	}
+
+	return oscar.XMessage{
+		SnacFrame: oscar.SnacFrame{
+			FoodGroup: oscar.FEEDBAG,
+			SubGroup:  oscar.FeedbagStatus,
+		},
+		SnacOut: snacPayloadOut,
+	}, nil
+}
+
+func (s FeedbagService) DeleteItemHandler(ctx context.Context, sess *server.Session, snacPayloadIn oscar.SNAC_0x13_0x0A_FeedbagDeleteItem) (oscar.XMessage, error) {
+	if err := s.fm.Delete(sess.ScreenName(), snacPayloadIn.Items); err != nil {
+		return oscar.XMessage{}, err
+	}
+
+	for _, item := range snacPayloadIn.Items {
+		if item.ClassID == oscar.FeedbagClassIDDeny {
+			unicastArrival(ctx, item.Name, sess.ScreenName(), s.sm)
+			unicastArrival(ctx, sess.ScreenName(), item.Name, s.sm)
+		}
+	}
+
+	snacPayloadOut := oscar.SNAC_0x13_0x0E_FeedbagStatus{}
+	for range snacPayloadIn.Items {
+		snacPayloadOut.Results = append(snacPayloadOut.Results, 0x0000) // success by default
+	}
+
+	return oscar.XMessage{
+		SnacFrame: oscar.SnacFrame{
+			FoodGroup: oscar.FEEDBAG,
+			SubGroup:  oscar.FeedbagStatus,
+		},
+		SnacOut: 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.SNAC_0x13_0x11_FeedbagStartCluster) {
+}

+ 572 - 0
handler/feedbag_test.go

@@ -0,0 +1,572 @@
+package handler
+
+import (
+	"github.com/mkaminski/goaim/oscar"
+	"github.com/mkaminski/goaim/server"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/mock"
+	"testing"
+	"time"
+)
+
+func TestQueryHandler(t *testing.T) {
+	cases := []struct {
+		// name is the unit test name
+		name string
+		// screenName is the buddy list owner
+		screenName string
+		// feedbagItems is the list of items in user's buddy list
+		feedbagItems []oscar.FeedbagItem
+		// lastModified is the time the buddy list was last changed
+		lastModified time.Time
+		// expectOutput is the SNAC payload sent from the server to the
+		// recipient client
+		expectOutput oscar.XMessage
+	}{
+		{
+			name:         "retrieve empty feedbag",
+			screenName:   "user_screen_name",
+			feedbagItems: []oscar.FeedbagItem{},
+			lastModified: time.UnixMilli(0),
+			expectOutput: oscar.XMessage{
+				SnacFrame: oscar.SnacFrame{
+					FoodGroup: oscar.FEEDBAG,
+					SubGroup:  oscar.FeedbagReply,
+				},
+				SnacOut: oscar.SNAC_0x13_0x06_FeedbagReply{
+					Items: []oscar.FeedbagItem{},
+				},
+			},
+		},
+		{
+			name:       "retrieve feedbag with items",
+			screenName: "user_screen_name",
+			feedbagItems: []oscar.FeedbagItem{
+				{
+					Name: "buddy_1",
+				},
+				{
+					Name: "buddy_2",
+				},
+			},
+			lastModified: time.UnixMilli(1696472198082),
+			expectOutput: oscar.XMessage{
+				SnacFrame: oscar.SnacFrame{
+					FoodGroup: oscar.FEEDBAG,
+					SubGroup:  oscar.FeedbagReply,
+				},
+				SnacOut: oscar.SNAC_0x13_0x06_FeedbagReply{
+					Version: 0,
+					Items: []oscar.FeedbagItem{
+						{
+							Name: "buddy_1",
+						},
+						{
+							Name: "buddy_2",
+						},
+					},
+					LastUpdate: uint32(time.UnixMilli(1696472198082).Unix()),
+				},
+			},
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			//
+			// initialize dependencies
+			//
+			fm := server.NewMockFeedbagManager(t)
+			fm.EXPECT().
+				Retrieve(tc.screenName).
+				Return(tc.feedbagItems, nil).
+				Maybe()
+			fm.EXPECT().
+				LastModified(tc.screenName).
+				Return(tc.lastModified, nil).
+				Maybe()
+			//
+			// send input SNAC
+			//
+			senderSession := newTestSession(tc.screenName)
+			svc := FeedbagService{
+				fm: fm,
+			}
+			outputSNAC, err := svc.QueryHandler(nil, senderSession)
+			assert.NoError(t, err)
+			assert.Equal(t, tc.expectOutput, outputSNAC)
+		})
+	}
+}
+
+func TestQueryIfModifiedHandler(t *testing.T) {
+	cases := []struct {
+		// name is the unit test name
+		name string
+		// screenName is the buddy list owner
+		screenName string
+		// feedbagItems is the list of items in user's buddy list
+		feedbagItems []oscar.FeedbagItem
+		// lastModified is the time the buddy list was last changed
+		lastModified time.Time
+		// inputSNAC is the SNAC sent by the sender client
+		inputSNAC oscar.SNAC_0x13_0x05_FeedbagQueryIfModified
+		// expectOutput is the SNAC payload sent from the server to the
+		// recipient client
+		expectOutput oscar.XMessage
+	}{
+		{
+			name:         "retrieve empty feedbag",
+			screenName:   "user_screen_name",
+			feedbagItems: []oscar.FeedbagItem{},
+			lastModified: time.UnixMilli(0),
+			inputSNAC: oscar.SNAC_0x13_0x05_FeedbagQueryIfModified{
+				LastUpdate: uint32(time.UnixMilli(100000).Unix()),
+			},
+			expectOutput: oscar.XMessage{
+				SnacFrame: oscar.SnacFrame{
+					FoodGroup: oscar.FEEDBAG,
+					SubGroup:  oscar.FeedbagReply,
+				},
+				SnacOut: oscar.SNAC_0x13_0x06_FeedbagReply{
+					Items: []oscar.FeedbagItem{},
+				},
+			},
+		},
+		{
+			name:       "retrieve feedbag with items",
+			screenName: "user_screen_name",
+			feedbagItems: []oscar.FeedbagItem{
+				{
+					Name: "buddy_1",
+				},
+				{
+					Name: "buddy_2",
+				},
+			},
+			lastModified: time.UnixMilli(200000),
+			inputSNAC: oscar.SNAC_0x13_0x05_FeedbagQueryIfModified{
+				LastUpdate: uint32(time.UnixMilli(100000).Unix()),
+			},
+			expectOutput: oscar.XMessage{
+				SnacFrame: oscar.SnacFrame{
+					FoodGroup: oscar.FEEDBAG,
+					SubGroup:  oscar.FeedbagReply,
+				},
+				SnacOut: oscar.SNAC_0x13_0x06_FeedbagReply{
+					Version: 0,
+					Items: []oscar.FeedbagItem{
+						{
+							Name: "buddy_1",
+						},
+						{
+							Name: "buddy_2",
+						},
+					},
+					LastUpdate: uint32(time.UnixMilli(200000).Unix()),
+				},
+			},
+		},
+		{
+			name:       "retrieve not-modified response",
+			screenName: "user_screen_name",
+			feedbagItems: []oscar.FeedbagItem{
+				{
+					Name: "buddy_1",
+				},
+				{
+					Name: "buddy_2",
+				},
+			},
+			lastModified: time.UnixMilli(100000),
+			inputSNAC: oscar.SNAC_0x13_0x05_FeedbagQueryIfModified{
+				LastUpdate: uint32(time.UnixMilli(200000).Unix()),
+			},
+			expectOutput: oscar.XMessage{
+				SnacFrame: oscar.SnacFrame{
+					FoodGroup: oscar.FEEDBAG,
+					SubGroup:  oscar.FeedbagReplyNotModified,
+				},
+				SnacOut: oscar.SNAC_0x13_0x05_FeedbagQueryIfModified{
+					LastUpdate: uint32(time.UnixMilli(100000).Unix()),
+					Count:      2,
+				},
+			},
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			//
+			// initialize dependencies
+			//
+			fm := server.NewMockFeedbagManager(t)
+			fm.EXPECT().
+				Retrieve(tc.screenName).
+				Return(tc.feedbagItems, nil).
+				Maybe()
+			fm.EXPECT().
+				LastModified(tc.screenName).
+				Return(tc.lastModified, nil).
+				Maybe()
+			//
+			// send input SNAC
+			//
+			senderSession := newTestSession(tc.screenName)
+			svc := FeedbagService{
+				fm: fm,
+			}
+			outputSNAC, err := svc.QueryIfModifiedHandler(nil, senderSession, tc.inputSNAC)
+			assert.NoError(t, err)
+			//
+			// verify output
+			//
+			assert.Equal(t, tc.expectOutput, outputSNAC)
+		})
+	}
+}
+
+func TestInsertItemHandler(t *testing.T) {
+	cases := []struct {
+		// name is the unit test name
+		name string
+		// userSession is the session of the user managing buddy list
+		userSession *server.Session
+		// feedbagItems is the list of items in user's buddy list
+		feedbagItems []oscar.FeedbagItem
+		// inputSNAC is the SNAC sent by the sender client
+		inputSNAC oscar.SNAC_0x13_0x08_FeedbagInsertItem
+		// screenNameLookups is the list of user's online buddies
+		screenNameLookups map[string]struct {
+			sess *server.Session
+			err  error
+		}
+		// clientResponse is the message returned to the client
+		clientResponse oscar.XMessage
+		// buddyMessages are events forwarded to buddy clients
+		buddyMessages []struct {
+			user string
+			msg  oscar.XMessage
+		}
+	}{
+		{
+			name:        "user adds 2 online buddies, expect OK response",
+			userSession: newTestSession("user_screen_name"),
+			inputSNAC: oscar.SNAC_0x13_0x08_FeedbagInsertItem{
+				Items: []oscar.FeedbagItem{
+					{
+						ClassID: 2,
+						Name:    "buddy_1_online",
+					},
+					{
+						ClassID: 2,
+						Name:    "buddy_2_online",
+					},
+				},
+			},
+			screenNameLookups: map[string]struct {
+				sess *server.Session
+				err  error
+			}{
+				"user_screen_name": {
+					sess: newTestSession("user_screen_name", sessOptCannedSignonTime),
+				},
+				"buddy_1_online": {
+					sess: newTestSession("buddy_1_online", sessOptCannedSignonTime),
+				},
+				"buddy_2_online": {
+					sess: newTestSession("buddy_2_online", sessOptCannedSignonTime),
+				},
+			},
+			clientResponse: oscar.XMessage{
+				SnacFrame: oscar.SnacFrame{
+					FoodGroup: oscar.FEEDBAG,
+					SubGroup:  oscar.FeedbagStatus,
+				},
+				SnacOut: oscar.SNAC_0x13_0x0E_FeedbagStatus{
+					Results: []uint16{0x0000, 0x0000},
+				},
+			},
+			buddyMessages: []struct {
+				user string
+				msg  oscar.XMessage
+			}{
+				{
+					user: "user_screen_name",
+					msg: oscar.XMessage{
+						SnacFrame: oscar.SnacFrame{
+							FoodGroup: oscar.BUDDY,
+							SubGroup:  oscar.BuddyArrived,
+						},
+						SnacOut: oscar.SNAC_0x03_0x0A_BuddyArrived{
+							TLVUserInfo: oscar.TLVUserInfo{
+								ScreenName: "buddy_1_online",
+								TLVBlock: oscar.TLVBlock{
+									TLVList: newTestSession("", sessOptCannedSignonTime).UserInfo(),
+								},
+							},
+						},
+					},
+				},
+				{
+					user: "user_screen_name",
+					msg: oscar.XMessage{
+						SnacFrame: oscar.SnacFrame{
+							FoodGroup: oscar.BUDDY,
+							SubGroup:  oscar.BuddyArrived,
+						},
+						SnacOut: oscar.SNAC_0x03_0x0A_BuddyArrived{
+							TLVUserInfo: oscar.TLVUserInfo{
+								ScreenName: "buddy_2_online",
+								TLVBlock: oscar.TLVBlock{
+									TLVList: newTestSession("", sessOptCannedSignonTime).UserInfo(),
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name:        "user adds an offline buddy, expect OK response and 0 buddy arrived events",
+			userSession: newTestSession("user_screen_name"),
+			inputSNAC: oscar.SNAC_0x13_0x08_FeedbagInsertItem{
+				Items: []oscar.FeedbagItem{
+					{
+						ClassID: 2,
+						Name:    "buddy_offline",
+					},
+				},
+			},
+			screenNameLookups: map[string]struct {
+				sess *server.Session
+				err  error
+			}{
+				"buddy_offline": {
+					sess: nil,
+				},
+			},
+			clientResponse: oscar.XMessage{
+				SnacFrame: oscar.SnacFrame{
+					FoodGroup: oscar.FEEDBAG,
+					SubGroup:  oscar.FeedbagStatus,
+				},
+				SnacOut: oscar.SNAC_0x13_0x0E_FeedbagStatus{
+					Results: []uint16{0x0000},
+				},
+			},
+		},
+		{
+			name:        "users adds an invisible buddy, expect OK response and 0 buddy arrived events",
+			userSession: newTestSession("user_screen_name"),
+			inputSNAC: oscar.SNAC_0x13_0x08_FeedbagInsertItem{
+				Items: []oscar.FeedbagItem{
+					{
+						ClassID: 2,
+						Name:    "invisible_buddy_online",
+					},
+				},
+			},
+			screenNameLookups: map[string]struct {
+				sess *server.Session
+				err  error
+			}{
+				"invisible_buddy_online": {
+					sess: newTestSession("invisible_buddy_online", sessOptInvisible),
+				},
+			},
+			clientResponse: oscar.XMessage{
+				SnacFrame: oscar.SnacFrame{
+					FoodGroup: oscar.FEEDBAG,
+					SubGroup:  oscar.FeedbagStatus,
+				},
+				SnacOut: oscar.SNAC_0x13_0x0E_FeedbagStatus{
+					Results: []uint16{0x0000},
+				},
+			},
+		},
+		{
+			name: "user blocks buddy currently online, expect OK response, buddy departed event client, 1 buddy " +
+				"departed event sent to buddy",
+			userSession: newTestSession("user_screen_name"),
+			inputSNAC: oscar.SNAC_0x13_0x08_FeedbagInsertItem{
+				Items: []oscar.FeedbagItem{
+					{
+						ClassID: 3,
+						Name:    "buddy_1",
+					},
+				},
+			},
+			screenNameLookups: map[string]struct {
+				sess *server.Session
+				err  error
+			}{
+				"user_screen_name": {
+					sess: newTestSession("user_screen_name"),
+				},
+				"buddy_1": {
+					sess: newTestSession("buddy_1"),
+				},
+			},
+			buddyMessages: []struct {
+				user string
+				msg  oscar.XMessage
+			}{
+				{
+					user: "buddy_1",
+					msg: oscar.XMessage{
+						SnacFrame: oscar.SnacFrame{
+							FoodGroup: oscar.BUDDY,
+							SubGroup:  oscar.BuddyDeparted,
+						},
+						SnacOut: oscar.SNAC_0x03_0x0B_BuddyDeparted{
+							TLVUserInfo: oscar.TLVUserInfo{
+								ScreenName:   "user_screen_name",
+								WarningLevel: 0,
+							},
+						},
+					},
+				},
+				{
+					user: "user_screen_name",
+					msg: oscar.XMessage{
+						SnacFrame: oscar.SnacFrame{
+							FoodGroup: oscar.BUDDY,
+							SubGroup:  oscar.BuddyDeparted,
+						},
+						SnacOut: oscar.SNAC_0x03_0x0B_BuddyDeparted{
+							TLVUserInfo: oscar.TLVUserInfo{
+								ScreenName:   "buddy_1",
+								WarningLevel: 0,
+							},
+						},
+					},
+				},
+			},
+			clientResponse: oscar.XMessage{
+				SnacFrame: oscar.SnacFrame{
+					FoodGroup: oscar.FEEDBAG,
+					SubGroup:  oscar.FeedbagStatus,
+				},
+				SnacOut: oscar.SNAC_0x13_0x0E_FeedbagStatus{
+					Results: []uint16{0x0000},
+				},
+			},
+		},
+		{
+			name:        "user blocks buddy currently offline, expect OK response and a superfluous buddy departed events",
+			userSession: newTestSession("user_screen_name"),
+			inputSNAC: oscar.SNAC_0x13_0x08_FeedbagInsertItem{
+				Items: []oscar.FeedbagItem{
+					{
+						ClassID: 3,
+						Name:    "buddy_1",
+					},
+				},
+			},
+			screenNameLookups: map[string]struct {
+				sess *server.Session
+				err  error
+			}{
+				"user_screen_name": {
+					sess: newTestSession("user_screen_name"),
+				},
+				"buddy_1": {
+					sess: nil,
+				},
+			},
+			clientResponse: oscar.XMessage{
+				SnacFrame: oscar.SnacFrame{
+					FoodGroup: oscar.FEEDBAG,
+					SubGroup:  oscar.FeedbagStatus,
+				},
+				SnacOut: oscar.SNAC_0x13_0x0E_FeedbagStatus{
+					Results: []uint16{0x0000},
+				},
+			},
+			buddyMessages: []struct {
+				user string
+				msg  oscar.XMessage
+			}{
+				{
+					user: "buddy_1",
+					msg: oscar.XMessage{
+						SnacFrame: oscar.SnacFrame{
+							FoodGroup: oscar.BUDDY,
+							SubGroup:  oscar.BuddyDeparted,
+						},
+						SnacOut: oscar.SNAC_0x03_0x0B_BuddyDeparted{
+							TLVUserInfo: oscar.TLVUserInfo{
+								ScreenName:   "user_screen_name",
+								WarningLevel: 0,
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name:        "user tries to block themselves, expect feedback error",
+			userSession: newTestSession("user_screen_name"),
+			inputSNAC: oscar.SNAC_0x13_0x08_FeedbagInsertItem{
+				Items: []oscar.FeedbagItem{
+					{
+						ClassID: 3,
+						Name:    "user_screen_name",
+					},
+				},
+			},
+			clientResponse: oscar.XMessage{
+				SnacFrame: oscar.SnacFrame{
+					FoodGroup: oscar.FEEDBAG,
+					SubGroup:  oscar.FeedbagErr,
+				},
+				SnacOut: oscar.SnacError{
+					Code: oscar.ErrorCodeNotSupportedByHost,
+				},
+			},
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			//
+			// initialize dependencies
+			//
+			fm := server.NewMockFeedbagManager(t)
+			fm.EXPECT().
+				Upsert(tc.userSession.ScreenName(), tc.inputSNAC.Items).
+				Return(nil).
+				Maybe()
+			fm.EXPECT().
+				Buddies(tc.userSession.ScreenName()).
+				Return([]string{}, nil).
+				Maybe()
+			sm := server.NewMockSessionManager(t)
+			for screenName, val := range tc.screenNameLookups {
+				sm.EXPECT().
+					RetrieveByScreenName(screenName).
+					Return(val.sess).
+					Maybe()
+			}
+			for _, n := range tc.buddyMessages {
+				sm.EXPECT().
+					SendToScreenName(mock.Anything, n.user, n.msg).
+					Maybe()
+			}
+			//
+			// send input SNAC
+			//
+			svc := FeedbagService{
+				fm: fm,
+				sm: sm,
+			}
+			output, err := svc.InsertItemHandler(nil, tc.userSession, tc.inputSNAC)
+			assert.NoError(t, err)
+			//
+			// verify response
+			//
+			assert.Equal(t, output, tc.clientResponse)
+		})
+	}
+}

+ 227 - 0
handler/icbm.go

@@ -0,0 +1,227 @@
+package handler
+
+import (
+	"context"
+	"github.com/mkaminski/goaim/oscar"
+	"github.com/mkaminski/goaim/server"
+)
+
+const (
+	evilDelta     = uint16(100)
+	evilDeltaAnon = uint16(30)
+)
+
+func NewICBMService(sm server.SessionManager, fm server.FeedbagManager) *ICBMService {
+	return &ICBMService{sm: sm, fm: fm}
+}
+
+type ICBMService struct {
+	sm server.SessionManager
+	fm server.FeedbagManager
+}
+
+func (s ICBMService) ParameterQueryHandler(context.Context) oscar.XMessage {
+	return oscar.XMessage{
+		SnacFrame: oscar.SnacFrame{
+			FoodGroup: oscar.ICBM,
+			SubGroup:  oscar.ICBMParameterReply,
+		},
+		SnacOut: oscar.SNAC_0x04_0x05_ICBMParameterReply{
+			MaxSlots:             100,
+			ICBMFlags:            3,
+			MaxIncomingICBMLen:   512,
+			MaxSourceEvil:        999,
+			MaxDestinationEvil:   999,
+			MinInterICBMInterval: 0,
+		},
+	}
+}
+
+func (s ICBMService) ChannelMsgToHostHandler(ctx context.Context, sess *server.Session, snacPayloadIn oscar.SNAC_0x04_0x06_ICBMChannelMsgToHost) (*oscar.XMessage, error) {
+	blocked, err := s.fm.Blocked(sess.ScreenName(), snacPayloadIn.ScreenName)
+	if err != nil {
+		return nil, err
+	}
+
+	if blocked != server.BlockedNo {
+		code := oscar.ErrorCodeNotLoggedOn
+		if blocked == server.BlockedA {
+			code = oscar.ErrorCodeInLocalPermitDeny
+		}
+		return &oscar.XMessage{
+			SnacFrame: oscar.SnacFrame{
+				FoodGroup: oscar.ICBM,
+				SubGroup:  oscar.ICBMErr,
+			},
+			SnacOut: oscar.SnacError{
+				Code: code,
+			},
+		}, nil
+	}
+
+	recipSess := s.sm.RetrieveByScreenName(snacPayloadIn.ScreenName)
+	if recipSess == nil {
+		return &oscar.XMessage{
+			SnacFrame: oscar.SnacFrame{
+				FoodGroup: oscar.ICBM,
+				SubGroup:  oscar.ICBMErr,
+			},
+			SnacOut: oscar.SnacError{
+				Code: oscar.ErrorCodeNotLoggedOn,
+			},
+		}, nil
+	}
+
+	clientIM := oscar.SNAC_0x04_0x07_ICBMChannelMsgToClient{
+		Cookie:    snacPayloadIn.Cookie,
+		ChannelID: snacPayloadIn.ChannelID,
+		TLVUserInfo: oscar.TLVUserInfo{
+			ScreenName:   sess.ScreenName(),
+			WarningLevel: sess.Warning(),
+		},
+		TLVRestBlock: oscar.TLVRestBlock{
+			TLVList: oscar.TLVList{
+				{
+					TType: 0x0B,
+					Val:   []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.AddTLVList(snacPayloadIn.TLVRestBlock.TLVList)
+
+	s.sm.SendToScreenName(ctx, recipSess.ScreenName(), oscar.XMessage{
+		SnacFrame: oscar.SnacFrame{
+			FoodGroup: oscar.ICBM,
+			SubGroup:  oscar.ICBMChannelMsgToclient,
+		},
+		SnacOut: clientIM,
+	})
+
+	if _, requestedConfirmation := snacPayloadIn.TLVRestBlock.GetSlice(oscar.ICBMTLVTagRequestHostAck); !requestedConfirmation {
+		// don't ack message
+		return nil, nil
+	}
+
+	// ack message back to sender
+	return &oscar.XMessage{
+		SnacFrame: oscar.SnacFrame{
+			FoodGroup: oscar.ICBM,
+			SubGroup:  oscar.ICBMHostAck,
+		},
+		SnacOut: oscar.SNAC_0x04_0x0C_ICBMHostAck{
+			Cookie:     snacPayloadIn.Cookie,
+			ChannelID:  snacPayloadIn.ChannelID,
+			ScreenName: snacPayloadIn.ScreenName,
+		},
+	}, nil
+}
+
+func (s ICBMService) ClientEventHandler(ctx context.Context, sess *server.Session, snacPayloadIn oscar.SNAC_0x04_0x14_ICBMClientEvent) error {
+	blocked, err := s.fm.Blocked(sess.ScreenName(), snacPayloadIn.ScreenName)
+
+	switch {
+	case err != nil:
+		return err
+	case blocked != server.BlockedNo:
+		return nil
+	default:
+		s.sm.SendToScreenName(ctx, snacPayloadIn.ScreenName, oscar.XMessage{
+			SnacFrame: oscar.SnacFrame{
+				FoodGroup: oscar.ICBM,
+				SubGroup:  oscar.ICBMClientEvent,
+			},
+			SnacOut: oscar.SNAC_0x04_0x14_ICBMClientEvent{
+				Cookie:     snacPayloadIn.Cookie,
+				ChannelID:  snacPayloadIn.ChannelID,
+				ScreenName: sess.ScreenName(),
+				Event:      snacPayloadIn.Event,
+			},
+		})
+		return nil
+	}
+}
+
+func (s ICBMService) EvilRequestHandler(ctx context.Context, sess *server.Session, snacPayloadIn oscar.SNAC_0x04_0x08_ICBMEvilRequest) (oscar.XMessage, error) {
+	// don't let users warn themselves, it causes the AIM client to go into a
+	// weird state.
+	if snacPayloadIn.ScreenName == sess.ScreenName() {
+		return oscar.XMessage{
+			SnacFrame: oscar.SnacFrame{
+				FoodGroup: oscar.ICBM,
+				SubGroup:  oscar.ICBMErr,
+			},
+			SnacOut: oscar.SnacError{
+				Code: oscar.ErrorCodeNotSupportedByHost,
+			},
+		}, nil
+	}
+
+	blocked, err := s.fm.Blocked(sess.ScreenName(), snacPayloadIn.ScreenName)
+	if err != nil {
+		return oscar.XMessage{}, nil
+	}
+	if blocked != server.BlockedNo {
+		return oscar.XMessage{
+			SnacFrame: oscar.SnacFrame{
+				FoodGroup: oscar.ICBM,
+				SubGroup:  oscar.ICBMErr,
+			},
+			SnacOut: oscar.SnacError{
+				Code: oscar.ErrorCodeNotLoggedOn,
+			},
+		}, nil
+	}
+
+	recipSess := s.sm.RetrieveByScreenName(snacPayloadIn.ScreenName)
+	if recipSess == nil {
+		return oscar.XMessage{}, nil
+	}
+
+	increase := evilDelta
+	if snacPayloadIn.SendAs == 1 {
+		increase = evilDeltaAnon
+	}
+	recipSess.IncreaseWarning(increase)
+
+	var notif any
+	if snacPayloadIn.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.sm.SendToScreenName(ctx, recipSess.ScreenName(), oscar.XMessage{
+		SnacFrame: oscar.SnacFrame{
+			FoodGroup: oscar.OSERVICE,
+			SubGroup:  oscar.OServiceEvilNotification,
+		},
+		SnacOut: notif,
+	})
+
+	if err := broadcastArrival(ctx, recipSess, s.sm, s.fm); err != nil {
+		return oscar.XMessage{}, nil
+	}
+
+	return oscar.XMessage{
+		SnacFrame: oscar.SnacFrame{
+			FoodGroup: oscar.ICBM,
+			SubGroup:  oscar.ICBMEvilReply,
+		},
+		SnacOut: oscar.SNAC_0x04_0x09_ICBMEvilReply{
+			EvilDeltaApplied: increase,
+			UpdatedEvilValue: recipSess.Warning(),
+		},
+	}, nil
+}

+ 524 - 0
handler/icbm_test.go

@@ -0,0 +1,524 @@
+package handler
+
+import (
+	"github.com/mkaminski/goaim/oscar"
+	"github.com/mkaminski/goaim/server"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/mock"
+	"testing"
+)
+
+func TestSendAndReceiveChannelMsgTohost(t *testing.T) {
+	cases := []struct {
+		// name is the unit test name
+		name string
+		// blockedState is the response to the sender/recipient block check
+		blockedState server.BlockedState
+		// recipRetrieveErr is the error returned by the recipient session
+		// lookup
+		recipRetrieveErr error
+		senderSession    *server.Session
+		recipientSession *server.Session
+		// inputSNAC is the SNAC sent by the sender client
+		inputSNAC oscar.SNAC_0x04_0x06_ICBMChannelMsgToHost
+		// expectSNACToClient is the SNAC sent from the server to the
+		// recipient client
+		expectSNACToClient oscar.XMessage
+		// inputSNAC is the SNAC frame sent from the server to the recipient
+		// client
+		expectOutput *oscar.XMessage
+	}{
+		{
+			name:             "transmit message from sender to recipient, ack message back to sender",
+			blockedState:     server.BlockedNo,
+			senderSession:    newTestSession("sender-screen-name", sessOptWarning(10)),
+			recipientSession: newTestSession("recipient-screen-name", sessOptWarning(20)),
+			inputSNAC: oscar.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+				ScreenName: "recipient-screen-name",
+				TLVRestBlock: oscar.TLVRestBlock{
+					TLVList: oscar.TLVList{
+						{
+							TType: oscar.ICBMTLVTagRequestHostAck,
+							Val:   []byte{},
+						},
+					},
+				},
+			},
+			expectSNACToClient: oscar.XMessage{
+				SnacFrame: oscar.SnacFrame{
+					FoodGroup: oscar.ICBM,
+					SubGroup:  oscar.ICBMChannelMsgToclient,
+				},
+				SnacOut: oscar.SNAC_0x04_0x07_ICBMChannelMsgToClient{
+					TLVUserInfo: oscar.TLVUserInfo{
+						ScreenName:   "sender-screen-name",
+						WarningLevel: 10,
+					},
+					TLVRestBlock: oscar.TLVRestBlock{
+						TLVList: oscar.TLVList{
+							{
+								TType: oscar.ICBMTLVTagsWantEvents,
+								Val:   []byte{},
+							},
+							{
+								TType: oscar.ICBMTLVTagRequestHostAck,
+								Val:   []byte{},
+							},
+						},
+					},
+				},
+			},
+			expectOutput: &oscar.XMessage{
+				SnacFrame: oscar.SnacFrame{
+					FoodGroup: oscar.ICBM,
+					SubGroup:  oscar.ICBMHostAck,
+				},
+				SnacOut: oscar.SNAC_0x04_0x0C_ICBMHostAck{
+					ScreenName: "recipient-screen-name",
+				},
+			},
+		},
+		{
+			name:             "transmit message from sender to recipient, don't ack message back to sender",
+			blockedState:     server.BlockedNo,
+			senderSession:    newTestSession("sender-screen-name", sessOptWarning(10)),
+			recipientSession: newTestSession("recipient-screen-name", sessOptWarning(20)),
+			inputSNAC: oscar.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+				ScreenName: "recipient-screen-name",
+				TLVRestBlock: oscar.TLVRestBlock{
+					TLVList: oscar.TLVList{},
+				},
+			},
+			expectSNACToClient: oscar.XMessage{
+				SnacFrame: oscar.SnacFrame{
+					FoodGroup: oscar.ICBM,
+					SubGroup:  oscar.ICBMChannelMsgToclient,
+				},
+				SnacOut: oscar.SNAC_0x04_0x07_ICBMChannelMsgToClient{
+					TLVUserInfo: oscar.TLVUserInfo{
+						ScreenName:   "sender-screen-name",
+						WarningLevel: 10,
+					},
+					TLVRestBlock: oscar.TLVRestBlock{
+						TLVList: oscar.TLVList{
+							{
+								TType: oscar.ICBMTLVTagsWantEvents,
+								Val:   []byte{},
+							},
+						},
+					},
+				},
+			},
+			expectOutput: nil,
+		},
+		{
+			name:             "don't transmit message from sender to recipient because sender has blocked recipient",
+			blockedState:     server.BlockedA,
+			senderSession:    newTestSession("sender-screen-name", sessOptWarning(10)),
+			recipientSession: newTestSession("recipient-screen-name", sessOptWarning(20)),
+			inputSNAC: oscar.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+				ScreenName: "recipient-screen-name",
+				TLVRestBlock: oscar.TLVRestBlock{
+					TLVList: oscar.TLVList{
+						{
+							TType: oscar.ICBMTLVTagRequestHostAck,
+							Val:   []byte{},
+						},
+					},
+				},
+			},
+			expectOutput: &oscar.XMessage{
+				SnacFrame: oscar.SnacFrame{
+					FoodGroup: oscar.ICBM,
+					SubGroup:  oscar.ICBMErr,
+				},
+				SnacOut: oscar.SnacError{
+					Code: oscar.ErrorCodeInLocalPermitDeny,
+				},
+			},
+		},
+		{
+			name:             "don't transmit message from sender to recipient because recipient has blocked sender",
+			blockedState:     server.BlockedB,
+			senderSession:    newTestSession("sender-screen-name", sessOptWarning(10)),
+			recipientSession: newTestSession("recipient-screen-name", sessOptWarning(20)),
+			inputSNAC: oscar.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+				ScreenName: "recipient-screen-name",
+				TLVRestBlock: oscar.TLVRestBlock{
+					TLVList: oscar.TLVList{
+						{
+							TType: oscar.ICBMTLVTagRequestHostAck,
+							Val:   []byte{},
+						},
+					},
+				},
+			},
+			expectOutput: &oscar.XMessage{
+				SnacFrame: oscar.SnacFrame{
+					FoodGroup: oscar.ICBM,
+					SubGroup:  oscar.ICBMErr,
+				},
+				SnacOut: oscar.SnacError{
+					Code: oscar.ErrorCodeNotLoggedOn,
+				},
+			},
+		},
+		{
+			name:             "don't transmit message from sender to recipient because recipient doesn't exist",
+			blockedState:     server.BlockedNo,
+			senderSession:    newTestSession("sender-screen-name", sessOptWarning(10)),
+			recipientSession: nil,
+			inputSNAC: oscar.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+				ScreenName: "recipient-screen-name",
+				TLVRestBlock: oscar.TLVRestBlock{
+					TLVList: oscar.TLVList{
+						{
+							TType: oscar.ICBMTLVTagRequestHostAck,
+							Val:   []byte{},
+						},
+					},
+				},
+			},
+			expectOutput: &oscar.XMessage{
+				SnacFrame: oscar.SnacFrame{
+					FoodGroup: oscar.ICBM,
+					SubGroup:  oscar.ICBMErr,
+				},
+				SnacOut: oscar.SnacError{
+					Code: oscar.ErrorCodeNotLoggedOn,
+				},
+			},
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			//
+			// initialize dependencies
+			//
+			fm := server.NewMockFeedbagManager(t)
+			fm.EXPECT().
+				Blocked(tc.senderSession.ScreenName(), tc.inputSNAC.ScreenName).
+				Return(tc.blockedState, nil).
+				Maybe()
+			sm := server.NewMockSessionManager(t)
+			sm.EXPECT().
+				RetrieveByScreenName(tc.inputSNAC.ScreenName).
+				Return(tc.recipientSession).
+				Maybe()
+			if tc.recipientSession != nil {
+				sm.EXPECT().
+					SendToScreenName(mock.Anything, tc.recipientSession.ScreenName(), tc.expectSNACToClient).
+					Maybe()
+			}
+			//
+			// send input SNAC
+			//
+			svc := ICBMService{
+				sm: sm,
+				fm: fm,
+			}
+			outputSNAC, err := svc.ChannelMsgToHostHandler(nil, tc.senderSession, tc.inputSNAC)
+			assert.NoError(t, err)
+			//
+			// verify output
+			//
+			assert.Equal(t, tc.expectOutput, outputSNAC)
+		})
+	}
+}
+
+func TestSendAndReceiveClientEvent(t *testing.T) {
+	cases := []struct {
+		// name is the unit test name
+		name string
+		// blockedState is the response to the sender/recipient block check
+		blockedState server.BlockedState
+		// senderScreenName is the screen name of the user sending the event
+		senderScreenName string
+		// inputSNAC is the SNAC sent by the sender client
+		inputSNAC oscar.SNAC_0x04_0x14_ICBMClientEvent
+		// expectSNACToClient is the SNAC sent from the server to the
+		// recipient client
+		expectSNACToClient oscar.XMessage
+	}{
+		{
+			name:             "transmit message from sender to recipient",
+			blockedState:     server.BlockedNo,
+			senderScreenName: "sender-screen-name",
+			inputSNAC: oscar.SNAC_0x04_0x14_ICBMClientEvent{
+				Cookie:     [8]byte{1, 2, 3, 4, 5, 6, 7, 8},
+				ChannelID:  42,
+				ScreenName: "recipient-screen-name",
+				Event:      12,
+			},
+			expectSNACToClient: oscar.XMessage{
+				SnacFrame: oscar.SnacFrame{
+					FoodGroup: oscar.ICBM,
+					SubGroup:  oscar.ICBMClientEvent,
+				},
+				SnacOut: oscar.SNAC_0x04_0x14_ICBMClientEvent{
+					Cookie:     [8]byte{1, 2, 3, 4, 5, 6, 7, 8},
+					ChannelID:  42,
+					ScreenName: "sender-screen-name",
+					Event:      12,
+				},
+			},
+		},
+		{
+			name:             "don't transmit message from sender to recipient because sender has blocked recipient",
+			blockedState:     server.BlockedA,
+			senderScreenName: "sender-screen-name",
+			inputSNAC: oscar.SNAC_0x04_0x14_ICBMClientEvent{
+				ScreenName: "recipient-screen-name",
+			},
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			//
+			// initialize dependencies
+			//
+			fm := server.NewMockFeedbagManager(t)
+			fm.EXPECT().
+				Blocked(tc.senderScreenName, tc.inputSNAC.ScreenName).
+				Return(tc.blockedState, nil).
+				Maybe()
+			sm := server.NewMockSessionManager(t)
+			if tc.blockedState == server.BlockedNo {
+				sm.EXPECT().
+					SendToScreenName(mock.Anything, tc.inputSNAC.ScreenName, tc.expectSNACToClient)
+			}
+			//
+			// send input SNAC
+			//
+			senderSession := newTestSession(tc.senderScreenName)
+			svc := ICBMService{
+				sm: sm,
+				fm: fm,
+			}
+			assert.NoError(t, svc.ClientEventHandler(nil, senderSession, tc.inputSNAC))
+		})
+	}
+}
+
+func TestSendAndReceiveEvilRequest(t *testing.T) {
+	cases := []struct {
+		// name is the unit test name
+		name string
+		// blockedState is the response to the sender/recipient block check
+		blockedState server.BlockedState
+		// recipRetrieveErr is the error returned by the recipient session
+		// lookup
+		recipRetrieveErr error
+		// senderScreenName is the session name of the user sending the IM
+		senderSession *server.Session
+		// 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.XMessage
+		// inputSNAC is the SNAC sent by the sender client
+		inputSNAC oscar.SNAC_0x04_0x08_ICBMEvilRequest
+		// expectSNACToClient is the SNAC sent from the server to the
+		// recipient client
+		expectSNACToClient oscar.XMessage
+
+		expectOutput oscar.XMessage
+	}{
+		{
+			name:                "transmit anonymous warning from sender to recipient",
+			blockedState:        server.BlockedNo,
+			senderSession:       newTestSession("sender-screen-name"),
+			recipientScreenName: "recipient-screen-name",
+			broadcastMessage: oscar.XMessage{
+				SnacFrame: oscar.SnacFrame{
+					FoodGroup: oscar.BUDDY,
+					SubGroup:  oscar.BuddyArrived,
+				},
+				SnacOut: oscar.SNAC_0x03_0x0A_BuddyArrived{
+					TLVUserInfo: oscar.TLVUserInfo{
+						ScreenName:   "recipient-screen-name",
+						WarningLevel: evilDeltaAnon,
+						TLVBlock: oscar.TLVBlock{
+							TLVList: newTestSession("", sessOptCannedSignonTime).UserInfo(),
+						},
+					},
+				},
+			},
+			recipientBuddies: []string{"buddy1", "buddy2"},
+			inputSNAC: oscar.SNAC_0x04_0x08_ICBMEvilRequest{
+				SendAs:     1, // make it anonymous
+				ScreenName: "recipient-screen-name",
+			},
+			expectSNACToClient: oscar.XMessage{
+				SnacFrame: oscar.SnacFrame{
+					FoodGroup: oscar.OSERVICE,
+					SubGroup:  oscar.OServiceEvilNotification,
+				},
+				SnacOut: oscar.SNAC_0x01_0x10_OServiceEvilNotificationAnon{
+					NewEvil: evilDeltaAnon,
+				},
+			},
+			expectOutput: oscar.XMessage{
+				SnacFrame: oscar.SnacFrame{
+					FoodGroup: oscar.ICBM,
+					SubGroup:  oscar.ICBMEvilReply,
+				},
+				SnacOut: oscar.SNAC_0x04_0x09_ICBMEvilReply{
+					EvilDeltaApplied: 30,
+					UpdatedEvilValue: 30,
+				},
+			},
+		},
+		{
+			name:                "transmit non-anonymous warning from sender to recipient",
+			blockedState:        server.BlockedNo,
+			senderSession:       newTestSession("sender-screen-name"),
+			recipientScreenName: "recipient-screen-name",
+			recipientBuddies:    []string{"buddy1", "buddy2"},
+			broadcastMessage: oscar.XMessage{
+				SnacFrame: oscar.SnacFrame{
+					FoodGroup: oscar.BUDDY,
+					SubGroup:  oscar.BuddyArrived,
+				},
+				SnacOut: oscar.SNAC_0x03_0x0A_BuddyArrived{
+					TLVUserInfo: oscar.TLVUserInfo{
+						ScreenName:   "recipient-screen-name",
+						WarningLevel: evilDelta,
+						TLVBlock: oscar.TLVBlock{
+							TLVList: newTestSession("", sessOptCannedSignonTime).UserInfo(),
+						},
+					},
+				},
+			},
+			inputSNAC: oscar.SNAC_0x04_0x08_ICBMEvilRequest{
+				SendAs:     0, // make it identified
+				ScreenName: "recipient-screen-name",
+			},
+			expectSNACToClient: oscar.XMessage{
+				SnacFrame: oscar.SnacFrame{
+					FoodGroup: oscar.OSERVICE,
+					SubGroup:  oscar.OServiceEvilNotification,
+				},
+				SnacOut: oscar.SNAC_0x01_0x10_OServiceEvilNotification{
+					NewEvil: evilDelta,
+					TLVUserInfo: oscar.TLVUserInfo{
+						ScreenName:   "sender-screen-name",
+						WarningLevel: 100,
+					},
+				},
+			},
+			expectOutput: oscar.XMessage{
+				SnacFrame: oscar.SnacFrame{
+					FoodGroup: oscar.ICBM,
+					SubGroup:  oscar.ICBMEvilReply,
+				},
+				SnacOut: oscar.SNAC_0x04_0x09_ICBMEvilReply{
+					EvilDeltaApplied: 100,
+					UpdatedEvilValue: 100,
+				},
+			},
+		},
+		{
+			name:                "don't transmit non-anonymous warning from sender to recipient because sender has blocked recipient",
+			blockedState:        server.BlockedA,
+			senderSession:       newTestSession("sender-screen-name"),
+			recipientScreenName: "recipient-screen-name",
+			recipientBuddies:    []string{"buddy1", "buddy2"},
+			inputSNAC: oscar.SNAC_0x04_0x08_ICBMEvilRequest{
+				SendAs:     0, // make it identified
+				ScreenName: "recipient-screen-name",
+			},
+			expectOutput: oscar.XMessage{
+				SnacFrame: oscar.SnacFrame{
+					FoodGroup: oscar.ICBM,
+					SubGroup:  oscar.ICBMErr,
+				},
+				SnacOut: oscar.SnacError{
+					Code: oscar.ErrorCodeNotLoggedOn,
+				},
+			},
+		},
+		{
+			name:                "don't transmit non-anonymous warning from sender to recipient because recipient has blocked sender",
+			blockedState:        server.BlockedB,
+			senderSession:       newTestSession("sender-screen-name"),
+			recipientScreenName: "recipient-screen-name",
+			recipientBuddies:    []string{"buddy1", "buddy2"},
+			inputSNAC: oscar.SNAC_0x04_0x08_ICBMEvilRequest{
+				SendAs:     0, // make it identified
+				ScreenName: "recipient-screen-name",
+			},
+			expectOutput: oscar.XMessage{
+				SnacFrame: oscar.SnacFrame{
+					FoodGroup: oscar.ICBM,
+					SubGroup:  oscar.ICBMErr,
+				},
+				SnacOut: oscar.SnacError{
+					Code: oscar.ErrorCodeNotLoggedOn,
+				},
+			},
+		},
+		{
+			name:                "don't let users block themselves",
+			senderSession:       newTestSession("sender-screen-name"),
+			recipientScreenName: "sender-screen-name",
+			inputSNAC: oscar.SNAC_0x04_0x08_ICBMEvilRequest{
+				SendAs:     0, // make it identified
+				ScreenName: "sender-screen-name",
+			},
+			expectOutput: oscar.XMessage{
+				SnacFrame: oscar.SnacFrame{
+					FoodGroup: oscar.ICBM,
+					SubGroup:  oscar.ICBMErr,
+				},
+				SnacOut: oscar.SnacError{
+					Code: oscar.ErrorCodeNotSupportedByHost,
+				},
+			},
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			//
+			// initialize dependencies
+			//
+			fm := server.NewMockFeedbagManager(t)
+			fm.EXPECT().
+				Blocked(tc.senderSession.ScreenName(), tc.recipientScreenName).
+				Return(tc.blockedState, nil).
+				Maybe()
+			fm.EXPECT().
+				InterestedUsers(tc.recipientScreenName).
+				Return(tc.recipientBuddies, nil).
+				Maybe()
+			recipSess := newTestSession(tc.recipientScreenName, sessOptCannedSignonTime)
+			sm := server.NewMockSessionManager(t)
+			sm.EXPECT().
+				RetrieveByScreenName(tc.recipientScreenName).
+				Return(recipSess).
+				Maybe()
+			sm.EXPECT().
+				SendToScreenName(mock.Anything, tc.recipientScreenName, tc.expectSNACToClient).
+				Maybe()
+			sm.EXPECT().
+				BroadcastToScreenNames(mock.Anything, tc.recipientBuddies, tc.broadcastMessage).
+				Maybe()
+			//
+			// send input SNAC
+			//
+			senderSession := newTestSession(tc.senderSession.ScreenName())
+			svc := ICBMService{
+				sm: sm,
+				fm: fm,
+			}
+			outputSNAC, err := svc.EvilRequestHandler(nil, senderSession, tc.inputSNAC)
+			assert.NoError(t, err)
+			assert.Equal(t, tc.expectOutput, outputSNAC)
+		})
+	}
+}

+ 147 - 0
handler/locate.go

@@ -0,0 +1,147 @@
+package handler
+
+import (
+	"context"
+	"github.com/mkaminski/goaim/oscar"
+	"github.com/mkaminski/goaim/server"
+)
+
+func NewLocateService(sm server.SessionManager, fm server.FeedbagManager, pm server.ProfileManager) LocateService {
+	return LocateService{
+		sm: sm,
+		fm: fm,
+		pm: pm,
+	}
+}
+
+type LocateService struct {
+	sm server.SessionManager
+	fm server.FeedbagManager
+	pm server.ProfileManager
+}
+
+func (s LocateService) RightsQueryHandler(context.Context) oscar.XMessage {
+	return oscar.XMessage{
+		SnacFrame: oscar.SnacFrame{
+			FoodGroup: oscar.LOCATE,
+			SubGroup:  oscar.LocateRightsReply,
+		},
+		SnacOut: oscar.SNAC_0x02_0x03_LocateRightsReply{
+			TLVRestBlock: oscar.TLVRestBlock{
+				TLVList: oscar.TLVList{
+					oscar.NewTLV(0x01, uint16(1000)),
+					oscar.NewTLV(0x02, uint16(1000)),
+					oscar.NewTLV(0x03, uint16(1000)),
+					oscar.NewTLV(0x04, uint16(1000)),
+					oscar.NewTLV(0x05, uint16(1000)),
+				},
+			},
+		},
+	}
+}
+
+func (s LocateService) SetInfoHandler(ctx context.Context, sess *server.Session, snacPayloadIn oscar.SNAC_0x02_0x04_LocateSetInfo) error {
+	// update profile
+	if profile, hasProfile := snacPayloadIn.GetString(oscar.LocateTLVTagsInfoSigData); hasProfile {
+		if err := s.pm.UpsertProfile(sess.ScreenName(), profile); err != nil {
+			return err
+		}
+	}
+
+	// broadcast away message change to buddies
+	if awayMsg, hasAwayMsg := snacPayloadIn.GetString(oscar.LocateTLVTagsInfoUnavailableData); hasAwayMsg {
+		sess.SetAwayMessage(awayMsg)
+		if err := broadcastArrival(ctx, sess, s.sm, s.fm); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func (s LocateService) UserInfoQuery2Handler(_ context.Context, sess *server.Session, snacPayloadIn oscar.SNAC_0x02_0x15_LocateUserInfoQuery2) (oscar.XMessage, error) {
+	blocked, err := s.fm.Blocked(sess.ScreenName(), snacPayloadIn.ScreenName)
+	switch {
+	case err != nil:
+		return oscar.XMessage{}, err
+	case blocked != server.BlockedNo:
+		return oscar.XMessage{
+			SnacFrame: oscar.SnacFrame{
+				FoodGroup: oscar.LOCATE,
+				SubGroup:  oscar.LocateErr,
+			},
+			SnacOut: oscar.SnacError{
+				Code: oscar.ErrorCodeNotLoggedOn,
+			},
+		}, nil
+	}
+
+	buddySess := s.sm.RetrieveByScreenName(snacPayloadIn.ScreenName)
+	if buddySess == nil {
+		return oscar.XMessage{
+			SnacFrame: oscar.SnacFrame{
+				FoodGroup: oscar.LOCATE,
+				SubGroup:  oscar.LocateErr,
+			},
+			SnacOut: oscar.SnacError{
+				Code: oscar.ErrorCodeNotLoggedOn,
+			},
+		}, nil
+	}
+
+	var list oscar.TLVList
+
+	if snacPayloadIn.RequestProfile() {
+		profile, err := s.pm.RetrieveProfile(snacPayloadIn.ScreenName)
+		if err != nil {
+			return oscar.XMessage{}, err
+		}
+		list.AddTLVList([]oscar.TLV{
+			oscar.NewTLV(oscar.LocateTLVTagsInfoSigMime, `text/aolrtf; charset="us-ascii"`),
+			oscar.NewTLV(oscar.LocateTLVTagsInfoSigData, profile),
+		})
+	}
+
+	if snacPayloadIn.RequestAwayMessage() {
+		list.AddTLVList([]oscar.TLV{
+			oscar.NewTLV(oscar.LocateTLVTagsInfoUnavailableMime, `text/aolrtf; charset="us-ascii"`),
+			oscar.NewTLV(oscar.LocateTLVTagsInfoUnavailableData, buddySess.AwayMessage()),
+		})
+	}
+
+	return oscar.XMessage{
+		SnacFrame: oscar.SnacFrame{
+			FoodGroup: oscar.LOCATE,
+			SubGroup:  oscar.LocateUserInfoReply,
+		},
+		SnacOut: oscar.SNAC_0x02_0x06_LocateUserInfoReply{
+			TLVUserInfo: buddySess.TLVUserInfo(),
+			LocateInfo: oscar.TLVRestBlock{
+				TLVList: list,
+			},
+		},
+	}, nil
+}
+
+func (s LocateService) SetDirInfoHandler(_ context.Context) oscar.XMessage {
+	return oscar.XMessage{
+		SnacFrame: oscar.SnacFrame{
+			FoodGroup: oscar.LOCATE,
+			SubGroup:  oscar.LocateSetDirReply,
+		},
+		SnacOut: oscar.SNAC_0x02_0x0A_LocateSetDirReply{
+			Result: 1,
+		},
+	}
+}
+
+func (s LocateService) SetKeywordInfoHandler(_ context.Context) oscar.XMessage {
+	return oscar.XMessage{
+		SnacFrame: oscar.SnacFrame{
+			FoodGroup: oscar.LOCATE,
+			SubGroup:  oscar.LocateSetKeywordReply,
+		},
+		SnacOut: oscar.SNAC_0x02_0x10_LocateSetKeywordReply{
+			Unknown: 1,
+		},
+	}
+}

+ 271 - 0
handler/locate_test.go

@@ -0,0 +1,271 @@
+package handler
+
+import (
+	"context"
+	"github.com/mkaminski/goaim/oscar"
+	"github.com/mkaminski/goaim/server"
+	"github.com/stretchr/testify/assert"
+	"testing"
+)
+
+func TestSendAndReceiveUserInfoQuery2(t *testing.T) {
+	cases := []struct {
+		// name is the unit test name
+		name string
+		// blockedState is the response to the sender/recipient block check
+		blockedState server.BlockedState
+		// screenNameLookups is the list of user session lookups
+		screenNameLookups map[string]struct {
+			sess *server.Session
+			err  error
+		}
+		// screenNameLookups is the list of user session lookups
+		profileLookups map[string]struct {
+			payload string
+			err     error
+		}
+		// userSession is the session of the user requesting the user info
+		userSession *server.Session
+		// inputSNAC is the SNAC sent by the sender client
+		inputSNAC    oscar.SNAC_0x02_0x15_LocateUserInfoQuery2
+		expectOutput oscar.XMessage
+	}{
+		{
+			name:         "request user info, expect user info response",
+			blockedState: server.BlockedNo,
+			screenNameLookups: map[string]struct {
+				sess *server.Session
+				err  error
+			}{
+				"requested-user": {
+					sess: newTestSession("requested-user",
+						sessOptCannedSignonTime,
+						sessOptCannedAwayMessage),
+				},
+			},
+			userSession: newTestSession("user_screen_name"),
+			inputSNAC: oscar.SNAC_0x02_0x15_LocateUserInfoQuery2{
+				Type2:      0,
+				ScreenName: "requested-user",
+			},
+			expectOutput: oscar.XMessage{
+				SnacFrame: oscar.SnacFrame{
+					FoodGroup: oscar.LOCATE,
+					SubGroup:  oscar.LocateUserInfoReply,
+				},
+				SnacOut: oscar.SNAC_0x02_0x06_LocateUserInfoReply{
+					TLVUserInfo: newTestSession("requested-user",
+						sessOptCannedSignonTime,
+						sessOptCannedAwayMessage).
+						TLVUserInfo(),
+					LocateInfo: oscar.TLVRestBlock{},
+				},
+			},
+		},
+		{
+			name:         "request user info + profile, expect user info response + profile",
+			blockedState: server.BlockedNo,
+			screenNameLookups: map[string]struct {
+				sess *server.Session
+				err  error
+			}{
+				"requested-user": {
+					sess: newTestSession("requested-user",
+						sessOptCannedSignonTime,
+						sessOptCannedAwayMessage),
+				},
+			},
+			profileLookups: map[string]struct {
+				payload string
+				err     error
+			}{
+				"requested-user": {
+					payload: "this is my profile!",
+				},
+			},
+			userSession: newTestSession("user_screen_name"),
+			inputSNAC: oscar.SNAC_0x02_0x15_LocateUserInfoQuery2{
+				// 2048 is a dummy to make sure bitmask check works
+				Type2:      oscar.LocateType2Sig | 2048,
+				ScreenName: "requested-user",
+			},
+			expectOutput: oscar.XMessage{
+				SnacFrame: oscar.SnacFrame{
+					FoodGroup: oscar.LOCATE,
+					SubGroup:  oscar.LocateUserInfoReply,
+				},
+				SnacOut: oscar.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!"),
+						},
+					},
+				},
+			},
+		},
+		{
+			name:         "request user info + profile, expect user info response + profile",
+			blockedState: server.BlockedNo,
+			screenNameLookups: map[string]struct {
+				sess *server.Session
+				err  error
+			}{
+				"requested-user": {
+					sess: newTestSession("requested-user",
+						sessOptCannedSignonTime,
+						sessOptCannedAwayMessage),
+				},
+			},
+			profileLookups: map[string]struct {
+				payload string
+				err     error
+			}{
+				"requested-user": {
+					payload: "this is my profile!",
+				},
+			},
+			userSession: newTestSession("user_screen_name"),
+			inputSNAC: oscar.SNAC_0x02_0x15_LocateUserInfoQuery2{
+				// 2048 is a dummy to make sure bitmask check works
+				Type2:      oscar.LocateType2Sig | 2048,
+				ScreenName: "requested-user",
+			},
+			expectOutput: oscar.XMessage{
+				SnacFrame: oscar.SnacFrame{
+					FoodGroup: oscar.LOCATE,
+					SubGroup:  oscar.LocateUserInfoReply,
+				},
+				SnacOut: oscar.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!"),
+						},
+					},
+				},
+			},
+		},
+		{
+			name:         "request user info + away message, expect user info response + away message",
+			blockedState: server.BlockedNo,
+			screenNameLookups: map[string]struct {
+				sess *server.Session
+				err  error
+			}{
+				"requested-user": {
+					sess: newTestSession("requested-user",
+						sessOptCannedSignonTime,
+						sessOptCannedAwayMessage),
+				},
+			},
+			userSession: newTestSession("user_screen_name"),
+			inputSNAC: oscar.SNAC_0x02_0x15_LocateUserInfoQuery2{
+				// 2048 is a dummy to make sure bitmask check works
+				Type2:      oscar.LocateType2Unavailable | 2048,
+				ScreenName: "requested-user",
+			},
+			expectOutput: oscar.XMessage{
+				SnacFrame: oscar.SnacFrame{
+					FoodGroup: oscar.LOCATE,
+					SubGroup:  oscar.LocateUserInfoReply,
+				},
+				SnacOut: oscar.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!"),
+						},
+					},
+				},
+			},
+		},
+		{
+			name:         "request user info of user who blocked requester, expect not logged in error",
+			blockedState: server.BlockedB,
+			userSession:  newTestSession("user_screen_name"),
+			inputSNAC: oscar.SNAC_0x02_0x15_LocateUserInfoQuery2{
+				ScreenName: "requested-user",
+			},
+			expectOutput: oscar.XMessage{
+				SnacFrame: oscar.SnacFrame{
+					FoodGroup: oscar.LOCATE,
+					SubGroup:  oscar.LocateErr,
+				},
+				SnacOut: oscar.SnacError{
+					Code: oscar.ErrorCodeNotLoggedOn,
+				},
+			},
+		},
+		{
+			name:         "request user info of user who does not exist, expect not logged in error",
+			blockedState: server.BlockedNo,
+			screenNameLookups: map[string]struct {
+				sess *server.Session
+				err  error
+			}{
+				"non_existent_requested_user": {
+					sess: nil,
+				},
+			},
+			userSession: newTestSession("user_screen_name"),
+			inputSNAC: oscar.SNAC_0x02_0x15_LocateUserInfoQuery2{
+				ScreenName: "non_existent_requested_user",
+			},
+			expectOutput: oscar.XMessage{
+				SnacFrame: oscar.SnacFrame{
+					FoodGroup: oscar.LOCATE,
+					SubGroup:  oscar.LocateErr,
+				},
+				SnacOut: oscar.SnacError{
+					Code: oscar.ErrorCodeNotLoggedOn,
+				},
+			},
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			fm := server.NewMockFeedbagManager(t)
+			fm.EXPECT().
+				Blocked(tc.userSession.ScreenName(), tc.inputSNAC.ScreenName).
+				Return(tc.blockedState, nil).
+				Maybe()
+			sm := server.NewMockSessionManager(t)
+			for screenName, val := range tc.screenNameLookups {
+				sm.EXPECT().
+					RetrieveByScreenName(screenName).
+					Return(val.sess).
+					Maybe()
+			}
+			pm := server.NewMockProfileManager(t)
+			for screenName, val := range tc.profileLookups {
+				pm.EXPECT().
+					RetrieveProfile(screenName).
+					Return(val.payload, val.err).
+					Maybe()
+			}
+
+			svc := LocateService{
+				sm: sm,
+				fm: fm,
+				pm: pm,
+			}
+			outputSNAC, err := svc.UserInfoQuery2Handler(context.Background(), tc.userSession, tc.inputSNAC)
+			assert.NoError(t, err)
+			assert.Equal(t, tc.expectOutput, outputSNAC)
+		})
+	}
+}

+ 271 - 0
handler/oservice.go

@@ -0,0 +1,271 @@
+package handler
+
+import (
+	"bytes"
+	"context"
+	"errors"
+	"fmt"
+	"github.com/mkaminski/goaim/oscar"
+	"github.com/mkaminski/goaim/server"
+	"time"
+)
+
+func NewOServiceService(cfg server.Config, sm server.SessionManager, fm server.FeedbagManager) *OServiceService {
+	return &OServiceService{cfg: cfg, sm: sm, fm: fm}
+}
+
+type OServiceService struct {
+	cfg server.Config
+	fm  server.FeedbagManager
+	sm  server.SessionManager
+}
+
+func (s OServiceService) ClientVersionsHandler(_ context.Context, snacPayloadIn oscar.SNAC_0x01_0x17_OServiceClientVersions) oscar.XMessage {
+	return oscar.XMessage{
+		SnacFrame: oscar.SnacFrame{
+			FoodGroup: oscar.OSERVICE,
+			SubGroup:  oscar.OServiceHostVersions,
+		},
+		SnacOut: oscar.SNAC_0x01_0x18_OServiceHostVersions{
+			Versions: snacPayloadIn.Versions,
+		},
+	}
+}
+
+func (s OServiceService) RateParamsQueryHandler(_ context.Context) oscar.XMessage {
+	snacFrameOut := oscar.SnacFrame{
+		FoodGroup: oscar.OSERVICE,
+		SubGroup:  oscar.OServiceRateParamsReply,
+	}
+	snacPayloadOut := 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 // protocol v2 only
+			CurrentState    uint8  // protocol v2 only
+		}{
+			{
+				ID:              0x0001,
+				WindowSize:      0x00000050,
+				ClearLevel:      0x000009C4,
+				AlertLevel:      0x000007D0,
+				LimitLevel:      0x000005DC,
+				DisconnectLevel: 0x00000320,
+				CurrentLevel:    0x00000D69,
+				MaxLevel:        0x00001770,
+				LastTime:        0x00000000,
+				CurrentState:    0x00,
+			},
+		},
+		RateGroups: []struct {
+			ID    uint16
+			Pairs []struct {
+				FoodGroup uint16
+				SubGroup  uint16
+			} `count_prefix:"uint16"`
+		}{
+			{
+				ID: 1,
+				Pairs: []struct {
+					FoodGroup uint16
+					SubGroup  uint16
+				}{},
+			},
+		},
+	}
+
+	for i := uint16(0); i < 24; i++ { // for each food group
+		for j := uint16(0); j < 32; j++ { // for each subgroup
+			snacPayloadOut.RateGroups[0].Pairs = append(snacPayloadOut.RateGroups[0].Pairs,
+				struct {
+					FoodGroup uint16
+					SubGroup  uint16
+				}{
+					FoodGroup: i,
+					SubGroup:  j,
+				})
+		}
+	}
+
+	return oscar.XMessage{
+		SnacFrame: snacFrameOut,
+		SnacOut:   snacPayloadOut,
+	}
+}
+
+func (s OServiceService) UserInfoQueryHandler(_ context.Context, sess *server.Session) oscar.XMessage {
+	return oscar.XMessage{
+		SnacFrame: oscar.SnacFrame{
+			FoodGroup: oscar.OSERVICE,
+			SubGroup:  oscar.OServiceUserInfoUpdate,
+		},
+		SnacOut: oscar.SNAC_0x01_0x0F_OServiceUserInfoUpdate{
+			TLVUserInfo: sess.TLVUserInfo(),
+		},
+	}
+}
+
+func (s OServiceService) SetUserInfoFieldsHandler(ctx context.Context, sess *server.Session, snacPayloadIn oscar.SNAC_0x01_0x1E_OServiceSetUserInfoFields) (oscar.XMessage, error) {
+	if status, hasStatus := snacPayloadIn.GetUint32(0x06); hasStatus {
+		switch status {
+		case 0x000:
+			sess.SetInvisible(false)
+			if err := broadcastArrival(ctx, sess, s.sm, s.fm); err != nil {
+				return oscar.XMessage{}, err
+			}
+		case 0x100:
+			sess.SetInvisible(true)
+			if err := broadcastDeparture(ctx, sess, s.sm, s.fm); err != nil {
+				return oscar.XMessage{}, err
+			}
+		default:
+			return oscar.XMessage{}, fmt.Errorf("don't know what to do with status %d", status)
+		}
+	}
+	return oscar.XMessage{
+		SnacFrame: oscar.SnacFrame{
+			FoodGroup: oscar.OSERVICE,
+			SubGroup:  oscar.OServiceUserInfoUpdate,
+		},
+		SnacOut: oscar.SNAC_0x01_0x0F_OServiceUserInfoUpdate{
+			TLVUserInfo: sess.TLVUserInfo(),
+		},
+	}, nil
+}
+
+func (s OServiceService) IdleNotificationHandler(ctx context.Context, sess *server.Session, snacPayloadIn oscar.SNAC_0x01_0x11_OServiceIdleNotification) error {
+	if snacPayloadIn.IdleTime == 0 {
+		sess.SetActive()
+	} else {
+		sess.SetIdle(time.Duration(snacPayloadIn.IdleTime) * time.Second)
+	}
+	return broadcastArrival(ctx, sess, s.sm, s.fm)
+}
+
+// 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) {
+}
+
+func NewOServiceServiceForBOS(oserviceService OServiceService, cr *server.ChatRegistry) *OServiceServiceForBOS {
+	return &OServiceServiceForBOS{OServiceService: oserviceService, cr: cr}
+}
+
+type OServiceServiceForBOS struct {
+	OServiceService
+	cr *server.ChatRegistry
+}
+
+func (s OServiceServiceForBOS) ServiceRequestHandler(_ context.Context, sess *server.Session, snacPayloadIn oscar.SNAC_0x01_0x04_OServiceServiceRequest) (oscar.XMessage, error) {
+	if snacPayloadIn.FoodGroup != oscar.CHAT {
+		return oscar.XMessage{}, server.ErrUnsupportedSubGroup
+	}
+
+	roomMeta, ok := snacPayloadIn.GetSlice(0x01)
+	if !ok {
+		return oscar.XMessage{}, errors.New("missing room info")
+	}
+
+	roomSnac := oscar.SNAC_0x01_0x04_TLVRoomInfo{}
+	if err := oscar.Unmarshal(&roomSnac, bytes.NewBuffer(roomMeta)); err != nil {
+		return oscar.XMessage{}, err
+	}
+
+	room, chatSessMgr, err := s.cr.Retrieve(string(roomSnac.Cookie))
+	if err != nil {
+		return oscar.XMessage{}, server.ErrUnsupportedSubGroup
+	}
+	chatSessMgr.NewSessionWithSN(sess.ID(), sess.ScreenName())
+
+	return oscar.XMessage{
+		SnacFrame: oscar.SnacFrame{
+			FoodGroup: oscar.OSERVICE,
+			SubGroup:  oscar.OServiceServiceResponse,
+		},
+		SnacOut: oscar.SNAC_0x01_0x05_OServiceServiceResponse{
+			TLVRestBlock: oscar.TLVRestBlock{
+				TLVList: oscar.TLVList{
+					oscar.NewTLV(oscar.OServiceTLVTagsReconnectHere, server.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
+}
+
+func (s OServiceServiceForBOS) WriteOServiceHostOnline() oscar.XMessage {
+	return oscar.XMessage{
+		SnacFrame: oscar.SnacFrame{
+			FoodGroup: oscar.OSERVICE,
+			SubGroup:  oscar.OServiceHostOnline,
+		},
+		SnacOut: oscar.SNAC_0x01_0x03_OServiceHostOnline{
+			FoodGroups: []uint16{
+				oscar.ALERT,
+				oscar.BUDDY,
+				oscar.CHAT_NAV,
+				oscar.FEEDBAG,
+				oscar.ICBM,
+				oscar.LOCATE,
+				oscar.OSERVICE,
+			},
+		},
+	}
+}
+
+func (s OServiceServiceForBOS) ClientOnlineHandler(ctx context.Context, _ oscar.SNAC_0x01_0x02_OServiceClientOnline, sess *server.Session) error {
+	if err := broadcastArrival(ctx, sess, s.sm, s.fm); err != nil {
+		return err
+	}
+	buddies, err := s.fm.Buddies(sess.ScreenName())
+	if err != nil {
+		return err
+	}
+	for _, buddy := range buddies {
+		unicastArrival(ctx, buddy, sess.ScreenName(), s.sm)
+	}
+	return nil
+}
+
+func NewOServiceServiceForChat(oserviceService OServiceService) *OServiceServiceForChat {
+	return &OServiceServiceForChat{OServiceService: oserviceService}
+}
+
+type OServiceServiceForChat struct {
+	OServiceService
+}
+
+func (s OServiceServiceForChat) ServiceRequestHandler(_ context.Context, _ *server.Session, _ oscar.SNAC_0x01_0x04_OServiceServiceRequest) (oscar.XMessage, error) {
+	return oscar.XMessage{}, server.ErrUnsupportedSubGroup
+}
+
+func (s OServiceServiceForChat) WriteOServiceHostOnline() oscar.XMessage {
+	return oscar.XMessage{
+		SnacFrame: oscar.SnacFrame{
+			FoodGroup: oscar.OSERVICE,
+			SubGroup:  oscar.OServiceHostOnline,
+		},
+		SnacOut: oscar.SNAC_0x01_0x03_OServiceHostOnline{
+			FoodGroups: []uint16{oscar.OSERVICE, oscar.CHAT},
+		},
+	}
+}
+
+func (s OServiceServiceForChat) ClientOnlineHandler(ctx context.Context, _ oscar.SNAC_0x01_0x02_OServiceClientOnline, sess *server.Session, chatSessMgr server.ChatSessionManager, room server.ChatRoom) error {
+	sendChatRoomInfoUpdate(ctx, sess, chatSessMgr, room)
+	alertUserJoined(ctx, sess, chatSessMgr)
+	setOnlineChatUsers(ctx, sess, chatSessMgr)
+	return nil
+}

+ 736 - 0
handler/oservice_test.go

@@ -0,0 +1,736 @@
+package handler
+
+import (
+	"bytes"
+	"github.com/mkaminski/goaim/oscar"
+	"github.com/mkaminski/goaim/server"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/mock"
+	"testing"
+	"time"
+)
+
+func TestReceiveAndSendServiceRequest(t *testing.T) {
+	cases := []struct {
+		// name is the unit test name
+		name string
+		// cfg is the application config
+		cfg server.Config
+		// chatRoom is the chat room the user connects to
+		chatRoom *server.ChatRoom
+		// userSession is the session of the user requesting the chat service
+		// info
+		userSession *server.Session
+		// inputSNAC is the SNAC sent by the sender client
+		inputSNAC oscar.SNAC_0x01_0x04_OServiceServiceRequest
+		// expectSNACFrame is the SNAC frame sent from the server to the recipient
+		// client
+		expectOutput oscar.XMessage
+		// 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.SNAC_0x01_0x04_OServiceServiceRequest{
+				FoodGroup: oscar.ICBM,
+			},
+			expectErr: server.ErrUnsupportedSubGroup,
+		},
+		{
+			name: "request info for connecting to chat room, return chat service and chat room metadata",
+			cfg: server.Config{
+				OSCARHost: "127.0.0.1",
+				ChatPort:  1234,
+			},
+			chatRoom: &server.ChatRoom{
+				CreateTime:     time.UnixMilli(0),
+				DetailLevel:    4,
+				Exchange:       8,
+				Cookie:         "the-chat-cookie",
+				InstanceNumber: 16,
+				Name:           "my new chat",
+			},
+			userSession: newTestSession("user_screen_name", sessOptCannedID),
+			inputSNAC: oscar.SNAC_0x01_0x04_OServiceServiceRequest{
+				FoodGroup: oscar.CHAT,
+				TLVRestBlock: oscar.TLVRestBlock{
+					TLVList: oscar.TLVList{
+						oscar.NewTLV(0x01, oscar.SNAC_0x01_0x04_TLVRoomInfo{
+							Exchange:       8,
+							Cookie:         []byte("the-chat-cookie"),
+							InstanceNumber: 16,
+						}),
+					},
+				},
+			},
+			expectOutput: oscar.XMessage{
+				SnacFrame: oscar.SnacFrame{
+					FoodGroup: oscar.OSERVICE,
+					SubGroup:  oscar.OServiceServiceResponse,
+				},
+				SnacOut: 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-sess-id",
+							}),
+							oscar.NewTLV(oscar.OServiceTLVTagsGroupID, oscar.CHAT),
+							oscar.NewTLV(oscar.OServiceTLVTagsSSLCertName, ""),
+							oscar.NewTLV(oscar.OServiceTLVTagsSSLState, uint8(0x00)),
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "request info for connecting to non-existent chat room, return SNAC error",
+			cfg: server.Config{
+				OSCARHost: "127.0.0.1",
+				ChatPort:  1234,
+			},
+			chatRoom:    nil,
+			userSession: newTestSession("user_screen_name", sessOptCannedID),
+			inputSNAC: oscar.SNAC_0x01_0x04_OServiceServiceRequest{
+				FoodGroup: oscar.CHAT,
+				TLVRestBlock: oscar.TLVRestBlock{
+					TLVList: oscar.TLVList{
+						oscar.NewTLV(0x01, oscar.SNAC_0x01_0x04_TLVRoomInfo{
+							Exchange:       8,
+							Cookie:         []byte("the-chat-cookie"),
+							InstanceNumber: 16,
+						}),
+					},
+				},
+			},
+			expectErr: server.ErrUnsupportedSubGroup,
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			//
+			// initialize dependencies
+			//
+			sm := server.NewMockChatSessionManager(t)
+			cr := server.NewChatRegistry()
+			if tc.chatRoom != nil {
+				sm.EXPECT().
+					NewSessionWithSN(tc.userSession.ID(), tc.userSession.ScreenName()).
+					Return(&server.Session{}).
+					Maybe()
+				cr.Register(*tc.chatRoom, sm)
+			}
+			//
+			// send input SNAC
+			//
+			svc := OServiceServiceForBOS{
+				OServiceService: OServiceService{
+					cfg: tc.cfg,
+					sm:  sm,
+				},
+				cr: cr,
+			}
+
+			outputSNAC, err := svc.ServiceRequestHandler(nil, tc.userSession, tc.inputSNAC)
+			assert.ErrorIs(t, err, tc.expectErr)
+			if tc.expectErr != nil {
+				return
+			}
+			//
+			// verify output
+			//
+			assert.Equal(t, tc.expectOutput, outputSNAC)
+		})
+	}
+}
+
+func TestOServiceRouter_RouteOService_ForBOS(t *testing.T) {
+	cases := []struct {
+		// name is the unit test name
+		name string
+		// input is the request payload
+		input oscar.XMessage
+		// output is the response payload
+		output oscar.XMessage
+		// handlerErr is the mocked handler error response
+		handlerErr error
+		// expectErr is the expected error returned by the router
+		expectErr error
+	}{
+		{
+			name: "receive OServiceClientOnline, return no response",
+			input: oscar.XMessage{
+				SnacFrame: oscar.SnacFrame{
+					FoodGroup: oscar.OSERVICE,
+					SubGroup:  oscar.OServiceClientOnline,
+				},
+				SnacOut: oscar.SNAC_0x01_0x02_OServiceClientOnline{
+					GroupVersions: []struct {
+						FoodGroup   uint16
+						Version     uint16
+						ToolID      uint16
+						ToolVersion uint16
+					}{
+						{
+							FoodGroup: 10,
+						},
+					},
+				},
+			},
+			output: oscar.XMessage{},
+		},
+		{
+			name: "receive OServiceServiceRequest, return OServiceServiceResponse",
+			input: oscar.XMessage{
+				SnacFrame: oscar.SnacFrame{
+					FoodGroup: oscar.OSERVICE,
+					SubGroup:  oscar.OServiceServiceRequest,
+				},
+				SnacOut: oscar.SNAC_0x01_0x04_OServiceServiceRequest{
+					FoodGroup: 10,
+				},
+			},
+			output: oscar.XMessage{
+				SnacFrame: oscar.SnacFrame{
+					FoodGroup: oscar.OSERVICE,
+					SubGroup:  oscar.OServiceServiceResponse,
+				},
+				SnacOut: oscar.SNAC_0x01_0x05_OServiceServiceResponse{
+					TLVRestBlock: oscar.TLVRestBlock{
+						TLVList: oscar.TLVList{
+							oscar.NewTLV(0x01, uint16(1000)),
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "receive OServiceRateParamsQuery, return OServiceRateParamsReply",
+			input: oscar.XMessage{
+				SnacFrame: oscar.SnacFrame{
+					FoodGroup: oscar.OSERVICE,
+					SubGroup:  oscar.OServiceRateParamsQuery,
+				},
+				SnacOut: struct{}{},
+			},
+			output: oscar.XMessage{
+				SnacFrame: oscar.SnacFrame{
+					FoodGroup: oscar.OSERVICE,
+					SubGroup:  oscar.OServiceRateParamsReply,
+				},
+				SnacOut: oscar.SNAC_0x01_0x07_OServiceRateParamsReply{
+					RateGroups: []struct {
+						ID    uint16
+						Pairs []struct {
+							FoodGroup uint16
+							SubGroup  uint16
+						} `count_prefix:"uint16"`
+					}{
+						{
+							ID: 1,
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "receive OServiceRateParamsSubAdd, return no response",
+			input: oscar.XMessage{
+				SnacFrame: oscar.SnacFrame{
+					FoodGroup: oscar.OSERVICE,
+					SubGroup:  oscar.OServiceRateParamsSubAdd,
+				},
+				SnacOut: oscar.SNAC_0x01_0x08_OServiceRateParamsSubAdd{
+					TLVRestBlock: oscar.TLVRestBlock{
+						TLVList: oscar.TLVList{
+							oscar.NewTLV(0x01, []byte{1, 2, 3, 4}),
+						},
+					},
+				},
+			},
+			output: oscar.XMessage{},
+		},
+		{
+			name: "receive OServiceUserInfoQuery, return OServiceUserInfoUpdate",
+			input: oscar.XMessage{
+				SnacFrame: oscar.SnacFrame{
+					FoodGroup: oscar.OSERVICE,
+					SubGroup:  oscar.OServiceUserInfoQuery,
+				},
+				SnacOut: struct{}{},
+			},
+			output: oscar.XMessage{
+				SnacFrame: oscar.SnacFrame{
+					FoodGroup: oscar.OSERVICE,
+					SubGroup:  oscar.OServiceUserInfoUpdate,
+				},
+				SnacOut: oscar.SNAC_0x01_0x0F_OServiceUserInfoUpdate{
+					TLVUserInfo: oscar.TLVUserInfo{
+						ScreenName: "screen-name",
+					},
+				},
+			},
+		},
+		{
+			name: "receive OServiceIdleNotification, return no response",
+			input: oscar.XMessage{
+				SnacFrame: oscar.SnacFrame{
+					FoodGroup: oscar.OSERVICE,
+					SubGroup:  oscar.OServiceIdleNotification,
+				},
+				SnacOut: oscar.SNAC_0x01_0x11_OServiceIdleNotification{
+					IdleTime: 10,
+				},
+			},
+			output: oscar.XMessage{},
+		},
+		{
+			name: "receive OServiceClientVersions, return OServiceHostVersions",
+			input: oscar.XMessage{
+				SnacFrame: oscar.SnacFrame{
+					FoodGroup: oscar.OSERVICE,
+					SubGroup:  oscar.OServiceClientVersions,
+				},
+				SnacOut: oscar.SNAC_0x01_0x17_OServiceClientVersions{
+					Versions: []uint16{
+						10,
+					},
+				},
+			},
+			output: oscar.XMessage{
+				SnacFrame: oscar.SnacFrame{
+					FoodGroup: oscar.OSERVICE,
+					SubGroup:  oscar.OServiceHostVersions,
+				},
+				SnacOut: oscar.SNAC_0x01_0x18_OServiceHostVersions{
+					Versions: []uint16{
+						10,
+					},
+				},
+			},
+		},
+		{
+			name: "receive OServiceSetUserInfoFields, return OServiceUserInfoUpdate",
+			input: oscar.XMessage{
+				SnacFrame: oscar.SnacFrame{
+					FoodGroup: oscar.OSERVICE,
+					SubGroup:  oscar.OServiceSetUserInfoFields,
+				},
+				SnacOut: oscar.SNAC_0x01_0x1E_OServiceSetUserInfoFields{
+					TLVRestBlock: oscar.TLVRestBlock{
+						TLVList: oscar.TLVList{
+							oscar.NewTLV(0x01, []byte{1, 2, 3, 4}),
+						},
+					},
+				},
+			},
+			output: oscar.XMessage{
+				SnacFrame: oscar.SnacFrame{
+					FoodGroup: oscar.OSERVICE,
+					SubGroup:  oscar.OServiceUserInfoUpdate,
+				},
+				SnacOut: oscar.SNAC_0x01_0x0F_OServiceUserInfoUpdate{
+					TLVUserInfo: oscar.TLVUserInfo{
+						ScreenName: "screen-name",
+					},
+				},
+			},
+		},
+		{
+			name: "receive OServicePauseReq, expect ErrUnsupportedSubGroup",
+			input: oscar.XMessage{
+				SnacFrame: oscar.SnacFrame{
+					FoodGroup: oscar.OSERVICE,
+					SubGroup:  oscar.OServicePauseReq,
+				},
+				SnacOut: struct{}{}, // empty SNAC
+			},
+			output:    oscar.XMessage{}, // empty SNAC
+			expectErr: server.ErrUnsupportedSubGroup,
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			svc := server.NewMockOServiceHandler(t)
+			svc.EXPECT().
+				RateParamsQueryHandler(mock.Anything).
+				Return(tc.output).
+				Maybe()
+			svc.EXPECT().
+				UserInfoQueryHandler(mock.Anything, mock.Anything).
+				Return(tc.output).
+				Maybe()
+			svc.EXPECT().
+				ClientVersionsHandler(mock.Anything, tc.input.SnacOut).
+				Return(tc.output).
+				Maybe()
+			svc.EXPECT().
+				SetUserInfoFieldsHandler(mock.Anything, mock.Anything, tc.input.SnacOut).
+				Return(tc.output, tc.handlerErr).
+				Maybe()
+			svc.EXPECT().
+				RateParamsSubAddHandler(mock.Anything, tc.input.SnacOut).
+				Maybe()
+			svc.EXPECT().
+				IdleNotificationHandler(mock.Anything, mock.Anything, tc.input.SnacOut).
+				Return(tc.handlerErr).
+				Maybe()
+
+			svcBOS := server.NewMockOServiceBOSHandler(t)
+			svcBOS.EXPECT().
+				ServiceRequestHandler(mock.Anything, mock.Anything, tc.input.SnacOut).
+				Return(tc.output, tc.handlerErr).
+				Maybe()
+			svcBOS.EXPECT().
+				ClientOnlineHandler(mock.Anything, tc.input.SnacOut, mock.Anything).
+				Return(tc.handlerErr).
+				Maybe()
+
+			router := server.OServiceBOSRouter{
+				OServiceRouter: server.OServiceRouter{
+					OServiceHandler: svc,
+					RouteLogger: server.RouteLogger{
+						Logger: server.NewLogger(server.Config{}),
+					},
+				},
+				OServiceBOSHandler: svcBOS,
+			}
+
+			bufIn := &bytes.Buffer{}
+			assert.NoError(t, oscar.Marshal(tc.input.SnacOut, bufIn))
+
+			bufOut := &bytes.Buffer{}
+			seq := uint32(1)
+
+			err := router.RouteOService(nil, nil, tc.input.SnacFrame, bufIn, bufOut, &seq)
+			assert.ErrorIs(t, err, tc.expectErr)
+			if tc.expectErr != nil {
+				return
+			}
+
+			if tc.output == (oscar.XMessage{}) {
+				// 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.SNACBuffer(bufOut)
+			assert.NoError(t, err)
+
+			// verify the SNAC frame
+			snacFrame := oscar.SnacFrame{}
+			assert.NoError(t, oscar.Unmarshal(&snacFrame, flapBuf))
+			assert.Equal(t, tc.output.SnacFrame, snacFrame)
+
+			// verify the SNAC message
+			snacBuf := &bytes.Buffer{}
+			assert.NoError(t, oscar.Marshal(tc.output.SnacOut, snacBuf))
+			assert.Equal(t, snacBuf.Bytes(), flapBuf.Bytes())
+		})
+	}
+}
+
+func TestOServiceRouter_RouteOService_ForChat(t *testing.T) {
+	cases := []struct {
+		// name is the unit test name
+		name string
+		// input is the request payload
+		input oscar.XMessage
+		// output is the response payload
+		output oscar.XMessage
+		// handlerErr is the mocked handler error response
+		handlerErr error
+		// expectErr is the expected error returned by the router
+		expectErr error
+	}{
+		{
+			name: "receive OServiceClientOnline, return no response",
+			input: oscar.XMessage{
+				SnacFrame: oscar.SnacFrame{
+					FoodGroup: oscar.OSERVICE,
+					SubGroup:  oscar.OServiceClientOnline,
+				},
+				SnacOut: oscar.SNAC_0x01_0x02_OServiceClientOnline{
+					GroupVersions: []struct {
+						FoodGroup   uint16
+						Version     uint16
+						ToolID      uint16
+						ToolVersion uint16
+					}{
+						{
+							FoodGroup: 10,
+						},
+					},
+				},
+			},
+			output: oscar.XMessage{},
+		},
+		{
+			name: "receive OServiceServiceRequest, return OServiceServiceResponse",
+			input: oscar.XMessage{
+				SnacFrame: oscar.SnacFrame{
+					FoodGroup: oscar.OSERVICE,
+					SubGroup:  oscar.OServiceServiceRequest,
+				},
+				SnacOut: oscar.SNAC_0x01_0x04_OServiceServiceRequest{
+					FoodGroup: 10,
+				},
+			},
+			output: oscar.XMessage{
+				SnacFrame: oscar.SnacFrame{
+					FoodGroup: oscar.OSERVICE,
+					SubGroup:  oscar.OServiceServiceResponse,
+				},
+				SnacOut: oscar.SNAC_0x01_0x05_OServiceServiceResponse{
+					TLVRestBlock: oscar.TLVRestBlock{
+						TLVList: oscar.TLVList{
+							oscar.NewTLV(0x01, uint16(1000)),
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "receive OServiceRateParamsQuery, return OServiceRateParamsReply",
+			input: oscar.XMessage{
+				SnacFrame: oscar.SnacFrame{
+					FoodGroup: oscar.OSERVICE,
+					SubGroup:  oscar.OServiceRateParamsQuery,
+				},
+				SnacOut: struct{}{},
+			},
+			output: oscar.XMessage{
+				SnacFrame: oscar.SnacFrame{
+					FoodGroup: oscar.OSERVICE,
+					SubGroup:  oscar.OServiceRateParamsReply,
+				},
+				SnacOut: oscar.SNAC_0x01_0x07_OServiceRateParamsReply{
+					RateGroups: []struct {
+						ID    uint16
+						Pairs []struct {
+							FoodGroup uint16
+							SubGroup  uint16
+						} `count_prefix:"uint16"`
+					}{
+						{
+							ID: 1,
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "receive OServiceRateParamsSubAdd, return no response",
+			input: oscar.XMessage{
+				SnacFrame: oscar.SnacFrame{
+					FoodGroup: oscar.OSERVICE,
+					SubGroup:  oscar.OServiceRateParamsSubAdd,
+				},
+				SnacOut: oscar.SNAC_0x01_0x08_OServiceRateParamsSubAdd{
+					TLVRestBlock: oscar.TLVRestBlock{
+						TLVList: oscar.TLVList{
+							oscar.NewTLV(0x01, []byte{1, 2, 3, 4}),
+						},
+					},
+				},
+			},
+			output: oscar.XMessage{},
+		},
+		{
+			name: "receive OServiceUserInfoQuery, return OServiceUserInfoUpdate",
+			input: oscar.XMessage{
+				SnacFrame: oscar.SnacFrame{
+					FoodGroup: oscar.OSERVICE,
+					SubGroup:  oscar.OServiceUserInfoQuery,
+				},
+				SnacOut: struct{}{},
+			},
+			output: oscar.XMessage{
+				SnacFrame: oscar.SnacFrame{
+					FoodGroup: oscar.OSERVICE,
+					SubGroup:  oscar.OServiceUserInfoUpdate,
+				},
+				SnacOut: oscar.SNAC_0x01_0x0F_OServiceUserInfoUpdate{
+					TLVUserInfo: oscar.TLVUserInfo{
+						ScreenName: "screen-name",
+					},
+				},
+			},
+		},
+		{
+			name: "receive OServiceIdleNotification, return no response",
+			input: oscar.XMessage{
+				SnacFrame: oscar.SnacFrame{
+					FoodGroup: oscar.OSERVICE,
+					SubGroup:  oscar.OServiceIdleNotification,
+				},
+				SnacOut: oscar.SNAC_0x01_0x11_OServiceIdleNotification{
+					IdleTime: 10,
+				},
+			},
+			output: oscar.XMessage{},
+		},
+		{
+			name: "receive OServiceClientVersions, return OServiceHostVersions",
+			input: oscar.XMessage{
+				SnacFrame: oscar.SnacFrame{
+					FoodGroup: oscar.OSERVICE,
+					SubGroup:  oscar.OServiceClientVersions,
+				},
+				SnacOut: oscar.SNAC_0x01_0x17_OServiceClientVersions{
+					Versions: []uint16{
+						10,
+					},
+				},
+			},
+			output: oscar.XMessage{
+				SnacFrame: oscar.SnacFrame{
+					FoodGroup: oscar.OSERVICE,
+					SubGroup:  oscar.OServiceHostVersions,
+				},
+				SnacOut: oscar.SNAC_0x01_0x18_OServiceHostVersions{
+					Versions: []uint16{
+						10,
+					},
+				},
+			},
+		},
+		{
+			name: "receive OServiceSetUserInfoFields, return OServiceUserInfoUpdate",
+			input: oscar.XMessage{
+				SnacFrame: oscar.SnacFrame{
+					FoodGroup: oscar.OSERVICE,
+					SubGroup:  oscar.OServiceSetUserInfoFields,
+				},
+				SnacOut: oscar.SNAC_0x01_0x1E_OServiceSetUserInfoFields{
+					TLVRestBlock: oscar.TLVRestBlock{
+						TLVList: oscar.TLVList{
+							oscar.NewTLV(0x01, []byte{1, 2, 3, 4}),
+						},
+					},
+				},
+			},
+			output: oscar.XMessage{
+				SnacFrame: oscar.SnacFrame{
+					FoodGroup: oscar.OSERVICE,
+					SubGroup:  oscar.OServiceUserInfoUpdate,
+				},
+				SnacOut: oscar.SNAC_0x01_0x0F_OServiceUserInfoUpdate{
+					TLVUserInfo: oscar.TLVUserInfo{
+						ScreenName: "screen-name",
+					},
+				},
+			},
+		},
+		{
+			name: "receive OServicePauseReq, expect ErrUnsupportedSubGroup",
+			input: oscar.XMessage{
+				SnacFrame: oscar.SnacFrame{
+					FoodGroup: oscar.OSERVICE,
+					SubGroup:  oscar.OServicePauseReq,
+				},
+				SnacOut: struct{}{}, // empty SNAC
+			},
+			output:    oscar.XMessage{}, // empty SNAC
+			expectErr: server.ErrUnsupportedSubGroup,
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			svc := server.NewMockOServiceHandler(t)
+			svc.EXPECT().
+				RateParamsQueryHandler(mock.Anything).
+				Return(tc.output).
+				Maybe()
+			svc.EXPECT().
+				UserInfoQueryHandler(mock.Anything, mock.Anything).
+				Return(tc.output).
+				Maybe()
+			svc.EXPECT().
+				ClientVersionsHandler(mock.Anything, tc.input.SnacOut).
+				Return(tc.output).
+				Maybe()
+			svc.EXPECT().
+				SetUserInfoFieldsHandler(mock.Anything, mock.Anything, tc.input.SnacOut).
+				Return(tc.output, tc.handlerErr).
+				Maybe()
+			svc.EXPECT().
+				RateParamsSubAddHandler(mock.Anything, tc.input.SnacOut).
+				Maybe()
+			svc.EXPECT().
+				IdleNotificationHandler(mock.Anything, mock.Anything, tc.input.SnacOut).
+				Return(tc.handlerErr).
+				Maybe()
+
+			svcBOS := server.NewMockOServiceChatHandler(t)
+			svcBOS.EXPECT().
+				ServiceRequestHandler(mock.Anything, mock.Anything, tc.input.SnacOut).
+				Return(tc.output, tc.handlerErr).
+				Maybe()
+			svcBOS.EXPECT().
+				ClientOnlineHandler(mock.Anything, tc.input.SnacOut, mock.Anything, mock.Anything, mock.Anything).
+				Return(tc.handlerErr).
+				Maybe()
+
+			router := server.OServiceChatRouter{
+				OServiceRouter: server.OServiceRouter{
+					OServiceHandler: svc,
+					RouteLogger: server.RouteLogger{
+						Logger: server.NewLogger(server.Config{}),
+					},
+				},
+				OServiceChatHandler: svcBOS,
+			}
+
+			bufIn := &bytes.Buffer{}
+			assert.NoError(t, oscar.Marshal(tc.input.SnacOut, bufIn))
+
+			bufOut := &bytes.Buffer{}
+			seq := uint32(1)
+
+			err := router.RouteOService(nil, nil, nil, server.ChatRoom{}, tc.input.SnacFrame, bufIn, bufOut, &seq)
+			assert.ErrorIs(t, err, tc.expectErr)
+			if tc.expectErr != nil {
+				return
+			}
+
+			if tc.output == (oscar.XMessage{}) {
+				// 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.SNACBuffer(bufOut)
+			assert.NoError(t, err)
+
+			// verify the SNAC frame
+			snacFrame := oscar.SnacFrame{}
+			assert.NoError(t, oscar.Unmarshal(&snacFrame, flapBuf))
+			assert.Equal(t, tc.output.SnacFrame, snacFrame)
+
+			// verify the SNAC message
+			snacBuf := &bytes.Buffer{}
+			assert.NoError(t, oscar.Marshal(tc.output.SnacOut, snacBuf))
+			assert.Equal(t, snacBuf.Bytes(), flapBuf.Bytes())
+		})
+	}
+}

+ 18 - 9
server/test_helpers.go → handler/test_helpers.go

@@ -1,44 +1,53 @@
-package server
+package handler
 
 
 import (
 import (
+	"github.com/mkaminski/goaim/server"
 	"time"
 	"time"
 )
 )
 
 
 // sessOptWarning sets a warning level on the session object
 // sessOptWarning sets a warning level on the session object
-func sessOptWarning(level uint16) func(session *Session) {
-	return func(session *Session) {
+func sessOptWarning(level uint16) func(session *server.Session) {
+	return func(session *server.Session) {
 		session.IncreaseWarning(level)
 		session.IncreaseWarning(level)
 	}
 	}
 }
 }
 
 
 // sessOptCannedID sets a canned session ID ("user-sess-id") on the session
 // sessOptCannedID sets a canned session ID ("user-sess-id") on the session
 // object
 // object
-func sessOptCannedID(session *Session) {
+func sessOptCannedID(session *server.Session) {
 	session.SetID("user-sess-id")
 	session.SetID("user-sess-id")
 }
 }
 
 
+// sessOptCannedID sets a canned session ID ("user-sess-id") on the session
+// object
+func sessOptID(ID string) func(session *server.Session) {
+	return func(session *server.Session) {
+		session.SetID(ID)
+	}
+}
+
 // sessOptCannedAwayMessage sets a canned away message ("this is my away
 // sessOptCannedAwayMessage sets a canned away message ("this is my away
 // message!") on the session object
 // message!") on the session object
-func sessOptCannedAwayMessage(session *Session) {
+func sessOptCannedAwayMessage(session *server.Session) {
 	session.SetAwayMessage("this is my away message!")
 	session.SetAwayMessage("this is my away message!")
 }
 }
 
 
 // sessOptCannedSignonTime sets a canned sign-on time (1696790127565) on the
 // sessOptCannedSignonTime sets a canned sign-on time (1696790127565) on the
 // session object
 // session object
-func sessOptCannedSignonTime(session *Session) {
+func sessOptCannedSignonTime(session *server.Session) {
 	session.SetSignonTime(time.UnixMilli(1696790127565))
 	session.SetSignonTime(time.UnixMilli(1696790127565))
 }
 }
 
 
 // sessOptCannedSignonTime sets the invisible flag to true on the session
 // sessOptCannedSignonTime sets the invisible flag to true on the session
 // object
 // object
-func sessOptInvisible(session *Session) {
+func sessOptInvisible(session *server.Session) {
 	session.SetInvisible(true)
 	session.SetInvisible(true)
 }
 }
 
 
 // newTestSession creates a session object with 0 or more functional options
 // newTestSession creates a session object with 0 or more functional options
 // applied
 // applied
-func newTestSession(screenName string, options ...func(session *Session)) *Session {
-	s := NewSession()
+func newTestSession(screenName string, options ...func(session *server.Session)) *server.Session {
+	s := server.NewSession()
 	s.SetScreenName(screenName)
 	s.SetScreenName(screenName)
 	for _, op := range options {
 	for _, op := range options {
 		op(s)
 		op(s)

+ 12 - 0
oscar/snacs.go

@@ -734,6 +734,18 @@ type SNAC_0x13_0x11_FeedbagStartCluster struct {
 // 0x17: BUCP
 // 0x17: BUCP
 //
 //
 
 
+const (
+	BUCPErr                      uint16 = 0x0001
+	BUCPLoginRequest             uint16 = 0x0002
+	BUCPLoginResponse            uint16 = 0x0003
+	BUCPRegisterRequest          uint16 = 0x0004
+	BUCPChallengeRequest         uint16 = 0x0006
+	BUCPChallengeResponse        uint16 = 0x0007
+	BUCPAsasnRequest             uint16 = 0x0008
+	BUCPSecuridRequest           uint16 = 0x000A
+	BUCPRegistrationImageRequest uint16 = 0x000C
+)
+
 type SNAC_0x17_0x02_BUCPLoginRequest struct {
 type SNAC_0x17_0x02_BUCPLoginRequest struct {
 	TLVRestBlock
 	TLVRestBlock
 }
 }

+ 0 - 225
server/bucp.go

@@ -1,234 +1,9 @@
 package server
 package server
 
 
 import (
 import (
-	"bytes"
 	"context"
 	"context"
-	"errors"
-	"fmt"
-	"io"
-
-	"github.com/google/uuid"
-	"github.com/mkaminski/goaim/oscar"
-)
-
-const (
-	BUCPErr                      uint16 = 0x0001
-	BUCPLoginRequest                    = 0x0002
-	BUCPLoginResponse                   = 0x0003
-	BUCPRegisterRequest                 = 0x0004
-	BUCPChallengeRequest                = 0x0006
-	BUCPChallengeResponse               = 0x0007
-	BUCPAsasnRequest                    = 0x0008
-	BUCPSecuridRequest                  = 0x000A
-	BUCPRegistrationImageRequest        = 0x000C
 )
 )
 
 
 func routeBUCP(context.Context) error {
 func routeBUCP(context.Context) error {
 	return ErrUnsupportedSubGroup
 	return ErrUnsupportedSubGroup
 }
 }
-
-func ReceiveAndSendAuthChallenge(cfg Config, fm *SQLiteFeedbagStore, r io.Reader, w io.Writer, sequence *uint32, newUUID func() uuid.UUID) error {
-	flap := oscar.FlapFrame{}
-	if err := oscar.Unmarshal(&flap, r); err != nil {
-		return err
-	}
-	b := make([]byte, flap.PayloadLength)
-	if _, err := r.Read(b); err != nil {
-		return err
-	}
-	snac := oscar.SnacFrame{}
-	buf := bytes.NewBuffer(b)
-	if err := oscar.Unmarshal(&snac, buf); err != nil {
-		return err
-	}
-
-	snacPayloadIn := oscar.SNAC_0x17_0x06_BUCPChallengeRequest{}
-	if err := oscar.Unmarshal(&snacPayloadIn, buf); err != nil {
-		return err
-	}
-	screenName, exists := snacPayloadIn.GetString(oscar.TLVScreenName)
-	if !exists {
-		return errors.New("screen name doesn't exist in tlv")
-	}
-
-	var authKey string
-
-	u, err := fm.GetUser(screenName)
-	switch {
-	case err != nil:
-		return err
-	case u != nil:
-		// user lookup succeeded
-		authKey = u.AuthKey
-	case cfg.DisableAuth:
-		// can't find user, generate stub auth key
-		authKey = newUUID().String()
-	default:
-		// can't find user, return login error
-		snacFrameOut := oscar.SnacFrame{
-			FoodGroup: oscar.BUCP,
-			SubGroup:  BUCPLoginResponse,
-		}
-		snacPayloadOut := oscar.SNAC_0x17_0x03_BUCPLoginResponse{}
-		snacPayloadOut.AddTLV(oscar.NewTLV(oscar.TLVErrorSubcode, uint16(0x01)))
-		return writeOutSNAC(snac, snacFrameOut, snacPayloadOut, sequence, w)
-	}
-
-	snacFrameOut := oscar.SnacFrame{
-		FoodGroup: oscar.BUCP,
-		SubGroup:  BUCPChallengeResponse,
-	}
-	snacPayloadOut := oscar.SNAC_0x17_0x07_BUCPChallengeResponse{
-		AuthKey: authKey,
-	}
-	return writeOutSNAC(snac, snacFrameOut, snacPayloadOut, sequence, w)
-}
-
-func ReceiveAndSendBUCPLoginRequest(cfg Config, sm SessionManager, fm *SQLiteFeedbagStore, r io.Reader, w io.Writer, sequence *uint32, newUUID func() uuid.UUID) error {
-	flap := oscar.FlapFrame{}
-	if err := oscar.Unmarshal(&flap, r); err != nil {
-		return err
-	}
-	snac := oscar.SnacFrame{}
-	b := make([]byte, flap.PayloadLength)
-	if _, err := r.Read(b); err != nil {
-		return err
-	}
-	buf := bytes.NewBuffer(b)
-	if err := oscar.Unmarshal(&snac, buf); err != nil {
-		return err
-	}
-
-	snacPayloadIn := oscar.SNAC_0x17_0x02_BUCPLoginRequest{}
-	if err := oscar.Unmarshal(&snacPayloadIn, buf); err != nil {
-		return err
-	}
-	screenName, found := snacPayloadIn.GetString(oscar.TLVScreenName)
-	if !found {
-		return errors.New("screen name doesn't exist in tlv")
-	}
-	md5Hash, found := snacPayloadIn.GetSlice(oscar.TLVPasswordHash)
-	if !found {
-		return errors.New("password hash doesn't exist in tlv")
-	}
-
-	loginOK := false
-
-	u, err := fm.GetUser(screenName)
-	switch {
-	case err != nil:
-		return err
-	case u != nil && bytes.Equal(u.PassHash, md5Hash):
-		// password check succeeded
-		loginOK = true
-	case cfg.DisableAuth:
-		// login failed but let them in anyway
-		newUser, err := NewStubUser(screenName)
-		if err != nil {
-			return err
-		}
-		if err := fm.UpsertUser(newUser); err != nil {
-			return err
-		}
-		loginOK = true
-	}
-
-	snacPayloadOut := oscar.SNAC_0x17_0x03_BUCPLoginResponse{}
-	snacPayloadOut.AddTLV(oscar.NewTLV(oscar.TLVScreenName, screenName))
-
-	if loginOK {
-		sess := sm.NewSessionWithSN(newUUID().String(), screenName)
-		snacPayloadOut.AddTLVList([]oscar.TLV{
-			oscar.NewTLV(oscar.TLVReconnectHere, Address(cfg.OSCARHost, cfg.BOSPort)),
-			oscar.NewTLV(oscar.TLVAuthorizationCookie, sess.ID()),
-		})
-	} else {
-		snacPayloadOut.AddTLVList([]oscar.TLV{
-			oscar.NewTLV(oscar.TLVErrorSubcode, uint16(0x01)),
-		})
-	}
-
-	snacFrameOut := oscar.SnacFrame{
-		FoodGroup: oscar.BUCP,
-		SubGroup:  BUCPLoginResponse,
-	}
-	return writeOutSNAC(snac, snacFrameOut, snacPayloadOut, sequence, w)
-}
-
-func SendAndReceiveSignonFrame(rw io.ReadWriter, sequence *uint32) (oscar.FlapSignonFrame, error) {
-	flapFrameOut := oscar.FlapFrame{
-		StartMarker:   42,
-		FrameType:     oscar.FlapFrameSignon,
-		Sequence:      uint16(*sequence),
-		PayloadLength: 4, // size of FlapSignonFrame
-	}
-	if err := oscar.Marshal(flapFrameOut, 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
-	flapFrameIn := oscar.FlapFrame{}
-	if err := oscar.Unmarshal(&flapFrameIn, rw); err != nil {
-		return oscar.FlapSignonFrame{}, err
-	}
-	b := make([]byte, flapFrameIn.PayloadLength)
-	if _, err := rw.Read(b); err != nil {
-		return oscar.FlapSignonFrame{}, err
-	}
-	flapSignonFrameIn := oscar.FlapSignonFrame{}
-	if err := oscar.Unmarshal(&flapSignonFrameIn, bytes.NewBuffer(b)); err != nil {
-		return oscar.FlapSignonFrame{}, err
-	}
-
-	*sequence++
-
-	return flapSignonFrameIn, nil
-}
-
-func VerifyLogin(sm SessionManager, rw io.ReadWriter) (*Session, uint32, error) {
-	seq := uint32(100)
-
-	flap, err := SendAndReceiveSignonFrame(rw, &seq)
-	if err != nil {
-		return nil, 0, err
-	}
-
-	var ok bool
-	ID, ok := flap.GetSlice(oscar.OServiceTLVTagsLoginCookie)
-	if !ok {
-		return nil, 0, errors.New("unable to get session id from payload")
-	}
-
-	sess, ok := sm.Retrieve(string(ID))
-	if !ok {
-		return nil, 0, fmt.Errorf("unable to find session by id %s", ID)
-	}
-
-	return sess, seq, nil
-}
-
-func VerifyChatLogin(rw io.ReadWriter) (*ChatCookie, uint32, error) {
-	seq := uint32(100)
-
-	flap, err := SendAndReceiveSignonFrame(rw, &seq)
-	if err != nil {
-		return nil, 0, err
-	}
-
-	var ok bool
-	buf, ok := flap.GetSlice(oscar.OServiceTLVTagsLoginCookie)
-	if !ok {
-		return nil, 0, errors.New("unable to get session id from payload")
-	}
-
-	cookie := ChatCookie{}
-	err = oscar.Unmarshal(&cookie, bytes.NewBuffer(buf))
-
-	return &cookie, seq, err
-}

+ 0 - 319
server/bucp_test.go

@@ -1,320 +1 @@
 package server
 package server
-
-import (
-	"bytes"
-	"os"
-	"testing"
-
-	"github.com/google/uuid"
-	"github.com/mkaminski/goaim/oscar"
-	"github.com/stretchr/testify/assert"
-)
-
-func TestReceiveAndSendBUCPLoginRequest(t *testing.T) {
-	userGoodPwd := User{
-		ScreenName: "sn_user_a",
-		AuthKey:    "auth_key_user",
-	}
-	assert.NoError(t, userGoodPwd.HashPassword("good_pwd"))
-	userBadPwd := userGoodPwd
-	assert.NoError(t, userBadPwd.HashPassword("bad_pwd"))
-
-	cases := []struct {
-		name            string
-		cfg             Config
-		userInDB        User
-		sessionUUID     uuid.UUID
-		inputSNAC       oscar.SNAC_0x17_0x02_BUCPLoginRequest
-		expectSnacFrame oscar.SnacFrame
-		expectSNACBody  oscar.SNAC_0x17_0x03_BUCPLoginResponse
-	}{
-		{
-			name: "login with valid password, expect OK login response",
-			cfg: Config{
-				OSCARHost: "127.0.0.1",
-				BOSPort:   1234,
-			},
-			userInDB:    userGoodPwd,
-			sessionUUID: uuid.UUID{1, 2, 3},
-			inputSNAC: oscar.SNAC_0x17_0x02_BUCPLoginRequest{
-				TLVRestBlock: oscar.TLVRestBlock{
-					TLVList: oscar.TLVList{
-						oscar.NewTLV(oscar.TLVPasswordHash, userGoodPwd.PassHash),
-						oscar.NewTLV(oscar.TLVScreenName, userGoodPwd.ScreenName),
-					},
-				},
-			},
-			expectSnacFrame: oscar.SnacFrame{
-				FoodGroup: oscar.BUCP,
-				SubGroup:  BUCPLoginResponse,
-			},
-			expectSNACBody: oscar.SNAC_0x17_0x03_BUCPLoginResponse{
-				TLVRestBlock: oscar.TLVRestBlock{
-					TLVList: oscar.TLVList{
-						oscar.NewTLV(oscar.TLVScreenName, userGoodPwd.ScreenName),
-						oscar.NewTLV(oscar.TLVReconnectHere, "127.0.0.1:1234"),
-						oscar.NewTLV(oscar.TLVAuthorizationCookie, uuid.UUID{1, 2, 3}.String()),
-					},
-				},
-			},
-		},
-		{
-			name: "login with bad password, expect OK login response (cfg.DisableAuth=true)",
-			cfg: Config{
-				OSCARHost:   "127.0.0.1",
-				BOSPort:     1234,
-				DisableAuth: true,
-			},
-			userInDB:    userGoodPwd,
-			sessionUUID: uuid.UUID{1, 2, 3},
-			inputSNAC: oscar.SNAC_0x17_0x02_BUCPLoginRequest{
-				TLVRestBlock: oscar.TLVRestBlock{
-					TLVList: oscar.TLVList{
-						oscar.NewTLV(oscar.TLVPasswordHash, userBadPwd.PassHash),
-						oscar.NewTLV(oscar.TLVScreenName, userBadPwd.ScreenName),
-					},
-				},
-			},
-			expectSnacFrame: oscar.SnacFrame{
-				FoodGroup: oscar.BUCP,
-				SubGroup:  BUCPLoginResponse,
-			},
-			expectSNACBody: oscar.SNAC_0x17_0x03_BUCPLoginResponse{
-				TLVRestBlock: oscar.TLVRestBlock{
-					TLVList: oscar.TLVList{
-						oscar.NewTLV(oscar.TLVScreenName, userBadPwd.ScreenName),
-						oscar.NewTLV(oscar.TLVReconnectHere, "127.0.0.1:1234"),
-						oscar.NewTLV(oscar.TLVAuthorizationCookie, uuid.UUID{1, 2, 3}.String()),
-					},
-				},
-			},
-		},
-		{
-			name: "login with bad password, expect failed login response (cfg.DisableAuth=false)",
-			cfg: Config{
-				OSCARHost: "127.0.0.1",
-				BOSPort:   1234,
-			},
-			userInDB:    userGoodPwd,
-			sessionUUID: uuid.UUID{1, 2, 3},
-			inputSNAC: oscar.SNAC_0x17_0x02_BUCPLoginRequest{
-				TLVRestBlock: oscar.TLVRestBlock{
-					TLVList: oscar.TLVList{
-						oscar.NewTLV(oscar.TLVPasswordHash, userBadPwd.PassHash),
-						oscar.NewTLV(oscar.TLVScreenName, userBadPwd.ScreenName),
-					},
-				},
-			},
-			expectSnacFrame: oscar.SnacFrame{
-				FoodGroup: oscar.BUCP,
-				SubGroup:  BUCPLoginResponse,
-			},
-			expectSNACBody: oscar.SNAC_0x17_0x03_BUCPLoginResponse{
-				TLVRestBlock: oscar.TLVRestBlock{
-					TLVList: oscar.TLVList{
-						oscar.NewTLV(oscar.TLVScreenName, userBadPwd.ScreenName),
-						oscar.NewTLV(oscar.TLVErrorSubcode, uint16(0x01)),
-					},
-				},
-			},
-		},
-	}
-
-	for _, tc := range cases {
-		t.Run(tc.name, func(t *testing.T) {
-			//
-			// initialize dependencies
-			//
-			const testFile string = "aim_test.db"
-			defer func() {
-				assert.NoError(t, os.Remove(testFile))
-			}()
-			fs, err := NewSQLiteFeedbagStore(testFile)
-			if err != nil {
-				assert.NoError(t, err)
-			}
-			assert.NoError(t, fs.InsertUser(tc.userInDB))
-			sm := NewSessionManager(NewLogger(Config{}))
-			//
-			// send input SNAC
-			//
-			input := &bytes.Buffer{}
-			var seq uint32
-			assert.NoError(t, writeOutSNAC(oscar.SnacFrame{}, oscar.SnacFrame{}, tc.inputSNAC, &seq, input))
-			//
-			// receive response
-			//
-			output := &bytes.Buffer{}
-			fnNewUUID := func() uuid.UUID {
-				return tc.sessionUUID
-			}
-			assert.NoError(t, ReceiveAndSendBUCPLoginRequest(tc.cfg, sm, fs, input, output, &seq, fnNewUUID))
-			flap := oscar.FlapFrame{}
-			assert.NoError(t, oscar.Unmarshal(&flap, output))
-			//
-			// verify output SNAC frame
-			//
-			SnacFrame := oscar.SnacFrame{}
-			assert.NoError(t, oscar.Unmarshal(&SnacFrame, output))
-			assert.Equal(t, tc.expectSnacFrame, SnacFrame)
-			//
-			// verify output SNAC body
-			//
-			actual := oscar.SNAC_0x17_0x03_BUCPLoginResponse{}
-			assert.NoError(t, oscar.Unmarshal(&actual, output))
-			assert.Equal(t, tc.expectSNACBody, actual)
-			assert.Equalf(t, 0, output.Len(), "the rest of the buffer is unread")
-		})
-	}
-}
-
-func TestReceiveAndSendAuthChallenge(t *testing.T) {
-	cases := []struct {
-		name            string
-		cfg             Config
-		userInDB        User
-		fnNewUUID       uuid.UUID
-		inputSNAC       oscar.SNAC_0x17_0x06_BUCPChallengeRequest
-		expectSnacFrame oscar.SnacFrame
-		expectSNACBody  any
-	}{
-		{
-			name: "login with valid username, expect OK login response",
-			cfg: Config{
-				OSCARHost: "127.0.0.1",
-				BOSPort:   1234,
-			},
-			userInDB: User{
-				ScreenName: "sn_user_a",
-				AuthKey:    "auth_key_user_a",
-			},
-			fnNewUUID: uuid.UUID{1, 2, 3},
-			inputSNAC: oscar.SNAC_0x17_0x06_BUCPChallengeRequest{
-				TLVRestBlock: oscar.TLVRestBlock{
-					TLVList: oscar.TLVList{
-						oscar.NewTLV(oscar.TLVScreenName, "sn_user_a"),
-					},
-				},
-			},
-			expectSnacFrame: oscar.SnacFrame{
-				FoodGroup: oscar.BUCP,
-				SubGroup:  BUCPChallengeResponse,
-			},
-			expectSNACBody: oscar.SNAC_0x17_0x07_BUCPChallengeResponse{
-				AuthKey: "auth_key_user_a",
-			},
-		},
-		{
-			name: "login with invalid username, expect OK login response (cfg.DisableAuth=true)",
-			cfg: Config{
-				OSCARHost:   "127.0.0.1",
-				BOSPort:     1234,
-				DisableAuth: true,
-			},
-			userInDB: User{
-				ScreenName: "sn_user_a",
-				AuthKey:    "auth_key_user_a",
-			},
-			fnNewUUID: uuid.UUID{1, 2, 3},
-			inputSNAC: oscar.SNAC_0x17_0x06_BUCPChallengeRequest{
-				TLVRestBlock: oscar.TLVRestBlock{
-					TLVList: oscar.TLVList{
-						oscar.NewTLV(oscar.TLVScreenName, "sn_user_b"),
-					},
-				},
-			},
-			expectSnacFrame: oscar.SnacFrame{
-				FoodGroup: oscar.BUCP,
-				SubGroup:  BUCPChallengeResponse,
-			},
-			expectSNACBody: oscar.SNAC_0x17_0x07_BUCPChallengeResponse{
-				AuthKey: uuid.UUID{1, 2, 3}.String(),
-			},
-		},
-		{
-			name: "login with invalid username, expect failed login response (cfg.DisableAuth=false)",
-			cfg: Config{
-				OSCARHost: "127.0.0.1",
-				BOSPort:   1234,
-			},
-			userInDB: User{
-				ScreenName: "sn_user_a",
-				AuthKey:    "auth_key_user_a",
-			},
-			fnNewUUID: uuid.UUID{1, 2, 3},
-			inputSNAC: oscar.SNAC_0x17_0x06_BUCPChallengeRequest{
-				TLVRestBlock: oscar.TLVRestBlock{
-					TLVList: oscar.TLVList{
-						oscar.NewTLV(oscar.TLVScreenName, "sn_user_b"),
-					},
-				},
-			},
-			expectSnacFrame: oscar.SnacFrame{
-				FoodGroup: oscar.BUCP,
-				SubGroup:  BUCPLoginResponse,
-			},
-			expectSNACBody: oscar.SNAC_0x17_0x03_BUCPLoginResponse{
-				TLVRestBlock: oscar.TLVRestBlock{
-					TLVList: oscar.TLVList{
-						oscar.NewTLV(oscar.TLVErrorSubcode, uint16(0x01)),
-					},
-				},
-			},
-		},
-	}
-
-	for _, tc := range cases {
-		t.Run(tc.name, func(t *testing.T) {
-			//
-			// initialize dependencies
-			//
-			const testFile string = "aim_test.db"
-			defer func() {
-				assert.NoError(t, os.Remove(testFile))
-			}()
-			fs, err := NewSQLiteFeedbagStore(testFile)
-			if err != nil {
-				assert.NoError(t, err)
-			}
-			assert.NoError(t, fs.InsertUser(tc.userInDB))
-			//
-			// send input SNAC
-			//
-			input := &bytes.Buffer{}
-			var seq uint32
-			assert.NoError(t, writeOutSNAC(oscar.SnacFrame{}, oscar.SnacFrame{}, tc.inputSNAC, &seq, input))
-			//
-			// receive response
-			//
-			output := &bytes.Buffer{}
-			fnNewUUID := func() uuid.UUID {
-				return tc.fnNewUUID
-			}
-			assert.NoError(t, ReceiveAndSendAuthChallenge(tc.cfg, fs, input, output, &seq, fnNewUUID))
-			flap := oscar.FlapFrame{}
-			assert.NoError(t, oscar.Unmarshal(&flap, output))
-			//
-			// verify output SNAC frame
-			//
-			SnacFrame := oscar.SnacFrame{}
-			assert.NoError(t, oscar.Unmarshal(&SnacFrame, output))
-			assert.Equal(t, tc.expectSnacFrame, SnacFrame)
-			//
-			// verify output SNAC body
-			//
-			switch v := tc.expectSNACBody.(type) {
-			case oscar.SNAC_0x17_0x07_BUCPChallengeResponse:
-				actual := oscar.SNAC_0x17_0x07_BUCPChallengeResponse{}
-				assert.NoError(t, oscar.Unmarshal(&actual, output))
-				assert.Equal(t, v, actual)
-			case oscar.SNAC_0x17_0x03_BUCPLoginResponse:
-				actual := oscar.SNAC_0x17_0x03_BUCPLoginResponse{}
-				assert.NoError(t, oscar.Unmarshal(&actual, output))
-				assert.Equal(t, v, actual)
-			default:
-				t.Fatalf("unexpected output SNAC type")
-			}
-			assert.Equalf(t, 0, output.Len(), "the rest of the buffer is unread")
-		})
-	}
-}

+ 2 - 115
server/buddy.go

@@ -12,9 +12,9 @@ type BuddyHandler interface {
 	RightsQueryHandler(ctx context.Context) oscar.XMessage
 	RightsQueryHandler(ctx context.Context) oscar.XMessage
 }
 }
 
 
-func NewBuddyRouter(logger *slog.Logger) BuddyRouter {
+func NewBuddyRouter(logger *slog.Logger, buddyHandler BuddyHandler) BuddyRouter {
 	return BuddyRouter{
 	return BuddyRouter{
-		BuddyHandler: BuddyService{},
+		BuddyHandler: buddyHandler,
 		RouteLogger: RouteLogger{
 		RouteLogger: RouteLogger{
 			Logger: logger,
 			Logger: logger,
 		},
 		},
@@ -40,116 +40,3 @@ func (rt *BuddyRouter) RouteBuddy(ctx context.Context, SNACFrame oscar.SnacFrame
 		return ErrUnsupportedSubGroup
 		return ErrUnsupportedSubGroup
 	}
 	}
 }
 }
-
-type BuddyService struct {
-}
-
-func (s BuddyService) RightsQueryHandler(context.Context) oscar.XMessage {
-	return oscar.XMessage{
-		SnacFrame: oscar.SnacFrame{
-			FoodGroup: oscar.BUDDY,
-			SubGroup:  oscar.BuddyRightsReply,
-		},
-		SnacOut: oscar.SNAC_0x03_0x03_BuddyRightsReply{
-			TLVRestBlock: oscar.TLVRestBlock{
-				TLVList: oscar.TLVList{
-					oscar.NewTLV(0x01, uint16(100)),
-					oscar.NewTLV(0x02, uint16(100)),
-					oscar.NewTLV(0x03, uint16(100)),
-					oscar.NewTLV(0x04, uint16(100)),
-				},
-			},
-		},
-	}
-}
-
-func BroadcastArrival(ctx context.Context, sess *Session, sm SessionManager, fm FeedbagManager) error {
-	screenNames, err := fm.InterestedUsers(sess.ScreenName())
-	if err != nil {
-		return err
-	}
-
-	sm.BroadcastToScreenNames(ctx, screenNames, oscar.XMessage{
-		SnacFrame: oscar.SnacFrame{
-			FoodGroup: oscar.BUDDY,
-			SubGroup:  oscar.BuddyArrived,
-		},
-		SnacOut: oscar.SNAC_0x03_0x0A_BuddyArrived{
-			TLVUserInfo: oscar.TLVUserInfo{
-				ScreenName:   sess.ScreenName(),
-				WarningLevel: sess.Warning(),
-				TLVBlock: oscar.TLVBlock{
-					TLVList: sess.UserInfo(),
-				},
-			},
-		},
-	})
-
-	return nil
-}
-
-func BroadcastDeparture(ctx context.Context, sess *Session, sm SessionManager, fm FeedbagManager) error {
-	screenNames, err := fm.InterestedUsers(sess.ScreenName())
-	if err != nil {
-		return err
-	}
-
-	sm.BroadcastToScreenNames(ctx, screenNames, oscar.XMessage{
-		SnacFrame: oscar.SnacFrame{
-			FoodGroup: oscar.BUDDY,
-			SubGroup:  oscar.BuddyDeparted,
-		},
-		SnacOut: oscar.SNAC_0x03_0x0B_BuddyDeparted{
-			TLVUserInfo: oscar.TLVUserInfo{
-				ScreenName:   sess.ScreenName(),
-				WarningLevel: sess.Warning(),
-			},
-		},
-	})
-
-	return nil
-}
-
-func UnicastArrival(ctx context.Context, srcScreenName, destScreenName string, sm SessionManager) {
-	sess := sm.RetrieveByScreenName(srcScreenName)
-	switch {
-	case sess == nil:
-		fallthrough
-	case sess.Invisible(): // don't tell user this buddy is online
-		return
-	}
-	sm.SendToScreenName(ctx, destScreenName, oscar.XMessage{
-		SnacFrame: oscar.SnacFrame{
-			FoodGroup: oscar.BUDDY,
-			SubGroup:  oscar.BuddyArrived,
-		},
-		SnacOut: oscar.SNAC_0x03_0x0A_BuddyArrived{
-			TLVUserInfo: sess.TLVUserInfo(),
-		},
-	})
-}
-
-func UnicastDeparture(ctx context.Context, srcScreenName, destScreenName string, sm SessionManager) {
-	sess := sm.RetrieveByScreenName(srcScreenName)
-	switch {
-	case sess == nil:
-		fallthrough
-	case sess.Invisible(): // don't tell user this buddy is online
-		return
-	}
-
-	sm.SendToScreenName(ctx, destScreenName, oscar.XMessage{
-		SnacFrame: oscar.SnacFrame{
-			FoodGroup: oscar.BUDDY,
-			SubGroup:  oscar.BuddyDeparted,
-		},
-		SnacOut: oscar.SNAC_0x03_0x0B_BuddyDeparted{
-			TLVUserInfo: oscar.TLVUserInfo{
-				// don't include the TLV block, otherwise the AIM client fails
-				// to process the block event
-				ScreenName:   sess.ScreenName(),
-				WarningLevel: sess.Warning(),
-			},
-		},
-	})
-}

+ 1 - 1
server/buddy_mock.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.35.2. DO NOT EDIT.
+// Code generated by mockery v2.34.2. DO NOT EDIT.
 
 
 package server
 package server
 
 

+ 2 - 126
server/chat.go

@@ -15,9 +15,9 @@ type ChatHandler interface {
 	ChannelMsgToHostHandler(ctx context.Context, sess *Session, room ChatSessionManager, snacPayloadIn oscar.SNAC_0x0E_0x05_ChatChannelMsgToHost) (*oscar.XMessage, error)
 	ChannelMsgToHostHandler(ctx context.Context, sess *Session, room ChatSessionManager, snacPayloadIn oscar.SNAC_0x0E_0x05_ChatChannelMsgToHost) (*oscar.XMessage, error)
 }
 }
 
 
-func NewChatRouter(logger *slog.Logger) ChatRouter {
+func NewChatRouter(logger *slog.Logger, chatHandler ChatHandler) ChatRouter {
 	return ChatRouter{
 	return ChatRouter{
-		ChatHandler: ChatService{},
+		ChatHandler: chatHandler,
 		RouteLogger: RouteLogger{
 		RouteLogger: RouteLogger{
 			Logger: logger,
 			Logger: logger,
 		},
 		},
@@ -51,130 +51,6 @@ func (rt *ChatRouter) RouteChat(ctx context.Context, sess *Session, chatSessMgr
 	}
 	}
 }
 }
 
 
-type ChatService struct {
-}
-
-func (s ChatService) ChannelMsgToHostHandler(ctx context.Context, sess *Session, chatSessMgr ChatSessionManager, snacPayloadIn oscar.SNAC_0x0E_0x05_ChatChannelMsgToHost) (*oscar.XMessage, error) {
-	snacFrameOut := oscar.SnacFrame{
-		FoodGroup: oscar.CHAT,
-		SubGroup:  oscar.ChatChannelMsgToClient,
-	}
-	snacPayloadOut := oscar.SNAC_0x0E_0x06_ChatChannelMsgToClient{
-		Cookie:  snacPayloadIn.Cookie,
-		Channel: snacPayloadIn.Channel,
-		TLVRestBlock: oscar.TLVRestBlock{
-			TLVList: snacPayloadIn.TLVList,
-		},
-	}
-	snacPayloadOut.AddTLV(
-		oscar.NewTLV(oscar.ChatTLVSenderInformation, oscar.TLVUserInfo{
-			ScreenName:   sess.ScreenName(),
-			WarningLevel: sess.Warning(),
-			TLVBlock: oscar.TLVBlock{
-				TLVList: sess.UserInfo(),
-			},
-		}),
-	)
-
-	// send message to all the participants except sender
-	chatSessMgr.BroadcastExcept(ctx, sess, oscar.XMessage{
-		SnacFrame: snacFrameOut,
-		SnacOut:   snacPayloadOut,
-	})
-
-	var ret *oscar.XMessage
-	if _, ackMsg := snacPayloadIn.GetTLV(oscar.ChatTLVEnableReflectionFlag); ackMsg {
-		// reflect the message back to the sender
-		ret = &oscar.XMessage{
-			SnacFrame: snacFrameOut,
-			SnacOut:   snacPayloadOut,
-		}
-	}
-
-	return ret, nil
-}
-
-func SetOnlineChatUsers(ctx context.Context, sess *Session, chatSessMgr ChatSessionManager) {
-	snacPayloadOut := oscar.SNAC_0x0E_0x03_ChatUsersJoined{}
-	sessions := chatSessMgr.Participants()
-
-	for _, uSess := range sessions {
-		snacPayloadOut.Users = append(snacPayloadOut.Users, oscar.TLVUserInfo{
-			ScreenName:   uSess.ScreenName(),
-			WarningLevel: uSess.Warning(),
-			TLVBlock: oscar.TLVBlock{
-				TLVList: uSess.UserInfo(),
-			},
-		})
-	}
-
-	chatSessMgr.SendToScreenName(ctx, sess.ScreenName(), oscar.XMessage{
-		SnacFrame: oscar.SnacFrame{
-			FoodGroup: oscar.CHAT,
-			SubGroup:  oscar.ChatUsersJoined,
-		},
-		SnacOut: snacPayloadOut,
-	})
-}
-
-func AlertUserJoined(ctx context.Context, sess *Session, chatSessMgr ChatSessionManager) {
-	chatSessMgr.BroadcastExcept(ctx, sess, oscar.XMessage{
-		SnacFrame: oscar.SnacFrame{
-			FoodGroup: oscar.CHAT,
-			SubGroup:  oscar.ChatUsersJoined,
-		},
-		SnacOut: oscar.SNAC_0x0E_0x03_ChatUsersJoined{
-			Users: []oscar.TLVUserInfo{
-				{
-					ScreenName:   sess.ScreenName(),
-					WarningLevel: sess.Warning(),
-					TLVBlock: oscar.TLVBlock{
-						TLVList: sess.UserInfo(),
-					},
-				},
-			},
-		},
-	})
-}
-
-func AlertUserLeft(ctx context.Context, sess *Session, chatSessMgr ChatSessionManager) {
-	chatSessMgr.BroadcastExcept(ctx, sess, oscar.XMessage{
-		SnacFrame: oscar.SnacFrame{
-			FoodGroup: oscar.CHAT,
-			SubGroup:  oscar.ChatUsersLeft,
-		},
-		SnacOut: oscar.SNAC_0x0E_0x04_ChatUsersLeft{
-			Users: []oscar.TLVUserInfo{
-				{
-					ScreenName:   sess.ScreenName(),
-					WarningLevel: sess.Warning(),
-					TLVBlock: oscar.TLVBlock{
-						TLVList: sess.UserInfo(),
-					},
-				},
-			},
-		},
-	})
-}
-
-func SendChatRoomInfoUpdate(ctx context.Context, sess *Session, chatSessMgr ChatSessionManager, room ChatRoom) {
-	chatSessMgr.SendToScreenName(ctx, sess.ScreenName(), oscar.XMessage{
-		SnacFrame: oscar.SnacFrame{
-			FoodGroup: oscar.CHAT,
-			SubGroup:  oscar.ChatRoomInfoUpdate,
-		},
-		SnacOut: oscar.SNAC_0x0E_0x02_ChatRoomInfoUpdate{
-			Exchange:       4,
-			Cookie:         room.Cookie,
-			InstanceNumber: 100,
-			DetailLevel:    2,
-			TLVBlock: oscar.TLVBlock{
-				TLVList: room.TLVList(),
-			},
-		},
-	})
-}
-
 type ChatRoom struct {
 type ChatRoom struct {
 	CreateTime     time.Time
 	CreateTime     time.Time
 	DetailLevel    uint8
 	DetailLevel    uint8

+ 1 - 1
server/chat_mock.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.35.2. DO NOT EDIT.
+// Code generated by mockery v2.34.2. DO NOT EDIT.
 
 
 package server
 package server
 
 

+ 2 - 113
server/chat_nav.go

@@ -2,7 +2,6 @@ package server
 
 
 import (
 import (
 	"context"
 	"context"
-	"errors"
 	"github.com/google/uuid"
 	"github.com/google/uuid"
 	"github.com/mkaminski/goaim/oscar"
 	"github.com/mkaminski/goaim/oscar"
 	"io"
 	"io"
@@ -16,12 +15,9 @@ type ChatNavHandler interface {
 	RequestRoomInfoHandler(ctx context.Context, snacPayloadIn oscar.SNAC_0x0D_0x04_ChatNavRequestRoomInfo) (oscar.XMessage, error)
 	RequestRoomInfoHandler(ctx context.Context, snacPayloadIn oscar.SNAC_0x0D_0x04_ChatNavRequestRoomInfo) (oscar.XMessage, error)
 }
 }
 
 
-func NewChatNavRouter(logger *slog.Logger, cr *ChatRegistry) ChatNavRouter {
+func NewChatNavRouter(handler ChatNavHandler, logger *slog.Logger) ChatNavRouter {
 	return ChatNavRouter{
 	return ChatNavRouter{
-		ChatNavHandler: ChatNavService{
-			Logger: logger,
-			cr:     cr,
-		},
+		ChatNavHandler: handler,
 		RouteLogger: RouteLogger{
 		RouteLogger: RouteLogger{
 			Logger: logger,
 			Logger: logger,
 		},
 		},
@@ -74,114 +70,7 @@ func (rt *ChatNavRouter) RouteChatNav(ctx context.Context, sess *Session, newCha
 	}
 	}
 }
 }
 
 
-type ChatNavService struct {
-	Logger *slog.Logger
-	cr     *ChatRegistry
-}
-
 type ChatCookie struct {
 type ChatCookie struct {
 	Cookie []byte `len_prefix:"uint16"`
 	Cookie []byte `len_prefix:"uint16"`
 	SessID string `len_prefix:"uint16"`
 	SessID string `len_prefix:"uint16"`
 }
 }
-
-func (s ChatNavService) RequestChatRightsHandler(context.Context) oscar.XMessage {
-	return oscar.XMessage{
-		SnacFrame: oscar.SnacFrame{
-			FoodGroup: oscar.CHAT_NAV,
-			SubGroup:  oscar.ChatNavNavInfo,
-		},
-		SnacOut: oscar.SNAC_0x0D_0x09_ChatNavNavInfo{
-			TLVRestBlock: oscar.TLVRestBlock{
-				TLVList: oscar.TLVList{
-					oscar.NewTLV(0x02, uint8(10)),
-					oscar.NewTLV(0x03, oscar.SNAC_0x0D_0x09_TLVExchangeInfo{
-						Identifier: 4,
-						TLVBlock: oscar.TLVBlock{
-							TLVList: oscar.TLVList{
-								oscar.NewTLV(0x0002, uint16(0x0010)),
-								oscar.NewTLV(0x00c9, uint16(15)),
-								oscar.NewTLV(0x00d3, "default Exchange"),
-								oscar.NewTLV(0x00d5, uint8(2)),
-								oscar.NewTLV(0xd6, "us-ascii"),
-								oscar.NewTLV(0xd7, "en"),
-								oscar.NewTLV(0xd8, "us-ascii"),
-								oscar.NewTLV(0xd9, "en"),
-							},
-						},
-					}),
-				},
-			},
-		},
-	}
-}
-
-func (s ChatNavService) CreateRoomHandler(ctx context.Context, sess *Session, newChatRoom func() ChatRoom, newChatSessMgr func() ChatSessionManager, snacPayloadIn oscar.SNAC_0x0E_0x02_ChatRoomInfoUpdate) (oscar.XMessage, error) {
-	name, hasName := snacPayloadIn.GetString(oscar.ChatTLVRoomName)
-	if !hasName {
-		return oscar.XMessage{}, errors.New("unable to find chat name")
-	}
-
-	room := newChatRoom()
-	room.DetailLevel = snacPayloadIn.DetailLevel
-	room.Exchange = snacPayloadIn.Exchange
-	room.InstanceNumber = snacPayloadIn.InstanceNumber
-	room.Name = name
-
-	chatSessMgr := newChatSessMgr()
-
-	s.cr.Register(room, chatSessMgr)
-
-	// add user to chat room
-	chatSessMgr.NewSessionWithSN(sess.ID(), sess.ScreenName())
-
-	return oscar.XMessage{
-		SnacFrame: oscar.SnacFrame{
-			FoodGroup: oscar.CHAT_NAV,
-			SubGroup:  oscar.ChatNavNavInfo,
-		},
-		SnacOut: oscar.SNAC_0x0D_0x09_ChatNavNavInfo{
-			TLVRestBlock: oscar.TLVRestBlock{
-				TLVList: oscar.TLVList{
-					oscar.NewTLV(oscar.ChatNavTLVRoomInfo, oscar.SNAC_0x0E_0x02_ChatRoomInfoUpdate{
-						Exchange:       snacPayloadIn.Exchange,
-						Cookie:         room.Cookie,
-						InstanceNumber: snacPayloadIn.InstanceNumber,
-						DetailLevel:    snacPayloadIn.DetailLevel,
-						TLVBlock: oscar.TLVBlock{
-							TLVList: room.TLVList(),
-						},
-					}),
-				},
-			},
-		},
-	}, nil
-}
-
-func (s ChatNavService) RequestRoomInfoHandler(_ context.Context, snacPayloadIn oscar.SNAC_0x0D_0x04_ChatNavRequestRoomInfo) (oscar.XMessage, error) {
-	room, _, err := s.cr.Retrieve(string(snacPayloadIn.Cookie))
-	if err != nil {
-		return oscar.XMessage{}, err
-	}
-
-	return oscar.XMessage{
-		SnacFrame: oscar.SnacFrame{
-			FoodGroup: oscar.CHAT_NAV,
-			SubGroup:  oscar.ChatNavNavInfo,
-		},
-		SnacOut: oscar.SNAC_0x0D_0x09_ChatNavNavInfo{
-			TLVRestBlock: oscar.TLVRestBlock{
-				TLVList: oscar.TLVList{
-					oscar.NewTLV(0x04, oscar.SNAC_0x0E_0x02_ChatRoomInfoUpdate{
-						Exchange:       4,
-						Cookie:         room.Cookie,
-						InstanceNumber: 100,
-						DetailLevel:    2,
-						TLVBlock: oscar.TLVBlock{
-							TLVList: room.TLVList(),
-						},
-					}),
-				},
-			},
-		},
-	}, nil
-}

+ 1 - 1
server/chat_nav_mock.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.35.2. DO NOT EDIT.
+// Code generated by mockery v2.34.2. DO NOT EDIT.
 
 
 package server
 package server
 
 

+ 1 - 93
server/chat_nav_test.go

@@ -2,104 +2,12 @@ package server
 
 
 import (
 import (
 	"bytes"
 	"bytes"
-	"context"
-	"testing"
-	"time"
-
 	"github.com/mkaminski/goaim/oscar"
 	"github.com/mkaminski/goaim/oscar"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/mock"
 	"github.com/stretchr/testify/mock"
+	"testing"
 )
 )
 
 
-func TestSendAndReceiveCreateRoom(t *testing.T) {
-	//
-	// build dependencies
-	//
-	userSess := newTestSession("user-screen-name", sessOptCannedID)
-
-	cr := NewChatRegistry()
-
-	sm := NewMockChatSessionManager(t)
-	sm.EXPECT().NewSessionWithSN(userSess.ID(), userSess.ScreenName()).
-		Return(&Session{})
-
-	chatSessMgrFactory := func() ChatSessionManager {
-		return sm
-	}
-
-	newChatRoom := func() ChatRoom {
-		return ChatRoom{
-			Cookie:     "dummy-cookie",
-			CreateTime: time.UnixMilli(0),
-		}
-	}
-
-	//
-	// send input SNAC
-	//
-	inputSNAC := 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 := ChatNavService{
-		cr: cr,
-	}
-	outputSNAC, err := svc.CreateRoomHandler(context.Background(), userSess, newChatRoom, chatSessMgrFactory, inputSNAC)
-	assert.NoError(t, err)
-
-	//
-	// verify chat room created by handler
-	//
-	expectChatRoom := ChatRoom{
-		Cookie:         "dummy-cookie",
-		CreateTime:     time.UnixMilli(0),
-		DetailLevel:    3,
-		Exchange:       1,
-		InstanceNumber: 2,
-		Name:           "the-chat-room-name",
-	}
-	chatRoom, _, err := cr.Retrieve("dummy-cookie")
-	assert.NoError(t, err)
-	assert.Equal(t, expectChatRoom, chatRoom)
-
-	//
-	// send input SNAC
-	//
-	expectSNAC := oscar.XMessage{
-		SnacFrame: oscar.SnacFrame{
-			FoodGroup: oscar.CHAT_NAV,
-			SubGroup:  oscar.ChatNavNavInfo,
-		},
-		SnacOut: oscar.SNAC_0x0D_0x09_ChatNavNavInfo{
-			TLVRestBlock: oscar.TLVRestBlock{
-				TLVList: oscar.TLVList{
-					oscar.NewTLV(
-						oscar.ChatNavTLVRoomInfo,
-						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 TestChatNavRouter_RouteChatNavRouter(t *testing.T) {
 func TestChatNavRouter_RouteChatNavRouter(t *testing.T) {
 	cases := []struct {
 	cases := []struct {
 		// name is the unit test name
 		// name is the unit test name

+ 160 - 0
server/chat_registry_mock.go

@@ -0,0 +1,160 @@
+// Code generated by mockery v2.35.2. DO NOT EDIT.
+
+package server
+
+import mock "github.com/stretchr/testify/mock"
+
+// MockChatRegistry is an autogenerated mock type for the ChatRegistry type
+type MockChatRegistry struct {
+	mock.Mock
+}
+
+type MockChatRegistry_Expecter struct {
+	mock *mock.Mock
+}
+
+func (_m *MockChatRegistry) EXPECT() *MockChatRegistry_Expecter {
+	return &MockChatRegistry_Expecter{mock: &_m.Mock}
+}
+
+// MaybeRemoveRoom provides a mock function with given fields: chatID
+func (_m *MockChatRegistry) MaybeRemoveRoom(chatID string) {
+	_m.Called(chatID)
+}
+
+// MockChatRegistry_MaybeRemoveRoom_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'MaybeRemoveRoom'
+type MockChatRegistry_MaybeRemoveRoom_Call struct {
+	*mock.Call
+}
+
+// MaybeRemoveRoom is a helper method to define mock.On call
+//   - chatID string
+func (_e *MockChatRegistry_Expecter) MaybeRemoveRoom(chatID interface{}) *MockChatRegistry_MaybeRemoveRoom_Call {
+	return &MockChatRegistry_MaybeRemoveRoom_Call{Call: _e.mock.On("MaybeRemoveRoom", chatID)}
+}
+
+func (_c *MockChatRegistry_MaybeRemoveRoom_Call) Run(run func(chatID string)) *MockChatRegistry_MaybeRemoveRoom_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(string))
+	})
+	return _c
+}
+
+func (_c *MockChatRegistry_MaybeRemoveRoom_Call) Return() *MockChatRegistry_MaybeRemoveRoom_Call {
+	_c.Call.Return()
+	return _c
+}
+
+func (_c *MockChatRegistry_MaybeRemoveRoom_Call) RunAndReturn(run func(string)) *MockChatRegistry_MaybeRemoveRoom_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// Register provides a mock function with given fields: room, chatSessMsgr
+func (_m *MockChatRegistry) Register(room ChatRoom, chatSessMsgr ChatSessionManager) {
+	_m.Called(room, chatSessMsgr)
+}
+
+// MockChatRegistry_Register_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Register'
+type MockChatRegistry_Register_Call struct {
+	*mock.Call
+}
+
+// Register is a helper method to define mock.On call
+//   - room ChatRoom
+//   - chatSessMsgr ChatSessionManager
+func (_e *MockChatRegistry_Expecter) Register(room interface{}, chatSessMsgr interface{}) *MockChatRegistry_Register_Call {
+	return &MockChatRegistry_Register_Call{Call: _e.mock.On("Register", room, chatSessMsgr)}
+}
+
+func (_c *MockChatRegistry_Register_Call) Run(run func(room ChatRoom, chatSessMsgr ChatSessionManager)) *MockChatRegistry_Register_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(ChatRoom), args[1].(ChatSessionManager))
+	})
+	return _c
+}
+
+func (_c *MockChatRegistry_Register_Call) Return() *MockChatRegistry_Register_Call {
+	_c.Call.Return()
+	return _c
+}
+
+func (_c *MockChatRegistry_Register_Call) RunAndReturn(run func(ChatRoom, ChatSessionManager)) *MockChatRegistry_Register_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// Retrieve provides a mock function with given fields: chatID
+func (_m *MockChatRegistry) Retrieve(chatID string) (ChatRoom, ChatSessionManager, error) {
+	ret := _m.Called(chatID)
+
+	var r0 ChatRoom
+	var r1 ChatSessionManager
+	var r2 error
+	if rf, ok := ret.Get(0).(func(string) (ChatRoom, ChatSessionManager, error)); ok {
+		return rf(chatID)
+	}
+	if rf, ok := ret.Get(0).(func(string) ChatRoom); ok {
+		r0 = rf(chatID)
+	} else {
+		r0 = ret.Get(0).(ChatRoom)
+	}
+
+	if rf, ok := ret.Get(1).(func(string) ChatSessionManager); ok {
+		r1 = rf(chatID)
+	} else {
+		if ret.Get(1) != nil {
+			r1 = ret.Get(1).(ChatSessionManager)
+		}
+	}
+
+	if rf, ok := ret.Get(2).(func(string) error); ok {
+		r2 = rf(chatID)
+	} else {
+		r2 = ret.Error(2)
+	}
+
+	return r0, r1, r2
+}
+
+// MockChatRegistry_Retrieve_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Retrieve'
+type MockChatRegistry_Retrieve_Call struct {
+	*mock.Call
+}
+
+// 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)}
+}
+
+func (_c *MockChatRegistry_Retrieve_Call) Run(run func(chatID string)) *MockChatRegistry_Retrieve_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(string))
+	})
+	return _c
+}
+
+func (_c *MockChatRegistry_Retrieve_Call) Return(_a0 ChatRoom, _a1 ChatSessionManager, _a2 error) *MockChatRegistry_Retrieve_Call {
+	_c.Call.Return(_a0, _a1, _a2)
+	return _c
+}
+
+func (_c *MockChatRegistry_Retrieve_Call) RunAndReturn(run func(string) (ChatRoom, ChatSessionManager, error)) *MockChatRegistry_Retrieve_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// NewMockChatRegistry creates a new instance of MockChatRegistry. 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 NewMockChatRegistry(t interface {
+	mock.TestingT
+	Cleanup(func())
+}) *MockChatRegistry {
+	mock := &MockChatRegistry{}
+	mock.Mock.Test(t)
+
+	t.Cleanup(func() { mock.AssertExpectations(t) })
+
+	return mock
+}

+ 1 - 1
server/chat_session_manager_mock.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.35.2. DO NOT EDIT.
+// Code generated by mockery v2.34.2. DO NOT EDIT.
 
 
 package server
 package server
 
 

+ 0 - 129
server/chat_test.go

@@ -2,7 +2,6 @@ package server
 
 
 import (
 import (
 	"bytes"
 	"bytes"
-	"context"
 	"github.com/stretchr/testify/mock"
 	"github.com/stretchr/testify/mock"
 	"testing"
 	"testing"
 
 
@@ -10,134 +9,6 @@ import (
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/assert"
 )
 )
 
 
-func TestSendAndReceiveChatChannelMsgToHost(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 *Session
-		// inputSNAC is the SNAC sent by the sender client
-		inputSNAC oscar.SNAC_0x0E_0x05_ChatChannelMsgToHost
-		// expectSNACToParticipants is the message the server broadcast to chat
-		// room participants (except the sender)
-		expectSNACToParticipants oscar.XMessage
-		expectOutput             *oscar.XMessage
-	}{
-		{
-			name:        "send chat room message, expect acknowledgement to sender client",
-			userSession: newTestSession("user_sending_chat_msg", sessOptCannedSignonTime),
-			inputSNAC: oscar.SNAC_0x0E_0x05_ChatChannelMsgToHost{
-				Cookie:  1234,
-				Channel: 14,
-				TLVRestBlock: oscar.TLVRestBlock{
-					TLVList: oscar.TLVList{
-						{
-							TType: oscar.ChatTLVPublicWhisperFlag,
-							Val:   []byte{},
-						},
-						{
-							TType: oscar.ChatTLVEnableReflectionFlag,
-							Val:   []byte{},
-						},
-					},
-				},
-			},
-			expectSNACToParticipants: oscar.XMessage{
-				SnacFrame: oscar.SnacFrame{
-					FoodGroup: oscar.CHAT,
-					SubGroup:  oscar.ChatChannelMsgToClient,
-				},
-				SnacOut: oscar.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()),
-						},
-					},
-				},
-			},
-			expectOutput: &oscar.XMessage{
-				SnacFrame: oscar.SnacFrame{
-					FoodGroup: oscar.CHAT,
-					SubGroup:  oscar.ChatChannelMsgToClient,
-				},
-				SnacOut: oscar.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()),
-						},
-					},
-				},
-			},
-		},
-		{
-			name:        "send chat room message, don't expect acknowledgement to sender client",
-			userSession: newTestSession("user_sending_chat_msg", sessOptCannedSignonTime),
-			inputSNAC: oscar.SNAC_0x0E_0x05_ChatChannelMsgToHost{
-				Cookie:  1234,
-				Channel: 14,
-				TLVRestBlock: oscar.TLVRestBlock{
-					TLVList: oscar.TLVList{
-						{
-							TType: oscar.ChatTLVPublicWhisperFlag,
-							Val:   []byte{},
-						},
-					},
-				},
-			},
-			expectSNACToParticipants: oscar.XMessage{
-				SnacFrame: oscar.SnacFrame{
-					FoodGroup: oscar.CHAT,
-					SubGroup:  oscar.ChatChannelMsgToClient,
-				},
-				SnacOut: oscar.SNAC_0x0E_0x06_ChatChannelMsgToClient{
-					Cookie:  1234,
-					Channel: 14,
-					TLVRestBlock: oscar.TLVRestBlock{
-						TLVList: oscar.TLVList{
-							oscar.NewTLV(oscar.ChatTLVPublicWhisperFlag, []byte{}),
-							oscar.NewTLV(oscar.ChatTLVSenderInformation,
-								newTestSession("user_sending_chat_msg", sessOptCannedSignonTime).TLVUserInfo()),
-						},
-					},
-				},
-			},
-			expectOutput: &oscar.XMessage{},
-		},
-	}
-
-	for _, tc := range cases {
-		t.Run(tc.name, func(t *testing.T) {
-			//
-			// initialize dependencies
-			//
-			crm := NewMockChatSessionManager(t)
-			crm.EXPECT().
-				BroadcastExcept(mock.Anything, tc.userSession, tc.expectSNACToParticipants)
-			//
-			// send input SNAC
-			//
-			svc := ChatService{}
-			outputSNAC, err := svc.ChannelMsgToHostHandler(context.Background(), tc.userSession, crm, tc.inputSNAC)
-			assert.NoError(t, err)
-
-			if tc.expectOutput.SnacFrame == (oscar.SnacFrame{}) {
-				return // handler doesn't return response
-			}
-
-			assert.Equal(t, tc.expectOutput, outputSNAC)
-		})
-	}
-}
-
 func TestChatRouter_RouteChat(t *testing.T) {
 func TestChatRouter_RouteChat(t *testing.T) {
 	cases := []struct {
 	cases := []struct {
 		// name is the unit test name
 		// name is the unit test name

+ 79 - 20
server/connection.go

@@ -114,8 +114,8 @@ func dispatchIncomingMessages(ctx context.Context, sess *Session, seq uint32, rw
 	}
 	}
 }
 }
 
 
-func HandleChatConnection(ctx context.Context, cr *ChatRegistry, rw io.ReadWriter, router ChatServiceRouter, logger *slog.Logger) {
-	cookie, seq, err := VerifyChatLogin(rw)
+func HandleChatConnection(ctx context.Context, cr *ChatRegistry, rw io.ReadWriter, router ChatServiceRouter, authHandler AuthHandler, logger *slog.Logger) {
+	cookie, seq, err := authHandler.VerifyChatLogin(rw)
 	if err != nil {
 	if err != nil {
 		logger.ErrorContext(ctx, "user disconnected with error", "err", err.Error())
 		logger.ErrorContext(ctx, "user disconnected with error", "err", err.Error())
 		return
 		return
@@ -136,15 +136,15 @@ func HandleChatConnection(ctx context.Context, cr *ChatRegistry, rw io.ReadWrite
 	defer chatSess.Close()
 	defer chatSess.Close()
 	go func() {
 	go func() {
 		<-chatSess.Closed()
 		<-chatSess.Closed()
-		AlertUserLeft(ctx, chatSess, chatSessMgr)
-		chatSessMgr.Remove(chatSess)
-		cr.MaybeRemoveRoom(room.Cookie)
+		authHandler.SignoutChat(ctx, cr, room, chatSessMgr, chatSess)
 	}()
 	}()
 
 
 	ctx = context.WithValue(ctx, "screenName", chatSess.ScreenName())
 	ctx = context.WithValue(ctx, "screenName", chatSess.ScreenName())
 
 
-	if err := router.WriteOServiceHostOnline(rw, &seq); err != nil {
+	msg := router.WriteOServiceHostOnline()
+	if err := writeOutSNAC(oscar.SnacFrame{}, msg.SnacFrame, msg.SnacOut, &seq, rw); err != nil {
 		logger.ErrorContext(ctx, "error WriteOServiceHostOnline")
 		logger.ErrorContext(ctx, "error WriteOServiceHostOnline")
+		return
 	}
 	}
 
 
 	fnClientReqHandler := func(ctx context.Context, r io.Reader, w io.Writer, seq *uint32) error {
 	fnClientReqHandler := func(ctx context.Context, r io.Reader, w io.Writer, seq *uint32) error {
@@ -156,30 +156,85 @@ func HandleChatConnection(ctx context.Context, cr *ChatRegistry, rw io.ReadWrite
 	dispatchIncomingMessages(ctx, chatSess, seq, rw, logger, fnClientReqHandler, fnAlertHandler)
 	dispatchIncomingMessages(ctx, chatSess, seq, rw, logger, fnClientReqHandler, fnAlertHandler)
 }
 }
 
 
-func HandleAuthConnection(cfg Config, sm *InMemorySessionManager, fm *SQLiteFeedbagStore, conn net.Conn) {
+func HandleAuthConnection(authHandler AuthHandler, conn net.Conn) {
 	defer conn.Close()
 	defer conn.Close()
 	seq := uint32(100)
 	seq := uint32(100)
-	_, err := SendAndReceiveSignonFrame(conn, &seq)
+	_, err := authHandler.SendAndReceiveSignonFrame(conn, &seq)
 	if err != nil {
 	if err != nil {
 		log.Println(err)
 		log.Println(err)
 		return
 		return
 	}
 	}
 
 
-	err = ReceiveAndSendAuthChallenge(cfg, fm, conn, conn, &seq, uuid.New)
+	flap := oscar.FlapFrame{}
+	if err := oscar.Unmarshal(&flap, conn); err != nil {
+		log.Println(err)
+		return
+	}
+	b := make([]byte, flap.PayloadLength)
+	if _, err := conn.Read(b); err != nil {
+		log.Println(err)
+		return
+	}
+	snac := oscar.SnacFrame{}
+	buf := bytes.NewBuffer(b)
+	if err := oscar.Unmarshal(&snac, buf); err != nil {
+		log.Println(err)
+		return
+	}
+
+	snacPayloadIn := oscar.SNAC_0x17_0x06_BUCPChallengeRequest{}
+	if err := oscar.Unmarshal(&snacPayloadIn, buf); err != nil {
+		log.Println(err)
+		return
+	}
+
+	msg, err := authHandler.ReceiveAndSendAuthChallenge(snacPayloadIn, uuid.New)
 	if err != nil {
 	if err != nil {
 		log.Println(err)
 		log.Println(err)
 		return
 		return
 	}
 	}
+	if err := writeOutSNAC(oscar.SnacFrame{}, msg.SnacFrame, msg.SnacOut, &seq, conn); err != nil {
+		log.Println(err)
+		return
+	}
 
 
-	err = ReceiveAndSendBUCPLoginRequest(cfg, sm, fm, conn, conn, &seq, uuid.New)
+	flap = oscar.FlapFrame{}
+	if err := oscar.Unmarshal(&flap, conn); err != nil {
+		log.Println(err)
+		return
+	}
+	snac = oscar.SnacFrame{}
+	b = make([]byte, flap.PayloadLength)
+	if _, err := conn.Read(b); err != nil {
+		log.Println(err)
+		return
+	}
+	buf = bytes.NewBuffer(b)
+	if err := oscar.Unmarshal(&snac, buf); err != nil {
+		log.Println(err)
+		return
+	}
+
+	snacPayloadIn2 := oscar.SNAC_0x17_0x02_BUCPLoginRequest{}
+	if err := oscar.Unmarshal(&snacPayloadIn2, buf); err != nil {
+		log.Println(err)
+		return
+	}
+
+	msg, err = authHandler.ReceiveAndSendBUCPLoginRequest(snacPayloadIn2, uuid.New)
 	if err != nil {
 	if err != nil {
 		log.Println(err)
 		log.Println(err)
 		return
 		return
 	}
 	}
+	if err := writeOutSNAC(oscar.SnacFrame{}, msg.SnacFrame, msg.SnacOut, &seq, conn); err != nil {
+		log.Println(err)
+		return
+	}
 }
 }
 
 
-func HandleBOSConnection(ctx context.Context, conn net.Conn, router BOSServiceRouter, logger *slog.Logger) {
-	sess, seq, err := router.VerifyLogin(conn)
+func HandleBOSConnection(ctx context.Context, conn net.Conn, router BOSServiceRouter, authHandler AuthHandler, logger *slog.Logger) {
+	// todo why is conn net.Conn but handleChat is rw?
+	sess, seq, err := authHandler.VerifyLogin(conn)
 	if err != nil {
 	if err != nil {
 		logger.ErrorContext(ctx, "user disconnected with error", "err", err.Error())
 		logger.ErrorContext(ctx, "user disconnected with error", "err", err.Error())
 		return
 		return
@@ -190,13 +245,17 @@ func HandleBOSConnection(ctx context.Context, conn net.Conn, router BOSServiceRo
 
 
 	go func() {
 	go func() {
 		<-sess.Closed()
 		<-sess.Closed()
-		router.Signout(ctx, logger, sess)
+		if err := authHandler.Signout(ctx, sess); err != nil {
+			logger.ErrorContext(ctx, "error notifying departure", "err", err.Error())
+		}
 	}()
 	}()
 
 
 	ctx = context.WithValue(ctx, "screenName", sess.ScreenName())
 	ctx = context.WithValue(ctx, "screenName", sess.ScreenName())
 
 
-	if err := router.WriteOServiceHostOnline(conn, &seq); err != nil {
+	msg := router.WriteOServiceHostOnline()
+	if err := writeOutSNAC(oscar.SnacFrame{}, msg.SnacFrame, msg.SnacOut, &seq, conn); err != nil {
 		logger.ErrorContext(ctx, "error WriteOServiceHostOnline")
 		logger.ErrorContext(ctx, "error WriteOServiceHostOnline")
+		return
 	}
 	}
 
 
 	fnClientReqHandler := func(ctx context.Context, r io.Reader, w io.Writer, seq *uint32) error {
 	fnClientReqHandler := func(ctx context.Context, r io.Reader, w io.Writer, seq *uint32) error {
@@ -208,7 +267,7 @@ func HandleBOSConnection(ctx context.Context, conn net.Conn, router BOSServiceRo
 	dispatchIncomingMessages(ctx, sess, seq, conn, logger, fnClientReqHandler, fnAlertHandler)
 	dispatchIncomingMessages(ctx, sess, seq, conn, logger, fnClientReqHandler, fnAlertHandler)
 }
 }
 
 
-func ListenChat(cfg Config, router ChatServiceRouter, cr *ChatRegistry, logger *slog.Logger) {
+func ListenChat(cfg Config, router ChatServiceRouter, cr *ChatRegistry, authHandler AuthHandler, logger *slog.Logger) {
 	addr := Address("", cfg.ChatPort)
 	addr := Address("", cfg.ChatPort)
 	listener, err := net.Listen("tcp", addr)
 	listener, err := net.Listen("tcp", addr)
 	if err != nil {
 	if err != nil {
@@ -229,13 +288,13 @@ func ListenChat(cfg Config, router ChatServiceRouter, cr *ChatRegistry, logger *
 		ctx = context.WithValue(ctx, "ip", conn.RemoteAddr().String())
 		ctx = context.WithValue(ctx, "ip", conn.RemoteAddr().String())
 		logger.DebugContext(ctx, "accepted connection")
 		logger.DebugContext(ctx, "accepted connection")
 		go func() {
 		go func() {
-			HandleChatConnection(ctx, cr, conn, router, logger)
+			HandleChatConnection(ctx, cr, conn, router, authHandler, logger)
 			conn.Close()
 			conn.Close()
 		}()
 		}()
 	}
 	}
 }
 }
 
 
-func ListenBOS(cfg Config, router BOSServiceRouter, logger *slog.Logger) {
+func ListenBOS(cfg Config, router BOSServiceRouter, authHandler AuthHandler, logger *slog.Logger) {
 	addr := Address("", cfg.BOSPort)
 	addr := Address("", cfg.BOSPort)
 	listener, err := net.Listen("tcp", addr)
 	listener, err := net.Listen("tcp", addr)
 	if err != nil {
 	if err != nil {
@@ -255,11 +314,11 @@ func ListenBOS(cfg Config, router BOSServiceRouter, logger *slog.Logger) {
 		ctx := context.Background()
 		ctx := context.Background()
 		ctx = context.WithValue(ctx, "ip", conn.RemoteAddr().String())
 		ctx = context.WithValue(ctx, "ip", conn.RemoteAddr().String())
 		logger.DebugContext(ctx, "accepted connection")
 		logger.DebugContext(ctx, "accepted connection")
-		go HandleBOSConnection(ctx, conn, router, logger)
+		go HandleBOSConnection(ctx, conn, router, authHandler, logger)
 	}
 	}
 }
 }
 
 
-func ListenBUCPLogin(cfg Config, err error, logger *slog.Logger, sm *InMemorySessionManager, fm *SQLiteFeedbagStore) {
+func ListenBUCPLogin(cfg Config, err error, logger *slog.Logger, authHandler AuthHandler) {
 	addr := Address("", cfg.OSCARPort)
 	addr := Address("", cfg.OSCARPort)
 	listener, err := net.Listen("tcp", addr)
 	listener, err := net.Listen("tcp", addr)
 	if err != nil {
 	if err != nil {
@@ -277,6 +336,6 @@ func ListenBUCPLogin(cfg Config, err error, logger *slog.Logger, sm *InMemorySes
 			continue
 			continue
 		}
 		}
 
 
-		go HandleAuthConnection(cfg, sm, fm, conn)
+		go HandleAuthConnection(authHandler, conn)
 	}
 	}
 }
 }

+ 3 - 233
server/feedbag.go

@@ -2,11 +2,9 @@ package server
 
 
 import (
 import (
 	"context"
 	"context"
+	"github.com/mkaminski/goaim/oscar"
 	"io"
 	"io"
 	"log/slog"
 	"log/slog"
-	"time"
-
-	"github.com/mkaminski/goaim/oscar"
 )
 )
 
 
 type FeedbagHandler interface {
 type FeedbagHandler interface {
@@ -19,12 +17,9 @@ type FeedbagHandler interface {
 	UpdateItemHandler(ctx context.Context, sess *Session, snacPayloadIn oscar.SNAC_0x13_0x09_FeedbagUpdateItem) (oscar.XMessage, error)
 	UpdateItemHandler(ctx context.Context, sess *Session, snacPayloadIn oscar.SNAC_0x13_0x09_FeedbagUpdateItem) (oscar.XMessage, error)
 }
 }
 
 
-func NewFeedbagRouter(logger *slog.Logger, sm SessionManager, fm FeedbagManager) FeedbagRouter {
+func NewFeedbagRouter(logger *slog.Logger, handler FeedbagHandler) FeedbagRouter {
 	return FeedbagRouter{
 	return FeedbagRouter{
-		FeedbagHandler: FeedbagService{
-			sm: sm,
-			fm: fm,
-		},
+		FeedbagHandler: handler,
 		RouteLogger: RouteLogger{
 		RouteLogger: RouteLogger{
 			Logger: logger,
 			Logger: logger,
 		},
 		},
@@ -115,228 +110,3 @@ func (rt FeedbagRouter) RouteFeedbag(ctx context.Context, sess *Session, SNACFra
 		return ErrUnsupportedSubGroup
 		return ErrUnsupportedSubGroup
 	}
 	}
 }
 }
-
-type FeedbagService struct {
-	sm SessionManager
-	fm FeedbagManager
-}
-
-func (s FeedbagService) RightsQueryHandler(context.Context) oscar.XMessage {
-	return oscar.XMessage{
-		SnacFrame: oscar.SnacFrame{
-			FoodGroup: oscar.FEEDBAG,
-			SubGroup:  oscar.FeedbagRightsReply,
-		},
-		SnacOut: oscar.SNAC_0x13_0x03_FeedbagRightsReply{
-			TLVRestBlock: oscar.TLVRestBlock{
-				TLVList: oscar.TLVList{
-					oscar.NewTLV(0x03, uint16(200)),
-					oscar.NewTLV(0x04, []uint16{
-						0x3D,
-						0x3D,
-						0x64,
-						0x64,
-						0x01,
-						0x01,
-						0x32,
-						0x00,
-						0x00,
-						0x03,
-						0x00,
-						0x00,
-						0x00,
-						0x80,
-						0xFF,
-						0x14,
-						0xC8,
-						0x01,
-						0x00,
-						0x01,
-						0x00,
-					}),
-					oscar.NewTLV(0x05, uint16(200)),
-					oscar.NewTLV(0x06, uint16(200)),
-					oscar.NewTLV(0x07, uint16(200)),
-					oscar.NewTLV(0x08, uint16(200)),
-					oscar.NewTLV(0x09, uint16(200)),
-					oscar.NewTLV(0x0A, uint16(200)),
-					oscar.NewTLV(0x0C, uint16(200)),
-					oscar.NewTLV(0x0D, uint16(200)),
-					oscar.NewTLV(0x0E, uint16(100)),
-				},
-			},
-		},
-	}
-}
-
-func (s FeedbagService) QueryHandler(_ context.Context, sess *Session) (oscar.XMessage, error) {
-	fb, err := s.fm.Retrieve(sess.ScreenName())
-	if err != nil {
-		return oscar.XMessage{}, err
-	}
-
-	lm := time.UnixMilli(0)
-
-	if len(fb) > 0 {
-		lm, err = s.fm.LastModified(sess.ScreenName())
-		if err != nil {
-			return oscar.XMessage{}, err
-		}
-	}
-
-	return oscar.XMessage{
-		SnacFrame: oscar.SnacFrame{
-			FoodGroup: oscar.FEEDBAG,
-			SubGroup:  oscar.FeedbagReply,
-		},
-		SnacOut: oscar.SNAC_0x13_0x06_FeedbagReply{
-			Version:    0,
-			Items:      fb,
-			LastUpdate: uint32(lm.Unix()),
-		},
-	}, nil
-}
-
-func (s FeedbagService) QueryIfModifiedHandler(_ context.Context, sess *Session, snacPayloadIn oscar.SNAC_0x13_0x05_FeedbagQueryIfModified) (oscar.XMessage, error) {
-	fb, err := s.fm.Retrieve(sess.ScreenName())
-	if err != nil {
-		return oscar.XMessage{}, err
-	}
-
-	lm := time.UnixMilli(0)
-
-	if len(fb) > 0 {
-		lm, err = s.fm.LastModified(sess.ScreenName())
-		if err != nil {
-			return oscar.XMessage{}, err
-		}
-		if lm.Before(time.Unix(int64(snacPayloadIn.LastUpdate), 0)) {
-			return oscar.XMessage{
-				SnacFrame: oscar.SnacFrame{
-					FoodGroup: oscar.FEEDBAG,
-					SubGroup:  oscar.FeedbagReplyNotModified,
-				},
-				SnacOut: oscar.SNAC_0x13_0x05_FeedbagQueryIfModified{
-					LastUpdate: uint32(lm.Unix()),
-					Count:      uint8(len(fb)),
-				},
-			}, nil
-		}
-	}
-
-	return oscar.XMessage{
-		SnacFrame: oscar.SnacFrame{
-			FoodGroup: oscar.FEEDBAG,
-			SubGroup:  oscar.FeedbagReply,
-		},
-		SnacOut: oscar.SNAC_0x13_0x06_FeedbagReply{
-			Version:    0,
-			Items:      fb,
-			LastUpdate: uint32(lm.Unix()),
-		},
-	}, nil
-}
-
-func (s FeedbagService) InsertItemHandler(ctx context.Context, sess *Session, snacPayloadIn oscar.SNAC_0x13_0x08_FeedbagInsertItem) (oscar.XMessage, error) {
-	for _, item := range snacPayloadIn.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.XMessage{
-				SnacFrame: oscar.SnacFrame{
-					FoodGroup: oscar.FEEDBAG,
-					SubGroup:  oscar.FeedbagErr,
-				},
-				SnacOut: oscar.SnacError{
-					Code: oscar.ErrorCodeNotSupportedByHost,
-				},
-			}, nil
-		}
-	}
-
-	if err := s.fm.Upsert(sess.ScreenName(), snacPayloadIn.Items); err != nil {
-		return oscar.XMessage{}, nil
-	}
-
-	for _, item := range snacPayloadIn.Items {
-		switch item.ClassID {
-		case oscar.FeedbagClassIdBuddy, oscar.FeedbagClassIDPermit: // add new buddy
-			UnicastArrival(ctx, item.Name, sess.ScreenName(), s.sm)
-		case oscar.FeedbagClassIDDeny: // block buddy
-			// notify this user that buddy is offline
-			UnicastDeparture(ctx, item.Name, sess.ScreenName(), s.sm)
-			// notify former buddy that this user is offline
-			UnicastDeparture(ctx, sess.ScreenName(), item.Name, s.sm)
-		}
-	}
-
-	snacPayloadOut := oscar.SNAC_0x13_0x0E_FeedbagStatus{}
-	for range snacPayloadIn.Items {
-		snacPayloadOut.Results = append(snacPayloadOut.Results, 0x0000)
-	}
-
-	return oscar.XMessage{
-		SnacFrame: oscar.SnacFrame{
-			FoodGroup: oscar.FEEDBAG,
-			SubGroup:  oscar.FeedbagStatus,
-		},
-		SnacOut: snacPayloadOut,
-	}, nil
-}
-
-func (s FeedbagService) UpdateItemHandler(ctx context.Context, sess *Session, snacPayloadIn oscar.SNAC_0x13_0x09_FeedbagUpdateItem) (oscar.XMessage, error) {
-	if err := s.fm.Upsert(sess.ScreenName(), snacPayloadIn.Items); err != nil {
-		return oscar.XMessage{}, nil
-	}
-
-	for _, item := range snacPayloadIn.Items {
-		switch item.ClassID {
-		case oscar.FeedbagClassIdBuddy, oscar.FeedbagClassIDPermit:
-			UnicastArrival(ctx, item.Name, sess.ScreenName(), s.sm)
-		}
-	}
-
-	snacPayloadOut := oscar.SNAC_0x13_0x0E_FeedbagStatus{}
-	for range snacPayloadIn.Items {
-		snacPayloadOut.Results = append(snacPayloadOut.Results, 0x0000)
-	}
-
-	return oscar.XMessage{
-		SnacFrame: oscar.SnacFrame{
-			FoodGroup: oscar.FEEDBAG,
-			SubGroup:  oscar.FeedbagStatus,
-		},
-		SnacOut: snacPayloadOut,
-	}, nil
-}
-
-func (s FeedbagService) DeleteItemHandler(ctx context.Context, sess *Session, snacPayloadIn oscar.SNAC_0x13_0x0A_FeedbagDeleteItem) (oscar.XMessage, error) {
-	if err := s.fm.Delete(sess.ScreenName(), snacPayloadIn.Items); err != nil {
-		return oscar.XMessage{}, err
-	}
-
-	for _, item := range snacPayloadIn.Items {
-		if item.ClassID == oscar.FeedbagClassIDDeny {
-			UnicastArrival(ctx, item.Name, sess.ScreenName(), s.sm)
-			UnicastArrival(ctx, sess.ScreenName(), item.Name, s.sm)
-		}
-	}
-
-	snacPayloadOut := oscar.SNAC_0x13_0x0E_FeedbagStatus{}
-	for range snacPayloadIn.Items {
-		snacPayloadOut.Results = append(snacPayloadOut.Results, 0x0000) // success by default
-	}
-
-	return oscar.XMessage{
-		SnacFrame: oscar.SnacFrame{
-			FoodGroup: oscar.FEEDBAG,
-			SubGroup:  oscar.FeedbagStatus,
-		},
-		SnacOut: 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.SNAC_0x13_0x11_FeedbagStartCluster) {
-}

+ 1 - 1
server/feedbag_manager_mock.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.35.2. DO NOT EDIT.
+// Code generated by mockery v2.34.2. DO NOT EDIT.
 
 
 package server
 package server
 
 

+ 1 - 1
server/feedbag_mock.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.35.2. DO NOT EDIT.
+// Code generated by mockery v2.34.2. DO NOT EDIT.
 
 
 package server
 package server
 
 

+ 0 - 16
server/feedbag_store.go

@@ -8,7 +8,6 @@ import (
 	"io"
 	"io"
 	"time"
 	"time"
 
 
-	"github.com/google/uuid"
 	_ "github.com/mattn/go-sqlite3"
 	_ "github.com/mattn/go-sqlite3"
 	"github.com/mkaminski/goaim/oscar"
 	"github.com/mkaminski/goaim/oscar"
 )
 )
@@ -57,21 +56,6 @@ func NewSQLiteFeedbagStore(dbFile string) (*SQLiteFeedbagStore, error) {
 	return &SQLiteFeedbagStore{db: db}, nil
 	return &SQLiteFeedbagStore{db: db}, nil
 }
 }
 
 
-func NewStubUser(screenName string) (User, error) {
-	u := User{ScreenName: screenName}
-
-	uid, err := uuid.NewRandom()
-	if err != nil {
-		return u, err
-	}
-	u.AuthKey = uid.String()
-
-	if err := u.HashPassword("welcome1"); err != nil {
-		return u, err
-	}
-	return u, u.HashPassword("welcome1")
-}
-
 type User struct {
 type User struct {
 	ScreenName string `json:"screen_name"`
 	ScreenName string `json:"screen_name"`
 	AuthKey    string `json:"-"`
 	AuthKey    string `json:"-"`

+ 0 - 563
server/feedbag_test.go

@@ -6,571 +6,8 @@ import (
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/mock"
 	"github.com/stretchr/testify/mock"
 	"testing"
 	"testing"
-	"time"
 )
 )
 
 
-func TestQueryHandler(t *testing.T) {
-	cases := []struct {
-		// name is the unit test name
-		name string
-		// screenName is the buddy list owner
-		screenName string
-		// feedbagItems is the list of items in user's buddy list
-		feedbagItems []oscar.FeedbagItem
-		// lastModified is the time the buddy list was last changed
-		lastModified time.Time
-		// expectOutput is the SNAC payload sent from the server to the
-		// recipient client
-		expectOutput oscar.XMessage
-	}{
-		{
-			name:         "retrieve empty feedbag",
-			screenName:   "user_screen_name",
-			feedbagItems: []oscar.FeedbagItem{},
-			lastModified: time.UnixMilli(0),
-			expectOutput: oscar.XMessage{
-				SnacFrame: oscar.SnacFrame{
-					FoodGroup: oscar.FEEDBAG,
-					SubGroup:  oscar.FeedbagReply,
-				},
-				SnacOut: oscar.SNAC_0x13_0x06_FeedbagReply{
-					Items: []oscar.FeedbagItem{},
-				},
-			},
-		},
-		{
-			name:       "retrieve feedbag with items",
-			screenName: "user_screen_name",
-			feedbagItems: []oscar.FeedbagItem{
-				{
-					Name: "buddy_1",
-				},
-				{
-					Name: "buddy_2",
-				},
-			},
-			lastModified: time.UnixMilli(1696472198082),
-			expectOutput: oscar.XMessage{
-				SnacFrame: oscar.SnacFrame{
-					FoodGroup: oscar.FEEDBAG,
-					SubGroup:  oscar.FeedbagReply,
-				},
-				SnacOut: oscar.SNAC_0x13_0x06_FeedbagReply{
-					Version: 0,
-					Items: []oscar.FeedbagItem{
-						{
-							Name: "buddy_1",
-						},
-						{
-							Name: "buddy_2",
-						},
-					},
-					LastUpdate: uint32(time.UnixMilli(1696472198082).Unix()),
-				},
-			},
-		},
-	}
-
-	for _, tc := range cases {
-		t.Run(tc.name, func(t *testing.T) {
-			//
-			// initialize dependencies
-			//
-			fm := NewMockFeedbagManager(t)
-			fm.EXPECT().
-				Retrieve(tc.screenName).
-				Return(tc.feedbagItems, nil).
-				Maybe()
-			fm.EXPECT().
-				LastModified(tc.screenName).
-				Return(tc.lastModified, nil).
-				Maybe()
-			//
-			// send input SNAC
-			//
-			senderSession := newTestSession(tc.screenName)
-			svc := FeedbagService{
-				fm: fm,
-			}
-			outputSNAC, err := svc.QueryHandler(nil, senderSession)
-			assert.NoError(t, err)
-			assert.Equal(t, tc.expectOutput, outputSNAC)
-		})
-	}
-}
-
-func TestQueryIfModifiedHandler(t *testing.T) {
-	cases := []struct {
-		// name is the unit test name
-		name string
-		// screenName is the buddy list owner
-		screenName string
-		// feedbagItems is the list of items in user's buddy list
-		feedbagItems []oscar.FeedbagItem
-		// lastModified is the time the buddy list was last changed
-		lastModified time.Time
-		// inputSNAC is the SNAC sent by the sender client
-		inputSNAC oscar.SNAC_0x13_0x05_FeedbagQueryIfModified
-		// expectOutput is the SNAC payload sent from the server to the
-		// recipient client
-		expectOutput oscar.XMessage
-	}{
-		{
-			name:         "retrieve empty feedbag",
-			screenName:   "user_screen_name",
-			feedbagItems: []oscar.FeedbagItem{},
-			lastModified: time.UnixMilli(0),
-			inputSNAC: oscar.SNAC_0x13_0x05_FeedbagQueryIfModified{
-				LastUpdate: uint32(time.UnixMilli(100000).Unix()),
-			},
-			expectOutput: oscar.XMessage{
-				SnacFrame: oscar.SnacFrame{
-					FoodGroup: oscar.FEEDBAG,
-					SubGroup:  oscar.FeedbagReply,
-				},
-				SnacOut: oscar.SNAC_0x13_0x06_FeedbagReply{
-					Items: []oscar.FeedbagItem{},
-				},
-			},
-		},
-		{
-			name:       "retrieve feedbag with items",
-			screenName: "user_screen_name",
-			feedbagItems: []oscar.FeedbagItem{
-				{
-					Name: "buddy_1",
-				},
-				{
-					Name: "buddy_2",
-				},
-			},
-			lastModified: time.UnixMilli(200000),
-			inputSNAC: oscar.SNAC_0x13_0x05_FeedbagQueryIfModified{
-				LastUpdate: uint32(time.UnixMilli(100000).Unix()),
-			},
-			expectOutput: oscar.XMessage{
-				SnacFrame: oscar.SnacFrame{
-					FoodGroup: oscar.FEEDBAG,
-					SubGroup:  oscar.FeedbagReply,
-				},
-				SnacOut: oscar.SNAC_0x13_0x06_FeedbagReply{
-					Version: 0,
-					Items: []oscar.FeedbagItem{
-						{
-							Name: "buddy_1",
-						},
-						{
-							Name: "buddy_2",
-						},
-					},
-					LastUpdate: uint32(time.UnixMilli(200000).Unix()),
-				},
-			},
-		},
-		{
-			name:       "retrieve not-modified response",
-			screenName: "user_screen_name",
-			feedbagItems: []oscar.FeedbagItem{
-				{
-					Name: "buddy_1",
-				},
-				{
-					Name: "buddy_2",
-				},
-			},
-			lastModified: time.UnixMilli(100000),
-			inputSNAC: oscar.SNAC_0x13_0x05_FeedbagQueryIfModified{
-				LastUpdate: uint32(time.UnixMilli(200000).Unix()),
-			},
-			expectOutput: oscar.XMessage{
-				SnacFrame: oscar.SnacFrame{
-					FoodGroup: oscar.FEEDBAG,
-					SubGroup:  oscar.FeedbagReplyNotModified,
-				},
-				SnacOut: oscar.SNAC_0x13_0x05_FeedbagQueryIfModified{
-					LastUpdate: uint32(time.UnixMilli(100000).Unix()),
-					Count:      2,
-				},
-			},
-		},
-	}
-
-	for _, tc := range cases {
-		t.Run(tc.name, func(t *testing.T) {
-			//
-			// initialize dependencies
-			//
-			fm := NewMockFeedbagManager(t)
-			fm.EXPECT().
-				Retrieve(tc.screenName).
-				Return(tc.feedbagItems, nil).
-				Maybe()
-			fm.EXPECT().
-				LastModified(tc.screenName).
-				Return(tc.lastModified, nil).
-				Maybe()
-			//
-			// send input SNAC
-			//
-			senderSession := newTestSession(tc.screenName)
-			svc := FeedbagService{
-				fm: fm,
-			}
-			outputSNAC, err := svc.QueryIfModifiedHandler(nil, senderSession, tc.inputSNAC)
-			assert.NoError(t, err)
-			//
-			// verify output
-			//
-			assert.Equal(t, tc.expectOutput, outputSNAC)
-		})
-	}
-}
-
-func TestInsertItemHandler(t *testing.T) {
-	cases := []struct {
-		// name is the unit test name
-		name string
-		// userSession is the session of the user managing buddy list
-		userSession *Session
-		// feedbagItems is the list of items in user's buddy list
-		feedbagItems []oscar.FeedbagItem
-		// inputSNAC is the SNAC sent by the sender client
-		inputSNAC oscar.SNAC_0x13_0x08_FeedbagInsertItem
-		// screenNameLookups is the list of user's online buddies
-		screenNameLookups map[string]struct {
-			sess *Session
-			err  error
-		}
-		// clientResponse is the message returned to the client
-		clientResponse oscar.XMessage
-		// buddyMessages are events forwarded to buddy clients
-		buddyMessages []struct {
-			user string
-			msg  oscar.XMessage
-		}
-	}{
-		{
-			name:        "user adds 2 online buddies, expect OK response",
-			userSession: newTestSession("user_screen_name"),
-			inputSNAC: oscar.SNAC_0x13_0x08_FeedbagInsertItem{
-				Items: []oscar.FeedbagItem{
-					{
-						ClassID: 2,
-						Name:    "buddy_1_online",
-					},
-					{
-						ClassID: 2,
-						Name:    "buddy_2_online",
-					},
-				},
-			},
-			screenNameLookups: map[string]struct {
-				sess *Session
-				err  error
-			}{
-				"user_screen_name": {
-					sess: newTestSession("user_screen_name", sessOptCannedSignonTime),
-				},
-				"buddy_1_online": {
-					sess: newTestSession("buddy_1_online", sessOptCannedSignonTime),
-				},
-				"buddy_2_online": {
-					sess: newTestSession("buddy_2_online", sessOptCannedSignonTime),
-				},
-			},
-			clientResponse: oscar.XMessage{
-				SnacFrame: oscar.SnacFrame{
-					FoodGroup: oscar.FEEDBAG,
-					SubGroup:  oscar.FeedbagStatus,
-				},
-				SnacOut: oscar.SNAC_0x13_0x0E_FeedbagStatus{
-					Results: []uint16{0x0000, 0x0000},
-				},
-			},
-			buddyMessages: []struct {
-				user string
-				msg  oscar.XMessage
-			}{
-				{
-					user: "user_screen_name",
-					msg: oscar.XMessage{
-						SnacFrame: oscar.SnacFrame{
-							FoodGroup: oscar.BUDDY,
-							SubGroup:  oscar.BuddyArrived,
-						},
-						SnacOut: oscar.SNAC_0x03_0x0A_BuddyArrived{
-							TLVUserInfo: oscar.TLVUserInfo{
-								ScreenName: "buddy_1_online",
-								TLVBlock: oscar.TLVBlock{
-									TLVList: newTestSession("", sessOptCannedSignonTime).UserInfo(),
-								},
-							},
-						},
-					},
-				},
-				{
-					user: "user_screen_name",
-					msg: oscar.XMessage{
-						SnacFrame: oscar.SnacFrame{
-							FoodGroup: oscar.BUDDY,
-							SubGroup:  oscar.BuddyArrived,
-						},
-						SnacOut: oscar.SNAC_0x03_0x0A_BuddyArrived{
-							TLVUserInfo: oscar.TLVUserInfo{
-								ScreenName: "buddy_2_online",
-								TLVBlock: oscar.TLVBlock{
-									TLVList: newTestSession("", sessOptCannedSignonTime).UserInfo(),
-								},
-							},
-						},
-					},
-				},
-			},
-		},
-		{
-			name:        "user adds an offline buddy, expect OK response and 0 buddy arrived events",
-			userSession: newTestSession("user_screen_name"),
-			inputSNAC: oscar.SNAC_0x13_0x08_FeedbagInsertItem{
-				Items: []oscar.FeedbagItem{
-					{
-						ClassID: 2,
-						Name:    "buddy_offline",
-					},
-				},
-			},
-			screenNameLookups: map[string]struct {
-				sess *Session
-				err  error
-			}{
-				"buddy_offline": {
-					sess: nil,
-				},
-			},
-			clientResponse: oscar.XMessage{
-				SnacFrame: oscar.SnacFrame{
-					FoodGroup: oscar.FEEDBAG,
-					SubGroup:  oscar.FeedbagStatus,
-				},
-				SnacOut: oscar.SNAC_0x13_0x0E_FeedbagStatus{
-					Results: []uint16{0x0000},
-				},
-			},
-		},
-		{
-			name:        "users adds an invisible buddy, expect OK response and 0 buddy arrived events",
-			userSession: newTestSession("user_screen_name"),
-			inputSNAC: oscar.SNAC_0x13_0x08_FeedbagInsertItem{
-				Items: []oscar.FeedbagItem{
-					{
-						ClassID: 2,
-						Name:    "invisible_buddy_online",
-					},
-				},
-			},
-			screenNameLookups: map[string]struct {
-				sess *Session
-				err  error
-			}{
-				"invisible_buddy_online": {
-					sess: newTestSession("invisible_buddy_online", sessOptInvisible),
-				},
-			},
-			clientResponse: oscar.XMessage{
-				SnacFrame: oscar.SnacFrame{
-					FoodGroup: oscar.FEEDBAG,
-					SubGroup:  oscar.FeedbagStatus,
-				},
-				SnacOut: oscar.SNAC_0x13_0x0E_FeedbagStatus{
-					Results: []uint16{0x0000},
-				},
-			},
-		},
-		{
-			name: "user blocks buddy currently online, expect OK response, buddy departed event client, 1 buddy " +
-				"departed event sent to buddy",
-			userSession: newTestSession("user_screen_name"),
-			inputSNAC: oscar.SNAC_0x13_0x08_FeedbagInsertItem{
-				Items: []oscar.FeedbagItem{
-					{
-						ClassID: 3,
-						Name:    "buddy_1",
-					},
-				},
-			},
-			screenNameLookups: map[string]struct {
-				sess *Session
-				err  error
-			}{
-				"user_screen_name": {
-					sess: newTestSession("user_screen_name"),
-				},
-				"buddy_1": {
-					sess: newTestSession("buddy_1"),
-				},
-			},
-			buddyMessages: []struct {
-				user string
-				msg  oscar.XMessage
-			}{
-				{
-					user: "buddy_1",
-					msg: oscar.XMessage{
-						SnacFrame: oscar.SnacFrame{
-							FoodGroup: oscar.BUDDY,
-							SubGroup:  oscar.BuddyDeparted,
-						},
-						SnacOut: oscar.SNAC_0x03_0x0B_BuddyDeparted{
-							TLVUserInfo: oscar.TLVUserInfo{
-								ScreenName:   "user_screen_name",
-								WarningLevel: 0,
-							},
-						},
-					},
-				},
-				{
-					user: "user_screen_name",
-					msg: oscar.XMessage{
-						SnacFrame: oscar.SnacFrame{
-							FoodGroup: oscar.BUDDY,
-							SubGroup:  oscar.BuddyDeparted,
-						},
-						SnacOut: oscar.SNAC_0x03_0x0B_BuddyDeparted{
-							TLVUserInfo: oscar.TLVUserInfo{
-								ScreenName:   "buddy_1",
-								WarningLevel: 0,
-							},
-						},
-					},
-				},
-			},
-			clientResponse: oscar.XMessage{
-				SnacFrame: oscar.SnacFrame{
-					FoodGroup: oscar.FEEDBAG,
-					SubGroup:  oscar.FeedbagStatus,
-				},
-				SnacOut: oscar.SNAC_0x13_0x0E_FeedbagStatus{
-					Results: []uint16{0x0000},
-				},
-			},
-		},
-		{
-			name:        "user blocks buddy currently offline, expect OK response and a superfluous buddy departed events",
-			userSession: newTestSession("user_screen_name"),
-			inputSNAC: oscar.SNAC_0x13_0x08_FeedbagInsertItem{
-				Items: []oscar.FeedbagItem{
-					{
-						ClassID: 3,
-						Name:    "buddy_1",
-					},
-				},
-			},
-			screenNameLookups: map[string]struct {
-				sess *Session
-				err  error
-			}{
-				"user_screen_name": {
-					sess: newTestSession("user_screen_name"),
-				},
-				"buddy_1": {
-					sess: nil,
-				},
-			},
-			clientResponse: oscar.XMessage{
-				SnacFrame: oscar.SnacFrame{
-					FoodGroup: oscar.FEEDBAG,
-					SubGroup:  oscar.FeedbagStatus,
-				},
-				SnacOut: oscar.SNAC_0x13_0x0E_FeedbagStatus{
-					Results: []uint16{0x0000},
-				},
-			},
-			buddyMessages: []struct {
-				user string
-				msg  oscar.XMessage
-			}{
-				{
-					user: "buddy_1",
-					msg: oscar.XMessage{
-						SnacFrame: oscar.SnacFrame{
-							FoodGroup: oscar.BUDDY,
-							SubGroup:  oscar.BuddyDeparted,
-						},
-						SnacOut: oscar.SNAC_0x03_0x0B_BuddyDeparted{
-							TLVUserInfo: oscar.TLVUserInfo{
-								ScreenName:   "user_screen_name",
-								WarningLevel: 0,
-							},
-						},
-					},
-				},
-			},
-		},
-		{
-			name:        "user tries to block themselves, expect feedback error",
-			userSession: newTestSession("user_screen_name"),
-			inputSNAC: oscar.SNAC_0x13_0x08_FeedbagInsertItem{
-				Items: []oscar.FeedbagItem{
-					{
-						ClassID: 3,
-						Name:    "user_screen_name",
-					},
-				},
-			},
-			clientResponse: oscar.XMessage{
-				SnacFrame: oscar.SnacFrame{
-					FoodGroup: oscar.FEEDBAG,
-					SubGroup:  oscar.FeedbagErr,
-				},
-				SnacOut: oscar.SnacError{
-					Code: oscar.ErrorCodeNotSupportedByHost,
-				},
-			},
-		},
-	}
-
-	for _, tc := range cases {
-		t.Run(tc.name, func(t *testing.T) {
-			//
-			// initialize dependencies
-			//
-			fm := NewMockFeedbagManager(t)
-			fm.EXPECT().
-				Upsert(tc.userSession.ScreenName(), tc.inputSNAC.Items).
-				Return(nil).
-				Maybe()
-			fm.EXPECT().
-				Buddies(tc.userSession.ScreenName()).
-				Return([]string{}, nil).
-				Maybe()
-			sm := NewMockSessionManager(t)
-			for screenName, val := range tc.screenNameLookups {
-				sm.EXPECT().
-					RetrieveByScreenName(screenName).
-					Return(val.sess).
-					Maybe()
-			}
-			for _, n := range tc.buddyMessages {
-				sm.EXPECT().
-					SendToScreenName(mock.Anything, n.user, n.msg).
-					Maybe()
-			}
-			//
-			// send input SNAC
-			//
-			svc := FeedbagService{
-				fm: fm,
-				sm: sm,
-			}
-			output, err := svc.InsertItemHandler(nil, tc.userSession, tc.inputSNAC)
-			assert.NoError(t, err)
-			//
-			// verify response
-			//
-			assert.Equal(t, output, tc.clientResponse)
-		})
-	}
-}
-
 func TestFeedbagRouter_RouteFeedbag(t *testing.T) {
 func TestFeedbagRouter_RouteFeedbag(t *testing.T) {
 	cases := []struct {
 	cases := []struct {
 		// name is the unit test name
 		// name is the unit test name

+ 2 - 221
server/icbm.go

@@ -8,11 +8,6 @@ import (
 	"github.com/mkaminski/goaim/oscar"
 	"github.com/mkaminski/goaim/oscar"
 )
 )
 
 
-const (
-	evilDelta     = uint16(100)
-	evilDeltaAnon = uint16(30)
-)
-
 type ICBMHandler interface {
 type ICBMHandler interface {
 	ChannelMsgToHostHandler(ctx context.Context, sess *Session, snacPayloadIn oscar.SNAC_0x04_0x06_ICBMChannelMsgToHost) (*oscar.XMessage, error)
 	ChannelMsgToHostHandler(ctx context.Context, sess *Session, snacPayloadIn oscar.SNAC_0x04_0x06_ICBMChannelMsgToHost) (*oscar.XMessage, error)
 	ClientEventHandler(ctx context.Context, sess *Session, snacPayloadIn oscar.SNAC_0x04_0x14_ICBMClientEvent) error
 	ClientEventHandler(ctx context.Context, sess *Session, snacPayloadIn oscar.SNAC_0x04_0x14_ICBMClientEvent) error
@@ -20,12 +15,9 @@ type ICBMHandler interface {
 	ParameterQueryHandler(context.Context) oscar.XMessage
 	ParameterQueryHandler(context.Context) oscar.XMessage
 }
 }
 
 
-func NewICBMRouter(logger *slog.Logger, sm SessionManager, fm FeedbagManager) ICBMRouter {
+func NewICBMRouter(logger *slog.Logger, handler ICBMHandler) ICBMRouter {
 	return ICBMRouter{
 	return ICBMRouter{
-		ICBMHandler: ICBMService{
-			sm: sm,
-			fm: fm,
-		},
+		ICBMHandler: handler,
 		RouteLogger: RouteLogger{
 		RouteLogger: RouteLogger{
 			Logger: logger,
 			Logger: logger,
 		},
 		},
@@ -85,214 +77,3 @@ func (rt *ICBMRouter) RouteICBM(ctx context.Context, sess *Session, SNACFrame os
 		return ErrUnsupportedSubGroup
 		return ErrUnsupportedSubGroup
 	}
 	}
 }
 }
-
-type ICBMService struct {
-	sm SessionManager
-	fm FeedbagManager
-}
-
-func (s ICBMService) ParameterQueryHandler(context.Context) oscar.XMessage {
-	return oscar.XMessage{
-		SnacFrame: oscar.SnacFrame{
-			FoodGroup: oscar.ICBM,
-			SubGroup:  oscar.ICBMParameterReply,
-		},
-		SnacOut: oscar.SNAC_0x04_0x05_ICBMParameterReply{
-			MaxSlots:             100,
-			ICBMFlags:            3,
-			MaxIncomingICBMLen:   512,
-			MaxSourceEvil:        999,
-			MaxDestinationEvil:   999,
-			MinInterICBMInterval: 0,
-		},
-	}
-}
-
-func (s ICBMService) ChannelMsgToHostHandler(ctx context.Context, sess *Session, snacPayloadIn oscar.SNAC_0x04_0x06_ICBMChannelMsgToHost) (*oscar.XMessage, error) {
-	blocked, err := s.fm.Blocked(sess.ScreenName(), snacPayloadIn.ScreenName)
-	if err != nil {
-		return nil, err
-	}
-
-	if blocked != BlockedNo {
-		code := oscar.ErrorCodeNotLoggedOn
-		if blocked == BlockedA {
-			code = oscar.ErrorCodeInLocalPermitDeny
-		}
-		return &oscar.XMessage{
-			SnacFrame: oscar.SnacFrame{
-				FoodGroup: oscar.ICBM,
-				SubGroup:  oscar.ICBMErr,
-			},
-			SnacOut: oscar.SnacError{
-				Code: code,
-			},
-		}, nil
-	}
-
-	recipSess := s.sm.RetrieveByScreenName(snacPayloadIn.ScreenName)
-	if recipSess == nil {
-		return &oscar.XMessage{
-			SnacFrame: oscar.SnacFrame{
-				FoodGroup: oscar.ICBM,
-				SubGroup:  oscar.ICBMErr,
-			},
-			SnacOut: oscar.SnacError{
-				Code: oscar.ErrorCodeNotLoggedOn,
-			},
-		}, nil
-	}
-
-	clientIM := oscar.SNAC_0x04_0x07_ICBMChannelMsgToClient{
-		Cookie:    snacPayloadIn.Cookie,
-		ChannelID: snacPayloadIn.ChannelID,
-		TLVUserInfo: oscar.TLVUserInfo{
-			ScreenName:   sess.ScreenName(),
-			WarningLevel: sess.Warning(),
-		},
-		TLVRestBlock: oscar.TLVRestBlock{
-			TLVList: oscar.TLVList{
-				{
-					TType: 0x0B,
-					Val:   []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.AddTLVList(snacPayloadIn.TLVRestBlock.TLVList)
-
-	s.sm.SendToScreenName(ctx, recipSess.ScreenName(), oscar.XMessage{
-		SnacFrame: oscar.SnacFrame{
-			FoodGroup: oscar.ICBM,
-			SubGroup:  oscar.ICBMChannelMsgToclient,
-		},
-		SnacOut: clientIM,
-	})
-
-	if _, requestedConfirmation := snacPayloadIn.TLVRestBlock.GetSlice(oscar.ICBMTLVTagRequestHostAck); !requestedConfirmation {
-		// don't ack message
-		return nil, nil
-	}
-
-	// ack message back to sender
-	return &oscar.XMessage{
-		SnacFrame: oscar.SnacFrame{
-			FoodGroup: oscar.ICBM,
-			SubGroup:  oscar.ICBMHostAck,
-		},
-		SnacOut: oscar.SNAC_0x04_0x0C_ICBMHostAck{
-			Cookie:     snacPayloadIn.Cookie,
-			ChannelID:  snacPayloadIn.ChannelID,
-			ScreenName: snacPayloadIn.ScreenName,
-		},
-	}, nil
-}
-
-func (s ICBMService) ClientEventHandler(ctx context.Context, sess *Session, snacPayloadIn oscar.SNAC_0x04_0x14_ICBMClientEvent) error {
-	blocked, err := s.fm.Blocked(sess.ScreenName(), snacPayloadIn.ScreenName)
-
-	switch {
-	case err != nil:
-		return err
-	case blocked != BlockedNo:
-		return nil
-	default:
-		s.sm.SendToScreenName(ctx, snacPayloadIn.ScreenName, oscar.XMessage{
-			SnacFrame: oscar.SnacFrame{
-				FoodGroup: oscar.ICBM,
-				SubGroup:  oscar.ICBMClientEvent,
-			},
-			SnacOut: oscar.SNAC_0x04_0x14_ICBMClientEvent{
-				Cookie:     snacPayloadIn.Cookie,
-				ChannelID:  snacPayloadIn.ChannelID,
-				ScreenName: sess.ScreenName(),
-				Event:      snacPayloadIn.Event,
-			},
-		})
-		return nil
-	}
-}
-
-func (s ICBMService) EvilRequestHandler(ctx context.Context, sess *Session, snacPayloadIn oscar.SNAC_0x04_0x08_ICBMEvilRequest) (oscar.XMessage, error) {
-	// don't let users warn themselves, it causes the AIM client to go into a
-	// weird state.
-	if snacPayloadIn.ScreenName == sess.ScreenName() {
-		return oscar.XMessage{
-			SnacFrame: oscar.SnacFrame{
-				FoodGroup: oscar.ICBM,
-				SubGroup:  oscar.ICBMErr,
-			},
-			SnacOut: oscar.SnacError{
-				Code: oscar.ErrorCodeNotSupportedByHost,
-			},
-		}, nil
-	}
-
-	blocked, err := s.fm.Blocked(sess.ScreenName(), snacPayloadIn.ScreenName)
-	if err != nil {
-		return oscar.XMessage{}, nil
-	}
-	if blocked != BlockedNo {
-		return oscar.XMessage{
-			SnacFrame: oscar.SnacFrame{
-				FoodGroup: oscar.ICBM,
-				SubGroup:  oscar.ICBMErr,
-			},
-			SnacOut: oscar.SnacError{
-				Code: oscar.ErrorCodeNotLoggedOn,
-			},
-		}, nil
-	}
-
-	recipSess := s.sm.RetrieveByScreenName(snacPayloadIn.ScreenName)
-	if recipSess == nil {
-		return oscar.XMessage{}, nil
-	}
-
-	increase := evilDelta
-	if snacPayloadIn.SendAs == 1 {
-		increase = evilDeltaAnon
-	}
-	recipSess.IncreaseWarning(increase)
-
-	var notif any
-	if snacPayloadIn.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.sm.SendToScreenName(ctx, recipSess.ScreenName(), oscar.XMessage{
-		SnacFrame: oscar.SnacFrame{
-			FoodGroup: oscar.OSERVICE,
-			SubGroup:  oscar.OServiceEvilNotification,
-		},
-		SnacOut: notif,
-	})
-
-	if err := BroadcastArrival(ctx, recipSess, s.sm, s.fm); err != nil {
-		return oscar.XMessage{}, nil
-	}
-
-	return oscar.XMessage{
-		SnacFrame: oscar.SnacFrame{
-			FoodGroup: oscar.ICBM,
-			SubGroup:  oscar.ICBMEvilReply,
-		},
-		SnacOut: oscar.SNAC_0x04_0x09_ICBMEvilReply{
-			EvilDeltaApplied: increase,
-			UpdatedEvilValue: recipSess.Warning(),
-		},
-	}, nil
-}

+ 1 - 1
server/icbm_mock.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.35.2. DO NOT EDIT.
+// Code generated by mockery v2.34.2. DO NOT EDIT.
 
 
 package server
 package server
 
 

+ 0 - 515
server/icbm_test.go

@@ -9,521 +9,6 @@ import (
 	"github.com/stretchr/testify/mock"
 	"github.com/stretchr/testify/mock"
 )
 )
 
 
-func TestSendAndReceiveChannelMsgTohost(t *testing.T) {
-	cases := []struct {
-		// name is the unit test name
-		name string
-		// blockedState is the response to the sender/recipient block check
-		blockedState BlockedState
-		// recipRetrieveErr is the error returned by the recipient session
-		// lookup
-		recipRetrieveErr error
-		senderSession    *Session
-		recipientSession *Session
-		// inputSNAC is the SNAC sent by the sender client
-		inputSNAC oscar.SNAC_0x04_0x06_ICBMChannelMsgToHost
-		// expectSNACToClient is the SNAC sent from the server to the
-		// recipient client
-		expectSNACToClient oscar.XMessage
-		// inputSNAC is the SNAC frame sent from the server to the recipient
-		// client
-		expectOutput *oscar.XMessage
-	}{
-		{
-			name:             "transmit message from sender to recipient, ack message back to sender",
-			blockedState:     BlockedNo,
-			senderSession:    newTestSession("sender-screen-name", sessOptWarning(10)),
-			recipientSession: newTestSession("recipient-screen-name", sessOptWarning(20)),
-			inputSNAC: oscar.SNAC_0x04_0x06_ICBMChannelMsgToHost{
-				ScreenName: "recipient-screen-name",
-				TLVRestBlock: oscar.TLVRestBlock{
-					TLVList: oscar.TLVList{
-						{
-							TType: oscar.ICBMTLVTagRequestHostAck,
-							Val:   []byte{},
-						},
-					},
-				},
-			},
-			expectSNACToClient: oscar.XMessage{
-				SnacFrame: oscar.SnacFrame{
-					FoodGroup: oscar.ICBM,
-					SubGroup:  oscar.ICBMChannelMsgToclient,
-				},
-				SnacOut: oscar.SNAC_0x04_0x07_ICBMChannelMsgToClient{
-					TLVUserInfo: oscar.TLVUserInfo{
-						ScreenName:   "sender-screen-name",
-						WarningLevel: 10,
-					},
-					TLVRestBlock: oscar.TLVRestBlock{
-						TLVList: oscar.TLVList{
-							{
-								TType: oscar.ICBMTLVTagsWantEvents,
-								Val:   []byte{},
-							},
-							{
-								TType: oscar.ICBMTLVTagRequestHostAck,
-								Val:   []byte{},
-							},
-						},
-					},
-				},
-			},
-			expectOutput: &oscar.XMessage{
-				SnacFrame: oscar.SnacFrame{
-					FoodGroup: oscar.ICBM,
-					SubGroup:  oscar.ICBMHostAck,
-				},
-				SnacOut: oscar.SNAC_0x04_0x0C_ICBMHostAck{
-					ScreenName: "recipient-screen-name",
-				},
-			},
-		},
-		{
-			name:             "transmit message from sender to recipient, don't ack message back to sender",
-			blockedState:     BlockedNo,
-			senderSession:    newTestSession("sender-screen-name", sessOptWarning(10)),
-			recipientSession: newTestSession("recipient-screen-name", sessOptWarning(20)),
-			inputSNAC: oscar.SNAC_0x04_0x06_ICBMChannelMsgToHost{
-				ScreenName: "recipient-screen-name",
-				TLVRestBlock: oscar.TLVRestBlock{
-					TLVList: oscar.TLVList{},
-				},
-			},
-			expectSNACToClient: oscar.XMessage{
-				SnacFrame: oscar.SnacFrame{
-					FoodGroup: oscar.ICBM,
-					SubGroup:  oscar.ICBMChannelMsgToclient,
-				},
-				SnacOut: oscar.SNAC_0x04_0x07_ICBMChannelMsgToClient{
-					TLVUserInfo: oscar.TLVUserInfo{
-						ScreenName:   "sender-screen-name",
-						WarningLevel: 10,
-					},
-					TLVRestBlock: oscar.TLVRestBlock{
-						TLVList: oscar.TLVList{
-							{
-								TType: oscar.ICBMTLVTagsWantEvents,
-								Val:   []byte{},
-							},
-						},
-					},
-				},
-			},
-			expectOutput: nil,
-		},
-		{
-			name:             "don't transmit message from sender to recipient because sender has blocked recipient",
-			blockedState:     BlockedA,
-			senderSession:    newTestSession("sender-screen-name", sessOptWarning(10)),
-			recipientSession: newTestSession("recipient-screen-name", sessOptWarning(20)),
-			inputSNAC: oscar.SNAC_0x04_0x06_ICBMChannelMsgToHost{
-				ScreenName: "recipient-screen-name",
-				TLVRestBlock: oscar.TLVRestBlock{
-					TLVList: oscar.TLVList{
-						{
-							TType: oscar.ICBMTLVTagRequestHostAck,
-							Val:   []byte{},
-						},
-					},
-				},
-			},
-			expectOutput: &oscar.XMessage{
-				SnacFrame: oscar.SnacFrame{
-					FoodGroup: oscar.ICBM,
-					SubGroup:  oscar.ICBMErr,
-				},
-				SnacOut: oscar.SnacError{
-					Code: oscar.ErrorCodeInLocalPermitDeny,
-				},
-			},
-		},
-		{
-			name:             "don't transmit message from sender to recipient because recipient has blocked sender",
-			blockedState:     BlockedB,
-			senderSession:    newTestSession("sender-screen-name", sessOptWarning(10)),
-			recipientSession: newTestSession("recipient-screen-name", sessOptWarning(20)),
-			inputSNAC: oscar.SNAC_0x04_0x06_ICBMChannelMsgToHost{
-				ScreenName: "recipient-screen-name",
-				TLVRestBlock: oscar.TLVRestBlock{
-					TLVList: oscar.TLVList{
-						{
-							TType: oscar.ICBMTLVTagRequestHostAck,
-							Val:   []byte{},
-						},
-					},
-				},
-			},
-			expectOutput: &oscar.XMessage{
-				SnacFrame: oscar.SnacFrame{
-					FoodGroup: oscar.ICBM,
-					SubGroup:  oscar.ICBMErr,
-				},
-				SnacOut: oscar.SnacError{
-					Code: oscar.ErrorCodeNotLoggedOn,
-				},
-			},
-		},
-		{
-			name:             "don't transmit message from sender to recipient because recipient doesn't exist",
-			blockedState:     BlockedNo,
-			senderSession:    newTestSession("sender-screen-name", sessOptWarning(10)),
-			recipientSession: nil,
-			inputSNAC: oscar.SNAC_0x04_0x06_ICBMChannelMsgToHost{
-				ScreenName: "recipient-screen-name",
-				TLVRestBlock: oscar.TLVRestBlock{
-					TLVList: oscar.TLVList{
-						{
-							TType: oscar.ICBMTLVTagRequestHostAck,
-							Val:   []byte{},
-						},
-					},
-				},
-			},
-			expectOutput: &oscar.XMessage{
-				SnacFrame: oscar.SnacFrame{
-					FoodGroup: oscar.ICBM,
-					SubGroup:  oscar.ICBMErr,
-				},
-				SnacOut: oscar.SnacError{
-					Code: oscar.ErrorCodeNotLoggedOn,
-				},
-			},
-		},
-	}
-
-	for _, tc := range cases {
-		t.Run(tc.name, func(t *testing.T) {
-			//
-			// initialize dependencies
-			//
-			fm := NewMockFeedbagManager(t)
-			fm.EXPECT().
-				Blocked(tc.senderSession.ScreenName(), tc.inputSNAC.ScreenName).
-				Return(tc.blockedState, nil).
-				Maybe()
-			sm := NewMockSessionManager(t)
-			sm.EXPECT().
-				RetrieveByScreenName(tc.inputSNAC.ScreenName).
-				Return(tc.recipientSession).
-				Maybe()
-			if tc.recipientSession != nil {
-				sm.EXPECT().
-					SendToScreenName(mock.Anything, tc.recipientSession.ScreenName(), tc.expectSNACToClient).
-					Maybe()
-			}
-			//
-			// send input SNAC
-			//
-			svc := ICBMService{
-				sm: sm,
-				fm: fm,
-			}
-			outputSNAC, err := svc.ChannelMsgToHostHandler(nil, tc.senderSession, tc.inputSNAC)
-			assert.NoError(t, err)
-			//
-			// verify output
-			//
-			assert.Equal(t, tc.expectOutput, outputSNAC)
-		})
-	}
-}
-
-func TestSendAndReceiveClientEvent(t *testing.T) {
-	cases := []struct {
-		// name is the unit test name
-		name string
-		// blockedState is the response to the sender/recipient block check
-		blockedState BlockedState
-		// senderScreenName is the screen name of the user sending the event
-		senderScreenName string
-		// inputSNAC is the SNAC sent by the sender client
-		inputSNAC oscar.SNAC_0x04_0x14_ICBMClientEvent
-		// expectSNACToClient is the SNAC sent from the server to the
-		// recipient client
-		expectSNACToClient oscar.XMessage
-	}{
-		{
-			name:             "transmit message from sender to recipient",
-			blockedState:     BlockedNo,
-			senderScreenName: "sender-screen-name",
-			inputSNAC: oscar.SNAC_0x04_0x14_ICBMClientEvent{
-				Cookie:     [8]byte{1, 2, 3, 4, 5, 6, 7, 8},
-				ChannelID:  42,
-				ScreenName: "recipient-screen-name",
-				Event:      12,
-			},
-			expectSNACToClient: oscar.XMessage{
-				SnacFrame: oscar.SnacFrame{
-					FoodGroup: oscar.ICBM,
-					SubGroup:  oscar.ICBMClientEvent,
-				},
-				SnacOut: oscar.SNAC_0x04_0x14_ICBMClientEvent{
-					Cookie:     [8]byte{1, 2, 3, 4, 5, 6, 7, 8},
-					ChannelID:  42,
-					ScreenName: "sender-screen-name",
-					Event:      12,
-				},
-			},
-		},
-		{
-			name:             "don't transmit message from sender to recipient because sender has blocked recipient",
-			blockedState:     BlockedA,
-			senderScreenName: "sender-screen-name",
-			inputSNAC: oscar.SNAC_0x04_0x14_ICBMClientEvent{
-				ScreenName: "recipient-screen-name",
-			},
-		},
-	}
-
-	for _, tc := range cases {
-		t.Run(tc.name, func(t *testing.T) {
-			//
-			// initialize dependencies
-			//
-			fm := NewMockFeedbagManager(t)
-			fm.EXPECT().
-				Blocked(tc.senderScreenName, tc.inputSNAC.ScreenName).
-				Return(tc.blockedState, nil).
-				Maybe()
-			sm := NewMockSessionManager(t)
-			if tc.blockedState == BlockedNo {
-				sm.EXPECT().
-					SendToScreenName(mock.Anything, tc.inputSNAC.ScreenName, tc.expectSNACToClient)
-			}
-			//
-			// send input SNAC
-			//
-			senderSession := newTestSession(tc.senderScreenName)
-			svc := ICBMService{
-				sm: sm,
-				fm: fm,
-			}
-			assert.NoError(t, svc.ClientEventHandler(nil, senderSession, tc.inputSNAC))
-		})
-	}
-}
-
-func TestSendAndReceiveEvilRequest(t *testing.T) {
-	cases := []struct {
-		// name is the unit test name
-		name string
-		// blockedState is the response to the sender/recipient block check
-		blockedState BlockedState
-		// recipRetrieveErr is the error returned by the recipient session
-		// lookup
-		recipRetrieveErr error
-		// senderScreenName is the session name of the user sending the IM
-		senderSession *Session
-		// 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.XMessage
-		// inputSNAC is the SNAC sent by the sender client
-		inputSNAC oscar.SNAC_0x04_0x08_ICBMEvilRequest
-		// expectSNACToClient is the SNAC sent from the server to the
-		// recipient client
-		expectSNACToClient oscar.XMessage
-
-		expectOutput oscar.XMessage
-	}{
-		{
-			name:                "transmit anonymous warning from sender to recipient",
-			blockedState:        BlockedNo,
-			senderSession:       newTestSession("sender-screen-name"),
-			recipientScreenName: "recipient-screen-name",
-			broadcastMessage: oscar.XMessage{
-				SnacFrame: oscar.SnacFrame{
-					FoodGroup: oscar.BUDDY,
-					SubGroup:  oscar.BuddyArrived,
-				},
-				SnacOut: oscar.SNAC_0x03_0x0A_BuddyArrived{
-					TLVUserInfo: oscar.TLVUserInfo{
-						ScreenName:   "recipient-screen-name",
-						WarningLevel: evilDeltaAnon,
-						TLVBlock: oscar.TLVBlock{
-							TLVList: newTestSession("", sessOptCannedSignonTime).UserInfo(),
-						},
-					},
-				},
-			},
-			recipientBuddies: []string{"buddy1", "buddy2"},
-			inputSNAC: oscar.SNAC_0x04_0x08_ICBMEvilRequest{
-				SendAs:     1, // make it anonymous
-				ScreenName: "recipient-screen-name",
-			},
-			expectSNACToClient: oscar.XMessage{
-				SnacFrame: oscar.SnacFrame{
-					FoodGroup: oscar.OSERVICE,
-					SubGroup:  oscar.OServiceEvilNotification,
-				},
-				SnacOut: oscar.SNAC_0x01_0x10_OServiceEvilNotificationAnon{
-					NewEvil: evilDeltaAnon,
-				},
-			},
-			expectOutput: oscar.XMessage{
-				SnacFrame: oscar.SnacFrame{
-					FoodGroup: oscar.ICBM,
-					SubGroup:  oscar.ICBMEvilReply,
-				},
-				SnacOut: oscar.SNAC_0x04_0x09_ICBMEvilReply{
-					EvilDeltaApplied: 30,
-					UpdatedEvilValue: 30,
-				},
-			},
-		},
-		{
-			name:                "transmit non-anonymous warning from sender to recipient",
-			blockedState:        BlockedNo,
-			senderSession:       newTestSession("sender-screen-name"),
-			recipientScreenName: "recipient-screen-name",
-			recipientBuddies:    []string{"buddy1", "buddy2"},
-			broadcastMessage: oscar.XMessage{
-				SnacFrame: oscar.SnacFrame{
-					FoodGroup: oscar.BUDDY,
-					SubGroup:  oscar.BuddyArrived,
-				},
-				SnacOut: oscar.SNAC_0x03_0x0A_BuddyArrived{
-					TLVUserInfo: oscar.TLVUserInfo{
-						ScreenName:   "recipient-screen-name",
-						WarningLevel: evilDelta,
-						TLVBlock: oscar.TLVBlock{
-							TLVList: newTestSession("", sessOptCannedSignonTime).UserInfo(),
-						},
-					},
-				},
-			},
-			inputSNAC: oscar.SNAC_0x04_0x08_ICBMEvilRequest{
-				SendAs:     0, // make it identified
-				ScreenName: "recipient-screen-name",
-			},
-			expectSNACToClient: oscar.XMessage{
-				SnacFrame: oscar.SnacFrame{
-					FoodGroup: oscar.OSERVICE,
-					SubGroup:  oscar.OServiceEvilNotification,
-				},
-				SnacOut: oscar.SNAC_0x01_0x10_OServiceEvilNotification{
-					NewEvil: evilDelta,
-					TLVUserInfo: oscar.TLVUserInfo{
-						ScreenName:   "sender-screen-name",
-						WarningLevel: 100,
-					},
-				},
-			},
-			expectOutput: oscar.XMessage{
-				SnacFrame: oscar.SnacFrame{
-					FoodGroup: oscar.ICBM,
-					SubGroup:  oscar.ICBMEvilReply,
-				},
-				SnacOut: oscar.SNAC_0x04_0x09_ICBMEvilReply{
-					EvilDeltaApplied: 100,
-					UpdatedEvilValue: 100,
-				},
-			},
-		},
-		{
-			name:                "don't transmit non-anonymous warning from sender to recipient because sender has blocked recipient",
-			blockedState:        BlockedA,
-			senderSession:       newTestSession("sender-screen-name"),
-			recipientScreenName: "recipient-screen-name",
-			recipientBuddies:    []string{"buddy1", "buddy2"},
-			inputSNAC: oscar.SNAC_0x04_0x08_ICBMEvilRequest{
-				SendAs:     0, // make it identified
-				ScreenName: "recipient-screen-name",
-			},
-			expectOutput: oscar.XMessage{
-				SnacFrame: oscar.SnacFrame{
-					FoodGroup: oscar.ICBM,
-					SubGroup:  oscar.ICBMErr,
-				},
-				SnacOut: oscar.SnacError{
-					Code: oscar.ErrorCodeNotLoggedOn,
-				},
-			},
-		},
-		{
-			name:                "don't transmit non-anonymous warning from sender to recipient because recipient has blocked sender",
-			blockedState:        BlockedB,
-			senderSession:       newTestSession("sender-screen-name"),
-			recipientScreenName: "recipient-screen-name",
-			recipientBuddies:    []string{"buddy1", "buddy2"},
-			inputSNAC: oscar.SNAC_0x04_0x08_ICBMEvilRequest{
-				SendAs:     0, // make it identified
-				ScreenName: "recipient-screen-name",
-			},
-			expectOutput: oscar.XMessage{
-				SnacFrame: oscar.SnacFrame{
-					FoodGroup: oscar.ICBM,
-					SubGroup:  oscar.ICBMErr,
-				},
-				SnacOut: oscar.SnacError{
-					Code: oscar.ErrorCodeNotLoggedOn,
-				},
-			},
-		},
-		{
-			name:                "don't let users block themselves",
-			senderSession:       newTestSession("sender-screen-name"),
-			recipientScreenName: "sender-screen-name",
-			inputSNAC: oscar.SNAC_0x04_0x08_ICBMEvilRequest{
-				SendAs:     0, // make it identified
-				ScreenName: "sender-screen-name",
-			},
-			expectOutput: oscar.XMessage{
-				SnacFrame: oscar.SnacFrame{
-					FoodGroup: oscar.ICBM,
-					SubGroup:  oscar.ICBMErr,
-				},
-				SnacOut: oscar.SnacError{
-					Code: oscar.ErrorCodeNotSupportedByHost,
-				},
-			},
-		},
-	}
-
-	for _, tc := range cases {
-		t.Run(tc.name, func(t *testing.T) {
-			//
-			// initialize dependencies
-			//
-			fm := NewMockFeedbagManager(t)
-			fm.EXPECT().
-				Blocked(tc.senderSession.ScreenName(), tc.recipientScreenName).
-				Return(tc.blockedState, nil).
-				Maybe()
-			fm.EXPECT().
-				InterestedUsers(tc.recipientScreenName).
-				Return(tc.recipientBuddies, nil).
-				Maybe()
-			recipSess := newTestSession(tc.recipientScreenName, sessOptCannedSignonTime)
-			sm := NewMockSessionManager(t)
-			sm.EXPECT().
-				RetrieveByScreenName(tc.recipientScreenName).
-				Return(recipSess).
-				Maybe()
-			sm.EXPECT().
-				SendToScreenName(mock.Anything, tc.recipientScreenName, tc.expectSNACToClient).
-				Maybe()
-			sm.EXPECT().
-				BroadcastToScreenNames(mock.Anything, tc.recipientBuddies, tc.broadcastMessage).
-				Maybe()
-			//
-			// send input SNAC
-			//
-			senderSession := newTestSession(tc.senderSession.ScreenName())
-			svc := ICBMService{
-				sm: sm,
-				fm: fm,
-			}
-			outputSNAC, err := svc.EvilRequestHandler(nil, senderSession, tc.inputSNAC)
-			assert.NoError(t, err)
-			assert.Equal(t, tc.expectOutput, outputSNAC)
-		})
-	}
-}
-
 func TestICBMRouter_RouteICBM(t *testing.T) {
 func TestICBMRouter_RouteICBM(t *testing.T) {
 	cases := []struct {
 	cases := []struct {
 		// name is the unit test name
 		// name is the unit test name

+ 2 - 138
server/locate.go

@@ -16,13 +16,9 @@ type LocateHandler interface {
 	UserInfoQuery2Handler(ctx context.Context, sess *Session, snacPayloadIn oscar.SNAC_0x02_0x15_LocateUserInfoQuery2) (oscar.XMessage, error)
 	UserInfoQuery2Handler(ctx context.Context, sess *Session, snacPayloadIn oscar.SNAC_0x02_0x15_LocateUserInfoQuery2) (oscar.XMessage, error)
 }
 }
 
 
-func NewLocateRouter(logger *slog.Logger, sm SessionManager, fm FeedbagManager, pm ProfileManager) LocateRouter {
+func NewLocateRouter(handler LocateHandler, logger *slog.Logger) LocateRouter {
 	return LocateRouter{
 	return LocateRouter{
-		LocateHandler: LocateService{
-			sm: sm,
-			fm: fm,
-			pm: pm,
-		},
+		LocateHandler: handler,
 		RouteLogger: RouteLogger{
 		RouteLogger: RouteLogger{
 			Logger: logger,
 			Logger: logger,
 		},
 		},
@@ -82,135 +78,3 @@ func (rt LocateRouter) RouteLocate(ctx context.Context, sess *Session, SNACFrame
 		return ErrUnsupportedSubGroup
 		return ErrUnsupportedSubGroup
 	}
 	}
 }
 }
-
-type LocateService struct {
-	sm SessionManager
-	fm FeedbagManager
-	pm ProfileManager
-}
-
-func (s LocateService) RightsQueryHandler(context.Context) oscar.XMessage {
-	return oscar.XMessage{
-		SnacFrame: oscar.SnacFrame{
-			FoodGroup: oscar.LOCATE,
-			SubGroup:  oscar.LocateRightsReply,
-		},
-		SnacOut: oscar.SNAC_0x02_0x03_LocateRightsReply{
-			TLVRestBlock: oscar.TLVRestBlock{
-				TLVList: oscar.TLVList{
-					oscar.NewTLV(0x01, uint16(1000)),
-					oscar.NewTLV(0x02, uint16(1000)),
-					oscar.NewTLV(0x03, uint16(1000)),
-					oscar.NewTLV(0x04, uint16(1000)),
-					oscar.NewTLV(0x05, uint16(1000)),
-				},
-			},
-		},
-	}
-}
-
-func (s LocateService) SetInfoHandler(ctx context.Context, sess *Session, snacPayloadIn oscar.SNAC_0x02_0x04_LocateSetInfo) error {
-	// update profile
-	if profile, hasProfile := snacPayloadIn.GetString(oscar.LocateTLVTagsInfoSigData); hasProfile {
-		if err := s.pm.UpsertProfile(sess.ScreenName(), profile); err != nil {
-			return err
-		}
-	}
-
-	// broadcast away message change to buddies
-	if awayMsg, hasAwayMsg := snacPayloadIn.GetString(oscar.LocateTLVTagsInfoUnavailableData); hasAwayMsg {
-		sess.SetAwayMessage(awayMsg)
-		if err := BroadcastArrival(ctx, sess, s.sm, s.fm); err != nil {
-			return err
-		}
-	}
-	return nil
-}
-
-func (s LocateService) UserInfoQuery2Handler(_ context.Context, sess *Session, snacPayloadIn oscar.SNAC_0x02_0x15_LocateUserInfoQuery2) (oscar.XMessage, error) {
-	blocked, err := s.fm.Blocked(sess.ScreenName(), snacPayloadIn.ScreenName)
-	switch {
-	case err != nil:
-		return oscar.XMessage{}, err
-	case blocked != BlockedNo:
-		return oscar.XMessage{
-			SnacFrame: oscar.SnacFrame{
-				FoodGroup: oscar.LOCATE,
-				SubGroup:  oscar.LocateErr,
-			},
-			SnacOut: oscar.SnacError{
-				Code: oscar.ErrorCodeNotLoggedOn,
-			},
-		}, nil
-	}
-
-	buddySess := s.sm.RetrieveByScreenName(snacPayloadIn.ScreenName)
-	if buddySess == nil {
-		return oscar.XMessage{
-			SnacFrame: oscar.SnacFrame{
-				FoodGroup: oscar.LOCATE,
-				SubGroup:  oscar.LocateErr,
-			},
-			SnacOut: oscar.SnacError{
-				Code: oscar.ErrorCodeNotLoggedOn,
-			},
-		}, nil
-	}
-
-	var list oscar.TLVList
-
-	if snacPayloadIn.RequestProfile() {
-		profile, err := s.pm.RetrieveProfile(snacPayloadIn.ScreenName)
-		if err != nil {
-			return oscar.XMessage{}, err
-		}
-		list.AddTLVList([]oscar.TLV{
-			oscar.NewTLV(oscar.LocateTLVTagsInfoSigMime, `text/aolrtf; charset="us-ascii"`),
-			oscar.NewTLV(oscar.LocateTLVTagsInfoSigData, profile),
-		})
-	}
-
-	if snacPayloadIn.RequestAwayMessage() {
-		list.AddTLVList([]oscar.TLV{
-			oscar.NewTLV(oscar.LocateTLVTagsInfoUnavailableMime, `text/aolrtf; charset="us-ascii"`),
-			oscar.NewTLV(oscar.LocateTLVTagsInfoUnavailableData, buddySess.AwayMessage()),
-		})
-	}
-
-	return oscar.XMessage{
-		SnacFrame: oscar.SnacFrame{
-			FoodGroup: oscar.LOCATE,
-			SubGroup:  oscar.LocateUserInfoReply,
-		},
-		SnacOut: oscar.SNAC_0x02_0x06_LocateUserInfoReply{
-			TLVUserInfo: buddySess.TLVUserInfo(),
-			LocateInfo: oscar.TLVRestBlock{
-				TLVList: list,
-			},
-		},
-	}, nil
-}
-
-func (s LocateService) SetDirInfoHandler(_ context.Context) oscar.XMessage {
-	return oscar.XMessage{
-		SnacFrame: oscar.SnacFrame{
-			FoodGroup: oscar.LOCATE,
-			SubGroup:  oscar.LocateSetDirReply,
-		},
-		SnacOut: oscar.SNAC_0x02_0x0A_LocateSetDirReply{
-			Result: 1,
-		},
-	}
-}
-
-func (s LocateService) SetKeywordInfoHandler(_ context.Context) oscar.XMessage {
-	return oscar.XMessage{
-		SnacFrame: oscar.SnacFrame{
-			FoodGroup: oscar.LOCATE,
-			SubGroup:  oscar.LocateSetKeywordReply,
-		},
-		SnacOut: oscar.SNAC_0x02_0x10_LocateSetKeywordReply{
-			Unknown: 1,
-		},
-	}
-}

+ 1 - 1
server/locate_mock.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.35.2. DO NOT EDIT.
+// Code generated by mockery v2.34.2. DO NOT EDIT.
 
 
 package server
 package server
 
 

+ 0 - 263
server/locate_test.go

@@ -2,275 +2,12 @@ package server
 
 
 import (
 import (
 	"bytes"
 	"bytes"
-	"context"
 	"github.com/mkaminski/goaim/oscar"
 	"github.com/mkaminski/goaim/oscar"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/mock"
 	"github.com/stretchr/testify/mock"
 	"testing"
 	"testing"
 )
 )
 
 
-func TestSendAndReceiveUserInfoQuery2(t *testing.T) {
-	cases := []struct {
-		// name is the unit test name
-		name string
-		// blockedState is the response to the sender/recipient block check
-		blockedState BlockedState
-		// screenNameLookups is the list of user session lookups
-		screenNameLookups map[string]struct {
-			sess *Session
-			err  error
-		}
-		// screenNameLookups is the list of user session lookups
-		profileLookups map[string]struct {
-			payload string
-			err     error
-		}
-		// userSession is the session of the user requesting the user info
-		userSession *Session
-		// inputSNAC is the SNAC sent by the sender client
-		inputSNAC    oscar.SNAC_0x02_0x15_LocateUserInfoQuery2
-		expectOutput oscar.XMessage
-	}{
-		{
-			name:         "request user info, expect user info response",
-			blockedState: BlockedNo,
-			screenNameLookups: map[string]struct {
-				sess *Session
-				err  error
-			}{
-				"requested-user": {
-					sess: newTestSession("requested-user",
-						sessOptCannedSignonTime,
-						sessOptCannedAwayMessage),
-				},
-			},
-			userSession: newTestSession("user_screen_name"),
-			inputSNAC: oscar.SNAC_0x02_0x15_LocateUserInfoQuery2{
-				Type2:      0,
-				ScreenName: "requested-user",
-			},
-			expectOutput: oscar.XMessage{
-				SnacFrame: oscar.SnacFrame{
-					FoodGroup: oscar.LOCATE,
-					SubGroup:  oscar.LocateUserInfoReply,
-				},
-				SnacOut: oscar.SNAC_0x02_0x06_LocateUserInfoReply{
-					TLVUserInfo: newTestSession("requested-user",
-						sessOptCannedSignonTime,
-						sessOptCannedAwayMessage).
-						TLVUserInfo(),
-					LocateInfo: oscar.TLVRestBlock{},
-				},
-			},
-		},
-		{
-			name:         "request user info + profile, expect user info response + profile",
-			blockedState: BlockedNo,
-			screenNameLookups: map[string]struct {
-				sess *Session
-				err  error
-			}{
-				"requested-user": {
-					sess: newTestSession("requested-user",
-						sessOptCannedSignonTime,
-						sessOptCannedAwayMessage),
-				},
-			},
-			profileLookups: map[string]struct {
-				payload string
-				err     error
-			}{
-				"requested-user": {
-					payload: "this is my profile!",
-				},
-			},
-			userSession: newTestSession("user_screen_name"),
-			inputSNAC: oscar.SNAC_0x02_0x15_LocateUserInfoQuery2{
-				// 2048 is a dummy to make sure bitmask check works
-				Type2:      oscar.LocateType2Sig | 2048,
-				ScreenName: "requested-user",
-			},
-			expectOutput: oscar.XMessage{
-				SnacFrame: oscar.SnacFrame{
-					FoodGroup: oscar.LOCATE,
-					SubGroup:  oscar.LocateUserInfoReply,
-				},
-				SnacOut: oscar.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!"),
-						},
-					},
-				},
-			},
-		},
-		{
-			name:         "request user info + profile, expect user info response + profile",
-			blockedState: BlockedNo,
-			screenNameLookups: map[string]struct {
-				sess *Session
-				err  error
-			}{
-				"requested-user": {
-					sess: newTestSession("requested-user",
-						sessOptCannedSignonTime,
-						sessOptCannedAwayMessage),
-				},
-			},
-			profileLookups: map[string]struct {
-				payload string
-				err     error
-			}{
-				"requested-user": {
-					payload: "this is my profile!",
-				},
-			},
-			userSession: newTestSession("user_screen_name"),
-			inputSNAC: oscar.SNAC_0x02_0x15_LocateUserInfoQuery2{
-				// 2048 is a dummy to make sure bitmask check works
-				Type2:      oscar.LocateType2Sig | 2048,
-				ScreenName: "requested-user",
-			},
-			expectOutput: oscar.XMessage{
-				SnacFrame: oscar.SnacFrame{
-					FoodGroup: oscar.LOCATE,
-					SubGroup:  oscar.LocateUserInfoReply,
-				},
-				SnacOut: oscar.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!"),
-						},
-					},
-				},
-			},
-		},
-		{
-			name:         "request user info + away message, expect user info response + away message",
-			blockedState: BlockedNo,
-			screenNameLookups: map[string]struct {
-				sess *Session
-				err  error
-			}{
-				"requested-user": {
-					sess: newTestSession("requested-user",
-						sessOptCannedSignonTime,
-						sessOptCannedAwayMessage),
-				},
-			},
-			userSession: newTestSession("user_screen_name"),
-			inputSNAC: oscar.SNAC_0x02_0x15_LocateUserInfoQuery2{
-				// 2048 is a dummy to make sure bitmask check works
-				Type2:      oscar.LocateType2Unavailable | 2048,
-				ScreenName: "requested-user",
-			},
-			expectOutput: oscar.XMessage{
-				SnacFrame: oscar.SnacFrame{
-					FoodGroup: oscar.LOCATE,
-					SubGroup:  oscar.LocateUserInfoReply,
-				},
-				SnacOut: oscar.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!"),
-						},
-					},
-				},
-			},
-		},
-		{
-			name:         "request user info of user who blocked requester, expect not logged in error",
-			blockedState: BlockedB,
-			userSession:  newTestSession("user_screen_name"),
-			inputSNAC: oscar.SNAC_0x02_0x15_LocateUserInfoQuery2{
-				ScreenName: "requested-user",
-			},
-			expectOutput: oscar.XMessage{
-				SnacFrame: oscar.SnacFrame{
-					FoodGroup: oscar.LOCATE,
-					SubGroup:  oscar.LocateErr,
-				},
-				SnacOut: oscar.SnacError{
-					Code: oscar.ErrorCodeNotLoggedOn,
-				},
-			},
-		},
-		{
-			name:         "request user info of user who does not exist, expect not logged in error",
-			blockedState: BlockedNo,
-			screenNameLookups: map[string]struct {
-				sess *Session
-				err  error
-			}{
-				"non_existent_requested_user": {
-					sess: nil,
-				},
-			},
-			userSession: newTestSession("user_screen_name"),
-			inputSNAC: oscar.SNAC_0x02_0x15_LocateUserInfoQuery2{
-				ScreenName: "non_existent_requested_user",
-			},
-			expectOutput: oscar.XMessage{
-				SnacFrame: oscar.SnacFrame{
-					FoodGroup: oscar.LOCATE,
-					SubGroup:  oscar.LocateErr,
-				},
-				SnacOut: oscar.SnacError{
-					Code: oscar.ErrorCodeNotLoggedOn,
-				},
-			},
-		},
-	}
-
-	for _, tc := range cases {
-		t.Run(tc.name, func(t *testing.T) {
-			fm := NewMockFeedbagManager(t)
-			fm.EXPECT().
-				Blocked(tc.userSession.ScreenName(), tc.inputSNAC.ScreenName).
-				Return(tc.blockedState, nil).
-				Maybe()
-			sm := NewMockSessionManager(t)
-			for screenName, val := range tc.screenNameLookups {
-				sm.EXPECT().
-					RetrieveByScreenName(screenName).
-					Return(val.sess).
-					Maybe()
-			}
-			pm := NewMockProfileManager(t)
-			for screenName, val := range tc.profileLookups {
-				pm.EXPECT().
-					RetrieveProfile(screenName).
-					Return(val.payload, val.err).
-					Maybe()
-			}
-
-			svc := LocateService{
-				sm: sm,
-				fm: fm,
-				pm: pm,
-			}
-			outputSNAC, err := svc.UserInfoQuery2Handler(context.Background(), tc.userSession, tc.inputSNAC)
-			assert.NoError(t, err)
-			assert.Equal(t, tc.expectOutput, outputSNAC)
-		})
-	}
-}
-
 func TestLocateRouter_RouteLocate(t *testing.T) {
 func TestLocateRouter_RouteLocate(t *testing.T) {
 	cases := []struct {
 	cases := []struct {
 		// name is the unit test name
 		// name is the unit test name

+ 17 - 282
server/oservice.go

@@ -1,14 +1,11 @@
 package server
 package server
 
 
 import (
 import (
-	"bytes"
 	"context"
 	"context"
 	"errors"
 	"errors"
-	"fmt"
 	"github.com/mkaminski/goaim/oscar"
 	"github.com/mkaminski/goaim/oscar"
 	"io"
 	"io"
 	"log/slog"
 	"log/slog"
-	"time"
 )
 )
 
 
 type OServiceHandler interface {
 type OServiceHandler interface {
@@ -22,14 +19,14 @@ type OServiceHandler interface {
 
 
 type OServiceBOSHandler interface {
 type OServiceBOSHandler interface {
 	OServiceHandler
 	OServiceHandler
-	WriteOServiceHostOnline(w io.Writer, sequence *uint32) error
+	WriteOServiceHostOnline() oscar.XMessage
 	ServiceRequestHandler(ctx context.Context, sess *Session, snacPayloadIn oscar.SNAC_0x01_0x04_OServiceServiceRequest) (oscar.XMessage, error)
 	ServiceRequestHandler(ctx context.Context, sess *Session, snacPayloadIn oscar.SNAC_0x01_0x04_OServiceServiceRequest) (oscar.XMessage, error)
 	ClientOnlineHandler(ctx context.Context, snacPayloadIn oscar.SNAC_0x01_0x02_OServiceClientOnline, sess *Session) error
 	ClientOnlineHandler(ctx context.Context, snacPayloadIn oscar.SNAC_0x01_0x02_OServiceClientOnline, sess *Session) error
 }
 }
 
 
 type OServiceChatHandler interface {
 type OServiceChatHandler interface {
 	OServiceHandler
 	OServiceHandler
-	WriteOServiceHostOnline(w io.Writer, sequence *uint32) error
+	WriteOServiceHostOnline() oscar.XMessage
 	ServiceRequestHandler(ctx context.Context, sess *Session, snacPayloadIn oscar.SNAC_0x01_0x04_OServiceServiceRequest) (oscar.XMessage, error)
 	ServiceRequestHandler(ctx context.Context, sess *Session, snacPayloadIn oscar.SNAC_0x01_0x04_OServiceServiceRequest) (oscar.XMessage, error)
 	ClientOnlineHandler(ctx context.Context, snacPayloadIn oscar.SNAC_0x01_0x02_OServiceClientOnline, sess *Session, chatSessMgr ChatSessionManager, room ChatRoom) error
 	ClientOnlineHandler(ctx context.Context, snacPayloadIn oscar.SNAC_0x01_0x02_OServiceClientOnline, sess *Session, chatSessMgr ChatSessionManager, room ChatRoom) error
 }
 }
@@ -88,23 +85,15 @@ func (rt OServiceRouter) RouteOService(ctx context.Context, sess *Session, SNACF
 	}
 	}
 }
 }
 
 
-func NewOServiceRouterForBOS(logger *slog.Logger, cfg Config, fm FeedbagManager, sm SessionManager, cr *ChatRegistry) OServiceBOSRouter {
-	oss := OServiceService{
-		cfg: cfg,
-		fm:  fm,
-		sm:  sm,
-	}
+func NewOServiceRouterForBOS(logger *slog.Logger, oserviceHandler OServiceHandler, oserviceBOSHandler OServiceBOSHandler) OServiceBOSRouter {
 	return OServiceBOSRouter{
 	return OServiceBOSRouter{
 		OServiceRouter: OServiceRouter{
 		OServiceRouter: OServiceRouter{
-			OServiceHandler: oss,
+			OServiceHandler: oserviceHandler,
 			RouteLogger: RouteLogger{
 			RouteLogger: RouteLogger{
 				Logger: logger,
 				Logger: logger,
 			},
 			},
 		},
 		},
-		OServiceBOSHandler: OServiceServiceForBOS{
-			OServiceService: oss,
-			cr:              cr,
-		},
+		OServiceBOSHandler: oserviceBOSHandler,
 	}
 	}
 }
 }
 
 
@@ -142,6 +131,18 @@ func (rt OServiceBOSRouter) RouteOService(ctx context.Context, sess *Session, SN
 	}
 	}
 }
 }
 
 
+func NewOServiceRouterForChat(logger *slog.Logger, oserviceHandler OServiceHandler, chatHandler OServiceChatHandler) OServiceChatRouter {
+	return OServiceChatRouter{
+		OServiceRouter: OServiceRouter{
+			OServiceHandler: oserviceHandler,
+			RouteLogger: RouteLogger{
+				Logger: logger,
+			},
+		},
+		OServiceChatHandler: chatHandler,
+	}
+}
+
 type OServiceChatRouter struct {
 type OServiceChatRouter struct {
 	OServiceRouter
 	OServiceRouter
 	OServiceChatHandler
 	OServiceChatHandler
@@ -175,269 +176,3 @@ func (rt OServiceChatRouter) RouteOService(ctx context.Context, sess *Session, c
 		return rt.OServiceRouter.RouteOService(ctx, sess, SNACFrame, r, w, sequence)
 		return rt.OServiceRouter.RouteOService(ctx, sess, SNACFrame, r, w, sequence)
 	}
 	}
 }
 }
-
-type OServiceService struct {
-	cfg Config
-	fm  FeedbagManager
-	sm  SessionManager
-}
-
-func (s OServiceService) ClientVersionsHandler(_ context.Context, snacPayloadIn oscar.SNAC_0x01_0x17_OServiceClientVersions) oscar.XMessage {
-	return oscar.XMessage{
-		SnacFrame: oscar.SnacFrame{
-			FoodGroup: oscar.OSERVICE,
-			SubGroup:  oscar.OServiceHostVersions,
-		},
-		SnacOut: oscar.SNAC_0x01_0x18_OServiceHostVersions{
-			Versions: snacPayloadIn.Versions,
-		},
-	}
-}
-
-func (s OServiceService) RateParamsQueryHandler(_ context.Context) oscar.XMessage {
-	snacFrameOut := oscar.SnacFrame{
-		FoodGroup: oscar.OSERVICE,
-		SubGroup:  oscar.OServiceRateParamsReply,
-	}
-	snacPayloadOut := 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 // protocol v2 only
-			CurrentState    uint8  // protocol v2 only
-		}{
-			{
-				ID:              0x0001,
-				WindowSize:      0x00000050,
-				ClearLevel:      0x000009C4,
-				AlertLevel:      0x000007D0,
-				LimitLevel:      0x000005DC,
-				DisconnectLevel: 0x00000320,
-				CurrentLevel:    0x00000D69,
-				MaxLevel:        0x00001770,
-				LastTime:        0x00000000,
-				CurrentState:    0x00,
-			},
-		},
-		RateGroups: []struct {
-			ID    uint16
-			Pairs []struct {
-				FoodGroup uint16
-				SubGroup  uint16
-			} `count_prefix:"uint16"`
-		}{
-			{
-				ID: 1,
-				Pairs: []struct {
-					FoodGroup uint16
-					SubGroup  uint16
-				}{},
-			},
-		},
-	}
-
-	for i := uint16(0); i < 24; i++ { // for each food group
-		for j := uint16(0); j < 32; j++ { // for each subgroup
-			snacPayloadOut.RateGroups[0].Pairs = append(snacPayloadOut.RateGroups[0].Pairs,
-				struct {
-					FoodGroup uint16
-					SubGroup  uint16
-				}{
-					FoodGroup: i,
-					SubGroup:  j,
-				})
-		}
-	}
-
-	return oscar.XMessage{
-		SnacFrame: snacFrameOut,
-		SnacOut:   snacPayloadOut,
-	}
-}
-
-func (s OServiceService) UserInfoQueryHandler(_ context.Context, sess *Session) oscar.XMessage {
-	return oscar.XMessage{
-		SnacFrame: oscar.SnacFrame{
-			FoodGroup: oscar.OSERVICE,
-			SubGroup:  oscar.OServiceUserInfoUpdate,
-		},
-		SnacOut: oscar.SNAC_0x01_0x0F_OServiceUserInfoUpdate{
-			TLVUserInfo: sess.TLVUserInfo(),
-		},
-	}
-}
-
-func (s OServiceService) SetUserInfoFieldsHandler(ctx context.Context, sess *Session, snacPayloadIn oscar.SNAC_0x01_0x1E_OServiceSetUserInfoFields) (oscar.XMessage, error) {
-	if status, hasStatus := snacPayloadIn.GetUint32(0x06); hasStatus {
-		switch status {
-		case 0x000:
-			sess.SetInvisible(false)
-			if err := BroadcastArrival(ctx, sess, s.sm, s.fm); err != nil {
-				return oscar.XMessage{}, err
-			}
-		case 0x100:
-			sess.SetInvisible(true)
-			if err := BroadcastDeparture(ctx, sess, s.sm, s.fm); err != nil {
-				return oscar.XMessage{}, err
-			}
-		default:
-			return oscar.XMessage{}, fmt.Errorf("don't know what to do with status %d", status)
-		}
-	}
-	return oscar.XMessage{
-		SnacFrame: oscar.SnacFrame{
-			FoodGroup: oscar.OSERVICE,
-			SubGroup:  oscar.OServiceUserInfoUpdate,
-		},
-		SnacOut: oscar.SNAC_0x01_0x0F_OServiceUserInfoUpdate{
-			TLVUserInfo: sess.TLVUserInfo(),
-		},
-	}, nil
-}
-
-func (s OServiceService) IdleNotificationHandler(ctx context.Context, sess *Session, snacPayloadIn oscar.SNAC_0x01_0x11_OServiceIdleNotification) error {
-	if snacPayloadIn.IdleTime == 0 {
-		sess.SetActive()
-	} else {
-		sess.SetIdle(time.Duration(snacPayloadIn.IdleTime) * time.Second)
-	}
-	return BroadcastArrival(ctx, sess, s.sm, s.fm)
-}
-
-// 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) {
-}
-
-type OServiceServiceForBOS struct {
-	OServiceService
-	cr *ChatRegistry
-}
-
-func (s OServiceServiceForBOS) ServiceRequestHandler(_ context.Context, sess *Session, snacPayloadIn oscar.SNAC_0x01_0x04_OServiceServiceRequest) (oscar.XMessage, error) {
-	if snacPayloadIn.FoodGroup != oscar.CHAT {
-		return oscar.XMessage{}, ErrUnsupportedSubGroup
-	}
-
-	roomMeta, ok := snacPayloadIn.GetSlice(0x01)
-	if !ok {
-		return oscar.XMessage{}, errors.New("missing room info")
-	}
-
-	roomSnac := oscar.SNAC_0x01_0x04_TLVRoomInfo{}
-	if err := oscar.Unmarshal(&roomSnac, bytes.NewBuffer(roomMeta)); err != nil {
-		return oscar.XMessage{}, err
-	}
-
-	room, chatSessMgr, err := s.cr.Retrieve(string(roomSnac.Cookie))
-	if err != nil {
-		return oscar.XMessage{}, ErrUnsupportedSubGroup
-	}
-	chatSessMgr.NewSessionWithSN(sess.ID(), sess.ScreenName())
-
-	return oscar.XMessage{
-		SnacFrame: oscar.SnacFrame{
-			FoodGroup: oscar.OSERVICE,
-			SubGroup:  oscar.OServiceServiceResponse,
-		},
-		SnacOut: oscar.SNAC_0x01_0x05_OServiceServiceResponse{
-			TLVRestBlock: oscar.TLVRestBlock{
-				TLVList: oscar.TLVList{
-					oscar.NewTLV(oscar.OServiceTLVTagsReconnectHere, Address(s.cfg.OSCARHost, s.cfg.ChatPort)),
-					oscar.NewTLV(oscar.OServiceTLVTagsLoginCookie, 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
-}
-
-func (s OServiceServiceForBOS) WriteOServiceHostOnline(w io.Writer, sequence *uint32) error {
-	snacFrameOut := oscar.SnacFrame{
-		FoodGroup: oscar.OSERVICE,
-		SubGroup:  oscar.OServiceHostOnline,
-	}
-	snacPayloadOut := oscar.SNAC_0x01_0x03_OServiceHostOnline{
-		FoodGroups: []uint16{
-			oscar.ALERT,
-			oscar.BUDDY,
-			oscar.CHAT_NAV,
-			oscar.FEEDBAG,
-			oscar.ICBM,
-			oscar.LOCATE,
-			oscar.OSERVICE,
-		},
-	}
-	return writeOutSNAC(oscar.SnacFrame{}, snacFrameOut, snacPayloadOut, sequence, w)
-}
-
-func (s OServiceServiceForBOS) ClientOnlineHandler(ctx context.Context, _ oscar.SNAC_0x01_0x02_OServiceClientOnline, sess *Session) error {
-	if err := BroadcastArrival(ctx, sess, s.sm, s.fm); err != nil {
-		return err
-	}
-	buddies, err := s.fm.Buddies(sess.ScreenName())
-	if err != nil {
-		return err
-	}
-	for _, buddy := range buddies {
-		UnicastArrival(ctx, buddy, sess.ScreenName(), s.sm)
-	}
-	return nil
-}
-
-func NewOServiceRouterForChat(logger *slog.Logger, cfg Config, fm FeedbagManager, sm SessionManager) OServiceChatRouter {
-	oss := OServiceService{
-		cfg: cfg,
-		fm:  fm,
-		sm:  sm,
-	}
-	return OServiceChatRouter{
-		OServiceRouter: OServiceRouter{
-			OServiceHandler: oss,
-			RouteLogger: RouteLogger{
-				Logger: logger,
-			},
-		},
-		OServiceChatHandler: OServiceServiceForChat{
-			OServiceService: oss,
-		},
-	}
-}
-
-type OServiceServiceForChat struct {
-	OServiceService
-	RouteLogger
-}
-
-func (s OServiceServiceForChat) ServiceRequestHandler(_ context.Context, _ *Session, _ oscar.SNAC_0x01_0x04_OServiceServiceRequest) (oscar.XMessage, error) {
-	return oscar.XMessage{}, ErrUnsupportedSubGroup
-}
-
-func (s OServiceServiceForChat) WriteOServiceHostOnline(w io.Writer, sequence *uint32) error {
-	snacFrameOut := oscar.SnacFrame{
-		FoodGroup: oscar.OSERVICE,
-		SubGroup:  oscar.OServiceHostOnline,
-	}
-	snacPayloadOut := oscar.SNAC_0x01_0x03_OServiceHostOnline{
-		FoodGroups: []uint16{oscar.OSERVICE, oscar.CHAT},
-	}
-	return writeOutSNAC(oscar.SnacFrame{}, snacFrameOut, snacPayloadOut, sequence, w)
-}
-
-func (s OServiceServiceForChat) ClientOnlineHandler(ctx context.Context, _ oscar.SNAC_0x01_0x02_OServiceClientOnline, sess *Session, chatSessMgr ChatSessionManager, room ChatRoom) error {
-	SendChatRoomInfoUpdate(ctx, sess, chatSessMgr, room)
-	AlertUserJoined(ctx, sess, chatSessMgr)
-	SetOnlineChatUsers(ctx, sess, chatSessMgr)
-	return nil
-}

+ 15 - 19
server/oservice_bos_mock.go

@@ -1,14 +1,12 @@
-// Code generated by mockery v2.35.2. DO NOT EDIT.
+// Code generated by mockery v2.34.2. DO NOT EDIT.
 
 
 package server
 package server
 
 
 import (
 import (
 	context "context"
 	context "context"
-	io "io"
-
-	mock "github.com/stretchr/testify/mock"
 
 
 	oscar "github.com/mkaminski/goaim/oscar"
 	oscar "github.com/mkaminski/goaim/oscar"
+	mock "github.com/stretchr/testify/mock"
 )
 )
 
 
 // MockOServiceBOSHandler is an autogenerated mock type for the OServiceBOSHandler type
 // MockOServiceBOSHandler is an autogenerated mock type for the OServiceBOSHandler type
@@ -382,15 +380,15 @@ func (_c *MockOServiceBOSHandler_UserInfoQueryHandler_Call) RunAndReturn(run fun
 	return _c
 	return _c
 }
 }
 
 
-// WriteOServiceHostOnline provides a mock function with given fields: w, sequence
-func (_m *MockOServiceBOSHandler) WriteOServiceHostOnline(w io.Writer, sequence *uint32) error {
-	ret := _m.Called(w, sequence)
+// WriteOServiceHostOnline provides a mock function with given fields:
+func (_m *MockOServiceBOSHandler) WriteOServiceHostOnline() oscar.XMessage {
+	ret := _m.Called()
 
 
-	var r0 error
-	if rf, ok := ret.Get(0).(func(io.Writer, *uint32) error); ok {
-		r0 = rf(w, sequence)
+	var r0 oscar.XMessage
+	if rf, ok := ret.Get(0).(func() oscar.XMessage); ok {
+		r0 = rf()
 	} else {
 	} else {
-		r0 = ret.Error(0)
+		r0 = ret.Get(0).(oscar.XMessage)
 	}
 	}
 
 
 	return r0
 	return r0
@@ -402,25 +400,23 @@ type MockOServiceBOSHandler_WriteOServiceHostOnline_Call struct {
 }
 }
 
 
 // WriteOServiceHostOnline is a helper method to define mock.On call
 // WriteOServiceHostOnline is a helper method to define mock.On call
-//   - w io.Writer
-//   - sequence *uint32
-func (_e *MockOServiceBOSHandler_Expecter) WriteOServiceHostOnline(w interface{}, sequence interface{}) *MockOServiceBOSHandler_WriteOServiceHostOnline_Call {
-	return &MockOServiceBOSHandler_WriteOServiceHostOnline_Call{Call: _e.mock.On("WriteOServiceHostOnline", w, sequence)}
+func (_e *MockOServiceBOSHandler_Expecter) WriteOServiceHostOnline() *MockOServiceBOSHandler_WriteOServiceHostOnline_Call {
+	return &MockOServiceBOSHandler_WriteOServiceHostOnline_Call{Call: _e.mock.On("WriteOServiceHostOnline")}
 }
 }
 
 
-func (_c *MockOServiceBOSHandler_WriteOServiceHostOnline_Call) Run(run func(w io.Writer, sequence *uint32)) *MockOServiceBOSHandler_WriteOServiceHostOnline_Call {
+func (_c *MockOServiceBOSHandler_WriteOServiceHostOnline_Call) Run(run func()) *MockOServiceBOSHandler_WriteOServiceHostOnline_Call {
 	_c.Call.Run(func(args mock.Arguments) {
 	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(io.Writer), args[1].(*uint32))
+		run()
 	})
 	})
 	return _c
 	return _c
 }
 }
 
 
-func (_c *MockOServiceBOSHandler_WriteOServiceHostOnline_Call) Return(_a0 error) *MockOServiceBOSHandler_WriteOServiceHostOnline_Call {
+func (_c *MockOServiceBOSHandler_WriteOServiceHostOnline_Call) Return(_a0 oscar.XMessage) *MockOServiceBOSHandler_WriteOServiceHostOnline_Call {
 	_c.Call.Return(_a0)
 	_c.Call.Return(_a0)
 	return _c
 	return _c
 }
 }
 
 
-func (_c *MockOServiceBOSHandler_WriteOServiceHostOnline_Call) RunAndReturn(run func(io.Writer, *uint32) error) *MockOServiceBOSHandler_WriteOServiceHostOnline_Call {
+func (_c *MockOServiceBOSHandler_WriteOServiceHostOnline_Call) RunAndReturn(run func() oscar.XMessage) *MockOServiceBOSHandler_WriteOServiceHostOnline_Call {
 	_c.Call.Return(run)
 	_c.Call.Return(run)
 	return _c
 	return _c
 }
 }

+ 15 - 19
server/oservice_chat_mock.go

@@ -1,14 +1,12 @@
-// Code generated by mockery v2.35.2. DO NOT EDIT.
+// Code generated by mockery v2.34.2. DO NOT EDIT.
 
 
 package server
 package server
 
 
 import (
 import (
 	context "context"
 	context "context"
-	io "io"
-
-	mock "github.com/stretchr/testify/mock"
 
 
 	oscar "github.com/mkaminski/goaim/oscar"
 	oscar "github.com/mkaminski/goaim/oscar"
+	mock "github.com/stretchr/testify/mock"
 )
 )
 
 
 // MockOServiceChatHandler is an autogenerated mock type for the OServiceChatHandler type
 // MockOServiceChatHandler is an autogenerated mock type for the OServiceChatHandler type
@@ -384,15 +382,15 @@ func (_c *MockOServiceChatHandler_UserInfoQueryHandler_Call) RunAndReturn(run fu
 	return _c
 	return _c
 }
 }
 
 
-// WriteOServiceHostOnline provides a mock function with given fields: w, sequence
-func (_m *MockOServiceChatHandler) WriteOServiceHostOnline(w io.Writer, sequence *uint32) error {
-	ret := _m.Called(w, sequence)
+// WriteOServiceHostOnline provides a mock function with given fields:
+func (_m *MockOServiceChatHandler) WriteOServiceHostOnline() oscar.XMessage {
+	ret := _m.Called()
 
 
-	var r0 error
-	if rf, ok := ret.Get(0).(func(io.Writer, *uint32) error); ok {
-		r0 = rf(w, sequence)
+	var r0 oscar.XMessage
+	if rf, ok := ret.Get(0).(func() oscar.XMessage); ok {
+		r0 = rf()
 	} else {
 	} else {
-		r0 = ret.Error(0)
+		r0 = ret.Get(0).(oscar.XMessage)
 	}
 	}
 
 
 	return r0
 	return r0
@@ -404,25 +402,23 @@ type MockOServiceChatHandler_WriteOServiceHostOnline_Call struct {
 }
 }
 
 
 // WriteOServiceHostOnline is a helper method to define mock.On call
 // WriteOServiceHostOnline is a helper method to define mock.On call
-//   - w io.Writer
-//   - sequence *uint32
-func (_e *MockOServiceChatHandler_Expecter) WriteOServiceHostOnline(w interface{}, sequence interface{}) *MockOServiceChatHandler_WriteOServiceHostOnline_Call {
-	return &MockOServiceChatHandler_WriteOServiceHostOnline_Call{Call: _e.mock.On("WriteOServiceHostOnline", w, sequence)}
+func (_e *MockOServiceChatHandler_Expecter) WriteOServiceHostOnline() *MockOServiceChatHandler_WriteOServiceHostOnline_Call {
+	return &MockOServiceChatHandler_WriteOServiceHostOnline_Call{Call: _e.mock.On("WriteOServiceHostOnline")}
 }
 }
 
 
-func (_c *MockOServiceChatHandler_WriteOServiceHostOnline_Call) Run(run func(w io.Writer, sequence *uint32)) *MockOServiceChatHandler_WriteOServiceHostOnline_Call {
+func (_c *MockOServiceChatHandler_WriteOServiceHostOnline_Call) Run(run func()) *MockOServiceChatHandler_WriteOServiceHostOnline_Call {
 	_c.Call.Run(func(args mock.Arguments) {
 	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(io.Writer), args[1].(*uint32))
+		run()
 	})
 	})
 	return _c
 	return _c
 }
 }
 
 
-func (_c *MockOServiceChatHandler_WriteOServiceHostOnline_Call) Return(_a0 error) *MockOServiceChatHandler_WriteOServiceHostOnline_Call {
+func (_c *MockOServiceChatHandler_WriteOServiceHostOnline_Call) Return(_a0 oscar.XMessage) *MockOServiceChatHandler_WriteOServiceHostOnline_Call {
 	_c.Call.Return(_a0)
 	_c.Call.Return(_a0)
 	return _c
 	return _c
 }
 }
 
 
-func (_c *MockOServiceChatHandler_WriteOServiceHostOnline_Call) RunAndReturn(run func(io.Writer, *uint32) error) *MockOServiceChatHandler_WriteOServiceHostOnline_Call {
+func (_c *MockOServiceChatHandler_WriteOServiceHostOnline_Call) RunAndReturn(run func() oscar.XMessage) *MockOServiceChatHandler_WriteOServiceHostOnline_Call {
 	_c.Call.Return(run)
 	_c.Call.Return(run)
 	return _c
 	return _c
 }
 }

+ 1 - 1
server/oservice_mock.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.35.2. DO NOT EDIT.
+// Code generated by mockery v2.34.2. DO NOT EDIT.
 
 
 package server
 package server
 
 

+ 2 - 141
server/oservice_test.go

@@ -2,151 +2,12 @@ package server
 
 
 import (
 import (
 	"bytes"
 	"bytes"
-	"github.com/stretchr/testify/mock"
-	"testing"
-	"time"
-
 	"github.com/mkaminski/goaim/oscar"
 	"github.com/mkaminski/goaim/oscar"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/mock"
+	"testing"
 )
 )
 
 
-func TestReceiveAndSendServiceRequest(t *testing.T) {
-	cases := []struct {
-		// name is the unit test name
-		name string
-		// cfg is the application config
-		cfg Config
-		// chatRoom is the chat room the user connects to
-		chatRoom *ChatRoom
-		// userSession is the session of the user requesting the chat service
-		// info
-		userSession *Session
-		// inputSNAC is the SNAC sent by the sender client
-		inputSNAC oscar.SNAC_0x01_0x04_OServiceServiceRequest
-		// expectSNACFrame is the SNAC frame sent from the server to the recipient
-		// client
-		expectOutput oscar.XMessage
-		// 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.SNAC_0x01_0x04_OServiceServiceRequest{
-				FoodGroup: oscar.ICBM,
-			},
-			expectErr: ErrUnsupportedSubGroup,
-		},
-		{
-			name: "request info for connecting to chat room, return chat service and chat room metadata",
-			cfg: Config{
-				OSCARHost: "127.0.0.1",
-				ChatPort:  1234,
-			},
-			chatRoom: &ChatRoom{
-				CreateTime:     time.UnixMilli(0),
-				DetailLevel:    4,
-				Exchange:       8,
-				Cookie:         "the-chat-cookie",
-				InstanceNumber: 16,
-				Name:           "my new chat",
-			},
-			userSession: newTestSession("user_screen_name", sessOptCannedID),
-			inputSNAC: oscar.SNAC_0x01_0x04_OServiceServiceRequest{
-				FoodGroup: oscar.CHAT,
-				TLVRestBlock: oscar.TLVRestBlock{
-					TLVList: oscar.TLVList{
-						oscar.NewTLV(0x01, oscar.SNAC_0x01_0x04_TLVRoomInfo{
-							Exchange:       8,
-							Cookie:         []byte("the-chat-cookie"),
-							InstanceNumber: 16,
-						}),
-					},
-				},
-			},
-			expectOutput: oscar.XMessage{
-				SnacFrame: oscar.SnacFrame{
-					FoodGroup: oscar.OSERVICE,
-					SubGroup:  oscar.OServiceServiceResponse,
-				},
-				SnacOut: oscar.SNAC_0x01_0x05_OServiceServiceResponse{
-					TLVRestBlock: oscar.TLVRestBlock{
-						TLVList: oscar.TLVList{
-							oscar.NewTLV(oscar.OServiceTLVTagsReconnectHere, "127.0.0.1:1234"),
-							oscar.NewTLV(oscar.OServiceTLVTagsLoginCookie, ChatCookie{
-								Cookie: []byte("the-chat-cookie"),
-								SessID: "user-sess-id",
-							}),
-							oscar.NewTLV(oscar.OServiceTLVTagsGroupID, oscar.CHAT),
-							oscar.NewTLV(oscar.OServiceTLVTagsSSLCertName, ""),
-							oscar.NewTLV(oscar.OServiceTLVTagsSSLState, uint8(0x00)),
-						},
-					},
-				},
-			},
-		},
-		{
-			name: "request info for connecting to non-existent chat room, return SNAC error",
-			cfg: Config{
-				OSCARHost: "127.0.0.1",
-				ChatPort:  1234,
-			},
-			chatRoom:    nil,
-			userSession: newTestSession("user_screen_name", sessOptCannedID),
-			inputSNAC: oscar.SNAC_0x01_0x04_OServiceServiceRequest{
-				FoodGroup: oscar.CHAT,
-				TLVRestBlock: oscar.TLVRestBlock{
-					TLVList: oscar.TLVList{
-						oscar.NewTLV(0x01, oscar.SNAC_0x01_0x04_TLVRoomInfo{
-							Exchange:       8,
-							Cookie:         []byte("the-chat-cookie"),
-							InstanceNumber: 16,
-						}),
-					},
-				},
-			},
-			expectErr: ErrUnsupportedSubGroup,
-		},
-	}
-
-	for _, tc := range cases {
-		t.Run(tc.name, func(t *testing.T) {
-			//
-			// initialize dependencies
-			//
-			sm := NewMockChatSessionManager(t)
-			cr := NewChatRegistry()
-			if tc.chatRoom != nil {
-				sm.EXPECT().
-					NewSessionWithSN(tc.userSession.ID(), tc.userSession.ScreenName()).
-					Return(&Session{}).
-					Maybe()
-				cr.Register(*tc.chatRoom, sm)
-			}
-			//
-			// send input SNAC
-			//
-			svc := OServiceServiceForBOS{
-				OServiceService: OServiceService{
-					cfg: tc.cfg,
-					sm:  sm,
-				},
-				cr: cr,
-			}
-
-			outputSNAC, err := svc.ServiceRequestHandler(nil, tc.userSession, tc.inputSNAC)
-			assert.ErrorIs(t, err, tc.expectErr)
-			if tc.expectErr != nil {
-				return
-			}
-			//
-			// verify output
-			//
-			assert.Equal(t, tc.expectOutput, outputSNAC)
-		})
-	}
-}
-
 func TestOServiceRouter_RouteOService_ForBOS(t *testing.T) {
 func TestOServiceRouter_RouteOService_ForBOS(t *testing.T) {
 	cases := []struct {
 	cases := []struct {
 		// name is the unit test name
 		// name is the unit test name

+ 1 - 1
server/profile_manager_mock.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.35.2. DO NOT EDIT.
+// Code generated by mockery v2.34.2. DO NOT EDIT.
 
 
 package server
 package server
 
 

+ 18 - 58
server/router.go

@@ -4,37 +4,17 @@ import (
 	"bytes"
 	"bytes"
 	"context"
 	"context"
 	"errors"
 	"errors"
-	"fmt"
+	"github.com/google/uuid"
+	"github.com/mkaminski/goaim/oscar"
 	"io"
 	"io"
 	"log/slog"
 	"log/slog"
 	"net"
 	"net"
-
-	"github.com/mkaminski/goaim/oscar"
 )
 )
 
 
-func NewBOSServiceRouter(logger *slog.Logger, cfg Config, fm FeedbagManager, sm SessionManager, cr *ChatRegistry, pm ProfileManager) BOSServiceRouter {
-	return BOSServiceRouter{
-		AlertRouter:       NewAlertRouter(logger),
-		BuddyRouter:       NewBuddyRouter(logger),
-		ChatNavRouter:     NewChatNavRouter(logger, cr),
-		FeedbagRouter:     NewFeedbagRouter(logger, sm, fm),
-		ICBMRouter:        NewICBMRouter(logger, sm, fm),
-		LocateRouter:      NewLocateRouter(logger, sm, fm, pm),
-		OServiceBOSRouter: NewOServiceRouterForBOS(logger, cfg, fm, sm, cr),
-		sm:                sm,
-		fm:                fm,
-		cfg:               cfg,
-		RouteLogger: RouteLogger{
-			Logger: logger,
-		},
-		NewChatSessMgr: func() ChatSessionManager { return NewSessionManager(logger) },
-	}
-}
-
-func NewChatServiceRouter(logger *slog.Logger, cfg Config, fm FeedbagManager, sm SessionManager) ChatServiceRouter {
+func NewChatServiceRouter(logger *slog.Logger, cfg Config, oserviceHandler OServiceHandler, chatHandler ChatHandler, oserviceChatHandler OServiceChatHandler) ChatServiceRouter {
 	return ChatServiceRouter{
 	return ChatServiceRouter{
-		OServiceChatRouter: NewOServiceRouterForChat(logger, cfg, fm, sm),
-		ChatRouter:         NewChatRouter(logger),
+		OServiceChatRouter: NewOServiceRouterForChat(logger, oserviceHandler, oserviceChatHandler),
+		ChatRouter:         NewChatRouter(logger, chatHandler),
 		cfg:                cfg,
 		cfg:                cfg,
 		RouteLogger: RouteLogger{
 		RouteLogger: RouteLogger{
 			Logger: logger,
 			Logger: logger,
@@ -42,17 +22,26 @@ func NewChatServiceRouter(logger *slog.Logger, cfg Config, fm FeedbagManager, sm
 	}
 	}
 }
 }
 
 
+type AuthHandler interface {
+	Signout(ctx context.Context, sess *Session) error
+	VerifyLogin(conn net.Conn) (*Session, uint32, error)
+	VerifyChatLogin(rw io.ReadWriter) (*ChatCookie, uint32, error)
+	SendAndReceiveSignonFrame(rw io.ReadWriter, sequence *uint32) (oscar.FlapSignonFrame, error)
+	ReceiveAndSendBUCPLoginRequest(snacPayloadIn oscar.SNAC_0x17_0x02_BUCPLoginRequest, newUUID func() uuid.UUID) (oscar.XMessage, error)
+	ReceiveAndSendAuthChallenge(snacPayloadIn oscar.SNAC_0x17_0x06_BUCPChallengeRequest, newUUID func() uuid.UUID) (oscar.XMessage, error)
+	SignoutChat(ctx context.Context, cr *ChatRegistry, chatRoom ChatRoom, chatSessManager ChatSessionManager, sess *Session)
+}
+
 type BOSServiceRouter struct {
 type BOSServiceRouter struct {
 	AlertRouter
 	AlertRouter
+	AuthHandler
 	BuddyRouter
 	BuddyRouter
 	ChatNavRouter
 	ChatNavRouter
 	FeedbagRouter
 	FeedbagRouter
 	ICBMRouter
 	ICBMRouter
 	LocateRouter
 	LocateRouter
 	OServiceBOSRouter
 	OServiceBOSRouter
-	sm  SessionManager
-	fm  FeedbagManager
-	cfg Config
+	Cfg Config
 	RouteLogger
 	RouteLogger
 	NewChatSessMgr func() ChatSessionManager
 	NewChatSessMgr func() ChatSessionManager
 }
 }
@@ -92,7 +81,7 @@ func (rt *BOSServiceRouter) Route(ctx context.Context, sess *Session, r io.Reade
 			if err1 := sendInvalidSNACErr(snac, w, sequence); err1 != nil {
 			if err1 := sendInvalidSNACErr(snac, w, sequence); err1 != nil {
 				err = errors.Join(err1, err)
 				err = errors.Join(err1, err)
 			}
 			}
-			if rt.cfg.FailFast {
+			if rt.Cfg.FailFast {
 				panic(err.Error())
 				panic(err.Error())
 			}
 			}
 			return nil
 			return nil
@@ -102,35 +91,6 @@ func (rt *BOSServiceRouter) Route(ctx context.Context, sess *Session, r io.Reade
 	return err
 	return err
 }
 }
 
 
-func (rt *BOSServiceRouter) Signout(ctx context.Context, logger *slog.Logger, sess *Session) {
-	if err := BroadcastDeparture(ctx, sess, rt.sm, rt.fm); err != nil {
-		logger.ErrorContext(ctx, "error notifying departure", "err", err.Error())
-	}
-	rt.sm.Remove(sess)
-}
-
-func (rt *BOSServiceRouter) VerifyLogin(conn net.Conn) (*Session, uint32, error) {
-	seq := uint32(100)
-
-	flap, err := SendAndReceiveSignonFrame(conn, &seq)
-	if err != nil {
-		return nil, 0, err
-	}
-
-	var ok bool
-	ID, ok := flap.GetSlice(oscar.OServiceTLVTagsLoginCookie)
-	if !ok {
-		return nil, 0, errors.New("unable to get session id from payload")
-	}
-
-	sess, ok := rt.sm.Retrieve(string(ID))
-	if !ok {
-		return nil, 0, fmt.Errorf("unable to find session by id %s", ID)
-	}
-
-	return sess, seq, nil
-}
-
 func writeOutSNAC(originsnac oscar.SnacFrame, snacFrame oscar.SnacFrame, snacOut any, sequence *uint32, w io.Writer) error {
 func writeOutSNAC(originsnac oscar.SnacFrame, snacFrame oscar.SnacFrame, snacOut any, sequence *uint32, w io.Writer) error {
 	if originsnac.RequestID != 0 {
 	if originsnac.RequestID != 0 {
 		snacFrame.RequestID = originsnac.RequestID
 		snacFrame.RequestID = originsnac.RequestID

+ 1 - 1
server/session_manager_mock.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.35.2. DO NOT EDIT.
+// Code generated by mockery v2.34.2. DO NOT EDIT.
 
 
 package server
 package server
 
 

+ 5 - 0
server/types.go

@@ -6,6 +6,11 @@ import (
 	"time"
 	"time"
 )
 )
 
 
+type UserManager interface {
+	GetUser(screenName string) (*User, error)
+	UpsertUser(u User) error
+}
+
 type FeedbagManager interface {
 type FeedbagManager interface {
 	Blocked(sn1, sn2 string) (BlockedState, error)
 	Blocked(sn1, sn2 string) (BlockedState, error)
 	Buddies(screenName string) ([]string, error)
 	Buddies(screenName string) ([]string, error)

+ 128 - 0
server/user_manager_manager_mock.go

@@ -0,0 +1,128 @@
+// Code generated by mockery v2.34.2. DO NOT EDIT.
+
+package server
+
+import mock "github.com/stretchr/testify/mock"
+
+// MockUserManager is an autogenerated mock type for the UserManager type
+type MockUserManager struct {
+	mock.Mock
+}
+
+type MockUserManager_Expecter struct {
+	mock *mock.Mock
+}
+
+func (_m *MockUserManager) EXPECT() *MockUserManager_Expecter {
+	return &MockUserManager_Expecter{mock: &_m.Mock}
+}
+
+// GetUser provides a mock function with given fields: screenName
+func (_m *MockUserManager) GetUser(screenName string) (*User, error) {
+	ret := _m.Called(screenName)
+
+	var r0 *User
+	var r1 error
+	if rf, ok := ret.Get(0).(func(string) (*User, error)); ok {
+		return rf(screenName)
+	}
+	if rf, ok := ret.Get(0).(func(string) *User); ok {
+		r0 = rf(screenName)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*User)
+		}
+	}
+
+	if rf, ok := ret.Get(1).(func(string) error); ok {
+		r1 = rf(screenName)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// MockUserManager_GetUser_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetUser'
+type MockUserManager_GetUser_Call struct {
+	*mock.Call
+}
+
+// GetUser is a helper method to define mock.On call
+//   - screenName string
+func (_e *MockUserManager_Expecter) GetUser(screenName interface{}) *MockUserManager_GetUser_Call {
+	return &MockUserManager_GetUser_Call{Call: _e.mock.On("GetUser", screenName)}
+}
+
+func (_c *MockUserManager_GetUser_Call) Run(run func(screenName string)) *MockUserManager_GetUser_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(string))
+	})
+	return _c
+}
+
+func (_c *MockUserManager_GetUser_Call) Return(_a0 *User, _a1 error) *MockUserManager_GetUser_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *MockUserManager_GetUser_Call) RunAndReturn(run func(string) (*User, error)) *MockUserManager_GetUser_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// UpsertUser provides a mock function with given fields: u
+func (_m *MockUserManager) UpsertUser(u User) error {
+	ret := _m.Called(u)
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(User) error); ok {
+		r0 = rf(u)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// MockUserManager_UpsertUser_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpsertUser'
+type MockUserManager_UpsertUser_Call struct {
+	*mock.Call
+}
+
+// UpsertUser is a helper method to define mock.On call
+//   - u User
+func (_e *MockUserManager_Expecter) UpsertUser(u interface{}) *MockUserManager_UpsertUser_Call {
+	return &MockUserManager_UpsertUser_Call{Call: _e.mock.On("UpsertUser", u)}
+}
+
+func (_c *MockUserManager_UpsertUser_Call) Run(run func(u User)) *MockUserManager_UpsertUser_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(User))
+	})
+	return _c
+}
+
+func (_c *MockUserManager_UpsertUser_Call) Return(_a0 error) *MockUserManager_UpsertUser_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *MockUserManager_UpsertUser_Call) RunAndReturn(run func(User) error) *MockUserManager_UpsertUser_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// NewMockUserManager creates a new instance of MockUserManager. 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 NewMockUserManager(t interface {
+	mock.TestingT
+	Cleanup(func())
+}) *MockUserManager {
+	mock := &MockUserManager{}
+	mock.Mock.Test(t)
+
+	t.Cleanup(func() { mock.AssertExpectations(t) })
+
+	return mock
+}