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',