Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled

Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

View File

@@ -0,0 +1,60 @@
// Imessage API module exposes the plugin public contract.
export { imessagePlugin } from "./src/channel.js";
export { imessageSetupPlugin } from "./src/channel.setup.js";
export {
listEnabledIMessageAccounts,
listIMessageAccountIds,
resolveDefaultIMessageAccountId,
type ResolvedIMessageAccount,
resolveIMessageAccount,
} from "./src/accounts.js";
export {
testing,
testing as __testing,
createIMessageConversationBindingManager,
} from "./src/conversation-bindings.js";
export {
matchIMessageAcpConversation,
normalizeIMessageAcpConversationId,
resolveIMessageConversationIdFromTarget,
resolveIMessageInboundConversationId,
} from "./src/conversation-id.js";
export {
resolveIMessageGroupRequireMention,
resolveIMessageGroupToolPolicy,
} from "./src/group-policy.js";
export { looksLikeIMessageTargetId, normalizeIMessageMessagingTarget } from "./src/normalize.js";
export { IMESSAGE_LEGACY_OUTBOUND_SEND_DEP_KEYS } from "./src/outbound-send-deps.js";
export {
DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS,
type IMessageProbe,
type IMessageProbeOptions,
probeIMessage,
} from "./src/probe.js";
export {
type ChatSenderAllowParams,
type ChatTargetPrefixesParams,
createAllowedChatSenderMatcher,
parseChatAllowTargetPrefixes,
parseChatTargetPrefixesOrThrow,
type ParsedChatAllowTarget,
type ParsedChatTarget,
resolveServicePrefixedAllowTarget,
resolveServicePrefixedChatTarget,
resolveServicePrefixedOrChatAllowTarget,
resolveServicePrefixedTarget,
type ServicePrefix,
} from "./src/target-parsing-helpers.js";
export {
formatIMessageChatTarget,
type IMessageAllowTarget,
type IMessageService,
type IMessageTarget,
inferIMessageTargetChatType,
isAllowedIMessageSender,
looksLikeIMessageExplicitTargetId,
normalizeIMessageHandle,
parseIMessageAllowTarget,
parseIMessageTarget,
} from "./src/targets.js";
export { IMESSAGE_ACTION_NAMES, IMESSAGE_ACTIONS } from "./src/actions-contract.js";

View File

@@ -0,0 +1,2 @@
// Imessage API module exposes the plugin public contract.
export { IMessageChannelConfigSchema } from "./src/config-schema.js";

View File

@@ -0,0 +1,2 @@
// Imessage API module exposes the plugin public contract.
export { imessagePlugin } from "./src/channel.js";

View File

@@ -0,0 +1,5 @@
// Imessage API module exposes the plugin public contract.
export {
buildChannelConfigSchema,
IMessageConfigSchema,
} from "openclaw/plugin-sdk/bundled-channel-config-schema";

View File

@@ -0,0 +1,8 @@
// Imessage API module exposes the plugin public contract.
export {
DEFAULT_IMESSAGE_ATTACHMENT_ROOTS,
resolveIMessageAttachmentRoots as resolveInboundAttachmentRoots,
resolveIMessageAttachmentRoots,
resolveIMessageRemoteAttachmentRoots as resolveRemoteInboundAttachmentRoots,
resolveIMessageRemoteAttachmentRoots,
} from "./media-contract-api.js";

View File

@@ -0,0 +1,87 @@
// Imessage API module exposes the plugin public contract.
import type {
ChannelDoctorConfigMutation,
ChannelDoctorLegacyConfigRule,
} from "openclaw/plugin-sdk/channel-contract";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
// Disabled `channels.imessage.catchup` blocks are retired. Enabled blocks stay
// as a compatibility contract: older configs that opted into replay still get
// downtime recovery, while new/default installs use the always-on recovery
// cursor plus stale-backlog fence.
function isEnabledCatchup(value: unknown): boolean {
return isRecord(value) && value.enabled === true;
}
function imessageEntryHasRetiredCatchup(entry: unknown): boolean {
if (!isRecord(entry)) {
return false;
}
if (Object.hasOwn(entry, "catchup") && !isEnabledCatchup(entry.catchup)) {
return true;
}
const accounts = entry.accounts;
if (!isRecord(accounts)) {
return false;
}
return Object.values(accounts).some(
(account) =>
isRecord(account) && Object.hasOwn(account, "catchup") && !isEnabledCatchup(account.catchup),
);
}
export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [
{
path: ["channels", "imessage"],
message:
"disabled channels.imessage.catchup config is retired; iMessage now recovers via always-on inbound dedupe and a stale-backlog age fence. " +
'Run "openclaw doctor --fix" to remove disabled catchup blocks.',
match: (value) => imessageEntryHasRetiredCatchup(value),
},
];
export function normalizeCompatibilityConfig({
cfg,
}: {
cfg: OpenClawConfig;
}): ChannelDoctorConfigMutation {
const channels = cfg.channels as Record<string, unknown> | undefined;
const imessage = channels?.imessage;
if (!imessageEntryHasRetiredCatchup(imessage) || !isRecord(imessage)) {
return { config: cfg, changes: [] };
}
const changes: string[] = [];
const nextImessage: Record<string, unknown> = { ...imessage };
if (Object.hasOwn(nextImessage, "catchup") && !isEnabledCatchup(nextImessage.catchup)) {
delete nextImessage.catchup;
changes.push("Removed disabled retired channels.imessage.catchup.");
}
if (isRecord(nextImessage.accounts)) {
let accountsChanged = false;
const nextAccounts: Record<string, unknown> = { ...nextImessage.accounts };
for (const [id, account] of Object.entries(nextImessage.accounts)) {
if (
isRecord(account) &&
Object.hasOwn(account, "catchup") &&
!isEnabledCatchup(account.catchup)
) {
const nextAccount = { ...account };
delete nextAccount.catchup;
nextAccounts[id] = nextAccount;
accountsChanged = true;
changes.push(`Removed disabled retired channels.imessage.accounts.${id}.catchup.`);
}
}
if (accountsChanged) {
nextImessage.accounts = nextAccounts;
}
}
return {
config: {
...cfg,
channels: { ...channels, imessage: nextImessage },
} as OpenClawConfig,
changes,
};
}

View File

@@ -0,0 +1,17 @@
// Imessage plugin entrypoint registers its OpenClaw integration.
import { defineBundledChannelEntry } from "openclaw/plugin-sdk/channel-entry-contract";
export default defineBundledChannelEntry({
id: "imessage",
name: "iMessage",
description: "iMessage channel plugin",
importMetaUrl: import.meta.url,
plugin: {
specifier: "./channel-plugin-api.js",
exportName: "imessagePlugin",
},
runtime: {
specifier: "./runtime-api.js",
exportName: "setIMessageRuntime",
},
});

View File

@@ -0,0 +1,2 @@
// Imessage API module exposes the plugin public contract.
export { detectIMessageLegacyStateMigrations } from "./src/state-migrations.js";

View File

@@ -0,0 +1,8 @@
// Imessage API module exposes the plugin public contract.
export {
DEFAULT_IMESSAGE_ATTACHMENT_ROOTS,
resolveIMessageAttachmentRoots,
resolveIMessageAttachmentRoots as resolveInboundAttachmentRoots,
resolveIMessageRemoteAttachmentRoots,
resolveIMessageRemoteAttachmentRoots as resolveRemoteInboundAttachmentRoots,
} from "./src/media-contract.js";

View File

@@ -0,0 +1,2 @@
// Imessage API module exposes the plugin public contract.
export { describeIMessageMessageTool as describeMessageTool } from "./src/message-tool-api.js";

View File

@@ -0,0 +1,13 @@
{
"id": "imessage",
"icon": "https://cdn.simpleicons.org/imessage",
"activation": {
"onStartup": false
},
"channels": ["imessage"],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@@ -0,0 +1,80 @@
{
"name": "@openclaw/imessage",
"version": "2026.6.11",
"private": true,
"description": "OpenClaw iMessage channel plugin using imsg on a signed-in Mac",
"type": "module",
"dependencies": {
"typebox": "1.3.3"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"
},
"openclaw": {
"extensions": [
"./index.ts"
],
"setupEntry": "./setup-entry.ts",
"setupFeatures": {
"legacyStateMigrations": true
},
"channel": {
"id": "imessage",
"label": "iMessage",
"selectionLabel": "iMessage (imsg)",
"detailLabel": "iMessage",
"docsPath": "/channels/imessage",
"docsLabel": "imessage",
"blurb": "Local iMessage/SMS through the imsg bridge, including private API message actions when enabled.",
"aliases": [
"imsg"
],
"systemImage": "message.fill",
"cliAddOptions": [
{
"flags": "--db-path <path>",
"description": "iMessage database path"
},
{
"flags": "--service <service>",
"description": "iMessage service (imessage|sms|auto)"
},
{
"flags": "--region <region>",
"description": "iMessage region (for SMS)"
}
]
},
"compat": {
"pluginApi": ">=2026.6.11"
},
"build": {
"openclawVersion": "2026.6.11"
}
},
"pluginInspector": {
"version": 1,
"plugin": {
"id": "imessage",
"priority": "high",
"seams": [
"channel-plugin",
"message-actions",
"conversation-bindings",
"outbound-media"
],
"sourceRoot": "src",
"expect": {
"registrations": [
"createChatChannelPlugin"
],
"manifestContracts": [
"channels"
]
}
},
"capture": {
"mockSdk": true
}
}
}

View File

@@ -0,0 +1,38 @@
// Imessage API module exposes the plugin public contract.
import type { OpenClawConfig as RuntimeApiOpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
export {
DEFAULT_ACCOUNT_ID,
getChatChannelMeta,
type ChannelPlugin,
} from "openclaw/plugin-sdk/core";
export { buildChannelConfigSchema, IMessageConfigSchema } from "./config-api.js";
export { PAIRING_APPROVED_MESSAGE } from "openclaw/plugin-sdk/channel-status";
export {
buildComputedAccountStatusSnapshot,
collectStatusIssuesFromLastError,
} from "openclaw/plugin-sdk/status-helpers";
export { formatTrimmedAllowFromEntries } from "openclaw/plugin-sdk/channel-config-helpers";
export {
resolveIMessageConfigAllowFrom,
resolveIMessageConfigDefaultTo,
} from "./src/config-accessors.js";
export { looksLikeIMessageTargetId, normalizeIMessageMessagingTarget } from "./src/normalize.js";
export { resolveChannelMediaMaxBytes } from "openclaw/plugin-sdk/media-runtime";
export {
resolveIMessageGroupRequireMention,
resolveIMessageGroupToolPolicy,
} from "./src/group-policy.js";
export { monitorIMessageProvider } from "./src/monitor.js";
export type { MonitorIMessageOpts } from "./src/monitor.js";
export { probeIMessage } from "./src/probe.js";
export type { IMessageProbe } from "./src/probe.js";
export { sendMessageIMessage } from "./src/send.js";
export { imessageMessageActions } from "./src/actions.js";
export { setIMessageRuntime } from "./src/runtime.js";
export { chunkTextForOutbound } from "./src/channel-api.js";
export type IMessageAccountConfig = Omit<
NonNullable<NonNullable<RuntimeApiOpenClawConfig["channels"]>["imessage"]>,
"accounts" | "defaultAccount"
>;

View File

@@ -0,0 +1,4 @@
// iMessage does not expose secret-contract surfaces.
export const secretTargetRegistryEntries: readonly [] = [];
export function collectRuntimeConfigAssignments(): void {}

View File

@@ -0,0 +1,5 @@
// Imessage API module exposes the plugin public contract.
export {
testing as imessageConversationBindingTesting,
createIMessageConversationBindingManager,
} from "./src/conversation-bindings.js";

View File

@@ -0,0 +1,17 @@
// Imessage plugin module implements setup entry behavior.
import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entry-contract";
export default defineBundledChannelSetupEntry({
importMetaUrl: import.meta.url,
features: {
legacyStateMigrations: true,
},
plugin: {
specifier: "./api.js",
exportName: "imessageSetupPlugin",
},
legacyStateMigrations: {
specifier: "./legacy-state-migrations-api.js",
exportName: "detectIMessageLegacyStateMigrations",
},
});

View File

@@ -0,0 +1,7 @@
// Imessage plugin module implements account types behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
export type IMessageAccountConfig = Omit<
NonNullable<NonNullable<OpenClawConfig["channels"]>["imessage"]>,
"accounts" | "defaultAccount"
>;

View File

@@ -0,0 +1,184 @@
// Imessage tests cover accounts plugin behavior.
import { describe, expect, it } from "vitest";
import {
collectIMessageDuplicateAccountSourceWarnings,
listEnabledIMessageAccounts,
listIMessageAccountIds,
resolveDefaultIMessageAccountId,
resolveIMessageAccount,
resolveIMessageDuplicateSourceOwner,
} from "./accounts.js";
describe("resolveIMessageAccount", () => {
it("preserves top-level default account when named accounts are configured", () => {
const cfg = {
channels: {
imessage: {
cliPath: "/usr/local/bin/imsg",
accounts: {
work: { enabled: false },
},
},
},
} as never;
expect(listIMessageAccountIds(cfg)).toEqual(["default", "work"]);
expect(resolveDefaultIMessageAccountId(cfg)).toBe("default");
expect(resolveIMessageAccount({ cfg }).config.cliPath).toBe("/usr/local/bin/imsg");
});
it("uses configured defaultAccount when accountId is omitted", () => {
const resolved = resolveIMessageAccount({
cfg: {
channels: {
imessage: {
defaultAccount: "work",
accounts: {
work: {
name: "Work",
cliPath: "/usr/local/bin/imsg-work",
dmPolicy: "open",
},
},
},
},
} as never,
});
expect(resolved.accountId).toBe("work");
expect(resolved.name).toBe("Work");
expect(resolved.config.cliPath).toBe("/usr/local/bin/imsg-work");
expect(resolved.config.dmPolicy).toBe("open");
expect(resolved.configured).toBe(true);
});
it("treats sendTransport as an intentional account config", () => {
const resolved = resolveIMessageAccount({
cfg: {
channels: {
imessage: {
accounts: {
work: {
sendTransport: "bridge",
},
},
},
},
} as never,
accountId: "work",
});
expect(resolved.config.sendTransport).toBe("bridge");
expect(resolved.configured).toBe(true);
});
});
describe("iMessage duplicate-source watcher ownership", () => {
it("flags default as a non-owner when a named account shares its source", () => {
const cfg = {
channels: {
imessage: {
accounts: {
"swang430-gmail-com": {
cliPath: "imsg",
dmPolicy: "pairing",
groupPolicy: "allowlist",
},
default: {
dmPolicy: "pairing",
groupPolicy: "allowlist",
},
},
},
},
} as never;
// Both accounts stay enabled so outbound, status, and capability surfaces
// keep treating them normally; only the watcher startup path consults
// resolveIMessageDuplicateSourceOwner to skip the redundant `imsg rpc`.
const enabled = listEnabledIMessageAccounts(cfg).map((a) => a.accountId);
expect(enabled).toEqual(["default", "swang430-gmail-com"]);
const dupAccount = resolveIMessageAccount({ cfg, accountId: "default" });
expect(resolveIMessageDuplicateSourceOwner({ cfg, account: dupAccount })).toBe(
"swang430-gmail-com",
);
const ownerAccount = resolveIMessageAccount({ cfg, accountId: "swang430-gmail-com" });
expect(resolveIMessageDuplicateSourceOwner({ cfg, account: ownerAccount })).toBeUndefined();
});
it("reports no duplicate ownership when accounts target different cliPaths", () => {
const cfg = {
channels: {
imessage: {
accounts: {
work: { cliPath: "/usr/local/bin/imsg-work" },
home: { cliPath: "/usr/local/bin/imsg-home" },
},
},
},
} as never;
const enabled = listEnabledIMessageAccounts(cfg).map((a) => a.accountId);
expect(enabled).toEqual(["home", "work"]);
for (const accountId of enabled) {
const account = resolveIMessageAccount({ cfg, accountId });
expect(resolveIMessageDuplicateSourceOwner({ cfg, account })).toBeUndefined();
}
});
it("ignores a disabled duplicate when computing ownership", () => {
const cfg = {
channels: {
imessage: {
accounts: {
"swang430-gmail-com": {},
default: { enabled: false },
},
},
},
} as never;
const enabled = listEnabledIMessageAccounts(cfg).map((a) => a.accountId);
expect(enabled).toEqual(["swang430-gmail-com"]);
const ownerAccount = resolveIMessageAccount({ cfg, accountId: "swang430-gmail-com" });
expect(resolveIMessageDuplicateSourceOwner({ cfg, account: ownerAccount })).toBeUndefined();
});
it("emits one preview warning per collision group", () => {
const cfg = {
channels: {
imessage: {
accounts: {
"swang430-gmail-com": {},
default: {},
},
},
},
} as never;
const warnings = collectIMessageDuplicateAccountSourceWarnings({ cfg });
expect(warnings).toHaveLength(1);
expect(warnings[0]).toMatch(/channels\.imessage:/);
expect(warnings[0]).toMatch(/swang430-gmail-com/);
expect(warnings[0]).toMatch(/"default"/);
expect(warnings[0]).toMatch(/cliPath=imsg/);
});
it("emits no warning when only one account is enabled", () => {
const cfg = {
channels: {
imessage: {
accounts: {
"swang430-gmail-com": {},
default: { enabled: false },
},
},
},
} as never;
expect(collectIMessageDuplicateAccountSourceWarnings({ cfg })).toEqual([]);
});
});

View File

@@ -0,0 +1,266 @@
// Imessage plugin module implements accounts behavior.
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
import {
createAccountListHelpers,
normalizeAccountId,
resolveMergedAccountConfig,
type OpenClawConfig,
} from "openclaw/plugin-sdk/account-resolution";
import { resolveAccountEntry } from "openclaw/plugin-sdk/routing";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { IMessageAccountConfig } from "./account-types.js";
export type ResolvedIMessageAccount = {
accountId: string;
enabled: boolean;
name?: string;
config: IMessageAccountConfig;
configured: boolean;
};
const { listAccountIds, resolveDefaultAccountId } = createAccountListHelpers("imessage", {
implicitDefaultAccount: {
channelKeys: ["cliPath", "dbPath"],
},
});
export const listIMessageAccountIds = listAccountIds;
export const resolveDefaultIMessageAccountId = resolveDefaultAccountId;
function resolveIMessageAccountConfig(
cfg: OpenClawConfig,
accountId: string,
): IMessageAccountConfig | undefined {
return resolveAccountEntry(cfg.channels?.imessage?.accounts, accountId);
}
type IMessageStreamingConfig = NonNullable<IMessageAccountConfig["streaming"]>;
function asStreamingConfigObject(value: unknown): IMessageStreamingConfig | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? (value as IMessageStreamingConfig)
: undefined;
}
function asOwnBooleanProperty(value: unknown, key: string): boolean | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
}
const record = value as Record<string, unknown>;
return Object.hasOwn(record, key) && typeof record[key] === "boolean" ? record[key] : undefined;
}
function mergeIMessageStreamingConfig(
base: unknown,
account: unknown,
accountFlatBlockStreaming: unknown,
): IMessageStreamingConfig | undefined {
const baseConfig = asStreamingConfigObject(base);
const accountConfig = asStreamingConfigObject(account);
const accountBlockEnabled = asOwnBooleanProperty(accountConfig?.block, "enabled");
const flatAccountBlockEnabled =
accountBlockEnabled === undefined && typeof accountFlatBlockStreaming === "boolean"
? accountFlatBlockStreaming
: undefined;
const applyFlatAccountBlockEnabled = (
config: IMessageStreamingConfig | undefined,
): IMessageStreamingConfig | undefined => {
if (flatAccountBlockEnabled === undefined || config === undefined) {
return config;
}
return {
...config,
block: {
...config.block,
enabled: flatAccountBlockEnabled,
},
};
};
if (!baseConfig || !accountConfig) {
return applyFlatAccountBlockEnabled(accountConfig ?? baseConfig);
}
return applyFlatAccountBlockEnabled({
...baseConfig,
...accountConfig,
...(baseConfig.block || accountConfig.block
? {
block: {
...baseConfig.block,
...accountConfig.block,
...(baseConfig.block?.coalesce || accountConfig.block?.coalesce
? {
coalesce: {
...baseConfig.block?.coalesce,
...accountConfig.block?.coalesce,
},
}
: {}),
},
}
: {}),
});
}
function mergeIMessageAccountConfig(cfg: OpenClawConfig, accountId: string): IMessageAccountConfig {
const accountConfig = resolveIMessageAccountConfig(cfg, accountId);
const merged = resolveMergedAccountConfig<IMessageAccountConfig>({
channelConfig: cfg.channels?.imessage as IMessageAccountConfig | undefined,
accounts: cfg.channels?.imessage?.accounts as
| Record<string, Partial<IMessageAccountConfig>>
| undefined,
accountId,
});
const streaming = mergeIMessageStreamingConfig(
(cfg.channels?.imessage as Record<string, unknown> | undefined)?.streaming,
(accountConfig as Record<string, unknown> | undefined)?.streaming,
(accountConfig as Record<string, unknown> | undefined)?.blockStreaming,
);
return streaming !== undefined ? ({ ...merged, streaming } as IMessageAccountConfig) : merged;
}
export function resolveIMessageAccount(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): ResolvedIMessageAccount {
const accountId = normalizeAccountId(
params.accountId ?? resolveDefaultIMessageAccountId(params.cfg),
);
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.sendTransport ||
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: normalizeOptionalString(merged.name),
config: merged,
configured,
};
}
function normalizeIMessageCliPath(value: string | undefined | null): string {
return value?.trim() || "imsg";
}
function normalizeIMessageDbPath(value: string | undefined | null): string {
return value?.trim() ?? "";
}
// Stable signature for the local Messages backend an iMessage account targets.
// Two enabled accounts that share a signature watch the same source, which
// caused duplicate inbound handling in openclaw/openclaw#65141.
export function resolveIMessageAccountSourceSignature(account: ResolvedIMessageAccount): string {
return JSON.stringify([
normalizeIMessageCliPath(account.config.cliPath),
normalizeIMessageDbPath(account.config.dbPath),
]);
}
function resolveIMessageAccountSourceOwner(params: {
cfg: OpenClawConfig;
signature: string;
}): string | undefined {
// Prefer an explicit named account over the implicit "default" so that
// bindings tied to the named account keep working (openclaw/openclaw#65141).
let defaultOwner: string | undefined;
for (const candidateAccountId of listIMessageAccountIds(params.cfg)) {
const candidate = resolveIMessageAccount({
cfg: params.cfg,
accountId: candidateAccountId,
});
if (!candidate.enabled) {
continue;
}
if (resolveIMessageAccountSourceSignature(candidate) !== params.signature) {
continue;
}
if (candidate.accountId === DEFAULT_ACCOUNT_ID) {
defaultOwner ??= candidate.accountId;
continue;
}
return candidate.accountId;
}
return defaultOwner;
}
/**
* Returns the owner account id when `account` is an enabled duplicate of
* another enabled account that targets the same local Messages source. Used
* by the iMessage gateway lifecycle to skip starting redundant `imsg rpc`
* watchers (openclaw/openclaw#65141) without otherwise marking the duplicate
* disabled — outbound selection, status surfaces, and capability listings
* keep treating both accounts normally.
*/
export function resolveIMessageDuplicateSourceOwner(params: {
cfg: OpenClawConfig;
account: ResolvedIMessageAccount;
}): string | undefined {
if (!params.account.enabled) {
return undefined;
}
const owner = resolveIMessageAccountSourceOwner({
cfg: params.cfg,
signature: resolveIMessageAccountSourceSignature(params.account),
});
return owner && owner !== params.account.accountId ? owner : undefined;
}
export function listEnabledIMessageAccounts(cfg: OpenClawConfig): ResolvedIMessageAccount[] {
return listIMessageAccountIds(cfg)
.map((accountId) => resolveIMessageAccount({ cfg, accountId }))
.filter((account) => account.enabled);
}
export function collectIMessageDuplicateAccountSourceWarnings(params: {
cfg: OpenClawConfig;
}): string[] {
const groups = new Map<string, ResolvedIMessageAccount[]>();
for (const accountId of listIMessageAccountIds(params.cfg)) {
const account = resolveIMessageAccount({ cfg: params.cfg, accountId });
if (!account.enabled) {
continue;
}
const signature = resolveIMessageAccountSourceSignature(account);
const existing = groups.get(signature);
if (existing) {
existing.push(account);
} else {
groups.set(signature, [account]);
}
}
const warnings: string[] = [];
for (const collisions of groups.values()) {
if (collisions.length < 2) {
continue;
}
const ownerId = resolveIMessageAccountSourceOwner({
cfg: params.cfg,
signature: resolveIMessageAccountSourceSignature(collisions[0]),
});
const owner = collisions.find((a) => a.accountId === ownerId) ?? collisions[0];
const duplicates = collisions.filter((a) => a.accountId !== owner.accountId);
const dupIds = duplicates.map((a) => `"${a.accountId}"`).join(", ");
const cliPath = normalizeIMessageCliPath(owner.config.cliPath);
const dbPath = normalizeIMessageDbPath(owner.config.dbPath);
const where = dbPath ? `cliPath=${cliPath}, dbPath=${dbPath}` : `cliPath=${cliPath}`;
warnings.push(
`- channels.imessage: accounts "${owner.accountId}" and ${dupIds} watch the same local Messages source (${where}). OpenClaw runs one watcher (owner: "${owner.accountId}") and idles the duplicate; the other accounts stay enabled for outbound sends and status. Inbound messages arrive tagged with accountId="${owner.accountId}", so bindings pinned to ${dupIds} should be re-pointed at "${owner.accountId}" (or set "enabled": false on "${owner.accountId}" to flip ownership). Set "enabled": false on the unused duplicates to silence this warning.`,
);
}
return warnings;
}

View File

@@ -0,0 +1,22 @@
// Imessage plugin module implements actions contract behavior.
export const IMESSAGE_ACTIONS = {
react: { gate: "reactions" },
edit: { gate: "edit" },
unsend: { gate: "unsend" },
reply: { gate: "reply" },
sendWithEffect: { gate: "sendWithEffect" },
renameGroup: { gate: "renameGroup", groupOnly: true },
setGroupIcon: { gate: "setGroupIcon", groupOnly: true },
addParticipant: { gate: "addParticipant", groupOnly: true },
removeParticipant: { gate: "removeParticipant", groupOnly: true },
leaveGroup: { gate: "leaveGroup", groupOnly: true },
sendAttachment: { gate: "sendAttachment" },
poll: { gate: "polls" },
"poll-vote": { gate: "polls" },
} as const;
type IMessageActionSpecs = typeof IMESSAGE_ACTIONS;
export const IMESSAGE_ACTION_NAMES = Object.keys(IMESSAGE_ACTIONS) as Array<
keyof IMessageActionSpecs
>;

View File

@@ -0,0 +1,268 @@
// Imessage tests cover actions plugin behavior.
import { EventEmitter } from "node:events";
import { afterEach, describe, expect, it, vi } from "vitest";
const spawnMock = vi.hoisted(() => vi.fn());
const createIMessageRpcClientMock = vi.hoisted(() => vi.fn());
vi.mock("node:child_process", async (importOriginal) => ({
...(await importOriginal<typeof import("node:child_process")>()),
spawn: spawnMock,
}));
vi.mock("./client.js", () => ({
createIMessageRpcClient: createIMessageRpcClientMock,
}));
const { imessageActionsRuntime, findChatGuidForTest, normalizeDirectChatIdentifierForTest } =
await import("./actions.runtime.js");
afterEach(() => {
vi.restoreAllMocks();
createIMessageRpcClientMock.mockReset();
spawnMock.mockReset();
});
function mockSpawnJsonResponse(payload: Record<string, unknown> = { success: true }) {
spawnMock.mockImplementationOnce(() => {
const child = new EventEmitter() as EventEmitter & {
stdout: EventEmitter & { setEncoding: (encoding: string) => void };
stderr: EventEmitter & { setEncoding: (encoding: string) => void };
kill: (signal: string) => void;
};
child.stdout = Object.assign(new EventEmitter(), { setEncoding: vi.fn() });
child.stderr = Object.assign(new EventEmitter(), { setEncoding: vi.fn() });
child.kill = vi.fn();
queueMicrotask(() => {
child.stdout.emit("data", `${JSON.stringify(payload)}\n`);
child.emit("close", 0);
});
return child;
});
}
function mockRpcChatList(chats: Array<Record<string, unknown>>) {
const request = vi.fn().mockResolvedValue({ chats });
const stop = vi.fn().mockResolvedValue(undefined);
createIMessageRpcClientMock.mockResolvedValueOnce({ request, stop });
return { request, stop };
}
describe("imessage actions runtime", () => {
it("passes the configured Messages db path to private API bridge commands", async () => {
mockSpawnJsonResponse();
await imessageActionsRuntime.sendReaction({
chatGuid: "iMessage;+;chat0000",
messageId: "message-guid",
reaction: "like",
options: {
cliPath: "imsg",
dbPath: "/tmp/messages.db",
chatGuid: "iMessage;+;chat0000",
},
});
expect(spawnMock).toHaveBeenCalledWith(
"imsg",
[
"tapback",
"--chat",
"iMessage;+;chat0000",
"--message",
"message-guid",
"--kind",
"like",
"--part",
"0",
"--db",
"/tmp/messages.db",
"--json",
],
{ stdio: ["ignore", "pipe", "pipe"] },
);
});
it("drops cached chats.list entries when the current clock is not a valid date timestamp", async () => {
vi.spyOn(Date, "now").mockReturnValueOnce(1_700_000_000_000).mockReturnValueOnce(Number.NaN);
const firstClient = mockRpcChatList([{ id: 1, guid: "iMessage;+;first" }]);
const secondClient = mockRpcChatList([{ id: 2, guid: "iMessage;+;second" }]);
await expect(
imessageActionsRuntime.resolveChatGuidForTarget({
target: { kind: "chat_id", chatId: 1 },
options: { cliPath: "imsg-invalid-clock" },
}),
).resolves.toBe("iMessage;+;first");
await expect(
imessageActionsRuntime.resolveChatGuidForTarget({
target: { kind: "chat_id", chatId: 2 },
options: { cliPath: "imsg-invalid-clock" },
}),
).resolves.toBe("iMessage;+;second");
expect(createIMessageRpcClientMock).toHaveBeenCalledTimes(2);
expect(firstClient.request).toHaveBeenCalledWith(
"chats.list",
{ limit: 1000 },
{ timeoutMs: undefined },
);
expect(secondClient.request).toHaveBeenCalledWith(
"chats.list",
{ limit: 1000 },
{ timeoutMs: undefined },
);
});
it("does not cache chats.list when the expiry timestamp would exceed the valid date range", async () => {
vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_000);
mockRpcChatList([{ id: 1, guid: "iMessage;+;first" }]);
mockRpcChatList([{ id: 2, guid: "iMessage;+;second" }]);
await expect(
imessageActionsRuntime.resolveChatGuidForTarget({
target: { kind: "chat_id", chatId: 1 },
options: { cliPath: "imsg-overflow-clock" },
}),
).resolves.toBe("iMessage;+;first");
await expect(
imessageActionsRuntime.resolveChatGuidForTarget({
target: { kind: "chat_id", chatId: 2 },
options: { cliPath: "imsg-overflow-clock" },
}),
).resolves.toBe("iMessage;+;second");
expect(createIMessageRpcClientMock).toHaveBeenCalledTimes(2);
});
});
describe("findChatGuid cross-format identifier resolution", () => {
// imsg's chats.list returns DM chats as `identifier: <phone>` and
// `guid: any;-;<phone>`. The agent's action surface synthesizes
// `iMessage;-;<phone>` from a phone-number target. A naive string-equality
// lookup would miss this match — this is the bug that surfaced in
// production today: agent passes phone target → chat-guid resolver returns
// null → react/edit/unsend throw "no registered chat" even though chats.list
// does have the chat.
const chatsList = [
{
id: 3,
identifier: "+12069106512",
guid: "any;-;+12069106512",
service: "iMessage",
is_group: false,
},
{
id: 7,
identifier: "chat0000",
guid: "iMessage;+;chat0000",
service: "iMessage",
is_group: true,
},
];
it("matches a synthesized iMessage;-;<phone> target against the chats.list <phone> identifier", () => {
const result = findChatGuidForTest(chatsList, {
kind: "chat_identifier",
chatIdentifier: "iMessage;-;+12069106512",
});
expect(result).toBe("any;-;+12069106512");
});
it("matches a synthesized SMS;-;<phone> target the same way", () => {
const result = findChatGuidForTest(chatsList, {
kind: "chat_identifier",
chatIdentifier: "SMS;-;+12069106512",
});
expect(result).toBe("any;-;+12069106512");
});
it("matches a bare <phone> identifier exactly", () => {
const result = findChatGuidForTest(chatsList, {
kind: "chat_identifier",
chatIdentifier: "+12069106512",
});
expect(result).toBe("any;-;+12069106512");
});
it("matches an any;-;<phone> guid form against the chats.list guid column", () => {
const result = findChatGuidForTest(chatsList, {
kind: "chat_identifier",
chatIdentifier: "any;-;+12069106512",
});
expect(result).toBe("any;-;+12069106512");
});
it("matches a group chat by exact guid", () => {
const result = findChatGuidForTest(chatsList, {
kind: "chat_identifier",
chatIdentifier: "iMessage;+;chat0000",
});
expect(result).toBe("iMessage;+;chat0000");
});
it("matches a group chat by chat_id", () => {
const result = findChatGuidForTest(chatsList, { kind: "chat_id", chatId: 7 });
expect(result).toBe("iMessage;+;chat0000");
});
it("does not coerce non-decimal chat ids from chats.list", () => {
const result = findChatGuidForTest(
[
{
id: "0x7",
identifier: "wrong",
guid: "iMessage;+;wrong",
},
],
{ kind: "chat_id", chatId: 7 },
);
expect(result).toBeNull();
});
it("returns null for a phone number that does not exist in chats.list", () => {
const result = findChatGuidForTest(chatsList, {
kind: "chat_identifier",
chatIdentifier: "iMessage;-;+19999999999",
});
expect(result).toBeNull();
});
it("does not cross-match different phone numbers via the prefix-stripping path", () => {
const result = findChatGuidForTest(chatsList, {
kind: "chat_identifier",
chatIdentifier: "iMessage;-;+18001234567",
});
expect(result).toBeNull();
});
it("does not match a DM target against a group's chat_identifier", () => {
const result = findChatGuidForTest(chatsList, {
kind: "chat_identifier",
chatIdentifier: "iMessage;+;chat-not-here",
});
expect(result).toBeNull();
});
});
describe("normalizeDirectChatIdentifier", () => {
it("strips the iMessage;-; prefix", () => {
expect(normalizeDirectChatIdentifierForTest("iMessage;-;+12069106512")).toBe("+12069106512");
});
it("strips the SMS;-; prefix", () => {
expect(normalizeDirectChatIdentifierForTest("SMS;-;+12069106512")).toBe("+12069106512");
});
it("strips the any;-; prefix", () => {
expect(normalizeDirectChatIdentifierForTest("any;-;+12069106512")).toBe("+12069106512");
});
it("matches case-insensitively", () => {
expect(normalizeDirectChatIdentifierForTest("IMESSAGE;-;+12069106512")).toBe("+12069106512");
});
it("leaves group identifiers (iMessage;+;chat...) unchanged", () => {
expect(normalizeDirectChatIdentifierForTest("iMessage;+;chat0000")).toBe("iMessage;+;chat0000");
});
it("leaves bare values unchanged", () => {
expect(normalizeDirectChatIdentifierForTest("+12069106512")).toBe("+12069106512");
expect(normalizeDirectChatIdentifierForTest("foo@bar.com")).toBe("foo@bar.com");
});
});

View File

@@ -0,0 +1,603 @@
// Imessage plugin module implements actions behavior.
import { spawn } from "node:child_process";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { extname, join } from "node:path";
import {
asDateTimestampMs,
parseStrictInteger,
resolveExpiresAtMsFromDurationMs,
} from "openclaw/plugin-sdk/number-runtime";
import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
import { appendIMessageCliStderrTail, appendIMessageCliStdout } from "./cli-output.js";
import { createIMessageRpcClient } from "./client.js";
import { extractMarkdownFormatRuns } from "./markdown-format.js";
import {
normalizeDirectChatIdentifier,
resolveIMessageMessageId as resolveIMessageMessageIdImpl,
} from "./monitor-reply-cache.js";
import type { IMessageTarget } from "./targets.js";
type CliRunOptions = {
cliPath: string;
dbPath?: string;
timeoutMs?: number;
};
type IMessageBridgeActionOptions = CliRunOptions & {
chatGuid: string;
};
type IMessageBridgeSendResult = {
messageId: string;
};
type TempFileInput = {
buffer: Uint8Array;
filename: string;
};
type IMessageChatListResponse = {
chats?: unknown;
};
function asChatList(value: unknown): Array<Record<string, unknown>> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return [];
}
const chats = (value as IMessageChatListResponse).chats;
if (!Array.isArray(chats)) {
return [];
}
return chats.filter(
(chat): chat is Record<string, unknown> =>
chat != null && typeof chat === "object" && !Array.isArray(chat),
);
}
function numberFromUnknown(value: unknown): number | undefined {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
return parseStrictInteger(value);
}
function stringFromUnknown(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
// 30s TTL on the chats.list cache, keyed by cliPath+dbPath. Long enough to
// absorb a burst of agent actions; short enough that a freshly-created
// chat shows up without restarting the gateway.
const CHAT_LIST_CACHE_TTL_MS = 30 * 1000;
type ChatListCacheEntry = {
list: ReadonlyArray<Record<string, unknown>>;
expiresAt: number;
};
const chatListCache = new Map<string, ChatListCacheEntry>();
function chatListCacheKey(cliPath: string, dbPath?: string): string {
return `${cliPath}\0${dbPath ?? ""}`;
}
function chatListCacheGet(
cliPath: string,
dbPath?: string,
): ReadonlyArray<Record<string, unknown>> | null {
const key = chatListCacheKey(cliPath, dbPath);
const entry = chatListCache.get(key);
if (!entry) {
return null;
}
const now = asDateTimestampMs(Date.now());
if (now === undefined || entry.expiresAt <= now) {
chatListCache.delete(key);
return null;
}
return entry.list;
}
function chatListCacheSet(
cliPath: string,
dbPath: string | undefined,
list: ReadonlyArray<Record<string, unknown>>,
): void {
const expiresAt = resolveExpiresAtMsFromDurationMs(CHAT_LIST_CACHE_TTL_MS);
if (expiresAt === undefined) {
return;
}
chatListCache.set(chatListCacheKey(cliPath, dbPath), {
list,
expiresAt,
});
}
/**
* Strip the iMessage;-;/SMS;-;/any;-; service prefix that Messages uses
* for direct DM chats. Different layers report direct DMs in different
* forms — the action surface synthesizes `iMessage;-;<phone>` from a
* handle target, while imsg's chats.list returns `identifier: <phone>`
* and `guid: any;-;<phone>`. Comparing the raw strings would falsely
* miss the match.
*/
export function normalizeDirectChatIdentifierForTest(raw: string): string {
return normalizeDirectChatIdentifier(raw);
}
export function findChatGuidForTest(
chats: readonly Record<string, unknown>[],
target: Extract<IMessageTarget, { kind: "chat_id" | "chat_identifier" }>,
): string | null {
return findChatGuid(chats, target);
}
function findChatGuid(
chats: readonly Record<string, unknown>[],
target: Extract<IMessageTarget, { kind: "chat_id" | "chat_identifier" }>,
): string | null {
if (target.kind === "chat_id") {
for (const chat of chats) {
const id = numberFromUnknown(chat.id);
const guid = stringFromUnknown(chat.guid);
if (id === target.chatId && guid) {
return guid;
}
}
return null;
}
// target.kind === "chat_identifier"
const wanted = normalizeDirectChatIdentifier(target.chatIdentifier);
for (const chat of chats) {
const identifier = stringFromUnknown(chat.identifier);
const guid = stringFromUnknown(chat.guid);
if (!guid) {
continue;
}
if (
identifier === target.chatIdentifier ||
guid === target.chatIdentifier ||
(identifier && normalizeDirectChatIdentifier(identifier) === wanted) ||
normalizeDirectChatIdentifier(guid) === wanted
) {
return guid;
}
}
return null;
}
function buildIMessageCliJsonArgs(args: readonly string[], options: CliRunOptions): string[] {
const dbPath = options.dbPath?.trim();
return [...args, ...(dbPath ? ["--db", dbPath] : []), "--json"];
}
async function runIMessageCliJson(
args: readonly string[],
options: CliRunOptions,
): Promise<Record<string, unknown>> {
return await new Promise((resolve, reject) => {
const child = spawn(options.cliPath, buildIMessageCliJsonArgs(args, options), {
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
let killEscalation: ReturnType<typeof setTimeout> | null = null;
let settled = false;
const clearTimers = (optionsValue: { keepKillEscalation?: boolean } = {}): void => {
if (timer) {
clearTimeout(timer);
}
if (killEscalation && !optionsValue.keepKillEscalation) {
clearTimeout(killEscalation);
}
};
const fail = (error: Error, optionsLocal: { keepKillEscalation?: boolean } = {}): void => {
if (settled) {
return;
}
settled = true;
clearTimers(optionsLocal);
reject(error);
};
const succeed = (value: Record<string, unknown>): void => {
if (settled) {
return;
}
settled = true;
clearTimers();
resolve(value);
};
const timer =
options.timeoutMs && options.timeoutMs > 0
? setTimeout(() => {
child.kill("SIGTERM");
// If SIGTERM doesn't take within 2s (wedged child, ignored
// signal handler), escalate to SIGKILL so the process doesn't
// linger as a zombie.
killEscalation = setTimeout(() => {
try {
child.kill("SIGKILL");
} catch {
// best-effort
}
}, 2000);
fail(new Error(`iMessage action timed out after ${options.timeoutMs}ms`), {
keepKillEscalation: true,
});
}, options.timeoutMs)
: null;
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk) => {
if (settled) {
return;
}
const appended = appendIMessageCliStdout(stdout, chunk);
if (!appended.ok) {
try {
child.kill("SIGKILL");
} catch {
// best-effort
}
fail(new Error(appended.message));
return;
}
stdout = appended.value;
});
child.stderr.on("data", (chunk) => {
stderr = appendIMessageCliStderrTail(stderr, chunk);
});
child.on("error", (error) => {
if (settled) {
clearTimers();
return;
}
fail(error);
});
child.on("close", (code) => {
if (settled) {
clearTimers();
return;
}
const lines = normalizeStringEntries(stdout.split(/\r?\n/));
const last = lines.at(-1);
let parsed: Record<string, unknown> | null = null;
if (last) {
try {
const value = JSON.parse(last);
if (value && typeof value === "object" && !Array.isArray(value)) {
parsed = value as Record<string, unknown>;
}
} catch {
parsed = null;
}
}
if (code !== 0) {
const detail =
(typeof parsed?.error === "string" && parsed.error.trim()) ||
stderr.trim() ||
stdout.trim() ||
`imsg exited with code ${code}`;
fail(new Error(detail));
return;
}
if (!parsed) {
fail(new Error(`imsg returned non-JSON output: ${stdout.trim() || stderr.trim()}`));
return;
}
if (parsed.success === false) {
const error =
typeof parsed.error === "string" && parsed.error.trim()
? parsed.error.trim()
: "iMessage action failed";
fail(new Error(error));
return;
}
succeed(parsed);
});
});
}
function resolveMessageId(result: Record<string, unknown>): string {
const raw =
(typeof result.messageGuid === "string" && result.messageGuid.trim()) ||
(typeof result.messageId === "string" && result.messageId.trim()) ||
(typeof result.guid === "string" && result.guid.trim()) ||
(typeof result.id === "string" && result.id.trim());
return raw || "ok";
}
async function withTempFile<T>(input: TempFileInput, fn: (path: string) => Promise<T>): Promise<T> {
const dir = await mkdtemp(join(resolvePreferredOpenClawTmpDir(), "openclaw-imessage-"));
const safeExt = extname(input.filename).slice(0, 16) || ".bin";
const filePath = join(dir, `upload${safeExt}`);
try {
await writeFile(filePath, input.buffer);
return await fn(filePath);
} finally {
await rm(dir, { recursive: true, force: true });
}
}
export const imessageActionsRuntime = {
resolveIMessageMessageId: resolveIMessageMessageIdImpl,
async resolveChatGuidForTarget(params: {
target: Extract<IMessageTarget, { kind: "chat_id" | "chat_identifier" }>;
options: CliRunOptions;
}): Promise<string | null> {
// Each `chats.list` call spawns a fresh imsg rpc subprocess and pulls
// every chat the account knows about. Bursts of agent actions (react
// then reply, reply then add-participant, etc.) all paid that cost
// until we cached the chats list per cliPath+dbPath for ~30 seconds.
const cached = chatListCacheGet(params.options.cliPath, params.options.dbPath);
if (cached) {
return findChatGuid(cached, params.target);
}
const client = await createIMessageRpcClient({
cliPath: params.options.cliPath,
dbPath: params.options.dbPath,
});
try {
const result = await client.request<IMessageChatListResponse>(
"chats.list",
{ limit: 1000 },
{ timeoutMs: params.options.timeoutMs },
);
const list = asChatList(result);
chatListCacheSet(params.options.cliPath, params.options.dbPath, list);
return findChatGuid(list, params.target);
} finally {
await client.stop();
}
},
async sendReaction(params: {
chatGuid: string;
messageId: string;
reaction: string;
remove?: boolean;
partIndex?: number;
options: IMessageBridgeActionOptions;
}) {
await runIMessageCliJson(
[
"tapback",
"--chat",
params.chatGuid,
"--message",
params.messageId,
"--kind",
params.reaction,
"--part",
String(params.partIndex ?? 0),
...(params.remove ? ["--remove"] : []),
],
params.options,
);
},
async editMessage(params: {
chatGuid: string;
messageId: string;
text: string;
backwardsCompatMessage?: string;
partIndex?: number;
options: IMessageBridgeActionOptions;
}) {
await runIMessageCliJson(
[
"edit",
"--chat",
params.chatGuid,
"--message",
params.messageId,
"--new-text",
params.text,
"--bc-text",
params.backwardsCompatMessage ?? params.text,
"--part",
String(params.partIndex ?? 0),
],
params.options,
);
},
async unsendMessage(params: {
chatGuid: string;
messageId: string;
partIndex?: number;
options: IMessageBridgeActionOptions;
}) {
await runIMessageCliJson(
[
"unsend",
"--chat",
params.chatGuid,
"--message",
params.messageId,
"--part",
String(params.partIndex ?? 0),
],
params.options,
);
},
async sendRichMessage(params: {
chatGuid: string;
text: string;
effectId?: string;
replyToMessageId?: string;
partIndex?: number;
// Optional attachment as an in-memory buffer that we stage to a temp
// file before invoking imsg. The buffer must already have been loaded
// by the outbound media resolver (mediaLocalRoots/sandbox/size limits)
// — this runtime intentionally does not accept a raw filesystem path,
// because that would let an attacker-controlled path bypass the
// resolver and let imsg send any host-readable file. Requires an imsg
// build that accepts `send-rich --file` (openclaw/imsg#114); callers
// must feature-detect via the cached private-api status first.
attachment?: { kind: "buffer"; buffer: Uint8Array; filename: string };
options: IMessageBridgeActionOptions;
}): Promise<IMessageBridgeSendResult> {
// Extract markdown bold/italic/underline/strikethrough into typed-run
// ranges so the recipient sees actual styling rather than literal
// asterisks. This mirrors the same extraction the rpc-send path does;
// any caller that hits the bridge via `imsg send-rich` benefits without
// needing to pre-format the text themselves.
const formatted = extractMarkdownFormatRuns(params.text);
const buildArgs = (filePath?: string): string[] => [
"send-rich",
"--chat",
params.chatGuid,
"--text",
formatted.text,
"--part",
String(params.partIndex ?? 0),
...(params.effectId ? ["--effect", params.effectId] : []),
...(params.replyToMessageId ? ["--reply-to", params.replyToMessageId] : []),
...(formatted.ranges.length > 0 ? ["--format", JSON.stringify(formatted.ranges)] : []),
...(filePath ? ["--file", filePath] : []),
];
if (params.attachment) {
return await withTempFile(
{ buffer: params.attachment.buffer, filename: params.attachment.filename },
async (filePath) => {
const result = await runIMessageCliJson(buildArgs(filePath), params.options);
return { messageId: resolveMessageId(result) };
},
);
}
const result = await runIMessageCliJson(buildArgs(), params.options);
return { messageId: resolveMessageId(result) };
},
async renameGroup(params: {
chatGuid: string;
displayName: string;
options: IMessageBridgeActionOptions;
}) {
await runIMessageCliJson(
["chat-name", "--chat", params.chatGuid, "--name", params.displayName],
params.options,
);
},
async setGroupIcon(params: {
chatGuid: string;
buffer: Uint8Array;
filename: string;
options: IMessageBridgeActionOptions;
}) {
await withTempFile({ buffer: params.buffer, filename: params.filename }, async (filePath) => {
await runIMessageCliJson(
["chat-photo", "--chat", params.chatGuid, "--file", filePath],
params.options,
);
});
},
async addParticipant(params: {
chatGuid: string;
address: string;
options: IMessageBridgeActionOptions;
}) {
await runIMessageCliJson(
["chat-add-member", "--chat", params.chatGuid, "--address", params.address],
params.options,
);
},
async removeParticipant(params: {
chatGuid: string;
address: string;
options: IMessageBridgeActionOptions;
}) {
await runIMessageCliJson(
["chat-remove-member", "--chat", params.chatGuid, "--address", params.address],
params.options,
);
},
async leaveGroup(params: { chatGuid: string; options: IMessageBridgeActionOptions }) {
await runIMessageCliJson(["chat-leave", "--chat", params.chatGuid], params.options);
},
async sendPoll(params: {
chatGuid: string;
question: string;
// Pre-validated, trimmed choices (>=2). Named `choices` so it does not
// shadow `options` (the CLI run options) on this params bag.
choices: readonly string[];
replyToMessageId?: string;
options: IMessageBridgeActionOptions;
}): Promise<IMessageBridgeSendResult> {
const result = await runIMessageCliJson(
[
"poll",
"send",
"--chat",
params.chatGuid,
"--question",
params.question,
...params.choices.flatMap((choice) => ["--option", choice]),
...(params.replyToMessageId ? ["--reply-to", params.replyToMessageId] : []),
],
params.options,
);
return { messageId: resolveMessageId(result) };
},
async sendPollVote(params: {
chatGuid: string;
pollGuid: string;
// Exactly one selector; the CLI resolves index/text to the option UUID.
optionIndex?: number;
optionId?: string;
optionText?: string;
options: IMessageBridgeActionOptions;
}): Promise<IMessageBridgeSendResult & { optionText?: string }> {
const selector = params.optionId
? ["--option-id", params.optionId]
: params.optionIndex !== undefined
? ["--option-index", String(params.optionIndex)]
: params.optionText
? ["--option", params.optionText]
: [];
const result = await runIMessageCliJson(
["poll", "vote", "--chat", params.chatGuid, "--poll", params.pollGuid, ...selector],
params.options,
);
const optionText = typeof result.optionText === "string" ? result.optionText.trim() : "";
return { messageId: resolveMessageId(result), ...(optionText ? { optionText } : {}) };
},
async sendAttachment(params: {
chatGuid: string;
buffer: Uint8Array;
filename: string;
asVoice?: boolean;
options: IMessageBridgeActionOptions;
}): Promise<IMessageBridgeSendResult> {
return await withTempFile(
{ buffer: params.buffer, filename: params.filename },
async (filePath) => {
const result = await runIMessageCliJson(
[
"send-attachment",
"--chat",
params.chatGuid,
"--file",
filePath,
...(params.asVoice ? ["--audio"] : []),
],
params.options,
);
return { messageId: resolveMessageId(result) };
},
);
},
};
export type IMessageActionsRuntime = typeof imessageActionsRuntime;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,899 @@
// Imessage plugin module implements actions behavior.
import { readBooleanParam } from "openclaw/plugin-sdk/boolean-param";
import {
createActionGate,
jsonResult,
readNonNegativeIntegerParam,
readPositiveIntegerParam,
readReactionParams,
readStringArrayParam,
readStringParam,
} from "openclaw/plugin-sdk/channel-actions";
import type {
ChannelMessageActionAdapter,
ChannelMessageActionName,
} from "openclaw/plugin-sdk/channel-contract";
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
import { normalizePollInput } from "openclaw/plugin-sdk/poll-runtime";
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { extractToolSend } from "openclaw/plugin-sdk/tool-send";
import { resolveIMessageAccount } from "./accounts.js";
import { IMESSAGE_ACTION_NAMES, IMESSAGE_ACTIONS } from "./actions-contract.js";
import { DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS } from "./constants.js";
import { describeIMessageMessageTool } from "./message-tool-api.js";
import {
findLatestIMessageEntryForChat,
rememberIMessageReplyCache,
type IMessageChatContext,
} from "./monitor-reply-cache.js";
import { imessageRpcSupportsMethod } from "./private-api-status.js";
import { getCachedIMessagePrivateApiStatus, probeIMessagePrivateApi } from "./probe.js";
import { parseIMessageTarget, type IMessageTarget } from "./targets.js";
const loadIMessageActionsRuntime = createLazyRuntimeNamedExport(
() => import("./actions.runtime.js"),
"imessageActionsRuntime",
);
const log = createSubsystemLogger("channels/imessage");
const providerId = "imessage";
const SUPPORTED_ACTIONS = new Set<ChannelMessageActionName>([
...IMESSAGE_ACTION_NAMES,
"upload-file",
]);
const GROUP_MANAGEMENT_ACTIONS = new Set<ChannelMessageActionName>([
"renameGroup",
"setGroupIcon",
"addParticipant",
"removeParticipant",
"leaveGroup",
]);
function readMessageText(params: Record<string, unknown>): string | undefined {
return readStringParam(params, "text") ?? readStringParam(params, "message");
}
function resolveIMessageDeliveryTarget(args: Record<string, unknown>): string | undefined {
const chatGuid = readStringParam(args, "chatGuid");
const chatId = readPositiveIntegerParam(args, "chatId");
const chatIdentifier = readStringParam(args, "chatIdentifier");
const targets = [
chatGuid ? `chat_guid:${chatGuid}` : undefined,
chatId !== undefined ? `chat_id:${chatId}` : undefined,
chatIdentifier ? `chat_identifier:${chatIdentifier}` : undefined,
].filter((value): value is string => Boolean(value));
if (targets.length > 1) {
throw new Error("iMessage action received conflicting delivery target aliases.");
}
return targets[0];
}
function rememberOutboundBridgeMessage(params: {
accountId: string;
messageId?: string;
chatGuid: string;
}): void {
const messageId = params.messageId?.trim();
if (!messageId || messageId === "ok" || messageId === "unknown") {
return;
}
rememberIMessageReplyCache({
accountId: params.accountId,
messageId,
chatGuid: params.chatGuid,
timestamp: Date.now(),
isFromMe: true,
});
}
/**
* Read messageId from the action params, falling back to the most recent
* inbound in the same chat when the caller omitted it. The natural intent
* for "react with 👍" or "tapback the last message" is the message that
* just arrived in the current conversation; making the agent re-quote a
* message id every time is friction the cache already has the answer for.
*/
function readMessageIdWithChatFallback(
params: Record<string, unknown>,
chatContext: IMessageChatContext & { accountId: string },
): string {
const explicit = readStringParam(params, "messageId");
if (explicit) {
return explicit;
}
const latest = findLatestIMessageEntryForChat(chatContext);
if (latest?.messageId) {
return latest.messageId;
}
// Surface the same error the strict readMessageId would have, so the
// agent gets a clear "you must supply messageId" signal when there is
// also no cached message to fall back to.
return readStringParam(params, "messageId", { required: true });
}
type IMessageActionsRuntime = Awaited<ReturnType<typeof loadIMessageActionsRuntime>>;
async function resolveChatGuid(params: {
action: ChannelMessageActionName;
actionParams: Record<string, unknown>;
currentChannelId?: string;
runtime: IMessageActionsRuntime;
options: {
cliPath: string;
dbPath?: string;
timeoutMs?: number;
};
}): Promise<string> {
const explicitChatGuid = readStringParam(params.actionParams, "chatGuid");
if (explicitChatGuid) {
return explicitChatGuid;
}
const explicitChatId = readPositiveIntegerParam(params.actionParams, "chatId");
if (typeof explicitChatId === "number") {
const resolved = await params.runtime.resolveChatGuidForTarget({
target: { kind: "chat_id", chatId: explicitChatId },
options: params.options,
});
if (resolved) {
return resolved;
}
throw new Error(`iMessage ${params.action} failed: chatGuid not found for chat_id:<redacted>.`);
}
const explicitChatIdentifier = readStringParam(params.actionParams, "chatIdentifier");
if (explicitChatIdentifier) {
const resolved = await params.runtime.resolveChatGuidForTarget({
target: { kind: "chat_identifier", chatIdentifier: explicitChatIdentifier },
options: params.options,
});
if (resolved) {
return resolved;
}
throw new Error(
`iMessage ${params.action} failed: chatGuid not found for chat_identifier:<redacted>.`,
);
}
const rawTarget =
readStringParam(params.actionParams, "to") ??
readStringParam(params.actionParams, "target") ??
(params.currentChannelId?.trim() || undefined);
if (rawTarget) {
const target = parseIMessageTarget(rawTarget);
if (target.kind === "chat_guid") {
return target.chatGuid;
}
if (target.kind === "chat_id" || target.kind === "chat_identifier") {
const resolved = await params.runtime.resolveChatGuidForTarget({
target,
options: params.options,
});
if (resolved) {
return resolved;
}
throw new Error(
`iMessage ${params.action} failed: chatGuid not found for ${formatUnresolvedTarget(target)}.`,
);
}
if (target.kind === "handle") {
// A bare phone/email is a valid chat scope for direct messages —
// Messages addresses DMs as `iMessage;-;<handle>` / `SMS;-;<handle>`.
// Promote it to chat_identifier so resolveChatGuidForTarget (which
// only accepts chat_id / chat_identifier kinds) can look it up.
const synthesizedIdentifier = `${target.service === "sms" ? "SMS" : "iMessage"};-;${target.to}`;
const resolved = await params.runtime.resolveChatGuidForTarget({
target: { kind: "chat_identifier", chatIdentifier: synthesizedIdentifier },
options: params.options,
});
if (resolved) {
return resolved;
}
// Per-action fallback policy:
// - send / reply / sendWithEffect / sendAttachment: fine to send to
// a synthesized DM identifier; Messages will register the chat.
// - react / edit / unsend: these mutate an existing message that
// must already exist in the chat. If we have no registered chat
// we have no message to act on, and synthesizing the identifier
// just produces a confusing CLI failure.
if (params.action === "react" || params.action === "edit" || params.action === "unsend") {
throw new Error(
`iMessage ${params.action} requires a known chat. ` +
`No registered chat for the supplied target; send a message first or pass an explicit chatGuid.`,
);
}
return synthesizedIdentifier;
}
}
throw new Error(
`iMessage ${params.action} requires chatGuid, chatId, chatIdentifier, or a chat target.`,
);
}
function formatUnresolvedTarget(
target: Extract<IMessageTarget, { kind: "chat_id" | "chat_identifier" }>,
): string {
// Redact the actual identifier — error strings end up in agent tool
// results and log streams, and exposing a chat_id or chat_identifier
// there would leak the conversation handle to anything that observes
// them.
return target.kind === "chat_id" ? "chat_id:<redacted>" : "chat_identifier:<redacted>";
}
function buildChatContextFromActionParams(params: {
actionParams: Record<string, unknown>;
currentChannelId?: string;
}): IMessageChatContext {
const explicitChatGuid = readStringParam(params.actionParams, "chatGuid")?.trim();
const explicitChatIdentifier = readStringParam(params.actionParams, "chatIdentifier")?.trim();
const explicitChatId = readPositiveIntegerParam(params.actionParams, "chatId");
// Trim before the truthy check so a whitespace-only currentChannelId can't
// reach parseIMessageTarget (which throws on empty/whitespace input and
// would abort the whole action with a confusing "target is required").
const rawTarget =
readStringParam(params.actionParams, "to") ??
readStringParam(params.actionParams, "target") ??
(params.currentChannelId?.trim() || undefined);
const target = rawTarget ? parseIMessageTarget(rawTarget) : null;
// A "handle" target (raw phone or email — what the agent uses most of the
// time) is still a usable chat scope: Messages addresses DMs as
// `iMessage;-;+15551234567` / `SMS;-;+15551234567`. Synthesizing the
// chat-identifier here lets resolveIMessageMessageId succeed without
// forcing every action plumbing site to also surface chatGuid/chatId.
const handleChatIdentifier =
target?.kind === "handle"
? `${target.service === "sms" ? "SMS" : "iMessage"};-;${target.to}`
: undefined;
return {
chatGuid: explicitChatGuid || (target?.kind === "chat_guid" ? target.chatGuid : undefined),
chatIdentifier:
explicitChatIdentifier ||
(target?.kind === "chat_identifier" ? target.chatIdentifier : undefined) ||
handleChatIdentifier,
chatId:
typeof explicitChatId === "number"
? explicitChatId
: target?.kind === "chat_id"
? target.chatId
: undefined,
};
}
function mapTapbackReaction(emoji?: string): string | undefined {
const value = normalizeOptionalLowercaseString(emoji)?.replace(/\ufe0f/g, "");
if (!value) {
return undefined;
}
if (["love", "heart", "❤", "❤️"].includes(value)) {
return "love";
}
if (["like", "+1", "thumbsup", "👍"].includes(value)) {
return "like";
}
if (["dislike", "-1", "thumbsdown", "👎"].includes(value)) {
return "dislike";
}
if (["laugh", "haha", "😂", "🤣"].includes(value)) {
return "laugh";
}
if (["emphasize", "!!", "‼", "‼️"].includes(value)) {
return "emphasize";
}
if (["question", "?", "", "❓"].includes(value)) {
return "question";
}
return undefined;
}
function decodeBase64Buffer(params: Record<string, unknown>, action: string): Uint8Array {
const base64Buffer = readStringParam(params, "buffer");
if (!base64Buffer) {
throw new Error(`iMessage ${action} requires buffer (base64) parameter.`);
}
return Uint8Array.from(Buffer.from(base64Buffer, "base64"));
}
// Path-shaped attachment params the message-tool schema declares. We only
// look at these to detect an unhydrated bypass attempt — the resolver in
// hydrateAttachmentParamsForAction is responsible for loading them into
// `buffer`/`filename` after enforcing localRoots, sandbox, and size limits.
const REPLY_ATTACHMENT_PATH_PARAM_NAMES: readonly string[] = [
"filePath",
"path",
"media",
"mediaUrl",
"fileUrl",
] as const;
type ReplyAttachmentSpec = { kind: "buffer"; buffer: Uint8Array; filename: string };
// Reply attachments must arrive hydrated: the core message-action runner
// loads `path`/`media`/`mediaUrl`/`filePath`/`fileUrl` through the outbound
// media resolver (mediaLocalRoots / sandbox / size limits / SSRF) and writes
// the result into `buffer` + `filename`. We deliberately do not consume raw
// path params here — accepting them would let an agent send any host file
// imsg can read, bypassing the resolver. If a path-shaped param is present
// without a corresponding `buffer`, the caller skipped hydration (most
// likely calling handleAction directly in a test); fail loudly instead.
function extractReplyAttachment(
params: Record<string, unknown>,
): { spec: ReplyAttachmentSpec; sourceParam: string } | { spec: null; bypassParam: string } | null {
const buffer = readStringParam(params, "buffer");
if (buffer) {
const filename = readStringParam(params, "filename") ?? "attachment.bin";
return {
spec: {
kind: "buffer",
buffer: Uint8Array.from(Buffer.from(buffer, "base64")),
filename,
},
sourceParam: "buffer",
};
}
for (const name of REPLY_ATTACHMENT_PATH_PARAM_NAMES) {
if (readStringParam(params, name)) {
return { spec: null, bypassParam: name };
}
}
return null;
}
// Whitelist of expressive-send effect IDs the bridge accepts. Restricting
// to a fixed set lets us return a clear error for typos ("invisible_ink"
// vs "invisibleink") instead of silently forwarding gibberish to the
// bridge and surfacing an opaque CLI failure.
const KNOWN_EFFECT_IDS: ReadonlySet<string> = new Set([
"com.apple.MobileSMS.expressivesend.impact",
"com.apple.MobileSMS.expressivesend.loud",
"com.apple.MobileSMS.expressivesend.gentle",
"com.apple.MobileSMS.expressivesend.invisibleink",
"com.apple.MobileSMS.expressivesend.confetti",
"com.apple.MobileSMS.expressivesend.lasers",
"com.apple.MobileSMS.expressivesend.fireworks",
"com.apple.MobileSMS.expressivesend.balloon",
"com.apple.MobileSMS.expressivesend.heart",
"com.apple.messages.effect.CKEchoEffect",
"com.apple.messages.effect.CKHappyBirthdayEffect",
"com.apple.messages.effect.CKShootingStarEffect",
"com.apple.messages.effect.CKSparklesEffect",
"com.apple.messages.effect.CKSpotlightEffect",
]);
function effectIdFromParam(raw?: string): string | undefined {
const value = normalizeOptionalLowercaseString(raw);
if (!value) {
return undefined;
}
const aliases: Record<string, string> = {
slam: "com.apple.MobileSMS.expressivesend.impact",
impact: "com.apple.MobileSMS.expressivesend.impact",
loud: "com.apple.MobileSMS.expressivesend.loud",
gentle: "com.apple.MobileSMS.expressivesend.gentle",
"invisible-ink": "com.apple.MobileSMS.expressivesend.invisibleink",
invisibleink: "com.apple.MobileSMS.expressivesend.invisibleink",
confetti: "com.apple.MobileSMS.expressivesend.confetti",
lasers: "com.apple.MobileSMS.expressivesend.lasers",
fireworks: "com.apple.MobileSMS.expressivesend.fireworks",
balloons: "com.apple.MobileSMS.expressivesend.balloon",
balloon: "com.apple.MobileSMS.expressivesend.balloon",
heart: "com.apple.MobileSMS.expressivesend.heart",
// Background screen effects (com.apple.messages.effect.CK*Effect).
// The error message below advertises these short names, so they must
// map to the canonical CKEffect identifier — without this, agents
// that follow our own guidance get "unknown effect" thrown back.
echo: "com.apple.messages.effect.CKEchoEffect",
happybirthday: "com.apple.messages.effect.CKHappyBirthdayEffect",
"happy-birthday": "com.apple.messages.effect.CKHappyBirthdayEffect",
shootingstar: "com.apple.messages.effect.CKShootingStarEffect",
"shooting-star": "com.apple.messages.effect.CKShootingStarEffect",
sparkles: "com.apple.messages.effect.CKSparklesEffect",
spotlight: "com.apple.messages.effect.CKSpotlightEffect",
};
const resolved = aliases[value] ?? raw;
if (typeof resolved === "string" && KNOWN_EFFECT_IDS.has(resolved)) {
return resolved;
}
throw new Error(
`iMessage sendWithEffect rejected unknown effect "${raw}". ` +
"Use one of: slam, loud, gentle, invisibleink, confetti, lasers, fireworks, balloon, heart, " +
"echo, happybirthday, shootingstar, sparkles, spotlight (or the canonical com.apple.MobileSMS.expressivesend.* / com.apple.messages.effect.* identifier).",
);
}
function assertActionEnabled(
action: ChannelMessageActionName,
actionsConfig: Record<string, boolean | undefined> | undefined,
): void {
const canonicalAction = action === "upload-file" ? "sendAttachment" : action;
const spec = IMESSAGE_ACTIONS[canonicalAction as keyof typeof IMESSAGE_ACTIONS];
if (!spec?.gate || !createActionGate(actionsConfig)(spec.gate)) {
throw new Error(`iMessage ${action} is disabled in config.`);
}
}
export const imessageMessageActions: ChannelMessageActionAdapter = {
describeMessageTool: describeIMessageMessageTool,
supportsAction: ({ action }) => SUPPORTED_ACTIONS.has(action),
requiresTrustedRequesterSender: ({ action, toolContext }) =>
normalizeOptionalLowercaseString(toolContext?.currentChannelProvider) === "imessage" &&
GROUP_MANAGEMENT_ACTIONS.has(action),
messageActionTargetAliases: {
react: { aliases: ["chatGuid", "chatIdentifier", "chatId"] },
edit: { aliases: ["chatGuid", "chatIdentifier", "chatId", "messageId"] },
unsend: { aliases: ["chatGuid", "chatIdentifier", "chatId", "messageId"] },
reply: {
aliases: ["chatGuid", "chatIdentifier", "chatId", "messageId"],
deliveryTargetAliases: ["chatGuid", "chatIdentifier", "chatId"],
resolveDeliveryTarget: ({ args }) => resolveIMessageDeliveryTarget(args),
},
sendWithEffect: {
aliases: ["chatGuid", "chatIdentifier", "chatId"],
deliveryTargetAliases: ["chatGuid", "chatIdentifier", "chatId"],
resolveDeliveryTarget: ({ args }) => resolveIMessageDeliveryTarget(args),
},
sendAttachment: {
aliases: ["chatGuid", "chatIdentifier", "chatId"],
deliveryTargetAliases: ["chatGuid", "chatIdentifier", "chatId"],
resolveDeliveryTarget: ({ args }) => resolveIMessageDeliveryTarget(args),
},
poll: {
aliases: ["chatGuid", "chatIdentifier", "chatId"],
deliveryTargetAliases: ["chatGuid", "chatIdentifier", "chatId"],
resolveDeliveryTarget: ({ args }) => resolveIMessageDeliveryTarget(args),
},
"poll-vote": {
aliases: ["chatGuid", "chatIdentifier", "chatId", "pollId", "messageId"],
deliveryTargetAliases: ["chatGuid", "chatIdentifier", "chatId"],
resolveDeliveryTarget: ({ args }) => resolveIMessageDeliveryTarget(args),
},
"upload-file": {
aliases: ["chatGuid", "chatIdentifier", "chatId"],
deliveryTargetAliases: ["chatGuid", "chatIdentifier", "chatId"],
resolveDeliveryTarget: ({ args }) => resolveIMessageDeliveryTarget(args),
},
renameGroup: { aliases: ["chatGuid", "chatIdentifier", "chatId"] },
setGroupIcon: { aliases: ["chatGuid", "chatIdentifier", "chatId"] },
addParticipant: { aliases: ["chatGuid", "chatIdentifier", "chatId"] },
removeParticipant: { aliases: ["chatGuid", "chatIdentifier", "chatId"] },
leaveGroup: { aliases: ["chatGuid", "chatIdentifier", "chatId"] },
},
extractToolSend: ({ args }) => extractToolSend(args, "sendMessage"),
handleAction: async ({
action,
params,
cfg,
accountId,
toolContext,
senderIsOwner,
gatewayClientScopes,
}) => {
// Group administration mutates the host's Messages identity, so model-driven
// actions need owner provenance or an admin-scoped Gateway caller.
if (
GROUP_MANAGEMENT_ACTIONS.has(action) &&
senderIsOwner !== true &&
!gatewayClientScopes?.includes("operator.admin")
) {
throw new Error("iMessage group management requires an owner or operator.admin requester.");
}
const runtime = await loadIMessageActionsRuntime();
const account = resolveIMessageAccount({
cfg,
accountId: accountId ?? undefined,
});
assertActionEnabled(action, account.config.actions);
const cliPathForProbe = account.config.cliPath?.trim() || "imsg";
let privateApiStatus = getCachedIMessagePrivateApiStatus(cliPathForProbe);
const probePrivateApiStatus = async (forceRefresh = false) => {
privateApiStatus = await probeIMessagePrivateApi(
cliPathForProbe,
account.config.probeTimeoutMs ?? DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS,
forceRefresh ? { forceRefresh: true } : undefined,
);
};
const assertPrivateApiEnabled = async () => {
if (privateApiStatus?.available !== true) {
// Probe lazily: the running gateway only populates the cache via the
// status adapter, which doesn't fire eagerly on first dispatch. Run
// an inline probe so the first react/send-rich attempt after `imsg
// launch` succeeds without requiring a manual `channels status`.
await probePrivateApiStatus();
}
if (!privateApiStatus?.available) {
// Surface the silent-drop case: the throw becomes a tool-result
// `success:false`, which the model may or may not relay clearly to the
// user. Without a log line, an operator has no signal that a reply
// disappeared — they only see "channel: running" in `channels status`.
// Common cause: gateway restart un-injects the imsg-bridge-helper.dylib
// from Messages.app while imsg rpc keeps running.
// imsg's status message names the actual blocker (SIP, library
// validation, macOS 26 AMFI gate) — append it so the operator isn't
// told to "run imsg launch" when the OS is rejecting the dylib.
const reason = privateApiStatus?.statusMessage
? ` imsg reports: ${privateApiStatus.statusMessage}`
: "";
log.warn(
`iMessage ${action} blocked: private API bridge unavailable (accountId=${account.accountId}, cliPath=${cliPathForProbe}). Run \`imsg launch\` to re-inject the dylib, then \`openclaw channels status --probe\` to refresh.${reason}`,
);
throw new Error(
`iMessage ${action} requires the imsg private API bridge. Run imsg launch, then openclaw channels status --probe to refresh capability detection.${reason}`,
);
}
};
const opts = {
cliPath: account.config.cliPath?.trim() || "imsg",
dbPath: account.config.dbPath?.trim() || undefined,
timeoutMs: account.config.probeTimeoutMs,
chatGuid: "",
};
const chatGuid = async () =>
await resolveChatGuid({
action,
actionParams: params,
currentChannelId: toolContext?.currentChannelId,
runtime,
options: opts,
});
const messageId = (resolveOpts?: { requireFromMe?: boolean }) => {
const chatContext = buildChatContextFromActionParams({
actionParams: params,
currentChannelId: toolContext?.currentChannelId,
});
const fallbackContext = { ...chatContext, accountId: account.accountId };
return runtime.resolveIMessageMessageId(
readMessageIdWithChatFallback(params, fallbackContext),
{
requireKnownShortId: true,
chatContext,
...(resolveOpts?.requireFromMe ? { requireFromMe: true } : {}),
},
);
};
if (action === "react") {
await assertPrivateApiEnabled();
const { emoji, remove, isEmpty } = readReactionParams(params, {
removeErrorMessage: "Emoji is required to remove an iMessage reaction.",
});
const reaction = mapTapbackReaction(emoji);
const TAPBACK_KINDS = ["love", "like", "dislike", "laugh", "emphasize", "question"] as const;
// For add operations we need a recognized tapback kind. For remove
// operations, the agent may not remember which kind it added — when
// the emoji is empty or unrecognized but `remove: true`, fan out a
// remove against every known kind. The bridge no-ops kinds that
// weren't there, so this is safe and matches user intent ("undo my
// reaction, whatever it was").
if (!remove && (isEmpty || !reaction)) {
throw new Error(
"iMessage react supports love, like, dislike, laugh, emphasize, and question tapbacks.",
);
}
const resolvedMessageId = messageId();
const partIndex = readNonNegativeIntegerParam(params, "partIndex");
const resolvedChatGuid = await chatGuid();
const reactionsToSend = remove && !reaction ? [...TAPBACK_KINDS] : reaction ? [reaction] : [];
for (const kind of reactionsToSend) {
await runtime.sendReaction({
chatGuid: resolvedChatGuid,
messageId: resolvedMessageId,
reaction: kind,
remove: remove || undefined,
partIndex: typeof partIndex === "number" ? partIndex : undefined,
options: { ...opts, chatGuid: resolvedChatGuid },
});
}
return jsonResult({ ok: true, ...(remove ? { removed: true } : { added: reaction }) });
}
if (action === "edit") {
await assertPrivateApiEnabled();
const resolvedMessageId = messageId({ requireFromMe: true });
const text =
readStringParam(params, "text") ??
readStringParam(params, "newText") ??
readStringParam(params, "message");
if (!text) {
throw new Error("iMessage edit requires text, newText, or message.");
}
const partIndex = readNonNegativeIntegerParam(params, "partIndex");
const backwardsCompatMessage = readStringParam(params, "backwardsCompatMessage");
const resolvedChatGuid = await chatGuid();
await runtime.editMessage({
chatGuid: resolvedChatGuid,
messageId: resolvedMessageId,
text,
backwardsCompatMessage: backwardsCompatMessage ?? undefined,
partIndex: typeof partIndex === "number" ? partIndex : undefined,
options: { ...opts, chatGuid: resolvedChatGuid },
});
return jsonResult({ ok: true, edited: resolvedMessageId });
}
if (action === "unsend") {
await assertPrivateApiEnabled();
const resolvedMessageId = messageId({ requireFromMe: true });
const partIndex = readNonNegativeIntegerParam(params, "partIndex");
const resolvedChatGuid = await chatGuid();
await runtime.unsendMessage({
chatGuid: resolvedChatGuid,
messageId: resolvedMessageId,
partIndex: typeof partIndex === "number" ? partIndex : undefined,
options: { ...opts, chatGuid: resolvedChatGuid },
});
return jsonResult({ ok: true, unsent: resolvedMessageId });
}
if (action === "reply") {
await assertPrivateApiEnabled();
const resolvedMessageId = messageId();
const text = readMessageText(params);
if (!text) {
throw new Error("iMessage reply requires text or message.");
}
const attachment = extractReplyAttachment(params);
if (attachment) {
if (attachment.spec === null) {
throw new Error(
`iMessage reply rejected \`${attachment.bypassParam}\` because it did not pass through the outbound media resolver. ` +
'Pass a base64 `buffer` + `filename` directly, or invoke message(action: "reply") through the runner so the resolver ' +
"can validate the path against mediaLocalRoots/sandbox/size before sending.",
);
}
// Reply-with-attachment requires the `imsg send-rich --file` flag
// (openclaw/imsg#114). Older imsg builds reject the option, so
// refuse loudly here rather than letting send-rich ship the text
// alone and silently drop the attachment — the original symptom
// of openclaw/openclaw#79822.
if (privateApiStatus?.cliCapabilities?.sendRichSupportsAttachment !== true) {
throw new Error(
"iMessage reply with an attachment needs an imsg build that exposes `send-rich --file` " +
"(openclaw/imsg#114). Upgrade imsg, or use action 'upload-file' (with filePath/filename) " +
"or action 'send' (with media) to deliver the file plus a separate 'reply' for any text.",
);
}
}
const partIndex = readNonNegativeIntegerParam(params, "partIndex");
const resolvedChatGuid = await chatGuid();
const result = await runtime.sendRichMessage({
chatGuid: resolvedChatGuid,
text,
replyToMessageId: resolvedMessageId,
partIndex: typeof partIndex === "number" ? partIndex : undefined,
attachment: attachment?.spec ?? undefined,
options: { ...opts, chatGuid: resolvedChatGuid },
});
rememberOutboundBridgeMessage({
accountId: account.accountId,
messageId: result.messageId,
chatGuid: resolvedChatGuid,
});
return jsonResult({ ok: true, messageId: result.messageId, repliedTo: resolvedMessageId });
}
if (action === "sendWithEffect") {
await assertPrivateApiEnabled();
const text = readMessageText(params);
const effectId = effectIdFromParam(
readStringParam(params, "effectId") ?? readStringParam(params, "effect"),
);
if (!text || !effectId) {
throw new Error("iMessage sendWithEffect requires text/message and effect/effectId.");
}
const resolvedChatGuid = await chatGuid();
const result = await runtime.sendRichMessage({
chatGuid: resolvedChatGuid,
text,
effectId,
options: { ...opts, chatGuid: resolvedChatGuid },
});
rememberOutboundBridgeMessage({
accountId: account.accountId,
messageId: result.messageId,
chatGuid: resolvedChatGuid,
});
return jsonResult({ ok: true, messageId: result.messageId, effect: effectId });
}
if (action === "renameGroup") {
await assertPrivateApiEnabled();
const displayName = readStringParam(params, "displayName") ?? readStringParam(params, "name");
if (!displayName) {
throw new Error("iMessage renameGroup requires displayName or name.");
}
const resolvedChatGuid = await chatGuid();
await runtime.renameGroup({
chatGuid: resolvedChatGuid,
displayName,
options: { ...opts, chatGuid: resolvedChatGuid },
});
return jsonResult({ ok: true, renamed: resolvedChatGuid, displayName });
}
if (action === "setGroupIcon") {
await assertPrivateApiEnabled();
const filename =
readStringParam(params, "filename") ?? readStringParam(params, "name") ?? "icon.png";
const resolvedChatGuid = await chatGuid();
await runtime.setGroupIcon({
chatGuid: resolvedChatGuid,
buffer: decodeBase64Buffer(params, action),
filename,
options: { ...opts, chatGuid: resolvedChatGuid },
});
return jsonResult({ ok: true, chatGuid: resolvedChatGuid, iconSet: true });
}
if (action === "addParticipant" || action === "removeParticipant") {
await assertPrivateApiEnabled();
const address = readStringParam(params, "address") ?? readStringParam(params, "participant");
if (!address) {
throw new Error(`iMessage ${action} requires address or participant.`);
}
const resolvedChatGuid = await chatGuid();
if (action === "addParticipant") {
await runtime.addParticipant({
chatGuid: resolvedChatGuid,
address,
options: { ...opts, chatGuid: resolvedChatGuid },
});
return jsonResult({ ok: true, added: address, chatGuid: resolvedChatGuid });
}
await runtime.removeParticipant({
chatGuid: resolvedChatGuid,
address,
options: { ...opts, chatGuid: resolvedChatGuid },
});
return jsonResult({ ok: true, removed: address, chatGuid: resolvedChatGuid });
}
if (action === "leaveGroup") {
await assertPrivateApiEnabled();
const resolvedChatGuid = await chatGuid();
await runtime.leaveGroup({
chatGuid: resolvedChatGuid,
options: { ...opts, chatGuid: resolvedChatGuid },
});
return jsonResult({ ok: true, left: resolvedChatGuid });
}
if (action === "sendAttachment" || action === "upload-file") {
await assertPrivateApiEnabled();
const filename = readStringParam(params, "filename", { required: true });
const asVoice = readBooleanParam(params, "asVoice") ?? readBooleanParam(params, "as_voice");
const resolvedChatGuid = await chatGuid();
const result = await runtime.sendAttachment({
chatGuid: resolvedChatGuid,
buffer: decodeBase64Buffer(params, action),
filename,
asVoice: asVoice ?? undefined,
options: { ...opts, chatGuid: resolvedChatGuid },
});
rememberOutboundBridgeMessage({
accountId: account.accountId,
messageId: result.messageId,
chatGuid: resolvedChatGuid,
});
return jsonResult({ ok: true, messageId: result.messageId });
}
if (action === "poll") {
await assertPrivateApiEnabled();
if (privateApiStatus?.selectors?.pollPayloadMessage !== true) {
await probePrivateApiStatus(true);
}
if (privateApiStatus?.selectors?.pollPayloadMessage !== true) {
throw new Error(
"iMessage poll requires an imsg bridge that advertises the pollPayloadMessage selector. Update imsg, run imsg launch to re-inject the bridge, then run openclaw channels status --probe to refresh capability detection.",
);
}
// Shared `message`-tool poll params (see src/poll-params.ts): pollQuestion
// + pollOption[]. normalizePollInput trims, enforces >=2 choices, and caps
// at Apple's 12-option Messages limit so the bridge send cannot exceed it.
const question = readStringParam(params, "pollQuestion", { required: true });
const rawChoices = readStringArrayParam(params, "pollOption", { required: true });
const poll = normalizePollInput({ question, options: rawChoices }, { maxOptions: 12 });
const resolvedChatGuid = await chatGuid();
const result = await runtime.sendPoll({
chatGuid: resolvedChatGuid,
question: poll.question,
choices: poll.options,
options: { ...opts, chatGuid: resolvedChatGuid },
});
rememberOutboundBridgeMessage({
accountId: account.accountId,
messageId: result.messageId,
chatGuid: resolvedChatGuid,
});
return jsonResult({ ok: true, messageId: result.messageId });
}
if (action === "poll-vote") {
await assertPrivateApiEnabled();
if (
privateApiStatus?.selectors?.pollVoteMessage !== true ||
!imessageRpcSupportsMethod(privateApiStatus, "poll.vote")
) {
await probePrivateApiStatus(true);
}
if (privateApiStatus?.selectors?.pollVoteMessage !== true) {
throw new Error(
"iMessage poll-vote requires an imsg bridge that advertises the pollVoteMessage selector. Update imsg, run imsg launch to re-inject the bridge, then run openclaw channels status --probe to refresh capability detection.",
);
}
// A previously injected helper can be newer than cliPath. The selector
// proves native construction; rpc_methods proves this binary has vote.
if (!imessageRpcSupportsMethod(privateApiStatus, "poll.vote")) {
throw new Error(
"iMessage poll-vote requires an imsg build that advertises the poll.vote capability. Update imsg, then run openclaw channels status --probe to refresh capability detection.",
);
}
// The poll being voted on is an inbound message; the agent references it
// by the shared `pollId` param or a message id, which we resolve to the
// poll's full GUID through the same reply cache the react path uses. When
// the model omits an explicit reference, default to the current inbound
// message id — the poll it is replying to — mirroring how reaction-like
// actions default their target (resolveReactionMessageId). Without this a
// vote that names only the option index fails the required-reference
// check below even though the intended poll is unambiguous.
const pollRef =
readStringParam(params, "pollId") ??
readStringParam(params, "pollGuid") ??
readStringParam(params, "messageId") ??
(toolContext?.currentMessageId != null ? String(toolContext.currentMessageId) : undefined);
if (!pollRef) {
throw new Error("iMessage poll-vote requires the poll message id (pollId or messageId).");
}
const chatContext = buildChatContextFromActionParams({
actionParams: params,
currentChannelId: toolContext?.currentChannelId,
});
const pollGuid = runtime.resolveIMessageMessageId(pollRef, {
requireKnownShortId: true,
chatContext,
});
// Option selection: 1-based index, explicit UUID, or option text — imsg
// resolves index/text to the stable optionIdentifier from the decoded poll.
// Require exactly one selector so a conflicting pair can't silently vote
// by precedence.
const optionIndex = readPositiveIntegerParam(params, "pollOptionIndex");
const optionId = readStringParam(params, "pollOptionId");
const optionText = readStringParam(params, "pollOptionText");
const selectorCount = [
optionIndex !== undefined,
Boolean(optionId),
Boolean(optionText),
].filter(Boolean).length;
if (selectorCount === 0) {
throw new Error(
"iMessage poll-vote requires pollOptionIndex, pollOptionId, or pollOptionText.",
);
}
if (selectorCount > 1) {
throw new Error(
"iMessage poll-vote requires exactly one of pollOptionIndex, pollOptionId, or pollOptionText.",
);
}
const resolvedChatGuid = await chatGuid();
const result = await runtime.sendPollVote({
chatGuid: resolvedChatGuid,
pollGuid,
optionIndex,
optionId: optionId ?? undefined,
optionText: optionText ?? undefined,
options: { ...opts, chatGuid: resolvedChatGuid },
});
rememberOutboundBridgeMessage({
accountId: account.accountId,
messageId: result.messageId,
chatGuid: resolvedChatGuid,
});
return jsonResult({
ok: true,
messageId: result.messageId,
...(result.optionText ? { pollVotedOption: result.optionText } : {}),
});
}
throw new Error(`Action ${action} is not supported for provider ${providerId}.`);
},
};

View File

@@ -0,0 +1,147 @@
// Imessage tests cover approval auth plugin behavior.
import { describe, expect, it } from "vitest";
import { getIMessageApprovalApprovers, imessageApprovalAuth } from "./approval-auth.js";
describe("imessageApprovalAuth", () => {
it("authorizes individual handles and ignores group/chat target entries", () => {
expect(
imessageApprovalAuth.authorizeActorAction({
cfg: { channels: { imessage: { allowFrom: ["+1 (555) 123-0000"] } } },
senderId: "+15551230000",
action: "approve",
approvalKind: "exec",
}),
).toEqual({ authorized: true });
expect(
getIMessageApprovalApprovers({
cfg: {
channels: {
imessage: {
allowFrom: ["chat_guid:iMessage;+;chat123", "chat_id:42"],
},
},
},
}),
).toEqual([]);
expect(
imessageApprovalAuth.authorizeActorAction({
cfg: { channels: { imessage: { allowFrom: ["+15551230000"] } } },
senderId: "+15551239999",
action: "approve",
approvalKind: "exec",
}),
).toEqual({
authorized: false,
reason: "❌ You are not authorized to approve exec requests on iMessage.",
});
});
it("authorizes lowercase-normalized email senders against canonical allowFrom", () => {
expect(
imessageApprovalAuth.authorizeActorAction({
cfg: { channels: { imessage: { allowFrom: ["Owner@Example.com"] } } },
senderId: "owner@example.com",
action: "approve",
approvalKind: "plugin",
}),
).toEqual({ authorized: true });
});
it("falls back to implicit same-chat authorization when no allowFrom is configured", () => {
expect(
getIMessageApprovalApprovers({
cfg: { channels: { imessage: { allowFrom: [] } } },
}),
).toEqual([]);
expect(
imessageApprovalAuth.authorizeActorAction({
cfg: { channels: { imessage: { allowFrom: [] } } },
senderId: "+15551230000",
action: "approve",
approvalKind: "exec",
}),
).toEqual({ authorized: true });
});
it("supports explicit wildcard approval approvers", () => {
expect(
imessageApprovalAuth.authorizeActorAction({
cfg: { channels: { imessage: { allowFrom: ["*"] } } },
senderId: "+15551230000",
action: "approve",
approvalKind: "plugin",
}),
).toEqual({ authorized: true });
});
it("strips imessage:/sms:/auto: service prefixes when normalizing approver entries", () => {
// The resolved approver list itself must contain the bare normalized
// handle — a previous bug rejected service-prefixed entries entirely,
// which silently fell back to the empty-approvers implicit-same-chat
// authorization and masked the regression. Assert the explicit list here
// so reaction resolution (which requires a non-empty approver list) works
// for service-prefixed allowFrom values too.
expect(
getIMessageApprovalApprovers({
cfg: { channels: { imessage: { allowFrom: ["imessage:+15551230000"] } } },
}),
).toEqual(["+15551230000"]);
expect(
getIMessageApprovalApprovers({
cfg: { channels: { imessage: { allowFrom: ["sms:+15551230001"] } } },
}),
).toEqual(["+15551230001"]);
expect(
getIMessageApprovalApprovers({
cfg: { channels: { imessage: { allowFrom: ["auto:Owner@Example.com"] } } },
}),
).toEqual(["owner@example.com"]);
// A sender that matches the normalized handle is explicitly authorized
// (not via the implicit same-chat fallback).
expect(
imessageApprovalAuth.authorizeActorAction({
cfg: { channels: { imessage: { allowFrom: ["imessage:+15551230000"] } } },
senderId: "+15551230000",
action: "approve",
approvalKind: "exec",
}),
).toEqual({ authorized: true });
// And a NON-matching sender is rejected — proving the entry was added
// to the approver list rather than collapsing to empty.
expect(
imessageApprovalAuth.authorizeActorAction({
cfg: { channels: { imessage: { allowFrom: ["imessage:+15551230000"] } } },
senderId: "+15559999999",
action: "approve",
approvalKind: "exec",
}),
).toEqual({
authorized: false,
reason: "❌ You are not authorized to approve exec requests on iMessage.",
});
});
it("rejects chat_id / chat_guid / chat_identifier as approver entries even with service prefixes", () => {
expect(
getIMessageApprovalApprovers({
cfg: {
channels: {
imessage: {
allowFrom: [
"chat_id:42",
"chat_guid:iMessage;+;chat42",
"chat_identifier:chat42@example.com",
"imessage:chat_id:43",
],
},
},
},
}),
).toEqual([]);
});
});

View File

@@ -0,0 +1,79 @@
// Imessage plugin module implements approval auth behavior.
import {
createResolvedApproverActionAuthAdapter,
resolveApprovalApprovers,
} from "openclaw/plugin-sdk/approval-auth-runtime";
import { resolveIMessageAccount } from "./accounts.js";
import { normalizeIMessageHandle } from "./targets.js";
type ApprovalKind = "exec" | "plugin";
export function normalizeIMessageApproverId(value: string | number): string | undefined {
const raw = String(value).trim();
if (!raw) {
return undefined;
}
// Normalize first so service-prefixed direct handles (`imessage:+...`,
// `sms:+...`, `auto:+...`) are stripped to their bare identifier before we
// decide whether to reject the entry. After normalization only the
// conversation-target prefixes (chat_id / chat_guid / chat_identifier) remain
// as illegal approver shapes — service-prefixed direct handles are valid
// approver values that map to a specific phone/email.
const normalized = normalizeIMessageHandle(raw);
if (
!normalized ||
normalized.startsWith("chat_id:") ||
normalized.startsWith("chat_guid:") ||
normalized.startsWith("chat_identifier:")
) {
return undefined;
}
return normalized;
}
function normalizeIMessageApproverEntry(value: string | number): string | undefined {
return String(value).trim() === "*" ? "*" : normalizeIMessageApproverId(value);
}
export function getIMessageApprovalApprovers(params: {
cfg: Parameters<typeof resolveIMessageAccount>[0]["cfg"];
accountId?: string | null;
}): string[] {
const account = resolveIMessageAccount({ cfg: params.cfg, accountId: params.accountId });
return resolveApprovalApprovers({
allowFrom: account.config.allowFrom,
normalizeApprover: normalizeIMessageApproverEntry,
});
}
const imessageResolvedApproverAuth = createResolvedApproverActionAuthAdapter({
channelLabel: "iMessage",
resolveApprovers: ({ cfg, accountId }) => getIMessageApprovalApprovers({ cfg, accountId }),
normalizeSenderId: (value) => normalizeIMessageApproverId(value),
});
export const imessageApprovalAuth = {
authorizeActorAction({
cfg,
accountId,
senderId,
approvalKind,
}: {
cfg: Parameters<typeof resolveIMessageAccount>[0]["cfg"];
accountId?: string | null;
senderId?: string | null;
action: "approve";
approvalKind: ApprovalKind;
}) {
if (getIMessageApprovalApprovers({ cfg, accountId }).includes("*")) {
return { authorized: true } as const;
}
return imessageResolvedApproverAuth.authorizeActorAction({
cfg,
accountId,
senderId,
action: "approve",
approvalKind,
});
},
};

View File

@@ -0,0 +1,275 @@
// Imessage tests cover approval handler plugin behavior.
import { beforeEach, describe, expect, it, vi } from "vitest";
import { imessageApprovalNativeRuntime } from "./approval-handler.runtime.js";
const sendMock = vi.hoisted(() => ({
sendMessageIMessage: vi.fn(),
}));
vi.mock("./send.js", () => ({
sendMessageIMessage: sendMock.sendMessageIMessage,
}));
describe("imessageApprovalNativeRuntime", () => {
it("renders shared reactions in pending exec approvals", async () => {
const payload = await imessageApprovalNativeRuntime.presentation.buildPendingPayload({
cfg: {} as never,
accountId: "default",
context: { accountId: "default" },
request: {
id: "exec-1",
request: {
command: "echo hi",
},
createdAtMs: 0,
expiresAtMs: 60_000,
},
approvalKind: "exec",
nowMs: 0,
view: {
approvalKind: "exec",
approvalId: "exec-1",
commandText: "echo hi",
actions: [
{
decision: "allow-once",
label: "Allow Once",
command: "/approve exec-1 allow-once",
style: "success",
},
{
decision: "deny",
label: "Deny",
command: "/approve exec-1 deny",
style: "danger",
},
],
} as never,
});
expect(payload.text).toContain("👍 Allow Once");
expect(payload.text).toContain("👎 Deny");
expect(payload.text).not.toContain("1⃣ Allow Once");
expect(payload.text).not.toContain("2⃣ Allow Always");
expect(payload.text).not.toContain("3⃣ Deny");
expect(payload.allowedDecisions).toEqual(["allow-once", "deny"]);
});
it("renders shared reactions in pending plugin approvals", async () => {
const payload = await imessageApprovalNativeRuntime.presentation.buildPendingPayload({
cfg: {} as never,
accountId: "default",
context: { accountId: "default" },
request: {
id: "plugin:abc",
request: {
title: "Allow Codex to use 1Password?",
description: "Allow Codex to use 1Password?",
pluginId: "openclaw-codex-app-server",
toolName: "codex_mcp_tool_approval",
severity: "warning",
allowedDecisions: ["allow-once", "allow-always", "deny"],
},
createdAtMs: 0,
expiresAtMs: 60_000,
},
approvalKind: "plugin",
nowMs: 0,
view: {
approvalKind: "plugin",
approvalId: "plugin:abc",
title: "Plugin approval required",
severity: "warning",
actions: [
{
decision: "allow-once",
label: "Allow Once",
command: "/approve plugin:abc allow-once",
style: "success",
},
{
decision: "allow-always",
label: "Allow Always",
command: "/approve plugin:abc allow-always",
style: "primary",
},
{
decision: "deny",
label: "Deny",
command: "/approve plugin:abc deny",
style: "danger",
},
],
} as never,
});
expect(payload.text).toContain("Plugin approval required");
expect(payload.text).toContain("Reply with: /approve plugin:abc allow-once|allow-always|deny");
expect(payload.text).toContain("👍 Allow Once");
expect(payload.text).toContain("♾️ Allow Always");
expect(payload.text).toContain("👎 Deny");
expect(payload.text).not.toContain("/approve <id>");
expect(payload.allowedDecisions).toEqual(["allow-once", "allow-always", "deny"]);
});
it("normalizes iMessage handle targets and carries account ids into prepared delivery", async () => {
await expect(
imessageApprovalNativeRuntime.transport.prepareTarget({
cfg: {} as never,
accountId: "ops",
context: { accountId: "ops" },
plannedTarget: {
surface: "origin",
reason: "preferred",
target: {
to: "+1 (555) 123-0000",
},
},
request: {
id: "exec-1",
request: { command: "echo hi" },
createdAtMs: 0,
expiresAtMs: 60_000,
},
approvalKind: "exec",
view: {
approvalKind: "exec",
approvalId: "exec-1",
commandText: "echo hi",
actions: [],
} as never,
pendingPayload: {
text: "pending",
allowedDecisions: ["allow-once"],
},
}),
).resolves.toEqual({
dedupeKey: expect.any(String),
target: {
to: "+15551230000",
accountId: "ops",
},
});
});
describe("deliverPending GUID-only binding", () => {
beforeEach(() => {
sendMock.sendMessageIMessage.mockReset();
});
const baseDeliverArgs = {
cfg: {} as never,
accountId: "default",
context: { accountId: "default" },
preparedTarget: { to: "+15551230000", accountId: "default" },
plannedTarget: {
surface: "origin" as const,
reason: "preferred" as const,
target: { to: "+15551230000" },
},
request: {
id: "exec-1",
request: { command: "echo hi" },
createdAtMs: 0,
expiresAtMs: 60_000,
},
approvalKind: "exec" as const,
view: {
approvalKind: "exec",
approvalId: "exec-1",
commandText: "echo hi",
actions: [],
} as never,
pendingPayload: {
text: "Reply with: /approve exec-1 allow-once",
allowedDecisions: ["allow-once" as const],
},
};
it("refuses to bind when the bridge returns only a numeric ROWID", async () => {
// Regression for ClawSweeper P1: native deliverPending must require a
// GUID for the binding because inbound `reacted_to_guid` is always a
// GUID — never the numeric ROWID. A bridge that returns just
// { message_id: 12345 } has no usable approval-reaction id.
sendMock.sendMessageIMessage.mockResolvedValue({
messageId: "12345",
sentText: "Reply with: /approve exec-1 allow-once",
receipt: { kind: "text" } as never,
});
await expect(
imessageApprovalNativeRuntime.transport.deliverPending(baseDeliverArgs),
).resolves.toBeNull();
});
it("binds against the GUID when the bridge returns one", async () => {
sendMock.sendMessageIMessage.mockResolvedValue({
messageId: "p:0/abc-123",
guid: "p:0/abc-123",
sentText: "Reply with: /approve exec-1 allow-once",
receipt: { kind: "text" } as never,
});
await expect(
imessageApprovalNativeRuntime.transport.deliverPending(baseDeliverArgs),
).resolves.toEqual({
accountId: "default",
to: "+15551230000",
conversation: { handle: "+15551230000" },
messageId: "p:0/abc-123",
});
});
it("refuses to bind when the bridge returns 'unknown' or 'ok' placeholders", async () => {
sendMock.sendMessageIMessage.mockResolvedValue({
messageId: "ok",
sentText: "Reply with: /approve exec-1 allow-once",
receipt: { kind: "text" } as never,
});
await expect(
imessageApprovalNativeRuntime.transport.deliverPending(baseDeliverArgs),
).resolves.toBeNull();
});
});
it("preserves group chat targets when preparing delivery", async () => {
await expect(
imessageApprovalNativeRuntime.transport.prepareTarget({
cfg: {} as never,
accountId: "default",
context: { accountId: "default" },
plannedTarget: {
surface: "approver-dm",
reason: "preferred",
target: {
to: "chat_guid:iMessage;+;chat42",
},
},
request: {
id: "exec-1",
request: { command: "echo hi" },
createdAtMs: 0,
expiresAtMs: 60_000,
},
approvalKind: "exec",
view: {
approvalKind: "exec",
approvalId: "exec-1",
commandText: "echo hi",
actions: [],
} as never,
pendingPayload: {
text: "pending",
allowedDecisions: ["allow-once"],
},
}),
).resolves.toEqual({
dedupeKey: expect.any(String),
target: {
to: "chat_guid:iMessage;+;chat42",
accountId: "default",
},
});
});
});

View File

@@ -0,0 +1,233 @@
// Imessage plugin module implements approval handler behavior.
import {
buildChannelApprovalExpiredText,
buildChannelApprovalResolvedText,
createChannelApprovalNativeRuntimeAdapter,
type PendingApprovalView,
resolvePreparedApprovalAccountId,
} from "openclaw/plugin-sdk/approval-handler-runtime";
import { buildChannelApprovalNativeTargetKey } from "openclaw/plugin-sdk/approval-native-runtime";
import { buildApprovalReactionPendingContent } from "openclaw/plugin-sdk/approval-reaction-runtime";
import type { ExecApprovalReplyDecision } from "openclaw/plugin-sdk/approval-reply-runtime";
import type {
ExecApprovalRequest,
PluginApprovalRequest,
} from "openclaw/plugin-sdk/approval-runtime";
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
import {
registerIMessageApprovalReactionTarget,
unregisterIMessageApprovalReactionTarget,
type IMessageApprovalConversationKey,
} from "./approval-reactions.js";
import { normalizeIMessageMessagingTarget } from "./normalize.js";
import { sendMessageIMessage } from "./send.js";
import { normalizeIMessageHandle, parseIMessageTarget } from "./targets.js";
const log = createSubsystemLogger("imessage/approvals");
type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest;
type IMessagePendingDelivery = {
text: string;
allowedDecisions: readonly ExecApprovalReplyDecision[];
};
type PreparedIMessageApprovalTarget = {
to: string;
accountId?: string;
};
type PendingIMessageApprovalEntry = {
accountId?: string;
to: string;
conversation: IMessageApprovalConversationKey;
messageId: string;
};
type IMessageFinalPayload = {
text: string;
};
function buildPendingPayload(params: {
request: ApprovalRequest;
approvalKind: "exec" | "plugin";
nowMs: number;
view: PendingApprovalView;
}): IMessagePendingDelivery {
const pendingContent = buildApprovalReactionPendingContent({
request: params.request,
view: params.view as never,
nowMs: params.nowMs,
});
return {
text: pendingContent.reactionPayload.text ?? "",
allowedDecisions: pendingContent.reactionPayload.allowedDecisions,
};
}
function buildConversationKeyForTarget(to: string): IMessageApprovalConversationKey | null {
try {
const parsed = parseIMessageTarget(to);
if (parsed.kind === "chat_id") {
return { chatId: parsed.chatId };
}
if (parsed.kind === "chat_guid") {
return { chatGuid: parsed.chatGuid };
}
if (parsed.kind === "chat_identifier") {
return { chatIdentifier: parsed.chatIdentifier };
}
const handle = normalizeIMessageHandle(parsed.to);
return handle ? { handle } : null;
} catch {
return null;
}
}
function shouldThreadApprovalUpdate(to: string): boolean {
try {
const parsed = parseIMessageTarget(to);
if (parsed.kind === "handle" && parsed.service === "sms") {
return false;
}
} catch {
return true;
}
return true;
}
export const imessageApprovalNativeRuntime = createChannelApprovalNativeRuntimeAdapter<
IMessagePendingDelivery,
PreparedIMessageApprovalTarget,
PendingIMessageApprovalEntry,
true,
IMessageFinalPayload
>({
eventKinds: ["exec", "plugin"],
availability: {
isConfigured: ({ context }) => Boolean(context),
shouldHandle: ({ context }) => Boolean(context),
},
presentation: {
buildPendingPayload: ({ request, approvalKind, nowMs, view }) =>
buildPendingPayload({ request, approvalKind, nowMs, view }),
buildResolvedResult: ({ request, resolved, view }) => ({
kind: "update",
payload: { text: buildChannelApprovalResolvedText({ request, resolved, view }) },
}),
buildExpiredResult: ({ request, view }) => ({
kind: "update",
payload: { text: buildChannelApprovalExpiredText({ request, view }) },
}),
},
transport: {
prepareTarget: ({ plannedTarget, accountId }) => {
const to = normalizeIMessageMessagingTarget(plannedTarget.target.to);
if (!to) {
return null;
}
const prepared: PreparedIMessageApprovalTarget = {
to,
accountId: resolvePreparedApprovalAccountId({
plannedAccountId: (plannedTarget.target as { accountId?: string | null }).accountId,
contextAccountId: accountId,
}),
};
return {
dedupeKey: `${prepared.accountId ?? ""}:${buildChannelApprovalNativeTargetKey({
to: prepared.to,
})}`,
target: prepared,
};
},
deliverPending: async ({ cfg, preparedTarget, pendingPayload }) => {
const result = await sendMessageIMessage(preparedTarget.to, pendingPayload.text, {
config: cfg,
...(preparedTarget.accountId ? { accountId: preparedTarget.accountId } : {}),
});
// Approval reaction bindings must use the GUID-only id (matches the
// inbound tapback's `reacted_to_guid`). When the bridge only returned a
// numeric ROWID / `ok` / `unknown`, `result.guid` is undefined — refuse
// to bind so the reaction shortcut won't silently miss a real tap.
const guid = result.guid;
if (!guid) {
return null;
}
const conversation = buildConversationKeyForTarget(preparedTarget.to);
if (!conversation) {
return null;
}
return {
...(preparedTarget.accountId ? { accountId: preparedTarget.accountId } : {}),
to: preparedTarget.to,
conversation,
messageId: guid,
};
},
updateEntry: async ({ cfg, entry, payload }) => {
await sendMessageIMessage(entry.to, payload.text, {
config: cfg,
...(entry.accountId ? { accountId: entry.accountId } : {}),
...(shouldThreadApprovalUpdate(entry.to) ? { replyToId: entry.messageId } : {}),
});
},
},
interactions: {
bindPending: ({ entry, request, view, pendingPayload }) => {
const accountId = entry.accountId?.trim();
if (!accountId) {
// An empty accountId would silently fail buildReactionTargetKey and
// leave the prompt with no way to be resolved via reaction. Surface
// this loudly instead of returning null with no signal.
log.error(
`imessage approvals: refusing to bind reaction target for ${request.id}; missing accountId in prepared entry`,
);
return null;
}
// If the approval is already past expiry by the time we bind (clock skew
// or delayed delivery), don't pretend to honor a 1ms TTL — refuse the
// binding so callers see an honest "no binding" and the prompt remains
// resolvable only via the /approve text fallback.
const ttlMs = view.expiresAtMs - Date.now();
if (ttlMs <= 0) {
log.error(
`imessage approvals: refusing to bind reaction target for ${request.id}; approval already expired at bind time`,
);
return null;
}
return registerIMessageApprovalReactionTarget({
accountId,
conversation: entry.conversation,
messageId: entry.messageId,
approvalId: request.id,
allowedDecisions: pendingPayload.allowedDecisions,
ttlMs,
})
? true
: null;
},
unbindPending: ({ entry }) => {
const accountId = entry.accountId?.trim();
if (!accountId) {
return;
}
unregisterIMessageApprovalReactionTarget({
accountId,
conversation: entry.conversation,
messageId: entry.messageId,
});
},
cancelDelivered: ({ entry }) => {
const accountId = entry.accountId?.trim();
if (!accountId) {
return;
}
unregisterIMessageApprovalReactionTarget({
accountId,
conversation: entry.conversation,
messageId: entry.messageId,
});
},
},
observe: {
onDeliveryError: ({ error, request }) => {
log.error(`imessage approvals: failed to send request ${request.id}: ${String(error)}`);
},
},
});

View File

@@ -0,0 +1,826 @@
// Imessage tests cover approval native plugin behavior.
import type {
ExecApprovalRequest,
PluginApprovalRequest,
} from "openclaw/plugin-sdk/approval-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { describe, expect, it } from "vitest";
import {
imessageApprovalCapability,
shouldSuppressLocalIMessageExecApprovalPrompt,
} from "./approval-native.js";
type IMessageConfig = NonNullable<NonNullable<OpenClawConfig["channels"]>["imessage"]>;
function buildConfig(
params: {
imessage?: Partial<IMessageConfig>;
approvals?: OpenClawConfig["approvals"];
} = {},
): OpenClawConfig {
return {
channels: {
imessage: {
enabled: true,
...params.imessage,
},
},
approvals: params.approvals,
} as OpenClawConfig;
}
function buildExecRequest(
turnSourceTo: string,
overrides: Partial<ExecApprovalRequest["request"]> = {},
): ExecApprovalRequest {
return {
id: "exec-1",
request: {
command: "echo hi",
agentId: "main",
turnSourceChannel: "imessage",
turnSourceTo,
turnSourceAccountId: "default",
sessionKey: `agent:main:imessage:${turnSourceTo}`,
...overrides,
},
createdAtMs: 0,
expiresAtMs: 1000,
};
}
function buildPluginRequest(
turnSourceTo: string,
overrides: Partial<PluginApprovalRequest["request"]> = {},
): PluginApprovalRequest {
return {
id: "plugin:approval-1",
request: {
title: "Plugin approval",
description: "Allow plugin action",
agentId: "main",
turnSourceChannel: "imessage",
turnSourceTo,
turnSourceAccountId: "default",
sessionKey: `agent:main:imessage:${turnSourceTo}`,
...overrides,
},
createdAtMs: 0,
expiresAtMs: 1000,
};
}
function nativeShouldHandle(params: {
cfg: OpenClawConfig;
request: ExecApprovalRequest | PluginApprovalRequest;
accountId?: string | null;
}) {
return imessageApprovalCapability.nativeRuntime?.availability.shouldHandle({
cfg: params.cfg,
accountId: params.accountId ?? "default",
context: {},
request: params.request,
});
}
function buildLocalApprovalPayload(
params: {
approvalKind?: "exec" | "plugin";
agentId?: string | null;
sessionKey?: string | null;
} = {},
) {
return {
text: "Approval required.",
channelData: {
execApproval: {
approvalId: params.approvalKind === "plugin" ? "plugin:approval-1" : "exec-1",
approvalSlug: params.approvalKind === "plugin" ? "plugin:approval-1" : "exec-1",
approvalKind: params.approvalKind ?? "exec",
agentId: params.agentId,
sessionKey: params.sessionKey,
},
},
};
}
describe("imessage approval capability", () => {
it("disables native approvals when no top-level approvals config is set", () => {
const cfg = buildConfig();
const execRequest = buildExecRequest("+15551230000");
const pluginRequest = buildPluginRequest("+15551230000");
expect(
imessageApprovalCapability?.getActionAvailabilityState?.({
cfg,
accountId: "default",
action: "approve",
approvalKind: "exec",
}),
).toEqual({ kind: "disabled" });
expect(
imessageApprovalCapability.native?.describeDeliveryCapabilities({
cfg,
accountId: "default",
approvalKind: "exec",
request: execRequest,
}).enabled,
).toBe(false);
expect(nativeShouldHandle({ cfg, request: execRequest })).toBe(false);
expect(nativeShouldHandle({ cfg, request: pluginRequest })).toBe(false);
});
it("allows session-mode exec delivery for matching iMessage origins", () => {
const cfg = buildConfig({ approvals: { exec: { enabled: true } } });
const request = buildExecRequest("+15551230000");
expect(
imessageApprovalCapability.native?.describeDeliveryCapabilities({
cfg,
accountId: "default",
approvalKind: "exec",
request,
}),
).toEqual({
enabled: true,
preferredSurface: "origin",
supportsOriginSurface: true,
supportsApproverDmSurface: false,
notifyOriginWhenDmOnly: true,
});
expect(nativeShouldHandle({ cfg, request })).toBe(true);
});
it("keeps exec and plugin forwarding gates independent", () => {
const execOnly = buildConfig({ approvals: { exec: { enabled: true } } });
const pluginOnly = buildConfig({ approvals: { plugin: { enabled: true } } });
expect(nativeShouldHandle({ cfg: execOnly, request: buildPluginRequest("+15551230000") })).toBe(
false,
);
expect(nativeShouldHandle({ cfg: pluginOnly, request: buildExecRequest("+15551230000") })).toBe(
false,
);
expect(
nativeShouldHandle({ cfg: pluginOnly, request: buildPluginRequest("+15551230000") }),
).toBe(true);
});
it("does not use session mode for non-iMessage-origin requests", () => {
const cfg = buildConfig({ approvals: { exec: { enabled: true } } });
const request = buildExecRequest("", {
turnSourceChannel: "slack",
turnSourceTo: "C123",
sessionKey: "agent:main:slack:channel:c123",
});
expect(nativeShouldHandle({ cfg, request })).toBe(false);
expect(
imessageApprovalCapability.native?.describeDeliveryCapabilities({
cfg,
accountId: "default",
approvalKind: "exec",
request,
}).enabled,
).toBe(false);
});
it("rejects group origin targets when no approvers are configured", () => {
const cfg = buildConfig({ approvals: { exec: { enabled: true } } });
const request = buildExecRequest("chat_guid:iMessage;+;chat42");
expect(
imessageApprovalCapability.native?.resolveOriginTarget?.({
cfg,
accountId: "default",
approvalKind: "exec",
request,
}),
).toBeNull();
});
it("allows group origin targets when explicit approvers are configured", () => {
const cfg = buildConfig({
imessage: { allowFrom: ["+15551230000"] },
approvals: { exec: { enabled: true } },
});
const request = buildExecRequest("chat_guid:iMessage;+;chat42");
expect(
imessageApprovalCapability.native?.resolveOriginTarget?.({
cfg,
accountId: "default",
approvalKind: "exec",
request,
}),
).toEqual({
to: "chat_guid:iMessage;+;chat42",
accountId: "default",
});
});
it("resolves approver-dm targets from channels.imessage.allowFrom when the request is session-eligible", () => {
const cfg = buildConfig({
imessage: { allowFrom: ["+15551230000", "owner@example.com"] },
approvals: { exec: { enabled: true } },
});
const request = buildExecRequest("+15551239999");
const targets = imessageApprovalCapability.native?.resolveApproverDmTargets?.({
cfg,
accountId: "default",
approvalKind: "exec",
request,
});
expect(targets).toEqual(
expect.arrayContaining([
expect.objectContaining({ to: "+15551230000" }),
expect.objectContaining({ to: "owner@example.com" }),
]),
);
});
it("uses target-mode config for requestless availability without native runtime handling", () => {
const cfg = buildConfig({
approvals: {
exec: {
enabled: true,
mode: "targets",
targets: [{ channel: "imessage", to: "+15551230000" }],
},
},
});
const request = buildExecRequest("+15551230000");
expect(
imessageApprovalCapability?.getActionAvailabilityState?.({
cfg,
accountId: "default",
action: "approve",
approvalKind: "exec",
}),
).toEqual({ kind: "enabled" });
expect(
imessageApprovalCapability.nativeRuntime?.availability.isConfigured({
cfg,
accountId: "default",
context: {},
}),
).toBe(false);
expect(nativeShouldHandle({ cfg, request })).toBe(false);
});
it("disables delivery when the iMessage channel is disabled", () => {
const cfg = buildConfig({
imessage: { enabled: false },
approvals: { exec: { enabled: true } },
});
const request = buildExecRequest("+15551230000");
expect(
imessageApprovalCapability.native?.describeDeliveryCapabilities({
cfg,
accountId: "default",
approvalKind: "exec",
request,
}).enabled,
).toBe(false);
expect(nativeShouldHandle({ cfg, request })).toBe(false);
});
it("renders thumbs-only reaction hints in exec approval prompts", () => {
const payload = imessageApprovalCapability.render?.exec?.buildPendingPayload?.({
cfg: buildConfig(),
request: buildExecRequest("+15551230000"),
target: { channel: "imessage", to: "+15551230000", source: "target" },
nowMs: 0,
});
expect(payload?.text).toContain("👍 Allow Once");
expect(payload?.text).toContain("👎 Deny");
});
it("renders thumbs-only reaction hints in plugin approval prompts and respects allowed decisions", () => {
const payload = imessageApprovalCapability.render?.plugin?.buildPendingPayload?.({
cfg: buildConfig(),
request: buildPluginRequest("+15551230000", {
allowedDecisions: ["allow-once", "deny"],
}) as never,
target: { channel: "imessage", to: "+15551230000", source: "target" },
nowMs: 0,
});
expect(payload?.text).toContain("👍 Allow Once");
expect(payload?.text).toContain("👎 Deny");
expect(payload?.text).not.toContain("Allow Always");
});
it("renders target-mode exec prompts with concrete thumbs-only reaction choices", () => {
const cfg = buildConfig({
approvals: {
exec: {
enabled: true,
mode: "targets",
targets: [{ channel: "imessage", to: "+15551230000" }],
},
},
});
const request = buildExecRequest("+15551230000", {
ask: "always",
cwd: "/tmp/work",
host: "gateway",
});
const payload = imessageApprovalCapability.render?.exec?.buildPendingPayload?.({
cfg,
request,
target: { channel: "imessage", to: "+15551230000", source: "target" },
nowMs: 0,
});
const text = payload?.text ?? "";
expect(text).toContain("/approve exec-1 allow-once");
expect(text).toContain("React with:");
expect(text).toContain("👍 Allow Once");
expect(text).toContain("👎 Deny");
expect(text).not.toContain("<id>");
expect(text).not.toContain("1⃣ Allow Once");
expect(text).not.toContain("2⃣ Allow Always");
expect(text).not.toContain("3⃣ Deny");
expect(text.indexOf("React with:")).toBeLessThan(text.indexOf("/approve exec-1 allow-once"));
});
it("renders target-mode plugin prompts with concrete thumbs-only reaction choices", () => {
const cfg = buildConfig({
approvals: {
plugin: {
enabled: true,
mode: "targets",
targets: [{ channel: "imessage", to: "+15551230000" }],
},
},
});
const request = buildPluginRequest("+15551230000", {
allowedDecisions: ["allow-once", "allow-always", "deny"],
});
const payload = imessageApprovalCapability.render?.plugin?.buildPendingPayload?.({
cfg,
request: request as never,
target: { channel: "imessage", to: "+15551230000", source: "target" },
nowMs: 0,
});
expect(payload?.text).toContain("/approve plugin:approval-1 allow-once");
expect(payload?.text).toContain(
"Reply with: /approve plugin:approval-1 allow-once|allow-always|deny",
);
expect(payload?.text).toContain("React with:");
expect(payload?.text).toContain("👍 Allow Once");
expect(payload?.text).toContain("👎 Deny");
expect(payload?.text).not.toContain("1⃣ Allow Once");
expect(payload?.text).not.toContain("2⃣ Allow Always");
expect(payload?.text).not.toContain("3⃣ Deny");
expect(payload?.text).not.toContain("<id>");
});
it("does not report target-mode availability when no iMessage target matches", () => {
const cfg = buildConfig({
approvals: {
exec: {
enabled: true,
mode: "targets",
targets: [{ channel: "slack", to: "C123" }],
},
},
});
expect(
imessageApprovalCapability?.getActionAvailabilityState?.({
cfg,
accountId: "default",
action: "approve",
approvalKind: "exec",
}),
).toEqual({ kind: "disabled" });
});
it("applies agent and session filters to native handling", () => {
const request = buildExecRequest("+15551230000", {
agentId: "main",
sessionKey: "agent:main:imessage:+15551230000",
});
const blockedByAgent = buildConfig({
approvals: { exec: { enabled: true, agentFilter: ["other"] } },
});
const blockedBySession = buildConfig({
approvals: { exec: { enabled: true, sessionFilter: ["telegram"] } },
});
expect(nativeShouldHandle({ cfg: blockedByAgent, request })).toBe(false);
expect(nativeShouldHandle({ cfg: blockedBySession, request })).toBe(false);
});
it("matches account-scoped top-level iMessage targets only for that account", () => {
const cfg = buildConfig({
imessage: {
accounts: {
work: { enabled: true },
},
} as Partial<IMessageConfig>,
approvals: {
exec: {
enabled: true,
mode: "targets",
targets: [{ channel: "imessage", to: "+15551230000", accountId: "work" }],
},
},
});
expect(
imessageApprovalCapability?.getActionAvailabilityState?.({
cfg,
accountId: "default",
action: "approve",
approvalKind: "exec",
}),
).toEqual({ kind: "disabled" });
expect(
imessageApprovalCapability?.getActionAvailabilityState?.({
cfg,
accountId: "work",
action: "approve",
approvalKind: "exec",
}),
).toEqual({ kind: "enabled" });
});
it("suppresses forwarding fallback only when the exact session-origin native target matches", () => {
const cfg = buildConfig({ approvals: { exec: { enabled: true } } });
const request = buildExecRequest("+15551230000");
const shouldSuppress = imessageApprovalCapability.delivery?.shouldSuppressForwardingFallback;
expect(
shouldSuppress?.({
cfg,
approvalKind: "exec",
target: {
channel: "imessage",
to: "+15551230000",
accountId: "default",
source: "session",
},
request,
}),
).toBe(true);
expect(
shouldSuppress?.({
cfg,
approvalKind: "exec",
target: {
channel: "imessage",
to: "+15550000000",
accountId: "default",
source: "session",
},
request,
}),
).toBe(false);
});
it("does not suppress target-only forwarding when native delivery cannot bind that target", () => {
// Locks down the behavior the live Lobster deploy exercised: with
// mode=targets and no matching iMessage session-origin, the suppression
// gate must stay off so the legacy forwarding path can deliver the
// prompt. Regressing this would leave targets-only operators with no
// delivery path (native runtime requires session-mode availability).
const cfg = buildConfig({
approvals: {
exec: {
enabled: true,
mode: "targets",
targets: [{ channel: "imessage", to: "+15550000000" }],
},
},
});
expect(
imessageApprovalCapability.delivery?.shouldSuppressForwardingFallback?.({
cfg,
approvalKind: "exec",
target: { channel: "imessage", to: "+15550000000", source: "target" },
request: buildExecRequest("+15551230000"),
}),
).toBe(false);
});
it("suppresses both-mode explicit targets that omit the origin account id", () => {
const cfg = buildConfig({
approvals: {
exec: {
enabled: true,
mode: "both",
targets: [{ channel: "imessage", to: "+15551230000" }],
},
},
});
expect(
imessageApprovalCapability.delivery?.shouldSuppressForwardingFallback?.({
cfg,
approvalKind: "exec",
target: { channel: "imessage", to: "+15551230000", source: "target" },
request: buildExecRequest("+15551230000"),
}),
).toBe(true);
});
it("suppresses both-mode unscoped targets through the configured default iMessage account", () => {
const cfg = buildConfig({
imessage: {
defaultAccount: "work",
accounts: {
default: { enabled: true },
work: { enabled: true },
},
} as Partial<IMessageConfig>,
approvals: {
exec: {
enabled: true,
mode: "both",
targets: [{ channel: "imessage", to: "+15551230000" }],
},
},
});
expect(
imessageApprovalCapability.delivery?.shouldSuppressForwardingFallback?.({
cfg,
approvalKind: "exec",
target: { channel: "imessage", to: "+15551230000", source: "target" },
request: buildExecRequest("+15551230000", {
turnSourceAccountId: "work",
}),
}),
).toBe(true);
});
it("allows group-origin tapback approvals only after exec forwarding and approvers are configured", () => {
const request = buildExecRequest("chat_guid:iMessage;+;chat42");
const withoutApprovers = buildConfig({ approvals: { exec: { enabled: true } } });
const withApprovers = buildConfig({
imessage: { allowFrom: ["+15551230000"] },
approvals: { exec: { enabled: true } },
});
expect(
imessageApprovalCapability.native?.resolveOriginTarget?.({
cfg: withoutApprovers,
accountId: "default",
approvalKind: "exec",
request,
}),
).toBeNull();
expect(
imessageApprovalCapability.native?.resolveOriginTarget?.({
cfg: withApprovers,
accountId: "default",
approvalKind: "exec",
request,
}),
).toEqual({
to: "chat_guid:iMessage;+;chat42",
accountId: "default",
});
});
});
describe("shouldSuppressLocalIMessageExecApprovalPrompt", () => {
const activeExecHint = {
kind: "approval-pending",
approvalKind: "exec",
nativeRouteActive: true,
} as const;
it("suppresses eligible session-mode exec approval prompts", () => {
const cfg = buildConfig({
imessage: { allowFrom: ["+15551230000"] },
approvals: {
exec: {
enabled: true,
agentFilter: ["main"],
},
},
});
expect(
shouldSuppressLocalIMessageExecApprovalPrompt({
cfg,
accountId: "default",
payload: buildLocalApprovalPayload({
agentId: null,
sessionKey: "agent:main:imessage:+15551230000",
}),
hint: activeExecHint,
}),
).toBe(true);
});
it("keeps local prompts for disabled, target-only, inactive, or non-exec cases", () => {
const enabledConfig = buildConfig({
imessage: { allowFrom: ["+15551230000"] },
approvals: { exec: { enabled: true } },
});
const payload = buildLocalApprovalPayload({
agentId: "main",
sessionKey: "agent:main:imessage:+15551230000",
});
expect(
shouldSuppressLocalIMessageExecApprovalPrompt({
cfg: buildConfig(),
payload,
hint: activeExecHint,
}),
).toBe(false);
expect(
shouldSuppressLocalIMessageExecApprovalPrompt({
cfg: buildConfig({
imessage: { allowFrom: ["+15551230000"] },
approvals: { exec: { enabled: false } },
}),
payload,
hint: activeExecHint,
}),
).toBe(false);
expect(
shouldSuppressLocalIMessageExecApprovalPrompt({
cfg: buildConfig({
imessage: { allowFrom: ["+15551230000"] },
approvals: {
exec: {
enabled: true,
mode: "targets",
targets: [{ channel: "imessage", to: "+15551230000" }],
},
},
}),
payload,
hint: activeExecHint,
}),
).toBe(false);
expect(
shouldSuppressLocalIMessageExecApprovalPrompt({
cfg: enabledConfig,
payload,
hint: { ...activeExecHint, nativeRouteActive: false },
}),
).toBe(false);
expect(
shouldSuppressLocalIMessageExecApprovalPrompt({
cfg: enabledConfig,
payload: buildLocalApprovalPayload({ approvalKind: "plugin" }),
hint: activeExecHint,
}),
).toBe(false);
expect(
shouldSuppressLocalIMessageExecApprovalPrompt({
cfg: enabledConfig,
payload: { text: "Approval required." },
hint: activeExecHint,
}),
).toBe(false);
});
it("suppresses direct same-chat iMessage prompts without explicit approvers", () => {
const cfg = buildConfig({
approvals: { exec: { enabled: true } },
});
expect(
shouldSuppressLocalIMessageExecApprovalPrompt({
cfg,
payload: buildLocalApprovalPayload({
agentId: "main",
sessionKey: "agent:main:imessage:+15551230000",
}),
hint: activeExecHint,
}),
).toBe(true);
expect(
shouldSuppressLocalIMessageExecApprovalPrompt({
cfg,
accountId: "default",
payload: buildLocalApprovalPayload({
agentId: "main",
sessionKey: "agent:main:imessage:direct:+15551230000",
}),
hint: activeExecHint,
}),
).toBe(true);
expect(
shouldSuppressLocalIMessageExecApprovalPrompt({
cfg,
accountId: "default",
payload: buildLocalApprovalPayload({
agentId: "main",
sessionKey: "agent:main:imessage:default:direct:+15551230000",
}),
hint: activeExecHint,
}),
).toBe(true);
});
it("keeps no-approver local prompts for ambiguous or group iMessage sessions", () => {
const cfg = buildConfig({
approvals: { exec: { enabled: true } },
});
expect(
shouldSuppressLocalIMessageExecApprovalPrompt({
cfg,
payload: buildLocalApprovalPayload({
agentId: "main",
sessionKey: "agent:main:imessage:group:test-group",
}),
hint: activeExecHint,
}),
).toBe(false);
expect(
shouldSuppressLocalIMessageExecApprovalPrompt({
cfg,
payload: buildLocalApprovalPayload({
agentId: "main",
sessionKey: "agent:main:imessage:chat_guid:iMessage;+;chat42",
}),
hint: activeExecHint,
}),
).toBe(false);
expect(
shouldSuppressLocalIMessageExecApprovalPrompt({
cfg,
payload: buildLocalApprovalPayload({
agentId: "main",
sessionKey: "agent:main:slack:C123",
}),
hint: activeExecHint,
}),
).toBe(false);
expect(
shouldSuppressLocalIMessageExecApprovalPrompt({
cfg,
accountId: "work",
payload: buildLocalApprovalPayload({
agentId: "main",
sessionKey: "agent:main:imessage:default:direct:+15551230000",
}),
hint: activeExecHint,
}),
).toBe(false);
});
it("applies top-level approval filters with agent fallback from session key", () => {
const cfg = buildConfig({
imessage: { allowFrom: ["+15551230000"] },
approvals: {
exec: {
enabled: true,
agentFilter: ["ops"],
sessionFilter: ["imessage"],
},
},
});
expect(
shouldSuppressLocalIMessageExecApprovalPrompt({
cfg,
payload: buildLocalApprovalPayload({
agentId: null,
sessionKey: "agent:ops:imessage:+15551230000",
}),
hint: activeExecHint,
}),
).toBe(true);
expect(
shouldSuppressLocalIMessageExecApprovalPrompt({
cfg,
payload: buildLocalApprovalPayload({
agentId: null,
sessionKey: "agent:main:imessage:+15551230000",
}),
hint: activeExecHint,
}),
).toBe(false);
expect(
shouldSuppressLocalIMessageExecApprovalPrompt({
cfg,
payload: buildLocalApprovalPayload({
agentId: null,
sessionKey: "agent:ops:slack:C123",
}),
hint: activeExecHint,
}),
).toBe(false);
});
});

View File

@@ -0,0 +1,443 @@
// Imessage plugin module implements approval native behavior.
import { createChannelApprovalCapability } from "openclaw/plugin-sdk/approval-delivery-runtime";
import { createLazyChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/approval-handler-adapter-runtime";
import type { ChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/approval-handler-runtime";
import {
createChannelApproverDmTargetResolver,
createChannelNativeOriginTargetResolver,
createNativeApprovalChannelRouteGates,
createNativeApprovalForwardingFallbackSuppressor,
shouldSuppressLocalNativeExecApprovalPrompt,
} from "openclaw/plugin-sdk/approval-native-runtime";
import {
buildExecApprovalPendingReplyPayload,
buildPluginApprovalPendingReplyPayload,
getExecApprovalReplyMetadata,
resolveExecApprovalCommandDisplay,
resolveExecApprovalRequestAllowedDecisions,
} from "openclaw/plugin-sdk/approval-runtime";
import type {
ExecApprovalRequest,
ExecApprovalReplyDecision,
PluginApprovalRequest,
} from "openclaw/plugin-sdk/approval-runtime";
import type {
ChannelApprovalCapability,
ChannelOutboundPayloadHint,
} from "openclaw/plugin-sdk/channel-contract";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import { normalizeAccountId, parseAgentSessionKey } from "openclaw/plugin-sdk/routing";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import {
listIMessageAccountIds,
resolveDefaultIMessageAccountId,
resolveIMessageAccount,
} from "./accounts.js";
import { getIMessageApprovalApprovers, imessageApprovalAuth } from "./approval-auth.js";
import { addIMessageApprovalReactionHintToText } from "./approval-reactions.js";
import { replaceApprovalIdPlaceholder } from "./approval-text.js";
import { normalizeIMessageMessagingTarget } from "./normalize.js";
import { inferIMessageTargetChatType } from "./targets.js";
type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest;
type ApprovalForwardingConfig = NonNullable<NonNullable<OpenClawConfig["approvals"]>["exec"]>;
type ApprovalForwardingMode = NonNullable<ApprovalForwardingConfig["mode"]>;
type ChannelApprovalForwardTarget = Parameters<
NonNullable<
NonNullable<ChannelApprovalCapability["delivery"]>["shouldSuppressForwardingFallback"]
>
>[0]["target"];
type IMessageApprovalTarget = {
to: string;
accountId?: string | null;
threadId?: string | number | null;
};
const DEFAULT_APPROVAL_FORWARDING_MODE: ApprovalForwardingMode = "session";
const DEFAULT_PLUGIN_APPROVAL_DECISIONS: readonly ExecApprovalReplyDecision[] = [
"allow-once",
"allow-always",
"deny",
];
function isIMessageApprovalTransportEnabled(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): boolean {
return resolveIMessageAccount({ cfg: params.cfg, accountId: params.accountId }).enabled;
}
function normalizeIMessageForwardTarget(
target: Pick<ChannelApprovalForwardTarget, "channel" | "to" | "accountId" | "threadId">,
): IMessageApprovalTarget | null {
if (normalizeLowercaseStringOrEmpty(target.channel) !== "imessage") {
return null;
}
const to = normalizeIMessageMessagingTarget(target.to);
if (!to) {
return null;
}
return {
to,
accountId: normalizeOptionalString(target.accountId),
threadId: target.threadId ?? null,
};
}
function resolveTurnSourceIMessageOriginTarget(
request: ApprovalRequest,
): IMessageApprovalTarget | null {
const turnSourceChannel = normalizeLowercaseStringOrEmpty(request.request.turnSourceChannel);
if (turnSourceChannel !== "imessage") {
return null;
}
const to = normalizeIMessageMessagingTarget(request.request.turnSourceTo ?? "");
if (!to) {
return null;
}
return {
to,
accountId: normalizeOptionalString(request.request.turnSourceAccountId),
};
}
function resolveSessionIMessageOriginTarget(sessionTarget: {
to: string;
accountId?: string | null;
}): IMessageApprovalTarget | null {
const to = normalizeIMessageMessagingTarget(sessionTarget.to);
return to ? { to, accountId: normalizeOptionalString(sessionTarget.accountId) } : null;
}
const imessageApprovalRouteGates = createNativeApprovalChannelRouteGates({
channel: "imessage",
defaultForwardingMode: DEFAULT_APPROVAL_FORWARDING_MODE,
isTransportEnabled: isIMessageApprovalTransportEnabled,
listAccountIds: listIMessageAccountIds,
resolveDefaultAccountId: resolveDefaultIMessageAccountId,
normalizeForwardTarget: normalizeIMessageForwardTarget,
resolveTurnSourceTarget: resolveTurnSourceIMessageOriginTarget,
});
const {
canApprovalPotentiallyRouteToChannel: canApprovalPotentiallyRouteToIMessage,
canAnyApprovalPotentiallyRouteToChannel: canAnyApprovalPotentiallyRouteToIMessage,
isSessionApprovalEligible: isIMessageSessionApprovalEligible,
isExplicitTargetEligible: isIMessageExplicitTargetEligible,
shouldHandleApprovalRequest: shouldHandleIMessageApprovalRequest,
} = imessageApprovalRouteGates;
function resolveIMessageSessionTargetFromSessionKey(
sessionKey?: string | null,
): IMessageApprovalTarget | null {
const parsed = parseAgentSessionKey(sessionKey);
const rest = parsed?.rest ?? normalizeOptionalString(sessionKey);
if (!rest || !normalizeLowercaseStringOrEmpty(rest).startsWith("imessage:")) {
return null;
}
const route = rest.slice("imessage:".length).trim();
const routeLower = normalizeLowercaseStringOrEmpty(route);
if (
!route ||
routeLower.startsWith("group:") ||
routeLower.startsWith("channel:") ||
routeLower.startsWith("chat:")
) {
return null;
}
const directPrefix = "direct:";
if (routeLower.startsWith(directPrefix)) {
const to = normalizeIMessageMessagingTarget(route.slice(directPrefix.length));
return to ? { to } : null;
}
const accountScopedDirect = /^([^:]+):direct:(.+)$/i.exec(route);
if (accountScopedDirect) {
const to = normalizeIMessageMessagingTarget(accountScopedDirect[2] ?? "");
return to ? { to, accountId: normalizeAccountId(accountScopedDirect[1] ?? "") } : null;
}
const to = normalizeIMessageMessagingTarget(route);
if (!to || inferIMessageTargetChatType(to) !== "direct") {
return null;
}
return { to };
}
export function shouldSuppressLocalIMessageExecApprovalPrompt(params: {
cfg: OpenClawConfig;
accountId?: string | null;
payload: ReplyPayload;
hint?: ChannelOutboundPayloadHint;
}): boolean {
if (
shouldSuppressLocalNativeExecApprovalPrompt({
...params,
isTransportEnabled: isIMessageApprovalTransportEnabled,
isSessionRouteEligible: ({ cfg, accountId, metadata }) => {
if (getIMessageApprovalApprovers({ cfg, accountId }).length > 0) {
return true;
}
const sessionTarget = resolveIMessageSessionTargetFromSessionKey(metadata.sessionKey);
if (!sessionTarget || inferIMessageTargetChatType(sessionTarget.to) !== "direct") {
return false;
}
const targetAccountId = normalizeOptionalString(sessionTarget.accountId);
return (
!targetAccountId ||
!accountId ||
normalizeAccountId(targetAccountId) === normalizeAccountId(accountId)
);
},
})
) {
return true;
}
const metadata = getExecApprovalReplyMetadata(params.payload);
if (
params.hint?.kind !== "approval-pending" ||
params.hint.approvalKind !== "exec" ||
params.hint.nativeRouteActive !== true ||
metadata?.approvalKind !== "exec"
) {
return false;
}
// The Pi tool-result path currently rebuilds the local approval prompt from
// exec result details that omit agentId/sessionKey. The native iMessage
// approval runtime has already received the full request and will deliver the
// reaction prompt. When explicit iMessage approvers exist, keep the local
// fallback from sending a second manual prompt for the same approval.
if (metadata.agentId || metadata.sessionKey) {
return false;
}
if (getIMessageApprovalApprovers({ cfg: params.cfg, accountId: params.accountId }).length === 0) {
return false;
}
return canApprovalPotentiallyRouteToIMessage({
...params,
approvalKind: "exec",
nativeSessionOnly: true,
});
}
const resolveIMessageOriginTargetBase = createChannelNativeOriginTargetResolver({
channel: "imessage",
shouldHandleRequest: shouldHandleIMessageApprovalRequest,
resolveTurnSourceTarget: resolveTurnSourceIMessageOriginTarget,
resolveSessionTarget: resolveSessionIMessageOriginTarget,
normalizeTarget: (target) => {
const to = normalizeIMessageMessagingTarget(target.to);
return to ? { ...target, to } : null;
},
});
function resolveIMessageOriginTarget(params: {
cfg: OpenClawConfig;
accountId?: string | null;
approvalKind?: "exec" | "plugin";
request: ApprovalRequest;
}): IMessageApprovalTarget | null {
const target = resolveIMessageOriginTargetBase(params);
if (!target) {
return null;
}
// Group conversations need explicit approvers configured before we route an
// approval prompt into them; otherwise any group member could approve.
if (
inferIMessageTargetChatType(target.to) === "group" &&
getIMessageApprovalApprovers({ cfg: params.cfg, accountId: params.accountId }).length === 0
) {
return null;
}
return target;
}
const resolveIMessageApproverDmTargets = createChannelApproverDmTargetResolver({
shouldHandleRequest: shouldHandleIMessageApprovalRequest,
resolveApprovers: getIMessageApprovalApprovers,
mapApprover: (approver, params) => {
const to = normalizeIMessageMessagingTarget(approver);
if (!to) {
return null;
}
return {
to,
accountId: normalizeOptionalString(params.accountId),
};
},
});
const shouldSuppressIMessageForwardingFallback =
createNativeApprovalForwardingFallbackSuppressor<IMessageApprovalTarget>({
channel: "imessage",
normalizeForwardTarget: normalizeIMessageForwardTarget,
resolveAccountId: ({ forwardingTarget, request }) =>
forwardingTarget.accountId ?? normalizeOptionalString(request.request.turnSourceAccountId),
resolveForwardingTargetForMatch: ({ forwardingTarget, accountId }) => ({
...forwardingTarget,
accountId,
}),
isSessionRouteEligible: isIMessageSessionApprovalEligible,
isExplicitTargetEligible: isIMessageExplicitTargetEligible,
resolveOriginTarget: resolveIMessageOriginTarget,
resolveApproverDmTargets: resolveIMessageApproverDmTargets,
});
function appendIMessageReactionHint(params: {
text?: string;
allowedDecisions: readonly ExecApprovalReplyDecision[];
}): string {
return addIMessageApprovalReactionHintToText({
text: params.text ?? "",
allowedDecisions: params.allowedDecisions,
});
}
function buildIMessageExecPendingPayload(params: { request: ExecApprovalRequest; nowMs: number }) {
const allowedDecisions = resolveExecApprovalRequestAllowedDecisions(params.request.request);
const command = resolveExecApprovalCommandDisplay(params.request.request).commandText;
const payload = buildExecApprovalPendingReplyPayload({
approvalId: params.request.id,
approvalSlug: params.request.id.slice(0, 8),
approvalCommandId: params.request.id,
warningText: params.request.request.warningText ?? undefined,
ask: params.request.request.ask ?? null,
agentId: params.request.request.agentId ?? null,
allowedDecisions,
command,
cwd: params.request.request.cwd ?? undefined,
host: params.request.request.host === "node" ? "node" : "gateway",
nodeId: params.request.request.nodeId ?? undefined,
sessionKey: params.request.request.sessionKey ?? null,
expiresAtMs: params.request.expiresAtMs,
nowMs: params.nowMs,
});
return {
...payload,
text: appendIMessageReactionHint({
text: replaceApprovalIdPlaceholder(payload.text, params.request.id),
allowedDecisions,
}),
};
}
function buildIMessagePluginPendingPayload(params: {
request: PluginApprovalRequest;
nowMs: number;
}) {
const configuredDecisions = params.request.request.allowedDecisions;
const allowedDecisions =
configuredDecisions && configuredDecisions.length > 0
? configuredDecisions
: DEFAULT_PLUGIN_APPROVAL_DECISIONS;
const payload = buildPluginApprovalPendingReplyPayload({
request: params.request,
nowMs: params.nowMs,
allowedDecisions,
});
return {
...payload,
text: appendIMessageReactionHint({
text: replaceApprovalIdPlaceholder(payload.text, params.request.id),
allowedDecisions,
}),
};
}
export const imessageApprovalCapability: ChannelApprovalCapability =
createChannelApprovalCapability({
...imessageApprovalAuth,
getActionAvailabilityState: ({ cfg, accountId, approvalKind }) =>
(
approvalKind
? canApprovalPotentiallyRouteToIMessage({ cfg, accountId, approvalKind })
: canAnyApprovalPotentiallyRouteToIMessage({ cfg, accountId })
)
? ({ kind: "enabled" } as const)
: ({ kind: "disabled" } as const),
getExecInitiatingSurfaceState: ({ cfg, accountId }) =>
canApprovalPotentiallyRouteToIMessage({ cfg, accountId, approvalKind: "exec" })
? ({ kind: "enabled" } as const)
: ({ kind: "disabled" } as const),
describeExecApprovalSetup: ({ accountId }) => {
const prefix =
accountId && accountId !== "default"
? `channels.imessage.accounts.${accountId}`
: "channels.imessage";
return `iMessage supports native exec approvals for this account when \`approvals.exec.enabled\` is true and the route allows iMessage. Keep the macOS imsg bridge running and configure \`${prefix}.allowFrom\` to restrict approvers.`;
},
delivery: {
hasConfiguredDmRoute: ({ cfg }) =>
listIMessageAccountIds(cfg).some((accountId) => {
if (
!canAnyApprovalPotentiallyRouteToIMessage({
cfg,
accountId,
nativeSessionOnly: true,
})
) {
return false;
}
return getIMessageApprovalApprovers({ cfg, accountId }).length > 0;
}),
shouldSuppressForwardingFallback: shouldSuppressIMessageForwardingFallback,
},
render: {
exec: {
buildPendingPayload: ({ request, nowMs }) =>
buildIMessageExecPendingPayload({ request, nowMs }),
},
plugin: {
buildPendingPayload: ({ request, nowMs }) =>
buildIMessagePluginPendingPayload({ request, nowMs }),
},
},
native: {
describeDeliveryCapabilities: ({ cfg, accountId, approvalKind, request }) => {
const originTarget = resolveIMessageOriginTarget({
cfg,
accountId,
approvalKind,
request,
});
const approverTargets = resolveIMessageApproverDmTargets({
cfg,
accountId,
approvalKind,
request,
});
const enabled = Boolean(originTarget) || approverTargets.length > 0;
return {
enabled,
preferredSurface: originTarget ? "origin" : "approver-dm",
supportsOriginSurface: Boolean(originTarget),
supportsApproverDmSurface: approverTargets.length > 0,
notifyOriginWhenDmOnly: true,
};
},
resolveOriginTarget: resolveIMessageOriginTarget,
resolveApproverDmTargets: resolveIMessageApproverDmTargets,
},
nativeRuntime: createLazyChannelApprovalNativeRuntimeAdapter({
eventKinds: ["exec", "plugin"],
isConfigured: ({ cfg, accountId, context }) =>
Boolean(context) &&
canAnyApprovalPotentiallyRouteToIMessage({
cfg,
accountId,
nativeSessionOnly: true,
}),
shouldHandle: ({ cfg, accountId, context, request }) =>
Boolean(context) && shouldHandleIMessageApprovalRequest({ cfg, accountId, request }),
load: async () =>
(await import("./approval-handler.runtime.js"))
.imessageApprovalNativeRuntime as unknown as ChannelApprovalNativeRuntimeAdapter,
}),
});

View File

@@ -0,0 +1,615 @@
// Imessage tests cover approval reaction poller plugin behavior.
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
clearIMessageApprovalReactionPollerStateForTest,
pollPendingIMessageApprovalReactions,
} from "./approval-reaction-poller.js";
import {
clearIMessageApprovalReactionTargetsForTest,
registerIMessageApprovalReactionTarget,
} from "./approval-reactions.js";
import type { IMessageRpcClient } from "./client.js";
const resolverMocks = vi.hoisted(() => ({
resolveIMessageApproval: vi.fn(),
isApprovalNotFoundError: vi.fn(() => false),
}));
vi.mock("./approval-resolver.js", () => ({
resolveIMessageApproval: resolverMocks.resolveIMessageApproval,
isApprovalNotFoundError: resolverMocks.isApprovalNotFoundError,
}));
function createClient(request: ReturnType<typeof vi.fn>): IMessageRpcClient {
return { request } as unknown as IMessageRpcClient;
}
describe("iMessage approval reaction poller", () => {
beforeEach(() => {
clearIMessageApprovalReactionTargetsForTest();
clearIMessageApprovalReactionPollerStateForTest();
resolverMocks.resolveIMessageApproval.mockReset();
resolverMocks.resolveIMessageApproval.mockResolvedValue(undefined);
resolverMocks.isApprovalNotFoundError.mockReset();
resolverMocks.isApprovalNotFoundError.mockReturnValue(false);
});
it("does not scan recent chats during fast polling with no pending targets", async () => {
const request = vi.fn();
await pollPendingIMessageApprovalReactions({
client: createClient(request),
cfg: { channels: { imessage: { allowFrom: ["+15551230000"] } } },
accountId: "default",
});
expect(request).not.toHaveBeenCalled();
});
it("does not scan recent chats during fast polling for handle-only targets", async () => {
registerIMessageApprovalReactionTarget({
accountId: "default",
conversation: { handle: "+15551230000" },
messageId: "msg-1",
approvalId: "exec-1",
allowedDecisions: ["allow-once", "deny"],
});
const request = vi.fn();
await pollPendingIMessageApprovalReactions({
client: createClient(request),
cfg: { channels: { imessage: { allowFrom: ["+15551230000"] } } },
accountId: "default",
});
expect(request).not.toHaveBeenCalled();
});
it("discovers observed approval prompts on the bounded recent-chat path", async () => {
const request = vi.fn(async (method: string) => {
if (method === "chats.list") {
return { chats: [{ id: 42 }] };
}
if (method === "messages.history") {
return {
messages: [
{
guid: "msg-1",
chat_id: 42,
chat_guid: "SMS;-;+15551230000",
chat_identifier: "+15551230000",
is_from_me: true,
sender: "+15551230000",
text: [
"Exec approval required",
"ID: exec-1",
"",
"Reply with: /approve exec-1 allow-once|deny",
].join("\n"),
reactions: [
{
id: 7,
sender: "+15551230000",
is_from_me: true,
type: "like",
emoji: "👍",
created_at: "2026-05-27T21:00:00.000Z",
},
],
},
],
};
}
throw new Error(`unexpected method ${method}`);
});
await pollPendingIMessageApprovalReactions({
client: createClient(request),
cfg: { channels: { imessage: { allowFrom: ["+15551230000"] } } },
accountId: "default",
allowRecentChatDiscovery: true,
});
expect(request).toHaveBeenCalledWith("chats.list", { limit: 50 }, { timeoutMs: 10_000 });
expect(resolverMocks.resolveIMessageApproval).toHaveBeenCalledWith({
cfg: { channels: { imessage: { allowFrom: ["+15551230000"] } } },
approvalId: "exec-1",
decision: "allow-once",
senderId: "+15551230000",
gatewayUrl: undefined,
});
});
it("bounds no-target recent-chat discovery to one pass per account", async () => {
const request = vi.fn(async (method: string) => {
if (method === "chats.list") {
return { chats: [{ id: 42 }] };
}
if (method === "messages.history") {
return { messages: [] };
}
throw new Error(`unexpected method ${method}`);
});
const pollParams = {
client: createClient(request),
cfg: { channels: { imessage: { allowFrom: ["+15551230000"] } } },
accountId: "default",
allowRecentChatDiscovery: true,
};
await pollPendingIMessageApprovalReactions(pollParams);
await pollPendingIMessageApprovalReactions(pollParams);
expect(request).toHaveBeenCalledTimes(2);
expect(request).toHaveBeenCalledWith("chats.list", { limit: 50 }, { timeoutMs: 10_000 });
expect(request).toHaveBeenCalledWith(
"messages.history",
{ chat_id: 42, limit: 30 },
{ timeoutMs: 10_000 },
);
});
it("bounds no-target discovery after resolving an observed reaction", async () => {
const request = vi.fn(async (method: string, payload?: { chat_id?: number }) => {
if (method === "chats.list") {
return { chats: [{ id: 42 }, { id: 99 }] };
}
if (method === "messages.history" && payload?.chat_id === 42) {
return {
messages: [
{
guid: "msg-1",
chat_id: 42,
chat_guid: "SMS;-;+15551230000",
chat_identifier: "+15551230000",
is_from_me: true,
sender: "+15551230000",
text: [
"Exec approval required",
"ID: exec-1",
"",
"Reply with: /approve exec-1 allow-once|deny",
].join("\n"),
reactions: [
{
id: 7,
sender: "+15551230000",
is_from_me: true,
type: "like",
emoji: "👍",
created_at: "2026-05-27T21:00:00.000Z",
},
],
},
],
};
}
if (method === "messages.history" && payload?.chat_id === 99) {
return { messages: [] };
}
throw new Error(`unexpected request ${method} ${JSON.stringify(payload)}`);
});
const pollParams = {
client: createClient(request),
cfg: { channels: { imessage: { allowFrom: ["+15551230000"] } } },
accountId: "default",
allowRecentChatDiscovery: true,
};
await pollPendingIMessageApprovalReactions(pollParams);
await pollPendingIMessageApprovalReactions(pollParams);
expect(resolverMocks.resolveIMessageApproval).toHaveBeenCalledTimes(1);
expect(request.mock.calls.filter(([method]) => method === "chats.list")).toHaveLength(1);
expect(request.mock.calls.filter(([method]) => method === "messages.history")).toHaveLength(2);
expect(request).toHaveBeenCalledWith(
"messages.history",
{ chat_id: 99, limit: 30 },
{ timeoutMs: 10_000 },
);
});
it("retries no-target discovery after resolver failures expire observed targets", async () => {
resolverMocks.resolveIMessageApproval.mockRejectedValue(new Error("gateway down"));
const request = vi.fn(async (method: string) => {
if (method === "chats.list") {
return { chats: [{ id: 42 }] };
}
if (method === "messages.history") {
return {
messages: [
{
guid: "msg-1",
chat_id: 42,
chat_guid: "SMS;-;+15551230000",
chat_identifier: "+15551230000",
is_from_me: true,
sender: "+15551230000",
text: [
"Exec approval required",
"ID: exec-1",
"",
"Reply with: /approve exec-1 allow-once|deny",
].join("\n"),
reactions: [
{
id: 7,
sender: "+15551230000",
is_from_me: true,
type: "like",
emoji: "👍",
created_at: "2026-05-27T21:00:00.000Z",
},
],
},
],
};
}
throw new Error(`unexpected method ${method}`);
});
const dateNow = vi.spyOn(Date, "now").mockReturnValue(1_800_000_000_000);
try {
const pollParams = {
client: createClient(request),
cfg: { channels: { imessage: { allowFrom: ["+15551230000"] } } },
accountId: "default",
allowRecentChatDiscovery: true,
};
await pollPendingIMessageApprovalReactions(pollParams);
dateNow.mockReturnValue(1_800_000_301_000);
await pollPendingIMessageApprovalReactions(pollParams);
} finally {
dateNow.mockRestore();
}
expect(resolverMocks.resolveIMessageApproval).toHaveBeenCalledTimes(2);
expect(request.mock.calls.filter(([method]) => method === "chats.list")).toHaveLength(2);
});
it("retries no-target recent-chat discovery after the first chat list fails", async () => {
const request = vi.fn(async (method: string) => {
if (method === "chats.list") {
const chatListCalls = request.mock.calls.filter(
([calledMethod]) => calledMethod === "chats.list",
);
if (chatListCalls.length === 1) {
throw new Error("temporary imsg failure");
}
return { chats: [{ id: 42 }] };
}
if (method === "messages.history") {
return { messages: [] };
}
throw new Error(`unexpected method ${method}`);
});
const pollParams = {
client: createClient(request),
cfg: { channels: { imessage: { allowFrom: ["+15551230000"] } } },
accountId: "default",
allowRecentChatDiscovery: true,
};
await expect(pollPendingIMessageApprovalReactions(pollParams)).rejects.toThrow(
"temporary imsg failure",
);
await pollPendingIMessageApprovalReactions(pollParams);
expect(request.mock.calls.filter(([method]) => method === "chats.list")).toHaveLength(2);
expect(request.mock.calls.filter(([method]) => method === "messages.history")).toHaveLength(1);
});
it("retries no-target recent-chat discovery after the first history fetch fails", async () => {
const request = vi.fn(async (method: string) => {
if (method === "chats.list") {
return { chats: [{ id: 42 }] };
}
if (method === "messages.history") {
const historyCalls = request.mock.calls.filter(
([calledMethod]) => calledMethod === "messages.history",
);
if (historyCalls.length === 1) {
throw new Error("temporary history failure");
}
return { messages: [] };
}
throw new Error(`unexpected method ${method}`);
});
const pollParams = {
client: createClient(request),
cfg: { channels: { imessage: { allowFrom: ["+15551230000"] } } },
accountId: "default",
allowRecentChatDiscovery: true,
};
await pollPendingIMessageApprovalReactions(pollParams);
await pollPendingIMessageApprovalReactions(pollParams);
await pollPendingIMessageApprovalReactions(pollParams);
expect(request.mock.calls.filter(([method]) => method === "chats.list")).toHaveLength(2);
expect(request.mock.calls.filter(([method]) => method === "messages.history")).toHaveLength(2);
});
it("does not bind observed approval prompts when the process clock is invalid", async () => {
const request = vi.fn(async (method: string) => {
if (method === "chats.list") {
return { chats: [{ id: 42 }] };
}
if (method === "messages.history") {
return {
messages: [
{
guid: "msg-1",
chat_id: 42,
chat_guid: "SMS;-;+15551230000",
chat_identifier: "+15551230000",
is_from_me: true,
sender: "+15551230000",
text: "Exec approval required\nID: exec-1",
reactions: [
{
id: 7,
sender: "+15551230000",
is_from_me: true,
type: "like",
emoji: "👍",
created_at: "2026-05-27T21:00:00.000Z",
},
],
},
],
};
}
throw new Error(`unexpected method ${method}`);
});
const dateNow = vi.spyOn(Date, "now").mockReturnValue(Number.NaN);
try {
await pollPendingIMessageApprovalReactions({
client: createClient(request),
cfg: { channels: { imessage: { allowFrom: ["+15551230000"] } } },
accountId: "default",
allowRecentChatDiscovery: true,
});
} finally {
dateNow.mockRestore();
}
expect(resolverMocks.resolveIMessageApproval).not.toHaveBeenCalled();
});
it("uses learned chat ids for fast scoped polling after discovery", async () => {
registerIMessageApprovalReactionTarget({
accountId: "default",
conversation: { handle: "+15551230000" },
messageId: "msg-1",
approvalId: "exec-1",
allowedDecisions: ["allow-once", "deny"],
});
registerIMessageApprovalReactionTarget({
accountId: "default",
conversation: { chatId: 42, chatGuid: "SMS;-;+15551230000" },
messageId: "msg-1",
approvalId: "exec-1",
allowedDecisions: ["allow-once", "deny"],
});
const request = vi.fn(async (method: string) => {
if (method === "messages.history") {
return { messages: [] };
}
throw new Error(`unexpected method ${method}`);
});
await pollPendingIMessageApprovalReactions({
client: createClient(request),
cfg: { channels: { imessage: { allowFrom: ["+15551230000"] } } },
accountId: "default",
});
expect(request).toHaveBeenCalledTimes(1);
expect(request).toHaveBeenCalledWith(
"messages.history",
{ chat_id: 42, limit: 30 },
{ timeoutMs: 10_000 },
);
});
it("includes recent chats during discovery when scoped and unscoped targets are pending", async () => {
registerIMessageApprovalReactionTarget({
accountId: "default",
conversation: { chatId: 42, chatGuid: "SMS;-;+15551230000" },
messageId: "msg-scoped",
approvalId: "exec-scoped",
allowedDecisions: ["allow-once", "deny"],
});
registerIMessageApprovalReactionTarget({
accountId: "default",
conversation: { handle: "+15551239999" },
messageId: "msg-handle",
approvalId: "exec-handle",
allowedDecisions: ["allow-once", "deny"],
});
const request = vi.fn(async (method: string, payload?: { chat_id?: number }) => {
if (method === "chats.list") {
return { chats: [{ id: 42 }, { id: 99 }] };
}
if (method === "messages.history" && payload?.chat_id === 42) {
return { messages: [] };
}
if (method === "messages.history" && payload?.chat_id === 99) {
return {
messages: [
{
guid: "msg-handle",
chat_id: 99,
chat_guid: "SMS;-;+15551239999",
chat_identifier: "+15551239999",
is_from_me: true,
sender: "+15551239999",
text: "Exec approval required\nID: exec-handle",
reactions: [
{
id: 8,
sender: "+15551239999",
is_from_me: true,
type: "like",
emoji: "👍",
created_at: "2026-05-27T21:01:00.000Z",
},
],
},
],
};
}
throw new Error(`unexpected request ${method} ${JSON.stringify(payload)}`);
});
await pollPendingIMessageApprovalReactions({
client: createClient(request),
cfg: { channels: { imessage: { allowFrom: ["+15551239999"] } } },
accountId: "default",
allowRecentChatDiscovery: true,
});
expect(request).toHaveBeenCalledWith("chats.list", { limit: 50 }, { timeoutMs: 10_000 });
expect(request).toHaveBeenCalledWith(
"messages.history",
{ chat_id: 42, limit: 30 },
{ timeoutMs: 10_000 },
);
expect(request).toHaveBeenCalledWith(
"messages.history",
{ chat_id: 99, limit: 30 },
{ timeoutMs: 10_000 },
);
expect(resolverMocks.resolveIMessageApproval).toHaveBeenCalledWith({
cfg: { channels: { imessage: { allowFrom: ["+15551239999"] } } },
approvalId: "exec-handle",
decision: "allow-once",
senderId: "+15551239999",
gatewayUrl: undefined,
});
});
it("continues scanning after an unauthorized reaction leaves the approval pending", async () => {
registerIMessageApprovalReactionTarget({
accountId: "default",
conversation: { chatId: 42, chatGuid: "iMessage;+;chat-guid" },
messageId: "msg-1",
approvalId: "exec-1",
allowedDecisions: ["allow-once", "deny"],
});
const request = vi.fn(async (method: string) => {
if (method === "messages.history") {
return {
messages: [
{
guid: "msg-1",
chat_id: 42,
chat_guid: "iMessage;+;chat-guid",
is_group: true,
is_from_me: true,
text: "Exec approval required\nID: exec-1",
reactions: [
{
id: 8,
sender: "+15550000000",
type: "like",
emoji: "👍",
created_at: "2026-05-27T21:01:00.000Z",
},
{
id: 9,
sender: "+15551230000",
type: "like",
emoji: "👍",
created_at: "2026-05-27T21:02:00.000Z",
},
],
},
],
};
}
throw new Error(`unexpected method ${method}`);
});
await pollPendingIMessageApprovalReactions({
client: createClient(request),
cfg: { channels: { imessage: { allowFrom: ["+15551230000"] } } },
accountId: "default",
});
expect(resolverMocks.resolveIMessageApproval).toHaveBeenCalledTimes(1);
expect(resolverMocks.resolveIMessageApproval).toHaveBeenCalledWith({
cfg: { channels: { imessage: { allowFrom: ["+15551230000"] } } },
approvalId: "exec-1",
decision: "allow-once",
senderId: "+15551230000",
gatewayUrl: undefined,
});
});
it("stops scanning after an authorized resolver failure", async () => {
resolverMocks.resolveIMessageApproval.mockRejectedValueOnce(new Error("gateway down"));
registerIMessageApprovalReactionTarget({
accountId: "default",
conversation: { chatId: 42, chatGuid: "iMessage;+;chat-guid" },
messageId: "msg-1",
approvalId: "exec-1",
allowedDecisions: ["allow-once", "deny"],
});
const request = vi.fn(async (method: string) => {
if (method === "messages.history") {
return {
messages: [
{
guid: "msg-1",
chat_id: 42,
chat_guid: "iMessage;+;chat-guid",
is_group: true,
is_from_me: true,
text: "Exec approval required\nID: exec-1",
reactions: [
{
id: 8,
sender: "+15551230000",
type: "like",
emoji: "👍",
created_at: "2026-05-27T21:01:00.000Z",
},
{
id: 9,
sender: "+15551230000",
type: "dislike",
emoji: "👎",
created_at: "2026-05-27T21:02:00.000Z",
},
],
},
],
};
}
throw new Error(`unexpected method ${method}`);
});
await pollPendingIMessageApprovalReactions({
client: createClient(request),
cfg: { channels: { imessage: { allowFrom: ["+15551230000"] } } },
accountId: "default",
});
expect(resolverMocks.resolveIMessageApproval).toHaveBeenCalledTimes(1);
expect(resolverMocks.resolveIMessageApproval).toHaveBeenCalledWith({
cfg: { channels: { imessage: { allowFrom: ["+15551230000"] } } },
approvalId: "exec-1",
decision: "allow-once",
senderId: "+15551230000",
gatewayUrl: undefined,
});
});
});

View File

@@ -0,0 +1,310 @@
// Imessage plugin module implements approval reaction poller behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
asDateTimestampMs,
resolveExpiresAtMsFromDurationMs,
} from "openclaw/plugin-sdk/number-runtime";
import {
extractIMessageApprovalPromptBinding,
handleIMessageApprovalReaction,
listPendingIMessageApprovalReactionPollTargets,
registerIMessageApprovalReactionTarget,
type PendingIMessageApprovalReactionPollTarget,
type IMessageApprovalConversationKey,
} from "./approval-reactions.js";
import type { IMessageRpcClient } from "./client.js";
import type { IMessagePayload } from "./monitor/types.js";
const RECENT_CHAT_LIMIT = 50;
const PER_CHAT_HISTORY_LIMIT = 30;
const OBSERVED_APPROVAL_PROMPT_TARGET_TTL_MS = 5 * 60 * 1000;
const accountIdsWithCompletedNoTargetDiscovery = new Set<string>();
export function clearIMessageApprovalReactionPollerStateForTest(): void {
accountIdsWithCompletedNoTargetDiscovery.clear();
}
type ChatListEntry = {
id?: number | null;
};
type HistoryMessage = IMessagePayload & {
reactions?: Array<{
id?: number | string | null;
sender?: string | null;
is_from_me?: boolean | null;
type?: string | null;
emoji?: string | null;
created_at?: string | null;
}> | null;
};
function normalizeChatId(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : null;
}
function listTargetChatIds(
targets: readonly PendingIMessageApprovalReactionPollTarget[],
): number[] {
const chatIds = new Set<number>();
for (const target of targets) {
const chatId = normalizeChatId(target.conversation.chatId);
if (chatId !== null) {
chatIds.add(chatId);
}
}
return [...chatIds];
}
function hasUnscopedTarget(targets: readonly PendingIMessageApprovalReactionPollTarget[]): boolean {
return targets.some((target) => normalizeChatId(target.conversation.chatId) === null);
}
function uniqueChatIds(chatIds: readonly number[]): number[] {
return [...new Set(chatIds)];
}
function normalizeMessageGuid(value: string): string {
return value.trim().replace(/^p:\d+\//iu, "");
}
function enumerateMessageGuidCandidates(value: string): string[] {
const trimmed = value.trim();
if (!trimmed) {
return [];
}
const normalized = normalizeMessageGuid(trimmed);
return [trimmed, normalized].filter(
(candidate, index, candidates) =>
candidate.length > 0 && candidates.indexOf(candidate) === index,
);
}
function buildPendingTargetsByMessageId(
targets: readonly PendingIMessageApprovalReactionPollTarget[],
): Map<string, PendingIMessageApprovalReactionPollTarget> {
const pendingByMessageId = new Map<string, PendingIMessageApprovalReactionPollTarget>();
for (const target of targets) {
for (const candidate of enumerateMessageGuidCandidates(target.messageId)) {
pendingByMessageId.set(candidate, target);
}
}
return pendingByMessageId;
}
async function listRecentChatIds(client: IMessageRpcClient): Promise<number[]> {
const result = await client.request<{ chats?: ChatListEntry[] }>(
"chats.list",
{ limit: RECENT_CHAT_LIMIT },
{ timeoutMs: 10_000 },
);
return (result.chats ?? [])
.map((chat) => normalizeChatId(chat.id))
.filter((chatId): chatId is number => chatId !== null);
}
async function fetchRecentHistory(params: {
client: IMessageRpcClient;
chatId: number;
}): Promise<HistoryMessage[]> {
const result = await params.client.request<{ messages?: unknown[] }>(
"messages.history",
{
chat_id: params.chatId,
limit: PER_CHAT_HISTORY_LIMIT,
},
{ timeoutMs: 10_000 },
);
return (result.messages ?? []).filter((message): message is HistoryMessage =>
Boolean(message && typeof message === "object"),
);
}
function buildReactionPayload(params: {
targetMessage: HistoryMessage;
reaction: NonNullable<HistoryMessage["reactions"]>[number];
}): IMessagePayload | null {
const emoji = params.reaction.emoji?.trim();
const sender = params.reaction.sender?.trim();
const targetGuid = params.targetMessage.guid?.trim();
if (!emoji || !sender || !targetGuid) {
return null;
}
const reactionId = normalizeChatId(params.reaction.id);
return {
...(reactionId !== null ? { id: reactionId } : {}),
guid: `reaction:${targetGuid}:${sender}:${emoji}:${params.reaction.created_at ?? ""}`,
chat_id: params.targetMessage.chat_id,
chat_guid: params.targetMessage.chat_guid,
chat_identifier: params.targetMessage.chat_identifier,
chat_name: params.targetMessage.chat_name,
participants: params.targetMessage.participants,
is_group: params.targetMessage.is_group,
sender,
destination_caller_id: params.targetMessage.destination_caller_id,
is_from_me: params.reaction.is_from_me,
text: `${params.reaction.type ?? "reaction"} "${params.targetMessage.text ?? ""}"`,
created_at: params.reaction.created_at,
is_reaction: true,
is_tapback: true,
associated_message_guid: targetGuid,
associated_message_type: 2000,
reaction_type: params.reaction.type ?? undefined,
reaction_emoji: emoji,
is_reaction_add: true,
reacted_to_guid: targetGuid,
};
}
function buildConversationKeyFromMessage(message: HistoryMessage): IMessageApprovalConversationKey {
return {
...(message.chat_guid?.trim() ? { chatGuid: message.chat_guid.trim() } : {}),
...(message.chat_identifier?.trim() ? { chatIdentifier: message.chat_identifier.trim() } : {}),
...(normalizeChatId(message.chat_id) !== null ? { chatId: message.chat_id as number } : {}),
};
}
function bindObservedConversation(params: {
target: PendingIMessageApprovalReactionPollTarget;
message: HistoryMessage;
}): void {
const nowMs = asDateTimestampMs(Date.now());
const expiresAtMs = asDateTimestampMs(params.target.expiresAtMs);
if (nowMs === undefined || expiresAtMs === undefined || expiresAtMs <= nowMs) {
return;
}
const ttlMs = expiresAtMs - nowMs;
const conversation = buildConversationKeyFromMessage(params.message);
const messageIds = new Set([
...enumerateMessageGuidCandidates(params.target.messageId),
...enumerateMessageGuidCandidates(params.message.guid ?? ""),
]);
for (const messageId of messageIds) {
registerIMessageApprovalReactionTarget({
accountId: params.target.accountId,
conversation,
messageId,
approvalId: params.target.approvalId,
allowedDecisions: params.target.allowedDecisions,
ttlMs,
});
}
}
function bindObservedApprovalPrompt(params: {
accountId: string;
message: HistoryMessage;
}): PendingIMessageApprovalReactionPollTarget | null {
if (params.message.is_from_me !== true) {
return null;
}
const messageId = params.message.guid?.trim();
if (!messageId) {
return null;
}
const binding = extractIMessageApprovalPromptBinding(params.message.text ?? "");
if (!binding) {
return null;
}
const conversation = buildConversationKeyFromMessage(params.message);
const expiresAtMs = resolveExpiresAtMsFromDurationMs(OBSERVED_APPROVAL_PROMPT_TARGET_TTL_MS);
if (expiresAtMs === undefined) {
return null;
}
const target: PendingIMessageApprovalReactionPollTarget = {
accountId: params.accountId,
conversation,
messageId,
approvalId: binding.approvalId,
allowedDecisions: binding.allowedDecisions,
expiresAtMs,
};
bindObservedConversation({ target, message: params.message });
return target;
}
export async function pollPendingIMessageApprovalReactions(params: {
client: IMessageRpcClient;
cfg: OpenClawConfig;
accountId: string;
allowRecentChatDiscovery?: boolean;
logVerboseMessage?: (message: string) => void;
}): Promise<void> {
const targets = listPendingIMessageApprovalReactionPollTargets({
accountId: params.accountId,
});
const shouldAttemptNoTargetDiscovery =
targets.length === 0 &&
params.allowRecentChatDiscovery === true &&
!accountIdsWithCompletedNoTargetDiscovery.has(params.accountId);
if (targets.length === 0 && !shouldAttemptNoTargetDiscovery) {
return;
}
const pendingByMessageId = buildPendingTargetsByMessageId(targets);
const explicitChatIds = listTargetChatIds(targets);
const shouldDiscoverRecentChats =
params.allowRecentChatDiscovery === true &&
(targets.length === 0 || hasUnscopedTarget(targets));
const chatIds = shouldDiscoverRecentChats
? uniqueChatIds([...explicitChatIds, ...(await listRecentChatIds(params.client))])
: explicitChatIds;
if (chatIds.length === 0) {
if (shouldAttemptNoTargetDiscovery) {
accountIdsWithCompletedNoTargetDiscovery.add(params.accountId);
}
return;
}
let hadHistoryFetchError = false;
for (const chatId of chatIds) {
let messages: HistoryMessage[];
try {
messages = await fetchRecentHistory({ client: params.client, chatId });
} catch (err) {
hadHistoryFetchError = true;
params.logVerboseMessage?.(
`imessage: approval reaction poll skipped chat_id=${chatId}: ${String(err)}`,
);
continue;
}
for (const message of messages) {
const targetGuid = message.guid?.trim();
if (!targetGuid) {
continue;
}
const target =
pendingByMessageId.get(targetGuid) ??
pendingByMessageId.get(normalizeMessageGuid(targetGuid)) ??
bindObservedApprovalPrompt({
accountId: params.accountId,
message,
});
if (!target) {
continue;
}
bindObservedConversation({ target, message });
for (const reaction of message.reactions ?? []) {
const reactionPayload = buildReactionPayload({ targetMessage: message, reaction });
if (!reactionPayload) {
continue;
}
const handled = await handleIMessageApprovalReaction({
cfg: params.cfg,
accountId: params.accountId,
message: reactionPayload,
bodyText: reactionPayload.text ?? "",
logVerboseMessage: params.logVerboseMessage,
});
if (handled.stopPolling) {
if (shouldAttemptNoTargetDiscovery && handled.stopPollingReason !== "resolver-error") {
break;
}
return;
}
}
}
}
if (shouldAttemptNoTargetDiscovery && !hadHistoryFetchError) {
accountIdsWithCompletedNoTargetDiscovery.add(params.accountId);
}
}

View File

@@ -0,0 +1,741 @@
// Imessage tests cover approval reactions plugin behavior.
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
appendIMessageApprovalReactionHintForOutboundMessage,
buildIMessageApprovalReactionHint,
clearIMessageApprovalReactionTargetsForTest,
extractIMessageApprovalPromptBinding,
listPendingIMessageApprovalReactionPollTargets,
maybeResolveIMessageApprovalReaction,
registerIMessageApprovalReactionTargetForOutboundMessage,
registerIMessageApprovalReactionTarget,
resolveIMessageApprovalReactionTargetWithPersistence,
} from "./approval-reactions.js";
import type { IMessagePayload } from "./monitor/types.js";
const resolverMocks = vi.hoisted(() => ({
resolveIMessageApproval: vi.fn(),
isApprovalNotFoundError: vi.fn(() => false),
}));
vi.mock("./approval-resolver.js", () => ({
resolveIMessageApproval: resolverMocks.resolveIMessageApproval,
isApprovalNotFoundError: resolverMocks.isApprovalNotFoundError,
}));
function buildTapbackReactionPayload(overrides: Partial<IMessagePayload>): IMessagePayload {
return {
sender: "+15551230000",
is_reaction: true,
reaction_emoji: "👍",
reacted_to_guid: "msg-1",
...overrides,
} as IMessagePayload;
}
describe("iMessage approval reactions", () => {
beforeEach(() => {
clearIMessageApprovalReactionTargetsForTest();
resolverMocks.resolveIMessageApproval.mockReset();
resolverMocks.resolveIMessageApproval.mockResolvedValue(undefined);
resolverMocks.isApprovalNotFoundError.mockReset();
resolverMocks.isApprovalNotFoundError.mockReturnValue(false);
});
it("renders shared reaction choices for allowed decisions", () => {
expect(buildIMessageApprovalReactionHint(["allow-once", "allow-always", "deny"])).toBe(
"React with:\n\n👍 Allow Once\n♾ Allow Always\n👎 Deny",
);
});
it("appends thumbs-only reaction choices to outbound approval prompts", () => {
expect(
appendIMessageApprovalReactionHintForOutboundMessage(
"Exec approval required\nID: exec-1\n\nReply with: /approve exec-1 allow-once|deny",
),
).toBe(
"Exec approval required\nID: exec-1\n\nReact with:\n\n👍 Allow Once\n👎 Deny\n\nReply with: /approve exec-1 allow-once|deny",
);
});
it("does not duplicate reaction choices on native approval prompts", () => {
const prompt = [
"Plugin approval required",
"Reply with: /approve plugin:abc allow-once|allow-always|deny",
"",
"React with:",
"",
"👍 Allow Once",
"👎 Deny",
].join("\n");
expect(appendIMessageApprovalReactionHintForOutboundMessage(prompt)).toBe(prompt);
});
it("exposes allow-always as the shared infinity reaction choice", () => {
expect(buildIMessageApprovalReactionHint(["allow-once", "allow-always", "deny"])).toBe(
"React with:\n\n👍 Allow Once\n♾ Allow Always\n👎 Deny",
);
});
it("registers and resolves allow-always through the shared infinity reaction", async () => {
expect(
registerIMessageApprovalReactionTarget({
accountId: "default",
conversation: { handle: "+15551230000" },
messageId: "msg-allow-always",
approvalId: "exec-allow-always",
allowedDecisions: ["allow-always"],
}),
).toEqual({
approvalId: "exec-allow-always",
allowedDecisions: ["allow-always"],
});
await expect(
resolveIMessageApprovalReactionTargetWithPersistence({
accountId: "default",
conversation: { handle: "+15551230000" },
messageId: "msg-allow-always",
reactionKey: "♾",
}),
).resolves.toEqual({
approvalId: "exec-allow-always",
decision: "allow-always",
});
});
it("resolves a registered reaction target keyed by handle", async () => {
registerIMessageApprovalReactionTarget({
accountId: "default",
conversation: { handle: "+15551230000" },
messageId: "msg-1",
approvalId: "exec-1",
allowedDecisions: ["allow-once", "deny"],
});
await expect(
resolveIMessageApprovalReactionTargetWithPersistence({
accountId: "default",
conversation: { handle: "+15551230000" },
messageId: "msg-1",
reactionKey: "👎",
}),
).resolves.toEqual({
approvalId: "exec-1",
decision: "deny",
});
});
it("merges learned chat ids into pending poll targets", () => {
registerIMessageApprovalReactionTarget({
accountId: "default",
conversation: { handle: "+15551230000" },
messageId: "p:0/msg-1",
approvalId: "exec-1",
allowedDecisions: ["allow-once", "deny"],
});
registerIMessageApprovalReactionTarget({
accountId: "default",
conversation: {
chatGuid: "SMS;-;+15551230000",
chatIdentifier: "+15551230000",
chatId: 42,
},
messageId: "msg-1",
approvalId: "exec-1",
allowedDecisions: ["allow-once", "deny"],
});
expect(listPendingIMessageApprovalReactionPollTargets({ accountId: "default" })).toEqual([
expect.objectContaining({
approvalId: "exec-1",
conversation: {
chatGuid: "SMS;-;+15551230000",
chatIdentifier: "+15551230000",
chatId: 42,
handle: "+15551230000",
},
messageId: "p:0/msg-1",
}),
]);
});
it("does not keep pending poll targets when the process clock is invalid", () => {
const dateNow = vi.spyOn(Date, "now").mockReturnValue(Number.NaN);
try {
expect(
registerIMessageApprovalReactionTarget({
accountId: "default",
conversation: { handle: "+15551230000" },
messageId: "msg-invalid-clock",
approvalId: "exec-invalid-clock",
allowedDecisions: ["allow-once", "deny"],
}),
).toBeNull();
} finally {
dateNow.mockRestore();
}
expect(listPendingIMessageApprovalReactionPollTargets({ accountId: "default" })).toEqual([]);
});
it("falls back to the default pending poll target ttl for invalid explicit ttl values", () => {
const nowMs = 1_800_000_000_000;
const dateNow = vi.spyOn(Date, "now").mockReturnValue(nowMs);
try {
registerIMessageApprovalReactionTarget({
accountId: "default",
conversation: { handle: "+15551230000" },
messageId: "msg-invalid-ttl",
approvalId: "exec-invalid-ttl",
allowedDecisions: ["allow-once", "deny"],
ttlMs: Number.NaN,
});
} finally {
dateNow.mockRestore();
}
expect(listPendingIMessageApprovalReactionPollTargets({ accountId: "default" })).toEqual([
expect.objectContaining({
approvalId: "exec-invalid-ttl",
expiresAtMs: nowMs + 24 * 60 * 60 * 1000,
}),
]);
});
it("resolves a registered group reaction target keyed by chat_guid", async () => {
registerIMessageApprovalReactionTarget({
accountId: "default",
conversation: { chatGuid: "iMessage;+;chat42" },
messageId: "msg-group-1",
approvalId: "plugin:abc",
allowedDecisions: ["allow-once", "allow-always", "deny"],
});
await expect(
resolveIMessageApprovalReactionTargetWithPersistence({
accountId: "default",
conversation: { chatGuid: "iMessage;+;chat42" },
messageId: "msg-group-1",
reactionKey: "👍",
}),
).resolves.toEqual({
approvalId: "plugin:abc",
decision: "allow-once",
});
});
it("extracts approval bindings from explicit outbound prompts", async () => {
expect(
extractIMessageApprovalPromptBinding(
[
"Plugin approval required",
"ID: plugin:abc",
"Reply with: /approve plugin:abc allow-once|allow-always|deny",
].join("\n"),
),
).toEqual({
approvalId: "plugin:abc",
allowedDecisions: ["allow-once", "allow-always", "deny"],
});
expect(
registerIMessageApprovalReactionTargetForOutboundMessage({
accountId: "default",
conversation: { handle: "+15551230000" },
messageId: "prompt-message",
text: [
"Exec approval required",
"ID: exec-1",
"",
"Reply with: /approve exec-1 allow-once|deny",
].join("\n"),
}),
).toBe(true);
await expect(
resolveIMessageApprovalReactionTargetWithPersistence({
accountId: "default",
conversation: { handle: "+15551230000" },
messageId: "prompt-message",
reactionKey: "👎",
}),
).resolves.toEqual({
approvalId: "exec-1",
decision: "deny",
});
for (const reactionKey of ["1⃣", "2⃣", "3⃣", "1", "2", "3", "❤️", "♾️"]) {
await expect(
resolveIMessageApprovalReactionTargetWithPersistence({
accountId: "default",
conversation: { handle: "+15551230000" },
messageId: "prompt-message",
reactionKey,
}),
).resolves.toBeNull();
}
});
it("does not register a phantom binding when /approve text appears in a non-approval message", () => {
// Agent help text quoting /approve syntax should NOT register a binding —
// requiring a canonical `ID: <id>` header line is the gate.
expect(
registerIMessageApprovalReactionTargetForOutboundMessage({
accountId: "default",
conversation: { handle: "+15551230000" },
messageId: "help-message",
text: "Run /approve task-7 allow-once when you're ready.",
}),
).toBe(false);
expect(
extractIMessageApprovalPromptBinding("Run /approve task-7 allow-once when you're ready."),
).toBeNull();
});
it("escapes `$` sequences in approvalId when interpolating into outbound text", () => {
// The shared replaceApprovalIdPlaceholder helper guards against
// String.prototype.replace interpreting `$1`/`$&`/`$$` in the
// replacement string. Verified indirectly via the binding extractor:
// a prompt rendered for approvalId "exec-$1abc" must keep the id intact.
const text = [
"Exec approval required",
"ID: exec-1abc",
"Reply with: /approve exec-1abc allow-once",
].join("\n");
expect(extractIMessageApprovalPromptBinding(text)).toEqual({
approvalId: "exec-1abc",
allowedDecisions: ["allow-once"],
});
});
it("resolves is_from_me tapbacks when the actor is an explicit approver", async () => {
registerIMessageApprovalReactionTarget({
accountId: "default",
conversation: { handle: "+15551230000" },
messageId: "approval-message",
approvalId: "exec-self",
allowedDecisions: ["allow-once", "deny"],
});
const handled = await maybeResolveIMessageApprovalReaction({
cfg: { channels: { imessage: { allowFrom: ["+15551230000"] } } },
accountId: "default",
message: buildTapbackReactionPayload({
sender: "+15551230000",
is_from_me: true,
reaction_emoji: "👍",
reacted_to_guid: "approval-message",
}),
bodyText: "",
});
expect(handled).toBe(true);
expect(resolverMocks.resolveIMessageApproval).toHaveBeenCalledWith(
expect.objectContaining({
approvalId: "exec-self",
decision: "allow-once",
senderId: "+15551230000",
}),
);
});
it("clears the in-memory binding on successful approval resolve so toggle 👍→👎 does not refire", async () => {
registerIMessageApprovalReactionTarget({
accountId: "default",
conversation: { handle: "+15551230000" },
messageId: "approval-message",
approvalId: "exec-success",
allowedDecisions: ["allow-once", "deny"],
});
const cfg = { channels: { imessage: { allowFrom: ["+15551230000"] } } };
await expect(
maybeResolveIMessageApprovalReaction({
cfg,
accountId: "default",
message: buildTapbackReactionPayload({
sender: "+15551230000",
reaction_emoji: "👍",
reacted_to_guid: "approval-message",
}),
bodyText: "",
}),
).resolves.toBe(true);
expect(resolverMocks.resolveIMessageApproval).toHaveBeenCalledTimes(1);
// Second tapback (toggle to 👎) must not hit the resolver — the in-memory
// binding was cleared on the first success.
await expect(
maybeResolveIMessageApprovalReaction({
cfg,
accountId: "default",
message: buildTapbackReactionPayload({
sender: "+15551230000",
reaction_emoji: "👎",
reacted_to_guid: "approval-message",
}),
bodyText: "",
}),
).resolves.toBe(false);
expect(resolverMocks.resolveIMessageApproval).toHaveBeenCalledTimes(1);
});
it("resolves a reaction when the approver was configured with a service-prefixed allowFrom entry", async () => {
// Regression test for the ClawSweeper-flagged normalizer bug: a previous
// version of normalizeIMessageApproverId rejected service-prefixed direct
// handles (`imessage:+...`, `sms:+...`, `auto:+...`) before stripping the
// prefix, so the approver list collapsed to empty and reaction resolution
// silently denied with "reactions require explicit approvers".
registerIMessageApprovalReactionTarget({
accountId: "default",
conversation: { handle: "+15551230000" },
messageId: "approval-message",
approvalId: "exec-service-prefix",
allowedDecisions: ["allow-once", "deny"],
});
const cfg = {
channels: { imessage: { allowFrom: ["imessage:+15551230000"] } },
};
const handled = await maybeResolveIMessageApprovalReaction({
cfg,
accountId: "default",
message: buildTapbackReactionPayload({
sender: "+15551230000",
reaction_emoji: "👍",
reacted_to_guid: "approval-message",
}),
bodyText: "",
});
expect(handled).toBe(true);
expect(resolverMocks.resolveIMessageApproval).toHaveBeenCalledWith({
cfg,
approvalId: "exec-service-prefix",
decision: "allow-once",
senderId: "+15551230000",
gatewayUrl: undefined,
});
});
it("resolves a reaction when the binding was registered under a `p:0/…` prefixed GUID and the tapback surfaces both forms", async () => {
// Regression for the second ClawSweeper P1 finding: imsg can return
// `p:0/<guid>` as the outbound guid, so send.ts registers the binding
// under that prefixed key. The inbound tapback's `targetGuid` is the
// normalized (unprefixed) form, but `targetGuids` contains BOTH the
// normalized and raw forms. The resolver must probe every candidate or
// the lookup misses for valid tapbacks.
registerIMessageApprovalReactionTarget({
accountId: "default",
conversation: { handle: "+15551230000" },
messageId: "p:0/abc-123",
approvalId: "exec-prefixed",
allowedDecisions: ["allow-once", "deny"],
});
const cfg = { channels: { imessage: { allowFrom: ["+15551230000"] } } };
const handled = await maybeResolveIMessageApprovalReaction({
cfg,
accountId: "default",
message: buildTapbackReactionPayload({
sender: "+15551230000",
// associated_message_guid carries the prefixed form; reacted_to_guid
// gets normalized by resolveIMessageReactionContext into the
// unprefixed form. The reaction-context helper exposes BOTH via
// `targetGuids`.
reacted_to_guid: "p:0/abc-123",
associated_message_guid: "p:0/abc-123",
reaction_emoji: "👍",
}),
bodyText: "",
});
expect(handled).toBe(true);
expect(resolverMocks.resolveIMessageApproval).toHaveBeenCalledWith({
cfg,
approvalId: "exec-prefixed",
decision: "allow-once",
senderId: "+15551230000",
gatewayUrl: undefined,
});
// Both forms should be cleared from the in-memory map after success so a
// toggle/replay tap doesn't re-fire.
await expect(
resolveIMessageApprovalReactionTargetWithPersistence({
accountId: "default",
conversation: { handle: "+15551230000" },
messageId: "p:0/abc-123",
reactionKey: "👍",
}),
).resolves.toBeNull();
await expect(
resolveIMessageApprovalReactionTargetWithPersistence({
accountId: "default",
conversation: { handle: "+15551230000" },
messageId: "abc-123",
reactionKey: "👍",
}),
).resolves.toBeNull();
});
it("resolves DM reactions even when send registered under handle but inbound carries chat_guid", async () => {
registerIMessageApprovalReactionTarget({
accountId: "default",
// Send path keys by handle (target.kind === 'handle').
conversation: { handle: "+15551230000" },
messageId: "approval-message",
approvalId: "exec-dm",
allowedDecisions: ["allow-once", "deny"],
});
const cfg = { channels: { imessage: { allowFrom: ["+15551230000"] } } };
const handled = await maybeResolveIMessageApprovalReaction({
cfg,
accountId: "default",
message: buildTapbackReactionPayload({
sender: "+15551230000",
// Inbound DM payload populates chat_guid (chat.db always sets it).
chat_guid: "iMessage;-;+15551230000",
chat_identifier: "+15551230000",
chat_id: 17,
is_group: false,
reaction_emoji: "👍",
reacted_to_guid: "approval-message",
}),
bodyText: "",
});
expect(handled).toBe(true);
expect(resolverMocks.resolveIMessageApproval).toHaveBeenCalledWith({
cfg,
approvalId: "exec-dm",
decision: "allow-once",
senderId: "+15551230000",
gatewayUrl: undefined,
});
});
it("ignores removed tapbacks for approval reactions", async () => {
registerIMessageApprovalReactionTarget({
accountId: "default",
conversation: { handle: "+15551230000" },
messageId: "approval-message",
approvalId: "exec-1",
allowedDecisions: ["allow-once", "deny"],
});
const handled = await maybeResolveIMessageApprovalReaction({
cfg: {
channels: {
imessage: { allowFrom: ["+15551230000"] },
},
},
accountId: "default",
message: buildTapbackReactionPayload({
sender: "+15551230000",
is_reaction: true,
is_reaction_add: false,
reaction_emoji: "👍",
reacted_to_guid: "approval-message",
}),
bodyText: "",
});
expect(handled).toBe(false);
expect(resolverMocks.resolveIMessageApproval).not.toHaveBeenCalled();
});
it("resolves a direct approval reaction from an authorized sender", async () => {
registerIMessageApprovalReactionTarget({
accountId: "default",
conversation: { handle: "+15551230000" },
messageId: "approval-message",
approvalId: "plugin:abc",
allowedDecisions: ["allow-once", "allow-always", "deny"],
});
const cfg = {
channels: {
imessage: { allowFrom: ["+15551230000"] },
},
};
const handled = await maybeResolveIMessageApprovalReaction({
cfg,
accountId: "default",
message: buildTapbackReactionPayload({
sender: "+15551230000",
reaction_emoji: "👍",
reacted_to_guid: "approval-message",
}),
bodyText: "",
});
expect(handled).toBe(true);
expect(resolverMocks.resolveIMessageApproval).toHaveBeenCalledWith({
cfg,
approvalId: "plugin:abc",
decision: "allow-once",
senderId: "+15551230000",
gatewayUrl: undefined,
});
});
it("resolves a group approval reaction keyed by chat_guid using the participant identity", async () => {
registerIMessageApprovalReactionTarget({
accountId: "default",
conversation: { chatGuid: "iMessage;+;chat42" },
messageId: "approval-message",
approvalId: "exec-group",
allowedDecisions: ["allow-once", "deny"],
});
const cfg = {
channels: {
imessage: { allowFrom: ["+15551239999"] },
},
};
const handled = await maybeResolveIMessageApprovalReaction({
cfg,
accountId: "default",
message: buildTapbackReactionPayload({
sender: "+15551239999",
chat_guid: "iMessage;+;chat42",
chat_id: 42,
is_group: true,
reaction_emoji: "👎",
reacted_to_guid: "approval-message",
}),
bodyText: "",
});
expect(handled).toBe(true);
expect(resolverMocks.resolveIMessageApproval).toHaveBeenCalledWith({
cfg,
approvalId: "exec-group",
decision: "deny",
senderId: "+15551239999",
gatewayUrl: undefined,
});
});
it("denies reactions from senders not on the approvers list", async () => {
registerIMessageApprovalReactionTarget({
accountId: "default",
conversation: { handle: "+15551239999" },
messageId: "approval-message",
approvalId: "exec-deny",
allowedDecisions: ["allow-once", "deny"],
});
const handled = await maybeResolveIMessageApprovalReaction({
cfg: {
channels: {
imessage: { allowFrom: ["+15551230000"] },
},
},
accountId: "default",
message: buildTapbackReactionPayload({
sender: "+15551239999",
reaction_emoji: "👍",
reacted_to_guid: "approval-message",
}),
bodyText: "",
});
expect(handled).toBe(true);
expect(resolverMocks.resolveIMessageApproval).not.toHaveBeenCalled();
});
it("requires explicit approvers for direct approval reactions", async () => {
registerIMessageApprovalReactionTarget({
accountId: "default",
conversation: { handle: "+15551230000" },
messageId: "approval-message",
approvalId: "exec-1",
allowedDecisions: ["allow-once"],
});
const handled = await maybeResolveIMessageApprovalReaction({
cfg: { channels: { imessage: {} } },
accountId: "default",
message: buildTapbackReactionPayload({
sender: "+15551230000",
reaction_emoji: "👍",
reacted_to_guid: "approval-message",
}),
bodyText: "",
});
expect(handled).toBe(true);
expect(resolverMocks.resolveIMessageApproval).not.toHaveBeenCalled();
});
it("forgets stale bindings when the gateway reports an unknown approval", async () => {
registerIMessageApprovalReactionTarget({
accountId: "default",
conversation: { handle: "+15551230000" },
messageId: "expired-message",
approvalId: "exec-expired",
allowedDecisions: ["allow-once"],
});
resolverMocks.resolveIMessageApproval.mockRejectedValueOnce(new Error("approval not found"));
resolverMocks.isApprovalNotFoundError.mockReturnValue(true);
const handled = await maybeResolveIMessageApprovalReaction({
cfg: {
channels: { imessage: { allowFrom: ["+15551230000"] } },
},
accountId: "default",
message: buildTapbackReactionPayload({
sender: "+15551230000",
reaction_emoji: "👍",
reacted_to_guid: "expired-message",
}),
bodyText: "",
});
expect(handled).toBe(true);
await expect(
resolveIMessageApprovalReactionTargetWithPersistence({
accountId: "default",
conversation: { handle: "+15551230000" },
messageId: "expired-message",
reactionKey: "👍",
}),
).resolves.toBeNull();
});
it("resolves approvals when the legacy tapback text path is used", async () => {
registerIMessageApprovalReactionTarget({
accountId: "default",
conversation: { handle: "+15551230000" },
messageId: "approval-message",
approvalId: "exec-legacy",
allowedDecisions: ["allow-once", "deny"],
});
const handled = await maybeResolveIMessageApprovalReaction({
cfg: {
channels: { imessage: { allowFrom: ["+15551230000"] } },
},
accountId: "default",
message: {
sender: "+15551230000",
reacted_to_guid: "approval-message",
} as IMessagePayload,
bodyText: "liked “Exec approval required”",
});
// Legacy text tapbacks lack a targetGuid in the reaction context, so they
// should fall through to the dispatch pipeline rather than resolving an
// approval here.
expect(handled).toBe(false);
expect(resolverMocks.resolveIMessageApproval).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,620 @@
// Imessage plugin module implements approval reactions behavior.
import {
addApprovalReactionHintToText,
buildApprovalReactionHint,
createApprovalReactionTargetStore,
hasApprovalReactionHintText,
listApprovalReactionBindings,
resolveApprovalReactionTarget,
type ApprovalReactionDecisionBinding,
type ApprovalReactionTargetRecord,
} from "openclaw/plugin-sdk/approval-reaction-runtime";
import type { ExecApprovalReplyDecision } from "openclaw/plugin-sdk/approval-reply-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import {
asDateTimestampMs,
isFutureDateTimestampMs,
resolveExpiresAtMsFromDurationMs,
} from "openclaw/plugin-sdk/number-runtime";
import { getIMessageApprovalApprovers, imessageApprovalAuth } from "./approval-auth.js";
import { resolveIMessageReactionContext } from "./monitor/reaction-context.js";
import type { IMessagePayload } from "./monitor/types.js";
import { getOptionalIMessageRuntime } from "./runtime.js";
import { normalizeIMessageHandle } from "./targets.js";
const PERSISTENT_NAMESPACE = "imessage.approval-reactions";
const PERSISTENT_MAX_ENTRIES = 1000;
const DEFAULT_REACTION_TARGET_TTL_MS = 24 * 60 * 60 * 1000;
export type IMessageApprovalReactionBinding = ApprovalReactionDecisionBinding;
type IMessageApprovalReactionResolution = {
approvalId: string;
decision: ExecApprovalReplyDecision;
};
export type IMessageApprovalReactionHandleResult =
| { handled: false; stopPolling: false }
| { handled: true; stopPolling: false }
| {
handled: true;
stopPolling: true;
stopPollingReason: "resolved" | "not-found" | "resolver-error";
};
type IMessageApprovalReactionTarget = ApprovalReactionTargetRecord;
export type IMessageApprovalConversationKey = {
chatGuid?: string;
chatIdentifier?: string;
chatId?: number | string;
/** Direct-message handle (already normalized via normalizeIMessageHandle). */
handle?: string;
};
export type PendingIMessageApprovalReactionPollTarget = {
accountId: string;
conversation: IMessageApprovalConversationKey;
messageId: string;
approvalId: string;
allowedDecisions: readonly ExecApprovalReplyDecision[];
expiresAtMs: number;
};
const resolverRuntimeLoader = createLazyRuntimeModule(() => import("./approval-resolver.js"));
const pendingReactionPollTargets = new Map<string, PendingIMessageApprovalReactionPollTarget>();
const loadApprovalResolver = resolverRuntimeLoader;
function chatIdToKeyValue(chatId: number | string | undefined): string | null {
if (chatId == null || chatId === "") {
return null;
}
if (typeof chatId === "number") {
// chat.db ROWID is always > 0; treat 0 as "missing" rather than a valid key.
return Number.isFinite(chatId) && chatId > 0 ? String(chatId) : null;
}
const value = chatId.trim();
return value || null;
}
function enumerateConversationKeyForms(conversation: IMessageApprovalConversationKey): string[] {
const forms: string[] = [];
const chatGuid = conversation.chatGuid?.trim();
if (chatGuid) {
forms.push(`chat_guid:${chatGuid}`);
}
const chatIdentifier = conversation.chatIdentifier?.trim();
if (chatIdentifier) {
forms.push(`chat_identifier:${chatIdentifier}`);
}
const chatIdValue = chatIdToKeyValue(conversation.chatId);
if (chatIdValue) {
forms.push(`chat_id:${chatIdValue}`);
}
const handle = conversation.handle?.trim();
if (handle) {
forms.push(`handle:${handle}`);
}
return forms;
}
function normalizeConversationKey(
conversation: IMessageApprovalConversationKey,
): string | undefined {
return enumerateConversationKeyForms(conversation)[0];
}
function enumerateReactionTargetKeys(params: {
accountId: string;
conversation: IMessageApprovalConversationKey;
messageId: string;
}): string[] {
const accountId = params.accountId.trim();
const messageId = params.messageId.trim();
if (!accountId || !messageId) {
return [];
}
return enumerateConversationKeyForms(params.conversation).map(
(form) => `${accountId}:${form}:${messageId}`,
);
}
function prunePendingReactionPollTargets(nowMs = Date.now()): void {
for (const [key, target] of pendingReactionPollTargets.entries()) {
if (!isFutureDateTimestampMs(target.expiresAtMs, { nowMs })) {
pendingReactionPollTargets.delete(key);
}
}
}
function resolvePendingReactionPollExpiry(
ttlMs: number | undefined,
): { ttlMs: number; expiresAtMs: number } | undefined {
const nowMs = asDateTimestampMs(Date.now());
if (nowMs === undefined) {
return undefined;
}
const expiresAtMs =
resolveExpiresAtMsFromDurationMs(ttlMs ?? DEFAULT_REACTION_TARGET_TTL_MS, { nowMs }) ??
resolveExpiresAtMsFromDurationMs(DEFAULT_REACTION_TARGET_TTL_MS, { nowMs });
if (expiresAtMs === undefined) {
return undefined;
}
return {
ttlMs: expiresAtMs - nowMs,
expiresAtMs,
};
}
function normalizePollTargetMessageId(messageId: string): string {
return messageId.trim().replace(/^p:\d+\//iu, "");
}
function mergePollTargetConversation(
left: IMessageApprovalConversationKey,
right: IMessageApprovalConversationKey,
): IMessageApprovalConversationKey {
return {
chatGuid: left.chatGuid ?? right.chatGuid,
chatIdentifier: left.chatIdentifier ?? right.chatIdentifier,
chatId: left.chatId ?? right.chatId,
handle: left.handle ?? right.handle,
};
}
export function listPendingIMessageApprovalReactionPollTargets(params: {
accountId: string;
}): PendingIMessageApprovalReactionPollTarget[] {
const accountId = params.accountId.trim();
if (!accountId) {
return [];
}
prunePendingReactionPollTargets();
const targetByApprovalAndMessage = new Map<string, PendingIMessageApprovalReactionPollTarget>();
for (const target of pendingReactionPollTargets.values()) {
if (target.accountId !== accountId) {
continue;
}
const key = `${target.approvalId}:${normalizePollTargetMessageId(target.messageId)}`;
const existing = targetByApprovalAndMessage.get(key);
if (!existing) {
targetByApprovalAndMessage.set(key, target);
continue;
}
targetByApprovalAndMessage.set(key, {
...existing,
conversation: mergePollTargetConversation(existing.conversation, target.conversation),
expiresAtMs: Math.max(existing.expiresAtMs, target.expiresAtMs),
});
}
return [...targetByApprovalAndMessage.values()];
}
function reportPersistentApprovalReactionError(error: unknown): void {
try {
getOptionalIMessageRuntime()
?.logging.getChildLogger({ plugin: "imessage", feature: "approval-reaction-state" })
.warn("iMessage persistent approval reaction state failed", { error: String(error) });
} catch {
// Best effort only: persistent state must never break iMessage reactions.
}
}
function readPersistedTarget(value: unknown): IMessageApprovalReactionTarget | null {
const target = value as Partial<IMessageApprovalReactionTarget> | undefined;
if (!target || typeof target.approvalId !== "string" || !Array.isArray(target.allowedDecisions)) {
return null;
}
const allowedDecisions = target.allowedDecisions
.map((valueValue) =>
typeof valueValue === "string" ? normalizeApprovalDecision(valueValue) : null,
)
.filter((valueLocal): valueLocal is ExecApprovalReplyDecision => Boolean(valueLocal));
if (allowedDecisions.length === 0) {
return null;
}
return {
approvalId: target.approvalId,
allowedDecisions,
...(target.approvalKind === "exec" || target.approvalKind === "plugin"
? { approvalKind: target.approvalKind }
: {}),
};
}
const imessageApprovalReactionTargets =
createApprovalReactionTargetStore<IMessageApprovalReactionTarget>({
namespace: PERSISTENT_NAMESPACE,
maxEntries: PERSISTENT_MAX_ENTRIES,
defaultTtlMs: DEFAULT_REACTION_TARGET_TTL_MS,
openStore: (params) => getOptionalIMessageRuntime()?.state.openKeyedStore(params),
logPersistentError: reportPersistentApprovalReactionError,
readPersistedTarget,
});
export function listIMessageApprovalReactionBindings(
allowedDecisions: readonly ExecApprovalReplyDecision[],
): IMessageApprovalReactionBinding[] {
return listApprovalReactionBindings({ allowedDecisions });
}
export function buildIMessageApprovalReactionHint(
allowedDecisions: readonly ExecApprovalReplyDecision[],
): string | null {
return buildApprovalReactionHint({ allowedDecisions });
}
export function addIMessageApprovalReactionHintToText(params: {
text: string;
allowedDecisions: readonly ExecApprovalReplyDecision[];
}): string {
return addApprovalReactionHintToText(params);
}
export function appendIMessageApprovalReactionHintForOutboundMessage(text: string): string {
if (hasApprovalReactionHintText(text)) {
return text;
}
const binding = extractIMessageApprovalPromptBinding(text);
if (!binding) {
return text;
}
return addIMessageApprovalReactionHintToText({
text,
allowedDecisions: binding.allowedDecisions,
});
}
function normalizeApprovalDecision(value: string): ExecApprovalReplyDecision | null {
const normalized = value.trim().toLowerCase();
if (normalized === "always") {
return "allow-always";
}
if (normalized === "allow-once" || normalized === "allow-always" || normalized === "deny") {
return normalized;
}
return null;
}
const APPROVAL_ID_LINE_RE = /^\s*ID:\s*([A-Za-z0-9][A-Za-z0-9._:-]*)\s*$/i;
const APPROVE_COMMAND_LINE_RE = /\/approve(?:@[^\s]+)?\s+([A-Za-z0-9][A-Za-z0-9._:-]*)\s+(.+)$/i;
export function extractIMessageApprovalPromptBinding(text: string): {
approvalId: string;
allowedDecisions: ExecApprovalReplyDecision[];
} | null {
const lines = text.split(/\r?\n/);
// Only treat as an approval prompt if it carries the canonical "ID: <approvalId>"
// header that the SDK payload builders emit. This prevents arbitrary outbound
// text containing `/approve <id> allow-once` (agent help text, quoted docs,
// pasted snippets) from getting a reaction binding registered against it.
const idHeaderMatch = lines
.map((line) => line.match(APPROVAL_ID_LINE_RE))
.find((match): match is RegExpMatchArray => Boolean(match));
if (!idHeaderMatch) {
return null;
}
const approvalId = idHeaderMatch[1];
const allowedDecisions: ExecApprovalReplyDecision[] = [];
for (const line of lines) {
const match = line.match(APPROVE_COMMAND_LINE_RE);
if (!match || match[1] !== approvalId) {
continue;
}
const decisions = match[2].split(/[\s|,]+/);
for (const decisionText of decisions) {
const decision = normalizeApprovalDecision(decisionText);
if (decision && !allowedDecisions.includes(decision)) {
allowedDecisions.push(decision);
}
}
}
return allowedDecisions.length > 0 ? { approvalId, allowedDecisions } : null;
}
export function registerIMessageApprovalReactionTarget(params: {
accountId: string;
conversation: IMessageApprovalConversationKey;
messageId: string;
approvalId: string;
allowedDecisions: readonly ExecApprovalReplyDecision[];
ttlMs?: number;
}): IMessageApprovalReactionTarget | null {
const approvalId = params.approvalId.trim();
const allowedDecisions = listIMessageApprovalReactionBindings(params.allowedDecisions).map(
(binding) => binding.decision,
);
if (!approvalId || allowedDecisions.length === 0) {
return null;
}
const target = { approvalId, allowedDecisions };
const expiry = resolvePendingReactionPollExpiry(params.ttlMs);
if (!expiry) {
return null;
}
// Register the binding under every key we can derive from the conversation
// (chat_guid / chat_identifier / chat_id / handle). Inbound lookup precedence
// can differ from outbound — e.g. send only sees `{handle: "+1..."}` for a
// DM target, while the bridge populates chat_guid on the inbound tapback.
// Indexing under every available key keeps send/inbound symmetric without
// forcing the caller to know which key the bridge will pick.
const keys = enumerateReactionTargetKeys({
accountId: params.accountId,
conversation: params.conversation,
messageId: params.messageId,
});
if (keys.length === 0) {
return null;
}
for (const key of keys) {
imessageApprovalReactionTargets.register(key, target, { ttlMs: expiry.ttlMs });
pendingReactionPollTargets.set(key, {
accountId: params.accountId,
conversation: params.conversation,
messageId: params.messageId,
approvalId,
allowedDecisions,
expiresAtMs: expiry.expiresAtMs,
});
}
prunePendingReactionPollTargets();
return target;
}
export function registerIMessageApprovalReactionTargetForOutboundMessage(params: {
accountId: string;
conversation: IMessageApprovalConversationKey;
messageId: string;
text: string;
ttlMs?: number;
}): boolean {
const binding = extractIMessageApprovalPromptBinding(params.text);
if (!binding) {
return false;
}
return Boolean(
registerIMessageApprovalReactionTarget({
accountId: params.accountId,
conversation: params.conversation,
messageId: params.messageId,
approvalId: binding.approvalId,
allowedDecisions: binding.allowedDecisions,
ttlMs: params.ttlMs,
}),
);
}
export function unregisterIMessageApprovalReactionTarget(params: {
accountId: string;
conversation: IMessageApprovalConversationKey;
messageId: string;
}): void {
const keys = enumerateReactionTargetKeys(params);
for (const key of keys) {
imessageApprovalReactionTargets.delete(key);
pendingReactionPollTargets.delete(key);
}
}
function resolveTarget(params: {
target: IMessageApprovalReactionTarget | null | undefined;
reactionKey: string;
}): IMessageApprovalReactionResolution | null {
const target = resolveApprovalReactionTarget(params);
return target ? { approvalId: target.approvalId, decision: target.decision } : null;
}
export async function resolveIMessageApprovalReactionTargetWithPersistence(params: {
accountId: string;
conversation: IMessageApprovalConversationKey;
messageId: string;
reactionKey: string;
}): Promise<IMessageApprovalReactionResolution | null> {
// Try every key we can derive from the inbound payload. Send-side may have
// registered only `handle:`, while the inbound payload carries chat_guid
// (the bridge sets chat_guid even for DMs). We probe in precedence order
// (chat_guid → chat_identifier → chat_id → handle) and accept the first hit.
const keys = enumerateReactionTargetKeys(params);
for (const key of keys) {
const target = resolveTarget({
target: await imessageApprovalReactionTargets.lookup(key),
reactionKey: params.reactionKey,
});
if (target) {
return target;
}
}
return null;
}
type IMessageApprovalReactionEvent = {
conversation: IMessageApprovalConversationKey;
/** Primary candidate (the normalized targetGuid form). */
messageId: string;
/**
* Every GUID candidate iMessage surfaced for the tapback target. iMessage
* `reaction.targetGuids` contains both the normalized form (e.g. `abc-123`)
* and the raw form (e.g. `p:0/abc-123`). The outbound binding may be
* registered under either form depending on which the imsg bridge returned
* from `send`, so the lookup must probe all of them.
*/
messageIdCandidates: readonly string[];
actorHandle: string;
reactionKey: string;
action: "added" | "removed";
};
function readApprovalReactionEvent(
message: IMessagePayload,
bodyText: string,
): IMessageApprovalReactionEvent | null {
const reaction = resolveIMessageReactionContext(message, bodyText);
if (!reaction) {
return null;
}
const reactionKey = reaction.emoji.trim();
const candidates = (reaction.targetGuids ?? [])
.map((value) => value.trim())
.filter((value) => value.length > 0);
const primary = reaction.targetGuid?.trim() || candidates[0] || "";
const messageIdCandidates = candidates.length > 0 ? candidates : primary ? [primary] : [];
const actorHandle = normalizeIMessageHandle((message.sender ?? "").trim());
if (!reactionKey || !primary || !actorHandle) {
return null;
}
const conversation: IMessageApprovalConversationKey = {
...(message.chat_guid?.trim() ? { chatGuid: message.chat_guid.trim() } : {}),
...(message.chat_identifier?.trim() ? { chatIdentifier: message.chat_identifier.trim() } : {}),
...(chatIdToKeyValue(message.chat_id ?? undefined)
? { chatId: message.chat_id as number }
: {}),
...(message.is_group ? {} : { handle: actorHandle }),
};
if (!normalizeConversationKey(conversation)) {
return null;
}
return {
conversation,
messageId: primary,
messageIdCandidates,
actorHandle,
reactionKey,
action: reaction.action,
};
}
export async function handleIMessageApprovalReaction(params: {
cfg: OpenClawConfig;
accountId: string;
message: IMessagePayload;
bodyText: string;
gatewayUrl?: string;
logVerboseMessage?: (message: string) => void;
}): Promise<IMessageApprovalReactionHandleResult> {
const event = readApprovalReactionEvent(params.message, params.bodyText);
if (!event) {
return { handled: false, stopPolling: false };
}
// A removed tapback (user un-taps 👍 or switches to a different emoji) is
// intentionally NOT a fresh resolve. We only want to clear the binding so
// the next added-tapback resolves freshly. Falling through to `return false`
// would surface the un-tap as a noisy reaction system event; instead we
// own the event and stay quiet.
if (event.action === "removed") {
return { handled: false, stopPolling: false };
}
let target: IMessageApprovalReactionResolution | null = null;
let matchedMessageId: string | null = null;
for (const candidate of event.messageIdCandidates) {
target = await resolveIMessageApprovalReactionTargetWithPersistence({
accountId: params.accountId,
conversation: event.conversation,
messageId: candidate,
reactionKey: event.reactionKey,
});
if (target) {
matchedMessageId = candidate;
break;
}
}
if (!target) {
return { handled: false, stopPolling: false };
}
const approvalKind = target.approvalId.startsWith("plugin:") ? "plugin" : "exec";
const approvers = getIMessageApprovalApprovers({ cfg: params.cfg, accountId: params.accountId });
if (approvers.length === 0) {
params.logVerboseMessage?.(
`imessage: approval reaction denied id=${target.approvalId}; reactions require explicit approvers`,
);
return { handled: true, stopPolling: false };
}
const auth = imessageApprovalAuth.authorizeActorAction({
cfg: params.cfg,
accountId: params.accountId,
senderId: event.actorHandle,
action: "approve",
approvalKind,
});
if (!auth.authorized) {
params.logVerboseMessage?.(
`imessage: approval reaction denied id=${target.approvalId} sender=${event.actorHandle}`,
);
return { handled: true, stopPolling: false };
}
const { isApprovalNotFoundError, resolveIMessageApproval } = await loadApprovalResolver();
try {
await resolveIMessageApproval({
cfg: params.cfg,
approvalId: target.approvalId,
decision: target.decision,
senderId: event.actorHandle,
gatewayUrl: params.gatewayUrl,
});
// Clear the binding on success so a second tapback (toggle 👍→👎, Apple
// cross-device echo, or chat.db replay) does not re-fire and produce a
// misleading 'expired approval' log line. Iterate every GUID candidate the
// inbound surfaced so the prefixed/unprefixed form pair both get cleared.
for (const candidate of event.messageIdCandidates) {
unregisterIMessageApprovalReactionTarget({
accountId: params.accountId,
conversation: event.conversation,
messageId: candidate,
});
}
params.logVerboseMessage?.(
`imessage: approval reaction resolved id=${target.approvalId} sender=${event.actorHandle} decision=${target.decision} via messageId=${matchedMessageId ?? event.messageId}`,
);
return { handled: true, stopPolling: true, stopPollingReason: "resolved" };
} catch (error) {
if (isApprovalNotFoundError(error)) {
for (const candidate of event.messageIdCandidates) {
unregisterIMessageApprovalReactionTarget({
accountId: params.accountId,
conversation: event.conversation,
messageId: candidate,
});
}
params.logVerboseMessage?.(
`imessage: approval reaction ignored for expired approval id=${target.approvalId} sender=${event.actorHandle}`,
);
return { handled: true, stopPolling: true, stopPollingReason: "not-found" };
}
// Surface non-NotFound errors at warn level so a gateway 5xx / network
// outage / auth failure is visible without OPENCLAW_LOG_LEVEL=debug.
try {
getOptionalIMessageRuntime()
?.logging.getChildLogger({ plugin: "imessage", feature: "approval-reactions" })
.warn("approval reaction failed", {
approvalId: target.approvalId,
senderId: event.actorHandle,
error: String(error),
});
} catch {
// Logger surface is optional in tests; never let logging mask the error.
}
params.logVerboseMessage?.(
`imessage: approval reaction failed id=${target.approvalId} sender=${event.actorHandle}: ${String(error)}`,
);
return { handled: true, stopPolling: true, stopPollingReason: "resolver-error" };
}
}
export async function maybeResolveIMessageApprovalReaction(params: {
cfg: OpenClawConfig;
accountId: string;
message: IMessagePayload;
bodyText: string;
gatewayUrl?: string;
logVerboseMessage?: (message: string) => void;
}): Promise<boolean> {
return (await handleIMessageApprovalReaction(params)).handled;
}
export function clearIMessageApprovalReactionTargetsForTest(): void {
imessageApprovalReactionTargets.clearForTest();
pendingReactionPollTargets.clear();
resolverRuntimeLoader.clear();
}

View File

@@ -0,0 +1,24 @@
// Imessage plugin module implements approval resolver behavior.
import { resolveApprovalOverGateway } from "openclaw/plugin-sdk/approval-gateway-runtime";
import type { ExecApprovalReplyDecision } from "openclaw/plugin-sdk/approval-reply-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { isApprovalNotFoundError } from "openclaw/plugin-sdk/error-runtime";
export { isApprovalNotFoundError };
export async function resolveIMessageApproval(params: {
cfg: OpenClawConfig;
approvalId: string;
decision: ExecApprovalReplyDecision;
senderId?: string | null;
gatewayUrl?: string;
}): Promise<void> {
await resolveApprovalOverGateway({
cfg: params.cfg,
approvalId: params.approvalId,
decision: params.decision,
senderId: params.senderId,
gatewayUrl: params.gatewayUrl,
clientDisplayName: `iMessage approval (${params.senderId?.trim() || "unknown"})`,
});
}

View File

@@ -0,0 +1,7 @@
// Substitute `/approve <id>` placeholders with the concrete approval id while
// escaping `$` so an approvalId containing `$&`/`$1`-`$9`/`$$`/`` $` ``/`$'` is
// not interpreted as a regex replacement pattern by String.prototype.replace.
export function replaceApprovalIdPlaceholder(text: string | undefined, approvalId: string): string {
const safeApprovalId = approvalId.replace(/\$/g, "$$$$");
return (text ?? "").replace(/\/approve\s+<id>/g, `/approve ${safeApprovalId}`);
}

View File

@@ -0,0 +1,24 @@
// Imessage API module exposes the plugin public contract.
import { formatTrimmedAllowFromEntries } from "openclaw/plugin-sdk/channel-config-helpers";
import { PAIRING_APPROVED_MESSAGE } from "openclaw/plugin-sdk/channel-status";
import {
DEFAULT_ACCOUNT_ID,
getChatChannelMeta,
type ChannelPlugin,
} from "openclaw/plugin-sdk/core";
import { resolveChannelMediaMaxBytes } from "openclaw/plugin-sdk/media-runtime";
import { collectStatusIssuesFromLastError } from "openclaw/plugin-sdk/status-helpers";
import { normalizeIMessageMessagingTarget } from "./normalize.js";
export { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking";
export {
collectStatusIssuesFromLastError,
DEFAULT_ACCOUNT_ID,
formatTrimmedAllowFromEntries,
getChatChannelMeta,
normalizeIMessageMessagingTarget,
PAIRING_APPROVED_MESSAGE,
resolveChannelMediaMaxBytes,
};
export type { ChannelPlugin };

View File

@@ -0,0 +1,81 @@
// Imessage tests cover channel inbound roots.contract plugin behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { describe, expect, it } from "vitest";
import {
DEFAULT_IMESSAGE_ATTACHMENT_ROOTS,
resolveIMessageAttachmentRoots,
resolveIMessageRemoteAttachmentRoots,
} from "../contract-api.js";
describe("iMessage channel-inbound-roots contract", () => {
function expectResolvedRootsCase(resolve: () => string[], expected: readonly string[]) {
expect(resolve()).toEqual(expected);
}
const accountOverrideCfg = {
channels: {
imessage: {
attachmentRoots: ["/Users/*/Library/Messages/Attachments"],
remoteAttachmentRoots: ["/Volumes/shared/imessage"],
accounts: {
work: {
attachmentRoots: ["/Users/work/Library/Messages/Attachments"],
remoteAttachmentRoots: ["/srv/work/attachments"],
},
},
},
},
} as OpenClawConfig;
it("resolves configured attachment roots with account overrides", () => {
expectResolvedRootsCase(
() => resolveIMessageAttachmentRoots({ cfg: accountOverrideCfg, accountId: "work" }),
["/Users/work/Library/Messages/Attachments", "/Users/*/Library/Messages/Attachments"],
);
});
it("resolves configured remote attachment roots with account overrides", () => {
expectResolvedRootsCase(
() => resolveIMessageRemoteAttachmentRoots({ cfg: accountOverrideCfg, accountId: "work" }),
[
"/srv/work/attachments",
"/Volumes/shared/imessage",
"/Users/work/Library/Messages/Attachments",
"/Users/*/Library/Messages/Attachments",
],
);
});
it("matches iMessage account ids case-insensitively for attachment roots", () => {
const cfg = {
channels: {
imessage: {
accounts: {
Work: {
attachmentRoots: ["/Users/work/Library/Messages/Attachments"],
},
},
},
},
} as OpenClawConfig;
expectResolvedRootsCase(
() => resolveIMessageAttachmentRoots({ cfg, accountId: "work" }),
["/Users/work/Library/Messages/Attachments", ...DEFAULT_IMESSAGE_ATTACHMENT_ROOTS],
);
});
it("falls back to default iMessage attachment roots", () => {
expectResolvedRootsCase(
() => resolveIMessageAttachmentRoots({ cfg: {} as OpenClawConfig }),
[...DEFAULT_IMESSAGE_ATTACHMENT_ROOTS],
);
});
it("falls back to default iMessage remote attachment roots", () => {
expectResolvedRootsCase(
() => resolveIMessageRemoteAttachmentRoots({ cfg: {} as OpenClawConfig }),
[...DEFAULT_IMESSAGE_ATTACHMENT_ROOTS],
);
});
});

View File

@@ -0,0 +1,109 @@
// Imessage tests cover channel plugin behavior.
import { describe, expect, it, vi } from "vitest";
const monitorMock = vi.hoisted(() => vi.fn(async () => undefined));
vi.mock("./monitor.js", async (importOriginal) => ({
...(await importOriginal<typeof import("./monitor.js")>()),
monitorIMessageProvider: monitorMock,
}));
const { startIMessageGatewayAccount } = await import("./channel.runtime.js");
const { resolveIMessageAccount } = await import("./accounts.js");
function makeCtx(params: {
cfg: Parameters<typeof resolveIMessageAccount>[0]["cfg"];
accountId: string;
}) {
const account = resolveIMessageAccount({ cfg: params.cfg, accountId: params.accountId });
const ac = new AbortController();
const statusEvents: unknown[] = [];
const logEvents: { level: string; line: string }[] = [];
return {
ctx: {
cfg: params.cfg,
accountId: params.accountId,
account,
runtime: {} as never,
abortSignal: ac.signal,
log: {
info: (line: string) => logEvents.push({ level: "info", line }),
},
getStatus: () => ({ accountId: params.accountId }),
setStatus: (next: unknown) => statusEvents.push(next),
channelRuntime: undefined as never,
} as never,
abort: () => ac.abort(),
statusEvents,
logEvents,
};
}
describe("startIMessageGatewayAccount duplicate-source handling", () => {
it("parks the watcher slot without spawning monitorIMessageProvider for a non-owner duplicate", async () => {
monitorMock.mockClear();
const cfg = {
channels: {
imessage: {
accounts: {
"swang430-gmail-com": { cliPath: "imsg" },
default: {},
},
},
},
} as never;
const { ctx, abort, logEvents } = makeCtx({ cfg, accountId: "default" });
const settled = vi.fn();
const task = startIMessageGatewayAccount(ctx).then(settled);
await Promise.resolve();
await Promise.resolve();
expect(monitorMock).not.toHaveBeenCalled();
expect(settled).not.toHaveBeenCalled();
expect(logEvents.some((e) => e.line.includes("skipping watcher"))).toBe(true);
expect(logEvents.some((e) => e.line.includes('using account "swang430-gmail-com"'))).toBe(true);
abort();
await task;
expect(settled).toHaveBeenCalled();
expect(monitorMock).not.toHaveBeenCalled();
});
it("starts monitorIMessageProvider for the duplicate-source owner", async () => {
monitorMock.mockClear();
monitorMock.mockResolvedValueOnce(undefined);
const cfg = {
channels: {
imessage: {
accounts: {
"swang430-gmail-com": { cliPath: "imsg" },
default: {},
},
},
},
} as never;
const { ctx } = makeCtx({ cfg, accountId: "swang430-gmail-com" });
await startIMessageGatewayAccount(ctx);
expect(monitorMock).toHaveBeenCalledTimes(1);
});
it("starts monitorIMessageProvider when an account has no duplicate sibling", async () => {
monitorMock.mockClear();
monitorMock.mockResolvedValueOnce(undefined);
const cfg = {
channels: {
imessage: {
accounts: {
solo: { cliPath: "/usr/local/bin/imsg-solo" },
},
},
},
} as never;
const { ctx } = makeCtx({ cfg, accountId: "solo" });
await startIMessageGatewayAccount(ctx);
expect(monitorMock).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,108 @@
// Imessage plugin module implements channel behavior.
import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-outbound";
import { resolveIMessageDuplicateSourceOwner, type ResolvedIMessageAccount } from "./accounts.js";
import { PAIRING_APPROVED_MESSAGE, resolveChannelMediaMaxBytes } from "./channel-api.js";
import type { ChannelPlugin } from "./channel-api.js";
import { monitorIMessageProvider } from "./monitor.js";
import { IMESSAGE_LEGACY_OUTBOUND_SEND_DEP_KEYS } from "./outbound-send-deps.js";
import { probeIMessage } from "./probe.js";
import { sendMessageIMessage } from "./send.js";
import { imessageSetupWizard } from "./setup-surface.js";
type IMessageSendFn = typeof sendMessageIMessage;
export async function sendIMessageOutbound(params: {
cfg: Parameters<typeof import("./accounts.js").resolveIMessageAccount>[0]["cfg"];
to: string;
text: string;
mediaUrl?: string;
mediaLocalRoots?: readonly string[];
audioAsVoice?: boolean;
accountId?: string;
deps?: { [channelId: string]: unknown };
replyToId?: string;
}) {
const send =
resolveOutboundSendDep<IMessageSendFn>(params.deps, "imessage", {
legacyKeys: IMESSAGE_LEGACY_OUTBOUND_SEND_DEP_KEYS,
}) ?? sendMessageIMessage;
const maxBytes = resolveChannelMediaMaxBytes({
cfg: params.cfg,
resolveChannelLimitMb: ({ cfg, accountId }) =>
cfg.channels?.imessage?.accounts?.[accountId]?.mediaMaxMb ??
cfg.channels?.imessage?.mediaMaxMb,
accountId: params.accountId,
});
return await send(params.to, params.text, {
config: params.cfg,
...(params.mediaUrl ? { mediaUrl: params.mediaUrl } : {}),
...(params.mediaLocalRoots?.length ? { mediaLocalRoots: params.mediaLocalRoots } : {}),
...(params.audioAsVoice ? { audioAsVoice: true } : {}),
maxBytes,
accountId: params.accountId ?? undefined,
replyToId: params.replyToId ?? undefined,
});
}
export async function notifyIMessageApproval(params: {
cfg: Parameters<typeof import("./accounts.js").resolveIMessageAccount>[0]["cfg"];
id: string;
}): Promise<void> {
await sendMessageIMessage(params.id, PAIRING_APPROVED_MESSAGE, { config: params.cfg });
}
export async function probeIMessageAccount(params?: {
timeoutMs?: number;
cliPath?: string;
dbPath?: string;
}) {
return await probeIMessage(params?.timeoutMs, {
cliPath: params?.cliPath,
dbPath: params?.dbPath,
forceRefresh: true,
});
}
export async function startIMessageGatewayAccount(
ctx: Parameters<
NonNullable<NonNullable<ChannelPlugin<ResolvedIMessageAccount>["gateway"]>["startAccount"]>
>[0],
) {
const account = ctx.account;
const cliPath = account.config.cliPath?.trim() || "imsg";
const dbPath = account.config.dbPath?.trim();
ctx.setStatus({
accountId: account.accountId,
cliPath,
dbPath: dbPath ?? null,
});
const ownerAccountId = resolveIMessageDuplicateSourceOwner({ cfg: ctx.cfg, account });
if (ownerAccountId) {
// openclaw/openclaw#65141: this account shares a local Messages source with
// an already-owning account, so spawning a second `imsg rpc` would deliver
// every inbound twice. Keep the account enabled for outbound sends, status,
// and capability surfaces; just park the watcher slot until shutdown.
ctx.log?.info?.(
`[${account.accountId}] skipping watcher: duplicate iMessage source; using account "${ownerAccountId}"`,
);
if (ctx.abortSignal.aborted) {
return;
}
await new Promise<void>((resolve) => {
ctx.abortSignal.addEventListener("abort", () => resolve(), { once: true });
});
return;
}
ctx.log?.info?.(
`[${account.accountId}] starting provider (${cliPath}${dbPath ? ` db=${dbPath}` : ""})`,
);
return await monitorIMessageProvider({
accountId: account.accountId,
config: ctx.cfg,
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
channelRuntime: ctx.channelRuntime,
});
}
export { imessageSetupWizard };

View File

@@ -0,0 +1,12 @@
// Imessage plugin module implements channel.setup behavior.
import type { ResolvedIMessageAccount } from "./accounts.js";
import type { ChannelPlugin } from "./channel-api.js";
import { imessageSetupAdapter } from "./setup-core.js";
import { createIMessagePluginBase, imessageSetupWizard } from "./shared.js";
export const imessageSetupPlugin: ChannelPlugin<ResolvedIMessageAccount> = {
...createIMessagePluginBase({
setupWizard: imessageSetupWizard,
setup: imessageSetupAdapter,
}),
};

View File

@@ -0,0 +1,390 @@
// Imessage plugin module implements channel behavior.
import { buildDmGroupAccountAllowlistAdapter } from "openclaw/plugin-sdk/allowlist-config-edit";
import { createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core";
import {
createMessageReceiptFromOutboundResults,
defineChannelMessageAdapter,
type ChannelMessageSendResult,
type MessageReceiptPartKind,
} from "openclaw/plugin-sdk/channel-outbound";
import { sanitizeForPlainText } from "openclaw/plugin-sdk/channel-outbound";
import { buildPassiveProbedChannelStatusSummary } from "openclaw/plugin-sdk/extension-shared";
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import { buildOutboundBaseSessionKey, type RoutePeer } from "openclaw/plugin-sdk/routing";
import {
createComputedAccountStatusAdapter,
createDefaultChannelRuntimeState,
} from "openclaw/plugin-sdk/status-helpers";
import { resolveIMessageAccount, type ResolvedIMessageAccount } from "./accounts.js";
import { imessageMessageActions } from "./actions.js";
import {
imessageApprovalCapability,
shouldSuppressLocalIMessageExecApprovalPrompt,
} from "./approval-native.js";
import {
chunkTextForOutbound,
collectStatusIssuesFromLastError,
DEFAULT_ACCOUNT_ID,
formatTrimmedAllowFromEntries,
normalizeIMessageMessagingTarget,
type ChannelPlugin,
} from "./channel-api.js";
import { createIMessageConversationBindingManager } from "./conversation-bindings.js";
import {
matchIMessageAcpConversation,
normalizeIMessageAcpConversationId,
resolveIMessageConversationIdFromTarget,
} from "./conversation-id.js";
import { imessageDoctor } from "./doctor.js";
import {
resolveIMessageGroupRequireMention,
resolveIMessageGroupToolPolicy,
} from "./group-policy.js";
import { sanitizeOutboundText } from "./monitor/sanitize-outbound.js";
import type { IMessageProbe } from "./probe.js";
import { imessageSetupAdapter } from "./setup-core.js";
import {
createIMessagePluginBase,
imessageSecurityAdapter,
imessageSetupWizard,
} from "./shared.js";
import { probeIMessageStatusAccount } from "./status-core.js";
import {
inferIMessageTargetChatType,
looksLikeIMessageExplicitTargetId,
normalizeIMessageHandle,
parseIMessageTarget,
} from "./targets.js";
const loadIMessageChannelRuntime = createLazyRuntimeModule(() => import("./channel.runtime.js"));
type IMessageMessageContextExtras = {
deps?: { [channelId: string]: unknown };
};
function toIMessageMessageSendResult(
result: { messageId?: string; receipt?: ChannelMessageSendResult["receipt"] },
kind: MessageReceiptPartKind,
replyToId?: string | null,
): ChannelMessageSendResult {
const receipt =
result.receipt ??
createMessageReceiptFromOutboundResults({
results: result.messageId ? [{ channel: "imessage", messageId: result.messageId }] : [],
kind,
...(replyToId ? { replyToId } : {}),
});
return {
messageId: result.messageId || receipt.primaryPlatformMessageId,
receipt,
};
}
const imessageMessageAdapter = defineChannelMessageAdapter({
id: "imessage",
durableFinal: {
capabilities: {
text: true,
media: true,
replyTo: true,
messageSendingHooks: true,
},
},
send: {
text: async (ctx) => {
const result = await (
await loadIMessageChannelRuntime()
).sendIMessageOutbound({
cfg: ctx.cfg,
to: ctx.to,
text: ctx.text,
accountId: ctx.accountId ?? undefined,
deps: (ctx as typeof ctx & IMessageMessageContextExtras).deps,
replyToId: ctx.replyToId ?? undefined,
});
return toIMessageMessageSendResult(result, "text", ctx.replyToId);
},
media: async (ctx) => {
const result = await (
await loadIMessageChannelRuntime()
).sendIMessageOutbound({
cfg: ctx.cfg,
to: ctx.to,
text: ctx.text,
mediaUrl: ctx.mediaUrl,
mediaLocalRoots: ctx.mediaLocalRoots,
audioAsVoice: ctx.audioAsVoice,
accountId: ctx.accountId ?? undefined,
deps: (ctx as typeof ctx & IMessageMessageContextExtras).deps,
replyToId: ctx.replyToId ?? undefined,
});
return toIMessageMessageSendResult(
result,
ctx.audioAsVoice ? "voice" : "media",
ctx.replyToId,
);
},
},
});
function buildIMessageBaseSessionKey(params: {
cfg: Parameters<typeof resolveIMessageAccount>[0]["cfg"];
agentId: string;
accountId?: string | null;
peer: RoutePeer;
}) {
return buildOutboundBaseSessionKey({ ...params, channel: "imessage" });
}
function resolveIMessageOutboundSessionRoute(params: {
cfg: Parameters<typeof resolveIMessageAccount>[0]["cfg"];
agentId: string;
accountId?: string | null;
target: string;
}) {
const parsed = parseIMessageTarget(params.target);
if (parsed.kind === "handle") {
const handle = normalizeIMessageHandle(parsed.to);
if (!handle) {
return null;
}
const account = resolveIMessageAccount({ cfg: params.cfg, accountId: params.accountId });
const service =
parsed.serviceExplicit || parsed.service !== "auto"
? parsed.service
: account.config.service === "sms"
? "sms"
: "imessage";
const directTarget = `${service}:${handle}`;
const peer: RoutePeer = { kind: "direct", id: handle };
const baseSessionKey = buildIMessageBaseSessionKey({
cfg: params.cfg,
agentId: params.agentId,
accountId: params.accountId,
peer,
});
return {
sessionKey: baseSessionKey,
baseSessionKey,
peer,
chatType: "direct" as const,
from: directTarget,
to: directTarget,
};
}
const peerId =
parsed.kind === "chat_id"
? String(parsed.chatId)
: parsed.kind === "chat_guid"
? parsed.chatGuid
: parsed.chatIdentifier;
if (!peerId) {
return null;
}
const peer: RoutePeer = { kind: "group", id: peerId };
const baseSessionKey = buildIMessageBaseSessionKey({
cfg: params.cfg,
agentId: params.agentId,
accountId: params.accountId,
peer,
});
const toPrefix =
parsed.kind === "chat_id"
? "chat_id"
: parsed.kind === "chat_guid"
? "chat_guid"
: "chat_identifier";
return {
sessionKey: baseSessionKey,
baseSessionKey,
peer,
chatType: "group" as const,
from: `imessage:group:${peerId}`,
to: `${toPrefix}:${peerId}`,
};
}
export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount, IMessageProbe> =
createChatChannelPlugin<ResolvedIMessageAccount, IMessageProbe>({
base: {
...createIMessagePluginBase({
setupWizard: imessageSetupWizard,
setup: imessageSetupAdapter,
}),
allowlist: buildDmGroupAccountAllowlistAdapter({
channelId: "imessage",
resolveAccount: resolveIMessageAccount,
normalize: ({ values }) => formatTrimmedAllowFromEntries(values),
resolveDmAllowFrom: (account) => account.config.allowFrom,
resolveGroupAllowFrom: (account) => account.config.groupAllowFrom,
resolveDmPolicy: (account) => account.config.dmPolicy,
resolveGroupPolicy: (account) => account.config.groupPolicy,
}),
groups: {
resolveRequireMention: resolveIMessageGroupRequireMention,
resolveToolPolicy: resolveIMessageGroupToolPolicy,
},
doctor: imessageDoctor,
conversationBindings: {
supportsCurrentConversationBinding: true,
createManager: ({ cfg, accountId }) =>
createIMessageConversationBindingManager({
cfg,
accountId: accountId ?? undefined,
}),
},
bindings: {
compileConfiguredBinding: ({ conversationId }) =>
normalizeIMessageAcpConversationId(conversationId),
matchInboundConversation: ({ compiledBinding, conversationId }) =>
matchIMessageAcpConversation({
bindingConversationId: compiledBinding.conversationId,
conversationId,
}),
resolveCommandConversation: ({ originatingTo, commandTo, fallbackTo }) => {
const conversationId =
resolveIMessageConversationIdFromTarget(originatingTo ?? "") ??
resolveIMessageConversationIdFromTarget(commandTo ?? "") ??
resolveIMessageConversationIdFromTarget(fallbackTo ?? "");
return conversationId ? { conversationId } : null;
},
},
messaging: {
normalizeTarget: normalizeIMessageMessagingTarget,
inferTargetChatType: ({ to }) => inferIMessageTargetChatType(to),
resolveOutboundSessionRoute: (params) => resolveIMessageOutboundSessionRoute(params),
targetResolver: {
looksLikeId: looksLikeIMessageExplicitTargetId,
hint: "<handle|chat_id:ID>",
resolveTarget: async ({ normalized }) => {
const to = normalized?.trim();
if (!to) {
return null;
}
const chatType = inferIMessageTargetChatType(to);
if (!chatType) {
return null;
}
return {
to,
kind: chatType === "direct" ? "user" : "group",
source: "normalized" as const,
};
},
},
},
status: createComputedAccountStatusAdapter<ResolvedIMessageAccount, IMessageProbe>({
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID, {
cliPath: null,
dbPath: null,
}),
collectStatusIssues: (accounts) => collectStatusIssuesFromLastError("imessage", accounts),
buildChannelSummary: ({ snapshot }) =>
buildPassiveProbedChannelStatusSummary(snapshot, {
cliPath: snapshot.cliPath ?? null,
dbPath: snapshot.dbPath ?? null,
}),
probeAccount: async ({ account, timeoutMs }) =>
await probeIMessageStatusAccount({
account,
timeoutMs,
probeIMessageAccount: async (params) =>
await (await loadIMessageChannelRuntime()).probeIMessageAccount(params),
}),
resolveAccountSnapshot: ({ account, runtime }) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: account.configured,
extra: {
cliPath: runtime?.cliPath ?? account.config.cliPath ?? null,
dbPath: runtime?.dbPath ?? account.config.dbPath ?? null,
},
}),
resolveAccountState: ({ enabled }) => (enabled ? "enabled" : "disabled"),
}),
gateway: {
startAccount: async (ctx) => {
const conversationBindings = createIMessageConversationBindingManager({
cfg: ctx.cfg,
accountId: ctx.accountId,
});
try {
return await (await loadIMessageChannelRuntime()).startIMessageGatewayAccount(ctx);
} finally {
conversationBindings.stop();
}
},
},
message: imessageMessageAdapter,
actions: imessageMessageActions,
approvalCapability: imessageApprovalCapability,
},
pairing: {
text: {
idLabel: "imessageSenderId",
message: "OpenClaw: your access has been approved.",
notify: async ({ id, cfg }) =>
await (await loadIMessageChannelRuntime()).notifyIMessageApproval({ id, cfg }),
},
},
security: imessageSecurityAdapter,
outbound: {
base: {
deliveryMode: "direct",
chunker: chunkTextForOutbound,
chunkerMode: "text",
textChunkLimit: 4000,
sanitizeText: ({ text }) => sanitizeForPlainText(sanitizeOutboundText(text)),
shouldSuppressLocalPayloadPrompt: ({ cfg, accountId, payload, hint }) =>
shouldSuppressLocalIMessageExecApprovalPrompt({ cfg, accountId, payload, hint }),
deliveryCapabilities: {
durableFinal: {
text: true,
media: true,
replyTo: true,
messageSendingHooks: true,
},
},
},
attachedResults: {
channel: "imessage",
sendText: async ({ cfg, to, text, accountId, deps, replyToId }) =>
await (
await loadIMessageChannelRuntime()
).sendIMessageOutbound({
cfg,
to,
text,
accountId: accountId ?? undefined,
deps,
replyToId: replyToId ?? undefined,
}),
sendMedia: async ({
cfg,
to,
text,
mediaUrl,
mediaLocalRoots,
audioAsVoice,
accountId,
deps,
replyToId,
}) =>
await (
await loadIMessageChannelRuntime()
).sendIMessageOutbound({
cfg,
to,
text,
mediaUrl,
mediaLocalRoots,
audioAsVoice,
accountId: accountId ?? undefined,
deps,
replyToId: replyToId ?? undefined,
}),
},
},
});

View File

@@ -0,0 +1,87 @@
// Imessage plugin module implements chat behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { requireRuntimeConfig } from "openclaw/plugin-sdk/plugin-config-runtime";
import { resolveIMessageAccount, type ResolvedIMessageAccount } from "./accounts.js";
import { createIMessageRpcClient, type IMessageRpcClient } from "./client.js";
import { formatIMessageChatTarget, type IMessageService, parseIMessageTarget } from "./targets.js";
type ChatActionOpts = {
cfg: OpenClawConfig;
accountId?: string;
account?: ResolvedIMessageAccount;
client?: IMessageRpcClient;
cliPath?: string;
dbPath?: string;
service?: IMessageService;
region?: string;
timeoutMs?: number;
chatId?: number;
};
function buildChatTargetParams(
to: string,
opts: ChatActionOpts,
): {
params: Record<string, unknown>;
service?: IMessageService;
region?: string;
account: ResolvedIMessageAccount;
} {
const cfg = requireRuntimeConfig(opts.cfg, "iMessage chat action");
const account = opts.account ?? resolveIMessageAccount({ cfg, accountId: opts.accountId });
const target = parseIMessageTarget(opts.chatId ? formatIMessageChatTarget(opts.chatId) : to);
const params: Record<string, unknown> = {};
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 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";
return { params, service, region, account };
}
async function runChatAction<T>(
method: "typing" | "read",
params: Record<string, unknown>,
opts: ChatActionOpts,
): Promise<T> {
const cfg = requireRuntimeConfig(opts.cfg, "iMessage chat action");
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 client = opts.client ?? (await createIMessageRpcClient({ cliPath, dbPath }));
const shouldClose = !opts.client;
try {
return await client.request<T>(method, params, { timeoutMs: opts.timeoutMs });
} finally {
if (shouldClose) {
await client.stop();
}
}
}
export async function sendIMessageTyping(
to: string,
isTyping: boolean,
opts: ChatActionOpts,
): Promise<void> {
const { params, service } = buildChatTargetParams(to, opts);
params.typing = isTyping;
if (service) {
params.service = service;
}
await runChatAction<{ ok?: boolean }>("typing", params, opts);
}
export async function markIMessageChatRead(to: string, opts: ChatActionOpts): Promise<void> {
const { params } = buildChatTargetParams(to, opts);
await runChatAction<{ ok?: boolean }>("read", params, opts);
}

View File

@@ -0,0 +1,20 @@
// Imessage tests cover cli output plugin behavior.
import { describe, expect, it } from "vitest";
import { appendIMessageCliStderrTail, appendIMessageCliStdout } from "./cli-output.js";
describe("iMessage CLI output bounds", () => {
it("rejects stdout once the JSON capture exceeds the cap", () => {
const result = appendIMessageCliStdout("abc", "def", 5);
expect(result).toEqual({
ok: false,
message: "imsg stdout exceeded 5 characters",
});
});
it("keeps only recent stderr details", () => {
const result = appendIMessageCliStderrTail("old-noise:", "recent-error", 12);
expect(result).toBe("recent-error");
});
});

View File

@@ -0,0 +1,30 @@
// Imessage plugin module implements cli output behavior.
export const IMESSAGE_CLI_STDOUT_MAX_CHARS = 8 * 1024 * 1024;
export const IMESSAGE_CLI_STDERR_TAIL_CHARS = 64 * 1024;
type AppendStdoutResult = { ok: true; value: string } | { ok: false; message: string };
function chunkToString(chunk: string | Buffer): string {
return typeof chunk === "string" ? chunk : chunk.toString("utf8");
}
export function appendIMessageCliStdout(
current: string,
chunk: string | Buffer,
maxChars = IMESSAGE_CLI_STDOUT_MAX_CHARS,
): AppendStdoutResult {
const next = current + chunkToString(chunk);
if (next.length > maxChars) {
return { ok: false, message: `imsg stdout exceeded ${maxChars} characters` };
}
return { ok: true, value: next };
}
export function appendIMessageCliStderrTail(
current: string,
chunk: string | Buffer,
maxChars = IMESSAGE_CLI_STDERR_TAIL_CHARS,
): string {
const next = current + chunkToString(chunk);
return next.length > maxChars ? next.slice(-maxChars) : next;
}

View File

@@ -0,0 +1,339 @@
// Imessage plugin module implements client behavior.
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
import { StringDecoder } from "node:string_decoder";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import { resolveUserPath } from "openclaw/plugin-sdk/text-utility-runtime";
import { DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS } from "./constants.js";
export type IMessageRpcError = {
code?: number;
message?: string;
data?: unknown;
};
export type IMessageRpcResponse<T> = {
jsonrpc?: string;
id?: string | number | null;
result?: T;
error?: IMessageRpcError;
method?: string;
params?: unknown;
};
export type IMessageRpcNotification = {
method: string;
params?: unknown;
};
export type IMessageRpcClientOptions = {
cliPath?: string;
dbPath?: string;
runtime?: RuntimeEnv;
onNotification?: (msg: IMessageRpcNotification) => void;
};
type PendingRequest = {
resolve: (value: unknown) => void;
reject: (error: Error) => void;
timer?: NodeJS.Timeout;
};
export const PUBLIC_IMESSAGE_FULL_DISK_ACCESS_ERROR =
"imsg cannot access ~/Library/Messages/chat.db. Grant Full Disk Access to the Gateway/launcher process and restart Gateway.";
function isTestEnv(): boolean {
if (process.env.NODE_ENV === "test") {
return true;
}
const vitest = normalizeLowercaseStringOrEmpty(process.env.VITEST);
return Boolean(vitest);
}
export function normalizeIMessageFullDiskAccessError(message: string): string | undefined {
const normalized = normalizeLowercaseStringOrEmpty(message);
if (!normalized.includes("full disk access") || !normalized.includes("chat.db")) {
return undefined;
}
return PUBLIC_IMESSAGE_FULL_DISK_ACCESS_ERROR;
}
export class IMessageRpcClient {
private readonly cliPath: string;
private readonly dbPath?: string;
private readonly runtime?: RuntimeEnv;
private readonly onNotification?: (msg: IMessageRpcNotification) => void;
private readonly pending = new Map<string, PendingRequest>();
private readonly closed: Promise<void>;
private closedResolve: (() => void) | null = null;
private child: ChildProcessWithoutNullStreams | null = null;
private stdoutBuffer = "";
private readonly stdoutDecoder = new StringDecoder("utf8");
private nextId = 1;
private publicProcessError: string | null = null;
constructor(opts: IMessageRpcClientOptions = {}) {
this.cliPath = opts.cliPath?.trim() || "imsg";
this.dbPath = opts.dbPath?.trim() ? resolveUserPath(opts.dbPath) : undefined;
this.runtime = opts.runtime;
this.onNotification = opts.onNotification;
this.closed = new Promise((resolve) => {
this.closedResolve = resolve;
});
}
async start(): Promise<void> {
if (this.child) {
return;
}
if (isTestEnv()) {
throw new Error("Refusing to start imsg rpc in test environment; mock iMessage RPC client");
}
const args = ["rpc", "--json"];
if (this.dbPath) {
args.push("--db", this.dbPath);
}
const child = spawn(this.cliPath, args, {
stdio: ["pipe", "pipe", "pipe"],
});
this.child = child;
child.stdout.on("data", (chunk) => {
if (this.child !== child) {
return;
}
this.handleStdoutChunk(chunk);
});
child.stderr?.on("data", (chunk) => {
const lines = chunk.toString().split(/\r?\n/);
for (const line of lines) {
if (!line.trim()) {
continue;
}
const trimmed = line.trim();
this.recordProcessDiagnostic(trimmed);
this.runtime?.error?.(`imsg rpc: ${trimmed}`);
}
});
child.on("error", (err) => {
this.failAll(err instanceof Error ? err : new Error(String(err)));
this.closedResolve?.();
});
// Without this listener, async EPIPE from a dead child crashes the
// gateway via uncaughtException. (#75438)
child.stdin.on("error", (err) => {
this.failAll(err instanceof Error ? err : new Error(String(err)));
});
child.on("close", (code, signal) => {
if (this.child === child) {
this.flushStdoutBuffer();
}
this.failAll(this.buildCloseError(code, signal));
this.closedResolve?.();
});
}
async stop(): Promise<void> {
if (!this.child) {
return;
}
this.stdoutBuffer = "";
this.stdoutDecoder.end();
this.child.stdin?.end();
const child = this.child;
this.child = null;
await Promise.race([
this.closed,
new Promise<void>((resolve) => {
setTimeout(() => {
if (!child.killed) {
child.kill("SIGTERM");
}
resolve();
}, 500);
}),
]);
}
async waitForClose(): Promise<void> {
await this.closed;
}
async request<T = unknown>(
method: string,
params?: Record<string, unknown>,
opts?: { timeoutMs?: number },
): Promise<T> {
if (!this.child || !this.child.stdin) {
throw new Error("imsg rpc not running");
}
const id = this.nextId++;
const payload = {
jsonrpc: "2.0",
id,
method,
params: params ?? {},
};
const line = `${JSON.stringify(payload)}\n`;
const timeoutMs = opts?.timeoutMs ?? DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS;
const response = new Promise<T>((resolve, reject) => {
const key = String(id);
const timer =
timeoutMs > 0
? setTimeout(() => {
this.pending.delete(key);
reject(new Error(`imsg rpc timeout (${method})`));
}, timeoutMs)
: undefined;
this.pending.set(key, {
resolve: (value) => resolve(value as T),
reject,
timer,
});
});
// Reject the specific pending request on write error (e.g. EPIPE)
// instead of letting it hang until timeout. (#75438)
this.child.stdin.write(line, (err) => {
if (err) {
const key = String(id);
const pending = this.pending.get(key);
if (pending) {
if (pending.timer) {
clearTimeout(pending.timer);
}
this.pending.delete(key);
pending.reject(err instanceof Error ? err : new Error(String(err)));
}
}
});
return await response;
}
private handleStdoutChunk(chunk: Buffer | string) {
const text = typeof chunk === "string" ? chunk : this.stdoutDecoder.write(chunk);
this.stdoutBuffer += text;
let newlineIndex = this.stdoutBuffer.indexOf("\n");
while (newlineIndex !== -1) {
const line = this.stdoutBuffer.slice(0, newlineIndex);
this.stdoutBuffer = this.stdoutBuffer.slice(newlineIndex + 1);
this.handleStdoutLine(line);
newlineIndex = this.stdoutBuffer.indexOf("\n");
}
}
private flushStdoutBuffer() {
const tail = this.stdoutDecoder.end();
if (tail) {
this.stdoutBuffer += tail;
}
if (!this.stdoutBuffer) {
return;
}
const line = this.stdoutBuffer;
this.stdoutBuffer = "";
this.handleStdoutLine(line);
}
private handleStdoutLine(line: string) {
const trimmed = line.trim();
if (!trimmed) {
return;
}
this.handleLine(trimmed);
}
private handleLine(line: string) {
let parsed: IMessageRpcResponse<unknown>;
try {
parsed = JSON.parse(line) as IMessageRpcResponse<unknown>;
} catch (err) {
this.recordProcessDiagnostic(line);
const detail = formatErrorMessage(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 recordProcessDiagnostic(line: string): void {
this.publicProcessError ??= normalizeIMessageFullDiskAccessError(line) ?? null;
}
private buildCloseError(code: number | null, signal: NodeJS.Signals | null): Error {
if (this.publicProcessError) {
return new Error(this.publicProcessError);
}
if (code !== 0 && code !== null) {
const reason = signal ? `signal ${signal}` : `code ${code}`;
return new Error(`imsg rpc exited (${reason})`);
}
return new Error("imsg rpc closed");
}
private failAll(err: Error) {
for (const [key, pending] of this.pending.entries()) {
if (pending.timer) {
clearTimeout(pending.timer);
}
pending.reject(err);
this.pending.delete(key);
}
}
}
export async function createIMessageRpcClient(
opts: IMessageRpcClientOptions = {},
): Promise<IMessageRpcClient> {
const client = new IMessageRpcClient(opts);
await client.start();
return client;
}

View File

@@ -0,0 +1,31 @@
// Imessage tests cover config accessors plugin behavior.
import { describe, expect, it } from "vitest";
import {
resolveIMessageConfigAllowFrom,
resolveIMessageConfigDefaultTo,
} from "./config-accessors.js";
describe("imessage config accessors", () => {
it("reads merged allowFrom/defaultTo from resolved account config", () => {
const cfg = {
channels: {
imessage: {
defaultTo: " root:chat ",
allowFrom: ["root"],
accounts: {
alt: {
defaultTo: " alt:chat ",
allowFrom: ["chat_id:9", "user@example.com"],
},
},
},
},
};
expect(resolveIMessageConfigAllowFrom({ cfg, accountId: "alt" })).toEqual([
"chat_id:9",
"user@example.com",
]);
expect(resolveIMessageConfigDefaultTo({ cfg, accountId: "alt" })).toBe("alt:chat");
});
});

View File

@@ -0,0 +1,22 @@
// Imessage helper module supports config accessors behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { resolveIMessageAccount } from "./accounts.js";
export function resolveIMessageConfigAllowFrom(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): string[] {
return (resolveIMessageAccount(params).config.allowFrom ?? []).map((entry) => String(entry));
}
export function resolveIMessageConfigDefaultTo(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): string | undefined {
const defaultTo = resolveIMessageAccount(params).config.defaultTo;
if (defaultTo == null) {
return undefined;
}
const normalized = defaultTo.trim();
return normalized || undefined;
}

View File

@@ -0,0 +1,215 @@
// Imessage tests cover config schema plugin behavior.
import { describe, expect, it } from "vitest";
import { IMessageConfigSchema } from "../config-api.js";
describe("imessage config schema", () => {
it('accepts dmPolicy="open" with allowFrom "*"', () => {
const res = IMessageConfigSchema.safeParse({ dmPolicy: "open", allowFrom: ["*"] });
expect(res.success).toBe(true);
if (res.success) {
expect(res.data.dmPolicy).toBe("open");
}
});
it('rejects dmPolicy="open" without allowFrom "*"', () => {
const res = IMessageConfigSchema.safeParse({
dmPolicy: "open",
allowFrom: ["+15555550123"],
});
expect(res.success).toBe(false);
if (!res.success) {
expect(res.error.issues[0]?.path.join(".")).toBe("allowFrom");
}
});
it("defaults dm/group policy", () => {
const res = IMessageConfigSchema.safeParse({});
expect(res.success).toBe(true);
if (res.success) {
expect(res.data.dmPolicy).toBe("pairing");
expect(res.data.groupPolicy).toBe("allowlist");
}
});
it("accepts historyLimit", () => {
const res = IMessageConfigSchema.safeParse({ historyLimit: 5 });
expect(res.success).toBe(true);
if (res.success) {
expect(res.data.historyLimit).toBe(5);
}
});
it("rejects unsafe executable config values", () => {
const res = IMessageConfigSchema.safeParse({ cliPath: "imsg; rm -rf /" });
expect(res.success).toBe(false);
if (!res.success) {
expect(res.error.issues[0]?.path.join(".")).toBe("cliPath");
}
});
it("accepts path-like executable values with spaces", () => {
const res = IMessageConfigSchema.safeParse({
cliPath: "/Applications/Imsg Tools/imsg",
});
expect(res.success).toBe(true);
});
it("accepts textChunkLimit", () => {
const res = IMessageConfigSchema.safeParse({
enabled: true,
textChunkLimit: 1111,
});
expect(res.success).toBe(true);
if (res.success) {
expect(res.data.textChunkLimit).toBe(1111);
}
});
it("accepts nested delivery streaming config", () => {
const res = IMessageConfigSchema.safeParse({
enabled: true,
streaming: {
chunkMode: "newline",
block: {
enabled: true,
coalesce: { minChars: 200, idleMs: 50 },
},
},
accounts: {
personal: {
streaming: { chunkMode: "length", block: { enabled: false } },
},
},
});
expect(res.success).toBe(true);
if (res.success) {
expect(res.data.streaming?.chunkMode).toBe("newline");
expect(res.data.streaming?.block?.enabled).toBe(true);
expect(res.data.accounts?.personal?.streaming?.block?.enabled).toBe(false);
}
});
it("accepts reaction notification mode overrides", () => {
const res = IMessageConfigSchema.safeParse({
reactionNotifications: "all",
accounts: {
quiet: {
reactionNotifications: "off",
},
},
});
expect(res.success).toBe(true);
});
it("accepts send transport overrides", () => {
const res = IMessageConfigSchema.safeParse({
sendTransport: "auto",
accounts: {
bridge: {
sendTransport: "bridge",
},
applescript: {
sendTransport: "applescript",
},
},
});
expect(res.success).toBe(true);
if (res.success) {
expect(res.data.sendTransport).toBe("auto");
expect(res.data.accounts?.bridge?.sendTransport).toBe("bridge");
expect(res.data.accounts?.applescript?.sendTransport).toBe("applescript");
}
});
it("rejects invalid send transport overrides", () => {
const res = IMessageConfigSchema.safeParse({
sendTransport: "private-api",
});
expect(res.success).toBe(false);
if (!res.success) {
expect(res.error.issues[0]?.path.join(".")).toBe("sendTransport");
}
});
it("rejects invalid reaction notification modes", () => {
const res = IMessageConfigSchema.safeParse({
reactionNotifications: "allowlist",
});
expect(res.success).toBe(false);
if (!res.success) {
expect(res.error.issues[0]?.path.join(".")).toBe("reactionNotifications");
}
});
it("accepts private API action gates", () => {
const res = IMessageConfigSchema.safeParse({
cliPath: "imsg",
actions: {
reactions: false,
edit: true,
sendAttachment: true,
},
accounts: {
work: {
actions: {
reply: false,
sendWithEffect: true,
},
},
},
});
expect(res.success).toBe(true);
});
it("accepts safe remoteHost", () => {
const res = IMessageConfigSchema.safeParse({
remoteHost: "bot@gateway-host",
});
expect(res.success).toBe(true);
});
it("rejects unsafe remoteHost", () => {
const res = IMessageConfigSchema.safeParse({
remoteHost: "bot@gateway-host -oProxyCommand=whoami",
});
expect(res.success).toBe(false);
if (!res.success) {
expect(res.error.issues[0]?.path.join(".")).toBe("remoteHost");
}
});
it("accepts attachment root patterns", () => {
const res = IMessageConfigSchema.safeParse({
attachmentRoots: ["/Users/*/Library/Messages/Attachments"],
remoteAttachmentRoots: ["/Volumes/relay/attachments"],
});
expect(res.success).toBe(true);
});
it("rejects relative attachment roots", () => {
const res = IMessageConfigSchema.safeParse({
attachmentRoots: ["./attachments"],
});
expect(res.success).toBe(false);
if (!res.success) {
expect(res.error.issues[0]?.path.join(".")).toBe("attachmentRoots.0");
}
});
});

View File

@@ -0,0 +1,7 @@
// Imessage helper module supports config schema behavior.
import { buildChannelConfigSchema, IMessageConfigSchema } from "../config-api.js";
import { iMessageChannelConfigUiHints } from "./config-ui-hints.js";
export const IMessageChannelConfigSchema = buildChannelConfigSchema(IMessageConfigSchema, {
uiHints: iMessageChannelConfigUiHints,
});

View File

@@ -0,0 +1,25 @@
// Imessage helper module supports config ui hints behavior.
import type { ChannelConfigUiHint } from "openclaw/plugin-sdk/core";
export const iMessageChannelConfigUiHints = {
"": {
label: "iMessage",
help: "iMessage channel provider configuration for CLI integration and DM access policy handling. Use explicit CLI paths when runtime environments have non-standard binary locations.",
},
dmPolicy: {
label: "iMessage DM Policy",
help: 'Direct message access control ("pairing" recommended). "open" requires channels.imessage.allowFrom=["*"].',
},
configWrites: {
label: "iMessage Config Writes",
help: "Allow iMessage to write config in response to channel events/commands (default: true).",
},
cliPath: {
label: "iMessage CLI Path",
help: "Filesystem path to the iMessage bridge CLI binary used for send/receive operations. Set explicitly when the binary is not on PATH in service runtime environments.",
},
sendTransport: {
label: "iMessage Send Transport",
help: 'Preferred imsg RPC send transport for normal outbound replies. "auto" uses the IMCore bridge when available, "bridge" requires it, and "applescript" forces Messages automation.',
},
} satisfies Record<string, ChannelConfigUiHint>;

View File

@@ -0,0 +1,11 @@
/** Default timeout for iMessage probe/RPC operations (10 seconds). */
export const DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS = 10_000;
// Sends get a much longer default than probes: on macOS 26 (Tahoe) the private
// API bridge intermittently stalls up to ~124s before the send completes. The
// 10s probe timeout aborts those mid-flight, and non-recoverable shapes
// (attachment/reply) are then lost. This must clear the observed upper bound
// plus headroom, otherwise the long tail of stalls still loses sends — 150s
// covers 124s with margin. Decoupling keeps probes/health checks fast while
// letting real sends ride out the stall. Akin to the BlueBubbles fix (#69193).
export const DEFAULT_IMESSAGE_SEND_TIMEOUT_MS = 150_000;

View File

@@ -0,0 +1,47 @@
// Imessage plugin module implements conversation bindings behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
createAccountScopedConversationBindingManager,
resetAccountScopedConversationBindingsForTests,
type AccountScopedConversationBindingManager,
type BindingTargetKind,
} from "openclaw/plugin-sdk/thread-bindings-runtime";
type IMessageBindingTargetKind = "subagent" | "acp";
type IMessageConversationBindingManager =
AccountScopedConversationBindingManager<IMessageBindingTargetKind>;
const IMESSAGE_CONVERSATION_BINDINGS_STATE_KEY = Symbol.for(
"openclaw.imessageConversationBindingsState",
);
function toSessionBindingTargetKind(raw: IMessageBindingTargetKind): BindingTargetKind {
return raw === "subagent" ? "subagent" : "session";
}
function toIMessageTargetKind(raw: BindingTargetKind): IMessageBindingTargetKind {
return raw === "subagent" ? "subagent" : "acp";
}
export function createIMessageConversationBindingManager(params: {
accountId?: string;
cfg: OpenClawConfig;
}): IMessageConversationBindingManager {
return createAccountScopedConversationBindingManager({
channel: "imessage",
cfg: params.cfg,
accountId: params.accountId,
stateKey: IMESSAGE_CONVERSATION_BINDINGS_STATE_KEY,
toStoredTargetKind: toIMessageTargetKind,
toSessionBindingTargetKind,
});
}
export const testing = {
resetIMessageConversationBindingsForTests() {
resetAccountScopedConversationBindingsForTests({
stateKey: IMESSAGE_CONVERSATION_BINDINGS_STATE_KEY,
});
},
};

View File

@@ -0,0 +1,51 @@
// Imessage plugin module implements conversation id core behavior.
import { normalizeIMessageHandle, parseIMessageTarget } from "./targets.js";
export function normalizeIMessageAcpConversationId(
conversationId: string,
): { conversationId: string } | null {
const trimmed = conversationId.trim();
if (!trimmed) {
return null;
}
try {
const parsed = parseIMessageTarget(trimmed);
if (parsed.kind === "handle") {
const handle = normalizeIMessageHandle(parsed.to);
return handle ? { conversationId: handle } : null;
}
if (parsed.kind === "chat_id") {
return { conversationId: String(parsed.chatId) };
}
if (parsed.kind === "chat_guid") {
return { conversationId: parsed.chatGuid };
}
return { conversationId: parsed.chatIdentifier };
} catch {
const handle = normalizeIMessageHandle(trimmed);
return handle ? { conversationId: handle } : null;
}
}
export function matchIMessageAcpConversation(params: {
bindingConversationId: string;
conversationId: string;
}): { conversationId: string; matchPriority: number } | null {
const binding = normalizeIMessageAcpConversationId(params.bindingConversationId);
const conversation = normalizeIMessageAcpConversationId(params.conversationId);
if (!binding || !conversation) {
return null;
}
if (binding.conversationId !== conversation.conversationId) {
return null;
}
return {
conversationId: conversation.conversationId,
matchPriority: 2,
};
}
export function resolveIMessageConversationIdFromTarget(target: string): string | undefined {
return normalizeIMessageAcpConversationId(target)?.conversationId;
}

View File

@@ -0,0 +1,27 @@
// Imessage plugin module implements conversation id behavior.
import {
matchIMessageAcpConversation,
normalizeIMessageAcpConversationId,
resolveIMessageConversationIdFromTarget,
} from "./conversation-id-core.js";
import { normalizeIMessageHandle } from "./targets.js";
export {
matchIMessageAcpConversation,
normalizeIMessageAcpConversationId,
resolveIMessageConversationIdFromTarget,
};
export function resolveIMessageInboundConversationId(params: {
isGroup: boolean;
sender: string;
chatId?: number;
}): string | undefined {
if (params.isGroup) {
return params.chatId != null && Number.isFinite(params.chatId)
? String(params.chatId)
: undefined;
}
const sender = normalizeIMessageHandle(params.sender);
return sender || undefined;
}

View File

@@ -0,0 +1,60 @@
// Imessage tests cover conversation route plugin behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
testing as sessionBindingTesting,
registerSessionBindingAdapter,
} from "openclaw/plugin-sdk/conversation-runtime";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { resolveIMessageConversationRoute } from "./conversation-route.js";
const baseCfg = {
session: { mainKey: "main", scope: "per-sender" },
agents: {
list: [{ id: "main" }, { id: "codex" }],
},
} satisfies OpenClawConfig;
describe("resolveIMessageConversationRoute", () => {
beforeEach(() => {
sessionBindingTesting.resetSessionBindingAdaptersForTests();
});
it("lets runtime iMessage conversation bindings override default routing", () => {
const touch = vi.fn();
registerSessionBindingAdapter({
channel: "imessage",
accountId: "default",
listBySession: () => [],
resolveByConversation: (ref) =>
ref.conversationId === "+15555550123"
? {
bindingId: "default:+15555550123",
targetSessionKey: "agent:codex:acp:bound-1",
targetKind: "session",
conversation: {
channel: "imessage",
accountId: "default",
conversationId: "+15555550123",
},
status: "active",
boundAt: Date.now(),
metadata: { boundBy: "user-1" },
}
: null,
touch,
});
const route = resolveIMessageConversationRoute({
cfg: baseCfg,
accountId: "default",
isGroup: false,
peerId: "+15555550123",
sender: "+15555550123",
});
expect(route.agentId).toBe("codex");
expect(route.sessionKey).toBe("agent:codex:acp:bound-1");
expect(route.matchedBy).toBe("binding.channel");
expect(touch).toHaveBeenCalledWith("default:+15555550123", undefined);
});
});

View File

@@ -0,0 +1,65 @@
// Imessage plugin module implements conversation route behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
resolveConfiguredBindingRoute,
resolveRuntimeConversationBindingRoute,
} from "openclaw/plugin-sdk/conversation-runtime";
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { resolveIMessageInboundConversationId } from "./conversation-id.js";
export function resolveIMessageConversationRoute(params: {
cfg: OpenClawConfig;
accountId: string;
isGroup: boolean;
peerId: string;
sender: string;
chatId?: number;
}): ReturnType<typeof resolveAgentRoute> {
let route = resolveAgentRoute({
cfg: params.cfg,
channel: "imessage",
accountId: params.accountId,
peer: {
kind: params.isGroup ? "group" : "direct",
id: params.peerId,
},
});
const conversationId = resolveIMessageInboundConversationId({
isGroup: params.isGroup,
sender: params.sender,
chatId: params.chatId,
});
if (!conversationId) {
return route;
}
route = resolveConfiguredBindingRoute({
cfg: params.cfg,
route,
conversation: {
channel: "imessage",
accountId: params.accountId,
conversationId,
},
}).route;
const runtimeRoute = resolveRuntimeConversationBindingRoute({
route,
conversation: {
channel: "imessage",
accountId: params.accountId,
conversationId,
},
});
route = runtimeRoute.route;
if (runtimeRoute.bindingRecord && !runtimeRoute.boundSessionKey) {
logVerbose(`imessage: plugin-bound conversation ${conversationId}`);
} else if (runtimeRoute.boundSessionKey) {
logVerbose(
`imessage: routed via bound conversation ${conversationId} -> ${runtimeRoute.boundSessionKey}`,
);
}
return route;
}

View File

@@ -0,0 +1,68 @@
// Imessage tests cover the doctor contract for deprecated catchup config.
import { describe, expect, it } from "vitest";
import { legacyConfigRules, normalizeCompatibilityConfig } from "../doctor-contract-api.js";
describe("iMessage doctor contract: deprecated catchup config", () => {
it("detects a disabled top-level catchup block", () => {
const cfg = { channels: { imessage: { catchup: { enabled: false } } } } as never;
const rule = legacyConfigRules[0];
expect(rule?.match?.((cfg as { channels: { imessage: unknown } }).channels.imessage, cfg)).toBe(
true,
);
});
it("detects a disabled per-account catchup block", () => {
const imessage = { accounts: { work: { catchup: { enabled: false } } } };
const cfg = { channels: { imessage } } as never;
expect(legacyConfigRules[0]?.match?.(imessage, cfg)).toBe(true);
});
it("does not flag enabled catchup because replay remains compatibility-supported", () => {
const imessage = {
catchup: { enabled: true, maxAgeMinutes: 360 },
accounts: { work: { catchup: { enabled: true, perRunLimit: 25 } } },
};
const cfg = { channels: { imessage } } as never;
expect(legacyConfigRules[0]?.match?.(imessage, cfg)).toBe(false);
});
it("does not flag a config without catchup", () => {
const imessage = { dmPolicy: "pairing", accounts: { work: { cliPath: "imsg" } } };
const cfg = { channels: { imessage } } as never;
expect(legacyConfigRules[0]?.match?.(imessage, cfg)).toBe(false);
});
it("strips disabled catchup and preserves enabled catchup", () => {
const cfg = {
channels: {
imessage: {
catchup: { enabled: true, maxAgeMinutes: 360 },
dmPolicy: "pairing",
accounts: {
work: { catchup: { enabled: false }, cliPath: "imsg" },
home: { catchup: { enabled: true, perRunLimit: 25 }, cliPath: "imsg-home" },
},
},
},
} as never;
const mutation = normalizeCompatibilityConfig({ cfg });
expect(mutation.changes).toHaveLength(1);
const imessage = (mutation.config as { channels: { imessage: Record<string, unknown> } })
.channels.imessage;
expect(imessage.catchup).toEqual({ enabled: true, maxAgeMinutes: 360 });
const accounts = imessage.accounts as {
work: Record<string, unknown>;
home: Record<string, unknown>;
};
expect("catchup" in accounts.work).toBe(false);
expect(accounts.home.catchup).toEqual({ enabled: true, perRunLimit: 25 });
expect(accounts.work.cliPath).toBe("imsg");
});
it("is a no-op when catchup is absent", () => {
const cfg = { channels: { imessage: { dmPolicy: "pairing" } } } as never;
const mutation = normalizeCompatibilityConfig({ cfg });
expect(mutation.changes).toHaveLength(0);
expect(mutation.config).toBe(cfg);
});
});

View File

@@ -0,0 +1,69 @@
// Imessage tests cover doctor plugin behavior.
import { describe, expect, it } from "vitest";
import { imessageDoctor } from "./doctor.js";
describe("imessageDoctor.collectPreviewWarnings", () => {
it("flags accounts that share the local Messages source", async () => {
const warnings = await imessageDoctor.collectPreviewWarnings?.({
cfg: {
channels: {
imessage: {
accounts: {
"swang430-gmail-com": {},
default: {},
},
},
},
} as never,
doctorFixCommand: "openclaw doctor --fix",
});
expect(warnings).toHaveLength(1);
const warning = warnings?.[0] ?? "";
expect(warning).toContain(
'channels.imessage: accounts "swang430-gmail-com" and "default" watch the same local Messages source (cliPath=imsg).',
);
expect(warning).toContain('OpenClaw runs one watcher (owner: "swang430-gmail-com")');
expect(warning).toContain("idles the duplicate");
expect(warning).toContain('accountId="swang430-gmail-com"');
expect(warning).toContain('"default"');
expect(warning).toContain('set "enabled": false');
});
it("includes dbPath in the warning when configured", async () => {
const warnings = await imessageDoctor.collectPreviewWarnings?.({
cfg: {
channels: {
imessage: {
accounts: {
primary: { cliPath: "imsg", dbPath: "/Users/me/chat.db" },
default: { cliPath: "imsg", dbPath: "/Users/me/chat.db" },
},
},
},
} as never,
doctorFixCommand: "openclaw doctor --fix",
});
expect(warnings).toHaveLength(1);
expect(warnings?.[0]).toMatch(/cliPath=imsg, dbPath=\/Users\/me\/chat\.db/);
});
it("stays quiet when each enabled account targets a distinct source", async () => {
const warnings = await imessageDoctor.collectPreviewWarnings?.({
cfg: {
channels: {
imessage: {
accounts: {
work: { cliPath: "/usr/local/bin/imsg-work" },
home: { cliPath: "/usr/local/bin/imsg-home" },
},
},
},
} as never,
doctorFixCommand: "openclaw doctor --fix",
});
expect(warnings).toEqual([]);
});
});

View File

@@ -0,0 +1,8 @@
// Imessage plugin module implements doctor behavior.
import type { ChannelDoctorAdapter } from "openclaw/plugin-sdk/channel-contract";
import { collectIMessageDuplicateAccountSourceWarnings } from "./accounts.js";
export const imessageDoctor: ChannelDoctorAdapter = {
groupAllowFromFallbackToAllowFrom: false,
collectPreviewWarnings: ({ cfg }) => collectIMessageDuplicateAccountSourceWarnings({ cfg }),
};

View File

@@ -0,0 +1,41 @@
// Imessage plugin module implements group policy behavior.
import {
resolveChannelGroupRequireMention,
resolveChannelGroupToolsPolicy,
type GroupToolPolicyConfig,
} from "openclaw/plugin-sdk/channel-policy";
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
type IMessageGroupContext = {
cfg: OpenClawConfig;
accountId?: string | null;
groupId?: string | null;
senderId?: string | null;
senderName?: string | null;
senderUsername?: string | null;
senderE164?: string | null;
};
export function resolveIMessageGroupRequireMention(params: IMessageGroupContext): boolean {
return resolveChannelGroupRequireMention({
cfg: params.cfg,
channel: "imessage",
groupId: params.groupId,
accountId: params.accountId,
});
}
export function resolveIMessageGroupToolPolicy(
params: IMessageGroupContext,
): GroupToolPolicyConfig | undefined {
return resolveChannelGroupToolsPolicy({
cfg: params.cfg,
channel: "imessage",
groupId: params.groupId,
accountId: params.accountId,
senderId: params.senderId,
senderName: params.senderName,
senderUsername: params.senderUsername,
senderE164: params.senderE164,
});
}

View File

@@ -0,0 +1,173 @@
// Imessage plugin module implements imessage plugin behavior.
import type {
ChannelMessageActionAdapter,
ChannelMessageActionName,
ChannelOutboundAdapter,
} from "openclaw/plugin-sdk/channel-contract";
import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-outbound";
import type { ChannelPlugin } from "openclaw/plugin-sdk/core";
import { collectStatusIssuesFromLastError } from "openclaw/plugin-sdk/status-helpers";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
function normalizeIMessageTestHandle(raw: string): string {
let trimmed = raw.trim();
if (!trimmed) {
return "";
}
while (trimmed) {
const lowered = normalizeLowercaseStringOrEmpty(trimmed);
if (lowered.startsWith("imessage:")) {
trimmed = trimmed.slice("imessage:".length).trim();
continue;
}
if (lowered.startsWith("sms:")) {
trimmed = trimmed.slice("sms:".length).trim();
continue;
}
if (lowered.startsWith("auto:")) {
trimmed = trimmed.slice("auto:".length).trim();
continue;
}
break;
}
if (!trimmed) {
return "";
}
if (/^(chat_id:|chat_guid:|chat_identifier:)/i.test(trimmed)) {
return trimmed.replace(/^(chat_id:|chat_guid:|chat_identifier:)/i, (match) =>
normalizeLowercaseStringOrEmpty(match),
);
}
if (trimmed.includes("@")) {
return normalizeLowercaseStringOrEmpty(trimmed);
}
const digits = trimmed.replace(/[^\d+]/g, "");
if (digits) {
return digits.startsWith("+") ? `+${digits.slice(1)}` : `+${digits}`;
}
return trimmed.replace(/\s+/g, "");
}
const defaultIMessageOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
deliveryCapabilities: {
durableFinal: {
text: true,
media: true,
replyTo: true,
messageSendingHooks: true,
},
},
sendText: async ({ to, text, accountId, replyToId, deps, cfg }) => {
const sendIMessage = resolveOutboundSendDep<
(
target: string,
content: string,
opts?: Record<string, unknown>,
) => Promise<{ messageId: string }>
>(deps, "imessage");
const result = await sendIMessage?.(to, text, {
config: cfg,
accountId: accountId ?? undefined,
replyToId: replyToId ?? undefined,
});
return { channel: "imessage", messageId: result?.messageId ?? "imessage-test-stub" };
},
sendMedia: async ({ to, text, mediaUrl, accountId, replyToId, deps, cfg, mediaLocalRoots }) => {
const sendIMessage = resolveOutboundSendDep<
(
target: string,
content: string,
opts?: Record<string, unknown>,
) => Promise<{ messageId: string }>
>(deps, "imessage");
const result = await sendIMessage?.(to, text, {
config: cfg,
mediaUrl,
accountId: accountId ?? undefined,
replyToId: replyToId ?? undefined,
mediaLocalRoots,
});
return { channel: "imessage", messageId: result?.messageId ?? "imessage-test-stub" };
},
};
const defaultIMessageActions: ChannelMessageActionAdapter = {
describeMessageTool: () => ({
actions: [
"react",
"edit",
"unsend",
"reply",
"sendWithEffect",
"upload-file",
"renameGroup",
"setGroupIcon",
"addParticipant",
"removeParticipant",
"leaveGroup",
],
}),
supportsAction: ({ action }) =>
new Set<ChannelMessageActionName>([
"react",
"edit",
"unsend",
"reply",
"sendWithEffect",
"upload-file",
"sendAttachment",
"renameGroup",
"setGroupIcon",
"addParticipant",
"removeParticipant",
"leaveGroup",
]).has(action),
};
export const createIMessageTestPlugin = (params?: {
outbound?: ChannelOutboundAdapter;
actions?: ChannelMessageActionAdapter;
}): ChannelPlugin => ({
id: "imessage",
meta: {
id: "imessage",
label: "iMessage",
selectionLabel: "iMessage (imsg)",
docsPath: "/channels/imessage",
blurb: "iMessage test stub.",
aliases: ["imsg"],
},
capabilities: { chatTypes: ["direct", "group"], media: true },
config: {
listAccountIds: () => [],
resolveAccount: () => ({}),
},
status: {
collectStatusIssues: (accounts) => collectStatusIssuesFromLastError("imessage", accounts),
},
actions: params?.actions ?? defaultIMessageActions,
outbound: params?.outbound ?? defaultIMessageOutbound,
messaging: {
targetResolver: {
looksLikeId: (raw) => {
const trimmed = raw.trim();
if (!trimmed) {
return false;
}
if (/^(imessage:|sms:|auto:|chat_id:|chat_guid:|chat_identifier:)/i.test(trimmed)) {
return true;
}
if (trimmed.includes("@")) {
return true;
}
return /^\+?\d{3,}$/.test(trimmed);
},
hint: "<handle|chat_id:ID>",
},
normalizeTarget: (raw) => normalizeIMessageTestHandle(raw),
},
});

View File

@@ -0,0 +1,100 @@
// Imessage tests cover markdown format plugin behavior.
import { describe, expect, it } from "vitest";
import { extractMarkdownFormatRuns } from "./markdown-format.js";
describe("extractMarkdownFormatRuns", () => {
it("returns the text unchanged when there is no markdown", () => {
const { text, ranges } = extractMarkdownFormatRuns("plain text reply");
expect(text).toBe("plain text reply");
expect(ranges).toStrictEqual([]);
});
it("extracts a bold span", () => {
const { text, ranges } = extractMarkdownFormatRuns("**bold** text");
expect(text).toBe("bold text");
expect(ranges).toEqual([{ start: 0, length: 4, styles: ["bold"] }]);
});
it("extracts mixed bold and italic", () => {
const { text, ranges } = extractMarkdownFormatRuns("**hi** and *there*");
expect(text).toBe("hi and there");
expect(ranges).toEqual([
{ start: 0, length: 2, styles: ["bold"] },
{ start: 7, length: 5, styles: ["italic"] },
]);
});
it("extracts underline and strikethrough", () => {
const { text, ranges } = extractMarkdownFormatRuns("__under__ and ~~strike~~");
expect(text).toBe("under and strike");
expect(ranges).toEqual([
{ start: 0, length: 5, styles: ["underline"] },
{ start: 10, length: 6, styles: ["strikethrough"] },
]);
});
it("respects word boundaries on single-underscore italics", () => {
const { text, ranges } = extractMarkdownFormatRuns("snake_case_var ok");
expect(text).toBe("snake_case_var ok");
expect(ranges).toStrictEqual([]);
});
it("treats single-underscore as italic when surrounded by whitespace", () => {
const { text, ranges } = extractMarkdownFormatRuns("a _word_ b");
expect(text).toBe("a word b");
expect(ranges).toEqual([{ start: 2, length: 4, styles: ["italic"] }]);
});
it("does not treat empty marker pairs as formatting", () => {
const { text, ranges } = extractMarkdownFormatRuns("** ** literal");
expect(text).toBe("** ** literal");
expect(ranges).toStrictEqual([]);
});
it("leaves a lone asterisk alone", () => {
const { text, ranges } = extractMarkdownFormatRuns("price * quantity");
expect(text).toBe("price * quantity");
expect(ranges).toStrictEqual([]);
});
it("computes ranges in output coordinates, not input", () => {
const { text, ranges } = extractMarkdownFormatRuns("a **b** c **d** e");
expect(text).toBe("a b c d e");
expect(ranges).toEqual([
{ start: 2, length: 1, styles: ["bold"] },
{ start: 6, length: 1, styles: ["bold"] },
]);
});
it("parses ***triple-marker*** as bold + italic over the same span", () => {
const { text, ranges } = extractMarkdownFormatRuns("***hi***");
expect(text).toBe("hi");
// Compound marker emits both styles over the same span.
expect(ranges).toEqual([
{ start: 0, length: 2, styles: ["bold"] },
{ start: 0, length: 2, styles: ["italic"] },
]);
});
it("parses **bold _and underline_ together** as nested ranges", () => {
const { text, ranges } = extractMarkdownFormatRuns("**bold _and underline_ together**");
expect(text).toBe("bold and underline together");
// Inner italic-via-_ at offset 5, length 13; outer bold over the full span.
expect(ranges).toEqual([
{ start: 5, length: 13, styles: ["italic"] },
{ start: 0, length: 27, styles: ["bold"] },
]);
});
it("respects word boundaries on double-underscore underline", () => {
const { text, ranges } = extractMarkdownFormatRuns("def __init__(self):");
expect(text).toBe("def __init__(self):");
expect(ranges).toStrictEqual([]);
});
it("does not leak literal asterisks from triple markers when intent is unclear", () => {
// `***bold***` should never produce a bare `*` in the output text.
const { text } = extractMarkdownFormatRuns("hello ***world***");
expect(text).not.toMatch(/\*/);
});
});

View File

@@ -0,0 +1,154 @@
/**
* Convert markdown bold/italic/underline/strikethrough markers in agent text
* into typed-run formatting ranges that the imsg bridge's `sendMessage`
* action understands. Returns the marker-stripped text plus an array of
* ranges keyed by their start in the OUTPUT string.
*
* macOS 15+ recipients render typed runs natively; macOS 14 falls back to
* client-side markdown rendering, so passing both raw markdown and ranges
* would double up — callers should send the stripped `text` only.
*
* Supported markers:
* - `**bold**`
* - `*italic*` / `_italic_` (single-underscore enforces word boundaries)
* - `__underline__` (double-underscore also enforces word boundaries so
* Python identifiers like `__init__` are not mangled)
* - `~~strikethrough~~`
*
* Nesting:
* - `***bold-italic***` is parsed as `**` containing `*italic*`, yielding
* two ranges over the same span (one bold, one italic).
* - Other nested combinations (`**bold _underline_**`, etc.) are
* similarly parsed by recursing into the inner text of every marker
* pair we consume.
*
* Out of scope: escaped markers (`\*literal\*`), code spans (` `code` `),
* and combining-character edge cases. The receiver's iMessage style
* vocabulary covers only bold/italic/underline/strikethrough — there is
* nowhere to render anything fancier, and over-eager parsing would mangle
* plain-text emoji/punctuation that happens to look like markdown.
*/
export type IMessageFormatStyle = "bold" | "italic" | "underline" | "strikethrough";
export type IMessageFormatRange = {
start: number;
length: number;
styles: IMessageFormatStyle[];
};
type Marker = {
marker: string;
styles: IMessageFormatStyle[];
/**
* When true, the marker only counts when both ends sit on a word
* boundary. Single-underscore italics need this so `snake_case_var` is
* left literal, and double-underscore underline needs it so Python
* dunder names like `__init__` are not turned into underline.
*/
requireWordBoundary: boolean;
};
// Order matters: longer/compound markers are tried first.
// - `***...***` is bold+italic over the inner span.
// - `___...___` is underline+italic.
// - `~~`, `**`, `__` cover their own styles.
// - `*` / `_` italic match last (with `_` enforcing word boundaries).
const MARKERS: readonly Marker[] = [
{ marker: "***", styles: ["bold", "italic"], requireWordBoundary: false },
{ marker: "___", styles: ["underline", "italic"], requireWordBoundary: true },
{ marker: "~~", styles: ["strikethrough"], requireWordBoundary: false },
{ marker: "**", styles: ["bold"], requireWordBoundary: false },
{ marker: "__", styles: ["underline"], requireWordBoundary: true },
{ marker: "*", styles: ["italic"], requireWordBoundary: false },
{ marker: "_", styles: ["italic"], requireWordBoundary: true },
];
function tryConsumeMarker(
input: string,
i: number,
m: Marker,
): { close: number; inner: string } | null {
if (!input.startsWith(m.marker, i)) {
return null;
}
// For single-char markers, reject when the next char is the same so we
// don't consume the leading half of a longer marker (e.g. `*` matching
// the first asterisk of `**bold**`).
if (m.marker.length === 1 && input[i + 1] === m.marker) {
return null;
}
// For 2-char markers, reject when there's a third repeat — that's the
// longer compound marker (`***`, `___`) which should match first.
if (m.marker.length === 2 && input[i + 2] === m.marker[0]) {
return null;
}
// For underscore markers we use a stricter rule than CommonMark: the
// OUTSIDE of each marker must be whitespace, start-of-string, or
// end-of-string. That keeps `def __init__(self)` literal (`(` after the
// close is neither whitespace nor end-of-string) while `__under__ and`
// still parses cleanly. Asterisk markers don't need this because they
// don't appear inside identifiers.
const isAtBoundary = (ch: string | undefined): boolean => ch === undefined || /\s/.test(ch);
if (m.requireWordBoundary && i > 0 && !isAtBoundary(input[i - 1])) {
return null;
}
const startInner = i + m.marker.length;
const close = input.indexOf(m.marker, startInner);
if (close === -1 || close === startInner) {
return null;
}
if (m.requireWordBoundary && !isAtBoundary(input[close + m.marker.length])) {
return null;
}
const inner = input.slice(startInner, close);
if (!inner.trim()) {
return null;
}
return { close, inner };
}
function parseInternal(input: string, baseOffset: number, sink: IMessageFormatRange[]): string {
let out = "";
let i = 0;
while (i < input.length) {
let consumed = false;
for (const m of MARKERS) {
const hit = tryConsumeMarker(input, i, m);
if (!hit) {
continue;
}
// Recurse on the inner span so nested markers compose. The inner
// ranges are emitted with offsets relative to the new base.
const innerOffset = baseOffset + out.length;
const innerStripped = parseInternal(hit.inner, innerOffset, sink);
// Compound markers (`***`, `___`) emit multiple styles over the same
// span — push them in order so callers see e.g. italic before bold.
for (const style of m.styles) {
sink.push({
start: innerOffset,
length: innerStripped.length,
styles: [style],
});
}
out += innerStripped;
i = hit.close + m.marker.length;
consumed = true;
break;
}
if (!consumed) {
out += input[i];
i += 1;
}
}
return out;
}
export function extractMarkdownFormatRuns(input: string): {
text: string;
ranges: IMessageFormatRange[];
} {
const ranges: IMessageFormatRange[] = [];
const text = parseInternal(input, 0, ranges);
return { text, ranges };
}

View File

@@ -0,0 +1,32 @@
// Imessage plugin module implements media contract behavior.
import { mergeInboundPathRoots } from "openclaw/plugin-sdk/channel-inbound";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { resolveIMessageAccount } from "./accounts.js";
export const DEFAULT_IMESSAGE_ATTACHMENT_ROOTS = ["/Users/*/Library/Messages/Attachments"] as const;
export function resolveIMessageAttachmentRoots(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): string[] {
const account = resolveIMessageAccount(params);
return mergeInboundPathRoots(
account.config.attachmentRoots,
params.cfg.channels?.imessage?.attachmentRoots,
DEFAULT_IMESSAGE_ATTACHMENT_ROOTS,
);
}
export function resolveIMessageRemoteAttachmentRoots(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): string[] {
const account = resolveIMessageAccount(params);
return mergeInboundPathRoots(
account.config.remoteAttachmentRoots,
params.cfg.channels?.imessage?.remoteAttachmentRoots,
account.config.attachmentRoots,
params.cfg.channels?.imessage?.attachmentRoots,
DEFAULT_IMESSAGE_ATTACHMENT_ROOTS,
);
}

View File

@@ -0,0 +1,141 @@
// Imessage tests cover message tool api plugin behavior.
import { beforeEach, describe, expect, it } from "vitest";
import { describeMessageTool } from "../message-tool-api.js";
import {
clearCachedIMessagePrivateApiStatus,
setCachedIMessagePrivateApiStatus,
} from "./private-api-status.js";
describe("iMessage message-tool artifact", () => {
beforeEach(() => {
clearCachedIMessagePrivateApiStatus();
});
it("keeps poll actions discoverable until the first lazy bridge probe", () => {
const discovery = describeMessageTool({
cfg: { channels: { imessage: { cliPath: "imsg" } } } as never,
currentChannelId: "chat_id:1",
});
expect(discovery?.actions).toContain("poll");
expect(discovery?.actions).toContain("poll-vote");
expect(discovery?.schema).toMatchObject({
actions: ["poll-vote"],
visibility: "all-configured",
properties: { pollOptionText: { type: "string" } },
});
});
it("exposes lightweight discovery without loading the channel plugin", () => {
setCachedIMessagePrivateApiStatus("imsg", {
available: true,
v2Ready: true,
selectors: {
editMessage: true,
retractMessagePart: true,
},
rpcMethods: [],
});
const discovery = describeMessageTool({
cfg: {
channels: {
imessage: {
cliPath: "imsg",
actions: {
edit: false,
},
},
},
} as never,
currentChannelId: "chat_id:1",
});
expect(discovery?.actions).toStrictEqual([
"react",
"unsend",
"reply",
"sendWithEffect",
"renameGroup",
"setGroupIcon",
"addParticipant",
"removeParticipant",
"leaveGroup",
"upload-file",
]);
});
it("offers poll but hides poll-vote on imsg builds without the poll.vote rpc", () => {
setCachedIMessagePrivateApiStatus("imsg", {
available: true,
v2Ready: true,
selectors: { pollPayloadMessage: true, pollVoteMessage: true },
rpcMethods: [],
});
const discovery = describeMessageTool({
cfg: { channels: { imessage: { cliPath: "imsg" } } } as never,
currentChannelId: "chat_id:1",
});
expect(discovery?.actions).toContain("poll");
expect(discovery?.actions).not.toContain("poll-vote");
expect(discovery?.schema).toBeUndefined();
});
it("hides poll-vote when only the poll creation selector is available", () => {
setCachedIMessagePrivateApiStatus("imsg", {
available: true,
v2Ready: true,
selectors: { pollPayloadMessage: true },
rpcMethods: ["send", "poll.send", "poll.vote"],
});
const discovery = describeMessageTool({
cfg: { channels: { imessage: { cliPath: "imsg" } } } as never,
currentChannelId: "chat_id:1",
});
expect(discovery?.actions).toContain("poll");
expect(discovery?.actions).not.toContain("poll-vote");
});
it("offers poll-vote once imsg advertises the poll.vote rpc", () => {
setCachedIMessagePrivateApiStatus("imsg", {
available: true,
v2Ready: true,
selectors: { pollPayloadMessage: true, pollVoteMessage: true },
rpcMethods: ["send", "poll.send", "poll.vote", "messages.poll.vote"],
});
const discovery = describeMessageTool({
cfg: { channels: { imessage: { cliPath: "imsg" } } } as never,
currentChannelId: "chat_id:1",
});
expect(discovery?.actions).toContain("poll");
expect(discovery?.actions).toContain("poll-vote");
});
it("hides private actions when cached bridge status is unavailable", () => {
setCachedIMessagePrivateApiStatus("imsg", {
available: false,
v2Ready: false,
selectors: {},
rpcMethods: [],
});
const discovery = describeMessageTool({
cfg: {
channels: {
imessage: {
cliPath: "imsg",
},
},
} as never,
currentChannelId: "chat_id:1",
});
expect(discovery?.actions).toStrictEqual([]);
});
});

View File

@@ -0,0 +1,124 @@
// Imessage API module exposes the plugin public contract.
import { createActionGate } from "openclaw/plugin-sdk/channel-actions";
import type {
ChannelMessageActionAdapter,
ChannelMessageActionName,
} from "openclaw/plugin-sdk/channel-contract";
import { Type } from "typebox";
import { resolveIMessageAccount } from "./accounts.js";
import { IMESSAGE_ACTION_NAMES, IMESSAGE_ACTIONS } from "./actions-contract.js";
import {
getCachedIMessagePrivateApiStatus,
imessageRpcSupportsMethod,
} from "./private-api-status.js";
import { inferIMessageTargetChatType } from "./targets.js";
const PRIVATE_API_ACTIONS = new Set<ChannelMessageActionName>([
"react",
"edit",
"unsend",
"reply",
"sendWithEffect",
"renameGroup",
"setGroupIcon",
"addParticipant",
"removeParticipant",
"leaveGroup",
"sendAttachment",
"poll",
"poll-vote",
]);
function isGroupTarget(raw?: string | null): boolean {
if (!raw) {
return false;
}
return inferIMessageTargetChatType(raw) === "group";
}
export function describeIMessageMessageTool({
cfg,
accountId,
currentChannelId,
}: Parameters<NonNullable<ChannelMessageActionAdapter["describeMessageTool"]>>[0]) {
const account = resolveIMessageAccount({ cfg, accountId });
if (!account.enabled || !account.configured) {
return null;
}
const cliPath = account.config.cliPath?.trim() || "imsg";
const privateApiStatus = getCachedIMessagePrivateApiStatus(cliPath);
const gate = createActionGate(account.config.actions);
const actions = new Set<ChannelMessageActionName>();
for (const action of IMESSAGE_ACTION_NAMES) {
const spec = IMESSAGE_ACTIONS[action];
if (!spec?.gate || !gate(spec.gate)) {
continue;
}
if (privateApiStatus?.available === false && PRIVATE_API_ACTIONS.has(action)) {
continue;
}
if (
action === "edit" &&
privateApiStatus?.selectors &&
!privateApiStatus.selectors.editMessage &&
!privateApiStatus.selectors.editMessageItem
) {
continue;
}
if (action === "unsend" && privateApiStatus?.selectors?.retractMessagePart !== true) {
continue;
}
// Keep first-dispatch discovery optimistic while the status cache is empty;
// handleAction probes lazily and enforces the exact selector before sending.
if (
action === "poll" &&
privateApiStatus?.selectors &&
!privateApiStatus.selectors.pollPayloadMessage
) {
continue;
}
if (
action === "poll-vote" &&
privateApiStatus?.selectors &&
!privateApiStatus.selectors.pollVoteMessage
) {
continue;
}
// The injected helper can outlive the selected imsg binary. Require both
// the native initializer and a binary new enough to advertise poll.vote.
if (
action === "poll-vote" &&
privateApiStatus &&
!imessageRpcSupportsMethod(privateApiStatus, "poll.vote")
) {
continue;
}
actions.add(action);
}
if (!isGroupTarget(currentChannelId)) {
for (const action of IMESSAGE_ACTION_NAMES) {
if ("groupOnly" in IMESSAGE_ACTIONS[action] && IMESSAGE_ACTIONS[action].groupOnly) {
actions.delete(action);
}
}
}
if (actions.delete("sendAttachment")) {
actions.add("upload-file");
}
return {
actions: Array.from(actions),
...(actions.has("poll-vote")
? {
schema: {
properties: {
pollOptionText: Type.Optional(
Type.String({ description: "Exact iMessage poll option text." }),
),
},
actions: ["poll-vote" as const],
visibility: "all-configured" as const,
},
}
: {}),
};
}

View File

@@ -0,0 +1,406 @@
// Imessage tests cover monitor reply cache plugin behavior.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
resetIMessageShortIdState,
findLatestIMessageEntryForChat,
isKnownFromMeIMessageMessageId,
rememberIMessageReplyCache,
resolveIMessageMessageId,
} from "./monitor-reply-cache.js";
import { installIMessageStateRuntimeForTest } from "./test-support/runtime.js";
beforeEach(() => {
installIMessageStateRuntimeForTest();
resetIMessageShortIdState();
});
afterEach(() => {
vi.useRealTimers();
});
describe("imessage short message id resolution", () => {
it("resolves a short id to a cached message guid", () => {
const entry = rememberIMessageReplyCache({
accountId: "default",
messageId: "full-guid",
chatGuid: "iMessage;+;chat0000",
timestamp: Date.now(),
});
expect(entry.shortId).toBe("1");
expect(
resolveIMessageMessageId("1", {
requireKnownShortId: true,
chatContext: { chatGuid: "iMessage;+;chat0000" },
}),
).toBe("full-guid");
});
it("resolves a known short id even without caller-supplied chat scope", () => {
rememberIMessageReplyCache({
accountId: "default",
messageId: "full-guid",
chatGuid: "iMessage;+;chat0000",
timestamp: Date.now(),
});
// The cached entry already carries chat info; cross-chat checks only
// matter when the caller separately provides a (potentially conflicting)
// chat scope. A plain known short id from the cache must resolve.
expect(resolveIMessageMessageId("1", { requireKnownShortId: true })).toBe("full-guid");
});
it("requires chat scope when a privileged short id is unknown", () => {
expect(() => resolveIMessageMessageId("9999", { requireKnownShortId: true })).toThrow(
"requires a chat scope",
);
});
it("rejects short ids from another chat", () => {
rememberIMessageReplyCache({
accountId: "default",
messageId: "full-guid",
chatGuid: "iMessage;+;chat0000",
timestamp: Date.now(),
});
expect(() =>
resolveIMessageMessageId("1", {
requireKnownShortId: true,
chatContext: { chatGuid: "iMessage;+;other" },
}),
).toThrow("belongs to a different chat");
});
it("guards full guid reuse across chats when cached", () => {
rememberIMessageReplyCache({
accountId: "default",
messageId: "full-guid",
chatId: 42,
timestamp: Date.now(),
});
expect(() => resolveIMessageMessageId("full-guid", { chatContext: { chatId: 99 } })).toThrow(
"belongs to a different chat",
);
});
it("recognizes only cached outbound message ids as own messages", () => {
rememberIMessageReplyCache({
accountId: "default",
messageId: "outbound-guid",
chatGuid: "any;-;+12069106512",
chatIdentifier: "+12069106512",
chatId: 3,
timestamp: Date.now(),
isFromMe: true,
});
rememberIMessageReplyCache({
accountId: "default",
messageId: "inbound-guid",
chatGuid: "any;-;+12069106512",
chatIdentifier: "+12069106512",
chatId: 3,
timestamp: Date.now(),
isFromMe: false,
});
expect(
isKnownFromMeIMessageMessageId("outbound-guid", {
accountId: "default",
chatGuid: "any;-;+12069106512",
chatIdentifier: "+12069106512",
chatId: 3,
}),
).toBe(true);
expect(
isKnownFromMeIMessageMessageId("inbound-guid", {
accountId: "default",
chatGuid: "any;-;+12069106512",
chatIdentifier: "+12069106512",
chatId: 3,
}),
).toBe(false);
expect(
isKnownFromMeIMessageMessageId("outbound-guid", {
accountId: "default",
chatGuid: "any;-;+12069106514",
chatIdentifier: "+12069106514",
chatId: 4,
}),
).toBe(false);
});
});
describe("requireFromMe (edit / unsend authorization)", () => {
it("rejects a short id resolution when the cached entry came from inbound", () => {
// The default inbound recorder sets isFromMe:false (or omits it), so
// resolving with requireFromMe must reject — agents cannot edit/unsend
// messages that other participants sent.
const entry = rememberIMessageReplyCache({
accountId: "default",
messageId: "inbound-guid",
chatGuid: "iMessage;+;chatA",
timestamp: Date.now(),
isFromMe: false,
});
expect(() =>
resolveIMessageMessageId(entry.shortId, {
requireKnownShortId: true,
chatContext: { chatGuid: "iMessage;+;chatA" },
requireFromMe: true,
}),
).toThrow("not one this agent sent");
});
it("allows a short id resolution when the cached entry was sent by the gateway", () => {
const entry = rememberIMessageReplyCache({
accountId: "default",
messageId: "outbound-guid",
chatGuid: "iMessage;+;chatA",
timestamp: Date.now(),
isFromMe: true,
});
expect(
resolveIMessageMessageId(entry.shortId, {
requireKnownShortId: true,
chatContext: { chatGuid: "iMessage;+;chatA" },
requireFromMe: true,
}),
).toBe("outbound-guid");
});
it("rejects an uncached full guid under requireFromMe (agent cannot edit/unsend unknown messages)", () => {
expect(() =>
resolveIMessageMessageId("never-seen-guid", {
chatContext: { chatGuid: "iMessage;+;chatA" },
requireFromMe: true,
}),
).toThrow("not one this agent sent");
});
it("rejects when the cached entry has no isFromMe field (older persisted entry, treated as not-from-me)", () => {
// Persisted entries written before this option existed do not carry
// isFromMe. Treat undefined as the safe default (false) — that pre-
// existing-on-disk caller is the inbound recorder, the only writer that
// existed before.
rememberIMessageReplyCache({
accountId: "default",
messageId: "legacy-guid",
chatGuid: "iMessage;+;chatA",
timestamp: Date.now(),
// isFromMe deliberately omitted
});
expect(() =>
resolveIMessageMessageId("legacy-guid", {
chatContext: { chatGuid: "iMessage;+;chatA" },
requireFromMe: true,
}),
).toThrow("not one this agent sent");
});
});
describe("findLatestIMessageEntryForChat", () => {
it("returns the latest entry for the matching chat scope", () => {
rememberIMessageReplyCache({
accountId: "default",
messageId: "older",
chatGuid: "any;-;+12069106512",
chatIdentifier: "+12069106512",
timestamp: Date.now() - 1000,
});
rememberIMessageReplyCache({
accountId: "default",
messageId: "newest",
chatGuid: "any;-;+12069106512",
chatIdentifier: "+12069106512",
timestamp: Date.now(),
});
const result = findLatestIMessageEntryForChat({
accountId: "default",
chatIdentifier: "iMessage;-;+12069106512",
});
expect(result?.messageId).toBe("newest");
});
it("requires a positive identifier match — no overlap means no fallback", () => {
// Cache entry has only chatGuid; caller has only chatId. With the old
// isCrossChatMismatch-as-filter, this entry would have been returned
// (no overlap → no mismatch → pass). The strict positive-match
// semantics require both sides to share at least one identifier kind.
rememberIMessageReplyCache({
accountId: "default",
messageId: "different-chat",
chatGuid: "iMessage;+;chat0000",
timestamp: Date.now(),
});
expect(findLatestIMessageEntryForChat({ accountId: "default", chatId: 99 })).toBeUndefined();
});
it("never crosses account boundaries", () => {
rememberIMessageReplyCache({
accountId: "other-account",
messageId: "foreign-account",
chatIdentifier: "+12069106512",
timestamp: Date.now(),
});
expect(
findLatestIMessageEntryForChat({
accountId: "default",
chatIdentifier: "+12069106512",
}),
).toBeUndefined();
});
it("ignores entries older than the recency window", () => {
const TWELVE_MINUTES_AGO = Date.now() - 12 * 60 * 1000;
rememberIMessageReplyCache({
accountId: "default",
messageId: "stale",
chatIdentifier: "+12069106512",
timestamp: TWELVE_MINUTES_AGO,
});
expect(
findLatestIMessageEntryForChat({
accountId: "default",
chatIdentifier: "+12069106512",
}),
).toBeUndefined();
});
it("matches across chat-id-format flavors (iMessage;-;<phone>, any;-;<phone>, bare phone)", () => {
rememberIMessageReplyCache({
accountId: "default",
messageId: "phone-msg",
chatGuid: "any;-;+12069106512",
chatIdentifier: "+12069106512",
timestamp: Date.now(),
});
for (const ctx of [
{ accountId: "default", chatIdentifier: "iMessage;-;+12069106512" },
{ accountId: "default", chatIdentifier: "SMS;-;+12069106512" },
{ accountId: "default", chatGuid: "any;-;+12069106512" },
{ accountId: "default", chatIdentifier: "+12069106512" },
]) {
const found = findLatestIMessageEntryForChat(ctx);
expect(found?.messageId).toBe("phone-msg");
}
});
it("requires accountId — refuses to guess across all known chats", () => {
rememberIMessageReplyCache({
accountId: "default",
messageId: "anywhere",
chatIdentifier: "+12069106512",
timestamp: Date.now(),
});
// accountId is optional in the signature; calling without it exercises the
// runtime guard that returns undefined rather than a cross-account match.
expect(findLatestIMessageEntryForChat({ chatIdentifier: "+12069106512" })).toBeUndefined();
});
});
describe("hydrate-on-resolve (post-restart short-id persistence)", () => {
it("hydrates SQLite state before resolving a short id whose mapping predates this run", () => {
// Issue-then-restart contract: a shortId we issued before a gateway
// restart must still resolve afterwards. The first resolve call after
// process boot would otherwise miss the persisted mapping because the
// in-memory maps haven't been hydrated yet — that's the bug codex
// review flagged. resolveIMessageMessageId now hydrates on entry.
const issued = rememberIMessageReplyCache({
accountId: "default",
messageId: "outbound-guid-pre-restart",
chatGuid: "iMessage;+;chatA",
timestamp: Date.now(),
isFromMe: true,
});
expect(issued.shortId).not.toBe("");
// Simulate a restart: clear only the process-local maps and leave the
// SQLite plugin-state rows intact.
resetIMessageShortIdState({ clearPersistent: false });
// Now resolve the short id we issued before the "restart". Without the
// hydrate-on-resolve fix this throws "no longer available" because the
// in-memory maps are empty and rememberIMessageReplyCache hasn't been
// called yet to trigger hydration.
expect(
resolveIMessageMessageId(issued.shortId, {
requireKnownShortId: true,
chatContext: { chatGuid: "iMessage;+;chatA" },
}),
).toBe("outbound-guid-pre-restart");
});
it("persists entries when optional chat fields are explicitly undefined", () => {
const issued = rememberIMessageReplyCache({
accountId: "default",
messageId: "guid-with-undefined-optionals",
chatGuid: undefined,
chatIdentifier: undefined,
chatId: undefined,
timestamp: Date.now(),
});
resetIMessageShortIdState({ clearPersistent: false });
expect(
resolveIMessageMessageId(issued.shortId, {
requireKnownShortId: true,
chatContext: { chatIdentifier: "+15551234567" },
}),
).toBe("guid-with-undefined-optionals");
});
it("does not reuse short ids after cached rows expire", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-05-08T00:00:00Z"));
const first = rememberIMessageReplyCache({
accountId: "default",
messageId: "old-guid",
timestamp: Date.now(),
});
expect(first.shortId).toBe("1");
vi.setSystemTime(new Date("2026-05-08T07:00:00Z"));
resetIMessageShortIdState({ clearPersistent: false });
const second = rememberIMessageReplyCache({
accountId: "default",
messageId: "new-guid",
timestamp: Date.now(),
});
expect(second.shortId).toBe("2");
});
});
describe("hydrate counter advancement (rowid-collision protection)", () => {
it("advances the short-id counter past a corrupt persisted line so new allocations don't collide", () => {
// Direct hydrate isn't easy to invoke without disk fixtures; instead
// verify the public contract: after rememberIMessageReplyCache fires,
// the next allocation never re-uses an existing live shortId.
const a = rememberIMessageReplyCache({
accountId: "default",
messageId: "msg-a",
chatIdentifier: "+12069106512",
timestamp: Date.now(),
});
const b = rememberIMessageReplyCache({
accountId: "default",
messageId: "msg-b",
chatIdentifier: "+12069106512",
timestamp: Date.now(),
});
expect(a.shortId).not.toBe(b.shortId);
expect(Number.parseInt(b.shortId, 10)).toBeGreaterThan(Number.parseInt(a.shortId, 10));
});
});

View File

@@ -0,0 +1,557 @@
// Imessage plugin module implements monitor reply cache behavior.
import { createHash } from "node:crypto";
import type { PluginStateSyncKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { getIMessageRuntime } from "./runtime.js";
export const IMESSAGE_REPLY_CACHE_NAMESPACE = "imessage.reply-cache";
export const IMESSAGE_REPLY_CACHE_MAX_ENTRIES = 2000;
export const IMESSAGE_REPLY_CACHE_COUNTER_NAMESPACE = "imessage.reply-cache-counter";
export const IMESSAGE_REPLY_CACHE_COUNTER_MAX_ENTRIES = 1;
export const IMESSAGE_REPLY_CACHE_COUNTER_KEY = "short-id-counter";
const REPLY_CACHE_TTL_MS = 6 * 60 * 60 * 1000;
/** Recency window for the "react to the latest message" fallback. */
const LATEST_FALLBACK_MS = 10 * 60 * 1000;
let persistenceFailureLogged = false;
function reportPersistenceFailure(scope: string, err: unknown): void {
if (persistenceFailureLogged) {
return;
}
persistenceFailureLogged = true;
logVerbose(`imessage reply-cache: ${scope} disabled after first failure: ${String(err)}`);
}
export type IMessageChatContext = {
chatGuid?: string;
chatIdentifier?: string;
chatId?: number;
};
type IMessageReplyCacheEntry = IMessageChatContext & {
accountId: string;
messageId: string;
shortId: string;
timestamp: number;
/**
* True when the gateway sent this message itself (recorded from the
* outbound path in send.ts after a successful imsg send), false when the
* cache entry came from inbound watch (most common path).
*
* Edit / unsend actions require this to be true: Messages.app only lets
* the original sender edit or retract a message, and even if the bridge
* accepted a non-sender attempt, letting an agent unsend a human user's
* message in a group chat would be a permission boundary violation.
*
* Optional for backwards compatibility with persisted entries from older
* gateway versions that did not record this field; missing values are
* treated as `false` (the safe default — pre-existing entries on disk
* came from the inbound-only writer that existed before this change).
*/
isFromMe?: boolean;
};
type IMessageReplyCacheStore = PluginStateSyncKeyedStore<IMessageReplyCacheEntry>;
type IMessageReplyCacheCounter = { counter: number };
const imessageReplyCacheByMessageId = new Map<string, IMessageReplyCacheEntry>();
const imessageShortIdToUuid = new Map<string, string>();
const imessageUuidToShortId = new Map<string, string>();
let imessageShortIdCounter = 0;
export function resolveIMessageReplyCacheEntryKey(messageId: string): string {
return createHash("sha256").update(messageId, "utf8").digest("hex").slice(0, 32);
}
function openReplyCacheStore(): IMessageReplyCacheStore {
return getIMessageRuntime().state.openSyncKeyedStore<IMessageReplyCacheEntry>({
namespace: IMESSAGE_REPLY_CACHE_NAMESPACE,
maxEntries: IMESSAGE_REPLY_CACHE_MAX_ENTRIES,
});
}
function openReplyCacheCounterStore(): PluginStateSyncKeyedStore<IMessageReplyCacheCounter> {
return getIMessageRuntime().state.openSyncKeyedStore<IMessageReplyCacheCounter>({
namespace: IMESSAGE_REPLY_CACHE_COUNTER_NAMESPACE,
maxEntries: IMESSAGE_REPLY_CACHE_COUNTER_MAX_ENTRIES,
});
}
function remainingTtlMs(timestamp: number): number | undefined {
const remaining = REPLY_CACHE_TTL_MS - Math.max(0, Date.now() - timestamp);
return remaining > 0 ? remaining : undefined;
}
let hydrated = false;
function hydrateFromStoreOnce(): void {
if (hydrated) {
return;
}
hydrated = true;
const cutoff = Date.now() - REPLY_CACHE_TTL_MS;
let entries: IMessageReplyCacheEntry[];
try {
const counter = openReplyCacheCounterStore().lookup(IMESSAGE_REPLY_CACHE_COUNTER_KEY);
if (counter && Number.isSafeInteger(counter.counter) && counter.counter > 0) {
imessageShortIdCounter = Math.max(imessageShortIdCounter, counter.counter);
}
const store = openReplyCacheStore();
entries = store
.entries()
.map(({ value }) => value)
.filter((entry) => entry.timestamp >= cutoff)
.toSorted((a, b) => a.timestamp - b.timestamp)
.slice(-IMESSAGE_REPLY_CACHE_MAX_ENTRIES);
for (const entry of entries) {
const numeric = Number.parseInt(entry.shortId, 10);
if (Number.isFinite(numeric) && numeric > imessageShortIdCounter) {
imessageShortIdCounter = numeric;
}
}
} catch (err) {
reportPersistenceFailure("read", err);
return;
}
if (entries.length === 0) {
return;
}
for (const entry of entries) {
imessageReplyCacheByMessageId.set(entry.messageId, entry);
imessageShortIdToUuid.set(entry.shortId, entry.messageId);
imessageUuidToShortId.set(entry.messageId, entry.shortId);
}
}
function persistReplyCacheEntry(entry: IMessageReplyCacheEntry): void {
const ttlMs = remainingTtlMs(entry.timestamp);
if (!ttlMs) {
return;
}
try {
openReplyCacheStore().register(resolveIMessageReplyCacheEntryKey(entry.messageId), entry, {
ttlMs,
});
} catch (err) {
reportPersistenceFailure("write", err);
}
}
function deleteReplyCacheEntry(messageId: string): void {
try {
openReplyCacheStore().delete(resolveIMessageReplyCacheEntryKey(messageId));
} catch (err) {
reportPersistenceFailure("delete", err);
}
}
function persistReplyCacheCounter(): void {
try {
openReplyCacheCounterStore().register(IMESSAGE_REPLY_CACHE_COUNTER_KEY, {
counter: imessageShortIdCounter,
});
} catch (err) {
reportPersistenceFailure("counter", err);
}
}
function buildReplyCacheEntry(
entry: Omit<IMessageReplyCacheEntry, "shortId">,
messageId: string,
shortId: string,
): IMessageReplyCacheEntry {
return {
accountId: entry.accountId,
messageId,
shortId,
timestamp: entry.timestamp,
...(typeof entry.chatGuid === "string" ? { chatGuid: entry.chatGuid } : {}),
...(typeof entry.chatIdentifier === "string" ? { chatIdentifier: entry.chatIdentifier } : {}),
...(typeof entry.chatId === "number" ? { chatId: entry.chatId } : {}),
...(typeof entry.isFromMe === "boolean" ? { isFromMe: entry.isFromMe } : {}),
};
}
function generateShortId(): string {
imessageShortIdCounter += 1;
persistReplyCacheCounter();
return String(imessageShortIdCounter);
}
export function rememberIMessageReplyCache(
entry: Omit<IMessageReplyCacheEntry, "shortId">,
): IMessageReplyCacheEntry {
hydrateFromStoreOnce();
const messageId = entry.messageId.trim();
if (!messageId) {
return { ...entry, shortId: "" };
}
let shortId = imessageUuidToShortId.get(messageId);
if (!shortId) {
shortId = generateShortId();
imessageShortIdToUuid.set(shortId, messageId);
imessageUuidToShortId.set(messageId, shortId);
}
const fullEntry = buildReplyCacheEntry(entry, messageId, shortId);
imessageReplyCacheByMessageId.delete(messageId);
imessageReplyCacheByMessageId.set(messageId, fullEntry);
const cutoff = Date.now() - REPLY_CACHE_TTL_MS;
let evicted = false;
const deletedMessageIds: string[] = [];
for (const [key, value] of imessageReplyCacheByMessageId) {
if (value.timestamp >= cutoff) {
break;
}
imessageReplyCacheByMessageId.delete(key);
deletedMessageIds.push(key);
if (value.shortId) {
imessageShortIdToUuid.delete(value.shortId);
imessageUuidToShortId.delete(key);
}
evicted = true;
}
while (imessageReplyCacheByMessageId.size > IMESSAGE_REPLY_CACHE_MAX_ENTRIES) {
const oldest = imessageReplyCacheByMessageId.keys().next().value;
if (!oldest) {
break;
}
const oldEntry = imessageReplyCacheByMessageId.get(oldest);
imessageReplyCacheByMessageId.delete(oldest);
deletedMessageIds.push(oldest);
if (oldEntry?.shortId) {
imessageShortIdToUuid.delete(oldEntry.shortId);
imessageUuidToShortId.delete(oldest);
}
evicted = true;
}
if (evicted) {
for (const messageIdToDelete of deletedMessageIds) {
deleteReplyCacheEntry(messageIdToDelete);
}
}
persistReplyCacheEntry(fullEntry);
return fullEntry;
}
function hasChatScope(ctx?: IMessageChatContext): boolean {
if (!ctx) {
return false;
}
return Boolean(
normalizeOptionalString(ctx.chatGuid) ||
normalizeOptionalString(ctx.chatIdentifier) ||
typeof ctx.chatId === "number",
);
}
/**
* Strip the `iMessage;-;` / `SMS;-;` / `any;-;` service prefix that Messages
* uses for direct chats. Different layers report direct DMs in different
* forms — imsg's watch emits the bare handle plus an `any;-;…` chat_guid,
* the action surface synthesizes `iMessage;-;…` from a phone-number target —
* so comparing the raw strings would falsely flag the same chat as a
* cross-chat target. Normalize both sides to the bare suffix.
*/
export function normalizeDirectChatIdentifier(raw: string): string {
const trimmed = raw.trim();
const lowered = trimmed.toLowerCase();
for (const prefix of ["imessage;-;", "sms;-;", "any;-;"]) {
if (lowered.startsWith(prefix)) {
return trimmed.slice(prefix.length);
}
}
return trimmed;
}
function isCrossChatMismatch(cached: IMessageReplyCacheEntry, ctx: IMessageChatContext): boolean {
const cachedChatGuid = normalizeOptionalString(cached.chatGuid);
const ctxChatGuid = normalizeOptionalString(ctx.chatGuid);
if (cachedChatGuid && ctxChatGuid) {
if (
normalizeDirectChatIdentifier(cachedChatGuid) === normalizeDirectChatIdentifier(ctxChatGuid)
) {
return false;
}
return cachedChatGuid !== ctxChatGuid;
}
const cachedChatIdentifier = normalizeOptionalString(cached.chatIdentifier);
const ctxChatIdentifier = normalizeOptionalString(ctx.chatIdentifier);
if (cachedChatIdentifier && ctxChatIdentifier) {
if (
normalizeDirectChatIdentifier(cachedChatIdentifier) ===
normalizeDirectChatIdentifier(ctxChatIdentifier)
) {
return false;
}
return cachedChatIdentifier !== ctxChatIdentifier;
}
const cachedChatId = typeof cached.chatId === "number" ? cached.chatId : undefined;
const ctxChatId = typeof ctx.chatId === "number" ? ctx.chatId : undefined;
if (cachedChatId !== undefined && ctxChatId !== undefined) {
return cachedChatId !== ctxChatId;
}
// Cross-format pairing: caller supplied chatIdentifier=iMessage;-;<phone>
// and the cache stored chatGuid=any;-;<phone> (or vice versa). Compare via
// the direct-DM normalization so we recognize them as the same chat.
const cachedFingerprint = cachedChatGuid
? normalizeDirectChatIdentifier(cachedChatGuid)
: cachedChatIdentifier
? normalizeDirectChatIdentifier(cachedChatIdentifier)
: undefined;
const ctxFingerprint = ctxChatGuid
? normalizeDirectChatIdentifier(ctxChatGuid)
: ctxChatIdentifier
? normalizeDirectChatIdentifier(ctxChatIdentifier)
: undefined;
if (cachedFingerprint && ctxFingerprint) {
return cachedFingerprint !== ctxFingerprint;
}
return false;
}
function describeChatForError(values: IMessageChatContext): string {
const parts: string[] = [];
if (normalizeOptionalString(values.chatGuid)) {
parts.push("chatGuid=<redacted>");
}
if (normalizeOptionalString(values.chatIdentifier)) {
parts.push("chatIdentifier=<redacted>");
}
if (typeof values.chatId === "number") {
parts.push("chatId=<redacted>");
}
return parts.length === 0 ? "<unknown chat>" : parts.join(", ");
}
function describeMessageIdForError(inputId: string, inputKind: "short" | "uuid"): string {
if (inputKind === "short") {
return `<short:${inputId.length}-digit>`;
}
return `<uuid:${inputId.slice(0, 8)}...>`;
}
function buildCrossChatError(
inputId: string,
inputKind: "short" | "uuid",
cached: IMessageReplyCacheEntry,
ctx: IMessageChatContext,
): Error {
const remediation =
inputKind === "short"
? "Retry with MessageSidFull to avoid cross-chat reactions/replies landing in the wrong conversation."
: "Retry with the correct chat target.";
return new Error(
`iMessage message id ${describeMessageIdForError(inputId, inputKind)} belongs to a different chat ` +
`(${describeChatForError(cached)}) than the current call target (${describeChatForError(ctx)}). ${remediation}`,
);
}
export function resolveIMessageMessageId(
shortOrUuid: string,
opts?: {
requireKnownShortId?: boolean;
chatContext?: IMessageChatContext;
/**
* When true, only resolve message ids that the gateway recorded as sent
* by itself (`isFromMe: true`). Used by `edit` / `unsend` so an agent
* cannot retract or edit messages other participants sent — Messages.app
* enforces this at the OS level too, but failing earlier in the plugin
* gives a clean error and avoids dispatching a guaranteed-to-fail bridge
* call.
*
* Cache entries with no `isFromMe` field (older persisted entries from
* before this option existed, or any uncached UUID the agent passes
* through) are treated as not-from-me and rejected.
*/
requireFromMe?: boolean;
},
): string {
const trimmed = shortOrUuid.trim();
if (!trimmed) {
return trimmed;
}
// Hydrate SQLite-backed mappings before reading them. Without this, the
// first post-restart action with a short MessageSid would miss
// `imessageShortIdToUuid` and fall through to "no longer available".
// `rememberIMessageReplyCache` already hydrates on its own, so this only
// matters for the resolve-first-after-restart sequence.
hydrateFromStoreOnce();
if (/^\d+$/.test(trimmed)) {
// Cache hit: the cached entry carries the chat info this short id was
// issued for, so we can resolve the UUID even without a caller-supplied
// chat scope. Cross-chat detection still fires when the caller did
// provide a scope and it disagrees with the cache.
const uuid = imessageShortIdToUuid.get(trimmed);
if (uuid) {
const cached = imessageReplyCacheByMessageId.get(uuid);
if (opts?.chatContext && hasChatScope(opts.chatContext)) {
if (cached && isCrossChatMismatch(cached, opts.chatContext)) {
throw buildCrossChatError(trimmed, "short", cached, opts.chatContext);
}
}
if (opts?.requireFromMe && cached?.isFromMe !== true) {
throw buildFromMeError(trimmed, "short");
}
return uuid;
}
// Cache miss: now the chat-scope requirement matters — without scope
// we have no way to verify the caller is reacting in the right chat,
// and without a cached UUID the bridge cannot resolve the short id.
if (opts?.requireKnownShortId && !hasChatScope(opts.chatContext)) {
throw new Error(
`iMessage short message id ${describeMessageIdForError(trimmed, "short")} requires a chat scope (chatGuid / chatIdentifier / chatId or a target).`,
);
}
if (opts?.requireKnownShortId) {
throw new Error(
`iMessage short message id ${describeMessageIdForError(trimmed, "short")} is no longer available. Use MessageSidFull.`,
);
}
return trimmed;
}
const cached = imessageReplyCacheByMessageId.get(trimmed);
if (opts?.chatContext) {
if (cached && isCrossChatMismatch(cached, opts.chatContext)) {
throw buildCrossChatError(trimmed, "uuid", cached, opts.chatContext);
}
}
if (opts?.requireFromMe && cached?.isFromMe !== true) {
throw buildFromMeError(trimmed, "uuid");
}
return trimmed;
}
export function isKnownFromMeIMessageMessageId(
messageId: string | undefined,
ctx: IMessageChatContext & { accountId?: string },
): boolean {
const trimmed = normalizeOptionalString(messageId);
if (!trimmed || !ctx.accountId || !hasChatScope(ctx)) {
return false;
}
hydrateFromStoreOnce();
const cached = imessageReplyCacheByMessageId.get(trimmed);
if (!cached || cached.isFromMe !== true || cached.accountId !== ctx.accountId) {
return false;
}
return isPositiveChatMatch(cached, ctx);
}
function buildFromMeError(inputId: string, inputKind: "short" | "uuid"): Error {
return new Error(
`iMessage message id ${describeMessageIdForError(inputId, inputKind)} is not one this agent sent. ` +
`edit and unsend can only target messages the gateway delivered itself; ` +
`messages received from other participants cannot be modified.`,
);
}
/**
* Return the most recent cached entry whose chat scope matches the supplied
* context. Used as a fallback when an agent calls a per-message action (e.g.
* `react`) without specifying a `messageId` — the natural intent is "react
* to the message I just received in this chat."
*
* Strict semantics for safety:
* - Caller must supply a chat scope. We refuse to "guess" the active chat.
* - Cached entry must positively match on at least one identifier kind
* (chatGuid, chatIdentifier, chatId, or normalized direct-DM fingerprint).
* We do NOT fall through on "no overlapping identifier" — that's how a
* cached entry from a foreign chat could be returned when the caller's
* context didn't share any identifier kind with the cache.
* - Caller must supply an accountId; we never cross account boundaries.
* - We only consider entries newer than `LATEST_FALLBACK_MS`. The intent
* of "react to the latest" is "the message I just received," not
* "anything in this chat from any time."
*/
export function findLatestIMessageEntryForChat(
ctx: IMessageChatContext & { accountId?: string },
): IMessageReplyCacheEntry | undefined {
if (!hasChatScope(ctx)) {
return undefined;
}
if (!ctx.accountId) {
return undefined;
}
const cutoff = Date.now() - LATEST_FALLBACK_MS;
let best: IMessageReplyCacheEntry | undefined;
for (const entry of imessageReplyCacheByMessageId.values()) {
if (entry.accountId !== ctx.accountId) {
continue;
}
if (entry.timestamp < cutoff) {
continue;
}
if (!isPositiveChatMatch(entry, ctx)) {
continue;
}
if (!best || entry.timestamp > best.timestamp) {
best = entry;
}
}
return best;
}
/**
* Return true when the cached entry positively matches the caller's chat
* context on at least one identifier kind. Unlike `isCrossChatMismatch`,
* which returns false for "no overlap," this requires concrete agreement.
*/
function isPositiveChatMatch(entry: IMessageReplyCacheEntry, ctx: IMessageChatContext): boolean {
const cachedChatGuid = normalizeOptionalString(entry.chatGuid);
const ctxChatGuid = normalizeOptionalString(ctx.chatGuid);
if (cachedChatGuid && ctxChatGuid && cachedChatGuid === ctxChatGuid) {
return true;
}
const cachedChatIdentifier = normalizeOptionalString(entry.chatIdentifier);
const ctxChatIdentifier = normalizeOptionalString(ctx.chatIdentifier);
if (cachedChatIdentifier && ctxChatIdentifier && cachedChatIdentifier === ctxChatIdentifier) {
return true;
}
if (
typeof entry.chatId === "number" &&
typeof ctx.chatId === "number" &&
entry.chatId === ctx.chatId
) {
return true;
}
// Cross-format: cached chatGuid vs ctx chatIdentifier, etc. Compare via
// the direct-DM normalization that strips iMessage;-;/SMS;-;/any;-; .
const cachedFingerprint = cachedChatGuid
? normalizeDirectChatIdentifier(cachedChatGuid)
: cachedChatIdentifier
? normalizeDirectChatIdentifier(cachedChatIdentifier)
: undefined;
const ctxFingerprint = ctxChatGuid
? normalizeDirectChatIdentifier(ctxChatGuid)
: ctxChatIdentifier
? normalizeDirectChatIdentifier(ctxChatIdentifier)
: undefined;
if (cachedFingerprint && ctxFingerprint && cachedFingerprint === ctxFingerprint) {
return true;
}
return false;
}
export function resetIMessageShortIdState(options: { clearPersistent?: boolean } = {}): void {
imessageReplyCacheByMessageId.clear();
imessageShortIdToUuid.clear();
imessageUuidToShortId.clear();
imessageShortIdCounter = 0;
hydrated = false;
persistenceFailureLogged = false;
if (options.clearPersistent === false) {
return;
}
try {
openReplyCacheStore().clear();
openReplyCacheCounterStore().clear();
} catch {
// best-effort
}
}

View File

@@ -0,0 +1,712 @@
// Imessage tests cover monitor.gating plugin behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { beforeEach, describe, expect, it } from "vitest";
import { resetIMessageShortIdState } from "./monitor-reply-cache.js";
import {
buildIMessageInboundContext,
resolveIMessageInboundDecision,
} from "./monitor/inbound-processing.js";
import { parseIMessageNotification } from "./monitor/parse-notification.js";
import type { IMessagePayload } from "./monitor/types.js";
import { installIMessageStateRuntimeForTest } from "./test-support/runtime.js";
beforeEach(() => {
installIMessageStateRuntimeForTest();
resetIMessageShortIdState();
});
function baseCfg(): OpenClawConfig {
return {
channels: {
imessage: {
dmPolicy: "open",
allowFrom: ["*"],
groupPolicy: "open",
groups: { "*": { requireMention: true } },
},
},
session: { mainKey: "main" },
messages: {
groupChat: { mentionPatterns: ["@openclaw"] },
},
} as unknown as OpenClawConfig;
}
async function resolve(params: {
cfg?: OpenClawConfig;
message: IMessagePayload;
storeAllowFrom?: string[];
}) {
const cfg = params.cfg ?? baseCfg();
const groupHistories = new Map();
return resolveIMessageInboundDecision({
cfg,
accountId: "default",
message: params.message,
opts: {},
messageText: (params.message.text ?? "").trim(),
bodyText: (params.message.text ?? "").trim(),
allowFrom: ["*"],
groupAllowFrom: [],
groupPolicy: cfg.channels?.imessage?.groupPolicy ?? "open",
dmPolicy: cfg.channels?.imessage?.dmPolicy ?? "pairing",
storeAllowFrom: params.storeAllowFrom ?? [],
historyLimit: 0,
groupHistories,
});
}
async function resolveDispatchDecision(params: {
cfg: OpenClawConfig;
message: IMessagePayload;
groupHistories?: Parameters<typeof resolveIMessageInboundDecision>[0]["groupHistories"];
allowFrom?: string[];
groupAllowFrom?: string[];
allowLegacyConversationAllowFromForGroup?: boolean;
groupPolicy?: "open" | "allowlist" | "disabled";
dmPolicy?: "open" | "pairing" | "allowlist" | "disabled";
}) {
const groupHistories = params.groupHistories ?? new Map();
const decision = await resolveIMessageInboundDecision({
cfg: params.cfg,
accountId: "default",
message: params.message,
opts: {},
messageText: params.message.text ?? "",
bodyText: params.message.text ?? "",
allowFrom: params.allowFrom ?? ["*"],
groupAllowFrom: params.groupAllowFrom ?? [],
allowLegacyConversationAllowFromForGroup: params.allowLegacyConversationAllowFromForGroup,
groupPolicy: params.groupPolicy ?? "open",
dmPolicy: params.dmPolicy ?? "open",
storeAllowFrom: [],
historyLimit: 0,
groupHistories,
});
expect(decision.kind).toBe("dispatch");
if (decision.kind !== "dispatch") {
throw new Error("expected dispatch decision");
}
return { decision, groupHistories };
}
async function buildDispatchContextPayload(params: {
cfg: OpenClawConfig;
message: IMessagePayload;
}) {
const { cfg, message } = params;
const { decision, groupHistories } = await resolveDispatchDecision({ cfg, message });
const { ctxPayload } = await buildIMessageInboundContext({
cfg,
decision,
message,
historyLimit: 0,
groupHistories,
});
return ctxPayload;
}
describe("imessage monitor gating + envelope builders", () => {
it("parseIMessageNotification rejects malformed payloads", async () => {
expect(
parseIMessageNotification({
message: { chat_id: 1, sender: { nested: "nope" } },
}),
).toBeNull();
});
it("parseIMessageNotification preserves destination_caller_id metadata", async () => {
expect(
parseIMessageNotification({
message: {
id: 1,
sender: "+15550001111",
destination_caller_id: "+15550002222",
is_from_me: true,
text: "hello",
},
}),
).toEqual({
id: 1,
sender: "+15550001111",
destination_caller_id: "+15550002222",
is_from_me: true,
text: "hello",
});
});
it("drops group messages without mention by default", async () => {
const decision = await resolve({
message: {
id: 1,
chat_id: 99,
sender: "+15550001111",
is_from_me: false,
text: "hello group",
is_group: true,
},
});
expect(decision.kind).toBe("drop");
if (decision.kind !== "drop") {
throw new Error("expected drop decision");
}
expect(decision.reason).toBe("no mention");
});
it("dispatches group messages with mention and builds a group envelope", async () => {
const cfg = baseCfg();
const message: IMessagePayload = {
id: 3,
chat_id: 42,
sender: "+15550002222",
is_from_me: false,
text: "@openclaw ping",
is_group: true,
chat_name: "Lobster Squad",
participants: ["+1555", "+1556"],
};
const ctxPayload = await buildDispatchContextPayload({ cfg, message });
expect(ctxPayload.ChatType).toBe("group");
expect(ctxPayload.SessionKey).toBe("agent:main:imessage:group:42");
expect(ctxPayload.Body ?? "").toContain("+15550002222:");
expect(ctxPayload.Body ?? "").not.toContain("[from:");
expect(ctxPayload.To).toBe("chat_id:42");
});
it("uses short message ids in context and keeps the full guid for actions", async () => {
const cfg = baseCfg();
const message: IMessagePayload = {
id: 3,
guid: "full-message-guid",
chat_id: 42,
chat_guid: "iMessage;+;chat0000",
chat_identifier: "thread-42",
sender: "+15550002222",
is_from_me: false,
text: "@openclaw ping",
is_group: true,
chat_name: "Lobster Squad",
participants: ["+1555", "+1556"],
};
const ctxPayload = await buildDispatchContextPayload({ cfg, message });
expect(ctxPayload.MessageSid).toBe("1");
expect(ctxPayload.MessageSidFull).toBe("full-message-guid");
});
it("includes reply-to context fields + suffix", async () => {
const cfg = baseCfg();
const message: IMessagePayload = {
id: 5,
chat_id: 55,
sender: "+15550001111",
is_from_me: false,
text: "replying now",
is_group: false,
reply_to_id: 9001,
reply_to_text: "original message",
reply_to_sender: "+15559998888",
};
const ctxPayload = await buildDispatchContextPayload({ cfg, message });
expect(ctxPayload.ReplyToId).toBe("9001");
expect(ctxPayload.ReplyToBody).toBe("original message");
expect(ctxPayload.ReplyToSender).toBe("+15559998888");
expect(ctxPayload.Body ?? "").toContain("[Replying to +15559998888 id:9001]");
expect(ctxPayload.Body ?? "").toContain("original message");
});
it("drops group reply context from non-allowlisted senders in allowlist mode", async () => {
const cfg = baseCfg();
cfg.channels ??= {};
cfg.channels.imessage ??= {};
cfg.channels.imessage.groupPolicy = "allowlist";
cfg.channels.imessage.contextVisibility = "allowlist";
const message: IMessagePayload = {
id: 6,
chat_id: 55,
sender: "+15550001111",
is_from_me: false,
text: "@openclaw replying now",
is_group: true,
reply_to_id: 9001,
reply_to_text: "blocked quote",
reply_to_sender: "+15559998888",
};
const { decision, groupHistories } = await resolveDispatchDecision({
cfg,
message,
allowFrom: ["*"],
groupAllowFrom: ["+15550001111"],
groupPolicy: "allowlist",
});
const { ctxPayload } = await buildIMessageInboundContext({
cfg,
decision,
message,
historyLimit: 0,
groupHistories,
});
expect(ctxPayload.ReplyToId).toBeUndefined();
expect(ctxPayload.ReplyToBody).toBeUndefined();
expect(ctxPayload.ReplyToSender).toBeUndefined();
expect(ctxPayload.Body ?? "").not.toContain("[Replying to");
});
it("keeps group reply context when the group allowlist matches the chat target", async () => {
const cfg = baseCfg();
cfg.channels ??= {};
cfg.channels.imessage ??= {};
cfg.channels.imessage.groupPolicy = "allowlist";
cfg.channels.imessage.contextVisibility = "allowlist";
const message: IMessagePayload = {
id: 8,
chat_id: 55,
sender: "+15550001111",
is_from_me: false,
text: "@openclaw replying now",
is_group: true,
reply_to_id: 9001,
reply_to_text: "quoted context",
reply_to_sender: "+15559998888",
};
const { decision, groupHistories } = await resolveDispatchDecision({
cfg,
message,
allowFrom: ["*"],
groupAllowFrom: ["chat_id:55"],
groupPolicy: "allowlist",
});
const { ctxPayload } = await buildIMessageInboundContext({
cfg,
decision,
message,
historyLimit: 0,
groupHistories,
});
expect(ctxPayload.ReplyToId).toBe("9001");
expect(ctxPayload.ReplyToBody).toBe("quoted context");
expect(ctxPayload.ReplyToSender).toBe("+15559998888");
expect(ctxPayload.Body ?? "").toContain("[Replying to +15559998888 id:9001]");
});
it("keeps group reply context when the group allowlist matches an access group", async () => {
const cfg = baseCfg();
cfg.channels ??= {};
cfg.channels.imessage ??= {};
cfg.channels.imessage.groupPolicy = "allowlist";
cfg.channels.imessage.contextVisibility = "allowlist";
cfg.accessGroups = {
oncall: {
type: "message.senders",
members: { imessage: ["+15559998888"] },
},
};
const message: IMessagePayload = {
id: 9,
chat_id: 56,
sender: "+15559998888",
is_from_me: false,
text: "@openclaw replying now",
is_group: true,
reply_to_id: 9002,
reply_to_text: "own quoted context",
reply_to_sender: "+15559998888",
};
const { decision, groupHistories } = await resolveDispatchDecision({
cfg,
message,
allowFrom: ["*"],
groupAllowFrom: ["accessGroup:oncall"],
groupPolicy: "allowlist",
});
const { ctxPayload } = await buildIMessageInboundContext({
cfg,
decision,
message,
historyLimit: 0,
groupHistories,
});
expect(ctxPayload.ReplyToId).toBe("9002");
expect(ctxPayload.ReplyToBody).toBe("own quoted context");
expect(ctxPayload.ReplyToSender).toBe("+15559998888");
expect(ctxPayload.Body ?? "").toContain("[Replying to +15559998888 id:9002]");
});
it("keeps group reply context in allowlist_quote mode", async () => {
const cfg = baseCfg();
cfg.channels ??= {};
cfg.channels.imessage ??= {};
cfg.channels.imessage.groupPolicy = "allowlist";
cfg.channels.imessage.contextVisibility = "allowlist_quote";
const message: IMessagePayload = {
id: 7,
chat_id: 55,
sender: "+15550001111",
is_from_me: false,
text: "@openclaw replying now",
is_group: true,
reply_to_id: 9001,
reply_to_text: "quoted context",
reply_to_sender: "+15559998888",
};
const { decision, groupHistories } = await resolveDispatchDecision({
cfg,
message,
allowFrom: ["*"],
groupAllowFrom: ["+15550001111"],
groupPolicy: "allowlist",
});
const { ctxPayload } = await buildIMessageInboundContext({
cfg,
decision,
message,
historyLimit: 0,
groupHistories,
});
expect(ctxPayload.ReplyToId).toBe("9001");
expect(ctxPayload.ReplyToBody).toBe("quoted context");
expect(ctxPayload.ReplyToSender).toBe("+15559998888");
expect(ctxPayload.Body ?? "").toContain("[Replying to +15559998888 id:9001]");
});
it("treats configured chat_id as a group session even when is_group is false", async () => {
const cfg = baseCfg();
cfg.channels ??= {};
cfg.channels.imessage ??= {};
cfg.channels.imessage.groups = { "2": { requireMention: false } };
const groupHistories = new Map();
const message: IMessagePayload = {
id: 14,
chat_id: 2,
sender: "+15550001111",
is_from_me: false,
text: "hello",
is_group: false,
};
const { decision } = await resolveDispatchDecision({ cfg, message, groupHistories });
expect(decision.isGroup).toBe(true);
expect(decision.route.sessionKey).toBe("agent:main:imessage:group:2");
});
it("allows group messages when requireMention is true but no mentionPatterns exist", async () => {
const cfg = baseCfg();
cfg.messages ??= {};
cfg.messages.groupChat ??= {};
cfg.messages.groupChat.mentionPatterns = [];
const groupHistories = new Map();
const decision = await resolveIMessageInboundDecision({
cfg,
accountId: "default",
message: {
id: 12,
chat_id: 777,
sender: "+15550001111",
is_from_me: false,
text: "hello group",
is_group: true,
},
opts: {},
messageText: "hello group",
bodyText: "hello group",
allowFrom: ["*"],
groupAllowFrom: [],
groupPolicy: "open",
dmPolicy: "open",
storeAllowFrom: [],
historyLimit: 0,
groupHistories,
});
expect(decision.kind).toBe("dispatch");
});
it("blocks group messages when imessage.groups is set without a wildcard", async () => {
const cfg = baseCfg();
cfg.channels ??= {};
cfg.channels.imessage ??= {};
cfg.channels.imessage.groups = { "99": { requireMention: false } };
const groupHistories = new Map();
const decision = await resolveIMessageInboundDecision({
cfg,
accountId: "default",
message: {
id: 13,
chat_id: 123,
sender: "+15550001111",
is_from_me: false,
text: "@openclaw hello",
is_group: true,
},
opts: {},
messageText: "@openclaw hello",
bodyText: "@openclaw hello",
allowFrom: ["*"],
groupAllowFrom: [],
groupPolicy: "open",
dmPolicy: "open",
storeAllowFrom: [],
historyLimit: 0,
groupHistories,
});
expect(decision.kind).toBe("drop");
});
it("honors group allowlist and ignores pairing-store senders in groups", async () => {
const cfg = baseCfg();
cfg.channels ??= {};
cfg.channels.imessage ??= {};
cfg.channels.imessage.groupPolicy = "allowlist";
const groupHistories = new Map();
const denied = await resolveIMessageInboundDecision({
cfg,
accountId: "default",
message: {
id: 3,
chat_id: 202,
sender: "+15550003333",
is_from_me: false,
text: "@openclaw hi",
is_group: true,
},
opts: {},
messageText: "@openclaw hi",
bodyText: "@openclaw hi",
allowFrom: ["*"],
groupAllowFrom: ["chat_id:101"],
groupPolicy: "allowlist",
dmPolicy: "pairing",
storeAllowFrom: ["+15550003333"],
historyLimit: 0,
groupHistories,
});
expect(denied.kind).toBe("drop");
const allowed = await resolveIMessageInboundDecision({
cfg,
accountId: "default",
message: {
id: 33,
chat_id: 101,
sender: "+15550003333",
is_from_me: false,
text: "@openclaw ok",
is_group: true,
},
opts: {},
messageText: "@openclaw ok",
bodyText: "@openclaw ok",
allowFrom: ["*"],
groupAllowFrom: ["chat_id:101"],
groupPolicy: "allowlist",
dmPolicy: "pairing",
storeAllowFrom: ["+15550003333"],
historyLimit: 0,
groupHistories,
});
expect(allowed.kind).toBe("dispatch");
});
it("uses legacy conversation allowFrom entries for group admission", async () => {
const cfg = baseCfg();
cfg.channels ??= {};
cfg.channels.imessage ??= {};
cfg.channels.imessage.groupPolicy = "allowlist";
const { decision } = await resolveDispatchDecision({
cfg,
message: {
id: 35,
chat_id: 101,
sender: "+15550003333",
is_from_me: false,
text: "@openclaw ok",
is_group: true,
},
allowFrom: ["chat_id:101"],
groupAllowFrom: [],
allowLegacyConversationAllowFromForGroup: true,
groupPolicy: "allowlist",
});
expect(decision.kind).toBe("dispatch");
});
it("does not use legacy conversation allowFrom entries when groupAllowFrom is explicitly empty", async () => {
const cfg = baseCfg();
cfg.channels ??= {};
cfg.channels.imessage ??= {};
cfg.channels.imessage.groupPolicy = "allowlist";
const decision = await resolveIMessageInboundDecision({
cfg,
accountId: "default",
message: {
id: 38,
chat_id: 101,
sender: "+15550003333",
is_from_me: false,
text: "@openclaw ok",
is_group: true,
},
opts: {},
messageText: "@openclaw ok",
bodyText: "@openclaw ok",
allowFrom: ["chat_id:101"],
groupAllowFrom: [],
groupPolicy: "allowlist",
dmPolicy: "pairing",
storeAllowFrom: [],
historyLimit: 0,
groupHistories: new Map(),
});
expect(decision).toEqual({
kind: "drop",
reason: "groupPolicy allowlist (empty groupAllowFrom)",
});
});
it("does not merge legacy conversation allowFrom entries when groupAllowFrom is configured", async () => {
const cfg = baseCfg();
cfg.channels ??= {};
cfg.channels.imessage ??= {};
cfg.channels.imessage.groupPolicy = "allowlist";
const decision = await resolveIMessageInboundDecision({
cfg,
accountId: "default",
message: {
id: 37,
chat_id: 101,
sender: "+15550003333",
is_from_me: false,
text: "@openclaw ok",
is_group: true,
},
opts: {},
messageText: "@openclaw ok",
bodyText: "@openclaw ok",
allowFrom: ["chat_id:101"],
groupAllowFrom: ["+15550004444"],
groupPolicy: "allowlist",
dmPolicy: "pairing",
storeAllowFrom: [],
historyLimit: 0,
groupHistories: new Map(),
});
expect(decision).toEqual({ kind: "drop", reason: "not in groupAllowFrom" });
});
it("does not authorize group control commands from conversation allowlist entries", async () => {
const cfg = baseCfg();
cfg.channels ??= {};
cfg.channels.imessage ??= {};
cfg.channels.imessage.groupPolicy = "allowlist";
const decision = await resolveIMessageInboundDecision({
cfg,
accountId: "default",
message: {
id: 34,
chat_id: 101,
sender: "+15550003333",
is_from_me: false,
text: "/status",
is_group: true,
},
opts: {},
messageText: "/status",
bodyText: "/status",
allowFrom: [],
groupAllowFrom: ["chat_id:101"],
groupPolicy: "allowlist",
dmPolicy: "pairing",
storeAllowFrom: [],
historyLimit: 0,
groupHistories: new Map(),
});
expect(decision).toEqual({ kind: "drop", reason: "control command (unauthorized)" });
});
it("does not authorize group control commands from legacy conversation allowFrom entries", async () => {
const cfg = baseCfg();
cfg.channels ??= {};
cfg.channels.imessage ??= {};
cfg.channels.imessage.groupPolicy = "allowlist";
const decision = await resolveIMessageInboundDecision({
cfg,
accountId: "default",
message: {
id: 36,
chat_id: 101,
sender: "+15550003333",
is_from_me: false,
text: "/status",
is_group: true,
},
opts: {},
messageText: "/status",
bodyText: "/status",
allowFrom: ["chat_id:101"],
groupAllowFrom: [],
allowLegacyConversationAllowFromForGroup: true,
groupPolicy: "allowlist",
dmPolicy: "pairing",
storeAllowFrom: [],
historyLimit: 0,
groupHistories: new Map(),
});
expect(decision).toEqual({ kind: "drop", reason: "control command (unauthorized)" });
});
it("blocks group messages when groupPolicy is disabled", async () => {
const cfg = baseCfg();
cfg.channels ??= {};
cfg.channels.imessage ??= {};
cfg.channels.imessage.groupPolicy = "disabled";
const groupHistories = new Map();
const decision = await resolveIMessageInboundDecision({
cfg,
accountId: "default",
message: {
id: 10,
chat_id: 303,
sender: "+15550003333",
is_from_me: false,
text: "@openclaw hi",
is_group: true,
},
opts: {},
messageText: "@openclaw hi",
bodyText: "@openclaw hi",
allowFrom: ["*"],
groupAllowFrom: [],
groupPolicy: "disabled",
dmPolicy: "open",
storeAllowFrom: [],
historyLimit: 0,
groupHistories,
});
expect(decision.kind).toBe("drop");
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,184 @@
// Imessage tests cover monitor.media policy plugin behavior.
import type { waitForTransportReady } from "openclaw/plugin-sdk/transport-ready-runtime";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { createIMessageRpcClient } from "./client.js";
import { monitorIMessageProvider } from "./monitor.js";
import type { stageIMessageAttachments } from "./monitor/media-staging.js";
import {
formatIMessageInboundMediaBody,
resolveIMessageInboundMediaInput,
} from "./monitor/monitor-provider.js";
const waitForTransportReadyMock = vi.hoisted(() =>
vi.fn<typeof waitForTransportReady>(async () => {}),
);
const createIMessageRpcClientMock = vi.hoisted(() => vi.fn<typeof createIMessageRpcClient>());
const stageIMessageAttachmentsMock = vi.hoisted(() => vi.fn<typeof stageIMessageAttachments>());
const readChannelAllowFromStoreMock = vi.hoisted(() => vi.fn(async () => [] as string[]));
vi.mock("openclaw/plugin-sdk/transport-ready-runtime", () => ({
waitForTransportReady: waitForTransportReadyMock,
}));
vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/conversation-runtime")>();
return {
...actual,
readChannelAllowFromStore: readChannelAllowFromStoreMock,
recordInboundSession: vi.fn(),
upsertChannelPairingRequest: vi.fn(),
};
});
vi.mock("openclaw/plugin-sdk/channel-inbound", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/channel-inbound")>();
return {
...actual,
createChannelInboundDebouncer: vi.fn((opts) => ({
debouncer: {
enqueue: async (entry: unknown) => await opts.onFlush([entry]),
},
})),
shouldDebounceTextInbound: vi.fn(() => false),
};
});
vi.mock("./client.js", () => ({
createIMessageRpcClient: createIMessageRpcClientMock,
}));
vi.mock("./monitor/abort-handler.js", () => ({
attachIMessageMonitorAbortHandler: vi.fn(() => () => {}),
}));
vi.mock("./monitor/media-staging.js", () => ({
stageIMessageAttachments: stageIMessageAttachmentsMock,
}));
describe("iMessage monitor attachment policy", () => {
beforeEach(() => {
createIMessageRpcClientMock.mockReset();
stageIMessageAttachmentsMock.mockReset();
readChannelAllowFromStoreMock.mockReset().mockResolvedValue([]);
});
it("does not stage local attachments for messages dropped by inbound policy", async () => {
stageIMessageAttachmentsMock.mockResolvedValue({ attachments: [], unavailableCount: 0 });
readChannelAllowFromStoreMock.mockResolvedValue([]);
const attachmentPath = "/Users/openclaw/Library/Messages/Attachments/AA/BB/photo.heic";
let onNotification:
| ((message: { method: string; params: unknown }) => void | Promise<void>)
| undefined;
const client = {
request: vi.fn(async () => ({ subscription: 1 })),
waitForClose: vi.fn(async () => {
void onNotification?.({
method: "message",
params: {
message: {
id: 1,
chat_id: 123,
sender: "+15550001111",
is_from_me: false,
is_group: true,
text: "no mention here",
attachments: [
{
original_path: attachmentPath,
mime_type: "image/heic",
missing: false,
},
],
},
},
});
await Promise.resolve();
await Promise.resolve();
}),
stop: vi.fn(async () => {}),
};
createIMessageRpcClientMock.mockImplementation(async (params) => {
if (!params?.onNotification) {
throw new Error("expected iMessage notification handler");
}
onNotification = params.onNotification;
return client as never;
});
await monitorIMessageProvider({
config: {
channels: {
imessage: {
includeAttachments: true,
attachmentRoots: ["/Users/*/Library/Messages/Attachments"],
dmPolicy: "open",
groupPolicy: "open",
groups: { "*": { requireMention: true } },
},
},
messages: { groupChat: { mentionPatterns: ["@openclaw"] } },
session: { mainKey: "main" },
} as never,
});
await vi.waitFor(() => expect(readChannelAllowFromStoreMock).toHaveBeenCalled());
expect(stageIMessageAttachmentsMock).not.toHaveBeenCalled();
});
it("admits attachment-only messages that are marked missing", async () => {
const attachment = {
original_path: "/Users/openclaw/Library/Messages/Attachments/missing.heic",
mime_type: "image/heic",
missing: true,
};
expect(
resolveIMessageInboundMediaInput({
messageText: "",
attachments: [attachment],
effectiveAttachmentRoots: [],
}),
).toEqual({
bodyText: "<media:image>",
mediaPlaceholder: "<media:image>",
mediaCandidates: [attachment],
rawMediaAttachments: [],
});
});
it("uses the first materialized attachment type when earlier media is unavailable", () => {
const missingImage = {
original_path: "/Users/openclaw/Library/Messages/Attachments/missing.heic",
mime_type: "image/heic",
missing: true,
};
const availableDocument = {
original_path: "/Users/openclaw/Library/Messages/Attachments/report.pdf",
mime_type: "application/pdf",
missing: false,
};
expect(
resolveIMessageInboundMediaInput({
messageText: "",
attachments: [missingImage, availableDocument],
effectiveAttachmentRoots: ["/Users/openclaw/Library/Messages/Attachments"],
}),
).toMatchObject({
bodyText: "<media:document>",
mediaPlaceholder: "<media:document>",
mediaCandidates: [missingImage, availableDocument],
rawMediaAttachments: [
{ path: availableDocument.original_path, contentType: "application/pdf" },
],
});
expect(
formatIMessageInboundMediaBody({
messageText: "",
optimisticPlaceholder: "<media:image>",
mediaAttachments: [{ contentType: "application/pdf" }],
unavailableCount: 1,
}),
).toBe("<media:document>\n\n[imessage attachment unavailable]");
});
});

View File

@@ -0,0 +1,103 @@
// Imessage tests cover monitor.plugin payload plugin behavior.
import type { waitForTransportReady } from "openclaw/plugin-sdk/transport-ready-runtime";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { createIMessageRpcClient } from "./client.js";
import { monitorIMessageProvider } from "./monitor.js";
const waitForTransportReadyMock = vi.hoisted(() =>
vi.fn<typeof waitForTransportReady>(async () => {}),
);
const createIMessageRpcClientMock = vi.hoisted(() => vi.fn<typeof createIMessageRpcClient>());
const shouldDebounceTextInboundMock = vi.hoisted(() => vi.fn(() => false));
vi.mock("openclaw/plugin-sdk/transport-ready-runtime", () => ({
waitForTransportReady: waitForTransportReadyMock,
}));
vi.mock("openclaw/plugin-sdk/channel-inbound", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/channel-inbound")>();
return {
...actual,
createChannelInboundDebouncer: vi.fn(
(opts: { shouldDebounce: (entry: unknown) => boolean }) => ({
debouncer: {
enqueue: async (entry: unknown) => {
opts.shouldDebounce(entry);
},
},
}),
),
shouldDebounceTextInbound: shouldDebounceTextInboundMock,
};
});
vi.mock("./client.js", () => ({
createIMessageRpcClient: createIMessageRpcClientMock,
}));
vi.mock("./monitor/abort-handler.js", () => ({
attachIMessageMonitorAbortHandler: vi.fn(() => () => {}),
}));
describe("iMessage plugin payload attachments", () => {
beforeEach(() => {
waitForTransportReadyMock.mockReset().mockResolvedValue(undefined);
createIMessageRpcClientMock.mockReset();
shouldDebounceTextInboundMock.mockReset().mockReturnValue(false);
});
it("does not count Apple rich-link plugin payloads as user media", async () => {
let onNotification: ((message: { method: string; params: unknown }) => void) | undefined;
const client = {
request: vi.fn(async () => ({ subscription: 1 })),
waitForClose: vi.fn(async () => {
onNotification?.({
method: "message",
params: {
message: {
id: 1,
chat_id: 123,
sender: "+15550001111",
is_from_me: false,
text: "https://example.com/article",
attachments: [
{
original_path:
"/Users/openclaw/Library/Messages/Attachments/AA/BB/link.pluginPayloadAttachment",
mime_type: null,
missing: false,
transfer_name: "link.pluginPayloadAttachment",
uti: "com.apple.messages.pluginPayloadAttachment",
},
],
is_group: false,
},
},
});
await Promise.resolve();
}),
stop: vi.fn(async () => {}),
};
createIMessageRpcClientMock.mockImplementation(async (params) => {
if (!params?.onNotification) {
throw new Error("expected iMessage notification handler");
}
onNotification = params.onNotification;
return client as never;
});
await monitorIMessageProvider({
config: {
channels: { imessage: { includeAttachments: true, dmPolicy: "open" } },
session: { mainKey: "main" },
} as never,
});
expect(shouldDebounceTextInboundMock).toHaveBeenCalledWith(
expect.objectContaining({
text: "https://example.com/article",
hasMedia: false,
}),
);
});
});

View File

@@ -0,0 +1,46 @@
// Imessage tests cover monitor.shutdown.unhandled rejection plugin behavior.
import { describe, expect, it, vi } from "vitest";
import { attachIMessageMonitorAbortHandler } from "./monitor/abort-handler.js";
describe("monitorIMessageProvider", () => {
it("does not trigger unhandledRejection when aborting during shutdown", async () => {
const abortController = new AbortController();
const subscriptionId: number | null = 1;
const requestMock = vi.fn((method: string, _params?: Record<string, unknown>) => {
if (method === "watch.unsubscribe") {
return Promise.reject(new Error("imsg rpc closed"));
}
return Promise.resolve({});
});
const stopMock = vi.fn(async () => {});
const unhandled: unknown[] = [];
const onUnhandled = (reason: unknown) => {
unhandled.push(reason);
};
process.on("unhandledRejection", onUnhandled);
try {
const detach = attachIMessageMonitorAbortHandler({
abortSignal: abortController.signal,
client: {
request: requestMock,
stop: stopMock,
},
getSubscriptionId: () => subscriptionId,
});
abortController.abort();
// Give the event loop a turn to surface any unhandledRejection, if present.
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
detach();
} finally {
process.off("unhandledRejection", onUnhandled);
}
expect(unhandled).toHaveLength(0);
expect(stopMock).toHaveBeenCalled();
expect(requestMock).toHaveBeenCalledWith("watch.unsubscribe", { subscription: 1 });
});
});

View File

@@ -0,0 +1,3 @@
// Imessage plugin module implements monitor behavior.
export { monitorIMessageProvider } from "./monitor/monitor-provider.js";
export type { MonitorIMessageOpts } from "./monitor/types.js";

View File

@@ -0,0 +1,226 @@
// Imessage tests cover monitor.watch subscribe retry plugin behavior.
import type { waitForTransportReady } from "openclaw/plugin-sdk/transport-ready-runtime";
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { createIMessageRpcClient, IMessageRpcClient } from "./client.js";
import { monitorIMessageProvider } from "./monitor.js";
import type { attachIMessageMonitorAbortHandler } from "./monitor/abort-handler.js";
import {
describeIMessageInboundDropDiagnostic,
shouldThrottleIMessageInboundDropDiagnostic,
} from "./monitor/monitor-provider.js";
import { clearIMessageRuntime } from "./runtime.js";
const waitForTransportReadyMock = vi.hoisted(() =>
vi.fn<typeof waitForTransportReady>(async () => {}),
);
const createIMessageRpcClientMock = vi.hoisted(() => vi.fn<typeof createIMessageRpcClient>());
const attachIMessageMonitorAbortHandlerMock = vi.hoisted(() =>
vi.fn<typeof attachIMessageMonitorAbortHandler>(() => () => {}),
);
vi.mock("openclaw/plugin-sdk/transport-ready-runtime", () => ({
waitForTransportReady: waitForTransportReadyMock,
}));
vi.mock("./client.js", () => ({
createIMessageRpcClient: createIMessageRpcClientMock,
}));
vi.mock("./monitor/abort-handler.js", () => ({
attachIMessageMonitorAbortHandler: attachIMessageMonitorAbortHandlerMock,
}));
function createRuntime() {
return {
log: vi.fn(),
error: vi.fn(),
};
}
type MockIMessageRpcClient = IMessageRpcClient & {
request: ReturnType<typeof vi.fn<(method: string) => Promise<unknown>>>;
waitForClose: ReturnType<typeof vi.fn<() => Promise<void>>>;
stop: ReturnType<typeof vi.fn<() => Promise<void>>>;
};
function createRpcClient(overrides?: {
request?: (method: string) => Promise<unknown>;
waitForClose?: () => Promise<void>;
}): MockIMessageRpcClient {
const client = {
request: vi.fn(
overrides?.request ??
(async () => {
return { subscription: 1 };
}),
),
waitForClose: vi.fn(
overrides?.waitForClose ??
(async () => {
return undefined;
}),
),
stop: vi.fn(async () => {}),
};
return client as unknown as MockIMessageRpcClient;
}
describe("monitorIMessageProvider watch.subscribe startup retry", () => {
beforeEach(() => {
vi.useFakeTimers();
// Sibling suites install the imessage runtime singleton without clearing
// it; a leaked runtime resurrects another file's recovery cursor and
// watch.subscribe then gains an unexpected since_rowid.
clearIMessageRuntime();
waitForTransportReadyMock.mockReset().mockResolvedValue(undefined);
createIMessageRpcClientMock.mockReset();
attachIMessageMonitorAbortHandlerMock.mockReset().mockReturnValue(() => {});
});
afterEach(() => {
vi.useRealTimers();
});
afterAll(() => {
vi.doUnmock("openclaw/plugin-sdk/transport-ready-runtime");
vi.doUnmock("./client.js");
vi.doUnmock("./monitor/abort-handler.js");
vi.resetModules();
});
it("retries a transient watch.subscribe startup timeout without tearing down the monitor", async () => {
const runtime = createRuntime();
const firstClient = createRpcClient({
request: async () => {
throw new Error("imsg rpc timeout (watch.subscribe)");
},
});
const secondClient = createRpcClient();
createIMessageRpcClientMock
.mockResolvedValueOnce(firstClient)
.mockResolvedValueOnce(secondClient);
const monitorPromise = monitorIMessageProvider({
config: { channels: { imessage: {} } } as never,
runtime: runtime as never,
});
await vi.runAllTimersAsync();
await monitorPromise;
expect(createIMessageRpcClientMock).toHaveBeenCalledTimes(2);
expect(firstClient.stop).toHaveBeenCalledTimes(1);
expect(secondClient.waitForClose).toHaveBeenCalledTimes(1);
expect(secondClient.stop).toHaveBeenCalledTimes(1);
expect(secondClient.request).toHaveBeenCalledWith(
"watch.subscribe",
{ attachments: false, include_reactions: true },
{ timeoutMs: 10_000 },
);
expect(runtime.log).toHaveBeenCalledTimes(1);
const retryLog = String(runtime.log.mock.calls[0]?.[0]);
expect(retryLog).toContain("imessage: watch.subscribe startup failed attempt=1/3");
expect(retryLog).toContain("account=default");
expect(retryLog).toContain("cliPath=imsg");
expect(retryLog).toContain("dbPath=default");
expect(retryLog).toContain("timeoutMs=10000");
expect(retryLog).toContain("since_rowid=none");
expect(retryLog).toContain("attachments=false");
expect(retryLog).toContain("retry_in_ms=1000");
expect(retryLog).toContain("Error: imsg rpc timeout (watch.subscribe)");
expect(
runtime.error.mock.calls.some(([message]) =>
String(message).includes("imessage: monitor failed"),
),
).toBe(false);
});
it("still fails after bounded startup retries are exhausted", async () => {
const runtime = createRuntime();
createIMessageRpcClientMock.mockImplementation(async () =>
createRpcClient({
request: async () => {
throw new Error("imsg rpc timeout (watch.subscribe)");
},
}),
);
const monitorErrorPromise = monitorIMessageProvider({
config: { channels: { imessage: {} } } as never,
runtime: runtime as never,
}).catch((error: unknown) => error);
await vi.runAllTimersAsync();
const monitorError = await monitorErrorPromise;
expect(monitorError).toBeInstanceOf(Error);
expect((monitorError as Error).message).toContain("imsg rpc timeout (watch.subscribe)");
expect(createIMessageRpcClientMock).toHaveBeenCalledTimes(3);
expect(runtime.error).toHaveBeenCalledTimes(1);
const failureLog = String(runtime.error.mock.calls[0]?.[0]);
expect(failureLog).toContain(
"imessage: monitor failed: imessage: watch.subscribe startup failed attempt=3/3",
);
expect(failureLog).toContain("account=default");
expect(failureLog).toContain("timeoutMs=10000");
expect(failureLog).toContain("Error: imsg rpc timeout (watch.subscribe)");
});
});
describe("describeIMessageInboundDropDiagnostic", () => {
it("describes echo-style drops without message content or sender handles", () => {
const diagnostic = describeIMessageInboundDropDiagnostic({
accountId: "default",
reason: "echo",
message: {
id: 42,
chat_id: 123,
guid: "p:0/secret-guid",
is_group: false,
created_at: "2026-06-09T10:00:00.000Z",
},
});
expect(diagnostic).toBe(
'imessage: dropped inbound message account=default reason="echo" chat_id=123 group=false message_id=42 guid=present created_at=2026-06-09T10:00:00.000Z',
);
expect(diagnostic).not.toContain("secret-guid");
expect(diagnostic).not.toContain("+1555");
});
it("describes from-me drops and marks them for throttling", () => {
const diagnostic = describeIMessageInboundDropDiagnostic({
accountId: "default",
reason: "from me",
message: {
id: 43,
chat_id: 456,
guid: "p:0/outbound-guid",
is_group: true,
created_at: "2026-06-09T10:01:00.000Z",
},
});
expect(diagnostic).toBe(
'imessage: dropped inbound message account=default reason="from me" chat_id=456 group=true message_id=43 guid=present created_at=2026-06-09T10:01:00.000Z',
);
expect(diagnostic).not.toContain("outbound-guid");
expect(shouldThrottleIMessageInboundDropDiagnostic("from me")).toBe(true);
expect(shouldThrottleIMessageInboundDropDiagnostic("echo")).toBe(false);
});
it("keeps normal policy drops quiet", () => {
expect(
describeIMessageInboundDropDiagnostic({
accountId: "default",
reason: "no mention",
message: {
id: 42,
chat_id: 123,
is_group: true,
},
}),
).toBeNull();
});
});

View File

@@ -0,0 +1,35 @@
// Imessage plugin module implements abort handler behavior.
type IMessageMonitorClient = {
request: (method: string, params?: Record<string, unknown>) => Promise<unknown>;
stop: () => Promise<void>;
};
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);
}

View File

@@ -0,0 +1,434 @@
// Imessage tests cover catchup bridge plugin behavior.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { installIMessageStateRuntimeForTest } from "../test-support/runtime.js";
import { runIMessageCatchup } from "./catchup-bridge.js";
import {
resetIMessageCatchupCursorStoreForTest,
resolveCatchupConfig,
saveIMessageCatchupCursor,
} from "./catchup.js";
import type { IMessagePayload } from "./types.js";
type RpcCall = {
method: string;
params: unknown;
};
function makeFakeClient(responder: (call: RpcCall) => unknown): {
client: {
request: <T>(method: string, params: unknown) => Promise<T>;
};
calls: RpcCall[];
} {
const calls: RpcCall[] = [];
const client = {
request: async <T>(method: string, params: unknown): Promise<T> => {
calls.push({ method, params });
return responder({ method, params }) as T;
},
};
return { client, calls };
}
function makeRow(opts: {
id: number;
guid: string;
chat_id: number;
created_at: string;
is_from_me?: boolean;
text?: string;
sender?: string;
}): Record<string, unknown> {
return {
id: opts.id,
guid: opts.guid,
chat_id: opts.chat_id,
sender: opts.sender ?? "+15551234",
is_from_me: opts.is_from_me ?? false,
text: opts.text ?? "hello",
created_at: opts.created_at,
chat_identifier: "+15551234",
chat_guid: `iMessage;-;${opts.sender ?? "+15551234"}`,
is_group: false,
};
}
describe("runIMessageCatchup", () => {
beforeEach(() => {
installIMessageStateRuntimeForTest();
resetIMessageCatchupCursorStoreForTest();
});
afterEach(() => {
resetIMessageCatchupCursorStoreForTest();
vi.useRealTimers();
});
it("fetches chats then per-chat history and dispatches each row in rowid order", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-05-08T12:00:00Z"));
const dispatched: IMessagePayload[] = [];
const { client, calls } = makeFakeClient(({ method, params }) => {
if (method === "chats.list") {
return {
chats: [
{ id: 1, last_message_at: "2026-05-08T11:55:00.000Z" },
{ id: 2, last_message_at: "2026-05-08T11:50:00.000Z" },
],
};
}
if (method === "messages.history") {
const p = params as { chat_id: number };
if (p.chat_id === 1) {
return {
messages: [
makeRow({ id: 102, guid: "g-102", chat_id: 1, created_at: "2026-05-08T11:55:00Z" }),
makeRow({ id: 100, guid: "g-100", chat_id: 1, created_at: "2026-05-08T11:50:00Z" }),
],
};
}
return {
messages: [
makeRow({ id: 101, guid: "g-101", chat_id: 2, created_at: "2026-05-08T11:51:00Z" }),
],
};
}
throw new Error(`unexpected method ${method}`);
});
const summary = await runIMessageCatchup({
client: client as never,
accountId: "default",
config: resolveCatchupConfig({ enabled: true, perRunLimit: 50, maxAgeMinutes: 60 }),
includeAttachments: false,
dispatchPayload: async (msg) => {
dispatched.push(msg);
},
});
expect(summary.querySucceeded).toBe(true);
expect(summary.fullyCaughtUp).toBe(true);
expect(summary.replayed).toBe(3);
expect(dispatched.map((m) => m.guid)).toEqual(["g-100", "g-101", "g-102"]);
expect(calls[0]?.method).toBe("chats.list");
expect(calls.filter((c) => c.method === "messages.history")).toHaveLength(2);
});
it("skips chats whose last_message_at is older than the catchup window", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-05-08T12:00:00Z"));
let historyCalls = 0;
const { client } = makeFakeClient(({ method, params }) => {
if (method === "chats.list") {
return {
chats: [
{ id: 1, last_message_at: "2026-05-08T11:55:00.000Z" },
{ id: 99, last_message_at: "2025-12-01T00:00:00.000Z" }, // ancient
],
};
}
if (method === "messages.history") {
historyCalls += 1;
const p = params as { chat_id: number };
return {
messages: [
makeRow({
id: 200,
guid: `g-${p.chat_id}`,
chat_id: p.chat_id,
created_at: "2026-05-08T11:55:00Z",
}),
],
};
}
throw new Error(`unexpected method ${method}`);
});
const summary = await runIMessageCatchup({
client: client as never,
accountId: "default",
config: resolveCatchupConfig({ enabled: true, perRunLimit: 50, maxAgeMinutes: 60 }),
includeAttachments: false,
dispatchPayload: async () => {},
});
expect(summary.querySucceeded).toBe(true);
expect(historyCalls).toBe(1);
expect(summary.replayed).toBe(1);
});
it("does not crash on Date-invalid persisted cursor timestamps", async () => {
const log = vi.fn();
await saveIMessageCatchupCursor("default", {
lastSeenMs: 8_700_000_000_000_000,
lastSeenRowid: 10,
});
const { client, calls } = makeFakeClient(() => {
throw new Error("unexpected rpc");
});
const summary = await runIMessageCatchup({
client: client as never,
accountId: "default",
config: resolveCatchupConfig({ enabled: true, perRunLimit: 50, maxAgeMinutes: 60 }),
includeAttachments: false,
dispatchPayload: async () => {},
runtime: { log },
});
expect(summary.querySucceeded).toBe(false);
expect(calls).toEqual([]);
expect(log).toHaveBeenCalledWith(
expect.stringContaining("imessage catchup: invalid since timestamp"),
);
});
it("returns querySucceeded=false when chats.list throws", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-05-08T12:00:00Z"));
const { client } = makeFakeClient(({ method }) => {
if (method === "chats.list") {
throw new Error("rpc timeout");
}
throw new Error(`unexpected method ${method}`);
});
const summary = await runIMessageCatchup({
client: client as never,
accountId: "default",
config: resolveCatchupConfig({ enabled: true, perRunLimit: 50, maxAgeMinutes: 60 }),
includeAttachments: false,
dispatchPayload: async () => {
throw new Error("dispatch should not be called when fetch fails");
},
});
expect(summary.querySucceeded).toBe(false);
expect(summary.fullyCaughtUp).toBe(false);
expect(summary.replayed).toBe(0);
});
it("continues across chats when a single messages.history call throws", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-05-08T12:00:00Z"));
const dispatched: string[] = [];
const { client } = makeFakeClient(({ method, params }) => {
if (method === "chats.list") {
return {
chats: [
{ id: 1, last_message_at: "2026-05-08T11:55:00.000Z" },
{ id: 2, last_message_at: "2026-05-08T11:50:00.000Z" },
],
};
}
if (method === "messages.history") {
const p = params as { chat_id: number };
if (p.chat_id === 1) {
throw new Error("permission denied");
}
return {
messages: [
makeRow({ id: 300, guid: "g-300", chat_id: 2, created_at: "2026-05-08T11:51:00Z" }),
],
};
}
throw new Error(`unexpected method ${method}`);
});
const summary = await runIMessageCatchup({
client: client as never,
accountId: "default",
config: resolveCatchupConfig({ enabled: true, perRunLimit: 50, maxAgeMinutes: 60 }),
includeAttachments: false,
dispatchPayload: async (msg) => {
if (msg.guid) {
dispatched.push(msg.guid);
}
},
});
expect(summary.querySucceeded).toBe(true);
expect(summary.fullyCaughtUp).toBe(false);
expect(summary.replayed).toBe(1);
expect(dispatched).toEqual(["g-300"]);
});
it("caps cross-chat results at perRunLimit, oldest first", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-05-08T12:00:00Z"));
const dispatched: string[] = [];
const { client } = makeFakeClient(({ method, params }) => {
if (method === "chats.list") {
return {
chats: [
{ id: 1, last_message_at: "2026-05-08T11:55:00.000Z" },
{ id: 2, last_message_at: "2026-05-08T11:55:00.000Z" },
],
};
}
if (method === "messages.history") {
const p = params as { chat_id: number };
const base = p.chat_id * 100;
return {
messages: Array.from({ length: 4 }, (_, i) =>
makeRow({
id: base + i,
guid: `g-${base + i}`,
chat_id: p.chat_id,
created_at: "2026-05-08T11:55:00Z",
}),
),
};
}
throw new Error(`unexpected method ${method}`);
});
const summary = await runIMessageCatchup({
client: client as never,
accountId: "default",
config: resolveCatchupConfig({ enabled: true, perRunLimit: 5, maxAgeMinutes: 60 }),
includeAttachments: false,
dispatchPayload: async (msg) => {
if (msg.guid) {
dispatched.push(msg.guid);
}
},
});
expect(summary.fetchedCount).toBe(5);
expect(summary.fullyCaughtUp).toBe(false);
expect(summary.replayed).toBe(5);
// Oldest-first by rowid: 100, 101, 102, 103, 200 (chat 1's first 4, then chat 2's first).
expect(dispatched).toEqual(["g-100", "g-101", "g-102", "g-103", "g-200"]);
// Regression for clawsweeper #79387 finding: the cursor must NOT
// advance past the last dispatched row when perRunLimit truncates
// the cross-chat page. Without the cap-aware watermark clamp, the
// bridge would emit a watermark covering the raw rows it dropped
// (rowids 201, 202, 203 from chat 2), and the catchup loop would
// persist `lastSeenRowid` past them — so the promised "next startup
// picks up the rest" warning would lie and those rows would be
// permanently lost. Cursor must stop at the last dispatched rowid (200).
expect(summary.cursorAfter.lastSeenRowid).toBe(200);
});
it("treats a dispatch throw as a failure and holds the cursor", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-05-08T12:00:00Z"));
const { client } = makeFakeClient(({ method }) => {
if (method === "chats.list") {
return { chats: [{ id: 1, last_message_at: "2026-05-08T11:55:00.000Z" }] };
}
return {
messages: [
makeRow({ id: 500, guid: "g-500", chat_id: 1, created_at: "2026-05-08T11:55:00Z" }),
],
};
});
const summary = await runIMessageCatchup({
client: client as never,
accountId: "default",
config: resolveCatchupConfig({
enabled: true,
perRunLimit: 50,
maxAgeMinutes: 60,
maxFailureRetries: 3,
}),
includeAttachments: false,
dispatchPayload: async () => {
throw new Error("model unavailable");
},
});
expect(summary.failed).toBe(1);
expect(summary.replayed).toBe(0);
// Cursor clamps to `failed.rowid - 1` (== 499), strictly below the held
// failure, so the next pass refetches row 500 — and never leapfrogs it.
expect(summary.cursorAfter.lastSeenRowid).toBe(499);
});
it("emits a high-watermark even when every row fails payload validation", async () => {
// Regression: without this, a chat whose only fresh row is unparseable
// (corrupt text column, schema drift) would stall catchup forever — the
// row never reaches the cursor loop, the cursor never advances past it,
// the next pass re-fetches and re-drops the same row. The bridge probes
// raw `id` / `created_at` per row before parsing and emits the highest
// values it saw as a watermark so the cursor loop can still advance.
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-05-08T12:00:00Z"));
const { client } = makeFakeClient(({ method }) => {
if (method === "chats.list") {
return { chats: [{ id: 1, last_message_at: "2026-05-08T11:55:00.000Z" }] };
}
return {
messages: [
// Junk row — wrong types in everything except id + created_at, so
// parseIMessageNotification rejects it but the watermark probe
// still records id=999 and the parsed created_at.
{
id: 999,
guid: 42, // wrong type
chat_id: "x", // wrong type
sender: false, // wrong type
is_from_me: "no", // wrong type
text: 7, // wrong type
created_at: "2026-05-08T11:55:00.000Z",
},
],
};
});
const summary = await runIMessageCatchup({
client: client as never,
accountId: "default",
config: resolveCatchupConfig({ enabled: true, perRunLimit: 50, maxAgeMinutes: 60 }),
includeAttachments: false,
dispatchPayload: async () => {},
});
expect(summary.querySucceeded).toBe(true);
expect(summary.replayed).toBe(0);
expect(summary.fetchedCount).toBe(0);
// Cursor advances to the watermark — next pass won't keep re-fetching this row.
expect(summary.cursorAfter.lastSeenRowid).toBe(999);
});
it("filters rows that fail payload validation", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-05-08T12:00:00Z"));
const dispatched: string[] = [];
const { client } = makeFakeClient(({ method }) => {
if (method === "chats.list") {
return { chats: [{ id: 1, last_message_at: "2026-05-08T11:55:00.000Z" }] };
}
return {
messages: [
// Valid row.
makeRow({ id: 600, guid: "g-600", chat_id: 1, created_at: "2026-05-08T11:55:00Z" }),
// Junk row — wrong types — must be dropped silently.
{ id: "not-a-number", guid: 42, chat_id: "x" },
// Missing guid.
{
...makeRow({ id: 601, guid: "", chat_id: 1, created_at: "2026-05-08T11:55:00Z" }),
guid: undefined,
},
],
};
});
const summary = await runIMessageCatchup({
client: client as never,
accountId: "default",
config: resolveCatchupConfig({ enabled: true, perRunLimit: 50, maxAgeMinutes: 60 }),
includeAttachments: false,
dispatchPayload: async (msg) => {
if (msg.guid) {
dispatched.push(msg.guid);
}
},
});
expect(summary.replayed).toBe(1);
expect(dispatched).toEqual(["g-600"]);
});
});

View File

@@ -0,0 +1,298 @@
// Imessage plugin module implements catchup bridge behavior.
import { timestampMsToIsoString } from "openclaw/plugin-sdk/number-runtime";
import { warn } from "openclaw/plugin-sdk/runtime-env";
import type { IMessageRpcClient } from "../client.js";
import {
type CatchupDispatchFn,
type CatchupFetchFn,
type IMessageCatchupRow,
type IMessageCatchupSummary,
performIMessageCatchup,
type ResolvedCatchupConfig,
} from "./catchup.js";
import { parseIMessageNotification } from "./parse-notification.js";
import type { IMessagePayload } from "./types.js";
// Per-chat history fetch budget. messages.history is per-chat; we cap each
// chat's fetch to the global perRunLimit so a single noisy group cannot
// dominate the cursor advance — the cross-chat sort + final slice still
// caps the global pass at perRunLimit.
const PER_CHAT_HISTORY_LIMIT_CAP = 500;
// chats.list page size used during catchup. 200 covers far more than any
// realistic offline window worth of distinct chats while staying well under
// any sensible chat.db query cost.
const CATCHUP_CHATS_LIST_LIMIT = 200;
// Per-RPC timeout. Catchup runs once at startup; a slow imsg should not
// stall the live dispatch loop indefinitely.
const CATCHUP_RPC_TIMEOUT_MS = 30_000;
type ChatsListEntry = {
id?: number | null;
last_message_at?: string | null;
};
type MessagesHistoryResult = {
messages?: unknown[];
};
type RuntimeLogger = {
log?: (msg: string) => void;
error?: (msg: string) => void;
};
export type RunIMessageCatchupParams = {
client: IMessageRpcClient;
accountId: string;
config: ResolvedCatchupConfig;
includeAttachments: boolean;
/**
* The same per-message handler the live `imsg watch` notification path
* runs (i.e. the post-debounce `handleMessageNow` in `monitor-provider`).
* Catchup feeds rows in oldest-first by rowid. Throws are recorded as
* dispatch failures; non-throw returns count as successful dispatch
* (including non-error drops, which mirrors the live pipeline).
*/
dispatchPayload: (message: IMessagePayload) => Promise<void>;
/**
* Called for `is_from_me=true` rows that catchup intentionally does not
* dispatch. The live inbound path still needs to observe those rows so
* self-chat reflected companion rows can be deduped.
*/
observeSkippedFromMePayload?: (message: IMessagePayload) => Promise<void> | void;
runtime?: RuntimeLogger;
/** Override clock for tests. */
now?: () => number;
};
/**
* Wire `performIMessageCatchup` against the live `imsg` JSON-RPC client.
*
* Catchup recovers messages that landed in `chat.db` while the gateway was
* offline (crash, restart, mac sleep) by:
* 1. listing recently-active chats via `chats.list`,
* 2. fetching per-chat history since the cursor via `messages.history`,
* 3. sorting cross-chat by `rowid`, capping at `perRunLimit`,
* 4. replaying each row through the same `dispatchPayload` handler used
* by the live notification loop, so existing dedupe / coalesce / echo
* / read-receipt behavior covers replayed rows for free.
*
* Runs at most once per `monitorIMessageProvider` invocation, between
* `watch.subscribe` and the live dispatch loop. Anything that arrives during
* catchup itself flows through live dispatch; the existing inbound-dedupe
* cache absorbs any overlap.
*/
export async function runIMessageCatchup(
params: RunIMessageCatchupParams,
): Promise<IMessageCatchupSummary> {
const { client, accountId, config, includeAttachments, dispatchPayload, runtime } = params;
const log = (msg: string) => runtime?.log?.(msg);
const warnLog = (msg: string) => runtime?.log?.(warn(msg));
// Map keyed by guid so the dispatch adapter can recover the full payload
// the fetcher pulled from `messages.history`. Local to this catchup pass —
// discarded when the function returns.
const payloadByGuid = new Map<string, IMessagePayload>();
const fetchFn: CatchupFetchFn = async ({ sinceMs, sinceRowid, limit }) => {
const sinceISO = timestampMsToIsoString(sinceMs);
if (!sinceISO) {
warnLog(`imessage catchup: invalid since timestamp ${sinceMs}`);
return { resolved: false, rows: [] };
}
let chatsResult: { chats?: ChatsListEntry[] } | undefined;
try {
chatsResult = await client.request<{ chats?: ChatsListEntry[] }>(
"chats.list",
{ limit: CATCHUP_CHATS_LIST_LIMIT },
{ timeoutMs: CATCHUP_RPC_TIMEOUT_MS },
);
} catch (err) {
warnLog(`imessage catchup: chats.list failed: ${String(err)}`);
return { resolved: false, rows: [] };
}
const chats = chatsResult?.chats ?? [];
const collected: IMessageCatchupRow[] = [];
const perChatLimit = Math.min(limit, PER_CHAT_HISTORY_LIMIT_CAP);
let historyFetchFailed = false;
// Track the highest rowid / date the imsg bridge actually returned across
// all chats, regardless of whether each row passed the parser. The catchup
// loop uses this as a cursor-advance floor so an unparseable row (corrupt
// text column, schema drift, etc.) cannot stall catchup forever — without
// this, the same broken row would be re-fetched and re-dropped on every
// gateway startup.
let rawWatermarkRowid = -Infinity;
let rawWatermarkMs = -Infinity;
for (const chat of chats) {
const chatId = typeof chat.id === "number" && Number.isFinite(chat.id) ? chat.id : null;
if (chatId === null) {
continue;
}
// Skip chats that have not seen activity in the catchup window. Saves
// a per-chat RPC for every old archived conversation.
const lastMs =
typeof chat.last_message_at === "string" ? Date.parse(chat.last_message_at) : Number.NaN;
if (Number.isFinite(lastMs) && lastMs < sinceMs) {
continue;
}
let historyResult: MessagesHistoryResult | undefined;
try {
historyResult = await client.request<MessagesHistoryResult>(
"messages.history",
{
chat_id: chatId,
limit: perChatLimit,
start: sinceISO,
attachments: includeAttachments,
},
{ timeoutMs: CATCHUP_RPC_TIMEOUT_MS },
);
} catch (err) {
// Best-effort per chat. A single broken chat must not poison the
// whole pass — drop and continue.
historyFetchFailed = true;
warnLog(`imessage catchup: messages.history failed for chat_id=${chatId}: ${String(err)}`);
continue;
}
const messages = Array.isArray(historyResult?.messages) ? historyResult.messages : [];
for (const raw of messages) {
// Best-effort raw-watermark probe BEFORE we run the parser, so even
// rows we drop still let the cursor advance past them. We only trust
// numeric `id` / parseable `created_at` — if the row is so malformed
// that we cannot even read those, leave the watermark unchanged for
// this row (same forward-progress behavior as today, just no worse).
const rawRecord = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : null;
const rawRowid =
rawRecord && typeof rawRecord.id === "number" && Number.isFinite(rawRecord.id)
? rawRecord.id
: null;
const rawCreatedAt =
rawRecord && typeof rawRecord.created_at === "string" ? rawRecord.created_at : null;
const rawDateMs = rawCreatedAt ? Date.parse(rawCreatedAt) : Number.NaN;
if (rawRowid !== null) {
rawWatermarkRowid = Math.max(rawWatermarkRowid, rawRowid);
}
if (Number.isFinite(rawDateMs)) {
rawWatermarkMs = Math.max(rawWatermarkMs, rawDateMs);
}
// Reuse the live notification parser by wrapping the row in the same
// `{ message: ... }` envelope. Anything that fails the parser would
// also be dropped on the live path, so the same shape guard applies.
const payload = parseIMessageNotification({ message: raw });
if (!payload) {
continue;
}
const guid = payload.guid?.trim();
const rowid = typeof payload.id === "number" ? payload.id : null;
const dateMs =
typeof payload.created_at === "string" ? Date.parse(payload.created_at) : Number.NaN;
if (!guid || rowid === null || !Number.isFinite(rowid) || !Number.isFinite(dateMs)) {
continue;
}
if (rowid <= sinceRowid) {
continue;
}
collected.push({
guid,
rowid,
date: dateMs,
isFromMe: payload.is_from_me === true,
});
payloadByGuid.set(guid, payload);
}
}
const sorted = collected.toSorted((a, b) => a.rowid - b.rowid);
const capped = sorted.slice(0, limit);
const isCapTruncated = capped.length < sorted.length;
if (isCapTruncated) {
warnLog(
`imessage catchup: fetched ${sorted.length} rows across chats, ` +
`capped to perRunLimit=${limit} (oldest first); next startup picks up the rest`,
);
// Drop payloads we are no longer going to dispatch so the dispatch
// adapter does not have to defend against the discarded ones.
const keep = new Set(capped.map((row) => row.guid));
for (const guid of payloadByGuid.keys()) {
if (!keep.has(guid)) {
payloadByGuid.delete(guid);
}
}
}
// Clamp the raw watermark when cap-truncation hits so the catchup loop
// cannot persist a cursor past undispatched valid rows. Without this,
// a `messages.history` page wider than `perRunLimit` would silently
// skip the cap-truncated tail forever — the WARN above promises the
// next startup picks up the rest, and that promise relies on the
// cursor staying at the last dispatched rowid. When no truncation
// happens, the watermark covers parse-rejected rows interspersed
// with the dispatched batch (the original forward-progress fix).
let effectiveWatermarkRowid = rawWatermarkRowid;
let effectiveWatermarkMs = rawWatermarkMs;
if (isCapTruncated && capped.length > 0) {
const last = capped.at(-1);
if (last) {
effectiveWatermarkRowid = Math.min(effectiveWatermarkRowid, last.rowid);
effectiveWatermarkMs = Math.min(effectiveWatermarkMs, last.date);
}
} else if (isCapTruncated && capped.length === 0) {
// Pathological: cap=0. Don't emit any watermark; preserve the prior
// cursor and let the next pass try again.
effectiveWatermarkRowid = Number.NaN;
effectiveWatermarkMs = Number.NaN;
}
return {
resolved: true,
rows: capped,
fullyCaughtUp: !historyFetchFailed && !isCapTruncated,
...(Number.isFinite(effectiveWatermarkRowid)
? { highWatermarkRowid: effectiveWatermarkRowid }
: {}),
...(Number.isFinite(effectiveWatermarkMs) ? { highWatermarkMs: effectiveWatermarkMs } : {}),
};
};
const dispatchFn: CatchupDispatchFn = async (row) => {
const payload = payloadByGuid.get(row.guid);
if (!payload) {
// Should not happen: the fetcher only emits rows it has stashed. But
// if a future caller wires a different fetcher and forgets to populate
// the map, we would otherwise silently no-op. Treat as a transient
// failure so the cursor stays put and operators see the warning.
warnLog(`imessage catchup: missing payload for guid=${row.guid}, skipping`);
return { ok: false };
}
try {
await dispatchPayload(payload);
return { ok: true };
} catch (err) {
warnLog(`imessage catchup: dispatch threw for guid=${row.guid}: ${String(err)}`);
return { ok: false };
}
};
return await performIMessageCatchup({
accountId,
config,
fetch: fetchFn,
dispatch: dispatchFn,
observeSkippedFromMe: async (row) => {
const payload = payloadByGuid.get(row.guid);
if (!payload) {
warnLog(`imessage catchup: missing skipped from-me payload for guid=${row.guid}`);
return;
}
await params.observeSkippedFromMePayload?.(payload);
},
log,
warn: warnLog,
...(params.now ? { now: params.now() } : {}),
});
}

View File

@@ -0,0 +1,565 @@
// Imessage tests cover catchup plugin behavior.
import { beforeEach, describe, expect, it, vi } from "vitest";
import { installIMessageStateRuntimeForTest } from "../test-support/runtime.js";
import {
advanceIMessageCatchupCursor,
capFailureRetriesMap,
loadIMessageCatchupCursor,
performIMessageCatchup,
resetIMessageCatchupCursorStoreForTest,
resolveCatchupConfig,
saveIMessageCatchupCursor,
type CatchupDispatchFn,
type CatchupFetchFn,
type IMessageCatchupRow,
} from "./catchup.js";
beforeEach(() => {
installIMessageStateRuntimeForTest();
resetIMessageCatchupCursorStoreForTest();
});
describe("resolveCatchupConfig", () => {
it("falls back to defaults when raw is undefined", () => {
const cfg = resolveCatchupConfig(undefined);
expect(cfg.enabled).toBe(false);
expect(cfg.maxAgeMinutes).toBe(120);
expect(cfg.perRunLimit).toBe(50);
expect(cfg.firstRunLookbackMinutes).toBe(30);
expect(cfg.maxFailureRetries).toBe(10);
});
it("clamps over-limit input to the documented ceiling", () => {
const cfg = resolveCatchupConfig({
enabled: true,
maxAgeMinutes: 99_999,
perRunLimit: 10_000,
maxFailureRetries: 50_000,
});
expect(cfg.enabled).toBe(true);
expect(cfg.maxAgeMinutes).toBe(720);
expect(cfg.perRunLimit).toBe(500);
expect(cfg.maxFailureRetries).toBe(1000);
});
it("clamps zero / negative input to 1", () => {
const cfg = resolveCatchupConfig({
maxAgeMinutes: 0,
perRunLimit: -10,
firstRunLookbackMinutes: -1,
maxFailureRetries: 0,
});
expect(cfg.maxAgeMinutes).toBe(1);
expect(cfg.perRunLimit).toBe(1);
expect(cfg.firstRunLookbackMinutes).toBe(1);
expect(cfg.maxFailureRetries).toBe(1);
});
});
describe("loadIMessageCatchupCursor / saveIMessageCatchupCursor", () => {
it("returns null when no cursor exists", async () => {
const cursor = await loadIMessageCatchupCursor("primary");
expect(cursor).toBeNull();
});
it("round-trips a cursor without failureRetries", async () => {
await saveIMessageCatchupCursor("primary", {
lastSeenMs: 1_700_000_000_000,
lastSeenRowid: 42,
});
const cursor = await loadIMessageCatchupCursor("primary");
if (!cursor) {
throw new Error("expected iMessage catchup cursor");
}
expect(cursor.lastSeenMs).toBe(1_700_000_000_000);
expect(cursor.lastSeenRowid).toBe(42);
expect(cursor.failureRetries).toBeUndefined();
});
it("round-trips a cursor with failureRetries", async () => {
await saveIMessageCatchupCursor("primary", {
lastSeenMs: 1_700_000_000_000,
lastSeenRowid: 42,
failureRetries: { "GUID-A": 3 },
});
const cursor = await loadIMessageCatchupCursor("primary");
expect(cursor?.failureRetries).toEqual({ "GUID-A": 3 });
});
it("drops malformed failureRetries entries on load", async () => {
await saveIMessageCatchupCursor("primary", {
lastSeenMs: 1_700_000_000_000,
lastSeenRowid: 42,
failureRetries: {
"GUID-A": 3,
"GUID-B": -1,
"GUID-C": Number.NaN,
} as Record<string, number>,
});
const cursor = await loadIMessageCatchupCursor("primary");
expect(cursor?.failureRetries).toEqual({ "GUID-A": 3 });
});
it("isolates state per accountId", async () => {
await saveIMessageCatchupCursor("a", { lastSeenMs: 100, lastSeenRowid: 1 });
await saveIMessageCatchupCursor("b", { lastSeenMs: 200, lastSeenRowid: 2 });
expect((await loadIMessageCatchupCursor("a"))?.lastSeenRowid).toBe(1);
expect((await loadIMessageCatchupCursor("b"))?.lastSeenRowid).toBe(2);
});
it("advances monotonically from a live-handled row and preserves given-up retry state", async () => {
const config = resolveCatchupConfig({ enabled: true, maxFailureRetries: 3 });
await saveIMessageCatchupCursor("primary", {
lastSeenMs: 1_700_000_000_000,
lastSeenRowid: 42,
failureRetries: { "GIVEN-UP": 3 },
});
await expect(
advanceIMessageCatchupCursor(
"primary",
{ lastSeenMs: 1_700_000_001_000, lastSeenRowid: 41 },
config,
),
).resolves.toBe(false);
await expect(
advanceIMessageCatchupCursor(
"primary",
{ lastSeenMs: 1_700_000_002_000, lastSeenRowid: 50 },
config,
),
).resolves.toBe(true);
const cursor = await loadIMessageCatchupCursor("primary");
expect(cursor?.lastSeenRowid).toBe(50);
expect(cursor?.lastSeenMs).toBe(1_700_000_002_000);
expect(cursor?.failureRetries).toEqual({ "GIVEN-UP": 3 });
});
it("serializes concurrent live cursor advances so a lower row cannot overwrite a higher row", async () => {
const config = resolveCatchupConfig({ enabled: true, maxFailureRetries: 3 });
await saveIMessageCatchupCursor("primary", {
lastSeenMs: 1_700_000_000_000,
lastSeenRowid: 10,
});
const results = await Promise.all([
advanceIMessageCatchupCursor(
"primary",
{ lastSeenMs: 1_700_000_050_000, lastSeenRowid: 50 },
config,
),
...Array.from({ length: 24 }, (_, index) =>
advanceIMessageCatchupCursor(
"primary",
{ lastSeenMs: 1_700_000_011_000 + index, lastSeenRowid: 11 + index },
config,
),
),
]);
expect(results[0]).toBe(true);
expect(results.slice(1).every((advanced) => !advanced)).toBe(true);
const cursor = await loadIMessageCatchupCursor("primary");
expect(cursor?.lastSeenRowid).toBe(50);
expect(cursor?.lastSeenMs).toBe(1_700_000_050_000);
});
it("does not advance from live rows while a catchup failure is still retrying", async () => {
const config = resolveCatchupConfig({ enabled: true, maxFailureRetries: 3 });
await saveIMessageCatchupCursor("primary", {
lastSeenMs: 1_700_000_000_000,
lastSeenRowid: 42,
failureRetries: { RETRYING: 1 },
});
await expect(
advanceIMessageCatchupCursor(
"primary",
{ lastSeenMs: 1_700_000_002_000, lastSeenRowid: 50 },
config,
),
).resolves.toBe(false);
const cursor = await loadIMessageCatchupCursor("primary");
expect(cursor?.lastSeenRowid).toBe(42);
expect(cursor?.failureRetries).toEqual({ RETRYING: 1 });
});
});
describe("capFailureRetriesMap", () => {
it("is identity below the cap", () => {
const map = { a: 1, b: 2 };
expect(capFailureRetriesMap(map, 10)).toEqual({ a: 1, b: 2 });
});
it("keeps the highest counts when over the cap", () => {
const map = { a: 1, b: 9, c: 5, d: 9 };
const capped = capFailureRetriesMap(map, 2);
// Both b and d at 9; tiebreak by guid string (alphabetical) → b, d
expect(Object.keys(capped).toSorted()).toEqual(["b", "d"]);
});
it("keeps the persisted retry map under the plugin-state value budget", () => {
const map = Object.fromEntries(
Array.from({ length: 800 }, (_, index) => [`GUID-${index}-${"x".repeat(120)}`, index + 1]),
);
const capped = capFailureRetriesMap(map);
expect(Object.keys(capped).length).toBeLessThanOrEqual(512);
expect(new TextEncoder().encode(JSON.stringify(capped)).byteLength).toBeLessThanOrEqual(48_000);
});
});
describe("performIMessageCatchup", () => {
const config = resolveCatchupConfig({ enabled: true });
const now = 1_700_001_000_000; // arbitrary fixed clock
function row(overrides: Partial<IMessageCatchupRow>): IMessageCatchupRow {
return {
guid: "GUID-X",
rowid: 1,
date: now - 60_000,
isFromMe: false,
...overrides,
};
}
function fetchOf(rows: IMessageCatchupRow[]): CatchupFetchFn {
return vi.fn(async () => ({ resolved: true, rows }));
}
function alwaysOk(): CatchupDispatchFn {
return vi.fn(async () => ({ ok: true }));
}
it("replays every fresh inbound row through dispatch and advances the cursor", async () => {
const dispatch = alwaysOk();
const fetch = fetchOf([
row({ guid: "A", rowid: 10, date: now - 30_000 }),
row({ guid: "B", rowid: 11, date: now - 20_000 }),
]);
const summary = await performIMessageCatchup({
accountId: "primary",
config,
now,
fetch,
dispatch,
});
expect(summary.querySucceeded).toBe(true);
expect(summary.replayed).toBe(2);
expect(summary.failed).toBe(0);
expect(summary.cursorAfter.lastSeenRowid).toBe(11);
expect(dispatch).toHaveBeenCalledTimes(2);
const cursor = await loadIMessageCatchupCursor("primary");
expect(cursor?.lastSeenRowid).toBe(11);
});
it("skips is_from_me rows but still advances the cursor past them", async () => {
const dispatch = alwaysOk();
const observeSkippedFromMe = vi.fn();
const fetch = fetchOf([
row({ guid: "A", rowid: 10, isFromMe: true }),
row({ guid: "B", rowid: 11, isFromMe: false }),
]);
const summary = await performIMessageCatchup({
accountId: "primary",
config,
now,
fetch,
dispatch,
observeSkippedFromMe,
});
expect(summary.skippedFromMe).toBe(1);
expect(summary.replayed).toBe(1);
expect(summary.cursorAfter.lastSeenRowid).toBe(11);
expect(dispatch).toHaveBeenCalledTimes(1);
expect(observeSkippedFromMe).toHaveBeenCalledWith(
expect.objectContaining({ guid: "A", rowid: 10, isFromMe: true }),
);
});
it("drops rows older than the maxAgeMinutes ceiling and advances past them", async () => {
const tightConfig = resolveCatchupConfig({ enabled: true, maxAgeMinutes: 1 });
const dispatch = alwaysOk();
const fetch = fetchOf([
row({ guid: "OLD", rowid: 10, date: now - 10 * 60_000 }), // 10 min old, > 1 min ceiling
row({ guid: "NEW", rowid: 11, date: now - 30_000 }),
]);
const summary = await performIMessageCatchup({
accountId: "primary",
config: tightConfig,
now,
fetch,
dispatch,
});
expect(summary.skippedPreCursor).toBe(1);
expect(summary.replayed).toBe(1);
expect(summary.cursorAfter.lastSeenRowid).toBe(11);
});
it("holds the cursor on the failing row while count < maxFailureRetries", async () => {
const dispatch = vi.fn<CatchupDispatchFn>(async () => ({ ok: false }));
const fetch = fetchOf([row({ guid: "A", rowid: 10 })]);
const summary = await performIMessageCatchup({
accountId: "primary",
config,
now,
fetch,
dispatch,
});
expect(summary.failed).toBe(1);
expect(summary.givenUp).toBe(0);
// Cursor clamps to `failed.rowid - 1` (== 9), strictly below the held
// failure, so the next pass refetches row 10 — and never leapfrogs it.
expect(summary.cursorAfter.lastSeenRowid).toBe(9);
const cursor = await loadIMessageCatchupCursor("primary");
expect(cursor?.failureRetries?.A).toBe(1);
expect(cursor?.lastSeenRowid).toBe(9);
});
it("crosses the maxFailureRetries ceiling, gives up, and advances past the wedged row", async () => {
const tightConfig = resolveCatchupConfig({ enabled: true, maxFailureRetries: 2 });
const dispatch = vi.fn<CatchupDispatchFn>(async () => ({ ok: false }));
const fetch = fetchOf([row({ guid: "A", rowid: 10 })]);
// First pass: count goes 0 → 1, cursor held below the failed row.
// The clamp is `failed.rowid - 1` (== 9), not the prior cursor (0), so
// the next pass refetches row 10 without re-walking older history.
await performIMessageCatchup({
accountId: "primary",
config: tightConfig,
now,
fetch,
dispatch,
});
expect((await loadIMessageCatchupCursor("primary"))?.lastSeenRowid).toBe(9);
// Second pass: count goes 1 → 2 (== ceiling), give up, cursor advances.
const fetch2 = fetchOf([row({ guid: "A", rowid: 10 })]);
const summary = await performIMessageCatchup({
accountId: "primary",
config: tightConfig,
now,
fetch: fetch2,
dispatch,
});
expect(summary.givenUp).toBe(1);
expect(summary.cursorAfter.lastSeenRowid).toBe(10);
const cursor = await loadIMessageCatchupCursor("primary");
expect(cursor?.failureRetries?.A).toBe(2);
});
it("treats already-given-up rows as skippedGivenUp without dispatching", async () => {
await saveIMessageCatchupCursor("primary", {
lastSeenMs: now - 60_000,
lastSeenRowid: 0,
failureRetries: { "WEDGED-1": 99 },
});
const dispatch = alwaysOk();
const fetch = fetchOf([row({ guid: "WEDGED-1", rowid: 5 }), row({ guid: "FRESH", rowid: 6 })]);
const summary = await performIMessageCatchup({
accountId: "primary",
config,
now,
fetch,
dispatch,
});
expect(summary.skippedGivenUp).toBe(1);
expect(summary.replayed).toBe(1);
expect(dispatch).toHaveBeenCalledTimes(1);
});
it("removes a guid from the retry map after a successful dispatch", async () => {
await saveIMessageCatchupCursor("primary", {
lastSeenMs: now - 60_000,
lastSeenRowid: 0,
failureRetries: { RETRYING: 1 },
});
const dispatch = alwaysOk();
const fetch = fetchOf([row({ guid: "RETRYING", rowid: 5 })]);
await performIMessageCatchup({
accountId: "primary",
config,
now,
fetch,
dispatch,
});
const cursor = await loadIMessageCatchupCursor("primary");
expect(cursor?.failureRetries).toBeUndefined();
});
it("does NOT leapfrog a held failure when a later row in the same batch succeeds", async () => {
// Regression for #78649 cursor-leapfrog bug. Prior to the fix the loop
// advanced lastSeenRowid on every successful row, so a held failure at
// rowid 10 followed by a success at rowid 11 would persist the cursor
// at 11 — and the next pass would filter row 10 out via `row.rowid <=
// sinceRowid` and never retry it. With the fix in place the cursor is
// clamped to `earliestHeldFailureRow.rowid - 1` (== 9) so the next pass
// refetches row 10.
let dispatchCount = 0;
const dispatch = vi.fn<CatchupDispatchFn>(async (rowLocal) => {
dispatchCount += 1;
if (rowLocal.guid === "A") {
return { ok: false };
}
return { ok: true };
});
const fetch = fetchOf([
row({ guid: "A", rowid: 10, date: now - 40_000 }),
row({ guid: "B", rowid: 11, date: now - 30_000 }),
]);
const summary = await performIMessageCatchup({
accountId: "primary",
config,
now,
fetch,
dispatch,
});
expect(summary.failed).toBe(1);
expect(summary.replayed).toBe(1);
expect(summary.givenUp).toBe(0);
// Cursor must not leapfrog the held failure at rowid 10. The persisted
// cursor lands at rowid 9 so the next pass refetches row 10.
expect(summary.cursorAfter.lastSeenRowid).toBe(9);
expect(dispatchCount).toBe(2);
const cursor = await loadIMessageCatchupCursor("primary");
expect(cursor?.lastSeenRowid).toBe(9);
expect(cursor?.failureRetries?.A).toBe(1);
});
it("keeps held failure state when a live monitor advances the same cursor mid-pass", async () => {
const dispatch = vi.fn<CatchupDispatchFn>(async () => {
await advanceIMessageCatchupCursor(
"primary",
{ lastSeenMs: now - 10_000, lastSeenRowid: 50 },
config,
);
return { ok: false };
});
const fetch = fetchOf([row({ guid: "A", rowid: 10, date: now - 40_000 })]);
const summary = await performIMessageCatchup({
accountId: "primary",
config,
now,
fetch,
dispatch,
});
expect(summary.failed).toBe(1);
expect(summary.cursorAfter.lastSeenRowid).toBe(9);
const cursor = await loadIMessageCatchupCursor("primary");
expect(cursor?.lastSeenRowid).toBe(9);
expect(cursor?.failureRetries?.A).toBe(1);
});
it("advances the cursor past parser-rejected rows via the fetch high-watermark", async () => {
// Regression: without a high-watermark from the fetcher, an unparseable
// row never reaches the loop, so the cursor never advances past it and
// the next pass re-fetches and re-drops the same broken row forever.
// The bridge probes raw `id` / `created_at` per row and emits a
// `highWatermarkRowid` / `highWatermarkMs` floor so the loop can advance
// the cursor even when every fetched row fails the payload parser.
const dispatch = vi.fn<CatchupDispatchFn>(async () => ({ ok: true }));
const fetch: CatchupFetchFn = vi.fn(async () => ({
resolved: true,
rows: [],
highWatermarkRowid: 42,
highWatermarkMs: now - 5_000,
}));
const summary = await performIMessageCatchup({
accountId: "primary",
config,
now,
fetch,
dispatch,
});
expect(summary.querySucceeded).toBe(true);
expect(summary.replayed).toBe(0);
expect(summary.fetchedCount).toBe(0);
expect(summary.cursorAfter.lastSeenRowid).toBe(42);
expect(dispatch).not.toHaveBeenCalled();
const cursor = await loadIMessageCatchupCursor("primary");
expect(cursor?.lastSeenRowid).toBe(42);
expect(cursor?.lastSeenMs).toBe(now - 5_000);
});
it("does not let the high-watermark leapfrog a held failure", async () => {
// The fetcher's watermark is a floor for cursor advance, but a held
// failure must still clamp the cursor below the failed row even when
// the fetcher reports a higher watermark.
const dispatch = vi.fn<CatchupDispatchFn>(async () => ({ ok: false }));
const fetch: CatchupFetchFn = vi.fn(async () => ({
resolved: true,
rows: [row({ guid: "A", rowid: 10, date: now - 40_000 })],
highWatermarkRowid: 99,
highWatermarkMs: now - 100,
}));
const summary = await performIMessageCatchup({
accountId: "primary",
config,
now,
fetch,
dispatch,
});
expect(summary.failed).toBe(1);
// Even though the fetcher reports watermark=99, the held failure at
// rowid 10 clamps the cursor at 9.
expect(summary.cursorAfter.lastSeenRowid).toBe(9);
});
it("returns querySucceeded=false and preserves the cursor on fetch failure", async () => {
await saveIMessageCatchupCursor("primary", { lastSeenMs: now - 60_000, lastSeenRowid: 7 });
const dispatch = alwaysOk();
const fetch = vi.fn<CatchupFetchFn>(async () => {
throw new Error("imsg rpc closed");
});
const warn = vi.fn();
const summary = await performIMessageCatchup({
accountId: "primary",
config,
now,
fetch,
dispatch,
warn,
});
expect(summary.querySucceeded).toBe(false);
expect(summary.replayed).toBe(0);
expect(dispatch).not.toHaveBeenCalled();
expect(warn).toHaveBeenCalledWith("imessage catchup: fetch failed: Error: imsg rpc closed");
const cursor = await loadIMessageCatchupCursor("primary");
expect(cursor?.lastSeenRowid).toBe(7);
});
});

View File

@@ -0,0 +1,565 @@
// Imessage plugin module implements catchup behavior.
import { createHash } from "node:crypto";
import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue";
import type { PluginStateSyncKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime";
import { getIMessageRuntime } from "../runtime.js";
// iMessage inbound catchup. When the gateway is offline (crash, restart, mac
// sleep, machine off), `imsg watch` resumes from current state and ignores
// anything that landed in chat.db while the bridge was disconnected.
// Without a recovery pass, those messages are permanently lost.
//
// This module keeps catchup on the same inbound evaluation and dispatch path
// as live `imsg watch` notifications. The replay loop is pluggable via the
// `dispatch` callback so `evaluateIMessageInbound` + `dispatchInboundMessage`
// runs unchanged on replayed rows.
//
// See https://github.com/openclaw/openclaw/issues/78649 for design discussion.
const DEFAULT_MAX_AGE_MINUTES = 120;
const MAX_MAX_AGE_MINUTES = 12 * 60;
const DEFAULT_PER_RUN_LIMIT = 50;
const MAX_PER_RUN_LIMIT = 500;
const DEFAULT_FIRST_RUN_LOOKBACK_MINUTES = 30;
const DEFAULT_MAX_FAILURE_RETRIES = 10;
const MAX_MAX_FAILURE_RETRIES = 1_000;
// Defense-in-depth bound on the retry map. The cursor is one plugin-state
// value, so keep the retry payload well below the 64KB store limit.
const MAX_FAILURE_RETRY_MAP_SIZE = 512;
const MAX_FAILURE_RETRY_MAP_JSON_BYTES = 48_000;
const textEncoder = new TextEncoder();
export const IMESSAGE_CATCHUP_CURSOR_NAMESPACE = "imessage.catchup-cursors";
export const IMESSAGE_CATCHUP_CURSOR_MAX_ENTRIES = 256;
const cursorWriteQueue = new KeyedAsyncQueue();
export type IMessageCatchupConfig = {
enabled?: boolean;
maxAgeMinutes?: number;
perRunLimit?: number;
firstRunLookbackMinutes?: number;
maxFailureRetries?: number;
};
export type IMessageCatchupCursor = {
/** Timestamp (ms since epoch) of the highest-watermark message we processed. */
lastSeenMs: number;
/** ROWID of the highest-watermark processed message. Monotonic in chat.db. */
lastSeenRowid: number;
/** UTC ms timestamp of the most recent cursor write. */
updatedAt: number;
/**
* Per-GUID failure counter, preserved across runs. Two states:
* - `1 <= count < maxFailureRetries`: the GUID is still retrying and
* continues to hold the cursor back.
* - `count >= maxFailureRetries`: catchup has given up on the GUID. The
* message is skipped on sight (no dispatch attempt) and the cursor no
* longer waits on it. Entry stays in the map until the cursor naturally
* advances past the message's timestamp.
*
* A successful dispatch removes the entry. Optional on the persisted shape
* so older cursor files without this field load cleanly.
*/
failureRetries?: Record<string, number>;
};
export type IMessageCatchupRow = {
guid: string;
rowid: number;
/** Timestamp in ms since epoch. */
date: number;
isFromMe?: boolean;
};
export type IMessageCatchupSummary = {
querySucceeded: boolean;
fullyCaughtUp: boolean;
fetchedCount: number;
replayed: number;
skippedFromMe: number;
skippedPreCursor: number;
/**
* Messages whose GUID was already recorded as "given up" from a prior
* run (count >= `maxFailureRetries`). Skipped without a dispatch attempt
* so the cursor can advance past them.
*/
skippedGivenUp: number;
failed: number;
/**
* Messages that crossed the `maxFailureRetries` ceiling on this run. Each
* transition triggers a `warn` log line. Already-given-up messages in
* subsequent runs count under `skippedGivenUp`, not here.
*/
givenUp: number;
cursorBefore: { lastSeenMs: number; lastSeenRowid: number } | null;
cursorAfter: { lastSeenMs: number; lastSeenRowid: number };
windowStartMs: number;
windowEndMs: number;
};
export function resolveIMessageCatchupCursorKey(accountId: string): string {
return createHash("sha256").update(accountId, "utf8").digest("hex").slice(0, 32);
}
function openCatchupCursorStore(): PluginStateSyncKeyedStore<IMessageCatchupCursor> {
return getIMessageRuntime().state.openSyncKeyedStore<IMessageCatchupCursor>({
namespace: IMESSAGE_CATCHUP_CURSOR_NAMESPACE,
maxEntries: IMESSAGE_CATCHUP_CURSOR_MAX_ENTRIES,
});
}
function updateCatchupCursorStore(
key: string,
updateValue: (current: IMessageCatchupCursor | undefined) => IMessageCatchupCursor | undefined,
): boolean {
const store = openCatchupCursorStore();
if (!store.update) {
throw new Error("iMessage catchup cursor persistence requires atomic plugin-state update.");
}
return store.update(key, updateValue);
}
function enqueueCursorWrite<T>(accountId: string, fn: () => Promise<T>): Promise<T> {
const key = resolveIMessageCatchupCursorKey(accountId);
return cursorWriteQueue.enqueue(key, fn);
}
function sanitizeFailureRetriesInput(raw: unknown): Record<string, number> {
if (!raw || typeof raw !== "object") {
return {};
}
const out: Record<string, number> = {};
for (const [guid, count] of Object.entries(raw as Record<string, unknown>)) {
if (!guid || typeof guid !== "string") {
continue;
}
if (typeof count !== "number" || !Number.isFinite(count) || count <= 0) {
continue;
}
out[guid] = Math.floor(count);
}
return out;
}
function normalizeIMessageCatchupCursor(value: unknown): IMessageCatchupCursor | null {
if (!value || typeof value !== "object") {
return null;
}
const raw = value as Partial<IMessageCatchupCursor>;
if (typeof raw.lastSeenMs !== "number" || !Number.isFinite(raw.lastSeenMs)) {
return null;
}
if (typeof raw.lastSeenRowid !== "number" || !Number.isFinite(raw.lastSeenRowid)) {
return null;
}
const failureRetries = sanitizeFailureRetriesInput(raw.failureRetries);
const hasRetries = Object.keys(failureRetries).length > 0;
return {
lastSeenMs: raw.lastSeenMs,
lastSeenRowid: raw.lastSeenRowid,
updatedAt: typeof raw.updatedAt === "number" ? raw.updatedAt : 0,
...(hasRetries ? { failureRetries } : {}),
};
}
function readIMessageCatchupCursor(accountId: string): IMessageCatchupCursor | null {
return normalizeIMessageCatchupCursor(
openCatchupCursorStore().lookup(resolveIMessageCatchupCursorKey(accountId)),
);
}
export async function loadIMessageCatchupCursor(
accountId: string,
): Promise<IMessageCatchupCursor | null> {
return readIMessageCatchupCursor(accountId);
}
function buildIMessageCatchupCursor(next: {
lastSeenMs: number;
lastSeenRowid: number;
failureRetries?: Record<string, number>;
}): IMessageCatchupCursor {
const sanitized = sanitizeFailureRetriesInput(next.failureRetries);
const hasRetries = Object.keys(sanitized).length > 0;
return {
lastSeenMs: next.lastSeenMs,
lastSeenRowid: next.lastSeenRowid,
updatedAt: Date.now(),
...(hasRetries ? { failureRetries: sanitized } : {}),
};
}
export async function saveIMessageCatchupCursor(
accountId: string,
next: { lastSeenMs: number; lastSeenRowid: number; failureRetries?: Record<string, number> },
options: { allowCursorRewindForRetries?: boolean } = {},
): Promise<void> {
const cursor = buildIMessageCatchupCursor(next);
updateCatchupCursorStore(resolveIMessageCatchupCursorKey(accountId), (existingValue) => {
const existing = normalizeIMessageCatchupCursor(existingValue);
if (existing && cursor.lastSeenRowid < existing.lastSeenRowid) {
if (!options.allowCursorRewindForRetries) {
return undefined;
}
return buildIMessageCatchupCursor({
lastSeenMs: cursor.lastSeenMs,
lastSeenRowid: cursor.lastSeenRowid,
failureRetries: { ...existing.failureRetries, ...cursor.failureRetries },
});
}
return cursor;
});
}
export function resetIMessageCatchupCursorStoreForTest(): void {
openCatchupCursorStore().clear();
}
/**
* Bound the retry map so a pathological storm of unique failing GUIDs
* cannot grow the cursor file without limit. Keeps the `maxSize` entries
* with the highest counts (closest to give-up) when over the bound.
*/
export function capFailureRetriesMap(
map: Record<string, number>,
maxSize: number = MAX_FAILURE_RETRY_MAP_SIZE,
maxBytes: number = MAX_FAILURE_RETRY_MAP_JSON_BYTES,
): Record<string, number> {
const entries = Object.entries(map);
if (entries.length <= maxSize && textEncoder.encode(JSON.stringify(map)).byteLength <= maxBytes) {
return map;
}
// Sort by count desc; stable tiebreak on guid string so the retained set
// is deterministic across runs (important for cursor-file diffing during
// debugging).
entries.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
const capped: Record<string, number> = {};
for (let i = 0; i < entries.length && i < maxSize; i++) {
const [guid, count] = entries[i];
capped[guid] = count;
if (textEncoder.encode(JSON.stringify(capped)).byteLength > maxBytes) {
delete capped[guid];
break;
}
}
return capped;
}
export type ResolvedCatchupConfig = {
enabled: boolean;
maxAgeMinutes: number;
perRunLimit: number;
firstRunLookbackMinutes: number;
maxFailureRetries: number;
};
function clampInt(value: number | undefined, min: number, max: number, fallback: number): number {
if (typeof value !== "number" || !Number.isFinite(value)) {
return fallback;
}
return Math.min(max, Math.max(min, Math.floor(value)));
}
export function resolveCatchupConfig(
raw: IMessageCatchupConfig | undefined,
): ResolvedCatchupConfig {
return {
enabled: Boolean(raw?.enabled),
maxAgeMinutes: clampInt(raw?.maxAgeMinutes, 1, MAX_MAX_AGE_MINUTES, DEFAULT_MAX_AGE_MINUTES),
perRunLimit: clampInt(raw?.perRunLimit, 1, MAX_PER_RUN_LIMIT, DEFAULT_PER_RUN_LIMIT),
firstRunLookbackMinutes: clampInt(
raw?.firstRunLookbackMinutes,
1,
MAX_MAX_AGE_MINUTES,
DEFAULT_FIRST_RUN_LOOKBACK_MINUTES,
),
maxFailureRetries: clampInt(
raw?.maxFailureRetries,
1,
MAX_MAX_FAILURE_RETRIES,
DEFAULT_MAX_FAILURE_RETRIES,
),
};
}
export type CatchupFetchFn = (params: {
sinceMs: number;
sinceRowid: number;
limit: number;
}) => Promise<{
resolved: boolean;
rows: IMessageCatchupRow[];
/**
* Highest `rowid` the fetcher saw in the raw response, including rows it
* dropped (parser failure, schema drift, missing fields). The replay loop
* uses this as a floor for the cursor advance so a single unparseable row
* cannot stall catchup forever — without this, the bridge silently
* dropping a row would mean the next pass re-fetches the same broken row
* indefinitely. Optional so test fetchers that only emit fully-valid rows
* can omit it; when omitted, the cursor advance falls back to the rows
* the loop actually processed.
*/
highWatermarkRowid?: number;
/** Companion to `highWatermarkRowid` — highest `date` seen in the raw response. */
highWatermarkMs?: number;
/**
* True when the fetcher reached every eligible source row for this pass.
* False means a best-effort partial pass occurred (for example one chat
* history fetch failed, or the global cap left rows for a later startup).
*/
fullyCaughtUp?: boolean;
}>;
export type CatchupDispatchFn = (row: IMessageCatchupRow) => Promise<{ ok: boolean }>;
export type PerformCatchupParams = {
accountId: string;
config: ResolvedCatchupConfig;
now?: number;
fetch: CatchupFetchFn;
dispatch: CatchupDispatchFn;
observeSkippedFromMe?: (row: IMessageCatchupRow) => Promise<void> | void;
log?: (message: string) => void;
warn?: (message: string) => void;
};
export async function advanceIMessageCatchupCursor(
accountId: string,
next: { lastSeenMs: number; lastSeenRowid: number },
config: ResolvedCatchupConfig,
): Promise<boolean> {
if (!Number.isFinite(next.lastSeenMs) || !Number.isFinite(next.lastSeenRowid)) {
return false;
}
return await enqueueCursorWrite(accountId, async () => {
let advanced = false;
updateCatchupCursorStore(resolveIMessageCatchupCursorKey(accountId), (existingValue) => {
const cursor = normalizeIMessageCatchupCursor(existingValue);
if (cursor && next.lastSeenRowid <= cursor.lastSeenRowid) {
return undefined;
}
const blockingFailure = Object.values(cursor?.failureRetries ?? {}).some(
(count) => count < config.maxFailureRetries,
);
if (blockingFailure) {
return undefined;
}
advanced = true;
return buildIMessageCatchupCursor({
lastSeenMs: Math.max(cursor?.lastSeenMs ?? next.lastSeenMs, next.lastSeenMs),
lastSeenRowid: next.lastSeenRowid,
failureRetries: cursor?.failureRetries,
});
});
return advanced;
});
}
/**
* One catchup pass. Loads the cursor, fetches `messages.history`, replays
* each row through `dispatch`, advances the cursor on success / give-up,
* persists the cursor, returns a summary.
*
* The fetch and dispatch functions are injected so this loop is unit-testable
* without standing up an `imsg` daemon. The wiring in `monitor-provider.ts`
* passes the live `client.request("messages.history", ...)` adapter as
* `fetch` and the `evaluateIMessageInbound` + `dispatchInboundMessage`
* pipeline as `dispatch`.
*/
export async function performIMessageCatchup(
params: PerformCatchupParams,
): Promise<IMessageCatchupSummary> {
const now = params.now ?? Date.now();
const cfg = params.config;
const cursor = await loadIMessageCatchupCursor(params.accountId);
const lookbackMs =
cursor === null ? cfg.firstRunLookbackMinutes * 60_000 : cfg.maxAgeMinutes * 60_000;
const ageBoundMs = now - cfg.maxAgeMinutes * 60_000;
const windowStartMs = Math.max(cursor?.lastSeenMs ?? now - lookbackMs, ageBoundMs);
const windowEndMs = now;
const sinceRowid = cursor?.lastSeenRowid ?? 0;
const summary: IMessageCatchupSummary = {
querySucceeded: false,
fullyCaughtUp: false,
fetchedCount: 0,
replayed: 0,
skippedFromMe: 0,
skippedPreCursor: 0,
skippedGivenUp: 0,
failed: 0,
givenUp: 0,
cursorBefore: cursor
? { lastSeenMs: cursor.lastSeenMs, lastSeenRowid: cursor.lastSeenRowid }
: null,
cursorAfter: {
lastSeenMs: cursor?.lastSeenMs ?? windowStartMs,
lastSeenRowid: cursor?.lastSeenRowid ?? 0,
},
windowStartMs,
windowEndMs,
};
let fetchResult: Awaited<ReturnType<CatchupFetchFn>>;
try {
fetchResult = await params.fetch({
sinceMs: windowStartMs,
sinceRowid,
limit: cfg.perRunLimit,
});
} catch (err) {
params.warn?.(`imessage catchup: fetch failed: ${String(err)}`);
return summary;
}
if (!fetchResult.resolved) {
params.warn?.(`imessage catchup: fetch returned unresolved result`);
return summary;
}
summary.querySucceeded = true;
summary.fullyCaughtUp = fetchResult.fullyCaughtUp !== false;
summary.fetchedCount = fetchResult.rows.length;
// Stable order: process oldest-first so the cursor advances monotonically
// and a mid-run failure leaves a usable lastSeenRowid for the next pass.
const rows = fetchResult.rows.toSorted((a, b) => a.rowid - b.rowid);
const failureRetries = { ...cursor?.failureRetries };
// Two distinct watermarks: `highWatermark*` is the high point we reached on
// any row we processed cleanly (success / skipFromMe / skipPreCursor /
// skipGivenUp / give-up), and `earliestHeldFailureRow` is the smallest-rowid
// row whose dispatch failed below the retry ceiling on this pass. When a
// failure is held, the persisted cursor must NOT leapfrog it — otherwise
// the next pass would filter the failed row out via `row.rowid <= sinceRowid`
// and never retry. Already-successful rows above the held failure get
// re-replayed on the next pass and absorbed by the inbound-dedupe cache.
const cursorBeforeMs = cursor?.lastSeenMs ?? windowStartMs;
const cursorBeforeRowid = cursor?.lastSeenRowid ?? 0;
let highWatermarkMs = cursorBeforeMs;
let highWatermarkRowid = cursorBeforeRowid;
let earliestHeldFailureRow: IMessageCatchupRow | null = null;
for (const row of rows) {
if (row.rowid <= sinceRowid) {
summary.skippedPreCursor += 1;
continue;
}
if (row.date < ageBoundMs) {
// Row predates the recency ceiling. Skip but advance the cursor so we
// don't re-fetch it next pass.
summary.skippedPreCursor += 1;
highWatermarkMs = Math.max(highWatermarkMs, row.date);
highWatermarkRowid = Math.max(highWatermarkRowid, row.rowid);
continue;
}
if (row.isFromMe) {
try {
await params.observeSkippedFromMe?.(row);
} catch (err) {
params.warn?.(
`imessage catchup: from-me observer failed for guid=${row.guid}: ${String(err)}`,
);
}
summary.skippedFromMe += 1;
highWatermarkMs = Math.max(highWatermarkMs, row.date);
highWatermarkRowid = Math.max(highWatermarkRowid, row.rowid);
continue;
}
const priorCount = failureRetries[row.guid] ?? 0;
if (priorCount >= cfg.maxFailureRetries) {
summary.skippedGivenUp += 1;
highWatermarkMs = Math.max(highWatermarkMs, row.date);
highWatermarkRowid = Math.max(highWatermarkRowid, row.rowid);
continue;
}
let dispatched: { ok: boolean };
try {
dispatched = await params.dispatch(row);
} catch (err) {
params.warn?.(`imessage catchup: dispatch threw for guid=${row.guid}: ${String(err)}`);
dispatched = { ok: false };
}
if (dispatched.ok) {
summary.replayed += 1;
delete failureRetries[row.guid];
highWatermarkMs = Math.max(highWatermarkMs, row.date);
highWatermarkRowid = Math.max(highWatermarkRowid, row.rowid);
continue;
}
const nextCount = priorCount + 1;
failureRetries[row.guid] = nextCount;
summary.failed += 1;
if (nextCount >= cfg.maxFailureRetries) {
summary.givenUp += 1;
params.warn?.(
`imessage catchup: giving up on guid=${row.guid} after ${nextCount} failures; advancing cursor past it`,
);
// Cursor advances past the wedged guid so subsequent passes can make
// progress. Already-given-up entries in future runs count under
// skippedGivenUp.
highWatermarkMs = Math.max(highWatermarkMs, row.date);
highWatermarkRowid = Math.max(highWatermarkRowid, row.rowid);
continue;
}
// Below the retry ceiling: hold the cursor BEFORE this row so the next
// pass retries it. Rows are sorted ascending, so the first held failure
// is the lowest-rowid one — clamp the persisted cursor at its rowid - 1.
if (earliestHeldFailureRow === null || row.rowid < earliestHeldFailureRow.rowid) {
earliestHeldFailureRow = row;
}
}
// Apply the bridge's high-watermark floor. The bridge tracks the highest
// rowid in the raw `messages.history` response, including rows it had to
// drop (parser failure, schema drift). Without this, an unparseable row
// could permanently stall the cursor: it never reaches the loop, the loop
// never advances past it, the next pass re-fetches the same broken row.
// The floor only applies when no failure is held — a held failure has
// tighter cursor-cap semantics that must win.
if (earliestHeldFailureRow === null) {
if (typeof fetchResult.highWatermarkMs === "number") {
highWatermarkMs = Math.max(highWatermarkMs, fetchResult.highWatermarkMs);
}
if (typeof fetchResult.highWatermarkRowid === "number") {
highWatermarkRowid = Math.max(highWatermarkRowid, fetchResult.highWatermarkRowid);
}
}
let lastSeenMs: number;
let lastSeenRowid: number;
if (earliestHeldFailureRow !== null) {
// Hold cursor strictly below the failed row. Already-successful rows
// above it get re-replayed next pass; the inbound-dedupe cache absorbs
// the duplicate dispatch.
lastSeenMs = Math.max(cursorBeforeMs, earliestHeldFailureRow.date - 1);
lastSeenRowid = Math.max(cursorBeforeRowid, earliestHeldFailureRow.rowid - 1);
} else {
lastSeenMs = highWatermarkMs;
lastSeenRowid = highWatermarkRowid;
}
const capped = capFailureRetriesMap(failureRetries);
summary.cursorAfter = { lastSeenMs, lastSeenRowid };
await saveIMessageCatchupCursor(
params.accountId,
{
lastSeenMs,
lastSeenRowid,
failureRetries: capped,
},
{
allowCursorRewindForRetries: earliestHeldFailureRow !== null,
},
);
if (summary.replayed > 0 || summary.failed > 0 || summary.givenUp > 0) {
params.log?.(
`imessage catchup: replayed=${summary.replayed} skippedFromMe=${summary.skippedFromMe} skippedGivenUp=${summary.skippedGivenUp} failed=${summary.failed} givenUp=${summary.givenUp} fetchedCount=${summary.fetchedCount}`,
);
}
return summary;
}

View File

@@ -0,0 +1,248 @@
// Imessage tests cover coalesce plugin behavior.
import { describe, expect, it } from "vitest";
import {
combineIMessagePayloads,
IMESSAGE_URL_BALLOON_BUNDLE_ID,
isStandaloneIMessageUrlPreviewPayload,
MAX_COALESCED_ATTACHMENTS,
MAX_COALESCED_ENTRIES,
MAX_COALESCED_TEXT_CHARS,
shouldCombineIMessagePayloadBucket,
} from "./coalesce.js";
import type { IMessagePayload } from "./types.js";
const makePayload = (overrides: Partial<IMessagePayload> = {}): IMessagePayload => ({
guid: `msg-${Math.random().toString(36).slice(2, 10)}`,
chat_id: 1,
sender: "+15555550100",
is_from_me: false,
is_group: false,
text: null,
attachments: null,
created_at: new Date(2025, 0, 1).toISOString(),
...overrides,
});
describe("combineIMessagePayloads", () => {
it("throws on empty input", () => {
expect(() => combineIMessagePayloads([])).toThrow(
"combineIMessagePayloads: cannot combine empty payloads",
);
});
it("returns the lone payload unchanged when only one entry", () => {
const payload = makePayload({ text: "alone", guid: "solo" });
const result = combineIMessagePayloads([payload]);
expect(result).toBe(payload);
expect(result.guid).toBe("solo");
});
it("merges two same-sender rows into one payload anchored on the first GUID", () => {
const first = makePayload({
id: 41,
text: "summarize",
guid: "row-1",
created_at: "2025-01-01T00:00:00Z",
});
const second = makePayload({
id: 42,
text: "https://example.com/article",
guid: "row-2",
created_at: "2025-01-01T00:00:01.500Z",
});
const merged = combineIMessagePayloads([first, second]);
expect(merged.text).toBe("summarize https://example.com/article");
expect(merged.guid).toBe("row-1");
expect(merged.created_at).toBe("2025-01-01T00:00:01.500Z");
expect(merged.coalescedMessageGuids).toEqual(["row-1", "row-2"]);
expect(merged.coalescedCatchupCursor).toEqual({
lastSeenMs: Date.parse("2025-01-01T00:00:01.500Z"),
lastSeenRowid: 42,
});
});
it("preserves attachments instead of dropping them on merge", () => {
const text = makePayload({ text: "Save", guid: "row-1" });
const image = makePayload({
text: "caption",
guid: "row-2",
attachments: [{ original_path: "/tmp/a.jpg", mime_type: "image/jpeg" }],
});
const merged = combineIMessagePayloads([text, image]);
expect(merged.attachments).toEqual([{ original_path: "/tmp/a.jpg", mime_type: "image/jpeg" }]);
});
it("dedupes identical text appearing in both rows (URL in text and balloon)", () => {
const a = makePayload({ text: "https://example.com", guid: "row-1" });
const b = makePayload({ text: "https://example.com", guid: "row-2" });
const merged = combineIMessagePayloads([a, b]);
expect(merged.text).toBe("https://example.com");
expect(merged.coalescedMessageGuids).toEqual(["row-1", "row-2"]);
});
it("caps merged text length and appends the truncated marker", () => {
const longA = makePayload({ text: "A".repeat(3000), guid: "row-1" });
const longB = makePayload({ text: "B".repeat(3000), guid: "row-2" });
const merged = combineIMessagePayloads([longA, longB]);
expect(merged.text?.endsWith("…[truncated]")).toBe(true);
expect(merged.text?.length).toBeLessThanOrEqual(
MAX_COALESCED_TEXT_CHARS + "…[truncated]".length,
);
});
it("caps the attachment count", () => {
// 5 attachments per row × 6 rows = 30 attachments offered, capped at 20.
// Stays under the entry cap so the merge isn't pruned for that reason.
const payloads = Array.from({ length: 6 }, (_, i) =>
makePayload({
guid: `row-${i}`,
attachments: Array.from({ length: 5 }, (_Local, j) => ({
original_path: `/tmp/${i}-${j}.jpg`,
mime_type: "image/jpeg",
})),
}),
);
const merged = combineIMessagePayloads(payloads);
expect(merged.attachments?.length).toBe(MAX_COALESCED_ATTACHMENTS);
});
it("keeps first + most recent when entry count exceeds the cap, but tracks every GUID", () => {
const payloads = Array.from({ length: 25 }, (_, i) =>
makePayload({
id: i,
text: `msg ${i}`,
guid: `row-${i}`,
created_at: new Date(Date.UTC(2025, 0, 1, 0, 0, i)).toISOString(),
}),
);
const merged = combineIMessagePayloads(payloads);
// First payload's GUID anchors the merged shape.
expect(merged.guid).toBe("row-0");
// Every source GUID is tracked, even those whose text was dropped by the cap.
expect(merged.coalescedMessageGuids?.length).toBe(25);
expect(merged.coalescedMessageGuids?.[0]).toBe("row-0");
expect(merged.coalescedMessageGuids?.[24]).toBe("row-24");
// Merged text contains only first MAX_COALESCED_ENTRIES-1 entries plus the latest.
expect(merged.text).toContain("msg 0");
expect(merged.text).toContain("msg 24");
expect(merged.text).not.toContain("msg 10"); // dropped by cap
});
it("preserves reply context from any entry that carries one", () => {
const noReply = makePayload({ text: "hello", guid: "row-1" });
const reply = makePayload({
text: "follow-up",
guid: "row-2",
reply_to_id: "parent-msg",
reply_to_text: "earlier",
reply_to_sender: "+15555550199",
});
const merged = combineIMessagePayloads([noReply, reply]);
expect(merged.reply_to_id).toBe("parent-msg");
expect(merged.reply_to_text).toBe("earlier");
expect(merged.reply_to_sender).toBe("+15555550199");
});
it("does not set coalescedMessageGuids when no entry carries a GUID", () => {
const a = makePayload({ text: "a", guid: null });
const b = makePayload({ text: "b", guid: null });
const merged = combineIMessagePayloads([a, b]);
expect(merged.coalescedMessageGuids).toBeUndefined();
});
it("respects the documented entry cap value", () => {
expect(MAX_COALESCED_ENTRIES).toBeGreaterThan(1);
});
});
describe("isStandaloneIMessageUrlPreviewPayload", () => {
it("matches URL balloon rows that only carry the preview URL", () => {
expect(
isStandaloneIMessageUrlPreviewPayload(
makePayload({
text: "https://example.com/article",
balloon_bundle_id: IMESSAGE_URL_BALLOON_BUNDLE_ID,
}),
),
).toBe(true);
});
it("matches scheme-less www URL preview rows", () => {
expect(
isStandaloneIMessageUrlPreviewPayload(
makePayload({
text: "www.example.com/article",
balloon_bundle_id: IMESSAGE_URL_BALLOON_BUNDLE_ID,
}),
),
).toBe(true);
});
it("does not match already-complete URL balloon messages with text context", () => {
expect(
isStandaloneIMessageUrlPreviewPayload(
makePayload({
text: "summarize https://example.com/article",
balloon_bundle_id: IMESSAGE_URL_BALLOON_BUNDLE_ID,
}),
),
).toBe(false);
});
it("does not match non-URL balloon payloads", () => {
expect(
isStandaloneIMessageUrlPreviewPayload(
makePayload({
text: "https://example.com/article",
balloon_bundle_id: "com.apple.messages.HandwritingProvider",
}),
),
).toBe(false);
});
});
describe("shouldCombineIMessagePayloadBucket", () => {
it("combines a command row with a structurally marked URL balloon row", () => {
const command = makePayload({ text: "summarize", guid: "row-1" });
const preview = makePayload({
text: "https://example.com/article",
guid: "row-2",
balloon_bundle_id: IMESSAGE_URL_BALLOON_BUNDLE_ID,
});
expect(shouldCombineIMessagePayloadBucket([command, preview], true)).toBe(true);
});
it("keeps ordinary buffered rows separate once the bridge emits balloon metadata", () => {
const first = makePayload({ text: "first thought", guid: "row-1" });
const second = makePayload({ text: "second thought", guid: "row-2" });
expect(shouldCombineIMessagePayloadBucket([first, second], true)).toBe(false);
});
it("keeps non-URL balloon rows separate", () => {
const first = makePayload({ text: "first thought", guid: "row-1" });
const second = makePayload({
text: "second thought",
guid: "row-2",
balloon_bundle_id: "com.apple.messages.HandwritingProvider",
});
expect(shouldCombineIMessagePayloadBucket([first, second], false)).toBe(false);
});
it("falls back to combining old bridge buckets with no balloon metadata", () => {
const command = makePayload({ text: "summarize", guid: "row-1" });
const url = makePayload({ text: "https://example.com/article", guid: "row-2" });
expect(shouldCombineIMessagePayloadBucket([command, url], false)).toBe(true);
});
});

View File

@@ -0,0 +1,203 @@
// Imessage plugin module implements the same-sender inbound debounce merge.
import { sliceUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
import type { IMessagePayload } from "./types.js";
// Keep the merge contract narrow (caps, ID tracking, reply-context preference)
// so a future SDK lift into `openclaw/plugin-sdk/channel-inbound` is a
// mechanical extraction instead of a behavioral redesign. Apple's URL-preview
// split-send pipeline is the iMessage-only behavior this still protects.
/**
* Bounds on the merged output when multiple inbound iMessage payloads are
* folded into one agent turn. Caps each merge so a sender who
* rapid-fires DMs inside the debounce window cannot amplify the downstream
* prompt past a safe ceiling. Every source GUID still surfaces via
* `coalescedMessageGuids` so a future replay path can recognize duplicates.
*/
export const MAX_COALESCED_TEXT_CHARS = 4000;
export const MAX_COALESCED_ATTACHMENTS = 20;
export const MAX_COALESCED_ENTRIES = 10;
export const IMESSAGE_URL_BALLOON_BUNDLE_ID = "com.apple.messages.URLBalloonProvider";
export function hasIMessageUrlBalloonBundleID(payload: IMessagePayload): boolean {
return payload.balloon_bundle_id === IMESSAGE_URL_BALLOON_BUNDLE_ID;
}
function isSingleUrlToken(text: string): boolean {
if (/\s/.test(text)) {
return false;
}
if (/^www\.[^\s.]+\.[^\s]+$/i.test(text)) {
return true;
}
try {
const url = new URL(text);
return url.protocol === "http:" || url.protocol === "https:";
} catch {
return false;
}
}
export function isStandaloneIMessageUrlPreviewPayload(payload: IMessagePayload): boolean {
if (!hasIMessageUrlBalloonBundleID(payload)) {
return false;
}
const text = (payload.text ?? "").trim();
return text.length === 0 || isSingleUrlToken(text);
}
// imsg omits `balloon_bundle_id` for non-balloon rows, so a present value is
// the session signal that this bridge build exposes structural balloon
// metadata. Once latched, missing URL metadata is meaningful.
export function hasIMessageBalloonMetadata(payload: IMessagePayload): boolean {
return typeof payload.balloon_bundle_id === "string" && payload.balloon_bundle_id.length > 0;
}
/**
* Decide whether a debounced same-sender iMessage bucket should merge.
*
* URL-preview rows are merged with their preceding command row so Apple's
* command+URL split-send still reaches the agent as one turn. Once a bridge
* session has emitted balloon metadata, ordinary same-sender DMs without the
* URL marker flush separately instead of being collapsed.
*/
export function shouldCombineIMessagePayloadBucket(
payloads: readonly IMessagePayload[],
buildEmitsBalloonMetadata: boolean,
): boolean {
if (payloads.some(hasIMessageUrlBalloonBundleID)) {
return true;
}
if (buildEmitsBalloonMetadata || payloads.some(hasIMessageBalloonMetadata)) {
return false;
}
// Older imsg builds expose no balloon metadata, so a command+URL split-send
// is indistinguishable from two ordinary text rows. Keep the internal fallback
// until imsg advertises upstream coalescing for that exact shape.
return true;
}
export type CoalescedIMessagePayload = IMessagePayload & {
/**
* Source GUIDs folded into this merged payload, in arrival order. Includes
* GUIDs from entries that were dropped by the entry cap so downstream
* dedupe paths can still recognize them.
*/
coalescedMessageGuids?: string[];
coalescedCatchupCursor?: {
lastSeenMs: number;
lastSeenRowid: number;
};
};
/**
* Combine consecutive same-sender iMessage payloads into a single payload for
* downstream dispatch. Used for Apple's URL-preview split-send, and for the
* general inbound debounce (`messages.inbound`, off by default) when configured.
*
* The first payload anchors the merged shape (preserving its GUID for reply
* threading). Text is concatenated with deduplication, attachments are merged
* (capped), and the latest `created_at` wins so downstream sees the most
* recent activity timestamp.
*/
export function combineIMessagePayloads(payloads: IMessagePayload[]): CoalescedIMessagePayload {
if (payloads.length === 0) {
throw new Error("combineIMessagePayloads: cannot combine empty payloads");
}
if (payloads.length === 1) {
return payloads[0];
}
const first = payloads[0];
const last = payloads[payloads.length - 1];
// Cap entries: keep first (preserves command/context) + most recent
// (preserves latest payload) when a flood exceeds the cap.
const boundedPayloads =
payloads.length > MAX_COALESCED_ENTRIES
? [...payloads.slice(0, MAX_COALESCED_ENTRIES - 1), last]
: payloads;
// Combine text across bounded entries. Skip duplicates so a URL appearing
// both as plain text and as a separately-rendered link-preview row does not
// get repeated in the merged prompt.
const seenTexts = new Set<string>();
const textParts: string[] = [];
for (const payload of boundedPayloads) {
const text = (payload.text ?? "").trim();
if (!text) {
continue;
}
const normalized = text.toLowerCase();
if (seenTexts.has(normalized)) {
continue;
}
seenTexts.add(normalized);
textParts.push(text);
}
let combinedText = textParts.join(" ");
if (combinedText.length > MAX_COALESCED_TEXT_CHARS) {
combinedText = `${sliceUtf16Safe(combinedText, 0, MAX_COALESCED_TEXT_CHARS)}…[truncated]`;
}
// Merge attachments across bounded entries, capped to keep downstream media
// fan-out proportional to a single message.
const allAttachments = boundedPayloads
.flatMap((p) => p.attachments ?? [])
.slice(0, MAX_COALESCED_ATTACHMENTS);
// Latest `created_at` (lexically max ISO-8601 string) so downstream sees
// the freshest activity timestamp. Falls back to `first.created_at` if no
// entries carry a usable timestamp.
const createdAts = payloads
.map((p) => p.created_at)
.filter((c): c is string => typeof c === "string" && c.length > 0);
const latestCreatedAt =
createdAts.length > 0 ? createdAts.reduce((a, b) => (a > b ? a : b)) : first.created_at;
let maxRowid = -Infinity;
let maxDateMs = -Infinity;
for (const payload of payloads) {
if (typeof payload.id === "number" && Number.isFinite(payload.id)) {
maxRowid = Math.max(maxRowid, payload.id);
}
const dateMs =
typeof payload.created_at === "string" ? Date.parse(payload.created_at) : Number.NaN;
if (Number.isFinite(dateMs)) {
maxDateMs = Math.max(maxDateMs, dateMs);
}
}
// Walk the unbounded `payloads` so even GUIDs whose text/attachments were
// dropped by the cap are still remembered for downstream dedupe.
const seenGuids = new Set<string>();
const coalescedMessageGuids: string[] = [];
for (const payload of payloads) {
const guid = payload.guid?.trim();
if (!guid || seenGuids.has(guid)) {
continue;
}
seenGuids.add(guid);
coalescedMessageGuids.push(guid);
}
// Reply context: prefer any entry that carries one; the last balloon in a
// split-send rarely does, but a manual quote-reply earlier in the bucket
// might.
const entryWithReply = payloads.find((p) => p.reply_to_id != null);
return {
...first,
text: combinedText,
attachments: allAttachments.length > 0 ? allAttachments : null,
created_at: latestCreatedAt,
reply_to_id: entryWithReply?.reply_to_id ?? first.reply_to_id ?? null,
reply_to_text: entryWithReply?.reply_to_text ?? first.reply_to_text ?? null,
reply_to_sender: entryWithReply?.reply_to_sender ?? first.reply_to_sender ?? null,
coalescedMessageGuids: coalescedMessageGuids.length > 0 ? coalescedMessageGuids : undefined,
coalescedCatchupCursor:
Number.isFinite(maxRowid) && Number.isFinite(maxDateMs)
? { lastSeenMs: maxDateMs, lastSeenRowid: maxRowid }
: undefined,
};
}

View File

@@ -0,0 +1,156 @@
// Imessage tests cover conversation repair plugin behavior.
import { describe, expect, it, vi } from "vitest";
import { isIMessageAnchorless, repairIMessageConversationAnchor } from "./conversation-repair.js";
import type { IMessagePayload } from "./types.js";
function anchorlessMessage(overrides: Partial<IMessagePayload> = {}): IMessagePayload {
return {
id: 9500,
guid: "ANCHORLESS-GUID-1",
chat_id: 0,
sender: "+15550001111",
is_from_me: false,
text: "https://example.com",
chat_guid: "",
chat_identifier: "",
chat_name: "",
participants: null,
is_group: false,
...overrides,
};
}
function mockClient(chats: Array<{ id: number; messages: Record<string, unknown>[] }>) {
const request = vi.fn(async (method: string, params?: Record<string, unknown>) => {
if (method === "chats.list") {
return { chats: chats.map((chat) => ({ id: chat.id })) };
}
if (method === "messages.history") {
return {
messages: chats.find((chat) => chat.id === params?.chat_id)?.messages ?? [],
};
}
throw new Error(`unexpected method ${method}`);
});
return { request };
}
describe("isIMessageAnchorless", () => {
it("detects explicit broken conversation anchors", () => {
expect(isIMessageAnchorless(anchorlessMessage())).toBe(true);
expect(isIMessageAnchorless(anchorlessMessage({ chat_guid: undefined }))).toBe(true);
expect(isIMessageAnchorless(anchorlessMessage({ chat_identifier: undefined }))).toBe(true);
expect(
isIMessageAnchorless(
anchorlessMessage({ chat_id: undefined, chat_guid: "", chat_identifier: "" }),
),
).toBe(true);
});
it("does not classify sender-only direct messages as anchorless", () => {
expect(
isIMessageAnchorless({
guid: "DM-GUID",
sender: "+15550001111",
is_from_me: false,
text: "hello",
}),
).toBe(false);
});
it("does not classify messages with any usable conversation anchor", () => {
expect(isIMessageAnchorless(anchorlessMessage({ chat_id: 349 }))).toBe(false);
expect(isIMessageAnchorless(anchorlessMessage({ chat_guid: "iMessage;+;chat349" }))).toBe(
false,
);
expect(isIMessageAnchorless(anchorlessMessage({ chat_identifier: "chat349" }))).toBe(false);
});
});
describe("repairIMessageConversationAnchor", () => {
it("passes through non-anchorless messages without recovery RPCs", async () => {
const message = anchorlessMessage({ chat_id: 349, is_group: true });
const client = mockClient([]);
await expect(
repairIMessageConversationAnchor({ client: client as never, message }),
).resolves.toBe(message);
expect(client.request).not.toHaveBeenCalled();
});
it("recovers the conversation from recent history by GUID", async () => {
const message = anchorlessMessage();
const client = mockClient([
{ id: 100, messages: [{ guid: "OTHER-GUID", chat_id: 100, is_group: true }] },
{
id: 349,
messages: [
{
guid: "ANCHORLESS-GUID-1",
chat_id: 349,
chat_guid: "iMessage;+;chat349",
chat_identifier: "chat349",
chat_name: "Project group",
participants: ["+15550001111", "+15550002222"],
is_group: true,
},
],
},
]);
const repaired = await repairIMessageConversationAnchor({
client: client as never,
message,
});
expect(repaired).toMatchObject({
chat_id: 349,
chat_guid: "iMessage;+;chat349",
chat_identifier: "chat349",
chat_name: "Project group",
participants: ["+15550001111", "+15550002222"],
is_group: true,
});
});
it("drops fail-closed when the GUID cannot be matched", async () => {
const runtime = { error: vi.fn() };
const client = mockClient([{ id: 349, messages: [{ guid: "OTHER-GUID", chat_id: 349 }] }]);
await expect(
repairIMessageConversationAnchor({
client: client as never,
message: anchorlessMessage(),
runtime,
}),
).resolves.toBeNull();
expect(runtime.error.mock.calls.at(-1)?.[0]).toContain("no recent chat matched");
});
it("drops fail-closed when history finds the GUID but no usable anchor", async () => {
const runtime = { error: vi.fn() };
const client = mockClient([
{
id: 349,
messages: [
{
guid: "ANCHORLESS-GUID-1",
chat_id: 0,
chat_guid: "",
chat_identifier: "",
is_group: false,
},
],
},
]);
await expect(
repairIMessageConversationAnchor({
client: client as never,
message: anchorlessMessage(),
runtime,
}),
).resolves.toBeNull();
expect(runtime.error.mock.calls.at(-1)?.[0]).toContain("no usable conversation anchor");
});
});

View File

@@ -0,0 +1,168 @@
// Imessage plugin module implements conversation repair behavior.
import type { IMessageRpcClient } from "../client.js";
import type { IMessagePayload } from "./types.js";
const DEFAULT_CHATS_LIMIT = 20;
const DEFAULT_PER_CHAT_HISTORY_LIMIT = 50;
const DEFAULT_RPC_TIMEOUT_MS = 5_000;
type RuntimeLogger = {
error?: (message: string) => void;
log?: (message: string) => void;
};
type ChatsListEntry = {
id?: number | null;
};
type MessagesHistoryResult = {
messages?: unknown[];
};
export type RepairIMessageConversationAnchorParams = {
client: IMessageRpcClient;
message: IMessagePayload;
runtime?: RuntimeLogger;
chatsLimit?: number;
perChatHistoryLimit?: number;
rpcTimeoutMs?: number;
};
function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim() !== "";
}
function hasPositiveChatId(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value) && value > 0;
}
function isExplicitEmptyString(value: unknown): boolean {
return typeof value === "string" && value.trim() === "";
}
export function isIMessageAnchorless(message: IMessagePayload): boolean {
const hasUsableAnchor =
hasPositiveChatId(message.chat_id) ||
isNonEmptyString(message.chat_guid) ||
isNonEmptyString(message.chat_identifier);
if (hasUsableAnchor) {
return false;
}
const hasExplicitBrokenAnchor =
message.chat_id === null ||
(typeof message.chat_id === "number" &&
(!Number.isFinite(message.chat_id) || message.chat_id <= 0)) ||
isExplicitEmptyString(message.chat_guid) ||
isExplicitEmptyString(message.chat_identifier);
return hasExplicitBrokenAnchor;
}
function overlayRecoveredConversation(
message: IMessagePayload,
entry: Record<string, unknown>,
): IMessagePayload {
const repaired = { ...message };
if (hasPositiveChatId(entry.chat_id)) {
repaired.chat_id = entry.chat_id;
}
if (isNonEmptyString(entry.chat_guid)) {
repaired.chat_guid = entry.chat_guid;
}
if (isNonEmptyString(entry.chat_identifier)) {
repaired.chat_identifier = entry.chat_identifier;
}
if (typeof entry.is_group === "boolean") {
repaired.is_group = entry.is_group;
}
if (typeof entry.chat_name === "string") {
repaired.chat_name = entry.chat_name;
}
if (
Array.isArray(entry.participants) &&
entry.participants.every((participant) => typeof participant === "string")
) {
repaired.participants = entry.participants;
}
return repaired;
}
export async function repairIMessageConversationAnchor(
params: RepairIMessageConversationAnchorParams,
): Promise<IMessagePayload | null> {
const { client, message, runtime } = params;
if (!isIMessageAnchorless(message)) {
return message;
}
const guid = message.guid?.trim();
if (!guid) {
runtime?.error?.("imessage: dropping anchorless message without GUID");
return null;
}
let chatsResult: { chats?: ChatsListEntry[] } | undefined;
try {
chatsResult = await client.request<{ chats?: ChatsListEntry[] }>(
"chats.list",
{ limit: params.chatsLimit ?? DEFAULT_CHATS_LIMIT },
{ timeoutMs: params.rpcTimeoutMs ?? DEFAULT_RPC_TIMEOUT_MS },
);
} catch (err) {
runtime?.error?.(`imessage: anchorless message recovery failed listing chats: ${String(err)}`);
return null;
}
const chats = chatsResult?.chats ?? [];
for (const chat of chats) {
const chatId = hasPositiveChatId(chat.id) ? chat.id : null;
if (chatId === null) {
continue;
}
let historyResult: MessagesHistoryResult | undefined;
try {
historyResult = await client.request<MessagesHistoryResult>(
"messages.history",
{
attachments: false,
chat_id: chatId,
limit: params.perChatHistoryLimit ?? DEFAULT_PER_CHAT_HISTORY_LIMIT,
},
{ timeoutMs: params.rpcTimeoutMs ?? DEFAULT_RPC_TIMEOUT_MS },
);
} catch {
continue;
}
const messages = Array.isArray(historyResult?.messages) ? historyResult.messages : [];
for (const raw of messages) {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
continue;
}
const entry = raw as Record<string, unknown>;
if (entry.guid !== guid) {
continue;
}
const repaired = overlayRecoveredConversation(message, entry);
if (isIMessageAnchorless(repaired)) {
runtime?.error?.(
`imessage: dropping anchorless message GUID=${guid} after recovery found no usable conversation anchor`,
);
return null;
}
runtime?.log?.(
`imessage: recovered anchorless message GUID=${guid} chat_id=${repaired.chat_id ?? "unknown"} is_group=${repaired.is_group === true}`,
);
return repaired;
}
}
runtime?.error?.(`imessage: dropping anchorless message GUID=${guid}; no recent chat matched`);
return null;
}

View File

@@ -0,0 +1,4 @@
// Imessage plugin module implements deliver behavior.
export { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime";
export { chunkTextWithMode, resolveChunkMode } from "openclaw/plugin-sdk/reply-runtime";
export { convertMarkdownTables } from "openclaw/plugin-sdk/text-chunking";

View File

@@ -0,0 +1,250 @@
// Imessage tests cover deliver plugin behavior.
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const sendMessageIMessageMock = vi.hoisted(() =>
vi.fn().mockImplementation(async (_to: string, message: string) => ({
messageId: "imsg-1",
sentText: message,
})),
);
const chunkTextWithModeMock = vi.hoisted(() => vi.fn((text: string) => [text]));
const resolveChunkModeMock = vi.hoisted(() => vi.fn(() => "length"));
const convertMarkdownTablesMock = vi.hoisted(() => vi.fn((text: string) => text));
const resolveMarkdownTableModeMock = vi.hoisted(() => vi.fn(() => "code"));
vi.mock("../send.js", () => ({
sendMessageIMessage: (to: string, message: string, opts?: unknown) =>
sendMessageIMessageMock(to, message, opts),
}));
vi.mock("./deliver.runtime.js", () => ({
resolveMarkdownTableMode: vi.fn(() => resolveMarkdownTableModeMock()),
chunkTextWithMode: (text: string) => chunkTextWithModeMock(text),
resolveChunkMode: vi.fn(() => resolveChunkModeMock()),
convertMarkdownTables: (text: string) => convertMarkdownTablesMock(text),
}));
let deliverReplies: typeof import("./deliver.js").deliverReplies;
let createIMessageEchoCachingSend: typeof import("./deliver.js").createIMessageEchoCachingSend;
describe("deliverReplies", () => {
const IMESSAGE_TEST_CFG = { channels: { imessage: { accounts: { default: {} } } } };
const runtime = { log: vi.fn(), error: vi.fn() } as unknown as RuntimeEnv;
beforeAll(async () => {
({ createIMessageEchoCachingSend, deliverReplies } = await import("./deliver.js"));
});
beforeEach(() => {
vi.clearAllMocks();
chunkTextWithModeMock.mockImplementation((text: string) => [text]);
});
afterAll(() => {
vi.doUnmock("../send.js");
vi.doUnmock("./deliver.runtime.js");
vi.resetModules();
});
it("sends monitor text chunks without reusing the watch rpc client", async () => {
chunkTextWithModeMock.mockImplementation((text: string) => text.split("|"));
await deliverReplies({
cfg: IMESSAGE_TEST_CFG,
replies: [{ text: "first|second", replyToId: "reply-1" }],
target: "chat_id:10",
accountId: "default",
runtime,
maxBytes: 4096,
textLimit: 4000,
});
expect(sendMessageIMessageMock).toHaveBeenCalledTimes(2);
expect(sendMessageIMessageMock.mock.calls).toStrictEqual([
[
"chat_id:10",
"first",
expect.objectContaining({
config: IMESSAGE_TEST_CFG,
maxBytes: 4096,
accountId: "default",
replyToId: "reply-1",
}),
],
[
"chat_id:10",
"second",
expect.objectContaining({
config: IMESSAGE_TEST_CFG,
maxBytes: 4096,
accountId: "default",
replyToId: "reply-1",
}),
],
]);
});
it("propagates payload replyToId through media sends", async () => {
await deliverReplies({
cfg: IMESSAGE_TEST_CFG,
replies: [
{
text: "caption",
mediaUrls: ["https://example.com/a.jpg", "https://example.com/b.jpg"],
replyToId: "reply-2",
},
],
target: "chat_id:20",
accountId: "acct-2",
runtime,
maxBytes: 8192,
textLimit: 4000,
});
expect(sendMessageIMessageMock).toHaveBeenCalledTimes(2);
expect(sendMessageIMessageMock.mock.calls).toStrictEqual([
[
"chat_id:20",
"caption",
expect.objectContaining({
config: IMESSAGE_TEST_CFG,
mediaUrl: "https://example.com/a.jpg",
maxBytes: 8192,
accountId: "acct-2",
replyToId: "reply-2",
}),
],
[
"chat_id:20",
"",
expect.objectContaining({
config: IMESSAGE_TEST_CFG,
mediaUrl: "https://example.com/b.jpg",
maxBytes: 8192,
accountId: "acct-2",
replyToId: "reply-2",
}),
],
]);
});
it("records durable outbound sends in the sent-message cache", async () => {
const remember = vi.fn();
const send = createIMessageEchoCachingSend({
accountId: "acct-5",
sentMessageCache: { remember },
});
sendMessageIMessageMock.mockResolvedValueOnce({
messageId: "imsg-durable-1",
sentText: "durable hello",
});
await send("chat_id:50", "durable hello", {
config: IMESSAGE_TEST_CFG,
accountId: "acct-ignored",
});
expect(sendMessageIMessageMock.mock.calls).toStrictEqual([
[
"chat_id:50",
"durable hello",
expect.objectContaining({
config: IMESSAGE_TEST_CFG,
accountId: "acct-ignored",
}),
],
]);
expect(remember).toHaveBeenCalledWith("acct-5:chat_id:50", {
text: "durable hello",
messageId: "imsg-durable-1",
});
});
it("sanitizes durable outbound text before sending", async () => {
const remember = vi.fn();
const send = createIMessageEchoCachingSend({
accountId: "acct-6",
sentMessageCache: { remember },
});
sendMessageIMessageMock.mockResolvedValueOnce({
messageId: "imsg-durable-2",
sentText: "Visible reply",
});
await send("chat_id:60", "<thinking>hidden</thinking>\nVisible reply\nassistant:", {
config: IMESSAGE_TEST_CFG,
accountId: "acct-ignored",
});
expect(sendMessageIMessageMock.mock.calls).toStrictEqual([
[
"chat_id:60",
"Visible reply",
expect.objectContaining({
config: IMESSAGE_TEST_CFG,
accountId: "acct-ignored",
}),
],
]);
expect(remember).toHaveBeenCalledWith("acct-6:chat_id:60", {
text: "Visible reply",
messageId: "imsg-durable-2",
});
});
it("records outbound text and message ids in sent-message cache after send", async () => {
// Fix for #47830: monitor cache population remains per chunk, never the
// full un-chunked text before sending begins.
const remember = vi.fn();
chunkTextWithModeMock.mockImplementation((text: string) => text.split("|"));
sendMessageIMessageMock
.mockResolvedValueOnce({ messageId: "imsg-1", sentText: "first" })
.mockResolvedValueOnce({ messageId: "imsg-2", sentText: "second" });
await deliverReplies({
cfg: IMESSAGE_TEST_CFG,
replies: [{ text: "first|second" }],
target: "chat_id:30",
accountId: "acct-3",
runtime,
maxBytes: 2048,
textLimit: 4000,
sentMessageCache: { remember },
});
expect(remember).toHaveBeenCalledTimes(2);
expect(remember.mock.calls).toStrictEqual([
["acct-3:chat_id:30", { text: "first", messageId: "imsg-1" }],
["acct-3:chat_id:30", { text: "second", messageId: "imsg-2" }],
]);
expect(remember).not.toHaveBeenCalledWith("acct-3:chat_id:30", {
text: "first|second",
});
});
it("records the internal echo key for media-only replies", async () => {
const remember = vi.fn();
sendMessageIMessageMock.mockResolvedValueOnce({
messageId: "imsg-media-1",
sentText: "",
echoText: "<media:image>",
});
await deliverReplies({
cfg: IMESSAGE_TEST_CFG,
replies: [{ mediaUrls: ["https://example.com/a.jpg"] }],
target: "chat_id:40",
accountId: "acct-4",
runtime,
maxBytes: 2048,
textLimit: 4000,
sentMessageCache: { remember },
});
expect(remember).toHaveBeenCalledWith("acct-4:chat_id:40", {
text: "<media:image>",
messageId: "imsg-media-1",
});
});
});

View File

@@ -0,0 +1,93 @@
// Imessage plugin module implements deliver behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
deliverTextOrMediaReply,
resolveSendableOutboundReplyParts,
} from "openclaw/plugin-sdk/reply-payload";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { sendMessageIMessage } from "../send.js";
import {
chunkTextWithMode,
convertMarkdownTables,
resolveChunkMode,
resolveMarkdownTableMode,
} from "./deliver.runtime.js";
import type { SentMessageCache } from "./echo-cache.js";
import { sanitizeOutboundText } from "./sanitize-outbound.js";
export async function deliverReplies(params: {
cfg: OpenClawConfig;
replies: ReplyPayload[];
target: string;
accountId?: string;
runtime: RuntimeEnv;
maxBytes: number;
textLimit: number;
sentMessageCache?: Pick<SentMessageCache, "remember">;
}) {
const { replies, target, runtime, maxBytes, textLimit, accountId, sentMessageCache } = params;
const scope = `${accountId ?? ""}:${target}`;
const { cfg } = params;
const tableMode = resolveMarkdownTableMode({
cfg,
channel: "imessage",
accountId,
});
const chunkMode = resolveChunkMode(cfg, "imessage", accountId);
for (const payload of replies) {
const rawText = sanitizeOutboundText(payload.text ?? "");
const reply = resolveSendableOutboundReplyParts(payload, {
text: convertMarkdownTables(rawText, tableMode),
});
const delivered = await deliverTextOrMediaReply({
payload,
text: reply.text,
chunkText: (value) => chunkTextWithMode(value, textLimit, chunkMode),
sendText: async (chunk) => {
const sent = await sendMessageIMessage(target, chunk, {
config: params.cfg,
maxBytes,
accountId,
replyToId: payload.replyToId,
});
sentMessageCache?.remember(scope, {
text: sent.echoText ?? sent.sentText,
messageId: sent.messageId,
});
},
sendMedia: async ({ mediaUrl, caption }) => {
const sent = await sendMessageIMessage(target, caption ?? "", {
config: params.cfg,
mediaUrl,
maxBytes,
accountId,
replyToId: payload.replyToId,
});
sentMessageCache?.remember(scope, {
text: sent.echoText ?? (sent.sentText || undefined),
messageId: sent.messageId,
});
},
});
if (delivered !== "empty") {
runtime.log?.(`imessage: delivered reply to ${target}`);
}
}
}
export function createIMessageEchoCachingSend(params: {
accountId?: string;
sentMessageCache?: Pick<SentMessageCache, "remember">;
}): typeof sendMessageIMessage {
return async (target, text, opts) => {
const sanitizedText = sanitizeOutboundText(text);
const sent = await sendMessageIMessage(target, sanitizedText, opts);
const scope = `${params.accountId ?? opts.accountId ?? ""}:${target}`;
params.sentMessageCache?.remember(scope, {
text: sent.echoText ?? (sent.sentText || undefined),
messageId: sent.messageId,
});
return sent;
};
}

View File

@@ -0,0 +1,103 @@
// Imessage tests cover dm history plugin behavior.
import { resolveEnvelopeFormatOptions } from "openclaw/plugin-sdk/channel-inbound";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { describe, expect, it, vi } from "vitest";
import type { IMessageRpcClient } from "../client.js";
import { resolveIMessageDmHistoryContext, resolveIMessageDmHistoryLimit } from "./dm-history.js";
describe("resolveIMessageDmHistoryLimit", () => {
it("uses per-DM history overrides before the provider default", () => {
expect(
resolveIMessageDmHistoryLimit({
config: {
dmHistoryLimit: 5,
dms: {
"+15555550123": { historyLimit: 2 },
},
},
sender: "+1 (555) 555-0123",
senderNormalized: "+15555550123",
}),
).toBe(2);
});
it("defaults to disabled when no iMessage DM history limit is configured", () => {
expect(resolveIMessageDmHistoryLimit({ config: {}, sender: "+15555550123" })).toBe(0);
});
});
describe("resolveIMessageDmHistoryContext", () => {
it("fetches decoded imsg history rows and excludes the current message", async () => {
const request = vi.fn(async () => ({
messages: [
{
id: 8,
guid: "previous-in",
chat_id: 44,
sender: "+15555550123",
is_from_me: false,
text: "earlier inbound",
created_at: "2026-05-25T12:00:00.000Z",
is_group: false,
},
{
id: 9,
guid: "previous-out",
chat_id: 44,
sender: null,
is_from_me: true,
text: "earlier outbound",
created_at: "2026-05-25T12:01:00.000Z",
is_group: false,
},
{
id: 10,
guid: "current",
chat_id: 44,
sender: "+15555550123",
is_from_me: false,
text: "current",
created_at: "2026-05-25T12:02:00.000Z",
is_group: false,
},
],
}));
const context = await resolveIMessageDmHistoryContext({
client: { request } as unknown as IMessageRpcClient,
message: {
id: 10,
guid: "current",
chat_id: 44,
sender: "+15555550123",
text: "current",
is_from_me: false,
is_group: false,
},
senderNormalized: "+15555550123",
limit: 2,
envelopeOptions: resolveEnvelopeFormatOptions({} as OpenClawConfig),
});
expect(request).toHaveBeenCalledWith(
"messages.history",
{ chat_id: 44, limit: 3, attachments: false },
{ timeoutMs: 10_000 },
);
expect(context.inboundHistory).toEqual([
{
sender: "+15555550123",
body: "earlier inbound",
timestamp: Date.parse("2026-05-25T12:00:00.000Z"),
},
{
sender: "Me",
body: "earlier outbound",
timestamp: Date.parse("2026-05-25T12:01:00.000Z"),
},
]);
expect(context.body).toContain("earlier inbound");
expect(context.body).toContain("earlier outbound");
expect(context.body).not.toContain("current");
});
});

View File

@@ -0,0 +1,167 @@
// Imessage plugin module implements dm history behavior.
import {
formatInboundEnvelope,
type resolveEnvelopeFormatOptions,
} from "openclaw/plugin-sdk/channel-inbound";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { IMessageRpcClient } from "../client.js";
import { normalizeIMessageHandle } from "../targets.js";
import { parseIMessageNotification } from "./parse-notification.js";
import type { IMessagePayload } from "./types.js";
const DM_HISTORY_RPC_TIMEOUT_MS = 10_000;
type IMessageHistoryResult = {
messages?: unknown[];
};
type IMessageDmHistoryConfig = {
dmHistoryLimit?: number;
dms?: Record<string, { historyLimit?: number }>;
};
export type IMessageDmHistoryEntry = {
sender: string;
body: string;
timestamp?: number;
};
export type IMessageDmHistoryContext = {
body?: string;
inboundHistory?: IMessageDmHistoryEntry[];
};
export function resolveIMessageDmHistoryLimit(params: {
config: IMessageDmHistoryConfig;
sender?: string;
senderNormalized?: string;
}): number {
const senderCandidates = [
normalizeOptionalString(params.senderNormalized),
normalizeOptionalString(params.sender),
params.sender ? normalizeIMessageHandle(params.sender) : undefined,
].filter((candidate): candidate is string => Boolean(candidate));
for (const candidate of senderCandidates) {
const override = params.config.dms?.[candidate]?.historyLimit;
if (override !== undefined) {
return Math.max(0, override);
}
}
return Math.max(0, params.config.dmHistoryLimit ?? 0);
}
function historyRowSortValue(message: IMessagePayload): number {
if (typeof message.id === "number" && Number.isFinite(message.id)) {
return message.id;
}
const createdAtMs =
typeof message.created_at === "string" ? Date.parse(message.created_at) : Number.NaN;
return Number.isFinite(createdAtMs) ? createdAtMs : 0;
}
function isBeforeCurrentMessage(params: {
message: IMessagePayload;
currentMessage: IMessagePayload;
}): boolean {
const { message, currentMessage } = params;
if (
typeof message.id === "number" &&
typeof currentMessage.id === "number" &&
Number.isFinite(message.id) &&
Number.isFinite(currentMessage.id)
) {
return message.id < currentMessage.id;
}
const guid = normalizeOptionalString(message.guid);
const currentGuid = normalizeOptionalString(currentMessage.guid);
if (guid && currentGuid) {
return guid !== currentGuid;
}
return true;
}
function historyEntryFromMessage(message: IMessagePayload, fallbackSender: string) {
const body = normalizeOptionalString(message.text);
if (!body) {
return null;
}
const timestamp =
typeof message.created_at === "string" ? Date.parse(message.created_at) : Number.NaN;
return {
sender:
message.is_from_me === true
? "Me"
: normalizeIMessageHandle(normalizeOptionalString(message.sender) ?? fallbackSender) ||
fallbackSender,
body,
...(Number.isFinite(timestamp) ? { timestamp } : {}),
};
}
export async function resolveIMessageDmHistoryContext(params: {
client: IMessageRpcClient;
message: IMessagePayload;
senderNormalized: string;
limit: number;
envelopeOptions: ReturnType<typeof resolveEnvelopeFormatOptions>;
logVerbose?: (msg: string) => void;
}): Promise<IMessageDmHistoryContext> {
const maxMessages = Math.max(0, Math.floor(params.limit));
const chatId =
typeof params.message.chat_id === "number" && Number.isFinite(params.message.chat_id)
? params.message.chat_id
: undefined;
if (maxMessages <= 0 || chatId === undefined) {
return {};
}
let result: IMessageHistoryResult | undefined;
try {
result = await params.client.request<IMessageHistoryResult>(
"messages.history",
{
chat_id: chatId,
limit: maxMessages + 1,
attachments: false,
},
{ timeoutMs: DM_HISTORY_RPC_TIMEOUT_MS },
);
} catch (err) {
params.logVerbose?.(`imessage: DM history fetch failed for chat_id=${chatId}: ${String(err)}`);
return {};
}
const rows = Array.isArray(result?.messages) ? result.messages : [];
const history = rows
.map((row) => parseIMessageNotification({ message: row }))
.filter((message): message is IMessagePayload => Boolean(message))
.filter((message) => message.is_group !== true)
.filter((message) => isBeforeCurrentMessage({ message, currentMessage: params.message }))
.toSorted((a, b) => historyRowSortValue(a) - historyRowSortValue(b))
.map((message) => historyEntryFromMessage(message, params.senderNormalized))
.filter((entry): entry is IMessageDmHistoryEntry => Boolean(entry))
.slice(-maxMessages);
if (history.length === 0) {
return {};
}
return {
inboundHistory: history,
body: history
.map((entry) =>
formatInboundEnvelope({
channel: "iMessage",
from: entry.sender,
timestamp: entry.timestamp,
body: entry.body,
chatType: "direct",
senderLabel: entry.sender,
envelope: params.envelopeOptions,
}),
)
.join("\n\n"),
};
}

View File

@@ -0,0 +1,146 @@
// Imessage plugin module implements echo cache behavior.
import { stripLeadingEchoTextCorruptionMarkers } from "./echo-text-corruption.js";
import { hasPersistedIMessageEcho } from "./persisted-echo-cache.js";
type SentMessageLookup = {
text?: string;
messageId?: string;
};
type SentMessageLookupOptions = {
skipIdShortCircuit?: boolean;
includePendingText?: boolean;
};
export type SentMessageCache = {
remember: (scope: string, lookup: SentMessageLookup) => void;
/**
* Check whether an inbound message matches a recently-sent outbound message.
*
* @param skipIdShortCircuit - When true, skip the early return on message-ID
* mismatch and fall through to text-based matching. Use this for self-chat
* `is_from_me=true` messages where the inbound ID is a numeric SQLite row ID
* that will never match the GUID outbound IDs, but text matching is still
* the right way to identify agent reply echoes.
*/
has: (
scope: string,
lookup: SentMessageLookup,
options?: boolean | SentMessageLookupOptions,
) => boolean;
};
// Echo arrival observed at ~2.2s on M4 Mac Mini (SQLite poll interval is the bottleneck).
// 4s provides ~80% margin. If echoes arrive after TTL expiry, the system degrades to
// duplicate delivery (noisy but not lossy) — never message loss.
const SENT_MESSAGE_TEXT_TTL_MS = 4_000;
const SENT_MESSAGE_ID_TTL_MS = 60_000;
function normalizeEchoTextKey(text: string | undefined): string | null {
if (!text) {
return null;
}
const normalized = stripLeadingEchoTextCorruptionMarkers(
text.replace(/\r\n?/g, "\n").trim(),
).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<string, number>();
private textBackedByIdCache = new Map<string, number>();
private messageIdCache = new Map<string, number>();
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());
if (textKey) {
this.textBackedByIdCache.set(`${scope}:${textKey}`, Date.now());
}
}
this.cleanup();
}
has(
scope: string,
lookup: SentMessageLookup,
options: boolean | SentMessageLookupOptions = false,
): boolean {
this.cleanup();
const resolvedOptions =
typeof options === "boolean" ? { skipIdShortCircuit: options } : options;
if (
hasPersistedIMessageEcho({
scope,
...lookup,
includePendingText: resolvedOptions.includePendingText,
})
) {
return true;
}
const textKey = normalizeEchoTextKey(lookup.text);
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 textTimestamp = textKey ? this.textCache.get(`${scope}:${textKey}`) : undefined;
const textBackedByIdTimestamp = textKey
? this.textBackedByIdCache.get(`${scope}:${textKey}`)
: undefined;
const hasTextOnlyMatch =
typeof textTimestamp === "number" &&
(!textBackedByIdTimestamp || textTimestamp > textBackedByIdTimestamp);
if (!resolvedOptions.skipIdShortCircuit && !hasTextOnlyMatch) {
return false;
}
}
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.textBackedByIdCache.entries()) {
if (now - timestamp > SENT_MESSAGE_TEXT_TTL_MS) {
this.textBackedByIdCache.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();
}

View File

@@ -0,0 +1,19 @@
// Imessage plugin shared helper: strip leading attributedBody corruption markers from echo text.
// The in-memory (echo-cache) and persisted (persisted-echo-cache) echo-dedupe paths must normalize
// identically, so a reflected own-message echo whose attributedBody decoded with a leading
// NUL/replacement marker still matches the clean stored send. Kept here (a leaf module with no
// imports) so the persisted path can reuse it without an echo-cache <-> persisted-echo-cache cycle.
function isLeadingEchoTextCorruptionMarker(code: number): boolean {
return (
code === 0x0000 || code === 0xfeff || code === 0xfffd || code === 0xfffe || code === 0xffff
);
}
export function stripLeadingEchoTextCorruptionMarkers(text: string): string {
let offset = 0;
while (offset < text.length && isLeadingEchoTextCorruptionMarker(text.charCodeAt(offset))) {
offset += 1;
}
return offset === 0 ? text : text.slice(offset);
}

View File

@@ -0,0 +1,160 @@
// Imessage tests cover group allowlist warnings plugin behavior.
import { beforeEach, describe, expect, it } from "vitest";
import {
resetGroupAllowlistWarningsForTesting,
warnGroupAllowlistDropPerChatOnce,
warnGroupAllowlistMisconfigOnce,
} from "./group-allowlist-warnings.js";
beforeEach(() => {
resetGroupAllowlistWarningsForTesting();
});
describe("warnGroupAllowlistMisconfigOnce", () => {
it("fires when groupPolicy=allowlist and groups is undefined", () => {
const messages: string[] = [];
const fired = warnGroupAllowlistMisconfigOnce({
groupPolicy: "allowlist",
groups: undefined,
accountId: "default",
log: (m) => messages.push(m),
});
expect(fired).toBe(true);
expect(messages).toHaveLength(1);
expect(messages[0]).toContain('groupPolicy="allowlist"');
expect(messages[0]).toContain("channels.imessage.groups is empty");
expect(messages[0]).toContain("default");
});
it("fires when groupPolicy=allowlist and groups is empty object", () => {
const messages: string[] = [];
const fired = warnGroupAllowlistMisconfigOnce({
groupPolicy: "allowlist",
groups: {},
accountId: "default",
log: (m) => messages.push(m),
});
expect(fired).toBe(true);
expect(messages).toHaveLength(1);
});
it("does not fire when groupPolicy is not allowlist", () => {
const messages: string[] = [];
const fired = warnGroupAllowlistMisconfigOnce({
groupPolicy: "open",
groups: undefined,
accountId: "default",
log: (m) => messages.push(m),
});
expect(fired).toBe(false);
expect(messages).toHaveLength(0);
});
it("does not fire when groups has a wildcard entry", () => {
const messages: string[] = [];
const fired = warnGroupAllowlistMisconfigOnce({
groupPolicy: "allowlist",
groups: { "*": { requireMention: true } },
accountId: "default",
log: (m) => messages.push(m),
});
expect(fired).toBe(false);
expect(messages).toHaveLength(0);
});
it("does not fire when groups has explicit chat_id entries", () => {
const messages: string[] = [];
const fired = warnGroupAllowlistMisconfigOnce({
groupPolicy: "allowlist",
groups: { "12345": {} },
accountId: "default",
log: (m) => messages.push(m),
});
expect(fired).toBe(false);
expect(messages).toHaveLength(0);
});
it("only fires once per accountId", () => {
const messages: string[] = [];
const log = (m: string) => messages.push(m);
expect(
warnGroupAllowlistMisconfigOnce({
groupPolicy: "allowlist",
groups: undefined,
accountId: "default",
log,
}),
).toBe(true);
expect(
warnGroupAllowlistMisconfigOnce({
groupPolicy: "allowlist",
groups: undefined,
accountId: "default",
log,
}),
).toBe(false);
expect(messages).toHaveLength(1);
});
it("fires separately for distinct accountIds", () => {
const messages: string[] = [];
const log = (m: string) => messages.push(m);
warnGroupAllowlistMisconfigOnce({
groupPolicy: "allowlist",
groups: undefined,
accountId: "primary",
log,
});
warnGroupAllowlistMisconfigOnce({
groupPolicy: "allowlist",
groups: undefined,
accountId: "secondary",
log,
});
expect(messages).toHaveLength(2);
});
});
describe("warnGroupAllowlistDropPerChatOnce", () => {
it("fires once per accountId:chat_id pair", () => {
const messages: string[] = [];
const log = (m: string) => messages.push(m);
expect(warnGroupAllowlistDropPerChatOnce({ accountId: "default", chatId: 42, log })).toBe(true);
expect(warnGroupAllowlistDropPerChatOnce({ accountId: "default", chatId: 42, log })).toBe(
false,
);
expect(messages).toHaveLength(1);
expect(messages[0]).toContain("chat_id=42");
expect(messages[0]).toContain("default");
expect(messages[0]).toContain('channels.imessage.groups["42"]');
});
it("fires separately for distinct chat_ids on the same account", () => {
const messages: string[] = [];
const log = (m: string) => messages.push(m);
warnGroupAllowlistDropPerChatOnce({ accountId: "default", chatId: 1, log });
warnGroupAllowlistDropPerChatOnce({ accountId: "default", chatId: 2, log });
warnGroupAllowlistDropPerChatOnce({ accountId: "default", chatId: 2, log });
expect(messages).toHaveLength(2);
});
it("treats numeric and string chat_ids as the same key", () => {
const messages: string[] = [];
const log = (m: string) => messages.push(m);
warnGroupAllowlistDropPerChatOnce({ accountId: "default", chatId: 42, log });
warnGroupAllowlistDropPerChatOnce({ accountId: "default", chatId: "42", log });
expect(messages).toHaveLength(1);
});
it("skips when chat_id is undefined or empty", () => {
const messages: string[] = [];
const log = (m: string) => messages.push(m);
expect(
warnGroupAllowlistDropPerChatOnce({ accountId: "default", chatId: undefined, log }),
).toBe(false);
expect(warnGroupAllowlistDropPerChatOnce({ accountId: "default", chatId: "", log })).toBe(
false,
);
expect(messages).toHaveLength(0);
});
});

View File

@@ -0,0 +1,79 @@
// Group-allowlist visibility helpers. The runtime gate at line ~336 of
// inbound-processing.ts drops every group message when groupPolicy="allowlist"
// and channels.imessage.groups is missing. Without these warnings the drop is
// invisible at default log level during iMessage config migration. See
// https://github.com/openclaw/openclaw/issues/78749.
type GroupsConfig = Record<
string,
{ requireMention?: boolean; tools?: unknown; toolsBySender?: unknown }
>;
const startupWarned = new Set<string>();
const perChatWarned = new Set<string>();
/**
* Fires once per `accountId` at monitor startup when `groupPolicy === "allowlist"`
* but `channels.imessage.groups` is empty (no `"*"` wildcard, no explicit
* `chat_id` entries). Without one of those, every group message is dropped at
* the second gate even when the sender passes `groupAllowFrom`.
*/
export function warnGroupAllowlistMisconfigOnce(params: {
groupPolicy: string;
groups: GroupsConfig | undefined;
accountId: string;
log: (message: string) => void;
}): boolean {
if (params.groupPolicy !== "allowlist") {
return false;
}
const entries = params.groups ? Object.keys(params.groups) : [];
if (entries.length > 0) {
return false;
}
const key = `imessage:${params.accountId}`;
if (startupWarned.has(key)) {
return false;
}
startupWarned.add(key);
params.log(
`imessage: groupPolicy="allowlist" but channels.imessage.groups is empty for account "${params.accountId}". ` +
`Every inbound group message will be dropped. ` +
`Add channels.imessage.groups["*"] = { requireMention: true } to allow all groups, ` +
`or explicit per-chat_id entries to allow specific groups.`,
);
return true;
}
/**
* Fires once per `accountId:chat_id` when the runtime allowlist gate drops a
* group message because that chat_id is not in `channels.imessage.groups`.
* Bounded by the number of distinct group chats the gateway sees.
*/
export function warnGroupAllowlistDropPerChatOnce(params: {
accountId: string;
chatId: string | number | undefined;
log: (message: string) => void;
}): boolean {
const chat = params.chatId == null ? "" : String(params.chatId).trim();
if (!chat) {
return false;
}
const key = `imessage:${params.accountId}:${chat}`;
if (perChatWarned.has(key)) {
return false;
}
perChatWarned.add(key);
params.log(
`imessage: dropping group message from chat_id=${chat} (account "${params.accountId}") — ` +
`not in channels.imessage.groups allowlist. ` +
`Add channels.imessage.groups["${chat}"] or channels.imessage.groups["*"] to allow it.`,
);
return true;
}
/** Test helper. Keeps warning-cache state deterministic across test files. */
export function resetGroupAllowlistWarningsForTesting(): void {
startupWarned.clear();
perChatWarned.clear();
}

View File

@@ -0,0 +1,177 @@
// Imessage tests cover inbound dedupe + stale-backlog age fence behavior.
import { beforeEach, describe, expect, it } from "vitest";
import { installIMessageStateRuntimeForTest } from "../test-support/runtime.js";
import {
buildIMessageInboundReplayKey,
claimIMessageInboundReplay,
commitIMessageInboundReplay,
createIMessageInboundReplayGuard,
IMESSAGE_STALE_INBOUND_THRESHOLD_MS,
isStaleIMessageBacklog,
releaseIMessageInboundReplay,
} from "./inbound-dedupe.js";
import type { IMessagePayload } from "./types.js";
function payload(overrides: Partial<IMessagePayload> = {}): IMessagePayload {
return {
id: 1,
guid: "GUID-1",
sender: "+15550001111",
chat_id: 42,
text: "hello",
created_at: "2026-05-30T05:23:00.000Z",
...overrides,
} as IMessagePayload;
}
describe("buildIMessageInboundReplayKey", () => {
it("prefers the GUID", () => {
expect(buildIMessageInboundReplayKey({ accountId: "default", message: payload() })).toBe(
"default:guid:GUID-1",
);
});
it("falls back to a bounded composite key when the GUID is absent", () => {
const key = buildIMessageInboundReplayKey({
accountId: "default",
message: payload({ guid: undefined }),
});
// Hashed composite: account-scoped prefix + 32-hex digest, length-bounded
// regardless of message text length.
expect(key).toMatch(/^default:c:[0-9a-f]{32}$/);
});
it("keeps the composite key bounded for very long text", () => {
const key = buildIMessageInboundReplayKey({
accountId: "default",
message: payload({ guid: undefined, text: "x".repeat(20_000) }),
});
expect(key).toMatch(/^default:c:[0-9a-f]{32}$/);
expect((key ?? "").length).toBeLessThan(60);
});
it("derives distinct composite keys for distinct GUID-less rows", () => {
const a = buildIMessageInboundReplayKey({
accountId: "default",
message: payload({ guid: undefined, text: "hello" }),
});
const b = buildIMessageInboundReplayKey({
accountId: "default",
message: payload({ guid: undefined, text: "world" }),
});
expect(a).not.toBe(b);
});
it("returns null (fail open) when the message cannot be identified", () => {
expect(
buildIMessageInboundReplayKey({
accountId: "default",
message: payload({ guid: undefined, sender: undefined }),
}),
).toBeNull();
});
it("scopes keys by account so two accounts never collide on the same GUID", () => {
const a = buildIMessageInboundReplayKey({ accountId: "work", message: payload() });
const b = buildIMessageInboundReplayKey({ accountId: "home", message: payload() });
expect(a).not.toBe(b);
});
});
describe("isStaleIMessageBacklog", () => {
const now = Date.parse("2026-05-30T05:23:18.000Z");
it("suppresses a row whose send date is well past the threshold", () => {
expect(isStaleIMessageBacklog(payload({ created_at: "2023-08-09T03:45:59.000Z" }), now)).toBe(
true,
);
});
it("passes a fresh live row", () => {
expect(isStaleIMessageBacklog(payload({ created_at: "2026-05-30T05:23:00.000Z" }), now)).toBe(
false,
);
});
it("uses the threshold boundary (older-than, not equal)", () => {
const atThreshold = new Date(now - IMESSAGE_STALE_INBOUND_THRESHOLD_MS).toISOString();
expect(isStaleIMessageBacklog(payload({ created_at: atThreshold }), now)).toBe(false);
const pastThreshold = new Date(now - IMESSAGE_STALE_INBOUND_THRESHOLD_MS - 1).toISOString();
expect(isStaleIMessageBacklog(payload({ created_at: pastThreshold }), now)).toBe(true);
});
it("fails open when the send date is missing or unparseable", () => {
expect(isStaleIMessageBacklog(payload({ created_at: undefined }), now)).toBe(false);
expect(isStaleIMessageBacklog(payload({ created_at: "not-a-date" }), now)).toBe(false);
});
});
describe("createIMessageInboundReplayGuard claim/commit/release", () => {
beforeEach(() => {
installIMessageStateRuntimeForTest();
});
it("claims a key, and a committed key blocks a later claim as a duplicate", async () => {
const guard = createIMessageInboundReplayGuard();
const message = payload({ guid: "GUID-DEDUPE" });
const first = await claimIMessageInboundReplay({ guard, accountId: "default", message });
expect(first.claimed).toBe(true);
expect(first.key).toBe("default:guid:GUID-DEDUPE");
await commitIMessageInboundReplay({
guard,
accountId: "default",
keys: first.key ? [first.key] : [],
});
const second = await claimIMessageInboundReplay({ guard, accountId: "default", message });
expect(second.claimed).toBe(false);
});
it("a released claim is reclaimable so a transient failure can retry", async () => {
const guard = createIMessageInboundReplayGuard();
const message = payload({ guid: "GUID-RETRY" });
const first = await claimIMessageInboundReplay({ guard, accountId: "default", message });
expect(first.claimed).toBe(true);
releaseIMessageInboundReplay({
guard,
accountId: "default",
keys: first.key ? [first.key] : [],
});
const second = await claimIMessageInboundReplay({ guard, accountId: "default", message });
expect(second.claimed).toBe(true);
});
it("a held (uncommitted) claim reports a concurrent duplicate as not claimed", async () => {
const guard = createIMessageInboundReplayGuard();
const message = payload({ guid: "GUID-INFLIGHT" });
const first = await claimIMessageInboundReplay({ guard, accountId: "default", message });
expect(first.claimed).toBe(true);
// Second claim while the first is still in flight (not yet committed).
const second = await claimIMessageInboundReplay({ guard, accountId: "default", message });
expect(second.claimed).toBe(false);
});
it("round-trips the composite claim key for a GUID-less row", async () => {
// Regression guard: the exact claimed key (composite, no GUID) must be the
// one committed, or a GUID-less coalesced row would leak an in-flight claim.
const guard = createIMessageInboundReplayGuard();
const message = payload({ guid: undefined });
const first = await claimIMessageInboundReplay({ guard, accountId: "default", message });
expect(first.claimed).toBe(true);
expect(first.key).toBe(buildIMessageInboundReplayKey({ accountId: "default", message }));
await commitIMessageInboundReplay({
guard,
accountId: "default",
keys: first.key ? [first.key] : [],
});
const second = await claimIMessageInboundReplay({ guard, accountId: "default", message });
expect(second.claimed).toBe(false);
});
it("fails open: an unidentifiable message claims with no key", async () => {
const guard = createIMessageInboundReplayGuard();
const message = payload({ guid: undefined, sender: undefined });
const res = await claimIMessageInboundReplay({ guard, accountId: "default", message });
expect(res.claimed).toBe(true);
expect(res.key).toBeNull();
});
});

View File

@@ -0,0 +1,159 @@
// iMessage inbound replay protection: brings the channel in line with the
// other channels (whatsapp/discord/signal/...) by deduping inbound messages on
// a stable identity, plus an age fence that suppresses stale backlog Apple
// delivers in a burst after a bridge/Push recovery.
//
// Why both:
// - The GUID dedupe stops a message that was already dispatched from being
// dispatched again when imsg re-emits a recent row on reconnect.
// - Dedupe cannot catch a message that was *never seen* (the gateway was down
// when it was sent). Apple writes that backlog into chat.db with a fresh
// ROWID but the original (old) send date, so it arrives on the live watch as
// a "new" row. The age fence is what recognizes it as stale.
import { createHash } from "node:crypto";
import { createClaimableDedupe, type ClaimableDedupe } from "openclaw/plugin-sdk/persistent-dedupe";
import type { IMessagePayload } from "./types.js";
export const IMESSAGE_INBOUND_DEDUPE_PLUGIN_ID = "imessage";
export const IMESSAGE_INBOUND_DEDUPE_NAMESPACE_PREFIX = "imessage.inbound-dedupe";
// 4h recency window: long enough to absorb a reconnect/restart burst that
// re-emits recently dispatched rows, short enough that a genuinely-new message
// reusing a stale composite key after hours is not wrongly suppressed.
export const IMESSAGE_INBOUND_DEDUPE_TTL_MS = 4 * 60 * 60 * 1000;
export const IMESSAGE_INBOUND_DEDUPE_MEMORY_MAX = 5_000;
export const IMESSAGE_INBOUND_DEDUPE_STATE_MAX_ENTRIES = 10_000;
// Drop a LIVE inbound row whose send date is older than this relative to
// arrival. Stale backlog Apple flushes after a Push recovery carries old send
// dates; live messages are seconds old. 15min sits far above clock skew between
// a remote bridge host and the gateway, and far below any plausible live
// conversation latency.
export const IMESSAGE_STALE_INBOUND_THRESHOLD_MS = 15 * 60 * 1000;
// Recovery (catchup): on startup imsg replays rows that landed while the gateway
// was down. Those replayed rows are deliberately requested, so they use a wider
// age window than the live fence — deliver a missed message up to this old,
// suppress anything older so a long downtime cannot dump ancient history.
export const IMESSAGE_RECOVERY_MAX_AGE_MS = 2 * 60 * 60 * 1000;
// Cap the replay span so a months-down gateway does not stream its whole
// history: never set since_rowid more than this many rows below the current max.
export const IMESSAGE_RECOVERY_MAX_ROWS = 500;
/**
* Persistent inbound replay guard. Claimable (not a bare check/record) so the
* claim is atomic: a duplicate emitted twice in a reconnect burst while the
* first copy is still in flight is reported as a duplicate/inflight instead of
* racing through. Persistent so a claim committed before a crash still blocks a
* post-restart re-emit; release on dispatch failure lets a transient failure
* retry instead of being permanently suppressed.
*/
export function createIMessageInboundReplayGuard(): ClaimableDedupe {
return createClaimableDedupe({
pluginId: IMESSAGE_INBOUND_DEDUPE_PLUGIN_ID,
namespacePrefix: IMESSAGE_INBOUND_DEDUPE_NAMESPACE_PREFIX,
ttlMs: IMESSAGE_INBOUND_DEDUPE_TTL_MS,
memoryMaxSize: IMESSAGE_INBOUND_DEDUPE_MEMORY_MAX,
stateMaxEntries: IMESSAGE_INBOUND_DEDUPE_STATE_MAX_ENTRIES,
});
}
/**
* Claim a message before handling. Returns the key to commit/release later, and
* `claimed=false` when a recent copy already owns the key (duplicate/inflight)
* so the caller drops it. A message with no derivable key fails open (claimed,
* key=null) so it is always handled and nothing to commit.
*/
export async function claimIMessageInboundReplay(params: {
guard: ClaimableDedupe;
accountId: string;
message: IMessagePayload;
}): Promise<{ claimed: boolean; key: string | null }> {
const key = buildIMessageInboundReplayKey({
accountId: params.accountId,
message: params.message,
});
if (!key) {
return { claimed: true, key: null };
}
const claim = await params.guard.claim(key, { namespace: params.accountId });
return { claimed: claim.kind === "claimed", key };
}
export async function commitIMessageInboundReplay(params: {
guard: ClaimableDedupe;
accountId: string;
keys: readonly string[];
}): Promise<void> {
for (const key of new Set(params.keys)) {
await params.guard.commit(key, { namespace: params.accountId });
}
}
export function releaseIMessageInboundReplay(params: {
guard: ClaimableDedupe;
accountId: string;
keys: readonly string[];
error?: unknown;
}): void {
for (const key of new Set(params.keys)) {
params.guard.release(key, { namespace: params.accountId, error: params.error });
}
}
/**
* Stable replay key for an inbound message. Prefers the Apple GUID (globally
* unique, survives chat.db rowid churn). Falls back to a composite of the
* fields that identify a distinct send when no GUID is present, and returns
* null when the message cannot be identified at all (fail open: never suppress
* an unidentifiable message).
*/
export function buildIMessageInboundReplayKey(params: {
accountId: string;
message: IMessagePayload;
}): string | null {
const { accountId, message } = params;
const guid = message.guid?.trim();
if (guid) {
return `${accountId}:guid:${guid}`;
}
const sender = message.sender?.trim();
const conversation =
message.chat_id != null
? `chat:${message.chat_id}`
: (message.chat_guid?.trim() ?? message.chat_identifier?.trim());
const createdAt = message.created_at?.trim();
if (!sender || !conversation || !createdAt) {
return null;
}
const text = (message.text ?? "").trim();
// Hash the variable parts so the key is bounded regardless of text length
// (the persisted dedupe store caps key size); createdAt + sender + text make
// the identity unique enough for a GUID-less row.
const digest = createHash("sha256")
.update(`${conversation}\0${sender}\0${createdAt}\0${text}`)
.digest("hex")
.slice(0, 32);
return `${accountId}:c:${digest}`;
}
/**
* Age fence: true when the message's own send date is materially older than
* now, i.e. stale backlog rather than a live message. Fails open (returns
* false) when the send date is missing or unparseable so an undateable message
* is never suppressed on a timestamp we cannot read.
*/
export function isStaleIMessageBacklog(
message: IMessagePayload,
nowMs: number,
thresholdMs: number = IMESSAGE_STALE_INBOUND_THRESHOLD_MS,
): boolean {
const createdAt = message.created_at?.trim();
if (!createdAt) {
return false;
}
const sentMs = Date.parse(createdAt);
if (!Number.isFinite(sentMs)) {
return false;
}
return nowMs - sentMs > thresholdMs;
}

View File

@@ -0,0 +1,263 @@
// Imessage tests cover inbound processing.systemPrompt plugin behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { describe, expect, it } from "vitest";
import {
buildIMessageInboundContext,
resolveIMessageInboundDecision,
} from "./inbound-processing.js";
type DecisionParams = Parameters<typeof resolveIMessageInboundDecision>[0];
function buildCfgWithGroups(
groups: Record<string, { requireMention?: boolean; systemPrompt?: string }>,
): OpenClawConfig {
return {
channels: {
imessage: {
groupPolicy: "allowlist",
groups,
},
},
} as unknown as OpenClawConfig;
}
function buildDecisionParams(overrides: Partial<DecisionParams> = {}): DecisionParams {
return {
cfg: overrides.cfg ?? ({} as OpenClawConfig),
accountId: "default",
message: {
id: 1,
sender: "+15555550123",
text: "hi",
is_from_me: false,
is_group: true,
chat_id: 7,
chat_guid: "any;+;chatXYZ",
chat_identifier: "chatXYZ",
created_at: "2026-05-08T03:00:00Z",
} as DecisionParams["message"],
messageText: "hi",
bodyText: "hi",
allowFrom: ["+15555550123"],
groupAllowFrom: ["+15555550123"],
groupPolicy: "allowlist",
dmPolicy: "open",
storeAllowFrom: [],
historyLimit: 0,
groupHistories: new Map(),
echoCache: undefined,
selfChatCache: undefined,
logVerbose: undefined,
...overrides,
};
}
describe("resolveIMessageInboundDecision per-group systemPrompt", () => {
it("captures the per-chat_id systemPrompt on group dispatch decisions", async () => {
const decision = await resolveIMessageInboundDecision(
buildDecisionParams({
cfg: buildCfgWithGroups({
"7": { systemPrompt: "Keep responses under 3 sentences." },
}),
}),
);
expect(decision.kind).toBe("dispatch");
if (decision.kind !== "dispatch") {
return;
}
expect(decision.groupSystemPrompt).toBe("Keep responses under 3 sentences.");
});
it("falls back to the groups['*'] wildcard systemPrompt", async () => {
const decision = await resolveIMessageInboundDecision(
buildDecisionParams({
cfg: buildCfgWithGroups({
"*": { systemPrompt: "Default group voice." },
}),
}),
);
expect(decision.kind).toBe("dispatch");
if (decision.kind !== "dispatch") {
return;
}
expect(decision.groupSystemPrompt).toBe("Default group voice.");
});
it("prefers the per-chat_id systemPrompt over the wildcard when both are set", async () => {
const decision = await resolveIMessageInboundDecision(
buildDecisionParams({
cfg: buildCfgWithGroups({
"*": { systemPrompt: "Default group voice." },
"7": { systemPrompt: "Specific group voice." },
}),
}),
);
expect(decision.kind).toBe("dispatch");
if (decision.kind !== "dispatch") {
return;
}
expect(decision.groupSystemPrompt).toBe("Specific group voice.");
});
it("treats whitespace-only per-chat_id systemPrompt as suppression of the wildcard", async () => {
// Mirrors WhatsApp semantic: defining the systemPrompt key on a specific
// group entry (even as whitespace) means "this group has no prompt" and
// suppresses the groups["*"] fallback.
const decision = await resolveIMessageInboundDecision(
buildDecisionParams({
cfg: buildCfgWithGroups({
"*": { systemPrompt: "Wildcard." },
"7": { systemPrompt: " " },
}),
}),
);
expect(decision.kind).toBe("dispatch");
if (decision.kind !== "dispatch") {
return;
}
expect(decision.groupSystemPrompt).toBeUndefined();
});
it("treats explicit empty-string per-chat_id systemPrompt as suppression of the wildcard", async () => {
const decision = await resolveIMessageInboundDecision(
buildDecisionParams({
cfg: buildCfgWithGroups({
"*": { systemPrompt: "Wildcard." },
"7": { systemPrompt: "" },
}),
}),
);
expect(decision.kind).toBe("dispatch");
if (decision.kind !== "dispatch") {
return;
}
expect(decision.groupSystemPrompt).toBeUndefined();
});
it("falls back to the wildcard when the per-chat_id entry has no systemPrompt key at all", async () => {
const decision = await resolveIMessageInboundDecision(
buildDecisionParams({
cfg: buildCfgWithGroups({
"*": { systemPrompt: "Wildcard." },
"7": { requireMention: true },
}),
}),
);
expect(decision.kind).toBe("dispatch");
if (decision.kind !== "dispatch") {
return;
}
expect(decision.groupSystemPrompt).toBe("Wildcard.");
});
it("does not set groupSystemPrompt on true DM decisions", async () => {
// Use a chat_id that does NOT match any configured group entry, and
// route through the DM-shaped message (is_group=false, no chat_id key
// in groups). Without a groupConfig match the path stays a DM and the
// group prompt must not bleed into the ctx.
const decision = await resolveIMessageInboundDecision(
buildDecisionParams({
cfg: buildCfgWithGroups({
"999": { systemPrompt: "Other group." },
}),
message: {
id: 1,
sender: "+15555550123",
text: "hi",
is_from_me: false,
is_group: false,
chat_id: 42,
chat_identifier: "+15555550123",
destination_caller_id: "+15555550456",
created_at: "2026-05-08T03:00:00Z",
} as DecisionParams["message"],
groupPolicy: "open",
}),
);
expect(decision.kind).toBe("dispatch");
if (decision.kind !== "dispatch") {
return;
}
expect(decision.isGroup).toBe(false);
expect(decision.groupSystemPrompt).toBeUndefined();
});
});
describe("buildIMessageInboundContext forwards GroupSystemPrompt", () => {
function buildBuildParams(decision: {
isGroup: boolean;
groupSystemPrompt?: string;
groupRequireMention?: boolean;
}): Parameters<typeof buildIMessageInboundContext>[0] {
return {
cfg: {} as OpenClawConfig,
decision: {
kind: "dispatch",
isGroup: decision.isGroup,
chatId: decision.isGroup ? 7 : undefined,
chatGuid: decision.isGroup ? "any;+;chatXYZ" : "any;-;+15555550123",
chatIdentifier: decision.isGroup ? "chatXYZ" : "+15555550123",
groupId: decision.isGroup ? "7" : undefined,
historyKey: undefined,
sender: "+15555550123",
senderNormalized: "+15555550123",
route: {
accountId: "default",
agentId: "lobster",
channel: "imessage",
sessionKey: "k",
mainSessionKey: "mk",
lastRoutePolicy: "main",
matchedBy: "default",
},
bodyText: "hi",
createdAt: undefined,
replyContext: null,
effectiveWasMentioned: false,
groupRequireMention: decision.groupRequireMention ?? false,
commandAuthorized: false,
hasControlCommand: false,
effectiveDmAllowFrom: [],
effectiveGroupAllowFrom: [],
groupSystemPrompt: decision.groupSystemPrompt,
} as Parameters<typeof buildIMessageInboundContext>[0]["decision"],
message: {
sender: "+15555550123",
text: "hi",
is_group: decision.isGroup,
chat_id: decision.isGroup ? 7 : undefined,
chat_name: decision.isGroup ? "Test Group" : undefined,
} as Parameters<typeof buildIMessageInboundContext>[0]["message"],
historyLimit: 0,
groupHistories: new Map(),
} as Parameters<typeof buildIMessageInboundContext>[0];
}
it("sets ctxPayload.GroupSystemPrompt for group messages", async () => {
const { ctxPayload } = await buildIMessageInboundContext(
buildBuildParams({ isGroup: true, groupSystemPrompt: "Be concise." }),
);
expect(ctxPayload.GroupSystemPrompt).toBe("Be concise.");
});
it("forwards the effective group mention policy", async () => {
const { ctxPayload } = await buildIMessageInboundContext(
buildBuildParams({ isGroup: true, groupRequireMention: true }),
);
expect(ctxPayload.GroupRequireMention).toBe(true);
});
it("leaves ctxPayload.GroupSystemPrompt undefined when no per-group prompt is configured", async () => {
const { ctxPayload } = await buildIMessageInboundContext(
buildBuildParams({ isGroup: true, groupSystemPrompt: undefined }),
);
expect(ctxPayload.GroupSystemPrompt).toBeUndefined();
});
it("leaves ctxPayload.GroupSystemPrompt undefined for DMs even if a prompt is somehow on decision", async () => {
const { ctxPayload } = await buildIMessageInboundContext(
buildBuildParams({ isGroup: false, groupSystemPrompt: "should-not-leak" }),
);
expect(ctxPayload.GroupSystemPrompt).toBeUndefined();
});
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,51 @@
// Imessage tests cover loop rate limiter plugin behavior.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createLoopRateLimiter } from "./loop-rate-limiter.js";
describe("createLoopRateLimiter", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("allows messages below the threshold", () => {
const limiter = createLoopRateLimiter({ windowMs: 10_000, maxHits: 3 });
limiter.record("conv:1");
limiter.record("conv:1");
expect(limiter.isRateLimited("conv:1")).toBe(false);
});
it("rate limits at the threshold", () => {
const limiter = createLoopRateLimiter({ windowMs: 10_000, maxHits: 3 });
limiter.record("conv:1");
limiter.record("conv:1");
limiter.record("conv:1");
expect(limiter.isRateLimited("conv:1")).toBe(true);
});
it("does not cross-contaminate conversations", () => {
const limiter = createLoopRateLimiter({ windowMs: 10_000, maxHits: 2 });
limiter.record("conv:1");
limiter.record("conv:1");
expect(limiter.isRateLimited("conv:1")).toBe(true);
expect(limiter.isRateLimited("conv:2")).toBe(false);
});
it("resets after the time window expires", () => {
const limiter = createLoopRateLimiter({ windowMs: 5_000, maxHits: 2 });
limiter.record("conv:1");
limiter.record("conv:1");
expect(limiter.isRateLimited("conv:1")).toBe(true);
vi.advanceTimersByTime(6_000);
expect(limiter.isRateLimited("conv:1")).toBe(false);
});
it("returns false for unknown conversations", () => {
const limiter = createLoopRateLimiter();
expect(limiter.isRateLimited("unknown")).toBe(false);
});
});

Some files were not shown because too many files have changed in this diff Show More