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
@@ -1,6 +1,11 @@
|
||||
import { Router } from 'express';
|
||||
import type { ActiveChat } from '../../services/chat.js';
|
||||
import type { WebContext } from '../context.js';
|
||||
import {
|
||||
buildConversationState,
|
||||
chooseConversationIntent,
|
||||
mergeConversationState,
|
||||
} from '../../services/conversation-state.js';
|
||||
|
||||
export function createConversationsRouter(ctx: WebContext): Router {
|
||||
const router = Router();
|
||||
@@ -13,6 +18,18 @@ export function createConversationsRouter(ctx: WebContext): Router {
|
||||
);
|
||||
}
|
||||
|
||||
function getConversationIntelligence(conversation: ActiveChat, requestedIntent?: 'media-ask') {
|
||||
const messages = (conversation.messages ?? []).map((message) => ({
|
||||
sender: message.sender,
|
||||
text: message.text,
|
||||
isSelf: message.isSelf,
|
||||
}));
|
||||
const saved = conversation.roomId ? ctx.services.conversationState?.get(conversation.roomId) : undefined;
|
||||
const state = mergeConversationState(saved, buildConversationState(messages));
|
||||
if (conversation.roomId) ctx.services.conversationState?.save(conversation.roomId, state);
|
||||
return { messages, state, intent: chooseConversationIntent(messages, state, requestedIntent) };
|
||||
}
|
||||
|
||||
router.get('/conversations', async (_req, res) => {
|
||||
if (!ctx.services.chat) {
|
||||
res.json([]);
|
||||
@@ -138,11 +155,14 @@ export function createConversationsRouter(ctx: WebContext): Router {
|
||||
return;
|
||||
}
|
||||
|
||||
const intelligence = getConversationIntelligence(conversation);
|
||||
const suggestedReply = await ctx.services.ai.generateReply(
|
||||
conversation.messages.map((m) => ({ sender: m.sender, text: m.text, isSelf: m.isSelf })),
|
||||
intelligence.messages,
|
||||
conversation.username,
|
||||
intelligence.state,
|
||||
intelligence.intent,
|
||||
);
|
||||
res.json({ suggestedReply });
|
||||
res.json({ suggestedReply, intent: intelligence.intent, conversationState: intelligence.state });
|
||||
} catch (error) {
|
||||
ctx.logger.error('Failed to generate suggested reply:', error);
|
||||
res.status(500).json({ error: String(error) });
|
||||
@@ -173,11 +193,18 @@ export function createConversationsRouter(ctx: WebContext): Router {
|
||||
return;
|
||||
}
|
||||
|
||||
const intelligence = getConversationIntelligence(conversation, 'media-ask');
|
||||
if (intelligence.intent.kind !== 'media-ask') {
|
||||
res.status(409).json({ error: intelligence.intent.reason, intent: intelligence.intent, conversationState: intelligence.state });
|
||||
return;
|
||||
}
|
||||
const suggestedReply = await ctx.services.ai.generateMediaAsk(
|
||||
conversation.messages.map((m) => ({ sender: m.sender, text: m.text, isSelf: m.isSelf })),
|
||||
intelligence.messages,
|
||||
conversation.username,
|
||||
intelligence.state,
|
||||
intelligence.intent,
|
||||
);
|
||||
res.json({ suggestedReply });
|
||||
res.json({ suggestedReply, intent: intelligence.intent, conversationState: intelligence.state });
|
||||
} catch (error) {
|
||||
ctx.logger.error('Failed to generate media-ask message:', error);
|
||||
res.status(500).json({ error: String(error) });
|
||||
@@ -208,6 +235,19 @@ export function createConversationsRouter(ctx: WebContext): Router {
|
||||
|
||||
const success = await ctx.services.chat.sendChatMessage(conversation.url, message.trim());
|
||||
if (success) {
|
||||
if (conversation.roomId) {
|
||||
const currentMessages = (conversation.messages ?? []).map((item) => ({
|
||||
sender: item.sender,
|
||||
text: item.text,
|
||||
isSelf: item.isSelf,
|
||||
}));
|
||||
const saved = ctx.services.conversationState?.get(conversation.roomId);
|
||||
const updatedState = mergeConversationState(
|
||||
saved,
|
||||
buildConversationState([...currentMessages, { sender: 'Me', text: message.trim(), isSelf: true }]),
|
||||
);
|
||||
ctx.services.conversationState?.save(conversation.roomId, updatedState);
|
||||
}
|
||||
ctx.activity.push('sent', `Sent chat reply to u/${conversation.username}`, conversation.username);
|
||||
ctx.broadcaster.push('info', `Chat reply sent to u/${conversation.username}`);
|
||||
res.json({ success: true });
|
||||
@@ -249,6 +289,12 @@ export function createConversationsRouter(ctx: WebContext): Router {
|
||||
return;
|
||||
}
|
||||
ctx.activity.push('sent', `Started chat with u/${chat.username}`, chat.username);
|
||||
if (chat.roomId) {
|
||||
ctx.services.conversationState?.save(
|
||||
chat.roomId,
|
||||
buildConversationState(chat.messages ?? [{ sender: 'Me', text: message.trim(), isSelf: true }]),
|
||||
);
|
||||
}
|
||||
ctx.broadcaster.push('info', `Started new chat with u/${chat.username}`);
|
||||
res.json({ success: true, chat });
|
||||
} catch (error) {
|
||||
|
||||
+1
-18
@@ -1,29 +1,12 @@
|
||||
import { Router } from 'express';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import type { WebContext } from '../context.js';
|
||||
|
||||
export function createDebugRouter(ctx: WebContext): Router {
|
||||
const router = Router();
|
||||
|
||||
// Serves the screenshot dropped by the matrix token-extractor when the
|
||||
// sniff times out (data/matrix-debug.png). Lets us see what chat.reddit.com
|
||||
// looked like in the headed browser without needing host shell access.
|
||||
router.get('/debug/matrix-debug.png', (_req, res) => {
|
||||
const p = path.resolve(process.cwd(), 'data/matrix-debug.png');
|
||||
if (!fs.existsSync(p)) {
|
||||
res.status(404).json({ error: 'No matrix-debug.png yet — sniff has not failed since last clean start.' });
|
||||
return;
|
||||
}
|
||||
const stat = fs.statSync(p);
|
||||
res.set('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
res.set('X-Captured-At', stat.mtime.toISOString());
|
||||
res.sendFile(p);
|
||||
});
|
||||
|
||||
// Dumps the browser context's cookies (names + metadata only — values are
|
||||
// hashed) so we can tell which session/OAuth cookies are set after the
|
||||
// auto-login and which the chat surface might be missing.
|
||||
// extension import and which the chat surface might be missing.
|
||||
router.get('/debug/cookies', async (req, res) => {
|
||||
const browser = ctx.services.browser;
|
||||
if (!browser) {
|
||||
|
||||
+68
-37
@@ -1,62 +1,93 @@
|
||||
import { Router } from 'express';
|
||||
import type { Cookie } from 'playwright';
|
||||
import { z } from 'zod';
|
||||
import type { WebContext } from '../context.js';
|
||||
|
||||
// Keep this server-side parser local: the server's tsconfig intentionally
|
||||
// builds only src/, while the dashboard compiles the shared API contract.
|
||||
const RedditCookieImportRequestSchema = z.strictObject({
|
||||
cookies: z.array(z.strictObject({
|
||||
name: z.string().min(1).max(256),
|
||||
value: z.string().max(16_384),
|
||||
domain: z.string().min(1).max(253),
|
||||
path: z.string().min(1).max(4_096).default('/'),
|
||||
expires: z.number().finite().optional(),
|
||||
httpOnly: z.boolean().optional(),
|
||||
secure: z.boolean().optional(),
|
||||
sameSite: z.enum(['strict', 'lax', 'none', 'no_restriction', 'unspecified']).optional(),
|
||||
})).min(1).max(100),
|
||||
matrix: z.strictObject({
|
||||
homeserver: z.string().url(),
|
||||
accessToken: z.string().min(1).max(16_384),
|
||||
userId: z.string().min(1).max(512),
|
||||
capturedAt: z.number().finite(),
|
||||
}),
|
||||
});
|
||||
|
||||
function isRedditDomain(domain: string): boolean {
|
||||
const normalised = domain.toLowerCase().replace(/^\./, '');
|
||||
return normalised === 'reddit.com' || normalised.endsWith('.reddit.com');
|
||||
}
|
||||
|
||||
function toPlaywrightCookie(cookie: { name: string; value: string; domain: string; path: string; expires?: number; httpOnly?: boolean; secure?: boolean; sameSite?: string }): Cookie {
|
||||
return {
|
||||
name: cookie.name,
|
||||
value: cookie.value,
|
||||
domain: cookie.domain,
|
||||
path: cookie.path,
|
||||
expires: cookie.expires ?? -1,
|
||||
httpOnly: cookie.httpOnly ?? false,
|
||||
secure: cookie.secure ?? true,
|
||||
sameSite: cookie.sameSite === 'strict' ? 'Strict' : cookie.sameSite === 'none' || cookie.sameSite === 'no_restriction' ? 'None' : 'Lax',
|
||||
};
|
||||
}
|
||||
|
||||
export function createLoginRouter(ctx: WebContext): Router {
|
||||
const router = Router();
|
||||
|
||||
// Info the dashboard uses to decide whether to show the VNC iframe and
|
||||
// which port to point it at.
|
||||
router.get('/login/info', (_req, res) => {
|
||||
const novncPort = parseInt(process.env.NOVNC_PORT ?? '6080', 10);
|
||||
res.json({
|
||||
vncEnabled: Boolean(process.env.DISPLAY),
|
||||
novncPort,
|
||||
publicUrl: process.env.NOVNC_PUBLIC_URL || null,
|
||||
passwordRequired: Boolean(process.env.VNC_PASSWORD),
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/login/status', async (_req, res) => {
|
||||
const browser = ctx.services.browser;
|
||||
const accountAuth = ctx.services.accountAuth;
|
||||
if (!browser || !accountAuth) {
|
||||
if (!browser) {
|
||||
res.status(503).json({ ready: false, error: 'browser not initialised' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const loggedIn = await accountAuth.ensureFullyAuthenticated();
|
||||
const browserState = browser.getSessionState();
|
||||
res.json({
|
||||
ready: true,
|
||||
loggedIn,
|
||||
status: loggedIn
|
||||
? 'authenticated'
|
||||
: browserState === 'unreachable'
|
||||
? 'unreachable'
|
||||
: 'login-required',
|
||||
});
|
||||
const loggedIn = await browser.isSessionValid();
|
||||
const username = loggedIn ? await browser.getAuthenticatedUsername() : null;
|
||||
res.json({ ready: true, loggedIn, status: browser.getSessionState(), username });
|
||||
} catch (error) {
|
||||
ctx.logger.error('Failed to check login status:', error);
|
||||
res.status(500).json({ ready: true, loggedIn: false, error: 'check failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// 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 nativeLogin = ctx.services.nativeLogin;
|
||||
if (!nativeLogin) {
|
||||
res.status(503).json({ ok: false, error: 'native login unavailable' });
|
||||
router.post('/login/session-import', async (req, res) => {
|
||||
const importer = ctx.services.sessionImporter;
|
||||
if (!importer) {
|
||||
res.status(503).json({ ok: false, error: 'session importer not initialised' });
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = RedditCookieImportRequestSchema.safeParse(req.body);
|
||||
if (!payload.success) {
|
||||
res.status(400).json({ ok: false, error: 'Invalid Reddit session export' });
|
||||
return;
|
||||
}
|
||||
|
||||
const cookies = payload.data.cookies.filter((cookie) => isRedditDomain(cookie.domain));
|
||||
if (cookies.length === 0) {
|
||||
res.status(400).json({ ok: false, error: 'The export did not contain Reddit cookies' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
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 });
|
||||
await importer.import(cookies.map(toPlaywrightCookie), payload.data.matrix);
|
||||
ctx.logger.info(`Imported ${cookies.length} Reddit session cookies and verified the browser session`);
|
||||
res.json({ ok: true, status: 'authenticated' });
|
||||
} catch (error) {
|
||||
ctx.logger.error('Failed to start native login mode:', error);
|
||||
res.status(500).json({ ok: false, error: 'handoff failed' });
|
||||
ctx.logger.error('Failed to import Reddit session cookies:', error);
|
||||
const message = error instanceof Error ? error.message : 'Could not import Reddit session';
|
||||
res.status(401).json({ ok: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user