Merge branch 'codex/native-login-handoff' into main
# Conflicts: # public/index.html # src/bootstrap.ts # src/services/chat/service.ts # tests/chat-service-username-persist.test.ts
This commit is contained in:
commit
50985b52f1
28 files changed
+987
-124
No files matched your search
+83
-44
@@ -23,8 +23,6 @@ import {
|
||||
MatrixClient,
|
||||
MatrixMediaDownloader,
|
||||
MatrixTokenRefresher,
|
||||
extractMatrixCredentials,
|
||||
loadCachedCredentials,
|
||||
type UsernameResolver,
|
||||
type MatrixCredentials,
|
||||
} from './services/chat/index.js';
|
||||
@@ -34,6 +32,12 @@ import { RateLimiter } from './utils/rate-limiter.js';
|
||||
import { fetchWithTimeout } from './utils/fetch-with-timeout.js';
|
||||
import { WebServer } from './web/server.js';
|
||||
import { reportReply } from './web/event-reporting.js';
|
||||
import {
|
||||
AccountAuthContext,
|
||||
authenticateCurrentAccount,
|
||||
type AuthenticatedAccount,
|
||||
} from './services/account-auth-context.js';
|
||||
import { ProcessNativeLoginHandoff } from './services/native-login-handoff.js';
|
||||
|
||||
export interface AppContainer {
|
||||
config: Config;
|
||||
@@ -42,6 +46,7 @@ export interface AppContainer {
|
||||
|
||||
browserClient: BrowserClient;
|
||||
messagePage: RedditMessagePage;
|
||||
accountAuth: AccountAuthContext;
|
||||
|
||||
postsRepo: PostsRepository;
|
||||
messagesRepo: MessagesRepository;
|
||||
@@ -98,6 +103,7 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
|
||||
const page = await browserClient.getPage();
|
||||
const messagePage = new RedditMessagePage(page, logger);
|
||||
messagePage.setDryRun(config.browser.dryRun);
|
||||
const accountAuth = new AccountAuthContext(logger);
|
||||
|
||||
// Login verification. When the web dashboard is enabled we treat a missing
|
||||
// session as recoverable — the dashboard exposes a noVNC console so a human
|
||||
@@ -107,7 +113,7 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
|
||||
if (!loggedIn) {
|
||||
logger.warn('Session invalid or not logged in.');
|
||||
|
||||
if (config.reddit.username && config.reddit.password) {
|
||||
if (!config.web.enabled && config.reddit.username && config.reddit.password) {
|
||||
logger.info('Credentials found in config/env. Attempting automated login...');
|
||||
loggedIn = await browserClient.login();
|
||||
}
|
||||
@@ -155,36 +161,19 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
|
||||
successfulMessagesRepo,
|
||||
postsRepo,
|
||||
logger,
|
||||
accountAuth,
|
||||
);
|
||||
|
||||
// ---- Matrix chat backend (replaces the Playwright scraper) ----
|
||||
// 1. Get credentials: prefer cached, fall back to a fresh sniff via the
|
||||
// persistent browser session that we just verified is logged in.
|
||||
// If the sniff fails (e.g. egress blocked, chat.reddit.com refuses to
|
||||
// load), we *don't* crash bootstrap — the post-monitoring/processor
|
||||
// side of the bot doesn't need Matrix, and keeping the web UI up makes
|
||||
// the situation diagnosable. Chat calls will fail with 401 until a
|
||||
// subsequent token refresh succeeds.
|
||||
let matrixCreds: MatrixCredentials | null = await loadCachedCredentials();
|
||||
if (!matrixCreds) {
|
||||
logger.info('No cached Matrix credentials — extracting via browser session...');
|
||||
try {
|
||||
matrixCreds = await extractMatrixCredentials(browserClient.getContext()!, logger);
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
'[matrix] credential extraction failed — chat features will be disabled until the next successful refresh:',
|
||||
err,
|
||||
);
|
||||
matrixCreds = {
|
||||
homeserver: 'https://matrix.redditspace.com',
|
||||
accessToken: '',
|
||||
userId: '',
|
||||
capturedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
} else {
|
||||
logger.info(`Loaded cached Matrix credentials (user=${matrixCreds.userId})`);
|
||||
}
|
||||
// A cached bearer token is not accepted as application authentication.
|
||||
// The current Chrome profile must yield a fresh token and Matrix identity
|
||||
// before the shared account context unlocks any bot behavior.
|
||||
const matrixCreds: MatrixCredentials = {
|
||||
homeserver: 'https://matrix.redditspace.com',
|
||||
accessToken: '',
|
||||
userId: '',
|
||||
capturedAt: Date.now(),
|
||||
};
|
||||
const matrixClient = new MatrixClient(matrixCreds, logger);
|
||||
|
||||
// 2. Build the media downloader + service on top of the Matrix client so
|
||||
@@ -195,13 +184,32 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
|
||||
// 3. Token refresher — on 401, restore the persistent Reddit session when
|
||||
// possible, re-sniff once, and swap credentials into the chat service.
|
||||
// Concurrent callers share that refresh rather than opening extra pages.
|
||||
let chatService: ChatService;
|
||||
const credentialState: { latest: MatrixCredentials | null } = { latest: null };
|
||||
const tokenRefresher = new MatrixTokenRefresher(
|
||||
browserClient,
|
||||
logger,
|
||||
(fresh) => chatService.setMatrixClient(new MatrixClient(fresh, logger)),
|
||||
(fresh) => {
|
||||
credentialState.latest = fresh;
|
||||
matrixClient.updateCredentials(fresh);
|
||||
},
|
||||
);
|
||||
|
||||
accountAuth.setAuthenticator(() => authenticateCurrentAccount({
|
||||
getRedditUsername: () => browserClient.getAuthenticatedUsername(),
|
||||
refreshMatrixCredentials: async () => {
|
||||
credentialState.latest = null;
|
||||
await tokenRefresher.refresh();
|
||||
return credentialState.latest as MatrixCredentials | null;
|
||||
},
|
||||
}));
|
||||
|
||||
if (loggedIn) {
|
||||
logger.info('Verifying full Reddit and Matrix account authentication...');
|
||||
await accountAuth.ensureFullyAuthenticated();
|
||||
} else {
|
||||
accountAuth.invalidate('Interactive Reddit login is required');
|
||||
}
|
||||
|
||||
// 4. Username resolver — turns t2 IDs into real Reddit usernames so the
|
||||
// dashboard isn't full of `t2_abc123`. Uses Reddit's bulk endpoint.
|
||||
const usernameResolver: UsernameResolver = {
|
||||
@@ -233,7 +241,7 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
|
||||
},
|
||||
};
|
||||
|
||||
chatService = new ChatService(
|
||||
const chatService = new ChatService(
|
||||
matrixClient,
|
||||
logger,
|
||||
successfulMessagesRepo,
|
||||
@@ -243,24 +251,52 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
|
||||
undefined,
|
||||
messagesRepo,
|
||||
redditUsersRepo,
|
||||
accountAuth,
|
||||
);
|
||||
|
||||
const mediaSyncService = new MediaSyncService(chatService, logger, config.media.syncIntervalMs);
|
||||
const mediaSyncService = new MediaSyncService(
|
||||
chatService,
|
||||
logger,
|
||||
config.media.syncIntervalMs,
|
||||
accountAuth,
|
||||
);
|
||||
const rateLimiter = new RateLimiter(config, logger);
|
||||
|
||||
await chatService.loadCache();
|
||||
|
||||
// Fire-and-forget startup sync: full if we've never backfilled, delta
|
||||
// otherwise. Non-blocking so the rest of the bot can come up immediately;
|
||||
// the dashboard's sync-status banner shows progress.
|
||||
chatService
|
||||
.runStartupSync()
|
||||
.then(async ({ kind, chats }) => {
|
||||
logger.info(`[chat] startup sync (${kind}) complete: ${chats} chats`);
|
||||
// Resolve t2 IDs now that the full chat list is available.
|
||||
let startupSyncAccountId: string | null = null;
|
||||
let startupSyncInFlightAccountId: string | null = null;
|
||||
const startAuthenticatedAccount = async (account: AuthenticatedAccount): Promise<void> => {
|
||||
if (
|
||||
startupSyncAccountId === account.accountId ||
|
||||
startupSyncInFlightAccountId === account.accountId
|
||||
) return;
|
||||
startupSyncInFlightAccountId = account.accountId;
|
||||
try {
|
||||
const { kind, chats } = await chatService.runStartupSync();
|
||||
await chatService.enrichUsernames();
|
||||
})
|
||||
.catch((err) => logger.error('[chat] startup sync failed:', err));
|
||||
if (accountAuth.isFullyAuthenticated()) {
|
||||
startupSyncAccountId = account.accountId;
|
||||
logger.info(`[chat] startup sync (${kind}) complete for ${account.redditUsername}: ${chats} chats`);
|
||||
}
|
||||
} finally {
|
||||
startupSyncInFlightAccountId = null;
|
||||
}
|
||||
};
|
||||
accountAuth.onAuthenticated((account) => startAuthenticatedAccount(account));
|
||||
const authenticatedAccount = accountAuth.getSnapshot();
|
||||
if (
|
||||
authenticatedAccount.status === 'authenticated' &&
|
||||
authenticatedAccount.accountId &&
|
||||
authenticatedAccount.redditUsername &&
|
||||
authenticatedAccount.matrixUserId
|
||||
) {
|
||||
void startAuthenticatedAccount({
|
||||
accountId: authenticatedAccount.accountId,
|
||||
redditUsername: authenticatedAccount.redditUsername,
|
||||
matrixUserId: authenticatedAccount.matrixUserId,
|
||||
}).catch((err) => logger.error('[chat] startup sync failed:', err));
|
||||
}
|
||||
|
||||
let webServer: WebServer | null = null;
|
||||
if (config.web.enabled) {
|
||||
@@ -327,6 +363,8 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
|
||||
webServer.setAiClient(aiClient);
|
||||
webServer.setStrategySelector(strategySelector);
|
||||
webServer.setBrowserClient(browserClient);
|
||||
webServer.setAccountAuthContext(accountAuth);
|
||||
webServer.setNativeLoginHandoff(new ProcessNativeLoginHandoff());
|
||||
webServer.setOnConfigChange(() => logger.info('Runtime config updated via dashboard'));
|
||||
}
|
||||
|
||||
@@ -348,6 +386,7 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
|
||||
db,
|
||||
browserClient,
|
||||
messagePage,
|
||||
accountAuth,
|
||||
postsRepo,
|
||||
messagesRepo,
|
||||
successfulMessagesRepo,
|
||||
|
||||
+20
-15
@@ -112,6 +112,15 @@ export class BrowserClient {
|
||||
return this.sessionState;
|
||||
}
|
||||
|
||||
async getAuthenticatedUsername(): Promise<string | null> {
|
||||
try {
|
||||
const page = await this.getPage();
|
||||
return await this.readAuthenticatedUsername(page);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this.connectedBrowser) {
|
||||
this.logger.info('Closing Google Chrome connection');
|
||||
@@ -185,34 +194,30 @@ export class BrowserClient {
|
||||
}
|
||||
|
||||
private async isLoggedInViaApi(page: Page): Promise<boolean> {
|
||||
return (await this.readAuthenticatedUsername(page)) !== null;
|
||||
}
|
||||
|
||||
private async readAuthenticatedUsername(page: Page): Promise<string | null> {
|
||||
try {
|
||||
return await page.evaluate(async () => {
|
||||
try {
|
||||
const res = await fetch('https://www.reddit.com/api/me.json', { credentials: 'include' });
|
||||
if (!res.ok) return false;
|
||||
const data = await res.json();
|
||||
return Boolean(data && data.name);
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json() as { name?: unknown };
|
||||
return typeof data.name === 'string' && data.name ? data.name : null;
|
||||
} catch {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async login(): Promise<boolean> {
|
||||
if (this.usingExternalChrome) {
|
||||
try {
|
||||
const page = await this.getPage();
|
||||
await page.bringToFront();
|
||||
await page.goto('https://www.reddit.com/login', { waitUntil: 'domcontentloaded' });
|
||||
this.sessionState = 'login-required';
|
||||
this.logger.warn('Interactive Reddit login required. Open the VNC page and sign in using Google Chrome.');
|
||||
} catch (error) {
|
||||
this.sessionState = classifyRedditSessionError(error);
|
||||
this.logger.error('Failed to open Google Chrome for interactive login:', error);
|
||||
}
|
||||
this.sessionState = 'login-required';
|
||||
this.logger.warn('Native Reddit login required. Start it from the dashboard before signing in through VNC.');
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
+16
-1
@@ -20,6 +20,7 @@ export async function runMainLoop(app: AppContainer): Promise<void> {
|
||||
config,
|
||||
logger,
|
||||
browserClient,
|
||||
accountAuth,
|
||||
postsRepo,
|
||||
messagesRepo,
|
||||
redditApi,
|
||||
@@ -66,7 +67,7 @@ export async function runMainLoop(app: AppContainer): Promise<void> {
|
||||
const RESPONSE_CHECK_INTERVAL_MS = Math.max(config.reddit.pollIntervalMs * 5, 60_000);
|
||||
|
||||
const checkResponses = async (): Promise<void> => {
|
||||
if (isShuttingDown) return;
|
||||
if (isShuttingDown || !accountAuth.isFullyAuthenticated()) return;
|
||||
try {
|
||||
logger.debug('Checking for responses to sent messages (Background Thread)...');
|
||||
const responses = await responseChecker.checkForResponses();
|
||||
@@ -93,12 +94,26 @@ export async function runMainLoop(app: AppContainer): Promise<void> {
|
||||
responseCheckInterval = setInterval(checkResponses, RESPONSE_CHECK_INTERVAL_MS);
|
||||
|
||||
let lastPausedLog = 0;
|
||||
let lastAuthPausedLog = 0;
|
||||
let lastCooldownLog = 0;
|
||||
let reportedCooldownUntil: number | null = null;
|
||||
const COOLDOWN_LOG_INTERVAL_MS = 5 * 60_000;
|
||||
|
||||
while (!isShuttingDown) {
|
||||
try {
|
||||
if (!accountAuth.isFullyAuthenticated()) {
|
||||
await accountAuth.ensureFullyAuthenticated();
|
||||
}
|
||||
if (!accountAuth.isFullyAuthenticated()) {
|
||||
if (Date.now() - lastAuthPausedLog > PAUSED_LOG_INTERVAL_MS) {
|
||||
logger.info('Bot logic is locked until full Reddit and Matrix authentication is verified.');
|
||||
lastAuthPausedLog = Date.now();
|
||||
}
|
||||
await sleep(PAUSE_POLL_MS);
|
||||
continue;
|
||||
}
|
||||
lastAuthPausedLog = 0;
|
||||
|
||||
if (webServer && !webServer.isRunning()) {
|
||||
if (Date.now() - lastPausedLog > PAUSED_LOG_INTERVAL_MS) {
|
||||
logger.info('Bot is paused. Use the web dashboard to start.');
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import http, { type Server } from 'node:http';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
export interface NativeLoginGateOptions {
|
||||
publicVncUrl: string;
|
||||
onComplete: () => void;
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value.replace(/[&<>"']/g, (character) => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
})[character] ?? character);
|
||||
}
|
||||
|
||||
function loginPage(publicVncUrl: string): string {
|
||||
const safeVncUrl = escapeHtml(publicVncUrl);
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Reddit login required</title>
|
||||
<style>
|
||||
body { margin: 0; font: 16px system-ui, sans-serif; color: #f5f5f5; background: #111; }
|
||||
main { max-width: 1100px; margin: 24px auto; padding: 0 20px; }
|
||||
.notice { padding: 18px; border: 1px solid #3c765f; border-radius: 12px; background: #17251f; }
|
||||
iframe { width: 100%; height: 70vh; margin-top: 18px; border: 1px solid #444; border-radius: 10px; background: #000; }
|
||||
button, a { display: inline-block; margin: 14px 8px 0 0; padding: 11px 16px; border: 0; border-radius: 8px; font-weight: 700; }
|
||||
button { color: white; background: #ff4500; cursor: pointer; }
|
||||
a { color: white; background: #444; text-decoration: none; }
|
||||
#status { margin-left: 8px; color: #b9dfcf; }
|
||||
</style>
|
||||
</head>
|
||||
<body><main>
|
||||
<section class="notice">
|
||||
<strong>Chrome is running without automation.</strong>
|
||||
<p>Log in to Reddit in the browser below. Only after Reddit shows you as signed in, click <em>I'm logged in - connect the bot</em>. Chrome will restart with the same profile and the bot will verify both Reddit and Matrix before any bot logic can run.</p>
|
||||
<button id="complete" type="button">I'm logged in - connect the bot</button>
|
||||
<a href="${safeVncUrl}" target="_blank" rel="noreferrer">Open VNC in a new tab</a>
|
||||
<span id="status"></span>
|
||||
</section>
|
||||
<iframe src="${safeVncUrl}" title="Reddit login browser"></iframe>
|
||||
<script>
|
||||
document.getElementById('complete').addEventListener('click', async () => {
|
||||
const button = document.getElementById('complete');
|
||||
const status = document.getElementById('status');
|
||||
button.disabled = true;
|
||||
status.textContent = 'Restarting Chrome and verifying your account...';
|
||||
const response = await fetch('/complete', { method: 'POST' });
|
||||
if (!response.ok) {
|
||||
button.disabled = false;
|
||||
status.textContent = 'Could not start verification. Please try again.';
|
||||
return;
|
||||
}
|
||||
setTimeout(() => window.location.reload(), 3000);
|
||||
});
|
||||
</script>
|
||||
</main></body></html>`;
|
||||
}
|
||||
|
||||
export function createNativeLoginGate(options: NativeLoginGateOptions): Server {
|
||||
return http.createServer((request, response) => {
|
||||
if (request.method === 'POST' && request.url === '/complete') {
|
||||
response.writeHead(202, { 'content-type': 'application/json' });
|
||||
response.end(JSON.stringify({ ok: true, restarting: true }));
|
||||
options.onComplete();
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method !== 'GET') {
|
||||
response.writeHead(405).end();
|
||||
return;
|
||||
}
|
||||
|
||||
response.writeHead(200, {
|
||||
'content-type': 'text/html; charset=utf-8',
|
||||
'cache-control': 'no-store',
|
||||
});
|
||||
response.end(loginPage(options.publicVncUrl));
|
||||
});
|
||||
}
|
||||
|
||||
function run(): void {
|
||||
const port = Number.parseInt(process.env.WEB_PORT ?? '3000', 10);
|
||||
const publicBase = (process.env.NOVNC_PUBLIC_URL ?? `http://localhost:${process.env.NOVNC_PORT ?? '6080'}`).replace(/\/$/, '');
|
||||
const publicVncUrl = `${publicBase}/vnc.html?autoconnect=1&resize=scale&reconnect=1`;
|
||||
let server: Server;
|
||||
server = createNativeLoginGate({
|
||||
publicVncUrl,
|
||||
onComplete: () => setTimeout(() => server.close(() => process.exit(0)), 250).unref(),
|
||||
});
|
||||
server.listen(port, () => {
|
||||
process.stdout.write(`[native-login] waiting for human login on port ${port}\n`);
|
||||
});
|
||||
}
|
||||
|
||||
const entryUrl = process.argv[1] ? pathToFileURL(process.argv[1]).href : '';
|
||||
if (import.meta.url === entryUrl) run();
|
||||
@@ -0,0 +1,177 @@
|
||||
import type { Logger } from '../utils/logger.js';
|
||||
|
||||
export type AccountAuthStatus =
|
||||
| 'login-required'
|
||||
| 'checking'
|
||||
| 'authenticated';
|
||||
|
||||
export interface AuthenticatedAccount {
|
||||
accountId: string;
|
||||
redditUsername: string;
|
||||
matrixUserId: string;
|
||||
}
|
||||
|
||||
export interface AccountAuthSnapshot {
|
||||
status: AccountAuthStatus;
|
||||
accountId: string | null;
|
||||
redditUsername: string | null;
|
||||
matrixUserId: string | null;
|
||||
reason: string | null;
|
||||
}
|
||||
|
||||
export interface AccountAuthGate {
|
||||
isFullyAuthenticated(): boolean;
|
||||
invalidate(reason: string): void;
|
||||
}
|
||||
|
||||
export type AccountAuthenticator = () => Promise<AuthenticatedAccount | null>;
|
||||
export type AccountAuthenticatedListener = (account: AuthenticatedAccount) => void | Promise<void>;
|
||||
|
||||
export interface AccountAuthContextOptions {
|
||||
leaseMs?: number;
|
||||
retryDelayMs?: number;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
export interface CurrentAccountAuthentication {
|
||||
getRedditUsername(): Promise<string | null>;
|
||||
refreshMatrixCredentials(): Promise<{ userId: string } | null>;
|
||||
}
|
||||
|
||||
export async function authenticateCurrentAccount(
|
||||
authentication: CurrentAccountAuthentication,
|
||||
): Promise<AuthenticatedAccount | null> {
|
||||
const redditUsername = await authentication.getRedditUsername();
|
||||
if (!redditUsername) return null;
|
||||
|
||||
const credentials = await authentication.refreshMatrixCredentials();
|
||||
if (!credentials?.userId) return null;
|
||||
return {
|
||||
accountId: credentials.userId,
|
||||
redditUsername,
|
||||
matrixUserId: credentials.userId,
|
||||
};
|
||||
}
|
||||
|
||||
export const denyAccountAuth: AccountAuthGate = {
|
||||
isFullyAuthenticated: () => false,
|
||||
invalidate: () => undefined,
|
||||
};
|
||||
|
||||
export class AccountAuthContext implements AccountAuthGate {
|
||||
private snapshot: AccountAuthSnapshot = {
|
||||
status: 'login-required',
|
||||
accountId: null,
|
||||
redditUsername: null,
|
||||
matrixUserId: null,
|
||||
reason: 'Full Reddit and Matrix authentication has not been verified',
|
||||
};
|
||||
private authenticator: AccountAuthenticator | null = null;
|
||||
private authenticationInFlight: Promise<boolean> | null = null;
|
||||
private readonly listeners = new Set<AccountAuthenticatedListener>();
|
||||
private authenticatedUntil = 0;
|
||||
private nextAuthenticationAttemptAt = 0;
|
||||
private readonly leaseMs: number;
|
||||
private readonly retryDelayMs: number;
|
||||
private readonly now: () => number;
|
||||
|
||||
constructor(
|
||||
private readonly logger: Logger,
|
||||
options: AccountAuthContextOptions = {},
|
||||
) {
|
||||
this.leaseMs = options.leaseMs ?? 30 * 60 * 1000;
|
||||
this.retryDelayMs = options.retryDelayMs ?? 60 * 1000;
|
||||
this.now = options.now ?? Date.now;
|
||||
}
|
||||
|
||||
setAuthenticator(authenticator: AccountAuthenticator): void {
|
||||
this.authenticator = authenticator;
|
||||
}
|
||||
|
||||
isFullyAuthenticated(): boolean {
|
||||
if (this.snapshot.status !== 'authenticated') return false;
|
||||
if (this.now() < this.authenticatedUntil) return true;
|
||||
this.invalidate('Full account authentication lease expired');
|
||||
return false;
|
||||
}
|
||||
|
||||
getSnapshot(): AccountAuthSnapshot {
|
||||
this.isFullyAuthenticated();
|
||||
return { ...this.snapshot };
|
||||
}
|
||||
|
||||
markAuthenticated(account: AuthenticatedAccount): void {
|
||||
if (!account.accountId || !account.redditUsername || !account.matrixUserId) {
|
||||
throw new Error('A fully authenticated account requires Reddit and Matrix identities');
|
||||
}
|
||||
if (account.accountId !== account.matrixUserId) {
|
||||
throw new Error('Authenticated account ID must match the Matrix user ID');
|
||||
}
|
||||
|
||||
const changedAccount = this.snapshot.accountId !== account.accountId;
|
||||
const wasAuthenticated = this.isFullyAuthenticated();
|
||||
this.snapshot = {
|
||||
status: 'authenticated',
|
||||
accountId: account.accountId,
|
||||
redditUsername: account.redditUsername,
|
||||
matrixUserId: account.matrixUserId,
|
||||
reason: null,
|
||||
};
|
||||
this.authenticatedUntil = this.now() + this.leaseMs;
|
||||
this.nextAuthenticationAttemptAt = 0;
|
||||
if (!wasAuthenticated || changedAccount) {
|
||||
for (const listener of this.listeners) {
|
||||
void Promise.resolve(listener(account)).catch((error) => {
|
||||
this.logger.error('Authenticated account listener failed:', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
invalidate(reason: string): void {
|
||||
this.authenticatedUntil = 0;
|
||||
this.snapshot = {
|
||||
status: 'login-required',
|
||||
accountId: null,
|
||||
redditUsername: null,
|
||||
matrixUserId: null,
|
||||
reason,
|
||||
};
|
||||
}
|
||||
|
||||
onAuthenticated(listener: AccountAuthenticatedListener): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
ensureFullyAuthenticated(): Promise<boolean> {
|
||||
if (this.isFullyAuthenticated()) return Promise.resolve(true);
|
||||
if (this.authenticationInFlight) return this.authenticationInFlight;
|
||||
if (!this.authenticator) return Promise.resolve(false);
|
||||
if (this.now() < this.nextAuthenticationAttemptAt) return Promise.resolve(false);
|
||||
|
||||
this.snapshot = { ...this.snapshot, status: 'checking', reason: null };
|
||||
this.authenticationInFlight = this.authenticate().finally(() => {
|
||||
this.authenticationInFlight = null;
|
||||
});
|
||||
return this.authenticationInFlight;
|
||||
}
|
||||
|
||||
private async authenticate(): Promise<boolean> {
|
||||
try {
|
||||
const account = await this.authenticator!();
|
||||
if (!account) {
|
||||
this.invalidate('Full Reddit and Matrix authentication is required');
|
||||
this.nextAuthenticationAttemptAt = this.now() + this.retryDelayMs;
|
||||
return false;
|
||||
}
|
||||
this.markAuthenticated(account);
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.invalidate(error instanceof Error ? error.message : String(error));
|
||||
this.nextAuthenticationAttemptAt = this.now() + this.retryDelayMs;
|
||||
this.logger.warn('Full account authentication failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import type { ActiveChat, ChatMessage, ScrapedChatMessage, InvitationCooldown }
|
||||
|
||||
export type { InvitationCooldown } from './types.js';
|
||||
import { fetchWithTimeout } from '../../utils/fetch-with-timeout.js';
|
||||
import type { AccountAuthGate } from '../account-auth-context.js';
|
||||
|
||||
const ACTIVE_WINDOW_MS = 96 * 60 * 60 * 1000;
|
||||
const ROOM_URL_PREFIX = 'https://chat.reddit.com/room/';
|
||||
@@ -131,6 +132,7 @@ export class ChatService {
|
||||
cache?: ChatCache,
|
||||
private messagesRepo?: MessagesRepository,
|
||||
private redditUsersRepo?: RedditUsersRepository,
|
||||
private accountAuth?: AccountAuthGate,
|
||||
) {
|
||||
this.cache = cache ?? new ChatCache(logger);
|
||||
if (this.redditUsersRepo) {
|
||||
@@ -178,6 +180,11 @@ export class ChatService {
|
||||
* refresh itself fails, we propagate.
|
||||
*/
|
||||
private async withAuth<T>(fn: () => Promise<T>): Promise<T> {
|
||||
if (!this.accountAuth?.isFullyAuthenticated()) {
|
||||
throw new MatrixAuthenticationUnavailableError(
|
||||
new Error('Full account authentication is required'),
|
||||
);
|
||||
}
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
@@ -186,9 +193,15 @@ export class ChatService {
|
||||
try {
|
||||
await this.tokenRefresher.refresh();
|
||||
} catch (refreshError) {
|
||||
this.accountAuth?.invalidate('Matrix authentication could not be restored');
|
||||
throw new MatrixAuthenticationUnavailableError(refreshError);
|
||||
}
|
||||
return await fn();
|
||||
try {
|
||||
return await fn();
|
||||
} catch (retryError) {
|
||||
this.accountAuth?.invalidate('Matrix authentication was rejected after refresh');
|
||||
throw retryError;
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
@@ -461,6 +474,7 @@ export class ChatService {
|
||||
* `chat.messages`.
|
||||
*/
|
||||
async loadConversationDetails(chat: ActiveChat): Promise<void> {
|
||||
if (!this.accountAuth?.isFullyAuthenticated()) return;
|
||||
return this.loadDetails(chat);
|
||||
}
|
||||
|
||||
@@ -470,6 +484,7 @@ export class ChatService {
|
||||
* a raw Matrix room ID like `!abc:reddit.com`.
|
||||
*/
|
||||
async sendChatMessage(chatUrlOrRoomId: string, message: string): Promise<boolean> {
|
||||
if (!this.accountAuth?.isFullyAuthenticated()) return false;
|
||||
const active = this.getInvitationCooldown();
|
||||
if (active) {
|
||||
const secondsLeft = Math.ceil((active.until - Date.now()) / 1000);
|
||||
@@ -510,6 +525,7 @@ export class ChatService {
|
||||
* line. Returns the new chat (or null on failure).
|
||||
*/
|
||||
async startConversation(username: string, message: string): Promise<ActiveChat | null> {
|
||||
if (!this.accountAuth?.isFullyAuthenticated()) return null;
|
||||
const active = this.getInvitationCooldown();
|
||||
if (active) {
|
||||
const secondsLeft = Math.ceil((active.until - Date.now()) / 1000);
|
||||
@@ -573,7 +589,7 @@ export class ChatService {
|
||||
* old Playwright sender; always returns true.
|
||||
*/
|
||||
async verifyMessageSent(_recipient: string, _messageText: string): Promise<boolean> {
|
||||
return true;
|
||||
return this.accountAuth?.isFullyAuthenticated() ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1030,6 +1046,10 @@ export class ChatService {
|
||||
* cache. Best-effort; safe to await or fire-and-forget.
|
||||
*/
|
||||
async enrichUsernames(): Promise<void> {
|
||||
if (!this.accountAuth?.isFullyAuthenticated()) {
|
||||
this.logger.debug('[chat] username enrichment skipped until full account authentication');
|
||||
return;
|
||||
}
|
||||
if (!this.usernameResolver) return;
|
||||
let dirty = false;
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Logger } from '../utils/logger.js';
|
||||
import type { ChatService } from './chat.js';
|
||||
import { denyAccountAuth, type AccountAuthGate } from './account-auth-context.js';
|
||||
|
||||
export interface MediaSyncStatus {
|
||||
isSyncing: boolean;
|
||||
@@ -22,7 +23,8 @@ export class MediaSyncService {
|
||||
constructor(
|
||||
private chatService: ChatService,
|
||||
private logger: Logger,
|
||||
intervalMs: number = 30 * 60 * 1000 // default 30 minutes
|
||||
intervalMs: number = 30 * 60 * 1000, // default 30 minutes
|
||||
private accountAuth: AccountAuthGate = denyAccountAuth,
|
||||
) {
|
||||
this.intervalMs = intervalMs;
|
||||
}
|
||||
@@ -64,6 +66,10 @@ export class MediaSyncService {
|
||||
* Returns immediately if a sync is already in progress.
|
||||
*/
|
||||
async triggerSync(full = false): Promise<boolean> {
|
||||
if (!this.accountAuth.isFullyAuthenticated()) {
|
||||
this.logger.warn('Media sync: blocked until the account is fully authenticated');
|
||||
return false;
|
||||
}
|
||||
if (this.syncing) {
|
||||
this.logger.info('Media sync: sync already in progress, skipping trigger');
|
||||
return false;
|
||||
@@ -86,6 +92,10 @@ export class MediaSyncService {
|
||||
}
|
||||
|
||||
private async runSync(full = false): Promise<void> {
|
||||
if (!this.accountAuth.isFullyAuthenticated()) {
|
||||
this.logger.debug('Media sync: skipped because the account is not fully authenticated');
|
||||
return;
|
||||
}
|
||||
if (this.syncing) return;
|
||||
this.syncing = true;
|
||||
this.lastError = null;
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
export interface NativeLoginHandoff {
|
||||
request(): void;
|
||||
}
|
||||
|
||||
export interface ProcessNativeLoginHandoffOptions {
|
||||
exitCode?: number;
|
||||
delayMs?: number;
|
||||
exit?: (code: number) => void;
|
||||
}
|
||||
|
||||
export class ProcessNativeLoginHandoff implements NativeLoginHandoff {
|
||||
private requested = false;
|
||||
private readonly exitCode: number;
|
||||
private readonly delayMs: number;
|
||||
private readonly exit: (code: number) => void;
|
||||
|
||||
constructor(options: ProcessNativeLoginHandoffOptions = {}) {
|
||||
this.exitCode = options.exitCode ?? 75;
|
||||
this.delayMs = options.delayMs ?? 250;
|
||||
this.exit = options.exit ?? ((code) => process.exit(code));
|
||||
}
|
||||
|
||||
request(): void {
|
||||
if (this.requested) return;
|
||||
this.requested = true;
|
||||
setTimeout(() => this.exit(this.exitCode), this.delayMs).unref();
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { Page } from 'playwright';
|
||||
import type { Logger } from '../utils/logger.js';
|
||||
import type { MessagesRepository, SuccessfulMessagesRepository, SentMessageRecord } from '../database/repositories.js';
|
||||
import type { PostsRepository } from '../database/repositories.js';
|
||||
import type { AccountAuthGate } from './account-auth-context.js';
|
||||
|
||||
export interface ResponseCheckResult {
|
||||
messageId: number;
|
||||
@@ -16,11 +17,16 @@ export class ResponseCheckerService {
|
||||
private messagesRepo: MessagesRepository,
|
||||
private successfulMessagesRepo: SuccessfulMessagesRepository,
|
||||
private postsRepo: PostsRepository,
|
||||
private logger: Logger
|
||||
private logger: Logger,
|
||||
private accountAuth?: AccountAuthGate,
|
||||
) { }
|
||||
|
||||
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
|
||||
|
||||
@@ -13,6 +13,8 @@ import type { RateLimiter } from '../utils/rate-limiter.js';
|
||||
import type { OpenRouterClient } from '../ai/openrouter.js';
|
||||
import type { StrategySelector } from '../services/strategy-selector.js';
|
||||
import type { BrowserClient } from '../browser/client.js';
|
||||
import type { AccountAuthContext } from '../services/account-auth-context.js';
|
||||
import type { NativeLoginHandoff } from '../services/native-login-handoff.js';
|
||||
import type { ActivityLog } from './activity-log.js';
|
||||
import type { LogBroadcaster } from './log-broadcaster.js';
|
||||
import type { RuntimeConfig } from './types.js';
|
||||
@@ -40,6 +42,8 @@ export interface WebContext {
|
||||
ai?: OpenRouterClient;
|
||||
strategySelector?: StrategySelector;
|
||||
browser?: BrowserClient;
|
||||
accountAuth?: AccountAuthContext;
|
||||
nativeLogin?: NativeLoginHandoff;
|
||||
};
|
||||
|
||||
/** Bot pause/resume toggle read by the main loop. */
|
||||
|
||||
@@ -5,6 +5,11 @@ export function createControlRouter(ctx: WebContext): Router {
|
||||
const router = Router();
|
||||
|
||||
router.post('/start', (_req, res) => {
|
||||
if (!ctx.services.accountAuth?.isFullyAuthenticated()) {
|
||||
ctx.logger.warn('Bot start blocked until full account authentication');
|
||||
res.status(409).json({ success: false, running: false, authenticated: false });
|
||||
return;
|
||||
}
|
||||
ctx.setBotRunning(true);
|
||||
ctx.activity.push('sent', 'Bot started by user');
|
||||
ctx.logger.info('Bot started via web UI');
|
||||
|
||||
+24
-15
@@ -18,36 +18,45 @@ export function createLoginRouter(ctx: WebContext): Router {
|
||||
|
||||
router.get('/login/status', async (_req, res) => {
|
||||
const browser = ctx.services.browser;
|
||||
if (!browser) {
|
||||
const accountAuth = ctx.services.accountAuth;
|
||||
if (!browser || !accountAuth) {
|
||||
res.status(503).json({ ready: false, error: 'browser not initialised' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const loggedIn = await browser.isSessionValid();
|
||||
res.json({ ready: true, loggedIn, status: browser.getSessionState() });
|
||||
const loggedIn = await accountAuth.ensureFullyAuthenticated();
|
||||
const browserState = browser.getSessionState();
|
||||
res.json({
|
||||
ready: true,
|
||||
loggedIn,
|
||||
status: loggedIn
|
||||
? 'authenticated'
|
||||
: browserState === 'unreachable'
|
||||
? 'unreachable'
|
||||
: 'login-required',
|
||||
});
|
||||
} catch (error) {
|
||||
ctx.logger.error('Failed to check login status:', error);
|
||||
res.status(500).json({ ready: true, loggedIn: false, error: 'check failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Navigate the headed Chromium running under Xvfb to reddit.com/login so
|
||||
// the human watching the noVNC stream can complete the flow.
|
||||
// Restart into a completely native Chrome process. The container supervisor
|
||||
// does not enable DevTools until the human explicitly completes the handoff.
|
||||
router.post('/login/start', async (_req, res) => {
|
||||
const browser = ctx.services.browser;
|
||||
if (!browser) {
|
||||
res.status(503).json({ ok: false, error: 'browser not initialised' });
|
||||
const nativeLogin = ctx.services.nativeLogin;
|
||||
if (!nativeLogin) {
|
||||
res.status(503).json({ ok: false, error: 'native login unavailable' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const page = await browser.getPage();
|
||||
await page.bringToFront().catch(() => undefined);
|
||||
await page.goto('https://www.reddit.com/login', { waitUntil: 'domcontentloaded' });
|
||||
ctx.logger.info('Opened Reddit login page for human completion via VNC');
|
||||
res.json({ ok: true });
|
||||
ctx.services.accountAuth?.invalidate('Native Reddit login requested');
|
||||
nativeLogin.request();
|
||||
ctx.logger.info('Restarting into native Chrome login mode; bot logic remains locked');
|
||||
res.status(202).json({ ok: true, restarting: true });
|
||||
} catch (error) {
|
||||
ctx.logger.error('Failed to open login page:', error);
|
||||
res.status(500).json({ ok: false, error: 'navigation failed' });
|
||||
ctx.logger.error('Failed to start native login mode:', error);
|
||||
res.status(500).json({ ok: false, error: 'handoff failed' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ import type { RateLimiter } from '../utils/rate-limiter.js';
|
||||
import type { OpenRouterClient } from '../ai/openrouter.js';
|
||||
import type { StrategySelector } from '../services/strategy-selector.js';
|
||||
import type { BrowserClient } from '../browser/client.js';
|
||||
import type { AccountAuthContext } from '../services/account-auth-context.js';
|
||||
import type { NativeLoginHandoff } from '../services/native-login-handoff.js';
|
||||
import { ActivityLog } from './activity-log.js';
|
||||
import { LogBroadcaster } from './log-broadcaster.js';
|
||||
import type { WebContext } from './context.js';
|
||||
@@ -199,6 +201,14 @@ export class WebServer {
|
||||
this.services.browser = browserClient;
|
||||
}
|
||||
|
||||
setAccountAuthContext(accountAuth: AccountAuthContext): void {
|
||||
this.services.accountAuth = accountAuth;
|
||||
}
|
||||
|
||||
setNativeLoginHandoff(nativeLogin: NativeLoginHandoff): void {
|
||||
this.services.nativeLogin = nativeLogin;
|
||||
}
|
||||
|
||||
logActivity(type: ActivityItem['type'], message: string, recipient?: string, imageUrl?: string): void {
|
||||
this.activity.push(type, message, recipient, imageUrl);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user