fix(feishu): normalize all mentions in inbound agent context (#30252)

* fix(feishu): normalize all mentions in inbound agent context

Convert Feishu mention placeholders to explicit <at user_id="..."> tags (including bot mentions), add mention semantics hints for the model, and remove unused mentionMessageBody parsing to keep context handling consistent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(feishu): use replacer callback and escape only < > in normalizeMentions

Switch String.replace to a function replacer to prevent $ sequences in
display names from being interpolated as replacement patterns. Narrow
escaping to < and > only — & does not need escaping in LLM prompt tag
bodies and escaping it degrades readability (e.g. R&D → R&amp;D).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(feishu): only use open_id in normalizeMentions tag, drop user_id fallback

When a mention has no open_id, degrade to @name instead of emitting
<at user_id="uid_...">. This keeps the tag user_id space exclusively
open_id, so the bot self-reference hint (which uses botOpenId) is
always consistent with what appears in the tags.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(feishu): register mention strip pattern for <at> tags in channel dock

Add mentions.stripPatterns to feishuPlugin so that normalizeCommandBody
receives a slash-clean string after normalizeMentions replaces Feishu
placeholders with <at user_id="...">name</at> tags. Without this,
group slash commands like @Bot /help had their leading / obscured by
the tag prefix and no longer triggered command handlers.

Pattern mirrors the approach used by Slack (<@[^>]+>) and Discord (<@!?\d+>).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(feishu): strip bot mention in p2p to preserve DM slash commands

In p2p messages the bot mention is a pure addressing prefix; converting
it to <at user_id="..."> breaks slash commands because buildCommandContext
skips stripMentions for DMs. Extend normalizeMentions with a stripKeys
set and populate it with bot mention keys in p2p, so @Bot /help arrives
as /help. Non-bot mentions (mention-forward targets) are still normalized
to <at> tags in both p2p and group contexts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Changelog: note Feishu inbound mention normalization

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
Jealous
2026-03-03 12:40:17 +08:00
committed by GitHub
parent 85377a2817
commit 9083a3f2e3
5 changed files with 153 additions and 40 deletions

View File

@@ -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 `<at user_id=\"...\">name</at>` 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.

View File

@@ -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 <at> 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('<at user_id="ou_bot">Bot</at> 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('<at user_id="ou_alice">Alice</at> 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(
'<at user_id="ou_bot_1">Bot One</at> hi <at user_id="ou_user_2">User Two</at>',
);
});
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('<at user_id="ou_x">$& the user</at> hi');
});
it("escapes < and > in mention name to protect tag structure", () => {
const ctx = parseFeishuMessageEvent(
makeEvent("@_user_1 test", [
{ key: "@_user_1", name: "<script>", id: { open_id: "ou_x" } },
]) as any,
BOT_OPEN_ID,
);
expect(ctx.content).toBe('<at user_id="ou_x">&lt;script&gt;</at> test');
});
});

View File

@@ -18,12 +18,7 @@ import { tryRecordMessage, tryRecordMessagePersistent } from "./dedup.js";
import { maybeCreateDynamicAgent } from "./dynamic-agent.js";
import { normalizeFeishuExternalKey } from "./external-keys.js";
import { downloadMessageResourceFeishu } from "./media.js";
import {
escapeRegExp,
extractMentionTargets,
extractMessageBody,
isMentionForwardRequest,
} from "./mention.js";
import { extractMentionTargets, isMentionForwardRequest } from "./mention.js";
import {
resolveFeishuGroupConfig,
resolveFeishuReplyPolicy,
@@ -478,17 +473,30 @@ function checkBotMentioned(event: FeishuMessageEvent, botOpenId?: string, botNam
return false;
}
export function stripBotMention(
function normalizeMentions(
text: string,
mentions?: FeishuMessageEvent["message"]["mentions"],
botStripId?: string,
): string {
if (!mentions || mentions.length === 0) return text;
const escaped = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const escapeName = (value: string) => value.replace(/</g, "&lt;").replace(/>/g, "&gt;");
let result = text;
for (const mention of mentions) {
result = result.replace(new RegExp(`@${escapeRegExp(mention.name)}\\s*`, "g"), "");
result = result.replace(new RegExp(escapeRegExp(mention.key), "g"), "");
const mentionId = mention.id.open_id;
const replacement =
botStripId && mentionId === botStripId
? ""
: mentionId
? `<at user_id="${mentionId}">${escapeName(mention.name)}</at>`
: `@${mention.name}`;
result = result.replace(new RegExp(escaped(mention.key), "g"), () => replacement).trim();
}
return result.trim();
return result;
}
/**
@@ -760,7 +768,15 @@ export function parseFeishuMessageEvent(
): FeishuMessageContext {
const rawContent = parseMessageContent(event.message.content, event.message.message_type);
const mentionedBot = checkBotMentioned(event, botOpenId, botName);
const content = stripBotMention(rawContent, event.message.mentions);
const hasAnyMention = (event.message.mentions?.length ?? 0) > 0;
// In p2p, the bot mention is a pure addressing prefix with no semantic value;
// strip it so slash commands like @Bot /help still have a leading /.
// Non-bot mentions (e.g. mention-forward targets) are still normalized to <at> tags.
const content = normalizeMentions(
rawContent,
event.message.mentions,
event.message.chat_type === "p2p" ? botOpenId : undefined,
);
const senderOpenId = event.sender.sender_id.open_id?.trim();
const senderUserId = event.sender.sender_id.user_id?.trim();
const senderFallbackId = senderOpenId || senderUserId || "";
@@ -774,6 +790,7 @@ export function parseFeishuMessageEvent(
senderOpenId: senderFallbackId,
chatType: event.message.chat_type,
mentionedBot,
hasAnyMention,
rootId: event.message.root_id || undefined,
parentId: event.message.parent_id || undefined,
threadId: event.message.thread_id || undefined,
@@ -786,9 +803,6 @@ export function parseFeishuMessageEvent(
const mentionTargets = extractMentionTargets(event, botOpenId);
if (mentionTargets.length > 0) {
ctx.mentionTargets = mentionTargets;
// Extract message body (remove all @ placeholders)
const allMentionKeys = (event.message.mentions ?? []).map((m) => m.key);
ctx.mentionMessageBody = extractMessageBody(content, allMentionKeys);
}
}
@@ -798,12 +812,13 @@ export function parseFeishuMessageEvent(
export function buildFeishuAgentBody(params: {
ctx: Pick<
FeishuMessageContext,
"content" | "senderName" | "senderOpenId" | "mentionTargets" | "messageId"
"content" | "senderName" | "senderOpenId" | "mentionTargets" | "messageId" | "hasAnyMention"
>;
quotedContent?: string;
permissionErrorForAgent?: PermissionError;
botOpenId?: string;
}): string {
const { ctx, quotedContent, permissionErrorForAgent } = params;
const { ctx, quotedContent, permissionErrorForAgent, botOpenId } = params;
let messageBody = ctx.content;
if (quotedContent) {
messageBody = `[Replying to: "${quotedContent}"]\n\n${ctx.content}`;
@@ -813,6 +828,16 @@ export function buildFeishuAgentBody(params: {
const speaker = ctx.senderName ?? ctx.senderOpenId;
messageBody = `${speaker}: ${messageBody}`;
if (ctx.hasAnyMention) {
const botIdHint = botOpenId?.trim();
messageBody +=
`\n\n[System: The content may include mention tags in the form <at user_id="...">name</at>. ` +
`Treat these as real mentions of Feishu entities (users or bots).]`;
if (botIdHint) {
messageBody += `\n[System: If user_id is "${botIdHint}", that mention refers to you.]`;
}
}
if (ctx.mentionTargets && ctx.mentionTargets.length > 0) {
const targetNames = ctx.mentionTargets.map((t) => t.name).join(", ");
messageBody += `\n\n[System: Your reply will automatically @mention: ${targetNames}. Do not write @xxx yourself.]`;
@@ -1223,6 +1248,7 @@ export async function handleFeishuMessage(params: {
ctx,
quotedContent,
permissionErrorForAgent,
botOpenId,
});
const envelopeFrom = isGroup ? `${ctx.chatId}:${ctx.senderOpenId}` : ctx.senderOpenId;
if (permissionErrorForAgent) {

View File

@@ -88,6 +88,9 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
groups: {
resolveToolPolicy: resolveFeishuGroupToolPolicy,
},
mentions: {
stripPatterns: () => ['<at user_id="[^"]*">[^<]*</at>'],
},
reload: { configPrefixes: ["channels.feishu"] },
configSchema: {
schema: {

View File

@@ -45,6 +45,7 @@ export type FeishuMessageContext = {
senderName?: string;
chatType: "p2p" | "group" | "private";
mentionedBot: boolean;
hasAnyMention?: boolean;
rootId?: string;
parentId?: string;
threadId?: string;
@@ -52,8 +53,6 @@ export type FeishuMessageContext = {
contentType: string;
/** Mention forward targets (excluding the bot itself) */
mentionTargets?: MentionTarget[];
/** Extracted message body (after removing @ placeholders) */
mentionMessageBody?: string;
};
export type FeishuSendResult = {