mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-25 07:00:48 +09:00
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:
@@ -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)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user