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

webapi: raise myInfo events from the user info SNAC

Mike 1 неделя назад
Родитель
Сommit
79ffb3457c

+ 0 - 11
server/webapi/aim_handler.go

@@ -330,17 +330,6 @@ func (h *AimHandler) StartSession(w http.ResponseWriter, r *http.Request) {
 		return h.IconSource.URLForHash(session.BaseURL, sn, hash)
 	}
 
-	// Wire the myInfo refresher so a self user-info update (icon upload/clear)
-	// re-renders the identity badge. currentWebState reflects the user's live
-	// presence; PublishedURL reflects the feedbag icon, already updated by the time
-	// the OServiceUserInfoUpdate is relayed.
-	session.MyInfoRefresher = func(ctx context.Context) (any, error) {
-		icon := h.IconSource.PublishedURL(ctx, session.BaseURL, screenName.IdentScreenName())
-		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.
 	session.PermitDenyRefresher = func(ctx context.Context) (any, error) {
 		frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagQuery}

+ 13 - 40
server/webapi/presence_handler.go

@@ -415,13 +415,6 @@ func (h *PresenceHandler) SetState(w http.ResponseWriter, r *http.Request, sessi
 		}
 	}
 
-	// Notify the user's own client so its status indicator re-renders. The AIM
-	// client updates its self-presence badge only from "myInfo" events; the
-	// "presence" broadcast above drives buddy dots, not the user's own state.
-	// Without this, changing to Busy/Away leaves the user still showing as
-	// available in their own UI.
-	h.pushMyInfo(ctx, session, reportedState, awayMsg, "")
-
 	h.Logger.InfoContext(ctx, "presence state updated",
 		"screenName", session.ScreenName.String(),
 		"state", stateParam,
@@ -475,10 +468,6 @@ func (h *PresenceHandler) SetStatus(w http.ResponseWriter, r *http.Request, sess
 		}
 	}
 
-	// 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(ctx, session, currentWebState(session.OSCARSession), session.OSCARSession.Session().AwayMessage(), statusMsg)
-
 	h.Logger.InfoContext(ctx, "status message updated",
 		"screenName", session.ScreenName.String(),
 		"statusMsg", statusMsg,
@@ -646,42 +635,26 @@ func statusMaskState(status uint32, isAIMCaller bool) string {
 	return st
 }
 
-// currentWebState maps an OSCAR session's presence flags to the web state string
-// the clients expect ("online", "away", "idle", "invisible", "occupied", "dnd").
-func currentWebState(instance *state.SessionInstance) string {
-	sess := instance.Session()
-	if sess.Invisible() {
+// selfWebState maps a user's own user info block to the web state string the
+// clients expect ("online", "away", "idle", "invisible", "occupied", "dnd").
+// Invisibility yields "invisible", not the "offline" a buddy sees.
+func selfWebState(info wire.TLVUserInfo, isAIMCaller bool) string {
+	if info.IsInvisible() {
 		return "invisible"
 	}
-	if st := statusMaskState(instance.UserStatusBitmask(), instance.IdentScreenName().UIN() == 0); st != "" {
+	if st := statusBitState(info, isAIMCaller); st != "" {
 		return st
 	}
-	switch {
-	case sess.Away():
+	if info.IsAway() {
 		return "away"
-	case instance.Idle():
-		return "idle"
-	default:
-		return "online"
 	}
-}
-
-// pushMyInfo queues a "myInfo" event on the user's own session so the AIM client
-// re-renders its self-presence badge. The client binds its identity-badge render
-// to "myInfo" events only, so state changes made via setState/setStatus are
-// invisible in the user's own UI unless a myInfo event is delivered.
-func (h *PresenceHandler) pushMyInfo(ctx context.Context, session *Session, webState, awayMsg, statusMsg string) {
-	if !session.IsSubscribedTo("myInfo") && !session.IsSubscribedTo("presence") {
-		return
+	if mask, ok := info.Uint32BE(wire.OServiceUserInfoStatus); ok && mask&wire.OServiceUserStatusAway != 0 {
+		return "away"
 	}
-
-	icon := h.IconSource.PublishedURL(ctx, session.BaseURL, session.ScreenName.IdentScreenName())
-	moodIcon := moodIconURL(session.BaseURL, webState, session.OSCARSession.Session().Caps())
-	myInfo := buildMyInfo(session.ScreenName, webState, icon, moodIcon)
-	myInfo.AwayMsg = awayMsg
-	myInfo.StatusMsg = statusMsg
-
-	session.EventQueue.Push(EventType("myInfo"), myInfo)
+	if idle, ok := info.Uint16BE(wire.OServiceUserInfoIdleTime); ok && idle > 0 {
+		return "idle"
+	}
+	return "online"
 }
 
 // userInfoCaps returns the capability UUIDs a user info block advertises.

+ 39 - 111
server/webapi/presence_handler_test.go

@@ -387,10 +387,10 @@ func TestPresenceHandler_SetState_InvalidState(t *testing.T) {
 	assert.Contains(t, rr.Body.String(), "invalid state parameter")
 }
 
-func TestPresenceHandler_SetState_EmitsMyInfoEvent(t *testing.T) {
-	// The AIM client re-renders its own status badge only from "myInfo" events,
-	// so setState must queue one on the user's own session for the change to be
-	// visible in their UI.
+func TestPresenceHandler_SetState_AppliesAwayState(t *testing.T) {
+	// setState only mutates the OSCAR session and broadcasts; the identity badge
+	// re-renders from the myInfo the SNAC pump raises, so the handler queues
+	// no event of its own.
 	oscarInstance := state.NewSession().AddInstance()
 	sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
 
@@ -410,20 +410,28 @@ func TestPresenceHandler_SetState_EmitsMyInfoEvent(t *testing.T) {
 	requireSession(handler.SessionManager, handler.SetState).ServeHTTP(rr, req)
 	assert.Equal(t, http.StatusOK, rr.Code)
 
+	assert.True(t, oscarInstance.Session().Away())
+	assert.Equal(t, "brb", oscarInstance.Session().AwayMessage())
+	assert.Equal(t, wire.OServiceUserStatusAway, oscarInstance.UserStatusBitmask())
+
+	var resp struct {
+		Response struct {
+			Data SetStateData `json:"data"`
+		} `json:"response"`
+	}
+	assert.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
+	assert.Equal(t, "away", resp.Response.Data.State)
+	assert.Equal(t, "brb", resp.Response.Data.AwayMsg)
+	assert.Equal(t, "testuser", resp.Response.Data.AimID)
+
 	session, err := sessionMgr.GetSession(context.Background(), aimsid)
 	assert.NoError(t, err)
-
-	myInfo := queuedMyInfo(session)
-	assert.NotNil(t, myInfo, "expected a myInfo event to be queued")
-	assert.Equal(t, "away", myInfo.State)
-	assert.Equal(t, "brb", myInfo.AwayMsg)
-	assert.Equal(t, "testuser", myInfo.AimID)
+	assert.Empty(t, session.EventQueue.GetAllEvents())
 }
 
-func TestPresenceHandler_SetState_MyInfoNormalizesAimID(t *testing.T) {
-	// The client shallow-merges myInfo onto the shared user object, so aimId must
-	// be the normalized id while displayId and friendly keep the user's own
-	// casing and spacing.
+func TestPresenceHandler_SetState_NormalizesAimID(t *testing.T) {
+	// The client keys users by the normalized aimId, so the response must carry
+	// that while displayId keeps the user's own casing and spacing.
 	oscarInstance := state.NewSession().AddInstance()
 	sessionMgr, aimsid := createTestSessionManagerWithOSCAR("Mike Kelly", oscarInstance)
 
@@ -452,15 +460,6 @@ func TestPresenceHandler_SetState_MyInfoNormalizesAimID(t *testing.T) {
 	assert.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
 	assert.Equal(t, "mikekelly", resp.Response.Data["aimId"])
 	assert.Equal(t, "Mike Kelly", resp.Response.Data["displayId"])
-
-	session, err := sessionMgr.GetSession(context.Background(), aimsid)
-	assert.NoError(t, err)
-
-	myInfo := queuedMyInfo(session)
-	require.NotNil(t, myInfo, "expected a myInfo event to be queued")
-	assert.Equal(t, "mikekelly", myInfo.AimID)
-	assert.Equal(t, "Mike Kelly", myInfo.DisplayID)
-	assert.Equal(t, "Mike Kelly", myInfo.Friendly)
 }
 
 func TestPresenceHandler_Icon(t *testing.T) {
@@ -616,18 +615,6 @@ 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
-	for _, event := range session.EventQueue.GetAllEvents() {
-		if event.Type == "myInfo" {
-			myInfo, _ = event.Data.(*MyInfo)
-		}
-	}
-	return myInfo
-}
-
 func TestPresenceHandler_SetState_Occupied(t *testing.T) {
 	// ICQ's Busy, which is a selectable connect state and must be accepted. An AIM
 	// caller is told "away" instead, since AIM 8 draws "occupied" as offline.
@@ -648,9 +635,6 @@ func TestPresenceHandler_SetState_Occupied(t *testing.T) {
 			oscarInstance := oscarSession.AddInstance()
 			sessionMgr, aimsid := createTestSessionManagerWithOSCAR(tt.screenName, oscarInstance)
 
-			session, err := sessionMgr.GetSession(context.Background(), aimsid)
-			assert.NoError(t, err)
-
 			broadcaster := newMockBuddyBroadcaster(t)
 			broadcaster.EXPECT().BroadcastBuddyArrived(mock.Anything, mock.Anything, mock.Anything).Return(nil)
 
@@ -671,13 +655,9 @@ func TestPresenceHandler_SetState_Occupied(t *testing.T) {
 			assert.Equal(t, wire.OServiceUserStatusBusy, oscarInstance.UserStatusBitmask())
 			assert.Contains(t, rr.Body.String(), `"state":"`+tt.wantState+`"`)
 
-			// The state must survive the round trip: a later myInfo push reads it back
-			// through currentWebState, and "online" there would undo the change.
-			assert.Equal(t, tt.wantState, currentWebState(oscarInstance))
-
-			myInfo := queuedMyInfo(session)
-			assert.NotNil(t, myInfo, "expected a myInfo event to be queued")
-			assert.Equal(t, tt.wantState, myInfo.State)
+			// The state must survive the round trip: the myInfo raised by the
+			// user info update reads the Busy bit back off the wire.
+			assert.Equal(t, tt.wantState, selfWebState(oscarSession.TLVUserInfo(), oscarSession.IdentScreenName().UIN() == 0))
 		})
 	}
 }
@@ -889,7 +869,7 @@ func searchPageTargets(n int) []string {
 
 // 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) {
+func setStatusCaps(t *testing.T, instance *state.SessionInstance, query string) ([][16]byte, int) {
 	t.Helper()
 
 	sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", instance)
@@ -924,13 +904,10 @@ func setStatusCaps(t *testing.T, instance *state.SessionInstance, query string)
 	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 nil, rr.Code
 	}
-	return gotCaps, session, rr.Code
+	return gotCaps, rr.Code
 }
 
 func TestPresenceHandler_SetStatus_Mood(t *testing.T) {
@@ -941,23 +918,18 @@ func TestPresenceHandler_SetStatus_Mood(t *testing.T) {
 		// 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 known mood is advertised as its capability",
+			query:    "&mood=0icqmood6",
+			wantCaps: [][16]byte{wire.CapXStatusPlate},
 		},
 		{
-			name:       "a mood with only a placeholder capability still resolves",
-			query:      "&mood=0icqmood13",
-			wantCaps:   [][16]byte{wire.CapMoodHavingFun},
-			wantMoodID: "0icqmood13",
+			name:     "a mood with only a placeholder capability still resolves",
+			query:    "&mood=0icqmood13",
+			wantCaps: [][16]byte{wire.CapMoodHavingFun},
 		},
 		{
 			// The client sends mood= alongside every plain state change, so this
@@ -984,7 +956,7 @@ func TestPresenceHandler_SetStatus_Mood(t *testing.T) {
 		t.Run(tt.name, func(t *testing.T) {
 			instance := state.NewSession().AddInstance()
 
-			gotCaps, session, code := setStatusCaps(t, instance, tt.query)
+			gotCaps, code := setStatusCaps(t, instance, tt.query)
 
 			if tt.wantCode != 0 {
 				assert.Equal(t, tt.wantCode, code)
@@ -994,14 +966,6 @@ func TestPresenceHandler_SetStatus_Mood(t *testing.T) {
 
 			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)
 		})
 	}
 }
@@ -1009,10 +973,10 @@ func TestPresenceHandler_SetStatus_Mood(t *testing.T) {
 func TestPresenceHandler_SetStatus_MoodReplacesRatherThanAccumulates(t *testing.T) {
 	instance := state.NewSession().AddInstance()
 
-	_, _, code := setStatusCaps(t, instance, "&mood=0icqmood6")
+	_, code := setStatusCaps(t, instance, "&mood=0icqmood6")
 	assert.Equal(t, http.StatusOK, code)
 
-	gotCaps, _, code := setStatusCaps(t, instance, "&mood=0icqmood4")
+	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")
 }
@@ -1023,11 +987,11 @@ func TestPresenceHandler_SetStatus_PreservesNonMoodCaps(t *testing.T) {
 	instance := state.NewSession().AddInstance()
 	instance.SetCaps([][16]byte{wire.CapChat, wire.CapXStatusBeer})
 
-	gotCaps, _, code := setStatusCaps(t, instance, "&mood=0icqmood6")
+	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=")
+	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")
 }
@@ -1053,39 +1017,3 @@ func TestPresenceHandler_SetStatus_SetInfoError(t *testing.T) {
 
 	assert.Equal(t, http.StatusInternalServerError, rr.Code)
 }
-
-func TestPresenceHandler_SetState_MyInfoCarriesBuddyIcon(t *testing.T) {
-	// Mandarin replaces its identity from each myInfo rather than merging into it,
-	// clearing its avatar when buddyIcon is absent.
-	oscarInstance := state.NewSession().AddInstance()
-	sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
-
-	session, err := sessionMgr.GetSession(context.Background(), aimsid)
-	require.NoError(t, err)
-	session.BaseURL = "http://api.example.com"
-
-	broadcaster := newMockBuddyBroadcaster(t)
-	broadcaster.EXPECT().BroadcastBuddyArrived(mock.Anything, mock.Anything, mock.Anything).Return(nil)
-
-	iconRetriever := newMockBuddyIconRetriever(t)
-	iconRetriever.EXPECT().BuddyIconMetadata(mock.Anything, state.NewIdentScreenName("testuser")).
-		Return(bartID([]byte{0xab, 0xcd}), nil)
-
-	handler := &PresenceHandler{
-		SessionManager:   sessionMgr,
-		BuddyBroadcaster: broadcaster,
-		IconSource:       BuddyIconSource{IconRetriever: iconRetriever, Logger: slog.Default()},
-		Logger:           slog.Default(),
-	}
-
-	req, err := http.NewRequest("GET", "/presence/setState?aimsid="+aimsid+"&state=away", nil)
-	require.NoError(t, err)
-
-	rr := httptest.NewRecorder()
-	requireSession(handler.SessionManager, handler.SetState).ServeHTTP(rr, req)
-	require.Equal(t, http.StatusOK, rr.Code)
-
-	myInfo := queuedMyInfo(session)
-	require.NotNil(t, myInfo, "expected a myInfo event to be queued")
-	assert.Equal(t, "http://api.example.com/expressions/get?t=testuser&type=buddyIcon&bartId=abcd", myInfo.BuddyIcon)
-}

+ 33 - 11
server/webapi/session.go

@@ -74,7 +74,6 @@ type Session struct {
 	RemoteAddr          string                                 // Client IP address
 	BuddyListRefresher  func(ctx context.Context) (any, error) // Called on feedbag changes to push buddylist event
 	PermitDenyRefresher func(ctx context.Context) (any, error) // Called on feedbag changes to push permitDeny event
-	MyInfoRefresher     func(ctx context.Context) (any, error) // Called on self user-info updates (e.g. icon change) to push myInfo event
 	BuddyAliasLoader    func(ctx context.Context) (map[string]string, error)
 	// BuddyIconURL formats the absolute buddyIcon URL for a buddy from the icon
 	// hash carried in a presence SNAC. Returns "" when no URL can be published.
@@ -254,20 +253,43 @@ func (s *Session) handleOServiceMessage(msg wire.SNACMessage) {
 // handleUserInfoUpdate surfaces OServiceUserInfoUpdate, which the server relays to
 // a user when their own user info changes (notably a buddy icon upload or clear).
 // The client re-renders its identity badge from myInfo events only, so we
-// translate this into a fresh myInfo.
+// translate this into a fresh myInfo. The away message is the one field read off
+// the session, since no user info block carries the text.
 func (s *Session) handleUserInfoUpdate(msg wire.SNACMessage) {
 	if !s.IsSubscribedTo("myInfo") && !s.IsSubscribedTo("presence") {
 		return
 	}
-	if s.MyInfoRefresher == nil {
+
+	body, ok := msg.Body.(wire.SNAC_0x01_0x0F_OServiceUserInfoUpdate)
+	if !ok || len(body.UserInfo) == 0 {
 		return
 	}
-	data, err := s.MyInfoRefresher(s.ctx)
-	if err != nil {
-		s.logger.Error("failed to refresh myInfo after user-info update", "err", err)
-		return
+	// Block 0 is the session's aggregated user info; any blocks after it
+	// describe individual instances.
+	info := body.UserInfo[0]
+
+	screenName := state.DisplayScreenName(info.ScreenName)
+	webState := selfWebState(info, screenName.IdentScreenName().UIN() == 0)
+
+	// A missing icon TLV yields a nil hash, which publishes the placeholder URL
+	// and so clears an icon the client still holds.
+	var hash []byte
+	if b, ok := info.Bytes(wire.OServiceUserInfoBARTInfo); ok {
+		var id wire.BARTID
+		if err := wire.UnmarshalBE(&id, bytes.NewBuffer(b)); err == nil {
+			hash = id.Hash
+		}
 	}
-	s.EventQueue.Push(EventType("myInfo"), data)
+
+	myInfo := buildMyInfo(
+		screenName,
+		webState,
+		s.BuddyIconURL(screenName.IdentScreenName(), hash),
+		moodIconURL(s.BaseURL, webState, userInfoCaps(info)),
+	)
+	myInfo.AwayMsg = s.OSCARSession.Session().AwayMessage()
+
+	s.EventQueue.Push(EventTypeMyInfo, myInfo)
 }
 
 // handleRateLimitUpdate translates a rate limit status change — broadcast by the
@@ -679,9 +701,9 @@ func NewSessionManager() *SessionManager {
 //
 // The session does not begin listening to its OSCAR instance yet: the caller
 // must wire the session's refresher callbacks (BuddyListRefresher, BuddyIconURL,
-// MyInfoRefresher, ...) and then call StartListeningToOSCARSession. Wiring them
-// after the listener starts would race the goroutine, which reads them as it
-// converts SNACs into events.
+// ...) and then call StartListeningToOSCARSession. Wiring them after the
+// listener starts would race the goroutine, which reads them as it converts
+// SNACs into events.
 func (m *SessionManager) CreateSession(screenName state.DisplayScreenName, events []string, oscarSession *state.SessionInstance, baseURL string, logger *slog.Logger) (*Session, error) {
 	m.mu.Lock()
 	defer m.mu.Unlock()

+ 140 - 31
server/webapi/session_test.go

@@ -769,60 +769,169 @@ func TestSession_PublishesBuddyIconOnPresence(t *testing.T) {
 	})
 }
 
-// A user's own icon change is relayed to their session as OServiceUserInfoUpdate,
-// which the pump turns into a myInfo event so the identity badge re-renders.
+// A change to a user's own info is relayed to their session as
+// OServiceUserInfoUpdate, which the pump turns into a myInfo event so the
+// identity badge re-renders.
 func TestSession_PushesMyInfoOnUserInfoUpdate(t *testing.T) {
-	newSession := func(events ...string) (*Session, *int) {
-		var refreshes int
+	newSession := func(events ...string) *Session {
 		return &Session{
-			ScreenName: state.DisplayScreenName("me"),
-			Events:     events,
-			EventQueue: NewEventQueue(10),
-			logger:     slog.New(slog.NewTextHandler(io.Discard, nil)),
-			MyInfoRefresher: func(_ context.Context) (any, error) {
-				refreshes++
-				return map[string]any{"aimId": "me", "buddyIcon": "icon:new"}, nil
+			ScreenName:   state.DisplayScreenName("me"),
+			OSCARSession: state.NewSession().AddInstance(),
+			BaseURL:      "http://api.example.com",
+			Events:       events,
+			EventQueue:   NewEventQueue(10),
+			logger:       slog.New(slog.NewTextHandler(io.Discard, nil)),
+			BuddyIconURL: func(sn state.IdentScreenName, hash []byte) string {
+				if len(hash) == 0 {
+					return "placeholder:" + sn.String()
+				}
+				return "icon:" + hex.EncodeToString(hash)
 			},
-		}, &refreshes
+		}
 	}
 
-	userInfoUpdate := wire.SNACMessage{Frame: wire.SNACFrame{
-		FoodGroup: wire.OService,
-		SubGroup:  wire.OServiceUserInfoUpdate,
-	}}
-
-	t.Run("subscribed session gets one myInfo event", func(t *testing.T) {
-		sess, refreshes := newSession("myInfo")
-		sess.handleSNACMessage(userInfoUpdate)
+	update := func(info wire.TLVUserInfo) wire.SNACMessage {
+		return wire.SNACMessage{
+			Frame: wire.SNACFrame{
+				FoodGroup: wire.OService,
+				SubGroup:  wire.OServiceUserInfoUpdate,
+			},
+			Body: wire.SNAC_0x01_0x0F_OServiceUserInfoUpdate{
+				UserInfo: []wire.TLVUserInfo{info},
+			},
+		}
+	}
 
+	lastMyInfo := func(t *testing.T, sess *Session) *MyInfo {
+		t.Helper()
 		events := sess.EventQueue.GetAllEvents()
 		require.Len(t, events, 1)
-		assert.Equal(t, "myInfo", string(events[0].Type))
-		assert.Equal(t, "icon:new", events[0].Data.(map[string]any)["buddyIcon"])
-		assert.Equal(t, 1, *refreshes)
+		require.Equal(t, EventTypeMyInfo, events[0].Type)
+		return events[0].Data.(*MyInfo)
+	}
+
+	t.Run("subscribed session gets one myInfo built from the SNAC", func(t *testing.T) {
+		sess := newSession("myInfo")
+		sess.handleSNACMessage(update(wire.TLVUserInfo{ScreenName: "Mike Kelly"}))
+
+		myInfo := lastMyInfo(t, sess)
+		assert.Equal(t, "mikekelly", myInfo.AimID)
+		assert.Equal(t, "Mike Kelly", myInfo.DisplayID)
+		assert.Equal(t, "Mike Kelly", myInfo.Friendly)
+		assert.Equal(t, "online", myInfo.State)
 	})
 
 	t.Run("a presence subscription also delivers myInfo", func(t *testing.T) {
-		sess, _ := newSession("presence")
-		sess.handleSNACMessage(userInfoUpdate)
+		sess := newSession("presence")
+		sess.handleSNACMessage(update(wire.TLVUserInfo{ScreenName: "me"}))
 		assert.Len(t, sess.EventQueue.GetAllEvents(), 1)
 	})
 
-	t.Run("unsubscribed session gets nothing and does not refresh", func(t *testing.T) {
-		sess, refreshes := newSession("im")
-		sess.handleSNACMessage(userInfoUpdate)
+	t.Run("unsubscribed session gets nothing", func(t *testing.T) {
+		sess := newSession("im")
+		sess.handleSNACMessage(update(wire.TLVUserInfo{ScreenName: "me"}))
 		assert.Empty(t, sess.EventQueue.GetAllEvents())
-		assert.Equal(t, 0, *refreshes)
 	})
 
 	t.Run("other OService subgroups are ignored", func(t *testing.T) {
-		sess, refreshes := newSession("myInfo")
+		sess := newSession("myInfo")
 		sess.handleSNACMessage(wire.SNACMessage{Frame: wire.SNACFrame{
 			FoodGroup: wire.OService,
 			SubGroup:  wire.OServiceRateParamsQuery,
 		}})
 		assert.Empty(t, sess.EventQueue.GetAllEvents())
-		assert.Equal(t, 0, *refreshes)
+	})
+
+	t.Run("an update carrying no user info block pushes nothing", func(t *testing.T) {
+		sess := newSession("myInfo")
+		sess.handleSNACMessage(wire.SNACMessage{
+			Frame: wire.SNACFrame{
+				FoodGroup: wire.OService,
+				SubGroup:  wire.OServiceUserInfoUpdate,
+			},
+			Body: wire.SNAC_0x01_0x0F_OServiceUserInfoUpdate{},
+		})
+		assert.Empty(t, sess.EventQueue.GetAllEvents())
+	})
+
+	t.Run("the away message comes from the session", func(t *testing.T) {
+		sess := newSession("myInfo")
+		// Session.AwayMessage reads only instances that are actually away.
+		sess.OSCARSession.SetUserInfoFlag(wire.OServiceUserFlagUnavailable)
+		sess.OSCARSession.SetAwayMessage("brb")
+
+		info := wire.TLVUserInfo{ScreenName: "me"}
+		info.Append(wire.NewTLVBE(wire.OServiceUserInfoUserFlags, wire.OServiceUserFlagUnavailable))
+		sess.handleSNACMessage(update(info))
+
+		myInfo := lastMyInfo(t, sess)
+		assert.Equal(t, "away", myInfo.State)
+		assert.Equal(t, "brb", myInfo.AwayMsg)
+	})
+
+	t.Run("a mood capability publishes its icon", func(t *testing.T) {
+		sess := newSession("myInfo")
+
+		info := wire.TLVUserInfo{ScreenName: "me"}
+		info.Append(wire.NewTLVBE(wire.OServiceUserInfoOscarCaps, wire.CapXStatusPlate[:]))
+		sess.handleSNACMessage(update(info))
+
+		assert.Equal(t, "http://api.example.com/mood?id="+wire.MoodIconID("0icqmood6"), lastMyInfo(t, sess).MoodIcon)
+	})
+
+	t.Run("the icon hash yields the content-addressed URL", func(t *testing.T) {
+		sess := newSession("myInfo")
+
+		info := wire.TLVUserInfo{ScreenName: "me"}
+		info.Append(wire.NewTLVBE(wire.OServiceUserInfoBARTInfo, wire.BARTID{
+			Type:     wire.BARTTypesBuddyIcon,
+			BARTInfo: wire.BARTInfo{Hash: []byte{0xde, 0xad, 0xbe, 0xef}},
+		}))
+		sess.handleSNACMessage(update(info))
+
+		assert.Equal(t, "icon:deadbeef", lastMyInfo(t, sess).BuddyIcon)
+	})
+
+	t.Run("no icon TLV yields the placeholder URL, which clears a removed icon", func(t *testing.T) {
+		sess := newSession("myInfo")
+		sess.handleSNACMessage(update(wire.TLVUserInfo{ScreenName: "me"}))
+		assert.Equal(t, "placeholder:me", lastMyInfo(t, sess).BuddyIcon)
+	})
+
+	t.Run("the state is read off the user info block", func(t *testing.T) {
+		tests := []struct {
+			name       string
+			screenName string
+			flags      uint16
+			status     uint32
+			idle       uint16
+			want       string
+		}{
+			{name: "invisible", screenName: "me", status: wire.OServiceUserStatusInvisible, want: "invisible"},
+			{name: "icq busy", screenName: "100003", status: wire.OServiceUserStatusBusy, want: "occupied"},
+			{name: "aim busy reports away", screenName: "me", status: wire.OServiceUserStatusBusy, want: "away"},
+			{name: "icq dnd", screenName: "100003", status: wire.OServiceUserStatusDND, want: "dnd"},
+			{name: "away flag", screenName: "me", flags: wire.OServiceUserFlagUnavailable, want: "away"},
+			{name: "away status bit", screenName: "me", status: wire.OServiceUserStatusAway, want: "away"},
+			{name: "idle", screenName: "me", idle: 5, want: "idle"},
+			{name: "nothing set", screenName: "me", want: "online"},
+		}
+
+		for _, tt := range tests {
+			t.Run(tt.name, func(t *testing.T) {
+				sess := newSession("myInfo")
+
+				info := wire.TLVUserInfo{ScreenName: tt.screenName}
+				info.Append(wire.NewTLVBE(wire.OServiceUserInfoUserFlags, tt.flags))
+				info.Append(wire.NewTLVBE(wire.OServiceUserInfoStatus, tt.status))
+				if tt.idle > 0 {
+					info.Append(wire.NewTLVBE(wire.OServiceUserInfoIdleTime, tt.idle))
+				}
+				sess.handleSNACMessage(update(info))
+
+				assert.Equal(t, tt.want, lastMyInfo(t, sess).State)
+			})
+		}
 	})
 }