1315 lines
48 KiB
TypeScript
1315 lines
48 KiB
TypeScript
import type { Logger } from '../../utils/logger.js';
|
|
import type {
|
|
MessagesRepository,
|
|
RedditUsersRepository,
|
|
SentMessageRecord,
|
|
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, InvitationCooldown } from './types.js';
|
|
|
|
export type { InvitationCooldown } from './types.js';
|
|
import { fetchWithTimeout } from '../../utils/fetch-with-timeout.js';
|
|
import type { AccountAuthGate } from '../account-auth-context.js';
|
|
|
|
const ACTIVE_WINDOW_MS = 96 * 60 * 60 * 1000;
|
|
const ROOM_URL_PREFIX = 'https://chat.reddit.com/room/';
|
|
|
|
export interface TokenRefresher {
|
|
/**
|
|
* Requests replacement Matrix credentials. Extension-only authentication
|
|
* deliberately rejects this until the user imports a fresh dump.
|
|
*/
|
|
refresh(): Promise<void>;
|
|
}
|
|
|
|
class MatrixAuthenticationUnavailableError extends Error {
|
|
constructor(cause: unknown) {
|
|
super('Matrix authentication could not be restored', { cause });
|
|
this.name = 'MatrixAuthenticationUnavailableError';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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>>;
|
|
/** Resolve a Reddit username to its t2 account ID when starting a DM. */
|
|
resolveUsernameToT2?(username: string): Promise<string | null>;
|
|
}
|
|
|
|
interface BuildChatOpts {
|
|
knownUsernames: Map<string, string>;
|
|
includeTimelineMessages?: boolean;
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
export type SyncCompletion =
|
|
| { status: 'completed' }
|
|
| { status: 'failed'; error: string };
|
|
|
|
/** `null` signals "sync ended" (clear progress UI). */
|
|
export type SyncProgressListener = (
|
|
progress: SyncProgress | null,
|
|
completion?: SyncCompletion,
|
|
) => void;
|
|
|
|
/** Fired when a chat we sent into first receives a reply from the other party. */
|
|
export type NewReplyListener = (info: {
|
|
conversationId: string;
|
|
replyId: string;
|
|
username: string;
|
|
replySnippet: string;
|
|
}) => void;
|
|
|
|
export class ChatService {
|
|
private cache: ChatCache;
|
|
private queue: Promise<unknown> = Promise.resolve();
|
|
private deltaSyncInFlight: Promise<void> | null = null;
|
|
private readonly t2ToUsername = new Map<string, string>();
|
|
/** Rooms whose /joined_members lookup has already failed — don't retry. */
|
|
private readonly unresolvableRooms = new Set<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,
|
|
cache?: ChatCache,
|
|
private messagesRepo?: MessagesRepository,
|
|
private redditUsersRepo?: RedditUsersRepository,
|
|
private accountAuth?: AccountAuthGate,
|
|
) {
|
|
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 ------------
|
|
|
|
/** 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, invalidate the shared account gate.
|
|
* The configured refresher reports that a new extension dump is required.
|
|
*/
|
|
private async withAuth<T>(fn: () => Promise<T>): Promise<T> {
|
|
if (!this.accountAuth?.isFullyAuthenticated()) {
|
|
throw new MatrixAuthenticationUnavailableError(
|
|
new Error('Full account authentication is required'),
|
|
);
|
|
}
|
|
try {
|
|
return await fn();
|
|
} catch (err) {
|
|
if (err instanceof MatrixAuthError && this.tokenRefresher) {
|
|
this.logger.warn('[chat] Matrix token rejected — waiting for a new extension dump');
|
|
try {
|
|
await this.tokenRefresher.refresh();
|
|
} catch (refreshError) {
|
|
this.accountAuth?.invalidate('Matrix authentication could not be restored');
|
|
throw new MatrixAuthenticationUnavailableError(refreshError);
|
|
}
|
|
try {
|
|
return await fn();
|
|
} catch (retryError) {
|
|
this.accountAuth?.invalidate('Matrix authentication was rejected after refresh');
|
|
throw retryError;
|
|
}
|
|
}
|
|
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);
|
|
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;
|
|
const minutesLeft = Math.ceil((persisted.until - Date.now()) / 60_000);
|
|
this.logger.warn(
|
|
`[chat] restored send cooldown from cache: ${persisted.reason} (~${minutesLeft}m left, code=${persisted.redditCode ?? 'n/a'})`,
|
|
);
|
|
} else if (persisted) {
|
|
this.cache.setCooldown(null);
|
|
}
|
|
}
|
|
|
|
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');
|
|
let failure: unknown;
|
|
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;
|
|
} catch (error) {
|
|
failure = error;
|
|
throw error;
|
|
} finally {
|
|
this.endSync(failure);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
refreshTopChats(_limit = 5): Promise<void> {
|
|
if (this.deltaSyncInFlight) return this.deltaSyncInFlight;
|
|
|
|
const operation = this.enqueue(async () => {
|
|
this.beginSync('delta');
|
|
let failure: unknown;
|
|
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) {
|
|
failure = error;
|
|
this.logger.error('[chat] refreshTopChats failed:', error);
|
|
throw error;
|
|
} finally {
|
|
this.endSync(failure);
|
|
}
|
|
});
|
|
|
|
const tracked = operation.finally(() => {
|
|
this.deltaSyncInFlight = null;
|
|
});
|
|
this.deltaSyncInFlight = tracked;
|
|
return tracked;
|
|
}
|
|
|
|
/**
|
|
* 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');
|
|
let failure: unknown;
|
|
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`);
|
|
|
|
const knownUsernames = new Map(this.t2ToUsername);
|
|
const chats: ActiveChat[] = [];
|
|
|
|
for (const roomId of joinedIds) {
|
|
try {
|
|
const room = seedJoined[roomId];
|
|
const chat = this.roomToActiveChat(roomId, room, {
|
|
knownUsernames,
|
|
includeTimelineMessages: true,
|
|
});
|
|
chats.push(chat);
|
|
} catch (err) {
|
|
this.logger.warn(`[chat] backfill: failed to seed ${roomId}: ${(err as Error).message}`);
|
|
}
|
|
}
|
|
|
|
// Make the inbox usable as soon as Matrix has listed the rooms. A
|
|
// full history backfill can take many minutes for large accounts, so
|
|
// keeping these summaries local until the final room has loaded makes
|
|
// a healthy sync look like an empty inbox for the entire run.
|
|
chats.sort((a, b) => b.lastMessageTime - a.lastMessageTime);
|
|
this.cache.set(chats);
|
|
await this.cache.save(chats);
|
|
|
|
this.updateProgress({
|
|
phase: 'resolving-usernames',
|
|
current: 0,
|
|
total: joinedIds.length,
|
|
});
|
|
const resolvedUsernames = await this.resolveUsernamesForRooms(joinedIds, seedJoined);
|
|
for (const chat of chats) {
|
|
const t2 = this.extractT2(chat);
|
|
if (!t2) continue;
|
|
const resolved = resolvedUsernames.get(t2);
|
|
if (!resolved) continue;
|
|
chat.username = resolved;
|
|
if (!chat.roomName || chat.roomName.startsWith('t2_')) chat.roomName = resolved;
|
|
}
|
|
await this.cache.save(chats);
|
|
|
|
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) {
|
|
if (err instanceof MatrixAuthenticationUnavailableError) throw 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) {
|
|
failure = error;
|
|
this.logger.error('[chat] fetchAllMessages failed:', error);
|
|
if (error instanceof MatrixAuthenticationUnavailableError) throw error;
|
|
return this.cache.get();
|
|
} finally {
|
|
this.endSync(failure);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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(error?: unknown): void {
|
|
this.syncingKind = null;
|
|
this.progress = null;
|
|
if (this.progressListener) {
|
|
try {
|
|
const completion: SyncCompletion = error === undefined
|
|
? { status: 'completed' }
|
|
: {
|
|
status: 'failed',
|
|
error: error instanceof Error ? error.message : String(error),
|
|
};
|
|
this.progressListener(null, completion);
|
|
} 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> {
|
|
if (!this.accountAuth?.isFullyAuthenticated()) return;
|
|
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> {
|
|
if (!this.accountAuth?.isFullyAuthenticated()) return false;
|
|
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,
|
|
knownT2Id?: string,
|
|
): Promise<ActiveChat | null> {
|
|
if (!this.accountAuth?.isFullyAuthenticated()) return 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, knownT2Id);
|
|
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);
|
|
this.persistUsername(t2Id, 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 this.accountAuth?.isFullyAuthenticated() ?? false;
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
this.cache.setCooldown(null);
|
|
void this.cache.persistCooldown();
|
|
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'})`,
|
|
);
|
|
this.cache.setCooldown(this.invitationCooldown);
|
|
void this.cache.persistCooldown();
|
|
}
|
|
|
|
// ------------ 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), 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);
|
|
}
|
|
|
|
if (this.mediaService) {
|
|
for (const [roomId, room] of Object.entries(joined)) {
|
|
const scraped = this.scrapeMessageEvents(room?.timeline?.events ?? []);
|
|
const mediaMessages = scraped.filter((m) => m.mediaUrls.length > 0);
|
|
if (mediaMessages.length === 0) continue;
|
|
const chat = existing.get(roomId);
|
|
if (!chat) continue;
|
|
try {
|
|
const withMedia = await this.mediaService.attachMedia(chat, mediaMessages);
|
|
this.mergeNewMessages(chat, withMedia);
|
|
if (withMedia.some((message) => !message.isSelf && (message.media?.length ?? 0) > 0)) {
|
|
this.recordReceivedImage(chat);
|
|
}
|
|
} catch (err) {
|
|
this.logger.warn(
|
|
`[chat] delta media attach failed for ${roomId}: ${(err as Error).message}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
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);
|
|
// Resolve any t2_-prefixed usernames the delta just introduced, so the
|
|
// dashboard doesn't show raw Matrix-style IDs for new rooms.
|
|
await this.enrichUsernames();
|
|
return merged;
|
|
}
|
|
|
|
private scrapeMessageEvents(events: MatrixTimelineEvent[]): ScrapedChatMessage[] {
|
|
return events
|
|
.filter((e) => e.type === 'm.room.message')
|
|
.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] : [],
|
|
};
|
|
});
|
|
}
|
|
|
|
private mergeNewMessages(chat: ActiveChat, newMessages: ChatMessage[]): void {
|
|
if (!chat.messages || chat.messages.length === 0) return;
|
|
const seen = new Set(chat.messages.map((m) => `${m.timestamp}|${m.sender}`));
|
|
for (const msg of newMessages) {
|
|
const key = `${msg.timestamp}|${msg.sender}`;
|
|
if (seen.has(key)) continue;
|
|
chat.messages.push(msg);
|
|
seen.add(key);
|
|
}
|
|
}
|
|
|
|
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);
|
|
const hasReceivedImage = chat.messages.some(
|
|
(message) => !message.isSelf && (message.media?.length ?? 0) > 0,
|
|
);
|
|
let performanceMessage: SentMessageRecord | undefined;
|
|
|
|
if (firstSelf && chat.roomId && this.messagesRepo) {
|
|
try {
|
|
const existing = this.messagesRepo.getByChatRoomId(chat.roomId);
|
|
const hasPerformanceContext = Boolean(
|
|
existing?.postId || existing?.strategyId || existing?.subreddit,
|
|
);
|
|
if (existing && hasPerformanceContext) {
|
|
performanceMessage = existing;
|
|
if (hasReply && !existing.gotResponse) {
|
|
this.messagesRepo.markGotResponse(existing.id);
|
|
}
|
|
if (hasReceivedImage && !existing.gotImage) {
|
|
this.messagesRepo.markGotImage(existing.id);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
this.logger.warn('[chat] failed to associate reply with performance context:', err);
|
|
}
|
|
}
|
|
|
|
if (firstSelf && hasReply && performanceMessage && this.successfulMessagesRepo) {
|
|
if (!this.successfulMessagesRepo.existsByMessage(firstSelf.text)) {
|
|
const firstReply = chat.messages.find((m) => !m.isSelf);
|
|
try {
|
|
this.successfulMessagesRepo.insert({
|
|
sentMessageId: performanceMessage.id,
|
|
originalMessage: firstSelf.text,
|
|
imageAnalysis: performanceMessage.postImageAnalysis ?? null,
|
|
postTitle: performanceMessage.postTitle ?? null,
|
|
responseSnippet: firstReply?.text.slice(0, 200) || null,
|
|
subreddit: performanceMessage.subreddit ?? null,
|
|
});
|
|
this.logger.info(`[chat] stored successful chat example from ${chat.username}`);
|
|
try {
|
|
this.newReplyListener?.({
|
|
conversationId: chat.roomId,
|
|
replyId: `${chat.roomId}:${firstReply?.timestamp ?? Date.now()}`,
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private recordReceivedImage(chat: ActiveChat): void {
|
|
if (!chat.roomId || !this.messagesRepo) return;
|
|
try {
|
|
const existing = this.messagesRepo.getByChatRoomId(chat.roomId);
|
|
const hasPerformanceContext = Boolean(
|
|
existing?.postId || existing?.strategyId || existing?.subreddit,
|
|
);
|
|
if (!existing || !hasPerformanceContext) return;
|
|
if (!existing.gotImage) this.messagesRepo.markGotImage(existing.id);
|
|
} catch (err) {
|
|
this.logger.warn('[chat] failed to associate received image with performance context:', 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;
|
|
// Include 'invite' too — for outgoing chats the recipient never
|
|
// accepted, their MXID is only in state as an invite, not a join.
|
|
// chat.reddit.com's web UI reads the same to display the invitee.
|
|
if (
|
|
(membership === 'join' || membership === 'invite') &&
|
|
stateKey &&
|
|
stateKey !== this.matrix.userId
|
|
) {
|
|
memberJoins.push(stateKey);
|
|
}
|
|
}
|
|
// Reddit's homeserver routinely omits heroes/member state from /sync
|
|
// for chats that have been idle for a while — but the timeline events
|
|
// it does return always carry full MXIDs in their `sender` field, so
|
|
// mine those too.
|
|
const timelineSenders = timeline
|
|
.map((e) => e.sender)
|
|
.filter((s): s is string => !!s && s !== this.matrix.userId);
|
|
const candidateMxids = [...new Set([...heroes, ...memberJoins, ...timelineSenders])];
|
|
const otherT2 = candidateMxids
|
|
.map((h) => h.match(/^@(t2_[^:]+):/)?.[1])
|
|
.filter((s): s is string => !!s);
|
|
|
|
let username = prev?.username ?? null;
|
|
if (
|
|
!username ||
|
|
username === 'unknown' ||
|
|
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 timelineMessages = opts.includeTimelineMessages && !prev?.messages
|
|
? this.scrapeMessageEvents(timeline).map(({ mediaUrls: _ignored, ...message }) => message)
|
|
: prev?.messages;
|
|
|
|
const unread = (room?.unread_notifications?.notification_count ?? 0) > 0;
|
|
|
|
return {
|
|
...(prev ?? {}),
|
|
username,
|
|
roomName,
|
|
lastMessageTime,
|
|
lastMessageSnippet,
|
|
isFromUs,
|
|
unread,
|
|
url: ROOM_URL_PREFIX + encodeURIComponent(roomId),
|
|
roomId,
|
|
...(timelineMessages ? { messages: timelineMessages } : {}),
|
|
};
|
|
}
|
|
|
|
/** 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 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(/^@([^:]+):/);
|
|
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. 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[],
|
|
rooms: Record<string, MatrixJoinedRoom> = {},
|
|
): Promise<Map<string, string>> {
|
|
const cached = new Map(this.t2ToUsername);
|
|
const candidateT2s = new Set(cached.keys());
|
|
|
|
for (const roomId of roomIds) {
|
|
const room = rooms[roomId];
|
|
const mxids = [
|
|
...(room?.summary?.['m.heroes'] ?? []),
|
|
...(room?.state?.events ?? []).map((event) =>
|
|
(event as MatrixTimelineEvent & { state_key?: string }).state_key,
|
|
),
|
|
...(room?.timeline?.events ?? []).map((event) => event.sender),
|
|
];
|
|
for (const mxid of mxids) {
|
|
if (!mxid || mxid === this.matrix.userId) continue;
|
|
const t2 = mxid.match(/^@t2_([^:]+):/)?.[1];
|
|
if (t2) candidateT2s.add(t2);
|
|
}
|
|
}
|
|
|
|
let staleT2s = Array.from(candidateT2s).filter(id => {
|
|
const name = cached.get(id);
|
|
return !name || name.startsWith('t2_') || name.startsWith('@') || this.isStaleUsername(name);
|
|
});
|
|
|
|
// Matrix already knows the display name attached to each room member, so
|
|
// prefer that authenticated source before trying Reddit's legacy lookup.
|
|
if (staleT2s.length > 0 && this.usernameResolver) {
|
|
try {
|
|
const resolved = await this.usernameResolver.resolveT2Ids(
|
|
staleT2s.map((id) => `t2_${id}`),
|
|
);
|
|
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);
|
|
}
|
|
staleT2s = staleT2s.filter((id) => {
|
|
const name = cached.get(id);
|
|
return !name || this.isStaleUsername(name);
|
|
});
|
|
}
|
|
|
|
// Fall back to Reddit's alfred API for profiles without a Matrix display
|
|
// name. This is best-effort and leaves the stable t2 ID visible on error.
|
|
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);
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* 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.accountAuth?.isFullyAuthenticated()) {
|
|
this.logger.debug('[chat] username enrichment skipped until full account authentication');
|
|
return;
|
|
}
|
|
if (!this.usernameResolver) return;
|
|
let dirty = false;
|
|
|
|
// Pass 1 — upgrade any t2_-prefixed chat whose t2 we *already* know how
|
|
// to resolve. Without this, a delta that re-creates a t2-only chat
|
|
// wouldn't get upgraded until the bot restarts, because the resolver
|
|
// below skips known mappings.
|
|
for (const chat of this.cache.get()) {
|
|
const t2 = this.extractT2(chat);
|
|
if (!t2) continue;
|
|
const known = this.t2ToUsername.get(t2);
|
|
if (known && known !== chat.username) {
|
|
chat.username = known;
|
|
if (!chat.roomName || chat.roomName.startsWith('t2_') || chat.roomName === 'unknown') {
|
|
chat.roomName = known;
|
|
}
|
|
dirty = true;
|
|
}
|
|
}
|
|
|
|
// Pass 2 — chats still stuck on 'unknown' (or a bare MXID) didn't get a
|
|
// t2 ID from /sync heroes/state/timeline. Try /state first — it returns
|
|
// every m.room.member event including 'invite'-membership ones, which is
|
|
// how chat.reddit.com's web UI names rooms the recipient never accepted.
|
|
// Fall back to mining a /messages page if /state somehow yields nothing.
|
|
for (const chat of this.cache.get()) {
|
|
if (!chat.roomId) continue;
|
|
if (this.unresolvableRooms.has(chat.roomId)) continue;
|
|
const needsLookup =
|
|
!chat.username ||
|
|
chat.username === 'unknown' ||
|
|
chat.username.startsWith('@');
|
|
if (!needsLookup) continue;
|
|
let t2: string | undefined;
|
|
try {
|
|
const state = await this.withAuth(() => this.matrix.getRoomFullState(chat.roomId!));
|
|
const otherMember = state.find((e) => {
|
|
if (e.type !== 'm.room.member') return false;
|
|
const sk = (e as { state_key?: string }).state_key;
|
|
const membership = (e.content as { membership?: string }).membership;
|
|
return (
|
|
sk &&
|
|
sk !== this.matrix.userId &&
|
|
(membership === 'join' || membership === 'invite')
|
|
);
|
|
}) as { state_key?: string } | undefined;
|
|
t2 = otherMember?.state_key?.match(/^@(t2_[^:]+):/)?.[1];
|
|
} catch (err) {
|
|
if (err instanceof MatrixAuthenticationUnavailableError) {
|
|
this.logger.warn('[chat] username enrichment paused until Reddit login is restored');
|
|
return;
|
|
}
|
|
this.logger.debug(`[chat] /state lookup failed for ${chat.roomId}:`, err);
|
|
}
|
|
if (!t2) {
|
|
try {
|
|
const res = await this.withAuth(() =>
|
|
this.matrix.getRoomMessages(chat.roomId!, { limit: 20, dir: 'b' }),
|
|
);
|
|
const otherSender = res.chunk?.find(
|
|
(e) => e.sender && e.sender !== this.matrix.userId,
|
|
)?.sender;
|
|
t2 = otherSender?.match(/^@(t2_[^:]+):/)?.[1];
|
|
} catch (err) {
|
|
if (err instanceof MatrixAuthenticationUnavailableError) {
|
|
this.logger.warn('[chat] username enrichment paused until Reddit login is restored');
|
|
return;
|
|
}
|
|
this.logger.debug(`[chat] /messages lookup failed for ${chat.roomId}:`, err);
|
|
}
|
|
}
|
|
if (t2) {
|
|
const bare = t2.replace(/^t2_/, '');
|
|
const known = this.t2ToUsername.get(bare);
|
|
chat.username = known ?? `t2_${bare}`;
|
|
if (!chat.roomName || chat.roomName === 'unknown' || chat.roomName.startsWith('@')) {
|
|
chat.roomName = chat.username;
|
|
}
|
|
dirty = true;
|
|
} else {
|
|
// Neither /state nor /messages surfaced a t2 — give up on this room
|
|
// so we don't keep hammering the homeserver every sync.
|
|
this.unresolvableRooms.add(chat.roomId);
|
|
}
|
|
}
|
|
|
|
// Pass 3 — for every chat still on a raw t2 ID, batch-resolve via the
|
|
// injected resolver (www.reddit.com/api/user_data_by_account_ids.json).
|
|
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) {
|
|
try {
|
|
const got = await this.usernameResolver.resolveT2Ids(needed);
|
|
let updated = 0;
|
|
for (const [t2, name] of Object.entries(got)) {
|
|
const bare = t2.replace(/^t2_/, '');
|
|
if (!name) continue;
|
|
this.t2ToUsername.set(bare, name);
|
|
this.persistUsername(t2, 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`);
|
|
dirty = true;
|
|
}
|
|
} catch (err) {
|
|
this.logger.debug('[chat] enrichUsernames failed:', err);
|
|
}
|
|
}
|
|
|
|
if (dirty) await this.cache.save();
|
|
}
|
|
|
|
/** Resolve an account ID from listing data, the durable cache, or Reddit auth. */
|
|
private async resolveUsernameToT2(
|
|
username: string,
|
|
knownT2Id?: string,
|
|
): Promise<string | null> {
|
|
if (knownT2Id && /^t2_[a-z0-9]+$/i.test(knownT2Id)) return knownT2Id;
|
|
|
|
const cached = this.redditUsersRepo?.getByUsername(username);
|
|
if (cached) return cached.t2Id;
|
|
|
|
if (this.usernameResolver?.resolveUsernameToT2) {
|
|
return this.usernameResolver.resolveUsernameToT2(username);
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
}
|