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.
This commit is contained in:
homer-byte
2026-05-13 12:03:05 -04:00
committed by GitHub
parent ddd79e51ba
commit c3e5d85ce1
5 changed files with 51 additions and 12 deletions

View File

@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- 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.
- 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.

View File

@@ -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: "<media:image>",
sentText: "",
echoText: "<media:image>",
});
await deliverReplies({

View File

@@ -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;

View File

@@ -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("<media:image>");
expect(result.sentText).toBe("");
expect(result.echoText).toBe("<media:image>");
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 <media:image> text", {
config: IMESSAGE_TEST_CFG,
client,
});
expect(result.sentText).toBe("literal <media:image> text");
expect(result.echoText).toBe("literal <media:image> text");
expect(client.request).toHaveBeenCalledWith(
"send",
expect.objectContaining({
chat_id: 42,
text: "literal <media:image> text",
}),
expect.any(Object),
);
});
it("does not treat compatibility ok responses as visible platform ids", async () => {
const client = createClient({ ok: "true" });

View File

@@ -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<string, unknown> | 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" ? "<media:image>" : `<media:${kind}>`;
}
@@ -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<string, unknown> = {
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,