Discord
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
type CdpCommandOptions = {
|
||||
relayPort: number;
|
||||
userToken: string;
|
||||
targetId?: string;
|
||||
};
|
||||
|
||||
async function sendCdpCommand(opts: CdpCommandOptions, method: string, params?: unknown): Promise<unknown> {
|
||||
const { relayPort, userToken, targetId } = opts;
|
||||
const url = `ws://127.0.0.1:${relayPort}/cdp?token=${encodeURIComponent(userToken)}`;
|
||||
|
||||
return await new Promise<unknown>((resolve, reject) => {
|
||||
const ws = new WebSocket(url);
|
||||
let settled = false;
|
||||
const timeout = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
ws.close();
|
||||
reject(new Error(`CDP command timeout: ${method}`));
|
||||
}, 30_000);
|
||||
|
||||
ws.addEventListener('open', () => {
|
||||
const cmd: Record<string, unknown> = { id: 1, method };
|
||||
if (params) cmd.params = params;
|
||||
if (targetId) cmd.sessionId = targetId;
|
||||
ws.send(JSON.stringify(cmd));
|
||||
});
|
||||
|
||||
ws.addEventListener('message', (event) => {
|
||||
if (settled) return;
|
||||
try {
|
||||
const msg = JSON.parse(String(event.data)) as { id?: number; result?: unknown; error?: { message: string } };
|
||||
if (msg.id === 1) {
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
ws.close();
|
||||
if (msg.error) reject(new Error(msg.error.message));
|
||||
else resolve(msg.result);
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors, wait for correct message
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener('error', () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
reject(new Error('CDP WebSocket connection failed'));
|
||||
});
|
||||
|
||||
ws.addEventListener('close', () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
reject(new Error('CDP WebSocket closed before response'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function captureScreenshot(
|
||||
opts: CdpCommandOptions,
|
||||
format: 'png' | 'jpeg' = 'png',
|
||||
quality?: number,
|
||||
): Promise<string> {
|
||||
const params: Record<string, unknown> = { format };
|
||||
if (quality !== undefined) params.quality = quality;
|
||||
const result = (await sendCdpCommand(opts, 'Page.captureScreenshot', params)) as { data: string };
|
||||
return result.data;
|
||||
}
|
||||
|
||||
export async function evaluateJS(opts: CdpCommandOptions, expression: string): Promise<unknown> {
|
||||
const result = (await sendCdpCommand(opts, 'Runtime.evaluate', {
|
||||
expression,
|
||||
returnByValue: true,
|
||||
awaitPromise: true,
|
||||
})) as { result?: { value?: unknown; description?: string }; exceptionDetails?: { text?: string } };
|
||||
|
||||
if (result.exceptionDetails) {
|
||||
throw new Error(result.exceptionDetails.text ?? 'Evaluation failed');
|
||||
}
|
||||
return result.result?.value;
|
||||
}
|
||||
|
||||
export async function navigateTo(opts: CdpCommandOptions, url: string): Promise<void> {
|
||||
await sendCdpCommand(opts, 'Page.navigate', { url });
|
||||
}
|
||||
|
||||
export async function getPageInfo(opts: CdpCommandOptions): Promise<{ title: string; url: string }> {
|
||||
const result = (await sendCdpCommand(opts, 'Runtime.evaluate', {
|
||||
expression: 'JSON.stringify({ title: document.title, url: location.href })',
|
||||
returnByValue: true,
|
||||
})) as { result?: { value?: string } };
|
||||
|
||||
try {
|
||||
return JSON.parse(result.result?.value ?? '{}');
|
||||
} catch {
|
||||
return { title: '', url: '' };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { createHmac } from 'node:crypto';
|
||||
|
||||
const RELAY_TOKEN_CONTEXT = 'officer-browser-relay-v1';
|
||||
|
||||
const { JWT_SECRET } = process.env;
|
||||
|
||||
if (!JWT_SECRET) {
|
||||
throw new Error('JWT_SECRET is required for browser relay auth');
|
||||
}
|
||||
|
||||
export function deriveRelayToken(userId: number, port: number): string {
|
||||
return createHmac('sha256', JWT_SECRET!)
|
||||
.update(`${RELAY_TOKEN_CONTEXT}:${port}:${userId}`)
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
const tokenToUser = new Map<string, number>();
|
||||
|
||||
export function registerUserToken(userId: number, port: number): string {
|
||||
const token = deriveRelayToken(userId, port);
|
||||
tokenToUser.set(token, userId);
|
||||
return token;
|
||||
}
|
||||
|
||||
export function resolveUserFromToken(token: string): number | null {
|
||||
return tokenToUser.get(token) ?? null;
|
||||
}
|
||||
|
||||
export function getRelayAuthHeader(): string {
|
||||
return 'x-officer-relay-token';
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
export type TargetInfo = {
|
||||
targetId: string;
|
||||
type?: string;
|
||||
title?: string;
|
||||
url?: string;
|
||||
attached?: boolean;
|
||||
};
|
||||
|
||||
export type ConnectedTarget = {
|
||||
sessionId: string;
|
||||
targetId: string;
|
||||
targetInfo: TargetInfo;
|
||||
};
|
||||
|
||||
export type RelayStatus = {
|
||||
extensionConnected: boolean;
|
||||
targetCount: number;
|
||||
};
|
||||
|
||||
export type CdpCommand = {
|
||||
id: number;
|
||||
method: string;
|
||||
params?: unknown;
|
||||
sessionId?: string;
|
||||
};
|
||||
|
||||
export type CdpResponse = {
|
||||
id: number;
|
||||
result?: unknown;
|
||||
error?: { message: string };
|
||||
sessionId?: string;
|
||||
};
|
||||
|
||||
export type CdpEvent = {
|
||||
method: string;
|
||||
params?: unknown;
|
||||
sessionId?: string;
|
||||
};
|
||||
|
||||
export type ExtensionForwardCommandMessage = {
|
||||
id: number;
|
||||
method: 'forwardCDPCommand';
|
||||
params: { method: string; params?: unknown; sessionId?: string };
|
||||
};
|
||||
|
||||
export type ExtensionResponseMessage = {
|
||||
id: number;
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type ExtensionForwardEventMessage = {
|
||||
method: 'forwardCDPEvent';
|
||||
params: { method: string; params?: unknown; sessionId?: string };
|
||||
};
|
||||
|
||||
export type ExtensionPingMessage = { method: 'ping' };
|
||||
export type ExtensionPongMessage = { method: 'pong' };
|
||||
|
||||
export type ExtensionMessage = ExtensionResponseMessage | ExtensionForwardEventMessage | ExtensionPongMessage;
|
||||
|
||||
export type AttachedToTargetEvent = {
|
||||
sessionId: string;
|
||||
targetInfo: TargetInfo;
|
||||
waitingForDebugger?: boolean;
|
||||
};
|
||||
|
||||
export type DetachedFromTargetEvent = {
|
||||
sessionId: string;
|
||||
targetId?: string;
|
||||
};
|
||||
|
||||
export type UserRelay = {
|
||||
extensionWs: unknown | null;
|
||||
cdpClients: Set<unknown>;
|
||||
connectedTargets: Map<string, ConnectedTarget>;
|
||||
pingInterval: Timer | null;
|
||||
gracePeriodTimer: Timer | null;
|
||||
pendingExtension: Map<
|
||||
number,
|
||||
{
|
||||
resolve: (v: unknown) => void;
|
||||
reject: (e: Error) => void;
|
||||
timer: Timer;
|
||||
}
|
||||
>;
|
||||
nextExtensionId: number;
|
||||
extensionReconnectWaiters: Set<(connected: boolean) => void>;
|
||||
};
|
||||
@@ -0,0 +1,670 @@
|
||||
// Based on OpenClaw Browser Extension Relay (MIT License)
|
||||
// https://github.com/nicepkg/openclaw
|
||||
//
|
||||
// Rewritten for Bun.serve() with multi-user support.
|
||||
|
||||
import type { ServerWebSocket } from 'bun';
|
||||
import type {
|
||||
AttachedToTargetEvent,
|
||||
CdpCommand,
|
||||
CdpEvent,
|
||||
CdpResponse,
|
||||
ConnectedTarget,
|
||||
DetachedFromTargetEvent,
|
||||
ExtensionForwardCommandMessage,
|
||||
ExtensionForwardEventMessage,
|
||||
ExtensionMessage,
|
||||
ExtensionPongMessage,
|
||||
} from './relay-types';
|
||||
import { getRelayAuthHeader, resolveUserFromToken } from './relay-auth';
|
||||
|
||||
const RELAY_AUTH_HEADER = getRelayAuthHeader();
|
||||
const DEFAULT_EXTENSION_RECONNECT_GRACE_MS = 5_000;
|
||||
const DEFAULT_EXTENSION_COMMAND_RECONNECT_WAIT_MS = 3_000;
|
||||
|
||||
type WSData = {
|
||||
kind: 'extension' | 'cdp';
|
||||
userId: number;
|
||||
token: string;
|
||||
};
|
||||
|
||||
type UserRelay = {
|
||||
extensionWs: ServerWebSocket<WSData> | null;
|
||||
cdpClients: Set<ServerWebSocket<WSData>>;
|
||||
connectedTargets: Map<string, ConnectedTarget>;
|
||||
pingInterval: Timer | null;
|
||||
gracePeriodTimer: Timer | null;
|
||||
pendingExtension: Map<number, { resolve: (v: unknown) => void; reject: (e: Error) => void; timer: Timer }>;
|
||||
nextExtensionId: number;
|
||||
extensionReconnectWaiters: Set<(connected: boolean) => void>;
|
||||
};
|
||||
|
||||
const userRelays = new Map<number, UserRelay>();
|
||||
|
||||
function getUserRelay(userId: number): UserRelay {
|
||||
let relay = userRelays.get(userId);
|
||||
if (!relay) {
|
||||
relay = {
|
||||
extensionWs: null,
|
||||
cdpClients: new Set(),
|
||||
connectedTargets: new Map(),
|
||||
pingInterval: null,
|
||||
gracePeriodTimer: null,
|
||||
pendingExtension: new Map(),
|
||||
nextExtensionId: 1,
|
||||
extensionReconnectWaiters: new Set(),
|
||||
};
|
||||
userRelays.set(userId, relay);
|
||||
}
|
||||
return relay;
|
||||
}
|
||||
|
||||
function extensionConnected(relay: UserRelay): boolean {
|
||||
return relay.extensionWs !== null && relay.extensionWs.readyState === WebSocket.OPEN;
|
||||
}
|
||||
|
||||
function flushExtensionReconnectWaiters(relay: UserRelay, connected: boolean) {
|
||||
if (relay.extensionReconnectWaiters.size === 0) return;
|
||||
const waiters = Array.from(relay.extensionReconnectWaiters);
|
||||
relay.extensionReconnectWaiters.clear();
|
||||
for (const waiter of waiters) waiter(connected);
|
||||
}
|
||||
|
||||
function clearGracePeriodTimer(relay: UserRelay) {
|
||||
if (!relay.gracePeriodTimer) return;
|
||||
clearTimeout(relay.gracePeriodTimer);
|
||||
relay.gracePeriodTimer = null;
|
||||
}
|
||||
|
||||
function closeCdpClientsAfterExtensionDisconnect(relay: UserRelay) {
|
||||
relay.connectedTargets.clear();
|
||||
for (const client of relay.cdpClients) {
|
||||
try {
|
||||
client.close(1011, 'extension disconnected');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
relay.cdpClients.clear();
|
||||
flushExtensionReconnectWaiters(relay, false);
|
||||
}
|
||||
|
||||
function scheduleExtensionDisconnectCleanup(relay: UserRelay) {
|
||||
clearGracePeriodTimer(relay);
|
||||
relay.gracePeriodTimer = setTimeout(() => {
|
||||
relay.gracePeriodTimer = null;
|
||||
if (extensionConnected(relay)) return;
|
||||
closeCdpClientsAfterExtensionDisconnect(relay);
|
||||
}, DEFAULT_EXTENSION_RECONNECT_GRACE_MS);
|
||||
}
|
||||
|
||||
async function waitForExtensionReconnect(relay: UserRelay, timeoutMs: number): Promise<boolean> {
|
||||
if (extensionConnected(relay)) return true;
|
||||
return await new Promise<boolean>((resolve) => {
|
||||
let settled = false;
|
||||
const waiter = (connected: boolean) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
relay.extensionReconnectWaiters.delete(waiter);
|
||||
resolve(connected);
|
||||
};
|
||||
const timer = setTimeout(() => waiter(false), timeoutMs);
|
||||
relay.extensionReconnectWaiters.add(waiter);
|
||||
});
|
||||
}
|
||||
|
||||
function sendToExtension(relay: UserRelay, payload: ExtensionForwardCommandMessage): Promise<unknown> {
|
||||
const ws = relay.extensionWs;
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||
throw new Error('Chrome extension not connected');
|
||||
}
|
||||
ws.send(JSON.stringify(payload));
|
||||
return new Promise<unknown>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
relay.pendingExtension.delete(payload.id);
|
||||
reject(new Error(`extension request timeout: ${payload.params.method}`));
|
||||
}, 30_000);
|
||||
relay.pendingExtension.set(payload.id, { resolve, reject, timer });
|
||||
});
|
||||
}
|
||||
|
||||
function broadcastToCdpClients(relay: UserRelay, evt: CdpEvent) {
|
||||
const msg = JSON.stringify(evt);
|
||||
for (const ws of relay.cdpClients) {
|
||||
if (ws.readyState !== WebSocket.OPEN) continue;
|
||||
ws.send(msg);
|
||||
}
|
||||
}
|
||||
|
||||
function sendResponseToCdp(ws: ServerWebSocket<WSData>, res: CdpResponse) {
|
||||
if (ws.readyState !== WebSocket.OPEN) return;
|
||||
ws.send(JSON.stringify(res));
|
||||
}
|
||||
|
||||
function ensureTargetEventsForClient(relay: UserRelay, ws: ServerWebSocket<WSData>, mode: 'autoAttach' | 'discover') {
|
||||
for (const target of relay.connectedTargets.values()) {
|
||||
if (mode === 'autoAttach') {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
method: 'Target.attachedToTarget',
|
||||
params: {
|
||||
sessionId: target.sessionId,
|
||||
targetInfo: { ...target.targetInfo, attached: true },
|
||||
waitingForDebugger: false,
|
||||
},
|
||||
} satisfies CdpEvent),
|
||||
);
|
||||
} else {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
method: 'Target.targetCreated',
|
||||
params: { targetInfo: { ...target.targetInfo, attached: true } },
|
||||
} satisfies CdpEvent),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function routeCdpCommand(relay: UserRelay, cmd: CdpCommand): Promise<unknown> {
|
||||
switch (cmd.method) {
|
||||
case 'Browser.getVersion':
|
||||
return {
|
||||
protocolVersion: '1.3',
|
||||
product: 'Chrome/Officer-Browser-Relay',
|
||||
revision: '0',
|
||||
userAgent: 'Officer-Browser-Relay',
|
||||
jsVersion: 'V8',
|
||||
};
|
||||
case 'Browser.setDownloadBehavior':
|
||||
return {};
|
||||
case 'Target.setAutoAttach':
|
||||
case 'Target.setDiscoverTargets':
|
||||
return {};
|
||||
case 'Target.getTargets':
|
||||
return {
|
||||
targetInfos: Array.from(relay.connectedTargets.values()).map((t) => ({
|
||||
...t.targetInfo,
|
||||
attached: true,
|
||||
})),
|
||||
};
|
||||
case 'Target.getTargetInfo': {
|
||||
const params = (cmd.params ?? {}) as { targetId?: string };
|
||||
const targetId = typeof params.targetId === 'string' ? params.targetId : undefined;
|
||||
if (targetId) {
|
||||
for (const t of relay.connectedTargets.values()) {
|
||||
if (t.targetId === targetId) return { targetInfo: t.targetInfo };
|
||||
}
|
||||
}
|
||||
if (cmd.sessionId && relay.connectedTargets.has(cmd.sessionId)) {
|
||||
const t = relay.connectedTargets.get(cmd.sessionId);
|
||||
if (t) return { targetInfo: t.targetInfo };
|
||||
}
|
||||
const first = Array.from(relay.connectedTargets.values())[0];
|
||||
return { targetInfo: first?.targetInfo };
|
||||
}
|
||||
case 'Target.attachToTarget': {
|
||||
const params = (cmd.params ?? {}) as { targetId?: string };
|
||||
const targetId = typeof params.targetId === 'string' ? params.targetId : undefined;
|
||||
if (!targetId) throw new Error('targetId required');
|
||||
for (const t of relay.connectedTargets.values()) {
|
||||
if (t.targetId === targetId) return { sessionId: t.sessionId };
|
||||
}
|
||||
throw new Error('target not found');
|
||||
}
|
||||
default: {
|
||||
const id = relay.nextExtensionId++;
|
||||
return await sendToExtension(relay, {
|
||||
id,
|
||||
method: 'forwardCDPCommand',
|
||||
params: {
|
||||
method: cmd.method,
|
||||
sessionId: cmd.sessionId,
|
||||
params: cmd.params,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleExtensionMessage(relay: UserRelay, data: string) {
|
||||
let parsed: ExtensionMessage | null = null;
|
||||
try {
|
||||
parsed = JSON.parse(data) as ExtensionMessage;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed && typeof parsed === 'object' && 'id' in parsed && typeof parsed.id === 'number') {
|
||||
const pending = relay.pendingExtension.get(parsed.id);
|
||||
if (!pending) return;
|
||||
relay.pendingExtension.delete(parsed.id);
|
||||
clearTimeout(pending.timer);
|
||||
if ('error' in parsed && typeof parsed.error === 'string' && parsed.error.trim()) {
|
||||
pending.reject(new Error(parsed.error));
|
||||
} else {
|
||||
pending.resolve(parsed.result);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed && typeof parsed === 'object' && 'method' in parsed) {
|
||||
if ((parsed as ExtensionPongMessage).method === 'pong') return;
|
||||
if ((parsed as ExtensionForwardEventMessage).method !== 'forwardCDPEvent') return;
|
||||
|
||||
const evt = parsed as ExtensionForwardEventMessage;
|
||||
const method = evt.params?.method;
|
||||
const params = evt.params?.params;
|
||||
const sessionId = evt.params?.sessionId;
|
||||
if (!method || typeof method !== 'string') return;
|
||||
|
||||
if (method === 'Target.attachedToTarget') {
|
||||
const attached = (params ?? {}) as AttachedToTargetEvent;
|
||||
const targetType = attached?.targetInfo?.type ?? 'page';
|
||||
if (targetType !== 'page') return;
|
||||
if (attached?.sessionId && attached?.targetInfo?.targetId) {
|
||||
const prev = relay.connectedTargets.get(attached.sessionId);
|
||||
const nextTargetId = attached.targetInfo.targetId;
|
||||
const prevTargetId = prev?.targetId;
|
||||
const changedTarget = Boolean(prev && prevTargetId && prevTargetId !== nextTargetId);
|
||||
relay.connectedTargets.set(attached.sessionId, {
|
||||
sessionId: attached.sessionId,
|
||||
targetId: nextTargetId,
|
||||
targetInfo: attached.targetInfo,
|
||||
});
|
||||
if (changedTarget && prevTargetId) {
|
||||
broadcastToCdpClients(relay, {
|
||||
method: 'Target.detachedFromTarget',
|
||||
params: { sessionId: attached.sessionId, targetId: prevTargetId },
|
||||
sessionId: attached.sessionId,
|
||||
});
|
||||
}
|
||||
if (!prev || changedTarget) {
|
||||
broadcastToCdpClients(relay, { method, params, sessionId });
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (method === 'Target.detachedFromTarget') {
|
||||
const detached = (params ?? {}) as DetachedFromTargetEvent;
|
||||
if (detached?.sessionId) {
|
||||
relay.connectedTargets.delete(detached.sessionId);
|
||||
}
|
||||
broadcastToCdpClients(relay, { method, params, sessionId });
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === 'Target.targetInfoChanged') {
|
||||
const changed = (params ?? {}) as { targetInfo?: { targetId?: string; type?: string } };
|
||||
const targetInfo = changed?.targetInfo;
|
||||
const targetId = targetInfo?.targetId;
|
||||
if (targetId && (targetInfo?.type ?? 'page') === 'page') {
|
||||
for (const [sid, target] of relay.connectedTargets) {
|
||||
if (target.targetId !== targetId) continue;
|
||||
relay.connectedTargets.set(sid, {
|
||||
...target,
|
||||
targetInfo: { ...target.targetInfo, ...(targetInfo as object) },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
broadcastToCdpClients(relay, { method, params, sessionId });
|
||||
}
|
||||
}
|
||||
|
||||
function handleExtensionClose(relay: UserRelay, ws: ServerWebSocket<WSData>) {
|
||||
if (relay.pingInterval) {
|
||||
clearInterval(relay.pingInterval);
|
||||
relay.pingInterval = null;
|
||||
}
|
||||
if (relay.extensionWs !== ws) return;
|
||||
relay.extensionWs = null;
|
||||
for (const [, pending] of relay.pendingExtension) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.reject(new Error('extension disconnected'));
|
||||
}
|
||||
relay.pendingExtension.clear();
|
||||
scheduleExtensionDisconnectCleanup(relay);
|
||||
}
|
||||
|
||||
function handleExtensionOpen(relay: UserRelay, ws: ServerWebSocket<WSData>) {
|
||||
relay.extensionWs = ws;
|
||||
clearGracePeriodTimer(relay);
|
||||
flushExtensionReconnectWaiters(relay, true);
|
||||
|
||||
relay.pingInterval = setInterval(() => {
|
||||
if (ws.readyState !== WebSocket.OPEN) return;
|
||||
ws.send(JSON.stringify({ method: 'ping' }));
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
async function handleCdpMessage(relay: UserRelay, ws: ServerWebSocket<WSData>, data: string) {
|
||||
let cmd: CdpCommand | null = null;
|
||||
try {
|
||||
cmd = JSON.parse(data) as CdpCommand;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!cmd || typeof cmd !== 'object') return;
|
||||
if (typeof cmd.id !== 'number' || typeof cmd.method !== 'string') return;
|
||||
|
||||
if (!extensionConnected(relay)) {
|
||||
const reconnected = await waitForExtensionReconnect(relay, DEFAULT_EXTENSION_COMMAND_RECONNECT_WAIT_MS);
|
||||
if (!reconnected || !extensionConnected(relay)) {
|
||||
sendResponseToCdp(ws, {
|
||||
id: cmd.id,
|
||||
sessionId: cmd.sessionId,
|
||||
error: { message: 'Extension not connected' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await routeCdpCommand(relay, cmd);
|
||||
|
||||
if (cmd.method === 'Target.setAutoAttach' && !cmd.sessionId) {
|
||||
ensureTargetEventsForClient(relay, ws, 'autoAttach');
|
||||
}
|
||||
if (cmd.method === 'Target.setDiscoverTargets') {
|
||||
const discover = (cmd.params ?? {}) as { discover?: boolean };
|
||||
if (discover.discover === true) {
|
||||
ensureTargetEventsForClient(relay, ws, 'discover');
|
||||
}
|
||||
}
|
||||
if (cmd.method === 'Target.attachToTarget') {
|
||||
const params = (cmd.params ?? {}) as { targetId?: string };
|
||||
const targetId = typeof params.targetId === 'string' ? params.targetId : undefined;
|
||||
if (targetId) {
|
||||
const target = Array.from(relay.connectedTargets.values()).find((t) => t.targetId === targetId);
|
||||
if (target) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
method: 'Target.attachedToTarget',
|
||||
params: {
|
||||
sessionId: target.sessionId,
|
||||
targetInfo: { ...target.targetInfo, attached: true },
|
||||
waitingForDebugger: false,
|
||||
},
|
||||
} satisfies CdpEvent),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sendResponseToCdp(ws, { id: cmd.id, sessionId: cmd.sessionId, result });
|
||||
} catch (err) {
|
||||
sendResponseToCdp(ws, {
|
||||
id: cmd.id,
|
||||
sessionId: cmd.sessionId,
|
||||
error: { message: err instanceof Error ? err.message : String(err) },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function getTokenFromUrl(url: string): string | null {
|
||||
try {
|
||||
const parsed = new URL(url, 'http://localhost');
|
||||
return parsed.searchParams.get('token')?.trim() ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getTokenFromHeaders(headers: Headers): string | null {
|
||||
return headers.get(RELAY_AUTH_HEADER)?.trim() ?? null;
|
||||
}
|
||||
|
||||
function getTokenFromRequest(req: Request): string | null {
|
||||
return getTokenFromHeaders(req.headers) ?? getTokenFromUrl(req.url);
|
||||
}
|
||||
|
||||
function authenticateRequest(req: Request): number | null {
|
||||
const token = getTokenFromRequest(req);
|
||||
if (!token) return null;
|
||||
return resolveUserFromToken(token);
|
||||
}
|
||||
|
||||
let relayServer: ReturnType<typeof Bun.serve> | null = null;
|
||||
let relayPort = 0;
|
||||
|
||||
export function getRelayPort(): number {
|
||||
return relayPort;
|
||||
}
|
||||
|
||||
export function getUserRelayStatus(userId: number): { extensionConnected: boolean; targetCount: number } {
|
||||
const relay = userRelays.get(userId);
|
||||
if (!relay) return { extensionConnected: false, targetCount: 0 };
|
||||
return {
|
||||
extensionConnected: extensionConnected(relay),
|
||||
targetCount: relay.connectedTargets.size,
|
||||
};
|
||||
}
|
||||
|
||||
export function getUserTargets(userId: number): ConnectedTarget[] {
|
||||
const relay = userRelays.get(userId);
|
||||
if (!relay) return [];
|
||||
return Array.from(relay.connectedTargets.values());
|
||||
}
|
||||
|
||||
export function startBrowserRelay(port: number) {
|
||||
relayPort = port;
|
||||
|
||||
const server = Bun.serve<WSData>({
|
||||
port,
|
||||
hostname: '0.0.0.0',
|
||||
idleTimeout: 60,
|
||||
|
||||
routes: {
|
||||
'/': (req: Request) => {
|
||||
if (req.method === 'HEAD') return new Response(null, { status: 200 });
|
||||
return new Response('OK', { status: 200, headers: { 'Content-Type': 'text/plain' } });
|
||||
},
|
||||
},
|
||||
|
||||
fetch(req, server) {
|
||||
const url = new URL(req.url);
|
||||
const path = url.pathname;
|
||||
|
||||
// CORS for chrome-extension origins
|
||||
const origin = req.headers.get('origin');
|
||||
const corsHeaders: Record<string, string> = {};
|
||||
if (origin) {
|
||||
corsHeaders['Access-Control-Allow-Origin'] = origin;
|
||||
corsHeaders['Vary'] = 'Origin';
|
||||
}
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
headers: {
|
||||
...corsHeaders,
|
||||
'Access-Control-Allow-Methods': 'GET, PUT, POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers': `Content-Type, ${RELAY_AUTH_HEADER}`,
|
||||
'Access-Control-Max-Age': '86400',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (path === '/extension/status') {
|
||||
const connectedCount = Array.from(userRelays.values()).filter((r) => extensionConnected(r)).length;
|
||||
return Response.json({ connected: connectedCount > 0, users: connectedCount }, { headers: corsHeaders });
|
||||
}
|
||||
|
||||
// Auth-protected endpoints
|
||||
const token = getTokenFromRequest(req);
|
||||
const userId = token ? resolveUserFromToken(token) : null;
|
||||
|
||||
if (path === '/json/version' || path === '/json/version/') {
|
||||
if (userId === null) return new Response('Unauthorized', { status: 401, headers: corsHeaders });
|
||||
const relay = userRelays.get(userId);
|
||||
const payload: Record<string, unknown> = {
|
||||
Browser: 'Officer/browser-relay',
|
||||
'Protocol-Version': '1.3',
|
||||
};
|
||||
if (relay && extensionConnected(relay)) {
|
||||
const hostHeader = req.headers.get('host')?.trim() || `0.0.0.0:${port}`;
|
||||
payload.webSocketDebuggerUrl = `ws://${hostHeader}/cdp?token=${token}`;
|
||||
}
|
||||
return Response.json(payload, { headers: corsHeaders });
|
||||
}
|
||||
|
||||
const listPaths = new Set(['/json', '/json/', '/json/list', '/json/list/']);
|
||||
if (listPaths.has(path)) {
|
||||
if (userId === null) return new Response('Unauthorized', { status: 401, headers: corsHeaders });
|
||||
const relay = userRelays.get(userId);
|
||||
const hostHeader = req.headers.get('host')?.trim() || `0.0.0.0:${port}`;
|
||||
const cdpWsUrl = `ws://${hostHeader}/cdp?token=${token}`;
|
||||
const list = relay
|
||||
? Array.from(relay.connectedTargets.values()).map((t) => ({
|
||||
id: t.targetId,
|
||||
type: t.targetInfo.type ?? 'page',
|
||||
title: t.targetInfo.title ?? '',
|
||||
description: t.targetInfo.title ?? '',
|
||||
url: t.targetInfo.url ?? '',
|
||||
webSocketDebuggerUrl: cdpWsUrl,
|
||||
}))
|
||||
: [];
|
||||
return Response.json(list, { headers: corsHeaders });
|
||||
}
|
||||
|
||||
const activateMatch = path.match(/^\/json\/activate\/(.+)$/);
|
||||
if (activateMatch) {
|
||||
if (userId === null) return new Response('Unauthorized', { status: 401, headers: corsHeaders });
|
||||
const targetId = decodeURIComponent(activateMatch[1]!).trim();
|
||||
if (!targetId) return new Response('targetId required', { status: 400, headers: corsHeaders });
|
||||
const relay = userRelays.get(userId);
|
||||
if (relay && extensionConnected(relay)) {
|
||||
void sendToExtension(relay, {
|
||||
id: relay.nextExtensionId++,
|
||||
method: 'forwardCDPCommand',
|
||||
params: { method: 'Target.activateTarget', params: { targetId } },
|
||||
}).catch(() => {});
|
||||
}
|
||||
return new Response('OK', { status: 200, headers: corsHeaders });
|
||||
}
|
||||
|
||||
const closeMatch = path.match(/^\/json\/close\/(.+)$/);
|
||||
if (closeMatch) {
|
||||
if (userId === null) return new Response('Unauthorized', { status: 401, headers: corsHeaders });
|
||||
const targetId = decodeURIComponent(closeMatch[1]!).trim();
|
||||
if (!targetId) return new Response('targetId required', { status: 400, headers: corsHeaders });
|
||||
const relay = userRelays.get(userId);
|
||||
if (relay && extensionConnected(relay)) {
|
||||
void sendToExtension(relay, {
|
||||
id: relay.nextExtensionId++,
|
||||
method: 'forwardCDPCommand',
|
||||
params: { method: 'Target.closeTarget', params: { targetId } },
|
||||
}).catch(() => {});
|
||||
}
|
||||
return new Response('OK', { status: 200, headers: corsHeaders });
|
||||
}
|
||||
|
||||
// WebSocket upgrades
|
||||
if (path === '/extension') {
|
||||
if (userId === null) return new Response('Unauthorized', { status: 401, headers: corsHeaders });
|
||||
const relay = getUserRelay(userId);
|
||||
// Close stale extension WS
|
||||
if (relay.extensionWs && relay.extensionWs.readyState !== WebSocket.OPEN) {
|
||||
try {
|
||||
(relay.extensionWs as ServerWebSocket<WSData>).close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
relay.extensionWs = null;
|
||||
}
|
||||
if (extensionConnected(relay)) {
|
||||
return new Response('Extension already connected', { status: 409, headers: corsHeaders });
|
||||
}
|
||||
const ok = server.upgrade(req, { data: { kind: 'extension', userId, token: token! } });
|
||||
if (!ok) return new Response('Upgrade failed', { status: 500, headers: corsHeaders });
|
||||
return undefined as unknown as Response;
|
||||
}
|
||||
|
||||
if (path === '/cdp') {
|
||||
if (userId === null) return new Response('Unauthorized', { status: 401, headers: corsHeaders });
|
||||
const relay = userRelays.get(userId);
|
||||
if (!relay || !extensionConnected(relay)) {
|
||||
return new Response('Extension not connected', { status: 503, headers: corsHeaders });
|
||||
}
|
||||
const ok = server.upgrade(req, { data: { kind: 'cdp', userId, token: token! } });
|
||||
if (!ok) return new Response('Upgrade failed', { status: 500, headers: corsHeaders });
|
||||
return undefined as unknown as Response;
|
||||
}
|
||||
|
||||
return new Response('Not found', { status: 404, headers: corsHeaders });
|
||||
},
|
||||
|
||||
websocket: {
|
||||
open(ws) {
|
||||
const { kind, userId } = ws.data;
|
||||
const relay = getUserRelay(userId);
|
||||
|
||||
if (kind === 'extension') {
|
||||
handleExtensionOpen(relay, ws);
|
||||
} else {
|
||||
relay.cdpClients.add(ws);
|
||||
}
|
||||
},
|
||||
|
||||
message(ws, raw) {
|
||||
const { kind, userId } = ws.data;
|
||||
const relay = userRelays.get(userId);
|
||||
if (!relay) return;
|
||||
const data = typeof raw === 'string' ? raw : new TextDecoder().decode(raw as unknown as ArrayBuffer);
|
||||
|
||||
if (kind === 'extension') {
|
||||
handleExtensionMessage(relay, data);
|
||||
} else {
|
||||
void handleCdpMessage(relay, ws, data);
|
||||
}
|
||||
},
|
||||
|
||||
close(ws) {
|
||||
const { kind, userId } = ws.data;
|
||||
const relay = userRelays.get(userId);
|
||||
if (!relay) return;
|
||||
|
||||
if (kind === 'extension') {
|
||||
handleExtensionClose(relay, ws);
|
||||
} else {
|
||||
relay.cdpClients.delete(ws);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
relayServer = server;
|
||||
return server;
|
||||
}
|
||||
|
||||
export function stopBrowserRelay() {
|
||||
if (!relayServer) return;
|
||||
for (const [, relay] of userRelays) {
|
||||
clearGracePeriodTimer(relay);
|
||||
if (relay.pingInterval) clearInterval(relay.pingInterval);
|
||||
flushExtensionReconnectWaiters(relay, false);
|
||||
for (const [, pending] of relay.pendingExtension) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.reject(new Error('server stopping'));
|
||||
}
|
||||
relay.pendingExtension.clear();
|
||||
try {
|
||||
relay.extensionWs?.close(1001, 'server stopping');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
for (const ws of relay.cdpClients) {
|
||||
try {
|
||||
ws.close(1001, 'server stopping');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
userRelays.clear();
|
||||
relayServer.stop();
|
||||
relayServer = null;
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { createRouter } from '@@/create-router';
|
||||
import * as errors from '@@/custom-errors';
|
||||
import { registerUserToken } from './relay-auth';
|
||||
import { getRelayPort, getUserRelayStatus, getUserTargets } from './relay';
|
||||
import { captureScreenshot, evaluateJS, navigateTo } from './cdp';
|
||||
|
||||
export const browserRouter = createRouter();
|
||||
|
||||
function getOpts(userId: number) {
|
||||
const port = getRelayPort();
|
||||
const token = registerUserToken(userId, port);
|
||||
return { relayPort: port, userToken: token };
|
||||
}
|
||||
|
||||
browserRouter.get('/status', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const status = getUserRelayStatus(user.id);
|
||||
return ctx.json(status);
|
||||
});
|
||||
|
||||
browserRouter.get('/relay-token', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const port = getRelayPort();
|
||||
const token = registerUserToken(user.id, port);
|
||||
return ctx.json({ token, port });
|
||||
});
|
||||
|
||||
browserRouter.get('/targets', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const targets = getUserTargets(user.id);
|
||||
return ctx.json(
|
||||
targets.map((t) => ({
|
||||
id: t.targetId,
|
||||
sessionId: t.sessionId,
|
||||
type: t.targetInfo.type ?? 'page',
|
||||
title: t.targetInfo.title ?? '',
|
||||
url: t.targetInfo.url ?? '',
|
||||
})),
|
||||
);
|
||||
});
|
||||
|
||||
browserRouter.get('/targets/:id/screenshot', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const targetId = ctx.req.param('id');
|
||||
const format = (ctx.req.query('format') as 'png' | 'jpeg') || 'png';
|
||||
|
||||
const opts = getOpts(user.id);
|
||||
// Find session ID for the target
|
||||
const targets = getUserTargets(user.id);
|
||||
const target = targets.find((t) => t.targetId === targetId);
|
||||
if (!target) throw errors.NOT_FOUND('Target not found');
|
||||
|
||||
try {
|
||||
const data = await captureScreenshot({ ...opts, targetId: target.sessionId }, format);
|
||||
return ctx.json({ data, format });
|
||||
} catch (err) {
|
||||
throw errors.INTERNAL_SERVER_ERROR(err instanceof Error ? err.message : 'Screenshot failed');
|
||||
}
|
||||
});
|
||||
|
||||
browserRouter.post('/targets/:id/evaluate', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const targetId = ctx.req.param('id');
|
||||
const body = ctx.get('body') as { expression?: string };
|
||||
|
||||
if (!body.expression) throw errors.BAD_REQUEST('expression is required');
|
||||
|
||||
const opts = getOpts(user.id);
|
||||
const targets = getUserTargets(user.id);
|
||||
const target = targets.find((t) => t.targetId === targetId);
|
||||
if (!target) throw errors.NOT_FOUND('Target not found');
|
||||
|
||||
try {
|
||||
const result = await evaluateJS({ ...opts, targetId: target.sessionId }, body.expression);
|
||||
return ctx.json({ result });
|
||||
} catch (err) {
|
||||
throw errors.INTERNAL_SERVER_ERROR(err instanceof Error ? err.message : 'Evaluation failed');
|
||||
}
|
||||
});
|
||||
|
||||
browserRouter.post('/targets/:id/navigate', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const targetId = ctx.req.param('id');
|
||||
const body = ctx.get('body') as { url?: string };
|
||||
|
||||
if (!body.url) throw errors.BAD_REQUEST('url is required');
|
||||
|
||||
const opts = getOpts(user.id);
|
||||
const targets = getUserTargets(user.id);
|
||||
const target = targets.find((t) => t.targetId === targetId);
|
||||
if (!target) throw errors.NOT_FOUND('Target not found');
|
||||
|
||||
try {
|
||||
await navigateTo({ ...opts, targetId: target.sessionId }, body.url);
|
||||
return ctx.json({ ok: true });
|
||||
} catch (err) {
|
||||
throw errors.INTERNAL_SERVER_ERROR(err instanceof Error ? err.message : 'Navigation failed');
|
||||
}
|
||||
});
|
||||
|
||||
browserRouter.post('/targets/:id/activate', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const targetId = ctx.req.param('id');
|
||||
|
||||
const port = getRelayPort();
|
||||
const token = registerUserToken(user.id, port);
|
||||
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/json/activate/${encodeURIComponent(targetId)}`, {
|
||||
headers: { 'x-officer-relay-token': token },
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return ctx.json({ ok: true });
|
||||
} catch (err) {
|
||||
throw errors.INTERNAL_SERVER_ERROR(err instanceof Error ? err.message : 'Activate failed');
|
||||
}
|
||||
});
|
||||
|
||||
browserRouter.post('/targets/:id/close', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const targetId = ctx.req.param('id');
|
||||
|
||||
const port = getRelayPort();
|
||||
const token = registerUserToken(user.id, port);
|
||||
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/json/close/${encodeURIComponent(targetId)}`, {
|
||||
headers: { 'x-officer-relay-token': token },
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return ctx.json({ ok: true });
|
||||
} catch (err) {
|
||||
throw errors.INTERNAL_SERVER_ERROR(err instanceof Error ? err.message : 'Close failed');
|
||||
}
|
||||
});
|
||||
@@ -11,12 +11,15 @@ emailRouter.get('/messages', async (ctx) => {
|
||||
const email = ctx.get('user').email;
|
||||
const page = Number(ctx.req.query('page') ?? '1');
|
||||
const limit = Number(ctx.req.query('limit') ?? '50');
|
||||
const folder = ctx.req.query('folder') ?? 'inbox';
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const folderWhere = folder === 'all' ? 'deleted = 0' : `deleted = 0 AND labels LIKE '%${folder}%'`;
|
||||
|
||||
const db = openEmailDb(email);
|
||||
try {
|
||||
const rows = db.query('SELECT * FROM emails WHERE deleted = 0 ORDER BY date DESC LIMIT ? OFFSET ?').all(limit, offset) as Record<string, unknown>[];
|
||||
const countRow = db.query('SELECT COUNT(*) as total FROM emails WHERE deleted = 0').get() as { total: number };
|
||||
const rows = db.query(`SELECT * FROM emails WHERE ${folderWhere} ORDER BY date DESC LIMIT ? OFFSET ?`).all(limit, offset) as Record<string, unknown>[];
|
||||
const countRow = db.query(`SELECT COUNT(*) as total FROM emails WHERE ${folderWhere}`).get() as { total: number };
|
||||
const messages = rows.map(rowToSummary);
|
||||
return ctx.json({ messages, total: countRow.total });
|
||||
} finally {
|
||||
@@ -122,15 +125,43 @@ emailRouter.get('/sync-status', async (ctx) => {
|
||||
|
||||
emailRouter.get('/stats', async (ctx) => {
|
||||
const email = ctx.get('user').email;
|
||||
const folder = ctx.req.query('folder') ?? 'inbox';
|
||||
|
||||
const folderWhere = folder === 'all' ? 'deleted = 0' : `deleted = 0 AND labels LIKE '%${folder}%'`;
|
||||
|
||||
const db = openEmailDb(email);
|
||||
try {
|
||||
const total = (db.query('SELECT COUNT(*) as count FROM emails WHERE deleted = 0').get() as { count: number }).count;
|
||||
const byDomain = db.query('SELECT from_domain, COUNT(*) as count FROM emails WHERE deleted = 0 GROUP BY from_domain ORDER BY count DESC LIMIT 20').all() as Array<{ from_domain: string; count: number }>;
|
||||
const bySender = db.query('SELECT from_address, from_name, COUNT(*) as count FROM emails WHERE deleted = 0 GROUP BY from_address ORDER BY count DESC LIMIT 20').all() as Array<{ from_address: string; from_name: string; count: number }>;
|
||||
const total = (db.query(`SELECT COUNT(*) as count FROM emails WHERE ${folderWhere}`).get() as { count: number }).count;
|
||||
const byDomain = db.query(`SELECT from_domain, COUNT(*) as count FROM emails WHERE ${folderWhere} GROUP BY from_domain ORDER BY count DESC LIMIT 20`).all() as Array<{ from_domain: string; count: number }>;
|
||||
const bySender = db.query(`SELECT from_address, from_name, COUNT(*) as count FROM emails WHERE ${folderWhere} GROUP BY from_address ORDER BY count DESC LIMIT 20`).all() as Array<{ from_address: string; from_name: string; count: number }>;
|
||||
|
||||
return ctx.json({ total, byDomain, bySender });
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
emailRouter.get('/labels', async (ctx) => {
|
||||
const email = ctx.get('user').email;
|
||||
|
||||
const db = openEmailDb(email);
|
||||
try {
|
||||
const rows = db.query('SELECT labels FROM emails WHERE deleted = 0 AND labels IS NOT NULL').all() as Array<{ labels: string }>;
|
||||
|
||||
const counts = new Map<string, number>();
|
||||
for (const row of rows) {
|
||||
for (const label of row.labels.split(',')) {
|
||||
const trimmed = label.trim().toLowerCase();
|
||||
if (trimmed) counts.set(trimmed, (counts.get(trimmed) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const labels = [...counts.entries()]
|
||||
.map(([label, count]) => ({ label, count }))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
|
||||
return ctx.json({ labels });
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import { syncSeedResources } from './sync-resources';
|
||||
import { migrateSettingsToResources } from './migrate-resources';
|
||||
import { generateResourceSkill } from './api/pi/pi-bridge';
|
||||
import { initQueue } from './queue';
|
||||
import { startDiscordBotIfConfigured } from './channels/discord/bot';
|
||||
|
||||
mkdirSync(DATA_PATH, { recursive: true });
|
||||
|
||||
@@ -61,4 +62,8 @@ async function installPi(): Promise<boolean> {
|
||||
await initQueue().catch(err => {
|
||||
console.error('[bootstrap] Failed to initialize queue:', err);
|
||||
});
|
||||
|
||||
await startDiscordBotIfConfigured().catch(err => {
|
||||
console.error('[channels] Failed to start Discord bot:', err);
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Client, GatewayIntentBits, Partials, Events } from 'discord.js';
|
||||
import { getServerIntegration } from 'officerdb';
|
||||
import { handleDiscordMessage } from './handler';
|
||||
|
||||
let client: Client | null = null;
|
||||
|
||||
export async function startDiscordBot(token: string): Promise<void> {
|
||||
if (client) {
|
||||
await stopDiscordBot();
|
||||
}
|
||||
|
||||
client = new Client({
|
||||
intents: [
|
||||
GatewayIntentBits.Guilds,
|
||||
GatewayIntentBits.GuildMembers,
|
||||
GatewayIntentBits.GuildPresences,
|
||||
GatewayIntentBits.DirectMessages,
|
||||
GatewayIntentBits.MessageContent,
|
||||
],
|
||||
partials: [Partials.Channel],
|
||||
});
|
||||
|
||||
client.on(Events.MessageCreate, (message) => {
|
||||
handleDiscordMessage(message).catch((err) => {
|
||||
console.error('[discord] Unhandled error in message handler:', err);
|
||||
});
|
||||
});
|
||||
|
||||
client.once(Events.ClientReady, (c) => {
|
||||
console.log(`[discord] Bot logged in as ${c.user.tag}`);
|
||||
});
|
||||
|
||||
await client.login(token);
|
||||
}
|
||||
|
||||
export async function stopDiscordBot(): Promise<void> {
|
||||
if (client) {
|
||||
client.destroy();
|
||||
client = null;
|
||||
console.log('[discord] Bot stopped');
|
||||
}
|
||||
}
|
||||
|
||||
export function isDiscordBotRunning(): boolean {
|
||||
return client !== null && client.isReady();
|
||||
}
|
||||
|
||||
export function getDiscordBotUsername(): string | null {
|
||||
return client?.user?.tag ?? null;
|
||||
}
|
||||
|
||||
type DiscordConfig = {
|
||||
botToken: string;
|
||||
};
|
||||
|
||||
export async function startDiscordBotIfConfigured(): Promise<void> {
|
||||
const integration = await getServerIntegration('discord');
|
||||
if (!integration?.enabled) return;
|
||||
|
||||
const config = integration.config as Record<string, unknown>;
|
||||
const botToken = config.botToken as string | undefined;
|
||||
if (!botToken) return;
|
||||
|
||||
await startDiscordBot(botToken);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
const MAX_LENGTH = 2000;
|
||||
|
||||
export function chunkMessage(text: string): string[] {
|
||||
if (text.length <= MAX_LENGTH) return [text];
|
||||
|
||||
const chunks: string[] = [];
|
||||
const paragraphs = text.split('\n\n');
|
||||
|
||||
let current = '';
|
||||
|
||||
for (const paragraph of paragraphs) {
|
||||
if (paragraph.length > MAX_LENGTH) {
|
||||
// Flush current chunk
|
||||
if (current) {
|
||||
chunks.push(current.trim());
|
||||
current = '';
|
||||
}
|
||||
// Split long paragraph on newlines
|
||||
const lines = paragraph.split('\n');
|
||||
for (const line of lines) {
|
||||
if (line.length > MAX_LENGTH) {
|
||||
// Flush current
|
||||
if (current) {
|
||||
chunks.push(current.trim());
|
||||
current = '';
|
||||
}
|
||||
// Hard-split long line
|
||||
for (let i = 0; i < line.length; i += MAX_LENGTH) {
|
||||
chunks.push(line.slice(i, i + MAX_LENGTH));
|
||||
}
|
||||
} else if (current.length + 1 + line.length > MAX_LENGTH) {
|
||||
chunks.push(current.trim());
|
||||
current = line;
|
||||
} else {
|
||||
current += (current ? '\n' : '') + line;
|
||||
}
|
||||
}
|
||||
} else if (current.length + 2 + paragraph.length > MAX_LENGTH) {
|
||||
chunks.push(current.trim());
|
||||
current = paragraph;
|
||||
} else {
|
||||
current += (current ? '\n\n' : '') + paragraph;
|
||||
}
|
||||
}
|
||||
|
||||
if (current.trim()) {
|
||||
chunks.push(current.trim());
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import type { Message as DiscordMessage } from 'discord.js';
|
||||
import { findUserByIntegrationConfig, readConfigValue } from 'officerdb';
|
||||
import { sendAndAwait, getSessionModel, setSessionModel } from '../send-and-await';
|
||||
import { consumePairingCode } from '../pairing';
|
||||
import { chunkMessage } from './chunker';
|
||||
import { listPiModels } from '@@/api/pi/list-models';
|
||||
import { enqueue } from '@@/queue/engine';
|
||||
import { readJob } from '@@/queue/storage';
|
||||
import { openEmailDb } from '@@/api/email/email-db';
|
||||
import type { ModelInfo } from '@@/api/pi/types';
|
||||
|
||||
const PAIRING_CODE_PATTERN = /^[A-Z0-9]{6}$/;
|
||||
const TYPING_INTERVAL_MS = 8_000;
|
||||
|
||||
type SendableChannel = { send: (content: string) => Promise<unknown> };
|
||||
|
||||
type AccessPolicy = { allowedModels: string[] };
|
||||
const ACCESS_POLICY_KEY = 'pi-access-policy';
|
||||
|
||||
async function getVisibleModels(): Promise<ModelInfo[]> {
|
||||
const allModels = await listPiModels();
|
||||
const policy = await readConfigValue<AccessPolicy>(ACCESS_POLICY_KEY, { allowedModels: [] });
|
||||
const allowed = policy.allowedModels;
|
||||
|
||||
if (allowed.length === 0) return allModels;
|
||||
|
||||
const allowedSet = new Set(allowed);
|
||||
const allowedProviderSet = new Set(allowed.map((key) => key.split(':')[0]));
|
||||
|
||||
return allModels.filter((m) => {
|
||||
const key = `${m.provider}:${m.id}`;
|
||||
const isExplicitlyAllowed = allowedSet.has(key);
|
||||
const isFromNewProvider = !allowedProviderSet.has(m.provider);
|
||||
return isExplicitlyAllowed || isFromNewProvider;
|
||||
});
|
||||
}
|
||||
|
||||
type CommandContext = {
|
||||
content: string;
|
||||
channel: SendableChannel;
|
||||
userId: number;
|
||||
email: string;
|
||||
discordId: string;
|
||||
};
|
||||
|
||||
async function handleEmailSync(ctx: CommandContext): Promise<void> {
|
||||
const { channel, email } = ctx;
|
||||
|
||||
// Count emails before sync
|
||||
let countBefore = 0;
|
||||
try {
|
||||
const db = openEmailDb(email);
|
||||
const row = db.query('SELECT COUNT(*) as count FROM emails WHERE deleted = 0').get() as { count: number };
|
||||
countBefore = row.count;
|
||||
db.close();
|
||||
} catch {
|
||||
// DB might not exist yet
|
||||
}
|
||||
|
||||
await channel.send('Syncing emails...');
|
||||
|
||||
const job = await enqueue({ lane: 'google-api', type: 'gmail-sync', userId: email });
|
||||
|
||||
// Poll until done
|
||||
const poll = async (): Promise<'completed' | 'failed' | 'cancelled'> => {
|
||||
for (let i = 0; i < 120; i++) {
|
||||
await new Promise((r) => setTimeout(r, 3000));
|
||||
const current = await readJob(job.id);
|
||||
if (!current) return 'failed';
|
||||
if (current.status === 'completed' || current.status === 'failed' || current.status === 'cancelled') {
|
||||
return current.status;
|
||||
}
|
||||
}
|
||||
return 'failed';
|
||||
};
|
||||
|
||||
const status = await poll();
|
||||
|
||||
if (status !== 'completed') {
|
||||
await channel.send('Email sync failed. Check the queue dashboard for details.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Count emails after sync and get newest ones
|
||||
try {
|
||||
const db = openEmailDb(email);
|
||||
const row = db.query('SELECT COUNT(*) as count FROM emails WHERE deleted = 0').get() as { count: number };
|
||||
const countAfter = row.count;
|
||||
const newCount = countAfter - countBefore;
|
||||
|
||||
if (newCount <= 0) {
|
||||
db.close();
|
||||
await channel.send('Sync complete — no new emails.');
|
||||
return;
|
||||
}
|
||||
|
||||
const newest = db.query(
|
||||
'SELECT from_name, from_address, subject FROM emails WHERE deleted = 0 ORDER BY date DESC LIMIT ?',
|
||||
).all(Math.min(newCount, 20)) as Array<{ from_name: string | null; from_address: string; subject: string }>;
|
||||
db.close();
|
||||
|
||||
const lines = newest.map((e) => {
|
||||
const sender = e.from_name || e.from_address;
|
||||
return `- **${sender}**: ${e.subject}`;
|
||||
});
|
||||
|
||||
let text = `Sync complete — **${newCount}** new email${newCount !== 1 ? 's' : ''}`;
|
||||
if (newCount > 20) text += ` (showing latest 20)`;
|
||||
text += `:\n\n${lines.join('\n')}`;
|
||||
|
||||
const chunks = chunkMessage(text);
|
||||
for (const chunk of chunks) {
|
||||
await channel.send(chunk);
|
||||
}
|
||||
} catch (err) {
|
||||
await channel.send(`Sync complete but failed to read results: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCommand(ctx: CommandContext): Promise<boolean> {
|
||||
const { content, channel, discordId } = ctx;
|
||||
const lower = content.toLowerCase();
|
||||
|
||||
const helpSections: Record<string, string> = {
|
||||
models:
|
||||
'**Models:**\n' +
|
||||
'`!model` — show current model\n' +
|
||||
'`!model <id>` — switch model\n' +
|
||||
'`!models` — list available models',
|
||||
email:
|
||||
'**Email:**\n' +
|
||||
'`!email sync` — sync Gmail and show new emails',
|
||||
};
|
||||
|
||||
if (lower === '!help' || lower.startsWith('!help ')) {
|
||||
const topic = content.slice('!help'.length).trim().toLowerCase();
|
||||
if (topic && topic in helpSections) {
|
||||
await channel.send(helpSections[topic]!);
|
||||
return true;
|
||||
}
|
||||
if (topic) {
|
||||
await channel.send(`Unknown topic: \`${topic}\`\nAvailable: ${Object.keys(helpSections).map((k) => `\`${k}\``).join(', ')}`);
|
||||
return true;
|
||||
}
|
||||
const full = Object.values(helpSections).join('\n\n');
|
||||
await channel.send(full + '\n\n`!help <topic>` — show commands for a topic');
|
||||
return true;
|
||||
}
|
||||
|
||||
if (lower === '!models') {
|
||||
const models = await getVisibleModels();
|
||||
if (models.length === 0) {
|
||||
await channel.send('No models available.');
|
||||
return true;
|
||||
}
|
||||
const current = getSessionModel('discord', discordId);
|
||||
const grouped = new Map<string, string[]>();
|
||||
for (const m of models) {
|
||||
const list = grouped.get(m.provider) ?? [];
|
||||
list.push(m.id === current ? `**${m.id}** (current)` : m.id);
|
||||
grouped.set(m.provider, list);
|
||||
}
|
||||
let text = '**Available models:**\n';
|
||||
for (const [provider, ids] of grouped) {
|
||||
text += `\n__${provider}__\n${ids.map((id) => ` ${id}`).join('\n')}\n`;
|
||||
}
|
||||
text += '\nUse `!model <id>` to switch.';
|
||||
await channel.send(text);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (lower === '!model') {
|
||||
const current = getSessionModel('discord', discordId);
|
||||
await channel.send(current ? `Current model: **${current}**` : 'No active session yet — the default model will be used on your next message.');
|
||||
return true;
|
||||
}
|
||||
|
||||
if (lower.startsWith('!model ')) {
|
||||
const requested = content.slice('!model '.length).trim();
|
||||
if (!requested) {
|
||||
const current = getSessionModel('discord', discordId);
|
||||
await channel.send(current ? `Current model: **${current}**` : 'No active session yet.');
|
||||
return true;
|
||||
}
|
||||
const models = await getVisibleModels();
|
||||
const match = models.find((m) => m.id === requested || m.name === requested);
|
||||
if (!match) {
|
||||
await channel.send(`Model not found: \`${requested}\`\nUse \`!models\` to see available models.`);
|
||||
return true;
|
||||
}
|
||||
setSessionModel('discord', discordId, match.id);
|
||||
await channel.send(`Model switched to **${match.id}**. The new model will be used on your next message.`);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (lower === '!email sync') {
|
||||
await handleEmailSync(ctx);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Not a recognized command — pass through to PI
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function handleDiscordMessage(message: DiscordMessage): Promise<void> {
|
||||
// Ignore bots and non-DM messages
|
||||
if (message.author.bot) return;
|
||||
if (!message.channel.isDMBased() || !('send' in message.channel)) return;
|
||||
|
||||
const channel = message.channel;
|
||||
const discordId = message.author.id;
|
||||
const content = message.content.trim();
|
||||
if (!content) return;
|
||||
|
||||
// Look up linked Officer user
|
||||
const linked = await findUserByIntegrationConfig('discord', 'discordId', discordId);
|
||||
|
||||
if (!linked) {
|
||||
// Check if this is a pairing code
|
||||
if (PAIRING_CODE_PATTERN.test(content.toUpperCase())) {
|
||||
const result = await consumePairingCode(content.toUpperCase(), discordId);
|
||||
if (result) {
|
||||
await channel.send('Account linked! You can now chat with me.');
|
||||
return;
|
||||
}
|
||||
await channel.send('Invalid or expired pairing code. Please generate a new one from Officer Settings.');
|
||||
return;
|
||||
}
|
||||
|
||||
await channel.send(
|
||||
'I don\'t recognize your Discord account. To link it:\n' +
|
||||
'1. Go to Officer Settings → Integrations → Discord\n' +
|
||||
'2. Click "Link Discord" to get a pairing code\n' +
|
||||
'3. Send the 6-character code to me here',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle commands
|
||||
if (content.startsWith('!')) {
|
||||
const handled = await handleCommand({ content, channel, userId: linked.user.id, email: linked.user.email, discordId });
|
||||
if (handled) return;
|
||||
}
|
||||
|
||||
// Start typing indicator with keep-alive
|
||||
const sendTyping = () => {
|
||||
if ('sendTyping' in channel) {
|
||||
(channel as { sendTyping: () => Promise<void> }).sendTyping().catch(() => {});
|
||||
}
|
||||
};
|
||||
const typingInterval = setInterval(sendTyping, TYPING_INTERVAL_MS);
|
||||
sendTyping();
|
||||
|
||||
try {
|
||||
const result = await sendAndAwait({
|
||||
userId: linked.user.id,
|
||||
email: linked.user.email,
|
||||
username: linked.user.username ?? linked.user.email.split('@')[0]!,
|
||||
prompt: content,
|
||||
context: 'discord',
|
||||
contextId: discordId,
|
||||
});
|
||||
|
||||
clearInterval(typingInterval);
|
||||
|
||||
const signature = `\`${result.model}\`\n`;
|
||||
const chunks = chunkMessage(result.text);
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
await channel.send(i === 0 ? signature + chunks[i]! : chunks[i]!);
|
||||
}
|
||||
} catch (err) {
|
||||
clearInterval(typingInterval);
|
||||
console.error('[discord] Error handling message:', err);
|
||||
await channel.send('Sorry, something went wrong processing your message.').catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { ChannelProvider } from './types';
|
||||
import { upsertUserIntegration } from 'officerdb';
|
||||
|
||||
type PairingEntry = {
|
||||
userId: number;
|
||||
email: string;
|
||||
provider: ChannelProvider;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
const pairingCodes = new Map<string, PairingEntry>();
|
||||
|
||||
const CODE_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
||||
const CODE_LENGTH = 6;
|
||||
const CODE_CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // no 0/O/1/I ambiguity
|
||||
|
||||
function generateCode(): string {
|
||||
let code = '';
|
||||
for (let i = 0; i < CODE_LENGTH; i++) {
|
||||
code += CODE_CHARS[Math.floor(Math.random() * CODE_CHARS.length)]!;
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
function cleanupExpiredCodes(): void {
|
||||
const now = Date.now();
|
||||
for (const [code, entry] of pairingCodes) {
|
||||
if (entry.expiresAt <= now) {
|
||||
pairingCodes.delete(code);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function generatePairingCode(userId: number, email: string, provider: ChannelProvider): string {
|
||||
cleanupExpiredCodes();
|
||||
|
||||
// Revoke any existing code for this user+provider
|
||||
for (const [code, entry] of pairingCodes) {
|
||||
if (entry.userId === userId && entry.provider === provider) {
|
||||
pairingCodes.delete(code);
|
||||
}
|
||||
}
|
||||
|
||||
let code: string;
|
||||
do {
|
||||
code = generateCode();
|
||||
} while (pairingCodes.has(code));
|
||||
|
||||
pairingCodes.set(code, {
|
||||
userId,
|
||||
email,
|
||||
provider,
|
||||
expiresAt: Date.now() + CODE_TTL_MS,
|
||||
});
|
||||
|
||||
return code;
|
||||
}
|
||||
|
||||
type PairingResult = {
|
||||
userId: number;
|
||||
email: string;
|
||||
provider: ChannelProvider;
|
||||
};
|
||||
|
||||
export async function consumePairingCode(code: string, channelUserId: string): Promise<PairingResult | null> {
|
||||
const entry = pairingCodes.get(code.toUpperCase());
|
||||
if (!entry || entry.expiresAt <= Date.now()) return null;
|
||||
|
||||
pairingCodes.delete(code.toUpperCase());
|
||||
|
||||
await upsertUserIntegration({
|
||||
userId: entry.userId,
|
||||
provider: entry.provider,
|
||||
config: { discordId: channelUserId },
|
||||
});
|
||||
|
||||
return { userId: entry.userId, email: entry.email, provider: entry.provider };
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { createRouter } from '@@/create-router';
|
||||
import {
|
||||
getServerIntegration,
|
||||
upsertServerIntegration,
|
||||
getUserIntegration,
|
||||
deleteUserIntegration,
|
||||
} from 'officerdb';
|
||||
import { startDiscordBot, stopDiscordBot, isDiscordBotRunning, getDiscordBotUsername } from './discord/bot';
|
||||
import { generatePairingCode } from './pairing';
|
||||
|
||||
export const channelsRouter = createRouter();
|
||||
|
||||
// ── Admin: Discord config ──
|
||||
|
||||
channelsRouter.get('/discord/config', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
if (user.role !== 'Super Admin') return ctx.json({ error: 'Forbidden' }, 403);
|
||||
|
||||
const integration = await getServerIntegration('discord');
|
||||
if (!integration) return ctx.json({ configured: false });
|
||||
|
||||
const config = integration.config as Record<string, unknown>;
|
||||
const botToken = config.botToken as string | undefined;
|
||||
|
||||
return ctx.json({
|
||||
configured: !!botToken,
|
||||
enabled: integration.enabled,
|
||||
botToken: botToken ? `${botToken.slice(0, 8)}...${botToken.slice(-4)}` : null,
|
||||
serverInvite: (config.serverInvite as string) ?? null,
|
||||
botHandle: (config.botHandle as string) ?? null,
|
||||
});
|
||||
});
|
||||
|
||||
channelsRouter.put('/discord/config', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
if (user.role !== 'Super Admin') return ctx.json({ error: 'Forbidden' }, 403);
|
||||
|
||||
const body = ctx.get('body') as Record<string, unknown>;
|
||||
const botToken = body.botToken as string | undefined;
|
||||
const enabled = body.enabled as boolean | undefined;
|
||||
const serverInvite = body.serverInvite as string | undefined;
|
||||
const botHandle = body.botHandle as string | undefined;
|
||||
|
||||
if (!botToken && enabled === undefined && serverInvite === undefined && botHandle === undefined) {
|
||||
return ctx.json({ error: 'At least one field required' }, 400);
|
||||
}
|
||||
|
||||
const existing = await getServerIntegration('discord');
|
||||
const existingConfig = (existing?.config ?? {}) as Record<string, unknown>;
|
||||
const newConfig = { ...existingConfig };
|
||||
if (botToken) newConfig.botToken = botToken;
|
||||
if (serverInvite !== undefined) newConfig.serverInvite = serverInvite;
|
||||
if (botHandle !== undefined) newConfig.botHandle = botHandle;
|
||||
|
||||
await upsertServerIntegration('discord', newConfig, enabled ?? existing?.enabled ?? true);
|
||||
|
||||
// Restart bot if running or if we have a token and it's enabled
|
||||
const shouldRun = enabled ?? existing?.enabled ?? true;
|
||||
const token = (botToken ?? existingConfig.botToken) as string | undefined;
|
||||
|
||||
if (token && shouldRun) {
|
||||
try {
|
||||
await startDiscordBot(token);
|
||||
} catch (err) {
|
||||
console.error('[channels] Failed to start Discord bot:', err);
|
||||
return ctx.json({ success: true, botStarted: false, error: String(err) });
|
||||
}
|
||||
return ctx.json({ success: true, botStarted: true });
|
||||
}
|
||||
|
||||
if (!shouldRun) {
|
||||
await stopDiscordBot();
|
||||
}
|
||||
|
||||
return ctx.json({ success: true, botStarted: false });
|
||||
});
|
||||
|
||||
channelsRouter.get('/discord/status', async (ctx) => {
|
||||
const integration = await getServerIntegration('discord');
|
||||
const config = (integration?.config ?? {}) as Record<string, unknown>;
|
||||
|
||||
return ctx.json({
|
||||
configured: !!config.botToken,
|
||||
enabled: integration?.enabled ?? false,
|
||||
running: isDiscordBotRunning(),
|
||||
botUsername: getDiscordBotUsername(),
|
||||
serverInvite: (config.serverInvite as string) ?? null,
|
||||
botHandle: (config.botHandle as string) ?? null,
|
||||
});
|
||||
});
|
||||
|
||||
// ── User: Discord pairing ──
|
||||
|
||||
channelsRouter.post('/discord/pair', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const code = generatePairingCode(user.id, user.email, 'discord');
|
||||
return ctx.json({ code, expiresIn: 600 });
|
||||
});
|
||||
|
||||
channelsRouter.get('/discord/connection', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const integration = await getUserIntegration(user.id, 'discord');
|
||||
if (!integration) return ctx.json({ linked: false });
|
||||
|
||||
const config = integration.config as Record<string, unknown>;
|
||||
return ctx.json({
|
||||
linked: true,
|
||||
discordId: config.discordId,
|
||||
});
|
||||
});
|
||||
|
||||
channelsRouter.delete('/discord/connection', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const deleted = await deleteUserIntegration(user.id, 'discord');
|
||||
return ctx.json({ success: deleted });
|
||||
});
|
||||
@@ -0,0 +1,325 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import { join } from 'path';
|
||||
import type { PiEvent, MessageCost, Message } from '@@/api/pi/types';
|
||||
import { sessionManager } from '@@/api/pi/session-manager';
|
||||
import * as storage from '@@/api/pi/storage';
|
||||
import * as piBridge from '@@/api/pi/pi-bridge';
|
||||
import { getHomeDir } from '@@/data-path';
|
||||
import { getUserSettings } from 'officerdb';
|
||||
import { logger } from '@@/api/pi/logger';
|
||||
|
||||
const DEFAULT_MODEL = 'opencode/big-pickle';
|
||||
const IDLE_TIMEOUT_MS = 60 * 60 * 1000;
|
||||
const SEND_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
|
||||
type SendAndAwaitParams = {
|
||||
userId: number;
|
||||
email: string;
|
||||
username: string;
|
||||
prompt: string;
|
||||
context: string;
|
||||
contextId: string;
|
||||
model?: string;
|
||||
};
|
||||
|
||||
type SendAndAwaitResult = {
|
||||
text: string;
|
||||
sessionId: string;
|
||||
model: string;
|
||||
cost: MessageCost;
|
||||
};
|
||||
|
||||
// Per-session mutex to serialize concurrent prompts
|
||||
const sessionLocks = new Map<string, Promise<void>>();
|
||||
|
||||
// Per-session callback — swapped each time a new prompt is sent
|
||||
type EventCallback = (event: PiEvent) => void;
|
||||
const sessionCallbacks = new Map<string, EventCallback>();
|
||||
|
||||
// Channel model overrides — survive session eviction/recreation
|
||||
const channelModelOverrides = new Map<string, string>();
|
||||
|
||||
async function getUserDefaultModel(userId: number): Promise<string | null> {
|
||||
try {
|
||||
const settings = await getUserSettings(userId);
|
||||
const chat = settings?.chat as Record<string, unknown> | undefined;
|
||||
return (chat?.defaultModel as string) || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getSessionModel(context: string, contextId: string): string | null {
|
||||
const sessionId = `channel-${context}-${contextId}`;
|
||||
const session = sessionManager.getSession(sessionId);
|
||||
return session?.model ?? channelModelOverrides.get(sessionId) ?? null;
|
||||
}
|
||||
|
||||
export function setSessionModel(context: string, contextId: string, model: string): void {
|
||||
const sessionId = `channel-${context}-${contextId}`;
|
||||
// Store override independently of session — survives idle eviction
|
||||
channelModelOverrides.set(sessionId, model);
|
||||
|
||||
const session = sessionManager.getSession(sessionId);
|
||||
if (session) {
|
||||
session.model = model;
|
||||
session.meta.model = model;
|
||||
// Kill existing PI process so it respawns with the new model
|
||||
if (session.piProcess) {
|
||||
piBridge.killPi(session.piProcess);
|
||||
session.piProcess = null;
|
||||
}
|
||||
logger.info('Channel model switched', { sessionId, model, killedProcess: true });
|
||||
} else {
|
||||
logger.info('Channel model override stored (no active session)', { sessionId, model });
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendAndAwait(params: SendAndAwaitParams): Promise<SendAndAwaitResult> {
|
||||
const { context, contextId } = params;
|
||||
const sessionId = `channel-${context}-${contextId}`;
|
||||
|
||||
// Serialize per session — if two messages arrive at once, second waits for first
|
||||
const existing = sessionLocks.get(sessionId) ?? Promise.resolve();
|
||||
let releaseLock: () => void;
|
||||
const lockPromise = new Promise<void>((resolve) => {
|
||||
releaseLock = resolve;
|
||||
});
|
||||
sessionLocks.set(sessionId, existing.then(() => lockPromise));
|
||||
|
||||
await existing;
|
||||
|
||||
try {
|
||||
return await doSend(sessionId, params);
|
||||
} finally {
|
||||
releaseLock!();
|
||||
if (sessionLocks.get(sessionId) === existing.then(() => lockPromise)) {
|
||||
sessionLocks.delete(sessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Persistent event dispatcher — registered once at spawn time, delegates to current callback
|
||||
function createDispatcher(sessionId: string): (event: PiEvent) => void {
|
||||
return (event: PiEvent) => {
|
||||
const cb = sessionCallbacks.get(sessionId);
|
||||
if (cb) cb(event);
|
||||
};
|
||||
}
|
||||
|
||||
async function doSend(sessionId: string, params: SendAndAwaitParams): Promise<SendAndAwaitResult> {
|
||||
const { userId, email, username, prompt, context, contextId } = params;
|
||||
const homeDir = getHomeDir(email);
|
||||
const cwd = homeDir;
|
||||
|
||||
// Resolve model: explicit param > channel override > existing session model > user default > system default
|
||||
const existingSession = sessionManager.getSession(sessionId);
|
||||
let model = params.model ?? channelModelOverrides.get(sessionId) ?? existingSession?.model;
|
||||
if (!model) {
|
||||
const userDefault = await getUserDefaultModel(userId);
|
||||
model = userDefault ?? DEFAULT_MODEL;
|
||||
}
|
||||
|
||||
const session = sessionManager.getOrCreate(sessionId, email, cwd, model, null, context, contextId);
|
||||
session.model = model;
|
||||
session.meta.model = model;
|
||||
session.userId = userId;
|
||||
logger.info('Channel doSend', { sessionId, model, hasProcess: !!session.piProcess });
|
||||
|
||||
return new Promise<SendAndAwaitResult>((resolve, reject) => {
|
||||
let resultText = '';
|
||||
const cost: MessageCost = { inputTokens: 0, outputTokens: 0, totalUSD: 0 };
|
||||
let settled = false;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
session.isGenerating = false;
|
||||
sessionCallbacks.delete(sessionId);
|
||||
reject(new Error('sendAndAwait timed out after 5 minutes'));
|
||||
}
|
||||
}, SEND_TIMEOUT_MS);
|
||||
|
||||
const settle = () => {
|
||||
sessionCallbacks.delete(sessionId);
|
||||
clearTimeout(timeout);
|
||||
settled = true;
|
||||
};
|
||||
|
||||
// Register per-prompt callback — the persistent dispatcher will call this
|
||||
sessionCallbacks.set(sessionId, (event: PiEvent) => {
|
||||
if (settled) return;
|
||||
|
||||
switch (event.type) {
|
||||
case 'delta': {
|
||||
session.streamBuffer += event.text;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'text': {
|
||||
const text = event.text || session.streamBuffer;
|
||||
if (text) {
|
||||
resultText += (resultText ? '\n\n' : '') + text;
|
||||
const assistantMsg: Message = {
|
||||
id: randomUUID(),
|
||||
timestamp: Date.now(),
|
||||
role: 'assistant',
|
||||
text,
|
||||
model,
|
||||
};
|
||||
session.messages.push(assistantMsg);
|
||||
session.meta.messageCount += 1;
|
||||
session.streamBuffer = '';
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'tool:start': {
|
||||
if (session.streamBuffer) {
|
||||
resultText += (resultText ? '\n\n' : '') + session.streamBuffer;
|
||||
const assistantMsg: Message = {
|
||||
id: randomUUID(),
|
||||
timestamp: Date.now(),
|
||||
role: 'assistant',
|
||||
text: session.streamBuffer,
|
||||
model,
|
||||
};
|
||||
session.messages.push(assistantMsg);
|
||||
session.meta.messageCount += 1;
|
||||
session.streamBuffer = '';
|
||||
}
|
||||
const toolMsg: Message = {
|
||||
id: randomUUID(),
|
||||
timestamp: Date.now(),
|
||||
role: 'tool',
|
||||
toolCallId: event.toolCallId,
|
||||
toolName: event.toolName,
|
||||
toolInput: event.toolInput,
|
||||
};
|
||||
session.messages.push(toolMsg);
|
||||
session.meta.messageCount += 1;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'tool:result': {
|
||||
for (let i = session.messages.length - 1; i >= 0; i--) {
|
||||
const m = session.messages[i]!;
|
||||
if (m.role === 'tool' && m.toolCallId === event.toolCallId) {
|
||||
m.output = event.output;
|
||||
m.isError = event.isError;
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'result': {
|
||||
if (session.streamBuffer) {
|
||||
resultText += (resultText ? '\n\n' : '') + session.streamBuffer;
|
||||
const assistantMsg: Message = {
|
||||
id: randomUUID(),
|
||||
timestamp: Date.now(),
|
||||
role: 'assistant',
|
||||
text: session.streamBuffer,
|
||||
model,
|
||||
cost: event.cost,
|
||||
};
|
||||
session.messages.push(assistantMsg);
|
||||
session.meta.messageCount += 1;
|
||||
session.streamBuffer = '';
|
||||
}
|
||||
|
||||
session.isGenerating = false;
|
||||
session.meta.cost.inputTokens += event.cost.inputTokens;
|
||||
session.meta.cost.outputTokens += event.cost.outputTokens;
|
||||
session.meta.cost.totalUSD += event.cost.totalUSD;
|
||||
session.meta.updatedAt = Date.now();
|
||||
|
||||
cost.inputTokens = event.cost.inputTokens;
|
||||
cost.outputTokens = event.cost.outputTokens;
|
||||
cost.totalUSD = event.cost.totalUSD;
|
||||
|
||||
storage.saveSession(homeDir, sessionId, session.meta, session.messages).catch((err) => {
|
||||
logger.error('Failed to save channel session', { sessionId, error: String(err) });
|
||||
});
|
||||
|
||||
sessionManager.setIdleTimeout(sessionId, IDLE_TIMEOUT_MS);
|
||||
|
||||
settle();
|
||||
resolve({ text: resultText || '(no response)', sessionId, model: model!, cost });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'error': {
|
||||
session.isGenerating = false;
|
||||
settle();
|
||||
reject(new Error(event.message));
|
||||
break;
|
||||
}
|
||||
|
||||
case 'stopped': {
|
||||
session.isGenerating = false;
|
||||
settle();
|
||||
resolve({ text: resultText || '(stopped)', sessionId, model: model!, cost });
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Spawn Pi process if not running
|
||||
(async () => {
|
||||
try {
|
||||
if (!session.piProcess) {
|
||||
let spawnOptions: { sessionFile?: string } | undefined;
|
||||
if (session.messages.length > 0) {
|
||||
await storage.saveSession(homeDir, sessionId, session.meta, session.messages);
|
||||
const hostPath = await storage.getSessionFilePath(homeDir, sessionId);
|
||||
if (hostPath) {
|
||||
// Remap host path to container path
|
||||
const containerHome = `/home/${username}`;
|
||||
const sessionsPrefix = join(homeDir, '.pi', 'agent', 'sessions');
|
||||
const relativePart = hostPath.slice(sessionsPrefix.length);
|
||||
spawnOptions = { sessionFile: `${containerHome}/.pi/agent/sessions${relativePart}` };
|
||||
}
|
||||
}
|
||||
|
||||
const dispatcher = createDispatcher(sessionId);
|
||||
const sandbox = { userId, username, email, homeDir };
|
||||
session.piProcess = await piBridge.spawnPi(cwd, model!, userId, email, dispatcher, sandbox, spawnOptions);
|
||||
session.sandboxed = true;
|
||||
|
||||
const proc = session.piProcess;
|
||||
proc.exited.then(() => {
|
||||
if (session.piProcess === proc) {
|
||||
session.piProcess = null;
|
||||
logger.info('Channel Pi process exited', { sessionId });
|
||||
}
|
||||
});
|
||||
|
||||
logger.info('Spawned Pi for channel session', { sessionId, model, context });
|
||||
}
|
||||
|
||||
// Add user message
|
||||
const userMsg: Message = {
|
||||
id: randomUUID(),
|
||||
timestamp: Date.now(),
|
||||
role: 'user',
|
||||
text: prompt,
|
||||
};
|
||||
session.messages.push(userMsg);
|
||||
session.meta.messageCount += 1;
|
||||
session.meta.updatedAt = Date.now();
|
||||
|
||||
if (!session.meta.title) {
|
||||
session.meta.title = prompt.slice(0, 100);
|
||||
}
|
||||
|
||||
session.isGenerating = true;
|
||||
piBridge.sendPrompt(session.piProcess, prompt, randomUUID());
|
||||
} catch (err) {
|
||||
settle();
|
||||
reject(err);
|
||||
}
|
||||
})();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export type ChannelProvider = 'discord' | 'telegram' | 'whatsapp';
|
||||
|
||||
export type ChannelBot = {
|
||||
provider: ChannelProvider;
|
||||
start: (token: string) => Promise<void>;
|
||||
stop: () => Promise<void>;
|
||||
isRunning: () => boolean;
|
||||
};
|
||||
@@ -24,6 +24,8 @@ import { dockRouter } from './api/dock/dock';
|
||||
import { integrationsRouter, googleCallbackHandler } from './api/integrations/integrations';
|
||||
import { queueRouter } from './api/queue/queue';
|
||||
import { emailRouter } from './api/email/email';
|
||||
import { channelsRouter } from './channels/routes';
|
||||
import { browserRouter } from './api/browser/router';
|
||||
import { CustomError } from './custom-errors';
|
||||
import { userMiddleware, bodyParser, isOriginAllowed, superAdminMiddleware } from './_middlewares';
|
||||
|
||||
@@ -79,6 +81,8 @@ protectedRouter.route('/dock', dockRouter);
|
||||
protectedRouter.route('/integrations', integrationsRouter);
|
||||
protectedRouter.route('/queue', queueRouter);
|
||||
protectedRouter.route('/email', emailRouter);
|
||||
protectedRouter.route('/channels', channelsRouter);
|
||||
protectedRouter.route('/browser', browserRouter);
|
||||
protectedRouter.route('/', piRestRouter);
|
||||
|
||||
honoServer.route('/api', protectedRouter);
|
||||
|
||||
@@ -383,8 +383,7 @@ const gmailSyncHandler: JobHandler = {
|
||||
|
||||
if (year) {
|
||||
// Year-scoped sync: count total emails first, then sync month by month
|
||||
const labelScope = '(in:inbox OR in:sent OR in:trash OR in:spam)';
|
||||
const yearQuery = `${labelScope} after:${year}/1/1 before:${year + 1}/1/1`;
|
||||
const yearQuery = `after:${year}/1/1 before:${year + 1}/1/1`;
|
||||
await ctx.updateProgress({ current: 0, total: 0, label: 'Counting emails...' });
|
||||
const totalEmails = await countMessages(token, yearQuery);
|
||||
console.log(`[gmail-sync] Pre-flight: ${totalEmails} emails for ${year}`);
|
||||
@@ -393,7 +392,7 @@ const gmailSyncHandler: JobHandler = {
|
||||
for (let i = 0; i < months.length; i++) {
|
||||
const month = months[i]!;
|
||||
await ctx.updateProgress({ current: totalSaved + totalSkipped, total: totalEmails, label: month.label });
|
||||
const query = `${labelScope} after:${month.after} before:${month.before}`;
|
||||
const query = `after:${month.after} before:${month.before}`;
|
||||
const result = await syncInbox(token, db, ctx.job.userId, query, (p) => {
|
||||
const current = totalSaved + p.saved + p.skipped + p.errors;
|
||||
const label = `${month.label} — Saved ${(totalSaved + p.saved).toLocaleString()} of ${totalEmails.toLocaleString()}`;
|
||||
@@ -411,14 +410,11 @@ const gmailSyncHandler: JobHandler = {
|
||||
}
|
||||
await ctx.updateProgress({ current: totalEmails, total: totalEmails, label: 'Done' });
|
||||
} else {
|
||||
// Scope to inbox + sent to avoid syncing trash/spam/drafts
|
||||
const lastSyncDate = getSyncMeta(db, 'last_sync_date');
|
||||
const labelScope = 'in:inbox OR in:sent OR in:trash OR in:spam';
|
||||
let syncQuery: string = labelScope;
|
||||
let syncQuery: string | undefined;
|
||||
if (lastSyncDate) {
|
||||
const d = new Date(lastSyncDate);
|
||||
const dateScope = `after:${d.getFullYear()}/${d.getMonth() + 1}/${d.getDate()}`;
|
||||
syncQuery = `(${labelScope}) ${dateScope}`;
|
||||
syncQuery = `after:${d.getFullYear()}/${d.getMonth() + 1}/${d.getDate()}`;
|
||||
console.log(`[gmail-sync] Scoping full sync with query: ${syncQuery}`);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user