From c71abf13a1afe1666b8106769ba58a620ed13b7f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 25 Nov 2025 02:20:35 +0100 Subject: [PATCH] Extract env + Twilio utils; shrink index --- src/env.ts | 106 +++++++++++++++++++++++++++++++++++++ src/index.ts | 121 ++----------------------------------------- src/twilio/client.ts | 14 +++++ src/twilio/utils.ts | 37 +++++++++++++ 4 files changed, 160 insertions(+), 118 deletions(-) create mode 100644 src/env.ts create mode 100644 src/twilio/client.ts create mode 100644 src/twilio/utils.ts diff --git a/src/env.ts b/src/env.ts new file mode 100644 index 000000000..f8a8bfd7b --- /dev/null +++ b/src/env.ts @@ -0,0 +1,106 @@ +import { z } from "zod"; + +import { danger } from "./globals.js"; +import { defaultRuntime, type RuntimeEnv } from "./runtime.js"; + +export type AuthMode = + | { accountSid: string; authToken: string } + | { accountSid: string; apiKey: string; apiSecret: string }; + +export type EnvConfig = { + accountSid: string; + whatsappFrom: string; + whatsappSenderSid?: string; + auth: AuthMode; +}; + +const EnvSchema = z + .object({ + TWILIO_ACCOUNT_SID: z.string().min(1, "TWILIO_ACCOUNT_SID required"), + TWILIO_WHATSAPP_FROM: z.string().min(1, "TWILIO_WHATSAPP_FROM required"), + TWILIO_SENDER_SID: z.string().optional(), + TWILIO_AUTH_TOKEN: z.string().optional(), + TWILIO_API_KEY: z.string().optional(), + TWILIO_API_SECRET: z.string().optional(), + }) + .superRefine((val, ctx) => { + if (val.TWILIO_API_KEY && !val.TWILIO_API_SECRET) { + ctx.addIssue({ + code: "custom", + message: "TWILIO_API_SECRET required when TWILIO_API_KEY is set", + }); + } + if (val.TWILIO_API_SECRET && !val.TWILIO_API_KEY) { + ctx.addIssue({ + code: "custom", + message: "TWILIO_API_KEY required when TWILIO_API_SECRET is set", + }); + } + if ( + !val.TWILIO_AUTH_TOKEN && + !(val.TWILIO_API_KEY && val.TWILIO_API_SECRET) + ) { + ctx.addIssue({ + code: "custom", + message: + "Provide TWILIO_AUTH_TOKEN or both TWILIO_API_KEY and TWILIO_API_SECRET", + }); + } + }); + +export function readEnv(runtime: RuntimeEnv = defaultRuntime): EnvConfig { + // Load and validate Twilio auth + sender configuration from env. + const parsed = EnvSchema.safeParse(process.env); + if (!parsed.success) { + runtime.error("Invalid environment configuration:"); + parsed.error.issues.forEach((iss) => { + runtime.error(`- ${iss.message}`); + }); + runtime.exit(1); + } + + const { + TWILIO_ACCOUNT_SID: accountSid, + TWILIO_WHATSAPP_FROM: whatsappFrom, + TWILIO_SENDER_SID: whatsappSenderSid, + TWILIO_AUTH_TOKEN: authToken, + TWILIO_API_KEY: apiKey, + TWILIO_API_SECRET: apiSecret, + } = parsed.data; + + let auth: AuthMode; + if (apiKey && apiSecret) { + auth = { accountSid, apiKey, apiSecret }; + } else if (authToken) { + auth = { accountSid, authToken }; + } else { + runtime.error("Missing Twilio auth configuration"); + runtime.exit(1); + throw new Error("unreachable"); + } + + return { + accountSid, + whatsappFrom, + whatsappSenderSid, + auth, + }; +} + +export function ensureTwilioEnv(runtime: RuntimeEnv = defaultRuntime) { + // Guardrails: fail fast when Twilio env vars are missing or incomplete. + const required = ["TWILIO_ACCOUNT_SID", "TWILIO_WHATSAPP_FROM"]; + const missing = required.filter((k) => !process.env[k]); + const hasToken = Boolean(process.env.TWILIO_AUTH_TOKEN); + const hasKey = Boolean( + process.env.TWILIO_API_KEY && process.env.TWILIO_API_SECRET, + ); + if (missing.length > 0 || (!hasToken && !hasKey)) { + runtime.error( + danger( + `Missing Twilio env: ${missing.join(", ") || "auth token or api key/secret"}. Set them in .env before using provider=twilio.`, + ), + ); + runtime.exit(1); + } +} diff --git a/src/index.ts b/src/index.ts index 3790bfeb7..04de4dae0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,7 +16,6 @@ import express, { type Request, type Response } from "express"; import JSON5 from "json5"; import Twilio from "twilio"; import type { MessageInstance } from "twilio/lib/rest/api/v2010/account/message.js"; -import { z } from "zod"; import { runCommandWithTimeout, runExec, @@ -28,6 +27,9 @@ import { autoReplyIfConfigured, getReplyFromConfig, } from "./auto-reply/reply.js"; +import { readEnv, ensureTwilioEnv, type EnvConfig } from "./env.js"; +import { createClient } from "./twilio/client.js"; +import { logTwilioSendError, formatTwilioError } from "./twilio/utils.js"; import { CLAUDE_BIN, parseClaudeJsonText } from "./auto-reply/claude.js"; import { applyTemplate, @@ -90,10 +92,6 @@ dotenv.config({ quiet: true }); const program = new Command(); -type AuthMode = - | { accountSid: string; authToken: string } - | { accountSid: string; apiKey: string; apiSecret: string }; - type CliDeps = { sendMessage: typeof sendMessage; sendMessageWeb: typeof sendMessageWeb; @@ -218,86 +216,6 @@ type TwilioRequester = { request: (options: TwilioRequestOptions) => Promise; }; -type EnvConfig = { - accountSid: string; - whatsappFrom: string; - whatsappSenderSid?: string; - auth: AuthMode; -}; - -const EnvSchema = z - .object({ - TWILIO_ACCOUNT_SID: z.string().min(1, "TWILIO_ACCOUNT_SID required"), - TWILIO_WHATSAPP_FROM: z.string().min(1, "TWILIO_WHATSAPP_FROM required"), - TWILIO_SENDER_SID: z.string().optional(), - TWILIO_AUTH_TOKEN: z.string().optional(), - TWILIO_API_KEY: z.string().optional(), - TWILIO_API_SECRET: z.string().optional(), - }) - .superRefine((val, ctx) => { - if (val.TWILIO_API_KEY && !val.TWILIO_API_SECRET) { - ctx.addIssue({ - code: "custom", - message: "TWILIO_API_SECRET required when TWILIO_API_KEY is set", - }); - } - if (val.TWILIO_API_SECRET && !val.TWILIO_API_KEY) { - ctx.addIssue({ - code: "custom", - message: "TWILIO_API_KEY required when TWILIO_API_SECRET is set", - }); - } - if ( - !val.TWILIO_AUTH_TOKEN && - !(val.TWILIO_API_KEY && val.TWILIO_API_SECRET) - ) { - ctx.addIssue({ - code: "custom", - message: - "Provide TWILIO_AUTH_TOKEN or both TWILIO_API_KEY and TWILIO_API_SECRET", - }); - } - }); - -function readEnv(runtime: RuntimeEnv = defaultRuntime): EnvConfig { - // Load and validate Twilio auth + sender configuration from env. - const parsed = EnvSchema.safeParse(process.env); - if (!parsed.success) { - runtime.error("Invalid environment configuration:"); - parsed.error.issues.forEach((iss) => { - runtime.error(`- ${iss.message}`); - }); - runtime.exit(1); - } - - const { - TWILIO_ACCOUNT_SID: accountSid, - TWILIO_WHATSAPP_FROM: whatsappFrom, - TWILIO_SENDER_SID: whatsappSenderSid, - TWILIO_AUTH_TOKEN: authToken, - TWILIO_API_KEY: apiKey, - TWILIO_API_SECRET: apiSecret, - } = parsed.data; - - let auth: AuthMode; - if (apiKey && apiSecret) { - auth = { accountSid, apiKey, apiSecret }; - } else if (authToken) { - auth = { accountSid, authToken }; - } else { - runtime.error("Missing Twilio auth configuration"); - runtime.exit(1); - throw new Error("unreachable"); - } - - return { - accountSid, - whatsappFrom, - whatsappSenderSid, - auth, - }; -} - class PortInUseError extends Error { port: number; @@ -1038,39 +956,6 @@ async function updateWebhook( runtime.exit(1); } -type TwilioApiError = { - code?: number | string; - status?: number | string; - message?: string; - moreInfo?: string; - response?: { body?: unknown }; -}; - -function formatTwilioError(err: unknown): string { - const e = err as TwilioApiError; - const pieces = []; - if (e.code != null) pieces.push(`code ${e.code}`); - if (e.status != null) pieces.push(`status ${e.status}`); - if (e.message) pieces.push(e.message); - if (e.moreInfo) pieces.push(`more: ${e.moreInfo}`); - return pieces.length ? pieces.join(" | ") : String(err); -} - -function logTwilioSendError( - err: unknown, - destination?: string, - runtime: RuntimeEnv = defaultRuntime, -) { - const prefix = destination ? `to ${destination}: ` : ""; - runtime.error( - danger(`❌ Twilio send failed ${prefix}${formatTwilioError(err)}`), - ); - const body = (err as TwilioApiError)?.response?.body; - if (body) { - runtime.error(info("Response body:"), JSON.stringify(body, null, 2)); - } -} - function ensureTwilioEnv(runtime: RuntimeEnv = defaultRuntime) { const required = ["TWILIO_ACCOUNT_SID", "TWILIO_WHATSAPP_FROM"]; const missing = required.filter((k) => !process.env[k]); diff --git a/src/twilio/client.ts b/src/twilio/client.ts new file mode 100644 index 000000000..2324cefe0 --- /dev/null +++ b/src/twilio/client.ts @@ -0,0 +1,14 @@ +import Twilio from "twilio"; +import type { EnvConfig } from "../env.js"; + +export function createClient(env: EnvConfig) { + // Twilio client using either auth token or API key/secret. + if ("authToken" in env.auth) { + return Twilio(env.accountSid, env.auth.authToken, { + accountSid: env.accountSid, + }); + } + return Twilio(env.auth.apiKey, env.auth.apiSecret, { + accountSid: env.accountSid, + }); +} diff --git a/src/twilio/utils.ts b/src/twilio/utils.ts new file mode 100644 index 000000000..7ea648c88 --- /dev/null +++ b/src/twilio/utils.ts @@ -0,0 +1,37 @@ +import { danger, info } from "../globals.js"; +import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; + +type TwilioApiError = { + code?: number | string; + status?: number | string; + message?: string; + moreInfo?: string; + response?: { body?: unknown }; +}; + +export function formatTwilioError(err: unknown): string { + // Normalize Twilio error objects into a single readable string. + const e = err as TwilioApiError; + const pieces = []; + if (e.code != null) pieces.push(`code ${e.code}`); + if (e.status != null) pieces.push(`status ${e.status}`); + if (e.message) pieces.push(e.message); + if (e.moreInfo) pieces.push(`more: ${e.moreInfo}`); + return pieces.length ? pieces.join(" | ") : String(err); +} + +export function logTwilioSendError( + err: unknown, + destination?: string, + runtime: RuntimeEnv = defaultRuntime, +) { + // Friendly error logger for send failures, including response body when present. + const prefix = destination ? `to ${destination}: ` : ""; + runtime.error( + danger(`❌ Twilio send failed ${prefix}${formatTwilioError(err)}`), + ); + const body = (err as TwilioApiError)?.response?.body; + if (body) { + runtime.error(info("Response body:"), JSON.stringify(body, null, 2)); + } +}