diff --git a/CHANGELOG.md b/CHANGELOG.md index 3dc75d93568..ecaad4cd72c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai - Feishu: use the original message `create_time` instead of `Date.now()` for inbound timestamps so offline-retried messages carry the correct authoring time, preventing mis-targeted agent actions on stale instructions. (#52809) Thanks @schumilin. - Plugins/SDK: thread `moduleUrl` through plugin-sdk alias resolution so user-installed plugins outside the openclaw directory (e.g. `~/.openclaw/extensions/`) correctly resolve `openclaw/plugin-sdk/*` subpath imports, and gate `plugin-sdk:check-exports` in `release:check`. (#54283) Thanks @xieyongliang. - Telegram/pairing: ignore self-authored DM `message` updates so bot-pinned status cards and similar service updates do not trigger bogus pairing requests or re-enter inbound dispatch. (#54530) thanks @huntharo +- iMessage: stop leaking inline `[[reply_to:...]]` tags into delivered text by sending `reply_to` as RPC metadata and stripping stray directive tags from outbound messages. (#39512) Thanks @mvanhorn. ## 2026.3.24 diff --git a/extensions/imessage/src/channel.outbound.test.ts b/extensions/imessage/src/channel.outbound.test.ts index 1c7f1021743..1780297d265 100644 --- a/extensions/imessage/src/channel.outbound.test.ts +++ b/extensions/imessage/src/channel.outbound.test.ts @@ -269,18 +269,19 @@ describe("sendMessageIMessage", () => { expect(result.messageId).toBe("123"); }); - it("prepends reply tag as the first token when replyToId is provided", async () => { + it("passes replyToId as separate reply_to param instead of embedding in text", async () => { requestMock.mockClear().mockResolvedValue({ ok: true }); stopMock.mockClear().mockResolvedValue(undefined); - await sendWithDefaults("chat_id:123", " hello\nworld", { + await sendWithDefaults("chat_id:123", "hello world", { replyToId: "abc-123", }); const params = getSentParams(); - expect(params.text).toBe("[[reply_to:abc-123]] hello\nworld"); + expect(params.text).toBe("hello world"); + expect(params.reply_to).toBe("abc-123"); }); - it("rewrites an existing leading reply tag to keep the requested id first", async () => { + it("strips inline reply tags from text and passes replyToId as reply_to param", async () => { requestMock.mockClear().mockResolvedValue({ ok: true }); stopMock.mockClear().mockResolvedValue(undefined); @@ -288,10 +289,11 @@ describe("sendMessageIMessage", () => { replyToId: "new-id", }); const params = getSentParams(); - expect(params.text).toBe("[[reply_to:new-id]] hello"); + expect(params.text).toBe("hello"); + expect(params.reply_to).toBe("new-id"); }); - it("sanitizes replyToId before writing the leading reply tag", async () => { + it("sanitizes replyToId before passing as reply_to param", async () => { requestMock.mockClear().mockResolvedValue({ ok: true }); stopMock.mockClear().mockResolvedValue(undefined); @@ -299,10 +301,11 @@ describe("sendMessageIMessage", () => { replyToId: " [ab]\n\u0000c\td ] ", }); const params = getSentParams(); - expect(params.text).toBe("[[reply_to:abcd]] hello"); + expect(params.text).toBe("hello"); + expect(params.reply_to).toBe("abcd"); }); - it("skips reply tagging when sanitized replyToId is empty", async () => { + it("omits reply_to param when sanitized replyToId is empty", async () => { requestMock.mockClear().mockResolvedValue({ ok: true }); stopMock.mockClear().mockResolvedValue(undefined); @@ -311,6 +314,35 @@ describe("sendMessageIMessage", () => { }); const params = getSentParams(); expect(params.text).toBe("hello"); + expect(params.reply_to).toBeUndefined(); + }); + + it("strips stray [[reply_to:...]] tags from text even without replyToId option", async () => { + requestMock.mockClear().mockResolvedValue({ ok: true }); + stopMock.mockClear().mockResolvedValue(undefined); + + await sendWithDefaults("chat_id:123", "[[reply_to:65]] Great question"); + const params = getSentParams(); + expect(params.text).toBe("Great question"); + expect(params.reply_to).toBeUndefined(); + }); + + it("strips [[audio_as_voice]] tags from outbound text", async () => { + requestMock.mockClear().mockResolvedValue({ ok: true }); + stopMock.mockClear().mockResolvedValue(undefined); + + await sendWithDefaults("chat_id:123", "hello [[audio_as_voice]] world"); + const params = getSentParams(); + expect(params.text).toBe("hello world"); + }); + + it("throws when text is only directive tags and no media", async () => { + requestMock.mockClear().mockResolvedValue({ ok: true }); + stopMock.mockClear().mockResolvedValue(undefined); + + await expect(sendWithDefaults("chat_id:123", "[[reply_to:65]]")).rejects.toThrow( + "iMessage send requires text or media", + ); }); it("normalizes string message_id values from rpc result", async () => { diff --git a/extensions/imessage/src/send.ts b/extensions/imessage/src/send.ts index 70c996329e1..b5ba40c3c54 100644 --- a/extensions/imessage/src/send.ts +++ b/extensions/imessage/src/send.ts @@ -3,6 +3,7 @@ import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; import { kindFromMime } from "openclaw/plugin-sdk/media-runtime"; import { resolveOutboundAttachmentFromUrl } from "openclaw/plugin-sdk/media-runtime"; import { convertMarkdownTables } from "openclaw/plugin-sdk/text-runtime"; +import { stripInlineDirectiveTagsForDelivery } from "openclaw/plugin-sdk/text-runtime"; import { resolveIMessageAccount, type ResolvedIMessageAccount } from "./accounts.js"; import { createIMessageRpcClient, type IMessageRpcClient } from "./client.js"; import { formatIMessageChatTarget, type IMessageService, parseIMessageTarget } from "./targets.js"; @@ -34,7 +35,6 @@ export type IMessageSendResult = { messageId: string; }; -const LEADING_REPLY_TAG_RE = /^\s*\[\[\s*reply_to\s*:\s*([^\]\n]+)\s*\]\]\s*/i; const MAX_REPLY_TO_ID_LENGTH = 256; function stripUnsafeReplyTagChars(value: string): string { @@ -64,21 +64,6 @@ function sanitizeReplyToId(rawReplyToId?: string): string | undefined { return sanitized; } -function prependReplyTagIfNeeded(message: string, replyToId?: string): string { - const resolvedReplyToId = sanitizeReplyToId(replyToId); - if (!resolvedReplyToId) { - return message; - } - const replyTag = `[[reply_to:${resolvedReplyToId}]]`; - const existingLeadingTag = message.match(LEADING_REPLY_TAG_RE); - if (existingLeadingTag) { - const remainder = message.slice(existingLeadingTag[0].length).trimStart(); - return remainder ? `${replyTag} ${remainder}` : replyTag; - } - const trimmedMessage = message.trimStart(); - return trimmedMessage ? `${replyTag} ${trimmedMessage}` : replyTag; -} - function resolveMessageId(result: Record | null | undefined): string | null { if (!result) { return null; @@ -147,13 +132,19 @@ export async function sendMessageIMessage( }); message = convertMarkdownTables(message, tableMode); } - message = prependReplyTagIfNeeded(message, opts.replyToId); - + message = stripInlineDirectiveTagsForDelivery(message).text; + if (!message.trim() && !filePath) { + throw new Error("iMessage send requires text or media"); + } + const resolvedReplyToId = sanitizeReplyToId(opts.replyToId); const params: Record = { text: message, service: service || "auto", region, }; + if (resolvedReplyToId) { + params.reply_to = resolvedReplyToId; + } if (filePath) { params.file = filePath; } diff --git a/src/plugin-sdk/text-runtime.ts b/src/plugin-sdk/text-runtime.ts index ddec425cc49..e48cd94a4b2 100644 --- a/src/plugin-sdk/text-runtime.ts +++ b/src/plugin-sdk/text-runtime.ts @@ -20,6 +20,7 @@ export * from "../shared/text/reasoning-tags.js"; export * from "../shared/text/strip-markdown.js"; export * from "../shared/scoped-expiring-id-cache.js"; export * from "../terminal/safe-text.js"; +export * from "../utils/directive-tags.js"; export * from "../utils.js"; export * from "../utils/chunk-items.js"; export * from "../utils/fetch-timeout.js"; diff --git a/src/utils/directive-tags.test.ts b/src/utils/directive-tags.test.ts index 21b042b22b0..0e1658c389d 100644 --- a/src/utils/directive-tags.test.ts +++ b/src/utils/directive-tags.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "vitest"; import { + stripInlineDirectiveTagsForDelivery, stripInlineDirectiveTagsForDisplay, stripInlineDirectiveTagsFromMessageForDisplay, } from "./directive-tags.js"; @@ -27,6 +28,29 @@ describe("stripInlineDirectiveTagsForDisplay", () => { }); }); +describe("stripInlineDirectiveTagsForDelivery", () => { + test("removes directives and surrounding whitespace for outbound text", () => { + const input = "hello [[reply_to_current]] world [[audio_as_voice]]"; + const result = stripInlineDirectiveTagsForDelivery(input); + expect(result.changed).toBe(true); + expect(result.text).toBe("hello world"); + }); + + test("preserves intentional multi-space formatting away from directives", () => { + const input = "a b [[reply_to:123]] c d"; + const result = stripInlineDirectiveTagsForDelivery(input); + expect(result.changed).toBe(true); + expect(result.text).toBe("a b c d"); + }); + + test("does not trim plain text when no directive tags are present", () => { + const input = " keep leading and trailing whitespace "; + const result = stripInlineDirectiveTagsForDelivery(input); + expect(result.changed).toBe(false); + expect(result.text).toBe(input); + }); +}); + describe("stripInlineDirectiveTagsFromMessageForDisplay", () => { test("strips inline directives from text content blocks", () => { const input = { diff --git a/src/utils/directive-tags.ts b/src/utils/directive-tags.ts index e22e9a47c35..6d69e795b05 100644 --- a/src/utils/directive-tags.ts +++ b/src/utils/directive-tags.ts @@ -16,6 +16,8 @@ type InlineDirectiveParseOptions = { const AUDIO_TAG_RE = /\[\[\s*audio_as_voice\s*\]\]/gi; const REPLY_TAG_RE = /\[\[\s*(?:reply_to_current|reply_to\s*:\s*([^\]\n]+))\s*\]\]/gi; +const INLINE_DIRECTIVE_TAG_WITH_PADDING_RE = + /\s*(?:\[\[\s*audio_as_voice\s*\]\]|\[\[\s*(?:reply_to_current|reply_to\s*:\s*[^\]\n]+)\s*\]\])\s*/gi; function normalizeDirectiveWhitespace(text: string): string { return text @@ -52,6 +54,18 @@ export function stripInlineDirectiveTagsForDisplay(text: string): StripInlineDir }; } +export function stripInlineDirectiveTagsForDelivery(text: string): StripInlineDirectiveTagsResult { + if (!text) { + return { text, changed: false }; + } + const stripped = text.replace(INLINE_DIRECTIVE_TAG_WITH_PADDING_RE, " "); + const changed = stripped !== text; + return { + text: changed ? stripped.trim() : text, + changed, + }; +} + function isMessageTextPart(part: MessagePart): part is MessageTextPart { return Boolean(part) && part?.type === "text" && typeof part.text === "string"; }