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,109 @@
import { Injectable, Logger } from '@nestjs/common';
import { GeminiService } from '../gemini/gemini.service';
import { PredictionCardDto } from './dto/prediction-card.dto';
const SYSTEM_PROMPT = `Sen profesyonel bir spor analisti ve sosyal medya içerik üreticisisin.
Verilen maç tahmin verisini kullanarak kısa, etkili ve ilgi çekici sosyal medya postları yazıyorsun.
KURALLAR:
- Türkçe yaz
- Maximum 250 karakter (X/Twitter uyumlu)
- Emoji kullan ama abartma (2-4 emoji yeterli)
- Skor tahminini vurgula
- Güven yüzdesini belirt
- İlgili hashtag'leri ekle (#PremierLeague, #SüperLig vb.)
- KESİNLİKLE "kesin kazanır", "garanti" gibi ifadeler KULLANMA
- "Tahminimiz", "Beklentimiz", "Analizimiz" gibi ifadeler kullan
- Farklı maçlar için farklı tarzda yaz, tekdüze olma
- Son satıra her zaman hashtag'leri koy`;
@Injectable()
export class CaptionGeneratorService {
private readonly logger = new Logger(CaptionGeneratorService.name);
constructor(private readonly geminiService: GeminiService) {}
/**
* Generate a social media caption for a match prediction using Gemini AI.
*/
async generateCaption(card: PredictionCardDto): Promise<string> {
if (!this.geminiService.isAvailable()) {
this.logger.warn('Gemini not available, using template caption');
return this.generateFallbackCaption(card);
}
const prompt = this.buildPrompt(card);
try {
const { text } = await this.geminiService.generateText(prompt, {
systemPrompt: SYSTEM_PROMPT,
temperature: 0.8,
maxTokens: 300,
});
// Ensure hashtags are present
const caption = this.ensureHashtags(text, card);
this.logger.log(
`Caption generated for ${card.homeTeam} vs ${card.awayTeam}`,
);
return caption;
} catch (error) {
this.logger.error('Gemini caption generation failed', error);
return this.generateFallbackCaption(card);
}
}
private buildPrompt(card: PredictionCardDto): string {
const topPicksText = card.topPicks
.map(
(p, i) =>
`${i + 1}. ${p.market} (${p.marketEn}) — ${p.pick} — Güven: %${p.confidence} — Oran: ${p.odds}`,
)
.join('\n');
return `Aşağıdaki maç tahmin verisini kullanarak bir sosyal medya postu oluştur:
MAÇ: ${card.homeTeam} vs ${card.awayTeam}
LİG: ${card.leagueName}
TARİH: ${card.matchDate}
İLK YARI SKOR TAHMİNİ: ${card.htScore}
MAÇ SONU SKOR TAHMİNİ: ${card.ftScore}
SKOR GÜVEN: %${card.scoreConfidence}
RİSK SEVİYESİ: ${card.riskLevel}
EN İYİ TAHMİNLER:
${topPicksText}
Sadece post metnini yaz, başka hiçbir şey ekleme.`;
}
private ensureHashtags(text: string, card: PredictionCardDto): string {
// If no hashtags in text, add them
if (!text.includes('#')) {
const leagueTag = card.leagueName
.replace(/\s+/g, '')
.replace(/[^a-zA-Z0-9üöçşğıİÜÖÇŞĞ]/g, '');
const homeTag = card.homeTeam.replace(/\s+/g, '');
const awayTag = card.awayTeam.replace(/\s+/g, '');
text += `\n\n#${leagueTag} #${homeTag} #${awayTag}`;
}
return text.trim();
}
/**
* Fallback caption when Gemini is not available.
*/
private generateFallbackCaption(card: PredictionCardDto): string {
const topPick = card.topPicks[0];
const leagueTag = card.leagueName
.replace(/\s+/g, '')
.replace(/[^a-zA-Z0-9üöçşğıİÜÖÇŞĞ]/g, '');
return `${card.homeTeam} vs ${card.awayTeam}
🎯 Tahminimiz: ${card.ftScore} (İY: ${card.htScore})
📊 Güven: %${card.scoreConfidence}
${topPick ? `🔥 ${topPick.market}: ${topPick.pick} (%${topPick.confidence})` : ''}
#${leagueTag} #SuggestBet #Bahis`.trim();
}
}
@@ -0,0 +1,60 @@
/**
* Prediction Card DTO
*
* Typed data structure for rendering match prediction cards
* and generating social media captions.
*/
export interface TopPick {
/** Market name in Turkish, e.g. "Üst 2.5 Gol" */
market: string;
/** Market name in English, e.g. "Over 2.5" */
marketEn: string;
/** Pick label, e.g. "Üst" */
pick: string;
/** Confidence 0-100 */
confidence: number;
/** Odds value */
odds: number;
}
export interface PredictionCardDto {
// ─── Match Info ───
matchId: string;
homeTeam: string;
awayTeam: string;
homeLogo: string;
awayLogo: string;
leagueName: string;
leagueLogo?: string;
/** Formatted date, e.g. "01 Mar 2026 - 21:00" */
matchDate: string;
// ─── Score Predictions ───
/** HT score, e.g. "1-0" */
htScore: string;
/** FT score, e.g. "2-1" */
ftScore: string;
/** Overall confidence 0-100 */
scoreConfidence: number;
// ─── Top 3 Best Bets ───
topPicks: TopPick[];
// ─── Risk ───
riskLevel: 'LOW' | 'MEDIUM' | 'HIGH' | 'EXTREME';
// ─── Raw prediction JSON (for Gemini caption) ───
rawPrediction?: Record<string, any>;
}
export interface SocialPostResult {
matchId: string;
imagePath: string;
caption: string;
twitterPostId?: string;
facebookPostId?: string;
instagramPostId?: string;
postedAt: Date;
errors?: string[];
}
@@ -0,0 +1,462 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import * as fs from 'fs';
import * as path from 'path';
import axios from 'axios';
import { createCanvas, loadImage } from 'canvas';
import { PredictionCardDto } from './dto/prediction-card.dto';
@Injectable()
export class ImageRendererService implements OnModuleInit {
private readonly logger = new Logger(ImageRendererService.name);
private readonly outputDir = path.join(
process.cwd(),
'public',
'predictions',
);
onModuleInit() {
// Ensure output directory exists
if (!fs.existsSync(this.outputDir)) {
fs.mkdirSync(this.outputDir, { recursive: true });
}
}
/**
* Render a prediction card to a PNG image using Canvas API.
* Returns the file path of the generated image.
*/
async renderCard(card: PredictionCardDto): Promise<string> {
const fileName = `prediction_${card.matchId}_${Date.now()}.png`;
const filePath = path.join(this.outputDir, fileName);
try {
this.logger.log(
`🎨 Rendering canvas for ${card.homeTeam} vs ${card.awayTeam}...`,
);
await this.drawCanvas(card, filePath);
this.logger.log(`✅ Card rendered to ${fileName}`);
return filePath;
} catch (error) {
this.logger.error(`Failed to render canvas card: ${error.message}`);
throw error;
}
}
/**
* Load a team logo image. Handles:
* 1. Local file path (e.g., /uploads/teams/xxx.png → public/uploads/teams/xxx.png)
* 2. Full HTTP URL (e.g., https://cdn.example.com/logo.png)
* 3. Mackolik CDN fallback using team slug from path
*/
private async downloadImage(url: string) {
if (!url) return null;
try {
// Case 1: Local relative path → read from public/ directory
if (url.startsWith('/')) {
const localPath = path.join(process.cwd(), 'public', url);
if (fs.existsSync(localPath)) {
this.logger.debug(`Loading logo from local file: ${localPath}`);
return await loadImage(localPath);
}
// Local file not found → try as full URL via APP_BASE_URL
this.logger.debug(
`Local file not found: ${localPath}, trying remote...`,
);
}
// Case 2: Full HTTP/HTTPS URL → fetch directly
if (url.startsWith('http')) {
const response = await axios.get(url, {
responseType: 'arraybuffer',
timeout: 5000,
});
return await loadImage(response.data);
}
this.logger.warn(`Could not resolve logo path: ${url}`);
return null;
} catch (error) {
this.logger.warn(`Could not load image from ${url}: ${error.message}`);
return null;
}
}
private fillRoundRect(
ctx: any,
x: number,
y: number,
width: number,
height: number,
radius: number,
) {
ctx.beginPath();
ctx.moveTo(x + radius, y);
ctx.lineTo(x + width - radius, y);
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
ctx.lineTo(x + width, y + height - radius);
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
ctx.lineTo(x + radius, y + height);
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
ctx.lineTo(x, y + radius);
ctx.quadraticCurveTo(x, y, x + radius, y);
ctx.closePath();
ctx.fill();
}
private strokeRoundRect(
ctx: any,
x: number,
y: number,
width: number,
height: number,
radius: number,
) {
ctx.beginPath();
ctx.moveTo(x + radius, y);
ctx.lineTo(x + width - radius, y);
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
ctx.lineTo(x + width, y + height - radius);
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
ctx.lineTo(x + radius, y + height);
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
ctx.lineTo(x, y + radius);
ctx.quadraticCurveTo(x, y, x + radius, y);
ctx.closePath();
ctx.stroke();
}
private async drawCanvas(
data: PredictionCardDto,
outPath: string,
): Promise<void> {
const width = 1080;
const height = 1920;
const canvas = createCanvas(width, height);
const ctx = canvas.getContext('2d');
// Background Gradient
const bgGrad = ctx.createLinearGradient(0, 0, width, height);
bgGrad.addColorStop(0, '#0a0e27');
bgGrad.addColorStop(0.35, '#1a1040');
bgGrad.addColorStop(0.7, '#0d1b2a');
bgGrad.addColorStop(1, '#0a0e27');
ctx.fillStyle = bgGrad;
ctx.fillRect(0, 0, width, height);
// Watermark
ctx.save();
ctx.translate(width / 2, height / 2);
ctx.rotate((-35 * Math.PI) / 180);
ctx.fillStyle = 'rgba(255, 255, 255, 0.05)';
ctx.font = '900 100px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const wmLine =
'iddaai.com iddaai.com iddaai.com iddaai.com iddaai.com iddaai.com iddaai.com iddaai.com';
for (let i = -15; i <= 15; i++) {
ctx.fillText(wmLine, 0, i * 180);
}
ctx.restore();
// Settings
const paddingX = 80;
// Header
ctx.fillStyle = 'rgba(255, 255, 255, 0.7)';
ctx.font = '600 28px sans-serif';
ctx.textAlign = 'left';
ctx.fillText(data.leagueName.toUpperCase(), paddingX, 120);
ctx.fillStyle = 'rgba(255, 255, 255, 0.45)';
ctx.font = '400 22px sans-serif';
ctx.textAlign = 'right';
ctx.fillText(data.matchDate, width - paddingX, 120);
// Teams Section
let currentY = 280;
const [homeImg, awayImg] = await Promise.all([
this.downloadImage(data.homeLogo),
this.downloadImage(data.awayLogo),
]);
if (homeImg) ctx.drawImage(homeImg, width / 4 - 100, currentY, 200, 200);
if (awayImg)
ctx.drawImage(awayImg, (width / 4) * 3 - 100, currentY, 200, 200);
ctx.fillStyle = 'rgba(255, 255, 255, 0.15)';
ctx.font = '900 56px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('VS', width / 2, currentY + 110);
currentY += 250;
ctx.fillStyle = '#ffffff';
ctx.font = '700 36px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(data.homeTeam, width / 4, currentY);
ctx.fillText(data.awayTeam, (width / 4) * 3, currentY);
// Divider: Skore Prediction
currentY += 140;
const drawSectionTitle = (y: number, text: string) => {
ctx.textAlign = 'center';
ctx.fillStyle = 'rgba(255, 255, 255, 0.5)';
ctx.font = '600 22px sans-serif';
ctx.fillText(text, width / 2, y + 8);
const txtWidth = ctx.measureText(text).width;
const grad = ctx.createLinearGradient(paddingX, y, width - paddingX, y);
grad.addColorStop(0, 'rgba(120, 80, 255, 0)');
grad.addColorStop(0.5, 'rgba(120, 80, 255, 0.6)');
grad.addColorStop(1, 'rgba(120, 80, 255, 0)');
ctx.fillStyle = grad;
ctx.fillRect(
paddingX,
y - 2,
(width - 2 * paddingX - txtWidth - 40) / 2,
3,
);
ctx.fillRect(
width / 2 + txtWidth / 2 + 20,
y - 2,
(width - 2 * paddingX - txtWidth - 40) / 2,
3,
);
};
drawSectionTitle(currentY, 'SKOR TAHMİNİ / SCORE PREDICTION');
// Scores
currentY += 80;
const scoreBoxWidth = 380;
const scoreBoxHeight = 220;
const htX = width / 2 - scoreBoxWidth - 24;
const ftX = width / 2 + 24;
// HT Box
ctx.fillStyle = 'rgba(255, 255, 255, 0.04)';
ctx.strokeStyle = 'rgba(255, 255, 255, 0.08)';
ctx.lineWidth = 2;
this.fillRoundRect(ctx, htX, currentY, scoreBoxWidth, scoreBoxHeight, 20);
this.strokeRoundRect(ctx, htX, currentY, scoreBoxWidth, scoreBoxHeight, 20);
ctx.fillStyle = 'rgba(255, 255, 255, 0.45)';
ctx.font = '600 20px sans-serif';
ctx.fillText('İLK YARI', htX + scoreBoxWidth / 2, currentY + 40);
ctx.fillStyle = 'rgba(255, 255, 255, 0.25)';
ctx.font = '400 16px sans-serif';
ctx.fillText('Half Time', htX + scoreBoxWidth / 2, currentY + 65);
ctx.fillStyle = '#ffffff';
ctx.font = '900 80px sans-serif';
ctx.fillText(data.htScore, htX + scoreBoxWidth / 2, currentY + 160);
// FT Box
const ftGrad = ctx.createLinearGradient(
ftX,
currentY,
ftX + scoreBoxWidth,
currentY + scoreBoxHeight,
);
ftGrad.addColorStop(0, 'rgba(120, 80, 255, 0.15)');
ftGrad.addColorStop(1, 'rgba(0, 200, 255, 0.1)');
ctx.fillStyle = ftGrad;
ctx.strokeStyle = 'rgba(120, 80, 255, 0.3)';
this.fillRoundRect(ctx, ftX, currentY, scoreBoxWidth, scoreBoxHeight, 20);
this.strokeRoundRect(ctx, ftX, currentY, scoreBoxWidth, scoreBoxHeight, 20);
ctx.fillStyle = 'rgba(255, 255, 255, 0.45)';
ctx.font = '600 20px sans-serif';
ctx.fillText('MAÇ SONU', ftX + scoreBoxWidth / 2, currentY + 40);
ctx.fillStyle = 'rgba(255, 255, 255, 0.25)';
ctx.font = '400 16px sans-serif';
ctx.fillText('Full Time', ftX + scoreBoxWidth / 2, currentY + 65);
// Score text gradient
const txtGrad = ctx.createLinearGradient(
ftX,
currentY + 100,
ftX,
currentY + 160,
);
txtGrad.addColorStop(0, '#9b6fff');
txtGrad.addColorStop(1, '#00c8ff');
ctx.fillStyle = txtGrad;
ctx.font = '900 80px sans-serif';
ctx.fillText(data.ftScore, ftX + scoreBoxWidth / 2, currentY + 160);
// Confidence badge
ctx.fillStyle = '#0a0e27';
ctx.strokeStyle = 'rgba(120, 80, 255, 0.6)';
this.fillRoundRect(
ctx,
ftX + scoreBoxWidth / 2 - 80,
currentY + scoreBoxHeight - 20,
160,
40,
20,
);
this.strokeRoundRect(
ctx,
ftX + scoreBoxWidth / 2 - 80,
currentY + scoreBoxHeight - 20,
160,
40,
20,
);
ctx.fillStyle = '#b89dff';
ctx.font = '800 20px sans-serif';
ctx.fillText(
`🎯 %${data.scoreConfidence}`,
ftX + scoreBoxWidth / 2,
currentY + scoreBoxHeight + 7,
);
// Divider: Picks
currentY += scoreBoxHeight + 100;
drawSectionTitle(currentY, 'EN İYİ TAHMİNLER / BEST PICKS');
// Picks rendering
currentY += 80;
data.topPicks.forEach((pick, index) => {
ctx.fillStyle = 'rgba(255, 255, 255, 0.03)';
ctx.strokeStyle = 'rgba(255, 255, 255, 0.06)';
this.fillRoundRect(
ctx,
paddingX,
currentY,
width - 2 * paddingX,
100,
16,
);
this.strokeRoundRect(
ctx,
paddingX,
currentY,
width - 2 * paddingX,
100,
16,
);
ctx.fillStyle = 'rgba(255, 255, 255, 0.3)';
ctx.font = '700 28px sans-serif';
ctx.textAlign = 'left';
ctx.fillText(String(index + 1), paddingX + 30, currentY + 58);
ctx.fillStyle = '#ffffff';
ctx.font = '600 26px sans-serif';
ctx.fillText(pick.market, paddingX + 80, currentY + 45);
const marketWidth = ctx.measureText(pick.market).width;
ctx.fillStyle = 'rgba(255, 255, 255, 0.35)';
ctx.font = '400 18px sans-serif';
ctx.fillText(
`(${pick.marketEn})`,
paddingX + 80 + marketWidth + 10,
currentY + 43,
);
// Pick Bar bg
ctx.fillStyle = 'rgba(255, 255, 255, 0.06)';
const barMaxWidth = width - 2 * paddingX - 220;
this.fillRoundRect(ctx, paddingX + 80, currentY + 65, barMaxWidth, 12, 6);
// Pick Bar fill
const fillWidth = (pick.confidence / 100) * barMaxWidth;
const barGrad = ctx.createLinearGradient(
paddingX + 80,
0,
paddingX + 80 + barMaxWidth,
0,
);
barGrad.addColorStop(0, '#7850ff');
barGrad.addColorStop(1, '#00c8ff');
ctx.fillStyle = barGrad;
this.fillRoundRect(ctx, paddingX + 80, currentY + 65, fillWidth, 12, 6);
// Confidence text
ctx.fillStyle = '#b89dff';
ctx.font = '900 32px sans-serif';
ctx.textAlign = 'right';
ctx.fillText(`%${pick.confidence}`, width - paddingX - 30, currentY + 58);
currentY += 124;
});
// Footer
currentY = height - 80;
ctx.fillStyle = 'rgba(255, 255, 255, 0.4)';
ctx.font = '700 26px sans-serif';
ctx.textAlign = 'left';
ctx.fillText('⚡ AI Powered by SuggestBet', paddingX, currentY);
let riskBg, riskColor, riskBorder;
switch (data.riskLevel) {
case 'LOW':
riskBg = 'rgba(0, 200, 100, 0.15)';
riskColor = '#4ade80';
riskBorder = 'rgba(0, 200, 100, 0.3)';
break;
case 'MEDIUM':
riskBg = 'rgba(255, 200, 0, 0.12)';
riskColor = '#fbbf24';
riskBorder = 'rgba(255, 200, 0, 0.25)';
break;
case 'HIGH':
riskBg = 'rgba(255, 100, 50, 0.12)';
riskColor = '#f97316';
riskBorder = 'rgba(255, 100, 50, 0.25)';
break;
case 'EXTREME':
riskBg = 'rgba(255, 50, 50, 0.15)';
riskColor = '#ef4444';
riskBorder = 'rgba(255, 50, 50, 0.3)';
break;
default:
riskBg = 'rgba(255, 255, 255, 0.1)';
riskColor = '#ffffff';
riskBorder = 'rgba(255, 255, 255, 0.3)';
}
const riskText = `RISK: ${data.riskLevel}`;
ctx.font = '800 20px sans-serif';
const riskWidth = ctx.measureText(riskText).width;
ctx.fillStyle = riskBg;
ctx.strokeStyle = riskBorder;
this.fillRoundRect(
ctx,
width - paddingX - riskWidth - 48,
currentY - 26,
riskWidth + 48,
44,
22,
);
this.strokeRoundRect(
ctx,
width - paddingX - riskWidth - 48,
currentY - 26,
riskWidth + 48,
44,
22,
);
ctx.fillStyle = riskColor;
ctx.textAlign = 'center';
ctx.fillText(riskText, width - paddingX - riskWidth / 2 - 24, currentY + 3);
// Save Output directly using the buffer
const buffer = canvas.toBuffer('image/png');
fs.writeFileSync(outPath, buffer);
}
/**
* Get the web-accessible URL for a rendered image.
*/
getImageUrl(filePath: string): string {
const relativePath = path.relative(
path.join(process.cwd(), 'public'),
filePath,
);
return `/${relativePath.replace(/\\/g, '/')}`;
}
}
+180
View File
@@ -0,0 +1,180 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios from 'axios';
@Injectable()
export class MetaService {
private readonly logger = new Logger(MetaService.name);
private readonly pageAccessToken: string;
private readonly pageId: string;
private readonly igUserId: string;
private readonly isEnabled: boolean;
private readonly graphApiBase = 'https://graph.facebook.com/v21.0';
constructor(private readonly configService: ConfigService) {
this.pageAccessToken =
this.configService.get<string>('META_PAGE_ACCESS_TOKEN') || '';
this.pageId = this.configService.get<string>('META_PAGE_ID') || '';
this.igUserId = this.configService.get<string>('META_IG_USER_ID') || '';
this.isEnabled = !!(this.pageAccessToken && this.pageId);
if (this.isEnabled) {
this.logger.log('✅ Meta API client initialized');
} else {
this.logger.warn(
'⚠️ Meta API not configured. Set META_PAGE_ACCESS_TOKEN, META_PAGE_ID, META_IG_USER_ID',
);
}
}
get facebookAvailable(): boolean {
return this.isEnabled;
}
get instagramAvailable(): boolean {
return this.isEnabled && !!this.igUserId;
}
// ═══════════════════════════════════════════════════════════════════════════
// FACEBOOK
// ═══════════════════════════════════════════════════════════════════════════
/**
* Post a photo to a Facebook Page.
*
* @param message - Post caption
* @param imageUrl - Publicly accessible image URL
* @returns Facebook post ID
*/
async postToFacebook(
message: string,
imageUrl: string,
): Promise<string | null> {
if (!this.facebookAvailable) {
this.logger.warn('Facebook not available, skipping post');
return null;
}
try {
const response = await axios.post(
`${this.graphApiBase}/${this.pageId}/photos`,
{
url: imageUrl,
message,
access_token: this.pageAccessToken,
},
);
const postId = response.data?.id;
this.logger.log(`✅ Facebook post published: ${postId}`);
return postId || null;
} catch (error) {
this.logger.error(
`❌ Facebook post failed: ${error.response?.data?.error?.message || error.message}`,
);
return null;
}
}
// ═══════════════════════════════════════════════════════════════════════════
// INSTAGRAM
// ═══════════════════════════════════════════════════════════════════════════
/**
* Post a photo to Instagram Business/Creator account.
*
* Two-step process:
* 1. Create media container with image_url
* 2. Publish the container
*
* @param caption - Post caption (max 2200 chars)
* @param imageUrl - Publicly accessible JPEG image URL
* @returns Instagram media ID
*/
async postToInstagram(
caption: string,
imageUrl: string,
): Promise<string | null> {
if (!this.instagramAvailable) {
this.logger.warn('Instagram not available, skipping post');
return null;
}
try {
// Step 1: Create media container
const containerResponse = await axios.post(
`${this.graphApiBase}/${this.igUserId}/media`,
{
image_url: imageUrl,
caption,
access_token: this.pageAccessToken,
},
);
const containerId = containerResponse.data?.id;
if (!containerId) {
throw new Error('No container ID returned');
}
// Wait for container processing (IG needs a few seconds)
await this.waitForContainerReady(containerId);
// Step 2: Publish
const publishResponse = await axios.post(
`${this.graphApiBase}/${this.igUserId}/media_publish`,
{
creation_id: containerId,
access_token: this.pageAccessToken,
},
);
const mediaId = publishResponse.data?.id;
this.logger.log(`✅ Instagram post published: ${mediaId}`);
return mediaId || null;
} catch (error) {
this.logger.error(
`❌ Instagram post failed: ${error.response?.data?.error?.message || error.message}`,
);
return null;
}
}
/**
* Wait for Instagram container to be ready for publishing.
*/
private async waitForContainerReady(
containerId: string,
maxWaitMs = 30000,
): Promise<void> {
const startTime = Date.now();
while (Date.now() - startTime < maxWaitMs) {
try {
const response = await axios.get(
`${this.graphApiBase}/${containerId}`,
{
params: {
fields: 'status_code',
access_token: this.pageAccessToken,
},
},
);
const status = response.data?.status_code;
if (status === 'FINISHED') return;
if (status === 'ERROR') {
throw new Error('Container processing failed');
}
} catch (error) {
if (error.message === 'Container processing failed') throw error;
}
// Wait 2 seconds before checking again
await new Promise((resolve) => setTimeout(resolve, 2000));
}
this.logger.warn('Container wait timed out, attempting publish anyway');
}
}
@@ -0,0 +1,25 @@
import { Controller, Post, Param, Get, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { SocialPosterService } from './social-poster.service';
import { Roles } from '../../common/decorators';
import { RolesGuard } from '../auth/guards/auth.guards';
@ApiTags('Social Poster')
@ApiBearerAuth()
@UseGuards(RolesGuard)
@Roles('admin')
@Controller('social-poster')
export class SocialPosterController {
constructor(private readonly socialPosterService: SocialPosterService) {}
@Get('preview/:matchId')
async previewCard(@Param('matchId') matchId: string) {
return this.socialPosterService.renderPreview(matchId);
}
@Post('post/:matchId')
async postMatch(@Param('matchId') matchId: string) {
return this.socialPosterService.manualPost(matchId);
}
}
@@ -0,0 +1,31 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
import { SocialPosterService } from './social-poster.service';
import { ImageRendererService } from './image-renderer.service';
import { CaptionGeneratorService } from './caption-generator.service';
import { TwitterService } from './twitter.service';
import { MetaService } from './meta.service';
import { SocialPosterController } from './social-poster.controller';
/**
* Social Poster Module
*
* Automates the generation of prediction cards and social media posting
* to X (Twitter), Facebook, and Instagram for upcoming matches.
*/
@Module({
imports: [ConfigModule, ScheduleModule.forRoot()],
controllers: [SocialPosterController],
providers: [
SocialPosterService,
ImageRendererService,
CaptionGeneratorService,
TwitterService,
MetaService,
],
exports: [SocialPosterService],
})
export class SocialPosterModule {}
@@ -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 };
}
}
@@ -0,0 +1,87 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as fs from 'fs';
@Injectable()
export class TwitterService {
private readonly logger = new Logger(TwitterService.name);
private client: any = null;
private isEnabled = false;
constructor(private readonly configService: ConfigService) {
const apiKey = this.configService.get<string>('TWITTER_API_KEY');
const apiSecret = this.configService.get<string>('TWITTER_API_SECRET');
const accessToken = this.configService.get<string>('TWITTER_ACCESS_TOKEN');
const accessSecret = this.configService.get<string>(
'TWITTER_ACCESS_SECRET',
);
if (apiKey && apiSecret && accessToken && accessSecret) {
void this.initClient(apiKey, apiSecret, accessToken, accessSecret);
} else {
this.logger.warn(
'⚠️ Twitter API keys not configured. Set TWITTER_API_KEY, TWITTER_API_SECRET, TWITTER_ACCESS_TOKEN, TWITTER_ACCESS_SECRET',
);
}
}
private async initClient(
apiKey: string,
apiSecret: string,
accessToken: string,
accessSecret: string,
) {
try {
const { TwitterApi } = await import('twitter-api-v2');
this.client = new TwitterApi({
appKey: apiKey,
appSecret: apiSecret,
accessToken,
accessSecret,
});
this.isEnabled = true;
this.logger.log('✅ Twitter API client initialized');
} catch (error) {
this.logger.error('Failed to initialize Twitter client', error);
}
}
get available(): boolean {
return this.isEnabled && this.client !== null;
}
/**
* Post a tweet with an image.
*
* @param text - Tweet text
* @param imagePath - Absolute path to the image file
* @returns Tweet ID
*/
async postWithImage(text: string, imagePath: string): Promise<string | null> {
if (!this.available) {
this.logger.warn('Twitter not available, skipping post');
return null;
}
try {
// Step 1: Upload media via v1.1
const mediaData = fs.readFileSync(imagePath);
const mediaId = await this.client.v1.uploadMedia(mediaData, {
mimeType: 'image/png',
});
// Step 2: Create tweet via v2
const tweet = await this.client.v2.tweet({
text,
media: { media_ids: [mediaId] },
});
const tweetId = tweet.data?.id;
this.logger.log(`✅ Tweet posted: ${tweetId}`);
return tweetId || null;
} catch (error) {
this.logger.error(`❌ Twitter post failed: ${error.message}`);
return null;
}
}
}