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.
This commit is contained in:
Viet Tran authored and GitHub committed 2026-03-11 07:58:05 +07:00
1 parent cd2e407b29
commit 6a51e8d7c4
2 files changed
+82 -10

No files matched your search

+72 -3
View File
@@ -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"`
+10 -7
View File
@@ -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)