From 0ce23dc62d376f5625a3c6c572d07d8cac0c16dc Mon Sep 17 00:00:00 2001 From: scoootscooob <167050519+scoootscooob@users.noreply.github.com> Date: Sat, 14 Mar 2026 02:44:23 -0700 Subject: [PATCH] refactor: move iMessage channel to extensions/imessage (#45539) --- extensions/imessage/src/accounts.ts | 70 +++ extensions/imessage/src/client.ts | 255 +++++++++ extensions/imessage/src/constants.ts | 2 + .../imessage/src}/monitor.gating.test.ts | 2 +- ...nitor.shutdown.unhandled-rejection.test.ts | 0 extensions/imessage/src/monitor.ts | 2 + .../imessage/src/monitor/abort-handler.ts | 34 ++ .../imessage/src}/monitor/deliver.test.ts | 10 +- extensions/imessage/src/monitor/deliver.ts | 70 +++ extensions/imessage/src/monitor/echo-cache.ts | 87 +++ .../src}/monitor/inbound-processing.test.ts | 4 +- .../src/monitor/inbound-processing.ts | 525 +++++++++++++++++ .../src}/monitor/loop-rate-limiter.test.ts | 0 .../imessage/src/monitor/loop-rate-limiter.ts | 69 +++ .../monitor-provider.echo-cache.test.ts | 0 .../imessage/src/monitor/monitor-provider.ts | 537 +++++++++++++++++ .../src/monitor/parse-notification.ts | 83 +++ .../monitor/provider.group-policy.test.ts | 2 +- .../src}/monitor/reflection-guard.test.ts | 0 .../imessage/src/monitor/reflection-guard.ts | 64 +++ extensions/imessage/src/monitor/runtime.ts | 11 + .../src}/monitor/sanitize-outbound.test.ts | 0 .../imessage/src/monitor/sanitize-outbound.ts | 31 + .../src}/monitor/self-chat-cache.test.ts | 0 .../imessage/src/monitor/self-chat-cache.ts | 103 ++++ extensions/imessage/src/monitor/types.ts | 40 ++ .../imessage/src}/probe.test.ts | 4 +- extensions/imessage/src/probe.ts | 105 ++++ .../imessage/src}/send.test.ts | 0 extensions/imessage/src/send.ts | 190 ++++++ .../imessage/src/target-parsing-helpers.ts | 223 ++++++++ .../imessage/src}/targets.test.ts | 0 extensions/imessage/src/targets.ts | 147 +++++ src/imessage/accounts.ts | 72 +-- src/imessage/client.ts | 257 +-------- src/imessage/constants.ts | 4 +- src/imessage/monitor.ts | 4 +- src/imessage/monitor/abort-handler.ts | 36 +- src/imessage/monitor/deliver.ts | 72 +-- src/imessage/monitor/echo-cache.ts | 89 +-- src/imessage/monitor/inbound-processing.ts | 524 +---------------- src/imessage/monitor/loop-rate-limiter.ts | 71 +-- src/imessage/monitor/monitor-provider.ts | 539 +----------------- src/imessage/monitor/parse-notification.ts | 85 +-- src/imessage/monitor/reflection-guard.ts | 66 +-- src/imessage/monitor/runtime.ts | 13 +- src/imessage/monitor/sanitize-outbound.ts | 33 +- src/imessage/monitor/self-chat-cache.ts | 105 +--- src/imessage/monitor/types.ts | 42 +- src/imessage/probe.ts | 107 +--- src/imessage/send.ts | 192 +------ src/imessage/target-parsing-helpers.ts | 225 +------- src/imessage/targets.ts | 149 +---- 53 files changed, 2699 insertions(+), 2656 deletions(-) create mode 100644 extensions/imessage/src/accounts.ts create mode 100644 extensions/imessage/src/client.ts create mode 100644 extensions/imessage/src/constants.ts rename {src/imessage => extensions/imessage/src}/monitor.gating.test.ts (99%) rename {src/imessage => extensions/imessage/src}/monitor.shutdown.unhandled-rejection.test.ts (100%) create mode 100644 extensions/imessage/src/monitor.ts create mode 100644 extensions/imessage/src/monitor/abort-handler.ts rename {src/imessage => extensions/imessage/src}/monitor/deliver.test.ts (93%) create mode 100644 extensions/imessage/src/monitor/deliver.ts create mode 100644 extensions/imessage/src/monitor/echo-cache.ts rename {src/imessage => extensions/imessage/src}/monitor/inbound-processing.test.ts (98%) create mode 100644 extensions/imessage/src/monitor/inbound-processing.ts rename {src/imessage => extensions/imessage/src}/monitor/loop-rate-limiter.test.ts (100%) create mode 100644 extensions/imessage/src/monitor/loop-rate-limiter.ts rename {src/imessage => extensions/imessage/src}/monitor/monitor-provider.echo-cache.test.ts (100%) create mode 100644 extensions/imessage/src/monitor/monitor-provider.ts create mode 100644 extensions/imessage/src/monitor/parse-notification.ts rename {src/imessage => extensions/imessage/src}/monitor/provider.group-policy.test.ts (91%) rename {src/imessage => extensions/imessage/src}/monitor/reflection-guard.test.ts (100%) create mode 100644 extensions/imessage/src/monitor/reflection-guard.ts create mode 100644 extensions/imessage/src/monitor/runtime.ts rename {src/imessage => extensions/imessage/src}/monitor/sanitize-outbound.test.ts (100%) create mode 100644 extensions/imessage/src/monitor/sanitize-outbound.ts rename {src/imessage => extensions/imessage/src}/monitor/self-chat-cache.test.ts (100%) create mode 100644 extensions/imessage/src/monitor/self-chat-cache.ts create mode 100644 extensions/imessage/src/monitor/types.ts rename {src/imessage => extensions/imessage/src}/probe.test.ts (91%) create mode 100644 extensions/imessage/src/probe.ts rename {src/imessage => extensions/imessage/src}/send.test.ts (100%) create mode 100644 extensions/imessage/src/send.ts create mode 100644 extensions/imessage/src/target-parsing-helpers.ts rename {src/imessage => extensions/imessage/src}/targets.test.ts (100%) create mode 100644 extensions/imessage/src/targets.ts diff --git a/extensions/imessage/src/accounts.ts b/extensions/imessage/src/accounts.ts new file mode 100644 index 00000000000..f370fd54860 --- /dev/null +++ b/extensions/imessage/src/accounts.ts @@ -0,0 +1,70 @@ +import { createAccountListHelpers } from "../../../src/channels/plugins/account-helpers.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { IMessageAccountConfig } from "../../../src/config/types.js"; +import { resolveAccountEntry } from "../../../src/routing/account-lookup.js"; +import { normalizeAccountId } from "../../../src/routing/session-key.js"; + +export type ResolvedIMessageAccount = { + accountId: string; + enabled: boolean; + name?: string; + config: IMessageAccountConfig; + configured: boolean; +}; + +const { listAccountIds, resolveDefaultAccountId } = createAccountListHelpers("imessage"); +export const listIMessageAccountIds = listAccountIds; +export const resolveDefaultIMessageAccountId = resolveDefaultAccountId; + +function resolveAccountConfig( + cfg: OpenClawConfig, + accountId: string, +): IMessageAccountConfig | undefined { + return resolveAccountEntry(cfg.channels?.imessage?.accounts, accountId); +} + +function mergeIMessageAccountConfig(cfg: OpenClawConfig, accountId: string): IMessageAccountConfig { + const { accounts: _ignored, ...base } = (cfg.channels?.imessage ?? + {}) as IMessageAccountConfig & { accounts?: unknown }; + const account = resolveAccountConfig(cfg, accountId) ?? {}; + return { ...base, ...account }; +} + +export function resolveIMessageAccount(params: { + cfg: OpenClawConfig; + accountId?: string | null; +}): ResolvedIMessageAccount { + const accountId = normalizeAccountId(params.accountId); + const baseEnabled = params.cfg.channels?.imessage?.enabled !== false; + const merged = mergeIMessageAccountConfig(params.cfg, accountId); + const accountEnabled = merged.enabled !== false; + const configured = Boolean( + merged.cliPath?.trim() || + merged.dbPath?.trim() || + merged.service || + merged.region?.trim() || + (merged.allowFrom && merged.allowFrom.length > 0) || + (merged.groupAllowFrom && merged.groupAllowFrom.length > 0) || + merged.dmPolicy || + merged.groupPolicy || + typeof merged.includeAttachments === "boolean" || + (merged.attachmentRoots && merged.attachmentRoots.length > 0) || + (merged.remoteAttachmentRoots && merged.remoteAttachmentRoots.length > 0) || + typeof merged.mediaMaxMb === "number" || + typeof merged.textChunkLimit === "number" || + (merged.groups && Object.keys(merged.groups).length > 0), + ); + return { + accountId, + enabled: baseEnabled && accountEnabled, + name: merged.name?.trim() || undefined, + config: merged, + configured, + }; +} + +export function listEnabledIMessageAccounts(cfg: OpenClawConfig): ResolvedIMessageAccount[] { + return listIMessageAccountIds(cfg) + .map((accountId) => resolveIMessageAccount({ cfg, accountId })) + .filter((account) => account.enabled); +} diff --git a/extensions/imessage/src/client.ts b/extensions/imessage/src/client.ts new file mode 100644 index 00000000000..efe9e5deb3b --- /dev/null +++ b/extensions/imessage/src/client.ts @@ -0,0 +1,255 @@ +import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process"; +import { createInterface, type Interface } from "node:readline"; +import type { RuntimeEnv } from "../../../src/runtime.js"; +import { resolveUserPath } from "../../../src/utils.js"; +import { DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS } from "./constants.js"; + +export type IMessageRpcError = { + code?: number; + message?: string; + data?: unknown; +}; + +export type IMessageRpcResponse = { + jsonrpc?: string; + id?: string | number | null; + result?: T; + error?: IMessageRpcError; + method?: string; + params?: unknown; +}; + +export type IMessageRpcNotification = { + method: string; + params?: unknown; +}; + +export type IMessageRpcClientOptions = { + cliPath?: string; + dbPath?: string; + runtime?: RuntimeEnv; + onNotification?: (msg: IMessageRpcNotification) => void; +}; + +type PendingRequest = { + resolve: (value: unknown) => void; + reject: (error: Error) => void; + timer?: NodeJS.Timeout; +}; + +function isTestEnv(): boolean { + if (process.env.NODE_ENV === "test") { + return true; + } + const vitest = process.env.VITEST?.trim().toLowerCase(); + return Boolean(vitest); +} + +export class IMessageRpcClient { + private readonly cliPath: string; + private readonly dbPath?: string; + private readonly runtime?: RuntimeEnv; + private readonly onNotification?: (msg: IMessageRpcNotification) => void; + private readonly pending = new Map(); + private readonly closed: Promise; + private closedResolve: (() => void) | null = null; + private child: ChildProcessWithoutNullStreams | null = null; + private reader: Interface | null = null; + private nextId = 1; + + constructor(opts: IMessageRpcClientOptions = {}) { + this.cliPath = opts.cliPath?.trim() || "imsg"; + this.dbPath = opts.dbPath?.trim() ? resolveUserPath(opts.dbPath) : undefined; + this.runtime = opts.runtime; + this.onNotification = opts.onNotification; + this.closed = new Promise((resolve) => { + this.closedResolve = resolve; + }); + } + + async start(): Promise { + if (this.child) { + return; + } + if (isTestEnv()) { + throw new Error("Refusing to start imsg rpc in test environment; mock iMessage RPC client"); + } + const args = ["rpc"]; + if (this.dbPath) { + args.push("--db", this.dbPath); + } + const child = spawn(this.cliPath, args, { + stdio: ["pipe", "pipe", "pipe"], + }); + this.child = child; + this.reader = createInterface({ input: child.stdout }); + + this.reader.on("line", (line) => { + const trimmed = line.trim(); + if (!trimmed) { + return; + } + this.handleLine(trimmed); + }); + + child.stderr?.on("data", (chunk) => { + const lines = chunk.toString().split(/\r?\n/); + for (const line of lines) { + if (!line.trim()) { + continue; + } + this.runtime?.error?.(`imsg rpc: ${line.trim()}`); + } + }); + + child.on("error", (err) => { + this.failAll(err instanceof Error ? err : new Error(String(err))); + this.closedResolve?.(); + }); + + child.on("close", (code, signal) => { + if (code !== 0 && code !== null) { + const reason = signal ? `signal ${signal}` : `code ${code}`; + this.failAll(new Error(`imsg rpc exited (${reason})`)); + } else { + this.failAll(new Error("imsg rpc closed")); + } + this.closedResolve?.(); + }); + } + + async stop(): Promise { + if (!this.child) { + return; + } + this.reader?.close(); + this.reader = null; + this.child.stdin?.end(); + const child = this.child; + this.child = null; + + await Promise.race([ + this.closed, + new Promise((resolve) => { + setTimeout(() => { + if (!child.killed) { + child.kill("SIGTERM"); + } + resolve(); + }, 500); + }), + ]); + } + + async waitForClose(): Promise { + await this.closed; + } + + async request( + method: string, + params?: Record, + opts?: { timeoutMs?: number }, + ): Promise { + if (!this.child || !this.child.stdin) { + throw new Error("imsg rpc not running"); + } + const id = this.nextId++; + const payload = { + jsonrpc: "2.0", + id, + method, + params: params ?? {}, + }; + const line = `${JSON.stringify(payload)}\n`; + const timeoutMs = opts?.timeoutMs ?? DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS; + + const response = new Promise((resolve, reject) => { + const key = String(id); + const timer = + timeoutMs > 0 + ? setTimeout(() => { + this.pending.delete(key); + reject(new Error(`imsg rpc timeout (${method})`)); + }, timeoutMs) + : undefined; + this.pending.set(key, { + resolve: (value) => resolve(value as T), + reject, + timer, + }); + }); + + this.child.stdin.write(line); + return await response; + } + + private handleLine(line: string) { + let parsed: IMessageRpcResponse; + try { + parsed = JSON.parse(line) as IMessageRpcResponse; + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + this.runtime?.error?.(`imsg rpc: failed to parse ${line}: ${detail}`); + 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 baseMessage = parsed.error.message ?? "imsg rpc error"; + const details = parsed.error.data; + const code = parsed.error.code; + const suffixes = [] as string[]; + if (typeof code === "number") { + suffixes.push(`code=${code}`); + } + if (details !== undefined) { + const detailText = + typeof details === "string" ? details : JSON.stringify(details, null, 2); + if (detailText) { + suffixes.push(detailText); + } + } + const msg = suffixes.length > 0 ? `${baseMessage}: ${suffixes.join(" ")}` : baseMessage; + pending.reject(new Error(msg)); + return; + } + pending.resolve(parsed.result); + return; + } + + if (parsed.method) { + this.onNotification?.({ + method: parsed.method, + params: parsed.params, + }); + } + } + + private failAll(err: Error) { + for (const [key, pending] of this.pending.entries()) { + if (pending.timer) { + clearTimeout(pending.timer); + } + pending.reject(err); + this.pending.delete(key); + } + } +} + +export async function createIMessageRpcClient( + opts: IMessageRpcClientOptions = {}, +): Promise { + const client = new IMessageRpcClient(opts); + await client.start(); + return client; +} diff --git a/extensions/imessage/src/constants.ts b/extensions/imessage/src/constants.ts new file mode 100644 index 00000000000..d82eaa5028b --- /dev/null +++ b/extensions/imessage/src/constants.ts @@ -0,0 +1,2 @@ +/** Default timeout for iMessage probe/RPC operations (10 seconds). */ +export const DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS = 10_000; diff --git a/src/imessage/monitor.gating.test.ts b/extensions/imessage/src/monitor.gating.test.ts similarity index 99% rename from src/imessage/monitor.gating.test.ts rename to extensions/imessage/src/monitor.gating.test.ts index 36a324e009b..2e564cc30cf 100644 --- a/src/imessage/monitor.gating.test.ts +++ b/extensions/imessage/src/monitor.gating.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import type { OpenClawConfig } from "../config/config.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; import { buildIMessageInboundContext, resolveIMessageInboundDecision, diff --git a/src/imessage/monitor.shutdown.unhandled-rejection.test.ts b/extensions/imessage/src/monitor.shutdown.unhandled-rejection.test.ts similarity index 100% rename from src/imessage/monitor.shutdown.unhandled-rejection.test.ts rename to extensions/imessage/src/monitor.shutdown.unhandled-rejection.test.ts diff --git a/extensions/imessage/src/monitor.ts b/extensions/imessage/src/monitor.ts new file mode 100644 index 00000000000..487e99e5911 --- /dev/null +++ b/extensions/imessage/src/monitor.ts @@ -0,0 +1,2 @@ +export { monitorIMessageProvider } from "./monitor/monitor-provider.js"; +export type { MonitorIMessageOpts } from "./monitor/types.js"; diff --git a/extensions/imessage/src/monitor/abort-handler.ts b/extensions/imessage/src/monitor/abort-handler.ts new file mode 100644 index 00000000000..bd5388260df --- /dev/null +++ b/extensions/imessage/src/monitor/abort-handler.ts @@ -0,0 +1,34 @@ +export type IMessageMonitorClient = { + request: (method: string, params?: Record) => Promise; + stop: () => Promise; +}; + +export function attachIMessageMonitorAbortHandler(params: { + abortSignal?: AbortSignal; + client: IMessageMonitorClient; + getSubscriptionId: () => number | null; +}): () => void { + const abort = params.abortSignal; + if (!abort) { + return () => {}; + } + + const onAbort = () => { + const subscriptionId = params.getSubscriptionId(); + if (subscriptionId) { + void params.client + .request("watch.unsubscribe", { + subscription: subscriptionId, + }) + .catch(() => { + // Ignore disconnect errors during shutdown. + }); + } + void params.client.stop().catch(() => { + // Ignore disconnect errors during shutdown. + }); + }; + + abort.addEventListener("abort", onAbort, { once: true }); + return () => abort.removeEventListener("abort", onAbort); +} diff --git a/src/imessage/monitor/deliver.test.ts b/extensions/imessage/src/monitor/deliver.test.ts similarity index 93% rename from src/imessage/monitor/deliver.test.ts rename to extensions/imessage/src/monitor/deliver.test.ts index 9db03d6ace5..75d18eec71e 100644 --- a/src/imessage/monitor/deliver.test.ts +++ b/extensions/imessage/src/monitor/deliver.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { RuntimeEnv } from "../../runtime.js"; +import type { RuntimeEnv } from "../../../../src/runtime.js"; const sendMessageIMessageMock = vi.hoisted(() => vi.fn().mockResolvedValue({ messageId: "imsg-1" }), @@ -14,20 +14,20 @@ vi.mock("../send.js", () => ({ sendMessageIMessageMock(to, message, opts), })); -vi.mock("../../auto-reply/chunk.js", () => ({ +vi.mock("../../../../src/auto-reply/chunk.js", () => ({ chunkTextWithMode: (text: string) => chunkTextWithModeMock(text), resolveChunkMode: () => resolveChunkModeMock(), })); -vi.mock("../../config/config.js", () => ({ +vi.mock("../../../../src/config/config.js", () => ({ loadConfig: () => ({}), })); -vi.mock("../../config/markdown-tables.js", () => ({ +vi.mock("../../../../src/config/markdown-tables.js", () => ({ resolveMarkdownTableMode: () => resolveMarkdownTableModeMock(), })); -vi.mock("../../markdown/tables.js", () => ({ +vi.mock("../../../../src/markdown/tables.js", () => ({ convertMarkdownTables: (text: string) => convertMarkdownTablesMock(text), })); diff --git a/extensions/imessage/src/monitor/deliver.ts b/extensions/imessage/src/monitor/deliver.ts new file mode 100644 index 00000000000..e8db8c0cac9 --- /dev/null +++ b/extensions/imessage/src/monitor/deliver.ts @@ -0,0 +1,70 @@ +import { chunkTextWithMode, resolveChunkMode } from "../../../../src/auto-reply/chunk.js"; +import type { ReplyPayload } from "../../../../src/auto-reply/types.js"; +import { loadConfig } from "../../../../src/config/config.js"; +import { resolveMarkdownTableMode } from "../../../../src/config/markdown-tables.js"; +import { convertMarkdownTables } from "../../../../src/markdown/tables.js"; +import type { RuntimeEnv } from "../../../../src/runtime.js"; +import type { createIMessageRpcClient } from "../client.js"; +import { sendMessageIMessage } from "../send.js"; +import type { SentMessageCache } from "./echo-cache.js"; +import { sanitizeOutboundText } from "./sanitize-outbound.js"; + +export async function deliverReplies(params: { + replies: ReplyPayload[]; + target: string; + client: Awaited>; + accountId?: string; + runtime: RuntimeEnv; + maxBytes: number; + textLimit: number; + sentMessageCache?: Pick; +}) { + const { replies, target, client, runtime, maxBytes, textLimit, accountId, sentMessageCache } = + params; + const scope = `${accountId ?? ""}:${target}`; + const cfg = loadConfig(); + const tableMode = resolveMarkdownTableMode({ + cfg, + channel: "imessage", + accountId, + }); + const chunkMode = resolveChunkMode(cfg, "imessage", accountId); + for (const payload of replies) { + const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); + const rawText = sanitizeOutboundText(payload.text ?? ""); + const text = convertMarkdownTables(rawText, tableMode); + if (!text && mediaList.length === 0) { + continue; + } + if (mediaList.length === 0) { + sentMessageCache?.remember(scope, { text }); + for (const chunk of chunkTextWithMode(text, textLimit, chunkMode)) { + const sent = await sendMessageIMessage(target, chunk, { + maxBytes, + client, + accountId, + replyToId: payload.replyToId, + }); + sentMessageCache?.remember(scope, { text: chunk, messageId: sent.messageId }); + } + } else { + let first = true; + for (const url of mediaList) { + const caption = first ? text : ""; + first = false; + const sent = await sendMessageIMessage(target, caption, { + mediaUrl: url, + maxBytes, + client, + accountId, + replyToId: payload.replyToId, + }); + sentMessageCache?.remember(scope, { + text: caption || undefined, + messageId: sent.messageId, + }); + } + } + runtime.log?.(`imessage: delivered reply to ${target}`); + } +} diff --git a/extensions/imessage/src/monitor/echo-cache.ts b/extensions/imessage/src/monitor/echo-cache.ts new file mode 100644 index 00000000000..06f5ee847f5 --- /dev/null +++ b/extensions/imessage/src/monitor/echo-cache.ts @@ -0,0 +1,87 @@ +export type SentMessageLookup = { + text?: string; + messageId?: string; +}; + +export type SentMessageCache = { + remember: (scope: string, lookup: SentMessageLookup) => void; + has: (scope: string, lookup: SentMessageLookup) => boolean; +}; + +// Keep the text fallback short so repeated user replies like "ok" are not +// suppressed for long; delayed reflections should match the stronger message-id key. +const SENT_MESSAGE_TEXT_TTL_MS = 5_000; +const SENT_MESSAGE_ID_TTL_MS = 60_000; + +function normalizeEchoTextKey(text: string | undefined): string | null { + if (!text) { + return null; + } + const normalized = text.replace(/\r\n?/g, "\n").trim(); + return normalized ? normalized : null; +} + +function normalizeEchoMessageIdKey(messageId: string | undefined): string | null { + if (!messageId) { + return null; + } + const normalized = messageId.trim(); + if (!normalized || normalized === "ok" || normalized === "unknown") { + return null; + } + return normalized; +} + +class DefaultSentMessageCache implements SentMessageCache { + private textCache = new Map(); + private messageIdCache = new Map(); + + remember(scope: string, lookup: SentMessageLookup): void { + const textKey = normalizeEchoTextKey(lookup.text); + if (textKey) { + this.textCache.set(`${scope}:${textKey}`, Date.now()); + } + const messageIdKey = normalizeEchoMessageIdKey(lookup.messageId); + if (messageIdKey) { + this.messageIdCache.set(`${scope}:${messageIdKey}`, Date.now()); + } + this.cleanup(); + } + + has(scope: string, lookup: SentMessageLookup): boolean { + this.cleanup(); + const messageIdKey = normalizeEchoMessageIdKey(lookup.messageId); + if (messageIdKey) { + const idTimestamp = this.messageIdCache.get(`${scope}:${messageIdKey}`); + if (idTimestamp && Date.now() - idTimestamp <= SENT_MESSAGE_ID_TTL_MS) { + return true; + } + } + const textKey = normalizeEchoTextKey(lookup.text); + if (textKey) { + const textTimestamp = this.textCache.get(`${scope}:${textKey}`); + if (textTimestamp && Date.now() - textTimestamp <= SENT_MESSAGE_TEXT_TTL_MS) { + return true; + } + } + return false; + } + + private cleanup(): void { + const now = Date.now(); + for (const [key, timestamp] of this.textCache.entries()) { + if (now - timestamp > SENT_MESSAGE_TEXT_TTL_MS) { + this.textCache.delete(key); + } + } + for (const [key, timestamp] of this.messageIdCache.entries()) { + if (now - timestamp > SENT_MESSAGE_ID_TTL_MS) { + this.messageIdCache.delete(key); + } + } + } +} + +export function createSentMessageCache(): SentMessageCache { + return new DefaultSentMessageCache(); +} diff --git a/src/imessage/monitor/inbound-processing.test.ts b/extensions/imessage/src/monitor/inbound-processing.test.ts similarity index 98% rename from src/imessage/monitor/inbound-processing.test.ts rename to extensions/imessage/src/monitor/inbound-processing.test.ts index d2adc37bf74..4575a28de36 100644 --- a/src/imessage/monitor/inbound-processing.test.ts +++ b/extensions/imessage/src/monitor/inbound-processing.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../config/config.js"; -import { sanitizeTerminalText } from "../../terminal/safe-text.js"; +import type { OpenClawConfig } from "../../../../src/config/config.js"; +import { sanitizeTerminalText } from "../../../../src/terminal/safe-text.js"; import { describeIMessageEchoDropLog, resolveIMessageInboundDecision, diff --git a/extensions/imessage/src/monitor/inbound-processing.ts b/extensions/imessage/src/monitor/inbound-processing.ts new file mode 100644 index 00000000000..af900e21b40 --- /dev/null +++ b/extensions/imessage/src/monitor/inbound-processing.ts @@ -0,0 +1,525 @@ +import { hasControlCommand } from "../../../../src/auto-reply/command-detection.js"; +import { + formatInboundEnvelope, + formatInboundFromLabel, + resolveEnvelopeFormatOptions, + type EnvelopeFormatOptions, +} from "../../../../src/auto-reply/envelope.js"; +import { + buildPendingHistoryContextFromMap, + recordPendingHistoryEntryIfEnabled, + type HistoryEntry, +} from "../../../../src/auto-reply/reply/history.js"; +import { finalizeInboundContext } from "../../../../src/auto-reply/reply/inbound-context.js"; +import { + buildMentionRegexes, + matchesMentionPatterns, +} from "../../../../src/auto-reply/reply/mentions.js"; +import { resolveDualTextControlCommandGate } from "../../../../src/channels/command-gating.js"; +import { logInboundDrop } from "../../../../src/channels/logging.js"; +import type { OpenClawConfig } from "../../../../src/config/config.js"; +import { + resolveChannelGroupPolicy, + resolveChannelGroupRequireMention, +} from "../../../../src/config/group-policy.js"; +import { resolveAgentRoute } from "../../../../src/routing/resolve-route.js"; +import { + DM_GROUP_ACCESS_REASON, + resolveDmGroupAccessWithLists, +} from "../../../../src/security/dm-policy-shared.js"; +import { sanitizeTerminalText } from "../../../../src/terminal/safe-text.js"; +import { truncateUtf16Safe } from "../../../../src/utils.js"; +import { + formatIMessageChatTarget, + isAllowedIMessageSender, + normalizeIMessageHandle, +} from "../targets.js"; +import { detectReflectedContent } from "./reflection-guard.js"; +import type { SelfChatCache } from "./self-chat-cache.js"; +import type { MonitorIMessageOpts, IMessagePayload } from "./types.js"; + +type IMessageReplyContext = { + id?: string; + body: string; + sender?: string; +}; + +function normalizeReplyField(value: unknown): string | undefined { + if (typeof value === "string") { + const trimmed = value.trim(); + return trimmed ? trimmed : undefined; + } + if (typeof value === "number") { + return String(value); + } + return undefined; +} + +function describeReplyContext(message: IMessagePayload): IMessageReplyContext | null { + const body = normalizeReplyField(message.reply_to_text); + if (!body) { + return null; + } + const id = normalizeReplyField(message.reply_to_id); + const sender = normalizeReplyField(message.reply_to_sender); + return { body, id, sender }; +} + +export type IMessageInboundDispatchDecision = { + kind: "dispatch"; + isGroup: boolean; + chatId?: number; + chatGuid?: string; + chatIdentifier?: string; + groupId?: string; + historyKey?: string; + sender: string; + senderNormalized: string; + route: ReturnType; + bodyText: string; + createdAt?: number; + replyContext: IMessageReplyContext | null; + effectiveWasMentioned: boolean; + commandAuthorized: boolean; + // Used for allowlist checks for control commands. + effectiveDmAllowFrom: string[]; + effectiveGroupAllowFrom: string[]; +}; + +export type IMessageInboundDecision = + | { kind: "drop"; reason: string } + | { kind: "pairing"; senderId: string } + | IMessageInboundDispatchDecision; + +export function resolveIMessageInboundDecision(params: { + cfg: OpenClawConfig; + accountId: string; + message: IMessagePayload; + opts?: Pick; + messageText: string; + bodyText: string; + allowFrom: string[]; + groupAllowFrom: string[]; + groupPolicy: string; + dmPolicy: string; + storeAllowFrom: string[]; + historyLimit: number; + groupHistories: Map; + echoCache?: { has: (scope: string, lookup: { text?: string; messageId?: string }) => boolean }; + selfChatCache?: SelfChatCache; + logVerbose?: (msg: string) => void; +}): IMessageInboundDecision { + const senderRaw = params.message.sender ?? ""; + const sender = senderRaw.trim(); + if (!sender) { + return { kind: "drop", reason: "missing sender" }; + } + const senderNormalized = normalizeIMessageHandle(sender); + const chatId = params.message.chat_id ?? undefined; + const chatGuid = params.message.chat_guid ?? undefined; + const chatIdentifier = params.message.chat_identifier ?? undefined; + const createdAt = params.message.created_at ? Date.parse(params.message.created_at) : undefined; + + const groupIdCandidate = chatId !== undefined ? String(chatId) : undefined; + const groupListPolicy = groupIdCandidate + ? resolveChannelGroupPolicy({ + cfg: params.cfg, + channel: "imessage", + accountId: params.accountId, + groupId: groupIdCandidate, + }) + : { + allowlistEnabled: false, + allowed: true, + groupConfig: undefined, + defaultConfig: undefined, + }; + + // If the owner explicitly configures a chat_id under imessage.groups, treat that thread as a + // "group" for permission gating + session isolation, even when is_group=false. + const treatAsGroupByConfig = Boolean( + groupIdCandidate && groupListPolicy.allowlistEnabled && groupListPolicy.groupConfig, + ); + const isGroup = Boolean(params.message.is_group) || treatAsGroupByConfig; + const selfChatLookup = { + accountId: params.accountId, + isGroup, + chatId, + sender, + text: params.bodyText, + createdAt, + }; + if (params.message.is_from_me) { + params.selfChatCache?.remember(selfChatLookup); + return { kind: "drop", reason: "from me" }; + } + if (isGroup && !chatId) { + return { kind: "drop", reason: "group without chat_id" }; + } + + const groupId = isGroup ? groupIdCandidate : undefined; + const accessDecision = resolveDmGroupAccessWithLists({ + isGroup, + dmPolicy: params.dmPolicy, + groupPolicy: params.groupPolicy, + allowFrom: params.allowFrom, + groupAllowFrom: params.groupAllowFrom, + storeAllowFrom: params.storeAllowFrom, + groupAllowFromFallbackToAllowFrom: false, + isSenderAllowed: (allowFrom) => + isAllowedIMessageSender({ + allowFrom, + sender, + chatId, + chatGuid, + chatIdentifier, + }), + }); + const effectiveDmAllowFrom = accessDecision.effectiveAllowFrom; + const effectiveGroupAllowFrom = accessDecision.effectiveGroupAllowFrom; + + if (accessDecision.decision !== "allow") { + if (isGroup) { + if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.GROUP_POLICY_DISABLED) { + params.logVerbose?.("Blocked iMessage group message (groupPolicy: disabled)"); + return { kind: "drop", reason: "groupPolicy disabled" }; + } + if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.GROUP_POLICY_EMPTY_ALLOWLIST) { + params.logVerbose?.( + "Blocked iMessage group message (groupPolicy: allowlist, no groupAllowFrom)", + ); + return { kind: "drop", reason: "groupPolicy allowlist (empty groupAllowFrom)" }; + } + if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.GROUP_POLICY_NOT_ALLOWLISTED) { + params.logVerbose?.(`Blocked iMessage sender ${sender} (not in groupAllowFrom)`); + return { kind: "drop", reason: "not in groupAllowFrom" }; + } + params.logVerbose?.(`Blocked iMessage group message (${accessDecision.reason})`); + return { kind: "drop", reason: accessDecision.reason }; + } + if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.DM_POLICY_DISABLED) { + return { kind: "drop", reason: "dmPolicy disabled" }; + } + if (accessDecision.decision === "pairing") { + return { kind: "pairing", senderId: senderNormalized }; + } + params.logVerbose?.(`Blocked iMessage sender ${sender} (dmPolicy=${params.dmPolicy})`); + return { kind: "drop", reason: "dmPolicy blocked" }; + } + + if (isGroup && groupListPolicy.allowlistEnabled && !groupListPolicy.allowed) { + params.logVerbose?.( + `imessage: skipping group message (${groupId ?? "unknown"}) not in allowlist`, + ); + return { kind: "drop", reason: "group id not in allowlist" }; + } + + const route = resolveAgentRoute({ + cfg: params.cfg, + channel: "imessage", + accountId: params.accountId, + peer: { + kind: isGroup ? "group" : "direct", + id: isGroup ? String(chatId ?? "unknown") : senderNormalized, + }, + }); + const mentionRegexes = buildMentionRegexes(params.cfg, route.agentId); + const messageText = params.messageText.trim(); + const bodyText = params.bodyText.trim(); + if (!bodyText) { + return { kind: "drop", reason: "empty body" }; + } + + if ( + params.selfChatCache?.has({ + ...selfChatLookup, + text: bodyText, + }) + ) { + const preview = sanitizeTerminalText(truncateUtf16Safe(bodyText, 50)); + params.logVerbose?.(`imessage: dropping self-chat reflected duplicate: "${preview}"`); + return { kind: "drop", reason: "self-chat echo" }; + } + + // Echo detection: check if the received message matches a recently sent message. + // Scope by conversation so same text in different chats is not conflated. + const inboundMessageId = params.message.id != null ? String(params.message.id) : undefined; + if (params.echoCache && (messageText || inboundMessageId)) { + const echoScope = buildIMessageEchoScope({ + accountId: params.accountId, + isGroup, + chatId, + sender, + }); + if ( + params.echoCache.has(echoScope, { + text: messageText || undefined, + messageId: inboundMessageId, + }) + ) { + params.logVerbose?.( + describeIMessageEchoDropLog({ messageText, messageId: inboundMessageId }), + ); + return { kind: "drop", reason: "echo" }; + } + } + + // Reflection guard: drop inbound messages that contain assistant-internal + // metadata markers. These indicate outbound content was reflected back as + // inbound, which causes recursive echo amplification. + const reflection = detectReflectedContent(messageText); + if (reflection.isReflection) { + params.logVerbose?.( + `imessage: dropping reflected assistant content (markers: ${reflection.matchedLabels.join(", ")})`, + ); + return { kind: "drop", reason: "reflected assistant content" }; + } + + const replyContext = describeReplyContext(params.message); + const historyKey = isGroup + ? String(chatId ?? chatGuid ?? chatIdentifier ?? "unknown") + : undefined; + + const mentioned = isGroup ? matchesMentionPatterns(messageText, mentionRegexes) : true; + const requireMention = resolveChannelGroupRequireMention({ + cfg: params.cfg, + channel: "imessage", + accountId: params.accountId, + groupId, + requireMentionOverride: params.opts?.requireMention, + overrideOrder: "before-config", + }); + const canDetectMention = mentionRegexes.length > 0; + + const useAccessGroups = params.cfg.commands?.useAccessGroups !== false; + const commandDmAllowFrom = isGroup ? params.allowFrom : effectiveDmAllowFrom; + const ownerAllowedForCommands = + commandDmAllowFrom.length > 0 + ? isAllowedIMessageSender({ + allowFrom: commandDmAllowFrom, + sender, + chatId, + chatGuid, + chatIdentifier, + }) + : false; + const groupAllowedForCommands = + effectiveGroupAllowFrom.length > 0 + ? isAllowedIMessageSender({ + allowFrom: effectiveGroupAllowFrom, + sender, + chatId, + chatGuid, + chatIdentifier, + }) + : false; + const hasControlCommandInMessage = hasControlCommand(messageText, params.cfg); + const { commandAuthorized, shouldBlock } = resolveDualTextControlCommandGate({ + useAccessGroups, + primaryConfigured: commandDmAllowFrom.length > 0, + primaryAllowed: ownerAllowedForCommands, + secondaryConfigured: effectiveGroupAllowFrom.length > 0, + secondaryAllowed: groupAllowedForCommands, + hasControlCommand: hasControlCommandInMessage, + }); + if (isGroup && shouldBlock) { + if (params.logVerbose) { + logInboundDrop({ + log: params.logVerbose, + channel: "imessage", + reason: "control command (unauthorized)", + target: sender, + }); + } + return { kind: "drop", reason: "control command (unauthorized)" }; + } + + const shouldBypassMention = + isGroup && requireMention && !mentioned && commandAuthorized && hasControlCommandInMessage; + const effectiveWasMentioned = mentioned || shouldBypassMention; + if (isGroup && requireMention && canDetectMention && !mentioned && !shouldBypassMention) { + params.logVerbose?.(`imessage: skipping group message (no mention)`); + recordPendingHistoryEntryIfEnabled({ + historyMap: params.groupHistories, + historyKey: historyKey ?? "", + limit: params.historyLimit, + entry: historyKey + ? { + sender: senderNormalized, + body: bodyText, + timestamp: createdAt, + messageId: params.message.id ? String(params.message.id) : undefined, + } + : null, + }); + return { kind: "drop", reason: "no mention" }; + } + + return { + kind: "dispatch", + isGroup, + chatId, + chatGuid, + chatIdentifier, + groupId, + historyKey, + sender, + senderNormalized, + route, + bodyText, + createdAt, + replyContext, + effectiveWasMentioned, + commandAuthorized, + effectiveDmAllowFrom, + effectiveGroupAllowFrom, + }; +} + +export function buildIMessageInboundContext(params: { + cfg: OpenClawConfig; + decision: IMessageInboundDispatchDecision; + message: IMessagePayload; + envelopeOptions?: EnvelopeFormatOptions; + previousTimestamp?: number; + remoteHost?: string; + media?: { + path?: string; + type?: string; + paths?: string[]; + types?: Array; + }; + historyLimit: number; + groupHistories: Map; +}): { + ctxPayload: ReturnType; + fromLabel: string; + chatTarget?: string; + imessageTo: string; + inboundHistory?: Array<{ sender: string; body: string; timestamp?: number }>; +} { + const envelopeOptions = params.envelopeOptions ?? resolveEnvelopeFormatOptions(params.cfg); + const { decision } = params; + const chatId = decision.chatId; + const chatTarget = + decision.isGroup && chatId != null ? formatIMessageChatTarget(chatId) : undefined; + + const replySuffix = decision.replyContext + ? `\n\n[Replying to ${decision.replyContext.sender ?? "unknown sender"}${ + decision.replyContext.id ? ` id:${decision.replyContext.id}` : "" + }]\n${decision.replyContext.body}\n[/Replying]` + : ""; + + const fromLabel = formatInboundFromLabel({ + isGroup: decision.isGroup, + groupLabel: params.message.chat_name ?? undefined, + groupId: chatId !== undefined ? String(chatId) : "unknown", + groupFallback: "Group", + directLabel: decision.senderNormalized, + directId: decision.sender, + }); + + const body = formatInboundEnvelope({ + channel: "iMessage", + from: fromLabel, + timestamp: decision.createdAt, + body: `${decision.bodyText}${replySuffix}`, + chatType: decision.isGroup ? "group" : "direct", + sender: { name: decision.senderNormalized, id: decision.sender }, + previousTimestamp: params.previousTimestamp, + envelope: envelopeOptions, + }); + + let combinedBody = body; + if (decision.isGroup && decision.historyKey) { + combinedBody = buildPendingHistoryContextFromMap({ + historyMap: params.groupHistories, + historyKey: decision.historyKey, + limit: params.historyLimit, + currentMessage: combinedBody, + formatEntry: (entry) => + formatInboundEnvelope({ + channel: "iMessage", + from: fromLabel, + timestamp: entry.timestamp, + body: `${entry.body}${entry.messageId ? ` [id:${entry.messageId}]` : ""}`, + chatType: "group", + senderLabel: entry.sender, + envelope: envelopeOptions, + }), + }); + } + + const imessageTo = (decision.isGroup ? chatTarget : undefined) || `imessage:${decision.sender}`; + const inboundHistory = + decision.isGroup && decision.historyKey && params.historyLimit > 0 + ? (params.groupHistories.get(decision.historyKey) ?? []).map((entry) => ({ + sender: entry.sender, + body: entry.body, + timestamp: entry.timestamp, + })) + : undefined; + + const ctxPayload = finalizeInboundContext({ + Body: combinedBody, + BodyForAgent: decision.bodyText, + InboundHistory: inboundHistory, + RawBody: decision.bodyText, + CommandBody: decision.bodyText, + From: decision.isGroup + ? `imessage:group:${chatId ?? "unknown"}` + : `imessage:${decision.sender}`, + To: imessageTo, + SessionKey: decision.route.sessionKey, + AccountId: decision.route.accountId, + ChatType: decision.isGroup ? "group" : "direct", + ConversationLabel: fromLabel, + GroupSubject: decision.isGroup ? (params.message.chat_name ?? undefined) : undefined, + GroupMembers: decision.isGroup + ? (params.message.participants ?? []).filter(Boolean).join(", ") + : undefined, + SenderName: decision.senderNormalized, + SenderId: decision.sender, + Provider: "imessage", + Surface: "imessage", + MessageSid: params.message.id ? String(params.message.id) : undefined, + ReplyToId: decision.replyContext?.id, + ReplyToBody: decision.replyContext?.body, + ReplyToSender: decision.replyContext?.sender, + Timestamp: decision.createdAt, + MediaPath: params.media?.path, + MediaType: params.media?.type, + MediaUrl: params.media?.path, + MediaPaths: + params.media?.paths && params.media.paths.length > 0 ? params.media.paths : undefined, + MediaTypes: + params.media?.types && params.media.types.length > 0 ? params.media.types : undefined, + MediaUrls: + params.media?.paths && params.media.paths.length > 0 ? params.media.paths : undefined, + MediaRemoteHost: params.remoteHost, + WasMentioned: decision.effectiveWasMentioned, + CommandAuthorized: decision.commandAuthorized, + OriginatingChannel: "imessage" as const, + OriginatingTo: imessageTo, + }); + + return { ctxPayload, fromLabel, chatTarget, imessageTo, inboundHistory }; +} + +export function buildIMessageEchoScope(params: { + accountId: string; + isGroup: boolean; + chatId?: number; + sender: string; +}): string { + return `${params.accountId}:${params.isGroup ? formatIMessageChatTarget(params.chatId) : `imessage:${params.sender}`}`; +} + +export function describeIMessageEchoDropLog(params: { + messageText: string; + messageId?: string; +}): string { + const preview = truncateUtf16Safe(params.messageText, 50); + const messageIdPart = params.messageId ? ` id=${params.messageId}` : ""; + return `imessage: skipping echo message${messageIdPart}: "${preview}"`; +} diff --git a/src/imessage/monitor/loop-rate-limiter.test.ts b/extensions/imessage/src/monitor/loop-rate-limiter.test.ts similarity index 100% rename from src/imessage/monitor/loop-rate-limiter.test.ts rename to extensions/imessage/src/monitor/loop-rate-limiter.test.ts diff --git a/extensions/imessage/src/monitor/loop-rate-limiter.ts b/extensions/imessage/src/monitor/loop-rate-limiter.ts new file mode 100644 index 00000000000..56c234a1b14 --- /dev/null +++ b/extensions/imessage/src/monitor/loop-rate-limiter.ts @@ -0,0 +1,69 @@ +/** + * Per-conversation rate limiter that detects rapid-fire identical echo + * patterns and suppresses them before they amplify into queue overflow. + */ + +const DEFAULT_WINDOW_MS = 60_000; +const DEFAULT_MAX_HITS = 5; +const CLEANUP_INTERVAL_MS = 120_000; + +type ConversationWindow = { + timestamps: number[]; +}; + +export type LoopRateLimiter = { + /** Returns true if this conversation has exceeded the rate limit. */ + isRateLimited: (conversationKey: string) => boolean; + /** Record an inbound message for a conversation. */ + record: (conversationKey: string) => void; +}; + +export function createLoopRateLimiter(opts?: { + windowMs?: number; + maxHits?: number; +}): LoopRateLimiter { + const windowMs = opts?.windowMs ?? DEFAULT_WINDOW_MS; + const maxHits = opts?.maxHits ?? DEFAULT_MAX_HITS; + const conversations = new Map(); + let lastCleanup = Date.now(); + + function cleanup() { + const now = Date.now(); + if (now - lastCleanup < CLEANUP_INTERVAL_MS) { + return; + } + lastCleanup = now; + for (const [key, win] of conversations.entries()) { + const recent = win.timestamps.filter((ts) => now - ts <= windowMs); + if (recent.length === 0) { + conversations.delete(key); + } else { + win.timestamps = recent; + } + } + } + + return { + record(conversationKey: string) { + cleanup(); + let win = conversations.get(conversationKey); + if (!win) { + win = { timestamps: [] }; + conversations.set(conversationKey, win); + } + win.timestamps.push(Date.now()); + }, + + isRateLimited(conversationKey: string): boolean { + cleanup(); + const win = conversations.get(conversationKey); + if (!win) { + return false; + } + const now = Date.now(); + const recent = win.timestamps.filter((ts) => now - ts <= windowMs); + win.timestamps = recent; + return recent.length >= maxHits; + }, + }; +} diff --git a/src/imessage/monitor/monitor-provider.echo-cache.test.ts b/extensions/imessage/src/monitor/monitor-provider.echo-cache.test.ts similarity index 100% rename from src/imessage/monitor/monitor-provider.echo-cache.test.ts rename to extensions/imessage/src/monitor/monitor-provider.echo-cache.test.ts diff --git a/extensions/imessage/src/monitor/monitor-provider.ts b/extensions/imessage/src/monitor/monitor-provider.ts new file mode 100644 index 00000000000..e3c062cd814 --- /dev/null +++ b/extensions/imessage/src/monitor/monitor-provider.ts @@ -0,0 +1,537 @@ +import fs from "node:fs/promises"; +import { resolveHumanDelayConfig } from "../../../../src/agents/identity.js"; +import { resolveTextChunkLimit } from "../../../../src/auto-reply/chunk.js"; +import { dispatchInboundMessage } from "../../../../src/auto-reply/dispatch.js"; +import { + clearHistoryEntriesIfEnabled, + DEFAULT_GROUP_HISTORY_LIMIT, + type HistoryEntry, +} from "../../../../src/auto-reply/reply/history.js"; +import { createReplyDispatcher } from "../../../../src/auto-reply/reply/reply-dispatcher.js"; +import { + createChannelInboundDebouncer, + shouldDebounceTextInbound, +} from "../../../../src/channels/inbound-debounce-policy.js"; +import { createReplyPrefixOptions } from "../../../../src/channels/reply-prefix.js"; +import { recordInboundSession } from "../../../../src/channels/session.js"; +import { loadConfig } from "../../../../src/config/config.js"; +import { + resolveOpenProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, + warnMissingProviderGroupPolicyFallbackOnce, +} from "../../../../src/config/runtime-group-policy.js"; +import { readSessionUpdatedAt, resolveStorePath } from "../../../../src/config/sessions.js"; +import { danger, logVerbose, shouldLogVerbose, warn } from "../../../../src/globals.js"; +import { normalizeScpRemoteHost } from "../../../../src/infra/scp-host.js"; +import { waitForTransportReady } from "../../../../src/infra/transport-ready.js"; +import { + isInboundPathAllowed, + resolveIMessageAttachmentRoots, + resolveIMessageRemoteAttachmentRoots, +} from "../../../../src/media/inbound-path-policy.js"; +import { kindFromMime } from "../../../../src/media/mime.js"; +import { issuePairingChallenge } from "../../../../src/pairing/pairing-challenge.js"; +import { + readChannelAllowFromStore, + upsertChannelPairingRequest, +} from "../../../../src/pairing/pairing-store.js"; +import { resolvePinnedMainDmOwnerFromAllowlist } from "../../../../src/security/dm-policy-shared.js"; +import { truncateUtf16Safe } from "../../../../src/utils.js"; +import { resolveIMessageAccount } from "../accounts.js"; +import { createIMessageRpcClient } from "../client.js"; +import { DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS } from "../constants.js"; +import { probeIMessage } from "../probe.js"; +import { sendMessageIMessage } from "../send.js"; +import { normalizeIMessageHandle } from "../targets.js"; +import { attachIMessageMonitorAbortHandler } from "./abort-handler.js"; +import { deliverReplies } from "./deliver.js"; +import { createSentMessageCache } from "./echo-cache.js"; +import { + buildIMessageInboundContext, + resolveIMessageInboundDecision, +} from "./inbound-processing.js"; +import { createLoopRateLimiter } from "./loop-rate-limiter.js"; +import { parseIMessageNotification } from "./parse-notification.js"; +import { normalizeAllowList, resolveRuntime } from "./runtime.js"; +import { createSelfChatCache } from "./self-chat-cache.js"; +import type { IMessagePayload, MonitorIMessageOpts } from "./types.js"; + +/** + * Try to detect remote host from an SSH wrapper script like: + * exec ssh -T openclaw@192.168.64.3 /opt/homebrew/bin/imsg "$@" + * exec ssh -T mac-mini imsg "$@" + * Returns the user@host or host portion if found, undefined otherwise. + */ +async function detectRemoteHostFromCliPath(cliPath: string): Promise { + try { + // Expand ~ to home directory + const expanded = cliPath.startsWith("~") + ? cliPath.replace(/^~/, process.env.HOME ?? "") + : cliPath; + const content = await fs.readFile(expanded, "utf8"); + + // Match user@host pattern first (e.g., openclaw@192.168.64.3) + const userHostMatch = content.match(/\bssh\b[^\n]*?\s+([a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+)/); + if (userHostMatch) { + return userHostMatch[1]; + } + + // Fallback: match host-only before imsg command (e.g., ssh -T mac-mini imsg) + const hostOnlyMatch = content.match(/\bssh\b[^\n]*?\s+([a-zA-Z][a-zA-Z0-9._-]*)\s+\S*\bimsg\b/); + return hostOnlyMatch?.[1]; + } catch { + return undefined; + } +} + +export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): Promise { + const runtime = resolveRuntime(opts); + const cfg = opts.config ?? loadConfig(); + const accountInfo = resolveIMessageAccount({ + cfg, + accountId: opts.accountId, + }); + const imessageCfg = accountInfo.config; + const historyLimit = Math.max( + 0, + imessageCfg.historyLimit ?? + cfg.messages?.groupChat?.historyLimit ?? + DEFAULT_GROUP_HISTORY_LIMIT, + ); + const groupHistories = new Map(); + const sentMessageCache = createSentMessageCache(); + const selfChatCache = createSelfChatCache(); + const loopRateLimiter = createLoopRateLimiter(); + const textLimit = resolveTextChunkLimit(cfg, "imessage", accountInfo.accountId); + const allowFrom = normalizeAllowList(opts.allowFrom ?? imessageCfg.allowFrom); + const groupAllowFrom = normalizeAllowList( + opts.groupAllowFrom ?? + imessageCfg.groupAllowFrom ?? + (imessageCfg.allowFrom && imessageCfg.allowFrom.length > 0 ? imessageCfg.allowFrom : []), + ); + const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg); + const { groupPolicy, providerMissingFallbackApplied } = resolveOpenProviderRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.imessage !== undefined, + groupPolicy: imessageCfg.groupPolicy, + defaultGroupPolicy, + }); + warnMissingProviderGroupPolicyFallbackOnce({ + providerMissingFallbackApplied, + providerKey: "imessage", + accountId: accountInfo.accountId, + log: (message) => runtime.log?.(warn(message)), + }); + const dmPolicy = imessageCfg.dmPolicy ?? "pairing"; + const includeAttachments = opts.includeAttachments ?? imessageCfg.includeAttachments ?? false; + const mediaMaxBytes = (opts.mediaMaxMb ?? imessageCfg.mediaMaxMb ?? 16) * 1024 * 1024; + const cliPath = opts.cliPath ?? imessageCfg.cliPath ?? "imsg"; + const dbPath = opts.dbPath ?? imessageCfg.dbPath; + const probeTimeoutMs = imessageCfg.probeTimeoutMs ?? DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS; + const attachmentRoots = resolveIMessageAttachmentRoots({ + cfg, + accountId: accountInfo.accountId, + }); + const remoteAttachmentRoots = resolveIMessageRemoteAttachmentRoots({ + cfg, + accountId: accountInfo.accountId, + }); + + // Resolve remoteHost: explicit config, or auto-detect from SSH wrapper script. + // Accept only a safe host token to avoid option/argument injection into SCP. + const configuredRemoteHost = normalizeScpRemoteHost(imessageCfg.remoteHost); + if (imessageCfg.remoteHost && !configuredRemoteHost) { + logVerbose("imessage: ignoring unsafe channels.imessage.remoteHost value"); + } + + let remoteHost = configuredRemoteHost; + if (!remoteHost && cliPath && cliPath !== "imsg") { + const detected = await detectRemoteHostFromCliPath(cliPath); + const normalizedDetected = normalizeScpRemoteHost(detected); + if (detected && !normalizedDetected) { + logVerbose("imessage: ignoring unsafe auto-detected remoteHost from cliPath"); + } + remoteHost = normalizedDetected; + if (remoteHost) { + logVerbose(`imessage: detected remoteHost=${remoteHost} from cliPath`); + } + } + + const { debouncer: inboundDebouncer } = createChannelInboundDebouncer<{ + message: IMessagePayload; + }>({ + cfg, + channel: "imessage", + buildKey: (entry) => { + const sender = entry.message.sender?.trim(); + if (!sender) { + return null; + } + const conversationId = + entry.message.chat_id != null + ? `chat:${entry.message.chat_id}` + : (entry.message.chat_guid ?? entry.message.chat_identifier ?? "unknown"); + return `imessage:${accountInfo.accountId}:${conversationId}:${sender}`; + }, + shouldDebounce: (entry) => { + return shouldDebounceTextInbound({ + text: entry.message.text, + cfg, + hasMedia: Boolean(entry.message.attachments && entry.message.attachments.length > 0), + }); + }, + onFlush: async (entries) => { + const last = entries.at(-1); + if (!last) { + return; + } + if (entries.length === 1) { + await handleMessageNow(last.message); + return; + } + const combinedText = entries + .map((entry) => entry.message.text ?? "") + .filter(Boolean) + .join("\n"); + const syntheticMessage: IMessagePayload = { + ...last.message, + text: combinedText, + attachments: null, + }; + await handleMessageNow(syntheticMessage); + }, + onError: (err) => { + runtime.error?.(`imessage debounce flush failed: ${String(err)}`); + }, + }); + + async function handleMessageNow(message: IMessagePayload) { + const messageText = (message.text ?? "").trim(); + + const attachments = includeAttachments ? (message.attachments ?? []) : []; + const effectiveAttachmentRoots = remoteHost ? remoteAttachmentRoots : attachmentRoots; + const validAttachments = attachments.filter((entry) => { + const attachmentPath = entry?.original_path?.trim(); + if (!attachmentPath || entry?.missing) { + return false; + } + if (isInboundPathAllowed({ filePath: attachmentPath, roots: effectiveAttachmentRoots })) { + return true; + } + logVerbose(`imessage: dropping inbound attachment outside allowed roots: ${attachmentPath}`); + return false; + }); + const firstAttachment = validAttachments[0]; + const mediaPath = firstAttachment?.original_path ?? undefined; + const mediaType = firstAttachment?.mime_type ?? undefined; + // Build arrays for all attachments (for multi-image support) + const mediaPaths = validAttachments.map((a) => a.original_path).filter(Boolean) as string[]; + const mediaTypes = validAttachments.map((a) => a.mime_type ?? undefined); + const kind = kindFromMime(mediaType ?? undefined); + const placeholder = kind + ? `` + : validAttachments.length + ? "" + : ""; + const bodyText = messageText || placeholder; + + const storeAllowFrom = await readChannelAllowFromStore( + "imessage", + process.env, + accountInfo.accountId, + ).catch(() => []); + const decision = resolveIMessageInboundDecision({ + cfg, + accountId: accountInfo.accountId, + message, + opts, + messageText, + bodyText, + allowFrom, + groupAllowFrom, + groupPolicy, + dmPolicy, + storeAllowFrom, + historyLimit, + groupHistories, + echoCache: sentMessageCache, + selfChatCache, + logVerbose, + }); + + // Build conversation key for rate limiting (used by both drop and dispatch paths). + const chatId = message.chat_id ?? undefined; + const senderForKey = (message.sender ?? "").trim(); + const conversationKey = chatId != null ? `group:${chatId}` : `dm:${senderForKey}`; + const rateLimitKey = `${accountInfo.accountId}:${conversationKey}`; + + if (decision.kind === "drop") { + // Record echo/reflection drops so the rate limiter can detect sustained loops. + // Only loop-related drop reasons feed the counter; policy/mention/empty drops + // are normal and should not escalate. + const isLoopDrop = + decision.reason === "echo" || + decision.reason === "self-chat echo" || + decision.reason === "reflected assistant content" || + decision.reason === "from me"; + if (isLoopDrop) { + loopRateLimiter.record(rateLimitKey); + } + return; + } + + // After repeated echo/reflection drops for a conversation, suppress all + // remaining messages as a safety net against amplification that slips + // through the primary guards. + if (decision.kind === "dispatch" && loopRateLimiter.isRateLimited(rateLimitKey)) { + logVerbose(`imessage: rate-limited conversation ${conversationKey} (echo loop detected)`); + return; + } + + if (decision.kind === "pairing") { + const sender = (message.sender ?? "").trim(); + if (!sender) { + return; + } + await issuePairingChallenge({ + channel: "imessage", + senderId: decision.senderId, + senderIdLine: `Your iMessage sender id: ${decision.senderId}`, + meta: { + sender: decision.senderId, + chatId: chatId ? String(chatId) : undefined, + }, + upsertPairingRequest: async ({ id, meta }) => + await upsertChannelPairingRequest({ + channel: "imessage", + id, + accountId: accountInfo.accountId, + meta, + }), + onCreated: () => { + logVerbose(`imessage pairing request sender=${decision.senderId}`); + }, + sendPairingReply: async (text) => { + await sendMessageIMessage(sender, text, { + client, + maxBytes: mediaMaxBytes, + accountId: accountInfo.accountId, + ...(chatId ? { chatId } : {}), + }); + }, + onReplyError: (err) => { + logVerbose(`imessage pairing reply failed for ${decision.senderId}: ${String(err)}`); + }, + }); + return; + } + + const storePath = resolveStorePath(cfg.session?.store, { + agentId: decision.route.agentId, + }); + const previousTimestamp = readSessionUpdatedAt({ + storePath, + sessionKey: decision.route.sessionKey, + }); + const { ctxPayload, chatTarget } = buildIMessageInboundContext({ + cfg, + decision, + message, + previousTimestamp, + remoteHost, + historyLimit, + groupHistories, + media: { + path: mediaPath, + type: mediaType, + paths: mediaPaths, + types: mediaTypes, + }, + }); + + const updateTarget = chatTarget || decision.sender; + const pinnedMainDmOwner = resolvePinnedMainDmOwnerFromAllowlist({ + dmScope: cfg.session?.dmScope, + allowFrom, + normalizeEntry: normalizeIMessageHandle, + }); + await recordInboundSession({ + storePath, + sessionKey: ctxPayload.SessionKey ?? decision.route.sessionKey, + ctx: ctxPayload, + updateLastRoute: + !decision.isGroup && updateTarget + ? { + sessionKey: decision.route.mainSessionKey, + channel: "imessage", + to: updateTarget, + accountId: decision.route.accountId, + mainDmOwnerPin: + pinnedMainDmOwner && decision.senderNormalized + ? { + ownerRecipient: pinnedMainDmOwner, + senderRecipient: decision.senderNormalized, + onSkip: ({ ownerRecipient, senderRecipient }) => { + logVerbose( + `imessage: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`, + ); + }, + } + : undefined, + } + : undefined, + onRecordError: (err) => { + logVerbose(`imessage: failed updating session meta: ${String(err)}`); + }, + }); + + if (shouldLogVerbose()) { + const preview = truncateUtf16Safe(String(ctxPayload.Body ?? ""), 200).replace(/\n/g, "\\n"); + logVerbose( + `imessage inbound: chatId=${chatId ?? "unknown"} from=${ctxPayload.From} len=${ + String(ctxPayload.Body ?? "").length + } preview="${preview}"`, + ); + } + + const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({ + cfg, + agentId: decision.route.agentId, + channel: "imessage", + accountId: decision.route.accountId, + }); + + const dispatcher = createReplyDispatcher({ + ...prefixOptions, + humanDelay: resolveHumanDelayConfig(cfg, decision.route.agentId), + deliver: async (payload) => { + const target = ctxPayload.To; + if (!target) { + runtime.error?.(danger("imessage: missing delivery target")); + return; + } + await deliverReplies({ + replies: [payload], + target, + client, + accountId: accountInfo.accountId, + runtime, + maxBytes: mediaMaxBytes, + textLimit, + sentMessageCache, + }); + }, + onError: (err, info) => { + runtime.error?.(danger(`imessage ${info.kind} reply failed: ${String(err)}`)); + }, + }); + + const { queuedFinal } = await dispatchInboundMessage({ + ctx: ctxPayload, + cfg, + dispatcher, + replyOptions: { + disableBlockStreaming: + typeof accountInfo.config.blockStreaming === "boolean" + ? !accountInfo.config.blockStreaming + : undefined, + onModelSelected, + }, + }); + + if (!queuedFinal) { + if (decision.isGroup && decision.historyKey) { + clearHistoryEntriesIfEnabled({ + historyMap: groupHistories, + historyKey: decision.historyKey, + limit: historyLimit, + }); + } + return; + } + if (decision.isGroup && decision.historyKey) { + clearHistoryEntriesIfEnabled({ + historyMap: groupHistories, + historyKey: decision.historyKey, + limit: historyLimit, + }); + } + } + + const handleMessage = async (raw: unknown) => { + const message = parseIMessageNotification(raw); + if (!message) { + logVerbose("imessage: dropping malformed RPC message payload"); + return; + } + await inboundDebouncer.enqueue({ message }); + }; + + await waitForTransportReady({ + label: "imsg rpc", + timeoutMs: 30_000, + logAfterMs: 10_000, + logIntervalMs: 10_000, + pollIntervalMs: 500, + abortSignal: opts.abortSignal, + runtime, + check: async () => { + const probe = await probeIMessage(probeTimeoutMs, { cliPath, dbPath, runtime }); + if (probe.ok) { + return { ok: true }; + } + if (probe.fatal) { + throw new Error(probe.error ?? "imsg rpc unavailable"); + } + return { ok: false, error: probe.error ?? "unreachable" }; + }, + }); + + if (opts.abortSignal?.aborted) { + return; + } + + const client = await createIMessageRpcClient({ + cliPath, + 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 detachAbortHandler = attachIMessageMonitorAbortHandler({ + abortSignal: abort, + client, + getSubscriptionId: () => subscriptionId, + }); + + 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 { + detachAbortHandler(); + await client.stop(); + } +} + +export const __testing = { + resolveIMessageRuntimeGroupPolicy: resolveOpenProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, +}; diff --git a/extensions/imessage/src/monitor/parse-notification.ts b/extensions/imessage/src/monitor/parse-notification.ts new file mode 100644 index 00000000000..98ad941665c --- /dev/null +++ b/extensions/imessage/src/monitor/parse-notification.ts @@ -0,0 +1,83 @@ +import type { IMessagePayload } from "./types.js"; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function isOptionalString(value: unknown): value is string | null | undefined { + return value === undefined || value === null || typeof value === "string"; +} + +function isOptionalStringOrNumber(value: unknown): value is string | number | null | undefined { + return ( + value === undefined || value === null || typeof value === "string" || typeof value === "number" + ); +} + +function isOptionalNumber(value: unknown): value is number | null | undefined { + return value === undefined || value === null || typeof value === "number"; +} + +function isOptionalBoolean(value: unknown): value is boolean | null | undefined { + return value === undefined || value === null || typeof value === "boolean"; +} + +function isOptionalStringArray(value: unknown): value is string[] | null | undefined { + return ( + value === undefined || + value === null || + (Array.isArray(value) && value.every((entry) => typeof entry === "string")) + ); +} + +function isOptionalAttachments(value: unknown): value is IMessagePayload["attachments"] { + if (value === undefined || value === null) { + return true; + } + if (!Array.isArray(value)) { + return false; + } + return value.every((attachment) => { + if (!isRecord(attachment)) { + return false; + } + return ( + isOptionalString(attachment.original_path) && + isOptionalString(attachment.mime_type) && + isOptionalBoolean(attachment.missing) + ); + }); +} + +export function parseIMessageNotification(raw: unknown): IMessagePayload | null { + if (!isRecord(raw)) { + return null; + } + const maybeMessage = raw.message; + if (!isRecord(maybeMessage)) { + return null; + } + + const message: IMessagePayload = maybeMessage; + if ( + !isOptionalNumber(message.id) || + !isOptionalNumber(message.chat_id) || + !isOptionalString(message.sender) || + !isOptionalBoolean(message.is_from_me) || + !isOptionalString(message.text) || + !isOptionalStringOrNumber(message.reply_to_id) || + !isOptionalString(message.reply_to_text) || + !isOptionalString(message.reply_to_sender) || + !isOptionalString(message.created_at) || + !isOptionalAttachments(message.attachments) || + !isOptionalString(message.chat_identifier) || + !isOptionalString(message.chat_guid) || + !isOptionalString(message.chat_name) || + !isOptionalStringArray(message.participants) || + !isOptionalBoolean(message.is_group) + ) { + return null; + } + + return message; +} diff --git a/src/imessage/monitor/provider.group-policy.test.ts b/extensions/imessage/src/monitor/provider.group-policy.test.ts similarity index 91% rename from src/imessage/monitor/provider.group-policy.test.ts rename to extensions/imessage/src/monitor/provider.group-policy.test.ts index 58812ad5711..d6a7b1f880b 100644 --- a/src/imessage/monitor/provider.group-policy.test.ts +++ b/extensions/imessage/src/monitor/provider.group-policy.test.ts @@ -1,5 +1,5 @@ import { describe } from "vitest"; -import { installProviderRuntimeGroupPolicyFallbackSuite } from "../../test-utils/runtime-group-policy-contract.js"; +import { installProviderRuntimeGroupPolicyFallbackSuite } from "../../../../src/test-utils/runtime-group-policy-contract.js"; import { __testing } from "./monitor-provider.js"; describe("resolveIMessageRuntimeGroupPolicy", () => { diff --git a/src/imessage/monitor/reflection-guard.test.ts b/extensions/imessage/src/monitor/reflection-guard.test.ts similarity index 100% rename from src/imessage/monitor/reflection-guard.test.ts rename to extensions/imessage/src/monitor/reflection-guard.test.ts diff --git a/extensions/imessage/src/monitor/reflection-guard.ts b/extensions/imessage/src/monitor/reflection-guard.ts new file mode 100644 index 00000000000..0af95d957cc --- /dev/null +++ b/extensions/imessage/src/monitor/reflection-guard.ts @@ -0,0 +1,64 @@ +/** + * Detects inbound messages that are reflections of assistant-originated content. + * These patterns indicate internal metadata leaked into a channel and then + * bounced back as a new inbound message — creating an echo loop. + */ + +import { findCodeRegions, isInsideCode } from "../../../../src/shared/text/code-regions.js"; + +const INTERNAL_SEPARATOR_RE = /(?:#\+){2,}#?/; +const ASSISTANT_ROLE_MARKER_RE = /\bassistant\s+to\s*=\s*\w+/i; +// Require closing `>` to avoid false-positives on phrases like "". +const THINKING_TAG_RE = /<\s*\/?\s*(?:think(?:ing)?|thought|antthinking)\b[^<>]*>/i; +const RELEVANT_MEMORIES_TAG_RE = /<\s*\/?\s*relevant[-_]memories\b[^<>]*>/i; +// Require closing `>` to avoid false-positives on phrases like "". +const FINAL_TAG_RE = /<\s*\/?\s*final\b[^<>]*>/i; + +const REFLECTION_PATTERNS: Array<{ re: RegExp; label: string }> = [ + { re: INTERNAL_SEPARATOR_RE, label: "internal-separator" }, + { re: ASSISTANT_ROLE_MARKER_RE, label: "assistant-role-marker" }, + { re: THINKING_TAG_RE, label: "thinking-tag" }, + { re: RELEVANT_MEMORIES_TAG_RE, label: "relevant-memories-tag" }, + { re: FINAL_TAG_RE, label: "final-tag" }, +]; + +export type ReflectionDetection = { + isReflection: boolean; + matchedLabels: string[]; +}; + +function hasMatchOutsideCode(text: string, re: RegExp): boolean { + const codeRegions = findCodeRegions(text); + const globalRe = new RegExp(re.source, re.flags.includes("g") ? re.flags : `${re.flags}g`); + + for (const match of text.matchAll(globalRe)) { + const start = match.index ?? -1; + if (start >= 0 && !isInsideCode(start, codeRegions)) { + return true; + } + } + + return false; +} + +/** + * Check whether an inbound message appears to be a reflection of + * assistant-originated content. Returns matched pattern labels for telemetry. + */ +export function detectReflectedContent(text: string): ReflectionDetection { + if (!text) { + return { isReflection: false, matchedLabels: [] }; + } + + const matchedLabels: string[] = []; + for (const { re, label } of REFLECTION_PATTERNS) { + if (hasMatchOutsideCode(text, re)) { + matchedLabels.push(label); + } + } + + return { + isReflection: matchedLabels.length > 0, + matchedLabels, + }; +} diff --git a/extensions/imessage/src/monitor/runtime.ts b/extensions/imessage/src/monitor/runtime.ts new file mode 100644 index 00000000000..e4fe6ae4336 --- /dev/null +++ b/extensions/imessage/src/monitor/runtime.ts @@ -0,0 +1,11 @@ +import { createNonExitingRuntime, type RuntimeEnv } from "../../../../src/runtime.js"; +import { normalizeStringEntries } from "../../../../src/shared/string-normalization.js"; +import type { MonitorIMessageOpts } from "./types.js"; + +export function resolveRuntime(opts: MonitorIMessageOpts): RuntimeEnv { + return opts.runtime ?? createNonExitingRuntime(); +} + +export function normalizeAllowList(list?: Array) { + return normalizeStringEntries(list); +} diff --git a/src/imessage/monitor/sanitize-outbound.test.ts b/extensions/imessage/src/monitor/sanitize-outbound.test.ts similarity index 100% rename from src/imessage/monitor/sanitize-outbound.test.ts rename to extensions/imessage/src/monitor/sanitize-outbound.test.ts diff --git a/extensions/imessage/src/monitor/sanitize-outbound.ts b/extensions/imessage/src/monitor/sanitize-outbound.ts new file mode 100644 index 00000000000..83eb75a8da2 --- /dev/null +++ b/extensions/imessage/src/monitor/sanitize-outbound.ts @@ -0,0 +1,31 @@ +import { stripAssistantInternalScaffolding } from "../../../../src/shared/text/assistant-visible-text.js"; + +/** + * Patterns that indicate assistant-internal metadata leaked into text. + * These must never reach a user-facing channel. + */ +const INTERNAL_SEPARATOR_RE = /(?:#\+){2,}#?/g; +const ASSISTANT_ROLE_MARKER_RE = /\bassistant\s+to\s*=\s*\w+/gi; +const ROLE_TURN_MARKER_RE = /\b(?:user|system|assistant)\s*:\s*$/gm; + +/** + * Strip all assistant-internal scaffolding from outbound text before delivery. + * Applies reasoning/thinking tag removal, memory tag removal, and + * model-specific internal separator stripping. + */ +export function sanitizeOutboundText(text: string): string { + if (!text) { + return text; + } + + let cleaned = stripAssistantInternalScaffolding(text); + + cleaned = cleaned.replace(INTERNAL_SEPARATOR_RE, ""); + cleaned = cleaned.replace(ASSISTANT_ROLE_MARKER_RE, ""); + cleaned = cleaned.replace(ROLE_TURN_MARKER_RE, ""); + + // Collapse excessive blank lines left after stripping. + cleaned = cleaned.replace(/\n{3,}/g, "\n\n").trim(); + + return cleaned; +} diff --git a/src/imessage/monitor/self-chat-cache.test.ts b/extensions/imessage/src/monitor/self-chat-cache.test.ts similarity index 100% rename from src/imessage/monitor/self-chat-cache.test.ts rename to extensions/imessage/src/monitor/self-chat-cache.test.ts diff --git a/extensions/imessage/src/monitor/self-chat-cache.ts b/extensions/imessage/src/monitor/self-chat-cache.ts new file mode 100644 index 00000000000..a2c4c31ccd9 --- /dev/null +++ b/extensions/imessage/src/monitor/self-chat-cache.ts @@ -0,0 +1,103 @@ +import { createHash } from "node:crypto"; +import { formatIMessageChatTarget } from "../targets.js"; + +type SelfChatCacheKeyParts = { + accountId: string; + sender: string; + isGroup: boolean; + chatId?: number; +}; + +export type SelfChatLookup = SelfChatCacheKeyParts & { + text?: string; + createdAt?: number; +}; + +export type SelfChatCache = { + remember: (lookup: SelfChatLookup) => void; + has: (lookup: SelfChatLookup) => boolean; +}; + +const SELF_CHAT_TTL_MS = 10_000; +const MAX_SELF_CHAT_CACHE_ENTRIES = 512; +const CLEANUP_MIN_INTERVAL_MS = 1_000; + +function normalizeText(text: string | undefined): string | null { + if (!text) { + return null; + } + const normalized = text.replace(/\r\n?/g, "\n").trim(); + return normalized ? normalized : null; +} + +function isUsableTimestamp(createdAt: number | undefined): createdAt is number { + return typeof createdAt === "number" && Number.isFinite(createdAt); +} + +function digestText(text: string): string { + return createHash("sha256").update(text).digest("hex"); +} + +function buildScope(parts: SelfChatCacheKeyParts): string { + if (!parts.isGroup) { + return `${parts.accountId}:imessage:${parts.sender}`; + } + const chatTarget = formatIMessageChatTarget(parts.chatId) || "chat_id:unknown"; + return `${parts.accountId}:${chatTarget}:imessage:${parts.sender}`; +} + +class DefaultSelfChatCache implements SelfChatCache { + private cache = new Map(); + private lastCleanupAt = 0; + + private buildKey(lookup: SelfChatLookup): string | null { + const text = normalizeText(lookup.text); + if (!text || !isUsableTimestamp(lookup.createdAt)) { + return null; + } + return `${buildScope(lookup)}:${lookup.createdAt}:${digestText(text)}`; + } + + remember(lookup: SelfChatLookup): void { + const key = this.buildKey(lookup); + if (!key) { + return; + } + this.cache.set(key, Date.now()); + this.maybeCleanup(); + } + + has(lookup: SelfChatLookup): boolean { + this.maybeCleanup(); + const key = this.buildKey(lookup); + if (!key) { + return false; + } + const timestamp = this.cache.get(key); + return typeof timestamp === "number" && Date.now() - timestamp <= SELF_CHAT_TTL_MS; + } + + private maybeCleanup(): void { + const now = Date.now(); + if (now - this.lastCleanupAt < CLEANUP_MIN_INTERVAL_MS) { + return; + } + this.lastCleanupAt = now; + for (const [key, timestamp] of this.cache.entries()) { + if (now - timestamp > SELF_CHAT_TTL_MS) { + this.cache.delete(key); + } + } + while (this.cache.size > MAX_SELF_CHAT_CACHE_ENTRIES) { + const oldestKey = this.cache.keys().next().value; + if (typeof oldestKey !== "string") { + break; + } + this.cache.delete(oldestKey); + } + } +} + +export function createSelfChatCache(): SelfChatCache { + return new DefaultSelfChatCache(); +} diff --git a/extensions/imessage/src/monitor/types.ts b/extensions/imessage/src/monitor/types.ts new file mode 100644 index 00000000000..074c7c34c9f --- /dev/null +++ b/extensions/imessage/src/monitor/types.ts @@ -0,0 +1,40 @@ +import type { OpenClawConfig } from "../../../../src/config/config.js"; +import type { RuntimeEnv } from "../../../../src/runtime.js"; + +export type IMessageAttachment = { + original_path?: string | null; + mime_type?: string | null; + missing?: boolean | null; +}; + +export type IMessagePayload = { + id?: number | null; + chat_id?: number | null; + sender?: string | null; + is_from_me?: boolean | null; + text?: string | null; + reply_to_id?: number | string | null; + reply_to_text?: string | null; + reply_to_sender?: 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; + accountId?: string; + config?: OpenClawConfig; + allowFrom?: Array; + groupAllowFrom?: Array; + includeAttachments?: boolean; + mediaMaxMb?: number; + requireMention?: boolean; +}; diff --git a/src/imessage/probe.test.ts b/extensions/imessage/src/probe.test.ts similarity index 91% rename from src/imessage/probe.test.ts rename to extensions/imessage/src/probe.test.ts index adee76063bb..5d676327c11 100644 --- a/src/imessage/probe.test.ts +++ b/extensions/imessage/src/probe.test.ts @@ -5,11 +5,11 @@ const detectBinaryMock = vi.hoisted(() => vi.fn()); const runCommandWithTimeoutMock = vi.hoisted(() => vi.fn()); const createIMessageRpcClientMock = vi.hoisted(() => vi.fn()); -vi.mock("../commands/onboard-helpers.js", () => ({ +vi.mock("../../../src/commands/onboard-helpers.js", () => ({ detectBinary: (...args: unknown[]) => detectBinaryMock(...args), })); -vi.mock("../process/exec.js", () => ({ +vi.mock("../../../src/process/exec.js", () => ({ runCommandWithTimeout: (...args: unknown[]) => runCommandWithTimeoutMock(...args), })); diff --git a/extensions/imessage/src/probe.ts b/extensions/imessage/src/probe.ts new file mode 100644 index 00000000000..1b6ab665d09 --- /dev/null +++ b/extensions/imessage/src/probe.ts @@ -0,0 +1,105 @@ +import type { BaseProbeResult } from "../../../src/channels/plugins/types.js"; +import { detectBinary } from "../../../src/commands/onboard-helpers.js"; +import { loadConfig } from "../../../src/config/config.js"; +import { runCommandWithTimeout } from "../../../src/process/exec.js"; +import type { RuntimeEnv } from "../../../src/runtime.js"; +import { createIMessageRpcClient } from "./client.js"; +import { DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS } from "./constants.js"; + +// Re-export for backwards compatibility +export { DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS } from "./constants.js"; + +export type IMessageProbe = BaseProbeResult & { + fatal?: boolean; +}; + +export type IMessageProbeOptions = { + cliPath?: string; + dbPath?: string; + runtime?: RuntimeEnv; +}; + +type RpcSupportResult = { + supported: boolean; + error?: string; + fatal?: boolean; +}; + +const rpcSupportCache = new Map(); + +async function probeRpcSupport(cliPath: string, timeoutMs: number): Promise { + const cached = rpcSupportCache.get(cliPath); + if (cached) { + return cached; + } + try { + const result = await runCommandWithTimeout([cliPath, "rpc", "--help"], { timeoutMs }); + const combined = `${result.stdout}\n${result.stderr}`.trim(); + const normalized = combined.toLowerCase(); + if (normalized.includes("unknown command") && normalized.includes("rpc")) { + const fatal = { + supported: false, + fatal: true, + error: 'imsg CLI does not support the "rpc" subcommand (update imsg)', + }; + rpcSupportCache.set(cliPath, fatal); + return fatal; + } + if (result.code === 0) { + const supported = { supported: true }; + rpcSupportCache.set(cliPath, supported); + return supported; + } + return { + supported: false, + error: combined || `imsg rpc --help failed (code ${String(result.code ?? "unknown")})`, + }; + } catch (err) { + return { supported: false, error: String(err) }; + } +} + +/** + * Probe iMessage RPC availability. + * @param timeoutMs - Explicit timeout in ms. If undefined, uses config or default. + * @param opts - Additional options (cliPath, dbPath, runtime). + */ +export async function probeIMessage( + timeoutMs?: number, + opts: IMessageProbeOptions = {}, +): Promise { + const cfg = opts.cliPath || opts.dbPath ? undefined : loadConfig(); + const cliPath = opts.cliPath?.trim() || cfg?.channels?.imessage?.cliPath?.trim() || "imsg"; + const dbPath = opts.dbPath?.trim() || cfg?.channels?.imessage?.dbPath?.trim(); + // Use explicit timeout if provided, otherwise fall back to config, then default + const effectiveTimeout = + timeoutMs ?? cfg?.channels?.imessage?.probeTimeoutMs ?? DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS; + + const detected = await detectBinary(cliPath); + if (!detected) { + return { ok: false, error: `imsg not found (${cliPath})` }; + } + + const rpcSupport = await probeRpcSupport(cliPath, effectiveTimeout); + if (!rpcSupport.supported) { + return { + ok: false, + error: rpcSupport.error ?? "imsg rpc unavailable", + fatal: rpcSupport.fatal, + }; + } + + const client = await createIMessageRpcClient({ + cliPath, + dbPath, + runtime: opts.runtime, + }); + try { + await client.request("chats.list", { limit: 1 }, { timeoutMs: effectiveTimeout }); + return { ok: true }; + } catch (err) { + return { ok: false, error: String(err) }; + } finally { + await client.stop(); + } +} diff --git a/src/imessage/send.test.ts b/extensions/imessage/src/send.test.ts similarity index 100% rename from src/imessage/send.test.ts rename to extensions/imessage/src/send.test.ts diff --git a/extensions/imessage/src/send.ts b/extensions/imessage/src/send.ts new file mode 100644 index 00000000000..5bc02b6bb7f --- /dev/null +++ b/extensions/imessage/src/send.ts @@ -0,0 +1,190 @@ +import { loadConfig } from "../../../src/config/config.js"; +import { resolveMarkdownTableMode } from "../../../src/config/markdown-tables.js"; +import { convertMarkdownTables } from "../../../src/markdown/tables.js"; +import { kindFromMime } from "../../../src/media/mime.js"; +import { resolveOutboundAttachmentFromUrl } from "../../../src/media/outbound-attachment.js"; +import { resolveIMessageAccount, type ResolvedIMessageAccount } from "./accounts.js"; +import { createIMessageRpcClient, type IMessageRpcClient } from "./client.js"; +import { formatIMessageChatTarget, type IMessageService, parseIMessageTarget } from "./targets.js"; + +export type IMessageSendOpts = { + cliPath?: string; + dbPath?: string; + service?: IMessageService; + region?: string; + accountId?: string; + replyToId?: string; + mediaUrl?: string; + mediaLocalRoots?: readonly string[]; + maxBytes?: number; + timeoutMs?: number; + chatId?: number; + client?: IMessageRpcClient; + config?: ReturnType; + account?: ResolvedIMessageAccount; + resolveAttachmentImpl?: ( + mediaUrl: string, + maxBytes: number, + options?: { localRoots?: readonly string[] }, + ) => Promise<{ path: string; contentType?: string }>; + createClient?: (params: { cliPath: string; dbPath?: string }) => Promise; +}; + +export type IMessageSendResult = { + messageId: string; +}; + +const LEADING_REPLY_TAG_RE = /^\s*\[\[\s*reply_to\s*:\s*([^\]\n]+)\s*\]\]\s*/i; +const MAX_REPLY_TO_ID_LENGTH = 256; + +function stripUnsafeReplyTagChars(value: string): string { + let next = ""; + for (const ch of value) { + const code = ch.charCodeAt(0); + if ((code >= 0 && code <= 31) || code === 127 || ch === "[" || ch === "]") { + continue; + } + next += ch; + } + return next; +} + +function sanitizeReplyToId(rawReplyToId?: string): string | undefined { + const trimmed = rawReplyToId?.trim(); + if (!trimmed) { + return undefined; + } + const sanitized = stripUnsafeReplyTagChars(trimmed).trim(); + if (!sanitized) { + return undefined; + } + if (sanitized.length > MAX_REPLY_TO_ID_LENGTH) { + return sanitized.slice(0, MAX_REPLY_TO_ID_LENGTH); + } + return sanitized; +} + +function prependReplyTagIfNeeded(message: string, replyToId?: string): string { + const resolvedReplyToId = sanitizeReplyToId(replyToId); + if (!resolvedReplyToId) { + return message; + } + const replyTag = `[[reply_to:${resolvedReplyToId}]]`; + const existingLeadingTag = message.match(LEADING_REPLY_TAG_RE); + if (existingLeadingTag) { + const remainder = message.slice(existingLeadingTag[0].length).trimStart(); + return remainder ? `${replyTag} ${remainder}` : replyTag; + } + const trimmedMessage = message.trimStart(); + return trimmedMessage ? `${replyTag} ${trimmedMessage}` : replyTag; +} + +function resolveMessageId(result: Record | null | undefined): string | null { + if (!result) { + return null; + } + const raw = + (typeof result.messageId === "string" && result.messageId.trim()) || + (typeof result.message_id === "string" && result.message_id.trim()) || + (typeof result.id === "string" && result.id.trim()) || + (typeof result.guid === "string" && result.guid.trim()) || + (typeof result.message_id === "number" ? String(result.message_id) : null) || + (typeof result.id === "number" ? String(result.id) : null); + return raw ? String(raw).trim() : null; +} + +export async function sendMessageIMessage( + to: string, + text: string, + opts: IMessageSendOpts = {}, +): Promise { + const cfg = opts.config ?? loadConfig(); + const account = + opts.account ?? + resolveIMessageAccount({ + cfg, + accountId: opts.accountId, + }); + const cliPath = opts.cliPath?.trim() || account.config.cliPath?.trim() || "imsg"; + const dbPath = opts.dbPath?.trim() || account.config.dbPath?.trim(); + const target = parseIMessageTarget(opts.chatId ? formatIMessageChatTarget(opts.chatId) : to); + const service = + opts.service ?? + (target.kind === "handle" ? target.service : undefined) ?? + (account.config.service as IMessageService | undefined); + const region = opts.region?.trim() || account.config.region?.trim() || "US"; + const maxBytes = + typeof opts.maxBytes === "number" + ? opts.maxBytes + : typeof account.config.mediaMaxMb === "number" + ? account.config.mediaMaxMb * 1024 * 1024 + : 16 * 1024 * 1024; + let message = text ?? ""; + let filePath: string | undefined; + + if (opts.mediaUrl?.trim()) { + const resolveAttachmentFn = opts.resolveAttachmentImpl ?? resolveOutboundAttachmentFromUrl; + const resolved = await resolveAttachmentFn(opts.mediaUrl.trim(), maxBytes, { + localRoots: opts.mediaLocalRoots, + }); + filePath = resolved.path; + if (!message.trim()) { + const kind = kindFromMime(resolved.contentType ?? undefined); + if (kind) { + message = kind === "image" ? "" : ``; + } + } + } + + if (!message.trim() && !filePath) { + throw new Error("iMessage send requires text or media"); + } + if (message.trim()) { + const tableMode = resolveMarkdownTableMode({ + cfg, + channel: "imessage", + accountId: account.accountId, + }); + message = convertMarkdownTables(message, tableMode); + } + message = prependReplyTagIfNeeded(message, opts.replyToId); + + const params: Record = { + text: message, + service: service || "auto", + 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 ?? + (opts.createClient + ? await opts.createClient({ cliPath, dbPath }) + : await createIMessageRpcClient({ cliPath, dbPath })); + const shouldClose = !opts.client; + try { + const result = await client.request<{ ok?: string }>("send", params, { + timeoutMs: opts.timeoutMs, + }); + const resolvedId = resolveMessageId(result); + return { + messageId: resolvedId ?? (result?.ok ? "ok" : "unknown"), + }; + } finally { + if (shouldClose) { + await client.stop(); + } + } +} diff --git a/extensions/imessage/src/target-parsing-helpers.ts b/extensions/imessage/src/target-parsing-helpers.ts new file mode 100644 index 00000000000..95ccc3682ce --- /dev/null +++ b/extensions/imessage/src/target-parsing-helpers.ts @@ -0,0 +1,223 @@ +import { isAllowedParsedChatSender } from "../../../src/plugin-sdk/allow-from.js"; + +export type ServicePrefix = { prefix: string; service: TService }; + +export type ChatTargetPrefixesParams = { + trimmed: string; + lower: string; + chatIdPrefixes: string[]; + chatGuidPrefixes: string[]; + chatIdentifierPrefixes: string[]; +}; + +export type ParsedChatTarget = + | { kind: "chat_id"; chatId: number } + | { kind: "chat_guid"; chatGuid: string } + | { kind: "chat_identifier"; chatIdentifier: string }; + +export type ParsedChatAllowTarget = ParsedChatTarget | { kind: "handle"; handle: string }; + +export type ChatSenderAllowParams = { + allowFrom: Array; + sender: string; + chatId?: number | null; + chatGuid?: string | null; + chatIdentifier?: string | null; +}; + +function stripPrefix(value: string, prefix: string): string { + return value.slice(prefix.length).trim(); +} + +function startsWithAnyPrefix(value: string, prefixes: readonly string[]): boolean { + return prefixes.some((prefix) => value.startsWith(prefix)); +} + +export function resolveServicePrefixedTarget(params: { + trimmed: string; + lower: string; + servicePrefixes: Array>; + isChatTarget: (remainderLower: string) => boolean; + parseTarget: (remainder: string) => TTarget; +}): ({ kind: "handle"; to: string; service: TService } | TTarget) | null { + for (const { prefix, service } of params.servicePrefixes) { + if (!params.lower.startsWith(prefix)) { + continue; + } + const remainder = stripPrefix(params.trimmed, prefix); + if (!remainder) { + throw new Error(`${prefix} target is required`); + } + const remainderLower = remainder.toLowerCase(); + if (params.isChatTarget(remainderLower)) { + return params.parseTarget(remainder); + } + return { kind: "handle", to: remainder, service }; + } + return null; +} + +export function resolveServicePrefixedChatTarget(params: { + trimmed: string; + lower: string; + servicePrefixes: Array>; + chatIdPrefixes: string[]; + chatGuidPrefixes: string[]; + chatIdentifierPrefixes: string[]; + extraChatPrefixes?: string[]; + parseTarget: (remainder: string) => TTarget; +}): ({ kind: "handle"; to: string; service: TService } | TTarget) | null { + const chatPrefixes = [ + ...params.chatIdPrefixes, + ...params.chatGuidPrefixes, + ...params.chatIdentifierPrefixes, + ...(params.extraChatPrefixes ?? []), + ]; + return resolveServicePrefixedTarget({ + trimmed: params.trimmed, + lower: params.lower, + servicePrefixes: params.servicePrefixes, + isChatTarget: (remainderLower) => startsWithAnyPrefix(remainderLower, chatPrefixes), + parseTarget: params.parseTarget, + }); +} + +export function parseChatTargetPrefixesOrThrow( + params: ChatTargetPrefixesParams, +): ParsedChatTarget | null { + for (const prefix of params.chatIdPrefixes) { + if (params.lower.startsWith(prefix)) { + const value = stripPrefix(params.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 params.chatGuidPrefixes) { + if (params.lower.startsWith(prefix)) { + const value = stripPrefix(params.trimmed, prefix); + if (!value) { + throw new Error("chat_guid is required"); + } + return { kind: "chat_guid", chatGuid: value }; + } + } + + for (const prefix of params.chatIdentifierPrefixes) { + if (params.lower.startsWith(prefix)) { + const value = stripPrefix(params.trimmed, prefix); + if (!value) { + throw new Error("chat_identifier is required"); + } + return { kind: "chat_identifier", chatIdentifier: value }; + } + } + + return null; +} + +export function resolveServicePrefixedAllowTarget(params: { + trimmed: string; + lower: string; + servicePrefixes: Array<{ prefix: string }>; + parseAllowTarget: (remainder: string) => TAllowTarget; +}): (TAllowTarget | { kind: "handle"; handle: string }) | null { + for (const { prefix } of params.servicePrefixes) { + if (!params.lower.startsWith(prefix)) { + continue; + } + const remainder = stripPrefix(params.trimmed, prefix); + if (!remainder) { + return { kind: "handle", handle: "" }; + } + return params.parseAllowTarget(remainder); + } + return null; +} + +export function resolveServicePrefixedOrChatAllowTarget< + TAllowTarget extends ParsedChatAllowTarget, +>(params: { + trimmed: string; + lower: string; + servicePrefixes: Array<{ prefix: string }>; + parseAllowTarget: (remainder: string) => TAllowTarget; + chatIdPrefixes: string[]; + chatGuidPrefixes: string[]; + chatIdentifierPrefixes: string[]; +}): TAllowTarget | null { + const servicePrefixed = resolveServicePrefixedAllowTarget({ + trimmed: params.trimmed, + lower: params.lower, + servicePrefixes: params.servicePrefixes, + parseAllowTarget: params.parseAllowTarget, + }); + if (servicePrefixed) { + return servicePrefixed as TAllowTarget; + } + + const chatTarget = parseChatAllowTargetPrefixes({ + trimmed: params.trimmed, + lower: params.lower, + chatIdPrefixes: params.chatIdPrefixes, + chatGuidPrefixes: params.chatGuidPrefixes, + chatIdentifierPrefixes: params.chatIdentifierPrefixes, + }); + if (chatTarget) { + return chatTarget as TAllowTarget; + } + return null; +} + +export function createAllowedChatSenderMatcher(params: { + normalizeSender: (sender: string) => string; + parseAllowTarget: (entry: string) => TParsed; +}): (input: ChatSenderAllowParams) => boolean { + return (input) => + isAllowedParsedChatSender({ + allowFrom: input.allowFrom, + sender: input.sender, + chatId: input.chatId, + chatGuid: input.chatGuid, + chatIdentifier: input.chatIdentifier, + normalizeSender: params.normalizeSender, + parseAllowTarget: params.parseAllowTarget, + }); +} + +export function parseChatAllowTargetPrefixes( + params: ChatTargetPrefixesParams, +): ParsedChatTarget | null { + for (const prefix of params.chatIdPrefixes) { + if (params.lower.startsWith(prefix)) { + const value = stripPrefix(params.trimmed, prefix); + const chatId = Number.parseInt(value, 10); + if (Number.isFinite(chatId)) { + return { kind: "chat_id", chatId }; + } + } + } + + for (const prefix of params.chatGuidPrefixes) { + if (params.lower.startsWith(prefix)) { + const value = stripPrefix(params.trimmed, prefix); + if (value) { + return { kind: "chat_guid", chatGuid: value }; + } + } + } + + for (const prefix of params.chatIdentifierPrefixes) { + if (params.lower.startsWith(prefix)) { + const value = stripPrefix(params.trimmed, prefix); + if (value) { + return { kind: "chat_identifier", chatIdentifier: value }; + } + } + } + + return null; +} diff --git a/src/imessage/targets.test.ts b/extensions/imessage/src/targets.test.ts similarity index 100% rename from src/imessage/targets.test.ts rename to extensions/imessage/src/targets.test.ts diff --git a/extensions/imessage/src/targets.ts b/extensions/imessage/src/targets.ts new file mode 100644 index 00000000000..a376a6e7f45 --- /dev/null +++ b/extensions/imessage/src/targets.ts @@ -0,0 +1,147 @@ +import { normalizeE164 } from "../../../src/utils.js"; +import { + createAllowedChatSenderMatcher, + type ChatSenderAllowParams, + type ParsedChatTarget, + parseChatTargetPrefixesOrThrow, + resolveServicePrefixedChatTarget, + resolveServicePrefixedOrChatAllowTarget, +} from "./target-parsing-helpers.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 = ParsedChatTarget | { 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" }, +]; + +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)); + } + + // Normalize chat_id/chat_guid/chat_identifier prefixes case-insensitively + for (const prefix of CHAT_ID_PREFIXES) { + if (lowered.startsWith(prefix)) { + const value = trimmed.slice(prefix.length).trim(); + return `chat_id:${value}`; + } + } + for (const prefix of CHAT_GUID_PREFIXES) { + if (lowered.startsWith(prefix)) { + const value = trimmed.slice(prefix.length).trim(); + return `chat_guid:${value}`; + } + } + for (const prefix of CHAT_IDENTIFIER_PREFIXES) { + if (lowered.startsWith(prefix)) { + const value = trimmed.slice(prefix.length).trim(); + return `chat_identifier:${value}`; + } + } + + 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(); + + const servicePrefixed = resolveServicePrefixedChatTarget({ + trimmed, + lower, + servicePrefixes: SERVICE_PREFIXES, + chatIdPrefixes: CHAT_ID_PREFIXES, + chatGuidPrefixes: CHAT_GUID_PREFIXES, + chatIdentifierPrefixes: CHAT_IDENTIFIER_PREFIXES, + parseTarget: parseIMessageTarget, + }); + if (servicePrefixed) { + return servicePrefixed; + } + + const chatTarget = parseChatTargetPrefixesOrThrow({ + trimmed, + lower, + chatIdPrefixes: CHAT_ID_PREFIXES, + chatGuidPrefixes: CHAT_GUID_PREFIXES, + chatIdentifierPrefixes: CHAT_IDENTIFIER_PREFIXES, + }); + if (chatTarget) { + return chatTarget; + } + + 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(); + + const servicePrefixed = resolveServicePrefixedOrChatAllowTarget({ + trimmed, + lower, + servicePrefixes: SERVICE_PREFIXES, + parseAllowTarget: parseIMessageAllowTarget, + chatIdPrefixes: CHAT_ID_PREFIXES, + chatGuidPrefixes: CHAT_GUID_PREFIXES, + chatIdentifierPrefixes: CHAT_IDENTIFIER_PREFIXES, + }); + if (servicePrefixed) { + return servicePrefixed; + } + + return { kind: "handle", handle: normalizeIMessageHandle(trimmed) }; +} + +const isAllowedIMessageSenderMatcher = createAllowedChatSenderMatcher({ + normalizeSender: normalizeIMessageHandle, + parseAllowTarget: parseIMessageAllowTarget, +}); + +export function isAllowedIMessageSender(params: ChatSenderAllowParams): boolean { + return isAllowedIMessageSenderMatcher(params); +} + +export function formatIMessageChatTarget(chatId?: number | null): string { + if (!chatId || !Number.isFinite(chatId)) { + return ""; + } + return `chat_id:${chatId}`; +} diff --git a/src/imessage/accounts.ts b/src/imessage/accounts.ts index d0ed6a9218c..e30ba6e559b 100644 --- a/src/imessage/accounts.ts +++ b/src/imessage/accounts.ts @@ -1,70 +1,2 @@ -import { createAccountListHelpers } from "../channels/plugins/account-helpers.js"; -import type { OpenClawConfig } from "../config/config.js"; -import type { IMessageAccountConfig } from "../config/types.js"; -import { resolveAccountEntry } from "../routing/account-lookup.js"; -import { normalizeAccountId } from "../routing/session-key.js"; - -export type ResolvedIMessageAccount = { - accountId: string; - enabled: boolean; - name?: string; - config: IMessageAccountConfig; - configured: boolean; -}; - -const { listAccountIds, resolveDefaultAccountId } = createAccountListHelpers("imessage"); -export const listIMessageAccountIds = listAccountIds; -export const resolveDefaultIMessageAccountId = resolveDefaultAccountId; - -function resolveAccountConfig( - cfg: OpenClawConfig, - accountId: string, -): IMessageAccountConfig | undefined { - return resolveAccountEntry(cfg.channels?.imessage?.accounts, accountId); -} - -function mergeIMessageAccountConfig(cfg: OpenClawConfig, accountId: string): IMessageAccountConfig { - const { accounts: _ignored, ...base } = (cfg.channels?.imessage ?? - {}) as IMessageAccountConfig & { accounts?: unknown }; - const account = resolveAccountConfig(cfg, accountId) ?? {}; - return { ...base, ...account }; -} - -export function resolveIMessageAccount(params: { - cfg: OpenClawConfig; - accountId?: string | null; -}): ResolvedIMessageAccount { - const accountId = normalizeAccountId(params.accountId); - const baseEnabled = params.cfg.channels?.imessage?.enabled !== false; - const merged = mergeIMessageAccountConfig(params.cfg, accountId); - const accountEnabled = merged.enabled !== false; - const configured = Boolean( - merged.cliPath?.trim() || - merged.dbPath?.trim() || - merged.service || - merged.region?.trim() || - (merged.allowFrom && merged.allowFrom.length > 0) || - (merged.groupAllowFrom && merged.groupAllowFrom.length > 0) || - merged.dmPolicy || - merged.groupPolicy || - typeof merged.includeAttachments === "boolean" || - (merged.attachmentRoots && merged.attachmentRoots.length > 0) || - (merged.remoteAttachmentRoots && merged.remoteAttachmentRoots.length > 0) || - typeof merged.mediaMaxMb === "number" || - typeof merged.textChunkLimit === "number" || - (merged.groups && Object.keys(merged.groups).length > 0), - ); - return { - accountId, - enabled: baseEnabled && accountEnabled, - name: merged.name?.trim() || undefined, - config: merged, - configured, - }; -} - -export function listEnabledIMessageAccounts(cfg: OpenClawConfig): ResolvedIMessageAccount[] { - return listIMessageAccountIds(cfg) - .map((accountId) => resolveIMessageAccount({ cfg, accountId })) - .filter((account) => account.enabled); -} +// Shim: re-exports from extensions/imessage/src/accounts +export * from "../../extensions/imessage/src/accounts.js"; diff --git a/src/imessage/client.ts b/src/imessage/client.ts index d4ec458a7e9..f89deeec3c4 100644 --- a/src/imessage/client.ts +++ b/src/imessage/client.ts @@ -1,255 +1,2 @@ -import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process"; -import { createInterface, type Interface } from "node:readline"; -import type { RuntimeEnv } from "../runtime.js"; -import { resolveUserPath } from "../utils.js"; -import { DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS } from "./constants.js"; - -export type IMessageRpcError = { - code?: number; - message?: string; - data?: unknown; -}; - -export type IMessageRpcResponse = { - jsonrpc?: string; - id?: string | number | null; - result?: T; - error?: IMessageRpcError; - method?: string; - params?: unknown; -}; - -export type IMessageRpcNotification = { - method: string; - params?: unknown; -}; - -export type IMessageRpcClientOptions = { - cliPath?: string; - dbPath?: string; - runtime?: RuntimeEnv; - onNotification?: (msg: IMessageRpcNotification) => void; -}; - -type PendingRequest = { - resolve: (value: unknown) => void; - reject: (error: Error) => void; - timer?: NodeJS.Timeout; -}; - -function isTestEnv(): boolean { - if (process.env.NODE_ENV === "test") { - return true; - } - const vitest = process.env.VITEST?.trim().toLowerCase(); - return Boolean(vitest); -} - -export class IMessageRpcClient { - private readonly cliPath: string; - private readonly dbPath?: string; - private readonly runtime?: RuntimeEnv; - private readonly onNotification?: (msg: IMessageRpcNotification) => void; - private readonly pending = new Map(); - private readonly closed: Promise; - private closedResolve: (() => void) | null = null; - private child: ChildProcessWithoutNullStreams | null = null; - private reader: Interface | null = null; - private nextId = 1; - - constructor(opts: IMessageRpcClientOptions = {}) { - this.cliPath = opts.cliPath?.trim() || "imsg"; - this.dbPath = opts.dbPath?.trim() ? resolveUserPath(opts.dbPath) : undefined; - this.runtime = opts.runtime; - this.onNotification = opts.onNotification; - this.closed = new Promise((resolve) => { - this.closedResolve = resolve; - }); - } - - async start(): Promise { - if (this.child) { - return; - } - if (isTestEnv()) { - throw new Error("Refusing to start imsg rpc in test environment; mock iMessage RPC client"); - } - const args = ["rpc"]; - if (this.dbPath) { - args.push("--db", this.dbPath); - } - const child = spawn(this.cliPath, args, { - stdio: ["pipe", "pipe", "pipe"], - }); - this.child = child; - this.reader = createInterface({ input: child.stdout }); - - this.reader.on("line", (line) => { - const trimmed = line.trim(); - if (!trimmed) { - return; - } - this.handleLine(trimmed); - }); - - child.stderr?.on("data", (chunk) => { - const lines = chunk.toString().split(/\r?\n/); - for (const line of lines) { - if (!line.trim()) { - continue; - } - this.runtime?.error?.(`imsg rpc: ${line.trim()}`); - } - }); - - child.on("error", (err) => { - this.failAll(err instanceof Error ? err : new Error(String(err))); - this.closedResolve?.(); - }); - - child.on("close", (code, signal) => { - if (code !== 0 && code !== null) { - const reason = signal ? `signal ${signal}` : `code ${code}`; - this.failAll(new Error(`imsg rpc exited (${reason})`)); - } else { - this.failAll(new Error("imsg rpc closed")); - } - this.closedResolve?.(); - }); - } - - async stop(): Promise { - if (!this.child) { - return; - } - this.reader?.close(); - this.reader = null; - this.child.stdin?.end(); - const child = this.child; - this.child = null; - - await Promise.race([ - this.closed, - new Promise((resolve) => { - setTimeout(() => { - if (!child.killed) { - child.kill("SIGTERM"); - } - resolve(); - }, 500); - }), - ]); - } - - async waitForClose(): Promise { - await this.closed; - } - - async request( - method: string, - params?: Record, - opts?: { timeoutMs?: number }, - ): Promise { - if (!this.child || !this.child.stdin) { - throw new Error("imsg rpc not running"); - } - const id = this.nextId++; - const payload = { - jsonrpc: "2.0", - id, - method, - params: params ?? {}, - }; - const line = `${JSON.stringify(payload)}\n`; - const timeoutMs = opts?.timeoutMs ?? DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS; - - const response = new Promise((resolve, reject) => { - const key = String(id); - const timer = - timeoutMs > 0 - ? setTimeout(() => { - this.pending.delete(key); - reject(new Error(`imsg rpc timeout (${method})`)); - }, timeoutMs) - : undefined; - this.pending.set(key, { - resolve: (value) => resolve(value as T), - reject, - timer, - }); - }); - - this.child.stdin.write(line); - return await response; - } - - private handleLine(line: string) { - let parsed: IMessageRpcResponse; - try { - parsed = JSON.parse(line) as IMessageRpcResponse; - } catch (err) { - const detail = err instanceof Error ? err.message : String(err); - this.runtime?.error?.(`imsg rpc: failed to parse ${line}: ${detail}`); - 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 baseMessage = parsed.error.message ?? "imsg rpc error"; - const details = parsed.error.data; - const code = parsed.error.code; - const suffixes = [] as string[]; - if (typeof code === "number") { - suffixes.push(`code=${code}`); - } - if (details !== undefined) { - const detailText = - typeof details === "string" ? details : JSON.stringify(details, null, 2); - if (detailText) { - suffixes.push(detailText); - } - } - const msg = suffixes.length > 0 ? `${baseMessage}: ${suffixes.join(" ")}` : baseMessage; - pending.reject(new Error(msg)); - return; - } - pending.resolve(parsed.result); - return; - } - - if (parsed.method) { - this.onNotification?.({ - method: parsed.method, - params: parsed.params, - }); - } - } - - private failAll(err: Error) { - for (const [key, pending] of this.pending.entries()) { - if (pending.timer) { - clearTimeout(pending.timer); - } - pending.reject(err); - this.pending.delete(key); - } - } -} - -export async function createIMessageRpcClient( - opts: IMessageRpcClientOptions = {}, -): Promise { - const client = new IMessageRpcClient(opts); - await client.start(); - return client; -} +// Shim: re-exports from extensions/imessage/src/client +export * from "../../extensions/imessage/src/client.js"; diff --git a/src/imessage/constants.ts b/src/imessage/constants.ts index d82eaa5028b..a4217dd0bd0 100644 --- a/src/imessage/constants.ts +++ b/src/imessage/constants.ts @@ -1,2 +1,2 @@ -/** Default timeout for iMessage probe/RPC operations (10 seconds). */ -export const DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS = 10_000; +// Shim: re-exports from extensions/imessage/src/constants +export * from "../../extensions/imessage/src/constants.js"; diff --git a/src/imessage/monitor.ts b/src/imessage/monitor.ts index 487e99e5911..0cdd8cc9067 100644 --- a/src/imessage/monitor.ts +++ b/src/imessage/monitor.ts @@ -1,2 +1,2 @@ -export { monitorIMessageProvider } from "./monitor/monitor-provider.js"; -export type { MonitorIMessageOpts } from "./monitor/types.js"; +// Shim: re-exports from extensions/imessage/src/monitor +export * from "../../extensions/imessage/src/monitor.js"; diff --git a/src/imessage/monitor/abort-handler.ts b/src/imessage/monitor/abort-handler.ts index bd5388260df..52d6fc5d8f9 100644 --- a/src/imessage/monitor/abort-handler.ts +++ b/src/imessage/monitor/abort-handler.ts @@ -1,34 +1,2 @@ -export type IMessageMonitorClient = { - request: (method: string, params?: Record) => Promise; - stop: () => Promise; -}; - -export function attachIMessageMonitorAbortHandler(params: { - abortSignal?: AbortSignal; - client: IMessageMonitorClient; - getSubscriptionId: () => number | null; -}): () => void { - const abort = params.abortSignal; - if (!abort) { - return () => {}; - } - - const onAbort = () => { - const subscriptionId = params.getSubscriptionId(); - if (subscriptionId) { - void params.client - .request("watch.unsubscribe", { - subscription: subscriptionId, - }) - .catch(() => { - // Ignore disconnect errors during shutdown. - }); - } - void params.client.stop().catch(() => { - // Ignore disconnect errors during shutdown. - }); - }; - - abort.addEventListener("abort", onAbort, { once: true }); - return () => abort.removeEventListener("abort", onAbort); -} +// Shim: re-exports from extensions/imessage/src/monitor/abort-handler +export * from "../../../extensions/imessage/src/monitor/abort-handler.js"; diff --git a/src/imessage/monitor/deliver.ts b/src/imessage/monitor/deliver.ts index fc949d3cfc1..107c713995c 100644 --- a/src/imessage/monitor/deliver.ts +++ b/src/imessage/monitor/deliver.ts @@ -1,70 +1,2 @@ -import { chunkTextWithMode, resolveChunkMode } from "../../auto-reply/chunk.js"; -import type { ReplyPayload } from "../../auto-reply/types.js"; -import { loadConfig } from "../../config/config.js"; -import { resolveMarkdownTableMode } from "../../config/markdown-tables.js"; -import { convertMarkdownTables } from "../../markdown/tables.js"; -import type { RuntimeEnv } from "../../runtime.js"; -import type { createIMessageRpcClient } from "../client.js"; -import { sendMessageIMessage } from "../send.js"; -import type { SentMessageCache } from "./echo-cache.js"; -import { sanitizeOutboundText } from "./sanitize-outbound.js"; - -export async function deliverReplies(params: { - replies: ReplyPayload[]; - target: string; - client: Awaited>; - accountId?: string; - runtime: RuntimeEnv; - maxBytes: number; - textLimit: number; - sentMessageCache?: Pick; -}) { - const { replies, target, client, runtime, maxBytes, textLimit, accountId, sentMessageCache } = - params; - const scope = `${accountId ?? ""}:${target}`; - const cfg = loadConfig(); - const tableMode = resolveMarkdownTableMode({ - cfg, - channel: "imessage", - accountId, - }); - const chunkMode = resolveChunkMode(cfg, "imessage", accountId); - for (const payload of replies) { - const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); - const rawText = sanitizeOutboundText(payload.text ?? ""); - const text = convertMarkdownTables(rawText, tableMode); - if (!text && mediaList.length === 0) { - continue; - } - if (mediaList.length === 0) { - sentMessageCache?.remember(scope, { text }); - for (const chunk of chunkTextWithMode(text, textLimit, chunkMode)) { - const sent = await sendMessageIMessage(target, chunk, { - maxBytes, - client, - accountId, - replyToId: payload.replyToId, - }); - sentMessageCache?.remember(scope, { text: chunk, messageId: sent.messageId }); - } - } else { - let first = true; - for (const url of mediaList) { - const caption = first ? text : ""; - first = false; - const sent = await sendMessageIMessage(target, caption, { - mediaUrl: url, - maxBytes, - client, - accountId, - replyToId: payload.replyToId, - }); - sentMessageCache?.remember(scope, { - text: caption || undefined, - messageId: sent.messageId, - }); - } - } - runtime.log?.(`imessage: delivered reply to ${target}`); - } -} +// Shim: re-exports from extensions/imessage/src/monitor/deliver +export * from "../../../extensions/imessage/src/monitor/deliver.js"; diff --git a/src/imessage/monitor/echo-cache.ts b/src/imessage/monitor/echo-cache.ts index 06f5ee847f5..fc38448ad95 100644 --- a/src/imessage/monitor/echo-cache.ts +++ b/src/imessage/monitor/echo-cache.ts @@ -1,87 +1,2 @@ -export type SentMessageLookup = { - text?: string; - messageId?: string; -}; - -export type SentMessageCache = { - remember: (scope: string, lookup: SentMessageLookup) => void; - has: (scope: string, lookup: SentMessageLookup) => boolean; -}; - -// Keep the text fallback short so repeated user replies like "ok" are not -// suppressed for long; delayed reflections should match the stronger message-id key. -const SENT_MESSAGE_TEXT_TTL_MS = 5_000; -const SENT_MESSAGE_ID_TTL_MS = 60_000; - -function normalizeEchoTextKey(text: string | undefined): string | null { - if (!text) { - return null; - } - const normalized = text.replace(/\r\n?/g, "\n").trim(); - return normalized ? normalized : null; -} - -function normalizeEchoMessageIdKey(messageId: string | undefined): string | null { - if (!messageId) { - return null; - } - const normalized = messageId.trim(); - if (!normalized || normalized === "ok" || normalized === "unknown") { - return null; - } - return normalized; -} - -class DefaultSentMessageCache implements SentMessageCache { - private textCache = new Map(); - private messageIdCache = new Map(); - - remember(scope: string, lookup: SentMessageLookup): void { - const textKey = normalizeEchoTextKey(lookup.text); - if (textKey) { - this.textCache.set(`${scope}:${textKey}`, Date.now()); - } - const messageIdKey = normalizeEchoMessageIdKey(lookup.messageId); - if (messageIdKey) { - this.messageIdCache.set(`${scope}:${messageIdKey}`, Date.now()); - } - this.cleanup(); - } - - has(scope: string, lookup: SentMessageLookup): boolean { - this.cleanup(); - const messageIdKey = normalizeEchoMessageIdKey(lookup.messageId); - if (messageIdKey) { - const idTimestamp = this.messageIdCache.get(`${scope}:${messageIdKey}`); - if (idTimestamp && Date.now() - idTimestamp <= SENT_MESSAGE_ID_TTL_MS) { - return true; - } - } - const textKey = normalizeEchoTextKey(lookup.text); - if (textKey) { - const textTimestamp = this.textCache.get(`${scope}:${textKey}`); - if (textTimestamp && Date.now() - textTimestamp <= SENT_MESSAGE_TEXT_TTL_MS) { - return true; - } - } - return false; - } - - private cleanup(): void { - const now = Date.now(); - for (const [key, timestamp] of this.textCache.entries()) { - if (now - timestamp > SENT_MESSAGE_TEXT_TTL_MS) { - this.textCache.delete(key); - } - } - for (const [key, timestamp] of this.messageIdCache.entries()) { - if (now - timestamp > SENT_MESSAGE_ID_TTL_MS) { - this.messageIdCache.delete(key); - } - } - } -} - -export function createSentMessageCache(): SentMessageCache { - return new DefaultSentMessageCache(); -} +// Shim: re-exports from extensions/imessage/src/monitor/echo-cache +export * from "../../../extensions/imessage/src/monitor/echo-cache.js"; diff --git a/src/imessage/monitor/inbound-processing.ts b/src/imessage/monitor/inbound-processing.ts index fcef1fd53c9..c00b48c4b1a 100644 --- a/src/imessage/monitor/inbound-processing.ts +++ b/src/imessage/monitor/inbound-processing.ts @@ -1,522 +1,2 @@ -import { hasControlCommand } from "../../auto-reply/command-detection.js"; -import { - formatInboundEnvelope, - formatInboundFromLabel, - resolveEnvelopeFormatOptions, - type EnvelopeFormatOptions, -} from "../../auto-reply/envelope.js"; -import { - buildPendingHistoryContextFromMap, - recordPendingHistoryEntryIfEnabled, - type HistoryEntry, -} from "../../auto-reply/reply/history.js"; -import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js"; -import { buildMentionRegexes, matchesMentionPatterns } from "../../auto-reply/reply/mentions.js"; -import { resolveDualTextControlCommandGate } from "../../channels/command-gating.js"; -import { logInboundDrop } from "../../channels/logging.js"; -import type { OpenClawConfig } from "../../config/config.js"; -import { - resolveChannelGroupPolicy, - resolveChannelGroupRequireMention, -} from "../../config/group-policy.js"; -import { resolveAgentRoute } from "../../routing/resolve-route.js"; -import { - DM_GROUP_ACCESS_REASON, - resolveDmGroupAccessWithLists, -} from "../../security/dm-policy-shared.js"; -import { sanitizeTerminalText } from "../../terminal/safe-text.js"; -import { truncateUtf16Safe } from "../../utils.js"; -import { - formatIMessageChatTarget, - isAllowedIMessageSender, - normalizeIMessageHandle, -} from "../targets.js"; -import { detectReflectedContent } from "./reflection-guard.js"; -import type { SelfChatCache } from "./self-chat-cache.js"; -import type { MonitorIMessageOpts, IMessagePayload } from "./types.js"; - -type IMessageReplyContext = { - id?: string; - body: string; - sender?: string; -}; - -function normalizeReplyField(value: unknown): string | undefined { - if (typeof value === "string") { - const trimmed = value.trim(); - return trimmed ? trimmed : undefined; - } - if (typeof value === "number") { - return String(value); - } - return undefined; -} - -function describeReplyContext(message: IMessagePayload): IMessageReplyContext | null { - const body = normalizeReplyField(message.reply_to_text); - if (!body) { - return null; - } - const id = normalizeReplyField(message.reply_to_id); - const sender = normalizeReplyField(message.reply_to_sender); - return { body, id, sender }; -} - -export type IMessageInboundDispatchDecision = { - kind: "dispatch"; - isGroup: boolean; - chatId?: number; - chatGuid?: string; - chatIdentifier?: string; - groupId?: string; - historyKey?: string; - sender: string; - senderNormalized: string; - route: ReturnType; - bodyText: string; - createdAt?: number; - replyContext: IMessageReplyContext | null; - effectiveWasMentioned: boolean; - commandAuthorized: boolean; - // Used for allowlist checks for control commands. - effectiveDmAllowFrom: string[]; - effectiveGroupAllowFrom: string[]; -}; - -export type IMessageInboundDecision = - | { kind: "drop"; reason: string } - | { kind: "pairing"; senderId: string } - | IMessageInboundDispatchDecision; - -export function resolveIMessageInboundDecision(params: { - cfg: OpenClawConfig; - accountId: string; - message: IMessagePayload; - opts?: Pick; - messageText: string; - bodyText: string; - allowFrom: string[]; - groupAllowFrom: string[]; - groupPolicy: string; - dmPolicy: string; - storeAllowFrom: string[]; - historyLimit: number; - groupHistories: Map; - echoCache?: { has: (scope: string, lookup: { text?: string; messageId?: string }) => boolean }; - selfChatCache?: SelfChatCache; - logVerbose?: (msg: string) => void; -}): IMessageInboundDecision { - const senderRaw = params.message.sender ?? ""; - const sender = senderRaw.trim(); - if (!sender) { - return { kind: "drop", reason: "missing sender" }; - } - const senderNormalized = normalizeIMessageHandle(sender); - const chatId = params.message.chat_id ?? undefined; - const chatGuid = params.message.chat_guid ?? undefined; - const chatIdentifier = params.message.chat_identifier ?? undefined; - const createdAt = params.message.created_at ? Date.parse(params.message.created_at) : undefined; - - const groupIdCandidate = chatId !== undefined ? String(chatId) : undefined; - const groupListPolicy = groupIdCandidate - ? resolveChannelGroupPolicy({ - cfg: params.cfg, - channel: "imessage", - accountId: params.accountId, - groupId: groupIdCandidate, - }) - : { - allowlistEnabled: false, - allowed: true, - groupConfig: undefined, - defaultConfig: undefined, - }; - - // If the owner explicitly configures a chat_id under imessage.groups, treat that thread as a - // "group" for permission gating + session isolation, even when is_group=false. - const treatAsGroupByConfig = Boolean( - groupIdCandidate && groupListPolicy.allowlistEnabled && groupListPolicy.groupConfig, - ); - const isGroup = Boolean(params.message.is_group) || treatAsGroupByConfig; - const selfChatLookup = { - accountId: params.accountId, - isGroup, - chatId, - sender, - text: params.bodyText, - createdAt, - }; - if (params.message.is_from_me) { - params.selfChatCache?.remember(selfChatLookup); - return { kind: "drop", reason: "from me" }; - } - if (isGroup && !chatId) { - return { kind: "drop", reason: "group without chat_id" }; - } - - const groupId = isGroup ? groupIdCandidate : undefined; - const accessDecision = resolveDmGroupAccessWithLists({ - isGroup, - dmPolicy: params.dmPolicy, - groupPolicy: params.groupPolicy, - allowFrom: params.allowFrom, - groupAllowFrom: params.groupAllowFrom, - storeAllowFrom: params.storeAllowFrom, - groupAllowFromFallbackToAllowFrom: false, - isSenderAllowed: (allowFrom) => - isAllowedIMessageSender({ - allowFrom, - sender, - chatId, - chatGuid, - chatIdentifier, - }), - }); - const effectiveDmAllowFrom = accessDecision.effectiveAllowFrom; - const effectiveGroupAllowFrom = accessDecision.effectiveGroupAllowFrom; - - if (accessDecision.decision !== "allow") { - if (isGroup) { - if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.GROUP_POLICY_DISABLED) { - params.logVerbose?.("Blocked iMessage group message (groupPolicy: disabled)"); - return { kind: "drop", reason: "groupPolicy disabled" }; - } - if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.GROUP_POLICY_EMPTY_ALLOWLIST) { - params.logVerbose?.( - "Blocked iMessage group message (groupPolicy: allowlist, no groupAllowFrom)", - ); - return { kind: "drop", reason: "groupPolicy allowlist (empty groupAllowFrom)" }; - } - if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.GROUP_POLICY_NOT_ALLOWLISTED) { - params.logVerbose?.(`Blocked iMessage sender ${sender} (not in groupAllowFrom)`); - return { kind: "drop", reason: "not in groupAllowFrom" }; - } - params.logVerbose?.(`Blocked iMessage group message (${accessDecision.reason})`); - return { kind: "drop", reason: accessDecision.reason }; - } - if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.DM_POLICY_DISABLED) { - return { kind: "drop", reason: "dmPolicy disabled" }; - } - if (accessDecision.decision === "pairing") { - return { kind: "pairing", senderId: senderNormalized }; - } - params.logVerbose?.(`Blocked iMessage sender ${sender} (dmPolicy=${params.dmPolicy})`); - return { kind: "drop", reason: "dmPolicy blocked" }; - } - - if (isGroup && groupListPolicy.allowlistEnabled && !groupListPolicy.allowed) { - params.logVerbose?.( - `imessage: skipping group message (${groupId ?? "unknown"}) not in allowlist`, - ); - return { kind: "drop", reason: "group id not in allowlist" }; - } - - const route = resolveAgentRoute({ - cfg: params.cfg, - channel: "imessage", - accountId: params.accountId, - peer: { - kind: isGroup ? "group" : "direct", - id: isGroup ? String(chatId ?? "unknown") : senderNormalized, - }, - }); - const mentionRegexes = buildMentionRegexes(params.cfg, route.agentId); - const messageText = params.messageText.trim(); - const bodyText = params.bodyText.trim(); - if (!bodyText) { - return { kind: "drop", reason: "empty body" }; - } - - if ( - params.selfChatCache?.has({ - ...selfChatLookup, - text: bodyText, - }) - ) { - const preview = sanitizeTerminalText(truncateUtf16Safe(bodyText, 50)); - params.logVerbose?.(`imessage: dropping self-chat reflected duplicate: "${preview}"`); - return { kind: "drop", reason: "self-chat echo" }; - } - - // Echo detection: check if the received message matches a recently sent message. - // Scope by conversation so same text in different chats is not conflated. - const inboundMessageId = params.message.id != null ? String(params.message.id) : undefined; - if (params.echoCache && (messageText || inboundMessageId)) { - const echoScope = buildIMessageEchoScope({ - accountId: params.accountId, - isGroup, - chatId, - sender, - }); - if ( - params.echoCache.has(echoScope, { - text: messageText || undefined, - messageId: inboundMessageId, - }) - ) { - params.logVerbose?.( - describeIMessageEchoDropLog({ messageText, messageId: inboundMessageId }), - ); - return { kind: "drop", reason: "echo" }; - } - } - - // Reflection guard: drop inbound messages that contain assistant-internal - // metadata markers. These indicate outbound content was reflected back as - // inbound, which causes recursive echo amplification. - const reflection = detectReflectedContent(messageText); - if (reflection.isReflection) { - params.logVerbose?.( - `imessage: dropping reflected assistant content (markers: ${reflection.matchedLabels.join(", ")})`, - ); - return { kind: "drop", reason: "reflected assistant content" }; - } - - const replyContext = describeReplyContext(params.message); - const historyKey = isGroup - ? String(chatId ?? chatGuid ?? chatIdentifier ?? "unknown") - : undefined; - - const mentioned = isGroup ? matchesMentionPatterns(messageText, mentionRegexes) : true; - const requireMention = resolveChannelGroupRequireMention({ - cfg: params.cfg, - channel: "imessage", - accountId: params.accountId, - groupId, - requireMentionOverride: params.opts?.requireMention, - overrideOrder: "before-config", - }); - const canDetectMention = mentionRegexes.length > 0; - - const useAccessGroups = params.cfg.commands?.useAccessGroups !== false; - const commandDmAllowFrom = isGroup ? params.allowFrom : effectiveDmAllowFrom; - const ownerAllowedForCommands = - commandDmAllowFrom.length > 0 - ? isAllowedIMessageSender({ - allowFrom: commandDmAllowFrom, - sender, - chatId, - chatGuid, - chatIdentifier, - }) - : false; - const groupAllowedForCommands = - effectiveGroupAllowFrom.length > 0 - ? isAllowedIMessageSender({ - allowFrom: effectiveGroupAllowFrom, - sender, - chatId, - chatGuid, - chatIdentifier, - }) - : false; - const hasControlCommandInMessage = hasControlCommand(messageText, params.cfg); - const { commandAuthorized, shouldBlock } = resolveDualTextControlCommandGate({ - useAccessGroups, - primaryConfigured: commandDmAllowFrom.length > 0, - primaryAllowed: ownerAllowedForCommands, - secondaryConfigured: effectiveGroupAllowFrom.length > 0, - secondaryAllowed: groupAllowedForCommands, - hasControlCommand: hasControlCommandInMessage, - }); - if (isGroup && shouldBlock) { - if (params.logVerbose) { - logInboundDrop({ - log: params.logVerbose, - channel: "imessage", - reason: "control command (unauthorized)", - target: sender, - }); - } - return { kind: "drop", reason: "control command (unauthorized)" }; - } - - const shouldBypassMention = - isGroup && requireMention && !mentioned && commandAuthorized && hasControlCommandInMessage; - const effectiveWasMentioned = mentioned || shouldBypassMention; - if (isGroup && requireMention && canDetectMention && !mentioned && !shouldBypassMention) { - params.logVerbose?.(`imessage: skipping group message (no mention)`); - recordPendingHistoryEntryIfEnabled({ - historyMap: params.groupHistories, - historyKey: historyKey ?? "", - limit: params.historyLimit, - entry: historyKey - ? { - sender: senderNormalized, - body: bodyText, - timestamp: createdAt, - messageId: params.message.id ? String(params.message.id) : undefined, - } - : null, - }); - return { kind: "drop", reason: "no mention" }; - } - - return { - kind: "dispatch", - isGroup, - chatId, - chatGuid, - chatIdentifier, - groupId, - historyKey, - sender, - senderNormalized, - route, - bodyText, - createdAt, - replyContext, - effectiveWasMentioned, - commandAuthorized, - effectiveDmAllowFrom, - effectiveGroupAllowFrom, - }; -} - -export function buildIMessageInboundContext(params: { - cfg: OpenClawConfig; - decision: IMessageInboundDispatchDecision; - message: IMessagePayload; - envelopeOptions?: EnvelopeFormatOptions; - previousTimestamp?: number; - remoteHost?: string; - media?: { - path?: string; - type?: string; - paths?: string[]; - types?: Array; - }; - historyLimit: number; - groupHistories: Map; -}): { - ctxPayload: ReturnType; - fromLabel: string; - chatTarget?: string; - imessageTo: string; - inboundHistory?: Array<{ sender: string; body: string; timestamp?: number }>; -} { - const envelopeOptions = params.envelopeOptions ?? resolveEnvelopeFormatOptions(params.cfg); - const { decision } = params; - const chatId = decision.chatId; - const chatTarget = - decision.isGroup && chatId != null ? formatIMessageChatTarget(chatId) : undefined; - - const replySuffix = decision.replyContext - ? `\n\n[Replying to ${decision.replyContext.sender ?? "unknown sender"}${ - decision.replyContext.id ? ` id:${decision.replyContext.id}` : "" - }]\n${decision.replyContext.body}\n[/Replying]` - : ""; - - const fromLabel = formatInboundFromLabel({ - isGroup: decision.isGroup, - groupLabel: params.message.chat_name ?? undefined, - groupId: chatId !== undefined ? String(chatId) : "unknown", - groupFallback: "Group", - directLabel: decision.senderNormalized, - directId: decision.sender, - }); - - const body = formatInboundEnvelope({ - channel: "iMessage", - from: fromLabel, - timestamp: decision.createdAt, - body: `${decision.bodyText}${replySuffix}`, - chatType: decision.isGroup ? "group" : "direct", - sender: { name: decision.senderNormalized, id: decision.sender }, - previousTimestamp: params.previousTimestamp, - envelope: envelopeOptions, - }); - - let combinedBody = body; - if (decision.isGroup && decision.historyKey) { - combinedBody = buildPendingHistoryContextFromMap({ - historyMap: params.groupHistories, - historyKey: decision.historyKey, - limit: params.historyLimit, - currentMessage: combinedBody, - formatEntry: (entry) => - formatInboundEnvelope({ - channel: "iMessage", - from: fromLabel, - timestamp: entry.timestamp, - body: `${entry.body}${entry.messageId ? ` [id:${entry.messageId}]` : ""}`, - chatType: "group", - senderLabel: entry.sender, - envelope: envelopeOptions, - }), - }); - } - - const imessageTo = (decision.isGroup ? chatTarget : undefined) || `imessage:${decision.sender}`; - const inboundHistory = - decision.isGroup && decision.historyKey && params.historyLimit > 0 - ? (params.groupHistories.get(decision.historyKey) ?? []).map((entry) => ({ - sender: entry.sender, - body: entry.body, - timestamp: entry.timestamp, - })) - : undefined; - - const ctxPayload = finalizeInboundContext({ - Body: combinedBody, - BodyForAgent: decision.bodyText, - InboundHistory: inboundHistory, - RawBody: decision.bodyText, - CommandBody: decision.bodyText, - From: decision.isGroup - ? `imessage:group:${chatId ?? "unknown"}` - : `imessage:${decision.sender}`, - To: imessageTo, - SessionKey: decision.route.sessionKey, - AccountId: decision.route.accountId, - ChatType: decision.isGroup ? "group" : "direct", - ConversationLabel: fromLabel, - GroupSubject: decision.isGroup ? (params.message.chat_name ?? undefined) : undefined, - GroupMembers: decision.isGroup - ? (params.message.participants ?? []).filter(Boolean).join(", ") - : undefined, - SenderName: decision.senderNormalized, - SenderId: decision.sender, - Provider: "imessage", - Surface: "imessage", - MessageSid: params.message.id ? String(params.message.id) : undefined, - ReplyToId: decision.replyContext?.id, - ReplyToBody: decision.replyContext?.body, - ReplyToSender: decision.replyContext?.sender, - Timestamp: decision.createdAt, - MediaPath: params.media?.path, - MediaType: params.media?.type, - MediaUrl: params.media?.path, - MediaPaths: - params.media?.paths && params.media.paths.length > 0 ? params.media.paths : undefined, - MediaTypes: - params.media?.types && params.media.types.length > 0 ? params.media.types : undefined, - MediaUrls: - params.media?.paths && params.media.paths.length > 0 ? params.media.paths : undefined, - MediaRemoteHost: params.remoteHost, - WasMentioned: decision.effectiveWasMentioned, - CommandAuthorized: decision.commandAuthorized, - OriginatingChannel: "imessage" as const, - OriginatingTo: imessageTo, - }); - - return { ctxPayload, fromLabel, chatTarget, imessageTo, inboundHistory }; -} - -export function buildIMessageEchoScope(params: { - accountId: string; - isGroup: boolean; - chatId?: number; - sender: string; -}): string { - return `${params.accountId}:${params.isGroup ? formatIMessageChatTarget(params.chatId) : `imessage:${params.sender}`}`; -} - -export function describeIMessageEchoDropLog(params: { - messageText: string; - messageId?: string; -}): string { - const preview = truncateUtf16Safe(params.messageText, 50); - const messageIdPart = params.messageId ? ` id=${params.messageId}` : ""; - return `imessage: skipping echo message${messageIdPart}: "${preview}"`; -} +// Shim: re-exports from extensions/imessage/src/monitor/inbound-processing +export * from "../../../extensions/imessage/src/monitor/inbound-processing.js"; diff --git a/src/imessage/monitor/loop-rate-limiter.ts b/src/imessage/monitor/loop-rate-limiter.ts index 56c234a1b14..72349ec69a5 100644 --- a/src/imessage/monitor/loop-rate-limiter.ts +++ b/src/imessage/monitor/loop-rate-limiter.ts @@ -1,69 +1,2 @@ -/** - * Per-conversation rate limiter that detects rapid-fire identical echo - * patterns and suppresses them before they amplify into queue overflow. - */ - -const DEFAULT_WINDOW_MS = 60_000; -const DEFAULT_MAX_HITS = 5; -const CLEANUP_INTERVAL_MS = 120_000; - -type ConversationWindow = { - timestamps: number[]; -}; - -export type LoopRateLimiter = { - /** Returns true if this conversation has exceeded the rate limit. */ - isRateLimited: (conversationKey: string) => boolean; - /** Record an inbound message for a conversation. */ - record: (conversationKey: string) => void; -}; - -export function createLoopRateLimiter(opts?: { - windowMs?: number; - maxHits?: number; -}): LoopRateLimiter { - const windowMs = opts?.windowMs ?? DEFAULT_WINDOW_MS; - const maxHits = opts?.maxHits ?? DEFAULT_MAX_HITS; - const conversations = new Map(); - let lastCleanup = Date.now(); - - function cleanup() { - const now = Date.now(); - if (now - lastCleanup < CLEANUP_INTERVAL_MS) { - return; - } - lastCleanup = now; - for (const [key, win] of conversations.entries()) { - const recent = win.timestamps.filter((ts) => now - ts <= windowMs); - if (recent.length === 0) { - conversations.delete(key); - } else { - win.timestamps = recent; - } - } - } - - return { - record(conversationKey: string) { - cleanup(); - let win = conversations.get(conversationKey); - if (!win) { - win = { timestamps: [] }; - conversations.set(conversationKey, win); - } - win.timestamps.push(Date.now()); - }, - - isRateLimited(conversationKey: string): boolean { - cleanup(); - const win = conversations.get(conversationKey); - if (!win) { - return false; - } - const now = Date.now(); - const recent = win.timestamps.filter((ts) => now - ts <= windowMs); - win.timestamps = recent; - return recent.length >= maxHits; - }, - }; -} +// Shim: re-exports from extensions/imessage/src/monitor/loop-rate-limiter +export * from "../../../extensions/imessage/src/monitor/loop-rate-limiter.js"; diff --git a/src/imessage/monitor/monitor-provider.ts b/src/imessage/monitor/monitor-provider.ts index 1324529cbff..7649e7083fa 100644 --- a/src/imessage/monitor/monitor-provider.ts +++ b/src/imessage/monitor/monitor-provider.ts @@ -1,537 +1,2 @@ -import fs from "node:fs/promises"; -import { resolveHumanDelayConfig } from "../../agents/identity.js"; -import { resolveTextChunkLimit } from "../../auto-reply/chunk.js"; -import { dispatchInboundMessage } from "../../auto-reply/dispatch.js"; -import { - clearHistoryEntriesIfEnabled, - DEFAULT_GROUP_HISTORY_LIMIT, - type HistoryEntry, -} from "../../auto-reply/reply/history.js"; -import { createReplyDispatcher } from "../../auto-reply/reply/reply-dispatcher.js"; -import { - createChannelInboundDebouncer, - shouldDebounceTextInbound, -} from "../../channels/inbound-debounce-policy.js"; -import { createReplyPrefixOptions } from "../../channels/reply-prefix.js"; -import { recordInboundSession } from "../../channels/session.js"; -import { loadConfig } from "../../config/config.js"; -import { - resolveOpenProviderRuntimeGroupPolicy, - resolveDefaultGroupPolicy, - warnMissingProviderGroupPolicyFallbackOnce, -} from "../../config/runtime-group-policy.js"; -import { readSessionUpdatedAt, resolveStorePath } from "../../config/sessions.js"; -import { danger, logVerbose, shouldLogVerbose, warn } from "../../globals.js"; -import { normalizeScpRemoteHost } from "../../infra/scp-host.js"; -import { waitForTransportReady } from "../../infra/transport-ready.js"; -import { - isInboundPathAllowed, - resolveIMessageAttachmentRoots, - resolveIMessageRemoteAttachmentRoots, -} from "../../media/inbound-path-policy.js"; -import { kindFromMime } from "../../media/mime.js"; -import { issuePairingChallenge } from "../../pairing/pairing-challenge.js"; -import { - readChannelAllowFromStore, - upsertChannelPairingRequest, -} from "../../pairing/pairing-store.js"; -import { resolvePinnedMainDmOwnerFromAllowlist } from "../../security/dm-policy-shared.js"; -import { truncateUtf16Safe } from "../../utils.js"; -import { resolveIMessageAccount } from "../accounts.js"; -import { createIMessageRpcClient } from "../client.js"; -import { DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS } from "../constants.js"; -import { probeIMessage } from "../probe.js"; -import { sendMessageIMessage } from "../send.js"; -import { normalizeIMessageHandle } from "../targets.js"; -import { attachIMessageMonitorAbortHandler } from "./abort-handler.js"; -import { deliverReplies } from "./deliver.js"; -import { createSentMessageCache } from "./echo-cache.js"; -import { - buildIMessageInboundContext, - resolveIMessageInboundDecision, -} from "./inbound-processing.js"; -import { createLoopRateLimiter } from "./loop-rate-limiter.js"; -import { parseIMessageNotification } from "./parse-notification.js"; -import { normalizeAllowList, resolveRuntime } from "./runtime.js"; -import { createSelfChatCache } from "./self-chat-cache.js"; -import type { IMessagePayload, MonitorIMessageOpts } from "./types.js"; - -/** - * Try to detect remote host from an SSH wrapper script like: - * exec ssh -T openclaw@192.168.64.3 /opt/homebrew/bin/imsg "$@" - * exec ssh -T mac-mini imsg "$@" - * Returns the user@host or host portion if found, undefined otherwise. - */ -async function detectRemoteHostFromCliPath(cliPath: string): Promise { - try { - // Expand ~ to home directory - const expanded = cliPath.startsWith("~") - ? cliPath.replace(/^~/, process.env.HOME ?? "") - : cliPath; - const content = await fs.readFile(expanded, "utf8"); - - // Match user@host pattern first (e.g., openclaw@192.168.64.3) - const userHostMatch = content.match(/\bssh\b[^\n]*?\s+([a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+)/); - if (userHostMatch) { - return userHostMatch[1]; - } - - // Fallback: match host-only before imsg command (e.g., ssh -T mac-mini imsg) - const hostOnlyMatch = content.match(/\bssh\b[^\n]*?\s+([a-zA-Z][a-zA-Z0-9._-]*)\s+\S*\bimsg\b/); - return hostOnlyMatch?.[1]; - } catch { - return undefined; - } -} - -export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): Promise { - const runtime = resolveRuntime(opts); - const cfg = opts.config ?? loadConfig(); - const accountInfo = resolveIMessageAccount({ - cfg, - accountId: opts.accountId, - }); - const imessageCfg = accountInfo.config; - const historyLimit = Math.max( - 0, - imessageCfg.historyLimit ?? - cfg.messages?.groupChat?.historyLimit ?? - DEFAULT_GROUP_HISTORY_LIMIT, - ); - const groupHistories = new Map(); - const sentMessageCache = createSentMessageCache(); - const selfChatCache = createSelfChatCache(); - const loopRateLimiter = createLoopRateLimiter(); - const textLimit = resolveTextChunkLimit(cfg, "imessage", accountInfo.accountId); - const allowFrom = normalizeAllowList(opts.allowFrom ?? imessageCfg.allowFrom); - const groupAllowFrom = normalizeAllowList( - opts.groupAllowFrom ?? - imessageCfg.groupAllowFrom ?? - (imessageCfg.allowFrom && imessageCfg.allowFrom.length > 0 ? imessageCfg.allowFrom : []), - ); - const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg); - const { groupPolicy, providerMissingFallbackApplied } = resolveOpenProviderRuntimeGroupPolicy({ - providerConfigPresent: cfg.channels?.imessage !== undefined, - groupPolicy: imessageCfg.groupPolicy, - defaultGroupPolicy, - }); - warnMissingProviderGroupPolicyFallbackOnce({ - providerMissingFallbackApplied, - providerKey: "imessage", - accountId: accountInfo.accountId, - log: (message) => runtime.log?.(warn(message)), - }); - const dmPolicy = imessageCfg.dmPolicy ?? "pairing"; - const includeAttachments = opts.includeAttachments ?? imessageCfg.includeAttachments ?? false; - const mediaMaxBytes = (opts.mediaMaxMb ?? imessageCfg.mediaMaxMb ?? 16) * 1024 * 1024; - const cliPath = opts.cliPath ?? imessageCfg.cliPath ?? "imsg"; - const dbPath = opts.dbPath ?? imessageCfg.dbPath; - const probeTimeoutMs = imessageCfg.probeTimeoutMs ?? DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS; - const attachmentRoots = resolveIMessageAttachmentRoots({ - cfg, - accountId: accountInfo.accountId, - }); - const remoteAttachmentRoots = resolveIMessageRemoteAttachmentRoots({ - cfg, - accountId: accountInfo.accountId, - }); - - // Resolve remoteHost: explicit config, or auto-detect from SSH wrapper script. - // Accept only a safe host token to avoid option/argument injection into SCP. - const configuredRemoteHost = normalizeScpRemoteHost(imessageCfg.remoteHost); - if (imessageCfg.remoteHost && !configuredRemoteHost) { - logVerbose("imessage: ignoring unsafe channels.imessage.remoteHost value"); - } - - let remoteHost = configuredRemoteHost; - if (!remoteHost && cliPath && cliPath !== "imsg") { - const detected = await detectRemoteHostFromCliPath(cliPath); - const normalizedDetected = normalizeScpRemoteHost(detected); - if (detected && !normalizedDetected) { - logVerbose("imessage: ignoring unsafe auto-detected remoteHost from cliPath"); - } - remoteHost = normalizedDetected; - if (remoteHost) { - logVerbose(`imessage: detected remoteHost=${remoteHost} from cliPath`); - } - } - - const { debouncer: inboundDebouncer } = createChannelInboundDebouncer<{ - message: IMessagePayload; - }>({ - cfg, - channel: "imessage", - buildKey: (entry) => { - const sender = entry.message.sender?.trim(); - if (!sender) { - return null; - } - const conversationId = - entry.message.chat_id != null - ? `chat:${entry.message.chat_id}` - : (entry.message.chat_guid ?? entry.message.chat_identifier ?? "unknown"); - return `imessage:${accountInfo.accountId}:${conversationId}:${sender}`; - }, - shouldDebounce: (entry) => { - return shouldDebounceTextInbound({ - text: entry.message.text, - cfg, - hasMedia: Boolean(entry.message.attachments && entry.message.attachments.length > 0), - }); - }, - onFlush: async (entries) => { - const last = entries.at(-1); - if (!last) { - return; - } - if (entries.length === 1) { - await handleMessageNow(last.message); - return; - } - const combinedText = entries - .map((entry) => entry.message.text ?? "") - .filter(Boolean) - .join("\n"); - const syntheticMessage: IMessagePayload = { - ...last.message, - text: combinedText, - attachments: null, - }; - await handleMessageNow(syntheticMessage); - }, - onError: (err) => { - runtime.error?.(`imessage debounce flush failed: ${String(err)}`); - }, - }); - - async function handleMessageNow(message: IMessagePayload) { - const messageText = (message.text ?? "").trim(); - - const attachments = includeAttachments ? (message.attachments ?? []) : []; - const effectiveAttachmentRoots = remoteHost ? remoteAttachmentRoots : attachmentRoots; - const validAttachments = attachments.filter((entry) => { - const attachmentPath = entry?.original_path?.trim(); - if (!attachmentPath || entry?.missing) { - return false; - } - if (isInboundPathAllowed({ filePath: attachmentPath, roots: effectiveAttachmentRoots })) { - return true; - } - logVerbose(`imessage: dropping inbound attachment outside allowed roots: ${attachmentPath}`); - return false; - }); - const firstAttachment = validAttachments[0]; - const mediaPath = firstAttachment?.original_path ?? undefined; - const mediaType = firstAttachment?.mime_type ?? undefined; - // Build arrays for all attachments (for multi-image support) - const mediaPaths = validAttachments.map((a) => a.original_path).filter(Boolean) as string[]; - const mediaTypes = validAttachments.map((a) => a.mime_type ?? undefined); - const kind = kindFromMime(mediaType ?? undefined); - const placeholder = kind - ? `` - : validAttachments.length - ? "" - : ""; - const bodyText = messageText || placeholder; - - const storeAllowFrom = await readChannelAllowFromStore( - "imessage", - process.env, - accountInfo.accountId, - ).catch(() => []); - const decision = resolveIMessageInboundDecision({ - cfg, - accountId: accountInfo.accountId, - message, - opts, - messageText, - bodyText, - allowFrom, - groupAllowFrom, - groupPolicy, - dmPolicy, - storeAllowFrom, - historyLimit, - groupHistories, - echoCache: sentMessageCache, - selfChatCache, - logVerbose, - }); - - // Build conversation key for rate limiting (used by both drop and dispatch paths). - const chatId = message.chat_id ?? undefined; - const senderForKey = (message.sender ?? "").trim(); - const conversationKey = chatId != null ? `group:${chatId}` : `dm:${senderForKey}`; - const rateLimitKey = `${accountInfo.accountId}:${conversationKey}`; - - if (decision.kind === "drop") { - // Record echo/reflection drops so the rate limiter can detect sustained loops. - // Only loop-related drop reasons feed the counter; policy/mention/empty drops - // are normal and should not escalate. - const isLoopDrop = - decision.reason === "echo" || - decision.reason === "self-chat echo" || - decision.reason === "reflected assistant content" || - decision.reason === "from me"; - if (isLoopDrop) { - loopRateLimiter.record(rateLimitKey); - } - return; - } - - // After repeated echo/reflection drops for a conversation, suppress all - // remaining messages as a safety net against amplification that slips - // through the primary guards. - if (decision.kind === "dispatch" && loopRateLimiter.isRateLimited(rateLimitKey)) { - logVerbose(`imessage: rate-limited conversation ${conversationKey} (echo loop detected)`); - return; - } - - if (decision.kind === "pairing") { - const sender = (message.sender ?? "").trim(); - if (!sender) { - return; - } - await issuePairingChallenge({ - channel: "imessage", - senderId: decision.senderId, - senderIdLine: `Your iMessage sender id: ${decision.senderId}`, - meta: { - sender: decision.senderId, - chatId: chatId ? String(chatId) : undefined, - }, - upsertPairingRequest: async ({ id, meta }) => - await upsertChannelPairingRequest({ - channel: "imessage", - id, - accountId: accountInfo.accountId, - meta, - }), - onCreated: () => { - logVerbose(`imessage pairing request sender=${decision.senderId}`); - }, - sendPairingReply: async (text) => { - await sendMessageIMessage(sender, text, { - client, - maxBytes: mediaMaxBytes, - accountId: accountInfo.accountId, - ...(chatId ? { chatId } : {}), - }); - }, - onReplyError: (err) => { - logVerbose(`imessage pairing reply failed for ${decision.senderId}: ${String(err)}`); - }, - }); - return; - } - - const storePath = resolveStorePath(cfg.session?.store, { - agentId: decision.route.agentId, - }); - const previousTimestamp = readSessionUpdatedAt({ - storePath, - sessionKey: decision.route.sessionKey, - }); - const { ctxPayload, chatTarget } = buildIMessageInboundContext({ - cfg, - decision, - message, - previousTimestamp, - remoteHost, - historyLimit, - groupHistories, - media: { - path: mediaPath, - type: mediaType, - paths: mediaPaths, - types: mediaTypes, - }, - }); - - const updateTarget = chatTarget || decision.sender; - const pinnedMainDmOwner = resolvePinnedMainDmOwnerFromAllowlist({ - dmScope: cfg.session?.dmScope, - allowFrom, - normalizeEntry: normalizeIMessageHandle, - }); - await recordInboundSession({ - storePath, - sessionKey: ctxPayload.SessionKey ?? decision.route.sessionKey, - ctx: ctxPayload, - updateLastRoute: - !decision.isGroup && updateTarget - ? { - sessionKey: decision.route.mainSessionKey, - channel: "imessage", - to: updateTarget, - accountId: decision.route.accountId, - mainDmOwnerPin: - pinnedMainDmOwner && decision.senderNormalized - ? { - ownerRecipient: pinnedMainDmOwner, - senderRecipient: decision.senderNormalized, - onSkip: ({ ownerRecipient, senderRecipient }) => { - logVerbose( - `imessage: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`, - ); - }, - } - : undefined, - } - : undefined, - onRecordError: (err) => { - logVerbose(`imessage: failed updating session meta: ${String(err)}`); - }, - }); - - if (shouldLogVerbose()) { - const preview = truncateUtf16Safe(String(ctxPayload.Body ?? ""), 200).replace(/\n/g, "\\n"); - logVerbose( - `imessage inbound: chatId=${chatId ?? "unknown"} from=${ctxPayload.From} len=${ - String(ctxPayload.Body ?? "").length - } preview="${preview}"`, - ); - } - - const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({ - cfg, - agentId: decision.route.agentId, - channel: "imessage", - accountId: decision.route.accountId, - }); - - const dispatcher = createReplyDispatcher({ - ...prefixOptions, - humanDelay: resolveHumanDelayConfig(cfg, decision.route.agentId), - deliver: async (payload) => { - const target = ctxPayload.To; - if (!target) { - runtime.error?.(danger("imessage: missing delivery target")); - return; - } - await deliverReplies({ - replies: [payload], - target, - client, - accountId: accountInfo.accountId, - runtime, - maxBytes: mediaMaxBytes, - textLimit, - sentMessageCache, - }); - }, - onError: (err, info) => { - runtime.error?.(danger(`imessage ${info.kind} reply failed: ${String(err)}`)); - }, - }); - - const { queuedFinal } = await dispatchInboundMessage({ - ctx: ctxPayload, - cfg, - dispatcher, - replyOptions: { - disableBlockStreaming: - typeof accountInfo.config.blockStreaming === "boolean" - ? !accountInfo.config.blockStreaming - : undefined, - onModelSelected, - }, - }); - - if (!queuedFinal) { - if (decision.isGroup && decision.historyKey) { - clearHistoryEntriesIfEnabled({ - historyMap: groupHistories, - historyKey: decision.historyKey, - limit: historyLimit, - }); - } - return; - } - if (decision.isGroup && decision.historyKey) { - clearHistoryEntriesIfEnabled({ - historyMap: groupHistories, - historyKey: decision.historyKey, - limit: historyLimit, - }); - } - } - - const handleMessage = async (raw: unknown) => { - const message = parseIMessageNotification(raw); - if (!message) { - logVerbose("imessage: dropping malformed RPC message payload"); - return; - } - await inboundDebouncer.enqueue({ message }); - }; - - await waitForTransportReady({ - label: "imsg rpc", - timeoutMs: 30_000, - logAfterMs: 10_000, - logIntervalMs: 10_000, - pollIntervalMs: 500, - abortSignal: opts.abortSignal, - runtime, - check: async () => { - const probe = await probeIMessage(probeTimeoutMs, { cliPath, dbPath, runtime }); - if (probe.ok) { - return { ok: true }; - } - if (probe.fatal) { - throw new Error(probe.error ?? "imsg rpc unavailable"); - } - return { ok: false, error: probe.error ?? "unreachable" }; - }, - }); - - if (opts.abortSignal?.aborted) { - return; - } - - const client = await createIMessageRpcClient({ - cliPath, - 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 detachAbortHandler = attachIMessageMonitorAbortHandler({ - abortSignal: abort, - client, - getSubscriptionId: () => subscriptionId, - }); - - 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 { - detachAbortHandler(); - await client.stop(); - } -} - -export const __testing = { - resolveIMessageRuntimeGroupPolicy: resolveOpenProviderRuntimeGroupPolicy, - resolveDefaultGroupPolicy, -}; +// Shim: re-exports from extensions/imessage/src/monitor/monitor-provider +export * from "../../../extensions/imessage/src/monitor/monitor-provider.js"; diff --git a/src/imessage/monitor/parse-notification.ts b/src/imessage/monitor/parse-notification.ts index 98ad941665c..154e144f71d 100644 --- a/src/imessage/monitor/parse-notification.ts +++ b/src/imessage/monitor/parse-notification.ts @@ -1,83 +1,2 @@ -import type { IMessagePayload } from "./types.js"; - -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - -function isOptionalString(value: unknown): value is string | null | undefined { - return value === undefined || value === null || typeof value === "string"; -} - -function isOptionalStringOrNumber(value: unknown): value is string | number | null | undefined { - return ( - value === undefined || value === null || typeof value === "string" || typeof value === "number" - ); -} - -function isOptionalNumber(value: unknown): value is number | null | undefined { - return value === undefined || value === null || typeof value === "number"; -} - -function isOptionalBoolean(value: unknown): value is boolean | null | undefined { - return value === undefined || value === null || typeof value === "boolean"; -} - -function isOptionalStringArray(value: unknown): value is string[] | null | undefined { - return ( - value === undefined || - value === null || - (Array.isArray(value) && value.every((entry) => typeof entry === "string")) - ); -} - -function isOptionalAttachments(value: unknown): value is IMessagePayload["attachments"] { - if (value === undefined || value === null) { - return true; - } - if (!Array.isArray(value)) { - return false; - } - return value.every((attachment) => { - if (!isRecord(attachment)) { - return false; - } - return ( - isOptionalString(attachment.original_path) && - isOptionalString(attachment.mime_type) && - isOptionalBoolean(attachment.missing) - ); - }); -} - -export function parseIMessageNotification(raw: unknown): IMessagePayload | null { - if (!isRecord(raw)) { - return null; - } - const maybeMessage = raw.message; - if (!isRecord(maybeMessage)) { - return null; - } - - const message: IMessagePayload = maybeMessage; - if ( - !isOptionalNumber(message.id) || - !isOptionalNumber(message.chat_id) || - !isOptionalString(message.sender) || - !isOptionalBoolean(message.is_from_me) || - !isOptionalString(message.text) || - !isOptionalStringOrNumber(message.reply_to_id) || - !isOptionalString(message.reply_to_text) || - !isOptionalString(message.reply_to_sender) || - !isOptionalString(message.created_at) || - !isOptionalAttachments(message.attachments) || - !isOptionalString(message.chat_identifier) || - !isOptionalString(message.chat_guid) || - !isOptionalString(message.chat_name) || - !isOptionalStringArray(message.participants) || - !isOptionalBoolean(message.is_group) - ) { - return null; - } - - return message; -} +// Shim: re-exports from extensions/imessage/src/monitor/parse-notification +export * from "../../../extensions/imessage/src/monitor/parse-notification.js"; diff --git a/src/imessage/monitor/reflection-guard.ts b/src/imessage/monitor/reflection-guard.ts index 97a329315e8..d0a9b7cfdad 100644 --- a/src/imessage/monitor/reflection-guard.ts +++ b/src/imessage/monitor/reflection-guard.ts @@ -1,64 +1,2 @@ -/** - * Detects inbound messages that are reflections of assistant-originated content. - * These patterns indicate internal metadata leaked into a channel and then - * bounced back as a new inbound message — creating an echo loop. - */ - -import { findCodeRegions, isInsideCode } from "../../shared/text/code-regions.js"; - -const INTERNAL_SEPARATOR_RE = /(?:#\+){2,}#?/; -const ASSISTANT_ROLE_MARKER_RE = /\bassistant\s+to\s*=\s*\w+/i; -// Require closing `>` to avoid false-positives on phrases like "". -const THINKING_TAG_RE = /<\s*\/?\s*(?:think(?:ing)?|thought|antthinking)\b[^<>]*>/i; -const RELEVANT_MEMORIES_TAG_RE = /<\s*\/?\s*relevant[-_]memories\b[^<>]*>/i; -// Require closing `>` to avoid false-positives on phrases like "". -const FINAL_TAG_RE = /<\s*\/?\s*final\b[^<>]*>/i; - -const REFLECTION_PATTERNS: Array<{ re: RegExp; label: string }> = [ - { re: INTERNAL_SEPARATOR_RE, label: "internal-separator" }, - { re: ASSISTANT_ROLE_MARKER_RE, label: "assistant-role-marker" }, - { re: THINKING_TAG_RE, label: "thinking-tag" }, - { re: RELEVANT_MEMORIES_TAG_RE, label: "relevant-memories-tag" }, - { re: FINAL_TAG_RE, label: "final-tag" }, -]; - -export type ReflectionDetection = { - isReflection: boolean; - matchedLabels: string[]; -}; - -function hasMatchOutsideCode(text: string, re: RegExp): boolean { - const codeRegions = findCodeRegions(text); - const globalRe = new RegExp(re.source, re.flags.includes("g") ? re.flags : `${re.flags}g`); - - for (const match of text.matchAll(globalRe)) { - const start = match.index ?? -1; - if (start >= 0 && !isInsideCode(start, codeRegions)) { - return true; - } - } - - return false; -} - -/** - * Check whether an inbound message appears to be a reflection of - * assistant-originated content. Returns matched pattern labels for telemetry. - */ -export function detectReflectedContent(text: string): ReflectionDetection { - if (!text) { - return { isReflection: false, matchedLabels: [] }; - } - - const matchedLabels: string[] = []; - for (const { re, label } of REFLECTION_PATTERNS) { - if (hasMatchOutsideCode(text, re)) { - matchedLabels.push(label); - } - } - - return { - isReflection: matchedLabels.length > 0, - matchedLabels, - }; -} +// Shim: re-exports from extensions/imessage/src/monitor/reflection-guard +export * from "../../../extensions/imessage/src/monitor/reflection-guard.js"; diff --git a/src/imessage/monitor/runtime.ts b/src/imessage/monitor/runtime.ts index 72066272d6c..ab06a2bc8a2 100644 --- a/src/imessage/monitor/runtime.ts +++ b/src/imessage/monitor/runtime.ts @@ -1,11 +1,2 @@ -import { createNonExitingRuntime, type RuntimeEnv } from "../../runtime.js"; -import { normalizeStringEntries } from "../../shared/string-normalization.js"; -import type { MonitorIMessageOpts } from "./types.js"; - -export function resolveRuntime(opts: MonitorIMessageOpts): RuntimeEnv { - return opts.runtime ?? createNonExitingRuntime(); -} - -export function normalizeAllowList(list?: Array) { - return normalizeStringEntries(list); -} +// Shim: re-exports from extensions/imessage/src/monitor/runtime +export * from "../../../extensions/imessage/src/monitor/runtime.js"; diff --git a/src/imessage/monitor/sanitize-outbound.ts b/src/imessage/monitor/sanitize-outbound.ts index 9fe1664e1eb..e3ffc556be1 100644 --- a/src/imessage/monitor/sanitize-outbound.ts +++ b/src/imessage/monitor/sanitize-outbound.ts @@ -1,31 +1,2 @@ -import { stripAssistantInternalScaffolding } from "../../shared/text/assistant-visible-text.js"; - -/** - * Patterns that indicate assistant-internal metadata leaked into text. - * These must never reach a user-facing channel. - */ -const INTERNAL_SEPARATOR_RE = /(?:#\+){2,}#?/g; -const ASSISTANT_ROLE_MARKER_RE = /\bassistant\s+to\s*=\s*\w+/gi; -const ROLE_TURN_MARKER_RE = /\b(?:user|system|assistant)\s*:\s*$/gm; - -/** - * Strip all assistant-internal scaffolding from outbound text before delivery. - * Applies reasoning/thinking tag removal, memory tag removal, and - * model-specific internal separator stripping. - */ -export function sanitizeOutboundText(text: string): string { - if (!text) { - return text; - } - - let cleaned = stripAssistantInternalScaffolding(text); - - cleaned = cleaned.replace(INTERNAL_SEPARATOR_RE, ""); - cleaned = cleaned.replace(ASSISTANT_ROLE_MARKER_RE, ""); - cleaned = cleaned.replace(ROLE_TURN_MARKER_RE, ""); - - // Collapse excessive blank lines left after stripping. - cleaned = cleaned.replace(/\n{3,}/g, "\n\n").trim(); - - return cleaned; -} +// Shim: re-exports from extensions/imessage/src/monitor/sanitize-outbound +export * from "../../../extensions/imessage/src/monitor/sanitize-outbound.js"; diff --git a/src/imessage/monitor/self-chat-cache.ts b/src/imessage/monitor/self-chat-cache.ts index a2c4c31ccd9..d58989db85f 100644 --- a/src/imessage/monitor/self-chat-cache.ts +++ b/src/imessage/monitor/self-chat-cache.ts @@ -1,103 +1,2 @@ -import { createHash } from "node:crypto"; -import { formatIMessageChatTarget } from "../targets.js"; - -type SelfChatCacheKeyParts = { - accountId: string; - sender: string; - isGroup: boolean; - chatId?: number; -}; - -export type SelfChatLookup = SelfChatCacheKeyParts & { - text?: string; - createdAt?: number; -}; - -export type SelfChatCache = { - remember: (lookup: SelfChatLookup) => void; - has: (lookup: SelfChatLookup) => boolean; -}; - -const SELF_CHAT_TTL_MS = 10_000; -const MAX_SELF_CHAT_CACHE_ENTRIES = 512; -const CLEANUP_MIN_INTERVAL_MS = 1_000; - -function normalizeText(text: string | undefined): string | null { - if (!text) { - return null; - } - const normalized = text.replace(/\r\n?/g, "\n").trim(); - return normalized ? normalized : null; -} - -function isUsableTimestamp(createdAt: number | undefined): createdAt is number { - return typeof createdAt === "number" && Number.isFinite(createdAt); -} - -function digestText(text: string): string { - return createHash("sha256").update(text).digest("hex"); -} - -function buildScope(parts: SelfChatCacheKeyParts): string { - if (!parts.isGroup) { - return `${parts.accountId}:imessage:${parts.sender}`; - } - const chatTarget = formatIMessageChatTarget(parts.chatId) || "chat_id:unknown"; - return `${parts.accountId}:${chatTarget}:imessage:${parts.sender}`; -} - -class DefaultSelfChatCache implements SelfChatCache { - private cache = new Map(); - private lastCleanupAt = 0; - - private buildKey(lookup: SelfChatLookup): string | null { - const text = normalizeText(lookup.text); - if (!text || !isUsableTimestamp(lookup.createdAt)) { - return null; - } - return `${buildScope(lookup)}:${lookup.createdAt}:${digestText(text)}`; - } - - remember(lookup: SelfChatLookup): void { - const key = this.buildKey(lookup); - if (!key) { - return; - } - this.cache.set(key, Date.now()); - this.maybeCleanup(); - } - - has(lookup: SelfChatLookup): boolean { - this.maybeCleanup(); - const key = this.buildKey(lookup); - if (!key) { - return false; - } - const timestamp = this.cache.get(key); - return typeof timestamp === "number" && Date.now() - timestamp <= SELF_CHAT_TTL_MS; - } - - private maybeCleanup(): void { - const now = Date.now(); - if (now - this.lastCleanupAt < CLEANUP_MIN_INTERVAL_MS) { - return; - } - this.lastCleanupAt = now; - for (const [key, timestamp] of this.cache.entries()) { - if (now - timestamp > SELF_CHAT_TTL_MS) { - this.cache.delete(key); - } - } - while (this.cache.size > MAX_SELF_CHAT_CACHE_ENTRIES) { - const oldestKey = this.cache.keys().next().value; - if (typeof oldestKey !== "string") { - break; - } - this.cache.delete(oldestKey); - } - } -} - -export function createSelfChatCache(): SelfChatCache { - return new DefaultSelfChatCache(); -} +// Shim: re-exports from extensions/imessage/src/monitor/self-chat-cache +export * from "../../../extensions/imessage/src/monitor/self-chat-cache.js"; diff --git a/src/imessage/monitor/types.ts b/src/imessage/monitor/types.ts index 2f13b3ecfb9..e27461d9531 100644 --- a/src/imessage/monitor/types.ts +++ b/src/imessage/monitor/types.ts @@ -1,40 +1,2 @@ -import type { OpenClawConfig } from "../../config/config.js"; -import type { RuntimeEnv } from "../../runtime.js"; - -export type IMessageAttachment = { - original_path?: string | null; - mime_type?: string | null; - missing?: boolean | null; -}; - -export type IMessagePayload = { - id?: number | null; - chat_id?: number | null; - sender?: string | null; - is_from_me?: boolean | null; - text?: string | null; - reply_to_id?: number | string | null; - reply_to_text?: string | null; - reply_to_sender?: 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; - accountId?: string; - config?: OpenClawConfig; - allowFrom?: Array; - groupAllowFrom?: Array; - includeAttachments?: boolean; - mediaMaxMb?: number; - requireMention?: boolean; -}; +// Shim: re-exports from extensions/imessage/src/monitor/types +export * from "../../../extensions/imessage/src/monitor/types.js"; diff --git a/src/imessage/probe.ts b/src/imessage/probe.ts index 9c33a471ab0..e93de22a785 100644 --- a/src/imessage/probe.ts +++ b/src/imessage/probe.ts @@ -1,105 +1,2 @@ -import type { BaseProbeResult } from "../channels/plugins/types.js"; -import { detectBinary } from "../commands/onboard-helpers.js"; -import { loadConfig } from "../config/config.js"; -import { runCommandWithTimeout } from "../process/exec.js"; -import type { RuntimeEnv } from "../runtime.js"; -import { createIMessageRpcClient } from "./client.js"; -import { DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS } from "./constants.js"; - -// Re-export for backwards compatibility -export { DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS } from "./constants.js"; - -export type IMessageProbe = BaseProbeResult & { - fatal?: boolean; -}; - -export type IMessageProbeOptions = { - cliPath?: string; - dbPath?: string; - runtime?: RuntimeEnv; -}; - -type RpcSupportResult = { - supported: boolean; - error?: string; - fatal?: boolean; -}; - -const rpcSupportCache = new Map(); - -async function probeRpcSupport(cliPath: string, timeoutMs: number): Promise { - const cached = rpcSupportCache.get(cliPath); - if (cached) { - return cached; - } - try { - const result = await runCommandWithTimeout([cliPath, "rpc", "--help"], { timeoutMs }); - const combined = `${result.stdout}\n${result.stderr}`.trim(); - const normalized = combined.toLowerCase(); - if (normalized.includes("unknown command") && normalized.includes("rpc")) { - const fatal = { - supported: false, - fatal: true, - error: 'imsg CLI does not support the "rpc" subcommand (update imsg)', - }; - rpcSupportCache.set(cliPath, fatal); - return fatal; - } - if (result.code === 0) { - const supported = { supported: true }; - rpcSupportCache.set(cliPath, supported); - return supported; - } - return { - supported: false, - error: combined || `imsg rpc --help failed (code ${String(result.code ?? "unknown")})`, - }; - } catch (err) { - return { supported: false, error: String(err) }; - } -} - -/** - * Probe iMessage RPC availability. - * @param timeoutMs - Explicit timeout in ms. If undefined, uses config or default. - * @param opts - Additional options (cliPath, dbPath, runtime). - */ -export async function probeIMessage( - timeoutMs?: number, - opts: IMessageProbeOptions = {}, -): Promise { - const cfg = opts.cliPath || opts.dbPath ? undefined : loadConfig(); - const cliPath = opts.cliPath?.trim() || cfg?.channels?.imessage?.cliPath?.trim() || "imsg"; - const dbPath = opts.dbPath?.trim() || cfg?.channels?.imessage?.dbPath?.trim(); - // Use explicit timeout if provided, otherwise fall back to config, then default - const effectiveTimeout = - timeoutMs ?? cfg?.channels?.imessage?.probeTimeoutMs ?? DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS; - - const detected = await detectBinary(cliPath); - if (!detected) { - return { ok: false, error: `imsg not found (${cliPath})` }; - } - - const rpcSupport = await probeRpcSupport(cliPath, effectiveTimeout); - if (!rpcSupport.supported) { - return { - ok: false, - error: rpcSupport.error ?? "imsg rpc unavailable", - fatal: rpcSupport.fatal, - }; - } - - const client = await createIMessageRpcClient({ - cliPath, - dbPath, - runtime: opts.runtime, - }); - try { - await client.request("chats.list", { limit: 1 }, { timeoutMs: effectiveTimeout }); - return { ok: true }; - } catch (err) { - return { ok: false, error: String(err) }; - } finally { - await client.stop(); - } -} +// Shim: re-exports from extensions/imessage/src/probe +export * from "../../extensions/imessage/src/probe.js"; diff --git a/src/imessage/send.ts b/src/imessage/send.ts index efa3fca3366..2830bac534d 100644 --- a/src/imessage/send.ts +++ b/src/imessage/send.ts @@ -1,190 +1,2 @@ -import { loadConfig } from "../config/config.js"; -import { resolveMarkdownTableMode } from "../config/markdown-tables.js"; -import { convertMarkdownTables } from "../markdown/tables.js"; -import { kindFromMime } from "../media/mime.js"; -import { resolveOutboundAttachmentFromUrl } from "../media/outbound-attachment.js"; -import { resolveIMessageAccount, type ResolvedIMessageAccount } from "./accounts.js"; -import { createIMessageRpcClient, type IMessageRpcClient } from "./client.js"; -import { formatIMessageChatTarget, type IMessageService, parseIMessageTarget } from "./targets.js"; - -export type IMessageSendOpts = { - cliPath?: string; - dbPath?: string; - service?: IMessageService; - region?: string; - accountId?: string; - replyToId?: string; - mediaUrl?: string; - mediaLocalRoots?: readonly string[]; - maxBytes?: number; - timeoutMs?: number; - chatId?: number; - client?: IMessageRpcClient; - config?: ReturnType; - account?: ResolvedIMessageAccount; - resolveAttachmentImpl?: ( - mediaUrl: string, - maxBytes: number, - options?: { localRoots?: readonly string[] }, - ) => Promise<{ path: string; contentType?: string }>; - createClient?: (params: { cliPath: string; dbPath?: string }) => Promise; -}; - -export type IMessageSendResult = { - messageId: string; -}; - -const LEADING_REPLY_TAG_RE = /^\s*\[\[\s*reply_to\s*:\s*([^\]\n]+)\s*\]\]\s*/i; -const MAX_REPLY_TO_ID_LENGTH = 256; - -function stripUnsafeReplyTagChars(value: string): string { - let next = ""; - for (const ch of value) { - const code = ch.charCodeAt(0); - if ((code >= 0 && code <= 31) || code === 127 || ch === "[" || ch === "]") { - continue; - } - next += ch; - } - return next; -} - -function sanitizeReplyToId(rawReplyToId?: string): string | undefined { - const trimmed = rawReplyToId?.trim(); - if (!trimmed) { - return undefined; - } - const sanitized = stripUnsafeReplyTagChars(trimmed).trim(); - if (!sanitized) { - return undefined; - } - if (sanitized.length > MAX_REPLY_TO_ID_LENGTH) { - return sanitized.slice(0, MAX_REPLY_TO_ID_LENGTH); - } - return sanitized; -} - -function prependReplyTagIfNeeded(message: string, replyToId?: string): string { - const resolvedReplyToId = sanitizeReplyToId(replyToId); - if (!resolvedReplyToId) { - return message; - } - const replyTag = `[[reply_to:${resolvedReplyToId}]]`; - const existingLeadingTag = message.match(LEADING_REPLY_TAG_RE); - if (existingLeadingTag) { - const remainder = message.slice(existingLeadingTag[0].length).trimStart(); - return remainder ? `${replyTag} ${remainder}` : replyTag; - } - const trimmedMessage = message.trimStart(); - return trimmedMessage ? `${replyTag} ${trimmedMessage}` : replyTag; -} - -function resolveMessageId(result: Record | null | undefined): string | null { - if (!result) { - return null; - } - const raw = - (typeof result.messageId === "string" && result.messageId.trim()) || - (typeof result.message_id === "string" && result.message_id.trim()) || - (typeof result.id === "string" && result.id.trim()) || - (typeof result.guid === "string" && result.guid.trim()) || - (typeof result.message_id === "number" ? String(result.message_id) : null) || - (typeof result.id === "number" ? String(result.id) : null); - return raw ? String(raw).trim() : null; -} - -export async function sendMessageIMessage( - to: string, - text: string, - opts: IMessageSendOpts = {}, -): Promise { - const cfg = opts.config ?? loadConfig(); - const account = - opts.account ?? - resolveIMessageAccount({ - cfg, - accountId: opts.accountId, - }); - const cliPath = opts.cliPath?.trim() || account.config.cliPath?.trim() || "imsg"; - const dbPath = opts.dbPath?.trim() || account.config.dbPath?.trim(); - const target = parseIMessageTarget(opts.chatId ? formatIMessageChatTarget(opts.chatId) : to); - const service = - opts.service ?? - (target.kind === "handle" ? target.service : undefined) ?? - (account.config.service as IMessageService | undefined); - const region = opts.region?.trim() || account.config.region?.trim() || "US"; - const maxBytes = - typeof opts.maxBytes === "number" - ? opts.maxBytes - : typeof account.config.mediaMaxMb === "number" - ? account.config.mediaMaxMb * 1024 * 1024 - : 16 * 1024 * 1024; - let message = text ?? ""; - let filePath: string | undefined; - - if (opts.mediaUrl?.trim()) { - const resolveAttachmentFn = opts.resolveAttachmentImpl ?? resolveOutboundAttachmentFromUrl; - const resolved = await resolveAttachmentFn(opts.mediaUrl.trim(), maxBytes, { - localRoots: opts.mediaLocalRoots, - }); - filePath = resolved.path; - if (!message.trim()) { - const kind = kindFromMime(resolved.contentType ?? undefined); - if (kind) { - message = kind === "image" ? "" : ``; - } - } - } - - if (!message.trim() && !filePath) { - throw new Error("iMessage send requires text or media"); - } - if (message.trim()) { - const tableMode = resolveMarkdownTableMode({ - cfg, - channel: "imessage", - accountId: account.accountId, - }); - message = convertMarkdownTables(message, tableMode); - } - message = prependReplyTagIfNeeded(message, opts.replyToId); - - const params: Record = { - text: message, - service: service || "auto", - 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 ?? - (opts.createClient - ? await opts.createClient({ cliPath, dbPath }) - : await createIMessageRpcClient({ cliPath, dbPath })); - const shouldClose = !opts.client; - try { - const result = await client.request<{ ok?: string }>("send", params, { - timeoutMs: opts.timeoutMs, - }); - const resolvedId = resolveMessageId(result); - return { - messageId: resolvedId ?? (result?.ok ? "ok" : "unknown"), - }; - } finally { - if (shouldClose) { - await client.stop(); - } - } -} +// Shim: re-exports from extensions/imessage/src/send +export * from "../../extensions/imessage/src/send.js"; diff --git a/src/imessage/target-parsing-helpers.ts b/src/imessage/target-parsing-helpers.ts index ba00590e6d5..7aa3410caa6 100644 --- a/src/imessage/target-parsing-helpers.ts +++ b/src/imessage/target-parsing-helpers.ts @@ -1,223 +1,2 @@ -import { isAllowedParsedChatSender } from "../plugin-sdk/allow-from.js"; - -export type ServicePrefix = { prefix: string; service: TService }; - -export type ChatTargetPrefixesParams = { - trimmed: string; - lower: string; - chatIdPrefixes: string[]; - chatGuidPrefixes: string[]; - chatIdentifierPrefixes: string[]; -}; - -export type ParsedChatTarget = - | { kind: "chat_id"; chatId: number } - | { kind: "chat_guid"; chatGuid: string } - | { kind: "chat_identifier"; chatIdentifier: string }; - -export type ParsedChatAllowTarget = ParsedChatTarget | { kind: "handle"; handle: string }; - -export type ChatSenderAllowParams = { - allowFrom: Array; - sender: string; - chatId?: number | null; - chatGuid?: string | null; - chatIdentifier?: string | null; -}; - -function stripPrefix(value: string, prefix: string): string { - return value.slice(prefix.length).trim(); -} - -function startsWithAnyPrefix(value: string, prefixes: readonly string[]): boolean { - return prefixes.some((prefix) => value.startsWith(prefix)); -} - -export function resolveServicePrefixedTarget(params: { - trimmed: string; - lower: string; - servicePrefixes: Array>; - isChatTarget: (remainderLower: string) => boolean; - parseTarget: (remainder: string) => TTarget; -}): ({ kind: "handle"; to: string; service: TService } | TTarget) | null { - for (const { prefix, service } of params.servicePrefixes) { - if (!params.lower.startsWith(prefix)) { - continue; - } - const remainder = stripPrefix(params.trimmed, prefix); - if (!remainder) { - throw new Error(`${prefix} target is required`); - } - const remainderLower = remainder.toLowerCase(); - if (params.isChatTarget(remainderLower)) { - return params.parseTarget(remainder); - } - return { kind: "handle", to: remainder, service }; - } - return null; -} - -export function resolveServicePrefixedChatTarget(params: { - trimmed: string; - lower: string; - servicePrefixes: Array>; - chatIdPrefixes: string[]; - chatGuidPrefixes: string[]; - chatIdentifierPrefixes: string[]; - extraChatPrefixes?: string[]; - parseTarget: (remainder: string) => TTarget; -}): ({ kind: "handle"; to: string; service: TService } | TTarget) | null { - const chatPrefixes = [ - ...params.chatIdPrefixes, - ...params.chatGuidPrefixes, - ...params.chatIdentifierPrefixes, - ...(params.extraChatPrefixes ?? []), - ]; - return resolveServicePrefixedTarget({ - trimmed: params.trimmed, - lower: params.lower, - servicePrefixes: params.servicePrefixes, - isChatTarget: (remainderLower) => startsWithAnyPrefix(remainderLower, chatPrefixes), - parseTarget: params.parseTarget, - }); -} - -export function parseChatTargetPrefixesOrThrow( - params: ChatTargetPrefixesParams, -): ParsedChatTarget | null { - for (const prefix of params.chatIdPrefixes) { - if (params.lower.startsWith(prefix)) { - const value = stripPrefix(params.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 params.chatGuidPrefixes) { - if (params.lower.startsWith(prefix)) { - const value = stripPrefix(params.trimmed, prefix); - if (!value) { - throw new Error("chat_guid is required"); - } - return { kind: "chat_guid", chatGuid: value }; - } - } - - for (const prefix of params.chatIdentifierPrefixes) { - if (params.lower.startsWith(prefix)) { - const value = stripPrefix(params.trimmed, prefix); - if (!value) { - throw new Error("chat_identifier is required"); - } - return { kind: "chat_identifier", chatIdentifier: value }; - } - } - - return null; -} - -export function resolveServicePrefixedAllowTarget(params: { - trimmed: string; - lower: string; - servicePrefixes: Array<{ prefix: string }>; - parseAllowTarget: (remainder: string) => TAllowTarget; -}): (TAllowTarget | { kind: "handle"; handle: string }) | null { - for (const { prefix } of params.servicePrefixes) { - if (!params.lower.startsWith(prefix)) { - continue; - } - const remainder = stripPrefix(params.trimmed, prefix); - if (!remainder) { - return { kind: "handle", handle: "" }; - } - return params.parseAllowTarget(remainder); - } - return null; -} - -export function resolveServicePrefixedOrChatAllowTarget< - TAllowTarget extends ParsedChatAllowTarget, ->(params: { - trimmed: string; - lower: string; - servicePrefixes: Array<{ prefix: string }>; - parseAllowTarget: (remainder: string) => TAllowTarget; - chatIdPrefixes: string[]; - chatGuidPrefixes: string[]; - chatIdentifierPrefixes: string[]; -}): TAllowTarget | null { - const servicePrefixed = resolveServicePrefixedAllowTarget({ - trimmed: params.trimmed, - lower: params.lower, - servicePrefixes: params.servicePrefixes, - parseAllowTarget: params.parseAllowTarget, - }); - if (servicePrefixed) { - return servicePrefixed as TAllowTarget; - } - - const chatTarget = parseChatAllowTargetPrefixes({ - trimmed: params.trimmed, - lower: params.lower, - chatIdPrefixes: params.chatIdPrefixes, - chatGuidPrefixes: params.chatGuidPrefixes, - chatIdentifierPrefixes: params.chatIdentifierPrefixes, - }); - if (chatTarget) { - return chatTarget as TAllowTarget; - } - return null; -} - -export function createAllowedChatSenderMatcher(params: { - normalizeSender: (sender: string) => string; - parseAllowTarget: (entry: string) => TParsed; -}): (input: ChatSenderAllowParams) => boolean { - return (input) => - isAllowedParsedChatSender({ - allowFrom: input.allowFrom, - sender: input.sender, - chatId: input.chatId, - chatGuid: input.chatGuid, - chatIdentifier: input.chatIdentifier, - normalizeSender: params.normalizeSender, - parseAllowTarget: params.parseAllowTarget, - }); -} - -export function parseChatAllowTargetPrefixes( - params: ChatTargetPrefixesParams, -): ParsedChatTarget | null { - for (const prefix of params.chatIdPrefixes) { - if (params.lower.startsWith(prefix)) { - const value = stripPrefix(params.trimmed, prefix); - const chatId = Number.parseInt(value, 10); - if (Number.isFinite(chatId)) { - return { kind: "chat_id", chatId }; - } - } - } - - for (const prefix of params.chatGuidPrefixes) { - if (params.lower.startsWith(prefix)) { - const value = stripPrefix(params.trimmed, prefix); - if (value) { - return { kind: "chat_guid", chatGuid: value }; - } - } - } - - for (const prefix of params.chatIdentifierPrefixes) { - if (params.lower.startsWith(prefix)) { - const value = stripPrefix(params.trimmed, prefix); - if (value) { - return { kind: "chat_identifier", chatIdentifier: value }; - } - } - } - - return null; -} +// Shim: re-exports from extensions/imessage/src/target-parsing-helpers +export * from "../../extensions/imessage/src/target-parsing-helpers.js"; diff --git a/src/imessage/targets.ts b/src/imessage/targets.ts index e709f1064e4..9ef87a31933 100644 --- a/src/imessage/targets.ts +++ b/src/imessage/targets.ts @@ -1,147 +1,2 @@ -import { normalizeE164 } from "../utils.js"; -import { - createAllowedChatSenderMatcher, - type ChatSenderAllowParams, - type ParsedChatTarget, - parseChatTargetPrefixesOrThrow, - resolveServicePrefixedChatTarget, - resolveServicePrefixedOrChatAllowTarget, -} from "./target-parsing-helpers.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 = ParsedChatTarget | { 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" }, -]; - -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)); - } - - // Normalize chat_id/chat_guid/chat_identifier prefixes case-insensitively - for (const prefix of CHAT_ID_PREFIXES) { - if (lowered.startsWith(prefix)) { - const value = trimmed.slice(prefix.length).trim(); - return `chat_id:${value}`; - } - } - for (const prefix of CHAT_GUID_PREFIXES) { - if (lowered.startsWith(prefix)) { - const value = trimmed.slice(prefix.length).trim(); - return `chat_guid:${value}`; - } - } - for (const prefix of CHAT_IDENTIFIER_PREFIXES) { - if (lowered.startsWith(prefix)) { - const value = trimmed.slice(prefix.length).trim(); - return `chat_identifier:${value}`; - } - } - - 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(); - - const servicePrefixed = resolveServicePrefixedChatTarget({ - trimmed, - lower, - servicePrefixes: SERVICE_PREFIXES, - chatIdPrefixes: CHAT_ID_PREFIXES, - chatGuidPrefixes: CHAT_GUID_PREFIXES, - chatIdentifierPrefixes: CHAT_IDENTIFIER_PREFIXES, - parseTarget: parseIMessageTarget, - }); - if (servicePrefixed) { - return servicePrefixed; - } - - const chatTarget = parseChatTargetPrefixesOrThrow({ - trimmed, - lower, - chatIdPrefixes: CHAT_ID_PREFIXES, - chatGuidPrefixes: CHAT_GUID_PREFIXES, - chatIdentifierPrefixes: CHAT_IDENTIFIER_PREFIXES, - }); - if (chatTarget) { - return chatTarget; - } - - 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(); - - const servicePrefixed = resolveServicePrefixedOrChatAllowTarget({ - trimmed, - lower, - servicePrefixes: SERVICE_PREFIXES, - parseAllowTarget: parseIMessageAllowTarget, - chatIdPrefixes: CHAT_ID_PREFIXES, - chatGuidPrefixes: CHAT_GUID_PREFIXES, - chatIdentifierPrefixes: CHAT_IDENTIFIER_PREFIXES, - }); - if (servicePrefixed) { - return servicePrefixed; - } - - return { kind: "handle", handle: normalizeIMessageHandle(trimmed) }; -} - -const isAllowedIMessageSenderMatcher = createAllowedChatSenderMatcher({ - normalizeSender: normalizeIMessageHandle, - parseAllowTarget: parseIMessageAllowTarget, -}); - -export function isAllowedIMessageSender(params: ChatSenderAllowParams): boolean { - return isAllowedIMessageSenderMatcher(params); -} - -export function formatIMessageChatTarget(chatId?: number | null): string { - if (!chatId || !Number.isFinite(chatId)) { - return ""; - } - return `chat_id:${chatId}`; -} +// Shim: re-exports from extensions/imessage/src/targets +export * from "../../extensions/imessage/src/targets.js";