From af3e68d2717916f446ee3131fc1b8f90bb29c2be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=80=AA=E7=A8=8B=E4=BC=9F?= Date: Wed, 20 May 2026 08:47:39 +0800 Subject: [PATCH] fix(feishu): use SDK mentions field for require_mention group filtering (#163) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #162 require_mention=true previously degraded to a no-op when botPrefix was unset: shouldProcess() returned true for all messages and checkAccess() fell through unconditionally, so any group message would be answered — including ones where the @mention targeted another user. FeishuChannelAdapter now consults the Feishu SDK's mentions field directly: - WebSocket: read EventMessage.getMentions(); webhook: read mentions[] from the JSON payload. In both paths each mention's id.open_id is compared against the bot's own open_id. - Bot open_id is fetched lazily via /open-apis/bot/v3/info and cached on the adapter instance. If the call fails the message is allowed through, matching the previous behaviour. - The require_mention gate is applied at the top of handleFeishuMessage so 1:1 chats are unaffected. Tests: 15 unit cases covering null/empty inputs, bot mentioned, only-other mentioned, bot among multiple mentions, and malformed payloads. --- .../channel/feishu/FeishuChannelAdapter.java | 83 ++++++++++++- .../channel/feishu/FeishuMentionTest.java | 117 ++++++++++++++++++ 2 files changed, 196 insertions(+), 4 deletions(-) create mode 100644 mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuMentionTest.java diff --git a/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java b/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java index 96d85a95..31f3d73a 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java @@ -54,6 +54,8 @@ import java.util.concurrent.TimeUnit; * - stale_event_threshold_seconds: 过滤旧事件阈值(默认 30,0 禁用) * - card_format: 卡片格式化模式 "auto"(默认)| "always" | "never" * auto: 根据内容自动检测;always: 全部包卡片;never: 全部纯文本(降级/调试用) + * - require_mention: 群聊中是否需要 @机器人 才响应(默认 false) + * true: 仅当消息中 @了机器人才处理;通过飞书 mentions 字段精确判断,无需配置 botPrefix * * @author MateClaw Team */ @@ -97,6 +99,9 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter { /** 旧事件过滤默认阈值(秒):超过 30 秒的事件视为重连后回放 */ private static final long DEFAULT_STALE_THRESHOLD_SECONDS = 30L; + /** 机器人自身 open_id 缓存(用于 require_mention 精确判断,懒加载) */ + private volatile String botOpenId; + public FeishuChannelAdapter(ChannelEntity channelEntity, ChannelMessageRouter messageRouter, ObjectMapper objectMapper) { @@ -397,7 +402,70 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter { senderOpenId = sender.getSenderId().getOpenId(); } - handleFeishuMessage(messageId, messageType, contentStr, chatId, chatType, senderOpenId, parentId, event); + boolean isBotMentioned = isBotMentionedInEvent(message.getMentions()); + handleFeishuMessage(messageId, messageType, contentStr, chatId, chatType, senderOpenId, parentId, isBotMentioned, event); + } + + // ==================== @提及检测 ==================== + + private boolean isBotMentionedInEvent(com.lark.oapi.service.im.v1.model.MentionEvent[] mentions) { + return eventMentionsContainBot(mentions, getBotOpenId()); + } + + private boolean isBotMentionedInWebhookMessage(Map message) { + Object mentionsObj = message.get("mentions"); + if (!(mentionsObj instanceof List list)) return false; + return webhookMentionsContainBot(list, getBotOpenId()); + } + + /** Package-private for testing: 判断 SDK mentions 数组中是否包含指定 open_id */ + static boolean eventMentionsContainBot(com.lark.oapi.service.im.v1.model.MentionEvent[] mentions, + String botOpenId) { + if (mentions == null || mentions.length == 0 || botOpenId == null) return false; + for (var mention : mentions) { + if (mention.getId() != null && botOpenId.equals(mention.getId().getOpenId())) return true; + } + return false; + } + + /** Package-private for testing: 判断 Webhook mentions 列表中是否包含指定 open_id */ + static boolean webhookMentionsContainBot(List mentions, String botOpenId) { + if (mentions == null || botOpenId == null) return false; + for (Object item : mentions) { + if (!(item instanceof Map mention)) continue; + Object idObj = mention.get("id"); + if (!(idObj instanceof Map id)) continue; + if (botOpenId.equals(id.get("open_id"))) return true; + } + return false; + } + + /** + * 获取机器人自身的 open_id(懒加载并缓存) + * 调用 /open-apis/bot/v3/info 接口,失败时返回 null(降级到放行,保持兼容) + */ + private String getBotOpenId() { + if (botOpenId != null) return botOpenId; + try { + ensureTokenValid(); + String apiBase = getApiBaseUrl(); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(apiBase + "/open-apis/bot/v3/info")) + .header("Authorization", "Bearer " + tenantAccessToken) + .GET() + .timeout(Duration.ofSeconds(5)) + .build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + Map body = objectMapper.readValue(response.body(), Map.class); + Map bot = (Map) body.get("bot"); + if (bot != null) { + botOpenId = (String) bot.get("open_id"); + log.info("[feishu] Bot open_id fetched and cached: {}", botOpenId); + } + } catch (Exception e) { + log.warn("[feishu] Failed to fetch bot open_id, require_mention will allow message: {}", e.getMessage()); + } + return botOpenId; } // ==================== Token 管理 ==================== @@ -543,7 +611,8 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter { } } - handleFeishuMessage(messageId, messageType, contentStr, chatId, chatType, senderOpenId, parentId, payload); + boolean isBotMentioned = isBotMentionedInWebhookMessage(message); + handleFeishuMessage(messageId, messageType, contentStr, chatId, chatType, senderOpenId, parentId, isBotMentioned, payload); } catch (Exception e) { log.error("[feishu] Failed to handle webhook: {}", e.getMessage(), e); @@ -568,7 +637,14 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter { */ private void handleFeishuMessage(String messageId, String messageType, String contentStr, String chatId, String chatType, String senderOpenId, - String parentId, Object rawPayload) { + String parentId, boolean isBotMentioned, Object rawPayload) { + // require_mention 群聊过滤:群聊中必须 @机器人才响应 + boolean isGroup = "group".equals(chatType); + if (isGroup && getConfigBoolean("require_mention", false) && !isBotMentioned) { + log.debug("[feishu] require_mention=true but bot not mentioned, ignoring messageId={}", messageId); + return; + } + // 消息去重 if (messageId != null && !processedMessageIds.add(messageId)) { log.debug("[feishu] Duplicate message_id: {}, skipping", messageId); @@ -609,7 +685,6 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter { } // 生成短会话后缀 - boolean isGroup = "group".equals(chatType); String shortSuffix = generateShortSessionSuffix(chatId, senderOpenId, isGroup); ChannelMessage channelMessage = ChannelMessage.builder() diff --git a/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuMentionTest.java b/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuMentionTest.java new file mode 100644 index 00000000..20520519 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuMentionTest.java @@ -0,0 +1,117 @@ +package vip.mate.channel.feishu; + +import com.lark.oapi.service.im.v1.model.MentionEvent; +import com.lark.oapi.service.im.v1.model.UserId; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class FeishuMentionTest { + + private static final String BOT_ID = "ou_bot123"; + private static final String OTHER_ID = "ou_user456"; + + // ==================== eventMentionsContainBot ==================== + + @Test + void event_nullMentions_returnsFalse() { + assertFalse(FeishuChannelAdapter.eventMentionsContainBot(null, BOT_ID)); + } + + @Test + void event_emptyMentions_returnsFalse() { + assertFalse(FeishuChannelAdapter.eventMentionsContainBot(new MentionEvent[0], BOT_ID)); + } + + @Test + void event_nullBotOpenId_returnsFalse() { + MentionEvent mention = mentionEvent(BOT_ID); + assertFalse(FeishuChannelAdapter.eventMentionsContainBot(new MentionEvent[]{mention}, null)); + } + + @Test + void event_botIsMentioned_returnsTrue() { + MentionEvent mention = mentionEvent(BOT_ID); + assertTrue(FeishuChannelAdapter.eventMentionsContainBot(new MentionEvent[]{mention}, BOT_ID)); + } + + @Test + void event_onlyOtherUserMentioned_returnsFalse() { + MentionEvent mention = mentionEvent(OTHER_ID); + assertFalse(FeishuChannelAdapter.eventMentionsContainBot(new MentionEvent[]{mention}, BOT_ID)); + } + + @Test + void event_botAmongMultipleMentions_returnsTrue() { + MentionEvent[] mentions = {mentionEvent(OTHER_ID), mentionEvent(BOT_ID)}; + assertTrue(FeishuChannelAdapter.eventMentionsContainBot(mentions, BOT_ID)); + } + + @Test + void event_mentionWithNullId_skippedSafely() { + MentionEvent mention = MentionEvent.newBuilder().key("@_user_xxx").build(); // no id set + assertFalse(FeishuChannelAdapter.eventMentionsContainBot(new MentionEvent[]{mention}, BOT_ID)); + } + + // ==================== webhookMentionsContainBot ==================== + + @Test + void webhook_nullList_returnsFalse() { + assertFalse(FeishuChannelAdapter.webhookMentionsContainBot(null, BOT_ID)); + } + + @Test + void webhook_emptyList_returnsFalse() { + assertFalse(FeishuChannelAdapter.webhookMentionsContainBot(List.of(), BOT_ID)); + } + + @Test + void webhook_nullBotOpenId_returnsFalse() { + List mentions = List.of(webhookMention(BOT_ID)); + assertFalse(FeishuChannelAdapter.webhookMentionsContainBot(mentions, null)); + } + + @Test + void webhook_botIsMentioned_returnsTrue() { + List mentions = List.of(webhookMention(BOT_ID)); + assertTrue(FeishuChannelAdapter.webhookMentionsContainBot(mentions, BOT_ID)); + } + + @Test + void webhook_onlyOtherUserMentioned_returnsFalse() { + List mentions = List.of(webhookMention(OTHER_ID)); + assertFalse(FeishuChannelAdapter.webhookMentionsContainBot(mentions, BOT_ID)); + } + + @Test + void webhook_botAmongMultipleMentions_returnsTrue() { + List mentions = List.of(webhookMention(OTHER_ID), webhookMention(BOT_ID)); + assertTrue(FeishuChannelAdapter.webhookMentionsContainBot(mentions, BOT_ID)); + } + + @Test + void webhook_malformedItem_skippedSafely() { + List mentions = List.of("not-a-map", Map.of("no_id_key", "value"), webhookMention(BOT_ID)); + assertTrue(FeishuChannelAdapter.webhookMentionsContainBot(mentions, BOT_ID)); + } + + @Test + void webhook_idMissingOpenId_skippedSafely() { + Map mention = Map.of("id", Map.of("user_id", "u123")); // no open_id key + assertFalse(FeishuChannelAdapter.webhookMentionsContainBot(List.of(mention), BOT_ID)); + } + + // ==================== helpers ==================== + + private static MentionEvent mentionEvent(String openId) { + UserId userId = UserId.newBuilder().openId(openId).build(); + return MentionEvent.newBuilder().id(userId).build(); + } + + private static Map webhookMention(String openId) { + return Map.of("id", Map.of("open_id", openId)); + } +}