Skip to content

Commit c3e5d85

Browse files
authored
fix(imessage): avoid visible media placeholder text (#81209)
Keep media-only iMessage sends from delivering visible <media:image> text while preserving a non-visible echo key for self-echo dedupe. Thanks @homer-byte.
1 parent ddd79e5 commit c3e5d85

5 files changed

Lines changed: 51 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
66

77
### Fixes
88

9+
- iMessage: stop sending visible `<media:image>` placeholder text for media-only native image sends while preserving the internal echo key that prevents self-echo duplicate replies. (#81209) Thanks @homer-byte.
910
- 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.
1011
- gateway: pass Talk session scope to resolver [AI]. (#81379) Thanks @pgondhi987.
1112
- 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.

extensions/imessage/src/monitor/deliver.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -237,11 +237,12 @@ describe("deliverReplies", () => {
237237
});
238238
});
239239

240-
it("records the actual sent placeholder for media-only replies", async () => {
240+
it("records the internal echo key for media-only replies", async () => {
241241
const remember = vi.fn();
242242
sendMessageIMessageMock.mockResolvedValueOnce({
243243
messageId: "imsg-media-1",
244-
sentText: "<media:image>",
244+
sentText: "",
245+
echoText: "<media:image>",
245246
});
246247

247248
await deliverReplies({

extensions/imessage/src/monitor/deliver.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,10 @@ export async function deliverReplies(params: {
5858
// not before. The window between send completion and cache write is sub-millisecond;
5959
// the next SQLite inbound poll is 1-2s away, so no echo can arrive before the
6060
// cache entry exists.
61-
sentMessageCache?.remember(scope, { text: sent.sentText, messageId: sent.messageId });
61+
sentMessageCache?.remember(scope, {
62+
text: sent.echoText ?? sent.sentText,
63+
messageId: sent.messageId,
64+
});
6265
},
6366
sendMedia: async ({ mediaUrl, caption }) => {
6467
const sent = await sendMessageIMessage(target, caption ?? "", {
@@ -70,7 +73,7 @@ export async function deliverReplies(params: {
7073
replyToId: payload.replyToId,
7174
});
7275
sentMessageCache?.remember(scope, {
73-
text: sent.sentText || undefined,
76+
text: sent.echoText ?? (sent.sentText || undefined),
7477
messageId: sent.messageId,
7578
});
7679
},
@@ -94,7 +97,7 @@ export function createIMessageEchoCachingSend(params: {
9497
});
9598
const scope = `${params.accountId ?? opts.accountId ?? ""}:${target}`;
9699
params.sentMessageCache?.remember(scope, {
97-
text: sent.sentText || undefined,
100+
text: sent.echoText ?? (sent.sentText || undefined),
98101
messageId: sent.messageId,
99102
});
100103
return sent;

extensions/imessage/src/send.test.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ describe("sendMessageIMessage receipts", () => {
3131

3232
expect(result.messageId).toBe("p:0/imsg-1");
3333
expect(result.sentText).toBe("hello");
34+
expect(result.echoText).toBe("hello");
3435
expect(result.receipt.primaryPlatformMessageId).toBe("p:0/imsg-1");
3536
expect(result.receipt.platformMessageIds).toEqual(["p:0/imsg-1"]);
3637
expect(result.receipt.replyToId).toBe("reply-1");
@@ -70,9 +71,19 @@ describe("sendMessageIMessage receipts", () => {
7071
});
7172

7273
expect(result.messageId).toBe("12345");
73-
expect(result.sentText).toBe("<media:image>");
74+
expect(result.sentText).toBe("");
75+
expect(result.echoText).toBe("<media:image>");
7476
expect(result.receipt.primaryPlatformMessageId).toBe("12345");
7577
expect(result.receipt.platformMessageIds).toEqual(["12345"]);
78+
expect(client.request).toHaveBeenCalledWith(
79+
"send",
80+
expect.objectContaining({
81+
chat_guid: "chat-1",
82+
file: "/tmp/image.png",
83+
text: "",
84+
}),
85+
expect.any(Object),
86+
);
7687
expect(result.receipt.raw).toEqual([
7788
{
7889
channel: "imessage",
@@ -97,6 +108,26 @@ describe("sendMessageIMessage receipts", () => {
97108
expect(result.receipt.sentAt).toBeGreaterThan(0);
98109
});
99110

111+
it("preserves literal media placeholder text when no attachment is sent", async () => {
112+
const client = createClient({ guid: "p:0/imsg-text" });
113+
114+
const result = await sendMessageIMessage("chat_id:42", "literal <media:image> text", {
115+
config: IMESSAGE_TEST_CFG,
116+
client,
117+
});
118+
119+
expect(result.sentText).toBe("literal <media:image> text");
120+
expect(result.echoText).toBe("literal <media:image> text");
121+
expect(client.request).toHaveBeenCalledWith(
122+
"send",
123+
expect.objectContaining({
124+
chat_id: 42,
125+
text: "literal <media:image> text",
126+
}),
127+
expect.any(Object),
128+
);
129+
});
130+
100131
it("does not treat compatibility ok responses as visible platform ids", async () => {
101132
const client = createClient({ ok: "true" });
102133

extensions/imessage/src/send.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,7 @@ import {
66
} from "openclaw/plugin-sdk/channel-message";
77
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
88
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime";
9-
import { kindFromMime } from "openclaw/plugin-sdk/media-runtime";
10-
import { resolveOutboundAttachmentFromUrl } from "openclaw/plugin-sdk/media-runtime";
9+
import { kindFromMime, resolveOutboundAttachmentFromUrl } from "openclaw/plugin-sdk/media-runtime";
1110
import { requireRuntimeConfig } from "openclaw/plugin-sdk/plugin-config-runtime";
1211
import { convertMarkdownTables } from "openclaw/plugin-sdk/text-chunking";
1312
import { stripInlineDirectiveTagsForDelivery } from "openclaw/plugin-sdk/text-chunking";
@@ -48,6 +47,7 @@ type IMessageSendOpts = {
4847
type IMessageSendResult = {
4948
messageId: string;
5049
sentText: string;
50+
echoText?: string;
5151
receipt: MessageReceipt;
5252
};
5353

@@ -94,13 +94,13 @@ function resolveMessageId(result: Record<string, unknown> | null | undefined): s
9494
return raw ? raw.trim() : null;
9595
}
9696

97-
function resolveDeliveredIMessageText(text: string, mediaContentType?: string): string {
97+
function resolveOutboundEchoText(text: string, mediaContentType?: string): string | undefined {
9898
if (text.trim()) {
9999
return text;
100100
}
101101
const kind = kindFromMime(mediaContentType ?? undefined);
102102
if (!kind) {
103-
return text;
103+
return undefined;
104104
}
105105
return kind === "image" ? "<media:image>" : `<media:${kind}>`;
106106
}
@@ -187,6 +187,7 @@ export async function sendMessageIMessage(
187187
: 16 * 1024 * 1024;
188188
let message = text ?? "";
189189
let filePath: string | undefined;
190+
let mediaContentType: string | undefined;
190191

191192
if (opts.mediaUrl?.trim()) {
192193
const resolveAttachmentFn = opts.resolveAttachmentImpl ?? resolveOutboundAttachmentFromUrl;
@@ -195,7 +196,7 @@ export async function sendMessageIMessage(
195196
readFile: opts.mediaReadFile,
196197
});
197198
filePath = resolved.path;
198-
message = resolveDeliveredIMessageText(message, resolved.contentType ?? undefined);
199+
mediaContentType = resolved.contentType ?? undefined;
199200
}
200201

201202
if (!message.trim() && !filePath) {
@@ -224,6 +225,7 @@ export async function sendMessageIMessage(
224225
if (!message.trim() && !filePath) {
225226
throw new Error("iMessage send requires text or media");
226227
}
228+
const echoText = resolveOutboundEchoText(message, filePath ? mediaContentType : undefined);
227229
const resolvedReplyToId = sanitizeReplyToId(opts.replyToId);
228230
const params: Record<string, unknown> = {
229231
text: message,
@@ -266,7 +268,7 @@ export async function sendMessageIMessage(
266268
if (echoScope) {
267269
rememberPersistedIMessageEcho({
268270
scope: echoScope,
269-
text: message,
271+
text: echoText,
270272
messageId: resolvedId ?? undefined,
271273
});
272274
}
@@ -293,6 +295,7 @@ export async function sendMessageIMessage(
293295
return {
294296
messageId,
295297
sentText: message,
298+
...(echoText ? { echoText } : {}),
296299
receipt: createIMessageSendReceipt({
297300
messageId,
298301
target,

0 commit comments

Comments
 (0)