feat(channel): add quoted message context injection for Discord and Feishu

Prepend replied-to message text and attachments into the user query so
the LLM can see what is being replied to, matching the existing Telegram
behavior. Also set is_reply_to_bot metadata for Feishu reply-to-bot
detection in group chats.
This commit is contained in:
Acbox
2026-03-11 16:57:33 +08:00
parent 93ddf3c6d4
commit a2e5c4f893
4 changed files with 207 additions and 2 deletions
+60 -2
View File
@@ -22,8 +22,9 @@ import (
)
const (
inboundDedupTTL = time.Minute
discordMaxLength = 2000
inboundDedupTTL = time.Minute
discordMaxLength = 2000
discordQuotedTextMaxLength = 200
)
// assetOpener reads stored asset bytes by content hash.
@@ -172,6 +173,22 @@ func (a *DiscordAdapter) Connect(ctx context.Context, cfg channel.ChannelConfig,
chatType = "guild"
}
// Prepend quoted message context so the AI can see what is being replied to,
// and include quoted attachments so the LLM can see the actual media.
var replyRef *channel.ReplyRef
if m.ReferencedMessage != nil {
if quotedText := buildDiscordQuotedText(m.ReferencedMessage); quotedText != "" {
text = quotedText + "\n" + text
}
if quotedAttachments := a.collectAttachments(m.ReferencedMessage); len(quotedAttachments) > 0 {
attachments = append(attachments, quotedAttachments...)
}
replyRef = &channel.ReplyRef{
MessageID: m.ReferencedMessage.ID,
Target: m.ChannelID,
}
}
isMentioned := a.isBotMentioned(m.Message, botID)
isReplyToBot := m.ReferencedMessage != nil &&
m.ReferencedMessage.Author != nil &&
@@ -184,6 +201,7 @@ func (a *DiscordAdapter) Connect(ctx context.Context, cfg channel.ChannelConfig,
Format: channel.MessageFormatPlain,
Text: text,
Attachments: attachments,
Reply: replyRef,
},
BotID: cfg.BotID,
ReplyTarget: m.ChannelID,
@@ -627,3 +645,43 @@ func (a *DiscordAdapter) clearSessionState(token string) func() {
delete(a.sessions, token)
return remove
}
// buildDiscordQuotedText extracts a textual summary of the replied-to message
// so the AI can see what message the user is replying to.
func buildDiscordQuotedText(ref *discordgo.Message) string {
if ref == nil {
return ""
}
senderName := ""
if ref.Author != nil {
senderName = strings.TrimSpace(ref.Author.Username)
}
text := strings.TrimSpace(ref.Content)
if text == "" && len(ref.Attachments) > 0 {
types := make([]string, 0, len(ref.Attachments))
for _, att := range ref.Attachments {
contentType := strings.TrimSpace(att.ContentType)
switch {
case strings.HasPrefix(contentType, "image/"):
types = append(types, "image")
case strings.HasPrefix(contentType, "video/"):
types = append(types, "video")
case strings.HasPrefix(contentType, "audio/"):
types = append(types, "audio")
default:
types = append(types, "file")
}
}
text = "[" + strings.Join(types, ", ") + "]"
}
if text == "" {
return ""
}
if len([]rune(text)) > discordQuotedTextMaxLength {
text = string([]rune(text)[:discordQuotedTextMaxLength]) + "..."
}
if senderName != "" {
return fmt.Sprintf("[Reply to %s: %s]", senderName, text)
}
return fmt.Sprintf("[Reply to: %s]", text)
}
@@ -428,6 +428,7 @@ func (a *FeishuAdapter) Connect(ctx context.Context, cfg channel.ChannelConfig,
return nil
}
a.enrichSenderProfile(connCtx, cfg, event, &msg)
a.enrichQuotedMessage(connCtx, cfg, &msg, botOpenID)
msg.BotID = cfg.BotID
if a.logger != nil {
isMentioned := false
@@ -0,0 +1,145 @@
package feishu
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"strings"
"time"
lark "github.com/larksuite/oapi-sdk-go/v3"
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
"github.com/memohai/memoh/internal/channel"
)
const feishuQuotedTextMaxLength = 200
// enrichQuotedMessage fetches the parent message via API and prepends a
// quoted-text summary to the inbound message so the AI can see what is
// being replied to. It also sets the "is_reply_to_bot" metadata flag.
func (a *FeishuAdapter) enrichQuotedMessage(ctx context.Context, cfg channel.ChannelConfig, msg *channel.InboundMessage, botOpenID string) {
if msg == nil || msg.Message.Reply == nil {
return
}
parentID := strings.TrimSpace(msg.Message.Reply.MessageID)
if parentID == "" {
return
}
feishuCfg, err := parseConfig(cfg.Credentials)
if err != nil {
return
}
lookupCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
client := lark.NewClient(feishuCfg.AppID, feishuCfg.AppSecret, lark.WithOpenBaseUrl(feishuCfg.openBaseURL()))
resp, err := client.Im.Message.Get(lookupCtx, larkim.NewGetMessageReqBuilder().MessageId(parentID).Build())
if err != nil {
if a.logger != nil {
a.logger.Debug("feishu quoted message fetch failed",
slog.String("parent_id", parentID),
slog.Any("error", err),
)
}
return
}
if resp == nil || !resp.Success() || resp.Data == nil || len(resp.Data.Items) == 0 {
if a.logger != nil {
code, respMsg := 0, ""
if resp != nil {
code = resp.Code
respMsg = resp.Msg
}
a.logger.Debug("feishu quoted message fetch empty",
slog.String("parent_id", parentID),
slog.Int("code", code),
slog.String("msg", respMsg),
)
}
return
}
parent := resp.Data.Items[0]
// Determine if the parent message is from the bot itself.
isReplyToBot := false
senderName := ""
if parent.Sender != nil {
senderType := ptrStr(parent.Sender.SenderType)
senderID := ptrStr(parent.Sender.Id)
if senderType == "app" {
// When botOpenID is known, match precisely; otherwise any app sender counts.
isReplyToBot = strings.TrimSpace(botOpenID) == "" || senderID == strings.TrimSpace(botOpenID)
}
}
if msg.Metadata == nil {
msg.Metadata = map[string]any{}
}
msg.Metadata["is_reply_to_bot"] = isReplyToBot
// Extract text content from the parent message.
text := extractFeishuMessageText(parent)
if text == "" {
msgType := ptrStr(parent.MsgType)
if msgType != "" && msgType != larkim.MsgTypeText {
text = "[" + msgType + "]"
}
}
if text == "" {
return
}
if len([]rune(text)) > feishuQuotedTextMaxLength {
text = string([]rune(text)[:feishuQuotedTextMaxLength]) + "..."
}
var quotedText string
if senderName != "" {
quotedText = fmt.Sprintf("[Reply to %s: %s]", senderName, text)
} else {
quotedText = fmt.Sprintf("[Reply to: %s]", text)
}
current := strings.TrimSpace(msg.Message.Text)
if current != "" {
msg.Message.Text = quotedText + "\n" + current
} else {
msg.Message.Text = quotedText
}
}
// extractFeishuMessageText extracts plain text from a Feishu message object
// returned by the Get Message API.
func extractFeishuMessageText(msg *larkim.Message) string {
if msg == nil || msg.Body == nil || msg.Body.Content == nil {
return ""
}
content := strings.TrimSpace(*msg.Body.Content)
if content == "" {
return ""
}
var contentMap map[string]any
if err := json.Unmarshal([]byte(content), &contentMap); err != nil {
return ""
}
msgType := ptrStr(msg.MsgType)
switch msgType {
case larkim.MsgTypeText:
if txt, ok := contentMap["text"].(string); ok {
return strings.TrimSpace(txt)
}
case larkim.MsgTypePost:
return extractFeishuPostText(contentMap)
}
// Fallback: try "text" key for unknown types.
if txt, ok := contentMap["text"].(string); ok {
return strings.TrimSpace(txt)
}
return ""
}
@@ -110,6 +110,7 @@ func (h *WebhookHandler) Handle(c echo.Context) error {
return nil
}
h.adapter.enrichSenderProfile(reqCtx, cfg, event, &msg)
h.adapter.enrichQuotedMessage(reqCtx, cfg, &msg, botOpenID)
msg.BotID = cfg.BotID
return h.manager.HandleInbound(reqCtx, cfg, msg)
})