This commit is contained in:
2026-02-27 08:27:43 +00:00
parent 7bf55af5b3
commit bc4c20929c
48 changed files with 4344 additions and 275 deletions
+1
View File
@@ -59,6 +59,7 @@ export function App() {
<Route path="/projects/new" element={<Dashboard.NewProjectRedirect />} />
<Route path="/projects/:id" element={<Dashboard.ProjectScreen />} />
<Route path="/email" element={<Dashboard.EmailScreen />} />
<Route path="/browser" element={<Dashboard.BrowserScreen />} />
<Route path="/terminal" element={<Dashboard.TerminalScreen />} />
<Route path="/auth/signout" element={<Authentication.SignoutScreen />} />
<Route path="*" element={<Navigate to="/" replace />} />
@@ -0,0 +1,38 @@
import { useMemo, useCallback } from 'react';
import type { LayoutNode, PanelComponents } from 'officerdev';
import { WorkspaceView } from 'officerdev';
import { useWorkspacesState } from 'state/useWorkspacesState';
import { useIsMobile } from 'hooks/useIsMobile';
import { useGlobal } from 'hooks/useGlobal';
import { defaultLayout } from './defaultLayout';
import { TabList } from './TabList';
import { TabPreview } from './TabPreview';
export const BrowserScreen = () => {
const isMobile = useIsMobile();
const [selectedId, setSelectedId] = useGlobal<string | null>('BROWSER_SELECTED_TAB', null);
const workspace = useWorkspacesState<LayoutNode>('screens/browser', defaultLayout);
const components: PanelComponents = useMemo(
() => ({
'browser-tabs': TabList,
'browser-preview': TabPreview,
}),
[],
);
const mobilePanelId = isMobile && selectedId ? 'browser-preview' : undefined;
const onMobileBack = useCallback(() => setSelectedId(null), [setSelectedId]);
return (
<div className="h-full w-full pt-2">
<WorkspaceView
workspace={workspace}
locked
components={components}
mobilePanelId={mobilePanelId}
onMobilePanelChange={mobilePanelId ? () => onMobileBack() : undefined}
/>
</div>
);
};
@@ -0,0 +1,177 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Globe, Copy, Check, Loader2, X, Eye } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { useClient } from 'hooks/useClient';
import { useGlobal } from 'hooks/useGlobal';
type RelayToken = {
token: string;
port: number;
};
type Target = {
id: string;
sessionId: string;
type: string;
title: string;
url: string;
};
export const TabList = () => {
const client = useClient();
const [selectedId, setSelectedId] = useGlobal<string | null>('BROWSER_SELECTED_TAB', null);
const [copied, setCopied] = useState(false);
const { data: status } = useQuery({
queryKey: ['browser-status'],
queryFn: () => client.get<{ extensionConnected: boolean; targetCount: number }>('/browser/status'),
refetchInterval: 5000,
});
const { data: tokenData } = useQuery({
queryKey: ['browser-relay-token'],
queryFn: () => client.get<RelayToken>('/browser/relay-token'),
});
const { data: targets, isLoading } = useQuery({
queryKey: ['browser-targets'],
queryFn: () => client.get<Target[]>('/browser/targets'),
refetchInterval: 3000,
enabled: !!status?.extensionConnected,
});
const targetList: Target[] = targets ?? [];
const handleCopyToken = async () => {
if (!tokenData?.token) return;
try {
await navigator.clipboard.writeText(tokenData.token);
setCopied(true);
toast.success('Token copied to clipboard');
setTimeout(() => setCopied(false), 2000);
} catch {
toast.error('Failed to copy');
}
};
const handleClose = async (targetId: string) => {
try {
await client.post(`/browser/targets/${targetId}/close`);
} catch {
toast.error('Failed to close tab');
}
};
const handleActivate = async (targetId: string) => {
try {
await client.post(`/browser/targets/${targetId}/activate`);
} catch {
toast.error('Failed to activate tab');
}
};
const isConnected = status?.extensionConnected ?? false;
return (
<div className="flex h-full flex-col overflow-y-auto">
<div className="flex items-center gap-2 border-b px-3 py-2">
<Globe className="h-4 w-4 opacity-60" />
<span className="text-sm font-medium">Browser</span>
<div
className={`ml-auto h-2 w-2 rounded-full ${isConnected ? 'bg-green-500' : 'bg-red-500'}`}
title={isConnected ? 'Extension connected' : 'Extension not connected'}
/>
</div>
{!isConnected && (
<div className="flex flex-col gap-3 p-4 text-sm">
<p className="opacity-60">Connect the Officer Browser Relay extension to start.</p>
{tokenData && (
<div className="flex flex-col gap-2 rounded-lg border p-3">
<div className="flex items-center justify-between">
<span className="text-xs font-medium opacity-60">Server address</span>
<code className="text-xs">{window.location.hostname}</code>
</div>
<div className="flex items-center justify-between">
<span className="text-xs font-medium opacity-60">Relay port</span>
<code className="text-xs">{tokenData.port}</code>
</div>
<div className="flex items-center gap-2">
<span className="text-xs font-medium opacity-60">Relay token</span>
<Button variant="ghost" size="sm" className="ml-auto h-6 px-2" onClick={handleCopyToken}>
{copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
<span className="ml-1 text-xs">{copied ? 'Copied' : 'Copy'}</span>
</Button>
</div>
</div>
)}
<ol className="list-inside list-decimal space-y-1 text-xs opacity-50">
<li>Load the extension from <code>src/extensions/browser-relay/</code></li>
<li>Open extension options</li>
<li>Enter the server address, port, and token</li>
<li>Click the extension icon on a tab</li>
</ol>
</div>
)}
{isConnected && isLoading && (
<div className="flex h-32 items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin opacity-50" />
</div>
)}
{isConnected && !isLoading && targetList.length === 0 && (
<div className="flex h-32 flex-col items-center justify-center gap-2 text-sm opacity-50">
<Globe className="h-6 w-6" />
<span>No tabs attached</span>
<span className="text-xs">Click the extension icon on a tab</span>
</div>
)}
{isConnected && targetList.length > 0 && (
<div className="flex flex-1 flex-col divide-y divide-white/10">
{targetList.map((target) => (
<button
key={target.id}
onClick={() => setSelectedId(target.id)}
className={`group flex flex-col gap-0.5 px-3 py-2.5 text-left transition-colors cursor-pointer ${
selectedId === target.id ? 'bg-accent' : 'hover:bg-accent/50'
}`}
>
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium">{target.title || 'Untitled'}</span>
<div className="ml-auto flex shrink-0 items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<button
onClick={(ev) => {
ev.stopPropagation();
handleActivate(target.id);
}}
className="rounded p-0.5 hover:bg-black/10"
title="Focus tab"
>
<Eye className="h-3.5 w-3.5" />
</button>
<button
onClick={(ev) => {
ev.stopPropagation();
handleClose(target.id);
}}
className="rounded p-0.5 hover:bg-black/10"
title="Close tab"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
</div>
<span className="truncate text-xs opacity-50">{target.url}</span>
</button>
))}
</div>
)}
</div>
);
};
@@ -0,0 +1,137 @@
import { useState, useCallback } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Globe, RefreshCw, Send, Terminal, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { useClient } from 'hooks/useClient';
import { useGlobal } from 'hooks/useGlobal';
export const TabPreview = () => {
const client = useClient();
const [selectedId] = useGlobal<string | null>('BROWSER_SELECTED_TAB', null);
const [navUrl, setNavUrl] = useState('');
const [evalExpr, setEvalExpr] = useState('');
const [evalResult, setEvalResult] = useState<string | null>(null);
const [isNavigating, setIsNavigating] = useState(false);
const [isEvaluating, setIsEvaluating] = useState(false);
const {
data: screenshot,
isLoading,
refetch,
isFetching,
} = useQuery({
queryKey: ['browser-screenshot', selectedId],
queryFn: () => client.get<{ data: string; format: string }>(`/browser/targets/${selectedId}/screenshot`),
enabled: !!selectedId,
refetchInterval: 10000,
});
const handleNavigate = useCallback(async () => {
if (!selectedId || !navUrl.trim()) return;
setIsNavigating(true);
try {
await client.post(`/browser/targets/${selectedId}/navigate`, { url: navUrl.trim() });
setTimeout(() => void refetch(), 1000);
} catch {
toast.error('Navigation failed');
} finally {
setIsNavigating(false);
}
}, [client, selectedId, navUrl, refetch]);
const handleEvaluate = useCallback(async () => {
if (!selectedId || !evalExpr.trim()) return;
setIsEvaluating(true);
try {
const res = await client.post<{ result: unknown }>(`/browser/targets/${selectedId}/evaluate`, {
expression: evalExpr.trim(),
});
setEvalResult(JSON.stringify(res.result, null, 2));
} catch (err) {
setEvalResult(`Error: ${err instanceof Error ? err.message : String(err)}`);
} finally {
setIsEvaluating(false);
}
}, [client, selectedId, evalExpr]);
if (!selectedId) {
return (
<div className="flex h-full flex-col items-center justify-center gap-2 text-sm opacity-50">
<Globe className="h-8 w-8" />
<span>Select a tab to preview</span>
</div>
);
}
return (
<div className="flex h-full flex-col overflow-hidden">
{/* URL bar */}
<div className="flex items-center gap-2 border-b px-3 py-2">
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 shrink-0" onClick={() => void refetch()} disabled={isFetching}>
{isFetching ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
</Button>
<form
className="flex flex-1 items-center gap-2"
onSubmit={(ev) => {
ev.preventDefault();
void handleNavigate();
}}
>
<input
type="text"
value={navUrl}
onChange={(ev) => setNavUrl(ev.target.value)}
placeholder="Navigate to URL..."
className="flex-1 rounded-md border bg-transparent px-2 py-1 text-sm outline-none focus:border-cyan-500"
/>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 shrink-0" type="submit" disabled={isNavigating || !navUrl.trim()}>
<Send className="h-3.5 w-3.5" />
</Button>
</form>
</div>
{/* Screenshot */}
<div className="flex-1 overflow-auto p-2">
{isLoading && !screenshot && (
<div className="flex h-full items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin opacity-50" />
</div>
)}
{screenshot && (
<img
src={`data:image/${screenshot.format};base64,${screenshot.data}`}
alt="Tab screenshot"
className="w-full rounded-md border"
/>
)}
</div>
{/* JS Console */}
<div className="border-t">
<form
className="flex items-center gap-2 px-3 py-2"
onSubmit={(ev) => {
ev.preventDefault();
void handleEvaluate();
}}
>
<Terminal className="h-3.5 w-3.5 shrink-0 opacity-50" />
<input
type="text"
value={evalExpr}
onChange={(ev) => setEvalExpr(ev.target.value)}
placeholder="Evaluate JavaScript..."
className="flex-1 bg-transparent text-sm outline-none font-mono"
/>
<Button variant="ghost" size="sm" className="h-6 px-2 shrink-0" type="submit" disabled={isEvaluating || !evalExpr.trim()}>
{isEvaluating ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Run'}
</Button>
</form>
{evalResult !== null && (
<pre className="max-h-32 overflow-auto border-t px-3 py-2 text-xs font-mono opacity-70">{evalResult}</pre>
)}
</div>
</div>
);
};
@@ -0,0 +1,11 @@
import type { LayoutNode } from 'officerdev';
export const defaultLayout: LayoutNode = {
type: 'group',
id: 'browser-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'browser-tabs', appType: null }, size: 25 },
{ node: { type: 'panel', id: 'browser-preview', appType: null }, size: 75 },
],
};
@@ -0,0 +1 @@
export { BrowserScreen } from './BrowserScreen';
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from 'react';
import { Link } from 'react-router';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { ChevronLeft, ChevronRight, Loader2, Mail, Paperclip, RefreshCw } from 'lucide-react';
import { ChevronLeft, ChevronRight, Inbox, Loader2, Mail, Paperclip, RefreshCw, Send, ShieldAlert, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { useClient } from 'hooks/useClient';
@@ -18,6 +18,13 @@ type GoogleStatus = {
const LIMIT = 50;
const FOLDERS = [
{ key: 'inbox', label: 'Inbox', icon: Inbox },
{ key: 'sent', label: 'Sent', icon: Send },
{ key: 'spam', label: 'Spam', icon: ShieldAlert },
{ key: 'trash', label: 'Trash', icon: Trash2 },
] as const;
const formatDate = (iso: string) => {
const date = new Date(iso);
const now = new Date();
@@ -32,6 +39,7 @@ export const EmailList = () => {
const client = useClient();
const queryClient = useQueryClient();
const [selectedId, setSelectedId] = useGlobal<string | null>('EMAIL_SELECTED', null);
const [folder, setFolder] = useGlobal<string>('EMAIL_FOLDER', 'inbox');
const [page, setPage] = useState(1);
const { jobs, createJob } = useJobs({ type: 'gmail-sync' });
const isSyncing = jobs.some((j) => j.status === 'queued' || j.status === 'running');
@@ -42,11 +50,16 @@ export const EmailList = () => {
});
const { data, isLoading } = useQuery({
queryKey: ['email-messages', page],
queryKey: ['email-messages', page, folder],
queryFn: () =>
client.get<{ messages: EmailSummary[]; total: number }>(`/email/messages?page=${page}&limit=${LIMIT}`),
client.get<{ messages: EmailSummary[]; total: number }>(`/email/messages?page=${page}&limit=${LIMIT}&folder=${folder}`),
});
const handleFolderChange = (newFolder: string) => {
setFolder(newFolder);
setPage(1);
};
const handleSync = async () => {
try {
await createJob({ lane: 'google-api', type: 'gmail-sync', notify: false });
@@ -106,8 +119,25 @@ export const EmailList = () => {
return (
<div className="flex h-full flex-col overflow-y-auto">
<div className="flex items-center gap-2 border-b px-3 py-2">
<Mail className="h-4 w-4 opacity-60" />
<span className="text-sm font-medium">Inbox</span>
<div className="flex items-center gap-1">
{FOLDERS.map((f) => {
const Icon = f.icon;
const isActive = folder === f.key;
return (
<button
key={f.key}
onClick={() => handleFolderChange(f.key)}
className={`flex items-center gap-1 rounded px-2 py-1 text-xs transition-colors cursor-pointer ${
isActive ? 'bg-accent font-medium' : 'opacity-60 hover:opacity-100 hover:bg-accent/50'
}`}
title={f.label}
>
<Icon className="h-3.5 w-3.5" />
<span className="hidden sm:inline">{f.label}</span>
</button>
);
})}
</div>
<span className="text-xs opacity-50">{total}</span>
<button
onClick={handleSync}
@@ -110,7 +110,7 @@ export const Dock = ({ items, className }: DockProps) => {
};
import { Home, MessageCircle, FileText, FolderOpen, Code, LayoutGrid, Bot, ScrollText, FolderKanban, Monitor, Mail } from 'lucide-react';
import { Home, MessageCircle, FileText, FolderOpen, Code, LayoutGrid, Bot, ScrollText, FolderKanban, Monitor, Mail, Globe } from 'lucide-react';
export const ALL_DOCK_ITEMS: DockItem[] = [
{ label: 'Home', to: '/', icon: Home, color: '#f59e0b' },
@@ -123,6 +123,7 @@ export const ALL_DOCK_ITEMS: DockItem[] = [
{ label: 'Logs', to: '/task-logs', icon: ScrollText, color: '#94a3b8' },
{ label: 'Terminal', to: '/terminal', icon: Monitor, color: '#f97316' },
{ label: 'Projects', to: '/projects', icon: FolderKanban, color: '#10b981' },
{ label: 'Browser', to: '/browser', icon: Globe, color: '#06b6d4' },
{ label: 'Workspaces', to: '/workspaces', icon: LayoutGrid, color: '#8b5cf6' },
];
@@ -0,0 +1,174 @@
import { useState, useEffect } from 'react';
import { toast } from 'sonner';
import { Copy } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useClient } from 'hooks/useClient';
type DiscordConnection = {
linked: boolean;
discordId?: string;
};
type DiscordStatus = {
configured: boolean;
running: boolean;
serverInvite: string | null;
botHandle: string | null;
};
export const DiscordAccount = () => {
const client = useClient();
const [isLoading, setIsLoading] = useState(true);
const [connection, setConnection] = useState<DiscordConnection>({ linked: false });
const [botStatus, setBotStatus] = useState<DiscordStatus>({ configured: false, running: false });
const [pairingCode, setPairingCode] = useState<string | null>(null);
const [isGenerating, setIsGenerating] = useState(false);
useEffect(() => {
Promise.all([
client.get<DiscordConnection>('/channels/discord/connection').then(setConnection),
client.get<DiscordStatus>('/channels/discord/status').then(setBotStatus),
])
.catch(() => {})
.finally(() => setIsLoading(false));
}, []);
const handleGenerateCode = async () => {
setIsGenerating(true);
try {
const res = await client.post<{ code: string; expiresIn: number }>('/channels/discord/pair', {});
setPairingCode(res.code);
} catch {
toast.error('Failed to generate pairing code');
} finally {
setIsGenerating(false);
}
};
const handleCopyCode = () => {
if (pairingCode) {
navigator.clipboard.writeText(pairingCode);
toast.success('Code copied to clipboard');
}
};
const handleDisconnect = async () => {
try {
await client.delete('/channels/discord/connection');
setConnection({ linked: false });
setPairingCode(null);
toast.success('Discord account unlinked');
} catch {
toast.error('Failed to unlink Discord account');
}
};
if (isLoading) return null;
if (!botStatus.configured) {
return (
<div className="grid gap-4">
<p className="text-sm text-duck-dark/60 dark:text-foreground/60">
Discord integration has not been configured yet. Ask your administrator to set up the Discord bot in the
Enterprise settings.
</p>
</div>
);
}
const connectionInfo = (botStatus.serverInvite || botStatus.botHandle) && (
<div className="rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-4 grid gap-2">
{botStatus.serverInvite && (
<div className="flex items-center gap-2 text-sm">
<span className="text-duck-dark/50 dark:text-foreground/50 shrink-0">Server:</span>
<a
href={botStatus.serverInvite}
target="_blank"
rel="noopener noreferrer"
className="text-duck-teal underline truncate"
>
{botStatus.serverInvite}
</a>
</div>
)}
{botStatus.botHandle && (
<div className="flex items-center gap-2 text-sm">
<span className="text-duck-dark/50 dark:text-foreground/50 shrink-0">Bot:</span>
<code className="text-xs bg-duck-dark/5 dark:bg-foreground/5 px-2 py-1 rounded">{botStatus.botHandle}</code>
</div>
)}
</div>
);
if (connection.linked) {
return (
<div className="grid gap-4">
<div className="flex items-center gap-3 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-4">
<div className="h-2.5 w-2.5 rounded-full bg-green-500 shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-duck-dark dark:text-foreground">Linked</p>
<p className="text-xs text-duck-dark/50 dark:text-foreground/50 truncate">
Discord ID: {connection.discordId}
</p>
</div>
</div>
{connectionInfo}
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">
You can send direct messages to the bot on Discord and they will be handled by your PI agent.
</p>
<Button
type="button"
variant="outline"
onClick={handleDisconnect}
className="w-full h-11 cursor-pointer"
>
Unlink Discord
</Button>
</div>
);
}
return (
<div className="grid gap-4">
<p className="text-sm text-duck-dark/60 dark:text-foreground/60">
Link your Discord account to chat with your PI agent via direct messages.
</p>
{connectionInfo}
{pairingCode ? (
<div className="grid gap-3">
<div className="flex items-center justify-between rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-4">
<code className="text-2xl font-mono font-bold tracking-[0.3em] text-duck-dark dark:text-foreground">
{pairingCode}
</code>
<Button type="button" variant="ghost" size="icon" onClick={handleCopyCode} className="cursor-pointer shrink-0">
<Copy className="h-4 w-4" />
</Button>
</div>
<p className="text-xs text-duck-dark/50 dark:text-foreground/50">
Send this code as a direct message to the bot on Discord. Expires in 10 minutes.
</p>
<Button
type="button"
variant="outline"
onClick={handleGenerateCode}
disabled={isGenerating}
className="w-full h-11 cursor-pointer"
>
Generate New Code
</Button>
</div>
) : (
<Button
type="button"
onClick={handleGenerateCode}
disabled={isGenerating}
className="w-full h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold transition-all duration-200 hover:scale-105 cursor-pointer"
>
{isGenerating ? 'Generating...' : 'Link Discord'}
</Button>
)}
</div>
);
};
@@ -0,0 +1,237 @@
import { useState, useEffect } from 'react';
import { toast } from 'sonner';
import { ChevronDown } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
import { useClient } from 'hooks/useClient';
type DiscordConfig = {
configured: boolean;
enabled: boolean;
botToken: string | null;
serverInvite: string | null;
botHandle: string | null;
};
type DiscordStatus = {
configured: boolean;
enabled: boolean;
running: boolean;
botUsername: string | null;
};
const SetupGuide = () => {
const [open, setOpen] = useState(false);
return (
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger className="flex items-center gap-2 text-sm font-medium text-duck-teal cursor-pointer hover:underline w-full">
<ChevronDown className={`h-3.5 w-3.5 transition-transform duration-200 ${open ? 'rotate-180' : ''}`} />
Step-by-step setup guide
</CollapsibleTrigger>
<CollapsibleContent>
<ol className="mt-3 grid gap-4 text-sm text-duck-dark/70 dark:text-foreground/70 list-decimal list-outside pl-5">
<li>
<strong className="text-duck-dark dark:text-foreground">Create a Discord application</strong>
<p className="mt-1">
Go to the{' '}
<a href="https://discord.com/developers/applications" target="_blank" rel="noopener noreferrer" className="text-duck-teal underline">
Discord Developer Portal
</a>{' '}
and click <strong>New Application</strong>.
</p>
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
<li>Fill in <strong>Name</strong> (e.g. &quot;Officer&quot;), optionally a <strong>Description</strong> and <strong>Tags</strong></li>
<li>Leave everything else empty <strong>Interactions Endpoint URL</strong>, <strong>Linked Roles Verification URL</strong>, and <strong>Terms of Service / Privacy Policy URLs</strong> are not needed</li>
</ul>
<p className="mt-1">Click <strong>Create</strong>.</p>
<p className="mt-2 text-xs text-duck-dark/50 dark:text-foreground/50">
You will see an Application ID and Public Key on this page you can ignore both. Our bot connects via the gateway (WebSocket), not webhooks, so these are not used.
</p>
</li>
<li>
<strong className="text-duck-dark dark:text-foreground">Go to the Bot page</strong>
<p className="mt-1">
In the left sidebar, click <strong>Bot</strong>. A bot user is created automatically with your application.
You can optionally set a custom username and avatar here this is what users will see when they DM the bot.
</p>
</li>
<li>
<strong className="text-duck-dark dark:text-foreground">Enable privileged intents</strong>
<p className="mt-1">
Still on the <strong>Bot</strong> page, scroll down to <strong>Privileged Gateway Intents</strong> and enable:
</p>
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
<li><strong>Presence Intent</strong> shows the bot as online in your server</li>
<li><strong>Server Members Intent</strong> makes the bot visible in the member list so users can find and DM it</li>
<li><strong>Message Content Intent</strong> required to read DM message text</li>
</ul>
<p className="mt-1">Click <strong>Save Changes</strong>.</p>
</li>
<li>
<strong className="text-duck-dark dark:text-foreground">Copy the bot token</strong>
<p className="mt-1">
On the <strong>Bot</strong> page, click <strong>Reset Token</strong> (or <strong>View Token</strong> if this is a new bot).
Copy the token you will only see it once.
</p>
<p className="mt-2 text-xs text-duck-dark/50 dark:text-foreground/50">
Keep this token secret. Anyone with this token can control your bot. If it leaks, reset it immediately from this page.
</p>
</li>
<li>
<strong className="text-duck-dark dark:text-foreground">Invite the bot to your server (optional)</strong>
<p className="mt-1">
Open this URL in your browser, replacing{' '}
<code className="text-xs bg-duck-dark/5 dark:bg-foreground/5 px-1.5 py-0.5 rounded">YOUR_APP_ID</code>{' '}
with the Application ID from the <strong>General Information</strong> page:
</p>
<code className="mt-1.5 block text-xs bg-duck-dark/5 dark:bg-foreground/5 px-3 py-2 rounded break-all">
https://discord.com/oauth2/authorize?client_id=YOUR_APP_ID&amp;scope=bot&amp;permissions=0
</code>
<p className="mt-1.5">
Select your server and click <strong>Authorize</strong>. No bot permissions are needed leave them at zero.
</p>
<p className="mt-2 text-xs text-duck-dark/50 dark:text-foreground/50">
Adding the bot to a shared server is optional but has a few advantages:
</p>
<ul className="mt-1 text-xs text-duck-dark/50 dark:text-foreground/50 list-disc list-outside pl-5 grid gap-0.5">
<li>Users can find the bot in the member list and right-click <strong>Message</strong> to start a DM</li>
<li>The bot shows as online in the server, so users can see at a glance whether it&apos;s running</li>
<li>Easier onboarding you can pin the pairing instructions in a channel for your team</li>
</ul>
<p className="mt-1 text-xs text-duck-dark/50 dark:text-foreground/50">
Without a shared server, users can still DM the bot by searching its exact username but a shared server makes discovery much simpler.
</p>
</li>
<li>
<strong className="text-duck-dark dark:text-foreground">Paste the token below and save</strong>
<p className="mt-1">
Paste the bot token into the field below and click <strong>Save</strong>.
The bot will come online automatically. Users can then link their Discord accounts from their personal integration settings.
</p>
</li>
</ol>
</CollapsibleContent>
</Collapsible>
);
};
export const DiscordBotConfig = () => {
const client = useClient();
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [botToken, setBotToken] = useState('');
const [serverInvite, setServerInvite] = useState('');
const [botHandle, setBotHandle] = useState('');
const [status, setStatus] = useState<DiscordStatus | null>(null);
const fetchStatus = () => {
client
.get<DiscordStatus>('/channels/discord/status')
.then(setStatus)
.catch(() => {});
};
useEffect(() => {
Promise.all([
client.get<DiscordConfig>('/channels/discord/config').then((data) => {
if (data.botToken) setBotToken(data.botToken);
if (data.serverInvite) setServerInvite(data.serverInvite);
if (data.botHandle) setBotHandle(data.botHandle);
}),
client.get<DiscordStatus>('/channels/discord/status').then(setStatus),
])
.catch(() => {})
.finally(() => setIsLoading(false));
}, []);
const handleSave = async () => {
if (isSaving) return;
setIsSaving(true);
try {
await client.put('/channels/discord/config', {
botToken: botToken.trim(),
serverInvite: serverInvite.trim() || undefined,
botHandle: botHandle.trim() || undefined,
});
toast.success('Discord bot configuration saved');
fetchStatus();
} catch {
toast.error('Failed to save Discord bot configuration');
} finally {
setIsSaving(false);
}
};
if (isLoading) return null;
return (
<div className="grid gap-5">
{status && (
<div className="flex items-center gap-2 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3">
<div className={`h-2.5 w-2.5 rounded-full shrink-0 ${status.running ? 'bg-green-500' : 'bg-duck-dark/20 dark:bg-foreground/20'}`} />
<span className="text-sm text-duck-dark dark:text-foreground">
{status.running ? `Online as ${status.botUsername}` : status.configured ? 'Bot configured but not running' : 'Not configured'}
</span>
</div>
)}
<SetupGuide />
<div className="border-t border-duck-dark/10 dark:border-foreground/10 pt-5 grid gap-5">
<Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Bot Token</span>
<Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="password"
value={botToken}
onChange={(ev) => setBotToken(ev.target.value)}
placeholder="MTIz..."
/>
</Label>
<Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Server Invite Link</span>
<Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
value={serverInvite}
onChange={(ev) => setServerInvite(ev.target.value)}
placeholder="https://discord.gg/..."
/>
<span className="text-xs text-duck-dark/40 dark:text-foreground/40">
Shown to users so they can join the server and find the bot.
</span>
</Label>
<Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Bot Handle</span>
<Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
value={botHandle}
onChange={(ev) => setBotHandle(ev.target.value)}
placeholder="my-bot#1234"
/>
<span className="text-xs text-duck-dark/40 dark:text-foreground/40">
The bot&apos;s username#discriminator shown to users who want to DM the bot directly.
</span>
</Label>
<Button
type="button"
onClick={handleSave}
disabled={isSaving || !botToken.trim()}
className="w-full h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-50 disabled:hover:scale-100"
>
{isSaving ? 'Saving...' : 'Save'}
</Button>
</div>
</div>
);
};
@@ -1,5 +1,5 @@
import { useMemo } from 'react';
import { Puzzle, KeyRound, UserCircle } from 'lucide-react';
import { Puzzle, KeyRound, UserCircle, MessageCircle } from 'lucide-react';
import type { LayoutNode, PanelComponents } from 'officerdev';
import { WorkspaceLayout } from 'officerdev';
import { useAuth } from 'hooks/useAuth';
@@ -9,16 +9,20 @@ import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { SettingsSidebar, SettingsContent, type SettingsSection } from '../SettingsPanel';
import { GoogleOAuthConfig } from './GoogleOAuthConfig';
import { GoogleAccount } from './GoogleAccount';
import { DiscordBotConfig } from './DiscordBotConfig';
import { DiscordAccount } from './DiscordAccount';
const GLOBAL_KEY = 'INTEGRATIONS_SETTINGS_SELECTED';
const TAB_KEY = 'INTEGRATIONS_SETTINGS_TAB';
const enterpriseSections: SettingsSection[] = [
{ key: 'google-oauth', icon: KeyRound, title: 'Google OAuth', description: 'Client ID and secret for Google APIs', content: <GoogleOAuthConfig /> },
{ key: 'discord-bot', icon: MessageCircle, title: 'Discord Bot', description: 'Bot token for Discord integration', content: <DiscordBotConfig /> },
];
const personalSections: SettingsSection[] = [
{ key: 'google-account', icon: UserCircle, title: 'Google Account', description: 'Connect your Google account', content: <GoogleAccount /> },
{ key: 'discord-account', icon: MessageCircle, title: 'Discord', description: 'Link your Discord account', content: <DiscordAccount /> },
];
const IntegrationsSidebar = () => {
@@ -17,3 +17,4 @@ export * from './Workspaces';
export * from './Projects';
export * from './Terminal';
export * from './Email';
export * from './Browser';
+1
View File
@@ -31,6 +31,7 @@ export {
getUserIntegration,
upsertUserIntegration,
deleteUserIntegration,
findUserByIntegrationConfig,
} from './queries/integrations';
export { db } from './db';
@@ -1,6 +1,7 @@
import { eq, and } from 'drizzle-orm';
import { eq, and, sql } from 'drizzle-orm';
import { db } from '../db';
import { serverIntegrations, userIntegrations } from '../schema';
import { users } from '../schema/auth';
import type { ServerIntegrationSelect, UserIntegrationSelect } from '../types';
// ── Server Integrations ──
@@ -71,3 +72,41 @@ export async function deleteUserIntegration(userId: number, provider: string): P
.returning({ id: userIntegrations.id });
return result.length > 0;
}
// ── Cross-table lookup ──
type UserIntegrationWithUser = UserIntegrationSelect & {
user: { id: number; email: string; username: string | null; role: string };
};
export async function findUserByIntegrationConfig(
provider: string,
configKey: string,
configValue: string,
): Promise<UserIntegrationWithUser | undefined> {
const [row] = await db
.select({
id: userIntegrations.id,
userId: userIntegrations.userId,
provider: userIntegrations.provider,
serverIntegrationId: userIntegrations.serverIntegrationId,
config: userIntegrations.config,
createdAt: userIntegrations.createdAt,
updatedAt: userIntegrations.updatedAt,
user: {
id: users.id,
email: users.email,
username: users.username,
role: users.role,
},
})
.from(userIntegrations)
.innerJoin(users, eq(userIntegrations.userId, users.id))
.where(
and(
eq(userIntegrations.provider, provider),
sql`${userIntegrations.config}->>${configKey} = ${configValue}`,
),
);
return row as UserIntegrationWithUser | undefined;
}
-230
View File
@@ -1,230 +0,0 @@
import { join } from 'node:path';
import { mkdir } from 'node:fs/promises';
import type { UserSelect, UserInsert, PasskeySelect, PasskeyInsert } from './types';
type PasskeyChallenge = {
email: string;
origin: string;
challenge: string;
createdAt: number;
};
type TokenBlacklistEntry = {
jti: string;
expiresAt: number;
};
type Meta = {
nextUserId: number;
nextPasskeyId: number;
};
const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
const AUTH_DIR = join(DATA_PATH, 'auth');
const files = {
users: join(AUTH_DIR, 'users.json'),
passkeys: join(AUTH_DIR, 'passkeys.json'),
challenges: join(AUTH_DIR, 'passkey-challenges.json'),
blacklist: join(AUTH_DIR, 'token-blacklist.json'),
meta: join(AUTH_DIR, 'meta.json'),
};
let users: UserSelect[] = [];
let passkeys: PasskeySelect[] = [];
let challenges: PasskeyChallenge[] = [];
let blacklist: TokenBlacklistEntry[] = [];
let meta: Meta = { nextUserId: 1, nextPasskeyId: 1 };
async function readJson<T>(path: string, fallback: T): Promise<T> {
try {
const file = Bun.file(path);
if (!(await file.exists())) return fallback;
return (await file.json()) as T;
} catch {
return fallback;
}
}
const writeJson = (path: string, data: unknown) => Bun.write(path, JSON.stringify(data, null, 2));
async function flushUsers() {
await writeJson(files.users, users);
}
async function flushPasskeys() {
await writeJson(files.passkeys, passkeys);
}
async function flushChallenges() {
await writeJson(files.challenges, challenges);
}
async function flushBlacklist() {
await writeJson(files.blacklist, blacklist);
}
async function flushMeta() {
await writeJson(files.meta, meta);
}
// ── Lifecycle ──
export async function initAuthStore() {
await mkdir(AUTH_DIR, { recursive: true });
users = await readJson(files.users, []);
passkeys = await readJson(files.passkeys, []);
challenges = await readJson(files.challenges, []);
blacklist = await readJson(files.blacklist, []);
meta = await readJson(files.meta, { nextUserId: 1, nextPasskeyId: 1 });
// Reconcile meta with existing data
const maxUserId = users.reduce((max, u) => Math.max(max, u.id), 0);
const maxPasskeyId = passkeys.reduce((max, p) => Math.max(max, p.id), 0);
if (meta.nextUserId <= maxUserId) meta.nextUserId = maxUserId + 1;
if (meta.nextPasskeyId <= maxPasskeyId) meta.nextPasskeyId = maxPasskeyId + 1;
}
// ── Users ──
export function getUsers(): UserSelect[] {
return users;
}
export function getUserById(id: number): UserSelect | undefined {
return users.find((u) => u.id === id);
}
export function getUserByEmail(email: string): UserSelect | undefined {
return users.find((u) => u.email === email);
}
export function getUserCount(): number {
return users.length;
}
export async function createUser(data: UserInsert): Promise<UserSelect> {
const id = meta.nextUserId++;
const user: UserSelect = {
id,
email: data.email,
password: data.password ?? null,
role: data.role ?? 'Member',
status: data.status ?? 'Unverified',
name: data.name ?? null,
username: data.username ?? null,
avatar: data.avatar ?? null,
passwordChangedAt: data.passwordChangedAt ?? null,
};
users.push(user);
await Promise.all([flushUsers(), flushMeta()]);
return user;
}
export async function updateUser(id: number, data: Partial<Omit<UserSelect, 'id'>>): Promise<UserSelect | undefined> {
const idx = users.findIndex((u) => u.id === id);
if (idx === -1) return undefined;
users[idx] = { ...users[idx]!, ...data };
await flushUsers();
return users[idx];
}
export async function deleteUser(id: number): Promise<boolean> {
const idx = users.findIndex((u) => u.id === id);
if (idx === -1) return false;
users.splice(idx, 1);
await flushUsers();
return true;
}
// ── Passkeys ──
export function getPasskeysByEmail(email: string): PasskeySelect[] {
return passkeys.filter((p) => p.email === email);
}
export function getPasskeysByEmailAndOrigin(email: string, origin: string): PasskeySelect[] {
return passkeys.filter((p) => p.email === email && p.origin === origin);
}
export function getPasskeyByCredentialId(email: string, credentialId: string): PasskeySelect | undefined {
return passkeys.find((p) => p.email === email && p.credentialId === credentialId);
}
export async function createPasskey(data: PasskeyInsert): Promise<PasskeySelect> {
const id = meta.nextPasskeyId++;
const passkey: PasskeySelect = {
id,
email: data.email,
origin: data.origin ?? null,
credentialId: data.credentialId ?? null,
publicKey: data.publicKey ?? null,
counter: data.counter ?? 0,
};
passkeys.push(passkey);
await Promise.all([flushPasskeys(), flushMeta()]);
return passkey;
}
export async function updatePasskey(
id: number,
data: Partial<Omit<PasskeySelect, 'id'>>,
): Promise<PasskeySelect | undefined> {
const idx = passkeys.findIndex((p) => p.id === id);
if (idx === -1) return undefined;
passkeys[idx] = { ...passkeys[idx]!, ...data };
await flushPasskeys();
return passkeys[idx];
}
// ── Passkey Challenges ──
export async function storeChallenge(email: string, origin: string, challenge: string) {
const idx = challenges.findIndex((c) => c.email === email && c.origin === origin);
const entry: PasskeyChallenge = { email, origin, challenge, createdAt: Date.now() };
if (idx !== -1) {
challenges[idx] = entry;
} else {
challenges.push(entry);
}
await flushChallenges();
}
export async function consumeChallenge(email: string, origin: string, ttlMs: number): Promise<string | null> {
const now = Date.now();
// Remove expired challenges
challenges = challenges.filter((c) => now - c.createdAt < ttlMs);
const idx = challenges.findIndex((c) => c.email === email && c.origin === origin);
if (idx === -1) {
await flushChallenges();
return null;
}
const entry = challenges[idx]!;
challenges.splice(idx, 1);
await flushChallenges();
if (now - entry.createdAt >= ttlMs) return null;
return entry.challenge;
}
// ── Token Blacklist ──
export async function blacklistToken(jti: string, expiresAt: number) {
if (blacklist.some((b) => b.jti === jti)) return;
blacklist.push({ jti, expiresAt });
await flushBlacklist();
}
export function isTokenBlacklisted(jti: string): boolean {
return blacklist.some((b) => b.jti === jti);
}
export async function cleanupExpiredTokens() {
const now = Math.floor(Date.now() / 1000);
const before = blacklist.length;
blacklist = blacklist.filter((b) => b.expiresAt >= now);
if (blacklist.length !== before) await flushBlacklist();
}
+45
View File
@@ -0,0 +1,45 @@
# Officer Browser Relay — Setup Guide
## Prerequisites
- Officer server running (`bun dev`)
- Google Chrome or Chromium-based browser on your machine
- Both machines on the same Tailscale network (or localhost for local dev)
## Steps
### 1. Verify relay is running
Check the server logs for `[browser-relay] listening on port 18792`.
### 2. Get your relay credentials
Open Officer dashboard → **Browser** screen (or `/browser`).
The setup card shows your **server address**, **port**, and **relay token**.
### 3. Load the extension in Chrome
1. Open `chrome://extensions`
2. Enable **Developer mode** (top-right toggle)
3. Click **Load unpacked**
4. Select `src/extensions/browser-relay/`
### 4. Configure the extension
1. The options page opens automatically after install (or right-click extension → Options)
2. Enter the **Server address** (Officer's IP — e.g. your Tailscale IP, or `127.0.0.1` for local)
3. Enter the **Port** (default `18792`)
4. Paste your **Relay token** from step 2
5. Click **Save** — status should show green "Relay reachable and authenticated"
### 5. Attach a tab
1. Navigate to any webpage in Chrome
2. Click the Officer Browser Relay extension icon in the toolbar
3. Badge shows **ON** (cyan) = attached
### 6. Use from the dashboard
1. Open `/browser` in Officer
2. Your attached tab appears in the left panel
3. Click it to see a live screenshot in the right panel
4. Use the URL bar to navigate, or the JS console to evaluate expressions
## Troubleshooting
- **Red `!` badge**: Relay not reachable — check server address/port/token
- **`…` badge**: Reconnecting — relay was temporarily unavailable
- **Tab not appearing**: Make sure the extension badge shows ON, and refresh the dashboard
- **Screenshot blank**: The tab may be on a `chrome://` page (not supported by CDP)
@@ -0,0 +1,51 @@
// Based on OpenClaw Browser Extension Relay (MIT License)
// https://github.com/nicepkg/openclaw
export function reconnectDelayMs(
attempt,
opts = { baseMs: 1000, maxMs: 30000, jitterMs: 1000, random: Math.random },
) {
const baseMs = Number.isFinite(opts.baseMs) ? opts.baseMs : 1000;
const maxMs = Number.isFinite(opts.maxMs) ? opts.maxMs : 30000;
const jitterMs = Number.isFinite(opts.jitterMs) ? opts.jitterMs : 1000;
const random = typeof opts.random === "function" ? opts.random : Math.random;
const safeAttempt = Math.max(0, Number.isFinite(attempt) ? attempt : 0);
const backoff = Math.min(baseMs * 2 ** safeAttempt, maxMs);
return backoff + Math.max(0, jitterMs) * random();
}
export async function deriveRelayToken(gatewayToken, port) {
const enc = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
enc.encode(gatewayToken),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const sig = await crypto.subtle.sign(
"HMAC",
key,
enc.encode(`officer-browser-relay-v1:${port}`),
);
return [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, "0")).join("");
}
export async function buildRelayWsUrl(host, port, gatewayToken) {
const token = String(gatewayToken || "").trim();
if (!token) {
throw new Error(
"Missing relay token in extension settings (chrome.storage.local.relayToken)",
);
}
const relayToken = await deriveRelayToken(token, port);
return `ws://${host}:${port}/extension?token=${encodeURIComponent(relayToken)}`;
}
export function isRetryableReconnectError(err) {
const message = err instanceof Error ? err.message : String(err || "");
if (message.includes("Missing relay token")) {
return false;
}
return true;
}
+920
View File
@@ -0,0 +1,920 @@
// Based on OpenClaw Browser Extension Relay (MIT License)
// https://github.com/nicepkg/openclaw
import { buildRelayWsUrl, isRetryableReconnectError, reconnectDelayMs } from './background-utils.js'
const DEFAULT_PORT = 18792
const DEFAULT_HOST = '127.0.0.1'
const BADGE = {
on: { text: 'ON', color: '#06b6d4' },
off: { text: '', color: '#000000' },
connecting: { text: '…', color: '#F59E0B' },
error: { text: '!', color: '#B91C1C' },
}
/** @type {WebSocket|null} */
let relayWs = null
/** @type {Promise<void>|null} */
let relayConnectPromise = null
let relayGatewayToken = ''
/** @type {string|null} */
let relayConnectRequestId = null
let nextSession = 1
/** @type {Map<number, {state:'connecting'|'connected', sessionId?:string, targetId?:string, attachOrder?:number}>} */
const tabs = new Map()
/** @type {Map<string, number>} */
const tabBySession = new Map()
/** @type {Map<string, number>} */
const childSessionToTab = new Map()
/** @type {Map<number, {resolve:(v:any)=>void, reject:(e:Error)=>void}>} */
const pending = new Map()
/** @type {Set<number>} */
const tabOperationLocks = new Set()
/** @type {Set<number>} */
const reattachPending = new Set()
let reconnectAttempt = 0
let reconnectTimer = null
async function getRelayPort() {
const stored = await chrome.storage.local.get(['relayPort'])
const raw = stored.relayPort
const n = Number.parseInt(String(raw || ''), 10)
if (!Number.isFinite(n) || n <= 0 || n > 65535) return DEFAULT_PORT
return n
}
async function getRelayHost() {
const stored = await chrome.storage.local.get(['relayHost'])
const host = String(stored.relayHost || '').trim()
return host || DEFAULT_HOST
}
async function getRelayToken() {
const stored = await chrome.storage.local.get(['relayToken'])
const token = String(stored.relayToken || '').trim()
return token || ''
}
function setBadge(tabId, kind) {
const cfg = BADGE[kind]
void chrome.action.setBadgeText({ tabId, text: cfg.text })
void chrome.action.setBadgeBackgroundColor({ tabId, color: cfg.color })
void chrome.action.setBadgeTextColor({ tabId, color: '#FFFFFF' }).catch(() => {})
}
async function persistState() {
try {
const tabEntries = []
for (const [tabId, tab] of tabs.entries()) {
if (tab.state === 'connected' && tab.sessionId && tab.targetId) {
tabEntries.push({ tabId, sessionId: tab.sessionId, targetId: tab.targetId, attachOrder: tab.attachOrder })
}
}
await chrome.storage.session.set({
persistedTabs: tabEntries,
nextSession,
})
} catch {
// chrome.storage.session may not be available in all contexts.
}
}
async function rehydrateState() {
try {
const stored = await chrome.storage.session.get(['persistedTabs', 'nextSession'])
if (stored.nextSession) {
nextSession = Math.max(nextSession, stored.nextSession)
}
const entries = stored.persistedTabs || []
for (const entry of entries) {
tabs.set(entry.tabId, {
state: 'connected',
sessionId: entry.sessionId,
targetId: entry.targetId,
attachOrder: entry.attachOrder,
})
tabBySession.set(entry.sessionId, entry.tabId)
setBadge(entry.tabId, 'on')
}
for (const entry of entries) {
try {
await chrome.tabs.get(entry.tabId)
await chrome.debugger.sendCommand({ tabId: entry.tabId }, 'Runtime.evaluate', {
expression: '1',
returnByValue: true,
})
} catch {
tabs.delete(entry.tabId)
tabBySession.delete(entry.sessionId)
setBadge(entry.tabId, 'off')
}
}
} catch {
// Ignore rehydration errors.
}
}
async function ensureRelayConnection() {
if (relayWs && relayWs.readyState === WebSocket.OPEN) return
if (relayConnectPromise) return await relayConnectPromise
relayConnectPromise = (async () => {
const port = await getRelayPort()
const host = await getRelayHost()
const token = await getRelayToken()
const httpBase = `http://${host}:${port}`
const wsUrl = await buildRelayWsUrl(host, port, token)
try {
await fetch(`${httpBase}/`, { method: 'HEAD', signal: AbortSignal.timeout(2000) })
} catch (err) {
throw new Error(`Relay server not reachable at ${httpBase} (${String(err)})`)
}
const ws = new WebSocket(wsUrl)
relayWs = ws
relayGatewayToken = token
ws.onmessage = (event) => {
if (ws !== relayWs) return
void whenReady(() => onRelayMessage(String(event.data || '')))
}
await new Promise((resolve, reject) => {
const t = setTimeout(() => reject(new Error('WebSocket connect timeout')), 5000)
ws.onopen = () => {
clearTimeout(t)
resolve()
}
ws.onerror = () => {
clearTimeout(t)
reject(new Error('WebSocket connect failed'))
}
ws.onclose = (ev) => {
clearTimeout(t)
reject(new Error(`WebSocket closed (${ev.code} ${ev.reason || 'no reason'})`))
}
})
ws.onclose = () => {
if (ws !== relayWs) return
onRelayClosed('closed')
}
ws.onerror = () => {
if (ws !== relayWs) return
onRelayClosed('error')
}
})()
try {
await relayConnectPromise
reconnectAttempt = 0
} finally {
relayConnectPromise = null
}
}
function onRelayClosed(reason) {
relayWs = null
relayGatewayToken = ''
relayConnectRequestId = null
for (const [id, p] of pending.entries()) {
pending.delete(id)
p.reject(new Error(`Relay disconnected (${reason})`))
}
reattachPending.clear()
for (const [tabId, tab] of tabs.entries()) {
if (tab.state === 'connected') {
setBadge(tabId, 'connecting')
void chrome.action.setTitle({
tabId,
title: 'Officer Browser Relay: relay reconnecting…',
})
}
}
scheduleReconnect()
}
function scheduleReconnect() {
if (reconnectTimer) {
clearTimeout(reconnectTimer)
reconnectTimer = null
}
const delay = reconnectDelayMs(reconnectAttempt)
reconnectAttempt++
console.log(`Scheduling reconnect attempt ${reconnectAttempt} in ${Math.round(delay)}ms`)
reconnectTimer = setTimeout(async () => {
reconnectTimer = null
try {
await ensureRelayConnection()
reconnectAttempt = 0
console.log('Reconnected successfully')
await reannounceAttachedTabs()
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
console.warn(`Reconnect attempt ${reconnectAttempt} failed: ${message}`)
if (!isRetryableReconnectError(err)) {
return
}
scheduleReconnect()
}
}, delay)
}
function cancelReconnect() {
if (reconnectTimer) {
clearTimeout(reconnectTimer)
reconnectTimer = null
}
reconnectAttempt = 0
}
async function reannounceAttachedTabs() {
for (const [tabId, tab] of tabs.entries()) {
if (tab.state !== 'connected' || !tab.sessionId || !tab.targetId) continue
try {
await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', {
expression: '1',
returnByValue: true,
})
} catch {
tabs.delete(tabId)
if (tab.sessionId) tabBySession.delete(tab.sessionId)
setBadge(tabId, 'off')
void chrome.action.setTitle({
tabId,
title: 'Officer Browser Relay (click to attach/detach)',
})
continue
}
try {
const info = /** @type {any} */ (
await chrome.debugger.sendCommand({ tabId }, 'Target.getTargetInfo')
)
const targetInfo = info?.targetInfo
sendToRelay({
method: 'forwardCDPEvent',
params: {
method: 'Target.attachedToTarget',
params: {
sessionId: tab.sessionId,
targetInfo: { ...targetInfo, attached: true },
waitingForDebugger: false,
},
},
})
setBadge(tabId, 'on')
void chrome.action.setTitle({
tabId,
title: 'Officer Browser Relay: attached (click to detach)',
})
} catch {
setBadge(tabId, 'on')
}
}
await persistState()
}
function sendToRelay(payload) {
const ws = relayWs
if (!ws || ws.readyState !== WebSocket.OPEN) {
throw new Error('Relay not connected')
}
ws.send(JSON.stringify(payload))
}
function ensureGatewayHandshakeStarted(payload) {
if (relayConnectRequestId) return
const nonce = typeof payload?.nonce === 'string' ? payload.nonce.trim() : ''
relayConnectRequestId = `ext-connect-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`
sendToRelay({
type: 'req',
id: relayConnectRequestId,
method: 'connect',
params: {
minProtocol: 3,
maxProtocol: 3,
client: {
id: 'officer-browser-relay-extension',
version: '1.0.0',
platform: 'chrome-extension',
mode: 'webchat',
},
role: 'operator',
scopes: ['operator.read', 'operator.write'],
caps: [],
commands: [],
nonce: nonce || undefined,
auth: relayGatewayToken ? { token: relayGatewayToken } : undefined,
},
})
}
async function maybeOpenHelpOnce() {
try {
const stored = await chrome.storage.local.get(['helpOnErrorShown'])
if (stored.helpOnErrorShown === true) return
await chrome.storage.local.set({ helpOnErrorShown: true })
await chrome.runtime.openOptionsPage()
} catch {
// ignore
}
}
function requestFromRelay(command) {
const id = command.id
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
pending.delete(id)
reject(new Error('Relay request timeout (30s)'))
}, 30000)
pending.set(id, {
resolve: (v) => { clearTimeout(timer); resolve(v) },
reject: (e) => { clearTimeout(timer); reject(e) },
})
try {
sendToRelay(command)
} catch (err) {
clearTimeout(timer)
pending.delete(id)
reject(err instanceof Error ? err : new Error(String(err)))
}
})
}
async function onRelayMessage(text) {
/** @type {any} */
let msg
try {
msg = JSON.parse(text)
} catch {
return
}
if (msg && msg.type === 'event' && msg.event === 'connect.challenge') {
try {
ensureGatewayHandshakeStarted(msg.payload)
} catch (err) {
console.warn('gateway connect handshake start failed', err instanceof Error ? err.message : String(err))
relayConnectRequestId = null
const ws = relayWs
if (ws && ws.readyState === WebSocket.OPEN) {
ws.close(1008, 'gateway connect failed')
}
}
return
}
if (msg && msg.type === 'res' && relayConnectRequestId && msg.id === relayConnectRequestId) {
relayConnectRequestId = null
if (!msg.ok) {
const detail = msg?.error?.message || msg?.error || 'gateway connect failed'
console.warn('gateway connect handshake rejected', String(detail))
const ws = relayWs
if (ws && ws.readyState === WebSocket.OPEN) {
ws.close(1008, 'gateway connect failed')
}
}
return
}
if (msg && msg.method === 'ping') {
try {
sendToRelay({ method: 'pong' })
} catch {
// ignore
}
return
}
if (msg && typeof msg.id === 'number' && (msg.result !== undefined || msg.error !== undefined)) {
const p = pending.get(msg.id)
if (!p) return
pending.delete(msg.id)
if (msg.error) p.reject(new Error(String(msg.error)))
else p.resolve(msg.result)
return
}
if (msg && typeof msg.id === 'number' && msg.method === 'forwardCDPCommand') {
try {
const result = await handleForwardCdpCommand(msg)
sendToRelay({ id: msg.id, result })
} catch (err) {
sendToRelay({ id: msg.id, error: err instanceof Error ? err.message : String(err) })
}
}
}
function getTabBySessionId(sessionId) {
const direct = tabBySession.get(sessionId)
if (direct) return { tabId: direct, kind: 'main' }
const child = childSessionToTab.get(sessionId)
if (child) return { tabId: child, kind: 'child' }
return null
}
function getTabByTargetId(targetId) {
for (const [tabId, tab] of tabs.entries()) {
if (tab.targetId === targetId) return tabId
}
return null
}
async function attachTab(tabId, opts = {}) {
const debuggee = { tabId }
await chrome.debugger.attach(debuggee, '1.3')
await chrome.debugger.sendCommand(debuggee, 'Page.enable').catch(() => {})
const info = /** @type {any} */ (await chrome.debugger.sendCommand(debuggee, 'Target.getTargetInfo'))
const targetInfo = info?.targetInfo
const targetId = String(targetInfo?.targetId || '').trim()
if (!targetId) {
throw new Error('Target.getTargetInfo returned no targetId')
}
const sid = nextSession++
const sessionId = `cb-tab-${sid}`
const attachOrder = sid
tabs.set(tabId, { state: 'connected', sessionId, targetId, attachOrder })
tabBySession.set(sessionId, tabId)
void chrome.action.setTitle({
tabId,
title: 'Officer Browser Relay: attached (click to detach)',
})
if (!opts.skipAttachedEvent) {
sendToRelay({
method: 'forwardCDPEvent',
params: {
method: 'Target.attachedToTarget',
params: {
sessionId,
targetInfo: { ...targetInfo, attached: true },
waitingForDebugger: false,
},
},
})
}
setBadge(tabId, 'on')
await persistState()
return { sessionId, targetId }
}
async function detachTab(tabId, reason) {
const tab = tabs.get(tabId)
for (const [childSessionId, parentTabId] of childSessionToTab.entries()) {
if (parentTabId === tabId) {
try {
sendToRelay({
method: 'forwardCDPEvent',
params: {
method: 'Target.detachedFromTarget',
params: { sessionId: childSessionId, reason: 'parent_detached' },
},
})
} catch {
// Relay may be down.
}
childSessionToTab.delete(childSessionId)
}
}
if (tab?.sessionId && tab?.targetId) {
try {
sendToRelay({
method: 'forwardCDPEvent',
params: {
method: 'Target.detachedFromTarget',
params: { sessionId: tab.sessionId, targetId: tab.targetId, reason },
},
})
} catch {
// Relay may be down.
}
}
if (tab?.sessionId) tabBySession.delete(tab.sessionId)
tabs.delete(tabId)
try {
await chrome.debugger.detach({ tabId })
} catch {
// May already be detached.
}
setBadge(tabId, 'off')
void chrome.action.setTitle({
tabId,
title: 'Officer Browser Relay (click to attach/detach)',
})
await persistState()
}
async function connectOrToggleForActiveTab() {
const [active] = await chrome.tabs.query({ active: true, currentWindow: true })
const tabId = active?.id
if (!tabId) return
if (tabOperationLocks.has(tabId)) return
tabOperationLocks.add(tabId)
try {
if (reattachPending.has(tabId)) {
reattachPending.delete(tabId)
setBadge(tabId, 'off')
void chrome.action.setTitle({
tabId,
title: 'Officer Browser Relay (click to attach/detach)',
})
return
}
const existing = tabs.get(tabId)
if (existing?.state === 'connected') {
await detachTab(tabId, 'toggle')
return
}
cancelReconnect()
tabs.set(tabId, { state: 'connecting' })
setBadge(tabId, 'connecting')
void chrome.action.setTitle({
tabId,
title: 'Officer Browser Relay: connecting to relay…',
})
try {
await ensureRelayConnection()
await attachTab(tabId)
} catch (err) {
tabs.delete(tabId)
setBadge(tabId, 'error')
void chrome.action.setTitle({
tabId,
title: 'Officer Browser Relay: relay not running (open options for setup)',
})
void maybeOpenHelpOnce()
const message = err instanceof Error ? err.message : String(err)
console.warn('attach failed', message)
}
} finally {
tabOperationLocks.delete(tabId)
}
}
async function handleForwardCdpCommand(msg) {
const method = String(msg?.params?.method || '').trim()
const params = msg?.params?.params || undefined
const sessionId = typeof msg?.params?.sessionId === 'string' ? msg.params.sessionId : undefined
const bySession = sessionId ? getTabBySessionId(sessionId) : null
const targetId = typeof params?.targetId === 'string' ? params.targetId : undefined
const tabId =
bySession?.tabId ||
(targetId ? getTabByTargetId(targetId) : null) ||
(() => {
for (const [id, tab] of tabs.entries()) {
if (tab.state === 'connected') return id
}
return null
})()
if (!tabId) throw new Error(`No attached tab for method ${method}`)
/** @type {chrome.debugger.DebuggerSession} */
const debuggee = { tabId }
if (method === 'Runtime.enable') {
try {
await chrome.debugger.sendCommand(debuggee, 'Runtime.disable')
await new Promise((r) => setTimeout(r, 50))
} catch {
// ignore
}
return await chrome.debugger.sendCommand(debuggee, 'Runtime.enable', params)
}
if (method === 'Target.createTarget') {
const url = typeof params?.url === 'string' ? params.url : 'about:blank'
const tab = await chrome.tabs.create({ url, active: false })
if (!tab.id) throw new Error('Failed to create tab')
await new Promise((r) => setTimeout(r, 100))
const attached = await attachTab(tab.id)
return { targetId: attached.targetId }
}
if (method === 'Target.closeTarget') {
const target = typeof params?.targetId === 'string' ? params.targetId : ''
const toClose = target ? getTabByTargetId(target) : tabId
if (!toClose) return { success: false }
try {
await chrome.tabs.remove(toClose)
} catch {
return { success: false }
}
return { success: true }
}
if (method === 'Target.activateTarget') {
const target = typeof params?.targetId === 'string' ? params.targetId : ''
const toActivate = target ? getTabByTargetId(target) : tabId
if (!toActivate) return {}
const tab = await chrome.tabs.get(toActivate).catch(() => null)
if (!tab) return {}
if (tab.windowId) {
await chrome.windows.update(tab.windowId, { focused: true }).catch(() => {})
}
await chrome.tabs.update(toActivate, { active: true }).catch(() => {})
return {}
}
const tabState = tabs.get(tabId)
const mainSessionId = tabState?.sessionId
const debuggerSession =
sessionId && mainSessionId && sessionId !== mainSessionId
? { ...debuggee, sessionId }
: debuggee
return await chrome.debugger.sendCommand(debuggerSession, method, params)
}
function onDebuggerEvent(source, method, params) {
const tabId = source.tabId
if (!tabId) return
const tab = tabs.get(tabId)
if (!tab?.sessionId) return
if (method === 'Target.attachedToTarget' && params?.sessionId) {
childSessionToTab.set(String(params.sessionId), tabId)
}
if (method === 'Target.detachedFromTarget' && params?.sessionId) {
childSessionToTab.delete(String(params.sessionId))
}
try {
sendToRelay({
method: 'forwardCDPEvent',
params: {
sessionId: source.sessionId || tab.sessionId,
method,
params,
},
})
} catch {
// Relay may be down.
}
}
async function onDebuggerDetach(source, reason) {
const tabId = source.tabId
if (!tabId) return
if (!tabs.has(tabId)) return
if (reason === 'canceled_by_user' || reason === 'replaced_with_devtools') {
void detachTab(tabId, reason)
return
}
let tabInfo
try {
tabInfo = await chrome.tabs.get(tabId)
} catch {
void detachTab(tabId, reason)
return
}
if (tabInfo.url?.startsWith('chrome://') || tabInfo.url?.startsWith('chrome-extension://')) {
void detachTab(tabId, reason)
return
}
if (reattachPending.has(tabId)) return
const oldTab = tabs.get(tabId)
const oldSessionId = oldTab?.sessionId
const oldTargetId = oldTab?.targetId
if (oldSessionId) tabBySession.delete(oldSessionId)
tabs.delete(tabId)
for (const [childSessionId, parentTabId] of childSessionToTab.entries()) {
if (parentTabId === tabId) childSessionToTab.delete(childSessionId)
}
if (oldSessionId && oldTargetId) {
try {
sendToRelay({
method: 'forwardCDPEvent',
params: {
method: 'Target.detachedFromTarget',
params: { sessionId: oldSessionId, targetId: oldTargetId, reason: 'navigation-reattach' },
},
})
} catch {
// Relay may be down.
}
}
reattachPending.add(tabId)
setBadge(tabId, 'connecting')
void chrome.action.setTitle({
tabId,
title: 'Officer Browser Relay: re-attaching after navigation…',
})
const delays = [300, 700, 1500]
for (let attempt = 0; attempt < delays.length; attempt++) {
await new Promise((r) => setTimeout(r, delays[attempt]))
if (!reattachPending.has(tabId)) return
try {
await chrome.tabs.get(tabId)
} catch {
reattachPending.delete(tabId)
setBadge(tabId, 'off')
return
}
if (!relayWs || relayWs.readyState !== WebSocket.OPEN) {
reattachPending.delete(tabId)
setBadge(tabId, 'error')
void chrome.action.setTitle({
tabId,
title: 'Officer Browser Relay: relay disconnected during re-attach',
})
return
}
try {
await attachTab(tabId)
reattachPending.delete(tabId)
return
} catch {
// continue retries
}
}
reattachPending.delete(tabId)
setBadge(tabId, 'off')
void chrome.action.setTitle({
tabId,
title: 'Officer Browser Relay: re-attach failed (click to retry)',
})
}
chrome.tabs.onRemoved.addListener((tabId) => void whenReady(() => {
reattachPending.delete(tabId)
if (!tabs.has(tabId)) return
const tab = tabs.get(tabId)
if (tab?.sessionId) tabBySession.delete(tab.sessionId)
tabs.delete(tabId)
for (const [childSessionId, parentTabId] of childSessionToTab.entries()) {
if (parentTabId === tabId) childSessionToTab.delete(childSessionId)
}
if (tab?.sessionId && tab?.targetId) {
try {
sendToRelay({
method: 'forwardCDPEvent',
params: {
method: 'Target.detachedFromTarget',
params: { sessionId: tab.sessionId, targetId: tab.targetId, reason: 'tab_closed' },
},
})
} catch {
// Relay may be down.
}
}
void persistState()
}))
chrome.tabs.onReplaced.addListener((addedTabId, removedTabId) => void whenReady(() => {
const tab = tabs.get(removedTabId)
if (!tab) return
tabs.delete(removedTabId)
tabs.set(addedTabId, tab)
if (tab.sessionId) {
tabBySession.set(tab.sessionId, addedTabId)
}
for (const [childSessionId, parentTabId] of childSessionToTab.entries()) {
if (parentTabId === removedTabId) {
childSessionToTab.set(childSessionId, addedTabId)
}
}
setBadge(addedTabId, 'on')
void persistState()
}))
chrome.debugger.onEvent.addListener((...args) => void whenReady(() => onDebuggerEvent(...args)))
chrome.debugger.onDetach.addListener((...args) => void whenReady(() => onDebuggerDetach(...args)))
chrome.action.onClicked.addListener(() => void whenReady(() => connectOrToggleForActiveTab()))
chrome.webNavigation.onCompleted.addListener(({ tabId, frameId }) => void whenReady(() => {
if (frameId !== 0) return
const tab = tabs.get(tabId)
if (tab?.state === 'connected') {
setBadge(tabId, relayWs && relayWs.readyState === WebSocket.OPEN ? 'on' : 'connecting')
}
}))
chrome.tabs.onActivated.addListener(({ tabId }) => void whenReady(() => {
const tab = tabs.get(tabId)
if (tab?.state === 'connected') {
setBadge(tabId, relayWs && relayWs.readyState === WebSocket.OPEN ? 'on' : 'connecting')
}
}))
chrome.runtime.onInstalled.addListener(() => {
void chrome.runtime.openOptionsPage()
})
chrome.alarms.create('relay-keepalive', { periodInMinutes: 0.5 })
chrome.alarms.onAlarm.addListener(async (alarm) => {
if (alarm.name !== 'relay-keepalive') return
await initPromise
if (tabs.size === 0) return
for (const [tabId, tab] of tabs.entries()) {
if (tab.state === 'connected') {
setBadge(tabId, relayWs && relayWs.readyState === WebSocket.OPEN ? 'on' : 'connecting')
}
}
if (!relayWs || relayWs.readyState !== WebSocket.OPEN) {
if (!relayConnectPromise && !reconnectTimer) {
console.log('Keepalive: WebSocket unhealthy, triggering reconnect')
await ensureRelayConnection().catch(() => {
if (!reconnectTimer) {
scheduleReconnect()
}
})
}
}
})
const initPromise = rehydrateState()
initPromise.then(() => {
if (tabs.size > 0) {
ensureRelayConnection().then(() => {
reconnectAttempt = 0
return reannounceAttachedTabs()
}).catch(() => {
scheduleReconnect()
})
}
})
async function whenReady(fn) {
await initPromise
return fn()
}
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
if (msg?.type !== 'relayCheck') return false
const { url, token } = msg
const headers = token ? { 'x-officer-relay-token': token } : {}
fetch(url, { method: 'GET', headers, signal: AbortSignal.timeout(2000) })
.then(async (res) => {
const contentType = String(res.headers.get('content-type') || '')
let json = null
if (contentType.includes('application/json')) {
try {
json = await res.json()
} catch {
json = null
}
}
sendResponse({ status: res.status, ok: res.ok, contentType, json })
})
.catch((err) => sendResponse({ status: 0, ok: false, error: String(err) }))
return true
})
Binary file not shown.

After

Width:  |  Height:  |  Size: 614 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 370 B

@@ -0,0 +1,25 @@
{
"manifest_version": 3,
"name": "Officer Browser Relay",
"version": "0.1.0",
"description": "Connect your Chrome browser to Officer",
"icons": {
"16": "icons/icon16.png",
"32": "icons/icon32.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
},
"permissions": ["debugger", "tabs", "activeTab", "storage", "alarms", "webNavigation"],
"host_permissions": ["<all_urls>"],
"background": { "service_worker": "background.js", "type": "module" },
"action": {
"default_title": "Officer Browser Relay (click to attach/detach)",
"default_icon": {
"16": "icons/icon16.png",
"32": "icons/icon32.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
},
"options_ui": { "page": "options.html", "open_in_tab": true }
}
@@ -0,0 +1,66 @@
// Based on OpenClaw Browser Extension Relay (MIT License)
// https://github.com/nicepkg/openclaw
function hasCdpVersionShape(data) {
return !!data && typeof data === 'object' && 'Browser' in data && 'Protocol-Version' in data
}
export function classifyRelayCheckResponse(res, host, port) {
if (!res) {
return { action: 'throw', error: 'No response from service worker' }
}
if (res.status === 401) {
return { action: 'status', kind: 'error', message: 'Relay token rejected. Check token and save again.' }
}
if (res.error) {
return { action: 'throw', error: res.error }
}
if (!res.ok) {
return { action: 'throw', error: `HTTP ${res.status}` }
}
const contentType = String(res.contentType || '')
if (!contentType.includes('application/json')) {
return {
action: 'status',
kind: 'error',
message: `Wrong endpoint: expected Officer browser relay at this address.`,
}
}
if (!hasCdpVersionShape(res.json)) {
const browser = res.json?.Browser ?? ''
if (browser && !browser.startsWith('Officer/')) {
return {
action: 'status',
kind: 'error',
message: `Wrong relay: found "${browser}" instead of Officer/browser-relay.`,
}
}
return {
action: 'status',
kind: 'error',
message: `Wrong endpoint: expected relay /json/version response.`,
}
}
return { action: 'status', kind: 'ok', message: `Relay reachable and authenticated at http://${host}:${port}/` }
}
export function classifyRelayCheckException(err, host, port) {
const message = String(err || '').toLowerCase()
if (message.includes('json') || message.includes('syntax')) {
return {
kind: 'error',
message: `Wrong endpoint: this is not a relay endpoint.`,
}
}
return {
kind: 'error',
message: `Relay not reachable at http://${host}:${port}/. Verify the server address and relay token.`,
}
}
+209
View File
@@ -0,0 +1,209 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Officer Browser Relay</title>
<style>
:root {
color-scheme: light dark;
--accent: #06b6d4;
--panel: color-mix(in oklab, canvas 92%, canvasText 8%);
--border: color-mix(in oklab, canvasText 18%, transparent);
--muted: color-mix(in oklab, canvasText 70%, transparent);
--shadow: 0 10px 30px color-mix(in oklab, canvasText 18%, transparent);
font-family: ui-rounded, system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Rounded",
"SF Pro Display", "Segoe UI", sans-serif;
line-height: 1.4;
}
body {
margin: 0;
min-height: 100vh;
background:
radial-gradient(1000px 500px at 10% 0%, color-mix(in oklab, var(--accent) 30%, transparent), transparent 70%),
radial-gradient(900px 450px at 90% 0%, color-mix(in oklab, var(--accent) 18%, transparent), transparent 75%),
canvas;
color: canvasText;
}
.wrap {
max-width: 820px;
margin: 36px auto;
padding: 0 24px 48px 24px;
}
header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 18px;
}
.logo {
width: 44px;
height: 44px;
border-radius: 14px;
background: color-mix(in oklab, var(--accent) 18%, transparent);
border: 1px solid color-mix(in oklab, var(--accent) 35%, transparent);
box-shadow: var(--shadow);
display: grid;
place-items: center;
font-size: 24px;
}
h1 {
font-size: 20px;
margin: 0;
letter-spacing: -0.01em;
}
.subtitle {
margin: 2px 0 0 0;
color: var(--muted);
font-size: 13px;
}
.grid {
display: grid;
grid-template-columns: 1fr;
gap: 14px;
}
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 16px;
padding: 16px;
box-shadow: var(--shadow);
}
.card h2 {
margin: 0 0 10px 0;
font-size: 14px;
letter-spacing: 0.01em;
}
.card p {
margin: 8px 0 0 0;
color: var(--muted);
font-size: 13px;
}
.row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
label {
display: block;
font-size: 12px;
color: var(--muted);
margin-bottom: 6px;
}
input {
padding: 10px 12px;
border-radius: 12px;
border: 1px solid var(--border);
background: color-mix(in oklab, canvas 92%, canvasText 8%);
color: canvasText;
outline: none;
}
input:focus {
border-color: color-mix(in oklab, var(--accent) 70%, transparent);
box-shadow: 0 0 0 4px color-mix(in oklab, var(--accent) 20%, transparent);
}
input.host-input {
width: 240px;
}
input.port-input {
width: 100px;
}
input.token-input {
width: min(520px, 100%);
}
button {
padding: 10px 14px;
border-radius: 12px;
border: 1px solid color-mix(in oklab, var(--accent) 55%, transparent);
background: linear-gradient(
180deg,
color-mix(in oklab, var(--accent) 80%, white 20%),
var(--accent)
);
color: white;
font-weight: 650;
letter-spacing: 0.01em;
cursor: pointer;
}
button:active {
transform: translateY(1px);
}
.hint {
margin-top: 10px;
font-size: 12px;
color: var(--muted);
}
code {
font-family: ui-monospace, Menlo, Monaco, Consolas, "SF Mono", monospace;
font-size: 12px;
}
a {
color: color-mix(in oklab, var(--accent) 85%, canvasText 15%);
}
.status {
margin-top: 10px;
font-size: 12px;
color: color-mix(in oklab, var(--accent) 70%, canvasText 30%);
min-height: 16px;
}
.status[data-kind='ok'] {
color: color-mix(in oklab, #16a34a 75%, canvasText 25%);
}
.status[data-kind='error'] {
color: color-mix(in oklab, #ef4444 75%, canvasText 25%);
}
</style>
</head>
<body>
<div class="wrap">
<header>
<div class="logo" aria-hidden="true">&#x1f310;</div>
<div>
<h1>Officer Browser Relay</h1>
<p class="subtitle">Click the toolbar button on a tab to attach / detach.</p>
</div>
</header>
<div class="grid">
<div class="card">
<h2>Getting started</h2>
<p>
If you see a red <code>!</code> badge on the extension icon, the relay server is not reachable.
Make sure Officer is running and enter the correct server address, port, and relay token below.
</p>
<p>
You can find your relay token in the Officer dashboard under <strong>Browser</strong>.
</p>
</div>
<div class="card">
<h2>Relay connection</h2>
<label for="host">Server address</label>
<div class="row">
<input id="host" class="host-input" placeholder="e.g. 100.64.0.1" />
</div>
<label for="port" style="margin-top: 10px">Port</label>
<div class="row">
<input id="port" class="port-input" inputmode="numeric" pattern="[0-9]*" />
</div>
<label for="token" style="margin-top: 10px">Relay token</label>
<div class="row">
<input id="token" class="token-input" type="password" autocomplete="off" />
<button id="save" type="button">Save</button>
</div>
<div class="hint">
Default port: <code>18792</code>. Extension connects to: <code id="relay-url">http://&lt;host&gt;:&lt;port&gt;/</code>.
</div>
<div class="status" id="status"></div>
</div>
</div>
<script type="module" src="options.js"></script>
</div>
</body>
</html>
+78
View File
@@ -0,0 +1,78 @@
import { deriveRelayToken } from './background-utils.js'
import { classifyRelayCheckException, classifyRelayCheckResponse } from './options-validation.js'
const DEFAULT_PORT = 18792
const DEFAULT_HOST = '127.0.0.1'
function clampPort(value) {
const n = Number.parseInt(String(value || ''), 10)
if (!Number.isFinite(n)) return DEFAULT_PORT
if (n <= 0 || n > 65535) return DEFAULT_PORT
return n
}
function updateRelayUrl(host, port) {
const el = document.getElementById('relay-url')
if (!el) return
el.textContent = `http://${host}:${port}/`
}
function setStatus(kind, message) {
const status = document.getElementById('status')
if (!status) return
status.dataset.kind = kind || ''
status.textContent = message || ''
}
async function checkRelayReachable(host, port, token) {
const url = `http://${host}:${port}/json/version`
const trimmedToken = String(token || '').trim()
if (!trimmedToken) {
setStatus('error', 'Relay token required. Save your token to connect.')
return
}
try {
const relayToken = await deriveRelayToken(trimmedToken, port)
const res = await chrome.runtime.sendMessage({
type: 'relayCheck',
url,
token: relayToken,
})
const result = classifyRelayCheckResponse(res, host, port)
if (result.action === 'throw') throw new Error(result.error)
setStatus(result.kind, result.message)
} catch (err) {
const result = classifyRelayCheckException(err, host, port)
setStatus(result.kind, result.message)
}
}
async function load() {
const stored = await chrome.storage.local.get(['relayPort', 'relayHost', 'relayToken'])
const port = clampPort(stored.relayPort)
const host = String(stored.relayHost || '').trim() || DEFAULT_HOST
const token = String(stored.relayToken || '').trim()
document.getElementById('host').value = host
document.getElementById('port').value = String(port)
document.getElementById('token').value = token
updateRelayUrl(host, port)
await checkRelayReachable(host, port, token)
}
async function save() {
const hostInput = document.getElementById('host')
const portInput = document.getElementById('port')
const tokenInput = document.getElementById('token')
const host = String(hostInput.value || '').trim() || DEFAULT_HOST
const port = clampPort(portInput.value)
const token = String(tokenInput.value || '').trim()
await chrome.storage.local.set({ relayHost: host, relayPort: port, relayToken: token })
hostInput.value = host
portInput.value = String(port)
tokenInput.value = token
updateRelayUrl(host, port)
await checkRelayReachable(host, port, token)
}
document.getElementById('save').addEventListener('click', () => void save())
void load()
+9
View File
@@ -8,6 +8,7 @@ import { terminalWebsocket, initTerminalSidecars } from './servers/api/terminal/
import { piWebsocket } from './servers/api/pi/websocket';
import { findEntryByProxyId, touchEntry } from './servers/api/dev-server/router';
import officerWeb from './apps/officer-web/index.html';
import { startBrowserRelay } from './servers/api/browser/relay';
const { PORT = '5000' } = process.env;
@@ -199,6 +200,14 @@ const server = serve({
console.log(`🚀 Server running at ${server.url}`);
const BROWSER_RELAY_PORT = Number(process.env.BROWSER_RELAY_PORT ?? '18792');
try {
startBrowserRelay(BROWSER_RELAY_PORT);
console.log(`[browser-relay] listening on port ${BROWSER_RELAY_PORT}`);
} catch (err) {
console.error('[browser-relay] failed to start:', err instanceof Error ? err.message : err);
}
void initTerminalSidecars();
+99
View File
@@ -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: '' };
}
}
+31
View File
@@ -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';
}
+89
View File
@@ -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>;
};
+670
View File
@@ -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;
}
+135
View File
@@ -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');
}
});
+36 -5
View File
@@ -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();
}
});
+5
View File
@@ -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);
});
})();
+65
View File
@@ -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);
}
+51
View File
@@ -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;
}
+276
View File
@@ -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(() => {});
}
}
+78
View File
@@ -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 };
}
+116
View File
@@ -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 });
});
+325
View File
@@ -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);
}
})();
});
}
+8
View File
@@ -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;
};
+4
View File
@@ -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);
+4 -8
View File
@@ -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}`);
}