diff --git a/src/db.ts b/src/db.ts index 406db7f..41b0878 100644 --- a/src/db.ts +++ b/src/db.ts @@ -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) @@ -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 { diff --git a/src/service.ts b/src/service.ts index 748982b..07cb7a1 100644 --- a/src/service.ts +++ b/src/service.ts @@ -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 @@ -1191,6 +1187,15 @@ export class GroupCallService { }, 45_000).unref?.() } + private isRoomHost(room: Room, participant: Participant, shown: ReturnType): 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 ------------------------------------------------- /** @@ -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) diff --git a/test/app.test.ts b/test/app.test.ts index 2e0034a..b87afbb 100644 --- a/test/app.test.ts +++ b/test/app.test.ts @@ -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() { @@ -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() + 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 () => {