#!/usr/bin/env node /** * ClawdTalk WebSocket Client v1.3.0 * * Connects to ClawdTalk server and routes voice calls to your Clawdbot gateway. * Phone → STT → Gateway Agent → TTS → Phone * * v1.3.0: Instant approval via WebSocket (no more polling delay) * * Env vars: OPENCLAW_GATEWAY_URL, CLAWDBOT_GATEWAY_URL, OPENCLAW_GATEWAY_TOKEN, CLAWDBOT_GATEWAY_TOKEN * Endpoints: https://clawdtalk.com (WebSocket), http://127.0.0.1: (local gateway) * Reads: skill-config.json * Writes: none */ const WebSocket = require('ws'); const fs = require('fs'); const path = require('path'); /** * Resolve ${ENV_VAR} references in config values. * Returns the original value if the env var is not set. */ function resolveEnvVar(value) { if (typeof value !== 'string') return value; const match = value.match(/^\$\{([A-Z_][A-Z0-9_]*)\}$/); if (match) { const envVal = process.env[match[1]]; return envVal !== undefined ? envVal : value; } return value; } const SKILL_DIR = path.dirname(__dirname); const CONFIG_FILE = path.join(SKILL_DIR, 'skill-config.json'); // Reconnection with exponential backoff const RECONNECT_DELAY_MIN = 5000; const RECONNECT_DELAY_MAX = 180000; const DEFAULT_GREETING = "Hey, what's up?"; // Gateway defaults (overridden by skill-config.json values set during setup) const DEFAULT_GATEWAY_URL = 'http://127.0.0.1:18789'; const DEFAULT_AGENT_ID = 'main'; // Default voice context with drip progress updates const DEFAULT_VOICE_CONTEXT = `[VOICE CALL ACTIVE] Voice call in progress. Speech is transcribed to text. Your response is converted to speech via TTS. VOICE RULES: - Keep responses SHORT (1-3 sentences). This is a phone call. - Speak naturally. NO markdown, NO bullet points, NO asterisks, NO emoji. - Be direct and conversational. - Numbers: say naturally ("fifteen hundred" not "1,500"). - Don't repeat back what the caller said. - You have FULL tool access: Slack, memory, web search, etc. Use them when needed. - NEVER output raw JSON, function calls, or code. Everything you say will be spoken aloud. DRIP PROGRESS UPDATES: - The caller is waiting on the phone. Keep them informed with brief progress updates. - After each tool call or significant step, respond with a SHORT update: "Checking Slack now...", "Found 3 messages, reading through them...", "Pulling up the PR details..." - Be specific about what you're doing, not generic. "Looking at your calendar" not "Processing..." - These updates are spoken aloud immediately, so they fill silence while you work. - Don't wait until the end to summarize — drip information as you find it. APPROVAL REQUESTS (IMPORTANT): - Before performing any SENSITIVE or DESTRUCTIVE action, you MUST request approval first. - This sends a push notification to the user's phone. They approve or deny from the app. - Actions that REQUIRE approval: deleting repos/files/data, sending messages on behalf of the user (Slack, email, tweets), making purchases, posting to social media, any irreversible action. - To request approval, use the approval.sh script: exec approval.sh request "" - Add --biometric for high-security actions (financial, destructive). - Tell the caller EXPLICITLY: "I'm sending a notification to your phone now for you to approve." Then wait for the result. - Result handling: - "approved" → proceed with the action and confirm completion - "denied" → say "No problem, I won't do that" and move on - "timeout" → say "The notification timed out. Would you like me to try again, or would you like to confirm by voice instead? Just say approve or deny." - "no_devices" → say "You don't have any devices registered for notifications. Would you like to confirm by voice? Say approve or deny." - "no_devices_reached" → say "The notification couldn't be delivered to your phone. Would you like to confirm by voice instead? Say approve or deny." - If the user confirms by voice (says "approve", "yes", "go ahead"), treat it as approved and proceed. - Actions that do NOT need approval: reading data, searching, checking status, answering questions, looking things up.`; // Parse command line args for server override function parseArgs() { var args = process.argv.slice(2); var serverOverride = null; for (var i = 0; i < args.length; i++) { if (args[i] === '--server' && args[i + 1]) { serverOverride = args[i + 1]; } } return { serverOverride: serverOverride }; } class ClawdTalkClient { constructor() { this.ws = null; this.config = null; this.reconnectTimer = null; this.isShuttingDown = false; this.pingTimer = null; this.pongTimeout = null; this.conversations = new Map(); this.args = parseArgs(); // Exponential backoff for reconnection this.reconnectAttempts = 0; this.currentReconnectDelay = RECONNECT_DELAY_MIN; // Gateway this.gatewayToolsUrl = null; this.gatewayToken = null; this.mainAgentId = 'main'; this.voiceContext = DEFAULT_VOICE_CONTEXT; this.greeting = DEFAULT_GREETING; // Personalization this.ownerName = null; this.agentName = null; this.loadConfig(); this.loadSkillConfig(); process.on('SIGINT', this.shutdown.bind(this, 'SIGINT')); process.on('SIGTERM', this.shutdown.bind(this, 'SIGTERM')); process.on('uncaughtException', function(err) { this.log('ERROR', 'Uncaught exception: ' + err.message); if (err.code === 'ENOTFOUND' || err.message.includes('ECONNREFUSED') || err.message.includes('getaddrinfo') || err.message.includes('socket')) { this.log('WARN', 'Network error, attempting reconnection...'); if (this.ws) { try { this.ws.close(); } catch (e) {} } this.scheduleReconnect(); } else { this.log('FATAL', 'Unrecoverable error, exiting...'); process.exit(1); } }.bind(this)); process.on('unhandledRejection', function(reason) { this.log('ERROR', 'Unhandled rejection: ' + (reason ? reason.toString() : 'unknown')); }.bind(this)); } loadConfig() { try { this.config = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')); // Resolve env var references in key config values this.config.api_key = resolveEnvVar(this.config.api_key); this.config.server = resolveEnvVar(this.config.server); // Command line override takes precedence if (this.args.serverOverride) { this.config.server = this.args.serverOverride; this.log('INFO', 'Server override: ' + this.config.server); } else if (!this.config.server) { this.config.server = 'https://clawdtalk.com'; } if (!this.config.api_key) throw new Error('No API key configured'); // Store for later use (SMS replies, etc) this.apiKey = this.config.api_key; this.baseUrl = this.config.server; this.log('INFO', 'Config loaded -> ' + this.config.server); } catch (err) { this.log('ERROR', 'Config: ' + err.message); process.exit(1); } } loadSkillConfig() { // Gateway config from skill-config.json (set during setup.sh) with env var fallbacks var gatewayUrl = resolveEnvVar(this.config.gateway_url || '') || process.env.OPENCLAW_GATEWAY_URL || process.env.CLAWDBOT_GATEWAY_URL || DEFAULT_GATEWAY_URL; this.gatewayToolsUrl = gatewayUrl.replace(/\/$/, '') + '/tools/invoke'; this.gatewayToken = resolveEnvVar(this.config.gateway_token || '') || process.env.OPENCLAW_GATEWAY_TOKEN || process.env.CLAWDBOT_GATEWAY_TOKEN || ''; this.mainAgentId = this.config.agent_id || DEFAULT_AGENT_ID; this.greeting = this.config.greeting || DEFAULT_GREETING; // Load names for voice context this.ownerName = this.config.owner_name || null; this.agentName = this.config.agent_name || null; // Inject names into voice context if available if (this.ownerName || this.agentName) { var nameContext = '\n\nIDENTITY:'; if (this.agentName) nameContext += '\n- Your name is ' + this.agentName + '.'; if (this.ownerName) nameContext += '\n- You are speaking with ' + this.ownerName + '. Use their name naturally in conversation.'; this.voiceContext += nameContext; } if (this.ownerName) this.log('INFO', 'Owner: ' + this.ownerName); if (this.agentName) this.log('INFO', 'Agent: ' + this.agentName); this.log('INFO', 'Gateway tools: ' + this.gatewayToolsUrl); this.log('INFO', 'Main agent: ' + this.mainAgentId); } log(level, msg) { console.log('[' + new Date().toISOString() + '] ' + level + ': ' + msg); } // ── Connection ────────────────────────────────────────────── async connect() { if (this.ws && this.ws.readyState === WebSocket.OPEN) return; if (this.isShuttingDown) return; var serverUrl = this.config.server.replace(/^http/, 'ws'); this.log('INFO', 'Connecting to ' + serverUrl + '...'); this.ws = new WebSocket(serverUrl + '/ws', { handshakeTimeout: 10000 }); this.ws.on('open', this.onOpen.bind(this)); this.ws.on('message', this.onMessage.bind(this)); this.ws.on('close', this.onClose.bind(this)); this.ws.on('error', function(err) { this.log('ERROR', 'WS: ' + err.message); if (err.message && err.message.indexOf('429') !== -1) { this.log('WARN', 'Rate limited — waiting 60s'); this._nextReconnectDelay = 60000; } }.bind(this)); this.ws.on('ping', function() { if (this.ws) this.ws.pong(); }.bind(this)); this.ws.on('pong', function() { if (this.pongTimeout) { clearTimeout(this.pongTimeout); this.pongTimeout = null; } }.bind(this)); } onOpen() { this.log('INFO', 'Connected, authenticating...'); // Send auth with optional name info for assistant personalization var authMsg = { type: 'auth', api_key: this.config.api_key }; if (this.ownerName) authMsg.owner_name = this.ownerName; if (this.agentName) authMsg.agent_name = this.agentName; this.ws.send(JSON.stringify(authMsg)); } async onMessage(data) { var msg; try { msg = JSON.parse(data.toString()); } catch (e) { return; } // Debug: log all incoming messages if (process.env.DEBUG) { this.log('DEBUG', 'WS msg: ' + JSON.stringify(msg).substring(0, 300)); } if (msg.type === 'auth_ok') { this.log('INFO', 'Authenticated (v1.3.0 agentic mode)'); this.reconnectAttempts = 0; this.currentReconnectDelay = RECONNECT_DELAY_MIN; this.startPing(); } else if (msg.type === 'auth_error') { this.log('ERROR', 'Auth failed: ' + msg.message); this.isShuttingDown = true; } else if (msg.type === 'event') { await this.handleEvent(msg); } } // ── Call Events ───────────────────────────────────────────── async handleEvent(msg) { var event = msg.event; var callId = msg.call_id; // Handle context_request (server asking for context at call start) if (event === 'context_request') { this.log('INFO', 'Call started (context_request): ' + callId); this.conversations.set(callId, [ { role: 'system', content: this.voiceContext } ]); // Send context response back to server var contextResponse = { type: 'context_response', call_id: callId, context: { memory: 'Voice call with full agent capabilities. Tools available: Slack messaging, web search, and more.', system_prompt: this.voiceContext } }; if (this.ws && this.ws.readyState === 1) { this.ws.send(JSON.stringify(contextResponse)); this.log('INFO', 'Context sent for call: ' + callId); } // Send greeting await this.sendResponse(callId, this.greeting); this.log('INFO', 'Greeting sent'); return; } // Also handle call.started for compatibility if (event === 'call.started') { var direction = msg.direction || 'inbound'; if (!this.conversations.has(callId)) { this.conversations.set(callId, [ { role: 'system', content: this.voiceContext } ]); } this.log('INFO', 'Call started: ' + callId + ' direction=' + direction); if (direction === 'inbound' && !this.conversations.get(callId)._greeted) { await this.sendResponse(callId, this.greeting); this.conversations.get(callId)._greeted = true; this.log('INFO', 'Greeting sent for inbound call'); } return; } if (event === 'call.ended') { this.conversations.delete(callId); this.log('INFO', 'Call ended: ' + callId); // Report call outcome to user this.reportCallOutcome(msg); return; } // Handle deep_tool_request (Voice AI asking for complex query via Clawdbot) if (event === 'deep_tool_request') { var requestId = msg.request_id; var query = msg.query || ''; this.log('INFO', 'Deep tool request [' + requestId + ']: ' + query.substring(0, 100)); // Process via full Clawdbot agent this.handleDeepToolRequest(callId, requestId, query, msg.context || {}); return; } // Handle SMS received - forward to bot and send reply if (event === 'sms.received') { var smsFrom = msg.from; var smsBody = msg.body || ''; var messageId = msg.message_id; this.log('INFO', 'SMS received from ' + (smsFrom ? smsFrom.substring(0, 6) + '***' : 'unknown') + ': ' + smsBody.substring(0, 50)); // Process via Clawdbot and send reply this.handleInboundSms(smsFrom, smsBody, messageId); return; } // Handle approval response (instant WebSocket notification) if (event === 'approval.responded') { var approvalRequestId = msg.request_id; var decision = msg.decision; this.log('INFO', 'Approval response via WS: ' + approvalRequestId + ' -> ' + decision); var pending = this.pendingApprovals.get(approvalRequestId); if (pending) { clearTimeout(pending.timeout); this.pendingApprovals.delete(approvalRequestId); pending.resolve(decision); } return; } // Handle walkie_request (Clawdie-Talkie push-to-talk) if (event === 'walkie_request') { var walkieRequestId = msg.request_id; var walkieTranscript = msg.transcript || ''; var walkieSessionKey = msg.session_key || 'agent:main:main'; this.log('INFO', 'Walkie request [' + walkieRequestId + ']: ' + walkieTranscript.substring(0, 100)); this.handleWalkieRequest(walkieRequestId, walkieTranscript, walkieSessionKey); return; } // Log unhandled events for debugging if (process.env.DEBUG) { this.log('DEBUG', 'Unhandled event: ' + event); } } // ── Deep Tool Handler ─────────────────────────────────────── // Keywords that indicate a sensitive/destructive action needing approval isSensitiveRequest(query) { var lower = query.toLowerCase(); var sensitivePatterns = [ 'delete', 'remove', 'destroy', 'drop', 'send message', 'send email', 'send slack', 'send sms', 'send text', 'post to', 'tweet', 'publish', 'repo', 'repository', 'github', // Any repo/GitHub action is sensitive 'push to', 'merge', 'deploy', 'transfer', 'payment', 'purchase', 'buy', 'add file', 'add a file', 'modify', 'change', 'commit', 'write to', ]; return sensitivePatterns.some(function(p) { return lower.includes(p); }); } async handleDeepToolRequest(callId, requestId, query, context) { try { // TEST PHRASE: "send test push" or "test notification" triggers approval directly var lowerQuery = query.toLowerCase(); if (lowerQuery.includes('test push') || lowerQuery.includes('test notification') || lowerQuery.includes('send a test')) { this.log('INFO', 'Test phrase detected - triggering approval push'); var approvalResult = await this.triggerTestApproval(); var responseText = approvalResult; if (approvalResult === 'approved') { responseText = 'You approved the test notification. The push system is working correctly.'; } else if (approvalResult === 'denied') { responseText = 'You denied the test notification. The push system is working, you just said no.'; } this.sendDeepToolResult(requestId, responseText); this.log('INFO', 'Deep tool complete [' + requestId + ']: ' + responseText.substring(0, 100)); return; } // Check if this is a sensitive action that needs approval if (this.isSensitiveRequest(query)) { this.log('INFO', 'Sensitive request detected, requesting approval: ' + query.substring(0, 80)); // Tell the caller we're sending a notification this.sendDeepToolProgress(requestId, 'Sending you a notification for approval.'); var approvalDecision = await this.requestApproval(query.substring(0, 200)); if (approvalDecision === 'approved') { this.sendDeepToolProgress(requestId, 'I see you approved that. Let me take care of it now.'); this.log('INFO', 'Approval granted, routing to agent'); // Fall through to route to agent below } else if (approvalDecision === 'denied') { this.sendDeepToolProgress(requestId, 'I see you denied that request.'); this.sendDeepToolResult(requestId, 'No problem, I won\'t do that.'); this.log('INFO', 'Approval denied by user'); return; } else if (approvalDecision === 'no_devices' || approvalDecision === 'no_devices_reached') { this.log('INFO', 'No devices for approval, skipping approval and routing directly'); // No devices — skip approval entirely and route to agent } else if (approvalDecision === 'timeout') { this.sendDeepToolResult(requestId, 'The approval request timed out. Would you like to try again?'); this.log('INFO', 'Approval timed out'); return; } } // Route to main session via tools/invoke sessions_send - uses full agent context/memory var voicePrefix = '[VOICE CALL] Respond concisely for speech. No markdown, no lists, no URLs. Do NOT request approval — it has already been handled. Just perform the action directly. '; // Use the main agent session - always route to main session var mainSessionKey = 'agent:main:main'; this.log('DEBUG', 'Deep tool calling Gateway: url=' + this.gatewayToolsUrl + ' session=' + mainSessionKey + ' hasToken=' + !!this.gatewayToken); var response = await fetch(this.gatewayToolsUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + this.gatewayToken }, body: JSON.stringify({ tool: 'sessions_send', args: { sessionKey: mainSessionKey, message: voicePrefix + query, timeoutSeconds: 90 } }), signal: AbortSignal.timeout(120000) }); if (!response.ok) { var errText = await response.text(); this.log('ERROR', 'sessions_send failed: ' + response.status + ' ' + errText); this.sendDeepToolResult(requestId, 'Sorry, I had trouble reaching the agent.'); return; } var result = await response.json(); this.log('DEBUG', 'Gateway response: ' + JSON.stringify(result).substring(0, 500)); // Extract reply from the nested response structure var reply = ''; if (result.result && result.result.details && result.result.details.reply) { reply = result.result.details.reply; } else if (result.result && result.result.content) { // Try to parse from content array var content = result.result.content; if (Array.isArray(content) && content[0] && content[0].text) { try { var parsed = JSON.parse(content[0].text); reply = parsed.reply || ''; } catch (e) { reply = content[0].text; } } } if (!reply || reply === 'HEARTBEAT_OK') { reply = 'Done.'; } // Clean for voice output var cleanedResult = this.cleanForVoice(reply); this.sendDeepToolResult(requestId, cleanedResult); this.log('INFO', 'Deep tool complete [' + requestId + ']: ' + cleanedResult.substring(0, 100)); } catch (err) { if (err.name === 'TimeoutError' || err.name === 'AbortError') { this.log('ERROR', 'Deep tool timed out'); this.sendDeepToolResult(requestId, 'That took too long. Try asking again.'); } else { this.log('ERROR', 'Deep tool failed: ' + err.message); this.sendDeepToolResult(requestId, 'Sorry, I had trouble with that request.'); } } } // ── Walkie-Talkie Handler ────────────────────────────────── async handleWalkieRequest(requestId, transcript, sessionKey) { try { var voicePrefix = '[WALKIE-TALKIE] Push-to-talk message. Respond concisely for speech (1-3 sentences). No markdown, no lists, no URLs. '; this.log('DEBUG', 'Walkie calling Gateway: url=' + this.gatewayToolsUrl + ' session=' + sessionKey); var response = await fetch(this.gatewayToolsUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + this.gatewayToken }, body: JSON.stringify({ tool: 'sessions_send', args: { sessionKey: sessionKey, message: voicePrefix + transcript, timeoutSeconds: 90 } }), signal: AbortSignal.timeout(120000) }); if (!response.ok) { var errText = await response.text(); this.log('ERROR', 'Walkie sessions_send failed: ' + response.status + ' ' + errText); this.sendWalkieResponse(requestId, null, 'Failed to reach the agent.'); return; } var result = await response.json(); // Extract reply (same logic as deep tool) var reply = ''; if (result.result && result.result.details && result.result.details.reply) { reply = result.result.details.reply; } else if (result.result && result.result.content) { var content = result.result.content; if (Array.isArray(content) && content[0] && content[0].text) { try { var parsed = JSON.parse(content[0].text); reply = parsed.reply || ''; } catch (e) { reply = content[0].text; } } } if (!reply || reply === 'HEARTBEAT_OK') { reply = 'Done.'; } var cleanedReply = this.cleanForVoice(reply); this.sendWalkieResponse(requestId, cleanedReply, null); this.log('INFO', 'Walkie complete [' + requestId + ']: ' + cleanedReply.substring(0, 100)); } catch (err) { this.log('ERROR', 'Walkie request failed: ' + err.message); this.sendWalkieResponse(requestId, null, 'Request failed: ' + err.message); } } sendWalkieResponse(requestId, reply, error) { if (this.ws && this.ws.readyState === 1) { this.ws.send(JSON.stringify({ type: 'walkie_response', request_id: requestId, reply: reply, error: error || undefined })); } } async triggerTestApproval() { return this.requestApproval('Test notification from voice call', { timeout: 60 }); } /** * Request approval via HTTP and wait for WebSocket response (instant) * Falls back to polling if WebSocket notification doesn't arrive */ async requestApproval(action, options = {}) { const timeout = options.timeout || 60; const details = options.details || null; const biometric = options.biometric || false; try { this.log('INFO', 'Requesting approval: ' + action); // Create approval request via HTTP const response = await fetch(this.baseUrl + '/v1/approvals', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + this.apiKey }, body: JSON.stringify({ action: action, details: details, require_biometric: biometric, expires_in: timeout }) }); if (!response.ok) { const errText = await response.text(); this.log('ERROR', 'Approval request failed: ' + response.status + ' ' + errText); return 'Failed to send approval request.'; } const result = await response.json(); const requestId = result.request_id; const devicesNotified = result.devices_notified || 0; const devicesFailed = result.devices_failed || 0; this.log('INFO', 'Approval created: ' + requestId + ' (notified: ' + devicesNotified + ', failed: ' + devicesFailed + ')'); if (devicesNotified === 0) { if (devicesFailed > 0) { return 'no_devices_reached'; } return 'no_devices'; } // Wait for WebSocket notification (with timeout fallback) const decision = await this.waitForApproval(requestId, timeout * 1000); this.log('INFO', 'Approval result: ' + decision); if (decision === 'approved') { return 'approved'; } else if (decision === 'denied') { return 'denied'; } else if (decision === 'timeout' || decision === 'expired') { return 'timeout'; } else { return 'Approval result: ' + decision; } } catch (err) { this.log('ERROR', 'Approval request failed: ' + err.message); return 'Failed to send approval request. Error: ' + err.message; } } /** * Wait for approval response via WebSocket (instant) or polling (fallback) */ waitForApproval(requestId, timeoutMs) { var self = this; return new Promise(function(resolve) { // Set up timeout var timeoutId = setTimeout(function() { self.pendingApprovals.delete(requestId); resolve('timeout'); }, timeoutMs); // Register pending approval for WebSocket notification self.pendingApprovals.set(requestId, { resolve: resolve, timeout: timeoutId }); // Also poll as fallback (WebSocket might miss it) self.pollApprovalStatus(requestId, resolve, timeoutId); }); } /** * Poll approval status as fallback (in case WebSocket misses the event) */ async pollApprovalStatus(requestId, resolve, timeoutId) { const pollInterval = 1000; // 1 second const poll = async () => { // Check if already resolved via WebSocket if (!this.pendingApprovals.has(requestId)) { return; // Already resolved } try { const response = await fetch(this.baseUrl + '/v1/approvals/' + requestId, { headers: { 'Authorization': 'Bearer ' + this.apiKey } }); if (response.ok) { const result = await response.json(); if (result.status !== 'pending') { // Resolved! Clear and return clearTimeout(timeoutId); this.pendingApprovals.delete(requestId); resolve(result.status); return; } } } catch (err) { this.log('WARN', 'Approval poll failed: ' + err.message); } // Still pending, poll again if (this.pendingApprovals.has(requestId)) { setTimeout(() => poll(), pollInterval); } }; // Start polling after a short delay (give WebSocket a chance first) setTimeout(() => poll(), 500); } sendDeepToolProgress(requestId, text) { if (!this.ws || this.ws.readyState !== 1) return; try { this.ws.send(JSON.stringify({ type: 'deep_tool_progress', request_id: requestId, text: text })); } catch (err) { this.log('ERROR', 'Failed to send deep tool progress: ' + err.message); } } sendDeepToolResult(requestId, text) { if (!this.ws || this.ws.readyState !== 1) return; try { this.ws.send(JSON.stringify({ type: 'deep_tool_result', request_id: requestId, text: text })); } catch (err) { this.log('ERROR', 'Failed to send deep tool result: ' + err.message); } } // ── SMS Handler ───────────────────────────────────────────── async handleInboundSms(fromNumber, body, messageId) { try { // Route SMS to main session via sessions_send var smsPrefix = '[SMS from ' + fromNumber + '] Reply concisely (under 300 chars). No markdown. '; var mainSessionKey = 'agent:' + this.mainAgentId + ':main'; var response = await fetch(this.gatewayToolsUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + this.gatewayToken }, body: JSON.stringify({ tool: 'sessions_send', args: { sessionKey: mainSessionKey, message: smsPrefix + body, timeoutSeconds: 60 } }), signal: AbortSignal.timeout(90000) }); if (!response.ok) { this.log('ERROR', 'SMS agent request failed: ' + response.status); return; } var result = await response.json(); var reply = result.result || result.response || ''; if (!reply) { this.log('WARN', 'No reply from agent for SMS'); return; } // Truncate reply for SMS if (reply.length > 1500) { reply = reply.substring(0, 1497) + '...'; } this.log('INFO', 'SMS reply: ' + reply.substring(0, 50) + '...'); // Send reply via ClawdTalk API var sendResponse = await fetch(this.baseUrl + '/v1/messages/send', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + this.apiKey }, body: JSON.stringify({ to: fromNumber, message: reply }) }); if (sendResponse.ok) { this.log('INFO', 'SMS reply sent to ' + fromNumber.substring(0, 6) + '***'); } else { var errText = await sendResponse.text(); this.log('ERROR', 'Failed to send SMS reply: ' + errText); } } catch (err) { if (err.name === 'TimeoutError') { this.log('WARN', 'SMS agent timed out'); } else { this.log('ERROR', 'SMS handler error: ' + err.message); } } } // ── TTS Helpers ───────────────────────────────────────────── cleanForVoice(text) { if (!text) return ''; // Filter JSON tool call attempts var stripped = text.trim(); if (stripped.startsWith('{') && stripped.endsWith('}')) { try { var parsed = JSON.parse(stripped); if (parsed.name || parsed.function || parsed.tool_call || parsed.arguments) { this.log('WARN', 'Filtered JSON from TTS'); return "Done."; } } catch (e) {} } return text .replace(/[*_~`#>]/g, '') .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') .replace(/\n{2,}/g, '. ') .replace(/\n/g, ' ') .replace(/\s{2,}/g, ' ') .replace(/[^\x00-\x7F\u00C0-\u024F\u1E00-\u1EFF]/g, '') .trim(); } async sendResponse(callId, text) { if (!this.conversations.has(callId)) return; if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return; try { this.ws.send(JSON.stringify({ type: 'response', call_id: callId, text: text.substring(0, 2000) })); } catch (err) { this.log('ERROR', 'Send failed: ' + err.message); } } /** * Report call outcome to user via gateway sessions_send * Routes to the main persistent session instead of creating ephemeral sessions */ async reportCallOutcome(callEvent) { if (!this.gatewayToken) { this.log('DEBUG', 'No gateway configured, skipping call report'); return; } var direction = callEvent.direction || 'unknown'; var duration = callEvent.duration_seconds || 0; var reason = callEvent.reason || 'unknown'; var outcome = callEvent.outcome; var toNumber = callEvent.to_number; var purpose = callEvent.purpose || callEvent.greeting; var voicemailMessage = callEvent.voicemail_message; // Build human-readable summary var summary = ''; var emoji = '📞'; if (direction === 'outbound') { var target = toNumber ? toNumber.replace(/(\+\d{1})(\d{3})(\d{3})(\d{4})/, '$1 ($2) $3-$4') : 'unknown number'; if (outcome === 'voicemail') { emoji = '📬'; summary = emoji + ' **Voicemail left** for ' + target; if (voicemailMessage) { summary += '\n> "' + voicemailMessage.substring(0, 200) + (voicemailMessage.length > 200 ? '...' : '') + '"'; } } else if (outcome === 'voicemail_failed') { emoji = '📵'; summary = emoji + ' Call to ' + target + ' went to voicemail but couldn\'t leave message (no beep detected)'; } else if (outcome === 'no_answer' || reason === 'amd_silence') { emoji = '📵'; summary = emoji + ' Call to ' + target + ' - no answer (silence detected)'; } else if (outcome === 'fax') { emoji = '📠'; summary = emoji + ' Call to ' + target + ' - fax machine detected, call ended'; } else if (reason === 'user_hangup') { emoji = '✅'; summary = emoji + ' Call to ' + target + ' completed (' + this.formatDuration(duration) + ')'; } else { summary = emoji + ' Call to ' + target + ' ended: ' + reason + ' (' + this.formatDuration(duration) + ')'; } if (purpose && outcome !== 'voicemail') { summary += '\n📋 Purpose: ' + purpose.substring(0, 100); } } else if (direction === 'inbound') { summary = emoji + ' Inbound call ended (' + this.formatDuration(duration) + ')'; } else { summary = emoji + ' Call ended: ' + reason; } try { var response = await fetch(this.gatewayToolsUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + this.gatewayToken }, body: JSON.stringify({ tool: 'sessions_send', args: { sessionKey: 'agent:main:main', // Route to main persistent session message: '[ClawdTalk] ' + summary, timeoutSeconds: 0 // Fire and forget } }) }); if (response.ok) { this.log('INFO', 'Call outcome reported to user (via sessions_send)'); } else { var errText = await response.text().catch(function() { return ''; }); this.log('WARN', 'Failed to report call outcome: ' + response.status + ' ' + errText); } } catch (err) { this.log('ERROR', 'Failed to report call outcome: ' + err.message); } } formatDuration(seconds) { if (!seconds || seconds < 1) return '0s'; if (seconds < 60) return seconds + 's'; var mins = Math.floor(seconds / 60); var secs = seconds % 60; return mins + 'm ' + secs + 's'; } // ── Connection Management ─────────────────────────────────── onClose(code) { var closeReason = code === 4000 ? ' ← Server killing connection (duplicate client?)' : ''; this.log('WARN', 'WS closed: ' + code + closeReason); this.stopPing(); // Track consecutive 4000 errors (duplicate client kicks) if (code === 4000) { this.duplicateKickCount = (this.duplicateKickCount || 0) + 1; if (this.duplicateKickCount >= 3) { this.log('ERROR', '════════════════════════════════════════════════════════════════'); this.log('ERROR', 'DUPLICATE CLIENT DETECTED!'); this.log('ERROR', ''); this.log('ERROR', 'Another ClawdTalk client is running with the same API key.'); this.log('ERROR', 'Each connection kicks the other off, causing this loop.'); this.log('ERROR', ''); this.log('ERROR', 'To fix:'); this.log('ERROR', ' 1. Find and kill all other ws-client processes:'); this.log('ERROR', ' pkill -f "ws-client.js" && pkill -f "connect.sh"'); this.log('ERROR', ' 2. Or check other machines/containers using this API key'); this.log('ERROR', ' 3. Then restart: ./scripts/connect.sh start'); this.log('ERROR', '════════════════════════════════════════════════════════════════'); // Stop reconnecting - let user fix it this.isShuttingDown = true; process.exit(1); } } else { // Reset counter on non-4000 close this.duplicateKickCount = 0; } if (!this.isShuttingDown) this.scheduleReconnect(); } startPing() { this.stopPing(); this.pingTimer = setInterval(function() { if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.ping(); this.pongTimeout = setTimeout(function() { this.ws.terminate(); }.bind(this), 10000); } }.bind(this), 30000); } stopPing() { if (this.pingTimer) { clearInterval(this.pingTimer); this.pingTimer = null; } if (this.pongTimeout) { clearTimeout(this.pongTimeout); this.pongTimeout = null; } } scheduleReconnect() { if (this.isShuttingDown || this.reconnectTimer) return; var delay = this.currentReconnectDelay; this.reconnectAttempts++; this.currentReconnectDelay = Math.min(this.currentReconnectDelay * 2, RECONNECT_DELAY_MAX); this.log('INFO', 'Reconnecting in ' + (delay / 1000) + 's (attempt ' + this.reconnectAttempts + ')'); this.reconnectTimer = setTimeout(function() { this.reconnectTimer = null; this.connect(); }.bind(this), delay); } shutdown(signal) { this.log('INFO', 'Shutting down (' + (signal || '?') + ')'); this.isShuttingDown = true; if (this.reconnectTimer) clearTimeout(this.reconnectTimer); this.stopPing(); if (this.ws && this.ws.readyState === WebSocket.OPEN) this.ws.close(1000); process.exit(0); } // ── Start ─────────────────────────────────────────────────── start() { this.log('INFO', '═══════════════════════════════════════════════'); this.log('INFO', 'ClawdTalk WebSocket Client v1.3.0'); this.log('INFO', 'Full agentic mode with main session routing'); this.log('INFO', '═══════════════════════════════════════════════'); this.log('INFO', 'Tools endpoint: ' + this.gatewayToolsUrl); this.log('INFO', 'Main agent: ' + this.mainAgentId); this.connect(); } } async function ensureDeps() { try { require('ws'); } catch (e) { require('child_process').execSync('cd ' + SKILL_DIR + ' && npm install ws@8', { stdio: 'inherit' }); } } async function main() { await ensureDeps(); new ClawdTalkClient().start(); } if (require.main === module) main().catch(function(e) { console.error(e); process.exit(1); }); module.exports = ClawdTalkClient;