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 <noreply@anthropic.com>
This commit is contained in:
Martin Porwoll 2026-01-16 21:28:40 +00:00
parent 797f1a9100
commit b107d60183
12 changed files with 3493 additions and 0 deletions

View file

@ -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

View file

@ -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<string, unknown>
} = {
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)}`
)
}
}

View file

@ -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)}`
)
}
}

View file

@ -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',
},
},
],
},

View file

@ -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<string> {
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<T>(
endpoint: string,
options: {
method?: 'GET' | 'POST' | 'DELETE'
params?: Record<string, string | number | boolean | undefined>
body?: Record<string, unknown>
} = {}
): Promise<T> {
// Temporär den Access Token auf Page Token setzen
const originalToken = this.accessToken
this.accessToken = this.pageAccessToken
try {
return await this.request<T>(endpoint, options)
} finally {
this.accessToken = originalToken
}
}
// ===========================================================================
// Posts
// ===========================================================================
/**
* Holt alle Posts einer Page
*/
async getPosts(options: FacebookPostsOptions = {}): Promise<FacebookPost[]> {
const { limit = 25, since, until } = options
const params: Record<string, string | number | boolean | undefined> = {
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<MetaPaginatedResponse<FacebookPost>>(
`${this.pageId}/posts`,
{ params }
)
return response.data
}
/**
* Holt einen einzelnen Post mit allen Details
*/
async getPost(postId: string): Promise<FacebookPost> {
return this.pageRequest<FacebookPost>(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<FacebookPost[]> {
const { limit = 100, since, until } = options
const params: Record<string, string | number | boolean | undefined> = {
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<FacebookPost>(`${this.pageId}/posts`, {
params,
limit,
maxPages: 10,
})
} finally {
this.accessToken = originalToken
}
}
// ===========================================================================
// Comments
// ===========================================================================
/**
* Holt alle Kommentare eines Posts
*/
async getPostComments(
postId: string,
options: FacebookCommentsOptions = {}
): Promise<FacebookComment[]> {
const { limit = 50, order = 'reverse_time', filter = 'stream' } = options
const response = await this.pageRequest<MetaPaginatedResponse<FacebookComment>>(
`${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<FacebookComment[]> {
const { limit = 100, order = 'reverse_time', filter = 'stream' } = options
const originalToken = this.accessToken
this.accessToken = this.pageAccessToken
try {
return await this.requestPaginated<FacebookComment>(`${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<FacebookComment> {
return this.pageRequest<FacebookComment>(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<FacebookConversation[]> {
const response = await this.pageRequest<MetaPaginatedResponse<FacebookConversation>>(
`${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<FacebookMessage[]> {
const response = await this.pageRequest<MetaPaginatedResponse<FacebookMessage>>(
`${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<FacebookPageInsight[]> {
const { metrics, period = 'day', since, until } = options
const params: Record<string, string | number | boolean | undefined> = {
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<FacebookPage>(`${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

View file

@ -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<number | null> {
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<MetaSyncResult> {
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<ProcessedComment> {
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<string, unknown> = {
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<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
}
export default FacebookSyncService

View file

@ -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<InstagramBusinessAccount> {
return this.request<InstagramBusinessAccount>(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<InstagramMedia[]> {
const { limit = 25 } = options
const response = await this.request<MetaPaginatedResponse<InstagramMedia>>(
`${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<InstagramMedia[]> {
const { limit = 50 } = options
return this.requestPaginated<InstagramMedia>(`${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<InstagramMedia> {
return this.request<InstagramMedia>(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<InstagramComment[]> {
const { limit = 50 } = options
const response = await this.request<MetaPaginatedResponse<InstagramComment>>(
`${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<InstagramComment[]> {
const { limit = 100 } = options
return this.requestPaginated<InstagramComment>(`${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<InstagramComment> {
return this.request<InstagramComment>(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<InstagramMention[]> {
try {
const response = await this.request<MetaPaginatedResponse<InstagramMention>>(
`${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<InstagramStoryMention[]> {
try {
const response = await this.request<MetaPaginatedResponse<InstagramStoryMention>>(
`${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<InstagramConversation[]> {
try {
const response = await this.request<MetaPaginatedResponse<InstagramConversation>>(
`${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<InstagramMessage[]> {
const response = await this.request<MetaPaginatedResponse<InstagramMessage>>(
`${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<InstagramInsight[]> {
const { metrics, period = 'day', since, until } = options
const params: Record<string, string | number | boolean | undefined> = {
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<InstagramInsight[]> {
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

View file

@ -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<number | null> {
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<MetaSyncResult> {
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<ProcessedComment> {
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<string, unknown> = {
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<string, unknown> = {
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<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
}
export default InstagramSyncService

View file

@ -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<T>(
endpoint: string,
options: {
method?: 'GET' | 'POST' | 'DELETE'
params?: Record<string, string | number | boolean | undefined>
body?: Record<string, unknown>
} = {}
): Promise<T> {
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<T>(
endpoint: string,
options: {
params?: Record<string, string | number | boolean | undefined>
limit?: number
maxPages?: number
} = {}
): Promise<T[]> {
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<MetaPaginatedResponse<T>>(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<T> = 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<MetaUser> {
return this.request<MetaUser>('me', {
params: {
fields: 'id,name,email',
},
})
}
/**
* Hole alle Facebook Pages des Users
*/
async getPages(): Promise<FacebookPage[]> {
const response = await this.request<MetaPaginatedResponse<FacebookPage>>(
'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<InstagramBusinessAccount | null> {
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<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
/**
* Rate Limiting Helper - wartet bei Rate Limit Errors
*/
protected async withRetry<T>(
fn: () => Promise<T>,
maxRetries: number = 3,
initialDelay: number = 1000
): Promise<T> {
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

View file

@ -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'

View file

@ -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<MetaTokens> {
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<MetaLongLivedToken> {
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<MetaLongLivedToken> {
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<MetaTokenDebugInfo> {
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<boolean> {
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,
}

367
src/types/meta.ts Normal file
View file

@ -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<string, number>
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<T> {
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<string, unknown>
}>
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[]
}