diff --git a/.gitignore b/.gitignore index debb60e..cfae37f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ dev-dist node_modules .env .playwright-mcp +blog-*.md diff --git a/README.md b/README.md index 436d8da..da307a9 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,8 @@ Open `http://localhost:5173`, paste your [Anthropic API key](https://console.ant │ │ │ Channels: │ │ ├── Browser Chat (built-in) │ -│ └── Telegram Bot API (optional, pure HTTPS) │ +│ ├── Telegram Bot API (optional, pure HTTPS) │ +│ └── iMessage (optional, HTTPS + Socket.IO) │ └──────────────────────────────────────────────────────────┘ ``` @@ -59,18 +60,19 @@ Open `http://localhost:5173`, paste your [Anthropic API key](https://console.ant | `src/router.ts` | Routes messages to correct channel | | `src/channels/browser-chat.ts` | In-browser chat channel | | `src/channels/telegram.ts` | Telegram Bot API channel | +| `src/channels/imessage.ts` | iMessage channel (remote) | | `src/task-scheduler.ts` | Cron expression evaluation | | `src/crypto.ts` | AES-256-GCM encryption for stored credentials | | `src/ui/` | Chat, settings, and task manager components | ## How It Works -1. **You type a message** in the browser chat (or send one via Telegram) +1. **You type a message** in the browser chat (or send one via Telegram / iMessage) 2. **The orchestrator** checks the trigger pattern, saves to IndexedDB, queues for processing 3. **The agent worker** (a Web Worker) sends your message + conversation history to the Anthropic API 4. **Claude responds**, possibly using tools (bash, file I/O, fetch, JavaScript) 5. **Tool results** are fed back to Claude in a loop until it produces a final text response -6. **The response** is routed back to the originating channel (browser chat or Telegram) +6. **The response** is routed back to the originating channel (browser chat, Telegram, or iMessage) ## Tools @@ -95,6 +97,22 @@ Optional. Works entirely via HTTPS — no WebSockets or special protocols. **Caveat**: The browser tab must be open for the bot to respond. Messages queue on Telegram's side and are processed when you reopen the tab. +## iMessage + +Optional. Connects to a remote iMessage server via Socket.IO + REST. + +**Requirements:** +- An iMessage server +- Valid API key for the server + +**Setup:** +Open Settings → iMessage, enter your server URL and API key, and save. + +**How it works:** +- Each iMessage chat appears as a separate group with the prefix `im:` followed by the chat GUID (e.g. `im:iMessage;-;+1234567890`). +- Every incoming message triggers a response automatically — no `@mention` needed. +- Responses are sent back to the originating iMessage chat. + ## WebVM (Optional) The `bash` tool runs commands in a v86-emulated Alpine Linux. To enable: @@ -117,7 +135,7 @@ Without these assets, the `bash` tool returns a helpful error. All other tools w | Database | SQLite (better-sqlite3) | IndexedDB | | Files | Filesystem | OPFS | | Primary channel | WhatsApp | In-browser chat | -| Other channels | Telegram, Discord | Telegram | +| Other channels | Telegram, Discord, iMessage | Telegram, iMessage | | Agent SDK | Claude Agent SDK | Raw Anthropic API | | Background tasks | launchd service | setInterval (tab must be open) | | Deployment | Self-hosted server | Static files (any CDN) | @@ -156,5 +174,6 @@ OpenBrowserClaw is a proof of concept. All data stays in your browser, nothing i - The `javascript` tool runs `eval()` in the Worker, which has access to `fetch()`. This means Claude can make arbitrary HTTP requests through the JS tool regardless of any `fetch_url` restrictions. - Outgoing HTTP requests (via `fetch_url` or the JS tool) have no user confirmation step. - The Telegram bot token is currently stored in plaintext. +- The iMessage API key is currently stored in plaintext. This is a single-user local tool, not a multi-tenant platform. Contributions to improve the security model are welcome. diff --git a/package-lock.json b/package-lock.json index 1100416..0b84735 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "remark-gfm": "^4.0.1", + "socket.io-client": "^4.8.3", "zustand": "^5.0.11" }, "devDependencies": { @@ -81,7 +82,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1607,6 +1607,17 @@ "node": ">=6.9.0" } }, + "node_modules/@emnapi/runtime": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -2548,6 +2559,12 @@ "win32" ] }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, "node_modules/@surma/rollup-plugin-off-main-thread": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz", @@ -2954,7 +2971,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -3035,7 +3051,6 @@ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -3237,7 +3252,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3695,6 +3709,28 @@ "dev": true, "license": "ISC" }, + "node_modules/engine.io-client": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz", + "integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.18.3", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.19.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", @@ -6724,7 +6760,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -6734,7 +6769,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -7370,6 +7404,34 @@ "node": ">=20.0.0" } }, + "node_modules/socket.io-client": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", + "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.5.tgz", + "integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/source-map": { "version": "0.8.0-beta.0", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz", @@ -7609,8 +7671,7 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz", "integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tapable": { "version": "2.3.0", @@ -7661,7 +7722,6 @@ "integrity": "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", @@ -7722,6 +7782,14 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/type-fest": { "version": "0.16.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz", @@ -8111,7 +8179,6 @@ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -8518,7 +8585,6 @@ "integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "rollup": "dist/bin/rollup" }, @@ -8666,6 +8732,35 @@ "workbox-core": "7.4.0" } }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index 974af11..052dbf7 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "remark-gfm": "^4.0.1", + "socket.io-client": "^4.8.3", "zustand": "^5.0.11" } } diff --git a/src/App.tsx b/src/App.tsx index 31de7ad..7d05fdb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -12,6 +12,20 @@ import { FilesPage } from './components/files/FilesPage.js'; import { TasksPage } from './components/tasks/TasksPage.js'; import { SettingsPage } from './components/settings/SettingsPage.js'; +// Module-level singleton — survives React StrictMode double-mounts. +let singletonReady: Promise | null = null; + +function getOrCreateOrchestrator(): Promise { + if (!singletonReady) { + singletonReady = (async () => { + const orch = new Orchestrator(); + await orch.init(); + return orch; + })(); + } + return singletonReady; +} + export function App() { const orchRef = useRef(null); const [loading, setLoading] = useState(true); @@ -23,9 +37,8 @@ export function App() { async function boot() { try { - const orch = new Orchestrator(); + const orch = await getOrCreateOrchestrator(); orchRef.current = orch; - await orch.init(); await initOrchestratorStore(orch); if (!cancelled) setLoading(false); } catch (err) { diff --git a/src/channels/imessage.ts b/src/channels/imessage.ts new file mode 100644 index 0000000..00c1a23 --- /dev/null +++ b/src/channels/imessage.ts @@ -0,0 +1,276 @@ +// --------------------------------------------------------------------------- +// OpenBrowserClaw — iMessage Channel +// --------------------------------------------------------------------------- +// +// Connects to a remote iMessage server via Socket.IO + REST. +// Browser-safe — uses socket.io-client and fetch() directly. +// +// groupId prefix: "im:" +// Examples: +// DM: im:iMessage;-;+918527438574 +// Group: im:iMessage;+;chat143843922472236064 +// --------------------------------------------------------------------------- + +import { io, type Socket } from 'socket.io-client'; +import type { Channel, InboundMessage } from '../types.js'; + +type MessageCallback = (msg: InboundMessage) => void; + +// --------------------------------------------------------------------------- +// Remote-mode types +// --------------------------------------------------------------------------- + +interface RemoteMessage { + guid: string; + text: string | null; + handle?: { address: string }; + chats?: Array<{ guid: string }>; + isFromMe: boolean; + dateCreated: number; + attachments?: Array<{ guid: string; transferName: string; mimeType: string; totalBytes: number }>; + associatedMessageGuid?: string; + associatedMessageType?: string | number | null; +} + +// --------------------------------------------------------------------------- +// IMessageChannel +// --------------------------------------------------------------------------- + +export interface IMessageConfig { + serverUrl: string; + apiKey: string; +} + +export class IMessageChannel implements Channel { + readonly type = 'imessage' as const; + + private enabled = false; + private serverUrl = ''; + private apiKey = ''; + + private socket: Socket | null = null; + private processedGuids = new Map(); + private guidCleanupTimer: ReturnType | null = null; + private static readonly GUID_TTL_MS = 5 * 60 * 1000; + + private messageCallback: MessageCallback | null = null; + private running = false; + private typingTimer: ReturnType | null = null; + + // ----------------------------------------------------------------------- + // Configuration + // ----------------------------------------------------------------------- + + configure(config: IMessageConfig): void { + this.serverUrl = config.serverUrl.replace(/\/+$/, ''); + this.apiKey = config.apiKey; + this.enabled = !!(this.serverUrl && this.apiKey); + } + + disable(): void { + this.stop(); + this.enabled = false; + this.serverUrl = ''; + this.apiKey = ''; + } + + isEnabled(): boolean { + return this.enabled; + } + + // ----------------------------------------------------------------------- + // Channel interface + // ----------------------------------------------------------------------- + + start(): void { + if (!this.enabled || this.running) return; + this.running = true; + this._startRemote().catch((err) => { + console.error('[iMessage] start error:', err); + this.running = false; + }); + } + + stop(): void { + this.running = false; + if (this.typingTimer) { + clearTimeout(this.typingTimer); + this.typingTimer = null; + } + if (this.guidCleanupTimer) { + clearInterval(this.guidCleanupTimer); + this.guidCleanupTimer = null; + } + this.processedGuids.clear(); + if (this.socket) { + this.socket.removeAllListeners(); + this.socket.disconnect(); + this.socket = null; + } + } + + async send(groupId: string, text: string): Promise { + if (!this.enabled) return; + const chatGuid = this._chatGuid(groupId); + await this._post('/api/v1/message/text', { chatGuid, message: text }); + } + + setTyping(groupId: string, typing: boolean): void { + if (!this.enabled) return; + const chatGuid = this._chatGuid(groupId); + const encoded = encodeURIComponent(chatGuid); + if (typing) { + if (this.typingTimer) clearTimeout(this.typingTimer); + this._post(`/api/v1/chat/${encoded}/typing`, {}).catch(() => {}); + this.typingTimer = setTimeout(() => { + this.typingTimer = null; + this._delete(`/api/v1/chat/${encoded}/typing`).catch(() => {}); + }, 3000); + } else { + if (this.typingTimer) { + clearTimeout(this.typingTimer); + this.typingTimer = null; + } + this._delete(`/api/v1/chat/${encoded}/typing`).catch(() => {}); + } + } + + onMessage(callback: MessageCallback): void { + this.messageCallback = callback; + } + + // ----------------------------------------------------------------------- + // Private — socket connection + // ----------------------------------------------------------------------- + + private async _startRemote(): Promise { + if (!this.serverUrl) return; + + if (this.socket) { + this.socket.removeAllListeners(); + this.socket.disconnect(); + this.socket = null; + } + + const socket = io(this.serverUrl, { + auth: this.apiKey ? { apiKey: this.apiKey } : undefined, + transports: ['websocket'], + timeout: 10_000, + reconnection: true, + reconnectionDelay: 1_000, + reconnectionDelayMax: 30_000, + forceNew: true, + autoConnect: false, + }); + this.socket = socket; + + socket.on('connect_error', (err) => { + console.error('[iMessage] connect_error:', err.message); + }); + + socket.on('auth-error', (err: { message: string; reason?: string }) => { + console.error('[iMessage] auth-error:', err.message, err.reason ?? ''); + this.stop(); + }); + + socket.on('new-message', (msg: RemoteMessage) => { + if (msg.guid && this.processedGuids.has(msg.guid)) return; + if (msg.guid) this.processedGuids.set(msg.guid, Date.now()); + + if (!this.messageCallback) return; + if (msg.isFromMe) return; + if (msg.associatedMessageGuid) return; + if (!msg.text?.trim()) return; + + const chatGuid = msg.chats?.[0]?.guid; + if (!chatGuid) return; + + this.messageCallback({ + id: msg.guid, + groupId: `im:${chatGuid}`, + sender: msg.handle?.address ?? 'unknown', + content: msg.text, + timestamp: msg.dateCreated, + channel: 'imessage', + }); + }); + + socket.on('disconnect', (reason) => { + if (!this.running) return; + if (reason === 'io server disconnect') { + socket.connect(); + } + }); + + if (!this.guidCleanupTimer) { + this.guidCleanupTimer = setInterval(() => { + const cutoff = Date.now() - IMessageChannel.GUID_TTL_MS; + for (const [guid, ts] of this.processedGuids) { + if (ts < cutoff) this.processedGuids.delete(guid); + } + }, 60_000); + } + + socket.connect(); + } + + // ----------------------------------------------------------------------- + // Private — REST helpers + // ----------------------------------------------------------------------- + + private static readonly REQUEST_TIMEOUT_MS = 15_000; + + private async _request(path: string, init: RequestInit): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout( + () => controller.abort(), + IMessageChannel.REQUEST_TIMEOUT_MS, + ); + try { + return await fetch(`${this.serverUrl}${path}`, { + ...init, + signal: controller.signal, + }); + } catch (err) { + if (err instanceof DOMException && err.name === 'AbortError') { + throw new Error(`[iMessage] ${init.method ?? 'GET'} ${path} timed out after ${IMessageChannel.REQUEST_TIMEOUT_MS}ms`); + } + throw err; + } finally { + clearTimeout(timeoutId); + } + } + + private async _post(path: string, body: unknown): Promise { + const res = await this._request(path, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(this.apiKey ? { 'X-API-Key': this.apiKey } : {}), + }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`[iMessage] POST ${path} failed ${res.status}: ${text}`); + } + const json = await res.json(); + return json.data ?? json; + } + + private async _delete(path: string): Promise { + await this._request(path, { + method: 'DELETE', + headers: this.apiKey ? { 'X-API-Key': this.apiKey } : {}, + }); + } + + // ----------------------------------------------------------------------- + // Private — helpers + // ----------------------------------------------------------------------- + + private _chatGuid(groupId: string): string { + const raw = groupId.startsWith('im:') ? groupId.slice(3) : groupId; + return raw.replace(/^iMessage;/, 'any;'); + } +} diff --git a/src/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx index ee5bce0..511b6cb 100644 --- a/src/components/settings/SettingsPage.tsx +++ b/src/components/settings/SettingsPage.tsx @@ -5,7 +5,7 @@ import { useEffect, useState } from 'react'; import { Palette, KeyRound, Eye, EyeOff, Bot, MessageSquare, - Smartphone, HardDrive, Lock, Check, + Smartphone, HardDrive, Lock, Check, MessageCircle, } from 'lucide-react'; import { getConfig, setConfig } from '../../db.js'; import { CONFIG_KEYS } from '../../config.js'; @@ -47,6 +47,14 @@ export function SettingsPage() { const [telegramChatIds, setTelegramChatIds] = useState(''); const [telegramSaved, setTelegramSaved] = useState(false); + // iMessage + const [imessageEnabled, setImessageEnabled] = useState(false); + const [imessageServerUrl, setImessageServerUrl] = useState(''); + const [imessageApiKey, setImessageApiKey] = useState(''); + const [imessageApiKeyMasked, setImessageApiKeyMasked] = useState(true); + const [imessageSaved, setImessageSaved] = useState(false); + const [imessageDisabled, setImessageDisabled] = useState(false); + // Storage const [storageUsage, setStorageUsage] = useState(0); const [storageQuota, setStorageQuota] = useState(0); @@ -81,6 +89,13 @@ export function SettingsPage() { } } + // iMessage + const storedServerUrl = await getConfig(CONFIG_KEYS.IMESSAGE_SERVER_URL); + if (storedServerUrl) setImessageServerUrl(storedServerUrl); + const storedImApiKey = await getConfig(CONFIG_KEYS.IMESSAGE_API_KEY); + if (storedImApiKey) setImessageApiKey(storedImApiKey); + if (storedServerUrl && storedImApiKey) setImessageEnabled(true); + // Storage const est = await getStorageEstimate(); setStorageUsage(est.usage); @@ -117,12 +132,30 @@ export function SettingsPage() { setTimeout(() => setTelegramSaved(false), 2000); } + async function handleIMessageSave() { + if (!imessageServerUrl.trim() || !imessageApiKey.trim()) return; + await orch.configureIMessage(imessageServerUrl.trim(), imessageApiKey.trim()); + setImessageEnabled(true); + setImessageSaved(true); + setTimeout(() => setImessageSaved(false), 2000); + } + + async function handleIMessageDisable() { + await orch.disableIMessage(); + setImessageEnabled(false); + setImessageServerUrl(''); + setImessageApiKey(''); + setImessageDisabled(true); + setTimeout(() => setImessageDisabled(false), 2000); + } + async function handleRequestPersistent() { const granted = await requestPersistentStorage(); setIsPersistent(granted); } const storagePercent = storageQuota > 0 ? (storageUsage / storageQuota) * 100 : 0; + const imessageSaveDisabled = !imessageServerUrl.trim() || !imessageApiKey.trim(); return (
@@ -262,6 +295,85 @@ export function SettingsPage() {
+ {/* ---- iMessage ---- */} +
+
+

+ iMessage +

+ +
+ Mode +
Remote
+

+ Connects to a remote iMessage server via Socket.IO. +

+
+ +
+ Server URL + setImessageServerUrl(e.target.value)} + /> +
+
+ API Key +
+ setImessageApiKey(e.target.value)} + /> + +
+
+ +
+ + {imessageEnabled && ( + + )} + {imessageSaved && ( + + Saved + + )} + {imessageDisabled && ( + + Disabled + + )} +
+ +

+ iMessage conversations appear as separate chat groups. + Every incoming message triggers a response automatically — no @mention needed. +

+
+
+ {/* ---- Storage ---- */}
diff --git a/src/config.ts b/src/config.ts index 9d79920..b5b1a67 100644 --- a/src/config.ts +++ b/src/config.ts @@ -69,4 +69,7 @@ export const CONFIG_KEYS = { PASSPHRASE_SALT: 'passphrase_salt', PASSPHRASE_VERIFY: 'passphrase_verify', ASSISTANT_NAME: 'assistant_name', + // iMessage (remote) + IMESSAGE_SERVER_URL: 'imessage_server_url', + IMESSAGE_API_KEY: 'imessage_api_key', } as const; diff --git a/src/orchestrator.ts b/src/orchestrator.ts index 0ee06a0..cf82bad 100644 --- a/src/orchestrator.ts +++ b/src/orchestrator.ts @@ -43,6 +43,7 @@ import { readGroupFile } from './storage.js'; import { encryptValue, decryptValue } from './crypto.js'; import { BrowserChatChannel } from './channels/browser-chat.js'; import { TelegramChannel } from './channels/telegram.js'; +import { IMessageChannel } from './channels/imessage.js'; import { Router } from './router.js'; import { TaskScheduler } from './task-scheduler.js'; import { ulid } from './ulid.js'; @@ -93,6 +94,7 @@ export class Orchestrator { readonly events = new EventBus(); readonly browserChat = new BrowserChatChannel(); readonly telegram = new TelegramChannel(); + readonly imessage = new IMessageChannel(); private router!: Router; private scheduler!: TaskScheduler; @@ -106,6 +108,8 @@ export class Orchestrator { private messageQueue: InboundMessage[] = []; private processing = false; private pendingScheduledTasks = new Set(); + private destroyed = false; + private recentInboundIds = new Map(); /** * Initialize the orchestrator. Must be called before anything else. @@ -133,8 +137,8 @@ export class Orchestrator { 10, ); - // Set up router - this.router = new Router(this.browserChat, this.telegram); + // Set up router (iMessage channel wired in below after config load) + this.router = new Router(this.browserChat, this.telegram, this.imessage); // Set up channels this.browserChat.onMessage((msg) => this.enqueue(msg)); @@ -149,6 +153,17 @@ export class Orchestrator { this.telegram.start(); } + // Configure iMessage if server URL + API key exist + if (this.destroyed) return; + const imessageServerUrl = await getConfig(CONFIG_KEYS.IMESSAGE_SERVER_URL); + const imessageApiKey = await getConfig(CONFIG_KEYS.IMESSAGE_API_KEY); + if (this.destroyed) return; + if (imessageServerUrl && imessageApiKey) { + this.imessage.configure({ serverUrl: imessageServerUrl, apiKey: imessageApiKey }); + this.imessage.onMessage((msg) => this.enqueue(msg)); + this.imessage.start(); + } + // Set up agent worker this.agentWorker = new Worker( new URL('./agent-worker.ts', import.meta.url), @@ -168,7 +183,7 @@ export class Orchestrator { this.scheduler.start(); // Wire up browser chat display callback - this.browserChat.onDisplay((groupId, text, isFromMe) => { + this.browserChat.onDisplay((_groupId, _text, _isFromMe) => { // Display handled via events.emit('message', ...) }); @@ -240,6 +255,28 @@ export class Orchestrator { this.telegram.start(); } + /** + * Configure iMessage (remote mode). + */ + async configureIMessage(serverUrl: string, apiKey: string): Promise { + await setConfig(CONFIG_KEYS.IMESSAGE_SERVER_URL, serverUrl); + await setConfig(CONFIG_KEYS.IMESSAGE_API_KEY, apiKey); + + this.imessage.stop(); + this.imessage.configure({ serverUrl, apiKey }); + this.imessage.onMessage((msg) => this.enqueue(msg)); + this.imessage.start(); + } + + /** + * Disable iMessage integration. + */ + async disableIMessage(): Promise { + this.imessage.disable(); + await setConfig(CONFIG_KEYS.IMESSAGE_SERVER_URL, ''); + await setConfig(CONFIG_KEYS.IMESSAGE_API_KEY, ''); + } + /** * Submit a message from the browser chat UI. */ @@ -308,9 +345,12 @@ export class Orchestrator { * Shut down everything. */ shutdown(): void { - this.scheduler.stop(); + this.destroyed = true; + this.scheduler?.stop(); this.telegram.stop(); - this.agentWorker.terminate(); + this.imessage.stop(); + this.agentWorker?.terminate(); + this.messageQueue.length = 0; } // ----------------------------------------------------------------------- @@ -323,19 +363,35 @@ export class Orchestrator { } private async enqueue(msg: InboundMessage): Promise { - // Save to DB + if (this.destroyed) return; + + // Dedup: drop messages with the same id seen within the last 60 s + if (msg.id) { + const now = Date.now(); + const seenAt = this.recentInboundIds.get(msg.id); + if (seenAt && now - seenAt < 60_000) return; + + this.recentInboundIds.set(msg.id, now); + if (this.recentInboundIds.size > 500) { + const cutoff = now - 60_000; + for (const [id, ts] of this.recentInboundIds) { + if (ts < cutoff) this.recentInboundIds.delete(id); + } + } + } + const stored: StoredMessage = { ...msg, isFromMe: false, isTrigger: false, }; - // Check trigger const isBrowserMain = msg.groupId === DEFAULT_GROUP_ID; + const isImessage = msg.groupId.startsWith('im:'); const hasTrigger = this.triggerPattern.test(msg.content.trim()); - // Browser main group always triggers; other groups need the trigger pattern - if (isBrowserMain || hasTrigger) { + // iMessage and browser chat always trigger; other channels need @mention + if (isBrowserMain || isImessage || hasTrigger) { stored.isTrigger = true; this.messageQueue.push(msg); } @@ -343,7 +399,6 @@ export class Orchestrator { await saveMessage(stored); this.events.emit('message', stored); - // Process queue this.processQueue(); } @@ -391,7 +446,11 @@ export class Orchestrator { sender: 'Scheduler', content: triggerContent, timestamp: Date.now(), - channel: groupId.startsWith('tg:') ? 'telegram' : 'browser', + channel: groupId.startsWith('tg:') + ? 'telegram' + : groupId.startsWith('im:') + ? 'imessage' + : 'browser', isFromMe: false, isTrigger: true, }; @@ -490,7 +549,11 @@ export class Orchestrator { sender: this.assistantName, content: `📝 **Context Compacted**\n\n${summary}`, timestamp: Date.now(), - channel: groupId.startsWith('tg:') ? 'telegram' : 'browser', + channel: groupId.startsWith('tg:') + ? 'telegram' + : groupId.startsWith('im:') + ? 'imessage' + : 'browser', isFromMe: true, isTrigger: false, }; @@ -502,29 +565,33 @@ export class Orchestrator { } private async deliverResponse(groupId: string, text: string): Promise { - // Save to DB const stored: StoredMessage = { id: ulid(), groupId, sender: this.assistantName, content: text, timestamp: Date.now(), - channel: groupId.startsWith('tg:') ? 'telegram' : 'browser', + channel: groupId.startsWith('tg:') + ? 'telegram' + : groupId.startsWith('im:') + ? 'imessage' + : 'browser', isFromMe: true, isTrigger: false, }; await saveMessage(stored); - // Route to channel - await this.router.send(groupId, text); + try { + await this.router.send(groupId, text); + } catch (err) { + console.error(`[${stored.channel}] Failed to deliver response:`, err); + } - // Play notification chime for scheduled task responses if (this.pendingScheduledTasks.has(groupId)) { this.pendingScheduledTasks.delete(groupId); playNotificationChime(); } - // Emit for UI this.events.emit('message', stored); this.events.emit('typing', { groupId, typing: false }); diff --git a/src/router.ts b/src/router.ts index b384bd9..45169a3 100644 --- a/src/router.ts +++ b/src/router.ts @@ -5,6 +5,7 @@ import type { Channel } from './types.js'; import { BrowserChatChannel } from './channels/browser-chat.js'; import { TelegramChannel } from './channels/telegram.js'; +import { IMessageChannel } from './channels/imessage.js'; /** * Routes outbound messages and typing indicators to the correct channel @@ -13,11 +14,13 @@ import { TelegramChannel } from './channels/telegram.js'; * Prefix mapping: * "br:" → BrowserChatChannel * "tg:" → TelegramChannel + * "im:" → IMessageChannel */ export class Router { constructor( private browserChat: BrowserChatChannel, private telegram: TelegramChannel | null, + private imessage: IMessageChannel | null, ) {} /** @@ -71,6 +74,9 @@ export class Router { if (groupId.startsWith('tg:')) { return this.telegram; } + if (groupId.startsWith('im:')) { + return this.imessage; + } return this.browserChat; } } diff --git a/src/stores/orchestrator-store.ts b/src/stores/orchestrator-store.ts index 70bf775..05764d5 100644 --- a/src/stores/orchestrator-store.ts +++ b/src/stores/orchestrator-store.ts @@ -34,6 +34,7 @@ interface OrchestratorStoreState { } let orchestratorInstance: Orchestrator | null = null; +let storeInitialized = false; export function getOrchestrator(): Orchestrator { if (!orchestratorInstance) throw new Error('Orchestrator not initialized'); @@ -80,6 +81,8 @@ export const useOrchestratorStore = create((set, get) => */ export async function initOrchestratorStore(orch: Orchestrator): Promise { orchestratorInstance = orch; + if (storeInitialized) return; + storeInitialized = true; const store = useOrchestratorStore; // Subscribe to events diff --git a/src/types.ts b/src/types.ts index b29d5d1..e7f1af2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -5,7 +5,7 @@ /** Inbound message from any channel */ export interface InboundMessage { id: string; - groupId: string; // "br:main", "tg:-100123456" + groupId: string; // "br:main", "tg:-100123456", "im:iMessage;-;+1234567890" sender: string; content: string; timestamp: number; // epoch ms @@ -54,7 +54,7 @@ export interface ConfigEntry { value: string; // JSON-encoded or raw string } -export type ChannelType = 'browser' | 'telegram'; +export type ChannelType = 'browser' | 'telegram' | 'imessage'; /** Channel interface — matches NanoClaw's Channel abstraction */ export interface Channel {