Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,7 @@ export class Store {

removeRoom(code: string): void {
this.db.transaction(() => {
this.db.query('DELETE FROM agent_invites WHERE code = ?').run(code)
this.db.query('DELETE FROM rooms WHERE code = ?').run(code)
this.db.query('DELETE FROM room_events WHERE code = ?').run(code)
this.db.query('DELETE FROM transcript_lines WHERE code = ?').run(code)
Expand Down Expand Up @@ -610,6 +611,11 @@ export class Store {
return (this.db.query('SELECT * FROM agent_invites WHERE code = ? ORDER BY created_at DESC LIMIT ?').all(code, limit) as any[]).map((r) => this.toInvite(r)!)
}

/** Agents ever invited to this room are its standing team roster. */
roomAgentRoster(code: string): { agent: string; sub: string }[] {
return this.db.query('SELECT agent, MAX(sub) AS sub FROM agent_invites WHERE code = ? GROUP BY agent ORDER BY agent').all(code) as { agent: string; sub: string }[]
}

// ---- room passes -------------------------------------------------------

private toPass(r: any): Pass | null {
Expand Down
29 changes: 19 additions & 10 deletions src/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1170,15 +1170,11 @@ export class GroupCallService {
return
}
const room = this.store.room(code)
if (room && shown.kind === 'person') {
// An invitee leaves after three minutes if nobody has arrived. Bring that
// team back when the host joins, rather than leaving stale names behind.
for (const previous of this.store.invites(code, 50)) {
if (previous.status !== 'done' || !/\b(?:nobody came|everyone else left)\b/i.test(previous.note) || Date.now() - previous.createdAt > 15 * 60_000) continue
if (AGENTS[previous.agent]) {
this.store.finishInvite(previous.id, 'done', 'requeued after host arrival')
this.queueAgent(room, previous.agent, previous.sub)
}
if (room && this.isRoomHost(room, who, shown)) {
// The invited agents are a standing room roster. Dial them only after the
// owner/host arrives; they never wait in an empty room.
for (const member of this.store.roomAgentRoster(code)) {
if (AGENTS[member.agent]) this.queueAgent(room, member.agent, member.sub)
}
}
const greeter = room?.greeter
Expand All @@ -1191,6 +1187,15 @@ export class GroupCallService {
}, 45_000).unref?.()
}

private isRoomHost(room: Room, participant: Participant, shown: ReturnType<typeof shownParticipant>): boolean {
if (shown.kind !== 'person') return false
if (room.host?.startsWith('+')) return Boolean(participant.from && normalNumber(participant.from) === normalNumber(room.host))
if (room.host) return shown.name.toLowerCase().includes(room.host.toLowerCase())
const owner = this.store.user(room.sub)
const known = shown.trusted && owner?.name && owner.name.toLowerCase().startsWith(shown.name.toLowerCase())
return Boolean(known)
}

// ---- our voice agents' tools -------------------------------------------------

/**
Expand Down Expand Up @@ -1464,7 +1469,11 @@ export class GroupCallService {
private maybeGreet(code: string, who: Participant): void {
const room = this.store.room(code)
if (!room?.greeter || !AGENTS[room.greeter]) return
if (shownParticipant(who, this.agentNumbers, this.config.knownCallers, this.calledIn).kind !== 'person') return
const shown = shownParticipant(who, this.agentNumbers, this.config.knownCallers, this.calledIn)
if (shown.kind !== 'person') return
// A room with a standing team waits for its owner; the receptionist is part
// of that team and must not come in early for another caller.
if (this.store.roomAgentRoster(code).length || this.isRoomHost(room, who, shown)) return
if (this.hostHere(code)) return
if (this.live(code).some((p) => p.name === room.greeter)) return
const open = this.store.openInvite(code, room.greeter)
Expand Down
44 changes: 23 additions & 21 deletions test/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { createHmac, generateKeyPairSync, sign as signMessage } from 'node:crypt
import { signWebhook } from '@profullstack/coinpay-onboard'
import { createApp } from '../src/server.ts'
import { Sealer, Store } from '../src/db.ts'
import { FREE_ROOMS, telLink } from '../src/service.ts'
import { AGENTS, FREE_ROOMS, telLink } from '../src/service.ts'

/** An ed25519 pair standing in for the Telnyx account's, so a test can sign webhooks. */
function telnyxKeys() {
Expand Down Expand Up @@ -472,26 +472,28 @@ test('an invite stuck "dialling" from an agent that never arrived does not block
expect(store.invite(first.id)?.status).toBe('failed')
})

test('agents who timed out or left before the host returned are dialled back into the room', async () => {
const { call, event, store, telnyx } = setup({ agentSip: { riotcoder: 'sip:+15106169327@sip.rtc.elevenlabs.io', coinpay: 'sip:+15106169328@sip.rtc.elevenlabs.io' } })
const room = (await (await call('gc_op', 'POST', '/api/v1/rooms', { name: 'Late host' })).json()) as any
const expired = store.addInvite({ code: room.code, agent: 'riotcoder', sub: 'sub-op' })
store.finishInvite(expired.id, 'done', 'on the call 182 s, nobody came')
const left = store.addInvite({ code: room.code, agent: 'coinpay', sub: 'sub-op' })
store.finishInvite(left.id, 'done', 'on the call 182 s, everyone else left')

await event('call.initiated', { call_control_id: 'late-host', direction: 'incoming', from: '+15550001111' })
await event('call.gather.ended', { call_control_id: 'late-host', digits: room.code })

expect(store.invite(expired.id)?.note).toBe('requeued after host arrival')
expect(store.invite(left.id)?.note).toBe('requeued after host arrival')
expect(store.invites(room.code, 10).some((invite) => invite.id !== expired.id && invite.agent === 'riotcoder')).toBe(true)
expect(store.invites(room.code, 10).some((invite) => invite.id !== left.id && invite.agent === 'coinpay')).toBe(true)
expect(telnyx.calls.some((c) => c.path === '/calls' && c.body.to.includes('sip.rtc.elevenlabs.io'))).toBe(true)

await event('call.initiated', { call_control_id: 'second-host', direction: 'incoming', from: '+15550001112' })
await event('call.gather.ended', { call_control_id: 'second-host', digits: room.code })
expect(telnyx.calls.filter((c) => c.path === '/calls')).toHaveLength(2)
test('a room brings its standing agent team in only after the owner joins', async () => {
const ownerPhone = '+14086562473'
const { call, event, store, svc, op } = setup({ knownCallers: { [ownerPhone]: 'Anthony' } })
const room = (await (await call('gc_op', 'POST', '/api/v1/rooms', { name: 'Team room' })).json()) as any
await call('gc_op', 'POST', `/api/v1/rooms/${room.code}/greeter`, { agent: 'cha0spunk' })
const original = new Set<string>()
for (const agent of Object.keys(AGENTS)) {
const invite = svc.inviteAgent(op, room.code, agent)
original.add(invite.id)
store.finishInvite(invite.id, 'done', 'previous call ended', Date.now() - 30 * 60_000)
store.db.query('UPDATE agent_invites SET created_at = ? WHERE id = ?').run(Date.now() - 30 * 60_000, invite.id)
}
expect(svc.nextInvite()).toBeNull()

await event('call.initiated', { call_control_id: 'guest-first', direction: 'incoming', from: '+15550001111' })
await event('call.gather.ended', { call_control_id: 'guest-first', digits: room.code })
expect(svc.nextInvite()).toBeNull()

await event('call.initiated', { call_control_id: 'room-owner', direction: 'incoming', from: ownerPhone })
await event('call.gather.ended', { call_control_id: 'room-owner', digits: room.code })
const queued = store.invites(room.code, 50).filter((invite) => !original.has(invite.id) && invite.status === 'queued')
expect(queued.map((invite) => invite.agent).sort()).toEqual(Object.keys(AGENTS).sort())
})

test("a known caller is named, and agents can see which recent lines came from someone trusted", async () => {
Expand Down
Loading