cr
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { GeminiService } from '../gemini/gemini.service';
|
||||
import { PredictionCardDto } from './dto/prediction-card.dto';
|
||||
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.
|
||||
@@ -28,7 +28,7 @@ export class CaptionGeneratorService {
|
||||
*/
|
||||
async generateCaption(card: PredictionCardDto): Promise<string> {
|
||||
if (!this.geminiService.isAvailable()) {
|
||||
this.logger.warn('Gemini not available, using template caption');
|
||||
this.logger.warn("Gemini not available, using template caption");
|
||||
return this.generateFallbackCaption(card);
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ export class CaptionGeneratorService {
|
||||
);
|
||||
return caption;
|
||||
} catch (error) {
|
||||
this.logger.error('Gemini caption generation failed', error);
|
||||
this.logger.error("Gemini caption generation failed", error);
|
||||
return this.generateFallbackCaption(card);
|
||||
}
|
||||
}
|
||||
@@ -59,7 +59,7 @@ export class CaptionGeneratorService {
|
||||
(p, i) =>
|
||||
`${i + 1}. ${p.market} (${p.marketEn}) — ${p.pick} — Güven: %${p.confidence} — Oran: ${p.odds}`,
|
||||
)
|
||||
.join('\n');
|
||||
.join("\n");
|
||||
|
||||
return `Aşağıdaki maç tahmin verisini kullanarak bir sosyal medya postu oluştur:
|
||||
|
||||
@@ -79,12 +79,12 @@ 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('#')) {
|
||||
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, '');
|
||||
.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();
|
||||
@@ -96,13 +96,13 @@ Sadece post metnini yaz, başka hiçbir şey ekleme.`;
|
||||
private generateFallbackCaption(card: PredictionCardDto): string {
|
||||
const topPick = card.topPicks[0];
|
||||
const leagueTag = card.leagueName
|
||||
.replace(/\s+/g, '')
|
||||
.replace(/[^a-zA-Z0-9üöçşğıİÜÖÇŞĞ]/g, '');
|
||||
.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})` : ''}
|
||||
${topPick ? `🔥 ${topPick.market}: ${topPick.pick} (%${topPick.confidence})` : ""}
|
||||
|
||||
#${leagueTag} #SuggestBet #Bahis`.trim();
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ export interface PredictionCardDto {
|
||||
topPicks: TopPick[];
|
||||
|
||||
// ─── Risk ───
|
||||
riskLevel: 'LOW' | 'MEDIUM' | 'HIGH' | 'EXTREME';
|
||||
riskLevel: "LOW" | "MEDIUM" | "HIGH" | "EXTREME";
|
||||
|
||||
// ─── Raw prediction JSON (for Gemini caption) ───
|
||||
rawPrediction?: Record<string, any>;
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
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';
|
||||
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',
|
||||
"public",
|
||||
"predictions",
|
||||
);
|
||||
|
||||
onModuleInit() {
|
||||
@@ -53,8 +53,8 @@ export class ImageRendererService implements OnModuleInit {
|
||||
|
||||
try {
|
||||
// Case 1: Local relative path → read from public/ directory
|
||||
if (url.startsWith('/')) {
|
||||
const localPath = path.join(process.cwd(), 'public', url);
|
||||
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);
|
||||
@@ -66,9 +66,9 @@ export class ImageRendererService implements OnModuleInit {
|
||||
}
|
||||
|
||||
// Case 2: Full HTTP/HTTPS URL → fetch directly
|
||||
if (url.startsWith('http')) {
|
||||
if (url.startsWith("http")) {
|
||||
const response = await axios.get(url, {
|
||||
responseType: 'arraybuffer',
|
||||
responseType: "arraybuffer",
|
||||
timeout: 5000,
|
||||
});
|
||||
return await loadImage(response.data);
|
||||
@@ -133,14 +133,14 @@ export class ImageRendererService implements OnModuleInit {
|
||||
const width = 1080;
|
||||
const height = 1920;
|
||||
const canvas = createCanvas(width, height);
|
||||
const ctx = canvas.getContext('2d');
|
||||
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');
|
||||
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);
|
||||
|
||||
@@ -148,12 +148,12 @@ export class ImageRendererService implements OnModuleInit {
|
||||
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';
|
||||
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';
|
||||
"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);
|
||||
}
|
||||
@@ -163,14 +163,14 @@ export class ImageRendererService implements OnModuleInit {
|
||||
const paddingX = 80;
|
||||
|
||||
// Header
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.7)';
|
||||
ctx.font = '600 28px sans-serif';
|
||||
ctx.textAlign = 'left';
|
||||
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.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
|
||||
@@ -184,31 +184,31 @@ export class ImageRendererService implements OnModuleInit {
|
||||
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);
|
||||
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.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.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)');
|
||||
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(
|
||||
@@ -225,7 +225,7 @@ export class ImageRendererService implements OnModuleInit {
|
||||
);
|
||||
};
|
||||
|
||||
drawSectionTitle(currentY, 'SKOR TAHMİNİ / SCORE PREDICTION');
|
||||
drawSectionTitle(currentY, "SKOR TAHMİNİ / SCORE PREDICTION");
|
||||
|
||||
// Scores
|
||||
currentY += 80;
|
||||
@@ -235,20 +235,20 @@ export class ImageRendererService implements OnModuleInit {
|
||||
const ftX = width / 2 + 24;
|
||||
|
||||
// HT Box
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.04)';
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.08)';
|
||||
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.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
|
||||
@@ -258,19 +258,19 @@ export class ImageRendererService implements OnModuleInit {
|
||||
ftX + scoreBoxWidth,
|
||||
currentY + scoreBoxHeight,
|
||||
);
|
||||
ftGrad.addColorStop(0, 'rgba(120, 80, 255, 0.15)');
|
||||
ftGrad.addColorStop(1, 'rgba(0, 200, 255, 0.1)');
|
||||
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)';
|
||||
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);
|
||||
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(
|
||||
@@ -279,15 +279,15 @@ export class ImageRendererService implements OnModuleInit {
|
||||
ftX,
|
||||
currentY + 160,
|
||||
);
|
||||
txtGrad.addColorStop(0, '#9b6fff');
|
||||
txtGrad.addColorStop(1, '#00c8ff');
|
||||
txtGrad.addColorStop(0, "#9b6fff");
|
||||
txtGrad.addColorStop(1, "#00c8ff");
|
||||
ctx.fillStyle = txtGrad;
|
||||
ctx.font = '900 80px sans-serif';
|
||||
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)';
|
||||
ctx.fillStyle = "#0a0e27";
|
||||
ctx.strokeStyle = "rgba(120, 80, 255, 0.6)";
|
||||
this.fillRoundRect(
|
||||
ctx,
|
||||
ftX + scoreBoxWidth / 2 - 80,
|
||||
@@ -304,8 +304,8 @@ export class ImageRendererService implements OnModuleInit {
|
||||
40,
|
||||
20,
|
||||
);
|
||||
ctx.fillStyle = '#b89dff';
|
||||
ctx.font = '800 20px sans-serif';
|
||||
ctx.fillStyle = "#b89dff";
|
||||
ctx.font = "800 20px sans-serif";
|
||||
ctx.fillText(
|
||||
`🎯 %${data.scoreConfidence}`,
|
||||
ftX + scoreBoxWidth / 2,
|
||||
@@ -314,13 +314,13 @@ export class ImageRendererService implements OnModuleInit {
|
||||
|
||||
// Divider: Picks
|
||||
currentY += scoreBoxHeight + 100;
|
||||
drawSectionTitle(currentY, 'EN İYİ TAHMİNLER / BEST PICKS');
|
||||
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)';
|
||||
ctx.fillStyle = "rgba(255, 255, 255, 0.03)";
|
||||
ctx.strokeStyle = "rgba(255, 255, 255, 0.06)";
|
||||
this.fillRoundRect(
|
||||
ctx,
|
||||
paddingX,
|
||||
@@ -338,18 +338,18 @@ export class ImageRendererService implements OnModuleInit {
|
||||
16,
|
||||
);
|
||||
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.3)';
|
||||
ctx.font = '700 28px sans-serif';
|
||||
ctx.textAlign = 'left';
|
||||
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.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.fillStyle = "rgba(255, 255, 255, 0.35)";
|
||||
ctx.font = "400 18px sans-serif";
|
||||
ctx.fillText(
|
||||
`(${pick.marketEn})`,
|
||||
paddingX + 80 + marketWidth + 10,
|
||||
@@ -357,7 +357,7 @@ export class ImageRendererService implements OnModuleInit {
|
||||
);
|
||||
|
||||
// Pick Bar bg
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.06)';
|
||||
ctx.fillStyle = "rgba(255, 255, 255, 0.06)";
|
||||
const barMaxWidth = width - 2 * paddingX - 220;
|
||||
this.fillRoundRect(ctx, paddingX + 80, currentY + 65, barMaxWidth, 12, 6);
|
||||
|
||||
@@ -369,15 +369,15 @@ export class ImageRendererService implements OnModuleInit {
|
||||
paddingX + 80 + barMaxWidth,
|
||||
0,
|
||||
);
|
||||
barGrad.addColorStop(0, '#7850ff');
|
||||
barGrad.addColorStop(1, '#00c8ff');
|
||||
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.fillStyle = "#b89dff";
|
||||
ctx.font = "900 32px sans-serif";
|
||||
ctx.textAlign = "right";
|
||||
ctx.fillText(`%${pick.confidence}`, width - paddingX - 30, currentY + 58);
|
||||
|
||||
currentY += 124;
|
||||
@@ -385,41 +385,41 @@ export class ImageRendererService implements OnModuleInit {
|
||||
|
||||
// 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);
|
||||
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)';
|
||||
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)';
|
||||
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)';
|
||||
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)';
|
||||
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)';
|
||||
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';
|
||||
ctx.font = "800 20px sans-serif";
|
||||
const riskWidth = ctx.measureText(riskText).width;
|
||||
ctx.fillStyle = riskBg;
|
||||
ctx.strokeStyle = riskBorder;
|
||||
@@ -441,11 +441,11 @@ export class ImageRendererService implements OnModuleInit {
|
||||
);
|
||||
|
||||
ctx.fillStyle = riskColor;
|
||||
ctx.textAlign = 'center';
|
||||
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');
|
||||
const buffer = canvas.toBuffer("image/png");
|
||||
fs.writeFileSync(outPath, buffer);
|
||||
}
|
||||
|
||||
@@ -454,9 +454,9 @@ export class ImageRendererService implements OnModuleInit {
|
||||
*/
|
||||
getImageUrl(filePath: string): string {
|
||||
const relativePath = path.relative(
|
||||
path.join(process.cwd(), 'public'),
|
||||
path.join(process.cwd(), "public"),
|
||||
filePath,
|
||||
);
|
||||
return `/${relativePath.replace(/\\/g, '/')}`;
|
||||
return `/${relativePath.replace(/\\/g, "/")}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import axios from 'axios';
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import axios from "axios";
|
||||
|
||||
@Injectable()
|
||||
export class MetaService {
|
||||
@@ -10,21 +10,21 @@ export class MetaService {
|
||||
private readonly pageId: string;
|
||||
private readonly igUserId: string;
|
||||
private readonly isEnabled: boolean;
|
||||
private readonly graphApiBase = 'https://graph.facebook.com/v21.0';
|
||||
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.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');
|
||||
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',
|
||||
"⚠️ Meta API not configured. Set META_PAGE_ACCESS_TOKEN, META_PAGE_ID, META_IG_USER_ID",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -53,7 +53,7 @@ export class MetaService {
|
||||
imageUrl: string,
|
||||
): Promise<string | null> {
|
||||
if (!this.facebookAvailable) {
|
||||
this.logger.warn('Facebook not available, skipping post');
|
||||
this.logger.warn("Facebook not available, skipping post");
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ export class MetaService {
|
||||
imageUrl: string,
|
||||
): Promise<string | null> {
|
||||
if (!this.instagramAvailable) {
|
||||
this.logger.warn('Instagram not available, skipping post');
|
||||
this.logger.warn("Instagram not available, skipping post");
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ export class MetaService {
|
||||
|
||||
const containerId = containerResponse.data?.id;
|
||||
if (!containerId) {
|
||||
throw new Error('No container ID returned');
|
||||
throw new Error("No container ID returned");
|
||||
}
|
||||
|
||||
// Wait for container processing (IG needs a few seconds)
|
||||
@@ -156,25 +156,25 @@ export class MetaService {
|
||||
`${this.graphApiBase}/${containerId}`,
|
||||
{
|
||||
params: {
|
||||
fields: 'status_code',
|
||||
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');
|
||||
if (status === "FINISHED") return;
|
||||
if (status === "ERROR") {
|
||||
throw new Error("Container processing failed");
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.message === 'Container processing failed') throw 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');
|
||||
this.logger.warn("Container wait timed out, attempting publish anyway");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
import { Controller, Post, Param, Get, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
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';
|
||||
import { SocialPosterService } from "./social-poster.service";
|
||||
import { Roles } from "../../common/decorators";
|
||||
import { RolesGuard } from "../auth/guards/auth.guards";
|
||||
|
||||
@ApiTags('Social Poster')
|
||||
@ApiTags("Social Poster")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles('admin')
|
||||
@Controller('social-poster')
|
||||
@Roles("admin")
|
||||
@Controller("social-poster")
|
||||
export class SocialPosterController {
|
||||
constructor(private readonly socialPosterService: SocialPosterService) {}
|
||||
|
||||
@Get('preview/:matchId')
|
||||
async previewCard(@Param('matchId') matchId: string) {
|
||||
@Get("preview/:matchId")
|
||||
async previewCard(@Param("matchId") matchId: string) {
|
||||
return this.socialPosterService.renderPreview(matchId);
|
||||
}
|
||||
|
||||
@Post('post/:matchId')
|
||||
async postMatch(@Param('matchId') matchId: string) {
|
||||
@Post("post/:matchId")
|
||||
async postMatch(@Param("matchId") matchId: string) {
|
||||
return this.socialPosterService.manualPost(matchId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
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 { 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';
|
||||
import { SocialPosterController } from "./social-poster.controller";
|
||||
|
||||
/**
|
||||
* Social Poster Module
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
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 { 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 { 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';
|
||||
} from "./dto/prediction-card.dto";
|
||||
|
||||
// Top leagues loaded once
|
||||
|
||||
const TOP_LEAGUES_PATH = path.join(process.cwd(), 'top_leagues.json');
|
||||
const TOP_LEAGUES_PATH = path.join(process.cwd(), "top_leagues.json");
|
||||
|
||||
@Injectable()
|
||||
export class SocialPosterService {
|
||||
@@ -38,24 +38,24 @@ export class SocialPosterService {
|
||||
private readonly metaService: MetaService,
|
||||
) {
|
||||
this.aiEngineUrl =
|
||||
this.configService.get<string>('AI_ENGINE_URL') ||
|
||||
'http://localhost:8000';
|
||||
this.configService.get<string>("AI_ENGINE_URL") ||
|
||||
"http://localhost:8000";
|
||||
this.appBaseUrl =
|
||||
this.configService.get<string>('APP_BASE_URL') || 'http://localhost:3000';
|
||||
this.configService.get<string>("APP_BASE_URL") || "http://localhost:3000";
|
||||
this.isEnabled =
|
||||
this.configService.get<string>('SOCIAL_POSTER_ENABLED') === 'true';
|
||||
this.configService.get<string>("SOCIAL_POSTER_ENABLED") === "true";
|
||||
|
||||
this.loadTopLeagues();
|
||||
}
|
||||
|
||||
private loadTopLeagues() {
|
||||
try {
|
||||
const data = fs.readFileSync(TOP_LEAGUES_PATH, 'utf-8');
|
||||
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');
|
||||
this.logger.warn("⚠️ Could not load top_leagues.json");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ export class SocialPosterService {
|
||||
* Cron: Every 10 minutes, check for upcoming matches.
|
||||
* Posts predictions 30 minutes before kickoff.
|
||||
*/
|
||||
@Cron('*/10 * * * *')
|
||||
@Cron("*/10 * * * *")
|
||||
async checkAndPostUpcomingMatches() {
|
||||
if (!this.isEnabled) return;
|
||||
|
||||
@@ -115,7 +115,7 @@ export class SocialPosterService {
|
||||
|
||||
const matches = await this.prisma.liveMatch.findMany({
|
||||
where: {
|
||||
sport: 'football',
|
||||
sport: "football",
|
||||
leagueId: { in: Array.from(this.topLeagueIds) },
|
||||
mstUtc: {
|
||||
gte: minTime,
|
||||
@@ -144,7 +144,7 @@ export class SocialPosterService {
|
||||
// Step 1: Get prediction from AI Engine
|
||||
const prediction = await this.getPrediction(matchId);
|
||||
if (!prediction) {
|
||||
throw new Error('No prediction returned from AI Engine');
|
||||
throw new Error("No prediction returned from AI Engine");
|
||||
}
|
||||
|
||||
// Step 2: Build prediction card data
|
||||
@@ -194,9 +194,9 @@ export class SocialPosterService {
|
||||
|
||||
this.logger.log(
|
||||
`✅ Posted: ${match.homeTeam?.name} vs ${match.awayTeam?.name} ` +
|
||||
`[TW: ${result.twitterPostId ? '✅' : '❌'}, ` +
|
||||
`FB: ${result.facebookPostId ? '✅' : '❌'}, ` +
|
||||
`IG: ${result.instagramPostId ? '✅' : '❌'}]`,
|
||||
`[TW: ${result.twitterPostId ? "✅" : "❌"}, ` +
|
||||
`FB: ${result.facebookPostId ? "✅" : "❌"}, ` +
|
||||
`IG: ${result.instagramPostId ? "✅" : "❌"}]`,
|
||||
);
|
||||
|
||||
return result;
|
||||
@@ -229,8 +229,8 @@ export class SocialPosterService {
|
||||
): PredictionCardDto {
|
||||
// V20+ returns score_prediction.ft / .ht
|
||||
const score = prediction.score_prediction || {};
|
||||
const htScore = score.ht || '0-0';
|
||||
const ftScore = score.ft || '1-1';
|
||||
const htScore = score.ht || "0-0";
|
||||
const ftScore = score.ft || "1-1";
|
||||
|
||||
// Extract best bets from bet_summary array
|
||||
const topPicks = this.extractTopPicks(prediction);
|
||||
@@ -247,18 +247,18 @@ export class SocialPosterService {
|
||||
return {
|
||||
matchId: match.id,
|
||||
homeTeam:
|
||||
match.homeTeam?.name || prediction.match_info?.home_team || 'Home',
|
||||
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 || '',
|
||||
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',
|
||||
riskLevel: prediction.risk?.level || "MEDIUM",
|
||||
rawPrediction: prediction,
|
||||
};
|
||||
}
|
||||
@@ -271,16 +271,16 @@ export class SocialPosterService {
|
||||
|
||||
// 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' },
|
||||
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) => {
|
||||
@@ -308,11 +308,11 @@ export class SocialPosterService {
|
||||
* Locally during dev, we fetch them from the deployed server via APP_BASE_URL.
|
||||
*/
|
||||
private resolveLogoUrl(logoUrl: string): string {
|
||||
if (!logoUrl) return '';
|
||||
if (!logoUrl) return "";
|
||||
// Already a full URL
|
||||
if (logoUrl.startsWith('http')) return logoUrl;
|
||||
if (logoUrl.startsWith("http")) return logoUrl;
|
||||
// Relative path → check local first, otherwise make full URL
|
||||
const localPath = path.join(process.cwd(), 'public', logoUrl);
|
||||
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}`;
|
||||
@@ -321,24 +321,24 @@ export class SocialPosterService {
|
||||
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',
|
||||
"Oca",
|
||||
"Şub",
|
||||
"Mar",
|
||||
"Nis",
|
||||
"May",
|
||||
"Haz",
|
||||
"Tem",
|
||||
"Ağu",
|
||||
"Eyl",
|
||||
"Eki",
|
||||
"Kas",
|
||||
"Ara",
|
||||
];
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
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');
|
||||
const hour = String(d.getHours()).padStart(2, "0");
|
||||
const min = String(d.getMinutes()).padStart(2, "0");
|
||||
return `${day} ${month} ${year} - ${hour}:${min}`;
|
||||
}
|
||||
|
||||
@@ -383,7 +383,7 @@ export class SocialPosterService {
|
||||
|
||||
const prediction = await this.getPrediction(matchId);
|
||||
if (!prediction) {
|
||||
throw new Error('No prediction returned from AI Engine');
|
||||
throw new Error("No prediction returned from AI Engine");
|
||||
}
|
||||
|
||||
const card = this.buildCardFromPrediction(match, prediction);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as fs from 'fs';
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import * as fs from "fs";
|
||||
|
||||
@Injectable()
|
||||
export class TwitterService {
|
||||
@@ -9,18 +9,18 @@ export class TwitterService {
|
||||
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 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',
|
||||
"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',
|
||||
"⚠️ Twitter API keys not configured. Set TWITTER_API_KEY, TWITTER_API_SECRET, TWITTER_ACCESS_TOKEN, TWITTER_ACCESS_SECRET",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ export class TwitterService {
|
||||
accessSecret: string,
|
||||
) {
|
||||
try {
|
||||
const { TwitterApi } = await import('twitter-api-v2');
|
||||
const { TwitterApi } = await import("twitter-api-v2");
|
||||
this.client = new TwitterApi({
|
||||
appKey: apiKey,
|
||||
appSecret: apiSecret,
|
||||
@@ -40,9 +40,9 @@ export class TwitterService {
|
||||
accessSecret,
|
||||
});
|
||||
this.isEnabled = true;
|
||||
this.logger.log('✅ Twitter API client initialized');
|
||||
this.logger.log("✅ Twitter API client initialized");
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to initialize Twitter client', error);
|
||||
this.logger.error("Failed to initialize Twitter client", error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ export class TwitterService {
|
||||
*/
|
||||
async postWithImage(text: string, imagePath: string): Promise<string | null> {
|
||||
if (!this.available) {
|
||||
this.logger.warn('Twitter not available, skipping post');
|
||||
this.logger.warn("Twitter not available, skipping post");
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ export class TwitterService {
|
||||
// Step 1: Upload media via v1.1
|
||||
const mediaData = fs.readFileSync(imagePath);
|
||||
const mediaId = await this.client.v1.uploadMedia(mediaData, {
|
||||
mimeType: 'image/png',
|
||||
mimeType: "image/png",
|
||||
});
|
||||
|
||||
// Step 2: Create tweet via v2
|
||||
|
||||
Reference in New Issue
Block a user