mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-20 21:48:40 +02:00
feat(tiktok): publier les reels sur les comptes TikTok des magasins
Ajoute la gestion multi-comptes TikTok et permet d'envoyer la même vidéo sur plusieurs pages Facebook ET plusieurs comptes TikTok en une seule publication. Un compte TikTok vit dans social_pages comme une page Facebook : il hérite donc des permissions par utilisateur, de la planification et du calendrier. Trois particularités de l'API TikTok structurent l'implémentation : - pas de jeton saisi à la main : chaque compte passe par OAuth, et l'access token (24h) est renouvelé via le refresh token (1 an) avant chaque publication ; - la publication est asynchrone : l'upload rend un publish_id, et un poller récupère l'identifiant définitif du post ; - creator_info doit être interrogé avant chaque envoi pour ne demander qu'un niveau de confidentialité réellement autorisé sur le compte. Serveur : - service TikTok (OAuth, creator_info, upload FILE_UPLOAD par chunks, suivi de statut) et routes de connexion/configuration - schéma : platform 'tiktok', refresh token et scopes sur social_pages, publish_id/publish_status sur scheduled_posts, table tiktok_config - routage par plateforme dans les deux flux de reels et le planificateur - cron de suivi des publications, token manager et analytics adaptés Client : - connexion et reconnexion des comptes TikTok depuis « Pages gérées » - sélection combinée pages Facebook / comptes TikTok à la publication - configuration de l'application TikTok dans les paramètres (admin) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018uUPZ1GNpEkYB1dpXHYPek
This commit is contained in:
27 files changed
+1679
-183
No files matched your search
+39
-15
@@ -7,6 +7,7 @@ import type { User, Media, SocialPage } from '@shared/schema';
|
||||
import { storage } from '../storage';
|
||||
import { ffmpegService } from '../services/ffmpeg';
|
||||
import { facebookService } from '../services/facebook';
|
||||
import { tiktokService } from '../services/tiktok';
|
||||
import { minioService as cloudinaryService, buildMinioUrl, resolveInternalUrl } from '../services/minio';
|
||||
import { openRouterService } from '../services/openrouter';
|
||||
import { db } from '../db';
|
||||
@@ -642,8 +643,12 @@ async function processReelBackground(
|
||||
continue;
|
||||
}
|
||||
|
||||
if (page.platform !== 'facebook') {
|
||||
results.push({ pageId, success: false, error: 'Seules les pages Facebook sont supportées' });
|
||||
if (page.platform !== 'facebook' && page.platform !== 'tiktok') {
|
||||
results.push({
|
||||
pageId,
|
||||
success: false,
|
||||
error: `Plateforme non supportée pour les reels : ${page.platform}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -656,23 +661,42 @@ async function processReelBackground(
|
||||
});
|
||||
|
||||
if (!scheduledFor) {
|
||||
// Publication immédiate
|
||||
console.log(`🚀 [Background] Publishing to Page ${page.pageName}...`);
|
||||
// Publication immédiate — la même vidéo part sur chaque destination
|
||||
console.log(`🚀 [Background] Publishing to ${page.platform} ${page.pageName}...`);
|
||||
const finalDescription = description || overlayText || '';
|
||||
|
||||
const reelId = await facebookService.publishReel(
|
||||
page,
|
||||
cloudinaryResult.originalUrl,
|
||||
finalDescription
|
||||
);
|
||||
if (page.platform === 'facebook') {
|
||||
const reelId = await facebookService.publishReel(
|
||||
page,
|
||||
cloudinaryResult.originalUrl,
|
||||
finalDescription
|
||||
);
|
||||
|
||||
// Mise à jour succès
|
||||
await storage.updateScheduledPost(scheduledPost.id, {
|
||||
publishedAt: new Date(),
|
||||
externalPostId: reelId,
|
||||
});
|
||||
// Mise à jour succès
|
||||
await storage.updateScheduledPost(scheduledPost.id, {
|
||||
publishedAt: new Date(),
|
||||
externalPostId: reelId,
|
||||
});
|
||||
|
||||
results.push({ pageId, success: true, reelId });
|
||||
results.push({ pageId, success: true, reelId });
|
||||
} else {
|
||||
const publishId = await tiktokService.publishVideoFromBuffer(
|
||||
page,
|
||||
videoBuffer,
|
||||
finalDescription
|
||||
);
|
||||
|
||||
// TikTok finalise la publication de son côté : on marque l'envoi
|
||||
// comme effectué pour ne pas republier, et le poller de statut
|
||||
// renseignera l'identifiant définitif du post.
|
||||
await storage.updateScheduledPost(scheduledPost.id, {
|
||||
publishedAt: new Date(),
|
||||
publishId,
|
||||
publishStatus: 'PROCESSING_UPLOAD',
|
||||
});
|
||||
|
||||
results.push({ pageId, success: true, reelId: publishId });
|
||||
}
|
||||
} else {
|
||||
// Planifié
|
||||
results.push({ pageId, success: true, reelId: 'scheduled' });
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ffmpegService } from "../services/ffmpeg";
|
||||
import { storage as dbStorage } from "../storage";
|
||||
import { minioService as cloudinaryService, buildMinioUrl } from "../services/minio";
|
||||
import { facebookService } from "../services/facebook";
|
||||
import { tiktokService } from "../services/tiktok";
|
||||
import * as musicMetadata from "music-metadata";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
@@ -427,7 +428,10 @@ remotionRouter.post("/publish", async (req, res) => {
|
||||
try {
|
||||
const page = await dbStorage.getSocialPage(pageId);
|
||||
if (!page) { results.push({ pageId, success: false, error: "Page introuvable" }); continue; }
|
||||
if (page.platform !== "facebook") { results.push({ pageId, success: false, error: "Seules les pages Facebook sont supportées" }); continue; }
|
||||
if (page.platform !== "facebook" && page.platform !== "tiktok") {
|
||||
results.push({ pageId, success: false, error: `Plateforme non supportée pour les reels : ${page.platform}` });
|
||||
continue;
|
||||
}
|
||||
|
||||
const scheduledPost = await dbStorage.createScheduledPost({
|
||||
postId: post.id,
|
||||
@@ -437,12 +441,25 @@ remotionRouter.post("/publish", async (req, res) => {
|
||||
});
|
||||
|
||||
if (!scheduledFor) {
|
||||
console.log(`🚀 Publishing to ${page.pageName} (direct binary upload)...`);
|
||||
// Upload video bytes directly — Facebook cannot access local URLs
|
||||
console.log(`🚀 Publishing to ${page.platform} ${page.pageName} (direct binary upload)...`);
|
||||
// Upload video bytes directly — les APIs ne peuvent pas lire une URL locale
|
||||
const videoBuffer = await fs.promises.readFile(localPath);
|
||||
const reelId = await facebookService.publishVideoFromBuffer(page, videoBuffer, description || "");
|
||||
await dbStorage.updateScheduledPost(scheduledPost.id, { publishedAt: new Date(), externalPostId: reelId });
|
||||
results.push({ pageId, success: true, reelId });
|
||||
|
||||
if (page.platform === "facebook") {
|
||||
const reelId = await facebookService.publishVideoFromBuffer(page, videoBuffer, description || "");
|
||||
await dbStorage.updateScheduledPost(scheduledPost.id, { publishedAt: new Date(), externalPostId: reelId });
|
||||
results.push({ pageId, success: true, reelId });
|
||||
} else {
|
||||
// TikTok finalise la publication de son côté : le poller de statut
|
||||
// renseignera l'identifiant définitif du post.
|
||||
const publishId = await tiktokService.publishVideoFromBuffer(page, videoBuffer, description || "");
|
||||
await dbStorage.updateScheduledPost(scheduledPost.id, {
|
||||
publishedAt: new Date(),
|
||||
publishId,
|
||||
publishStatus: "PROCESSING_UPLOAD",
|
||||
});
|
||||
results.push({ pageId, success: true, reelId: publishId });
|
||||
}
|
||||
} else {
|
||||
results.push({ pageId, success: true, reelId: "scheduled" });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* Routes de gestion des comptes TikTok.
|
||||
*
|
||||
* Un compte TikTok de magasin se connecte en OAuth (TikTok n'autorise pas la
|
||||
* saisie manuelle d'un token), puis vit dans `social_pages` comme une page
|
||||
* Facebook : mêmes permissions, même planification, même calendrier.
|
||||
*/
|
||||
|
||||
import crypto from 'crypto';
|
||||
import { Router, Request, Response } from 'express';
|
||||
import type { User } from '@shared/schema';
|
||||
import { storage } from '../storage';
|
||||
import { tiktokService } from '../services/tiktok';
|
||||
import { insertTiktokConfigSchema, updateTiktokConfigSchema } from '@shared/schema';
|
||||
|
||||
export const tiktokRouter = Router();
|
||||
|
||||
/** Page vers laquelle on renvoie le navigateur à la fin du flux OAuth. */
|
||||
const RETURN_PATH = '/pages';
|
||||
|
||||
function isAdmin(req: Request): boolean {
|
||||
const user = req.user as User | undefined;
|
||||
return user?.role === 'admin';
|
||||
}
|
||||
|
||||
function buildReturnUrl(status: 'success' | 'error', message: string): string {
|
||||
const params = new URLSearchParams({ tiktok: status, message });
|
||||
return `${RETURN_PATH}?${params.toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* État de la configuration TikTok.
|
||||
* Le client secret n'est jamais renvoyé au client.
|
||||
*/
|
||||
tiktokRouter.get('/config', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const config = await storage.getTiktokConfig();
|
||||
const configured = await tiktokService.isConfigured();
|
||||
|
||||
// Tout le monde a besoin de savoir si la connexion est possible ; seuls les
|
||||
// admins voient les détails de l'application développeur.
|
||||
if (!isAdmin(req)) {
|
||||
return res.json({ configured });
|
||||
}
|
||||
|
||||
res.json({
|
||||
configured,
|
||||
clientKey: config?.clientKey || (process.env.TIKTOK_CLIENT_KEY ? '(défini par variable d\'environnement)' : ''),
|
||||
hasClientSecret: !!(config?.clientSecret || process.env.TIKTOK_CLIENT_SECRET),
|
||||
redirectUri: tiktokService.getRedirectUri(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('❌ [TikTok] Erreur lecture configuration:', error);
|
||||
res.status(500).json({ error: 'Impossible de lire la configuration TikTok' });
|
||||
}
|
||||
});
|
||||
|
||||
/** Enregistre les identifiants de l'application développeur TikTok (admin). */
|
||||
tiktokRouter.put('/config', async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!isAdmin(req)) {
|
||||
return res.status(403).json({ error: 'Accès refusé. Réservé aux administrateurs.' });
|
||||
}
|
||||
|
||||
const existing = await storage.getTiktokConfig();
|
||||
// Le secret peut être omis lors d'une mise à jour : on garde l'existant
|
||||
const schema = existing ? updateTiktokConfigSchema : insertTiktokConfigSchema;
|
||||
const data = schema.parse(req.body);
|
||||
|
||||
const config = await storage.upsertTiktokConfig(data);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
clientKey: config.clientKey,
|
||||
redirectUri: tiktokService.getRedirectUri(),
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('❌ [TikTok] Erreur enregistrement configuration:', error);
|
||||
res.status(400).json({
|
||||
error: error?.errors?.[0]?.message || error?.message || 'Configuration TikTok invalide',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Démarre la connexion d'un compte TikTok.
|
||||
* À appeler par navigation complète du navigateur (window.location), pas en fetch.
|
||||
*/
|
||||
tiktokRouter.get('/connect', async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!(await tiktokService.isConfigured())) {
|
||||
return res.redirect(
|
||||
buildReturnUrl('error', "TikTok n'est pas configuré. Renseignez d'abord le client key et le client secret.")
|
||||
);
|
||||
}
|
||||
|
||||
// Protection CSRF : l'état est vérifié au retour
|
||||
const state = crypto.randomBytes(16).toString('hex');
|
||||
(req.session as any).tiktokOAuthState = state;
|
||||
|
||||
const authUrl = await tiktokService.buildAuthorizationUrl(state);
|
||||
|
||||
// La session doit être écrite avant la redirection, sinon l'état est perdu
|
||||
req.session.save((err) => {
|
||||
if (err) {
|
||||
console.error('❌ [TikTok] Impossible de sauvegarder la session OAuth:', err);
|
||||
return res.redirect(buildReturnUrl('error', 'Erreur de session, réessayez.'));
|
||||
}
|
||||
res.redirect(authUrl);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('❌ [TikTok] Erreur démarrage OAuth:', error);
|
||||
res.redirect(
|
||||
buildReturnUrl('error', error instanceof Error ? error.message : 'Erreur de connexion TikTok')
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Retour d'autorisation TikTok : échange le code, puis crée ou met à jour le
|
||||
* compte dans social_pages.
|
||||
*/
|
||||
tiktokRouter.get('/callback', async (req: Request, res: Response) => {
|
||||
const user = req.user as User;
|
||||
const { code, state, error: oauthError, error_description: oauthErrorDescription } = req.query;
|
||||
|
||||
try {
|
||||
if (oauthError) {
|
||||
const description = (oauthErrorDescription as string) || (oauthError as string);
|
||||
return res.redirect(buildReturnUrl('error', `Autorisation TikTok refusée : ${description}`));
|
||||
}
|
||||
|
||||
const expectedState = (req.session as any)?.tiktokOAuthState;
|
||||
delete (req.session as any)?.tiktokOAuthState;
|
||||
|
||||
if (!state || !expectedState || state !== expectedState) {
|
||||
return res.redirect(buildReturnUrl('error', 'Requête de connexion TikTok invalide (état non reconnu).'));
|
||||
}
|
||||
|
||||
if (!code || typeof code !== 'string') {
|
||||
return res.redirect(buildReturnUrl('error', "Aucun code d'autorisation reçu de TikTok."));
|
||||
}
|
||||
|
||||
const tokens = await tiktokService.exchangeCodeForToken(code);
|
||||
const profile = await tiktokService.getUserInfo(tokens.access_token);
|
||||
const openId = profile.openId || tokens.open_id;
|
||||
|
||||
// Un compte déjà connecté est mis à jour plutôt que dupliqué
|
||||
const existingPages = await storage.getSocialPages(user.id);
|
||||
const existing = existingPages.find(p => p.platform === 'tiktok' && p.pageId === openId);
|
||||
|
||||
if (existing) {
|
||||
await storage.updateSocialPage(existing.id, {
|
||||
pageName: profile.displayName,
|
||||
avatarUrl: profile.avatarUrl || null,
|
||||
isActive: 'true',
|
||||
});
|
||||
await tiktokService.persistTokens(existing.id, tokens);
|
||||
|
||||
console.log(`🔁 [TikTok] Compte reconnecté: ${profile.displayName}`);
|
||||
return res.redirect(buildReturnUrl('success', `Compte TikTok "${profile.displayName}" reconnecté.`));
|
||||
}
|
||||
|
||||
const page = await storage.createSocialPage({
|
||||
userId: user.id,
|
||||
platform: 'tiktok',
|
||||
pageId: openId,
|
||||
pageName: profile.displayName,
|
||||
accessToken: tokens.access_token,
|
||||
tokenExpiresAt: new Date(Date.now() + tokens.expires_in * 1000),
|
||||
refreshToken: tokens.refresh_token,
|
||||
refreshTokenExpiresAt: new Date(Date.now() + tokens.refresh_expires_in * 1000),
|
||||
scopes: tokens.scope,
|
||||
avatarUrl: profile.avatarUrl || null,
|
||||
isActive: 'true',
|
||||
});
|
||||
|
||||
// Les non-admins ne voient que les pages qui leur sont explicitement
|
||||
// attribuées : sans cette permission, ils ne verraient pas leur propre compte.
|
||||
if (user.role !== 'admin') {
|
||||
try {
|
||||
await storage.createPagePermission({ userId: user.id, pageId: page.id });
|
||||
} catch (permissionError) {
|
||||
console.error('⚠️ [TikTok] Impossible de créer la permission de page:', permissionError);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✅ [TikTok] Nouveau compte connecté: ${profile.displayName} (${openId})`);
|
||||
res.redirect(buildReturnUrl('success', `Compte TikTok "${profile.displayName}" connecté.`));
|
||||
} catch (error) {
|
||||
console.error('❌ [TikTok] Erreur callback OAuth:', error);
|
||||
res.redirect(
|
||||
buildReturnUrl('error', error instanceof Error ? error.message : 'Connexion TikTok impossible')
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Options réellement autorisées sur un compte (confidentialité, commentaires,
|
||||
* duo, stitch, durée max). TikTok impose de les afficher avant publication.
|
||||
*/
|
||||
tiktokRouter.get('/accounts/:id/creator-info', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const user = req.user as User;
|
||||
const page = await storage.getSocialPage(req.params.id);
|
||||
|
||||
if (!page || page.platform !== 'tiktok') {
|
||||
return res.status(404).json({ error: 'Compte TikTok non trouvé' });
|
||||
}
|
||||
|
||||
if (user.role !== 'admin' && page.userId !== user.id) {
|
||||
const accessible = await storage.getUserAccessiblePages(user.id);
|
||||
if (!accessible.some(p => p.id === page.id)) {
|
||||
return res.status(403).json({ error: 'Accès refusé à ce compte' });
|
||||
}
|
||||
}
|
||||
|
||||
const creatorInfo = await tiktokService.getCreatorInfo(page);
|
||||
res.json(creatorInfo);
|
||||
} catch (error) {
|
||||
console.error('❌ [TikTok] Erreur récupération creator info:', error);
|
||||
res.status(502).json({
|
||||
error: error instanceof Error ? error.message : 'Impossible de contacter TikTok',
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user