Files
iddaai-be/src/modules/social-poster/social-poster.service.ts
T
fahricansecer 5645b38f20
Deploy Iddaai Backend / build-and-deploy (push) Successful in 32s
main
2026-05-05 17:09:11 +03:00

535 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 };
}
}