From 6a51e8d7c4973756ba78cc9a24ebe14c18acae00 Mon Sep 17 00:00:00 2001 From: Viet Tran Date: Wed, 11 Mar 2026 07:58:05 +0700 Subject: [PATCH] fix(zalo): download CDN media to temp file and handle []byte credentials (#130) Zalo CDN photo URLs are auth-restricted and expire quickly. Download images to local temp files before passing to the agent, falling back to the raw URL on failure. Also adds PhotoURL field support. Credentials Update() now handles []byte type in the switch case, preventing incorrect encryption when credentials arrive as raw bytes. --- internal/channels/zalo/zalo.go | 75 ++++++++++++++++++++++++-- internal/store/pg/channel_instances.go | 17 +++--- 2 files changed, 82 insertions(+), 10 deletions(-) diff --git a/internal/channels/zalo/zalo.go b/internal/channels/zalo/zalo.go index c727e50e..68af4b55 100644 --- a/internal/channels/zalo/zalo.go +++ b/internal/channels/zalo/zalo.go @@ -13,6 +13,7 @@ import ( "io" "log/slog" "net/http" + "os" "strings" "sync" "time" @@ -231,14 +232,32 @@ func (c *Channel) handleImageMessage(msg *zaloMessage) { content = "[image]" } + // Download photo from Zalo CDN to local temp file (CDN URLs are auth-restricted/expiring) var media []string - if msg.Photo != "" { - media = []string{msg.Photo} + var photoURL string + switch { + case msg.PhotoURL != "": + photoURL = msg.PhotoURL + case msg.Photo != "": + photoURL = msg.Photo } - slog.Debug("zalo image message received", + if photoURL != "" { + localPath, err := c.downloadMedia(photoURL) + if err != nil { + slog.Warn("zalo photo download failed, passing URL as fallback", + "photo_url", photoURL, "error", err) + media = []string{photoURL} + } else { + media = []string{localPath} + } + } + + slog.Info("zalo image message received", "sender_id", senderID, "chat_id", chatID, + "photo_url", photoURL, + "has_media", len(media) > 0, ) metadata := map[string]string{ @@ -316,6 +335,55 @@ func (c *Channel) sendPairingReply(senderID, chatID string) { } } +// --- Media download --- + +const maxMediaBytes = 10 * 1024 * 1024 // 10MB + +// downloadMedia fetches a photo from a Zalo CDN URL and saves it as a local temp file. +// Zalo CDN URLs are auth-restricted and expire, so we must download immediately. +func (c *Channel) downloadMedia(url string) (string, error) { + resp, err := c.client.Get(url) + if err != nil { + return "", fmt.Errorf("fetch: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("http %d", resp.StatusCode) + } + + // Detect extension from Content-Type + ext := ".jpg" + ct := resp.Header.Get("Content-Type") + switch { + case strings.Contains(ct, "png"): + ext = ".png" + case strings.Contains(ct, "gif"): + ext = ".gif" + case strings.Contains(ct, "webp"): + ext = ".webp" + } + + f, err := os.CreateTemp("", "goclaw_zalo_*"+ext) + if err != nil { + return "", fmt.Errorf("create temp: %w", err) + } + defer f.Close() + + n, err := io.Copy(f, io.LimitReader(resp.Body, maxMediaBytes)) + if err != nil { + os.Remove(f.Name()) + return "", fmt.Errorf("write: %w", err) + } + if n == 0 { + os.Remove(f.Name()) + return "", fmt.Errorf("empty response") + } + + slog.Debug("zalo media downloaded", "path", f.Name(), "size", n) + return f.Name(), nil +} + // --- Chunked text sending --- func (c *Channel) sendChunkedText(chatID, text string) error { @@ -358,6 +426,7 @@ type zaloMessage struct { MessageID string `json:"message_id"` Text string `json:"text"` Photo string `json:"photo"` + PhotoURL string `json:"photo_url"` Caption string `json:"caption"` From zaloFrom `json:"from"` Chat zaloChat `json:"chat"` diff --git a/internal/store/pg/channel_instances.go b/internal/store/pg/channel_instances.go index 236d2278..94b8abaf 100644 --- a/internal/store/pg/channel_instances.go +++ b/internal/store/pg/channel_instances.go @@ -147,23 +147,26 @@ func (s *PGChannelInstanceStore) scanInstances(rows *sql.Rows) ([]store.ChannelI func (s *PGChannelInstanceStore) Update(ctx context.Context, id uuid.UUID, updates map[string]any) error { // Encrypt credentials if present if credsVal, ok := updates["credentials"]; ok && credsVal != nil { - var credsStr string + var credsBytes []byte switch v := credsVal.(type) { + case []byte: + credsBytes = v case string: - credsStr = v + credsBytes = []byte(v) default: - // Object/map from JSON — marshal to string for encryption + // Object/map from JSON — marshal to []byte if b, err := json.Marshal(v); err == nil { - credsStr = string(b) + credsBytes = b } } - if credsStr != "" && s.encKey != "" { - encrypted, err := crypto.Encrypt(credsStr, s.encKey) + if len(credsBytes) > 0 && s.encKey != "" { + encrypted, err := crypto.Encrypt(string(credsBytes), s.encKey) if err != nil { return fmt.Errorf("encrypt credentials: %w", err) } - updates["credentials"] = []byte(encrypted) + credsBytes = []byte(encrypted) } + updates["credentials"] = credsBytes } updates["updated_at"] = time.Now() return execMapUpdate(ctx, s.db, "channel_instances", id, updates)