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 禁用
* - 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<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 管理 ====================
@ -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()

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));
}
}