From c3e5d85ce1df7bd65c733053bf5e388f684bcc41 Mon Sep 17 00:00:00 2001 From: homer-byte Date: Wed, 13 May 2026 12:03:05 -0400 Subject: [PATCH] fix(imessage): avoid visible media placeholder text (#81209) Keep media-only iMessage sends from delivering visible text while preserving a non-visible echo key for self-echo dedupe. Thanks @homer-byte. --- CHANGELOG.md | 1 + .../imessage/src/monitor/deliver.test.ts | 5 +-- extensions/imessage/src/monitor/deliver.ts | 9 +++-- extensions/imessage/src/send.test.ts | 33 ++++++++++++++++++- extensions/imessage/src/send.ts | 15 +++++---- 5 files changed, 51 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index de3f9063f72..bd51fab2660 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- iMessage: stop sending visible `` placeholder text for media-only native image sends while preserving the internal echo key that prevents self-echo duplicate replies. (#81209) Thanks @homer-byte. - Agents/sessions: create configured agent main sessions before first `sessions_send` or gateway send, so agent-to-agent messages no longer fail when the target agent has not started yet. - gateway: pass Talk session scope to resolver [AI]. (#81379) Thanks @pgondhi987. - Gateway protocol: require v4 clients and stream explicit chat `deltaText`/`replace` frames so SDK clients can consume assistant updates without local diffing. (#80725) Thanks @samzong. diff --git a/extensions/imessage/src/monitor/deliver.test.ts b/extensions/imessage/src/monitor/deliver.test.ts index f8b20e5d5bb..eb654dda7f7 100644 --- a/extensions/imessage/src/monitor/deliver.test.ts +++ b/extensions/imessage/src/monitor/deliver.test.ts @@ -237,11 +237,12 @@ describe("deliverReplies", () => { }); }); - it("records the actual sent placeholder for media-only replies", async () => { + it("records the internal echo key for media-only replies", async () => { const remember = vi.fn(); sendMessageIMessageMock.mockResolvedValueOnce({ messageId: "imsg-media-1", - sentText: "", + sentText: "", + echoText: "", }); await deliverReplies({ diff --git a/extensions/imessage/src/monitor/deliver.ts b/extensions/imessage/src/monitor/deliver.ts index c5d23e9f608..de5629a984f 100644 --- a/extensions/imessage/src/monitor/deliver.ts +++ b/extensions/imessage/src/monitor/deliver.ts @@ -58,7 +58,10 @@ export async function deliverReplies(params: { // 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 }); + sentMessageCache?.remember(scope, { + text: sent.echoText ?? sent.sentText, + messageId: sent.messageId, + }); }, sendMedia: async ({ mediaUrl, caption }) => { const sent = await sendMessageIMessage(target, caption ?? "", { @@ -70,7 +73,7 @@ export async function deliverReplies(params: { replyToId: payload.replyToId, }); sentMessageCache?.remember(scope, { - text: sent.sentText || undefined, + text: sent.echoText ?? (sent.sentText || undefined), messageId: sent.messageId, }); }, @@ -94,7 +97,7 @@ export function createIMessageEchoCachingSend(params: { }); const scope = `${params.accountId ?? opts.accountId ?? ""}:${target}`; params.sentMessageCache?.remember(scope, { - text: sent.sentText || undefined, + text: sent.echoText ?? (sent.sentText || undefined), messageId: sent.messageId, }); return sent; diff --git a/extensions/imessage/src/send.test.ts b/extensions/imessage/src/send.test.ts index d341b14a125..03698cc2a49 100644 --- a/extensions/imessage/src/send.test.ts +++ b/extensions/imessage/src/send.test.ts @@ -31,6 +31,7 @@ describe("sendMessageIMessage receipts", () => { expect(result.messageId).toBe("p:0/imsg-1"); expect(result.sentText).toBe("hello"); + expect(result.echoText).toBe("hello"); expect(result.receipt.primaryPlatformMessageId).toBe("p:0/imsg-1"); expect(result.receipt.platformMessageIds).toEqual(["p:0/imsg-1"]); expect(result.receipt.replyToId).toBe("reply-1"); @@ -70,9 +71,19 @@ describe("sendMessageIMessage receipts", () => { }); expect(result.messageId).toBe("12345"); - expect(result.sentText).toBe(""); + expect(result.sentText).toBe(""); + expect(result.echoText).toBe(""); expect(result.receipt.primaryPlatformMessageId).toBe("12345"); expect(result.receipt.platformMessageIds).toEqual(["12345"]); + expect(client.request).toHaveBeenCalledWith( + "send", + expect.objectContaining({ + chat_guid: "chat-1", + file: "/tmp/image.png", + text: "", + }), + expect.any(Object), + ); expect(result.receipt.raw).toEqual([ { channel: "imessage", @@ -97,6 +108,26 @@ describe("sendMessageIMessage receipts", () => { expect(result.receipt.sentAt).toBeGreaterThan(0); }); + it("preserves literal media placeholder text when no attachment is sent", async () => { + const client = createClient({ guid: "p:0/imsg-text" }); + + const result = await sendMessageIMessage("chat_id:42", "literal text", { + config: IMESSAGE_TEST_CFG, + client, + }); + + expect(result.sentText).toBe("literal text"); + expect(result.echoText).toBe("literal text"); + expect(client.request).toHaveBeenCalledWith( + "send", + expect.objectContaining({ + chat_id: 42, + text: "literal text", + }), + expect.any(Object), + ); + }); + it("does not treat compatibility ok responses as visible platform ids", async () => { const client = createClient({ ok: "true" }); diff --git a/extensions/imessage/src/send.ts b/extensions/imessage/src/send.ts index 2348e8dce53..fd178bd6ebe 100644 --- a/extensions/imessage/src/send.ts +++ b/extensions/imessage/src/send.ts @@ -6,8 +6,7 @@ import { } from "openclaw/plugin-sdk/channel-message"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime"; -import { kindFromMime } from "openclaw/plugin-sdk/media-runtime"; -import { resolveOutboundAttachmentFromUrl } from "openclaw/plugin-sdk/media-runtime"; +import { kindFromMime, resolveOutboundAttachmentFromUrl } from "openclaw/plugin-sdk/media-runtime"; import { requireRuntimeConfig } from "openclaw/plugin-sdk/plugin-config-runtime"; import { convertMarkdownTables } from "openclaw/plugin-sdk/text-chunking"; import { stripInlineDirectiveTagsForDelivery } from "openclaw/plugin-sdk/text-chunking"; @@ -48,6 +47,7 @@ type IMessageSendOpts = { type IMessageSendResult = { messageId: string; sentText: string; + echoText?: string; receipt: MessageReceipt; }; @@ -94,13 +94,13 @@ function resolveMessageId(result: Record | null | undefined): s return raw ? raw.trim() : null; } -function resolveDeliveredIMessageText(text: string, mediaContentType?: string): string { +function resolveOutboundEchoText(text: string, mediaContentType?: string): string | undefined { if (text.trim()) { return text; } const kind = kindFromMime(mediaContentType ?? undefined); if (!kind) { - return text; + return undefined; } return kind === "image" ? "" : ``; } @@ -187,6 +187,7 @@ export async function sendMessageIMessage( : 16 * 1024 * 1024; let message = text ?? ""; let filePath: string | undefined; + let mediaContentType: string | undefined; if (opts.mediaUrl?.trim()) { const resolveAttachmentFn = opts.resolveAttachmentImpl ?? resolveOutboundAttachmentFromUrl; @@ -195,7 +196,7 @@ export async function sendMessageIMessage( readFile: opts.mediaReadFile, }); filePath = resolved.path; - message = resolveDeliveredIMessageText(message, resolved.contentType ?? undefined); + mediaContentType = resolved.contentType ?? undefined; } if (!message.trim() && !filePath) { @@ -224,6 +225,7 @@ export async function sendMessageIMessage( if (!message.trim() && !filePath) { throw new Error("iMessage send requires text or media"); } + const echoText = resolveOutboundEchoText(message, filePath ? mediaContentType : undefined); const resolvedReplyToId = sanitizeReplyToId(opts.replyToId); const params: Record = { text: message, @@ -266,7 +268,7 @@ export async function sendMessageIMessage( if (echoScope) { rememberPersistedIMessageEcho({ scope: echoScope, - text: message, + text: echoText, messageId: resolvedId ?? undefined, }); } @@ -293,6 +295,7 @@ export async function sendMessageIMessage( return { messageId, sentText: message, + ...(echoText ? { echoText } : {}), receipt: createIMessageSendReceipt({ messageId, target,