Skip to content

Commit d0b3f59

Browse files
committed
feat(dispatch): detectar URLs de mídia e enviá-las no postback
O dispatch enviava toda resposta como content_type text, então links de mídia chegavam como texto. Agora extrai URLs de mídia (por extensão) do conteúdo ANTES de segmentar, envia o texto residual segmentado normalmente, e despacha os attachments num postback dedicado ao final (evita anexar mídia ao part de texto errado em respostas multi-segmento). - postbackRequest ganha campo Attachments []postbackAttachment {url, file_type}. - extractMediaURLs/mediaFileType espelham o Ruby MediaTypeDetector (mesmas extensões, document→file, casa querystring/fragment). O CRM mantém detecção própria como fallback, então divergência Go/Ruby degrada sem perder mídia. Realinhado dispatch_engine_test.go (órfão de refactor anterior do NewDispatchEngine) e adicionado media_detection_test.go (table tests de detecção/extração). Testes verdes. DEPLOY: subir DEPOIS do evo-ai-crm-community (que processa os attachments).
1 parent 2e3fa20 commit d0b3f59

3 files changed

Lines changed: 193 additions & 15 deletions

File tree

pkg/dispatch/service/dispatch_engine.go

Lines changed: 110 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"io"
99
"log/slog"
1010
"net/http"
11+
"regexp"
1112
"strings"
1213
"time"
1314
"unicode/utf8"
@@ -32,9 +33,18 @@ type DispatchEngine interface {
3233

3334
// postbackRequest is the JSON body for each HTTP POST to the postback endpoint.
3435
type postbackRequest struct {
35-
Content string `json:"content"`
36-
MessageType string `json:"message_type"`
37-
ContentType string `json:"content_type"`
36+
Content string `json:"content"`
37+
MessageType string `json:"message_type"`
38+
ContentType string `json:"content_type"`
39+
Attachments []postbackAttachment `json:"attachments,omitempty"`
40+
}
41+
42+
// postbackAttachment is a media URL detected in the AI response, sent to the CRM
43+
// so it can download and render it as real media instead of a plain text link.
44+
// FileType matches the CRM Attachment enum: image / audio / video / file.
45+
type postbackAttachment struct {
46+
URL string `json:"url"`
47+
FileType string `json:"file_type"`
3848
}
3949

4050
type dispatchEngineImpl struct {
@@ -63,7 +73,13 @@ func (d *dispatchEngineImpl) Dispatch(
6373
cfg model.BotConfig,
6474
postbackURL string,
6575
) error {
66-
parts := segmentContent(content, cfg)
76+
// Pull media URLs out of the full response BEFORE segmenting, so media is
77+
// not split across text parts. Media is delivered in a single dedicated
78+
// postback after the text parts (see below). The CRM also re-detects media
79+
// from the text as a fallback, so this is an optimization, not the only path.
80+
residual, atts := extractMediaURLs(content)
81+
82+
parts := segmentContent(residual, cfg)
6783

6884
// Prepend signature to the first part (FR-21)
6985
if cfg.MessageSignature != "" && len(parts) > 0 {
@@ -73,6 +89,12 @@ func (d *dispatchEngineImpl) Dispatch(
7389
start := time.Now()
7490

7591
for i, part := range parts {
92+
// Skip empty residual (e.g. response was only a media URL): the media
93+
// postback below still runs.
94+
if part == "" {
95+
continue
96+
}
97+
7698
// Check cancellation BEFORE sending this part
7799
select {
78100
case <-ctx.Done():
@@ -85,7 +107,7 @@ func (d *dispatchEngineImpl) Dispatch(
85107
default:
86108
}
87109

88-
if err := d.sendPart(ctx, postbackURL, part); err != nil {
110+
if err := d.sendPart(ctx, postbackURL, part, nil); err != nil {
89111
return fmt.Errorf("pipeline.dispatch.send[%d]: %w", i, err)
90112
}
91113

@@ -105,21 +127,36 @@ func (d *dispatchEngineImpl) Dispatch(
105127
}
106128
}
107129

130+
// Deliver media (if any) in a single dedicated postback after the text.
131+
if len(atts) > 0 {
132+
select {
133+
case <-ctx.Done():
134+
return brtErrors.ErrDispatchInterrupted
135+
default:
136+
}
137+
if err := d.sendPart(ctx, postbackURL, "", atts); err != nil {
138+
return fmt.Errorf("pipeline.dispatch.send[media]: %w", err)
139+
}
140+
}
141+
108142
slog.Info("pipeline.dispatch.completed",
109143
"contact_id", contactID,
110144
"conversation_id", conversationID,
111145
"duration_ms", time.Since(start).Milliseconds(),
112146
"parts_total", len(parts),
147+
"attachments", len(atts),
113148
)
114149
return nil
115150
}
116151

117-
// sendPart sends a single content part to the postback URL.
118-
func (d *dispatchEngineImpl) sendPart(ctx context.Context, postbackURL, content string) error {
152+
// sendPart sends a single content part (and optional media attachments) to the
153+
// postback URL.
154+
func (d *dispatchEngineImpl) sendPart(ctx context.Context, postbackURL, content string, atts []postbackAttachment) error {
119155
body, err := json.Marshal(postbackRequest{
120156
Content: content,
121157
MessageType: "outgoing",
122158
ContentType: "text",
159+
Attachments: atts,
123160
})
124161
if err != nil {
125162
return fmt.Errorf("marshal: %w", err)
@@ -205,3 +242,69 @@ func segmentContent(content string, cfg model.BotConfig) []string {
205242
}
206243
return merged
207244
}
245+
246+
// --- Media URL detection ---------------------------------------------------
247+
//
248+
// extractMediaURLs pulls media URLs (by file extension) out of the response
249+
// text and returns the residual text plus the detected attachments.
250+
//
251+
// DRIFT WARNING: this MUST stay in sync with the Ruby
252+
// AgentBots::MediaTypeDetector (app/services/agent_bots/media_type_detector.rb)
253+
// in evo-ai-crm-community. The CRM re-detects media from text as a fallback, so
254+
// a divergence degrades gracefully rather than losing media.
255+
256+
var urlRegex = regexp.MustCompile(`https?://[^\s<>"']+`)
257+
258+
// extension -> file_type (matches Attachment enum; document maps to "file").
259+
var mediaExtToFileType = func() map[string]string {
260+
m := map[string]string{}
261+
for _, e := range []string{"jpg", "jpeg", "png", "gif", "bmp", "webp", "svg", "tiff"} {
262+
m[e] = "image"
263+
}
264+
for _, e := range []string{"mp3", "wav", "ogg", "m4a", "aac", "flac"} {
265+
m[e] = "audio"
266+
}
267+
for _, e := range []string{"mp4", "avi", "mov", "wmv", "flv", "mkv", "webm"} {
268+
m[e] = "video"
269+
}
270+
for _, e := range []string{"pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "rtf", "odt"} {
271+
m[e] = "file"
272+
}
273+
return m
274+
}()
275+
276+
// trailing punctuation often captured when a URL ends a sentence.
277+
const trailingPunct = ")].,!?;:"
278+
279+
func extractMediaURLs(content string) (residual string, atts []postbackAttachment) {
280+
residual = content
281+
for _, rawURL := range urlRegex.FindAllString(content, -1) {
282+
url := strings.TrimRight(rawURL, trailingPunct)
283+
fileType := mediaFileType(url)
284+
if fileType == "" {
285+
continue // non-media URL stays in the text
286+
}
287+
atts = append(atts, postbackAttachment{URL: url, FileType: fileType})
288+
residual = strings.Replace(residual, rawURL, "", 1)
289+
}
290+
return strings.TrimSpace(residual), atts
291+
}
292+
293+
// mediaFileType returns the Attachment file_type for a URL, or "" if not media.
294+
// Matches the extension in the PATH, ignoring query string / fragment.
295+
func mediaFileType(url string) string {
296+
path := url
297+
if i := strings.IndexAny(path, "?#"); i >= 0 {
298+
path = path[:i]
299+
}
300+
seg := path
301+
if i := strings.LastIndex(seg, "/"); i >= 0 {
302+
seg = seg[i+1:]
303+
}
304+
dot := strings.LastIndex(seg, ".")
305+
if dot < 0 {
306+
return ""
307+
}
308+
ext := strings.ToLower(seg[dot+1:])
309+
return mediaExtToFileType[ext]
310+
}

pkg/dispatch/service/dispatch_engine_test.go

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ func collectParts(t *testing.T) (*httptest.Server, *[]string, *sync.Mutex) {
4242
func TestDispatch_MultiPart_SignatureOnFirstOnly(t *testing.T) {
4343
server, partsPtr, mu := collectParts(t)
4444

45-
eng := service.NewDispatchEngine()
45+
eng := service.NewDispatchEngine("")
4646
cfg := model.BotConfig{
4747
TextSegmentationEnabled: true,
4848
TextSegmentationLimit: 15, // forces multiple parts
@@ -79,7 +79,7 @@ func TestDispatch_MultiPart_SignatureOnFirstOnly(t *testing.T) {
7979
func TestDispatch_NoSegmentation_SinglePart(t *testing.T) {
8080
server, partsPtr, mu := collectParts(t)
8181

82-
eng := service.NewDispatchEngine()
82+
eng := service.NewDispatchEngine("")
8383
cfg := model.BotConfig{
8484
TextSegmentationEnabled: false,
8585
MessageSignature: "—signature ",
@@ -118,7 +118,7 @@ func TestDispatch_Cancellation_ReturnsInterrupted(t *testing.T) {
118118

119119
ctx, cancel := context.WithCancel(context.Background())
120120

121-
eng := service.NewDispatchEngine()
121+
eng := service.NewDispatchEngine("")
122122
cfg := model.BotConfig{
123123
TextSegmentationEnabled: true,
124124
TextSegmentationLimit: 5, // small limit → many parts
@@ -149,7 +149,7 @@ func TestDispatch_Cancellation_ReturnsInterrupted(t *testing.T) {
149149
func TestDispatch_EmptySignature_NoSuffix(t *testing.T) {
150150
server, partsPtr, mu := collectParts(t)
151151

152-
eng := service.NewDispatchEngine()
152+
eng := service.NewDispatchEngine("")
153153
cfg := model.BotConfig{
154154
TextSegmentationEnabled: false,
155155
MessageSignature: "", // empty — no suffix
@@ -178,7 +178,7 @@ func TestDispatch_NonOKResponse_ReturnsError(t *testing.T) {
178178
}))
179179
defer server.Close()
180180

181-
eng := service.NewDispatchEngine()
181+
eng := service.NewDispatchEngine("")
182182
cfg := model.BotConfig{TextSegmentationEnabled: false}
183183

184184
err := eng.Dispatch(context.Background(), 8, 8, "some content", cfg, server.URL)
@@ -192,7 +192,7 @@ func TestSegmentContent_MergeDoesNotExceedLimit(t *testing.T) {
192192
// would produce "hello world test"(16 runes) > limit=11 → must NOT merge.
193193
server, partsPtr, mu := collectParts(t)
194194

195-
eng := service.NewDispatchEngine()
195+
eng := service.NewDispatchEngine("")
196196
cfg := model.BotConfig{
197197
TextSegmentationEnabled: true,
198198
TextSegmentationLimit: 11,
@@ -226,7 +226,7 @@ func TestSegmentContent_RuneAwareLimits(t *testing.T) {
226226
// A byte-counting bug would compute 10 bytes > 9 and wrongly split into 2 parts.
227227
server, partsPtr, mu := collectParts(t)
228228

229-
eng := service.NewDispatchEngine()
229+
eng := service.NewDispatchEngine("")
230230
cfg := model.BotConfig{
231231
TextSegmentationEnabled: true,
232232
TextSegmentationLimit: 9, // rune limit — "olá mundo" is exactly 9 runes
@@ -262,7 +262,7 @@ func TestDispatch_ValidatesPostBody(t *testing.T) {
262262
}))
263263
defer server.Close()
264264

265-
eng := service.NewDispatchEngine()
265+
eng := service.NewDispatchEngine("")
266266
cfg := model.BotConfig{TextSegmentationEnabled: false}
267267

268268
if err := eng.Dispatch(context.Background(), 5, 5, "test content", cfg, server.URL); err != nil {
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package service
2+
3+
import (
4+
"reflect"
5+
"testing"
6+
)
7+
8+
func TestMediaFileType(t *testing.T) {
9+
cases := map[string]string{
10+
"https://x/v.mp4": "video",
11+
"https://x/v.MP4": "video",
12+
"https://x/v.mp4?token=abc": "video", // querystring ignored
13+
"https://x/v.mov#t=10": "video",
14+
"https://x/pic.jpg": "image",
15+
"https://x/pic.png": "image",
16+
"https://x/song.mp3": "audio",
17+
"https://x/doc.pdf": "file", // document -> file
18+
"https://x/sheet.xlsx": "file",
19+
"https://site.com/page": "", // no extension
20+
"https://site.com/page.html": "", // not media
21+
"https://x/no-dot/segment": "",
22+
}
23+
for url, want := range cases {
24+
if got := mediaFileType(url); got != want {
25+
t.Errorf("mediaFileType(%q) = %q, want %q", url, got, want)
26+
}
27+
}
28+
}
29+
30+
func TestExtractMediaURLs(t *testing.T) {
31+
t.Run("text with trailing media url", func(t *testing.T) {
32+
text, atts := extractMediaURLs("Assiste aí https://pedrofelixtreinador.com.br/x/VLS_Atleta.mp4")
33+
if text != "Assiste aí" {
34+
t.Errorf("text = %q, want %q", text, "Assiste aí")
35+
}
36+
want := []postbackAttachment{{URL: "https://pedrofelixtreinador.com.br/x/VLS_Atleta.mp4", FileType: "video"}}
37+
if !reflect.DeepEqual(atts, want) {
38+
t.Errorf("atts = %+v, want %+v", atts, want)
39+
}
40+
})
41+
42+
t.Run("media only (residual empty)", func(t *testing.T) {
43+
text, atts := extractMediaURLs("https://x/pic.jpg")
44+
if text != "" {
45+
t.Errorf("text = %q, want empty", text)
46+
}
47+
if len(atts) != 1 || atts[0].FileType != "image" {
48+
t.Errorf("atts = %+v, want one image", atts)
49+
}
50+
})
51+
52+
t.Run("non-media url stays in text", func(t *testing.T) {
53+
text, atts := extractMediaURLs("veja em https://site.com/pagina")
54+
if text != "veja em https://site.com/pagina" {
55+
t.Errorf("text = %q, should keep non-media url", text)
56+
}
57+
if len(atts) != 0 {
58+
t.Errorf("atts = %+v, want none", atts)
59+
}
60+
})
61+
62+
t.Run("no urls", func(t *testing.T) {
63+
text, atts := extractMediaURLs("plain text only")
64+
if text != "plain text only" || len(atts) != 0 {
65+
t.Errorf("got text=%q atts=%+v", text, atts)
66+
}
67+
})
68+
69+
t.Run("trailing punctuation trimmed", func(t *testing.T) {
70+
_, atts := extractMediaURLs("olha (https://x/v.mp4).")
71+
if len(atts) != 1 || atts[0].URL != "https://x/v.mp4" {
72+
t.Errorf("atts = %+v, want trimmed url", atts)
73+
}
74+
})
75+
}

0 commit comments

Comments
 (0)