feat(bitrix24): inject <media:*> tags into user message so the LLM sees attachments [B24:2794] (#1294)

Without these tags the Bitrix-borne PDF / audio / image arrive as bare text
to the agent loop: the LLM never realizes an attachment exists and degrades
to "no file attached" replies (or worse, hallucinates a CRM document id and
fires crm.documentgenerator at it). The other channels (Telegram, Slack,
Discord) all prepend <media:*> tags before publish via the shared
media.BuildMediaTags helper — Bitrix24 was the lone holdout.

The agent loop's enrichInputMedia REPLACES tags it finds (it does NOT
insert new ones), so adding the tag at the channel boundary is the fix
that aligns Bitrix with the other channels AND unlocks enrich's
read_document / read_audio / read_image routing downstream.

- media_tags.go: mediaFilesToInfos() adapter from bus.MediaFile to
  media.MediaInfo + classifyMediaType() MIME prefix → media.Type* mapping.
  Unknown MIMEs fall back to TypeDocument (read_document handles generic
  binaries safely; a bogus audio tag on a non-audio file would mislead the
  LLM). Bitrix ships voice notes as audio/* — they're classified as
  TypeAudio (read_audio handles both audio and voice identically).
- handle.go: prepend BuildMediaTags(...) output to text right before
  HandleMessageMedia. No-op when no media files.
- media_tags_test.go: pins the MIME map, the conversion, and the
  end-to-end tag shape through BuildMediaTags.

Surface parity: backend-only. No API / web UI / CLI impact.

Co-authored-by: DangTinh311 <dangtinh31193@gmail.com>
This commit is contained in:
SYNITYandDangTinh311 authored and GitHub committed 2026-06-28 10:25:22 +07:00
1 parent ea00d1f979
commit abe80de946
3 files changed
+193

No files matched your search

+17
View File
@@ -11,6 +11,7 @@ import (
"time"
"github.com/nextlevelbuilder/goclaw/internal/channels"
"github.com/nextlevelbuilder/goclaw/internal/channels/media"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
@@ -382,6 +383,22 @@ func (c *Channel) handleMessage(ctx context.Context, evt *Event) {
// the agent with their MIME type preserved. Best-effort: failures are logged
// inside downloadEventFiles and never block the text from reaching the agent.
mediaFiles := c.downloadEventFiles(ctx, c.BotID(), evt.Params.Files)
// Prepend <media:*> tags so the LLM sees the attachment alongside the body
// text. The agent loop's enrichInputMedia REPLACES tags it finds (it does
// NOT insert new ones), so without this step a Bitrix-borne PDF / audio
// arrives as bare text — the LLM never realizes an attachment exists and
// degrades to "no file attached" or hallucinates a CRM document. Match
// the Telegram / Slack / Discord pattern (shared media.BuildMediaTags) so
// the tag shape stays uniform across channels.
if len(mediaFiles) > 0 {
if tags := media.BuildMediaTags(mediaFilesToInfos(mediaFiles)); tags != "" {
if text == "" {
text = tags
} else {
text = tags + "\n" + text
}
}
}
slog.Info("bitrix24 message: publish to bus",
"sender_id", senderID,
"chat_id", chatID,
+60
View File
@@ -0,0 +1,60 @@
package bitrix24
import (
"strings"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/channels/media"
)
// mediaFilesToInfos converts the Bitrix24 channel's bus.MediaFile slice into
// the shared media.MediaInfo values so the channel can reuse the cross-channel
// media.BuildMediaTags helper. Telegram / Slack / Discord all use the same
// helper — having Bitrix follow suit keeps the <media:*> tag shape uniform
// across channels so the agent loop's enrichInputMedia (which REPLACES tags
// it finds, not inserts new ones) can match and inject persisted media IDs
// + file paths the same way it does for the other channels.
//
// MIME classification follows the inline routing comment in download.go
// ("image / document / audio / video") so the tag the LLM sees matches the
// read_* tool routing the agent loop performs downstream.
func mediaFilesToInfos(files []bus.MediaFile) []media.MediaInfo {
if len(files) == 0 {
return nil
}
out := make([]media.MediaInfo, 0, len(files))
for _, f := range files {
out = append(out, media.MediaInfo{
Type: classifyMediaType(f.MimeType),
FilePath: f.Path,
ContentType: f.MimeType,
FileName: f.Filename,
})
}
return out
}
// classifyMediaType maps a MIME type prefix to a media.Type* constant.
//
// Unknown MIMEs fall back to TypeDocument because the read_document tool is
// the safest catch-all: its local parser handles PDF / DOCX / archives and
// delegates to a vision-capable provider for anything else. A bogus
// "audio" tag on a non-audio file would mislead the LLM into calling
// read_audio and getting a useless transcript; "document" stays neutral.
//
// Bitrix webhook does not distinguish voice notes from generic audio in
// the MIME (both ship as audio/ogg or audio/mpeg). We classify all of them
// as TypeAudio — TypeVoice's only behavioral difference is the tag name
// (<media:voice> vs <media:audio>) and read_audio handles both identically.
func classifyMediaType(mime string) string {
switch {
case strings.HasPrefix(mime, "image/"):
return media.TypeImage
case strings.HasPrefix(mime, "video/"):
return media.TypeVideo
case strings.HasPrefix(mime, "audio/"):
return media.TypeAudio
default:
return media.TypeDocument
}
}
@@ -0,0 +1,116 @@
package bitrix24
import (
"testing"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/channels/media"
)
// TestClassifyMediaType pins the MIME → media.Type* mapping. The fallback to
// TypeDocument is the safest default: it routes through read_document's local
// parser + vision provider chain, which handles PDF / DOCX / generic binaries.
// Bogus audio/video classification on an unrelated MIME would mislead the LLM
// into calling read_audio / read_video on something they can't transcribe.
func TestClassifyMediaType(t *testing.T) {
cases := []struct {
mime string
want string
}{
{"image/jpeg", media.TypeImage},
{"image/png", media.TypeImage},
{"image/webp", media.TypeImage},
{"video/mp4", media.TypeVideo},
{"video/webm", media.TypeVideo},
// Bitrix ships voice notes as audio/* in the MIME — TypeAudio covers
// both regular audio and voice notes here.
{"audio/mpeg", media.TypeAudio},
{"audio/ogg", media.TypeAudio},
{"audio/wav", media.TypeAudio},
// PDF / DOCX / generic binaries route to TypeDocument.
{"application/pdf", media.TypeDocument},
{"application/vnd.openxmlformats-officedocument.wordprocessingml.document", media.TypeDocument},
{"application/octet-stream", media.TypeDocument},
{"text/plain", media.TypeDocument},
// Unknown / empty MIME stays as TypeDocument (safest fallback).
{"", media.TypeDocument},
{"weird/unknown", media.TypeDocument},
}
for _, tc := range cases {
t.Run(tc.mime, func(t *testing.T) {
if got := classifyMediaType(tc.mime); got != tc.want {
t.Errorf("classifyMediaType(%q) = %q; want %q", tc.mime, got, tc.want)
}
})
}
}
// TestMediaFilesToInfos pins the bus.MediaFile → media.MediaInfo conversion:
// FilePath / FileName / ContentType passthrough, Type via classifyMediaType,
// nil input → nil output, multiple files preserve order.
func TestMediaFilesToInfos(t *testing.T) {
t.Run("nil input returns nil", func(t *testing.T) {
if got := mediaFilesToInfos(nil); got != nil {
t.Errorf("nil input: got %v, want nil", got)
}
})
t.Run("empty slice returns nil", func(t *testing.T) {
if got := mediaFilesToInfos([]bus.MediaFile{}); got != nil {
t.Errorf("empty slice: got %v, want nil", got)
}
})
t.Run("mixed kinds preserved with correct Type", func(t *testing.T) {
in := []bus.MediaFile{
{Path: "/tmp/a.pdf", MimeType: "application/pdf", Filename: "report.pdf"},
{Path: "/tmp/b.mp3", MimeType: "audio/mpeg", Filename: "voice.mp3"},
{Path: "/tmp/c.jpg", MimeType: "image/jpeg", Filename: "photo.jpg"},
}
got := mediaFilesToInfos(in)
if len(got) != 3 {
t.Fatalf("len = %d, want 3", len(got))
}
wantTypes := []string{media.TypeDocument, media.TypeAudio, media.TypeImage}
wantNames := []string{"report.pdf", "voice.mp3", "photo.jpg"}
for i, info := range got {
if info.Type != wantTypes[i] {
t.Errorf("[%d] Type = %q; want %q", i, info.Type, wantTypes[i])
}
if info.FileName != wantNames[i] {
t.Errorf("[%d] FileName = %q; want %q", i, info.FileName, wantNames[i])
}
if info.FilePath != in[i].Path {
t.Errorf("[%d] FilePath = %q; want %q", i, info.FilePath, in[i].Path)
}
if info.ContentType != in[i].MimeType {
t.Errorf("[%d] ContentType = %q; want %q", i, info.ContentType, in[i].MimeType)
}
}
})
t.Run("end-to-end with BuildMediaTags produces expected tags", func(t *testing.T) {
in := []bus.MediaFile{
{Path: "/tmp/x.pdf", MimeType: "application/pdf", Filename: "HDDT (12).pdf"},
{Path: "/tmp/y.mp3", MimeType: "audio/mpeg", Filename: "song.mp3"},
}
tags := media.BuildMediaTags(mediaFilesToInfos(in))
// Document: name attribute included (BuildMediaTags emits name=).
if !contains(tags, `<media:document name="HDDT (12).pdf">`) {
t.Errorf("missing document tag with name; got: %q", tags)
}
// Audio: bare tag (no name attribute, no transcript yet).
if !contains(tags, "<media:audio>") {
t.Errorf("missing audio tag; got: %q", tags)
}
})
}
func contains(s, sub string) bool {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}