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:
1 parent
160172403b
commit
1b6dfd67e3
7 files changed
+407
-18
No files matched your search
+148
-13
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user