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
+406 -17

No files matched your search

+6 -3
View File
@@ -7,6 +7,7 @@ import {
MessagesRepository, MessagesRepository,
SuccessfulMessagesRepository, SuccessfulMessagesRepository,
ActivityLogRepository, ActivityLogRepository,
RedditUsersRepository,
} from './database/repositories.js'; } from './database/repositories.js';
import { BrowserClient } from './browser/client.js'; import { BrowserClient } from './browser/client.js';
import { RedditMessagePage } from './browser/message-page.js'; import { RedditMessagePage } from './browser/message-page.js';
@@ -79,6 +80,7 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
const messagesRepo = new MessagesRepository(db); const messagesRepo = new MessagesRepository(db);
const successfulMessagesRepo = new SuccessfulMessagesRepository(db); const successfulMessagesRepo = new SuccessfulMessagesRepository(db);
const activityLogRepo = new ActivityLogRepository(db); const activityLogRepo = new ActivityLogRepository(db);
const redditUsersRepo = new RedditUsersRepository(db);
logger.info('Database initialized'); logger.info('Database initialized');
// Browser // Browser
@@ -208,22 +210,23 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
usernameResolver, usernameResolver,
undefined, undefined,
messagesRepo, messagesRepo,
redditUsersRepo,
); );
const mediaSyncService = new MediaSyncService(chatService, logger, config.media.syncIntervalMs); const mediaSyncService = new MediaSyncService(chatService, logger, config.media.syncIntervalMs);
const rateLimiter = new RateLimiter(config, logger); const rateLimiter = new RateLimiter(config, logger);
await chatService.loadCache(); await chatService.loadCache();
// Lazy enrichment in the background — first sync may show t2 IDs briefly.
chatService.enrichUsernames().catch((err) => logger.debug('enrichUsernames failed:', err));
// Fire-and-forget startup sync: full if we've never backfilled, delta // Fire-and-forget startup sync: full if we've never backfilled, delta
// otherwise. Non-blocking so the rest of the bot can come up immediately; // otherwise. Non-blocking so the rest of the bot can come up immediately;
// the dashboard's sync-status banner shows progress. // the dashboard's sync-status banner shows progress.
chatService chatService
.runStartupSync() .runStartupSync()
.then(({ kind, chats }) => { .then(async ({ kind, chats }) => {
logger.info(`[chat] startup sync (${kind}) complete: ${chats} chats`); logger.info(`[chat] startup sync (${kind}) complete: ${chats} chats`);
// Resolve t2 IDs now that the full chat list is available.
await chatService.enrichUsernames();
}) })
.catch((err) => logger.error('[chat] startup sync failed:', err)); .catch((err) => logger.error('[chat] startup sync failed:', err));
+9
View File
@@ -66,6 +66,15 @@ export function runMigrations(db: Database.Database): void {
image_url TEXT image_url TEXT
); );
CREATE TABLE IF NOT EXISTS reddit_users (
t2_id TEXT PRIMARY KEY,
username TEXT NOT NULL,
mxid TEXT,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_reddit_users_username
ON reddit_users(username);
CREATE INDEX IF NOT EXISTS idx_posts_subreddit CREATE INDEX IF NOT EXISTS idx_posts_subreddit
ON processed_posts(subreddit); ON processed_posts(subreddit);
CREATE INDEX IF NOT EXISTS idx_posts_processed_at CREATE INDEX IF NOT EXISTS idx_posts_processed_at
+80
View File
@@ -566,3 +566,83 @@ export class SuccessfulMessagesRepository {
}; };
} }
} }
export interface RedditUserRecord {
t2Id: string;
username: string;
mxid: string | null;
updatedAt: number;
}
/**
* Durable mapping of Reddit account IDs (`t2_xxx`) to current usernames.
* Populated as the chat sync resolves IDs via Reddit's alfred/user_data
* endpoints; read back on startup so we never have to show raw `t2_xxx`
* or MXIDs in the UI just because the in-memory cache is cold.
*/
export class RedditUsersRepository {
private upsertStmt: Database.Statement;
private getByT2Stmt: Database.Statement;
private getByUsernameStmt: Database.Statement;
private getAllStmt: Database.Statement;
constructor(private db: Database.Database) {
this.upsertStmt = db.prepare(`
INSERT INTO reddit_users (t2_id, username, mxid, updated_at)
VALUES (@t2Id, @username, @mxid, @updatedAt)
ON CONFLICT(t2_id) DO UPDATE SET
username = excluded.username,
mxid = COALESCE(excluded.mxid, reddit_users.mxid),
updated_at = excluded.updated_at
WHERE excluded.username != ''
`);
this.getByT2Stmt = db.prepare(`SELECT * FROM reddit_users WHERE t2_id = ?`);
this.getByUsernameStmt = db.prepare(`SELECT * FROM reddit_users WHERE username = ? COLLATE NOCASE`);
this.getAllStmt = db.prepare(`SELECT * FROM reddit_users`);
}
upsert(record: { t2Id: string; username: string; mxid?: string | null }): void {
if (!record.t2Id || !record.username) return;
if (record.username.startsWith('t2_') || record.username.startsWith('@')) return;
const t2Id = record.t2Id.startsWith('t2_') ? record.t2Id : `t2_${record.t2Id}`;
this.upsertStmt.run({
t2Id,
username: record.username,
mxid: record.mxid ?? null,
updatedAt: Date.now(),
});
}
upsertMany(records: Array<{ t2Id: string; username: string; mxid?: string | null }>): void {
const tx = this.db.transaction((items: typeof records) => {
for (const r of items) this.upsert(r);
});
tx(records);
}
getByT2(t2Id: string): RedditUserRecord | null {
const normalized = t2Id.startsWith('t2_') ? t2Id : `t2_${t2Id}`;
const row = this.getByT2Stmt.get(normalized) as Record<string, unknown> | undefined;
return row ? this.mapRow(row) : null;
}
getByUsername(username: string): RedditUserRecord | null {
const row = this.getByUsernameStmt.get(username) as Record<string, unknown> | undefined;
return row ? this.mapRow(row) : null;
}
/** Bulk load — used to seed the in-memory cache on startup. */
getAll(): RedditUserRecord[] {
const rows = this.getAllStmt.all() as Record<string, unknown>[];
return rows.map((r) => this.mapRow(r));
}
private mapRow(row: Record<string, unknown>): RedditUserRecord {
return {
t2Id: row.t2_id as string,
username: row.username as string,
mxid: (row.mxid as string | null) ?? null,
updatedAt: row.updated_at as number,
};
}
}
+147 -12
View File
@@ -1,5 +1,9 @@
import type { Logger } from '../../utils/logger.js'; 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 type { MediaService } from '../media.js';
import { ChatCache } from './cache.js'; import { ChatCache } from './cache.js';
import { MatrixClient, MatrixAuthError, MatrixRateLimitError } from './matrix/client.js'; import { MatrixClient, MatrixAuthError, MatrixRateLimitError } from './matrix/client.js';
@@ -115,8 +119,37 @@ export class ChatService {
private usernameResolver?: UsernameResolver, private usernameResolver?: UsernameResolver,
cache?: ChatCache, cache?: ChatCache,
private messagesRepo?: MessagesRepository, private messagesRepo?: MessagesRepository,
private redditUsersRepo?: RedditUsersRepository,
) { ) {
this.cache = cache ?? new ChatCache(logger); 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 ------------ // ------------ scheduling + auth helpers ------------
@@ -154,8 +187,25 @@ export class ChatService {
const t2 = this.extractT2(chat); const t2 = this.extractT2(chat);
if (t2 && chat.username && !chat.username.startsWith('t2_')) { if (t2 && chat.username && !chat.username.startsWith('t2_')) {
this.t2ToUsername.set(t2, chat.username); 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(); const persisted = this.cache.getCooldown();
if (persisted && persisted.until > Date.now()) { if (persisted && persisted.until > Date.now()) {
this.invitationCooldown = persisted; this.invitationCooldown = persisted;
@@ -468,6 +518,7 @@ export class ChatService {
); );
this.t2ToUsername.set(t2Id.replace(/^t2_/, ''), username); this.t2ToUsername.set(t2Id.replace(/^t2_/, ''), username);
this.persistUsername(t2Id, username);
const chat: ActiveChat = { const chat: ActiveChat = {
username, username,
@@ -832,23 +883,106 @@ export class ChatService {
return m ? m[1] : null; 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 { private shortenSender(mxid: string): string {
const m = mxid.match(/^@([^:]+):/); 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 * Best-effort batch resolution of t2 IDs to usernames. Queries Reddit's
* `UsernameResolver` (if any); failures are logged and ignored, with the * `alfred` API to look up usernames from t2 IDs, and also resolves
* t2 ID staying as the fallback display. * 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>> { private async resolveUsernamesForRooms(roomIds: string[]): Promise<Map<string, string>> {
// We don't have the recipient mxids until rooms are projected, so this // Collect all known t2 IDs (from the in-memory cache) and query
// method returns the in-memory cache and lets `roomToActiveChat` fall // Reddit's alfred API for reverse-resolution.
// back to the t2 ID. Resolution happens lazily via `enrichUsernames()` const cached = new Map(this.t2ToUsername);
// which the caller can invoke after building the chat list.
return 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)) { for (const [t2, name] of Object.entries(got)) {
const bare = t2.replace(/^t2_/, ''); const bare = t2.replace(/^t2_/, '');
this.t2ToUsername.set(bare, name); this.t2ToUsername.set(bare, name);
this.persistUsername(t2, name);
for (const chat of this.cache.get()) { for (const chat of this.cache.get()) {
if (chat.username === `t2_${bare}`) { if (chat.username === `t2_${bare}`) {
chat.username = name; chat.username = name;
@@ -0,0 +1,92 @@
import { describe, it, expect } from 'vitest';
import os from 'os';
import path from 'path';
import Database from 'better-sqlite3';
import { runMigrations } from '../src/database/client.js';
import { RedditUsersRepository } from '../src/database/repositories.js';
import { ChatService } from '../src/services/chat/service.js';
import { ChatCache } from '../src/services/chat/cache.js';
import { MatrixClient } from '../src/services/chat/matrix/client.js';
import { createMockLogger } from './mockLogger.js';
const creds = {
homeserver: 'https://matrix.example.test',
accessToken: 'tok',
userId: '@me:example.test',
capturedAt: Date.now(),
};
function buildMatrix() {
const logger = createMockLogger();
const fetchImpl = (() =>
Promise.resolve(
new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } }),
)) as typeof fetch;
return new MatrixClient(creds, logger, { minRequestIntervalMs: 0, fetchImpl });
}
describe('ChatService username persistence', () => {
it('seeds the in-memory cache from RedditUsersRepository on construction', () => {
const db = new Database(':memory:');
runMigrations(db);
const repo = new RedditUsersRepository(db);
repo.upsert({ t2Id: 't2_abc', username: 'alice' });
const logger = createMockLogger();
const svc = new ChatService(
buildMatrix(),
logger,
undefined, undefined, undefined, undefined, undefined, undefined,
repo,
);
expect(svc.resolveSenderDisplay('@t2_abc:reddit.com')).toBe('alice');
expect(svc.resolveSenderDisplay('t2_abc')).toBe('alice');
db.close();
});
it('falls back to t2 form when no mapping is known', () => {
const logger = createMockLogger();
const svc = new ChatService(buildMatrix(), logger);
expect(svc.resolveSenderDisplay('@t2_unknown:reddit.com')).toBe('t2_unknown');
});
it('upgrades cached chats with t2_ usernames from the db map on loadCache', async () => {
const db = new Database(':memory:');
runMigrations(db);
const repo = new RedditUsersRepository(db);
repo.upsert({ t2Id: 't2_abc', username: 'alice' });
const logger = createMockLogger();
const tmpFile = path.join(os.tmpdir(), `chat-cache-${Date.now()}-${Math.random()}.json`);
const cache = new ChatCache(logger, tmpFile);
cache.set([
{
username: 't2_abc',
roomName: 't2_abc',
lastMessageTime: Date.now(),
lastMessageSnippet: 'hi',
isFromUs: false,
unread: false,
url: 'https://chat.reddit.com/room/x',
roomId: '!room:reddit.com',
messages: [],
},
]);
const svc = new ChatService(
buildMatrix(),
logger,
undefined, undefined, undefined, undefined,
cache,
undefined,
repo,
);
await svc.loadCache();
const chats = await svc.getCachedConversations();
expect(chats[0].username).toBe('alice');
expect(chats[0].roomName).toBe('alice');
db.close();
});
});
+2 -2
View File
@@ -103,8 +103,8 @@ describe('Database Client', () => {
const tables = db const tables = db
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'") .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
.all() as { name: string }[]; .all() as { name: string }[];
// processed_posts, sent_messages, successful_messages, activity_log // processed_posts, sent_messages, successful_messages, activity_log, reddit_users
expect(tables.length).toBe(4); expect(tables.length).toBe(5);
}); });
it('processed_posts has correct schema', () => { it('processed_posts has correct schema', () => {
+70
View File
@@ -0,0 +1,70 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import Database from 'better-sqlite3';
import { runMigrations } from '../src/database/client.js';
import { RedditUsersRepository } from '../src/database/repositories.js';
describe('RedditUsersRepository', () => {
let db: Database.Database;
let repo: RedditUsersRepository;
beforeEach(() => {
db = new Database(':memory:');
db.pragma('foreign_keys = ON');
runMigrations(db);
repo = new RedditUsersRepository(db);
});
afterEach(() => {
db.close();
});
it('upserts a new user and reads it back', () => {
repo.upsert({ t2Id: 't2_abc', username: 'alice' });
const got = repo.getByT2('t2_abc');
expect(got?.username).toBe('alice');
expect(got?.t2Id).toBe('t2_abc');
});
it('normalizes a bare t2 id when looking up or writing', () => {
repo.upsert({ t2Id: 'abc', username: 'alice' });
expect(repo.getByT2('abc')?.username).toBe('alice');
expect(repo.getByT2('t2_abc')?.username).toBe('alice');
});
it('updates the username when the same t2 is upserted twice', () => {
repo.upsert({ t2Id: 't2_abc', username: 'old_name' });
repo.upsert({ t2Id: 't2_abc', username: 'new_name' });
expect(repo.getByT2('t2_abc')?.username).toBe('new_name');
});
it('refuses to store t2_-prefixed pseudo-usernames', () => {
repo.upsert({ t2Id: 't2_abc', username: 't2_abc' });
expect(repo.getByT2('t2_abc')).toBeNull();
});
it('refuses to store MXID-shaped pseudo-usernames', () => {
repo.upsert({ t2Id: 't2_abc', username: '@t2_abc:reddit.com' });
expect(repo.getByT2('t2_abc')).toBeNull();
});
it('looks up by username case-insensitively', () => {
repo.upsert({ t2Id: 't2_abc', username: 'Alice' });
expect(repo.getByUsername('alice')?.t2Id).toBe('t2_abc');
expect(repo.getByUsername('ALICE')?.t2Id).toBe('t2_abc');
});
it('preserves an existing mxid when a later upsert passes null', () => {
repo.upsert({ t2Id: 't2_abc', username: 'alice', mxid: '@t2_abc:reddit.com' });
repo.upsert({ t2Id: 't2_abc', username: 'alice' });
expect(repo.getByT2('t2_abc')?.mxid).toBe('@t2_abc:reddit.com');
});
it('bulk-inserts via upsertMany', () => {
repo.upsertMany([
{ t2Id: 't2_a', username: 'alice' },
{ t2Id: 't2_b', username: 'bob' },
]);
const all = repo.getAll();
expect(all.map((u) => u.username).sort()).toEqual(['alice', 'bob']);
});
});