Sfoglia il codice sorgente

implement BART icon support

Mike 2 anni fa
parent
commit
816dea002d

+ 7 - 1
.mockery.yaml

@@ -54,6 +54,9 @@ packages:
       BuddyService:
         config:
           filename: "mock_buddy_test.go"
+      BARTService:
+        config:
+          filename: "mock_bart_test.go"
   github.com/mk6i/retro-aim-server/foodgroup:
     interfaces:
       FeedbagManager:
@@ -76,4 +79,7 @@ packages:
           filename: "mock_chat_message_relayer_test.go"
       ChatRegistry:
         config:
-          filename: "mock_chat_registry_test.go"
+          filename: "mock_chat_registry_test.go"
+      BARTManager:
+        config:
+          filename: "mock_bart_manager_test.go"

+ 1 - 1
README.md

@@ -20,7 +20,7 @@ This project is currently under heavy development. Retro AIM Server supports/wil
 - [x] Chat Rooms
 - [x] Visibility Toggle
 - [x] User Blocking
-- [ ] Buddy Icons
+- [x] Buddy Icons
 - [ ] User Directory
 
 ## Quickstart

+ 3 - 1
cmd/server/main.go

@@ -45,13 +45,14 @@ 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)
 		oServiceServiceForBOS := foodgroup.NewOServiceServiceForBOS(*oServiceService, chatRegistry)
 		locateService := foodgroup.NewLocateService(sessionManager, feedbagStore, feedbagStore)
 		newChatSessMgr := func() foodgroup.SessionManager { return state.NewInMemorySessionManager(logger) }
 		chatNavService := foodgroup.NewChatNavService(logger, chatRegistry, state.NewChatRoom, newChatSessMgr)
-		feedbagService := foodgroup.NewFeedbagService(sessionManager, feedbagStore)
+		feedbagService := foodgroup.NewFeedbagService(logger, sessionManager, feedbagStore, feedbagStore)
 		icbmService := foodgroup.NewICBMService(sessionManager, feedbagStore)
 
 		oscar.BOSServer{
@@ -59,6 +60,7 @@ func main() {
 			Config:      cfg,
 			Handler: handler.NewBOSRouter(handler.Handlers{
 				AlertHandler:       handler.NewAlertHandler(logger),
+				BARTHandler:        handler.NewBARTHandler(logger, bartService),
 				BuddyHandler:       handler.NewBuddyHandler(logger, buddyService),
 				ChatNavHandler:     handler.NewChatNavHandler(chatNavService, logger),
 				FeedbagHandler:     handler.NewFeedbagHandler(logger, feedbagService),

+ 5 - 5
foodgroup/auth_test.go

@@ -813,7 +813,7 @@ func TestAuthService_Signout(t *testing.T) {
 					},
 				},
 				feedbagManagerParams: feedbagManagerParams{
-					interestedUsersParams: interestedUsersParams{
+					adjacentUsersParams: adjacentUsersParams{
 						{
 							screenName: "user_screen_name",
 							users:      []string{"friend1", "friend2"},
@@ -821,7 +821,7 @@ func TestAuthService_Signout(t *testing.T) {
 					},
 				},
 				messageRelayerParams: messageRelayerParams{
-					broadcastToScreenNamesParams: broadcastToScreenNamesParams{
+					relayToScreenNamesParams: relayToScreenNamesParams{
 						{
 							screenNames: []string{"friend1", "friend2"},
 							message: wire.SNACMessage{
@@ -849,7 +849,7 @@ func TestAuthService_Signout(t *testing.T) {
 			},
 			mockParams: mockParams{
 				feedbagManagerParams: feedbagManagerParams{
-					interestedUsersParams: interestedUsersParams{
+					adjacentUsersParams: adjacentUsersParams{
 						{
 							screenName: "user_screen_name",
 							users:      []string{"friend1", "friend2"},
@@ -864,12 +864,12 @@ func TestAuthService_Signout(t *testing.T) {
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
 			messageRelayer := newMockMessageRelayer(t)
-			for _, params := range tt.mockParams.broadcastToScreenNamesParams {
+			for _, params := range tt.mockParams.relayToScreenNamesParams {
 				messageRelayer.EXPECT().
 					RelayToScreenNames(mock.Anything, params.screenNames, params.message)
 			}
 			feedbagManager := newMockFeedbagManager(t)
-			for _, params := range tt.mockParams.interestedUsersParams {
+			for _, params := range tt.mockParams.adjacentUsersParams {
 				feedbagManager.EXPECT().
 					AdjacentUsers(params.screenName).
 					Return(params.users, params.err)

+ 105 - 0
foodgroup/bart.go

@@ -0,0 +1,105 @@
+package foodgroup
+
+import (
+	"context"
+	"crypto/md5"
+	"errors"
+	"fmt"
+	"log/slog"
+
+	"github.com/mk6i/retro-aim-server/state"
+	"github.com/mk6i/retro-aim-server/wire"
+)
+
+// blankGIF is a blank, transparent 50x50p GIF that takes the place of a
+// cleared buddy icon.
+var blankGIF = []byte{
+	0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x32, 0x00, 0x32, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00,
+	0x00, 0x00, 0x00, 0x21, 0xf9, 0x04, 0x01, 0x00, 0x00, 0x00, 0x00, 0x2c, 0x00, 0x00, 0x00, 0x00,
+	0x32, 0x00, 0x32, 0x00, 0x00, 0x02, 0x02, 0x44, 0x01, 0x00, 0x3b,
+}
+
+// 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 {
+	return BARTService{
+		bartManager:    bartManager,
+		feedbagManager: feedbagManager,
+		logger:         logger,
+		messageRelayer: messageRelayer,
+	}
+}
+
+type BARTService struct {
+	bartManager    BARTManager
+	feedbagManager FeedbagManager
+	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) {
+	h := md5.New()
+	if _, err := h.Write(inBody.Data); err != nil {
+		return wire.SNACMessage{}, err
+	}
+	hash := h.Sum(nil)
+
+	if err := s.bartManager.BARTUpsert(hash, inBody.Data); err != nil {
+		return wire.SNACMessage{}, err
+	}
+
+	s.logger.DebugContext(ctx, "successfully uploaded buddy icon", "hash", fmt.Sprintf("%x", hash))
+
+	if err := broadcastArrival(ctx, sess, s.messageRelayer, s.feedbagManager); err != nil {
+		return wire.SNACMessage{}, err
+	}
+
+	return wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.BART,
+			SubGroup:  wire.BARTUploadReply,
+			RequestID: inFrame.RequestID,
+		},
+		Body: wire.SNAC_0x10_0x03_BARTUploadReply{
+			Code: wire.BARTReplyCodesSuccess,
+			ID: wire.BARTID{
+				Type: inBody.Type,
+				BARTInfo: wire.BARTInfo{
+					Flags: wire.BARTFlagsKnown,
+					Hash:  hash,
+				},
+			},
+		},
+	}, nil
+}
+
+func (s BARTService) RetrieveItem(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x10_0x04_BARTDownloadQuery) (wire.SNACMessage, error) {
+	if inBody.Flags != wire.BARTFlagsKnown {
+		return wire.SNACMessage{}, errKnownIconsOnly
+	}
+
+	var icon []byte
+	if inBody.HasClearIconHash() {
+		icon = blankGIF
+	} else {
+		var err error
+		if icon, err = s.bartManager.BARTRetrieve(inBody.Hash); err != nil {
+			return wire.SNACMessage{}, err
+		}
+	}
+
+	// todo... how to reply if requested icon doesn't exist
+	return wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.BART,
+			SubGroup:  wire.BARTDownloadReply,
+			RequestID: inFrame.RequestID,
+		},
+		Body: wire.SNAC_0x10_0x05_BARTDownloadReply{
+			ScreenName: inBody.ScreenName,
+			BARTID:     inBody.BARTID,
+			Data:       icon,
+		},
+	}, nil
+}

+ 288 - 0
foodgroup/bart_test.go

@@ -0,0 +1,288 @@
+package foodgroup
+
+import (
+	"log/slog"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/mock"
+
+	"github.com/mk6i/retro-aim-server/state"
+	"github.com/mk6i/retro-aim-server/wire"
+)
+
+func TestBARTService_UpsertItem(t *testing.T) {
+	cases := []struct {
+		// name is the unit test name
+		name string
+		// userSession is the session of the user adding to feedbag
+		userSession *state.Session
+		// inputSNAC is the SNAC sent from the client to the server
+		inputSNAC wire.SNACMessage
+		// mockParams is the list of params sent to mocks that satisfy this
+		// method's dependencies
+		mockParams mockParams
+		// expectOutput is the SNAC sent from the server to client
+		expectOutput wire.SNACMessage
+	}{
+		{
+			name:        "upsert item",
+			userSession: newTestSession("user_screen_name"),
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					RequestID: 1234,
+				},
+				Body: wire.SNAC_0x10_0x02_BARTUploadQuery{
+					Type: 1,
+					Data: []byte{'i', 't', 'e', 'm', 'd', 'a', 't', 'a'},
+				},
+			},
+			mockParams: mockParams{
+				feedbagManagerParams: feedbagManagerParams{
+					adjacentUsersParams: adjacentUsersParams{
+						{
+							screenName: "user_screen_name",
+							users:      []string{"friend1"},
+						},
+					},
+					feedbagParams: feedbagParams{
+						{
+							screenName: "user_screen_name",
+						},
+					},
+				},
+				bartManagerParams: bartManagerParams{
+					bartManagerUpsertParams: bartManagerUpsertParams{
+						{
+							itemHash: []byte{0x4e, 0xd9, 0xc1, 0x96, 0x45, 0xdb, 0x5a, 0xec, 0xdb, 0xf5, 0xc7, 0xa2, 0x4e, 0x8e, 0xa0, 0xed},
+							payload:  []byte{'i', 't', 'e', 'm', 'd', 'a', 't', 'a'},
+						},
+					},
+				},
+				messageRelayerParams: messageRelayerParams{
+					relayToScreenNamesParams: relayToScreenNamesParams{
+						{
+							screenNames: []string{"friend1"},
+							message: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.Buddy,
+									SubGroup:  wire.BuddyArrived,
+								},
+								Body: wire.SNAC_0x03_0x0B_BuddyArrived{
+									TLVUserInfo: newTestSession("user_screen_name").TLVUserInfo(),
+								},
+							},
+						},
+					},
+				},
+			},
+			expectOutput: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.BART,
+					SubGroup:  wire.BARTUploadReply,
+					RequestID: 1234,
+				},
+				Body: wire.SNAC_0x10_0x03_BARTUploadReply{
+					Code: wire.BARTReplyCodesSuccess,
+					ID: wire.BARTID{
+						Type: wire.BARTTypesBuddyIcon,
+						BARTInfo: wire.BARTInfo{
+							Flags: wire.BARTFlagsKnown,
+							Hash:  []byte{0x4e, 0xd9, 0xc1, 0x96, 0x45, 0xdb, 0x5a, 0xec, 0xdb, 0xf5, 0xc7, 0xa2, 0x4e, 0x8e, 0xa0, 0xed},
+						},
+					},
+				},
+			},
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			feedbagManager := newMockFeedbagManager(t)
+			for _, params := range tc.mockParams.feedbagManagerParams.feedbagUpsertParams {
+				feedbagManager.EXPECT().
+					FeedbagUpsert(params.screenName, params.items).
+					Return(nil)
+			}
+			for _, params := range tc.mockParams.feedbagManagerParams.adjacentUsersParams {
+				feedbagManager.EXPECT().
+					AdjacentUsers(params.screenName).
+					Return(params.users, params.err)
+			}
+			for _, params := range tc.mockParams.feedbagManagerParams.feedbagParams {
+				feedbagManager.EXPECT().Feedbag(params.screenName).Return(params.results, nil)
+			}
+			messageRelayer := newMockMessageRelayer(t)
+			for _, params := range tc.mockParams.messageRelayerParams.relayToScreenNameParams {
+				messageRelayer.EXPECT().
+					RelayToScreenName(mock.Anything, params.screenName, params.message)
+			}
+			for _, params := range tc.mockParams.messageRelayerParams.relayToScreenNamesParams {
+				messageRelayer.EXPECT().
+					RelayToScreenNames(mock.Anything, params.screenNames, params.message)
+			}
+			bartManager := newMockBARTManager(t)
+			for _, params := range tc.mockParams.bartManagerParams.bartManagerUpsertParams {
+				bartManager.EXPECT().
+					BARTUpsert(params.itemHash, params.payload).
+					Return(nil)
+			}
+
+			svc := NewBARTService(slog.Default(), bartManager, messageRelayer, feedbagManager)
+
+			output, err := svc.UpsertItem(nil, tc.userSession, tc.inputSNAC.Frame,
+				tc.inputSNAC.Body.(wire.SNAC_0x10_0x02_BARTUploadQuery))
+
+			assert.NoError(t, err)
+			assert.Equal(t, output, tc.expectOutput)
+		})
+	}
+}
+
+func TestBARTService_RetrieveItem(t *testing.T) {
+	cases := []struct {
+		// name is the unit test name
+		name string
+		// userSession is the session of the user adding to feedbag
+		userSession *state.Session
+		// inputSNAC is the SNAC sent from the client to the server
+		inputSNAC wire.SNACMessage
+		// mockParams is the list of params sent to mocks that satisfy this
+		// method's dependencies
+		mockParams mockParams
+		// expectOutput is the SNAC sent from the server to client
+		expectOutput wire.SNACMessage
+		// expectErr is the expected error
+		expectErr error
+	}{
+		{
+			name:        "retrieve buddy icon",
+			userSession: newTestSession("user_screen_name"),
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					RequestID: 1234,
+				},
+				Body: wire.SNAC_0x10_0x04_BARTDownloadQuery{
+					ScreenName: "user_screen_name",
+					Command:    1,
+					BARTID: wire.BARTID{
+						Type: wire.BARTTypesBuddyIcon,
+						BARTInfo: wire.BARTInfo{
+							Flags: wire.BARTFlagsKnown,
+							Hash:  []byte{0x4e, 0xd9, 0xc1, 0x96, 0x45, 0xdb, 0x5a, 0xec, 0xdb, 0xf5, 0xc7, 0xa2, 0x4e, 0x8e, 0xa0, 0xed},
+						},
+					},
+				},
+			},
+			mockParams: mockParams{
+				bartManagerParams: bartManagerParams{
+					bartManagerRetrieveParams: bartManagerRetrieveParams{
+						{
+							itemHash: []byte{0x4e, 0xd9, 0xc1, 0x96, 0x45, 0xdb, 0x5a, 0xec, 0xdb, 0xf5, 0xc7, 0xa2, 0x4e, 0x8e, 0xa0, 0xed},
+							result:   []byte{'i', 't', 'e', 'm', 'd', 'a', 't', 'a'},
+						},
+					},
+				},
+			},
+			expectOutput: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.BART,
+					SubGroup:  wire.BARTDownloadReply,
+					RequestID: 1234,
+				},
+				Body: wire.SNAC_0x10_0x05_BARTDownloadReply{
+					ScreenName: "user_screen_name",
+					BARTID: wire.BARTID{
+						Type: wire.BARTTypesBuddyIcon,
+						BARTInfo: wire.BARTInfo{
+							Flags: wire.BARTFlagsKnown,
+							Hash:  []byte{0x4e, 0xd9, 0xc1, 0x96, 0x45, 0xdb, 0x5a, 0xec, 0xdb, 0xf5, 0xc7, 0xa2, 0x4e, 0x8e, 0xa0, 0xed},
+						},
+					},
+					Data: []byte{'i', 't', 'e', 'm', 'd', 'a', 't', 'a'},
+				},
+			},
+		},
+		{
+			name:        "retrieve blank icon used for clearing buddy icon",
+			userSession: newTestSession("user_screen_name"),
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					RequestID: 1234,
+				},
+				Body: wire.SNAC_0x10_0x04_BARTDownloadQuery{
+					ScreenName: "user_screen_name",
+					Command:    1,
+					BARTID: wire.BARTID{
+						Type: wire.BARTTypesBuddyIcon,
+						BARTInfo: wire.BARTInfo{
+							Flags: wire.BARTFlagsKnown,
+							Hash:  wire.GetClearIconHash(),
+						},
+					},
+				},
+			},
+			expectOutput: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.BART,
+					SubGroup:  wire.BARTDownloadReply,
+					RequestID: 1234,
+				},
+				Body: wire.SNAC_0x10_0x05_BARTDownloadReply{
+					ScreenName: "user_screen_name",
+					BARTID: wire.BARTID{
+						Type: wire.BARTTypesBuddyIcon,
+						BARTInfo: wire.BARTInfo{
+							Flags: wire.BARTFlagsKnown,
+							Hash:  wire.GetClearIconHash(),
+						},
+					},
+					Data: blankGIF,
+				},
+			},
+		},
+		{
+			name:        "retrieve unknown icon, expect err",
+			userSession: newTestSession("user_screen_name"),
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					RequestID: 1234,
+				},
+				Body: wire.SNAC_0x10_0x04_BARTDownloadQuery{
+					ScreenName: "user_screen_name",
+					Command:    1,
+					BARTID: wire.BARTID{
+						Type: wire.BARTTypesBuddyIcon,
+						BARTInfo: wire.BARTInfo{
+							Flags: wire.BARTFlagsUnknown,
+							Hash:  wire.GetClearIconHash(),
+						},
+					},
+				},
+			},
+			expectErr: errKnownIconsOnly,
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			bartManager := newMockBARTManager(t)
+			for _, params := range tc.mockParams.bartManagerParams.bartManagerRetrieveParams {
+				bartManager.EXPECT().
+					BARTRetrieve(params.itemHash).
+					Return(params.result, nil)
+			}
+
+			svc := NewBARTService(slog.Default(), bartManager, nil, nil)
+
+			output, err := svc.RetrieveItem(nil, tc.userSession, tc.inputSNAC.Frame,
+				tc.inputSNAC.Body.(wire.SNAC_0x10_0x04_BARTDownloadQuery))
+
+			assert.ErrorIs(t, err, tc.expectErr)
+			if tc.expectErr != nil {
+				return
+			}
+			assert.Equal(t, output, tc.expectOutput)
+		})
+	}
+}

+ 70 - 1
foodgroup/buddy.go

@@ -1,7 +1,10 @@
 package foodgroup
 
 import (
+	"bytes"
 	"context"
+	"errors"
+	"strconv"
 
 	"github.com/mk6i/retro-aim-server/state"
 	"github.com/mk6i/retro-aim-server/wire"
@@ -47,19 +50,85 @@ func broadcastArrival(ctx context.Context, sess *state.Session, messageRelayer M
 		return err
 	}
 
+	userInfo := sess.TLVUserInfo()
+	icon, err := getBuddyIconRefFromFeedbag(sess, feedbagManager)
+	switch {
+	case err != nil:
+		return err
+	case icon != nil:
+		userInfo.Append(wire.NewTLV(wire.OServiceUserInfoBARTInfo, *icon))
+	}
+
 	messageRelayer.RelayToScreenNames(ctx, screenNames, wire.SNACMessage{
 		Frame: wire.SNACFrame{
 			FoodGroup: wire.Buddy,
 			SubGroup:  wire.BuddyArrived,
 		},
 		Body: wire.SNAC_0x03_0x0B_BuddyArrived{
-			TLVUserInfo: sess.TLVUserInfo(),
+			TLVUserInfo: userInfo,
 		},
 	})
 
 	return nil
 }
 
+// getBuddyIconRefFromFeedbag retrieves a reference to the user's buddy icon
+// from their feedbag. If it exists, the buddy icon is the feedbag item of
+// class wire.FeedbagClassIdBart with BART type wire.BARTTypesBuddyIcon.
+func getBuddyIconRefFromFeedbag(sess *state.Session, feedbagManager FeedbagManager) (*wire.BARTID, error) {
+	items, err := feedbagManager.Feedbag(sess.ScreenName())
+	if err != nil {
+		return nil, err
+	}
+
+	for _, item := range items {
+		if item.ClassID != wire.FeedbagClassIdBart {
+			continue
+		}
+		b, hasBuf := item.Slice(wire.FeedbagAttributesBartInfo)
+		if !hasBuf {
+			return nil, errors.New("unable to extract icon payload")
+		}
+		bartInfo := wire.BARTInfo{}
+		if err := wire.Unmarshal(&bartInfo, bytes.NewBuffer(b)); err != nil {
+			return nil, err
+		}
+		bartType, err := extractBARTItemType(item)
+		if err != nil {
+			return nil, err
+		}
+		if bartType != wire.BARTTypesBuddyIcon {
+			continue
+		}
+		if bartInfo.HasClearIconHash() {
+			bartInfo.Flags = wire.BARTFlagsCustom | wire.BARTFlagsData
+		}
+		return &wire.BARTID{
+			Type: bartType,
+			BARTInfo: wire.BARTInfo{
+				Flags: bartInfo.Flags,
+				Hash:  bartInfo.Hash,
+			},
+		}, nil
+	}
+
+	return nil, nil
+}
+
+// extractBARTItemType gets the BART type for item, which is stored in the
+// "name" field.
+func extractBARTItemType(item wire.FeedbagItem) (uint16, error) {
+	var bartType uint16
+	// Feedbag items of type wire.FeedbagClassIdBart store the BART type in the
+	// name field.
+	if bt, err := strconv.ParseUint(item.Name, 10, 16); err != nil {
+		return 0, err
+	} else {
+		bartType = uint16(bt)
+	}
+	return bartType, nil
+}
+
 func broadcastDeparture(ctx context.Context, sess *state.Session, messageRelayer MessageRelayer, feedbagManager FeedbagManager) error {
 	screenNames, err := feedbagManager.AdjacentUsers(sess.ScreenName())
 	if err != nil {

+ 89 - 54
foodgroup/feedbag.go

@@ -1,7 +1,11 @@
 package foodgroup
 
 import (
+	"bytes"
 	"context"
+	"errors"
+	"fmt"
+	"log/slog"
 	"time"
 
 	"github.com/mk6i/retro-aim-server/state"
@@ -9,10 +13,12 @@ import (
 )
 
 // NewFeedbagService creates a new instance of FeedbagService.
-func NewFeedbagService(messageRelayer MessageRelayer, feedbagManager FeedbagManager) FeedbagService {
+func NewFeedbagService(logger *slog.Logger, messageRelayer MessageRelayer, feedbagManager FeedbagManager, bartManager BARTManager) FeedbagService {
 	return FeedbagService{
-		messageRelayer: messageRelayer,
+		bartManager:    bartManager,
 		feedbagManager: feedbagManager,
+		logger:         logger,
+		messageRelayer: messageRelayer,
 	}
 }
 
@@ -21,12 +27,33 @@ func NewFeedbagService(messageRelayer MessageRelayer, feedbagManager FeedbagMana
 type FeedbagService struct {
 	messageRelayer MessageRelayer
 	feedbagManager FeedbagManager
+	bartManager    BARTManager
+	logger         *slog.Logger
 }
 
 // RightsQuery returns SNAC wire.FeedbagRightsReply, which contains Feedbag
 // food group settings for the current user. The values within the SNAC are not
 // well understood but seem to make the AIM client happy.
 func (s FeedbagService) RightsQuery(_ context.Context, inFrame wire.SNACFrame) wire.SNACMessage {
+	// maxItemsByClass defines per-type item limits. Types not listed here are
+	// 0 by default. The slice size is equal to the maximum "enum" value+1.
+	maxItemsByClass := make([]uint16, 21)
+	maxItemsByClass[wire.FeedbagClassIdBuddy] = 61
+	maxItemsByClass[wire.FeedbagClassIdGroup] = 61
+	maxItemsByClass[wire.FeedbagClassIDPermit] = 100
+	maxItemsByClass[wire.FeedbagClassIDDeny] = 100
+	maxItemsByClass[wire.FeedbagClassIdPdinfo] = 1
+	maxItemsByClass[wire.FeedbagClassIdBuddyPrefs] = 1
+	maxItemsByClass[wire.FeedbagClassIdNonbuddy] = 50
+	maxItemsByClass[wire.FeedbagClassIdClientPrefs] = 3
+	maxItemsByClass[wire.FeedbagClassIdWatchList] = 128
+	maxItemsByClass[wire.FeedbagClassIdIgnoreList] = 255
+	maxItemsByClass[wire.FeedbagClassIdDateTime] = 20
+	maxItemsByClass[wire.FeedbagClassIdExternalUser] = 200
+	maxItemsByClass[wire.FeedbagClassIdRootCreator] = 1
+	maxItemsByClass[wire.FeedbagClassIdImportTimestamp] = 1
+	maxItemsByClass[wire.FeedbagClassIdBart] = 200
+
 	return wire.SNACMessage{
 		Frame: wire.SNACFrame{
 			FoodGroup: wire.Feedbag,
@@ -37,29 +64,7 @@ func (s FeedbagService) RightsQuery(_ context.Context, inFrame wire.SNACFrame) w
 			TLVRestBlock: wire.TLVRestBlock{
 				TLVList: wire.TLVList{
 					wire.NewTLV(wire.FeedbagRightsMaxItemAttrs, uint16(200)),
-					wire.NewTLV(wire.FeedbagRightsMaxItemsByClass, []uint16{
-						0x3D, // max num of contacts
-						0x3D, // max num of groups
-						0x64, // max visible contacts
-						0x64, // max invisible contacts
-						0x01, // max vis/invis bitmasks
-						0x01, // max presense info fields
-						0x32, // limit for item type 06
-						0x00, // limit for item type 07
-						0x00, // limit for item type 08
-						0x03, // limit for item type 09
-						0x00, // limit for item type 0a
-						0x00, // limit for item type 0b
-						0x00, // limit for item type 0c
-						0x80, // limit for item type 0d
-						0xFF, // max ignore list entries
-						0x14, // limit for item type 0f
-						0xC8, // limit for item 10
-						0x01, // limit for item 11
-						0x00, // limit for item 12
-						0x01, // limit for item 13
-						0x00, // limit for item 14
-					}),
+					wire.NewTLV(wire.FeedbagRightsMaxItemsByClass, maxItemsByClass),
 					wire.NewTLV(wire.FeedbagRightsMaxClientItems, uint16(200)),
 					wire.NewTLV(wire.FeedbagRightsMaxItemNameLen, uint16(200)),
 					wire.NewTLV(wire.FeedbagRightsMaxRecentBuddies, uint16(200)),
@@ -152,13 +157,17 @@ 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
 // buddy arrival notifications for each online & visible buddy added to the
 // feedbag. Sends a buddy departure notification to blocked buddies if current
 // user is visible. It returns wire.FeedbagStatus, which contains insert
 // confirmation.
-func (s FeedbagService) InsertItem(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x08_FeedbagInsertItem) (wire.SNACMessage, error) {
-	for _, item := range inBody.Items {
+// UpdateItem updates items in the user's feedbag (aka buddy list). Sends user
+// buddy arrival notifications for each online & visible buddy added to the
+// feedbag. It returns wire.FeedbagStatus, which contains update confirmation.
+func (s FeedbagService) UpsertItem(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, items []wire.FeedbagItem) (wire.SNACMessage, error) {
+	for _, item := range 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() {
@@ -175,11 +184,11 @@ func (s FeedbagService) InsertItem(ctx context.Context, sess *state.Session, inF
 		}
 	}
 
-	if err := s.feedbagManager.FeedbagUpsert(sess.ScreenName(), inBody.Items); err != nil {
+	if err := s.feedbagManager.FeedbagUpsert(sess.ScreenName(), items); err != nil {
 		return wire.SNACMessage{}, nil
 	}
 
-	for _, item := range inBody.Items {
+	for _, item := range items {
 		switch item.ClassID {
 		case wire.FeedbagClassIdBuddy, wire.FeedbagClassIDPermit: // add new buddy
 			buddy := s.messageRelayer.RetrieveByScreenName(item.Name)
@@ -197,11 +206,15 @@ func (s FeedbagService) InsertItem(ctx context.Context, sess *state.Session, inF
 			}
 			// alert blocked buddy that current user is offline
 			unicastDeparture(ctx, sess, blockedSess, s.messageRelayer)
+		case wire.FeedbagClassIdBart:
+			if err := s.broadcastIconUpdate(ctx, sess, item); err != nil {
+				return wire.SNACMessage{}, err
+			}
 		}
 	}
 
 	snacPayloadOut := wire.SNAC_0x13_0x0E_FeedbagStatus{}
-	for range inBody.Items {
+	for range items {
 		snacPayloadOut.Results = append(snacPayloadOut.Results, 0x0000)
 	}
 
@@ -215,38 +228,60 @@ func (s FeedbagService) InsertItem(ctx context.Context, sess *state.Session, inF
 	}, nil
 }
 
-// UpdateItem updates items in the user's feedbag (aka buddy list). Sends user
-// buddy arrival notifications for each online & visible buddy added to the
-// feedbag. It returns wire.FeedbagStatus, which contains update confirmation.
-func (s FeedbagService) UpdateItem(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x09_FeedbagUpdateItem) (wire.SNACMessage, error) {
-	if err := s.feedbagManager.FeedbagUpsert(sess.ScreenName(), inBody.Items); err != nil {
-		return wire.SNACMessage{}, nil
+// broadcastIconUpdate informs clients about buddy icon update. If the BART
+// store doesn't have the icon, then tell the client to upload the buddy icon.
+// If the icon already exists, tell the user's buddies about the icon change.
+func (s FeedbagService) broadcastIconUpdate(ctx context.Context, sess *state.Session, item wire.FeedbagItem) error {
+	btlv := wire.BARTInfo{}
+	if b, hasBuf := item.Slice(wire.FeedbagAttributesBartInfo); hasBuf {
+		if err := wire.Unmarshal(&btlv, bytes.NewBuffer(b)); err != nil {
+			return err
+		}
+	} else {
+		return errors.New("unable to extract icon payload")
 	}
 
-	for _, item := range inBody.Items {
-		switch item.ClassID {
-		case wire.FeedbagClassIdBuddy, wire.FeedbagClassIDPermit:
-			buddy := s.messageRelayer.RetrieveByScreenName(item.Name)
-			if buddy == nil || buddy.Invisible() {
-				continue
-			}
-			unicastArrival(ctx, buddy, sess, s.messageRelayer)
-		}
+	if bytes.Equal(btlv.Hash, wire.GetClearIconHash()) {
+		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)
 	}
 
-	snacPayloadOut := wire.SNAC_0x13_0x0E_FeedbagStatus{}
-	for range inBody.Items {
-		snacPayloadOut.Results = append(snacPayloadOut.Results, 0x0000)
+	bid := wire.BARTID{
+		Type: wire.BARTTypesBuddyIcon,
+		BARTInfo: wire.BARTInfo{
+			Flags: wire.BARTFlagsCustom,
+			Hash:  btlv.Hash,
+		},
+	}
+	if b, err := s.bartManager.BARTRetrieve(btlv.Hash); err != nil {
+		return err
+	} else if len(b) == 0 {
+		// icon doesn't exist, tell the client to upload buddy icon
+		s.logger.DebugContext(ctx, "icon doesn't exist in BART store, client must upload the icon file",
+			"hash", fmt.Sprintf("%x", btlv.Hash))
+		bid.Flags |= wire.BARTFlagsUnknown
+	} else {
+		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 {
+			return err
+		}
 	}
 
-	return wire.SNACMessage{
+	s.messageRelayer.RelayToScreenName(ctx, sess.ScreenName(), wire.SNACMessage{
 		Frame: wire.SNACFrame{
-			FoodGroup: wire.Feedbag,
-			SubGroup:  wire.FeedbagStatus,
-			RequestID: inFrame.RequestID,
+			FoodGroup: wire.OService,
+			SubGroup:  wire.OServiceBartReply,
 		},
-		Body: snacPayloadOut,
-	}, nil
+		Body: wire.SNAC_0x01_0x21_OServiceBARTReply{
+			BARTID: bid,
+		},
+	})
+
+	return nil
 }
 
 // DeleteItem removes items from feedbag (aka buddy list). Sends user buddy

+ 246 - 132
foodgroup/feedbag_test.go

@@ -1,6 +1,7 @@
 package foodgroup
 
 import (
+	"log/slog"
 	"testing"
 	"time"
 
@@ -314,7 +315,7 @@ func TestFeedbagService_QueryIfModified(t *testing.T) {
 }
 
 func TestFeedbagService_RightsQuery(t *testing.T) {
-	svc := NewFeedbagService(nil, nil)
+	svc := NewFeedbagService(nil, nil, nil, nil)
 
 	outputSNAC := svc.RightsQuery(nil, wire.SNACFrame{RequestID: 1234})
 	expectSNAC := wire.SNACMessage{
@@ -328,27 +329,27 @@ func TestFeedbagService_RightsQuery(t *testing.T) {
 				TLVList: wire.TLVList{
 					wire.NewTLV(wire.FeedbagRightsMaxItemAttrs, uint16(200)),
 					wire.NewTLV(wire.FeedbagRightsMaxItemsByClass, []uint16{
-						0x3D,
-						0x3D,
-						0x64,
-						0x64,
-						0x01,
-						0x01,
-						0x32,
+						61,
+						61,
+						100,
+						100,
+						1,
+						1,
+						50,
 						0x00,
 						0x00,
-						0x03,
+						3,
 						0x00,
 						0x00,
 						0x00,
-						0x80,
-						0xFF,
-						0x14,
-						0xC8,
-						0x01,
-						0x00,
-						0x01,
+						128,
+						255,
+						20,
+						200,
+						1,
 						0x00,
+						1,
+						200,
 					}),
 					wire.NewTLV(wire.FeedbagRightsMaxClientItems, uint16(200)),
 					wire.NewTLV(wire.FeedbagRightsMaxItemNameLen, uint16(200)),
@@ -367,7 +368,7 @@ func TestFeedbagService_RightsQuery(t *testing.T) {
 	assert.Equal(t, expectSNAC, outputSNAC)
 }
 
-func TestFeedbagService_InsertItem(t *testing.T) {
+func TestFeedbagService_UpsertItem(t *testing.T) {
 	cases := []struct {
 		// name is the unit test name
 		name string
@@ -430,7 +431,7 @@ func TestFeedbagService_InsertItem(t *testing.T) {
 							sess:       newTestSession("buddy_2_online", sessOptCannedSignonTime),
 						},
 					},
-					sendToScreenNameParams: sendToScreenNameParams{
+					relayToScreenNameParams: relayToScreenNameParams{
 						{
 							screenName: "user_screen_name",
 							message: wire.SNACMessage{
@@ -606,7 +607,7 @@ func TestFeedbagService_InsertItem(t *testing.T) {
 							sess:       newTestSession("buddy_1"),
 						},
 					},
-					sendToScreenNameParams: sendToScreenNameParams{
+					relayToScreenNameParams: relayToScreenNameParams{
 						{
 							screenName: "buddy_1",
 							message: wire.SNACMessage{
@@ -755,124 +756,73 @@ func TestFeedbagService_InsertItem(t *testing.T) {
 				},
 			},
 		},
-	}
-
-	for _, tc := range cases {
-		t.Run(tc.name, func(t *testing.T) {
-			feedbagManager := newMockFeedbagManager(t)
-			for _, params := range tc.mockParams.feedbagManagerParams.feedbagUpsertParams {
-				feedbagManager.EXPECT().
-					FeedbagUpsert(params.screenName, params.items).
-					Return(nil)
-			}
-			messageRelayer := newMockMessageRelayer(t)
-			for _, params := range tc.mockParams.messageRelayerParams.retrieveByScreenNameParams {
-				messageRelayer.EXPECT().
-					RetrieveByScreenName(params.screenName).
-					Return(params.sess)
-			}
-			for _, params := range tc.mockParams.messageRelayerParams.sendToScreenNameParams {
-				messageRelayer.EXPECT().
-					RelayToScreenName(mock.Anything, params.screenName, params.message)
-			}
-
-			svc := FeedbagService{
-				feedbagManager: feedbagManager,
-				messageRelayer: messageRelayer,
-			}
-			output, err := svc.InsertItem(nil, tc.userSession, tc.inputSNAC.Frame,
-				tc.inputSNAC.Body.(wire.SNAC_0x13_0x08_FeedbagInsertItem))
-			assert.NoError(t, err)
-			assert.Equal(t, output, tc.expectOutput)
-		})
-	}
-}
-
-func TestFeedbagService_UpdateItem(t *testing.T) {
-	cases := []struct {
-		// name is the unit test name
-		name string
-		// userSession is the session of the user adding to feedbag
-		userSession *state.Session
-		// inputSNAC is the SNAC sent from the client to the server
-		inputSNAC wire.SNACMessage
-		// mockParams is the list of params sent to mocks that satisfy this
-		// method's dependencies
-		mockParams mockParams
-		// expectOutput is the SNAC sent from the server to client
-		expectOutput wire.SNACMessage
-	}{
 		{
-			name:        "user updates online buddies in feedbag, receives buddy arrival notifications",
+			name:        "add icon hash to feedbag, icon doesn't exist in BART store, instruct client to upload icon",
 			userSession: newTestSession("user_screen_name"),
 			inputSNAC: wire.SNACMessage{
 				Frame: wire.SNACFrame{
 					RequestID: 1234,
 				},
-				Body: wire.SNAC_0x13_0x09_FeedbagUpdateItem{
+				Body: wire.SNAC_0x13_0x08_FeedbagInsertItem{
 					Items: []wire.FeedbagItem{
 						{
-							ClassID: wire.FeedbagClassIDPermit,
-							Name:    "buddy_1_online",
-						},
-						{
-							ClassID: wire.FeedbagClassIDPermit,
-							Name:    "buddy_2_online",
+							ClassID: wire.FeedbagClassIdBart,
+							TLVLBlock: wire.TLVLBlock{
+								TLVList: wire.TLVList{
+									wire.NewTLV(wire.FeedbagAttributesBartInfo, wire.BARTInfo{
+										Hash: []byte{'t', 'h', 'e', 'h', 'a', 's', 'h'},
+									}),
+								},
+							},
 						},
 					},
 				},
 			},
 			mockParams: mockParams{
+				bartManagerParams: bartManagerParams{
+					bartManagerRetrieveParams: bartManagerRetrieveParams{
+						{
+							itemHash: []byte{'t', 'h', 'e', 'h', 'a', 's', 'h'},
+							result:   []byte{}, // icon doesn't exist
+						},
+					},
+				},
 				feedbagManagerParams: feedbagManagerParams{
 					feedbagUpsertParams: feedbagUpsertParams{
 						{
 							screenName: "user_screen_name",
 							items: []wire.FeedbagItem{
 								{
-									ClassID: wire.FeedbagClassIDPermit,
-									Name:    "buddy_1_online",
-								},
-								{
-									ClassID: wire.FeedbagClassIDPermit,
-									Name:    "buddy_2_online",
+									ClassID: wire.FeedbagClassIdBart,
+									TLVLBlock: wire.TLVLBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLV(wire.FeedbagAttributesBartInfo, wire.BARTInfo{
+												Hash: []byte{'t', 'h', 'e', 'h', 'a', 's', 'h'},
+											}),
+										},
+									},
 								},
 							},
 						},
 					},
 				},
 				messageRelayerParams: messageRelayerParams{
-					retrieveByScreenNameParams: retrieveByScreenNameParams{
-						{
-							screenName: "buddy_1_online",
-							sess:       newTestSession("buddy_1_online", sessOptCannedSignonTime),
-						},
-						{
-							screenName: "buddy_2_online",
-							sess:       newTestSession("buddy_2_online", sessOptCannedSignonTime),
-						},
-					},
-					sendToScreenNameParams: sendToScreenNameParams{
-						{
-							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(),
-								},
-							},
-						},
+					relayToScreenNameParams: relayToScreenNameParams{
 						{
 							screenName: "user_screen_name",
 							message: wire.SNACMessage{
 								Frame: wire.SNACFrame{
-									FoodGroup: wire.Buddy,
-									SubGroup:  wire.BuddyArrived,
+									FoodGroup: wire.OService,
+									SubGroup:  wire.OServiceBartReply,
 								},
-								Body: wire.SNAC_0x03_0x0B_BuddyArrived{
-									TLVUserInfo: newTestSession("buddy_2_online", sessOptCannedSignonTime).TLVUserInfo(),
+								Body: wire.SNAC_0x01_0x21_OServiceBARTReply{
+									BARTID: wire.BARTID{
+										Type: wire.BARTTypesBuddyIcon,
+										BARTInfo: wire.BARTInfo{
+											Flags: wire.BARTFlagsCustom | wire.BARTFlagsUnknown,
+											Hash:  []byte{'t', 'h', 'e', 'h', 'a', 's', 'h'},
+										},
+									},
 								},
 							},
 						},
@@ -886,45 +836,130 @@ func TestFeedbagService_UpdateItem(t *testing.T) {
 					RequestID: 1234,
 				},
 				Body: wire.SNAC_0x13_0x0E_FeedbagStatus{
-					Results: []uint16{0x0000, 0x0000},
+					Results: []uint16{0x0000},
 				},
 			},
 		},
 		{
-			name:        "user updates offline buddy in feedbag, receives no buddy arrival notification",
+			name:        "add icon hash to feedbag, icon already exists in BART store, notify buddies about icon change",
 			userSession: newTestSession("user_screen_name"),
 			inputSNAC: wire.SNACMessage{
 				Frame: wire.SNACFrame{
 					RequestID: 1234,
 				},
-				Body: wire.SNAC_0x13_0x09_FeedbagUpdateItem{
+				Body: wire.SNAC_0x13_0x08_FeedbagInsertItem{
 					Items: []wire.FeedbagItem{
 						{
-							ClassID: wire.FeedbagClassIDPermit,
-							Name:    "buddy_offline",
+							ClassID: wire.FeedbagClassIdBart,
+							TLVLBlock: wire.TLVLBlock{
+								TLVList: wire.TLVList{
+									wire.NewTLV(wire.FeedbagAttributesBartInfo, wire.BARTInfo{
+										Hash: []byte{'t', 'h', 'e', 'h', 'a', 's', 'h'},
+									}),
+								},
+							},
 						},
 					},
 				},
 			},
 			mockParams: mockParams{
+				bartManagerParams: bartManagerParams{
+					bartManagerRetrieveParams: bartManagerRetrieveParams{
+						{
+							itemHash: []byte{'t', 'h', 'e', 'h', 'a', 's', 'h'},
+							result:   []byte{'i', 'c', 'o', 'n', 'd', 'a', 't', 'a'},
+						},
+					},
+				},
 				feedbagManagerParams: feedbagManagerParams{
 					feedbagUpsertParams: feedbagUpsertParams{
 						{
 							screenName: "user_screen_name",
 							items: []wire.FeedbagItem{
 								{
-									ClassID: wire.FeedbagClassIDPermit,
-									Name:    "buddy_offline",
+									ClassID: wire.FeedbagClassIdBart,
+									TLVLBlock: wire.TLVLBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLV(wire.FeedbagAttributesBartInfo, wire.BARTInfo{
+												Hash: []byte{'t', 'h', 'e', 'h', 'a', 's', 'h'},
+											}),
+										},
+									},
+								},
+							},
+						},
+					},
+					adjacentUsersParams: adjacentUsersParams{
+						{
+							screenName: "user_screen_name",
+							users:      []string{"friend1"},
+						},
+					},
+					feedbagParams: feedbagParams{
+						{
+							screenName: "user_screen_name",
+							results: []wire.FeedbagItem{
+								{
+									Name:    "1",
+									ClassID: wire.FeedbagClassIdBart,
+									TLVLBlock: wire.TLVLBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLV(wire.FeedbagAttributesBartInfo,
+												[]byte{
+													wire.BARTFlagsCustom,
+													0x07, // hash len
+													't', 'h', 'e', 'h', 'a', 's', 'h',
+												},
+											),
+										},
+									},
 								},
 							},
 						},
 					},
 				},
 				messageRelayerParams: messageRelayerParams{
-					retrieveByScreenNameParams: retrieveByScreenNameParams{
+					relayToScreenNameParams: relayToScreenNameParams{
 						{
-							screenName: "buddy_offline",
-							sess:       nil,
+							screenName: "user_screen_name",
+							message: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.OService,
+									SubGroup:  wire.OServiceBartReply,
+								},
+								Body: wire.SNAC_0x01_0x21_OServiceBARTReply{
+									BARTID: wire.BARTID{
+										Type: wire.BARTTypesBuddyIcon,
+										BARTInfo: wire.BARTInfo{
+											Flags: wire.BARTFlagsCustom,
+											Hash:  []byte{'t', 'h', 'e', 'h', 'a', 's', 'h'},
+										},
+									},
+								},
+							},
+						},
+					},
+					relayToScreenNamesParams: relayToScreenNamesParams{
+						{
+							screenNames: []string{"friend1"},
+							message: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.Buddy,
+									SubGroup:  wire.BuddyArrived,
+								},
+								Body: wire.SNAC_0x03_0x0B_BuddyArrived{
+									TLVUserInfo: userInfoWithBARTIcon(
+										newTestSession("user_screen_name"),
+										wire.BARTID{
+											Type: wire.BARTTypesBuddyIcon,
+											BARTInfo: wire.BARTInfo{
+												Flags: wire.BARTFlagsCustom,
+												Hash:  []byte{'t', 'h', 'e', 'h', 'a', 's', 'h'},
+											},
+										},
+									),
+								},
+							},
 						},
 					},
 				},
@@ -941,17 +976,23 @@ func TestFeedbagService_UpdateItem(t *testing.T) {
 			},
 		},
 		{
-			name:        "user updates an invisible buddy in feedbag, receives no buddy arrival notification",
+			name:        "clear icon, notify buddies about icon change",
 			userSession: newTestSession("user_screen_name"),
 			inputSNAC: wire.SNACMessage{
 				Frame: wire.SNACFrame{
 					RequestID: 1234,
 				},
-				Body: wire.SNAC_0x13_0x09_FeedbagUpdateItem{
+				Body: wire.SNAC_0x13_0x08_FeedbagInsertItem{
 					Items: []wire.FeedbagItem{
 						{
-							ClassID: wire.FeedbagClassIDPermit,
-							Name:    "invisible_buddy_online",
+							ClassID: wire.FeedbagClassIdBart,
+							TLVLBlock: wire.TLVLBlock{
+								TLVList: wire.TLVList{
+									wire.NewTLV(wire.FeedbagAttributesBartInfo, wire.BARTInfo{
+										Hash: wire.GetClearIconHash(),
+									}),
+								},
+							},
 						},
 					},
 				},
@@ -963,18 +1004,71 @@ func TestFeedbagService_UpdateItem(t *testing.T) {
 							screenName: "user_screen_name",
 							items: []wire.FeedbagItem{
 								{
-									ClassID: wire.FeedbagClassIDPermit,
-									Name:    "invisible_buddy_online",
+									ClassID: wire.FeedbagClassIdBart,
+									TLVLBlock: wire.TLVLBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLV(wire.FeedbagAttributesBartInfo, wire.BARTInfo{
+												Hash: wire.GetClearIconHash(),
+											}),
+										},
+									},
+								},
+							},
+						},
+					},
+					adjacentUsersParams: adjacentUsersParams{
+						{
+							screenName: "user_screen_name",
+							users:      []string{"friend1"},
+						},
+					},
+					feedbagParams: feedbagParams{
+						{
+							screenName: "user_screen_name",
+							results: []wire.FeedbagItem{
+								{
+									Name:    "1",
+									ClassID: wire.FeedbagClassIdBart,
+									TLVLBlock: wire.TLVLBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLV(wire.FeedbagAttributesBartInfo,
+												append(
+													[]byte{
+														wire.BARTFlagsCustom,
+														uint8(len(wire.GetClearIconHash())),
+													},
+													wire.GetClearIconHash()...,
+												),
+											),
+										},
+									},
 								},
 							},
 						},
 					},
 				},
 				messageRelayerParams: messageRelayerParams{
-					retrieveByScreenNameParams: retrieveByScreenNameParams{
+					relayToScreenNamesParams: relayToScreenNamesParams{
 						{
-							screenName: "invisible_buddy_online",
-							sess:       newTestSession("invisible_buddy_online", sessOptInvisible),
+							screenNames: []string{"friend1"},
+							message: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.Buddy,
+									SubGroup:  wire.BuddyArrived,
+								},
+								Body: wire.SNAC_0x03_0x0B_BuddyArrived{
+									TLVUserInfo: userInfoWithBARTIcon(
+										newTestSession("user_screen_name"),
+										wire.BARTID{
+											Type: wire.BARTTypesBuddyIcon,
+											BARTInfo: wire.BARTInfo{
+												Flags: wire.BARTFlagsCustom | wire.BARTFlagsData,
+												Hash:  wire.GetClearIconHash(),
+											},
+										},
+									),
+								},
+							},
 						},
 					},
 				},
@@ -1000,23 +1094,43 @@ func TestFeedbagService_UpdateItem(t *testing.T) {
 					FeedbagUpsert(params.screenName, params.items).
 					Return(nil)
 			}
+			for _, params := range tc.mockParams.feedbagManagerParams.adjacentUsersParams {
+				feedbagManager.EXPECT().
+					AdjacentUsers(params.screenName).
+					Return(params.users, params.err)
+			}
+			for _, params := range tc.mockParams.feedbagManagerParams.feedbagParams {
+				feedbagManager.EXPECT().Feedbag(params.screenName).Return(params.results, nil)
+			}
 			messageRelayer := newMockMessageRelayer(t)
 			for _, params := range tc.mockParams.messageRelayerParams.retrieveByScreenNameParams {
 				messageRelayer.EXPECT().
 					RetrieveByScreenName(params.screenName).
 					Return(params.sess)
 			}
-			for _, params := range tc.mockParams.messageRelayerParams.sendToScreenNameParams {
+			for _, params := range tc.mockParams.messageRelayerParams.relayToScreenNameParams {
 				messageRelayer.EXPECT().
 					RelayToScreenName(mock.Anything, params.screenName, params.message)
 			}
+			for _, params := range tc.mockParams.messageRelayerParams.relayToScreenNamesParams {
+				messageRelayer.EXPECT().
+					RelayToScreenNames(mock.Anything, params.screenNames, params.message)
+			}
+			bartManager := newMockBARTManager(t)
+			for _, params := range tc.mockParams.bartManagerParams.bartManagerRetrieveParams {
+				bartManager.EXPECT().
+					BARTRetrieve(params.itemHash).
+					Return(params.result, nil)
+			}
 
 			svc := FeedbagService{
+				bartManager:    bartManager,
 				feedbagManager: feedbagManager,
+				logger:         slog.Default(),
 				messageRelayer: messageRelayer,
 			}
-			output, err := svc.UpdateItem(nil, tc.userSession, tc.inputSNAC.Frame,
-				tc.inputSNAC.Body.(wire.SNAC_0x13_0x09_FeedbagUpdateItem))
+			output, err := svc.UpsertItem(nil, tc.userSession, tc.inputSNAC.Frame,
+				tc.inputSNAC.Body.(wire.SNAC_0x13_0x08_FeedbagInsertItem).Items)
 			assert.NoError(t, err)
 			assert.Equal(t, output, tc.expectOutput)
 		})
@@ -1128,7 +1242,7 @@ func TestFeedbagService_DeleteItem(t *testing.T) {
 							sess:       newTestSession("buddy_2_online", sessOptCannedSignonTime),
 						},
 					},
-					sendToScreenNameParams: sendToScreenNameParams{
+					relayToScreenNameParams: relayToScreenNameParams{
 						{
 							screenName: "user_screen_name",
 							message: wire.SNACMessage{
@@ -1278,7 +1392,7 @@ func TestFeedbagService_DeleteItem(t *testing.T) {
 							sess:       newTestSession("invisible_buddy_online", sessOptInvisible),
 						},
 					},
-					sendToScreenNameParams: sendToScreenNameParams{
+					relayToScreenNameParams: relayToScreenNameParams{
 						{
 							screenName: "invisible_buddy_online",
 							message: wire.SNACMessage{
@@ -1321,7 +1435,7 @@ func TestFeedbagService_DeleteItem(t *testing.T) {
 					RetrieveByScreenName(params.screenName).
 					Return(params.sess)
 			}
-			for _, params := range tc.mockParams.messageRelayerParams.sendToScreenNameParams {
+			for _, params := range tc.mockParams.messageRelayerParams.relayToScreenNameParams {
 				messageRelayer.EXPECT().
 					RelayToScreenName(mock.Anything, params.screenName, params.message)
 			}

+ 4 - 0
foodgroup/icbm_test.go

@@ -554,6 +554,10 @@ func TestICBMService_EvilRequest(t *testing.T) {
 				AdjacentUsers(tc.recipientScreenName).
 				Return(tc.recipientBuddies, nil).
 				Maybe()
+			feedbagManager.EXPECT().
+				Feedbag(tc.recipientScreenName).
+				Return(nil, nil).
+				Maybe()
 			recipSess := newTestSession(tc.recipientScreenName, sessOptCannedSignonTime)
 			messageRelayer := newMockMessageRelayer(t)
 			messageRelayer.EXPECT().

+ 31 - 1
foodgroup/locate.go

@@ -2,11 +2,23 @@ package foodgroup
 
 import (
 	"context"
+	"errors"
 
 	"github.com/mk6i/retro-aim-server/state"
 	"github.com/mk6i/retro-aim-server/wire"
 )
 
+// omitCaps is the map of to filter out of the client's capability list
+// because they are not currently supported by the server.
+var omitCaps = map[[16]byte]bool{
+	// 0946134a-4c7f-11d1-8222-444553540000 (games)
+	{9, 70, 19, 74, 76, 127, 17, 209, 130, 34, 68, 69, 83, 84, 0, 0}: true,
+	// 0946134d-4c7f-11d1-8222-444553540000 (ICQ inter-op)
+	{9, 70, 19, 77, 76, 127, 17, 209, 130, 34, 68, 69, 83, 84, 0, 0}: true,
+	// 09461341-4c7f-11d1-8222-444553540000 (voice chat)
+	{9, 70, 19, 65, 76, 127, 17, 209, 130, 34, 68, 69, 83, 84, 0, 0}: true,
+}
+
 // NewLocateService creates a new instance of LocateService.
 func NewLocateService(messageRelayer MessageRelayer, feedbagManager FeedbagManager, profileManager ProfileManager) LocateService {
 	return LocateService{
@@ -50,7 +62,7 @@ func (s LocateService) RightsQuery(_ context.Context, inFrame wire.SNACFrame) wi
 	}
 }
 
-// SetInfo sets the user's profile or away message.
+// SetInfo sets the user's profile, away message or capabilities.
 func (s LocateService) SetInfo(ctx context.Context, sess *state.Session, inBody wire.SNAC_0x02_0x04_LocateSetInfo) error {
 	// update profile
 	if profile, hasProfile := inBody.String(wire.LocateTLVTagsInfoSigData); hasProfile {
@@ -66,6 +78,24 @@ func (s LocateService) SetInfo(ctx context.Context, sess *state.Session, inBody
 			return err
 		}
 	}
+
+	// update client capabilities (buddy icon, chat, etc...)
+	if b, hasCaps := inBody.Slice(wire.LocateTLVTagsInfoCapabilities); hasCaps {
+		if len(b)%16 != 0 {
+			return errors.New("capability list must be array of 16-byte values")
+		}
+		var caps [][16]byte
+		for i := 0; i < len(b); i += 16 {
+			var c [16]byte
+			copy(c[:], b[i:i+16])
+			if _, found := omitCaps[c]; found {
+				continue
+			}
+			caps = append(caps, c)
+		}
+		sess.SetCaps(caps)
+	}
+
 	return nil
 }
 

+ 61 - 10
foodgroup/locate_test.go

@@ -6,9 +6,10 @@ import (
 
 	"github.com/stretchr/testify/mock"
 
+	"github.com/stretchr/testify/assert"
+
 	"github.com/mk6i/retro-aim-server/state"
 	"github.com/mk6i/retro-aim-server/wire"
-	"github.com/stretchr/testify/assert"
 )
 
 func TestLocateService_UserInfoQuery2(t *testing.T) {
@@ -415,6 +416,16 @@ func TestLocateService_SetInfo(t *testing.T) {
 					},
 				},
 			},
+			mockParams: mockParams{
+				profileManagerParams: profileManagerParams{
+					upsertProfileParams: upsertProfileParams{
+						{
+							screenName: "test-user",
+							body:       "profile-result",
+						},
+					},
+				},
+			},
 		},
 		{
 			name:        "set away message",
@@ -428,7 +439,7 @@ func TestLocateService_SetInfo(t *testing.T) {
 			},
 			mockParams: mockParams{
 				messageRelayerParams: messageRelayerParams{
-					broadcastToScreenNamesParams: broadcastToScreenNamesParams{
+					relayToScreenNamesParams: relayToScreenNamesParams{
 						{
 							screenNames: []string{"friend1", "friend2"},
 							message: wire.SNACMessage{
@@ -444,15 +455,17 @@ func TestLocateService_SetInfo(t *testing.T) {
 					},
 				},
 				feedbagManagerParams: feedbagManagerParams{
-					interestedUsersParams: interestedUsersParams{
+					adjacentUsersParams: adjacentUsersParams{
 						{
 							screenName: "user_screen_name",
 							users:      []string{"friend1", "friend2"},
 						},
 					},
-				},
-				profileManagerParams: profileManagerParams{
-					upsertProfileParams: upsertProfileParams{},
+					feedbagParams: feedbagParams{
+						{
+							screenName: "user_screen_name",
+						},
+					},
 				},
 			},
 		},
@@ -460,20 +473,25 @@ func TestLocateService_SetInfo(t *testing.T) {
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
 			messageRelayer := newMockMessageRelayer(t)
-			for _, params := range tt.mockParams.broadcastToScreenNamesParams {
+			for _, params := range tt.mockParams.relayToScreenNamesParams {
 				messageRelayer.EXPECT().
 					RelayToScreenNames(mock.Anything, params.screenNames, params.message)
 			}
 			feedbagManager := newMockFeedbagManager(t)
-			for _, params := range tt.mockParams.interestedUsersParams {
+			for _, params := range tt.mockParams.adjacentUsersParams {
 				feedbagManager.EXPECT().
 					AdjacentUsers(params.screenName).
 					Return(params.users, nil)
 			}
+			for _, params := range tt.mockParams.feedbagParams {
+				feedbagManager.EXPECT().
+					Feedbag(params.screenName).
+					Return(params.results, nil)
+			}
 			profileManager := newMockProfileManager(t)
-			if msg, hasProf := tt.inBody.String(wire.LocateTLVTagsInfoSigData); hasProf {
+			for _, params := range tt.mockParams.upsertProfileParams {
 				profileManager.EXPECT().
-					SetProfile(tt.userSession.ScreenName(), msg).
+					SetProfile(params.screenName, params.body).
 					Return(nil)
 			}
 			svc := NewLocateService(messageRelayer, feedbagManager, profileManager)
@@ -482,6 +500,39 @@ func TestLocateService_SetInfo(t *testing.T) {
 	}
 }
 
+func TestLocateService_SetInfo_SetCaps(t *testing.T) {
+	svc := NewLocateService(nil, nil, nil)
+
+	sess := newTestSession("screen-name")
+	inBody := wire.SNAC_0x02_0x04_LocateSetInfo{
+		TLVRestBlock: wire.TLVRestBlock{
+			TLVList: wire.TLVList{
+				wire.NewTLV(wire.LocateTLVTagsInfoCapabilities, []byte{
+					// chat: "748F2420-6287-11D1-8222-444553540000"
+					0x74, 0x8f, 0x24, 0x20, 0x62, 0x87, 0x11, 0xd1, 0x82, 0x22, 0x44, 0x45, 0x53, 0x54, 0x00, 0x00,
+					// avatar: "09461346-4c7f-11d1-8222-444553540000"
+					9, 70, 19, 70, 76, 127, 17, 209, 130, 34, 68, 69, 83, 84, 0, 0,
+					// 0946134a-4c7f-11d1-8222-444553540000 (games)
+					9, 70, 19, 74, 76, 127, 17, 209, 130, 34, 68, 69, 83, 84, 0, 0,
+					// 0946134d-4c7f-11d1-8222-444553540000 (ICQ inter-op)
+					9, 70, 19, 77, 76, 127, 17, 209, 130, 34, 68, 69, 83, 84, 0, 0,
+					// 09461341-4c7f-11d1-8222-444553540000 (voice chat)
+					9, 70, 19, 65, 76, 127, 17, 209, 130, 34, 68, 69, 83, 84, 0, 0,
+				}),
+			},
+		},
+	}
+	assert.NoError(t, svc.SetInfo(nil, sess, inBody))
+
+	expect := [][16]byte{
+		// 748F2420-6287-11D1-8222-444553540000 (chat)
+		{0x74, 0x8f, 0x24, 0x20, 0x62, 0x87, 0x11, 0xd1, 0x82, 0x22, 0x44, 0x45, 0x53, 0x54, 0x00, 0x00},
+		// 09461346-4C7F-11D1-8222-444553540000 (avatar)
+		{9, 70, 19, 70, 76, 127, 17, 209, 130, 34, 68, 69, 83, 84, 0, 0},
+	}
+	assert.Equal(t, expect, sess.Caps())
+}
+
 func TestLocateService_RightsQuery(t *testing.T) {
 	svc := NewLocateService(nil, nil, nil)
 

+ 137 - 0
foodgroup/mock_bart_manager_test.go

@@ -0,0 +1,137 @@
+// Code generated by mockery v2.40.1. DO NOT EDIT.
+
+package foodgroup
+
+import mock "github.com/stretchr/testify/mock"
+
+// mockBARTManager is an autogenerated mock type for the BARTManager type
+type mockBARTManager struct {
+	mock.Mock
+}
+
+type mockBARTManager_Expecter struct {
+	mock *mock.Mock
+}
+
+func (_m *mockBARTManager) EXPECT() *mockBARTManager_Expecter {
+	return &mockBARTManager_Expecter{mock: &_m.Mock}
+}
+
+// BARTRetrieve provides a mock function with given fields: itemHash
+func (_m *mockBARTManager) BARTRetrieve(itemHash []byte) ([]byte, error) {
+	ret := _m.Called(itemHash)
+
+	if len(ret) == 0 {
+		panic("no return value specified for BARTRetrieve")
+	}
+
+	var r0 []byte
+	var r1 error
+	if rf, ok := ret.Get(0).(func([]byte) ([]byte, error)); ok {
+		return rf(itemHash)
+	}
+	if rf, ok := ret.Get(0).(func([]byte) []byte); ok {
+		r0 = rf(itemHash)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).([]byte)
+		}
+	}
+
+	if rf, ok := ret.Get(1).(func([]byte) error); ok {
+		r1 = rf(itemHash)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// mockBARTManager_BARTRetrieve_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BARTRetrieve'
+type mockBARTManager_BARTRetrieve_Call struct {
+	*mock.Call
+}
+
+// BARTRetrieve is a helper method to define mock.On call
+//   - itemHash []byte
+func (_e *mockBARTManager_Expecter) BARTRetrieve(itemHash interface{}) *mockBARTManager_BARTRetrieve_Call {
+	return &mockBARTManager_BARTRetrieve_Call{Call: _e.mock.On("BARTRetrieve", itemHash)}
+}
+
+func (_c *mockBARTManager_BARTRetrieve_Call) Run(run func(itemHash []byte)) *mockBARTManager_BARTRetrieve_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].([]byte))
+	})
+	return _c
+}
+
+func (_c *mockBARTManager_BARTRetrieve_Call) Return(_a0 []byte, _a1 error) *mockBARTManager_BARTRetrieve_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *mockBARTManager_BARTRetrieve_Call) RunAndReturn(run func([]byte) ([]byte, error)) *mockBARTManager_BARTRetrieve_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// BARTUpsert provides a mock function with given fields: itemHash, payload
+func (_m *mockBARTManager) BARTUpsert(itemHash []byte, payload []byte) error {
+	ret := _m.Called(itemHash, payload)
+
+	if len(ret) == 0 {
+		panic("no return value specified for BARTUpsert")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func([]byte, []byte) error); ok {
+		r0 = rf(itemHash, payload)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// mockBARTManager_BARTUpsert_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BARTUpsert'
+type mockBARTManager_BARTUpsert_Call struct {
+	*mock.Call
+}
+
+// BARTUpsert is a helper method to define mock.On call
+//   - itemHash []byte
+//   - payload []byte
+func (_e *mockBARTManager_Expecter) BARTUpsert(itemHash interface{}, payload interface{}) *mockBARTManager_BARTUpsert_Call {
+	return &mockBARTManager_BARTUpsert_Call{Call: _e.mock.On("BARTUpsert", itemHash, payload)}
+}
+
+func (_c *mockBARTManager_BARTUpsert_Call) Run(run func(itemHash []byte, payload []byte)) *mockBARTManager_BARTUpsert_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].([]byte), args[1].([]byte))
+	})
+	return _c
+}
+
+func (_c *mockBARTManager_BARTUpsert_Call) Return(_a0 error) *mockBARTManager_BARTUpsert_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockBARTManager_BARTUpsert_Call) RunAndReturn(run func([]byte, []byte) error) *mockBARTManager_BARTUpsert_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// newMockBARTManager creates a new instance of mockBARTManager. 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 newMockBARTManager(t interface {
+	mock.TestingT
+	Cleanup(func())
+}) *mockBARTManager {
+	mock := &mockBARTManager{}
+	mock.Mock.Test(t)
+
+	t.Cleanup(func() { mock.AssertExpectations(t) })
+
+	return mock
+}

+ 17 - 1
foodgroup/oservice.go

@@ -242,6 +242,14 @@ func (s OServiceService) RateParamsQuery(_ context.Context, inFrame wire.SNACFra
 							FoodGroup: wire.OService,
 							SubGroup:  wire.OServiceSetUserInfoFields,
 						},
+						{
+							FoodGroup: wire.BART,
+							SubGroup:  wire.BARTUploadQuery,
+						},
+						{
+							FoodGroup: wire.BART,
+							SubGroup:  wire.BARTDownloadQuery,
+						},
 					},
 				},
 			},
@@ -408,6 +416,7 @@ func (s OServiceServiceForBOS) HostOnline() wire.SNACMessage {
 				wire.ICBM,
 				wire.Locate,
 				wire.OService,
+				wire.BART,
 			},
 		},
 	}
@@ -415,13 +424,19 @@ func (s OServiceServiceForBOS) HostOnline() wire.SNACMessage {
 
 // ClientOnline runs when the current user is ready to join.
 // It performs the following sequence of actions:
+//   - Pulls the buddy icon from the feedbag and set it on the session.
 //   - Announce current user's arrival to users who have the current user on
 //     their buddy list.
-//   - Send current user its buddy list
+//   - Send current user its buddy list.
 func (s OServiceServiceForBOS) ClientOnline(ctx context.Context, _ wire.SNAC_0x01_0x02_OServiceClientOnline, sess *state.Session) error {
 	if err := broadcastArrival(ctx, sess, s.messageRelayer, s.feedbagManager); err != nil {
 		return err
 	}
+
+	return s.retrieveOnlineBuddies(ctx, sess)
+}
+
+func (s OServiceServiceForBOS) retrieveOnlineBuddies(ctx context.Context, sess *state.Session) error {
 	buddies, err := s.feedbagManager.Buddies(sess.ScreenName())
 	if err != nil {
 		return err
@@ -433,6 +448,7 @@ func (s OServiceServiceForBOS) ClientOnline(ctx context.Context, _ wire.SNAC_0x0
 		}
 		unicastArrival(ctx, buddy, sess, s.messageRelayer)
 	}
+
 	return nil
 }
 

+ 43 - 11
foodgroup/oservice_test.go

@@ -4,12 +4,14 @@ import (
 	"testing"
 	"time"
 
-	"github.com/mk6i/retro-aim-server/config"
 	"github.com/stretchr/testify/mock"
 
+	"github.com/mk6i/retro-aim-server/config"
+
+	"github.com/stretchr/testify/assert"
+
 	"github.com/mk6i/retro-aim-server/state"
 	"github.com/mk6i/retro-aim-server/wire"
-	"github.com/stretchr/testify/assert"
 )
 
 func TestOServiceServiceForBOS_ServiceRequest(t *testing.T) {
@@ -296,6 +298,10 @@ func TestSetUserInfoFields(t *testing.T) {
 					Return(friends, nil).
 					Maybe()
 			}
+			feedbagManager.EXPECT().
+				Feedbag(tc.userSession.ScreenName()).
+				Return(nil, nil).
+				Maybe()
 			messageRelayer := newMockMessageRelayer(t)
 			for _, broadcastMsg := range tc.broadcastMessage {
 				messageRelayer.EXPECT().RelayToScreenNames(mock.Anything, broadcastMsg.recipients, broadcastMsg.msg)
@@ -503,6 +509,14 @@ func TestOServiceService_RateParamsQuery(t *testing.T) {
 							FoodGroup: wire.OService,
 							SubGroup:  wire.OServiceSetUserInfoFields,
 						},
+						{
+							FoodGroup: wire.BART,
+							SubGroup:  wire.BARTUploadQuery,
+						},
+						{
+							FoodGroup: wire.BART,
+							SubGroup:  wire.BARTDownloadQuery,
+						},
 					},
 				},
 			},
@@ -529,6 +543,7 @@ func TestOServiceServiceForBOS_OServiceHostOnline(t *testing.T) {
 				wire.ICBM,
 				wire.Locate,
 				wire.OService,
+				wire.BART,
 			},
 		},
 	}
@@ -657,6 +672,10 @@ func TestOServiceService_IdleNotification(t *testing.T) {
 				AdjacentUsers(tt.recipientScreenName).
 				Return(tt.recipientBuddies, nil).
 				Maybe()
+			feedbagManager.EXPECT().
+				Feedbag(tt.sess.ScreenName()).
+				Return(nil, nil).
+				Maybe()
 			messageRelayer := newMockMessageRelayer(t)
 			messageRelayer.EXPECT().
 				RelayToScreenNames(mock.Anything, tt.recipientBuddies, tt.broadcastMessage).
@@ -687,33 +706,35 @@ func TestOServiceServiceForBOS_ClientOnline(t *testing.T) {
 		// buddyLookupParams contains params for looking up arriving user's
 		// buddies
 		buddyLookupParams buddiesLookupParams
-		// interestedUsersParams contains params for looking up users who have
+		// adjacentUsersParams contains params for looking up users who have
 		// the arriving user on their buddy list
-		interestedUsersParams interestedUsersParams
-		// broadcastToScreenNamesParams contains params for sending
+		interestedUsersParams adjacentUsersParams
+		// relayToScreenNamesParams contains params for sending
 		// buddy online notification to users who have the arriving user on
 		// their buddy list
-		broadcastToScreenNamesParams broadcastToScreenNamesParams
+		broadcastToScreenNamesParams relayToScreenNamesParams
 		// retrieveByScreenNameParams contains params for looking up the
 		// session for each of the arriving user's buddies
 		retrieveByScreenNameParams retrieveByScreenNameParams
 		// sendToScreenNameParams contains params for sending arrival
 		// notifications for each of the arriving user's buddies to the
 		// arriving user's client
-		sendToScreenNameParams sendToScreenNameParams
-		wantErr                error
+		sendToScreenNameParams relayToScreenNameParams
+		// feedbagParams contains params for retrieving a user's feedbag
+		feedbagParams feedbagParams
+		wantErr       error
 	}{
 		{
 			name:   "notify arriving user's buddies of its arrival and populate the arriving user's buddy list",
 			sess:   newTestSession("test-user"),
 			bodyIn: wire.SNAC_0x01_0x02_OServiceClientOnline{},
-			interestedUsersParams: interestedUsersParams{
+			interestedUsersParams: adjacentUsersParams{
 				{
 					screenName: "test-user",
 					users:      []string{"buddy1", "buddy2", "buddy3", "buddy4"},
 				},
 			},
-			broadcastToScreenNamesParams: broadcastToScreenNamesParams{
+			broadcastToScreenNamesParams: relayToScreenNamesParams{
 				{
 					screenNames: []string{"buddy1", "buddy2", "buddy3", "buddy4"},
 					message: wire.SNACMessage{
@@ -743,7 +764,7 @@ func TestOServiceServiceForBOS_ClientOnline(t *testing.T) {
 					sess:       newTestSession("buddy3"),
 				},
 			},
-			sendToScreenNameParams: sendToScreenNameParams{
+			sendToScreenNameParams: relayToScreenNameParams{
 				{
 					screenName: "test-user",
 					message: wire.SNACMessage{
@@ -769,6 +790,12 @@ func TestOServiceServiceForBOS_ClientOnline(t *testing.T) {
 					},
 				},
 			},
+			feedbagParams: feedbagParams{
+				{
+					screenName: "test-user",
+					results:    []wire.FeedbagItem{},
+				},
+			},
 		},
 	}
 	for _, tt := range tests {
@@ -798,6 +825,11 @@ func TestOServiceServiceForBOS_ClientOnline(t *testing.T) {
 				messageRelayer.EXPECT().
 					RelayToScreenName(mock.Anything, params.screenName, params.message)
 			}
+			for _, params := range tt.feedbagParams {
+				feedbagManager.EXPECT().
+					Feedbag(params.screenName).
+					Return(params.results, nil)
+			}
 
 			svc := NewOServiceServiceForBOS(OServiceService{
 				feedbagManager: feedbagManager,

+ 46 - 11
foodgroup/test_helpers.go

@@ -10,6 +10,7 @@ import (
 // mockParams is a helper struct that centralizes mock function call parameters
 // in one place for a table test
 type mockParams struct {
+	bartManagerParams
 	chatMessageRelayerParams
 	chatRegistryParams
 	feedbagManagerParams
@@ -19,13 +20,34 @@ type mockParams struct {
 	userManagerParams
 }
 
-// userManagerParams is a helper struct that contains mock parameters for
+// bartManagerParams is a helper struct that contains mock parameters for
+// BARTManager methods
+type bartManagerParams struct {
+	bartManagerRetrieveParams
+	bartManagerUpsertParams
+}
+
+// bartManagerRetrieveParams is the list of parameters passed at the mock
+// BARTManager.BARTRetrieve call site
+type bartManagerRetrieveParams []struct {
+	itemHash []byte
+	result   []byte
+}
+
+// bartManagerUpsertParams is the list of parameters passed at the mock
+// BARTManager.BARTUpsert call site
+type bartManagerUpsertParams []struct {
+	itemHash []byte
+	payload  []byte
+}
+
+// chatRegistryParams is a helper struct that contains mock parameters for
 // ChatRegistry methods
 type chatRegistryParams struct {
 	chatRegistryRetrieveParams
 }
 
-// upsertUserParams is the list of parameters passed at the mock
+// chatRegistryRetrieveParams is the list of parameters passed at the mock
 // ChatRegistry.Retrieve call site
 type chatRegistryRetrieveParams struct {
 	cookie         string
@@ -88,7 +110,7 @@ type emptyParams []struct {
 // FeedbagManager methods
 type feedbagManagerParams struct {
 	blockedStateParams
-	interestedUsersParams
+	adjacentUsersParams
 	feedbagUpsertParams
 	buddiesParams
 	feedbagParams
@@ -105,9 +127,9 @@ type blockedStateParams []struct {
 	err         error
 }
 
-// interestedUsersParams is the list of parameters passed at the mock
+// adjacentUsersParams is the list of parameters passed at the mock
 // FeedbagManager.AdjacentUsers call site
-type interestedUsersParams []struct {
+type adjacentUsersParams []struct {
 	screenName string
 	users      []string
 	err        error
@@ -152,8 +174,8 @@ type feedbagDeleteParams []struct {
 // MessageRelayer methods
 type messageRelayerParams struct {
 	retrieveByScreenNameParams
-	broadcastToScreenNamesParams
-	sendToScreenNameParams
+	relayToScreenNamesParams
+	relayToScreenNameParams
 }
 
 // retrieveByScreenNameParams is the list of parameters passed at the mock
@@ -163,16 +185,16 @@ type retrieveByScreenNameParams []struct {
 	sess       *state.Session
 }
 
-// broadcastToScreenNamesParams is the list of parameters passed at the mock
+// relayToScreenNamesParams is the list of parameters passed at the mock
 // MessageRelayer.RelayToScreenNames call site
-type broadcastToScreenNamesParams []struct {
+type relayToScreenNamesParams []struct {
 	screenNames []string
 	message     wire.SNACMessage
 }
 
-// sendToScreenNameParams is the list of parameters passed at the mock
+// relayToScreenNameParams is the list of parameters passed at the mock
 // MessageRelayer.RelayToScreenName call site
-type sendToScreenNameParams []struct {
+type relayToScreenNameParams []struct {
 	screenName string
 	message    wire.SNACMessage
 }
@@ -272,6 +294,13 @@ func sessOptIdle(dur time.Duration) func(session *state.Session) {
 	}
 }
 
+// sessOptCaps sets caps
+func sessOptCaps(caps [][16]byte) func(session *state.Session) {
+	return func(session *state.Session) {
+		session.SetCaps(caps)
+	}
+}
+
 // newTestSession creates a session object with 0 or more functional options
 // applied
 func newTestSession(screenName string, options ...func(session *state.Session)) *state.Session {
@@ -282,3 +311,9 @@ func newTestSession(screenName string, options ...func(session *state.Session))
 	}
 	return s
 }
+
+func userInfoWithBARTIcon(sess *state.Session, bid wire.BARTID) wire.TLVUserInfo {
+	info := sess.TLVUserInfo()
+	info.Append(wire.NewTLV(wire.OServiceUserInfoBARTInfo, bid))
+	return info
+}

+ 5 - 0
foodgroup/types.go

@@ -85,3 +85,8 @@ type ChatRegistry interface {
 	Retrieve(cookie string) (state.ChatRoom, any, error)
 	Remove(cookie string)
 }
+
+type BARTManager interface {
+	BARTUpsert(itemHash []byte, payload []byte) error
+	BARTRetrieve(itemHash []byte) ([]byte, error)
+}

+ 57 - 0
server/oscar/handler/bart.go

@@ -0,0 +1,57 @@
+package handler
+
+import (
+	"context"
+	"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"
+)
+
+type BARTService interface {
+	UpsertItem(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x10_0x02_BARTUploadQuery) (wire.SNACMessage, error)
+	RetrieveItem(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x10_0x04_BARTDownloadQuery) (wire.SNACMessage, error)
+}
+
+func NewBARTHandler(logger *slog.Logger, bartService BARTService) BARTHandler {
+	return BARTHandler{
+		BARTService: bartService,
+		RouteLogger: middleware.RouteLogger{
+			Logger: logger,
+		},
+	}
+}
+
+type BARTHandler struct {
+	BARTService
+	middleware.RouteLogger
+}
+
+func (h BARTHandler) UploadQuery(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw oscar.ResponseWriter) error {
+	inBody := wire.SNAC_0x10_0x02_BARTUploadQuery{}
+	if err := wire.Unmarshal(&inBody, r); err != nil {
+		return err
+	}
+	outSNAC, err := h.UpsertItem(ctx, sess, inFrame, inBody)
+	if err != nil {
+		return err
+	}
+	h.LogRequestAndResponse(ctx, inFrame, outSNAC, outSNAC.Frame, outSNAC.Body)
+	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
+}
+
+func (h BARTHandler) DownloadQuery(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw oscar.ResponseWriter) error {
+	inBody := wire.SNAC_0x10_0x04_BARTDownloadQuery{}
+	if err := wire.Unmarshal(&inBody, r); err != nil {
+		return err
+	}
+	outSNAC, err := h.RetrieveItem(ctx, sess, inFrame, inBody)
+	if err != nil {
+		return err
+	}
+	h.LogRequestAndResponse(ctx, inFrame, outSNAC, outSNAC.Frame, outSNAC.Body)
+	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
+}

+ 86 - 0
server/oscar/handler/bart_test.go

@@ -0,0 +1,86 @@
+package handler
+
+import (
+	"bytes"
+	"log/slog"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/mock"
+
+	"github.com/mk6i/retro-aim-server/wire"
+)
+
+func TestBARTHandler_DownloadQuery(t *testing.T) {
+	input := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.BART,
+			SubGroup:  wire.BARTDownloadQuery,
+		},
+		Body: wire.SNAC_0x10_0x04_BARTDownloadQuery{},
+	}
+	output := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.BART,
+			SubGroup:  wire.BARTDownloadReply,
+		},
+		Body: wire.SNAC_0x10_0x05_BARTDownloadReply{
+			ScreenName: "the-screen-name",
+		},
+	}
+
+	svc := newMockBARTService(t)
+	svc.EXPECT().
+		RetrieveItem(mock.Anything, mock.Anything, input.Frame, input.Body).
+		Return(output, nil)
+
+	h := NewBARTHandler(slog.Default(), svc)
+
+	responseWriter := newMockResponseWriter(t)
+	responseWriter.EXPECT().
+		SendSNAC(output.Frame, output.Body).
+		Return(nil)
+
+	buf := &bytes.Buffer{}
+	assert.NoError(t, wire.Marshal(input.Body, buf))
+
+	assert.NoError(t, h.DownloadQuery(nil, nil, input.Frame, buf, responseWriter))
+}
+
+func TestBARTHandler_UploadQuery(t *testing.T) {
+	input := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.BART,
+			SubGroup:  wire.BARTUploadQuery,
+		},
+		Body: wire.SNAC_0x10_0x02_BARTUploadQuery{
+			Type: 1,
+		},
+	}
+	output := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.BART,
+			SubGroup:  wire.BARTUploadReply,
+		},
+		Body: wire.SNAC_0x10_0x03_BARTUploadReply{
+			Code: wire.BARTReplyCodesSuccess,
+		},
+	}
+
+	svc := newMockBARTService(t)
+	svc.EXPECT().
+		UpsertItem(mock.Anything, mock.Anything, input.Frame, input.Body).
+		Return(output, nil)
+
+	h := NewBARTHandler(slog.Default(), svc)
+
+	responseWriter := newMockResponseWriter(t)
+	responseWriter.EXPECT().
+		SendSNAC(output.Frame, output.Body).
+		Return(nil)
+
+	buf := &bytes.Buffer{}
+	assert.NoError(t, wire.Marshal(input.Body, buf))
+
+	assert.NoError(t, h.UploadQuery(nil, nil, input.Frame, buf, responseWriter))
+}

+ 5 - 5
server/oscar/handler/feedbag.go

@@ -2,10 +2,11 @@ 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,12 +14,11 @@ import (
 
 type FeedbagService interface {
 	DeleteItem(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x0A_FeedbagDeleteItem) (wire.SNACMessage, error)
-	InsertItem(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x08_FeedbagInsertItem) (wire.SNACMessage, error)
 	Query(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame) (wire.SNACMessage, error)
 	QueryIfModified(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x05_FeedbagQueryIfModified) (wire.SNACMessage, error)
 	RightsQuery(ctx context.Context, inFrame wire.SNACFrame) wire.SNACMessage
 	StartCluster(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x11_FeedbagStartCluster)
-	UpdateItem(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x09_FeedbagUpdateItem) (wire.SNACMessage, error)
+	UpsertItem(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, items []wire.FeedbagItem) (wire.SNACMessage, error)
 }
 
 func NewFeedbagHandler(logger *slog.Logger, feedbagService FeedbagService) FeedbagHandler {
@@ -77,7 +77,7 @@ func (h FeedbagHandler) InsertItem(ctx context.Context, sess *state.Session, inF
 	if err := wire.Unmarshal(&inBody, r); err != nil {
 		return err
 	}
-	outSNAC, err := h.FeedbagService.InsertItem(ctx, sess, inFrame, inBody)
+	outSNAC, err := h.FeedbagService.UpsertItem(ctx, sess, inFrame, inBody.Items)
 	if err != nil {
 		return err
 	}
@@ -90,7 +90,7 @@ func (h FeedbagHandler) UpdateItem(ctx context.Context, sess *state.Session, inF
 	if err := wire.Unmarshal(&inBody, r); err != nil {
 		return err
 	}
-	outSNAC, err := h.FeedbagService.UpdateItem(ctx, sess, inFrame, inBody)
+	outSNAC, err := h.FeedbagService.UpsertItem(ctx, sess, inFrame, inBody.Items)
 	if err != nil {
 		return err
 	}

+ 2 - 2
server/oscar/handler/feedbag_test.go

@@ -98,7 +98,7 @@ func TestFeedbagHandler_InsertItem(t *testing.T) {
 
 	svc := newMockFeedbagService(t)
 	svc.EXPECT().
-		InsertItem(mock.Anything, mock.Anything, input.Frame, input.Body).
+		UpsertItem(mock.Anything, mock.Anything, input.Frame, input.Body.(wire.SNAC_0x13_0x08_FeedbagInsertItem).Items).
 		Return(output, nil)
 
 	h := NewFeedbagHandler(slog.Default(), svc)
@@ -307,7 +307,7 @@ func TestFeedbagHandler_UpdateItem(t *testing.T) {
 
 	svc := newMockFeedbagService(t)
 	svc.EXPECT().
-		UpdateItem(mock.Anything, mock.Anything, input.Frame, input.Body).
+		UpsertItem(mock.Anything, mock.Anything, input.Frame, input.Body.(wire.SNAC_0x13_0x09_FeedbagUpdateItem).Items).
 		Return(output, nil)
 
 	h := NewFeedbagHandler(slog.Default(), svc)

+ 157 - 0
server/oscar/handler/mock_bart_test.go

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

+ 20 - 79
server/oscar/handler/mock_feedbag_test.go

@@ -83,65 +83,6 @@ func (_c *mockFeedbagService_DeleteItem_Call) RunAndReturn(run func(context.Cont
 	return _c
 }
 
-// InsertItem provides a mock function with given fields: ctx, sess, inFrame, inBody
-func (_m *mockFeedbagService) InsertItem(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x08_FeedbagInsertItem) (wire.SNACMessage, error) {
-	ret := _m.Called(ctx, sess, inFrame, inBody)
-
-	if len(ret) == 0 {
-		panic("no return value specified for InsertItem")
-	}
-
-	var r0 wire.SNACMessage
-	var r1 error
-	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x13_0x08_FeedbagInsertItem) (wire.SNACMessage, error)); ok {
-		return rf(ctx, sess, inFrame, inBody)
-	}
-	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x13_0x08_FeedbagInsertItem) wire.SNACMessage); ok {
-		r0 = rf(ctx, sess, inFrame, inBody)
-	} else {
-		r0 = ret.Get(0).(wire.SNACMessage)
-	}
-
-	if rf, ok := ret.Get(1).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x13_0x08_FeedbagInsertItem) error); ok {
-		r1 = rf(ctx, sess, inFrame, inBody)
-	} else {
-		r1 = ret.Error(1)
-	}
-
-	return r0, r1
-}
-
-// mockFeedbagService_InsertItem_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'InsertItem'
-type mockFeedbagService_InsertItem_Call struct {
-	*mock.Call
-}
-
-// InsertItem is a helper method to define mock.On call
-//   - ctx context.Context
-//   - sess *state.Session
-//   - inFrame wire.SNACFrame
-//   - inBody wire.SNAC_0x13_0x08_FeedbagInsertItem
-func (_e *mockFeedbagService_Expecter) InsertItem(ctx interface{}, sess interface{}, inFrame interface{}, inBody interface{}) *mockFeedbagService_InsertItem_Call {
-	return &mockFeedbagService_InsertItem_Call{Call: _e.mock.On("InsertItem", ctx, sess, inFrame, inBody)}
-}
-
-func (_c *mockFeedbagService_InsertItem_Call) Run(run func(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x08_FeedbagInsertItem)) *mockFeedbagService_InsertItem_Call {
-	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(context.Context), args[1].(*state.Session), args[2].(wire.SNACFrame), args[3].(wire.SNAC_0x13_0x08_FeedbagInsertItem))
-	})
-	return _c
-}
-
-func (_c *mockFeedbagService_InsertItem_Call) Return(_a0 wire.SNACMessage, _a1 error) *mockFeedbagService_InsertItem_Call {
-	_c.Call.Return(_a0, _a1)
-	return _c
-}
-
-func (_c *mockFeedbagService_InsertItem_Call) RunAndReturn(run func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x13_0x08_FeedbagInsertItem) (wire.SNACMessage, error)) *mockFeedbagService_InsertItem_Call {
-	_c.Call.Return(run)
-	return _c
-}
-
 // Query provides a mock function with given fields: ctx, sess, inFrame
 func (_m *mockFeedbagService) Query(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame) (wire.SNACMessage, error) {
 	ret := _m.Called(ctx, sess, inFrame)
@@ -341,27 +282,27 @@ func (_c *mockFeedbagService_StartCluster_Call) RunAndReturn(run func(context.Co
 	return _c
 }
 
-// UpdateItem provides a mock function with given fields: ctx, sess, inFrame, inBody
-func (_m *mockFeedbagService) UpdateItem(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x09_FeedbagUpdateItem) (wire.SNACMessage, error) {
-	ret := _m.Called(ctx, sess, inFrame, inBody)
+// UpsertItem provides a mock function with given fields: ctx, sess, inFrame, items
+func (_m *mockFeedbagService) UpsertItem(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, items []wire.FeedbagItem) (wire.SNACMessage, error) {
+	ret := _m.Called(ctx, sess, inFrame, items)
 
 	if len(ret) == 0 {
-		panic("no return value specified for UpdateItem")
+		panic("no return value specified for UpsertItem")
 	}
 
 	var r0 wire.SNACMessage
 	var r1 error
-	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x13_0x09_FeedbagUpdateItem) (wire.SNACMessage, error)); ok {
-		return rf(ctx, sess, inFrame, inBody)
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNACFrame, []wire.FeedbagItem) (wire.SNACMessage, error)); ok {
+		return rf(ctx, sess, inFrame, items)
 	}
-	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x13_0x09_FeedbagUpdateItem) wire.SNACMessage); ok {
-		r0 = rf(ctx, sess, inFrame, inBody)
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNACFrame, []wire.FeedbagItem) wire.SNACMessage); ok {
+		r0 = rf(ctx, sess, inFrame, items)
 	} else {
 		r0 = ret.Get(0).(wire.SNACMessage)
 	}
 
-	if rf, ok := ret.Get(1).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x13_0x09_FeedbagUpdateItem) error); ok {
-		r1 = rf(ctx, sess, inFrame, inBody)
+	if rf, ok := ret.Get(1).(func(context.Context, *state.Session, wire.SNACFrame, []wire.FeedbagItem) error); ok {
+		r1 = rf(ctx, sess, inFrame, items)
 	} else {
 		r1 = ret.Error(1)
 	}
@@ -369,33 +310,33 @@ func (_m *mockFeedbagService) UpdateItem(ctx context.Context, sess *state.Sessio
 	return r0, r1
 }
 
-// mockFeedbagService_UpdateItem_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateItem'
-type mockFeedbagService_UpdateItem_Call struct {
+// mockFeedbagService_UpsertItem_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpsertItem'
+type mockFeedbagService_UpsertItem_Call struct {
 	*mock.Call
 }
 
-// UpdateItem is a helper method to define mock.On call
+// UpsertItem is a helper method to define mock.On call
 //   - ctx context.Context
 //   - sess *state.Session
 //   - inFrame wire.SNACFrame
-//   - inBody wire.SNAC_0x13_0x09_FeedbagUpdateItem
-func (_e *mockFeedbagService_Expecter) UpdateItem(ctx interface{}, sess interface{}, inFrame interface{}, inBody interface{}) *mockFeedbagService_UpdateItem_Call {
-	return &mockFeedbagService_UpdateItem_Call{Call: _e.mock.On("UpdateItem", ctx, sess, inFrame, inBody)}
+//   - items []wire.FeedbagItem
+func (_e *mockFeedbagService_Expecter) UpsertItem(ctx interface{}, sess interface{}, inFrame interface{}, items interface{}) *mockFeedbagService_UpsertItem_Call {
+	return &mockFeedbagService_UpsertItem_Call{Call: _e.mock.On("UpsertItem", ctx, sess, inFrame, items)}
 }
 
-func (_c *mockFeedbagService_UpdateItem_Call) Run(run func(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x09_FeedbagUpdateItem)) *mockFeedbagService_UpdateItem_Call {
+func (_c *mockFeedbagService_UpsertItem_Call) Run(run func(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, items []wire.FeedbagItem)) *mockFeedbagService_UpsertItem_Call {
 	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(context.Context), args[1].(*state.Session), args[2].(wire.SNACFrame), args[3].(wire.SNAC_0x13_0x09_FeedbagUpdateItem))
+		run(args[0].(context.Context), args[1].(*state.Session), args[2].(wire.SNACFrame), args[3].([]wire.FeedbagItem))
 	})
 	return _c
 }
 
-func (_c *mockFeedbagService_UpdateItem_Call) Return(_a0 wire.SNACMessage, _a1 error) *mockFeedbagService_UpdateItem_Call {
+func (_c *mockFeedbagService_UpsertItem_Call) Return(_a0 wire.SNACMessage, _a1 error) *mockFeedbagService_UpsertItem_Call {
 	_c.Call.Return(_a0, _a1)
 	return _c
 }
 
-func (_c *mockFeedbagService_UpdateItem_Call) RunAndReturn(run func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x13_0x09_FeedbagUpdateItem) (wire.SNACMessage, error)) *mockFeedbagService_UpdateItem_Call {
+func (_c *mockFeedbagService_UpsertItem_Call) RunAndReturn(run func(context.Context, *state.Session, wire.SNACFrame, []wire.FeedbagItem) (wire.SNACMessage, error)) *mockFeedbagService_UpsertItem_Call {
 	_c.Call.Return(run)
 	return _c
 }

+ 5 - 1
server/oscar/handler/routes.go

@@ -10,13 +10,14 @@ import (
 // specific handler responsible for a distinct aspect of the OSCAR service,
 // such as managing buddy lists, chat sessions, and user alerts.
 type Handlers struct {
+	AlertHandler
+	BARTHandler
 	BuddyHandler
 	ChatHandler
 	ChatNavHandler
 	FeedbagHandler
 	ICBMHandler
 	LocateHandler
-	AlertHandler
 	OServiceBOSHandler
 	OServiceChatHandler
 }
@@ -29,6 +30,9 @@ func NewBOSRouter(h Handlers) oscar.Router {
 	router.Register(wire.Alert, wire.AlertNotifyCapabilities, h.AlertHandler.NotifyCapabilities)
 	router.Register(wire.Alert, wire.AlertNotifyDisplayCapabilities, h.AlertHandler.NotifyDisplayCapabilities)
 
+	router.Register(wire.BART, wire.BARTUploadQuery, h.BARTHandler.UploadQuery)
+	router.Register(wire.BART, wire.BARTDownloadQuery, h.BARTHandler.DownloadQuery)
+
 	router.Register(wire.Buddy, wire.BuddyRightsQuery, h.BuddyHandler.RightsQuery)
 
 	router.Register(wire.ChatNav, wire.ChatNavCreateRoom, h.ChatNavHandler.CreateRoom)

+ 23 - 13
state/session.go

@@ -5,13 +5,8 @@ import (
 	"time"
 
 	"github.com/mk6i/retro-aim-server/wire"
-
-	"github.com/google/uuid"
 )
 
-// capChat is a UID that indicates a client supports the chat capability
-var capChat, _ = uuid.MustParse("748F2420-6287-11D1-8222-444553540000").MarshalBinary()
-
 // SessSendStatus is the result of sending a message to a user.
 type SessSendStatus int
 
@@ -42,6 +37,7 @@ type Session struct {
 	signonTime     time.Time
 	stopCh         chan struct{}
 	warning        uint16
+	caps           [][16]byte
 }
 
 // NewSession returns a new instance of Session. By default, the user may have
@@ -52,6 +48,7 @@ func NewSession() *Session {
 		nowFn:      time.Now,
 		stopCh:     make(chan struct{}),
 		signonTime: time.Now(),
+		caps:       make([][16]byte, 0),
 	}
 }
 
@@ -171,9 +168,9 @@ func (s *Session) TLVUserInfo() wire.TLVUserInfo {
 }
 
 func (s *Session) userInfo() wire.TLVList {
-	// sign-in timestamp
 	tlvs := wire.TLVList{}
 
+	// sign-in timestamp
 	tlvs.Append(wire.NewTLV(wire.OServiceUserInfoSignonTOD, uint32(s.signonTime.Unix())))
 
 	// away message status
@@ -187,7 +184,7 @@ func (s *Session) userInfo() wire.TLVList {
 	if s.invisible {
 		tlvs.Append(wire.NewTLV(wire.OServiceUserInfoStatus, wire.OServiceUserFlagInvisible))
 	} else {
-		tlvs.Append(wire.NewTLV(wire.OServiceUserInfoStatus, uint16(0x0000)))
+		tlvs.Append(wire.NewTLV(wire.OServiceUserInfoStatus, uint16(0)))
 	}
 
 	// idle status
@@ -197,16 +194,29 @@ func (s *Session) userInfo() wire.TLVList {
 		tlvs.Append(wire.NewTLV(wire.OServiceUserInfoIdleTime, uint16(0)))
 	}
 
-	// capabilities
-	var caps []byte
-	// chat capability
-	caps = append(caps, capChat...)
-	tlvs.Append(wire.NewTLV(wire.OServiceUserInfoOscarCaps, caps))
+	// capabilities (buddy icon, chat, etc...)
+	if len(s.caps) > 0 {
+		tlvs.Append(wire.NewTLV(wire.OServiceUserInfoOscarCaps, s.caps))
+	}
 
 	return tlvs
 }
 
-// Warning returns the user's warning level.
+// SetCaps sets capability UUIDs that represent the features the client
+// supports. If set, capability metadata appears in the user info TLV list.
+func (s *Session) SetCaps(caps [][16]byte) {
+	s.mutex.Lock()
+	defer s.mutex.Unlock()
+	s.caps = caps
+}
+
+// Caps retrieves user capabilities.
+func (s *Session) Caps() [][16]byte {
+	s.mutex.RLock()
+	defer s.mutex.RUnlock()
+	return s.caps
+}
+
 func (s *Session) Warning() uint16 {
 	s.mutex.RLock()
 	defer s.mutex.RUnlock()

+ 78 - 26
state/session_test.go

@@ -81,11 +81,10 @@ func TestSession_TLVUserInfo(t *testing.T) {
 				WarningLevel: 10,
 				TLVBlock: wire.TLVBlock{
 					TLVList: wire.TLVList{
-						wire.NewTLV(0x03, uint32(1)),
-						wire.NewTLV(0x01, uint16(0x0010)),
-						wire.NewTLV(0x06, uint16(0x0000)),
-						wire.NewTLV(0x04, uint16(0)),
-						wire.NewTLV(0x0D, capChat),
+						wire.NewTLV(wire.OServiceUserInfoSignonTOD, uint32(1)),
+						wire.NewTLV(wire.OServiceUserInfoUserFlags, uint16(0x0010)),
+						wire.NewTLV(wire.OServiceUserInfoStatus, uint16(0x0000)),
+						wire.NewTLV(wire.OServiceUserInfoIdleTime, uint16(0)),
 					},
 				},
 			},
@@ -95,17 +94,16 @@ func TestSession_TLVUserInfo(t *testing.T) {
 			givenSessionFn: func() *Session {
 				s := NewSession()
 				s.SetSignonTime(time.Unix(1, 0))
-				s.SetAwayMessage("here's my away essage")
+				s.SetAwayMessage("here's my away message")
 				return s
 			},
 			want: wire.TLVUserInfo{
 				TLVBlock: wire.TLVBlock{
 					TLVList: wire.TLVList{
-						wire.NewTLV(0x03, uint32(1)),
-						wire.NewTLV(0x01, uint16(0x30)),
-						wire.NewTLV(0x06, uint16(0x0000)),
-						wire.NewTLV(0x04, uint16(0)),
-						wire.NewTLV(0x0D, capChat),
+						wire.NewTLV(wire.OServiceUserInfoSignonTOD, uint32(1)),
+						wire.NewTLV(wire.OServiceUserInfoUserFlags, uint16(0x30)),
+						wire.NewTLV(wire.OServiceUserInfoStatus, uint16(0x0000)),
+						wire.NewTLV(wire.OServiceUserInfoIdleTime, uint16(0)),
 					},
 				},
 			},
@@ -121,11 +119,10 @@ func TestSession_TLVUserInfo(t *testing.T) {
 			want: wire.TLVUserInfo{
 				TLVBlock: wire.TLVBlock{
 					TLVList: wire.TLVList{
-						wire.NewTLV(0x03, uint32(1)),
-						wire.NewTLV(0x01, uint16(0x0010)),
-						wire.NewTLV(0x06, uint16(0x0100)),
-						wire.NewTLV(0x04, uint16(0)),
-						wire.NewTLV(0x0D, capChat),
+						wire.NewTLV(wire.OServiceUserInfoSignonTOD, uint32(1)),
+						wire.NewTLV(wire.OServiceUserInfoUserFlags, uint16(0x0010)),
+						wire.NewTLV(wire.OServiceUserInfoStatus, uint16(0x0100)),
+						wire.NewTLV(wire.OServiceUserInfoIdleTime, uint16(0)),
 					},
 				},
 			},
@@ -145,11 +142,10 @@ func TestSession_TLVUserInfo(t *testing.T) {
 			want: wire.TLVUserInfo{
 				TLVBlock: wire.TLVBlock{
 					TLVList: wire.TLVList{
-						wire.NewTLV(0x03, uint32(1)),
-						wire.NewTLV(0x01, uint16(0x0010)),
-						wire.NewTLV(0x06, uint16(0x0000)),
-						wire.NewTLV(0x04, uint16(1001)),
-						wire.NewTLV(0x0D, capChat),
+						wire.NewTLV(wire.OServiceUserInfoSignonTOD, uint32(1)),
+						wire.NewTLV(wire.OServiceUserInfoUserFlags, uint16(0x0010)),
+						wire.NewTLV(wire.OServiceUserInfoStatus, uint16(0x0000)),
+						wire.NewTLV(wire.OServiceUserInfoIdleTime, uint16(1001)),
 					},
 				},
 			},
@@ -166,11 +162,67 @@ func TestSession_TLVUserInfo(t *testing.T) {
 			want: wire.TLVUserInfo{
 				TLVBlock: wire.TLVBlock{
 					TLVList: wire.TLVList{
-						wire.NewTLV(0x03, uint32(1)),
-						wire.NewTLV(0x01, uint16(0x0010)),
-						wire.NewTLV(0x06, uint16(0x0000)),
-						wire.NewTLV(0x04, uint16(0)),
-						wire.NewTLV(0x0D, capChat),
+						wire.NewTLV(wire.OServiceUserInfoSignonTOD, uint32(1)),
+						wire.NewTLV(wire.OServiceUserInfoUserFlags, uint16(0x0010)),
+						wire.NewTLV(wire.OServiceUserInfoStatus, uint16(0x0000)),
+						wire.NewTLV(wire.OServiceUserInfoIdleTime, uint16(0)),
+					},
+				},
+			},
+		},
+		{
+			name: "user has capabilities",
+			givenSessionFn: func() *Session {
+				s := NewSession()
+				s.SetSignonTime(time.Unix(1, 0))
+				s.SetCaps([][16]byte{
+					{
+						// chat: "748F2420-6287-11D1-8222-444553540000"
+						0x74, 0x8f, 0x24, 0x20, 0x62, 0x87, 0x11, 0xd1,
+						0x82, 0x22, 0x44, 0x45, 0x53, 0x54, 0x00, 0x00,
+					},
+					{
+						// chat2: "748F2420-6287-11D1-8222-444553540000"
+						0x75, 0x8f, 0x24, 0x20, 0x62, 0x87, 0x11, 0xd1,
+						0x82, 0x22, 0x44, 0x45, 0x53, 0x54, 0x00, 0x01,
+					},
+				})
+				return s
+			},
+			want: wire.TLVUserInfo{
+				TLVBlock: wire.TLVBlock{
+					TLVList: wire.TLVList{
+						wire.NewTLV(wire.OServiceUserInfoSignonTOD, uint32(1)),
+						wire.NewTLV(wire.OServiceUserInfoUserFlags, uint16(0x0010)),
+						wire.NewTLV(wire.OServiceUserInfoStatus, uint16(0x0000)),
+						wire.NewTLV(wire.OServiceUserInfoIdleTime, uint16(0)),
+						wire.NewTLV(wire.OServiceUserInfoOscarCaps, []byte{
+							// chat: "748F2420-6287-11D1-8222-444553540000"
+							0x74, 0x8f, 0x24, 0x20, 0x62, 0x87, 0x11, 0xd1,
+							0x82, 0x22, 0x44, 0x45, 0x53, 0x54, 0x00, 0x00,
+							// chat: "748F2420-6287-11D1-8222-444553540000"
+							0x75, 0x8f, 0x24, 0x20, 0x62, 0x87, 0x11, 0xd1,
+							0x82, 0x22, 0x44, 0x45, 0x53, 0x54, 0x00, 0x01,
+						}),
+					},
+				},
+			},
+		},
+		{
+			name: "user has buddy icon",
+			givenSessionFn: func() *Session {
+				s := NewSession()
+				s.SetSignonTime(time.Unix(1, 0))
+				return s
+			},
+			want: wire.TLVUserInfo{
+				WarningLevel: 0,
+				TLVBlock: wire.TLVBlock{
+					TLVList: wire.TLVList{
+						wire.NewTLV(wire.OServiceUserInfoSignonTOD, uint32(1)),
+						wire.NewTLV(wire.OServiceUserInfoUserFlags, uint16(0x0010)),
+						wire.NewTLV(wire.OServiceUserInfoStatus, uint16(0x0000)),
+						wire.NewTLV(wire.OServiceUserInfoIdleTime, uint16(0)),
 					},
 				},
 			},

+ 29 - 0
state/user_store.go

@@ -37,6 +37,11 @@ var userStoreDDL = `
 		screenName VARCHAR(16) PRIMARY KEY,
 		body  TEXT
 	);
+	CREATE TABLE IF NOT EXISTS bartItem
+	(
+		hash CHAR(16) PRIMARY KEY,
+		body BLOB    
+	);
 `
 
 // BlockedState represents the blocked status between two users
@@ -384,6 +389,30 @@ func (f SQLiteUserStore) SetProfile(screenName string, body string) error {
 	return err
 }
 
+func (f SQLiteUserStore) BARTUpsert(itemHash []byte, body []byte) error {
+	q := `
+		INSERT INTO bartItem (hash, body)
+		VALUES (?, ?)
+		ON CONFLICT DO NOTHING
+	`
+	_, err := f.db.Exec(q, itemHash, body)
+	return err
+}
+
+func (f SQLiteUserStore) BARTRetrieve(hash []byte) ([]byte, error) {
+	q := `
+		SELECT body
+		FROM bartItem
+		WHERE hash = ?
+	`
+	var body []byte
+	err := f.db.QueryRow(q, hash).Scan(&body)
+	if errors.Is(err, sql.ErrNoRows) {
+		err = nil
+	}
+	return body, nil
+}
+
 // NewStubUser creates a new user with canned credentials. The default password
 // is "welcome1". This is typically used for development purposes.
 func NewStubUser(screenName string) (User, error) {

+ 23 - 0
state/user_store_test.go

@@ -488,3 +488,26 @@ func TestSQLiteUserStore_AdjacentUsers(t *testing.T) {
 
 	assert.Equal(t, want, have)
 }
+
+func TestSQLiteUserStore_BARTUpsertAndRetrieve(t *testing.T) {
+	defer func() {
+		assert.NoError(t, os.Remove(testFile))
+	}()
+
+	feedbagStore, err := NewSQLiteUserStore(testFile)
+	assert.NoError(t, err)
+
+	hash := []byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}
+	item := []byte{'a', 'b', 'c', 'd'}
+
+	b, err := feedbagStore.BARTRetrieve(hash)
+	assert.NoError(t, err)
+	assert.Empty(t, b)
+
+	err = feedbagStore.BARTUpsert(hash, item)
+	assert.NoError(t, err)
+
+	b, err = feedbagStore.BARTRetrieve(hash)
+	assert.NoError(t, err)
+	assert.Equal(t, item, b)
+}

+ 127 - 7
wire/snacs.go

@@ -1,6 +1,9 @@
 package wire
 
-import "errors"
+import (
+	"bytes"
+	"errors"
+)
 
 // ErrUnsupportedFoodGroup indicates that a foodgroup value is either invalid
 // or unsupported by a method.
@@ -117,13 +120,13 @@ const (
 	OServiceBartQuery2        uint16 = 0x0022
 	OServiceBartReply2        uint16 = 0x0023
 
-	OServiceUserInfoUserFlags uint16 = 0x01
-	OServiceUserInfoSignonTOD uint16 = 0x03
-	OServiceUserInfoIdleTime  uint16 = 0x04
-	OServiceUserInfoStatus    uint16 = 0x06
-	OServiceUserInfoOscarCaps uint16 = 0x0D
-
+	OServiceUserInfoUserFlags   uint16 = 0x01
+	OServiceUserInfoSignonTOD   uint16 = 0x03
+	OServiceUserInfoIdleTime    uint16 = 0x04
+	OServiceUserInfoStatus      uint16 = 0x06
+	OServiceUserInfoOscarCaps   uint16 = 0x0D
 	OServiceUserFlagOSCARFree   uint16 = 0x0010 // AIM (not AOL) account
+	OServiceUserInfoBARTInfo    uint16 = 0x1D
 	OServiceUserFlagUnavailable uint16 = 0x0020 // user is away
 	OServiceUserFlagInvisible   uint16 = 0x0100 // user is invisible
 
@@ -240,6 +243,10 @@ type SNAC_0x01_0x1E_OServiceSetUserInfoFields struct {
 	TLVRestBlock
 }
 
+type SNAC_0x01_0x21_OServiceBARTReply struct {
+	BARTID
+}
+
 //
 // 0x02: Locate
 //
@@ -620,6 +627,119 @@ type SNAC_0x0E_0x06_ChatChannelMsgToClient struct {
 }
 
 //
+// 0x10: BART
+//
+//
+
+const (
+	BARTTypesBuddyIconSmall      uint16 = 0x00
+	BARTTypesBuddyIcon           uint16 = 0x01
+	BARTTypesStatusStr           uint16 = 0x02
+	BARTTypesArriveSound         uint16 = 0x03
+	BARTTypesRichName            uint16 = 0x04
+	BARTTypesSuperIcon           uint16 = 0x05
+	BARTTypesRadioStation        uint16 = 0x06
+	BARTTypesSuperIconTrigger    uint16 = 0x07
+	BARTTypesStatusTextLink      uint16 = 0x09
+	BARTTypesLocation            uint16 = 0x0B
+	BARTTypesBuddyIconBig        uint16 = 0x0C
+	BARTTypesStatusTextTimestamp uint16 = 0x0D
+	BARTTypesCurrentAvtrack      uint16 = 0x0F
+	BARTTypesDepartSound         uint16 = 0x60
+	BARTTypesImBackground        uint16 = 0x80
+	BARTTypesImChrome            uint16 = 0x81
+	BARTTypesImSkin              uint16 = 0x82
+	BARTTypesImSound             uint16 = 0x83
+	BARTTypesBadge               uint16 = 0x84
+	BARTTypesBadgeUrl            uint16 = 0x85
+	BARTTypesImInitialSound      uint16 = 0x86
+	BARTTypesFlashWallpaper      uint16 = 0x88
+	BARTTypesImmersiveWallpaper  uint16 = 0x89
+	BARTTypesBuddylistBackground uint16 = 0x100
+	BARTTypesBuddylistImage      uint16 = 0x101
+	BARTTypesBuddylistSkin       uint16 = 0x102
+	BARTTypesSmileySet           uint16 = 0x400
+	BARTTypesEncrCertChain       uint16 = 0x402
+	BARTTypesSignCertChain       uint16 = 0x403
+	BARTTypesGatewayCert         uint16 = 0x404
+)
+
+const (
+	BARTErr            uint16 = 0x0001
+	BARTUploadQuery    uint16 = 0x0002
+	BARTUploadReply    uint16 = 0x0003
+	BARTDownloadQuery  uint16 = 0x0004
+	BARTDownloadReply  uint16 = 0x0005
+	BARTDownload2Query uint16 = 0x0006
+	BARTDownload2Reply uint16 = 0x0007
+)
+
+const (
+	BARTFlagsKnown    uint8 = 0x00
+	BARTFlagsCustom   uint8 = 0x01
+	BARTFlagsUrl      uint8 = 0x02
+	BARTFlagsData     uint8 = 0x04
+	BARTFlagsUnknown  uint8 = 0x40
+	BARTFlagsRedirect uint8 = 0x80
+	BARTFlagsBanned   uint8 = 0xC0
+)
+
+const (
+	BARTReplyCodesSuccess     uint8 = 0x00
+	BARTReplyCodesInvalid     uint8 = 0x01
+	BARTReplyCodesNoCustom    uint8 = 0x02
+	BARTReplyCodesTooSmall    uint8 = 0x03
+	BARTReplyCodesTooBig      uint8 = 0x04
+	BARTReplyCodesInvalidType uint8 = 0x05
+	BARTReplyCodesBanned      uint8 = 0x06
+	BARTReplyCodesNotfound    uint8 = 0x07
+)
+
+// GetClearIconHash returns an opaque value set in BARTID hash that indicates
+// the user wants to clear their buddy icon.
+func GetClearIconHash() []byte {
+	return []byte{0x02, 0x01, 0xd2, 0x04, 0x72}
+}
+
+// BARTInfo represents a BART feedbag item
+type BARTInfo struct {
+	Flags uint8
+	Hash  []byte `len_prefix:"uint8"`
+}
+
+// HasClearIconHash reports whether the BART ID hash contains the
+// ClearIconHash sentinel value.
+func (h BARTInfo) HasClearIconHash() bool {
+	return bytes.Equal(h.Hash, GetClearIconHash())
+}
+
+type BARTID struct {
+	Type uint16
+	BARTInfo
+}
+
+type SNAC_0x10_0x02_BARTUploadQuery struct {
+	Type uint16
+	Data []byte `len_prefix:"uint16"`
+}
+
+type SNAC_0x10_0x03_BARTUploadReply struct {
+	Code uint8
+	ID   BARTID
+}
+
+type SNAC_0x10_0x04_BARTDownloadQuery struct {
+	ScreenName string `len_prefix:"uint8"`
+	Command    uint8
+	BARTID
+}
+
+type SNAC_0x10_0x05_BARTDownloadReply struct {
+	ScreenName string `len_prefix:"uint8"`
+	BARTID     BARTID
+	Data       []byte `len_prefix:"uint16"`
+}
+
 // 0x13: Feedbag
 //
 

+ 9 - 0
wire/snacs_string.go

@@ -254,6 +254,15 @@ var subGroupName = map[uint16]map[uint16]string{
 		AlertNotifyDisplayCapabilities: "AlertNotifyDisplayCapabilities",
 		AlertUserOnline:                "AlertUserOnline",
 	},
+	BART: {
+		BARTErr:            "BARTErr",
+		BARTUploadQuery:    "BARTUploadQuery",
+		BARTUploadReply:    "BARTUploadReply",
+		BARTDownloadQuery:  "BARTDownloadQuery",
+		BARTDownloadReply:  "BARTDownloadReply",
+		BARTDownload2Query: "BARTDownload2Query",
+		BARTDownload2Reply: "BARTDownload2Reply",
+	},
 }
 
 // SubGroupName gets the string name of a subgroup within a food group. It

+ 33 - 0
wire/snacs_test.go

@@ -0,0 +1,33 @@
+package wire
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestBARTInfo_HasClearIconHash(t *testing.T) {
+	tests := []struct {
+		name     string
+		bartInfo BARTInfo
+		want     bool
+	}{
+		{
+			bartInfo: BARTInfo{
+				Hash: GetClearIconHash(),
+			},
+			want: true,
+		},
+		{
+			bartInfo: BARTInfo{
+				Hash: []byte{'s', 'o', 'm', 'e', 'd', 'a', 't', 'a'},
+			},
+			want: false,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			assert.Equal(t, tt.want, tt.bartInfo.HasClearIconHash())
+		})
+	}
+}