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
9 changes: 8 additions & 1 deletion ops/agent-phone.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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();
};
Expand Down Expand Up @@ -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 = {};
Expand Down
2 changes: 1 addition & 1 deletion public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
? e.live.map((p) => `<li class="${esc(p.kind)}"><span class="dot" aria-hidden="true"></span>${esc(p.name)} <span class="muted">${p.kind === 'agent' ? 'agent' : 'on the phone'}</span></li>`).join('')
: '<li class="empty muted">Nobody on the line.</li>'
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 = `<span class="muted">${time(e.at)}</span> ${esc(e.who)} ${what}`
if (log.children.length === 1 && log.firstElementChild.classList.contains('muted')) log.firstElementChild.remove()
Expand Down
29 changes: 21 additions & 8 deletions src/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -20,15 +20,15 @@ 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 }

/** 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 }
Expand Down Expand Up @@ -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
);
Expand Down Expand Up @@ -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 {}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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<AgentInvite, 'code' | 'agent' | 'sub'> & { 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))
}
Expand All @@ -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 *")
Expand All @@ -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[] {
Expand Down
2 changes: 1 addition & 1 deletion src/html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ export function roomPage(ctx: Ctx, data: { room: Room; owner: boolean; live: { n
: '<li class="empty muted">Nobody on the line.</li>'
const log = data.events.length
? data.events
.map((e) => `<li><span class="muted">${esc(when(e.at).slice(11, 16))}</span> ${esc(e.who)} ${e.kind === 'join' ? 'joined' : e.kind === 'leave' ? 'left' : e.kind === 'invite' || e.kind === 'quality' ? esc(e.text) : `said: “${esc(e.text)}”`}</li>`)
.map((e) => `<li><span class="muted">${esc(when(e.at).slice(11, 16))}</span> ${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)}”`}</li>`)
.join('')
: '<li class="muted">Nothing yet.</li>'
const ownerTools = data.owner
Expand Down
13 changes: 11 additions & 2 deletions src/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions test/agent-phone.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
24 changes: 24 additions & 0 deletions test/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading