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

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