Files
openclaw/extensions/imessage/src/actions.runtime.ts
Peter Steinberger db02a96c4c refactor(process): route bounded commands through Execa (#106495)
* refactor(process): centralize bounded command execution

* refactor(process): migrate core one-shot commands

* refactor(plugins): migrate one-shot commands

* fix(process): await Windows tree termination

* chore(plugin-sdk): refresh process runtime surface

* refactor(process): migrate remaining bounded commands

* refactor(process): normalize command result handling

* refactor(process): split execution responsibilities

* chore(plugin-sdk): refresh API baseline

* chore(process): remove release-owned changelog entry

* fix(process): narrow binary command input checks

* fix(process): cap sandbox command output

* fix(qa-lab): preserve exact node probe env

* chore(ci): refresh dead export baseline

* fix(process): preserve force-kill command deadlines

* fix(process): avoid post-exit timeout reclassification

* test(process): update scp staging wrapper mock

* test(process): update remaining wrapper mocks

* refactor(qa-lab): preserve Execa tar execution
2026-07-13 11:07:35 -07:00

482 lines
15 KiB
TypeScript

// Imessage plugin module implements actions behavior.
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { extname, join } from "node:path";
import {
asDateTimestampMs,
parseStrictInteger,
resolveExpiresAtMsFromDurationMs,
} from "openclaw/plugin-sdk/number-runtime";
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
import { runIMessageCliJsonCommand } from "./cli-output.js";
import { createIMessageRpcClient } from "./client.js";
import { extractMarkdownFormatRuns } from "./markdown-format.js";
import {
normalizeDirectChatIdentifier,
resolveIMessageMessageId as resolveIMessageMessageIdImpl,
} from "./monitor-reply-cache.js";
import type { IMessageTarget } from "./targets.js";
type CliRunOptions = {
cliPath: string;
dbPath?: string;
timeoutMs?: number;
};
type IMessageBridgeActionOptions = CliRunOptions & {
chatGuid: string;
};
type IMessageBridgeSendResult = {
messageId: string;
};
type TempFileInput = {
buffer: Uint8Array;
filename: string;
};
type IMessageChatListResponse = {
chats?: unknown;
};
function asChatList(value: unknown): Array<Record<string, unknown>> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return [];
}
const chats = (value as IMessageChatListResponse).chats;
if (!Array.isArray(chats)) {
return [];
}
return chats.filter(
(chat): chat is Record<string, unknown> =>
chat != null && typeof chat === "object" && !Array.isArray(chat),
);
}
function numberFromUnknown(value: unknown): number | undefined {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
return parseStrictInteger(value);
}
function stringFromUnknown(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
// 30s TTL on the chats.list cache, keyed by cliPath+dbPath. Long enough to
// absorb a burst of agent actions; short enough that a freshly-created
// chat shows up without restarting the gateway.
const CHAT_LIST_CACHE_TTL_MS = 30 * 1000;
type ChatListCacheEntry = {
list: ReadonlyArray<Record<string, unknown>>;
expiresAt: number;
};
const chatListCache = new Map<string, ChatListCacheEntry>();
function chatListCacheKey(cliPath: string, dbPath?: string): string {
return `${cliPath}\0${dbPath ?? ""}`;
}
function chatListCacheGet(
cliPath: string,
dbPath?: string,
): ReadonlyArray<Record<string, unknown>> | null {
const key = chatListCacheKey(cliPath, dbPath);
const entry = chatListCache.get(key);
if (!entry) {
return null;
}
const now = asDateTimestampMs(Date.now());
if (now === undefined || entry.expiresAt <= now) {
chatListCache.delete(key);
return null;
}
return entry.list;
}
function chatListCacheSet(
cliPath: string,
dbPath: string | undefined,
list: ReadonlyArray<Record<string, unknown>>,
): void {
const expiresAt = resolveExpiresAtMsFromDurationMs(CHAT_LIST_CACHE_TTL_MS);
if (expiresAt === undefined) {
return;
}
chatListCache.set(chatListCacheKey(cliPath, dbPath), {
list,
expiresAt,
});
}
/**
* Strip the iMessage;-;/SMS;-;/any;-; service prefix that Messages uses
* for direct DM chats. Different layers report direct DMs in different
* forms — the action surface synthesizes `iMessage;-;<phone>` from a
* handle target, while imsg's chats.list returns `identifier: <phone>`
* and `guid: any;-;<phone>`. Comparing the raw strings would falsely
* miss the match.
*/
export function normalizeDirectChatIdentifierForTest(raw: string): string {
return normalizeDirectChatIdentifier(raw);
}
export function findChatGuidForTest(
chats: readonly Record<string, unknown>[],
target: Extract<IMessageTarget, { kind: "chat_id" | "chat_identifier" }>,
): string | null {
return findChatGuid(chats, target);
}
function findChatGuid(
chats: readonly Record<string, unknown>[],
target: Extract<IMessageTarget, { kind: "chat_id" | "chat_identifier" }>,
): string | null {
if (target.kind === "chat_id") {
for (const chat of chats) {
const id = numberFromUnknown(chat.id);
const guid = stringFromUnknown(chat.guid);
if (id === target.chatId && guid) {
return guid;
}
}
return null;
}
// target.kind === "chat_identifier"
const wanted = normalizeDirectChatIdentifier(target.chatIdentifier);
for (const chat of chats) {
const identifier = stringFromUnknown(chat.identifier);
const guid = stringFromUnknown(chat.guid);
if (!guid) {
continue;
}
if (
identifier === target.chatIdentifier ||
guid === target.chatIdentifier ||
(identifier && normalizeDirectChatIdentifier(identifier) === wanted) ||
normalizeDirectChatIdentifier(guid) === wanted
) {
return guid;
}
}
return null;
}
async function runIMessageCliJson(
args: readonly string[],
options: CliRunOptions,
): Promise<Record<string, unknown>> {
return await runIMessageCliJsonCommand({
args,
cliPath: options.cliPath,
dbPath: options.dbPath,
timeoutMs: options.timeoutMs,
});
}
function resolveMessageId(result: Record<string, unknown>): string {
const raw =
(typeof result.messageGuid === "string" && result.messageGuid.trim()) ||
(typeof result.messageId === "string" && result.messageId.trim()) ||
(typeof result.guid === "string" && result.guid.trim()) ||
(typeof result.id === "string" && result.id.trim());
return raw || "ok";
}
async function withTempFile<T>(input: TempFileInput, fn: (path: string) => Promise<T>): Promise<T> {
const dir = await mkdtemp(join(resolvePreferredOpenClawTmpDir(), "openclaw-imessage-"));
const safeExt = extname(input.filename).slice(0, 16) || ".bin";
const filePath = join(dir, `upload${safeExt}`);
try {
await writeFile(filePath, input.buffer);
return await fn(filePath);
} finally {
await rm(dir, { recursive: true, force: true });
}
}
export const imessageActionsRuntime = {
resolveIMessageMessageId: resolveIMessageMessageIdImpl,
async resolveChatGuidForTarget(params: {
target: Extract<IMessageTarget, { kind: "chat_id" | "chat_identifier" }>;
options: CliRunOptions;
}): Promise<string | null> {
// Each `chats.list` call spawns a fresh imsg rpc subprocess and pulls
// every chat the account knows about. Bursts of agent actions (react
// then reply, reply then add-participant, etc.) all paid that cost
// until we cached the chats list per cliPath+dbPath for ~30 seconds.
const cached = chatListCacheGet(params.options.cliPath, params.options.dbPath);
if (cached) {
return findChatGuid(cached, params.target);
}
const client = await createIMessageRpcClient({
cliPath: params.options.cliPath,
dbPath: params.options.dbPath,
});
try {
const result = await client.request<IMessageChatListResponse>(
"chats.list",
{ limit: 1000 },
{ timeoutMs: params.options.timeoutMs },
);
const list = asChatList(result);
chatListCacheSet(params.options.cliPath, params.options.dbPath, list);
return findChatGuid(list, params.target);
} finally {
await client.stop();
}
},
async sendReaction(params: {
chatGuid: string;
messageId: string;
reaction: string;
remove?: boolean;
partIndex?: number;
options: IMessageBridgeActionOptions;
}) {
await runIMessageCliJson(
[
"tapback",
"--chat",
params.chatGuid,
"--message",
params.messageId,
"--kind",
params.reaction,
"--part",
String(params.partIndex ?? 0),
...(params.remove ? ["--remove"] : []),
],
params.options,
);
},
async editMessage(params: {
chatGuid: string;
messageId: string;
text: string;
backwardsCompatMessage?: string;
partIndex?: number;
options: IMessageBridgeActionOptions;
}) {
await runIMessageCliJson(
[
"edit",
"--chat",
params.chatGuid,
"--message",
params.messageId,
"--new-text",
params.text,
"--bc-text",
params.backwardsCompatMessage ?? params.text,
"--part",
String(params.partIndex ?? 0),
],
params.options,
);
},
async unsendMessage(params: {
chatGuid: string;
messageId: string;
partIndex?: number;
options: IMessageBridgeActionOptions;
}) {
await runIMessageCliJson(
[
"unsend",
"--chat",
params.chatGuid,
"--message",
params.messageId,
"--part",
String(params.partIndex ?? 0),
],
params.options,
);
},
async sendRichMessage(params: {
chatGuid: string;
text: string;
effectId?: string;
replyToMessageId?: string;
partIndex?: number;
// Optional attachment as an in-memory buffer that we stage to a temp
// file before invoking imsg. The buffer must already have been loaded
// by the outbound media resolver (mediaLocalRoots/sandbox/size limits)
// — this runtime intentionally does not accept a raw filesystem path,
// because that would let an attacker-controlled path bypass the
// resolver and let imsg send any host-readable file. Requires an imsg
// build that accepts `send-rich --file` (openclaw/imsg#114); callers
// must feature-detect via the cached private-api status first.
attachment?: { kind: "buffer"; buffer: Uint8Array; filename: string };
options: IMessageBridgeActionOptions;
}): Promise<IMessageBridgeSendResult> {
// Extract markdown bold/italic/underline/strikethrough into typed-run
// ranges so the recipient sees actual styling rather than literal
// asterisks. This mirrors the same extraction the rpc-send path does;
// any caller that hits the bridge via `imsg send-rich` benefits without
// needing to pre-format the text themselves.
const formatted = extractMarkdownFormatRuns(params.text);
const buildArgs = (filePath?: string): string[] => [
"send-rich",
"--chat",
params.chatGuid,
"--text",
formatted.text,
"--part",
String(params.partIndex ?? 0),
...(params.effectId ? ["--effect", params.effectId] : []),
...(params.replyToMessageId ? ["--reply-to", params.replyToMessageId] : []),
...(formatted.ranges.length > 0 ? ["--format", JSON.stringify(formatted.ranges)] : []),
...(filePath ? ["--file", filePath] : []),
];
if (params.attachment) {
return await withTempFile(
{ buffer: params.attachment.buffer, filename: params.attachment.filename },
async (filePath) => {
const result = await runIMessageCliJson(buildArgs(filePath), params.options);
return { messageId: resolveMessageId(result) };
},
);
}
const result = await runIMessageCliJson(buildArgs(), params.options);
return { messageId: resolveMessageId(result) };
},
async renameGroup(params: {
chatGuid: string;
displayName: string;
options: IMessageBridgeActionOptions;
}) {
await runIMessageCliJson(
["chat-name", "--chat", params.chatGuid, "--name", params.displayName],
params.options,
);
},
async setGroupIcon(params: {
chatGuid: string;
buffer: Uint8Array;
filename: string;
options: IMessageBridgeActionOptions;
}) {
await withTempFile({ buffer: params.buffer, filename: params.filename }, async (filePath) => {
await runIMessageCliJson(
["chat-photo", "--chat", params.chatGuid, "--file", filePath],
params.options,
);
});
},
async addParticipant(params: {
chatGuid: string;
address: string;
options: IMessageBridgeActionOptions;
}) {
await runIMessageCliJson(
["chat-add-member", "--chat", params.chatGuid, "--address", params.address],
params.options,
);
},
async removeParticipant(params: {
chatGuid: string;
address: string;
options: IMessageBridgeActionOptions;
}) {
await runIMessageCliJson(
["chat-remove-member", "--chat", params.chatGuid, "--address", params.address],
params.options,
);
},
async leaveGroup(params: { chatGuid: string; options: IMessageBridgeActionOptions }) {
await runIMessageCliJson(["chat-leave", "--chat", params.chatGuid], params.options);
},
async sendPoll(params: {
chatGuid: string;
question: string;
// Pre-validated, trimmed choices (>=2). Named `choices` so it does not
// shadow `options` (the CLI run options) on this params bag.
choices: readonly string[];
replyToMessageId?: string;
options: IMessageBridgeActionOptions;
}): Promise<IMessageBridgeSendResult> {
const result = await runIMessageCliJson(
[
"poll",
"send",
"--chat",
params.chatGuid,
"--question",
params.question,
...params.choices.flatMap((choice) => ["--option", choice]),
...(params.replyToMessageId ? ["--reply-to", params.replyToMessageId] : []),
],
params.options,
);
return { messageId: resolveMessageId(result) };
},
async sendPollVote(params: {
chatGuid: string;
pollGuid: string;
// Exactly one selector; the CLI resolves index/text to the option UUID.
optionIndex?: number;
optionId?: string;
optionText?: string;
options: IMessageBridgeActionOptions;
}): Promise<IMessageBridgeSendResult & { optionText?: string }> {
const selector = params.optionId
? ["--option-id", params.optionId]
: params.optionIndex !== undefined
? ["--option-index", String(params.optionIndex)]
: params.optionText
? ["--option", params.optionText]
: [];
const result = await runIMessageCliJson(
["poll", "vote", "--chat", params.chatGuid, "--poll", params.pollGuid, ...selector],
params.options,
);
const optionText = typeof result.optionText === "string" ? result.optionText.trim() : "";
return { messageId: resolveMessageId(result), ...(optionText ? { optionText } : {}) };
},
async sendAttachment(params: {
chatGuid: string;
buffer: Uint8Array;
filename: string;
asVoice?: boolean;
options: IMessageBridgeActionOptions;
}): Promise<IMessageBridgeSendResult> {
return await withTempFile(
{ buffer: params.buffer, filename: params.filename },
async (filePath) => {
const result = await runIMessageCliJson(
[
"send-attachment",
"--chat",
params.chatGuid,
"--file",
filePath,
...(params.asVoice ? ["--audio"] : []),
],
params.options,
);
return { messageId: resolveMessageId(result) };
},
);
},
};
export type IMessageActionsRuntime = typeof imessageActionsRuntime;