fix: stop leaking reply tags in iMessage outbound text (#39512) (thanks @mvanhorn)

* fix: stop leaking reply tags in iMessage outbound text (#39512) (thanks @mvanhorn)

* fix: preserve iMessage outbound whitespace without directive tags (#39512) (thanks @mvanhorn)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
This commit is contained in:
Matt Van Horn 2026-03-25 10:30:16 -07:00 committed by GitHub
parent f63c4b0856
commit e0972db7a2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 89 additions and 26 deletions

View File

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

View File

@ -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 () => {

View File

@ -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<string, unknown> | 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<string, unknown> = {
text: message,
service: service || "auto",
region,
};
if (resolvedReplyToId) {
params.reply_to = resolvedReplyToId;
}
if (filePath) {
params.file = filePath;
}

View File

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

View File

@ -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 = {

View File

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