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)