瀏覽代碼

webapi: make buddy list ordering deterministic

also fix missing "friendly" alias on presence updates
Mike 6 天之前
父節點
當前提交
74cfc9d4b8

+ 9 - 81
server/webapi/buddy_list_manager.go

@@ -6,7 +6,6 @@ import (
 	"fmt"
 	"log/slog"
 	"math/rand"
-	"slices"
 	"strings"
 
 	"github.com/mk6i/open-oscar-server/state"
@@ -73,95 +72,24 @@ func (m *BuddyListManager) GetBuddyListForUser(ctx context.Context, sess *Sessio
 		return nil, fmt.Errorf("failed to retrieve feedbag: %w", err)
 	}
 
-	type buddy struct {
-		name  string
-		alias string
-	}
-	type group struct {
-		name    string
-		buddies map[uint16]buddy
-		order   []uint16
-	}
-	type feedbagBL struct {
-		order  []uint16
-		groups map[uint16]group
-	}
-	bl := feedbagBL{groups: make(map[uint16]group)}
-
-	for _, item := range items {
-		if item.ClassID != wire.FeedbagClassIdGroup {
-			continue
-		}
-		if item.GroupID == 0 {
-			val, hasVal := item.Uint16SliceBE(wire.FeedbagAttributesOrder)
-			if hasVal {
-				bl.order = val
-			}
-			continue
-		}
-		name := item.Name
-		if name == "" {
-			name = "Buddies"
-		}
-		g := group{
-			name:    name,
-			buddies: make(map[uint16]buddy),
-		}
-		val, _ := item.Uint16SliceBE(wire.FeedbagAttributesOrder)
-		g.order = val
-		bl.groups[item.GroupID] = g
-	}
-
-	for _, item := range items {
-		if item.ClassID != wire.FeedbagClassIdBuddy || item.Name == "" {
-			continue
-		}
-		if _, exists := bl.groups[item.GroupID]; !exists {
-			bl.groups[item.GroupID] = group{
-				name:    "Buddies",
-				buddies: make(map[uint16]buddy),
-				order:   nil,
-			}
-		}
-		b := buddy{name: item.Name}
-		if val, hasVal := item.String(wire.FeedbagAttributesAlias); hasVal {
-			b.alias = val
-		}
-		g := bl.groups[item.GroupID]
-		g.buddies[item.ItemID] = b
-		bl.groups[item.GroupID] = g
-	}
+	fl := state.NewFeedbagList(items, nil)
 
-	groupOrder := bl.order
-	if len(groupOrder) == 0 && len(bl.groups) > 0 {
-		groupOrder = make([]uint16, 0, len(bl.groups))
-		for gid := range bl.groups {
-			groupOrder = append(groupOrder, gid)
-		}
-		slices.Sort(groupOrder)
-	}
-
-	var out []BuddyGroup
-	for _, gid := range groupOrder {
-		g, ok := bl.groups[gid]
-		if !ok {
-			continue
-		}
-		groupName := g.name
+	out := []BuddyGroup{}
+	for _, group := range fl.Groups() {
+		groupName := group.Name
 		if groupName == "" {
 			groupName = "Buddies"
 		}
-		wg := BuddyGroup{ID: int(gid), Name: groupName, Buddies: []BuddyInfo{}}
-		for _, bid := range g.order {
-			b, ok := g.buddies[bid]
-			if !ok {
+		wg := BuddyGroup{ID: int(group.GroupID), Name: groupName, Buddies: []BuddyInfo{}}
+		for _, buddy := range fl.Buddies(group) {
+			if buddy.Name == "" {
 				continue
 			}
-			info := m.getBuddyInfo(ctx, sess, b.name)
+			info := m.getBuddyInfo(ctx, sess, buddy.Name)
 			// The alias belongs in friendly, not displayId: the client renders
 			// friendly in preference to displayId but still shows displayId as
 			// the buddy's actual screen name.
-			info.Friendly = b.alias
+			info.Friendly, _ = buddy.String(wire.FeedbagAttributesAlias)
 			wg.Buddies = append(wg.Buddies, info)
 		}
 		out = append(out, wg)

+ 103 - 4
server/webapi/buddy_list_manager_test.go

@@ -48,11 +48,11 @@ func TestBuddyListManager_GetBuddyListForUser(t *testing.T) {
 			wantErr: "failed to retrieve feedbag",
 		},
 		{
-			name: "root group missing order attribute yields no groups",
+			name: "root group only yields no groups",
 			fb: []wire.FeedbagItem{
 				{Name: "", GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup, TLVLBlock: wire.TLVLBlock{}},
 			},
-			want: nil,
+			want: []BuddyGroup{},
 		},
 		{
 			name: "empty buddylist yields no groups",
@@ -62,7 +62,7 @@ func TestBuddyListManager_GetBuddyListForUser(t *testing.T) {
 					TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{})}},
 				},
 			},
-			want: nil,
+			want: []BuddyGroup{},
 		},
 		{
 			name: "single group with buddies",
@@ -101,7 +101,7 @@ func TestBuddyListManager_GetBuddyListForUser(t *testing.T) {
 					TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesPdMode, uint8(3))}},
 				},
 			},
-			want: nil,
+			want: []BuddyGroup{},
 		},
 		{
 			name: "buddy with alias",
@@ -289,6 +289,105 @@ func TestBuddyListManager_GetBuddyListForUser(t *testing.T) {
 				},
 			},
 		},
+		{
+			name: "group without order attribute lists buddies in feedbag order",
+			fb: []wire.FeedbagItem{
+				{
+					Name: "", GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
+					TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{100})}},
+				},
+				{Name: "Buddies", GroupID: 100, ItemID: 0, ClassID: wire.FeedbagClassIdGroup},
+				{ItemID: 9, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "user9"},
+				{ItemID: 3, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "user3"},
+			},
+			want: []BuddyGroup{
+				{
+					Name: "Buddies",
+					ID:   100,
+					Buddies: []BuddyInfo{
+						offlineWebAPIBuddy("user9", "user9"),
+						offlineWebAPIBuddy("user3", "user3"),
+					},
+				},
+			},
+		},
+		{
+			name: "buddy missing from group order follows listed buddies",
+			fb: []wire.FeedbagItem{
+				{
+					Name: "", GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
+					TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{100})}},
+				},
+				{Name: "Buddies", GroupID: 100, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
+					TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{2})}}},
+				{ItemID: 1, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "unlisted"},
+				{ItemID: 2, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "listed"},
+			},
+			want: []BuddyGroup{
+				{
+					Name: "Buddies",
+					ID:   100,
+					Buddies: []BuddyInfo{
+						offlineWebAPIBuddy("listed", "listed"),
+						offlineWebAPIBuddy("unlisted", "unlisted"),
+					},
+				},
+			},
+		},
+		{
+			name: "group missing from root order follows listed groups",
+			fb: []wire.FeedbagItem{
+				{
+					Name: "", GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
+					TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{200})}},
+				},
+				{Name: "Unlisted", GroupID: 100, ItemID: 0, ClassID: wire.FeedbagClassIdGroup},
+				{Name: "Listed", GroupID: 200, ItemID: 0, ClassID: wire.FeedbagClassIdGroup},
+			},
+			want: []BuddyGroup{
+				{Name: "Listed", ID: 200, Buddies: []BuddyInfo{}},
+				{Name: "Unlisted", ID: 100, Buddies: []BuddyInfo{}},
+			},
+		},
+		{
+			name: "groups follow feedbag position when root group has no order attribute",
+			fb: []wire.FeedbagItem{
+				{Name: "", GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup},
+				{Name: "Family", GroupID: 200, ItemID: 0, ClassID: wire.FeedbagClassIdGroup},
+				{Name: "Friends", GroupID: 100, ItemID: 0, ClassID: wire.FeedbagClassIdGroup},
+			},
+			want: []BuddyGroup{
+				{Name: "Family", ID: 200, Buddies: []BuddyInfo{}},
+				{Name: "Friends", ID: 100, Buddies: []BuddyInfo{}},
+			},
+		},
+		{
+			name: "orphan buddy with root order is left out",
+			fb: []wire.FeedbagItem{
+				{
+					Name: "", GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
+					TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{100})}},
+				},
+				{Name: "Buddies", GroupID: 100, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
+					TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{1})}}},
+				{ItemID: 1, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "user1"},
+				{ItemID: 2, ClassID: wire.FeedbagClassIdBuddy, GroupID: 300, Name: "orphan"},
+			},
+			want: []BuddyGroup{
+				{
+					Name:    "Buddies",
+					ID:      100,
+					Buddies: []BuddyInfo{offlineWebAPIBuddy("user1", "user1")},
+				},
+			},
+		},
+		{
+			name: "orphan buddy without root order is left out",
+			fb: []wire.FeedbagItem{
+				{ItemID: 2, ClassID: wire.FeedbagClassIdBuddy, GroupID: 300, Name: "orphan"},
+			},
+			want: []BuddyGroup{},
+		},
 	}
 
 	for _, tt := range tests {

+ 26 - 52
server/webapi/presence_handler.go

@@ -202,65 +202,39 @@ func (h *PresenceHandler) getBuddyListGroups(ctx context.Context, session *Sessi
 		return nil, err
 	}
 
-	// Organize items into groups, keyed by GroupID. Group rows store their
-	// identity in GroupID (ItemID is 0 for every group), so a GroupID-keyed map
-	// is the only way to associate buddies — which reference their group via
-	// GroupID — with the right group.
-	groupMap := make(map[uint16]*BuddyGroupInfo)
-
-	// First pass: identify groups. Skip the root group (GroupID 0), which holds
-	// the master group order rather than buddies.
-	for _, item := range items {
-		if item.ClassID != wire.FeedbagClassIdGroup || item.GroupID == 0 {
-			continue
-		}
-		name := item.Name
+	fl := state.NewFeedbagList(items, nil)
+
+	var groups []BuddyGroupInfo
+	for _, group := range fl.Groups() {
+		name := group.Name
 		if name == "" {
 			name = "Buddies" // Default group name
 		}
-		groupMap[item.GroupID] = &BuddyGroupInfo{
-			Name:    name,
-			Buddies: []BuddyPresenceInfo{},
-		}
-	}
-
-	// Second pass: add buddies to their group with presence info.
-	for _, item := range items {
-		if item.ClassID != wire.FeedbagClassIdBuddy || item.Name == "" {
-			continue
-		}
-		group, exists := groupMap[item.GroupID]
-		if !exists {
-			// Orphan buddy whose group row is missing: synthesize a default
-			// group for its GroupID so the buddy is not dropped.
-			group = &BuddyGroupInfo{Name: "Buddies", Buddies: []BuddyPresenceInfo{}}
-			groupMap[item.GroupID] = group
-		}
-
-		// Served from the presence view, where blocked and offline buddies alike
-		// come back as "offline". profileMsg is the exception: profile text is only
-		// reachable through a locate reply.
-		var presence BuddyPresenceInfo
-		if wantProfileMsg {
-			presence = h.getUserPresence(ctx, session.OSCARSession, session.BaseURL, state.DisplayScreenName(item.Name), true)
-		} else {
-			presence = h.cachedPresence(ctx, session, item.Name)
+		info := BuddyGroupInfo{Name: name, Buddies: []BuddyPresenceInfo{}}
+		for _, buddy := range fl.Buddies(group) {
+			if buddy.Name == "" {
+				continue
+			}
+			// Served from the presence view, where blocked and offline buddies
+			// alike come back as "offline". profileMsg is the exception: profile
+			// text is only reachable through a locate reply.
+			var presence BuddyPresenceInfo
+			if wantProfileMsg {
+				presence = h.getUserPresence(ctx, session.OSCARSession, session.BaseURL, state.DisplayScreenName(buddy.Name), true)
+			} else {
+				presence = h.cachedPresence(ctx, session, buddy.Name)
+			}
+			// The client's user-object merge deletes any alias it holds, so the
+			// buddy list has to carry friendly for aliased buddies.
+			presence.Friendly, _ = buddy.String(wire.FeedbagAttributesAlias)
+			info.Buddies = append(info.Buddies, presence)
 		}
-		group.Buddies = append(group.Buddies, presence)
+		groups = append(groups, info)
 	}
 
 	// If no groups exist at all, return a single default group.
-	if len(groupMap) == 0 {
-		groupMap[0] = &BuddyGroupInfo{
-			Name:    "Buddies",
-			Buddies: []BuddyPresenceInfo{},
-		}
-	}
-
-	// Convert map to slice
-	groups := make([]BuddyGroupInfo, 0, len(groupMap))
-	for _, group := range groupMap {
-		groups = append(groups, *group)
+	if len(groups) == 0 {
+		groups = []BuddyGroupInfo{{Name: "Buddies", Buddies: []BuddyPresenceInfo{}}}
 	}
 
 	return groups, nil

+ 156 - 8
server/webapi/presence_handler_test.go

@@ -52,13 +52,52 @@ func TestPresenceHandler_GetPresence(t *testing.T) {
 			name:        "Success_BuddyList",
 			queryParams: "bl=1",
 			setupMocks: func(fr *mockFeedbagService, ls *mockLocateService) {
-				// Return feedbag with a group and buddy
+				// The root group orders Friends before Coworkers; Family and Enemies
+				// are missing from it. Friends orders buddy2 before buddy1; buddy3
+				// and buddy4 are missing from it.
 				fr.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
 					Return(wire.SNACMessage{
 						Body: wire.SNAC_0x13_0x06_FeedbagReply{
 							Items: []wire.FeedbagItem{
-								{ItemID: 0, ClassID: wire.FeedbagClassIdGroup, Name: "Friends", GroupID: 1},
-								{ItemID: 2, ClassID: wire.FeedbagClassIdBuddy, Name: "buddy1", GroupID: 1},
+								{
+									ClassID: wire.FeedbagClassIdGroup,
+									GroupID: 0,
+									TLVLBlock: wire.TLVLBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{2, 1}),
+										},
+									},
+								},
+								{ClassID: wire.FeedbagClassIdGroup, Name: "Coworkers", GroupID: 1},
+								{ClassID: wire.FeedbagClassIdBuddy, Name: "coworker1", GroupID: 1, ItemID: 1},
+								{
+									ClassID: wire.FeedbagClassIdGroup,
+									Name:    "Friends",
+									GroupID: 2,
+									TLVLBlock: wire.TLVLBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{5, 2}),
+										},
+									},
+								},
+								{ClassID: wire.FeedbagClassIdBuddy, Name: "buddy3", GroupID: 2, ItemID: 6},
+								{
+									ClassID: wire.FeedbagClassIdBuddy,
+									Name:    "buddy1",
+									GroupID: 2,
+									ItemID:  2,
+									TLVLBlock: wire.TLVLBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLVBE(wire.FeedbagAttributesAlias, "Buddy One"),
+										},
+									},
+								},
+								{ClassID: wire.FeedbagClassIdBuddy, Name: "buddy4", GroupID: 2, ItemID: 7},
+								{ClassID: wire.FeedbagClassIdBuddy, Name: "buddy2", GroupID: 2, ItemID: 5},
+								{ClassID: wire.FeedbagClassIdGroup, Name: "Family", GroupID: 3},
+								{ClassID: wire.FeedbagClassIdBuddy, Name: "family1", GroupID: 3, ItemID: 3},
+								{ClassID: wire.FeedbagClassIdGroup, Name: "Enemies", GroupID: 4},
+								{ClassID: wire.FeedbagClassIdBuddy, Name: "enemy1", GroupID: 4, ItemID: 4},
 							},
 						},
 					}, nil)
@@ -67,14 +106,123 @@ func TestPresenceHandler_GetPresence(t *testing.T) {
 			// buddy's arrival.
 			seedPresence: func(sess *Session) {
 				buddyArrives(sess, onlineBuddy("buddy1"))
+				buddyArrives(sess, onlineBuddy("buddy2"))
+				buddyArrives(sess, onlineBuddy("buddy3"))
+				buddyArrives(sess, onlineBuddy("buddy4"))
+				buddyArrives(sess, onlineBuddy("coworker1"))
+				buddyArrives(sess, onlineBuddy("family1"))
+				buddyArrives(sess, onlineBuddy("enemy1"))
 			},
 			expectedStatusCode: http.StatusOK,
 			checkResponse: func(t *testing.T, body string) {
-				assert.Contains(t, body, `"statusCode":200`)
-				assert.Contains(t, body, `"groups"`)
-				assert.Contains(t, body, `"Friends"`)
-				assert.Contains(t, body, `"buddy1"`)
-				assert.Contains(t, body, `"online"`)
+				want := `{"response":{"statusCode":200,"statusText":"Ok","data":{"groups":[{"name":"Friends","buddies":[{"aimId":"buddy2","displayId":"buddy2","state":"online","userType":"aim"},{"aimId":"buddy1","displayId":"buddy1","friendly":"Buddy One","state":"online","userType":"aim"},{"aimId":"buddy3","displayId":"buddy3","state":"online","userType":"aim"},{"aimId":"buddy4","displayId":"buddy4","state":"online","userType":"aim"}]},{"name":"Coworkers","buddies":[{"aimId":"coworker1","displayId":"coworker1","state":"online","userType":"aim"}]},{"name":"Family","buddies":[{"aimId":"family1","displayId":"family1","state":"online","userType":"aim"}]},{"name":"Enemies","buddies":[{"aimId":"enemy1","displayId":"enemy1","state":"online","userType":"aim"}]}]}}}`
+				assert.Equal(t, want, body)
+			},
+		},
+		{
+			name:        "Success_BuddyList_NoGroupOrder",
+			queryParams: "bl=1",
+			setupMocks: func(fr *mockFeedbagService, ls *mockLocateService) {
+				// Neither the root group nor Family has an order attribute, so groups
+				// and buddies keep their feedbag position.
+				fr.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
+					Return(wire.SNACMessage{
+						Body: wire.SNAC_0x13_0x06_FeedbagReply{
+							Items: []wire.FeedbagItem{
+								{ClassID: wire.FeedbagClassIdGroup, GroupID: 0},
+								{ClassID: wire.FeedbagClassIdGroup, Name: "Family", GroupID: 3},
+								{ClassID: wire.FeedbagClassIdBuddy, Name: "family2", GroupID: 3, ItemID: 9},
+								{ClassID: wire.FeedbagClassIdBuddy, Name: "family1", GroupID: 3, ItemID: 3},
+								{ClassID: wire.FeedbagClassIdGroup, Name: "Coworkers", GroupID: 1},
+								{ClassID: wire.FeedbagClassIdBuddy, Name: "coworker1", GroupID: 1, ItemID: 1},
+								{ClassID: wire.FeedbagClassIdGroup, Name: "Friends", GroupID: 2},
+								{ClassID: wire.FeedbagClassIdBuddy, Name: "buddy1", GroupID: 2, ItemID: 2},
+							},
+						},
+					}, nil)
+			},
+			seedPresence: func(sess *Session) {
+				buddyArrives(sess, onlineBuddy("buddy1"))
+				buddyArrives(sess, onlineBuddy("coworker1"))
+				buddyArrives(sess, onlineBuddy("family1"))
+				buddyArrives(sess, onlineBuddy("family2"))
+			},
+			expectedStatusCode: http.StatusOK,
+			checkResponse: func(t *testing.T, body string) {
+				want := `{"response":{"statusCode":200,"statusText":"Ok","data":{"groups":[{"name":"Family","buddies":[{"aimId":"family2","displayId":"family2","state":"online","userType":"aim"},{"aimId":"family1","displayId":"family1","state":"online","userType":"aim"}]},{"name":"Coworkers","buddies":[{"aimId":"coworker1","displayId":"coworker1","state":"online","userType":"aim"}]},{"name":"Friends","buddies":[{"aimId":"buddy1","displayId":"buddy1","state":"online","userType":"aim"}]}]}}}`
+				assert.Equal(t, want, body)
+			},
+		},
+		{
+			name:        "Success_BuddyList_DropsOrphans",
+			queryParams: "bl=1",
+			setupMocks: func(fr *mockFeedbagService, ls *mockLocateService) {
+				// orphan1 references a group with no group row; orphan2 sits in the
+				// root group.
+				fr.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
+					Return(wire.SNACMessage{
+						Body: wire.SNAC_0x13_0x06_FeedbagReply{
+							Items: []wire.FeedbagItem{
+								{
+									ClassID: wire.FeedbagClassIdGroup,
+									GroupID: 0,
+									TLVLBlock: wire.TLVLBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{1}),
+										},
+									},
+								},
+								{ClassID: wire.FeedbagClassIdBuddy, Name: "orphan1", GroupID: 7, ItemID: 1},
+								{ClassID: wire.FeedbagClassIdBuddy, Name: "orphan2", GroupID: 0, ItemID: 2},
+								{ClassID: wire.FeedbagClassIdGroup, Name: "Friends", GroupID: 1},
+								{ClassID: wire.FeedbagClassIdBuddy, Name: "buddy1", GroupID: 1, ItemID: 3},
+							},
+						},
+					}, nil)
+			},
+			seedPresence: func(sess *Session) {
+				buddyArrives(sess, onlineBuddy("buddy1"))
+				buddyArrives(sess, onlineBuddy("orphan1"))
+				buddyArrives(sess, onlineBuddy("orphan2"))
+			},
+			expectedStatusCode: http.StatusOK,
+			checkResponse: func(t *testing.T, body string) {
+				want := `{"response":{"statusCode":200,"statusText":"Ok","data":{"groups":[{"name":"Friends","buddies":[{"aimId":"buddy1","displayId":"buddy1","state":"online","userType":"aim"}]}]}}}`
+				assert.Equal(t, want, body)
+			},
+		},
+		{
+			name:        "Success_BuddyList_ProfileMsg_Alias",
+			queryParams: "bl=1&profileMsg=1",
+			setupMocks: func(fr *mockFeedbagService, ls *mockLocateService) {
+				// profileMsg=1 reads presence through a locate query; it fails here,
+				// so the buddy renders offline but still carries its alias.
+				fr.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
+					Return(wire.SNACMessage{
+						Body: wire.SNAC_0x13_0x06_FeedbagReply{
+							Items: []wire.FeedbagItem{
+								{ClassID: wire.FeedbagClassIdGroup, Name: "Friends", GroupID: 1},
+								{
+									ClassID: wire.FeedbagClassIdBuddy,
+									Name:    "buddy1",
+									GroupID: 1,
+									ItemID:  1,
+									TLVLBlock: wire.TLVLBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLVBE(wire.FeedbagAttributesAlias, "Buddy One"),
+										},
+									},
+								},
+							},
+						},
+					}, nil)
+				ls.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("buddy1")).
+					Return(wire.SNACMessage{}, fmt.Errorf("locate unavailable"))
+			},
+			expectedStatusCode: http.StatusOK,
+			checkResponse: func(t *testing.T, body string) {
+				want := `{"response":{"statusCode":200,"statusText":"Ok","data":{"groups":[{"name":"Friends","buddies":[{"aimId":"buddy1","displayId":"buddy1","friendly":"Buddy One","state":"offline","userType":"aim"}]}]}}}`
+				assert.Equal(t, want, body)
 			},
 		},
 		{

+ 111 - 0
state/feedbag_list.go

@@ -413,6 +413,117 @@ func (f *FeedbagList) Items() []wire.FeedbagItem {
 	return result
 }
 
+// Groups returns the feedbag's groups, excluding the root group. When the root
+// group has an order attribute, groups follow that order and groups missing
+// from it are appended in feedbag order; otherwise all groups are in feedbag
+// order.
+func (f *FeedbagList) Groups() []wire.FeedbagItem {
+
+	groupOrder := func(items []*wire.FeedbagItem, groupID uint16) ([]uint16, bool) {
+		grpIdx := slices.IndexFunc(items, func(item *wire.FeedbagItem) bool {
+			return item.ClassID == wire.FeedbagClassIdGroup && item.GroupID == groupID
+		})
+		if grpIdx == -1 {
+			return []uint16{}, false
+		}
+		ordering, hasOrder := items[grpIdx].Uint16SliceBE(wire.FeedbagAttributesOrder)
+		return ordering, hasOrder
+	}
+
+	if ordering, hasOrder := groupOrder(f.items, 0); hasOrder {
+		orderMap := make(map[uint16]int)
+		for i, order := range ordering {
+			orderMap[order] = i
+		}
+
+		var ordered []*wire.FeedbagItem
+		var unordered []*wire.FeedbagItem
+		for _, group := range f.items {
+			if group.ClassID == wire.FeedbagClassIdGroup && group.GroupID > 0 {
+				if _, ok := orderMap[group.GroupID]; ok {
+					ordered = append(ordered, group)
+				} else {
+					unordered = append(unordered, group)
+				}
+			}
+		}
+
+		// order groups present in ordering TLV by their position in the ordering TLV
+		slices.SortFunc(ordered, func(a, b *wire.FeedbagItem) int {
+			return orderMap[a.GroupID] - orderMap[b.GroupID]
+		})
+
+		ret := make([]wire.FeedbagItem, 0, len(ordered)+len(unordered))
+		for _, group := range ordered {
+			ret = append(ret, *group)
+		}
+		for _, group := range unordered {
+			ret = append(ret, *group)
+		}
+		return ret
+	}
+
+	var ret []wire.FeedbagItem
+	for _, group := range f.items {
+		if group.ClassID == wire.FeedbagClassIdGroup && group.GroupID != 0 {
+			ret = append(ret, *group)
+		}
+	}
+
+	return ret
+}
+
+// Buddies returns the buddies in groupItem. When groupItem has an order
+// attribute, buddies follow that order and buddies missing from it are
+// appended in feedbag order; otherwise all buddies are in feedbag order.
+func (f *FeedbagList) Buddies(groupItem wire.FeedbagItem) []wire.FeedbagItem {
+	if groupItem.ClassID != wire.FeedbagClassIdGroup {
+		panic("Buddies called on non-group item")
+	}
+
+	if ordering, hasOrder := groupItem.Uint16SliceBE(wire.FeedbagAttributesOrder); hasOrder {
+		orderMap := make(map[uint16]int)
+		for i, order := range ordering {
+			orderMap[order] = i
+		}
+
+		var ordered []*wire.FeedbagItem
+		var unordered []*wire.FeedbagItem
+		for _, buddy := range f.items {
+			if buddy.ClassID == wire.FeedbagClassIdBuddy && buddy.GroupID == groupItem.GroupID {
+				if _, ok := orderMap[buddy.ItemID]; ok {
+					ordered = append(ordered, buddy)
+				} else {
+					unordered = append(unordered, buddy)
+				}
+			}
+		}
+
+		// order buddies present in ordering TLV by their position in the ordering TLV
+		slices.SortFunc(ordered, func(a, b *wire.FeedbagItem) int {
+			return orderMap[a.ItemID] - orderMap[b.ItemID]
+		})
+
+		ret := make([]wire.FeedbagItem, 0, len(ordered)+len(unordered))
+		for _, buddy := range ordered {
+			ret = append(ret, *buddy)
+		}
+		for _, buddy := range unordered {
+			ret = append(ret, *buddy)
+		}
+		return ret
+	}
+
+	var ret []wire.FeedbagItem
+	for _, buddy := range f.items {
+		if buddy.ClassID == wire.FeedbagClassIdBuddy && buddy.GroupID == groupItem.GroupID {
+			ret = append(ret, *buddy)
+		}
+	}
+
+	return ret
+}
+
 // rootGroup retrieves the root group, creating one if non-existent.
 func (f *FeedbagList) rootGroup() *wire.FeedbagItem {
 	var root *wire.FeedbagItem

+ 124 - 0
state/feedbag_list_test.go

@@ -1425,3 +1425,127 @@ func TestFeedbagList_SetIcon(t *testing.T) {
 		assert.Len(t, fl.Items(), 1)
 	})
 }
+
+// feedbagItemNames returns the names of items, in order.
+func feedbagItemNames(items []wire.FeedbagItem) []string {
+	var names []string
+	for _, item := range items {
+		names = append(names, item.Name)
+	}
+	return names
+}
+
+// orderedGroup returns a group item whose order attribute lists the given IDs.
+func orderedGroup(name string, groupID uint16, order ...uint16) wire.FeedbagItem {
+	return wire.FeedbagItem{
+		Name:    name,
+		ClassID: wire.FeedbagClassIdGroup,
+		GroupID: groupID,
+		TLVLBlock: wire.TLVLBlock{
+			TLVList: wire.TLVList{
+				wire.NewTLVBE(wire.FeedbagAttributesOrder, order),
+			},
+		},
+	}
+}
+
+func TestFeedbagList_Groups(t *testing.T) {
+	t.Run("orders listed groups by the root order, then unlisted groups by position", func(t *testing.T) {
+		fl := NewFeedbagList([]wire.FeedbagItem{
+			orderedGroup("", 0, 3, 1),
+			{Name: "A", ClassID: wire.FeedbagClassIdGroup, GroupID: 1},
+			{Name: "buddy", ClassID: wire.FeedbagClassIdBuddy, GroupID: 1, ItemID: 10},
+			{Name: "B", ClassID: wire.FeedbagClassIdGroup, GroupID: 2},
+			{Name: "permit", ClassID: wire.FeedbagClassIDPermit, ItemID: 11},
+			{Name: "C", ClassID: wire.FeedbagClassIdGroup, GroupID: 3},
+			{Name: "D", ClassID: wire.FeedbagClassIdGroup, GroupID: 4},
+		}, nil)
+		assert.Equal(t, []string{"C", "A", "B", "D"}, feedbagItemNames(fl.Groups()))
+	})
+
+	t.Run("ignores order entries with no matching group", func(t *testing.T) {
+		fl := NewFeedbagList([]wire.FeedbagItem{
+			orderedGroup("", 0, 9, 2, 1),
+			{Name: "A", ClassID: wire.FeedbagClassIdGroup, GroupID: 1},
+			{Name: "B", ClassID: wire.FeedbagClassIdGroup, GroupID: 2},
+		}, nil)
+		assert.Equal(t, []string{"B", "A"}, feedbagItemNames(fl.Groups()))
+	})
+
+	t.Run("keeps feedbag position when the root group has no order", func(t *testing.T) {
+		fl := NewFeedbagList([]wire.FeedbagItem{
+			{ClassID: wire.FeedbagClassIdGroup, GroupID: 0},
+			{Name: "C", ClassID: wire.FeedbagClassIdGroup, GroupID: 3},
+			{Name: "A", ClassID: wire.FeedbagClassIdGroup, GroupID: 1},
+			{Name: "B", ClassID: wire.FeedbagClassIdGroup, GroupID: 2},
+		}, nil)
+		assert.Equal(t, []string{"C", "A", "B"}, feedbagItemNames(fl.Groups()))
+	})
+
+	t.Run("keeps feedbag position when there is no root group", func(t *testing.T) {
+		fl := NewFeedbagList([]wire.FeedbagItem{
+			{Name: "C", ClassID: wire.FeedbagClassIdGroup, GroupID: 3},
+			{Name: "A", ClassID: wire.FeedbagClassIdGroup, GroupID: 1},
+		}, nil)
+		assert.Equal(t, []string{"C", "A"}, feedbagItemNames(fl.Groups()))
+	})
+
+	t.Run("returns no groups for an empty feedbag", func(t *testing.T) {
+		fl := NewFeedbagList(nil, nil)
+		assert.Empty(t, fl.Groups())
+	})
+}
+
+func TestFeedbagList_Buddies(t *testing.T) {
+	t.Run("orders listed buddies by the group order, then unlisted buddies by position", func(t *testing.T) {
+		friends := orderedGroup("Friends", 1, 5, 2)
+		fl := NewFeedbagList([]wire.FeedbagItem{
+			{ClassID: wire.FeedbagClassIdGroup, GroupID: 0},
+			friends,
+			{Name: "buddy3", ClassID: wire.FeedbagClassIdBuddy, GroupID: 1, ItemID: 6},
+			{Name: "other", ClassID: wire.FeedbagClassIdBuddy, GroupID: 2, ItemID: 8},
+			{Name: "buddy1", ClassID: wire.FeedbagClassIdBuddy, GroupID: 1, ItemID: 2},
+			{Name: "buddy4", ClassID: wire.FeedbagClassIdBuddy, GroupID: 1, ItemID: 7},
+			{Name: "buddy2", ClassID: wire.FeedbagClassIdBuddy, GroupID: 1, ItemID: 5},
+		}, nil)
+		assert.Equal(t, []string{"buddy2", "buddy1", "buddy3", "buddy4"}, feedbagItemNames(fl.Buddies(friends)))
+	})
+
+	t.Run("excludes a buddy in another group that shares a listed item ID", func(t *testing.T) {
+		friends := orderedGroup("Friends", 1, 5)
+		fl := NewFeedbagList([]wire.FeedbagItem{
+			friends,
+			{Name: "buddy1", ClassID: wire.FeedbagClassIdBuddy, GroupID: 1, ItemID: 5},
+			{Name: "other", ClassID: wire.FeedbagClassIdBuddy, GroupID: 2, ItemID: 5},
+		}, nil)
+		assert.Equal(t, []string{"buddy1"}, feedbagItemNames(fl.Buddies(friends)))
+	})
+
+	t.Run("keeps feedbag position when the group has no order", func(t *testing.T) {
+		family := wire.FeedbagItem{Name: "Family", ClassID: wire.FeedbagClassIdGroup, GroupID: 1}
+		fl := NewFeedbagList([]wire.FeedbagItem{
+			family,
+			{Name: "family2", ClassID: wire.FeedbagClassIdBuddy, GroupID: 1, ItemID: 9},
+			{Name: "other", ClassID: wire.FeedbagClassIdBuddy, GroupID: 2, ItemID: 4},
+			{Name: "family1", ClassID: wire.FeedbagClassIdBuddy, GroupID: 1, ItemID: 3},
+		}, nil)
+		assert.Equal(t, []string{"family2", "family1"}, feedbagItemNames(fl.Buddies(family)))
+	})
+
+	t.Run("returns no buddies for an empty group", func(t *testing.T) {
+		empty := wire.FeedbagItem{Name: "Empty", ClassID: wire.FeedbagClassIdGroup, GroupID: 3}
+		fl := NewFeedbagList([]wire.FeedbagItem{
+			empty,
+			{Name: "Friends", ClassID: wire.FeedbagClassIdGroup, GroupID: 1},
+			{Name: "buddy1", ClassID: wire.FeedbagClassIdBuddy, GroupID: 1, ItemID: 2},
+		}, nil)
+		assert.Empty(t, fl.Buddies(empty))
+	})
+
+	t.Run("panics on a non-group item", func(t *testing.T) {
+		fl := NewFeedbagList(nil, nil)
+		assert.Panics(t, func() {
+			fl.Buddies(wire.FeedbagItem{Name: "buddy1", ClassID: wire.FeedbagClassIdBuddy})
+		})
+	})
+}