Mine t2 IDs from timeline events and /messages history

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 <noreply@anthropic.com>
This commit is contained in:
jjbruntonandClaude Opus 4.7 committed 2026-05-22 23:15:27 +01:00
1 parent 1df7b680ee
commit 01ea8fd36b
1 file changed
+65 -38
+65 -38
View File
@@ -834,7 +834,14 @@ export class ChatService {
memberJoins.push(stateKey); 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 const otherT2 = candidateMxids
.map((h) => h.match(/^@(t2_[^:]+):/)?.[1]) .map((h) => h.match(/^@(t2_[^:]+):/)?.[1])
.filter((s): s is string => !!s); .filter((s): s is string => !!s);
@@ -1001,11 +1008,29 @@ export class ChatService {
*/ */
async enrichUsernames(): Promise<void> { async enrichUsernames(): Promise<void> {
if (!this.usernameResolver) return; if (!this.usernameResolver) return;
// First, for any chats whose username is still 'unknown' (heroes/state in let dirty = false;
// 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 // Pass 1 — upgrade any t2_-prefixed chat whose t2 we *already* know how
// pick it up. // to resolve. Without this, a delta that re-creates a t2-only chat
const roomT2: Map<string, string> = new Map(); // roomId -> bare t2 id // 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()) { for (const chat of this.cache.get()) {
if (!chat.roomId) continue; if (!chat.roomId) continue;
if (this.unresolvableRooms.has(chat.roomId)) continue; if (this.unresolvableRooms.has(chat.roomId)) continue;
@@ -1015,62 +1040,64 @@ export class ChatService {
chat.username.startsWith('@'); chat.username.startsWith('@');
if (!needsLookup) continue; if (!needsLookup) continue;
try { try {
const members = await this.withAuth(() => this.matrix.getJoinedMembers(chat.roomId!)); const res = await this.withAuth(() =>
const otherMxid = Object.keys(members).find((m) => m !== this.matrix.userId); this.matrix.getRoomMessages(chat.roomId!, { limit: 20, dir: 'b' }),
const t2 = otherMxid?.match(/^@(t2_[^:]+):/)?.[1]; );
const otherSender = res.chunk?.find(
(e) => e.sender && e.sender !== this.matrix.userId,
)?.sender;
const t2 = otherSender?.match(/^@(t2_[^:]+):/)?.[1];
if (t2) { if (t2) {
const bare = t2.replace(/^t2_/, ''); const bare = t2.replace(/^t2_/, '');
roomT2.set(chat.roomId, bare);
const known = this.t2ToUsername.get(bare); const known = this.t2ToUsername.get(bare);
chat.username = known ?? t2; chat.username = known ?? t2;
if (!chat.roomName || chat.roomName === 'unknown' || chat.roomName.startsWith('@')) { if (!chat.roomName || chat.roomName === 'unknown' || chat.roomName.startsWith('@')) {
chat.roomName = chat.username; chat.roomName = chat.username;
} }
dirty = true;
} else { } else {
// No t2-shaped member in the room (probably empty or only us // No other party has ever sent a message here (we sent an opener
// after the other party left). Don't keep retrying every sync. // that was never accepted, etc). Don't retry every sync.
this.unresolvableRooms.add(chat.roomId); this.unresolvableRooms.add(chat.roomId);
} }
} catch (err) { } 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.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[] = []; const needed: string[] = [];
for (const chat of this.cache.get()) { for (const chat of this.cache.get()) {
const t2 = this.extractT2(chat); const t2 = this.extractT2(chat);
if (t2 && !this.t2ToUsername.has(t2)) needed.push(`t2_${t2}`); if (t2 && !this.t2ToUsername.has(t2)) needed.push(`t2_${t2}`);
} }
if (needed.length === 0) { if (needed.length > 0) {
if (roomT2.size > 0) await this.cache.save(); try {
return; const got = await this.usernameResolver.resolveT2Ids(needed);
} let updated = 0;
try { for (const [t2, name] of Object.entries(got)) {
const got = await this.usernameResolver.resolveT2Ids(needed); const bare = t2.replace(/^t2_/, '');
let updated = 0; this.t2ToUsername.set(bare, name);
for (const [t2, name] of Object.entries(got)) { this.persistUsername(t2, name);
const bare = t2.replace(/^t2_/, ''); for (const chat of this.cache.get()) {
this.t2ToUsername.set(bare, name); if (chat.username === `t2_${bare}`) {
this.persistUsername(t2, name); chat.username = name;
for (const chat of this.cache.get()) { chat.roomName = chat.roomName?.startsWith('t2_') ? name : chat.roomName;
if (chat.username === `t2_${bare}`) { updated++;
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();
} }
/** /**