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:
1 parent
1df7b680ee
commit
01ea8fd36b
1 file changed
+65
-38
@@ -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<void> {
|
||||
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<string, string> = 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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user