@@ -3,13 +3,18 @@ package service
33import (
44 "bytes"
55 "context"
6+ "encoding/base64"
67 "encoding/json"
78 "errors"
89 "fmt"
910 "io"
1011 "log/slog"
1112 "math/rand"
13+ "mime"
1214 "net/http"
15+ neturl "net/url"
16+ "path"
17+ "strings"
1318 "time"
1419
1520 brtErrors "github.com/EvolutionAPI/evo-bot-runtime/internal/errors"
@@ -19,6 +24,31 @@ import (
1924// maxResponseBytes caps the AI Processor response body to prevent OOM on oversized payloads.
2025const maxResponseBytes = 1 << 20 // 1 MiB
2126
27+ // maxAttachmentBytes caps a single downloaded incoming attachment (EVO-2180).
28+ // Images routinely exceed maxResponseBytes (1 MiB); base64 inflates ~33%, so keep
29+ // this conservative relative to the processor's request-body limit.
30+ const maxAttachmentBytes = 15 << 20 // 15 MiB
31+
32+ // maxAttachmentsTotalBytes caps the SUM of every attachment forwarded in one call.
33+ // The debounce window aggregates the media of all messages in it, so a per-file cap
34+ // alone lets a photo burst build a body of len(attachments) x maxAttachmentBytes.
35+ // Base64 inflates that ~33% and the gateway rejects the POST (nginx
36+ // client_max_body_size) — and a 413 is not retryable, so the customer would lose the
37+ // text reply as well. Over budget, the remaining attachments are dropped and the
38+ // call proceeds with what fits.
39+ const maxAttachmentsTotalBytes = 20 << 20 // 20 MiB (~27 MiB once base64-encoded)
40+
41+ // Attachment downloads run before the AI call and outside its retry ceiling, so they
42+ // need a bound of their own: reusing the AI timeout (AI_CALL_TIMEOUT_SECONDS,
43+ // default 30s) meant an unreachable media host stalled every turn for
44+ // timeout x len(attachments) before the processor was even called.
45+ // maxAttachmentDownload bounds one download; the whole set shares
46+ // attachmentsTotalTimeFactor times that.
47+ const (
48+ maxAttachmentDownload = 10 * time .Second
49+ attachmentsTotalTimeFactor = 3
50+ )
51+
2252// maxBackoff caps the exponential backoff between retries so a large
2353// AI_CALL_RETRY_BASE_MS or retry count cannot balloon the wait.
2454const maxBackoff = 5 * time .Second
@@ -83,6 +113,12 @@ func (a *aiAdapter) Call(ctx context.Context, req *model.A2ARequest) (*model.Nor
83113 // fall back to the numeric ID only when the metadata is absent (legacy callers).
84114 userID := contactUserID (req .Metadata , req .ContactID )
85115
116+ // Message parts: text first, then one file part per downloaded attachment.
117+ // EVO-2180: downloads happen ONCE here (before Marshal), so the byte-identical
118+ // body is reused across retries.
119+ parts := []model.JSONRPCPart {{Type : "text" , Text : req .Message }}
120+ parts = append (parts , a .buildFileParts (ctx , req )... )
121+
86122 rpcReq := model.JSONRPCRequest {
87123 JSONRPC : "2.0" ,
88124 ID : fmt .Sprintf ("%d:%d" , req .ContactID , req .ConversationID ),
@@ -91,10 +127,8 @@ func (a *aiAdapter) Call(ctx context.Context, req *model.A2ARequest) (*model.Nor
91127 ContextID : contextID ,
92128 UserID : userID ,
93129 Message : model.JSONRPCMessage {
94- Role : "user" ,
95- Parts : []model.JSONRPCPart {
96- {Type : "text" , Text : req .Message },
97- },
130+ Role : "user" ,
131+ Parts : parts ,
98132 },
99133 Metadata : nonNilMetadata (req .Metadata ),
100134 },
@@ -287,6 +321,192 @@ func nonNilMetadata(m map[string]any) map[string]any {
287321 return m
288322}
289323
324+ // buildFileParts downloads each incoming attachment and returns it as a base64 A2A
325+ // file part. A failure (unreachable/private URL, non-200, oversize, timeout) is
326+ // logged and skipped — the text-only message must always survive a media failure.
327+ // EVO-2180.
328+ func (a * aiAdapter ) buildFileParts (ctx context.Context , req * model.A2ARequest ) []model.JSONRPCPart {
329+ if len (req .Attachments ) == 0 {
330+ return nil
331+ }
332+ perDownload := a .attachmentTimeout ()
333+ budgetCtx , cancelBudget := context .WithTimeout (ctx , attachmentsTotalTimeFactor * perDownload )
334+ defer cancelBudget ()
335+
336+ parts := make ([]model.JSONRPCPart , 0 , len (req .Attachments ))
337+ remaining := maxAttachmentsTotalBytes
338+ for i , att := range req .Attachments {
339+ if att .URL == "" {
340+ continue
341+ }
342+ if budgetCtx .Err () != nil {
343+ slog .Warn ("pipeline.ai.attachment.budget_exhausted" ,
344+ "contact_id" , req .ContactID ,
345+ "conversation_id" , req .ConversationID ,
346+ "limit" , "time" ,
347+ "forwarded" , len (parts ),
348+ "dropped" , len (req .Attachments )- i ,
349+ )
350+ break
351+ }
352+ limit := min (maxAttachmentBytes , remaining )
353+ if limit <= 0 {
354+ slog .Warn ("pipeline.ai.attachment.budget_exhausted" ,
355+ "contact_id" , req .ContactID ,
356+ "conversation_id" , req .ConversationID ,
357+ "limit" , "bytes" ,
358+ "forwarded" , len (parts ),
359+ "dropped" , len (req .Attachments )- i ,
360+ )
361+ break
362+ }
363+ data , respContentType , err := a .downloadAttachment (budgetCtx , att .URL , perDownload , limit )
364+ if err != nil {
365+ slog .Warn ("pipeline.ai.attachment.download_failed" ,
366+ "contact_id" , req .ContactID ,
367+ "conversation_id" , req .ConversationID ,
368+ "file_type" , att .FileType ,
369+ "error" , err ,
370+ )
371+ continue
372+ }
373+ mimeType , ok := resolveMimeType (att , respContentType )
374+ if ! ok {
375+ slog .Warn ("pipeline.ai.attachment.skipped_not_media" ,
376+ "contact_id" , req .ContactID ,
377+ "conversation_id" , req .ConversationID ,
378+ "file_type" , att .FileType ,
379+ "declared_content_type" , att .ContentType ,
380+ "response_content_type" , respContentType ,
381+ )
382+ continue
383+ }
384+ remaining -= len (data )
385+ parts = append (parts , model.JSONRPCPart {
386+ Type : "file" ,
387+ File : & model.JSONRPCFile {
388+ Name : attachmentName (att .URL ),
389+ MimeType : mimeType ,
390+ Bytes : base64 .StdEncoding .EncodeToString (data ),
391+ },
392+ })
393+ slog .Info ("pipeline.ai.attachment.forwarded" ,
394+ "contact_id" , req .ContactID ,
395+ "conversation_id" , req .ConversationID ,
396+ "file_type" , att .FileType ,
397+ "mime_type" , mimeType ,
398+ "bytes" , len (data ),
399+ )
400+ }
401+ return parts
402+ }
403+
404+ // attachmentTimeout is the per-download timeout: maxAttachmentDownload, shrunk to
405+ // the configured AI timeout when that is smaller (so a deployment tuned for fast
406+ // failure does not wait longer on media than on the AI call itself).
407+ func (a * aiAdapter ) attachmentTimeout () time.Duration {
408+ d := maxAttachmentDownload
409+ if t := time .Duration (a .timeoutSecs ) * time .Second ; t > 0 && t < d {
410+ d = t
411+ }
412+ return d
413+ }
414+
415+ // downloadAttachment GETs the URL with the adapter's client and the given timeout,
416+ // reading at most limit bytes. It returns the body and the response Content-Type so
417+ // the caller can decide what the bytes actually are.
418+ func (a * aiAdapter ) downloadAttachment (ctx context.Context , url string , timeout time.Duration , limit int ) ([]byte , string , error ) {
419+ dlCtx , cancel := context .WithTimeout (ctx , timeout )
420+ defer cancel ()
421+
422+ httpReq , err := http .NewRequestWithContext (dlCtx , http .MethodGet , url , nil )
423+ if err != nil {
424+ return nil , "" , fmt .Errorf ("new_request: %w" , err )
425+ }
426+ resp , err := a .client .Do (httpReq )
427+ if err != nil {
428+ return nil , "" , fmt .Errorf ("do: %w" , err )
429+ }
430+ defer resp .Body .Close ()
431+ if resp .StatusCode != http .StatusOK {
432+ return nil , "" , fmt .Errorf ("unexpected status %d" , resp .StatusCode )
433+ }
434+ // +1 so an exactly-at-cap read is distinguishable from an oversize one.
435+ data , err := io .ReadAll (io .LimitReader (resp .Body , int64 (limit )+ 1 ))
436+ if err != nil {
437+ return nil , "" , fmt .Errorf ("read: %w" , err )
438+ }
439+ if len (data ) > limit {
440+ return nil , "" , fmt .Errorf ("attachment exceeds the %d bytes still available in this call" , limit )
441+ }
442+ return data , resp .Header .Get ("Content-Type" ), nil
443+ }
444+
445+ // resolveMimeType picks the mime type sent to the AI Processor, which forwards it
446+ // verbatim into the model call (runner_utils: Blob(mime_type=content_type)) — so a
447+ // wrong value here surfaces as a processor-side failure, not a graceful skip.
448+ //
449+ // It prefers the Content-Type of the response actually downloaded (that describes
450+ // the bytes in hand), falls back to what the CRM declared, then to the URL
451+ // extension. It returns false when the payload is a web page: a Rails proxy URL
452+ // answering 200 with an error/login page would otherwise be forwarded as a valid
453+ // image. It also returns false when nothing better than application/octet-stream
454+ // can be determined — an opaque blob is rejected by the model APIs, so dropping it
455+ // keeps the text reply alive instead of failing the whole turn.
456+ func resolveMimeType (att model.Attachment , respContentType string ) (string , bool ) {
457+ respType := mediaTypeOf (respContentType )
458+ declared := mediaTypeOf (att .ContentType )
459+ if isWebPage (respType ) || isWebPage (declared ) {
460+ return "" , false
461+ }
462+ for _ , candidate := range []string {respType , declared , mediaTypeOf (mimeFromURL (att .URL ))} {
463+ if candidate != "" && candidate != octetStream {
464+ return candidate , true
465+ }
466+ }
467+ return "" , false
468+ }
469+
470+ const octetStream = "application/octet-stream"
471+
472+ // mediaTypeOf normalises a Content-Type header to its bare media type
473+ // ("image/jpeg; charset=binary" → "image/jpeg").
474+ func mediaTypeOf (contentType string ) string {
475+ if contentType == "" {
476+ return ""
477+ }
478+ if mt , _ , err := mime .ParseMediaType (contentType ); err == nil {
479+ return mt
480+ }
481+ return strings .ToLower (strings .TrimSpace (strings .Split (contentType , ";" )[0 ]))
482+ }
483+
484+ // isWebPage reports whether a media type is an HTML document rather than media.
485+ func isWebPage (mediaType string ) bool {
486+ return mediaType == "text/html" || mediaType == "application/xhtml+xml"
487+ }
488+
489+ // mimeFromURL guesses a mime type from the URL's file extension. ActiveStorage
490+ // proxy URLs keep the original filename, so this recovers the type when neither the
491+ // CRM nor the storage backend declares a useful one.
492+ func mimeFromURL (rawURL string ) string {
493+ u , err := neturl .Parse (rawURL )
494+ if err != nil {
495+ return ""
496+ }
497+ return mime .TypeByExtension (path .Ext (u .Path ))
498+ }
499+
500+ // attachmentName derives a filename from the URL path (fallback "file").
501+ func attachmentName (rawURL string ) string {
502+ if u , err := neturl .Parse (rawURL ); err == nil {
503+ if base := path .Base (u .Path ); base != "" && base != "." && base != "/" {
504+ return base
505+ }
506+ }
507+ return "file"
508+ }
509+
290510// conversationContextID resolves the contextId for the JSON-RPC call. It reads the
291511// conversation UUID the CRM nests at metadata.evoai_crm_data.conversation.id and
292512// returns it; if any hop is missing or empty it falls back to the numeric
0 commit comments