feat: add imessage rpc adapter

This commit is contained in:
Peter Steinberger
2026-01-02 01:19:13 +01:00
parent 3ee27a00c7
commit cbac34347b
23 changed files with 1451 additions and 15 deletions

View File

@@ -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;

View File

@@ -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.");
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -42,6 +42,7 @@ const makeDeps = (overrides: Partial<CliDeps> = {}): 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();

View File

@@ -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<{