mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 19:44:12 +00:00
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:
parent
797f1a9100
commit
b107d60183
12 changed files with 3493 additions and 0 deletions
13
CLAUDE.md
13
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
|
||||
|
|
|
|||
221
src/app/(payload)/api/auth/meta/callback/route.ts
Normal file
221
src/app/(payload)/api/auth/meta/callback/route.ts
Normal 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)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
101
src/app/(payload)/api/auth/meta/route.ts
Normal file
101
src/app/(payload)/api/auth/meta/route.ts
Normal 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)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
|
|
|
|||
579
src/lib/integrations/meta/FacebookClient.ts
Normal file
579
src/lib/integrations/meta/FacebookClient.ts
Normal 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
|
||||
403
src/lib/integrations/meta/FacebookSyncService.ts
Normal file
403
src/lib/integrations/meta/FacebookSyncService.ts
Normal 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
|
||||
619
src/lib/integrations/meta/InstagramClient.ts
Normal file
619
src/lib/integrations/meta/InstagramClient.ts
Normal 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
|
||||
557
src/lib/integrations/meta/InstagramSyncService.ts
Normal file
557
src/lib/integrations/meta/InstagramSyncService.ts
Normal 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
|
||||
307
src/lib/integrations/meta/MetaBaseClient.ts
Normal file
307
src/lib/integrations/meta/MetaBaseClient.ts
Normal 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
|
||||
45
src/lib/integrations/meta/index.ts
Normal file
45
src/lib/integrations/meta/index.ts
Normal 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'
|
||||
260
src/lib/integrations/meta/oauth.ts
Normal file
260
src/lib/integrations/meta/oauth.ts
Normal 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
367
src/types/meta.ts
Normal 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[]
|
||||
}
|
||||
Loading…
Reference in a new issue