From 74b2607e407141f2c1848dbf0c5bd0507dc3971a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=80=AA=E7=A8=8B=E4=BC=9F?= Date: Tue, 9 Jun 2026 09:01:37 +0800 Subject: [PATCH] =?UTF-8?q?feat(feishu):=20=E7=BE=A4=E8=81=8A=20@=E6=9C=BA?= =?UTF-8?q?=E5=99=A8=E4=BA=BA=20=E5=88=AB=E5=90=8D=E5=AD=A6=E4=B9=A0?= =?UTF-8?q?=EF=BC=8C=E4=BF=AE=E5=A4=8D=E9=83=A8=E5=88=86=20mention=20?= =?UTF-8?q?=E6=BC=8F=E6=A3=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mention 事件里 bot 的标识可能是 unionId/userId 或群内自定义别名, 仅用 botOpenId 直接比对会把确实被 @ 的消息判为「未 @我」。 - 拉取并缓存 botName(/bot/v3/info 的 app_name),mention 比对增加按 name 命中 - eventMentionsContainBot 对 openId/unionId/userId/name 做集合命中判断 - detectBotMentionWithLearning:双投递场景下机会性学习群内别名, 按群隔离写入 chatBotAliases[chatId],后续单事件投递即可命中 - mentionTracker(带 TTL)做短期相关性跟踪,cleanupMentionTracker 按 TTL 淘汰 仅影响群聊 mention 判定;私聊不变。不改会话 ID / 去重 / 日志级别。 Closes #298 --- .../channel/feishu/FeishuChannelAdapter.java | 178 +++++++++++++++++- .../channel/feishu/FeishuMentionTest.java | 121 +++++++++++- 2 files changed, 284 insertions(+), 15 deletions(-) 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 847e5290..f823644c 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 @@ -86,6 +86,32 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre /** 消息去重:最近处理过的 message_id */ private final Set processedMessageIds = ConcurrentHashMap.newKeySet(); + /** + * 群内 bot 别名缓存:chatId → 学到的别名集合(openId / unionId / userId / name)。 + *

飞书 SDK 投递的 mention 里,bot 的标识可能是群内自定义别名({@code ou_357e...} / 自定义名称), + * 而不是 {@code /bot/v3/info} 返回的全局 openId / app_name。我们在双投递场景下 + * 机会性地学习这些别名,后续单事件投递的消息就能命中缓存。 + */ + private final ConcurrentHashMap> chatBotAliases = new ConcurrentHashMap<>(); + + /** + * Per-messageId mention tracker(带 TTL)。 + *

飞书 SDK 经常对同一条消息双投递:一份 mentions 含 bot 的全局身份(来自 /bot/v3/info), + * 另一份含 bot 的群内别名。我们累积同一 messageId 下所有投递看到的 mention 标识, + * 一旦其中任何一份被识别为 @bot,就把累积的全部标识写入 {@link #chatBotAliases}。 + */ + private final ConcurrentHashMap mentionTracker = new ConcurrentHashMap<>(); + + /** mention tracker 条目 TTL(60s 远大于双投递的真实间隔,几个 ms 级别)。 */ + private static final long MENTION_TRACK_TTL_MS = 60_000L; + + /** Package-private for testing. */ + static final class MentionTrack { + final Set seenIds = ConcurrentHashMap.newKeySet(); + final long createdAtMs = System.currentTimeMillis(); + volatile boolean matched = false; + } + /** 昵称缓存:open_id → 显示名称 */ private final ConcurrentHashMap nicknameCache = new ConcurrentHashMap<>(); private static final int NICKNAME_CACHE_MAX = 500; @@ -114,6 +140,9 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre /** Bot's own open_id, fetched once from /open-apis/bot/v3/info and cached. */ private volatile String botOpenId; + /** Bot's display name (app_name), fetched alongside open_id. Used for name-based mention matching. */ + private volatile String botName; + /** Serializes lazy bot-open-id fetches so concurrent group messages share one API roundtrip. */ private final Object botOpenIdLock = new Object(); @@ -331,9 +360,12 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre // a torn write that could re-cache a stale id. synchronized (botOpenIdLock) { this.botOpenId = null; + this.botName = null; this.botOpenIdLastFailureMs = 0L; } this.processedMessageIds.clear(); + this.chatBotAliases.clear(); + this.mentionTracker.clear(); this.nicknameCache.clear(); this.quotedMessageCache.clear(); log.info("[feishu] Feishu channel stopped"); @@ -643,14 +675,102 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre senderOpenId = sender.getSenderId().getOpenId(); } - boolean isBotMentioned = isBotMentionedInEvent(message.getMentions()); + com.lark.oapi.service.im.v1.model.MentionEvent[] mentions = message.getMentions(); + boolean isBotMentioned = detectBotMentionWithLearning(mentions, chatId, messageId); 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()); + /** + * 判断本次事件是否 @ 了 bot,并机会性地学习"群内 bot 别名"。 + * + *

飞书 SDK 在群内对同一条 @bot 的消息会双投递两个事件,两次的 mentions 数据形态不同: + *

+ * 重启后第一条消息能命中"全局身份"那一份直接匹配;后续消息往往只来一份"群内别名"。 + * 本方法在双投递可见时把两份的所有标识聚合到 {@link #chatBotAliases},后续单事件投递就能命中缓存放行。 + * + *

识别顺序: + *

    + *
  1. 直接匹配 {@code /bot/v3/info} 拿到的 botOpenId / botName;
  2. + *
  3. 查 {@link #chatBotAliases} 缓存里学到的群内别名;
  4. + *
  5. 双投递推断:同一 messageId 之前的事件已被识别 → 本事件的 mentions 也是 bot 的别名。
  6. + *
+ */ + private boolean detectBotMentionWithLearning(com.lark.oapi.service.im.v1.model.MentionEvent[] mentions, + String chatId, String messageId) { + return detectBotMentionWithLearning(mentions, chatId, messageId, getBotOpenId(), botName); + } + + /** Package-private for testing: 纯有状态核心,bot 身份由调用方显式传入(避免触发 /bot/v3/info HTTP)。 */ + boolean detectBotMentionWithLearning(com.lark.oapi.service.im.v1.model.MentionEvent[] mentions, + String chatId, String messageId, + String botOpenId, String botName) { + if (mentions == null || mentions.length == 0) { + return false; + } + + cleanupMentionTracker(); + + // 把本次事件看到的所有标识累积到 per-messageId tracker —— 即使本次匹配不上, + // 后到的事件如果匹配成功,learnFromTrack 会把它们一起 cache。 + MentionTrack track = null; + if (messageId != null) { + track = mentionTracker.computeIfAbsent(messageId, k -> new MentionTrack()); + collectMentionIdentifiers(mentions, track.seenIds); + } + + // 1. 直接匹配 bot 的全局身份 + if (eventMentionsContainBot(mentions, botOpenId, botName)) { + learnFromTrack(chatId, track); + if (track != null) track.matched = true; + return true; + } + + // 2. 群内已学习别名命中 + if (chatId != null) { + Set learned = chatBotAliases.get(chatId); + if (learned != null && mentionMatchesAnyAlias(mentions, learned)) { + log.info("[feishu] @bot matched via learned chat alias: chatId={}, messageId={}", chatId, messageId); + learnFromTrack(chatId, track); + if (track != null) track.matched = true; + return true; + } + } + + // 3. 双投递学习:同 messageId 的另一次投递已被识别 → 本事件 mentions 是 bot 别名 + if (track != null && track.matched) { + log.info("[feishu] @bot inferred via dual-delivery learning: chatId={}, messageId={}", chatId, messageId); + learnFromTrack(chatId, track); + return true; + } + + return false; + } + + private void learnFromTrack(String chatId, MentionTrack track) { + if (chatId == null || track == null || track.seenIds.isEmpty()) return; + Set aliases = chatBotAliases.computeIfAbsent(chatId, k -> ConcurrentHashMap.newKeySet()); + int before = aliases.size(); + aliases.addAll(track.seenIds); + int added = aliases.size() - before; + if (added > 0) { + log.info("[feishu] Learned {} new bot alias(es) for chat={} (cache size={})", + added, chatId, aliases.size()); + } + } + + private void cleanupMentionTracker() { + evictStaleTracks(mentionTracker, System.currentTimeMillis(), MENTION_TRACK_TTL_MS); + } + + /** Package-private for testing: 按 TTL 淘汰 mention tracker 中的过期项({@code nowMs} 显式传入便于测试)。 */ + static void evictStaleTracks(Map tracker, long nowMs, long ttlMs) { + long cutoff = nowMs - ttlMs; + tracker.entrySet().removeIf(e -> e.getValue().createdAtMs < cutoff); } private boolean isBotMentionedInWebhookMessage(Map message) { @@ -659,12 +779,51 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre return webhookMentionsContainBot(list, getBotOpenId()); } - /** Package-private for testing: 判断 SDK mentions 数组中是否包含指定 open_id */ + /** Package-private for testing: 判断 SDK mentions 数组中是否包含指定 bot(按 openId / unionId / userId / name 命中) */ static boolean eventMentionsContainBot(com.lark.oapi.service.im.v1.model.MentionEvent[] mentions, - String botOpenId) { - if (mentions == null || mentions.length == 0 || botOpenId == null) return false; + String botOpenId, String botName) { + if (mentions == null || mentions.length == 0) return false; for (var mention : mentions) { - if (mention.getId() != null && botOpenId.equals(mention.getId().getOpenId())) return true; + var id = mention.getId(); + // 匹配 openId、unionId、userId 中的任意一个 + if (id != null && botOpenId != null) { + if (botOpenId.equals(id.getOpenId())) return true; + if (botOpenId.equals(id.getUnionId())) return true; + if (botOpenId.equals(id.getUserId())) return true; + } + // 飞书 SDK 对 bot mention 可能使用不同 ID 体系,fallback 到 name 匹配 + if (botName != null && botName.equals(mention.getName())) return true; + } + return false; + } + + /** Package-private for testing: 把 mentions 中每个非空 openId / unionId / userId / name 灌入 sink。 */ + static void collectMentionIdentifiers(com.lark.oapi.service.im.v1.model.MentionEvent[] mentions, + Set sink) { + if (mentions == null || sink == null) return; + for (var mention : mentions) { + var id = mention.getId(); + if (id != null) { + if (id.getOpenId() != null) sink.add(id.getOpenId()); + if (id.getUnionId() != null) sink.add(id.getUnionId()); + if (id.getUserId() != null) sink.add(id.getUserId()); + } + if (mention.getName() != null) sink.add(mention.getName()); + } + } + + /** Package-private for testing: mentions 中是否有任意 openId / unionId / userId / name 命中 aliases 集合。 */ + static boolean mentionMatchesAnyAlias(com.lark.oapi.service.im.v1.model.MentionEvent[] mentions, + Set aliases) { + if (mentions == null || mentions.length == 0 || aliases == null || aliases.isEmpty()) return false; + for (var mention : mentions) { + var id = mention.getId(); + if (id != null) { + if (id.getOpenId() != null && aliases.contains(id.getOpenId())) return true; + if (id.getUnionId() != null && aliases.contains(id.getUnionId())) return true; + if (id.getUserId() != null && aliases.contains(id.getUserId())) return true; + } + if (mention.getName() != null && aliases.contains(mention.getName())) return true; } return false; } @@ -734,7 +893,10 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre Map bot = (Map) body.get("bot"); if (bot != null && bot.get("open_id") instanceof String openId && !openId.isBlank()) { botOpenId = openId; - log.info("[feishu] Bot open_id fetched and cached: {}", openId); + if (bot.get("app_name") instanceof String name && !name.isBlank()) { + botName = name; + } + log.info("[feishu] Bot info fetched: open_id={}, name={}", openId, botName); return openId; } // 2xx with no bot.open_id field → treat as transient failure. 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 53d03551..ab8fd4de 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 @@ -1,13 +1,17 @@ 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 { @@ -18,42 +22,132 @@ class FeishuMentionTest { @Test void event_nullMentions_returnsFalse() { - assertFalse(FeishuChannelAdapter.eventMentionsContainBot(null, BOT_ID)); + assertFalse(FeishuChannelAdapter.eventMentionsContainBot(null, BOT_ID, null)); } @Test void event_emptyMentions_returnsFalse() { - assertFalse(FeishuChannelAdapter.eventMentionsContainBot(new MentionEvent[0], BOT_ID)); + 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)); + 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)); + 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)); + 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)); + 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)); + 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)); + } + + // ==================== mention tracker TTL ==================== + + @Test + void tracker_staleEntriesEvictedByTtl() { + Map 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 ==================== @@ -139,6 +233,19 @@ class FeishuMentionTest { // ==================== 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();