mirror of https://github.com/openclaw/openclaw.git
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:
parent
f63c4b0856
commit
e0972db7a2
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue