Merge remote-tracking branch 'origin/main'

# Conflicts:
#	src/browser/client.ts
#	src/services/reddit-session-importer.ts
#	src/web/routes/conversations.ts
This commit is contained in:
jjbrunton committed 2026-08-08 20:17:19 +01:00
commit 48492d5238
30 files changed
+1023 -163

No files matched your search

@@ -38,12 +38,19 @@ function MessageHistory({ conversation }: { conversation: ConversationDetailResp
{message.media?.length ? ( {message.media?.length ? (
<div className="conversation-message-images"> <div className="conversation-message-images">
{message.media.map((media) => ( {message.media.map((media) => (
<a
aria-label={`View original image ${message.isSelf ? 'sent by you' : `received from ${redditUsernameLabel(conversation.username)}`}`}
href={mediaPath(media.localPath)}
key={`${message.timestamp}-${media.localPath}`}
rel="noreferrer"
target="_blank"
>
<img <img
alt={`Image ${message.isSelf ? 'sent by you' : `received from ${redditUsernameLabel(conversation.username)}`}`} alt={`Image ${message.isSelf ? 'sent by you' : `received from ${redditUsernameLabel(conversation.username)}`}`}
key={`${message.timestamp}-${media.localPath}`}
loading="lazy" loading="lazy"
src={mediaPath(media.localPath)} src={mediaPath(media.localPath)}
/> />
</a>
))} ))}
</div> </div>
) : null} ) : null}
@@ -60,6 +60,8 @@
.conversation-messages time { color: var(--muted); font-size: .7rem; } .conversation-messages time { color: var(--muted); font-size: .7rem; }
.conversation-messages p { margin: 0; line-height: 1.45; white-space: pre-wrap; } .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 { 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-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-empty-detail { padding: 2rem; color: var(--muted); text-align: center; }
.conversation-composer { display: grid; gap: .65rem; padding: 1rem; border-top: 1px solid var(--border); } .conversation-composer { display: grid; gap: .65rem; padding: 1rem; border-top: 1px solid var(--border); }
@@ -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. 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. 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.
@@ -1,7 +1,7 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "RedditBot Session Exporter", "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.", "description": "Exports Reddit cookies and Reddit Chat credentials for a self-hosted RedditBot dashboard.",
"permissions": ["cookies", "downloads", "storage", "webRequest"], "permissions": ["cookies", "downloads", "storage", "webRequest"],
"host_permissions": [ "host_permissions": [
@@ -1,4 +1,7 @@
import { createSessionExportDownload } from './session-export.js'; import {
assertMatchingSessionIdentity,
createSessionExportDownload,
} from './session-export.js';
const MATRIX_URLS = [ const MATRIX_URLS = [
'https://matrix.redditspace.com/*', 'https://matrix.redditspace.com/*',
@@ -27,6 +30,29 @@ chrome.webRequest.onBeforeSendHeaders.addListener(
['requestHeaders', 'extraHeaders'], ['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) { async function showError(message) {
await chrome.action.setBadgeText({ text: 'ERR' }); await chrome.action.setBadgeText({ text: 'ERR' });
await chrome.action.setBadgeBackgroundColor({ color: '#dc3545' }); await chrome.action.setBadgeBackgroundColor({ color: '#dc3545' });
@@ -48,6 +74,14 @@ chrome.action.onClicked.addListener(async () => {
const identity = await whoami.json(); const identity = await whoami.json();
if (!identity?.user_id) throw new Error('Reddit Chat did not return an account identity.'); 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({ await chrome.downloads.download({
...createSessionExportDownload(cookies, { ...matrixCapture, userId: identity.user_id }), ...createSessionExportDownload(cookies, { ...matrixCapture, userId: identity.user_id }),
saveAs: true, saveAs: true,
@@ -1,5 +1,26 @@
const REDDITBOT_SESSION_FILENAME = 'redditbot-session.json'; 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) { function isRedditDomain(domain) {
const normalised = domain.toLowerCase().replace(/^\./, ''); const normalised = domain.toLowerCase().replace(/^\./, '');
return normalised === 'reddit.com' || normalised.endsWith('.reddit.com'); return normalised === 'reddit.com' || normalised.endsWith('.reddit.com');
+8 -8
View File
@@ -37,7 +37,7 @@ import { RateLimiter } from './utils/rate-limiter.js';
import { WebServer } from './web/server.js'; import { WebServer } from './web/server.js';
import { reportReply } from './web/event-reporting.js'; import { reportReply } from './web/event-reporting.js';
import { RedditSessionImporter } from './services/reddit-session-importer.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 { ConfigStore } from './config/persistence.js';
import { RedditAccountsRepository, AccountContentRepository } from './database/account-repositories.js'; import { RedditAccountsRepository, AccountContentRepository } from './database/account-repositories.js';
import { RedditAccountsService } from './services/reddit-accounts.js'; import { RedditAccountsService } from './services/reddit-accounts.js';
@@ -199,17 +199,17 @@ export async function bootstrap(
}; };
} }
let activeMatrixClient = new MatrixClient(matrixCreds, logger); let activeMatrixClient = new MatrixClient(matrixCreds, logger);
accountAuth.setAuthenticator(async () => { accountAuth.setAuthenticator(() => authenticateCurrentAccount({
const redditUsername = await browserClient.getAuthenticatedUsername(); getRedditIdentity: () => browserClient.getAuthenticatedIdentity(),
if (!redditUsername) return null; refreshMatrixCredentials: async () => {
try { try {
const matrixUserId = await activeMatrixClient.whoami(); const userId = await activeMatrixClient.whoami();
if (!matrixUserId) return null; return userId ? { userId } : null;
return { accountId: matrixUserId, redditUsername, matrixUserId };
} catch { } catch {
return null; return null;
} }
}); },
}));
const fullyAuthenticated = redditLoggedIn && await accountAuth.ensureFullyAuthenticated(); const fullyAuthenticated = redditLoggedIn && await accountAuth.ensureFullyAuthenticated();
if (fullyAuthenticated) logger.info('Reddit and Matrix authentication verified'); if (fullyAuthenticated) logger.info('Reddit and Matrix authentication verified');
broadcaster.dashboardEvents.redditSession({ broadcaster.dashboardEvents.redditSession({
+26 -19
View File
@@ -3,6 +3,18 @@ import type { Config } from '../config/index.js';
import type { Logger } from '../utils/logger.js'; import type { Logger } from '../utils/logger.js';
import path from 'path'; import path from 'path';
import fs from 'fs'; 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 { export interface BrowserRuntime {
launchPersistentContext( launchPersistentContext(
@@ -115,17 +127,13 @@ export class BrowserClient {
/** Returns the account name from Reddit's authenticated identity endpoint. */ /** Returns the account name from Reddit's authenticated identity endpoint. */
async getAuthenticatedUsername(): Promise<string | null> { async getAuthenticatedUsername(): Promise<string | null> {
try { return (await this.getAuthenticatedIdentity())?.username ?? null;
return (await this.getRedditIdentity(await this.getPage()))?.username ?? null;
} catch {
return null;
}
} }
async getAuthenticatedIdentity(): Promise<{ username: string; accountId: string } | null> { /** Returns the username and stable t2 account ID for identity binding. */
async getAuthenticatedIdentity(): Promise<RedditAccountIdentity | null> {
try { try {
const identity = await this.getRedditIdentity(await this.getPage()); return await this.getRedditIdentity(await this.getPage());
return identity?.accountId ? { username: identity.username, accountId: identity.accountId } : null;
} catch { } catch {
return null; return null;
} }
@@ -180,25 +188,24 @@ export class BrowserClient {
return (await this.getRedditIdentity(page)) !== null; return (await this.getRedditIdentity(page)) !== null;
} }
private async getRedditIdentity(page: Page): Promise<{ username: string; accountId: string | null } | null> { private async getRedditIdentity(page: Page): Promise<RedditAccountIdentity | null> {
try { try {
return await page.evaluate(async () => { const payload: unknown = await page.evaluate(async () => {
try { try {
const res = await fetch('https://www.reddit.com/api/me.json', { credentials: 'include' }); const res = await fetch('https://www.reddit.com/api/me.json', { credentials: 'include' });
if (!res.ok) return null; if (!res.ok) return null;
const data: unknown = await res.json(); return 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;
} catch { } catch {
return null; 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 { } catch {
return null; return null;
} }
-23
View File
@@ -258,29 +258,6 @@ export async function runMainLoop(app: AppContainer): Promise<void> {
processedAt: Date.now(), 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. // Refresh examples weighted toward this post's subreddit.
const subredditExamples = responseChecker.getSuccessfulExamples(3, post.subreddit); const subredditExamples = responseChecker.getSuccessfulExamples(3, post.subreddit);
processorService.setSuccessfulExamples(subredditExamples); processorService.setSuccessfulExamples(subredditExamples);
+102 -26
View File
@@ -48,6 +48,38 @@ export interface RedditPost {
link_flair_text?: string | null; 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 // Extracts URL-ish tokens (with or without scheme) and returns the first
// domain match. Matches `host` or any subdomain (`*.host`), case-insensitive. // domain match. Matches `host` or any subdomain (`*.host`), case-insensitive.
export function findSellerDomain(text: string, domains: string[]): string | null { export function findSellerDomain(text: string, domains: string[]): string | null {
@@ -249,10 +281,28 @@ export class RedditApiClient {
async isSellerProfile( async isSellerProfile(
username: string, username: string,
sellerKeywords: string[], sellerKeywords: string[],
sellerDomains: string[] = [] sellerDomains: string[] = [],
currentPost?: RedditPost,
): Promise<boolean> { ): Promise<boolean> {
if (sellerKeywords.length === 0 && sellerDomains.length === 0) return false; 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 { try {
const cookieHeader = await this.getCookieHeader(); const cookieHeader = await this.getCookieHeader();
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
@@ -260,51 +310,77 @@ export class RedditApiClient {
headers['Cookie'] = cookieHeader; 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 }, params: { raw_json: 1 },
headers, headers,
}); });
const userData = response.data?.data; const userData = response.data?.data;
if (!userData) return false; if (userData) {
const profileText = joinText([
// Combine all text fields to scan
const textToCheck = [
userData.subreddit?.public_description || '', userData.subreddit?.public_description || '',
userData.subreddit?.title || '', userData.subreddit?.title || '',
userData.subreddit?.display_name_prefixed || '', userData.subreddit?.display_name_prefixed || '',
] ]);
.join(' ') const profileSignal = findSellerSignal(profileText, sellerKeywords, sellerDomains);
.toLowerCase(); if (profileSignal) {
for (const keyword of sellerKeywords) {
if (textToCheck.includes(keyword.toLowerCase())) {
this.logger.info( 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; 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) { } 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) { if (axios.isAxiosError(error) && error.response?.status === 404) {
this.logger.warn(`User u/${username} profile not found`); this.logger.warn(`User u/${username} profile not found`);
return false; return false;
} }
this.logger.warn(`Failed to check profile for u/${username}:`, error); 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<string, string> = {};
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 { getPostUrl(post: RedditPost): string {
+39 -10
View File
@@ -6,11 +6,18 @@ export type AccountAuthStatus =
| 'authenticated'; | 'authenticated';
export interface AuthenticatedAccount { export interface AuthenticatedAccount {
/** Reddit account fullname, e.g. `t2_abc123`. */
accountId: string; accountId: string;
redditUsername: string; redditUsername: string;
matrixUserId: string; matrixUserId: string;
} }
export interface RedditAccountIdentity {
username: string;
/** Reddit's account ID, with or without the `t2_` prefix. */
accountId: string;
}
export interface AccountAuthSnapshot { export interface AccountAuthSnapshot {
status: AccountAuthStatus; status: AccountAuthStatus;
accountId: string | null; accountId: string | null;
@@ -34,21 +41,43 @@ export interface AccountAuthContextOptions {
} }
export interface CurrentAccountAuthentication { export interface CurrentAccountAuthentication {
getRedditUsername(): Promise<string | null>; getRedditIdentity(): Promise<RedditAccountIdentity | null>;
refreshMatrixCredentials(): Promise<{ userId: string } | null>; 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( export async function authenticateCurrentAccount(
authentication: CurrentAccountAuthentication, authentication: CurrentAccountAuthentication,
): Promise<AuthenticatedAccount | null> { ): Promise<AuthenticatedAccount | null> {
const redditUsername = await authentication.getRedditUsername(); const redditIdentity = await authentication.getRedditIdentity();
if (!redditUsername) return null; if (!redditIdentity) return null;
const credentials = await authentication.refreshMatrixCredentials(); const credentials = await authentication.refreshMatrixCredentials();
if (!credentials?.userId) return null; if (!credentials?.userId) return null;
return { return {
accountId: credentials.userId, accountId: normalizeRedditAccountId(redditIdentity.accountId),
redditUsername, redditUsername: redditIdentity.username,
matrixUserId: credentials.userId, matrixUserId: credentials.userId,
}; };
} }
@@ -104,15 +133,15 @@ export class AccountAuthContext implements AccountAuthGate {
if (!account.accountId || !account.redditUsername || !account.matrixUserId) { if (!account.accountId || !account.redditUsername || !account.matrixUserId) {
throw new Error('A fully authenticated account requires Reddit and Matrix identities'); throw new Error('A fully authenticated account requires Reddit and Matrix identities');
} }
if (account.accountId !== account.matrixUserId) { assertMatchingAccountIdentity(account);
throw new Error('Authenticated account ID must match the Matrix user ID');
}
const changedAccount = this.snapshot.accountId !== account.accountId; const accountId = normalizeRedditAccountId(account.accountId);
const changedAccount = this.snapshot.accountId !== accountId;
const wasAuthenticated = this.isFullyAuthenticated(); const wasAuthenticated = this.isFullyAuthenticated();
this.snapshot = { this.snapshot = {
status: 'authenticated', status: 'authenticated',
accountId: account.accountId, accountId,
redditUsername: account.redditUsername, redditUsername: account.redditUsername,
matrixUserId: account.matrixUserId, matrixUserId: account.matrixUserId,
reason: null, reason: null,
+55 -2
View File
@@ -54,6 +54,11 @@ export interface RoomSummary {
recentEvents: MatrixTimelineEvent[]; recentEvents: MatrixTimelineEvent[];
} }
export interface DirectRoomMapping {
recipientMxid: string;
roomId: string;
}
/** /**
* Cap on how long we'll sit and sleep waiting out a 429. Anything longer * 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 * is surfaced as a `MatrixRateLimitError` so the main loop can back off
@@ -405,7 +410,7 @@ export class MatrixClient {
* "Start chat" in the UI: * "Start chat" in the UI:
* *
* POST /_matrix/client/v3/createRoom * 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 * `reddit_dm` is a Reddit-specific preset that the homeserver expands
* server-side into the right power levels, history visibility, and * server-side into the right power levels, history visibility, and
@@ -413,12 +418,53 @@ export class MatrixClient {
*/ */
async createDirectRoom(recipientMxid: string): Promise<string> { async createDirectRoom(recipientMxid: string): Promise<string> {
const res = await this.request<{ room_id: string }>('POST', '/_matrix/client/v3/createRoom', { 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}`); this.logger.debug(`[matrix] created DM room ${res.room_id} with ${recipientMxid}`);
return res.room_id; 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<number> {
if (mappings.length === 0) return 0;
let directRooms: Record<string, string[]>;
try {
directRooms = await this.request<Record<string, string[]>>(
'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<Record<string, never>>(
'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 * 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. * message. Returns the new room_id and the event_id of the message.
@@ -428,6 +474,13 @@ export class MatrixClient {
body: string, body: string,
): Promise<{ roomId: string; eventId: string }> { ): Promise<{ roomId: string; eventId: string }> {
const roomId = await this.createDirectRoom(recipientMxid); 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); const eventId = await this.sendTextMessage(roomId, body);
return { roomId, eventId }; return { roomId, eventId };
} }
+52 -6
View File
@@ -402,6 +402,7 @@ export class ChatService {
if (!chat.roomName || chat.roomName.startsWith('t2_')) chat.roomName = resolved; if (!chat.roomName || chat.roomName.startsWith('t2_')) chat.roomName = resolved;
} }
await this.cache.save(chats); await this.cache.save(chats);
await this.repairDirectRoomMappings(chats);
let done = 0; let done = 0;
for (const chat of chats) { for (const chat of chats) {
@@ -736,17 +737,21 @@ export class ChatService {
existing.set(roomId, next); existing.set(roomId, next);
} }
if (this.mediaService) {
for (const [roomId, room] of Object.entries(joined)) { for (const [roomId, room] of Object.entries(joined)) {
const scraped = this.scrapeMessageEvents(room?.timeline?.events ?? []); const scraped = this.scrapeMessageEvents(room?.timeline?.events ?? []);
const mediaMessages = scraped.filter((m) => m.mediaUrls.length > 0); if (scraped.length === 0) continue;
if (mediaMessages.length === 0) continue;
const chat = existing.get(roomId); const chat = existing.get(roomId);
if (!chat) continue; if (!chat) continue;
let newMessages: ChatMessage[] = scraped.map(
({ mediaUrls: _ignored, ...message }) => message,
);
if (this.mediaService && scraped.some((message) => message.mediaUrls.length > 0)) {
try { try {
const withMedia = await this.mediaService.attachMedia(chat, mediaMessages); newMessages = await this.mediaService.attachMedia(chat, scraped);
this.mergeNewMessages(chat, withMedia); if (newMessages.some(
if (withMedia.some((message) => !message.isSelf && (message.media?.length ?? 0) > 0)) { (message) => !message.isSelf && (message.media?.length ?? 0) > 0,
)) {
this.recordReceivedImage(chat); this.recordReceivedImage(chat);
} }
} catch (err) { } catch (err) {
@@ -755,6 +760,7 @@ export class ChatService {
); );
} }
} }
this.mergeNewMessages(chat, newMessages);
} }
const merged = Array.from(existing.values()).sort( 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 // Resolve any t2_-prefixed usernames the delta just introduced, so the
// dashboard doesn't show raw Matrix-style IDs for new rooms. // dashboard doesn't show raw Matrix-style IDs for new rooms.
await this.enrichUsernames(); await this.enrichUsernames();
await this.repairDirectRoomMappings(merged);
return 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<void> {
const mappings: Array<{ recipientMxid: string; roomId: string }> = [];
const seen = new Set<string>();
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[] { private scrapeMessageEvents(events: MatrixTimelineEvent[]): ScrapedChatMessage[] {
return events return events
.filter((e) => e.type === 'm.room.message') .filter((e) => e.type === 'm.room.message')
+56 -26
View File
@@ -36,9 +36,12 @@ export class MonitorService {
this.logger.debug(`Checking r/${subreddit.name} (${subreddit.type}) for new posts...`); this.logger.debug(`Checking r/${subreddit.name} (${subreddit.type}) for new posts...`);
const posts = await this.redditApi.getNewPosts(subreddit.name, 25, subreddit.type); const posts = await this.redditApi.getNewPosts(subreddit.name, 25, subreddit.type);
// Filter for valid, unprocessed posts // Reddit normally returns /new in descending order, but sort explicitly
const validPosts: RedditPost[] = []; // so we can stop as soon as the first fully eligible candidate is found.
for (const post of posts) { // 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)) { if (this.postsRepo.isProcessed(post.id)) {
continue; continue;
} }
@@ -48,6 +51,23 @@ export class MonitorService {
continue; 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 // 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 // title/body (e.g. [25F], M4F). Drop posts that don't match the
// configured targetGenders here so they never enter the processor. // configured targetGenders here so they never enter the processor.
@@ -55,41 +75,33 @@ export class MonitorService {
const tag = parseGenderTag(`${post.title}\n${post.selftext ?? ''}`); const tag = parseGenderTag(`${post.title}\n${post.selftext ?? ''}`);
if (!tag) { if (!tag) {
this.logger.debug(`Skipping chat post ${post.id} - no gender tag in title/body`); 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; continue;
} }
if (!targetGenders.includes(tag)) { if (!targetGenders.includes(tag)) {
this.logger.debug( this.logger.debug(
`Skipping chat post ${post.id} - gender tag "${tag}" not in target list [${targetGenders.join(', ')}]`, `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; continue;
} }
} }
validPosts.push(post); // 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];
} }
if (validPosts.length > 0) { // This is the newest eligible post in this subreddit. Keep it as a
// Sort by creation time (newest first) // possible fallback, then move to the next subreddit to look for a
validPosts.sort((a, b) => b.created_utc - a.created_utc); // recent candidate. Older posts in this batch cannot improve it.
if (!fallback || post.created_utc > fallback.created_utc) fallback = post;
if (prioritizeRecent) { break;
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]];
}
} }
} catch (error) { } catch (error) {
if (error instanceof RedditRateLimitError) throw error; if (error instanceof RedditRateLimitError) throw error;
@@ -160,4 +172,22 @@ export class MonitorService {
private sleep(ms: number): Promise<void> { private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms)); 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);
}
} }
+25 -20
View File
@@ -2,12 +2,16 @@ import type { Cookie } from 'playwright';
import type { MatrixClient } from './chat/matrix/client.js'; import type { MatrixClient } from './chat/matrix/client.js';
import type { MatrixCredentials } from './chat/matrix/types.js'; import type { MatrixCredentials } from './chat/matrix/types.js';
import type { AccountAuthContext } from './account-auth-context.js'; import type { AccountAuthContext } from './account-auth-context.js';
import {
assertMatchingAccountIdentity,
normalizeRedditAccountId,
type RedditAccountIdentity,
} from './account-auth-context.js';
interface BrowserSessionTarget { interface BrowserSessionTarget {
importRedditCookies(cookies: Cookie[]): Promise<void>; importRedditCookies(cookies: Cookie[]): Promise<void>;
isSessionValid(): Promise<boolean>; isSessionValid(): Promise<boolean>;
getAuthenticatedUsername(): Promise<string | null>; getAuthenticatedIdentity(): Promise<RedditAccountIdentity | null>;
getAuthenticatedIdentity?(): Promise<{ username: string; accountId: string } | null>;
} }
interface ChatSessionTarget { interface ChatSessionTarget {
@@ -28,11 +32,6 @@ export interface VerifiedRedditSessionStore {
}): void; }): void;
} }
function matrixRedditAccountId(matrixUserId: string): string | null {
const match = matrixUserId.match(/^@t2_([^:]+):reddit\.com$/i);
return match?.[1]?.toLowerCase() ?? null;
}
export class RedditSessionImporter { export class RedditSessionImporter {
constructor( constructor(
private readonly browser: BrowserSessionTarget, private readonly browser: BrowserSessionTarget,
@@ -63,31 +62,37 @@ export class RedditSessionImporter {
} }
await this.browser.importRedditCookies(cookies); await this.browser.importRedditCookies(cookies);
let redditIdentity: RedditAccountIdentity;
try {
if (!(await this.browser.isSessionValid())) { if (!(await this.browser.isSessionValid())) {
throw new Error('Imported cookies did not authenticate with Reddit'); throw new Error('Imported cookies did not authenticate with Reddit');
} }
const identity = this.browser.getAuthenticatedIdentity const identity = await this.browser.getAuthenticatedIdentity();
? await this.browser.getAuthenticatedIdentity() if (!identity) throw new Error('Reddit did not return an authenticated account identity');
: null; redditIdentity = identity;
const redditUsername = identity?.username ?? await this.browser.getAuthenticatedUsername(); assertMatchingAccountIdentity({
if (!redditUsername) throw new Error('Reddit did not return an authenticated account identity'); accountId: identity.accountId,
if (identity) { redditUsername: identity.username,
const matrixAccountId = matrixRedditAccountId(userId); matrixUserId: userId,
if (!matrixAccountId || matrixAccountId !== identity.accountId.toLowerCase().replace(/^t2_/, '')) { });
throw new Error('Reddit cookies and Matrix credentials belong to different accounts'); } catch (error) {
this.accountAuth.invalidate(error instanceof Error ? error.message : String(error));
throw error;
} }
}
this.saveCredentials(credentials); this.saveCredentials(credentials);
this.chat.setMatrixClient(matrixClient); this.chat.setMatrixClient(matrixClient);
const account = { const account = {
accountId: userId, accountId: userId,
redditUsername, redditUsername: redditIdentity.username,
matrixUserId: userId, matrixUserId: userId,
}; };
this.accountAuth.markAuthenticated({
accountId: normalizeRedditAccountId(redditIdentity.accountId),
redditUsername: redditIdentity.username,
matrixUserId: userId,
});
this.sessionStore?.save({ ...account, cookies, credentials }); this.sessionStore?.save({ ...account, cookies, credentials });
await this.chat.switchAccount?.(account.accountId); await this.chat.switchAccount?.(account.accountId);
this.accountAuth.markAuthenticated(account);
return account; return account;
} }
} }
+93
View File
@@ -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<object>(),
): 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);
}
}
+4 -2
View File
@@ -6,6 +6,7 @@ import {
RealtimeEventHub, RealtimeEventHub,
} from './events/index.js'; } from './events/index.js';
import { createExpressSseHandler } from './events/express-sse-adapter.js'; import { createExpressSseHandler } from './events/express-sse-adapter.js';
import { toJsonSafe } from '../utils/json-safe.js';
const MAX_LOGS = 500; const MAX_LOGS = 500;
const INITIAL_LOGS_ON_CONNECT = 50; const INITIAL_LOGS_ON_CONNECT = 50;
@@ -28,14 +29,15 @@ export class LogBroadcaster {
} }
push(level: LogEntry['level'], message: string, data?: unknown): void { push(level: LogEntry['level'], message: string, data?: unknown): void {
const safeData = toJsonSafe(data);
const realtimeEvent = this.eventHub.closed const realtimeEvent = this.eventHub.closed
? undefined ? undefined
: this.eventHub.publish('log.created', { level, message, data }); : this.eventHub.publish('log.created', { level, message, data: safeData });
const entry: LogEntry = { const entry: LogEntry = {
timestamp: realtimeEvent ? Date.parse(realtimeEvent.occurredAt) : Date.now(), timestamp: realtimeEvent ? Date.parse(realtimeEvent.occurredAt) : Date.now(),
level, level,
message, message,
data, data: safeData,
}; };
this.entries.push(entry); this.entries.push(entry);
if (this.entries.length > MAX_LOGS) { if (this.entries.length > MAX_LOGS) {
+11 -4
View File
@@ -343,11 +343,18 @@ export function createConversationsRouter(ctx: WebContext): Router {
ctx.activity.push('sent', `Sent chat reply to u/${conversation.username}`, conversation.username); ctx.activity.push('sent', `Sent chat reply to u/${conversation.username}`, conversation.username);
} }
ctx.broadcaster.push('info', `Chat reply sent to u/${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 }); res.json({ success: true });
chatService.refreshTopChats(5).catch((err) =>
ctx.logger.error('Failed to refresh chats after send-reply:', err),
);
} else { } else {
res.status(500).json({ error: 'Failed to send chat message' }); res.status(500).json({ error: 'Failed to send chat message' });
} }
+22 -6
View File
@@ -10,7 +10,7 @@ describe('AccountAuthContext', () => {
const refreshMatrixCredentials = vi.fn(); const refreshMatrixCredentials = vi.fn();
await expect(authenticateCurrentAccount({ await expect(authenticateCurrentAccount({
getRedditUsername: vi.fn().mockResolvedValue(null), getRedditIdentity: vi.fn().mockResolvedValue(null),
refreshMatrixCredentials, refreshMatrixCredentials,
})).resolves.toBeNull(); })).resolves.toBeNull();
@@ -20,7 +20,7 @@ describe('AccountAuthContext', () => {
it('fails closed until Reddit and Matrix resolve to one authenticated account', async () => { it('fails closed until Reddit and Matrix resolve to one authenticated account', async () => {
const auth = new AccountAuthContext(createMockLogger()); const auth = new AccountAuthContext(createMockLogger());
const authenticate = vi.fn().mockResolvedValue({ const authenticate = vi.fn().mockResolvedValue({
accountId: '@t2_account:reddit.com', accountId: 't2_account',
redditUsername: 'account', redditUsername: 'account',
matrixUserId: '@t2_account:reddit.com', matrixUserId: '@t2_account:reddit.com',
}); });
@@ -34,7 +34,7 @@ describe('AccountAuthContext', () => {
expect(auth.isFullyAuthenticated()).toBe(true); expect(auth.isFullyAuthenticated()).toBe(true);
expect(auth.getSnapshot()).toMatchObject({ expect(auth.getSnapshot()).toMatchObject({
status: 'authenticated', status: 'authenticated',
accountId: '@t2_account:reddit.com', accountId: 't2_account',
}); });
}); });
@@ -52,7 +52,7 @@ describe('AccountAuthContext', () => {
const second = auth.ensureFullyAuthenticated(); const second = auth.ensureFullyAuthenticated();
await vi.waitFor(() => expect(authenticate).toHaveBeenCalledOnce()); await vi.waitFor(() => expect(authenticate).toHaveBeenCalledOnce());
resolveAuth?.({ resolveAuth?.({
accountId: '@t2_account:reddit.com', accountId: 't2_account',
redditUsername: 'account', redditUsername: 'account',
matrixUserId: '@t2_account:reddit.com', matrixUserId: '@t2_account:reddit.com',
}); });
@@ -63,7 +63,7 @@ describe('AccountAuthContext', () => {
it('invalidates the whole account context when either login layer expires', async () => { it('invalidates the whole account context when either login layer expires', async () => {
const auth = new AccountAuthContext(createMockLogger()); const auth = new AccountAuthContext(createMockLogger());
auth.markAuthenticated({ auth.markAuthenticated({
accountId: '@t2_account:reddit.com', accountId: 't2_account',
redditUsername: 'account', redditUsername: 'account',
matrixUserId: '@t2_account:reddit.com', matrixUserId: '@t2_account:reddit.com',
}); });
@@ -86,7 +86,7 @@ describe('AccountAuthContext', () => {
now: () => now, now: () => now,
}); });
auth.markAuthenticated({ auth.markAuthenticated({
accountId: '@t2_account:reddit.com', accountId: 't2_account',
redditUsername: 'account', redditUsername: 'account',
matrixUserId: '@t2_account:reddit.com', matrixUserId: '@t2_account:reddit.com',
}); });
@@ -115,4 +115,20 @@ describe('AccountAuthContext', () => {
await auth.ensureFullyAuthenticated(); await auth.ensureFullyAuthenticated();
expect(authenticate).toHaveBeenCalledTimes(2); 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/),
});
});
}); });
+6 -2
View File
@@ -36,10 +36,10 @@ describe('BrowserClient', () => {
expect(JSON.stringify(logger)).not.toContain('secret'); 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({ const fetchMock = vi.fn().mockResolvedValue({
ok: true, ok: true,
json: async () => ({ kind: 't2', data: { name: 'bot-account' } }), json: async () => ({ kind: 't2', data: { name: 'bot-account', id: 'abc123' } }),
}); });
vi.stubGlobal('fetch', fetchMock); vi.stubGlobal('fetch', fetchMock);
const page = { const page = {
@@ -50,6 +50,10 @@ describe('BrowserClient', () => {
const client = new BrowserClient(getTestConfig(), createLogger(), runtime); const client = new BrowserClient(getTestConfig(), createLogger(), runtime);
await expect(client.getAuthenticatedUsername()).resolves.toBe('bot-account'); await expect(client.getAuthenticatedUsername()).resolves.toBe('bot-account');
await expect(client.getAuthenticatedIdentity()).resolves.toEqual({
username: 'bot-account',
accountId: 't2_abc123',
});
expect(fetchMock).toHaveBeenCalledWith( expect(fetchMock).toHaveBeenCalledWith(
'https://www.reddit.com/api/me.json', 'https://www.reddit.com/api/me.json',
{ credentials: 'include' }, { credentials: 'include' },
@@ -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',
}]);
});
});
+96
View File
@@ -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);
});
});
+8
View File
@@ -182,6 +182,14 @@ describe('ConversationsPage', () => {
'src', 'src',
'/media/received%20image.jpg', '/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 () => { it('turns a suggestion into an editable draft and confirms sending without losing failed input', async () => {
+33
View File
@@ -34,4 +34,37 @@ describe('LogBroadcaster realtime bridge', () => {
expect.objectContaining({ timestamp: occurredAt.getTime() }), 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<string, unknown> = { constructorName: 'TLSSocket' };
const parser: Record<string, unknown> = { 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"');
});
}); });
+59
View File
@@ -93,6 +93,65 @@ describe('MatrixClient HTTP timeout', () => {
}, 10_000); }, 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', () => { describe('MatrixClient authentication classification', () => {
it('does not classify a room-level 403 as an expired account token', async () => { it('does not classify a room-level 403 as an expired account token', async () => {
const client = new MatrixClient(creds, createMockLogger(), { const client = new MatrixClient(creds, createMockLogger(), {
+59
View File
@@ -105,6 +105,31 @@ describe('MonitorService', () => {
expect(messagesRepo.hasSentTo).toHaveBeenCalledWith('already_msgd'); 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 () => { it('returns newest post when multiple valid posts exist', async () => {
const post1: RedditPost = { const post1: RedditPost = {
id: 't3_1', subreddit: 'sub1', author: 'user1', title: 'Older', id: 't3_1', subreddit: 'sub1', author: 'user1', title: 'Older',
@@ -127,6 +152,40 @@ describe('MonitorService', () => {
expect(result.length).toBe(1); expect(result.length).toBe(1);
expect(result[0].id).toBe('t3_2'); 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 () => { it('tries next subreddit when first has no valid posts', async () => {
+44
View File
@@ -242,6 +242,38 @@ describe('RedditApiClient', () => {
}); });
describe('isSellerProfile', () => { 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 () => { it('returns true when seller keyword matches', async () => {
const mockResponse = { const mockResponse = {
data: { data: {
@@ -332,6 +364,18 @@ describe('RedditApiClient', () => {
expect(result).toBe(false); 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 () => { it('returns true when a domain in the bio matches sellerDomains', async () => {
const mockResponse = { const mockResponse = {
data: { data: {
+20 -1
View File
@@ -1,5 +1,8 @@
import { describe, expect, it } from 'vitest'; 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', () => { describe('createSessionExportDownload', () => {
it('creates a JSON data URL that Chrome can download from a service worker', () => { 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); 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/);
});
});
+25 -5
View File
@@ -18,12 +18,12 @@ describe('RedditSessionImporter', () => {
const browser = { const browser = {
importRedditCookies: vi.fn(), importRedditCookies: vi.fn(),
isSessionValid: vi.fn(async () => true), 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 matrix = { whoami: vi.fn(async () => credentials.userId) };
const chat = { setMatrixClient: vi.fn() }; const chat = { setMatrixClient: vi.fn() };
const save = 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); const importer = new RedditSessionImporter(browser, chat as never, vi.fn(() => matrix as never), save, accountAuth as never);
await importer.import(cookies, credentials); await importer.import(cookies, credentials);
@@ -33,20 +33,40 @@ describe('RedditSessionImporter', () => {
expect(chat.setMatrixClient).toHaveBeenCalledWith(matrix); expect(chat.setMatrixClient).toHaveBeenCalledWith(matrix);
expect(save).toHaveBeenCalledWith(credentials); expect(save).toHaveBeenCalledWith(credentials);
expect(accountAuth.markAuthenticated).toHaveBeenCalledWith({ expect(accountAuth.markAuthenticated).toHaveBeenCalledWith({
accountId: credentials.userId, accountId: 't2_bot',
redditUsername: 'bot-account', redditUsername: 'bot-account',
matrixUserId: credentials.userId, 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 () => { it('rejects non-Reddit Matrix homeservers without making a request', async () => {
const createMatrix = vi.fn(); const createMatrix = vi.fn();
const importer = new RedditSessionImporter( 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, { setMatrixClient: vi.fn() } as never,
createMatrix, createMatrix,
vi.fn(), 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' })) await expect(importer.import(cookies, { ...credentials, homeserver: 'https://attacker.example' }))
+44
View File
@@ -300,4 +300,48 @@ describe('conversations route', () => {
}); });
expect(context.repos.messages.getLatestByRecipient).not.toHaveBeenCalled(); 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<void>((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 });
});
}); });