From 76585f07a1ea8107559797fde7462272e3ab8931 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Jun 2026 07:38:54 +0000 Subject: [PATCH 1/2] thread-inbox: subscription-sourced room fields, app-unsubscribe filter, DM HR info MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refines the cross-site user thread inbox (history-service leaf pipeline + user-service aggregator): - Hide unsubscribed-app threads: the membership join now gates botDM rooms on isSubscribed, so threads from apps the user soft-unsubscribed from (row retained, isSubscribed=false) drop out of the inbox. channel/dm/discussion rooms pass through untouched. - Source roomName/roomType from the membership subscription instead of the rooms collection: dm/botDM room docs carry no name (the per-subscriber display name lives on Subscription.Name), and the subscription already holds roomType. This also removes the post-$limit rooms $lookup entirely (two joins, not three). - Enrich DM rows with the counterpart's HR record (hrInfo: native + English name) in the aggregator, resolved from roomName via GetHRInfoByAccounts on the returned page only; degrades to no hrInfo on lookup failure. Adds ThreadListItem.HRInfo (reusing SubscriptionHRInfo). - Remove the unreachable cross-site dedup: thread_rooms live only on their owning site and are never replicated, so history-service's thread-room $unwind drops any replica thread-subscription whose thread_room isn't local — each threadRoomId is returned by exactly one site. Updates docs/client-api.md for the new hrInfo field and the roomName sourcing. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NGcbzRWVwyrUtYnTBuFobk --- docs/client-api.md | 15 ++- .../internal/mongorepo/pipelines.go | 52 +++++---- .../mongorepo/threadsubscription_test.go | 102 ++++++++++++++++-- .../mongorepo/threadsubscription_unit_test.go | 9 +- pkg/model/threadlist.go | 5 + pkg/model/threadlist_test.go | 21 ++++ user-service/service/threads.go | 51 +++++++-- user-service/service/threads_test.go | 72 +++++++++---- 8 files changed, 259 insertions(+), 68 deletions(-) diff --git a/docs/client-api.md b/docs/client-api.md index b9ef2313f..b4d456321 100644 --- a/docs/client-api.md +++ b/docs/client-api.md @@ -4378,7 +4378,7 @@ Returns the user's thread subscriptions across **all sites** as one globally-ord |---|---|---| | `siteId` | string | The thread's owning site. | | `roomId` | string | The room the thread belongs to. | -| `roomName` | string | The owning room's name (empty if the room doc is unavailable). | +| `roomName` | string | Per-subscriber display label, sourced from the user's subscription: `channel` → room name; `dm` → counterpart account; `botDM` → app name. | | `roomType` | string | The owning room's type (`channel`, `dm`, `botDM`, `discussion`). | | `threadRoomId` | string | The thread room ID. | | `parentMessageId` | string | The thread's parent (top-level) message ID. | @@ -4388,6 +4388,7 @@ Returns the user's thread subscriptions across **all sites** as one globally-ord | `lastMsgAt` | number | UTC ms of the thread's last activity — the global sort key. | | `parentMessage` | [Message](#message-schema) | Optional. The hydrated parent message; reply count rides on its `tcount`. | | `lastMessage` | [Message](#message-schema) | Optional. The hydrated last reply. | +| `hrInfo` | [SubscriptionHRInfo](#subscriptionhrinfo) | Optional. Present **only on `dm` rows** — the counterpart's HR record, resolved from `roomName`. Omitted when the directory lookup degrades. | ```json { @@ -4415,6 +4416,18 @@ Returns the user's thread subscriptions across **all sites** as one globally-ord "sender": { "id": "01970a4f8c2d7c9a01970a4f8c2d7c9b", "account": "bob" }, "msg": "shipping it" } + }, + { + "siteId": "site-a", + "roomId": "01970a4f8c2d7c9aDM", + "roomName": "bob", + "roomType": "dm", + "threadRoomId": "01970a4f8c2d7c9aTHR2", + "parentMessageId": "01970a4f8c2d7c9aPQRS", + "hasMention": false, + "unread": false, + "lastMsgAt": 1746518100000, + "hrInfo": { "account": "bob", "name": "鮑伯", "engName": "Bob" } } ], "nextCursor": "eyJsYXN0TXNnQXQiOjE3NDY1MTg0MDAwMDAsInRocmVhZFJvb21JZCI6IjAxOTcwYTRmOGMyZDdjOWFUSFJEIn0=", diff --git a/history-service/internal/mongorepo/pipelines.go b/history-service/internal/mongorepo/pipelines.go index 6ea360673..9b4bdc380 100644 --- a/history-service/internal/mongorepo/pipelines.go +++ b/history-service/internal/mongorepo/pipelines.go @@ -36,21 +36,23 @@ func followingThreadsPipeline(roomID, account string, accessSince *time.Time) bs // from thread_subscriptions (the per-user filter) and joins thread_rooms for the // activity/parent fields. // -// $lookup justification: three joins, none avoidable. +// $lookup justification: two joins, none avoidable. // 1. subscriptions (membership) runs FIRST, on the thread_subscription's own // roomId — the room subscription, not the thread subscription, is the source // of truth for whether the user still belongs to the room (purged on leave; -// thread_subscriptions rows are not). Filtering here, before $limit, keeps the -// page exact; doing it before the thread_rooms join means that join runs only -// for accessible threads. Indexed point read on (u.account, roomId). +// thread_subscriptions rows are not). For botDM rooms, where unsubscribe is a +// soft toggle that retains the row, the same join also gates on isSubscribed so +// an unsubscribed app's threads drop out. Filtering here, before $limit, keeps +// the page exact; doing it before the thread_rooms join means that join runs +// only for accessible threads. Indexed point read on (u.account, roomId). This +// join also carries roomName and roomType: the subscription holds the +// per-subscriber display name (dm/botDM room docs have none) and the roomType, +// so the page needs no rooms lookup. // 2. thread_rooms supplies the inbox sort key (lastMsgAt) and parent/activity // fields, which live there rather than on thread_subscriptions, so we must // sort and paginate on the looked-up field. Denormalizing lastMsgAt onto every // subscription was rejected because it would write-amplify across all // subscribers on every reply (see docs/design/user-thread-list.md §5). -// 3. rooms (name/type) runs AFTER $limit, so it enriches only the ≤limit+1 page -// rows with indexed _id point reads — cheaper than a separate round trip, and -// it avoids bolting a non-cacheable GetRoomsMeta onto the cached RoomRepository. func userThreadSubscriptionsPipeline(account string, cursorLastMsgAt *time.Time, cursorThreadRoomID string, limit int) bson.A { pipeline := bson.A{ bson.D{{Key: "$match", Value: bson.M{"userAccount": account}}}, @@ -63,13 +65,29 @@ func userThreadSubscriptionsPipeline(account string, cursorLastMsgAt *time.Time, bson.D{{Key: "$match", Value: bson.M{ "u.account": account, "$expr": bson.M{"$eq": bson.A{"$roomId", "$$rid"}}, + // botDM unsubscribe is a soft toggle (isSubscribed=false, row + // retained), unlike a room leave that purges the row. Gate botDM + // rooms on isSubscribed so an unsubscribed app's threads drop out + // of the inbox; channel/dm/discussion rooms pass through untouched. + "$or": bson.A{ + bson.M{"roomType": bson.M{"$ne": "botDM"}}, + bson.M{"isSubscribed": true}, + }, }}}, - bson.D{{Key: "$project", Value: bson.M{"_id": 1}}}, + bson.D{{Key: "$project", Value: bson.M{"_id": 1, "name": 1, "roomType": 1}}}, }, "as": "sub", }}}, // {$ne: []} — $lookup sets "sub" to [] when no subscription matched; non-empty means subscribed. bson.D{{Key: "$match", Value: bson.M{"sub": bson.M{"$ne": bson.A{}}}}}, + // roomName and roomType come from the user's own subscription, not a room doc: + // dm/botDM rooms store an empty room name (the display name is per-subscriber), + // and the subscription already carries roomType — so no rooms lookup is needed. + // Lifted to scalars here so the sub array can be dropped before $sort. + bson.D{{Key: "$set", Value: bson.M{ + "roomName": bson.M{"$arrayElemAt": bson.A{"$sub.name", 0}}, + "roomType": bson.M{"$arrayElemAt": bson.A{"$sub.roomType", 0}}, + }}}, bson.D{{Key: "$project", Value: bson.M{"sub": 0}}}, // drop before $sort — mirrors unreadThreadsPipeline bson.D{{Key: "$lookup", Value: bson.M{ "from": threadRoomsCollection, @@ -96,24 +114,14 @@ func userThreadSubscriptionsPipeline(account string, cursorLastMsgAt *time.Time, pipeline = append(pipeline, bson.D{{Key: "$sort", Value: bson.D{{Key: "tr.lastMsgAt", Value: -1}, {Key: "threadRoomId", Value: -1}}}}, bson.D{{Key: "$limit", Value: int64(limit + 1)}}, - // Page-scoped (post-$limit) room name/type enrichment; preserve rows whose - // room doc is missing (degrade to empty name/type). - bson.D{{Key: "$lookup", Value: bson.M{ - "from": roomsCollection, - "localField": "tr.roomId", - "foreignField": "_id", - "as": "room", - "pipeline": bson.A{ - bson.D{{Key: "$project", Value: bson.M{"name": 1, "type": 1}}}, - }, - }}}, - bson.D{{Key: "$unwind", Value: bson.M{"path": "$room", "preserveNullAndEmptyArrays": true}}}, + // roomName/roomType both ride in on the membership subscription (join 1), so + // the page needs no rooms lookup. bson.D{{Key: "$project", Value: bson.M{ "_id": "$threadRoomId", "roomId": "$tr.roomId", "siteId": "$tr.siteId", - "roomName": "$room.name", - "roomType": "$room.type", + "roomName": 1, // sourced from the subscription above, not a room doc + "roomType": 1, // sourced from the subscription above, not a room doc "parentMessageId": "$tr.parentMessageId", "lastMsgId": "$tr.lastMsgId", "lastMsgAt": "$tr.lastMsgAt", diff --git a/history-service/internal/mongorepo/threadsubscription_test.go b/history-service/internal/mongorepo/threadsubscription_test.go index aececa9a0..28b5436b6 100644 --- a/history-service/internal/mongorepo/threadsubscription_test.go +++ b/history-service/internal/mongorepo/threadsubscription_test.go @@ -9,10 +9,99 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" "github.com/hmchangw/chat/pkg/model" ) +// insertBotDMSubscription seeds a botDM room subscription with an explicit +// isSubscribed flag, mirroring how user-service's SetAppSubscribed persists the +// soft-unsubscribe toggle ($set isSubscribed:false, row retained). Raw bson, not +// model.Subscription, because the struct's `omitempty` would drop a false +// isSubscribed — and explicit false is exactly the production state under test. +func insertBotDMSubscription(t *testing.T, db *mongo.Database, account, roomID string, subscribed bool) { + t.Helper() + _, err := db.Collection("subscriptions").InsertOne(context.Background(), bson.M{ + "_id": account + ":" + roomID, + "u": bson.M{"account": account}, + "roomId": roomID, + "siteId": "site-a", + "roomType": string(model.RoomTypeBotDM), + "isSubscribed": subscribed, + }) + require.NoError(t, err) +} + +// insertNamedSubscription seeds a room subscription carrying the per-subscriber +// display Name and roomType, mirroring how room-worker's newSub persists them +// (channel: room name; dm/botDM: counterpart account / app name). +func insertNamedSubscription(t *testing.T, db *mongo.Database, account, roomID, name string, roomType model.RoomType) { + t.Helper() + _, err := db.Collection("subscriptions").InsertOne(context.Background(), model.Subscription{ + ID: account + ":" + roomID, + User: model.SubscriptionUser{Account: account}, + RoomID: roomID, + SiteID: "site-a", + Name: name, + RoomType: roomType, + }) + require.NoError(t, err) +} + +// roomName and roomType are filled from the user's own subscription, not a room +// document: dm/botDM rooms carry an empty room name (the display name is +// per-subscriber). No rooms collection is seeded — the pipeline must resolve both +// fields without it. +func TestThreadSubscriptionRepo_ListUserThreadSubscriptions_RoomNameFromSubscription(t *testing.T) { + db := setupMongo(t) + repo := NewThreadSubscriptionRepo(db) + ctx := context.Background() + base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + + // A DM thread with no rooms document at all; alice's subscription names the + // counterpart (bob) and carries the room type. + insertThreadRoom(t, db, model.ThreadRoom{ID: "tr-dm", RoomID: "dm1", ParentMessageID: "p-dm", LastMsgID: "m-dm", SiteID: "site-a", LastMsgAt: base.Add(time.Hour), CreatedAt: base, UpdatedAt: base}) + insertThreadSubscription(t, db, model.ThreadSubscription{ID: "ts-dm", ThreadRoomID: "tr-dm", RoomID: "dm1", ParentMessageID: "p-dm", UserAccount: "alice", SiteID: "site-a", CreatedAt: base, UpdatedAt: base}) + insertNamedSubscription(t, db, "alice", "dm1", "bob", model.RoomTypeDM) + + rows, _, err := repo.ListUserThreadSubscriptions(ctx, "alice", nil, "", 10) + require.NoError(t, err) + require.Len(t, rows, 1) + assert.Equal(t, "bob", rows[0].RoomName, "DM roomName must come from the subscription Name, not the empty room name") + assert.Equal(t, model.RoomTypeDM, rows[0].RoomType, "DM roomType must come from the subscription, with no rooms lookup") +} + +// An unsubscribed app's botDM thread must not appear in the inbox, while a +// still-subscribed app's botDM thread does. botDM unsubscribe is a soft toggle +// (isSubscribed=false) that leaves the subscription row in place — unlike a room +// leave, which purges it — so the membership join must gate botDM rows on +// isSubscribed. +func TestThreadSubscriptionRepo_ListUserThreadSubscriptions_UnsubscribedApp(t *testing.T) { + db := setupMongo(t) + repo := NewThreadSubscriptionRepo(db) + ctx := context.Background() + base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + + // Two botDM rooms, each hosting one thread alice follows. bot-off has the newer + // activity, so a missing filter would surface it first. + insertThreadRoom(t, db, model.ThreadRoom{ID: "tr-on", RoomID: "bot-on", ParentMessageID: "p-on", LastMsgID: "m-on", SiteID: "site-a", LastMsgAt: base.Add(3 * time.Hour), CreatedAt: base, UpdatedAt: base}) + insertThreadRoom(t, db, model.ThreadRoom{ID: "tr-off", RoomID: "bot-off", ParentMessageID: "p-off", LastMsgID: "m-off", SiteID: "site-a", LastMsgAt: base.Add(5 * time.Hour), CreatedAt: base, UpdatedAt: base}) + + insertThreadSubscription(t, db, model.ThreadSubscription{ID: "ts-on", ThreadRoomID: "tr-on", RoomID: "bot-on", ParentMessageID: "p-on", UserAccount: "alice", SiteID: "site-a", CreatedAt: base, UpdatedAt: base}) + insertThreadSubscription(t, db, model.ThreadSubscription{ID: "ts-off", ThreadRoomID: "tr-off", RoomID: "bot-off", ParentMessageID: "p-off", UserAccount: "alice", SiteID: "site-a", CreatedAt: base, UpdatedAt: base}) + + // alice still subscribes to bot-on; she has unsubscribed from bot-off. + insertBotDMSubscription(t, db, "alice", "bot-on", true) + insertBotDMSubscription(t, db, "alice", "bot-off", false) + + rows, hasMore, err := repo.ListUserThreadSubscriptions(ctx, "alice", nil, "", 10) + require.NoError(t, err) + assert.False(t, hasMore) + require.Len(t, rows, 1) + assert.Equal(t, "tr-on", rows[0].ThreadRoomID, "unsubscribed-app thread must be filtered out") +} + func TestThreadSubscriptionRepo_ListUserThreadSubscriptions(t *testing.T) { db := setupMongo(t) repo := NewThreadSubscriptionRepo(db) @@ -37,15 +126,12 @@ func TestThreadSubscriptionRepo_ListUserThreadSubscriptions(t *testing.T) { insertThreadSubscription(t, db, model.ThreadSubscription{ID: "ts-left", ThreadRoomID: "tr-left", RoomID: "r-left", ParentMessageID: "p-left", UserAccount: "alice", SiteID: "site-a", CreatedAt: base, UpdatedAt: base}) // alice's room subscriptions — the membership $lookup keeps only threads whose - // room she is still subscribed to. She is in r1 and r2, but not r-left. - insertSubscription(t, db, "alice", "r1") + // room she is still subscribed to. She is in r1 and r2, but not r-left. The + // subscription is also where roomName/roomType come from: r1 carries its channel + // name and type; r2 is left nameless/typeless to exercise the empty pass-through. + insertNamedSubscription(t, db, "alice", "r1", "general", model.RoomTypeChannel) insertSubscription(t, db, "alice", "r2") - // rooms feed the name/type $lookup; r2 is intentionally unseeded to exercise - // the missing-room degrade (empty name/type, row still returned). - _, err := db.Collection("rooms").InsertOne(ctx, model.Room{ID: "r1", Name: "general", Type: model.RoomTypeChannel, SiteID: "site-a"}) - require.NoError(t, err) - // Page 1, limit 2: newest two (tr-1 5h, tr-2 3h), hasMore true. rows, hasMore, err := repo.ListUserThreadSubscriptions(ctx, "alice", nil, "", 2) require.NoError(t, err) @@ -64,7 +150,7 @@ func TestThreadSubscriptionRepo_ListUserThreadSubscriptions(t *testing.T) { assert.WithinDuration(t, base.Add(2*time.Hour), *rows[0].LastSeenAt, time.Second) assert.Nil(t, rows[1].LastSeenAt) // never-seen thread - // Room name/type ride in via the rooms $lookup; r2 unseeded ⇒ degrade to empty. + // Room name/type ride in on the membership subscription; r2's sub is nameless ⇒ empty. assert.Equal(t, "general", rows[0].RoomName) assert.Equal(t, model.RoomTypeChannel, rows[0].RoomType) assert.Empty(t, rows[1].RoomName) diff --git a/history-service/internal/mongorepo/threadsubscription_unit_test.go b/history-service/internal/mongorepo/threadsubscription_unit_test.go index a7b507701..525a1b12e 100644 --- a/history-service/internal/mongorepo/threadsubscription_unit_test.go +++ b/history-service/internal/mongorepo/threadsubscription_unit_test.go @@ -30,10 +30,11 @@ func TestUserThreadSubscriptionsPipeline_FirstPageHasNoCursorMatch(t *testing.T) p := userThreadSubscriptionsPipeline("alice", nil, "", 20) // First page: userAccount $match + membership $match (sub != []), no value-cursor $match. assert.Equal(t, 2, countStages(t, p, "$match")) - // Three joins: thread_rooms (sort/cursor), subscriptions (membership), rooms (name/type). - assert.Equal(t, 3, countStages(t, p, "$lookup")) - // thread_rooms and rooms are unwound; the membership join uses {$ne: []}, not $unwind. - assert.Equal(t, 2, countStages(t, p, "$unwind")) + // Two joins: thread_rooms (sort/cursor) and subscriptions (membership). roomName + // and roomType both ride in on the membership join, so there is no rooms lookup. + assert.Equal(t, 2, countStages(t, p, "$lookup")) + // Only thread_rooms is unwound; the membership join uses {$ne: []}, not $unwind. + assert.Equal(t, 1, countStages(t, p, "$unwind")) assert.Equal(t, 1, countStages(t, p, "$sort")) // Only the outer page $limit is top-level; the membership join's inner $limit:1 // is nested inside the $lookup pipeline and not counted here. diff --git a/pkg/model/threadlist.go b/pkg/model/threadlist.go index 81b15eb52..edb09470f 100644 --- a/pkg/model/threadlist.go +++ b/pkg/model/threadlist.go @@ -25,6 +25,11 @@ type ThreadListItem struct { // Hydrated message bodies, subject to the thread access window. ParentMessage *cassandra.Message `json:"parentMessage,omitempty" bson:"parentMessage,omitempty"` LastMessage *cassandra.Message `json:"lastMessage,omitempty" bson:"lastMessage,omitempty"` + + // HRInfo carries the DM counterpart's HR-directory record (native + English + // name). The user-service aggregator resolves it from RoomName (which holds the + // counterpart account for DM rooms); present on DM rows only. + HRInfo *SubscriptionHRInfo `json:"hrInfo,omitempty" bson:"hrInfo,omitempty"` } // ThreadSubscriptionListRequest is the server-to-server leaf request the diff --git a/pkg/model/threadlist_test.go b/pkg/model/threadlist_test.go index 01211e55c..664bc379b 100644 --- a/pkg/model/threadlist_test.go +++ b/pkg/model/threadlist_test.go @@ -29,6 +29,27 @@ func TestThreadListItemJSON(t *testing.T) { roundTrip(t, &src, &model.ThreadListItem{}) } +// A DM thread row carries the counterpart's HR record, which survives a round trip. +func TestThreadListItemJSON_WithHRInfo(t *testing.T) { + src := model.ThreadListItem{ + SiteID: "site-a", RoomID: "dm1", RoomName: "bob", RoomType: model.RoomTypeDM, + ThreadRoomID: "thr-1", LastMsgAt: 1746518400000, + HRInfo: &model.SubscriptionHRInfo{Account: "bob", Name: "鮑勃", EngName: "Bob Chen"}, + } + roundTrip(t, &src, &model.ThreadListItem{}) +} + +// hrInfo is omitted for non-DM rows (no counterpart record). +func TestThreadListItemJSON_OmitsNilHRInfo(t *testing.T) { + src := model.ThreadListItem{SiteID: "site-a", RoomID: "room-1", RoomType: model.RoomTypeChannel, ThreadRoomID: "thr-1"} + data, err := json.Marshal(&src) + require.NoError(t, err) + var raw map[string]any + require.NoError(t, json.Unmarshal(data, &raw)) + _, hasHR := raw["hrInfo"] + assert.False(t, hasHR, "nil hrInfo must be omitted") +} + // LastSeenAt is omitted when nil (never-seen thread). func TestThreadListItemJSON_OmitsNilLastSeenAt(t *testing.T) { src := model.ThreadListItem{SiteID: "site-a", RoomID: "room-1", ThreadRoomID: "thr-1"} diff --git a/user-service/service/threads.go b/user-service/service/threads.go index 2b5b34a59..31503fcf2 100644 --- a/user-service/service/threads.go +++ b/user-service/service/threads.go @@ -100,13 +100,15 @@ func (s *UserService) ListUserThreads(c *natsrouter.Context, req model.ThreadLis } return merged[a].ThreadRoomID > merged[b].ThreadRoomID }) - merged = dedupeThreads(merged) hasNext := anyHasMore || len(merged) > limit if len(merged) > limit { merged = merged[:limit] } + // Enrich only the returned page so each DM counterpart is looked up at most once. + s.enrichThreadHRInfo(c, merged) + resp := &model.ThreadListResponse{Items: merged, HasNext: hasNext, UnavailableSites: unavailable} if hasNext && len(merged) > 0 { last := merged[len(merged)-1] @@ -218,18 +220,45 @@ func (s *UserService) enrichCrossSiteThreads(c *natsrouter.Context, sites []stri wg.Wait() } -// dedupeThreads drops repeat threadRoomIds, keeping the first (newest) — guards -// against the deletion edge where a recomputed lastMsgAt could resurface an -// already-emitted thread on a later page. -func dedupeThreads(items []model.ThreadListItem) []model.ThreadListItem { - seen := make(map[string]struct{}, len(items)) - out := items[:0:0] +// enrichThreadHRInfo attaches each DM row's counterpart HR record (native + +// English name) to the page, keyed on RoomName — history-service fills a DM's +// roomName from the per-subscriber subscription Name, which is the counterpart +// account. A failed/missing lookup degrades to no hrInfo and never fails the +// request. Mirrors lookupHRInfo on the subscription-list path. +func (s *UserService) enrichThreadHRInfo(c *natsrouter.Context, items []model.ThreadListItem) { + accounts := distinctDMRoomNames(items) + if len(accounts) == 0 { + return + } + hr, err := s.users.GetHRInfoByAccounts(c, accounts) + if err != nil { + slog.WarnContext(c, "thread hr info lookup degraded", "account", c.Param("account"), "request_id", natsutil.RequestIDFromContext(c), "error", err) + return + } for i := range items { - if _, dup := seen[items[i].ThreadRoomID]; dup { + if items[i].RoomType != model.RoomTypeDM { + continue + } + if info, ok := hr[items[i].RoomName]; ok { + items[i].HRInfo = info + } + } +} + +// distinctDMRoomNames returns the deduped counterpart accounts (RoomName) of the +// DM rows, skipping blanks. +func distinctDMRoomNames(items []model.ThreadListItem) []string { + var accounts []string + seen := map[string]struct{}{} + for i := range items { + if items[i].RoomType != model.RoomTypeDM || items[i].RoomName == "" { + continue + } + if _, dup := seen[items[i].RoomName]; dup { continue } - seen[items[i].ThreadRoomID] = struct{}{} - out = append(out, items[i]) + seen[items[i].RoomName] = struct{}{} + accounts = append(accounts, items[i].RoomName) } - return out + return accounts } diff --git a/user-service/service/threads_test.go b/user-service/service/threads_test.go index b65ed5512..d914c22bd 100644 --- a/user-service/service/threads_test.go +++ b/user-service/service/threads_test.go @@ -18,21 +18,22 @@ import ( // newThreadSvc builds a UserService whose fan-out set is site-a (local) + site-b // (cross), from ALL_SITE_IDS. The thread inbox only depends on the history // client, so the other deps are fresh no-expectation mocks. -func newThreadSvc(t *testing.T) (*UserService, *mocks.MockHistoryClient) { +func newThreadSvc(t *testing.T) (*UserService, *mocks.MockHistoryClient, *mocks.MockUserRepository) { t.Helper() ctrl := gomock.NewController(t) history := mocks.NewMockHistoryClient(ctrl) + users := mocks.NewMockUserRepository(ctrl) cfg := &config.Config{SiteID: "site-a", AllSiteIDs: []string{"site-a", "site-b"}, MaxSubscriptionLimit: 1000, MaxAccountNames: 100} svc := New( mocks.NewMockSubscriptionRepository(ctrl), - mocks.NewMockUserRepository(ctrl), + users, mocks.NewMockAppRepository(ctrl), mocks.NewMockRoomClient(ctrl), history, mocks.NewMockEventPublisher(ctrl), cfg, ) - return svc, history + return svc, history, users } func item(site, threadRoomID string, lastMsgAt int64) model.ThreadListItem { @@ -46,7 +47,7 @@ func expectThreadList(history *mocks.MockHistoryClient, site string, items []mod } func TestUserService_ListUserThreads_MergeAcrossSites(t *testing.T) { - svc, history := newThreadSvc(t) + svc, history, _ := newThreadSvc(t) expectThreadList(history, "site-a", []model.ThreadListItem{item("site-a", "ta1", 50), item("site-a", "ta2", 20)}, false) expectThreadList(history, "site-b", []model.ThreadListItem{item("site-b", "tb1", 40), item("site-b", "tb2", 30)}, false) @@ -61,7 +62,7 @@ func TestUserService_ListUserThreads_MergeAcrossSites(t *testing.T) { } func TestUserService_ListUserThreads_PaginatesAndSetsCursor(t *testing.T) { - svc, history := newThreadSvc(t) + svc, history, _ := newThreadSvc(t) expectThreadList(history, "site-a", []model.ThreadListItem{item("site-a", "t1", 50), item("site-a", "t2", 40), item("site-a", "t3", 30)}, true) expectThreadList(history, "site-b", nil, false) @@ -80,7 +81,7 @@ func TestUserService_ListUserThreads_PaginatesAndSetsCursor(t *testing.T) { } func TestUserService_ListUserThreads_AllSitesEmpty(t *testing.T) { - svc, history := newThreadSvc(t) + svc, history, _ := newThreadSvc(t) expectThreadList(history, "site-a", nil, false) expectThreadList(history, "site-b", nil, false) @@ -92,14 +93,14 @@ func TestUserService_ListUserThreads_AllSitesEmpty(t *testing.T) { } func TestUserService_ListUserThreads_BadCursor(t *testing.T) { - svc, _ := newThreadSvc(t) + svc, _, _ := newThreadSvc(t) // Decode happens before any fan-out, so no GetThreadList calls are made. _, err := svc.ListUserThreads(ctx("alice", "site-a"), model.ThreadListRequest{Cursor: "!!!not-base64"}) requireCode(t, err, errcode.CodeBadRequest) } func TestUserService_ListUserThreads_PartialFailureDegrades(t *testing.T) { - svc, history := newThreadSvc(t) + svc, history, _ := newThreadSvc(t) expectThreadList(history, "site-a", []model.ThreadListItem{item("site-a", "ta1", 50)}, false) history.EXPECT().GetThreadList(gomock.Any(), "site-b", gomock.Any()). Return(model.ThreadSubscriptionListResponse{}, errors.New("site-b down")) @@ -110,20 +111,8 @@ func TestUserService_ListUserThreads_PartialFailureDegrades(t *testing.T) { assert.Equal(t, []string{"site-b"}, resp.UnavailableSites) } -func TestUserService_ListUserThreads_DedupesThreadRoomID(t *testing.T) { - svc, history := newThreadSvc(t) - // Same threadRoomId surfaces from two sites (deletion-edge); keep one. - expectThreadList(history, "site-a", []model.ThreadListItem{item("site-a", "dup", 50)}, false) - expectThreadList(history, "site-b", []model.ThreadListItem{item("site-b", "dup", 50)}, false) - - resp, err := svc.ListUserThreads(ctx("alice", "site-a"), model.ThreadListRequest{Limit: 10}) - require.NoError(t, err) - require.Len(t, resp.Items, 1) - assert.Equal(t, "dup", resp.Items[0].ThreadRoomID) -} - func TestUserService_ListUserThreads_Tiebreak(t *testing.T) { - svc, history := newThreadSvc(t) + svc, history, _ := newThreadSvc(t) expectThreadList(history, "site-a", []model.ThreadListItem{ item("site-a", "ta", 50), item("site-a", "tc", 50), item("site-a", "tb", 50), }, false) @@ -136,7 +125,7 @@ func TestUserService_ListUserThreads_Tiebreak(t *testing.T) { } func TestUserService_ListUserThreads_PassesCursorToLeaf(t *testing.T) { - svc, history := newThreadSvc(t) + svc, history, _ := newThreadSvc(t) var got model.ThreadSubscriptionListRequest history.EXPECT().GetThreadList(gomock.Any(), "site-a", gomock.Any()). DoAndReturn(func(_ context.Context, _ string, req model.ThreadSubscriptionListRequest) (model.ThreadSubscriptionListResponse, error) { @@ -155,6 +144,45 @@ func TestUserService_ListUserThreads_PassesCursorToLeaf(t *testing.T) { assert.Equal(t, "t9", got.CursorThreadRoomID) } +// dmItem is a DM thread row whose RoomName carries the counterpart account. +func dmItem(site, threadRoomID string, lastMsgAt int64, counterpart string) model.ThreadListItem { + return model.ThreadListItem{SiteID: site, ThreadRoomID: threadRoomID, LastMsgAt: lastMsgAt, RoomType: model.RoomTypeDM, RoomName: counterpart} +} + +// On the final page, DM rows are enriched with the counterpart's HR record +// (resolved from RoomName); non-DM rows are left untouched. +func TestUserService_ListUserThreads_DM_CarriesHRInfo(t *testing.T) { + svc, history, users := newThreadSvc(t) + expectThreadList(history, "site-a", []model.ThreadListItem{dmItem("site-a", "td", 50, "bob"), item("site-a", "tc", 40)}, false) + expectThreadList(history, "site-b", nil, false) + users.EXPECT().GetHRInfoByAccounts(gomock.Any(), []string{"bob"}). + Return(map[string]*model.SubscriptionHRInfo{"bob": {Account: "bob", Name: "鮑勃", EngName: "Bob Chen"}}, nil) + + resp, err := svc.ListUserThreads(ctx("alice", "site-a"), model.ThreadListRequest{Limit: 10}) + require.NoError(t, err) + require.Len(t, resp.Items, 2) + + dm := resp.Items[0] + require.Equal(t, "td", dm.ThreadRoomID) + require.NotNil(t, dm.HRInfo, "DM row must carry hrInfo") + assert.Equal(t, "鮑勃", dm.HRInfo.Name) + assert.Equal(t, "Bob Chen", dm.HRInfo.EngName) + assert.Nil(t, resp.Items[1].HRInfo, "channel row must not carry hrInfo") +} + +// A failed HR lookup degrades to no hrInfo — it never fails the request. +func TestUserService_ListUserThreads_HRInfoDegrades(t *testing.T) { + svc, history, users := newThreadSvc(t) + expectThreadList(history, "site-a", []model.ThreadListItem{dmItem("site-a", "td", 50, "bob")}, false) + expectThreadList(history, "site-b", nil, false) + users.EXPECT().GetHRInfoByAccounts(gomock.Any(), []string{"bob"}).Return(nil, errors.New("hr down")) + + resp, err := svc.ListUserThreads(ctx("alice", "site-a"), model.ThreadListRequest{Limit: 10}) + require.NoError(t, err, "hr lookup failure must degrade, not fail the request") + require.Len(t, resp.Items, 1) + assert.Nil(t, resp.Items[0].HRInfo) +} + func ids(items []model.ThreadListItem) []string { out := make([]string, len(items)) for i := range items { From 5ad9c227010d43d6461adb2ff001d3388dedeeca Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Jun 2026 02:42:36 +0000 Subject: [PATCH 2/2] user-service: replace botDM thread roomName with the app display name The thread inbox filled a botDM row's roomName from the subscription Name, which is the bot account (e.g. "helper.bot"). Mirror the subscription-list path: after merge+limit, look up the apps for the page's distinct botDM bot accounts via GetAppsByAssistants and swap each botDM roomName for the app's display name. Generalizes distinctDMRoomNames -> distinctRoomNamesByType(items, roomType) to feed both the DM hr-info and botDM app-name passes. App lookup degrades to the bot account on failure/missing, independent of the hr-info pass, and never fails the request. docs/client-api.md already documents botDM roomName as the app name. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NGcbzRWVwyrUtYnTBuFobk --- user-service/service/threads.go | 101 +++++++++++++++++++-------- user-service/service/threads_test.go | 63 +++++++++++++---- 2 files changed, 123 insertions(+), 41 deletions(-) diff --git a/user-service/service/threads.go b/user-service/service/threads.go index 31503fcf2..f7b8644d5 100644 --- a/user-service/service/threads.go +++ b/user-service/service/threads.go @@ -106,8 +106,9 @@ func (s *UserService) ListUserThreads(c *natsrouter.Context, req model.ThreadLis merged = merged[:limit] } - // Enrich only the returned page so each DM counterpart is looked up at most once. - s.enrichThreadHRInfo(c, merged) + // Enrich only the returned page: DM rows gain the counterpart's HR record, and + // botDM rows swap their bot account for the app's display name. + s.enrichThreadPage(c, merged) resp := &model.ThreadListResponse{Items: merged, HasNext: hasNext, UnavailableSites: unavailable} if hasNext && len(merged) > 0 { @@ -220,45 +221,87 @@ func (s *UserService) enrichCrossSiteThreads(c *natsrouter.Context, sites []stri wg.Wait() } -// enrichThreadHRInfo attaches each DM row's counterpart HR record (native + -// English name) to the page, keyed on RoomName — history-service fills a DM's -// roomName from the per-subscriber subscription Name, which is the counterpart -// account. A failed/missing lookup degrades to no hrInfo and never fails the -// request. Mirrors lookupHRInfo on the subscription-list path. -func (s *UserService) enrichThreadHRInfo(c *natsrouter.Context, items []model.ThreadListItem) { - accounts := distinctDMRoomNames(items) +// enrichThreadPage resolves the page's per-row display data in a single apply +// pass: DM rows gain the counterpart's HR record, and botDM rows swap their bot +// account (the RoomName history-service filled from the subscription Name) for the +// app's display name. Each lookup is issued once for the page's distinct accounts +// and degrades independently — a failed/missing lookup leaves that row's base data +// and never fails the request. Mirrors buildListItems on the subscription path. +func (s *UserService) enrichThreadPage(c *natsrouter.Context, items []model.ThreadListItem) { + dmAccounts, botAccounts := distinctDMAndBotNames(items) + hr := s.lookupThreadHRInfo(c, dmAccounts) + apps := s.lookupThreadApps(c, botAccounts) + for i := range items { + switch items[i].RoomType { + case model.RoomTypeDM: + if info, ok := hr[items[i].RoomName]; ok { + items[i].HRInfo = info + } + case model.RoomTypeBotDM: + if app, ok := apps[items[i].RoomName]; ok && app != nil && app.Name != "" { + items[i].RoomName = app.Name + } + case model.RoomTypeChannel, model.RoomTypeDiscussion: + // No per-row enrichment: roomName/roomType already carry the final values. + } + } +} + +// lookupThreadHRInfo fetches HR records for the given DM counterpart accounts; a +// failure or empty set degrades to nil (no hrInfo applied). +func (s *UserService) lookupThreadHRInfo(c *natsrouter.Context, accounts []string) map[string]*model.SubscriptionHRInfo { if len(accounts) == 0 { - return + return nil } hr, err := s.users.GetHRInfoByAccounts(c, accounts) if err != nil { slog.WarnContext(c, "thread hr info lookup degraded", "account", c.Param("account"), "request_id", natsutil.RequestIDFromContext(c), "error", err) - return + return nil } - for i := range items { - if items[i].RoomType != model.RoomTypeDM { - continue - } - if info, ok := hr[items[i].RoomName]; ok { - items[i].HRInfo = info - } + return hr +} + +// lookupThreadApps fetches app docs for the given botDM bot accounts; a failure or +// empty set degrades to nil (bot account kept as the display name). +func (s *UserService) lookupThreadApps(c *natsrouter.Context, bots []string) map[string]*model.App { + if len(bots) == 0 { + return nil } + apps, err := s.apps.GetAppsByAssistants(c, bots) + if err != nil { + slog.WarnContext(c, "thread app metadata lookup degraded", "account", c.Param("account"), "request_id", natsutil.RequestIDFromContext(c), "error", err) + return nil + } + return apps } -// distinctDMRoomNames returns the deduped counterpart accounts (RoomName) of the -// DM rows, skipping blanks. -func distinctDMRoomNames(items []model.ThreadListItem) []string { - var accounts []string - seen := map[string]struct{}{} +// distinctDMAndBotNames collects, in a single pass over the page, the deduped DM +// counterpart accounts and botDM bot accounts (each is a row's RoomName), skipping +// blanks. One pass feeds both enrichment lookups. +func distinctDMAndBotNames(items []model.ThreadListItem) (dmAccounts, botAccounts []string) { + dmSeen := map[string]struct{}{} + botSeen := map[string]struct{}{} for i := range items { - if items[i].RoomType != model.RoomTypeDM || items[i].RoomName == "" { + name := items[i].RoomName + if name == "" { continue } - if _, dup := seen[items[i].RoomName]; dup { - continue + switch items[i].RoomType { + case model.RoomTypeDM: + if _, dup := dmSeen[name]; dup { + continue + } + dmSeen[name] = struct{}{} + dmAccounts = append(dmAccounts, name) + case model.RoomTypeBotDM: + if _, dup := botSeen[name]; dup { + continue + } + botSeen[name] = struct{}{} + botAccounts = append(botAccounts, name) + case model.RoomTypeChannel, model.RoomTypeDiscussion: + // Not enriched — no name to collect. } - seen[items[i].RoomName] = struct{}{} - accounts = append(accounts, items[i].RoomName) } - return accounts + return dmAccounts, botAccounts } diff --git a/user-service/service/threads_test.go b/user-service/service/threads_test.go index d914c22bd..d8b781973 100644 --- a/user-service/service/threads_test.go +++ b/user-service/service/threads_test.go @@ -18,22 +18,23 @@ import ( // newThreadSvc builds a UserService whose fan-out set is site-a (local) + site-b // (cross), from ALL_SITE_IDS. The thread inbox only depends on the history // client, so the other deps are fresh no-expectation mocks. -func newThreadSvc(t *testing.T) (*UserService, *mocks.MockHistoryClient, *mocks.MockUserRepository) { +func newThreadSvc(t *testing.T) (*UserService, *mocks.MockHistoryClient, *mocks.MockUserRepository, *mocks.MockAppRepository) { t.Helper() ctrl := gomock.NewController(t) history := mocks.NewMockHistoryClient(ctrl) users := mocks.NewMockUserRepository(ctrl) + apps := mocks.NewMockAppRepository(ctrl) cfg := &config.Config{SiteID: "site-a", AllSiteIDs: []string{"site-a", "site-b"}, MaxSubscriptionLimit: 1000, MaxAccountNames: 100} svc := New( mocks.NewMockSubscriptionRepository(ctrl), users, - mocks.NewMockAppRepository(ctrl), + apps, mocks.NewMockRoomClient(ctrl), history, mocks.NewMockEventPublisher(ctrl), cfg, ) - return svc, history, users + return svc, history, users, apps } func item(site, threadRoomID string, lastMsgAt int64) model.ThreadListItem { @@ -47,7 +48,7 @@ func expectThreadList(history *mocks.MockHistoryClient, site string, items []mod } func TestUserService_ListUserThreads_MergeAcrossSites(t *testing.T) { - svc, history, _ := newThreadSvc(t) + svc, history, _, _ := newThreadSvc(t) expectThreadList(history, "site-a", []model.ThreadListItem{item("site-a", "ta1", 50), item("site-a", "ta2", 20)}, false) expectThreadList(history, "site-b", []model.ThreadListItem{item("site-b", "tb1", 40), item("site-b", "tb2", 30)}, false) @@ -62,7 +63,7 @@ func TestUserService_ListUserThreads_MergeAcrossSites(t *testing.T) { } func TestUserService_ListUserThreads_PaginatesAndSetsCursor(t *testing.T) { - svc, history, _ := newThreadSvc(t) + svc, history, _, _ := newThreadSvc(t) expectThreadList(history, "site-a", []model.ThreadListItem{item("site-a", "t1", 50), item("site-a", "t2", 40), item("site-a", "t3", 30)}, true) expectThreadList(history, "site-b", nil, false) @@ -81,7 +82,7 @@ func TestUserService_ListUserThreads_PaginatesAndSetsCursor(t *testing.T) { } func TestUserService_ListUserThreads_AllSitesEmpty(t *testing.T) { - svc, history, _ := newThreadSvc(t) + svc, history, _, _ := newThreadSvc(t) expectThreadList(history, "site-a", nil, false) expectThreadList(history, "site-b", nil, false) @@ -93,14 +94,14 @@ func TestUserService_ListUserThreads_AllSitesEmpty(t *testing.T) { } func TestUserService_ListUserThreads_BadCursor(t *testing.T) { - svc, _, _ := newThreadSvc(t) + svc, _, _, _ := newThreadSvc(t) // Decode happens before any fan-out, so no GetThreadList calls are made. _, err := svc.ListUserThreads(ctx("alice", "site-a"), model.ThreadListRequest{Cursor: "!!!not-base64"}) requireCode(t, err, errcode.CodeBadRequest) } func TestUserService_ListUserThreads_PartialFailureDegrades(t *testing.T) { - svc, history, _ := newThreadSvc(t) + svc, history, _, _ := newThreadSvc(t) expectThreadList(history, "site-a", []model.ThreadListItem{item("site-a", "ta1", 50)}, false) history.EXPECT().GetThreadList(gomock.Any(), "site-b", gomock.Any()). Return(model.ThreadSubscriptionListResponse{}, errors.New("site-b down")) @@ -112,7 +113,7 @@ func TestUserService_ListUserThreads_PartialFailureDegrades(t *testing.T) { } func TestUserService_ListUserThreads_Tiebreak(t *testing.T) { - svc, history, _ := newThreadSvc(t) + svc, history, _, _ := newThreadSvc(t) expectThreadList(history, "site-a", []model.ThreadListItem{ item("site-a", "ta", 50), item("site-a", "tc", 50), item("site-a", "tb", 50), }, false) @@ -125,7 +126,7 @@ func TestUserService_ListUserThreads_Tiebreak(t *testing.T) { } func TestUserService_ListUserThreads_PassesCursorToLeaf(t *testing.T) { - svc, history, _ := newThreadSvc(t) + svc, history, _, _ := newThreadSvc(t) var got model.ThreadSubscriptionListRequest history.EXPECT().GetThreadList(gomock.Any(), "site-a", gomock.Any()). DoAndReturn(func(_ context.Context, _ string, req model.ThreadSubscriptionListRequest) (model.ThreadSubscriptionListResponse, error) { @@ -152,7 +153,7 @@ func dmItem(site, threadRoomID string, lastMsgAt int64, counterpart string) mode // On the final page, DM rows are enriched with the counterpart's HR record // (resolved from RoomName); non-DM rows are left untouched. func TestUserService_ListUserThreads_DM_CarriesHRInfo(t *testing.T) { - svc, history, users := newThreadSvc(t) + svc, history, users, _ := newThreadSvc(t) expectThreadList(history, "site-a", []model.ThreadListItem{dmItem("site-a", "td", 50, "bob"), item("site-a", "tc", 40)}, false) expectThreadList(history, "site-b", nil, false) users.EXPECT().GetHRInfoByAccounts(gomock.Any(), []string{"bob"}). @@ -172,7 +173,7 @@ func TestUserService_ListUserThreads_DM_CarriesHRInfo(t *testing.T) { // A failed HR lookup degrades to no hrInfo — it never fails the request. func TestUserService_ListUserThreads_HRInfoDegrades(t *testing.T) { - svc, history, users := newThreadSvc(t) + svc, history, users, _ := newThreadSvc(t) expectThreadList(history, "site-a", []model.ThreadListItem{dmItem("site-a", "td", 50, "bob")}, false) expectThreadList(history, "site-b", nil, false) users.EXPECT().GetHRInfoByAccounts(gomock.Any(), []string{"bob"}).Return(nil, errors.New("hr down")) @@ -183,6 +184,44 @@ func TestUserService_ListUserThreads_HRInfoDegrades(t *testing.T) { assert.Nil(t, resp.Items[0].HRInfo) } +// botDMItem is a botDM thread row whose RoomName carries the bot account. +func botDMItem(site, threadRoomID string, lastMsgAt int64, botAccount string) model.ThreadListItem { + return model.ThreadListItem{SiteID: site, ThreadRoomID: threadRoomID, LastMsgAt: lastMsgAt, RoomType: model.RoomTypeBotDM, RoomName: botAccount} +} + +// On the final page, a botDM row's RoomName (the bot account) is replaced with +// the app's display name; non-botDM rows are left untouched. +func TestUserService_ListUserThreads_BotDM_ReplacesRoomNameWithAppName(t *testing.T) { + svc, history, _, apps := newThreadSvc(t) + channel := model.ThreadListItem{SiteID: "site-a", ThreadRoomID: "tc", LastMsgAt: 40, RoomType: model.RoomTypeChannel, RoomName: "general"} + expectThreadList(history, "site-a", []model.ThreadListItem{botDMItem("site-a", "tb", 50, "helper.bot"), channel}, false) + expectThreadList(history, "site-b", nil, false) + apps.EXPECT().GetAppsByAssistants(gomock.Any(), []string{"helper.bot"}). + Return(map[string]*model.App{"helper.bot": {ID: "app1", Name: "Helper App", Assistant: &model.AppAssistant{Name: "helper.bot"}}}, nil) + + resp, err := svc.ListUserThreads(ctx("alice", "site-a"), model.ThreadListRequest{Limit: 10}) + require.NoError(t, err) + require.Len(t, resp.Items, 2) + + bot := resp.Items[0] + require.Equal(t, "tb", bot.ThreadRoomID) + assert.Equal(t, "Helper App", bot.RoomName, "botDM roomName must be replaced with the app name") + assert.Equal(t, "general", resp.Items[1].RoomName, "channel row roomName must be left untouched") +} + +// A failed/missing app lookup keeps the bot account as roomName — never fails the request. +func TestUserService_ListUserThreads_BotDM_AppLookupDegrades(t *testing.T) { + svc, history, _, apps := newThreadSvc(t) + expectThreadList(history, "site-a", []model.ThreadListItem{botDMItem("site-a", "tb", 50, "helper.bot")}, false) + expectThreadList(history, "site-b", nil, false) + apps.EXPECT().GetAppsByAssistants(gomock.Any(), []string{"helper.bot"}).Return(nil, errors.New("apps down")) + + resp, err := svc.ListUserThreads(ctx("alice", "site-a"), model.ThreadListRequest{Limit: 10}) + require.NoError(t, err, "app lookup failure must degrade, not fail the request") + require.Len(t, resp.Items, 1) + assert.Equal(t, "helper.bot", resp.Items[0].RoomName, "degraded app lookup keeps the bot account") +} + func ids(items []model.ThreadListItem) []string { out := make([]string, len(items)) for i := range items {