Quellcode durchsuchen

webapi: implement feedbag renameGroup, moveBuddy, setBuddyAttribute, setGroupAttribute

Mike vor 2 Wochen
Ursprung
Commit
bf77eb9f9f

+ 227 - 0
server/webapi/handlers/buddy_list_manager.go

@@ -332,6 +332,233 @@ func (m *BuddyListManager) RemoveGroupFromFeedbag(ctx context.Context, sess *sta
 	return "success", nil
 	return "success", nil
 }
 }
 
 
+// RenameGroupInFeedbag renames a buddy group, updating the group item in place.
+func (m *BuddyListManager) RenameGroupInFeedbag(ctx context.Context, sess *state.WebAPISession, oldGroup, newGroup string) (resultCode string, err error) {
+	oldGroup = strings.TrimSpace(oldGroup)
+	newGroup = strings.TrimSpace(newGroup)
+	if oldGroup == "" || newGroup == "" {
+		return "error", fmt.Errorf("empty group name")
+	}
+	if sess.OSCARSession == nil {
+		return "error", fmt.Errorf("no OSCAR session")
+	}
+
+	frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagQuery}
+	snac, err := m.feedbagService.Query(ctx, sess.OSCARSession, frame)
+	if err != nil {
+		m.logger.ErrorContext(ctx, "rename group: feedbag query failed", "err", err.Error())
+		return "error", err
+	}
+	reply, ok := snac.Body.(wire.SNAC_0x13_0x06_FeedbagReply)
+	if !ok {
+		return "error", fmt.Errorf("unexpected feedbag reply type")
+	}
+
+	storedName, found := storedGroupNameForRequest(reply.Items, oldGroup)
+	if !found {
+		return "notFound", nil
+	}
+
+	fl := state.NewFeedbagList(reply.Items, rand.Intn)
+	if err := fl.RenameGroup(storedName, newGroup); err != nil {
+		switch {
+		case errors.Is(err, state.ErrGroupNotFound):
+			return "notFound", nil
+		case errors.Is(err, state.ErrGroupExists):
+			return "alreadyExists", nil
+		default:
+			m.logger.ErrorContext(ctx, "rename group: RenameGroup failed", "err", err.Error())
+			return "error", err
+		}
+	}
+
+	if pending := fl.PendingUpdates(); len(pending) > 0 {
+		upFrame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagUpdateItem}
+		if _, err := m.feedbagService.UpsertItem(ctx, sess.OSCARSession, upFrame, pending); err != nil {
+			m.logger.ErrorContext(ctx, "rename group: Feedbag UpsertItem failed", "err", err.Error())
+			return "error", err
+		}
+	}
+
+	return "success", nil
+}
+
+// MoveBuddyInFeedbag moves a buddy to a different group and/or repositions it
+// within a group's order.
+func (m *BuddyListManager) MoveBuddyInFeedbag(ctx context.Context, sess *state.WebAPISession, buddyName, fromGroup, toGroup, beforeBuddy string) (resultCode string, err error) {
+	buddyName = strings.TrimSpace(buddyName)
+	fromGroup = strings.TrimSpace(fromGroup)
+	toGroup = strings.TrimSpace(toGroup)
+	beforeBuddy = strings.TrimSpace(beforeBuddy)
+	if buddyName == "" || fromGroup == "" {
+		return "error", fmt.Errorf("empty buddy or group")
+	}
+	if sess.OSCARSession == nil {
+		return "error", fmt.Errorf("no OSCAR session")
+	}
+
+	frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagQuery}
+	snac, err := m.feedbagService.Query(ctx, sess.OSCARSession, frame)
+	if err != nil {
+		m.logger.ErrorContext(ctx, "move buddy: feedbag query failed", "err", err.Error())
+		return "error", err
+	}
+	reply, ok := snac.Body.(wire.SNAC_0x13_0x06_FeedbagReply)
+	if !ok {
+		return "error", fmt.Errorf("unexpected feedbag reply type")
+	}
+
+	storedFrom, found := storedGroupNameForRequest(reply.Items, fromGroup)
+	if !found {
+		return "notFound", nil
+	}
+	storedTo := ""
+	if toGroup != "" {
+		storedTo, found = storedGroupNameForRequest(reply.Items, toGroup)
+		if !found {
+			return "notFound", nil
+		}
+	}
+
+	fl := state.NewFeedbagList(reply.Items, rand.Intn)
+	if err := fl.MoveBuddy(storedFrom, storedTo, buddyName, beforeBuddy); err != nil {
+		if errors.Is(err, state.ErrGroupNotFound) || errors.Is(err, state.ErrBuddyNotFound) {
+			return "notFound", nil
+		}
+		m.logger.ErrorContext(ctx, "move buddy: MoveBuddy failed", "err", err.Error())
+		return "error", err
+	}
+
+	if pending := fl.PendingDeletes(); len(pending) > 0 {
+		delFrame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagDeleteItem}
+		delBody := wire.SNAC_0x13_0x0A_FeedbagDeleteItem{Items: pending}
+		if _, err := m.feedbagService.DeleteItem(ctx, sess.OSCARSession, delFrame, delBody); err != nil {
+			m.logger.ErrorContext(ctx, "move buddy: Feedbag DeleteItem failed", "err", err.Error())
+			return "error", err
+		}
+	}
+
+	if pending := fl.PendingUpdates(); len(pending) > 0 {
+		var inserts, updates []wire.FeedbagItem
+		for _, item := range pending {
+			if item.ClassID == wire.FeedbagClassIdBuddy {
+				inserts = append(inserts, item)
+			} else {
+				updates = append(updates, item)
+			}
+		}
+		if len(inserts) > 0 {
+			insFrame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagInsertItem}
+			if _, err := m.feedbagService.UpsertItem(ctx, sess.OSCARSession, insFrame, inserts); err != nil {
+				m.logger.ErrorContext(ctx, "move buddy: Feedbag InsertItem failed", "err", err.Error())
+				return "error", err
+			}
+		}
+		if len(updates) > 0 {
+			upFrame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagUpdateItem}
+			if _, err := m.feedbagService.UpsertItem(ctx, sess.OSCARSession, upFrame, updates); err != nil {
+				m.logger.ErrorContext(ctx, "move buddy: Feedbag UpdateItem failed", "err", err.Error())
+				return "error", err
+			}
+		}
+	}
+
+	return "success", nil
+}
+
+// SetBuddyAttributeInFeedbag sets a buddy's friendly (alias) name across all
+// groups it belongs to. An empty friendly clears the alias.
+func (m *BuddyListManager) SetBuddyAttributeInFeedbag(ctx context.Context, sess *state.WebAPISession, buddyName, friendly string) (resultCode string, err error) {
+	buddyName = strings.TrimSpace(buddyName)
+	if buddyName == "" {
+		return "error", fmt.Errorf("empty buddy")
+	}
+	if sess.OSCARSession == nil {
+		return "error", fmt.Errorf("no OSCAR session")
+	}
+
+	frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagQuery}
+	snac, err := m.feedbagService.Query(ctx, sess.OSCARSession, frame)
+	if err != nil {
+		m.logger.ErrorContext(ctx, "set buddy attribute: feedbag query failed", "err", err.Error())
+		return "error", err
+	}
+	reply, ok := snac.Body.(wire.SNAC_0x13_0x06_FeedbagReply)
+	if !ok {
+		return "error", fmt.Errorf("unexpected feedbag reply type")
+	}
+
+	fl := state.NewFeedbagList(reply.Items, rand.Intn)
+	found, err := fl.SetBuddyAlias(buddyName, friendly)
+	if err != nil {
+		m.logger.ErrorContext(ctx, "set buddy attribute: SetBuddyAlias failed", "err", err.Error())
+		return "error", err
+	}
+	if !found {
+		return "notFound", nil
+	}
+
+	if pending := fl.PendingUpdates(); len(pending) > 0 {
+		upFrame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagUpdateItem}
+		if _, err := m.feedbagService.UpsertItem(ctx, sess.OSCARSession, upFrame, pending); err != nil {
+			m.logger.ErrorContext(ctx, "set buddy attribute: Feedbag UpsertItem failed", "err", err.Error())
+			return "error", err
+		}
+	}
+
+	return "success", nil
+}
+
+// SetGroupAttributeInFeedbag sets a group's collapsed state. An empty group
+// targets the unnamed default group.
+func (m *BuddyListManager) SetGroupAttributeInFeedbag(ctx context.Context, sess *state.WebAPISession, groupName string, collapsed bool) (resultCode string, err error) {
+	groupName = strings.TrimSpace(groupName)
+	if sess.OSCARSession == nil {
+		return "error", fmt.Errorf("no OSCAR session")
+	}
+
+	frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagQuery}
+	snac, err := m.feedbagService.Query(ctx, sess.OSCARSession, frame)
+	if err != nil {
+		m.logger.ErrorContext(ctx, "set group attribute: feedbag query failed", "err", err.Error())
+		return "error", err
+	}
+	reply, ok := snac.Body.(wire.SNAC_0x13_0x06_FeedbagReply)
+	if !ok {
+		return "error", fmt.Errorf("unexpected feedbag reply type")
+	}
+
+	// An empty group targets the unnamed default group (stored name ""); a named
+	// group is resolved through the "Buddies" default-label mapping.
+	storedName := ""
+	if groupName != "" {
+		var found bool
+		storedName, found = storedGroupNameForRequest(reply.Items, groupName)
+		if !found {
+			return "notFound", nil
+		}
+	}
+
+	fl := state.NewFeedbagList(reply.Items, rand.Intn)
+	if err := fl.SetGroupCollapsed(storedName, collapsed); err != nil {
+		if errors.Is(err, state.ErrGroupNotFound) {
+			return "notFound", nil
+		}
+		m.logger.ErrorContext(ctx, "set group attribute: SetGroupCollapsed failed", "err", err.Error())
+		return "error", err
+	}
+
+	if pending := fl.PendingUpdates(); len(pending) > 0 {
+		upFrame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagUpdateItem}
+		if _, err := m.feedbagService.UpsertItem(ctx, sess.OSCARSession, upFrame, pending); err != nil {
+			m.logger.ErrorContext(ctx, "set group attribute: Feedbag UpsertItem failed", "err", err.Error())
+			return "error", err
+		}
+	}
+
+	return "success", nil
+}
+
 // FormatBuddyListEvent formats a buddy list for an event.
 // FormatBuddyListEvent formats a buddy list for an event.
 func (m *BuddyListManager) FormatBuddyListEvent(groups []WebAPIBuddyGroup) map[string]interface{} {
 func (m *BuddyListManager) FormatBuddyListEvent(groups []WebAPIBuddyGroup) map[string]interface{} {
 	// Convert groups to a format that AMF3 can properly encode
 	// Convert groups to a format that AMF3 can properly encode

+ 191 - 0
server/webapi/handlers/buddylist.go

@@ -39,6 +39,10 @@ func NewBuddyListHandler(sessionManager WebAPISessionManager, blm *BuddyListMana
 	m.Handle("GET /buddylist/addGroup", h.SessionMiddleware(h.AddGroup))
 	m.Handle("GET /buddylist/addGroup", h.SessionMiddleware(h.AddGroup))
 	m.Handle("GET /buddylist/removeBuddy", h.SessionMiddleware(h.RemoveBuddy))
 	m.Handle("GET /buddylist/removeBuddy", h.SessionMiddleware(h.RemoveBuddy))
 	m.Handle("GET /buddylist/removeGroup", h.SessionMiddleware(h.RemoveGroup))
 	m.Handle("GET /buddylist/removeGroup", h.SessionMiddleware(h.RemoveGroup))
+	m.Handle("GET /buddylist/renameGroup", h.SessionMiddleware(h.RenameGroup))
+	m.Handle("GET /buddylist/moveBuddy", h.SessionMiddleware(h.MoveBuddy))
+	m.Handle("GET /buddylist/setBuddyAttribute", h.SessionMiddleware(h.SetBuddyAttribute))
+	m.Handle("GET /buddylist/setGroupAttribute", h.SessionMiddleware(h.SetGroupAttribute))
 	h.mux = m
 	h.mux = m
 	return h
 	return h
 }
 }
@@ -491,6 +495,193 @@ func (h *BuddyListHandler) RemoveTempBuddy(w http.ResponseWriter, r *http.Reques
 	)
 	)
 }
 }
 
 
+// RenameGroup handles GET /buddylist/renameGroup requests.
+//
+// The Web AIM client calls this with oldGroup (current group name) and newGroup
+// (the requested new name). This is a stub; it does not yet rename the group in
+// the feedbag.
+func (h *BuddyListHandler) RenameGroup(w http.ResponseWriter, r *http.Request, session *state.WebAPISession) {
+	ctx := r.Context()
+	aimsid := r.URL.Query().Get("aimsid")
+
+	oldGroup := strings.TrimSpace(r.URL.Query().Get("oldGroup"))
+	newGroup := strings.TrimSpace(r.URL.Query().Get("newGroup"))
+	if oldGroup == "" || newGroup == "" {
+		h.sendError(w, http.StatusBadRequest, "missing oldGroup or newGroup parameter")
+		return
+	}
+
+	resultCode, rnErr := h.BuddyListManager.RenameGroupInFeedbag(ctx, session, oldGroup, newGroup)
+	if rnErr != nil {
+		h.Logger.ErrorContext(ctx, "rename group failed", "err", rnErr.Error())
+	}
+
+	resp := BaseResponse{}
+	resp.Response.StatusCode = 200
+	resp.Response.StatusText = "OK"
+	resp.Response.Data = map[string]any{
+		"resultCode": resultCode,
+	}
+	SendResponse(w, r, resp, h.Logger)
+
+	if resultCode == "success" {
+		h.pushBuddyListEvent(ctx, session)
+	}
+
+	h.Logger.InfoContext(ctx, "buddy list group renamed",
+		"aimsid", aimsid,
+		"oldGroup", oldGroup,
+		"newGroup", newGroup,
+		"result", resultCode,
+	)
+}
+
+// MoveBuddy handles GET /buddylist/moveBuddy requests.
+//
+// The Web AIM client calls this with buddy (the buddy to move), group (the
+// current group), and optionally newGroup (destination group) and beforeBuddy
+// (buddy to position it before). This is a stub; it does not yet move the buddy
+// in the feedbag.
+func (h *BuddyListHandler) MoveBuddy(w http.ResponseWriter, r *http.Request, session *state.WebAPISession) {
+	ctx := r.Context()
+	aimsid := r.URL.Query().Get("aimsid")
+
+	buddyName := strings.TrimSpace(r.URL.Query().Get("buddy"))
+	groupName := strings.TrimSpace(r.URL.Query().Get("group"))
+	newGroup := strings.TrimSpace(r.URL.Query().Get("newGroup"))
+	beforeBuddy := strings.TrimSpace(r.URL.Query().Get("beforeBuddy"))
+	if buddyName == "" {
+		h.sendError(w, http.StatusBadRequest, "missing buddy parameter")
+		return
+	}
+	if groupName == "" {
+		h.sendError(w, http.StatusBadRequest, "missing group parameter")
+		return
+	}
+
+	resultCode, mvErr := h.BuddyListManager.MoveBuddyInFeedbag(ctx, session, buddyName, groupName, newGroup, beforeBuddy)
+	if mvErr != nil {
+		h.Logger.ErrorContext(ctx, "move buddy failed", "err", mvErr.Error())
+	}
+
+	resp := BaseResponse{}
+	resp.Response.StatusCode = 200
+	resp.Response.StatusText = "OK"
+	resp.Response.Data = map[string]any{
+		"resultCode": resultCode,
+	}
+	SendResponse(w, r, resp, h.Logger)
+
+	if resultCode == "success" {
+		h.pushBuddyListEvent(ctx, session)
+	}
+
+	h.Logger.InfoContext(ctx, "buddy moved",
+		"aimsid", aimsid,
+		"buddy", buddyName,
+		"group", groupName,
+		"newGroup", newGroup,
+		"beforeBuddy", beforeBuddy,
+		"result", resultCode,
+	)
+}
+
+// SetBuddyAttribute handles GET /buddylist/setBuddyAttribute requests.
+//
+// The Web AIM client calls this with t (the buddy) and friendly (the display
+// name / alias). This is a stub; it does not yet persist the attribute to the
+// feedbag.
+func (h *BuddyListHandler) SetBuddyAttribute(w http.ResponseWriter, r *http.Request, session *state.WebAPISession) {
+	ctx := r.Context()
+	aimsid := r.URL.Query().Get("aimsid")
+
+	buddyName := strings.TrimSpace(r.URL.Query().Get("t"))
+	friendly := strings.TrimSpace(r.URL.Query().Get("friendly"))
+	if buddyName == "" {
+		h.sendError(w, http.StatusBadRequest, "missing t parameter")
+		return
+	}
+
+	resultCode, saErr := h.BuddyListManager.SetBuddyAttributeInFeedbag(ctx, session, buddyName, friendly)
+	if saErr != nil {
+		h.Logger.ErrorContext(ctx, "set buddy attribute failed", "err", saErr.Error())
+	}
+
+	resp := BaseResponse{}
+	resp.Response.StatusCode = 200
+	resp.Response.StatusText = "OK"
+	resp.Response.Data = map[string]any{
+		"resultCode": resultCode,
+	}
+	SendResponse(w, r, resp, h.Logger)
+
+	if resultCode == "success" {
+		h.pushBuddyListEvent(ctx, session)
+	}
+
+	h.Logger.InfoContext(ctx, "buddy attribute set",
+		"aimsid", aimsid,
+		"buddy", buddyName,
+		"friendly", friendly,
+		"result", resultCode,
+	)
+}
+
+// SetGroupAttribute handles GET /buddylist/setGroupAttribute requests.
+//
+// The Web AIM client calls this with collapsed (the group's collapsed state)
+// and, for named groups, group. The unnamed default group omits group. This is
+// a stub; it does not yet persist the attribute to the feedbag.
+func (h *BuddyListHandler) SetGroupAttribute(w http.ResponseWriter, r *http.Request, session *state.WebAPISession) {
+	ctx := r.Context()
+	aimsid := r.URL.Query().Get("aimsid")
+
+	query := r.URL.Query()
+	groupName := strings.TrimSpace(query.Get("group"))
+	collapsedParam := strings.TrimSpace(query.Get("collapsed"))
+	if collapsedParam == "" {
+		h.sendError(w, http.StatusBadRequest, "missing collapsed parameter")
+		return
+	}
+	collapsed := collapsedParam == "true" || collapsedParam == "1"
+
+	resultCode, saErr := h.BuddyListManager.SetGroupAttributeInFeedbag(ctx, session, groupName, collapsed)
+	if saErr != nil {
+		h.Logger.ErrorContext(ctx, "set group attribute failed", "err", saErr.Error())
+	}
+
+	resp := BaseResponse{}
+	resp.Response.StatusCode = 200
+	resp.Response.StatusText = "OK"
+	resp.Response.Data = map[string]any{
+		"resultCode": resultCode,
+	}
+	SendResponse(w, r, resp, h.Logger)
+
+	if resultCode == "success" {
+		h.pushBuddyListEvent(ctx, session)
+	}
+
+	h.Logger.InfoContext(ctx, "buddy list group attribute set",
+		"aimsid", aimsid,
+		"group", groupName,
+		"collapsed", collapsed,
+		"result", resultCode,
+	)
+}
+
+// pushBuddyListEvent refreshes the buddy list and pushes it to the session's
+// event queue so the Web client re-renders after a mutation.
+func (h *BuddyListHandler) pushBuddyListEvent(ctx context.Context, session *state.WebAPISession) {
+	groups, err := h.BuddyListManager.GetBuddyListForUser(ctx, session)
+	if err != nil {
+		h.Logger.ErrorContext(ctx, "failed to get buddy list for event", "err", err.Error())
+		return
+	}
+	blPayload := map[string]any{"groups": groups}
+	session.EventQueue.Push(types.EventTypeBuddyList, blPayload)
+}
+
 // sendError is a convenience method that wraps the common SendError function.
 // sendError is a convenience method that wraps the common SendError function.
 func (h *BuddyListHandler) sendError(w http.ResponseWriter, statusCode int, message string) {
 func (h *BuddyListHandler) sendError(w http.ResponseWriter, statusCode int, message string) {
 	SendError(w, statusCode, message)
 	SendError(w, statusCode, message)

+ 347 - 0
server/webapi/handlers/buddylist_test.go

@@ -953,3 +953,350 @@ func TestBuddyListHandler_sessionMiddleware(t *testing.T) {
 		})
 		})
 	}
 	}
 }
 }
+
+func TestBuddyListHandler_RenameGroup(t *testing.T) {
+	newBLM := func(fs *MockFeedbagService) *BuddyListManager {
+		return NewBuddyListManager(fs, &MockLocateService{}, slog.Default())
+	}
+	sessWithOSCAR := func(aimsid string) *state.WebAPISession {
+		return &state.WebAPISession{
+			AimSID:       aimsid,
+			ScreenName:   state.DisplayScreenName("testuser"),
+			OSCARSession: state.NewSession().AddInstance(),
+			EventQueue:   types.NewEventQueue(100),
+			LastAccessed: time.Now(),
+		}
+	}
+
+	tests := []struct {
+		name             string
+		queryParams      map[string][]string
+		setup            func(*MockFeedbagService, string) *state.WebAPISession
+		expectStatusCode int
+		expectResponse   string
+	}{
+		{
+			name:        "Error_MissingParam",
+			queryParams: map[string][]string{"aimsid": {"sess"}, "oldGroup": {"Friends"}},
+			setup: func(fs *MockFeedbagService, aimsid string) *state.WebAPISession {
+				return &state.WebAPISession{AimSID: aimsid, LastAccessed: time.Now()}
+			},
+			expectStatusCode: http.StatusBadRequest,
+			expectResponse:   `{"response":{"statusCode":400,"statusText":"missing oldGroup or newGroup parameter"}}`,
+		},
+		{
+			name:        "Success",
+			queryParams: map[string][]string{"aimsid": {"sess"}, "oldGroup": {"Friends"}, "newGroup": {"Pals"}},
+			setup: func(fs *MockFeedbagService, aimsid string) *state.WebAPISession {
+				items := []wire.FeedbagItem{
+					{GroupID: 0, ClassID: wire.FeedbagClassIdGroup, Name: ""},
+					{GroupID: 1, ClassID: wire.FeedbagClassIdGroup, Name: "Friends"},
+				}
+				fs.On("Query", mock.Anything, mock.Anything, mock.Anything).
+					Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: items}}, nil).Once()
+				fs.On("UpsertItem", mock.Anything, mock.Anything, mock.Anything, mock.Anything).
+					Return((*wire.SNACMessage)(nil), nil).Once()
+				fs.On("Query", mock.Anything, mock.Anything, mock.Anything).
+					Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: nil}}, nil).Once()
+				return sessWithOSCAR(aimsid)
+			},
+			expectStatusCode: http.StatusOK,
+			expectResponse:   `{"response":{"statusCode":200,"statusText":"OK","data":{"resultCode":"success"}}}`,
+		},
+		{
+			name:        "NotFound",
+			queryParams: map[string][]string{"aimsid": {"sess"}, "oldGroup": {"Ghost"}, "newGroup": {"Pals"}},
+			setup: func(fs *MockFeedbagService, aimsid string) *state.WebAPISession {
+				fs.On("Query", mock.Anything, mock.Anything, mock.Anything).
+					Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: nil}}, nil).Once()
+				return sessWithOSCAR(aimsid)
+			},
+			expectStatusCode: http.StatusOK,
+			expectResponse:   `{"response":{"statusCode":200,"statusText":"OK","data":{"resultCode":"notFound"}}}`,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			fs := &MockFeedbagService{}
+			session := tt.setup(fs, "sess")
+			handler := &BuddyListHandler{BuddyListManager: newBLM(fs), Logger: slog.Default()}
+
+			values := url.Values{}
+			for k, vs := range tt.queryParams {
+				for _, v := range vs {
+					values.Add(k, v)
+				}
+			}
+			req, _ := http.NewRequest("GET", "/buddylist/renameGroup?"+values.Encode(), nil)
+			rr := httptest.NewRecorder()
+			handler.RenameGroup(rr, req, session)
+
+			assert.Equal(t, tt.expectStatusCode, rr.Code)
+			assert.Equal(t, tt.expectResponse, strings.TrimSpace(rr.Body.String()))
+			fs.AssertExpectations(t)
+		})
+	}
+}
+
+func TestBuddyListHandler_MoveBuddy(t *testing.T) {
+	newBLM := func(fs *MockFeedbagService) *BuddyListManager {
+		return NewBuddyListManager(fs, &MockLocateService{}, slog.Default())
+	}
+	sessWithOSCAR := func(aimsid string) *state.WebAPISession {
+		return &state.WebAPISession{
+			AimSID:       aimsid,
+			ScreenName:   state.DisplayScreenName("testuser"),
+			OSCARSession: state.NewSession().AddInstance(),
+			EventQueue:   types.NewEventQueue(100),
+			LastAccessed: time.Now(),
+		}
+	}
+
+	tests := []struct {
+		name             string
+		queryParams      map[string][]string
+		setup            func(*MockFeedbagService, string) *state.WebAPISession
+		expectStatusCode int
+		expectResponse   string
+	}{
+		{
+			name:        "Error_MissingBuddy",
+			queryParams: map[string][]string{"aimsid": {"sess"}, "group": {"Friends"}},
+			setup: func(fs *MockFeedbagService, aimsid string) *state.WebAPISession {
+				return &state.WebAPISession{AimSID: aimsid, LastAccessed: time.Now()}
+			},
+			expectStatusCode: http.StatusBadRequest,
+			expectResponse:   `{"response":{"statusCode":400,"statusText":"missing buddy parameter"}}`,
+		},
+		{
+			name:        "Success_Reorder",
+			queryParams: map[string][]string{"aimsid": {"sess"}, "buddy": {"bob"}, "group": {"Friends"}, "beforeBuddy": {"alice"}},
+			setup: func(fs *MockFeedbagService, aimsid string) *state.WebAPISession {
+				items := []wire.FeedbagItem{
+					{GroupID: 1, ClassID: wire.FeedbagClassIdGroup, Name: "Friends",
+						TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{10, 20}),
+						}}},
+					{GroupID: 1, ItemID: 10, ClassID: wire.FeedbagClassIdBuddy, Name: "alice"},
+					{GroupID: 1, ItemID: 20, ClassID: wire.FeedbagClassIdBuddy, Name: "bob"},
+				}
+				fs.On("Query", mock.Anything, mock.Anything, mock.Anything).
+					Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: items}}, nil).Once()
+				fs.On("UpsertItem", mock.Anything, mock.Anything, mock.Anything, mock.Anything).
+					Return((*wire.SNACMessage)(nil), nil).Once()
+				fs.On("Query", mock.Anything, mock.Anything, mock.Anything).
+					Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: nil}}, nil).Once()
+				return sessWithOSCAR(aimsid)
+			},
+			expectStatusCode: http.StatusOK,
+			expectResponse:   `{"response":{"statusCode":200,"statusText":"OK","data":{"resultCode":"success"}}}`,
+		},
+		{
+			name:        "NotFound_Buddy",
+			queryParams: map[string][]string{"aimsid": {"sess"}, "buddy": {"ghost"}, "group": {"Friends"}},
+			setup: func(fs *MockFeedbagService, aimsid string) *state.WebAPISession {
+				items := []wire.FeedbagItem{
+					{GroupID: 1, ClassID: wire.FeedbagClassIdGroup, Name: "Friends"},
+				}
+				fs.On("Query", mock.Anything, mock.Anything, mock.Anything).
+					Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: items}}, nil).Once()
+				return sessWithOSCAR(aimsid)
+			},
+			expectStatusCode: http.StatusOK,
+			expectResponse:   `{"response":{"statusCode":200,"statusText":"OK","data":{"resultCode":"notFound"}}}`,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			fs := &MockFeedbagService{}
+			session := tt.setup(fs, "sess")
+			handler := &BuddyListHandler{BuddyListManager: newBLM(fs), Logger: slog.Default()}
+
+			values := url.Values{}
+			for k, vs := range tt.queryParams {
+				for _, v := range vs {
+					values.Add(k, v)
+				}
+			}
+			req, _ := http.NewRequest("GET", "/buddylist/moveBuddy?"+values.Encode(), nil)
+			rr := httptest.NewRecorder()
+			handler.MoveBuddy(rr, req, session)
+
+			assert.Equal(t, tt.expectStatusCode, rr.Code)
+			assert.Equal(t, tt.expectResponse, strings.TrimSpace(rr.Body.String()))
+			fs.AssertExpectations(t)
+		})
+	}
+}
+
+func TestBuddyListHandler_SetBuddyAttribute(t *testing.T) {
+	newBLM := func(fs *MockFeedbagService) *BuddyListManager {
+		return NewBuddyListManager(fs, &MockLocateService{}, slog.Default())
+	}
+	sessWithOSCAR := func(aimsid string) *state.WebAPISession {
+		return &state.WebAPISession{
+			AimSID:       aimsid,
+			ScreenName:   state.DisplayScreenName("testuser"),
+			OSCARSession: state.NewSession().AddInstance(),
+			EventQueue:   types.NewEventQueue(100),
+			LastAccessed: time.Now(),
+		}
+	}
+
+	tests := []struct {
+		name             string
+		queryParams      map[string][]string
+		setup            func(*MockFeedbagService, string) *state.WebAPISession
+		expectStatusCode int
+		expectResponse   string
+	}{
+		{
+			name:        "Error_MissingT",
+			queryParams: map[string][]string{"aimsid": {"sess"}, "friendly": {"Al"}},
+			setup: func(fs *MockFeedbagService, aimsid string) *state.WebAPISession {
+				return &state.WebAPISession{AimSID: aimsid, LastAccessed: time.Now()}
+			},
+			expectStatusCode: http.StatusBadRequest,
+			expectResponse:   `{"response":{"statusCode":400,"statusText":"missing t parameter"}}`,
+		},
+		{
+			name:        "Success",
+			queryParams: map[string][]string{"aimsid": {"sess"}, "t": {"alice"}, "friendly": {"Al"}},
+			setup: func(fs *MockFeedbagService, aimsid string) *state.WebAPISession {
+				items := []wire.FeedbagItem{
+					{GroupID: 1, ClassID: wire.FeedbagClassIdGroup, Name: "Friends"},
+					{GroupID: 1, ItemID: 10, ClassID: wire.FeedbagClassIdBuddy, Name: "alice"},
+				}
+				fs.On("Query", mock.Anything, mock.Anything, mock.Anything).
+					Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: items}}, nil).Once()
+				fs.On("UpsertItem", mock.Anything, mock.Anything, mock.Anything, mock.Anything).
+					Return((*wire.SNACMessage)(nil), nil).Once()
+				fs.On("Query", mock.Anything, mock.Anything, mock.Anything).
+					Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: nil}}, nil).Once()
+				return sessWithOSCAR(aimsid)
+			},
+			expectStatusCode: http.StatusOK,
+			expectResponse:   `{"response":{"statusCode":200,"statusText":"OK","data":{"resultCode":"success"}}}`,
+		},
+		{
+			name:        "NotFound",
+			queryParams: map[string][]string{"aimsid": {"sess"}, "t": {"ghost"}, "friendly": {"Al"}},
+			setup: func(fs *MockFeedbagService, aimsid string) *state.WebAPISession {
+				fs.On("Query", mock.Anything, mock.Anything, mock.Anything).
+					Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: nil}}, nil).Once()
+				return sessWithOSCAR(aimsid)
+			},
+			expectStatusCode: http.StatusOK,
+			expectResponse:   `{"response":{"statusCode":200,"statusText":"OK","data":{"resultCode":"notFound"}}}`,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			fs := &MockFeedbagService{}
+			session := tt.setup(fs, "sess")
+			handler := &BuddyListHandler{BuddyListManager: newBLM(fs), Logger: slog.Default()}
+
+			values := url.Values{}
+			for k, vs := range tt.queryParams {
+				for _, v := range vs {
+					values.Add(k, v)
+				}
+			}
+			req, _ := http.NewRequest("GET", "/buddylist/setBuddyAttribute?"+values.Encode(), nil)
+			rr := httptest.NewRecorder()
+			handler.SetBuddyAttribute(rr, req, session)
+
+			assert.Equal(t, tt.expectStatusCode, rr.Code)
+			assert.Equal(t, tt.expectResponse, strings.TrimSpace(rr.Body.String()))
+			fs.AssertExpectations(t)
+		})
+	}
+}
+
+func TestBuddyListHandler_SetGroupAttribute(t *testing.T) {
+	newBLM := func(fs *MockFeedbagService) *BuddyListManager {
+		return NewBuddyListManager(fs, &MockLocateService{}, slog.Default())
+	}
+	sessWithOSCAR := func(aimsid string) *state.WebAPISession {
+		return &state.WebAPISession{
+			AimSID:       aimsid,
+			ScreenName:   state.DisplayScreenName("testuser"),
+			OSCARSession: state.NewSession().AddInstance(),
+			EventQueue:   types.NewEventQueue(100),
+			LastAccessed: time.Now(),
+		}
+	}
+
+	tests := []struct {
+		name             string
+		queryParams      map[string][]string
+		setup            func(*MockFeedbagService, string) *state.WebAPISession
+		expectStatusCode int
+		expectResponse   string
+	}{
+		{
+			name:        "Error_MissingCollapsed",
+			queryParams: map[string][]string{"aimsid": {"sess"}, "group": {"Friends"}},
+			setup: func(fs *MockFeedbagService, aimsid string) *state.WebAPISession {
+				return &state.WebAPISession{AimSID: aimsid, LastAccessed: time.Now()}
+			},
+			expectStatusCode: http.StatusBadRequest,
+			expectResponse:   `{"response":{"statusCode":400,"statusText":"missing collapsed parameter"}}`,
+		},
+		{
+			name:        "Success",
+			queryParams: map[string][]string{"aimsid": {"sess"}, "group": {"Friends"}, "collapsed": {"true"}},
+			setup: func(fs *MockFeedbagService, aimsid string) *state.WebAPISession {
+				items := []wire.FeedbagItem{
+					{GroupID: 0, ClassID: wire.FeedbagClassIdGroup, Name: ""},
+					{GroupID: 1, ClassID: wire.FeedbagClassIdGroup, Name: "Friends"},
+				}
+				fs.On("Query", mock.Anything, mock.Anything, mock.Anything).
+					Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: items}}, nil).Once()
+				fs.On("UpsertItem", mock.Anything, mock.Anything, mock.Anything, mock.Anything).
+					Return((*wire.SNACMessage)(nil), nil).Once()
+				fs.On("Query", mock.Anything, mock.Anything, mock.Anything).
+					Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: nil}}, nil).Once()
+				return sessWithOSCAR(aimsid)
+			},
+			expectStatusCode: http.StatusOK,
+			expectResponse:   `{"response":{"statusCode":200,"statusText":"OK","data":{"resultCode":"success"}}}`,
+		},
+		{
+			name:        "NotFound",
+			queryParams: map[string][]string{"aimsid": {"sess"}, "group": {"NoSuch"}, "collapsed": {"true"}},
+			setup: func(fs *MockFeedbagService, aimsid string) *state.WebAPISession {
+				fs.On("Query", mock.Anything, mock.Anything, mock.Anything).
+					Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: nil}}, nil).Once()
+				return sessWithOSCAR(aimsid)
+			},
+			expectStatusCode: http.StatusOK,
+			expectResponse:   `{"response":{"statusCode":200,"statusText":"OK","data":{"resultCode":"notFound"}}}`,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			fs := &MockFeedbagService{}
+			session := tt.setup(fs, "sess")
+			handler := &BuddyListHandler{BuddyListManager: newBLM(fs), Logger: slog.Default()}
+
+			values := url.Values{}
+			for k, vs := range tt.queryParams {
+				for _, v := range vs {
+					values.Add(k, v)
+				}
+			}
+			req, _ := http.NewRequest("GET", "/buddylist/setGroupAttribute?"+values.Encode(), nil)
+			rr := httptest.NewRecorder()
+			handler.SetGroupAttribute(rr, req, session)
+
+			assert.Equal(t, tt.expectStatusCode, rr.Code)
+			assert.Equal(t, tt.expectResponse, strings.TrimSpace(rr.Body.String()))
+			fs.AssertExpectations(t)
+		})
+	}
+}

+ 12 - 10
server/webapi/handlers/preference.go

@@ -81,7 +81,7 @@ var webBuddyPrefs = map[string]buddyPref{
 	"showBuddyFeed":               {wire.FeedbagBuddyPrefsShowBuddyFeed, true},
 	"showBuddyFeed":               {wire.FeedbagBuddyPrefsShowBuddyFeed, true},
 	"noSaveVanityInfo":            {wire.FeedbagBuddyPrefsNoSaveVanityInfo, false},
 	"noSaveVanityInfo":            {wire.FeedbagBuddyPrefsNoSaveVanityInfo, false},
 	"acceptOffLineIM":             {wire.FeedbagBuddyPrefsAcceptOfflineIM, true},
 	"acceptOffLineIM":             {wire.FeedbagBuddyPrefsAcceptOfflineIM, true},
-	"showGroups":                  {wire.FeedbagBuddyPrefsShowGroups, false},
+	"showGroups":                  {wire.FeedbagBuddyPrefsShowGroups, true},
 	"sortGroup":                   {wire.FeedbagBuddyPrefsSortGroup, true},
 	"sortGroup":                   {wire.FeedbagBuddyPrefsSortGroup, true},
 	"showOffLineBuddies":          {wire.FeedbagBuddyPrefsShowOfflineBuddies, true},
 	"showOffLineBuddies":          {wire.FeedbagBuddyPrefsShowOfflineBuddies, true},
 	"expandBuddies":               {wire.FeedbagBuddyPrefsExpandBuddies, false},
 	"expandBuddies":               {wire.FeedbagBuddyPrefsExpandBuddies, false},
@@ -313,16 +313,18 @@ func buddyPrefsItem(ctx context.Context, fs FeedbagService, instance *state.Sess
 	}, nil
 	}, nil
 }
 }
 
 
-// validBuddyPrefs returns the 0/1 values of the preferences that are actually
-// present (valid) in the feedbag buddy-prefs bitmask. Preferences the user has
-// never set are omitted, so callers can distinguish "unset" from a default
-// value.
-func validBuddyPrefs(list wire.TLVList) map[string]interface{} {
-	prefs := make(map[string]interface{})
+// effectiveBuddyPrefs returns the 0/1 value of every web buddy pref, using the
+// feedbag value when the pref's valid bit is set and the spec default otherwise.
+// This mirrors GetPreferences so the pushed preference event and the
+// preference/get endpoint agree: server-side defaults (e.g. showGroups) reach
+// the client even for prefs the user has never explicitly set. The web client
+// reads these from the startup preference event and has no other default for
+// them, so an omitted pref would silently fall back to the client's own hidden
+// default (which, for showGroups, hides group headers).
+func effectiveBuddyPrefs(list wire.TLVList) map[string]interface{} {
+	prefs := make(map[string]interface{}, len(webBuddyPrefs))
 	for name, pref := range webBuddyPrefs {
 	for name, pref := range webBuddyPrefs {
-		if valid, val := wire.BuddyPref(list, pref.num); valid {
-			prefs[name] = boolToPrefInt(val)
-		}
+		prefs[name] = effectivePrefValue(list, pref)
 	}
 	}
 	return prefs
 	return prefs
 }
 }

+ 16 - 10
server/webapi/handlers/preference_test.go

@@ -151,23 +151,29 @@ func TestPreferenceHandler_GetPreferences_AMF(t *testing.T) {
 	assert.NotContains(t, body, `playIMSound":"0"`)
 	assert.NotContains(t, body, `playIMSound":"0"`)
 }
 }
 
 
-func TestValidBuddyPrefs_OnlyValidBits(t *testing.T) {
-	// Only playIMSound (set false) and viewIMsInBubbles (set true) are valid;
-	// everything else must be omitted rather than defaulted.
+func TestEffectiveBuddyPrefs_StoredOverridesDefault(t *testing.T) {
+	// playIMSound defaults true but is stored false; viewIMsInBubbles defaults
+	// true and is stored true. Stored (valid) values must win over defaults.
 	var list wire.TLVList
 	var list wire.TLVList
 	list = wire.SetBuddyPref(list, wire.FeedbagBuddyPrefsPlayIMSound, false)
 	list = wire.SetBuddyPref(list, wire.FeedbagBuddyPrefsPlayIMSound, false)
 	list = wire.SetBuddyPref(list, wire.FeedbagBuddyPrefsViewIMsInBubbles, true)
 	list = wire.SetBuddyPref(list, wire.FeedbagBuddyPrefsViewIMsInBubbles, true)
 
 
-	got := validBuddyPrefs(list)
+	got := effectiveBuddyPrefs(list)
 
 
-	assert.Equal(t, map[string]interface{}{
-		"playIMSound":      0,
-		"viewIMsInBubbles": 1,
-	}, got)
+	// Every pref is present (defaults applied for unset ones).
+	assert.Len(t, got, len(webBuddyPrefs))
+	assert.Equal(t, 0, got["playIMSound"])
+	assert.Equal(t, 1, got["viewIMsInBubbles"])
 }
 }
 
 
-func TestValidBuddyPrefs_EmptyWhenNothingSet(t *testing.T) {
-	assert.Empty(t, validBuddyPrefs(wire.TLVList{}))
+func TestEffectiveBuddyPrefs_AppliesDefaultsWhenNothingSet(t *testing.T) {
+	got := effectiveBuddyPrefs(wire.TLVList{})
+
+	// Unset prefs resolve to their spec defaults rather than being omitted.
+	assert.Len(t, got, len(webBuddyPrefs))
+	assert.Equal(t, 1, got["showGroups"], "showGroups should default to shown")
+	assert.Equal(t, 1, got["playIMSound"], "playIMSound defaults true")
+	assert.Equal(t, 0, got["sortBuddyList"], "sortBuddyList defaults false")
 }
 }
 
 
 func TestPreferenceHandler_SetPreferences_NoOSCARSession(t *testing.T) {
 func TestPreferenceHandler_SetPreferences_NoOSCARSession(t *testing.T) {

+ 32 - 43
server/webapi/handlers/presence.go

@@ -168,66 +168,55 @@ func (h *PresenceHandler) getBuddyListGroups(ctx context.Context, session *state
 	}
 	}
 	items := body.Items
 	items := body.Items
 
 
-	// Organize items into groups
+	// 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)
 	groupMap := make(map[uint16]*BuddyGroupInfo)
-	buddyToGroup := make(map[string]uint16)
 
 
-	// First pass: identify groups
+	// First pass: identify groups. Skip the root group (GroupID 0), which holds
+	// the master group order rather than buddies.
 	for _, item := range items {
 	for _, item := range items {
-		if item.ClassID == wire.FeedbagClassIdGroup {
-			name := item.Name
-			if name == "" {
-				name = "Buddies" // Default group name
-			}
-
-			groupMap[item.ItemID] = &BuddyGroupInfo{
-				Name:    name,
-				Buddies: []BuddyPresenceInfo{},
-			}
+		if item.ClassID != wire.FeedbagClassIdGroup || item.GroupID == 0 {
+			continue
 		}
 		}
-	}
-
-	// Second pass: add buddies to groups
-	for _, item := range items {
-		if item.ClassID == wire.FeedbagClassIdBuddy {
-			// Get buddy screen name
-			buddyName := item.Name
-			if buddyName == "" {
-				continue
-			}
-
-			// Find buddy's group
-			groupID := item.GroupID
-
-			buddyToGroup[buddyName] = groupID
+		name := item.Name
+		if name == "" {
+			name = "Buddies" // Default group name
 		}
 		}
-	}
-
-	// If no groups exist, create a default one
-	if len(groupMap) == 0 {
-		groupMap[0] = &BuddyGroupInfo{
-			Name:    "Buddies",
+		groupMap[item.GroupID] = &BuddyGroupInfo{
+			Name:    name,
 			Buddies: []BuddyPresenceInfo{},
 			Buddies: []BuddyPresenceInfo{},
 		}
 		}
 	}
 	}
 
 
-	// Add buddies to their groups with presence info
-	for buddyName, groupID := range buddyToGroup {
-		group, exists := groupMap[groupID]
+	// 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 {
 		if !exists {
-			// Put in first available group if group doesn't exist
-			for _, g := range groupMap {
-				group = g
-				break
-			}
+			// 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
 		}
 		}
 
 
 		// UserInfoQuery performs the blocking check and online lookup; blocked or
 		// UserInfoQuery performs the blocking check and online lookup; blocked or
 		// offline buddies come back as "offline", preserving the list structure.
 		// offline buddies come back as "offline", preserving the list structure.
-		presence := h.getUserPresence(ctx, session.OSCARSession, state.NewIdentScreenName(buddyName), wantProfileMsg)
+		presence := h.getUserPresence(ctx, session.OSCARSession, state.NewIdentScreenName(item.Name), wantProfileMsg)
 		group.Buddies = append(group.Buddies, presence)
 		group.Buddies = append(group.Buddies, presence)
 	}
 	}
 
 
+	// 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
 	// Convert map to slice
 	groups := make([]BuddyGroupInfo, 0, len(groupMap))
 	groups := make([]BuddyGroupInfo, 0, len(groupMap))
 	for _, group := range groupMap {
 	for _, group := range groupMap {

+ 74 - 1
server/webapi/handlers/presence_test.go

@@ -2,6 +2,7 @@ package handlers
 
 
 import (
 import (
 	"context"
 	"context"
+	"encoding/json"
 	"log/slog"
 	"log/slog"
 	"net/http"
 	"net/http"
 	"net/http/httptest"
 	"net/http/httptest"
@@ -121,7 +122,7 @@ func TestPresenceHandler_GetPresence(t *testing.T) {
 					Return(wire.SNACMessage{
 					Return(wire.SNACMessage{
 						Body: wire.SNAC_0x13_0x06_FeedbagReply{
 						Body: wire.SNAC_0x13_0x06_FeedbagReply{
 							Items: []wire.FeedbagItem{
 							Items: []wire.FeedbagItem{
-								{ItemID: 1, ClassID: wire.FeedbagClassIdGroup, Name: "Friends", GroupID: 0},
+								{ItemID: 0, ClassID: wire.FeedbagClassIdGroup, Name: "Friends", GroupID: 1},
 								{ItemID: 2, ClassID: wire.FeedbagClassIdBuddy, Name: "buddy1", GroupID: 1},
 								{ItemID: 2, ClassID: wire.FeedbagClassIdBuddy, Name: "buddy1", GroupID: 1},
 							},
 							},
 						},
 						},
@@ -234,6 +235,78 @@ func TestPresenceHandler_GetPresence(t *testing.T) {
 	}
 	}
 }
 }
 
 
+// TestPresenceHandler_GetPresence_BuddyListGrouping verifies that bl=1 places
+// each buddy under its own group using realistic feedbag data, where group rows
+// carry ItemID 0 and a distinct nonzero GroupID, and buddy rows reference those
+// GroupIDs. This is the shape the OSCAR feedbag actually stores.
+func TestPresenceHandler_GetPresence_BuddyListGrouping(t *testing.T) {
+	feedbagService := &MockFeedbagService{}
+	locateService := &MockLocateService{}
+
+	items := []wire.FeedbagItem{
+		// Root order group: ItemID 0, GroupID 0, empty name — not a real buddy group.
+		{ItemID: 0, GroupID: 0, ClassID: wire.FeedbagClassIdGroup, Name: ""},
+		// Named groups: ItemID 0, distinct nonzero GroupIDs.
+		{ItemID: 0, GroupID: 10, ClassID: wire.FeedbagClassIdGroup, Name: "Friends"},
+		{ItemID: 0, GroupID: 20, ClassID: wire.FeedbagClassIdGroup, Name: "Work"},
+		// Buddies reference their group's GroupID.
+		{ItemID: 101, GroupID: 10, ClassID: wire.FeedbagClassIdBuddy, Name: "alice"},
+		{ItemID: 201, GroupID: 20, ClassID: wire.FeedbagClassIdBuddy, Name: "bob"},
+	}
+	feedbagService.On("Query", mock.Anything, mock.Anything, mock.Anything).
+		Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: items}}, nil)
+	locateService.On("UserInfoQuery", mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("alice")).
+		Return(onlineUserInfoReply("alice", 0), nil)
+	locateService.On("UserInfoQuery", mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("bob")).
+		Return(onlineUserInfoReply("bob", 0), nil)
+
+	oscarInstance := state.NewSession().AddInstance()
+	sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
+	handler := &PresenceHandler{
+		SessionManager: sessionMgr,
+		FeedbagService: feedbagService,
+		LocateService:  locateService,
+		Logger:         slog.Default(),
+	}
+
+	req, err := http.NewRequest("GET", "/presence/get?aimsid="+aimsid+"&bl=1", nil)
+	assert.NoError(t, err)
+	rr := httptest.NewRecorder()
+	handler.GetPresence(rr, req)
+
+	assert.Equal(t, http.StatusOK, rr.Code)
+
+	var parsed struct {
+		Response struct {
+			Data struct {
+				Groups []struct {
+					Name    string `json:"name"`
+					Buddies []struct {
+						AimID string `json:"aimId"`
+					} `json:"buddies"`
+				} `json:"groups"`
+			} `json:"data"`
+		} `json:"response"`
+	}
+	assert.NoError(t, json.Unmarshal(rr.Body.Bytes(), &parsed))
+
+	// Build name -> set of buddy aimIds.
+	byGroup := map[string][]string{}
+	for _, g := range parsed.Response.Data.Groups {
+		for _, b := range g.Buddies {
+			byGroup[g.Name] = append(byGroup[g.Name], b.AimID)
+		}
+	}
+
+	// Exactly the two named groups appear; the root group is excluded.
+	assert.Len(t, parsed.Response.Data.Groups, 2)
+	assert.Equal(t, []string{"alice"}, byGroup["Friends"])
+	assert.Equal(t, []string{"bob"}, byGroup["Work"])
+
+	feedbagService.AssertExpectations(t)
+	locateService.AssertExpectations(t)
+}
+
 func TestPresenceHandler_GetPresence_MissingAimsid(t *testing.T) {
 func TestPresenceHandler_GetPresence_MissingAimsid(t *testing.T) {
 	handler := &PresenceHandler{
 	handler := &PresenceHandler{
 		SessionManager: state.NewWebAPISessionManager(),
 		SessionManager: state.NewWebAPISessionManager(),

+ 7 - 4
server/webapi/handlers/session.go

@@ -464,15 +464,18 @@ func (h *SessionHandler) StartSession(w http.ResponseWriter, r *http.Request) {
 				session.EventQueue.Push(types.EventTypeBuddyList, blPayload)
 				session.EventQueue.Push(types.EventTypeBuddyList, blPayload)
 			}
 			}
 		case types.EventTypePreference:
 		case types.EventTypePreference:
-			// Seed the client with the preferences the user has actually set
-			// (those present in the feedbag buddy-prefs valid bitmask). Unset
-			// prefs are omitted so we don't clobber client-side defaults.
+			// Seed the client with effective preference values: the user's stored
+			// prefs where set, and the server-side spec defaults otherwise. The
+			// client reads its buddy-list display prefs (e.g. showGroups) only from
+			// this event and has no default of its own for them, so an omitted pref
+			// would silently fall back to the client's hidden default and, for
+			// showGroups, hide group headers.
 			prefPayload := map[string]interface{}{}
 			prefPayload := map[string]interface{}{}
 			if authToken != "" && session.OSCARSession != nil {
 			if authToken != "" && session.OSCARSession != nil {
 				if item, err := buddyPrefsItem(ctx, h.FeedbagService, session.OSCARSession); err != nil {
 				if item, err := buddyPrefsItem(ctx, h.FeedbagService, session.OSCARSession); err != nil {
 					h.Logger.ErrorContext(ctx, "failed to get preferences", "err", err.Error())
 					h.Logger.ErrorContext(ctx, "failed to get preferences", "err", err.Error())
 				} else {
 				} else {
-					prefPayload = validBuddyPrefs(item.TLVList)
+					prefPayload = effectiveBuddyPrefs(item.TLVList)
 				}
 				}
 			}
 			}
 			if resp.Response.Data.Events == nil {
 			if resp.Response.Data.Events == nil {

+ 201 - 1
state/feedbag_list.go

@@ -12,6 +12,13 @@ import (
 // ErrGroupNotFound is returned when a feedbag group cannot be found.
 // ErrGroupNotFound is returned when a feedbag group cannot be found.
 var ErrGroupNotFound = errors.New("group not found")
 var ErrGroupNotFound = errors.New("group not found")
 
 
+// ErrGroupExists is returned when a feedbag group cannot be created or renamed
+// because another group already has the target name.
+var ErrGroupExists = errors.New("group already exists")
+
+// ErrBuddyNotFound is returned when a feedbag buddy cannot be found.
+var ErrBuddyNotFound = errors.New("buddy not found")
+
 // FeedbagList provides operations for manipulating a collection of feedbag
 // FeedbagList provides operations for manipulating a collection of feedbag
 // items. It supports lookups by class/name/group, item insertion with
 // items. It supports lookups by class/name/group, item insertion with
 // automatic ID generation, and transparent root group management.
 // automatic ID generation, and transparent root group management.
@@ -164,6 +171,110 @@ func (f *FeedbagList) DeleteBuddy(groupName, buddyName string) error {
 	return nil
 	return nil
 }
 }
 
 
+// RenameGroup changes a group's name in place, preserving its GroupID and
+// ItemID. Returns ErrGroupNotFound if oldName does not exist, or ErrGroupExists
+// if a different group already uses newName. The group is renamed in place
+// rather than re-inserted because upsertItem keys groups on name and would
+// otherwise create a duplicate.
+func (f *FeedbagList) RenameGroup(oldName, newName string) error {
+	group := f.groupByName(oldName)
+	if group == nil {
+		return fmt.Errorf("%w: %q", ErrGroupNotFound, oldName)
+	}
+	if newName != oldName && f.groupByName(newName) != nil {
+		return fmt.Errorf("%w: %q", ErrGroupExists, newName)
+	}
+	if group.Name == newName {
+		return nil
+	}
+	group.Name = newName
+	f.trackUpdate(group)
+	return nil
+}
+
+// MoveBuddy moves a buddy between groups and/or repositions it within a group's
+// order. When toGroup names a different group than fromGroup, the buddy's
+// feedbag item is deleted from the source group and re-inserted into the
+// destination (a buddy's identity includes its GroupID at the protocol level),
+// carrying over its alias and note attributes. When beforeBuddy is non-empty,
+// the buddy is positioned immediately before that buddy in the destination
+// group's order; otherwise it is appended. Returns ErrGroupNotFound or
+// ErrBuddyNotFound if the source group/buddy or destination group is missing.
+func (f *FeedbagList) MoveBuddy(fromGroup, toGroup, buddyName, beforeBuddy string) error {
+	src := f.groupByName(fromGroup)
+	if src == nil {
+		return fmt.Errorf("%w: %q", ErrGroupNotFound, fromGroup)
+	}
+	srcBuddy := f.buddyItem(src, buddyName)
+	if srcBuddy == nil {
+		return fmt.Errorf("%w: %q", ErrBuddyNotFound, buddyName)
+	}
+
+	dst := src
+	if toGroup != "" && toGroup != fromGroup {
+		dst = f.groupByName(toGroup)
+		if dst == nil {
+			return fmt.Errorf("%w: %q", ErrGroupNotFound, toGroup)
+		}
+
+		alias, _ := srcBuddy.String(wire.FeedbagAttributesAlias)
+		note, _ := srcBuddy.String(wire.FeedbagAttributesNote)
+
+		if err := f.DeleteBuddy(fromGroup, buddyName); err != nil {
+			return err
+		}
+		if _, err := f.AddBuddy(toGroup, buddyName, alias, note); err != nil {
+			return err
+		}
+	}
+
+	if beforeBuddy != "" {
+		moved := f.buddyItem(dst, buddyName)
+		before := f.buddyItem(dst, beforeBuddy)
+		if moved != nil && before != nil {
+			f.reorderInGroupOrder(dst, moved.ItemID, before.ItemID)
+		}
+	}
+
+	return nil
+}
+
+// SetBuddyAlias sets (or, when alias is empty, clears) the alias attribute on
+// every buddy item matching buddyName across all groups. Returns true if at
+// least one buddy item was found.
+func (f *FeedbagList) SetBuddyAlias(buddyName, alias string) (bool, error) {
+	buddies := f.buddyItemsByName(buddyName)
+	if len(buddies) == 0 {
+		return false, nil
+	}
+	for _, buddy := range buddies {
+		if alias != "" {
+			setItemTLV(buddy, wire.FeedbagAttributesAlias, alias)
+		} else {
+			clearItemTLV(buddy, wire.FeedbagAttributesAlias)
+		}
+		f.trackUpdate(buddy)
+	}
+	return true, nil
+}
+
+// SetGroupCollapsed sets (or, when collapsed is false, clears) the collapsed
+// attribute on a group. An empty groupName targets the unnamed default group.
+// Returns ErrGroupNotFound if the group does not exist.
+func (f *FeedbagList) SetGroupCollapsed(groupName string, collapsed bool) error {
+	group := f.groupByName(groupName)
+	if group == nil {
+		return fmt.Errorf("%w: %q", ErrGroupNotFound, groupName)
+	}
+	if collapsed {
+		setItemTLV(group, wire.FeedbagAttributesCollapsed, []byte{})
+	} else {
+		clearItemTLV(group, wire.FeedbagAttributesCollapsed)
+	}
+	f.trackUpdate(group)
+	return nil
+}
+
 // PermitUser upserts a permit-list entry for the given screen name.
 // PermitUser upserts a permit-list entry for the given screen name.
 func (f *FeedbagList) PermitUser(screenName string) {
 func (f *FeedbagList) PermitUser(screenName string) {
 	f.upsertItem(wire.FeedbagItem{
 	f.upsertItem(wire.FeedbagItem{
@@ -289,15 +400,104 @@ func (f *FeedbagList) rootGroup() *wire.FeedbagItem {
 }
 }
 
 
 // groupByName returns the group item with the given name, or nil if not found.
 // groupByName returns the group item with the given name, or nil if not found.
+// The root group (GroupID 0) is never returned; it holds the master group order
+// rather than buddies, and an empty name matches the unnamed default buddy group.
 func (f *FeedbagList) groupByName(name string) *wire.FeedbagItem {
 func (f *FeedbagList) groupByName(name string) *wire.FeedbagItem {
 	for _, item := range f.items {
 	for _, item := range f.items {
-		if item.ClassID == wire.FeedbagClassIdGroup && item.Name == name {
+		if item.ClassID == wire.FeedbagClassIdGroup && item.GroupID != 0 && item.Name == name {
 			return item
 			return item
 		}
 		}
 	}
 	}
 	return nil
 	return nil
 }
 }
 
 
+// buddyItem returns the buddy item with the given name in the given group, or
+// nil if not found. Names are normalized before comparison.
+func (f *FeedbagList) buddyItem(group *wire.FeedbagItem, buddyName string) *wire.FeedbagItem {
+	want := NewIdentScreenName(buddyName).String()
+	for _, item := range f.items {
+		if item.ClassID != wire.FeedbagClassIdBuddy || item.GroupID != group.GroupID {
+			continue
+		}
+		if NewIdentScreenName(item.Name).String() == want {
+			return item
+		}
+	}
+	return nil
+}
+
+// buddyItemsByName returns every buddy item matching buddyName across all
+// groups. Names are normalized before comparison.
+func (f *FeedbagList) buddyItemsByName(buddyName string) []*wire.FeedbagItem {
+	want := NewIdentScreenName(buddyName).String()
+	var out []*wire.FeedbagItem
+	for _, item := range f.items {
+		if item.ClassID != wire.FeedbagClassIdBuddy {
+			continue
+		}
+		if NewIdentScreenName(item.Name).String() == want {
+			out = append(out, item)
+		}
+	}
+	return out
+}
+
+// reorderInGroupOrder moves itemID so that it sits immediately before
+// beforeItemID in the group's order TLV. If beforeItemID is not present, itemID
+// is appended. The group is tracked as updated.
+func (f *FeedbagList) reorderInGroupOrder(group *wire.FeedbagItem, itemID, beforeItemID uint16) {
+	order, ok := group.Uint16SliceBE(wire.FeedbagAttributesOrder)
+	if !ok {
+		return
+	}
+	filtered := make([]uint16, 0, len(order))
+	for _, id := range order {
+		if id != itemID {
+			filtered = append(filtered, id)
+		}
+	}
+	insertAt := len(filtered)
+	for i, id := range filtered {
+		if id == beforeItemID {
+			insertAt = i
+			break
+		}
+	}
+	reordered := make([]uint16, 0, len(filtered)+1)
+	reordered = append(reordered, filtered[:insertAt]...)
+	reordered = append(reordered, itemID)
+	reordered = append(reordered, filtered[insertAt:]...)
+
+	if group.HasTag(wire.FeedbagAttributesOrder) {
+		group.Replace(wire.NewTLVBE(wire.FeedbagAttributesOrder, reordered))
+	} else {
+		group.Append(wire.NewTLVBE(wire.FeedbagAttributesOrder, reordered))
+	}
+	f.trackUpdate(group)
+}
+
+// setItemTLV sets a single attribute TLV on item, replacing an existing TLV
+// with the same tag or appending a new one. Mirrors AppendOrderMembers'
+// replace-or-append pattern since TLVList.Replace no-ops when the tag is absent.
+func setItemTLV(item *wire.FeedbagItem, tag uint16, value any) {
+	if item.HasTag(tag) {
+		item.Replace(wire.NewTLVBE(tag, value))
+	} else {
+		item.Append(wire.NewTLVBE(tag, value))
+	}
+}
+
+// clearItemTLV removes every TLV with the given tag from item.
+func clearItemTLV(item *wire.FeedbagItem, tag uint16) {
+	filtered := item.TLVList[:0:0]
+	for _, tlv := range item.TLVList {
+		if tlv.Tag != tag {
+			filtered = append(filtered, tlv)
+		}
+	}
+	item.TLVList = filtered
+}
+
 // trackUpdate adds item to the pending-updates list if not already present.
 // trackUpdate adds item to the pending-updates list if not already present.
 func (f *FeedbagList) trackUpdate(item *wire.FeedbagItem) {
 func (f *FeedbagList) trackUpdate(item *wire.FeedbagItem) {
 	if slices.Contains(f.pendingUpdates, item) {
 	if slices.Contains(f.pendingUpdates, item) {

+ 217 - 0
state/feedbag_list_test.go

@@ -1099,3 +1099,220 @@ func TestFeedbagList_HasLinkedScreenName(t *testing.T) {
 		assert.True(t, fl.HasLinkedScreenName("Alice"))
 		assert.True(t, fl.HasLinkedScreenName("Alice"))
 	})
 	})
 }
 }
+
+func TestFeedbagList_RenameGroup(t *testing.T) {
+	t.Run("renames group in place preserving IDs", func(t *testing.T) {
+		fl := NewFeedbagList([]wire.FeedbagItem{
+			{Name: "Coworkers", ClassID: wire.FeedbagClassIdGroup, GroupID: 5, ItemID: 0},
+		}, nil)
+
+		err := fl.RenameGroup("Coworkers", "Colleagues")
+		assert.NoError(t, err)
+
+		upserts := fl.PendingUpdates()
+		assert.Len(t, upserts, 1)
+		assert.Equal(t, "Colleagues", upserts[0].Name)
+		assert.Equal(t, uint16(5), upserts[0].GroupID)
+		assert.Equal(t, wire.FeedbagClassIdGroup, upserts[0].ClassID)
+	})
+
+	t.Run("returns ErrGroupNotFound for missing group", func(t *testing.T) {
+		fl := NewFeedbagList(nil, nil)
+		err := fl.RenameGroup("Nope", "New")
+		assert.ErrorIs(t, err, ErrGroupNotFound)
+		assert.Nil(t, fl.PendingUpdates())
+	})
+
+	t.Run("returns ErrGroupExists when target name taken", func(t *testing.T) {
+		fl := NewFeedbagList([]wire.FeedbagItem{
+			{Name: "A", ClassID: wire.FeedbagClassIdGroup, GroupID: 1},
+			{Name: "B", ClassID: wire.FeedbagClassIdGroup, GroupID: 2},
+		}, nil)
+		err := fl.RenameGroup("A", "B")
+		assert.ErrorIs(t, err, ErrGroupExists)
+		assert.Nil(t, fl.PendingUpdates())
+	})
+
+	t.Run("renaming to same name is a no-op", func(t *testing.T) {
+		fl := NewFeedbagList([]wire.FeedbagItem{
+			{Name: "A", ClassID: wire.FeedbagClassIdGroup, GroupID: 1},
+		}, nil)
+		err := fl.RenameGroup("A", "A")
+		assert.NoError(t, err)
+		assert.Nil(t, fl.PendingUpdates())
+	})
+}
+
+func TestFeedbagList_MoveBuddy(t *testing.T) {
+	t.Run("moves buddy across groups carrying alias", func(t *testing.T) {
+		fl := NewFeedbagList([]wire.FeedbagItem{
+			{Name: "Buddies", ClassID: wire.FeedbagClassIdGroup, GroupID: 1,
+				TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{
+					wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{10}),
+				}}},
+			{Name: "alice", ClassID: wire.FeedbagClassIdBuddy, GroupID: 1, ItemID: 10,
+				TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{
+					wire.NewTLVBE(wire.FeedbagAttributesAlias, "Al"),
+				}}},
+			{Name: "Coworkers", ClassID: wire.FeedbagClassIdGroup, GroupID: 2},
+		}, func(n int) int { return 99 })
+
+		err := fl.MoveBuddy("Buddies", "Coworkers", "alice", "")
+		assert.NoError(t, err)
+
+		deletes := fl.PendingDeletes()
+		assert.Len(t, deletes, 1)
+		assert.Equal(t, "alice", deletes[0].Name)
+		assert.Equal(t, uint16(1), deletes[0].GroupID)
+
+		upserts := fl.PendingUpdates()
+		var newBuddy *wire.FeedbagItem
+		for i := range upserts {
+			if upserts[i].ClassID == wire.FeedbagClassIdBuddy {
+				newBuddy = &upserts[i]
+			}
+		}
+		assert.NotNil(t, newBuddy)
+		assert.Equal(t, uint16(2), newBuddy.GroupID)
+		alias, ok := newBuddy.Bytes(wire.FeedbagAttributesAlias)
+		assert.True(t, ok)
+		assert.Equal(t, []byte("Al"), alias)
+	})
+
+	t.Run("reorders within a group before another buddy", func(t *testing.T) {
+		fl := NewFeedbagList([]wire.FeedbagItem{
+			{Name: "Buddies", ClassID: wire.FeedbagClassIdGroup, GroupID: 1,
+				TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{
+					wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{10, 20, 30}),
+				}}},
+			{Name: "alice", ClassID: wire.FeedbagClassIdBuddy, GroupID: 1, ItemID: 10},
+			{Name: "bob", ClassID: wire.FeedbagClassIdBuddy, GroupID: 1, ItemID: 20},
+			{Name: "carol", ClassID: wire.FeedbagClassIdBuddy, GroupID: 1, ItemID: 30},
+		}, nil)
+
+		// move carol (30) before alice (10)
+		err := fl.MoveBuddy("Buddies", "", "carol", "alice")
+		assert.NoError(t, err)
+
+		upserts := fl.PendingUpdates()
+		assert.Len(t, upserts, 1)
+		order, ok := upserts[0].Uint16SliceBE(wire.FeedbagAttributesOrder)
+		assert.True(t, ok)
+		assert.Equal(t, []uint16{30, 10, 20}, order)
+	})
+
+	t.Run("returns ErrBuddyNotFound when buddy missing", func(t *testing.T) {
+		fl := NewFeedbagList([]wire.FeedbagItem{
+			{Name: "Buddies", ClassID: wire.FeedbagClassIdGroup, GroupID: 1},
+		}, nil)
+		err := fl.MoveBuddy("Buddies", "", "ghost", "")
+		assert.ErrorIs(t, err, ErrBuddyNotFound)
+	})
+
+	t.Run("returns ErrGroupNotFound when destination missing", func(t *testing.T) {
+		fl := NewFeedbagList([]wire.FeedbagItem{
+			{Name: "Buddies", ClassID: wire.FeedbagClassIdGroup, GroupID: 1},
+			{Name: "alice", ClassID: wire.FeedbagClassIdBuddy, GroupID: 1, ItemID: 10},
+		}, nil)
+		err := fl.MoveBuddy("Buddies", "Nowhere", "alice", "")
+		assert.ErrorIs(t, err, ErrGroupNotFound)
+	})
+}
+
+func TestFeedbagList_SetBuddyAlias(t *testing.T) {
+	t.Run("sets alias on all matching buddies", func(t *testing.T) {
+		fl := NewFeedbagList([]wire.FeedbagItem{
+			{Name: "alice", ClassID: wire.FeedbagClassIdBuddy, GroupID: 1, ItemID: 10},
+			{Name: "alice", ClassID: wire.FeedbagClassIdBuddy, GroupID: 2, ItemID: 20},
+		}, nil)
+
+		found, err := fl.SetBuddyAlias("alice", "Al")
+		assert.NoError(t, err)
+		assert.True(t, found)
+
+		upserts := fl.PendingUpdates()
+		assert.Len(t, upserts, 2)
+		for _, item := range upserts {
+			alias, ok := item.Bytes(wire.FeedbagAttributesAlias)
+			assert.True(t, ok)
+			assert.Equal(t, []byte("Al"), alias)
+		}
+	})
+
+	t.Run("clears alias when empty", func(t *testing.T) {
+		fl := NewFeedbagList([]wire.FeedbagItem{
+			{Name: "alice", ClassID: wire.FeedbagClassIdBuddy, GroupID: 1, ItemID: 10,
+				TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{
+					wire.NewTLVBE(wire.FeedbagAttributesAlias, "Al"),
+				}}},
+		}, nil)
+
+		found, err := fl.SetBuddyAlias("alice", "")
+		assert.NoError(t, err)
+		assert.True(t, found)
+
+		upserts := fl.PendingUpdates()
+		assert.Len(t, upserts, 1)
+		assert.False(t, upserts[0].HasTag(wire.FeedbagAttributesAlias))
+	})
+
+	t.Run("returns false when buddy not found", func(t *testing.T) {
+		fl := NewFeedbagList(nil, nil)
+		found, err := fl.SetBuddyAlias("ghost", "X")
+		assert.NoError(t, err)
+		assert.False(t, found)
+		assert.Nil(t, fl.PendingUpdates())
+	})
+}
+
+func TestFeedbagList_SetGroupCollapsed(t *testing.T) {
+	t.Run("sets collapsed attribute", func(t *testing.T) {
+		fl := NewFeedbagList([]wire.FeedbagItem{
+			{Name: "Coworkers", ClassID: wire.FeedbagClassIdGroup, GroupID: 1},
+		}, nil)
+
+		err := fl.SetGroupCollapsed("Coworkers", true)
+		assert.NoError(t, err)
+
+		upserts := fl.PendingUpdates()
+		assert.Len(t, upserts, 1)
+		assert.True(t, upserts[0].HasTag(wire.FeedbagAttributesCollapsed))
+	})
+
+	t.Run("clears collapsed attribute", func(t *testing.T) {
+		fl := NewFeedbagList([]wire.FeedbagItem{
+			{Name: "Coworkers", ClassID: wire.FeedbagClassIdGroup, GroupID: 1,
+				TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{
+					wire.NewTLVBE(wire.FeedbagAttributesCollapsed, []byte{}),
+				}}},
+		}, nil)
+
+		err := fl.SetGroupCollapsed("Coworkers", false)
+		assert.NoError(t, err)
+
+		upserts := fl.PendingUpdates()
+		assert.Len(t, upserts, 1)
+		assert.False(t, upserts[0].HasTag(wire.FeedbagAttributesCollapsed))
+	})
+
+	t.Run("targets unnamed default group, not the root group", func(t *testing.T) {
+		fl := NewFeedbagList([]wire.FeedbagItem{
+			{Name: "", ClassID: wire.FeedbagClassIdGroup, GroupID: 0},
+			{Name: "", ClassID: wire.FeedbagClassIdGroup, GroupID: 3},
+		}, nil)
+
+		err := fl.SetGroupCollapsed("", true)
+		assert.NoError(t, err)
+
+		upserts := fl.PendingUpdates()
+		assert.Len(t, upserts, 1)
+		assert.Equal(t, uint16(3), upserts[0].GroupID)
+		assert.True(t, upserts[0].HasTag(wire.FeedbagAttributesCollapsed))
+	})
+
+	t.Run("returns ErrGroupNotFound for missing group", func(t *testing.T) {
+		fl := NewFeedbagList(nil, nil)
+		err := fl.SetGroupCollapsed("Nope", true)
+		assert.ErrorIs(t, err, ErrGroupNotFound)
+	})
+}