From cbac34347b62b366ccfa337ffb7e6df32719ceba Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 01:19:13 +0100 Subject: [PATCH] feat: add imessage rpc adapter --- src/cli/cron-cli.ts | 4 +- src/cli/deps.ts | 3 + src/cli/program.ts | 10 +- src/commands/agent.test.ts | 1 + src/commands/onboard-interactive.ts | 16 ++ src/commands/onboard-providers.ts | 141 ++++++++++++- src/commands/onboard-types.ts | 7 +- src/commands/send.test.ts | 18 ++ src/commands/send.ts | 25 +++ src/config/sessions.ts | 8 +- src/cron/isolated-agent.test.ts | 6 + src/cron/types.ts | 8 +- src/imessage/client.ts | 213 +++++++++++++++++++ src/imessage/index.ts | 3 + src/imessage/monitor.test.ts | 194 ++++++++++++++++++ src/imessage/monitor.ts | 303 ++++++++++++++++++++++++++++ src/imessage/probe.ts | 33 +++ src/imessage/send.test.ts | 64 ++++++ src/imessage/send.ts | 127 ++++++++++++ src/imessage/targets.test.ts | 56 +++++ src/imessage/targets.ts | 172 ++++++++++++++++ src/infra/heartbeat-runner.ts | 42 +++- src/infra/provider-summary.ts | 12 ++ 23 files changed, 1451 insertions(+), 15 deletions(-) create mode 100644 src/imessage/client.ts create mode 100644 src/imessage/index.ts create mode 100644 src/imessage/monitor.test.ts create mode 100644 src/imessage/monitor.ts create mode 100644 src/imessage/probe.ts create mode 100644 src/imessage/send.test.ts create mode 100644 src/imessage/send.ts create mode 100644 src/imessage/targets.test.ts create mode 100644 src/imessage/targets.ts diff --git a/src/cli/cron-cli.ts b/src/cli/cron-cli.ts index 903f43b00..af844cce5 100644 --- a/src/cli/cron-cli.ts +++ b/src/cli/cron-cli.ts @@ -155,7 +155,7 @@ export function registerCronCli(program: Command) { .option("--deliver", "Deliver agent output", false) .option( "--channel ", - "Delivery channel (last|whatsapp|telegram|discord|signal)", + "Delivery channel (last|whatsapp|telegram|discord|signal|imessage)", "last", ) .option( @@ -414,7 +414,7 @@ export function registerCronCli(program: Command) { .option("--deliver", "Deliver agent output", false) .option( "--channel ", - "Delivery channel (last|whatsapp|telegram|discord|signal)", + "Delivery channel (last|whatsapp|telegram|discord|signal|imessage)", ) .option( "--to ", diff --git a/src/cli/deps.ts b/src/cli/deps.ts index b2bd241f5..441617377 100644 --- a/src/cli/deps.ts +++ b/src/cli/deps.ts @@ -1,4 +1,5 @@ import { sendMessageDiscord } from "../discord/send.js"; +import { sendMessageIMessage } from "../imessage/send.js"; import { logWebSelfId, sendMessageWhatsApp } from "../providers/web/index.js"; import { sendMessageSignal } from "../signal/send.js"; import { sendMessageTelegram } from "../telegram/send.js"; @@ -8,6 +9,7 @@ export type CliDeps = { sendMessageTelegram: typeof sendMessageTelegram; sendMessageDiscord: typeof sendMessageDiscord; sendMessageSignal: typeof sendMessageSignal; + sendMessageIMessage: typeof sendMessageIMessage; }; export function createDefaultDeps(): CliDeps { @@ -16,6 +18,7 @@ export function createDefaultDeps(): CliDeps { sendMessageTelegram, sendMessageDiscord, sendMessageSignal, + sendMessageIMessage, }; } diff --git a/src/cli/program.ts b/src/cli/program.ts index 11a41010c..b53f4137d 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -282,10 +282,12 @@ export function buildProgram() { program .command("send") - .description("Send a message (WhatsApp Web, Telegram bot, Discord, Signal)") + .description( + "Send a message (WhatsApp Web, Telegram bot, Discord, Signal, iMessage)", + ) .requiredOption( "-t, --to ", - "Recipient: E.164 for WhatsApp/Signal, Telegram chat id/@username, or Discord channel/user", + "Recipient: E.164 for WhatsApp/Signal, Telegram chat id/@username, Discord channel/user, or iMessage handle/chat_id", ) .requiredOption("-m, --message ", "Message body") .option( @@ -294,7 +296,7 @@ export function buildProgram() { ) .option( "--provider ", - "Delivery provider: whatsapp|telegram|discord|signal (default: whatsapp)", + "Delivery provider: whatsapp|telegram|discord|signal|imessage (default: whatsapp)", ) .option("--dry-run", "Print payload and skip sending", false) .option("--json", "Output result as JSON", false) @@ -337,7 +339,7 @@ Examples: .option("--verbose ", "Persist agent verbose level for the session") .option( "--provider ", - "Delivery provider: whatsapp|telegram|discord|signal (default: whatsapp)", + "Delivery provider: whatsapp|telegram|discord|signal|imessage (default: whatsapp)", ) .option( "--deliver", diff --git a/src/commands/agent.test.ts b/src/commands/agent.test.ts index b9bacd362..d36bf01ca 100644 --- a/src/commands/agent.test.ts +++ b/src/commands/agent.test.ts @@ -212,6 +212,7 @@ describe("agentCommand", () => { .mockResolvedValue({ messageId: "t1", chatId: "123" }), sendMessageDiscord: vi.fn(), sendMessageSignal: vi.fn(), + sendMessageIMessage: vi.fn(), }; const prevTelegramToken = process.env.TELEGRAM_BOT_TOKEN; diff --git a/src/commands/onboard-interactive.ts b/src/commands/onboard-interactive.ts index c0fd68eb0..758ddaf61 100644 --- a/src/commands/onboard-interactive.ts +++ b/src/commands/onboard-interactive.ts @@ -20,6 +20,7 @@ import { import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js"; import { resolveGatewayProgramArguments } from "../daemon/program-args.js"; import { resolveGatewayService } from "../daemon/service.js"; +import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js"; import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; import { resolveUserPath, sleep } from "../utils.js"; @@ -478,5 +479,20 @@ export async function runInteractiveOnboarding( "Optional apps", ); + note( + (() => { + const tailnetIPv4 = pickPrimaryTailnetIPv4(); + const host = + bind === "tailnet" || (bind === "auto" && tailnetIPv4) + ? (tailnetIPv4 ?? "127.0.0.1") + : "127.0.0.1"; + return [ + `Control UI: http://${host}:${port}/`, + `Gateway WS: ws://${host}:${port}`, + ].join("\n"); + })(), + "Open the Control UI", + ); + outro("Onboarding complete."); } diff --git a/src/commands/onboard-providers.ts b/src/commands/onboard-providers.ts index b23c9e316..1876d8dbe 100644 --- a/src/commands/onboard-providers.ts +++ b/src/commands/onboard-providers.ts @@ -1,12 +1,13 @@ import fs from "node:fs/promises"; import path from "node:path"; -import { confirm, multiselect, note, text } from "@clack/prompts"; +import { confirm, multiselect, note, select, text } from "@clack/prompts"; import chalk from "chalk"; import type { ClawdisConfig } from "../config/config.js"; import { loginWeb } from "../provider-web.js"; import type { RuntimeEnv } from "../runtime.js"; +import { normalizeE164 } from "../utils.js"; import { resolveWebAuthDir } from "../web/session.js"; import { detectBinary, guardCancel } from "./onboard-helpers.js"; import type { ProviderChoice } from "./onboard-types.js"; @@ -33,6 +34,7 @@ function noteProviderPrimer(): void { "Telegram: Bot API (token from @BotFather), replies via your bot.", "Discord: Bot token from Discord Developer Portal; invite bot to your server.", "Signal: signal-cli as a linked device (recommended: separate bot number).", + "iMessage: local imsg CLI (JSON-RPC over stdio) reading Messages DB.", ].join("\n"), "How providers work", ); @@ -79,6 +81,11 @@ export async function setupProviders( ); const signalCliPath = cfg.signal?.cliPath ?? "signal-cli"; const signalCliDetected = await detectBinary(signalCliPath); + const imessageConfigured = Boolean( + cfg.imessage?.cliPath || cfg.imessage?.dbPath || cfg.imessage?.allowFrom, + ); + const imessageCliPath = cfg.imessage?.cliPath ?? "imsg"; + const imessageCliDetected = await detectBinary(imessageCliPath); note( [ @@ -100,9 +107,17 @@ export async function setupProviders( ? chalk.green("configured") : chalk.yellow("needs setup") }`, + `iMessage: ${ + imessageConfigured + ? chalk.green("configured") + : chalk.yellow("needs setup") + }`, `signal-cli: ${ signalCliDetected ? chalk.green("found") : chalk.red("missing") } (${signalCliPath})`, + `imsg: ${ + imessageCliDetected ? chalk.green("found") : chalk.red("missing") + } (${imessageCliPath})`, ].join("\n"), "Provider status", ); @@ -142,6 +157,11 @@ export async function setupProviders( label: "Signal (signal-cli)", hint: signalCliDetected ? "signal-cli found" : "signal-cli missing", }, + { + value: "imessage", + label: "iMessage (imsg)", + hint: imessageCliDetected ? "imsg found" : "imsg missing", + }, ], }), runtime, @@ -177,6 +197,71 @@ export async function setupProviders( } else if (!whatsappLinked) { note("Run `clawdis login` later to link WhatsApp.", "WhatsApp"); } + + const existingAllowFrom = cfg.routing?.allowFrom ?? []; + if (existingAllowFrom.length === 0) { + note( + [ + "WhatsApp direct chats are gated by `routing.allowFrom`.", + 'Default (unset) = self-chat only; use "*" to allow anyone.', + ].join("\n"), + "Allowlist (recommended)", + ); + const mode = guardCancel( + await select({ + message: "Who can trigger the bot via WhatsApp?", + options: [ + { value: "self", label: "Self-chat only (default)" }, + { value: "list", label: "Specific numbers (recommended)" }, + { value: "any", label: "Anyone (*)" }, + ], + }), + runtime, + ) as "self" | "list" | "any"; + + if (mode === "any") { + next = { + ...next, + routing: { ...next.routing, allowFrom: ["*"] }, + }; + } else if (mode === "list") { + const allowRaw = guardCancel( + await text({ + message: "Allowed sender numbers (comma-separated, E.164)", + placeholder: "+15555550123, +447700900123", + validate: (value) => { + const raw = String(value ?? "").trim(); + if (!raw) return "Required"; + const parts = raw + .split(/[\n,;]+/g) + .map((p) => p.trim()) + .filter(Boolean); + if (parts.length === 0) return "Required"; + for (const part of parts) { + if (part === "*") continue; + const normalized = normalizeE164(part); + if (!normalized) return `Invalid number: ${part}`; + } + return undefined; + }, + }), + runtime, + ); + + const parts = String(allowRaw) + .split(/[\n,;]+/g) + .map((p) => p.trim()) + .filter(Boolean); + const normalized = parts.map((part) => + part === "*" ? "*" : normalizeE164(part), + ); + const unique = [...new Set(normalized.filter(Boolean))]; + next = { + ...next, + routing: { ...next.routing, allowFrom: unique }, + }; + } + } } if (selection.includes("telegram")) { @@ -395,6 +480,44 @@ export async function setupProviders( ); } + if (selection.includes("imessage")) { + let resolvedCliPath = imessageCliPath; + if (!imessageCliDetected) { + const entered = guardCancel( + await text({ + message: "imsg CLI path", + initialValue: resolvedCliPath, + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + runtime, + ); + resolvedCliPath = String(entered).trim(); + if (!resolvedCliPath) { + note("imsg CLI path required to enable iMessage.", "iMessage"); + } + } + + if (resolvedCliPath) { + next = { + ...next, + imessage: { + ...next.imessage, + enabled: true, + cliPath: resolvedCliPath, + }, + }; + } + + note( + [ + "Ensure Clawdis has Full Disk Access to Messages DB.", + "Grant Automation permission for Messages when prompted.", + "List chats with: imsg chats --limit 20", + ].join("\n"), + "iMessage next steps", + ); + } + if (options?.allowDisable) { if (!selection.includes("telegram") && telegramConfigured) { const disable = guardCancel( @@ -443,6 +566,22 @@ export async function setupProviders( }; } } + + if (!selection.includes("imessage") && imessageConfigured) { + const disable = guardCancel( + await confirm({ + message: "Disable iMessage provider?", + initialValue: false, + }), + runtime, + ); + if (disable) { + next = { + ...next, + imessage: { ...next.imessage, enabled: false }, + }; + } + } } return next; diff --git a/src/commands/onboard-types.ts b/src/commands/onboard-types.ts index 876f3514f..5baf8b0fa 100644 --- a/src/commands/onboard-types.ts +++ b/src/commands/onboard-types.ts @@ -5,7 +5,12 @@ export type ResetScope = "config" | "config+creds+sessions" | "full"; export type GatewayBind = "loopback" | "lan" | "tailnet" | "auto"; export type TailscaleMode = "off" | "serve" | "funnel"; export type NodeManagerChoice = "npm" | "pnpm" | "bun"; -export type ProviderChoice = "whatsapp" | "telegram" | "discord" | "signal"; +export type ProviderChoice = + | "whatsapp" + | "telegram" + | "discord" + | "signal" + | "imessage"; export type OnboardOptions = { mode?: OnboardMode; diff --git a/src/commands/send.test.ts b/src/commands/send.test.ts index f62a18457..e7d05e754 100644 --- a/src/commands/send.test.ts +++ b/src/commands/send.test.ts @@ -42,6 +42,7 @@ const makeDeps = (overrides: Partial = {}): CliDeps => ({ sendMessageTelegram: vi.fn(), sendMessageDiscord: vi.fn(), sendMessageSignal: vi.fn(), + sendMessageIMessage: vi.fn(), ...overrides, }); @@ -151,6 +152,23 @@ describe("sendCommand", () => { expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled(); }); + it("routes to imessage provider", async () => { + const deps = makeDeps({ + sendMessageIMessage: vi.fn().mockResolvedValue({ messageId: "i1" }), + }); + await sendCommand( + { to: "chat_id:42", message: "hi", provider: "imessage" }, + deps, + runtime, + ); + expect(deps.sendMessageIMessage).toHaveBeenCalledWith( + "chat_id:42", + "hi", + expect.objectContaining({ mediaUrl: undefined }), + ); + expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled(); + }); + it("emits json output", async () => { callGatewayMock.mockResolvedValueOnce({ messageId: "direct2" }); const deps = makeDeps(); diff --git a/src/commands/send.ts b/src/commands/send.ts index 6f98c772e..fa431063e 100644 --- a/src/commands/send.ts +++ b/src/commands/send.ts @@ -108,6 +108,31 @@ export async function sendCommand( return; } + if (provider === "imessage" || provider === "imsg") { + const result = await deps.sendMessageIMessage(opts.to, opts.message, { + mediaUrl: opts.media, + }); + runtime.log( + success(`✅ Sent via iMessage. Message ID: ${result.messageId}`), + ); + if (opts.json) { + runtime.log( + JSON.stringify( + { + provider: "imessage", + via: "direct", + to: opts.to, + messageId: result.messageId, + mediaUrl: opts.media ?? null, + }, + null, + 2, + ), + ); + } + return; + } + // Always send via gateway over WS to avoid multi-session corruption. const sendViaGateway = async () => callGateway<{ diff --git a/src/config/sessions.ts b/src/config/sessions.ts index 1f653d74b..449638101 100644 --- a/src/config/sessions.ts +++ b/src/config/sessions.ts @@ -27,7 +27,13 @@ export type SessionEntry = { totalTokens?: number; model?: string; contextTokens?: number; - lastChannel?: "whatsapp" | "telegram" | "discord" | "signal" | "webchat"; + lastChannel?: + | "whatsapp" + | "telegram" + | "discord" + | "signal" + | "imessage" + | "webchat"; lastTo?: string; skillsSnapshot?: SessionSkillSnapshot; }; diff --git a/src/cron/isolated-agent.test.ts b/src/cron/isolated-agent.test.ts index 89adfeed0..10bb46c74 100644 --- a/src/cron/isolated-agent.test.ts +++ b/src/cron/isolated-agent.test.ts @@ -97,6 +97,7 @@ describe("runCronIsolatedAgentTurn", () => { sendMessageTelegram: vi.fn(), sendMessageDiscord: vi.fn(), sendMessageSignal: vi.fn(), + sendMessageIMessage: vi.fn(), }; vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ payloads: [{ text: "first" }, { text: " " }, { text: " last " }], @@ -128,6 +129,7 @@ describe("runCronIsolatedAgentTurn", () => { sendMessageTelegram: vi.fn(), sendMessageDiscord: vi.fn(), sendMessageSignal: vi.fn(), + sendMessageIMessage: vi.fn(), }; const long = "a".repeat(2001); vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ @@ -160,6 +162,7 @@ describe("runCronIsolatedAgentTurn", () => { sendMessageTelegram: vi.fn(), sendMessageDiscord: vi.fn(), sendMessageSignal: vi.fn(), + sendMessageIMessage: vi.fn(), }; vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ payloads: [{ text: "hello" }], @@ -199,6 +202,7 @@ describe("runCronIsolatedAgentTurn", () => { sendMessageTelegram: vi.fn(), sendMessageDiscord: vi.fn(), sendMessageSignal: vi.fn(), + sendMessageIMessage: vi.fn(), }; vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ payloads: [{ text: "hello" }], @@ -240,6 +244,7 @@ describe("runCronIsolatedAgentTurn", () => { }), sendMessageDiscord: vi.fn(), sendMessageSignal: vi.fn(), + sendMessageIMessage: vi.fn(), }; vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ payloads: [{ text: "hello from cron" }], @@ -294,6 +299,7 @@ describe("runCronIsolatedAgentTurn", () => { channelId: "chan", }), sendMessageSignal: vi.fn(), + sendMessageIMessage: vi.fn(), }; vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ payloads: [{ text: "hello from cron" }], diff --git a/src/cron/types.ts b/src/cron/types.ts index d3bfa44aa..ab1cf99e7 100644 --- a/src/cron/types.ts +++ b/src/cron/types.ts @@ -14,7 +14,13 @@ export type CronPayload = thinking?: string; timeoutSeconds?: number; deliver?: boolean; - channel?: "last" | "whatsapp" | "telegram" | "discord" | "signal"; + channel?: + | "last" + | "whatsapp" + | "telegram" + | "discord" + | "signal" + | "imessage"; to?: string; bestEffortDeliver?: boolean; }; diff --git a/src/imessage/client.ts b/src/imessage/client.ts new file mode 100644 index 000000000..e4604f9b8 --- /dev/null +++ b/src/imessage/client.ts @@ -0,0 +1,213 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { createInterface, type Interface } from "node:readline"; + +import type { RuntimeEnv } from "../runtime.js"; +import { resolveUserPath } from "../utils.js"; + +export type IMessageRpcError = { + code?: number; + message?: string; + data?: unknown; +}; + +export type IMessageRpcResponse = { + jsonrpc?: string; + id?: string | number | null; + result?: T; + error?: IMessageRpcError; + method?: string; + params?: unknown; +}; + +export type IMessageRpcNotification = { + method: string; + params?: unknown; +}; + +export type IMessageRpcClientOptions = { + cliPath?: string; + dbPath?: string; + runtime?: RuntimeEnv; + onNotification?: (msg: IMessageRpcNotification) => void; +}; + +type PendingRequest = { + resolve: (value: unknown) => void; + reject: (error: Error) => void; + timer?: NodeJS.Timeout; +}; + +export class IMessageRpcClient { + private readonly cliPath: string; + private readonly dbPath?: string; + private readonly runtime?: RuntimeEnv; + private readonly onNotification?: (msg: IMessageRpcNotification) => void; + private readonly pending = new Map(); + private readonly closed: Promise; + private closedResolve: (() => void) | null = null; + private child: ChildProcessWithoutNullStreams | null = null; + private reader: Interface | null = null; + private nextId = 1; + + constructor(opts: IMessageRpcClientOptions = {}) { + this.cliPath = opts.cliPath?.trim() || "imsg"; + this.dbPath = opts.dbPath?.trim() ? resolveUserPath(opts.dbPath) : undefined; + this.runtime = opts.runtime; + this.onNotification = opts.onNotification; + this.closed = new Promise((resolve) => { + this.closedResolve = resolve; + }); + } + + async start(): Promise { + if (this.child) return; + const args = ["rpc"]; + if (this.dbPath) { + args.push("--db", this.dbPath); + } + const child = spawn(this.cliPath, args, { + stdio: ["pipe", "pipe", "pipe"], + }); + this.child = child; + this.reader = createInterface({ input: child.stdout }); + + this.reader.on("line", (line) => { + const trimmed = line.trim(); + if (!trimmed) return; + this.handleLine(trimmed); + }); + + child.stderr?.on("data", (chunk) => { + const lines = chunk.toString().split(/\r?\n/); + for (const line of lines) { + if (!line.trim()) continue; + this.runtime?.error?.(`imsg rpc: ${line.trim()}`); + } + }); + + child.on("error", (err) => { + this.failAll(err instanceof Error ? err : new Error(String(err))); + this.closedResolve?.(); + }); + + child.on("close", (code, signal) => { + if (code !== 0 && code !== null) { + const reason = signal ? `signal ${signal}` : `code ${code}`; + this.failAll(new Error(`imsg rpc exited (${reason})`)); + } else { + this.failAll(new Error("imsg rpc closed")); + } + this.closedResolve?.(); + }); + } + + async stop(): Promise { + if (!this.child) return; + this.reader?.close(); + this.reader = null; + this.child.stdin?.end(); + const child = this.child; + this.child = null; + + await Promise.race([ + this.closed, + new Promise((resolve) => { + setTimeout(() => { + if (!child.killed) child.kill("SIGTERM"); + resolve(); + }, 500); + }), + ]); + } + + async waitForClose(): Promise { + await this.closed; + } + + async request( + method: string, + params?: Record, + opts?: { timeoutMs?: number }, + ): Promise { + if (!this.child || !this.child.stdin) { + throw new Error("imsg rpc not running"); + } + const id = this.nextId++; + const payload = { + jsonrpc: "2.0", + id, + method, + params: params ?? {}, + }; + const line = `${JSON.stringify(payload)}\n`; + const timeoutMs = opts?.timeoutMs ?? 10_000; + + const response = new Promise((resolve, reject) => { + const key = String(id); + const timer = + timeoutMs > 0 + ? setTimeout(() => { + this.pending.delete(key); + reject(new Error(`imsg rpc timeout (${method})`)); + }, timeoutMs) + : undefined; + this.pending.set(key, { + resolve: (value) => resolve(value as T), + reject, + timer, + }); + }); + + this.child.stdin.write(line); + return await response; + } + + private handleLine(line: string) { + let parsed: IMessageRpcResponse; + try { + parsed = JSON.parse(line) as IMessageRpcResponse; + } catch (err) { + this.runtime?.error?.(`imsg rpc: failed to parse ${line}`); + return; + } + + if (parsed.id !== undefined && parsed.id !== null) { + const key = String(parsed.id); + const pending = this.pending.get(key); + if (!pending) return; + if (pending.timer) clearTimeout(pending.timer); + this.pending.delete(key); + + if (parsed.error) { + const msg = parsed.error.message ?? "imsg rpc error"; + pending.reject(new Error(msg)); + return; + } + pending.resolve(parsed.result); + return; + } + + if (parsed.method) { + this.onNotification?.({ + method: parsed.method, + params: parsed.params, + }); + } + } + + private failAll(err: Error) { + for (const [key, pending] of this.pending.entries()) { + if (pending.timer) clearTimeout(pending.timer); + pending.reject(err); + this.pending.delete(key); + } + } +} + +export async function createIMessageRpcClient( + opts: IMessageRpcClientOptions = {}, +): Promise { + const client = new IMessageRpcClient(opts); + await client.start(); + return client; +} diff --git a/src/imessage/index.ts b/src/imessage/index.ts new file mode 100644 index 000000000..d921f2ed7 --- /dev/null +++ b/src/imessage/index.ts @@ -0,0 +1,3 @@ +export { monitorIMessageProvider } from "./monitor.js"; +export { probeIMessage } from "./probe.js"; +export { sendMessageIMessage } from "./send.js"; diff --git a/src/imessage/monitor.test.ts b/src/imessage/monitor.test.ts new file mode 100644 index 000000000..9540c7fc8 --- /dev/null +++ b/src/imessage/monitor.test.ts @@ -0,0 +1,194 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { monitorIMessageProvider } from "./monitor.js"; + +const requestMock = vi.fn(); +const stopMock = vi.fn(); +const sendMock = vi.fn(); +const replyMock = vi.fn(); +const updateLastRouteMock = vi.fn(); + +let config: Record = {}; +let notificationHandler: ((msg: { method: string; params?: unknown }) => void) | undefined; +let closeResolve: (() => void) | undefined; + +vi.mock("../config/config.js", () => ({ + loadConfig: () => config, +})); + +vi.mock("../auto-reply/reply.js", () => ({ + getReplyFromConfig: (...args: unknown[]) => replyMock(...args), +})); + +vi.mock("./send.js", () => ({ + sendMessageIMessage: (...args: unknown[]) => sendMock(...args), +})); + +vi.mock("../config/sessions.js", () => ({ + resolveStorePath: vi.fn(() => "/tmp/clawdis-sessions.json"), + updateLastRoute: (...args: unknown[]) => updateLastRouteMock(...args), +})); + +vi.mock("./client.js", () => ({ + createIMessageRpcClient: vi.fn(async (opts: { onNotification?: typeof notificationHandler }) => { + notificationHandler = opts.onNotification; + return { + request: (...args: unknown[]) => requestMock(...args), + waitForClose: () => + new Promise((resolve) => { + closeResolve = resolve; + }), + stop: (...args: unknown[]) => stopMock(...args), + }; + }), +})); + +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + +async function waitForSubscribe() { + for (let i = 0; i < 5; i += 1) { + if (requestMock.mock.calls.some((call) => call[0] === "watch.subscribe")) return; + await flush(); + } +} + +beforeEach(() => { + config = { + imessage: {}, + session: { mainKey: "main" }, + routing: { + groupChat: { mentionPatterns: ["@clawd"], requireMention: true }, + allowFrom: [], + }, + }; + requestMock.mockReset().mockImplementation((method: string) => { + if (method === "watch.subscribe") return Promise.resolve({ subscription: 1 }); + return Promise.resolve({}); + }); + stopMock.mockReset().mockResolvedValue(undefined); + sendMock.mockReset().mockResolvedValue({ messageId: "ok" }); + replyMock.mockReset().mockResolvedValue({ text: "ok" }); + updateLastRouteMock.mockReset(); + notificationHandler = undefined; + closeResolve = undefined; +}); + +describe("monitorIMessageProvider", () => { + it("skips group messages without a mention by default", async () => { + const run = monitorIMessageProvider(); + await waitForSubscribe(); + + notificationHandler?.({ + method: "message", + params: { + message: { + id: 1, + chat_id: 99, + sender: "+15550001111", + is_from_me: false, + text: "hello group", + is_group: true, + }, + }, + }); + + await flush(); + closeResolve?.(); + await run; + + expect(replyMock).not.toHaveBeenCalled(); + expect(sendMock).not.toHaveBeenCalled(); + }); + + it("delivers group replies when mentioned", async () => { + replyMock.mockResolvedValueOnce({ text: "yo" }); + const run = monitorIMessageProvider(); + await waitForSubscribe(); + + notificationHandler?.({ + method: "message", + params: { + message: { + id: 2, + chat_id: 42, + sender: "+15550002222", + is_from_me: false, + text: "@clawd ping", + is_group: true, + chat_name: "Lobster Squad", + participants: ["+1555", "+1556"], + }, + }, + }); + + await flush(); + closeResolve?.(); + await run; + + expect(sendMock).toHaveBeenCalledWith( + "chat_id:42", + "yo", + expect.objectContaining({ client: expect.any(Object) }), + ); + }); + + it("honors allowFrom entries", async () => { + config = { + ...config, + imessage: { allowFrom: ["chat_id:101"] }, + }; + const run = monitorIMessageProvider(); + await waitForSubscribe(); + + notificationHandler?.({ + method: "message", + params: { + message: { + id: 3, + chat_id: 202, + sender: "+15550003333", + is_from_me: false, + text: "@clawd hi", + is_group: true, + }, + }, + }); + + await flush(); + closeResolve?.(); + await run; + + expect(replyMock).not.toHaveBeenCalled(); + }); + + it("updates last route with chat_id for direct messages", async () => { + replyMock.mockResolvedValueOnce({ text: "ok" }); + const run = monitorIMessageProvider(); + await waitForSubscribe(); + + notificationHandler?.({ + method: "message", + params: { + message: { + id: 4, + chat_id: 7, + sender: "+15550004444", + is_from_me: false, + text: "hey", + is_group: false, + }, + }, + }); + + await flush(); + closeResolve?.(); + await run; + + expect(updateLastRouteMock).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "imessage", + to: "chat_id:7", + }), + ); + }); +}); diff --git a/src/imessage/monitor.ts b/src/imessage/monitor.ts new file mode 100644 index 000000000..655b738dc --- /dev/null +++ b/src/imessage/monitor.ts @@ -0,0 +1,303 @@ +import { chunkText } from "../auto-reply/chunk.js"; +import { formatAgentEnvelope } from "../auto-reply/envelope.js"; +import { getReplyFromConfig } from "../auto-reply/reply.js"; +import type { ReplyPayload } from "../auto-reply/types.js"; +import { loadConfig } from "../config/config.js"; +import { resolveStorePath, updateLastRoute } from "../config/sessions.js"; +import { danger, isVerbose, logVerbose } from "../globals.js"; +import { mediaKindFromMime } from "../media/constants.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { createIMessageRpcClient } from "./client.js"; +import { sendMessageIMessage } from "./send.js"; +import { + formatIMessageChatTarget, + isAllowedIMessageSender, + normalizeIMessageHandle, +} from "./targets.js"; + +type IMessageAttachment = { + original_path?: string | null; + mime_type?: string | null; + missing?: boolean | null; +}; + +type IMessagePayload = { + id?: number | null; + chat_id?: number | null; + sender?: string | null; + is_from_me?: boolean | null; + text?: string | null; + created_at?: string | null; + attachments?: IMessageAttachment[] | null; + chat_identifier?: string | null; + chat_guid?: string | null; + chat_name?: string | null; + participants?: string[] | null; + is_group?: boolean | null; +}; + +export type MonitorIMessageOpts = { + runtime?: RuntimeEnv; + abortSignal?: AbortSignal; + cliPath?: string; + dbPath?: string; + allowFrom?: Array; + includeAttachments?: boolean; + mediaMaxMb?: number; + requireMention?: boolean; +}; + +function resolveRuntime(opts: MonitorIMessageOpts): RuntimeEnv { + return ( + opts.runtime ?? { + log: console.log, + error: console.error, + exit: (code: number): never => { + throw new Error(`exit ${code}`); + }, + } + ); +} + +function resolveAllowFrom(opts: MonitorIMessageOpts): string[] { + const cfg = loadConfig(); + const raw = + opts.allowFrom ?? cfg.imessage?.allowFrom ?? cfg.routing?.allowFrom ?? []; + return raw.map((entry) => String(entry).trim()).filter(Boolean); +} + +function resolveMentionRegexes(cfg: ReturnType): RegExp[] { + return ( + cfg.routing?.groupChat?.mentionPatterns + ?.map((pattern) => { + try { + return new RegExp(pattern, "i"); + } catch { + return null; + } + }) + .filter((val): val is RegExp => Boolean(val)) ?? [] + ); +} + +function resolveRequireMention(opts: MonitorIMessageOpts): boolean { + const cfg = loadConfig(); + if (typeof opts.requireMention === "boolean") return opts.requireMention; + return cfg.routing?.groupChat?.requireMention ?? true; +} + +function isMentioned(text: string, regexes: RegExp[]): boolean { + if (!text) return false; + const cleaned = text + .replace(/[\u200b-\u200f\u202a-\u202e\u2060-\u206f]/g, "") + .toLowerCase(); + return regexes.some((re) => re.test(cleaned)); +} + +async function deliverReplies(params: { + replies: ReplyPayload[]; + target: string; + client: Awaited>; + runtime: RuntimeEnv; + maxBytes: number; +}) { + const { replies, target, client, runtime, maxBytes } = params; + for (const payload of replies) { + const mediaList = + payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); + const text = payload.text ?? ""; + if (!text && mediaList.length === 0) continue; + if (mediaList.length === 0) { + for (const chunk of chunkText(text, 4000)) { + await sendMessageIMessage(target, chunk, { maxBytes, client }); + } + } else { + let first = true; + for (const url of mediaList) { + const caption = first ? text : ""; + first = false; + await sendMessageIMessage(target, caption, { + mediaUrl: url, + maxBytes, + client, + }); + } + } + runtime.log?.(`imessage: delivered reply to ${target}`); + } +} + +export async function monitorIMessageProvider( + opts: MonitorIMessageOpts = {}, +): Promise { + const runtime = resolveRuntime(opts); + const cfg = loadConfig(); + const allowFrom = resolveAllowFrom(opts); + const mentionRegexes = resolveMentionRegexes(cfg); + const requireMention = resolveRequireMention(opts); + const includeAttachments = + opts.includeAttachments ?? cfg.imessage?.includeAttachments ?? false; + const mediaMaxBytes = + (opts.mediaMaxMb ?? cfg.imessage?.mediaMaxMb ?? 16) * 1024 * 1024; + + const handleMessage = async (raw: unknown) => { + const params = raw as { message?: IMessagePayload | null }; + const message = params?.message ?? null; + if (!message) return; + + const senderRaw = message.sender ?? ""; + const sender = senderRaw.trim(); + if (!sender) return; + if (message.is_from_me) return; + + const chatId = message.chat_id ?? undefined; + const chatGuid = message.chat_guid ?? undefined; + const chatIdentifier = message.chat_identifier ?? undefined; + const isGroup = Boolean(message.is_group); + if (isGroup && !chatId) return; + + if ( + !isAllowedIMessageSender({ + allowFrom, + sender, + chatId: chatId ?? undefined, + chatGuid, + chatIdentifier, + }) + ) { + logVerbose(`Blocked iMessage sender ${sender} (not in allowFrom)`); + return; + } + + const messageText = (message.text ?? "").trim(); + const mentioned = isGroup ? isMentioned(messageText, mentionRegexes) : true; + if (isGroup && requireMention && !mentioned) { + logVerbose(`imessage: skipping group message (no mention)`); + return; + } + + const attachments = includeAttachments ? message.attachments ?? [] : []; + const firstAttachment = attachments?.find( + (entry) => entry?.original_path && !entry?.missing, + ); + const mediaPath = firstAttachment?.original_path ?? undefined; + const mediaType = firstAttachment?.mime_type ?? undefined; + const kind = mediaKindFromMime(mediaType ?? undefined); + const placeholder = + kind ? `` : attachments?.length ? "" : ""; + const bodyText = messageText || placeholder; + if (!bodyText) return; + + const chatTarget = formatIMessageChatTarget(chatId); + const fromLabel = isGroup + ? `${message.chat_name || "iMessage Group"} id:${chatId ?? "unknown"}` + : `${normalizeIMessageHandle(sender)} id:${sender}`; + const createdAt = message.created_at + ? Date.parse(message.created_at) + : undefined; + const body = formatAgentEnvelope({ + surface: "iMessage", + from: fromLabel, + timestamp: createdAt, + body: bodyText, + }); + + const ctxPayload = { + Body: body, + From: isGroup ? `group:${chatId}` : `imessage:${sender}`, + To: chatTarget || `imessage:${sender}`, + ChatType: isGroup ? "group" : "direct", + GroupSubject: isGroup ? (message.chat_name ?? undefined) : undefined, + GroupMembers: isGroup + ? (message.participants ?? []).filter(Boolean).join(", ") + : undefined, + SenderName: sender, + Surface: "imessage", + MessageSid: message.id ? String(message.id) : undefined, + Timestamp: createdAt, + MediaPath: mediaPath, + MediaType: mediaType, + MediaUrl: mediaPath, + WasMentioned: mentioned, + }; + + if (!isGroup) { + const sessionCfg = cfg.session; + const mainKey = (sessionCfg?.mainKey ?? "main").trim() || "main"; + const storePath = resolveStorePath(sessionCfg?.store); + const to = chatTarget || sender; + if (to) { + await updateLastRoute({ + storePath, + sessionKey: mainKey, + channel: "imessage", + to, + }); + } + } + + if (isVerbose()) { + const preview = body.slice(0, 200).replace(/\n/g, "\\n"); + logVerbose( + `imessage inbound: chatId=${chatId ?? "unknown"} from=${ctxPayload.From} len=${body.length} preview="${preview}"`, + ); + } + + const replyResult = await getReplyFromConfig(ctxPayload, undefined, cfg); + const replies = replyResult + ? Array.isArray(replyResult) + ? replyResult + : [replyResult] + : []; + if (replies.length === 0) return; + + await deliverReplies({ + replies, + target: ctxPayload.To, + client, + runtime, + maxBytes: mediaMaxBytes, + }); + }; + + const client = await createIMessageRpcClient({ + cliPath: opts.cliPath ?? cfg.imessage?.cliPath, + dbPath: opts.dbPath ?? cfg.imessage?.dbPath, + runtime, + onNotification: (msg) => { + if (msg.method === "message") { + void handleMessage(msg.params).catch((err) => { + runtime.error?.(`imessage: handler failed: ${String(err)}`); + }); + } else if (msg.method === "error") { + runtime.error?.(`imessage: watch error ${JSON.stringify(msg.params)}`); + } + }, + }); + + let subscriptionId: number | null = null; + const abort = opts.abortSignal; + const onAbort = () => { + if (subscriptionId) { + void client.request("watch.unsubscribe", { subscription: subscriptionId }); + } + void client.stop(); + }; + abort?.addEventListener("abort", onAbort, { once: true }); + + try { + const result = await client.request<{ subscription?: number }>( + "watch.subscribe", + { attachments: includeAttachments }, + ); + subscriptionId = result?.subscription ?? null; + await client.waitForClose(); + } catch (err) { + if (abort?.aborted) return; + runtime.error?.(danger(`imessage: monitor failed: ${String(err)}`)); + throw err; + } finally { + abort?.removeEventListener("abort", onAbort); + await client.stop(); + } +} diff --git a/src/imessage/probe.ts b/src/imessage/probe.ts new file mode 100644 index 000000000..d8f405443 --- /dev/null +++ b/src/imessage/probe.ts @@ -0,0 +1,33 @@ +import { detectBinary } from "../commands/onboard-helpers.js"; +import { loadConfig } from "../config/config.js"; +import { createIMessageRpcClient } from "./client.js"; + +export type IMessageProbe = { + ok: boolean; + error?: string | null; +}; + +export async function probeIMessage( + timeoutMs = 2000, +): Promise { + const cfg = loadConfig(); + const cliPath = cfg.imessage?.cliPath?.trim() || "imsg"; + const dbPath = cfg.imessage?.dbPath?.trim(); + const detected = await detectBinary(cliPath); + if (!detected) { + return { ok: false, error: `imsg not found (${cliPath})` }; + } + + const client = await createIMessageRpcClient({ + cliPath, + dbPath, + }); + try { + await client.request("chats.list", { limit: 1 }, { timeoutMs }); + return { ok: true }; + } catch (err) { + return { ok: false, error: String(err) }; + } finally { + await client.stop(); + } +} diff --git a/src/imessage/send.test.ts b/src/imessage/send.test.ts new file mode 100644 index 000000000..2984345b0 --- /dev/null +++ b/src/imessage/send.test.ts @@ -0,0 +1,64 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { sendMessageIMessage } from "./send.js"; + +const requestMock = vi.fn(); +const stopMock = vi.fn(); + +vi.mock("../config/config.js", () => ({ + loadConfig: () => ({}), +})); + +vi.mock("./client.js", () => ({ + createIMessageRpcClient: vi.fn().mockResolvedValue({ + request: (...args: unknown[]) => requestMock(...args), + stop: (...args: unknown[]) => stopMock(...args), + }), +})); + +vi.mock("../web/media.js", () => ({ + loadWebMedia: vi.fn().mockResolvedValue({ + buffer: Buffer.from("data"), + contentType: "image/jpeg", + }), +})); + +vi.mock("../media/store.js", () => ({ + saveMediaBuffer: vi.fn().mockResolvedValue({ + path: "/tmp/imessage-media.jpg", + contentType: "image/jpeg", + }), +})); + +describe("sendMessageIMessage", () => { + beforeEach(() => { + requestMock.mockReset().mockResolvedValue({ ok: true }); + stopMock.mockReset().mockResolvedValue(undefined); + }); + + it("sends to chat_id targets", async () => { + await sendMessageIMessage("chat_id:123", "hi"); + const params = requestMock.mock.calls[0]?.[1] as Record; + expect(requestMock).toHaveBeenCalledWith( + "send", + expect.any(Object), + expect.any(Object), + ); + expect(params.chat_id).toBe(123); + expect(params.text).toBe("hi"); + }); + + it("applies sms service prefix", async () => { + await sendMessageIMessage("sms:+1555", "hello"); + const params = requestMock.mock.calls[0]?.[1] as Record; + expect(params.service).toBe("sms"); + expect(params.to).toBe("+1555"); + }); + + it("adds file attachment with placeholder text", async () => { + await sendMessageIMessage("chat_id:7", "", { mediaUrl: "http://x/y.jpg" }); + const params = requestMock.mock.calls[0]?.[1] as Record; + expect(params.file).toBe("/tmp/imessage-media.jpg"); + expect(params.text).toBe(""); + }); +}); diff --git a/src/imessage/send.ts b/src/imessage/send.ts new file mode 100644 index 000000000..f043a1db8 --- /dev/null +++ b/src/imessage/send.ts @@ -0,0 +1,127 @@ +import { loadConfig } from "../config/config.js"; +import { mediaKindFromMime } from "../media/constants.js"; +import { saveMediaBuffer } from "../media/store.js"; +import { loadWebMedia } from "../web/media.js"; +import { createIMessageRpcClient, type IMessageRpcClient } from "./client.js"; +import { + formatIMessageChatTarget, + parseIMessageTarget, + type IMessageService, +} from "./targets.js"; + +export type IMessageSendOpts = { + cliPath?: string; + dbPath?: string; + service?: IMessageService; + region?: string; + mediaUrl?: string; + maxBytes?: number; + timeoutMs?: number; + chatId?: number; + client?: IMessageRpcClient; +}; + +export type IMessageSendResult = { + messageId: string; +}; + +function resolveCliPath(explicit?: string): string { + const cfg = loadConfig(); + return explicit?.trim() || cfg.imessage?.cliPath?.trim() || "imsg"; +} + +function resolveDbPath(explicit?: string): string | undefined { + const cfg = loadConfig(); + return explicit?.trim() || cfg.imessage?.dbPath?.trim() || undefined; +} + +function resolveService(explicit?: IMessageService): IMessageService { + const cfg = loadConfig(); + return ( + explicit || + (cfg.imessage?.service as IMessageService | undefined) || + "auto" + ); +} + +function resolveRegion(explicit?: string): string { + const cfg = loadConfig(); + return explicit?.trim() || cfg.imessage?.region?.trim() || "US"; +} + +async function resolveAttachment( + mediaUrl: string, + maxBytes: number, +): Promise<{ path: string; contentType?: string }> { + const media = await loadWebMedia(mediaUrl, maxBytes); + const saved = await saveMediaBuffer( + media.buffer, + media.contentType ?? undefined, + "outbound", + maxBytes, + ); + return { path: saved.path, contentType: saved.contentType }; +} + +export async function sendMessageIMessage( + to: string, + text: string, + opts: IMessageSendOpts = {}, +): Promise { + const cliPath = resolveCliPath(opts.cliPath); + const dbPath = resolveDbPath(opts.dbPath); + const target = parseIMessageTarget( + opts.chatId ? formatIMessageChatTarget(opts.chatId) : to, + ); + const service = + opts.service ?? (target.kind === "handle" ? target.service : undefined); + const region = resolveRegion(opts.region); + const maxBytes = opts.maxBytes ?? 16 * 1024 * 1024; + let message = text ?? ""; + let filePath: string | undefined; + + if (opts.mediaUrl?.trim()) { + const resolved = await resolveAttachment(opts.mediaUrl.trim(), maxBytes); + filePath = resolved.path; + if (!message.trim()) { + const kind = mediaKindFromMime(resolved.contentType ?? undefined); + if (kind) message = kind === "image" ? "" : ``; + } + } + + if (!message.trim() && !filePath) { + throw new Error("iMessage send requires text or media"); + } + + const params: Record = { + text: message, + service: resolveService(service), + region, + }; + if (filePath) params.file = filePath; + + if (target.kind === "chat_id") { + params.chat_id = target.chatId; + } else if (target.kind === "chat_guid") { + params.chat_guid = target.chatGuid; + } else if (target.kind === "chat_identifier") { + params.chat_identifier = target.chatIdentifier; + } else { + params.to = target.to; + } + + const client = opts.client ?? (await createIMessageRpcClient({ cliPath, dbPath })); + const shouldClose = !opts.client; + try { + const result = await client.request<{ ok?: boolean }>("send", params, { + timeoutMs: opts.timeoutMs, + }); + return { + messageId: result?.ok ? "ok" : "unknown", + }; + } finally { + if (shouldClose) { + await client.stop(); + } + } +} diff --git a/src/imessage/targets.test.ts b/src/imessage/targets.test.ts new file mode 100644 index 000000000..b1549e569 --- /dev/null +++ b/src/imessage/targets.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; + +import { + formatIMessageChatTarget, + isAllowedIMessageSender, + normalizeIMessageHandle, + parseIMessageTarget, +} from "./targets.js"; + +describe("imessage targets", () => { + it("parses chat_id targets", () => { + const target = parseIMessageTarget("chat_id:123"); + expect(target).toEqual({ kind: "chat_id", chatId: 123 }); + }); + + it("parses group chat targets", () => { + const target = parseIMessageTarget("group:456"); + expect(target).toEqual({ kind: "chat_id", chatId: 456 }); + }); + + it("parses sms handles with service", () => { + const target = parseIMessageTarget("sms:+1555"); + expect(target).toEqual({ kind: "handle", to: "+1555", service: "sms" }); + }); + + it("normalizes handles", () => { + expect(normalizeIMessageHandle("Name@Example.com")).toBe( + "name@example.com", + ); + expect(normalizeIMessageHandle(" +1 (555) 222-3333 ")).toBe( + "+15552223333", + ); + }); + + it("checks allowFrom against chat_id", () => { + const ok = isAllowedIMessageSender({ + allowFrom: ["chat_id:9"], + sender: "+1555", + chatId: 9, + }); + expect(ok).toBe(true); + }); + + it("checks allowFrom against handle", () => { + const ok = isAllowedIMessageSender({ + allowFrom: ["user@example.com"], + sender: "User@Example.com", + }); + expect(ok).toBe(true); + }); + + it("formats chat targets", () => { + expect(formatIMessageChatTarget(42)).toBe("chat_id:42"); + expect(formatIMessageChatTarget(undefined)).toBe(""); + }); +}); diff --git a/src/imessage/targets.ts b/src/imessage/targets.ts new file mode 100644 index 000000000..afa2147b8 --- /dev/null +++ b/src/imessage/targets.ts @@ -0,0 +1,172 @@ +import { normalizeE164 } from "../utils.js"; + +export type IMessageService = "imessage" | "sms" | "auto"; + +export type IMessageTarget = + | { kind: "chat_id"; chatId: number } + | { kind: "chat_guid"; chatGuid: string } + | { kind: "chat_identifier"; chatIdentifier: string } + | { kind: "handle"; to: string; service: IMessageService }; + +export type IMessageAllowTarget = + | { kind: "chat_id"; chatId: number } + | { kind: "chat_guid"; chatGuid: string } + | { kind: "chat_identifier"; chatIdentifier: string } + | { kind: "handle"; handle: string }; + +const CHAT_ID_PREFIXES = ["chat_id:", "chatid:", "chat:"]; +const CHAT_GUID_PREFIXES = ["chat_guid:", "chatguid:", "guid:"]; +const CHAT_IDENTIFIER_PREFIXES = [ + "chat_identifier:", + "chatidentifier:", + "chatident:", +]; +const SERVICE_PREFIXES: Array<{ prefix: string; service: IMessageService }> = [ + { prefix: "imessage:", service: "imessage" }, + { prefix: "sms:", service: "sms" }, + { prefix: "auto:", service: "auto" }, +]; + +function stripPrefix(value: string, prefix: string): string { + return value.slice(prefix.length).trim(); +} + +export function normalizeIMessageHandle(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed) return ""; + const lowered = trimmed.toLowerCase(); + if (lowered.startsWith("imessage:")) return normalizeIMessageHandle(trimmed.slice(9)); + if (lowered.startsWith("sms:")) return normalizeIMessageHandle(trimmed.slice(4)); + if (lowered.startsWith("auto:")) return normalizeIMessageHandle(trimmed.slice(5)); + if (trimmed.includes("@")) return trimmed.toLowerCase(); + const normalized = normalizeE164(trimmed); + if (normalized) return normalized; + return trimmed.replace(/\s+/g, ""); +} + +export function parseIMessageTarget(raw: string): IMessageTarget { + const trimmed = raw.trim(); + if (!trimmed) throw new Error("iMessage target is required"); + const lower = trimmed.toLowerCase(); + + for (const prefix of CHAT_ID_PREFIXES) { + if (lower.startsWith(prefix)) { + const value = stripPrefix(trimmed, prefix); + const chatId = Number.parseInt(value, 10); + if (!Number.isFinite(chatId)) { + throw new Error(`Invalid chat_id: ${value}`); + } + return { kind: "chat_id", chatId }; + } + } + + for (const prefix of CHAT_GUID_PREFIXES) { + if (lower.startsWith(prefix)) { + const value = stripPrefix(trimmed, prefix); + if (!value) throw new Error("chat_guid is required"); + return { kind: "chat_guid", chatGuid: value }; + } + } + + for (const prefix of CHAT_IDENTIFIER_PREFIXES) { + if (lower.startsWith(prefix)) { + const value = stripPrefix(trimmed, prefix); + if (!value) throw new Error("chat_identifier is required"); + return { kind: "chat_identifier", chatIdentifier: value }; + } + } + + if (lower.startsWith("group:")) { + const value = stripPrefix(trimmed, "group:"); + const chatId = Number.parseInt(value, 10); + if (Number.isFinite(chatId)) { + return { kind: "chat_id", chatId }; + } + if (!value) throw new Error("group target is required"); + return { kind: "chat_guid", chatGuid: value }; + } + + for (const { prefix, service } of SERVICE_PREFIXES) { + if (lower.startsWith(prefix)) { + const to = stripPrefix(trimmed, prefix); + if (!to) throw new Error(`${prefix} target is required`); + return { kind: "handle", to, service }; + } + } + + return { kind: "handle", to: trimmed, service: "auto" }; +} + +export function parseIMessageAllowTarget(raw: string): IMessageAllowTarget { + const trimmed = raw.trim(); + if (!trimmed) return { kind: "handle", handle: "" }; + const lower = trimmed.toLowerCase(); + + for (const prefix of CHAT_ID_PREFIXES) { + if (lower.startsWith(prefix)) { + const value = stripPrefix(trimmed, prefix); + const chatId = Number.parseInt(value, 10); + if (Number.isFinite(chatId)) return { kind: "chat_id", chatId }; + } + } + + for (const prefix of CHAT_GUID_PREFIXES) { + if (lower.startsWith(prefix)) { + const value = stripPrefix(trimmed, prefix); + if (value) return { kind: "chat_guid", chatGuid: value }; + } + } + + for (const prefix of CHAT_IDENTIFIER_PREFIXES) { + if (lower.startsWith(prefix)) { + const value = stripPrefix(trimmed, prefix); + if (value) return { kind: "chat_identifier", chatIdentifier: value }; + } + } + + if (lower.startsWith("group:")) { + const value = stripPrefix(trimmed, "group:"); + const chatId = Number.parseInt(value, 10); + if (Number.isFinite(chatId)) return { kind: "chat_id", chatId }; + if (value) return { kind: "chat_guid", chatGuid: value }; + } + + return { kind: "handle", handle: normalizeIMessageHandle(trimmed) }; +} + +export function isAllowedIMessageSender(params: { + allowFrom: Array; + sender: string; + chatId?: number | null; + chatGuid?: string | null; + chatIdentifier?: string | null; +}): boolean { + const allowFrom = params.allowFrom.map((entry) => String(entry).trim()); + if (allowFrom.length === 0) return true; + if (allowFrom.includes("*")) return true; + + const senderNormalized = normalizeIMessageHandle(params.sender); + const chatId = params.chatId ?? undefined; + const chatGuid = params.chatGuid?.trim(); + const chatIdentifier = params.chatIdentifier?.trim(); + + for (const entry of allowFrom) { + if (!entry) continue; + const parsed = parseIMessageAllowTarget(entry); + if (parsed.kind === "chat_id" && chatId !== undefined) { + if (parsed.chatId === chatId) return true; + } else if (parsed.kind === "chat_guid" && chatGuid) { + if (parsed.chatGuid === chatGuid) return true; + } else if (parsed.kind === "chat_identifier" && chatIdentifier) { + if (parsed.chatIdentifier === chatIdentifier) return true; + } else if (parsed.kind === "handle" && senderNormalized) { + if (parsed.handle === senderNormalized) return true; + } + } + return false; +} + +export function formatIMessageChatTarget(chatId?: number | null): string { + if (!chatId || !Number.isFinite(chatId)) return ""; + return `chat_id:${chatId}`; +} diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index 5473d92ed..f3c69913b 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -15,6 +15,7 @@ import { saveSessionStore, } from "../config/sessions.js"; import { sendMessageDiscord } from "../discord/send.js"; +import { sendMessageIMessage } from "../imessage/send.js"; import { formatErrorMessage } from "../infra/errors.js"; import { createSubsystemLogger } from "../logging.js"; import { getQueueSize } from "../process/command-queue.js"; @@ -38,10 +39,11 @@ export type HeartbeatTarget = | "telegram" | "discord" | "signal" + | "imessage" | "none"; export type HeartbeatDeliveryTarget = { - channel: "whatsapp" | "telegram" | "discord" | "signal" | "none"; + channel: "whatsapp" | "telegram" | "discord" | "signal" | "imessage" | "none"; to?: string; reason?: string; }; @@ -52,6 +54,7 @@ type HeartbeatDeps = { sendTelegram?: typeof sendMessageTelegram; sendDiscord?: typeof sendMessageDiscord; sendSignal?: typeof sendMessageSignal; + sendIMessage?: typeof sendMessageIMessage; getQueueSize?: (lane?: string) => number; nowMs?: () => number; webAuthExists?: () => Promise; @@ -181,6 +184,7 @@ export function resolveHeartbeatDeliveryTarget(params: { rawTarget === "telegram" || rawTarget === "discord" || rawTarget === "signal" || + rawTarget === "imessage" || rawTarget === "none" || rawTarget === "last" ? rawTarget @@ -201,13 +205,20 @@ export function resolveHeartbeatDeliveryTarget(params: { : undefined; const lastTo = typeof entry?.lastTo === "string" ? entry.lastTo.trim() : ""; - const channel: "whatsapp" | "telegram" | "discord" | "signal" | undefined = + const channel: + | "whatsapp" + | "telegram" + | "discord" + | "signal" + | "imessage" + | undefined = target === "last" ? lastChannel : target === "whatsapp" || target === "telegram" || target === "discord" || - target === "signal" + target === "signal" || + target === "imessage" ? target : undefined; @@ -274,14 +285,18 @@ function normalizeHeartbeatReply( } async function deliverHeartbeatReply(params: { - channel: "whatsapp" | "telegram" | "discord" | "signal"; + channel: "whatsapp" | "telegram" | "discord" | "signal" | "imessage"; to: string; text: string; mediaUrls: string[]; deps: Required< Pick< HeartbeatDeps, - "sendWhatsApp" | "sendTelegram" | "sendDiscord" | "sendSignal" + | "sendWhatsApp" + | "sendTelegram" + | "sendDiscord" + | "sendSignal" + | "sendIMessage" > >; }) { @@ -318,6 +333,22 @@ async function deliverHeartbeatReply(params: { return; } + if (channel === "imessage") { + if (mediaUrls.length === 0) { + for (const chunk of chunkText(text, 4000)) { + await deps.sendIMessage(to, chunk); + } + return; + } + let first = true; + for (const url of mediaUrls) { + const caption = first ? text : ""; + first = false; + await deps.sendIMessage(to, caption, { mediaUrl: url }); + } + return; + } + if (channel === "telegram") { if (mediaUrls.length === 0) { for (const chunk of chunkText(text, 4000)) { @@ -464,6 +495,7 @@ export async function runHeartbeatOnce(opts: { sendTelegram: opts.deps?.sendTelegram ?? sendMessageTelegram, sendDiscord: opts.deps?.sendDiscord ?? sendMessageDiscord, sendSignal: opts.deps?.sendSignal ?? sendMessageSignal, + sendIMessage: opts.deps?.sendIMessage ?? sendMessageIMessage, }; await deliverHeartbeatReply({ channel: delivery.channel, diff --git a/src/infra/provider-summary.ts b/src/infra/provider-summary.ts index 3e16056c3..70186d4ef 100644 --- a/src/infra/provider-summary.ts +++ b/src/infra/provider-summary.ts @@ -60,6 +60,18 @@ export async function buildProviderSummary( ); } + const imessageEnabled = effective.imessage?.enabled !== false; + if (!imessageEnabled) { + lines.push(chalk.cyan("iMessage: disabled")); + } else { + const imessageConfigured = Boolean(effective.imessage); + lines.push( + imessageConfigured + ? chalk.green("iMessage: configured") + : chalk.cyan("iMessage: not configured"), + ); + } + const allowFrom = effective.routing?.allowFrom?.length ? effective.routing.allowFrom.map(normalizeE164).filter(Boolean) : [];