Extract env + Twilio utils; shrink index
This commit is contained in:
106
src/env.ts
Normal file
106
src/env.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
121
src/index.ts
121
src/index.ts
@@ -16,7 +16,6 @@ import express, { type Request, type Response } from "express";
|
|||||||
import JSON5 from "json5";
|
import JSON5 from "json5";
|
||||||
import Twilio from "twilio";
|
import Twilio from "twilio";
|
||||||
import type { MessageInstance } from "twilio/lib/rest/api/v2010/account/message.js";
|
import type { MessageInstance } from "twilio/lib/rest/api/v2010/account/message.js";
|
||||||
import { z } from "zod";
|
|
||||||
import {
|
import {
|
||||||
runCommandWithTimeout,
|
runCommandWithTimeout,
|
||||||
runExec,
|
runExec,
|
||||||
@@ -28,6 +27,9 @@ import {
|
|||||||
autoReplyIfConfigured,
|
autoReplyIfConfigured,
|
||||||
getReplyFromConfig,
|
getReplyFromConfig,
|
||||||
} from "./auto-reply/reply.js";
|
} 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 { CLAUDE_BIN, parseClaudeJsonText } from "./auto-reply/claude.js";
|
||||||
import {
|
import {
|
||||||
applyTemplate,
|
applyTemplate,
|
||||||
@@ -90,10 +92,6 @@ dotenv.config({ quiet: true });
|
|||||||
|
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
|
|
||||||
type AuthMode =
|
|
||||||
| { accountSid: string; authToken: string }
|
|
||||||
| { accountSid: string; apiKey: string; apiSecret: string };
|
|
||||||
|
|
||||||
type CliDeps = {
|
type CliDeps = {
|
||||||
sendMessage: typeof sendMessage;
|
sendMessage: typeof sendMessage;
|
||||||
sendMessageWeb: typeof sendMessageWeb;
|
sendMessageWeb: typeof sendMessageWeb;
|
||||||
@@ -218,86 +216,6 @@ type TwilioRequester = {
|
|||||||
request: (options: TwilioRequestOptions) => Promise<TwilioRequestResponse>;
|
request: (options: TwilioRequestOptions) => Promise<TwilioRequestResponse>;
|
||||||
};
|
};
|
||||||
|
|
||||||
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 {
|
class PortInUseError extends Error {
|
||||||
port: number;
|
port: number;
|
||||||
@@ -1038,39 +956,6 @@ async function updateWebhook(
|
|||||||
runtime.exit(1);
|
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) {
|
function ensureTwilioEnv(runtime: RuntimeEnv = defaultRuntime) {
|
||||||
const required = ["TWILIO_ACCOUNT_SID", "TWILIO_WHATSAPP_FROM"];
|
const required = ["TWILIO_ACCOUNT_SID", "TWILIO_WHATSAPP_FROM"];
|
||||||
const missing = required.filter((k) => !process.env[k]);
|
const missing = required.filter((k) => !process.env[k]);
|
||||||
|
|||||||
14
src/twilio/client.ts
Normal file
14
src/twilio/client.ts
Normal file
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
37
src/twilio/utils.ts
Normal file
37
src/twilio/utils.ts
Normal file
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user