619 lines
19 KiB
Go
619 lines
19 KiB
Go
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)
|
|
}
|
|
}
|