first (part 3: src directory)
Deploy Iddaai Backend / build-and-deploy (push) Successful in 33s

This commit is contained in:
2026-04-16 15:12:27 +03:00
parent 2f0b85a0c7
commit 182f4aae16
125 changed files with 22552 additions and 0 deletions
@@ -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 };
}
}