diff --git a/CHANGELOG.md b/CHANGELOG.md
index bdf798406be..6ebcfaefb2a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -39,6 +39,7 @@ Docs: https://docs.openclaw.ai
- Plugin command/runtime hardening: validate and normalize plugin command name/description at registration boundaries, and guard Telegram native menu normalization paths so malformed plugin command specs cannot crash startup (`trim` on undefined). (#31997) Fixes #31944. Thanks @liuxiaopai-ai.
- Telegram: guard duplicate-token checks and gateway startup token normalization when account tokens are missing, preventing `token.trim()` crashes during status/start flows. (#31973) Thanks @ningding97.
- Discord/lifecycle startup status: push an immediate `connected` status snapshot when the gateway is already connected before lifecycle debug listeners attach, with abort-guarding to avoid contradictory status flips during pre-aborted startup. (#32336) Thanks @mitchmcalister.
+- Feishu/inbound mention normalization: preserve all inbound mention semantics by normalizing Feishu mention placeholders into explicit `name` tags (instead of stripping them), improving multi-mention context fidelity in agent prompts while retaining bot/self mention disambiguation. (#30252) Thanks @Lanfei.
- Feishu/multi-app mention routing: guard mention detection in multi-bot groups by validating mention display name alongside bot `open_id`, preventing false-positive self-mentions from Feishu WebSocket remapping so only the actually mentioned bot responds under `requireMention`. (#30315) Thanks @teaguexiao.
- Feishu/session-memory hook parity: trigger the shared `before_reset` session-memory hook path when Feishu `/new` and `/reset` commands execute so reset flows preserve memory behavior consistent with other channels. (#31437) Thanks @Linux2010.
- Feishu/LINE group system prompts: forward per-group `systemPrompt` config into inbound context `GroupSystemPrompt` for Feishu and LINE group/room events so configured group-specific behavior actually applies at dispatch time. (#31713) Thanks @whiskyboy.
diff --git a/extensions/feishu/src/bot.stripBotMention.test.ts b/extensions/feishu/src/bot.stripBotMention.test.ts
index 98016115a1b..543af29a0eb 100644
--- a/extensions/feishu/src/bot.stripBotMention.test.ts
+++ b/extensions/feishu/src/bot.stripBotMention.test.ts
@@ -1,38 +1,122 @@
import { describe, expect, it } from "vitest";
-import { stripBotMention, type FeishuMessageEvent } from "./bot.js";
+import { parseFeishuMessageEvent } from "./bot.js";
-type Mentions = FeishuMessageEvent["message"]["mentions"];
+function makeEvent(
+ text: string,
+ mentions?: Array<{ key: string; name: string; id: { open_id?: string; user_id?: string } }>,
+ chatType: "p2p" | "group" = "p2p",
+) {
+ return {
+ sender: { sender_id: { user_id: "u1", open_id: "ou_sender" } },
+ message: {
+ message_id: "msg_1",
+ chat_id: "oc_chat1",
+ chat_type: chatType,
+ message_type: "text",
+ content: JSON.stringify({ text }),
+ mentions,
+ },
+ };
+}
-describe("stripBotMention", () => {
+const BOT_OPEN_ID = "ou_bot";
+
+describe("normalizeMentions (via parseFeishuMessageEvent)", () => {
it("returns original text when mentions are missing", () => {
- expect(stripBotMention("hello world", undefined)).toBe("hello world");
+ const ctx = parseFeishuMessageEvent(makeEvent("hello world", undefined) as any, BOT_OPEN_ID);
+ expect(ctx.content).toBe("hello world");
});
- it("strips mention name and key for normal mentions", () => {
- const mentions: Mentions = [{ key: "@_bot_1", name: "Bot", id: { open_id: "ou_bot" } }];
- expect(stripBotMention("@Bot hello @_bot_1", mentions)).toBe("hello");
+ it("strips bot mention in p2p (addressing prefix, not semantic content)", () => {
+ const ctx = parseFeishuMessageEvent(
+ makeEvent("@_bot_1 hello", [
+ { key: "@_bot_1", name: "Bot", id: { open_id: "ou_bot" } },
+ ]) as any,
+ BOT_OPEN_ID,
+ );
+ expect(ctx.content).toBe("hello");
});
- it("treats mention.name regex metacharacters as literal text", () => {
- const mentions: Mentions = [{ key: "@_bot_1", name: ".*", id: { open_id: "ou_bot" } }];
- expect(stripBotMention("@NotBot hello", mentions)).toBe("@NotBot hello");
+ it("normalizes bot mention to tag in group (semantic content)", () => {
+ const ctx = parseFeishuMessageEvent(
+ makeEvent(
+ "@_bot_1 hello",
+ [{ key: "@_bot_1", name: "Bot", id: { open_id: "ou_bot" } }],
+ "group",
+ ) as any,
+ BOT_OPEN_ID,
+ );
+ expect(ctx.content).toBe('Bot hello');
});
- it("treats mention.key regex metacharacters as literal text", () => {
- const mentions: Mentions = [{ key: ".*", name: "Bot", id: { open_id: "ou_bot" } }];
- expect(stripBotMention("hello world", mentions)).toBe("hello world");
+ it("strips bot mention but normalizes other mentions in p2p (mention-forward)", () => {
+ const ctx = parseFeishuMessageEvent(
+ makeEvent("@_bot_1 @_user_alice hello", [
+ { key: "@_bot_1", name: "Bot", id: { open_id: "ou_bot" } },
+ { key: "@_user_alice", name: "Alice", id: { open_id: "ou_alice" } },
+ ]) as any,
+ BOT_OPEN_ID,
+ );
+ expect(ctx.content).toBe('Alice hello');
});
- it("trims once after all mention replacements", () => {
- const mentions: Mentions = [{ key: "@_bot_1", name: "Bot", id: { open_id: "ou_bot" } }];
- expect(stripBotMention(" @_bot_1 hello ", mentions)).toBe("hello");
+ it("falls back to @name when open_id is absent", () => {
+ const ctx = parseFeishuMessageEvent(
+ makeEvent("@_user_1 hi", [
+ { key: "@_user_1", name: "Alice", id: { user_id: "uid_alice" } },
+ ]) as any,
+ BOT_OPEN_ID,
+ );
+ expect(ctx.content).toBe("@Alice hi");
});
- it("strips multiple mentions in one pass", () => {
- const mentions: Mentions = [
- { key: "@_bot_1", name: "Bot One", id: { open_id: "ou_bot_1" } },
- { key: "@_bot_2", name: "Bot Two", id: { open_id: "ou_bot_2" } },
- ];
- expect(stripBotMention("@Bot One @_bot_1 hi @Bot Two @_bot_2", mentions)).toBe("hi");
+ it("falls back to plain @name when no id is present", () => {
+ const ctx = parseFeishuMessageEvent(
+ makeEvent("@_unknown hey", [{ key: "@_unknown", name: "Nobody", id: {} }]) as any,
+ BOT_OPEN_ID,
+ );
+ expect(ctx.content).toBe("@Nobody hey");
+ });
+
+ it("treats mention key regex metacharacters as literal text", () => {
+ const ctx = parseFeishuMessageEvent(
+ makeEvent("hello world", [{ key: ".*", name: "Bot", id: { open_id: "ou_bot" } }]) as any,
+ BOT_OPEN_ID,
+ );
+ expect(ctx.content).toBe("hello world");
+ });
+
+ it("normalizes multiple mentions in one pass", () => {
+ const ctx = parseFeishuMessageEvent(
+ makeEvent("@_bot_1 hi @_user_2", [
+ { key: "@_bot_1", name: "Bot One", id: { open_id: "ou_bot_1" } },
+ { key: "@_user_2", name: "User Two", id: { open_id: "ou_user_2" } },
+ ]) as any,
+ BOT_OPEN_ID,
+ );
+ expect(ctx.content).toBe(
+ 'Bot One hi User Two',
+ );
+ });
+
+ it("treats $ in display name as literal (no replacement-pattern interpolation)", () => {
+ const ctx = parseFeishuMessageEvent(
+ makeEvent("@_user_1 hi", [
+ { key: "@_user_1", name: "$& the user", id: { open_id: "ou_x" } },
+ ]) as any,
+ BOT_OPEN_ID,
+ );
+ // $ is preserved literally (no $& pattern substitution); & is not escaped in tag body
+ expect(ctx.content).toBe('$& the user hi');
+ });
+
+ it("escapes < and > in mention name to protect tag structure", () => {
+ const ctx = parseFeishuMessageEvent(
+ makeEvent("@_user_1 test", [
+ { key: "@_user_1", name: "