Restructure chat service, web routes, and add test suite
Split monolithic chat service into modular structure with Matrix client support, extract web server into per-resource route modules, introduce bootstrap/main-loop separation, and add comprehensive vitest coverage across services, repositories, and utilities. Adds project docs and CLAUDE.md agent instructions.
This commit is contained in:
1 parent
dfa0580859
commit
633bb0ec04
84 files changed
+19278
-3888
No files matched your search
@@ -0,0 +1,856 @@
|
||||
import type { Logger } from '../../utils/logger.js';
|
||||
import type { SuccessfulMessagesRepository } from '../../database/repositories.js';
|
||||
import type { MediaService } from '../media.js';
|
||||
import { ChatCache } from './cache.js';
|
||||
import { MatrixClient, MatrixAuthError, MatrixRateLimitError } from './matrix/client.js';
|
||||
import type { MatrixJoinedRoom, MatrixTimelineEvent } from './matrix/types.js';
|
||||
import type { ActiveChat, ChatMessage, ScrapedChatMessage } from './types.js';
|
||||
import { fetchWithTimeout } from '../../utils/fetch-with-timeout.js';
|
||||
|
||||
const ACTIVE_WINDOW_MS = 96 * 60 * 60 * 1000;
|
||||
const ROOM_URL_PREFIX = 'https://chat.reddit.com/room/';
|
||||
|
||||
export interface TokenRefresher {
|
||||
/**
|
||||
* Re-extract a fresh Matrix bearer token (e.g. by re-running the browser
|
||||
* sniff against the persistent session) and apply it to the underlying
|
||||
* client. Resolves when the client is ready to be retried.
|
||||
*/
|
||||
refresh(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional Reddit-API hook for resolving t2 IDs to real usernames. Without
|
||||
* it, conversations show up as `t2_abc123` (stable but ugly). With it, the
|
||||
* dashboard sees real names.
|
||||
*/
|
||||
export interface UsernameResolver {
|
||||
/** Map t2 IDs (with or without `t2_` prefix) to Reddit usernames. */
|
||||
resolveT2Ids(t2Ids: string[]): Promise<Record<string, string>>;
|
||||
}
|
||||
|
||||
interface BuildChatOpts {
|
||||
knownUsernames: Map<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Public chat API. Internally a thin wrapper around `MatrixClient`:
|
||||
*
|
||||
* - `/sync` (delta cursor) drives `getActiveChats` / `refreshTopChats` —
|
||||
* cheap, just-changed rooms only.
|
||||
* - `/joined_rooms` + per-room `/messages` paginate every historical
|
||||
* conversation when `fetchAllMessages` is called.
|
||||
* - Sends use `PUT .../send/m.room.message/<txn>` directly — no UI.
|
||||
*
|
||||
* The public surface (`getActiveChats`, `sendChatMessage`, etc.) is
|
||||
* preserved from the old Playwright implementation so callers (web routes,
|
||||
* MediaSyncService) don't change.
|
||||
*/
|
||||
export type SyncPhase =
|
||||
| 'starting'
|
||||
| 'seeding'
|
||||
| 'listing-rooms'
|
||||
| 'resolving-usernames'
|
||||
| 'loading-details'
|
||||
| 'finalizing'
|
||||
| 'done';
|
||||
|
||||
export interface SyncProgress {
|
||||
kind: 'full' | 'delta';
|
||||
phase: SyncPhase;
|
||||
/** Number of items completed in the current phase (e.g. rooms processed). */
|
||||
current: number;
|
||||
/** Total items in the current phase, if known. */
|
||||
total: number;
|
||||
/** Optional label for the in-flight item (e.g. current room name). */
|
||||
label?: string;
|
||||
/** Wall-clock ms when the sync started — for elapsed-time display. */
|
||||
startedAt: number;
|
||||
}
|
||||
|
||||
export interface SyncStatus {
|
||||
/** True if a sync is currently running. */
|
||||
syncing: boolean;
|
||||
/** Kind of sync currently running, if any. */
|
||||
syncingKind: 'full' | 'delta' | null;
|
||||
/** Wall-clock ms of the last successful full backfill, or null. */
|
||||
lastFullSyncAt: number | null;
|
||||
/** Wall-clock ms of the last delta sync, or null. */
|
||||
lastDeltaSyncAt: number | null;
|
||||
/** Has the bot ever completed a full sync on this machine? */
|
||||
hasEverFullSynced: boolean;
|
||||
/** Number of chats currently cached. */
|
||||
cachedChats: number;
|
||||
/** Live progress for the in-flight sync, if any. */
|
||||
progress: SyncProgress | null;
|
||||
}
|
||||
|
||||
/** `null` signals "sync ended" (clear progress UI). */
|
||||
export type SyncProgressListener = (progress: SyncProgress | null) => void;
|
||||
|
||||
/** Fired when a chat we sent into first receives a reply from the other party. */
|
||||
export type NewReplyListener = (info: {
|
||||
username: string;
|
||||
replySnippet: string;
|
||||
}) => void;
|
||||
|
||||
/**
|
||||
* Set when Reddit's homeserver tells us to stop creating new DMs for a while
|
||||
* (e.g. the "Wow, you've created a lot of chats. Let's take a break." soft
|
||||
* throttle, errcode `rate.invitation_limit_mature_account`).
|
||||
*/
|
||||
export interface InvitationCooldown {
|
||||
/** Wall-clock ms when sends may resume. */
|
||||
until: number;
|
||||
/** Reddit-side error code, e.g. `rate.invitation_limit_mature_account`. */
|
||||
redditCode: string | null;
|
||||
/** Human-readable explanation suitable for the dashboard. */
|
||||
reason: string;
|
||||
/** Wall-clock ms when the cooldown was set. */
|
||||
setAt: number;
|
||||
}
|
||||
|
||||
export class ChatService {
|
||||
private cache: ChatCache;
|
||||
private queue: Promise<unknown> = Promise.resolve();
|
||||
private readonly t2ToUsername = new Map<string, string>();
|
||||
private syncingKind: 'full' | 'delta' | null = null;
|
||||
private progress: SyncProgress | null = null;
|
||||
private progressListener: SyncProgressListener | null = null;
|
||||
private newReplyListener: NewReplyListener | null = null;
|
||||
private invitationCooldown: InvitationCooldown | null = null;
|
||||
|
||||
constructor(
|
||||
private matrix: MatrixClient,
|
||||
private logger: Logger,
|
||||
private successfulMessagesRepo?: SuccessfulMessagesRepository,
|
||||
private mediaService?: MediaService,
|
||||
private tokenRefresher?: TokenRefresher,
|
||||
private usernameResolver?: UsernameResolver,
|
||||
) {
|
||||
this.cache = new ChatCache(logger);
|
||||
}
|
||||
|
||||
// ------------ scheduling + auth helpers ------------
|
||||
|
||||
/** Serialize work so concurrent callers don't clobber cache state. */
|
||||
private enqueue<T>(task: () => Promise<T>): Promise<T> {
|
||||
const result = this.queue.then(() => task());
|
||||
this.queue = result.catch(() => undefined);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a Matrix call; if it 401/403s, re-extract the bearer via the
|
||||
* configured `TokenRefresher` and retry once. Single retry only — if the
|
||||
* refresh itself fails, we propagate.
|
||||
*/
|
||||
private async withAuth<T>(fn: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
if (err instanceof MatrixAuthError && this.tokenRefresher) {
|
||||
this.logger.warn('[chat] Matrix token rejected — refreshing via browser session');
|
||||
await this.tokenRefresher.refresh();
|
||||
return await fn();
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------ public API ------------
|
||||
|
||||
async loadCache(): Promise<void> {
|
||||
await this.cache.load();
|
||||
for (const chat of this.cache.get()) {
|
||||
const t2 = this.extractT2(chat);
|
||||
if (t2 && chat.username && !chat.username.startsWith('t2_')) {
|
||||
this.t2ToUsername.set(t2, chat.username);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async saveCacheExternal(): Promise<void> {
|
||||
await this.cache.save();
|
||||
}
|
||||
|
||||
async getCachedConversations(): Promise<ActiveChat[]> {
|
||||
if (this.cache.get().length > 0) return this.cache.get();
|
||||
await this.cache.load();
|
||||
return this.cache.get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns chats with activity in the last 96h. Uses the persisted
|
||||
* `next_batch` cursor — first call is essentially the initial sync,
|
||||
* subsequent calls are delta-only.
|
||||
*/
|
||||
async getActiveChats(): Promise<ActiveChat[]> {
|
||||
return this.enqueue(async () => {
|
||||
if (this.cache.isFresh()) return this.cache.get();
|
||||
this.beginSync('delta');
|
||||
try {
|
||||
this.updateProgress({ phase: 'starting', current: 0, total: 0 });
|
||||
const merged = await this.syncOnce();
|
||||
const cutoff = Date.now() - ACTIVE_WINDOW_MS;
|
||||
const active = merged.filter((c) => c.lastMessageTime >= cutoff);
|
||||
this.cache.set(active);
|
||||
await this.cache.save(merged);
|
||||
this.logger.info(`[chat] sync → ${merged.length} known rooms, ${active.length} active`);
|
||||
this.updateProgress({ phase: 'done', current: merged.length, total: merged.length });
|
||||
return active;
|
||||
} finally {
|
||||
this.endSync();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Incremental refresh — calls /sync once and applies the deltas to the
|
||||
* cache. The `limit` arg is preserved for API compatibility but Matrix
|
||||
* already returns only rooms with new events, so it's a no-op.
|
||||
*/
|
||||
async refreshTopChats(_limit = 5): Promise<void> {
|
||||
await this.enqueue(async () => {
|
||||
this.beginSync('delta');
|
||||
try {
|
||||
this.updateProgress({ phase: 'starting', current: 0, total: 0 });
|
||||
await this.syncOnce();
|
||||
this.logger.info('[chat] incremental sync complete');
|
||||
this.updateProgress({
|
||||
phase: 'done',
|
||||
current: this.cache.get().length,
|
||||
total: this.cache.get().length,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error('[chat] refreshTopChats failed:', error);
|
||||
} finally {
|
||||
this.endSync();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Full backfill — walks every room via /joined_rooms and paginates each
|
||||
* one's full history. Slow but rare; intended for "rebuild the cache from
|
||||
* scratch" or initial provisioning.
|
||||
*/
|
||||
async fetchAllMessages(): Promise<ActiveChat[]> {
|
||||
return this.enqueue(async () => {
|
||||
this.beginSync('full');
|
||||
try {
|
||||
this.logger.info('[chat] full backfill starting (via /joined_rooms)...');
|
||||
const t0 = Date.now();
|
||||
|
||||
this.updateProgress({ phase: 'seeding', current: 0, total: 0 });
|
||||
// Seed display-name hints from one /sync (heroes / m.room.name).
|
||||
const seed = await this.withAuth(() => this.matrix.sync({ timeoutMs: 0 }));
|
||||
this.cache.setNextBatch(seed.next_batch);
|
||||
const seedJoined = seed.rooms?.join ?? {};
|
||||
|
||||
this.updateProgress({ phase: 'listing-rooms', current: 0, total: 0 });
|
||||
const joinedIds = await this.withAuth(() => this.matrix.getJoinedRooms());
|
||||
this.logger.info(`[chat] backfill: ${joinedIds.length} joined rooms`);
|
||||
|
||||
this.updateProgress({
|
||||
phase: 'resolving-usernames',
|
||||
current: 0,
|
||||
total: joinedIds.length,
|
||||
});
|
||||
const knownUsernames = await this.resolveUsernamesForRooms(joinedIds);
|
||||
const chats: ActiveChat[] = [];
|
||||
|
||||
for (const roomId of joinedIds) {
|
||||
try {
|
||||
const room = seedJoined[roomId];
|
||||
const chat = this.roomToActiveChat(roomId, room, { knownUsernames });
|
||||
chats.push(chat);
|
||||
} catch (err) {
|
||||
this.logger.warn(`[chat] backfill: failed to seed ${roomId}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
let done = 0;
|
||||
for (const chat of chats) {
|
||||
this.updateProgress({
|
||||
phase: 'loading-details',
|
||||
current: done,
|
||||
total: chats.length,
|
||||
label: chat.roomName || chat.username,
|
||||
});
|
||||
try {
|
||||
await this.loadDetails(chat);
|
||||
} catch (err) {
|
||||
this.logger.error(`[chat] backfill: failed details for ${chat.roomName}`, err);
|
||||
}
|
||||
done++;
|
||||
}
|
||||
|
||||
this.updateProgress({
|
||||
phase: 'finalizing',
|
||||
current: chats.length,
|
||||
total: chats.length,
|
||||
});
|
||||
chats.sort((a, b) => b.lastMessageTime - a.lastMessageTime);
|
||||
this.cache.set(chats);
|
||||
this.cache.markFullSync();
|
||||
await this.cache.save(chats);
|
||||
|
||||
this.logger.info(
|
||||
`[chat] full backfill done in ${Math.round((Date.now() - t0) / 1000)}s — ${chats.length} chats`,
|
||||
);
|
||||
this.updateProgress({ phase: 'done', current: chats.length, total: chats.length });
|
||||
return chats;
|
||||
} catch (error) {
|
||||
this.logger.error('[chat] fetchAllMessages failed:', error);
|
||||
return this.cache.get();
|
||||
} finally {
|
||||
this.endSync();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether to run a full backfill or just a delta sync based on
|
||||
* whether the bot has ever completed a full sync on this machine.
|
||||
* Called once on bootstrap.
|
||||
*/
|
||||
async runStartupSync(): Promise<{ kind: 'full' | 'delta'; chats: number }> {
|
||||
const { lastFullSyncAt } = this.cache.getMarkers();
|
||||
if (!lastFullSyncAt || this.cache.get().length === 0) {
|
||||
this.logger.info('[chat] startup: no prior full sync recorded → running full backfill');
|
||||
const chats = await this.fetchAllMessages();
|
||||
return { kind: 'full', chats: chats.length };
|
||||
}
|
||||
this.logger.info(
|
||||
`[chat] startup: full sync was ${new Date(lastFullSyncAt).toISOString()} → running delta`,
|
||||
);
|
||||
const chats = await this.getActiveChats();
|
||||
return { kind: 'delta', chats: chats.length };
|
||||
}
|
||||
|
||||
/** Lightweight status for the dashboard's sync controls. */
|
||||
getSyncStatus(): SyncStatus {
|
||||
const markers = this.cache.getMarkers();
|
||||
return {
|
||||
syncing: this.syncingKind !== null,
|
||||
syncingKind: this.syncingKind,
|
||||
lastFullSyncAt: markers.lastFullSyncAt,
|
||||
lastDeltaSyncAt: markers.lastDeltaSyncAt,
|
||||
hasEverFullSynced: !!markers.lastFullSyncAt,
|
||||
cachedChats: this.cache.get().length,
|
||||
progress: this.progress,
|
||||
};
|
||||
}
|
||||
|
||||
/** Subscribe to live sync-progress updates. Single listener; replaces any prior one. */
|
||||
setProgressListener(listener: SyncProgressListener | null): void {
|
||||
this.progressListener = listener;
|
||||
}
|
||||
|
||||
/** Subscribe to new-reply notifications (fires once per chat the first time it gets a reply). */
|
||||
setNewReplyListener(listener: NewReplyListener | null): void {
|
||||
this.newReplyListener = listener;
|
||||
}
|
||||
|
||||
private beginSync(kind: 'full' | 'delta'): void {
|
||||
this.syncingKind = kind;
|
||||
this.progress = {
|
||||
kind,
|
||||
phase: 'starting',
|
||||
current: 0,
|
||||
total: 0,
|
||||
startedAt: Date.now(),
|
||||
};
|
||||
this.emitProgress();
|
||||
}
|
||||
|
||||
private updateProgress(patch: Partial<Omit<SyncProgress, 'kind' | 'startedAt'>>): void {
|
||||
if (!this.progress) return;
|
||||
this.progress = { ...this.progress, ...patch };
|
||||
this.emitProgress();
|
||||
}
|
||||
|
||||
private endSync(): void {
|
||||
this.syncingKind = null;
|
||||
this.progress = null;
|
||||
if (this.progressListener) {
|
||||
try {
|
||||
this.progressListener(null);
|
||||
} catch (err) {
|
||||
this.logger.debug('progress listener error:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private emitProgress(): void {
|
||||
if (!this.progress || !this.progressListener) return;
|
||||
try {
|
||||
this.progressListener(this.progress);
|
||||
} catch (err) {
|
||||
this.logger.debug('progress listener error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load full message history (+ media) for a single chat. Mutates
|
||||
* `chat.messages`.
|
||||
*/
|
||||
async loadConversationDetails(chat: ActiveChat): Promise<void> {
|
||||
return this.loadDetails(chat);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a reply into an existing room. Accepts either a full
|
||||
* `https://chat.reddit.com/room/<roomId>` URL (web UI compatibility) or
|
||||
* a raw Matrix room ID like `!abc:reddit.com`.
|
||||
*/
|
||||
async sendChatMessage(chatUrlOrRoomId: string, message: string): Promise<boolean> {
|
||||
const active = this.getInvitationCooldown();
|
||||
if (active) {
|
||||
const secondsLeft = Math.ceil((active.until - Date.now()) / 1000);
|
||||
this.logger.warn(
|
||||
`[chat] skipping sendChatMessage — send cooldown active for ${secondsLeft}s (${active.redditCode ?? 'rate-limited'})`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return this.enqueue(async () => {
|
||||
try {
|
||||
const roomId = ChatService.extractRoomId(chatUrlOrRoomId);
|
||||
if (!roomId) {
|
||||
this.logger.error(`[chat] cannot extract room ID from "${chatUrlOrRoomId}"`);
|
||||
return false;
|
||||
}
|
||||
const eventId = await this.withAuth(() => this.matrix.sendTextMessage(roomId, message));
|
||||
this.logger.info(`[chat] sent message to ${roomId} (event_id=${eventId})`);
|
||||
const chat = this.cache.get().find((c) => c.roomId === roomId);
|
||||
if (chat) {
|
||||
chat.lastMessageSnippet = message.slice(0, 200);
|
||||
chat.lastMessageTime = Date.now();
|
||||
chat.isFromUs = true;
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof MatrixRateLimitError) {
|
||||
this.setInvitationCooldown(error);
|
||||
return false;
|
||||
}
|
||||
this.logger.error('[chat] sendChatMessage failed:', error);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a brand-new DM with the given Reddit user and send the opening
|
||||
* line. Returns the new chat (or null on failure).
|
||||
*/
|
||||
async startConversation(username: string, message: string): Promise<ActiveChat | null> {
|
||||
const active = this.getInvitationCooldown();
|
||||
if (active) {
|
||||
const secondsLeft = Math.ceil((active.until - Date.now()) / 1000);
|
||||
this.logger.warn(
|
||||
`[chat] skipping startConversation(${username}) — invitation cooldown active for ${secondsLeft}s (${active.redditCode ?? 'rate-limited'})`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return this.enqueue(async () => {
|
||||
try {
|
||||
const t2Id = await this.resolveUsernameToT2(username);
|
||||
if (!t2Id) {
|
||||
this.logger.error(`[chat] cannot start conversation: unknown user "${username}"`);
|
||||
return null;
|
||||
}
|
||||
const mxid = MatrixClient.t2ToMxid(t2Id);
|
||||
const { roomId, eventId } = await this.withAuth(() =>
|
||||
this.matrix.sendInitialDirectMessage(mxid, message),
|
||||
);
|
||||
this.logger.info(
|
||||
`[chat] started conversation with ${username} (${t2Id}) → room=${roomId} event=${eventId}`,
|
||||
);
|
||||
|
||||
this.t2ToUsername.set(t2Id.replace(/^t2_/, ''), username);
|
||||
|
||||
const chat: ActiveChat = {
|
||||
username,
|
||||
roomName: username,
|
||||
lastMessageTime: Date.now(),
|
||||
lastMessageSnippet: message.slice(0, 200),
|
||||
isFromUs: true,
|
||||
unread: false,
|
||||
url: ROOM_URL_PREFIX + encodeURIComponent(roomId),
|
||||
roomId,
|
||||
messages: [
|
||||
{ sender: 'Me', text: message, timestamp: Date.now(), isSelf: true },
|
||||
],
|
||||
originMessage: message,
|
||||
};
|
||||
|
||||
const existing = this.cache.get();
|
||||
const merged = [chat, ...existing.filter((c) => c.roomId !== roomId)];
|
||||
this.cache.set(merged);
|
||||
await this.cache.save(merged);
|
||||
return chat;
|
||||
} catch (error) {
|
||||
if (error instanceof MatrixRateLimitError) {
|
||||
this.setInvitationCooldown(error);
|
||||
return null;
|
||||
}
|
||||
this.logger.error('[chat] startConversation failed:', error);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Matrix sends are authoritative — the event_id in the PUT response
|
||||
* confirms delivery. Kept on the public API for compatibility with the
|
||||
* old Playwright sender; always returns true.
|
||||
*/
|
||||
async verifyMessageSent(_recipient: string, _messageText: string): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy debug hook. There's no DOM to inspect on the Matrix backend;
|
||||
* returns a small status payload so any UI still wired to this endpoint
|
||||
* stops 500-ing.
|
||||
*/
|
||||
async debugInspectDom(_chatUrl: string): Promise<unknown> {
|
||||
return {
|
||||
backend: 'matrix',
|
||||
note: 'DOM inspection is not available on the Matrix backend — chat data is sourced from the Reddit Matrix homeserver API directly.',
|
||||
};
|
||||
}
|
||||
|
||||
/** Public so bootstrap can swap in a fresh client after token refresh. */
|
||||
setMatrixClient(client: MatrixClient): void {
|
||||
this.matrix = client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Active invitation cooldown (i.e. Reddit's "you've created a lot of chats,
|
||||
* take a break" throttle), or null if we're clear to send. Auto-expires
|
||||
* by wall clock — callers don't have to clear it manually.
|
||||
*/
|
||||
getInvitationCooldown(): InvitationCooldown | null {
|
||||
if (!this.invitationCooldown) return null;
|
||||
if (Date.now() >= this.invitationCooldown.until) {
|
||||
this.invitationCooldown = null;
|
||||
return null;
|
||||
}
|
||||
return this.invitationCooldown;
|
||||
}
|
||||
|
||||
/** Record a cooldown after a `MatrixRateLimitError`. Test seam + reuse. */
|
||||
setInvitationCooldown(error: MatrixRateLimitError): void {
|
||||
const now = Date.now();
|
||||
// Add a small safety margin so we don't probe again the very second
|
||||
// the homeserver thinks the window expired.
|
||||
const margin = 5_000;
|
||||
this.invitationCooldown = {
|
||||
until: now + error.retryAfterMs + margin,
|
||||
redditCode: error.redditCode,
|
||||
reason:
|
||||
error.redditCode === 'rate.invitation_limit_mature_account'
|
||||
? "Reddit is throttling new chat invitations (\"you've created a lot of chats, take a break\")."
|
||||
: `Matrix rate-limited: ${error.errcode ?? '429'}`,
|
||||
setAt: now,
|
||||
};
|
||||
const minutes = Math.ceil(error.retryAfterMs / 60_000);
|
||||
this.logger.warn(
|
||||
`[chat] invitation cooldown set: ${this.invitationCooldown.reason} (~${minutes}m, code=${error.redditCode ?? error.errcode ?? '429'})`,
|
||||
);
|
||||
}
|
||||
|
||||
// ------------ internals ------------
|
||||
|
||||
/** Single delta /sync that updates cache + nextBatch and returns the union. */
|
||||
private async syncOnce(): Promise<ActiveChat[]> {
|
||||
const sync = await this.withAuth(() =>
|
||||
this.matrix.sync({ since: this.cache.getMarkers().nextBatch ?? undefined, timeoutMs: 0 }),
|
||||
);
|
||||
this.cache.setNextBatch(sync.next_batch);
|
||||
const joined = sync.rooms?.join ?? {};
|
||||
|
||||
const existing = new Map(this.cache.get().map((c) => [c.roomId!, c] as const));
|
||||
const knownUsernames = await this.resolveUsernamesForRooms(Object.keys(joined));
|
||||
|
||||
for (const [roomId, room] of Object.entries(joined)) {
|
||||
const prev = existing.get(roomId);
|
||||
const next = this.roomToActiveChat(roomId, room, { knownUsernames }, prev);
|
||||
existing.set(roomId, next);
|
||||
}
|
||||
|
||||
const merged = Array.from(existing.values()).sort(
|
||||
(a, b) => b.lastMessageTime - a.lastMessageTime,
|
||||
);
|
||||
this.cache.set(merged);
|
||||
this.cache.markDeltaSync();
|
||||
await this.cache.save(merged);
|
||||
return merged;
|
||||
}
|
||||
|
||||
private async loadDetails(chat: ActiveChat): Promise<void> {
|
||||
if (!chat.roomId) {
|
||||
this.logger.warn(`[chat] no roomId for ${chat.roomName}, skipping details`);
|
||||
return;
|
||||
}
|
||||
|
||||
const events: MatrixTimelineEvent[] = [];
|
||||
let from: string | undefined = undefined;
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const res = await this.withAuth(() =>
|
||||
this.matrix.getRoomMessages(chat.roomId!, { from, limit: 100, dir: 'b' }),
|
||||
);
|
||||
events.push(...res.chunk);
|
||||
if (!res.end || res.end === from || res.chunk.length === 0) break;
|
||||
from = res.end;
|
||||
}
|
||||
|
||||
// Pagination yields newest-first; flip to chronological.
|
||||
const messages = events.reverse().filter((e) => e.type === 'm.room.message');
|
||||
|
||||
const scraped: ScrapedChatMessage[] = messages.map((e) => {
|
||||
const body = (e.content.body as string | undefined) ?? '';
|
||||
const mxc = typeof e.content.url === 'string' ? (e.content.url as string) : null;
|
||||
return {
|
||||
sender: e.sender === this.matrix.userId ? 'Me' : this.shortenSender(e.sender),
|
||||
text: body || (mxc ? '[Image]' : ''),
|
||||
timestamp: e.origin_server_ts,
|
||||
isSelf: e.sender === this.matrix.userId,
|
||||
mediaUrls: mxc ? [mxc] : [],
|
||||
};
|
||||
});
|
||||
|
||||
// Backfill the recipient from message senders BEFORE attachMedia stamps
|
||||
// it into media records. /sync's heroes + state.events miss inactive
|
||||
// rooms; the senders of historical messages don't.
|
||||
if (!chat.username || chat.username === 'unknown' || chat.username.startsWith('@')) {
|
||||
const firstOther = messages.find((e) => e.sender !== this.matrix.userId);
|
||||
if (firstOther) {
|
||||
const t2 = firstOther.sender.match(/^@(t2_[^:]+):/)?.[1];
|
||||
if (t2) {
|
||||
const resolved =
|
||||
this.t2ToUsername.get(t2.replace(/^t2_/, '')) ?? t2;
|
||||
chat.username = resolved;
|
||||
if (!chat.roomName || chat.roomName === 'unknown') chat.roomName = resolved;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
chat.messages = this.mediaService
|
||||
? await this.mediaService.attachMedia(chat, scraped)
|
||||
: scraped.map(({ mediaUrls: _ignored, ...m }) => m as ChatMessage);
|
||||
|
||||
if (chat.messages.length === 0) {
|
||||
this.logger.debug(`[chat] no messages in ${chat.roomName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
chat.lastMessageSnippet = chat.messages[chat.messages.length - 1].text;
|
||||
chat.lastMessageTime = chat.messages[chat.messages.length - 1].timestamp;
|
||||
|
||||
const firstSelf = chat.messages.find((m) => m.isSelf);
|
||||
if (firstSelf) chat.originMessage = firstSelf.text;
|
||||
|
||||
const hasReply = chat.messages.some((m) => !m.isSelf);
|
||||
if (firstSelf && hasReply && this.successfulMessagesRepo) {
|
||||
if (!this.successfulMessagesRepo.existsByMessage(firstSelf.text)) {
|
||||
const firstReply = chat.messages.find((m) => !m.isSelf);
|
||||
try {
|
||||
this.successfulMessagesRepo.insert({
|
||||
sentMessageId: null,
|
||||
originalMessage: firstSelf.text,
|
||||
imageAnalysis: null,
|
||||
postTitle: `Chat with ${chat.username}`,
|
||||
responseSnippet: firstReply?.text.slice(0, 200) || null,
|
||||
subreddit: null,
|
||||
});
|
||||
this.logger.info(`[chat] stored successful chat example from ${chat.username}`);
|
||||
try {
|
||||
this.newReplyListener?.({
|
||||
username: chat.username,
|
||||
replySnippet: firstReply?.text.slice(0, 200) ?? '',
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.warn('[chat] newReplyListener threw:', err);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn('[chat] failed to store successful chat example:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a Matrix room (from /sync) into an ActiveChat. If `prev` is
|
||||
* supplied, fields it carries (messages, pendingReply, originMessage)
|
||||
* are preserved.
|
||||
*/
|
||||
private roomToActiveChat(
|
||||
roomId: string,
|
||||
room: MatrixJoinedRoom | undefined,
|
||||
opts: BuildChatOpts,
|
||||
prev?: ActiveChat,
|
||||
): ActiveChat {
|
||||
const timeline = room?.timeline?.events ?? [];
|
||||
const lastMessage = [...timeline].reverse().find((e) => e.type === 'm.room.message');
|
||||
|
||||
// Prefer summary.m.heroes (cheap), but fall back to scanning the room's
|
||||
// member-join state events — Reddit often omits heroes for older rooms
|
||||
// even though the recipient is in state.events as an m.room.member join.
|
||||
const heroes = (room?.summary?.['m.heroes'] ?? []).filter((h) => h !== this.matrix.userId);
|
||||
const memberJoins: string[] = [];
|
||||
for (const ev of room?.state?.events ?? []) {
|
||||
if (ev.type !== 'm.room.member') continue;
|
||||
const stateKey = (ev as { state_key?: string }).state_key;
|
||||
const membership = (ev.content as { membership?: string }).membership;
|
||||
if (membership === 'join' && stateKey && stateKey !== this.matrix.userId) {
|
||||
memberJoins.push(stateKey);
|
||||
}
|
||||
}
|
||||
const candidateMxids = [...new Set([...heroes, ...memberJoins])];
|
||||
const otherT2 = candidateMxids
|
||||
.map((h) => h.match(/^@(t2_[^:]+):/)?.[1])
|
||||
.filter((s): s is string => !!s);
|
||||
|
||||
let username = prev?.username ?? null;
|
||||
if (!username || username.startsWith('t2_') || username.startsWith('@')) {
|
||||
const resolved = otherT2
|
||||
.map((id) => opts.knownUsernames.get(id.replace(/^t2_/, '')))
|
||||
.find((x): x is string => !!x);
|
||||
if (resolved) username = resolved;
|
||||
else if (otherT2[0]) username = otherT2[0];
|
||||
}
|
||||
if (!username) username = prev?.username ?? 'unknown';
|
||||
|
||||
const nameEvent = room?.state?.events?.find((e) => e.type === 'm.room.name');
|
||||
let roomName = prev?.roomName ?? username;
|
||||
if (nameEvent && typeof nameEvent.content.name === 'string' && nameEvent.content.name) {
|
||||
roomName = nameEvent.content.name as string;
|
||||
} else if (!prev) {
|
||||
roomName = username;
|
||||
}
|
||||
|
||||
let lastMessageTime = prev?.lastMessageTime ?? 0;
|
||||
let lastMessageSnippet = prev?.lastMessageSnippet ?? '';
|
||||
let isFromUs = prev?.isFromUs ?? false;
|
||||
if (lastMessage) {
|
||||
lastMessageTime = lastMessage.origin_server_ts;
|
||||
const body = (lastMessage.content.body as string | undefined) ?? '';
|
||||
lastMessageSnippet = body || (lastMessage.content.url ? '[Image]' : '');
|
||||
isFromUs = lastMessage.sender === this.matrix.userId;
|
||||
}
|
||||
|
||||
const unread = (room?.unread_notifications?.notification_count ?? 0) > 0;
|
||||
|
||||
return {
|
||||
...(prev ?? {}),
|
||||
username,
|
||||
roomName,
|
||||
lastMessageTime,
|
||||
lastMessageSnippet,
|
||||
isFromUs,
|
||||
unread,
|
||||
url: ROOM_URL_PREFIX + encodeURIComponent(roomId),
|
||||
roomId,
|
||||
};
|
||||
}
|
||||
|
||||
/** Returns the t2 id portion of a stored `t2_xxx` username. */
|
||||
private extractT2(chat: ActiveChat): string | null {
|
||||
const m = chat.username?.match(/^t2_(.+)$/);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
/** Convert `@t2_abc:reddit.com` → `t2_abc`. */
|
||||
private shortenSender(mxid: string): string {
|
||||
const m = mxid.match(/^@([^:]+):/);
|
||||
return m ? m[1] : mxid;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* After a sync, look up real usernames for any t2-only entries in the
|
||||
* cache. Best-effort; safe to await or fire-and-forget.
|
||||
*/
|
||||
async enrichUsernames(): Promise<void> {
|
||||
if (!this.usernameResolver) return;
|
||||
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) 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);
|
||||
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`);
|
||||
await this.cache.save();
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.debug('[chat] enrichUsernames failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up the t2 ID for a Reddit username via the standard about.json
|
||||
* endpoint. Returns `t2_xxx` or null.
|
||||
*/
|
||||
private async resolveUsernameToT2(username: string): Promise<string | null> {
|
||||
try {
|
||||
const res = await fetchWithTimeout(
|
||||
`https://www.reddit.com/user/${encodeURIComponent(username)}/about.json`,
|
||||
{ headers: { 'User-Agent': 'reddit-bot-chat/1.0' }, timeoutMs: 15_000 },
|
||||
);
|
||||
if (!res.ok) {
|
||||
this.logger.warn(`[chat] /user/${username}/about.json → ${res.status}`);
|
||||
return null;
|
||||
}
|
||||
const json = (await res.json()) as { data?: { id?: string } };
|
||||
const id = json.data?.id;
|
||||
if (!id) return null;
|
||||
return id.startsWith('t2_') ? id : `t2_${id}`;
|
||||
} catch (err) {
|
||||
this.logger.error(`[chat] failed to resolve username "${username}":`, err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse a chat URL like `…/room/!abc%3Areddit.com` → `!abc:reddit.com`. */
|
||||
static extractRoomId(chatUrlOrRoomId: string): string | null {
|
||||
if (chatUrlOrRoomId.startsWith('!')) return chatUrlOrRoomId;
|
||||
const m = chatUrlOrRoomId.match(/\/room\/([^/?#]+)/);
|
||||
if (!m) return null;
|
||||
try {
|
||||
return decodeURIComponent(m[1]);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user