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 +}