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

webapi: store preferences in feedbag

Mike 2 недель назад
Родитель
Сommit
db520ea8fc

+ 0 - 2
cmd/server/factory.go

@@ -582,8 +582,6 @@ func WebAPI(deps Container) *webapi.Server {
 		SessionRetriever: deps.inMemorySessionManager,
 		// Phase 2 additions
 		BuddyBroadcaster: oscarBuddyBroadcaster,
-		// Phase 3 additions
-		PreferenceManager: deps.sqLiteUserStore.NewWebPreferenceManager(),
 		// Phase 4 additions for OSCAR Bridge
 		OSCARBridgeStore: deps.sqLiteUserStore.NewOSCARBridgeStore(),
 		OSCARConfig:      webapi.NewOSCARConfigAdapter(deps.cfg),

+ 4 - 117
foodgroup/feedbag.go

@@ -1023,124 +1023,11 @@ func setSessionBuddyPrefs(items []wire.FeedbagItem, instance *state.SessionInsta
 	}
 }
 
-// FeedbagBuddyPref returns a pref value stored in the user's feedbag.
-//
-// Preferences are binary values stored in a logical bitmask spanning 2
-// physical bitmasks. Each preference value is a position in the logical
-// bitmask.
-//
-// The first bitmask (BuddyPrefs) is fixed-length of 32 bits (4 bytes).
-// It's 0-offset: pref 1 is at offset 1, pref 2 at offset 2, etc. The most
-// significant bit is on the right side.
-//
-// The second bitmask (BuddyPrefs2) is of an unbounded length. The values
-// are at a position relative to the beginning at BuddyPrefs1. The most
-// significant bit is on the left side.
-//
-// Items 1-31 are located in BuddyPrefs:
-//
-//	Item #1:
-//	00000000 00000000 00000000 00000010 (BuddyPrefs)
-//	                                 ^ offset 1, bit 2
-//	00000000 00000000 00000000 00000000 (BuddyPrefs2)
-//
-//	Item #31:
-//	10000000 00000000 00000000 00000000 (BuddyPrefs)
-//	^ offset 31, bit 32
-//	00000000 00000000 00000000 00000000 (BuddyPrefs2)
-//
-// Items 32+ are located in BuddyPrefs. To find the offset, calculate (Item #)-33.
-// For example, item 52 is located at offset 19.
-//
-//	Item #52:
-//	00000000 00000000 00000000 00000000 (BuddyPrefs)
-//	00000000 00000000 00010000 00000000 (BuddyPrefs2)
-//	                     ^ offset 19, bit 52
-//
-// There is a weird edge case for items 32 and 33 that is either a bug caused
-// by the transition from offset to positional-based indexing, or a
-// misunderstanding on my part: both items fall under offset 0 in BuddyPrefs2.
-//
-//	Item #32:
-//	00000000 00000000 00000000 00000000 (BuddyPrefs)
-//	10000000 00000000 00000000 00000000 (BuddyPrefs2)
-//	^ offset 0, bit 33
-//
-//	Item #33:
-//	00000000 00000000 00000000 00000000 (BuddyPrefs)
-//	10000000 00000000 00000000 00000000 (BuddyPrefs2)
-//	^ offset 0, bit 33
-//
-// For each logical bitmask, there are 2 physical bitmasks. The first contains
-// the set values, and the second contains the valid bitmask positions. I guess
-// this was done to remove ambiguity about an unset position: i.e. does an unset
-// value mean false or null?
-//
-// The bitmasks are present in 4 TLVs:
-//
-// - 0x00C9: FeedbagAttributesBuddyPrefs
-// - 0x00D6: FeedbagAttributesBuddyPrefsValid
-// - 0x00D7: FeedbagAttributesBuddyPrefs2
-// - 0x00D8: FeedbagAttributesBuddyPrefs2Valid
-//
-// For a given item, this function returns whether the preference number is
-// available in the bitmask (valid) and what the value of it is (value).
+// feedbagBuddyPref returns whether preference prefNum is present in the user's
+// feedbag buddy-prefs bitmask (valid) and its boolean value. See wire.BuddyPref
+// for the bitmask layout.
 func feedbagBuddyPref(prefNum uint16, list wire.TLVList) (valid bool, value bool) {
-	offset := int(prefNum)
-
-	// value is in BuddyPrefs; the most significant bit is on the right side
-	if offset < 32 {
-		buddyPrefValid, ok := list.Bytes(wire.FeedbagAttributesBuddyPrefsValid)
-		if !ok {
-			return false, false
-		}
-		buddyPrefEnabled, ok := list.Bytes(wire.FeedbagAttributesBuddyPrefs)
-		if !ok {
-			return false, false
-		}
-
-		index := (len(buddyPrefValid) - 1) - (offset / 8)
-		if index >= len(buddyPrefValid) || index >= len(buddyPrefEnabled) {
-			return false, false
-		}
-
-		bitOffset := offset % 8
-		mask := byte(1 << bitOffset)
-
-		valid = buddyPrefValid[index]&mask != 0
-		value = buddyPrefEnabled[index]&mask != 0
-
-		return valid, value
-	}
-
-	// value is in BuddyPrefs2; the most significant bit is on the left side
-	if prefNum == 32 {
-		offset = 0 // account for transition from offset-based to position-based
-	} else {
-		offset -= 33
-	}
-
-	buddyPrefValid, ok := list.Bytes(wire.FeedbagAttributesBuddyPrefs2Valid)
-	if !ok {
-		return false, false
-	}
-	buddyPrefEnabled, ok := list.Bytes(wire.FeedbagAttributesBuddyPrefs2)
-	if !ok {
-		return false, false
-	}
-
-	index := offset / 8
-	if index >= len(buddyPrefValid) || index >= len(buddyPrefEnabled) {
-		return false, false
-	}
-
-	bitOffset := offset % 8
-	mask := byte(0x80) >> bitOffset
-
-	valid = buddyPrefValid[index]&mask != 0
-	value = buddyPrefEnabled[index]&mask != 0
-
-	return valid, value
+	return wire.BuddyPref(list, prefNum)
 }
 
 // ForwardICQAuthEvents converts ICQ channel-4 payloads to feedbag SNACs and

+ 0 - 2
server/webapi/handler.go

@@ -22,8 +22,6 @@ type Handler struct {
 	SessionRetriever SessionRetriever
 	// Phase 2 additions
 	BuddyBroadcaster BuddyBroadcaster
-	// Phase 3 additions
-	PreferenceManager PreferenceManager
 	// Phase 4 additions for OSCAR Bridge
 	OSCARBridgeStore OSCARBridgeStore
 	OSCARConfig      OSCARConfig

+ 232 - 149
server/webapi/handlers/preference.go

@@ -2,28 +2,118 @@ package handlers
 
 import (
 	"context"
+	"fmt"
 	"log/slog"
 	"math/rand"
 	"net/http"
-	"strconv"
 	"strings"
 
+	"github.com/mk6i/open-oscar-server/server/webapi/types"
 	"github.com/mk6i/open-oscar-server/state"
 	"github.com/mk6i/open-oscar-server/wire"
 )
 
 // PreferenceHandler handles Web AIM API preference-related endpoints.
 type PreferenceHandler struct {
-	FeedbagService    FeedbagService
-	SessionManager    *state.WebAPISessionManager
-	PreferenceManager PreferenceManager
-	Logger            *slog.Logger
+	FeedbagService FeedbagService
+	SessionManager *state.WebAPISessionManager
+	Logger         *slog.Logger
 }
 
-// PreferenceManager provides methods to manage user preferences.
-type PreferenceManager interface {
-	SetPreferences(ctx context.Context, screenName state.IdentScreenName, prefs map[string]interface{}) error
-	GetPreferences(ctx context.Context, screenName state.IdentScreenName) (map[string]interface{}, error)
+// buddyPref maps a Web AIM API preference to its OSCAR buddy-pref bit number
+// and the default value defined by the Web API spec.
+type buddyPref struct {
+	num uint16
+	def bool
+}
+
+// webBuddyPrefs maps Web AIM API preference names to OSCAR buddy prefs, which
+// are stored as a bitmask in the user's feedbag (see wire.BuddyPref). Pref
+// numbers 0x07, 0x13, and 0x17 are reserved/unused and intentionally absent.
+var webBuddyPrefs = map[string]buddyPref{
+	"displayLogin":                {wire.FeedbagBuddyPrefsDisplayLogin, true},
+	"displayEBuddy":               {wire.FeedbagBuddyPrefsDisplayEBuddy, true},
+	"playEnter":                   {wire.FeedbagBuddyPrefsPlayEnter, true},
+	"playExit":                    {wire.FeedbagBuddyPrefsPlayExit, true},
+	"viewIMTimestamps":            {wire.FeedbagBuddyPrefsViewIMStamp, true},
+	"viewSmilies":                 {wire.FeedbagBuddyPrefsViewSmileys, true},
+	"acceptIcons":                 {wire.FeedbagBuddyPrefsAcceptIcons, true},
+	"knockNonAOLIMs":              {wire.FeedbagBuddyPrefsKnockNonAOLIMs, true},
+	"knockNonListIMs":             {wire.FeedbagBuddyPrefsKnockNonListIMs, true},
+	"discloseIdle":                {wire.FeedbagBuddyPrefsDiscloseIdle, true},
+	"acceptCustomBart":            {wire.FeedbagBuddyPrefsAcceptCustomBart, false},
+	"acceptNonListBart":           {wire.FeedbagBuddyPrefsAcceptNonListBart, false},
+	"acceptBgs":                   {wire.FeedbagBuddyPrefsAcceptBgs, true},
+	"acceptChromes":               {wire.FeedbagBuddyPrefsAcceptChromes, true},
+	"acceptBLSounds":              {wire.FeedbagBuddyPrefsAcceptBLSounds, true},
+	"acceptIMsounds":              {wire.FeedbagBuddyPrefsAcceptIMSounds, true},
+	"noSeeRecentBuddies":          {wire.FeedbagBuddyPrefsNoSeeRecentBuddies, false},
+	"acceptSMSLegal":              {wire.FeedbagBuddyPrefsAcceptSMSLegal, false},
+	"enterDoesCRLF":               {wire.FeedbagBuddyPrefsEnterDoesCRLF, false},
+	"playIMSound":                 {wire.FeedbagBuddyPrefsPlayIMSound, true},
+	"discloseTyping":              {wire.FeedbagBuddyPrefsDiscloseTyping, true},
+	"acceptSuperIcons":            {wire.FeedbagBuddyPrefsAcceptSuperIcons, true},
+	"acceptBLRichText":            {wire.FeedbagBuddyPrefsAcceptBLRichText, true},
+	"reduceIMSound":               {wire.FeedbagBuddyPrefsReduceIMSound, true},
+	"confirmDirectIM":             {wire.FeedbagBuddyPrefsConfirmDirectIM, true},
+	"oneTabbedIMWindow":           {wire.FeedbagBuddyPrefsOneTabbedIMWindow, true},
+	"buddyInfoOnMouseover":        {wire.FeedbagBuddyPrefsBuddyInfoOnMouseover, true},
+	"discloseBuddyMatches":        {wire.FeedbagBuddyPrefsDiscloseBuddyMatches, true},
+	"catchIMs":                    {wire.FeedbagBuddyPrefsCatchIMs, false},
+	"showFriendlyName":            {wire.FeedbagBuddyPrefsShowFriendlyName, true},
+	"discloseRadio":               {wire.FeedbagBuddyPrefsDiscloseRadio, true},
+	"showCapabilities":            {wire.FeedbagBuddyPrefsShowCapabilities, true},
+	"showBuddyListFilter":         {wire.FeedbagBuddyPrefsShowBuddyListFilter, true},
+	"showAwayIdle":                {wire.FeedbagBuddyPrefsShowAwayIdle, true},
+	"showMobile":                  {wire.FeedbagBuddyPrefsShowMobile, true},
+	"sortBuddyList":               {wire.FeedbagBuddyPrefsSortBuddyList, false},
+	"catchIMsForClient":           {wire.FeedbagBuddyPrefsCatchIMsForClient, false},
+	"newMessageSmallNotification": {wire.FeedbagBuddyPrefsNewMessageSmallNotify, true},
+	"noFrequentBuddies":           {wire.FeedbagBuddyPrefsNoFrequentBuddies, false},
+	"blogAwayMessages":            {wire.FeedbagBuddyPrefsBlogAwayMessages, false},
+	"blogAIMSigMessages":          {wire.FeedbagBuddyPrefsBlogAIMSigMessages, false},
+	"blogNoComments":              {wire.FeedbagBuddyPrefsBlogNoComments, false},
+	"friendOfFriend":              {wire.FeedbagBuddyPrefsFriendOfFriend, false},
+	"friendGetContactList":        {wire.FeedbagBuddyPrefsFriendGetContactList, false},
+	"compadInit":                  {wire.FeedbagBuddyPrefsCompadInit, false},
+	"sendBuddyFeed":               {wire.FeedbagBuddyPrefsSendBuddyFeed, true},
+	"blkSendIMWhileAway":          {wire.FeedbagBuddyPrefsBlkSendIMWhileAway, false},
+	"showBuddyFeed":               {wire.FeedbagBuddyPrefsShowBuddyFeed, true},
+	"noSaveVanityInfo":            {wire.FeedbagBuddyPrefsNoSaveVanityInfo, false},
+	"acceptOffLineIM":             {wire.FeedbagBuddyPrefsAcceptOfflineIM, true},
+	"showGroups":                  {wire.FeedbagBuddyPrefsShowGroups, false},
+	"sortGroup":                   {wire.FeedbagBuddyPrefsSortGroup, true},
+	"showOffLineBuddies":          {wire.FeedbagBuddyPrefsShowOfflineBuddies, true},
+	"expandBuddies":               {wire.FeedbagBuddyPrefsExpandBuddies, false},
+	"thirdPartyFeeds":             {wire.FeedbagBuddyPrefsThirdPartyFeeds, false},
+	"notifyReceivedInvite":        {wire.FeedbagBuddyPrefsNotifyReceivedInvite, true},
+	"apfAutoAccept":               {wire.FeedbagBuddyPrefsApfAutoAccept, false},
+	"apfAutoAcceptBuddy":          {wire.FeedbagBuddyPrefsApfAutoAcceptBuddy, false},
+	"blockAwayMsgFeed":            {wire.FeedbagBuddyPrefsBlockAwayMsgFeed, false},
+	"blockAIMProfileFeed":         {wire.FeedbagBuddyPrefsBlockAIMProfileFeed, false},
+	"blockAIMPagesFeed":           {wire.FeedbagBuddyPrefsBlockAIMPagesFeed, false},
+	"blockJournalsFeed":           {wire.FeedbagBuddyPrefsBlockJournalsFeed, false},
+	"blockLocationFeed":           {wire.FeedbagBuddyPrefsBlockLocationFeed, false},
+	"blockStickiesFeed":           {wire.FeedbagBuddyPrefsBlockStickiesFeed, false},
+	"blockUncutFeed":              {wire.FeedbagBuddyPrefsBlockUncutFeed, false},
+	"blockLinksFeed":              {wire.FeedbagBuddyPrefsBlockLinksFeed, false},
+	"blockAIMBulletinFeed":        {wire.FeedbagBuddyPrefsBlockAIMBulletinFeed, false},
+	"saveStatusMsg":               {wire.FeedbagBuddyPrefsSaveStatusMsg, true},
+	// Not in the spec Preferences enum, but sent by the web client.
+	"apfNotifyReceivedInviteByEmail": {wire.FeedbagBuddyPrefsApfNotifyReceivedByEmail, false},
+	"showOfflineGrp":                 {wire.FeedbagBuddyPrefsShowOfflineGrp, true},
+	"offlineGrpCollapsed":            {wire.FeedbagBuddyPrefsOfflineGrpCollapsed, false},
+	"firstImSoundOnly":               {wire.FeedbagBuddyPrefsFirstIMSoundOnly, false},
+	"imblastInviteNotify":            {wire.FeedbagBuddyPrefsImblastInviteNotify, true},
+
+	// Web-client-only preferences with no OSCAR buddy-pref equivalent. OSCAR
+	// defines prefs through 0x4B, so we persist these in the same feedbag
+	// buddy-prefs bitmask at positions above that range; no real OSCAR client
+	// reads or writes these bits.
+	"viewIMsInBubbles":           {wire.FeedbagBuddyPrefsViewIMsInBubbles, true},
+	"viewIMTimestampsRelative":   {wire.FeedbagBuddyPrefsViewIMTimestampsRelative, false},
+	"globalOTR":                  {wire.FeedbagBuddyPrefsGlobalOTR, false},
+	"imblastInviteFromBuddyOnly": {wire.FeedbagBuddyPrefsImblastInviteFromBuddyOnly, false},
 }
 
 // PermitDenyData contains permit/deny list information.
@@ -56,60 +146,58 @@ func (h *PreferenceHandler) SetPreferences(w http.ResponseWriter, r *http.Reques
 		h.Logger.WarnContext(ctx, "failed to touch session", "aimsid", aimsid, "error", err)
 	}
 
-	// Parse preferences from query parameters
-	prefs := make(map[string]interface{})
+	// Preferences are stored as OSCAR buddy prefs in the feedbag, which requires
+	// an OSCAR session to act on behalf of.
+	instance := session.OSCARSession
+	if instance == nil {
+		h.sendError(w, http.StatusBadRequest, "no OSCAR session")
+		return
+	}
 
-	// Common preference keys from the Web AIM API spec
-	prefKeys := []string{
-		"statusMsg", "awayMsg", "profileMsg", "buddyIcon",
-		"soundsOn", "alertsOn", "typingStatus", "idleTime",
-		"pdMode", "invisibleTo", "visibleTo", "blockList",
-		"allowList", "language", "timeZone", "dateFormat",
-		"showTimestamps", "fontSize", "fontFamily", "theme",
-		"autoResponse", "saveHistory", "encryptMessages",
-	}
-
-	// Extract preferences from query parameters
-	for _, key := range prefKeys {
-		if val := r.URL.Query().Get(key); val != "" {
-			// Try to parse as boolean
-			if val == "true" || val == "false" {
-				prefs[key] = val == "true"
-			} else if num, err := strconv.Atoi(val); err == nil {
-				// Try to parse as integer
-				prefs[key] = num
-			} else {
-				// Store as string
-				prefs[key] = val
-			}
-		}
+	// Read-modify-write the buddy-prefs item so bits the web client doesn't
+	// manage (e.g. the typing-events bit consumed by the OSCAR session) survive.
+	item, err := buddyPrefsItem(ctx, h.FeedbagService, instance)
+	if err != nil {
+		h.Logger.ErrorContext(ctx, "failed to retrieve feedbag", "err", err.Error())
+		h.sendError(w, http.StatusInternalServerError, "failed to retrieve feedbag")
+		return
 	}
 
-	// Allow any other parameters starting with "pref_" for extensibility
-	for key, values := range r.URL.Query() {
-		if strings.HasPrefix(key, "pref_") && len(values) > 0 {
-			actualKey := strings.TrimPrefix(key, "pref_")
-			prefs[actualKey] = values[0]
+	applied := make(map[string]interface{})
+	for name, pref := range webBuddyPrefs {
+		val := r.URL.Query().Get(name)
+		if val == "" {
+			continue
 		}
+		on := parseBoolPref(val)
+		item.TLVList = wire.SetBuddyPref(item.TLVList, pref.num, on)
+		applied[name] = boolToPrefInt(on)
 	}
 
-	// Save preferences
-	if err := h.PreferenceManager.SetPreferences(ctx, session.ScreenName.IdentScreenName(), prefs); err != nil {
-		h.Logger.ErrorContext(ctx, "failed to set preferences", "err", err.Error())
-		h.sendError(w, http.StatusInternalServerError, "failed to save preferences")
-		return
+	if len(applied) > 0 {
+		frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagInsertItem}
+		if _, err := h.FeedbagService.UpsertItem(ctx, instance, frame, []wire.FeedbagItem{item}); err != nil {
+			h.Logger.ErrorContext(ctx, "failed to set preferences", "err", err.Error())
+			h.sendError(w, http.StatusInternalServerError, "failed to save preferences")
+			return
+		}
+
+		// Notify the client's open windows via the event stream so display
+		// changes (e.g. bubbles/classic) take effect immediately without a
+		// browser refresh.
+		session.EventQueue.Push(types.EventTypePreference, applied)
 	}
 
 	h.Logger.DebugContext(ctx, "preferences updated",
 		"screenName", session.ScreenName.String(),
-		"prefCount", len(prefs),
+		"prefCount", len(applied),
 	)
 
 	// Send success response
 	response := BaseResponse{}
 	response.Response.StatusCode = 200
 	response.Response.StatusText = "OK"
-	response.Response.Data = prefs
+	response.Response.Data = applied
 	SendResponse(w, r, response, h.Logger)
 }
 
@@ -136,103 +224,60 @@ func (h *PreferenceHandler) GetPreferences(w http.ResponseWriter, r *http.Reques
 		h.Logger.WarnContext(ctx, "failed to touch session", "aimsid", aimsid, "error", err)
 	}
 
-	// Get target user (optional, defaults to session user)
-	targetUser := session.ScreenName.IdentScreenName()
-	if t := r.URL.Query().Get("t"); t != "" {
-		targetUser = state.NewIdentScreenName(t)
-	}
-
-	// Get all stored preferences or defaults
-	allPrefs, err := h.PreferenceManager.GetPreferences(ctx, targetUser)
-	if err != nil {
-		h.Logger.ErrorContext(ctx, "failed to get preferences", "err", err.Error())
-		allPrefs = h.getDefaultPreferences()
-	}
-	if len(allPrefs) == 0 {
-		allPrefs = h.getDefaultPreferences()
+	// Load the buddy-prefs bitmask from the feedbag. Absent prefs fall back to
+	// the spec default. Web-only sessions resolve entirely to defaults.
+	var prefsList wire.TLVList
+	if instance := session.OSCARSession; instance != nil {
+		if item, err := buddyPrefsItem(ctx, h.FeedbagService, instance); err != nil {
+			h.Logger.WarnContext(ctx, "failed to get preferences", "err", err.Error())
+		} else {
+			prefsList = item.TLVList
+		}
 	}
 
-	// Check if specific preferences are being requested
+	// When specific preferences are named in the query (e.g. playIMSound=1), the
+	// client is selecting those; otherwise return all preferences.
 	requestedPrefs := make(map[string]interface{})
-	defaultPrefs := h.getDefaultPreferences()
-
-	// Check each known preference key in the query parameters
-	// When a preference appears in the query (e.g., playIMSound=1),
-	// the client is requesting that specific preference value
-	for key := range defaultPrefs {
-		if r.URL.Query().Has(key) {
-			// Client is requesting this specific preference
-			if prefValue, exists := allPrefs[key]; exists {
-				requestedPrefs[key] = prefValue
-			} else {
-				requestedPrefs[key] = defaultPrefs[key]
-			}
+	for name, pref := range webBuddyPrefs {
+		if r.URL.Query().Has(name) {
+			requestedPrefs[name] = effectivePrefValue(prefsList, pref)
 		}
 	}
 
-	// If no specific preferences were requested, return all
 	var prefs map[string]interface{}
 	if len(requestedPrefs) > 0 {
 		prefs = requestedPrefs
 	} else {
-		prefs = allPrefs
+		prefs = make(map[string]interface{}, len(webBuddyPrefs))
+		for name, pref := range webBuddyPrefs {
+			prefs[name] = effectivePrefValue(prefsList, pref)
+		}
 	}
 
 	h.Logger.DebugContext(ctx, "preferences retrieved",
-		"screenName", targetUser.String(),
+		"screenName", session.ScreenName.String(),
 		"prefCount", len(prefs),
 		"requested", len(requestedPrefs) > 0,
 	)
 
-	// Check for AMF format to handle special Gromit compatibility requirements
+	// AMF clients (e.g. Gromit) expect the payload shaped a specific way. Pref
+	// values are already numeric 0/1, which is what these clients expect.
 	format := strings.ToLower(r.URL.Query().Get("f"))
 	if format == "amf" || format == "amf3" {
-		// Convert string "1"/"0" to numeric values for Gromit compatibility
-		// Gromit expects numeric values for boolean preferences
-		convertedPrefs := make(map[string]interface{})
-		for key, val := range prefs {
-			if strVal, ok := val.(string); ok {
-				switch strVal {
-				case "1":
-					convertedPrefs[key] = 1
-				case "0":
-					convertedPrefs[key] = 0
-				default:
-					// Keep non-boolean values as strings
-					convertedPrefs[key] = val
-				}
-			} else {
-				convertedPrefs[key] = val
-			}
+		// Ensure prefs is never empty for Gromit.
+		if len(prefs) == 0 {
+			prefs = map[string]interface{}{"playIMSound": 1}
+		}
+		// A single preference is returned directly; multiple are wrapped in
+		// jsonData for Gromit compatibility.
+		if len(prefs) != 1 {
+			prefs = map[string]interface{}{"jsonData": prefs}
 		}
-		prefs = convertedPrefs
 
 		h.Logger.DebugContext(ctx, "AMF preference response",
-			"prefs", prefs,
 			"prefCount", len(prefs),
 			"format", format,
 		)
-
-		// Ensure prefs is never nil or empty for Gromit
-		if len(prefs) == 0 {
-			// If no preferences found, at least return the requested ones with defaults
-			if len(requestedPrefs) > 0 {
-				prefs = requestedPrefs
-			} else {
-				// Return playIMSound as default if nothing else
-				prefs = map[string]interface{}{
-					"playIMSound": 1,
-				}
-			}
-		}
-
-		// For single preference requests, return directly for Gromit compatibility
-		// For multiple preferences, wrap in jsonData
-		if len(prefs) != 1 {
-			prefs = map[string]interface{}{
-				"jsonData": prefs,
-			}
-		}
 	}
 
 	// Send response in requested format
@@ -243,6 +288,74 @@ func (h *PreferenceHandler) GetPreferences(w http.ResponseWriter, r *http.Reques
 	SendResponse(w, r, response, h.Logger)
 }
 
+// buddyPrefsItem returns the user's buddy-prefs feedbag item, creating a fresh
+// (empty) one if the feedbag does not have it yet.
+func buddyPrefsItem(ctx context.Context, fs FeedbagService, instance *state.SessionInstance) (wire.FeedbagItem, error) {
+	frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagQuery}
+	fb, err := fs.Query(ctx, instance, frame)
+	if err != nil {
+		return wire.FeedbagItem{}, err
+	}
+	reply, ok := fb.Body.(wire.SNAC_0x13_0x06_FeedbagReply)
+	if !ok {
+		return wire.FeedbagItem{}, fmt.Errorf("unexpected feedbag reply type")
+	}
+	for _, item := range reply.Items {
+		if item.ClassID == wire.FeedbagClassIdBuddyPrefs {
+			return item, nil
+		}
+	}
+	// No buddy-prefs item yet; create one. Only a single item of this class is
+	// allowed, with an empty name and no group.
+	return wire.FeedbagItem{
+		ClassID: wire.FeedbagClassIdBuddyPrefs,
+		ItemID:  uint16(rand.Intn(0xFFFF)),
+	}, 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{})
+	for name, pref := range webBuddyPrefs {
+		if valid, val := wire.BuddyPref(list, pref.num); valid {
+			prefs[name] = boolToPrefInt(val)
+		}
+	}
+	return prefs
+}
+
+// effectivePrefValue returns the 0/1 value for pref, using the feedbag value
+// when present and the spec default otherwise. Values are emitted as numbers
+// (not "1"/"0" strings) because the web client evaluates them with JavaScript
+// truthiness/numeric comparisons, where the string "0" is truthy.
+func effectivePrefValue(list wire.TLVList, pref buddyPref) int {
+	valid, val := wire.BuddyPref(list, pref.num)
+	if !valid {
+		val = pref.def
+	}
+	return boolToPrefInt(val)
+}
+
+// parseBoolPref interprets a Web API preference query value as a boolean.
+func parseBoolPref(v string) bool {
+	switch strings.ToLower(v) {
+	case "1", "true", "yes", "on":
+		return true
+	default:
+		return false
+	}
+}
+
+func boolToPrefInt(b bool) int {
+	if b {
+		return 1
+	}
+	return 0
+}
+
 // SetPermitDeny handles GET /preference/setPermitDeny requests to update permit/deny settings.
 func (h *PreferenceHandler) SetPermitDeny(w http.ResponseWriter, r *http.Request) {
 	ctx := r.Context()
@@ -456,36 +569,6 @@ func (h *PreferenceHandler) GetPermitDeny(w http.ResponseWriter, r *http.Request
 	SendResponse(w, r, response, h.Logger)
 }
 
-// sendError sends an error response in Web AIM API format.
-// getDefaultPreferences returns default preference values that clients expect.
-func (h *PreferenceHandler) getDefaultPreferences() map[string]interface{} {
-	return map[string]interface{}{
-		"autoPlay":            "1",
-		"playIMSound":         "1",
-		"playBuddySound":      "1",
-		"showTimestamps":      "1",
-		"showAdsFlag":         "1",
-		"soundSetting":        "1",
-		"awayMessageOn":       "0",
-		"awayMessage":         "",
-		"confirmSignOff":      "0",
-		"skipNavigator":       "1",
-		"displayIdleTime":     "1",
-		"repliesAnyone":       "0",
-		"repliesUsersOnline":  "0",
-		"repliesBuddies":      "0",
-		"replyMessage":        "",
-		"allowAccessPresence": "0",
-		"blockIdleStatus":     "0",
-		"reportIdleTyping":    "1",
-		"smileysDisabled":     "0",
-		"sortBuddiesAlpha":    "0",
-		"statusMsg":           "",
-		"statusIcon":          "",
-		"skin":                "default",
-	}
-}
-
 func (h *PreferenceHandler) sendError(w http.ResponseWriter, statusCode int, message string) {
 	// todo log
 	SendError(w, statusCode, message)

+ 190 - 0
server/webapi/handlers/preference_test.go

@@ -0,0 +1,190 @@
+package handlers
+
+import (
+	"log/slog"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/mock"
+
+	"github.com/mk6i/open-oscar-server/state"
+	"github.com/mk6i/open-oscar-server/wire"
+)
+
+// buddyPrefsFeedbag builds a feedbag reply carrying a single buddy-prefs item
+// with the given prefs pre-set.
+func buddyPrefsFeedbag(prefs map[uint16]bool) wire.SNACMessage {
+	var list wire.TLVList
+	for num, val := range prefs {
+		list = wire.SetBuddyPref(list, num, val)
+	}
+	return wire.SNACMessage{
+		Body: wire.SNAC_0x13_0x06_FeedbagReply{
+			Items: []wire.FeedbagItem{
+				{ClassID: wire.FeedbagClassIdBuddyPrefs, ItemID: 1, TLVLBlock: wire.TLVLBlock{TLVList: list}},
+			},
+		},
+	}
+}
+
+func TestPreferenceHandler_SetPreferences(t *testing.T) {
+	fs := &MockFeedbagService{}
+	oscarInstance := state.NewSession().AddInstance()
+	sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
+
+	// Existing feedbag already has displayLogin (0x00) enabled; it must survive.
+	fs.On("Query", mock.Anything, oscarInstance, mock.Anything).
+		Return(buddyPrefsFeedbag(map[uint16]bool{0x00: true}), nil)
+
+	var upserted []wire.FeedbagItem
+	fs.On("UpsertItem", mock.Anything, oscarInstance, mock.Anything, mock.Anything).
+		Run(func(args mock.Arguments) { upserted = args.Get(3).([]wire.FeedbagItem) }).
+		Return(nil, nil)
+
+	handler := &PreferenceHandler{
+		SessionManager: sessionMgr,
+		FeedbagService: fs,
+		Logger:         slog.Default(),
+	}
+
+	req, _ := http.NewRequest("GET", "/preference/set?aimsid="+aimsid+"&playIMSound=0&discloseTyping=1", nil)
+	rr := httptest.NewRecorder()
+	handler.SetPreferences(rr, req)
+
+	assert.Equal(t, http.StatusOK, rr.Code)
+	if assert.Len(t, upserted, 1) {
+		item := upserted[0]
+		assert.Equal(t, wire.FeedbagClassIdBuddyPrefs, item.ClassID)
+
+		assertPref := func(num uint16, wantValid, wantValue bool) {
+			valid, value := wire.BuddyPref(item.TLVList, num)
+			assert.Equalf(t, wantValid, valid, "pref 0x%02x valid", num)
+			assert.Equalf(t, wantValue, value, "pref 0x%02x value", num)
+		}
+		assertPref(0x00, true, true)  // preserved
+		assertPref(0x15, true, false) // playIMSound off
+		assertPref(0x16, true, true)  // discloseTyping on
+	}
+	fs.AssertExpectations(t)
+}
+
+func TestPreferenceHandler_GetPreferences_Selected(t *testing.T) {
+	fs := &MockFeedbagService{}
+	oscarInstance := state.NewSession().AddInstance()
+	sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
+
+	// playIMSound (0x15) explicitly disabled in the feedbag.
+	fs.On("Query", mock.Anything, oscarInstance, mock.Anything).
+		Return(buddyPrefsFeedbag(map[uint16]bool{0x15: false}), nil)
+
+	handler := &PreferenceHandler{
+		SessionManager: sessionMgr,
+		FeedbagService: fs,
+		Logger:         slog.Default(),
+	}
+
+	// Request two prefs: playIMSound (stored=false) and acceptIcons (unset -> default true).
+	req, _ := http.NewRequest("GET", "/preference/get?aimsid="+aimsid+"&playIMSound&acceptIcons", nil)
+	rr := httptest.NewRecorder()
+	handler.GetPreferences(rr, req)
+
+	assert.Equal(t, http.StatusOK, rr.Code)
+	body := rr.Body.String()
+	assert.Contains(t, body, `"playIMSound":0`)
+	assert.Contains(t, body, `"acceptIcons":1`)
+	// Only the two requested prefs should be present.
+	assert.NotContains(t, body, `"displayLogin"`)
+}
+
+func TestPreferenceHandler_GetPreferences_All(t *testing.T) {
+	fs := &MockFeedbagService{}
+	oscarInstance := state.NewSession().AddInstance()
+	sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
+
+	// Empty feedbag -> every pref resolves to its spec default.
+	fs.On("Query", mock.Anything, oscarInstance, mock.Anything).
+		Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{}}, nil)
+
+	handler := &PreferenceHandler{
+		SessionManager: sessionMgr,
+		FeedbagService: fs,
+		Logger:         slog.Default(),
+	}
+
+	req, _ := http.NewRequest("GET", "/preference/get?aimsid="+aimsid, nil)
+	rr := httptest.NewRecorder()
+	handler.GetPreferences(rr, req)
+
+	assert.Equal(t, http.StatusOK, rr.Code)
+	body := rr.Body.String()
+	assert.Contains(t, body, `"displayLogin":1`)     // default true
+	assert.Contains(t, body, `"acceptCustomBart":0`) // default false
+}
+
+func TestPreferenceHandler_GetPreferences_AMF(t *testing.T) {
+	fs := &MockFeedbagService{}
+	oscarInstance := state.NewSession().AddInstance()
+	sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
+
+	fs.On("Query", mock.Anything, oscarInstance, mock.Anything).
+		Return(buddyPrefsFeedbag(map[uint16]bool{0x15: false}), nil)
+
+	handler := &PreferenceHandler{
+		SessionManager: sessionMgr,
+		FeedbagService: fs,
+		Logger:         slog.Default(),
+	}
+
+	// Single-pref AMF request returns a numeric value (not wrapped in jsonData).
+	req, _ := http.NewRequest("GET", "/preference/get?aimsid="+aimsid+"&f=amf&playIMSound", nil)
+	rr := httptest.NewRecorder()
+	handler.GetPreferences(rr, req)
+
+	assert.Equal(t, http.StatusOK, rr.Code)
+	body := rr.Body.String()
+	// AMF-encoded response: the value is a numeric 0 (AMF integer marker 0x04
+	// followed by 0x00), not the string "0".
+	assert.Contains(t, body, "playIMSound\x04\x00")
+	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.
+	var list wire.TLVList
+	list = wire.SetBuddyPref(list, wire.FeedbagBuddyPrefsPlayIMSound, false)
+	list = wire.SetBuddyPref(list, wire.FeedbagBuddyPrefsViewIMsInBubbles, true)
+
+	got := validBuddyPrefs(list)
+
+	assert.Equal(t, map[string]interface{}{
+		"playIMSound":      0,
+		"viewIMsInBubbles": 1,
+	}, got)
+}
+
+func TestValidBuddyPrefs_EmptyWhenNothingSet(t *testing.T) {
+	assert.Empty(t, validBuddyPrefs(wire.TLVList{}))
+}
+
+func TestPreferenceHandler_SetPreferences_NoOSCARSession(t *testing.T) {
+	fs := &MockFeedbagService{}
+	sessionMgr, aimsid := createTestSessionManager("webonly") // nil OSCARSession
+
+	handler := &PreferenceHandler{
+		SessionManager: sessionMgr,
+		FeedbagService: fs,
+		Logger:         slog.Default(),
+	}
+
+	req, _ := http.NewRequest("GET", "/preference/set?aimsid="+aimsid+"&playIMSound=1", nil)
+	rr := httptest.NewRecorder()
+	handler.SetPreferences(rr, req)
+
+	assert.Equal(t, http.StatusBadRequest, rr.Code)
+	fs.AssertNotCalled(t, "Query", mock.Anything, mock.Anything, mock.Anything)
+	_ = strings.TrimSpace(rr.Body.String())
+}

+ 10 - 5
server/webapi/handlers/session.go

@@ -464,11 +464,16 @@ func (h *SessionHandler) StartSession(w http.ResponseWriter, r *http.Request) {
 				session.EventQueue.Push(types.EventTypeBuddyList, blPayload)
 			}
 		case types.EventTypePreference:
-			prefPayload := map[string]interface{}{
-				"showGroups":     true,
-				"showOfflineGrp": true,
-				"sortBuddyList":  false,
-				"globalOTR":      false,
+			// 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.
+			prefPayload := map[string]interface{}{}
+			if authToken != "" && session.OSCARSession != nil {
+				if item, err := buddyPrefsItem(ctx, h.FeedbagService, session.OSCARSession); err != nil {
+					h.Logger.ErrorContext(ctx, "failed to get preferences", "err", err.Error())
+				} else {
+					prefPayload = validBuddyPrefs(item.TLVList)
+				}
 			}
 			if resp.Response.Data.Events == nil {
 				resp.Response.Data.Events = make(map[string]interface{})

+ 3 - 4
server/webapi/server.go

@@ -71,10 +71,9 @@ func NewServer(listeners []string, logger *slog.Logger, handler Handler, apiKeyV
 
 	// Phase 3: Preference handler
 	preferenceHandler := &handlers.PreferenceHandler{
-		SessionManager:    sessionManager,
-		PreferenceManager: handler.PreferenceManager,
-		FeedbagService:    handler.FeedbagService,
-		Logger:            logger,
+		SessionManager: sessionManager,
+		FeedbagService: handler.FeedbagService,
+		Logger:         logger,
 	}
 
 	// Phase 4: OSCAR Bridge handler

+ 0 - 8
server/webapi/types.go

@@ -87,14 +87,6 @@ type BuddyBroadcaster interface {
 	BroadcastBuddyDeparted(ctx context.Context, screenName state.IdentScreenName) error
 }
 
-// Phase 3: Preference interfaces
-
-// PreferenceManager provides methods to manage user preferences.
-type PreferenceManager interface {
-	SetPreferences(ctx context.Context, screenName state.IdentScreenName, prefs map[string]interface{}) error
-	GetPreferences(ctx context.Context, screenName state.IdentScreenName) (map[string]interface{}, error)
-}
-
 // Phase 4: OSCAR Bridge interfaces
 
 // OSCARBridgeStore manages the persistence of OSCAR bridge sessions.

+ 10 - 0
state/migrations/0040_drop_web_preferences.down.sql

@@ -0,0 +1,10 @@
+-- Rollback: recreate the Web API preferences table.
+CREATE TABLE IF NOT EXISTS web_preferences
+(
+    screen_name         VARCHAR(16) PRIMARY KEY,
+    preferences         TEXT,        -- JSON object of preference key-value pairs
+    created_at          INTEGER NOT NULL,
+    updated_at          INTEGER NOT NULL
+);
+
+CREATE INDEX IF NOT EXISTS idx_web_preferences_screen_name ON web_preferences(screen_name);

+ 4 - 0
state/migrations/0040_drop_web_preferences.up.sql

@@ -0,0 +1,4 @@
+-- Web API preferences are now stored as OSCAR buddy prefs in the feedbag,
+-- so the standalone web_preferences table is no longer used.
+DROP INDEX IF EXISTS idx_web_preferences_screen_name;
+DROP TABLE IF EXISTS web_preferences;

+ 0 - 62
state/webapi_preferences.go

@@ -1,62 +0,0 @@
-package state
-
-import (
-	"context"
-	"database/sql"
-	"encoding/json"
-	"errors"
-	"time"
-)
-
-// WebPreferenceManager handles Web API user preferences.
-type WebPreferenceManager struct {
-	store *SQLiteUserStore
-}
-
-// NewWebPreferenceManager creates a new WebPreferenceManager.
-func (s *SQLiteUserStore) NewWebPreferenceManager() *WebPreferenceManager {
-	return &WebPreferenceManager{store: s}
-}
-
-// SetPreferences stores user preferences in the database.
-func (m *WebPreferenceManager) SetPreferences(ctx context.Context, screenName IdentScreenName, prefs map[string]interface{}) error {
-	prefsJSON, err := json.Marshal(prefs)
-	if err != nil {
-		return err
-	}
-
-	now := time.Now().Unix()
-	q := `
-		INSERT INTO web_preferences (screen_name, preferences, created_at, updated_at)
-		VALUES (?, ?, ?, ?)
-		ON CONFLICT (screen_name)
-		DO UPDATE SET preferences = excluded.preferences, updated_at = excluded.updated_at
-	`
-	_, err = m.store.db.ExecContext(ctx, q, screenName.String(), string(prefsJSON), now, now)
-	return err
-}
-
-// GetPreferences retrieves user preferences from the database.
-func (m *WebPreferenceManager) GetPreferences(ctx context.Context, screenName IdentScreenName) (map[string]interface{}, error) {
-	q := `
-		SELECT preferences
-		FROM web_preferences
-		WHERE screen_name = ?
-	`
-	var prefsJSON string
-	err := m.store.db.QueryRowContext(ctx, q, screenName.String()).Scan(&prefsJSON)
-	if err != nil {
-		if errors.Is(err, sql.ErrNoRows) {
-			// Return empty preferences if none exist
-			return make(map[string]interface{}), nil
-		}
-		return nil, err
-	}
-
-	var prefs map[string]interface{}
-	if err := json.Unmarshal([]byte(prefsJSON), &prefs); err != nil {
-		return nil, err
-	}
-
-	return prefs, nil
-}

+ 255 - 0
wire/buddy_prefs.go

@@ -0,0 +1,255 @@
+package wire
+
+import "slices"
+
+// Buddy preferences are boolean values stored in a single feedbag item of class
+// FeedbagClassIdBuddyPrefs. Each preference is a bit position in a logical
+// bitmask spanning two physical bitmasks, each paired with a "valid" bitmask
+// that records which positions carry a meaningful value.
+//
+// The first bitmask (BuddyPrefs, 0x00C9) is fixed at 32 bits (4 bytes) and is
+// 0-offset with the most significant bit on the right side. The second bitmask
+// (BuddyPrefs2, 0x00D7) is unbounded, positional, with the most significant bit
+// on the left side.
+//
+// Preferences 0-31 live in BuddyPrefs:
+//
+//	Pref #1:
+//	00000000 00000000 00000000 00000010 (BuddyPrefs)
+//	                                 ^ offset 1, bit 2
+//
+//	Pref #31:
+//	10000000 00000000 00000000 00000000 (BuddyPrefs)
+//	^ offset 31, bit 32
+//
+// Preferences 32+ live in BuddyPrefs2. The bit offset is (prefNum-33), except
+// for the edge case of prefs 32 and 33, which both fall at offset 0 (an
+// artifact of the transition from offset- to position-based indexing):
+//
+//	Pref #52:
+//	00000000 00000000 00010000 00000000 (BuddyPrefs2)
+//	                     ^ offset 19, bit 52
+//
+// For each logical bitmask there are two physical bitmasks: the value bitmask
+// and the valid bitmask. The valid bitmask disambiguates an unset position:
+// i.e. whether an unset value means false or "not present".
+//
+// The bitmasks are carried in four TLVs:
+//
+//   - 0x00C9: FeedbagAttributesBuddyPrefs        (value, prefs 0-31)
+//   - 0x00D6: FeedbagAttributesBuddyPrefsValid   (valid, prefs 0-31)
+//   - 0x00D7: FeedbagAttributesBuddyPrefs2       (value, prefs 32+)
+//   - 0x00D8: FeedbagAttributesBuddyPrefs2Valid  (valid, prefs 32+)
+
+// Buddy preference bit numbers. OSCAR defines preferences through
+// FeedbagBuddyPrefsImblastInviteNotify (0x4B).
+const (
+	FeedbagBuddyPrefsDisplayLogin             uint16 = 0x00
+	FeedbagBuddyPrefsDisplayEBuddy            uint16 = 0x01
+	FeedbagBuddyPrefsPlayEnter                uint16 = 0x02
+	FeedbagBuddyPrefsPlayExit                 uint16 = 0x03
+	FeedbagBuddyPrefsViewIMStamp              uint16 = 0x04
+	FeedbagBuddyPrefsViewSmileys              uint16 = 0x05
+	FeedbagBuddyPrefsAcceptIcons              uint16 = 0x06
+	FeedbagBuddyPrefsKnockNonAOLIMs           uint16 = 0x08
+	FeedbagBuddyPrefsKnockNonListIMs          uint16 = 0x09
+	FeedbagBuddyPrefsDiscloseIdle             uint16 = 0x0A
+	FeedbagBuddyPrefsAcceptCustomBart         uint16 = 0x0B
+	FeedbagBuddyPrefsAcceptNonListBart        uint16 = 0x0C
+	FeedbagBuddyPrefsAcceptBgs                uint16 = 0x0D
+	FeedbagBuddyPrefsAcceptChromes            uint16 = 0x0E
+	FeedbagBuddyPrefsAcceptBLSounds           uint16 = 0x0F
+	FeedbagBuddyPrefsAcceptIMSounds           uint16 = 0x10
+	FeedbagBuddyPrefsNoSeeRecentBuddies       uint16 = 0x11
+	FeedbagBuddyPrefsAcceptSMSLegal           uint16 = 0x12
+	FeedbagBuddyPrefsEnterDoesCRLF            uint16 = 0x14
+	FeedbagBuddyPrefsPlayIMSound              uint16 = 0x15
+	FeedbagBuddyPrefsDiscloseTyping           uint16 = 0x16
+	FeedbagBuddyPrefsAcceptSuperIcons         uint16 = 0x18
+	FeedbagBuddyPrefsAcceptBLRichText         uint16 = 0x19
+	FeedbagBuddyPrefsReduceIMSound            uint16 = 0x1A
+	FeedbagBuddyPrefsConfirmDirectIM          uint16 = 0x1B
+	FeedbagBuddyPrefsOneTabbedIMWindow        uint16 = 0x1C
+	FeedbagBuddyPrefsBuddyInfoOnMouseover     uint16 = 0x1D
+	FeedbagBuddyPrefsDiscloseBuddyMatches     uint16 = 0x1E
+	FeedbagBuddyPrefsCatchIMs                 uint16 = 0x1F
+	FeedbagBuddyPrefsShowFriendlyName         uint16 = 0x20
+	FeedbagBuddyPrefsDiscloseRadio            uint16 = 0x21
+	FeedbagBuddyPrefsShowCapabilities         uint16 = 0x22
+	FeedbagBuddyPrefsShowBuddyListFilter      uint16 = 0x23
+	FeedbagBuddyPrefsShowAwayIdle             uint16 = 0x24
+	FeedbagBuddyPrefsShowMobile               uint16 = 0x25
+	FeedbagBuddyPrefsSortBuddyList            uint16 = 0x26
+	FeedbagBuddyPrefsCatchIMsForClient        uint16 = 0x27
+	FeedbagBuddyPrefsNewMessageSmallNotify    uint16 = 0x28
+	FeedbagBuddyPrefsNoFrequentBuddies        uint16 = 0x29
+	FeedbagBuddyPrefsBlogAwayMessages         uint16 = 0x2A
+	FeedbagBuddyPrefsBlogAIMSigMessages       uint16 = 0x2B
+	FeedbagBuddyPrefsBlogNoComments           uint16 = 0x2C
+	FeedbagBuddyPrefsFriendOfFriend           uint16 = 0x2D
+	FeedbagBuddyPrefsFriendGetContactList     uint16 = 0x2E
+	FeedbagBuddyPrefsCompadInit               uint16 = 0x2F
+	FeedbagBuddyPrefsSendBuddyFeed            uint16 = 0x30
+	FeedbagBuddyPrefsBlkSendIMWhileAway       uint16 = 0x31
+	FeedbagBuddyPrefsShowBuddyFeed            uint16 = 0x32
+	FeedbagBuddyPrefsNoSaveVanityInfo         uint16 = 0x33
+	FeedbagBuddyPrefsAcceptOfflineIM          uint16 = 0x34
+	FeedbagBuddyPrefsShowGroups               uint16 = 0x35
+	FeedbagBuddyPrefsSortGroup                uint16 = 0x36
+	FeedbagBuddyPrefsShowOfflineBuddies       uint16 = 0x37
+	FeedbagBuddyPrefsExpandBuddies            uint16 = 0x38
+	FeedbagBuddyPrefsThirdPartyFeeds          uint16 = 0x39
+	FeedbagBuddyPrefsNotifyReceivedInvite     uint16 = 0x3A
+	FeedbagBuddyPrefsApfAutoAccept            uint16 = 0x3B
+	FeedbagBuddyPrefsApfAutoAcceptBuddy       uint16 = 0x3C
+	FeedbagBuddyPrefsBlockAwayMsgFeed         uint16 = 0x3D
+	FeedbagBuddyPrefsBlockAIMProfileFeed      uint16 = 0x3E
+	FeedbagBuddyPrefsBlockAIMPagesFeed        uint16 = 0x3F
+	FeedbagBuddyPrefsBlockJournalsFeed        uint16 = 0x40
+	FeedbagBuddyPrefsBlockLocationFeed        uint16 = 0x41
+	FeedbagBuddyPrefsBlockStickiesFeed        uint16 = 0x42
+	FeedbagBuddyPrefsBlockUncutFeed           uint16 = 0x43
+	FeedbagBuddyPrefsBlockLinksFeed           uint16 = 0x44
+	FeedbagBuddyPrefsBlockAIMBulletinFeed     uint16 = 0x45
+	FeedbagBuddyPrefsSaveStatusMsg            uint16 = 0x46
+	FeedbagBuddyPrefsApfNotifyReceivedByEmail uint16 = 0x47
+	FeedbagBuddyPrefsShowOfflineGrp           uint16 = 0x48
+	FeedbagBuddyPrefsOfflineGrpCollapsed      uint16 = 0x49
+	FeedbagBuddyPrefsFirstIMSoundOnly         uint16 = 0x4A
+	FeedbagBuddyPrefsImblastInviteNotify      uint16 = 0x4B
+)
+
+// Web-client-only preferences with no OSCAR equivalent. They are persisted in
+// the buddy-prefs bitmask at positions above OSCAR's range (0x4B); no real
+// OSCAR client reads or writes these bits.
+const (
+	FeedbagBuddyPrefsViewIMsInBubbles           uint16 = 0x4C
+	FeedbagBuddyPrefsViewIMTimestampsRelative   uint16 = 0x4D
+	FeedbagBuddyPrefsGlobalOTR                  uint16 = 0x4E
+	FeedbagBuddyPrefsImblastInviteFromBuddyOnly uint16 = 0x4F
+)
+
+// FeedbagBuddyPrefsWantsTypingEvents is the DiscloseTyping (0x16) preference in
+// raw uint32-mask form (bit 22 of the fixed BuddyPrefs field). It is used when
+// reading the whole 4-byte bitmask as a single integer rather than by pref
+// number.
+const FeedbagBuddyPrefsWantsTypingEvents uint32 = 0x400000
+
+// buddyPrefTags returns the (valid, value) TLV tags that hold prefNum.
+func buddyPrefTags(prefNum uint16) (validTag, valueTag uint16) {
+	if prefNum < 32 {
+		return FeedbagAttributesBuddyPrefsValid, FeedbagAttributesBuddyPrefs
+	}
+	return FeedbagAttributesBuddyPrefs2Valid, FeedbagAttributesBuddyPrefs2
+}
+
+// buddyPrefBit returns the byte index and bit mask for prefNum within a bitmask
+// of the given byte length.
+func buddyPrefBit(prefNum uint16, length int) (index int, mask byte) {
+	if prefNum < 32 {
+		// most significant bit on the right side
+		index = (length - 1) - int(prefNum)/8
+		return index, byte(1) << (prefNum % 8)
+	}
+	// most significant bit on the left side
+	offset := 0
+	if prefNum != 32 {
+		offset = int(prefNum) - 33
+	}
+	index = offset / 8
+	return index, byte(0x80) >> (offset % 8)
+}
+
+// BuddyPref reads preference prefNum from a feedbag buddy-prefs TLV list. valid
+// reports whether the preference is present in the bitmask; value is its boolean
+// value, meaningful only when valid is true.
+func BuddyPref(list TLVList, prefNum uint16) (valid, value bool) {
+	validTag, valueTag := buddyPrefTags(prefNum)
+
+	validBytes, ok := list.Bytes(validTag)
+	if !ok {
+		return false, false
+	}
+	valueBytes, ok := list.Bytes(valueTag)
+	if !ok {
+		return false, false
+	}
+
+	index, mask := buddyPrefBit(prefNum, len(validBytes))
+	if index < 0 || index >= len(validBytes) || index >= len(valueBytes) {
+		return false, false
+	}
+
+	valid = validBytes[index]&mask != 0
+	value = valueBytes[index]&mask != 0
+	return valid, value
+}
+
+// SetBuddyPref returns list with preference prefNum set to value and marked
+// valid, growing the underlying bitmask byte slices as needed. The value and
+// valid TLVs are created if absent and other preference bits are preserved.
+func SetBuddyPref(list TLVList, prefNum uint16, value bool) TLVList {
+	validTag, valueTag := buddyPrefTags(prefNum)
+
+	// Clone so we never mutate a caller's backing array in place.
+	rawValid, _ := list.Bytes(validTag)
+	validBytes := slices.Clone(rawValid)
+	rawValue, _ := list.Bytes(valueTag)
+	valueBytes := slices.Clone(rawValue)
+
+	// Normalize both bitmasks to a common length large enough to hold prefNum.
+	var length int
+	if prefNum < 32 {
+		length = max(4, len(validBytes), len(valueBytes))
+		validBytes = leftPad(validBytes, length)
+		valueBytes = leftPad(valueBytes, length)
+	} else {
+		offset := 0
+		if prefNum != 32 {
+			offset = int(prefNum) - 33
+		}
+		length = max(offset/8+1, len(validBytes), len(valueBytes))
+		validBytes = rightPad(validBytes, length)
+		valueBytes = rightPad(valueBytes, length)
+	}
+
+	index, mask := buddyPrefBit(prefNum, length)
+	validBytes[index] |= mask // a preference we set is always valid
+	if value {
+		valueBytes[index] |= mask
+	} else {
+		valueBytes[index] &^= mask
+	}
+
+	list = setTLVBytes(list, validTag, validBytes)
+	list = setTLVBytes(list, valueTag, valueBytes)
+	return list
+}
+
+// setTLVBytes replaces the value of tag in list, or appends it when absent.
+func setTLVBytes(list TLVList, tag uint16, b []byte) TLVList {
+	if list.HasTag(tag) {
+		list.Replace(NewTLVBE(tag, b))
+		return list
+	}
+	list.Append(NewTLVBE(tag, b))
+	return list
+}
+
+// leftPad grows b to length n by prepending zero bytes (bit indexing is
+// right-aligned for the fixed BuddyPrefs bitmask).
+func leftPad(b []byte, n int) []byte {
+	if len(b) >= n {
+		return b
+	}
+	return append(make([]byte, n-len(b)), b...)
+}
+
+// rightPad grows b to length n by appending zero bytes (bit indexing is
+// left-aligned for the positional BuddyPrefs2 bitmask).
+func rightPad(b []byte, n int) []byte {
+	if len(b) >= n {
+		return b
+	}
+	return append(b, make([]byte, n-len(b))...)
+}

+ 132 - 0
wire/buddy_prefs_test.go

@@ -0,0 +1,132 @@
+package wire
+
+import (
+	"bytes"
+	"testing"
+)
+
+func TestBuddyPref(t *testing.T) {
+	tests := []struct {
+		name      string
+		prefNum   uint16
+		list      TLVList
+		wantValid bool
+		wantValue bool
+	}{
+		{
+			name:    "absent bitmask => not valid",
+			prefNum: 0x15,
+			list:    TLVList{},
+		},
+		{
+			// pref 0x34 (=52) lives in BuddyPrefs2 at offset 19 (index 2, mask 0x10).
+			name:    "offline messages disabled",
+			prefNum: FeedbagBuddyPrefsAcceptOfflineIM,
+			list: TLVList{
+				{Tag: FeedbagAttributesBuddyPrefsValid, Value: []byte{0, 0, 24, 64}},
+				{Tag: FeedbagAttributesBuddyPrefs, Value: []byte{0, 0, 24, 64}},
+				{Tag: FeedbagAttributesBuddyPrefs2Valid, Value: []byte{0, 0, 17}},
+				{Tag: FeedbagAttributesBuddyPrefs2, Value: []byte{0, 0, 1}},
+			},
+			wantValid: true,
+			wantValue: false,
+		},
+		{
+			// pref 22 lives in BuddyPrefs at index 1, mask 0x40.
+			name:    "typing events enabled (pref 22 in BuddyPrefs)",
+			prefNum: 22,
+			list: TLVList{
+				{Tag: FeedbagAttributesBuddyPrefsValid, Value: []byte{0, 0x40, 0, 0}},
+				{Tag: FeedbagAttributesBuddyPrefs, Value: []byte{0, 0x40, 0, 0}},
+			},
+			wantValid: true,
+			wantValue: true,
+		},
+		{
+			// prefs 32 and 33 both fall at offset 0 in BuddyPrefs2.
+			name:    "pref 33 at shared offset 0",
+			prefNum: 33,
+			list: TLVList{
+				{Tag: FeedbagAttributesBuddyPrefs2Valid, Value: []byte{0x80, 0, 0, 0}},
+				{Tag: FeedbagAttributesBuddyPrefs2, Value: []byte{0x80, 0, 0, 0}},
+			},
+			wantValid: true,
+			wantValue: true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			valid, value := BuddyPref(tt.list, tt.prefNum)
+			if valid != tt.wantValid || value != tt.wantValue {
+				t.Fatalf("BuddyPref(%d) = (valid=%v, value=%v), want (valid=%v, value=%v)",
+					tt.prefNum, valid, value, tt.wantValid, tt.wantValue)
+			}
+		})
+	}
+}
+
+func TestSetBuddyPref_RoundTrip(t *testing.T) {
+	// Cover both bitmasks (0-31 and 32+), the 32==33 edge, and true/false.
+	prefs := []uint16{0x00, 0x16, 0x20, 0x34, 0x49}
+	for _, want := range []bool{true, false} {
+		for _, prefNum := range prefs {
+			list := SetBuddyPref(TLVList{}, prefNum, want)
+			valid, value := BuddyPref(list, prefNum)
+			if !valid {
+				t.Errorf("pref 0x%02x set to %v: not valid after set", prefNum, want)
+			}
+			if value != want {
+				t.Errorf("pref 0x%02x round-trip: got %v, want %v", prefNum, value, want)
+			}
+		}
+	}
+}
+
+func TestSetBuddyPref_PreservesOtherBits(t *testing.T) {
+	// Two prefs in the same BuddyPrefs byte, plus one in BuddyPrefs2.
+	list := TLVList{}
+	list = SetBuddyPref(list, 0x15, false) // playIMSound off
+	list = SetBuddyPref(list, 0x16, true)  // discloseTyping on
+	list = SetBuddyPref(list, 0x34, true)  // acceptOffLineIM on
+
+	for _, tc := range []struct {
+		prefNum   uint16
+		wantValue bool
+	}{
+		{0x15, false},
+		{0x16, true},
+		{0x34, true},
+	} {
+		valid, value := BuddyPref(list, tc.prefNum)
+		if !valid || value != tc.wantValue {
+			t.Errorf("pref 0x%02x = (valid=%v, value=%v), want (true, %v)",
+				tc.prefNum, valid, value, tc.wantValue)
+		}
+	}
+}
+
+func TestSetBuddyPref_Encoding(t *testing.T) {
+	// pref 0x34 (=52) enabled from empty: BuddyPrefs2 index 2, mask 0x10.
+	list := SetBuddyPref(TLVList{}, 0x34, true)
+
+	valid, _ := list.Bytes(FeedbagAttributesBuddyPrefs2Valid)
+	value, _ := list.Bytes(FeedbagAttributesBuddyPrefs2)
+	if !bytes.Equal(valid, []byte{0, 0, 0x10}) {
+		t.Errorf("BuddyPrefs2Valid = %v, want [0 0 16]", valid)
+	}
+	if !bytes.Equal(value, []byte{0, 0, 0x10}) {
+		t.Errorf("BuddyPrefs2 = %v, want [0 0 16]", value)
+	}
+
+	// A pref in the fixed bitmask allocates the full 4 bytes.
+	list = SetBuddyPref(TLVList{}, 0x15, true) // index 1, mask 0x20
+	valid, _ = list.Bytes(FeedbagAttributesBuddyPrefsValid)
+	value, _ = list.Bytes(FeedbagAttributesBuddyPrefs)
+	if !bytes.Equal(valid, []byte{0, 0x20, 0, 0}) {
+		t.Errorf("BuddyPrefsValid = %v, want [0 32 0 0]", valid)
+	}
+	if !bytes.Equal(value, []byte{0, 0x20, 0, 0}) {
+		t.Errorf("BuddyPrefs = %v, want [0 32 0 0]", value)
+	}
+}

+ 0 - 3
wire/snacs.go

@@ -1738,9 +1738,6 @@ const (
 	FeedbagAttributesFirstCreationTimeXc     uint16 = 0x0167
 	FeedbagAttributesPdModeXc                uint16 = 0x016E
 
-	FeedbagBuddyPrefsWantsTypingEvents uint32 = 0x400000 // user wants to send and receive typing events
-	FeedbagBuddyPrefsAcceptOfflineIM   uint16 = 0x34     // user wants to send and receive typing events
-
 	FeedbagRightsMaxClassAttrs       uint16 = 0x02
 	FeedbagRightsMaxItemAttrs        uint16 = 0x03
 	FeedbagRightsMaxItemsByClass     uint16 = 0x04