Skip to content

Commit cbac343

Browse files
committed
feat: add imessage rpc adapter
1 parent 3ee27a0 commit cbac343

23 files changed

Lines changed: 1451 additions & 15 deletions

src/cli/cron-cli.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ export function registerCronCli(program: Command) {
155155
.option("--deliver", "Deliver agent output", false)
156156
.option(
157157
"--channel <channel>",
158-
"Delivery channel (last|whatsapp|telegram|discord|signal)",
158+
"Delivery channel (last|whatsapp|telegram|discord|signal|imessage)",
159159
"last",
160160
)
161161
.option(
@@ -414,7 +414,7 @@ export function registerCronCli(program: Command) {
414414
.option("--deliver", "Deliver agent output", false)
415415
.option(
416416
"--channel <channel>",
417-
"Delivery channel (last|whatsapp|telegram|discord|signal)",
417+
"Delivery channel (last|whatsapp|telegram|discord|signal|imessage)",
418418
)
419419
.option(
420420
"--to <dest>",

src/cli/deps.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { sendMessageDiscord } from "../discord/send.js";
2+
import { sendMessageIMessage } from "../imessage/send.js";
23
import { logWebSelfId, sendMessageWhatsApp } from "../providers/web/index.js";
34
import { sendMessageSignal } from "../signal/send.js";
45
import { sendMessageTelegram } from "../telegram/send.js";
@@ -8,6 +9,7 @@ export type CliDeps = {
89
sendMessageTelegram: typeof sendMessageTelegram;
910
sendMessageDiscord: typeof sendMessageDiscord;
1011
sendMessageSignal: typeof sendMessageSignal;
12+
sendMessageIMessage: typeof sendMessageIMessage;
1113
};
1214

1315
export function createDefaultDeps(): CliDeps {
@@ -16,6 +18,7 @@ export function createDefaultDeps(): CliDeps {
1618
sendMessageTelegram,
1719
sendMessageDiscord,
1820
sendMessageSignal,
21+
sendMessageIMessage,
1922
};
2023
}
2124

src/cli/program.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -282,10 +282,12 @@ export function buildProgram() {
282282

283283
program
284284
.command("send")
285-
.description("Send a message (WhatsApp Web, Telegram bot, Discord, Signal)")
285+
.description(
286+
"Send a message (WhatsApp Web, Telegram bot, Discord, Signal, iMessage)",
287+
)
286288
.requiredOption(
287289
"-t, --to <number>",
288-
"Recipient: E.164 for WhatsApp/Signal, Telegram chat id/@username, or Discord channel/user",
290+
"Recipient: E.164 for WhatsApp/Signal, Telegram chat id/@username, Discord channel/user, or iMessage handle/chat_id",
289291
)
290292
.requiredOption("-m, --message <text>", "Message body")
291293
.option(
@@ -294,7 +296,7 @@ export function buildProgram() {
294296
)
295297
.option(
296298
"--provider <provider>",
297-
"Delivery provider: whatsapp|telegram|discord|signal (default: whatsapp)",
299+
"Delivery provider: whatsapp|telegram|discord|signal|imessage (default: whatsapp)",
298300
)
299301
.option("--dry-run", "Print payload and skip sending", false)
300302
.option("--json", "Output result as JSON", false)
@@ -337,7 +339,7 @@ Examples:
337339
.option("--verbose <on|off>", "Persist agent verbose level for the session")
338340
.option(
339341
"--provider <provider>",
340-
"Delivery provider: whatsapp|telegram|discord|signal (default: whatsapp)",
342+
"Delivery provider: whatsapp|telegram|discord|signal|imessage (default: whatsapp)",
341343
)
342344
.option(
343345
"--deliver",

src/commands/agent.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,7 @@ describe("agentCommand", () => {
212212
.mockResolvedValue({ messageId: "t1", chatId: "123" }),
213213
sendMessageDiscord: vi.fn(),
214214
sendMessageSignal: vi.fn(),
215+
sendMessageIMessage: vi.fn(),
215216
};
216217

217218
const prevTelegramToken = process.env.TELEGRAM_BOT_TOKEN;

src/commands/onboard-interactive.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js";
2121
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
2222
import { resolveGatewayService } from "../daemon/service.js";
23+
import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js";
2324
import type { RuntimeEnv } from "../runtime.js";
2425
import { defaultRuntime } from "../runtime.js";
2526
import { resolveUserPath, sleep } from "../utils.js";
@@ -478,5 +479,20 @@ export async function runInteractiveOnboarding(
478479
"Optional apps",
479480
);
480481

482+
note(
483+
(() => {
484+
const tailnetIPv4 = pickPrimaryTailnetIPv4();
485+
const host =
486+
bind === "tailnet" || (bind === "auto" && tailnetIPv4)
487+
? (tailnetIPv4 ?? "127.0.0.1")
488+
: "127.0.0.1";
489+
return [
490+
`Control UI: http://${host}:${port}/`,
491+
`Gateway WS: ws://${host}:${port}`,
492+
].join("\n");
493+
})(),
494+
"Open the Control UI",
495+
);
496+
481497
outro("Onboarding complete.");
482498
}

src/commands/onboard-providers.ts

Lines changed: 140 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import fs from "node:fs/promises";
22
import path from "node:path";
33

4-
import { confirm, multiselect, note, text } from "@clack/prompts";
4+
import { confirm, multiselect, note, select, text } from "@clack/prompts";
55
import chalk from "chalk";
66

77
import type { ClawdisConfig } from "../config/config.js";
88
import { loginWeb } from "../provider-web.js";
99
import type { RuntimeEnv } from "../runtime.js";
10+
import { normalizeE164 } from "../utils.js";
1011
import { resolveWebAuthDir } from "../web/session.js";
1112
import { detectBinary, guardCancel } from "./onboard-helpers.js";
1213
import type { ProviderChoice } from "./onboard-types.js";
@@ -33,6 +34,7 @@ function noteProviderPrimer(): void {
3334
"Telegram: Bot API (token from @BotFather), replies via your bot.",
3435
"Discord: Bot token from Discord Developer Portal; invite bot to your server.",
3536
"Signal: signal-cli as a linked device (recommended: separate bot number).",
37+
"iMessage: local imsg CLI (JSON-RPC over stdio) reading Messages DB.",
3638
].join("\n"),
3739
"How providers work",
3840
);
@@ -79,6 +81,11 @@ export async function setupProviders(
7981
);
8082
const signalCliPath = cfg.signal?.cliPath ?? "signal-cli";
8183
const signalCliDetected = await detectBinary(signalCliPath);
84+
const imessageConfigured = Boolean(
85+
cfg.imessage?.cliPath || cfg.imessage?.dbPath || cfg.imessage?.allowFrom,
86+
);
87+
const imessageCliPath = cfg.imessage?.cliPath ?? "imsg";
88+
const imessageCliDetected = await detectBinary(imessageCliPath);
8289

8390
note(
8491
[
@@ -100,9 +107,17 @@ export async function setupProviders(
100107
? chalk.green("configured")
101108
: chalk.yellow("needs setup")
102109
}`,
110+
`iMessage: ${
111+
imessageConfigured
112+
? chalk.green("configured")
113+
: chalk.yellow("needs setup")
114+
}`,
103115
`signal-cli: ${
104116
signalCliDetected ? chalk.green("found") : chalk.red("missing")
105117
} (${signalCliPath})`,
118+
`imsg: ${
119+
imessageCliDetected ? chalk.green("found") : chalk.red("missing")
120+
} (${imessageCliPath})`,
106121
].join("\n"),
107122
"Provider status",
108123
);
@@ -142,6 +157,11 @@ export async function setupProviders(
142157
label: "Signal (signal-cli)",
143158
hint: signalCliDetected ? "signal-cli found" : "signal-cli missing",
144159
},
160+
{
161+
value: "imessage",
162+
label: "iMessage (imsg)",
163+
hint: imessageCliDetected ? "imsg found" : "imsg missing",
164+
},
145165
],
146166
}),
147167
runtime,
@@ -177,6 +197,71 @@ export async function setupProviders(
177197
} else if (!whatsappLinked) {
178198
note("Run `clawdis login` later to link WhatsApp.", "WhatsApp");
179199
}
200+
201+
const existingAllowFrom = cfg.routing?.allowFrom ?? [];
202+
if (existingAllowFrom.length === 0) {
203+
note(
204+
[
205+
"WhatsApp direct chats are gated by `routing.allowFrom`.",
206+
'Default (unset) = self-chat only; use "*" to allow anyone.',
207+
].join("\n"),
208+
"Allowlist (recommended)",
209+
);
210+
const mode = guardCancel(
211+
await select({
212+
message: "Who can trigger the bot via WhatsApp?",
213+
options: [
214+
{ value: "self", label: "Self-chat only (default)" },
215+
{ value: "list", label: "Specific numbers (recommended)" },
216+
{ value: "any", label: "Anyone (*)" },
217+
],
218+
}),
219+
runtime,
220+
) as "self" | "list" | "any";
221+
222+
if (mode === "any") {
223+
next = {
224+
...next,
225+
routing: { ...next.routing, allowFrom: ["*"] },
226+
};
227+
} else if (mode === "list") {
228+
const allowRaw = guardCancel(
229+
await text({
230+
message: "Allowed sender numbers (comma-separated, E.164)",
231+
placeholder: "+15555550123, +447700900123",
232+
validate: (value) => {
233+
const raw = String(value ?? "").trim();
234+
if (!raw) return "Required";
235+
const parts = raw
236+
.split(/[\n,;]+/g)
237+
.map((p) => p.trim())
238+
.filter(Boolean);
239+
if (parts.length === 0) return "Required";
240+
for (const part of parts) {
241+
if (part === "*") continue;
242+
const normalized = normalizeE164(part);
243+
if (!normalized) return `Invalid number: ${part}`;
244+
}
245+
return undefined;
246+
},
247+
}),
248+
runtime,
249+
);
250+
251+
const parts = String(allowRaw)
252+
.split(/[\n,;]+/g)
253+
.map((p) => p.trim())
254+
.filter(Boolean);
255+
const normalized = parts.map((part) =>
256+
part === "*" ? "*" : normalizeE164(part),
257+
);
258+
const unique = [...new Set(normalized.filter(Boolean))];
259+
next = {
260+
...next,
261+
routing: { ...next.routing, allowFrom: unique },
262+
};
263+
}
264+
}
180265
}
181266

182267
if (selection.includes("telegram")) {
@@ -395,6 +480,44 @@ export async function setupProviders(
395480
);
396481
}
397482

483+
if (selection.includes("imessage")) {
484+
let resolvedCliPath = imessageCliPath;
485+
if (!imessageCliDetected) {
486+
const entered = guardCancel(
487+
await text({
488+
message: "imsg CLI path",
489+
initialValue: resolvedCliPath,
490+
validate: (value) => (value?.trim() ? undefined : "Required"),
491+
}),
492+
runtime,
493+
);
494+
resolvedCliPath = String(entered).trim();
495+
if (!resolvedCliPath) {
496+
note("imsg CLI path required to enable iMessage.", "iMessage");
497+
}
498+
}
499+
500+
if (resolvedCliPath) {
501+
next = {
502+
...next,
503+
imessage: {
504+
...next.imessage,
505+
enabled: true,
506+
cliPath: resolvedCliPath,
507+
},
508+
};
509+
}
510+
511+
note(
512+
[
513+
"Ensure Clawdis has Full Disk Access to Messages DB.",
514+
"Grant Automation permission for Messages when prompted.",
515+
"List chats with: imsg chats --limit 20",
516+
].join("\n"),
517+
"iMessage next steps",
518+
);
519+
}
520+
398521
if (options?.allowDisable) {
399522
if (!selection.includes("telegram") && telegramConfigured) {
400523
const disable = guardCancel(
@@ -443,6 +566,22 @@ export async function setupProviders(
443566
};
444567
}
445568
}
569+
570+
if (!selection.includes("imessage") && imessageConfigured) {
571+
const disable = guardCancel(
572+
await confirm({
573+
message: "Disable iMessage provider?",
574+
initialValue: false,
575+
}),
576+
runtime,
577+
);
578+
if (disable) {
579+
next = {
580+
...next,
581+
imessage: { ...next.imessage, enabled: false },
582+
};
583+
}
584+
}
446585
}
447586

448587
return next;

src/commands/onboard-types.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,12 @@ export type ResetScope = "config" | "config+creds+sessions" | "full";
55
export type GatewayBind = "loopback" | "lan" | "tailnet" | "auto";
66
export type TailscaleMode = "off" | "serve" | "funnel";
77
export type NodeManagerChoice = "npm" | "pnpm" | "bun";
8-
export type ProviderChoice = "whatsapp" | "telegram" | "discord" | "signal";
8+
export type ProviderChoice =
9+
| "whatsapp"
10+
| "telegram"
11+
| "discord"
12+
| "signal"
13+
| "imessage";
914

1015
export type OnboardOptions = {
1116
mode?: OnboardMode;

src/commands/send.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ const makeDeps = (overrides: Partial<CliDeps> = {}): CliDeps => ({
4242
sendMessageTelegram: vi.fn(),
4343
sendMessageDiscord: vi.fn(),
4444
sendMessageSignal: vi.fn(),
45+
sendMessageIMessage: vi.fn(),
4546
...overrides,
4647
});
4748

@@ -151,6 +152,23 @@ describe("sendCommand", () => {
151152
expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled();
152153
});
153154

155+
it("routes to imessage provider", async () => {
156+
const deps = makeDeps({
157+
sendMessageIMessage: vi.fn().mockResolvedValue({ messageId: "i1" }),
158+
});
159+
await sendCommand(
160+
{ to: "chat_id:42", message: "hi", provider: "imessage" },
161+
deps,
162+
runtime,
163+
);
164+
expect(deps.sendMessageIMessage).toHaveBeenCalledWith(
165+
"chat_id:42",
166+
"hi",
167+
expect.objectContaining({ mediaUrl: undefined }),
168+
);
169+
expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled();
170+
});
171+
154172
it("emits json output", async () => {
155173
callGatewayMock.mockResolvedValueOnce({ messageId: "direct2" });
156174
const deps = makeDeps();

src/commands/send.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,31 @@ export async function sendCommand(
108108
return;
109109
}
110110

111+
if (provider === "imessage" || provider === "imsg") {
112+
const result = await deps.sendMessageIMessage(opts.to, opts.message, {
113+
mediaUrl: opts.media,
114+
});
115+
runtime.log(
116+
success(`✅ Sent via iMessage. Message ID: ${result.messageId}`),
117+
);
118+
if (opts.json) {
119+
runtime.log(
120+
JSON.stringify(
121+
{
122+
provider: "imessage",
123+
via: "direct",
124+
to: opts.to,
125+
messageId: result.messageId,
126+
mediaUrl: opts.media ?? null,
127+
},
128+
null,
129+
2,
130+
),
131+
);
132+
}
133+
return;
134+
}
135+
111136
// Always send via gateway over WS to avoid multi-session corruption.
112137
const sendViaGateway = async () =>
113138
callGateway<{

0 commit comments

Comments
 (0)