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');
+});