From d42c0d34d0ff0f90217f266d34ff84fe764fc916 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 1 Nov 2026 14:28:01 +0000 Subject: [PATCH] Scope phone workers to every Fleet agent --- README.md | 14 ++-- bin/install.conf | 4 +- bin/install.sh | 181 +++++++++++++++++++++++++++----------------- ops/agent-phone.mjs | 54 ++++++------- src/db.ts | 14 +++- src/html.ts | 4 +- src/mcp.ts | 6 +- src/server.ts | 25 ++++-- src/service.ts | 15 ++-- test/app.test.ts | 32 +++++++- 10 files changed, 220 insertions(+), 129 deletions(-) diff --git a/README.md b/README.md index 2df6b55..144c1eb 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ keys a code on the keypad, and the engine puts them in that room's Telnyx confer app decides what a code means (a room someone made here), names callers it can, logs who comes and goes, and streams that to the room page. -Our agents (riotcoder, cha0spunk, rootkey, CoinPay, Knox and Nora) are ElevenLabs voice agents built with +Our voice agents (riotcoder, cha0spunk, rootkey, CoinPay, Knox and Nora) are ElevenLabs voice agents built with [@profullstack/voice-agent](https://github.com/profullstack/voice-agent) (`src/agents.ts`): Claude Sonnet with real turn-taking, dialled straight into the room over SIP with X-Gc-* headers that say which room, who asked and why. During the call they act through group.call's @@ -52,11 +52,11 @@ through Telnyx and hears the room through Telnyx transcription. Anyone else's agent can do the same from any telephony API, after naming itself with `POST /api/v1/rooms/:code/expect`. -RiotCoder's phone fallback runs as the `riotcoder` Linux user. Its private worker key can claim -only RiotCoder invites and call tasks; the shared legacy worker skips RiotCoder's queue. The -installer keeps his Telnyx settings under `/home/riotcoder/.config/agent-phone/phone.env` and -installs the Off the Record podcast prompt in his own home. A trusted task requested in QryptChat -or during a call is handed to his full `agent-llm` runner and reported in the original chat. +Each configured agent has a separate `GROUPCALL_AGENT_WORKER_KEY_` and phone worker under +its own Linux account. A key can claim only that agent's calls and call tasks; the shared worker +skips every scoped account. Each worker hands tasks to its owner's QryptChat task runner, so +credentials and task state stay in that account. Trusted requests from QryptChat, a phone call, +or email run through the same full `agent-llm` profile and report to the originating chat. Calling someone in: the room rings any US or Canadian number from the dial-in, and whoever answers presses 1 to join. From the room page (**Call someone in**), the CLI @@ -69,7 +69,7 @@ Contacts and inviting by name: each account keeps a contacts book of people and with aliases for how speech recognition spells them ("chaos punk"). A contact can be reached by one of our agent identities, a SIP address, a phone number, or email. Phone contacts are called; email-only contacts receive the room name, dial-in number and code -by email, then call in themselves. Operators have all six fleet agents in their book +by email, then call in themselves. Operators have all seven fleet agents in their book without saving them. On a call, a known caller (or a person the owner marked trusted) just says **"invite riotcoder, cha0spunk and Kim"**: each name is matched against the room owner's book, agents are queued, people are rung or emailed, and the room hears what diff --git a/bin/install.conf b/bin/install.conf index 98d808e..440b0f4 100644 --- a/bin/install.conf +++ b/bin/install.conf @@ -2,7 +2,7 @@ # No secrets here: settings go in $STATE_DIR/app.env, written from the logicsrc # vault profullstack/group-call--prod (GROUPCALL_KEY, GROUPCALL_COINPAY_CLIENT_ID/SECRET, # COINPAY_API_KEY, COINPAY_WEBHOOK_SECRET, TELNYX_API_KEY, TELNYX_PUBLIC_KEY, -# GROUPCALL_DIAL_IN, GROUPCALL_AGENT_WORKER_KEY, GROUPCALL_AGENT_WORKER_KEY_RIOTCODER (agent-scoped), +# GROUPCALL_DIAL_IN, GROUPCALL_AGENT_WORKER_KEY (legacy), GROUPCALL_AGENT_WORKER_KEY_ (agent-scoped), # GROUPCALL_AGENT_OPERATORS (CoinPay subs, never emails), # GROUPCALL_AGENT_CALLER_IDS=riotcoder=+1...,cha0spunk=+1..., GROUPCALL_TRANSCRIBE, # GROUPCALL_SMTP_HOST/PORT/USER/PASS, GROUPCALL_EMAIL_FROM, @@ -20,4 +20,4 @@ PORT=8810 HEALTH_PATH=/health DOMAINS= ACCESS_LOG=off -AGENT_PHONE_USER=riotcoder +AGENT_PHONE_USERS="cha0spunk coinpay knox nora quill riotcoder rootkey" diff --git a/bin/install.sh b/bin/install.sh index 6369443..2f4e468 100755 --- a/bin/install.sh +++ b/bin/install.sh @@ -277,99 +277,132 @@ phase_setup() { install_agent_phone() { [ "$APP" = group-call ] || return 0 [ -f "$SRC_DIR/ops/agent-phone.mjs" ] || return 0 - local worker_user worker_home worker_group worker_node source_node source_node_version source_node_major source_node_minor phone_source phone_tmp worker_key worker_env_tmp - worker_user="${AGENT_PHONE_USER:-riotcoder}" - worker_home="$(getent passwd "$worker_user" | cut -d: -f6)" - [ -n "$worker_home" ] || die "AGENT_PHONE_USER '$worker_user' has no home directory" - worker_group="$(id -gn "$worker_user")" - if [ "$worker_user" = riotcoder ]; then - # Install a private Node runtime copy so RiotCoder never executes through - # Anthony's mise shims or depends on Anthony's home directory. - source_node="$(find /home/anthony/.local/share/mise/installs/node -maxdepth 4 -type f -name node -perm /111 2>/dev/null | sort -V | tail -n 1)" - [ -n "$source_node" ] || die "Node 22.18+ runtime not found for agent worker" - source_node_version="$("$source_node" --version 2>/dev/null || true)" - source_node_version="${source_node_version#v}" - source_node_major="${source_node_version%%.*}" - source_node_minor="${source_node_version#*.}" - source_node_minor="${source_node_minor%%.*}" - [[ "$source_node_major" =~ ^[0-9]+$ && "$source_node_minor" =~ ^[0-9]+$ ]] || die "could not read agent worker Node version" - (( source_node_major > 22 || (source_node_major == 22 && source_node_minor >= 18) )) || die "agent worker requires Node 22.18 or newer" + local -a worker_users ready missing_accounts missing caller_entries + local worker_user worker_home worker_group worker_node source_node source_node_version source_node_major source_node_minor + local phone_source phone_tmp worker_key worker_env_tmp worker_env_tmp_agent agent_upper worker_var old_worker_key caller_map caller_id + local groups entry path name + read -r -a worker_users <<< "${AGENT_PHONE_USERS:-${AGENT_PHONE_USER:-riotcoder}}" + [ "${#worker_users[@]}" -gt 0 ] || die "AGENT_PHONE_USERS must list at least one agent" + source_node="$(find /home/anthony/.local/share/mise/installs/node -maxdepth 4 -type f -name node -perm /111 2>/dev/null | sort -V | tail -n 1)" + [ -n "$source_node" ] || die "Node 22.18+ runtime not found for agent workers" + source_node_version="$("$source_node" --version 2>/dev/null || true)" + source_node_version="${source_node_version#v}" + source_node_major="${source_node_version%%.*}" + source_node_minor="${source_node_version#*.}" + source_node_minor="${source_node_minor%%.*}" + [[ "$source_node_major" =~ ^[0-9]+$ && "$source_node_minor" =~ ^[0-9]+$ ]] || die "could not read agent worker Node version" + (( source_node_major > 22 || (source_node_major == 22 && source_node_minor >= 18) )) || die "agent workers require Node 22.18 or newer" + + phone_source="/home/anthony/www/qrypt.chat/app.env" + [ -r "$phone_source" ] || die "phone integration settings not found at $phone_source" + phone_tmp="$(mktemp)" + grep -E '^(TELNYX_API_KEY|TELNYX_AGENT_APP_ID|AGENT_PHONE_SECRET|PHONE_CALLER_ID|GROUPCALL_AGENT_CALLER_IDS)=' "$phone_source" > "$phone_tmp" + for name in TELNYX_API_KEY TELNYX_AGENT_APP_ID AGENT_PHONE_SECRET PHONE_CALLER_ID; do + grep -q "^$name=" "$phone_tmp" || { rm -f "$phone_tmp"; die "required phone setting $name is missing"; } + done + caller_map="$(sed -n 's/^GROUPCALL_AGENT_CALLER_IDS=//p' "$phone_tmp" | tail -n 1)" + if [ -z "$caller_map" ] && [ -f "$STATE_DIR/app.env" ]; then + caller_map="$(sed -n 's/^GROUPCALL_AGENT_CALLER_IDS=//p' "$STATE_DIR/app.env" | tail -n 1)" + fi + caller_map="${caller_map#\"}"; caller_map="${caller_map%\"}"; caller_map="${caller_map#\'}"; caller_map="${caller_map%\'}" + caller_fallback="$(sed -n 's/^PHONE_CALLER_ID=//p' "$phone_tmp" | tail -n 1)" + caller_fallback="${caller_fallback#\"}"; caller_fallback="${caller_fallback%\"}"; caller_fallback="${caller_fallback#\'}"; caller_fallback="${caller_fallback%\'}" + old_worker_key="" + if [ -f "$STATE_DIR/riotcoder-worker.env" ]; then + old_worker_key="$(sed -n 's/^GROUPCALL_AGENT_WORKER_KEY_RIOTCODER=//p' "$STATE_DIR/riotcoder-worker.env" | tail -n 1)" + fi + ready=() + missing_accounts=() + worker_env_tmp="$(mktemp)" + chmod 0600 "$worker_env_tmp" + for worker_user in "${worker_users[@]}"; do + worker_home="$(getent passwd "$worker_user" | cut -d: -f6)" + [ -n "$worker_home" ] || die "agent account '$worker_user' has no home directory" + worker_group="$(id -gn "$worker_user")" + groups=" $(id -nG "$worker_user") " + case "$groups" in *" sudo "*|*" wheel "*|*" admin "*) die "$worker_user has an admin group" ;; esac + case "$worker_user" in *[!a-z0-9]*|'') die "invalid agent name '$worker_user'" ;; esac + agent_upper="$(printf '%s' "$worker_user" | tr '[:lower:]' '[:upper:]')" + worker_var="GROUPCALL_AGENT_WORKER_KEY_$agent_upper" + worker_key="${!worker_var:-}" + if [ -z "$worker_key" ] && [ -f "$STATE_DIR/agent-worker.env" ]; then + worker_key="$(sed -n "s/^$worker_var=//p" "$STATE_DIR/agent-worker.env" | tail -n 1)" + fi + if [ -z "$worker_key" ] && [ "$worker_user" = riotcoder ]; then worker_key="$old_worker_key"; fi + if [ -z "$worker_key" ]; then worker_key="$(od -An -N32 -tx1 /dev/urandom | tr -d '[:space:]')"; fi + printf '%s=%s\n' "$worker_var" "$worker_key" >> "$worker_env_tmp" + printf -v "$worker_var" '%s' "$worker_key" + export "$worker_var" + worker_node="$worker_home/.local/share/agent-phone/node" install -d -o "$worker_user" -g "$worker_group" -m 0750 "$worker_home/.local/share/agent-phone" "$worker_home/.local/bin" "$worker_home/.local/libexec" "$worker_home/.config/agent-phone" "$worker_home/.config/groupcall" "$worker_home/.config/qc-agents" - install -o "$worker_user" -g "$worker_group" -m 0755 "$source_node" "$worker_node" - - phone_source="/home/anthony/www/qrypt.chat/app.env" - [ -r "$phone_source" ] || die "phone integration settings not found at $phone_source" - phone_tmp="$(mktemp)" - grep -E '^(TELNYX_API_KEY|TELNYX_AGENT_APP_ID|AGENT_PHONE_SECRET|PHONE_CALLER_ID)=' "$phone_source" > "$phone_tmp" - for name in TELNYX_API_KEY TELNYX_AGENT_APP_ID AGENT_PHONE_SECRET PHONE_CALLER_ID; do - grep -q "^$name=" "$phone_tmp" || { rm -f "$phone_tmp"; die "required phone setting $name is missing"; } + install -o "$worker_user" -g "$worker_group" -m 0750 "$source_node" "$worker_node" + install -o "$worker_user" -g "$worker_group" -m 0600 "$phone_tmp" "$worker_home/.config/agent-phone/phone.env.tmp" + caller_id="$caller_fallback" + IFS=',' read -r -a caller_entries <<< "$caller_map" + for entry in "${caller_entries[@]}"; do + if [ "${entry%%=*}" = "$worker_user" ]; then caller_id="${entry#*=}"; fi done - install -o "$worker_user" -g "$worker_group" -m 0600 "$phone_tmp" "$worker_home/.config/agent-phone/phone.env" - rm -f "$phone_tmp" - - worker_key="${GROUPCALL_AGENT_WORKER_KEY_RIOTCODER:-}" - if [ -z "$worker_key" ] && [ -f "$STATE_DIR/riotcoder-worker.env" ]; then - worker_key="$(sed -n 's/^GROUPCALL_AGENT_WORKER_KEY_RIOTCODER=//p' "$STATE_DIR/riotcoder-worker.env" | tail -n 1)" + if [ -n "$caller_id" ]; then + sed -i '/^PHONE_CALLER_ID=/d' "$worker_home/.config/agent-phone/phone.env.tmp" + printf 'PHONE_CALLER_ID=%s\n' "$caller_id" >> "$worker_home/.config/agent-phone/phone.env.tmp" fi - if [ -z "$worker_key" ]; then - worker_key="$(od -An -N32 -tx1 /dev/urandom | tr -d '[:space:]')" - fi - put_root_file "$STATE_DIR/riotcoder-worker.env" 0600 < "$worker_env_tmp" < "$worker_env_tmp_agent" < "$worker_home/.local/bin/agent-phone.tmp" < "$worker_home/.local/bin/agent-phone.tmp" </dev/null 2>&1 || true + continue + fi + + put_root_file "/etc/systemd/system/groupcall-$worker_user-worker.service" 0644 </dev/null + ready+=("$worker_user") + done + rm -f "$phone_tmp" + put_root_file "$STATE_DIR/agent-worker.env" 0600 < "$worker_env_tmp" + rm -f "$worker_env_tmp" + rm -f "$STATE_DIR/riotcoder-worker.env" + systemctl daemon-reload + for worker_user in "${ready[@]}"; do systemctl enable --now "groupcall-$worker_user-worker.timer" >/dev/null; done + log "agent phone workers installed for ${ready[*]:-(none)}" + if [ "${#missing_accounts[@]}" -gt 0 ]; then + warn "phone workers skipped until each account has its own credentials: ${missing_accounts[*]}" fi - log "agent worker installed for $worker_user" } phase_build() { @@ -422,7 +463,7 @@ Environment=NODE_ENV=production Environment=PATH=$unit_path EnvironmentFile=-$STATE_DIR/db.env EnvironmentFile=-$STATE_DIR/app.env -EnvironmentFile=-$STATE_DIR/riotcoder-worker.env +EnvironmentFile=-$STATE_DIR/agent-worker.env ExecStart=$STATE_DIR/run.sh Restart=always RestartSec=2 diff --git a/ops/agent-phone.mjs b/ops/agent-phone.mjs index dbdec8e..4cf91a4 100755 --- a/ops/agent-phone.mjs +++ b/ops/agent-phone.mjs @@ -38,7 +38,7 @@ const envVal = (file, name) => { return process.env[name] || ''; } }; -const PHONE_ENV = HOME === '/home/riotcoder' ? `${HOME}/.config/agent-phone/phone.env` : `${HOME}/www/qrypt.chat/app.env`; +const PHONE_ENV = HOME === '/home/anthony' ? `${HOME}/www/qrypt.chat/app.env` : `${HOME}/.config/agent-phone/phone.env`; const TELNYX_KEY = envVal(PHONE_ENV, 'TELNYX_API_KEY'); const AGENT_APP = envVal(PHONE_ENV, 'TELNYX_AGENT_APP_ID'); const SECRET = envVal(PHONE_ENV, 'AGENT_PHONE_SECRET'); @@ -53,6 +53,7 @@ export const POLLY_VOICES = { coinpay: 'AWS.Polly.Danielle-Neural', knox: 'AWS.Polly.Joey-Neural', nora: 'AWS.Polly.Ruth-Neural', + quill: 'AWS.Polly.Matthew-Neural', }; export function fallbackVoiceFor(agent) { return POLLY_VOICES[agent] || FALLBACK_VOICE; } export function voiceFor(agent, override = '', skipElevenLabs = false) { @@ -69,7 +70,7 @@ 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' }; +const DISPLAY_NAMES = { riotcoder: 'RiotCoder', cha0spunk: 'ChaosPunk', rootkey: 'RootKey', coinpay: 'CoinPay', knox: 'Knox', nora: 'Nora', quill: 'Quill' }; 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) { const last = [...transcript].reverse().find((line) => line.who === agent); @@ -109,6 +110,7 @@ const NAME_ALIASES = { coinpay: ['coinpay', 'coin pay', 'cfo', 'accountant'], knox: ['knox', 'knocks', 'sales'], nora: ['nora', 'nora coach', 'career coach', 'workplace coach'], + quill: ['quill', 'scribe', 'call scribe'], }; function lev(a, b) { const d = Array.from({ length: a.length + 1 }, (_, i) => [i]); @@ -125,6 +127,7 @@ const SQUASHED = { coinpay: ['coinpay'], knox: ['knox'], nora: ['nora'], + quill: ['quill'], }; /** * Whether a transcribed line says this agent's name. Speech recognition mangles @@ -399,38 +402,35 @@ export function parseTurn(out) { } /** The qrypt.chat chat each agent reports task results into (the Agents group with Anthony). */ -const TASK_CHAT = { riotcoder: '6f1191b7-4dbf-4f31-b78e-8525880c1b2f', cha0spunk: '6f1191b7-4dbf-4f31-b78e-8525880c1b2f' }; +const TASK_CHAT = { + cha0spunk: '6f1191b7-4dbf-4f31-b78e-8525880c1b2f', + coinpay: '6f1191b7-4dbf-4f31-b78e-8525880c1b2f', + knox: '6f1191b7-4dbf-4f31-b78e-8525880c1b2f', + nora: '6f1191b7-4dbf-4f31-b78e-8525880c1b2f', + quill: '6f1191b7-4dbf-4f31-b78e-8525880c1b2f', + riotcoder: '6f1191b7-4dbf-4f31-b78e-8525880c1b2f', + rootkey: '6f1191b7-4dbf-4f31-b78e-8525880c1b2f', +}; /** Queue work for the agent's own task runner (riotcoder-qc-sweep --tasks), which reports in qrypt.chat. */ + function queueTask(agent, ack, brief, said) { - if (agent === 'riotcoder') { - const task = { - source: 'groupcall', - chat_id: TASK_CHAT[agent], - chat_title: 'Agents', - ack, - instructions: `Anthony's phone request:\n${brief}\n\nWords heard on the call (transcription may be wrong):\n${said}`, - context: said, - }; - execFileSync('/usr/bin/python3', ['/usr/local/libexec/qc-agent-responder', '--enqueue'], { - env: { ...process.env, QC_AGENT: agent }, - input: JSON.stringify(task), - stdio: ['pipe', 'ignore', 'ignore'], - timeout: 20_000, - }); - return; - } - const dir = `${HOME}/.local/share/${agent}-qc`; + if (!TASK_CHAT[agent]) throw new Error(`Unknown agent ${agent}`); const task = { + source: 'groupcall', chat_id: TASK_CHAT[agent], chat_title: 'Agents', ack, - brief, - cleared: null, - messages: `[${new Date().toISOString()}] (Anthony said this on a group.call phone call; transcribed, words may be wrong) ${said}`, + instructions: `Anthony's phone request:\n${brief}\n\nWords heard on the call (transcription may be wrong):\n${said}`, + request: said, + context: said, }; - appendFileSync(`${dir}/tasks.jsonl`, JSON.stringify(task) + '\n'); - execFile(`${HOME}/.local/bin/riotcoder-qc-sweep`, ['--tasks'], { env: { ...process.env, QC_AGENT: agent } }, () => {}); + execFileSync('/usr/bin/python3', ['/usr/local/libexec/qc-agent-responder', '--enqueue'], { + env: { ...process.env, QC_AGENT: agent }, + input: JSON.stringify(task), + stdio: ['pipe', 'ignore', 'ignore'], + timeout: 20_000, + }); } async function main() { @@ -1016,7 +1016,7 @@ async function groupcallWorker(a) { if (!key) throw new Error('GROUPCALL_AGENT_WORKER_KEY missing (~/.config/groupcall/worker.env)'); const workerAgent = String(a.agent || val('GROUPCALL_AGENT') || ''); if (workerAgent && !TASK_CHAT[workerAgent]) throw new Error('Unknown GROUPCALL_AGENT'); - if (workerAgent === 'riotcoder' && HOME !== '/home/riotcoder') throw new Error('RiotCoder phone worker must run from /home/riotcoder'); + if (workerAgent && HOME !== `/home/${workerAgent}`) throw new Error(`${workerAgent} phone worker must run from its own home`); const until = Date.now() + (Number(a.for) || 55) * 1000; const post = (path, body) => fetch(`${base}${path}`, { method: 'POST', headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' }, ...(body ? { body: JSON.stringify(body) } : {}) }); diff --git a/src/db.ts b/src/db.ts index e72a447..4fe460c 100644 --- a/src/db.ts +++ b/src/db.ts @@ -309,11 +309,14 @@ export class Store { } /** The oldest queued task, taken (once) by whoever asks. */ - claimTask(now = Date.now(), agent?: string, excludeAgent?: string): AgentTask | null { + claimTask(now = Date.now(), agent?: string, excludeAgents: string[] = []): AgentTask | null { const filters = ["status = 'queued'"] const values: string[] = [] if (agent) { filters.push('agent = ?'); values.push(agent) } - if (excludeAgent) { filters.push('agent <> ?'); values.push(excludeAgent) } + if (excludeAgents.length) { + filters.push(`agent NOT IN (${excludeAgents.map(() => '?').join(', ')})`) + values.push(...excludeAgents) + } const where = filters.join(' AND ') const r = this.db.query(`UPDATE agent_tasks SET status = 'taken', taken_at = ? WHERE id = (SELECT id FROM agent_tasks WHERE ${where} ORDER BY created_at LIMIT 1) RETURNING *`).get(now, ...values) as any return r ? { id: r.id, agent: r.agent, code: r.code, roomName: r.room_name, brief: r.brief, ack: r.ack, said: r.said, status: 'taken', createdAt: r.created_at } : null @@ -599,12 +602,15 @@ export class Store { * workers never dial the same agent. Invites older than ten minutes are stale: * whoever asked has given up. */ - claimInvite(now = Date.now(), agent?: string, excludeAgent?: string): AgentInvite | null { + claimInvite(now = Date.now(), agent?: string, excludeAgents: string[] = []): AgentInvite | null { 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) const filters = ["status = 'queued'"] const values: string[] = [] if (agent) { filters.push('agent = ?'); values.push(agent) } - if (excludeAgent) { filters.push('agent <> ?'); values.push(excludeAgent) } + if (excludeAgents.length) { + filters.push(`agent NOT IN (${excludeAgents.map(() => '?').join(', ')})`) + values.push(...excludeAgents) + } const where = filters.join(' AND ') return this.toInvite( this.db diff --git a/src/html.ts b/src/html.ts index 67581c5..a79ebcf 100644 --- a/src/html.ts +++ b/src/html.ts @@ -458,7 +458,7 @@ export function helpPage(ctx: Ctx): string {

Agents

-

Our voice agents dial into the room over SIP and use group.call tools to follow the room context. The fleet includes riotcoder, cha0spunk, rootkey, CoinPay, Knox and Nora. Room owners on this instance can invite them from the room page.

+

Our fleet agents can join rooms and receive tasks under their own accounts. RiotCoder, ChaosPunk, RootKey, CoinPay, Knox and Nora have voice agents over SIP; Quill joins through the private phone worker. Room owners on this instance can invite any of them from the room page.

Invite by name. Keep a contacts book of people and agents, reached by one of our agent identities, a SIP address, a phone number or email. On the call, just say "invite riotcoder, cha0spunk and Kim": agents dial in, phone contacts are called, email-only contacts get the room number and code, and the room hears what happened. That works for the room's known callers and for people you marked trusted; anyone else saying it changes nothing. Also groupcall invite 4821 riotcoder, Kim, MCP invite, or POST /api/v1/rooms/4821/invite {"names": "riotcoder, Kim"}.

Group texts. Share a link on a call (a readm3.com report, a PDF, anything) and everyone on the line gets it by text from ${esc(number)}: text it to ${esc(number)} while you are on the call, use Text everyone on the room page, groupcall text 4821 https://readm3.com/..., MCP text_room, or ask riotcoder on the call. Reply STOP to stop getting them. Optional: connect the same CoinPay account from SMSHub Settings and your rooms' texts go out as smshub groups from your own number; if smshub cannot send, the room number is used.

Webhooks. Add a URL on your account (or groupcall webhook add, MCP create_webhook, POST /api/v1/webhooks) and group.call POSTs room.joined, room.left, transcript.line, text.sent, phone.called and recording.ready to it as signed CloudEvents (Standard Webhooks, via @profullstack/webhooks).

@@ -483,7 +483,7 @@ export function helpPage(ctx: Ctx): string { POST /api/v1/rooms/:code/expect{"name", "from"}: name the next caller from that number POST /api/v1/rooms/:code/call{"phone", "name"?}: ring a phone into the room; they press 1 to join (pass). Answers with the route used (via) and its rate a minute GET /api/v1/rates?phone=what a minute to that number costs on every route that carries it, cheapest first (least-cost routing) - POST /api/v1/rooms/:code/agents{"agent"}: ask riotcoder or cha0spunk in (operators) + POST /api/v1/rooms/:code/agents{"agent"}: ask a fleet agent in (operators) GET /r/:code/liveserver-sent events as people come and go (signed in)
diff --git a/src/mcp.ts b/src/mcp.ts index b94b910..33c9a71 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -18,7 +18,7 @@ const wrap = (fn: (a: any) => Promise) => async (a: any) => { return failure(e) } } -const fleetAgent = z.enum(['riotcoder', 'cha0spunk', 'rootkey', 'coinpay', 'knox', 'nora']) +const fleetAgent = z.enum(['riotcoder', 'cha0spunk', 'rootkey', 'coinpay', 'knox', 'nora', 'quill']) const contactFields = { name: z.string().min(1).max(40).optional(), kind: z.enum(['person', 'agent']).optional(), @@ -82,7 +82,7 @@ export function createMcpServer(client = new GroupCallClient()): McpServer { server.tool('list_contacts', 'Your contacts book: people and agents you can invite by name (operators also see our own agents, builtin).', {}, wrap(() => client.contacts())) server.tool( 'add_contact', - 'Add someone to your contacts book with any details you have. A phone or SIP address lets group.call call them; an email-only contact gets room dialing instructions when invited. An agent can also be one of ours (riotcoder|cha0spunk|rootkey|coinpay|knox|nora). Aliases help speech recognition ("chaos punk"); trusted lets a person ask for invites by voice from their phone.', + 'Add someone to your contacts book with any details you have. A phone or SIP address lets group.call call them; an email-only contact gets room dialing instructions when invited. An agent can also be one of ours (riotcoder|cha0spunk|rootkey|coinpay|knox|nora|quill). Aliases help speech recognition ("chaos punk"); trusted lets a person ask for invites by voice from their phone.', { ...contactFields, name: z.string().min(1).max(40) }, wrap((c) => client.addContact(c)), ) @@ -93,7 +93,7 @@ export function createMcpServer(client = new GroupCallClient()): McpServer { return client.updateContact(c.id, fields) })) server.tool('remove_contact', 'Remove a contact by its id (from list_contacts).', { id: z.string() }, wrap(({ id }) => client.removeContact(id))) - server.tool('invite_agent', "Ask one of the instance's own agents (riotcoder, cha0spunk, rootkey, coinpay, knox or nora) to dial in to a room. Operators only.", { code, agent: fleetAgent }, wrap(({ code, agent }) => client.invite(code, agent))) + server.tool('invite_agent', "Ask one of the instance's own agents (riotcoder, cha0spunk, rootkey, coinpay, knox, nora or quill) to dial in to a room. Operators only.", { code, agent: fleetAgent }, wrap(({ code, agent }) => client.invite(code, agent))) server.tool('create_invite', 'Make a single-use invite link to group.call (each person has a few). Whoever signs in through it and you both get bonus minutes.', { note: z.string().max(80).optional() }, wrap(({ note }) => client.newInvite(note))) server.tool('redeem_code', 'Redeem a credit code (extra call minutes, one-off or every month).', { code: z.string().min(6) }, wrap(({ code }) => client.redeem(code))) server.tool( diff --git a/src/server.ts b/src/server.ts index cbe9e0e..0e1481a 100644 --- a/src/server.ts +++ b/src/server.ts @@ -827,31 +827,36 @@ export function createApp(deps: Deps) { // ---- the agent worker -------------------------------------------------------- // The box our agents run on cannot take webhooks, so it asks here for work. - type WorkerScope = 'shared' | 'riotcoder' + type WorkerScope = 'shared' | { agent: string } + const scopedWorkerKeys: Record = { ...(config.agentWorkerKeys ?? {}) } + if (config.riotcoderAgentWorkerKey) scopedWorkerKeys.riotcoder ??= config.riotcoderAgentWorkerKey const workerScope = (c: Context): WorkerScope | null => { const key = (c.req.header('authorization') ?? '').replace(/^Bearer\s+/i, '') - if (config.riotcoderAgentWorkerKey && sameSecret(key, config.riotcoderAgentWorkerKey)) return 'riotcoder' + for (const [agent, workerKey] of Object.entries(scopedWorkerKeys)) { + if (sameSecret(key, workerKey)) return { agent } + } if (config.agentWorkerKey && sameSecret(key, config.agentWorkerKey)) return 'shared' return null } - const isolateRiotcoderWorker = Boolean(config.riotcoderAgentWorkerKey) const workerCanUseAgent = (scope: WorkerScope, agent: string) => - scope === 'riotcoder' ? agent === 'riotcoder' : !isolateRiotcoderWorker || agent !== 'riotcoder' + scope === 'shared' ? !scopedWorkerKeys[agent] : scope.agent === agent const workerCanUseRoom = (scope: WorkerScope, code: string) => { const roster = store.roomAgentRoster(code).map((entry) => entry.agent) - return scope === 'riotcoder' ? roster.includes('riotcoder') : !isolateRiotcoderWorker || !roster.includes('riotcoder') + return scope === 'shared' ? roster.every((agent) => workerCanUseAgent(scope, agent)) : roster.includes(scope.agent) } + const scopedAgent = (scope: WorkerScope) => scope === 'shared' ? undefined : scope.agent + const excludedAgents = (scope: WorkerScope) => scope === 'shared' ? Object.keys(scopedWorkerKeys) : [] app.post('/api/v1/agent-queue/next', (c) => { const scope = workerScope(c) if (!scope) return c.json({ error: 'worker key required' }, 401) - const invite = svc.nextInvite(scope === 'riotcoder' ? 'riotcoder' : undefined, scope === 'shared' && isolateRiotcoderWorker ? 'riotcoder' : undefined) + const invite = svc.nextInvite(scopedAgent(scope), excludedAgents(scope)) return invite ? c.json(invite) : c.body(null, 204) }) // Work our voice agents took on during calls: the worker hands each to the agent's task runner. app.post('/api/v1/agent-queue/tasks/next', (c) => { const scope = workerScope(c) if (!scope) return c.json({ error: 'worker key required' }, 401) - const task = svc.nextTask(scope === 'riotcoder' ? 'riotcoder' : undefined, scope === 'shared' && isolateRiotcoderWorker ? 'riotcoder' : undefined) + const task = svc.nextTask(scopedAgent(scope), excludedAgents(scope)) return task ? c.json({ id: task.id, agent: task.agent, code: task.code, room_name: task.roomName, brief: task.brief, ack: task.ack, said: task.said, at: new Date(task.createdAt).toISOString() }) : c.body(null, 204) }) @@ -929,6 +934,11 @@ export function createApp(deps: Deps) { export function serve(options: { port?: number; hostname?: string } = {}) { const env = process.env const key = env.GROUPCALL_KEY ?? '' + const agentWorkerKeys = Object.fromEntries( + Object.entries(env) + .filter(([name, value]) => name.startsWith('GROUPCALL_AGENT_WORKER_KEY_') && /^[A-Z0-9]+$/.test(name.slice('GROUPCALL_AGENT_WORKER_KEY_'.length)) && Boolean(value)) + .map(([name, value]) => [name.slice('GROUPCALL_AGENT_WORKER_KEY_'.length).toLowerCase(), value!]), + ) const smtpPassword = env.GROUPCALL_SMTP_PASS ?? env.MAIL_WORK_PASSWORD ?? '' const smtpUser = env.GROUPCALL_SMTP_USER ?? env.MAIL_WORK_USER ?? 'anthony@profullstack.com' const emailFrom = env.GROUPCALL_EMAIL_FROM ?? 'Anthony ' @@ -993,6 +1003,7 @@ export function serve(options: { port?: number; hostname?: string } = {}) { ...(env.GROUPCALL_VOICE_KEY_REF ? { telnyxVoiceKeyRef: env.GROUPCALL_VOICE_KEY_REF } : {}), agentOperators: operatorList(env.GROUPCALL_AGENT_OPERATORS), ...(env.GROUPCALL_AGENT_WORKER_KEY ? { agentWorkerKey: env.GROUPCALL_AGENT_WORKER_KEY } : {}), + ...(Object.keys(agentWorkerKeys).length ? { agentWorkerKeys } : {}), ...(env.GROUPCALL_AGENT_WORKER_KEY_RIOTCODER ? { riotcoderAgentWorkerKey: env.GROUPCALL_AGENT_WORKER_KEY_RIOTCODER } : {}), ...(sendInvitationEmail ? { sendInvitationEmail } : {}), knownCallers: Object.fromEntries( diff --git a/src/service.ts b/src/service.ts index 31544f8..26a2637 100644 --- a/src/service.ts +++ b/src/service.ts @@ -69,7 +69,9 @@ export type Config = { agentOperators?: string[] /** The worker that dials agents authenticates with this. */ agentWorkerKey?: string - /** RiotCoder's worker key is scoped to his Linux account and queue. */ + /** Per-agent worker keys scope phone workers to their Linux account and queue. */ + agentWorkerKeys?: Record + /** Backward compatibility for the RiotCoder-only worker rollout. */ riotcoderAgentWorkerKey?: string /** * Our agents as ElevenLabs voice agents (src/agents.ts): agent -> SIP address. An agent @@ -119,6 +121,7 @@ export const AGENTS: Record { expect((await riotTask.json() as any).agent).toBe('riotcoder') }) + test('each agent worker key claims only its own invites and tasks; shared worker skips scoped agents', async () => { + const { call, app, store } = setup({ + agentWorkerKeys: { coinpay: 'coinpay-worker-secret', quill: 'quill-worker-secret' }, + }) + const coinpayRoom = (await (await call('gc_op', 'POST', '/api/v1/rooms', { name: 'CoinPay' })).json()) as any + const quillRoom = (await (await call('gc_op', 'POST', '/api/v1/rooms', { name: 'Quill' })).json()) as any + const openRoom = (await (await call('gc_op', 'POST', '/api/v1/rooms', { name: 'Shared' })).json()) as any + expect((await call('gc_op', 'POST', `/api/v1/rooms/${coinpayRoom.code}/agents`, { agent: 'coinpay' })).status).toBe(202) + expect((await call('gc_op', 'POST', `/api/v1/rooms/${quillRoom.code}/agents`, { agent: 'quill' })).status).toBe(202) + expect((await call('gc_op', 'POST', `/api/v1/rooms/${openRoom.code}/agents`, { agent: 'cha0spunk' })).status).toBe(202) + store.addTask({ agent: 'coinpay', code: coinpayRoom.code, roomName: 'CoinPay', brief: 'CoinPay task', ack: 'On it.', said: 'Finance' }) + store.addTask({ agent: 'quill', code: quillRoom.code, roomName: 'Quill', brief: 'Quill task', ack: 'On it.', said: 'Notes' }) + store.addTask({ agent: 'cha0spunk', code: openRoom.code, roomName: 'Shared', brief: 'Shared task', ack: 'On it.', said: 'Email' }) + + const headers = (key: string) => ({ authorization: `Bearer ${key}` }) + const sharedInvite = await app.request('/api/v1/agent-queue/next', { method: 'POST', headers: headers('worker-secret') }) + expect((await sharedInvite.json() as any).agent).toBe('cha0spunk') + const coinpayInvite = await app.request('/api/v1/agent-queue/next', { method: 'POST', headers: headers('coinpay-worker-secret') }) + expect((await coinpayInvite.json() as any).agent).toBe('coinpay') + const quillInvite = await app.request('/api/v1/agent-queue/next', { method: 'POST', headers: headers('quill-worker-secret') }) + expect((await quillInvite.json() as any).agent).toBe('quill') + + const sharedTask = await app.request('/api/v1/agent-queue/tasks/next', { method: 'POST', headers: headers('worker-secret') }) + expect((await sharedTask.json() as any).agent).toBe('cha0spunk') + const coinpayTask = await app.request('/api/v1/agent-queue/tasks/next', { method: 'POST', headers: headers('coinpay-worker-secret') }) + expect((await coinpayTask.json() as any).agent).toBe('coinpay') + const quillTask = await app.request('/api/v1/agent-queue/tasks/next', { method: 'POST', headers: headers('quill-worker-secret') }) + expect((await quillTask.json() as any).agent).toBe('quill') + }) + test('Nora has a dedicated caller ID, so a worker arrival is shown by name', async () => { const noraNumber = '+19492847328' const { call, event, svc } = setup({ @@ -1134,7 +1164,7 @@ describe('the contacts book and inviting by name', () => { const kim = await call('gc_op', 'POST', '/api/v1/contacts', { name: 'Kim Lee', phone: '408-555-0123', aliases: ['kimmy'] }) expect(kim.status).toBe(201) const book = ((await (await call('gc_op', 'GET', '/api/v1/contacts')).json()) as any).contacts - expect(book.map((c: any) => `${c.name}:${c.kind}:${c.builtin ?? false}`)).toEqual(['riotcoder:agent:true', 'cha0spunk:agent:true', 'rootkey:agent:true', 'coinpay:agent:true', 'knox:agent:true', 'nora:agent:true', 'Kim Lee:person:false']) + expect(book.map((c: any) => `${c.name}:${c.kind}:${c.builtin ?? false}`)).toEqual(['riotcoder:agent:true', 'cha0spunk:agent:true', 'rootkey:agent:true', 'coinpay:agent:true', 'knox:agent:true', 'nora:agent:true', 'quill:agent:true', 'Kim Lee:person:false']) await event('call.initiated', { call_control_id: 'leg-me', direction: 'incoming', from: ME }) await event('call.gather.ended', { call_control_id: 'leg-me', digits: room.code })