Files
Duy Nguyen f8571dec88 feat(channels): extract media from replied-to messages across all channels
When a user replies to a message containing images/attachments and
mentions the bot, the agent now receives media from both the replied-to
message and the current message. Reply media is tagged with
"(from replied message)" so the LLM can distinguish origin.

Changes per channel:
- Telegram: always merge reply + current media (was fallback-only)
- Discord: extract ReferencedMessage content + attachments, implicit mention
- Feishu: extend fetchReplyContext to download parent media via API
- Slack: add fetchThreadParentContext for thread parent files
- Shared: FromReply field on MediaInfo, annotated BuildMediaTags
2026-03-22 16:38:36 +07:00

109 lines
2.7 KiB
Go

package slack
import (
"fmt"
"log/slog"
"strings"
slackapi "github.com/slack-go/slack"
"github.com/nextlevelbuilder/goclaw/internal/channels/media"
)
const defaultMediaMaxBytes int64 = 20 * 1024 * 1024 // 20MB
// mediaItem represents a downloaded file from a Slack message.
type mediaItem struct {
Type string // "image", "audio", "document"
FilePath string // local temp file path
FileName string // original filename
ContentType string // MIME type
FromReply bool // true if media came from a replied-to/thread-parent message
}
// resolveMedia downloads and classifies files attached to a Slack message.
// Returns media items and any extra content (extracted document text).
func (c *Channel) resolveMedia(files []slackapi.File) (items []mediaItem, extraContent string) {
maxBytes := c.config.MediaMaxBytes
if maxBytes == 0 {
maxBytes = defaultMediaMaxBytes
}
for _, f := range files {
// Skip files exceeding size limit before download
if int64(f.Size) > maxBytes {
slog.Warn("slack: file too large, skipping",
"file", f.Name, "size", f.Size, "max", maxBytes)
continue
}
mtype := classifyMime(f.Mimetype)
filePath, err := c.downloadFile(f.Name, f.URLPrivate, f.URLPrivateDownload, maxBytes)
if err != nil {
slog.Warn("slack: file download failed",
"file", f.Name, "error", err)
continue
}
items = append(items, mediaItem{
Type: mtype,
FilePath: filePath,
FileName: f.Name,
ContentType: f.Mimetype,
})
// Extract text from document files
if mtype == "document" {
docContent, err := media.ExtractDocumentContent(filePath, f.Name)
if err != nil {
slog.Warn("slack: document extraction failed",
"file", f.Name, "error", err)
continue
}
if extraContent != "" {
extraContent += "\n"
}
extraContent += docContent
}
}
return items, extraContent
}
// classifyMime maps a MIME type to a media category.
func classifyMime(mime string) string {
switch {
case strings.HasPrefix(mime, "image/"):
return "image"
case strings.HasPrefix(mime, "audio/"):
return "audio"
default:
return "document"
}
}
// buildMediaTags generates content tags for media items.
// Items with FromReply=true are annotated so the LLM can distinguish origin.
func buildMediaTags(items []mediaItem) string {
var tags []string
for _, m := range items {
var tag string
switch m.Type {
case "image":
tag = "<media:image>"
case "audio":
tag = "<media:audio>"
case "document":
tag = fmt.Sprintf("<media:document file=%q>", m.FileName)
}
if tag != "" {
if m.FromReply {
tag += " (from replied message)"
}
tags = append(tags, tag)
}
}
return strings.Join(tags, "\n")
}