cms.c2sgmbh/src/lib/integrations/meta/MetaBaseClient.ts

307 lines
8 KiB
TypeScript

// 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 }
const 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 ('error' in (nextData as unknown as MetaErrorResponse)) {
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