fix(feishu): gate group pairing by target bot (#1291)

* fix(feishu): require exact bot_open_id match for mention detection

- Previously, when bot_open_id was empty, ALL mentions were treated as bot mentions
- This caused multiple agents in same group to all respond to any mention
- Now requires bot_open_id to be set AND match exactly for mentionedBot=true
- Fixes issue where CPPAI PM would respond even when other agent was mentioned

* fix(feishu): gate group pairing by target bot

---------

Co-authored-by: ntduc <ntduc@cpp.ai.vn>
This commit is contained in:
Duc Nguyenandntduc authored and GitHub committed 2026-06-28 00:25:49 +07:00
1 parent b72959b34c
commit d460c90403
8 files changed
+728 -56

No files matched your search

+30
View File
@@ -4,6 +4,36 @@ Significant changes, features, and fixes in reverse chronological order.
---
## 2026-06-27
### Feishu/Lark group pairing stability
**Fixes**
- Group pairing now only runs after the target bot is actually mentioned, so
other agents in the same Lark group stay silent and do not create pair codes.
- Feishu group messages with explicit mentions that do not match the current
bot are now dropped before media resolution, policy checks, pairing, or agent
routing, even when `require_mention` is disabled for the channel.
- Feishu WebSocket/webhook message events are now guarded by `header.app_id`
before dispatching to a channel instance, preventing app-routing mismatch from
reaching pairing logic.
- Feishu now re-checks pairing state immediately before sending a pair code,
preventing stale policy decisions from creating duplicate requests.
- Feishu pairing and bot identity logs now include channel/chat/tenant context so
multi-agent Lark groups can be diagnosed without inferring from log order.
**Tests**
- Added Feishu regressions for non-target mentions, target mentions, and unknown
bot identity fail-closed behavior.
- Added Feishu WebSocket regressions for app-id mismatch vs match handling.
- Added Feishu regression for non-target mentions when `require_mention` is
disabled.
- Added Feishu regression coverage for already-paired group mentions.
---
## 2026-06-13
### Cron NO_REPLY delivery suppression (issue #141)
+107 -29
View File
@@ -16,33 +16,43 @@ import (
// messageContext holds parsed information from a Feishu message event.
type messageContext struct {
ChatID string
MessageID string
SenderID string // sender_id.open_id
ChatType string // "p2p" or "group"
Content string
ContentType string // "text", "post", "image", etc.
ChatID string
MessageID string
SenderID string // sender_id.open_id
ChatType string // "p2p" or "group"
Content string
ContentType string // "text", "post", "image", etc.
MentionedBot bool
RootID string // reply-chain root (populated on ANY reply, incl. plain quote reply)
ParentID string // direct parent in reply chain
ThreadID string // set ONLY when message is inside an actual topic thread
Mentions []mentionInfo
RootID string // reply-chain root (populated on ANY reply, incl. plain quote reply)
ParentID string // direct parent in reply chain
ThreadID string // set ONLY when message is inside an actual topic thread
Mentions []mentionInfo
}
type mentionInfo struct {
Key string // @_user_N placeholder
OpenID string
Name string
Key string // @_user_N placeholder
OpenID string
UserID string
UnionID string
Name string
TenantKey string
}
// handleMessageEvent processes an incoming Feishu message event.
func (c *Channel) handleMessageEvent(ctx context.Context, event *MessageEvent) {
c.handleMessageEventFrom(ctx, event, "direct")
}
func (c *Channel) handleMessageEventFrom(ctx context.Context, event *MessageEvent, source string) {
// Inject tenant scope so store queries filter by the correct tenant_id.
ctx = store.WithTenantID(ctx, c.TenantID())
if event == nil {
return
}
if !c.shouldProcessMessageEvent(event, source) {
return
}
msg := &event.Event.Message
sender := &event.Event.Sender
@@ -63,6 +73,7 @@ func (c *Channel) handleMessageEvent(ctx context.Context, event *MessageEvent) {
if mc == nil {
return
}
c.logParsedMessage(event, mc, source)
// 2a. Slash commands in DMs are rejected early with a clear hint so
// they never reach the agent pipeline (otherwise users typing
@@ -73,6 +84,21 @@ func (c *Channel) handleMessageEvent(ctx context.Context, event *MessageEvent) {
return
}
if mc.ChatType == "group" && len(mc.Mentions) > 0 && !mc.MentionedBot {
slog.Info("feishu group message skipped; explicit mention is not this bot",
"source", source,
"decision", "skip_non_target_mention",
"channel", c.Name(),
"event_id", event.Header.EventID,
"message_id", mc.MessageID,
"chat_id", mc.ChatID,
"sender_id", mc.SenderID,
"bot_open_id", c.botOpenID,
"mention_count", len(mc.Mentions),
)
return
}
// 3. Resolve sender name (cached)
senderName := c.resolveSenderName(ctx, mc.SenderID)
@@ -96,24 +122,45 @@ func (c *Channel) handleMessageEvent(ctx context.Context, event *MessageEvent) {
// 5. Group policy
if mc.ChatType == "group" {
if !c.checkGroupPolicy(ctx, mc.SenderID, mc.ChatID) {
slog.Debug("feishu group message rejected by policy", "sender_id", mc.SenderID, "chat_id", mc.ChatID)
return
}
isWriterCommand := c.isWriterSlashCommand(mc)
// 5a. Writer management slash commands run AFTER the group policy
// gate so commands cannot bypass allowlists or pairing. Commands
// short-circuit the agent pipeline to avoid consuming LLM tokens.
if c.maybeHandleWriterCommand(ctx, mc) {
return
}
// 6. RequireMention check — record to history if not mentioned
// 5a. RequireMention check runs before pairing for normal messages so
// non-target bots stay silent in multi-agent groups. Do not create a
// pairing request for messages that did not mention this bot.
requireMention := true
if c.cfg.RequireMention != nil {
requireMention = *c.cfg.RequireMention
}
if requireMention && !mc.MentionedBot {
slog.Debug("feishu group mention gate",
"source", source,
"decision", "evaluate_group_gate",
"channel", c.Name(),
"event_id", event.Header.EventID,
"message_id", mc.MessageID,
"chat_id", mc.ChatID,
"sender_id", mc.SenderID,
"bot_open_id", c.botOpenID,
"mentioned_bot", mc.MentionedBot,
"mention_count", len(mc.Mentions),
"mentions", formatMentionInfos(mc.Mentions),
"require_mention", requireMention,
"group_policy", c.cfg.GroupPolicy,
"is_writer_command", isWriterCommand,
)
if !isWriterCommand && requireMention && !mc.MentionedBot {
if !c.canRecordUnmentionedGroupMessage(ctx, mc.SenderID, mc.ChatID) {
slog.Debug("feishu group message skipped; no bot mention and policy not approved",
"source", source,
"decision", "skip_no_mention_unapproved",
"channel", c.Name(),
"event_id", event.Header.EventID,
"message_id", mc.MessageID,
"chat_id", mc.ChatID,
"sender_id", mc.SenderID,
)
return
}
historyKey := mc.ChatID
if mc.RootID != "" && c.cfg.TopicSessionMode == "enabled" {
historyKey = fmt.Sprintf("%s:topic:%s", mc.ChatID, mc.RootID)
@@ -132,11 +179,37 @@ func (c *Channel) handleMessageEvent(ctx context.Context, event *MessageEvent) {
cc.EnsureContact(ctx, c.Type(), c.Name(), mc.SenderID, mc.SenderID, senderName, "", "group", "user", "", "")
}
slog.Debug("feishu group message recorded (no mention)",
"chat_id", mc.ChatID, "sender", senderName,
slog.Debug("feishu group message recorded without bot mention",
"source", source,
"decision", "record_no_mention_history",
"channel", c.Name(),
"event_id", event.Header.EventID,
"message_id", mc.MessageID,
"chat_id", mc.ChatID,
"sender", senderName,
)
return
}
if !c.checkGroupPolicy(ctx, mc.SenderID, mc.ChatID) {
slog.Debug("feishu group message rejected by policy",
"source", source,
"decision", "reject_group_policy",
"channel", c.Name(),
"event_id", event.Header.EventID,
"message_id", mc.MessageID,
"sender_id", mc.SenderID,
"chat_id", mc.ChatID,
)
return
}
// 5b. Writer management slash commands run AFTER the group policy
// gate so commands cannot bypass allowlists or pairing. Commands
// short-circuit the agent pipeline to avoid consuming LLM tokens.
if isWriterCommand && c.maybeHandleWriterCommand(ctx, mc) {
return
}
}
// 6. DM policy (pairing flow)
@@ -179,7 +252,12 @@ func (c *Channel) handleMessageEvent(ctx context.Context, event *MessageEvent) {
chatID = fmt.Sprintf("%s:topic:%s", mc.ChatID, mc.RootID)
}
slog.Debug("feishu message received",
slog.Debug("feishu message accepted",
"source", source,
"decision", "publish_inbound",
"channel", c.Name(),
"event_id", event.Header.EventID,
"message_id", mc.MessageID,
"sender_id", mc.SenderID,
"sender_name", senderName,
"chat_id", chatID,
+21 -7
View File
@@ -31,16 +31,18 @@ func (c *Channel) parseMessageEvent(event *MessageEvent) *messageContext {
mentionedBot := false
for _, m := range msg.Mentions {
mi := mentionInfo{
Key: m.Key,
OpenID: m.ID.OpenID,
Name: m.Name,
Key: m.Key,
OpenID: m.ID.OpenID,
UserID: m.ID.UserID,
UnionID: m.ID.UnionID,
Name: m.Name,
TenantKey: m.TenantKey,
}
mentions = append(mentions, mi)
// Check if bot is mentioned.
// If botOpenID is known, match exactly; otherwise treat any mention as bot mention
// (fallback when probeBotInfo fails — better to process than silently drop).
if c.botOpenID == "" || mi.OpenID == c.botOpenID {
// Check if this bot is mentioned. Unknown bot identity must fail closed
// so multi-agent groups do not all respond to another bot's mention.
if c.botOpenID != "" && mi.OpenID == c.botOpenID {
mentionedBot = true
}
}
@@ -252,3 +254,15 @@ func resolveMentions(text string, mentions []mentionInfo, botOpenID string) stri
}
return strings.TrimSpace(text)
}
func formatMentionInfos(mentions []mentionInfo) []string {
if len(mentions) == 0 {
return nil
}
out := make([]string, 0, len(mentions))
for _, m := range mentions {
out = append(out, fmt.Sprintf("key=%s open_id=%s user_id=%s union_id=%s name=%s tenant=%s",
m.Key, m.OpenID, m.UserID, m.UnionID, m.Name, m.TenantKey))
}
return out
}
+27 -7
View File
@@ -8,9 +8,9 @@ import (
func TestParseMessageContent_Text(t *testing.T) {
cases := []struct {
name string
raw string
want string
name string
raw string
want string
}{
{"normal text", `{"text":"hello world"}`, "hello world"},
{"empty raw", "", ""},
@@ -214,10 +214,8 @@ func TestResolveMentions_NoBotID_AllMentionsReplacedWithName(t *testing.T) {
{Key: "@_user_2", OpenID: "ou_B", Name: "Bob"},
}
got := resolveMentions(text, mentions, "")
// botOpenID is empty: all mentions treated as bot → stripped
// (because the condition is: if botOpenID != "" && openID == botOpenID → strip, else → replace with name)
// When botOpenID == "", neither branch strips unless openID matches empty string.
// Both get replaced with @Name.
// botOpenID is empty: no mention is treated as this bot, so all user
// mentions are preserved as readable names.
if got != "@Alice and @Bob" {
t.Errorf("got %q, want %q", got, "@Alice and @Bob")
}
@@ -276,6 +274,28 @@ func TestParseMessageEvent_BotMentioned(t *testing.T) {
}
}
func TestParseMessageEvent_UnknownBotIDDoesNotTreatAnyMentionAsBot(t *testing.T) {
ch := &Channel{}
ev := &MessageEvent{}
ev.Event.Message.MessageType = "text"
ev.Event.Message.Content = `{"text":"@_user_1 please help"}`
ev.Event.Message.Mentions = []EventMention{
{Key: "@_user_1", ID: struct {
OpenID string `json:"open_id"`
UserID string `json:"user_id"`
UnionID string `json:"union_id"`
}{OpenID: "ou_other_bot"}, Name: "OtherBot"},
}
mc := ch.parseMessageEvent(ev)
if mc.MentionedBot {
t.Fatal("MentionedBot must be false when bot open_id is unknown")
}
if mc.Content != "@OtherBot please help" {
t.Fatalf("content = %q, want other mention preserved", mc.Content)
}
}
func TestParseMessageEvent_NoSender(t *testing.T) {
ch := &Channel{}
ev := &MessageEvent{}
+141 -5
View File
@@ -63,6 +63,7 @@ func (c *Channel) checkGroupPolicy(ctx context.Context, senderID, chatID string)
if groupPolicy == "" {
groupPolicy = "open"
}
channelName := c.channelNameForLog()
switch groupPolicy {
case "disabled":
@@ -72,15 +73,30 @@ func (c *Channel) checkGroupPolicy(ctx context.Context, senderID, chatID string)
case "pairing":
// Feishu groupAllowList bypass: per-user sender allowlist specific to Feishu.
if c.isInGroupAllowList(senderID) {
slog.Debug("feishu group policy allowed by group allowlist",
"channel", channelName,
"sender_id", senderID,
"chat_id", chatID,
"group_policy", groupPolicy,
)
return true
}
// Delegate remaining pairing logic to BaseChannel (handles allowList, approvedGroups, DB check).
result := c.CheckGroupPolicy(ctx, senderID, chatID, groupPolicy)
groupSenderID := fmt.Sprintf("group:%s", chatID)
slog.Debug("feishu group policy checked",
"channel", channelName,
"sender_id", senderID,
"group_sender_id", groupSenderID,
"chat_id", chatID,
"group_policy", groupPolicy,
"result", policyResultLabel(result),
"tenant_id", c.TenantID(),
)
switch result {
case channels.PolicyAllow:
return true
case channels.PolicyNeedsPairing:
groupSenderID := fmt.Sprintf("group:%s", chatID)
c.sendPairingReply(ctx, groupSenderID, chatID)
return false
default:
@@ -91,6 +107,38 @@ func (c *Channel) checkGroupPolicy(ctx context.Context, senderID, chatID string)
}
}
func (c *Channel) canRecordUnmentionedGroupMessage(ctx context.Context, senderID, chatID string) bool {
groupPolicy := c.cfg.GroupPolicy
if groupPolicy == "" {
groupPolicy = "open"
}
channelName := c.channelNameForLog()
switch groupPolicy {
case "disabled":
return false
case "allowlist":
return c.IsAllowed(senderID) || c.isInGroupAllowList(senderID)
case "pairing":
if c.isInGroupAllowList(senderID) {
return true
}
result := c.CheckGroupPolicy(ctx, senderID, chatID, groupPolicy)
slog.Debug("feishu unmentioned group history policy checked",
"channel", channelName,
"sender_id", senderID,
"group_sender_id", fmt.Sprintf("group:%s", chatID),
"chat_id", chatID,
"group_policy", groupPolicy,
"result", policyResultLabel(result),
"tenant_id", c.TenantID(),
)
return result == channels.PolicyAllow
default:
return true
}
}
func (c *Channel) checkDMPolicy(ctx context.Context, senderID, chatID string) bool {
result := c.CheckDMPolicy(ctx, senderID, c.cfg.DMPolicy)
switch result {
@@ -110,14 +158,61 @@ func (c *Channel) sendPairingReply(ctx context.Context, senderID, chatID string)
if ps == nil {
return
}
channelName := c.channelNameForLog()
paired, err := ps.IsPaired(ctx, senderID, channelName)
if err != nil {
slog.Warn("security.pairing_check_failed, denying pairing request (fail-closed)",
"sender_id", senderID,
"channel", channelName,
"chat_id", chatID,
"tenant_id", c.TenantID(),
"error", err,
)
return
}
if paired {
if strings.HasPrefix(senderID, "group:") {
c.MarkGroupApproved(chatID)
}
slog.Debug("feishu pairing reply skipped; sender already paired",
"sender_id", senderID,
"channel", channelName,
"chat_id", chatID,
"tenant_id", c.TenantID(),
"source", pairingReplySource(senderID),
)
return
}
slog.Debug("feishu pairing reply gate needs pairing",
"sender_id", senderID,
"channel", channelName,
"chat_id", chatID,
"tenant_id", c.TenantID(),
"source", pairingReplySource(senderID),
)
if !c.CanSendPairingNotif(senderID, pairingDebounceTime) {
slog.Debug("feishu pairing reply skipped by debounce",
"sender_id", senderID,
"channel", channelName,
"chat_id", chatID,
"tenant_id", c.TenantID(),
"source", pairingReplySource(senderID),
)
return
}
code, err := ps.RequestPairing(ctx, senderID, c.Name(), chatID, "default", nil)
code, err := ps.RequestPairing(ctx, senderID, channelName, chatID, "default", nil)
if err != nil {
slog.Debug("feishu pairing request failed", "sender_id", senderID, "error", err)
slog.Debug("feishu pairing request failed",
"sender_id", senderID,
"channel", channelName,
"chat_id", chatID,
"tenant_id", c.TenantID(),
"source", pairingReplySource(senderID),
"error", err,
)
return
}
@@ -128,9 +223,50 @@ func (c *Channel) sendPairingReply(ctx context.Context, senderID, chatID string)
receiveIDType := resolveReceiveIDType(chatID)
if err := c.sendText(context.Background(), chatID, receiveIDType, replyText, ""); err != nil {
slog.Warn("failed to send feishu pairing reply", "error", err)
slog.Warn("failed to send feishu pairing reply",
"sender_id", senderID,
"channel", channelName,
"chat_id", chatID,
"tenant_id", c.TenantID(),
"source", pairingReplySource(senderID),
"error", err,
)
} else {
c.MarkPairingNotifSent(senderID)
slog.Info("feishu pairing reply sent", "sender_id", senderID, "code", code)
slog.Info("feishu pairing reply sent",
"sender_id", senderID,
"channel", channelName,
"chat_id", chatID,
"tenant_id", c.TenantID(),
"source", pairingReplySource(senderID),
"code", code,
)
}
}
func (c *Channel) channelNameForLog() string {
if c == nil || c.BaseChannel == nil {
return ""
}
return c.Name()
}
func pairingReplySource(senderID string) string {
if strings.HasPrefix(senderID, "group:") {
return "group_policy"
}
return "dm_policy"
}
func policyResultLabel(result channels.PolicyResult) string {
switch result {
case channels.PolicyAllow:
return "allow"
case channels.PolicyDeny:
return "deny"
case channels.PolicyNeedsPairing:
return "needs_pairing"
default:
return fmt.Sprintf("unknown_%d", result)
}
}
+229
View File
@@ -2,7 +2,14 @@ package feishu
import (
"context"
"encoding/json"
"errors"
"testing"
"time"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// --- isInGroupAllowList ---
@@ -73,6 +80,228 @@ func TestCheckGroupPolicy_Pairing_GroupAllowListBypasses(t *testing.T) {
}
}
type feishuPolicyPairingStore struct {
requests int
lastSenderID string
lastChannel string
lastChatID string
paired map[string]bool
}
func (s *feishuPolicyPairingStore) RequestPairing(_ context.Context, senderID, channel, chatID, _ string, _ map[string]string) (string, error) {
s.requests++
s.lastSenderID = senderID
s.lastChannel = channel
s.lastChatID = chatID
return "PAIR1234", nil
}
func (s *feishuPolicyPairingStore) IsPaired(_ context.Context, senderID, channel string) (bool, error) {
return s.paired[senderID+"|"+channel], nil
}
func (s *feishuPolicyPairingStore) ApprovePairing(context.Context, string, string) (*store.PairedDeviceData, error) {
return nil, errors.New("not implemented")
}
func (s *feishuPolicyPairingStore) DenyPairing(context.Context, string) error { return nil }
func (s *feishuPolicyPairingStore) RevokePairing(context.Context, string, string) error {
return nil
}
func (s *feishuPolicyPairingStore) ListPending(context.Context) []store.PairingRequestData {
return nil
}
func (s *feishuPolicyPairingStore) ListPaired(context.Context) []store.PairedDeviceData {
return nil
}
func (s *feishuPolicyPairingStore) MigrateGroupChatID(context.Context, string, string, string) error {
return nil
}
func TestHandleMessageEvent_GroupPairingRequiresTargetBotMention(t *testing.T) {
requireMention := true
ps := &feishuPolicyPairingStore{paired: map[string]bool{}}
msgBus := bus.New()
defer msgBus.Close()
ch, err := New(config.FeishuConfig{
AppID: "app",
AppSecret: "secret",
GroupPolicy: "pairing",
RequireMention: &requireMention,
}, msgBus, ps, nil, nil)
if err != nil {
t.Fatalf("New feishu channel: %v", err)
}
ch.SetName("feishu-test")
ch.botOpenID = "ou_target_bot"
srv := newSimpleMockServer(t, `{"code":0,"msg":"ok","data":{"message_id":"om_sent"}}`)
ch.client = NewLarkClient("app", "secret", srv.URL)
event := feishuGroupTextEvent(t, "om_non_target", "ou_alice", "oc_group", "@_user_1 help", []EventMention{
feishuMention("@_user_1", "ou_other_bot", "OtherBot"),
})
ch.handleMessageEvent(context.Background(), event)
if ps.requests != 0 {
t.Fatalf("pairing requests = %d, want 0 for non-target mention", ps.requests)
}
assertNoFeishuInbound(t, msgBus)
}
func TestHandleMessageEvent_GroupSkipsExplicitOtherMentionEvenWhenRequireMentionDisabled(t *testing.T) {
requireMention := false
ps := &feishuPolicyPairingStore{paired: map[string]bool{}}
msgBus := bus.New()
defer msgBus.Close()
ch, err := New(config.FeishuConfig{
AppID: "app",
AppSecret: "secret",
GroupPolicy: "pairing",
RequireMention: &requireMention,
}, msgBus, ps, nil, nil)
if err != nil {
t.Fatalf("New feishu channel: %v", err)
}
ch.SetName("lark-cppai-pm")
ch.botOpenID = "ou_0b6fac6e84cf8c773a0c2768147799e2"
srv := newSimpleMockServer(t, `{"code":0,"msg":"ok","data":{"message_id":"om_sent"}}`)
ch.client = NewLarkClient("app", "secret", srv.URL)
event := feishuGroupTextEvent(t, "om_other_bot_target", "ou_alice", "oc_group", "@_user_1 help", []EventMention{
feishuMention("@_user_1", "ou_3ef6f00429b40780fb7ffc4a972fd835", "itsddvnm"),
})
ch.handleMessageEvent(context.Background(), event)
if ps.requests != 0 {
t.Fatalf("pairing requests = %d, want 0 when explicit mention targets another bot", ps.requests)
}
assertNoFeishuInbound(t, msgBus)
}
func TestHandleMessageEvent_GroupPairingRequestsOnlyWhenTargetMentioned(t *testing.T) {
requireMention := true
ps := &feishuPolicyPairingStore{paired: map[string]bool{}}
msgBus := bus.New()
defer msgBus.Close()
ch, err := New(config.FeishuConfig{
AppID: "app",
AppSecret: "secret",
GroupPolicy: "pairing",
RequireMention: &requireMention,
}, msgBus, ps, nil, nil)
if err != nil {
t.Fatalf("New feishu channel: %v", err)
}
ch.SetName("feishu-test")
ch.botOpenID = "ou_target_bot"
srv := newSimpleMockServer(t, `{"code":0,"msg":"ok","data":{"message_id":"om_sent"}}`)
ch.client = NewLarkClient("app", "secret", srv.URL)
event := feishuGroupTextEvent(t, "om_target", "ou_alice", "oc_group", "@_user_1 help", []EventMention{
feishuMention("@_user_1", "ou_target_bot", "TargetBot"),
})
ch.handleMessageEvent(context.Background(), event)
if ps.requests != 1 {
t.Fatalf("pairing requests = %d, want 1", ps.requests)
}
if ps.lastSenderID != "group:oc_group" {
t.Fatalf("pairing senderID = %q, want group:oc_group", ps.lastSenderID)
}
if ps.lastChannel != "feishu-test" || ps.lastChatID != "oc_group" {
t.Fatalf("unexpected pairing request channel/chat: %s/%s", ps.lastChannel, ps.lastChatID)
}
assertNoFeishuInbound(t, msgBus)
}
func TestHandleMessageEvent_GroupPairingAllowsAlreadyPairedTargetMention(t *testing.T) {
requireMention := true
ps := &feishuPolicyPairingStore{
paired: map[string]bool{"group:oc_group|feishu-test": true},
}
msgBus := bus.New()
defer msgBus.Close()
ch, err := New(config.FeishuConfig{
AppID: "app",
AppSecret: "secret",
GroupPolicy: "pairing",
RequireMention: &requireMention,
}, msgBus, ps, nil, nil)
if err != nil {
t.Fatalf("New feishu channel: %v", err)
}
ch.SetName("feishu-test")
ch.botOpenID = "ou_target_bot"
srv := newSimpleMockServer(t, `{"code":0,"msg":"ok","data":{"name":"Alice"}}`)
ch.client = NewLarkClient("app", "secret", srv.URL)
event := feishuGroupTextEvent(t, "om_paired_target", "ou_alice", "oc_group", "@_user_1 help", []EventMention{
feishuMention("@_user_1", "ou_target_bot", "TargetBot"),
})
ch.handleMessageEvent(context.Background(), event)
if ps.requests != 0 {
t.Fatalf("pairing requests = %d, want 0 for already paired group", ps.requests)
}
msg := assertFeishuInbound(t, msgBus)
if msg.Channel != "feishu-test" || msg.ChatID != "oc_group" || msg.PeerKind != "group" {
t.Fatalf("unexpected inbound route: channel=%q chat=%q peer=%q", msg.Channel, msg.ChatID, msg.PeerKind)
}
}
func feishuGroupTextEvent(t *testing.T, messageID, senderID, chatID, text string, mentions []EventMention) *MessageEvent {
t.Helper()
content, err := json.Marshal(map[string]string{"text": text})
if err != nil {
t.Fatalf("marshal text content: %v", err)
}
ev := &MessageEvent{}
ev.Event.Message.MessageID = messageID
ev.Event.Message.ChatID = chatID
ev.Event.Message.ChatType = "group"
ev.Event.Message.MessageType = "text"
ev.Event.Message.Content = string(content)
ev.Event.Message.Mentions = mentions
ev.Event.Sender.SenderID.OpenID = senderID
return ev
}
func feishuMention(key, openID, name string) EventMention {
return EventMention{
Key: key,
ID: struct {
OpenID string `json:"open_id"`
UserID string `json:"user_id"`
UnionID string `json:"union_id"`
}{OpenID: openID},
Name: name,
}
}
func assertFeishuInbound(t *testing.T, msgBus *bus.MessageBus) bus.InboundMessage {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
msg, ok := msgBus.ConsumeInbound(ctx)
if !ok {
t.Fatal("expected inbound message")
}
return msg
}
func assertNoFeishuInbound(t *testing.T, msgBus *bus.MessageBus) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer cancel()
if msg, ok := msgBus.ConsumeInbound(ctx); ok {
t.Fatalf("unexpected inbound message: %+v", msg)
}
}
// --- webhookPath ---
func TestWebhookPath_Default(t *testing.T) {
+88 -8
View File
@@ -135,13 +135,12 @@ func (c *Channel) Start(ctx context.Context) error {
c.GroupHistory().StartFlusher()
slog.Info("starting feishu/lark bot")
// Probe bot identity. Without botOpenID, mention detection degrades to
// "any mention counts as bot mention" (see parseMessageEvent), so retry
// transient failures before giving up.
// Probe bot identity. Group mention detection fails closed without
// botOpenID, so retry transient failures before giving up.
if err := c.probeBotInfoWithRetry(ctx, 3); err != nil {
slog.Warn("feishu bot probe failed (will continue)", "error", err)
slog.Warn("feishu bot probe failed (will continue)", "channel", c.Name(), "error", err)
} else {
slog.Info("feishu bot connected", "bot_open_id", c.botOpenID)
slog.Info("feishu bot connected", "channel", c.Name(), "bot_open_id", c.botOpenID)
}
mode := c.cfg.ConnectionMode
@@ -275,7 +274,7 @@ func (a *wsEventAdapter) HandleEvent(ctx context.Context, payload []byte) error
return fmt.Errorf("parse event: %w", err)
}
if event.Header.EventType == "im.message.receive_v1" {
a.ch.handleMessageEvent(ctx, &event)
a.ch.handleMessageEventFrom(ctx, &event, "websocket")
}
return nil
}
@@ -316,7 +315,7 @@ func (c *Channel) WebhookHandler() (string, http.Handler) {
handler := NewWebhookHandler(c.cfg.VerificationToken, c.cfg.EncryptKey, func(event *MessageEvent) {
ctx := store.WithTenantID(context.Background(), c.TenantID())
c.handleMessageEvent(ctx, event)
c.handleMessageEventFrom(ctx, event, "webhook-main")
})
return path, http.HandlerFunc(handler)
@@ -337,7 +336,7 @@ func (c *Channel) startWebhook(ctx context.Context) error {
handler := NewWebhookHandler(c.cfg.VerificationToken, c.cfg.EncryptKey, func(event *MessageEvent) {
ctx := store.WithTenantID(context.Background(), c.TenantID())
c.handleMessageEvent(ctx, event)
c.handleMessageEventFrom(ctx, event, "webhook-server")
})
mux := http.NewServeMux()
@@ -358,6 +357,87 @@ func (c *Channel) startWebhook(ctx context.Context) error {
return nil
}
func (c *Channel) shouldProcessMessageEvent(event *MessageEvent, source string) bool {
eventType := strings.TrimSpace(event.Header.EventType)
if eventType != "" && eventType != "im.message.receive_v1" {
slog.Debug("feishu inbound event skipped; unsupported event type",
"source", source,
"decision", "skip_event_type",
"channel", c.Name(),
"event_id", event.Header.EventID,
"event_type", event.Header.EventType,
"event_app_id", appIDForLog(event.Header.AppID),
"configured_app_id", appIDForLog(c.cfg.AppID),
)
return false
}
appIDMatch := c.eventAppIDMatches(event)
slog.Debug("feishu inbound message event received",
"source", source,
"decision", "received",
"channel", c.Name(),
"event_id", event.Header.EventID,
"event_type", event.Header.EventType,
"event_app_id", appIDForLog(event.Header.AppID),
"configured_app_id", appIDForLog(c.cfg.AppID),
"app_id_match", appIDMatch,
"message_id", event.Event.Message.MessageID,
"chat_id", event.Event.Message.ChatID,
"chat_type", event.Event.Message.ChatType,
"message_type", event.Event.Message.MessageType,
"sender_open_id", event.Event.Sender.SenderID.OpenID,
"mention_count", len(event.Event.Message.Mentions),
)
if !appIDMatch {
slog.Info("feishu inbound message skipped; app id mismatch",
"source", source,
"decision", "skip_app_mismatch",
"channel", c.Name(),
"event_id", event.Header.EventID,
"event_app_id", appIDForLog(event.Header.AppID),
"configured_app_id", appIDForLog(c.cfg.AppID),
"message_id", event.Event.Message.MessageID,
"chat_id", event.Event.Message.ChatID,
)
return false
}
return true
}
func (c *Channel) eventAppIDMatches(event *MessageEvent) bool {
expected := strings.TrimSpace(c.cfg.AppID)
actual := strings.TrimSpace(event.Header.AppID)
return expected == "" || actual == "" || expected == actual
}
func (c *Channel) logParsedMessage(event *MessageEvent, mc *messageContext, source string) {
slog.Debug("feishu message parsed",
"source", source,
"decision", "parsed",
"channel", c.Name(),
"event_id", event.Header.EventID,
"message_id", mc.MessageID,
"chat_id", mc.ChatID,
"chat_type", mc.ChatType,
"content_type", mc.ContentType,
"sender_open_id", mc.SenderID,
"bot_open_id", c.botOpenID,
"mentioned_bot", mc.MentionedBot,
"mention_count", len(mc.Mentions),
"mentions", formatMentionInfos(mc.Mentions),
"preview", channels.Truncate(mc.Content, 80),
)
}
func appIDForLog(appID string) string {
appID = strings.TrimSpace(appID)
if len(appID) <= 8 {
return appID
}
return appID[:4] + "..." + appID[len(appID)-4:]
}
// --- Bot probe ---
func (c *Channel) probeBotInfo(ctx context.Context) error {
@@ -2,10 +2,12 @@ package feishu
import (
"context"
"encoding/json"
"testing"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/config"
)
@@ -30,6 +32,80 @@ func TestWSEventAdapter_HandleEvent_NonMessageEvent_NoError(t *testing.T) {
}
}
func TestWSEventAdapter_HandleEvent_AppIDMismatchSkipsMessage(t *testing.T) {
requireMention := true
ps := &feishuPolicyPairingStore{paired: map[string]bool{}}
msgBus := bus.New()
defer msgBus.Close()
ch, err := New(config.FeishuConfig{
AppID: "app-cppai",
AppSecret: "secret",
GroupPolicy: "pairing",
RequireMention: &requireMention,
}, msgBus, ps, nil, nil)
if err != nil {
t.Fatalf("New feishu channel: %v", err)
}
ch.SetName("lark-cppai-pm")
ch.botOpenID = "ou_0b6fac6e84cf8c773a0c2768147799e2"
event := feishuGroupTextEvent(t, "om_app_mismatch", "ou_alice", "oc_group", "@_user_1 help", []EventMention{
feishuMention("@_user_1", ch.botOpenID, "CPPAI PM"),
})
event.Header.EventType = "im.message.receive_v1"
event.Header.AppID = "app-itsddnv"
payload := mustMarshalMessageEvent(t, event)
adapter := &wsEventAdapter{ch: ch}
if err := adapter.HandleEvent(context.Background(), payload); err != nil {
t.Fatalf("HandleEvent error: %v", err)
}
if ps.requests != 0 {
t.Fatalf("pairing requests = %d, want 0 for app_id mismatch", ps.requests)
}
assertNoFeishuInbound(t, msgBus)
}
func TestWSEventAdapter_HandleEvent_AppIDMatchProcessesMessage(t *testing.T) {
requireMention := true
ps := &feishuPolicyPairingStore{paired: map[string]bool{}}
msgBus := bus.New()
defer msgBus.Close()
ch, err := New(config.FeishuConfig{
AppID: "app-cppai",
AppSecret: "secret",
GroupPolicy: "pairing",
RequireMention: &requireMention,
}, msgBus, ps, nil, nil)
if err != nil {
t.Fatalf("New feishu channel: %v", err)
}
ch.SetName("lark-cppai-pm")
ch.botOpenID = "ou_0b6fac6e84cf8c773a0c2768147799e2"
srv := newSimpleMockServer(t, `{"code":0,"msg":"ok","data":{"message_id":"om_sent"}}`)
ch.client = NewLarkClient("app-cppai", "secret", srv.URL)
event := feishuGroupTextEvent(t, "om_app_match", "ou_alice", "oc_group", "@_user_1 help", []EventMention{
feishuMention("@_user_1", ch.botOpenID, "CPPAI PM"),
})
event.Header.EventType = "im.message.receive_v1"
event.Header.AppID = "app-cppai"
payload := mustMarshalMessageEvent(t, event)
adapter := &wsEventAdapter{ch: ch}
if err := adapter.HandleEvent(context.Background(), payload); err != nil {
t.Fatalf("HandleEvent error: %v", err)
}
if ps.requests != 1 {
t.Fatalf("pairing requests = %d, want 1 for matching app_id", ps.requests)
}
assertNoFeishuInbound(t, msgBus)
}
// --- probeBotInfo ---
func TestProbeBotInfo_Success(t *testing.T) {
@@ -227,3 +303,12 @@ func newLifecycleTestChannel(t *testing.T) *Channel {
}
return ch
}
func mustMarshalMessageEvent(t *testing.T, event *MessageEvent) []byte {
t.Helper()
payload, err := json.Marshal(event)
if err != nil {
t.Fatalf("marshal message event: %v", err)
}
return payload
}