Files
openclaw/extensions/imessage/src/message-tool-api.ts
T
Peter Steinberger 71a33f07a9 fix(imessage): make SSH-backed sends and actions reliable (#121038)
* fix(imessage): harden remote Mac transport

Route SSH-backed iMessage actions through JSON-RPC, preserve remote database paths, and stage outbound files on the Messages Mac with bounded cleanup. Keep local action semantics intact while failing closed on ambiguous wrappers and surfacing the remaining imsg v0.13.4 limits.

* fix(imessage): remove test-only exports
2026-08-09 04:48:38 -07:00

153 lines
4.7 KiB
TypeScript

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