From 01ea8fd36b69862a93d64526aab9cfe8a8055f73 Mon Sep 17 00:00:00 2001 From: jjbrunton Date: Fri, 22 May 2026 23:15:27 +0100 Subject: [PATCH] Mine t2 IDs from timeline events and /messages history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reddit's homeserver does not expose /_matrix/client/v3/rooms/{id}/joined_members (it returns 'page not found'), so the previous fallback was always a 404. Two replacements: - roomToActiveChat: include timeline event senders as candidate MXIDs alongside heroes / member-join state. /sync omits heroes for idle rooms but always carries full sender MXIDs in any timeline events it does return, so we recover a t2 ID whenever the delta sees a message. - enrichUsernames: split into three passes — apply known mappings first (so re-created delta chats get upgraded without a restart), then page /messages 'b' once per still-unknown room to mine a t2 from the recipient's history, then resolve any remaining raw t2s via the bulk user_data endpoint. Failed history lookups are cached in unresolvableRooms so we don't retry every sync. Co-Authored-By: Claude Opus 4.7 --- src/services/chat/service.ts | 103 ++++++++++++++++++++++------------- 1 file changed, 65 insertions(+), 38 deletions(-) diff --git a/src/services/chat/service.ts b/src/services/chat/service.ts index adb5084..75501f6 100644 --- a/src/services/chat/service.ts +++ b/src/services/chat/service.ts @@ -834,7 +834,14 @@ export class ChatService { memberJoins.push(stateKey); } } - const candidateMxids = [...new Set([...heroes, ...memberJoins])]; + // Reddit's homeserver routinely omits heroes/member state from /sync + // for chats that have been idle for a while — but the timeline events + // it does return always carry full MXIDs in their `sender` field, so + // mine those too. + const timelineSenders = timeline + .map((e) => e.sender) + .filter((s): s is string => !!s && s !== this.matrix.userId); + const candidateMxids = [...new Set([...heroes, ...memberJoins, ...timelineSenders])]; const otherT2 = candidateMxids .map((h) => h.match(/^@(t2_[^:]+):/)?.[1]) .filter((s): s is string => !!s); @@ -1001,11 +1008,29 @@ export class ChatService { */ async enrichUsernames(): Promise { if (!this.usernameResolver) return; - // First, for any chats whose username is still 'unknown' (heroes/state in - // the delta didn't surface the other member), fetch the room's joined - // members and stamp the t2 ID onto the chat so the resolver below can - // pick it up. - const roomT2: Map = new Map(); // roomId -> bare t2 id + let dirty = false; + + // Pass 1 — upgrade any t2_-prefixed chat whose t2 we *already* know how + // to resolve. Without this, a delta that re-creates a t2-only chat + // wouldn't get upgraded until the bot restarts, because the resolver + // below skips known mappings. + for (const chat of this.cache.get()) { + const t2 = this.extractT2(chat); + if (!t2) continue; + const known = this.t2ToUsername.get(t2); + if (known && known !== chat.username) { + chat.username = known; + if (!chat.roomName || chat.roomName.startsWith('t2_') || chat.roomName === 'unknown') { + chat.roomName = known; + } + dirty = true; + } + } + + // Pass 2 — chats still stuck on 'unknown' (or a bare MXID) didn't get a + // t2 ID from /sync heroes/state/timeline. Try to mine one from /messages: + // a single 'b' page is enough to surface the recipient's MXID on any + // room that has ever exchanged a message. for (const chat of this.cache.get()) { if (!chat.roomId) continue; if (this.unresolvableRooms.has(chat.roomId)) continue; @@ -1015,62 +1040,64 @@ export class ChatService { chat.username.startsWith('@'); if (!needsLookup) continue; try { - const members = await this.withAuth(() => this.matrix.getJoinedMembers(chat.roomId!)); - const otherMxid = Object.keys(members).find((m) => m !== this.matrix.userId); - const t2 = otherMxid?.match(/^@(t2_[^:]+):/)?.[1]; + const res = await this.withAuth(() => + this.matrix.getRoomMessages(chat.roomId!, { limit: 20, dir: 'b' }), + ); + const otherSender = res.chunk?.find( + (e) => e.sender && e.sender !== this.matrix.userId, + )?.sender; + const t2 = otherSender?.match(/^@(t2_[^:]+):/)?.[1]; if (t2) { const bare = t2.replace(/^t2_/, ''); - roomT2.set(chat.roomId, bare); const known = this.t2ToUsername.get(bare); chat.username = known ?? t2; if (!chat.roomName || chat.roomName === 'unknown' || chat.roomName.startsWith('@')) { chat.roomName = chat.username; } + dirty = true; } else { - // No t2-shaped member in the room (probably empty or only us - // after the other party left). Don't keep retrying every sync. + // No other party has ever sent a message here (we sent an opener + // that was never accepted, etc). Don't retry every sync. this.unresolvableRooms.add(chat.roomId); } } catch (err) { - // 404/403 means we can't read membership for this room (left, kicked, - // or never fully joined). Cache the failure so the next delta doesn't - // spam the homeserver — and the log — with the same lookup. this.unresolvableRooms.add(chat.roomId); - this.logger.debug(`[chat] joined_members lookup failed for ${chat.roomId}:`, err); + this.logger.debug(`[chat] history lookup failed for ${chat.roomId}:`, err); } } + // Pass 3 — for every chat still on a raw t2 ID, call the resolver. const needed: string[] = []; for (const chat of this.cache.get()) { const t2 = this.extractT2(chat); if (t2 && !this.t2ToUsername.has(t2)) needed.push(`t2_${t2}`); } - if (needed.length === 0) { - if (roomT2.size > 0) await this.cache.save(); - return; - } - try { - const got = await this.usernameResolver.resolveT2Ids(needed); - let updated = 0; - for (const [t2, name] of Object.entries(got)) { - const bare = t2.replace(/^t2_/, ''); - this.t2ToUsername.set(bare, name); - this.persistUsername(t2, name); - for (const chat of this.cache.get()) { - if (chat.username === `t2_${bare}`) { - chat.username = name; - chat.roomName = chat.roomName?.startsWith('t2_') ? name : chat.roomName; - updated++; + if (needed.length > 0) { + try { + const got = await this.usernameResolver.resolveT2Ids(needed); + let updated = 0; + for (const [t2, name] of Object.entries(got)) { + const bare = t2.replace(/^t2_/, ''); + this.t2ToUsername.set(bare, name); + this.persistUsername(t2, name); + for (const chat of this.cache.get()) { + if (chat.username === `t2_${bare}`) { + chat.username = name; + chat.roomName = chat.roomName?.startsWith('t2_') ? name : chat.roomName; + updated++; + } } } + if (updated > 0) { + this.logger.info(`[chat] enriched ${updated} chats with usernames`); + dirty = true; + } + } catch (err) { + this.logger.debug('[chat] enrichUsernames failed:', err); } - if (updated > 0 || roomT2.size > 0) { - this.logger.info(`[chat] enriched ${updated} chats with usernames`); - await this.cache.save(); - } - } catch (err) { - this.logger.debug('[chat] enrichUsernames failed:', err); } + + if (dirty) await this.cache.save(); } /**