From 2e5cf4a9c9a132788bec0da9e5c95c08e2a3d1f4 Mon Sep 17 00:00:00 2001 From: viettranx Date: Fri, 27 Feb 2026 20:22:08 +0700 Subject: [PATCH] fix: Implement Lark bot identity probe and group history for mention detection, fix Discord placeholder chunking. - Implement `probeBotInfo()` to fetch bot's `open_id` via `/open-apis/bot/v3/info`, fixing group mention detection that was always false due to empty `botOpenID`. - Add group pending history support to Feishu channel (record/build context/clear) matching Discord pattern. - Fix Discord placeholder edit to chunk long responses instead of truncating at 2000 chars. --- internal/channels/discord/discord.go | 21 +++++++++++--- internal/channels/feishu/bot.go | 31 ++++++++++++++++++--- internal/channels/feishu/feishu.go | 38 ++++++++++++++++++-------- internal/channels/feishu/larkclient.go | 21 ++++++++++++++ 4 files changed, 92 insertions(+), 19 deletions(-) diff --git a/internal/channels/discord/discord.go b/internal/channels/discord/discord.go index 390a7808..d84c1cb3 100644 --- a/internal/channels/discord/discord.go +++ b/internal/channels/discord/discord.go @@ -146,18 +146,31 @@ func (c *Channel) Send(_ context.Context, msg bus.OutboundMessage) error { return nil } - // Try to edit the placeholder "Thinking..." message + // Try to edit the placeholder "Thinking..." message with the first chunk, + // then send the rest as follow-up messages. if pID, ok := c.placeholders.Load(placeholderKey); ok { c.placeholders.Delete(placeholderKey) msgID := pID.(string) - // Discord has a 2000-char message limit + const maxLen = 2000 editContent := content - if len(editContent) > 2000 { - editContent = editContent[:1997] + "..." + remaining := "" + + if len(editContent) > maxLen { + // Break at a newline if possible + cutAt := maxLen + if idx := lastIndexByte(content[:maxLen], '\n'); idx > maxLen/2 { + cutAt = idx + 1 + } + editContent = content[:cutAt] + remaining = content[cutAt:] } if _, editErr := c.session.ChannelMessageEdit(channelID, msgID, editContent); editErr == nil { + // Send remaining content as follow-up messages + if remaining != "" { + return c.sendChunked(channelID, remaining) + } return nil } else { slog.Warn("discord: placeholder edit failed, sending new message", diff --git a/internal/channels/feishu/bot.go b/internal/channels/feishu/bot.go index 0c44a8fa..c1b01c54 100644 --- a/internal/channels/feishu/bot.go +++ b/internal/channels/feishu/bot.go @@ -67,13 +67,26 @@ func (c *Channel) handleMessageEvent(ctx context.Context, event *MessageEvent) { return } - // 5. RequireMention check + // 5. RequireMention check — record to history if not mentioned requireMention := true if c.cfg.RequireMention != nil { requireMention = *c.cfg.RequireMention } if requireMention && !mc.MentionedBot { - slog.Debug("feishu group message skipped: bot not mentioned", "chat_id", mc.ChatID) + historyKey := mc.ChatID + if mc.RootID != "" && c.cfg.TopicSessionMode == "enabled" { + historyKey = fmt.Sprintf("%s:topic:%s", mc.ChatID, mc.RootID) + } + c.groupHistory.Record(historyKey, channels.HistoryEntry{ + Sender: senderName, + Body: mc.Content, + Timestamp: time.Now(), + MessageID: messageID, + }, c.historyLimit) + + slog.Debug("feishu group message recorded (no mention)", + "chat_id", mc.ChatID, "sender", senderName, + ) return } } @@ -124,13 +137,23 @@ func (c *Channel) handleMessageEvent(ctx context.Context, event *MessageEvent) { metadata["sender_open_id"] = sender.SenderID.OpenID } - // Annotate current message with sender name so LLM knows who is talking in groups. + // Build final content with group context (pending history + sender annotation). if mc.ChatType == "group" && senderName != "" { - content = fmt.Sprintf("[From: %s]\n%s", senderName, content) + annotated := fmt.Sprintf("[From: %s]\n%s", senderName, content) + if c.historyLimit > 0 { + content = c.groupHistory.BuildContext(chatID, annotated, c.historyLimit) + } else { + content = annotated + } } // 10. Publish to bus c.HandleMessage(mc.SenderID, chatID, content, nil, metadata, peerKind) + + // Clear pending history after sending to agent. + if mc.ChatType == "group" { + c.groupHistory.Clear(chatID) + } } // --- Parse --- diff --git a/internal/channels/feishu/feishu.go b/internal/channels/feishu/feishu.go index b7a40a0a..c8e81ff5 100644 --- a/internal/channels/feishu/feishu.go +++ b/internal/channels/feishu/feishu.go @@ -31,17 +31,19 @@ const ( // Channel connects to Feishu/Lark via native HTTP + WebSocket. type Channel struct { *channels.BaseChannel - cfg config.FeishuConfig - client *LarkClient - botOpenID string - pairingService store.PairingStore - senderCache sync.Map // open_id → *senderCacheEntry - dedup sync.Map // message_id → struct{} + cfg config.FeishuConfig + client *LarkClient + botOpenID string + pairingService store.PairingStore + senderCache sync.Map // open_id → *senderCacheEntry + dedup sync.Map // message_id → struct{} pairingDebounce sync.Map // senderID → time.Time - groupAllowList []string - stopCh chan struct{} - httpServer *http.Server - wsClient *WSClient + groupAllowList []string + groupHistory *channels.PendingHistory + historyLimit int + stopCh chan struct{} + httpServer *http.Server + wsClient *WSClient } type senderCacheEntry struct { @@ -62,12 +64,19 @@ func New(cfg config.FeishuConfig, msgBus *bus.MessageBus, pairingSvc store.Pairi base := channels.NewBaseChannel("feishu", msgBus, cfg.AllowFrom) + historyLimit := cfg.HistoryLimit + if historyLimit == 0 { + historyLimit = channels.DefaultGroupHistoryLimit + } + return &Channel{ BaseChannel: base, cfg: cfg, client: client, pairingService: pairingSvc, groupAllowList: cfg.GroupAllowFrom, + groupHistory: channels.NewPendingHistory(), + historyLimit: historyLimit, stopCh: make(chan struct{}), }, nil } @@ -233,7 +242,14 @@ func (c *Channel) startWebhook(ctx context.Context) error { // --- Bot probe --- func (c *Channel) probeBotInfo(ctx context.Context) error { - // Bot open_id will be resolved from first message event if needed + openID, err := c.client.GetBotInfo(ctx) + if err != nil { + return fmt.Errorf("fetch bot info: %w", err) + } + if openID == "" { + return fmt.Errorf("bot open_id is empty") + } + c.botOpenID = openID return nil } diff --git a/internal/channels/feishu/larkclient.go b/internal/channels/feishu/larkclient.go index e35788b5..8f7a0b36 100644 --- a/internal/channels/feishu/larkclient.go +++ b/internal/channels/feishu/larkclient.go @@ -387,6 +387,27 @@ func (c *LarkClient) UpdateCardElement(ctx context.Context, cardID, elementID, c return nil } +// --- Bot API --- + +// GetBotInfo fetches the bot's identity from /open-apis/bot/v3/info. +// Returns the bot's open_id which is needed for mention detection in groups. +func (c *LarkClient) GetBotInfo(ctx context.Context) (string, error) { + resp, err := c.doJSON(ctx, "GET", "/open-apis/bot/v3/info", nil) + if err != nil { + return "", err + } + if resp.Code != 0 { + return "", fmt.Errorf("get bot info: code=%d msg=%s", resp.Code, resp.Msg) + } + var result struct { + Bot struct { + OpenID string `json:"open_id"` + } `json:"bot"` + } + json.Unmarshal(resp.Data, &result) + return result.Bot.OpenID, nil +} + // --- Contact API --- func (c *LarkClient) GetUser(ctx context.Context, userID, userIDType string) (string, error) {