Skip to content

Commit fe79d85

Browse files
authored
feat(imessage): add native imsg message actions
Adds native iMessage private-API message actions, lightweight message-tool discovery, bridge capability cache sharing, execution-time action gates, target alias coverage, and regression tests.
1 parent 1819e41 commit fe79d85

22 files changed

Lines changed: 499 additions & 200 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai
1919
- Codex app-server: default implicit local stdio app-server permissions to guardian when Codex system requirements disallow the YOLO approval, reviewer, or sandbox value, including hostname-scoped remote sandbox entries, avoiding turn-start failures on managed hosts that permit only reviewed approval or narrower sandboxes.
2020
- Discord/voice: stream ElevenLabs TTS directly into Discord playback and send ElevenLabs latency optimization as the documented query parameter so spoken replies can start sooner.
2121
- Discord/voice: keep TTS playback running when another user starts speaking, ignore new capture during playback to avoid feedback loops, and downgrade expected receive-stream aborts to verbose diagnostics.
22+
- iMessage: expose native private-API message actions through `imsg rpc` for reactions, edits, unsends, replies, rich sends, attachments, and group management when `imsg status --json` reports the required bridge capabilities.
2223
- Telegram: treat successful same-chat `message` tool outbound sends during an inbound telegram turn as delivered when deciding whether to emit the rewritten silent reply fallback (#78685). Thanks @neeravmakwana.
2324
- Gateway/tasks: reconcile stale CLI run-context tasks whose live run context disappeared even when a child session row remains, and apply the default bounded reload deferral timeout to channel hot reloads so stale task records cannot block Discord/Slack/Telegram reloads forever.
2425
- Gateway/sessions: keep session-store index writes atomic while skipping durable fsync inside the writer lock, reducing cron and channel-turn starvation on slow filesystems and addressing the session-store strand of #73655. Thanks @mmartoccia.

extensions/imessage/api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,4 @@ export {
5555
parseIMessageAllowTarget,
5656
parseIMessageTarget,
5757
} from "./src/targets.js";
58+
export { IMESSAGE_ACTION_NAMES, IMESSAGE_ACTIONS } from "./src/actions-contract.js";
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { describeIMessageMessageTool as describeMessageTool } from "./src/message-tool-api.js";

extensions/imessage/runtime-api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export type { MonitorIMessageOpts } from "./src/monitor.js";
2828
export { probeIMessage } from "./src/probe.js";
2929
export type { IMessageProbe } from "./src/probe.js";
3030
export { sendMessageIMessage } from "./src/send.js";
31+
export { imessageMessageActions } from "./src/actions.js";
3132
export { setIMessageRuntime } from "./src/runtime.js";
3233
export { chunkTextForOutbound } from "./src/channel-api.js";
3334
export type IMessageAccountConfig = Omit<

extensions/imessage/src/actions.test.ts

Lines changed: 58 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ vi.mock("./probe.js", () => ({
1717
getCachedIMessagePrivateApiStatus: probeMock.getCachedIMessagePrivateApiStatus,
1818
}));
1919

20+
vi.mock("./private-api-status.js", () => ({
21+
getCachedIMessagePrivateApiStatus: probeMock.getCachedIMessagePrivateApiStatus,
22+
}));
23+
2024
vi.mock("./actions.runtime.js", () => ({
2125
imessageActionsRuntime: runtimeMock,
2226
}));
@@ -126,6 +130,28 @@ describe("imessage message actions", () => {
126130
expect(described?.actions).toContain("edit");
127131
});
128132

133+
it("rejects configured-off actions at execution time", async () => {
134+
probeMock.getCachedIMessagePrivateApiStatus.mockReturnValue({
135+
available: true,
136+
v2Ready: true,
137+
selectors: {},
138+
});
139+
140+
await expect(
141+
imessageMessageActions.handleAction?.({
142+
action: "react",
143+
cfg: cfg({ reactions: false }),
144+
params: {
145+
chatGuid: "iMessage;+;chat0000",
146+
messageId: "message-guid",
147+
emoji: "👍",
148+
},
149+
} as never),
150+
).rejects.toThrow(/disabled in config/i);
151+
152+
expect(runtimeMock.sendReaction).not.toHaveBeenCalled();
153+
});
154+
129155
it("maps message tool reactions to imsg tapback kinds", async () => {
130156
probeMock.getCachedIMessagePrivateApiStatus.mockReturnValue({
131157
available: true,
@@ -506,30 +532,38 @@ describe("imessage message actions", () => {
506532
});
507533
});
508534

509-
it("routes upload-file through the private API attachment bridge", async () => {
510-
probeMock.getCachedIMessagePrivateApiStatus.mockReturnValue({
511-
available: true,
512-
v2Ready: true,
513-
selectors: {},
514-
});
515-
runtimeMock.sendAttachment.mockResolvedValue({ messageId: "sent-guid" });
535+
it.each([
536+
["asVoice", { asVoice: true }],
537+
["as_voice", { as_voice: true }],
538+
])(
539+
"routes upload-file through the private API attachment bridge with %s",
540+
async (_label, voiceParam) => {
541+
probeMock.getCachedIMessagePrivateApiStatus.mockReturnValue({
542+
available: true,
543+
v2Ready: true,
544+
selectors: {},
545+
});
546+
runtimeMock.sendAttachment.mockResolvedValue({ messageId: "sent-guid" });
516547

517-
const result = await imessageMessageActions.handleAction?.({
518-
action: "upload-file",
519-
cfg: cfg(),
520-
params: {
521-
chatGuid: "iMessage;+;chat0000",
522-
filename: "photo.jpg",
523-
buffer: Buffer.from("image").toString("base64"),
524-
},
525-
} as never);
548+
const result = await imessageMessageActions.handleAction?.({
549+
action: "upload-file",
550+
cfg: cfg(),
551+
params: {
552+
chatGuid: "iMessage;+;chat0000",
553+
filename: "photo.jpg",
554+
buffer: Buffer.from("image").toString("base64"),
555+
...voiceParam,
556+
},
557+
} as never);
526558

527-
expect(runtimeMock.sendAttachment).toHaveBeenCalledWith(
528-
expect.objectContaining({
529-
chatGuid: "iMessage;+;chat0000",
530-
filename: "photo.jpg",
531-
}),
532-
);
533-
expect(result?.details).toEqual({ ok: true, messageId: "sent-guid" });
534-
});
559+
expect(runtimeMock.sendAttachment).toHaveBeenCalledWith(
560+
expect.objectContaining({
561+
chatGuid: "iMessage;+;chat0000",
562+
filename: "photo.jpg",
563+
asVoice: true,
564+
}),
565+
);
566+
expect(result?.details).toEqual({ ok: true, messageId: "sent-guid" });
567+
},
568+
);
535569
});

extensions/imessage/src/actions.ts

Lines changed: 30 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,10 @@ import { extractToolSend } from "openclaw/plugin-sdk/tool-send";
1616
import { resolveIMessageAccount } from "./accounts.js";
1717
import { IMESSAGE_ACTION_NAMES, IMESSAGE_ACTIONS } from "./actions-contract.js";
1818
import { DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS } from "./constants.js";
19+
import { describeIMessageMessageTool } from "./message-tool-api.js";
1920
import { findLatestIMessageEntryForChat, type IMessageChatContext } from "./monitor-reply-cache.js";
2021
import { getCachedIMessagePrivateApiStatus } from "./probe.js";
21-
import {
22-
inferIMessageTargetChatType,
23-
parseIMessageTarget,
24-
type IMessageTarget,
25-
} from "./targets.js";
22+
import { parseIMessageTarget, type IMessageTarget } from "./targets.js";
2623

2724
const loadIMessageActionsRuntime = createLazyRuntimeNamedExport(
2825
() => import("./actions.runtime.js"),
@@ -35,20 +32,6 @@ const SUPPORTED_ACTIONS = new Set<ChannelMessageActionName>([
3532
...IMESSAGE_ACTION_NAMES,
3633
"upload-file",
3734
]);
38-
const PRIVATE_API_ACTIONS = new Set<ChannelMessageActionName>([
39-
"react",
40-
"edit",
41-
"unsend",
42-
"reply",
43-
"sendWithEffect",
44-
"renameGroup",
45-
"setGroupIcon",
46-
"addParticipant",
47-
"removeParticipant",
48-
"leaveGroup",
49-
"sendAttachment",
50-
]);
51-
5235
function readMessageText(params: Record<string, unknown>): string | undefined {
5336
return readStringParam(params, "text") ?? readStringParam(params, "message");
5437
}
@@ -78,16 +61,6 @@ function readMessageIdWithChatFallback(
7861
return readStringParam(params, "messageId", { required: true });
7962
}
8063

81-
function isGroupTarget(raw?: string | null): boolean {
82-
// Defer to the canonical target classifier so action gating and the
83-
// routing layer can't drift apart on edge cases (URI-encoded targets,
84-
// service prefixes, etc.).
85-
if (!raw) {
86-
return false;
87-
}
88-
return inferIMessageTargetChatType(raw) === "group";
89-
}
90-
9164
type IMessageActionsRuntime = Awaited<ReturnType<typeof loadIMessageActionsRuntime>>;
9265

9366
async function resolveChatGuid(params: {
@@ -329,58 +302,42 @@ function effectIdFromParam(raw?: string): string | undefined {
329302
);
330303
}
331304

305+
function assertActionEnabled(
306+
action: ChannelMessageActionName,
307+
actionsConfig: Record<string, boolean | undefined> | undefined,
308+
): void {
309+
const canonicalAction = action === "upload-file" ? "sendAttachment" : action;
310+
const spec = IMESSAGE_ACTIONS[canonicalAction as keyof typeof IMESSAGE_ACTIONS];
311+
if (!spec?.gate || !createActionGate(actionsConfig)(spec.gate)) {
312+
throw new Error(`iMessage ${action} is disabled in config.`);
313+
}
314+
}
315+
332316
export const imessageMessageActions: ChannelMessageActionAdapter = {
333-
describeMessageTool: ({ cfg, accountId, currentChannelId }) => {
334-
const account = resolveIMessageAccount({ cfg, accountId });
335-
if (!account.enabled || !account.configured) {
336-
return null;
337-
}
338-
const privateApiStatus = getCachedIMessagePrivateApiStatus(
339-
account.config.cliPath?.trim() || "imsg",
340-
);
341-
const gate = createActionGate(account.config.actions);
342-
const actions = new Set<ChannelMessageActionName>();
343-
for (const action of IMESSAGE_ACTION_NAMES) {
344-
const spec = IMESSAGE_ACTIONS[action];
345-
if (!spec?.gate || !gate(spec.gate)) {
346-
continue;
347-
}
348-
if (privateApiStatus?.available === false && PRIVATE_API_ACTIONS.has(action)) {
349-
continue;
350-
}
351-
if (
352-
action === "edit" &&
353-
privateApiStatus?.selectors &&
354-
!privateApiStatus.selectors.editMessage &&
355-
!privateApiStatus.selectors.editMessageItem
356-
) {
357-
continue;
358-
}
359-
if (action === "unsend" && privateApiStatus?.selectors?.retractMessagePart !== true) {
360-
continue;
361-
}
362-
actions.add(action);
363-
}
364-
if (!isGroupTarget(currentChannelId)) {
365-
for (const action of IMESSAGE_ACTION_NAMES) {
366-
if ("groupOnly" in IMESSAGE_ACTIONS[action] && IMESSAGE_ACTIONS[action].groupOnly) {
367-
actions.delete(action);
368-
}
369-
}
370-
}
371-
if (actions.delete("sendAttachment")) {
372-
actions.add("upload-file");
373-
}
374-
return { actions: Array.from(actions) };
375-
},
317+
describeMessageTool: describeIMessageMessageTool,
376318
supportsAction: ({ action }) => SUPPORTED_ACTIONS.has(action),
319+
messageActionTargetAliases: {
320+
react: { aliases: ["chatGuid", "chatIdentifier", "chatId"] },
321+
edit: { aliases: ["chatGuid", "chatIdentifier", "chatId", "messageId"] },
322+
unsend: { aliases: ["chatGuid", "chatIdentifier", "chatId", "messageId"] },
323+
reply: { aliases: ["chatGuid", "chatIdentifier", "chatId", "messageId"] },
324+
sendWithEffect: { aliases: ["chatGuid", "chatIdentifier", "chatId"] },
325+
sendAttachment: { aliases: ["chatGuid", "chatIdentifier", "chatId"] },
326+
"upload-file": { aliases: ["chatGuid", "chatIdentifier", "chatId"] },
327+
renameGroup: { aliases: ["chatGuid", "chatIdentifier", "chatId"] },
328+
setGroupIcon: { aliases: ["chatGuid", "chatIdentifier", "chatId"] },
329+
addParticipant: { aliases: ["chatGuid", "chatIdentifier", "chatId"] },
330+
removeParticipant: { aliases: ["chatGuid", "chatIdentifier", "chatId"] },
331+
leaveGroup: { aliases: ["chatGuid", "chatIdentifier", "chatId"] },
332+
},
377333
extractToolSend: ({ args }) => extractToolSend(args, "sendMessage"),
378334
handleAction: async ({ action, params, cfg, accountId, toolContext }) => {
379335
const runtime = await loadIMessageActionsRuntime();
380336
const account = resolveIMessageAccount({
381337
cfg,
382338
accountId: accountId ?? undefined,
383339
});
340+
assertActionEnabled(action, account.config.actions);
384341
const cliPathForProbe = account.config.cliPath?.trim() || "imsg";
385342
let privateApiStatus = getCachedIMessagePrivateApiStatus(cliPathForProbe);
386343
const assertPrivateApiEnabled = async () => {
@@ -607,7 +564,7 @@ export const imessageMessageActions: ChannelMessageActionAdapter = {
607564
if (action === "sendAttachment" || action === "upload-file") {
608565
await assertPrivateApiEnabled();
609566
const filename = readStringParam(params, "filename", { required: true });
610-
const asVoice = readBooleanParam(params, "asVoice");
567+
const asVoice = readBooleanParam(params, "asVoice") ?? readBooleanParam(params, "as_voice");
611568
const resolvedChatGuid = await chatGuid();
612569
const result = await runtime.sendAttachment({
613570
chatGuid: resolvedChatGuid,
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { beforeEach, describe, expect, it } from "vitest";
2+
import { describeMessageTool } from "../message-tool-api.js";
3+
import {
4+
clearCachedIMessagePrivateApiStatus,
5+
setCachedIMessagePrivateApiStatus,
6+
} from "./private-api-status.js";
7+
8+
describe("iMessage message-tool artifact", () => {
9+
beforeEach(() => {
10+
clearCachedIMessagePrivateApiStatus();
11+
});
12+
13+
it("exposes lightweight discovery without loading the channel plugin", () => {
14+
setCachedIMessagePrivateApiStatus("imsg", {
15+
available: true,
16+
v2Ready: true,
17+
selectors: {
18+
editMessage: true,
19+
retractMessagePart: true,
20+
},
21+
rpcMethods: [],
22+
});
23+
24+
const discovery = describeMessageTool({
25+
cfg: {
26+
channels: {
27+
imessage: {
28+
cliPath: "imsg",
29+
actions: {
30+
edit: false,
31+
},
32+
},
33+
},
34+
} as never,
35+
currentChannelId: "chat_id:1",
36+
});
37+
38+
expect(discovery?.actions).toEqual(
39+
expect.arrayContaining(["react", "reply", "sendWithEffect", "upload-file"]),
40+
);
41+
expect(discovery?.actions).not.toContain("edit");
42+
expect(discovery?.actions).not.toContain("sendAttachment");
43+
});
44+
45+
it("hides private actions when cached bridge status is unavailable", () => {
46+
setCachedIMessagePrivateApiStatus("imsg", {
47+
available: false,
48+
v2Ready: false,
49+
selectors: {},
50+
rpcMethods: [],
51+
});
52+
53+
const discovery = describeMessageTool({
54+
cfg: {
55+
channels: {
56+
imessage: {
57+
cliPath: "imsg",
58+
},
59+
},
60+
} as never,
61+
currentChannelId: "chat_id:1",
62+
});
63+
64+
expect(discovery?.actions).toEqual([]);
65+
});
66+
});

0 commit comments

Comments
 (0)