Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
106 changes: 94 additions & 12 deletions platform/feishu/feishu.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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...),
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand All @@ -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).
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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())
Expand All @@ -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.
Expand All @@ -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)
Expand All @@ -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 != "" {
Expand All @@ -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).
Expand Down Expand Up @@ -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.
Expand Down
16 changes: 16 additions & 0 deletions platform/feishu/feishu_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading