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
|
SEND_EMAIL_ALLOWED_IPS= # Optional: Komma-separierte IPs/CIDRs
|
||||||
ADMIN_ALLOWED_IPS= # Optional: IP-Beschränkung für Admin-Panel
|
ADMIN_ALLOWED_IPS= # Optional: IP-Beschränkung für Admin-Panel
|
||||||
BLOCKED_IPS= # Optional: Global geblockte IPs
|
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
|
> **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: 'LinkedIn API', value: 'linkedin' },
|
||||||
{ label: 'Instagram Graph API', value: 'instagram_graph' },
|
{ label: 'Instagram Graph API', value: 'instagram_graph' },
|
||||||
{ label: 'Facebook Graph API', value: 'facebook_graph' },
|
{ label: 'Facebook Graph API', value: 'facebook_graph' },
|
||||||
|
{ label: 'Meta Graph API (FB + IG)', value: 'meta_graph' },
|
||||||
{ label: 'Custom/Webhook', value: 'custom' },
|
{ label: 'Custom/Webhook', value: 'custom' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
@ -130,6 +131,16 @@ export const SocialPlatforms: CollectionConfig = {
|
||||||
{ label: 'Bearer Token', value: 'bearer' },
|
{ 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',
|
name: 'scopes',
|
||||||
type: 'array',
|
type: 'array',
|
||||||
|
|
@ -139,6 +150,16 @@ export const SocialPlatforms: CollectionConfig = {
|
||||||
},
|
},
|
||||||
fields: [{ name: 'scope', type: 'text', label: 'Scope' }],
|
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