535 lines
16 KiB
TypeScript
535 lines
16 KiB
TypeScript
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<string>();
|
||
private topLeagueIds: Set<string> = 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<string>("AI_ENGINE_URL") ||
|
||
"http://localhost:8000";
|
||
this.appBaseUrl =
|
||
this.configService.get<string>("APP_BASE_URL") || "http://localhost:3000";
|
||
this.isEnabled =
|
||
this.configService.get<string>("SOCIAL_POSTER_ENABLED") === "true";
|
||
this.sports = (
|
||
this.configService.get<string>("SOCIAL_POSTER_SPORTS") ||
|
||
"football,basketball"
|
||
)
|
||
.split(",")
|
||
.map((sport) => sport.trim())
|
||
.filter(Boolean);
|
||
this.windowMinMinutes = Number(
|
||
this.configService.get<string>("SOCIAL_POSTER_WINDOW_MIN") || 25,
|
||
);
|
||
this.windowMaxMinutes = Number(
|
||
this.configService.get<string>("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<any[]> {
|
||
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<SocialPostResult> {
|
||
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<any> {
|
||
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<string, { tr: string; en: string }> = {
|
||
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<string, { tr: string; en: string }> = {
|
||
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<SocialPostResult> {
|
||
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 };
|
||
}
|
||
}
|