mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-18 01:34:11 +00:00
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>
579 lines
15 KiB
TypeScript
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
|