This commit is contained in:
@@ -0,0 +1,395 @@
|
||||
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');
|
||||
|
||||
@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 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.loadTopLeagues();
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cron: Every 10 minutes, check for upcoming matches.
|
||||
* Posts predictions 30 minutes before kickoff.
|
||||
*/
|
||||
@Cron('*/10 * * * *')
|
||||
async checkAndPostUpcomingMatches() {
|
||||
if (!this.isEnabled) return;
|
||||
|
||||
try {
|
||||
const matches = await this.getUpcomingMatches(25, 40); // 25-40 min window
|
||||
this.logger.log(
|
||||
`📅 Found ${matches.length} upcoming matches in the window`,
|
||||
);
|
||||
|
||||
for (const match of matches) {
|
||||
if (this.postedMatchIds.has(match.id)) continue;
|
||||
|
||||
try {
|
||||
await this.predictAndPost(match);
|
||||
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));
|
||||
}
|
||||
} 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 matches = await this.prisma.liveMatch.findMany({
|
||||
where: {
|
||||
sport: 'football',
|
||||
leagueId: { in: Array.from(this.topLeagueIds) },
|
||||
mstUtc: {
|
||||
gte: minTime,
|
||||
lte: maxTime,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
homeTeam: true,
|
||||
awayTeam: true,
|
||||
league: true,
|
||||
},
|
||||
});
|
||||
|
||||
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 topPicks = this.extractTopPicks(prediction);
|
||||
|
||||
// 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,
|
||||
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 || '',
|
||||
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): TopPick[] {
|
||||
const betSummary: any[] = prediction.bet_summary || [];
|
||||
|
||||
// Market code to Turkish/English label mapping
|
||||
const marketLabels: 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 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: 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: 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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user