From 350d4d6d57d9eb780f0abcf607941d5152d295e1 Mon Sep 17 00:00:00 2001 From: Abhishek Date: Fri, 13 Feb 2026 09:23:22 -0800 Subject: [PATCH] feat: add Telnyx/ClawdTalk voice channel integration - Add telnyx-voice-client.ts for voice calling capabilities - Support inbound calls with auto-answer and greeting - Support outbound calls via CLI and agent responses - DTMF gathering for keypad input during calls - Text-to-speech for agent responses to callers - Webhook server for Telnyx call events - Update channel registry in common.sh and setup-wizard.sh - Add voice configuration to types.ts - Create docs/VOICE.md with setup instructions - Update README.md with voice channel information - Add .env.example with Telnyx configuration variables ClawdTalk integration enables AI agents to make and receive phone calls through Telnyx programmable voice API. --- .env.example | 21 + README.md | 18 +- docs/VOICE.md | 203 ++++++++++ lib/common.sh | 13 +- lib/setup-wizard.sh | 25 +- package.json | 2 + src/channels/telnyx-voice-client.ts | 602 ++++++++++++++++++++++++++++ src/lib/types.ts | 7 + 8 files changed, 884 insertions(+), 7 deletions(-) create mode 100644 .env.example create mode 100644 docs/VOICE.md create mode 100644 src/channels/telnyx-voice-client.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..42cf799 --- /dev/null +++ b/.env.example @@ -0,0 +1,21 @@ +# TinyClaw Environment Variables +# Copy this file to .env and fill in your values + +# Discord Bot Token +# Get one at: https://discord.com/developers/applications +DISCORD_BOT_TOKEN=your_discord_bot_token_here + +# Telegram Bot Token +# Create a bot via @BotFather on Telegram +TELEGRAM_BOT_TOKEN=your_telegram_bot_token_here + +# Telnyx Voice Channel (Optional) +# Get your API key at: https://portal.telnyx.com/#/app/api-keys +TELNYX_API_KEY=your_telnyx_api_key_here +TELNYX_PUBLIC_KEY=your_telnyx_public_key_here +TELNYX_CONNECTION_ID=your_connection_id_here +TELNYX_PHONE_NUMBER=+15551234567 + +# Voice Webhook Configuration +TELNYX_WEBHOOK_PORT=8080 +TELNYX_WEBHOOK_PATH=/telnyx-webhook diff --git a/README.md b/README.md index 490fe90..327223b 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Run multiple AI agents simultaneously with isolated workspaces and conversation - ✅ **Multi-agent** - Run multiple isolated AI agents with specialized roles - ✅ **Team collaboration** - Agents hand off work to teammates via chain execution and fan-out - ✅ **Multiple AI providers** - Anthropic Claude (Sonnet/Opus) and OpenAI (GPT/Codex) -- ✅ **Multi-channel** - Discord, WhatsApp, and Telegram +- ✅ **Multi-channel** - Discord, WhatsApp, Telegram, and Voice (Telnyx/ClawdTalk) - ✅ **Parallel processing** - Agents process messages concurrently - ✅ **Live TUI dashboard** - Real-time team visualizer for monitoring agent chains - ✅ **Persistent sessions** - Conversation context maintained across restarts @@ -97,6 +97,16 @@ After starting TinyClaw, scan the QR code: 📱 Settings → Linked Devices → Link a Device ``` +### Voice Setup (Telnyx/ClawdTalk) + +1. Create a [Telnyx account](https://telnyx.com) +2. Create an API key at [Portal > API Keys](https://portal.telnyx.com/#/app/api-keys) +3. Configure a [Voice Profile](https://portal.telnyx.com/#/app/voice/profiles) +4. Purchase a [Phone Number](https://portal.telnyx.com/#/app/numbers) +5. Set webhook URL in your voice profile + +See [docs/VOICE.md](docs/VOICE.md) for detailed setup instructions. + ## 📋 Commands @@ -187,7 +197,7 @@ export TINYCLAW_SKIP_UPDATE_CHECK=1 ### In-Chat Commands -These commands work in Discord, Telegram, and WhatsApp: +These commands work in Discord, Telegram, WhatsApp, and Voice calls: | Command | Description | Example | | ------------------- | -------------------------------------------- | ------------------------------------ | @@ -266,7 +276,7 @@ See [docs/AGENTS.md](docs/AGENTS.md) for: ``` ┌─────────────────────────────────────────────────────────────┐ │ Message Channels │ -│ (Discord, Telegram, WhatsApp, Heartbeat) │ +│ (Discord, Telegram, WhatsApp, Voice/Telnyx, Heartbeat) │ └────────────────────┬────────────────────────────────────────┘ │ Write message.json ↓ @@ -454,6 +464,7 @@ All channels share agent conversations! - [AGENTS.md](docs/AGENTS.md) - Agent management and routing - [TEAMS.md](docs/TEAMS.md) - Team collaboration, chain execution, and visualizer - [QUEUE.md](docs/QUEUE.md) - Queue system and message flow +- [VOICE.md](docs/VOICE.md) - Voice channel setup (Telnyx/ClawdTalk) - [TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) - Common issues and solutions ## 🐛 Troubleshooting @@ -493,6 +504,7 @@ tinyclaw logs all - Inspired by [OpenClaw](https://openclaw.ai/) by Peter Steinberger - Built on [Claude Code](https://claude.com/claude-code) and [Codex CLI](https://docs.openai.com/codex) - Uses [discord.js](https://discord.js.org/), [whatsapp-web.js](https://github.com/pedroslopez/whatsapp-web.js), [node-telegram-bot-api](https://github.com/yagop/node-telegram-bot-api) +- Voice powered by [Telnyx](https://telnyx.com) and [ClawdTalk](https://clawdtalk.com) ## 📄 License diff --git a/docs/VOICE.md b/docs/VOICE.md new file mode 100644 index 0000000..58c491a --- /dev/null +++ b/docs/VOICE.md @@ -0,0 +1,203 @@ +# Voice Channel (Telnyx/ClawdTalk) + +TinyClaw supports voice calling capabilities through Telnyx, powered by ClawdTalk for AI voice interactions. This channel enables your agents to make and receive phone calls. + +## Features + +- **Inbound calls**: Receive calls on your Telnyx phone number +- **Outbound calls**: Make calls programmatically from agents +- **Text-to-speech**: Speak responses to callers +- **DTMF gathering**: Collect keypad input from callers +- **Call recording**: Optional recording of conversations +- **Multi-agent routing**: Route calls to specific agents with `@agent_id` + +## Setup + +### 1. Create a Telnyx Account + +1. Sign up at [telnyx.com](https://telnyx.com) +2. Navigate to the [Portal](https://portal.telnyx.com) +3. Complete account verification + +### 2. Create an API Key + +1. Go to [API Keys](https://portal.telnyx.com/#/app/api-keys) +2. Click "Create API Key" +3. Save the key securely (you will need it for configuration) + +### 3. Configure a Voice Profile + +1. Go to [Voice > Profiles](https://portal.telnyx.com/#/app/voice/profiles) +2. Create a new voice profile +3. Note the **Connection ID** for configuration + +### 4. Purchase a Phone Number + +1. Go to [Phone Numbers](https://portal.telnyx.com/#/app/numbers) +2. Search for and purchase a number +3. Assign it to your voice profile +4. Note the number in E.164 format (e.g., `+15551234567`) + +### 5. Configure Webhook + +1. In your voice profile settings, set the webhook URL: + ``` + https://your-server.com:8080/telnyx-webhook + ``` +2. Ensure your server is accessible from the internet +3. Port 8080 is used by default (configurable via `TELNYX_WEBHOOK_PORT`) + +### 6. Run Setup Wizard + +```bash +tinyclaw setup +``` + +When prompted: +1. Select "Voice (Telnyx/ClawdTalk)" as a channel +2. Enter your Telnyx API key +3. Enter your Connection ID +4. Enter your phone number + +## Environment Variables + +The voice channel can also be configured via environment variables: + +| Variable | Description | Required | +|----------|-------------|----------| +| `TELNYX_API_KEY` | Your Telnyx API key | Yes | +| `TELNYX_PUBLIC_KEY` | Your Telnyx public key (for webhook verification) | Recommended | +| `TELNYX_CONNECTION_ID` | Voice profile connection ID | For outbound calls | +| `TELNYX_PHONE_NUMBER` | Your Telnyx phone number (E.164) | For outbound calls | +| `TELNYX_WEBHOOK_PORT` | Webhook server port (default: 8080) | No | +| `TELNYX_WEBHOOK_PATH` | Webhook endpoint path (default: `/telnyx-webhook`) | No | + +## Usage + +### Inbound Calls + +When someone calls your Telnyx number: +1. The call is automatically answered +2. A greeting is spoken to the caller +3. The call is routed to the default agent +4. Agent responses are spoken to the caller +5. DTMF input is collected and processed + +### Outbound Calls + +From the command line: +```bash +node dist/channels/telnyx-voice-client.js call +15551234567 "Hello, this is a test call" +``` + +From within an agent response: +``` +[voice_call: +15551234567] +``` + +### Agent Routing + +Route voice calls to specific agents: +- During a call, say `@agent_id` followed by your request +- Example: "Let me connect you to @coder for that technical question" + +### DTMF Input + +Callers can use their keypad to provide input: +- Press 1 for sales +- Press 2 for support +- etc. + +The DTMF tones are captured and forwarded to the agent as text. + +## Response Metadata + +Agents can include special metadata in their responses to control the call: + +```json +{ + "message": "Thank you for calling. Goodbye!", + "metadata": { + "speak": "Thank you for calling. Goodbye!", + "hangup": true + } +} +``` + +| Field | Description | +|-------|-------------| +| `speak` | Text to speak to the caller | +| `hangup` | Whether to end the call after speaking | +| `gather` | Whether to collect DTMF input after speaking | + +## ClawdTalk Integration + +[ClawdTalk](https://clawdtalk.com) provides advanced voice AI capabilities for AI agents: + +- Natural language understanding +- Real-time transcription +- Conversation flow management +- Multi-language support + +To use ClawdTalk with your TinyClaw voice channel: +1. Sign up at [clawdtalk.com](https://clawdtalk.com) +2. Follow the integration guide at [github.com/team-telnyx/clawdtalk-client](https://github.com/team-telnyx/clawdtalk-client) +3. Configure your agent to use ClawdTalk for voice processing + +## Troubleshooting + +### Webhook Not Receiving Events + +1. Verify your webhook URL is publicly accessible +2. Check firewall rules allow inbound connections on the webhook port +3. Verify the webhook URL matches what's configured in Telnyx portal + +### Outbound Calls Failing + +1. Verify `TELNYX_CONNECTION_ID` and `TELNYX_PHONE_NUMBER` are set +2. Check your Telnyx account has sufficient balance +3. Verify the destination number is in E.164 format + +### Signature Verification Failing + +1. Ensure `TELNYX_PUBLIC_KEY` is set correctly +2. Verify you're using the correct public key (not the API key) + +## Logs + +View voice channel logs: +```bash +tinyclaw logs voice +``` + +Or directly: +```bash +tail -f ~/.tinyclaw/logs/voice.log +``` + +## API Reference + +The voice channel exports the following functions for programmatic use: + +```typescript +import { makeOutboundCall, speakOnCall, hangupCall, gatherInput } from './channels/telnyx-voice-client'; + +// Make an outbound call +const callControlId = await makeOutboundCall('+15551234567', 'Hello from AI'); + +// Speak on an active call +await speakOnCall(callControlId, 'This is a test message'); + +// Gather DTMF input +await gatherInput(callControlId); + +// Hang up a call +await hangupCall(callControlId); +``` + +## Resources + +- [Telnyx Documentation](https://developers.telnyx.com) +- [Telnyx Node SDK](https://www.npmjs.com/package/@telnyx/node) +- [ClawdTalk](https://clawdtalk.com) +- [ClawdTalk Client](https://github.com/team-telnyx/clawdtalk-client) diff --git a/lib/common.sh b/lib/common.sh index 4f6d2d2..7ec8d5a 100644 --- a/lib/common.sh +++ b/lib/common.sh @@ -31,30 +31,35 @@ NC='\033[0m' # --- Channel registry --- # Single source of truth. Add new channels here and everything else adapts. -ALL_CHANNELS=(discord whatsapp telegram) +ALL_CHANNELS=(discord whatsapp telegram voice) declare -A CHANNEL_DISPLAY=( [discord]="Discord" [whatsapp]="WhatsApp" [telegram]="Telegram" + [voice]="Voice (Telnyx)" ) declare -A CHANNEL_SCRIPT=( [discord]="dist/channels/discord-client.js" [whatsapp]="dist/channels/whatsapp-client.js" [telegram]="dist/channels/telegram-client.js" + [voice]="dist/channels/telnyx-voice-client.js" ) declare -A CHANNEL_ALIAS=( [discord]="dc" [whatsapp]="wa" [telegram]="tg" + [voice]="vc" ) declare -A CHANNEL_TOKEN_KEY=( [discord]="discord_bot_token" [telegram]="telegram_bot_token" + [voice]="api_key" ) declare -A CHANNEL_TOKEN_ENV=( [discord]="DISCORD_BOT_TOKEN" [telegram]="TELEGRAM_BOT_TOKEN" + [voice]="TELNYX_API_KEY" ) # Runtime state: filled by load_settings @@ -105,7 +110,11 @@ load_settings() { for ch in "${ALL_CHANNELS[@]}"; do local token_key="${CHANNEL_TOKEN_KEY[$ch]:-}" if [ -n "$token_key" ]; then - CHANNEL_TOKENS[$ch]=$(jq -r ".channels.${ch}.bot_token // empty" "$SETTINGS_FILE" 2>/dev/null) + if [ "$ch" = "voice" ]; then + CHANNEL_TOKENS[$ch]=$(jq -r ".channels.voice.api_key // empty" "$SETTINGS_FILE" 2>/dev/null) + else + CHANNEL_TOKENS[$ch]=$(jq -r ".channels.${ch}.bot_token // empty" "$SETTINGS_FILE" 2>/dev/null) + fi fi done diff --git a/lib/setup-wizard.sh b/lib/setup-wizard.sh index c7acfe2..bf58b7e 100755 --- a/lib/setup-wizard.sh +++ b/lib/setup-wizard.sh @@ -19,24 +19,28 @@ echo "" # --- Channel registry --- # To add a new channel, add its ID here and fill in the config arrays below. -ALL_CHANNELS=(telegram discord whatsapp) +ALL_CHANNELS=(telegram discord whatsapp voice) declare -A CHANNEL_DISPLAY=( [telegram]="Telegram" [discord]="Discord" [whatsapp]="WhatsApp" + [voice]="Voice (Telnyx/ClawdTalk)" ) declare -A CHANNEL_TOKEN_KEY=( [discord]="discord_bot_token" [telegram]="telegram_bot_token" + [voice]="telnyx_api_key" ) declare -A CHANNEL_TOKEN_PROMPT=( [discord]="Enter your Discord bot token:" [telegram]="Enter your Telegram bot token:" + [voice]="Enter your Telnyx API key:" ) declare -A CHANNEL_TOKEN_HELP=( [discord]="(Get one at: https://discord.com/developers/applications)" [telegram]="(Create a bot via @BotFather on Telegram to get a token)" + [voice]="(Get one at: https://portal.telnyx.com/#/app/api-keys)" ) # Channel selection - simple checklist @@ -265,6 +269,18 @@ CHANNELS_JSON="${CHANNELS_JSON}]" # Build channel configs with tokens DISCORD_TOKEN="${TOKENS[discord]:-}" TELEGRAM_TOKEN="${TOKENS[telegram]:-}" +VOICE_API_KEY="${TOKENS[voice]:-}" + +# Collect additional voice configuration if voice is enabled +VOICE_CONNECTION_ID="" +VOICE_PHONE_NUMBER="" +if [[ " ${ENABLED_CHANNELS[*]} " =~ " voice " ]] && [ -n "$VOICE_API_KEY" ]; then + echo "" + echo -e "${YELLOW}Voice channel configuration:${NC}" + echo "" + read -rp " Telnyx Connection ID: " VOICE_CONNECTION_ID + read -rp " Telnyx Phone Number (E.164 format, e.g., +15551234567): " VOICE_PHONE_NUMBER +fi # Write settings.json with layered structure # Use jq to build valid JSON to avoid escaping issues with agent prompts @@ -288,7 +304,12 @@ cat > "$SETTINGS_FILE" < { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } +}); + +// Configuration +const TELNYX_API_KEY = process.env.TELNYX_API_KEY; +const TELNYX_PUBLIC_KEY = process.env.TELNYX_PUBLIC_KEY; +const TELNYX_CONNECTION_ID = process.env.TELNYX_CONNECTION_ID; +const TELNYX_PHONE_NUMBER = process.env.TELNYX_PHONE_NUMBER; +const WEBHOOK_PORT = parseInt(process.env.TELNYX_WEBHOOK_PORT || '8080'); +const WEBHOOK_PATH = process.env.TELNYX_WEBHOOK_PATH || '/telnyx-webhook'; + +// Validate configuration +if (!TELNYX_API_KEY || TELNYX_API_KEY === 'your_api_key_here') { + console.error('ERROR: TELNYX_API_KEY is not set in .env file'); + console.error('Get your API key from https://portal.telnyx.com/#/app/api-keys'); + process.exit(1); +} + +// Initialize Telnyx client +const telnyx = new Telnyx({ apiKey: TELNYX_API_KEY }); + +interface PendingCall { + callControlId: string; + callerNumber: string; + callerName: string; + timestamp: number; + status: 'ringing' | 'answered' | 'ended'; + audioBuffer: Buffer[]; +} + +interface QueueData { + channel: string; + sender: string; + senderId: string; + message: string; + timestamp: number; + messageId: string; + files?: string[]; + metadata?: { + callControlId?: string; + callType?: 'inbound' | 'outbound'; + duration?: number; + }; +} + +interface ResponseData { + channel: string; + sender: string; + message: string; + originalMessage: string; + timestamp: number; + messageId: string; + files?: string[]; + metadata?: { + speak?: string; + hangup?: boolean; + gather?: boolean; + }; +} + +// Track active calls +const activeCalls = new Map(); +let processingOutgoingQueue = false; + +// Logger +function log(level: string, message: string): void { + const timestamp = new Date().toISOString(); + const logMessage = `[${timestamp}] [${level}] ${message}\n`; + console.log(logMessage.trim()); + fs.appendFileSync(LOG_FILE, logMessage); +} + +// Load teams from settings +function getTeamListText(): string { + try { + const settingsData = fs.readFileSync(SETTINGS_FILE, 'utf8'); + const settings = JSON.parse(settingsData); + const teams = settings.teams; + if (!teams || Object.keys(teams).length === 0) { + return 'No teams configured.\n\nCreate a team with: tinyclaw team add'; + } + let text = 'Available Teams:\n'; + for (const [id, team] of Object.entries(teams) as [string, any][]) { + text += `\n@${id} - ${team.name}`; + text += `\n Agents: ${team.agents.join(', ')}`; + text += `\n Leader: @${team.leader_agent}`; + } + text += '\n\nUsage: Start your message with @team_id to route to a team.'; + return text; + } catch { + return 'Could not load team configuration.'; + } +} + +// Load agents from settings +function getAgentListText(): string { + try { + const settingsData = fs.readFileSync(SETTINGS_FILE, 'utf8'); + const settings = JSON.parse(settingsData); + const agents = settings.agents; + if (!agents || Object.keys(agents).length === 0) { + return 'No agents configured. Using default single-agent mode.\n\nConfigure agents in .tinyclaw/settings.json or run: tinyclaw agent add'; + } + let text = 'Available Agents:\n'; + for (const [id, agent] of Object.entries(agents) as [string, any][]) { + text += `\n@${id} - ${agent.name}`; + text += `\n Provider: ${agent.provider}/${agent.model}`; + text += `\n Directory: ${agent.working_directory}`; + } + text += '\n\nUsage: Start your message with @agent_id to route to a specific agent.'; + return text; + } catch { + return 'Could not load agent configuration.'; + } +} + +/** + * Make an outbound call + */ +async function makeOutboundCall(to: string, message: string): Promise { + try { + if (!TELNYX_CONNECTION_ID || !TELNYX_PHONE_NUMBER) { + throw new Error('TELNYX_CONNECTION_ID and TELNYX_PHONE_NUMBER must be configured'); + } + + log('INFO', `Making outbound call to ${to}`); + + const call = await telnyx.calls.dial({ + connection_id: TELNYX_CONNECTION_ID, + to: to, + from: TELNYX_PHONE_NUMBER, + }); + + const callControlId = call.data.call_control_id; + + // Store call info + const messageId = `voice_${Date.now()}_${Math.random().toString(36).substring(7)}`; + activeCalls.set(callControlId, { + callControlId, + callerNumber: to, + callerName: 'Outbound', + timestamp: Date.now(), + status: 'ringing', + audioBuffer: [], + }); + + log('INFO', `Call initiated: ${callControlId}`); + + // Queue message for agent to handle + const queueData: QueueData = { + channel: 'voice', + sender: to, + senderId: to, + message: `[Outbound call initiated to ${to}]: ${message}`, + timestamp: Date.now(), + messageId, + metadata: { + callControlId, + callType: 'outbound', + }, + }; + + const queueFile = path.join(QUEUE_INCOMING, `voice_${messageId}.json`); + fs.writeFileSync(queueFile, JSON.stringify(queueData, null, 2)); + + return callControlId; + } catch (error) { + log('ERROR', `Failed to make outbound call: ${(error as Error).message}`); + throw error; + } +} + +/** + * Speak text on an active call + */ +async function speakOnCall(callControlId: string, text: string): Promise { + try { + await telnyx.calls.speak({ + call_control_id: callControlId, + payload: text, + voice: 'female', + language: 'en-US', + }); + log('INFO', `Speaking on call ${callControlId}: "${text.substring(0, 50)}..."`); + } catch (error) { + log('ERROR', `Failed to speak on call: ${(error as Error).message}`); + throw error; + } +} + +/** + * Hang up a call + */ +async function hangupCall(callControlId: string): Promise { + try { + await telnyx.calls.hangup({ + call_control_id: callControlId, + }); + log('INFO', `Hung up call ${callControlId}`); + activeCalls.delete(callControlId); + } catch (error) { + log('ERROR', `Failed to hang up call: ${(error as Error).message}`); + } +} + +/** + * Gather DTMF input from caller + */ +async function gatherInput(callControlId: string): Promise { + try { + await telnyx.calls.gather({ + call_control_id: callControlId, + inter_digit_timeout_millis: 5000, + max_digits: 20, + timeout_millis: 30000, + }); + log('INFO', `Gathering input on call ${callControlId}`); + } catch (error) { + log('ERROR', `Failed to gather input: ${(error as Error).message}`); + } +} + +/** + * Verify Telnyx webhook signature + */ +function verifyWebhookSignature(payload: string, signature: string, timestamp: string): boolean { + if (!TELNYX_PUBLIC_KEY) { + log('WARN', 'TELNYX_PUBLIC_KEY not set, skipping signature verification'); + return true; + } + + try { + const payloadToSign = timestamp + '|' + payload; + const expectedSignature = crypto + .createHmac('sha256', TELNYX_PUBLIC_KEY) + .update(payloadToSign) + .digest('hex'); + + return signature === expectedSignature; + } catch (error) { + log('ERROR', `Signature verification failed: ${(error as Error).message}`); + return false; + } +} + +/** + * Handle incoming webhook from Telnyx + */ +async function handleWebhook(req: http.IncomingMessage, res: http.ServerResponse): Promise { + let body = ''; + + req.on('data', chunk => { + body += chunk.toString(); + }); + + req.on('end', async () => { + try { + const signature = req.headers['telnyx-signature-ed25519'] as string; + const timestamp = req.headers['telnyx-timestamp'] as string; + + // Verify signature if public key is set + if (TELNYX_PUBLIC_KEY && !verifyWebhookSignature(body, signature, timestamp)) { + log('WARN', 'Invalid webhook signature, rejecting'); + res.writeHead(401); + res.end('Unauthorized'); + return; + } + + const event = JSON.parse(body); + const eventType = event.data.event_type; + const payload = event.data.payload; + + log('INFO', `Received webhook event: ${eventType}`); + + // Handle different call events + switch (eventType) { + case 'call.initiated': { + // Incoming call ringing + const callControlId = payload.call_control_id; + const callerNumber = payload.from; + const callerName = payload.caller_name || callerNumber; + + log('INFO', `Incoming call from ${callerNumber}`); + + activeCalls.set(callControlId, { + callControlId, + callerNumber, + callerName, + timestamp: Date.now(), + status: 'ringing', + audioBuffer: [], + }); + + // Auto-answer the call + try { + await telnyx.calls.answer({ + call_control_id: callControlId, + }); + log('INFO', `Answered incoming call ${callControlId}`); + } catch (answerError) { + log('ERROR', `Failed to answer call: ${(answerError as Error).message}`); + } + break; + } + + case 'call.answered': { + const callControlId = payload.call_control_id; + const call = activeCalls.get(callControlId); + + if (call) { + call.status = 'answered'; + log('INFO', `Call ${callControlId} answered`); + + // Queue message for agent + const messageId = `voice_${Date.now()}_${Math.random().toString(36).substring(7)}`; + const queueData: QueueData = { + channel: 'voice', + sender: call.callerNumber, + senderId: call.callerNumber, + message: `[Incoming call from ${call.callerName} (${call.callerNumber})]: Caller is connected`, + timestamp: Date.now(), + messageId, + metadata: { + callControlId, + callType: 'inbound', + }, + }; + + const queueFile = path.join(QUEUE_INCOMING, `voice_${messageId}.json`); + fs.writeFileSync(queueFile, JSON.stringify(queueData, null, 2)); + log('INFO', `Queued incoming call message ${messageId}`); + + // Store mapping for response handling + pendingMessages.set(messageId, { + callControlId, + callerNumber: call.callerNumber, + timestamp: Date.now(), + }); + + // Greet the caller + await speakOnCall(callControlId, 'Hello! I\'m your AI assistant. How can I help you today?'); + await gatherInput(callControlId); + } + break; + } + + case 'call.hangup': { + const callControlId = payload.call_control_id; + const call = activeCalls.get(callControlId); + + if (call) { + call.status = 'ended'; + const duration = Math.floor((Date.now() - call.timestamp) / 1000); + log('INFO', `Call ${callControlId} ended. Duration: ${duration}s`); + activeCalls.delete(callControlId); + } + break; + } + + case 'call.speak.ended': { + // Speech finished, ready for next action + const callControlId = payload.call_control_id; + log('INFO', `Speech ended on call ${callControlId}`); + break; + } + + case 'call.gather.ended': { + // DTMF input received + const callControlId = payload.call_control_id; + const digits = payload.digits; + + log('INFO', `Gathered input on call ${callControlId}: ${digits}`); + + // Queue the input for agent + const messageId = `voice_${Date.now()}_${Math.random().toString(36).substring(7)}`; + const queueData: QueueData = { + channel: 'voice', + sender: activeCalls.get(callControlId)?.callerNumber || 'unknown', + senderId: activeCalls.get(callControlId)?.callerNumber || 'unknown', + message: `[Voice input]: ${digits}`, + timestamp: Date.now(), + messageId, + metadata: { + callControlId, + callType: 'inbound', + }, + }; + + const queueFile = path.join(QUEUE_INCOMING, `voice_${messageId}.json`); + fs.writeFileSync(queueFile, JSON.stringify(queueData, null, 2)); + + // Continue gathering + const call = activeCalls.get(callControlId); + if (call && call.status === 'answered') { + await gatherInput(callControlId); + } + break; + } + + case 'call.dtmf.received': { + // Real-time DTMF + const callControlId = payload.call_control_id; + const digit = payload.digit; + log('INFO', `DTMF received on call ${callControlId}: ${digit}`); + break; + } + + case 'call.recording.saved': { + // Recording available + const recordingUrl = payload.recording_urls?.[0]; + if (recordingUrl) { + log('INFO', `Recording saved: ${recordingUrl}`); + } + break; + } + + default: + log('DEBUG', `Unhandled event type: ${eventType}`); + } + + res.writeHead(200); + res.end('OK'); + } catch (error) { + log('ERROR', `Webhook handling error: ${(error as Error).message}`); + res.writeHead(500); + res.end('Error'); + } + }); +} + +interface PendingMessage { + callControlId: string; + callerNumber: string; + timestamp: number; +} + +const pendingMessages = new Map(); + +/** + * Check outgoing queue for responses to send + */ +async function checkOutgoingQueue(): Promise { + if (processingOutgoingQueue) { + return; + } + + processingOutgoingQueue = true; + + try { + const files = fs.readdirSync(QUEUE_OUTGOING) + .filter(f => f.startsWith('voice_') && f.endsWith('.json')); + + for (const file of files) { + const filePath = path.join(QUEUE_OUTGOING, file); + + try { + const responseData: ResponseData = JSON.parse(fs.readFileSync(filePath, 'utf8')); + const { messageId, message, metadata } = responseData; + + const pending = pendingMessages.get(messageId); + if (pending) { + // Handle voice response + if (metadata?.speak) { + await speakOnCall(pending.callControlId, metadata.speak); + } else if (message) { + // Default: speak the message + await speakOnCall(pending.callControlId, message); + } + + if (metadata?.hangup) { + await hangupCall(pending.callControlId); + } + + log('INFO', `Sent voice response for ${messageId}`); + pendingMessages.delete(messageId); + fs.unlinkSync(filePath); + } else { + log('WARN', `No pending message for ${messageId}, cleaning up`); + fs.unlinkSync(filePath); + } + } catch (error) { + log('ERROR', `Error processing response file ${file}: ${(error as Error).message}`); + } + } + } catch (error) { + log('ERROR', `Outgoing queue error: ${(error as Error).message}`); + } finally { + processingOutgoingQueue = false; + } +} + +// Create HTTP server for webhooks +const server = http.createServer((req, res) => { + if (req.url === WEBHOOK_PATH && req.method === 'POST') { + handleWebhook(req, res); + } else if (req.url === '/health') { + res.writeHead(200); + res.end('OK'); + } else { + res.writeHead(404); + res.end('Not Found'); + } +}); + +// Start server +server.listen(WEBHOOK_PORT, () => { + log('INFO', `Telnyx webhook server listening on port ${WEBHOOK_PORT}`); + log('INFO', `Webhook URL: http://your-server:${WEBHOOK_PORT}${WEBHOOK_PATH}`); +}); + +// Check outgoing queue periodically +setInterval(checkOutgoingQueue, 1000); + +// Graceful shutdown +process.on('SIGINT', () => { + log('INFO', 'Shutting down Telnyx voice client...'); + server.close(); + + // Hang up all active calls + for (const [callControlId] of activeCalls) { + hangupCall(callControlId).catch(() => {}); + } + + process.exit(0); +}); + +process.on('SIGTERM', () => { + log('INFO', 'Shutting down Telnyx voice client...'); + server.close(); + process.exit(0); +}); + +// Export for programmatic use +export { + makeOutboundCall, + speakOnCall, + hangupCall, + gatherInput, + activeCalls, +}; + +// CLI interface for making outbound calls +if (process.argv[2] === 'call' && process.argv[3]) { + const to = process.argv[3]; + const message = process.argv[4] || 'Hello, this is your AI assistant calling.'; + + makeOutboundCall(to, message) + .then(callControlId => { + console.log(`Call initiated: ${callControlId}`); + }) + .catch(error => { + console.error('Failed to make call:', error.message); + process.exit(1); + }); +} + +log('INFO', 'Telnyx voice client started'); +log('INFO', `API Key configured: ${TELNYX_API_KEY ? 'Yes' : 'No'}`); +log('INFO', `Phone number: ${TELNYX_PHONE_NUMBER || 'Not configured'}`); +log('INFO', `Connection ID: ${TELNYX_CONNECTION_ID || 'Not configured'}`); +log('INFO', ''); +log('INFO', 'To make an outbound call:'); +log('INFO', ' node dist/channels/telnyx-voice-client.js call +1234567890 "Hello, this is a test call"'); +log('INFO', ''); +log('INFO', 'Configure webhook URL in Telnyx portal to receive inbound calls'); diff --git a/src/lib/types.ts b/src/lib/types.ts index 1a20267..e0caa3a 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -26,6 +26,13 @@ export interface Settings { discord?: { bot_token?: string }; telegram?: { bot_token?: string }; whatsapp?: {}; + voice?: { + api_key?: string; + public_key?: string; + connection_id?: string; + phone_number?: string; + webhook_port?: number; + }; }; models?: { provider?: string; // 'anthropic' or 'openai'