diff --git a/apps/dashboard/src/features/conversations/conversation-detail.tsx b/apps/dashboard/src/features/conversations/conversation-detail.tsx index 425f602..de87d18 100644 --- a/apps/dashboard/src/features/conversations/conversation-detail.tsx +++ b/apps/dashboard/src/features/conversations/conversation-detail.tsx @@ -38,12 +38,19 @@ function MessageHistory({ conversation }: { conversation: ConversationDetailResp {message.media?.length ? (
{message.media.map((media) => ( - {`Image + rel="noreferrer" + target="_blank" + > + {`Image + ))}
) : null} diff --git a/apps/dashboard/src/features/conversations/conversations.css b/apps/dashboard/src/features/conversations/conversations.css index cdc1834..493323b 100644 --- a/apps/dashboard/src/features/conversations/conversations.css +++ b/apps/dashboard/src/features/conversations/conversations.css @@ -60,6 +60,8 @@ .conversation-messages time { color: var(--muted); font-size: .7rem; } .conversation-messages p { margin: 0; line-height: 1.45; white-space: pre-wrap; } .conversation-message-images { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(12rem, 100%), 1fr)); gap: .5rem; margin-top: .65rem; } +.conversation-message-images a { display: block; border-radius: .6rem; } +.conversation-message-images a:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } .conversation-message-images img { display: block; width: 100%; max-width: 28rem; max-height: 32rem; object-fit: contain; background: #08100c; border: 1px solid var(--border); border-radius: .6rem; } .conversation-empty-detail { padding: 2rem; color: var(--muted); text-align: center; } .conversation-composer { display: grid; gap: .65rem; padding: 1rem; border-top: 1px solid var(--border); } diff --git a/extensions/reddit-session-exporter/README.md b/extensions/reddit-session-exporter/README.md index 4d8dcfe..1abc917 100644 --- a/extensions/reddit-session-exporter/README.md +++ b/extensions/reddit-session-exporter/README.md @@ -13,3 +13,5 @@ This local Chrome extension exports `reddit.com` cookies and the current Reddit 6. Delete the downloaded JSON file once the dashboard confirms that the session is authenticated. The extension observes only requests to Reddit's Matrix homeservers, verifies the captured token with Matrix `whoami`, reads Reddit cookies, and saves the combined export locally. It does not send the dump to any server; you upload it directly to your RedditBot dashboard. + +Before downloading, the extension independently resolves the current Reddit cookie identity and refuses the export unless its `t2` account ID matches Matrix `whoami`. Changing Reddit accounts clears the stored Matrix capture, so Reddit Chat must be reopened after every account switch. diff --git a/extensions/reddit-session-exporter/manifest.json b/extensions/reddit-session-exporter/manifest.json index e89587a..528842f 100644 --- a/extensions/reddit-session-exporter/manifest.json +++ b/extensions/reddit-session-exporter/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "RedditBot Session Exporter", - "version": "1.0.0", + "version": "1.1.0", "description": "Exports Reddit cookies and Reddit Chat credentials for a self-hosted RedditBot dashboard.", "permissions": ["cookies", "downloads", "storage", "webRequest"], "host_permissions": [ diff --git a/extensions/reddit-session-exporter/service-worker.js b/extensions/reddit-session-exporter/service-worker.js index 5ddfbcb..ab0eee7 100644 --- a/extensions/reddit-session-exporter/service-worker.js +++ b/extensions/reddit-session-exporter/service-worker.js @@ -1,4 +1,7 @@ -import { createSessionExportDownload } from './session-export.js'; +import { + assertMatchingSessionIdentity, + createSessionExportDownload, +} from './session-export.js'; const MATRIX_URLS = [ 'https://matrix.redditspace.com/*', @@ -27,6 +30,29 @@ chrome.webRequest.onBeforeSendHeaders.addListener( ['requestHeaders', 'extraHeaders'], ); +chrome.cookies.onChanged.addListener(async ({ cookie }) => { + if (!['reddit_session', 'token_v2'].includes(cookie.name)) return; + await chrome.storage.local.remove('matrixCapture'); + await chrome.action.setBadgeText({ text: '' }); + await chrome.action.setTitle({ + title: 'Reddit account changed. Open Reddit Chat before exporting.', + }); +}); + +async function getRedditIdentity() { + const response = await fetch('https://www.reddit.com/api/me.json', { + credentials: 'include', + cache: 'no-store', + }); + if (!response.ok) throw new Error('Reddit cookies did not authenticate. Sign in and try again.'); + const payload = await response.json(); + const account = payload?.data ?? payload; + if (!account?.name || !account?.id) { + throw new Error('Reddit did not return an authenticated account identity.'); + } + return { username: account.name, accountId: account.id }; +} + async function showError(message) { await chrome.action.setBadgeText({ text: 'ERR' }); await chrome.action.setBadgeBackgroundColor({ color: '#dc3545' }); @@ -48,6 +74,14 @@ chrome.action.onClicked.addListener(async () => { const identity = await whoami.json(); if (!identity?.user_id) throw new Error('Reddit Chat did not return an account identity.'); + const redditIdentity = await getRedditIdentity(); + try { + assertMatchingSessionIdentity(redditIdentity, identity.user_id); + } catch (error) { + await chrome.storage.local.remove('matrixCapture'); + throw error; + } + await chrome.downloads.download({ ...createSessionExportDownload(cookies, { ...matrixCapture, userId: identity.user_id }), saveAs: true, diff --git a/extensions/reddit-session-exporter/session-export.js b/extensions/reddit-session-exporter/session-export.js index 5d87a03..b6c7aa4 100644 --- a/extensions/reddit-session-exporter/session-export.js +++ b/extensions/reddit-session-exporter/session-export.js @@ -1,5 +1,26 @@ const REDDITBOT_SESSION_FILENAME = 'redditbot-session.json'; +export function normalizeRedditAccountId(accountId) { + const trimmed = String(accountId ?? '').trim(); + const normalized = trimmed.startsWith('t2_') ? trimmed : `t2_${trimmed}`; + if (!/^t2_[a-z0-9]+$/i.test(normalized)) { + throw new Error('Reddit returned an invalid account ID.'); + } + return normalized; +} + +export function assertMatchingSessionIdentity(redditIdentity, matrixUserId) { + const accountId = normalizeRedditAccountId(redditIdentity?.accountId); + const username = String(redditIdentity?.username ?? '').trim(); + if (!username) throw new Error('Reddit did not return an authenticated username.'); + const expectedMatrixUserId = `@${accountId}:reddit.com`; + if (matrixUserId !== expectedMatrixUserId) { + throw new Error( + `Reddit is logged in as u/${username} (${accountId}), but Chat is authenticated as ${matrixUserId}. Reload Reddit Chat after switching accounts, then export again.`, + ); + } +} + function isRedditDomain(domain) { const normalised = domain.toLowerCase().replace(/^\./, ''); return normalised === 'reddit.com' || normalised.endsWith('.reddit.com'); diff --git a/src/bootstrap.ts b/src/bootstrap.ts index 0e2f60e..229e320 100644 --- a/src/bootstrap.ts +++ b/src/bootstrap.ts @@ -37,7 +37,7 @@ import { RateLimiter } from './utils/rate-limiter.js'; import { WebServer } from './web/server.js'; import { reportReply } from './web/event-reporting.js'; import { RedditSessionImporter } from './services/reddit-session-importer.js'; -import { AccountAuthContext } from './services/account-auth-context.js'; +import { AccountAuthContext, authenticateCurrentAccount } from './services/account-auth-context.js'; import { ConfigStore } from './config/persistence.js'; import { RedditAccountsRepository, AccountContentRepository } from './database/account-repositories.js'; import { RedditAccountsService } from './services/reddit-accounts.js'; @@ -199,17 +199,17 @@ export async function bootstrap( }; } let activeMatrixClient = new MatrixClient(matrixCreds, logger); - accountAuth.setAuthenticator(async () => { - const redditUsername = await browserClient.getAuthenticatedUsername(); - if (!redditUsername) return null; - try { - const matrixUserId = await activeMatrixClient.whoami(); - if (!matrixUserId) return null; - return { accountId: matrixUserId, redditUsername, matrixUserId }; - } catch { - return null; - } - }); + accountAuth.setAuthenticator(() => authenticateCurrentAccount({ + getRedditIdentity: () => browserClient.getAuthenticatedIdentity(), + refreshMatrixCredentials: async () => { + try { + const userId = await activeMatrixClient.whoami(); + return userId ? { userId } : null; + } catch { + return null; + } + }, + })); const fullyAuthenticated = redditLoggedIn && await accountAuth.ensureFullyAuthenticated(); if (fullyAuthenticated) logger.info('Reddit and Matrix authentication verified'); broadcaster.dashboardEvents.redditSession({ diff --git a/src/browser/client.ts b/src/browser/client.ts index 091b0a9..0a3f7d6 100644 --- a/src/browser/client.ts +++ b/src/browser/client.ts @@ -3,6 +3,18 @@ import type { Config } from '../config/index.js'; import type { Logger } from '../utils/logger.js'; import path from 'path'; import fs from 'fs'; +import { z } from 'zod'; +import type { RedditAccountIdentity } from '../services/account-auth-context.js'; + +const RedditAccountSchema = z.object({ + name: z.string().trim().min(1), + id: z.string().trim().min(1), +}).passthrough(); + +const RedditIdentityResponseSchema = z.union([ + RedditAccountSchema, + z.object({ data: RedditAccountSchema }).passthrough().transform(({ data }) => data), +]); export interface BrowserRuntime { launchPersistentContext( @@ -115,17 +127,13 @@ export class BrowserClient { /** Returns the account name from Reddit's authenticated identity endpoint. */ async getAuthenticatedUsername(): Promise { - try { - return (await this.getRedditIdentity(await this.getPage()))?.username ?? null; - } catch { - return null; - } + return (await this.getAuthenticatedIdentity())?.username ?? null; } - async getAuthenticatedIdentity(): Promise<{ username: string; accountId: string } | null> { + /** Returns the username and stable t2 account ID for identity binding. */ + async getAuthenticatedIdentity(): Promise { try { - const identity = await this.getRedditIdentity(await this.getPage()); - return identity?.accountId ? { username: identity.username, accountId: identity.accountId } : null; + return await this.getRedditIdentity(await this.getPage()); } catch { return null; } @@ -180,25 +188,24 @@ export class BrowserClient { return (await this.getRedditIdentity(page)) !== null; } - private async getRedditIdentity(page: Page): Promise<{ username: string; accountId: string | null } | null> { + private async getRedditIdentity(page: Page): Promise { try { - return await page.evaluate(async () => { + const payload: unknown = await page.evaluate(async () => { try { const res = await fetch('https://www.reddit.com/api/me.json', { credentials: 'include' }); if (!res.ok) return null; - const data: unknown = await res.json(); - if (!data || typeof data !== 'object') return null; - const account = 'data' in data && data.data && typeof data.data === 'object' - ? data.data - : data; - if (!('name' in account) || typeof account.name !== 'string') return null; - const username = account.name.trim(); - const accountId = 'id' in account && typeof account.id === 'string' ? account.id.trim() : ''; - return username ? { username, accountId: accountId || null } : null; + return await res.json(); } catch { return null; } }); + const parsed = RedditIdentityResponseSchema.safeParse(payload); + if (!parsed.success) return null; + const rawAccountId = parsed.data.id; + return { + username: parsed.data.name, + accountId: rawAccountId.startsWith('t2_') ? rawAccountId : `t2_${rawAccountId}`, + }; } catch { return null; } diff --git a/src/main-loop.ts b/src/main-loop.ts index 0bcc370..4101244 100644 --- a/src/main-loop.ts +++ b/src/main-loop.ts @@ -258,29 +258,6 @@ export async function runMainLoop(app: AppContainer): Promise { processedAt: Date.now(), }); - // Skip OF/seller accounts before spending on AI. - if ( - config.reddit.filterSellers && - (config.reddit.sellerKeywords.length > 0 || config.reddit.sellerDomains.length > 0) - ) { - const isSeller = await redditApi.isSellerProfile( - post.author, - config.reddit.sellerKeywords, - config.reddit.sellerDomains - ); - if (isSeller) { - logger.info(`Skipping u/${post.author} - seller/OnlyFans profile detected`); - webServer?.logActivity( - 'skipped', - `Skipped post by u/${post.author} (seller/OnlyFans profile)`, - post.author, - ); - webServer?.pushLog('info', `🚫 Skipped u/${post.author} - seller/OnlyFans profile`); - postsRepo.setError(post.id, 'Seller profile detected'); - continue; - } - } - // Refresh examples weighted toward this post's subreddit. const subredditExamples = responseChecker.getSuccessfulExamples(3, post.subreddit); processorService.setSuccessfulExamples(subredditExamples); diff --git a/src/reddit/api.ts b/src/reddit/api.ts index 5bbd80c..14bd8c0 100644 --- a/src/reddit/api.ts +++ b/src/reddit/api.ts @@ -48,6 +48,38 @@ export interface RedditPost { link_flair_text?: string | null; } +interface RedditListingChild { + data?: { + title?: unknown; + selftext?: unknown; + body?: unknown; + url?: unknown; + subreddit?: unknown; + }; +} + +function joinText(values: unknown[]): string { + return values.filter((value): value is string => typeof value === 'string').join(' '); +} + +/** Match configured seller signals in user-controlled text. */ +export function findSellerSignal( + text: string, + keywords: string[], + domains: string[], +): { kind: 'keyword' | 'domain'; value: string } | null { + const normalized = text.toLocaleLowerCase(); + for (const keyword of keywords) { + const candidate = keyword.trim().toLocaleLowerCase(); + if (candidate && normalized.includes(candidate)) { + return { kind: 'keyword', value: keyword }; + } + } + + const domain = findSellerDomain(text, domains); + return domain ? { kind: 'domain', value: domain } : null; +} + // Extracts URL-ish tokens (with or without scheme) and returns the first // domain match. Matches `host` or any subdomain (`*.host`), case-insensitive. export function findSellerDomain(text: string, domains: string[]): string | null { @@ -249,10 +281,28 @@ export class RedditApiClient { async isSellerProfile( username: string, sellerKeywords: string[], - sellerDomains: string[] = [] + sellerDomains: string[] = [], + currentPost?: RedditPost, ): Promise { if (sellerKeywords.length === 0 && sellerDomains.length === 0) return false; + // Check the post first. This catches direct promotional posts without an + // extra request and also covers accounts whose profile bio is deliberately + // vague. + if (currentPost) { + const postSignal = findSellerSignal( + joinText([currentPost.title, currentPost.selftext, currentPost.url]), + sellerKeywords, + sellerDomains, + ); + if (postSignal) { + this.logger.info( + `u/${username} flagged as seller from current post (matched ${postSignal.kind}: "${postSignal.value}")`, + ); + return true; + } + } + try { const cookieHeader = await this.getCookieHeader(); const headers: Record = {}; @@ -260,51 +310,77 @@ export class RedditApiClient { headers['Cookie'] = cookieHeader; } - const response = await this.client.get(`/user/${username}/about.json`, { + const response = await this.client.get(`/user/${encodeURIComponent(username)}/about.json`, { params: { raw_json: 1 }, headers, }); const userData = response.data?.data; - if (!userData) return false; - - // Combine all text fields to scan - const textToCheck = [ - userData.subreddit?.public_description || '', - userData.subreddit?.title || '', - userData.subreddit?.display_name_prefixed || '', - ] - .join(' ') - .toLowerCase(); - - for (const keyword of sellerKeywords) { - if (textToCheck.includes(keyword.toLowerCase())) { + if (userData) { + const profileText = joinText([ + userData.subreddit?.public_description || '', + userData.subreddit?.title || '', + userData.subreddit?.display_name_prefixed || '', + ]); + const profileSignal = findSellerSignal(profileText, sellerKeywords, sellerDomains); + if (profileSignal) { this.logger.info( - `u/${username} flagged as seller profile (matched keyword: "${keyword}")` + `u/${username} flagged as seller profile (matched ${profileSignal.kind}: "${profileSignal.value}")`, ); return true; } } - - if (sellerDomains.length > 0) { - const matchedDomain = findSellerDomain(textToCheck, sellerDomains); - if (matchedDomain) { - this.logger.info( - `u/${username} flagged as seller profile (matched domain: "${matchedDomain}")` - ); - return true; - } - } - - return false; } catch (error) { + if (axios.isAxiosError(error) && error.response?.status === 429) { + const rateLimitError = new RedditRateLimitError(getRateLimitDelayMs(error.response.headers)); + this.logger.warn(rateLimitError.message); + throw rateLimitError; + } if (axios.isAxiosError(error) && error.response?.status === 404) { this.logger.warn(`User u/${username} profile not found`); return false; } this.logger.warn(`Failed to check profile for u/${username}:`, error); - return false; // Don't block on errors - let it through } + + // Bios are often clean while recent posts advertise the paid account. + // Scan a small overview page, including comment bodies and subreddit + // names, before allowing the candidate through. + try { + const cookieHeader = await this.getCookieHeader(); + const headers: Record = {}; + if (cookieHeader) headers.Cookie = cookieHeader; + const response = await this.client.get(`/user/${encodeURIComponent(username)}/overview.json`, { + params: { limit: 25, raw_json: 1 }, + headers, + }); + const children: RedditListingChild[] = Array.isArray(response.data?.data?.children) + ? response.data.data.children + : []; + const activityText = children.map(({ data }) => joinText([ + data?.title, + data?.selftext, + data?.body, + data?.url, + data?.subreddit, + ])).join(' '); + const activitySignal = findSellerSignal(activityText, sellerKeywords, sellerDomains); + if (activitySignal) { + this.logger.info( + `u/${username} flagged as seller from recent activity (matched ${activitySignal.kind}: "${activitySignal.value}")`, + ); + return true; + } + } catch (error) { + if (axios.isAxiosError(error) && error.response?.status === 429) { + const rateLimitError = new RedditRateLimitError(getRateLimitDelayMs(error.response.headers)); + this.logger.warn(rateLimitError.message); + throw rateLimitError; + } + this.logger.warn(`Failed to check recent activity for u/${username}:`, error); + } + + return false; // Fail open if Reddit profile endpoints are unavailable. } getPostUrl(post: RedditPost): string { diff --git a/src/services/account-auth-context.ts b/src/services/account-auth-context.ts index fe9fbfa..3813b81 100644 --- a/src/services/account-auth-context.ts +++ b/src/services/account-auth-context.ts @@ -6,11 +6,18 @@ export type AccountAuthStatus = | 'authenticated'; export interface AuthenticatedAccount { + /** Reddit account fullname, e.g. `t2_abc123`. */ accountId: string; redditUsername: string; matrixUserId: string; } +export interface RedditAccountIdentity { + username: string; + /** Reddit's account ID, with or without the `t2_` prefix. */ + accountId: string; +} + export interface AccountAuthSnapshot { status: AccountAuthStatus; accountId: string | null; @@ -34,21 +41,43 @@ export interface AccountAuthContextOptions { } export interface CurrentAccountAuthentication { - getRedditUsername(): Promise; + getRedditIdentity(): Promise; refreshMatrixCredentials(): Promise<{ userId: string } | null>; } +export function normalizeRedditAccountId(accountId: string): string { + const trimmed = accountId.trim(); + const normalized = trimmed.startsWith('t2_') ? trimmed : `t2_${trimmed}`; + if (!/^t2_[a-z0-9]+$/i.test(normalized)) { + throw new Error('Reddit returned an invalid account ID'); + } + return normalized; +} + +export function expectedMatrixUserId(accountId: string): string { + return `@${normalizeRedditAccountId(accountId)}:reddit.com`; +} + +export function assertMatchingAccountIdentity(account: AuthenticatedAccount): void { + const expected = expectedMatrixUserId(account.accountId); + if (account.matrixUserId !== expected) { + throw new Error( + `Reddit account u/${account.redditUsername} (${normalizeRedditAccountId(account.accountId)}) does not match Matrix account ${account.matrixUserId}`, + ); + } +} + export async function authenticateCurrentAccount( authentication: CurrentAccountAuthentication, ): Promise { - const redditUsername = await authentication.getRedditUsername(); - if (!redditUsername) return null; + const redditIdentity = await authentication.getRedditIdentity(); + if (!redditIdentity) return null; const credentials = await authentication.refreshMatrixCredentials(); if (!credentials?.userId) return null; return { - accountId: credentials.userId, - redditUsername, + accountId: normalizeRedditAccountId(redditIdentity.accountId), + redditUsername: redditIdentity.username, matrixUserId: credentials.userId, }; } @@ -104,15 +133,15 @@ export class AccountAuthContext implements AccountAuthGate { if (!account.accountId || !account.redditUsername || !account.matrixUserId) { throw new Error('A fully authenticated account requires Reddit and Matrix identities'); } - if (account.accountId !== account.matrixUserId) { - throw new Error('Authenticated account ID must match the Matrix user ID'); - } + assertMatchingAccountIdentity(account); - const changedAccount = this.snapshot.accountId !== account.accountId; + const accountId = normalizeRedditAccountId(account.accountId); + + const changedAccount = this.snapshot.accountId !== accountId; const wasAuthenticated = this.isFullyAuthenticated(); this.snapshot = { status: 'authenticated', - accountId: account.accountId, + accountId, redditUsername: account.redditUsername, matrixUserId: account.matrixUserId, reason: null, diff --git a/src/services/chat/matrix/client.ts b/src/services/chat/matrix/client.ts index 961a13f..131bf3e 100644 --- a/src/services/chat/matrix/client.ts +++ b/src/services/chat/matrix/client.ts @@ -54,6 +54,11 @@ export interface RoomSummary { recentEvents: MatrixTimelineEvent[]; } +export interface DirectRoomMapping { + recipientMxid: string; + roomId: string; +} + /** * Cap on how long we'll sit and sleep waiting out a 429. Anything longer * is surfaced as a `MatrixRateLimitError` so the main loop can back off @@ -405,7 +410,7 @@ export class MatrixClient { * "Start chat" in the UI: * * POST /_matrix/client/v3/createRoom - * { "preset": "reddit_dm", "invite": [mxid] } + * { "preset": "reddit_dm", "invite": [mxid], "is_direct": true } * * `reddit_dm` is a Reddit-specific preset that the homeserver expands * server-side into the right power levels, history visibility, and @@ -413,12 +418,53 @@ export class MatrixClient { */ async createDirectRoom(recipientMxid: string): Promise { const res = await this.request<{ room_id: string }>('POST', '/_matrix/client/v3/createRoom', { - body: { preset: 'reddit_dm', invite: [recipientMxid] }, + body: { preset: 'reddit_dm', invite: [recipientMxid], is_direct: true }, }); this.logger.debug(`[matrix] created DM room ${res.room_id} with ${recipientMxid}`); return res.room_id; } + /** + * Add missing rooms to the account-level `m.direct` map used by Matrix + * clients to classify joined rooms as direct chats. Existing mappings are + * preserved verbatim; the write is skipped when there is nothing to repair. + */ + async repairDirectRoomMappings(mappings: DirectRoomMapping[]): Promise { + if (mappings.length === 0) return 0; + + let directRooms: Record; + try { + directRooms = await this.request>( + 'GET', + `/_matrix/client/v3/user/${encodeURIComponent(this.creds.userId)}/account_data/m.direct`, + ); + } catch (error) { + if (error instanceof Error && error.message.includes('→ 404')) { + directRooms = {}; + } else { + throw error; + } + } + + let repaired = 0; + for (const { recipientMxid, roomId } of mappings) { + const existing = Array.isArray(directRooms[recipientMxid]) + ? directRooms[recipientMxid] + : []; + if (existing.includes(roomId)) continue; + directRooms[recipientMxid] = [...existing, roomId]; + repaired++; + } + + if (repaired === 0) return 0; + await this.request>( + 'PUT', + `/_matrix/client/v3/user/${encodeURIComponent(this.creds.userId)}/account_data/m.direct`, + { body: directRooms }, + ); + return repaired; + } + /** * One-shot: create a DM room with the recipient and send the opening * message. Returns the new room_id and the event_id of the message. @@ -428,6 +474,13 @@ export class MatrixClient { body: string, ): Promise<{ roomId: string; eventId: string }> { const roomId = await this.createDirectRoom(recipientMxid); + try { + await this.repairDirectRoomMappings([{ recipientMxid, roomId }]); + } catch (error) { + // Room creation has already succeeded. Do not turn a metadata repair + // failure into an orphan-and-retry cycle that creates duplicate rooms. + this.logger.warn(`[matrix] failed to register ${roomId} as a direct chat:`, error); + } const eventId = await this.sendTextMessage(roomId, body); return { roomId, eventId }; } diff --git a/src/services/chat/service.ts b/src/services/chat/service.ts index 38d30ea..4a16b74 100644 --- a/src/services/chat/service.ts +++ b/src/services/chat/service.ts @@ -402,6 +402,7 @@ export class ChatService { if (!chat.roomName || chat.roomName.startsWith('t2_')) chat.roomName = resolved; } await this.cache.save(chats); + await this.repairDirectRoomMappings(chats); let done = 0; for (const chat of chats) { @@ -736,17 +737,21 @@ export class ChatService { 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; + for (const [roomId, room] of Object.entries(joined)) { + const scraped = this.scrapeMessageEvents(room?.timeline?.events ?? []); + if (scraped.length === 0) continue; + const chat = existing.get(roomId); + if (!chat) continue; + + let newMessages: ChatMessage[] = scraped.map( + ({ mediaUrls: _ignored, ...message }) => message, + ); + if (this.mediaService && scraped.some((message) => message.mediaUrls.length > 0)) { try { - const withMedia = await this.mediaService.attachMedia(chat, mediaMessages); - this.mergeNewMessages(chat, withMedia); - if (withMedia.some((message) => !message.isSelf && (message.media?.length ?? 0) > 0)) { + newMessages = await this.mediaService.attachMedia(chat, scraped); + if (newMessages.some( + (message) => !message.isSelf && (message.media?.length ?? 0) > 0, + )) { this.recordReceivedImage(chat); } } catch (err) { @@ -755,6 +760,7 @@ export class ChatService { ); } } + this.mergeNewMessages(chat, newMessages); } const merged = Array.from(existing.values()).sort( @@ -766,9 +772,49 @@ export class ChatService { // 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(); + await this.repairDirectRoomMappings(merged); return merged; } + /** + * Reconcile cached one-to-one chats with Matrix account data. The durable + * username repository lets delta sync repair older rooms even when Reddit + * omits their member state from the latest /sync response. + */ + private async repairDirectRoomMappings(chats: ActiveChat[]): Promise { + const mappings: Array<{ recipientMxid: string; roomId: string }> = []; + const seen = new Set(); + + for (const chat of chats) { + if (!chat.roomId) continue; + const rawT2 = this.extractT2(chat); + const persistedT2 = rawT2 + ? `t2_${rawT2}` + : this.redditUsersRepo?.getByUsername(chat.username)?.t2Id; + if (!persistedT2) continue; + + const recipientMxid = MatrixClient.t2ToMxid(persistedT2); + const key = `${recipientMxid}|${chat.roomId}`; + if (seen.has(key)) continue; + seen.add(key); + mappings.push({ recipientMxid, roomId: chat.roomId }); + } + + if (mappings.length === 0) return; + try { + const repaired = await this.withAuth(() => + this.matrix.repairDirectRoomMappings(mappings), + ); + if (repaired > 0) { + this.logger.info(`[chat] repaired ${repaired} missing direct-room mapping(s)`); + } + } catch (error) { + // Sync remains useful even if Reddit temporarily rejects account-data + // maintenance. A later delta/full sync will try the additive repair again. + this.logger.warn('[chat] direct-room mapping repair failed:', error); + } + } + private scrapeMessageEvents(events: MatrixTimelineEvent[]): ScrapedChatMessage[] { return events .filter((e) => e.type === 'm.room.message') diff --git a/src/services/monitor.ts b/src/services/monitor.ts index a034056..9218688 100644 --- a/src/services/monitor.ts +++ b/src/services/monitor.ts @@ -36,9 +36,12 @@ export class MonitorService { this.logger.debug(`Checking r/${subreddit.name} (${subreddit.type}) for new posts...`); const posts = await this.redditApi.getNewPosts(subreddit.name, 25, subreddit.type); - // Filter for valid, unprocessed posts - const validPosts: RedditPost[] = []; - for (const post of posts) { + // Reddit normally returns /new in descending order, but sort explicitly + // so we can stop as soon as the first fully eligible candidate is found. + // Previously we screened the entire batch before choosing one, causing up + // to two profile requests for every otherwise-valid post in the listing. + const newestFirst = [...posts].sort((a, b) => b.created_utc - a.created_utc); + for (const post of newestFirst) { if (this.postsRepo.isProcessed(post.id)) { continue; } @@ -48,6 +51,23 @@ export class MonitorService { continue; } + if ( + this.config.reddit.filterSellers && + (this.config.reddit.sellerKeywords.length > 0 || this.config.reddit.sellerDomains.length > 0) + ) { + const isSeller = await this.redditApi.isSellerProfile( + post.author, + this.config.reddit.sellerKeywords, + this.config.reddit.sellerDomains, + post, + ); + if (isSeller) { + this.logger.info(`Skipping post ${post.id} from u/${post.author} - seller signals detected`); + this.recordRejection(post, subreddit.type, 'Seller signals detected'); + continue; + } + } + // Chat subs almost always have a self-declared gender tag in the // title/body (e.g. [25F], M4F). Drop posts that don't match the // configured targetGenders here so they never enter the processor. @@ -55,41 +75,33 @@ export class MonitorService { const tag = parseGenderTag(`${post.title}\n${post.selftext ?? ''}`); if (!tag) { this.logger.debug(`Skipping chat post ${post.id} - no gender tag in title/body`); + this.recordRejection(post, subreddit.type, 'No gender tag in title/body'); continue; } if (!targetGenders.includes(tag)) { this.logger.debug( `Skipping chat post ${post.id} - gender tag "${tag}" not in target list [${targetGenders.join(', ')}]`, ); + this.recordRejection(post, subreddit.type, `Gender tag "${tag}" not targeted`); continue; } } - validPosts.push(post); - } - - if (validPosts.length > 0) { - // Sort by creation time (newest first) - validPosts.sort((a, b) => b.created_utc - a.created_utc); - - if (prioritizeRecent) { - const recent = validPosts.find((p) => p.created_utc >= recentCutoffSec); - if (recent) { - this.logger.info(`Found ${validPosts.length} new posts in r/${subreddit.name} (recent match)`); - return [recent]; - } - // Remember the freshest non-recent candidate as a fallback and - // keep scanning other subs for an actually-recent post. - if (!fallback || validPosts[0].created_utc > fallback.created_utc) { - fallback = validPosts[0]; - } - } else { - this.logger.info(`Found ${validPosts.length} new posts in r/${subreddit.name}`); - // Return just the first (newest) valid post - // This ensures we switch to a different random subreddit on the next poll cycle - // instead of processing a batch from the same subreddit - return [validPosts[0]]; + // At this point the candidate has passed every check. Select it now + // rather than spending more Reddit requests screening users that + // cannot be acted on during this poll cycle. + if (!prioritizeRecent || post.created_utc >= recentCutoffSec) { + this.logger.info( + `Found eligible post in r/${subreddit.name}${prioritizeRecent ? ' (recent match)' : ''}`, + ); + return [post]; } + + // This is the newest eligible post in this subreddit. Keep it as a + // possible fallback, then move to the next subreddit to look for a + // recent candidate. Older posts in this batch cannot improve it. + if (!fallback || post.created_utc > fallback.created_utc) fallback = post; + break; } } catch (error) { if (error instanceof RedditRateLimitError) throw error; @@ -160,4 +172,22 @@ export class MonitorService { private sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } + + /** Persist a completed screening decision so later polls do not repeat it. */ + private recordRejection( + post: RedditPost, + subredditType: 'image' | 'chat', + reason: string, + ): void { + this.postsRepo.insert({ + id: post.id, + subreddit: post.subreddit, + author: post.author, + title: post.title, + postUrl: this.redditApi.getPostUrl(post), + imageUrl: subredditType === 'image' ? this.redditApi.getImageUrl(post) : null, + processedAt: Date.now(), + }); + this.postsRepo.setError(post.id, reason); + } } diff --git a/src/services/reddit-session-importer.ts b/src/services/reddit-session-importer.ts index 7b20e25..f015e66 100644 --- a/src/services/reddit-session-importer.ts +++ b/src/services/reddit-session-importer.ts @@ -2,12 +2,16 @@ import type { Cookie } from 'playwright'; import type { MatrixClient } from './chat/matrix/client.js'; import type { MatrixCredentials } from './chat/matrix/types.js'; import type { AccountAuthContext } from './account-auth-context.js'; +import { + assertMatchingAccountIdentity, + normalizeRedditAccountId, + type RedditAccountIdentity, +} from './account-auth-context.js'; interface BrowserSessionTarget { importRedditCookies(cookies: Cookie[]): Promise; isSessionValid(): Promise; - getAuthenticatedUsername(): Promise; - getAuthenticatedIdentity?(): Promise<{ username: string; accountId: string } | null>; + getAuthenticatedIdentity(): Promise; } interface ChatSessionTarget { @@ -28,11 +32,6 @@ export interface VerifiedRedditSessionStore { }): void; } -function matrixRedditAccountId(matrixUserId: string): string | null { - const match = matrixUserId.match(/^@t2_([^:]+):reddit\.com$/i); - return match?.[1]?.toLowerCase() ?? null; -} - export class RedditSessionImporter { constructor( private readonly browser: BrowserSessionTarget, @@ -63,31 +62,37 @@ export class RedditSessionImporter { } await this.browser.importRedditCookies(cookies); - if (!(await this.browser.isSessionValid())) { - throw new Error('Imported cookies did not authenticate with Reddit'); - } - const identity = this.browser.getAuthenticatedIdentity - ? await this.browser.getAuthenticatedIdentity() - : null; - const redditUsername = identity?.username ?? await this.browser.getAuthenticatedUsername(); - if (!redditUsername) throw new Error('Reddit did not return an authenticated account identity'); - if (identity) { - const matrixAccountId = matrixRedditAccountId(userId); - if (!matrixAccountId || matrixAccountId !== identity.accountId.toLowerCase().replace(/^t2_/, '')) { - throw new Error('Reddit cookies and Matrix credentials belong to different accounts'); + let redditIdentity: RedditAccountIdentity; + try { + if (!(await this.browser.isSessionValid())) { + throw new Error('Imported cookies did not authenticate with Reddit'); } + const identity = await this.browser.getAuthenticatedIdentity(); + if (!identity) throw new Error('Reddit did not return an authenticated account identity'); + redditIdentity = identity; + assertMatchingAccountIdentity({ + accountId: identity.accountId, + redditUsername: identity.username, + matrixUserId: userId, + }); + } catch (error) { + this.accountAuth.invalidate(error instanceof Error ? error.message : String(error)); + throw error; } - this.saveCredentials(credentials); this.chat.setMatrixClient(matrixClient); const account = { accountId: userId, - redditUsername, + redditUsername: redditIdentity.username, matrixUserId: userId, }; + this.accountAuth.markAuthenticated({ + accountId: normalizeRedditAccountId(redditIdentity.accountId), + redditUsername: redditIdentity.username, + matrixUserId: userId, + }); this.sessionStore?.save({ ...account, cookies, credentials }); await this.chat.switchAccount?.(account.accountId); - this.accountAuth.markAuthenticated(account); return account; } } diff --git a/src/utils/json-safe.ts b/src/utils/json-safe.ts new file mode 100644 index 0000000..ebfd803 --- /dev/null +++ b/src/utils/json-safe.ts @@ -0,0 +1,93 @@ +export type JsonPrimitive = string | number | boolean | null; +export type JsonSafeValue = + | JsonPrimitive + | JsonSafeValue[] + | { [key: string]: JsonSafeValue }; + +const REDACTED = '[Redacted]'; +const CIRCULAR = '[Circular]'; +const MAX_DEPTH = 6; +const MAX_COLLECTION_ENTRIES = 100; + +function isSensitiveKey(key: string): boolean { + return /^(?:authorization|cookie|set-cookie|password|passwd|secret|token|access[_-]?token|refresh[_-]?token|api[_-]?key)$/i.test(key); +} + +function constructorLabel(value: object): string { + const constructorName = value.constructor?.name; + return constructorName ? `[${constructorName}]` : '[Object]'; +} + +/** + * Convert arbitrary diagnostic metadata into a bounded, JSON-safe value. + * Errors intentionally expose only useful diagnostics; request/response and + * socket internals can be circular and may contain credentials. + */ +export function toJsonSafe( + value: unknown, + depth = 0, + ancestors = new WeakSet(), +): JsonSafeValue | undefined { + if (value === undefined) return undefined; + if (value === null || typeof value === 'string' || typeof value === 'boolean') return value; + if (typeof value === 'number') return Number.isFinite(value) ? value : String(value); + if (typeof value === 'bigint') return value.toString(); + if (typeof value === 'symbol') return value.description ? `[Symbol: ${value.description}]` : '[Symbol]'; + if (typeof value === 'function') return `[Function: ${value.name || 'anonymous'}]`; + + if (value instanceof Date) { + return Number.isNaN(value.getTime()) ? '[Invalid Date]' : value.toISOString(); + } + if (value instanceof Error) { + const result: { [key: string]: JsonSafeValue } = { + name: value.name, + message: value.message, + }; + if (value.stack) result.stack = value.stack; + const code = Reflect.get(value, 'code'); + if (typeof code === 'string' || typeof code === 'number') result.code = code; + if (value.cause !== undefined && depth < MAX_DEPTH) { + const cause = toJsonSafe(value.cause, depth + 1, ancestors); + if (cause !== undefined) result.cause = cause; + } + return result; + } + if (depth >= MAX_DEPTH) return constructorLabel(value); + if (ancestors.has(value)) return CIRCULAR; + + ancestors.add(value); + try { + if (Array.isArray(value)) { + const items = value + .slice(0, MAX_COLLECTION_ENTRIES) + .map((item) => toJsonSafe(item, depth + 1, ancestors) ?? null); + if (value.length > MAX_COLLECTION_ENTRIES) items.push(`[Truncated ${value.length - MAX_COLLECTION_ENTRIES} items]`); + return items; + } + + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + return constructorLabel(value); + } + + const result: { [key: string]: JsonSafeValue } = {}; + const keys = Object.keys(value).slice(0, MAX_COLLECTION_ENTRIES); + for (const key of keys) { + if (isSensitiveKey(key)) { + result[key] = REDACTED; + continue; + } + try { + const child = toJsonSafe(Reflect.get(value, key), depth + 1, ancestors); + if (child !== undefined) result[key] = child; + } catch { + result[key] = '[Unreadable]'; + } + } + const omitted = Object.keys(value).length - keys.length; + if (omitted > 0) result.__truncated__ = `${omitted} properties omitted`; + return result; + } finally { + ancestors.delete(value); + } +} diff --git a/src/web/log-broadcaster.ts b/src/web/log-broadcaster.ts index 094b8a8..e2b024a 100644 --- a/src/web/log-broadcaster.ts +++ b/src/web/log-broadcaster.ts @@ -6,6 +6,7 @@ import { RealtimeEventHub, } from './events/index.js'; import { createExpressSseHandler } from './events/express-sse-adapter.js'; +import { toJsonSafe } from '../utils/json-safe.js'; const MAX_LOGS = 500; const INITIAL_LOGS_ON_CONNECT = 50; @@ -28,14 +29,15 @@ export class LogBroadcaster { } push(level: LogEntry['level'], message: string, data?: unknown): void { + const safeData = toJsonSafe(data); const realtimeEvent = this.eventHub.closed ? undefined - : this.eventHub.publish('log.created', { level, message, data }); + : this.eventHub.publish('log.created', { level, message, data: safeData }); const entry: LogEntry = { timestamp: realtimeEvent ? Date.parse(realtimeEvent.occurredAt) : Date.now(), level, message, - data, + data: safeData, }; this.entries.push(entry); if (this.entries.length > MAX_LOGS) { diff --git a/src/web/routes/conversations.ts b/src/web/routes/conversations.ts index a827ef0..bdffa2a 100644 --- a/src/web/routes/conversations.ts +++ b/src/web/routes/conversations.ts @@ -343,11 +343,18 @@ export function createConversationsRouter(ctx: WebContext): Router { ctx.activity.push('sent', `Sent chat reply to u/${conversation.username}`, conversation.username); } ctx.broadcaster.push('info', `Chat reply sent to u/${conversation.username}`); + try { + // Wait for Matrix /sync to merge the sent event into the cached + // message history. The dashboard invalidates its queries when this + // request completes, so returning earlier can immediately re-read + // the stale pre-send conversation. + await chatService.refreshTopChats(5); + } catch (err) { + // Delivery is already confirmed by Matrix's send response. A sync + // failure must not turn a successful send into a reported failure. + ctx.logger.error('Failed to refresh chats after send-reply:', err); + } res.json({ success: true }); - - chatService.refreshTopChats(5).catch((err) => - ctx.logger.error('Failed to refresh chats after send-reply:', err), - ); } else { res.status(500).json({ error: 'Failed to send chat message' }); } diff --git a/tests/account-auth-context.test.ts b/tests/account-auth-context.test.ts index f158eed..d8a3e8a 100644 --- a/tests/account-auth-context.test.ts +++ b/tests/account-auth-context.test.ts @@ -10,7 +10,7 @@ describe('AccountAuthContext', () => { const refreshMatrixCredentials = vi.fn(); await expect(authenticateCurrentAccount({ - getRedditUsername: vi.fn().mockResolvedValue(null), + getRedditIdentity: vi.fn().mockResolvedValue(null), refreshMatrixCredentials, })).resolves.toBeNull(); @@ -20,7 +20,7 @@ describe('AccountAuthContext', () => { it('fails closed until Reddit and Matrix resolve to one authenticated account', async () => { const auth = new AccountAuthContext(createMockLogger()); const authenticate = vi.fn().mockResolvedValue({ - accountId: '@t2_account:reddit.com', + accountId: 't2_account', redditUsername: 'account', matrixUserId: '@t2_account:reddit.com', }); @@ -34,7 +34,7 @@ describe('AccountAuthContext', () => { expect(auth.isFullyAuthenticated()).toBe(true); expect(auth.getSnapshot()).toMatchObject({ status: 'authenticated', - accountId: '@t2_account:reddit.com', + accountId: 't2_account', }); }); @@ -52,7 +52,7 @@ describe('AccountAuthContext', () => { const second = auth.ensureFullyAuthenticated(); await vi.waitFor(() => expect(authenticate).toHaveBeenCalledOnce()); resolveAuth?.({ - accountId: '@t2_account:reddit.com', + accountId: 't2_account', redditUsername: 'account', matrixUserId: '@t2_account:reddit.com', }); @@ -63,7 +63,7 @@ describe('AccountAuthContext', () => { it('invalidates the whole account context when either login layer expires', async () => { const auth = new AccountAuthContext(createMockLogger()); auth.markAuthenticated({ - accountId: '@t2_account:reddit.com', + accountId: 't2_account', redditUsername: 'account', matrixUserId: '@t2_account:reddit.com', }); @@ -86,7 +86,7 @@ describe('AccountAuthContext', () => { now: () => now, }); auth.markAuthenticated({ - accountId: '@t2_account:reddit.com', + accountId: 't2_account', redditUsername: 'account', matrixUserId: '@t2_account:reddit.com', }); @@ -115,4 +115,20 @@ describe('AccountAuthContext', () => { await auth.ensureFullyAuthenticated(); expect(authenticate).toHaveBeenCalledTimes(2); }); + + it('fails closed when Reddit and Matrix belong to different accounts', async () => { + const auth = new AccountAuthContext(createMockLogger()); + auth.setAuthenticator(vi.fn().mockResolvedValue({ + accountId: 't2_cookie', + redditUsername: 'cookie-account', + matrixUserId: '@t2_chat:reddit.com', + })); + + await expect(auth.ensureFullyAuthenticated()).resolves.toBe(false); + expect(auth.getSnapshot()).toMatchObject({ + status: 'login-required', + accountId: null, + reason: expect.stringMatching(/does not match Matrix account/), + }); + }); }); diff --git a/tests/browser-client.test.ts b/tests/browser-client.test.ts index dcddd27..3709023 100644 --- a/tests/browser-client.test.ts +++ b/tests/browser-client.test.ts @@ -36,10 +36,10 @@ describe('BrowserClient', () => { expect(JSON.stringify(logger)).not.toContain('secret'); }); - it('reads the username from Reddit\'s nested me.json account response', async () => { + it('reads the username and stable account ID from Reddit\'s nested me.json response', async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, - json: async () => ({ kind: 't2', data: { name: 'bot-account' } }), + json: async () => ({ kind: 't2', data: { name: 'bot-account', id: 'abc123' } }), }); vi.stubGlobal('fetch', fetchMock); const page = { @@ -50,6 +50,10 @@ describe('BrowserClient', () => { const client = new BrowserClient(getTestConfig(), createLogger(), runtime); await expect(client.getAuthenticatedUsername()).resolves.toBe('bot-account'); + await expect(client.getAuthenticatedIdentity()).resolves.toEqual({ + username: 'bot-account', + accountId: 't2_abc123', + }); expect(fetchMock).toHaveBeenCalledWith( 'https://www.reddit.com/api/me.json', { credentials: 'include' }, diff --git a/tests/chat-service-direct-room-repair.test.ts b/tests/chat-service-direct-room-repair.test.ts new file mode 100644 index 0000000..f045d93 --- /dev/null +++ b/tests/chat-service-direct-room-repair.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it, vi } from 'vitest'; +import os from 'os'; +import path from 'path'; +import type { RedditUsersRepository } from '../src/database/repositories.js'; +import { ChatCache } from '../src/services/chat/cache.js'; +import { ChatService } from '../src/services/chat/service.js'; +import { MatrixClient } from '../src/services/chat/matrix/client.js'; +import { createMockLogger } from './mockLogger.js'; +import { authenticatedAccountAuth } from './testUtils.js'; + +const creds = { + homeserver: 'https://matrix.example.test', + accessToken: 'tok', + userId: '@t2_me:reddit.com', + capturedAt: Date.now(), +}; + +describe('ChatService direct-room repair', () => { + it('repairs cached outbound rooms during delta sync', async () => { + const logger = createMockLogger(); + const matrix = new MatrixClient(creds, logger, { + minRequestIntervalMs: 0, + fetchImpl: vi.fn(), + }); + matrix.sync = vi.fn().mockResolvedValue({ next_batch: 'next', rooms: { join: {} } }); + matrix.repairDirectRoomMappings = vi.fn().mockResolvedValue(1); + + const users = { + getAll: () => [{ t2Id: 't2_alice', username: 'alice' }], + getByUsername: () => ({ t2Id: 't2_alice', username: 'alice' }), + upsert: vi.fn(), + } as unknown as RedditUsersRepository; + + const cache = new ChatCache( + logger, + path.join(os.tmpdir(), `chat-direct-repair-${Date.now()}-${Math.random()}.json`), + ); + cache.set([{ + username: 'alice', + roomName: 'alice', + lastMessageTime: Date.now(), + lastMessageSnippet: 'hello', + isFromUs: true, + unread: false, + url: 'https://chat.reddit.com/room/room', + roomId: '!room:reddit.com', + messages: [], + }]); + + const service = new ChatService( + matrix, + logger, + undefined, undefined, undefined, undefined, + cache, + undefined, + users, + authenticatedAccountAuth, + ); + + await service.refreshTopChats(); + + expect(matrix.repairDirectRoomMappings).toHaveBeenCalledWith([{ + recipientMxid: '@t2_alice:reddit.com', + roomId: '!room:reddit.com', + }]); + }); +}); diff --git a/tests/chat-service-text-sync.test.ts b/tests/chat-service-text-sync.test.ts new file mode 100644 index 0000000..ae4bd56 --- /dev/null +++ b/tests/chat-service-text-sync.test.ts @@ -0,0 +1,96 @@ +import fs from 'fs/promises'; +import os from 'os'; +import path from 'path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ChatCache } from '../src/services/chat/cache.js'; +import { MatrixClient } from '../src/services/chat/matrix/client.js'; +import { ChatService } from '../src/services/chat/service.js'; +import { createMockLogger } from './mockLogger.js'; +import { authenticatedAccountAuth } from './testUtils.js'; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((directory) => + fs.rm(directory, { recursive: true, force: true }), + )); +}); + +describe('ChatService text delta sync', () => { + it('merges a sent text event into conversation history without a media service', async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'chat-text-sync-')); + temporaryDirectories.push(directory); + const cacheFile = path.join(directory, 'chat-cache.json'); + const now = Date.now(); + await fs.writeFile(cacheFile, JSON.stringify({ + version: 1, + savedAt: now, + chats: [{ + username: 'alice', + roomName: 'alice', + roomId: '!alice:example.test', + lastMessageTime: now - 1_000, + lastMessageSnippet: 'hello', + isFromUs: false, + unread: false, + messages: [{ sender: 'alice', text: 'hello', timestamp: now - 1_000, isSelf: false }], + }], + nextBatch: 's1', + lastDeltaSyncAt: now, + }), 'utf-8'); + + const logger = createMockLogger(); + const matrix = new MatrixClient({ + homeserver: 'https://matrix.example.test', + accessToken: 'token', + userId: '@me:example.test', + capturedAt: now, + }, logger, { minRequestIntervalMs: 0 }); + matrix.sync = vi.fn(async () => ({ + next_batch: 's2', + rooms: { + join: { + '!alice:example.test': { + timeline: { + events: [{ + event_id: '$sent', + type: 'm.room.message', + sender: '@me:example.test', + origin_server_ts: now, + content: { msgtype: 'm.text', body: 'AI-assisted reply' }, + }], + }, + }, + }, + }, + })) as typeof matrix.sync; + + const cache = new ChatCache(logger, cacheFile); + const service = new ChatService( + matrix, + logger, + undefined, + undefined, + undefined, + undefined, + cache, + undefined, + undefined, + authenticatedAccountAuth, + ); + await service.loadCache(); + + await service.refreshTopChats(); + + const [conversation] = await service.getCachedConversations(); + expect(conversation.messages).toEqual([ + { sender: 'alice', text: 'hello', timestamp: now - 1_000, isSelf: false }, + { sender: 'Me', text: 'AI-assisted reply', timestamp: now, isSelf: true }, + ]); + + const persisted = JSON.parse(await fs.readFile(cacheFile, 'utf-8')) as { + chats: Array<{ messages: unknown[] }>; + }; + expect(persisted.chats[0].messages).toHaveLength(2); + }); +}); diff --git a/tests/dashboard/conversations.test.tsx b/tests/dashboard/conversations.test.tsx index 999d875..a4c60dd 100644 --- a/tests/dashboard/conversations.test.tsx +++ b/tests/dashboard/conversations.test.tsx @@ -182,6 +182,14 @@ describe('ConversationsPage', () => { 'src', '/media/received%20image.jpg', ); + expect(screen.getByRole('link', { name: 'View original image received from u/alice' })).toHaveAttribute( + 'href', + '/media/received%20image.jpg', + ); + expect(screen.getByRole('link', { name: 'View original image received from u/alice' })).toHaveAttribute( + 'target', + '_blank', + ); }); it('turns a suggestion into an editable draft and confirms sending without losing failed input', async () => { diff --git a/tests/log-broadcaster.test.ts b/tests/log-broadcaster.test.ts index 16225d8..f7fd448 100644 --- a/tests/log-broadcaster.test.ts +++ b/tests/log-broadcaster.test.ts @@ -34,4 +34,37 @@ describe('LogBroadcaster realtime bridge', () => { expect.objectContaining({ timestamp: occurredAt.getTime() }), ]); }); + + it('sanitizes circular network errors before buffering or publishing them', () => { + const hub = new RealtimeEventHub({ capacity: 10 }); + const received: RealtimeEvent[] = []; + hub.subscribe((event) => received.push(event)); + const broadcaster = new LogBroadcaster(hub); + const socket: Record = { constructorName: 'TLSSocket' }; + const parser: Record = { socket }; + socket.parser = parser; + const networkError = Object.assign(new Error('upstream request failed'), { + code: 'ECONNRESET', + request: socket, + }); + let serializedRecent = ''; + + broadcaster.push('error', 'Matrix request failed', { + error: networkError, + socket, + authorization: 'Bearer must-not-leak', + }); + broadcaster.recent( + { query: {} } as never, + { json: (value: unknown) => { serializedRecent = JSON.stringify(value); } } as never, + ); + + expect(() => JSON.stringify(received)).not.toThrow(); + expect(() => JSON.parse(serializedRecent)).not.toThrow(); + expect(serializedRecent).toContain('ECONNRESET'); + expect(serializedRecent).toContain('[Circular]'); + expect(serializedRecent).toContain('[Redacted]'); + expect(serializedRecent).not.toContain('must-not-leak'); + expect(serializedRecent).not.toContain('"request"'); + }); }); diff --git a/tests/matrix-client.test.ts b/tests/matrix-client.test.ts index b36457b..fa1ca6e 100644 --- a/tests/matrix-client.test.ts +++ b/tests/matrix-client.test.ts @@ -93,6 +93,65 @@ describe('MatrixClient HTTP timeout', () => { }, 10_000); }); +describe('MatrixClient direct room registration', () => { + it('creates rooms with the direct-message flag', async () => { + let requestBody: unknown; + const client = new MatrixClient(creds, createMockLogger(), { + minRequestIntervalMs: 0, + fetchImpl: vi.fn(async (_url, init) => { + requestBody = JSON.parse(String(init?.body)); + return new Response(JSON.stringify({ room_id: '!new:example.test' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }), + }); + + await expect(client.createDirectRoom('@them:example.test')) + .resolves.toBe('!new:example.test'); + expect(requestBody).toEqual({ + preset: 'reddit_dm', + invite: ['@them:example.test'], + is_direct: true, + }); + }); + + it('additively repairs m.direct account data without removing existing rooms', async () => { + const requests: Array<{ method: string; url: string; body?: unknown }> = []; + const client = new MatrixClient(creds, createMockLogger(), { + minRequestIntervalMs: 0, + fetchImpl: vi.fn(async (url, init) => { + requests.push({ + method: init?.method ?? 'GET', + url: String(url), + ...(init?.body ? { body: JSON.parse(String(init.body)) } : {}), + }); + if (init?.method === 'GET') { + return new Response(JSON.stringify({ + '@existing:example.test': ['!old:example.test'], + '@them:example.test': ['!already:example.test'], + }), { status: 200, headers: { 'content-type': 'application/json' } }); + } + return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } }); + }), + }); + + await expect(client.repairDirectRoomMappings([ + { recipientMxid: '@them:example.test', roomId: '!already:example.test' }, + { recipientMxid: '@them:example.test', roomId: '!new:example.test' }, + ])).resolves.toBe(1); + + expect(requests).toHaveLength(2); + expect(requests[1]).toMatchObject({ + method: 'PUT', + body: { + '@existing:example.test': ['!old:example.test'], + '@them:example.test': ['!already:example.test', '!new:example.test'], + }, + }); + }); +}); + describe('MatrixClient authentication classification', () => { it('does not classify a room-level 403 as an expired account token', async () => { const client = new MatrixClient(creds, createMockLogger(), { diff --git a/tests/monitor.test.ts b/tests/monitor.test.ts index 6aefcf4..38d9024 100644 --- a/tests/monitor.test.ts +++ b/tests/monitor.test.ts @@ -105,6 +105,31 @@ describe('MonitorService', () => { expect(messagesRepo.hasSentTo).toHaveBeenCalledWith('already_msgd'); }); + it('records seller posts as processed and continues to a clean candidate', async () => { + const sellerPost: RedditPost = { + id: 't3_seller', subreddit: 'sub1', author: 'seller', title: 'My page', + selftext: 'OnlyFans in bio', url: 'http://example.com/1.jpg', permalink: '/r/sub1/1', thumbnail: '', + is_self: false, created_utc: 2, over_18: true, spoiler: false, + }; + const cleanPost: RedditPost = { + id: 't3_clean', subreddit: 'sub1', author: 'clean', title: 'Hello', + url: 'http://example.com/2.jpg', permalink: '/r/sub1/2', thumbnail: '', + is_self: false, created_utc: 1, over_18: true, spoiler: false, + }; + const redditApi = createMockRedditApi([sellerPost, cleanPost]); + vi.mocked(redditApi.isSellerProfile).mockImplementation(async (username) => username === 'seller'); + const postsRepo = createMockPostsRepo(); + const config = getTestConfig({ reddit: { subreddits: [{ name: 'sub1', type: 'image' }] } }); + monitor = new MonitorService(redditApi, postsRepo, createMockMessagesRepo(), config, logger, 0); + + await expect(monitor.getUnprocessedPosts()).resolves.toEqual([cleanPost]); + expect(redditApi.isSellerProfile).toHaveBeenCalledWith( + 'seller', config.reddit.sellerKeywords, config.reddit.sellerDomains, sellerPost, + ); + expect(postsRepo.insert).toHaveBeenCalledWith(expect.objectContaining({ id: 't3_seller' })); + expect(postsRepo.setError).toHaveBeenCalledWith('t3_seller', 'Seller signals detected'); + }); + it('returns newest post when multiple valid posts exist', async () => { const post1: RedditPost = { id: 't3_1', subreddit: 'sub1', author: 'user1', title: 'Older', @@ -127,6 +152,40 @@ describe('MonitorService', () => { expect(result.length).toBe(1); expect(result[0].id).toBe('t3_2'); + expect(redditApi.isSellerProfile).toHaveBeenCalledTimes(1); + expect(redditApi.isSellerProfile).toHaveBeenCalledWith( + 'user2', config.reddit.sellerKeywords, config.reddit.sellerDomains, post2, + ); + }); + + it('finishes the seller and gender checks for one candidate before moving to the next', async () => { + const newer: RedditPost = { + id: 't3_newer', subreddit: 'chat-sub', author: 'newer', title: '[30M] hello', + url: '', permalink: '/r/chat-sub/newer', thumbnail: '', is_self: true, + created_utc: 2, over_18: false, spoiler: false, + }; + const eligible: RedditPost = { + id: 't3_eligible', subreddit: 'chat-sub', author: 'eligible', title: '[25F] hello', + url: '', permalink: '/r/chat-sub/eligible', thumbnail: '', is_self: true, + created_utc: 1, over_18: false, spoiler: false, + }; + const redditApi = createMockRedditApi([eligible, newer]); + const config = getTestConfig({ + reddit: { + subreddits: [{ name: 'chat-sub', type: 'chat' }], + targetGenders: ['female'], + }, + }); + const postsRepo = createMockPostsRepo(); + monitor = new MonitorService( + redditApi, postsRepo, createMockMessagesRepo(), config, logger, 0, + ); + + await expect(monitor.getUnprocessedPosts()).resolves.toEqual([eligible]); + expect(vi.mocked(redditApi.isSellerProfile).mock.calls.map(([username]) => username)) + .toEqual(['newer', 'eligible']); + expect(postsRepo.insert).toHaveBeenCalledWith(expect.objectContaining({ id: 't3_newer' })); + expect(postsRepo.setError).toHaveBeenCalledWith('t3_newer', 'Gender tag "male" not targeted'); }); it('tries next subreddit when first has no valid posts', async () => { diff --git a/tests/reddit-api.test.ts b/tests/reddit-api.test.ts index 911f2ca..7805841 100644 --- a/tests/reddit-api.test.ts +++ b/tests/reddit-api.test.ts @@ -242,6 +242,38 @@ describe('RedditApiClient', () => { }); describe('isSellerProfile', () => { + it('returns true for a seller signal in the current post without fetching the profile', async () => { + const get = vi.fn(); + vi.mocked(axios.create).mockReturnValue({ get } as any); + const client2 = new RedditApiClient(getTestConfig(), logger); + const post = { + id: 'post-1', name: 't3_post-1', subreddit: 'test', author: 'testuser', + title: 'Come and subscribe', selftext: 'My page is at https://onlyfans.com/example', + url: 'https://reddit.com/post-1', permalink: '/r/test/post-1', thumbnail: '', + is_self: true, created_utc: 1, over_18: true, spoiler: false, + }; + + await expect(client2.isSellerProfile('testuser', ['onlyfans'], ['onlyfans.com'], post)) + .resolves.toBe(true); + expect(get).not.toHaveBeenCalled(); + }); + + it('detects seller signals in recent profile activity when the bio is clean', async () => { + const get = vi.fn() + .mockResolvedValueOnce({ data: { data: { subreddit: { public_description: 'Just me' } } } }) + .mockResolvedValueOnce({ + data: { data: { children: [{ data: { title: 'New content', subreddit: 'OnlyFansPromotions' } }] } }, + }); + vi.mocked(axios.create).mockReturnValue({ get } as any); + const client2 = new RedditApiClient(getTestConfig(), logger); + + await expect(client2.isSellerProfile('testuser', ['onlyfans'])).resolves.toBe(true); + expect(get).toHaveBeenNthCalledWith(2, '/user/testuser/overview.json', { + params: { limit: 25, raw_json: 1 }, + headers: {}, + }); + }); + it('returns true when seller keyword matches', async () => { const mockResponse = { data: { @@ -332,6 +364,18 @@ describe('RedditApiClient', () => { expect(result).toBe(false); }); + it('propagates profile rate limits so the main loop backs off', async () => { + const error = { + isAxiosError: true, + response: { status: 429, headers: { 'retry-after': '42' } }, + }; + vi.mocked(axios.create).mockReturnValue({ get: vi.fn().mockRejectedValue(error) } as any); + + const client2 = new RedditApiClient(getTestConfig(), logger); + await expect(client2.isSellerProfile('testuser', ['onlyfans'])) + .rejects.toMatchObject({ retryAfterMs: 42_000 }); + }); + it('returns true when a domain in the bio matches sellerDomains', async () => { const mockResponse = { data: { diff --git a/tests/reddit-session-exporter.test.ts b/tests/reddit-session-exporter.test.ts index 66c00c6..f283897 100644 --- a/tests/reddit-session-exporter.test.ts +++ b/tests/reddit-session-exporter.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest'; -import { createSessionExportDownload } from '../extensions/reddit-session-exporter/session-export.js'; +import { + assertMatchingSessionIdentity, + createSessionExportDownload, +} from '../extensions/reddit-session-exporter/session-export.js'; describe('createSessionExportDownload', () => { it('creates a JSON data URL that Chrome can download from a service worker', () => { @@ -49,3 +52,19 @@ describe('createSessionExportDownload', () => { expect(() => createSessionExportDownload([], null)).toThrow(/Reddit Chat/i); }); }); + +describe('assertMatchingSessionIdentity', () => { + it('accepts a Matrix user bound to the current Reddit account ID', () => { + expect(() => assertMatchingSessionIdentity( + { username: 'bot-account', accountId: 'bot' }, + '@t2_bot:reddit.com', + )).not.toThrow(); + }); + + it('rejects a stale Matrix capture from another Reddit account', () => { + expect(() => assertMatchingSessionIdentity( + { username: 'bot-account', accountId: 't2_bot' }, + '@t2_other:reddit.com', + )).toThrow(/Reload Reddit Chat after switching accounts/); + }); +}); diff --git a/tests/reddit-session-importer.test.ts b/tests/reddit-session-importer.test.ts index d39f878..b924a36 100644 --- a/tests/reddit-session-importer.test.ts +++ b/tests/reddit-session-importer.test.ts @@ -18,12 +18,12 @@ describe('RedditSessionImporter', () => { const browser = { importRedditCookies: vi.fn(), isSessionValid: vi.fn(async () => true), - getAuthenticatedUsername: vi.fn(async () => 'bot-account'), + getAuthenticatedIdentity: vi.fn(async () => ({ username: 'bot-account', accountId: 't2_bot' })), }; const matrix = { whoami: vi.fn(async () => credentials.userId) }; const chat = { setMatrixClient: vi.fn() }; const save = vi.fn(); - const accountAuth = { markAuthenticated: vi.fn() }; + const accountAuth = { markAuthenticated: vi.fn(), invalidate: vi.fn() }; const importer = new RedditSessionImporter(browser, chat as never, vi.fn(() => matrix as never), save, accountAuth as never); await importer.import(cookies, credentials); @@ -33,20 +33,40 @@ describe('RedditSessionImporter', () => { expect(chat.setMatrixClient).toHaveBeenCalledWith(matrix); expect(save).toHaveBeenCalledWith(credentials); expect(accountAuth.markAuthenticated).toHaveBeenCalledWith({ - accountId: credentials.userId, + accountId: 't2_bot', redditUsername: 'bot-account', matrixUserId: credentials.userId, }); }); + it('rejects a dump whose Reddit cookies and Matrix token belong to different accounts', async () => { + const browser = { + importRedditCookies: vi.fn(), + isSessionValid: vi.fn(async () => true), + getAuthenticatedIdentity: vi.fn(async () => ({ username: 'other-account', accountId: 't2_other' })), + }; + const matrix = { whoami: vi.fn(async () => credentials.userId) }; + const chat = { setMatrixClient: vi.fn() }; + const save = vi.fn(); + const accountAuth = { markAuthenticated: vi.fn(), invalidate: vi.fn() }; + const importer = new RedditSessionImporter(browser, chat as never, vi.fn(() => matrix as never), save, accountAuth as never); + + await expect(importer.import(cookies, credentials)).rejects.toThrow(/does not match Matrix account/); + + expect(accountAuth.invalidate).toHaveBeenCalledWith(expect.stringMatching(/does not match Matrix account/)); + expect(save).not.toHaveBeenCalled(); + expect(chat.setMatrixClient).not.toHaveBeenCalled(); + expect(accountAuth.markAuthenticated).not.toHaveBeenCalled(); + }); + it('rejects non-Reddit Matrix homeservers without making a request', async () => { const createMatrix = vi.fn(); const importer = new RedditSessionImporter( - { importRedditCookies: vi.fn(), isSessionValid: vi.fn(), getAuthenticatedUsername: vi.fn() }, + { importRedditCookies: vi.fn(), isSessionValid: vi.fn(), getAuthenticatedIdentity: vi.fn() }, { setMatrixClient: vi.fn() } as never, createMatrix, vi.fn(), - { markAuthenticated: vi.fn() } as never, + { markAuthenticated: vi.fn(), invalidate: vi.fn() } as never, ); await expect(importer.import(cookies, { ...credentials, homeserver: 'https://attacker.example' })) diff --git a/tests/web/conversations-route.test.ts b/tests/web/conversations-route.test.ts index 6ddd1bf..779d855 100644 --- a/tests/web/conversations-route.test.ts +++ b/tests/web/conversations-route.test.ts @@ -300,4 +300,48 @@ describe('conversations route', () => { }); expect(context.repos.messages.getLatestByRecipient).not.toHaveBeenCalled(); }); + + it('waits for the conversation delta sync after sending a reply', async () => { + const conversation = { + username: 'alice', roomName: 'alice', roomId: 'room-alice', + url: 'https://chat.reddit.com/room/room-alice', + lastMessageTime: 10, lastMessageSnippet: 'Hello', isFromUs: false, unread: false, + messages: [{ sender: 'alice', text: 'Hello', timestamp: 10, isSelf: false }], + }; + let completeSync: (() => void) | undefined; + const refreshTopChats = vi.fn(() => new Promise((resolve) => { + completeSync = resolve; + })); + const context = { + logger: { error: vi.fn() }, + activity: { push: vi.fn() }, + broadcaster: { push: vi.fn() }, + services: { chat: { + getCachedConversations: vi.fn().mockResolvedValue([conversation]), + sendChatMessage: vi.fn().mockResolvedValue(true), + refreshTopChats, + } }, + } as unknown as WebContext; + const app = express() + .use(express.json()) + .use('/api', createConversationsRouter(context)); + + let responseSettled = false; + const responsePromise = request(app, '/api/conversations/room-alice/send-reply', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message: 'AI-assisted reply' }), + }).then((response) => { + responseSettled = true; + return response; + }); + + await vi.waitFor(() => expect(refreshTopChats).toHaveBeenCalledWith(5)); + expect(responseSettled).toBe(false); + completeSync?.(); + + const response = await responsePromise; + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ success: true }); + }); });