From 5645b38f20e2b9ee07071d20c49b18eed7d56b23 Mon Sep 17 00:00:00 2001 From: Fahri Can Date: Tue, 5 May 2026 17:09:11 +0300 Subject: [PATCH] main --- ai-engine/scripts/update_implied_odds.py | 4 +- .../services/single_match_orchestrator.py | 11 +- mds/SOCIAL_POSTER_MODULE.md | 113 +- src/config/env.validation.ts | 8 + .../caption-generator.service.ts | 72 +- .../social-poster/dto/prediction-card.dto.ts | 3 + .../social-poster/image-renderer.service.ts | 1162 +++++++++++------ src/modules/social-poster/meta.service.ts | 19 +- .../social-poster/social-poster.service.ts | 171 ++- src/modules/social-poster/twitter.service.ts | 14 +- 10 files changed, 1081 insertions(+), 496 deletions(-) diff --git a/ai-engine/scripts/update_implied_odds.py b/ai-engine/scripts/update_implied_odds.py index 51428c6..c1963fc 100644 --- a/ai-engine/scripts/update_implied_odds.py +++ b/ai-engine/scripts/update_implied_odds.py @@ -154,7 +154,7 @@ def update_implied_odds(conn): implied_draw = %s, implied_away = %s, implied_over25 = %s, - implied_btts = %s + implied_btts_yes = %s WHERE match_id = %s """, updates) updated += len(updates) @@ -168,7 +168,7 @@ def update_implied_odds(conn): implied_draw = %s, implied_away = %s, implied_over25 = %s, - implied_btts = %s + implied_btts_yes = %s WHERE match_id = %s """, updates) updated += len(updates) diff --git a/ai-engine/services/single_match_orchestrator.py b/ai-engine/services/single_match_orchestrator.py index 23dafc8..45da207 100755 --- a/ai-engine/services/single_match_orchestrator.py +++ b/ai-engine/services/single_match_orchestrator.py @@ -29,7 +29,14 @@ from psycopg2.extras import RealDictCursor from data.db import get_clean_dsn from models.v20_ensemble import FullMatchPrediction from models.v25_ensemble import V25Predictor, get_v25_predictor -from models.v27_predictor import V27Predictor, compute_divergence, compute_value_edge +try: + from models.v27_predictor import V27Predictor, compute_divergence, compute_value_edge +except ImportError: + V27Predictor = None + def compute_divergence(*args, **kwargs): + return 0.0 + def compute_value_edge(*args, **kwargs): + return 0.0 from features.odds_band_analyzer import OddsBandAnalyzer try: from models.basketball_v25 import ( @@ -246,6 +253,8 @@ class SingleMatchOrchestrator: def _get_v27_predictor(self) -> Optional[V27Predictor]: """Non-fatal V27 loader — returns None if models can't load.""" + if V27Predictor is None: + return None if getattr(self, "_v27", None) is not None: return self._v27 try: diff --git a/mds/SOCIAL_POSTER_MODULE.md b/mds/SOCIAL_POSTER_MODULE.md index b2d994d..b3ce8fb 100644 --- a/mds/SOCIAL_POSTER_MODULE.md +++ b/mds/SOCIAL_POSTER_MODULE.md @@ -1,6 +1,6 @@ # Social Poster Modülü — Otomatik Sosyal Medya Paylaşım Sistemi -Son güncelleme: 1 Mart 2026 +Son güncelleme: 5 Mayıs 2026 --- @@ -13,11 +13,11 @@ Top liglerdeki maçların AI tahminlerini **otomatik olarak görselleştirip** I ## 2. Mimari Akış ``` -Cron (*/10 dk) → LiveMatch sorgusu (top_leagues.json filtresi) +Cron (*/15 dk) → LiveMatch sorgusu (top_leagues.json filtresi) → AI Engine V20+ POST /v20plus/analyze/{match_id} → PredictionCardDto oluştur - → Node Canvas ile 1080x1920 PNG render - → Gemini ile Türkçe caption üret + → Node Canvas ile futbol/basketbol 1080x1080 JPEG render + → Ollama/Gemini ile Türkçe SEO uyumlu caption üret → Twitter / Facebook / Instagram API'ye paylaş ``` @@ -44,41 +44,46 @@ src/modules/social-poster/ ### 4.1 SocialPosterService -**Cron:** Her 10 dakikada bir çalışır. 25–40 dakika içinde başlayacak maçları `top_leagues.json` filtresiyle bulur. +**Cron:** Her 15 dakikada bir çalışır. Varsayılan olarak 25–45 dakika içinde başlayacak futbol ve basketbol maçlarını `top_leagues.json` filtresiyle bulur. + +**Tekrar paylaşım koruması:** Başarılı platform paylaşımı alan maç ID'leri `storage/social-poster-posted.json` içinde son 500 kayıt olarak tutulur. Servis restart sonrası aynı maç tekrar paylaşılmaz. **Pipeline:** `predictAndPost(match)` → Tahmin al → Görsel üret → Caption üret → Paylaş **AI Engine İsteği:** + ```typescript // POST — GET değil! AI Engine v20plus POST bekler. -axios.post(`${aiEngineUrl}/v20plus/analyze/${matchId}`, null, { timeout: 30000 }) +axios.post(`${aiEngineUrl}/v20plus/analyze/${matchId}`, null, { + timeout: 30000, +}); ``` **Veri Haritalandırma (V20+ → CardDto):** -| V20+ Response Alanı | CardDto Alanı | -|---|---| -| `score_prediction.ht` | `htScore` (ör: "1-1") | -| `score_prediction.ft` | `ftScore` (ör: "2-1") | -| `main_pick.confidence` | `scoreConfidence` (ör: 65) | +| V20+ Response Alanı | CardDto Alanı | +| ----------------------- | ---------------------------------------------- | +| `score_prediction.ht` | `htScore` (ör: "1-1") | +| `score_prediction.ft` | `ftScore` (ör: "2-1") | +| `main_pick.confidence` | `scoreConfidence` (ör: 65) | | `bet_summary[]` (array) | `topPicks[]` (ilk 3, confidence'a göre sıralı) | -| `risk.level` | `riskLevel` (LOW/MEDIUM/HIGH/EXTREME) | -| `match_info.home_team` | `homeTeam` (fallback) | +| `risk.level` | `riskLevel` (LOW/MEDIUM/HIGH/EXTREME) | +| `match_info.home_team` | `homeTeam` (fallback) | **Bet Summary Market Kodları:** -| Kod | Türkçe | English | -|---|---|---| -| MS | Maç Sonucu | Match Result | -| OU15 | Üst 1.5 Gol | Over 1.5 | -| OU25 | Üst 2.5 Gol | Over 2.5 | -| OU35 | Üst 3.5 Gol | Over 3.5 | -| BTTS | Karşılıklı Gol | Both Teams Score | -| DC | Çifte Şans | Double Chance | -| HT | İlk Yarı Sonucu | Half Time Result | -| HT_OU05 | İY 0.5 Üst/Alt | HT Over/Under 0.5 | -| OE | Tek/Çift | Odd/Even | -| HTFT | İY/MS | HT/FT | +| Kod | Türkçe | English | +| ------- | --------------- | ----------------- | +| MS | Maç Sonucu | Match Result | +| OU15 | Üst 1.5 Gol | Over 1.5 | +| OU25 | Üst 2.5 Gol | Over 2.5 | +| OU35 | Üst 3.5 Gol | Over 3.5 | +| BTTS | Karşılıklı Gol | Both Teams Score | +| DC | Çifte Şans | Double Chance | +| HT | İlk Yarı Sonucu | Half Time Result | +| HT_OU05 | İY 0.5 Üst/Alt | HT Over/Under 0.5 | +| OE | Tek/Çift | Odd/Even | +| HTFT | İY/MS | HT/FT | ### 4.2 ImageRendererService @@ -89,6 +94,7 @@ axios.post(`${aiEngineUrl}/v20plus/analyze/${matchId}`, null, { timeout: 30000 } **Boyut:** 1080×1920 px (Instagram Story / Reels uyumlu) **Özellikler:** + - Koyu gradient arka plan (#0a0e27 → #1a1040 → #0d1b2a) - Lig adı + tarih başlık satırı - Takım logoları (200×200px) — `public/uploads/teams/` altından okunur @@ -100,6 +106,7 @@ axios.post(`${aiEngineUrl}/v20plus/analyze/${matchId}`, null, { timeout: 30000 } - Alt bilgi: "⚡ AI Powered by SuggestBet" **Logo Çözümleme:** + ``` 1. Yerel dosya varsa → public/uploads/teams/xxx.png oku 2. URL http ile başlıyorsa → HTTP ile indir @@ -118,10 +125,10 @@ Gemini API kullanarak maç verisi JSON'ından Türkçe post metni üretir. ## 5. API Endpointleri -| Method | Path | Auth | Açıklama | -|---|---|---|---| -| GET | `/api/social-poster/preview/:matchId` | @Public | Sadece görsel üret + caption üret (paylaşma) | -| POST | `/api/social-poster/post/:matchId` | @Public | Görsel üret + caption üret + tüm platformlara paylaş | +| Method | Path | Auth | Açıklama | +| ------ | ------------------------------------- | ------- | ---------------------------------------------------- | +| GET | `/api/social-poster/preview/:matchId` | @Public | Sadece görsel üret + caption üret (paylaşma) | +| POST | `/api/social-poster/post/:matchId` | @Public | Görsel üret + caption üret + tüm platformlara paylaş | > **Not:** Test endpointleri `@Public()` dekoratörüyle auth bypass edilmiştir. Production'da kaldırılmalı veya admin-only yapılmalıdır. @@ -129,14 +136,20 @@ Gemini API kullanarak maç verisi JSON'ından Türkçe post metni üretir. ## 6. Environment Değişkenleri -| Key | Zorunlu | Varsayılan | Açıklama | -|---|---|---|---| -| `AI_ENGINE_URL` | ✅ | `http://localhost:8000` | AI Engine base URL | -| `APP_BASE_URL` | ✅ | `http://localhost:3000` | Logo URL çözümleme için | -| `SOCIAL_POSTER_ENABLED` | ❌ | `false` | Cron job'ı aktif/pasif | -| `GOOGLE_API_KEY` | ❌ | — | Gemini caption için | -| Twitter API keys | ❌ | — | Twitter paylaşım için | -| Meta API keys | ❌ | — | FB/IG paylaşım için | +| Key | Zorunlu | Varsayılan | Açıklama | +| --------------------------------------------- | ------- | ------------------------ | -------------------------------------------------------------------- | +| `AI_ENGINE_URL` | ✅ | `http://localhost:8000` | AI Engine base URL | +| `APP_BASE_URL` | ✅ | `http://localhost:3000` | Meta'nın çekebileceği public görsel URL'i ve logo URL çözümleme için | +| `SOCIAL_POSTER_ENABLED` | ❌ | `false` | Cron job'ı aktif/pasif | +| `SOCIAL_POSTER_SPORTS` | ❌ | `football,basketball` | Otomatik paylaşılacak sporlar | +| `SOCIAL_POSTER_WINDOW_MIN` | ❌ | `25` | Başlama zaman penceresi alt sınırı (dakika) | +| `SOCIAL_POSTER_WINDOW_MAX` | ❌ | `45` | Başlama zaman penceresi üst sınırı (dakika) | +| `OLLAMA_BASE_URL` | ❌ | `http://localhost:11434` | Lokal LLM endpoint'i | +| `OLLAMA_MODEL` / `SOCIAL_POSTER_OLLAMA_MODEL` | ❌ | — | Caption üretiminde kullanılacak lokal model | +| `GOOGLE_API_KEY` | ❌ | — | Gemini caption için | +| Twitter API keys | ❌ | — | X medya upload + `/2/tweets` paylaşımı için OAuth 1.0a user context | +| `META_GRAPH_API_VERSION` | ❌ | `v25.0` | Meta Graph API sürümü | +| Meta API keys | ❌ | — | FB/IG paylaşım için | --- @@ -144,9 +157,9 @@ Gemini API kullanarak maç verisi JSON'ından Türkçe post metni üretir. ```json { - "canvas": "^2.x", // Node Canvas — görsel üretimi - "axios": "^1.x", // HTTP istekleri (AI Engine + logo indirme) - "@nestjs/schedule": "*" // Cron job desteği + "canvas": "^2.x", // Node Canvas — görsel üretimi + "axios": "^1.x", // HTTP istekleri (AI Engine + logo indirme) + "@nestjs/schedule": "*" // Cron job desteği } ``` @@ -165,10 +178,10 @@ RUN apk add --no-cache cairo-dev pango-dev jpeg-dev giflib-dev librsvg-dev ### Port Yönetimi -| Servis | Port | -|---|---| -| NestJS Backend | 3000 (production: 150X) | -| AI Engine | 8000 (dev: 8005 — Windows port kısıtlaması) | +| Servis | Port | +| -------------- | ------------------------------------------- | +| NestJS Backend | 3000 (production: 150X) | +| AI Engine | 8000 (dev: 8005 — Windows port kısıtlaması) | ### Dosya Sistemi @@ -182,9 +195,9 @@ public/ ## 9. Bilinen Sorunlar & Çözümler -| Sorun | Sebep | Çözüm | -|---|---|---| -| `WinError 10013` port erişim hatası | Windows Hyper-V port rezervasyonu | Farklı port kullan (8005) | -| `Invalid prisma.liveMatch.findUnique()` | Prisma client eskimiş | `npx prisma generate` çalıştır | -| `405 Method Not Allowed` AI Engine | GET yerine POST gerekiyor | `axios.post()` kullan | -| Logolar görünmüyor (lokal dev) | Logo dosyaları sunucuda, lokalde yok | Deploy'da çalışır, lokal'de graceful skip | +| Sorun | Sebep | Çözüm | +| --------------------------------------- | ------------------------------------ | ----------------------------------------- | +| `WinError 10013` port erişim hatası | Windows Hyper-V port rezervasyonu | Farklı port kullan (8005) | +| `Invalid prisma.liveMatch.findUnique()` | Prisma client eskimiş | `npx prisma generate` çalıştır | +| `405 Method Not Allowed` AI Engine | GET yerine POST gerekiyor | `axios.post()` kullan | +| Logolar görünmüyor (lokal dev) | Logo dosyaları sunucuda, lokalde yok | Deploy'da çalışır, lokal'de graceful skip | diff --git a/src/config/env.validation.ts b/src/config/env.validation.ts index f4f09d2..10fd34b 100755 --- a/src/config/env.validation.ts +++ b/src/config/env.validation.ts @@ -56,13 +56,21 @@ export const envSchema = z.object({ .string() .transform((val) => val === "true") .default("false" as any), + SOCIAL_POSTER_SPORTS: z.string().default("football,basketball"), + SOCIAL_POSTER_WINDOW_MIN: z.coerce.number().default(25), + SOCIAL_POSTER_WINDOW_MAX: z.coerce.number().default(45), + SOCIAL_POSTER_OLLAMA_MODEL: z.string().optional(), + APP_BASE_URL: z.string().url().optional(), TWITTER_API_KEY: z.string().optional(), TWITTER_API_SECRET: z.string().optional(), TWITTER_ACCESS_TOKEN: z.string().optional(), TWITTER_ACCESS_SECRET: z.string().optional(), + META_GRAPH_API_VERSION: z.string().default("v25.0"), META_PAGE_ACCESS_TOKEN: z.string().optional(), META_PAGE_ID: z.string().optional(), META_IG_USER_ID: z.string().optional(), + OLLAMA_BASE_URL: z.string().url().optional(), + OLLAMA_MODEL: z.string().optional(), // Optional Features ENABLE_MAIL: booleanString, diff --git a/src/modules/social-poster/caption-generator.service.ts b/src/modules/social-poster/caption-generator.service.ts index b4973f2..4db480b 100644 --- a/src/modules/social-poster/caption-generator.service.ts +++ b/src/modules/social-poster/caption-generator.service.ts @@ -1,6 +1,8 @@ import { Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; import { GeminiService } from "../gemini/gemini.service"; import { PredictionCardDto } from "./dto/prediction-card.dto"; +import axios from "axios"; const SYSTEM_PROMPT = `Sen profesyonel bir spor analisti ve sosyal medya içerik üreticisisin. Verilen maç tahmin verisini kullanarak kısa, etkili ve ilgi çekici sosyal medya postları yazıyorsun. @@ -11,6 +13,7 @@ KURALLAR: - Emoji kullan ama abartma (2-4 emoji yeterli) - Skor tahminini vurgula - Güven yüzdesini belirt +- Lig, ülke, takım adları ve ana tahminleri SEO için doğal şekilde geçir - İlgili hashtag'leri ekle (#PremierLeague, #SüperLig vb.) - KESİNLİKLE "kesin kazanır", "garanti" gibi ifadeler KULLANMA - "Tahminimiz", "Beklentimiz", "Analizimiz" gibi ifadeler kullan @@ -20,13 +23,31 @@ KURALLAR: @Injectable() export class CaptionGeneratorService { private readonly logger = new Logger(CaptionGeneratorService.name); + private readonly ollamaBaseUrl: string; + private readonly ollamaModel: string; - constructor(private readonly geminiService: GeminiService) {} + constructor( + private readonly geminiService: GeminiService, + private readonly configService: ConfigService, + ) { + this.ollamaBaseUrl = + this.configService.get("OLLAMA_BASE_URL") || + "http://localhost:11434"; + this.ollamaModel = + this.configService.get("OLLAMA_MODEL") || + this.configService.get("SOCIAL_POSTER_OLLAMA_MODEL") || + ""; + } /** * Generate a social media caption for a match prediction using Gemini AI. */ async generateCaption(card: PredictionCardDto): Promise { + if (this.ollamaModel) { + const caption = await this.generateWithOllama(card); + if (caption) return caption; + } + if (!this.geminiService.isAvailable()) { this.logger.warn("Gemini not available, using template caption"); return this.generateFallbackCaption(card); @@ -53,6 +74,39 @@ export class CaptionGeneratorService { } } + private async generateWithOllama(card: PredictionCardDto): Promise { + const prompt = `${SYSTEM_PROMPT} + +${this.buildPrompt(card)}`; + + try { + const response = await axios.post( + `${this.ollamaBaseUrl.replace(/\/$/, "")}/api/generate`, + { + model: this.ollamaModel, + prompt, + stream: false, + options: { + temperature: 0.7, + num_predict: 260, + }, + }, + { timeout: 20000 }, + ); + + const text = String(response.data?.response || "").trim(); + if (!text) return ""; + + this.logger.log( + `Ollama caption generated for ${card.homeTeam} vs ${card.awayTeam}`, + ); + return this.ensureHashtags(text, card); + } catch (error) { + this.logger.warn(`Ollama caption generation failed: ${error.message}`); + return ""; + } + } + private buildPrompt(card: PredictionCardDto): string { const topPicksText = card.topPicks .map( @@ -64,9 +118,11 @@ export class CaptionGeneratorService { return `Aşağıdaki maç tahmin verisini kullanarak bir sosyal medya postu oluştur: MAÇ: ${card.homeTeam} vs ${card.awayTeam} +SPOR: ${card.sport === "basketball" ? "Basketbol" : "Futbol"} LİG: ${card.leagueName} +ÜLKE/BÖLGE: ${card.countryName || "-"} TARİH: ${card.matchDate} -İLK YARI SKOR TAHMİNİ: ${card.htScore} +${card.sport === "basketball" ? "İLK DEVRE" : "İLK YARI"} SKOR TAHMİNİ: ${card.htScore} MAÇ SONU SKOR TAHMİNİ: ${card.ftScore} SKOR GÜVEN: %${card.scoreConfidence} RİSK SEVİYESİ: ${card.riskLevel} @@ -85,7 +141,8 @@ Sadece post metnini yaz, başka hiçbir şey ekleme.`; .replace(/[^a-zA-Z0-9üöçşğıİÜÖÇŞĞ]/g, ""); const homeTag = card.homeTeam.replace(/\s+/g, ""); const awayTag = card.awayTeam.replace(/\s+/g, ""); - text += `\n\n#${leagueTag} #${homeTag} #${awayTag}`; + const sportTag = card.sport === "basketball" ? "Basketbol" : "Futbol"; + text += `\n\n#${leagueTag} #${homeTag} #${awayTag} #${sportTag}`; } return text.trim(); } @@ -99,11 +156,14 @@ Sadece post metnini yaz, başka hiçbir şey ekleme.`; .replace(/\s+/g, "") .replace(/[^a-zA-Z0-9üöçşğıİÜÖÇŞĞ]/g, ""); - return `⚡ ${card.homeTeam} vs ${card.awayTeam} -🎯 Tahminimiz: ${card.ftScore} (İY: ${card.htScore}) + const sportLabel = card.sport === "basketball" ? "Basketbol" : "Futbol"; + const halfLabel = card.sport === "basketball" ? "İD" : "İY"; + + return `⚡ ${card.leagueName}${card.countryName ? ` (${card.countryName})` : ""}: ${card.homeTeam} vs ${card.awayTeam} +🎯 ${sportLabel} tahminimiz: ${card.ftScore} (${halfLabel}: ${card.htScore}) 📊 Güven: %${card.scoreConfidence} ${topPick ? `🔥 ${topPick.market}: ${topPick.pick} (%${topPick.confidence})` : ""} -#${leagueTag} #SuggestBet #Bahis`.trim(); +#${leagueTag} #${sportLabel} #MaçTahmini #iddaai`.trim(); } } diff --git a/src/modules/social-poster/dto/prediction-card.dto.ts b/src/modules/social-poster/dto/prediction-card.dto.ts index 9608e2c..20e44cc 100644 --- a/src/modules/social-poster/dto/prediction-card.dto.ts +++ b/src/modules/social-poster/dto/prediction-card.dto.ts @@ -21,12 +21,15 @@ export interface TopPick { export interface PredictionCardDto { // ─── Match Info ─── matchId: string; + sport: "football" | "basketball"; homeTeam: string; awayTeam: string; homeLogo: string; awayLogo: string; leagueName: string; leagueLogo?: string; + countryName?: string; + countryFlag?: string; /** Formatted date, e.g. "01 Mar 2026 - 21:00" */ matchDate: string; diff --git a/src/modules/social-poster/image-renderer.service.ts b/src/modules/social-poster/image-renderer.service.ts index 6d9ff9b..847ddb8 100644 --- a/src/modules/social-poster/image-renderer.service.ts +++ b/src/modules/social-poster/image-renderer.service.ts @@ -2,7 +2,8 @@ import { Injectable, Logger, OnModuleInit } from "@nestjs/common"; import * as fs from "fs"; import * as path from "path"; import axios from "axios"; -// Canvas is optional – native module may fail on ARM64 (RPi) +import { PredictionCardDto, TopPick } from "./dto/prediction-card.dto"; + let createCanvas: any; let loadImage: any; try { @@ -11,9 +12,18 @@ try { createCanvas = canvas.createCanvas; loadImage = canvas.loadImage; } catch { - // Canvas unavailable – ImageRendererService methods will throw at runtime if called + // Canvas unavailable: render calls will fail with a clear error. } -import { PredictionCardDto } from "./dto/prediction-card.dto"; + +type Theme = { + accent: string; + accent2: string; + mutedAccent: string; + glow: string; + title: string; + halfLabel: string; + backdrop: "pitch" | "court"; +}; @Injectable() export class ImageRendererService implements OnModuleInit { @@ -25,115 +35,24 @@ export class ImageRendererService implements OnModuleInit { ); onModuleInit() { - // Ensure output directory exists if (!fs.existsSync(this.outputDir)) { fs.mkdirSync(this.outputDir, { recursive: true }); } } - /** - * Render a prediction card to a PNG image using Canvas API. - * Returns the file path of the generated image. - */ async renderCard(card: PredictionCardDto): Promise { - const fileName = `prediction_${card.matchId}_${Date.now()}.png`; + if (!createCanvas || !loadImage) { + throw new Error("canvas native module is not available"); + } + + const fileName = `prediction_${card.sport}_${card.matchId}_${Date.now()}.jpg`; const filePath = path.join(this.outputDir, fileName); - try { - this.logger.log( - `🎨 Rendering canvas for ${card.homeTeam} vs ${card.awayTeam}...`, - ); - await this.drawCanvas(card, filePath); - this.logger.log(`✅ Card rendered to ${fileName}`); - return filePath; - } catch (error) { - this.logger.error(`Failed to render canvas card: ${error.message}`); - throw error; - } - } - - /** - * Load a team logo image. Handles: - * 1. Local file path (e.g., /uploads/teams/xxx.png → public/uploads/teams/xxx.png) - * 2. Full HTTP URL (e.g., https://cdn.example.com/logo.png) - * 3. Mackolik CDN fallback using team slug from path - */ - private async downloadImage(url: string) { - if (!url) return null; - - try { - // Case 1: Local relative path → read from public/ directory - if (url.startsWith("/")) { - const localPath = path.join(process.cwd(), "public", url); - if (fs.existsSync(localPath)) { - this.logger.debug(`Loading logo from local file: ${localPath}`); - return await loadImage(localPath); - } - // Local file not found → try as full URL via APP_BASE_URL - this.logger.debug( - `Local file not found: ${localPath}, trying remote...`, - ); - } - - // Case 2: Full HTTP/HTTPS URL → fetch directly - if (url.startsWith("http")) { - const response = await axios.get(url, { - responseType: "arraybuffer", - timeout: 5000, - }); - return await loadImage(response.data); - } - - this.logger.warn(`Could not resolve logo path: ${url}`); - return null; - } catch (error) { - this.logger.warn(`Could not load image from ${url}: ${error.message}`); - return null; - } - } - - private fillRoundRect( - ctx: any, - x: number, - y: number, - width: number, - height: number, - radius: number, - ) { - ctx.beginPath(); - ctx.moveTo(x + radius, y); - ctx.lineTo(x + width - radius, y); - ctx.quadraticCurveTo(x + width, y, x + width, y + radius); - ctx.lineTo(x + width, y + height - radius); - ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height); - ctx.lineTo(x + radius, y + height); - ctx.quadraticCurveTo(x, y + height, x, y + height - radius); - ctx.lineTo(x, y + radius); - ctx.quadraticCurveTo(x, y, x + radius, y); - ctx.closePath(); - ctx.fill(); - } - - private strokeRoundRect( - ctx: any, - x: number, - y: number, - width: number, - height: number, - radius: number, - ) { - ctx.beginPath(); - ctx.moveTo(x + radius, y); - ctx.lineTo(x + width - radius, y); - ctx.quadraticCurveTo(x + width, y, x + width, y + radius); - ctx.lineTo(x + width, y + height - radius); - ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height); - ctx.lineTo(x + radius, y + height); - ctx.quadraticCurveTo(x, y + height, x, y + height - radius); - ctx.lineTo(x, y + radius); - ctx.quadraticCurveTo(x, y, x + radius, y); - ctx.closePath(); - ctx.stroke(); + this.logger.log( + `Rendering ${card.sport} social card for ${card.homeTeam} vs ${card.awayTeam}`, + ); + await this.drawCanvas(card, filePath); + return filePath; } private async drawCanvas( @@ -141,327 +60,740 @@ export class ImageRendererService implements OnModuleInit { outPath: string, ): Promise { const width = 1080; - const height = 1920; + const height = 1080; const canvas = createCanvas(width, height); const ctx = canvas.getContext("2d"); + const theme = this.getTheme(data.sport); - // Background Gradient - const bgGrad = ctx.createLinearGradient(0, 0, width, height); - bgGrad.addColorStop(0, "#0a0e27"); - bgGrad.addColorStop(0.35, "#1a1040"); - bgGrad.addColorStop(0.7, "#0d1b2a"); - bgGrad.addColorStop(1, "#0a0e27"); - ctx.fillStyle = bgGrad; - ctx.fillRect(0, 0, width, height); + this.drawBackground(ctx, width, height, theme); - // Watermark - ctx.save(); - ctx.translate(width / 2, height / 2); - ctx.rotate((-35 * Math.PI) / 180); - ctx.fillStyle = "rgba(255, 255, 255, 0.05)"; - ctx.font = "900 100px sans-serif"; - ctx.textAlign = "center"; - ctx.textBaseline = "middle"; - const wmLine = - "iddaai.com iddaai.com iddaai.com iddaai.com iddaai.com iddaai.com iddaai.com iddaai.com"; - for (let i = -15; i <= 15; i++) { - ctx.fillText(wmLine, 0, i * 180); - } - ctx.restore(); - - // Settings - const paddingX = 80; - - // Header - ctx.fillStyle = "rgba(255, 255, 255, 0.7)"; - ctx.font = "600 28px sans-serif"; - ctx.textAlign = "left"; - ctx.fillText(data.leagueName.toUpperCase(), paddingX, 120); - - ctx.fillStyle = "rgba(255, 255, 255, 0.45)"; - ctx.font = "400 22px sans-serif"; - ctx.textAlign = "right"; - ctx.fillText(data.matchDate, width - paddingX, 120); - - // Teams Section - let currentY = 280; - const [homeImg, awayImg] = await Promise.all([ - this.downloadImage(data.homeLogo), - this.downloadImage(data.awayLogo), + const [homeImg, awayImg, countryFlag, leagueLogo] = await Promise.all([ + this.loadImageSafe(data.homeLogo), + this.loadImageSafe(data.awayLogo), + this.loadImageSafe(data.countryFlag || ""), + this.loadImageSafe(data.leagueLogo || ""), ]); - if (homeImg) ctx.drawImage(homeImg, width / 4 - 100, currentY, 200, 200); - if (awayImg) - ctx.drawImage(awayImg, (width / 4) * 3 - 100, currentY, 200, 200); + this.drawHeader(ctx, data, theme, countryFlag, leagueLogo); + this.drawMatchBlock(ctx, data, theme, homeImg, awayImg); + this.drawScoreBlock(ctx, data, theme); + this.drawPicks(ctx, data.topPicks, theme); + this.drawFooter(ctx, data, theme); - ctx.fillStyle = "rgba(255, 255, 255, 0.15)"; - ctx.font = "900 56px sans-serif"; - ctx.textAlign = "center"; - ctx.fillText("VS", width / 2, currentY + 110); - - currentY += 250; - ctx.fillStyle = "#ffffff"; - ctx.font = "700 36px sans-serif"; - ctx.textAlign = "center"; - ctx.fillText(data.homeTeam, width / 4, currentY); - ctx.fillText(data.awayTeam, (width / 4) * 3, currentY); - - // Divider: Skore Prediction - currentY += 140; - const drawSectionTitle = (y: number, text: string) => { - ctx.textAlign = "center"; - ctx.fillStyle = "rgba(255, 255, 255, 0.5)"; - ctx.font = "600 22px sans-serif"; - ctx.fillText(text, width / 2, y + 8); - - const txtWidth = ctx.measureText(text).width; - const grad = ctx.createLinearGradient(paddingX, y, width - paddingX, y); - grad.addColorStop(0, "rgba(120, 80, 255, 0)"); - grad.addColorStop(0.5, "rgba(120, 80, 255, 0.6)"); - grad.addColorStop(1, "rgba(120, 80, 255, 0)"); - - ctx.fillStyle = grad; - ctx.fillRect( - paddingX, - y - 2, - (width - 2 * paddingX - txtWidth - 40) / 2, - 3, - ); - ctx.fillRect( - width / 2 + txtWidth / 2 + 20, - y - 2, - (width - 2 * paddingX - txtWidth - 40) / 2, - 3, - ); - }; - - drawSectionTitle(currentY, "SKOR TAHMİNİ / SCORE PREDICTION"); - - // Scores - currentY += 80; - const scoreBoxWidth = 380; - const scoreBoxHeight = 220; - const htX = width / 2 - scoreBoxWidth - 24; - const ftX = width / 2 + 24; - - // HT Box - ctx.fillStyle = "rgba(255, 255, 255, 0.04)"; - ctx.strokeStyle = "rgba(255, 255, 255, 0.08)"; - ctx.lineWidth = 2; - this.fillRoundRect(ctx, htX, currentY, scoreBoxWidth, scoreBoxHeight, 20); - this.strokeRoundRect(ctx, htX, currentY, scoreBoxWidth, scoreBoxHeight, 20); - - ctx.fillStyle = "rgba(255, 255, 255, 0.45)"; - ctx.font = "600 20px sans-serif"; - ctx.fillText("İLK YARI", htX + scoreBoxWidth / 2, currentY + 40); - ctx.fillStyle = "rgba(255, 255, 255, 0.25)"; - ctx.font = "400 16px sans-serif"; - ctx.fillText("Half Time", htX + scoreBoxWidth / 2, currentY + 65); - ctx.fillStyle = "#ffffff"; - ctx.font = "900 80px sans-serif"; - ctx.fillText(data.htScore, htX + scoreBoxWidth / 2, currentY + 160); - - // FT Box - const ftGrad = ctx.createLinearGradient( - ftX, - currentY, - ftX + scoreBoxWidth, - currentY + scoreBoxHeight, - ); - ftGrad.addColorStop(0, "rgba(120, 80, 255, 0.15)"); - ftGrad.addColorStop(1, "rgba(0, 200, 255, 0.1)"); - ctx.fillStyle = ftGrad; - ctx.strokeStyle = "rgba(120, 80, 255, 0.3)"; - this.fillRoundRect(ctx, ftX, currentY, scoreBoxWidth, scoreBoxHeight, 20); - this.strokeRoundRect(ctx, ftX, currentY, scoreBoxWidth, scoreBoxHeight, 20); - - ctx.fillStyle = "rgba(255, 255, 255, 0.45)"; - ctx.font = "600 20px sans-serif"; - ctx.fillText("MAÇ SONU", ftX + scoreBoxWidth / 2, currentY + 40); - ctx.fillStyle = "rgba(255, 255, 255, 0.25)"; - ctx.font = "400 16px sans-serif"; - ctx.fillText("Full Time", ftX + scoreBoxWidth / 2, currentY + 65); - - // Score text gradient - const txtGrad = ctx.createLinearGradient( - ftX, - currentY + 100, - ftX, - currentY + 160, - ); - txtGrad.addColorStop(0, "#9b6fff"); - txtGrad.addColorStop(1, "#00c8ff"); - ctx.fillStyle = txtGrad; - ctx.font = "900 80px sans-serif"; - ctx.fillText(data.ftScore, ftX + scoreBoxWidth / 2, currentY + 160); - - // Confidence badge - ctx.fillStyle = "#0a0e27"; - ctx.strokeStyle = "rgba(120, 80, 255, 0.6)"; - this.fillRoundRect( - ctx, - ftX + scoreBoxWidth / 2 - 80, - currentY + scoreBoxHeight - 20, - 160, - 40, - 20, - ); - this.strokeRoundRect( - ctx, - ftX + scoreBoxWidth / 2 - 80, - currentY + scoreBoxHeight - 20, - 160, - 40, - 20, - ); - ctx.fillStyle = "#b89dff"; - ctx.font = "800 20px sans-serif"; - ctx.fillText( - `🎯 %${data.scoreConfidence}`, - ftX + scoreBoxWidth / 2, - currentY + scoreBoxHeight + 7, - ); - - // Divider: Picks - currentY += scoreBoxHeight + 100; - drawSectionTitle(currentY, "EN İYİ TAHMİNLER / BEST PICKS"); - - // Picks rendering - currentY += 80; - data.topPicks.forEach((pick, index) => { - ctx.fillStyle = "rgba(255, 255, 255, 0.03)"; - ctx.strokeStyle = "rgba(255, 255, 255, 0.06)"; - this.fillRoundRect( - ctx, - paddingX, - currentY, - width - 2 * paddingX, - 100, - 16, - ); - this.strokeRoundRect( - ctx, - paddingX, - currentY, - width - 2 * paddingX, - 100, - 16, - ); - - ctx.fillStyle = "rgba(255, 255, 255, 0.3)"; - ctx.font = "700 28px sans-serif"; - ctx.textAlign = "left"; - ctx.fillText(String(index + 1), paddingX + 30, currentY + 58); - - ctx.fillStyle = "#ffffff"; - ctx.font = "600 26px sans-serif"; - ctx.fillText(pick.market, paddingX + 80, currentY + 45); - - const marketWidth = ctx.measureText(pick.market).width; - ctx.fillStyle = "rgba(255, 255, 255, 0.35)"; - ctx.font = "400 18px sans-serif"; - ctx.fillText( - `(${pick.marketEn})`, - paddingX + 80 + marketWidth + 10, - currentY + 43, - ); - - // Pick Bar bg - ctx.fillStyle = "rgba(255, 255, 255, 0.06)"; - const barMaxWidth = width - 2 * paddingX - 220; - this.fillRoundRect(ctx, paddingX + 80, currentY + 65, barMaxWidth, 12, 6); - - // Pick Bar fill - const fillWidth = (pick.confidence / 100) * barMaxWidth; - const barGrad = ctx.createLinearGradient( - paddingX + 80, - 0, - paddingX + 80 + barMaxWidth, - 0, - ); - barGrad.addColorStop(0, "#7850ff"); - barGrad.addColorStop(1, "#00c8ff"); - ctx.fillStyle = barGrad; - this.fillRoundRect(ctx, paddingX + 80, currentY + 65, fillWidth, 12, 6); - - // Confidence text - ctx.fillStyle = "#b89dff"; - ctx.font = "900 32px sans-serif"; - ctx.textAlign = "right"; - ctx.fillText(`%${pick.confidence}`, width - paddingX - 30, currentY + 58); - - currentY += 124; - }); - - // Footer - currentY = height - 80; - ctx.fillStyle = "rgba(255, 255, 255, 0.4)"; - ctx.font = "700 26px sans-serif"; - ctx.textAlign = "left"; - ctx.fillText("⚡ AI Powered by iddaai.com", paddingX, currentY); - - let riskBg, riskColor, riskBorder; - switch (data.riskLevel) { - case "LOW": - riskBg = "rgba(0, 200, 100, 0.15)"; - riskColor = "#4ade80"; - riskBorder = "rgba(0, 200, 100, 0.3)"; - break; - case "MEDIUM": - riskBg = "rgba(255, 200, 0, 0.12)"; - riskColor = "#fbbf24"; - riskBorder = "rgba(255, 200, 0, 0.25)"; - break; - case "HIGH": - riskBg = "rgba(255, 100, 50, 0.12)"; - riskColor = "#f97316"; - riskBorder = "rgba(255, 100, 50, 0.25)"; - break; - case "EXTREME": - riskBg = "rgba(255, 50, 50, 0.15)"; - riskColor = "#ef4444"; - riskBorder = "rgba(255, 50, 50, 0.3)"; - break; - default: - riskBg = "rgba(255, 255, 255, 0.1)"; - riskColor = "#ffffff"; - riskBorder = "rgba(255, 255, 255, 0.3)"; - } - - const riskText = `RISK: ${data.riskLevel}`; - ctx.font = "800 20px sans-serif"; - const riskWidth = ctx.measureText(riskText).width; - ctx.fillStyle = riskBg; - ctx.strokeStyle = riskBorder; - this.fillRoundRect( - ctx, - width - paddingX - riskWidth - 48, - currentY - 26, - riskWidth + 48, - 44, - 22, - ); - this.strokeRoundRect( - ctx, - width - paddingX - riskWidth - 48, - currentY - 26, - riskWidth + 48, - 44, - 22, - ); - - ctx.fillStyle = riskColor; - ctx.textAlign = "center"; - ctx.fillText(riskText, width - paddingX - riskWidth / 2 - 24, currentY + 3); - - // Save Output directly using the buffer - const buffer = canvas.toBuffer("image/png"); + const buffer = canvas.toBuffer("image/jpeg", { quality: 0.94 }); fs.writeFileSync(outPath, buffer); } - /** - * Get the web-accessible URL for a rendered image. - */ + private getTheme(sport: "football" | "basketball"): Theme { + if (sport === "basketball") { + return { + accent: "#ff7a1a", + accent2: "#20e48b", + mutedAccent: "rgba(255, 122, 26, 0.18)", + glow: "rgba(255, 122, 26, 0.35)", + title: "BASKETBOL TAHMİNİ", + halfLabel: "İlk Devre", + backdrop: "court", + }; + } + + return { + accent: "#21e88a", + accent2: "#65d7ff", + mutedAccent: "rgba(33, 232, 138, 0.15)", + glow: "rgba(33, 232, 138, 0.28)", + title: "MAÇ TAHMİNİ", + halfLabel: "İlk Yarı", + backdrop: "pitch", + }; + } + + private drawBackground( + ctx: any, + width: number, + height: number, + theme: Theme, + ) { + const bg = ctx.createRadialGradient(540, 300, 120, 540, 540, 820); + bg.addColorStop(0, "#1a2328"); + bg.addColorStop(0.45, "#071015"); + bg.addColorStop(1, "#020407"); + ctx.fillStyle = bg; + ctx.fillRect(0, 0, width, height); + + const floor = ctx.createLinearGradient(0, 570, 0, 1080); + floor.addColorStop(0, "rgba(0,0,0,0)"); + floor.addColorStop( + 0.58, + theme.backdrop === "court" + ? "rgba(120,55,12,0.58)" + : "rgba(10,70,36,0.48)", + ); + floor.addColorStop(1, "#030405"); + ctx.fillStyle = floor; + ctx.fillRect(0, 520, width, 560); + + ctx.save(); + ctx.globalAlpha = theme.backdrop === "court" ? 0.28 : 0.22; + ctx.strokeStyle = "rgba(255,255,255,0.18)"; + ctx.lineWidth = 2; + + if (theme.backdrop === "court") { + for (let y = 560; y < height; y += 36) { + ctx.beginPath(); + ctx.moveTo(0, y + (y - 560) * 0.18); + ctx.lineTo(width, y + (y - 560) * 0.18); + ctx.stroke(); + } + ctx.beginPath(); + ctx.arc(width / 2, 710, 240, Math.PI, Math.PI * 2); + ctx.stroke(); + } else { + ctx.beginPath(); + ctx.moveTo(0, 630); + ctx.lineTo(width, 630); + ctx.moveTo(0, 840); + ctx.lineTo(width, 790); + ctx.moveTo(130, 1080); + ctx.lineTo(392, 640); + ctx.moveTo(950, 1080); + ctx.lineTo(688, 640); + ctx.stroke(); + } + ctx.restore(); + + this.radialGlow(ctx, 120, 135, 260, "rgba(255,255,255,0.23)"); + this.radialGlow(ctx, 960, 135, 260, "rgba(255,255,255,0.23)"); + this.radialGlow(ctx, 205, 210, 360, theme.glow); + this.radialGlow(ctx, 875, 210, 360, theme.glow); + this.radialGlow(ctx, 540, 640, 400, theme.mutedAccent); + + ctx.save(); + ctx.strokeStyle = theme.accent; + ctx.globalAlpha = 0.32; + ctx.lineWidth = 2; + for (let i = 0; i < 6; i++) { + const offset = i * 14; + ctx.beginPath(); + ctx.moveTo(70 + offset, 420); + ctx.lineTo(238 + offset, 360); + ctx.lineTo(402 + offset, 420); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(1010 - offset, 420); + ctx.lineTo(842 - offset, 360); + ctx.lineTo(678 - offset, 420); + ctx.stroke(); + } + ctx.restore(); + + ctx.save(); + ctx.strokeStyle = "rgba(255,255,255,0.18)"; + ctx.lineWidth = 1; + for (let y = 120; y < 520; y += 52) { + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.lineTo(width, y - 28); + ctx.stroke(); + } + ctx.restore(); + } + + private drawHeader( + ctx: any, + data: PredictionCardDto, + theme: Theme, + countryFlag: any, + leagueLogo: any, + ) { + ctx.save(); + ctx.shadowColor = theme.glow; + ctx.shadowBlur = 20; + ctx.fillStyle = "rgba(0,0,0,0.78)"; + this.angularRect(ctx, 118, 26, 844, 70, 34, true); + ctx.restore(); + ctx.strokeStyle = theme.accent; + ctx.lineWidth = 2; + this.angularRect(ctx, 118, 26, 844, 70, 34, false, true); + + const iconY = 62; + this.drawHeaderItem( + ctx, + data.sport === "basketball" ? "◉" : "●", + data.leagueName, + 204, + iconY, + theme, + leagueLogo, + ); + this.drawHeaderItem( + ctx, + "", + data.countryName || "", + 520, + iconY, + theme, + countryFlag, + ); + this.drawHeaderItem(ctx, "◷", data.matchDate, 790, iconY, theme); + + ctx.fillStyle = theme.accent; + ctx.fillRect(388, 44, 2, 36); + ctx.fillRect(634, 44, 2, 36); + } + + private drawMatchBlock( + ctx: any, + data: PredictionCardDto, + theme: Theme, + homeImg: any, + awayImg: any, + ) { + this.drawTeam(ctx, homeImg, data.homeTeam, 260, 214, theme); + this.drawTeam(ctx, awayImg, data.awayTeam, 820, 214, theme); + + ctx.save(); + ctx.shadowColor = "rgba(255,255,255,0.55)"; + ctx.shadowBlur = 12; + ctx.fillStyle = "#ffffff"; + ctx.font = "900 70px Arial"; + ctx.textAlign = "center"; + ctx.fillText("VS", 540, 265); + ctx.restore(); + + ctx.save(); + ctx.shadowColor = theme.glow; + ctx.shadowBlur = 18; + ctx.fillStyle = "rgba(0,0,0,0.82)"; + this.angularRect(ctx, 230, 408, 620, 72, 42, true); + ctx.restore(); + ctx.strokeStyle = theme.accent; + ctx.lineWidth = 2; + this.angularRect(ctx, 230, 408, 620, 72, 42, false, true); + + ctx.fillStyle = "#ffffff"; + ctx.font = "900 50px Arial"; + ctx.textAlign = "center"; + ctx.fillText(theme.title, 540, 462); + } + + private drawTeam( + ctx: any, + img: any, + name: string, + centerX: number, + centerY: number, + theme: Theme, + ) { + this.drawShield(ctx, centerX, centerY - 15, 172, 204, theme); + + if (img) { + this.drawImageContain(ctx, img, centerX - 62, centerY - 82, 124, 124); + } else { + ctx.fillStyle = theme.accent; + ctx.font = "900 72px Arial"; + ctx.textAlign = "center"; + ctx.fillText(this.initials(name), centerX, centerY - 8); + } + + ctx.save(); + ctx.shadowColor = "rgba(255,255,255,0.28)"; + ctx.shadowBlur = 8; + ctx.fillStyle = "#ffffff"; + ctx.textAlign = "center"; + this.fitCenteredText( + ctx, + name.toLocaleUpperCase("tr-TR"), + centerX, + 362, + 452, + 54, + ); + ctx.restore(); + + const underline = ctx.createLinearGradient( + centerX - 190, + 0, + centerX + 190, + 0, + ); + underline.addColorStop(0, theme.accent); + underline.addColorStop(0.5, "#ffffff"); + underline.addColorStop(1, theme.accent2); + ctx.fillStyle = underline; + ctx.fillRect(centerX - 190, 382, 380, 3); + } + + private drawScoreBlock(ctx: any, data: PredictionCardDto, theme: Theme) { + this.drawScoreCell( + ctx, + 105, + 512, + 370, + 150, + theme.halfLabel, + data.htScore, + theme, + false, + ); + this.drawScoreCell( + ctx, + 605, + 512, + 370, + 150, + "Maç Sonu", + data.ftScore, + theme, + true, + ); + + this.drawSportBall(ctx, 540, 608, data.sport, theme); + + const badgeText = `Güven %${data.scoreConfidence}`; + ctx.font = "900 32px Arial"; + const badgeW = Math.ceil(ctx.measureText(badgeText).width + 64); + const badgeX = Math.round(540 - badgeW / 2); + ctx.fillStyle = "rgba(0,0,0,0.88)"; + this.angularRect(ctx, badgeX, 658, badgeW, 54, 28, true); + ctx.strokeStyle = theme.accent; + ctx.lineWidth = 2; + this.angularRect(ctx, badgeX, 658, badgeW, 54, 28, false, true); + ctx.fillStyle = theme.accent2; + ctx.font = "900 32px Arial"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(badgeText, 540, 685); + ctx.textBaseline = "alphabetic"; + } + + private drawScoreCell( + ctx: any, + x: number, + y: number, + w: number, + h: number, + label: string, + score: string, + theme: Theme, + highlighted: boolean, + ) { + const grad = ctx.createLinearGradient(x, y, x + w, y + h); + grad.addColorStop(0, highlighted ? theme.mutedAccent : "rgba(0,0,0,0.86)"); + grad.addColorStop(1, "rgba(0,0,0,0.92)"); + ctx.fillStyle = grad; + this.angularRect(ctx, x, y + 32, w, h - 32, 32, true); + ctx.strokeStyle = theme.accent; + ctx.lineWidth = 2; + this.angularRect(ctx, x, y + 32, w, h - 32, 32, false, true); + + const labelGrad = ctx.createLinearGradient(x, y, x + w, y); + labelGrad.addColorStop(0, theme.mutedAccent); + labelGrad.addColorStop(1, "rgba(0,0,0,0.88)"); + ctx.fillStyle = labelGrad; + this.angularRect(ctx, x + 55, y, w - 110, 48, 18, true); + ctx.strokeStyle = theme.accent; + this.angularRect(ctx, x + 55, y, w - 110, 48, 18, false, true); + + ctx.fillStyle = "#ffffff"; + ctx.font = "900 34px Arial"; + ctx.textAlign = "center"; + ctx.fillText(label, x + w / 2, y + 36); + + ctx.save(); + ctx.shadowColor = "rgba(255,255,255,0.35)"; + ctx.shadowBlur = 10; + ctx.fillStyle = "#ffffff"; + ctx.font = "900 92px Arial"; + ctx.fillText(score.replace("-", " - "), x + w / 2, y + 122); + ctx.restore(); + } + + private drawPicks(ctx: any, picks: TopPick[], theme: Theme) { + ctx.fillStyle = "rgba(0,0,0,0.88)"; + this.angularRect(ctx, 326, 704, 428, 58, 32, true); + ctx.strokeStyle = theme.accent; + ctx.lineWidth = 2; + this.angularRect(ctx, 326, 704, 428, 58, 32, false, true); + ctx.fillStyle = "#ffffff"; + ctx.font = "900 38px Arial"; + ctx.textAlign = "center"; + ctx.fillText("EN İYİ 3 TAHMİN", 540, 746); + + picks.slice(0, 3).forEach((pick, index) => { + const y = 772 + index * 58; + ctx.fillStyle = "rgba(0,0,0,0.88)"; + this.angularRect(ctx, 66, y, 948, 50, 26, true); + ctx.strokeStyle = theme.accent; + ctx.lineWidth = 1.5; + this.angularRect(ctx, 66, y, 948, 50, 26, false, true); + + const rankGrad = ctx.createLinearGradient(76, y, 178, y + 54); + rankGrad.addColorStop(0, theme.accent); + rankGrad.addColorStop(1, "rgba(0,0,0,0.75)"); + ctx.fillStyle = rankGrad; + this.angularRect(ctx, 76, y, 88, 50, 18, true); + ctx.fillStyle = "#ffffff"; + ctx.font = "900 34px Arial"; + ctx.textAlign = "center"; + ctx.fillText(String(index + 1), 120, y + 37); + + ctx.fillStyle = "#ffffff"; + this.fitText(ctx, this.pickLabel(pick), 192, y + 34, 330, 29, "900"); + + const barX = 520; + const barY = y + 20; + const barW = 330; + ctx.fillStyle = "rgba(255,255,255,0.22)"; + this.roundRect(ctx, barX, barY, barW, 10, 5, true); + const fill = Math.max(0, Math.min(1, pick.confidence / 100)); + const grad = ctx.createLinearGradient(barX, 0, barX + barW, 0); + grad.addColorStop(0, "#8df05d"); + grad.addColorStop(1, theme.accent2); + ctx.fillStyle = grad; + this.roundRect(ctx, barX, barY, barW * fill, 10, 5, true); + + ctx.fillStyle = "#89f45d"; + ctx.font = "900 34px Arial"; + ctx.textAlign = "right"; + ctx.fillText(`%${pick.confidence}`, 962, y + 37); + }); + } + + private drawFooter(ctx: any, data: PredictionCardDto, theme: Theme) { + const riskText = `Risk: ${this.translateRisk(data.riskLevel)}`; + ctx.font = "900 34px Arial"; + const riskW = Math.ceil(ctx.measureText(riskText).width + 72); + const riskX = Math.round(540 - riskW / 2); + ctx.fillStyle = "rgba(0,0,0,0.86)"; + this.roundRect(ctx, riskX, 956, riskW, 54, 24, true); + ctx.strokeStyle = "#ffbe32"; + ctx.lineWidth = 2; + this.roundRect(ctx, riskX, 956, riskW, 54, 24, false, true); + ctx.fillStyle = this.riskColor(data.riskLevel); + ctx.font = "900 34px Arial"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(riskText, 540, 983); + ctx.textBaseline = "alphabetic"; + + ctx.fillStyle = "#ffffff"; + ctx.font = "900 42px Arial"; + ctx.textAlign = "center"; + ctx.fillText("iddaai.com", 540, 1065); + } + + private drawHeaderItem( + ctx: any, + icon: string, + text: string, + centerX: number, + centerY: number, + theme: Theme, + image?: any, + ) { + if (image) { + this.drawImageCover(ctx, image, centerX - 92, centerY - 16, 34, 28, 8); + } else { + ctx.fillStyle = theme.accent; + ctx.font = "900 28px Arial"; + ctx.textAlign = "center"; + ctx.fillText(icon, centerX - 75, centerY + 10); + } + + ctx.fillStyle = "#ffffff"; + ctx.font = "900 30px Arial"; + ctx.textAlign = "left"; + ctx.fillText(text, centerX - 42, centerY + 10); + } + + private drawShield( + ctx: any, + centerX: number, + centerY: number, + width: number, + height: number, + theme: Theme, + ) { + const x = centerX - width / 2; + const y = centerY - height / 2; + + ctx.save(); + ctx.shadowColor = theme.glow; + ctx.shadowBlur = 26; + const grad = ctx.createLinearGradient(x, y, x + width, y + height); + grad.addColorStop(0, theme.mutedAccent); + grad.addColorStop(0.55, "rgba(2,8,12,0.95)"); + grad.addColorStop(1, "rgba(0,0,0,0.98)"); + ctx.fillStyle = grad; + ctx.beginPath(); + ctx.moveTo(centerX, y); + ctx.lineTo(x + width - 10, y + 34); + ctx.lineTo(x + width - 20, y + height - 56); + ctx.quadraticCurveTo(centerX, y + height + 8, x + 20, y + height - 56); + ctx.lineTo(x + 10, y + 34); + ctx.closePath(); + ctx.fill(); + ctx.restore(); + + ctx.strokeStyle = "#ffd34d"; + ctx.lineWidth = 5; + ctx.beginPath(); + ctx.moveTo(centerX, y); + ctx.lineTo(x + width - 10, y + 34); + ctx.lineTo(x + width - 20, y + height - 56); + ctx.quadraticCurveTo(centerX, y + height + 8, x + 20, y + height - 56); + ctx.lineTo(x + 10, y + 34); + ctx.closePath(); + ctx.stroke(); + } + + private drawSportBall( + ctx: any, + x: number, + y: number, + sport: PredictionCardDto["sport"], + theme: Theme, + ) { + ctx.save(); + ctx.shadowColor = theme.glow; + ctx.shadowBlur = 18; + ctx.fillStyle = "rgba(0,0,0,0.85)"; + ctx.beginPath(); + ctx.arc(x, y, 55, 0, Math.PI * 2); + ctx.fill(); + ctx.strokeStyle = theme.accent; + ctx.lineWidth = 3; + ctx.stroke(); + + ctx.fillStyle = sport === "basketball" ? "#d96a19" : "#f4f4f4"; + ctx.beginPath(); + ctx.arc(x, y, 36, 0, Math.PI * 2); + ctx.fill(); + ctx.strokeStyle = sport === "basketball" ? "#1b0f08" : "#111"; + ctx.lineWidth = 3; + ctx.stroke(); + + ctx.beginPath(); + if (sport === "basketball") { + ctx.moveTo(x - 36, y); + ctx.lineTo(x + 36, y); + ctx.moveTo(x, y - 36); + ctx.lineTo(x, y + 36); + ctx.arc(x - 36, y, 36, -Math.PI / 2, Math.PI / 2); + ctx.arc(x + 36, y, 36, Math.PI / 2, -Math.PI / 2); + } else { + ctx.moveTo(x - 28, y); + ctx.lineTo(x + 28, y); + ctx.moveTo(x, y - 28); + ctx.lineTo(x, y + 28); + ctx.arc(x, y, 18, 0, Math.PI * 2); + } + ctx.stroke(); + ctx.restore(); + } + + private async loadImageSafe(url: string) { + if (!url) return null; + + try { + if (url.startsWith("/")) { + const localPath = path.join(process.cwd(), "public", url); + if (fs.existsSync(localPath)) { + return await loadImage(localPath); + } + } + + if (url.startsWith("http")) { + const response = await axios.get(url, { + responseType: "arraybuffer", + timeout: 7000, + }); + return await loadImage(response.data); + } + } catch (error) { + this.logger.warn( + `Could not load social poster image ${url}: ${error.message}`, + ); + } + + return null; + } + + private roundRect( + ctx: any, + x: number, + y: number, + width: number, + height: number, + radius: number, + fill = false, + stroke = false, + ) { + const r = Math.min(radius, width / 2, height / 2); + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.arcTo(x + width, y, x + width, y + height, r); + ctx.arcTo(x + width, y + height, x, y + height, r); + ctx.arcTo(x, y + height, x, y, r); + ctx.arcTo(x, y, x + width, y, r); + ctx.closePath(); + if (fill) ctx.fill(); + if (stroke) ctx.stroke(); + } + + private angularRect( + ctx: any, + x: number, + y: number, + width: number, + height: number, + cut: number, + fill = false, + stroke = false, + ) { + const c = Math.min(cut, width / 2, height / 2); + ctx.beginPath(); + ctx.moveTo(x + c, y); + ctx.lineTo(x + width - c, y); + ctx.lineTo(x + width, y + height / 2); + ctx.lineTo(x + width - c, y + height); + ctx.lineTo(x + c, y + height); + ctx.lineTo(x, y + height / 2); + ctx.closePath(); + if (fill) ctx.fill(); + if (stroke) ctx.stroke(); + } + + private radialGlow( + ctx: any, + x: number, + y: number, + radius: number, + color: string, + ) { + const grad = ctx.createRadialGradient(x, y, 0, x, y, radius); + grad.addColorStop(0, color); + grad.addColorStop(1, "rgba(0,0,0,0)"); + ctx.fillStyle = grad; + ctx.fillRect(x - radius, y - radius, radius * 2, radius * 2); + } + + private drawImageContain( + ctx: any, + img: any, + x: number, + y: number, + w: number, + h: number, + ) { + const ratio = Math.min(w / img.width, h / img.height); + const drawW = img.width * ratio; + const drawH = img.height * ratio; + ctx.drawImage(img, x + (w - drawW) / 2, y + (h - drawH) / 2, drawW, drawH); + } + + private drawImageCover( + ctx: any, + img: any, + x: number, + y: number, + w: number, + h: number, + radius: number, + ) { + ctx.save(); + this.roundRect(ctx, x, y, w, h, radius, false); + ctx.clip(); + const ratio = Math.max(w / img.width, h / img.height); + const drawW = img.width * ratio; + const drawH = img.height * ratio; + ctx.drawImage(img, x + (w - drawW) / 2, y + (h - drawH) / 2, drawW, drawH); + ctx.restore(); + } + + private fitText( + ctx: any, + text: string, + x: number, + y: number, + maxWidth: number, + fontSize: number, + weight = "700", + ) { + let size = fontSize; + do { + ctx.font = `${weight} ${size}px Arial`; + if (ctx.measureText(text).width <= maxWidth) break; + size -= 1; + } while (size >= 16); + ctx.textAlign = x < 540 ? "left" : "center"; + ctx.fillText(text, x < 540 ? x : x + maxWidth / 2, y); + } + + private fitCenteredText( + ctx: any, + text: string, + centerX: number, + y: number, + maxWidth: number, + fontSize: number, + ) { + let size = fontSize; + do { + ctx.font = `900 ${size}px Arial`; + if (ctx.measureText(text).width <= maxWidth) break; + size -= 1; + } while (size >= 22); + ctx.textAlign = "center"; + ctx.fillText(text, centerX, y); + } + + private pickLabel(pick: TopPick): string { + return pick.market + .replace(/\s*:\s*/g, " ") + .replace(/(Üst\s+\d+(?:\.\d+)?\s+Gol)\s+Üst$/i, "$1") + .trim(); + } + + private initials(name: string): string { + return name + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map((part) => part[0]) + .join("") + .toLocaleUpperCase("tr-TR"); + } + + private translateRisk(risk: PredictionCardDto["riskLevel"]): string { + switch (risk) { + case "LOW": + return "Düşük"; + case "HIGH": + return "Yüksek"; + case "EXTREME": + return "Çok Yüksek"; + case "MEDIUM": + default: + return "Orta"; + } + } + + private riskBg(risk: PredictionCardDto["riskLevel"]): string { + if (risk === "LOW") return "rgba(32, 228, 139, 0.16)"; + if (risk === "HIGH") return "rgba(255, 122, 26, 0.16)"; + if (risk === "EXTREME") return "rgba(255, 64, 80, 0.16)"; + return "rgba(255, 196, 52, 0.16)"; + } + + private riskColor(risk: PredictionCardDto["riskLevel"]): string { + if (risk === "LOW") return "#20e48b"; + if (risk === "HIGH") return "#ff7a1a"; + if (risk === "EXTREME") return "#ff4050"; + return "#ffc434"; + } + getImageUrl(filePath: string): string { const relativePath = path.relative( path.join(process.cwd(), "public"), diff --git a/src/modules/social-poster/meta.service.ts b/src/modules/social-poster/meta.service.ts index df45de3..93c7c93 100644 --- a/src/modules/social-poster/meta.service.ts +++ b/src/modules/social-poster/meta.service.ts @@ -10,13 +10,16 @@ export class MetaService { private readonly pageId: string; private readonly igUserId: string; private readonly isEnabled: boolean; - private readonly graphApiBase = "https://graph.facebook.com/v21.0"; + private readonly graphApiBase: string; constructor(private readonly configService: ConfigService) { this.pageAccessToken = this.configService.get("META_PAGE_ACCESS_TOKEN") || ""; this.pageId = this.configService.get("META_PAGE_ID") || ""; this.igUserId = this.configService.get("META_IG_USER_ID") || ""; + const graphVersion = + this.configService.get("META_GRAPH_API_VERSION") || "v25.0"; + this.graphApiBase = `https://graph.facebook.com/${graphVersion}`; this.isEnabled = !!(this.pageAccessToken && this.pageId); @@ -63,11 +66,12 @@ export class MetaService { { url: imageUrl, message, + published: true, access_token: this.pageAccessToken, }, ); - const postId = response.data?.id; + const postId = response.data?.post_id || response.data?.id; this.logger.log(`✅ Facebook post published: ${postId}`); return postId || null; } catch (error) { @@ -109,6 +113,7 @@ export class MetaService { { image_url: imageUrl, caption, + alt_text: this.buildAltText(caption), access_token: this.pageAccessToken, }, ); @@ -156,7 +161,7 @@ export class MetaService { `${this.graphApiBase}/${containerId}`, { params: { - fields: "status_code", + fields: "status_code,status", access_token: this.pageAccessToken, }, }, @@ -177,4 +182,12 @@ export class MetaService { this.logger.warn("Container wait timed out, attempting publish anyway"); } + + private buildAltText(caption: string): string { + return caption + .replace(/#[^\s#]+/g, "") + .replace(/\s+/g, " ") + .trim() + .slice(0, 900); + } } diff --git a/src/modules/social-poster/social-poster.service.ts b/src/modules/social-poster/social-poster.service.ts index 85fc021..5d9267d 100644 --- a/src/modules/social-poster/social-poster.service.ts +++ b/src/modules/social-poster/social-poster.service.ts @@ -19,6 +19,11 @@ import { // Top leagues loaded once const TOP_LEAGUES_PATH = path.join(process.cwd(), "top_leagues.json"); +const POSTED_STATE_PATH = path.join( + process.cwd(), + "storage", + "social-poster-posted.json", +); @Injectable() export class SocialPosterService { @@ -26,6 +31,9 @@ export class SocialPosterService { private readonly aiEngineUrl: string; private readonly appBaseUrl: string; private readonly isEnabled: boolean; + private readonly sports: string[]; + private readonly windowMinMinutes: number; + private readonly windowMaxMinutes: number; private readonly postedMatchIds = new Set(); private topLeagueIds: Set = new Set(); @@ -44,8 +52,22 @@ export class SocialPosterService { this.configService.get("APP_BASE_URL") || "http://localhost:3000"; this.isEnabled = this.configService.get("SOCIAL_POSTER_ENABLED") === "true"; + this.sports = ( + this.configService.get("SOCIAL_POSTER_SPORTS") || + "football,basketball" + ) + .split(",") + .map((sport) => sport.trim()) + .filter(Boolean); + this.windowMinMinutes = Number( + this.configService.get("SOCIAL_POSTER_WINDOW_MIN") || 25, + ); + this.windowMaxMinutes = Number( + this.configService.get("SOCIAL_POSTER_WINDOW_MAX") || 45, + ); this.loadTopLeagues(); + this.loadPostedState(); } private loadTopLeagues() { @@ -59,16 +81,45 @@ export class SocialPosterService { } } + private loadPostedState() { + try { + const data = fs.readFileSync(POSTED_STATE_PATH, "utf-8"); + const ids = JSON.parse(data); + if (Array.isArray(ids)) { + ids.forEach((id) => this.postedMatchIds.add(String(id))); + } + this.logger.log( + `✅ Loaded ${this.postedMatchIds.size} posted social match IDs`, + ); + } catch { + this.logger.warn("⚠️ No social poster state file found yet"); + } + } + + private savePostedState() { + const dir = path.dirname(POSTED_STATE_PATH); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync( + POSTED_STATE_PATH, + JSON.stringify(Array.from(this.postedMatchIds).slice(-500), null, 2), + ); + } + /** - * Cron: Every 10 minutes, check for upcoming matches. + * Cron: Every 15 minutes, check for upcoming matches. * Posts predictions 30 minutes before kickoff. */ - @Cron("*/10 * * * *") + @Cron("*/15 * * * *") async checkAndPostUpcomingMatches() { if (!this.isEnabled) return; try { - const matches = await this.getUpcomingMatches(25, 40); // 25-40 min window + const matches = await this.getUpcomingMatches( + this.windowMinMinutes, + this.windowMaxMinutes, + ); this.logger.log( `📅 Found ${matches.length} upcoming matches in the window`, ); @@ -77,7 +128,19 @@ export class SocialPosterService { if (this.postedMatchIds.has(match.id)) continue; try { - await this.predictAndPost(match); + const result = await this.predictAndPost(match); + const posted = + result.twitterPostId || + result.facebookPostId || + result.instagramPostId; + + if (!posted) { + this.logger.warn( + `No platform accepted post for match ${match.id}; it will be retried later`, + ); + continue; + } + this.postedMatchIds.add(match.id); // Cleanup: remove old IDs (keep last 500) @@ -87,6 +150,7 @@ export class SocialPosterService { .slice(0, arr.length - 500) .forEach((id) => this.postedMatchIds.delete(id)); } + this.savePostedState(); } catch (error) { this.logger.error( `Failed to process match ${match.id}: ${error.message}`, @@ -113,19 +177,33 @@ export class SocialPosterService { const minTime = now + minMinutes * 60 * 1000; const maxTime = now + maxMinutes * 60 * 1000; + const where: any = { + sport: { in: this.sports }, + mstUtc: { + gte: minTime, + lte: maxTime, + }, + }; + + if (this.topLeagueIds.size > 0) { + where.leagueId = { in: Array.from(this.topLeagueIds) }; + } + const matches = await this.prisma.liveMatch.findMany({ where: { - sport: "football", - leagueId: { in: Array.from(this.topLeagueIds) }, - mstUtc: { - gte: minTime, - lte: maxTime, - }, + ...where, }, include: { homeTeam: true, awayTeam: true, - league: true, + league: { + include: { + country: true, + }, + }, + }, + orderBy: { + mstUtc: "asc", }, }); @@ -233,7 +311,10 @@ export class SocialPosterService { const ftScore = score.ft || "1-1"; // Extract best bets from bet_summary array - const topPicks = this.extractTopPicks(prediction); + const sport = this.normalizeSport( + match.sport || prediction.match_info?.sport, + ); + const topPicks = this.extractTopPicks(prediction, sport); // Match date formatting const matchDate = this.formatMatchDate(match.mstUtc); @@ -246,6 +327,7 @@ export class SocialPosterService { return { matchId: match.id, + sport, homeTeam: match.homeTeam?.name || prediction.match_info?.home_team || "Home", awayTeam: @@ -253,6 +335,12 @@ export class SocialPosterService { homeLogo: this.resolveLogoUrl(match.homeTeam?.logoUrl || ""), awayLogo: this.resolveLogoUrl(match.awayTeam?.logoUrl || ""), leagueName: match.league?.name || prediction.match_info?.league || "", + leagueLogo: this.resolveLogoUrl(match.league?.logoUrl || ""), + countryName: + match.league?.country?.name || + prediction.match_info?.country || + this.inferCountryName(match.league?.name || ""), + countryFlag: this.resolveLogoUrl(match.league?.country?.flagUrl || ""), matchDate, htScore, ftScore, @@ -266,11 +354,14 @@ export class SocialPosterService { /** * Extract top 3 picks sorted by confidence from the V20+ bet_summary array. */ - private extractTopPicks(prediction: any): TopPick[] { + private extractTopPicks( + prediction: any, + sport: "football" | "basketball", + ): TopPick[] { const betSummary: any[] = prediction.bet_summary || []; // Market code to Turkish/English label mapping - const marketLabels: Record = { + const footballLabels: Record = { MS: { tr: "Maç Sonucu", en: "Match Result" }, OU15: { tr: "Üst 1.5 Gol", en: "Over 1.5" }, OU25: { tr: "Üst 2.5 Gol", en: "Over 2.5" }, @@ -282,6 +373,20 @@ export class SocialPosterService { OE: { tr: "Tek/Çift", en: "Odd/Even" }, HTFT: { tr: "İY/MS", en: "HT/FT" }, }; + const basketballLabels: Record = { + MS: { tr: "Maç Sonucu", en: "Match Result" }, + ML: { tr: "Maç Sonucu", en: "Moneyline" }, + WINNER: { tr: "Maç Sonucu", en: "Winner" }, + TOTAL: { tr: "Toplam Sayı", en: "Total Points" }, + OU: { tr: "Toplam Sayı", en: "Total Points" }, + OVER_UNDER: { tr: "Toplam Sayı", en: "Total Points" }, + HANDICAP: { tr: "Handikap", en: "Handicap" }, + HND: { tr: "Handikap", en: "Handicap" }, + SPREAD: { tr: "Handikap", en: "Spread" }, + HT: { tr: "İlk Devre Sonucu", en: "First Half Result" }, + }; + const marketLabels = + sport === "basketball" ? basketballLabels : footballLabels; const candidates: TopPick[] = betSummary.map((bet) => { const labels = marketLabels[bet.market] || { @@ -302,6 +407,32 @@ export class SocialPosterService { return candidates.slice(0, 3); } + private inferCountryName(leagueName: string): string { + const normalized = leagueName.toLocaleLowerCase("tr-TR"); + if (normalized.includes("süper lig") || normalized.includes("türkiye")) { + return "Türkiye"; + } + if ( + normalized.includes("premier league") || + normalized.includes("championship") + ) { + return "İngiltere"; + } + if (normalized.includes("la liga")) return "İspanya"; + if (normalized.includes("serie a")) return "İtalya"; + if (normalized.includes("bundesliga")) return "Almanya"; + if (normalized.includes("ligue 1")) return "Fransa"; + if (normalized.includes("euroleague")) return "Avrupa"; + if (normalized.includes("nba")) return "ABD"; + return ""; + } + + private normalizeSport(sport?: string): "football" | "basketball" { + return String(sport).toLowerCase() === "basketball" + ? "basketball" + : "football"; + } + /** * Convert relative logo paths to full HTTP URLs. * On the deployed server, logos exist at public/uploads/teams/... @@ -351,7 +482,11 @@ export class SocialPosterService { include: { homeTeam: true, awayTeam: true, - league: true, + league: { + include: { + country: true, + }, + }, }, }); @@ -373,7 +508,11 @@ export class SocialPosterService { include: { homeTeam: true, awayTeam: true, - league: true, + league: { + include: { + country: true, + }, + }, }, }); diff --git a/src/modules/social-poster/twitter.service.ts b/src/modules/social-poster/twitter.service.ts index fe9c358..0ca4e8e 100644 --- a/src/modules/social-poster/twitter.service.ts +++ b/src/modules/social-poster/twitter.service.ts @@ -20,7 +20,7 @@ export class TwitterService { void this.initClient(apiKey, apiSecret, accessToken, accessSecret); } else { this.logger.warn( - "⚠️ Twitter API keys not configured. Set TWITTER_API_KEY, TWITTER_API_SECRET, TWITTER_ACCESS_TOKEN, TWITTER_ACCESS_SECRET", + "⚠️ X/Twitter API keys not configured. Set TWITTER_API_KEY, TWITTER_API_SECRET, TWITTER_ACCESS_TOKEN, TWITTER_ACCESS_SECRET", ); } } @@ -64,10 +64,10 @@ export class TwitterService { } try { - // Step 1: Upload media via v1.1 + // Step 1: Upload image media via the X media upload endpoint. const mediaData = fs.readFileSync(imagePath); const mediaId = await this.client.v1.uploadMedia(mediaData, { - mimeType: "image/png", + mimeType: this.getMimeType(imagePath), }); // Step 2: Create tweet via v2 @@ -84,4 +84,12 @@ export class TwitterService { return null; } } + + private getMimeType(imagePath: string): string { + const ext = imagePath.toLowerCase().split(".").pop(); + if (ext === "jpg" || ext === "jpeg") return "image/jpeg"; + if (ext === "webp") return "image/webp"; + if (ext === "png") return "image/png"; + return "image/jpeg"; + } }