feat: add imessage rpc adapter
This commit is contained in:
@@ -155,7 +155,7 @@ export function registerCronCli(program: Command) {
|
|||||||
.option("--deliver", "Deliver agent output", false)
|
.option("--deliver", "Deliver agent output", false)
|
||||||
.option(
|
.option(
|
||||||
"--channel <channel>",
|
"--channel <channel>",
|
||||||
"Delivery channel (last|whatsapp|telegram|discord|signal)",
|
"Delivery channel (last|whatsapp|telegram|discord|signal|imessage)",
|
||||||
"last",
|
"last",
|
||||||
)
|
)
|
||||||
.option(
|
.option(
|
||||||
@@ -414,7 +414,7 @@ export function registerCronCli(program: Command) {
|
|||||||
.option("--deliver", "Deliver agent output", false)
|
.option("--deliver", "Deliver agent output", false)
|
||||||
.option(
|
.option(
|
||||||
"--channel <channel>",
|
"--channel <channel>",
|
||||||
"Delivery channel (last|whatsapp|telegram|discord|signal)",
|
"Delivery channel (last|whatsapp|telegram|discord|signal|imessage)",
|
||||||
)
|
)
|
||||||
.option(
|
.option(
|
||||||
"--to <dest>",
|
"--to <dest>",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { sendMessageDiscord } from "../discord/send.js";
|
import { sendMessageDiscord } from "../discord/send.js";
|
||||||
|
import { sendMessageIMessage } from "../imessage/send.js";
|
||||||
import { logWebSelfId, sendMessageWhatsApp } from "../providers/web/index.js";
|
import { logWebSelfId, sendMessageWhatsApp } from "../providers/web/index.js";
|
||||||
import { sendMessageSignal } from "../signal/send.js";
|
import { sendMessageSignal } from "../signal/send.js";
|
||||||
import { sendMessageTelegram } from "../telegram/send.js";
|
import { sendMessageTelegram } from "../telegram/send.js";
|
||||||
@@ -8,6 +9,7 @@ export type CliDeps = {
|
|||||||
sendMessageTelegram: typeof sendMessageTelegram;
|
sendMessageTelegram: typeof sendMessageTelegram;
|
||||||
sendMessageDiscord: typeof sendMessageDiscord;
|
sendMessageDiscord: typeof sendMessageDiscord;
|
||||||
sendMessageSignal: typeof sendMessageSignal;
|
sendMessageSignal: typeof sendMessageSignal;
|
||||||
|
sendMessageIMessage: typeof sendMessageIMessage;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createDefaultDeps(): CliDeps {
|
export function createDefaultDeps(): CliDeps {
|
||||||
@@ -16,6 +18,7 @@ export function createDefaultDeps(): CliDeps {
|
|||||||
sendMessageTelegram,
|
sendMessageTelegram,
|
||||||
sendMessageDiscord,
|
sendMessageDiscord,
|
||||||
sendMessageSignal,
|
sendMessageSignal,
|
||||||
|
sendMessageIMessage,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -282,10 +282,12 @@ export function buildProgram() {
|
|||||||
|
|
||||||
program
|
program
|
||||||
.command("send")
|
.command("send")
|
||||||
.description("Send a message (WhatsApp Web, Telegram bot, Discord, Signal)")
|
.description(
|
||||||
|
"Send a message (WhatsApp Web, Telegram bot, Discord, Signal, iMessage)",
|
||||||
|
)
|
||||||
.requiredOption(
|
.requiredOption(
|
||||||
"-t, --to <number>",
|
"-t, --to <number>",
|
||||||
"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 <text>", "Message body")
|
.requiredOption("-m, --message <text>", "Message body")
|
||||||
.option(
|
.option(
|
||||||
@@ -294,7 +296,7 @@ export function buildProgram() {
|
|||||||
)
|
)
|
||||||
.option(
|
.option(
|
||||||
"--provider <provider>",
|
"--provider <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("--dry-run", "Print payload and skip sending", false)
|
||||||
.option("--json", "Output result as JSON", false)
|
.option("--json", "Output result as JSON", false)
|
||||||
@@ -337,7 +339,7 @@ Examples:
|
|||||||
.option("--verbose <on|off>", "Persist agent verbose level for the session")
|
.option("--verbose <on|off>", "Persist agent verbose level for the session")
|
||||||
.option(
|
.option(
|
||||||
"--provider <provider>",
|
"--provider <provider>",
|
||||||
"Delivery provider: whatsapp|telegram|discord|signal (default: whatsapp)",
|
"Delivery provider: whatsapp|telegram|discord|signal|imessage (default: whatsapp)",
|
||||||
)
|
)
|
||||||
.option(
|
.option(
|
||||||
"--deliver",
|
"--deliver",
|
||||||
|
|||||||
@@ -212,6 +212,7 @@ describe("agentCommand", () => {
|
|||||||
.mockResolvedValue({ messageId: "t1", chatId: "123" }),
|
.mockResolvedValue({ messageId: "t1", chatId: "123" }),
|
||||||
sendMessageDiscord: vi.fn(),
|
sendMessageDiscord: vi.fn(),
|
||||||
sendMessageSignal: vi.fn(),
|
sendMessageSignal: vi.fn(),
|
||||||
|
sendMessageIMessage: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const prevTelegramToken = process.env.TELEGRAM_BOT_TOKEN;
|
const prevTelegramToken = process.env.TELEGRAM_BOT_TOKEN;
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js";
|
import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js";
|
||||||
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
|
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
|
||||||
import { resolveGatewayService } from "../daemon/service.js";
|
import { resolveGatewayService } from "../daemon/service.js";
|
||||||
|
import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js";
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
import { resolveUserPath, sleep } from "../utils.js";
|
import { resolveUserPath, sleep } from "../utils.js";
|
||||||
@@ -478,5 +479,20 @@ export async function runInteractiveOnboarding(
|
|||||||
"Optional apps",
|
"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.");
|
outro("Onboarding complete.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
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 chalk from "chalk";
|
||||||
|
|
||||||
import type { ClawdisConfig } from "../config/config.js";
|
import type { ClawdisConfig } from "../config/config.js";
|
||||||
import { loginWeb } from "../provider-web.js";
|
import { loginWeb } from "../provider-web.js";
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
|
import { normalizeE164 } from "../utils.js";
|
||||||
import { resolveWebAuthDir } from "../web/session.js";
|
import { resolveWebAuthDir } from "../web/session.js";
|
||||||
import { detectBinary, guardCancel } from "./onboard-helpers.js";
|
import { detectBinary, guardCancel } from "./onboard-helpers.js";
|
||||||
import type { ProviderChoice } from "./onboard-types.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.",
|
"Telegram: Bot API (token from @BotFather), replies via your bot.",
|
||||||
"Discord: Bot token from Discord Developer Portal; invite bot to your server.",
|
"Discord: Bot token from Discord Developer Portal; invite bot to your server.",
|
||||||
"Signal: signal-cli as a linked device (recommended: separate bot number).",
|
"Signal: signal-cli as a linked device (recommended: separate bot number).",
|
||||||
|
"iMessage: local imsg CLI (JSON-RPC over stdio) reading Messages DB.",
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
"How providers work",
|
"How providers work",
|
||||||
);
|
);
|
||||||
@@ -79,6 +81,11 @@ export async function setupProviders(
|
|||||||
);
|
);
|
||||||
const signalCliPath = cfg.signal?.cliPath ?? "signal-cli";
|
const signalCliPath = cfg.signal?.cliPath ?? "signal-cli";
|
||||||
const signalCliDetected = await detectBinary(signalCliPath);
|
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(
|
note(
|
||||||
[
|
[
|
||||||
@@ -100,9 +107,17 @@ export async function setupProviders(
|
|||||||
? chalk.green("configured")
|
? chalk.green("configured")
|
||||||
: chalk.yellow("needs setup")
|
: chalk.yellow("needs setup")
|
||||||
}`,
|
}`,
|
||||||
|
`iMessage: ${
|
||||||
|
imessageConfigured
|
||||||
|
? chalk.green("configured")
|
||||||
|
: chalk.yellow("needs setup")
|
||||||
|
}`,
|
||||||
`signal-cli: ${
|
`signal-cli: ${
|
||||||
signalCliDetected ? chalk.green("found") : chalk.red("missing")
|
signalCliDetected ? chalk.green("found") : chalk.red("missing")
|
||||||
} (${signalCliPath})`,
|
} (${signalCliPath})`,
|
||||||
|
`imsg: ${
|
||||||
|
imessageCliDetected ? chalk.green("found") : chalk.red("missing")
|
||||||
|
} (${imessageCliPath})`,
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
"Provider status",
|
"Provider status",
|
||||||
);
|
);
|
||||||
@@ -142,6 +157,11 @@ export async function setupProviders(
|
|||||||
label: "Signal (signal-cli)",
|
label: "Signal (signal-cli)",
|
||||||
hint: signalCliDetected ? "signal-cli found" : "signal-cli missing",
|
hint: signalCliDetected ? "signal-cli found" : "signal-cli missing",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: "imessage",
|
||||||
|
label: "iMessage (imsg)",
|
||||||
|
hint: imessageCliDetected ? "imsg found" : "imsg missing",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
runtime,
|
runtime,
|
||||||
@@ -177,6 +197,71 @@ export async function setupProviders(
|
|||||||
} else if (!whatsappLinked) {
|
} else if (!whatsappLinked) {
|
||||||
note("Run `clawdis login` later to link WhatsApp.", "WhatsApp");
|
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")) {
|
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 (options?.allowDisable) {
|
||||||
if (!selection.includes("telegram") && telegramConfigured) {
|
if (!selection.includes("telegram") && telegramConfigured) {
|
||||||
const disable = guardCancel(
|
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;
|
return next;
|
||||||
|
|||||||
@@ -5,7 +5,12 @@ export type ResetScope = "config" | "config+creds+sessions" | "full";
|
|||||||
export type GatewayBind = "loopback" | "lan" | "tailnet" | "auto";
|
export type GatewayBind = "loopback" | "lan" | "tailnet" | "auto";
|
||||||
export type TailscaleMode = "off" | "serve" | "funnel";
|
export type TailscaleMode = "off" | "serve" | "funnel";
|
||||||
export type NodeManagerChoice = "npm" | "pnpm" | "bun";
|
export type NodeManagerChoice = "npm" | "pnpm" | "bun";
|
||||||
export type ProviderChoice = "whatsapp" | "telegram" | "discord" | "signal";
|
export type ProviderChoice =
|
||||||
|
| "whatsapp"
|
||||||
|
| "telegram"
|
||||||
|
| "discord"
|
||||||
|
| "signal"
|
||||||
|
| "imessage";
|
||||||
|
|
||||||
export type OnboardOptions = {
|
export type OnboardOptions = {
|
||||||
mode?: OnboardMode;
|
mode?: OnboardMode;
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ const makeDeps = (overrides: Partial<CliDeps> = {}): CliDeps => ({
|
|||||||
sendMessageTelegram: vi.fn(),
|
sendMessageTelegram: vi.fn(),
|
||||||
sendMessageDiscord: vi.fn(),
|
sendMessageDiscord: vi.fn(),
|
||||||
sendMessageSignal: vi.fn(),
|
sendMessageSignal: vi.fn(),
|
||||||
|
sendMessageIMessage: vi.fn(),
|
||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -151,6 +152,23 @@ describe("sendCommand", () => {
|
|||||||
expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled();
|
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 () => {
|
it("emits json output", async () => {
|
||||||
callGatewayMock.mockResolvedValueOnce({ messageId: "direct2" });
|
callGatewayMock.mockResolvedValueOnce({ messageId: "direct2" });
|
||||||
const deps = makeDeps();
|
const deps = makeDeps();
|
||||||
|
|||||||
@@ -108,6 +108,31 @@ export async function sendCommand(
|
|||||||
return;
|
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.
|
// Always send via gateway over WS to avoid multi-session corruption.
|
||||||
const sendViaGateway = async () =>
|
const sendViaGateway = async () =>
|
||||||
callGateway<{
|
callGateway<{
|
||||||
|
|||||||
@@ -27,7 +27,13 @@ export type SessionEntry = {
|
|||||||
totalTokens?: number;
|
totalTokens?: number;
|
||||||
model?: string;
|
model?: string;
|
||||||
contextTokens?: number;
|
contextTokens?: number;
|
||||||
lastChannel?: "whatsapp" | "telegram" | "discord" | "signal" | "webchat";
|
lastChannel?:
|
||||||
|
| "whatsapp"
|
||||||
|
| "telegram"
|
||||||
|
| "discord"
|
||||||
|
| "signal"
|
||||||
|
| "imessage"
|
||||||
|
| "webchat";
|
||||||
lastTo?: string;
|
lastTo?: string;
|
||||||
skillsSnapshot?: SessionSkillSnapshot;
|
skillsSnapshot?: SessionSkillSnapshot;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ describe("runCronIsolatedAgentTurn", () => {
|
|||||||
sendMessageTelegram: vi.fn(),
|
sendMessageTelegram: vi.fn(),
|
||||||
sendMessageDiscord: vi.fn(),
|
sendMessageDiscord: vi.fn(),
|
||||||
sendMessageSignal: vi.fn(),
|
sendMessageSignal: vi.fn(),
|
||||||
|
sendMessageIMessage: vi.fn(),
|
||||||
};
|
};
|
||||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||||
payloads: [{ text: "first" }, { text: " " }, { text: " last " }],
|
payloads: [{ text: "first" }, { text: " " }, { text: " last " }],
|
||||||
@@ -128,6 +129,7 @@ describe("runCronIsolatedAgentTurn", () => {
|
|||||||
sendMessageTelegram: vi.fn(),
|
sendMessageTelegram: vi.fn(),
|
||||||
sendMessageDiscord: vi.fn(),
|
sendMessageDiscord: vi.fn(),
|
||||||
sendMessageSignal: vi.fn(),
|
sendMessageSignal: vi.fn(),
|
||||||
|
sendMessageIMessage: vi.fn(),
|
||||||
};
|
};
|
||||||
const long = "a".repeat(2001);
|
const long = "a".repeat(2001);
|
||||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||||
@@ -160,6 +162,7 @@ describe("runCronIsolatedAgentTurn", () => {
|
|||||||
sendMessageTelegram: vi.fn(),
|
sendMessageTelegram: vi.fn(),
|
||||||
sendMessageDiscord: vi.fn(),
|
sendMessageDiscord: vi.fn(),
|
||||||
sendMessageSignal: vi.fn(),
|
sendMessageSignal: vi.fn(),
|
||||||
|
sendMessageIMessage: vi.fn(),
|
||||||
};
|
};
|
||||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||||
payloads: [{ text: "hello" }],
|
payloads: [{ text: "hello" }],
|
||||||
@@ -199,6 +202,7 @@ describe("runCronIsolatedAgentTurn", () => {
|
|||||||
sendMessageTelegram: vi.fn(),
|
sendMessageTelegram: vi.fn(),
|
||||||
sendMessageDiscord: vi.fn(),
|
sendMessageDiscord: vi.fn(),
|
||||||
sendMessageSignal: vi.fn(),
|
sendMessageSignal: vi.fn(),
|
||||||
|
sendMessageIMessage: vi.fn(),
|
||||||
};
|
};
|
||||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||||
payloads: [{ text: "hello" }],
|
payloads: [{ text: "hello" }],
|
||||||
@@ -240,6 +244,7 @@ describe("runCronIsolatedAgentTurn", () => {
|
|||||||
}),
|
}),
|
||||||
sendMessageDiscord: vi.fn(),
|
sendMessageDiscord: vi.fn(),
|
||||||
sendMessageSignal: vi.fn(),
|
sendMessageSignal: vi.fn(),
|
||||||
|
sendMessageIMessage: vi.fn(),
|
||||||
};
|
};
|
||||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||||
payloads: [{ text: "hello from cron" }],
|
payloads: [{ text: "hello from cron" }],
|
||||||
@@ -294,6 +299,7 @@ describe("runCronIsolatedAgentTurn", () => {
|
|||||||
channelId: "chan",
|
channelId: "chan",
|
||||||
}),
|
}),
|
||||||
sendMessageSignal: vi.fn(),
|
sendMessageSignal: vi.fn(),
|
||||||
|
sendMessageIMessage: vi.fn(),
|
||||||
};
|
};
|
||||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||||
payloads: [{ text: "hello from cron" }],
|
payloads: [{ text: "hello from cron" }],
|
||||||
|
|||||||
@@ -14,7 +14,13 @@ export type CronPayload =
|
|||||||
thinking?: string;
|
thinking?: string;
|
||||||
timeoutSeconds?: number;
|
timeoutSeconds?: number;
|
||||||
deliver?: boolean;
|
deliver?: boolean;
|
||||||
channel?: "last" | "whatsapp" | "telegram" | "discord" | "signal";
|
channel?:
|
||||||
|
| "last"
|
||||||
|
| "whatsapp"
|
||||||
|
| "telegram"
|
||||||
|
| "discord"
|
||||||
|
| "signal"
|
||||||
|
| "imessage";
|
||||||
to?: string;
|
to?: string;
|
||||||
bestEffortDeliver?: boolean;
|
bestEffortDeliver?: boolean;
|
||||||
};
|
};
|
||||||
|
|||||||
213
src/imessage/client.ts
Normal file
213
src/imessage/client.ts
Normal file
@@ -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<T> = {
|
||||||
|
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<string, PendingRequest>();
|
||||||
|
private readonly closed: Promise<void>;
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void>((resolve) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!child.killed) child.kill("SIGTERM");
|
||||||
|
resolve();
|
||||||
|
}, 500);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async waitForClose(): Promise<void> {
|
||||||
|
await this.closed;
|
||||||
|
}
|
||||||
|
|
||||||
|
async request<T = unknown>(
|
||||||
|
method: string,
|
||||||
|
params?: Record<string, unknown>,
|
||||||
|
opts?: { timeoutMs?: number },
|
||||||
|
): Promise<T> {
|
||||||
|
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<T>((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<unknown>;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(line) as IMessageRpcResponse<unknown>;
|
||||||
|
} 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<IMessageRpcClient> {
|
||||||
|
const client = new IMessageRpcClient(opts);
|
||||||
|
await client.start();
|
||||||
|
return client;
|
||||||
|
}
|
||||||
3
src/imessage/index.ts
Normal file
3
src/imessage/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export { monitorIMessageProvider } from "./monitor.js";
|
||||||
|
export { probeIMessage } from "./probe.js";
|
||||||
|
export { sendMessageIMessage } from "./send.js";
|
||||||
194
src/imessage/monitor.test.ts
Normal file
194
src/imessage/monitor.test.ts
Normal file
@@ -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<string, unknown> = {};
|
||||||
|
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<void>((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",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
303
src/imessage/monitor.ts
Normal file
303
src/imessage/monitor.ts
Normal file
@@ -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<string | number>;
|
||||||
|
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<typeof loadConfig>): 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<ReturnType<typeof createIMessageRpcClient>>;
|
||||||
|
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<void> {
|
||||||
|
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 ? `<media:${kind}>` : attachments?.length ? "<media:attachment>" : "";
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
33
src/imessage/probe.ts
Normal file
33
src/imessage/probe.ts
Normal file
@@ -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<IMessageProbe> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
64
src/imessage/send.test.ts
Normal file
64
src/imessage/send.test.ts
Normal file
@@ -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<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
expect(params.file).toBe("/tmp/imessage-media.jpg");
|
||||||
|
expect(params.text).toBe("<media:image>");
|
||||||
|
});
|
||||||
|
});
|
||||||
127
src/imessage/send.ts
Normal file
127
src/imessage/send.ts
Normal file
@@ -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<IMessageSendResult> {
|
||||||
|
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" ? "<media:image>" : `<media:${kind}>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!message.trim() && !filePath) {
|
||||||
|
throw new Error("iMessage send requires text or media");
|
||||||
|
}
|
||||||
|
|
||||||
|
const params: Record<string, unknown> = {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
56
src/imessage/targets.test.ts
Normal file
56
src/imessage/targets.test.ts
Normal file
@@ -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("");
|
||||||
|
});
|
||||||
|
});
|
||||||
172
src/imessage/targets.ts
Normal file
172
src/imessage/targets.ts
Normal file
@@ -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<string | number>;
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
saveSessionStore,
|
saveSessionStore,
|
||||||
} from "../config/sessions.js";
|
} from "../config/sessions.js";
|
||||||
import { sendMessageDiscord } from "../discord/send.js";
|
import { sendMessageDiscord } from "../discord/send.js";
|
||||||
|
import { sendMessageIMessage } from "../imessage/send.js";
|
||||||
import { formatErrorMessage } from "../infra/errors.js";
|
import { formatErrorMessage } from "../infra/errors.js";
|
||||||
import { createSubsystemLogger } from "../logging.js";
|
import { createSubsystemLogger } from "../logging.js";
|
||||||
import { getQueueSize } from "../process/command-queue.js";
|
import { getQueueSize } from "../process/command-queue.js";
|
||||||
@@ -38,10 +39,11 @@ export type HeartbeatTarget =
|
|||||||
| "telegram"
|
| "telegram"
|
||||||
| "discord"
|
| "discord"
|
||||||
| "signal"
|
| "signal"
|
||||||
|
| "imessage"
|
||||||
| "none";
|
| "none";
|
||||||
|
|
||||||
export type HeartbeatDeliveryTarget = {
|
export type HeartbeatDeliveryTarget = {
|
||||||
channel: "whatsapp" | "telegram" | "discord" | "signal" | "none";
|
channel: "whatsapp" | "telegram" | "discord" | "signal" | "imessage" | "none";
|
||||||
to?: string;
|
to?: string;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
};
|
};
|
||||||
@@ -52,6 +54,7 @@ type HeartbeatDeps = {
|
|||||||
sendTelegram?: typeof sendMessageTelegram;
|
sendTelegram?: typeof sendMessageTelegram;
|
||||||
sendDiscord?: typeof sendMessageDiscord;
|
sendDiscord?: typeof sendMessageDiscord;
|
||||||
sendSignal?: typeof sendMessageSignal;
|
sendSignal?: typeof sendMessageSignal;
|
||||||
|
sendIMessage?: typeof sendMessageIMessage;
|
||||||
getQueueSize?: (lane?: string) => number;
|
getQueueSize?: (lane?: string) => number;
|
||||||
nowMs?: () => number;
|
nowMs?: () => number;
|
||||||
webAuthExists?: () => Promise<boolean>;
|
webAuthExists?: () => Promise<boolean>;
|
||||||
@@ -181,6 +184,7 @@ export function resolveHeartbeatDeliveryTarget(params: {
|
|||||||
rawTarget === "telegram" ||
|
rawTarget === "telegram" ||
|
||||||
rawTarget === "discord" ||
|
rawTarget === "discord" ||
|
||||||
rawTarget === "signal" ||
|
rawTarget === "signal" ||
|
||||||
|
rawTarget === "imessage" ||
|
||||||
rawTarget === "none" ||
|
rawTarget === "none" ||
|
||||||
rawTarget === "last"
|
rawTarget === "last"
|
||||||
? rawTarget
|
? rawTarget
|
||||||
@@ -201,13 +205,20 @@ export function resolveHeartbeatDeliveryTarget(params: {
|
|||||||
: undefined;
|
: undefined;
|
||||||
const lastTo = typeof entry?.lastTo === "string" ? entry.lastTo.trim() : "";
|
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"
|
target === "last"
|
||||||
? lastChannel
|
? lastChannel
|
||||||
: target === "whatsapp" ||
|
: target === "whatsapp" ||
|
||||||
target === "telegram" ||
|
target === "telegram" ||
|
||||||
target === "discord" ||
|
target === "discord" ||
|
||||||
target === "signal"
|
target === "signal" ||
|
||||||
|
target === "imessage"
|
||||||
? target
|
? target
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
@@ -274,14 +285,18 @@ function normalizeHeartbeatReply(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function deliverHeartbeatReply(params: {
|
async function deliverHeartbeatReply(params: {
|
||||||
channel: "whatsapp" | "telegram" | "discord" | "signal";
|
channel: "whatsapp" | "telegram" | "discord" | "signal" | "imessage";
|
||||||
to: string;
|
to: string;
|
||||||
text: string;
|
text: string;
|
||||||
mediaUrls: string[];
|
mediaUrls: string[];
|
||||||
deps: Required<
|
deps: Required<
|
||||||
Pick<
|
Pick<
|
||||||
HeartbeatDeps,
|
HeartbeatDeps,
|
||||||
"sendWhatsApp" | "sendTelegram" | "sendDiscord" | "sendSignal"
|
| "sendWhatsApp"
|
||||||
|
| "sendTelegram"
|
||||||
|
| "sendDiscord"
|
||||||
|
| "sendSignal"
|
||||||
|
| "sendIMessage"
|
||||||
>
|
>
|
||||||
>;
|
>;
|
||||||
}) {
|
}) {
|
||||||
@@ -318,6 +333,22 @@ async function deliverHeartbeatReply(params: {
|
|||||||
return;
|
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 (channel === "telegram") {
|
||||||
if (mediaUrls.length === 0) {
|
if (mediaUrls.length === 0) {
|
||||||
for (const chunk of chunkText(text, 4000)) {
|
for (const chunk of chunkText(text, 4000)) {
|
||||||
@@ -464,6 +495,7 @@ export async function runHeartbeatOnce(opts: {
|
|||||||
sendTelegram: opts.deps?.sendTelegram ?? sendMessageTelegram,
|
sendTelegram: opts.deps?.sendTelegram ?? sendMessageTelegram,
|
||||||
sendDiscord: opts.deps?.sendDiscord ?? sendMessageDiscord,
|
sendDiscord: opts.deps?.sendDiscord ?? sendMessageDiscord,
|
||||||
sendSignal: opts.deps?.sendSignal ?? sendMessageSignal,
|
sendSignal: opts.deps?.sendSignal ?? sendMessageSignal,
|
||||||
|
sendIMessage: opts.deps?.sendIMessage ?? sendMessageIMessage,
|
||||||
};
|
};
|
||||||
await deliverHeartbeatReply({
|
await deliverHeartbeatReply({
|
||||||
channel: delivery.channel,
|
channel: delivery.channel,
|
||||||
|
|||||||
@@ -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
|
const allowFrom = effective.routing?.allowFrom?.length
|
||||||
? effective.routing.allowFrom.map(normalizeE164).filter(Boolean)
|
? effective.routing.allowFrom.map(normalizeE164).filter(Boolean)
|
||||||
: [];
|
: [];
|
||||||
|
|||||||
Reference in New Issue
Block a user