cms.c2sgmbh/src/lib/integrations/meta/FacebookClient.ts
Martin Porwoll b107d60183 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>
2026-01-16 21:28:40 +00:00

579 lines
15 KiB
TypeScript

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