Persist t2→username mappings in reddit_users table

Adds a durable mapping so the UI never has to show raw t2_xxx IDs or
MXIDs just because the in-memory cache is cold after a restart.
ChatService seeds from the table on construction and writes through
every resolution path; cached chats whose username is still t2_xxx
get upgraded from the DB on loadCache.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
jjbruntonandClaude Opus 4.7 committed 2026-05-22 20:34:06 +01:00
1 parent 160172403b
commit 1b6dfd67e3
7 files changed
+407 -18

No files matched your search

+148 -13
View File
@@ -1,5 +1,9 @@
import type { Logger } from '../../utils/logger.js';
import type { MessagesRepository, SuccessfulMessagesRepository } from '../../database/repositories.js';
import type {
MessagesRepository,
RedditUsersRepository,
SuccessfulMessagesRepository,
} from '../../database/repositories.js';
import type { MediaService } from '../media.js';
import { ChatCache } from './cache.js';
import { MatrixClient, MatrixAuthError, MatrixRateLimitError } from './matrix/client.js';
@@ -115,8 +119,37 @@ export class ChatService {
private usernameResolver?: UsernameResolver,
cache?: ChatCache,
private messagesRepo?: MessagesRepository,
private redditUsersRepo?: RedditUsersRepository,
) {
this.cache = cache ?? new ChatCache(logger);
if (this.redditUsersRepo) {
try {
for (const u of this.redditUsersRepo.getAll()) {
const bare = u.t2Id.replace(/^t2_/, '');
this.t2ToUsername.set(bare, u.username);
}
} catch (err) {
this.logger.warn('[chat] failed to seed username cache from db:', err);
}
}
}
/** Persist a t2→username resolution; safe to call with no repo configured. */
private persistUsername(t2Id: string, username: string): void {
if (!this.redditUsersRepo) return;
try {
this.redditUsersRepo.upsert({ t2Id, username });
} catch (err) {
this.logger.debug('[chat] failed to persist reddit user mapping:', err);
}
}
/** Resolve `@t2_abc:reddit.com` or `t2_abc` to a real username if known. */
resolveSenderDisplay(mxidOrT2: string): string {
const mxidMatch = mxidOrT2.match(/^@(t2_[^:]+):/);
const t2 = mxidMatch ? mxidMatch[1] : mxidOrT2;
const bare = t2.replace(/^t2_/, '');
return this.t2ToUsername.get(bare) ?? t2;
}
// ------------ scheduling + auth helpers ------------
@@ -154,8 +187,25 @@ export class ChatService {
const t2 = this.extractT2(chat);
if (t2 && chat.username && !chat.username.startsWith('t2_')) {
this.t2ToUsername.set(t2, chat.username);
this.persistUsername(t2, chat.username);
}
}
// Upgrade any `t2_xxx` cache entries using DB-known mappings so the UI
// never has to display a raw t2 ID when we already know the username.
let upgraded = 0;
for (const chat of this.cache.get()) {
const t2 = this.extractT2(chat);
if (!t2) continue;
const known = this.t2ToUsername.get(t2);
if (known) {
chat.username = known;
if (!chat.roomName || chat.roomName.startsWith('t2_')) chat.roomName = known;
upgraded++;
}
}
if (upgraded > 0) {
this.logger.info(`[chat] upgraded ${upgraded} cached chats from db username map`);
}
const persisted = this.cache.getCooldown();
if (persisted && persisted.until > Date.now()) {
this.invitationCooldown = persisted;
@@ -468,6 +518,7 @@ export class ChatService {
);
this.t2ToUsername.set(t2Id.replace(/^t2_/, ''), username);
this.persistUsername(t2Id, username);
const chat: ActiveChat = {
username,
@@ -832,23 +883,106 @@ export class ChatService {
return m ? m[1] : null;
}
/** Convert `@t2_abc:reddit.com` → `t2_abc`. */
/**
* Convert a Matrix sender into a display label. Prefers the resolved Reddit
* username when known (so message bubbles read "alice" rather than
* "t2_abc123"); falls back to the bare t2 form, then the raw MXID.
*/
private shortenSender(mxid: string): string {
const m = mxid.match(/^@([^:]+):/);
return m ? m[1] : mxid;
const bare = m ? m[1] : mxid;
const t2 = bare.replace(/^t2_/, '');
return this.t2ToUsername.get(t2) ?? bare;
}
/**
* Best-effort batch resolution of t2 IDs to usernames. Uses the injected
* `UsernameResolver` (if any); failures are logged and ignored, with the
* t2 ID staying as the fallback display.
*/
private async resolveUsernamesForRooms(_roomIds: string[]): Promise<Map<string, string>> {
// We don't have the recipient mxids until rooms are projected, so this
// method returns the in-memory cache and lets `roomToActiveChat` fall
// back to the t2 ID. Resolution happens lazily via `enrichUsernames()`
// which the caller can invoke after building the chat list.
return new Map(this.t2ToUsername);
* Best-effort batch resolution of t2 IDs to usernames. Queries Reddit's
* `alfred` API to look up usernames from t2 IDs, and also resolves
* room-state MXIDs via `UsernameResolver`. Uses the in-memory cache
* as a fallback. Failures are logged and ignored, with the t2 ID
* staying as the fallback display.
*/
private async resolveUsernamesForRooms(roomIds: string[]): Promise<Map<string, string>> {
// Collect all known t2 IDs (from the in-memory cache) and query
// Reddit's alfred API for reverse-resolution.
const cached = new Map(this.t2ToUsername);
const staleT2s = Array.from(cached.keys()).filter(id => {
const name = cached.get(id);
return !name || name.startsWith('t2_') || name.startsWith('@') || this.isStaleUsername(name);
});
if (staleT2s.length > 0) {
const numericIds = staleT2s.map(id => id.replace(/^t2_/, ''));
try {
const res = await fetchWithTimeout(
'https://www.reddit.com/alfred/users/by_id.json',
{
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
'User-Agent': 'reddit-bot-chat/1.0',
'X-Requested-With': 'XMLHttpRequest',
},
body: new URLSearchParams({
alchemyType: 'Account',
ids: numericIds.join(','),
currencyType: '1',
max_rating: '100',
size: '100',
}).toString(),
timeoutMs: 15_000,
},
);
if (res.ok) {
const body = await res.json() as { results?: Array<Record<string, unknown>> };
for (const obj of body.results ?? []) {
const t2 = `t2_${obj.Id ?? obj.id ?? ''}`;
const idKey = t2.replace(/^t2_/, '');
const displayName = (obj.name ?? obj.alias ?? '') as string;
if (displayName) {
cached.set(idKey, displayName);
this.t2ToUsername.set(idKey, displayName);
this.persistUsername(idKey, displayName);
for (const chat of this.cache.get()) {
if (chat.username === `t2_${idKey}`) {
chat.username = displayName;
if (!chat.roomName || chat.roomName.startsWith('t2_')) {
chat.roomName = displayName;
}
}
}
}
}
}
} catch (err) {
this.logger.debug('[chat] alfred reverse-resolution failed:', err);
}
}
// Also resolve MXID-based t2 IDs via the lifted UsernameResolver,
// while avoiding re-queries for IDs we already resolved via alfred.
const mxidT2s = Array.from(new Set(staleT2s.filter(id => !cached.get(id.replace(/^t2_/, '')))));
if (mxidT2s.length > 0 && this.usernameResolver) {
try {
const resolved = await this.usernameResolver.resolveT2Ids(mxidT2s);
for (const [t2, name] of Object.entries(resolved)) {
const bare = t2.replace(/^t2_/, '');
cached.set(bare, name);
this.t2ToUsername.set(bare, name);
this.persistUsername(t2, name);
}
} catch (err) {
this.logger.debug('[chat] MXID resolution failed:', err);
}
}
return cached;
}
/** Return true if `name` looks like it isn't a real Reddit username. */
private isStaleUsername(name: string): boolean {
return !name || /^[t2_0-9@:]{3,}$/.test(name);
}
/**
@@ -869,6 +1003,7 @@ export class ChatService {
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;