package services import ( "encoding/json" "fmt" "io" "mime" "net/http" neturl "net/url" "os" "path" "path/filepath" "regexp" "strconv" "strings" "time" ) const ( defaultGiphyAPIBaseURL = "https://api.giphy.com" defaultGiphyMaxResults = 100 giphyBatchSize = 20 giphyDownloadSizeLimit = 50 * 1024 * 1024 ) type GiphyConfig struct { Enabled bool APIKey string MaxResults int Rating string Lang string DownloadDir string BaseURL string } type GiphyService struct { Config GiphyConfig Client *http.Client Gemini *GeminiService Debug func(message string, data any) BaseURL string } type GiphyResult struct { Provider string `json:"provider"` ProviderID string `json:"providerId"` Link string `json:"link,omitempty"` Title string `json:"title"` SearchQuery string `json:"searchQuery"` OriginalQuery string `json:"originalQuery,omitempty"` PreviewURL string `json:"previewUrl"` PreviewStillURL string `json:"previewStillUrl"` FullURL string `json:"fullUrl"` DownloadURL string `json:"downloadUrl"` Width int `json:"width"` Height int `json:"height"` Rating string `json:"rating"` SourcePageURL string `json:"sourcePageUrl"` OpenURL string `json:"openUrl,omitempty"` Source string `json:"source,omitempty"` ActionLabel string `json:"actionLabel,omitempty"` ActionType string `json:"actionType,omitempty"` SecondaryActionLabel string `json:"secondaryActionLabel,omitempty"` PreviewBlockedReason string `json:"previewBlockedReason,omitempty"` Raw map[string]any `json:"raw,omitempty"` } type GiphySearchResponse struct { Provider string `json:"provider"` OriginalQuery string `json:"originalQuery"` ExpandedQueries []string `json:"expandedQueries"` Total int `json:"total"` Items []GiphyResult `json:"items"` Warning string `json:"warning,omitempty"` } type GiphyDownloadRequest struct { ProviderID string `json:"providerId"` Title string `json:"title"` DownloadURL string `json:"downloadUrl"` OriginalQuery string `json:"originalQuery"` SelectedExpansionQuery string `json:"selectedExpansionQuery"` } type GiphyDownloadResponse struct { OK bool `json:"ok"` FileName string `json:"fileName,omitempty"` SavedPath string `json:"savedPath,omitempty"` Error string `json:"error,omitempty"` Message string `json:"message,omitempty"` } type giphySearchAPIResponse struct { Data []giphyAPIItem `json:"data"` } type giphyAPIItem struct { ID string `json:"id"` Title string `json:"title"` Slug string `json:"slug"` Rating string `json:"rating"` URL string `json:"url"` Images struct { Original giphyRendition `json:"original"` OriginalStill giphyRendition `json:"original_still"` FixedWidth giphyRendition `json:"fixed_width"` FixedWidthStill giphyRendition `json:"fixed_width_still"` FixedWidthDownsample giphyRendition `json:"fixed_width_downsampled"` FixedHeight giphyRendition `json:"fixed_height"` PreviewGIF giphyRendition `json:"preview_gif"` Downsized giphyRendition `json:"downsized"` DownsizedLarge giphyRendition `json:"downsized_large"` } `json:"images"` } type giphyRendition struct { URL string `json:"url"` MP4 string `json:"mp4"` WebP string `json:"webp"` Width string `json:"width"` Height string `json:"height"` } func NewGiphyService(config GiphyConfig, gemini *GeminiService) *GiphyService { if config.MaxResults <= 0 { config.MaxResults = defaultGiphyMaxResults } if strings.TrimSpace(config.Rating) == "" { config.Rating = "g" } if strings.TrimSpace(config.Lang) == "" { config.Lang = "en" } if strings.TrimSpace(config.BaseURL) == "" { config.BaseURL = defaultGiphyAPIBaseURL } return &GiphyService{ Config: config, BaseURL: strings.TrimRight(config.BaseURL, "/"), Client: &http.Client{Timeout: 20 * time.Second}, Gemini: gemini, } } func (s *GiphyService) SearchImages(query string, requestedMax int) (GiphySearchResponse, error) { response := GiphySearchResponse{ Provider: "giphy", OriginalQuery: strings.TrimSpace(query), } if !s.Config.Enabled { return response, fmt.Errorf("giphy is disabled") } if response.OriginalQuery == "" { return response, fmt.Errorf("query is required") } if strings.TrimSpace(s.Config.APIKey) == "" { return response, fmt.Errorf("giphy api key is not configured") } target := s.Config.MaxResults if requestedMax > 0 { target = minInt(requestedMax, s.Config.MaxResults) } if target <= 0 { target = minInt(defaultGiphyMaxResults, s.Config.MaxResults) } expandedQueries, expansionErr := s.expandQueries(response.OriginalQuery) response.ExpandedQueries = expandedQueries if expansionErr != nil { response.Warning = "Query expansion failed, using fallback search terms." s.debug("giphy:query_expansion_fallback", map[string]any{ "query": response.OriginalQuery, "queries": expandedQueries, "error": expansionErr.Error(), }) } else { s.debug("giphy:query_expansion", map[string]any{ "query": response.OriginalQuery, "queries": expandedQueries, }) } type queryState struct { query string offset int enabled bool } states := make([]queryState, 0, len(expandedQueries)) for _, item := range expandedQueries { states = append(states, queryState{query: item, enabled: true}) } items := make([]GiphyResult, 0, target) seen := map[string]bool{} var allErrs []string var successfulCalls int fetchRound := func(limit int) bool { progress := false for idx := range states { if !states[idx].enabled || len(items) >= target { continue } batchLimit := minInt(limit, target-len(items)) found, err := s.searchQuery(states[idx].query, batchLimit, states[idx].offset, response.OriginalQuery) if err != nil { allErrs = append(allErrs, err.Error()) s.debug("giphy:query_error", map[string]any{ "query": states[idx].query, "offset": states[idx].offset, "error": err.Error(), }) continue } successfulCalls++ s.debug("giphy:query_results", map[string]any{ "query": states[idx].query, "offset": states[idx].offset, "count": len(found), }) if len(found) == 0 { states[idx].enabled = false continue } states[idx].offset += len(found) before := len(items) for _, item := range found { if len(items) >= target { break } if mergeUniqueGiphyResult(&items, item, seen) { progress = true } } if len(found) < batchLimit && len(items) == before { states[idx].enabled = false } } return progress } progress := fetchRound(minInt(giphyBatchSize, target)) for round := 0; len(items) < target && progress && round < 3; round++ { progress = fetchRound(minInt(giphyBatchSize, target-len(items))) } response.Items = items response.Total = len(items) s.debug("giphy:search_complete", map[string]any{ "query": response.OriginalQuery, "expanded": response.ExpandedQueries, "total": response.Total, "target": target, "warning": response.Warning, "successCalls": successfulCalls, }) if response.Total == 0 && len(allErrs) > 0 { return response, fmt.Errorf("giphy search failed: %s", strings.Join(uniqueStrings(allErrs, 3), "; ")) } if len(allErrs) > 0 && response.Warning == "" { response.Warning = "Some GIPHY requests failed, showing partial results." } return response, nil } func (s *GiphyService) DownloadMedia(req GiphyDownloadRequest) (GiphyDownloadResponse, error) { if !s.Config.Enabled { return GiphyDownloadResponse{OK: false, Error: "GIPHY_DISABLED", Message: "GIPHY is disabled"}, fmt.Errorf("giphy is disabled") } if strings.TrimSpace(req.ProviderID) == "" || strings.TrimSpace(req.DownloadURL) == "" { return GiphyDownloadResponse{OK: false, Error: "INVALID_REQUEST", Message: "providerId and downloadUrl are required"}, fmt.Errorf("providerId and downloadUrl are required") } if !isAllowedGiphyDownloadURL(req.DownloadURL) { return GiphyDownloadResponse{OK: false, Error: "INVALID_DOWNLOAD_URL", Message: "Only approved GIPHY media URLs are allowed"}, fmt.Errorf("download url is not on an approved giphy host") } if err := os.MkdirAll(s.Config.DownloadDir, 0o755); err != nil { return GiphyDownloadResponse{OK: false, Error: "DOWNLOAD_DIR_FAILED", Message: "Failed to prepare GIPHY download directory"}, err } httpReq, err := http.NewRequest(http.MethodGet, req.DownloadURL, nil) if err != nil { return GiphyDownloadResponse{OK: false, Error: "DOWNLOAD_FAILED", Message: "Failed to build GIPHY download request"}, err } httpReq.Header.Set("User-Agent", "AI-Media-Hub/1.0") resp, err := s.Client.Do(httpReq) if err != nil { return GiphyDownloadResponse{OK: false, Error: "DOWNLOAD_FAILED", Message: "Failed to download media from GIPHY"}, err } defer resp.Body.Close() if resp.StatusCode >= 300 { return GiphyDownloadResponse{OK: false, Error: "DOWNLOAD_FAILED", Message: "Failed to download media from GIPHY"}, fmt.Errorf("giphy download returned status %d", resp.StatusCode) } if resp.ContentLength > giphyDownloadSizeLimit { return GiphyDownloadResponse{OK: false, Error: "DOWNLOAD_TOO_LARGE", Message: "GIPHY media exceeds the configured size limit"}, fmt.Errorf("giphy download too large: %d", resp.ContentLength) } contentType, _, _ := mime.ParseMediaType(resp.Header.Get("Content-Type")) extension := determineGiphyExtension(req.DownloadURL, contentType) fileName := buildGiphyFilename(req.ProviderID, req.Title, extension, time.Now()) targetPath := filepath.Join(s.Config.DownloadDir, fileName) cleanTargetPath := filepath.Clean(targetPath) cleanBaseDir := filepath.Clean(s.Config.DownloadDir) if !strings.HasPrefix(cleanTargetPath, cleanBaseDir) { return GiphyDownloadResponse{OK: false, Error: "INVALID_PATH", Message: "Resolved download path is invalid"}, fmt.Errorf("resolved giphy download path escaped base directory") } data, err := io.ReadAll(io.LimitReader(resp.Body, giphyDownloadSizeLimit+1)) if err != nil { return GiphyDownloadResponse{OK: false, Error: "DOWNLOAD_FAILED", Message: "Failed to read media from GIPHY"}, err } if int64(len(data)) > giphyDownloadSizeLimit { return GiphyDownloadResponse{OK: false, Error: "DOWNLOAD_TOO_LARGE", Message: "GIPHY media exceeds the configured size limit"}, fmt.Errorf("giphy download exceeded size limit during read") } if err := os.WriteFile(cleanTargetPath, data, 0o644); err != nil { return GiphyDownloadResponse{OK: false, Error: "DOWNLOAD_FAILED", Message: "Failed to save media from GIPHY"}, err } s.debug("giphy:download_success", map[string]any{ "providerId": req.ProviderID, "fileName": fileName, "savedPath": cleanTargetPath, }) return GiphyDownloadResponse{ OK: true, FileName: fileName, SavedPath: cleanTargetPath, }, nil } func (s *GiphyService) expandQueries(query string) ([]string, error) { if s.Gemini == nil { return buildFallbackImageQueries(query, strings.TrimSpace(query)), fmt.Errorf("gemini service is not configured") } return s.Gemini.ExpandImageQueries(query) } func (s *GiphyService) searchQuery(query string, limit, offset int, originalQuery string) ([]GiphyResult, error) { endpoint, err := neturl.Parse(s.BaseURL + "/v1/gifs/search") if err != nil { return nil, err } params := endpoint.Query() params.Set("api_key", s.Config.APIKey) params.Set("q", query) params.Set("limit", strconv.Itoa(limit)) params.Set("offset", strconv.Itoa(max(offset, 0))) params.Set("rating", s.Config.Rating) params.Set("lang", s.Config.Lang) endpoint.RawQuery = params.Encode() req, err := http.NewRequest(http.MethodGet, endpoint.String(), nil) if err != nil { return nil, err } req.Header.Set("Accept", "application/json") resp, err := s.Client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode >= 300 { data, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) return nil, fmt.Errorf("giphy returned status %d: %s", resp.StatusCode, strings.TrimSpace(string(data))) } var payload giphySearchAPIResponse if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { return nil, err } items := make([]GiphyResult, 0, len(payload.Data)) for _, item := range payload.Data { mapped := mapGiphyItem(item, query, originalQuery) if mapped.ProviderID == "" || mapped.FullURL == "" { continue } items = append(items, mapped) } return items, nil } func mapGiphyItem(item giphyAPIItem, searchQuery, originalQuery string) GiphyResult { previewURL := firstNonEmpty( item.Images.FixedWidthDownsample.URL, item.Images.FixedWidth.URL, item.Images.FixedHeight.URL, item.Images.PreviewGIF.URL, item.Images.Downsized.URL, item.Images.Original.URL, ) stillURL := firstNonEmpty( item.Images.FixedWidthStill.URL, item.Images.OriginalStill.URL, item.Images.FixedWidth.URL, item.Images.Downsized.URL, item.Images.Original.URL, ) fullURL := firstNonEmpty( item.Images.Original.URL, item.Images.DownsizedLarge.URL, item.Images.Downsized.URL, item.Images.FixedWidth.URL, ) downloadURL := firstNonEmpty( item.Images.Original.URL, item.Images.DownsizedLarge.URL, item.Images.FixedWidth.URL, item.Images.Original.MP4, ) width := atoiOrZero(firstNonEmpty(item.Images.Original.Width, item.Images.FixedWidth.Width, item.Images.DownsizedLarge.Width)) height := atoiOrZero(firstNonEmpty(item.Images.Original.Height, item.Images.FixedWidth.Height, item.Images.DownsizedLarge.Height)) title := strings.TrimSpace(item.Title) if title == "" { title = strings.ReplaceAll(strings.TrimSpace(item.Slug), "-", " ") } if title == "" { title = "Untitled GIPHY" } return GiphyResult{ Provider: "giphy", ProviderID: strings.TrimSpace(item.ID), Link: strings.TrimSpace(item.URL), Title: title, SearchQuery: searchQuery, OriginalQuery: originalQuery, PreviewURL: previewURL, PreviewStillURL: stillURL, FullURL: fullURL, DownloadURL: downloadURL, Width: width, Height: height, Rating: strings.TrimSpace(item.Rating), SourcePageURL: strings.TrimSpace(item.URL), OpenURL: strings.TrimSpace(item.URL), Source: "GIPHY", ActionLabel: "Download", ActionType: "giphy_download", SecondaryActionLabel: "Open Original", } } func mergeUniqueGiphyResult(items *[]GiphyResult, candidate GiphyResult, seen map[string]bool) bool { for _, key := range giphyDedupKeys(candidate) { if key == "" { continue } if seen[key] { return false } } for _, key := range giphyDedupKeys(candidate) { if key != "" { seen[key] = true } } *items = append(*items, candidate) return true } func giphyDedupKeys(item GiphyResult) []string { keys := []string{} if item.ProviderID != "" { keys = append(keys, "id:"+strings.ToLower(item.ProviderID)) } if item.FullURL != "" { keys = append(keys, "full:"+strings.ToLower(strings.TrimSpace(item.FullURL))) } if item.SourcePageURL != "" { keys = append(keys, "source:"+strings.ToLower(strings.TrimSpace(item.SourcePageURL))) } return keys } func buildFallbackImageQueries(originalQuery, englishBase string) []string { base := strings.TrimSpace(englishBase) if base == "" { base = strings.TrimSpace(originalQuery) } candidates := []string{ base, base + " gif", base + " reaction gif", base + " meme gif", base + " animated gif", base + " reaction image", base + " sticker", } queries := normalizeImageExpansionQueries(candidates) if len(queries) > 5 { return queries[:5] } for len(queries) < 5 { queries = append(queries, fmt.Sprintf("%s gif %d", base, len(queries)+1)) } return queries[:5] } func normalizeImageExpansionQueries(items []string) []string { seen := map[string]bool{} queries := make([]string, 0, 5) for _, item := range items { normalized := sanitizePlainEnglishLine(item) normalized = strings.Join(strings.Fields(normalized), " ") if normalized == "" { continue } if !looksMostlyASCII(normalized) { continue } key := strings.ToLower(normalized) if seen[key] { continue } seen[key] = true queries = append(queries, normalized) } return queries } func isAllowedGiphyDownloadURL(rawURL string) bool { parsed, err := neturl.Parse(strings.TrimSpace(rawURL)) if err != nil { return false } host := strings.ToLower(parsed.Hostname()) if host == "" { return false } if parsed.Scheme != "https" && parsed.Scheme != "http" { return false } return host == "giphy.com" || strings.HasSuffix(host, ".giphy.com") } func buildGiphyFilename(providerID, title, extension string, now time.Time) string { slug := sanitizeGiphyFilenameComponent(title) if slug == "" { slug = "untitled" } providerID = sanitizeGiphyFilenameComponent(providerID) if providerID == "" { providerID = "unknown" } ext := extension if !strings.HasPrefix(ext, ".") { ext = "." + ext } return fmt.Sprintf("giphy_%s_%s_%s%s", providerID, slug, now.Format("20060102_150405"), ext) } func sanitizeGiphyFilenameComponent(value string) string { re := regexp.MustCompile(`[^a-z0-9]+`) normalized := strings.ToLower(strings.TrimSpace(value)) normalized = re.ReplaceAllString(normalized, "-") return strings.Trim(normalized, "-") } func determineGiphyExtension(rawURL, contentType string) string { switch strings.ToLower(strings.TrimSpace(contentType)) { case "image/gif": return ".gif" case "video/mp4": return ".mp4" case "image/webp": return ".webp" case "image/png": return ".png" case "image/jpeg": return ".jpg" } parsed, err := neturl.Parse(rawURL) if err == nil { ext := strings.ToLower(path.Ext(parsed.Path)) switch ext { case ".gif", ".mp4", ".webp", ".png", ".jpg", ".jpeg": if ext == ".jpeg" { return ".jpg" } return ext } } return ".gif" } func atoiOrZero(value string) int { parsed, err := strconv.Atoi(strings.TrimSpace(value)) if err != nil { return 0 } return parsed } func uniqueStrings(items []string, limit int) []string { seen := map[string]bool{} unique := make([]string, 0, minInt(len(items), limit)) for _, item := range items { trimmed := strings.TrimSpace(item) if trimmed == "" || seen[trimmed] { continue } seen[trimmed] = true unique = append(unique, trimmed) if len(unique) >= limit { break } } return unique } func (s *GiphyService) debug(message string, data any) { if s != nil && s.Debug != nil { s.Debug(message, data) } }