import { Telnyx } from "telnyx"; import { createHmac } from "crypto"; export interface SmsProvider { sendSms(to: string, body: string, mediaUrls?: string[]): Promise<{ messageId: string; status: string }>; validateWebhookSignature(req: Request): boolean; } interface TelnyxSmsResult { message_id: string; status: string; } function createTelnyxClient(): Telnyx | null { const apiKey = process.env.TELNYX_API_KEY; if (!apiKey) return null; return new Telnyx(apiKey); } let _client: Telnyx | null | undefined; function getClient(): Telnyx | null { if (_client === undefined) _client = createTelnyxClient(); return _client; } function getFromNumber(): string | null { return process.env.TELNYX_FROM_NUMBER ?? null; } function isE164(phone: string): boolean { return /^\+[1-9]\d{7,14}$/.test(phone); } export function validateTelnyxSignature( rawBody: string, signature: string | undefined | null ): boolean { if (!signature) return false; const secret = process.env.TELNYX_WEBHOOK_SECRET; if (!secret) return false; try { const hmac = createHmac("sha256", secret); const expected = `sha256=${hmac.update(rawBody).digest("hex")}`; const sigBuf = Buffer.from(signature); const expBuf = Buffer.from(expected); if (sigBuf.length !== expBuf.length) return false; let diff = 0; for (let i = 0; i < sigBuf.length; i++) { const sigByte = sigBuf[i] ?? 0; const expByte = expBuf[i] ?? 0; diff |= sigByte ^ expByte; } return diff === 0; } catch { return false; } } export async function sendSms( to: string, body: string, mediaUrls?: string[] ): Promise<{ messageId: string; status: string }> { const client = getClient(); if (!client) throw new Error("Telnyx client not initialized. Set TELNYX_API_KEY."); const from = getFromNumber(); if (!from) throw new Error("TELNYX_FROM_NUMBER is not set"); if (!isE164(to)) throw new Error(`Invalid recipient phone format: ${to}. Expected E.164.`); if (!isE164(from)) throw new Error(`Invalid sender phone format: ${from}. Expected E.164.`); const payload: Record = { from, to, body, }; if (mediaUrls && mediaUrls.length > 0) { payload.media_urls = mediaUrls; } const result = await client.messages.create(payload as Record); const smsResult = result.data as unknown as TelnyxSmsResult; return { messageId: smsResult.message_id, status: smsResult.status, }; } export class TelnyxProvider implements SmsProvider { async sendSms( to: string, body: string, mediaUrls?: string[] ): Promise<{ messageId: string; status: string }> { return sendSms(to, body, mediaUrls); } validateWebhookSignature(req: Request): boolean { return validateTelnyxSignature(JSON.stringify(req.body), req.headers.get("telnyx-signature")); } } let _provider: SmsProvider | null | undefined; export function createSmsProvider(): SmsProvider | null { if (_provider === undefined) { if (process.env.SMS_ENABLED !== "true") { _provider = null; return null; } switch (process.env.SMS_PROVIDER) { case "telnyx": { const client = getClient(); if (!client) { _provider = null; return null; } _provider = new TelnyxProvider(); break; } default: _provider = null; } } return _provider; } export async function smsSend( to: string, body: string, mediaUrls?: string[] ): Promise { const provider = createSmsProvider(); if (!provider) return false; await provider.sendSms(to, body, mediaUrls); return true; }