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:
commit
48492d5238
30 files changed
+1041
-181
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) => (
|
||||||
<img
|
<a
|
||||||
alt={`Image ${message.isSelf ? 'sent by you' : `received from ${redditUsernameLabel(conversation.username)}`}`}
|
aria-label={`View original image ${message.isSelf ? 'sent by you' : `received from ${redditUsernameLabel(conversation.username)}`}`}
|
||||||
|
href={mediaPath(media.localPath)}
|
||||||
key={`${message.timestamp}-${media.localPath}`}
|
key={`${message.timestamp}-${media.localPath}`}
|
||||||
loading="lazy"
|
rel="noreferrer"
|
||||||
src={mediaPath(media.localPath)}
|
target="_blank"
|
||||||
/>
|
>
|
||||||
|
<img
|
||||||
|
alt={`Image ${message.isSelf ? 'sent by you' : `received from ${redditUsernameLabel(conversation.username)}`}`}
|
||||||
|
loading="lazy"
|
||||||
|
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');
|
||||||
|
|||||||
+12
-12
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
+105
-29
@@ -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
|
userData.subreddit?.public_description || '',
|
||||||
const textToCheck = [
|
userData.subreddit?.title || '',
|
||||||
userData.subreddit?.public_description || '',
|
userData.subreddit?.display_name_prefixed || '',
|
||||||
userData.subreddit?.title || '',
|
]);
|
||||||
userData.subreddit?.display_name_prefixed || '',
|
const profileSignal = findSellerSignal(profileText, sellerKeywords, sellerDomains);
|
||||||
]
|
if (profileSignal) {
|
||||||
.join(' ')
|
|
||||||
.toLowerCase();
|
|
||||||
|
|
||||||
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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ?? []);
|
if (scraped.length === 0) continue;
|
||||||
const mediaMessages = scraped.filter((m) => m.mediaUrls.length > 0);
|
const chat = existing.get(roomId);
|
||||||
if (mediaMessages.length === 0) continue;
|
if (!chat) 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 {
|
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')
|
||||||
|
|||||||
+57
-27
@@ -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 (validPosts.length > 0) {
|
if (!prioritizeRecent || post.created_utc >= recentCutoffSec) {
|
||||||
// Sort by creation time (newest first)
|
this.logger.info(
|
||||||
validPosts.sort((a, b) => b.created_utc - a.created_utc);
|
`Found eligible post in r/${subreddit.name}${prioritizeRecent ? ' (recent match)' : ''}`,
|
||||||
|
);
|
||||||
if (prioritizeRecent) {
|
return [post];
|
||||||
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]];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
} 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
if (!(await this.browser.isSessionValid())) {
|
let redditIdentity: RedditAccountIdentity;
|
||||||
throw new Error('Imported cookies did not authenticate with Reddit');
|
try {
|
||||||
}
|
if (!(await this.browser.isSessionValid())) {
|
||||||
const identity = this.browser.getAuthenticatedIdentity
|
throw new Error('Imported cookies did not authenticate with Reddit');
|
||||||
? 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');
|
|
||||||
}
|
}
|
||||||
|
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.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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/),
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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',
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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"');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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(), {
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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' }))
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user