This commit is contained in:
@@ -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, '/')}`;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user