From b107d6018320d384774de36f8bf3cc92223be919 Mon Sep 17 00:00:00 2001 From: Martin Porwoll Date: Fri, 16 Jan 2026 21:28:40 +0000 Subject: [PATCH] feat(community): Phase 2.3 - Meta (Facebook + Instagram) Integration Implements complete Meta Graph API integration for Facebook Pages and Instagram Business Accounts. Phase 2.3a - Meta OAuth & Base Infrastructure: - Meta OAuth service with long-lived token support (60 days) - MetaBaseClient with error handling and retry logic - OAuth routes (/api/auth/meta, /api/auth/meta/callback) - Type definitions for all Meta API responses Phase 2.3b - Facebook Client: - FacebookClient extending MetaBaseClient - Page posts and comments retrieval - Comment moderation (reply, hide, delete, like) - Messenger conversations support - Page insights and analytics - FacebookSyncService for comment synchronization Phase 2.3c - Instagram Client: - InstagramClient for Business Accounts - Media (posts/reels/carousels) retrieval - Comment management with replies - Mentions and Story-Mentions (24h expiry) - Instagram Direct messaging - Account and media insights - InstagramSyncService for comment/mention sync Additional changes: - SocialPlatforms collection extended with oauthEndpoint field - Environment variables documented (META_APP_ID, META_APP_SECRET) - Module index with all exports Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 13 + .../(payload)/api/auth/meta/callback/route.ts | 221 +++++++ src/app/(payload)/api/auth/meta/route.ts | 101 +++ src/collections/SocialPlatforms.ts | 21 + src/lib/integrations/meta/FacebookClient.ts | 579 ++++++++++++++++ .../integrations/meta/FacebookSyncService.ts | 403 ++++++++++++ src/lib/integrations/meta/InstagramClient.ts | 619 ++++++++++++++++++ .../integrations/meta/InstagramSyncService.ts | 557 ++++++++++++++++ src/lib/integrations/meta/MetaBaseClient.ts | 307 +++++++++ src/lib/integrations/meta/index.ts | 45 ++ src/lib/integrations/meta/oauth.ts | 260 ++++++++ src/types/meta.ts | 367 +++++++++++ 12 files changed, 3493 insertions(+) create mode 100644 src/app/(payload)/api/auth/meta/callback/route.ts create mode 100644 src/app/(payload)/api/auth/meta/route.ts create mode 100644 src/lib/integrations/meta/FacebookClient.ts create mode 100644 src/lib/integrations/meta/FacebookSyncService.ts create mode 100644 src/lib/integrations/meta/InstagramClient.ts create mode 100644 src/lib/integrations/meta/InstagramSyncService.ts create mode 100644 src/lib/integrations/meta/MetaBaseClient.ts create mode 100644 src/lib/integrations/meta/index.ts create mode 100644 src/lib/integrations/meta/oauth.ts create mode 100644 src/types/meta.ts diff --git a/CLAUDE.md b/CLAUDE.md index 97527e5..ffa67d0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -162,6 +162,19 @@ TRUST_PROXY=true # PFLICHT hinter Reverse-Proxy (Caddy/ SEND_EMAIL_ALLOWED_IPS= # Optional: Komma-separierte IPs/CIDRs ADMIN_ALLOWED_IPS= # Optional: IP-Beschränkung für Admin-Panel BLOCKED_IPS= # Optional: Global geblockte IPs + +# Meta (Facebook + Instagram) OAuth +META_APP_ID=your-app-id # Facebook App ID +META_APP_SECRET=your-app-secret # Facebook App Secret +META_REDIRECT_URI=https://your-domain/api/auth/meta/callback + +# YouTube OAuth (existing) +GOOGLE_CLIENT_ID=your-client-id # Google Cloud Console +GOOGLE_CLIENT_SECRET=your-client-secret +YOUTUBE_REDIRECT_URI=https://your-domain/api/youtube/callback + +# Cron Jobs +CRON_SECRET=your-64-char-hex # Auth für Cron-Endpoints ``` > **Wichtig:** `TRUST_PROXY=true` muss gesetzt sein wenn die App hinter einem Reverse-Proxy diff --git a/src/app/(payload)/api/auth/meta/callback/route.ts b/src/app/(payload)/api/auth/meta/callback/route.ts new file mode 100644 index 0000000..ee296ed --- /dev/null +++ b/src/app/(payload)/api/auth/meta/callback/route.ts @@ -0,0 +1,221 @@ +/** + * Meta OAuth Callback + * + * Verarbeitet den OAuth Callback von Meta und speichert die Tokens. + * + * GET /api/auth/meta/callback?code=...&state=... + */ + +import { NextRequest, NextResponse } from 'next/server' +import { getPayload } from 'payload' +import config from '@payload-config' +import { + exchangeCodeForTokens, + getLongLivedToken, + decodeState, + calculateTokenExpiry, +} from '@/lib/integrations/meta/oauth' +import { MetaBaseClient } from '@/lib/integrations/meta/MetaBaseClient' +import { createSafeLogger } from '@/lib/security' +import type { MetaOAuthState } from '@/types/meta' + +const logger = createSafeLogger('API:MetaCallback') + +export async function GET(req: NextRequest) { + const searchParams = req.nextUrl.searchParams + const code = searchParams.get('code') + const stateParam = searchParams.get('state') + const error = searchParams.get('error') + const errorDescription = searchParams.get('error_description') + const errorReason = searchParams.get('error_reason') + + const baseUrl = process.env.NEXT_PUBLIC_SERVER_URL || '' + + // Fehler von Meta + if (error) { + logger.error('OAuth error from Meta:', { error, errorDescription, errorReason }) + return NextResponse.redirect( + `${baseUrl}/admin/collections/social-accounts?error=${encodeURIComponent(errorDescription || error)}` + ) + } + + // Fehlende Parameter + if (!code || !stateParam) { + logger.warn('Missing OAuth parameters') + return NextResponse.redirect( + `${baseUrl}/admin/collections/social-accounts?error=missing_params` + ) + } + + try { + // State decodieren + let state: MetaOAuthState + try { + state = decodeState(stateParam) + } catch { + logger.error('Invalid state parameter') + return NextResponse.redirect( + `${baseUrl}/admin/collections/social-accounts?error=invalid_state` + ) + } + + const { socialAccountId, accountType, returnUrl } = state + + if (!socialAccountId) { + logger.error('Missing socialAccountId in state') + return NextResponse.redirect( + `${baseUrl}/admin/collections/social-accounts?error=invalid_state` + ) + } + + // State-Alter prüfen (max 10 Minuten) + const stateAge = Date.now() - state.timestamp + if (stateAge > 10 * 60 * 1000) { + logger.warn('State expired', { age: stateAge }) + return NextResponse.redirect( + `${baseUrl}/admin/collections/social-accounts?error=state_expired` + ) + } + + // Tokens von Meta abrufen (Short-lived Token) + logger.info('Exchanging code for tokens') + const shortLivedTokens = await exchangeCodeForTokens(code) + + if (!shortLivedTokens.access_token) { + throw new Error('No access token received from Meta') + } + + // Long-lived Token abrufen (60 Tage gültig) + logger.info('Exchanging for long-lived token') + const longLivedToken = await getLongLivedToken(shortLivedTokens.access_token) + + // Meta Client initialisieren + const metaClient = new MetaBaseClient(longLivedToken.access_token) + + // User-Informationen abrufen + const metaUser = await metaClient.getMe() + logger.info('Meta user retrieved', { + userId: metaUser.id, + userName: metaUser.name, + }) + + // Account-spezifische Daten abrufen + let accountData: { + externalId: string + accountHandle?: string + accountUrl?: string + followers?: number + totalPosts?: number + additionalData?: Record + } = { + externalId: metaUser.id, + accountHandle: metaUser.name, + } + + // Je nach accountType unterschiedliche Daten abrufen + if (accountType === 'facebook' || accountType === 'both') { + const pages = await metaClient.getPages() + logger.info('Facebook pages retrieved', { count: pages.length }) + + // Erste Page als Haupt-Account verwenden + if (pages.length > 0) { + const primaryPage = pages[0] + accountData = { + externalId: primaryPage.id, + accountHandle: primaryPage.name, + accountUrl: `https://facebook.com/${primaryPage.id}`, + additionalData: { + pageAccessToken: primaryPage.access_token, + category: primaryPage.category, + allPages: pages.map((p) => ({ + id: p.id, + name: p.name, + category: p.category, + hasInstagram: !!p.instagram_business_account?.id, + })), + }, + } + } + } + + if (accountType === 'instagram' || accountType === 'both') { + const instagramAccounts = await metaClient.getAllInstagramAccounts() + logger.info('Instagram accounts retrieved', { count: instagramAccounts.length }) + + // Wenn Instagram-Account gefunden, diesen priorisieren für Instagram-Type + if (instagramAccounts.length > 0 && accountType === 'instagram') { + const primaryInstagram = instagramAccounts[0].instagram + accountData = { + externalId: primaryInstagram.id, + accountHandle: `@${primaryInstagram.username}`, + accountUrl: `https://instagram.com/${primaryInstagram.username}`, + followers: primaryInstagram.followers_count, + totalPosts: primaryInstagram.media_count, + additionalData: { + linkedPage: { + id: instagramAccounts[0].page.id, + name: instagramAccounts[0].page.name, + }, + allInstagramAccounts: instagramAccounts.map((acc) => ({ + id: acc.instagram.id, + username: acc.instagram.username, + pageId: acc.page.id, + pageName: acc.page.name, + })), + }, + } + } else if (instagramAccounts.length > 0 && accountType === 'both') { + // Bei 'both' Instagram-Daten zu den additionalData hinzufügen + accountData.additionalData = { + ...accountData.additionalData, + instagramAccounts: instagramAccounts.map((acc) => ({ + id: acc.instagram.id, + username: acc.instagram.username, + followers: acc.instagram.followers_count, + pageId: acc.page.id, + pageName: acc.page.name, + })), + } + } + } + + const payload = await getPayload({ config }) + + // Social Account aktualisieren + await payload.update({ + collection: 'social-accounts', + id: socialAccountId, + data: { + externalId: accountData.externalId, + accountHandle: accountData.accountHandle, + accountUrl: accountData.accountUrl, + credentials: { + accessToken: longLivedToken.access_token, + // Meta verwendet kein separates Refresh Token - der Long-lived Token wird refreshed + tokenExpiresAt: calculateTokenExpiry(longLivedToken.expires_in).toISOString(), + }, + stats: { + followers: accountData.followers || 0, + totalPosts: accountData.totalPosts || 0, + lastSyncedAt: new Date().toISOString(), + }, + }, + }) + + logger.info('Meta OAuth completed', { + socialAccountId, + accountType, + externalId: accountData.externalId, + accountHandle: accountData.accountHandle, + }) + + return NextResponse.redirect(`${baseUrl}${returnUrl || `/admin/collections/social-accounts/${socialAccountId}`}?success=connected`) + } catch (err: unknown) { + const errorMessage = err instanceof Error ? err.message : 'Unknown error' + logger.error('OAuth callback error:', { error: errorMessage }) + + return NextResponse.redirect( + `${baseUrl}/admin/collections/social-accounts?error=${encodeURIComponent(errorMessage)}` + ) + } +} diff --git a/src/app/(payload)/api/auth/meta/route.ts b/src/app/(payload)/api/auth/meta/route.ts new file mode 100644 index 0000000..5311c49 --- /dev/null +++ b/src/app/(payload)/api/auth/meta/route.ts @@ -0,0 +1,101 @@ +/** + * Meta OAuth Auth Initiation + * + * Startet den OAuth Flow für Facebook/Instagram Integration. + * + * GET /api/auth/meta?socialAccountId=123&accountType=both + * + * Query Parameters: + * - socialAccountId: ID des Social Accounts (erforderlich) + * - accountType: 'facebook' | 'instagram' | 'both' (optional, default: 'both') + */ + +import { NextRequest, NextResponse } from 'next/server' +import { getPayload } from 'payload' +import config from '@payload-config' +import { getAuthUrl, encodeState } from '@/lib/integrations/meta/oauth' +import { createSafeLogger } from '@/lib/security' +import type { MetaOAuthState } from '@/types/meta' + +const logger = createSafeLogger('API:MetaAuth') + +export async function GET(req: NextRequest) { + try { + const searchParams = req.nextUrl.searchParams + const socialAccountId = searchParams.get('socialAccountId') + const accountType = (searchParams.get('accountType') as MetaOAuthState['accountType']) || 'both' + + const baseUrl = process.env.NEXT_PUBLIC_SERVER_URL || '' + + if (!socialAccountId) { + return NextResponse.json( + { error: 'socialAccountId query parameter required' }, + { status: 400 } + ) + } + + // Validiere accountType + if (!['facebook', 'instagram', 'both'].includes(accountType)) { + return NextResponse.json( + { error: 'Invalid accountType. Must be facebook, instagram, or both' }, + { status: 400 } + ) + } + + const payload = await getPayload({ config }) + + // Authentifizierung prüfen + const { user } = await payload.auth({ headers: req.headers }) + + if (!user) { + return NextResponse.redirect( + `${baseUrl}/admin/login?redirect=/api/auth/meta?socialAccountId=${socialAccountId}&accountType=${accountType}` + ) + } + + // Prüfen ob der Social Account existiert + const account = await payload.findByID({ + collection: 'social-accounts', + id: parseInt(socialAccountId, 10), + }) + + if (!account) { + return NextResponse.json({ error: 'Social account not found' }, { status: 404 }) + } + + // Prüfen ob die Plattform Facebook oder Instagram ist + const platform = account.platform as string + if (!['facebook', 'instagram'].includes(platform)) { + return NextResponse.json( + { error: 'This endpoint is only for Facebook and Instagram accounts' }, + { status: 400 } + ) + } + + // Auth URL generieren + const authUrl = getAuthUrl({ + returnUrl: `/admin/collections/social-accounts/${socialAccountId}`, + accountType, + userId: (user as { id: number }).id, + socialAccountId: parseInt(socialAccountId, 10), + }) + + // State separat loggen (ohne den vollen Token) + logger.info('Meta OAuth initiated', { + socialAccountId, + userId: (user as { id: number }).id, + platform, + accountType, + }) + + return NextResponse.redirect(authUrl) + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error' + logger.error('Meta auth error:', { error: errorMessage }) + + const baseUrl = process.env.NEXT_PUBLIC_SERVER_URL || '' + return NextResponse.redirect( + `${baseUrl}/admin/collections/social-accounts?error=${encodeURIComponent(errorMessage)}` + ) + } +} diff --git a/src/collections/SocialPlatforms.ts b/src/collections/SocialPlatforms.ts index 896f35b..7f3b8fe 100644 --- a/src/collections/SocialPlatforms.ts +++ b/src/collections/SocialPlatforms.ts @@ -109,6 +109,7 @@ export const SocialPlatforms: CollectionConfig = { { label: 'LinkedIn API', value: 'linkedin' }, { label: 'Instagram Graph API', value: 'instagram_graph' }, { label: 'Facebook Graph API', value: 'facebook_graph' }, + { label: 'Meta Graph API (FB + IG)', value: 'meta_graph' }, { label: 'Custom/Webhook', value: 'custom' }, ], }, @@ -130,6 +131,16 @@ export const SocialPlatforms: CollectionConfig = { { label: 'Bearer Token', value: 'bearer' }, ], }, + { + name: 'oauthEndpoint', + type: 'text', + label: 'OAuth Endpoint', + admin: { + description: 'Relativer API-Pfad für OAuth-Initiation (z.B. /api/youtube/auth)', + placeholder: '/api/youtube/auth', + condition: (data, siblingData) => siblingData?.authType === 'oauth2', + }, + }, { name: 'scopes', type: 'array', @@ -139,6 +150,16 @@ export const SocialPlatforms: CollectionConfig = { }, fields: [{ name: 'scope', type: 'text', label: 'Scope' }], }, + { + name: 'tokenValidityDays', + type: 'number', + label: 'Token Gültigkeit (Tage)', + defaultValue: 60, + admin: { + description: 'Wie lange ist der Access Token gültig? (YouTube: unbegrenzt mit Refresh, Meta: 60 Tage)', + condition: (data, siblingData) => siblingData?.authType === 'oauth2', + }, + }, ], }, diff --git a/src/lib/integrations/meta/FacebookClient.ts b/src/lib/integrations/meta/FacebookClient.ts new file mode 100644 index 0000000..9967bb0 --- /dev/null +++ b/src/lib/integrations/meta/FacebookClient.ts @@ -0,0 +1,579 @@ +// src/lib/integrations/meta/FacebookClient.ts +// Facebook Client für Page Management, Posts und Kommentare + +import { MetaBaseClient, MetaApiError } from './MetaBaseClient' +import type { + FacebookPage, + FacebookPost, + FacebookComment, + FacebookConversation, + FacebookMessage, + FacebookPageInsight, + MetaPaginatedResponse, +} from '@/types/meta' + +// ============================================================================= +// Types +// ============================================================================= + +export interface FacebookPostsOptions { + /** Maximale Anzahl Posts */ + limit?: number + /** Filter: published, scheduled, draft */ + filter?: 'published' | 'scheduled' | 'draft' + /** Nur Posts seit diesem Datum */ + since?: Date + /** Nur Posts bis zu diesem Datum */ + until?: Date +} + +export interface FacebookCommentsOptions { + /** Maximale Anzahl Kommentare */ + limit?: number + /** Sortierung: time (älteste zuerst) oder reverse_time (neueste zuerst) */ + order?: 'time' | 'reverse_time' + /** Filter: toplevel (nur Top-Level), stream (alle inkl. Replies) */ + filter?: 'toplevel' | 'stream' +} + +export interface FacebookInsightsOptions { + /** Metriken die abgefragt werden sollen */ + metrics: string[] + /** Zeitraum: day, week, days_28 */ + period?: 'day' | 'week' | 'days_28' | 'lifetime' + /** Startdatum */ + since?: Date + /** Enddatum */ + until?: Date +} + +// ============================================================================= +// Facebook Client +// ============================================================================= + +export class FacebookClient extends MetaBaseClient { + private pageId: string + private pageAccessToken: string + + constructor(options: { + /** User Access Token (Long-lived) */ + userAccessToken: string + /** Page ID */ + pageId: string + /** Page Access Token (optional, wird sonst automatisch abgerufen) */ + pageAccessToken?: string + }) { + super(options.userAccessToken) + this.pageId = options.pageId + this.pageAccessToken = options.pageAccessToken || options.userAccessToken + } + + // =========================================================================== + // Page Token Management + // =========================================================================== + + /** + * Holt den Page Access Token für die aktuelle Page + */ + async getPageAccessToken(): Promise { + const response = await this.request<{ access_token: string }>(`${this.pageId}`, { + params: { + fields: 'access_token', + }, + }) + this.pageAccessToken = response.access_token + return response.access_token + } + + /** + * Setzt den Page Access Token für nachfolgende Requests + */ + setPageAccessToken(token: string): void { + this.pageAccessToken = token + } + + /** + * Führt einen Request mit dem Page Access Token aus + */ + private async pageRequest( + endpoint: string, + options: { + method?: 'GET' | 'POST' | 'DELETE' + params?: Record + body?: Record + } = {} + ): Promise { + // Temporär den Access Token auf Page Token setzen + const originalToken = this.accessToken + this.accessToken = this.pageAccessToken + + try { + return await this.request(endpoint, options) + } finally { + this.accessToken = originalToken + } + } + + // =========================================================================== + // Posts + // =========================================================================== + + /** + * Holt alle Posts einer Page + */ + async getPosts(options: FacebookPostsOptions = {}): Promise { + const { limit = 25, since, until } = options + + const params: Record = { + fields: [ + 'id', + 'message', + 'story', + 'full_picture', + 'permalink_url', + 'created_time', + 'updated_time', + 'shares', + 'reactions.summary(true)', + 'comments.summary(true)', + 'attachments{type,url,media}', + ].join(','), + limit, + } + + if (since) { + params.since = Math.floor(since.getTime() / 1000) + } + if (until) { + params.until = Math.floor(until.getTime() / 1000) + } + + const response = await this.pageRequest>( + `${this.pageId}/posts`, + { params } + ) + + return response.data + } + + /** + * Holt einen einzelnen Post mit allen Details + */ + async getPost(postId: string): Promise { + return this.pageRequest(postId, { + params: { + fields: [ + 'id', + 'message', + 'story', + 'full_picture', + 'permalink_url', + 'created_time', + 'updated_time', + 'shares', + 'reactions.summary(true)', + 'comments.summary(true)', + 'attachments{type,url,media}', + ].join(','), + }, + }) + } + + /** + * Holt alle Posts mit Pagination + */ + async getAllPosts(options: FacebookPostsOptions = {}): Promise { + const { limit = 100, since, until } = options + + const params: Record = { + fields: [ + 'id', + 'message', + 'story', + 'full_picture', + 'permalink_url', + 'created_time', + 'updated_time', + 'shares', + 'reactions.summary(true)', + 'comments.summary(true)', + ].join(','), + } + + if (since) { + params.since = Math.floor(since.getTime() / 1000) + } + if (until) { + params.until = Math.floor(until.getTime() / 1000) + } + + // Temporär Token wechseln + const originalToken = this.accessToken + this.accessToken = this.pageAccessToken + + try { + return await this.requestPaginated(`${this.pageId}/posts`, { + params, + limit, + maxPages: 10, + }) + } finally { + this.accessToken = originalToken + } + } + + // =========================================================================== + // Comments + // =========================================================================== + + /** + * Holt alle Kommentare eines Posts + */ + async getPostComments( + postId: string, + options: FacebookCommentsOptions = {} + ): Promise { + const { limit = 50, order = 'reverse_time', filter = 'stream' } = options + + const response = await this.pageRequest>( + `${postId}/comments`, + { + params: { + fields: [ + 'id', + 'message', + 'from{id,name}', + 'created_time', + 'like_count', + 'comment_count', + 'is_hidden', + 'can_hide', + 'can_reply_privately', + 'parent{id}', + 'attachment{type,url,media}', + ].join(','), + order, + filter, + limit, + }, + } + ) + + return response.data + } + + /** + * Holt alle Kommentare eines Posts mit Pagination + */ + async getAllPostComments( + postId: string, + options: FacebookCommentsOptions = {} + ): Promise { + const { limit = 100, order = 'reverse_time', filter = 'stream' } = options + + const originalToken = this.accessToken + this.accessToken = this.pageAccessToken + + try { + return await this.requestPaginated(`${postId}/comments`, { + params: { + fields: [ + 'id', + 'message', + 'from{id,name}', + 'created_time', + 'like_count', + 'comment_count', + 'is_hidden', + 'can_hide', + 'parent{id}', + ].join(','), + order, + filter, + }, + limit, + maxPages: 10, + }) + } finally { + this.accessToken = originalToken + } + } + + /** + * Holt einen einzelnen Kommentar mit Details + */ + async getComment(commentId: string): Promise { + return this.pageRequest(commentId, { + params: { + fields: [ + 'id', + 'message', + 'from{id,name}', + 'created_time', + 'like_count', + 'comment_count', + 'is_hidden', + 'can_hide', + 'can_reply_privately', + 'parent{id}', + 'attachment{type,url,media}', + ].join(','), + }, + }) + } + + /** + * Antwortet auf einen Kommentar + */ + async replyToComment(commentId: string, message: string): Promise<{ id: string }> { + return this.pageRequest<{ id: string }>(`${commentId}/comments`, { + method: 'POST', + body: { message }, + }) + } + + /** + * Versteckt einen Kommentar + */ + async hideComment(commentId: string): Promise<{ success: boolean }> { + return this.pageRequest<{ success: boolean }>(commentId, { + method: 'POST', + body: { is_hidden: true }, + }) + } + + /** + * Zeigt einen versteckten Kommentar wieder an + */ + async unhideComment(commentId: string): Promise<{ success: boolean }> { + return this.pageRequest<{ success: boolean }>(commentId, { + method: 'POST', + body: { is_hidden: false }, + }) + } + + /** + * Löscht einen Kommentar + */ + async deleteComment(commentId: string): Promise<{ success: boolean }> { + return this.pageRequest<{ success: boolean }>(commentId, { + method: 'DELETE', + }) + } + + /** + * Liked einen Kommentar (als Page) + */ + async likeComment(commentId: string): Promise<{ success: boolean }> { + return this.pageRequest<{ success: boolean }>(`${commentId}/likes`, { + method: 'POST', + }) + } + + /** + * Entfernt Like von einem Kommentar + */ + async unlikeComment(commentId: string): Promise<{ success: boolean }> { + return this.pageRequest<{ success: boolean }>(`${commentId}/likes`, { + method: 'DELETE', + }) + } + + // =========================================================================== + // Conversations (Messenger) + // =========================================================================== + + /** + * Holt alle Conversations der Page + */ + async getConversations(limit: number = 25): Promise { + const response = await this.pageRequest>( + `${this.pageId}/conversations`, + { + params: { + fields: [ + 'id', + 'participants{id,name,email}', + 'updated_time', + 'message_count', + 'unread_count', + 'can_reply', + ].join(','), + limit, + }, + } + ) + + return response.data + } + + /** + * Holt Nachrichten einer Conversation + */ + async getConversationMessages( + conversationId: string, + limit: number = 25 + ): Promise { + const response = await this.pageRequest>( + `${conversationId}/messages`, + { + params: { + fields: [ + 'id', + 'message', + 'from{id,name,email}', + 'to{id,name}', + 'created_time', + 'attachments{id,mime_type,name,file_url,image_data}', + ].join(','), + limit, + }, + } + ) + + return response.data + } + + /** + * Sendet eine Nachricht in einer Conversation + */ + async sendMessage( + recipientId: string, + message: string + ): Promise<{ recipient_id: string; message_id: string }> { + return this.pageRequest<{ recipient_id: string; message_id: string }>( + `${this.pageId}/messages`, + { + method: 'POST', + body: { + recipient: { id: recipientId }, + message: { text: message }, + }, + } + ) + } + + // =========================================================================== + // Page Insights + // =========================================================================== + + /** + * Holt Page Insights/Analytics + */ + async getPageInsights(options: FacebookInsightsOptions): Promise { + const { metrics, period = 'day', since, until } = options + + const params: Record = { + metric: metrics.join(','), + period, + } + + if (since) { + params.since = Math.floor(since.getTime() / 1000) + } + if (until) { + params.until = Math.floor(until.getTime() / 1000) + } + + const response = await this.pageRequest<{ data: FacebookPageInsight[] }>( + `${this.pageId}/insights`, + { params } + ) + + return response.data + } + + /** + * Holt Standard Page Insights (Impressions, Reach, Engagement) + */ + async getStandardInsights( + since?: Date, + until?: Date + ): Promise<{ + impressions: number + reach: number + engagedUsers: number + newFans: number + }> { + const insights = await this.getPageInsights({ + metrics: [ + 'page_impressions', + 'page_post_engagements', + 'page_fan_adds', + 'page_engaged_users', + ], + period: 'day', + since, + until, + }) + + const getValue = (name: string): number => { + const insight = insights.find((i) => i.name === name) + if (!insight?.values?.[0]) return 0 + const value = insight.values[0].value + return typeof value === 'number' ? value : 0 + } + + return { + impressions: getValue('page_impressions'), + reach: getValue('page_post_engagements'), + engagedUsers: getValue('page_engaged_users'), + newFans: getValue('page_fan_adds'), + } + } + + // =========================================================================== + // Utility Methods + // =========================================================================== + + /** + * Prüft ob die Page verbunden ist und Zugriffsrechte hat + */ + async verifyPageAccess(): Promise<{ + isValid: boolean + pageName?: string + permissions?: string[] + }> { + try { + const page = await this.pageRequest(`${this.pageId}`, { + params: { + fields: 'id,name,tasks', + }, + }) + + return { + isValid: true, + pageName: page.name, + permissions: page.tasks, + } + } catch (error) { + if (error instanceof MetaApiError) { + return { + isValid: false, + } + } + throw error + } + } + + /** + * Holt alle ungelesenen Aktivitäten (Kommentare + Nachrichten) + */ + async getUnreadActivity(): Promise<{ + unreadComments: number + unreadMessages: number + }> { + // Ungelesene Conversations zählen + const conversations = await this.getConversations(100) + const unreadMessages = conversations.reduce((sum, conv) => sum + (conv.unread_count || 0), 0) + + // Für Kommentare gibt es keine direkte "unread" API + // Dies müsste über die Datenbank/Sync getracked werden + return { + unreadComments: 0, // Muss über eigenen Sync-Status getracked werden + unreadMessages, + } + } +} + +export default FacebookClient diff --git a/src/lib/integrations/meta/FacebookSyncService.ts b/src/lib/integrations/meta/FacebookSyncService.ts new file mode 100644 index 0000000..612dfc9 --- /dev/null +++ b/src/lib/integrations/meta/FacebookSyncService.ts @@ -0,0 +1,403 @@ +// src/lib/integrations/meta/FacebookSyncService.ts +// Facebook Comments Sync Service + +import type { Payload } from 'payload' +import { FacebookClient } from './FacebookClient' +import { ClaudeAnalysisService } from '../claude/ClaudeAnalysisService' +import type { FacebookPost, FacebookComment, MetaSyncResult, MetaSyncOptions } from '@/types/meta' + +// ============================================================================= +// Types +// ============================================================================= + +interface FacebookSyncOptions extends MetaSyncOptions { + /** Sync auch Kommentar-Replies */ + syncReplies?: boolean + /** Maximale Posts pro Sync */ + maxPosts?: number +} + +interface ProcessedComment { + isNew: boolean + interactionId: number +} + +// ============================================================================= +// Facebook Sync Service +// ============================================================================= + +export class FacebookSyncService { + private payload: Payload + private claudeService: ClaudeAnalysisService + private platformId: number | null = null + + constructor(payload: Payload) { + this.payload = payload + this.claudeService = new ClaudeAnalysisService() + } + + /** + * Holt die Platform ID für Facebook + */ + private async getPlatformId(): Promise { + if (this.platformId) return this.platformId + + const platform = await this.payload.find({ + collection: 'social-platforms', + where: { + or: [ + { slug: { equals: 'facebook' } }, + { 'apiConfig.apiType': { equals: 'facebook_graph' } }, + { 'apiConfig.apiType': { equals: 'meta_graph' } }, + ], + }, + limit: 1, + }) + + if (platform.docs[0]) { + this.platformId = platform.docs[0].id as number + } + + return this.platformId + } + + /** + * Hauptmethode: Synchronisiert Kommentare für einen Facebook Social Account + */ + async syncComments(options: FacebookSyncOptions): Promise { + const { + socialAccountId, + sinceDate, + maxItems = 100, + maxPosts = 25, + syncComments = true, + syncReplies = true, + analyzeWithAI = true, + } = options + + const startTime = Date.now() + const result: MetaSyncResult = { + accountId: socialAccountId, + accountName: '', + platform: 'facebook', + postsProcessed: 0, + newComments: 0, + updatedComments: 0, + newMessages: 0, + errors: [], + duration: 0, + } + + try { + // 1. Social Account laden + const account = await this.payload.findByID({ + collection: 'social-accounts', + id: socialAccountId, + depth: 2, + }) + + if (!account) { + result.errors.push('Social Account nicht gefunden') + return this.finalizeResult(result, startTime) + } + + result.accountName = (account.displayName as string) || `Account ${socialAccountId}` + + // 2. Platform prüfen + const platform = account.platform as { slug?: string; apiConfig?: { apiType?: string } } + const isMetaPlatform = + platform?.slug === 'facebook' || + platform?.apiConfig?.apiType === 'facebook_graph' || + platform?.apiConfig?.apiType === 'meta_graph' + + if (!isMetaPlatform) { + result.errors.push('Account ist kein Facebook-Account') + return this.finalizeResult(result, startTime) + } + + // 3. Credentials prüfen + const credentials = account.credentials as { + accessToken?: string + pageAccessToken?: string + } + + if (!credentials?.accessToken) { + result.errors.push('Keine gültigen API-Credentials') + return this.finalizeResult(result, startTime) + } + + // 4. Facebook Client initialisieren + const pageId = account.externalId as string + if (!pageId) { + result.errors.push('Keine Page ID gefunden') + return this.finalizeResult(result, startTime) + } + + const facebookClient = new FacebookClient({ + userAccessToken: credentials.accessToken, + pageId, + pageAccessToken: credentials.pageAccessToken, + }) + + // 5. Posts abrufen + const posts = await facebookClient.getPosts({ + limit: maxPosts, + since: sinceDate, + }) + + console.log(`[FacebookSync] Found ${posts.length} posts for account ${socialAccountId}`) + + // 6. Für jeden Post: Kommentare synchronisieren + if (syncComments) { + for (const post of posts) { + try { + const postResult = await this.syncPostComments({ + facebookClient, + post, + account, + maxComments: Math.ceil(maxItems / posts.length), + syncReplies, + analyzeWithAI, + }) + + result.postsProcessed++ + result.newComments += postResult.newComments + result.updatedComments += postResult.updatedComments + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + result.errors.push(`Post ${post.id}: ${errorMsg}`) + } + + // Rate Limiting zwischen Posts + await this.sleep(200) + } + } + + // 7. Account-Stats aktualisieren + await this.payload.update({ + collection: 'social-accounts', + id: socialAccountId, + data: { + stats: { + lastSyncedAt: new Date().toISOString(), + totalPosts: posts.length, + }, + }, + }) + + console.log( + `[FacebookSync] Completed: ${result.newComments} new, ${result.updatedComments} updated` + ) + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + result.errors.push(`Sync-Fehler: ${errorMsg}`) + console.error('[FacebookSync] Error:', error) + } + + return this.finalizeResult(result, startTime) + } + + /** + * Synchronisiert Kommentare eines einzelnen Posts + */ + private async syncPostComments(options: { + facebookClient: FacebookClient + post: FacebookPost + account: any + maxComments: number + syncReplies: boolean + analyzeWithAI: boolean + }): Promise<{ newComments: number; updatedComments: number }> { + const { facebookClient, post, account, maxComments, syncReplies, analyzeWithAI } = options + + let newComments = 0 + let updatedComments = 0 + + // Kommentare abrufen + const comments = await facebookClient.getPostComments(post.id, { + limit: maxComments, + filter: syncReplies ? 'stream' : 'toplevel', + order: 'reverse_time', + }) + + for (const comment of comments) { + try { + const processResult = await this.processComment({ + comment, + post, + account, + analyzeWithAI, + }) + + if (processResult.isNew) { + newComments++ + } else { + updatedComments++ + } + } catch (error) { + console.error(`[FacebookSync] Error processing comment ${comment.id}:`, error) + } + + // Rate Limiting + await this.sleep(50) + } + + return { newComments, updatedComments } + } + + /** + * Verarbeitet einen einzelnen Kommentar + */ + private async processComment(options: { + comment: FacebookComment + post: FacebookPost + account: any + analyzeWithAI: boolean + }): Promise { + const { comment, post, account, analyzeWithAI } = options + + // Prüfen ob Kommentar bereits existiert + const existing = await this.payload.find({ + collection: 'community-interactions', + where: { + externalId: { equals: comment.id }, + }, + limit: 1, + }) + + const isNew = existing.totalDocs === 0 + + // AI-Analyse durchführen + let analysis = null + if (analyzeWithAI && isNew) { + try { + analysis = await this.claudeService.analyzeComment(comment.message) + } catch (error) { + console.warn('[FacebookSync] AI analysis failed:', error) + } + } + + // Platform ID holen + const platformId = await this.getPlatformId() + + // Interaction-Typ bestimmen (comment oder reply) + const interactionType = comment.parent?.id ? 'reply' : 'comment' + + // Parent-Interaction finden (falls Reply) + let parentInteractionId = null + if (comment.parent?.id) { + const parent = await this.payload.find({ + collection: 'community-interactions', + where: { + externalId: { equals: comment.parent.id }, + }, + limit: 1, + }) + if (parent.docs[0]) { + parentInteractionId = parent.docs[0].id + } + } + + // Interaction-Daten zusammenstellen + const interactionData: Record = { + platform: platformId, + socialAccount: account.id, + type: interactionType, + externalId: comment.id, + parentInteraction: parentInteractionId, + author: { + name: comment.from?.name || 'Unknown', + handle: comment.from?.id, + profileUrl: comment.from?.id + ? `https://facebook.com/${comment.from.id}` + : undefined, + isVerified: false, + isSubscriber: false, + isMember: false, + }, + message: comment.message, + publishedAt: new Date(comment.created_time).toISOString(), + engagement: { + likes: comment.like_count || 0, + replies: comment.comment_count || 0, + isHearted: false, + isPinned: false, + }, + // Attachment wenn vorhanden + ...(comment.attachment && { + attachments: [ + { + type: comment.attachment.type === 'photo' ? 'image' : comment.attachment.type, + url: comment.attachment.url || comment.attachment.media?.image?.src, + }, + ], + }), + // AI-Analyse wenn verfügbar + ...(analysis && { + analysis: { + sentiment: analysis.sentiment, + sentimentScore: analysis.sentimentScore, + confidence: analysis.confidence, + topics: analysis.topics.map((t: string) => ({ topic: t })), + language: analysis.language, + suggestedReply: analysis.suggestedReply, + analyzedAt: new Date().toISOString(), + }, + flags: { + isMedicalQuestion: analysis.isMedicalQuestion, + requiresEscalation: analysis.requiresEscalation, + isSpam: analysis.isSpam, + isFromInfluencer: false, + }, + }), + // Interne Notizen mit Post-Kontext + internalNotes: `Facebook Post: ${post.permalink_url || post.id}`, + } + + let interactionId: number + + if (isNew) { + const created = await this.payload.create({ + collection: 'community-interactions', + data: interactionData, + }) + interactionId = created.id as number + } else { + // Update: Behalte existierende Workflow-Daten + const existingDoc = existing.docs[0] + await this.payload.update({ + collection: 'community-interactions', + id: existingDoc.id, + data: { + ...interactionData, + // Behalte Workflow-Status + status: existingDoc.status, + priority: existingDoc.priority, + assignedTo: existingDoc.assignedTo, + response: existingDoc.response, + internalNotes: existingDoc.internalNotes, + }, + }) + interactionId = existingDoc.id as number + } + + return { isNew, interactionId } + } + + /** + * Finalisiert das Ergebnis mit Zeitdauer + */ + private finalizeResult(result: MetaSyncResult, startTime: number): MetaSyncResult { + result.duration = Date.now() - startTime + return result + } + + /** + * Hilfsmethode: Sleep + */ + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) + } +} + +export default FacebookSyncService diff --git a/src/lib/integrations/meta/InstagramClient.ts b/src/lib/integrations/meta/InstagramClient.ts new file mode 100644 index 0000000..2316310 --- /dev/null +++ b/src/lib/integrations/meta/InstagramClient.ts @@ -0,0 +1,619 @@ +// src/lib/integrations/meta/InstagramClient.ts +// Instagram Client für Business Account Management + +import { MetaBaseClient, MetaApiError } from './MetaBaseClient' +import type { + InstagramBusinessAccount, + InstagramMedia, + InstagramComment, + InstagramMention, + InstagramStoryMention, + InstagramConversation, + InstagramMessage, + InstagramInsight, + MetaPaginatedResponse, +} from '@/types/meta' + +// ============================================================================= +// Types +// ============================================================================= + +export interface InstagramMediaOptions { + /** Maximale Anzahl Media */ + limit?: number + /** Nur bestimmte Media-Typen */ + mediaType?: 'IMAGE' | 'VIDEO' | 'CAROUSEL_ALBUM' | 'REELS' + /** Nur Media seit diesem Datum */ + since?: Date +} + +export interface InstagramCommentsOptions { + /** Maximale Anzahl Kommentare */ + limit?: number +} + +export interface InstagramInsightsOptions { + /** Metriken für Account Insights */ + metrics: string[] + /** Zeitraum */ + period?: 'day' | 'week' | 'days_28' | 'lifetime' + /** Startdatum */ + since?: Date + /** Enddatum */ + until?: Date +} + +export interface InstagramMediaInsightsOptions { + /** Metriken für Media Insights */ + metrics: string[] +} + +// ============================================================================= +// Instagram Client +// ============================================================================= + +export class InstagramClient extends MetaBaseClient { + private instagramAccountId: string + private pageAccessToken: string + + constructor(options: { + /** Page Access Token (mit Instagram-Berechtigung) */ + pageAccessToken: string + /** Instagram Business Account ID */ + instagramAccountId: string + }) { + super(options.pageAccessToken) + this.instagramAccountId = options.instagramAccountId + this.pageAccessToken = options.pageAccessToken + } + + // =========================================================================== + // Account Info + // =========================================================================== + + /** + * Holt Informationen über den Instagram Business Account + */ + async getAccountInfo(): Promise { + return this.request(this.instagramAccountId, { + params: { + fields: [ + 'id', + 'username', + 'name', + 'profile_picture_url', + 'followers_count', + 'follows_count', + 'media_count', + 'biography', + 'website', + ].join(','), + }, + }) + } + + // =========================================================================== + // Media + // =========================================================================== + + /** + * Holt alle Media (Posts) des Accounts + */ + async getMedia(options: InstagramMediaOptions = {}): Promise { + const { limit = 25 } = options + + const response = await this.request>( + `${this.instagramAccountId}/media`, + { + params: { + fields: [ + 'id', + 'media_type', + 'media_url', + 'thumbnail_url', + 'permalink', + 'caption', + 'timestamp', + 'like_count', + 'comments_count', + 'children{id,media_type,media_url}', + ].join(','), + limit, + }, + } + ) + + return response.data + } + + /** + * Holt alle Media mit Pagination + */ + async getAllMedia(options: InstagramMediaOptions = {}): Promise { + const { limit = 50 } = options + + return this.requestPaginated(`${this.instagramAccountId}/media`, { + params: { + fields: [ + 'id', + 'media_type', + 'media_url', + 'thumbnail_url', + 'permalink', + 'caption', + 'timestamp', + 'like_count', + 'comments_count', + ].join(','), + }, + limit, + maxPages: 10, + }) + } + + /** + * Holt ein einzelnes Media mit Details + */ + async getMediaById(mediaId: string): Promise { + return this.request(mediaId, { + params: { + fields: [ + 'id', + 'media_type', + 'media_url', + 'thumbnail_url', + 'permalink', + 'caption', + 'timestamp', + 'like_count', + 'comments_count', + 'children{id,media_type,media_url}', + ].join(','), + }, + }) + } + + // =========================================================================== + // Comments + // =========================================================================== + + /** + * Holt alle Kommentare eines Media + */ + async getMediaComments( + mediaId: string, + options: InstagramCommentsOptions = {} + ): Promise { + const { limit = 50 } = options + + const response = await this.request>( + `${mediaId}/comments`, + { + params: { + fields: [ + 'id', + 'text', + 'username', + 'timestamp', + 'like_count', + 'replies{id,text,username,timestamp,like_count}', + 'from{id,username}', + 'hidden', + ].join(','), + limit, + }, + } + ) + + return response.data + } + + /** + * Holt alle Kommentare mit Pagination + */ + async getAllMediaComments( + mediaId: string, + options: InstagramCommentsOptions = {} + ): Promise { + const { limit = 100 } = options + + return this.requestPaginated(`${mediaId}/comments`, { + params: { + fields: [ + 'id', + 'text', + 'username', + 'timestamp', + 'like_count', + 'from{id,username}', + 'hidden', + ].join(','), + }, + limit, + maxPages: 5, + }) + } + + /** + * Holt einen einzelnen Kommentar + */ + async getComment(commentId: string): Promise { + return this.request(commentId, { + params: { + fields: [ + 'id', + 'text', + 'username', + 'timestamp', + 'like_count', + 'replies{id,text,username,timestamp,like_count}', + 'from{id,username}', + 'hidden', + ].join(','), + }, + }) + } + + /** + * Antwortet auf einen Kommentar + */ + async replyToComment(commentId: string, message: string): Promise<{ id: string }> { + return this.request<{ id: string }>(`${commentId}/replies`, { + method: 'POST', + body: { message }, + }) + } + + /** + * Versteckt einen Kommentar + */ + async hideComment(commentId: string): Promise<{ success: boolean }> { + return this.request<{ success: boolean }>(commentId, { + method: 'POST', + body: { hide: true }, + }) + } + + /** + * Zeigt einen versteckten Kommentar wieder an + */ + async unhideComment(commentId: string): Promise<{ success: boolean }> { + return this.request<{ success: boolean }>(commentId, { + method: 'POST', + body: { hide: false }, + }) + } + + /** + * Löscht einen Kommentar + */ + async deleteComment(commentId: string): Promise<{ success: boolean }> { + return this.request<{ success: boolean }>(commentId, { + method: 'DELETE', + }) + } + + // =========================================================================== + // Mentions + // =========================================================================== + + /** + * Holt alle Mentions (Posts in denen der Account getaggt wurde) + */ + async getMentions(limit: number = 25): Promise { + try { + const response = await this.request>( + `${this.instagramAccountId}/tags`, + { + params: { + fields: [ + 'id', + 'caption', + 'media_type', + 'media_url', + 'permalink', + 'timestamp', + 'username', + 'comments_count', + 'like_count', + ].join(','), + limit, + }, + } + ) + + return response.data + } catch (error) { + // Tags-Endpoint benötigt spezielle Berechtigung + if (error instanceof MetaApiError && error.isPermissionError()) { + console.warn('[InstagramClient] No permission for tags endpoint') + return [] + } + throw error + } + } + + /** + * Holt Story-Mentions (Stories in denen der Account getaggt wurde) + * WICHTIG: Stories verschwinden nach 24h! + */ + async getStoryMentions(limit: number = 25): Promise { + try { + const response = await this.request>( + `${this.instagramAccountId}/stories`, + { + params: { + fields: ['id', 'media_type', 'media_url', 'timestamp'].join(','), + limit, + }, + } + ) + + return response.data + } catch (error) { + // Stories-Endpoint benötigt spezielle Berechtigung + if (error instanceof MetaApiError && error.isPermissionError()) { + console.warn('[InstagramClient] No permission for stories endpoint') + return [] + } + throw error + } + } + + // =========================================================================== + // Conversations (Instagram Direct) + // =========================================================================== + + /** + * Holt alle Instagram Direct Conversations + * Benötigt instagram_manage_messages Berechtigung + */ + async getConversations(limit: number = 25): Promise { + try { + const response = await this.request>( + `${this.instagramAccountId}/conversations`, + { + params: { + fields: [ + 'id', + 'participants{id,username}', + 'updated_time', + 'messages{id,message,from,created_time}', + ].join(','), + limit, + }, + } + ) + + return response.data + } catch (error) { + if (error instanceof MetaApiError && error.isPermissionError()) { + console.warn('[InstagramClient] No permission for conversations endpoint') + return [] + } + throw error + } + } + + /** + * Holt Nachrichten einer Conversation + */ + async getConversationMessages( + conversationId: string, + limit: number = 25 + ): Promise { + const response = await this.request>( + `${conversationId}/messages`, + { + params: { + fields: [ + 'id', + 'message', + 'from{id,username}', + 'created_time', + 'attachments{type,url}', + ].join(','), + limit, + }, + } + ) + + return response.data + } + + /** + * Sendet eine Nachricht + * Benötigt instagram_manage_messages Berechtigung + */ + async sendMessage( + recipientId: string, + message: string + ): Promise<{ recipient_id: string; message_id: string }> { + return this.request<{ recipient_id: string; message_id: string }>( + `${this.instagramAccountId}/messages`, + { + method: 'POST', + body: { + recipient: { id: recipientId }, + message: { text: message }, + }, + } + ) + } + + // =========================================================================== + // Insights + // =========================================================================== + + /** + * Holt Account Insights + */ + async getAccountInsights(options: InstagramInsightsOptions): Promise { + const { metrics, period = 'day', since, until } = options + + const params: Record = { + metric: metrics.join(','), + period, + } + + if (since) { + params.since = Math.floor(since.getTime() / 1000) + } + if (until) { + params.until = Math.floor(until.getTime() / 1000) + } + + const response = await this.request<{ data: InstagramInsight[] }>( + `${this.instagramAccountId}/insights`, + { params } + ) + + return response.data + } + + /** + * Holt Standard Account Insights (Impressions, Reach, Profile Views) + */ + async getStandardInsights( + since?: Date, + until?: Date + ): Promise<{ + impressions: number + reach: number + profileViews: number + websiteClicks: number + }> { + const insights = await this.getAccountInsights({ + metrics: ['impressions', 'reach', 'profile_views', 'website_clicks'], + period: 'day', + since, + until, + }) + + const getValue = (name: string): number => { + const insight = insights.find((i) => i.name === name) + if (!insight?.values?.[0]) return 0 + return insight.values[0].value + } + + return { + impressions: getValue('impressions'), + reach: getValue('reach'), + profileViews: getValue('profile_views'), + websiteClicks: getValue('website_clicks'), + } + } + + /** + * Holt Insights für ein einzelnes Media + */ + async getMediaInsights( + mediaId: string, + options: InstagramMediaInsightsOptions + ): Promise { + const { metrics } = options + + const response = await this.request<{ data: InstagramInsight[] }>(`${mediaId}/insights`, { + params: { + metric: metrics.join(','), + }, + }) + + return response.data + } + + /** + * Holt Standard Media Insights (Impressions, Reach, Engagement) + */ + async getStandardMediaInsights(mediaId: string): Promise<{ + impressions: number + reach: number + engagement: number + saved: number + }> { + try { + const insights = await this.getMediaInsights(mediaId, { + metrics: ['impressions', 'reach', 'engagement', 'saved'], + }) + + const getValue = (name: string): number => { + const insight = insights.find((i) => i.name === name) + if (!insight?.values?.[0]) return 0 + return insight.values[0].value + } + + return { + impressions: getValue('impressions'), + reach: getValue('reach'), + engagement: getValue('engagement'), + saved: getValue('saved'), + } + } catch (error) { + // Media Insights sind nicht für alle Media-Typen verfügbar + if (error instanceof MetaApiError) { + return { impressions: 0, reach: 0, engagement: 0, saved: 0 } + } + throw error + } + } + + // =========================================================================== + // Utility Methods + // =========================================================================== + + /** + * Prüft ob der Account verbunden ist und Zugriffsrechte hat + */ + async verifyAccountAccess(): Promise<{ + isValid: boolean + username?: string + followersCount?: number + }> { + try { + const account = await this.getAccountInfo() + + return { + isValid: true, + username: account.username, + followersCount: account.followers_count, + } + } catch (error) { + if (error instanceof MetaApiError) { + return { isValid: false } + } + throw error + } + } + + /** + * Holt zusammengefasste Statistiken für den Account + */ + async getAccountStats(): Promise<{ + followers: number + following: number + mediaCount: number + recentEngagement: number + }> { + const account = await this.getAccountInfo() + + // Berechne Engagement der letzten Posts + let recentEngagement = 0 + try { + const recentMedia = await this.getMedia({ limit: 10 }) + recentEngagement = recentMedia.reduce((sum, media) => { + return sum + (media.like_count || 0) + (media.comments_count || 0) + }, 0) + } catch { + // Engagement-Berechnung optional + } + + return { + followers: account.followers_count, + following: account.follows_count, + mediaCount: account.media_count, + recentEngagement, + } + } +} + +export default InstagramClient diff --git a/src/lib/integrations/meta/InstagramSyncService.ts b/src/lib/integrations/meta/InstagramSyncService.ts new file mode 100644 index 0000000..6d0424d --- /dev/null +++ b/src/lib/integrations/meta/InstagramSyncService.ts @@ -0,0 +1,557 @@ +// src/lib/integrations/meta/InstagramSyncService.ts +// Instagram Comments & Mentions Sync Service + +import type { Payload } from 'payload' +import { InstagramClient } from './InstagramClient' +import { ClaudeAnalysisService } from '../claude/ClaudeAnalysisService' +import type { + InstagramMedia, + InstagramComment, + InstagramMention, + MetaSyncResult, + MetaSyncOptions, +} from '@/types/meta' + +// ============================================================================= +// Types +// ============================================================================= + +interface InstagramSyncOptions extends MetaSyncOptions { + /** Sync auch Mentions (wo Account getaggt wurde) */ + syncMentions?: boolean + /** Maximale Media pro Sync */ + maxMedia?: number +} + +interface ProcessedComment { + isNew: boolean + interactionId: number +} + +// ============================================================================= +// Instagram Sync Service +// ============================================================================= + +export class InstagramSyncService { + private payload: Payload + private claudeService: ClaudeAnalysisService + private platformId: number | null = null + + constructor(payload: Payload) { + this.payload = payload + this.claudeService = new ClaudeAnalysisService() + } + + /** + * Holt die Platform ID für Instagram + */ + private async getPlatformId(): Promise { + if (this.platformId) return this.platformId + + const platform = await this.payload.find({ + collection: 'social-platforms', + where: { + or: [ + { slug: { equals: 'instagram' } }, + { 'apiConfig.apiType': { equals: 'instagram_graph' } }, + ], + }, + limit: 1, + }) + + if (platform.docs[0]) { + this.platformId = platform.docs[0].id as number + } + + return this.platformId + } + + /** + * Hauptmethode: Synchronisiert Kommentare für einen Instagram Account + */ + async syncComments(options: InstagramSyncOptions): Promise { + const { + socialAccountId, + sinceDate, + maxItems = 100, + maxMedia = 25, + syncComments = true, + syncMentions = true, + analyzeWithAI = true, + } = options + + const startTime = Date.now() + const result: MetaSyncResult = { + accountId: socialAccountId, + accountName: '', + platform: 'instagram', + postsProcessed: 0, + newComments: 0, + updatedComments: 0, + newMessages: 0, + errors: [], + duration: 0, + } + + try { + // 1. Social Account laden + const account = await this.payload.findByID({ + collection: 'social-accounts', + id: socialAccountId, + depth: 2, + }) + + if (!account) { + result.errors.push('Social Account nicht gefunden') + return this.finalizeResult(result, startTime) + } + + result.accountName = (account.displayName as string) || `Account ${socialAccountId}` + + // 2. Platform prüfen + const platform = account.platform as { slug?: string; apiConfig?: { apiType?: string } } + const isInstagramPlatform = + platform?.slug === 'instagram' || + platform?.apiConfig?.apiType === 'instagram_graph' + + if (!isInstagramPlatform) { + result.errors.push('Account ist kein Instagram-Account') + return this.finalizeResult(result, startTime) + } + + // 3. Credentials prüfen + const credentials = account.credentials as { + accessToken?: string + } + + if (!credentials?.accessToken) { + result.errors.push('Keine gültigen API-Credentials') + return this.finalizeResult(result, startTime) + } + + // 4. Instagram Account ID prüfen + const instagramAccountId = account.externalId as string + if (!instagramAccountId) { + result.errors.push('Keine Instagram Account ID gefunden') + return this.finalizeResult(result, startTime) + } + + // 5. Instagram Client initialisieren + const instagramClient = new InstagramClient({ + pageAccessToken: credentials.accessToken, + instagramAccountId, + }) + + // 6. Media (Posts) abrufen + const media = await instagramClient.getMedia({ limit: maxMedia }) + console.log(`[InstagramSync] Found ${media.length} media for account ${socialAccountId}`) + + // 7. Für jedes Media: Kommentare synchronisieren + if (syncComments) { + for (const mediaItem of media) { + // Filter nach Datum wenn angegeben + if (sinceDate && new Date(mediaItem.timestamp) < sinceDate) { + continue + } + + try { + const mediaResult = await this.syncMediaComments({ + instagramClient, + media: mediaItem, + account, + maxComments: Math.ceil(maxItems / media.length), + analyzeWithAI, + }) + + result.postsProcessed++ + result.newComments += mediaResult.newComments + result.updatedComments += mediaResult.updatedComments + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + result.errors.push(`Media ${mediaItem.id}: ${errorMsg}`) + } + + // Rate Limiting + await this.sleep(200) + } + } + + // 8. Mentions synchronisieren + if (syncMentions) { + try { + const mentionsResult = await this.syncMentions({ + instagramClient, + account, + analyzeWithAI, + }) + result.newComments += mentionsResult.newMentions + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + result.errors.push(`Mentions: ${errorMsg}`) + } + } + + // 9. Account-Stats aktualisieren + try { + const stats = await instagramClient.getAccountStats() + await this.payload.update({ + collection: 'social-accounts', + id: socialAccountId, + data: { + stats: { + followers: stats.followers, + totalPosts: stats.mediaCount, + lastSyncedAt: new Date().toISOString(), + }, + }, + }) + } catch (error) { + console.warn('[InstagramSync] Could not update stats:', error) + } + + console.log( + `[InstagramSync] Completed: ${result.newComments} new, ${result.updatedComments} updated` + ) + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + result.errors.push(`Sync-Fehler: ${errorMsg}`) + console.error('[InstagramSync] Error:', error) + } + + return this.finalizeResult(result, startTime) + } + + /** + * Synchronisiert Kommentare eines einzelnen Media + */ + private async syncMediaComments(options: { + instagramClient: InstagramClient + media: InstagramMedia + account: any + maxComments: number + analyzeWithAI: boolean + }): Promise<{ newComments: number; updatedComments: number }> { + const { instagramClient, media, account, maxComments, analyzeWithAI } = options + + let newComments = 0 + let updatedComments = 0 + + // Kommentare abrufen + const comments = await instagramClient.getMediaComments(media.id, { + limit: maxComments, + }) + + for (const comment of comments) { + try { + const processResult = await this.processComment({ + comment, + media, + account, + analyzeWithAI, + }) + + if (processResult.isNew) { + newComments++ + } else { + updatedComments++ + } + + // Auch Replies verarbeiten + if (comment.replies?.data) { + for (const reply of comment.replies.data) { + try { + const replyResult = await this.processComment({ + comment: reply, + media, + account, + analyzeWithAI, + parentCommentId: comment.id, + }) + + if (replyResult.isNew) { + newComments++ + } else { + updatedComments++ + } + } catch (error) { + console.error(`[InstagramSync] Error processing reply ${reply.id}:`, error) + } + } + } + } catch (error) { + console.error(`[InstagramSync] Error processing comment ${comment.id}:`, error) + } + + // Rate Limiting + await this.sleep(50) + } + + return { newComments, updatedComments } + } + + /** + * Verarbeitet einen einzelnen Kommentar + */ + private async processComment(options: { + comment: InstagramComment + media: InstagramMedia + account: any + analyzeWithAI: boolean + parentCommentId?: string + }): Promise { + const { comment, media, account, analyzeWithAI, parentCommentId } = options + + // Prüfen ob Kommentar bereits existiert + const existing = await this.payload.find({ + collection: 'community-interactions', + where: { + externalId: { equals: comment.id }, + }, + limit: 1, + }) + + const isNew = existing.totalDocs === 0 + + // AI-Analyse durchführen + let analysis = null + if (analyzeWithAI && isNew) { + try { + analysis = await this.claudeService.analyzeComment(comment.text) + } catch (error) { + console.warn('[InstagramSync] AI analysis failed:', error) + } + } + + // Platform ID holen + const platformId = await this.getPlatformId() + + // Interaction-Typ bestimmen + const interactionType = parentCommentId ? 'reply' : 'comment' + + // Parent-Interaction finden (falls Reply) + let parentInteractionId = null + if (parentCommentId) { + const parent = await this.payload.find({ + collection: 'community-interactions', + where: { + externalId: { equals: parentCommentId }, + }, + limit: 1, + }) + if (parent.docs[0]) { + parentInteractionId = parent.docs[0].id + } + } + + // Interaction-Daten zusammenstellen + const interactionData: Record = { + platform: platformId, + socialAccount: account.id, + type: interactionType, + externalId: comment.id, + parentInteraction: parentInteractionId, + author: { + name: comment.username, + handle: `@${comment.username}`, + profileUrl: `https://instagram.com/${comment.username}`, + isVerified: false, + isSubscriber: false, + isMember: false, + }, + message: comment.text, + publishedAt: new Date(comment.timestamp).toISOString(), + engagement: { + likes: comment.like_count || 0, + replies: comment.replies?.data?.length || 0, + isHearted: false, + isPinned: false, + }, + // AI-Analyse wenn verfügbar + ...(analysis && { + analysis: { + sentiment: analysis.sentiment, + sentimentScore: analysis.sentimentScore, + confidence: analysis.confidence, + topics: analysis.topics.map((t: string) => ({ topic: t })), + language: analysis.language, + suggestedReply: analysis.suggestedReply, + analyzedAt: new Date().toISOString(), + }, + flags: { + isMedicalQuestion: analysis.isMedicalQuestion, + requiresEscalation: analysis.requiresEscalation, + isSpam: analysis.isSpam, + isFromInfluencer: false, + }, + }), + // Interne Notizen mit Media-Kontext + internalNotes: `Instagram Post: ${media.permalink}`, + } + + let interactionId: number + + if (isNew) { + const created = await this.payload.create({ + collection: 'community-interactions', + data: interactionData, + }) + interactionId = created.id as number + } else { + // Update: Behalte existierende Workflow-Daten + const existingDoc = existing.docs[0] + await this.payload.update({ + collection: 'community-interactions', + id: existingDoc.id, + data: { + ...interactionData, + // Behalte Workflow-Status + status: existingDoc.status, + priority: existingDoc.priority, + assignedTo: existingDoc.assignedTo, + response: existingDoc.response, + internalNotes: existingDoc.internalNotes, + }, + }) + interactionId = existingDoc.id as number + } + + return { isNew, interactionId } + } + + /** + * Synchronisiert Mentions (Posts wo Account getaggt wurde) + */ + private async syncMentions(options: { + instagramClient: InstagramClient + account: any + analyzeWithAI: boolean + }): Promise<{ newMentions: number }> { + const { instagramClient, account, analyzeWithAI } = options + + let newMentions = 0 + + try { + const mentions = await instagramClient.getMentions(25) + + for (const mention of mentions) { + try { + const result = await this.processMention({ + mention, + account, + analyzeWithAI, + }) + + if (result.isNew) { + newMentions++ + } + } catch (error) { + console.error(`[InstagramSync] Error processing mention ${mention.id}:`, error) + } + + await this.sleep(100) + } + } catch (error) { + console.warn('[InstagramSync] Could not sync mentions:', error) + } + + return { newMentions } + } + + /** + * Verarbeitet eine Mention + */ + private async processMention(options: { + mention: InstagramMention + account: any + analyzeWithAI: boolean + }): Promise<{ isNew: boolean }> { + const { mention, account, analyzeWithAI } = options + + // Prüfen ob Mention bereits existiert + const existing = await this.payload.find({ + collection: 'community-interactions', + where: { + externalId: { equals: `mention_${mention.id}` }, + }, + limit: 1, + }) + + const isNew = existing.totalDocs === 0 + + if (!isNew) { + return { isNew: false } + } + + // AI-Analyse für Caption + let analysis = null + if (analyzeWithAI && mention.caption) { + try { + analysis = await this.claudeService.analyzeComment(mention.caption) + } catch (error) { + console.warn('[InstagramSync] AI analysis failed:', error) + } + } + + // Platform ID holen + const platformId = await this.getPlatformId() + + // Mention als Interaction speichern + const interactionData: Record = { + platform: platformId, + socialAccount: account.id, + type: 'mention', + externalId: `mention_${mention.id}`, + author: { + name: mention.username, + handle: `@${mention.username}`, + profileUrl: `https://instagram.com/${mention.username}`, + isVerified: false, + isSubscriber: false, + isMember: false, + }, + message: mention.caption || `[Erwähnung in ${mention.media_type}]`, + publishedAt: new Date(mention.timestamp).toISOString(), + engagement: { + likes: mention.like_count || 0, + replies: mention.comments_count || 0, + isHearted: false, + isPinned: false, + }, + ...(analysis && { + analysis: { + sentiment: analysis.sentiment, + sentimentScore: analysis.sentimentScore, + confidence: analysis.confidence, + topics: analysis.topics.map((t: string) => ({ topic: t })), + language: analysis.language, + analyzedAt: new Date().toISOString(), + }, + }), + internalNotes: `Instagram Mention: ${mention.permalink}`, + } + + await this.payload.create({ + collection: 'community-interactions', + data: interactionData, + }) + + return { isNew: true } + } + + /** + * Finalisiert das Ergebnis mit Zeitdauer + */ + private finalizeResult(result: MetaSyncResult, startTime: number): MetaSyncResult { + result.duration = Date.now() - startTime + return result + } + + /** + * Hilfsmethode: Sleep + */ + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) + } +} + +export default InstagramSyncService diff --git a/src/lib/integrations/meta/MetaBaseClient.ts b/src/lib/integrations/meta/MetaBaseClient.ts new file mode 100644 index 0000000..d74e4ce --- /dev/null +++ b/src/lib/integrations/meta/MetaBaseClient.ts @@ -0,0 +1,307 @@ +// src/lib/integrations/meta/MetaBaseClient.ts +// Base Client für Meta Graph API (Facebook + Instagram) + +import type { + MetaUser, + FacebookPage, + InstagramBusinessAccount, + MetaPaginatedResponse, + MetaErrorResponse, +} from '@/types/meta' +import { META_CONFIG } from './oauth' + +// ============================================================================= +// Error Types +// ============================================================================= + +export class MetaApiError extends Error { + code: number + subcode?: number + type: string + fbtraceId: string + + constructor(error: MetaErrorResponse['error']) { + super(error.message) + this.name = 'MetaApiError' + this.code = error.code + this.subcode = error.error_subcode + this.type = error.type + this.fbtraceId = error.fbtrace_id + } + + isRateLimitError(): boolean { + return this.code === 4 || this.code === 17 || this.code === 32 + } + + isAuthError(): boolean { + return this.code === 190 || this.code === 102 + } + + isPermissionError(): boolean { + return this.code === 10 || this.code === 200 || this.code === 230 + } +} + +// ============================================================================= +// Base Client +// ============================================================================= + +export class MetaBaseClient { + protected accessToken: string + protected apiVersion: string + protected baseUrl: string + + constructor(accessToken: string) { + this.accessToken = accessToken + this.apiVersion = META_CONFIG.apiVersion + this.baseUrl = META_CONFIG.baseUrl + } + + // =========================================================================== + // Core Request Method + // =========================================================================== + + protected async request( + endpoint: string, + options: { + method?: 'GET' | 'POST' | 'DELETE' + params?: Record + body?: Record + } = {} + ): Promise { + const { method = 'GET', params = {}, body } = options + + // Build URL with params + const url = new URL(`${this.baseUrl}/${this.apiVersion}/${endpoint}`) + + // Add access token + url.searchParams.set('access_token', this.accessToken) + + // Add other params + for (const [key, value] of Object.entries(params)) { + if (value !== undefined) { + url.searchParams.set(key, String(value)) + } + } + + // Build request options + const fetchOptions: RequestInit = { + method, + headers: { + 'Content-Type': 'application/json', + }, + } + + if (body && method !== 'GET') { + fetchOptions.body = JSON.stringify(body) + } + + // Execute request + const response = await fetch(url.toString(), fetchOptions) + const data = await response.json() + + // Handle errors + if (!response.ok || data.error) { + const error = data.error || { + message: 'Unknown error', + type: 'UnknownError', + code: response.status, + fbtrace_id: 'unknown', + } + throw new MetaApiError(error) + } + + return data as T + } + + // =========================================================================== + // Paginated Request Helper + // =========================================================================== + + protected async requestPaginated( + endpoint: string, + options: { + params?: Record + limit?: number + maxPages?: number + } = {} + ): Promise { + const { params = {}, limit = 100, maxPages = 10 } = options + const allItems: T[] = [] + let pageCount = 0 + let nextUrl: string | undefined + + // Initial request + const initialParams = { ...params, limit } + let response = await this.request>(endpoint, { + params: initialParams, + }) + + allItems.push(...response.data) + nextUrl = response.paging?.next + pageCount++ + + // Fetch additional pages + while (nextUrl && pageCount < maxPages) { + const nextResponse = await fetch(nextUrl) + const nextData: MetaPaginatedResponse = await nextResponse.json() + + if (nextData.error) { + throw new MetaApiError((nextData as unknown as MetaErrorResponse).error) + } + + allItems.push(...nextData.data) + nextUrl = nextData.paging?.next + pageCount++ + + // Rate limiting + await this.sleep(100) + } + + return allItems + } + + // =========================================================================== + // User & Account Methods + // =========================================================================== + + /** + * Hole Informationen über den authentifizierten User + */ + async getMe(): Promise { + return this.request('me', { + params: { + fields: 'id,name,email', + }, + }) + } + + /** + * Hole alle Facebook Pages des Users + */ + async getPages(): Promise { + const response = await this.request>( + 'me/accounts', + { + params: { + fields: + 'id,name,access_token,category,category_list,tasks,instagram_business_account', + }, + } + ) + return response.data + } + + /** + * Hole Instagram Business Account für eine Page + */ + async getInstagramAccount(pageId: string): Promise { + try { + const response = await this.request<{ + instagram_business_account?: InstagramBusinessAccount + }>(`${pageId}`, { + params: { + fields: + 'instagram_business_account{id,username,name,profile_picture_url,followers_count,follows_count,media_count,biography,website}', + }, + }) + + return response.instagram_business_account || null + } catch (error) { + if (error instanceof MetaApiError && error.isPermissionError()) { + return null + } + throw error + } + } + + /** + * Hole alle Instagram Business Accounts des Users (über alle Pages) + */ + async getAllInstagramAccounts(): Promise< + Array<{ + page: FacebookPage + instagram: InstagramBusinessAccount + }> + > { + const pages = await this.getPages() + const results: Array<{ + page: FacebookPage + instagram: InstagramBusinessAccount + }> = [] + + for (const page of pages) { + if (page.instagram_business_account?.id) { + const instagram = await this.getInstagramAccount(page.id) + if (instagram) { + results.push({ page, instagram }) + } + } + + // Rate limiting + await this.sleep(100) + } + + return results + } + + // =========================================================================== + // Token Management + // =========================================================================== + + /** + * Aktualisiert den Access Token + */ + setAccessToken(token: string): void { + this.accessToken = token + } + + /** + * Gibt den aktuellen Access Token zurück + */ + getAccessToken(): string { + return this.accessToken + } + + // =========================================================================== + // Utility Methods + // =========================================================================== + + protected sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) + } + + /** + * Rate Limiting Helper - wartet bei Rate Limit Errors + */ + protected async withRetry( + fn: () => Promise, + maxRetries: number = 3, + initialDelay: number = 1000 + ): Promise { + let lastError: Error | undefined + let delay = initialDelay + + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + return await fn() + } catch (error) { + lastError = error as Error + + if (error instanceof MetaApiError && error.isRateLimitError()) { + console.warn( + `[MetaClient] Rate limit hit, waiting ${delay}ms before retry ${attempt + 1}/${maxRetries}` + ) + await this.sleep(delay) + delay *= 2 // Exponential backoff + } else { + throw error + } + } + } + + throw lastError + } +} + +export default MetaBaseClient diff --git a/src/lib/integrations/meta/index.ts b/src/lib/integrations/meta/index.ts new file mode 100644 index 0000000..bd0b2dc --- /dev/null +++ b/src/lib/integrations/meta/index.ts @@ -0,0 +1,45 @@ +// src/lib/integrations/meta/index.ts +// Meta (Facebook + Instagram) Integration Module + +// OAuth & Configuration +export * from './oauth' + +// Clients +export { MetaBaseClient, MetaApiError } from './MetaBaseClient' +export { FacebookClient } from './FacebookClient' +export { InstagramClient } from './InstagramClient' + +// Sync Services +export { FacebookSyncService } from './FacebookSyncService' +export { InstagramSyncService } from './InstagramSyncService' + +// Re-export types +export type { + // OAuth Types + MetaTokens, + MetaLongLivedToken, + MetaTokenDebugInfo, + MetaOAuthState, + MetaUser, + // Facebook Types + FacebookPage, + FacebookPost, + FacebookComment, + FacebookConversation, + FacebookMessage, + FacebookPageInsight, + // Instagram Types + InstagramBusinessAccount, + InstagramMedia, + InstagramComment, + InstagramMention, + InstagramStoryMention, + InstagramConversation, + InstagramMessage, + InstagramInsight, + // Common Types + MetaSyncResult, + MetaSyncOptions, + MetaPaginatedResponse, + MetaErrorResponse, +} from '@/types/meta' diff --git a/src/lib/integrations/meta/oauth.ts b/src/lib/integrations/meta/oauth.ts new file mode 100644 index 0000000..1ec035f --- /dev/null +++ b/src/lib/integrations/meta/oauth.ts @@ -0,0 +1,260 @@ +// src/lib/integrations/meta/oauth.ts +// Meta OAuth 2.0 Implementation für Facebook und Instagram + +import type { + MetaTokens, + MetaLongLivedToken, + MetaTokenDebugInfo, + MetaOAuthState, +} from '@/types/meta' + +// ============================================================================= +// Configuration +// ============================================================================= + +const META_API_VERSION = 'v19.0' +const META_BASE_URL = 'https://graph.facebook.com' +const META_AUTH_URL = 'https://www.facebook.com' + +// Scopes für Facebook + Instagram +const META_SCOPES = [ + // Facebook Page Management + 'pages_show_list', + 'pages_read_engagement', + 'pages_manage_posts', + 'pages_read_user_content', + 'pages_manage_engagement', + 'pages_messaging', + + // Instagram + 'instagram_basic', + 'instagram_manage_comments', + 'instagram_manage_messages', + 'instagram_content_publish', + + // Business + 'business_management', + + // User Info + 'public_profile', + 'email', +] + +// ============================================================================= +// Helper Functions +// ============================================================================= + +function getConfig() { + const appId = process.env.META_APP_ID + const appSecret = process.env.META_APP_SECRET + const redirectUri = process.env.META_REDIRECT_URI + + if (!appId || !appSecret || !redirectUri) { + throw new Error( + 'Missing Meta OAuth configuration. Please set META_APP_ID, META_APP_SECRET, and META_REDIRECT_URI environment variables.' + ) + } + + return { appId, appSecret, redirectUri } +} + +function encodeState(state: MetaOAuthState): string { + return Buffer.from(JSON.stringify(state)).toString('base64url') +} + +function decodeState(encoded: string): MetaOAuthState { + try { + return JSON.parse(Buffer.from(encoded, 'base64url').toString('utf-8')) + } catch { + throw new Error('Invalid OAuth state') + } +} + +// ============================================================================= +// OAuth Functions +// ============================================================================= + +/** + * Generiert die Authorization URL für den Meta OAuth Flow + */ +export function getAuthUrl(options: { + returnUrl?: string + accountType?: 'facebook' | 'instagram' | 'both' + userId?: number + socialAccountId?: number +}): string { + const config = getConfig() + const { returnUrl = '/admin', accountType = 'both', userId, socialAccountId } = options + + const state: MetaOAuthState = { + returnUrl, + accountType, + userId, + socialAccountId, + timestamp: Date.now(), + } + + const params = new URLSearchParams({ + client_id: config.appId, + redirect_uri: config.redirectUri, + scope: META_SCOPES.join(','), + response_type: 'code', + state: encodeState(state), + }) + + return `${META_AUTH_URL}/${META_API_VERSION}/dialog/oauth?${params.toString()}` +} + +/** + * Tauscht den Authorization Code gegen Access Token + */ +export async function exchangeCodeForTokens(code: string): Promise { + const config = getConfig() + + const params = new URLSearchParams({ + client_id: config.appId, + client_secret: config.appSecret, + redirect_uri: config.redirectUri, + code, + }) + + const response = await fetch( + `${META_BASE_URL}/${META_API_VERSION}/oauth/access_token?${params.toString()}` + ) + + if (!response.ok) { + const error = await response.json() + console.error('[Meta OAuth] Token exchange failed:', error) + throw new Error(error.error?.message || 'Failed to exchange code for tokens') + } + + return response.json() +} + +/** + * Wandelt Short-lived Token in Long-lived Token um (60 Tage gültig) + */ +export async function getLongLivedToken( + shortLivedToken: string +): Promise { + const config = getConfig() + + const params = new URLSearchParams({ + grant_type: 'fb_exchange_token', + client_id: config.appId, + client_secret: config.appSecret, + fb_exchange_token: shortLivedToken, + }) + + const response = await fetch( + `${META_BASE_URL}/${META_API_VERSION}/oauth/access_token?${params.toString()}` + ) + + if (!response.ok) { + const error = await response.json() + console.error('[Meta OAuth] Long-lived token exchange failed:', error) + throw new Error(error.error?.message || 'Failed to get long-lived token') + } + + return response.json() +} + +/** + * Refresht einen Long-lived Token (vor Ablauf) + * Nur möglich wenn Token noch gültig ist! + */ +export async function refreshLongLivedToken( + token: string +): Promise { + const config = getConfig() + + const params = new URLSearchParams({ + grant_type: 'fb_exchange_token', + client_id: config.appId, + client_secret: config.appSecret, + fb_exchange_token: token, + }) + + const response = await fetch( + `${META_BASE_URL}/${META_API_VERSION}/oauth/access_token?${params.toString()}` + ) + + if (!response.ok) { + const error = await response.json() + console.error('[Meta OAuth] Token refresh failed:', error) + throw new Error(error.error?.message || 'Failed to refresh token') + } + + return response.json() +} + +/** + * Validiert einen Token und gibt Debug-Informationen zurück + */ +export async function debugToken(token: string): Promise { + const config = getConfig() + + const params = new URLSearchParams({ + input_token: token, + access_token: `${config.appId}|${config.appSecret}`, + }) + + const response = await fetch( + `${META_BASE_URL}/${META_API_VERSION}/debug_token?${params.toString()}` + ) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.error?.message || 'Failed to debug token') + } + + const result = await response.json() + return result.data +} + +/** + * Prüft ob ein Token noch gültig ist + */ +export async function isTokenValid(token: string): Promise { + try { + const debug = await debugToken(token) + return debug.is_valid && debug.expires_at > Math.floor(Date.now() / 1000) + } catch { + return false + } +} + +/** + * Berechnet das Ablaufdatum eines Tokens + */ +export function calculateTokenExpiry(expiresIn: number): Date { + return new Date(Date.now() + expiresIn * 1000) +} + +/** + * Prüft ob Token bald abläuft (innerhalb der nächsten X Tage) + */ +export function isTokenExpiringSoon(expiresAt: Date, daysThreshold: number = 7): boolean { + const threshold = new Date() + threshold.setDate(threshold.getDate() + daysThreshold) + return expiresAt <= threshold +} + +// ============================================================================= +// State Management +// ============================================================================= + +export { encodeState, decodeState } + +// ============================================================================= +// Constants Export +// ============================================================================= + +export const META_CONFIG = { + apiVersion: META_API_VERSION, + baseUrl: META_BASE_URL, + authUrl: META_AUTH_URL, + scopes: META_SCOPES, + tokenValidityDays: 60, + refreshThresholdDays: 7, +} diff --git a/src/types/meta.ts b/src/types/meta.ts new file mode 100644 index 0000000..84040cf --- /dev/null +++ b/src/types/meta.ts @@ -0,0 +1,367 @@ +// src/types/meta.ts +// Type Definitions für Meta Graph API (Facebook + Instagram) + +// ============================================================================= +// OAuth & Auth Types +// ============================================================================= + +export interface MetaTokens { + access_token: string + token_type: string + expires_in?: number +} + +export interface MetaLongLivedToken { + access_token: string + token_type: string + expires_in: number // Typischerweise 5184000 (60 Tage) +} + +export interface MetaTokenDebugInfo { + app_id: string + type: string + application: string + data_access_expires_at: number + expires_at: number + is_valid: boolean + scopes: string[] + user_id: string +} + +// ============================================================================= +// User & Account Types +// ============================================================================= + +export interface MetaUser { + id: string + name: string + email?: string +} + +export interface FacebookPage { + id: string + name: string + access_token: string // Page-spezifischer Token + category: string + category_list?: Array<{ id: string; name: string }> + tasks?: string[] + instagram_business_account?: { + id: string + username?: string + } +} + +export interface InstagramBusinessAccount { + id: string + username: string + name?: string + profile_picture_url?: string + followers_count: number + follows_count: number + media_count: number + biography?: string + website?: string +} + +// ============================================================================= +// Facebook Types +// ============================================================================= + +export interface FacebookPost { + id: string + message?: string + story?: string + full_picture?: string + permalink_url: string + created_time: string + updated_time?: string + shares?: { count: number } + reactions?: { summary: { total_count: number } } + comments?: { summary: { total_count: number } } + attachments?: { + data: Array<{ + type: string + url?: string + media?: { image: { src: string } } + }> + } +} + +export interface FacebookComment { + id: string + message: string + from: { + id: string + name: string + } + created_time: string + like_count: number + comment_count: number + is_hidden: boolean + can_hide: boolean + can_reply_privately: boolean + parent?: { + id: string + } + attachment?: { + type: string + url?: string + media?: { image: { src: string } } + } +} + +export interface FacebookConversation { + id: string + participants: { + data: Array<{ + id: string + name: string + email?: string + }> + } + updated_time: string + message_count: number + unread_count: number + can_reply: boolean +} + +export interface FacebookMessage { + id: string + message: string + from: { + id: string + name: string + email?: string + } + to: { + data: Array<{ + id: string + name: string + }> + } + created_time: string + attachments?: { + data: Array<{ + id: string + mime_type: string + name: string + file_url?: string + image_data?: { + url: string + width: number + height: number + } + }> + } +} + +export interface FacebookPageInsight { + name: string + period: string + values: Array<{ + value: number | Record + end_time: string + }> + title: string + description: string + id: string +} + +// ============================================================================= +// Instagram Types +// ============================================================================= + +export interface InstagramMedia { + id: string + media_type: 'IMAGE' | 'VIDEO' | 'CAROUSEL_ALBUM' | 'REELS' + media_url?: string + thumbnail_url?: string + permalink: string + caption?: string + timestamp: string + like_count: number + comments_count: number + children?: { + data: Array<{ + id: string + media_type: string + media_url: string + }> + } +} + +export interface InstagramComment { + id: string + text: string + username: string + timestamp: string + like_count: number + replies?: { + data: InstagramComment[] + } + from?: { + id: string + username: string + } + hidden: boolean +} + +export interface InstagramMention { + id: string + caption?: string + media_type: string + media_url?: string + permalink: string + timestamp: string + username: string + comments_count?: number + like_count?: number +} + +export interface InstagramStoryMention { + id: string + media_type: 'IMAGE' | 'VIDEO' + media_url: string + timestamp: string + // Story mentions expire after 24h! +} + +export interface InstagramConversation { + id: string + participants: { + data: Array<{ + id: string + username: string + }> + } + updated_time: string + messages?: { + data: InstagramMessage[] + } +} + +export interface InstagramMessage { + id: string + message: string + from: { + id: string + username: string + } + created_time: string + attachments?: { + data: Array<{ + type: string + url: string + }> + } +} + +export interface InstagramInsight { + name: string + period: string + values: Array<{ + value: number + end_time?: string + }> + title: string + description: string + id: string +} + +// ============================================================================= +// API Response Types +// ============================================================================= + +export interface MetaPaginatedResponse { + data: T[] + paging?: { + cursors?: { + before: string + after: string + } + next?: string + previous?: string + } + summary?: { + total_count?: number + } +} + +export interface MetaErrorResponse { + error: { + message: string + type: string + code: number + error_subcode?: number + fbtrace_id: string + } +} + +// ============================================================================= +// Sync Types +// ============================================================================= + +export interface MetaSyncResult { + accountId: number + accountName: string + platform: 'facebook' | 'instagram' + postsProcessed: number + newComments: number + updatedComments: number + newMessages: number + errors: string[] + duration: number +} + +export interface MetaSyncOptions { + socialAccountId: number + sinceDate?: Date + maxItems?: number + syncComments?: boolean + syncMessages?: boolean + syncMentions?: boolean + analyzeWithAI?: boolean +} + +// ============================================================================= +// OAuth State Types +// ============================================================================= + +export interface MetaOAuthState { + returnUrl: string + accountType: 'facebook' | 'instagram' | 'both' + userId?: number + socialAccountId?: number + timestamp: number +} + +// ============================================================================= +// Webhook Types (für zukünftige Implementierung) +// ============================================================================= + +export interface MetaWebhookEntry { + id: string + time: number + changes?: Array<{ + field: string + value: Record + }> + messaging?: Array<{ + sender: { id: string } + recipient: { id: string } + timestamp: number + message?: { + mid: string + text?: string + attachments?: Array<{ + type: string + payload: { url: string } + }> + } + }> +} + +export interface MetaWebhookPayload { + object: 'page' | 'instagram' + entry: MetaWebhookEntry[] +}