Add GIPHY image search feature
This commit is contained in:
@@ -20,6 +20,7 @@ import (
|
||||
|
||||
type GeminiService struct {
|
||||
APIKey string
|
||||
Model string
|
||||
Client *http.Client
|
||||
GenerateEndpoint string
|
||||
TranslateEndpoint string
|
||||
@@ -69,11 +70,15 @@ type QueryExpansion struct {
|
||||
Querywords []string `json:"querywords"`
|
||||
}
|
||||
|
||||
func NewGeminiService(apiKey string) *GeminiService {
|
||||
func NewGeminiService(apiKey, model string) *GeminiService {
|
||||
if strings.TrimSpace(model) == "" {
|
||||
model = "gemini-2.5-flash"
|
||||
}
|
||||
return &GeminiService{
|
||||
APIKey: apiKey,
|
||||
Model: model,
|
||||
Client: &http.Client{Timeout: 40 * time.Second},
|
||||
GenerateEndpoint: "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent",
|
||||
GenerateEndpoint: "https://generativelanguage.googleapis.com/v1beta/models/" + model + ":generateContent",
|
||||
TranslateEndpoint: "https://translate.googleapis.com/translate_a/single",
|
||||
visualCache: map[string]cachedVisualData{},
|
||||
translationCache: map[string]cachedStringValue{},
|
||||
@@ -99,6 +104,75 @@ func (g *GeminiService) ExpandQuery(query string) ([]string, error) {
|
||||
return expanded, nil
|
||||
}
|
||||
|
||||
func (g *GeminiService) ExpandImageQueries(query string) ([]string, error) {
|
||||
trimmed := strings.TrimSpace(query)
|
||||
if trimmed == "" {
|
||||
return nil, fmt.Errorf("query is empty")
|
||||
}
|
||||
cacheKey := "image-expansion\n" + trimmed
|
||||
if cached, ok := g.getCachedExpansion(cacheKey); ok {
|
||||
g.debug("gemini:image_expand_cache_hit", map[string]any{"query": trimmed, "expanded": cached})
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
fallback := buildFallbackImageQueries(trimmed, g.TranslateQuery(trimmed))
|
||||
if g.APIKey == "" {
|
||||
g.setCachedExpansion(cacheKey, fallback, 15*time.Minute)
|
||||
return fallback, fmt.Errorf("gemini api key is not configured")
|
||||
}
|
||||
|
||||
body := map[string]any{
|
||||
"systemInstruction": map[string]any{
|
||||
"parts": []map[string]string{{
|
||||
"text": "Return exactly 5 concise English search queries for GIPHY image or GIF search. Respond with JSON only in this shape: {\"queries\":[\"...\",\"...\",\"...\",\"...\",\"...\"]}. Keep the queries meaning-preserving, practical, deduplicated, and concise.",
|
||||
}},
|
||||
},
|
||||
"contents": []map[string]any{{
|
||||
"parts": []map[string]string{{
|
||||
"text": "User query: " + trimmed + "\nGenerate exactly 5 English search queries for GIPHY image or GIF search. Include a direct translation, a common phrasing, and only relevant related variants.",
|
||||
}},
|
||||
}},
|
||||
"generationConfig": map[string]any{
|
||||
"responseMimeType": "application/json",
|
||||
"temperature": 0.2,
|
||||
"maxOutputTokens": 160,
|
||||
},
|
||||
}
|
||||
|
||||
rawText, err := g.generateText(body)
|
||||
if err != nil {
|
||||
g.debug("gemini:image_expand_error", map[string]any{"query": trimmed, "error": err.Error()})
|
||||
g.setCachedExpansion(cacheKey, fallback, 15*time.Minute)
|
||||
return fallback, err
|
||||
}
|
||||
jsonText, err := extractJSONObject(rawText)
|
||||
if err != nil {
|
||||
g.debug("gemini:image_expand_parse_error", map[string]any{"query": trimmed, "error": err.Error()})
|
||||
g.setCachedExpansion(cacheKey, fallback, 15*time.Minute)
|
||||
return fallback, err
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Queries []string `json:"queries"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(jsonText), &payload); err != nil {
|
||||
g.debug("gemini:image_expand_json_error", map[string]any{"query": trimmed, "error": err.Error(), "raw": truncateForError(rawText, 200)})
|
||||
g.setCachedExpansion(cacheKey, fallback, 15*time.Minute)
|
||||
return fallback, err
|
||||
}
|
||||
queries := normalizeImageExpansionQueries(payload.Queries)
|
||||
if len(queries) != 5 {
|
||||
err := fmt.Errorf("gemini image expansion returned %d queries", len(queries))
|
||||
g.debug("gemini:image_expand_invalid_count", map[string]any{"query": trimmed, "queries": queries, "error": err.Error()})
|
||||
g.setCachedExpansion(cacheKey, fallback, 15*time.Minute)
|
||||
return fallback, err
|
||||
}
|
||||
|
||||
g.setCachedExpansion(cacheKey, queries, 15*time.Minute)
|
||||
g.debug("gemini:image_expand_success", map[string]any{"query": trimmed, "queries": queries})
|
||||
return queries, nil
|
||||
}
|
||||
|
||||
func (g *GeminiService) TranslateSummaryToKorean(text string) (string, error) {
|
||||
trimmed := strings.TrimSpace(text)
|
||||
if trimmed == "" {
|
||||
|
||||
@@ -15,7 +15,7 @@ func TestTranslateQueryFallsBackToGoogleWithoutGeminiKey(t *testing.T) {
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
service := NewGeminiService("")
|
||||
service := NewGeminiService("", "")
|
||||
service.Client = &http.Client{Timeout: 2 * time.Second}
|
||||
service.TranslateEndpoint = server.URL
|
||||
|
||||
@@ -31,7 +31,7 @@ func TestTranslateQueryFallsBackToDictionaryWhenTranslateFails(t *testing.T) {
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
service := NewGeminiService("")
|
||||
service := NewGeminiService("", "")
|
||||
service.Client = &http.Client{Timeout: 2 * time.Second}
|
||||
service.TranslateEndpoint = server.URL
|
||||
|
||||
@@ -50,7 +50,7 @@ func TestTranslateSummaryToKoreanUsesGoogleAndCaches(t *testing.T) {
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
service := NewGeminiService("")
|
||||
service := NewGeminiService("", "")
|
||||
service.Client = &http.Client{Timeout: 2 * time.Second}
|
||||
service.TranslateEndpoint = server.URL
|
||||
|
||||
@@ -84,7 +84,7 @@ func TestBuildSupplementalQueriesReturnsGeneratedLines(t *testing.T) {
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
service := NewGeminiService("dummy-key")
|
||||
service := NewGeminiService("dummy-key", "")
|
||||
service.Client = &http.Client{Timeout: 2 * time.Second}
|
||||
service.GenerateEndpoint = server.URL
|
||||
|
||||
@@ -99,6 +99,52 @@ func TestBuildSupplementalQueriesReturnsGeneratedLines(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandImageQueriesReturnsExactlyFiveEnglishQueries(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"candidates":[{"content":{"parts":[{"text":"{\"queries\":[\"funny cat\",\"funny cat gif\",\"cute cat reaction\",\"cat meme gif\",\"animated cat sticker\"]}"}]}}]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
service := NewGeminiService("dummy-key", "gemini-2.5-flash")
|
||||
service.Client = &http.Client{Timeout: 2 * time.Second}
|
||||
service.GenerateEndpoint = server.URL
|
||||
|
||||
queries, err := service.ExpandImageQueries("웃긴 고양이")
|
||||
if err != nil {
|
||||
t.Fatalf("expected image query expansion to succeed, got %v", err)
|
||||
}
|
||||
if len(queries) != 5 {
|
||||
t.Fatalf("expected exactly 5 queries, got %#v", queries)
|
||||
}
|
||||
if queries[0] != "funny cat" || queries[4] != "animated cat sticker" {
|
||||
t.Fatalf("unexpected image queries: %#v", queries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandImageQueriesFallsBackWhenGeminiFails(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "boom", http.StatusBadGateway)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
service := NewGeminiService("dummy-key", "gemini-2.5-flash")
|
||||
service.Client = &http.Client{Timeout: 2 * time.Second}
|
||||
service.GenerateEndpoint = server.URL
|
||||
service.TranslateEndpoint = server.URL
|
||||
|
||||
queries, err := service.ExpandImageQueries("happy dog")
|
||||
if err == nil {
|
||||
t.Fatal("expected fallback warning error when gemini expansion fails")
|
||||
}
|
||||
if len(queries) != 5 {
|
||||
t.Fatalf("expected fallback to still provide 5 queries, got %#v", queries)
|
||||
}
|
||||
if queries[0] != "happy dog" {
|
||||
t.Fatalf("expected original query to be preserved in fallback, got %#v", queries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectUnevaluatedCandidatesSkipsReviewedLinks(t *testing.T) {
|
||||
ranked := []SearchResult{
|
||||
{Link: "https://a.example"},
|
||||
@@ -129,7 +175,7 @@ func TestRemainingGeminiCapacityShrinksWithReviewedItems(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGeminiVisualCacheRoundTrip(t *testing.T) {
|
||||
service := NewGeminiService("")
|
||||
service := NewGeminiService("", "")
|
||||
service.setCachedVisual("image\nhttps://example.com/thumb.jpg", "abc", "image/jpeg", time.Minute)
|
||||
|
||||
data, mimeType, ok := service.getCachedVisual("image\nhttps://example.com/thumb.jpg")
|
||||
@@ -142,7 +188,7 @@ func TestGeminiVisualCacheRoundTrip(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGeminiTranslationCacheRoundTrip(t *testing.T) {
|
||||
service := NewGeminiService("")
|
||||
service := NewGeminiService("", "")
|
||||
service.setCachedTranslation("비 오는 도시", "rainy city", time.Minute)
|
||||
|
||||
value, ok := service.getCachedTranslation("비 오는 도시")
|
||||
@@ -155,7 +201,7 @@ func TestGeminiTranslationCacheRoundTrip(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGeminiExpansionCacheRoundTrip(t *testing.T) {
|
||||
service := NewGeminiService("")
|
||||
service := NewGeminiService("", "")
|
||||
service.setCachedExpansion("city rain", []string{"city rain", "city rain stock footage"}, time.Minute)
|
||||
|
||||
value, ok := service.getCachedExpansion("city rain")
|
||||
|
||||
@@ -0,0 +1,618 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNewGiphyServiceAppliesDefaults(t *testing.T) {
|
||||
service := NewGiphyService(GiphyConfig{Enabled: true}, nil)
|
||||
if service.Config.MaxResults != 100 {
|
||||
t.Fatalf("expected default max results 100, got %d", service.Config.MaxResults)
|
||||
}
|
||||
if service.Config.Rating != "g" || service.Config.Lang != "en" {
|
||||
t.Fatalf("unexpected defaults: %#v", service.Config)
|
||||
}
|
||||
if service.BaseURL != defaultGiphyAPIBaseURL {
|
||||
t.Fatalf("expected default base url %q, got %q", defaultGiphyAPIBaseURL, service.BaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAllowedGiphyDownloadURL(t *testing.T) {
|
||||
if !isAllowedGiphyDownloadURL("https://media2.giphy.com/media/test/giphy.gif") {
|
||||
t.Fatal("expected media.giphy.com host to be allowed")
|
||||
}
|
||||
if isAllowedGiphyDownloadURL("https://example.com/file.gif") {
|
||||
t.Fatal("expected non-giphy host to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildGiphyFilenameSanitizesInput(t *testing.T) {
|
||||
got := buildGiphyFilename("ABC123", "Funny Cat!!!", ".gif", time.Date(2026, 3, 24, 15, 32, 12, 0, time.UTC))
|
||||
want := "giphy_abc123_funny-cat_20260324_153212.gif"
|
||||
if got != want {
|
||||
t.Fatalf("expected %q, got %q", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGiphySearchAggregatesDedupesAndCapsAt100(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/generate", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"candidates":[{"content":{"parts":[{"text":"{\"queries\":[\"funny cat\",\"happy cat gif\",\"cat reaction\",\"cat meme\",\"animated cat sticker\"]}"}]}}]}`))
|
||||
})
|
||||
mux.HandleFunc("/v1/gifs/search", func(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query().Get("q")
|
||||
limit := atoiOrZero(r.URL.Query().Get("limit"))
|
||||
offset := atoiOrZero(r.URL.Query().Get("offset"))
|
||||
data := make([]map[string]any, 0, limit)
|
||||
for idx := 0; idx < limit; idx++ {
|
||||
id := fmt.Sprintf("%s-%d", strings.ReplaceAll(query, " ", "-"), offset+idx)
|
||||
if idx == 0 {
|
||||
id = fmt.Sprintf("shared-%d", offset)
|
||||
}
|
||||
data = append(data, map[string]any{
|
||||
"id": id,
|
||||
"title": fmt.Sprintf("%s %d", query, offset+idx),
|
||||
"slug": fmt.Sprintf("%s-%d", strings.ReplaceAll(query, " ", "-"), offset+idx),
|
||||
"rating": "g",
|
||||
"url": "https://giphy.com/gifs/" + id,
|
||||
"images": map[string]any{
|
||||
"original": map[string]any{
|
||||
"url": fmt.Sprintf("https://media.giphy.com/media/%s/giphy.gif", id),
|
||||
"width": "480",
|
||||
"height": "270",
|
||||
},
|
||||
"fixed_width": map[string]any{
|
||||
"url": fmt.Sprintf("https://media.giphy.com/media/%s/200w.gif", id),
|
||||
"width": "200",
|
||||
"height": "113",
|
||||
},
|
||||
"fixed_width_still": map[string]any{
|
||||
"url": fmt.Sprintf("https://media.giphy.com/media/%s/200w_s.gif", id),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"data": data})
|
||||
})
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
|
||||
gemini := NewGeminiService("dummy-key", "gemini-2.5-flash")
|
||||
gemini.Client = &http.Client{Timeout: 2 * time.Second}
|
||||
gemini.GenerateEndpoint = server.URL + "/generate"
|
||||
|
||||
service := NewGiphyService(GiphyConfig{
|
||||
Enabled: true,
|
||||
APIKey: "test-key",
|
||||
MaxResults: 100,
|
||||
BaseURL: server.URL,
|
||||
}, gemini)
|
||||
service.Client = &http.Client{Timeout: 2 * time.Second}
|
||||
|
||||
resp, err := service.SearchImages("웃긴 고양이", 100)
|
||||
if err != nil {
|
||||
t.Fatalf("expected giphy search to succeed, got %v", err)
|
||||
}
|
||||
if len(resp.ExpandedQueries) != 5 {
|
||||
t.Fatalf("expected 5 expanded queries, got %#v", resp.ExpandedQueries)
|
||||
}
|
||||
if resp.Total != 100 || len(resp.Items) != 100 {
|
||||
t.Fatalf("expected capped 100 unique items, got total=%d len=%d", resp.Total, len(resp.Items))
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, item := range resp.Items {
|
||||
if seen[item.ProviderID] {
|
||||
t.Fatalf("found duplicate providerId %q in aggregated results", item.ProviderID)
|
||||
}
|
||||
seen[item.ProviderID] = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadMediaHappyPath(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "image/gif")
|
||||
_, _ = w.Write([]byte("GIF89a"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
serverURL, err := url.Parse(server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse server url: %v", err)
|
||||
}
|
||||
tempDir := t.TempDir()
|
||||
service := NewGiphyService(GiphyConfig{
|
||||
Enabled: true,
|
||||
APIKey: "test-key",
|
||||
DownloadDir: tempDir,
|
||||
}, nil)
|
||||
service.Client = &http.Client{
|
||||
Timeout: 2 * time.Second,
|
||||
Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) {
|
||||
clone := req.Clone(req.Context())
|
||||
if strings.HasSuffix(clone.URL.Host, "giphy.com") {
|
||||
clone.URL.Scheme = serverURL.Scheme
|
||||
clone.URL.Host = serverURL.Host
|
||||
clone.Host = serverURL.Host
|
||||
}
|
||||
return http.DefaultTransport.RoundTrip(clone)
|
||||
}),
|
||||
}
|
||||
|
||||
resp, err := service.DownloadMedia(GiphyDownloadRequest{
|
||||
ProviderID: "abc123",
|
||||
Title: "Funny Cat",
|
||||
DownloadURL: "https://media.giphy.com/media/abc123/giphy.gif",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected download to succeed, got %v", err)
|
||||
}
|
||||
if !resp.OK {
|
||||
t.Fatalf("expected ok response, got %#v", resp)
|
||||
}
|
||||
if filepath.Ext(resp.FileName) != ".gif" {
|
||||
t.Fatalf("expected gif extension, got %q", resp.FileName)
|
||||
}
|
||||
if _, err := os.Stat(resp.SavedPath); err != nil {
|
||||
t.Fatalf("expected saved file at %q: %v", resp.SavedPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user