From 4de7d49353615bce248ef14e0a7b31dfb200a598 Mon Sep 17 00:00:00 2001 From: Corey Johnson Date: Wed, 21 Jan 2026 14:09:02 -0800 Subject: [PATCH] Improve SIP registration error handling with Twilio API - Catch all SIP error codes (not just 403) in registration failures - Query Twilio API on error to get detailed account status - Show helpful message like "Twilio account suspended - add funds" - Retry registration after 2 minutes instead of exiting - Add restart() method to Baresip for clean reconnection Co-Authored-By: Claude Opus 4.5 --- src/phone.ts | 55 ++++++++++++++++++++++++++------------------- src/sip.ts | 21 +++++++++++++---- src/utils/twilio.ts | 50 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 99 insertions(+), 27 deletions(-) create mode 100644 src/utils/twilio.ts diff --git a/src/phone.ts b/src/phone.ts index cb33fc5..7747dcc 100644 --- a/src/phone.ts +++ b/src/phone.ts @@ -16,8 +16,9 @@ import Buzz from "./buzz" import { join } from "path" import GPIO from "./pins" import { Agent } from "./agent" -import { searchWeb } from "./agent/tools" +import { searchWeb, vestaboard } from "./agent/tools" import { ring } from "./utils" +import { getTwilioAccountInfo, formatTwilioError } from "./utils/twilio" import { getSound, WaitingSounds } from "./utils/waiting-sounds" type CancelableTask = () => void @@ -47,7 +48,6 @@ export const runPhone = async (agentId: string, agentKey: string) => { using rotaryNumber = gpio.input(23, { pull: "up", debounce: 3 }) await Buzz.setVolume(0.3) - log(`📞 Phone is ${hook.value ? "off hook" : "on hook"}`) playStartRing(ringer) @@ -71,7 +71,6 @@ const listenForPhoneEvents = ( ) => { hook.onChange((event) => { const type = event.value == 0 ? "hang-up" : "pick-up" - log(`📞 Hook ${event.value} sending ${type}`) phoneService.send({ type }) }) @@ -95,7 +94,7 @@ const startBaresip = async (phoneService: PhoneService, hook: GPIO.Input, ringer const baresip = new Baresip(["/usr/bin/baresip", "-v", "-f", baresipConfig]) baresip.registrationSuccess.on(async () => { - log("🐻 server connected") + log.debug("🐻 server connected") if (hook.value === 0) { phoneService.send({ type: "initialized" }) } else { @@ -104,17 +103,17 @@ const startBaresip = async (phoneService: PhoneService, hook: GPIO.Input, ringer }) baresip.callReceived.on(({ contact }) => { - log(`🐻 incoming call from ${contact}`) + log.debug(`🐻 incoming call from ${contact}`) phoneService.send({ type: "incoming-call", from: contact }) }) baresip.callEstablished.on(({ contact }) => { - log(`🐻 call established with ${contact}`) + log.debug(`🐻 call established with ${contact}`) phoneService.send({ type: "answered" }) }) baresip.hungUp.on(() => { - log("🐻 call hung up") + log.debug("🐻 call hung up") phoneService.send({ type: "remote-hang-up" }) }) @@ -123,14 +122,29 @@ const startBaresip = async (phoneService: PhoneService, hook: GPIO.Input, ringer phoneService.send({ type: "error", message: error.message }) }) - baresip.error.on(async ({ message }) => { - log.error("🐻 error:", message) - phoneService.send({ type: "error", message }) + baresip.error.on(async ({ message, statusCode, reason }) => { + let errorMessage = message + + if (statusCode) { + const twilioInfo = await getTwilioAccountInfo() + if (twilioInfo && twilioInfo.status !== "active") { + errorMessage = formatTwilioError(twilioInfo) + } else { + errorMessage = `Registration failed: ${statusCode} ${reason}` + } + } + + log.error("🐻 error:", errorMessage) + phoneService.send({ type: "error", message: errorMessage }) + for (let i = 0; i < 4; i++) { await ring(ringer, 500) await sleep(250) } - process.exit(1) + + log("🔄 Retrying registration in 2 minutes...") + await sleep(2 * 60 * 1000) + baresip.restart() }) return baresip @@ -173,6 +187,7 @@ const startAgent = (service: Service, ctx: PhoneContext) => apiKey: ctx.agentKey, tools: { search_web: (args: { query: string }) => searchWeb(args.query), + vestaboard: (args: { query: string }) => vestaboard(args.query), }, }) @@ -298,6 +313,8 @@ const handleAgentEvents = ( case "error": log.error("🤖 Agent error:", event.error) + streamPlayback?.stop() + service.send({ type: "remote-hang-up" }) break case "ping": @@ -322,7 +339,6 @@ const incomingCall = (ctx: PhoneContext, event: { type: "incoming-call"; from?: } const hangUp = (ctx: PhoneContext) => { - console.log(`📞 Hanging up call`) ctx.baresip.hangUp() } @@ -389,19 +405,12 @@ const digitIncrement = (ctx: PhoneContext) => { } const playStartRing = async (ringer: GPIO.Output) => { - // Three quick beeps, getting faster = energetic/welcoming ringer.value = 1 - await Bun.sleep(80) + await Bun.sleep(500) ringer.value = 0 - await Bun.sleep(120) - + await Bun.sleep(500) ringer.value = 1 - await Bun.sleep(80) - ringer.value = 0 - await Bun.sleep(100) - - ringer.value = 1 - await Bun.sleep(80) + await Bun.sleep(1000) ringer.value = 0 } @@ -454,5 +463,5 @@ const phoneMachine = createMachine( ) d._onEnter = function (machine, to, state, prevState, event) { - log(`📱 ${machine.current} -> ${to} (${(event as any).type})`) + log.debug(`📱 ${machine.current} -> ${to} (${(event as any).type})`) } diff --git a/src/sip.ts b/src/sip.ts index 46c26de..4626d84 100644 --- a/src/sip.ts +++ b/src/sip.ts @@ -8,7 +8,7 @@ export class Baresip { callEstablished = new Emitter<{ contact: string }>() callReceived = new Emitter<{ contact: string }>() hungUp = new Emitter() - error = new Emitter<{ message: string }>() + error = new Emitter<{ message: string; statusCode?: string; reason?: string }>() registrationSuccess = new Emitter() constructor(baresipArgs: string[]) { @@ -52,6 +52,7 @@ export class Baresip { this.callReceived.removeAllListeners() this.hungUp.removeAllListeners() this.registrationSuccess.removeAllListeners() + this.error.removeAllListeners() } kill() { @@ -61,6 +62,14 @@ export class Baresip { this.process = undefined } + async restart() { + if (this.process) { + this.process.kill() + this.process = undefined + } + await this.connect() + } + #parseLine(line: string) { log.debug(`📞 Baresip: ${line}`) const callEstablishedMatch = line.match(/Call established: (.+)/) @@ -91,10 +100,14 @@ export class Baresip { this.registrationSuccess.emit() } - const registrationFailedMatch = line.match(/reg: sip:\S+ 403 Forbidden/) + const registrationFailedMatch = line.match(/reg: sip:\S+ .*?(\d{3}) (\w+)/) const socketInUseMatch = line.match(/tcp: sock_bind:/) - if (registrationFailedMatch || socketInUseMatch) { - log.error(`⁉️ NOT HANDLED: Registration failed with "${line}"`) + if (registrationFailedMatch) { + const [, statusCode, reason] = registrationFailedMatch + log.error(`Registration failed: ${statusCode} ${reason}`) + this.error.emit({ message: line, statusCode, reason }) + } else if (socketInUseMatch) { + log.error(`Registration failed: socket in use`) this.error.emit({ message: line }) } } diff --git a/src/utils/twilio.ts b/src/utils/twilio.ts new file mode 100644 index 0000000..ea95066 --- /dev/null +++ b/src/utils/twilio.ts @@ -0,0 +1,50 @@ +const accountSid = process.env.TWILIO_ACCOUNT_SID +const authToken = process.env.TWILIO_AUTH_TOKEN + +type AccountStatus = "active" | "suspended" | "closed" + +interface TwilioAccountInfo { + status: AccountStatus + balance?: string + currency?: string +} + +export async function getTwilioAccountInfo(): Promise { + if (!accountSid || !authToken) { + return undefined + } + + const credentials = Buffer.from(`${accountSid}:${authToken}`).toString("base64") + const headers = { Authorization: `Basic ${credentials}` } + + const [accountRes, balanceRes] = await Promise.all([ + fetch(`https://api.twilio.com/2010-04-01/Accounts/${accountSid}.json`, { headers }), + fetch(`https://api.twilio.com/2010-04-01/Accounts/${accountSid}/Balance.json`, { headers }), + ]) + + if (!accountRes.ok) { + return undefined + } + + const account = (await accountRes.json()) as { status: AccountStatus } + const info: TwilioAccountInfo = { status: account.status } + + if (balanceRes.ok) { + const balance = (await balanceRes.json()) as { balance: string; currency: string } + info.balance = balance.balance + info.currency = balance.currency + } + + return info +} + +export function formatTwilioError(info: TwilioAccountInfo): string { + if (info.status === "suspended") { + const balanceInfo = info.balance ? ` (balance: ${info.balance} ${info.currency})` : "" + return `Twilio account suspended${balanceInfo} - add funds at twilio.com/console` + } + if (info.status === "closed") { + return "Twilio account is closed" + } + return `Twilio account status: ${info.status}` +}