From abe80de946f912c5d34b7c0e229b72d1855c5a67 Mon Sep 17 00:00:00 2001 From: SYNITY Date: Sun, 28 Jun 2026 10:25:22 +0700 Subject: [PATCH] feat(bitrix24): inject tags into user message so the LLM sees attachments [B24:2794] (#1294) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 --- internal/channels/bitrix24/handle.go | 17 +++ internal/channels/bitrix24/media_tags.go | 60 +++++++++ internal/channels/bitrix24/media_tags_test.go | 116 ++++++++++++++++++ 3 files changed, 193 insertions(+) create mode 100644 internal/channels/bitrix24/media_tags.go create mode 100644 internal/channels/bitrix24/media_tags_test.go diff --git a/internal/channels/bitrix24/handle.go b/internal/channels/bitrix24/handle.go index a74d67e4..9a6f08d3 100644 --- a/internal/channels/bitrix24/handle.go +++ b/internal/channels/bitrix24/handle.go @@ -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 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, diff --git a/internal/channels/bitrix24/media_tags.go b/internal/channels/bitrix24/media_tags.go new file mode 100644 index 00000000..16a72a8a --- /dev/null +++ b/internal/channels/bitrix24/media_tags.go @@ -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 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 +// ( vs ) 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 + } +} diff --git a/internal/channels/bitrix24/media_tags_test.go b/internal/channels/bitrix24/media_tags_test.go new file mode 100644 index 00000000..3eb4ceb2 --- /dev/null +++ b/internal/channels/bitrix24/media_tags_test.go @@ -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, ``) { + t.Errorf("missing document tag with name; got: %q", tags) + } + // Audio: bare tag (no name attribute, no transcript yet). + if !contains(tags, "") { + 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 +}