mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-09 15:35:17 +00:00
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&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:
@@ -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.
|
||||
|
||||
@@ -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"><script></at> test');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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, "<").replace(/>/g, ">");
|
||||
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) {
|
||||
|
||||
@@ -88,6 +88,9 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
||||
groups: {
|
||||
resolveToolPolicy: resolveFeishuGroupToolPolicy,
|
||||
},
|
||||
mentions: {
|
||||
stripPatterns: () => ['<at user_id="[^"]*">[^<]*</at>'],
|
||||
},
|
||||
reload: { configPrefixes: ["channels.feishu"] },
|
||||
configSchema: {
|
||||
schema: {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user