fix(feishu): use SDK mentions field for require_mention group filtering (#163)

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.
This commit is contained in:
倪程伟 2026-05-20 08:47:39 +08:00 committed by matevip
parent ee0c229f52
commit af3e68d271
2 changed files with 196 additions and 4 deletions

View File

@ -54,6 +54,8 @@ import java.util.concurrent.TimeUnit;
* - stale_event_threshold_seconds: 过滤旧事件阈值默认 300 禁用 * - stale_event_threshold_seconds: 过滤旧事件阈值默认 300 禁用
* - card_format: 卡片格式化模式 "auto"默认| "always" | "never" * - card_format: 卡片格式化模式 "auto"默认| "always" | "never"
* auto: 根据内容自动检测always: 全部包卡片never: 全部纯文本降级/调试用 * auto: 根据内容自动检测always: 全部包卡片never: 全部纯文本降级/调试用
* - require_mention: 群聊中是否需要 @机器人 才响应默认 false
* true: 仅当消息中 @了机器人才处理通过飞书 mentions 字段精确判断无需配置 botPrefix
* *
* @author MateClaw Team * @author MateClaw Team
*/ */
@ -97,6 +99,9 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter {
/** 旧事件过滤默认阈值(秒):超过 30 秒的事件视为重连后回放 */ /** 旧事件过滤默认阈值(秒):超过 30 秒的事件视为重连后回放 */
private static final long DEFAULT_STALE_THRESHOLD_SECONDS = 30L; private static final long DEFAULT_STALE_THRESHOLD_SECONDS = 30L;
/** 机器人自身 open_id 缓存(用于 require_mention 精确判断,懒加载) */
private volatile String botOpenId;
public FeishuChannelAdapter(ChannelEntity channelEntity, public FeishuChannelAdapter(ChannelEntity channelEntity,
ChannelMessageRouter messageRouter, ChannelMessageRouter messageRouter,
ObjectMapper objectMapper) { ObjectMapper objectMapper) {
@ -397,7 +402,70 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter {
senderOpenId = sender.getSenderId().getOpenId(); 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<String, Object> 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<String> 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 管理 ==================== // ==================== 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) { } catch (Exception e) {
log.error("[feishu] Failed to handle webhook: {}", e.getMessage(), 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, private void handleFeishuMessage(String messageId, String messageType, String contentStr,
String chatId, String chatType, String senderOpenId, 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)) { if (messageId != null && !processedMessageIds.add(messageId)) {
log.debug("[feishu] Duplicate message_id: {}, skipping", 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); String shortSuffix = generateShortSessionSuffix(chatId, senderOpenId, isGroup);
ChannelMessage channelMessage = ChannelMessage.builder() ChannelMessage channelMessage = ChannelMessage.builder()

View File

@ -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<String, Object> 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<String, Object> webhookMention(String openId) {
return Map.of("id", Map.of("open_id", openId));
}
}