Skip to content

Commit 5ad9776

Browse files
committed
fix(imessage): preserve media echo key
1 parent 24693ee commit 5ad9776

5 files changed

Lines changed: 31 additions & 7 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
- gateway: pass Talk session scope to resolver [AI]. (#81379) Thanks @pgondhi987.
1011
- 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.
1112
- OpenAI plugin: clarify remote Codex OAuth login copy so tunneled users know sign-in may finish automatically before they paste the redirect URL. (#81301) Thanks @rubencu.

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: 3 additions & 0 deletions
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");
@@ -71,6 +72,7 @@ describe("sendMessageIMessage receipts", () => {
7172

7273
expect(result.messageId).toBe("12345");
7374
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"]);
7678
expect(client.request).toHaveBeenCalledWith(
@@ -115,6 +117,7 @@ describe("sendMessageIMessage receipts", () => {
115117
});
116118

117119
expect(result.sentText).toBe("literal <media:image> text");
120+
expect(result.echoText).toBe("literal <media:image> text");
118121
expect(client.request).toHaveBeenCalledWith(
119122
"send",
120123
expect.objectContaining({

extensions/imessage/src/send.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +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 { resolveOutboundAttachmentFromUrl } from "openclaw/plugin-sdk/media-runtime";
9+
import { kindFromMime, resolveOutboundAttachmentFromUrl } from "openclaw/plugin-sdk/media-runtime";
1010
import { requireRuntimeConfig } from "openclaw/plugin-sdk/plugin-config-runtime";
1111
import { convertMarkdownTables } from "openclaw/plugin-sdk/text-chunking";
1212
import { stripInlineDirectiveTagsForDelivery } from "openclaw/plugin-sdk/text-chunking";
@@ -47,6 +47,7 @@ type IMessageSendOpts = {
4747
type IMessageSendResult = {
4848
messageId: string;
4949
sentText: string;
50+
echoText?: string;
5051
receipt: MessageReceipt;
5152
};
5253

@@ -93,6 +94,17 @@ function resolveMessageId(result: Record<string, unknown> | null | undefined): s
9394
return raw ? raw.trim() : null;
9495
}
9596

97+
function resolveOutboundEchoText(text: string, mediaContentType?: string): string | undefined {
98+
if (text.trim()) {
99+
return text;
100+
}
101+
const kind = kindFromMime(mediaContentType ?? undefined);
102+
if (!kind) {
103+
return undefined;
104+
}
105+
return kind === "image" ? "<media:image>" : `<media:${kind}>`;
106+
}
107+
96108
function createIMessageSendReceipt(params: {
97109
messageId: string;
98110
target: ReturnType<typeof parseIMessageTarget>;
@@ -175,6 +187,7 @@ export async function sendMessageIMessage(
175187
: 16 * 1024 * 1024;
176188
let message = text ?? "";
177189
let filePath: string | undefined;
190+
let mediaContentType: string | undefined;
178191

179192
if (opts.mediaUrl?.trim()) {
180193
const resolveAttachmentFn = opts.resolveAttachmentImpl ?? resolveOutboundAttachmentFromUrl;
@@ -183,6 +196,7 @@ export async function sendMessageIMessage(
183196
readFile: opts.mediaReadFile,
184197
});
185198
filePath = resolved.path;
199+
mediaContentType = resolved.contentType ?? undefined;
186200
}
187201

188202
if (!message.trim() && !filePath) {
@@ -211,6 +225,7 @@ export async function sendMessageIMessage(
211225
if (!message.trim() && !filePath) {
212226
throw new Error("iMessage send requires text or media");
213227
}
228+
const echoText = resolveOutboundEchoText(message, filePath ? mediaContentType : undefined);
214229
const resolvedReplyToId = sanitizeReplyToId(opts.replyToId);
215230
const params: Record<string, unknown> = {
216231
text: message,
@@ -253,7 +268,7 @@ export async function sendMessageIMessage(
253268
if (echoScope) {
254269
rememberPersistedIMessageEcho({
255270
scope: echoScope,
256-
text: message,
271+
text: echoText,
257272
messageId: resolvedId ?? undefined,
258273
});
259274
}
@@ -280,6 +295,7 @@ export async function sendMessageIMessage(
280295
return {
281296
messageId,
282297
sentText: message,
298+
...(echoText ? { echoText } : {}),
283299
receipt: createIMessageSendReceipt({
284300
messageId,
285301
target,

0 commit comments

Comments
 (0)