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 <noreply@anthropic.com>
This commit is contained in:
parent
953fb3aff1
commit
4de7d49353
55
src/phone.ts
55
src/phone.ts
|
|
@ -16,8 +16,9 @@ import Buzz from "./buzz"
|
||||||
import { join } from "path"
|
import { join } from "path"
|
||||||
import GPIO from "./pins"
|
import GPIO from "./pins"
|
||||||
import { Agent } from "./agent"
|
import { Agent } from "./agent"
|
||||||
import { searchWeb } from "./agent/tools"
|
import { searchWeb, vestaboard } from "./agent/tools"
|
||||||
import { ring } from "./utils"
|
import { ring } from "./utils"
|
||||||
|
import { getTwilioAccountInfo, formatTwilioError } from "./utils/twilio"
|
||||||
import { getSound, WaitingSounds } from "./utils/waiting-sounds"
|
import { getSound, WaitingSounds } from "./utils/waiting-sounds"
|
||||||
|
|
||||||
type CancelableTask = () => void
|
type CancelableTask = () => void
|
||||||
|
|
@ -47,7 +48,6 @@ export const runPhone = async (agentId: string, agentKey: string) => {
|
||||||
using rotaryNumber = gpio.input(23, { pull: "up", debounce: 3 })
|
using rotaryNumber = gpio.input(23, { pull: "up", debounce: 3 })
|
||||||
|
|
||||||
await Buzz.setVolume(0.3)
|
await Buzz.setVolume(0.3)
|
||||||
log(`📞 Phone is ${hook.value ? "off hook" : "on hook"}`)
|
|
||||||
|
|
||||||
playStartRing(ringer)
|
playStartRing(ringer)
|
||||||
|
|
||||||
|
|
@ -71,7 +71,6 @@ const listenForPhoneEvents = (
|
||||||
) => {
|
) => {
|
||||||
hook.onChange((event) => {
|
hook.onChange((event) => {
|
||||||
const type = event.value == 0 ? "hang-up" : "pick-up"
|
const type = event.value == 0 ? "hang-up" : "pick-up"
|
||||||
log(`📞 Hook ${event.value} sending ${type}`)
|
|
||||||
phoneService.send({ 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])
|
const baresip = new Baresip(["/usr/bin/baresip", "-v", "-f", baresipConfig])
|
||||||
|
|
||||||
baresip.registrationSuccess.on(async () => {
|
baresip.registrationSuccess.on(async () => {
|
||||||
log("🐻 server connected")
|
log.debug("🐻 server connected")
|
||||||
if (hook.value === 0) {
|
if (hook.value === 0) {
|
||||||
phoneService.send({ type: "initialized" })
|
phoneService.send({ type: "initialized" })
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -104,17 +103,17 @@ const startBaresip = async (phoneService: PhoneService, hook: GPIO.Input, ringer
|
||||||
})
|
})
|
||||||
|
|
||||||
baresip.callReceived.on(({ contact }) => {
|
baresip.callReceived.on(({ contact }) => {
|
||||||
log(`🐻 incoming call from ${contact}`)
|
log.debug(`🐻 incoming call from ${contact}`)
|
||||||
phoneService.send({ type: "incoming-call", from: contact })
|
phoneService.send({ type: "incoming-call", from: contact })
|
||||||
})
|
})
|
||||||
|
|
||||||
baresip.callEstablished.on(({ contact }) => {
|
baresip.callEstablished.on(({ contact }) => {
|
||||||
log(`🐻 call established with ${contact}`)
|
log.debug(`🐻 call established with ${contact}`)
|
||||||
phoneService.send({ type: "answered" })
|
phoneService.send({ type: "answered" })
|
||||||
})
|
})
|
||||||
|
|
||||||
baresip.hungUp.on(() => {
|
baresip.hungUp.on(() => {
|
||||||
log("🐻 call hung up")
|
log.debug("🐻 call hung up")
|
||||||
phoneService.send({ type: "remote-hang-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 })
|
phoneService.send({ type: "error", message: error.message })
|
||||||
})
|
})
|
||||||
|
|
||||||
baresip.error.on(async ({ message }) => {
|
baresip.error.on(async ({ message, statusCode, reason }) => {
|
||||||
log.error("🐻 error:", message)
|
let errorMessage = message
|
||||||
phoneService.send({ type: "error", 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++) {
|
for (let i = 0; i < 4; i++) {
|
||||||
await ring(ringer, 500)
|
await ring(ringer, 500)
|
||||||
await sleep(250)
|
await sleep(250)
|
||||||
}
|
}
|
||||||
process.exit(1)
|
|
||||||
|
log("🔄 Retrying registration in 2 minutes...")
|
||||||
|
await sleep(2 * 60 * 1000)
|
||||||
|
baresip.restart()
|
||||||
})
|
})
|
||||||
|
|
||||||
return baresip
|
return baresip
|
||||||
|
|
@ -173,6 +187,7 @@ const startAgent = (service: Service<typeof phoneMachine>, ctx: PhoneContext) =>
|
||||||
apiKey: ctx.agentKey,
|
apiKey: ctx.agentKey,
|
||||||
tools: {
|
tools: {
|
||||||
search_web: (args: { query: string }) => searchWeb(args.query),
|
search_web: (args: { query: string }) => searchWeb(args.query),
|
||||||
|
vestaboard: (args: { query: string }) => vestaboard(args.query),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -298,6 +313,8 @@ const handleAgentEvents = (
|
||||||
|
|
||||||
case "error":
|
case "error":
|
||||||
log.error("🤖 Agent error:", event.error)
|
log.error("🤖 Agent error:", event.error)
|
||||||
|
streamPlayback?.stop()
|
||||||
|
service.send({ type: "remote-hang-up" })
|
||||||
break
|
break
|
||||||
|
|
||||||
case "ping":
|
case "ping":
|
||||||
|
|
@ -322,7 +339,6 @@ const incomingCall = (ctx: PhoneContext, event: { type: "incoming-call"; from?:
|
||||||
}
|
}
|
||||||
|
|
||||||
const hangUp = (ctx: PhoneContext) => {
|
const hangUp = (ctx: PhoneContext) => {
|
||||||
console.log(`📞 Hanging up call`)
|
|
||||||
ctx.baresip.hangUp()
|
ctx.baresip.hangUp()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -389,19 +405,12 @@ const digitIncrement = (ctx: PhoneContext) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const playStartRing = async (ringer: GPIO.Output) => {
|
const playStartRing = async (ringer: GPIO.Output) => {
|
||||||
// Three quick beeps, getting faster = energetic/welcoming
|
|
||||||
ringer.value = 1
|
ringer.value = 1
|
||||||
await Bun.sleep(80)
|
await Bun.sleep(500)
|
||||||
ringer.value = 0
|
ringer.value = 0
|
||||||
await Bun.sleep(120)
|
await Bun.sleep(500)
|
||||||
|
|
||||||
ringer.value = 1
|
ringer.value = 1
|
||||||
await Bun.sleep(80)
|
await Bun.sleep(1000)
|
||||||
ringer.value = 0
|
|
||||||
await Bun.sleep(100)
|
|
||||||
|
|
||||||
ringer.value = 1
|
|
||||||
await Bun.sleep(80)
|
|
||||||
ringer.value = 0
|
ringer.value = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -454,5 +463,5 @@ const phoneMachine = createMachine(
|
||||||
)
|
)
|
||||||
|
|
||||||
d._onEnter = function (machine, to, state, prevState, event) {
|
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})`)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
21
src/sip.ts
21
src/sip.ts
|
|
@ -8,7 +8,7 @@ export class Baresip {
|
||||||
callEstablished = new Emitter<{ contact: string }>()
|
callEstablished = new Emitter<{ contact: string }>()
|
||||||
callReceived = new Emitter<{ contact: string }>()
|
callReceived = new Emitter<{ contact: string }>()
|
||||||
hungUp = new Emitter()
|
hungUp = new Emitter()
|
||||||
error = new Emitter<{ message: string }>()
|
error = new Emitter<{ message: string; statusCode?: string; reason?: string }>()
|
||||||
registrationSuccess = new Emitter()
|
registrationSuccess = new Emitter()
|
||||||
|
|
||||||
constructor(baresipArgs: string[]) {
|
constructor(baresipArgs: string[]) {
|
||||||
|
|
@ -52,6 +52,7 @@ export class Baresip {
|
||||||
this.callReceived.removeAllListeners()
|
this.callReceived.removeAllListeners()
|
||||||
this.hungUp.removeAllListeners()
|
this.hungUp.removeAllListeners()
|
||||||
this.registrationSuccess.removeAllListeners()
|
this.registrationSuccess.removeAllListeners()
|
||||||
|
this.error.removeAllListeners()
|
||||||
}
|
}
|
||||||
|
|
||||||
kill() {
|
kill() {
|
||||||
|
|
@ -61,6 +62,14 @@ export class Baresip {
|
||||||
this.process = undefined
|
this.process = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async restart() {
|
||||||
|
if (this.process) {
|
||||||
|
this.process.kill()
|
||||||
|
this.process = undefined
|
||||||
|
}
|
||||||
|
await this.connect()
|
||||||
|
}
|
||||||
|
|
||||||
#parseLine(line: string) {
|
#parseLine(line: string) {
|
||||||
log.debug(`📞 Baresip: ${line}`)
|
log.debug(`📞 Baresip: ${line}`)
|
||||||
const callEstablishedMatch = line.match(/Call established: (.+)/)
|
const callEstablishedMatch = line.match(/Call established: (.+)/)
|
||||||
|
|
@ -91,10 +100,14 @@ export class Baresip {
|
||||||
this.registrationSuccess.emit()
|
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:/)
|
const socketInUseMatch = line.match(/tcp: sock_bind:/)
|
||||||
if (registrationFailedMatch || socketInUseMatch) {
|
if (registrationFailedMatch) {
|
||||||
log.error(`⁉️ NOT HANDLED: Registration failed with "${line}"`)
|
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 })
|
this.error.emit({ message: line })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
50
src/utils/twilio.ts
Normal file
50
src/utils/twilio.ts
Normal file
|
|
@ -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<TwilioAccountInfo | undefined> {
|
||||||
|
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}`
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user