feat: replace browser login with imported Reddit sessions
This commit is contained in:
1 parent
2b0545afcf
commit
c483c7b8be
76 files changed
+2048
-2828
No files matched your search
@@ -17,12 +17,7 @@ export type {
|
||||
} from './types.js';
|
||||
export { MatrixClient, MatrixAuthError } from './matrix/client.js';
|
||||
export type { MatrixCredentials } from './matrix/types.js';
|
||||
export {
|
||||
extractMatrixCredentials,
|
||||
loadCachedCredentials,
|
||||
MatrixBrowserLoginRequiredError,
|
||||
saveCredentials,
|
||||
} from './matrix/token-extractor.js';
|
||||
export { loadCachedCredentials, saveCredentials } from './matrix/credential-store.js';
|
||||
export {
|
||||
MatrixRemoteLoginRequiredError,
|
||||
MatrixTokenRefresher,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import type { MatrixCredentials } from './types.js';
|
||||
|
||||
const DEFAULT_CACHE_PATH = path.resolve(process.cwd(), 'data/matrix-credentials.json');
|
||||
|
||||
export async function loadCachedCredentials(
|
||||
cachePath = DEFAULT_CACHE_PATH,
|
||||
): Promise<MatrixCredentials | null> {
|
||||
try {
|
||||
if (!fs.existsSync(cachePath)) return null;
|
||||
const parsed: unknown = JSON.parse(fs.readFileSync(cachePath, 'utf-8'));
|
||||
if (!parsed || typeof parsed !== 'object') return null;
|
||||
const value = parsed as Partial<MatrixCredentials>;
|
||||
if (!value.accessToken || !value.homeserver || !value.userId || !value.capturedAt) return null;
|
||||
return value as MatrixCredentials;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveCredentials(
|
||||
credentials: MatrixCredentials,
|
||||
cachePath = DEFAULT_CACHE_PATH,
|
||||
): void {
|
||||
const dir = path.dirname(cachePath);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(cachePath, JSON.stringify(credentials, null, 2), 'utf-8');
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import type { BrowserContext } from 'playwright';
|
||||
import type { Logger } from '../../../utils/logger.js';
|
||||
import type { MatrixCredentials } from './types.js';
|
||||
import { fetchWithTimeout } from '../../../utils/fetch-with-timeout.js';
|
||||
|
||||
const DEFAULT_CACHE_PATH = path.resolve(process.cwd(), 'data/matrix-credentials.json');
|
||||
/** Reddit chat homeserver. The bearer token is host-scoped to this origin. */
|
||||
const MATRIX_HOST_RE = /matrix\.(redditspace|reddit)\.com$/i;
|
||||
|
||||
export interface TokenExtractorOptions {
|
||||
/** Where to persist the captured token. */
|
||||
cachePath?: string;
|
||||
/** Max ms to wait for a Matrix-bound request to appear. */
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export class MatrixBrowserLoginRequiredError extends Error {
|
||||
constructor(loginUrl: string) {
|
||||
super(
|
||||
`Reddit redirected Matrix token capture to the login page (${loginUrl}). ` +
|
||||
'Restore the browser session before retrying.',
|
||||
);
|
||||
this.name = 'MatrixBrowserLoginRequiredError';
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadCachedCredentials(
|
||||
cachePath = DEFAULT_CACHE_PATH,
|
||||
): Promise<MatrixCredentials | null> {
|
||||
try {
|
||||
if (!fs.existsSync(cachePath)) return null;
|
||||
const raw = fs.readFileSync(cachePath, 'utf-8');
|
||||
const parsed = JSON.parse(raw) as MatrixCredentials;
|
||||
if (!parsed.accessToken || !parsed.homeserver || !parsed.userId) return null;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveCredentials(
|
||||
creds: MatrixCredentials,
|
||||
cachePath = DEFAULT_CACHE_PATH,
|
||||
): void {
|
||||
const dir = path.dirname(cachePath);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(cachePath, JSON.stringify(creds, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sniffs the Matrix bearer token by opening chat.reddit.com in the supplied
|
||||
* (already-logged-in) browser context and watching outbound requests for any
|
||||
* call to the Matrix homeserver. The first request carrying an
|
||||
* `Authorization: Bearer ...` header is captured.
|
||||
*
|
||||
* Requires the context to already hold a valid Reddit session — call this
|
||||
* after BrowserClient.login() or with a persistent context that has cookies.
|
||||
*/
|
||||
export async function extractMatrixCredentials(
|
||||
context: BrowserContext,
|
||||
logger: Logger,
|
||||
options: TokenExtractorOptions = {},
|
||||
): Promise<MatrixCredentials> {
|
||||
const timeoutMs = options.timeoutMs ?? 30_000;
|
||||
const cachePath = options.cachePath ?? DEFAULT_CACHE_PATH;
|
||||
|
||||
const page = await context.newPage();
|
||||
try {
|
||||
let resolveCreds: (c: MatrixCredentials) => void;
|
||||
let rejectCreds: (err: Error) => void;
|
||||
const credsPromise = new Promise<MatrixCredentials>((resolve, reject) => {
|
||||
resolveCreds = resolve;
|
||||
rejectCreds = reject;
|
||||
});
|
||||
|
||||
let done = false;
|
||||
// Track every host the page touches so we can tell, on timeout, whether
|
||||
// chat.reddit.com failed to load at all, loaded but never reached the
|
||||
// Matrix homeserver, or reached it but without a bearer header.
|
||||
const seenHosts = new Map<string, { count: number; sawAuth: boolean }>();
|
||||
const onRequest = (request: import('playwright').Request) => {
|
||||
if (done) return;
|
||||
try {
|
||||
const url = new URL(request.url());
|
||||
const isMatrix = MATRIX_HOST_RE.test(url.hostname);
|
||||
const headers = request.headers();
|
||||
const auth = headers['authorization'] || headers['Authorization'];
|
||||
const hasAuth = !!auth && auth.toLowerCase().startsWith('bearer ');
|
||||
const entry = seenHosts.get(url.hostname) ?? { count: 0, sawAuth: false };
|
||||
entry.count += 1;
|
||||
if (hasAuth) entry.sawAuth = true;
|
||||
seenHosts.set(url.hostname, entry);
|
||||
if (!isMatrix) return;
|
||||
if (!hasAuth) return;
|
||||
const token = auth!.slice('bearer '.length).trim();
|
||||
if (!token) return;
|
||||
const homeserver = `${url.protocol}//${url.host}`;
|
||||
done = true;
|
||||
logger.info(`[matrix] captured bearer token for ${homeserver}`);
|
||||
resolveCreds({
|
||||
homeserver,
|
||||
accessToken: token,
|
||||
userId: '',
|
||||
capturedAt: Date.now(),
|
||||
});
|
||||
} catch {
|
||||
// swallow malformed URLs
|
||||
}
|
||||
};
|
||||
|
||||
page.on('request', onRequest);
|
||||
page.on('framenavigated', (frame) => {
|
||||
if (done || frame !== page.mainFrame()) return;
|
||||
try {
|
||||
const url = new URL(frame.url());
|
||||
const isRedditHost = url.hostname === 'reddit.com' || url.hostname === 'www.reddit.com';
|
||||
if (isRedditHost && url.pathname.startsWith('/login')) {
|
||||
done = true;
|
||||
logger.warn('[matrix] chat page redirected to Reddit login; token capture aborted');
|
||||
rejectCreds(new MatrixBrowserLoginRequiredError(url.toString()));
|
||||
}
|
||||
} catch {
|
||||
// Ignore transient or malformed navigation URLs.
|
||||
}
|
||||
});
|
||||
|
||||
// Reddit now redirects `chat.reddit.com` → `www.reddit.com/chat/`. Going
|
||||
// direct to the canonical URL avoids one redirect hop and means the
|
||||
// session cookie the bot already has for www.reddit.com is applied to
|
||||
// the very first request, rather than only after a cross-subdomain
|
||||
// bounce that's been bouncing logged-in sessions to the login wall.
|
||||
//
|
||||
// We start the navigation but don't await it — the request listener
|
||||
// resolves credsPromise the moment the SPA fires off its first authed
|
||||
// call to matrix.redditspace.com, which may happen well before the page
|
||||
// reaches `networkidle`. Awaiting goto would block past that point.
|
||||
logger.info('[matrix] loading www.reddit.com/chat/ to capture access token...');
|
||||
void page
|
||||
.goto('https://www.reddit.com/chat/', {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: timeoutMs,
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.warn(
|
||||
`[matrix] page.goto error (ignored, still listening): ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
});
|
||||
|
||||
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeout = new Promise<MatrixCredentials>((_, reject) => {
|
||||
timeoutHandle = setTimeout(
|
||||
() => reject(new Error(`Matrix token capture timed out after ${timeoutMs}ms`)),
|
||||
timeoutMs,
|
||||
);
|
||||
});
|
||||
|
||||
let captured: MatrixCredentials;
|
||||
try {
|
||||
captured = await Promise.race([credsPromise, timeout]);
|
||||
} catch (err) {
|
||||
if (err instanceof MatrixBrowserLoginRequiredError) throw err;
|
||||
// On timeout, surface enough info to tell the three failure modes apart:
|
||||
// (a) chat.reddit.com never loaded → page.url() still about:blank, almost no hosts
|
||||
// (b) page loaded, no Matrix host hit → reddit hosts present, no matrix.*
|
||||
// (c) Matrix hit, but no Authorization → matrix.* present with sawAuth: false
|
||||
try {
|
||||
const finalUrl = page.url();
|
||||
const hostSummary = Array.from(seenHosts.entries())
|
||||
.map(([host, info]) => `${host}=${info.count}${info.sawAuth ? '*' : ''}`)
|
||||
.join(', ');
|
||||
logger.error(
|
||||
`[matrix] timeout diagnostics: finalUrl=${finalUrl} hosts=[${hostSummary || 'none'}]`,
|
||||
);
|
||||
const shotDir = path.dirname(cachePath);
|
||||
if (!fs.existsSync(shotDir)) fs.mkdirSync(shotDir, { recursive: true });
|
||||
const shotPath = path.join(shotDir, 'matrix-debug.png');
|
||||
await page.screenshot({ path: shotPath, fullPage: true }).catch(() => undefined);
|
||||
logger.error(`[matrix] saved debug screenshot to ${shotPath}`);
|
||||
} catch (diagErr) {
|
||||
logger.warn('[matrix] failed to collect timeout diagnostics:', diagErr);
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
if (timeoutHandle) clearTimeout(timeoutHandle);
|
||||
}
|
||||
|
||||
// Resolve the user_id by calling /whoami so the caller knows who we are.
|
||||
const whoamiRes = await fetchWithTimeout(
|
||||
`${captured.homeserver}/_matrix/client/v3/account/whoami`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${captured.accessToken}` },
|
||||
timeoutMs: 15_000,
|
||||
},
|
||||
);
|
||||
if (whoamiRes.ok) {
|
||||
const body = (await whoamiRes.json()) as { user_id?: string };
|
||||
if (body.user_id) captured.userId = body.user_id;
|
||||
} else {
|
||||
logger.warn(`[matrix] whoami returned ${whoamiRes.status}; userId left blank`);
|
||||
}
|
||||
|
||||
saveCredentials(captured, cachePath);
|
||||
logger.info(`[matrix] credentials saved to ${cachePath} (user=${captured.userId || 'unknown'})`);
|
||||
return captured;
|
||||
} finally {
|
||||
await page.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
@@ -1,97 +1,15 @@
|
||||
import type { BrowserContext } from 'playwright';
|
||||
import type { Logger } from '../../../utils/logger.js';
|
||||
import type { TokenRefresher } from '../service.js';
|
||||
import type { MatrixCredentials } from './types.js';
|
||||
import {
|
||||
extractMatrixCredentials,
|
||||
MatrixBrowserLoginRequiredError,
|
||||
} from './token-extractor.js';
|
||||
|
||||
export interface MatrixSessionBrowser {
|
||||
getContext(): BrowserContext | null;
|
||||
isSessionValid(): Promise<boolean>;
|
||||
login(): Promise<boolean>;
|
||||
}
|
||||
|
||||
export type MatrixCredentialExtractor = (
|
||||
context: BrowserContext,
|
||||
logger: Logger,
|
||||
) => Promise<MatrixCredentials>;
|
||||
|
||||
export class MatrixRemoteLoginRequiredError extends Error {
|
||||
constructor() {
|
||||
super(
|
||||
'Reddit browser session has expired and automatic login could not restore it. ' +
|
||||
'Open the dashboard and use the noVNC console to log in to Reddit, then retry chat sync.',
|
||||
);
|
||||
super('Matrix credentials expired. Import a fresh session dump from the extension, then retry.');
|
||||
this.name = 'MatrixRemoteLoginRequiredError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores the browser login when needed, then captures and applies one fresh
|
||||
* Matrix credential set. Concurrent callers share the same browser operation.
|
||||
*/
|
||||
/** Matrix credentials are replaced only by a newly verified extension dump. */
|
||||
export class MatrixTokenRefresher implements TokenRefresher {
|
||||
private refreshInFlight: Promise<void> | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly browser: MatrixSessionBrowser,
|
||||
private readonly logger: Logger,
|
||||
private readonly applyCredentials: (credentials: MatrixCredentials) => void,
|
||||
private readonly extract: MatrixCredentialExtractor = extractMatrixCredentials,
|
||||
) {}
|
||||
|
||||
refresh(): Promise<void> {
|
||||
if (this.refreshInFlight) return this.refreshInFlight;
|
||||
|
||||
this.refreshInFlight = this.performRefresh().finally(() => {
|
||||
this.refreshInFlight = null;
|
||||
});
|
||||
return this.refreshInFlight;
|
||||
}
|
||||
|
||||
private async performRefresh(): Promise<void> {
|
||||
const sessionValid = await this.browser.isSessionValid();
|
||||
let sessionRestored = false;
|
||||
if (!sessionValid) {
|
||||
await this.restoreSession();
|
||||
sessionRestored = true;
|
||||
}
|
||||
|
||||
const context = this.browser.getContext();
|
||||
if (!context) {
|
||||
throw new Error('Matrix token refresh requires an initialized browser context');
|
||||
}
|
||||
|
||||
let credentials: MatrixCredentials;
|
||||
try {
|
||||
credentials = await this.extract(context, this.logger);
|
||||
} catch (error) {
|
||||
if (!(error instanceof MatrixBrowserLoginRequiredError)) throw error;
|
||||
if (sessionRestored) throw new MatrixRemoteLoginRequiredError();
|
||||
|
||||
this.logger.warn(
|
||||
'[matrix] Reddit API session looked valid but chat requires login; attempting recovery',
|
||||
);
|
||||
await this.restoreSession();
|
||||
try {
|
||||
credentials = await this.extract(context, this.logger);
|
||||
} catch (retryError) {
|
||||
if (retryError instanceof MatrixBrowserLoginRequiredError) {
|
||||
throw new MatrixRemoteLoginRequiredError();
|
||||
}
|
||||
throw retryError;
|
||||
}
|
||||
}
|
||||
this.applyCredentials(credentials);
|
||||
}
|
||||
|
||||
private async restoreSession(): Promise<void> {
|
||||
this.logger.warn('[matrix] Reddit browser session expired; attempting login recovery');
|
||||
if (!(await this.browser.login())) {
|
||||
throw new MatrixRemoteLoginRequiredError();
|
||||
}
|
||||
this.logger.info('[matrix] Reddit browser session restored');
|
||||
async refresh(): Promise<void> {
|
||||
throw new MatrixRemoteLoginRequiredError();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export const CONVERSATION_OUTCOMES = ['engaged', 'declined', 'boundary', 'upsell', 'unknown'] as const;
|
||||
export type ConversationOutcome = (typeof CONVERSATION_OUTCOMES)[number];
|
||||
|
||||
/**
|
||||
* Deterministic first-pass outcome classifier. It is deliberately conservative:
|
||||
* only replies with no decline, boundary, or sales signal become learning examples.
|
||||
*/
|
||||
export function classifyConversationOutcome(reply: string): ConversationOutcome {
|
||||
const text = reply.toLowerCase();
|
||||
if (/\b(onlyfans|fansly|manyvids|link in bio|cash\.?app|venmo)\b/.test(text)) return 'upsell';
|
||||
if (/\b(stop|leave me alone|do not message|don't message)\b/.test(text)) return 'boundary';
|
||||
if (/\b(no thanks|not interested|nope|don't send|do not send)\b/.test(text)) return 'declined';
|
||||
if (text.trim().length > 0) return 'engaged';
|
||||
return 'unknown';
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
export interface ConversationMessage {
|
||||
sender: string;
|
||||
text: string;
|
||||
isSelf: boolean;
|
||||
}
|
||||
|
||||
export type ConversationIntentKind =
|
||||
| 'answer-question'
|
||||
| 'respect-boundary'
|
||||
| 'reciprocate'
|
||||
| 'follow-up'
|
||||
| 'media-ask';
|
||||
|
||||
export interface ConversationIntent {
|
||||
kind: ConversationIntentKind;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface ConversationPersona {
|
||||
age: number | null;
|
||||
location: string | null;
|
||||
relationshipStatus: 'married' | 'single' | 'unspecified';
|
||||
}
|
||||
|
||||
export interface ConversationState {
|
||||
persona: ConversationPersona;
|
||||
boundaries: string[];
|
||||
askedForMedia: boolean;
|
||||
topics: string[];
|
||||
}
|
||||
|
||||
const EMPTY_PERSONA: ConversationPersona = {
|
||||
age: null,
|
||||
location: null,
|
||||
relationshipStatus: 'unspecified',
|
||||
};
|
||||
|
||||
export function buildConversationState(messages: ConversationMessage[]): ConversationState {
|
||||
const firstSelfMessage = messages.find((message) => message.isSelf)?.text ?? '';
|
||||
const theirText = messages
|
||||
.filter((message) => !message.isSelf)
|
||||
.map((message) => message.text)
|
||||
.join('\n');
|
||||
const ownText = messages
|
||||
.filter((message) => message.isSelf)
|
||||
.map((message) => message.text)
|
||||
.join('\n');
|
||||
|
||||
const ageMatch = firstSelfMessage.match(/\b(3[0-5])\s*[mM]\b/);
|
||||
const persona: ConversationPersona = {
|
||||
age: ageMatch ? Number(ageMatch[1]) : EMPTY_PERSONA.age,
|
||||
location: /\bUK\b/i.test(firstSelfMessage) ? 'UK' : EMPTY_PERSONA.location,
|
||||
relationshipStatus: /\bmarried\b|\bwife\b/i.test(firstSelfMessage) ? 'married' : EMPTY_PERSONA.relationshipStatus,
|
||||
};
|
||||
|
||||
const boundaries: string[] = [];
|
||||
if (/\b(no|not)\b[^.\n]{0,50}\b(pic|pics|photo|photos|selfie|selfies|video|videos|nudes?)\b|\bdon't send\b[^.\n]{0,30}\b(pic|photo|video|nudes?)\b/i.test(theirText)) {
|
||||
boundaries.push('does not want to send photos or videos');
|
||||
}
|
||||
if (/\bstop\b|\bleave me alone\b|\bnot interested\b/i.test(theirText)) {
|
||||
boundaries.push('does not want further sexual escalation');
|
||||
}
|
||||
|
||||
const topics = extractTopics(theirText);
|
||||
return {
|
||||
persona,
|
||||
boundaries,
|
||||
askedForMedia: /\b(send|show|share)\b[^.\n]{0,40}\b(pic|pics|photo|photos|selfie|selfies|video|videos|nudes?)\b/i.test(ownText),
|
||||
topics,
|
||||
};
|
||||
}
|
||||
|
||||
export function chooseConversationIntent(
|
||||
messages: ConversationMessage[],
|
||||
state: ConversationState,
|
||||
requestedIntent?: 'media-ask',
|
||||
): ConversationIntent {
|
||||
if (requestedIntent === 'media-ask') {
|
||||
if (state.boundaries.includes('does not want to send photos or videos')) {
|
||||
return { kind: 'respect-boundary', reason: 'they have declined or set a boundary around photos or videos' };
|
||||
}
|
||||
if (state.askedForMedia) {
|
||||
return { kind: 'follow-up', reason: 'a media ask was already made, so do not repeat it' };
|
||||
}
|
||||
return { kind: 'media-ask', reason: 'a media ask was explicitly requested and no boundary is recorded' };
|
||||
}
|
||||
|
||||
if (state.boundaries.length > 0) {
|
||||
return { kind: 'respect-boundary', reason: 'their conversation contains a stated boundary' };
|
||||
}
|
||||
|
||||
const latestOtherMessage = [...messages].reverse().find((message) => !message.isSelf)?.text ?? '';
|
||||
if (/\?\s*$|\b(what|why|where|when|who|how|are you|do you|would you|can you)\b/i.test(latestOtherMessage)) {
|
||||
return { kind: 'answer-question', reason: 'their latest message asks a direct question' };
|
||||
}
|
||||
if (latestOtherMessage.trim().length > 0) {
|
||||
return { kind: 'reciprocate', reason: 'they shared something to acknowledge before advancing the conversation' };
|
||||
}
|
||||
return { kind: 'follow-up', reason: 'there is no substantive reply to build on' };
|
||||
}
|
||||
|
||||
/** Merge durable memory with observations from the currently loaded transcript. */
|
||||
export function mergeConversationState(
|
||||
saved: ConversationState | undefined,
|
||||
observed: ConversationState,
|
||||
): ConversationState {
|
||||
if (!saved) return observed;
|
||||
return {
|
||||
persona: {
|
||||
age: observed.persona.age ?? saved.persona.age,
|
||||
location: observed.persona.location ?? saved.persona.location,
|
||||
relationshipStatus: observed.persona.relationshipStatus !== 'unspecified'
|
||||
? observed.persona.relationshipStatus
|
||||
: saved.persona.relationshipStatus,
|
||||
},
|
||||
boundaries: [...new Set([...saved.boundaries, ...observed.boundaries])],
|
||||
askedForMedia: saved.askedForMedia || observed.askedForMedia,
|
||||
topics: [...new Set([...saved.topics, ...observed.topics])],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Conversation text is deliberately delimited as data. It must never override
|
||||
* the system instruction, even if a participant includes instruction-like text.
|
||||
*/
|
||||
export function formatConversationAsUntrustedData(messages: ConversationMessage[], maxMessages = 24): string {
|
||||
const recentMessages = messages.slice(-maxMessages);
|
||||
const rows = recentMessages.map((message) => {
|
||||
const speaker = message.isSelf ? 'You' : 'Other person';
|
||||
return `${speaker}: ${message.text}`;
|
||||
});
|
||||
return `BEGIN UNTRUSTED CONVERSATION DATA\n${rows.join('\n')}\nEND UNTRUSTED CONVERSATION DATA`;
|
||||
}
|
||||
|
||||
/** Reject obvious instruction leakage and outputs that disregard the reply length contract. */
|
||||
export function isSafeConversationReply(message: string): boolean {
|
||||
const trimmed = message.trim();
|
||||
if (!trimmed || trimmed.length > 600) return false;
|
||||
if (/\b(as an ai|system prompt|begin untrusted|end untrusted|ignore (all|previous) instructions)\b/i.test(trimmed)) return false;
|
||||
const sentences = trimmed.match(/[^.!?]+[.!?]+/g) ?? [];
|
||||
return sentences.length <= 3;
|
||||
}
|
||||
|
||||
export function serializeConversationState(state: ConversationState): string {
|
||||
return JSON.stringify(state);
|
||||
}
|
||||
|
||||
export function parseConversationState(value: string | null | undefined): ConversationState | null {
|
||||
if (!value) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(value) as Partial<ConversationState>;
|
||||
if (!parsed.persona || !Array.isArray(parsed.boundaries) || !Array.isArray(parsed.topics) || typeof parsed.askedForMedia !== 'boolean') {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
persona: {
|
||||
age: typeof parsed.persona.age === 'number' ? parsed.persona.age : null,
|
||||
location: typeof parsed.persona.location === 'string' ? parsed.persona.location : null,
|
||||
relationshipStatus: parsed.persona.relationshipStatus === 'married' || parsed.persona.relationshipStatus === 'single'
|
||||
? parsed.persona.relationshipStatus
|
||||
: 'unspecified',
|
||||
},
|
||||
boundaries: parsed.boundaries.filter((value): value is string => typeof value === 'string'),
|
||||
askedForMedia: parsed.askedForMedia,
|
||||
topics: parsed.topics.filter((value): value is string => typeof value === 'string'),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function extractTopics(text: string): string[] {
|
||||
const topics: string[] = [];
|
||||
for (const topic of ['music', 'films', 'movies', 'gaming', 'work', 'travel', 'fitness']) {
|
||||
if (new RegExp(`\\b${topic}\\b`, 'i').test(text)) topics.push(topic);
|
||||
}
|
||||
return topics;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Cookie } from 'playwright';
|
||||
import type { MatrixClient } from './chat/matrix/client.js';
|
||||
import type { MatrixCredentials } from './chat/matrix/types.js';
|
||||
|
||||
interface BrowserSessionTarget {
|
||||
importRedditCookies(cookies: Cookie[]): Promise<void>;
|
||||
isSessionValid(): Promise<boolean>;
|
||||
}
|
||||
|
||||
interface ChatSessionTarget {
|
||||
setMatrixClient(client: MatrixClient): void;
|
||||
}
|
||||
|
||||
type MatrixClientFactory = (credentials: MatrixCredentials) => MatrixClient;
|
||||
type CredentialStore = (credentials: MatrixCredentials) => void;
|
||||
|
||||
export class RedditSessionImporter {
|
||||
constructor(
|
||||
private readonly browser: BrowserSessionTarget,
|
||||
private readonly chat: ChatSessionTarget,
|
||||
private readonly createMatrixClient: MatrixClientFactory,
|
||||
private readonly saveCredentials: CredentialStore,
|
||||
) {}
|
||||
|
||||
async import(cookies: Cookie[], credentials: MatrixCredentials): Promise<void> {
|
||||
const homeserver = new URL(credentials.homeserver);
|
||||
if (
|
||||
homeserver.protocol !== 'https:' ||
|
||||
!/^matrix\.(?:redditspace|reddit)\.com$/i.test(homeserver.hostname)
|
||||
) {
|
||||
throw new Error('Invalid Reddit Matrix homeserver');
|
||||
}
|
||||
|
||||
const matrixClient = this.createMatrixClient(credentials);
|
||||
const userId = await matrixClient.whoami();
|
||||
if (!userId || userId !== credentials.userId) {
|
||||
throw new Error('Matrix credentials did not verify the exported account');
|
||||
}
|
||||
|
||||
await this.browser.importRedditCookies(cookies);
|
||||
if (!(await this.browser.isSessionValid())) {
|
||||
throw new Error('Imported cookies did not authenticate with Reddit');
|
||||
}
|
||||
|
||||
this.saveCredentials(credentials);
|
||||
this.chat.setMatrixClient(matrixClient);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { Page } from 'playwright';
|
||||
import type { Logger } from '../utils/logger.js';
|
||||
import type { MessagesRepository, SuccessfulMessagesRepository, SentMessageRecord } from '../database/repositories.js';
|
||||
import type { MessagesRepository, SuccessfulMessagesRepository, SentMessageRecord, ConversationOutcomesRepository } from '../database/repositories.js';
|
||||
import type { PostsRepository } from '../database/repositories.js';
|
||||
import type { AccountAuthGate } from './account-auth-context.js';
|
||||
import { classifyConversationOutcome } from './conversation-outcome.js';
|
||||
|
||||
export interface ResponseCheckResult {
|
||||
messageId: number;
|
||||
@@ -18,15 +18,11 @@ export class ResponseCheckerService {
|
||||
private successfulMessagesRepo: SuccessfulMessagesRepository,
|
||||
private postsRepo: PostsRepository,
|
||||
private logger: Logger,
|
||||
private accountAuth?: AccountAuthGate,
|
||||
private outcomesRepo?: ConversationOutcomesRepository,
|
||||
) { }
|
||||
|
||||
async checkForResponses(): Promise<ResponseCheckResult[]> {
|
||||
const results: ResponseCheckResult[] = [];
|
||||
if (!this.accountAuth?.isFullyAuthenticated()) {
|
||||
this.logger.debug('Response check skipped until full account authentication');
|
||||
return results;
|
||||
}
|
||||
|
||||
// Get messages that haven't been checked recently (at least 1 hour old, recheck every 6 hours)
|
||||
const minAgeMs = 60 * 60 * 1000; // 1 hour
|
||||
@@ -64,17 +60,21 @@ export class ResponseCheckerService {
|
||||
// Get the original post info
|
||||
const post = message.postId ? this.postsRepo.getById(message.postId) : undefined;
|
||||
|
||||
// Store as successful message for learning
|
||||
this.successfulMessagesRepo.insert({
|
||||
sentMessageId: message.id,
|
||||
originalMessage: message.message,
|
||||
imageAnalysis: post?.imageAnalysis || null,
|
||||
postTitle: post?.title || null,
|
||||
responseSnippet: result.responseSnippet || null,
|
||||
subreddit: post?.subreddit || null,
|
||||
});
|
||||
|
||||
this.logger.info(`Got response from u/${message.recipient}! Stored for learning.`);
|
||||
const outcome = classifyConversationOutcome(result.responseSnippet ?? '');
|
||||
this.outcomesRepo?.record(message.id, outcome, result.responseSnippet ?? null);
|
||||
if (outcome === 'engaged') {
|
||||
this.successfulMessagesRepo.insert({
|
||||
sentMessageId: message.id,
|
||||
originalMessage: message.message,
|
||||
imageAnalysis: post?.imageAnalysis || null,
|
||||
postTitle: post?.title || null,
|
||||
responseSnippet: result.responseSnippet || null,
|
||||
subreddit: post?.subreddit || null,
|
||||
});
|
||||
this.logger.info(`Engaged response from u/${message.recipient}; stored for learning.`);
|
||||
} else {
|
||||
this.logger.info(`Response from u/${message.recipient} classified as ${outcome}; excluded from learning examples.`);
|
||||
}
|
||||
} else {
|
||||
// Just mark as checked
|
||||
this.messagesRepo.markChecked(message.id);
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { RedditSessionState } from '../browser/client.js';
|
||||
|
||||
/** Starts and stops account-bound work as authentication changes. */
|
||||
export class SessionBoundRuntime {
|
||||
private active = false;
|
||||
|
||||
constructor(
|
||||
private readonly start: () => void,
|
||||
private readonly stop: () => void,
|
||||
) {}
|
||||
|
||||
reconcile(state: RedditSessionState): boolean {
|
||||
const authenticated = state === 'authenticated';
|
||||
if (authenticated && !this.active) {
|
||||
this.active = true;
|
||||
this.start();
|
||||
} else if (!authenticated && this.active) {
|
||||
this.active = false;
|
||||
this.stop();
|
||||
}
|
||||
return authenticated;
|
||||
}
|
||||
|
||||
shutdown(): void {
|
||||
if (!this.active) return;
|
||||
this.active = false;
|
||||
this.stop();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user