Files

1054 lines
39 KiB
JavaScript

#!/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:<port> (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 "<description of action>"
- 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;