mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-10 09:10:46 +08:00
重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。
同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。 Made-with: Cursor
This commit is contained in:
parent
4a23b715a2
commit
dbbe3add6a
14438 changed files with 2693620 additions and 2546 deletions
6
openclaw/extensions/imessage/src/account-types.ts
Normal file
6
openclaw/extensions/imessage/src/account-types.ts
Normal 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"
|
||||
>;
|
||||
29
openclaw/extensions/imessage/src/accounts.test.ts
Normal file
29
openclaw/extensions/imessage/src/accounts.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
71
openclaw/extensions/imessage/src/accounts.ts
Normal file
71
openclaw/extensions/imessage/src/accounts.ts
Normal 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);
|
||||
}
|
||||
32
openclaw/extensions/imessage/src/channel-api.ts
Normal file
32
openclaw/extensions/imessage/src/channel-api.ts
Normal 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 };
|
||||
83
openclaw/extensions/imessage/src/channel.runtime.ts
Normal file
83
openclaw/extensions/imessage/src/channel.runtime.ts
Normal 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 };
|
||||
11
openclaw/extensions/imessage/src/channel.setup.ts
Normal file
11
openclaw/extensions/imessage/src/channel.setup.ts
Normal 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,
|
||||
}),
|
||||
};
|
||||
286
openclaw/extensions/imessage/src/channel.ts
Normal file
286
openclaw/extensions/imessage/src/channel.ts
Normal 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,
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
256
openclaw/extensions/imessage/src/client.ts
Normal file
256
openclaw/extensions/imessage/src/client.ts
Normal 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;
|
||||
}
|
||||
30
openclaw/extensions/imessage/src/config-accessors.test.ts
Normal file
30
openclaw/extensions/imessage/src/config-accessors.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
21
openclaw/extensions/imessage/src/config-accessors.ts
Normal file
21
openclaw/extensions/imessage/src/config-accessors.ts
Normal 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;
|
||||
}
|
||||
6
openclaw/extensions/imessage/src/config-schema.ts
Normal file
6
openclaw/extensions/imessage/src/config-schema.ts
Normal 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,
|
||||
});
|
||||
20
openclaw/extensions/imessage/src/config-ui-hints.ts
Normal file
20
openclaw/extensions/imessage/src/config-ui-hints.ts
Normal 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>;
|
||||
2
openclaw/extensions/imessage/src/constants.ts
Normal file
2
openclaw/extensions/imessage/src/constants.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
/** Default timeout for iMessage probe/RPC operations (10 seconds). */
|
||||
export const DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS = 10_000;
|
||||
46
openclaw/extensions/imessage/src/conversation-bindings.ts
Normal file
46
openclaw/extensions/imessage/src/conversation-bindings.ts
Normal 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,
|
||||
});
|
||||
},
|
||||
};
|
||||
50
openclaw/extensions/imessage/src/conversation-id-core.ts
Normal file
50
openclaw/extensions/imessage/src/conversation-id-core.ts
Normal 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;
|
||||
}
|
||||
26
openclaw/extensions/imessage/src/conversation-id.ts
Normal file
26
openclaw/extensions/imessage/src/conversation-id.ts
Normal 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;
|
||||
}
|
||||
59
openclaw/extensions/imessage/src/conversation-route.test.ts
Normal file
59
openclaw/extensions/imessage/src/conversation-route.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
79
openclaw/extensions/imessage/src/conversation-route.ts
Normal file
79
openclaw/extensions/imessage/src/conversation-route.ts
Normal 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",
|
||||
};
|
||||
}
|
||||
40
openclaw/extensions/imessage/src/group-policy.ts
Normal file
40
openclaw/extensions/imessage/src/group-policy.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
125
openclaw/extensions/imessage/src/imessage.test-plugin.ts
Normal file
125
openclaw/extensions/imessage/src/imessage.test-plugin.ts
Normal 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),
|
||||
},
|
||||
});
|
||||
31
openclaw/extensions/imessage/src/media-contract.ts
Normal file
31
openclaw/extensions/imessage/src/media-contract.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
433
openclaw/extensions/imessage/src/monitor.gating.test.ts
Normal file
433
openclaw/extensions/imessage/src/monitor.gating.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
2
openclaw/extensions/imessage/src/monitor.ts
Normal file
2
openclaw/extensions/imessage/src/monitor.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { monitorIMessageProvider } from "./monitor/monitor-provider.js";
|
||||
export type { MonitorIMessageOpts } from "./monitor/types.js";
|
||||
|
|
@ -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"));
|
||||
});
|
||||
});
|
||||
34
openclaw/extensions/imessage/src/monitor/abort-handler.ts
Normal file
34
openclaw/extensions/imessage/src/monitor/abort-handler.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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";
|
||||
181
openclaw/extensions/imessage/src/monitor/deliver.test.ts
Normal file
181
openclaw/extensions/imessage/src/monitor/deliver.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
79
openclaw/extensions/imessage/src/monitor/deliver.ts
Normal file
79
openclaw/extensions/imessage/src/monitor/deliver.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
116
openclaw/extensions/imessage/src/monitor/echo-cache.ts
Normal file
116
openclaw/extensions/imessage/src/monitor/echo-cache.ts
Normal 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();
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
672
openclaw/extensions/imessage/src/monitor/inbound-processing.ts
Normal file
672
openclaw/extensions/imessage/src/monitor/inbound-processing.ts
Normal 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}"`;
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
647
openclaw/extensions/imessage/src/monitor/monitor-provider.ts
Normal file
647
openclaw/extensions/imessage/src/monitor/monitor-provider.ts
Normal 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;
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
64
openclaw/extensions/imessage/src/monitor/reflection-guard.ts
Normal file
64
openclaw/extensions/imessage/src/monitor/reflection-guard.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
11
openclaw/extensions/imessage/src/monitor/runtime.ts
Normal file
11
openclaw/extensions/imessage/src/monitor/runtime.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
103
openclaw/extensions/imessage/src/monitor/self-chat-cache.ts
Normal file
103
openclaw/extensions/imessage/src/monitor/self-chat-cache.ts
Normal 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();
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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("");
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
42
openclaw/extensions/imessage/src/monitor/types.ts
Normal file
42
openclaw/extensions/imessage/src/monitor/types.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -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({});
|
||||
});
|
||||
});
|
||||
38
openclaw/extensions/imessage/src/monitor/watch-error-log.ts
Normal file
38
openclaw/extensions/imessage/src/monitor/watch-error-log.ts
Normal 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;
|
||||
}
|
||||
28
openclaw/extensions/imessage/src/normalize.test.ts
Normal file
28
openclaw/extensions/imessage/src/normalize.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
99
openclaw/extensions/imessage/src/normalize.ts
Normal file
99
openclaw/extensions/imessage/src/normalize.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
1
openclaw/extensions/imessage/src/outbound-send-deps.ts
Normal file
1
openclaw/extensions/imessage/src/outbound-send-deps.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export const IMESSAGE_LEGACY_OUTBOUND_SEND_DEP_KEYS = ["sendIMessage"] as const;
|
||||
106
openclaw/extensions/imessage/src/probe.ts
Normal file
106
openclaw/extensions/imessage/src/probe.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
9
openclaw/extensions/imessage/src/runtime.ts
Normal file
9
openclaw/extensions/imessage/src/runtime.ts
Normal 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 };
|
||||
194
openclaw/extensions/imessage/src/send.ts
Normal file
194
openclaw/extensions/imessage/src/send.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
221
openclaw/extensions/imessage/src/setup-core.ts
Normal file
221
openclaw/extensions/imessage/src/setup-core.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
46
openclaw/extensions/imessage/src/setup-surface.ts
Normal file
46
openclaw/extensions/imessage/src/setup-surface.ts
Normal 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 };
|
||||
121
openclaw/extensions/imessage/src/shared.ts
Normal file
121
openclaw/extensions/imessage/src/shared.ts
Normal 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"
|
||||
>;
|
||||
}
|
||||
20
openclaw/extensions/imessage/src/status-core.ts
Normal file
20
openclaw/extensions/imessage/src/status-core.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
197
openclaw/extensions/imessage/src/status.test.ts
Normal file
197
openclaw/extensions/imessage/src/status.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
14
openclaw/extensions/imessage/src/target-parsing-helpers.ts
Normal file
14
openclaw/extensions/imessage/src/target-parsing-helpers.ts
Normal 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";
|
||||
235
openclaw/extensions/imessage/src/targets.test.ts
Normal file
235
openclaw/extensions/imessage/src/targets.test.ts
Normal 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", "*"]);
|
||||
});
|
||||
});
|
||||
176
openclaw/extensions/imessage/src/targets.ts
Normal file
176
openclaw/extensions/imessage/src/targets.ts
Normal 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}`;
|
||||
}
|
||||
31
openclaw/extensions/imessage/src/test-plugin.test.ts
Normal file
31
openclaw/extensions/imessage/src/test-plugin.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue