mateclaw/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuMentionTest.java
matevip fcc2dd5ccf fix(feishu): stop co-mentioned humans being learned as bot aliases
The mention alias-learning fed every identifier of every mention in a
delivery into the per-chat alias cache. A single delivery of "@bot @alice"
matched the bot by its global id and then learned alice's openId as a bot
alias, so every later "@alice" message was misdetected as @bot and the agent
replied to messages never addressed to it.

Only single-mention deliveries are unambiguous bot identities, so restrict
alias learning to them — a multi-mention delivery mixes the bot with
co-mentioned humans, and Feishu's dual-delivery alias form is itself a single
mention, so this is safe and keeps the learning feature working. Also cap the
per-chat alias set size. Adds a [bot, human] co-mention regression test.
2026-06-09 09:36:10 +08:00

274 lines
11 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package vip.mate.channel.feishu;
import com.fasterxml.jackson.databind.ObjectMapper;
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 vip.mate.channel.ChannelMessageRouter;
import vip.mate.channel.model.ChannelEntity;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.mock;
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, null));
}
@Test
void event_emptyMentions_returnsFalse() {
assertFalse(FeishuChannelAdapter.eventMentionsContainBot(new MentionEvent[0], BOT_ID, null));
}
@Test
void event_nullBotOpenId_returnsFalse() {
MentionEvent mention = mentionEvent(BOT_ID);
assertFalse(FeishuChannelAdapter.eventMentionsContainBot(new MentionEvent[]{mention}, null, null));
}
@Test
void event_botIsMentioned_returnsTrue() {
MentionEvent mention = mentionEvent(BOT_ID);
assertTrue(FeishuChannelAdapter.eventMentionsContainBot(new MentionEvent[]{mention}, BOT_ID, null));
}
@Test
void event_onlyOtherUserMentioned_returnsFalse() {
MentionEvent mention = mentionEvent(OTHER_ID);
assertFalse(FeishuChannelAdapter.eventMentionsContainBot(new MentionEvent[]{mention}, BOT_ID, null));
}
@Test
void event_botAmongMultipleMentions_returnsTrue() {
MentionEvent[] mentions = {mentionEvent(OTHER_ID), mentionEvent(BOT_ID)};
assertTrue(FeishuChannelAdapter.eventMentionsContainBot(mentions, BOT_ID, null));
}
@Test
void event_mentionWithNullId_skippedSafely() {
MentionEvent mention = MentionEvent.newBuilder().key("@_user_xxx").build(); // no id set
assertFalse(FeishuChannelAdapter.eventMentionsContainBot(new MentionEvent[]{mention}, BOT_ID, null));
}
// ==================== eventMentionsContainBot: 多 ID 体系 + name 命中 ====================
@Test
void event_matchByUnionId_returnsTrue() {
UserId id = UserId.newBuilder().unionId("on_union_777").build();
MentionEvent mention = MentionEvent.newBuilder().id(id).build();
assertTrue(FeishuChannelAdapter.eventMentionsContainBot(new MentionEvent[]{mention}, "on_union_777", null));
}
@Test
void event_matchByUserId_returnsTrue() {
UserId id = UserId.newBuilder().userId("u_555").build();
MentionEvent mention = MentionEvent.newBuilder().id(id).build();
assertTrue(FeishuChannelAdapter.eventMentionsContainBot(new MentionEvent[]{mention}, "u_555", null));
}
@Test
void event_matchByName_returnsTrue() {
// No matching id, but the mention name equals the bot's display name.
MentionEvent mention = MentionEvent.newBuilder().id(UserId.newBuilder().openId(OTHER_ID).build())
.name("MateBot").build();
assertTrue(FeishuChannelAdapter.eventMentionsContainBot(new MentionEvent[]{mention}, BOT_ID, "MateBot"));
}
@Test
void event_noIdNoNameMatch_returnsFalse() {
MentionEvent mention = MentionEvent.newBuilder().id(UserId.newBuilder().openId(OTHER_ID).build())
.name("Somebody").build();
assertFalse(FeishuChannelAdapter.eventMentionsContainBot(new MentionEvent[]{mention}, BOT_ID, "MateBot"));
}
// ==================== detectBotMentionWithLearning: 别名学习(有状态) ====================
@Test
void learning_aliasMissedBeforeLearn_thenHitsAfterDualDelivery() {
FeishuChannelAdapter adapter = newAdapter();
String alias = "ou_group_alias_001";
String chatA = "oc_chatA";
// 学习前群内别名单独到达botOpenId/botName 都匹配不上 → 漏检。
assertFalse(adapter.detectBotMentionWithLearning(
new MentionEvent[]{mentionEvent(alias)}, chatA, "m_pre", BOT_ID, null));
// 双投递:同一 messageId 先来别名(不匹配),后来全局身份(匹配)→ 聚合学习。
assertFalse(adapter.detectBotMentionWithLearning(
new MentionEvent[]{mentionEvent(alias)}, chatA, "m_dual", BOT_ID, null));
assertTrue(adapter.detectBotMentionWithLearning(
new MentionEvent[]{mentionEvent(BOT_ID)}, chatA, "m_dual", BOT_ID, null));
// 学习后:同群再次只收到别名 → 命中。
assertTrue(adapter.detectBotMentionWithLearning(
new MentionEvent[]{mentionEvent(alias)}, chatA, "m_post", BOT_ID, null));
}
@Test
void learning_aliasIsolatedPerChat() {
FeishuChannelAdapter adapter = newAdapter();
String alias = "ou_group_alias_001";
String chatA = "oc_chatA";
String chatB = "oc_chatB";
// 在 A 群学到别名。
adapter.detectBotMentionWithLearning(new MentionEvent[]{mentionEvent(alias)}, chatA, "m1", BOT_ID, null);
adapter.detectBotMentionWithLearning(new MentionEvent[]{mentionEvent(BOT_ID)}, chatA, "m1", BOT_ID, null);
assertTrue(adapter.detectBotMentionWithLearning(
new MentionEvent[]{mentionEvent(alias)}, chatA, "m2", BOT_ID, null));
// B 群从未学习该别名 → 不泄漏,仍漏检。
assertFalse(adapter.detectBotMentionWithLearning(
new MentionEvent[]{mentionEvent(alias)}, chatB, "m3", BOT_ID, null));
}
@Test
void learning_coMentionedHumanNotLearnedAsAlias() {
FeishuChannelAdapter adapter = newAdapter();
String human = "ou_human_alice";
String chatA = "oc_chatA";
// @bot @alice 在同一次投递bot 用全局身份命中(返回 true
// 但被同时 @ 的人不能被学成 bot 别名。
assertTrue(adapter.detectBotMentionWithLearning(
new MentionEvent[]{mentionEvent(BOT_ID), mentionEvent(human)}, chatA, "m_co", BOT_ID, null));
// 之后只 @ 那个人的消息,绝不能被误判为 @bot。
assertFalse(adapter.detectBotMentionWithLearning(
new MentionEvent[]{mentionEvent(human)}, chatA, "m_human", BOT_ID, null));
}
// ==================== mention tracker TTL ====================
@Test
void tracker_staleEntriesEvictedByTtl() {
Map<String, FeishuChannelAdapter.MentionTrack> tracker = new java.util.concurrent.ConcurrentHashMap<>();
FeishuChannelAdapter.MentionTrack track = new FeishuChannelAdapter.MentionTrack();
tracker.put("m1", track);
long ttl = 60_000L;
// now == createdAt → 未过期,保留。
FeishuChannelAdapter.evictStaleTracks(tracker, track.createdAtMs, ttl);
assertEquals(1, tracker.size());
// now 超过 createdAt + ttl → 过期,淘汰。
FeishuChannelAdapter.evictStaleTracks(tracker, track.createdAtMs + ttl + 1, ttl);
assertEquals(0, tracker.size());
}
// ==================== 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));
}
// ==================== 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 FeishuChannelAdapter newAdapter() {
ChannelEntity e = new ChannelEntity();
e.setId(1L);
e.setChannelType("feishu");
e.setConfigJson("{\"app_id\":\"x\",\"app_secret\":\"y\"}");
return new FeishuChannelAdapter(
e,
mock(ChannelMessageRouter.class),
new ObjectMapper(),
null, null, null, null, null, null,
null);
}
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));
}
}