From 255c927deeee2d6faa86c061bfaa9884b537367d Mon Sep 17 00:00:00 2001 From: Kumar Vandit Date: Thu, 5 Mar 2026 09:54:55 +0530 Subject: [PATCH 1/9] feat: add Photon iMessage channel (local + remote mode) Adds a new iMessage integration via Photon SDKs, supporting two modes: - **Local mode**: reads the iMessage SQLite DB directly on macOS via @photon-ai/imessage-kit (no server needed) - **Remote mode**: connects to a Photon iMessage server via Socket.IO + REST, enabling edit, unsend, tapbacks, effects, typing indicators, polls, and group chat management Changes: - New `IMessageChannel` class with full Channel interface implementation - Orchestrator wired to configure, start, and route iMessage messages - Router extended with `im:` prefix for iMessage group routing - Settings UI for mode selection, server URL, and API key - Config keys for iMessage mode/server/API key in IndexedDB - `ChannelType` union extended with 'imessage' - Module-level Orchestrator singleton to prevent duplicate instances under React StrictMode - Store init guard to prevent duplicate event listener registration - PWA service worker disabled in dev mode to avoid stale cache issues - README updated with setup instructions for both modes --- README.md | 48 ++- package-lock.json | 117 +++++- package.json | 1 + src/App.tsx | 17 +- src/channels/imessage.ts | 488 +++++++++++++++++++++++ src/components/settings/SettingsPage.tsx | 159 +++++++- src/config.ts | 4 + src/orchestrator.ts | 114 +++++- src/router.ts | 6 + src/stores/orchestrator-store.ts | 3 + src/types.ts | 4 +- vite.config.ts | 2 +- 12 files changed, 930 insertions(+), 33 deletions(-) create mode 100644 src/channels/imessage.ts diff --git a/README.md b/README.md index 436d8da..0c69e06 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) │ +│ └── Photon iMessage (optional, local or remote mode) │ └──────────────────────────────────────────────────────────┘ ``` @@ -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` | Photon iMessage channel (local + 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,43 @@ 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. +## Photon iMessage + +Optional. Integrates with [Photon](https://photon.codes) iMessage SDKs to receive and reply to iMessages directly from OpenBrowserClaw. Two modes are available: + +### Local mode — `@photon-ai/imessage-kit` + +Reads the iMessage SQLite database directly on macOS. No server required. + +**Requirements:** +- macOS only +- Node.js ≥ 18 or Bun ≥ 1.0 +- Full Disk Access granted to your terminal or browser +- `@photon-ai/imessage-kit` installed as a project dependency + +**Setup:** +```bash +npm install @photon-ai/imessage-kit better-sqlite3 +``` +Then open Settings → Photon iMessage, select **Local**, and save. + +### Remote mode — Photon server + +Connects to a Photon iMessage server via Socket.IO + REST. Unlocks advanced features: edit, unsend, tapback reactions, message effects, typing indicators, group chat management, and polls. No extra dependencies needed — uses `socket.io-client` and `fetch` directly. + +**Requirements:** +- A running Photon iMessage server (macOS, any network-accessible host) +- Valid API key for the server + +**Setup:** +Open Settings → Photon iMessage, select **Remote**, enter your server URL and API key, and save. + +### How iMessage conversations work + +- Each iMessage chat appears as a separate group with the prefix `im:` followed by the chat GUID (e.g. `im:iMessage;-;+1234567890`). +- Trigger the assistant by mentioning `@Andy` (or your configured assistant name) in any iMessage. +- 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 +156,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 (Photon) | | 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 +195,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 (remote mode) is stored in plaintext in IndexedDB. 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..fad82ff --- /dev/null +++ b/src/channels/imessage.ts @@ -0,0 +1,488 @@ +// --------------------------------------------------------------------------- +// OpenBrowserClaw — Photon iMessage Channel +// --------------------------------------------------------------------------- +// +// Supports two modes: +// local — @photon-ai/imessage-kit (macOS only, direct SQLite DB access) +// remote — direct socket.io + REST (any OS, Photon server, browser-safe) +// +// groupId prefix: "im:" +// Examples: +// DM: im:iMessage;-;+918527438574 +// Group: im:iMessage;+;chat143843922472236064 +// +// Remote mode connects directly to the Photon server using socket.io-client +// and fetch(). No Node.js dependencies — works in the browser. +// --------------------------------------------------------------------------- + +import { io, type Socket } from 'socket.io-client'; +import type { Channel, InboundMessage } from '../types.js'; + +type MessageCallback = (msg: InboundMessage) => void; + +// --------------------------------------------------------------------------- +// Local-mode types (Node only — @photon-ai/imessage-kit) +// --------------------------------------------------------------------------- + +interface LocalSDK { + send(to: string, content: string | { text?: string; images?: string[]; files?: string[] }): Promise<{ message?: { guid?: string } }>; + getMessages(filter: { chatId?: string; search?: string; limit?: number; since?: Date; unreadOnly?: boolean }): Promise<{ messages: LocalMessage[] }>; + getUnreadMessages(): Promise<{ groups: Array<{ sender: string; messages: LocalMessage[] }>; total: number; senderCount: number }>; + listChats(opts?: { type?: 'all' | 'dm' | 'group'; hasUnread?: boolean; limit?: number; search?: string }): Promise; + startWatching(events: { onMessage: (msg: LocalMessage) => void; onError?: (err: Error) => void }): Promise; + stopWatching(): void; + close(): Promise; +} + +interface LocalMessage { + guid: string; + text: string | null; + sender: string; + senderName?: string; + chatId: string; + isGroupChat: boolean; + isFromMe: boolean; + date: Date; + attachments: Array<{ id: string; filename: string; mimeType: string; size: number }>; +} + +interface LocalChat { + chatId: string; + displayName: string; + isGroup: boolean; + unreadCount: number; +} + +// --------------------------------------------------------------------------- +// 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; +} + +interface RemoteChat { + guid: string; + displayName: string; + chatIdentifier: string; + style: number; + participants?: unknown[]; + isArchived?: boolean; +} + +// --------------------------------------------------------------------------- +// MESSAGE_EFFECTS — iMessage bubble/screen effect identifiers +// --------------------------------------------------------------------------- + +export const MESSAGE_EFFECTS = { + slam: 'com.apple.MobileSMS.expressivesend.impact', + loud: 'com.apple.MobileSMS.expressivesend.loud', + gentle: 'com.apple.MobileSMS.expressivesend.gentle', + invisibleInk: 'com.apple.MobileSMS.expressivesend.invisibleink', + confetti: 'com.apple.messages.effect.CKConfettiEffect', + balloons: 'com.apple.messages.effect.CKBalloonEffect', + fireworks: 'com.apple.messages.effect.CKFireworksEffect', + shooting: 'com.apple.messages.effect.CKShootingStarEffect', + lasers: 'com.apple.messages.effect.CKLasersEffect', + love: 'com.apple.messages.effect.CKHeartEffect', + celebration: 'com.apple.messages.effect.CKSpotlightEffect', + echo: 'com.apple.messages.effect.CKEchoEffect', +} as const; + +export type MessageEffect = typeof MESSAGE_EFFECTS[keyof typeof MESSAGE_EFFECTS]; + +// --------------------------------------------------------------------------- +// IMessageChannel +// --------------------------------------------------------------------------- + +export type IMessageMode = 'disabled' | 'local' | 'remote'; + +export interface IMessageConfig { + mode: IMessageMode; + serverUrl?: string; + apiKey?: string; +} + +export class IMessageChannel implements Channel { + readonly type = 'imessage' as const; + + private mode: IMessageMode = 'disabled'; + private serverUrl = ''; + private apiKey = ''; + + private localSdk: LocalSDK | null = null; + + // Remote mode — raw socket + server URL for REST calls + 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; + + // ----------------------------------------------------------------------- + // Configuration + // ----------------------------------------------------------------------- + + configure(config: IMessageConfig): void { + this.mode = config.mode; + this.serverUrl = (config.serverUrl ?? '').replace(/\/+$/, ''); + this.apiKey = config.apiKey ?? ''; + } + + // ----------------------------------------------------------------------- + // Channel interface + // ----------------------------------------------------------------------- + + start(): void { + if (this.mode === 'disabled') { + console.warn('[iMessage] start() called but mode is disabled'); + return; + } + if (this.running) { + console.log('[iMessage] already running — skipping duplicate start()'); + return; + } + this.running = true; + + console.log(`[iMessage] starting in ${this.mode} mode`); + + if (this.mode === 'local') { + this._startLocal().catch((err) => { + console.error('[iMessage] local start error:', err); + }); + } else { + this._startRemote().catch((err) => { + console.error('[iMessage] remote start error:', err); + }); + } + } + + stop(): void { + console.log('[iMessage] stop() called, running was:', this.running); + this.running = false; + if (this.guidCleanupTimer) { + clearInterval(this.guidCleanupTimer); + this.guidCleanupTimer = null; + } + this.processedGuids.clear(); + if (this.mode === 'local' && this.localSdk) { + this.localSdk.stopWatching(); + this.localSdk.close().catch(() => {}); + this.localSdk = null; + } + if (this.socket) { + this.socket.removeAllListeners(); + this.socket.disconnect(); + this.socket = null; + } + } + + async send(groupId: string, text: string): Promise { + if (this.mode === 'local') { + const sdk = this._requireLocal('send'); + await sdk.send(this._localTarget(groupId), text); + } else if (this.mode === 'remote') { + const chatGuid = this._chatGuid(groupId); + await this._post('/api/v1/message/text', { chatGuid, message: text }); + } + } + + setTyping(groupId: string, typing: boolean): void { + if (this.mode !== 'remote') return; + const chatGuid = this._chatGuid(groupId); + const encoded = encodeURIComponent(chatGuid); + if (typing) { + this._post(`/api/v1/chat/${encoded}/typing`, {}).catch(() => {}); + setTimeout(() => { + this._delete(`/api/v1/chat/${encoded}/typing`).catch(() => {}); + }, 3000); + } else { + this._delete(`/api/v1/chat/${encoded}/typing`).catch(() => {}); + } + } + + onMessage(callback: MessageCallback): void { + this.messageCallback = callback; + } + + // ----------------------------------------------------------------------- + // Extended local-mode methods + // ----------------------------------------------------------------------- + + async getMessagesLocal(groupId: string, opts?: { limit?: number; since?: Date }): Promise { + const sdk = this._requireLocal('getMessagesLocal'); + const result = await sdk.getMessages({ chatId: this._chatGuid(groupId), ...opts }); + return result.messages; + } + + async searchMessagesLocal(keyword: string, opts?: { limit?: number }): Promise { + const sdk = this._requireLocal('searchMessagesLocal'); + const result = await sdk.getMessages({ search: keyword, limit: opts?.limit ?? 20 }); + return result.messages; + } + + async getUnreadMessagesLocal(): Promise<{ groups: Array<{ sender: string; messages: LocalMessage[] }>; total: number; senderCount: number }> { + return this._requireLocal('getUnreadMessagesLocal').getUnreadMessages(); + } + + async listChatsLocal(opts?: { type?: 'all' | 'dm' | 'group'; hasUnread?: boolean; limit?: number; search?: string }): Promise { + return this._requireLocal('listChatsLocal').listChats(opts); + } + + // ----------------------------------------------------------------------- + // Extended remote-mode methods (REST) + // ----------------------------------------------------------------------- + + async editMessage(messageGuid: string, editedMessage: string): Promise { + await this._post(`/api/v1/message/${encodeURIComponent(messageGuid)}/edit`, { + editedMessage, + backwardsCompatibilityMessage: editedMessage, + partIndex: 0, + }); + } + + async addReaction(groupId: string, messageGuid: string, reaction: string): Promise { + await this._post('/api/v1/message/react', { + chatGuid: this._chatGuid(groupId), + selectedMessageGuid: messageGuid, + reaction, + partIndex: 0, + }); + } + + async removeReaction(groupId: string, messageGuid: string, reaction: string): Promise { + await this._post('/api/v1/message/react', { + chatGuid: this._chatGuid(groupId), + selectedMessageGuid: messageGuid, + reaction: `-${reaction}`, + partIndex: 0, + }); + } + + async getMessagesRemote(groupId: string, opts?: { limit?: number; sort?: 'ASC' | 'DESC'; before?: number; after?: number }): Promise { + const res = await this._post('/api/v1/message/query', { + chatGuid: this._chatGuid(groupId), + with: ['chat', 'handle', 'attachment'], + ...opts, + }); + return res as RemoteMessage[]; + } + + async getChatInfo(groupId: string): Promise { + const chatGuid = this._chatGuid(groupId); + const res = await this._get(`/api/v1/chat/${encodeURIComponent(chatGuid)}`); + return res as RemoteChat; + } + + async createPoll(groupId: string, title: string, options: string[]): Promise<{ guid: string }> { + const res = await this._post('/api/v1/poll', { + chatGuid: this._chatGuid(groupId), + title, + options, + }); + return res as { guid: string }; + } + + // ----------------------------------------------------------------------- + // Private — start helpers + // ----------------------------------------------------------------------- + + private async _startLocal(): Promise { + const localPkg = '@photon-ai/imessage-kit'; + const { IMessageSDK } = await import(/* @vite-ignore */ localPkg); + const sdk = new IMessageSDK() as unknown as LocalSDK; + this.localSdk = sdk; + + await sdk.startWatching({ + onMessage: (msg: LocalMessage) => { + if (msg.isFromMe) return; + if (!this.messageCallback) return; + this.messageCallback({ + id: msg.guid, + groupId: `im:${msg.chatId}`, + sender: msg.sender, + content: msg.text ?? '', + timestamp: msg.date.getTime(), + channel: 'imessage', + }); + }, + onError: (err: Error) => { + console.error('[iMessage] watcher error:', err); + }, + }); + } + + private async _startRemote(): Promise { + if (!this.serverUrl) { + console.error('[iMessage] no serverUrl configured'); + return; + } + console.log('[iMessage] connecting to', this.serverUrl, 'hasKey:', !!this.apiKey); + + // Clean up any existing socket before creating a new one + 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, + forceNew: true, + autoConnect: false, + }); + this.socket = socket; + + // Wire up ALL listeners BEFORE calling connect(), so we never miss events. + + socket.on('connect', () => { + console.log('[iMessage] socket connected (id:', socket.id + '), waiting for server ready...'); + }); + + // Different server versions emit different ready events + const onReady = () => { + console.log('[iMessage] server ready — listening for new-message events'); + }; + socket.on('hello-world', onReady); + socket.on('auth-ok', onReady); + + 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 ?? ''); + }); + + 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()); + + console.log('[iMessage] new-message:', { + guid: msg.guid, + text: msg.text?.slice(0, 50), + isFromMe: msg.isFromMe, + sender: msg.handle?.address, + chatGuid: msg.chats?.[0]?.guid, + }); + + if (!this.messageCallback) return; + if (msg.isFromMe) return; + if (msg.associatedMessageGuid) return; + + const chatGuid = msg.chats?.[0]?.guid ?? ''; + 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) => { + console.warn('[iMessage] disconnected:', reason); + if (!this.running) return; + if (reason === 'io server disconnect') { + console.log('[iMessage] server kicked us — reconnecting...'); + socket.connect(); + } + }); + + // Log any unhandled events for debugging + socket.onAny((event, ...args) => { + if (['connect', 'hello-world', 'auth-ok', 'connect_error', 'auth-error', 'new-message', 'disconnect'].includes(event)) return; + console.log('[iMessage] event:', event, JSON.stringify(args).slice(0, 200)); + }); + + // Periodically prune stale guids to prevent unbounded memory growth + 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); + } + + // NOW connect — all listeners are already in place + socket.connect(); + } + + // ----------------------------------------------------------------------- + // Private — REST helpers + // ----------------------------------------------------------------------- + + private async _post(path: string, body: unknown): Promise { + const res = await fetch(`${this.serverUrl}${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 _get(path: string): Promise { + const res = await fetch(`${this.serverUrl}${path}`, { + headers: this.apiKey ? { 'X-API-Key': this.apiKey } : {}, + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`[iMessage] GET ${path} failed ${res.status}: ${text}`); + } + const json = await res.json(); + return json.data ?? json; + } + + private async _delete(path: string): Promise { + await fetch(`${this.serverUrl}${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;'); + } + + private _localTarget(groupId: string): string { + const chatGuid = this._chatGuid(groupId); + if (chatGuid.includes(';+;chat') || (chatGuid.startsWith('chat') && !chatGuid.includes(';'))) { + return chatGuid; + } + return chatGuid.split(';').pop() ?? chatGuid; + } + + private _requireLocal(method: string): LocalSDK { + if (!this.localSdk) { + throw new Error(`[iMessage] ${method}: local SDK not initialised. Call start() first and ensure mode is 'local'.`); + } + return this.localSdk; + } +} diff --git a/src/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx index ee5bce0..eae3c5a 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'; @@ -13,6 +13,7 @@ import { getStorageEstimate, requestPersistentStorage } from '../../storage.js'; import { decryptValue } from '../../crypto.js'; import { getOrchestrator } from '../../stores/orchestrator-store.js'; import { useThemeStore, type ThemeChoice } from '../../stores/theme-store.js'; +import type { IMessageMode } from '../../channels/imessage.js'; const MODELS = [ { value: 'claude-opus-4-6', label: 'Claude Opus 4.6' }, @@ -47,6 +48,14 @@ export function SettingsPage() { const [telegramChatIds, setTelegramChatIds] = useState(''); const [telegramSaved, setTelegramSaved] = useState(false); + // Photon iMessage + const [imessageMode, setImessageMode] = useState(''); + 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 +90,14 @@ export function SettingsPage() { } } + // Photon iMessage + const storedMode = (await getConfig(CONFIG_KEYS.IMESSAGE_MODE)) as IMessageMode | ''; + if (storedMode) setImessageMode(storedMode); + 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); + // Storage const est = await getStorageEstimate(); setStorageUsage(est.usage); @@ -117,12 +134,37 @@ export function SettingsPage() { setTimeout(() => setTelegramSaved(false), 2000); } + async function handleIMessageSave() { + if (!imessageMode) return; + await orch.configureIMessage( + imessageMode, + imessageMode === 'remote' ? imessageServerUrl.trim() : undefined, + imessageMode === 'remote' ? imessageApiKey.trim() : undefined, + ); + setImessageSaved(true); + setTimeout(() => setImessageSaved(false), 2000); + } + + async function handleIMessageDisable() { + await orch.disableIMessage(); + setImessageMode(''); + 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 imessageRemoteValid = + imessageMode === 'remote' + ? imessageServerUrl.trim().length > 0 && imessageApiKey.trim().length > 0 + : true; + const imessageSaveDisabled = !imessageMode || !imessageRemoteValid; return (
@@ -262,6 +304,121 @@ export function SettingsPage() {
+ {/* ---- Photon iMessage ---- */} +
+
+

+ Photon iMessage +

+ + {/* Mode selector */} +
+ Mode + +

+ Local mode reads the iMessage database directly on macOS (no server needed). + Remote mode connects to a Photon server and unlocks advanced features such as + edit, unsend, tapbacks, effects, and typing indicators. +

+
+ + {/* Remote-only fields */} + {imessageMode === 'remote' && ( + <> +
+ Server URL + setImessageServerUrl(e.target.value)} + /> +

+ URL of your Photon iMessage server +

+
+
+ API Key +
+ setImessageApiKey(e.target.value)} + /> + +
+
+ + )} + + {/* Local mode info */} + {imessageMode === 'local' && ( +
+ + Local mode requires macOS with Full Disk Access granted to your browser + or the application running OpenBrowserClaw. + Install @photon-ai/imessage-kit as a + dependency before enabling. + +
+ )} + +
+ + {imessageMode && ( + + )} + {imessageSaved && ( + + Saved + + )} + {imessageDisabled && ( + + Disabled + + )} +
+ +

+ Incoming iMessage conversations will appear as separate chat groups with the + prefix im:. + Trigger the assistant by mentioning @{assistantName} in any iMessage. +

+
+
+ {/* ---- Storage ---- */}
diff --git a/src/config.ts b/src/config.ts index 9d79920..ca5b29b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -69,4 +69,8 @@ export const CONFIG_KEYS = { PASSPHRASE_SALT: 'passphrase_salt', PASSPHRASE_VERIFY: 'passphrase_verify', ASSISTANT_NAME: 'assistant_name', + // Photon iMessage + IMESSAGE_MODE: 'imessage_mode', // 'local' | 'remote' + IMESSAGE_SERVER_URL: 'imessage_server_url', // remote mode only + IMESSAGE_API_KEY: 'imessage_api_key', // remote mode only } as const; diff --git a/src/orchestrator.ts b/src/orchestrator.ts index 0ee06a0..1a4e5dc 100644 --- a/src/orchestrator.ts +++ b/src/orchestrator.ts @@ -43,6 +43,8 @@ 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 type { IMessageMode } from './channels/imessage.js'; import { Router } from './router.js'; import { TaskScheduler } from './task-scheduler.js'; import { ulid } from './ulid.js'; @@ -93,6 +95,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 +109,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 +138,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 +154,22 @@ export class Orchestrator { this.telegram.start(); } + // Configure Photon iMessage if mode is set + if (this.destroyed) return; + const imessageMode = (await getConfig(CONFIG_KEYS.IMESSAGE_MODE)) as IMessageMode | null; + console.log('[orchestrator] iMessage config from DB:', { imessageMode }); + if (this.destroyed) return; + if (imessageMode && imessageMode !== 'disabled') { + const serverUrl = (await getConfig(CONFIG_KEYS.IMESSAGE_SERVER_URL)) || undefined; + const imessageApiKey = (await getConfig(CONFIG_KEYS.IMESSAGE_API_KEY)) || undefined; + console.log('[orchestrator] configuring iMessage:', { mode: imessageMode, serverUrl, hasApiKey: !!imessageApiKey }); + this.imessage.configure({ mode: imessageMode, serverUrl, apiKey: imessageApiKey }); + this.imessage.onMessage((msg) => this.enqueue(msg)); + this.imessage.start(); + } else { + console.log('[orchestrator] iMessage not configured — skipping'); + } + // Set up agent worker this.agentWorker = new Worker( new URL('./agent-worker.ts', import.meta.url), @@ -168,7 +189,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 +261,38 @@ export class Orchestrator { this.telegram.start(); } + /** + * Configure Photon iMessage (local or remote mode). + * + * Local mode — uses @photon-ai/imessage-kit directly (macOS only). + * Remote mode — connects to a Photon server via socket.io + REST. + */ + async configureIMessage( + mode: IMessageMode, + serverUrl?: string, + apiKey?: string, + ): Promise { + console.log('[orchestrator] configureIMessage called:', { mode, serverUrl, hasApiKey: !!apiKey }); + await setConfig(CONFIG_KEYS.IMESSAGE_MODE, mode); + if (serverUrl !== undefined) await setConfig(CONFIG_KEYS.IMESSAGE_SERVER_URL, serverUrl); + if (apiKey !== undefined) await setConfig(CONFIG_KEYS.IMESSAGE_API_KEY, apiKey); + + this.imessage.stop(); + this.imessage.configure({ mode, serverUrl, apiKey }); + this.imessage.onMessage((msg) => this.enqueue(msg)); + this.imessage.start(); + } + + /** + * Disable iMessage integration. + */ + async disableIMessage(): Promise { + this.imessage.stop(); + await setConfig(CONFIG_KEYS.IMESSAGE_MODE, ''); + await setConfig(CONFIG_KEYS.IMESSAGE_SERVER_URL, ''); + await setConfig(CONFIG_KEYS.IMESSAGE_API_KEY, ''); + } + /** * Submit a message from the browser chat UI. */ @@ -308,9 +361,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,27 +379,49 @@ 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 && this.recentInboundIds.has(msg.id)) return; + if (msg.id) { + this.recentInboundIds.set(msg.id, Date.now()); + if (this.recentInboundIds.size > 500) { + const cutoff = Date.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 hasTrigger = this.triggerPattern.test(msg.content.trim()); - // Browser main group always triggers; other groups need the trigger pattern + console.log('[orchestrator] enqueue:', { + groupId: msg.groupId, + content: msg.content.slice(0, 50), + isBrowserMain, + hasTrigger, + triggerPattern: this.triggerPattern.source, + assistantName: this.assistantName, + }); + if (isBrowserMain || hasTrigger) { stored.isTrigger = true; this.messageQueue.push(msg); + console.log('[orchestrator] message queued for agent'); + } else { + console.log('[orchestrator] message saved but NOT triggered — needs @' + this.assistantName); } await saveMessage(stored); this.events.emit('message', stored); - // Process queue this.processQueue(); } @@ -391,7 +469,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 +572,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, }; @@ -509,7 +595,11 @@ export class Orchestrator { 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, }; diff --git a/src/router.ts b/src/router.ts index b384bd9..387c908 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 (Photon iMessage — local or remote mode) */ export class Router { constructor( private browserChat: BrowserChatChannel, private telegram: TelegramChannel | null, + private imessage: IMessageChannel | null = 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 { diff --git a/vite.config.ts b/vite.config.ts index caab289..90122ad 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -10,7 +10,7 @@ export default defineConfig({ VitePWA({ registerType: 'autoUpdate', devOptions: { - enabled: true, + enabled: false, }, includeAssets: ['favicon.svg', 'apple-touch-icon.png'], manifest: { From 895454810870866839ed151026af8a351ba3c910 Mon Sep 17 00:00:00 2001 From: Kumar Vandit Date: Thu, 5 Mar 2026 12:14:10 +0530 Subject: [PATCH 2/9] refactor(imessage): remove local mode, remote only via Photon managed server - Strip all local-mode code (LocalSDK, startLocal, local types/methods) - Simplify IMessageConfig to just serverUrl + apiKey - Remove IMESSAGE_MODE config key (no mode selector needed) - Rename settings section from "Photon iMessage" to "iMessage" - Mode label shows "Remote (Photon managed)" as static text - Settings UI simplified: just server URL + API key fields --- src/channels/imessage.ts | 184 ++++------------------- src/components/settings/SettingsPage.tsx | 132 ++++++---------- src/config.ts | 7 +- src/orchestrator.ts | 38 ++--- 4 files changed, 91 insertions(+), 270 deletions(-) diff --git a/src/channels/imessage.ts b/src/channels/imessage.ts index fad82ff..e0de2ee 100644 --- a/src/channels/imessage.ts +++ b/src/channels/imessage.ts @@ -1,18 +1,14 @@ // --------------------------------------------------------------------------- -// OpenBrowserClaw — Photon iMessage Channel +// OpenBrowserClaw — iMessage Channel // --------------------------------------------------------------------------- // -// Supports two modes: -// local — @photon-ai/imessage-kit (macOS only, direct SQLite DB access) -// remote — direct socket.io + REST (any OS, Photon server, browser-safe) +// Connects to a Photon-managed 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 -// -// Remote mode connects directly to the Photon server using socket.io-client -// and fetch(). No Node.js dependencies — works in the browser. // --------------------------------------------------------------------------- import { io, type Socket } from 'socket.io-client'; @@ -20,39 +16,6 @@ import type { Channel, InboundMessage } from '../types.js'; type MessageCallback = (msg: InboundMessage) => void; -// --------------------------------------------------------------------------- -// Local-mode types (Node only — @photon-ai/imessage-kit) -// --------------------------------------------------------------------------- - -interface LocalSDK { - send(to: string, content: string | { text?: string; images?: string[]; files?: string[] }): Promise<{ message?: { guid?: string } }>; - getMessages(filter: { chatId?: string; search?: string; limit?: number; since?: Date; unreadOnly?: boolean }): Promise<{ messages: LocalMessage[] }>; - getUnreadMessages(): Promise<{ groups: Array<{ sender: string; messages: LocalMessage[] }>; total: number; senderCount: number }>; - listChats(opts?: { type?: 'all' | 'dm' | 'group'; hasUnread?: boolean; limit?: number; search?: string }): Promise; - startWatching(events: { onMessage: (msg: LocalMessage) => void; onError?: (err: Error) => void }): Promise; - stopWatching(): void; - close(): Promise; -} - -interface LocalMessage { - guid: string; - text: string | null; - sender: string; - senderName?: string; - chatId: string; - isGroupChat: boolean; - isFromMe: boolean; - date: Date; - attachments: Array<{ id: string; filename: string; mimeType: string; size: number }>; -} - -interface LocalChat { - chatId: string; - displayName: string; - isGroup: boolean; - unreadCount: number; -} - // --------------------------------------------------------------------------- // Remote-mode types // --------------------------------------------------------------------------- @@ -103,24 +66,18 @@ export type MessageEffect = typeof MESSAGE_EFFECTS[keyof typeof MESSAGE_EFFECTS] // IMessageChannel // --------------------------------------------------------------------------- -export type IMessageMode = 'disabled' | 'local' | 'remote'; - export interface IMessageConfig { - mode: IMessageMode; - serverUrl?: string; - apiKey?: string; + serverUrl: string; + apiKey: string; } export class IMessageChannel implements Channel { readonly type = 'imessage' as const; - private mode: IMessageMode = 'disabled'; + private enabled = false; private serverUrl = ''; private apiKey = ''; - private localSdk: LocalSDK | null = null; - - // Remote mode — raw socket + server URL for REST calls private socket: Socket | null = null; private processedGuids = new Map(); private guidCleanupTimer: ReturnType | null = null; @@ -134,9 +91,20 @@ export class IMessageChannel implements Channel { // ----------------------------------------------------------------------- configure(config: IMessageConfig): void { - this.mode = config.mode; - this.serverUrl = (config.serverUrl ?? '').replace(/\/+$/, ''); - this.apiKey = config.apiKey ?? ''; + 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; } // ----------------------------------------------------------------------- @@ -144,8 +112,8 @@ export class IMessageChannel implements Channel { // ----------------------------------------------------------------------- start(): void { - if (this.mode === 'disabled') { - console.warn('[iMessage] start() called but mode is disabled'); + if (!this.enabled) { + console.warn('[iMessage] start() called but not enabled'); return; } if (this.running) { @@ -153,18 +121,10 @@ export class IMessageChannel implements Channel { return; } this.running = true; - - console.log(`[iMessage] starting in ${this.mode} mode`); - - if (this.mode === 'local') { - this._startLocal().catch((err) => { - console.error('[iMessage] local start error:', err); - }); - } else { - this._startRemote().catch((err) => { - console.error('[iMessage] remote start error:', err); - }); - } + console.log('[iMessage] starting'); + this._startRemote().catch((err) => { + console.error('[iMessage] start error:', err); + }); } stop(): void { @@ -175,11 +135,6 @@ export class IMessageChannel implements Channel { this.guidCleanupTimer = null; } this.processedGuids.clear(); - if (this.mode === 'local' && this.localSdk) { - this.localSdk.stopWatching(); - this.localSdk.close().catch(() => {}); - this.localSdk = null; - } if (this.socket) { this.socket.removeAllListeners(); this.socket.disconnect(); @@ -188,17 +143,11 @@ export class IMessageChannel implements Channel { } async send(groupId: string, text: string): Promise { - if (this.mode === 'local') { - const sdk = this._requireLocal('send'); - await sdk.send(this._localTarget(groupId), text); - } else if (this.mode === 'remote') { - const chatGuid = this._chatGuid(groupId); - await this._post('/api/v1/message/text', { chatGuid, message: text }); - } + const chatGuid = this._chatGuid(groupId); + await this._post('/api/v1/message/text', { chatGuid, message: text }); } setTyping(groupId: string, typing: boolean): void { - if (this.mode !== 'remote') return; const chatGuid = this._chatGuid(groupId); const encoded = encodeURIComponent(chatGuid); if (typing) { @@ -216,31 +165,7 @@ export class IMessageChannel implements Channel { } // ----------------------------------------------------------------------- - // Extended local-mode methods - // ----------------------------------------------------------------------- - - async getMessagesLocal(groupId: string, opts?: { limit?: number; since?: Date }): Promise { - const sdk = this._requireLocal('getMessagesLocal'); - const result = await sdk.getMessages({ chatId: this._chatGuid(groupId), ...opts }); - return result.messages; - } - - async searchMessagesLocal(keyword: string, opts?: { limit?: number }): Promise { - const sdk = this._requireLocal('searchMessagesLocal'); - const result = await sdk.getMessages({ search: keyword, limit: opts?.limit ?? 20 }); - return result.messages; - } - - async getUnreadMessagesLocal(): Promise<{ groups: Array<{ sender: string; messages: LocalMessage[] }>; total: number; senderCount: number }> { - return this._requireLocal('getUnreadMessagesLocal').getUnreadMessages(); - } - - async listChatsLocal(opts?: { type?: 'all' | 'dm' | 'group'; hasUnread?: boolean; limit?: number; search?: string }): Promise { - return this._requireLocal('listChatsLocal').listChats(opts); - } - - // ----------------------------------------------------------------------- - // Extended remote-mode methods (REST) + // Extended methods (REST) // ----------------------------------------------------------------------- async editMessage(messageGuid: string, editedMessage: string): Promise { @@ -269,7 +194,7 @@ export class IMessageChannel implements Channel { }); } - async getMessagesRemote(groupId: string, opts?: { limit?: number; sort?: 'ASC' | 'DESC'; before?: number; after?: number }): Promise { + async getMessages(groupId: string, opts?: { limit?: number; sort?: 'ASC' | 'DESC'; before?: number; after?: number }): Promise { const res = await this._post('/api/v1/message/query', { chatGuid: this._chatGuid(groupId), with: ['chat', 'handle', 'attachment'], @@ -294,34 +219,9 @@ export class IMessageChannel implements Channel { } // ----------------------------------------------------------------------- - // Private — start helpers + // Private — socket connection // ----------------------------------------------------------------------- - private async _startLocal(): Promise { - const localPkg = '@photon-ai/imessage-kit'; - const { IMessageSDK } = await import(/* @vite-ignore */ localPkg); - const sdk = new IMessageSDK() as unknown as LocalSDK; - this.localSdk = sdk; - - await sdk.startWatching({ - onMessage: (msg: LocalMessage) => { - if (msg.isFromMe) return; - if (!this.messageCallback) return; - this.messageCallback({ - id: msg.guid, - groupId: `im:${msg.chatId}`, - sender: msg.sender, - content: msg.text ?? '', - timestamp: msg.date.getTime(), - channel: 'imessage', - }); - }, - onError: (err: Error) => { - console.error('[iMessage] watcher error:', err); - }, - }); - } - private async _startRemote(): Promise { if (!this.serverUrl) { console.error('[iMessage] no serverUrl configured'); @@ -329,7 +229,6 @@ export class IMessageChannel implements Channel { } console.log('[iMessage] connecting to', this.serverUrl, 'hasKey:', !!this.apiKey); - // Clean up any existing socket before creating a new one if (this.socket) { this.socket.removeAllListeners(); this.socket.disconnect(); @@ -345,13 +244,10 @@ export class IMessageChannel implements Channel { }); this.socket = socket; - // Wire up ALL listeners BEFORE calling connect(), so we never miss events. - socket.on('connect', () => { console.log('[iMessage] socket connected (id:', socket.id + '), waiting for server ready...'); }); - // Different server versions emit different ready events const onReady = () => { console.log('[iMessage] server ready — listening for new-message events'); }; @@ -402,13 +298,11 @@ export class IMessageChannel implements Channel { } }); - // Log any unhandled events for debugging socket.onAny((event, ...args) => { if (['connect', 'hello-world', 'auth-ok', 'connect_error', 'auth-error', 'new-message', 'disconnect'].includes(event)) return; console.log('[iMessage] event:', event, JSON.stringify(args).slice(0, 200)); }); - // Periodically prune stale guids to prevent unbounded memory growth if (!this.guidCleanupTimer) { this.guidCleanupTimer = setInterval(() => { const cutoff = Date.now() - IMessageChannel.GUID_TTL_MS; @@ -418,7 +312,6 @@ export class IMessageChannel implements Channel { }, 60_000); } - // NOW connect — all listeners are already in place socket.connect(); } @@ -470,19 +363,4 @@ export class IMessageChannel implements Channel { const raw = groupId.startsWith('im:') ? groupId.slice(3) : groupId; return raw.replace(/^iMessage;/, 'any;'); } - - private _localTarget(groupId: string): string { - const chatGuid = this._chatGuid(groupId); - if (chatGuid.includes(';+;chat') || (chatGuid.startsWith('chat') && !chatGuid.includes(';'))) { - return chatGuid; - } - return chatGuid.split(';').pop() ?? chatGuid; - } - - private _requireLocal(method: string): LocalSDK { - if (!this.localSdk) { - throw new Error(`[iMessage] ${method}: local SDK not initialised. Call start() first and ensure mode is 'local'.`); - } - return this.localSdk; - } } diff --git a/src/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx index eae3c5a..67eace9 100644 --- a/src/components/settings/SettingsPage.tsx +++ b/src/components/settings/SettingsPage.tsx @@ -13,7 +13,6 @@ import { getStorageEstimate, requestPersistentStorage } from '../../storage.js'; import { decryptValue } from '../../crypto.js'; import { getOrchestrator } from '../../stores/orchestrator-store.js'; import { useThemeStore, type ThemeChoice } from '../../stores/theme-store.js'; -import type { IMessageMode } from '../../channels/imessage.js'; const MODELS = [ { value: 'claude-opus-4-6', label: 'Claude Opus 4.6' }, @@ -48,8 +47,8 @@ export function SettingsPage() { const [telegramChatIds, setTelegramChatIds] = useState(''); const [telegramSaved, setTelegramSaved] = useState(false); - // Photon iMessage - const [imessageMode, setImessageMode] = useState(''); + // iMessage + const [imessageEnabled, setImessageEnabled] = useState(false); const [imessageServerUrl, setImessageServerUrl] = useState(''); const [imessageApiKey, setImessageApiKey] = useState(''); const [imessageApiKeyMasked, setImessageApiKeyMasked] = useState(true); @@ -90,13 +89,12 @@ export function SettingsPage() { } } - // Photon iMessage - const storedMode = (await getConfig(CONFIG_KEYS.IMESSAGE_MODE)) as IMessageMode | ''; - if (storedMode) setImessageMode(storedMode); + // 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(); @@ -135,19 +133,16 @@ export function SettingsPage() { } async function handleIMessageSave() { - if (!imessageMode) return; - await orch.configureIMessage( - imessageMode, - imessageMode === 'remote' ? imessageServerUrl.trim() : undefined, - imessageMode === 'remote' ? imessageApiKey.trim() : undefined, - ); + 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(); - setImessageMode(''); + setImessageEnabled(false); setImessageServerUrl(''); setImessageApiKey(''); setImessageDisabled(true); @@ -160,11 +155,7 @@ export function SettingsPage() { } const storagePercent = storageQuota > 0 ? (storageUsage / storageQuota) * 100 : 0; - const imessageRemoteValid = - imessageMode === 'remote' - ? imessageServerUrl.trim().length > 0 && imessageApiKey.trim().length > 0 - : true; - const imessageSaveDisabled = !imessageMode || !imessageRemoteValid; + const imessageSaveDisabled = !imessageServerUrl.trim() || !imessageApiKey.trim(); return (
@@ -304,84 +295,50 @@ export function SettingsPage() {
- {/* ---- Photon iMessage ---- */} + {/* ---- iMessage ---- */}

- Photon iMessage + iMessage

- {/* Mode selector */}
Mode - +
Remote (Photon managed)

- Local mode reads the iMessage database directly on macOS (no server needed). - Remote mode connects to a Photon server and unlocks advanced features such as - edit, unsend, tapbacks, effects, and typing indicators. + Connects to a Photon iMessage server via Socket.IO. Supports + send, edit, unsend, tapbacks, effects, typing indicators, and polls.

- {/* Remote-only fields */} - {imessageMode === 'remote' && ( - <> -
- Server URL - setImessageServerUrl(e.target.value)} - /> -

- URL of your Photon iMessage server -

-
-
- API Key -
- setImessageApiKey(e.target.value)} - /> - -
-
- - )} - - {/* Local mode info */} - {imessageMode === 'local' && ( -
- - Local mode requires macOS with Full Disk Access granted to your browser - or the application running OpenBrowserClaw. - Install @photon-ai/imessage-kit as a - dependency before enabling. - +
+ Server URL + setImessageServerUrl(e.target.value)} + /> +
+
+ API Key +
+ setImessageApiKey(e.target.value)} + /> +
- )} +
- {imessageMode && ( + {imessageEnabled && (
diff --git a/src/config.ts b/src/config.ts index ca5b29b..7da524e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -69,8 +69,7 @@ export const CONFIG_KEYS = { PASSPHRASE_SALT: 'passphrase_salt', PASSPHRASE_VERIFY: 'passphrase_verify', ASSISTANT_NAME: 'assistant_name', - // Photon iMessage - IMESSAGE_MODE: 'imessage_mode', // 'local' | 'remote' - IMESSAGE_SERVER_URL: 'imessage_server_url', // remote mode only - IMESSAGE_API_KEY: 'imessage_api_key', // remote mode only + // iMessage (Photon managed) + 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 1a4e5dc..eaac7fe 100644 --- a/src/orchestrator.ts +++ b/src/orchestrator.ts @@ -44,7 +44,6 @@ 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 type { IMessageMode } from './channels/imessage.js'; import { Router } from './router.js'; import { TaskScheduler } from './task-scheduler.js'; import { ulid } from './ulid.js'; @@ -154,16 +153,14 @@ export class Orchestrator { this.telegram.start(); } - // Configure Photon iMessage if mode is set + // Configure iMessage if server URL + API key exist if (this.destroyed) return; - const imessageMode = (await getConfig(CONFIG_KEYS.IMESSAGE_MODE)) as IMessageMode | null; - console.log('[orchestrator] iMessage config from DB:', { imessageMode }); + const imessageServerUrl = await getConfig(CONFIG_KEYS.IMESSAGE_SERVER_URL); + const imessageApiKey = await getConfig(CONFIG_KEYS.IMESSAGE_API_KEY); if (this.destroyed) return; - if (imessageMode && imessageMode !== 'disabled') { - const serverUrl = (await getConfig(CONFIG_KEYS.IMESSAGE_SERVER_URL)) || undefined; - const imessageApiKey = (await getConfig(CONFIG_KEYS.IMESSAGE_API_KEY)) || undefined; - console.log('[orchestrator] configuring iMessage:', { mode: imessageMode, serverUrl, hasApiKey: !!imessageApiKey }); - this.imessage.configure({ mode: imessageMode, serverUrl, apiKey: imessageApiKey }); + if (imessageServerUrl && imessageApiKey) { + console.log('[orchestrator] configuring iMessage:', { serverUrl: imessageServerUrl, hasApiKey: true }); + this.imessage.configure({ serverUrl: imessageServerUrl, apiKey: imessageApiKey }); this.imessage.onMessage((msg) => this.enqueue(msg)); this.imessage.start(); } else { @@ -262,23 +259,15 @@ export class Orchestrator { } /** - * Configure Photon iMessage (local or remote mode). - * - * Local mode — uses @photon-ai/imessage-kit directly (macOS only). - * Remote mode — connects to a Photon server via socket.io + REST. + * Configure iMessage (remote mode via Photon-managed server). */ - async configureIMessage( - mode: IMessageMode, - serverUrl?: string, - apiKey?: string, - ): Promise { - console.log('[orchestrator] configureIMessage called:', { mode, serverUrl, hasApiKey: !!apiKey }); - await setConfig(CONFIG_KEYS.IMESSAGE_MODE, mode); - if (serverUrl !== undefined) await setConfig(CONFIG_KEYS.IMESSAGE_SERVER_URL, serverUrl); - if (apiKey !== undefined) await setConfig(CONFIG_KEYS.IMESSAGE_API_KEY, apiKey); + async configureIMessage(serverUrl: string, apiKey: string): Promise { + console.log('[orchestrator] configureIMessage called:', { serverUrl, hasApiKey: !!apiKey }); + await setConfig(CONFIG_KEYS.IMESSAGE_SERVER_URL, serverUrl); + await setConfig(CONFIG_KEYS.IMESSAGE_API_KEY, apiKey); this.imessage.stop(); - this.imessage.configure({ mode, serverUrl, apiKey }); + this.imessage.configure({ serverUrl, apiKey }); this.imessage.onMessage((msg) => this.enqueue(msg)); this.imessage.start(); } @@ -287,8 +276,7 @@ export class Orchestrator { * Disable iMessage integration. */ async disableIMessage(): Promise { - this.imessage.stop(); - await setConfig(CONFIG_KEYS.IMESSAGE_MODE, ''); + this.imessage.disable(); await setConfig(CONFIG_KEYS.IMESSAGE_SERVER_URL, ''); await setConfig(CONFIG_KEYS.IMESSAGE_API_KEY, ''); } From 6b62b6eebaecafdd1da8eed3986c1fbb4426bef1 Mon Sep 17 00:00:00 2001 From: Kumar Vandit Date: Thu, 5 Mar 2026 12:14:37 +0530 Subject: [PATCH 3/9] feat(imessage): auto-trigger on all incoming iMessages without @mention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iMessage conversations are direct chats — requiring @mention for every message is unnecessary friction. Messages from im: prefixed groups now always trigger the agent, matching the browser chat behavior. Other channels (Telegram) still require the @mention pattern. --- src/orchestrator.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/orchestrator.ts b/src/orchestrator.ts index eaac7fe..14bd416 100644 --- a/src/orchestrator.ts +++ b/src/orchestrator.ts @@ -388,18 +388,21 @@ export class Orchestrator { }; const isBrowserMain = msg.groupId === DEFAULT_GROUP_ID; + const isImessage = msg.groupId.startsWith('im:'); const hasTrigger = this.triggerPattern.test(msg.content.trim()); console.log('[orchestrator] enqueue:', { groupId: msg.groupId, content: msg.content.slice(0, 50), isBrowserMain, + isImessage, hasTrigger, triggerPattern: this.triggerPattern.source, assistantName: this.assistantName, }); - if (isBrowserMain || hasTrigger) { + // iMessage and browser chat always trigger; other channels need @mention + if (isBrowserMain || isImessage || hasTrigger) { stored.isTrigger = true; this.messageQueue.push(msg); console.log('[orchestrator] message queued for agent'); From aeebd168e0be5149ee9af8f5ef2dbd30331baaf3 Mon Sep 17 00:00:00 2001 From: Kumar Vandit Date: Thu, 5 Mar 2026 12:15:16 +0530 Subject: [PATCH 4/9] docs: update README for remote-only iMessage, drop local mode references --- README.md | 39 +++++++++------------------------------ 1 file changed, 9 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 0c69e06..5f38bef 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ Open `http://localhost:5173`, paste your [Anthropic API key](https://console.ant │ Channels: │ │ ├── Browser Chat (built-in) │ │ ├── Telegram Bot API (optional, pure HTTPS) │ -│ └── Photon iMessage (optional, local or remote mode) │ +│ └── iMessage (optional, Photon managed) │ └──────────────────────────────────────────────────────────┘ ``` @@ -60,7 +60,7 @@ 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` | Photon iMessage channel (local + remote) | +| `src/channels/imessage.ts` | iMessage channel (Photon managed) | | `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 | @@ -97,41 +97,20 @@ 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. -## Photon iMessage +## iMessage -Optional. Integrates with [Photon](https://photon.codes) iMessage SDKs to receive and reply to iMessages directly from OpenBrowserClaw. Two modes are available: - -### Local mode — `@photon-ai/imessage-kit` - -Reads the iMessage SQLite database directly on macOS. No server required. +Optional. Connects to a Photon-managed iMessage server via Socket.IO + REST. Supports send, edit, unsend, tapback reactions, message effects, typing indicators, and polls. **Requirements:** -- macOS only -- Node.js ≥ 18 or Bun ≥ 1.0 -- Full Disk Access granted to your terminal or browser -- `@photon-ai/imessage-kit` installed as a project dependency - -**Setup:** -```bash -npm install @photon-ai/imessage-kit better-sqlite3 -``` -Then open Settings → Photon iMessage, select **Local**, and save. - -### Remote mode — Photon server - -Connects to a Photon iMessage server via Socket.IO + REST. Unlocks advanced features: edit, unsend, tapback reactions, message effects, typing indicators, group chat management, and polls. No extra dependencies needed — uses `socket.io-client` and `fetch` directly. - -**Requirements:** -- A running Photon iMessage server (macOS, any network-accessible host) +- A Photon iMessage server (macOS host, any network-accessible address) - Valid API key for the server **Setup:** -Open Settings → Photon iMessage, select **Remote**, enter your server URL and API key, and save. - -### How iMessage conversations work +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`). -- Trigger the assistant by mentioning `@Andy` (or your configured assistant name) in any iMessage. +- Every incoming message triggers a response automatically — no `@mention` needed. - Responses are sent back to the originating iMessage chat. ## WebVM (Optional) @@ -156,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, iMessage | Telegram, iMessage (Photon) | +| 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) | From 643ac63848abd738d92956a6687a84a77820f3e2 Mon Sep 17 00:00:00 2001 From: Kumar Vandit Date: Fri, 6 Mar 2026 09:39:16 +0530 Subject: [PATCH 5/9] fix: address PR review feedback - Remove default null value for imessage param in Router constructor - Remove all dev-only console.log statements from orchestrator and imessage channel - Replace "Photon managed" branding with neutral "remote" / "HTTPS + Socket.IO" - Simplify iMessage server requirement description in README - Fix security section to list Telegram and iMessage plaintext storage consistently - Revert unrelated devOptions.enabled change in vite.config.ts --- README.md | 10 +++--- src/channels/imessage.ts | 44 ++---------------------- src/components/settings/SettingsPage.tsx | 4 +-- src/config.ts | 2 +- src/orchestrator.ts | 19 +--------- src/router.ts | 4 +-- vite.config.ts | 2 +- 7 files changed, 15 insertions(+), 70 deletions(-) diff --git a/README.md b/README.md index 5f38bef..45f876c 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ Open `http://localhost:5173`, paste your [Anthropic API key](https://console.ant │ Channels: │ │ ├── Browser Chat (built-in) │ │ ├── Telegram Bot API (optional, pure HTTPS) │ -│ └── iMessage (optional, Photon managed) │ +│ └── iMessage (optional, HTTPS + Socket.IO) │ └──────────────────────────────────────────────────────────┘ ``` @@ -60,7 +60,7 @@ 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 (Photon managed) | +| `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 | @@ -99,10 +99,10 @@ Optional. Works entirely via HTTPS — no WebSockets or special protocols. ## iMessage -Optional. Connects to a Photon-managed iMessage server via Socket.IO + REST. Supports send, edit, unsend, tapback reactions, message effects, typing indicators, and polls. +Optional. Connects to a remote iMessage server via Socket.IO + REST. Supports send, edit, unsend, tapback reactions, message effects, typing indicators, and polls. **Requirements:** -- A Photon iMessage server (macOS host, any network-accessible address) +- An iMessage server - Valid API key for the server **Setup:** @@ -174,6 +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 (remote mode) is stored in plaintext in IndexedDB. +- 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/src/channels/imessage.ts b/src/channels/imessage.ts index e0de2ee..116c4d8 100644 --- a/src/channels/imessage.ts +++ b/src/channels/imessage.ts @@ -2,7 +2,7 @@ // OpenBrowserClaw — iMessage Channel // --------------------------------------------------------------------------- // -// Connects to a Photon-managed iMessage server via Socket.IO + REST. +// Connects to a remote iMessage server via Socket.IO + REST. // Browser-safe — uses socket.io-client and fetch() directly. // // groupId prefix: "im:" @@ -112,23 +112,14 @@ export class IMessageChannel implements Channel { // ----------------------------------------------------------------------- start(): void { - if (!this.enabled) { - console.warn('[iMessage] start() called but not enabled'); - return; - } - if (this.running) { - console.log('[iMessage] already running — skipping duplicate start()'); - return; - } + if (!this.enabled || this.running) return; this.running = true; - console.log('[iMessage] starting'); this._startRemote().catch((err) => { console.error('[iMessage] start error:', err); }); } stop(): void { - console.log('[iMessage] stop() called, running was:', this.running); this.running = false; if (this.guidCleanupTimer) { clearInterval(this.guidCleanupTimer); @@ -223,11 +214,7 @@ export class IMessageChannel implements Channel { // ----------------------------------------------------------------------- private async _startRemote(): Promise { - if (!this.serverUrl) { - console.error('[iMessage] no serverUrl configured'); - return; - } - console.log('[iMessage] connecting to', this.serverUrl, 'hasKey:', !!this.apiKey); + if (!this.serverUrl) return; if (this.socket) { this.socket.removeAllListeners(); @@ -244,16 +231,6 @@ export class IMessageChannel implements Channel { }); this.socket = socket; - socket.on('connect', () => { - console.log('[iMessage] socket connected (id:', socket.id + '), waiting for server ready...'); - }); - - const onReady = () => { - console.log('[iMessage] server ready — listening for new-message events'); - }; - socket.on('hello-world', onReady); - socket.on('auth-ok', onReady); - socket.on('connect_error', (err) => { console.error('[iMessage] connect_error:', err.message); }); @@ -266,14 +243,6 @@ export class IMessageChannel implements Channel { if (msg.guid && this.processedGuids.has(msg.guid)) return; if (msg.guid) this.processedGuids.set(msg.guid, Date.now()); - console.log('[iMessage] new-message:', { - guid: msg.guid, - text: msg.text?.slice(0, 50), - isFromMe: msg.isFromMe, - sender: msg.handle?.address, - chatGuid: msg.chats?.[0]?.guid, - }); - if (!this.messageCallback) return; if (msg.isFromMe) return; if (msg.associatedMessageGuid) return; @@ -290,19 +259,12 @@ export class IMessageChannel implements Channel { }); socket.on('disconnect', (reason) => { - console.warn('[iMessage] disconnected:', reason); if (!this.running) return; if (reason === 'io server disconnect') { - console.log('[iMessage] server kicked us — reconnecting...'); socket.connect(); } }); - socket.onAny((event, ...args) => { - if (['connect', 'hello-world', 'auth-ok', 'connect_error', 'auth-error', 'new-message', 'disconnect'].includes(event)) return; - console.log('[iMessage] event:', event, JSON.stringify(args).slice(0, 200)); - }); - if (!this.guidCleanupTimer) { this.guidCleanupTimer = setInterval(() => { const cutoff = Date.now() - IMessageChannel.GUID_TTL_MS; diff --git a/src/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx index 67eace9..bbcaa11 100644 --- a/src/components/settings/SettingsPage.tsx +++ b/src/components/settings/SettingsPage.tsx @@ -304,9 +304,9 @@ export function SettingsPage() {
Mode -
Remote (Photon managed)
+
Remote

- Connects to a Photon iMessage server via Socket.IO. Supports + Connects to a remote iMessage server via Socket.IO. Supports send, edit, unsend, tapbacks, effects, typing indicators, and polls.

diff --git a/src/config.ts b/src/config.ts index 7da524e..b5b1a67 100644 --- a/src/config.ts +++ b/src/config.ts @@ -69,7 +69,7 @@ export const CONFIG_KEYS = { PASSPHRASE_SALT: 'passphrase_salt', PASSPHRASE_VERIFY: 'passphrase_verify', ASSISTANT_NAME: 'assistant_name', - // iMessage (Photon managed) + // 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 14bd416..b4d6f51 100644 --- a/src/orchestrator.ts +++ b/src/orchestrator.ts @@ -159,12 +159,9 @@ export class Orchestrator { const imessageApiKey = await getConfig(CONFIG_KEYS.IMESSAGE_API_KEY); if (this.destroyed) return; if (imessageServerUrl && imessageApiKey) { - console.log('[orchestrator] configuring iMessage:', { serverUrl: imessageServerUrl, hasApiKey: true }); this.imessage.configure({ serverUrl: imessageServerUrl, apiKey: imessageApiKey }); this.imessage.onMessage((msg) => this.enqueue(msg)); this.imessage.start(); - } else { - console.log('[orchestrator] iMessage not configured — skipping'); } // Set up agent worker @@ -259,10 +256,9 @@ export class Orchestrator { } /** - * Configure iMessage (remote mode via Photon-managed server). + * Configure iMessage (remote mode). */ async configureIMessage(serverUrl: string, apiKey: string): Promise { - console.log('[orchestrator] configureIMessage called:', { serverUrl, hasApiKey: !!apiKey }); await setConfig(CONFIG_KEYS.IMESSAGE_SERVER_URL, serverUrl); await setConfig(CONFIG_KEYS.IMESSAGE_API_KEY, apiKey); @@ -391,23 +387,10 @@ export class Orchestrator { const isImessage = msg.groupId.startsWith('im:'); const hasTrigger = this.triggerPattern.test(msg.content.trim()); - console.log('[orchestrator] enqueue:', { - groupId: msg.groupId, - content: msg.content.slice(0, 50), - isBrowserMain, - isImessage, - hasTrigger, - triggerPattern: this.triggerPattern.source, - assistantName: this.assistantName, - }); - // iMessage and browser chat always trigger; other channels need @mention if (isBrowserMain || isImessage || hasTrigger) { stored.isTrigger = true; this.messageQueue.push(msg); - console.log('[orchestrator] message queued for agent'); - } else { - console.log('[orchestrator] message saved but NOT triggered — needs @' + this.assistantName); } await saveMessage(stored); diff --git a/src/router.ts b/src/router.ts index 387c908..45169a3 100644 --- a/src/router.ts +++ b/src/router.ts @@ -14,13 +14,13 @@ import { IMessageChannel } from './channels/imessage.js'; * Prefix mapping: * "br:" → BrowserChatChannel * "tg:" → TelegramChannel - * "im:" → IMessageChannel (Photon iMessage — local or remote mode) + * "im:" → IMessageChannel */ export class Router { constructor( private browserChat: BrowserChatChannel, private telegram: TelegramChannel | null, - private imessage: IMessageChannel | null = null, + private imessage: IMessageChannel | null, ) {} /** diff --git a/vite.config.ts b/vite.config.ts index 90122ad..caab289 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -10,7 +10,7 @@ export default defineConfig({ VitePWA({ registerType: 'autoUpdate', devOptions: { - enabled: false, + enabled: true, }, includeAssets: ['favicon.svg', 'apple-touch-icon.png'], manifest: { From 53a3cfddeffc42da70971d71168d80aa23a5d238 Mon Sep 17 00:00:00 2001 From: Kumar Vandit Date: Fri, 6 Mar 2026 09:50:34 +0530 Subject: [PATCH 6/9] fix: remove dead iMessage extended methods and update descriptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove editMessage, addReaction, removeReaction, getMessages, getChatInfo, createPoll — all defined but never called from anywhere. Also remove unused RemoteChat type, MESSAGE_EFFECTS, MessageEffect, and the now-orphaned _get helper. Update SettingsPage and README to not claim unsupported features. --- README.md | 2 +- src/channels/imessage.ts | 96 ------------------------ src/components/settings/SettingsPage.tsx | 3 +- 3 files changed, 2 insertions(+), 99 deletions(-) diff --git a/README.md b/README.md index 45f876c..da307a9 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ Optional. Works entirely via HTTPS — no WebSockets or special protocols. ## iMessage -Optional. Connects to a remote iMessage server via Socket.IO + REST. Supports send, edit, unsend, tapback reactions, message effects, typing indicators, and polls. +Optional. Connects to a remote iMessage server via Socket.IO + REST. **Requirements:** - An iMessage server diff --git a/src/channels/imessage.ts b/src/channels/imessage.ts index 116c4d8..3c7494d 100644 --- a/src/channels/imessage.ts +++ b/src/channels/imessage.ts @@ -32,36 +32,6 @@ interface RemoteMessage { associatedMessageType?: string | number | null; } -interface RemoteChat { - guid: string; - displayName: string; - chatIdentifier: string; - style: number; - participants?: unknown[]; - isArchived?: boolean; -} - -// --------------------------------------------------------------------------- -// MESSAGE_EFFECTS — iMessage bubble/screen effect identifiers -// --------------------------------------------------------------------------- - -export const MESSAGE_EFFECTS = { - slam: 'com.apple.MobileSMS.expressivesend.impact', - loud: 'com.apple.MobileSMS.expressivesend.loud', - gentle: 'com.apple.MobileSMS.expressivesend.gentle', - invisibleInk: 'com.apple.MobileSMS.expressivesend.invisibleink', - confetti: 'com.apple.messages.effect.CKConfettiEffect', - balloons: 'com.apple.messages.effect.CKBalloonEffect', - fireworks: 'com.apple.messages.effect.CKFireworksEffect', - shooting: 'com.apple.messages.effect.CKShootingStarEffect', - lasers: 'com.apple.messages.effect.CKLasersEffect', - love: 'com.apple.messages.effect.CKHeartEffect', - celebration: 'com.apple.messages.effect.CKSpotlightEffect', - echo: 'com.apple.messages.effect.CKEchoEffect', -} as const; - -export type MessageEffect = typeof MESSAGE_EFFECTS[keyof typeof MESSAGE_EFFECTS]; - // --------------------------------------------------------------------------- // IMessageChannel // --------------------------------------------------------------------------- @@ -155,60 +125,6 @@ export class IMessageChannel implements Channel { this.messageCallback = callback; } - // ----------------------------------------------------------------------- - // Extended methods (REST) - // ----------------------------------------------------------------------- - - async editMessage(messageGuid: string, editedMessage: string): Promise { - await this._post(`/api/v1/message/${encodeURIComponent(messageGuid)}/edit`, { - editedMessage, - backwardsCompatibilityMessage: editedMessage, - partIndex: 0, - }); - } - - async addReaction(groupId: string, messageGuid: string, reaction: string): Promise { - await this._post('/api/v1/message/react', { - chatGuid: this._chatGuid(groupId), - selectedMessageGuid: messageGuid, - reaction, - partIndex: 0, - }); - } - - async removeReaction(groupId: string, messageGuid: string, reaction: string): Promise { - await this._post('/api/v1/message/react', { - chatGuid: this._chatGuid(groupId), - selectedMessageGuid: messageGuid, - reaction: `-${reaction}`, - partIndex: 0, - }); - } - - async getMessages(groupId: string, opts?: { limit?: number; sort?: 'ASC' | 'DESC'; before?: number; after?: number }): Promise { - const res = await this._post('/api/v1/message/query', { - chatGuid: this._chatGuid(groupId), - with: ['chat', 'handle', 'attachment'], - ...opts, - }); - return res as RemoteMessage[]; - } - - async getChatInfo(groupId: string): Promise { - const chatGuid = this._chatGuid(groupId); - const res = await this._get(`/api/v1/chat/${encodeURIComponent(chatGuid)}`); - return res as RemoteChat; - } - - async createPoll(groupId: string, title: string, options: string[]): Promise<{ guid: string }> { - const res = await this._post('/api/v1/poll', { - chatGuid: this._chatGuid(groupId), - title, - options, - }); - return res as { guid: string }; - } - // ----------------------------------------------------------------------- // Private — socket connection // ----------------------------------------------------------------------- @@ -298,18 +214,6 @@ export class IMessageChannel implements Channel { return json.data ?? json; } - private async _get(path: string): Promise { - const res = await fetch(`${this.serverUrl}${path}`, { - headers: this.apiKey ? { 'X-API-Key': this.apiKey } : {}, - }); - if (!res.ok) { - const text = await res.text(); - throw new Error(`[iMessage] GET ${path} failed ${res.status}: ${text}`); - } - const json = await res.json(); - return json.data ?? json; - } - private async _delete(path: string): Promise { await fetch(`${this.serverUrl}${path}`, { method: 'DELETE', diff --git a/src/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx index bbcaa11..5099160 100644 --- a/src/components/settings/SettingsPage.tsx +++ b/src/components/settings/SettingsPage.tsx @@ -306,8 +306,7 @@ export function SettingsPage() { Mode
Remote

- Connects to a remote iMessage server via Socket.IO. Supports - send, edit, unsend, tapbacks, effects, typing indicators, and polls. + Connects to a remote iMessage server via Socket.IO.

From f59f13f44b700ded844ca5cbd84bca318f9095f7 Mon Sep 17 00:00:00 2001 From: Kumar Vandit Date: Sat, 7 Mar 2026 10:11:57 +0530 Subject: [PATCH 7/9] fix: production-harden iMessage channel and delivery error handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add typing timer tracking to prevent overlapping POST/DELETE calls - Guard send/setTyping with enabled check after disable - Filter empty-text messages (attachment-only) before triggering agent - Reset running flag if _startRemote() throws during startup - Stop channel on auth-error instead of retrying with bad credentials - Enable Socket.IO reconnection with exponential backoff (1s–30s) - Catch router.send() failures in deliverResponse so state resets to idle - Replace Photon placeholder URL with generic example.com --- src/channels/imessage.ts | 23 +++++++++++++++++++++-- src/components/settings/SettingsPage.tsx | 2 +- src/orchestrator.ts | 10 +++++----- 3 files changed, 27 insertions(+), 8 deletions(-) diff --git a/src/channels/imessage.ts b/src/channels/imessage.ts index 3c7494d..771da4c 100644 --- a/src/channels/imessage.ts +++ b/src/channels/imessage.ts @@ -55,6 +55,7 @@ export class IMessageChannel implements Channel { private messageCallback: MessageCallback | null = null; private running = false; + private typingTimer: ReturnType | null = null; // ----------------------------------------------------------------------- // Configuration @@ -86,11 +87,16 @@ export class IMessageChannel implements Channel { 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; @@ -104,19 +110,27 @@ export class IMessageChannel implements Channel { } 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(() => {}); - setTimeout(() => { + 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(() => {}); } } @@ -142,6 +156,9 @@ export class IMessageChannel implements Channel { auth: this.apiKey ? { apiKey: this.apiKey } : undefined, transports: ['websocket'], timeout: 10_000, + reconnection: true, + reconnectionDelay: 1_000, + reconnectionDelayMax: 30_000, forceNew: true, autoConnect: false, }); @@ -153,6 +170,7 @@ export class IMessageChannel implements Channel { 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) => { @@ -162,13 +180,14 @@ export class IMessageChannel implements Channel { if (!this.messageCallback) return; if (msg.isFromMe) return; if (msg.associatedMessageGuid) return; + if (!msg.text?.trim()) return; const chatGuid = msg.chats?.[0]?.guid ?? ''; this.messageCallback({ id: msg.guid, groupId: `im:${chatGuid}`, sender: msg.handle?.address ?? 'unknown', - content: msg.text ?? '', + content: msg.text, timestamp: msg.dateCreated, channel: 'imessage', }); diff --git a/src/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx index 5099160..511b6cb 100644 --- a/src/components/settings/SettingsPage.tsx +++ b/src/components/settings/SettingsPage.tsx @@ -315,7 +315,7 @@ export function SettingsPage() { setImessageServerUrl(e.target.value)} /> diff --git a/src/orchestrator.ts b/src/orchestrator.ts index b4d6f51..3b26c0f 100644 --- a/src/orchestrator.ts +++ b/src/orchestrator.ts @@ -562,7 +562,6 @@ export class Orchestrator { } private async deliverResponse(groupId: string, text: string): Promise { - // Save to DB const stored: StoredMessage = { id: ulid(), groupId, @@ -579,16 +578,17 @@ export class Orchestrator { }; 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 }); From 8a02fdf2c76b8baf0fff494b225fe407ef38cb4b Mon Sep 17 00:00:00 2001 From: Kumar Vandit Date: Sat, 7 Mar 2026 22:16:47 +0530 Subject: [PATCH 8/9] =?UTF-8?q?fix:=20address=20CodeRabbit=20review=20?= =?UTF-8?q?=E2=80=94=20chat=20GUID=20guard,=20request=20timeout,=20dedup?= =?UTF-8?q?=20TTL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reject socket events without a valid chat GUID instead of emitting groupId "im:" which collapses messages into a fake conversation - Add 15s AbortController timeout to REST helpers (_post, _delete) via shared _request method so hung servers don't block state indefinitely - Fix dedup cache to actually respect 60s TTL by checking stored timestamp on lookup, not just map membership --- src/channels/imessage.ts | 31 ++++++++++++++++++++++++++++--- src/orchestrator.ts | 9 ++++++--- 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/src/channels/imessage.ts b/src/channels/imessage.ts index 771da4c..00c1a23 100644 --- a/src/channels/imessage.ts +++ b/src/channels/imessage.ts @@ -182,7 +182,9 @@ export class IMessageChannel implements Channel { if (msg.associatedMessageGuid) return; if (!msg.text?.trim()) return; - const chatGuid = msg.chats?.[0]?.guid ?? ''; + const chatGuid = msg.chats?.[0]?.guid; + if (!chatGuid) return; + this.messageCallback({ id: msg.guid, groupId: `im:${chatGuid}`, @@ -216,8 +218,31 @@ export class IMessageChannel implements Channel { // 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 fetch(`${this.serverUrl}${path}`, { + const res = await this._request(path, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -234,7 +259,7 @@ export class IMessageChannel implements Channel { } private async _delete(path: string): Promise { - await fetch(`${this.serverUrl}${path}`, { + await this._request(path, { method: 'DELETE', headers: this.apiKey ? { 'X-API-Key': this.apiKey } : {}, }); diff --git a/src/orchestrator.ts b/src/orchestrator.ts index 3b26c0f..cf82bad 100644 --- a/src/orchestrator.ts +++ b/src/orchestrator.ts @@ -366,11 +366,14 @@ export class Orchestrator { if (this.destroyed) return; // Dedup: drop messages with the same id seen within the last 60 s - if (msg.id && this.recentInboundIds.has(msg.id)) return; if (msg.id) { - this.recentInboundIds.set(msg.id, Date.now()); + 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 = Date.now() - 60_000; + const cutoff = now - 60_000; for (const [id, ts] of this.recentInboundIds) { if (ts < cutoff) this.recentInboundIds.delete(id); } From f75acdd65906128b9b7c8e2b0c245142a32f6b02 Mon Sep 17 00:00:00 2001 From: Kumar Vandit Date: Sat, 14 Mar 2026 18:26:19 +0530 Subject: [PATCH 9/9] chore: add blog drafts to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) 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