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

webapi: serve buddy presence from cache

fixes bug where receiving a message can default a user's status
to online even if they are away
Mike 2 дней назад
Родитель
Сommit
1b903d88fb

+ 4 - 0
server/webapi/aim_handler.go

@@ -692,6 +692,10 @@ func (h *AimHandler) RemoveTempBuddy(w http.ResponseWriter, r *http.Request, ses
 		return
 	}
 
+	for _, buddyName := range buddyNames {
+		session.forgetBuddyPresence(state.NewIdentScreenName(buddyName))
+	}
+
 	SendOK(w, r, nil, h.Logger)
 
 	h.Logger.InfoContext(ctx, "temporary buddies removed",

+ 63 - 0
server/webapi/aim_handler_test.go

@@ -513,3 +513,66 @@ func TestSeedRateLimitAlert(t *testing.T) {
 		assert.Empty(t, rateLimitEventStatuses(t, session))
 	})
 }
+
+// A temp buddy stops being watched the moment it is removed, so no departure for
+// it ever arrives and its cached presence would otherwise be served forever. This
+// is the most reachable form of that bug: add a temp buddy, remove it, and the
+// target's state is frozen at whatever it was when they were last observed.
+func TestAimHandler_RemoveTempBuddy_ForgetsPresence(t *testing.T) {
+	session := newTestWebAPISession(t, tightRateLimitClasses())
+	session.Events = []string{"presence"}
+	session.logger = slog.Default()
+
+	buddyArrives(session, onlineBuddy("Mike Kelly"))
+	buddyArrives(session, onlineBuddy("keeper"))
+
+	buddyService := newMockBuddyService(t)
+	buddyService.EXPECT().
+		DelTempBuddies(mock.Anything, session.OSCARSession, mock.Anything).
+		Return(nil).Once()
+
+	handler := &AimHandler{
+		BuddyService: buddyService,
+		Logger:       slog.Default(),
+	}
+
+	req := httptest.NewRequest(http.MethodGet, "/aim/removeTempBuddy?aimsid="+session.AimSID+"&t=mikekelly", nil)
+	rr := httptest.NewRecorder()
+	handler.RemoveTempBuddy(rr, req, session)
+	require.Equal(t, http.StatusOK, rr.Code)
+
+	_, ok := session.BuddyPresence(state.NewIdentScreenName("mikekelly"))
+	assert.False(t, ok, "removed temp buddy should be forgotten")
+
+	// Only the named buddies are forgotten.
+	_, ok = session.BuddyPresence(state.NewIdentScreenName("keeper"))
+	assert.True(t, ok)
+}
+
+// A failed removal leaves the buddy watched, so their presence must survive.
+func TestAimHandler_RemoveTempBuddy_KeepsPresenceOnFailure(t *testing.T) {
+	session := newTestWebAPISession(t, tightRateLimitClasses())
+	session.Events = []string{"presence"}
+	session.logger = slog.Default()
+
+	buddyArrives(session, onlineBuddy("Mike Kelly"))
+
+	buddyService := newMockBuddyService(t)
+	buddyService.EXPECT().
+		DelTempBuddies(mock.Anything, session.OSCARSession, mock.Anything).
+		Return(io.ErrUnexpectedEOF).Once()
+
+	handler := &AimHandler{
+		BuddyService: buddyService,
+		Logger:       slog.Default(),
+	}
+
+	req := httptest.NewRequest(http.MethodGet, "/aim/removeTempBuddy?aimsid="+session.AimSID+"&t=mikekelly", nil)
+	rr := httptest.NewRecorder()
+	handler.RemoveTempBuddy(rr, req, session)
+	require.Equal(t, http.StatusInternalServerError, rr.Code)
+
+	got, ok := session.BuddyPresence(state.NewIdentScreenName("mikekelly"))
+	require.True(t, ok)
+	assert.Equal(t, "online", got.State)
+}

+ 56 - 46
server/webapi/buddy_list_manager.go

@@ -163,7 +163,7 @@ func (m *BuddyListManager) GetBuddyListForUser(ctx context.Context, sess *Sessio
 			if !ok {
 				continue
 			}
-			info := m.getBuddyInfo(ctx, sess.OSCARSession, sess.BaseURL, b.name)
+			info := m.getBuddyInfo(ctx, sess, b.name)
 			// The alias belongs in friendly, not displayId: the client renders
 			// friendly in preference to displayId but still shows displayId as
 			// the buddy's actual screen name.
@@ -176,15 +176,19 @@ func (m *BuddyListManager) GetBuddyListForUser(ctx context.Context, sess *Sessio
 	return out, nil
 }
 
-// getBuddyInfo retrieves a buddy's current presence by issuing a locate
-// UserInfoQuery on behalf of the requesting session's OSCAR instance.
-func (m *BuddyListManager) getBuddyInfo(ctx context.Context, instance *state.SessionInstance, baseURL string, buddyName string) BuddyInfo {
+// getBuddyInfo renders a buddy's presence from the session's presence view. A
+// buddy the view has never seen renders offline, which at sign-on includes one
+// whose arrival has not been drained yet; the presence events correct that.
+//
+// AwayMsg costs a locate query per unavailable buddy, since an away message is
+// absent from presence broadcasts.
+func (m *BuddyListManager) getBuddyInfo(ctx context.Context, sess *Session, buddyName string) BuddyInfo {
 	// Default to offline. The web client keys users by the normalized aimId and
 	// shallow-merges each buddy map onto the shared user object, so a display-form
 	// aimId here overwrites the id every other event is keyed by.
 	//
 	// Feedbag buddy names are stored normalized, so they are not a source of
-	// display names. DisplayID is filled in from the locate reply below when the
+	// display names. DisplayID is filled in from the presence view below when the
 	// buddy is online, or overridden by the caller's alias when one is set.
 	ident := state.NewIdentScreenName(buddyName)
 	info := BuddyInfo{
@@ -196,58 +200,34 @@ func (m *BuddyListManager) getBuddyInfo(ctx context.Context, instance *state.Ses
 		Bot:       false,
 	}
 
-	reply, err := m.locateService.UserInfoQuery(ctx, instance, wire.SNACFrame{},
-		wire.SNAC_0x02_0x05_LocateUserInfoQuery{
-			Type:       uint16(wire.LocateTypeUnavailable), // away message
-			ScreenName: ident.String(),
-		})
-	if err != nil {
-		m.logger.WarnContext(ctx, "failed to query buddy info", "screenName", buddyName, "error", err)
-		return info
-	}
-
-	userInfo, ok := reply.Body.(wire.SNAC_0x02_0x06_LocateUserInfoReply)
+	presence, ok := sess.BuddyPresence(ident)
 	if !ok {
-		// Locate error => buddy is blocked or offline.
 		return info
 	}
 
-	info.State = "online"
-	info.Capabilities = []string{}
-
-	// Publish the icon only now that locate has confirmed the buddy is online and
-	// has not blocked the caller. Offline and blocking buddies return above without
-	// an icon, so neither their icon nor its activity-revealing hash leaks.
-	info.BuddyIcon = m.iconSource.PublishedURL(ctx, baseURL, ident)
-
-	// The locate reply carries the screen name as the buddy formatted it.
-	if userInfo.ScreenName != "" {
-		info.DisplayID = userInfo.ScreenName
+	// A departure keeps the last display name seen, which beats the normalized
+	// feedbag spelling.
+	if presence.DisplayID != "" {
+		info.DisplayID = presence.DisplayID
 	}
 
-	if tod, ok := userInfo.Uint32BE(wire.OServiceUserInfoSignonTOD); ok {
-		info.OnlineTime = int64(tod)
-	}
-
-	info.StatusMsg = userStatusMsg(userInfo.TLVUserInfo)
+	info.State = presence.State
+	info.StatusMsg = presence.StatusMsg
+	info.OnlineTime = presence.OnlineTime
+	info.IdleTime = presence.IdleTime
 
-	if st := statusBitState(userInfo.TLVUserInfo, instance.IdentScreenName().UIN() == 0); st != "" {
-		info.State = st
-	} else if userInfo.IsAway() {
-		info.State = "away"
-		if msg, ok := userInfo.LocateInfo.String(wire.LocateTLVTagsInfoUnavailableData); ok {
-			info.AwayMsg = msg
-		}
+	if !presence.Online() {
+		return info
 	}
 
-	if idle, ok := userInfo.Uint16BE(wire.OServiceUserInfoIdleTime); ok && idle > 0 {
-		info.IdleTime = int(idle)
-		if info.State == "online" {
-			info.State = "idle"
-		}
+	if hasAwayMsg(presence.State) {
+		info.AwayMsg = awayMessage(ctx, m.locateService, sess.OSCARSession, ident, m.logger)
 	}
 
-	info.MoodIcon = moodIconURL(baseURL, info.State, userInfoCaps(userInfo.TLVUserInfo))
+	info.Capabilities = []string{}
+
+	info.BuddyIcon = m.iconSource.URLForHash(sess.BaseURL, ident, presence.IconHash)
+	info.MoodIcon = moodIconURL(sess.BaseURL, presence.State, presence.Caps)
 
 	return info
 }
@@ -296,6 +276,9 @@ func (m *BuddyListManager) RemoveBuddyFromFeedbag(ctx context.Context, sess *Ses
 			m.logger.ErrorContext(ctx, "remove buddy: Feedbag DeleteItem failed", "err", err.Error())
 			return "error", err
 		}
+		if !stillListsBuddy(fl.Items(), buddyName) {
+			sess.forgetBuddyPresence(state.NewIdentScreenName(buddyName))
+		}
 	} else {
 		return "notFound", nil
 	}
@@ -345,6 +328,16 @@ func (m *BuddyListManager) RemoveGroupFromFeedbag(ctx context.Context, sess *Ses
 			m.logger.ErrorContext(ctx, "remove group: Feedbag DeleteItem failed", "err", err.Error())
 			return "error", err
 		}
+
+		remaining := fl.Items()
+		for _, item := range pending {
+			if item.ClassID != wire.FeedbagClassIdBuddy || item.Name == "" {
+				continue
+			}
+			if !stillListsBuddy(remaining, item.Name) {
+				sess.forgetBuddyPresence(state.NewIdentScreenName(item.Name))
+			}
+		}
 	} else {
 		return "notFound", nil
 	}
@@ -579,6 +572,23 @@ func (m *BuddyListManager) SetGroupAttributeInFeedbag(ctx context.Context, sess
 	return "success", nil
 }
 
+// stillListsBuddy reports whether buddyName appears among items. Callers deleting
+// a buddy from one group use it to tell a partial removal from a full one: a buddy
+// listed in several groups is still watched after leaving one of them, so their
+// cached presence is still maintained.
+func stillListsBuddy(items []wire.FeedbagItem, buddyName string) bool {
+	want := state.NewIdentScreenName(buddyName)
+	for _, item := range items {
+		if item.ClassID != wire.FeedbagClassIdBuddy {
+			continue
+		}
+		if state.NewIdentScreenName(item.Name) == want {
+			return true
+		}
+	}
+	return false
+}
+
 // feedbagGroupMatchesRequested returns true if a feedbag group row matches the
 // group the Web client asked for. OSCAR often stores the default group with an
 // empty name; GetBuddyListForUser labels that as "Buddies", so addBuddy must

+ 247 - 65
server/webapi/buddy_list_manager_test.go

@@ -294,11 +294,8 @@ func TestBuddyListManager_GetBuddyListForUser(t *testing.T) {
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
 			fs := newMockFeedbagService(t)
-			// The locate query returns an error, so every buddy resolves to
-			// offline. This keeps the focus on feedbag -> group conversion.
-			ls := newMockLocateService(t)
-			ls.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
-				Return(wire.SNACMessage{}, errors.New("offline")).Maybe()
+			// No buddy arrives, so every buddy resolves to offline, keeping the
+			// focus on feedbag -> group conversion.
 			if tt.fbErr != nil {
 				fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).Return(wire.SNACMessage{}, tt.fbErr).Once()
 			} else {
@@ -307,7 +304,7 @@ func TestBuddyListManager_GetBuddyListForUser(t *testing.T) {
 				).Once()
 			}
 
-			m := NewBuddyListManager(fs, ls, newTestIconSource(t), slog.Default())
+			m := NewBuddyListManager(fs, newMockLocateService(t), newTestIconSource(t), slog.Default())
 			sess := &Session{
 				ScreenName:   state.DisplayScreenName(owner.String()),
 				OSCARSession: state.NewSession().AddInstance(),
@@ -325,9 +322,9 @@ func TestBuddyListManager_GetBuddyListForUser(t *testing.T) {
 	}
 }
 
-func TestBuddyListManager_GetBuddyListForUser_DisplayIDFromLocateReply(t *testing.T) {
+func TestBuddyListManager_GetBuddyListForUser_DisplayIDFromPresenceView(t *testing.T) {
 	// Feedbag buddy names are stored normalized, so an online buddy's display
-	// name can only come from the locate reply's user info.
+	// name can only come from the user info their arrival carried.
 	ctx := context.Background()
 
 	fb := []wire.FeedbagItem{
@@ -345,18 +342,14 @@ func TestBuddyListManager_GetBuddyListForUser_DisplayIDFromLocateReply(t *testin
 		wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: fb}}, nil,
 	).Once()
 
-	ls := newMockLocateService(t)
-	ls.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(
-		wire.SNACMessage{Body: wire.SNAC_0x02_0x06_LocateUserInfoReply{
-			TLVUserInfo: wire.TLVUserInfo{ScreenName: "Mike Kelly"},
-		}}, nil,
-	).Once()
-
-	m := NewBuddyListManager(fs, ls, newTestIconSource(t), slog.Default())
+	m := NewBuddyListManager(fs, newMockLocateService(t), newTestIconSource(t), slog.Default())
 	sess := &Session{
 		ScreenName:   state.DisplayScreenName("listowner"),
 		OSCARSession: state.NewSession().AddInstance(),
+		logger:       slog.Default(),
 	}
+	buddyArrives(sess, onlineBuddy("Mike Kelly"))
+
 	got, err := m.GetBuddyListForUser(ctx, sess)
 	require.NoError(t, err)
 	require.Len(t, got, 1)
@@ -367,10 +360,50 @@ func TestBuddyListManager_GetBuddyListForUser_DisplayIDFromLocateReply(t *testin
 	assert.Equal(t, "online", got[0].Buddies[0].State)
 }
 
-// Icons are published only for online, non-blocking buddies: an online buddy with
-// an icon gets a content-addressed URL, an online buddy without one gets the
-// hash-less placeholder URL, and an offline (or blocking) buddy gets no icon and
-// is never even looked up, so neither their icon nor its hash leaks.
+// A departure keeps the display name the buddy last arrived with, rather than
+// falling back to the normalized feedbag name.
+func TestBuddyListManager_GetBuddyListForUser_DepartureKeepsDisplayID(t *testing.T) {
+	ctx := context.Background()
+
+	fb := []wire.FeedbagItem{
+		{Name: "", GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
+			TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{100})}}},
+		{Name: "Buddies", GroupID: 100, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
+			TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{1})}}},
+		{ItemID: 1, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "mikekelly"},
+	}
+
+	fs := newMockFeedbagService(t)
+	fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).Return(
+		wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: fb}}, nil,
+	).Once()
+
+	m := NewBuddyListManager(fs, newMockLocateService(t), newTestIconSource(t), slog.Default())
+	sess := &Session{
+		ScreenName:   state.DisplayScreenName("listowner"),
+		OSCARSession: state.NewSession().AddInstance(),
+		BaseURL:      "http://api.example.com",
+		logger:       slog.Default(),
+	}
+	buddyArrives(sess, bartBuddy("Mike Kelly", testIconBART, testStatusBART))
+	buddyDeparts(sess, "Mike Kelly")
+
+	got, err := m.GetBuddyListForUser(ctx, sess)
+	require.NoError(t, err)
+	require.Len(t, got[0].Buddies, 1)
+
+	buddy := got[0].Buddies[0]
+	assert.Equal(t, "offline", buddy.State)
+	assert.Equal(t, "Mike Kelly", buddy.DisplayID)
+	// An offline buddy publishes neither their icon nor their status message.
+	assert.Empty(t, buddy.BuddyIcon)
+	assert.Empty(t, buddy.StatusMsg)
+}
+
+// Icons are published only for online buddies: one with an icon gets a
+// content-addressed URL, one without gets the placeholder URL, and an offline
+// buddy gets none, so neither their icon nor its hash leaks. The hash rides in on
+// the arrival, so no metadata lookup happens.
 func TestBuddyListManager_GetBuddyListForUser_PublishesBuddyIcons(t *testing.T) {
 	ctx := context.Background()
 
@@ -389,30 +422,9 @@ func TestBuddyListManager_GetBuddyListForUser_PublishesBuddyIcons(t *testing.T)
 		wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: fb}}, nil,
 	).Once()
 
-	online := func(name string) wire.SNACMessage {
-		return wire.SNACMessage{Body: wire.SNAC_0x02_0x06_LocateUserInfoReply{
-			TLVUserInfo: wire.TLVUserInfo{ScreenName: name},
-		}}
-	}
-	locateFor := func(name string) any {
-		return mock.MatchedBy(func(q wire.SNAC_0x02_0x05_LocateUserInfoQuery) bool { return q.ScreenName == name })
-	}
-
-	ls := newMockLocateService(t)
-	ls.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, locateFor("onlineicon")).
-		Return(online("onlineicon"), nil).Once()
-	ls.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, locateFor("onlinenoicon")).
-		Return(online("onlinenoicon"), nil).Once()
-	ls.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, locateFor("offlinebud")).
-		Return(wire.SNACMessage{}, errors.New("offline")).Once()
-
 	iconRetriever := newMockBuddyIconRetriever(t)
-	iconRetriever.EXPECT().BuddyIconMetadata(mock.Anything, state.NewIdentScreenName("onlineicon")).
-		Return(bartID([]byte{0xab, 0xcd}), nil).Once()
-	iconRetriever.EXPECT().BuddyIconMetadata(mock.Anything, state.NewIdentScreenName("onlinenoicon")).
-		Return(nil, nil).Once()
 
-	m := NewBuddyListManager(fs, ls, BuddyIconSource{
+	m := NewBuddyListManager(fs, newMockLocateService(t), BuddyIconSource{
 		IconRetriever: iconRetriever,
 		Logger:        slog.Default(),
 	}, slog.Default())
@@ -421,7 +433,14 @@ func TestBuddyListManager_GetBuddyListForUser_PublishesBuddyIcons(t *testing.T)
 		ScreenName:   state.DisplayScreenName("listowner"),
 		OSCARSession: state.NewSession().AddInstance(),
 		BaseURL:      "http://api.example.com",
+		logger:       slog.Default(),
 	}
+	buddyArrives(sess, bartBuddy("onlineicon", wire.BARTID{
+		Type:     wire.BARTTypesBuddyIcon,
+		BARTInfo: wire.BARTInfo{Hash: []byte{0xab, 0xcd}},
+	}))
+	buddyArrives(sess, onlineBuddy("onlinenoicon"))
+
 	got, err := m.GetBuddyListForUser(ctx, sess)
 	require.NoError(t, err)
 	require.Len(t, got, 1)
@@ -433,16 +452,18 @@ func TestBuddyListManager_GetBuddyListForUser_PublishesBuddyIcons(t *testing.T)
 		"http://api.example.com/expressions/get?t=onlineicon&type=buddyIcon&bartId=abcd",
 		got[0].Buddies[0].BuddyIcon)
 
-	// offlinebud: no icon, and its metadata is never queried.
+	// offlinebud: never arrived, so no icon.
 	assert.Equal(t, "offline", got[0].Buddies[1].State)
 	assert.Empty(t, got[0].Buddies[1].BuddyIcon)
-	iconRetriever.AssertNotCalled(t, "BuddyIconMetadata", mock.Anything, state.NewIdentScreenName("offlinebud"))
 
 	// onlinenoicon: hash-less placeholder URL so a cleared icon still propagates.
 	assert.Equal(t, "online", got[0].Buddies[2].State)
 	assert.Equal(t,
 		"http://api.example.com/expressions/get?t=onlinenoicon&type=buddyIcon",
 		got[0].Buddies[2].BuddyIcon)
+
+	// The arrivals carried every hash, so the roster costs no metadata lookups.
+	iconRetriever.AssertNotCalled(t, "BuddyIconMetadata", mock.Anything, mock.Anything)
 }
 
 // The roster is where a client first reads a buddy's status message.
@@ -463,40 +484,82 @@ func TestBuddyListManager_GetBuddyListForUser_PublishesStatusMessages(t *testing
 		wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: fb}}, nil,
 	).Once()
 
-	withStatus := wire.TLVUserInfo{ScreenName: "hasstatus"}
-	withStatus.Append(wire.NewTLVBE(wire.OServiceUserInfoBARTInfo, []wire.BARTID{testStatusBART}))
+	m := NewBuddyListManager(fs, newMockLocateService(t), newTestIconSource(t), slog.Default())
+	sess := &Session{
+		ScreenName:   state.DisplayScreenName("listowner"),
+		OSCARSession: state.NewSession().AddInstance(),
+		BaseURL:      "http://api.example.com",
+		logger:       slog.Default(),
+	}
+	buddyArrives(sess, bartBuddy("hasstatus", testStatusBART))
+	buddyArrives(sess, onlineBuddy("nostatus"))
+
+	got, err := m.GetBuddyListForUser(ctx, sess)
+	require.NoError(t, err)
+	require.Len(t, got, 1)
+	require.Len(t, got[0].Buddies, 2)
 
-	locateFor := func(name string) any {
-		return mock.MatchedBy(func(q wire.SNAC_0x02_0x05_LocateUserInfoQuery) bool { return q.ScreenName == name })
+	assert.Equal(t, "brb", got[0].Buddies[0].StatusMsg)
+	assert.Empty(t, got[0].Buddies[1].StatusMsg)
+}
+
+// The roster is rebuilt on every feedbag change, so only an unavailable buddy may
+// cost a locate query. Mockery fails the test on any other call.
+func TestBuddyListManager_GetBuddyListForUser_QueriesOnlyAwayBuddies(t *testing.T) {
+	ctx := context.Background()
+
+	fb := []wire.FeedbagItem{
+		{Name: "", GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
+			TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{100})}}},
+		{Name: "Buddies", GroupID: 100, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
+			TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{1, 2, 3})}}},
+		{ItemID: 1, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "onlinebud"},
+		{ItemID: 2, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "awaybud"},
+		{ItemID: 3, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "neverseen"},
 	}
+
+	fs := newMockFeedbagService(t)
+	fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).Return(
+		wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: fb}}, nil,
+	).Once()
+
 	ls := newMockLocateService(t)
-	ls.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, locateFor("hasstatus")).
-		Return(wire.SNACMessage{Body: wire.SNAC_0x02_0x06_LocateUserInfoReply{TLVUserInfo: withStatus}}, nil).Once()
-	ls.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, locateFor("nostatus")).
+	// Only the away buddy earns a query.
+	ls.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything,
+		mock.MatchedBy(func(q wire.SNAC_0x02_0x05_LocateUserInfoQuery) bool {
+			return q.ScreenName == "awaybud"
+		})).
 		Return(wire.SNACMessage{Body: wire.SNAC_0x02_0x06_LocateUserInfoReply{
-			TLVUserInfo: wire.TLVUserInfo{ScreenName: "nostatus"},
+			TLVUserInfo: wire.TLVUserInfo{ScreenName: "awaybud"},
+			LocateInfo: wire.TLVRestBlock{TLVList: wire.TLVList{
+				wire.NewTLVBE(wire.LocateTLVTagsInfoUnavailableData, "out to lunch"),
+			}},
 		}}, nil).Once()
 
-	iconRetriever := newMockBuddyIconRetriever(t)
-	iconRetriever.EXPECT().BuddyIconMetadata(mock.Anything, mock.Anything).Return(nil, nil).Twice()
-
-	m := NewBuddyListManager(fs, ls, BuddyIconSource{
-		IconRetriever: iconRetriever,
-		Logger:        slog.Default(),
-	}, slog.Default())
-
+	m := NewBuddyListManager(fs, ls, newTestIconSource(t), slog.Default())
 	sess := &Session{
 		ScreenName:   state.DisplayScreenName("listowner"),
 		OSCARSession: state.NewSession().AddInstance(),
-		BaseURL:      "http://api.example.com",
+		logger:       slog.Default(),
 	}
+
+	away := wire.TLVUserInfo{ScreenName: "awaybud"}
+	away.Append(wire.NewTLVBE(wire.OServiceUserInfoUserFlags, wire.OServiceUserFlagUnavailable))
+
+	buddyArrives(sess, onlineBuddy("onlinebud"))
+	buddyArrives(sess, away)
+
 	got, err := m.GetBuddyListForUser(ctx, sess)
 	require.NoError(t, err)
-	require.Len(t, got, 1)
-	require.Len(t, got[0].Buddies, 2)
+	require.Len(t, got[0].Buddies, 3)
 
-	assert.Equal(t, "brb", got[0].Buddies[0].StatusMsg)
-	assert.Empty(t, got[0].Buddies[1].StatusMsg)
+	assert.Equal(t, "online", got[0].Buddies[0].State)
+	assert.Equal(t, "away", got[0].Buddies[1].State)
+	// The away message is the one field a presence broadcast cannot carry.
+	assert.Equal(t, "out to lunch", got[0].Buddies[1].AwayMsg)
+	// A buddy no arrival has been relayed for renders offline rather than
+	// triggering a lookup.
+	assert.Equal(t, "offline", got[0].Buddies[2].State)
 }
 
 // The feedbag service relays a session's own writes only to the owner's other
@@ -571,3 +634,122 @@ func TestStoredGroupNameForRequest(t *testing.T) {
 	assert.True(t, ok2)
 	assert.Equal(t, "Friends", st2)
 }
+
+// Removing a buddy stops any further presence SNAC for them, so their cached
+// presence is dropped. A buddy listed in more than one group is still watched
+// after leaving one of them, and keeps their entry.
+func TestBuddyListManager_RemoveBuddyFromFeedbag_ForgetsPresence(t *testing.T) {
+	ctx := context.Background()
+
+	// feedbag places mikekelly in every group named.
+	feedbag := func(groups ...string) []wire.FeedbagItem {
+		items := []wire.FeedbagItem{
+			{Name: "", GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
+				TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{100, 200})}}},
+		}
+		for i, group := range groups {
+			gid := uint16(100 * (i + 1))
+			itemID := uint16(i + 1)
+			items = append(items,
+				wire.FeedbagItem{Name: group, GroupID: gid, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
+					TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{itemID})}}},
+				wire.FeedbagItem{ItemID: itemID, ClassID: wire.FeedbagClassIdBuddy, GroupID: gid, Name: "mikekelly"},
+			)
+		}
+		return items
+	}
+
+	tests := []struct {
+		name        string
+		fb          []wire.FeedbagItem
+		group       string
+		allGroups   bool
+		wantForgot  bool
+		description string
+	}{
+		{
+			name:       "sole listing forgets the buddy",
+			fb:         feedbag("Buddies"),
+			group:      "Buddies",
+			wantForgot: true,
+		},
+		{
+			name:       "removing from all groups forgets the buddy",
+			fb:         feedbag("Buddies", "Work"),
+			allGroups:  true,
+			wantForgot: true,
+		},
+		{
+			// Still on the list via the other group, so still watched.
+			name:       "removal from one of two groups keeps the buddy",
+			fb:         feedbag("Buddies", "Work"),
+			group:      "Buddies",
+			wantForgot: false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			fs := newMockFeedbagService(t)
+			fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).Return(
+				wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: tt.fb}}, nil,
+			).Maybe()
+			fs.EXPECT().DeleteItem(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
+				Return(&wire.SNACMessage{}, nil).Once()
+			fs.EXPECT().UpsertItem(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
+				Return(&wire.SNACMessage{}, nil).Maybe()
+
+			m := NewBuddyListManager(fs, newMockLocateService(t), newTestIconSource(t), slog.Default())
+			sess := &Session{
+				ScreenName:   state.DisplayScreenName("listowner"),
+				OSCARSession: state.NewSession().AddInstance(),
+				logger:       slog.Default(),
+			}
+			buddyArrives(sess, onlineBuddy("Mike Kelly"))
+
+			resultCode, err := m.RemoveBuddyFromFeedbag(ctx, sess, "mikekelly", tt.group, tt.allGroups)
+			require.NoError(t, err)
+			require.Equal(t, "success", resultCode)
+
+			_, ok := sess.BuddyPresence(state.NewIdentScreenName("mikekelly"))
+			assert.Equal(t, !tt.wantForgot, ok)
+		})
+	}
+}
+
+// Deleting a group deletes the buddies in it.
+func TestBuddyListManager_RemoveGroupFromFeedbag_ForgetsPresence(t *testing.T) {
+	ctx := context.Background()
+
+	fb := []wire.FeedbagItem{
+		{Name: "", GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
+			TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{100})}}},
+		{Name: "Buddies", GroupID: 100, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
+			TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{1})}}},
+		{ItemID: 1, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "mikekelly"},
+	}
+
+	fs := newMockFeedbagService(t)
+	fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).Return(
+		wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: fb}}, nil,
+	).Maybe()
+	fs.EXPECT().DeleteItem(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
+		Return(&wire.SNACMessage{}, nil).Once()
+	fs.EXPECT().UpsertItem(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
+		Return(&wire.SNACMessage{}, nil).Maybe()
+
+	m := NewBuddyListManager(fs, newMockLocateService(t), newTestIconSource(t), slog.Default())
+	sess := &Session{
+		ScreenName:   state.DisplayScreenName("listowner"),
+		OSCARSession: state.NewSession().AddInstance(),
+		logger:       slog.Default(),
+	}
+	buddyArrives(sess, onlineBuddy("Mike Kelly"))
+
+	resultCode, err := m.RemoveGroupFromFeedbag(ctx, sess, "Buddies")
+	require.NoError(t, err)
+	require.Equal(t, "success", resultCode)
+
+	_, ok := sess.BuddyPresence(state.NewIdentScreenName("mikekelly"))
+	assert.False(t, ok)
+}

+ 26 - 0
server/webapi/helpers_test.go

@@ -102,3 +102,29 @@ func rateLimitEventStatuses(t *testing.T, session *Session) []string {
 	}
 	return statuses
 }
+
+// buddyArrives feeds a BuddyArrived through the session's SNAC handler, which is
+// how its presence view learns that a buddy is online.
+func buddyArrives(sess *Session, info wire.TLVUserInfo) {
+	sess.handleBuddyArrived(wire.SNACMessage{Body: wire.SNAC_0x03_0x0B_BuddyArrived{TLVUserInfo: info}})
+}
+
+// buddyDeparts feeds a BuddyDeparted through the session's SNAC handler.
+func buddyDeparts(sess *Session, screenName string) {
+	sess.handleBuddyDeparted(wire.SNACMessage{Body: wire.SNAC_0x03_0x0C_BuddyDeparted{
+		TLVUserInfo: wire.TLVUserInfo{ScreenName: screenName},
+	}})
+}
+
+// onlineBuddy is the user info an ordinary online buddy arrives with.
+func onlineBuddy(screenName string) wire.TLVUserInfo {
+	return wire.TLVUserInfo{ScreenName: screenName}
+}
+
+// bartBuddy is the user info a buddy arrives with when they advertise BART
+// items, which carry the icon hash and the status message.
+func bartBuddy(screenName string, ids ...wire.BARTID) wire.TLVUserInfo {
+	info := wire.TLVUserInfo{ScreenName: screenName}
+	info.Append(wire.NewTLVBE(wire.OServiceUserInfoBARTInfo, ids))
+	return info
+}

+ 13 - 8
server/webapi/im_handler.go

@@ -197,20 +197,25 @@ func (h *MessagingHandler) pushSenderWebAPIEvents(sess *Session, recipient state
 	senderAimID := sess.ScreenName.IdentScreenName().String()
 	recipientAimID := recipient.String()
 
+	dest := UserInfo{
+		AimID:     recipientAimID,
+		DisplayID: recipientDisplay,
+		Friendly:  recipientAlias,
+		UserType:  userTypeFor(recipient),
+	}
+	if presence, ok := sess.BuddyPresence(recipient); ok {
+		dest.State = presence.State
+		dest.OnlineTime = presence.OnlineTime
+	}
+
 	senderEventData := SentIMEvent{
 		Sender: UserInfo{
 			AimID:     senderAimID,
 			DisplayID: sess.ScreenName.String(),
 			UserType:  userTypeFor(sess.ScreenName.IdentScreenName()),
-			State:     "online",
-		},
-		Dest: UserInfo{
-			AimID:     recipientAimID,
-			DisplayID: recipientDisplay,
-			Friendly:  recipientAlias,
-			UserType:  userTypeFor(recipient),
-			State:     "online",
+			State:     selfWebState(sess.OSCARSession.Session().TLVUserInfo(), sess.isAIMViewer()),
 		},
+		Dest:      dest,
 		Message:   message,
 		MsgID:     messageID,
 		Timestamp: now,

+ 79 - 0
server/webapi/im_handler_test.go

@@ -562,3 +562,82 @@ func TestMessagingHandler_SendIM_ClientErrorDuringSendNamesTheMessage(t *testing
 	assert.Equal(t, sentMsgID, clientErr.Cookie)
 	assert.Equal(t, "recipient", clientErr.Source.AimID)
 }
+
+// The sentIM echo is merged onto both parties' user objects, and sending a
+// message changes nobody's presence.
+func TestMessagingHandler_SendIM_EchoesRealStates(t *testing.T) {
+	// sendIM drives SendIM from a sender whose own OSCAR session is configured by
+	// configSender, with recipientPresence seeded into the session's view.
+	sendIM := func(t *testing.T, configSender func(*state.SessionInstance), recipientPresence *wire.TLVUserInfo) SentIMEvent {
+		t.Helper()
+
+		oscarInstance := state.NewSession().AddInstance()
+		configSender(oscarInstance)
+
+		icbmService := newMockICBMService(t)
+		icbmService.EXPECT().ChannelMsgToHost(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
+			Return(nil, nil)
+
+		mgr := NewSessionManager()
+		session, err := mgr.CreateSession(state.DisplayScreenName("Ann Dupree"),
+			[]string{"im", "sentIM"}, oscarInstance, "", slog.Default())
+		require.NoError(t, err)
+
+		if recipientPresence != nil {
+			buddyArrives(session, *recipientPresence)
+		}
+
+		handler := &MessagingHandler{
+			ICBMService:    icbmService,
+			LocateService:  stubLocateService(t, "Mike Kelly"),
+			FeedbagService: stubFeedbagService(t, "mikekelly", ""),
+			Logger:         slog.Default(),
+		}
+		session.BuddyAliasLoader = func(ctx context.Context) (map[string]string, error) {
+			return LookupBuddyAliases(ctx, handler.FeedbagService, session.OSCARSession)
+		}
+
+		req, err := http.NewRequest("GET", "/im/sendIM?aimsid="+session.AimSID+"&t=mikekelly&message=hi", nil)
+		require.NoError(t, err)
+		rr := httptest.NewRecorder()
+		requireSession(mgr, handler.SendIM).ServeHTTP(rr, req)
+		require.Equal(t, http.StatusOK, rr.Code)
+
+		for _, event := range session.EventQueue.GetAllEvents() {
+			if sent, ok := event.Data.(SentIMEvent); ok {
+				return sent
+			}
+		}
+		t.Fatal("no sentIM event was pushed")
+		return SentIMEvent{}
+	}
+
+	online := func(*state.SessionInstance) {}
+	awayRecipient := func() *wire.TLVUserInfo {
+		info := wire.TLVUserInfo{ScreenName: "Mike Kelly"}
+		info.Append(wire.NewTLVBE(wire.OServiceUserInfoUserFlags, wire.OServiceUserFlagUnavailable))
+		return &info
+	}
+
+	t.Run("the recipient's state comes from the presence view", func(t *testing.T) {
+		got := sendIM(t, online, awayRecipient())
+		assert.Equal(t, "away", got.Dest.State)
+	})
+
+	t.Run("a recipient with no presence carries no state", func(t *testing.T) {
+		got := sendIM(t, online, nil)
+		assert.Empty(t, got.Dest.State)
+	})
+
+	t.Run("the sender's own away state survives sending", func(t *testing.T) {
+		got := sendIM(t, func(instance *state.SessionInstance) {
+			instance.SetUserInfoFlag(wire.OServiceUserFlagUnavailable)
+		}, nil)
+		assert.Equal(t, "away", got.Sender.State)
+	})
+
+	t.Run("an online sender reports online", func(t *testing.T) {
+		got := sendIM(t, online, nil)
+		assert.Equal(t, "online", got.Sender.State)
+	})
+}

+ 86 - 16
server/webapi/presence_handler.go

@@ -142,7 +142,7 @@ func (h *PresenceHandler) GetPresence(w http.ResponseWriter, r *http.Request, se
 		aliases := session.Aliases(ctx)
 
 		for _, user := range targetUsers {
-			info := h.getUserPresence(ctx, session.OSCARSession, session.BaseURL, state.DisplayScreenName(user), wantProfileMsg)
+			info := h.targetPresence(ctx, session, state.DisplayScreenName(user), wantProfileMsg)
 			info.Friendly = aliases[info.AimID]
 			if wantDirInfo {
 				info.Profile = h.directoryProfile(ctx, user)
@@ -244,9 +244,15 @@ func (h *PresenceHandler) getBuddyListGroups(ctx context.Context, session *Sessi
 			groupMap[item.GroupID] = group
 		}
 
-		// UserInfoQuery performs the blocking check and online lookup; blocked or
-		// offline buddies come back as "offline", preserving the list structure.
-		presence := h.getUserPresence(ctx, session.OSCARSession, session.BaseURL, state.DisplayScreenName(item.Name), wantProfileMsg)
+		// Served from the presence view, where blocked and offline buddies alike
+		// come back as "offline". profileMsg is the exception: profile text is only
+		// reachable through a locate reply.
+		var presence BuddyPresenceInfo
+		if wantProfileMsg {
+			presence = h.getUserPresence(ctx, session.OSCARSession, session.BaseURL, state.DisplayScreenName(item.Name), true)
+		} else {
+			presence = h.cachedPresence(ctx, session, item.Name)
+		}
 		group.Buddies = append(group.Buddies, presence)
 	}
 
@@ -267,6 +273,81 @@ func (h *PresenceHandler) getBuddyListGroups(ctx context.Context, session *Sessi
 	return groups, nil
 }
 
+// offlinePresenceInfo is the presence a user renders as when the session has no
+// maintained record of them.
+func offlinePresenceInfo(ident state.IdentScreenName, target string) BuddyPresenceInfo {
+	return BuddyPresenceInfo{
+		AimID:     ident.String(),
+		DisplayID: target,
+		State:     "offline",
+		UserType:  userTypeFor(ident),
+		Service:   serviceFor(ident),
+	}
+}
+
+// cachedPresence renders a user's presence from the session's presence view. A
+// user the view has never seen renders offline, as does one who blocks the
+// caller: the broadcaster sends them a departure rather than an arrival.
+//
+// ProfileMsg is absent; callers that want it take the locate path. AwayMsg costs
+// a locate query per unavailable buddy.
+func (h *PresenceHandler) cachedPresence(ctx context.Context, session *Session, target string) BuddyPresenceInfo {
+	ident := state.NewIdentScreenName(target)
+
+	presence, ok := session.BuddyPresence(ident)
+	if !ok {
+		return offlinePresenceInfo(ident, target)
+	}
+	return h.presenceInfo(ctx, session, target, presence)
+}
+
+// presenceInfo renders a presence record the caller has already read out of the
+// view. Callers that branched on a cache hit pass the record in, so an eviction
+// racing them cannot turn the hit into an offline reading.
+func (h *PresenceHandler) presenceInfo(ctx context.Context, session *Session, target string, presence BuddyPresence) BuddyPresenceInfo {
+	ident := state.NewIdentScreenName(target)
+	info := offlinePresenceInfo(ident, target)
+
+	// A departure keeps the last display name seen, which beats the normalized
+	// spelling the caller passed in.
+	if presence.DisplayID != "" {
+		info.DisplayID = presence.DisplayID
+	}
+
+	info.State = presence.State
+	info.StatusMsg = presence.StatusMsg
+	info.OnlineTime = presence.OnlineTime
+	info.IdleTime = presence.IdleTime
+
+	if !presence.Online() {
+		// Offline and blocking users publish no icon, so neither their icon nor
+		// its activity-revealing hash leaks to a caller they are invisible to.
+		return info
+	}
+
+	if hasAwayMsg(presence.State) {
+		info.AwayMsg = awayMessage(ctx, h.LocateService, session.OSCARSession, ident, h.Logger)
+	}
+
+	// The presence SNAC carried the icon hash, so no metadata lookup is needed.
+	info.BuddyIcon = h.IconSource.URLForHash(session.BaseURL, ident, presence.IconHash)
+	info.MoodIcon = moodIconURL(session.BaseURL, presence.State, presence.Caps)
+
+	return info
+}
+
+// targetPresence resolves one of the users named by presence/get?t=. Unlike the
+// roster, a target need not be a buddy, so a user missing from the view gets a
+// locate query rather than reading as offline. profileMsg takes that path too.
+func (h *PresenceHandler) targetPresence(ctx context.Context, session *Session, target state.DisplayScreenName, wantProfileMsg bool) BuddyPresenceInfo {
+	if !wantProfileMsg {
+		if presence, ok := session.BuddyPresence(target.IdentScreenName()); ok {
+			return h.presenceInfo(ctx, session, target.String(), presence)
+		}
+	}
+	return h.getUserPresence(ctx, session.OSCARSession, session.BaseURL, target, wantProfileMsg)
+}
+
 // getUserPresence resolves a user's presence by issuing a locate UserInfoQuery
 // on behalf of the requesting OSCAR session (instance). UserInfoQuery performs
 // the OSCAR blocking check and online lookup internally: blocked and offline
@@ -307,7 +388,7 @@ func (h *PresenceHandler) getUserPresence(ctx context.Context, instance *state.S
 		return presence
 	}
 
-	presence.State = "online"
+	presence.State, presence.IdleTime = buddyWebState(info.TLVUserInfo, instance.IdentScreenName().UIN() == 0)
 
 	// Publish the icon only now that locate has confirmed the user is online and
 	// has not blocked the caller. Offline and blocking users return above without
@@ -325,17 +406,6 @@ func (h *PresenceHandler) getUserPresence(ctx context.Context, instance *state.S
 		presence.OnlineTime = int64(tod)
 	}
 
-	if st := statusBitState(info.TLVUserInfo, instance.IdentScreenName().UIN() == 0); st != "" {
-		presence.State = st
-	} else if info.IsAway() {
-		presence.State = "away"
-	}
-
-	if idle, ok := info.Uint16BE(wire.OServiceUserInfoIdleTime); ok && idle > 0 {
-		presence.State = "idle"
-		presence.IdleTime = int(idle)
-	}
-
 	if msg, ok := info.LocateInfo.String(wire.LocateTLVTagsInfoUnavailableData); ok {
 		presence.AwayMsg = msg
 	}

+ 262 - 7
server/webapi/presence_handler_test.go

@@ -44,6 +44,7 @@ func TestPresenceHandler_GetPresence(t *testing.T) {
 		name               string
 		queryParams        string
 		setupMocks         func(*mockFeedbagService, *mockLocateService)
+		seedPresence       func(*Session)
 		expectedStatusCode int
 		checkResponse      func(*testing.T, string)
 	}{
@@ -61,8 +62,11 @@ func TestPresenceHandler_GetPresence(t *testing.T) {
 							},
 						},
 					}, nil)
-				ls.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("buddy1")).
-					Return(onlineUserInfoReply("buddy1", 0), nil)
+			},
+			// bl=1 is served from the presence view, so the state comes from the
+			// buddy's arrival.
+			seedPresence: func(sess *Session) {
+				buddyArrives(sess, onlineBuddy("buddy1"))
 			},
 			expectedStatusCode: http.StatusOK,
 			checkResponse: func(t *testing.T, body string) {
@@ -153,6 +157,12 @@ func TestPresenceHandler_GetPresence(t *testing.T) {
 				Logger:         slog.Default(),
 			}
 
+			if tt.seedPresence != nil {
+				sess, err := sessionMgr.GetSession(context.Background(), aimsid)
+				require.NoError(t, err)
+				tt.seedPresence(sess)
+			}
+
 			tt.setupMocks(feedbagService, locateService)
 
 			// Presence payloads carry the viewer's alias, so GetPresence reads the
@@ -328,13 +338,14 @@ func TestPresenceHandler_GetPresence_BuddyListGrouping(t *testing.T) {
 	}
 	feedbagService.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
 		Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: items}}, nil)
-	locateService.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("alice")).
-		Return(onlineUserInfoReply("alice", 0), nil)
-	locateService.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("bob")).
-		Return(onlineUserInfoReply("bob", 0), nil)
 
 	oscarInstance := state.NewSession().AddInstance()
 	sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
+	sess, err := sessionMgr.GetSession(context.Background(), aimsid)
+	require.NoError(t, err)
+	buddyArrives(sess, onlineBuddy("alice"))
+	buddyArrives(sess, onlineBuddy("bob"))
+
 	handler := &PresenceHandler{
 		SessionManager: sessionMgr,
 		FeedbagService: feedbagService,
@@ -343,7 +354,7 @@ func TestPresenceHandler_GetPresence_BuddyListGrouping(t *testing.T) {
 	}
 
 	req, err := http.NewRequest("GET", "/presence/get?aimsid="+aimsid+"&bl=1", nil)
-	assert.NoError(t, err)
+	require.NoError(t, err)
 	rr := httptest.NewRecorder()
 	requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
 
@@ -1356,3 +1367,247 @@ func TestPresenceHandler_SetStatus_SetInfoError(t *testing.T) {
 
 	assert.Equal(t, http.StatusInternalServerError, rr.Code)
 }
+
+// presence/get?t= serves what the view knows and only pays for a locate query
+// when the caller asks for something the view cannot carry.
+func TestPresenceHandler_GetPresence_TargetsFallBackToLocate(t *testing.T) {
+	// getTargets drives presence/get for one target and returns the single user
+	// the response carries.
+	getTargets := func(t *testing.T, target, extraParams string, seed func(*Session), setupLocate func(*mockLocateService)) struct {
+		AimID     string `json:"aimId"`
+		State     string `json:"state"`
+		AwayMsg   string `json:"awayMsg"`
+		StatusMsg string `json:"statusMsg"`
+	} {
+		t.Helper()
+
+		feedbagService := newMockFeedbagService(t)
+		feedbagService.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
+			Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{}}, nil).Maybe()
+
+		locateService := newMockLocateService(t)
+		setupLocate(locateService)
+
+		oscarInstance := state.NewSession().AddInstance()
+		sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
+		sess, err := sessionMgr.GetSession(context.Background(), aimsid)
+		require.NoError(t, err)
+		seed(sess)
+
+		handler := &PresenceHandler{
+			SessionManager: sessionMgr,
+			FeedbagService: feedbagService,
+			LocateService:  locateService,
+			IconSource:     newTestIconSource(t),
+			Logger:         slog.Default(),
+		}
+
+		req, err := http.NewRequest("GET", "/presence/get?aimsid="+aimsid+"&t="+target+extraParams, nil)
+		require.NoError(t, err)
+		rr := httptest.NewRecorder()
+		requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
+		require.Equal(t, http.StatusOK, rr.Code)
+
+		var got struct {
+			Response struct {
+				Data struct {
+					Users []struct {
+						AimID     string `json:"aimId"`
+						State     string `json:"state"`
+						AwayMsg   string `json:"awayMsg"`
+						StatusMsg string `json:"statusMsg"`
+					} `json:"users"`
+				} `json:"data"`
+			} `json:"response"`
+		}
+		require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &got))
+		require.Len(t, got.Response.Data.Users, 1)
+		return got.Response.Data.Users[0]
+	}
+
+	noLocate := func(*mockLocateService) {}
+
+	t.Run("an online buddy is served from the view", func(t *testing.T) {
+		got := getTargets(t, "mikekelly", "", func(sess *Session) {
+			buddyArrives(sess, bartBuddy("Mike Kelly", testStatusBART))
+		}, noLocate)
+
+		assert.Equal(t, "online", got.State)
+		assert.Equal(t, "brb", got.StatusMsg)
+	})
+
+	// A target need not be a buddy, so nothing may be concluded from a miss.
+	t.Run("a target the view has never seen is looked up", func(t *testing.T) {
+		got := getTargets(t, "stranger", "", func(*Session) {}, func(ls *mockLocateService) {
+			ls.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("stranger")).
+				Return(onlineUserInfoReply("stranger", 0), nil).Once()
+		})
+
+		assert.Equal(t, "online", got.State)
+	})
+
+	// An away message is absent from presence broadcasts, so it costs a query.
+	t.Run("an away buddy is looked up for their away message", func(t *testing.T) {
+		awayReply := wire.TLVUserInfo{ScreenName: "awaybud"}
+		awayReply.Append(wire.NewTLVBE(wire.OServiceUserInfoUserFlags, wire.OServiceUserFlagUnavailable))
+
+		got := getTargets(t, "awaybud", "", func(sess *Session) {
+			buddyArrives(sess, awayReply)
+		}, func(ls *mockLocateService) {
+			ls.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("awaybud")).
+				Return(wire.SNACMessage{Body: wire.SNAC_0x02_0x06_LocateUserInfoReply{
+					TLVUserInfo: awayReply,
+					LocateInfo: wire.TLVRestBlock{TLVList: wire.TLVList{
+						wire.NewTLVBE(wire.LocateTLVTagsInfoUnavailableData, "out to lunch"),
+					}},
+				}}, nil).Once()
+		})
+
+		assert.Equal(t, "away", got.State)
+		assert.Equal(t, "out to lunch", got.AwayMsg)
+	})
+
+	// Profile text is likewise only reachable through a locate reply.
+	t.Run("profileMsg forces a lookup even for a cached buddy", func(t *testing.T) {
+		got := getTargets(t, "mikekelly", "&profileMsg=1", func(sess *Session) {
+			buddyArrives(sess, onlineBuddy("Mike Kelly"))
+		}, func(ls *mockLocateService) {
+			ls.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("mikekelly")).
+				Return(onlineUserInfoReply("Mike Kelly", 0), nil).Once()
+		})
+
+		assert.Equal(t, "online", got.State)
+	})
+}
+
+// presence/get?bl=1 walks the whole roster, so only an unavailable buddy may cost
+// a locate query. Mockery fails the test on any other call.
+func TestPresenceHandler_GetPresence_BuddyListQueriesOnlyAwayBuddies(t *testing.T) {
+	items := []wire.FeedbagItem{
+		{ItemID: 0, GroupID: 0, ClassID: wire.FeedbagClassIdGroup, Name: ""},
+		{ItemID: 0, GroupID: 10, ClassID: wire.FeedbagClassIdGroup, Name: "Friends"},
+		{ItemID: 101, GroupID: 10, ClassID: wire.FeedbagClassIdBuddy, Name: "onlinebud"},
+		{ItemID: 102, GroupID: 10, ClassID: wire.FeedbagClassIdBuddy, Name: "awaybud"},
+		{ItemID: 103, GroupID: 10, ClassID: wire.FeedbagClassIdBuddy, Name: "neverseen"},
+	}
+
+	feedbagService := newMockFeedbagService(t)
+	feedbagService.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
+		Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: items}}, nil)
+
+	locateService := newMockLocateService(t)
+	locateService.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("awaybud")).
+		Return(wire.SNACMessage{Body: wire.SNAC_0x02_0x06_LocateUserInfoReply{
+			TLVUserInfo: wire.TLVUserInfo{ScreenName: "awaybud"},
+			LocateInfo: wire.TLVRestBlock{TLVList: wire.TLVList{
+				wire.NewTLVBE(wire.LocateTLVTagsInfoUnavailableData, "out to lunch"),
+			}},
+		}}, nil).Once()
+
+	oscarInstance := state.NewSession().AddInstance()
+	sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
+	sess, err := sessionMgr.GetSession(context.Background(), aimsid)
+	require.NoError(t, err)
+
+	away := wire.TLVUserInfo{ScreenName: "awaybud"}
+	away.Append(wire.NewTLVBE(wire.OServiceUserInfoUserFlags, wire.OServiceUserFlagUnavailable))
+	buddyArrives(sess, onlineBuddy("onlinebud"))
+	buddyArrives(sess, away)
+
+	handler := &PresenceHandler{
+		SessionManager: sessionMgr,
+		FeedbagService: feedbagService,
+		LocateService:  locateService,
+		IconSource:     newTestIconSource(t),
+		Logger:         slog.Default(),
+	}
+
+	req, err := http.NewRequest("GET", "/presence/get?aimsid="+aimsid+"&bl=1", nil)
+	require.NoError(t, err)
+	rr := httptest.NewRecorder()
+	requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
+	require.Equal(t, http.StatusOK, rr.Code)
+
+	var got struct {
+		Response struct {
+			Data struct {
+				Groups []struct {
+					Buddies []struct {
+						AimID   string `json:"aimId"`
+						State   string `json:"state"`
+						AwayMsg string `json:"awayMsg"`
+					} `json:"buddies"`
+				} `json:"groups"`
+			} `json:"data"`
+		} `json:"response"`
+	}
+	require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &got))
+	require.Len(t, got.Response.Data.Groups, 1)
+
+	states := map[string]string{}
+	awayMsgs := map[string]string{}
+	for _, b := range got.Response.Data.Groups[0].Buddies {
+		states[b.AimID] = b.State
+		awayMsgs[b.AimID] = b.AwayMsg
+	}
+	assert.Equal(t, "online", states["onlinebud"])
+	assert.Equal(t, "away", states["awaybud"])
+	// The away message is the one field a presence broadcast cannot carry.
+	assert.Equal(t, "out to lunch", awayMsgs["awaybud"])
+	// A buddy no arrival has been relayed for renders offline rather than
+	// triggering a lookup.
+	assert.Equal(t, "offline", states["neverseen"])
+}
+
+// presence/get?t= must not trust a cache hit it no longer holds. It branches on a
+// lookup and then renders the record, so the record has to travel with the branch:
+// looking it up a second time can miss after an eviction and report the user
+// offline instead of falling back to the locate query.
+func TestPresenceHandler_GetPresence_ForgottenTargetFallsBackToLocate(t *testing.T) {
+	feedbagService := newMockFeedbagService(t)
+	feedbagService.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
+		Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{}}, nil).Maybe()
+
+	// The target is no longer watched, so only a live lookup knows their state.
+	locateService := newMockLocateService(t)
+	locateService.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("mikekelly")).
+		Return(wire.SNACMessage{Body: wire.SNACError{Code: wire.ErrorCodeNotLoggedOn}}, nil).Once()
+
+	oscarInstance := state.NewSession().AddInstance()
+	sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
+	sess, err := sessionMgr.GetSession(context.Background(), aimsid)
+	require.NoError(t, err)
+
+	buddyArrives(sess, onlineBuddy("Mike Kelly"))
+	sess.forgetBuddyPresence(state.NewIdentScreenName("mikekelly"))
+
+	handler := &PresenceHandler{
+		SessionManager: sessionMgr,
+		FeedbagService: feedbagService,
+		LocateService:  locateService,
+		IconSource:     newTestIconSource(t),
+		Logger:         slog.Default(),
+	}
+
+	req, err := http.NewRequest("GET", "/presence/get?aimsid="+aimsid+"&t=mikekelly", nil)
+	require.NoError(t, err)
+	rr := httptest.NewRecorder()
+	requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
+	require.Equal(t, http.StatusOK, rr.Code)
+
+	var got struct {
+		Response struct {
+			Data struct {
+				Users []struct {
+					AimID string `json:"aimId"`
+					State string `json:"state"`
+				} `json:"users"`
+			} `json:"data"`
+		} `json:"response"`
+	}
+	require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &got))
+	require.Len(t, got.Response.Data.Users, 1)
+
+	// The stale "online" snapshot must not survive the eviction.
+	assert.Equal(t, "offline", got.Response.Data.Users[0].State)
+}

+ 230 - 50
server/webapi/session.go

@@ -86,11 +86,16 @@ type Session struct {
 	BuddyIconURL func(screenName state.IdentScreenName, hash []byte) string
 	aliases      map[string]string // cached BuddyAliasLoader result, nil when unloaded or invalidated
 	aliasMu      sync.Mutex
-	imLog        map[string][]WebAPIStoredIM
-	imLogMu      sync.Mutex
-	sentIMs      map[uint64]string // OSCAR message cookie -> the msgId given to the client
-	sentIMOrder  []uint64          // insertion order of sentIMs, oldest first
-	sentIMMu     sync.Mutex
+	// presence is the session's view of its buddies' last-known presence, keyed by
+	// normalized aimId and fed by the BuddyArrived/BuddyDeparted SNACs the listener
+	// receives.
+	presence    map[string]BuddyPresence
+	presenceMu  sync.RWMutex
+	imLog       map[string][]WebAPIStoredIM
+	imLogMu     sync.Mutex
+	sentIMs     map[uint64]string // OSCAR message cookie -> the msgId given to the client
+	sentIMOrder []uint64          // insertion order of sentIMs, oldest first
+	sentIMMu    sync.Mutex
 	// IMRateClassID is the rate class that sending an IM spends. The web client
 	// renders any rate limit event as the IM banner, so only this class's updates
 	// may reach it. Zero disables the alert.
@@ -164,6 +169,73 @@ func (s *Session) aliasFor(buddy state.IdentScreenName) string {
 	return s.Aliases(s.ctx)[buddy.String()]
 }
 
+// BuddyPresence is a buddy's last-known presence, assembled from the
+// BuddyArrived and BuddyDeparted SNACs a session receives. It carries only what
+// those SNACs carry: an away message lives in the locate reply's LocateInfo and
+// is not part of a presence broadcast.
+type BuddyPresence struct {
+	DisplayID  string // screen name as the buddy formats it
+	State      string // "online", "away", "idle", "occupied", "dnd", "offline"
+	StatusMsg  string
+	OnlineTime int64
+	IdleTime   int // minutes
+	Caps       [][16]byte
+	IconHash   []byte
+}
+
+// Online reports whether the buddy is visible to the viewer.
+func (p BuddyPresence) Online() bool {
+	return p.State != "offline"
+}
+
+// BuddyPresence returns a buddy's last-known presence and whether the session
+// has one. Callers rendering the roster treat a miss as offline.
+func (s *Session) BuddyPresence(buddy state.IdentScreenName) (BuddyPresence, bool) {
+	s.presenceMu.RLock()
+	defer s.presenceMu.RUnlock()
+	p, ok := s.presence[buddy.String()]
+	return p, ok
+}
+
+// setBuddyPresence records a buddy's presence, replacing whatever was there.
+func (s *Session) setBuddyPresence(buddy state.IdentScreenName, p BuddyPresence) {
+	s.presenceMu.Lock()
+	defer s.presenceMu.Unlock()
+	if s.presence == nil {
+		s.presence = make(map[string]BuddyPresence)
+	}
+	s.presence[buddy.String()] = p
+}
+
+// setBuddyOffline marks a buddy offline, keeping the display name last seen for
+// them. A BuddyDeparted carries no TLV block, so nothing else survives: an
+// offline buddy publishes no icon, status message or mood.
+func (s *Session) setBuddyOffline(buddy state.IdentScreenName) {
+	s.presenceMu.Lock()
+	defer s.presenceMu.Unlock()
+	if s.presence == nil {
+		s.presence = make(map[string]BuddyPresence)
+	}
+	s.presence[buddy.String()] = BuddyPresence{
+		DisplayID: s.presence[buddy.String()].DisplayID,
+		State:     "offline",
+	}
+}
+
+// forgetBuddyPresence drops a buddy's cached presence. Callers do this when the
+// viewer stops watching them: OSCAR notifies only users who currently watch each
+// other, so no arrival or departure for that buddy is relayed here again.
+func (s *Session) forgetBuddyPresence(buddy state.IdentScreenName) {
+	s.presenceMu.Lock()
+	defer s.presenceMu.Unlock()
+	delete(s.presence, buddy.String())
+}
+
+// isAIMViewer reports whether the session owner is an AIM account.
+func (s *Session) isAIMViewer() bool {
+	return s.ScreenName.IdentScreenName().UIN() == 0
+}
+
 // Touch updates the last accessed time and extends expiration if needed.
 func (s *Session) Touch() {
 	s.LastAccessed = time.Now()
@@ -418,14 +490,19 @@ func (s *Session) handleIncomingIM(msg wire.SNACMessage) {
 			"to", s.ScreenName,
 			"sent", timestamp)
 	} else {
+		source := UserInfo{
+			AimID:     partnerAimID,
+			DisplayID: partnerDisplay,
+			Friendly:  s.aliasFor(partner),
+			UserType:  userTypeFor(partner),
+		}
+		if presence, ok := s.BuddyPresence(partner); ok {
+			source.State = presence.State
+			source.OnlineTime = presence.OnlineTime
+		}
+
 		s.EventQueue.Push(EventTypeIM, IMEvent{
-			Source: UserInfo{
-				AimID:     partnerAimID,
-				DisplayID: partnerDisplay,
-				Friendly:  s.aliasFor(partner),
-				UserType:  userTypeFor(partner),
-				State:     "online",
-			},
+			Source:    source,
 			Message:   messageText,
 			MsgID:     msgID,
 			Timestamp: timestamp,
@@ -533,41 +610,36 @@ func (s *Session) handleBuddyMessage(msg wire.SNACMessage) {
 }
 
 // handleBuddyArrived handles when a buddy comes online.
+//
+// For BuddyArrived updates, presence state is inferred from the TLVUserInfo.
+// Away and invisible transitions are typically broadcast using BuddyArrived
+// with updated user flags/status bits, not BuddyDeparted.
 func (s *Session) handleBuddyArrived(msg wire.SNACMessage) {
-	if !s.IsSubscribedTo("presence") {
-		return
-	}
-
 	body, ok := msg.Body.(wire.SNAC_0x03_0x0B_BuddyArrived)
 	if !ok {
 		return
 	}
 
-	stateStr := "online"
-	// For BuddyArrived updates, infer presence state from the TLVUserInfo.
-	// Away and invisible transitions are typically broadcast using BuddyArrived
-	// with updated user flags/status bits, not BuddyDeparted.
-	if body.IsInvisible() {
-		stateStr = "offline"
-	} else if st := statusBitState(body.TLVUserInfo, s.ScreenName.IdentScreenName().UIN() == 0); st != "" {
-		stateStr = st
-	} else if body.IsAway() {
-		stateStr = "away"
-	} else if mask, ok := body.Uint32BE(wire.OServiceUserInfoStatus); ok && mask&wire.OServiceUserStatusAway != 0 {
-		stateStr = "away"
+	buddy := state.NewIdentScreenName(body.ScreenName)
+	presence := buddyPresenceFrom(body.TLVUserInfo, s.isAIMViewer())
+
+	s.setBuddyPresence(buddy, presence)
+
+	if !s.IsSubscribedTo("presence") {
+		return
 	}
 
-	buddy := state.NewIdentScreenName(body.ScreenName)
 	presenceEvent := PresenceEvent{
-		AimID:    buddy.String(),
-		Friendly: s.aliasFor(buddy),
-		State:    stateStr,
-		UserType: userTypeFor(buddy),
+		AimID:      buddy.String(),
+		Friendly:   s.aliasFor(buddy),
+		State:      presence.State,
+		UserType:   userTypeFor(buddy),
+		StatusMsg:  presence.StatusMsg,
+		IdleTime:   presence.IdleTime,
+		OnlineTime: presence.OnlineTime,
+		MoodIcon:   moodIconURL(s.BaseURL, presence.State, presence.Caps),
 	}
 
-	presenceEvent.MoodIcon = moodIconURL(s.BaseURL, stateStr, userInfoCaps(body.TLVUserInfo))
-	presenceEvent.StatusMsg = userStatusMsg(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
@@ -576,7 +648,7 @@ func (s *Session) handleBuddyArrived(msg wire.SNACMessage) {
 	// client's shallow merge. An empty result (no origin known) is omitted, which
 	// preserves whatever icon the client already holds.
 	if s.BuddyIconURL != nil {
-		presenceEvent.BuddyIcon = s.BuddyIconURL(buddy, buddyIconHash(body.TLVUserInfo))
+		presenceEvent.BuddyIcon = s.BuddyIconURL(buddy, presence.IconHash)
 	}
 
 	s.EventQueue.Push(EventTypePresence, presenceEvent)
@@ -632,18 +704,115 @@ func sessionStatusMsg(instance *state.SessionInstance) string {
 	return msg
 }
 
-// handleBuddyDeparted handles when a buddy goes offline.
-func (s *Session) handleBuddyDeparted(msg wire.SNACMessage) {
-	if !s.IsSubscribedTo("presence") {
-		return
+// buddyWebState reports the web state a buddy's user info describes, along with
+// the minutes they have been idle. Invisibility reads as offline. Idle is always
+// reported, but only upgrades an otherwise online user to "idle".
+func buddyWebState(info wire.TLVUserInfo, isAIMViewer bool) (string, int) {
+	idle := 0
+	if mins, ok := info.Uint16BE(wire.OServiceUserInfoIdleTime); ok {
+		idle = int(mins)
+	}
+
+	if info.IsInvisible() {
+		return "offline", idle
 	}
 
+	// statusBitState must be consulted before IsAway: Busy and DND also raise
+	// the unavailable flag, so an away-first test reports every busy user as
+	// away.
+	if st := statusBitState(info, isAIMViewer); st != "" {
+		return st, idle
+	}
+	if info.IsAway() {
+		return "away", idle
+	}
+	if mask, ok := info.Uint32BE(wire.OServiceUserInfoStatus); ok && mask&wire.OServiceUserStatusAway != 0 {
+		return "away", idle
+	}
+	if idle > 0 {
+		return "idle", idle
+	}
+	return "online", idle
+}
+
+// buddyPresenceFrom builds a presence record from the user info block a
+// BuddyArrived carries. isAIMViewer decides whether Busy and DND collapse to
+// "away".
+func buddyPresenceFrom(info wire.TLVUserInfo, isAIMViewer bool) BuddyPresence {
+	st, idle := buddyWebState(info, isAIMViewer)
+
+	p := BuddyPresence{
+		DisplayID: info.ScreenName,
+		State:     st,
+		IdleTime:  idle,
+		StatusMsg: userStatusMsg(info),
+		Caps:      userInfoCaps(info),
+		IconHash:  buddyIconHash(info),
+	}
+	if tod, ok := info.Uint32BE(wire.OServiceUserInfoSignonTOD); ok {
+		p.OnlineTime = int64(tod)
+	}
+	return p
+}
+
+// hasAwayMsg reports whether a state is one whose user may be advertising an
+// away message.
+func hasAwayMsg(webState string) bool {
+	switch webState {
+	case "away", "occupied", "dnd":
+		return true
+	}
+	return false
+}
+
+// awayMessage reads a buddy's away message, which lives in the locate reply's
+// LocateInfo and so is absent from presence broadcasts. Callers ask only for a
+// buddy the view already reports as unavailable.
+func awayMessage(
+	ctx context.Context,
+	locateService LocateService,
+	instance *state.SessionInstance,
+	buddy state.IdentScreenName,
+	logger *slog.Logger,
+) string {
+	reply, err := locateService.UserInfoQuery(ctx, instance, wire.SNACFrame{},
+		wire.SNAC_0x02_0x05_LocateUserInfoQuery{
+			Type:       uint16(wire.LocateTypeUnavailable),
+			ScreenName: buddy.String(),
+		})
+	if err != nil {
+		logger.WarnContext(ctx, "failed to query away message",
+			"screenName", buddy.String(), "error", err)
+		return ""
+	}
+
+	info, ok := reply.Body.(wire.SNAC_0x02_0x06_LocateUserInfoReply)
+	if !ok {
+		// Locate error => the buddy went offline or blocked the caller between
+		// their arrival and this query.
+		return ""
+	}
+
+	msg, _ := info.LocateInfo.String(wire.LocateTLVTagsInfoUnavailableData)
+	return msg
+}
+
+// handleBuddyDeparted handles when a buddy goes offline.
+func (s *Session) handleBuddyDeparted(msg wire.SNACMessage) {
 	body, ok := msg.Body.(wire.SNAC_0x03_0x0C_BuddyDeparted)
 	if !ok {
 		return
 	}
 
 	buddy := state.NewIdentScreenName(body.ScreenName)
+
+	// Recorded before the subscription check, for the same reason as arrivals.
+	s.setBuddyOffline(buddy)
+
+	if !s.IsSubscribedTo("presence") {
+		return
+	}
+
 	// BuddyIcon is deliberately omitted: an offline buddy keeps their icon, and
 	// omitting it lets the client's merge preserve the icon it already holds.
 	presenceEvent := PresenceEvent{
@@ -695,18 +864,29 @@ func (s *Session) handleFeedbagMessage(msg wire.SNACMessage) {
 		s.refreshBuddyList()
 
 	case wire.FeedbagInsertItem, wire.FeedbagUpdateItem, wire.FeedbagDeleteItem:
+		// An insert and an update both relay an UpdateItem body; only a delete
+		// carries a DeleteItem body.
+		var items []wire.FeedbagItem
+		isDelete := false
+		switch body := msg.Body.(type) {
+		case wire.SNAC_0x13_0x09_FeedbagUpdateItem:
+			items = body.Items
+		case wire.SNAC_0x13_0x0A_FeedbagDeleteItem:
+			items = body.Items
+			isDelete = true
+		}
+
+		if isDelete {
+			for _, item := range items {
+				if item.ClassID == wire.FeedbagClassIdBuddy && item.Name != "" {
+					s.forgetBuddyPresence(state.NewIdentScreenName(item.Name))
+				}
+			}
+		}
+
 		s.refreshBuddyList()
 
 		if s.PermitDenyRefresher != nil {
-			// An insert and an update both relay an UpdateItem body; only a
-			// delete carries a DeleteItem body.
-			var items []wire.FeedbagItem
-			switch body := msg.Body.(type) {
-			case wire.SNAC_0x13_0x09_FeedbagUpdateItem:
-				items = body.Items
-			case wire.SNAC_0x13_0x0A_FeedbagDeleteItem:
-				items = body.Items
-			}
 			for _, item := range items {
 				if item.ClassID == wire.FeedbagClassIDPermit ||
 					item.ClassID == wire.FeedbagClassIDDeny ||

+ 426 - 0
server/webapi/session_test.go

@@ -1601,3 +1601,429 @@ func TestSessionManager_CreateSession_SeedsCapabilities(t *testing.T) {
 	require.NoError(t, err)
 	assert.Equal(t, webAPICaps, other.Caps(), "one session's mood must not reach the next session's seed")
 }
+
+// An IM does not change its sender's presence, and the client shallow-merges the
+// source user map onto the user object the buddy list rendered.
+func TestSession_IncomingIMReportsSenderPresenceFromTheView(t *testing.T) {
+	newSession := func() *Session {
+		return &Session{
+			ScreenName: state.DisplayScreenName("me"),
+			Events:     []string{"im", "presence"},
+			EventQueue: NewEventQueue(10),
+			logger:     slog.New(slog.NewTextHandler(io.Discard, nil)),
+		}
+	}
+
+	incomingIM := func(t *testing.T, sess *Session, from string) IMEvent {
+		t.Helper()
+		frags, err := wire.ICBMFragmentList("hello")
+		require.NoError(t, err)
+		body := wire.SNAC_0x04_0x07_ICBMChannelMsgToClient{
+			ChannelID:   wire.ICBMChannelIM,
+			TLVUserInfo: wire.TLVUserInfo{ScreenName: from},
+		}
+		body.Append(wire.NewTLVBE(wire.ICBMTLVAOLIMData, frags))
+		sess.handleIncomingIM(wire.SNACMessage{Body: body})
+
+		for _, event := range sess.EventQueue.GetAllEvents() {
+			if im, ok := event.Data.(IMEvent); ok {
+				return im
+			}
+		}
+		t.Fatal("no im event was pushed")
+		return IMEvent{}
+	}
+
+	t.Run("an away sender stays away", func(t *testing.T) {
+		sess := newSession()
+		away := wire.TLVUserInfo{ScreenName: "Mike Kelly"}
+		away.Append(wire.NewTLVBE(wire.OServiceUserInfoUserFlags, wire.OServiceUserFlagUnavailable))
+		buddyArrives(sess, away)
+
+		assert.Equal(t, "away", incomingIM(t, sess, "Mike Kelly").Source.State)
+	})
+
+	t.Run("a busy ICQ sender stays occupied", func(t *testing.T) {
+		sess := newSession()
+		sess.ScreenName = state.DisplayScreenName("100001")
+		busy := wire.TLVUserInfo{ScreenName: "100003"}
+		busy.Append(wire.NewTLVBE(wire.OServiceUserInfoStatus, wire.OServiceUserStatusBusy))
+		buddyArrives(sess, busy)
+
+		assert.Equal(t, "occupied", incomingIM(t, sess, "100003").Source.State)
+	})
+
+	t.Run("an online sender is online", func(t *testing.T) {
+		sess := newSession()
+		buddyArrives(sess, onlineBuddy("Mike Kelly"))
+
+		assert.Equal(t, "online", incomingIM(t, sess, "Mike Kelly").Source.State)
+	})
+
+	// A non-buddy raises no arrival, so there is no state to report.
+	t.Run("a sender with no presence carries no state", func(t *testing.T) {
+		sess := newSession()
+		assert.Empty(t, incomingIM(t, sess, "stranger").Source.State)
+	})
+}
+
+// userInfoWith builds the user info a buddy arrives with.
+func userInfoWith(screenName string, tlvs ...wire.TLV) wire.TLVUserInfo {
+	info := wire.TLVUserInfo{ScreenName: screenName}
+	for _, tlv := range tlvs {
+		info.Append(tlv)
+	}
+	return info
+}
+
+func awayFlagTLV() wire.TLV {
+	return wire.NewTLVBE(wire.OServiceUserInfoUserFlags, wire.OServiceUserFlagUnavailable)
+}
+
+func statusTLV(mask uint32) wire.TLV {
+	return wire.NewTLVBE(wire.OServiceUserInfoStatus, mask)
+}
+
+func idleTLV(minutes uint16) wire.TLV {
+	return wire.NewTLVBE(wire.OServiceUserInfoIdleTime, minutes)
+}
+
+func TestBuddyWebState(t *testing.T) {
+	tests := []struct {
+		name        string
+		info        wire.TLVUserInfo
+		isAIMViewer bool
+		wantState   string
+		wantIdle    int
+	}{
+		{
+			name:        "a plain arrival is online",
+			info:        userInfoWith("buddy"),
+			isAIMViewer: true,
+			wantState:   "online",
+		},
+		{
+			// A buddy cannot tell an invisible user from a signed-off one.
+			name:        "invisible reads as offline",
+			info:        userInfoWith("buddy", statusTLV(wire.OServiceUserStatusInvisible)),
+			isAIMViewer: true,
+			wantState:   "offline",
+		},
+		{
+			name:        "the unavailable flag is away",
+			info:        userInfoWith("buddy", awayFlagTLV()),
+			isAIMViewer: true,
+			wantState:   "away",
+		},
+		{
+			name:        "the away status bit is away",
+			info:        userInfoWith("buddy", statusTLV(wire.OServiceUserStatusAway)),
+			isAIMViewer: true,
+			wantState:   "away",
+		},
+		{
+			// Busy and DND also raise the unavailable flag, so the status bits
+			// have to be read first or every busy user reports as away.
+			name:        "busy reaches an ICQ viewer as occupied",
+			info:        userInfoWith("buddy", statusTLV(wire.OServiceUserStatusBusy), awayFlagTLV()),
+			isAIMViewer: false,
+			wantState:   "occupied",
+		},
+		{
+			name:        "dnd reaches an ICQ viewer as dnd",
+			info:        userInfoWith("buddy", statusTLV(wire.OServiceUserStatusDND), awayFlagTLV()),
+			isAIMViewer: false,
+			wantState:   "dnd",
+		},
+		{
+			// AIM accounts have no vocabulary for busy or dnd.
+			name:        "busy collapses to away for an AIM viewer",
+			info:        userInfoWith("buddy", statusTLV(wire.OServiceUserStatusBusy), awayFlagTLV()),
+			isAIMViewer: true,
+			wantState:   "away",
+		},
+		{
+			name:        "dnd collapses to away for an AIM viewer",
+			info:        userInfoWith("buddy", statusTLV(wire.OServiceUserStatusDND), awayFlagTLV()),
+			isAIMViewer: true,
+			wantState:   "away",
+		},
+		{
+			name:        "idle alone is idle",
+			info:        userInfoWith("buddy", idleTLV(7)),
+			isAIMViewer: true,
+			wantState:   "idle",
+			wantIdle:    7,
+		},
+		{
+			// Idle rides along in idleTime without taking over the state.
+			name:        "idle does not displace away",
+			info:        userInfoWith("buddy", awayFlagTLV(), idleTLV(12)),
+			isAIMViewer: true,
+			wantState:   "away",
+			wantIdle:    12,
+		},
+		{
+			name:        "idle does not displace occupied",
+			info:        userInfoWith("buddy", statusTLV(wire.OServiceUserStatusBusy), awayFlagTLV(), idleTLV(3)),
+			isAIMViewer: false,
+			wantState:   "occupied",
+			wantIdle:    3,
+		},
+		{
+			name:        "zero idle minutes leave the buddy online",
+			info:        userInfoWith("buddy", idleTLV(0)),
+			isAIMViewer: true,
+			wantState:   "online",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			gotState, gotIdle := buddyWebState(tt.info, tt.isAIMViewer)
+			assert.Equal(t, tt.wantState, gotState)
+			assert.Equal(t, tt.wantIdle, gotIdle)
+		})
+	}
+}
+
+// No locate query follows a presence record, so everything a roster payload
+// needs has to survive the trip from the SNAC.
+func TestBuddyPresenceFrom(t *testing.T) {
+	info := userInfoWith("Mike Kelly", idleTLV(4),
+		wire.NewTLVBE(wire.OServiceUserInfoSignonTOD, uint32(1700000000)),
+		wire.NewTLVBE(wire.OServiceUserInfoOscarCaps, wire.CapICQCh2Extended[:]),
+		wire.NewTLVBE(wire.OServiceUserInfoBARTInfo, []wire.BARTID{testIconBART, testStatusBART}),
+	)
+
+	got := buddyPresenceFrom(info, true)
+
+	assert.Equal(t, "Mike Kelly", got.DisplayID)
+	assert.Equal(t, "idle", got.State)
+	assert.Equal(t, 4, got.IdleTime)
+	assert.Equal(t, int64(1700000000), got.OnlineTime)
+	assert.Equal(t, "brb", got.StatusMsg)
+	assert.Equal(t, [][16]byte{wire.CapICQCh2Extended}, got.Caps)
+	assert.Equal(t, []byte{0xde, 0xad, 0xbe, 0xef}, got.IconHash)
+	assert.True(t, got.Online())
+}
+
+func TestSession_PresenceView(t *testing.T) {
+	newSession := func(events ...string) *Session {
+		return &Session{
+			ScreenName: state.DisplayScreenName("me"),
+			Events:     events,
+			EventQueue: NewEventQueue(10),
+			logger:     slog.New(slog.NewTextHandler(io.Discard, nil)),
+		}
+	}
+
+	t.Run("an arrival records the buddy under their normalized aimId", func(t *testing.T) {
+		sess := newSession("presence")
+		buddyArrives(sess, onlineBuddy("Mike Kelly"))
+
+		got, ok := sess.BuddyPresence(state.NewIdentScreenName("MIKE KELLY"))
+		require.True(t, ok)
+		assert.Equal(t, "online", got.State)
+		assert.Equal(t, "Mike Kelly", got.DisplayID)
+	})
+
+	t.Run("a buddy never seen has no entry", func(t *testing.T) {
+		sess := newSession("presence")
+		_, ok := sess.BuddyPresence(state.NewIdentScreenName("stranger"))
+		assert.False(t, ok)
+	})
+
+	// A departure carries no TLV block, so everything but the display name goes.
+	t.Run("a departure marks the buddy offline and keeps their display name", func(t *testing.T) {
+		sess := newSession("presence")
+		buddyArrives(sess, bartBuddy("Mike Kelly", testIconBART, testStatusBART))
+		buddyDeparts(sess, "mikekelly")
+
+		got, ok := sess.BuddyPresence(state.NewIdentScreenName("mikekelly"))
+		require.True(t, ok)
+		assert.Equal(t, "offline", got.State)
+		assert.Equal(t, "Mike Kelly", got.DisplayID)
+		assert.Empty(t, got.StatusMsg)
+		assert.Empty(t, got.IconHash)
+		assert.False(t, got.Online())
+	})
+
+	t.Run("a later arrival replaces the earlier state", func(t *testing.T) {
+		sess := newSession("presence")
+		buddyArrives(sess, onlineBuddy("mikekelly"))
+		buddyArrives(sess, userInfoWith("mikekelly", awayFlagTLV()))
+
+		got, _ := sess.BuddyPresence(state.NewIdentScreenName("mikekelly"))
+		assert.Equal(t, "away", got.State)
+	})
+
+	// The roster reads this view, so a buddylist-only client still needs it.
+	t.Run("the view is recorded without a presence subscription", func(t *testing.T) {
+		sess := newSession("buddylist")
+		buddyArrives(sess, onlineBuddy("mikekelly"))
+
+		got, ok := sess.BuddyPresence(state.NewIdentScreenName("mikekelly"))
+		require.True(t, ok)
+		assert.Equal(t, "online", got.State)
+		assert.Empty(t, sess.EventQueue.GetAllEvents())
+
+		buddyDeparts(sess, "mikekelly")
+		got, _ = sess.BuddyPresence(state.NewIdentScreenName("mikekelly"))
+		assert.Equal(t, "offline", got.State)
+		assert.Empty(t, sess.EventQueue.GetAllEvents())
+	})
+
+	// A UIN viewer keeps ICQ's own vocabulary; an AIM viewer does not.
+	t.Run("the viewer's account type decides how busy is reported", func(t *testing.T) {
+		busy := userInfoWith("100003", statusTLV(wire.OServiceUserStatusBusy), awayFlagTLV())
+
+		aimViewer := newSession("presence")
+		buddyArrives(aimViewer, busy)
+		got, _ := aimViewer.BuddyPresence(state.NewIdentScreenName("100003"))
+		assert.Equal(t, "away", got.State)
+
+		icqViewer := newSession("presence")
+		icqViewer.ScreenName = state.DisplayScreenName("100001")
+		buddyArrives(icqViewer, busy)
+		got, _ = icqViewer.BuddyPresence(state.NewIdentScreenName("100003"))
+		assert.Equal(t, "occupied", got.State)
+	})
+}
+
+// Idle time and signon time have fields on the presence event and come from the
+// same record the roster reads.
+func TestSession_PresenceEventCarriesIdleAndOnlineTime(t *testing.T) {
+	sess := &Session{
+		ScreenName: state.DisplayScreenName("me"),
+		Events:     []string{"presence"},
+		EventQueue: NewEventQueue(10),
+		logger:     slog.New(slog.NewTextHandler(io.Discard, nil)),
+	}
+
+	buddyArrives(sess, userInfoWith("mikekelly", idleTLV(9),
+		wire.NewTLVBE(wire.OServiceUserInfoSignonTOD, uint32(1700000000))))
+
+	events := sess.EventQueue.GetAllEvents()
+	require.Len(t, events, 1)
+	got := events[0].Data.(PresenceEvent)
+	assert.Equal(t, "idle", got.State)
+	assert.Equal(t, 9, got.IdleTime)
+	assert.Equal(t, int64(1700000000), got.OnlineTime)
+}
+
+// After un-watching a buddy, no arrival or departure for them reaches this
+// session again, so a retained entry would serve its last-seen state forever.
+func TestSession_ForgetBuddyPresence(t *testing.T) {
+	newSession := func() *Session {
+		return &Session{
+			ScreenName: state.DisplayScreenName("me"),
+			Events:     []string{"presence", "im"},
+			EventQueue: NewEventQueue(10),
+			logger:     slog.New(slog.NewTextHandler(io.Discard, nil)),
+		}
+	}
+
+	t.Run("forgetting drops the entry rather than marking it offline", func(t *testing.T) {
+		sess := newSession()
+		buddyArrives(sess, onlineBuddy("Mike Kelly"))
+		sess.forgetBuddyPresence(state.NewIdentScreenName("mikekelly"))
+
+		// A miss, not an offline record: callers that can fall back to a live
+		// lookup have to tell the two apart.
+		_, ok := sess.BuddyPresence(state.NewIdentScreenName("mikekelly"))
+		assert.False(t, ok)
+	})
+
+	t.Run("forgetting an unknown buddy is a no-op", func(t *testing.T) {
+		sess := newSession()
+		sess.forgetBuddyPresence(state.NewIdentScreenName("stranger"))
+		_, ok := sess.BuddyPresence(state.NewIdentScreenName("stranger"))
+		assert.False(t, ok)
+	})
+
+	t.Run("a later arrival re-establishes the entry", func(t *testing.T) {
+		sess := newSession()
+		buddyArrives(sess, onlineBuddy("mikekelly"))
+		sess.forgetBuddyPresence(state.NewIdentScreenName("mikekelly"))
+		buddyArrives(sess, onlineBuddy("mikekelly"))
+
+		got, ok := sess.BuddyPresence(state.NewIdentScreenName("mikekelly"))
+		require.True(t, ok)
+		assert.Equal(t, "online", got.State)
+	})
+
+	// While watched a departure would have arrived; after un-watching none does.
+	t.Run("an IM from a forgotten buddy carries no state", func(t *testing.T) {
+		sess := newSession()
+		buddyArrives(sess, onlineBuddy("Mike Kelly"))
+		sess.forgetBuddyPresence(state.NewIdentScreenName("mikekelly"))
+
+		frags, err := wire.ICBMFragmentList("hello")
+		require.NoError(t, err)
+		body := wire.SNAC_0x04_0x07_ICBMChannelMsgToClient{
+			ChannelID:   wire.ICBMChannelIM,
+			TLVUserInfo: wire.TLVUserInfo{ScreenName: "Mike Kelly"},
+		}
+		body.Append(wire.NewTLVBE(wire.ICBMTLVAOLIMData, frags))
+		sess.handleIncomingIM(wire.SNACMessage{Body: body})
+
+		for _, event := range sess.EventQueue.GetAllEvents() {
+			if im, ok := event.Data.(IMEvent); ok {
+				assert.Empty(t, im.Source.State)
+				return
+			}
+		}
+		t.Fatal("no im event was pushed")
+	})
+}
+
+// A feedbag delete relayed from another instance names the buddies that left the
+// roster, so the session forgets them without a query of its own.
+func TestSession_RelayedFeedbagDeleteForgetsPresence(t *testing.T) {
+	sess := &Session{
+		ScreenName: state.DisplayScreenName("me"),
+		Events:     []string{"presence", "buddylist"},
+		EventQueue: NewEventQueue(10),
+		logger:     slog.New(slog.NewTextHandler(io.Discard, nil)),
+	}
+	buddyArrives(sess, onlineBuddy("Mike Kelly"))
+	buddyArrives(sess, onlineBuddy("keeper"))
+
+	sess.handleFeedbagMessage(wire.SNACMessage{
+		Frame: wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagDeleteItem},
+		Body: wire.SNAC_0x13_0x0A_FeedbagDeleteItem{Items: []wire.FeedbagItem{
+			{ClassID: wire.FeedbagClassIdBuddy, Name: "mikekelly"},
+			// A group row names a group, not a buddy, and must not evict anything.
+			{ClassID: wire.FeedbagClassIdGroup, Name: "keeper"},
+		}},
+	})
+
+	_, ok := sess.BuddyPresence(state.NewIdentScreenName("mikekelly"))
+	assert.False(t, ok, "deleted buddy should be forgotten")
+
+	_, ok = sess.BuddyPresence(state.NewIdentScreenName("keeper"))
+	assert.True(t, ok, "a group row must not evict a like-named buddy")
+}
+
+// An insert or update leaves the roster membership intact, so it must not evict.
+func TestSession_RelayedFeedbagUpdateKeepsPresence(t *testing.T) {
+	sess := &Session{
+		ScreenName: state.DisplayScreenName("me"),
+		Events:     []string{"presence", "buddylist"},
+		EventQueue: NewEventQueue(10),
+		logger:     slog.New(slog.NewTextHandler(io.Discard, nil)),
+	}
+	buddyArrives(sess, onlineBuddy("Mike Kelly"))
+
+	sess.handleFeedbagMessage(wire.SNACMessage{
+		Frame: wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagUpdateItem},
+		Body: wire.SNAC_0x13_0x09_FeedbagUpdateItem{Items: []wire.FeedbagItem{
+			{ClassID: wire.FeedbagClassIdBuddy, Name: "mikekelly"},
+		}},
+	})
+
+	got, ok := sess.BuddyPresence(state.NewIdentScreenName("mikekelly"))
+	require.True(t, ok)
+	assert.Equal(t, "online", got.State)
+}