重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。

同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。

Made-with: Cursor
This commit is contained in:
oliver 2026-04-26 08:34:33 +08:00
parent 4a23b715a2
commit dbbe3add6a
14438 changed files with 2693620 additions and 2546 deletions

View file

@ -0,0 +1,6 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
export type IMessageAccountConfig = Omit<
NonNullable<NonNullable<OpenClawConfig["channels"]>["imessage"]>,
"accounts" | "defaultAccount"
>;

View file

@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { resolveIMessageAccount } from "./accounts.js";
describe("resolveIMessageAccount", () => {
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);
});
});

View file

@ -0,0 +1,71 @@
import {
createAccountListHelpers,
normalizeAccountId,
resolveMergedAccountConfig,
type OpenClawConfig,
} from "openclaw/plugin-sdk/account-resolution";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-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");
export const listIMessageAccountIds = listAccountIds;
export const resolveDefaultIMessageAccountId = resolveDefaultAccountId;
function mergeIMessageAccountConfig(cfg: OpenClawConfig, accountId: string): IMessageAccountConfig {
return resolveMergedAccountConfig<IMessageAccountConfig>({
channelConfig: cfg.channels?.imessage as IMessageAccountConfig | undefined,
accounts: cfg.channels?.imessage?.accounts as
| Record<string, Partial<IMessageAccountConfig>>
| undefined,
accountId,
});
}
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.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,
};
}
export function listEnabledIMessageAccounts(cfg: OpenClawConfig): ResolvedIMessageAccount[] {
return listIMessageAccountIds(cfg)
.map((accountId) => resolveIMessageAccount({ cfg, accountId }))
.filter((account) => account.enabled);
}

View file

@ -0,0 +1,32 @@
import { formatTrimmedAllowFromEntries } from "openclaw/plugin-sdk/channel-config-helpers";
import type { ChannelStatusIssue } from "openclaw/plugin-sdk/channel-contract";
import { PAIRING_APPROVED_MESSAGE } from "openclaw/plugin-sdk/channel-status";
import {
DEFAULT_ACCOUNT_ID,
getChatChannelMeta,
type ChannelPlugin,
type OpenClawConfig,
} from "openclaw/plugin-sdk/core";
import { resolveChannelMediaMaxBytes } from "openclaw/plugin-sdk/media-runtime";
import { collectStatusIssuesFromLastError } from "openclaw/plugin-sdk/status-helpers";
import {
resolveIMessageConfigAllowFrom,
resolveIMessageConfigDefaultTo,
} from "./config-accessors.js";
import { looksLikeIMessageTargetId, normalizeIMessageMessagingTarget } from "./normalize.js";
export { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking";
export {
collectStatusIssuesFromLastError,
DEFAULT_ACCOUNT_ID,
formatTrimmedAllowFromEntries,
getChatChannelMeta,
looksLikeIMessageTargetId,
normalizeIMessageMessagingTarget,
PAIRING_APPROVED_MESSAGE,
resolveChannelMediaMaxBytes,
resolveIMessageConfigAllowFrom,
resolveIMessageConfigDefaultTo,
};
export type { ChannelPlugin, ChannelStatusIssue, OpenClawConfig };

View file

@ -0,0 +1,83 @@
import { resolveOutboundSendDep } from "openclaw/plugin-sdk/outbound-runtime";
import 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[];
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 } : {}),
maxBytes,
accountId: params.accountId ?? undefined,
replyToId: params.replyToId ?? undefined,
});
}
export async function notifyIMessageApproval(id: string): Promise<void> {
await sendMessageIMessage(id, PAIRING_APPROVED_MESSAGE);
}
export async function probeIMessageAccount(params?: {
timeoutMs?: number;
cliPath?: string;
dbPath?: string;
}) {
return await probeIMessage(params?.timeoutMs, {
cliPath: params?.cliPath,
dbPath: params?.dbPath,
});
}
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,
});
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,
});
}
export { imessageSetupWizard };

View file

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

View file

@ -0,0 +1,286 @@
import { buildDmGroupAccountAllowlistAdapter } from "openclaw/plugin-sdk/allowlist-config-edit";
import { createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core";
import { buildPassiveProbedChannelStatusSummary } from "openclaw/plugin-sdk/extension-shared";
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import { sanitizeForPlainText } from "openclaw/plugin-sdk/outbound-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 {
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 {
resolveIMessageGroupRequireMention,
resolveIMessageGroupToolPolicy,
} from "./group-policy.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"));
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 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: `imessage:${handle}`,
to: `imessage:${handle}`,
};
}
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: {
groupAllowFromFallbackToAllowFrom: false,
},
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();
}
},
},
},
pairing: {
text: {
idLabel: "imessageSenderId",
message: "OpenClaw: your access has been approved.",
notify: async ({ id }) =>
await (await loadIMessageChannelRuntime()).notifyIMessageApproval(id),
},
},
security: imessageSecurityAdapter,
outbound: {
base: {
deliveryMode: "direct",
chunker: chunkTextForOutbound,
chunkerMode: "text",
textChunkLimit: 4000,
sanitizeText: ({ text }) => sanitizeForPlainText(text),
},
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,
accountId,
deps,
replyToId,
}) =>
await (
await loadIMessageChannelRuntime()
).sendIMessageOutbound({
cfg,
to,
text,
mediaUrl,
mediaLocalRoots,
accountId: accountId ?? undefined,
deps,
replyToId: replyToId ?? undefined,
}),
},
},
});

View file

@ -0,0 +1,256 @@
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
import { createInterface, type Interface } from "node:readline";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { normalizeLowercaseStringOrEmpty, resolveUserPath } from "openclaw/plugin-sdk/text-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;
};
function isTestEnv(): boolean {
if (process.env.NODE_ENV === "test") {
return true;
}
const vitest = normalizeLowercaseStringOrEmpty(process.env.VITEST);
return Boolean(vitest);
}
export class IMessageRpcClient {
private readonly cliPath: string;
private readonly dbPath?: string;
private readonly runtime?: RuntimeEnv;
private readonly onNotification?: (msg: IMessageRpcNotification) => void;
private readonly pending = new Map<string, PendingRequest>();
private readonly closed: Promise<void>;
private closedResolve: (() => void) | null = null;
private child: ChildProcessWithoutNullStreams | null = null;
private reader: Interface | null = null;
private nextId = 1;
constructor(opts: IMessageRpcClientOptions = {}) {
this.cliPath = opts.cliPath?.trim() || "imsg";
this.dbPath = opts.dbPath?.trim() ? resolveUserPath(opts.dbPath) : undefined;
this.runtime = opts.runtime;
this.onNotification = opts.onNotification;
this.closed = new Promise((resolve) => {
this.closedResolve = resolve;
});
}
async start(): Promise<void> {
if (this.child) {
return;
}
if (isTestEnv()) {
throw new Error("Refusing to start imsg rpc in test environment; mock iMessage RPC client");
}
const args = ["rpc"];
if (this.dbPath) {
args.push("--db", this.dbPath);
}
const child = spawn(this.cliPath, args, {
stdio: ["pipe", "pipe", "pipe"],
});
this.child = child;
this.reader = createInterface({ input: child.stdout });
this.reader.on("line", (line) => {
const trimmed = line.trim();
if (!trimmed) {
return;
}
this.handleLine(trimmed);
});
child.stderr?.on("data", (chunk) => {
const lines = chunk.toString().split(/\r?\n/);
for (const line of lines) {
if (!line.trim()) {
continue;
}
this.runtime?.error?.(`imsg rpc: ${line.trim()}`);
}
});
child.on("error", (err) => {
this.failAll(err instanceof Error ? err : new Error(String(err)));
this.closedResolve?.();
});
child.on("close", (code, signal) => {
if (code !== 0 && code !== null) {
const reason = signal ? `signal ${signal}` : `code ${code}`;
this.failAll(new Error(`imsg rpc exited (${reason})`));
} else {
this.failAll(new Error("imsg rpc closed"));
}
this.closedResolve?.();
});
}
async stop(): Promise<void> {
if (!this.child) {
return;
}
this.reader?.close();
this.reader = null;
this.child.stdin?.end();
const child = this.child;
this.child = null;
await Promise.race([
this.closed,
new Promise<void>((resolve) => {
setTimeout(() => {
if (!child.killed) {
child.kill("SIGTERM");
}
resolve();
}, 500);
}),
]);
}
async waitForClose(): Promise<void> {
await this.closed;
}
async request<T = unknown>(
method: string,
params?: Record<string, unknown>,
opts?: { timeoutMs?: number },
): Promise<T> {
if (!this.child || !this.child.stdin) {
throw new Error("imsg rpc not running");
}
const id = this.nextId++;
const payload = {
jsonrpc: "2.0",
id,
method,
params: params ?? {},
};
const line = `${JSON.stringify(payload)}\n`;
const timeoutMs = opts?.timeoutMs ?? 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,
});
});
this.child.stdin.write(line);
return await response;
}
private handleLine(line: string) {
let parsed: IMessageRpcResponse<unknown>;
try {
parsed = JSON.parse(line) as IMessageRpcResponse<unknown>;
} catch (err) {
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 failAll(err: Error) {
for (const [key, pending] of this.pending.entries()) {
if (pending.timer) {
clearTimeout(pending.timer);
}
pending.reject(err);
this.pending.delete(key);
}
}
}
export async function createIMessageRpcClient(
opts: IMessageRpcClientOptions = {},
): Promise<IMessageRpcClient> {
const client = new IMessageRpcClient(opts);
await client.start();
return client;
}

View file

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

View file

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

View file

@ -0,0 +1,6 @@
import { buildChannelConfigSchema, IMessageConfigSchema } from "../config-api.js";
import { iMessageChannelConfigUiHints } from "./config-ui-hints.js";
export const IMessageChannelConfigSchema = buildChannelConfigSchema(IMessageConfigSchema, {
uiHints: iMessageChannelConfigUiHints,
});

View file

@ -0,0 +1,20 @@
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.",
},
} satisfies Record<string, ChannelConfigUiHint>;

View file

@ -0,0 +1,2 @@
/** Default timeout for iMessage probe/RPC operations (10 seconds). */
export const DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS = 10_000;

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,79 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import {
getSessionBindingService,
isPluginOwnedSessionBindingRecord,
resolveConfiguredBindingRoute,
} from "openclaw/plugin-sdk/conversation-runtime";
import {
deriveLastRoutePolicy,
resolveAgentIdFromSessionKey,
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 runtimeBinding = getSessionBindingService().resolveByConversation({
channel: "imessage",
accountId: params.accountId,
conversationId,
});
const boundSessionKey = runtimeBinding?.targetSessionKey?.trim();
if (!runtimeBinding || !boundSessionKey) {
return route;
}
getSessionBindingService().touch(runtimeBinding.bindingId);
if (isPluginOwnedSessionBindingRecord(runtimeBinding)) {
logVerbose(`imessage: plugin-bound conversation ${conversationId}`);
return route;
}
logVerbose(`imessage: routed via bound conversation ${conversationId} -> ${boundSessionKey}`);
return {
...route,
sessionKey: boundSessionKey,
agentId: resolveAgentIdFromSessionKey(boundSessionKey),
lastRoutePolicy: deriveLastRoutePolicy({
sessionKey: boundSessionKey,
mainSessionKey: route.mainSessionKey,
}),
matchedBy: "binding.channel",
};
}

View file

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

View file

@ -0,0 +1,125 @@
import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-contract";
import type { ChannelPlugin } from "openclaw/plugin-sdk/core";
import { resolveOutboundSendDep } from "openclaw/plugin-sdk/outbound-runtime";
import { collectStatusIssuesFromLastError } from "openclaw/plugin-sdk/status-helpers";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-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",
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" };
},
};
export const createIMessageTestPlugin = (params?: {
outbound?: ChannelOutboundAdapter;
}): 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),
},
outbound: params?.outbound ?? defaultIMessageOutbound,
messaging: {
targetResolver: {
looksLikeId: (raw) => {
const trimmed = raw.trim();
if (!trimmed) {
return false;
}
if (/^(imessage:|sms:|auto:|chat_id:|chat_guid:|chat_identifier:)/i.test(trimmed)) {
return true;
}
if (trimmed.includes("@")) {
return true;
}
return /^\+?\d{3,}$/.test(trimmed);
},
hint: "<handle|chat_id:ID>",
},
normalizeTarget: (raw) => normalizeIMessageTestHandle(raw),
},
});

View file

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

View file

@ -0,0 +1,433 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { describe, expect, it } from "vitest";
import {
buildIMessageInboundContext,
resolveIMessageInboundDecision,
} from "./monitor/inbound-processing.js";
import { parseIMessageNotification } from "./monitor/parse-notification.js";
import type { IMessagePayload } from "./monitor/types.js";
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;
}
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,
});
}
function resolveDispatchDecision(params: {
cfg: OpenClawConfig;
message: IMessagePayload;
groupHistories?: Parameters<typeof resolveIMessageInboundDecision>[0]["groupHistories"];
allowFrom?: string[];
groupAllowFrom?: string[];
groupPolicy?: "open" | "allowlist" | "disabled";
dmPolicy?: "open" | "pairing" | "allowlist" | "disabled";
}) {
const groupHistories = params.groupHistories ?? new Map();
const decision = resolveIMessageInboundDecision({
cfg: params.cfg,
accountId: "default",
message: params.message,
opts: {},
messageText: params.message.text ?? "",
bodyText: params.message.text ?? "",
allowFrom: params.allowFrom ?? ["*"],
groupAllowFrom: params.groupAllowFrom ?? [],
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 };
}
function buildDispatchContextPayload(params: { cfg: OpenClawConfig; message: IMessagePayload }) {
const { cfg, message } = params;
const { decision, groupHistories } = resolveDispatchDecision({ cfg, message });
const { ctxPayload } = buildIMessageInboundContext({
cfg,
decision,
message,
historyLimit: 0,
groupHistories,
});
return ctxPayload;
}
describe("imessage monitor gating + envelope builders", () => {
it("parseIMessageNotification rejects malformed payloads", () => {
expect(
parseIMessageNotification({
message: { chat_id: 1, sender: { nested: "nope" } },
}),
).toBeNull();
});
it("parseIMessageNotification preserves destination_caller_id metadata", () => {
expect(
parseIMessageNotification({
message: {
id: 1,
sender: "+15550001111",
destination_caller_id: "+15550002222",
is_from_me: true,
text: "hello",
},
}),
).toMatchObject({
destination_caller_id: "+15550002222",
});
});
it("drops group messages without mention by default", () => {
const decision = 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", () => {
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 = buildDispatchContextPayload({ cfg, message });
expect(ctxPayload.ChatType).toBe("group");
expect(ctxPayload.SessionKey).toBe("agent:main:imessage:group:42");
expect(String(ctxPayload.Body ?? "")).toContain("+15550002222:");
expect(String(ctxPayload.Body ?? "")).not.toContain("[from:");
expect(ctxPayload.To).toBe("chat_id:42");
});
it("includes reply-to context fields + suffix", () => {
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 = buildDispatchContextPayload({ cfg, message });
expect(ctxPayload.ReplyToId).toBe("9001");
expect(ctxPayload.ReplyToBody).toBe("original message");
expect(ctxPayload.ReplyToSender).toBe("+15559998888");
expect(String(ctxPayload.Body ?? "")).toContain("[Replying to +15559998888 id:9001]");
expect(String(ctxPayload.Body ?? "")).toContain("original message");
});
it("drops group reply context from non-allowlisted senders in allowlist mode", () => {
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 } = resolveDispatchDecision({
cfg,
message,
allowFrom: ["*"],
groupAllowFrom: ["+15550001111"],
groupPolicy: "allowlist",
});
const { ctxPayload } = buildIMessageInboundContext({
cfg,
decision,
message,
historyLimit: 0,
groupHistories,
});
expect(ctxPayload.ReplyToId).toBeUndefined();
expect(ctxPayload.ReplyToBody).toBeUndefined();
expect(ctxPayload.ReplyToSender).toBeUndefined();
expect(String(ctxPayload.Body ?? "")).not.toContain("[Replying to");
});
it("keeps group reply context in allowlist_quote mode", () => {
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 } = resolveDispatchDecision({
cfg,
message,
allowFrom: ["*"],
groupAllowFrom: ["+15550001111"],
groupPolicy: "allowlist",
});
const { ctxPayload } = buildIMessageInboundContext({
cfg,
decision,
message,
historyLimit: 0,
groupHistories,
});
expect(ctxPayload.ReplyToId).toBe("9001");
expect(ctxPayload.ReplyToBody).toBe("quoted context");
expect(ctxPayload.ReplyToSender).toBe("+15559998888");
expect(String(ctxPayload.Body ?? "")).toContain("[Replying to +15559998888 id:9001]");
});
it("treats configured chat_id as a group session even when is_group is false", () => {
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 } = 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", () => {
const cfg = baseCfg();
cfg.messages ??= {};
cfg.messages.groupChat ??= {};
cfg.messages.groupChat.mentionPatterns = [];
const groupHistories = new Map();
const decision = 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", () => {
const cfg = baseCfg();
cfg.channels ??= {};
cfg.channels.imessage ??= {};
cfg.channels.imessage.groups = { "99": { requireMention: false } };
const groupHistories = new Map();
const decision = 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", () => {
const cfg = baseCfg();
cfg.channels ??= {};
cfg.channels.imessage ??= {};
cfg.channels.imessage.groupPolicy = "allowlist";
const groupHistories = new Map();
const denied = 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 = 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("blocks group messages when groupPolicy is disabled", () => {
const cfg = baseCfg();
cfg.channels ??= {};
cfg.channels.imessage ??= {};
cfg.channels.imessage.groupPolicy = "disabled";
const groupHistories = new Map();
const decision = 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");
});
});

View file

@ -0,0 +1,43 @@
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();
let subscriptionId: number | null = 1;
const requestMock = vi.fn((method: string, _params?: Record<string, unknown>) => {
if (method === "watch.unsubscribe") {
return Promise.reject(new Error("imsg rpc closed"));
}
return Promise.resolve({});
});
const stopMock = vi.fn(async () => {});
const unhandled: unknown[] = [];
const onUnhandled = (reason: unknown) => {
unhandled.push(reason);
};
process.on("unhandledRejection", onUnhandled);
try {
const detach = attachIMessageMonitorAbortHandler({
abortSignal: abortController.signal,
client: {
request: requestMock,
stop: stopMock,
},
getSubscriptionId: () => subscriptionId,
});
abortController.abort();
// Give the event loop a turn to surface any unhandledRejection, if present.
await new Promise<void>((resolve) => setImmediate(resolve));
detach();
} finally {
process.off("unhandledRejection", onUnhandled);
}
expect(unhandled).toHaveLength(0);
expect(stopMock).toHaveBeenCalled();
expect(requestMock).toHaveBeenCalledWith("watch.unsubscribe", { subscription: 1 });
});
});

View file

@ -0,0 +1,2 @@
export { monitorIMessageProvider } from "./monitor/monitor-provider.js";
export type { MonitorIMessageOpts } from "./monitor/types.js";

View file

@ -0,0 +1,130 @@
import type { waitForTransportReady } from "openclaw/plugin-sdk/infra-runtime";
import { 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";
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/infra-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();
waitForTransportReadyMock.mockReset().mockResolvedValue(undefined);
createIMessageRpcClientMock.mockReset();
attachIMessageMonitorAbortHandlerMock.mockReset().mockReturnValue(() => {});
});
afterEach(() => {
vi.useRealTimers();
});
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(runtime.log).toHaveBeenCalledWith(
expect.stringContaining("watch.subscribe startup failed"),
);
expect(runtime.error).not.toHaveBeenCalledWith(
expect.stringContaining("imessage: monitor failed"),
);
});
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) => 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).toHaveBeenCalledWith(expect.stringContaining("imessage: monitor failed"));
});
});

View file

@ -0,0 +1,34 @@
export type IMessageMonitorClient = {
request: (method: string, params?: Record<string, unknown>) => Promise<unknown>;
stop: () => Promise<void>;
};
export function attachIMessageMonitorAbortHandler(params: {
abortSignal?: AbortSignal;
client: IMessageMonitorClient;
getSubscriptionId: () => number | null;
}): () => void {
const abort = params.abortSignal;
if (!abort) {
return () => {};
}
const onAbort = () => {
const subscriptionId = params.getSubscriptionId();
if (subscriptionId) {
void params.client
.request("watch.unsubscribe", {
subscription: subscriptionId,
})
.catch(() => {
// Ignore disconnect errors during shutdown.
});
}
void params.client.stop().catch(() => {
// Ignore disconnect errors during shutdown.
});
};
abort.addEventListener("abort", onAbort, { once: true });
return () => abort.removeEventListener("abort", onAbort);
}

View file

@ -0,0 +1,3 @@
export { loadConfig, resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
export { chunkTextWithMode, resolveChunkMode } from "openclaw/plugin-sdk/reply-runtime";
export { convertMarkdownTables } from "openclaw/plugin-sdk/text-runtime";

View file

@ -0,0 +1,181 @@
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { 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", () => ({
loadConfig: vi.fn(() => ({})),
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;
describe("deliverReplies", () => {
const runtime = { log: vi.fn(), error: vi.fn() } as unknown as RuntimeEnv;
const client = {} as Awaited<ReturnType<typeof import("../client.js").createIMessageRpcClient>>;
beforeAll(async () => {
({ deliverReplies } = await import("./deliver.js"));
});
beforeEach(() => {
vi.clearAllMocks();
chunkTextWithModeMock.mockImplementation((text: string) => [text]);
});
it("propagates payload replyToId through all text chunks", async () => {
chunkTextWithModeMock.mockImplementation((text: string) => text.split("|"));
await deliverReplies({
replies: [{ text: "first|second", replyToId: "reply-1" }],
target: "chat_id:10",
client,
accountId: "default",
runtime,
maxBytes: 4096,
textLimit: 4000,
});
expect(sendMessageIMessageMock).toHaveBeenCalledTimes(2);
expect(sendMessageIMessageMock).toHaveBeenNthCalledWith(
1,
"chat_id:10",
"first",
expect.objectContaining({
client,
maxBytes: 4096,
accountId: "default",
replyToId: "reply-1",
}),
);
expect(sendMessageIMessageMock).toHaveBeenNthCalledWith(
2,
"chat_id:10",
"second",
expect.objectContaining({
client,
maxBytes: 4096,
accountId: "default",
replyToId: "reply-1",
}),
);
});
it("propagates payload replyToId through media sends", async () => {
await deliverReplies({
replies: [
{
text: "caption",
mediaUrls: ["https://example.com/a.jpg", "https://example.com/b.jpg"],
replyToId: "reply-2",
},
],
target: "chat_id:20",
client,
accountId: "acct-2",
runtime,
maxBytes: 8192,
textLimit: 4000,
});
expect(sendMessageIMessageMock).toHaveBeenCalledTimes(2);
expect(sendMessageIMessageMock).toHaveBeenNthCalledWith(
1,
"chat_id:20",
"caption",
expect.objectContaining({
mediaUrl: "https://example.com/a.jpg",
client,
maxBytes: 8192,
accountId: "acct-2",
replyToId: "reply-2",
}),
);
expect(sendMessageIMessageMock).toHaveBeenNthCalledWith(
2,
"chat_id:20",
"",
expect.objectContaining({
mediaUrl: "https://example.com/b.jpg",
client,
maxBytes: 8192,
accountId: "acct-2",
replyToId: "reply-2",
}),
);
});
it("records outbound text and message ids in sent-message cache (post-send only)", async () => {
// Fix for #47830: remember() is called ONLY after each chunk is sent,
// never with the full un-chunked text before sending begins.
// Pre-send population widened the false-positive window in self-chat.
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({
replies: [{ text: "first|second" }],
target: "chat_id:30",
client,
accountId: "acct-3",
runtime,
maxBytes: 2048,
textLimit: 4000,
sentMessageCache: { remember },
});
// Only the two per-chunk post-send calls — no pre-send full-text call.
expect(remember).toHaveBeenCalledTimes(2);
expect(remember).toHaveBeenCalledWith("acct-3:chat_id:30", {
text: "first",
messageId: "imsg-1",
});
expect(remember).toHaveBeenCalledWith("acct-3:chat_id:30", {
text: "second",
messageId: "imsg-2",
});
});
it("records the actual sent placeholder for media-only replies", async () => {
const remember = vi.fn();
sendMessageIMessageMock.mockResolvedValueOnce({
messageId: "imsg-media-1",
sentText: "<media:image>",
});
await deliverReplies({
replies: [{ mediaUrls: ["https://example.com/a.jpg"] }],
target: "chat_id:40",
client,
accountId: "acct-4",
runtime,
maxBytes: 2048,
textLimit: 4000,
sentMessageCache: { remember },
});
expect(remember).toHaveBeenCalledWith("acct-4:chat_id:40", {
text: "<media:image>",
messageId: "imsg-media-1",
});
});
});

View file

@ -0,0 +1,79 @@
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 type { createIMessageRpcClient } from "../client.js";
import { sendMessageIMessage } from "../send.js";
import {
chunkTextWithMode,
convertMarkdownTables,
loadConfig,
resolveChunkMode,
resolveMarkdownTableMode,
} from "./deliver.runtime.js";
import type { SentMessageCache } from "./echo-cache.js";
import { sanitizeOutboundText } from "./sanitize-outbound.js";
export async function deliverReplies(params: {
replies: ReplyPayload[];
target: string;
client: Awaited<ReturnType<typeof createIMessageRpcClient>>;
accountId?: string;
runtime: RuntimeEnv;
maxBytes: number;
textLimit: number;
sentMessageCache?: Pick<SentMessageCache, "remember">;
}) {
const { replies, target, client, runtime, maxBytes, textLimit, accountId, sentMessageCache } =
params;
const scope = `${accountId ?? ""}:${target}`;
const cfg = loadConfig();
const tableMode = resolveMarkdownTableMode({
cfg,
channel: "imessage",
accountId,
});
const chunkMode = resolveChunkMode(cfg, "imessage", accountId);
for (const payload of replies) {
const 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, {
maxBytes,
client,
accountId,
replyToId: payload.replyToId,
});
// Post-send cache population (#47830): caching happens after each chunk is sent,
// not before. The window between send completion and cache write is sub-millisecond;
// the next SQLite inbound poll is 1-2s away, so no echo can arrive before the
// cache entry exists.
sentMessageCache?.remember(scope, { text: sent.sentText, messageId: sent.messageId });
},
sendMedia: async ({ mediaUrl, caption }) => {
const sent = await sendMessageIMessage(target, caption ?? "", {
mediaUrl,
maxBytes,
client,
accountId,
replyToId: payload.replyToId,
});
sentMessageCache?.remember(scope, {
text: sent.sentText || undefined,
messageId: sent.messageId,
});
},
});
if (delivered !== "empty") {
runtime.log?.(`imessage: delivered reply to ${target}`);
}
}
}

View file

@ -0,0 +1,116 @@
export type SentMessageLookup = {
text?: string;
messageId?: string;
};
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, skipIdShortCircuit?: boolean) => 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 = text.replace(/\r\n?/g, "\n").trim();
return normalized ? normalized : null;
}
function normalizeEchoMessageIdKey(messageId: string | undefined): string | null {
if (!messageId) {
return null;
}
const normalized = messageId.trim();
if (!normalized || normalized === "ok" || normalized === "unknown") {
return null;
}
return normalized;
}
class DefaultSentMessageCache implements SentMessageCache {
private textCache = new Map<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, skipIdShortCircuit = false): boolean {
this.cleanup();
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 (!skipIdShortCircuit && !hasTextOnlyMatch) {
return false;
}
}
if (textKey) {
const textTimestamp = this.textCache.get(`${scope}:${textKey}`);
if (textTimestamp && Date.now() - textTimestamp <= SENT_MESSAGE_TEXT_TTL_MS) {
return true;
}
}
return false;
}
private cleanup(): void {
const now = Date.now();
for (const [key, timestamp] of this.textCache.entries()) {
if (now - timestamp > SENT_MESSAGE_TEXT_TTL_MS) {
this.textCache.delete(key);
}
}
for (const [key, timestamp] of this.textBackedByIdCache.entries()) {
if (now - timestamp > SENT_MESSAGE_TEXT_TTL_MS) {
this.textBackedByIdCache.delete(key);
}
}
for (const [key, timestamp] of this.messageIdCache.entries()) {
if (now - timestamp > SENT_MESSAGE_ID_TTL_MS) {
this.messageIdCache.delete(key);
}
}
}
}
export function createSentMessageCache(): SentMessageCache {
return new DefaultSentMessageCache();
}

View file

@ -0,0 +1,356 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { describe, expect, it, vi } from "vitest";
import { sanitizeTerminalText } from "../../../../src/terminal/safe-text.js";
import {
describeIMessageEchoDropLog,
resolveIMessageInboundDecision,
} from "./inbound-processing.js";
import { createSelfChatCache } from "./self-chat-cache.js";
describe("resolveIMessageInboundDecision echo detection", () => {
const cfg = {} as OpenClawConfig;
type InboundDecisionParams = Parameters<typeof resolveIMessageInboundDecision>[0];
function createInboundDecisionParams(
overrides: Omit<Partial<InboundDecisionParams>, "message"> & {
message?: Partial<InboundDecisionParams["message"]>;
} = {},
): InboundDecisionParams {
const { message: messageOverrides, ...restOverrides } = overrides;
const message = {
id: 42,
sender: "+15555550123",
text: "ok",
is_from_me: false,
is_group: false,
...messageOverrides,
};
const messageText = restOverrides.messageText ?? message.text ?? "";
const bodyText = restOverrides.bodyText ?? messageText;
const baseParams: Omit<InboundDecisionParams, "message" | "messageText" | "bodyText"> = {
cfg,
accountId: "default",
opts: undefined,
allowFrom: [],
groupAllowFrom: [],
groupPolicy: "open",
dmPolicy: "open",
storeAllowFrom: [],
historyLimit: 0,
groupHistories: new Map(),
echoCache: undefined,
selfChatCache: undefined,
logVerbose: undefined,
};
return {
...baseParams,
...restOverrides,
message,
messageText,
bodyText,
};
}
function resolveDecision(
overrides: Omit<Partial<InboundDecisionParams>, "message"> & {
message?: Partial<InboundDecisionParams["message"]>;
} = {},
) {
return resolveIMessageInboundDecision(createInboundDecisionParams(overrides));
}
it("drops inbound messages when outbound message id matches echo cache", () => {
const echoHas = vi.fn((_scope: string, lookup: { text?: string; messageId?: string }) => {
return lookup.messageId === "42";
});
const decision = resolveDecision({
message: {
id: 42,
text: "Reasoning:\n_step_",
},
messageText: "Reasoning:\n_step_",
bodyText: "Reasoning:\n_step_",
echoCache: { has: echoHas },
});
expect(decision).toEqual({ kind: "drop", reason: "echo" });
expect(echoHas).toHaveBeenNthCalledWith(1, "default:imessage:+15555550123", {
messageId: "42",
});
expect(echoHas).toHaveBeenCalledTimes(1);
});
it("matches attachment-only echoes by bodyText placeholder", () => {
const echoHas = vi.fn((_scope: string, lookup: { text?: string; messageId?: string }) => {
return lookup.text === "<media:image>" && lookup.messageId === "42";
});
const decision = resolveDecision({
message: {
id: 42,
text: "",
},
messageText: "",
bodyText: "<media:image>",
echoCache: { has: echoHas },
});
expect(decision).toEqual({ kind: "drop", reason: "echo" });
expect(echoHas).toHaveBeenNthCalledWith(1, "default:imessage:+15555550123", {
messageId: "42",
});
expect(echoHas).toHaveBeenNthCalledWith(
2,
"default:imessage:+15555550123",
{
text: "<media:image>",
messageId: "42",
},
undefined,
);
});
it("drops reflected self-chat duplicates after seeing the from-me copy", () => {
const selfChatCache = createSelfChatCache();
const createdAt = "2026-03-02T20:58:10.649Z";
expect(
resolveDecision({
message: {
id: 9641,
sender: "+15555550123",
chat_identifier: "+15555550123",
destination_caller_id: "+15555550123",
text: "Do you want to report this issue?",
created_at: createdAt,
is_from_me: true,
},
messageText: "Do you want to report this issue?",
bodyText: "Do you want to report this issue?",
selfChatCache,
}),
).toMatchObject({ kind: "dispatch" });
expect(
resolveDecision({
message: {
id: 9642,
sender: "+15555550123",
chat_identifier: "+15555550123",
text: "Do you want to report this issue?",
created_at: createdAt,
},
messageText: "Do you want to report this issue?",
bodyText: "Do you want to report this issue?",
selfChatCache,
}),
).toEqual({ kind: "drop", reason: "self-chat echo" });
});
it("does not drop same-text messages when created_at differs", () => {
const selfChatCache = createSelfChatCache();
resolveDecision({
message: {
id: 9641,
text: "ok",
created_at: "2026-03-02T20:58:10.649Z",
is_from_me: true,
},
selfChatCache,
});
const decision = resolveDecision({
message: {
id: 9642,
text: "ok",
created_at: "2026-03-02T20:58:11.649Z",
},
selfChatCache,
});
expect(decision.kind).toBe("dispatch");
});
it("keeps self-chat cache scoped to configured group threads", () => {
const selfChatCache = createSelfChatCache();
const groupedCfg = {
channels: {
imessage: {
groups: {
"123": {},
"456": {},
},
},
},
} as OpenClawConfig;
const createdAt = "2026-03-02T20:58:10.649Z";
expect(
resolveDecision({
cfg: groupedCfg,
message: {
id: 9701,
chat_id: 123,
text: "same text",
created_at: createdAt,
is_from_me: true,
},
selfChatCache,
}),
).toEqual({ kind: "drop", reason: "from me" });
const decision = resolveDecision({
cfg: groupedCfg,
message: {
id: 9702,
chat_id: 456,
text: "same text",
created_at: createdAt,
},
selfChatCache,
});
expect(decision.kind).toBe("dispatch");
});
it("does not drop other participants in the same group thread", () => {
const selfChatCache = createSelfChatCache();
const createdAt = "2026-03-02T20:58:10.649Z";
expect(
resolveDecision({
message: {
id: 9751,
chat_id: 123,
text: "same text",
created_at: createdAt,
is_from_me: true,
is_group: true,
},
selfChatCache,
}),
).toEqual({ kind: "drop", reason: "from me" });
const decision = resolveDecision({
message: {
id: 9752,
chat_id: 123,
sender: "+15555550999",
text: "same text",
created_at: createdAt,
is_group: true,
},
selfChatCache,
});
expect(decision.kind).toBe("dispatch");
});
it("sanitizes reflected duplicate previews before logging", () => {
const selfChatCache = createSelfChatCache();
const logVerbose = vi.fn();
const createdAt = "2026-03-02T20:58:10.649Z";
const bodyText = "line-1\nline-2\t\u001b[31mred";
resolveDecision({
message: {
id: 9801,
sender: "+15555550123",
chat_identifier: "+15555550123",
destination_caller_id: "+15555550123",
text: bodyText,
created_at: createdAt,
is_from_me: true,
},
messageText: bodyText,
bodyText,
selfChatCache,
logVerbose,
});
resolveDecision({
message: {
id: 9802,
sender: "+15555550123",
chat_identifier: "+15555550123",
text: bodyText,
created_at: createdAt,
},
messageText: bodyText,
bodyText,
selfChatCache,
logVerbose,
});
expect(logVerbose).toHaveBeenCalledWith(
`imessage: dropping self-chat reflected duplicate: "${sanitizeTerminalText(bodyText)}"`,
);
});
});
describe("describeIMessageEchoDropLog", () => {
it("includes message id when available", () => {
expect(
describeIMessageEchoDropLog({
messageText: "Reasoning:\n_step_",
messageId: "abc-123",
}),
).toContain("id=abc-123");
});
});
describe("resolveIMessageInboundDecision command auth", () => {
const cfg = {} as OpenClawConfig;
const resolveDmCommandDecision = (params: { messageId: number; storeAllowFrom: string[] }) =>
resolveIMessageInboundDecision({
cfg,
accountId: "default",
message: {
id: params.messageId,
sender: "+15555550123",
text: "/status",
is_from_me: false,
is_group: false,
},
opts: undefined,
messageText: "/status",
bodyText: "/status",
allowFrom: [],
groupAllowFrom: [],
groupPolicy: "open",
dmPolicy: "open",
storeAllowFrom: params.storeAllowFrom,
historyLimit: 0,
groupHistories: new Map(),
echoCache: undefined,
logVerbose: undefined,
});
it("does not auto-authorize DM commands in open mode without allowlists", () => {
const decision = resolveDmCommandDecision({
messageId: 100,
storeAllowFrom: [],
});
expect(decision.kind).toBe("dispatch");
if (decision.kind !== "dispatch") {
return;
}
expect(decision.commandAuthorized).toBe(false);
});
it("authorizes DM commands for senders in pairing-store allowlist", () => {
const decision = resolveDmCommandDecision({
messageId: 101,
storeAllowFrom: ["+15555550123"],
});
expect(decision.kind).toBe("dispatch");
if (decision.kind !== "dispatch") {
return;
}
expect(decision.commandAuthorized).toBe(true);
});
});

View file

@ -0,0 +1,672 @@
import {
buildMentionRegexes,
type EnvelopeFormatOptions,
formatInboundEnvelope,
formatInboundFromLabel,
logInboundDrop,
matchesMentionPatterns,
resolveEnvelopeFormatOptions,
resolveInboundMentionDecision,
} from "openclaw/plugin-sdk/channel-inbound";
import { hasControlCommand } from "openclaw/plugin-sdk/command-auth";
import { resolveDualTextControlCommandGate } from "openclaw/plugin-sdk/command-auth";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import {
resolveChannelContextVisibilityMode,
resolveChannelGroupPolicy,
resolveChannelGroupRequireMention,
} from "openclaw/plugin-sdk/config-runtime";
import {
buildPendingHistoryContextFromMap,
recordPendingHistoryEntryIfEnabled,
type HistoryEntry,
} from "openclaw/plugin-sdk/reply-history";
import { finalizeInboundContext } from "openclaw/plugin-sdk/reply-runtime";
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
import {
DM_GROUP_ACCESS_REASON,
resolveDmGroupAccessWithLists,
evaluateSupplementalContextVisibility,
} from "openclaw/plugin-sdk/security-runtime";
import { sanitizeTerminalText } from "openclaw/plugin-sdk/text-runtime";
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-runtime";
import { resolveIMessageConversationRoute } from "../conversation-route.js";
import {
formatIMessageChatTarget,
isAllowedIMessageSender,
normalizeIMessageHandle,
} from "../targets.js";
import { detectReflectedContent } from "./reflection-guard.js";
import type { SelfChatCache } from "./self-chat-cache.js";
import type { MonitorIMessageOpts, IMessagePayload } from "./types.js";
type IMessageReplyContext = {
id?: string;
body: string;
sender?: string;
};
function normalizeReplyField(value: unknown): string | undefined {
if (typeof value === "string") {
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
}
if (typeof value === "number") {
return String(value);
}
return undefined;
}
function describeReplyContext(message: IMessagePayload): IMessageReplyContext | null {
const body = normalizeReplyField(message.reply_to_text);
if (!body) {
return null;
}
const id = normalizeReplyField(message.reply_to_id);
const sender = normalizeReplyField(message.reply_to_sender);
return { body, id, sender };
}
function resolveInboundEchoMessageIds(message: IMessagePayload): string[] {
const values = [
message.id != null ? String(message.id) : undefined,
normalizeReplyField(message.guid),
];
const ids: string[] = [];
for (const value of values) {
if (!value || ids.includes(value)) {
continue;
}
ids.push(value);
}
return ids;
}
function hasIMessageEchoMatch(params: {
echoCache: {
has: (
scope: string,
lookup: { text?: string; messageId?: string },
skipIdShortCircuit?: boolean,
) => boolean;
};
scope: string;
text?: string;
messageIds: string[];
skipIdShortCircuit?: boolean;
}): boolean {
for (const messageId of params.messageIds) {
if (params.echoCache.has(params.scope, { messageId })) {
return true;
}
}
const fallbackMessageId = params.messageIds[0];
if (!params.text && !fallbackMessageId) {
return false;
}
return params.echoCache.has(
params.scope,
{ text: params.text, messageId: fallbackMessageId },
params.skipIdShortCircuit,
);
}
export type IMessageInboundDispatchDecision = {
kind: "dispatch";
isGroup: boolean;
chatId?: number;
chatGuid?: string;
chatIdentifier?: string;
groupId?: string;
historyKey?: string;
sender: string;
senderNormalized: string;
route: ReturnType<typeof resolveAgentRoute>;
bodyText: string;
createdAt?: number;
replyContext: IMessageReplyContext | null;
effectiveWasMentioned: boolean;
commandAuthorized: boolean;
// Used for allowlist checks for control commands.
effectiveDmAllowFrom: string[];
effectiveGroupAllowFrom: string[];
};
export type IMessageInboundDecision =
| { kind: "drop"; reason: string }
| { kind: "pairing"; senderId: string }
| IMessageInboundDispatchDecision;
export function resolveIMessageInboundDecision(params: {
cfg: OpenClawConfig;
accountId: string;
message: IMessagePayload;
opts?: Pick<MonitorIMessageOpts, "requireMention">;
messageText: string;
bodyText: string;
allowFrom: string[];
groupAllowFrom: string[];
groupPolicy: string;
dmPolicy: string;
storeAllowFrom: string[];
historyLimit: number;
groupHistories: Map<string, HistoryEntry[]>;
echoCache?: {
has: (
scope: string,
lookup: { text?: string; messageId?: string },
skipIdShortCircuit?: boolean,
) => boolean;
};
selfChatCache?: SelfChatCache;
logVerbose?: (msg: string) => void;
}): IMessageInboundDecision {
const senderRaw = params.message.sender ?? "";
const sender = senderRaw.trim();
if (!sender) {
return { kind: "drop", reason: "missing sender" };
}
const senderNormalized = normalizeIMessageHandle(sender);
const chatId = params.message.chat_id ?? undefined;
const chatGuid = params.message.chat_guid ?? undefined;
const chatIdentifier = params.message.chat_identifier ?? undefined;
const destinationCallerId = params.message.destination_caller_id ?? undefined;
const createdAt = params.message.created_at ? Date.parse(params.message.created_at) : undefined;
const messageText = params.messageText.trim();
const bodyText = params.bodyText.trim();
const groupIdCandidate = chatId !== undefined ? String(chatId) : undefined;
const groupListPolicy = groupIdCandidate
? resolveChannelGroupPolicy({
cfg: params.cfg,
channel: "imessage",
accountId: params.accountId,
groupId: groupIdCandidate,
})
: {
allowlistEnabled: false,
allowed: true,
groupConfig: undefined,
defaultConfig: undefined,
};
// If the owner explicitly configures a chat_id under imessage.groups, treat that thread as a
// "group" for permission gating + session isolation, even when is_group=false.
const treatAsGroupByConfig = Boolean(
groupIdCandidate && groupListPolicy.allowlistEnabled && groupListPolicy.groupConfig,
);
const isGroup = Boolean(params.message.is_group) || treatAsGroupByConfig;
const selfChatLookup = {
accountId: params.accountId,
isGroup,
chatId,
sender,
text: bodyText,
createdAt,
};
const chatIdentifierNormalized = normalizeIMessageHandle(chatIdentifier ?? "") || undefined;
const destinationCallerIdNormalized =
normalizeIMessageHandle(destinationCallerId ?? "") || undefined;
// Require an explicit destination handle that matches the sender. When
// destination_caller_id is missing, sender === chat_identifier is ambiguous:
// it is true for some DM SQLite rows as well as true self-chat (#63980).
const matchesSelfChatDestination =
destinationCallerIdNormalized != null && destinationCallerIdNormalized === senderNormalized;
const isSelfChat =
!isGroup &&
chatIdentifierNormalized != null &&
senderNormalized === chatIdentifierNormalized &&
matchesSelfChatDestination;
const isAmbiguousSelfThread =
!isGroup &&
chatIdentifierNormalized != null &&
senderNormalized === chatIdentifierNormalized &&
destinationCallerIdNormalized == null;
let skipSelfChatHasCheck = false;
const inboundMessageIds = resolveInboundEchoMessageIds(params.message);
const inboundMessageId = inboundMessageIds[0];
const hasInboundGuid = Boolean(normalizeReplyField(params.message.guid));
if (params.message.is_from_me) {
if (isAmbiguousSelfThread) {
params.selfChatCache?.remember(selfChatLookup);
}
if (isSelfChat) {
params.selfChatCache?.remember(selfChatLookup);
const echoScope = buildIMessageEchoScope({
accountId: params.accountId,
isGroup,
chatId,
sender,
});
if (
params.echoCache &&
(bodyText || inboundMessageId) &&
hasIMessageEchoMatch({
echoCache: params.echoCache,
scope: echoScope,
text: bodyText || undefined,
messageIds: inboundMessageIds,
skipIdShortCircuit: !hasInboundGuid,
})
) {
return { kind: "drop", reason: "agent echo in self-chat" };
}
skipSelfChatHasCheck = true;
} else {
return { kind: "drop", reason: "from me" };
}
}
if (isGroup && !chatId) {
return { kind: "drop", reason: "group without chat_id" };
}
const groupId = isGroup ? groupIdCandidate : undefined;
const accessDecision = resolveDmGroupAccessWithLists({
isGroup,
dmPolicy: params.dmPolicy,
groupPolicy: params.groupPolicy,
allowFrom: params.allowFrom,
groupAllowFrom: params.groupAllowFrom,
storeAllowFrom: params.storeAllowFrom,
groupAllowFromFallbackToAllowFrom: false,
isSenderAllowed: (allowFrom) =>
isAllowedIMessageSender({
allowFrom,
sender,
chatId,
chatGuid,
chatIdentifier,
}),
});
const effectiveDmAllowFrom = accessDecision.effectiveAllowFrom;
const effectiveGroupAllowFrom = accessDecision.effectiveGroupAllowFrom;
if (accessDecision.decision !== "allow") {
if (isGroup) {
if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.GROUP_POLICY_DISABLED) {
params.logVerbose?.("Blocked iMessage group message (groupPolicy: disabled)");
return { kind: "drop", reason: "groupPolicy disabled" };
}
if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.GROUP_POLICY_EMPTY_ALLOWLIST) {
params.logVerbose?.(
"Blocked iMessage group message (groupPolicy: allowlist, no groupAllowFrom)",
);
return { kind: "drop", reason: "groupPolicy allowlist (empty groupAllowFrom)" };
}
if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.GROUP_POLICY_NOT_ALLOWLISTED) {
params.logVerbose?.(`Blocked iMessage sender ${sender} (not in groupAllowFrom)`);
return { kind: "drop", reason: "not in groupAllowFrom" };
}
params.logVerbose?.(`Blocked iMessage group message (${accessDecision.reason})`);
return { kind: "drop", reason: accessDecision.reason };
}
if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.DM_POLICY_DISABLED) {
return { kind: "drop", reason: "dmPolicy disabled" };
}
if (accessDecision.decision === "pairing") {
return { kind: "pairing", senderId: senderNormalized };
}
params.logVerbose?.(`Blocked iMessage sender ${sender} (dmPolicy=${params.dmPolicy})`);
return { kind: "drop", reason: "dmPolicy blocked" };
}
if (isGroup && groupListPolicy.allowlistEnabled && !groupListPolicy.allowed) {
params.logVerbose?.(
`imessage: skipping group message (${groupId ?? "unknown"}) not in allowlist`,
);
return { kind: "drop", reason: "group id not in allowlist" };
}
const route = resolveIMessageConversationRoute({
cfg: params.cfg,
accountId: params.accountId,
isGroup,
peerId: isGroup ? String(chatId ?? "unknown") : senderNormalized,
sender,
chatId,
});
const mentionRegexes = buildMentionRegexes(params.cfg, route.agentId);
if (!bodyText) {
return { kind: "drop", reason: "empty body" };
}
const selfChatHit = skipSelfChatHasCheck
? false
: params.selfChatCache?.has({
...selfChatLookup,
text: bodyText,
});
if (selfChatHit) {
const preview = sanitizeTerminalText(truncateUtf16Safe(bodyText, 50));
params.logVerbose?.(`imessage: dropping self-chat reflected duplicate: "${preview}"`);
return { kind: "drop", reason: "self-chat echo" };
}
// Echo detection: check if the received message matches a recently sent message.
// Scope by conversation so same text in different chats is not conflated.
if (params.echoCache && (messageText || inboundMessageId)) {
const echoScope = buildIMessageEchoScope({
accountId: params.accountId,
isGroup,
chatId,
sender,
});
if (
hasIMessageEchoMatch({
echoCache: params.echoCache,
scope: echoScope,
text: bodyText || undefined,
messageIds: inboundMessageIds,
})
) {
params.logVerbose?.(
describeIMessageEchoDropLog({ messageText: bodyText, messageId: inboundMessageId }),
);
return { kind: "drop", reason: "echo" };
}
}
// Reflection guard: drop inbound messages that contain assistant-internal
// metadata markers. These indicate outbound content was reflected back as
// inbound, which causes recursive echo amplification.
const reflection = detectReflectedContent(messageText);
if (reflection.isReflection) {
params.logVerbose?.(
`imessage: dropping reflected assistant content (markers: ${reflection.matchedLabels.join(", ")})`,
);
return { kind: "drop", reason: "reflected assistant content" };
}
const replyContext = describeReplyContext(params.message);
const contextVisibilityMode = resolveChannelContextVisibilityMode({
cfg: params.cfg,
channel: "imessage",
accountId: params.accountId,
});
const replySenderAllowed =
!isGroup || effectiveGroupAllowFrom.length === 0
? true
: replyContext?.sender
? isAllowedIMessageSender({
allowFrom: effectiveGroupAllowFrom,
sender: replyContext.sender,
chatId,
chatGuid,
chatIdentifier,
})
: false;
const filteredReplyContext =
!replyContext ||
evaluateSupplementalContextVisibility({
mode: contextVisibilityMode,
kind: "quote",
senderAllowed: replySenderAllowed,
}).include
? replyContext
: null;
if (replyContext && !filteredReplyContext && isGroup) {
params.logVerbose?.(
`imessage: drop reply context (mode=${contextVisibilityMode}, sender_allowed=${replySenderAllowed ? "yes" : "no"})`,
);
}
const historyKey = isGroup
? String(chatId ?? chatGuid ?? chatIdentifier ?? "unknown")
: undefined;
const mentioned = isGroup ? matchesMentionPatterns(messageText, mentionRegexes) : true;
const requireMention = resolveChannelGroupRequireMention({
cfg: params.cfg,
channel: "imessage",
accountId: params.accountId,
groupId,
requireMentionOverride: params.opts?.requireMention,
overrideOrder: "before-config",
});
const canDetectMention = mentionRegexes.length > 0;
const useAccessGroups = params.cfg.commands?.useAccessGroups !== false;
const commandDmAllowFrom = isGroup ? params.allowFrom : effectiveDmAllowFrom;
const ownerAllowedForCommands =
commandDmAllowFrom.length > 0
? isAllowedIMessageSender({
allowFrom: commandDmAllowFrom,
sender,
chatId,
chatGuid,
chatIdentifier,
})
: false;
const groupAllowedForCommands =
effectiveGroupAllowFrom.length > 0
? isAllowedIMessageSender({
allowFrom: effectiveGroupAllowFrom,
sender,
chatId,
chatGuid,
chatIdentifier,
})
: false;
const hasControlCommandInMessage = hasControlCommand(messageText, params.cfg);
const { commandAuthorized, shouldBlock } = resolveDualTextControlCommandGate({
useAccessGroups,
primaryConfigured: commandDmAllowFrom.length > 0,
primaryAllowed: ownerAllowedForCommands,
secondaryConfigured: effectiveGroupAllowFrom.length > 0,
secondaryAllowed: groupAllowedForCommands,
hasControlCommand: hasControlCommandInMessage,
});
if (isGroup && shouldBlock) {
if (params.logVerbose) {
logInboundDrop({
log: params.logVerbose,
channel: "imessage",
reason: "control command (unauthorized)",
target: sender,
});
}
return { kind: "drop", reason: "control command (unauthorized)" };
}
const mentionDecision = resolveInboundMentionDecision({
facts: {
canDetectMention,
wasMentioned: mentioned,
hasAnyMention: false,
implicitMentionKinds: [],
},
policy: {
isGroup,
requireMention,
allowTextCommands: true,
hasControlCommand: hasControlCommandInMessage,
commandAuthorized,
},
});
const effectiveWasMentioned = mentionDecision.effectiveWasMentioned;
if (isGroup && requireMention && canDetectMention && mentionDecision.shouldSkip) {
params.logVerbose?.(`imessage: skipping group message (no mention)`);
recordPendingHistoryEntryIfEnabled({
historyMap: params.groupHistories,
historyKey: historyKey ?? "",
limit: params.historyLimit,
entry: historyKey
? {
sender: senderNormalized,
body: bodyText,
timestamp: createdAt,
messageId: params.message.id ? String(params.message.id) : undefined,
}
: null,
});
return { kind: "drop", reason: "no mention" };
}
return {
kind: "dispatch",
isGroup,
chatId,
chatGuid,
chatIdentifier,
groupId,
historyKey,
sender,
senderNormalized,
route,
bodyText,
createdAt,
replyContext: filteredReplyContext,
effectiveWasMentioned,
commandAuthorized,
effectiveDmAllowFrom,
effectiveGroupAllowFrom,
};
}
export function buildIMessageInboundContext(params: {
cfg: OpenClawConfig;
decision: IMessageInboundDispatchDecision;
message: IMessagePayload;
envelopeOptions?: EnvelopeFormatOptions;
previousTimestamp?: number;
remoteHost?: string;
media?: {
path?: string;
type?: string;
paths?: string[];
types?: Array<string | undefined>;
};
historyLimit: number;
groupHistories: Map<string, HistoryEntry[]>;
}): {
ctxPayload: ReturnType<typeof finalizeInboundContext>;
fromLabel: string;
chatTarget?: string;
imessageTo: string;
inboundHistory?: Array<{ sender: string; body: string; timestamp?: number }>;
} {
const envelopeOptions = params.envelopeOptions ?? resolveEnvelopeFormatOptions(params.cfg);
const { decision } = params;
const chatId = decision.chatId;
const chatTarget =
decision.isGroup && chatId != null ? formatIMessageChatTarget(chatId) : undefined;
const replySuffix = decision.replyContext
? `\n\n[Replying to ${decision.replyContext.sender ?? "unknown sender"}${
decision.replyContext.id ? ` id:${decision.replyContext.id}` : ""
}]\n${decision.replyContext.body}\n[/Replying]`
: "";
const fromLabel = formatInboundFromLabel({
isGroup: decision.isGroup,
groupLabel: params.message.chat_name ?? undefined,
groupId: chatId !== undefined ? String(chatId) : "unknown",
groupFallback: "Group",
directLabel: decision.senderNormalized,
directId: decision.sender,
});
const body = formatInboundEnvelope({
channel: "iMessage",
from: fromLabel,
timestamp: decision.createdAt,
body: `${decision.bodyText}${replySuffix}`,
chatType: decision.isGroup ? "group" : "direct",
sender: { name: decision.senderNormalized, id: decision.sender },
previousTimestamp: params.previousTimestamp,
envelope: envelopeOptions,
});
let combinedBody = body;
if (decision.isGroup && decision.historyKey) {
combinedBody = buildPendingHistoryContextFromMap({
historyMap: params.groupHistories,
historyKey: decision.historyKey,
limit: params.historyLimit,
currentMessage: combinedBody,
formatEntry: (entry) =>
formatInboundEnvelope({
channel: "iMessage",
from: fromLabel,
timestamp: entry.timestamp,
body: `${entry.body}${entry.messageId ? ` [id:${entry.messageId}]` : ""}`,
chatType: "group",
senderLabel: entry.sender,
envelope: envelopeOptions,
}),
});
}
const imessageTo = (decision.isGroup ? chatTarget : undefined) || `imessage:${decision.sender}`;
const inboundHistory =
decision.isGroup && decision.historyKey && params.historyLimit > 0
? (params.groupHistories.get(decision.historyKey) ?? []).map((entry) => ({
sender: entry.sender,
body: entry.body,
timestamp: entry.timestamp,
}))
: undefined;
const ctxPayload = finalizeInboundContext({
Body: combinedBody,
BodyForAgent: decision.bodyText,
InboundHistory: inboundHistory,
RawBody: decision.bodyText,
CommandBody: decision.bodyText,
From: decision.isGroup
? `imessage:group:${chatId ?? "unknown"}`
: `imessage:${decision.sender}`,
To: imessageTo,
SessionKey: decision.route.sessionKey,
AccountId: decision.route.accountId,
ChatType: decision.isGroup ? "group" : "direct",
ConversationLabel: fromLabel,
GroupSubject: decision.isGroup ? (params.message.chat_name ?? undefined) : undefined,
GroupMembers: decision.isGroup
? (params.message.participants ?? []).filter(Boolean).join(", ")
: undefined,
SenderName: decision.senderNormalized,
SenderId: decision.sender,
Provider: "imessage",
Surface: "imessage",
MessageSid: params.message.id ? String(params.message.id) : undefined,
ReplyToId: decision.replyContext?.id,
ReplyToBody: decision.replyContext?.body,
ReplyToSender: decision.replyContext?.sender,
Timestamp: decision.createdAt,
MediaPath: params.media?.path,
MediaType: params.media?.type,
MediaUrl: params.media?.path,
MediaPaths:
params.media?.paths && params.media.paths.length > 0 ? params.media.paths : undefined,
MediaTypes:
params.media?.types && params.media.types.length > 0 ? params.media.types : undefined,
MediaUrls:
params.media?.paths && params.media.paths.length > 0 ? params.media.paths : undefined,
MediaRemoteHost: params.remoteHost,
WasMentioned: decision.effectiveWasMentioned,
CommandAuthorized: decision.commandAuthorized,
OriginatingChannel: "imessage" as const,
OriginatingTo: imessageTo,
});
return { ctxPayload, fromLabel, chatTarget, imessageTo, inboundHistory };
}
export function buildIMessageEchoScope(params: {
accountId: string;
isGroup: boolean;
chatId?: number;
sender: string;
}): string {
return `${params.accountId}:${params.isGroup ? formatIMessageChatTarget(params.chatId) : `imessage:${params.sender}`}`;
}
export function describeIMessageEchoDropLog(params: {
messageText: string;
messageId?: string;
}): string {
const preview = truncateUtf16Safe(params.messageText, 50);
const messageIdPart = params.messageId ? ` id=${params.messageId}` : "";
return `imessage: skipping echo message${messageIdPart}: "${preview}"`;
}

View file

@ -0,0 +1,50 @@
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);
});
});

View file

@ -0,0 +1,69 @@
/**
* Per-conversation rate limiter that detects rapid-fire identical echo
* patterns and suppresses them before they amplify into queue overflow.
*/
const DEFAULT_WINDOW_MS = 60_000;
const DEFAULT_MAX_HITS = 5;
const CLEANUP_INTERVAL_MS = 120_000;
type ConversationWindow = {
timestamps: number[];
};
export type LoopRateLimiter = {
/** Returns true if this conversation has exceeded the rate limit. */
isRateLimited: (conversationKey: string) => boolean;
/** Record an inbound message for a conversation. */
record: (conversationKey: string) => void;
};
export function createLoopRateLimiter(opts?: {
windowMs?: number;
maxHits?: number;
}): LoopRateLimiter {
const windowMs = opts?.windowMs ?? DEFAULT_WINDOW_MS;
const maxHits = opts?.maxHits ?? DEFAULT_MAX_HITS;
const conversations = new Map<string, ConversationWindow>();
let lastCleanup = Date.now();
function cleanup() {
const now = Date.now();
if (now - lastCleanup < CLEANUP_INTERVAL_MS) {
return;
}
lastCleanup = now;
for (const [key, win] of conversations.entries()) {
const recent = win.timestamps.filter((ts) => now - ts <= windowMs);
if (recent.length === 0) {
conversations.delete(key);
} else {
win.timestamps = recent;
}
}
}
return {
record(conversationKey: string) {
cleanup();
let win = conversations.get(conversationKey);
if (!win) {
win = { timestamps: [] };
conversations.set(conversationKey, win);
}
win.timestamps.push(Date.now());
},
isRateLimited(conversationKey: string): boolean {
cleanup();
const win = conversations.get(conversationKey);
if (!win) {
return false;
}
const now = Date.now();
const recent = win.timestamps.filter((ts) => now - ts <= windowMs);
win.timestamps = recent;
return recent.length >= maxHits;
},
};
}

View file

@ -0,0 +1,44 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createSentMessageCache } from "./echo-cache.js";
describe("iMessage sent-message echo cache", () => {
afterEach(() => {
vi.useRealTimers();
});
it("matches recent text within the same scope", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-02-25T00:00:00Z"));
const cache = createSentMessageCache();
cache.remember("acct:imessage:+1555", { text: " Reasoning:\r\n_step_ " });
expect(cache.has("acct:imessage:+1555", { text: "Reasoning:\n_step_" })).toBe(true);
expect(cache.has("acct:imessage:+1666", { text: "Reasoning:\n_step_" })).toBe(false);
});
it("matches by outbound message id and ignores placeholder ids", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-02-25T00:00:00Z"));
const cache = createSentMessageCache();
cache.remember("acct:imessage:+1555", { messageId: "abc-123" });
cache.remember("acct:imessage:+1555", { messageId: "ok" });
expect(cache.has("acct:imessage:+1555", { messageId: "abc-123" })).toBe(true);
expect(cache.has("acct:imessage:+1555", { messageId: "ok" })).toBe(false);
});
it("keeps message-id lookups longer than text fallback", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-02-25T00:00:00Z"));
const cache = createSentMessageCache();
cache.remember("acct:imessage:+1555", { text: "hello", messageId: "m-1" });
// Text fallback stays short to avoid suppressing legitimate repeated user text.
vi.advanceTimersByTime(6_000);
expect(cache.has("acct:imessage:+1555", { text: "hello" })).toBe(false);
expect(cache.has("acct:imessage:+1555", { messageId: "m-1" })).toBe(true);
});
});

View file

@ -0,0 +1,647 @@
import fs from "node:fs/promises";
import { resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime";
import {
createChannelInboundDebouncer,
shouldDebounceTextInbound,
} from "openclaw/plugin-sdk/channel-inbound";
import { createChannelPairingChallengeIssuer } from "openclaw/plugin-sdk/channel-pairing";
import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
import { loadConfig } from "openclaw/plugin-sdk/config-runtime";
import {
resolveOpenProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
warnMissingProviderGroupPolicyFallbackOnce,
} from "openclaw/plugin-sdk/config-runtime";
import { readSessionUpdatedAt, resolveStorePath } from "openclaw/plugin-sdk/config-runtime";
import {
readChannelAllowFromStore,
upsertChannelPairingRequest,
} from "openclaw/plugin-sdk/conversation-runtime";
import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime";
import { normalizeScpRemoteHost } from "openclaw/plugin-sdk/host-runtime";
import { waitForTransportReady } from "openclaw/plugin-sdk/infra-runtime";
import { isInboundPathAllowed, kindFromMime } from "openclaw/plugin-sdk/media-runtime";
import {
clearHistoryEntriesIfEnabled,
DEFAULT_GROUP_HISTORY_LIMIT,
type HistoryEntry,
} from "openclaw/plugin-sdk/reply-history";
import { resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime";
import { dispatchInboundMessage } from "openclaw/plugin-sdk/reply-runtime";
import { createReplyDispatcher } from "openclaw/plugin-sdk/reply-runtime";
import { danger, logVerbose, shouldLogVerbose, warn } from "openclaw/plugin-sdk/runtime-env";
import { resolvePinnedMainDmOwnerFromAllowlist } from "openclaw/plugin-sdk/security-runtime";
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-runtime";
import { resolveIMessageAccount } from "../accounts.js";
import { createIMessageRpcClient, type IMessageRpcClient } from "../client.js";
import { DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS } from "../constants.js";
import {
resolveIMessageAttachmentRoots,
resolveIMessageRemoteAttachmentRoots,
} from "../media-contract.js";
import { probeIMessage } from "../probe.js";
import { sendMessageIMessage } from "../send.js";
import { normalizeIMessageHandle } from "../targets.js";
import { attachIMessageMonitorAbortHandler } from "./abort-handler.js";
import { deliverReplies } from "./deliver.js";
import { createSentMessageCache } from "./echo-cache.js";
import {
buildIMessageInboundContext,
resolveIMessageInboundDecision,
} from "./inbound-processing.js";
import { createLoopRateLimiter } from "./loop-rate-limiter.js";
import { parseIMessageNotification } from "./parse-notification.js";
import { normalizeAllowList, resolveRuntime } from "./runtime.js";
import { createSelfChatCache } from "./self-chat-cache.js";
import type { IMessagePayload, MonitorIMessageOpts } from "./types.js";
import { sanitizeIMessageWatchErrorPayload } from "./watch-error-log.js";
const WATCH_SUBSCRIBE_MAX_ATTEMPTS = 3;
const WATCH_SUBSCRIBE_RETRY_DELAY_MS = 1_000;
/**
* Try to detect remote host from an SSH wrapper script like:
* exec ssh -T openclaw@192.168.64.3 /opt/homebrew/bin/imsg "$@"
* exec ssh -T mac-mini imsg "$@"
* Returns the user@host or host portion if found, undefined otherwise.
*/
async function detectRemoteHostFromCliPath(cliPath: string): Promise<string | undefined> {
try {
// Expand ~ to home directory
const expanded = cliPath.startsWith("~")
? cliPath.replace(/^~/, process.env.HOME ?? "")
: cliPath;
const content = await fs.readFile(expanded, "utf8");
// Match user@host pattern first (e.g., openclaw@192.168.64.3)
const userHostMatch = content.match(/\bssh\b[^\n]*?\s+([a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+)/);
if (userHostMatch) {
return userHostMatch[1];
}
// Fallback: match host-only before imsg command (e.g., ssh -T mac-mini imsg)
const hostOnlyMatch = content.match(/\bssh\b[^\n]*?\s+([a-zA-Z][a-zA-Z0-9._-]*)\s+\S*\bimsg\b/);
return hostOnlyMatch?.[1];
} catch {
return undefined;
}
}
function isRetriableWatchSubscribeStartupError(error: unknown): boolean {
return /imsg rpc timeout \(watch\.subscribe\)|imsg rpc (closed|exited|not running)/i.test(
String(error),
);
}
async function waitForWatchSubscribeRetryDelay(params: {
ms: number;
abortSignal?: AbortSignal;
}): Promise<void> {
if (params.ms <= 0) {
return;
}
await new Promise<void>((resolve) => {
const timer = setTimeout(() => {
params.abortSignal?.removeEventListener("abort", onAbort);
resolve();
}, params.ms);
const onAbort = () => {
clearTimeout(timer);
params.abortSignal?.removeEventListener("abort", onAbort);
resolve();
};
params.abortSignal?.addEventListener("abort", onAbort, { once: true });
});
}
export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): Promise<void> {
const runtime = resolveRuntime(opts);
const cfg = opts.config ?? loadConfig();
const accountInfo = resolveIMessageAccount({
cfg,
accountId: opts.accountId,
});
const imessageCfg = accountInfo.config;
const historyLimit = Math.max(
0,
imessageCfg.historyLimit ??
cfg.messages?.groupChat?.historyLimit ??
DEFAULT_GROUP_HISTORY_LIMIT,
);
const groupHistories = new Map<string, HistoryEntry[]>();
const sentMessageCache = createSentMessageCache();
const selfChatCache = createSelfChatCache();
const loopRateLimiter = createLoopRateLimiter();
const textLimit = resolveTextChunkLimit(cfg, "imessage", accountInfo.accountId);
const allowFrom = normalizeAllowList(opts.allowFrom ?? imessageCfg.allowFrom);
const groupAllowFrom = normalizeAllowList(
opts.groupAllowFrom ??
imessageCfg.groupAllowFrom ??
(imessageCfg.allowFrom && imessageCfg.allowFrom.length > 0 ? imessageCfg.allowFrom : []),
);
const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
const { groupPolicy, providerMissingFallbackApplied } = resolveOpenProviderRuntimeGroupPolicy({
providerConfigPresent: cfg.channels?.imessage !== undefined,
groupPolicy: imessageCfg.groupPolicy,
defaultGroupPolicy,
});
warnMissingProviderGroupPolicyFallbackOnce({
providerMissingFallbackApplied,
providerKey: "imessage",
accountId: accountInfo.accountId,
log: (message) => runtime.log?.(warn(message)),
});
const dmPolicy = imessageCfg.dmPolicy ?? "pairing";
const includeAttachments = opts.includeAttachments ?? imessageCfg.includeAttachments ?? false;
const mediaMaxBytes = (opts.mediaMaxMb ?? imessageCfg.mediaMaxMb ?? 16) * 1024 * 1024;
const cliPath = opts.cliPath ?? imessageCfg.cliPath ?? "imsg";
const dbPath = opts.dbPath ?? imessageCfg.dbPath;
const probeTimeoutMs = imessageCfg.probeTimeoutMs ?? DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS;
const attachmentRoots = resolveIMessageAttachmentRoots({
cfg,
accountId: accountInfo.accountId,
});
const remoteAttachmentRoots = resolveIMessageRemoteAttachmentRoots({
cfg,
accountId: accountInfo.accountId,
});
// Resolve remoteHost: explicit config, or auto-detect from SSH wrapper script.
// Accept only a safe host token to avoid option/argument injection into SCP.
const configuredRemoteHost = normalizeScpRemoteHost(imessageCfg.remoteHost);
if (imessageCfg.remoteHost && !configuredRemoteHost) {
logVerbose("imessage: ignoring unsafe channels.imessage.remoteHost value");
}
let remoteHost = configuredRemoteHost;
if (!remoteHost && cliPath && cliPath !== "imsg") {
const detected = await detectRemoteHostFromCliPath(cliPath);
const normalizedDetected = normalizeScpRemoteHost(detected);
if (detected && !normalizedDetected) {
logVerbose("imessage: ignoring unsafe auto-detected remoteHost from cliPath");
}
remoteHost = normalizedDetected;
if (remoteHost) {
logVerbose(`imessage: detected remoteHost=${remoteHost} from cliPath`);
}
}
const { debouncer: inboundDebouncer } = createChannelInboundDebouncer<{
message: IMessagePayload;
}>({
cfg,
channel: "imessage",
buildKey: (entry) => {
const sender = entry.message.sender?.trim();
if (!sender) {
return null;
}
const conversationId =
entry.message.chat_id != null
? `chat:${entry.message.chat_id}`
: (entry.message.chat_guid ?? entry.message.chat_identifier ?? "unknown");
return `imessage:${accountInfo.accountId}:${conversationId}:${sender}`;
},
shouldDebounce: (entry) => {
return shouldDebounceTextInbound({
text: entry.message.text,
cfg,
hasMedia: Boolean(entry.message.attachments && entry.message.attachments.length > 0),
});
},
onFlush: async (entries) => {
const last = entries.at(-1);
if (!last) {
return;
}
if (entries.length === 1) {
await handleMessageNow(last.message);
return;
}
const combinedText = entries
.map((entry) => entry.message.text ?? "")
.filter(Boolean)
.join("\n");
const syntheticMessage: IMessagePayload = {
...last.message,
text: combinedText,
attachments: null,
};
await handleMessageNow(syntheticMessage);
},
onError: (err) => {
runtime.error?.(`imessage debounce flush failed: ${String(err)}`);
},
});
let client: IMessageRpcClient | undefined;
let detachAbortHandler = () => {};
const getActiveClient = () => {
if (!client) {
throw new Error("imessage monitor client not initialized");
}
return client;
};
async function handleMessageNow(message: IMessagePayload) {
const messageText = (message.text ?? "").trim();
const attachments = includeAttachments ? (message.attachments ?? []) : [];
const effectiveAttachmentRoots = remoteHost ? remoteAttachmentRoots : attachmentRoots;
const validAttachments = attachments.filter((entry) => {
const attachmentPath = entry?.original_path?.trim();
if (!attachmentPath || entry?.missing) {
return false;
}
if (isInboundPathAllowed({ filePath: attachmentPath, roots: effectiveAttachmentRoots })) {
return true;
}
logVerbose(`imessage: dropping inbound attachment outside allowed roots: ${attachmentPath}`);
return false;
});
const firstAttachment = validAttachments[0];
const mediaPath = firstAttachment?.original_path ?? undefined;
const mediaType = firstAttachment?.mime_type ?? undefined;
// Build arrays for all attachments (for multi-image support)
const mediaPaths = validAttachments.map((a) => a.original_path).filter(Boolean) as string[];
const mediaTypes = validAttachments.map((a) => a.mime_type ?? undefined);
const kind = kindFromMime(mediaType ?? undefined);
const placeholder = kind
? `<media:${kind}>`
: validAttachments.length
? "<media:attachment>"
: "";
const bodyText = messageText || placeholder;
const storeAllowFrom = await readChannelAllowFromStore(
"imessage",
process.env,
accountInfo.accountId,
).catch(() => []);
const decision = resolveIMessageInboundDecision({
cfg,
accountId: accountInfo.accountId,
message,
opts,
messageText,
bodyText,
allowFrom,
groupAllowFrom,
groupPolicy,
dmPolicy,
storeAllowFrom,
historyLimit,
groupHistories,
echoCache: sentMessageCache,
selfChatCache,
logVerbose,
});
// Build conversation key for rate limiting (used by both drop and dispatch paths).
const chatId = message.chat_id ?? undefined;
const senderForKey = (message.sender ?? "").trim();
const conversationKey = chatId != null ? `group:${chatId}` : `dm:${senderForKey}`;
const rateLimitKey = `${accountInfo.accountId}:${conversationKey}`;
if (decision.kind === "drop") {
// Record echo/reflection drops so the rate limiter can detect sustained loops.
// Only loop-related drop reasons feed the counter; policy/mention/empty drops
// are normal and should not escalate.
const isLoopDrop =
decision.reason === "echo" ||
decision.reason === "self-chat echo" ||
decision.reason === "reflected assistant content" ||
decision.reason === "from me";
if (isLoopDrop) {
loopRateLimiter.record(rateLimitKey);
}
return;
}
// After repeated echo/reflection drops for a conversation, suppress all
// remaining messages as a safety net against amplification that slips
// through the primary guards.
if (decision.kind === "dispatch" && loopRateLimiter.isRateLimited(rateLimitKey)) {
logVerbose(`imessage: rate-limited conversation ${conversationKey} (echo loop detected)`);
return;
}
if (decision.kind === "pairing") {
const sender = (message.sender ?? "").trim();
if (!sender) {
return;
}
await createChannelPairingChallengeIssuer({
channel: "imessage",
upsertPairingRequest: async ({ id, meta }) =>
await upsertChannelPairingRequest({
channel: "imessage",
id,
accountId: accountInfo.accountId,
meta,
}),
})({
senderId: decision.senderId,
senderIdLine: `Your iMessage sender id: ${decision.senderId}`,
meta: {
sender: decision.senderId,
chatId: chatId ? String(chatId) : undefined,
},
onCreated: () => {
logVerbose(`imessage pairing request sender=${decision.senderId}`);
},
sendPairingReply: async (text) => {
await sendMessageIMessage(sender, text, {
client: getActiveClient(),
maxBytes: mediaMaxBytes,
accountId: accountInfo.accountId,
...(chatId ? { chatId } : {}),
});
},
onReplyError: (err) => {
logVerbose(`imessage pairing reply failed for ${decision.senderId}: ${String(err)}`);
},
});
return;
}
const storePath = resolveStorePath(cfg.session?.store, {
agentId: decision.route.agentId,
});
const previousTimestamp = readSessionUpdatedAt({
storePath,
sessionKey: decision.route.sessionKey,
});
const { ctxPayload, chatTarget } = buildIMessageInboundContext({
cfg,
decision,
message,
previousTimestamp,
remoteHost,
historyLimit,
groupHistories,
media: {
path: mediaPath,
type: mediaType,
paths: mediaPaths,
types: mediaTypes,
},
});
const updateTarget = chatTarget || decision.sender;
const pinnedMainDmOwner = resolvePinnedMainDmOwnerFromAllowlist({
dmScope: cfg.session?.dmScope,
allowFrom,
normalizeEntry: normalizeIMessageHandle,
});
await recordInboundSession({
storePath,
sessionKey: ctxPayload.SessionKey ?? decision.route.sessionKey,
ctx: ctxPayload,
updateLastRoute:
!decision.isGroup && updateTarget
? {
sessionKey: decision.route.mainSessionKey,
channel: "imessage",
to: updateTarget,
accountId: decision.route.accountId,
mainDmOwnerPin:
pinnedMainDmOwner && decision.senderNormalized
? {
ownerRecipient: pinnedMainDmOwner,
senderRecipient: decision.senderNormalized,
onSkip: ({ ownerRecipient, senderRecipient }) => {
logVerbose(
`imessage: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`,
);
},
}
: undefined,
}
: undefined,
onRecordError: (err) => {
logVerbose(`imessage: failed updating session meta: ${String(err)}`);
},
});
if (shouldLogVerbose()) {
const preview = truncateUtf16Safe(ctxPayload.Body ?? "", 200).replace(/\n/g, "\\n");
logVerbose(
`imessage inbound: chatId=${chatId ?? "unknown"} from=${ctxPayload.From} len=${
(ctxPayload.Body ?? "").length
} preview="${preview}"`,
);
}
const { onModelSelected, ...replyPipeline } = createChannelReplyPipeline({
cfg,
agentId: decision.route.agentId,
channel: "imessage",
accountId: decision.route.accountId,
});
const dispatcher = createReplyDispatcher({
...replyPipeline,
humanDelay: resolveHumanDelayConfig(cfg, decision.route.agentId),
deliver: async (payload) => {
const target = ctxPayload.To;
if (!target) {
runtime.error?.(danger("imessage: missing delivery target"));
return;
}
await deliverReplies({
replies: [payload],
target,
client: getActiveClient(),
accountId: accountInfo.accountId,
runtime,
maxBytes: mediaMaxBytes,
textLimit,
sentMessageCache,
});
},
onError: (err, info) => {
runtime.error?.(danger(`imessage ${info.kind} reply failed: ${String(err)}`));
},
});
const { queuedFinal } = await dispatchInboundMessage({
ctx: ctxPayload,
cfg,
dispatcher,
replyOptions: {
disableBlockStreaming:
typeof accountInfo.config.blockStreaming === "boolean"
? !accountInfo.config.blockStreaming
: undefined,
onModelSelected,
},
});
if (!queuedFinal) {
if (decision.isGroup && decision.historyKey) {
clearHistoryEntriesIfEnabled({
historyMap: groupHistories,
historyKey: decision.historyKey,
limit: historyLimit,
});
}
return;
}
if (decision.isGroup && decision.historyKey) {
clearHistoryEntriesIfEnabled({
historyMap: groupHistories,
historyKey: decision.historyKey,
limit: historyLimit,
});
}
}
const handleMessage = async (raw: unknown) => {
const message = parseIMessageNotification(raw);
if (!message) {
logVerbose("imessage: dropping malformed RPC message payload");
return;
}
await inboundDebouncer.enqueue({ message });
};
await waitForTransportReady({
label: "imsg rpc",
timeoutMs: 30_000,
logAfterMs: 10_000,
logIntervalMs: 10_000,
pollIntervalMs: 500,
abortSignal: opts.abortSignal,
runtime,
check: async () => {
const probe = await probeIMessage(probeTimeoutMs, { cliPath, dbPath, runtime });
if (probe.ok) {
return { ok: true };
}
if (probe.fatal) {
throw new Error(probe.error ?? "imsg rpc unavailable");
}
return { ok: false, error: probe.error ?? "unreachable" };
},
});
if (opts.abortSignal?.aborted) {
return;
}
const abort = opts.abortSignal;
const createWatchClient = async () =>
await createIMessageRpcClient({
cliPath,
dbPath,
runtime,
onNotification: (msg) => {
if (msg.method === "message") {
void handleMessage(msg.params).catch((err) => {
runtime.error?.(`imessage: handler failed: ${String(err)}`);
});
} else if (msg.method === "error") {
runtime.error?.(
`imessage: watch error ${JSON.stringify(sanitizeIMessageWatchErrorPayload(msg.params))}`,
);
}
},
});
const requireWatchClient = (
watchClient: IMessageRpcClient | null | undefined,
): IMessageRpcClient => {
if (!watchClient) {
throw new Error("imessage monitor client not initialized");
}
return watchClient;
};
for (let attempt = 1; attempt <= WATCH_SUBSCRIBE_MAX_ATTEMPTS; attempt++) {
if (abort?.aborted) {
return;
}
let attemptClient: IMessageRpcClient | undefined;
let attemptDetachAbortHandler = () => {};
let keepAttemptClient = false;
try {
attemptClient = requireWatchClient(await createWatchClient());
let attemptSubscriptionId: number | null = null;
attemptDetachAbortHandler = attachIMessageMonitorAbortHandler({
abortSignal: abort,
client: attemptClient,
getSubscriptionId: () => attemptSubscriptionId,
});
const result = await attemptClient.request<{ subscription?: number }>(
"watch.subscribe",
{
attachments: includeAttachments,
},
{ timeoutMs: probeTimeoutMs },
);
attemptSubscriptionId = result?.subscription ?? null;
client = attemptClient;
detachAbortHandler = attemptDetachAbortHandler;
keepAttemptClient = true;
break;
} catch (err) {
if (abort?.aborted) {
return;
}
const shouldRetry =
attempt < WATCH_SUBSCRIBE_MAX_ATTEMPTS && isRetriableWatchSubscribeStartupError(err);
if (!shouldRetry) {
runtime.error?.(danger(`imessage: monitor failed: ${String(err)}`));
throw err;
}
runtime.log?.(
warn(
`imessage: watch.subscribe startup failed (attempt ${attempt}/${WATCH_SUBSCRIBE_MAX_ATTEMPTS}): ${String(err)}; retrying`,
),
);
// Tear down the failed client before waiting so a slow subscribe attempt
// cannot keep emitting notifications into the next retry window.
attemptDetachAbortHandler();
attemptDetachAbortHandler = () => {};
await attemptClient?.stop();
attemptClient = undefined;
await waitForWatchSubscribeRetryDelay({
ms: WATCH_SUBSCRIBE_RETRY_DELAY_MS,
abortSignal: abort,
});
if (abort?.aborted) {
return;
}
} finally {
if (!keepAttemptClient) {
attemptDetachAbortHandler();
await attemptClient?.stop();
}
}
}
const activeClient = client;
if (!activeClient) {
return;
}
try {
await activeClient.waitForClose();
} catch (err) {
if (abort?.aborted) {
return;
}
runtime.error?.(danger(`imessage: monitor failed: ${String(err)}`));
throw err;
} finally {
detachAbortHandler();
await activeClient.stop();
}
}
export const __testing = {
resolveIMessageRuntimeGroupPolicy: resolveOpenProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
};
export const resolveIMessageRuntimeGroupPolicy = resolveOpenProviderRuntimeGroupPolicy;

View file

@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { parseIMessageNotification } from "./parse-notification.js";
describe("parseIMessageNotification", () => {
it("strips a length-delimited field wrapper from text and reply_to_text", () => {
const wrappedText = `${String.fromCharCode(0x0a, 11)}hello world`;
const wrappedReply = `${String.fromCharCode(0x0a, 5)}quote`;
const raw = {
message: {
id: 1,
guid: "g",
chat_id: 2,
sender: "+10000000000",
destination_caller_id: null,
is_from_me: false,
text: wrappedText,
reply_to_id: null,
reply_to_text: wrappedReply,
reply_to_sender: null,
created_at: null,
attachments: null,
chat_identifier: null,
chat_guid: null,
chat_name: null,
participants: null,
is_group: false,
},
};
const parsed = parseIMessageNotification(raw);
expect(parsed?.text).toBe("hello world");
expect(parsed?.reply_to_text).toBe("quote");
});
});

View file

@ -0,0 +1,93 @@
import { isRecord } from "openclaw/plugin-sdk/text-runtime";
import { stripImessageLengthPrefixedUtf8Text } from "./strip-imsg-length-prefixed-text.js";
import type { IMessagePayload } from "./types.js";
function isOptionalString(value: unknown): value is string | null | undefined {
return value === undefined || value === null || typeof value === "string";
}
function isOptionalStringOrNumber(value: unknown): value is string | number | null | undefined {
return (
value === undefined || value === null || typeof value === "string" || typeof value === "number"
);
}
function isOptionalNumber(value: unknown): value is number | null | undefined {
return value === undefined || value === null || typeof value === "number";
}
function isOptionalBoolean(value: unknown): value is boolean | null | undefined {
return value === undefined || value === null || typeof value === "boolean";
}
function isOptionalStringArray(value: unknown): value is string[] | null | undefined {
return (
value === undefined ||
value === null ||
(Array.isArray(value) && value.every((entry) => typeof entry === "string"))
);
}
function isOptionalAttachments(value: unknown): value is IMessagePayload["attachments"] {
if (value === undefined || value === null) {
return true;
}
if (!Array.isArray(value)) {
return false;
}
return value.every((attachment) => {
if (!isRecord(attachment)) {
return false;
}
return (
isOptionalString(attachment.original_path) &&
isOptionalString(attachment.mime_type) &&
isOptionalBoolean(attachment.missing)
);
});
}
export function parseIMessageNotification(raw: unknown): IMessagePayload | null {
if (!isRecord(raw)) {
return null;
}
const maybeMessage = raw.message;
if (!isRecord(maybeMessage)) {
return null;
}
const message: IMessagePayload = maybeMessage;
if (
!isOptionalNumber(message.id) ||
!isOptionalString(message.guid) ||
!isOptionalNumber(message.chat_id) ||
!isOptionalString(message.sender) ||
!isOptionalString(message.destination_caller_id) ||
!isOptionalBoolean(message.is_from_me) ||
!isOptionalString(message.text) ||
!isOptionalStringOrNumber(message.reply_to_id) ||
!isOptionalString(message.reply_to_text) ||
!isOptionalString(message.reply_to_sender) ||
!isOptionalString(message.created_at) ||
!isOptionalAttachments(message.attachments) ||
!isOptionalString(message.chat_identifier) ||
!isOptionalString(message.chat_guid) ||
!isOptionalString(message.chat_name) ||
!isOptionalStringArray(message.participants) ||
!isOptionalBoolean(message.is_group)
) {
return null;
}
return {
...message,
text:
typeof message.text === "string"
? stripImessageLengthPrefixedUtf8Text(message.text)
: message.text,
reply_to_text:
typeof message.reply_to_text === "string"
? stripImessageLengthPrefixedUtf8Text(message.reply_to_text)
: message.reply_to_text,
};
}

View file

@ -0,0 +1,107 @@
import { describe, expect, it } from "vitest";
import { detectReflectedContent } from "./reflection-guard.js";
describe("detectReflectedContent", () => {
it("returns false for empty text", () => {
expect(detectReflectedContent("").isReflection).toBe(false);
});
it("returns false for normal user text", () => {
const result = detectReflectedContent("Hey, what's the weather today?");
expect(result.isReflection).toBe(false);
expect(result.matchedLabels).toEqual([]);
});
it("detects +#+#+#+# separator pattern", () => {
const result = detectReflectedContent("NO_REPLY +#+#+#+#+#+assistant to=final");
expect(result.isReflection).toBe(true);
expect(result.matchedLabels).toContain("internal-separator");
});
it("detects assistant to=final marker", () => {
const result = detectReflectedContent("some text assistant to=final rest");
expect(result.isReflection).toBe(true);
expect(result.matchedLabels).toContain("assistant-role-marker");
});
it("detects <thinking> tags", () => {
const result = detectReflectedContent("<thinking>internal reasoning</thinking>");
expect(result.isReflection).toBe(true);
expect(result.matchedLabels).toContain("thinking-tag");
});
it("detects <thought> tags", () => {
const result = detectReflectedContent("<thought>secret</thought>");
expect(result.isReflection).toBe(true);
expect(result.matchedLabels).toContain("thinking-tag");
});
it("detects <relevant_memories> tags", () => {
const result = detectReflectedContent("<relevant_memories>data</relevant_memories>");
expect(result.isReflection).toBe(true);
expect(result.matchedLabels).toContain("relevant-memories-tag");
});
it("detects <final> tags", () => {
const result = detectReflectedContent("<final>visible</final>");
expect(result.isReflection).toBe(true);
expect(result.matchedLabels).toContain("final-tag");
});
it("returns multiple matched labels for combined markers", () => {
const text = "NO_REPLY +#+#+#+# <thinking>step</thinking> assistant to=final";
const result = detectReflectedContent(text);
expect(result.isReflection).toBe(true);
expect(result.matchedLabels.length).toBeGreaterThanOrEqual(3);
});
it("ignores reflection markers inside inline code", () => {
const result = detectReflectedContent(
"Please keep `<thinking>debug trace</thinking>` in the example output",
);
expect(result.isReflection).toBe(false);
expect(result.matchedLabels).toEqual([]);
});
it("ignores reflection markers inside fenced code blocks", () => {
const result = detectReflectedContent(
[
"User pasted a repro snippet:",
"```xml",
"<relevant_memories>cached</relevant_memories>",
"assistant to=final",
"```",
].join("\n"),
);
expect(result.isReflection).toBe(false);
expect(result.matchedLabels).toEqual([]);
});
it("still flags markers that appear outside code blocks", () => {
const result = detectReflectedContent(
["```xml", "<thinking>inside code</thinking>", "```", "", "assistant to=final"].join("\n"),
);
expect(result.isReflection).toBe(true);
expect(result.matchedLabels).toContain("assistant-role-marker");
});
it("does not flag normal code discussion about thinking", () => {
const result = detectReflectedContent("I was thinking about your question");
expect(result.isReflection).toBe(false);
});
it("flags '<final answer>' as reflection when it forms a complete tag", () => {
const result = detectReflectedContent("Here is my <final answer>");
expect(result.isReflection).toBe(true);
});
it("does not flag partial tag without closing bracket", () => {
const result = detectReflectedContent("I sent a <final draft, see below");
expect(result.isReflection).toBe(false);
});
it("does not flag '<thought experiment>' phrase without closing bracket", () => {
const result = detectReflectedContent("This is a <thought experiment I ran");
expect(result.isReflection).toBe(false);
});
});

View file

@ -0,0 +1,64 @@
/**
* Detects inbound messages that are reflections of assistant-originated content.
* These patterns indicate internal metadata leaked into a channel and then
* bounced back as a new inbound message — creating an echo loop.
*/
import { findCodeRegions, isInsideCode } from "openclaw/plugin-sdk/text-runtime";
const INTERNAL_SEPARATOR_RE = /(?:#\+){2,}#?/;
const ASSISTANT_ROLE_MARKER_RE = /\bassistant\s+to\s*=\s*\w+/i;
// Require closing `>` to avoid false-positives on phrases like "<thought experiment>".
const THINKING_TAG_RE = /<\s*\/?\s*(?:think(?:ing)?|thought|antthinking)\b[^<>]*>/i;
const RELEVANT_MEMORIES_TAG_RE = /<\s*\/?\s*relevant[-_]memories\b[^<>]*>/i;
// Require closing `>` to avoid false-positives on phrases like "<final answer>".
const FINAL_TAG_RE = /<\s*\/?\s*final\b[^<>]*>/i;
const REFLECTION_PATTERNS: Array<{ re: RegExp; label: string }> = [
{ re: INTERNAL_SEPARATOR_RE, label: "internal-separator" },
{ re: ASSISTANT_ROLE_MARKER_RE, label: "assistant-role-marker" },
{ re: THINKING_TAG_RE, label: "thinking-tag" },
{ re: RELEVANT_MEMORIES_TAG_RE, label: "relevant-memories-tag" },
{ re: FINAL_TAG_RE, label: "final-tag" },
];
export type ReflectionDetection = {
isReflection: boolean;
matchedLabels: string[];
};
function hasMatchOutsideCode(text: string, re: RegExp): boolean {
const codeRegions = findCodeRegions(text);
const globalRe = new RegExp(re.source, re.flags.includes("g") ? re.flags : `${re.flags}g`);
for (const match of text.matchAll(globalRe)) {
const start = match.index ?? -1;
if (start >= 0 && !isInsideCode(start, codeRegions)) {
return true;
}
}
return false;
}
/**
* Check whether an inbound message appears to be a reflection of
* assistant-originated content. Returns matched pattern labels for telemetry.
*/
export function detectReflectedContent(text: string): ReflectionDetection {
if (!text) {
return { isReflection: false, matchedLabels: [] };
}
const matchedLabels: string[] = [];
for (const { re, label } of REFLECTION_PATTERNS) {
if (hasMatchOutsideCode(text, re)) {
matchedLabels.push(label);
}
}
return {
isReflection: matchedLabels.length > 0,
matchedLabels,
};
}

View file

@ -0,0 +1,11 @@
import { createNonExitingRuntime, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { normalizeStringEntries } from "openclaw/plugin-sdk/text-runtime";
import type { MonitorIMessageOpts } from "./types.js";
export function resolveRuntime(opts: MonitorIMessageOpts): RuntimeEnv {
return opts.runtime ?? createNonExitingRuntime();
}
export function normalizeAllowList(list?: Array<string | number>) {
return normalizeStringEntries(list);
}

View file

@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import { sanitizeOutboundText } from "./sanitize-outbound.js";
describe("sanitizeOutboundText", () => {
it("returns empty string unchanged", () => {
expect(sanitizeOutboundText("")).toBe("");
});
it("preserves normal user-facing text", () => {
const text = "Hello! How can I help you today?";
expect(sanitizeOutboundText(text)).toBe(text);
});
it("strips <thinking> tags and content", () => {
const text = "<thinking>internal reasoning</thinking>The answer is 42.";
expect(sanitizeOutboundText(text)).toBe("The answer is 42.");
});
it("strips <thought> tags and content", () => {
const text = "<thought>secret</thought>Visible reply";
expect(sanitizeOutboundText(text)).toBe("Visible reply");
});
it("strips <final> tags", () => {
const text = "<final>Hello world</final>";
expect(sanitizeOutboundText(text)).toBe("Hello world");
});
it("strips <relevant_memories> tags and content", () => {
const text = "<relevant_memories>memory data</relevant_memories>Visible";
expect(sanitizeOutboundText(text)).toBe("Visible");
});
it("strips +#+#+#+# separator patterns", () => {
const text = "NO_REPLY +#+#+#+#+#+ more internal stuff";
expect(sanitizeOutboundText(text)).not.toContain("+#+#");
});
it("strips assistant to=final markers", () => {
const text = "Some text assistant to=final more text";
const result = sanitizeOutboundText(text);
expect(result).not.toMatch(/assistant\s+to\s*=\s*final/i);
});
it("strips trailing role turn markers", () => {
const text = "Hello\nassistant:\nuser:";
const result = sanitizeOutboundText(text);
expect(result).not.toMatch(/^assistant:$/m);
});
it("collapses excessive blank lines after stripping", () => {
const text = "Hello\n\n\n\n\nWorld";
expect(sanitizeOutboundText(text)).toBe("Hello\n\nWorld");
});
it("handles combined internal markers in one message", () => {
const text = "<thinking>step 1</thinking>NO_REPLY +#+#+#+# assistant to=final\n\nActual reply";
const result = sanitizeOutboundText(text);
expect(result).not.toContain("<thinking>");
expect(result).not.toContain("+#+#");
expect(result).not.toMatch(/assistant to=final/i);
expect(result).toContain("Actual reply");
});
});

View file

@ -0,0 +1,31 @@
import { stripAssistantInternalScaffolding } from "openclaw/plugin-sdk/text-runtime";
/**
* Patterns that indicate assistant-internal metadata leaked into text.
* These must never reach a user-facing channel.
*/
const INTERNAL_SEPARATOR_RE = /(?:#\+){2,}#?/g;
const ASSISTANT_ROLE_MARKER_RE = /\bassistant\s+to\s*=\s*\w+/gi;
const ROLE_TURN_MARKER_RE = /\b(?:user|system|assistant)\s*:\s*$/gm;
/**
* Strip all assistant-internal scaffolding from outbound text before delivery.
* Applies reasoning/thinking tag removal, memory tag removal, and
* model-specific internal separator stripping.
*/
export function sanitizeOutboundText(text: string): string {
if (!text) {
return text;
}
let cleaned = stripAssistantInternalScaffolding(text);
cleaned = cleaned.replace(INTERNAL_SEPARATOR_RE, "");
cleaned = cleaned.replace(ASSISTANT_ROLE_MARKER_RE, "");
cleaned = cleaned.replace(ROLE_TURN_MARKER_RE, "");
// Collapse excessive blank lines left after stripping.
cleaned = cleaned.replace(/\n{3,}/g, "\n\n").trim();
return cleaned;
}

View file

@ -0,0 +1,76 @@
import { describe, expect, it, vi } from "vitest";
import { createSelfChatCache } from "./self-chat-cache.js";
describe("createSelfChatCache", () => {
const directLookup = {
accountId: "default",
sender: "+15555550123",
isGroup: false,
} as const;
it("matches repeated lookups for the same scope, timestamp, and text", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-07T00:00:00Z"));
const cache = createSelfChatCache();
cache.remember({
...directLookup,
text: " hello\r\nworld ",
createdAt: 123,
});
expect(
cache.has({
...directLookup,
text: "hello\nworld",
createdAt: 123,
}),
).toBe(true);
});
it("expires entries after the ttl window", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-07T00:00:00Z"));
const cache = createSelfChatCache();
cache.remember({ ...directLookup, text: "hello", createdAt: 123 });
vi.advanceTimersByTime(11_001);
expect(cache.has({ ...directLookup, text: "hello", createdAt: 123 })).toBe(false);
});
it("evicts older entries when the cache exceeds its cap", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-07T00:00:00Z"));
const cache = createSelfChatCache();
for (let i = 0; i < 513; i += 1) {
cache.remember({
...directLookup,
text: `message-${i}`,
createdAt: i,
});
vi.advanceTimersByTime(1_001);
}
expect(cache.has({ ...directLookup, text: "message-0", createdAt: 0 })).toBe(false);
expect(cache.has({ ...directLookup, text: "message-512", createdAt: 512 })).toBe(true);
});
it("does not collide long texts that differ only in the middle", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-07T00:00:00Z"));
const cache = createSelfChatCache();
const prefix = "a".repeat(256);
const suffix = "b".repeat(256);
const longTextA = `${prefix}${"x".repeat(300)}${suffix}`;
const longTextB = `${prefix}${"y".repeat(300)}${suffix}`;
cache.remember({ ...directLookup, text: longTextA, createdAt: 123 });
expect(cache.has({ ...directLookup, text: longTextA, createdAt: 123 })).toBe(true);
expect(cache.has({ ...directLookup, text: longTextB, createdAt: 123 })).toBe(false);
});
});

View file

@ -0,0 +1,103 @@
import { createHash } from "node:crypto";
import { formatIMessageChatTarget } from "../targets.js";
type SelfChatCacheKeyParts = {
accountId: string;
sender: string;
isGroup: boolean;
chatId?: number;
};
export type SelfChatLookup = SelfChatCacheKeyParts & {
text?: string;
createdAt?: number;
};
export type SelfChatCache = {
remember: (lookup: SelfChatLookup) => void;
has: (lookup: SelfChatLookup) => boolean;
};
const SELF_CHAT_TTL_MS = 10_000;
const MAX_SELF_CHAT_CACHE_ENTRIES = 512;
const CLEANUP_MIN_INTERVAL_MS = 1_000;
function normalizeText(text: string | undefined): string | null {
if (!text) {
return null;
}
const normalized = text.replace(/\r\n?/g, "\n").trim();
return normalized ? normalized : null;
}
function isUsableTimestamp(createdAt: number | undefined): createdAt is number {
return typeof createdAt === "number" && Number.isFinite(createdAt);
}
function digestText(text: string): string {
return createHash("sha256").update(text).digest("hex");
}
function buildScope(parts: SelfChatCacheKeyParts): string {
if (!parts.isGroup) {
return `${parts.accountId}:imessage:${parts.sender}`;
}
const chatTarget = formatIMessageChatTarget(parts.chatId) || "chat_id:unknown";
return `${parts.accountId}:${chatTarget}:imessage:${parts.sender}`;
}
class DefaultSelfChatCache implements SelfChatCache {
private cache = new Map<string, number>();
private lastCleanupAt = 0;
private buildKey(lookup: SelfChatLookup): string | null {
const text = normalizeText(lookup.text);
if (!text || !isUsableTimestamp(lookup.createdAt)) {
return null;
}
return `${buildScope(lookup)}:${lookup.createdAt}:${digestText(text)}`;
}
remember(lookup: SelfChatLookup): void {
const key = this.buildKey(lookup);
if (!key) {
return;
}
this.cache.set(key, Date.now());
this.maybeCleanup();
}
has(lookup: SelfChatLookup): boolean {
this.maybeCleanup();
const key = this.buildKey(lookup);
if (!key) {
return false;
}
const timestamp = this.cache.get(key);
return typeof timestamp === "number" && Date.now() - timestamp <= SELF_CHAT_TTL_MS;
}
private maybeCleanup(): void {
const now = Date.now();
if (now - this.lastCleanupAt < CLEANUP_MIN_INTERVAL_MS) {
return;
}
this.lastCleanupAt = now;
for (const [key, timestamp] of this.cache.entries()) {
if (now - timestamp > SELF_CHAT_TTL_MS) {
this.cache.delete(key);
}
}
while (this.cache.size > MAX_SELF_CHAT_CACHE_ENTRIES) {
const oldestKey = this.cache.keys().next().value;
if (typeof oldestKey !== "string") {
break;
}
this.cache.delete(oldestKey);
}
}
}
export function createSelfChatCache(): SelfChatCache {
return new DefaultSelfChatCache();
}

View file

@ -0,0 +1,809 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createSentMessageCache } from "./echo-cache.js";
import { resolveIMessageInboundDecision } from "./inbound-processing.js";
import { createSelfChatCache } from "./self-chat-cache.js";
/**
* Self-chat dedupe regression tests for #47830.
*
* PR #38440 introduced a SentMessageCache to suppress echo messages when the
* agent replies in iMessage. In self-chat (user messaging themselves), the
* sender == target so the echo scope collides, causing legitimate user
* messages to be silently dropped when text happens to match recent agent
* output.
*
* These tests verify:
* 1. User messages in self-chat are NOT dropped (even if text matches agent output)
* 2. Genuine agent echo reflections ARE still dropped
* 3. Different-text messages pass through unaffected
* 4. Chunked replies don't cause false drops of user messages matching a chunk
*/
type InboundDecisionParams = Parameters<typeof resolveIMessageInboundDecision>[0];
const cfg = {} as OpenClawConfig;
function createParams(
overrides: Omit<Partial<InboundDecisionParams>, "message"> & {
message?: Partial<InboundDecisionParams["message"]>;
} = {},
): InboundDecisionParams {
const { message: msgOverrides, ...restOverrides } = overrides;
const message = {
id: 100,
sender: "+15551234567",
text: "Hello",
is_from_me: false,
is_group: false,
...msgOverrides,
};
const messageText = restOverrides.messageText ?? message.text ?? "";
const bodyText = restOverrides.bodyText ?? messageText;
return {
cfg,
accountId: "default",
opts: undefined,
allowFrom: [],
groupAllowFrom: [],
groupPolicy: "open",
dmPolicy: "open",
storeAllowFrom: [],
historyLimit: 0,
groupHistories: new Map(),
echoCache: undefined,
selfChatCache: undefined,
logVerbose: undefined,
...restOverrides,
message,
messageText,
bodyText,
};
}
describe("echo cache — message ID type canary (#47830)", () => {
// Tests the implicit contract that outbound GUIDs (e.g. "p:0/abc-def-123")
// never match inbound SQLite row IDs (e.g. "200"). If iMessage ever changes
// ID schemes, this test should break loudly.
it("outbound GUID format and inbound SQLite row ID format never collide", () => {
const echoCache = createSentMessageCache();
const scope = "default:imessage:+15555550123";
// Outbound messageId is a GUID format string
echoCache.remember(scope, { text: "test", messageId: "p:0/abc-def-123" });
// An inbound SQLite row ID (numeric string) should NOT match the GUID
expect(echoCache.has(scope, { text: "different", messageId: "200" })).toBe(false);
// The original GUID should still match
expect(echoCache.has(scope, { text: "different", messageId: "p:0/abc-def-123" })).toBe(true);
});
it('falls back to text when outbound messageId was junk ("ok")', () => {
const echoCache = createSentMessageCache();
const scope = "default:imessage:+15555550123";
// "ok" is normalized out and should not populate the ID cache.
echoCache.remember(scope, { text: "text-only fallback", messageId: "ok" });
// Inbound has a numeric SQLite ID that does not exist in cache. Since this
// scope has no real cached IDs, has() must still fall through to text match.
expect(echoCache.has(scope, { text: "text-only fallback", messageId: "200" })).toBe(true);
});
it("keeps ID short-circuit when scope has real outbound GUID IDs", () => {
const echoCache = createSentMessageCache();
const scope = "default:imessage:+15555550123";
echoCache.remember(scope, { text: "guid-backed", messageId: "p:0/abc-def-123" });
// Different inbound numeric ID should still short-circuit to false.
expect(echoCache.has(scope, { text: "guid-backed", messageId: "200" })).toBe(false);
});
});
describe("echo cache — backward compat for channels without messageId", () => {
afterEach(() => {
vi.useRealTimers();
});
// Proves text-fallback echo detection still works when no messageId is present
// on either side. Critical for backward compat with channels that don't
// populate messageId.
it("text-only remember/has works within TTL", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));
const echoCache = createSentMessageCache();
const scope = "default:imessage:+15555550123";
echoCache.remember(scope, { text: "no id message" });
vi.advanceTimersByTime(2000);
expect(echoCache.has(scope, { text: "no id message" })).toBe(true);
});
it("text-only has returns false after TTL expiry", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));
const echoCache = createSentMessageCache();
const scope = "default:imessage:+15555550123";
echoCache.remember(scope, { text: "no id message" });
vi.advanceTimersByTime(5000);
expect(echoCache.has(scope, { text: "no id message" })).toBe(false);
});
it("text-only has returns false for different text", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));
const echoCache = createSentMessageCache();
const scope = "default:imessage:+15555550123";
echoCache.remember(scope, { text: "no id message" });
vi.advanceTimersByTime(1000);
expect(echoCache.has(scope, { text: "totally different text" })).toBe(false);
});
});
describe("self-chat dedupe — #47830", () => {
afterEach(() => {
vi.useRealTimers();
});
it("does NOT drop a user message that matches recently-sent agent text (self-chat scope collision)", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));
const echoCache = createSentMessageCache();
const selfChatCache = createSelfChatCache();
// Agent sends "Hello" to self-chat target +15551234567
const scope = "default:imessage:+15551234567";
echoCache.remember(scope, { text: "Hello", messageId: "agent-msg-1" });
// 2 seconds later, user sends "Hello" to themselves (different message id)
vi.advanceTimersByTime(2000);
const decision = resolveIMessageInboundDecision(
createParams({
message: {
id: 200,
sender: "+15551234567",
text: "Hello",
is_from_me: false,
},
messageText: "Hello",
bodyText: "Hello",
echoCache,
selfChatCache,
}),
);
// BUG: Before fix, this was "drop" reason "echo" — user message silently lost.
// After fix: message-id mismatch means this is NOT an echo.
// The echo cache should only match when message IDs match OR when text
// matches and no message ID is available on inbound.
expect(decision.kind).toBe("dispatch");
});
it("DOES drop genuine agent echo (same message id reflected back)", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));
const echoCache = createSentMessageCache();
// Agent sends "Hello" to target
const scope = "default:imessage:+15551234567";
echoCache.remember(scope, { text: "Hello", messageId: "agent-msg-1" });
// 1 second later, iMessage reflects it back with same message id
vi.advanceTimersByTime(1000);
const decision = resolveIMessageInboundDecision(
createParams({
message: {
id: "agent-msg-1" as unknown as number,
sender: "+15551234567",
text: "Hello",
is_from_me: false,
},
messageText: "Hello",
bodyText: "Hello",
echoCache,
}),
);
expect(decision).toEqual({ kind: "drop", reason: "echo" });
});
it("does NOT drop different-text messages even within TTL", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));
const echoCache = createSentMessageCache();
// Agent sends "Hello"
const scope = "default:imessage:+15551234567";
echoCache.remember(scope, { text: "Hello", messageId: "agent-msg-1" });
vi.advanceTimersByTime(1000);
const decision = resolveIMessageInboundDecision(
createParams({
message: {
id: 201,
sender: "+15551234567",
text: "Goodbye",
is_from_me: false,
},
messageText: "Goodbye",
bodyText: "Goodbye",
echoCache,
}),
);
expect(decision.kind).toBe("dispatch");
});
it("does NOT drop user messages that match a chunk of a multi-chunk agent reply", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));
const echoCache = createSentMessageCache();
const scope = "default:imessage:+15551234567";
// Agent sends a multi-chunk reply: "Part one", "Part two", "Part three"
echoCache.remember(scope, { text: "Part one", messageId: "agent-chunk-1" });
echoCache.remember(scope, { text: "Part two", messageId: "agent-chunk-2" });
echoCache.remember(scope, { text: "Part three", messageId: "agent-chunk-3" });
vi.advanceTimersByTime(2000);
// User sends "Part two" (matches chunk 2 text, but different message id)
const decision = resolveIMessageInboundDecision(
createParams({
message: {
id: 300,
sender: "+15551234567",
text: "Part two",
is_from_me: false,
},
messageText: "Part two",
bodyText: "Part two",
echoCache,
}),
);
// Should NOT be dropped — different message id means not an echo
expect(decision.kind).toBe("dispatch");
});
it("drops echo after text TTL expiry (4s TTL: expired at 5s)", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));
const echoCache = createSentMessageCache();
const scope = "default:imessage:+15555550123";
// Agent sends text (no message id available)
echoCache.remember(scope, { text: "Hello there" });
// After 5 seconds — beyond the 4s TTL, should NOT match
vi.advanceTimersByTime(5000);
const result = echoCache.has(scope, { text: "Hello there" });
expect(result).toBe(false);
});
// Safe failure mode: TTL expiry causes duplicate delivery (noisy), never message loss (lossy)
it("does NOT catch echo after TTL expiry — safe failure mode is duplicate delivery", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));
const echoCache = createSentMessageCache();
const scope = "default:imessage:+15551234567";
// Agent sends "Delayed echo test"
echoCache.remember(scope, { text: "Delayed echo test", messageId: "agent-msg-delayed" });
// 4.5 seconds later — beyond 4s TTL
vi.advanceTimersByTime(4500);
// Echo arrives with no messageId (text-only fallback path)
const result = echoCache.has(scope, { text: "Delayed echo test" });
// TTL expired → not caught → duplicate delivery (noisy but safe, not lossy)
expect(result).toBe(false);
});
it("still drops text echo within 4s TTL window", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));
const echoCache = createSentMessageCache();
const scope = "default:imessage:+15555550123";
echoCache.remember(scope, { text: "Hello there" });
// After 3 seconds — within the 4s TTL, should still match
vi.advanceTimersByTime(3000);
const result = echoCache.has(scope, { text: "Hello there" });
expect(result).toBe(true);
});
});
describe("self-chat is_from_me=true handling (Bruce Phase 2 fix)", () => {
afterEach(() => {
vi.useRealTimers();
});
it("processes real user self-chat message (is_from_me=true, no echo cache match)", () => {
const echoCache = createSentMessageCache();
const selfChatCache = createSelfChatCache();
const decision = resolveIMessageInboundDecision(
createParams({
message: {
id: 123703,
sender: "+15551234567",
chat_identifier: "+15551234567",
destination_caller_id: "+15551234567",
text: "Hello this is a test message",
is_from_me: true,
is_group: false,
},
messageText: "Hello this is a test message",
bodyText: "Hello this is a test message",
echoCache,
selfChatCache,
}),
);
expect(decision.kind).toBe("dispatch");
});
it("drops is_from_me outbound when destination_caller_id is blank and sender matches chat_identifier (#63980)", () => {
const echoCache = createSentMessageCache();
const selfChatCache = createSelfChatCache();
const decision = resolveIMessageInboundDecision(
createParams({
message: {
id: 123704,
sender: "+15551234567",
chat_identifier: "+15551234567",
destination_caller_id: "",
text: "Hello this is a test message",
is_from_me: true,
is_group: false,
},
messageText: "Hello this is a test message",
bodyText: "Hello this is a test message",
echoCache,
selfChatCache,
}),
);
expect(decision).toEqual({ kind: "drop", reason: "from me" });
});
it("drops DM false positives even when participant lists include the local handle", () => {
const echoCache = createSentMessageCache();
const selfChatCache = createSelfChatCache();
const decision = resolveIMessageInboundDecision(
createParams({
message: {
id: 123705,
sender: "+15551234567",
chat_identifier: "+15551234567",
destination_caller_id: "me@icloud.com",
participants: ["+15551234567", "me@icloud.com"],
text: "Hello from a normal DM row",
is_from_me: true,
is_group: false,
},
messageText: "Hello from a normal DM row",
bodyText: "Hello from a normal DM row",
echoCache,
selfChatCache,
}),
);
expect(decision).toEqual({ kind: "drop", reason: "from me" });
});
it("drops agent reply echo in self-chat (is_from_me=true, echo cache text match)", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));
const echoCache = createSentMessageCache();
const selfChatCache = createSelfChatCache();
// Agent sends "Hi there!" to self-chat
const scope = "default:imessage:+15551234567";
echoCache.remember(scope, { text: "Hi there!", messageId: "p:0/GUID-abc-def" });
// 1 second later, iMessage delivers the agent reply as is_from_me=true
// with a SQLite row ID (never matches the GUID)
vi.advanceTimersByTime(1000);
const decision = resolveIMessageInboundDecision(
createParams({
message: {
id: 123706,
guid: "p:0/GUID-abc-def",
sender: "+15551234567",
chat_identifier: "+15551234567",
destination_caller_id: "+15551234567",
text: "Hi there!",
is_from_me: true,
is_group: false,
},
messageText: "Hi there!",
bodyText: "Hi there!",
echoCache,
selfChatCache,
}),
);
// Agent echo — should be dropped
expect(decision).toEqual({ kind: "drop", reason: "agent echo in self-chat" });
});
it("drops attachment-only agent echo in self-chat via bodyText placeholder", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));
const echoCache = createSentMessageCache();
const selfChatCache = createSelfChatCache();
const scope = "default:imessage:+15551234567";
echoCache.remember(scope, { text: "<media:image>", messageId: "p:0/GUID-media" });
vi.advanceTimersByTime(1000);
const decision = resolveIMessageInboundDecision(
createParams({
message: {
id: 123707,
guid: "p:0/GUID-media",
sender: "+15551234567",
chat_identifier: "+15551234567",
destination_caller_id: "+15551234567",
text: "",
is_from_me: true,
is_group: false,
},
messageText: "",
bodyText: "<media:image>",
echoCache,
selfChatCache,
}),
);
expect(decision).toEqual({ kind: "drop", reason: "agent echo in self-chat" });
});
it("drops self-chat echo when outbound cache stored numeric id but inbound also carries a guid", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));
const echoCache = createSentMessageCache();
const selfChatCache = createSelfChatCache();
const scope = "default:imessage:+15551234567";
echoCache.remember(scope, { text: "Numeric id echo", messageId: "123709" });
vi.advanceTimersByTime(1000);
const decision = resolveIMessageInboundDecision(
createParams({
message: {
id: 123709,
guid: "p:0/GUID-different-shape",
sender: "+15551234567",
chat_identifier: "+15551234567",
destination_caller_id: "+15551234567",
text: "Numeric id echo",
is_from_me: true,
is_group: false,
},
messageText: "Numeric id echo",
bodyText: "Numeric id echo",
echoCache,
selfChatCache,
}),
);
expect(decision).toEqual({ kind: "drop", reason: "agent echo in self-chat" });
});
it("does not drop a real self-chat image just because a recent agent image used the same placeholder", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));
const echoCache = createSentMessageCache();
const selfChatCache = createSelfChatCache();
const scope = "default:imessage:+15551234567";
echoCache.remember(scope, { text: "<media:image>", messageId: "p:0/GUID-agent-image" });
vi.advanceTimersByTime(1000);
const decision = resolveIMessageInboundDecision(
createParams({
message: {
id: 123708,
guid: "p:0/GUID-user-image",
sender: "+15551234567",
chat_identifier: "+15551234567",
destination_caller_id: "+15551234567",
text: "",
is_from_me: true,
is_group: false,
},
messageText: "",
bodyText: "<media:image>",
echoCache,
selfChatCache,
}),
);
expect(decision.kind).toBe("dispatch");
});
it("drops is_from_me=false reflection via selfChatCache (existing behavior preserved)", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));
const selfChatCache = createSelfChatCache();
const createdAt = "2026-03-24T12:00:00.000Z";
// Step 1: is_from_me=true copy arrives (real user message) → processed, selfChatCache populated
const first = resolveIMessageInboundDecision(
createParams({
message: {
id: 123703,
sender: "+15551234567",
chat_identifier: "+15551234567",
destination_caller_id: "+15551234567",
text: "Hello",
created_at: createdAt,
is_from_me: true,
is_group: false,
},
messageText: "Hello",
bodyText: "Hello",
selfChatCache,
}),
);
expect(first.kind).toBe("dispatch");
// Step 2: is_from_me=false reflection arrives 2s later with same text+createdAt
vi.advanceTimersByTime(2200);
const second = resolveIMessageInboundDecision(
createParams({
message: {
id: 123704,
sender: "+15551234567",
chat_identifier: "+15551234567",
text: "Hello",
created_at: createdAt,
is_from_me: false,
is_group: false,
},
messageText: "Hello",
bodyText: "Hello",
selfChatCache,
}),
);
// Reflection correctly dropped
expect(second).toEqual({ kind: "drop", reason: "self-chat echo" });
});
it("drops outbound DM when sender matches chat_identifier but destination_caller_id is absent (#63980)", () => {
const selfChatCache = createSelfChatCache();
const decision = resolveIMessageInboundDecision(
createParams({
message: {
id: 10003,
sender: "+15550008888",
chat_identifier: "+15550008888",
text: "outbound",
is_from_me: true,
is_group: false,
},
messageText: "outbound",
bodyText: "outbound",
selfChatCache,
}),
);
expect(decision).toEqual({ kind: "drop", reason: "from me" });
});
it("drops reflected inbound when destination_caller_id is absent (#63980)", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));
const selfChatCache = createSelfChatCache();
const createdAt = "2026-03-24T12:00:00.000Z";
const outbound = resolveIMessageInboundDecision(
createParams({
message: {
id: 10003,
sender: "+15550008888",
chat_identifier: "+15550008888",
text: "outbound",
created_at: createdAt,
is_from_me: true,
is_group: false,
},
messageText: "outbound",
bodyText: "outbound",
selfChatCache,
}),
);
expect(outbound).toEqual({ kind: "drop", reason: "from me" });
vi.advanceTimersByTime(2200);
const reflection = resolveIMessageInboundDecision(
createParams({
message: {
id: 10004,
sender: "+15550008888",
chat_identifier: "+15550008888",
text: "outbound",
created_at: createdAt,
is_from_me: false,
is_group: false,
},
messageText: "outbound",
bodyText: "outbound",
selfChatCache,
}),
);
expect(reflection).toEqual({ kind: "drop", reason: "self-chat echo" });
});
it("normal DM is_from_me=true is still dropped (regression test)", () => {
const selfChatCache = createSelfChatCache();
// Normal DM with is_from_me=true: sender may be the local handle and
// chat_identifier the other party (they differ), so this is NOT self-chat.
const decision = resolveIMessageInboundDecision(
createParams({
message: {
id: 9999,
sender: "+15551234567", // local user sent this
chat_identifier: "+15555550123", // sent TO this other person
text: "Hello",
is_from_me: true,
is_group: false,
},
messageText: "Hello",
bodyText: "Hello",
selfChatCache,
}),
);
expect(decision).toEqual({ kind: "drop", reason: "from me" });
});
it("uses destination_caller_id to avoid DM self-chat false positives", () => {
const echoCache = createSentMessageCache();
const selfChatCache = createSelfChatCache();
echoCache.remember("default:imessage:+15551234567", {
text: "Clean outbound text",
messageId: "p:0/GUID-outbound",
});
const decision = resolveIMessageInboundDecision(
createParams({
message: {
id: 10001,
sender: "+15551234567",
chat_identifier: "+15551234567",
destination_caller_id: "+15550001111",
text: "<22>\u0001corrupted stored text",
is_from_me: true,
is_group: false,
},
messageText: "<22>\u0001corrupted stored text",
bodyText: "<22>\u0001corrupted stored text",
echoCache,
selfChatCache,
}),
);
expect(decision).toEqual({ kind: "drop", reason: "from me" });
});
it("echo cache text matching works with skipIdShortCircuit=true", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));
const echoCache = createSentMessageCache();
const scope = "default:imessage:+15551234567";
echoCache.remember(scope, { text: "Cached reply", messageId: "p:0/some-guid" });
vi.advanceTimersByTime(1000);
// Text matches but ID is a SQLite row (format mismatch). With skipIdShortCircuit=true,
// text matching should still fire.
expect(echoCache.has(scope, { text: "Cached reply", messageId: "123799" }, true)).toBe(true);
// With skipIdShortCircuit=false (default), ID mismatch causes early return false.
expect(echoCache.has(scope, { text: "Cached reply", messageId: "123799" }, false)).toBe(false);
});
});
describe("echo cache — text fallback for null-id inbound messages", () => {
it("still identifies echo via text when inbound message has id: null", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));
const echoCache = createSentMessageCache();
const selfChatCache = createSelfChatCache();
// Agent sends "Sounds good" — no messageId available (edge case)
const scope = "default:imessage:+15551234567";
echoCache.remember(scope, { text: "Sounds good" });
// 1 second later, inbound reflection arrives with id: null
vi.advanceTimersByTime(1000);
const decision = resolveIMessageInboundDecision(
createParams({
message: {
id: null as unknown as number,
sender: "+15551234567",
text: "Sounds good",
is_from_me: false,
},
messageText: "Sounds good",
bodyText: "Sounds good",
echoCache,
selfChatCache,
}),
);
// With id: null, the text-based fallback path is still active and should
// correctly identify this as an echo.
expect(decision).toEqual({ kind: "drop", reason: "echo" });
});
});
describe("echo cache — mixed GUID and text-only scopes", () => {
it("still falls back to text for the latest text-only send in a scope with older GUID-backed sends", () => {
const echoCache = createSentMessageCache();
const scope = "default:imessage:+15555550123";
echoCache.remember(scope, { text: "older guid-backed", messageId: "p:0/GUID-older" });
echoCache.remember(scope, { text: "latest text-only", messageId: "unknown" });
expect(echoCache.has(scope, { text: "latest text-only", messageId: "200" })).toBe(true);
});
it("still short-circuits when the latest copy of a text was GUID-backed", () => {
const echoCache = createSentMessageCache();
const scope = "default:imessage:+15555550123";
echoCache.remember(scope, { text: "same text", messageId: "unknown" });
echoCache.remember(scope, { text: "same text", messageId: "p:0/GUID-newer" });
expect(echoCache.has(scope, { text: "same text", messageId: "200" })).toBe(false);
});
});

View file

@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import {
stripImessageLengthPrefixedUtf8Text,
tryStripImessageLengthPrefixedUtf8Buffer,
} from "./strip-imsg-length-prefixed-text.js";
describe("stripImessageLengthPrefixedUtf8Text", () => {
it("removes a length-delimited field wrapper from text", () => {
const raw = `${String.fromCharCode(0x0a, 5)}hello`;
expect(stripImessageLengthPrefixedUtf8Text(raw)).toBe("hello");
});
it("removes a wrapped payload when the payload length byte is ASCII-printable", () => {
const inner = "Mrrrrow! 🐱 Ich bin wach und bereit!";
const raw = `${String.fromCharCode(0x0a, Buffer.byteLength(inner, "utf8"))}${inner}`;
expect(stripImessageLengthPrefixedUtf8Text(raw)).toBe(inner);
});
it("removes a payload behind a two-byte varint length (raw buffer)", () => {
const inner = "a".repeat(128);
const buf = Buffer.allocUnsafe(3 + Buffer.byteLength(inner, "utf8"));
buf.writeUInt8(0x0a, 0);
buf.writeUInt8(0x80, 1);
buf.writeUInt8(0x01, 2);
buf.write(inner, 3, "utf8");
expect(Buffer.from(tryStripImessageLengthPrefixedUtf8Buffer(buf) ?? []).toString("utf8")).toBe(
inner,
);
});
it("does not strip plain text whose first bytes can mimic a naked length prefix", () => {
const inner = `A${"b".repeat(65)}`;
expect(stripImessageLengthPrefixedUtf8Text(inner)).toBe(inner);
});
it("does not strip plain text that starts with a different length-delimited field tag", () => {
const inner = `B${"a".repeat(98)}`;
expect(stripImessageLengthPrefixedUtf8Text(inner)).toBe(inner);
});
it("preserves plain text", () => {
expect(stripImessageLengthPrefixedUtf8Text("Mrrrrow! 🐱")).toBe("Mrrrrow! 🐱");
});
it("preserves text when the wrapped length does not consume the whole string", () => {
const raw = `${String.fromCharCode(0x0a, 5)}hi`;
expect(stripImessageLengthPrefixedUtf8Text(raw)).toBe(raw);
});
it("preserves text when the field tag is missing", () => {
const raw = `${String.fromCharCode(5)}hello`;
expect(stripImessageLengthPrefixedUtf8Text(raw)).toBe(raw);
});
it("returns empty string unchanged", () => {
expect(stripImessageLengthPrefixedUtf8Text("")).toBe("");
});
});

View file

@ -0,0 +1,60 @@
type Varint = {
nextOffset: number;
value: number;
};
const utf8Decoder = new TextDecoder();
function readVarint(buf: Uint8Array, start: number): Varint | null {
let offset = start;
let value = 0;
let shift = 0;
while (offset < buf.length && shift <= 28) {
const byte = buf[offset];
offset += 1;
value |= (byte & 0x7f) << shift;
if ((byte & 0x80) === 0) {
return { nextOffset: offset, value };
}
shift += 7;
}
return null;
}
export function tryStripImessageLengthPrefixedUtf8Buffer(buf: Uint8Array): Uint8Array | null {
const key = readVarint(buf, 0);
if (!key || key.nextOffset >= buf.length) {
return null;
}
if (key.value !== 0x0a) {
return null;
}
const length = readVarint(buf, key.nextOffset);
if (!length || length.value === 0) {
return null;
}
if (length.nextOffset + length.value !== buf.length) {
return null;
}
return buf.subarray(length.nextOffset, buf.length);
}
export function stripImessageLengthPrefixedUtf8Text(text: string): string {
if (!text) {
return text;
}
const stripped = tryStripImessageLengthPrefixedUtf8Buffer(Buffer.from(text, "utf8"));
if (!stripped) {
return text;
}
const inner = utf8Decoder.decode(stripped);
return inner.length > 0 ? inner : text;
}

View file

@ -0,0 +1,42 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
export type IMessageAttachment = {
original_path?: string | null;
mime_type?: string | null;
missing?: boolean | null;
};
export type IMessagePayload = {
id?: number | null;
guid?: string | null;
chat_id?: number | null;
sender?: string | null;
destination_caller_id?: string | null;
is_from_me?: boolean | null;
text?: string | null;
reply_to_id?: number | string | null;
reply_to_text?: string | null;
reply_to_sender?: string | null;
created_at?: string | null;
attachments?: IMessageAttachment[] | null;
chat_identifier?: string | null;
chat_guid?: string | null;
chat_name?: string | null;
participants?: string[] | null;
is_group?: boolean | null;
};
export type MonitorIMessageOpts = {
runtime?: RuntimeEnv;
abortSignal?: AbortSignal;
cliPath?: string;
dbPath?: string;
accountId?: string;
config?: OpenClawConfig;
allowFrom?: Array<string | number>;
groupAllowFrom?: Array<string | number>;
includeAttachments?: boolean;
mediaMaxMb?: number;
requireMention?: boolean;
};

View file

@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { sanitizeIMessageWatchErrorPayload } from "./watch-error-log.js";
describe("sanitizeIMessageWatchErrorPayload", () => {
it("keeps only code and a sanitized truncated message", () => {
expect(
sanitizeIMessageWatchErrorPayload({
code: 500,
message: `boom\n\t\u001b[2K${"x".repeat(250)}`,
chatId: "chat-123",
participants: ["+15555550123"],
path: "/Users/me/Library/Messages/chat.db",
}),
).toEqual({
code: 500,
message: `boom\\n\\t${"x".repeat(191)}…`,
});
});
it("drops non-object payloads and unsupported fields", () => {
expect(sanitizeIMessageWatchErrorPayload("boom")).toEqual({});
expect(
sanitizeIMessageWatchErrorPayload({
code: Number.POSITIVE_INFINITY,
message: 123,
data: { sender: "+15555550123" },
}),
).toEqual({});
});
});

View file

@ -0,0 +1,38 @@
import {
isRecord,
sanitizeTerminalText,
truncateUtf16Safe,
} from "openclaw/plugin-sdk/text-runtime";
const MAX_WATCH_ERROR_MESSAGE_CHARS = 200;
export type SanitizedIMessageWatchErrorPayload = {
code?: number;
message?: string;
};
export function sanitizeIMessageWatchErrorPayload(
payload: unknown,
): SanitizedIMessageWatchErrorPayload {
if (!isRecord(payload)) {
return {};
}
const safe: SanitizedIMessageWatchErrorPayload = {};
if (typeof payload.code === "number" && Number.isFinite(payload.code)) {
safe.code = payload.code;
}
if (typeof payload.message === "string") {
const sanitizedMessage = sanitizeTerminalText(payload.message);
if (sanitizedMessage) {
safe.message =
sanitizedMessage.length > MAX_WATCH_ERROR_MESSAGE_CHARS
? `${truncateUtf16Safe(sanitizedMessage, MAX_WATCH_ERROR_MESSAGE_CHARS - 1)}…`
: sanitizedMessage;
}
}
return safe;
}

View file

@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { looksLikeIMessageTargetId, normalizeIMessageMessagingTarget } from "./normalize.js";
describe("normalizeIMessageMessagingTarget", () => {
it("normalizes blank inputs to undefined", () => {
expect(normalizeIMessageMessagingTarget(" ")).toBeUndefined();
});
it("preserves service prefixes for handles", () => {
expect(normalizeIMessageMessagingTarget("sms:+1 (555) 222-3333")).toBe("sms:+15552223333");
});
it("drops service prefixes for chat targets", () => {
expect(normalizeIMessageMessagingTarget("sms:chat_id:123")).toBe("chat_id:123");
expect(normalizeIMessageMessagingTarget("imessage:CHAT_GUID:abc")).toBe("chat_guid:abc");
expect(normalizeIMessageMessagingTarget("auto:ChatIdentifier:foo")).toBe("chatidentifier:foo");
});
});
describe("looksLikeIMessageTargetId", () => {
it("detects common iMessage target forms", () => {
expect(looksLikeIMessageTargetId("sms:+15555550123")).toBe(true);
expect(looksLikeIMessageTargetId("chat_id:123")).toBe(true);
expect(looksLikeIMessageTargetId("user@example.com")).toBe(true);
expect(looksLikeIMessageTargetId("+15555550123")).toBe(true);
expect(looksLikeIMessageTargetId("")).toBe(false);
});
});

View file

@ -0,0 +1,99 @@
import { normalizeE164 } from "openclaw/plugin-sdk/account-resolution";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "openclaw/plugin-sdk/text-runtime";
const SERVICE_PREFIXES = ["imessage:", "sms:", "auto:"] as const;
const CHAT_TARGET_PREFIX_RE =
/^(chat_id:|chatid:|chat:|chat_guid:|chatguid:|guid:|chat_identifier:|chatidentifier:|chatident:)/i;
function looksLikeHandleOrPhoneTarget(params: {
raw: string;
prefixPattern: RegExp;
phonePattern?: RegExp;
}): boolean {
const trimmed = params.raw.trim();
if (!trimmed) {
return false;
}
if (params.prefixPattern.test(trimmed)) {
return true;
}
if (trimmed.includes("@")) {
return true;
}
return (params.phonePattern ?? /^\+?\d{3,}$/).test(trimmed);
}
export function normalizeIMessageHandle(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) {
return "";
}
const lowered = normalizeLowercaseStringOrEmpty(trimmed);
if (lowered.startsWith("imessage:")) {
return normalizeIMessageHandle(trimmed.slice("imessage:".length));
}
if (lowered.startsWith("sms:")) {
return normalizeIMessageHandle(trimmed.slice("sms:".length));
}
if (lowered.startsWith("auto:")) {
return normalizeIMessageHandle(trimmed.slice("auto:".length));
}
if (CHAT_TARGET_PREFIX_RE.test(trimmed)) {
const prefix = trimmed.match(CHAT_TARGET_PREFIX_RE)?.[0];
if (!prefix) {
return "";
}
const value = trimmed.slice(prefix.length).trim();
return `${normalizeLowercaseStringOrEmpty(prefix)}${value}`;
}
if (trimmed.includes("@")) {
return normalizeLowercaseStringOrEmpty(trimmed);
}
const normalized = normalizeE164(trimmed);
if (normalized) {
return normalized;
}
return trimmed.replace(/\s+/g, "");
}
export function normalizeIMessageMessagingTarget(raw: string): string | undefined {
const trimmed = normalizeOptionalString(raw);
if (!trimmed) {
return undefined;
}
const lower = normalizeLowercaseStringOrEmpty(trimmed);
for (const prefix of SERVICE_PREFIXES) {
if (lower.startsWith(prefix)) {
const remainder = trimmed.slice(prefix.length).trim();
const normalizedHandle = normalizeIMessageHandle(remainder);
if (!normalizedHandle) {
return undefined;
}
if (CHAT_TARGET_PREFIX_RE.test(normalizedHandle)) {
return normalizedHandle;
}
return `${prefix}${normalizedHandle}`;
}
}
const normalized = normalizeIMessageHandle(trimmed);
return normalized || undefined;
}
export function looksLikeIMessageTargetId(raw: string): boolean {
const trimmed = normalizeOptionalString(raw);
if (!trimmed) {
return false;
}
if (CHAT_TARGET_PREFIX_RE.test(trimmed)) {
return true;
}
return looksLikeHandleOrPhoneTarget({
raw: trimmed,
prefixPattern: /^(imessage:|sms:|auto:)/i,
});
}

View file

@ -0,0 +1 @@
export const IMESSAGE_LEGACY_OUTBOUND_SEND_DEP_KEYS = ["sendIMessage"] as const;

View file

@ -0,0 +1,106 @@
import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-contract";
import { loadConfig } from "openclaw/plugin-sdk/config-runtime";
import { runCommandWithTimeout } from "openclaw/plugin-sdk/process-runtime";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { detectBinary } from "openclaw/plugin-sdk/setup";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import { createIMessageRpcClient } from "./client.js";
import { DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS } from "./constants.js";
// Re-export for backwards compatibility
export { DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS } from "./constants.js";
export type IMessageProbe = BaseProbeResult & {
fatal?: boolean;
};
export type IMessageProbeOptions = {
cliPath?: string;
dbPath?: string;
runtime?: RuntimeEnv;
};
type RpcSupportResult = {
supported: boolean;
error?: string;
fatal?: boolean;
};
const rpcSupportCache = new Map<string, RpcSupportResult>();
async function probeRpcSupport(cliPath: string, timeoutMs: number): Promise<RpcSupportResult> {
const cached = rpcSupportCache.get(cliPath);
if (cached) {
return cached;
}
try {
const result = await runCommandWithTimeout([cliPath, "rpc", "--help"], { timeoutMs });
const combined = `${result.stdout}\n${result.stderr}`.trim();
const normalized = normalizeLowercaseStringOrEmpty(combined);
if (normalized.includes("unknown command") && normalized.includes("rpc")) {
const fatal = {
supported: false,
fatal: true,
error: 'imsg CLI does not support the "rpc" subcommand (update imsg)',
};
rpcSupportCache.set(cliPath, fatal);
return fatal;
}
if (result.code === 0) {
const supported = { supported: true };
rpcSupportCache.set(cliPath, supported);
return supported;
}
return {
supported: false,
error: combined || `imsg rpc --help failed (code ${String(result.code ?? "unknown")})`,
};
} catch (err) {
return { supported: false, error: String(err) };
}
}
/**
* Probe iMessage RPC availability.
* @param timeoutMs - Explicit timeout in ms. If undefined, uses config or default.
* @param opts - Additional options (cliPath, dbPath, runtime).
*/
export async function probeIMessage(
timeoutMs?: number,
opts: IMessageProbeOptions = {},
): Promise<IMessageProbe> {
const cfg = opts.cliPath || opts.dbPath ? undefined : loadConfig();
const cliPath = opts.cliPath?.trim() || cfg?.channels?.imessage?.cliPath?.trim() || "imsg";
const dbPath = opts.dbPath?.trim() || cfg?.channels?.imessage?.dbPath?.trim();
// Use explicit timeout if provided, otherwise fall back to config, then default
const effectiveTimeout =
timeoutMs ?? cfg?.channels?.imessage?.probeTimeoutMs ?? DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS;
const detected = await detectBinary(cliPath);
if (!detected) {
return { ok: false, error: `imsg not found (${cliPath})` };
}
const rpcSupport = await probeRpcSupport(cliPath, effectiveTimeout);
if (!rpcSupport.supported) {
return {
ok: false,
error: rpcSupport.error ?? "imsg rpc unavailable",
fatal: rpcSupport.fatal,
};
}
const client = await createIMessageRpcClient({
cliPath,
dbPath,
runtime: opts.runtime,
});
try {
await client.request("chats.list", { limit: 1 }, { timeoutMs: effectiveTimeout });
return { ok: true };
} catch (err) {
return { ok: false, error: String(err) };
} finally {
await client.stop();
}
}

View file

@ -0,0 +1,9 @@
import type { PluginRuntime } from "openclaw/plugin-sdk/core";
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
const { setRuntime: setIMessageRuntime, getRuntime: getIMessageRuntime } =
createPluginRuntimeStore<PluginRuntime>({
pluginId: "imessage",
errorMessage: "iMessage runtime not initialized",
});
export { getIMessageRuntime, setIMessageRuntime };

View file

@ -0,0 +1,194 @@
import { loadConfig } from "openclaw/plugin-sdk/config-runtime";
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
import { kindFromMime } from "openclaw/plugin-sdk/media-runtime";
import { resolveOutboundAttachmentFromUrl } from "openclaw/plugin-sdk/media-runtime";
import { convertMarkdownTables } from "openclaw/plugin-sdk/text-runtime";
import { stripInlineDirectiveTagsForDelivery } from "openclaw/plugin-sdk/text-runtime";
import { resolveIMessageAccount, type ResolvedIMessageAccount } from "./accounts.js";
import { createIMessageRpcClient, type IMessageRpcClient } from "./client.js";
import { formatIMessageChatTarget, type IMessageService, parseIMessageTarget } from "./targets.js";
export type IMessageSendOpts = {
cliPath?: string;
dbPath?: string;
service?: IMessageService;
region?: string;
accountId?: string;
replyToId?: string;
mediaUrl?: string;
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
maxBytes?: number;
timeoutMs?: number;
chatId?: number;
client?: IMessageRpcClient;
config?: ReturnType<typeof loadConfig>;
account?: ResolvedIMessageAccount;
resolveAttachmentImpl?: (
mediaUrl: string,
maxBytes: number,
options?: {
localRoots?: readonly string[];
readFile?: (filePath: string) => Promise<Buffer>;
},
) => Promise<{ path: string; contentType?: string }>;
createClient?: (params: { cliPath: string; dbPath?: string }) => Promise<IMessageRpcClient>;
};
export type IMessageSendResult = {
messageId: string;
sentText: string;
};
const MAX_REPLY_TO_ID_LENGTH = 256;
function stripUnsafeReplyTagChars(value: string): string {
let next = "";
for (const ch of value) {
const code = ch.charCodeAt(0);
if ((code >= 0 && code <= 31) || code === 127 || ch === "[" || ch === "]") {
continue;
}
next += ch;
}
return next;
}
function sanitizeReplyToId(rawReplyToId?: string): string | undefined {
const trimmed = rawReplyToId?.trim();
if (!trimmed) {
return undefined;
}
const sanitized = stripUnsafeReplyTagChars(trimmed).trim();
if (!sanitized) {
return undefined;
}
if (sanitized.length > MAX_REPLY_TO_ID_LENGTH) {
return sanitized.slice(0, MAX_REPLY_TO_ID_LENGTH);
}
return sanitized;
}
function resolveMessageId(result: Record<string, unknown> | null | undefined): string | null {
if (!result) {
return null;
}
const raw =
(typeof result.messageId === "string" && result.messageId.trim()) ||
(typeof result.message_id === "string" && result.message_id.trim()) ||
(typeof result.id === "string" && result.id.trim()) ||
(typeof result.guid === "string" && result.guid.trim()) ||
(typeof result.message_id === "number" ? String(result.message_id) : null) ||
(typeof result.id === "number" ? String(result.id) : null);
return raw ? raw.trim() : null;
}
function resolveDeliveredIMessageText(text: string, mediaContentType?: string): string {
if (text.trim()) {
return text;
}
const kind = kindFromMime(mediaContentType ?? undefined);
if (!kind) {
return text;
}
return kind === "image" ? "<media:image>" : `<media:${kind}>`;
}
export async function sendMessageIMessage(
to: string,
text: string,
opts: IMessageSendOpts = {},
): Promise<IMessageSendResult> {
const cfg = opts.config ?? loadConfig();
const account =
opts.account ??
resolveIMessageAccount({
cfg,
accountId: opts.accountId,
});
const cliPath = opts.cliPath?.trim() || account.config.cliPath?.trim() || "imsg";
const dbPath = opts.dbPath?.trim() || account.config.dbPath?.trim();
const target = parseIMessageTarget(opts.chatId ? formatIMessageChatTarget(opts.chatId) : to);
const service =
opts.service ??
(target.kind === "handle" ? target.service : undefined) ??
(account.config.service as IMessageService | undefined);
const region = opts.region?.trim() || account.config.region?.trim() || "US";
const maxBytes =
typeof opts.maxBytes === "number"
? opts.maxBytes
: typeof account.config.mediaMaxMb === "number"
? account.config.mediaMaxMb * 1024 * 1024
: 16 * 1024 * 1024;
let message = text ?? "";
let filePath: string | undefined;
if (opts.mediaUrl?.trim()) {
const resolveAttachmentFn = opts.resolveAttachmentImpl ?? resolveOutboundAttachmentFromUrl;
const resolved = await resolveAttachmentFn(opts.mediaUrl.trim(), maxBytes, {
localRoots: opts.mediaLocalRoots,
readFile: opts.mediaReadFile,
});
filePath = resolved.path;
message = resolveDeliveredIMessageText(message, resolved.contentType ?? undefined);
}
if (!message.trim() && !filePath) {
throw new Error("iMessage send requires text or media");
}
if (message.trim()) {
const tableMode = resolveMarkdownTableMode({
cfg,
channel: "imessage",
accountId: account.accountId,
});
message = convertMarkdownTables(message, tableMode);
}
message = stripInlineDirectiveTagsForDelivery(message).text;
if (!message.trim() && !filePath) {
throw new Error("iMessage send requires text or media");
}
const resolvedReplyToId = sanitizeReplyToId(opts.replyToId);
const params: Record<string, unknown> = {
text: message,
service: service || "auto",
region,
};
if (resolvedReplyToId) {
params.reply_to = resolvedReplyToId;
}
if (filePath) {
params.file = filePath;
}
if (target.kind === "chat_id") {
params.chat_id = target.chatId;
} else if (target.kind === "chat_guid") {
params.chat_guid = target.chatGuid;
} else if (target.kind === "chat_identifier") {
params.chat_identifier = target.chatIdentifier;
} else {
params.to = target.to;
}
const client =
opts.client ??
(opts.createClient
? await opts.createClient({ cliPath, dbPath })
: await createIMessageRpcClient({ cliPath, dbPath }));
const shouldClose = !opts.client;
try {
const result = await client.request<{ ok?: string }>("send", params, {
timeoutMs: opts.timeoutMs,
});
const resolvedId = resolveMessageId(result);
return {
messageId: resolvedId ?? (result?.ok ? "ok" : "unknown"),
sentText: message,
};
} finally {
if (shouldClose) {
await client.stop();
}
}
}

View file

@ -0,0 +1,221 @@
import type {
ChannelSetupAdapter,
ChannelSetupWizard,
ChannelSetupWizardTextInput,
} from "openclaw/plugin-sdk/setup-runtime";
import {
createCliPathTextInput,
createDelegatedSetupWizardProxy,
createDelegatedTextInputShouldPrompt,
createPatchedAccountSetupAdapter,
mergeAllowFromEntries,
parseSetupEntriesAllowingWildcard,
patchChannelConfigForAccount,
promptParsedAllowFromForAccount,
setAccountAllowFromForChannel,
setSetupChannelEnabled,
type OpenClawConfig,
type WizardPrompter,
} from "openclaw/plugin-sdk/setup-runtime";
import { formatDocsLink } from "openclaw/plugin-sdk/setup-tools";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import { resolveDefaultIMessageAccountId, resolveIMessageAccount } from "./accounts.js";
import { normalizeIMessageHandle } from "./targets.js";
const channel = "imessage" as const;
export function parseIMessageAllowFromEntries(raw: string): { entries: string[]; error?: string } {
return parseSetupEntriesAllowingWildcard(raw, (entry) => {
const lower = normalizeLowercaseStringOrEmpty(entry);
if (lower.startsWith("chat_id:")) {
const id = entry.slice("chat_id:".length).trim();
if (!/^\d+$/.test(id)) {
return { error: `Invalid chat_id: ${entry}` };
}
return { value: entry };
}
if (lower.startsWith("chat_guid:")) {
if (!entry.slice("chat_guid:".length).trim()) {
return { error: "Invalid chat_guid entry" };
}
return { value: entry };
}
if (lower.startsWith("chat_identifier:")) {
if (!entry.slice("chat_identifier:".length).trim()) {
return { error: "Invalid chat_identifier entry" };
}
return { value: entry };
}
if (!normalizeIMessageHandle(entry)) {
return { error: `Invalid handle: ${entry}` };
}
return { value: entry };
});
}
function buildIMessageSetupPatch(input: {
cliPath?: string;
dbPath?: string;
service?: "imessage" | "sms" | "auto";
region?: string;
}) {
return {
...(input.cliPath ? { cliPath: input.cliPath } : {}),
...(input.dbPath ? { dbPath: input.dbPath } : {}),
...(input.service ? { service: input.service } : {}),
...(input.region ? { region: input.region } : {}),
};
}
export async function promptIMessageAllowFrom(params: {
cfg: OpenClawConfig;
prompter: WizardPrompter;
accountId?: string;
}): Promise<OpenClawConfig> {
return promptParsedAllowFromForAccount({
cfg: params.cfg,
accountId: params.accountId,
defaultAccountId: resolveDefaultIMessageAccountId(params.cfg),
prompter: params.prompter,
noteTitle: "iMessage allowlist",
noteLines: [
"Allowlist iMessage DMs by handle or chat target.",
"Examples:",
"- +15555550123",
"- user@example.com",
"- chat_id:123",
"- chat_guid:... or chat_identifier:...",
"Multiple entries: comma-separated.",
`Docs: ${formatDocsLink("/imessage", "imessage")}`,
],
message: "iMessage allowFrom (handle or chat_id)",
placeholder: "+15555550123, user@example.com, chat_id:123",
parseEntries: parseIMessageAllowFromEntries,
getExistingAllowFrom: ({ cfg, accountId }) =>
resolveIMessageAccount({ cfg, accountId }).config.allowFrom ?? [],
applyAllowFrom: ({ cfg, accountId, allowFrom }) =>
setAccountAllowFromForChannel({
cfg,
channel,
accountId,
allowFrom,
}),
});
}
export const imessageDmPolicy = {
label: "iMessage",
channel,
policyKey: "channels.imessage.dmPolicy",
allowFromKey: "channels.imessage.allowFrom",
resolveConfigKeys: (_cfg: OpenClawConfig, accountId?: string) => {
const targetAccountId = accountId ?? resolveDefaultIMessageAccountId(_cfg);
return targetAccountId !== "default"
? {
policyKey: `channels.imessage.accounts.${targetAccountId}.dmPolicy`,
allowFromKey: `channels.imessage.accounts.${targetAccountId}.allowFrom`,
}
: {
policyKey: "channels.imessage.dmPolicy",
allowFromKey: "channels.imessage.allowFrom",
};
},
getCurrent: (cfg: OpenClawConfig, accountId?: string) => {
const targetAccountId = accountId ?? resolveDefaultIMessageAccountId(cfg);
return resolveIMessageAccount({ cfg, accountId: targetAccountId }).config.dmPolicy ?? "pairing";
},
setPolicy: (
cfg: OpenClawConfig,
policy: "pairing" | "allowlist" | "open" | "disabled",
accountId?: string,
) => {
const targetAccountId = accountId ?? resolveDefaultIMessageAccountId(cfg);
return patchChannelConfigForAccount({
cfg,
channel,
accountId: targetAccountId,
patch:
policy === "open"
? {
dmPolicy: "open",
allowFrom: mergeAllowFromEntries(
resolveIMessageAccount({ cfg, accountId: targetAccountId }).config.allowFrom,
["*"],
),
}
: { dmPolicy: policy },
});
},
promptAllowFrom: promptIMessageAllowFrom,
};
function resolveIMessageCliPath(params: { cfg: OpenClawConfig; accountId: string }) {
return resolveIMessageAccount(params).config.cliPath ?? "imsg";
}
export function createIMessageCliPathTextInput(
shouldPrompt: NonNullable<ChannelSetupWizardTextInput["shouldPrompt"]>,
): ChannelSetupWizardTextInput {
return createCliPathTextInput({
inputKey: "cliPath",
message: "imsg CLI path",
resolvePath: ({ cfg, accountId }) => resolveIMessageCliPath({ cfg, accountId }),
shouldPrompt,
helpTitle: "iMessage",
helpLines: ["imsg CLI path required to enable iMessage."],
});
}
export const imessageCompletionNote = {
title: "iMessage next steps",
lines: [
"This is still a work in progress.",
"Ensure OpenClaw has Full Disk Access to Messages DB.",
"Grant Automation permission for Messages when prompted.",
"List chats with: imsg chats --limit 20",
`Docs: ${formatDocsLink("/imessage", "imessage")}`,
],
};
export const imessageSetupAdapter: ChannelSetupAdapter = createPatchedAccountSetupAdapter({
channelKey: channel,
buildPatch: (input) => buildIMessageSetupPatch(input),
});
export const imessageSetupStatusBase = {
configuredLabel: "configured",
unconfiguredLabel: "needs setup",
configuredHint: "imsg found",
unconfiguredHint: "imsg missing",
configuredScore: 1,
unconfiguredScore: 0,
resolveConfigured: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId?: string }) =>
resolveIMessageAccount({ cfg, accountId }).configured,
};
export function createIMessageSetupWizardProxy(loadWizard: () => Promise<ChannelSetupWizard>) {
return createDelegatedSetupWizardProxy({
channel,
loadWizard,
status: {
configuredLabel: imessageSetupStatusBase.configuredLabel,
unconfiguredLabel: imessageSetupStatusBase.unconfiguredLabel,
configuredHint: imessageSetupStatusBase.configuredHint,
unconfiguredHint: imessageSetupStatusBase.unconfiguredHint,
configuredScore: imessageSetupStatusBase.configuredScore,
unconfiguredScore: imessageSetupStatusBase.unconfiguredScore,
},
credentials: [],
textInputs: [
createIMessageCliPathTextInput(
createDelegatedTextInputShouldPrompt({
loadWizard,
inputKey: "cliPath",
}),
),
],
completionNote: imessageCompletionNote,
dmPolicy: imessageDmPolicy,
disable: (cfg: OpenClawConfig) => setSetupChannelEnabled(cfg, channel, false),
});
}

View file

@ -0,0 +1,46 @@
import {
createDetectedBinaryStatus,
setSetupChannelEnabled,
type ChannelSetupWizard,
} from "openclaw/plugin-sdk/setup";
import { detectBinary } from "openclaw/plugin-sdk/setup-tools";
import { resolveIMessageAccount } from "./accounts.js";
import {
createIMessageCliPathTextInput,
imessageCompletionNote,
imessageDmPolicy,
imessageSetupAdapter,
imessageSetupStatusBase,
parseIMessageAllowFromEntries,
} from "./setup-core.js";
const channel = "imessage" as const;
export const imessageSetupWizard: ChannelSetupWizard = {
channel,
status: createDetectedBinaryStatus({
channelLabel: "iMessage",
binaryLabel: "imsg",
configuredLabel: imessageSetupStatusBase.configuredLabel,
unconfiguredLabel: imessageSetupStatusBase.unconfiguredLabel,
configuredHint: imessageSetupStatusBase.configuredHint,
unconfiguredHint: imessageSetupStatusBase.unconfiguredHint,
configuredScore: imessageSetupStatusBase.configuredScore,
unconfiguredScore: imessageSetupStatusBase.unconfiguredScore,
resolveConfigured: imessageSetupStatusBase.resolveConfigured,
resolveBinaryPath: ({ cfg, accountId }) =>
resolveIMessageAccount({ cfg, accountId }).config.cliPath ?? "imsg",
detectBinary,
}),
credentials: [],
textInputs: [
createIMessageCliPathTextInput(async ({ currentValue }) => {
return !(await detectBinary(currentValue ?? "imsg"));
}),
],
completionNote: imessageCompletionNote,
dmPolicy: imessageDmPolicy,
disable: (cfg) => setSetupChannelEnabled(cfg, channel, false),
};
export { imessageSetupAdapter, parseIMessageAllowFromEntries };

View file

@ -0,0 +1,121 @@
import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
import {
adaptScopedAccountAccessor,
createScopedChannelConfigAdapter,
formatTrimmedAllowFromEntries,
} from "openclaw/plugin-sdk/channel-config-helpers";
import { createRestrictSendersChannelSecurity } from "openclaw/plugin-sdk/channel-policy";
import { createChannelPluginBase } from "openclaw/plugin-sdk/core";
import {
listIMessageAccountIds,
resolveDefaultIMessageAccountId,
resolveIMessageAccount,
type ResolvedIMessageAccount,
} from "./accounts.js";
import { getChatChannelMeta, type ChannelPlugin } from "./channel-api.js";
import { IMessageChannelConfigSchema } from "./config-schema.js";
import {
resolveIMessageAttachmentRoots,
resolveIMessageRemoteAttachmentRoots,
} from "./media-contract.js";
import { createIMessageSetupWizardProxy } from "./setup-core.js";
export const IMESSAGE_CHANNEL = "imessage" as const;
async function loadIMessageChannelRuntime() {
return await import("./channel.runtime.js");
}
export const imessageSetupWizard = createIMessageSetupWizardProxy(
async () => (await loadIMessageChannelRuntime()).imessageSetupWizard,
);
export const imessageConfigAdapter = createScopedChannelConfigAdapter<ResolvedIMessageAccount>({
sectionKey: IMESSAGE_CHANNEL,
listAccountIds: listIMessageAccountIds,
resolveAccount: adaptScopedAccountAccessor(resolveIMessageAccount),
defaultAccountId: resolveDefaultIMessageAccountId,
clearBaseFields: ["cliPath", "dbPath", "service", "region", "name"],
resolveAllowFrom: (account: ResolvedIMessageAccount) => account.config.allowFrom,
formatAllowFrom: (allowFrom) => formatTrimmedAllowFromEntries(allowFrom),
resolveDefaultTo: (account: ResolvedIMessageAccount) => account.config.defaultTo,
});
export const imessageSecurityAdapter =
createRestrictSendersChannelSecurity<ResolvedIMessageAccount>({
channelKey: IMESSAGE_CHANNEL,
resolveDmPolicy: (account) => account.config.dmPolicy,
resolveDmAllowFrom: (account) => account.config.allowFrom,
resolveGroupPolicy: (account) => account.config.groupPolicy,
surface: "iMessage groups",
openScope: "any member",
groupPolicyPath: "channels.imessage.groupPolicy",
groupAllowFromPath: "channels.imessage.groupAllowFrom",
mentionGated: false,
policyPathSuffix: "dmPolicy",
});
export function createIMessagePluginBase(params: {
setupWizard?: NonNullable<ChannelPlugin<ResolvedIMessageAccount>["setupWizard"]>;
setup: NonNullable<ChannelPlugin<ResolvedIMessageAccount>["setup"]>;
}): Pick<
ChannelPlugin<ResolvedIMessageAccount>,
| "id"
| "meta"
| "setupWizard"
| "capabilities"
| "reload"
| "configSchema"
| "config"
| "security"
| "setup"
| "messaging"
> {
const base = createChannelPluginBase({
id: IMESSAGE_CHANNEL,
meta: {
...getChatChannelMeta(IMESSAGE_CHANNEL),
aliases: ["imsg"],
showConfigured: false,
},
setupWizard: params.setupWizard,
capabilities: {
chatTypes: ["direct", "group"],
media: true,
},
reload: { configPrefixes: ["channels.imessage"] },
configSchema: IMessageChannelConfigSchema,
config: {
...imessageConfigAdapter,
isConfigured: (account) => account.configured,
describeAccount: (account) =>
describeAccountSnapshot({
account,
configured: account.configured,
}),
},
security: imessageSecurityAdapter,
setup: params.setup,
});
return {
...base,
messaging: {
resolveInboundAttachmentRoots: (params) =>
resolveIMessageAttachmentRoots({ accountId: params.accountId, cfg: params.cfg }),
resolveRemoteInboundAttachmentRoots: (params) =>
resolveIMessageRemoteAttachmentRoots({ accountId: params.accountId, cfg: params.cfg }),
},
} as Pick<
ChannelPlugin<ResolvedIMessageAccount>,
| "id"
| "meta"
| "setupWizard"
| "capabilities"
| "reload"
| "configSchema"
| "config"
| "security"
| "setup"
| "messaging"
>;
}

View file

@ -0,0 +1,20 @@
import type { ResolvedIMessageAccount } from "./accounts.js";
import type { IMessageProbe } from "./probe.js";
type ProbeIMessageAccount = (params?: {
timeoutMs?: number;
cliPath?: string;
dbPath?: string;
}) => Promise<IMessageProbe>;
export async function probeIMessageStatusAccount(params: {
account: ResolvedIMessageAccount;
timeoutMs?: number;
probeIMessageAccount: ProbeIMessageAccount;
}): Promise<IMessageProbe> {
return await params.probeIMessageAccount({
timeoutMs: params.timeoutMs,
cliPath: params.account.config.cliPath,
dbPath: params.account.config.dbPath,
});
}

View file

@ -0,0 +1,197 @@
import * as processRuntime from "openclaw/plugin-sdk/process-runtime";
import * as setupRuntime from "openclaw/plugin-sdk/setup";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createPluginSetupWizardStatus } from "../../../test/helpers/plugins/setup-wizard.js";
import { resolveIMessageAccount } from "./accounts.js";
import * as channelRuntimeModule from "./channel.runtime.js";
import * as clientModule from "./client.js";
import { probeIMessage } from "./probe.js";
import { imessageSetupWizard } from "./setup-surface.js";
import { probeIMessageStatusAccount } from "./status-core.js";
const getIMessageSetupStatus = createPluginSetupWizardStatus({
id: "imessage",
meta: {
label: "iMessage",
},
setupWizard: imessageSetupWizard,
} as never);
const spawnMock = vi.hoisted(() => vi.fn());
vi.mock("node:child_process", async () => {
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
return {
...actual,
spawn: (...args: unknown[]) => spawnMock(...args),
};
});
describe("createIMessageRpcClient", () => {
beforeEach(() => {
spawnMock.mockClear();
vi.stubEnv("VITEST", "true");
});
it("refuses to spawn imsg rpc in test environments", async () => {
const { createIMessageRpcClient } = await import("./client.js");
await expect(createIMessageRpcClient()).rejects.toThrow(
/Refusing to start imsg rpc in test environment/i,
);
expect(spawnMock).not.toHaveBeenCalled();
});
});
describe("imessage setup status", () => {
it("does not inherit configured state from a sibling account", async () => {
const result = await getIMessageSetupStatus({
cfg: {
channels: {
imessage: {
accounts: {
default: {
cliPath: "/usr/local/bin/imsg",
},
work: {},
},
},
},
},
accountOverrides: {
imessage: "work",
},
});
expect(result.configured).toBe(false);
expect(result.statusLines).toContain("iMessage: needs setup");
});
it("uses configured defaultAccount for omitted setup status cliPath", async () => {
const status = await getIMessageSetupStatus({
cfg: {
channels: {
imessage: {
cliPath: "/tmp/root-imsg",
defaultAccount: "work",
accounts: {
work: {
cliPath: "/tmp/work-imsg",
},
},
},
},
} as never,
accountOverrides: {},
});
expect(status.statusLines).toContain("imsg: missing (/tmp/work-imsg)");
});
it("does not inherit configured state from a sibling when defaultAccount is named", async () => {
const status = await getIMessageSetupStatus({
cfg: {
channels: {
imessage: {
defaultAccount: "work",
accounts: {
default: {
cliPath: "/usr/local/bin/imsg",
},
work: {},
},
},
},
} as never,
accountOverrides: {},
});
expect(status.configured).toBe(false);
expect(status.statusLines).toContain("iMessage: needs setup");
});
it("setup status lines use the selected account cliPath", async () => {
const status = await getIMessageSetupStatus({
cfg: {
channels: {
imessage: {
cliPath: "/tmp/root-imsg",
accounts: {
work: {
cliPath: "/tmp/work-imsg",
},
},
},
},
} as never,
accountOverrides: { imessage: "work" },
});
expect(status.statusLines).toContain("imsg: missing (/tmp/work-imsg)");
});
});
describe("probeIMessage", () => {
beforeEach(() => {
vi.restoreAllMocks();
spawnMock.mockClear();
vi.spyOn(setupRuntime, "detectBinary").mockResolvedValue(true);
vi.spyOn(processRuntime, "runCommandWithTimeout").mockResolvedValue({
stdout: "",
stderr: 'unknown command "rpc" for "imsg"',
code: 1,
signal: null,
killed: false,
termination: "exit",
});
});
it("marks unknown rpc subcommand as fatal", async () => {
const createIMessageRpcClientMock = vi
.spyOn(clientModule, "createIMessageRpcClient")
.mockResolvedValue({
request: vi.fn(),
stop: vi.fn(),
} as unknown as Awaited<ReturnType<typeof clientModule.createIMessageRpcClient>>);
const result = await probeIMessage(1000, { cliPath: "imsg-test-rpc" });
expect(result.ok).toBe(false);
expect(result.fatal).toBe(true);
expect(result.error).toMatch(/rpc/i);
expect(createIMessageRpcClientMock).not.toHaveBeenCalled();
});
it("status probe uses account-scoped cliPath and dbPath", async () => {
const probeSpy = vi.spyOn(channelRuntimeModule, "probeIMessageAccount").mockResolvedValue({
ok: true,
cliPath: "imsg-work",
dbPath: "/tmp/work-db",
} as Awaited<ReturnType<typeof channelRuntimeModule.probeIMessageAccount>>);
const cfg = {
channels: {
imessage: {
cliPath: "imsg-root",
dbPath: "/tmp/root-db",
accounts: {
work: {
cliPath: "imsg-work",
dbPath: "/tmp/work-db",
},
},
},
},
} as const;
const account = resolveIMessageAccount({ cfg, accountId: "work" });
await probeIMessageStatusAccount({
account,
timeoutMs: 2500,
probeIMessageAccount: channelRuntimeModule.probeIMessageAccount,
});
expect(probeSpy).toHaveBeenCalledWith({
timeoutMs: 2500,
cliPath: "imsg-work",
dbPath: "/tmp/work-db",
});
});
});

View file

@ -0,0 +1,14 @@
export {
createAllowedChatSenderMatcher,
parseChatAllowTargetPrefixes,
parseChatTargetPrefixesOrThrow,
resolveServicePrefixedAllowTarget,
resolveServicePrefixedChatTarget,
resolveServicePrefixedOrChatAllowTarget,
resolveServicePrefixedTarget,
type ChatSenderAllowParams,
type ChatTargetPrefixesParams,
type ParsedChatAllowTarget,
type ParsedChatTarget,
type ServicePrefix,
} from "openclaw/plugin-sdk/channel-targets";

View file

@ -0,0 +1,235 @@
import { describe, expect, it } from "vitest";
import {
resolveIMessageGroupRequireMention,
resolveIMessageGroupToolPolicy,
} from "./group-policy.js";
import { imessageDmPolicy } from "./setup-core.js";
import { parseIMessageAllowFromEntries } from "./setup-surface.js";
import {
formatIMessageChatTarget,
inferIMessageTargetChatType,
isAllowedIMessageSender,
looksLikeIMessageExplicitTargetId,
normalizeIMessageHandle,
parseIMessageTarget,
} from "./targets.js";
describe("imessage targets", () => {
it("parses chat_id targets", () => {
const target = parseIMessageTarget("chat_id:123");
expect(target).toEqual({ kind: "chat_id", chatId: 123 });
});
it("parses chat targets", () => {
const target = parseIMessageTarget("chat:456");
expect(target).toEqual({ kind: "chat_id", chatId: 456 });
});
it("parses sms handles with service", () => {
const target = parseIMessageTarget("sms:+1555");
expect(target).toEqual({ kind: "handle", to: "+1555", service: "sms" });
});
it("normalizes handles", () => {
expect(normalizeIMessageHandle("Name@Example.com")).toBe("name@example.com");
expect(normalizeIMessageHandle(" +1 (555) 222-3333 ")).toBe("+15552223333");
});
it("normalizes chat_id prefixes case-insensitively", () => {
expect(normalizeIMessageHandle("CHAT_ID:123")).toBe("chat_id:123");
expect(normalizeIMessageHandle("Chat_Id:456")).toBe("chat_id:456");
expect(normalizeIMessageHandle("chatid:789")).toBe("chat_id:789");
expect(normalizeIMessageHandle("CHAT:42")).toBe("chat_id:42");
});
it("normalizes chat_guid prefixes case-insensitively", () => {
expect(normalizeIMessageHandle("CHAT_GUID:abc-def")).toBe("chat_guid:abc-def");
expect(normalizeIMessageHandle("ChatGuid:XYZ")).toBe("chat_guid:XYZ");
expect(normalizeIMessageHandle("GUID:test-guid")).toBe("chat_guid:test-guid");
});
it("normalizes chat_identifier prefixes case-insensitively", () => {
expect(normalizeIMessageHandle("CHAT_IDENTIFIER:iMessage;-;chat123")).toBe(
"chat_identifier:iMessage;-;chat123",
);
expect(normalizeIMessageHandle("ChatIdentifier:test")).toBe("chat_identifier:test");
expect(normalizeIMessageHandle("CHATIDENT:foo")).toBe("chat_identifier:foo");
});
it("checks allowFrom against chat_id", () => {
const ok = isAllowedIMessageSender({
allowFrom: ["chat_id:9"],
sender: "+1555",
chatId: 9,
});
expect(ok).toBe(true);
});
it("checks allowFrom against handle", () => {
const ok = isAllowedIMessageSender({
allowFrom: ["user@example.com"],
sender: "User@Example.com",
});
expect(ok).toBe(true);
});
it("denies when allowFrom is empty", () => {
const ok = isAllowedIMessageSender({
allowFrom: [],
sender: "+1555",
});
expect(ok).toBe(false);
});
it("formats chat targets", () => {
expect(formatIMessageChatTarget(42)).toBe("chat_id:42");
expect(formatIMessageChatTarget(undefined)).toBe("");
});
it("only treats explicit chat targets as immediate ids", () => {
expect(looksLikeIMessageExplicitTargetId("chat_id:42")).toBe(true);
expect(looksLikeIMessageExplicitTargetId("sms:+15552223333")).toBe(true);
expect(looksLikeIMessageExplicitTargetId("+15552223333")).toBe(false);
expect(looksLikeIMessageExplicitTargetId("user@example.com")).toBe(false);
});
it("infers direct and group chat types from normalized targets", () => {
expect(inferIMessageTargetChatType("+15552223333")).toBe("direct");
expect(inferIMessageTargetChatType("chat_id:42")).toBe("group");
});
});
describe("imessage group policy", () => {
it("uses generic channel group policy helpers", () => {
const cfg = {
channels: {
imessage: {
groups: {
"chat:family": {
requireMention: false,
tools: { deny: ["exec"] },
},
"*": {
requireMention: true,
tools: { allow: ["message.send"] },
},
},
},
},
} as any;
expect(resolveIMessageGroupRequireMention({ cfg, groupId: "chat:family" })).toBe(false);
expect(resolveIMessageGroupRequireMention({ cfg, groupId: "chat:other" })).toBe(true);
expect(resolveIMessageGroupToolPolicy({ cfg, groupId: "chat:family" })).toEqual({
deny: ["exec"],
});
expect(resolveIMessageGroupToolPolicy({ cfg, groupId: "chat:other" })).toEqual({
allow: ["message.send"],
});
});
});
describe("parseIMessageAllowFromEntries", () => {
it("parses handles and chat targets", () => {
expect(parseIMessageAllowFromEntries("+15555550123, chat_id:123, chat_guid:abc")).toEqual({
entries: ["+15555550123", "chat_id:123", "chat_guid:abc"],
});
});
it("returns validation errors for invalid chat_id", () => {
expect(parseIMessageAllowFromEntries("chat_id:abc")).toEqual({
entries: [],
error: "Invalid chat_id: chat_id:abc",
});
});
it("returns validation errors for invalid chat_identifier entries", () => {
expect(parseIMessageAllowFromEntries("chat_identifier:")).toEqual({
entries: [],
error: "Invalid chat_identifier entry",
});
});
it("reads the named-account DM policy instead of the channel root", () => {
expect(
imessageDmPolicy.getCurrent(
{
channels: {
imessage: {
dmPolicy: "disabled",
accounts: {
work: {
cliPath: "imsg",
dmPolicy: "allowlist",
},
},
},
},
},
"work",
),
).toBe("allowlist");
});
it("reports account-scoped config keys for named accounts", () => {
expect(imessageDmPolicy.resolveConfigKeys?.({ channels: { imessage: {} } }, "work")).toEqual({
policyKey: "channels.imessage.accounts.work.dmPolicy",
allowFromKey: "channels.imessage.accounts.work.allowFrom",
});
});
it('writes open policy state to the named account and stores inherited allowFrom with "*"', () => {
const next = imessageDmPolicy.setPolicy(
{
channels: {
imessage: {
allowFrom: ["+15555550123"],
accounts: {
work: {
cliPath: "imsg",
},
},
},
},
},
"open",
"work",
);
expect(next.channels?.imessage?.dmPolicy).toBeUndefined();
expect(next.channels?.imessage?.allowFrom).toEqual(["+15555550123"]);
expect(next.channels?.imessage?.accounts?.work?.dmPolicy).toBe("open");
expect(next.channels?.imessage?.accounts?.work?.allowFrom).toEqual(["+15555550123", "*"]);
});
it("uses the configured default account for omitted-account DM policy reads, keys, and writes", () => {
const cfg = {
channels: {
imessage: {
allowFrom: ["+15555550123"],
defaultAccount: "work",
accounts: {
work: {
cliPath: "imsg",
dmPolicy: "allowlist" as const,
allowFrom: ["chat_id:123"],
},
},
},
},
};
expect(imessageDmPolicy.getCurrent(cfg)).toBe("allowlist");
expect(imessageDmPolicy.resolveConfigKeys?.(cfg)).toEqual({
policyKey: "channels.imessage.accounts.work.dmPolicy",
allowFromKey: "channels.imessage.accounts.work.allowFrom",
});
const next = imessageDmPolicy.setPolicy(cfg, "open");
expect(next.channels?.imessage?.dmPolicy).toBeUndefined();
expect(next.channels?.imessage?.allowFrom).toEqual(["+15555550123"]);
expect(next.channels?.imessage?.accounts?.work?.dmPolicy).toBe("open");
expect(next.channels?.imessage?.accounts?.work?.allowFrom).toEqual(["chat_id:123", "*"]);
});
});

View file

@ -0,0 +1,176 @@
import { normalizeE164 } from "openclaw/plugin-sdk/account-resolution";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import {
createAllowedChatSenderMatcher,
type ChatSenderAllowParams,
type ParsedChatTarget,
parseChatTargetPrefixesOrThrow,
resolveServicePrefixedChatTarget,
resolveServicePrefixedOrChatAllowTarget,
} from "./target-parsing-helpers.js";
export type IMessageService = "imessage" | "sms" | "auto";
export type IMessageTarget =
| { kind: "chat_id"; chatId: number }
| { kind: "chat_guid"; chatGuid: string }
| { kind: "chat_identifier"; chatIdentifier: string }
| { kind: "handle"; to: string; service: IMessageService };
export type IMessageAllowTarget = ParsedChatTarget | { kind: "handle"; handle: string };
const CHAT_ID_PREFIXES = ["chat_id:", "chatid:", "chat:"];
const CHAT_GUID_PREFIXES = ["chat_guid:", "chatguid:", "guid:"];
const CHAT_IDENTIFIER_PREFIXES = ["chat_identifier:", "chatidentifier:", "chatident:"];
const SERVICE_PREFIXES: Array<{ prefix: string; service: IMessageService }> = [
{ prefix: "imessage:", service: "imessage" },
{ prefix: "sms:", service: "sms" },
{ prefix: "auto:", service: "auto" },
];
export function normalizeIMessageHandle(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) {
return "";
}
const lowered = normalizeLowercaseStringOrEmpty(trimmed);
if (lowered.startsWith("imessage:")) {
return normalizeIMessageHandle(trimmed.slice(9));
}
if (lowered.startsWith("sms:")) {
return normalizeIMessageHandle(trimmed.slice(4));
}
if (lowered.startsWith("auto:")) {
return normalizeIMessageHandle(trimmed.slice(5));
}
// Normalize chat_id/chat_guid/chat_identifier prefixes case-insensitively
for (const prefix of CHAT_ID_PREFIXES) {
if (lowered.startsWith(prefix)) {
const value = trimmed.slice(prefix.length).trim();
return `chat_id:${value}`;
}
}
for (const prefix of CHAT_GUID_PREFIXES) {
if (lowered.startsWith(prefix)) {
const value = trimmed.slice(prefix.length).trim();
return `chat_guid:${value}`;
}
}
for (const prefix of CHAT_IDENTIFIER_PREFIXES) {
if (lowered.startsWith(prefix)) {
const value = trimmed.slice(prefix.length).trim();
return `chat_identifier:${value}`;
}
}
if (trimmed.includes("@")) {
return normalizeLowercaseStringOrEmpty(trimmed);
}
const normalized = normalizeE164(trimmed);
if (normalized) {
return normalized;
}
return trimmed.replace(/\s+/g, "");
}
export function parseIMessageTarget(raw: string): IMessageTarget {
const trimmed = raw.trim();
if (!trimmed) {
throw new Error("iMessage target is required");
}
const lower = normalizeLowercaseStringOrEmpty(trimmed);
const servicePrefixed = resolveServicePrefixedChatTarget({
trimmed,
lower,
servicePrefixes: SERVICE_PREFIXES,
chatIdPrefixes: CHAT_ID_PREFIXES,
chatGuidPrefixes: CHAT_GUID_PREFIXES,
chatIdentifierPrefixes: CHAT_IDENTIFIER_PREFIXES,
parseTarget: parseIMessageTarget,
});
if (servicePrefixed) {
return servicePrefixed;
}
const chatTarget = parseChatTargetPrefixesOrThrow({
trimmed,
lower,
chatIdPrefixes: CHAT_ID_PREFIXES,
chatGuidPrefixes: CHAT_GUID_PREFIXES,
chatIdentifierPrefixes: CHAT_IDENTIFIER_PREFIXES,
});
if (chatTarget) {
return chatTarget;
}
return { kind: "handle", to: trimmed, service: "auto" };
}
export function looksLikeIMessageExplicitTargetId(raw: string): boolean {
const trimmed = raw.trim();
if (!trimmed) {
return false;
}
const lower = normalizeLowercaseStringOrEmpty(trimmed);
if (/^(imessage:|sms:|auto:)/.test(lower)) {
return true;
}
return (
CHAT_ID_PREFIXES.some((prefix) => lower.startsWith(prefix)) ||
CHAT_GUID_PREFIXES.some((prefix) => lower.startsWith(prefix)) ||
CHAT_IDENTIFIER_PREFIXES.some((prefix) => lower.startsWith(prefix))
);
}
export function inferIMessageTargetChatType(raw: string): "direct" | "group" | undefined {
try {
const parsed = parseIMessageTarget(raw);
if (parsed.kind === "handle") {
return "direct";
}
return "group";
} catch {
return undefined;
}
}
export function parseIMessageAllowTarget(raw: string): IMessageAllowTarget {
const trimmed = raw.trim();
if (!trimmed) {
return { kind: "handle", handle: "" };
}
const lower = normalizeLowercaseStringOrEmpty(trimmed);
const servicePrefixed = resolveServicePrefixedOrChatAllowTarget({
trimmed,
lower,
servicePrefixes: SERVICE_PREFIXES,
parseAllowTarget: parseIMessageAllowTarget,
chatIdPrefixes: CHAT_ID_PREFIXES,
chatGuidPrefixes: CHAT_GUID_PREFIXES,
chatIdentifierPrefixes: CHAT_IDENTIFIER_PREFIXES,
});
if (servicePrefixed) {
return servicePrefixed;
}
return { kind: "handle", handle: normalizeIMessageHandle(trimmed) };
}
const isAllowedIMessageSenderMatcher = createAllowedChatSenderMatcher({
normalizeSender: normalizeIMessageHandle,
parseAllowTarget: parseIMessageAllowTarget,
});
export function isAllowedIMessageSender(params: ChatSenderAllowParams): boolean {
return isAllowedIMessageSenderMatcher(params);
}
export function formatIMessageChatTarget(chatId?: number | null): string {
if (!chatId || !Number.isFinite(chatId)) {
return "";
}
return `chat_id:${chatId}`;
}

View file

@ -0,0 +1,31 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
listImportedBundledPluginFacadeIds,
resetFacadeRuntimeStateForTest,
} from "../../../src/plugin-sdk/facade-runtime.js";
import { createIMessageTestPlugin } from "./imessage.test-plugin.js";
beforeEach(() => {
resetFacadeRuntimeStateForTest();
});
afterEach(() => {
resetFacadeRuntimeStateForTest();
});
describe("createIMessageTestPlugin", () => {
it("does not load the bundled iMessage facade by default", () => {
expect(listImportedBundledPluginFacadeIds()).toEqual([]);
createIMessageTestPlugin();
expect(listImportedBundledPluginFacadeIds()).toEqual([]);
});
it("normalizes repeated transport prefixes without recursive stack growth", () => {
const plugin = createIMessageTestPlugin();
const prefixedHandle = `${"imessage:".repeat(5000)}+44 20 7946 0958`;
expect(plugin.messaging?.normalizeTarget?.(prefixedHandle)).toBe("+442079460958");
});
});