Bladeren bron

support client-side buddy lists for AIM 4.1

AIM versions 4.1 and older use client-side buddy list management and
versions newer than 4.1 use feedbag/server-side buddy list management.

The basic flow of this client-side buddy list implementation:
At signin, the AIM client dumps the buddy list content via SNAC
0x03-0x04 AddBuddies. The server adds each User->Buddy association to
an in-memory adjacency list. This list is used to retrieve a user's
buddies as well as look up all the users who have added a user as a
buddy.

When the server sends BuddyArrived and BuddyDeparted events, it consults
the adjacency list (in addition to the feedbag) for a list of users.
Mike 2 jaren geleden
bovenliggende
commit
ec11bf3750

+ 4 - 1
.mockery.yaml

@@ -88,4 +88,7 @@ packages:
           filename: "mock_chat_registry_test.go"
       BARTManager:
         config:
-          filename: "mock_bart_manager_test.go"
+          filename: "mock_bart_manager_test.go"
+      LegacyBuddyListManager:
+        config:
+          filename: "mock_legacy_buddy_list_manager_test.go"

+ 12 - 11
cmd/server/main.go

@@ -34,6 +34,7 @@ func main() {
 	logger := middleware.NewLogger(cfg)
 	sessionManager := state.NewInMemorySessionManager(logger)
 	chatRegistry := state.NewChatRegistry()
+	adjListBuddyListStore := state.NewAdjListBuddyListStore()
 
 	wg := sync.WaitGroup{}
 	wg.Add(4)
@@ -44,16 +45,16 @@ func main() {
 	}()
 	go func(logger *slog.Logger) {
 		logger = logger.With("svc", "BOS")
-		authService := foodgroup.NewAuthService(cfg, sessionManager, sessionManager, feedbagStore, feedbagStore, chatRegistry)
-		bartService := foodgroup.NewBARTService(logger, feedbagStore, sessionManager, feedbagStore)
-		buddyService := foodgroup.NewBuddyService()
-		oServiceService := foodgroup.NewOServiceService(cfg, sessionManager, feedbagStore)
+		authService := foodgroup.NewAuthService(cfg, sessionManager, sessionManager, feedbagStore, feedbagStore, chatRegistry, adjListBuddyListStore)
+		bartService := foodgroup.NewBARTService(logger, feedbagStore, sessionManager, feedbagStore, adjListBuddyListStore)
+		buddyService := foodgroup.NewBuddyService(sessionManager, feedbagStore, adjListBuddyListStore)
+		oServiceService := foodgroup.NewOServiceService(cfg, sessionManager, feedbagStore, adjListBuddyListStore)
 		oServiceServiceForBOS := foodgroup.NewOServiceServiceForBOS(*oServiceService, chatRegistry)
-		locateService := foodgroup.NewLocateService(sessionManager, feedbagStore, feedbagStore)
+		locateService := foodgroup.NewLocateService(sessionManager, feedbagStore, feedbagStore, adjListBuddyListStore)
 		newChatSessMgr := func() foodgroup.SessionManager { return state.NewInMemorySessionManager(logger) }
 		chatNavService := foodgroup.NewChatNavService(logger, chatRegistry, state.NewChatRoom, newChatSessMgr)
 		feedbagService := foodgroup.NewFeedbagService(logger, sessionManager, feedbagStore, feedbagStore)
-		icbmService := foodgroup.NewICBMService(sessionManager, feedbagStore)
+		icbmService := foodgroup.NewICBMService(sessionManager, feedbagStore, adjListBuddyListStore)
 
 		oscar.BOSServer{
 			AuthService: authService,
@@ -75,8 +76,8 @@ func main() {
 	}(logger)
 	go func(logger *slog.Logger) {
 		logger = logger.With("svc", "CHAT")
-		authService := foodgroup.NewAuthService(cfg, sessionManager, sessionManager, feedbagStore, feedbagStore, chatRegistry)
-		oServiceService := foodgroup.NewOServiceService(cfg, sessionManager, feedbagStore)
+		authService := foodgroup.NewAuthService(cfg, sessionManager, sessionManager, feedbagStore, feedbagStore, chatRegistry, adjListBuddyListStore)
+		oServiceService := foodgroup.NewOServiceService(cfg, sessionManager, feedbagStore, adjListBuddyListStore)
 		chatService := foodgroup.NewChatService(chatRegistry)
 		oServiceServiceForChat := foodgroup.NewOServiceServiceForChat(*oServiceService, chatRegistry)
 
@@ -94,8 +95,8 @@ func main() {
 	}(logger)
 	go func(logger *slog.Logger) {
 		logger = logger.With("svc", "CHAT_NAV")
-		authService := foodgroup.NewAuthService(cfg, sessionManager, sessionManager, feedbagStore, feedbagStore, chatRegistry)
-		oServiceService := foodgroup.NewOServiceService(cfg, sessionManager, feedbagStore)
+		authService := foodgroup.NewAuthService(cfg, sessionManager, sessionManager, feedbagStore, feedbagStore, chatRegistry, adjListBuddyListStore)
+		oServiceService := foodgroup.NewOServiceService(cfg, sessionManager, feedbagStore, adjListBuddyListStore)
 		oServiceServiceForChatNav := foodgroup.NewOServiceServiceForChatNav(*oServiceService, chatRegistry)
 		newChatSessMgr := func() foodgroup.SessionManager { return state.NewInMemorySessionManager(logger) }
 		chatNavService := foodgroup.NewChatNavService(logger, chatRegistry, state.NewChatRoom, newChatSessMgr)
@@ -114,7 +115,7 @@ func main() {
 	}(logger)
 	go func(logger *slog.Logger) {
 		logger = logger.With("svc", "AUTH")
-		authHandler := foodgroup.NewAuthService(cfg, sessionManager, nil, feedbagStore, feedbagStore, chatRegistry)
+		authHandler := foodgroup.NewAuthService(cfg, sessionManager, nil, feedbagStore, feedbagStore, chatRegistry, adjListBuddyListStore)
 
 		oscar.BUCPAuthService{
 			AuthService: authHandler,

+ 23 - 14
foodgroup/auth.go

@@ -13,25 +13,33 @@ import (
 )
 
 // NewAuthService creates a new instance of AuthService.
-func NewAuthService(cfg config.Config, sessionManager SessionManager, messageRelayer MessageRelayer, feedbagManager FeedbagManager, userManager UserManager, chatRegistry ChatRegistry) *AuthService {
+func NewAuthService(cfg config.Config,
+	sessionManager SessionManager,
+	messageRelayer MessageRelayer,
+	feedbagManager FeedbagManager,
+	userManager UserManager,
+	chatRegistry ChatRegistry,
+	legacyBuddyListManager LegacyBuddyListManager) *AuthService {
 	return &AuthService{
-		chatRegistry:   chatRegistry,
-		config:         cfg,
-		feedbagManager: feedbagManager,
-		messageRelayer: messageRelayer,
-		sessionManager: sessionManager,
-		userManager:    userManager,
+		chatRegistry:           chatRegistry,
+		config:                 cfg,
+		feedbagManager:         feedbagManager,
+		legacyBuddyListManager: legacyBuddyListManager,
+		messageRelayer:         messageRelayer,
+		sessionManager:         sessionManager,
+		userManager:            userManager,
 	}
 }
 
 // AuthService provides user BUCP login and session management services.
 type AuthService struct {
-	chatRegistry   ChatRegistry
-	config         config.Config
-	feedbagManager FeedbagManager
-	messageRelayer MessageRelayer
-	sessionManager SessionManager
-	userManager    UserManager
+	chatRegistry           ChatRegistry
+	config                 config.Config
+	feedbagManager         FeedbagManager
+	legacyBuddyListManager LegacyBuddyListManager
+	messageRelayer         MessageRelayer
+	sessionManager         SessionManager
+	userManager            UserManager
 }
 
 // RetrieveChatSession returns a chat room session. Return nil if the session
@@ -57,10 +65,11 @@ func (s AuthService) RetrieveBOSSession(sessionID string) (*state.Session, error
 // Signout removes user from the BOS server and notifies adjacent users (those
 // who have this user's screen name on their buddy list) of their departure.
 func (s AuthService) Signout(ctx context.Context, sess *state.Session) error {
-	if err := broadcastDeparture(ctx, sess, s.messageRelayer, s.feedbagManager); err != nil {
+	if err := broadcastDeparture(ctx, sess, s.messageRelayer, s.feedbagManager, s.legacyBuddyListManager); err != nil {
 		return err
 	}
 	s.sessionManager.RemoveSession(sess)
+	s.legacyBuddyListManager.DeleteUser(sess.ScreenName())
 	return nil
 }
 

+ 29 - 8
foodgroup/auth_test.go

@@ -554,7 +554,7 @@ func TestAuthService_RetrieveChatSession_HappyPath(t *testing.T) {
 		Retrieve(cookie).
 		Return(state.ChatRoom{}, sessionManager, nil)
 
-	svc := NewAuthService(config.Config{}, nil, nil, nil, nil, chatRegistry)
+	svc := NewAuthService(config.Config{}, nil, nil, nil, nil, chatRegistry, nil)
 
 	have, err := svc.RetrieveChatSession(buf.Bytes())
 	assert.NoError(t, err)
@@ -577,7 +577,7 @@ func TestAuthService_RetrieveChatSession_ChatNotFound(t *testing.T) {
 		Retrieve(cookie).
 		Return(state.ChatRoom{}, nil, state.ErrChatRoomNotFound)
 
-	svc := NewAuthService(config.Config{}, nil, nil, nil, nil, chatRegistry)
+	svc := NewAuthService(config.Config{}, nil, nil, nil, nil, chatRegistry, nil)
 
 	_, err := svc.RetrieveChatSession(loginCookie.Bytes())
 	assert.ErrorIs(t, err, state.ErrChatRoomNotFound)
@@ -604,7 +604,7 @@ func TestAuthService_RetrieveChatSession_SessionNotFound(t *testing.T) {
 		Retrieve(cookie).
 		Return(state.ChatRoom{}, sessionManager, nil)
 
-	svc := NewAuthService(config.Config{}, nil, nil, nil, nil, chatRegistry)
+	svc := NewAuthService(config.Config{}, nil, nil, nil, nil, chatRegistry, nil)
 
 	have, err := svc.RetrieveChatSession(buf.Bytes())
 	assert.NoError(t, err)
@@ -619,7 +619,7 @@ func TestAuthService_RetrieveBOSSession_HappyPath(t *testing.T) {
 		RetrieveSession(sess.ID()).
 		Return(sess)
 
-	svc := NewAuthService(config.Config{}, sessionManager, nil, nil, nil, nil)
+	svc := NewAuthService(config.Config{}, sessionManager, nil, nil, nil, nil, nil)
 
 	have, err := svc.RetrieveBOSSession(sess.ID())
 	assert.NoError(t, err)
@@ -634,7 +634,7 @@ func TestAuthService_RetrieveBOSSession_SessionNotFound(t *testing.T) {
 		RetrieveSession(sess.ID()).
 		Return(nil)
 
-	svc := NewAuthService(config.Config{}, sessionManager, nil, nil, nil, nil)
+	svc := NewAuthService(config.Config{}, sessionManager, nil, nil, nil, nil, nil)
 
 	have, err := svc.RetrieveBOSSession(sess.ID())
 	assert.NoError(t, err)
@@ -774,7 +774,7 @@ func TestAuthService_SignoutChat(t *testing.T) {
 				Retrieve(tt.chatRoom.Cookie).
 				Return(tt.chatRoom, chatSessionManager, tt.wantErr)
 
-			svc := NewAuthService(config.Config{}, nil, nil, nil, nil, chatRegistry)
+			svc := NewAuthService(config.Config{}, nil, nil, nil, nil, chatRegistry, nil)
 
 			err := svc.SignoutChat(nil, tt.userSession)
 			assert.ErrorIs(t, err, tt.wantErr)
@@ -820,6 +820,18 @@ func TestAuthService_Signout(t *testing.T) {
 						},
 					},
 				},
+				legacyBuddyListManagerParams: legacyBuddyListManagerParams{
+					deleteUserParams: deleteUserParams{
+						{
+							userScreenName: "user_screen_name",
+						},
+					},
+					whoAddedUserParams: whoAddedUserParams{
+						{
+							userScreenName: "user_screen_name",
+						},
+					},
+				},
 				messageRelayerParams: messageRelayerParams{
 					relayToScreenNamesParams: relayToScreenNamesParams{
 						{
@@ -842,7 +854,7 @@ func TestAuthService_Signout(t *testing.T) {
 			},
 		},
 		{
-			name:        "user signs out of chat room, room is empty after user leaves",
+			name:        "user signs out of chat room, feedbag lookup returns error",
 			userSession: sess,
 			chatRoom: state.ChatRoom{
 				Cookie: "the-chat-cookie",
@@ -878,8 +890,17 @@ func TestAuthService_Signout(t *testing.T) {
 			for _, params := range tt.mockParams.removeSessionParams {
 				sessionManager.EXPECT().RemoveSession(params.sess)
 			}
+			legacyBuddyListManager := newMockLegacyBuddyListManager(t)
+			for _, params := range tt.mockParams.deleteUserParams {
+				legacyBuddyListManager.EXPECT().DeleteUser(params.userScreenName)
+			}
+			for _, params := range tt.mockParams.whoAddedUserParams {
+				legacyBuddyListManager.EXPECT().
+					WhoAddedUser(params.userScreenName).
+					Return(params.result)
+			}
 
-			svc := NewAuthService(config.Config{}, sessionManager, messageRelayer, feedbagManager, nil, nil)
+			svc := NewAuthService(config.Config{}, sessionManager, messageRelayer, feedbagManager, nil, nil, legacyBuddyListManager)
 
 			err := svc.Signout(nil, tt.userSession)
 			assert.ErrorIs(t, err, tt.wantErr)

+ 12 - 10
foodgroup/bart.go

@@ -22,20 +22,22 @@ var blankGIF = []byte{
 // errKnownIconsOnly indicates that a non-known buddy icon was requested
 var errKnownIconsOnly = errors.New("can only satisfy requests for known icons")
 
-func NewBARTService(logger *slog.Logger, bartManager BARTManager, messageRelayer MessageRelayer, feedbagManager FeedbagManager) BARTService {
+func NewBARTService(logger *slog.Logger, bartManager BARTManager, messageRelayer MessageRelayer, feedbagManager FeedbagManager, legacyBuddyListManager LegacyBuddyListManager) BARTService {
 	return BARTService{
-		bartManager:    bartManager,
-		feedbagManager: feedbagManager,
-		logger:         logger,
-		messageRelayer: messageRelayer,
+		bartManager:            bartManager,
+		feedbagManager:         feedbagManager,
+		legacyBuddyListManager: legacyBuddyListManager,
+		logger:                 logger,
+		messageRelayer:         messageRelayer,
 	}
 }
 
 type BARTService struct {
-	bartManager    BARTManager
-	feedbagManager FeedbagManager
-	logger         *slog.Logger
-	messageRelayer MessageRelayer
+	bartManager            BARTManager
+	feedbagManager         FeedbagManager
+	legacyBuddyListManager LegacyBuddyListManager
+	logger                 *slog.Logger
+	messageRelayer         MessageRelayer
 }
 
 func (s BARTService) UpsertItem(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x10_0x02_BARTUploadQuery) (wire.SNACMessage, error) {
@@ -51,7 +53,7 @@ func (s BARTService) UpsertItem(ctx context.Context, sess *state.Session, inFram
 
 	s.logger.DebugContext(ctx, "successfully uploaded buddy icon", "hash", fmt.Sprintf("%x", hash))
 
-	if err := broadcastArrival(ctx, sess, s.messageRelayer, s.feedbagManager); err != nil {
+	if err := broadcastArrival(ctx, sess, s.messageRelayer, s.feedbagManager, s.legacyBuddyListManager); err != nil {
 		return wire.SNACMessage{}, err
 	}
 

+ 18 - 2
foodgroup/bart_test.go

@@ -75,6 +75,13 @@ func TestBARTService_UpsertItem(t *testing.T) {
 						},
 					},
 				},
+				legacyBuddyListManagerParams: legacyBuddyListManagerParams{
+					whoAddedUserParams: whoAddedUserParams{
+						{
+							userScreenName: "user_screen_name",
+						},
+					},
+				},
 			},
 			expectOutput: wire.SNACMessage{
 				Frame: wire.SNACFrame{
@@ -127,8 +134,17 @@ func TestBARTService_UpsertItem(t *testing.T) {
 					BARTUpsert(params.itemHash, params.payload).
 					Return(nil)
 			}
+			legacyBuddyListManager := newMockLegacyBuddyListManager(t)
+			for _, params := range tc.mockParams.deleteUserParams {
+				legacyBuddyListManager.EXPECT().DeleteUser(params.userScreenName)
+			}
+			for _, params := range tc.mockParams.whoAddedUserParams {
+				legacyBuddyListManager.EXPECT().
+					WhoAddedUser(params.userScreenName).
+					Return(params.result)
+			}
 
-			svc := NewBARTService(slog.Default(), bartManager, messageRelayer, feedbagManager)
+			svc := NewBARTService(slog.Default(), bartManager, messageRelayer, feedbagManager, legacyBuddyListManager)
 
 			output, err := svc.UpsertItem(nil, tc.userSession, tc.inputSNAC.Frame,
 				tc.inputSNAC.Body.(wire.SNAC_0x10_0x02_BARTUploadQuery))
@@ -273,7 +289,7 @@ func TestBARTService_RetrieveItem(t *testing.T) {
 					Return(params.result, nil)
 			}
 
-			svc := NewBARTService(slog.Default(), bartManager, nil, nil)
+			svc := NewBARTService(slog.Default(), bartManager, nil, nil, nil)
 
 			output, err := svc.RetrieveItem(nil, tc.userSession, tc.inputSNAC.Frame,
 				tc.inputSNAC.Body.(wire.SNAC_0x10_0x04_BARTDownloadQuery))

+ 67 - 8
foodgroup/buddy.go

@@ -11,8 +11,16 @@ import (
 )
 
 // NewBuddyService creates a new instance of BuddyService.
-func NewBuddyService() *BuddyService {
-	return &BuddyService{}
+func NewBuddyService(
+	messageRelayer MessageRelayer,
+	feedbagManager FeedbagManager,
+	legacyBuddyListManager LegacyBuddyListManager,
+) *BuddyService {
+	return &BuddyService{
+		feedbagManager:         feedbagManager,
+		legacyBuddyListManager: legacyBuddyListManager,
+		messageRelayer:         messageRelayer,
+	}
 }
 
 // BuddyService provides functionality for the Buddy food group, which sends
@@ -21,6 +29,9 @@ func NewBuddyService() *BuddyService {
 // Server. BuddyService just exists to satisfy AIM 5.x's buddy rights requests.
 // It may be expanded in the future to support older versions of AIM.
 type BuddyService struct {
+	feedbagManager         FeedbagManager
+	legacyBuddyListManager LegacyBuddyListManager
+	messageRelayer         MessageRelayer
 }
 
 // RightsQuery returns buddy list service parameters.
@@ -44,16 +55,54 @@ func (s BuddyService) RightsQuery(_ context.Context, frameIn wire.SNACFrame) wir
 	}
 }
 
+func (s BuddyService) AddBuddies(ctx context.Context, sess *state.Session, inBody wire.SNAC_0x03_0x04_BuddyAddBuddies) error {
+	for _, entry := range inBody.Buddies {
+		s.legacyBuddyListManager.AddBuddy(sess.ScreenName(), entry.ScreenName)
+		if !sess.SignonComplete() {
+			// client has not completed sign-on sequence, so any arrival
+			// messages sent at this point would be ignored by the client.
+			continue
+		}
+		buddy := s.messageRelayer.RetrieveByScreenName(entry.ScreenName)
+		if buddy == nil || buddy.Invisible() {
+			continue
+		}
+		// notify that buddy is online
+		if err := unicastArrival(ctx, buddy, sess, s.messageRelayer, s.feedbagManager); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func (s BuddyService) DelBuddies(_ context.Context, sess *state.Session, inBody wire.SNAC_0x03_0x05_BuddyDelBuddies) {
+	for _, entry := range inBody.Buddies {
+		s.legacyBuddyListManager.DeleteBuddy(sess.ScreenName(), entry.ScreenName)
+	}
+}
+
 // broadcastArrival sends the latest user info to the user's adjacent users.
 // While updates are sent via the wire.BuddyArrived SNAC, the message is not
 // only used to indicate the user coming online. It can also notify changes to
 // buddy icons, warning levels, invisibility status, etc.
-func broadcastArrival(ctx context.Context, sess *state.Session, messageRelayer MessageRelayer, feedbagManager FeedbagManager) error {
-	screenNames, err := feedbagManager.AdjacentUsers(sess.ScreenName())
+func broadcastArrival(
+	ctx context.Context,
+	sess *state.Session,
+	messageRelayer MessageRelayer,
+	feedbagManager FeedbagManager,
+	legacyBuddyListManager LegacyBuddyListManager,
+) error {
+
+	// find users who have this user on their server-side buddy list
+	recipients, err := feedbagManager.AdjacentUsers(sess.ScreenName())
 	if err != nil {
 		return err
 	}
 
+	// find users who have this user on their client-side buddy list
+	legacyUsers := legacyBuddyListManager.WhoAddedUser(sess.ScreenName())
+	recipients = append(recipients, legacyUsers...)
+
 	userInfo := sess.TLVUserInfo()
 	icon, err := getBuddyIconRefFromFeedbag(sess, feedbagManager)
 	switch {
@@ -63,7 +112,7 @@ func broadcastArrival(ctx context.Context, sess *state.Session, messageRelayer M
 		userInfo.Append(wire.NewTLV(wire.OServiceUserInfoBARTInfo, *icon))
 	}
 
-	messageRelayer.RelayToScreenNames(ctx, screenNames, wire.SNACMessage{
+	messageRelayer.RelayToScreenNames(ctx, recipients, wire.SNACMessage{
 		Frame: wire.SNACFrame{
 			FoodGroup: wire.Buddy,
 			SubGroup:  wire.BuddyArrived,
@@ -130,13 +179,23 @@ func extractBARTItemType(item wire.FeedbagItem) (uint16, error) {
 	return bartType, nil
 }
 
-func broadcastDeparture(ctx context.Context, sess *state.Session, messageRelayer MessageRelayer, feedbagManager FeedbagManager) error {
-	screenNames, err := feedbagManager.AdjacentUsers(sess.ScreenName())
+func broadcastDeparture(
+	ctx context.Context,
+	sess *state.Session,
+	messageRelayer MessageRelayer,
+	feedbagManager FeedbagManager,
+	legacyBuddyListManager LegacyBuddyListManager,
+) error {
+
+	recipients, err := feedbagManager.AdjacentUsers(sess.ScreenName())
 	if err != nil {
 		return err
 	}
 
-	messageRelayer.RelayToScreenNames(ctx, screenNames, wire.SNACMessage{
+	legacyUsers := legacyBuddyListManager.WhoAddedUser(sess.ScreenName())
+	recipients = append(recipients, legacyUsers...)
+
+	messageRelayer.RelayToScreenNames(ctx, recipients, wire.SNACMessage{
 		Frame: wire.SNACFrame{
 			FoodGroup: wire.Buddy,
 			SubGroup:  wire.BuddyDeparted,

+ 206 - 1
foodgroup/buddy_test.go

@@ -3,13 +3,16 @@ package foodgroup
 import (
 	"testing"
 
+	"github.com/stretchr/testify/mock"
+
+	"github.com/mk6i/retro-aim-server/state"
 	"github.com/mk6i/retro-aim-server/wire"
 
 	"github.com/stretchr/testify/assert"
 )
 
 func TestBuddyService_RightsQuery(t *testing.T) {
-	svc := NewBuddyService()
+	svc := NewBuddyService(nil, nil, nil)
 
 	want := wire.SNACMessage{
 		Frame: wire.SNACFrame{
@@ -32,3 +35,205 @@ func TestBuddyService_RightsQuery(t *testing.T) {
 
 	assert.Equal(t, want, have)
 }
+
+func TestBuddyService_AddBuddies(t *testing.T) {
+	tests := []struct {
+		// name is the name of the test
+		name string
+		// sess is the client session
+		sess *state.Session
+		// bodyIn is the input SNAC
+		bodyIn wire.SNAC_0x03_0x04_BuddyAddBuddies
+		// mockParams is the list of params sent to mocks that satisfy this
+		// method's dependencies
+		mockParams mockParams
+		// wantErr is the expected error
+		wantErr error
+	}{
+		{
+			name: "add 2 buddies, sign-on complete",
+			sess: newTestSession("user_screen_name", sessOptSignonComplete),
+			bodyIn: wire.SNAC_0x03_0x04_BuddyAddBuddies{
+				Buddies: []struct {
+					ScreenName string `len_prefix:"uint8"`
+				}{
+					{
+						ScreenName: "buddy_1_online",
+					},
+					{
+						ScreenName: "buddy_2_offline",
+					},
+				},
+			},
+			mockParams: mockParams{
+				legacyBuddyListManagerParams: legacyBuddyListManagerParams{
+					addBuddyParams: addBuddyParams{
+						{
+							userScreenName:  "user_screen_name",
+							buddyScreenName: "buddy_1_online",
+						},
+						{
+							userScreenName:  "user_screen_name",
+							buddyScreenName: "buddy_2_offline",
+						},
+					},
+				},
+				messageRelayerParams: messageRelayerParams{
+					retrieveByScreenNameParams: retrieveByScreenNameParams{
+						{
+							screenName: "buddy_1_online",
+							sess:       newTestSession("buddy_1_online", sessOptCannedSignonTime),
+						},
+						{
+							screenName: "buddy_2_offline",
+							sess:       nil,
+						},
+					},
+					relayToScreenNameParams: relayToScreenNameParams{
+						{
+							screenName: "user_screen_name",
+							message: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.Buddy,
+									SubGroup:  wire.BuddyArrived,
+								},
+								Body: wire.SNAC_0x03_0x0B_BuddyArrived{
+									TLVUserInfo: newTestSession("buddy_1_online", sessOptCannedSignonTime).TLVUserInfo(),
+								},
+							},
+						},
+					},
+				},
+				feedbagManagerParams: feedbagManagerParams{
+					feedbagParams: feedbagParams{
+						{
+							screenName: "buddy_1_online",
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "add 2 buddies, sign-on not complete",
+			sess: newTestSession("user_screen_name"),
+			bodyIn: wire.SNAC_0x03_0x04_BuddyAddBuddies{
+				Buddies: []struct {
+					ScreenName string `len_prefix:"uint8"`
+				}{
+					{
+						ScreenName: "buddy_1_online",
+					},
+					{
+						ScreenName: "buddy_2_offline",
+					},
+				},
+			},
+			mockParams: mockParams{
+				legacyBuddyListManagerParams: legacyBuddyListManagerParams{
+					addBuddyParams: addBuddyParams{
+						{
+							userScreenName:  "user_screen_name",
+							buddyScreenName: "buddy_1_online",
+						},
+						{
+							userScreenName:  "user_screen_name",
+							buddyScreenName: "buddy_2_offline",
+						},
+					},
+				},
+			},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			messageRelayer := newMockMessageRelayer(t)
+			for _, params := range tt.mockParams.retrieveByScreenNameParams {
+				messageRelayer.EXPECT().
+					RetrieveByScreenName(params.screenName).
+					Return(params.sess)
+			}
+			for _, params := range tt.mockParams.relayToScreenNameParams {
+				messageRelayer.EXPECT().
+					RelayToScreenName(mock.Anything, params.screenName, params.message)
+			}
+
+			feedbagManager := newMockFeedbagManager(t)
+			for _, params := range tt.mockParams.feedbagParams {
+				feedbagManager.EXPECT().
+					Feedbag(params.screenName).
+					Return(params.results, nil)
+			}
+
+			legacyBuddyListManager := newMockLegacyBuddyListManager(t)
+			for _, params := range tt.mockParams.addBuddyParams {
+				legacyBuddyListManager.EXPECT().
+					AddBuddy(params.userScreenName, params.buddyScreenName)
+			}
+
+			svc := NewBuddyService(messageRelayer, feedbagManager, legacyBuddyListManager)
+
+			haveErr := svc.AddBuddies(nil, tt.sess, tt.bodyIn)
+			assert.ErrorIs(t, tt.wantErr, haveErr)
+		})
+	}
+}
+
+func TestBuddyService_DelBuddies(t *testing.T) {
+	tests := []struct {
+		// name is the name of the test
+		name string
+		// sess is the client session
+		sess *state.Session
+		// bodyIn is the input SNAC
+		bodyIn wire.SNAC_0x03_0x05_BuddyDelBuddies
+		// mockParams is the list of params sent to mocks that satisfy this
+		// method's dependencies
+		mockParams mockParams
+		// wantErr is the expected error
+		wantErr error
+	}{
+		{
+			name: "delete 2 buddies",
+			sess: newTestSession("user_screen_name", sessOptSignonComplete),
+			bodyIn: wire.SNAC_0x03_0x05_BuddyDelBuddies{
+				Buddies: []struct {
+					ScreenName string `len_prefix:"uint8"`
+				}{
+					{
+						ScreenName: "buddy_1_online",
+					},
+					{
+						ScreenName: "buddy_2_offline",
+					},
+				},
+			},
+			mockParams: mockParams{
+				legacyBuddyListManagerParams: legacyBuddyListManagerParams{
+					deleteBuddyParams: deleteBuddyParams{
+						{
+							userScreenName:  "user_screen_name",
+							buddyScreenName: "buddy_1_online",
+						},
+						{
+							userScreenName:  "user_screen_name",
+							buddyScreenName: "buddy_2_offline",
+						},
+					},
+				},
+			},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			legacyBuddyListManager := newMockLegacyBuddyListManager(t)
+			for _, params := range tt.mockParams.deleteBuddyParams {
+				legacyBuddyListManager.EXPECT().
+					DeleteBuddy(params.userScreenName, params.buddyScreenName)
+			}
+
+			svc := NewBuddyService(nil, nil, legacyBuddyListManager)
+
+			svc.DelBuddies(nil, tt.sess, tt.bodyIn)
+		})
+	}
+}

+ 8 - 8
foodgroup/feedbag.go

@@ -25,10 +25,11 @@ func NewFeedbagService(logger *slog.Logger, messageRelayer MessageRelayer, feedb
 // FeedbagService provides functionality for the Feedbag food group, which
 // handles buddy list management.
 type FeedbagService struct {
-	messageRelayer MessageRelayer
-	feedbagManager FeedbagManager
-	bartManager    BARTManager
-	logger         *slog.Logger
+	bartManager            BARTManager
+	feedbagManager         FeedbagManager
+	legacyBuddyListManager LegacyBuddyListManager
+	logger                 *slog.Logger
+	messageRelayer         MessageRelayer
 }
 
 // RightsQuery returns SNAC wire.FeedbagRightsReply, which contains Feedbag
@@ -157,8 +158,7 @@ func (s FeedbagService) QueryIfModified(_ context.Context, sess *state.Session,
 	}, nil
 }
 
-// UpsertItem yadada
-// InsertItem adds items to the user's feedbag (aka buddy list). Sends user
+// UpsertItem updates items in the user's feedbag (aka buddy list). Sends user
 // buddy arrival notifications for each online & visible buddy added to the
 // feedbag. Sends a buddy departure notification to blocked buddies if current
 // user is visible. It returns wire.FeedbagStatus, which contains insert
@@ -249,7 +249,7 @@ func (s FeedbagService) broadcastIconUpdate(ctx context.Context, sess *state.Ses
 		s.logger.DebugContext(ctx, "user is clearing icon",
 			"hash", fmt.Sprintf("%x", btlv.Hash))
 		// tell buddies about the icon update
-		return broadcastArrival(ctx, sess, s.messageRelayer, s.feedbagManager)
+		return broadcastArrival(ctx, sess, s.messageRelayer, s.feedbagManager, s.legacyBuddyListManager)
 	}
 
 	bid := wire.BARTID{
@@ -270,7 +270,7 @@ func (s FeedbagService) broadcastIconUpdate(ctx context.Context, sess *state.Ses
 		s.logger.DebugContext(ctx, "icon already exists in BART store, don't upload the icon file",
 			"hash", fmt.Sprintf("%x", btlv.Hash))
 		// tell buddies about the icon update
-		if err := broadcastArrival(ctx, sess, s.messageRelayer, s.feedbagManager); err != nil {
+		if err := broadcastArrival(ctx, sess, s.messageRelayer, s.feedbagManager, s.legacyBuddyListManager); err != nil {
 			return err
 		}
 	}

+ 28 - 5
foodgroup/feedbag_test.go

@@ -986,6 +986,13 @@ func TestFeedbagService_UpsertItem(t *testing.T) {
 						},
 					},
 				},
+				legacyBuddyListManagerParams: legacyBuddyListManagerParams{
+					whoAddedUserParams: whoAddedUserParams{
+						{
+							userScreenName: "user_screen_name",
+						},
+					},
+				},
 			},
 			expectOutput: wire.SNACMessage{
 				Frame: wire.SNACFrame{
@@ -1095,6 +1102,13 @@ func TestFeedbagService_UpsertItem(t *testing.T) {
 						},
 					},
 				},
+				legacyBuddyListManagerParams: legacyBuddyListManagerParams{
+					whoAddedUserParams: whoAddedUserParams{
+						{
+							userScreenName: "user_screen_name",
+						},
+					},
+				},
 			},
 			expectOutput: wire.SNACMessage{
 				Frame: wire.SNACFrame{
@@ -1123,7 +1137,9 @@ func TestFeedbagService_UpsertItem(t *testing.T) {
 					Return(params.users, params.err)
 			}
 			for _, params := range tc.mockParams.feedbagManagerParams.feedbagParams {
-				feedbagManager.EXPECT().Feedbag(params.screenName).Return(params.results, nil)
+				feedbagManager.EXPECT().
+					Feedbag(params.screenName).
+					Return(params.results, nil)
 			}
 			messageRelayer := newMockMessageRelayer(t)
 			for _, params := range tc.mockParams.messageRelayerParams.retrieveByScreenNameParams {
@@ -1145,12 +1161,19 @@ func TestFeedbagService_UpsertItem(t *testing.T) {
 					BARTRetrieve(params.itemHash).
 					Return(params.result, nil)
 			}
+			legacyBuddyListManager := newMockLegacyBuddyListManager(t)
+			for _, params := range tc.mockParams.whoAddedUserParams {
+				legacyBuddyListManager.EXPECT().
+					WhoAddedUser(params.userScreenName).
+					Return(params.result)
+			}
 
 			svc := FeedbagService{
-				bartManager:    bartManager,
-				feedbagManager: feedbagManager,
-				logger:         slog.Default(),
-				messageRelayer: messageRelayer,
+				bartManager:            bartManager,
+				feedbagManager:         feedbagManager,
+				legacyBuddyListManager: legacyBuddyListManager,
+				logger:                 slog.Default(),
+				messageRelayer:         messageRelayer,
 			}
 			output, err := svc.UpsertItem(nil, tc.userSession, tc.inputSNAC.Frame,
 				tc.inputSNAC.Body.(wire.SNAC_0x13_0x08_FeedbagInsertItem).Items)

+ 14 - 5
foodgroup/icbm.go

@@ -13,16 +13,25 @@ const (
 )
 
 // NewICBMService returns a new instance of ICBMService.
-func NewICBMService(messageRelayer MessageRelayer, feedbagManager FeedbagManager) *ICBMService {
-	return &ICBMService{messageRelayer: messageRelayer, feedbagManager: feedbagManager}
+func NewICBMService(
+	messageRelayer MessageRelayer,
+	feedbagManager FeedbagManager,
+	legacyBuddyListManager LegacyBuddyListManager,
+) *ICBMService {
+	return &ICBMService{
+		feedbagManager:         feedbagManager,
+		legacyBuddyListManager: legacyBuddyListManager,
+		messageRelayer:         messageRelayer,
+	}
 }
 
 // ICBMService provides functionality for the ICBM food group, which is
 // responsible for sending and receiving instant messages and associated
 // functionality such as warning, typing events, etc.
 type ICBMService struct {
-	messageRelayer MessageRelayer
-	feedbagManager FeedbagManager
+	feedbagManager         FeedbagManager
+	legacyBuddyListManager LegacyBuddyListManager
+	messageRelayer         MessageRelayer
 }
 
 // ParameterQuery returns ICBM service parameters.
@@ -237,7 +246,7 @@ func (s ICBMService) EvilRequest(ctx context.Context, sess *state.Session, inFra
 	})
 
 	// inform the warned user's buddies that their warning level has increased
-	if err := broadcastArrival(ctx, recipSess, s.messageRelayer, s.feedbagManager); err != nil {
+	if err := broadcastArrival(ctx, recipSess, s.messageRelayer, s.feedbagManager, s.legacyBuddyListManager); err != nil {
 		return wire.SNACMessage{}, err
 	}
 

+ 32 - 5
foodgroup/icbm_test.go

@@ -246,7 +246,7 @@ func TestICBMService_ChannelMsgToHost(t *testing.T) {
 			//
 			// send input SNAC
 			//
-			svc := NewICBMService(messageRelayer, feedbagManager)
+			svc := NewICBMService(messageRelayer, feedbagManager, nil)
 			outputSNAC, err := svc.ChannelMsgToHost(nil, tc.senderSession, tc.inputSNAC.Frame,
 				tc.inputSNAC.Body.(wire.SNAC_0x04_0x06_ICBMChannelMsgToHost))
 			assert.NoError(t, err)
@@ -336,7 +336,7 @@ func TestICBMService_ClientEvent(t *testing.T) {
 			// send input SNAC
 			//
 			senderSession := newTestSession(tc.senderScreenName)
-			svc := NewICBMService(messageRelayer, feedbagManager)
+			svc := NewICBMService(messageRelayer, feedbagManager, nil)
 			assert.NoError(t, svc.ClientEvent(nil, senderSession, tc.inputSNAC.Frame,
 				tc.inputSNAC.Body.(wire.SNAC_0x04_0x14_ICBMClientEvent)))
 		})
@@ -365,8 +365,11 @@ func TestICBMService_EvilRequest(t *testing.T) {
 		// expectSNACToClient is the SNAC sent from the server to the
 		// recipient client
 		expectSNACToClient wire.SNACMessage
-
+		// expectOutput is the SNAC sent from the server to client
 		expectOutput wire.SNACMessage
+		// mockParams is the list of params sent to mocks that satisfy this
+		// method's dependencies
+		mockParams mockParams
 	}{
 		{
 			name:                "transmit anonymous warning from sender to recipient",
@@ -412,6 +415,15 @@ func TestICBMService_EvilRequest(t *testing.T) {
 					UpdatedEvilValue: 30,
 				},
 			},
+			mockParams: mockParams{
+				legacyBuddyListManagerParams: legacyBuddyListManagerParams{
+					whoAddedUserParams: whoAddedUserParams{
+						{
+							userScreenName: "recipient-screen-name",
+						},
+					},
+				},
+			},
 		},
 		{
 			name:                "transmit non-anonymous warning from sender to recipient",
@@ -461,6 +473,15 @@ func TestICBMService_EvilRequest(t *testing.T) {
 					UpdatedEvilValue: 100,
 				},
 			},
+			mockParams: mockParams{
+				legacyBuddyListManagerParams: legacyBuddyListManagerParams{
+					whoAddedUserParams: whoAddedUserParams{
+						{
+							userScreenName: "recipient-screen-name",
+						},
+					},
+				},
+			},
 		},
 		{
 			name:                "don't transmit non-anonymous warning from sender to recipient because sender has blocked recipient",
@@ -570,11 +591,17 @@ func TestICBMService_EvilRequest(t *testing.T) {
 			messageRelayer.EXPECT().
 				RelayToScreenNames(mock.Anything, tc.recipientBuddies, tc.broadcastMessage).
 				Maybe()
+			legacyBuddyListManager := newMockLegacyBuddyListManager(t)
+			for _, params := range tc.mockParams.whoAddedUserParams {
+				legacyBuddyListManager.EXPECT().
+					WhoAddedUser(params.userScreenName).
+					Return(params.result)
+			}
 			//
 			// send input SNAC
 			//
 			senderSession := newTestSession(tc.senderSession.ScreenName())
-			svc := NewICBMService(messageRelayer, feedbagManager)
+			svc := NewICBMService(messageRelayer, feedbagManager, legacyBuddyListManager)
 			outputSNAC, err := svc.EvilRequest(nil, senderSession, tc.inputSNAC.Frame,
 				tc.inputSNAC.Body.(wire.SNAC_0x04_0x08_ICBMEvilRequest))
 			assert.NoError(t, err)
@@ -584,7 +611,7 @@ func TestICBMService_EvilRequest(t *testing.T) {
 }
 
 func TestICBMService_ParameterQuery(t *testing.T) {
-	svc := NewICBMService(nil, nil)
+	svc := NewICBMService(nil, nil, nil)
 
 	have := svc.ParameterQuery(nil, wire.SNACFrame{RequestID: 1234})
 	want := wire.SNACMessage{

+ 15 - 8
foodgroup/locate.go

@@ -20,11 +20,17 @@ var omitCaps = map[[16]byte]bool{
 }
 
 // NewLocateService creates a new instance of LocateService.
-func NewLocateService(messageRelayer MessageRelayer, feedbagManager FeedbagManager, profileManager ProfileManager) LocateService {
+func NewLocateService(
+	messageRelayer MessageRelayer,
+	feedbagManager FeedbagManager,
+	profileManager ProfileManager,
+	legacyBuddyListManager LegacyBuddyListManager,
+) LocateService {
 	return LocateService{
-		sessionManager: messageRelayer,
-		feedbagManager: feedbagManager,
-		profileManager: profileManager,
+		feedbagManager:         feedbagManager,
+		legacyBuddyListManager: legacyBuddyListManager,
+		profileManager:         profileManager,
+		sessionManager:         messageRelayer,
 	}
 }
 
@@ -32,9 +38,10 @@ func NewLocateService(messageRelayer MessageRelayer, feedbagManager FeedbagManag
 // responsible for user profiles, user info lookups, directory information, and
 // keyword lookups.
 type LocateService struct {
-	sessionManager MessageRelayer
-	feedbagManager FeedbagManager
-	profileManager ProfileManager
+	feedbagManager         FeedbagManager
+	legacyBuddyListManager LegacyBuddyListManager
+	profileManager         ProfileManager
+	sessionManager         MessageRelayer
 }
 
 // RightsQuery returns SNAC wire.LocateRightsReply, which contains Locate food
@@ -74,7 +81,7 @@ func (s LocateService) SetInfo(ctx context.Context, sess *state.Session, inBody
 	// broadcast away message change to buddies
 	if awayMsg, hasAwayMsg := inBody.String(wire.LocateTLVTagsInfoUnavailableData); hasAwayMsg {
 		sess.SetAwayMessage(awayMsg)
-		if err := broadcastArrival(ctx, sess, s.sessionManager, s.feedbagManager); err != nil {
+		if err := broadcastArrival(ctx, sess, s.sessionManager, s.feedbagManager, s.legacyBuddyListManager); err != nil {
 			return err
 		}
 	}

+ 25 - 6
foodgroup/locate_test.go

@@ -348,7 +348,13 @@ func TestLocateService_UserInfoQuery(t *testing.T) {
 					Profile(val.screenName).
 					Return(val.result, val.err)
 			}
-			svc := NewLocateService(messageRelayer, feedbagManager, profileManager)
+			legacyBuddyListManager := newMockLegacyBuddyListManager(t)
+			for _, params := range tc.mockParams.whoAddedUserParams {
+				legacyBuddyListManager.EXPECT().
+					WhoAddedUser(params.userScreenName).
+					Return(params.result)
+			}
+			svc := NewLocateService(messageRelayer, feedbagManager, profileManager, legacyBuddyListManager)
 			outputSNAC, err := svc.UserInfoQuery(context.Background(), tc.userSession, tc.inputSNAC.Frame,
 				tc.inputSNAC.Body.(wire.SNAC_0x02_0x05_LocateUserInfoQuery))
 			assert.NoError(t, err)
@@ -358,7 +364,7 @@ func TestLocateService_UserInfoQuery(t *testing.T) {
 }
 
 func TestLocateService_SetKeywordInfo(t *testing.T) {
-	svc := NewLocateService(nil, nil, nil)
+	svc := NewLocateService(nil, nil, nil, nil)
 
 	outputSNAC := svc.SetKeywordInfo(nil, wire.SNACFrame{RequestID: 1234})
 	expectSNAC := wire.SNACMessage{
@@ -376,7 +382,7 @@ func TestLocateService_SetKeywordInfo(t *testing.T) {
 }
 
 func TestLocateService_SetDirInfo(t *testing.T) {
-	svc := NewLocateService(nil, nil, nil)
+	svc := NewLocateService(nil, nil, nil, nil)
 
 	outputSNAC := svc.SetDirInfo(nil, wire.SNACFrame{RequestID: 1234})
 	expectSNAC := wire.SNACMessage{
@@ -467,6 +473,13 @@ func TestLocateService_SetInfo(t *testing.T) {
 						},
 					},
 				},
+				legacyBuddyListManagerParams: legacyBuddyListManagerParams{
+					whoAddedUserParams: whoAddedUserParams{
+						{
+							userScreenName: "user_screen_name",
+						},
+					},
+				},
 			},
 		},
 	}
@@ -494,14 +507,20 @@ func TestLocateService_SetInfo(t *testing.T) {
 					SetProfile(params.screenName, params.body).
 					Return(nil)
 			}
-			svc := NewLocateService(messageRelayer, feedbagManager, profileManager)
+			legacyBuddyListManager := newMockLegacyBuddyListManager(t)
+			for _, params := range tt.mockParams.whoAddedUserParams {
+				legacyBuddyListManager.EXPECT().
+					WhoAddedUser(params.userScreenName).
+					Return(params.result)
+			}
+			svc := NewLocateService(messageRelayer, feedbagManager, profileManager, legacyBuddyListManager)
 			assert.Equal(t, tt.wantErr, svc.SetInfo(nil, tt.userSession, tt.inBody))
 		})
 	}
 }
 
 func TestLocateService_SetInfo_SetCaps(t *testing.T) {
-	svc := NewLocateService(nil, nil, nil)
+	svc := NewLocateService(nil, nil, nil, nil)
 
 	sess := newTestSession("screen-name")
 	inBody := wire.SNAC_0x02_0x04_LocateSetInfo{
@@ -534,7 +553,7 @@ func TestLocateService_SetInfo_SetCaps(t *testing.T) {
 }
 
 func TestLocateService_RightsQuery(t *testing.T) {
-	svc := NewLocateService(nil, nil, nil)
+	svc := NewLocateService(nil, nil, nil, nil)
 
 	outputSNAC := svc.RightsQuery(nil, wire.SNACFrame{RequestID: 1234})
 	expectSNAC := wire.SNACMessage{

+ 0 - 49
foodgroup/mock_feedbag_manager_test.go

@@ -3,8 +3,6 @@
 package foodgroup
 
 import (
-	context "context"
-
 	state "github.com/mk6i/retro-aim-server/state"
 	mock "github.com/stretchr/testify/mock"
 
@@ -407,53 +405,6 @@ func (_c *mockFeedbagManager_FeedbagUpsert_Call) RunAndReturn(run func(string, [
 	return _c
 }
 
-// Use provides a mock function with given fields: ctx, sess
-func (_m *mockFeedbagManager) Use(ctx context.Context, sess *state.Session) error {
-	ret := _m.Called(ctx, sess)
-
-	if len(ret) == 0 {
-		panic("no return value specified for Use")
-	}
-
-	var r0 error
-	if rf, ok := ret.Get(0).(func(context.Context, *state.Session) error); ok {
-		r0 = rf(ctx, sess)
-	} else {
-		r0 = ret.Error(0)
-	}
-
-	return r0
-}
-
-// mockFeedbagManager_Use_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Use'
-type mockFeedbagManager_Use_Call struct {
-	*mock.Call
-}
-
-// Use is a helper method to define mock.On call
-//   - ctx context.Context
-//   - sess *state.Session
-func (_e *mockFeedbagManager_Expecter) Use(ctx interface{}, sess interface{}) *mockFeedbagManager_Use_Call {
-	return &mockFeedbagManager_Use_Call{Call: _e.mock.On("Use", ctx, sess)}
-}
-
-func (_c *mockFeedbagManager_Use_Call) Run(run func(ctx context.Context, sess *state.Session)) *mockFeedbagManager_Use_Call {
-	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(context.Context), args[1].(*state.Session))
-	})
-	return _c
-}
-
-func (_c *mockFeedbagManager_Use_Call) Return(_a0 error) *mockFeedbagManager_Use_Call {
-	_c.Call.Return(_a0)
-	return _c
-}
-
-func (_c *mockFeedbagManager_Use_Call) RunAndReturn(run func(context.Context, *state.Session) error) *mockFeedbagManager_Use_Call {
-	_c.Call.Return(run)
-	return _c
-}
-
 // newMockFeedbagManager creates a new instance of mockFeedbagManager. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
 // The first argument is typically a *testing.T value.
 func newMockFeedbagManager(t interface {

+ 229 - 0
foodgroup/mock_legacy_buddy_list_manager_test.go

@@ -0,0 +1,229 @@
+// Code generated by mockery v2.40.1. DO NOT EDIT.
+
+package foodgroup
+
+import mock "github.com/stretchr/testify/mock"
+
+// mockLegacyBuddyListManager is an autogenerated mock type for the LegacyBuddyListManager type
+type mockLegacyBuddyListManager struct {
+	mock.Mock
+}
+
+type mockLegacyBuddyListManager_Expecter struct {
+	mock *mock.Mock
+}
+
+func (_m *mockLegacyBuddyListManager) EXPECT() *mockLegacyBuddyListManager_Expecter {
+	return &mockLegacyBuddyListManager_Expecter{mock: &_m.Mock}
+}
+
+// AddBuddy provides a mock function with given fields: userScreenName, buddyScreenName
+func (_m *mockLegacyBuddyListManager) AddBuddy(userScreenName string, buddyScreenName string) {
+	_m.Called(userScreenName, buddyScreenName)
+}
+
+// mockLegacyBuddyListManager_AddBuddy_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddBuddy'
+type mockLegacyBuddyListManager_AddBuddy_Call struct {
+	*mock.Call
+}
+
+// AddBuddy is a helper method to define mock.On call
+//   - userScreenName string
+//   - buddyScreenName string
+func (_e *mockLegacyBuddyListManager_Expecter) AddBuddy(userScreenName interface{}, buddyScreenName interface{}) *mockLegacyBuddyListManager_AddBuddy_Call {
+	return &mockLegacyBuddyListManager_AddBuddy_Call{Call: _e.mock.On("AddBuddy", userScreenName, buddyScreenName)}
+}
+
+func (_c *mockLegacyBuddyListManager_AddBuddy_Call) Run(run func(userScreenName string, buddyScreenName string)) *mockLegacyBuddyListManager_AddBuddy_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(string), args[1].(string))
+	})
+	return _c
+}
+
+func (_c *mockLegacyBuddyListManager_AddBuddy_Call) Return() *mockLegacyBuddyListManager_AddBuddy_Call {
+	_c.Call.Return()
+	return _c
+}
+
+func (_c *mockLegacyBuddyListManager_AddBuddy_Call) RunAndReturn(run func(string, string)) *mockLegacyBuddyListManager_AddBuddy_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// Buddies provides a mock function with given fields: userScreenName
+func (_m *mockLegacyBuddyListManager) Buddies(userScreenName string) []string {
+	ret := _m.Called(userScreenName)
+
+	if len(ret) == 0 {
+		panic("no return value specified for Buddies")
+	}
+
+	var r0 []string
+	if rf, ok := ret.Get(0).(func(string) []string); ok {
+		r0 = rf(userScreenName)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).([]string)
+		}
+	}
+
+	return r0
+}
+
+// mockLegacyBuddyListManager_Buddies_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Buddies'
+type mockLegacyBuddyListManager_Buddies_Call struct {
+	*mock.Call
+}
+
+// Buddies is a helper method to define mock.On call
+//   - userScreenName string
+func (_e *mockLegacyBuddyListManager_Expecter) Buddies(userScreenName interface{}) *mockLegacyBuddyListManager_Buddies_Call {
+	return &mockLegacyBuddyListManager_Buddies_Call{Call: _e.mock.On("Buddies", userScreenName)}
+}
+
+func (_c *mockLegacyBuddyListManager_Buddies_Call) Run(run func(userScreenName string)) *mockLegacyBuddyListManager_Buddies_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(string))
+	})
+	return _c
+}
+
+func (_c *mockLegacyBuddyListManager_Buddies_Call) Return(_a0 []string) *mockLegacyBuddyListManager_Buddies_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockLegacyBuddyListManager_Buddies_Call) RunAndReturn(run func(string) []string) *mockLegacyBuddyListManager_Buddies_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// DeleteBuddy provides a mock function with given fields: userScreenName, buddyScreenName
+func (_m *mockLegacyBuddyListManager) DeleteBuddy(userScreenName string, buddyScreenName string) {
+	_m.Called(userScreenName, buddyScreenName)
+}
+
+// mockLegacyBuddyListManager_DeleteBuddy_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteBuddy'
+type mockLegacyBuddyListManager_DeleteBuddy_Call struct {
+	*mock.Call
+}
+
+// DeleteBuddy is a helper method to define mock.On call
+//   - userScreenName string
+//   - buddyScreenName string
+func (_e *mockLegacyBuddyListManager_Expecter) DeleteBuddy(userScreenName interface{}, buddyScreenName interface{}) *mockLegacyBuddyListManager_DeleteBuddy_Call {
+	return &mockLegacyBuddyListManager_DeleteBuddy_Call{Call: _e.mock.On("DeleteBuddy", userScreenName, buddyScreenName)}
+}
+
+func (_c *mockLegacyBuddyListManager_DeleteBuddy_Call) Run(run func(userScreenName string, buddyScreenName string)) *mockLegacyBuddyListManager_DeleteBuddy_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(string), args[1].(string))
+	})
+	return _c
+}
+
+func (_c *mockLegacyBuddyListManager_DeleteBuddy_Call) Return() *mockLegacyBuddyListManager_DeleteBuddy_Call {
+	_c.Call.Return()
+	return _c
+}
+
+func (_c *mockLegacyBuddyListManager_DeleteBuddy_Call) RunAndReturn(run func(string, string)) *mockLegacyBuddyListManager_DeleteBuddy_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// DeleteUser provides a mock function with given fields: userScreenName
+func (_m *mockLegacyBuddyListManager) DeleteUser(userScreenName string) {
+	_m.Called(userScreenName)
+}
+
+// mockLegacyBuddyListManager_DeleteUser_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteUser'
+type mockLegacyBuddyListManager_DeleteUser_Call struct {
+	*mock.Call
+}
+
+// DeleteUser is a helper method to define mock.On call
+//   - userScreenName string
+func (_e *mockLegacyBuddyListManager_Expecter) DeleteUser(userScreenName interface{}) *mockLegacyBuddyListManager_DeleteUser_Call {
+	return &mockLegacyBuddyListManager_DeleteUser_Call{Call: _e.mock.On("DeleteUser", userScreenName)}
+}
+
+func (_c *mockLegacyBuddyListManager_DeleteUser_Call) Run(run func(userScreenName string)) *mockLegacyBuddyListManager_DeleteUser_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(string))
+	})
+	return _c
+}
+
+func (_c *mockLegacyBuddyListManager_DeleteUser_Call) Return() *mockLegacyBuddyListManager_DeleteUser_Call {
+	_c.Call.Return()
+	return _c
+}
+
+func (_c *mockLegacyBuddyListManager_DeleteUser_Call) RunAndReturn(run func(string)) *mockLegacyBuddyListManager_DeleteUser_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// WhoAddedUser provides a mock function with given fields: userScreenName
+func (_m *mockLegacyBuddyListManager) WhoAddedUser(userScreenName string) []string {
+	ret := _m.Called(userScreenName)
+
+	if len(ret) == 0 {
+		panic("no return value specified for WhoAddedUser")
+	}
+
+	var r0 []string
+	if rf, ok := ret.Get(0).(func(string) []string); ok {
+		r0 = rf(userScreenName)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).([]string)
+		}
+	}
+
+	return r0
+}
+
+// mockLegacyBuddyListManager_WhoAddedUser_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WhoAddedUser'
+type mockLegacyBuddyListManager_WhoAddedUser_Call struct {
+	*mock.Call
+}
+
+// WhoAddedUser is a helper method to define mock.On call
+//   - userScreenName string
+func (_e *mockLegacyBuddyListManager_Expecter) WhoAddedUser(userScreenName interface{}) *mockLegacyBuddyListManager_WhoAddedUser_Call {
+	return &mockLegacyBuddyListManager_WhoAddedUser_Call{Call: _e.mock.On("WhoAddedUser", userScreenName)}
+}
+
+func (_c *mockLegacyBuddyListManager_WhoAddedUser_Call) Run(run func(userScreenName string)) *mockLegacyBuddyListManager_WhoAddedUser_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(string))
+	})
+	return _c
+}
+
+func (_c *mockLegacyBuddyListManager_WhoAddedUser_Call) Return(_a0 []string) *mockLegacyBuddyListManager_WhoAddedUser_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockLegacyBuddyListManager_WhoAddedUser_Call) RunAndReturn(run func(string) []string) *mockLegacyBuddyListManager_WhoAddedUser_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// newMockLegacyBuddyListManager creates a new instance of mockLegacyBuddyListManager. 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 newMockLegacyBuddyListManager(t interface {
+	mock.TestingT
+	Cleanup(func())
+}) *mockLegacyBuddyListManager {
+	mock := &mockLegacyBuddyListManager{}
+	mock.Mock.Test(t)
+
+	t.Cleanup(func() { mock.AssertExpectations(t) })
+
+	return mock
+}

+ 37 - 9
foodgroup/oservice.go

@@ -13,16 +13,27 @@ import (
 )
 
 // NewOServiceService creates a new instance of OServiceService.
-func NewOServiceService(cfg config.Config, messageRelayer MessageRelayer, feedbagManager FeedbagManager) *OServiceService {
-	return &OServiceService{cfg: cfg, messageRelayer: messageRelayer, feedbagManager: feedbagManager}
+func NewOServiceService(
+	cfg config.Config,
+	messageRelayer MessageRelayer,
+	feedbagManager FeedbagManager,
+	legacyBuddyListManager LegacyBuddyListManager,
+) *OServiceService {
+	return &OServiceService{
+		cfg:                    cfg,
+		feedbagManager:         feedbagManager,
+		legacyBuddyListManager: legacyBuddyListManager,
+		messageRelayer:         messageRelayer,
+	}
 }
 
 // OServiceService provides functionality for the OService food group, which
 // provides an assortment of services useful across multiple food groups.
 type OServiceService struct {
-	cfg            config.Config
-	feedbagManager FeedbagManager
-	messageRelayer MessageRelayer
+	cfg                    config.Config
+	feedbagManager         FeedbagManager
+	legacyBuddyListManager LegacyBuddyListManager
+	messageRelayer         MessageRelayer
 }
 
 // ClientVersions informs the server what food group versions the client
@@ -282,12 +293,12 @@ func (s OServiceService) SetUserInfoFields(ctx context.Context, sess *state.Sess
 		switch status {
 		case 0x0000:
 			sess.SetInvisible(false)
-			if err := broadcastArrival(ctx, sess, s.messageRelayer, s.feedbagManager); err != nil {
+			if err := broadcastArrival(ctx, sess, s.messageRelayer, s.feedbagManager, s.legacyBuddyListManager); err != nil {
 				return wire.SNACMessage{}, err
 			}
 		case 0x0100:
 			sess.SetInvisible(true)
-			if err := broadcastDeparture(ctx, sess, s.messageRelayer, s.feedbagManager); err != nil {
+			if err := broadcastDeparture(ctx, sess, s.messageRelayer, s.feedbagManager, s.legacyBuddyListManager); err != nil {
 				return wire.SNACMessage{}, err
 			}
 		default:
@@ -315,7 +326,7 @@ func (s OServiceService) IdleNotification(ctx context.Context, sess *state.Sessi
 	} else {
 		sess.SetIdle(time.Duration(bodyIn.IdleTime) * time.Second)
 	}
-	return broadcastArrival(ctx, sess, s.messageRelayer, s.feedbagManager)
+	return broadcastArrival(ctx, sess, s.messageRelayer, s.feedbagManager, s.legacyBuddyListManager)
 }
 
 // RateParamsSubAdd exists to capture the SNAC input in unit tests to
@@ -451,7 +462,24 @@ func (s OServiceServiceForBOS) HostOnline() wire.SNACMessage {
 // It announces current user's arrival to users who have the current user on
 // their buddy list.
 func (s OServiceServiceForBOS) ClientOnline(ctx context.Context, _ wire.SNAC_0x01_0x02_OServiceClientOnline, sess *state.Session) error {
-	return broadcastArrival(ctx, sess, s.messageRelayer, s.feedbagManager)
+	sess.SetSignonComplete()
+
+	if err := broadcastArrival(ctx, sess, s.messageRelayer, s.feedbagManager, s.legacyBuddyListManager); err != nil {
+		return err
+	}
+
+	// send buddy arrival events to client-side buddy list
+	buddies := s.legacyBuddyListManager.Buddies(sess.ScreenName())
+	for _, buddy := range buddies {
+		buddySess := s.messageRelayer.RetrieveByScreenName(buddy)
+		if buddySess == nil || buddySess.Invisible() {
+			continue
+		}
+		if err := unicastArrival(ctx, buddySess, sess, s.messageRelayer, s.feedbagManager); err != nil {
+			return err
+		}
+	}
+	return nil
 }
 
 // NewOServiceServiceForChat creates a new instance of OServiceServiceForChat.

+ 192 - 22
foodgroup/oservice_test.go

@@ -224,6 +224,9 @@ func TestSetUserInfoFields(t *testing.T) {
 		interestedUserLookups map[string][]string
 		// expectErr is the expected error returned
 		expectErr error
+		// mockParams is the list of params sent to mocks that satisfy this
+		// method's dependencies
+		mockParams mockParams
 	}{
 		{
 			name:        "set user status to visible",
@@ -270,6 +273,15 @@ func TestSetUserInfoFields(t *testing.T) {
 			interestedUserLookups: map[string][]string{
 				"user_screen_name": {"friend1", "friend2"},
 			},
+			mockParams: mockParams{
+				legacyBuddyListManagerParams: legacyBuddyListManagerParams{
+					whoAddedUserParams: whoAddedUserParams{
+						{
+							userScreenName: "user_screen_name",
+						},
+					},
+				},
+			},
 		},
 		{
 			name:        "set user status to invisible",
@@ -319,6 +331,15 @@ func TestSetUserInfoFields(t *testing.T) {
 			interestedUserLookups: map[string][]string{
 				"user_screen_name": {"friend1", "friend2"},
 			},
+			mockParams: mockParams{
+				legacyBuddyListManagerParams: legacyBuddyListManagerParams{
+					whoAddedUserParams: whoAddedUserParams{
+						{
+							userScreenName: "user_screen_name",
+						},
+					},
+				},
+			},
 		},
 	}
 
@@ -342,10 +363,16 @@ func TestSetUserInfoFields(t *testing.T) {
 			for _, broadcastMsg := range tc.broadcastMessage {
 				messageRelayer.EXPECT().RelayToScreenNames(mock.Anything, broadcastMsg.recipients, broadcastMsg.msg)
 			}
+			legacyBuddyListManager := newMockLegacyBuddyListManager(t)
+			for _, params := range tc.mockParams.whoAddedUserParams {
+				legacyBuddyListManager.EXPECT().
+					WhoAddedUser(params.userScreenName).
+					Return(params.result)
+			}
 			//
 			// send input SNAC
 			//
-			svc := NewOServiceService(config.Config{}, messageRelayer, feedbagManager)
+			svc := NewOServiceService(config.Config{}, messageRelayer, feedbagManager, legacyBuddyListManager)
 			outputSNAC, err := svc.SetUserInfoFields(nil, tc.userSession, tc.inputSNAC.Frame,
 				tc.inputSNAC.Body.(wire.SNAC_0x01_0x1E_OServiceSetUserInfoFields))
 			assert.ErrorIs(t, err, tc.expectErr)
@@ -361,7 +388,7 @@ func TestSetUserInfoFields(t *testing.T) {
 }
 
 func TestOServiceService_RateParamsQuery(t *testing.T) {
-	svc := NewOServiceService(config.Config{}, nil, nil)
+	svc := NewOServiceService(config.Config{}, nil, nil, nil)
 
 	have := svc.RateParamsQuery(nil, wire.SNACFrame{RequestID: 1234})
 	want := wire.SNACMessage{
@@ -563,7 +590,7 @@ func TestOServiceService_RateParamsQuery(t *testing.T) {
 }
 
 func TestOServiceServiceForBOS_OServiceHostOnline(t *testing.T) {
-	svc := NewOServiceServiceForBOS(*NewOServiceService(config.Config{}, nil, nil), nil)
+	svc := NewOServiceServiceForBOS(*NewOServiceService(config.Config{}, nil, nil, nil), nil)
 
 	want := wire.SNACMessage{
 		Frame: wire.SNACFrame{
@@ -589,7 +616,7 @@ func TestOServiceServiceForBOS_OServiceHostOnline(t *testing.T) {
 }
 
 func TestOServiceServiceForChat_OServiceHostOnline(t *testing.T) {
-	svc := NewOServiceServiceForChat(*NewOServiceService(config.Config{}, nil, nil), nil)
+	svc := NewOServiceServiceForChat(*NewOServiceService(config.Config{}, nil, nil, nil), nil)
 
 	want := wire.SNACMessage{
 		Frame: wire.SNACFrame{
@@ -609,7 +636,7 @@ func TestOServiceServiceForChat_OServiceHostOnline(t *testing.T) {
 }
 
 func TestOServiceService_ClientVersions(t *testing.T) {
-	svc := NewOServiceService(config.Config{}, nil, nil)
+	svc := NewOServiceService(config.Config{}, nil, nil, nil)
 
 	want := wire.SNACMessage{
 		Frame: wire.SNACFrame{
@@ -632,7 +659,7 @@ func TestOServiceService_ClientVersions(t *testing.T) {
 }
 
 func TestOServiceService_UserInfoQuery(t *testing.T) {
-	svc := NewOServiceService(config.Config{}, nil, nil)
+	svc := NewOServiceService(config.Config{}, nil, nil, nil)
 	sess := newTestSession("test-user")
 
 	want := wire.SNACMessage{
@@ -663,6 +690,9 @@ func TestOServiceService_IdleNotification(t *testing.T) {
 		recipientBuddies []string
 		broadcastMessage wire.SNACMessage
 		wantErr          error
+		// mockParams is the list of params sent to mocks that satisfy this
+		// method's dependencies
+		mockParams mockParams
 	}{
 		{
 			name: "set idle from active",
@@ -681,6 +711,15 @@ func TestOServiceService_IdleNotification(t *testing.T) {
 					TLVUserInfo: newTestSession("test-user", sessOptIdle(90*time.Second)).TLVUserInfo(),
 				},
 			},
+			mockParams: mockParams{
+				legacyBuddyListManagerParams: legacyBuddyListManagerParams{
+					whoAddedUserParams: whoAddedUserParams{
+						{
+							userScreenName: "test-user",
+						},
+					},
+				},
+			},
 		},
 		{
 			name: "set active from idle",
@@ -699,6 +738,15 @@ func TestOServiceService_IdleNotification(t *testing.T) {
 					TLVUserInfo: newTestSession("test-user").TLVUserInfo(),
 				},
 			},
+			mockParams: mockParams{
+				legacyBuddyListManagerParams: legacyBuddyListManagerParams{
+					whoAddedUserParams: whoAddedUserParams{
+						{
+							userScreenName: "test-user",
+						},
+					},
+				},
+			},
 		},
 	}
 	for _, tt := range tests {
@@ -716,8 +764,13 @@ func TestOServiceService_IdleNotification(t *testing.T) {
 			messageRelayer.EXPECT().
 				RelayToScreenNames(mock.Anything, tt.recipientBuddies, tt.broadcastMessage).
 				Maybe()
-
-			svc := NewOServiceService(config.Config{}, messageRelayer, feedbagManager)
+			legacyBuddyListManager := newMockLegacyBuddyListManager(t)
+			for _, params := range tt.mockParams.whoAddedUserParams {
+				legacyBuddyListManager.EXPECT().
+					WhoAddedUser(params.userScreenName).
+					Return(params.result)
+			}
+			svc := NewOServiceService(config.Config{}, messageRelayer, feedbagManager, legacyBuddyListManager)
 
 			haveErr := svc.IdleNotification(nil, tt.sess, tt.bodyIn)
 			assert.ErrorIs(t, tt.wantErr, haveErr)
@@ -739,7 +792,7 @@ func TestOServiceServiceForBOS_ClientOnline(t *testing.T) {
 		// bodyIn is the SNAC body sent from the arriving user's client to the
 		// server
 		bodyIn wire.SNAC_0x01_0x02_OServiceClientOnline
-		// buddiesParams contains params for looking up arriving user's
+		// buddyLookupParams contains params for looking up arriving user's
 		// buddies
 		buddyLookupParams buddiesLookupParams
 		// adjacentUsersParams contains params for looking up users who have
@@ -748,7 +801,7 @@ func TestOServiceServiceForBOS_ClientOnline(t *testing.T) {
 		// relayToScreenNamesParams contains params for sending
 		// buddy online notification to users who have the arriving user on
 		// their buddy list
-		broadcastToScreenNamesParams relayToScreenNamesParams
+		relayToScreenNamesParams relayToScreenNamesParams
 		// retrieveByScreenNameParams contains params for looking up the
 		// session for each of the arriving user's buddies
 		retrieveByScreenNameParams retrieveByScreenNameParams
@@ -759,9 +812,12 @@ func TestOServiceServiceForBOS_ClientOnline(t *testing.T) {
 		// feedbagParams contains params for retrieving a user's feedbag
 		feedbagParams feedbagParams
 		wantErr       error
+		// mockParams is the list of params sent to mocks that satisfy this
+		// method's dependencies
+		mockParams mockParams
 	}{
 		{
-			name:   "notify arriving user's buddies of their arrival",
+			name:   "notify feedbag buddies that user is online",
 			sess:   newTestSession("test-user"),
 			bodyIn: wire.SNAC_0x01_0x02_OServiceClientOnline{},
 			interestedUsersParams: adjacentUsersParams{
@@ -770,7 +826,7 @@ func TestOServiceServiceForBOS_ClientOnline(t *testing.T) {
 					users:      []string{"buddy1", "buddy2", "buddy3", "buddy4"},
 				},
 			},
-			broadcastToScreenNamesParams: relayToScreenNamesParams{
+			relayToScreenNamesParams: relayToScreenNamesParams{
 				{
 					screenNames: []string{"buddy1", "buddy2", "buddy3", "buddy4"},
 					message: wire.SNACMessage{
@@ -790,6 +846,111 @@ func TestOServiceServiceForBOS_ClientOnline(t *testing.T) {
 					results:    []wire.FeedbagItem{},
 				},
 			},
+			mockParams: mockParams{
+				legacyBuddyListManagerParams: legacyBuddyListManagerParams{
+					legacyBuddiesParams: legacyBuddiesParams{
+						{
+							userScreenName: "test-user",
+						},
+					},
+					whoAddedUserParams: whoAddedUserParams{
+						{
+							userScreenName: "test-user",
+						},
+					},
+				},
+			},
+		},
+		{
+			name:   "notify feedbag + client-side buddies that user is online, populate client-side buddy list",
+			sess:   newTestSession("test-user"),
+			bodyIn: wire.SNAC_0x01_0x02_OServiceClientOnline{},
+			interestedUsersParams: adjacentUsersParams{
+				{
+					screenName: "test-user",
+					users:      []string{"buddy1", "buddy2"},
+				},
+			},
+			relayToScreenNameParams: relayToScreenNameParams{
+				{
+					screenName: "test-user",
+					message: wire.SNACMessage{
+						Frame: wire.SNACFrame{
+							FoodGroup: wire.Buddy,
+							SubGroup:  wire.BuddyArrived,
+						},
+						Body: wire.SNAC_0x03_0x0B_BuddyArrived{
+							TLVUserInfo: newTestSession("buddy1", sessOptCannedSignonTime).TLVUserInfo(),
+						},
+					},
+				},
+				{
+					screenName: "test-user",
+					message: wire.SNACMessage{
+						Frame: wire.SNACFrame{
+							FoodGroup: wire.Buddy,
+							SubGroup:  wire.BuddyArrived,
+						},
+						Body: wire.SNAC_0x03_0x0B_BuddyArrived{
+							TLVUserInfo: newTestSession("buddy2", sessOptCannedSignonTime).TLVUserInfo(),
+						},
+					},
+				},
+			},
+			relayToScreenNamesParams: relayToScreenNamesParams{
+				{
+					screenNames: []string{"buddy1", "buddy2", "buddy3", "buddy4"},
+					message: wire.SNACMessage{
+						Frame: wire.SNACFrame{
+							FoodGroup: wire.Buddy,
+							SubGroup:  wire.BuddyArrived,
+						},
+						Body: wire.SNAC_0x03_0x0B_BuddyArrived{
+							TLVUserInfo: newTestSession("test-user").TLVUserInfo(),
+						},
+					},
+				},
+			},
+			retrieveByScreenNameParams: retrieveByScreenNameParams{
+				{
+					screenName: "buddy1",
+					sess:       newTestSession("buddy1", sessOptCannedSignonTime),
+				},
+				{
+					screenName: "buddy2",
+					sess:       newTestSession("buddy2", sessOptCannedSignonTime),
+				},
+			},
+			feedbagParams: feedbagParams{
+				{
+					screenName: "test-user",
+					results:    []wire.FeedbagItem{},
+				},
+				{
+					screenName: "buddy1",
+					results:    []wire.FeedbagItem{},
+				},
+				{
+					screenName: "buddy2",
+					results:    []wire.FeedbagItem{},
+				},
+			},
+			mockParams: mockParams{
+				legacyBuddyListManagerParams: legacyBuddyListManagerParams{
+					legacyBuddiesParams: legacyBuddiesParams{
+						{
+							userScreenName: "test-user",
+							result:         []string{"buddy1", "buddy2"},
+						},
+					},
+					whoAddedUserParams: whoAddedUserParams{
+						{
+							userScreenName: "test-user",
+							result:         []string{"buddy3", "buddy4"},
+						},
+					},
+				},
+			},
 		},
 	}
 	for _, tt := range tests {
@@ -801,15 +962,10 @@ func TestOServiceServiceForBOS_ClientOnline(t *testing.T) {
 					AdjacentUsers(params.screenName).
 					Return(params.users, nil)
 			}
-			for _, params := range tt.broadcastToScreenNamesParams {
+			for _, params := range tt.relayToScreenNamesParams {
 				messageRelayer.EXPECT().
 					RelayToScreenNames(mock.Anything, params.screenNames, params.message)
 			}
-			for _, params := range tt.buddyLookupParams {
-				feedbagManager.EXPECT().
-					Buddies(params.screenName).
-					Return(params.buddies, nil)
-			}
 			for _, params := range tt.retrieveByScreenNameParams {
 				messageRelayer.EXPECT().
 					RetrieveByScreenName(params.screenName).
@@ -824,10 +980,21 @@ func TestOServiceServiceForBOS_ClientOnline(t *testing.T) {
 					Feedbag(params.screenName).
 					Return(params.results, nil)
 			}
-
+			legacyBuddyListManager := newMockLegacyBuddyListManager(t)
+			for _, params := range tt.mockParams.whoAddedUserParams {
+				legacyBuddyListManager.EXPECT().
+					WhoAddedUser(params.userScreenName).
+					Return(params.result)
+			}
+			for _, params := range tt.mockParams.legacyBuddiesParams {
+				legacyBuddyListManager.EXPECT().
+					Buddies(params.userScreenName).
+					Return(params.result)
+			}
 			svc := NewOServiceServiceForBOS(OServiceService{
-				feedbagManager: feedbagManager,
-				messageRelayer: messageRelayer,
+				feedbagManager:         feedbagManager,
+				legacyBuddyListManager: legacyBuddyListManager,
+				messageRelayer:         messageRelayer,
 			}, nil)
 
 			haveErr := svc.ClientOnline(nil, tt.bodyIn, tt.sess)
@@ -874,6 +1041,9 @@ func TestOServiceServiceForChat_ClientOnline(t *testing.T) {
 		// metadata and chat participant list to joining user
 		sendToScreenNameParams sendToScreenNameParams
 		wantErr                error
+		// mockParams is the list of params sent to mocks that satisfy this
+		// method's dependencies
+		mockParams mockParams
 	}{
 		{
 			name:           "upon joining, send chat room metadata and participant list to joining user; alert arrival to existing participants",
@@ -970,7 +1140,7 @@ func TestOServiceServiceForChat_ClientOnline(t *testing.T) {
 }
 
 func TestOServiceServiceForChatNav_HostOnline(t *testing.T) {
-	svc := NewOServiceServiceForChatNav(*NewOServiceService(config.Config{}, nil, nil), nil)
+	svc := NewOServiceServiceForChatNav(*NewOServiceService(config.Config{}, nil, nil, nil), nil)
 
 	want := wire.SNACMessage{
 		Frame: wire.SNACFrame{

+ 51 - 1
foodgroup/test_helpers.go

@@ -14,6 +14,7 @@ type mockParams struct {
 	chatMessageRelayerParams
 	chatRegistryParams
 	feedbagManagerParams
+	legacyBuddyListManagerParams
 	messageRelayerParams
 	profileManagerParams
 	sessionManagerParams
@@ -234,6 +235,50 @@ type broadcastExceptParams []struct {
 	message wire.SNACMessage
 }
 
+// legacyBuddyListManagerParams is a helper struct that contains mock
+// parameters for LegacyBuddyListManager methods
+type legacyBuddyListManagerParams struct {
+	addBuddyParams
+	deleteBuddyParams
+	deleteUserParams
+	legacyBuddiesParams
+	whoAddedUserParams
+}
+
+// legacyBuddiesParams is the list of parameters passed at the mock
+// LegacyBuddyListManager.AddBuddy call site
+type addBuddyParams []struct {
+	userScreenName  string
+	buddyScreenName string
+}
+
+// legacyBuddiesParams is the list of parameters passed at the mock
+// LegacyBuddyListManager.DeleteBuddy call site
+type deleteBuddyParams []struct {
+	userScreenName  string
+	buddyScreenName string
+}
+
+// deleteUserParams is the list of parameters passed at the mock
+// LegacyBuddyListManager.DeleteUser call site
+type deleteUserParams []struct {
+	userScreenName string
+}
+
+// legacyBuddiesParams is the list of parameters passed at the mock
+// LegacyBuddyListManager.Buddies call site
+type legacyBuddiesParams []struct {
+	userScreenName string
+	result         []string
+}
+
+// whoAddedUserParams is the list of parameters passed at the mock
+// LegacyBuddyListManager.WhoAddedUser call site
+type whoAddedUserParams []struct {
+	userScreenName string
+	result         []string
+}
+
 // sessOptWarning sets a warning level on the session object
 func sessOptWarning(level uint16) func(session *state.Session) {
 	return func(session *state.Session) {
@@ -281,7 +326,7 @@ func sessOptChatRoomCookie(cookie string) func(session *state.Session) {
 	}
 }
 
-// sessOptCannedSignonTime sets the invisible flag to true on the session
+// sessOptInvisible sets the invisible flag to true on the session
 // object
 func sessOptInvisible(session *state.Session) {
 	session.SetInvisible(true)
@@ -294,6 +339,11 @@ func sessOptIdle(dur time.Duration) func(session *state.Session) {
 	}
 }
 
+// sessOptSignonComplete sets the sign on complete flag to true
+func sessOptSignonComplete(session *state.Session) {
+	session.SetSignonComplete()
+}
+
 // sessOptCaps sets caps
 func sessOptCaps(caps [][16]byte) func(session *state.Session) {
 	return func(session *state.Session) {

+ 22 - 0
foodgroup/types.go

@@ -51,6 +51,28 @@ type FeedbagManager interface {
 	FeedbagUpsert(screenName string, items []wire.FeedbagItem) error
 }
 
+// LegacyBuddyListManager defines operations for tracking user relationships
+// for the client-side buddy list system used by clients prior to AIM version
+// 4.3.
+type LegacyBuddyListManager interface {
+	// AddBuddy adds buddyScreenName to userScreenName's buddy list.
+	AddBuddy(userScreenName, buddyScreenName string)
+
+	// Buddies returns a list of all buddies associated with the specified
+	// userScreenName.
+	Buddies(userScreenName string) []string
+
+	// DeleteBuddy removes buddyScreenName from userScreenName's buddy list.
+	DeleteBuddy(userScreenName, buddyScreenName string)
+
+	// DeleteUser removes userScreenName's buddy list.
+	DeleteUser(userScreenName string)
+
+	// WhoAddedUser returns a list of screen names who have userScreenName in
+	// their buddy lists.
+	WhoAddedUser(userScreenName string) []string
+}
+
 type UserManager interface {
 	User(screenName string) (*state.User, error)
 	InsertUser(u state.User) error

+ 22 - 1
server/oscar/handler/buddy.go

@@ -2,10 +2,10 @@ package handler
 
 import (
 	"context"
-	"github.com/mk6i/retro-aim-server/server/oscar"
 	"io"
 	"log/slog"
 
+	"github.com/mk6i/retro-aim-server/server/oscar"
 	"github.com/mk6i/retro-aim-server/server/oscar/middleware"
 	"github.com/mk6i/retro-aim-server/state"
 	"github.com/mk6i/retro-aim-server/wire"
@@ -13,6 +13,8 @@ import (
 
 type BuddyService interface {
 	RightsQuery(ctx context.Context, inFrame wire.SNACFrame) wire.SNACMessage
+	AddBuddies(ctx context.Context, sess *state.Session, inBody wire.SNAC_0x03_0x04_BuddyAddBuddies) error
+	DelBuddies(_ context.Context, sess *state.Session, inBody wire.SNAC_0x03_0x05_BuddyDelBuddies)
 }
 
 func NewBuddyHandler(logger *slog.Logger, buddyService BuddyService) BuddyHandler {
@@ -38,3 +40,22 @@ func (rt BuddyHandler) RightsQuery(ctx context.Context, _ *state.Session, inFram
 	rt.LogRequestAndResponse(ctx, inFrame, inSNAC, outSNAC.Frame, outSNAC.Body)
 	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
 }
+
+func (rt BuddyHandler) AddBuddies(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw oscar.ResponseWriter) error {
+	inSNAC := wire.SNAC_0x03_0x04_BuddyAddBuddies{}
+	if err := wire.Unmarshal(&inSNAC, r); err != nil {
+		return err
+	}
+	rt.LogRequest(ctx, inFrame, inSNAC)
+	return rt.BuddyService.AddBuddies(ctx, sess, inSNAC)
+}
+
+func (rt BuddyHandler) DelBuddies(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw oscar.ResponseWriter) error {
+	inSNAC := wire.SNAC_0x03_0x05_BuddyDelBuddies{}
+	if err := wire.Unmarshal(&inSNAC, r); err != nil {
+		return err
+	}
+	rt.LogRequest(ctx, inFrame, inSNAC)
+	rt.BuddyService.DelBuddies(ctx, sess, inSNAC)
+	return nil
+}

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

@@ -56,3 +56,66 @@ func TestBuddyHandler_RightsQuery(t *testing.T) {
 
 	assert.NoError(t, h.RightsQuery(nil, nil, input.Frame, buf, responseWriter))
 }
+
+func TestBuddyHandler_AddBuddies(t *testing.T) {
+	input := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Buddy,
+			SubGroup:  wire.BuddyAddBuddies,
+		},
+		Body: wire.SNAC_0x03_0x04_BuddyAddBuddies{
+			Buddies: []struct {
+				ScreenName string `len_prefix:"uint8"`
+			}{
+				{
+					ScreenName: "user1",
+				},
+			},
+		},
+	}
+
+	svc := newMockBuddyService(t)
+	svc.EXPECT().
+		AddBuddies(mock.Anything, mock.Anything, input.Body).
+		Return(nil)
+
+	h := NewBuddyHandler(slog.Default(), svc)
+
+	responseWriter := newMockResponseWriter(t)
+
+	buf := &bytes.Buffer{}
+	assert.NoError(t, wire.Marshal(input.Body, buf))
+
+	assert.NoError(t, h.AddBuddies(nil, nil, input.Frame, buf, responseWriter))
+}
+
+func TestBuddyHandler_DelBuddies(t *testing.T) {
+	input := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Buddy,
+			SubGroup:  wire.BuddyDelBuddies,
+		},
+		Body: wire.SNAC_0x03_0x05_BuddyDelBuddies{
+			Buddies: []struct {
+				ScreenName string `len_prefix:"uint8"`
+			}{
+				{
+					ScreenName: "user1",
+				},
+			},
+		},
+	}
+
+	svc := newMockBuddyService(t)
+	svc.EXPECT().
+		DelBuddies(mock.Anything, mock.Anything, input.Body)
+
+	h := NewBuddyHandler(slog.Default(), svc)
+
+	responseWriter := newMockResponseWriter(t)
+
+	buf := &bytes.Buffer{}
+	assert.NoError(t, wire.Marshal(input.Body, buf))
+
+	assert.NoError(t, h.DelBuddies(nil, nil, input.Frame, buf, responseWriter))
+}

+ 86 - 1
server/oscar/handler/mock_buddy_test.go

@@ -5,8 +5,10 @@ package handler
 import (
 	context "context"
 
-	wire "github.com/mk6i/retro-aim-server/wire"
+	state "github.com/mk6i/retro-aim-server/state"
 	mock "github.com/stretchr/testify/mock"
+
+	wire "github.com/mk6i/retro-aim-server/wire"
 )
 
 // mockBuddyService is an autogenerated mock type for the BuddyService type
@@ -22,6 +24,89 @@ func (_m *mockBuddyService) EXPECT() *mockBuddyService_Expecter {
 	return &mockBuddyService_Expecter{mock: &_m.Mock}
 }
 
+// AddBuddies provides a mock function with given fields: ctx, sess, inBody
+func (_m *mockBuddyService) AddBuddies(ctx context.Context, sess *state.Session, inBody wire.SNAC_0x03_0x04_BuddyAddBuddies) error {
+	ret := _m.Called(ctx, sess, inBody)
+
+	if len(ret) == 0 {
+		panic("no return value specified for AddBuddies")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNAC_0x03_0x04_BuddyAddBuddies) error); ok {
+		r0 = rf(ctx, sess, inBody)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// mockBuddyService_AddBuddies_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddBuddies'
+type mockBuddyService_AddBuddies_Call struct {
+	*mock.Call
+}
+
+// AddBuddies is a helper method to define mock.On call
+//   - ctx context.Context
+//   - sess *state.Session
+//   - inBody wire.SNAC_0x03_0x04_BuddyAddBuddies
+func (_e *mockBuddyService_Expecter) AddBuddies(ctx interface{}, sess interface{}, inBody interface{}) *mockBuddyService_AddBuddies_Call {
+	return &mockBuddyService_AddBuddies_Call{Call: _e.mock.On("AddBuddies", ctx, sess, inBody)}
+}
+
+func (_c *mockBuddyService_AddBuddies_Call) Run(run func(ctx context.Context, sess *state.Session, inBody wire.SNAC_0x03_0x04_BuddyAddBuddies)) *mockBuddyService_AddBuddies_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(*state.Session), args[2].(wire.SNAC_0x03_0x04_BuddyAddBuddies))
+	})
+	return _c
+}
+
+func (_c *mockBuddyService_AddBuddies_Call) Return(_a0 error) *mockBuddyService_AddBuddies_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockBuddyService_AddBuddies_Call) RunAndReturn(run func(context.Context, *state.Session, wire.SNAC_0x03_0x04_BuddyAddBuddies) error) *mockBuddyService_AddBuddies_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// DelBuddies provides a mock function with given fields: _a0, sess, inBody
+func (_m *mockBuddyService) DelBuddies(_a0 context.Context, sess *state.Session, inBody wire.SNAC_0x03_0x05_BuddyDelBuddies) {
+	_m.Called(_a0, sess, inBody)
+}
+
+// mockBuddyService_DelBuddies_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DelBuddies'
+type mockBuddyService_DelBuddies_Call struct {
+	*mock.Call
+}
+
+// DelBuddies is a helper method to define mock.On call
+//   - _a0 context.Context
+//   - sess *state.Session
+//   - inBody wire.SNAC_0x03_0x05_BuddyDelBuddies
+func (_e *mockBuddyService_Expecter) DelBuddies(_a0 interface{}, sess interface{}, inBody interface{}) *mockBuddyService_DelBuddies_Call {
+	return &mockBuddyService_DelBuddies_Call{Call: _e.mock.On("DelBuddies", _a0, sess, inBody)}
+}
+
+func (_c *mockBuddyService_DelBuddies_Call) Run(run func(_a0 context.Context, sess *state.Session, inBody wire.SNAC_0x03_0x05_BuddyDelBuddies)) *mockBuddyService_DelBuddies_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(*state.Session), args[2].(wire.SNAC_0x03_0x05_BuddyDelBuddies))
+	})
+	return _c
+}
+
+func (_c *mockBuddyService_DelBuddies_Call) Return() *mockBuddyService_DelBuddies_Call {
+	_c.Call.Return()
+	return _c
+}
+
+func (_c *mockBuddyService_DelBuddies_Call) RunAndReturn(run func(context.Context, *state.Session, wire.SNAC_0x03_0x05_BuddyDelBuddies)) *mockBuddyService_DelBuddies_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
 // RightsQuery provides a mock function with given fields: ctx, inFrame
 func (_m *mockBuddyService) RightsQuery(ctx context.Context, inFrame wire.SNACFrame) wire.SNACMessage {
 	ret := _m.Called(ctx, inFrame)

+ 6 - 0
server/oscar/handler/oservice.go

@@ -98,6 +98,12 @@ func (h OServiceHandler) SetUserInfoFields(ctx context.Context, sess *state.Sess
 	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
 }
 
+func (h OServiceHandler) Noop(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, _ io.Reader, rw oscar.ResponseWriter) error {
+	// no-op keep-alive
+	h.LogRequest(ctx, inFrame, nil)
+	return nil
+}
+
 func NewOServiceHandlerForBOS(logger *slog.Logger, oServiceService OServiceService, oServiceBOSService OServiceBOSService) OServiceBOSHandler {
 	return OServiceBOSHandler{
 		OServiceHandler: OServiceHandler{

+ 54 - 0
server/oscar/handler/oservice_test.go

@@ -378,3 +378,57 @@ func TestOServiceHandler_UserInfoQuery(t *testing.T) {
 
 	assert.NoError(t, h.UserInfoQuery(nil, nil, input.Frame, buf, responseWriter))
 }
+
+func TestOServiceHandler_Noop(t *testing.T) {
+	input := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.OService,
+			SubGroup:  wire.OServiceNoop,
+		},
+		Body: struct{}{},
+	}
+
+	h := OServiceHandler{
+		RouteLogger: middleware.RouteLogger{
+			Logger: slog.Default(),
+		},
+	}
+
+	responseWriter := newMockResponseWriter(t)
+	buf := &bytes.Buffer{}
+	assert.NoError(t, wire.Marshal(input.Body, buf))
+
+	assert.NoError(t, h.Noop(nil, nil, input.Frame, buf, responseWriter))
+}
+
+func TestOServiceChatNavHandler_ClientOnline(t *testing.T) {
+	input := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.OService,
+			SubGroup:  wire.OServiceClientOnline,
+		},
+		Body: wire.SNAC_0x01_0x02_OServiceClientOnline{
+			GroupVersions: []struct {
+				FoodGroup   uint16
+				Version     uint16
+				ToolID      uint16
+				ToolVersion uint16
+			}{
+				{
+					FoodGroup: 10,
+				},
+			},
+		},
+	}
+
+	svc := newMockOServiceChatNavService(t)
+
+	h := NewOServiceHandlerForChatNav(slog.Default(), nil, svc)
+
+	responseWriter := newMockResponseWriter(t)
+
+	buf := &bytes.Buffer{}
+	assert.NoError(t, wire.Marshal(input.Body, buf))
+
+	assert.NoError(t, h.ClientOnline(nil, nil, input.Frame, buf, responseWriter))
+}

+ 3 - 0
server/oscar/handler/routes.go

@@ -34,6 +34,8 @@ func NewBOSRouter(h Handlers) oscar.Router {
 	router.Register(wire.BART, wire.BARTUploadQuery, h.BARTHandler.UploadQuery)
 	router.Register(wire.BART, wire.BARTDownloadQuery, h.BARTHandler.DownloadQuery)
 
+	router.Register(wire.Buddy, wire.BuddyAddBuddies, h.BuddyHandler.AddBuddies)
+	router.Register(wire.Buddy, wire.BuddyDelBuddies, h.BuddyHandler.DelBuddies)
 	router.Register(wire.Buddy, wire.BuddyRightsQuery, h.BuddyHandler.RightsQuery)
 
 	router.Register(wire.ChatNav, wire.ChatNavCreateRoom, h.ChatNavHandler.CreateRoom)
@@ -68,6 +70,7 @@ func NewBOSRouter(h Handlers) oscar.Router {
 	router.Register(wire.OService, wire.OServiceClientOnline, h.OServiceBOSHandler.ClientOnline)
 	router.Register(wire.OService, wire.OServiceClientVersions, h.OServiceBOSHandler.OServiceHandler.ClientVersions)
 	router.Register(wire.OService, wire.OServiceIdleNotification, h.OServiceBOSHandler.OServiceHandler.IdleNotification)
+	router.Register(wire.OService, wire.OServiceNoop, h.OServiceBOSHandler.OServiceHandler.Noop)
 	router.Register(wire.OService, wire.OServiceRateParamsQuery, h.OServiceBOSHandler.OServiceHandler.RateParamsQuery)
 	router.Register(wire.OService, wire.OServiceRateParamsSubAdd, h.OServiceBOSHandler.OServiceHandler.RateParamsSubAdd)
 	router.Register(wire.OService, wire.OServiceServiceRequest, h.OServiceBOSHandler.ServiceRequest)

+ 77 - 0
state/buddy_list_store.go

@@ -0,0 +1,77 @@
+package state
+
+import "sync"
+
+// AdjListBuddyListStore implements a buddy list using an adjacency list.
+type AdjListBuddyListStore struct {
+	buddies map[string]map[string]bool
+	mu      sync.RWMutex // ensures thread-safe access
+}
+
+// NewAdjListBuddyListStore initializes a new instance of AdjListBuddyListStore.
+func NewAdjListBuddyListStore() *AdjListBuddyListStore {
+	return &AdjListBuddyListStore{
+		buddies: make(map[string]map[string]bool),
+	}
+}
+
+// AddBuddy adds buddyScreenName to userScreenName's buddy list.
+func (store *AdjListBuddyListStore) AddBuddy(userScreenName, buddyScreenName string) {
+	store.mu.Lock()
+	defer store.mu.Unlock()
+
+	if _, exists := store.buddies[userScreenName]; !exists {
+		store.buddies[userScreenName] = make(map[string]bool)
+	}
+	store.buddies[userScreenName][buddyScreenName] = true
+}
+
+// WhoAddedUser returns a list of screen names who have userScreenName in their buddy lists.
+func (store *AdjListBuddyListStore) WhoAddedUser(userScreenName string) []string {
+	store.mu.RLock()
+	defer store.mu.RUnlock()
+
+	var users []string
+	for user, buddies := range store.buddies {
+		if buddies[userScreenName] {
+			users = append(users, user)
+		}
+	}
+	return users
+}
+
+// Buddies returns a list of all buddies associated with the specified userScreenName.
+func (store *AdjListBuddyListStore) Buddies(userScreenName string) []string {
+	store.mu.RLock()
+	defer store.mu.RUnlock()
+
+	if buddies, exists := store.buddies[userScreenName]; exists {
+		users := make([]string, 0, len(buddies))
+		for buddy := range buddies {
+			users = append(users, buddy)
+		}
+		return users
+	}
+	return nil
+}
+
+// DeleteBuddy removes buddyScreenName from userScreenName's buddy list.
+func (store *AdjListBuddyListStore) DeleteBuddy(userScreenName, buddyScreenName string) {
+	store.mu.Lock()
+	defer store.mu.Unlock()
+
+	if buddies, exists := store.buddies[userScreenName]; exists {
+		delete(buddies, buddyScreenName)
+		if len(buddies) == 0 {
+			delete(store.buddies, userScreenName)
+		}
+	}
+}
+
+// DeleteUser removes userScreenName's buddy list.
+func (store *AdjListBuddyListStore) DeleteUser(userScreenName string) {
+	store.mu.Lock()
+	defer store.mu.Unlock()
+
+	delete(store.buddies, userScreenName)
+}

+ 75 - 0
state/buddy_list_store_test.go

@@ -0,0 +1,75 @@
+package state
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestAdjListBuddyListStore_AddBuddy_Buddies(t *testing.T) {
+	store := NewAdjListBuddyListStore()
+	store.AddBuddy("alice", "bob")
+	store.AddBuddy("bob", "alice")
+	store.AddBuddy("alice", "charlie")
+	store.AddBuddy("alice", "dave")
+	store.AddBuddy("dave", "bob")
+
+	buddies := store.Buddies("alice")
+	assert.ElementsMatch(t, []string{"bob", "charlie", "dave"}, buddies)
+}
+
+func TestAdjListBuddyListStore_DeleteBuddy_OneBuddy(t *testing.T) {
+	store := NewAdjListBuddyListStore()
+	store.AddBuddy("alice", "bob")
+	store.AddBuddy("bob", "alice")
+	store.AddBuddy("alice", "charlie")
+	store.AddBuddy("alice", "dave")
+	store.AddBuddy("dave", "bob")
+
+	store.DeleteBuddy("alice", "bob")
+
+	buddies := store.Buddies("alice")
+	assert.ElementsMatch(t, []string{"charlie", "dave"}, buddies)
+}
+
+func TestAdjListBuddyListStore_DeleteBuddy_AllBuddies(t *testing.T) {
+	store := NewAdjListBuddyListStore()
+	store.AddBuddy("alice", "bob")
+	store.AddBuddy("bob", "alice")
+	store.AddBuddy("alice", "charlie")
+	store.AddBuddy("alice", "dave")
+	store.AddBuddy("dave", "bob")
+
+	store.DeleteBuddy("alice", "bob")
+	store.DeleteBuddy("alice", "charlie")
+	store.DeleteBuddy("alice", "dave")
+
+	buddies := store.Buddies("alice")
+	assert.Nil(t, buddies)
+}
+
+func TestAdjListBuddyListStore_DeleteUser(t *testing.T) {
+	store := NewAdjListBuddyListStore()
+	store.AddBuddy("alice", "bob")
+	store.AddBuddy("bob", "alice")
+	store.AddBuddy("alice", "charlie")
+	store.AddBuddy("alice", "dave")
+	store.AddBuddy("dave", "bob")
+
+	store.DeleteUser("alice")
+
+	buddies := store.Buddies("alice")
+	assert.Nil(t, buddies)
+}
+
+func TestAdjListBuddyListStore_WhoAddedUser(t *testing.T) {
+	store := NewAdjListBuddyListStore()
+	store.AddBuddy("alice", "bob")
+	store.AddBuddy("alice", "charlie")
+	store.AddBuddy("charlie", "bob")
+	store.AddBuddy("dave", "bob")
+	store.AddBuddy("dave", "alive")
+
+	whoAddedBob := store.WhoAddedUser("bob")
+	assert.ElementsMatch(t, []string{"alice", "charlie", "dave"}, whoAddedBob)
+}

+ 15 - 0
state/session.go

@@ -33,6 +33,7 @@ type Session struct {
 	msgCh          chan wire.SNACMessage
 	mutex          sync.RWMutex
 	nowFn          func() time.Time
+	signonComplete bool
 	screenName     string
 	signonTime     time.Time
 	stopCh         chan struct{}
@@ -153,6 +154,20 @@ func (s *Session) ChatRoomCookie() string {
 	return s.chatRoomCookie
 }
 
+// SignonComplete indicates whether the client has completed the sign-on sequence.
+func (s *Session) SignonComplete() bool {
+	s.mutex.RLock()
+	defer s.mutex.RUnlock()
+	return s.signonComplete
+}
+
+// SetSignonComplete indicates that the client has completed the sign-on sequence.
+func (s *Session) SetSignonComplete() {
+	s.mutex.Lock()
+	defer s.mutex.Unlock()
+	s.signonComplete = true
+}
+
 // TLVUserInfo returns a TLV list containing session information required by
 // multiple SNAC message types that convey user information.
 func (s *Session) TLVUserInfo() wire.TLVUserInfo {

+ 12 - 0
wire/snacs.go

@@ -390,6 +390,18 @@ type SNAC_0x03_0x03_BuddyRightsReply struct {
 	TLVRestBlock
 }
 
+type SNAC_0x03_0x04_BuddyAddBuddies struct {
+	Buddies []struct {
+		ScreenName string `len_prefix:"uint8"`
+	}
+}
+
+type SNAC_0x03_0x05_BuddyDelBuddies struct {
+	Buddies []struct {
+		ScreenName string `len_prefix:"uint8"`
+	}
+}
+
 type SNAC_0x03_0x0B_BuddyArrived struct {
 	TLVUserInfo
 }