openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
630 lines (629 loc) • 23.2 kB
JavaScript
import { s as normalizeOptionalLowercaseString } from "./string-coerce-mnp54Vah.js";
import { t as createSubsystemLogger } from "./subsystem-BzXSmsuh.js";
import { n as resolveChannelGroupRequireMention, r as resolveChannelGroupToolsPolicy } from "./group-policy-CShy7eWv.js";
import { i as createLazyRuntimeNamedExport } from "./lazy-runtime-D-7_JraP.js";
import { _ as readReactionParams, b as readStringParam, g as readPositiveIntegerParam, i as createActionGate, l as jsonResult, m as readNonNegativeIntegerParam } from "./common-C4yy9V-D.js";
import { t as readBooleanParam } from "./boolean-param-K-k-cDXs.js";
import "./runtime-env-D_2q8-VK.js";
import "./string-coerce-runtime-CEGJWkQ_.js";
import "./channel-policy-CIW58SA5.js";
import "./channel-actions-BOfvC-Gl.js";
import { t as extractToolSend } from "./tool-send-DzTXHBeR.js";
import { a as resolveIMessageAccount } from "./accounts-DzoymhDS.js";
import { n as IMESSAGE_ACTIONS, r as IMESSAGE_ACTION_NAMES, t as describeIMessageMessageTool } from "./message-tool-api-BxLXqLWF.js";
import "./client-RDkCLFWE.js";
import { t as getCachedIMessagePrivateApiStatus } from "./private-api-status-DBgq8x0h.js";
import { c as parseIMessageTarget } from "./targets-BhNdlUSi.js";
import { a as findLatestIMessageEntryForChat, s as rememberIMessageReplyCache } from "./monitor-reply-cache-DUH5oJzE.js";
import { s as probeIMessagePrivateApi } from "./sanitize-outbound-B_Wx0jBt.js";
//#region extensions/imessage/src/actions.ts
const loadIMessageActionsRuntime = createLazyRuntimeNamedExport(() => import("./actions.runtime-CLfMQSOg.js"), "imessageActionsRuntime");
const log = createSubsystemLogger("channels/imessage");
const providerId = "imessage";
const SUPPORTED_ACTIONS = new Set([...IMESSAGE_ACTION_NAMES, "upload-file"]);
function readMessageText(params) {
return readStringParam(params, "text") ?? readStringParam(params, "message");
}
function rememberOutboundBridgeMessage(params) {
const messageId = params.messageId?.trim();
if (!messageId || messageId === "ok" || messageId === "unknown") return;
rememberIMessageReplyCache({
accountId: params.accountId,
messageId,
chatGuid: params.chatGuid,
timestamp: Date.now(),
isFromMe: true
});
}
/**
* Read messageId from the action params, falling back to the most recent
* inbound in the same chat when the caller omitted it. The natural intent
* for "react with 👍" or "tapback the last message" is the message that
* just arrived in the current conversation; making the agent re-quote a
* message id every time is friction the cache already has the answer for.
*/
function readMessageIdWithChatFallback(params, chatContext) {
const explicit = readStringParam(params, "messageId");
if (explicit) return explicit;
const latest = findLatestIMessageEntryForChat(chatContext);
if (latest?.messageId) return latest.messageId;
return readStringParam(params, "messageId", { required: true });
}
async function resolveChatGuid(params) {
const explicitChatGuid = readStringParam(params.actionParams, "chatGuid");
if (explicitChatGuid) return explicitChatGuid;
const explicitChatId = readPositiveIntegerParam(params.actionParams, "chatId");
if (typeof explicitChatId === "number") {
const resolved = await params.runtime.resolveChatGuidForTarget({
target: {
kind: "chat_id",
chatId: explicitChatId
},
options: params.options
});
if (resolved) return resolved;
throw new Error(`iMessage ${params.action} failed: chatGuid not found for chat_id:<redacted>.`);
}
const explicitChatIdentifier = readStringParam(params.actionParams, "chatIdentifier");
if (explicitChatIdentifier) {
const resolved = await params.runtime.resolveChatGuidForTarget({
target: {
kind: "chat_identifier",
chatIdentifier: explicitChatIdentifier
},
options: params.options
});
if (resolved) return resolved;
throw new Error(`iMessage ${params.action} failed: chatGuid not found for chat_identifier:<redacted>.`);
}
const rawTarget = readStringParam(params.actionParams, "to") ?? readStringParam(params.actionParams, "target") ?? (params.currentChannelId?.trim() || void 0);
if (rawTarget) {
const target = parseIMessageTarget(rawTarget);
if (target.kind === "chat_guid") return target.chatGuid;
if (target.kind === "chat_id" || target.kind === "chat_identifier") {
const resolved = await params.runtime.resolveChatGuidForTarget({
target,
options: params.options
});
if (resolved) return resolved;
throw new Error(`iMessage ${params.action} failed: chatGuid not found for ${formatUnresolvedTarget(target)}.`);
}
if (target.kind === "handle") {
const synthesizedIdentifier = `${target.service === "sms" ? "SMS" : "iMessage"};-;${target.to}`;
const resolved = await params.runtime.resolveChatGuidForTarget({
target: {
kind: "chat_identifier",
chatIdentifier: synthesizedIdentifier
},
options: params.options
});
if (resolved) return resolved;
if (params.action === "react" || params.action === "edit" || params.action === "unsend") throw new Error(`iMessage ${params.action} requires a known chat. No registered chat for the supplied target; send a message first or pass an explicit chatGuid.`);
return synthesizedIdentifier;
}
}
throw new Error(`iMessage ${params.action} requires chatGuid, chatId, chatIdentifier, or a chat target.`);
}
function formatUnresolvedTarget(target) {
return target.kind === "chat_id" ? "chat_id:<redacted>" : "chat_identifier:<redacted>";
}
function buildChatContextFromActionParams(params) {
const explicitChatGuid = readStringParam(params.actionParams, "chatGuid")?.trim();
const explicitChatIdentifier = readStringParam(params.actionParams, "chatIdentifier")?.trim();
const explicitChatId = readPositiveIntegerParam(params.actionParams, "chatId");
const rawTarget = readStringParam(params.actionParams, "to") ?? readStringParam(params.actionParams, "target") ?? (params.currentChannelId?.trim() || void 0);
const target = rawTarget ? parseIMessageTarget(rawTarget) : null;
const handleChatIdentifier = target?.kind === "handle" ? `${target.service === "sms" ? "SMS" : "iMessage"};-;${target.to}` : void 0;
return {
chatGuid: explicitChatGuid || (target?.kind === "chat_guid" ? target.chatGuid : void 0),
chatIdentifier: explicitChatIdentifier || (target?.kind === "chat_identifier" ? target.chatIdentifier : void 0) || handleChatIdentifier,
chatId: typeof explicitChatId === "number" ? explicitChatId : target?.kind === "chat_id" ? target.chatId : void 0
};
}
function mapTapbackReaction(emoji) {
const value = normalizeOptionalLowercaseString(emoji)?.replace(/\ufe0f/g, "");
if (!value) return;
if ([
"love",
"heart",
"❤",
"❤️"
].includes(value)) return "love";
if ([
"like",
"+1",
"thumbsup",
"👍"
].includes(value)) return "like";
if ([
"dislike",
"-1",
"thumbsdown",
"👎"
].includes(value)) return "dislike";
if ([
"laugh",
"haha",
"😂",
"🤣"
].includes(value)) return "laugh";
if ([
"emphasize",
"!!",
"‼",
"‼️"
].includes(value)) return "emphasize";
if ([
"question",
"?",
"?",
"❓"
].includes(value)) return "question";
}
function decodeBase64Buffer(params, action) {
const base64Buffer = readStringParam(params, "buffer");
if (!base64Buffer) throw new Error(`iMessage ${action} requires buffer (base64) parameter.`);
return Uint8Array.from(Buffer.from(base64Buffer, "base64"));
}
const REPLY_ATTACHMENT_PATH_PARAM_NAMES = [
"filePath",
"path",
"media",
"mediaUrl",
"fileUrl"
];
function extractReplyAttachment(params) {
const buffer = readStringParam(params, "buffer");
if (buffer) {
const filename = readStringParam(params, "filename") ?? "attachment.bin";
return {
spec: {
kind: "buffer",
buffer: Uint8Array.from(Buffer.from(buffer, "base64")),
filename
},
sourceParam: "buffer"
};
}
for (const name of REPLY_ATTACHMENT_PATH_PARAM_NAMES) if (readStringParam(params, name)) return {
spec: null,
bypassParam: name
};
return null;
}
const KNOWN_EFFECT_IDS = new Set([
"com.apple.MobileSMS.expressivesend.impact",
"com.apple.MobileSMS.expressivesend.loud",
"com.apple.MobileSMS.expressivesend.gentle",
"com.apple.MobileSMS.expressivesend.invisibleink",
"com.apple.MobileSMS.expressivesend.confetti",
"com.apple.MobileSMS.expressivesend.lasers",
"com.apple.MobileSMS.expressivesend.fireworks",
"com.apple.MobileSMS.expressivesend.balloon",
"com.apple.MobileSMS.expressivesend.heart",
"com.apple.messages.effect.CKEchoEffect",
"com.apple.messages.effect.CKHappyBirthdayEffect",
"com.apple.messages.effect.CKShootingStarEffect",
"com.apple.messages.effect.CKSparklesEffect",
"com.apple.messages.effect.CKSpotlightEffect"
]);
function effectIdFromParam(raw) {
const value = normalizeOptionalLowercaseString(raw);
if (!value) return;
const resolved = {
slam: "com.apple.MobileSMS.expressivesend.impact",
impact: "com.apple.MobileSMS.expressivesend.impact",
loud: "com.apple.MobileSMS.expressivesend.loud",
gentle: "com.apple.MobileSMS.expressivesend.gentle",
"invisible-ink": "com.apple.MobileSMS.expressivesend.invisibleink",
invisibleink: "com.apple.MobileSMS.expressivesend.invisibleink",
confetti: "com.apple.MobileSMS.expressivesend.confetti",
lasers: "com.apple.MobileSMS.expressivesend.lasers",
fireworks: "com.apple.MobileSMS.expressivesend.fireworks",
balloons: "com.apple.MobileSMS.expressivesend.balloon",
balloon: "com.apple.MobileSMS.expressivesend.balloon",
heart: "com.apple.MobileSMS.expressivesend.heart",
echo: "com.apple.messages.effect.CKEchoEffect",
happybirthday: "com.apple.messages.effect.CKHappyBirthdayEffect",
"happy-birthday": "com.apple.messages.effect.CKHappyBirthdayEffect",
shootingstar: "com.apple.messages.effect.CKShootingStarEffect",
"shooting-star": "com.apple.messages.effect.CKShootingStarEffect",
sparkles: "com.apple.messages.effect.CKSparklesEffect",
spotlight: "com.apple.messages.effect.CKSpotlightEffect"
}[value] ?? raw;
if (typeof resolved === "string" && KNOWN_EFFECT_IDS.has(resolved)) return resolved;
throw new Error(`iMessage sendWithEffect rejected unknown effect "${raw}". Use one of: slam, loud, gentle, invisibleink, confetti, lasers, fireworks, balloon, heart, echo, happybirthday, shootingstar, sparkles, spotlight (or the canonical com.apple.MobileSMS.expressivesend.* / com.apple.messages.effect.* identifier).`);
}
function assertActionEnabled(action, actionsConfig) {
const spec = IMESSAGE_ACTIONS[action === "upload-file" ? "sendAttachment" : action];
if (!spec?.gate || !createActionGate(actionsConfig)(spec.gate)) throw new Error(`iMessage ${action} is disabled in config.`);
}
const imessageMessageActions = {
describeMessageTool: describeIMessageMessageTool,
supportsAction: ({ action }) => SUPPORTED_ACTIONS.has(action),
messageActionTargetAliases: {
react: { aliases: [
"chatGuid",
"chatIdentifier",
"chatId"
] },
edit: { aliases: [
"chatGuid",
"chatIdentifier",
"chatId",
"messageId"
] },
unsend: { aliases: [
"chatGuid",
"chatIdentifier",
"chatId",
"messageId"
] },
reply: { aliases: [
"chatGuid",
"chatIdentifier",
"chatId",
"messageId"
] },
sendWithEffect: { aliases: [
"chatGuid",
"chatIdentifier",
"chatId"
] },
sendAttachment: { aliases: [
"chatGuid",
"chatIdentifier",
"chatId"
] },
"upload-file": { aliases: [
"chatGuid",
"chatIdentifier",
"chatId"
] },
renameGroup: { aliases: [
"chatGuid",
"chatIdentifier",
"chatId"
] },
setGroupIcon: { aliases: [
"chatGuid",
"chatIdentifier",
"chatId"
] },
addParticipant: { aliases: [
"chatGuid",
"chatIdentifier",
"chatId"
] },
removeParticipant: { aliases: [
"chatGuid",
"chatIdentifier",
"chatId"
] },
leaveGroup: { aliases: [
"chatGuid",
"chatIdentifier",
"chatId"
] }
},
extractToolSend: ({ args }) => extractToolSend(args, "sendMessage"),
handleAction: async ({ action, params, cfg, accountId, toolContext }) => {
const runtime = await loadIMessageActionsRuntime();
const account = resolveIMessageAccount({
cfg,
accountId: accountId ?? void 0
});
assertActionEnabled(action, account.config.actions);
const cliPathForProbe = account.config.cliPath?.trim() || "imsg";
let privateApiStatus = getCachedIMessagePrivateApiStatus(cliPathForProbe);
const assertPrivateApiEnabled = async () => {
if (privateApiStatus?.available !== true) privateApiStatus = await probeIMessagePrivateApi(cliPathForProbe, account.config.probeTimeoutMs ?? 1e4);
if (!privateApiStatus?.available) {
const reason = privateApiStatus?.statusMessage ? ` imsg reports: ${privateApiStatus.statusMessage}` : "";
log.warn(`iMessage ${action} blocked: private API bridge unavailable (accountId=${account.accountId}, cliPath=${cliPathForProbe}). Run \`imsg launch\` to re-inject the dylib, then \`openclaw channels status\` to refresh.${reason}`);
throw new Error(`iMessage ${action} requires the imsg private API bridge. Run imsg launch, then openclaw channels status to refresh capability detection.${reason}`);
}
};
const opts = {
cliPath: account.config.cliPath?.trim() || "imsg",
dbPath: account.config.dbPath?.trim() || void 0,
timeoutMs: account.config.probeTimeoutMs,
chatGuid: ""
};
const chatGuid = async () => await resolveChatGuid({
action,
actionParams: params,
currentChannelId: toolContext?.currentChannelId,
runtime,
options: opts
});
const messageId = (resolveOpts) => {
const chatContext = buildChatContextFromActionParams({
actionParams: params,
currentChannelId: toolContext?.currentChannelId
});
const fallbackContext = {
...chatContext,
accountId: account.accountId
};
return runtime.resolveIMessageMessageId(readMessageIdWithChatFallback(params, fallbackContext), {
requireKnownShortId: true,
chatContext,
...resolveOpts?.requireFromMe ? { requireFromMe: true } : {}
});
};
if (action === "react") {
await assertPrivateApiEnabled();
const { emoji, remove, isEmpty } = readReactionParams(params, { removeErrorMessage: "Emoji is required to remove an iMessage reaction." });
const reaction = mapTapbackReaction(emoji);
const TAPBACK_KINDS = [
"love",
"like",
"dislike",
"laugh",
"emphasize",
"question"
];
if (!remove && (isEmpty || !reaction)) throw new Error("iMessage react supports love, like, dislike, laugh, emphasize, and question tapbacks.");
const resolvedMessageId = messageId();
const partIndex = readNonNegativeIntegerParam(params, "partIndex");
const resolvedChatGuid = await chatGuid();
const reactionsToSend = remove && !reaction ? [...TAPBACK_KINDS] : reaction ? [reaction] : [];
for (const kind of reactionsToSend) await runtime.sendReaction({
chatGuid: resolvedChatGuid,
messageId: resolvedMessageId,
reaction: kind,
remove: remove || void 0,
partIndex: typeof partIndex === "number" ? partIndex : void 0,
options: {
...opts,
chatGuid: resolvedChatGuid
}
});
return jsonResult({
ok: true,
...remove ? { removed: true } : { added: reaction }
});
}
if (action === "edit") {
await assertPrivateApiEnabled();
const resolvedMessageId = messageId({ requireFromMe: true });
const text = readStringParam(params, "text") ?? readStringParam(params, "newText") ?? readStringParam(params, "message");
if (!text) throw new Error("iMessage edit requires text, newText, or message.");
const partIndex = readNonNegativeIntegerParam(params, "partIndex");
const backwardsCompatMessage = readStringParam(params, "backwardsCompatMessage");
const resolvedChatGuid = await chatGuid();
await runtime.editMessage({
chatGuid: resolvedChatGuid,
messageId: resolvedMessageId,
text,
backwardsCompatMessage: backwardsCompatMessage ?? void 0,
partIndex: typeof partIndex === "number" ? partIndex : void 0,
options: {
...opts,
chatGuid: resolvedChatGuid
}
});
return jsonResult({
ok: true,
edited: resolvedMessageId
});
}
if (action === "unsend") {
await assertPrivateApiEnabled();
const resolvedMessageId = messageId({ requireFromMe: true });
const partIndex = readNonNegativeIntegerParam(params, "partIndex");
const resolvedChatGuid = await chatGuid();
await runtime.unsendMessage({
chatGuid: resolvedChatGuid,
messageId: resolvedMessageId,
partIndex: typeof partIndex === "number" ? partIndex : void 0,
options: {
...opts,
chatGuid: resolvedChatGuid
}
});
return jsonResult({
ok: true,
unsent: resolvedMessageId
});
}
if (action === "reply") {
await assertPrivateApiEnabled();
const resolvedMessageId = messageId();
const text = readMessageText(params);
if (!text) throw new Error("iMessage reply requires text or message.");
const attachment = extractReplyAttachment(params);
if (attachment) {
if (attachment.spec === null) throw new Error(`iMessage reply rejected \`${attachment.bypassParam}\` because it did not pass through the outbound media resolver. Pass a base64 \`buffer\` + \`filename\` directly, or invoke message(action: "reply") through the runner so the resolver can validate the path against mediaLocalRoots/sandbox/size before sending.`);
if (privateApiStatus?.cliCapabilities?.sendRichSupportsAttachment !== true) throw new Error("iMessage reply with an attachment needs an imsg build that exposes `send-rich --file` (openclaw/imsg#114). Upgrade imsg, or use action 'upload-file' (with filePath/filename) or action 'send' (with media) to deliver the file plus a separate 'reply' for any text.");
}
const partIndex = readNonNegativeIntegerParam(params, "partIndex");
const resolvedChatGuid = await chatGuid();
const result = await runtime.sendRichMessage({
chatGuid: resolvedChatGuid,
text,
replyToMessageId: resolvedMessageId,
partIndex: typeof partIndex === "number" ? partIndex : void 0,
attachment: attachment?.spec ?? void 0,
options: {
...opts,
chatGuid: resolvedChatGuid
}
});
rememberOutboundBridgeMessage({
accountId: account.accountId,
messageId: result.messageId,
chatGuid: resolvedChatGuid
});
return jsonResult({
ok: true,
messageId: result.messageId,
repliedTo: resolvedMessageId
});
}
if (action === "sendWithEffect") {
await assertPrivateApiEnabled();
const text = readMessageText(params);
const effectId = effectIdFromParam(readStringParam(params, "effectId") ?? readStringParam(params, "effect"));
if (!text || !effectId) throw new Error("iMessage sendWithEffect requires text/message and effect/effectId.");
const resolvedChatGuid = await chatGuid();
const result = await runtime.sendRichMessage({
chatGuid: resolvedChatGuid,
text,
effectId,
options: {
...opts,
chatGuid: resolvedChatGuid
}
});
rememberOutboundBridgeMessage({
accountId: account.accountId,
messageId: result.messageId,
chatGuid: resolvedChatGuid
});
return jsonResult({
ok: true,
messageId: result.messageId,
effect: effectId
});
}
if (action === "renameGroup") {
await assertPrivateApiEnabled();
const displayName = readStringParam(params, "displayName") ?? readStringParam(params, "name");
if (!displayName) throw new Error("iMessage renameGroup requires displayName or name.");
const resolvedChatGuid = await chatGuid();
await runtime.renameGroup({
chatGuid: resolvedChatGuid,
displayName,
options: {
...opts,
chatGuid: resolvedChatGuid
}
});
return jsonResult({
ok: true,
renamed: resolvedChatGuid,
displayName
});
}
if (action === "setGroupIcon") {
await assertPrivateApiEnabled();
const filename = readStringParam(params, "filename") ?? readStringParam(params, "name") ?? "icon.png";
const resolvedChatGuid = await chatGuid();
await runtime.setGroupIcon({
chatGuid: resolvedChatGuid,
buffer: decodeBase64Buffer(params, action),
filename,
options: {
...opts,
chatGuid: resolvedChatGuid
}
});
return jsonResult({
ok: true,
chatGuid: resolvedChatGuid,
iconSet: true
});
}
if (action === "addParticipant" || action === "removeParticipant") {
await assertPrivateApiEnabled();
const address = readStringParam(params, "address") ?? readStringParam(params, "participant");
if (!address) throw new Error(`iMessage ${action} requires address or participant.`);
const resolvedChatGuid = await chatGuid();
if (action === "addParticipant") {
await runtime.addParticipant({
chatGuid: resolvedChatGuid,
address,
options: {
...opts,
chatGuid: resolvedChatGuid
}
});
return jsonResult({
ok: true,
added: address,
chatGuid: resolvedChatGuid
});
}
await runtime.removeParticipant({
chatGuid: resolvedChatGuid,
address,
options: {
...opts,
chatGuid: resolvedChatGuid
}
});
return jsonResult({
ok: true,
removed: address,
chatGuid: resolvedChatGuid
});
}
if (action === "leaveGroup") {
await assertPrivateApiEnabled();
const resolvedChatGuid = await chatGuid();
await runtime.leaveGroup({
chatGuid: resolvedChatGuid,
options: {
...opts,
chatGuid: resolvedChatGuid
}
});
return jsonResult({
ok: true,
left: resolvedChatGuid
});
}
if (action === "sendAttachment" || action === "upload-file") {
await assertPrivateApiEnabled();
const filename = readStringParam(params, "filename", { required: true });
const asVoice = readBooleanParam(params, "asVoice") ?? readBooleanParam(params, "as_voice");
const resolvedChatGuid = await chatGuid();
const result = await runtime.sendAttachment({
chatGuid: resolvedChatGuid,
buffer: decodeBase64Buffer(params, action),
filename,
asVoice: asVoice ?? void 0,
options: {
...opts,
chatGuid: resolvedChatGuid
}
});
rememberOutboundBridgeMessage({
accountId: account.accountId,
messageId: result.messageId,
chatGuid: resolvedChatGuid
});
return jsonResult({
ok: true,
messageId: result.messageId
});
}
throw new Error(`Action ${action} is not supported for provider ${providerId}.`);
}
};
//#endregion
//#region extensions/imessage/src/group-policy.ts
function resolveIMessageGroupRequireMention(params) {
return resolveChannelGroupRequireMention({
cfg: params.cfg,
channel: "imessage",
groupId: params.groupId,
accountId: params.accountId
});
}
function resolveIMessageGroupToolPolicy(params) {
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
});
}
//#endregion
export { resolveIMessageGroupToolPolicy as n, imessageMessageActions as r, resolveIMessageGroupRequireMention as t };