mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
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:
parent
ee0c229f52
commit
af3e68d271
@ -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<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()
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user