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
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:
60
extensions/imessage/api.ts
Normal file
60
extensions/imessage/api.ts
Normal 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";
|
||||
2
extensions/imessage/channel-config-api.ts
Normal file
2
extensions/imessage/channel-config-api.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Imessage API module exposes the plugin public contract.
|
||||
export { IMessageChannelConfigSchema } from "./src/config-schema.js";
|
||||
2
extensions/imessage/channel-plugin-api.ts
Normal file
2
extensions/imessage/channel-plugin-api.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Imessage API module exposes the plugin public contract.
|
||||
export { imessagePlugin } from "./src/channel.js";
|
||||
5
extensions/imessage/config-api.ts
Normal file
5
extensions/imessage/config-api.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// Imessage API module exposes the plugin public contract.
|
||||
export {
|
||||
buildChannelConfigSchema,
|
||||
IMessageConfigSchema,
|
||||
} from "openclaw/plugin-sdk/bundled-channel-config-schema";
|
||||
8
extensions/imessage/contract-api.ts
Normal file
8
extensions/imessage/contract-api.ts
Normal 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";
|
||||
87
extensions/imessage/doctor-contract-api.ts
Normal file
87
extensions/imessage/doctor-contract-api.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
17
extensions/imessage/index.ts
Normal file
17
extensions/imessage/index.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
2
extensions/imessage/legacy-state-migrations-api.ts
Normal file
2
extensions/imessage/legacy-state-migrations-api.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Imessage API module exposes the plugin public contract.
|
||||
export { detectIMessageLegacyStateMigrations } from "./src/state-migrations.js";
|
||||
8
extensions/imessage/media-contract-api.ts
Normal file
8
extensions/imessage/media-contract-api.ts
Normal 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";
|
||||
2
extensions/imessage/message-tool-api.ts
Normal file
2
extensions/imessage/message-tool-api.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Imessage API module exposes the plugin public contract.
|
||||
export { describeIMessageMessageTool as describeMessageTool } from "./src/message-tool-api.js";
|
||||
13
extensions/imessage/openclaw.plugin.json
Normal file
13
extensions/imessage/openclaw.plugin.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"id": "imessage",
|
||||
"icon": "https://cdn.simpleicons.org/imessage",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
"channels": ["imessage"],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
80
extensions/imessage/package.json
Normal file
80
extensions/imessage/package.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
38
extensions/imessage/runtime-api.ts
Normal file
38
extensions/imessage/runtime-api.ts
Normal 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"
|
||||
>;
|
||||
4
extensions/imessage/secret-contract-api.ts
Normal file
4
extensions/imessage/secret-contract-api.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// iMessage does not expose secret-contract surfaces.
|
||||
export const secretTargetRegistryEntries: readonly [] = [];
|
||||
|
||||
export function collectRuntimeConfigAssignments(): void {}
|
||||
5
extensions/imessage/session-binding-contract-api.ts
Normal file
5
extensions/imessage/session-binding-contract-api.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// Imessage API module exposes the plugin public contract.
|
||||
export {
|
||||
testing as imessageConversationBindingTesting,
|
||||
createIMessageConversationBindingManager,
|
||||
} from "./src/conversation-bindings.js";
|
||||
17
extensions/imessage/setup-entry.ts
Normal file
17
extensions/imessage/setup-entry.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
7
extensions/imessage/src/account-types.ts
Normal file
7
extensions/imessage/src/account-types.ts
Normal 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"
|
||||
>;
|
||||
184
extensions/imessage/src/accounts.test.ts
Normal file
184
extensions/imessage/src/accounts.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
266
extensions/imessage/src/accounts.ts
Normal file
266
extensions/imessage/src/accounts.ts
Normal 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;
|
||||
}
|
||||
22
extensions/imessage/src/actions-contract.ts
Normal file
22
extensions/imessage/src/actions-contract.ts
Normal 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
|
||||
>;
|
||||
268
extensions/imessage/src/actions.runtime.test.ts
Normal file
268
extensions/imessage/src/actions.runtime.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
603
extensions/imessage/src/actions.runtime.ts
Normal file
603
extensions/imessage/src/actions.runtime.ts
Normal 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;
|
||||
1375
extensions/imessage/src/actions.test.ts
Normal file
1375
extensions/imessage/src/actions.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
899
extensions/imessage/src/actions.ts
Normal file
899
extensions/imessage/src/actions.ts
Normal 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}.`);
|
||||
},
|
||||
};
|
||||
147
extensions/imessage/src/approval-auth.test.ts
Normal file
147
extensions/imessage/src/approval-auth.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
79
extensions/imessage/src/approval-auth.ts
Normal file
79
extensions/imessage/src/approval-auth.ts
Normal 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,
|
||||
});
|
||||
},
|
||||
};
|
||||
275
extensions/imessage/src/approval-handler.runtime.test.ts
Normal file
275
extensions/imessage/src/approval-handler.runtime.test.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
233
extensions/imessage/src/approval-handler.runtime.ts
Normal file
233
extensions/imessage/src/approval-handler.runtime.ts
Normal 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)}`);
|
||||
},
|
||||
},
|
||||
});
|
||||
826
extensions/imessage/src/approval-native.test.ts
Normal file
826
extensions/imessage/src/approval-native.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
443
extensions/imessage/src/approval-native.ts
Normal file
443
extensions/imessage/src/approval-native.ts
Normal 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,
|
||||
}),
|
||||
});
|
||||
615
extensions/imessage/src/approval-reaction-poller.test.ts
Normal file
615
extensions/imessage/src/approval-reaction-poller.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
310
extensions/imessage/src/approval-reaction-poller.ts
Normal file
310
extensions/imessage/src/approval-reaction-poller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
741
extensions/imessage/src/approval-reactions.test.ts
Normal file
741
extensions/imessage/src/approval-reactions.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
620
extensions/imessage/src/approval-reactions.ts
Normal file
620
extensions/imessage/src/approval-reactions.ts
Normal 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();
|
||||
}
|
||||
24
extensions/imessage/src/approval-resolver.ts
Normal file
24
extensions/imessage/src/approval-resolver.ts
Normal 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"})`,
|
||||
});
|
||||
}
|
||||
7
extensions/imessage/src/approval-text.ts
Normal file
7
extensions/imessage/src/approval-text.ts
Normal 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}`);
|
||||
}
|
||||
24
extensions/imessage/src/channel-api.ts
Normal file
24
extensions/imessage/src/channel-api.ts
Normal 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 };
|
||||
@@ -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],
|
||||
);
|
||||
});
|
||||
});
|
||||
109
extensions/imessage/src/channel.runtime.test.ts
Normal file
109
extensions/imessage/src/channel.runtime.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
108
extensions/imessage/src/channel.runtime.ts
Normal file
108
extensions/imessage/src/channel.runtime.ts
Normal 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 };
|
||||
12
extensions/imessage/src/channel.setup.ts
Normal file
12
extensions/imessage/src/channel.setup.ts
Normal 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,
|
||||
}),
|
||||
};
|
||||
390
extensions/imessage/src/channel.ts
Normal file
390
extensions/imessage/src/channel.ts
Normal 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,
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
87
extensions/imessage/src/chat.ts
Normal file
87
extensions/imessage/src/chat.ts
Normal 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);
|
||||
}
|
||||
20
extensions/imessage/src/cli-output.test.ts
Normal file
20
extensions/imessage/src/cli-output.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
30
extensions/imessage/src/cli-output.ts
Normal file
30
extensions/imessage/src/cli-output.ts
Normal 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;
|
||||
}
|
||||
339
extensions/imessage/src/client.ts
Normal file
339
extensions/imessage/src/client.ts
Normal 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;
|
||||
}
|
||||
31
extensions/imessage/src/config-accessors.test.ts
Normal file
31
extensions/imessage/src/config-accessors.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
22
extensions/imessage/src/config-accessors.ts
Normal file
22
extensions/imessage/src/config-accessors.ts
Normal 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;
|
||||
}
|
||||
215
extensions/imessage/src/config-schema.test.ts
Normal file
215
extensions/imessage/src/config-schema.test.ts
Normal 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");
|
||||
}
|
||||
});
|
||||
});
|
||||
7
extensions/imessage/src/config-schema.ts
Normal file
7
extensions/imessage/src/config-schema.ts
Normal 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,
|
||||
});
|
||||
25
extensions/imessage/src/config-ui-hints.ts
Normal file
25
extensions/imessage/src/config-ui-hints.ts
Normal 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>;
|
||||
11
extensions/imessage/src/constants.ts
Normal file
11
extensions/imessage/src/constants.ts
Normal 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;
|
||||
47
extensions/imessage/src/conversation-bindings.ts
Normal file
47
extensions/imessage/src/conversation-bindings.ts
Normal 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,
|
||||
});
|
||||
},
|
||||
};
|
||||
51
extensions/imessage/src/conversation-id-core.ts
Normal file
51
extensions/imessage/src/conversation-id-core.ts
Normal 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;
|
||||
}
|
||||
27
extensions/imessage/src/conversation-id.ts
Normal file
27
extensions/imessage/src/conversation-id.ts
Normal 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;
|
||||
}
|
||||
60
extensions/imessage/src/conversation-route.test.ts
Normal file
60
extensions/imessage/src/conversation-route.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
65
extensions/imessage/src/conversation-route.ts
Normal file
65
extensions/imessage/src/conversation-route.ts
Normal 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;
|
||||
}
|
||||
68
extensions/imessage/src/doctor-contract-api.test.ts
Normal file
68
extensions/imessage/src/doctor-contract-api.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
69
extensions/imessage/src/doctor.test.ts
Normal file
69
extensions/imessage/src/doctor.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
8
extensions/imessage/src/doctor.ts
Normal file
8
extensions/imessage/src/doctor.ts
Normal 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 }),
|
||||
};
|
||||
41
extensions/imessage/src/group-policy.ts
Normal file
41
extensions/imessage/src/group-policy.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
173
extensions/imessage/src/imessage.test-plugin.ts
Normal file
173
extensions/imessage/src/imessage.test-plugin.ts
Normal 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),
|
||||
},
|
||||
});
|
||||
100
extensions/imessage/src/markdown-format.test.ts
Normal file
100
extensions/imessage/src/markdown-format.test.ts
Normal 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(/\*/);
|
||||
});
|
||||
});
|
||||
154
extensions/imessage/src/markdown-format.ts
Normal file
154
extensions/imessage/src/markdown-format.ts
Normal 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 };
|
||||
}
|
||||
32
extensions/imessage/src/media-contract.ts
Normal file
32
extensions/imessage/src/media-contract.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
141
extensions/imessage/src/message-tool-api.test.ts
Normal file
141
extensions/imessage/src/message-tool-api.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
124
extensions/imessage/src/message-tool-api.ts
Normal file
124
extensions/imessage/src/message-tool-api.ts
Normal 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,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
406
extensions/imessage/src/monitor-reply-cache.test.ts
Normal file
406
extensions/imessage/src/monitor-reply-cache.test.ts
Normal 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));
|
||||
});
|
||||
});
|
||||
557
extensions/imessage/src/monitor-reply-cache.ts
Normal file
557
extensions/imessage/src/monitor-reply-cache.ts
Normal 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
|
||||
}
|
||||
}
|
||||
712
extensions/imessage/src/monitor.gating.test.ts
Normal file
712
extensions/imessage/src/monitor.gating.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
2139
extensions/imessage/src/monitor.last-route.test.ts
Normal file
2139
extensions/imessage/src/monitor.last-route.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
184
extensions/imessage/src/monitor.media-policy.test.ts
Normal file
184
extensions/imessage/src/monitor.media-policy.test.ts
Normal 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]");
|
||||
});
|
||||
});
|
||||
103
extensions/imessage/src/monitor.plugin-payload.test.ts
Normal file
103
extensions/imessage/src/monitor.plugin-payload.test.ts
Normal 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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
3
extensions/imessage/src/monitor.ts
Normal file
3
extensions/imessage/src/monitor.ts
Normal 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";
|
||||
226
extensions/imessage/src/monitor.watch-subscribe-retry.test.ts
Normal file
226
extensions/imessage/src/monitor.watch-subscribe-retry.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
35
extensions/imessage/src/monitor/abort-handler.ts
Normal file
35
extensions/imessage/src/monitor/abort-handler.ts
Normal 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);
|
||||
}
|
||||
434
extensions/imessage/src/monitor/catchup-bridge.test.ts
Normal file
434
extensions/imessage/src/monitor/catchup-bridge.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
298
extensions/imessage/src/monitor/catchup-bridge.ts
Normal file
298
extensions/imessage/src/monitor/catchup-bridge.ts
Normal 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() } : {}),
|
||||
});
|
||||
}
|
||||
565
extensions/imessage/src/monitor/catchup.test.ts
Normal file
565
extensions/imessage/src/monitor/catchup.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
565
extensions/imessage/src/monitor/catchup.ts
Normal file
565
extensions/imessage/src/monitor/catchup.ts
Normal 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;
|
||||
}
|
||||
248
extensions/imessage/src/monitor/coalesce.test.ts
Normal file
248
extensions/imessage/src/monitor/coalesce.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
203
extensions/imessage/src/monitor/coalesce.ts
Normal file
203
extensions/imessage/src/monitor/coalesce.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
156
extensions/imessage/src/monitor/conversation-repair.test.ts
Normal file
156
extensions/imessage/src/monitor/conversation-repair.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
168
extensions/imessage/src/monitor/conversation-repair.ts
Normal file
168
extensions/imessage/src/monitor/conversation-repair.ts
Normal 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;
|
||||
}
|
||||
4
extensions/imessage/src/monitor/deliver.runtime.ts
Normal file
4
extensions/imessage/src/monitor/deliver.runtime.ts
Normal 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";
|
||||
250
extensions/imessage/src/monitor/deliver.test.ts
Normal file
250
extensions/imessage/src/monitor/deliver.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
93
extensions/imessage/src/monitor/deliver.ts
Normal file
93
extensions/imessage/src/monitor/deliver.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
103
extensions/imessage/src/monitor/dm-history.test.ts
Normal file
103
extensions/imessage/src/monitor/dm-history.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
167
extensions/imessage/src/monitor/dm-history.ts
Normal file
167
extensions/imessage/src/monitor/dm-history.ts
Normal 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"),
|
||||
};
|
||||
}
|
||||
146
extensions/imessage/src/monitor/echo-cache.ts
Normal file
146
extensions/imessage/src/monitor/echo-cache.ts
Normal 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();
|
||||
}
|
||||
19
extensions/imessage/src/monitor/echo-text-corruption.ts
Normal file
19
extensions/imessage/src/monitor/echo-text-corruption.ts
Normal 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);
|
||||
}
|
||||
160
extensions/imessage/src/monitor/group-allowlist-warnings.test.ts
Normal file
160
extensions/imessage/src/monitor/group-allowlist-warnings.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
79
extensions/imessage/src/monitor/group-allowlist-warnings.ts
Normal file
79
extensions/imessage/src/monitor/group-allowlist-warnings.ts
Normal 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();
|
||||
}
|
||||
177
extensions/imessage/src/monitor/inbound-dedupe.test.ts
Normal file
177
extensions/imessage/src/monitor/inbound-dedupe.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
159
extensions/imessage/src/monitor/inbound-dedupe.ts
Normal file
159
extensions/imessage/src/monitor/inbound-dedupe.ts
Normal 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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
1092
extensions/imessage/src/monitor/inbound-processing.test.ts
Normal file
1092
extensions/imessage/src/monitor/inbound-processing.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
1114
extensions/imessage/src/monitor/inbound-processing.ts
Normal file
1114
extensions/imessage/src/monitor/inbound-processing.ts
Normal file
File diff suppressed because it is too large
Load Diff
51
extensions/imessage/src/monitor/loop-rate-limiter.test.ts
Normal file
51
extensions/imessage/src/monitor/loop-rate-limiter.test.ts
Normal 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
Reference in New Issue
Block a user