import type { WhatsAppBotMessageResponse, WhatsAppBotTemplate } from './types' import { WhatsAppBotError } from './types' const BASE_URL = 'https://graph.facebook.com/v23.0' const MAX_RETRIES = 3 const BASE_BACKOFF_MS = 100 export class WhatsAppBotClient { private phoneNumberId: string | null = null private accessToken: string | null = null private rateLimitRemaining: number | null = null private rateLimitResetAt: number = 0 async login(credentials?: { phoneNumberId: string; accessToken: string }): Promise { if (credentials) { if (!credentials.phoneNumberId) { throw new WhatsAppBotError('Phone number ID is required', 'missing_phone_number_id') } if (!credentials.accessToken) { throw new WhatsAppBotError('Access token is required', 'missing_access_token') } this.phoneNumberId = credentials.phoneNumberId this.accessToken = credentials.accessToken return this } const { WhatsAppBotCredentialManager } = await import('./credential-manager') const credManager = new WhatsAppBotCredentialManager() const creds = await credManager.getCredentials() if (!creds) { throw new WhatsAppBotError( 'No WhatsApp Bot credentials found. Run "agent-whatsappbot auth set" first.', 'no_credentials', ) } return this.login({ phoneNumberId: creds.phone_number_id, accessToken: creds.access_token }) } private ensureAuth(): void { if (this.phoneNumberId === null) { throw new WhatsAppBotError('Not authenticated. Call .login() first.', 'not_authenticated') } } async verifyToken(): Promise<{ verified_name: string }> { return this.request<{ verified_name: string }>('GET', `/${this.phoneNumberId!}?fields=verified_name`) } async sendTextMessage(to: string, text: string, previewUrl?: boolean): Promise { return this.request('POST', `/${this.phoneNumberId!}/messages`, { messaging_product: 'whatsapp', to, type: 'text', text: { body: text, preview_url: previewUrl ?? false }, }) } async sendTemplateMessage( to: string, templateName: string, languageCode: string, components?: unknown[], ): Promise { return this.request('POST', `/${this.phoneNumberId}/messages`, { messaging_product: 'whatsapp', to, type: 'template', template: { name: templateName, language: { code: languageCode }, components }, }) } async sendReaction(to: string, messageId: string, emoji: string): Promise { return this.request('POST', `/${this.phoneNumberId}/messages`, { messaging_product: 'whatsapp', to, type: 'reaction', reaction: { message_id: messageId, emoji }, }) } async sendImageMessage(to: string, imageUrl: string, caption?: string): Promise { return this.request('POST', `/${this.phoneNumberId}/messages`, { messaging_product: 'whatsapp', to, type: 'image', image: { link: imageUrl, caption }, }) } async sendDocumentMessage( to: string, documentUrl: string, filename?: string, caption?: string, ): Promise { return this.request('POST', `/${this.phoneNumberId}/messages`, { messaging_product: 'whatsapp', to, type: 'document', document: { link: documentUrl, filename, caption }, }) } async listTemplates(params?: { limit?: number }): Promise { return this.request( 'GET', this.buildPath(`/${this.phoneNumberId}/message_templates`, params), undefined, 'data', ) } async getTemplate(templateName: string): Promise { const templates = await this.request( 'GET', `/${this.phoneNumberId}/message_templates?name=${encodeURIComponent(templateName)}`, undefined, 'data', ) const template = templates[0] if (!template) { throw new WhatsAppBotError(`Template "${templateName}" not found`, 'not_found') } return template } private getHeaders(): Record { return { Authorization: `Bearer ${this.accessToken}`, 'Content-Type': 'application/json', } } private async waitForRateLimit(): Promise { const now = Date.now() if (this.rateLimitRemaining === 0 && this.rateLimitResetAt > now) { await this.sleep(this.rateLimitResetAt - now) } } private updateRateLimit(response: Response): void { const usageHeader = response.headers.get('x-business-use-case-usage') if (usageHeader) { try { const usage = JSON.parse(usageHeader) as Record< string, Array<{ call_count: number total_cputime: number total_time: number type: string estimated_time_to_regain_access: number }> > for (const entries of Object.values(usage)) { for (const entry of entries) { if (entry.call_count >= 100) { this.rateLimitRemaining = 0 this.rateLimitResetAt = Date.now() + (entry.estimated_time_to_regain_access ?? 60) * 1000 } } } } catch { // ignore parse errors } } } private async request(method: string, path: string, body?: unknown, unwrapKey?: string): Promise { this.ensureAuth() const url = `${BASE_URL}${path}` let lastError: Error | undefined for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { await this.waitForRateLimit() const options: RequestInit = { method, headers: this.getHeaders(), } if (body !== undefined) { options.body = JSON.stringify(body) } let response: Response try { response = await fetch(url, options) } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)) if (attempt < MAX_RETRIES && method === 'GET') { await this.sleep(BASE_BACKOFF_MS * 2 ** attempt) continue } throw new WhatsAppBotError(`Network error: ${lastError.message}`, 'network_error') } this.updateRateLimit(response) if (response.status === 429) { if (attempt < MAX_RETRIES) { const retryAfter = Number.parseFloat(response.headers.get('Retry-After') || '1') const retryAfterMs = (Number.isNaN(retryAfter) ? 1 : retryAfter) * 1000 await this.sleep(retryAfterMs) continue } throw new WhatsAppBotError('Rate limited', 'rate_limited') } if (response.status >= 500 && response.status <= 599) { if (attempt < MAX_RETRIES && method === 'GET') { await this.sleep(BASE_BACKOFF_MS * 2 ** attempt) continue } const errorBody = (await response.json().catch(() => ({}))) as { error?: { message?: string; code?: number } } const msg = errorBody.error?.message || `HTTP ${response.status}` const code = errorBody.error?.code ? String(errorBody.error.code) : `http_${response.status}` throw new WhatsAppBotError(msg, code) } if (!response.ok) { const errorBody = (await response.json().catch(() => ({}))) as { error?: { message?: string; code?: number } } const msg = errorBody.error?.message || `HTTP ${response.status}` const code = errorBody.error?.code ? String(errorBody.error.code) : `http_${response.status}` throw new WhatsAppBotError(msg, code) } if (response.status === 204) { return undefined as T } const data = await response.json() if (unwrapKey && data != null && typeof data === 'object' && unwrapKey in data) { return (data as Record)[unwrapKey] as T } return data as T } throw lastError || new WhatsAppBotError('Request failed after retries', 'max_retries') } private buildPath(path: string, params?: Record): string { if (!params) { return path } const searchParams = new URLSearchParams() for (const [key, value] of Object.entries(params)) { if (value !== undefined) { searchParams.set(key, String(value)) } } const query = searchParams.toString() if (!query) { return path } return `${path}?${query}` } private sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) } }