From 350ad3f29ba4120f814ae1b8e85cb2db0fe7857a Mon Sep 17 00:00:00 2001 From: jibraaan Date: Sun, 4 Oct 2026 03:45:19 +0500 Subject: [PATCH] feat: talk to a Dot over iMessage A local macOS bridge, started by the server when IMESSAGE_HANDLES is set. It reads new incoming one-to-one messages from the Messages database (read-only) and replies through Messages via AppleScript, with the reply passed as an argument rather than interpolated into the script. - Only allowlisted phone numbers / Apple ID emails are answered. - One persistent Dot conversation per sender; "/new" starts another. - History is never answered; the cursor advances before replying so a failure never double-answers; stale (>1h) messages and echoes of the bridge's own replies are skipped. - Archived attributedBody text is decoded; Apple nanosecond dates are converted in SQL to avoid overflowing JS numbers. - Settings show the bridge status, including missing Full Disk Access. Co-Authored-By: Claude Opus 5.5 --- .env.example | 6 + README.md | 5 + docs/IMESSAGE.md | 37 +++++ src/client/WorkspaceDialog.tsx | 7 + src/server/imessage-store.ts | 30 ++++ src/server/imessage.ts | 272 +++++++++++++++++++++++++++++++++ src/server/index.ts | 42 ++++- src/server/platform-config.ts | 3 + src/server/platform.ts | 20 ++- src/server/workspace.ts | 3 + src/shared/types.ts | 1 + tests/imessage.test.ts | 243 +++++++++++++++++++++++++++++ 12 files changed, 661 insertions(+), 8 deletions(-) create mode 100644 docs/IMESSAGE.md create mode 100644 src/server/imessage-store.ts create mode 100644 src/server/imessage.ts create mode 100644 tests/imessage.test.ts diff --git a/.env.example b/.env.example index eb099cb..4f7729c 100644 --- a/.env.example +++ b/.env.example @@ -38,6 +38,12 @@ SLACK_TEAM_ID= SLACK_USER_IDS= # SLACK_DOT_ID= (defaults to the initial Dot) +# Optional iMessage: run OpenDots on the Mac signed in to Messages; see docs/IMESSAGE.md. +# Only these phone numbers or Apple ID emails can reach your Dot (comma-separated). +IMESSAGE_HANDLES= +# IMESSAGE_DOT_ID= (defaults to the initial Dot) +# IMESSAGE_DB_PATH= (defaults to ~/Library/Messages/chat.db) + # Optional persistent computers, one per Dot, using OpenBot services. # See docs/COMPUTERS.md. Keep these two different random secrets on the server. COMPUTER_SUPERVISOR_URL= diff --git a/README.md b/README.md index 49dfde4..11c5d47 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,10 @@ https://github.com/user-attachments/assets/27d03a6c-a9e0-4c29-8d96-fafe0fbae20f Bring your agents into Slack with [Channels SDK](https://github.com/CopilotKit/channels-sdk). See the [managed Channels documentation](https://docs.copilotkit.ai/intelligence/channels) to connect them through CopilotKit Intelligence. +### iMessage + +Text your Dot from your phone. When OpenDots runs on a Mac signed in to Messages, it answers allowlisted numbers and Apple ID emails. Each sender gets their own persistent conversation, and `/new` starts a fresh one. See [iMessage setup](docs/IMESSAGE.md). + ## Architecture ### AG-UI connects the agent to the interface @@ -172,6 +176,7 @@ See [Setup](docs/SETUP.md) for configuration, Slack, calls, the browser service, | Pages | Searchable library, visual editor, slash commands, autosave, and revision checks | | Conversations | React SDK chat and Threads integration, page-specific conversations, and source links | | Slack | Managed Channels SDK declaration with workspace and user allowlists | +| iMessage | Local macOS bridge with a sender allowlist, per-sender conversations, and plain-text replies | | Calls | WebRTC speech, delegated compute, bounded sessions, hangup, and timeline receipts | | Background work | Scheduled server-side turns in their original conversation, with pause and retry controls | | Browser | Separate read-only public-page service with page capture and navigation limits | diff --git a/docs/IMESSAGE.md b/docs/IMESSAGE.md new file mode 100644 index 0000000..1342796 --- /dev/null +++ b/docs/IMESSAGE.md @@ -0,0 +1,37 @@ +# iMessage + +Text a Dot from your phone. Apple has no iMessage API, so OpenDots connects through the Messages app on a Mac. + +- **Receiving:** OpenDots reads new messages from the local Messages database. Access is read-only. +- **Replying:** it sends replies through Messages using AppleScript. + +The bridge works only while OpenDots runs natively on that Mac and the Mac is signed in to Messages. It does not work in Docker or on a remote server. + +## Setup + +1. Sign in to Messages on the Mac with the Apple ID your Dot should text from. A spare Apple ID works best: messages you send to your own Apple ID can arrive twice. +2. Add the phone numbers or Apple ID emails that may reach your Dot to `.env`: + + ```bash + IMESSAGE_HANDLES=+15551234567,me@icloud.com + # IMESSAGE_DOT_ID= # defaults to the first Dot + ``` + +3. Grant **Full Disk Access** to the app that starts OpenDots, such as Terminal, iTerm or your editor, in **System Settings → Privacy & Security → Full Disk Access**. Then restart that app. +4. Start OpenDots (`npm run dev` or `npm start`). The first reply asks you to allow the app to control **Messages**. Allow it. + +**Settings & setup** shows the bridge's status. It reads `needs Full Disk Access` until step 3 is done. + +## How it works + +- The bridge checks for new messages every few seconds. Only allowlisted senders in one-to-one chats get answers. Group chats are ignored. +- Each sender gets one persistent Dot conversation, which you can also open in the web app. Send `/new` to start a fresh one. +- Several quick messages are combined into one turn. Replies are sent as plain text, with Markdown removed. +- Message history is never answered. On first start the bridge begins at the newest message. After a restart, messages older than an hour are skipped. +- When OpenDots is paused, the Dot replies that it is paused. + +## Security notes + +- Anyone who can send from an allowlisted number or email can talk to the Dot and use its permitted tools. Keep the list short. +- With Full Disk Access, the process running OpenDots can read your messages and other private files. Consider a separate macOS user account for OpenDots. +- Reply text is passed to AppleScript as an argument, never inserted into the script. diff --git a/src/client/WorkspaceDialog.tsx b/src/client/WorkspaceDialog.tsx index 234fa4d..d15408d 100644 --- a/src/client/WorkspaceDialog.tsx +++ b/src/client/WorkspaceDialog.tsx @@ -371,6 +371,13 @@ export function WorkspaceDialog({ {workspace.setup.voice ? 'configuration present' : 'needs VOICE_API_KEY and VOICE_MODEL'} + . iMessage:{' '} + {workspace.setup.imessage === 'no_access' + ? 'needs Full Disk Access' + : (workspace.setup.imessage ?? 'not_configured').replaceAll( + '_', + ' ', + )} .

; +} +// Apple stores message dates since 2001-01-01: nanoseconds on current macOS, +// seconds on old versions. Nanoseconds overflow JS numbers, so SQL converts. +const APPLE_EPOCH_MS = 978_307_200_000; +// Newer macOS versions leave message.text empty and keep the body in an +// archived NSAttributedString. Its first NSString payload is the plain text. +export function attributedBodyText(body: Uint8Array | null): string { + if (!body) return ''; + const buffer = Buffer.from(body); + const marker = buffer.indexOf('NSString'); + if (marker < 0) return ''; + let at = marker + 'NSString'.length + 5; + let length = buffer[at]; + at += 1; + if (length === 0x81) { + length = buffer.readUInt16LE(at); + at += 2; + } else if (length === 0x82) { + length = buffer.readUInt32LE(at); + at += 4; + } + if (length === undefined || at + length > buffer.length) return ''; + return buffer.subarray(at, at + length).toString('utf8'); +} +export function normalizeHandle(value: string) { + const handle = value.trim().toLowerCase(); + if (handle.includes('@')) return handle; + const digits = handle.replace(/[^\d+]/g, ''); + return digits.startsWith('+') ? digits : `+${digits}`; +} +// Dots answer in Markdown; Messages shows plain text. +export function plainText(markdown: string) { + return markdown + .replace(/```[a-z]*\n?/gi, '') + .replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1') + .replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, text: string, url: string) => + text === url ? url : `${text} (${url})`, + ) + .replace(/^#{1,6}\s+/gm, '') + .replace(/(\*\*|__)(.+?)\1/g, '$2') + .replace(/`([^`]+)`/g, '$1') + .replace(/^\s*[-*]\s+/gm, '• ') + .replace(/\n{3,}/g, '\n\n') + .trim(); +} +const sendScript = `on run argv + tell application "Messages" + set targetService to 1st account whose service type = iMessage + send (item 2 of argv) to participant (item 1 of argv) of targetService + end tell +end run`; +export function macMessages( + path = join(homedir(), 'Library', 'Messages', 'chat.db'), + run: typeof execFile = execFile, +): MessagesSource { + // Read-only: OpenDots never writes to the Messages database. + const db = new DatabaseSync(path, { readOnly: true }); + return { + latestId() { + const row = db.prepare('SELECT MAX(ROWID) AS id FROM message').get(); + return Number(row?.id ?? 0); + }, + since(id) { + // One-to-one chats only (chat.style 45); group chats are ignored. + return db + .prepare( + `SELECT m.ROWID AS id, m.text AS text, m.attributedBody AS body, h.id AS handle, + CASE WHEN m.date > 100000000000 THEN m.date / 1000000 ELSE m.date * 1000 END AS dateMs + FROM message m + JOIN handle h ON h.ROWID = m.handle_id + JOIN chat_message_join j ON j.message_id = m.ROWID + JOIN chat c ON c.ROWID = j.chat_id + WHERE m.ROWID > ? AND m.is_from_me = 0 AND c.style = 45 + ORDER BY m.ROWID LIMIT 100`, + ) + .all(id) + .map((row) => ({ + id: Number(row.id), + handle: String(row.handle), + text: + typeof row.text === 'string' && row.text.trim() + ? row.text + : attributedBodyText(row.body as Uint8Array | null), + sentAt: Number(row.dateMs ?? 0) + APPLE_EPOCH_MS, + })); + }, + send(handle, text) { + return new Promise((resolve, reject) => + run( + 'osascript', + ['-e', sendScript, handle, text], + { timeout: 30_000 }, + (error) => (error ? reject(error) : resolve()), + ), + ); + }, + }; +} +export interface BridgeState { + cursor(): number | undefined; + setCursor(id: number): void; + thread(handle: string): string | undefined; + setThread(handle: string, threadId: string): void; +} +export type BridgeStatus = + 'not_configured' | 'unsupported' | 'running' | 'no_access'; +export class IMessageBridge { + status: BridgeStatus = 'not_configured'; + private timer?: ReturnType; + private busy = false; + private controller = new AbortController(); + private allowed: Set; + private sent = new Map(); + constructor( + private options: { + source: () => MessagesSource; + state: BridgeState; + handles: string[]; + dotId: () => string; + createThread: (dotId: string, title: string) => Promise; + turn: ( + threadId: string, + prompt: string, + signal: AbortSignal, + ) => Promise; + paused: () => boolean; + intervalMs?: number; + maxAgeMs?: number; + now?: () => number; + report?: (message: string) => void; + }, + ) { + this.allowed = new Set(options.handles.map(normalizeHandle)); + } + private source?: MessagesSource; + start() { + try { + this.source = this.options.source(); + // First run starts at the newest message: history is never answered. + if (this.options.state.cursor() === undefined) + this.options.state.setCursor(this.source.latestId()); + } catch (error) { + this.status = 'no_access'; + this.report( + `iMessage bridge could not open the Messages database (${safeFailure(error)}). Grant Full Disk Access to the app that runs OpenDots.`, + ); + return; + } + this.status = 'running'; + this.timer = setInterval( + () => void this.poll(), + this.options.intervalMs ?? 3000, + ); + } + stop() { + clearInterval(this.timer); + this.controller.abort(); + } + private report(message: string) { + (this.options.report ?? console.error)(message); + } + private fingerprint(handle: string, text: string) { + return createHash('sha256') + .update(`${normalizeHandle(handle)}\n${text.trim()}`) + .digest('hex'); + } + // Messaging your own Apple ID echoes each reply back as incoming. + private isEcho(handle: string, text: string) { + const now = this.now(); + for (const [key, at] of this.sent) + if (now - at > 120_000) this.sent.delete(key); + return this.sent.has(this.fingerprint(handle, text)); + } + private now() { + return (this.options.now ?? Date.now)(); + } + private async reply(handle: string, text: string) { + const body = plainText(text) || '…'; + this.sent.set(this.fingerprint(handle, body), this.now()); + await this.source!.send(handle, body); + } + async poll() { + if (this.busy || !this.source) return; + this.busy = true; + try { + const cursor = this.options.state.cursor() ?? 0; + const messages = this.source.since(cursor); + if (!messages.length) return; + // Advance first: a failing message must never be answered twice. + this.options.state.setCursor(messages.at(-1)!.id); + const maxAge = this.options.maxAgeMs ?? 60 * 60_000; + const batches = new Map(); + for (const message of messages) { + const handle = normalizeHandle(message.handle); + const text = message.text.trim(); + if ( + !text || + !this.allowed.has(handle) || + this.now() - message.sentAt > maxAge || + this.isEcho(message.handle, text) + ) + continue; + batches.set(message.handle, [ + ...(batches.get(message.handle) ?? []), + text, + ]); + } + for (const [handle, texts] of batches) + await this.answer(handle, texts.join('\n\n')); + } catch (error) { + this.report(`iMessage poll failed: ${safeFailure(error)}`); + } finally { + this.busy = false; + } + } + private async answer(handle: string, text: string) { + const key = normalizeHandle(handle); + try { + if (this.options.paused()) { + await this.reply( + handle, + 'OpenDots is paused. Resume it in the app, then message me again.', + ); + return; + } + if (text.toLowerCase() === '/new') { + const threadId = await this.options.createThread( + this.options.dotId(), + `iMessage · ${key}`, + ); + this.options.state.setThread(key, threadId); + await this.reply(handle, 'Started a new conversation.'); + return; + } + let threadId = this.options.state.thread(key); + if (!threadId) { + threadId = await this.options.createThread( + this.options.dotId(), + `iMessage · ${key}`, + ); + this.options.state.setThread(key, threadId); + } + const signal = AbortSignal.any([ + this.controller.signal, + AbortSignal.timeout(120_000), + ]); + await this.reply(handle, await this.options.turn(threadId, text, signal)); + } catch (error) { + this.report(`iMessage turn failed: ${safeFailure(error)}`); + await this.reply( + handle, + 'I could not finish that. Check OpenDots, then try again.', + ).catch(() => {}); + } + } +} diff --git a/src/server/index.ts b/src/server/index.ts index 6f8978e..6577b67 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -8,6 +8,7 @@ import { Runner } from './runner.js'; import { createApp } from './app.js'; import { WorkspaceStore } from './workspace.js'; import { Platform } from './platform.js'; +import { IMessageBridge, macMessages } from './imessage.js'; import type { PlatformConfig } from './platform-config.js'; const host = process.env.HOST ?? '127.0.0.1'; const port = Number(process.env.PORT ?? 4310); @@ -50,6 +51,12 @@ const config: PlatformConfig = { .map((value) => value.trim()) .filter(Boolean), slackDotId: process.env.SLACK_DOT_ID || undefined, + imessageHandles: (process.env.IMESSAGE_HANDLES ?? '') + .split(',') + .map((value) => value.trim()) + .filter(Boolean), + imessageDotId: process.env.IMESSAGE_DOT_ID || undefined, + imessageDbPath: process.env.IMESSAGE_DB_PATH || undefined, runtimeUrl: `http://${host === '::1' ? '[::1]' : '127.0.0.1'}:${port}/api/copilotkit`, ownerToken, }; @@ -105,9 +112,39 @@ app.use('*', async (c, next) => { app.get('/api/*', (c) => c.json({ error: 'Not found.' }, 404)); app.use('/*', serveStatic({ root: './dist/client' })); app.get('*', serveStatic({ path: './dist/client/index.html' })); +// iMessage runs only where Messages does: OpenDots on the signed-in Mac. +const imessage = config.imessageHandles?.length + ? new IMessageBridge({ + source: () => macMessages(config.imessageDbPath), + state: workspace.imessage, + handles: config.imessageHandles, + dotId: () => { + const id = config.imessageDotId ?? workspace.dots()[0].id; + if (!workspace.dot(id)) + throw new Error('IMESSAGE_DOT_ID does not identify an existing Dot.'); + return id; + }, + createThread: async (dotId, title) => + (await platform.createConversation(dotId, title)).id, + turn: (threadId, prompt, signal) => + platform.turn(threadId, prompt, signal, { opendotsSource: 'imessage' }), + paused: () => store.settings().paused, + }) + : undefined; +if (imessage) platform.imessageStatus = () => imessage.status; const server = serve({ fetch: app.fetch, hostname: host, port }, (info) => { console.log(`OpenDots template listening on http://${host}:${info.port}`); runner.start(); + if (imessage && process.platform !== 'darwin') { + imessage.status = 'unsupported'; + console.error( + 'iMessage needs OpenDots running on a Mac signed in to Messages.', + ); + } else if (imessage && platform.setup().missing.length) + console.error( + 'iMessage is waiting for setup: configure conversations first.', + ); + else imessage?.start(); void platform .start() .catch((error) => @@ -118,7 +155,10 @@ const server = serve({ fetch: app.fetch, hostname: host, port }, (info) => { ); }); const shutdown = createShutdown({ - stopRunner: () => runner.stop(), + stopRunner: () => { + imessage?.stop(); + runner.stop(); + }, stopPlatform: () => platform.stop(), closeServer: () => new Promise((resolve, reject) => diff --git a/src/server/platform-config.ts b/src/server/platform-config.ts index 4d4e8ad..53a9dee 100644 --- a/src/server/platform-config.ts +++ b/src/server/platform-config.ts @@ -20,6 +20,9 @@ export interface PlatformConfig extends WebConfig { slackTeam?: string; slackUsers: string[]; slackDotId?: string; + imessageHandles?: string[]; + imessageDotId?: string; + imessageDbPath?: string; runtimeUrl: string; ownerToken?: string; } diff --git a/src/server/platform.ts b/src/server/platform.ts index 7d19ad4..cbf4bd0 100644 --- a/src/server/platform.ts +++ b/src/server/platform.ts @@ -16,8 +16,11 @@ import { runThreadTurn } from './headless.js'; import { setupStatus, type PlatformConfig } from './platform-config.js'; import { validateRuntimeScope } from './runtime-scope.js'; import { learningSelector } from './learning.js'; +import type { BridgeStatus } from './imessage.js'; +import type { SetupStatus } from '../shared/types.js'; export class Platform { private channelStartupFailed = false; + imessageStatus: () => BridgeStatus = () => 'not_configured'; readonly pages: PageService; readonly computers: ComputerService; readonly intelligence?: CopilotKitIntelligence; @@ -84,13 +87,16 @@ export class Platform { cors: { origin: [] }, }); } - setup() { - return setupStatus( - this.config, - this.handler?.channels?.status().overall ?? - (this.config.slackChannel ? 'setup_required' : 'not_configured'), - this.channelStartupFailed, - ); + setup(): SetupStatus { + return { + ...setupStatus( + this.config, + this.handler?.channels?.status().overall ?? + (this.config.slackChannel ? 'setup_required' : 'not_configured'), + this.channelStartupFailed, + ), + imessage: this.imessageStatus(), + }; } requireReady() { const missing = this.setup().missing; diff --git a/src/server/workspace.ts b/src/server/workspace.ts index c2b9a1b..792ad12 100644 --- a/src/server/workspace.ts +++ b/src/server/workspace.ts @@ -1,4 +1,5 @@ import { ComputerStore } from './computer-store.js'; +import { IMessageStore } from './imessage-store.js'; import { Pages } from './pages.js'; import { DatabaseSync } from 'node:sqlite'; import { mkdirSync } from 'node:fs'; @@ -10,6 +11,7 @@ export class WorkspaceStore { private db: DatabaseSync; readonly pages: Pages; readonly computers: ComputerStore; + readonly imessage: IMessageStore; constructor( path: string, readonly ownerId: string, @@ -50,6 +52,7 @@ export class WorkspaceStore { COMMIT;`); } this.computers = new ComputerStore(this.db); + this.imessage = new IMessageStore(this.db); this.pages = new Pages(this.db, (id) => this.spaces().some((space) => space.id === id), ); diff --git a/src/shared/types.ts b/src/shared/types.ts index c87b548..1b5ad67 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -107,6 +107,7 @@ export interface SetupStatus { browser: boolean; voice: boolean; slack: string; + imessage?: string; missing: string[]; } export interface WorkspaceState { diff --git a/tests/imessage.test.ts b/tests/imessage.test.ts new file mode 100644 index 0000000..6a41489 --- /dev/null +++ b/tests/imessage.test.ts @@ -0,0 +1,243 @@ +import { afterEach, expect, it, vi } from 'vitest'; +import { DatabaseSync } from 'node:sqlite'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { execFile } from 'node:child_process'; +import { + IMessageBridge, + attributedBodyText, + macMessages, + normalizeHandle, + plainText, + type IncomingText, + type MessagesSource, +} from '../src/server/imessage.js'; +import { WorkspaceStore } from '../src/server/workspace.js'; +const cleanup: (() => void)[] = []; +afterEach(() => cleanup.splice(0).forEach((done) => done())); +const APPLE_EPOCH_MS = 978_307_200_000; +const archived = (text: string) => { + const bytes = Buffer.from(text); + const length = + bytes.length < 0x80 + ? Buffer.from([bytes.length]) + : Buffer.from([0x81, bytes.length & 0xff, bytes.length >> 8]); + return Buffer.concat([ + Buffer.from('\x04\x0bstreamtyped\x81\xe8\x03\x84\x01@\x84\x84\x84'), + Buffer.from('NSString'), + Buffer.from([0x01, 0x94, 0x84, 0x01, 0x2b]), + length, + bytes, + Buffer.from([0x86, 0x84]), + ]); +}; +it('decodes archived message bodies, short and long', () => { + expect(attributedBodyText(archived('Hi Dot 👋'))).toBe('Hi Dot 👋'); + const long = 'x'.repeat(300); + expect(attributedBodyText(archived(long))).toBe(long); + expect(attributedBodyText(null)).toBe(''); + expect(attributedBodyText(Buffer.from('garbage'))).toBe(''); +}); +it('normalizes phone and email handles', () => { + expect(normalizeHandle('+1 (555) 010-2000')).toBe('+15550102000'); + expect(normalizeHandle('15550102000')).toBe('+15550102000'); + expect(normalizeHandle(' Me@iCloud.com ')).toBe('me@icloud.com'); +}); +it('turns Markdown replies into readable plain text', () => { + expect( + plainText( + '## Plan\n\n**Book** the [venue](https://v.example)\n- one\n- `two`\n\n\n\nDone', + ), + ).toBe('Plan\n\nBook the venue (https://v.example)\n• one\n• two\n\nDone'); +}); +function chatDb() { + const dir = mkdtempSync(join(tmpdir(), 'opendots-imessage-')); + cleanup.push(() => rmSync(dir, { recursive: true, force: true })); + const path = join(dir, 'chat.db'); + const db = new DatabaseSync(path); + db.exec(`CREATE TABLE handle(ROWID INTEGER PRIMARY KEY, id TEXT); + CREATE TABLE chat(ROWID INTEGER PRIMARY KEY, style INTEGER, chat_identifier TEXT); + CREATE TABLE message(ROWID INTEGER PRIMARY KEY, text TEXT, attributedBody BLOB, handle_id INTEGER, is_from_me INTEGER, date INTEGER); + CREATE TABLE chat_message_join(chat_id INTEGER, message_id INTEGER); + INSERT INTO handle VALUES (1, '+15550102000'), (2, 'friend@example.com'); + INSERT INTO chat VALUES (1, 45, '+15550102000'), (2, 43, 'chat-group');`); + const add = ( + id: number, + handle: number, + chat: number, + text: string | null, + body: Buffer | null = null, + fromMe = 0, + ) => { + db.prepare('INSERT INTO message VALUES (?, ?, ?, ?, ?, ?)').run( + id, + text, + body, + handle, + fromMe, + (Date.UTC(2026, 9, 3) - APPLE_EPOCH_MS) * 1_000_000, + ); + db.prepare('INSERT INTO chat_message_join VALUES (?, ?)').run(chat, id); + }; + add(1, 1, 1, 'old message'); + add(2, 1, 1, null, archived('What is on today?')); + add(3, 2, 2, 'group chatter'); + add(4, 1, 1, 'my own reply', null, 1); + db.close(); + return path; +} +it('reads only incoming one-to-one messages from the Messages database', () => { + const source = macMessages(chatDb()); + expect(source.latestId()).toBe(4); + expect(source.since(1)).toEqual([ + { + id: 2, + handle: '+15550102000', + text: 'What is on today?', + sentAt: Date.UTC(2026, 9, 3), + }, + ]); +}); +it('sends through Messages with the reply passed as an argument, not script', async () => { + const run = vi.fn((_file, _args, _options, done) => done(null)) as never; + const source = macMessages(chatDb(), run as typeof execFile); + await source.send('+15550102000', '"); do shell script "rm -rf ~'); + const [file, args] = (run as unknown as ReturnType).mock + .calls[0]; + expect(file).toBe('osascript'); + expect(args.slice(2)).toEqual([ + '+15550102000', + '"); do shell script "rm -rf ~', + ]); + expect(args[1]).not.toContain('rm -rf'); +}); +function bridge( + options: { paused?: boolean; turn?: () => Promise } = {}, +) { + const workspace = new WorkspaceStore(':memory:', 'owner'); + cleanup.push(() => workspace.close()); + const inbox: IncomingText[] = []; + const outbox: [string, string][] = []; + let next = 10; + const now = Date.UTC(2026, 9, 3, 12); + const source: MessagesSource = { + latestId: () => 9, + since: (id) => inbox.filter((message) => message.id > id), + send: async (handle, text) => { + outbox.push([handle, text]); + }, + }; + const threads: string[] = []; + const turn = vi.fn< + (threadId: string, prompt: string, signal: AbortSignal) => Promise + >(options.turn ?? (async () => '**Sure.** On it.')); + const reports: string[] = []; + const instance = new IMessageBridge({ + source: () => source, + state: workspace.imessage, + handles: ['+1 555 010 2000'], + dotId: () => workspace.dots()[0].id, + createThread: async () => { + threads.push(`thread-${threads.length + 1}`); + return threads.at(-1)!; + }, + turn, + paused: () => options.paused ?? false, + now: () => now, + report: (message) => reports.push(message), + }); + const receive = (handle: string, text: string, age = 0) => + inbox.push({ id: next++, handle, text, sentAt: now - age }); + return { instance, workspace, receive, outbox, turn, threads, reports }; +} +it('answers allowlisted senders in a persistent conversation, ignoring history and strangers', async () => { + const f = bridge(); + f.instance.start(); + expect(f.instance.status).toBe('running'); + expect(f.workspace.imessage.cursor()).toBe(9); + f.receive('+15550102000', 'Plan my day'); + f.receive('+15550102000', 'Include the gym'); + f.receive('+19990000000', 'Hi, I am a stranger'); + await f.instance.poll(); + expect(f.turn).toHaveBeenCalledOnce(); + expect(f.turn.mock.calls[0].slice(0, 2)).toEqual([ + 'thread-1', + 'Plan my day\n\nInclude the gym', + ]); + expect(f.outbox).toEqual([['+15550102000', 'Sure. On it.']]); + f.receive('+15550102000', 'Thanks'); + await f.instance.poll(); + expect(f.turn.mock.calls[1][0]).toBe('thread-1'); + expect(f.threads).toEqual(['thread-1']); + expect(f.workspace.imessage.cursor()).toBe(13); + f.instance.stop(); +}); +it('skips stale messages and its own echoed replies', async () => { + const f = bridge(); + f.instance.start(); + f.receive('+15550102000', 'From two hours ago', 2 * 60 * 60_000); + f.receive('+15550102000', 'Hello'); + await f.instance.poll(); + expect(f.turn).toHaveBeenCalledOnce(); + // Messaging your own Apple ID: the reply arrives back as incoming. + f.receive('+15550102000', 'Sure. On it.'); + await f.instance.poll(); + expect(f.turn).toHaveBeenCalledOnce(); + f.instance.stop(); +}); +it('starts fresh on /new, explains a pause, and recovers from failed turns once', async () => { + const paused = bridge({ paused: true }); + paused.instance.start(); + paused.receive('+15550102000', 'Hello?'); + await paused.instance.poll(); + expect(paused.turn).not.toHaveBeenCalled(); + expect(paused.outbox[0][1]).toContain('paused'); + paused.instance.stop(); + const failing = bridge({ + turn: async () => { + throw new Error('model down'); + }, + }); + failing.instance.start(); + failing.receive('+15550102000', 'Hello?'); + failing.receive('+15550102000', '/new'); + await failing.instance.poll(); + expect(failing.outbox).toEqual([ + [ + '+15550102000', + 'I could not finish that. Check OpenDots, then try again.', + ], + ]); + expect(failing.reports.join()).not.toContain('model down'); + await failing.instance.poll(); + expect(failing.turn).toHaveBeenCalledOnce(); + failing.receive('+15550102000', '/NEW'); + await failing.instance.poll(); + expect(failing.outbox.at(-1)).toEqual([ + '+15550102000', + 'Started a new conversation.', + ]); + expect(failing.workspace.imessage.thread('+15550102000')).toBe('thread-2'); + failing.instance.stop(); +}); +it('reports missing Full Disk Access instead of crashing', () => { + const workspace = new WorkspaceStore(':memory:', 'owner'); + cleanup.push(() => workspace.close()); + const reports: string[] = []; + const instance = new IMessageBridge({ + source: () => { + throw new Error('unable to open database file'); + }, + state: workspace.imessage, + handles: ['+15550102000'], + dotId: () => 'dot', + createThread: async () => 'thread', + turn: async () => '', + paused: () => false, + report: (message) => reports.push(message), + }); + instance.start(); + expect(instance.status).toBe('no_access'); + expect(reports[0]).toContain('Full Disk Access'); +});