Ver código fonte

webapi: implement mood support

Mike 1 mês atrás
pai
commit
6001b4f1f1

+ 31 - 5
server/webapi/aim_handler.go

@@ -14,6 +14,8 @@ import (
 	"strings"
 	"time"
 
+	"github.com/google/uuid"
+
 	"github.com/mk6i/open-oscar-server/config"
 	"github.com/mk6i/open-oscar-server/state"
 	"github.com/mk6i/open-oscar-server/wire"
@@ -25,6 +27,7 @@ type AimHandler struct {
 	AuthService      AuthService
 	FeedbagService   FeedbagService
 	ICBMService      ICBMService
+	LocateService    LocateService
 	OServiceService  OServiceService
 	BuddyListManager *BuddyListManager
 	BuddyService     BuddyService
@@ -59,6 +62,8 @@ type MyInfo struct {
 	// BuddyIcon is omitted when empty so the client's merge preserves the icon it
 	// already holds.
 	BuddyIcon   string      `json:"buddyIcon,omitempty" xml:"buddyIcon,omitempty"`
+	MoodIcon    string      `json:"moodIcon,omitempty" xml:"moodIcon,omitempty"`
+	MoodTitle   string      `json:"moodTitle,omitempty" xml:"moodTitle,omitempty"`
 	AwayMsg     string      `json:"awayMsg,omitempty" xml:"awayMsg,omitempty"`
 	StatusMsg   string      `json:"statusMsg,omitempty" xml:"statusMsg,omitempty"`
 	OnlineTime  int64       `json:"onlineTime,omitempty" xml:"onlineTime,omitempty"`
@@ -233,6 +238,22 @@ func (h *AimHandler) StartSession(w http.ResponseWriter, r *http.Request) {
 
 	instance.SetSignonComplete()
 
+	setInfo := wire.SNAC_0x02_0x04_LocateSetInfo{
+		TLVRestBlock: wire.TLVRestBlock{
+			TLVList: wire.TLVList{
+				wire.NewTLVBE(wire.LocateTLVTagsInfoCapabilities, []uuid.UUID{
+					wire.CapICQCh2Extended,
+				}),
+			},
+		},
+	}
+	if err := h.LocateService.SetInfo(ctx, instance, setInfo); err != nil {
+		h.Logger.ErrorContext(ctx, "failed to set capabilities", "err", err.Error())
+		instance.CloseInstance()
+		SendEnvelopeStatus(w, r, http.StatusInternalServerError, "internal server error", h.Logger)
+		return
+	}
+
 	if err := h.OServiceService.ClientOnline(ctx, wire.BOS, wire.SNAC_0x01_0x02_OServiceClientOnline{}, instance); err != nil {
 		h.Logger.ErrorContext(ctx, "failed to set client online", "err", err.Error())
 		instance.CloseInstance()
@@ -314,7 +335,9 @@ func (h *AimHandler) StartSession(w http.ResponseWriter, r *http.Request) {
 	// the OServiceUserInfoUpdate is relayed.
 	session.MyInfoRefresher = func(ctx context.Context) (any, error) {
 		icon := h.IconSource.PublishedURL(ctx, session.BaseURL, screenName.IdentScreenName())
-		return buildMyInfo(screenName, currentWebState(session.OSCARSession), icon), nil
+		webState := currentWebState(session.OSCARSession)
+		mood := moodIconURL(session.BaseURL, webState, session.OSCARSession.Session().Caps())
+		return buildMyInfo(screenName, webState, icon, mood), nil
 	}
 
 	// Wire permit/deny refresher so FeedbagUpdateItem SNACs trigger a permitDeny event.
@@ -373,7 +396,8 @@ func (h *AimHandler) StartSession(w http.ResponseWriter, r *http.Request) {
 		Events: &StartSessionEvents{},
 	}
 
-	myInfoPayload := buildMyInfo(screenName, "online", myIconURL)
+	myMoodURL := moodIconURL(baseURL, "online", session.OSCARSession.Session().Caps())
+	myInfoPayload := buildMyInfo(screenName, "online", myIconURL, myMoodURL)
 	myInfoPayload.OnlineTime = time.Now().Unix()
 	myInfoPayload.MemberSince = time.Now().Unix() - 86400*30 // 30 days ago
 	myInfoPayload.Self = &MyInfoSelf{
@@ -403,7 +427,7 @@ func (h *AimHandler) StartSession(w http.ResponseWriter, r *http.Request) {
 	// myInfo and presence render the identity badge from the same payload and the
 	// client subscribes to both, which a per-subscription loop would queue twice.
 	if slices.Contains(events, "myInfo") || slices.Contains(events, "presence") {
-		myInfoData := buildMyInfo(screenName, "online", myIconURL)
+		myInfoData := buildMyInfo(screenName, "online", myIconURL, myMoodURL)
 		myInfoData.OnlineTime = time.Now().Unix()
 		myInfoData.MemberSince = time.Now().Unix() - 86400*30 // 30 days ago
 		session.EventQueue.Push(EventTypeMyInfo, myInfoData)
@@ -861,8 +885,9 @@ func seedRateLimitAlert(session *Session, classID wire.RateLimitClassID) {
 // omits them so the client keeps the signon time it already has; the
 // builders add them explicitly. buddyIcon is included only when non-empty; an
 // empty value would be dropped by the client merge anyway, and the placeholder
-// URL (not "") is what clears an icon.
-func buildMyInfo(screenName state.DisplayScreenName, webState, buddyIcon string) *MyInfo {
+// URL (not "") is what clears an icon. moodIcon is a parameter rather than a
+// field the callers set, because omitting it clears the user's mood.
+func buildMyInfo(screenName state.DisplayScreenName, webState, buddyIcon, moodIcon string) *MyInfo {
 	// The web client compares userType/service case-sensitively; a UIN account must
 	// report ICQ so it renders as an ICQ contact rather than AIM.
 	userType, service := "aim", "AIM"
@@ -880,6 +905,7 @@ func buildMyInfo(screenName state.DisplayScreenName, webState, buddyIcon string)
 		Bot:          false,
 		Service:      service,
 		BuddyIcon:    buddyIcon,
+		MoodIcon:     moodIcon,
 	}
 }
 

+ 3 - 3
server/webapi/aim_handler_test.go

@@ -34,7 +34,7 @@ func TestBuildMyInfo_UserTypeAndService(t *testing.T) {
 
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			mi := buildMyInfo(state.DisplayScreenName(tt.screenName), "online", "")
+			mi := buildMyInfo(state.DisplayScreenName(tt.screenName), "online", "", "")
 			assert.Equal(t, tt.wantType, mi.UserType)
 			assert.Equal(t, tt.wantSvc, mi.Service)
 		})
@@ -43,11 +43,11 @@ func TestBuildMyInfo_UserTypeAndService(t *testing.T) {
 
 func TestBuildMyInfo_BuddyIcon(t *testing.T) {
 	t.Run("included when set", func(t *testing.T) {
-		mi := buildMyInfo(state.DisplayScreenName("mikekelly"), "away", "http://x/icon")
+		mi := buildMyInfo(state.DisplayScreenName("mikekelly"), "away", "http://x/icon", "")
 		assert.Equal(t, "http://x/icon", mi.BuddyIcon)
 	})
 	t.Run("omitted when empty so the client merge preserves the current icon", func(t *testing.T) {
-		mi := buildMyInfo(state.DisplayScreenName("mikekelly"), "away", "")
+		mi := buildMyInfo(state.DisplayScreenName("mikekelly"), "away", "", "")
 		assert.Empty(t, mi.BuddyIcon)
 
 		// omitempty is what actually keeps it out of the payload.

+ 5 - 1
server/webapi/amf_shape_test.go

@@ -124,7 +124,7 @@ func TestAMFTimestampsAreDoubles(t *testing.T) {
 // myInfo carries the timestamps most likely to overflow, and reaches the encoder
 // as a struct rather than a map.
 func TestAMFMyInfoTimestampsAreDoubles(t *testing.T) {
-	mi := buildMyInfo("ChattingChuck", "online", "")
+	mi := buildMyInfo("ChattingChuck", "online", "", "")
 	mi.OnlineTime = 1700000000
 	mi.MemberSince = 1500000000
 	mi.Self = &MyInfoSelf{InstNum: 1, LoginTime: 1700000001, Events: []string{}, AssertCaps: []string{}}
@@ -179,6 +179,8 @@ func TestAMFPresenceCarriesEveryField(t *testing.T) {
 			AimID:      "mikekelly",
 			Friendly:   "Mike",
 			State:      "away",
+			MoodIcon:   "http://host/mood?id=0009306963716d6f6f6436",
+			MoodTitle:  "Eating",
 			StatusMsg:  "at lunch",
 			AwayMsg:    "back soon",
 			IdleTime:   5,
@@ -191,6 +193,8 @@ func TestAMFPresenceCarriesEveryField(t *testing.T) {
 	assert.Equal(t, "mikekelly", eventData["aimId"])
 	assert.Equal(t, "Mike", eventData["friendly"])
 	assert.Equal(t, "away", eventData["state"])
+	assert.Equal(t, "http://host/mood?id=0009306963716d6f6f6436", eventData["moodIcon"])
+	assert.Equal(t, "Eating", eventData["moodTitle"])
 	assert.Equal(t, "at lunch", eventData["statusMsg"])
 	assert.Equal(t, "back soon", eventData["awayMsg"])
 	assert.Equal(t, int32(5), eventData["idleTime"])

+ 3 - 0
server/webapi/buddy_list_manager.go

@@ -61,6 +61,7 @@ type BuddyInfo struct {
 	Service      string   `json:"service,omitempty" xml:"service,omitempty"` // "AIM", "ICQ" (Web AIM client compares case-sensitively)
 	PresenceIcon string   `json:"presenceIcon,omitempty" xml:"presenceIcon,omitempty"`
 	BuddyIcon    string   `json:"buddyIcon,omitempty" xml:"buddyIcon,omitempty"`
+	MoodIcon     string   `json:"moodIcon,omitempty" xml:"moodIcon,omitempty"`
 	Capabilities []string `json:"capabilities,omitempty" xml:"capabilities>capability,omitempty"`
 	MemberSince  int64    `json:"memberSince,omitempty" xml:"memberSince,omitempty"`
 }
@@ -244,6 +245,8 @@ func (m *BuddyListManager) getBuddyInfo(ctx context.Context, instance *state.Ses
 		}
 	}
 
+	info.MoodIcon = moodIconURL(baseURL, info.State, userInfoCaps(userInfo.TLVUserInfo))
+
 	return info
 }
 

+ 11 - 0
server/webapi/buddylist_handler.go

@@ -183,6 +183,7 @@ func (h *BuddyListHandler) addBuddyToFeedbag(ctx context.Context, sess *Session,
 	}
 
 	fl := state.NewFeedbagList(reply.Items, rand.Intn)
+	target := state.NewIdentScreenName(buddyName)
 
 	fl.AddGroup(groupName)
 	if pending := fl.PendingUpdates(); len(pending) > 0 {
@@ -214,6 +215,16 @@ func (h *BuddyListHandler) addBuddyToFeedbag(ctx context.Context, sess *Session,
 			}
 		}
 
+		if sess.OSCARSession.UIN() != 0 && target.UIN() != 0 {
+			for _, buddies := range buddyItems {
+				for i := range buddies {
+					if state.NewIdentScreenName(buddies[i].Name) == target {
+						buddies[i].Append(wire.NewTLVBE(wire.FeedbagAttributesPending, []byte{}))
+					}
+				}
+			}
+		}
+
 		for _, buddies := range buddyItems {
 			frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagInsertItem}
 			if _, err := h.FeedbagService.UpsertItem(ctx, sess.OSCARSession, frame, buddies); err != nil {

+ 109 - 3
server/webapi/buddylist_handler_test.go

@@ -925,6 +925,9 @@ func TestBuddyListHandler_AddBuddy_PreAuthorized(t *testing.T) {
 		authMsg       string
 		grantErr      error
 		wantGrant     bool
+		// wantPending follows the account pairing alone: the two icq->icq cases
+		// differ only in preAuthorized yet expect the same value.
+		wantPending bool
 	}{
 		{
 			name:          "no grant without preAuthorized",
@@ -932,6 +935,7 @@ func TestBuddyListHandler_AddBuddy_PreAuthorized(t *testing.T) {
 			buddy:         "100001",
 			preAuthorized: "",
 			wantGrant:     false,
+			wantPending:   true,
 		},
 		{
 			name:          "icq->icq grant carries the authorization message",
@@ -940,6 +944,7 @@ func TestBuddyListHandler_AddBuddy_PreAuthorized(t *testing.T) {
 			preAuthorized: "1",
 			authMsg:       "Hello! Please add me to your buddylist.",
 			wantGrant:     true,
+			wantPending:   true,
 		},
 		{
 			// The grant is not gated on protocol: the store no-ops for a user who
@@ -949,6 +954,7 @@ func TestBuddyListHandler_AddBuddy_PreAuthorized(t *testing.T) {
 			buddy:         "joemama",
 			preAuthorized: "1",
 			wantGrant:     true,
+			wantPending:   false,
 		},
 		{
 			// The buddy is on the list either way. Reporting an error here would
@@ -959,6 +965,7 @@ func TestBuddyListHandler_AddBuddy_PreAuthorized(t *testing.T) {
 			preAuthorized: "1",
 			grantErr:      errors.New("record failed"),
 			wantGrant:     true,
+			wantPending:   true,
 		},
 	}
 
@@ -972,6 +979,9 @@ func TestBuddyListHandler_AddBuddy_PreAuthorized(t *testing.T) {
 			// carry their identity.
 			oscarSess := state.NewSession()
 			oscarSess.SetIdentScreenName(state.NewIdentScreenName(tt.screenName))
+			// UIN is stored separately from the screen name, so an ICQ session
+			// reads as AIM until it is set.
+			oscarSess.SetUIN(state.NewIdentScreenName(tt.screenName).UIN())
 
 			session := &Session{
 				AimSID:       "sid",
@@ -989,8 +999,7 @@ func TestBuddyListHandler_AddBuddy_PreAuthorized(t *testing.T) {
 			fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
 				Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: items}}, nil).Once()
 
-			// The pending flag is what this used to set. Capture it to confirm it
-			// is gone, whatever preAuthorized says.
+			// Capture the pending flag to confirm preAuthorized does not drive it.
 			var sawPending, sawBuddyItem bool
 			fs.EXPECT().UpsertItem(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
 				RunAndReturn(func(_ context.Context, _ *state.SessionInstance, _ wire.SNACFrame, upserted []wire.FeedbagItem) (*wire.SNACMessage, error) {
@@ -1050,7 +1059,8 @@ func TestBuddyListHandler_AddBuddy_PreAuthorized(t *testing.T) {
 			// stored is conveyed by the async buddylist event, not this reply.
 			assert.JSONEq(t, `{"response":{"statusCode":200,"statusText":"Ok","data":{}}}`, rr.Body.String())
 			assert.True(t, sawBuddyItem, "expected a buddy item to be upserted")
-			assert.False(t, sawPending, "preAuthorized must not mark the stored item pending")
+			assert.Equal(t, tt.wantPending, sawPending,
+				"the pending flag must follow the account pairing, not preAuthorized")
 
 			if !tt.wantGrant {
 				return
@@ -1065,3 +1075,99 @@ func TestBuddyListHandler_AddBuddy_PreAuthorized(t *testing.T) {
 		})
 	}
 }
+
+// icq->icq adds are marked pending up front so the item is stored either way:
+// the decline a web client never sees would otherwise leave it holding a buddy
+// no roster lists. The other pairings already store the item, where a stray flag
+// would publish them as awaiting an authorization nobody requested.
+func TestBuddyListHandler_AddBuddy_PendingFlagFollowsPairing(t *testing.T) {
+	tests := []struct {
+		name        string
+		screenName  string
+		buddy       string
+		wantPending bool
+	}{
+		{
+			name:        "icq->icq is marked pending",
+			screenName:  "100002",
+			buddy:       "100001",
+			wantPending: true,
+		},
+		{
+			name:       "icq->aim is not",
+			screenName: "100002",
+			buddy:      "mike",
+		},
+		{
+			name:       "aim->icq is not; the feedbag service marks it itself",
+			screenName: "mike",
+			buddy:      "100001",
+		},
+		{
+			name:       "aim->aim is not",
+			screenName: "mike",
+			buddy:      "joemama",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			sm := newMockSessionResolver(t)
+			fs := newMockFeedbagService(t)
+
+			oscarSess := state.NewSession()
+			oscarSess.SetIdentScreenName(state.NewIdentScreenName(tt.screenName))
+			oscarSess.SetUIN(state.NewIdentScreenName(tt.screenName).UIN())
+
+			session := &Session{
+				AimSID:       "sid",
+				OSCARSession: oscarSess.AddInstance(),
+				ScreenName:   state.DisplayScreenName(tt.screenName),
+				EventQueue:   NewEventQueue(100),
+				LastAccessed: time.Now(),
+			}
+			sm.EXPECT().GetSession(mock.Anything, "sid").Return(session, nil)
+			sm.EXPECT().TouchSession(mock.Anything, "sid").Return(nil).Maybe()
+
+			items := []wire.FeedbagItem{
+				{GroupID: 1, ItemID: 0, ClassID: wire.FeedbagClassIdGroup, Name: "Friends"},
+			}
+			fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
+				Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: items}}, nil).Once()
+
+			var sawBuddyItem, sawPending bool
+			fs.EXPECT().UpsertItem(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
+				RunAndReturn(func(_ context.Context, _ *state.SessionInstance, _ wire.SNACFrame, upserted []wire.FeedbagItem) (*wire.SNACMessage, error) {
+					for _, item := range upserted {
+						if item.ClassID != wire.FeedbagClassIdBuddy {
+							continue
+						}
+						sawBuddyItem = true
+						if item.HasTag(wire.FeedbagAttributesPending) {
+							sawPending = true
+						}
+					}
+					return nil, nil
+				})
+
+			h := &BuddyListHandler{
+				FeedbagService: fs,
+				BuddyListManager: &BuddyListManager{
+					feedbagService: newMockFeedbagService(t),
+					iconSource:     newTestIconSource(t),
+					logger:         slog.Default(),
+				},
+				Logger: slog.Default(),
+			}
+
+			req := httptest.NewRequest(http.MethodGet,
+				"/buddylist/addBuddy?aimsid=sid&buddy="+tt.buddy+"&group=Friends", nil)
+			rr := httptest.NewRecorder()
+			requireSession(sm, h.AddBuddy).ServeHTTP(rr, req)
+
+			assert.Equal(t, http.StatusOK, rr.Code)
+			assert.True(t, sawBuddyItem, "expected a buddy item to be upserted")
+			assert.Equal(t, tt.wantPending, sawPending)
+		})
+	}
+}

+ 2 - 0
server/webapi/events.go

@@ -42,6 +42,8 @@ type PresenceEvent struct {
 	AimID      string `json:"aimId" xml:"aimId"`
 	Friendly   string `json:"friendly,omitempty" xml:"friendly,omitempty"`
 	State      string `json:"state" xml:"state"` // "online", "offline", "away", "idle"
+	MoodIcon   string `json:"moodIcon,omitempty" xml:"moodIcon,omitempty"`
+	MoodTitle  string `json:"moodTitle,omitempty" xml:"moodTitle,omitempty"`
 	StatusMsg  string `json:"statusMsg,omitempty" xml:"statusMsg,omitempty"`
 	AwayMsg    string `json:"awayMsg,omitempty" xml:"awayMsg,omitempty"`
 	IdleTime   int    `json:"idleTime,omitempty" xml:"idleTime,omitempty"`     // Minutes idle

+ 1 - 1
server/webapi/expressions_handler.go

@@ -165,7 +165,7 @@ func (h *ExpressionsHandler) Upload(w http.ResponseWriter, r *http.Request, sess
 
 	var bartType uint16
 	switch t := r.URL.Query().Get("type"); t {
-	case "buddyIcon", "bigBuddyIcon":
+	case "buddyIcon", "bigBuddyIcon", "largeBuddyIcon":
 		bartType = wire.BARTTypesBuddyIcon
 	case "":
 		SendErrorDetail(w, r, http.StatusBadRequest, statusMissingParameter, 0,

+ 63 - 12
server/webapi/presence_handler.go

@@ -5,6 +5,7 @@ import (
 	"fmt"
 	"log/slog"
 	"net/http"
+	"slices"
 	"strings"
 	"time"
 
@@ -75,6 +76,7 @@ type BuddyPresenceInfo struct {
 	OnlineTime int64  `json:"onlineTime,omitempty" xml:"onlineTime,omitempty"`
 	UserType   string `json:"userType" xml:"userType"` // "aim", "icq", "admin"
 	BuddyIcon  string `json:"buddyIcon,omitempty" xml:"buddyIcon,omitempty"`
+	MoodIcon   string `json:"moodIcon,omitempty" xml:"moodIcon,omitempty"`
 	// Profile carries member-directory fields, present only under mdir=1. It must be
 	// non-nil even when empty: clients treat a missing profile as "not a user".
 	Profile *BuddyProfileInfo `json:"profile,omitempty" xml:"profile,omitempty"`
@@ -351,6 +353,8 @@ func (h *PresenceHandler) getUserPresence(ctx context.Context, instance *state.S
 		}
 	}
 
+	presence.MoodIcon = moodIconURL(baseURL, presence.State, userInfoCaps(info.TLVUserInfo))
+
 	return presence
 }
 
@@ -457,29 +461,44 @@ func (h *PresenceHandler) SetStatus(w http.ResponseWriter, r *http.Request, sess
 	statusMsg := r.URL.Query().Get("statusMsg")
 	statusCode := r.URL.Query().Get("statusCode")
 
-	// Store status message in session (this would normally be stored in a profile/status service)
-	// For now, we'll broadcast it as part of presence
-
-	oscarSession := session.OSCARSession
-	// In OSCAR, status messages are typically part of the profile
-	// We'll need to extend this based on the actual implementation
+	if r.URL.Query().Has("mood") {
+		moodID := r.URL.Query().Get("mood")
+		if moodID == "" {
+			session.OSCARSession.ClearMood()
+		} else {
+			m, hasMood := wire.MoodByID(moodID)
+			if !hasMood {
+				SendError(w, r, http.StatusBadRequest, "invalid mood ID")
+				return
+			}
 
-	// Broadcast presence update with new status
-	if err := h.BuddyBroadcaster.BroadcastBuddyArrived(ctx, oscarSession.IdentScreenName(), oscarSession.Session().TLVUserInfo()); err != nil {
-		h.Logger.ErrorContext(ctx, "failed to broadcast status update", "err", err.Error())
+			session.OSCARSession.SetMood(m.Cap)
+		}
+		setInfo := wire.SNAC_0x02_0x04_LocateSetInfo{
+			TLVRestBlock: wire.TLVRestBlock{
+				TLVList: wire.TLVList{
+					wire.NewTLVBE(wire.LocateTLVTagsInfoCapabilities, session.OSCARSession.Caps()),
+				},
+			},
+		}
+		if err := h.LocateService.SetInfo(ctx, session.OSCARSession, setInfo); err != nil {
+			h.Logger.ErrorContext(ctx, "failed to set mood capability", "err", err.Error())
+			SendError(w, r, http.StatusInternalServerError, "failed to save status")
+			return
+		}
 	}
 
 	// Notify the user's own client so its status message re-renders. Preserve the
 	// current presence state so a status-only change does not flip the self badge.
-	h.pushMyInfo(session, currentWebState(oscarSession), oscarSession.Session().AwayMessage(), statusMsg)
+	h.pushMyInfo(session, currentWebState(session.OSCARSession), session.OSCARSession.Session().AwayMessage(), statusMsg)
 
 	h.Logger.InfoContext(ctx, "status message updated",
 		"screenName", session.ScreenName.String(),
 		"statusMsg", statusMsg,
 		"statusCode", statusCode,
+		"mood", r.URL.Query().Get("mood"),
 	)
 
-	// Send success response
 	SendOK(w, r, nil, h.Logger)
 }
 
@@ -662,9 +681,41 @@ func (h *PresenceHandler) pushMyInfo(session *Session, webState, awayMsg, status
 	// buddyIcon is omitted here (empty) so the client's merge preserves the icon it
 	// already holds; a setState/setStatus does not change the icon. Icon changes
 	// arrive on their own myInfo via the pump's MyInfoRefresher.
-	myInfo := buildMyInfo(session.ScreenName, webState, "")
+	moodIcon := moodIconURL(session.BaseURL, webState, session.OSCARSession.Session().Caps())
+	myInfo := buildMyInfo(session.ScreenName, webState, "", moodIcon)
 	myInfo.AwayMsg = awayMsg
 	myInfo.StatusMsg = statusMsg
 
 	session.EventQueue.Push(EventType("myInfo"), myInfo)
 }
+
+// userInfoCaps returns the capability UUIDs a user info block advertises.
+func userInfoCaps(info wire.TLVUserInfo) [][16]byte {
+	b, ok := info.Bytes(wire.OServiceUserInfoOscarCaps)
+	if !ok {
+		return nil
+	}
+	caps := make([][16]byte, 0, len(b)/16)
+	for chunk := range slices.Chunk(b, 16) {
+		if len(chunk) < 16 {
+			break
+		}
+		caps = append(caps, [16]byte(chunk))
+	}
+	return caps
+}
+
+// moodIconURL returns the mood icon URL for the mood advertised in caps, or ""
+// when there is none. A mood supersedes webState on the client, so a user who is
+// not visibly online never gets one.
+func moodIconURL(baseURL, webState string, caps [][16]byte) string {
+	if webState == "offline" || webState == "invisible" {
+		return ""
+	}
+	for _, c := range caps {
+		if m, ok := wire.MoodByCap(c); ok {
+			return baseURL + "/mood?id=" + wire.MoodIconID(m.ID)
+		}
+	}
+	return ""
+}

+ 169 - 0
server/webapi/presence_handler_test.go

@@ -4,6 +4,7 @@ import (
 	"context"
 	"encoding/json"
 	"fmt"
+	"io"
 	"log/slog"
 	"net/http"
 	"net/http/httptest"
@@ -636,6 +637,7 @@ func TestPresenceHandler_GetProfile(t *testing.T) {
 	assert.Contains(t, body, `"testuser"`)
 }
 
+// queuedMyInfo returns the myInfo event the session has queued, if any.
 // queuedMyInfo returns the myInfo event the session has queued, if any.
 func queuedMyInfo(session *Session) *MyInfo {
 	var myInfo *MyInfo
@@ -886,3 +888,170 @@ func searchPageTargets(n int) []string {
 	}
 	return names
 }
+
+// setStatusCaps drives setStatus and returns the capability list that reached
+// LocateService.SetInfo, or nil when SetInfo was never called.
+func setStatusCaps(t *testing.T, instance *state.SessionInstance, query string) ([][16]byte, *Session, int) {
+	t.Helper()
+
+	sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", instance)
+
+	var gotCaps [][16]byte
+	var called bool
+	locate := newMockLocateService(t)
+	locate.EXPECT().SetInfo(mock.Anything, mock.Anything, mock.Anything).
+		Run(func(_ context.Context, _ *state.SessionInstance, inBody wire.SNAC_0x02_0x04_LocateSetInfo) {
+			called = true
+			b, ok := inBody.Bytes(wire.LocateTLVTagsInfoCapabilities)
+			require.True(t, ok, "setStatus must always send a capabilities TLV")
+			require.Zero(t, len(b)%16)
+			gotCaps = userInfoCaps(wire.TLVUserInfo{
+				TLVBlock: wire.TLVBlock{
+					TLVList: wire.TLVList{wire.NewTLVBE(wire.OServiceUserInfoOscarCaps, b)},
+				},
+			})
+			// Apply it, as the real service would, so a follow-up call sees it.
+			instance.SetCaps(gotCaps)
+		}).Return(nil).Maybe()
+
+	handler := &PresenceHandler{
+		SessionManager: sessionMgr,
+		LocateService:  locate,
+		Logger:         slog.Default(),
+	}
+
+	req, err := http.NewRequest("GET", "/presence/setStatus?aimsid="+aimsid+query, nil)
+	require.NoError(t, err)
+
+	rr := httptest.NewRecorder()
+	requireSession(handler.SessionManager, handler.SetStatus).ServeHTTP(rr, req)
+
+	session, err := sessionMgr.GetSession(context.Background(), aimsid)
+	require.NoError(t, err)
+
+	if !called {
+		return nil, session, rr.Code
+	}
+	return gotCaps, session, rr.Code
+}
+
+func TestPresenceHandler_SetStatus_Mood(t *testing.T) {
+	tests := []struct {
+		name string
+		// query is appended to the request, after aimsid.
+		query string
+		// wantCaps is the capability list that must reach SetInfo. A nil value
+		// means SetInfo must not be called at all.
+		wantCaps [][16]byte
+		// wantMoodIcon is the id parameter expected on the queued myInfo, or ""
+		// for no mood.
+		wantMoodID string
+		// wantCode defaults to 200.
+		wantCode int
+	}{
+		{
+			name:       "a known mood is advertised as its capability",
+			query:      "&mood=0icqmood6",
+			wantCaps:   [][16]byte{wire.CapXStatusPlate},
+			wantMoodID: "0icqmood6",
+		},
+		{
+			name:       "a mood with only a placeholder capability still resolves",
+			query:      "&mood=0icqmood13",
+			wantCaps:   [][16]byte{wire.CapMoodHavingFun},
+			wantMoodID: "0icqmood13",
+		},
+		{
+			// The client sends mood= alongside every plain state change, so this
+			// is the path back to a moodless online/away/invisible.
+			name:     "an empty mood clears the capability",
+			query:    "&mood=",
+			wantCaps: [][16]byte{},
+		},
+		{
+			// A token the server cannot map is a client bug, not a reset: the
+			// mood the user is actually showing is left alone.
+			name:     "an unrecognized mood is rejected",
+			query:    "&mood=0icqmood999",
+			wantCode: http.StatusBadRequest,
+		},
+		{
+			// A status-message-only update must leave the mood alone.
+			name:  "an absent mood parameter leaves capabilities untouched",
+			query: "&statusMsg=hello",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			instance := state.NewSession().AddInstance()
+
+			gotCaps, session, code := setStatusCaps(t, instance, tt.query)
+
+			if tt.wantCode != 0 {
+				assert.Equal(t, tt.wantCode, code)
+				assert.Nil(t, gotCaps, "a rejected mood must not reach SetInfo")
+				return
+			}
+
+			assert.Equal(t, http.StatusOK, code)
+			assert.Equal(t, tt.wantCaps, gotCaps)
+
+			myInfo := queuedMyInfo(session)
+			require.NotNil(t, myInfo, "setStatus must queue a myInfo event")
+			if tt.wantMoodID == "" {
+				assert.Empty(t, myInfo.MoodIcon)
+				return
+			}
+			assert.Equal(t, "/mood?id="+wire.MoodIconID(tt.wantMoodID), myInfo.MoodIcon)
+		})
+	}
+}
+
+func TestPresenceHandler_SetStatus_MoodReplacesRatherThanAccumulates(t *testing.T) {
+	instance := state.NewSession().AddInstance()
+
+	_, _, code := setStatusCaps(t, instance, "&mood=0icqmood6")
+	assert.Equal(t, http.StatusOK, code)
+
+	gotCaps, _, code := setStatusCaps(t, instance, "&mood=0icqmood4")
+	assert.Equal(t, http.StatusOK, code)
+	assert.Equal(t, [][16]byte{wire.CapXStatusBeer}, gotCaps, "the previous mood must be dropped")
+}
+
+func TestPresenceHandler_SetStatus_PreservesNonMoodCaps(t *testing.T) {
+	// The capability list is rewritten wholesale, so anything the instance
+	// advertises that is not a mood has to be carried over.
+	instance := state.NewSession().AddInstance()
+	instance.SetCaps([][16]byte{wire.CapChat, wire.CapXStatusBeer})
+
+	gotCaps, _, code := setStatusCaps(t, instance, "&mood=0icqmood6")
+	assert.Equal(t, http.StatusOK, code)
+	assert.Equal(t, [][16]byte{wire.CapChat, wire.CapXStatusPlate}, gotCaps)
+
+	gotCaps, _, code = setStatusCaps(t, instance, "&mood=")
+	assert.Equal(t, http.StatusOK, code)
+	assert.Equal(t, [][16]byte{wire.CapChat}, gotCaps, "clearing a mood must keep the other caps")
+}
+
+func TestPresenceHandler_SetStatus_SetInfoError(t *testing.T) {
+	instance := state.NewSession().AddInstance()
+	sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", instance)
+
+	locate := newMockLocateService(t)
+	locate.EXPECT().SetInfo(mock.Anything, mock.Anything, mock.Anything).Return(io.EOF)
+
+	handler := &PresenceHandler{
+		SessionManager: sessionMgr,
+		LocateService:  locate,
+		Logger:         slog.Default(),
+	}
+
+	req, err := http.NewRequest("GET", "/presence/setStatus?aimsid="+aimsid+"&mood=0icqmood6", nil)
+	require.NoError(t, err)
+
+	rr := httptest.NewRecorder()
+	requireSession(handler.SessionManager, handler.SetStatus).ServeHTTP(rr, req)
+
+	assert.Equal(t, http.StatusInternalServerError, rr.Code)
+}

+ 1 - 0
server/webapi/server.go

@@ -42,6 +42,7 @@ func NewServer(listeners []string, logger *slog.Logger, handler Handler, session
 		AuthService:      handler.AuthService,
 		FeedbagService:   handler.FeedbagService,
 		ICBMService:      handler.ICBMService,
+		LocateService:    handler.LocateService,
 		OServiceService:  handler.OServiceService,
 		BuddyListManager: handler.BuddyListManager,
 		BuddyService:     handler.BuddyService,

+ 6 - 0
server/webapi/session.go

@@ -544,6 +544,12 @@ func (s *Session) handleBuddyArrived(msg wire.SNACMessage) {
 		UserType: "aim",
 	}
 
+	if buddy.UIN() > 0 {
+		presenceEvent.UserType = "icq"
+	}
+
+	presenceEvent.MoodIcon = moodIconURL(s.BaseURL, stateStr, userInfoCaps(body.TLVUserInfo))
+
 	// A BuddyArrived carries the buddy's current icon in TLV 0x1D whenever they
 	// have one, so an icon change (or clear, which arrives as the sentinel hash)
 	// rides along on the presence broadcast. Publish the matching URL: with an

+ 87 - 0
server/webapi/session_test.go

@@ -1230,3 +1230,90 @@ func TestSession_RecordSentIMEvictsOldestCookie(t *testing.T) {
 	assert.Equal(t, "msg-1-again", sess.msgIDForCookie(1))
 	assert.Len(t, sess.sentIMs, sentIMCookieLimit)
 }
+
+// A buddy's mood rides along in the capability list of a BuddyArrived, so the
+// presence event has to carry it as a mood icon URL.
+func TestSession_PublishesMoodOnPresence(t *testing.T) {
+	newSession := func() *Session {
+		return &Session{
+			ScreenName: state.DisplayScreenName("me"),
+			BaseURL:    "http://host",
+			Events:     []string{"presence"},
+			EventQueue: NewEventQueue(10),
+			logger:     slog.New(slog.NewTextHandler(io.Discard, nil)),
+		}
+	}
+
+	// arrived delivers a BuddyArrived carrying a raw capabilities TLV value.
+	arrived := func(sess *Session, caps []byte, invisible bool) PresenceEvent {
+		info := wire.TLVUserInfo{ScreenName: "Mike Kelly"}
+		if caps != nil {
+			info.Append(wire.NewTLVBE(wire.OServiceUserInfoOscarCaps, caps))
+		}
+		if invisible {
+			info.Append(wire.NewTLVBE(wire.OServiceUserInfoStatus, wire.OServiceUserStatusInvisible))
+		}
+		sess.handleBuddyArrived(wire.SNACMessage{Body: wire.SNAC_0x03_0x0B_BuddyArrived{TLVUserInfo: info}})
+
+		events := sess.EventQueue.GetAllEvents()
+		require.Len(t, events, 1)
+		return events[0].Data.(PresenceEvent)
+	}
+
+	capBytes := func(caps ...[16]byte) []byte {
+		var b []byte
+		for _, c := range caps {
+			b = append(b, c[:]...)
+		}
+		return b
+	}
+
+	t.Run("a mood capability yields the mood icon URL", func(t *testing.T) {
+		got := arrived(newSession(), capBytes(wire.CapXStatusBeer), false)
+		assert.Equal(t, "http://host/mood?id="+wire.MoodIconID("0icqmood4"), got.MoodIcon)
+	})
+
+	t.Run("a placeholder capability resolves too", func(t *testing.T) {
+		got := arrived(newSession(), capBytes(wire.CapMoodOnTheWay), false)
+		assert.Equal(t, "http://host/mood?id="+wire.MoodIconID("0icqmood83"), got.MoodIcon)
+	})
+
+	t.Run("a shared capability resolves to the canonical mood", func(t *testing.T) {
+		// Console is reachable from 0icqmood15 and 0icqmood81; the table's first
+		// entry wins, so the client is told the one it labels "gamepad".
+		got := arrived(newSession(), capBytes(wire.CapXStatusConsole), false)
+		assert.Equal(t, "http://host/mood?id="+wire.MoodIconID("0icqmood81"), got.MoodIcon)
+	})
+
+	t.Run("a mood is found after other capabilities", func(t *testing.T) {
+		got := arrived(newSession(), capBytes(wire.CapChat, wire.CapXStatusBeer), false)
+		assert.Equal(t, "http://host/mood?id="+wire.MoodIconID("0icqmood4"), got.MoodIcon)
+	})
+
+	t.Run("capabilities carrying no mood yield no icon", func(t *testing.T) {
+		assert.Empty(t, arrived(newSession(), capBytes(wire.CapChat), false).MoodIcon)
+	})
+
+	t.Run("no capabilities TLV yields no icon", func(t *testing.T) {
+		assert.Empty(t, arrived(newSession(), nil, false).MoodIcon)
+	})
+
+	t.Run("a trailing partial capability is ignored", func(t *testing.T) {
+		// A truncated final chunk must not be read as a capability, nor panic.
+		truncated := append(capBytes(wire.CapXStatusBeer), 0x01, 0x02, 0x03)
+		got := arrived(newSession(), truncated, false)
+		assert.Equal(t, "http://host/mood?id="+wire.MoodIconID("0icqmood4"), got.MoodIcon)
+	})
+
+	t.Run("a partial capability alone yields no icon", func(t *testing.T) {
+		assert.Empty(t, arrived(newSession(), []byte{0x01, 0x02, 0x03}, false).MoodIcon)
+	})
+
+	t.Run("an invisible buddy shows no mood", func(t *testing.T) {
+		// A mood supersedes state on the client, so one here would render the
+		// buddy as present instead of offline.
+		got := arrived(newSession(), capBytes(wire.CapXStatusBeer), true)
+		assert.Equal(t, "offline", got.State)
+		assert.Empty(t, got.MoodIcon)
+	})
+}

+ 1 - 1
server/webapi/xml_shape_test.go

@@ -118,7 +118,7 @@ func TestXMLItemNamesMatchSpec(t *testing.T) {
 // myInfo is the payload the spec documents in the most detail, and the one the
 // old hand-built XML truncated to two fields.
 func TestXMLMyInfoCarriesEveryField(t *testing.T) {
-	mi := buildMyInfo(state.DisplayScreenName("ChattingChuck"), "away", "http://host/icon")
+	mi := buildMyInfo(state.DisplayScreenName("ChattingChuck"), "away", "http://host/icon", "")
 	mi.OnlineTime = 100
 	mi.AwayMsg = "I'm busy right now chatting."
 

+ 45 - 0
state/session.go

@@ -8,6 +8,7 @@ import (
 	"sync"
 	"time"
 
+	"github.com/google/uuid"
 	"github.com/mk6i/open-oscar-server/wire"
 )
 
@@ -1351,6 +1352,24 @@ func (s *SessionInstance) closeOnly() {
 // User Settings / Attributes
 //
 
+// Caps returns a copy of this instance's capability UUIDs. Unlike Session.Caps
+// it does not merge the other instances, so a caller rewriting this instance's
+// list does not adopt theirs.
+func (s *SessionInstance) Caps() [][16]byte {
+	s.mutex.RLock()
+	defer s.mutex.RUnlock()
+	return slices.Clone(s.capabilities)
+}
+
+// ClearMood removes the mood capability the instance advertises, if any. The
+// user then presents whatever presence state they are in.
+func (s *SessionInstance) ClearMood() {
+	s.mutex.Lock()
+	defer s.mutex.Unlock()
+
+	s.clearMood()
+}
+
 // ClearUserInfoFlag clears a flag from the user info bitmask.
 func (s *SessionInstance) ClearUserInfoFlag(flag uint16) (flags uint16) {
 	s.mutex.Lock()
@@ -1427,6 +1446,24 @@ func (s *SessionInstance) SetKerberosAuth(enabled bool) {
 	s.kerberosAuth = enabled
 }
 
+// SetMood replaces the mood capability the instance advertises. A client shows
+// one mood at a time, so whichever mood was set before is dropped.
+//
+// It panics when mood is not a mood capability: the caller resolves it from the
+// mood table, so anything else is a programming error rather than bad input.
+func (s *SessionInstance) SetMood(mood uuid.UUID) {
+	s.mutex.Lock()
+	defer s.mutex.Unlock()
+
+	if !wire.IsMoodCap(mood) {
+		panic("uuid is not a mood capability")
+	}
+
+	s.clearMood()
+
+	s.capabilities = append(s.capabilities, mood)
+}
+
 // SetMultiConnFlag sets the multi-connection flag for this instance.
 func (s *SessionInstance) SetMultiConnFlag(flag wire.MultiConnFlag) {
 	s.mutex.Lock()
@@ -1515,6 +1552,14 @@ func (s *SessionInstance) caps() [][16]byte {
 	return s.capabilities
 }
 
+// clearMood drops every mood capability the instance advertises. The caller must
+// hold s.mutex.
+func (s *SessionInstance) clearMood() {
+	s.capabilities = slices.DeleteFunc(s.capabilities, func(cap [16]byte) bool {
+		return wire.IsMoodCap(cap)
+	})
+}
+
 //
 // Message Sending
 //

+ 89 - 0
state/session_test.go

@@ -2519,3 +2519,92 @@ func TestSession_InstanceNumberAssignment(t *testing.T) {
 		})
 	})
 }
+
+func TestSessionInstance_Caps(t *testing.T) {
+	t.Run("returns the instance's own capabilities, not the session's union", func(t *testing.T) {
+		sess := NewSession()
+		first := sess.AddInstance()
+		second := sess.AddInstance()
+
+		first.SetCaps([][16]byte{wire.CapChat})
+		second.SetCaps([][16]byte{wire.CapFileTransfer})
+
+		assert.Equal(t, [][16]byte{wire.CapChat}, first.Caps())
+		assert.Len(t, sess.Caps(), 2)
+	})
+
+	t.Run("returns a copy the caller cannot write through", func(t *testing.T) {
+		instance := NewSession().AddInstance()
+		instance.SetCaps([][16]byte{wire.CapChat})
+
+		caps := instance.Caps()
+		caps[0] = wire.CapFileTransfer
+
+		assert.Equal(t, [][16]byte{wire.CapChat}, instance.Caps())
+	})
+
+	t.Run("an instance with no capabilities returns an empty list", func(t *testing.T) {
+		assert.Empty(t, NewSession().AddInstance().Caps())
+	})
+}
+
+func TestSessionInstance_SetMood(t *testing.T) {
+	t.Run("advertises the mood alongside the existing capabilities", func(t *testing.T) {
+		instance := NewSession().AddInstance()
+		instance.SetCaps([][16]byte{wire.CapChat})
+
+		instance.SetMood(wire.CapXStatusBeer)
+
+		assert.Equal(t, [][16]byte{wire.CapChat, wire.CapXStatusBeer}, instance.Caps())
+	})
+
+	t.Run("a second mood replaces the first", func(t *testing.T) {
+		// A client shows one mood at a time, so the moods must not accumulate.
+		instance := NewSession().AddInstance()
+		instance.SetCaps([][16]byte{wire.CapChat})
+
+		instance.SetMood(wire.CapXStatusBeer)
+		instance.SetMood(wire.CapXStatusMusic)
+
+		assert.Equal(t, [][16]byte{wire.CapChat, wire.CapXStatusMusic}, instance.Caps())
+	})
+
+	t.Run("panics on a capability that is not a mood", func(t *testing.T) {
+		instance := NewSession().AddInstance()
+		assert.Panics(t, func() {
+			instance.SetMood(wire.CapChat)
+		})
+	})
+}
+
+func TestSessionInstance_ClearMood(t *testing.T) {
+	t.Run("removes the mood and keeps every other capability", func(t *testing.T) {
+		instance := NewSession().AddInstance()
+		instance.SetCaps([][16]byte{wire.CapChat, wire.CapFileTransfer})
+		instance.SetMood(wire.CapXStatusBeer)
+
+		instance.ClearMood()
+
+		assert.Equal(t, [][16]byte{wire.CapChat, wire.CapFileTransfer}, instance.Caps())
+	})
+
+	t.Run("is a no-op when no mood is set", func(t *testing.T) {
+		instance := NewSession().AddInstance()
+		instance.SetCaps([][16]byte{wire.CapChat})
+
+		instance.ClearMood()
+
+		assert.Equal(t, [][16]byte{wire.CapChat}, instance.Caps())
+	})
+
+	t.Run("removes a mood a client advertised through SetCaps", func(t *testing.T) {
+		// An ICQ client sets its mood by sending it in its own capability list,
+		// so a mood can arrive without SetMood ever being called.
+		instance := NewSession().AddInstance()
+		instance.SetCaps([][16]byte{wire.CapXStatusBeer, wire.CapChat})
+
+		instance.ClearMood()
+
+		assert.Equal(t, [][16]byte{wire.CapChat}, instance.Caps())
+	})
+}

+ 190 - 0
wire/xtraz.go

@@ -1,11 +1,16 @@
 package wire
 
 import (
+	"encoding/hex"
 	"encoding/xml"
 	"errors"
+	"fmt"
 	"html"
+	"math"
 	"strconv"
 	"strings"
+
+	"github.com/google/uuid"
 )
 
 const (
@@ -170,3 +175,188 @@ func BuildXtrazNotifyRequest(senderUIN string) string {
 		`<senderId>` + senderUIN + `</senderId></req></srv></NOTIFY></N>`
 	return MangleXtrazXML(xml)
 }
+
+// XStatus mood capability IDs, advertised alongside the regular capabilities to
+// indicate which mood icon a client is displaying.
+
+var (
+	// CapXStatusThinking is the UUID for the "thinking" mood
+	CapXStatusThinking = uuid.MustParse("3FB0BD36-AF3B-4A60-9EEF-CF190F6A5A7F")
+	// CapXStatusBusy is the UUID for the "busy" mood
+	CapXStatusBusy = uuid.MustParse("488E1489-8ACA-4A08-82AA-77CE7A165208")
+	// CapXStatusShopping is the UUID for the "shopping" mood
+	CapXStatusShopping = uuid.MustParse("63627337-A03F-49FF-80E5-F709CDE0A4EE")
+	// CapXStatusTypingAlt is an alternate UUID for the "typing" mood that ICQ 6
+	// does not set for its typewriter icon
+	CapXStatusTypingAlt = uuid.MustParse("634F6BD8-ADD2-4AA1-AAB9-115BC26D05A1")
+	// CapXStatusQuestion is the UUID for the "question" mood
+	CapXStatusQuestion = uuid.MustParse("631436FF-3F8A-40D0-A5CB-7B66E051B364")
+	// CapXStatusAngry is the UUID for the "angry" mood
+	CapXStatusAngry = uuid.MustParse("01D8D7EE-AC3B-492A-A58D-D3D877E66B92")
+	// CapXStatusPlate is the UUID for the "plate" (eating) mood
+	CapXStatusPlate = uuid.MustParse("F8E8D7B2-82C4-4142-90F8-10C6CE0A89A6")
+	// CapXStatusCinema is the UUID for the "cinema" mood
+	CapXStatusCinema = uuid.MustParse("107A9A18-1232-4DA4-B6CD-0879DB780F09")
+	// CapXStatusSick is the UUID for the "sick" mood
+	CapXStatusSick = uuid.MustParse("1F7A4071-BF3B-4E60-BC32-4C5787B04CF1")
+	// CapXStatusTyping is the UUID for the "typing" mood
+	CapXStatusTyping = uuid.MustParse("2CE0E4E5-7C64-4370-9C3A-7A1CE878A7DC")
+	// CapXStatusSuit is the UUID for the "suit" mood
+	CapXStatusSuit = uuid.MustParse("B70867F5-3825-4327-A1FF-CF4CC1939797")
+	// CapXStatusBathing is the UUID for the "bathing" mood
+	CapXStatusBathing = uuid.MustParse("5A581EA1-E580-430C-A06F-612298B7E4C7")
+	// CapXStatusTV is the UUID for the "tv" mood
+	CapXStatusTV = uuid.MustParse("80537DE2-A467-4A76-B354-6DFD075F5EC6")
+	// CapXStatusExcited is the UUID for the "excited" mood
+	CapXStatusExcited = uuid.MustParse("6F493098-4F7C-4AFF-A276-34A03BCEAEA7")
+	// CapXStatusSleeping is the UUID for the "sleeping" mood
+	CapXStatusSleeping = uuid.MustParse("785E8C48-40D3-4C65-886F-04CF3F3F43DF")
+	// CapXStatusHiptop is the UUID for the "hiptop" mood
+	CapXStatusHiptop = uuid.MustParse("101117C9-A3B0-40F9-81AC-49E159FBD5D4")
+	// CapXStatusInLove is the UUID for the "in love" mood
+	CapXStatusInLove = uuid.MustParse("DDCF0EA9-7195-4048-A9C6-413206D6F280")
+	// CapXStatusSleepy is the UUID for the "sleepy" mood
+	CapXStatusSleepy = uuid.MustParse("83C9B78E-77E7-4378-B2C5-FB6CFCC35BEC")
+	// CapXStatusMeeting is the UUID for the "meeting" mood
+	CapXStatusMeeting = uuid.MustParse("F18AB52E-DC57-491D-99DC-6444502457AF")
+	// CapXStatusPhone is the UUID for the "phone" mood
+	CapXStatusPhone = uuid.MustParse("1292E550-1B64-4F66-B206-B29AF378E48D")
+	// CapXStatusSurfing is the UUID for the "surfing" mood
+	CapXStatusSurfing = uuid.MustParse("A6ED557E-6BF7-44D4-A5D4-D2E7D95CE81F")
+	// CapXStatusMobile is the UUID for the "mobile" mood
+	CapXStatusMobile = uuid.MustParse("160C60BB-DD44-43F3-9140-050F00E6C009")
+	// CapXStatusSearch is the UUID for the "search" mood
+	CapXStatusSearch = uuid.MustParse("D4E2B0BA-334E-4FA5-98D0-117DBF4D3CC8")
+	// CapXStatusParty is the UUID for the "party" mood
+	CapXStatusParty = uuid.MustParse("E601E41C-3373-4BD1-BC06-811D6C323D81")
+	// CapXStatusCoffee is the UUID for the "coffee" mood
+	CapXStatusCoffee = uuid.MustParse("1B78AE31-FA0B-4D38-93D1-997EEEAFB218")
+	// CapXStatusConsole is the UUID for the "console" (gaming) mood
+	CapXStatusConsole = uuid.MustParse("D4A611D0-8F01-4EC0-9223-C5B6BEC6CCF0")
+	// CapXStatusInternet is the UUID for the "internet" mood
+	CapXStatusInternet = uuid.MustParse("12D07E3E-F885-489E-8E97-A72A6551E58D")
+	// CapXStatusCigarette is the UUID for the "cigarette" mood
+	CapXStatusCigarette = uuid.MustParse("6443C6AF-2260-4517-B58C-D7DF8E290352")
+	// CapXStatusWriting is the UUID for the "writing" mood
+	CapXStatusWriting = uuid.MustParse("0072D908-4AD1-43DD-9199-6F026966026F")
+	// CapXStatusBeer is the UUID for the "beer" mood
+	CapXStatusBeer = uuid.MustParse("8C50DBAE-81ED-4786-ACCA-16CC3213C7B7")
+	// CapXStatusMusic is the UUID for the "music" mood
+	CapXStatusMusic = uuid.MustParse("61BEE0DD-8BDD-475D-8DEE-5F4BAACF19A7")
+	// CapXStatusStudying is the UUID for the "studying" mood
+	CapXStatusStudying = uuid.MustParse("609D52F8-A29A-49A6-B2A0-2524C5E9D260")
+	// CapXStatusWorking is the UUID for the "working" mood
+	CapXStatusWorking = uuid.MustParse("BA74DB3E-9E24-434B-87B6-2F6B8DFEE50F")
+	// CapXStatusRestroom is the UUID for the "restroom" mood
+	CapXStatusRestroom = uuid.MustParse("16F5B76F-A9D2-4035-8CC5-C084703C98FA")
+)
+
+// A mood is a capability UUID ICQ clients advertise; Web API clients name the
+// same moods with a token string.
+
+// Invented capabilities for the four moods ICQ never assigned a UUID to, so at
+// least clients of this server resolve them. Namespaced under "MOOD" in ASCII
+// and ending in the mood number, they cannot collide with a genuine capability.
+var (
+	CapMoodHavingFun = uuid.MustParse("4D4F4F44-0000-0000-0000-00000000000D")
+	CapMoodLove      = uuid.MustParse("4D4F4F44-0000-0000-0000-00000000003D")
+	CapMoodWeekend   = uuid.MustParse("4D4F4F44-0000-0000-0000-000000000042")
+	CapMoodOnTheWay  = uuid.MustParse("4D4F4F44-0000-0000-0000-000000000053")
+)
+
+// Mood is one ICQ XStatus mood, in both of the forms it travels in.
+type Mood struct {
+	ID  string    // the token Web API clients exchange, e.g. "0icqmood6"
+	Cap uuid.UUID // the capability OSCAR clients advertise
+}
+
+// Moods lists every mood in the order ICQ web clients enumerate them. Where two
+// moods share a capability, the one listed first is its canonical name.
+var Moods = []Mood{
+	{ID: "0icqmood0", Cap: CapXStatusShopping},  // shopping
+	{ID: "0icqmood1", Cap: CapXStatusBathing},   // bathing
+	{ID: "0icqmood2", Cap: CapXStatusSleepy},    // tired
+	{ID: "0icqmood3", Cap: CapXStatusParty},     // party
+	{ID: "0icqmood4", Cap: CapXStatusBeer},      // beer
+	{ID: "0icqmood5", Cap: CapXStatusThinking},  // thinking
+	{ID: "0icqmood6", Cap: CapXStatusPlate},     // eating
+	{ID: "0icqmood7", Cap: CapXStatusTV},        // tv
+	{ID: "0icqmood8", Cap: CapXStatusMeeting},   // friends
+	{ID: "0icqmood9", Cap: CapXStatusCoffee},    // coffee
+	{ID: "0icqmood10", Cap: CapXStatusMusic},    // music
+	{ID: "0icqmood11", Cap: CapXStatusSuit},     // business
+	{ID: "0icqmood12", Cap: CapXStatusCinema},   // cinema
+	{ID: "0icqmood13", Cap: CapMoodHavingFun},   // having fun
+	{ID: "0icqmood14", Cap: CapXStatusPhone},    // phone
+	{ID: "0icqmood81", Cap: CapXStatusConsole},  // gamepad
+	{ID: "0icqmood16", Cap: CapXStatusStudying}, // studying
+	{ID: "0icqmood17", Cap: CapXStatusSick},     // sick
+	{ID: "0icqmood70", Cap: CapXStatusSleeping}, // sleeping
+	{ID: "0icqmood19", Cap: CapXStatusSurfing},  // surfing
+	{ID: "0icqmood20", Cap: CapXStatusInternet}, // internet
+	{ID: "0icqmood21", Cap: CapXStatusWorking},  // working
+	{ID: "0icqmood22", Cap: CapXStatusTyping},   // typing
+	{ID: "0icqmood23", Cap: CapXStatusAngry},    // angry
+	{ID: "0icqmood66", Cap: CapMoodWeekend},     // weekend
+	{ID: "0icqmood15", Cap: CapXStatusConsole},  // PSP
+	{ID: "0icqmood71", Cap: CapXStatusMobile},   // on mobile
+	{ID: "0icqmood18", Cap: CapXStatusSleeping}, // fall asleep
+	{ID: "0icqmood68", Cap: CapXStatusRestroom}, // WC
+	{ID: "0icqmood77", Cap: CapXStatusQuestion}, // confused
+	{ID: "0icqmood83", Cap: CapMoodOnTheWay},    // on the way
+	{ID: "0icqmood61", Cap: CapMoodLove},        // love
+	{ID: "0icqmood76", Cap: CapXStatusInLove},   // in love
+	{ID: "0icqmood85", Cap: CapXStatusSearch},   // searching
+	{ID: "0icqmood84", Cap: CapXStatusWriting},  // diary
+}
+
+var (
+	moodsByID  = indexMoodsByID()
+	moodsByCap = indexMoodsByCap()
+)
+
+func indexMoodsByID() map[string]Mood {
+	index := make(map[string]Mood, len(Moods))
+	for _, m := range Moods {
+		index[m.ID] = m
+	}
+	return index
+}
+
+func indexMoodsByCap() map[uuid.UUID]Mood {
+	index := make(map[uuid.UUID]Mood, len(Moods))
+	for _, m := range Moods {
+		if _, taken := index[m.Cap]; !taken {
+			index[m.Cap] = m
+		}
+	}
+	return index
+}
+
+// MoodByID returns the mood named by a Web API mood token.
+func MoodByID(id string) (Mood, bool) {
+	m, ok := moodsByID[id]
+	return m, ok
+}
+
+// MoodByCap returns the mood advertised by a capability UUID.
+func MoodByCap(c [16]byte) (Mood, bool) {
+	m, ok := moodsByCap[uuid.UUID(c)]
+	return m, ok
+}
+
+// IsMoodCap reports whether c is a known mood capability.
+func IsMoodCap(c [16]byte) bool {
+	_, ok := MoodByCap(c)
+	return ok
+}
+
+// MoodIconID encodes a mood token for the id parameter of a mood icon URL: hex
+// of a two-byte big-endian length followed by the token, which is how ICQ
+// clients read it. An unencodable token yields "", which clients read as no mood.
+func MoodIconID(id string) string {
+	if id == "" || len(id) > math.MaxUint16 {
+		return ""
+	}
+	return fmt.Sprintf("%04x%s", len(id), hex.EncodeToString([]byte(id)))
+}

+ 178 - 0
wire/xtraz_test.go

@@ -1,8 +1,13 @@
 package wire
 
 import (
+	"encoding/binary"
+	"encoding/hex"
+	"math"
+	"strings"
 	"testing"
 
+	"github.com/google/uuid"
 	"github.com/stretchr/testify/assert"
 )
 
@@ -227,3 +232,176 @@ func TestXStatusConstants(t *testing.T) {
 	assert.Equal(t, uint8(1), XStatusAngry)
 	assert.Equal(t, uint8(32), XStatusCoffee2)
 }
+
+func TestMoodByID(t *testing.T) {
+	tests := []struct {
+		name string
+		id   string
+		want Mood
+		ok   bool
+	}{
+		{
+			name: "mood with a real ICQ capability",
+			id:   "0icqmood6",
+			want: Mood{ID: "0icqmood6", Cap: CapXStatusPlate},
+			ok:   true,
+		},
+		{
+			name: "mood with a placeholder capability",
+			id:   "0icqmood13",
+			want: Mood{ID: "0icqmood13", Cap: CapMoodHavingFun},
+			ok:   true,
+		},
+		{
+			name: "unknown token",
+			id:   "0icqmood999",
+		},
+		{
+			name: "token missing the leading zero",
+			id:   "icqmood6",
+		},
+		{
+			name: "empty token",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got, ok := MoodByID(tt.id)
+			assert.Equal(t, tt.ok, ok)
+			assert.Equal(t, tt.want, got)
+		})
+	}
+}
+
+func TestMoodByCap(t *testing.T) {
+	tests := []struct {
+		name   string
+		cap    uuid.UUID
+		wantID string
+		ok     bool
+	}{
+		{
+			name:   "capability used by one mood",
+			cap:    CapXStatusBeer,
+			wantID: "0icqmood4",
+			ok:     true,
+		},
+		{
+			name:   "capability shared by two moods returns the canonical one",
+			cap:    CapXStatusConsole,
+			wantID: "0icqmood81",
+			ok:     true,
+		},
+		{
+			name:   "other shared capability returns the canonical one",
+			cap:    CapXStatusSleeping,
+			wantID: "0icqmood70",
+			ok:     true,
+		},
+		{
+			name:   "placeholder capability",
+			cap:    CapMoodOnTheWay,
+			wantID: "0icqmood83",
+			ok:     true,
+		},
+		{
+			name: "capability that is not a mood",
+			cap:  CapChat,
+		},
+		{
+			name: "nil capability",
+			cap:  uuid.Nil,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got, ok := MoodByCap(tt.cap)
+			assert.Equal(t, tt.ok, ok)
+			assert.Equal(t, tt.wantID, got.ID)
+			assert.Equal(t, tt.ok, IsMoodCap(tt.cap))
+		})
+	}
+}
+
+// TestMoodsRoundTrip verifies every token resolves to itself and every
+// capability to a token that carries it.
+func TestMoodsRoundTrip(t *testing.T) {
+	canonical := make(map[uuid.UUID]string)
+
+	for _, m := range Moods {
+		t.Run(m.ID, func(t *testing.T) {
+			byID, ok := MoodByID(m.ID)
+			assert.True(t, ok)
+			assert.Equal(t, m, byID)
+
+			// A mood with no capability is invisible to other users.
+			assert.NotEqual(t, uuid.Nil, m.Cap)
+
+			byCap, ok := MoodByCap(m.Cap)
+			assert.True(t, ok)
+			assert.Equal(t, m.Cap, byCap.Cap)
+		})
+
+		if first, seen := canonical[m.Cap]; seen {
+			// A shared capability resolves to whichever mood is listed first.
+			byCap, _ := MoodByCap(m.Cap)
+			assert.Equal(t, first, byCap.ID)
+			continue
+		}
+		canonical[m.Cap] = m.ID
+	}
+}
+
+func TestMoodIconID(t *testing.T) {
+	tests := []struct {
+		name string
+		id   string
+		want string
+	}{
+		{
+			name: "encodes length prefix and token bytes",
+			id:   "0icqmood6",
+			want: "0009306963716d6f6f6436",
+		},
+		{
+			name: "encodes a two digit mood",
+			id:   "0icqmood23",
+			want: "000a306963716d6f6f643233",
+		},
+		{
+			name: "empty token encodes to nothing",
+			id:   "",
+			want: "",
+		},
+		{
+			name: "oversized token encodes to nothing",
+			id:   strings.Repeat("a", math.MaxUint16+1),
+			want: "",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			assert.Equal(t, tt.want, MoodIconID(tt.id))
+		})
+	}
+}
+
+// TestMoodIconIDDecodes verifies every token encodes to something ICQ clients can
+// decode: even-length hex whose length prefix matches the token that follows.
+func TestMoodIconIDDecodes(t *testing.T) {
+	for _, m := range Moods {
+		t.Run(m.ID, func(t *testing.T) {
+			encoded := MoodIconID(m.ID)
+			assert.Zero(t, len(encoded)%2)
+
+			raw, err := hex.DecodeString(encoded)
+			assert.NoError(t, err)
+
+			assert.Equal(t, len(m.ID), int(binary.BigEndian.Uint16(raw[:2])))
+			assert.Equal(t, m.ID, string(raw[2:]))
+		})
+	}
+}