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 31f3d73a..b45ed631 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 @@ -441,8 +441,28 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter { } /** - * 获取机器人自身的 open_id(懒加载并缓存) - * 调用 /open-apis/bot/v3/info 接口,失败时返回 null(降级到放行,保持兼容) + * Package-private for testing: returns true when a group message should be dropped + * because {@code require_mention} is on, the bot was not mentioned, AND we know our + * own open_id (so we trust the negative answer). + * + *

When {@code botOpenId} is {@code null} the bot identity is unavailable — either + * the {@code /open-apis/bot/v3/info} fetch hasn't succeeded yet, or it failed and is + * in the negative-cache window. In that case the gate falls open so a transient + * Feishu API outage doesn't silence the bot in every group it's in. The accompanying + * warn-level log makes the degraded mode visible. + */ + static boolean isGroupNonMentionDrop(boolean isGroup, + boolean requireMention, + boolean isBotMentioned, + String botOpenId) { + return isGroup && requireMention && !isBotMentioned && botOpenId != null; + } + + /** + * Fetches the bot's own open_id once and caches it. Returns {@code null} when + * the fetch fails — callers that consult this value (currently the + * {@code require_mention} gate) must treat {@code null} as "identity unknown" + * and fall open so a transient API outage doesn't silence the bot. */ private String getBotOpenId() { if (botOpenId != null) return botOpenId; @@ -463,7 +483,7 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter { 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()); + log.warn("[feishu] Failed to fetch bot open_id; require_mention gate will fall open until next attempt: {}", e.getMessage()); } return botOpenId; } @@ -638,12 +658,19 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter { private void handleFeishuMessage(String messageId, String messageType, String contentStr, String chatId, String chatType, String senderOpenId, String parentId, boolean isBotMentioned, Object rawPayload) { - // require_mention 群聊过滤:群聊中必须 @机器人才响应 + // require_mention 群聊过滤:群聊中必须 @机器人才响应。 + // 当 botOpenId 为 null 时(API 抖动 / 尚未拉取成功),失败回退到放行 — + // 避免飞书 /open-apis/bot/v3/info 短暂不可用时整个群机器人变哑巴。 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); + boolean requireMention = getConfigBoolean("require_mention", false); + if (isGroupNonMentionDrop(isGroup, requireMention, isBotMentioned, botOpenId)) { + log.debug("[feishu] require_mention=true but bot not mentioned, dropping messageId={}", messageId); return; } + if (isGroup && requireMention && !isBotMentioned) { + // botOpenId is null here — identity unknown, gate falls open. + log.warn("[feishu] require_mention=true but bot open_id unavailable; allowing messageId={}", messageId); + } // 消息去重 if (messageId != null && !processedMessageIds.add(messageId)) { 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 index 20520519..53d03551 100644 --- a/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuMentionTest.java +++ b/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuMentionTest.java @@ -104,6 +104,39 @@ class FeishuMentionTest { assertFalse(FeishuChannelAdapter.webhookMentionsContainBot(List.of(mention), BOT_ID)); } + // ==================== isGroupNonMentionDrop (gate matrix) ==================== + + @Test + void gate_groupRequireMentionBotMentioned_passesThrough() { + assertFalse(FeishuChannelAdapter.isGroupNonMentionDrop(true, true, true, BOT_ID)); + } + + @Test + void gate_groupRequireMentionBotNotMentioned_dropsWhenOpenIdKnown() { + assertTrue(FeishuChannelAdapter.isGroupNonMentionDrop(true, true, false, BOT_ID)); + } + + @Test + void gate_groupRequireMentionBotNotMentioned_failsOpenWhenOpenIdUnknown() { + // Bot identity unavailable (API outage / pending fetch) → degrade to allow. + // This was the bug the original PR shipped with: gate dropped instead of fell open. + assertFalse(FeishuChannelAdapter.isGroupNonMentionDrop(true, true, false, null)); + } + + @Test + void gate_p2pAlwaysPasses_regardlessOfRequireMention() { + // require_mention only applies to group chat; DMs are never gated. + assertFalse(FeishuChannelAdapter.isGroupNonMentionDrop(false, true, false, BOT_ID)); + assertFalse(FeishuChannelAdapter.isGroupNonMentionDrop(false, true, false, null)); + assertFalse(FeishuChannelAdapter.isGroupNonMentionDrop(false, true, true, BOT_ID)); + } + + @Test + void gate_requireMentionDisabled_passesThrough() { + assertFalse(FeishuChannelAdapter.isGroupNonMentionDrop(true, false, false, BOT_ID)); + assertFalse(FeishuChannelAdapter.isGroupNonMentionDrop(true, false, false, null)); + } + // ==================== helpers ==================== private static MentionEvent mentionEvent(String openId) {