diff --git a/internal/channel/adapters/discord/discord.go b/internal/channel/adapters/discord/discord.go index bfb63f9c..2bd59891 100644 --- a/internal/channel/adapters/discord/discord.go +++ b/internal/channel/adapters/discord/discord.go @@ -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) +} diff --git a/internal/channel/adapters/feishu/feishu.go b/internal/channel/adapters/feishu/feishu.go index b5e55399..833df38a 100644 --- a/internal/channel/adapters/feishu/feishu.go +++ b/internal/channel/adapters/feishu/feishu.go @@ -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 diff --git a/internal/channel/adapters/feishu/quoted_message.go b/internal/channel/adapters/feishu/quoted_message.go new file mode 100644 index 00000000..aa2f1b43 --- /dev/null +++ b/internal/channel/adapters/feishu/quoted_message.go @@ -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 "" +} diff --git a/internal/channel/adapters/feishu/webhook_handler.go b/internal/channel/adapters/feishu/webhook_handler.go index 52115e40..2f464e15 100644 --- a/internal/channel/adapters/feishu/webhook_handler.go +++ b/internal/channel/adapters/feishu/webhook_handler.go @@ -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) })