import { Injectable, Logger } from "@nestjs/common"; import { Cron } from "@nestjs/schedule"; import { ConfigService } from "@nestjs/config"; import { PrismaService } from "../../database/prisma.service"; import axios from "axios"; import * as fs from "fs"; import * as path from "path"; import { ImageRendererService } from "./image-renderer.service"; import { CaptionGeneratorService } from "./caption-generator.service"; import { TwitterService } from "./twitter.service"; import { MetaService } from "./meta.service"; import { PredictionCardDto, TopPick, SocialPostResult, } from "./dto/prediction-card.dto"; // 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 { private readonly logger = new Logger(SocialPosterService.name); 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(); constructor( private readonly prisma: PrismaService, private readonly configService: ConfigService, private readonly imageRenderer: ImageRendererService, private readonly captionGenerator: CaptionGeneratorService, private readonly twitterService: TwitterService, private readonly metaService: MetaService, ) { this.aiEngineUrl = this.configService.get("AI_ENGINE_URL") || "http://localhost:8000"; this.appBaseUrl = 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() { try { const data = fs.readFileSync(TOP_LEAGUES_PATH, "utf-8"); const ids = JSON.parse(data); this.topLeagueIds = new Set(ids); this.logger.log(`✅ Loaded ${this.topLeagueIds.size} top league IDs`); } catch { this.logger.warn("⚠️ Could not load top_leagues.json"); } } 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 15 minutes, check for upcoming matches. * Posts predictions 30 minutes before kickoff. */ @Cron("*/15 * * * *") async checkAndPostUpcomingMatches() { if (!this.isEnabled) return; try { const matches = await this.getUpcomingMatches( this.windowMinMinutes, this.windowMaxMinutes, ); this.logger.log( `📅 Found ${matches.length} upcoming matches in the window`, ); for (const match of matches) { if (this.postedMatchIds.has(match.id)) continue; try { 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) if (this.postedMatchIds.size > 500) { const arr = Array.from(this.postedMatchIds); arr .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}`, ); } // Small delay between posts to avoid rate limits await new Promise((resolve) => setTimeout(resolve, 3000)); } } catch (error) { this.logger.error(`Cron job failed: ${error.message}`); } } /** * Get matches starting in [minMinutes, maxMinutes] from now. * Filtered by top leagues. */ private async getUpcomingMatches( minMinutes: number, maxMinutes: number, ): Promise { const now = Date.now(); 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: { ...where, }, include: { homeTeam: true, awayTeam: true, league: { include: { country: true, }, }, }, orderBy: { mstUtc: "asc", }, }); return matches; } /** * Full pipeline: Predict → Render Image → Generate Caption → Post. */ async predictAndPost(match: any): Promise { const matchId = match.id; this.logger.log( `🚀 Processing: ${match.homeTeam?.name} vs ${match.awayTeam?.name}`, ); // Step 1: Get prediction from AI Engine const prediction = await this.getPrediction(matchId); if (!prediction) { throw new Error("No prediction returned from AI Engine"); } // Step 2: Build prediction card data const card = this.buildCardFromPrediction(match, prediction); // Step 3: Render image const imagePath = await this.imageRenderer.renderCard(card); const imageUrl = `${this.appBaseUrl}${this.imageRenderer.getImageUrl(imagePath)}`; // Step 4: Generate caption via Gemini const caption = await this.captionGenerator.generateCaption(card); // Step 5: Post to all platforms const result: SocialPostResult = { matchId, imagePath, caption, postedAt: new Date(), errors: [], }; // Twitter try { result.twitterPostId = (await this.twitterService.postWithImage(caption, imagePath)) || undefined; } catch (error) { result.errors!.push(`Twitter: ${error.message}`); } // Facebook try { result.facebookPostId = (await this.metaService.postToFacebook(caption, imageUrl)) || undefined; } catch (error) { result.errors!.push(`Facebook: ${error.message}`); } // Instagram try { result.instagramPostId = (await this.metaService.postToInstagram(caption, imageUrl)) || undefined; } catch (error) { result.errors!.push(`Instagram: ${error.message}`); } this.logger.log( `✅ Posted: ${match.homeTeam?.name} vs ${match.awayTeam?.name} ` + `[TW: ${result.twitterPostId ? "✅" : "❌"}, ` + `FB: ${result.facebookPostId ? "✅" : "❌"}, ` + `IG: ${result.instagramPostId ? "✅" : "❌"}]`, ); return result; } /** * Call AI Engine's V20+ prediction endpoint directly. */ private async getPrediction(matchId: string): Promise { try { const response = await axios.post( `${this.aiEngineUrl}/v20plus/analyze/${matchId}`, null, { timeout: 30000 }, ); return response.data; } catch (error) { this.logger.error(`AI Engine request failed: ${error.message}`); return null; } } /** * Build a PredictionCardDto from the raw AI prediction + match data. * Maps the V20+ response structure to our card DTO. */ private buildCardFromPrediction( match: any, prediction: any, ): PredictionCardDto { // V20+ returns score_prediction.ft / .ht const score = prediction.score_prediction || {}; const htScore = score.ht || "0-0"; const ftScore = score.ft || "1-1"; // Extract best bets from bet_summary array 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); // Score confidence from main_pick or scenario_top5 const mainPick = prediction.main_pick || {}; const scoreConfidence = Math.round( mainPick.confidence || mainPick.raw_confidence || 50, ); return { matchId: match.id, sport, homeTeam: match.homeTeam?.name || prediction.match_info?.home_team || "Home", awayTeam: match.awayTeam?.name || prediction.match_info?.away_team || "Away", 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, scoreConfidence, topPicks, riskLevel: prediction.risk?.level || "MEDIUM", rawPrediction: prediction, }; } /** * Extract top 3 picks sorted by confidence from the V20+ bet_summary array. */ private extractTopPicks( prediction: any, sport: "football" | "basketball", ): TopPick[] { const betSummary: any[] = prediction.bet_summary || []; // Market code to Turkish/English label mapping 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" }, OU35: { tr: "Üst 3.5 Gol", en: "Over 3.5" }, BTTS: { tr: "Karşılıklı Gol", en: "Both Teams Score" }, DC: { tr: "Çifte Şans", en: "Double Chance" }, HT: { tr: "İlk Yarı Sonucu", en: "Half Time Result" }, HT_OU05: { tr: "İY 0.5 Üst/Alt", en: "HT Over/Under 0.5" }, 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] || { tr: bet.market, en: bet.market, }; return { market: `${labels.tr}: ${bet.pick}`, marketEn: `${labels.en}: ${bet.pick}`, pick: bet.pick, confidence: Math.round(bet.raw_confidence || bet.confidence || 0), odds: bet.odds || 0, }; }); // Sort by confidence and return top 3 candidates.sort((a, b) => b.confidence - a.confidence); 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/... * Locally during dev, we fetch them from the deployed server via APP_BASE_URL. */ private resolveLogoUrl(logoUrl: string): string { if (!logoUrl) return ""; // Already a full URL if (logoUrl.startsWith("http")) return logoUrl; // Relative path → check local first, otherwise make full URL const localPath = path.join(process.cwd(), "public", logoUrl); if (fs.existsSync(localPath)) return logoUrl; // Keep relative, renderer reads local // Not local → prepend base URL for remote fetch return `${this.appBaseUrl}${logoUrl}`; } private formatMatchDate(mstUtc: number | bigint): string { const d = new Date(Number(mstUtc)); const months = [ "Oca", "Şub", "Mar", "Nis", "May", "Haz", "Tem", "Ağu", "Eyl", "Eki", "Kas", "Ara", ]; const day = String(d.getDate()).padStart(2, "0"); const month = months[d.getMonth()]; const year = d.getFullYear(); const hour = String(d.getHours()).padStart(2, "0"); const min = String(d.getMinutes()).padStart(2, "0"); return `${day} ${month} ${year} - ${hour}:${min}`; } /** * Manual trigger for testing: predict and post for a specific match. */ async manualPost(matchId: string): Promise { const match = await this.prisma.liveMatch.findUnique({ where: { id: matchId }, include: { homeTeam: true, awayTeam: true, league: { include: { country: true, }, }, }, }); if (!match) { throw new Error(`Match ${matchId} not found`); } return this.predictAndPost(match); } /** * Manual trigger: render only (no posting) — for preview/testing. */ async renderPreview( matchId: string, ): Promise<{ imagePath: string; card: PredictionCardDto; caption: string }> { const match = await this.prisma.liveMatch.findUnique({ where: { id: matchId }, include: { homeTeam: true, awayTeam: true, league: { include: { country: true, }, }, }, }); if (!match) { throw new Error(`Match ${matchId} not found`); } const prediction = await this.getPrediction(matchId); if (!prediction) { throw new Error("No prediction returned from AI Engine"); } const card = this.buildCardFromPrediction(match, prediction); const imagePath = await this.imageRenderer.renderCard(card); const caption = await this.captionGenerator.generateCaption(card); return { imagePath, card, caption }; } }