@clawvoice/voice-assistant 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +125 -0
- package/CHANGELOG.md +112 -0
- package/LICENSE +21 -0
- package/README.md +215 -0
- package/dist/cli.d.ts +10 -0
- package/dist/cli.js +272 -0
- package/dist/config.d.ts +42 -0
- package/dist/config.js +182 -0
- package/dist/diagnostics/health.d.ts +14 -0
- package/dist/diagnostics/health.js +182 -0
- package/dist/hooks.d.ts +16 -0
- package/dist/hooks.js +113 -0
- package/dist/inbound/classifier.d.ts +5 -0
- package/dist/inbound/classifier.js +72 -0
- package/dist/inbound/types.d.ts +30 -0
- package/dist/inbound/types.js +2 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +52 -0
- package/dist/routes.d.ts +6 -0
- package/dist/routes.js +89 -0
- package/dist/services/memory-extraction.d.ts +42 -0
- package/dist/services/memory-extraction.js +117 -0
- package/dist/services/post-call.d.ts +56 -0
- package/dist/services/post-call.js +112 -0
- package/dist/services/relay.d.ts +9 -0
- package/dist/services/relay.js +19 -0
- package/dist/services/voice-call.d.ts +61 -0
- package/dist/services/voice-call.js +189 -0
- package/dist/telephony/telnyx.d.ts +12 -0
- package/dist/telephony/telnyx.js +60 -0
- package/dist/telephony/twilio.d.ts +12 -0
- package/dist/telephony/twilio.js +63 -0
- package/dist/telephony/types.d.ts +15 -0
- package/dist/telephony/types.js +2 -0
- package/dist/telephony/util.d.ts +2 -0
- package/dist/telephony/util.js +25 -0
- package/dist/tools.d.ts +5 -0
- package/dist/tools.js +167 -0
- package/dist/voice/bridge.d.ts +47 -0
- package/dist/voice/bridge.js +411 -0
- package/dist/voice/types.d.ts +168 -0
- package/dist/voice/types.js +42 -0
- package/dist/webhooks/verify.d.ts +30 -0
- package/dist/webhooks/verify.js +95 -0
- package/docs/FEATURES.md +36 -0
- package/docs/OPENCLAW_PLUGIN_GUIDE.md +1202 -0
- package/docs/SETUP.md +303 -0
- package/openclaw.plugin.json +137 -0
- package/package.json +37 -0
- package/skills/voice-assistant/SKILL.md +15 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MemoryExtractionService = void 0;
|
|
4
|
+
const CATEGORY_PATTERNS = [
|
|
5
|
+
{ category: "health", pattern: /\b(medication|doctor|appointment|pain|health|symptom|medicine)\b/i },
|
|
6
|
+
{ category: "schedule", pattern: /\b(tomorrow|next week|monday|tuesday|wednesday|thursday|friday|at \d|o'clock|appointment)\b/i },
|
|
7
|
+
{ category: "preference", pattern: /\b(i (like|prefer|enjoy|love|hate|dislike)|my favorite|i always|i never)\b/i },
|
|
8
|
+
{ category: "relationship", pattern: /\b(my (son|daughter|wife|husband|friend|neighbor|sister|brother|mother|father))\b/i },
|
|
9
|
+
{ category: "interest", pattern: /\b(hobby|garden|cook|read|music|sport|travel|game)\b/i },
|
|
10
|
+
];
|
|
11
|
+
class MemoryExtractionService {
|
|
12
|
+
constructor(config) {
|
|
13
|
+
this.config = config;
|
|
14
|
+
this.candidates = new Map();
|
|
15
|
+
this.memoryWriter = null;
|
|
16
|
+
this.memoryReader = null;
|
|
17
|
+
this.idCounter = 0;
|
|
18
|
+
}
|
|
19
|
+
setMemoryWriter(writer) {
|
|
20
|
+
this.memoryWriter = writer;
|
|
21
|
+
}
|
|
22
|
+
setMemoryReader(reader) {
|
|
23
|
+
this.memoryReader = reader;
|
|
24
|
+
}
|
|
25
|
+
extractFromTranscript(callId, transcript) {
|
|
26
|
+
const userTurns = transcript.filter((t) => t.speaker === "user");
|
|
27
|
+
const found = [];
|
|
28
|
+
for (const turn of userTurns) {
|
|
29
|
+
for (const { category, pattern } of CATEGORY_PATTERNS) {
|
|
30
|
+
if (pattern.test(turn.text)) {
|
|
31
|
+
this.idCounter += 1;
|
|
32
|
+
found.push({
|
|
33
|
+
id: `mem-${callId}-${this.idCounter}`,
|
|
34
|
+
callId,
|
|
35
|
+
category,
|
|
36
|
+
content: turn.text,
|
|
37
|
+
confidence: 0.7,
|
|
38
|
+
sourceQuote: turn.text.slice(0, 200),
|
|
39
|
+
status: "pending",
|
|
40
|
+
extractedAt: new Date().toISOString(),
|
|
41
|
+
});
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
this.candidates.set(callId, found);
|
|
47
|
+
return {
|
|
48
|
+
callId,
|
|
49
|
+
candidates: found,
|
|
50
|
+
extractedAt: new Date().toISOString(),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
getPendingCandidates(callId) {
|
|
54
|
+
if (callId) {
|
|
55
|
+
return (this.candidates.get(callId) ?? []).filter((c) => c.status === "pending");
|
|
56
|
+
}
|
|
57
|
+
const all = [];
|
|
58
|
+
for (const list of this.candidates.values()) {
|
|
59
|
+
all.push(...list.filter((c) => c.status === "pending"));
|
|
60
|
+
}
|
|
61
|
+
return all;
|
|
62
|
+
}
|
|
63
|
+
getCandidate(memoryId) {
|
|
64
|
+
for (const list of this.candidates.values()) {
|
|
65
|
+
const found = list.find((c) => c.id === memoryId);
|
|
66
|
+
if (found)
|
|
67
|
+
return found;
|
|
68
|
+
}
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
async approveAndPromote(memoryId) {
|
|
72
|
+
const candidate = this.getCandidate(memoryId);
|
|
73
|
+
if (!candidate) {
|
|
74
|
+
return { promoted: false, reason: "Memory candidate not found." };
|
|
75
|
+
}
|
|
76
|
+
if (candidate.status === "promoted") {
|
|
77
|
+
return { promoted: false, reason: "Already promoted." };
|
|
78
|
+
}
|
|
79
|
+
candidate.status = "approved";
|
|
80
|
+
if (!this.memoryWriter) {
|
|
81
|
+
return {
|
|
82
|
+
promoted: false,
|
|
83
|
+
reason: "No memory writer configured.",
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
await this.memoryWriter("main", `voice-promoted/${candidate.id}`, {
|
|
87
|
+
content: candidate.content,
|
|
88
|
+
category: candidate.category,
|
|
89
|
+
sourceCallId: candidate.callId,
|
|
90
|
+
confidence: candidate.confidence,
|
|
91
|
+
sourceQuote: candidate.sourceQuote,
|
|
92
|
+
promotedAt: new Date().toISOString(),
|
|
93
|
+
});
|
|
94
|
+
candidate.status = "promoted";
|
|
95
|
+
candidate.promotedAt = new Date().toISOString();
|
|
96
|
+
return { promoted: true };
|
|
97
|
+
}
|
|
98
|
+
rejectCandidate(memoryId) {
|
|
99
|
+
const candidate = this.getCandidate(memoryId);
|
|
100
|
+
if (!candidate || candidate.status !== "pending") {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
candidate.status = "rejected";
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
getAllCandidates() {
|
|
107
|
+
const all = [];
|
|
108
|
+
for (const list of this.candidates.values()) {
|
|
109
|
+
all.push(...list);
|
|
110
|
+
}
|
|
111
|
+
return all;
|
|
112
|
+
}
|
|
113
|
+
resetIdCounter() {
|
|
114
|
+
this.idCounter = 0;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
exports.MemoryExtractionService = MemoryExtractionService;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { ClawVoiceConfig } from "../config";
|
|
2
|
+
import { CallSummary, TranscriptEntry } from "../voice/types";
|
|
3
|
+
/**
|
|
4
|
+
* Structured call record persisted to voice-memory/calls/.
|
|
5
|
+
*/
|
|
6
|
+
export interface PersistedCallRecord {
|
|
7
|
+
callId: string;
|
|
8
|
+
outcome: string;
|
|
9
|
+
durationMs: number;
|
|
10
|
+
transcript: TranscriptEntry[];
|
|
11
|
+
failures: CallSummary["failures"];
|
|
12
|
+
pendingActions: string[];
|
|
13
|
+
retryContext: CallSummary["retryContext"];
|
|
14
|
+
completedAt: string;
|
|
15
|
+
persistedAt: string;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Notification payload sent to integrations after a call.
|
|
19
|
+
*/
|
|
20
|
+
export interface CallNotification {
|
|
21
|
+
channel: "telegram" | "discord" | "slack";
|
|
22
|
+
text: string;
|
|
23
|
+
callId: string;
|
|
24
|
+
}
|
|
25
|
+
export type MemoryWriter = (namespace: string, key: string, value: unknown) => Promise<void>;
|
|
26
|
+
export type NotificationSender = (notification: CallNotification) => Promise<void>;
|
|
27
|
+
/**
|
|
28
|
+
* Handles post-call transcript persistence and summary delivery.
|
|
29
|
+
*/
|
|
30
|
+
export declare class PostCallService {
|
|
31
|
+
private readonly config;
|
|
32
|
+
private memoryWriter;
|
|
33
|
+
private notificationSender;
|
|
34
|
+
private static readonly MAX_PROCESSED;
|
|
35
|
+
private readonly processedCalls;
|
|
36
|
+
constructor(config: ClawVoiceConfig);
|
|
37
|
+
setMemoryWriter(writer: MemoryWriter): void;
|
|
38
|
+
setNotificationSender(sender: NotificationSender): void;
|
|
39
|
+
/**
|
|
40
|
+
* Process a completed call: persist transcript and deliver summary.
|
|
41
|
+
* Idempotent — skips calls already processed.
|
|
42
|
+
*/
|
|
43
|
+
processCompletedCall(summary: CallSummary, transcript: TranscriptEntry[]): Promise<{
|
|
44
|
+
persisted: boolean;
|
|
45
|
+
notified: boolean;
|
|
46
|
+
}>;
|
|
47
|
+
private persistCallRecord;
|
|
48
|
+
private deliverSummary;
|
|
49
|
+
/**
|
|
50
|
+
* Format a human-readable summary for notifications.
|
|
51
|
+
*/
|
|
52
|
+
formatSummaryText(summary: CallSummary, transcript: TranscriptEntry[]): string;
|
|
53
|
+
private getConfiguredChannels;
|
|
54
|
+
isProcessed(callId: string): boolean;
|
|
55
|
+
getProcessedCount(): number;
|
|
56
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.PostCallService = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* Handles post-call transcript persistence and summary delivery.
|
|
6
|
+
*/
|
|
7
|
+
class PostCallService {
|
|
8
|
+
constructor(config) {
|
|
9
|
+
this.config = config;
|
|
10
|
+
this.memoryWriter = null;
|
|
11
|
+
this.notificationSender = null;
|
|
12
|
+
this.processedCalls = new Set();
|
|
13
|
+
}
|
|
14
|
+
setMemoryWriter(writer) {
|
|
15
|
+
this.memoryWriter = writer;
|
|
16
|
+
}
|
|
17
|
+
setNotificationSender(sender) {
|
|
18
|
+
this.notificationSender = sender;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Process a completed call: persist transcript and deliver summary.
|
|
22
|
+
* Idempotent — skips calls already processed.
|
|
23
|
+
*/
|
|
24
|
+
async processCompletedCall(summary, transcript) {
|
|
25
|
+
if (this.processedCalls.has(summary.callId)) {
|
|
26
|
+
return { persisted: false, notified: false };
|
|
27
|
+
}
|
|
28
|
+
this.processedCalls.add(summary.callId);
|
|
29
|
+
if (this.processedCalls.size > PostCallService.MAX_PROCESSED) {
|
|
30
|
+
const oldest = this.processedCalls.values().next().value;
|
|
31
|
+
if (oldest) {
|
|
32
|
+
this.processedCalls.delete(oldest);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const persisted = await this.persistCallRecord(summary, transcript);
|
|
36
|
+
const notified = await this.deliverSummary(summary, transcript);
|
|
37
|
+
return { persisted, notified };
|
|
38
|
+
}
|
|
39
|
+
async persistCallRecord(summary, transcript) {
|
|
40
|
+
if (!this.memoryWriter) {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
const record = {
|
|
44
|
+
callId: summary.callId,
|
|
45
|
+
outcome: summary.outcome,
|
|
46
|
+
durationMs: summary.durationMs,
|
|
47
|
+
transcript,
|
|
48
|
+
failures: summary.failures,
|
|
49
|
+
pendingActions: summary.pendingActions,
|
|
50
|
+
retryContext: summary.retryContext,
|
|
51
|
+
completedAt: summary.completedAt,
|
|
52
|
+
persistedAt: new Date().toISOString(),
|
|
53
|
+
};
|
|
54
|
+
await this.memoryWriter("voice-memory", `calls/${summary.callId}`, record);
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
async deliverSummary(summary, transcript) {
|
|
58
|
+
if (!this.notificationSender) {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
const text = this.formatSummaryText(summary, transcript);
|
|
62
|
+
const channels = this.getConfiguredChannels();
|
|
63
|
+
if (channels.length === 0) {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
for (const channel of channels) {
|
|
67
|
+
await this.notificationSender({
|
|
68
|
+
channel,
|
|
69
|
+
text,
|
|
70
|
+
callId: summary.callId,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Format a human-readable summary for notifications.
|
|
77
|
+
*/
|
|
78
|
+
formatSummaryText(summary, transcript) {
|
|
79
|
+
const lines = [];
|
|
80
|
+
lines.push(`Call ${summary.callId} — ${summary.outcome}`);
|
|
81
|
+
lines.push(`Duration: ${Math.round(summary.durationMs / 1000)}s`);
|
|
82
|
+
lines.push(`Transcript: ${transcript.length} turns`);
|
|
83
|
+
if (summary.failures.length > 0) {
|
|
84
|
+
lines.push(`Failures: ${summary.failures.map((f) => f.description).join("; ")}`);
|
|
85
|
+
}
|
|
86
|
+
if (summary.pendingActions.length > 0) {
|
|
87
|
+
lines.push(`Pending: ${summary.pendingActions.join(", ")}`);
|
|
88
|
+
}
|
|
89
|
+
if (summary.retryContext) {
|
|
90
|
+
lines.push(`Retry: ${summary.retryContext.suggestedApproach}`);
|
|
91
|
+
}
|
|
92
|
+
return lines.join("\n");
|
|
93
|
+
}
|
|
94
|
+
getConfiguredChannels() {
|
|
95
|
+
const channels = [];
|
|
96
|
+
if (this.config.notifyTelegram)
|
|
97
|
+
channels.push("telegram");
|
|
98
|
+
if (this.config.notifyDiscord)
|
|
99
|
+
channels.push("discord");
|
|
100
|
+
if (this.config.notifySlack)
|
|
101
|
+
channels.push("slack");
|
|
102
|
+
return channels;
|
|
103
|
+
}
|
|
104
|
+
isProcessed(callId) {
|
|
105
|
+
return this.processedCalls.has(callId);
|
|
106
|
+
}
|
|
107
|
+
getProcessedCount() {
|
|
108
|
+
return this.processedCalls.size;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
exports.PostCallService = PostCallService;
|
|
112
|
+
PostCallService.MAX_PROCESSED = 1000;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.WebSocketRelayService = void 0;
|
|
4
|
+
class WebSocketRelayService {
|
|
5
|
+
constructor(config) {
|
|
6
|
+
this.config = config;
|
|
7
|
+
this.running = false;
|
|
8
|
+
}
|
|
9
|
+
async start() {
|
|
10
|
+
this.running = this.config.mode === "managed";
|
|
11
|
+
}
|
|
12
|
+
async stop() {
|
|
13
|
+
this.running = false;
|
|
14
|
+
}
|
|
15
|
+
isRunning() {
|
|
16
|
+
return this.running;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
exports.WebSocketRelayService = WebSocketRelayService;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { ClawVoiceConfig } from "../config";
|
|
2
|
+
import { InboundCallRecord } from "../inbound/types";
|
|
3
|
+
import { VoiceBridgeService } from "../voice/bridge";
|
|
4
|
+
import { CallSummary } from "../voice/types";
|
|
5
|
+
import { PostCallService } from "./post-call";
|
|
6
|
+
export interface CallRecord {
|
|
7
|
+
callId: string;
|
|
8
|
+
providerCallId: string;
|
|
9
|
+
to: string;
|
|
10
|
+
provider: "telnyx" | "twilio";
|
|
11
|
+
purpose?: string;
|
|
12
|
+
greeting: string;
|
|
13
|
+
startedAt: string;
|
|
14
|
+
endedAt?: string;
|
|
15
|
+
status: "in-progress" | "completed";
|
|
16
|
+
summary?: CallSummary;
|
|
17
|
+
}
|
|
18
|
+
export interface StartCallRequest {
|
|
19
|
+
phoneNumber: string;
|
|
20
|
+
purpose?: string;
|
|
21
|
+
greeting?: string;
|
|
22
|
+
}
|
|
23
|
+
export interface StartCallResponse {
|
|
24
|
+
callId: string;
|
|
25
|
+
to: string;
|
|
26
|
+
openingGreeting: string;
|
|
27
|
+
message: string;
|
|
28
|
+
}
|
|
29
|
+
export interface HangupResponse {
|
|
30
|
+
callId: string;
|
|
31
|
+
message: string;
|
|
32
|
+
}
|
|
33
|
+
export declare class VoiceCallService {
|
|
34
|
+
private readonly config;
|
|
35
|
+
private running;
|
|
36
|
+
private readonly activeCalls;
|
|
37
|
+
private readonly recentCalls;
|
|
38
|
+
private readonly inboundRecords;
|
|
39
|
+
private readonly callTimers;
|
|
40
|
+
private readonly telephonyAdapter;
|
|
41
|
+
private dailyCallCount;
|
|
42
|
+
private dailyResetDate;
|
|
43
|
+
readonly bridge: VoiceBridgeService;
|
|
44
|
+
readonly postCall: PostCallService;
|
|
45
|
+
constructor(config: ClawVoiceConfig, fetchFn?: typeof globalThis.fetch);
|
|
46
|
+
start(): Promise<void>;
|
|
47
|
+
stop(): Promise<void>;
|
|
48
|
+
isRunning(): boolean;
|
|
49
|
+
getProviderSummary(): string;
|
|
50
|
+
private createCallId;
|
|
51
|
+
private checkDailyLimit;
|
|
52
|
+
startCall(request: StartCallRequest): Promise<StartCallResponse>;
|
|
53
|
+
hangup(callId?: string): Promise<HangupResponse>;
|
|
54
|
+
getActiveCalls(): CallRecord[];
|
|
55
|
+
private scheduleAutoHangup;
|
|
56
|
+
private autoHangup;
|
|
57
|
+
trackInboundCall(record: InboundCallRecord): void;
|
|
58
|
+
getInboundRecords(): InboundCallRecord[];
|
|
59
|
+
getCallSummary(callId: string): CallSummary | null;
|
|
60
|
+
private completeCall;
|
|
61
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.VoiceCallService = void 0;
|
|
4
|
+
const telnyx_1 = require("../telephony/telnyx");
|
|
5
|
+
const twilio_1 = require("../telephony/twilio");
|
|
6
|
+
const bridge_1 = require("../voice/bridge");
|
|
7
|
+
const post_call_1 = require("./post-call");
|
|
8
|
+
class VoiceCallService {
|
|
9
|
+
constructor(config, fetchFn) {
|
|
10
|
+
this.config = config;
|
|
11
|
+
this.running = false;
|
|
12
|
+
this.activeCalls = new Map();
|
|
13
|
+
this.recentCalls = [];
|
|
14
|
+
this.inboundRecords = [];
|
|
15
|
+
this.callTimers = new Map();
|
|
16
|
+
this.dailyCallCount = 0;
|
|
17
|
+
this.dailyResetDate = new Date().toISOString().slice(0, 10);
|
|
18
|
+
this.telephonyAdapter =
|
|
19
|
+
config.telephonyProvider === "twilio"
|
|
20
|
+
? new twilio_1.TwilioTelephonyAdapter(config, fetchFn)
|
|
21
|
+
: new telnyx_1.TelnyxTelephonyAdapter(config, fetchFn);
|
|
22
|
+
this.bridge = new bridge_1.VoiceBridgeService(config);
|
|
23
|
+
this.postCall = new post_call_1.PostCallService(config);
|
|
24
|
+
}
|
|
25
|
+
async start() {
|
|
26
|
+
this.running = true;
|
|
27
|
+
}
|
|
28
|
+
async stop() {
|
|
29
|
+
for (const timer of this.callTimers.values()) {
|
|
30
|
+
clearTimeout(timer);
|
|
31
|
+
}
|
|
32
|
+
this.callTimers.clear();
|
|
33
|
+
await this.bridge.stopAll();
|
|
34
|
+
this.running = false;
|
|
35
|
+
}
|
|
36
|
+
isRunning() {
|
|
37
|
+
return this.running;
|
|
38
|
+
}
|
|
39
|
+
getProviderSummary() {
|
|
40
|
+
return `${this.config.telephonyProvider}:${this.config.voiceProvider}`;
|
|
41
|
+
}
|
|
42
|
+
createCallId() {
|
|
43
|
+
const now = Date.now();
|
|
44
|
+
const random = Math.floor(Math.random() * 1000000)
|
|
45
|
+
.toString()
|
|
46
|
+
.padStart(6, "0");
|
|
47
|
+
return `call-${now}-${random}`;
|
|
48
|
+
}
|
|
49
|
+
checkDailyLimit() {
|
|
50
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
51
|
+
if (today !== this.dailyResetDate) {
|
|
52
|
+
this.dailyCallCount = 0;
|
|
53
|
+
this.dailyResetDate = today;
|
|
54
|
+
}
|
|
55
|
+
if (this.config.dailyCallLimit > 0 && this.dailyCallCount >= this.config.dailyCallLimit) {
|
|
56
|
+
throw new Error(`Daily call limit reached (${this.config.dailyCallLimit}). Try again tomorrow.`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
async startCall(request) {
|
|
60
|
+
this.checkDailyLimit();
|
|
61
|
+
const baseGreeting = request.greeting?.trim() ||
|
|
62
|
+
"Hello, this is an AI assistant calling on behalf of my user.";
|
|
63
|
+
const disclosure = this.config.disclosureEnabled
|
|
64
|
+
? this.config.disclosureStatement.trim()
|
|
65
|
+
: "";
|
|
66
|
+
const greeting = disclosure.length > 0
|
|
67
|
+
? `${disclosure} ${baseGreeting}`
|
|
68
|
+
: baseGreeting;
|
|
69
|
+
const providerResult = await this.telephonyAdapter.startCall({
|
|
70
|
+
to: request.phoneNumber,
|
|
71
|
+
from: this.config.telephonyProvider === "twilio"
|
|
72
|
+
? this.config.twilioPhoneNumber
|
|
73
|
+
: this.config.telnyxPhoneNumber,
|
|
74
|
+
greeting,
|
|
75
|
+
purpose: request.purpose,
|
|
76
|
+
});
|
|
77
|
+
const callId = this.createCallId();
|
|
78
|
+
const record = {
|
|
79
|
+
callId,
|
|
80
|
+
providerCallId: providerResult.providerCallId,
|
|
81
|
+
to: providerResult.normalizedTo,
|
|
82
|
+
provider: this.config.telephonyProvider,
|
|
83
|
+
purpose: request.purpose,
|
|
84
|
+
greeting,
|
|
85
|
+
startedAt: new Date().toISOString(),
|
|
86
|
+
status: "in-progress",
|
|
87
|
+
};
|
|
88
|
+
this.activeCalls.set(callId, record);
|
|
89
|
+
this.recentCalls.unshift(record);
|
|
90
|
+
this.recentCalls.splice(20);
|
|
91
|
+
this.dailyCallCount++;
|
|
92
|
+
this.scheduleAutoHangup(callId);
|
|
93
|
+
const bridgeEvent = this.bridge.createSession({
|
|
94
|
+
callId,
|
|
95
|
+
providerCallId: providerResult.providerCallId,
|
|
96
|
+
voiceProviderUrl: this.config.voiceProvider === "deepgram-agent"
|
|
97
|
+
? "wss://agent.deepgram.com/v1/agent/converse"
|
|
98
|
+
: `wss://api.elevenlabs.io/v1/convai/conversation?agent_id=${this.config.elevenlabsAgentId ?? ""}`,
|
|
99
|
+
voiceProviderAuth: this.config.deepgramApiKey ?? "",
|
|
100
|
+
telephonyCodec: "mulaw",
|
|
101
|
+
voiceProviderCodec: "mulaw",
|
|
102
|
+
sampleRate: 8000,
|
|
103
|
+
greeting,
|
|
104
|
+
systemPrompt: this.config.voiceSystemPrompt
|
|
105
|
+
? (request.purpose ? `${this.config.voiceSystemPrompt}\n\nCall purpose: ${request.purpose}` : this.config.voiceSystemPrompt)
|
|
106
|
+
: (request.purpose ?? ""),
|
|
107
|
+
voiceModel: this.config.deepgramVoice,
|
|
108
|
+
keepAliveIntervalMs: 5000,
|
|
109
|
+
greetingGracePeriodMs: 3000,
|
|
110
|
+
});
|
|
111
|
+
if (bridgeEvent.type === "connected") {
|
|
112
|
+
this.bridge.startKeepAlive(callId, 5000);
|
|
113
|
+
setTimeout(() => this.bridge.endGreetingGrace(callId), 3000);
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
callId,
|
|
117
|
+
to: providerResult.normalizedTo,
|
|
118
|
+
openingGreeting: greeting,
|
|
119
|
+
message: `Outbound call initiated via ${this.config.telephonyProvider}.`,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
async hangup(callId) {
|
|
123
|
+
const selectedCallId = callId ?? this.activeCalls.keys().next().value;
|
|
124
|
+
if (typeof selectedCallId !== "string") {
|
|
125
|
+
throw new Error("No active call found to hang up.");
|
|
126
|
+
}
|
|
127
|
+
const call = this.activeCalls.get(selectedCallId);
|
|
128
|
+
if (!call) {
|
|
129
|
+
throw new Error(`Call not found: ${selectedCallId}`);
|
|
130
|
+
}
|
|
131
|
+
await this.completeCall(selectedCallId, call.providerCallId);
|
|
132
|
+
return {
|
|
133
|
+
callId: selectedCallId,
|
|
134
|
+
message: "Call ended with a polite closing and clean connection termination.",
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
getActiveCalls() {
|
|
138
|
+
return Array.from(this.activeCalls.values());
|
|
139
|
+
}
|
|
140
|
+
scheduleAutoHangup(callId) {
|
|
141
|
+
const durationMs = Math.floor(this.config.maxCallDuration * 1000);
|
|
142
|
+
const timer = setTimeout(() => {
|
|
143
|
+
void this.autoHangup(callId);
|
|
144
|
+
}, durationMs);
|
|
145
|
+
timer.unref?.();
|
|
146
|
+
this.callTimers.set(callId, timer);
|
|
147
|
+
}
|
|
148
|
+
async autoHangup(callId) {
|
|
149
|
+
const call = this.activeCalls.get(callId);
|
|
150
|
+
if (!call) {
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
await this.completeCall(callId, call.providerCallId);
|
|
154
|
+
}
|
|
155
|
+
trackInboundCall(record) {
|
|
156
|
+
this.inboundRecords.unshift(record);
|
|
157
|
+
this.inboundRecords.splice(50);
|
|
158
|
+
}
|
|
159
|
+
getInboundRecords() {
|
|
160
|
+
return [...this.inboundRecords];
|
|
161
|
+
}
|
|
162
|
+
getCallSummary(callId) {
|
|
163
|
+
const call = this.recentCalls.find((c) => c.callId === callId);
|
|
164
|
+
return call?.summary ?? null;
|
|
165
|
+
}
|
|
166
|
+
async completeCall(callId, providerCallId) {
|
|
167
|
+
const call = this.activeCalls.get(callId);
|
|
168
|
+
if (!call) {
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const transcript = this.bridge.getTranscript(callId);
|
|
172
|
+
const summary = this.bridge.generateCallSummary(callId);
|
|
173
|
+
call.summary = summary ?? undefined;
|
|
174
|
+
this.bridge.destroySession(callId);
|
|
175
|
+
await this.telephonyAdapter.hangup(providerCallId);
|
|
176
|
+
call.status = "completed";
|
|
177
|
+
call.endedAt = new Date().toISOString();
|
|
178
|
+
this.activeCalls.delete(callId);
|
|
179
|
+
if (summary) {
|
|
180
|
+
await this.postCall.processCompletedCall(summary, transcript).catch(() => undefined);
|
|
181
|
+
}
|
|
182
|
+
const timer = this.callTimers.get(callId);
|
|
183
|
+
if (timer) {
|
|
184
|
+
clearTimeout(timer);
|
|
185
|
+
this.callTimers.delete(callId);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
exports.VoiceCallService = VoiceCallService;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { ClawVoiceConfig } from "../config";
|
|
2
|
+
import { StartCallInput, StartCallResult, TelephonyProviderAdapter } from "./types";
|
|
3
|
+
type FetchFn = typeof globalThis.fetch;
|
|
4
|
+
export declare class TelnyxTelephonyAdapter implements TelephonyProviderAdapter {
|
|
5
|
+
private readonly config;
|
|
6
|
+
private readonly fetchFn;
|
|
7
|
+
constructor(config: ClawVoiceConfig, fetchFn?: FetchFn);
|
|
8
|
+
providerName(): string;
|
|
9
|
+
startCall(input: StartCallInput): Promise<StartCallResult>;
|
|
10
|
+
hangup(providerCallId: string): Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.TelnyxTelephonyAdapter = void 0;
|
|
4
|
+
const util_1 = require("./util");
|
|
5
|
+
class TelnyxTelephonyAdapter {
|
|
6
|
+
constructor(config, fetchFn) {
|
|
7
|
+
this.config = config;
|
|
8
|
+
this.fetchFn = fetchFn ?? globalThis.fetch;
|
|
9
|
+
}
|
|
10
|
+
providerName() {
|
|
11
|
+
return "telnyx";
|
|
12
|
+
}
|
|
13
|
+
async startCall(input) {
|
|
14
|
+
const normalizedTo = (0, util_1.normalizeE164)(input.to);
|
|
15
|
+
if (!this.config.telnyxApiKey ||
|
|
16
|
+
!this.config.telnyxConnectionId ||
|
|
17
|
+
!this.config.telnyxPhoneNumber) {
|
|
18
|
+
throw new Error("Telnyx credentials missing: telnyxApiKey, telnyxConnectionId, and telnyxPhoneNumber are required");
|
|
19
|
+
}
|
|
20
|
+
const from = input.from ?? this.config.telnyxPhoneNumber;
|
|
21
|
+
const response = await this.fetchFn("https://api.telnyx.com/v2/calls", {
|
|
22
|
+
method: "POST",
|
|
23
|
+
headers: {
|
|
24
|
+
Authorization: `Bearer ${this.config.telnyxApiKey}`,
|
|
25
|
+
"Content-Type": "application/json",
|
|
26
|
+
},
|
|
27
|
+
body: JSON.stringify({
|
|
28
|
+
connection_id: this.config.telnyxConnectionId,
|
|
29
|
+
to: normalizedTo,
|
|
30
|
+
from: from ?? "",
|
|
31
|
+
answering_machine_detection: this.config.amdEnabled
|
|
32
|
+
? "detect"
|
|
33
|
+
: "disabled",
|
|
34
|
+
}),
|
|
35
|
+
});
|
|
36
|
+
if (!response.ok) {
|
|
37
|
+
const errorText = await response.text().catch(() => "Unknown error");
|
|
38
|
+
throw new Error(`Telnyx API error (${response.status}): ${errorText}`);
|
|
39
|
+
}
|
|
40
|
+
const data = (await response.json());
|
|
41
|
+
return {
|
|
42
|
+
providerCallId: data.data.call_control_id,
|
|
43
|
+
normalizedTo,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
async hangup(providerCallId) {
|
|
47
|
+
if (!this.config.telnyxApiKey) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
await this.fetchFn(`https://api.telnyx.com/v2/calls/${providerCallId}/actions/hangup`, {
|
|
51
|
+
method: "POST",
|
|
52
|
+
headers: {
|
|
53
|
+
Authorization: `Bearer ${this.config.telnyxApiKey}`,
|
|
54
|
+
"Content-Type": "application/json",
|
|
55
|
+
},
|
|
56
|
+
body: JSON.stringify({}),
|
|
57
|
+
}).catch(() => undefined);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
exports.TelnyxTelephonyAdapter = TelnyxTelephonyAdapter;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { ClawVoiceConfig } from "../config";
|
|
2
|
+
import { StartCallInput, StartCallResult, TelephonyProviderAdapter } from "./types";
|
|
3
|
+
type FetchFn = typeof globalThis.fetch;
|
|
4
|
+
export declare class TwilioTelephonyAdapter implements TelephonyProviderAdapter {
|
|
5
|
+
private readonly config;
|
|
6
|
+
private readonly fetchFn;
|
|
7
|
+
constructor(config: ClawVoiceConfig, fetchFn?: FetchFn);
|
|
8
|
+
providerName(): string;
|
|
9
|
+
startCall(input: StartCallInput): Promise<StartCallResult>;
|
|
10
|
+
hangup(providerCallId: string): Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
export {};
|