diff --git a/ops/agent-phone.mjs b/ops/agent-phone.mjs index e850b3c..2507fb1 100755 --- a/ops/agent-phone.mjs +++ b/ops/agent-phone.mjs @@ -50,6 +50,10 @@ export function speechRequest(text, voice) { if (voice.startsWith('ElevenLabs.')) request.voice_settings = { api_key_ref: 'qrypt_elevenlabs' }; return request; } +export function identityDtmf(token) { + if (!/^\d{8}$/.test(String(token))) throw new Error('Agent join token must be eight digits'); + return `*${token}#`; +} const DISPLAY_NAMES = { riotcoder: 'RiotCoder', cha0spunk: 'ChaosPunk', rootkey: 'RootKey', coinpay: 'CoinPay', knox: 'Knox', nora: 'Nora' }; export function agentIntro(agent) { return `Hi, I’m ${DISPLAY_NAMES[agent] || agent}, Anthony’s AI agent. I’m here and listening.`; } export function shouldIntroduce(agent, transcript, now = Date.now(), recentMs = 3 * 60_000) { @@ -472,6 +476,9 @@ async function main() { keyed = true; if (join) void followRoom(); await act('send_dtmf', { digits: `${a.pin}#`, duration_millis: 250 }).catch((e) => console.error(`dtmf failed: ${e.message}`)); + if (join && a['join-token']) { + setTimeout(() => act('send_dtmf', { digits: identityDtmf(a['join-token']), duration_millis: 180 }).catch((e) => console.error(`identity dtmf failed: ${e.message}`)), 1800).unref(); + } // The room welcomes us and says who is there; speak after that. setTimeout(() => turn(), 9000).unref(); }; @@ -893,7 +900,7 @@ async function groupcallWorker(a) { if (podcast) { try { brief = read(`${HOME}/.config/qc-agents/tuiweekly-brief.md`).trim() || brief; } catch {} } execFile( process.argv[1], - ['join', job.dial_in, '--pin', job.code, '--agent', job.agent, '--room', job.room_name, '--goal', brief, '--json', ...(podcast ? ['--podcast'] : []), ...(job.from ? ['--from', job.from] : []), ...(job.role ? ['--role', job.role] : [])], + ['join', job.dial_in, '--pin', job.code, '--agent', job.agent, '--room', job.room_name, '--goal', brief, '--json', ...(podcast ? ['--podcast'] : []), ...(job.from ? ['--from', job.from] : []), ...(job.joinToken ? ['--join-token', job.joinToken] : []), ...(job.role ? ['--role', job.role] : [])], { timeout: 65 * 60_000, maxBuffer: 8 << 20, env: { ...process.env, GROUPCALL_STATUS_URL: `${base}/api/v1/agent-queue/rooms/${job.code}`, GROUPCALL_AGENT_WORKER_KEY: key, ...(val('GEMINI_API_KEY') ? { GEMINI_API_KEY: val('GEMINI_API_KEY') } : {}) } }, async (err, stdout) => { let r = {}; diff --git a/public/app.js b/public/app.js index cc758b8..ec500f8 100644 --- a/public/app.js +++ b/public/app.js @@ -40,7 +40,7 @@ ? e.live.map((p) => `
  • ${esc(p.name)} ${p.kind === 'agent' ? 'agent' : 'on the phone'}
  • `).join('') : '
  • Nobody on the line.
  • ' if (e.type === 'hello') return - const what = e.type === 'join' ? 'joined' : e.type === 'leave' ? 'left' : e.type === 'invite' ? esc(e.text) : `said: “${esc(e.text)}”` + const what = e.type === 'join' ? 'joined' : e.type === 'leave' ? 'left' : e.type === 'invite' || e.type === 'identify' ? esc(e.text) : `said: “${esc(e.text)}”` const li = document.createElement('li') li.innerHTML = `${time(e.at)} ${esc(e.who)} ${what}` if (log.children.length === 1 && log.firstElementChild.classList.contains('muted')) log.firstElementChild.remove() diff --git a/src/db.ts b/src/db.ts index 3cfb57c..406db7f 100644 --- a/src/db.ts +++ b/src/db.ts @@ -9,7 +9,7 @@ * CoinPay tokens are sealed with GROUPCALL_KEY. */ import { Database } from 'bun:sqlite' -import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto' +import { createCipheriv, createDecipheriv, createHash, randomBytes, randomInt } from 'node:crypto' import type { Contact } from './contacts.js' export type User = { sub: string; name: string; email: string; apiKeyHash: string | null; createdAt: number } @@ -20,7 +20,7 @@ export type Room = { code: string; sub: string; name: string; createdAt: number; export type Recording = { id: string; code: string; conferenceId: string; recordingId: string | null; status: 'recording' | 'saving' | 'ready' | 'failed'; path: string; bytes: number; durationSecs: number | null; startedAt: number; endedAt: number | null; note: string } /** What happened in a room: somebody joined or left, or a line was said into it. */ -export type RoomEvent = { id: number; code: string; kind: 'join' | 'leave' | 'say' | 'invite' | 'quality' | 'text'; who: string; text: string; at: number } +export type RoomEvent = { id: number; code: string; kind: 'join' | 'leave' | 'say' | 'invite' | 'identify' | 'quality' | 'text'; who: string; text: string; at: number } /** How a leg sounded, from Telnyx's call_quality_stats on hangup. */ export type LegQuality = { code: string; leg: string; who: string; mosIn: number | null; mosOut: number | null; jitterMs: number | null; lossPct: number | null; at: number } @@ -28,7 +28,7 @@ export type LegQuality = { code: string; leg: string; who: string; mosIn: number /** One line said in a room: transcribed from a caller's leg, or typed in and read aloud. */ export type Line = { id: number; code: string; who: string; kind: 'person' | 'agent' | 'typed'; text: string; at: number } -export type AgentInvite = { id: string; code: string; agent: string; sub: string; role: string; status: 'queued' | 'dialling' | 'done' | 'failed'; note: string; createdAt: number; updatedAt: number } +export type AgentInvite = { id: string; code: string; agent: string; sub: string; role: string; joinToken: string; status: 'queued' | 'dialling' | 'done' | 'failed'; note: string; createdAt: number; updatedAt: number } /** Work an agent took on during a call, waiting for its task runner (riotcoder-qc and the like). */ export type AgentTask = { id: string; agent: string; code: string; roomName: string; brief: string; ack: string; said: string; status: 'queued' | 'taken'; createdAt: number } @@ -134,6 +134,7 @@ export class Store { sub TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'queued', note TEXT NOT NULL DEFAULT '', + join_token TEXT NOT NULL DEFAULT '', created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); @@ -188,7 +189,7 @@ export class Store { // Group texts: every number a room has texted, and whether it said STOP. this.db.exec('CREATE TABLE IF NOT EXISTS sms_numbers (number TEXT PRIMARY KEY, opted_out INTEGER NOT NULL DEFAULT 0, first_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)') // Added later: an agent that waits in the room with early callers until the host arrives. - for (const sql of ['ALTER TABLE rooms ADD COLUMN greeter TEXT', 'ALTER TABLE rooms ADD COLUMN host TEXT', "ALTER TABLE agent_invites ADD COLUMN role TEXT NOT NULL DEFAULT ''", "ALTER TABLE dialled_legs ADD COLUMN name TEXT NOT NULL DEFAULT ''"]) { + for (const sql of ['ALTER TABLE rooms ADD COLUMN greeter TEXT', 'ALTER TABLE rooms ADD COLUMN host TEXT', "ALTER TABLE agent_invites ADD COLUMN role TEXT NOT NULL DEFAULT ''", "ALTER TABLE agent_invites ADD COLUMN join_token TEXT NOT NULL DEFAULT ''", "ALTER TABLE dialled_legs ADD COLUMN name TEXT NOT NULL DEFAULT ''"]) { try { this.db.exec(sql) } catch {} @@ -519,6 +520,10 @@ export class Store { })() } + nameLiveLeg(leg: string, name: string): void { + this.db.query('UPDATE live_legs SET name = ? WHERE leg = ?').run(name, leg) + } + dropLeg(code: string, leg: string, roomEmpty: boolean): void { this.db.transaction(() => { this.db.query('DELETE FROM live_legs WHERE leg = ?').run(leg) @@ -557,15 +562,23 @@ export class Store { // ---- agent invites ----------------------------------------------------- private toInvite(r: any): AgentInvite | null { - return r ? { id: r.id, code: r.code, agent: r.agent, sub: r.sub, role: r.role ?? '', status: r.status, note: r.note, createdAt: r.created_at, updatedAt: r.updated_at } : null + return r ? { id: r.id, code: r.code, agent: r.agent, sub: r.sub, role: r.role ?? '', joinToken: r.join_token ?? '', status: r.status, note: r.note, createdAt: r.created_at, updatedAt: r.updated_at } : null } addInvite(i: Pick & { role?: string }, now = Date.now()): AgentInvite { const id = `inv_${randomBytes(9).toString('base64url')}` - this.db.query("INSERT INTO agent_invites (id, code, agent, sub, role, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'queued', ?, ?)").run(id, i.code, i.agent, i.sub, i.role ?? '', now, now) + const joinToken = String(randomInt(0, 100_000_000)).padStart(8, '0') + this.db.query("INSERT INTO agent_invites (id, code, agent, sub, role, join_token, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, 'queued', ?, ?)").run(id, i.code, i.agent, i.sub, i.role ?? '', joinToken, now, now) return this.invite(id)! } + /** Consume a short-lived DTMF token once to name an agent whose caller ID was withheld. */ + consumeAgentJoinToken(code: string, token: string, now = Date.now()): string | null { + if (!/^\d{8}$/.test(token)) return null + const row = this.db.query("UPDATE agent_invites SET join_token = '', updated_at = ? WHERE id = (SELECT id FROM agent_invites WHERE code = ? AND join_token = ? AND status = 'dialling' ORDER BY created_at DESC LIMIT 1) AND join_token = ? RETURNING agent").get(now, code, token, token) as { agent: string } | null + return row?.agent ?? null + } + invite(id: string): AgentInvite | null { return this.toInvite(this.db.query('SELECT * FROM agent_invites WHERE id = ?').get(id)) } @@ -581,7 +594,7 @@ export class Store { * whoever asked has given up. */ claimInvite(now = Date.now()): AgentInvite | null { - this.db.query("UPDATE agent_invites SET status = 'failed', note = 'nobody picked it up in time', updated_at = ? WHERE status = 'queued' AND created_at < ?").run(now, now - 10 * 60_000) + this.db.query("UPDATE agent_invites SET status = 'failed', note = 'nobody picked it up in time', join_token = '', updated_at = ? WHERE status = 'queued' AND created_at < ?").run(now, now - 10 * 60_000) return this.toInvite( this.db .query("UPDATE agent_invites SET status = 'dialling', updated_at = ? WHERE id = (SELECT id FROM agent_invites WHERE status = 'queued' ORDER BY created_at LIMIT 1) RETURNING *") @@ -590,7 +603,7 @@ export class Store { } finishInvite(id: string, status: 'dialling' | 'done' | 'failed', note = '', now = Date.now()): void { - this.db.query('UPDATE agent_invites SET status = ?, note = ?, updated_at = ? WHERE id = ?').run(status, note.slice(0, 300), now, id) + this.db.query("UPDATE agent_invites SET status = ?, note = ?, join_token = CASE WHEN ? = 'dialling' THEN join_token ELSE '' END, updated_at = ? WHERE id = ?").run(status, note.slice(0, 300), status, now, id) } invites(code: string, limit = 10): AgentInvite[] { diff --git a/src/html.ts b/src/html.ts index 608e88b..67581c5 100644 --- a/src/html.ts +++ b/src/html.ts @@ -187,7 +187,7 @@ export function roomPage(ctx: Ctx, data: { room: Room; owner: boolean; live: { n : '
  • Nobody on the line.
  • ' const log = data.events.length ? data.events - .map((e) => `
  • ${esc(when(e.at).slice(11, 16))} ${esc(e.who)} ${e.kind === 'join' ? 'joined' : e.kind === 'leave' ? 'left' : e.kind === 'invite' || e.kind === 'quality' ? esc(e.text) : `said: “${esc(e.text)}”`}
  • `) + .map((e) => `
  • ${esc(when(e.at).slice(11, 16))} ${esc(e.who)} ${e.kind === 'join' ? 'joined' : e.kind === 'leave' ? 'left' : e.kind === 'identify' ? esc(e.text) : e.kind === 'invite' || e.kind === 'quality' ? esc(e.text) : `said: “${esc(e.text)}”`}
  • `) .join('') : '
  • Nothing yet.
  • ' const ownerTools = data.owner diff --git a/src/service.ts b/src/service.ts index a308f89..f6db18a 100644 --- a/src/service.ts +++ b/src/service.ts @@ -174,7 +174,7 @@ export const formatNumber = (e164: string) => { /** One tap from a phone: dial, wait for the prompt, key the code. */ export const telLink = (dialIn: string, code: string) => `tel:${dialIn},,,${code}%23` -export type LiveEvent = { type: 'join' | 'leave' | 'say' | 'invite' | 'line'; code: string; who: string; text?: string; callers: number; at: number; line?: Line } +export type LiveEvent = { type: 'join' | 'leave' | 'say' | 'invite' | 'identify' | 'line'; code: string; who: string; text?: string; callers: number; at: number; line?: Line } /** A participant as a page or the API may see them: a name, or a masked number. */ /** Who a room's greeter waits for, in words. */ @@ -254,7 +254,16 @@ export class GroupCallService { store.touchRoom(code) return { welcome: `Welcome to ${room.name}. ${owner && this.mayCallPhones(owner) ? 'Press star to call someone in. ' : ''}` } }, - onInvite: (code, who, digits) => this.callInFromLine(code, who, digits), + onInvite: async (code, who, digits) => { + const agent = store.consumeAgentJoinToken(code, digits) + if (!agent) return this.callInFromLine(code, who, digits) + who.name = agent + store.nameLiveLeg(who.leg, agent) + const text = 'identity verified after caller ID was withheld' + store.addEvent({ code, kind: 'identify', who: agent, text }) + this.emit({ type: 'identify', code, who: agent, text, callers: this.line?.callersIn(code) ?? 0, at: Date.now() }) + return '' + }, onJoin: (code, who, info) => { // A phone the room rang has no caller number on its leg: it is the number we dialled, so a // known caller rung in (Anthony) is named and trusted like one who dialled in. diff --git a/test/agent-phone.test.mjs b/test/agent-phone.test.mjs index da530db..83097d1 100644 --- a/test/agent-phone.test.mjs +++ b/test/agent-phone.test.mjs @@ -32,6 +32,11 @@ test('ElevenLabs requests keep their API key reference', () => { }); }); +test('agent join identity uses one DTMF token and rejects malformed tokens', () => { + assert.equal(worker.identityDtmf('00428193'), '*00428193#'); + assert.throws(() => worker.identityDtmf('428193'), /eight digits/); +}); + test('a new call identifies its actual agent even when room history has a different agent', () => { const history = [{ who: 'cha0spunk', text: 'old line', at: new Date(now - 15 * minute).toISOString(), history: true }]; assert.equal(worker.shouldIntroduce('riotcoder', history, now), true); diff --git a/test/app.test.ts b/test/app.test.ts index 8fb532b..96ce65b 100644 --- a/test/app.test.ts +++ b/test/app.test.ts @@ -242,6 +242,30 @@ describe('agents', () => { expect(svc.live(room.code)).toEqual([{ name: 'nora', kind: 'agent', since: expect.any(Number) }]) }) + test('a one-time DTMF join token names an agent when Telnyx withholds caller ID', async () => { + const { call, event, svc, store, telnyx } = setup() + const room = (await (await call('gc_op', 'POST', '/api/v1/rooms', { name: 'Anonymous caller' })).json()) as any + await call('gc_op', 'POST', `/api/v1/rooms/${room.code}/agents`, { agent: 'riotcoder' }) + const invite = svc.nextInvite()! + expect(invite.joinToken).toMatch(/^\d{8}$/) + + const liveEvents: any[] = [] + svc.subscribe(room.code, (e) => liveEvents.push(e)) + await event('call.initiated', { call_control_id: 'leg-anonymous-agent', direction: 'incoming' }) + await event('call.gather.ended', { call_control_id: 'leg-anonymous-agent', digits: room.code }) + expect(svc.live(room.code)).toMatchObject([{ name: 'a caller', kind: 'person' }]) + + for (const digit of `*${invite.joinToken}#`) await event('call.dtmf.received', { call_control_id: 'leg-anonymous-agent', digit }) + + expect(svc.live(room.code)).toMatchObject([{ name: 'riotcoder', kind: 'agent' }]) + expect((store.db.query('SELECT name FROM live_legs WHERE leg = ?').get('leg-anonymous-agent') as any).name).toBe('riotcoder') + expect(liveEvents.some((e) => e.type === 'identify' && e.who === 'riotcoder')).toBe(true) + expect(store.events(room.code).map((e) => [e.kind, e.who])).toContainEqual(['identify', 'riotcoder']) + expect(telnyx.calls.some((c) => c.path === '/calls')).toBe(false) + expect(store.consumeAgentJoinToken(room.code, invite.joinToken)).toBeNull() + expect(store.invite(invite.id)?.joinToken).toBe('') + }) + test('six simultaneous agent arrivals all join the same conference', async () => { const agentCallerIds = { riotcoder: '+14159185508', cha0spunk: '+15107667447', coinpay: '+15623079115',