From cb9b9bfb633b6a37cdb0731102e7294d13bb37a3 Mon Sep 17 00:00:00 2001 From: luoye Date: Thu, 25 Jun 2026 17:52:56 +0800 Subject: [PATCH] feat(feishu): add group thread isolation lists --- config.example.toml | 4 + platform/feishu/feishu.go | 106 ++++++++++++++++--- platform/feishu/feishu_test.go | 16 +++ platform/feishu/platform_test.go | 170 +++++++++++++++++++++++++++++++ 4 files changed, 284 insertions(+), 12 deletions(-) diff --git a/config.example.toml b/config.example.toml index 51680145c..6717a2656 100644 --- a/config.example.toml +++ b/config.example.toml @@ -1005,6 +1005,10 @@ app_secret = "your-feishu-app-secret" # # 设为 true 时,群聊内所有用户共享同一个 Agent 会话 # thread_isolation = false # If true, group messages use Feishu reply threads as session boundaries (one thread/root = one agent session) # # 设为 true 时,群聊按飞书话题/根消息隔离会话(一个 thread/root 对应一个 Agent 会话) +# thread_isolation_black_group = "" # When thread_isolation=true, comma-separated group chat_ids that should keep normal replies +# # thread_isolation=true 时,逗号分隔的群聊 chat_id 黑名单;命中后仍使用普通回复 +# thread_isolation_white_group = "" # When thread_isolation=false, comma-separated group chat_ids that should use threaded replies +# # thread_isolation=false 时,逗号分隔的群聊 chat_id 白名单;命中后使用话题回复 # reply_to_trigger = true # Default: bot uses “reply to message” API (quotes the user’s message). Set false to post plain chat messages instead. # # 默认 true:用「回复消息」API(引用用户消息)。设为 false 则在会话里发普通消息、不引用触发消息。 # progress_style = "legacy" # Progress rendering style: legacy | compact | card diff --git a/platform/feishu/feishu.go b/platform/feishu/feishu.go index 44fcac457..18ab1c76c 100644 --- a/platform/feishu/feishu.go +++ b/platform/feishu/feishu.go @@ -107,9 +107,10 @@ func init() { } type replyContext struct { - messageID string - chatID string - sessionKey string + messageID string + chatID string + sessionKey string + nativeThreadMsg bool } type Platform struct { @@ -130,6 +131,8 @@ type Platform struct { respondToAtEveryoneAndHere bool shareSessionInChannel bool threadIsolation bool + threadIsolationBlackGroup string + threadIsolationWhiteGroup string // noReplyToTrigger: when true, send via Create instead of Im.Message.Reply (no quote to the user's message). noReplyToTrigger bool resolveMentions bool @@ -168,6 +171,7 @@ type Platform struct { // without requiring another @bot mention. Value is the last-seen time so // stale entries can be expired by a future TTL sweep if needed. activeThreadSessions sync.Map // sessionKey -> time.Time + nativeThreadSessions sync.Map // sessionKey -> time.Time richCardImageMu sync.Mutex richCardImageResolved map[string]string @@ -307,6 +311,8 @@ func newPlatform(name, domain string, opts map[string]any) (core.Platform, error respondToAtEveryoneAndHere, _ := opts["respond_to_at_everyone_and_here"].(bool) shareSessionInChannel, _ := opts["share_session_in_channel"].(bool) threadIsolation, _ := opts["thread_isolation"].(bool) + threadIsolationBlackGroup, _ := opts["thread_isolation_black_group"].(string) + threadIsolationWhiteGroup, _ := opts["thread_isolation_white_group"].(string) resolveMentionsOpt, _ := opts["resolve_mentions"].(bool) noReplyToTrigger := false if v, ok := opts["reply_to_trigger"].(bool); ok && !v { @@ -398,6 +404,8 @@ func newPlatform(name, domain string, opts map[string]any) (core.Platform, error respondToAtEveryoneAndHere: respondToAtEveryoneAndHere, shareSessionInChannel: shareSessionInChannel, threadIsolation: threadIsolation, + threadIsolationBlackGroup: threadIsolationBlackGroup, + threadIsolationWhiteGroup: threadIsolationWhiteGroup, resolveMentions: resolveMentionsOpt, noReplyToTrigger: noReplyToTrigger, client: lark.NewClient(appID, appSecret, clientOpts...), @@ -1337,7 +1345,7 @@ func (p *Platform) onMessage(ctx context.Context, event *larkim.P2MessageReceive // through without re-mentioning the bot. Plain text and rich-text // posts still require an explicit @bot to avoid pulling in // unrelated chatter. - case p.threadIsolation && isAttachmentMsgType(msgType) && p.isActiveThreadSession(sessionKey): + case isAttachmentMsgType(msgType) && p.isActiveThreadSession(sessionKey): slog.Debug(p.tag()+": passing attachment through active thread without mention", "chat_id", chatID, "session_key", sessionKey, "msg_type", msgType, "message_id", messageID) default: @@ -1375,7 +1383,8 @@ func (p *Platform) onMessage(ctx context.Context, event *larkim.P2MessageReceive mentions := msg.Mentions parentID := stringValue(msg.ParentId) - rctx := replyContext{messageID: messageID, chatID: chatID, sessionKey: sessionKey} + rctx := replyContext{messageID: messageID, chatID: chatID, sessionKey: sessionKey, nativeThreadMsg: isNativeThreadMessage(msg)} + p.markNativeThreadSession(sessionKey, rctx.nativeThreadMsg) slog.Debug(p.tag()+": routed inbound message", "message_id", messageID, "session_key", sessionKey, @@ -1384,7 +1393,7 @@ func (p *Platform) onMessage(ctx context.Context, event *larkim.P2MessageReceive // Mark this thread as bot-engaged so subsequent attachment-only messages // in the same thread can pass through without re-mentioning the bot. - p.markThreadSessionActive(sessionKey) + p.markThreadSessionActiveForContext(sessionKey, rctx.nativeThreadMsg) // Dispatch message handling asynchronously so the SDK event loop is not // blocked by IO-heavy operations (image/audio download, handler HTTP calls). @@ -1426,7 +1435,7 @@ func (p *Platform) dispatchMessage(ctx context.Context, msgType, content string, // inside a thread — the thread already provides conversational context, and // long quoted prefixes can drown out the user's actual text (issue #764). var quoted quotedMessage - if parentID != "" && !(p.threadIsolation && isThreadSessionKey(sessionKey)) { + if parentID != "" && !p.shouldUseThreadContext(sessionKey) { quoted = p.fetchQuotedMessage(ctx, parentID) } @@ -3302,9 +3311,16 @@ func isAttachmentMsgType(msgType string) bool { // markThreadSessionActive records that a thread sessionKey has been engaged // by an @bot message, enabling attachment-only follow-ups inside the thread. -// No-op when thread isolation is disabled or sessionKey is not a thread key. +// No-op when this chat should not use thread isolation or sessionKey is not a thread key. func (p *Platform) markThreadSessionActive(sessionKey string) { - if !p.threadIsolation || !isThreadSessionKey(sessionKey) { + p.markThreadSessionActiveForContext(sessionKey, false) +} + +func (p *Platform) markThreadSessionActiveForContext(sessionKey string, nativeThreadMsg bool) { + if !nativeThreadMsg && !p.shouldTreatSessionAsThread(sessionKey) { + return + } + if !isThreadSessionKey(sessionKey) { return } p.activeThreadSessions.Store(sessionKey, time.Now()) @@ -3313,13 +3329,28 @@ func (p *Platform) markThreadSessionActive(sessionKey string) { // isActiveThreadSession reports whether the given sessionKey corresponds to a // thread that has previously been engaged by an @bot message. func (p *Platform) isActiveThreadSession(sessionKey string) bool { - if !p.threadIsolation || !isThreadSessionKey(sessionKey) { + if !p.shouldTreatSessionAsThread(sessionKey) && !p.isNativeThreadSession(sessionKey) { return false } _, ok := p.activeThreadSessions.Load(sessionKey) return ok } +func (p *Platform) markNativeThreadSession(sessionKey string, nativeThreadMsg bool) { + if !nativeThreadMsg || !isThreadSessionKey(sessionKey) { + return + } + p.nativeThreadSessions.Store(sessionKey, time.Now()) +} + +func (p *Platform) isNativeThreadSession(sessionKey string) bool { + if !isThreadSessionKey(sessionKey) { + return false + } + _, ok := p.nativeThreadSessions.Load(sessionKey) + return ok +} + // stripMentions processes @mention placeholders (e.g. @_user_1) in text. // The bot's own mention is removed; other user mentions are replaced with // their display name so the agent can see who was referenced. @@ -3345,7 +3376,7 @@ func stripMentions(text string, mentions []*larkim.MentionEvent, botOpenID strin // TODO: Session-key derivation and reply-thread behavior are split across multiple code paths here. // Should revisit thread/root handling without changing thread_isolation=false behavior. func (p *Platform) makeSessionKey(msg *larkim.EventMessage, chatID, userID string) string { - if p.threadIsolation && msg != nil && stringValue(msg.ChatType) == "group" { + if msg != nil && stringValue(msg.ChatType) == "group" && p.shouldUseThreadIsolationForMessage(msg, chatID) { rootID := stringValue(msg.RootId) if rootID == "" { rootID = stringValue(msg.MessageId) @@ -3360,6 +3391,49 @@ func (p *Platform) makeSessionKey(msg *larkim.EventMessage, chatID, userID strin return fmt.Sprintf("%s:%s:%s", p.tag(), chatID, userID) } +func (p *Platform) shouldUseThreadIsolationForMessage(msg *larkim.EventMessage, chatID string) bool { + if msg == nil || stringValue(msg.ChatType) != "group" { + return false + } + if isNativeThreadMessage(msg) { + return true + } + return p.shouldUseThreadIsolationForChat(chatID) +} + +func isNativeThreadMessage(msg *larkim.EventMessage) bool { + if msg == nil { + return false + } + return stringValue(msg.ThreadId) != "" || stringValue(msg.RootId) != "" +} + +func (p *Platform) shouldUseThreadIsolationForChat(chatID string) bool { + if p.threadIsolation { + return !configuredListContains(p.threadIsolationBlackGroup, chatID) + } + return configuredListContains(p.threadIsolationWhiteGroup, chatID) +} + +func configuredListContains(list, value string) bool { + return strings.TrimSpace(list) != "" && core.AllowList(list, value) +} + +func (p *Platform) shouldTreatSessionAsThread(sessionKey string) bool { + if !isThreadSessionKey(sessionKey) { + return false + } + chatID, ok := chatIDFromSessionKey(sessionKey) + if !ok { + return false + } + return p.shouldUseThreadIsolationForChat(chatID) +} + +func (p *Platform) shouldUseThreadContext(sessionKey string) bool { + return p.isNativeThreadSession(sessionKey) || p.shouldTreatSessionAsThread(sessionKey) +} + func (p *Platform) sessionKeyFromCardAction(chatID, userID string, value map[string]any) string { if value != nil { if sessionKey, _ := value["session_key"].(string); sessionKey != "" { @@ -3376,7 +3450,7 @@ func (p *Platform) shouldReplyInThread(rc replyContext) bool { if rc.messageID == "" { return false } - return p.threadIsolation && isThreadSessionKey(rc.sessionKey) + return rc.nativeThreadMsg || p.shouldUseThreadContext(rc.sessionKey) } // shouldUseThreadOrReplyAPI is true when we should call Im.Message.Reply (optionally with ReplyInThread). @@ -3635,6 +3709,14 @@ func isThreadSessionKey(sessionKey string) bool { return ok } +func chatIDFromSessionKey(sessionKey string) (string, bool) { + parts := strings.SplitN(sessionKey, ":", 3) + if len(parts) < 2 || parts[1] == "" { + return "", false + } + return parts[1], true +} + // feishuPreviewHandle stores the message ID for an editable preview message. // Card 2.0 path needs mu/status/lastContent to let SetPreviewStatus patch // the header color without re-rendering the whole card. diff --git a/platform/feishu/feishu_test.go b/platform/feishu/feishu_test.go index 5c4b3e700..782d64827 100644 --- a/platform/feishu/feishu_test.go +++ b/platform/feishu/feishu_test.go @@ -1060,6 +1060,22 @@ func TestMarkAndIsActiveThreadSession(t *testing.T) { t.Fatal("thread should be active after mark") } }) + + t.Run("thread isolation enabled but blacklisted group is no-op", func(t *testing.T) { + p := &Platform{threadIsolation: true, threadIsolationBlackGroup: "oc_chat"} + p.markThreadSessionActive(threadKey) + if p.isActiveThreadSession(threadKey) { + t.Fatal("blacklisted group should not record active thread session") + } + }) + + t.Run("thread isolation disabled but whitelisted group records thread", func(t *testing.T) { + p := &Platform{threadIsolationWhiteGroup: "oc_chat"} + p.markThreadSessionActive(threadKey) + if !p.isActiveThreadSession(threadKey) { + t.Fatal("whitelisted group should record active thread session") + } + }) } // TestOnMessageThreadIsolationAdmitsAttachmentWithoutMention covers the fix diff --git a/platform/feishu/platform_test.go b/platform/feishu/platform_test.go index 7927284f9..8b04528b0 100644 --- a/platform/feishu/platform_test.go +++ b/platform/feishu/platform_test.go @@ -648,6 +648,26 @@ func TestNewFeishu_InvalidCustomDomain(t *testing.T) { } } +func TestNewFeishu_ThreadIsolationGroupLists(t *testing.T) { + pAny, err := New(map[string]any{ + "app_id": "cli_xxx", + "app_secret": "secret", + "thread_isolation_black_group": "oc_plain_a, oc_plain_b", + "thread_isolation_white_group": "oc_thread_a, oc_thread_b", + "enable_feishu_card": false, + }) + if err != nil { + t.Fatalf("New() error = %v", err) + } + p := pAny.(*Platform) + if p.threadIsolationBlackGroup != "oc_plain_a, oc_plain_b" { + t.Fatalf("threadIsolationBlackGroup = %q", p.threadIsolationBlackGroup) + } + if p.threadIsolationWhiteGroup != "oc_thread_a, oc_thread_b" { + t.Fatalf("threadIsolationWhiteGroup = %q", p.threadIsolationWhiteGroup) + } +} + func TestLark_SessionKeyPrefix(t *testing.T) { p, err := newPlatform("lark", lark.LarkBaseUrl, map[string]any{ "app_id": "cli_xxx", "app_secret": "secret", "enable_feishu_card": true, @@ -827,6 +847,122 @@ func TestLark_GroupReplyAllWithThreadIsolationUsesRootSessionKeyWithoutMention(t } } +func TestFeishu_MakeSessionKeyThreadIsolationGroupLists(t *testing.T) { + chatID := "oc_chat" + userID := "ou_user" + messageID := "om_msg" + rootID := "om_root" + threadID := "omt_thread" + + tests := []struct { + name string + p *Platform + msg *larkim.EventMessage + want string + }{ + { + name: "global on regular group uses thread key", + p: &Platform{platformName: "feishu", threadIsolation: true}, + msg: &larkim.EventMessage{ + MessageId: &messageID, + ChatType: stringPtr("group"), + }, + want: "feishu:oc_chat:root:om_msg", + }, + { + name: "global on blacklist regular group uses direct key", + p: &Platform{ + platformName: "feishu", + threadIsolation: true, + threadIsolationBlackGroup: "oc_chat", + threadIsolationWhiteGroup: "oc_other", + shareSessionInChannel: false, + }, + msg: &larkim.EventMessage{ + MessageId: &messageID, + ChatType: stringPtr("group"), + }, + want: "feishu:oc_chat:ou_user", + }, + { + name: "global off whitelist regular group uses thread key", + p: &Platform{ + platformName: "feishu", + threadIsolation: false, + threadIsolationWhiteGroup: "oc_chat", + }, + msg: &larkim.EventMessage{ + MessageId: &messageID, + ChatType: stringPtr("group"), + }, + want: "feishu:oc_chat:root:om_msg", + }, + { + name: "global off non-whitelist regular group uses direct key", + p: &Platform{ + platformName: "feishu", + threadIsolation: false, + threadIsolationWhiteGroup: "oc_other", + }, + msg: &larkim.EventMessage{ + MessageId: &messageID, + ChatType: stringPtr("group"), + }, + want: "feishu:oc_chat:ou_user", + }, + { + name: "native topic uses thread key even when global off and not whitelisted", + p: &Platform{ + platformName: "feishu", + threadIsolation: false, + threadIsolationWhiteGroup: "oc_other", + }, + msg: &larkim.EventMessage{ + MessageId: &messageID, + RootId: &rootID, + ThreadId: &threadID, + ChatType: stringPtr("group"), + }, + want: "feishu:oc_chat:root:om_root", + }, + { + name: "private chat unaffected by whitelist", + p: &Platform{ + platformName: "feishu", + threadIsolation: false, + threadIsolationWhiteGroup: "oc_chat", + }, + msg: &larkim.EventMessage{ + MessageId: &messageID, + ChatType: stringPtr("p2p"), + }, + want: "feishu:oc_chat:ou_user", + }, + { + name: "blacklisted regular group still honors shared channel session", + p: &Platform{ + platformName: "feishu", + threadIsolation: true, + threadIsolationBlackGroup: "oc_chat", + shareSessionInChannel: true, + }, + msg: &larkim.EventMessage{ + MessageId: &messageID, + ChatType: stringPtr("group"), + }, + want: "feishu:oc_chat", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.p.makeSessionKey(tt.msg, chatID, userID); got != tt.want { + t.Fatalf("makeSessionKey() = %q, want %q", got, tt.want) + } + }) + } +} + func TestBuildReplyMessageReqBody_SetsReplyInThreadFlag(t *testing.T) { tests := []struct { name string @@ -840,6 +976,40 @@ func TestBuildReplyMessageReqBody_SetsReplyInThreadFlag(t *testing.T) { replyCtx: replyContext{messageID: "om_reply", sessionKey: "feishu:oc_chat:root:om_root"}, wantThreading: true, }, + { + name: "thread isolation enabled but blacklisted chat remains non-threaded", + platform: &Platform{ + threadIsolation: true, + threadIsolationBlackGroup: "oc_chat", + }, + replyCtx: replyContext{messageID: "om_reply", chatID: "oc_chat", sessionKey: "feishu:oc_chat:root:om_root"}, + wantThreading: false, + }, + { + name: "thread isolation disabled but whitelisted chat replies in thread", + platform: &Platform{ + threadIsolation: false, + threadIsolationWhiteGroup: "oc_chat", + }, + replyCtx: replyContext{messageID: "om_reply", chatID: "oc_chat", sessionKey: "feishu:oc_chat:root:om_root"}, + wantThreading: true, + }, + { + name: "native topic replies in thread even when global isolation is off", + platform: &Platform{threadIsolation: false}, + replyCtx: replyContext{messageID: "om_reply", chatID: "oc_chat", sessionKey: "feishu:oc_chat:root:om_root", nativeThreadMsg: true}, + wantThreading: true, + }, + { + name: "previously seen native topic replies in thread after context reconstruction", + platform: func() *Platform { + p := &Platform{threadIsolation: false} + p.markNativeThreadSession("feishu:oc_chat:root:om_root", true) + return p + }(), + replyCtx: replyContext{messageID: "om_root", chatID: "oc_chat", sessionKey: "feishu:oc_chat:root:om_root"}, + wantThreading: true, + }, { name: "thread isolation does not affect p2p session", platform: &Platform{threadIsolation: true},