diff --git a/src/bootstrap.ts b/src/bootstrap.ts index 17afb16..c486055 100644 --- a/src/bootstrap.ts +++ b/src/bootstrap.ts @@ -7,6 +7,7 @@ import { MessagesRepository, SuccessfulMessagesRepository, ActivityLogRepository, + RedditUsersRepository, } from './database/repositories.js'; import { BrowserClient } from './browser/client.js'; import { RedditMessagePage } from './browser/message-page.js'; @@ -79,6 +80,7 @@ export async function bootstrap(config: Config, logger: Logger): Promise logger.debug('enrichUsernames failed:', err)); // 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; // the dashboard's sync-status banner shows progress. chatService .runStartupSync() - .then(({ kind, chats }) => { + .then(async ({ kind, 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)); diff --git a/src/database/client.ts b/src/database/client.ts index 8cda760..d309c1f 100644 --- a/src/database/client.ts +++ b/src/database/client.ts @@ -66,6 +66,15 @@ export function runMigrations(db: Database.Database): void { 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 ON processed_posts(subreddit); CREATE INDEX IF NOT EXISTS idx_posts_processed_at diff --git a/src/database/repositories.ts b/src/database/repositories.ts index b950870..1ce173c 100644 --- a/src/database/repositories.ts +++ b/src/database/repositories.ts @@ -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 | undefined; + return row ? this.mapRow(row) : null; + } + + getByUsername(username: string): RedditUserRecord | null { + const row = this.getByUsernameStmt.get(username) as Record | 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[]; + return rows.map((r) => this.mapRow(r)); + } + + private mapRow(row: Record): 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, + }; + } +} diff --git a/src/services/chat/service.ts b/src/services/chat/service.ts index 7affe26..3a9ba66 100644 --- a/src/services/chat/service.ts +++ b/src/services/chat/service.ts @@ -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> { - // 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> { + // 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> }; + 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; diff --git a/tests/chat-service-username-persist.test.ts b/tests/chat-service-username-persist.test.ts new file mode 100644 index 0000000..2bca8b8 --- /dev/null +++ b/tests/chat-service-username-persist.test.ts @@ -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(); + }); +}); diff --git a/tests/database.test.ts b/tests/database.test.ts index 2ba735e..8efa430 100644 --- a/tests/database.test.ts +++ b/tests/database.test.ts @@ -103,8 +103,8 @@ describe('Database Client', () => { const tables = db .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'") .all() as { name: string }[]; - // processed_posts, sent_messages, successful_messages, activity_log - expect(tables.length).toBe(4); + // processed_posts, sent_messages, successful_messages, activity_log, reddit_users + expect(tables.length).toBe(5); }); it('processed_posts has correct schema', () => { diff --git a/tests/reddit-users-repo.test.ts b/tests/reddit-users-repo.test.ts new file mode 100644 index 0000000..28efc04 --- /dev/null +++ b/tests/reddit-users-repo.test.ts @@ -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']); + }); +});