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) => (
-
})
+ rel="noreferrer"
+ target="_blank"
+ >
+
})
+
))}
) : 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