luca 3.0.2 → 3.1.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/bun.lock +45 -0
- package/commands/social.ts +137 -0
- package/datasets/lora/agentic-loop-session-candidates.jsonl +91 -0
- package/datasets/lora/agentic-loop-session-curation-summary.json +123 -0
- package/datasets/lora/luca-session-candidates.jsonl +29 -0
- package/datasets/lora/luca-session-curation-summary.json +121 -0
- package/datasets/lora/review-batch-1.jsonl +30 -0
- package/datasets/lora/review-manifest.json +41 -0
- package/datasets/lora/review-queue.jsonl +120 -0
- package/datasets/lora/review-schema.json +134 -0
- package/datasets/lora/review-template.jsonl +2 -0
- package/datasets/lora/review-ui.html +725 -0
- package/features/cipher-social.ts +493 -0
- package/package.json +6 -1
- package/scripts/curate-claude-sessions.ts +561 -0
- package/src/cli/build-info.ts +2 -2
- package/src/introspection/generated.agi.ts +13140 -12190
- package/src/introspection/generated.node.ts +3087 -2137
- package/src/node/container.ts +8 -0
- package/src/node/features/helpers.ts +12 -0
- package/src/node/features/socket-repl.ts +336 -0
- package/src/node/features/telnyx-assistant-connector.ts +1206 -0
- package/src/node/features/vm.ts +17 -0
- package/index.ts +0 -1
|
@@ -0,0 +1,1206 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import { FeatureStateSchema, FeatureOptionsSchema, FeatureEventsSchema } from '../../schemas/base.js'
|
|
3
|
+
import { Feature } from '../feature.js'
|
|
4
|
+
|
|
5
|
+
export const TelnyxAssistantConnectorStateSchema = FeatureStateSchema.extend({
|
|
6
|
+
publicUrl: z.string().optional().describe('The public URL for tool webhooks (tunnel or pre-configured domain)'),
|
|
7
|
+
telnyxAssistantId: z.string().optional().describe('The Telnyx assistant ID created for this session'),
|
|
8
|
+
phoneNumberId: z.string().optional().describe('The Telnyx phone number ID wired to the assistant'),
|
|
9
|
+
port: z.number().optional().describe('The port the express server is listening on'),
|
|
10
|
+
running: z.boolean().default(false).describe('Whether the connector is actively running'),
|
|
11
|
+
})
|
|
12
|
+
export type TelnyxConnectorState = z.infer<typeof TelnyxAssistantConnectorStateSchema>
|
|
13
|
+
|
|
14
|
+
export const TelnyxAssistantConnectorOptionsSchema = FeatureOptionsSchema.extend({
|
|
15
|
+
assistant: z.any().describe('The Luca assistant instance to bridge to Telnyx'),
|
|
16
|
+
port: z.number().default(4567).describe('Port for the local express server'),
|
|
17
|
+
model: z.string().default('meta-llama/Meta-Llama-3.1-70B-Instruct').describe('Telnyx model ID'),
|
|
18
|
+
greeting: z.string().optional().describe('Greeting message for the Telnyx assistant'),
|
|
19
|
+
phoneNumber: z.string().optional().describe('Phone number to wire to the assistant (e.g. +13125552200)'),
|
|
20
|
+
noTools: z.boolean().default(false).describe('Deploy without tools — skip local server and tunnel'),
|
|
21
|
+
debug: z.boolean().default(false).describe('Emit verbose [telnyx] log output'),
|
|
22
|
+
domain: z.string().optional().describe('Pre-configured domain name (e.g. from cloudflared tunnel). Skips ephemeral tunnel creation.'),
|
|
23
|
+
voice: z.string().optional().describe('TTS voice ID (e.g. Telnyx.Ultra.<id> or an ElevenLabs voice ID). If omitted, uses Telnyx default.'),
|
|
24
|
+
ttsProvider: z.string().optional().describe('TTS provider: "telnyx" (default) or "elevenlabs"'),
|
|
25
|
+
apiKeyRef: z.string().optional().describe('Integration secret identifier for the TTS provider API key (required for ElevenLabs)'),
|
|
26
|
+
})
|
|
27
|
+
export type TelnyxConnectorOptions = z.infer<typeof TelnyxAssistantConnectorOptionsSchema>
|
|
28
|
+
|
|
29
|
+
export const TelnyxAssistantConnectorEventsSchema = FeatureEventsSchema.extend({
|
|
30
|
+
started: z.tuple([z.object({
|
|
31
|
+
publicUrl: z.string(),
|
|
32
|
+
telnyxAssistantId: z.string(),
|
|
33
|
+
port: z.number(),
|
|
34
|
+
})]).describe('Emitted when the connector is fully running'),
|
|
35
|
+
toolCall: z.tuple([z.string(), z.any()]).describe('Emitted when a tool is called via webhook'),
|
|
36
|
+
toolError: z.tuple([z.string(), z.instanceof(Error)]).describe('Emitted when a tool call throws'),
|
|
37
|
+
stopped: z.tuple([]).describe('Emitted when the connector is torn down'),
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Bridges a local Luca assistant to Telnyx AI by exposing tool handlers
|
|
42
|
+
* as HTTP endpoints and creating a mirrored Telnyx assistant with webhook bindings.
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* ```typescript
|
|
46
|
+
* const mgr = container.feature('assistantsManager')
|
|
47
|
+
* const chief = mgr.create('chiefOfStaff')
|
|
48
|
+
* const connector = container.feature('telnyxAssistantConnector', { assistant: chief })
|
|
49
|
+
* await connector.start()
|
|
50
|
+
* ```
|
|
51
|
+
*
|
|
52
|
+
* @extends Feature
|
|
53
|
+
*/
|
|
54
|
+
export class TelnyxAssistantConnector extends Feature<TelnyxConnectorState, TelnyxConnectorOptions> {
|
|
55
|
+
static override shortcut = 'features.telnyxAssistantConnector' as const
|
|
56
|
+
static override stateSchema = TelnyxAssistantConnectorStateSchema
|
|
57
|
+
static override optionsSchema = TelnyxAssistantConnectorOptionsSchema
|
|
58
|
+
static override eventsSchema = TelnyxAssistantConnectorEventsSchema
|
|
59
|
+
static { Feature.register(this, 'telnyxAssistantConnector') }
|
|
60
|
+
|
|
61
|
+
private _log(...args: any[]) {
|
|
62
|
+
if (this.options.debug) console.log(...args)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private _server: any = null
|
|
66
|
+
private _tunnelProcess: any = null
|
|
67
|
+
private _telnyxClient: any = null
|
|
68
|
+
private _previousConnectionId: string | null = null
|
|
69
|
+
private _messagingProfileId: string | null = null
|
|
70
|
+
private _activeCallSid: string | null = null
|
|
71
|
+
|
|
72
|
+
get assistant() {
|
|
73
|
+
return this.options.assistant
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Canonical name derived from the assistant folder (e.g. `receptionist`),
|
|
78
|
+
* used for both the Telnyx assistant and its messaging profile.
|
|
79
|
+
*/
|
|
80
|
+
get assistantName(): string {
|
|
81
|
+
const folder = this.assistant?.options?.folder
|
|
82
|
+
if (folder) return String(folder).split('/').pop()!
|
|
83
|
+
return this.assistant?.name || this.assistant?.constructor?.name || 'assistant'
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Get a Telnyx client (uses existing one if running, otherwise creates a fresh one).
|
|
88
|
+
*/
|
|
89
|
+
private async _getClient() {
|
|
90
|
+
if (this._telnyxClient) return this._telnyxClient
|
|
91
|
+
const { Telnyx } = await import('telnyx')
|
|
92
|
+
return new Telnyx({ apiKey: process.env.TELNYX_API_KEY! })
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* List all messaging profiles on the account.
|
|
97
|
+
*/
|
|
98
|
+
async listMessagingProfiles() {
|
|
99
|
+
const client = await this._getClient()
|
|
100
|
+
const profiles = await client.messagingProfiles.list()
|
|
101
|
+
const results: any[] = []
|
|
102
|
+
for await (const p of profiles) {
|
|
103
|
+
results.push({
|
|
104
|
+
id: p.id,
|
|
105
|
+
name: p.name,
|
|
106
|
+
webhook_url: p.webhook_url,
|
|
107
|
+
whitelisted_destinations: p.whitelisted_destinations,
|
|
108
|
+
created_at: p.created_at,
|
|
109
|
+
updated_at: p.updated_at,
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
return results
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Get full details of a messaging profile by ID.
|
|
117
|
+
*/
|
|
118
|
+
async getMessagingProfile(profileId: string) {
|
|
119
|
+
const client = await this._getClient()
|
|
120
|
+
const resp = await client.messagingProfiles.retrieve(profileId)
|
|
121
|
+
return resp?.data || resp
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* List all AI assistants on the account.
|
|
126
|
+
*/
|
|
127
|
+
async listAssistants() {
|
|
128
|
+
const client = await this._getClient()
|
|
129
|
+
const resp = await client.ai.assistants.list()
|
|
130
|
+
const items = resp?.data || resp
|
|
131
|
+
return Array.isArray(items) ? items.map((a: any) => ({
|
|
132
|
+
id: a.id,
|
|
133
|
+
name: a.name,
|
|
134
|
+
model: a.model,
|
|
135
|
+
enabled_features: a.enabled_features,
|
|
136
|
+
telephony_settings: a.telephony_settings,
|
|
137
|
+
messaging_settings: a.messaging_settings,
|
|
138
|
+
})) : items
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Get full details of a Telnyx AI assistant by ID.
|
|
143
|
+
*/
|
|
144
|
+
async getAssistant(assistantId: string) {
|
|
145
|
+
const client = await this._getClient()
|
|
146
|
+
const resp = await client.ai.assistants.retrieve(assistantId)
|
|
147
|
+
return resp?.data || resp
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* List voices available to your Telnyx account. Optionally pass an
|
|
152
|
+
* integration secret ref for ElevenLabs — Telnyx will then include your
|
|
153
|
+
* personal ElevenLabs voices in the response.
|
|
154
|
+
*
|
|
155
|
+
* @example
|
|
156
|
+
* ```ts
|
|
157
|
+
* await connector.listVoices() // Telnyx defaults
|
|
158
|
+
* await connector.listVoices({ provider: 'ElevenLabs', // your custom voices
|
|
159
|
+
* apiKeyRef: 'elevenlabs_api_key' })
|
|
160
|
+
* ```
|
|
161
|
+
*/
|
|
162
|
+
async listVoices(opts: { provider?: string; apiKeyRef?: string; filter?: string } = {}) {
|
|
163
|
+
const params: any = {}
|
|
164
|
+
if (opts.apiKeyRef) params.elevenlabs_api_key_ref = opts.apiKeyRef
|
|
165
|
+
const query = new URLSearchParams(params).toString()
|
|
166
|
+
|
|
167
|
+
const r = await fetch(
|
|
168
|
+
`https://api.telnyx.com/v2/text-to-speech/voices${query ? `?${query}` : ''}`,
|
|
169
|
+
{ headers: { Authorization: `Bearer ${process.env.TELNYX_API_KEY!}` } }
|
|
170
|
+
)
|
|
171
|
+
const body: any = await r.json()
|
|
172
|
+
let voices: any[] = body?.voices || []
|
|
173
|
+
if (opts.provider) {
|
|
174
|
+
const needle = opts.provider.toLowerCase()
|
|
175
|
+
voices = voices.filter((v: any) => (v.provider || '').toLowerCase() === needle)
|
|
176
|
+
}
|
|
177
|
+
const filtered = opts.filter
|
|
178
|
+
? voices.filter((v: any) => {
|
|
179
|
+
const needle = opts.filter!.toLowerCase()
|
|
180
|
+
return (v.name || '').toLowerCase().includes(needle)
|
|
181
|
+
|| (v.id || '').toLowerCase().includes(needle)
|
|
182
|
+
|| (v.voice || '').toLowerCase().includes(needle)
|
|
183
|
+
})
|
|
184
|
+
: voices
|
|
185
|
+
return filtered.map((v: any) => ({
|
|
186
|
+
voice: v.id,
|
|
187
|
+
name: v.name,
|
|
188
|
+
provider: v.provider,
|
|
189
|
+
model_id: v.model_id,
|
|
190
|
+
language: v.language,
|
|
191
|
+
gender: v.gender,
|
|
192
|
+
}))
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Patch voice_settings on an existing Telnyx AI assistant. Useful for
|
|
197
|
+
* iterating on the voice string without redeploying.
|
|
198
|
+
*
|
|
199
|
+
* @example
|
|
200
|
+
* ```ts
|
|
201
|
+
* await connector.updateAssistantVoice('assistant-abc', {
|
|
202
|
+
* voice: 'ElevenLabs.eleven_v3.ulEiUT06p4S3sHtsvn4T',
|
|
203
|
+
* api_key_ref: 'elevenlabs_api_key',
|
|
204
|
+
* voice_speed: 1.05,
|
|
205
|
+
* })
|
|
206
|
+
* ```
|
|
207
|
+
*/
|
|
208
|
+
async updateAssistantVoice(assistantId: string, voiceSettings: any) {
|
|
209
|
+
const client = await this._getClient()
|
|
210
|
+
this._log('[telnyx] Updating assistant voice_settings:', JSON.stringify(voiceSettings, null, 2))
|
|
211
|
+
const resp = await client.ai.assistants.update(assistantId, { voice_settings: voiceSettings })
|
|
212
|
+
const updated = resp?.data || resp
|
|
213
|
+
this._log('[telnyx] Assistant now has voice_settings:', JSON.stringify(updated?.voice_settings, null, 2))
|
|
214
|
+
return updated
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Try a voice_settings object on the standalone TTS command endpoint and
|
|
219
|
+
* save the MP3 locally so you can listen. Fastest way to confirm a voice
|
|
220
|
+
* string is valid without deploying an assistant.
|
|
221
|
+
*
|
|
222
|
+
* @example
|
|
223
|
+
* ```ts
|
|
224
|
+
* await connector.testVoice({
|
|
225
|
+
* voice: 'ElevenLabs.eleven_v3.ulEiUT06p4S3sHtsvn4T',
|
|
226
|
+
* apiKeyRef: 'elevenlabs_api_key',
|
|
227
|
+
* text: 'Top of the morning.',
|
|
228
|
+
* outputPath: 'docs/calls/voice-test.mp3',
|
|
229
|
+
* })
|
|
230
|
+
* ```
|
|
231
|
+
*/
|
|
232
|
+
async testVoice(opts: { voice: string; apiKeyRef?: string; text: string; outputPath?: string; voiceSettings?: any }) {
|
|
233
|
+
const body: any = { voice: opts.voice, text: opts.text }
|
|
234
|
+
if (opts.apiKeyRef) body.api_key_ref = opts.apiKeyRef
|
|
235
|
+
if (opts.voiceSettings) body.voice_settings = opts.voiceSettings
|
|
236
|
+
|
|
237
|
+
this._log('[telnyx] Test TTS request:', JSON.stringify(body, null, 2))
|
|
238
|
+
|
|
239
|
+
const resp = await fetch('https://api.telnyx.com/v2/text-to-speech/speak', {
|
|
240
|
+
method: 'POST',
|
|
241
|
+
headers: {
|
|
242
|
+
Authorization: `Bearer ${process.env.TELNYX_API_KEY!}`,
|
|
243
|
+
'Content-Type': 'application/json',
|
|
244
|
+
},
|
|
245
|
+
body: JSON.stringify(body),
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
if (!resp.ok) {
|
|
249
|
+
const errText = await resp.text()
|
|
250
|
+
this._log('[telnyx] Test TTS failed:', resp.status, errText)
|
|
251
|
+
return { ok: false, status: resp.status, error: errText }
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const outputPath = opts.outputPath || 'docs/calls/voice-test.mp3'
|
|
255
|
+
const fs = this.container.feature('fs')
|
|
256
|
+
const absPath = this.container.paths.resolve(outputPath)
|
|
257
|
+
const buffer = Buffer.from(await resp.arrayBuffer())
|
|
258
|
+
await fs.writeFile(absPath, buffer)
|
|
259
|
+
this._log(`[telnyx] Saved TTS output → ${absPath} (${buffer.length} bytes)`)
|
|
260
|
+
return { ok: true, path: absPath, bytes: buffer.length }
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Pretty-print the voice-related config of an assistant. Shows the raw
|
|
265
|
+
* voice_settings that Telnyx has stored, so you can compare against what
|
|
266
|
+
* the UI displays.
|
|
267
|
+
*/
|
|
268
|
+
async inspectVoice(assistantId: string) {
|
|
269
|
+
const assistant = await this.getAssistant(assistantId)
|
|
270
|
+
const voice = assistant?.voice_settings
|
|
271
|
+
this._log('[telnyx] Current voice_settings on assistant:', JSON.stringify(voice, null, 2))
|
|
272
|
+
return voice
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ── Conversations ────────────────────────────────────────────────────────
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* List conversations for this assistant. Automatically filters by the
|
|
279
|
+
* assistant ID stored in state when available, so you only see conversations
|
|
280
|
+
* that belong to the current deployment.
|
|
281
|
+
*
|
|
282
|
+
* @example
|
|
283
|
+
* ```ts
|
|
284
|
+
* const convos = await connector.listConversations()
|
|
285
|
+
* const recent = await connector.listConversations({ order: 'last_message_at.desc', limit: 20 })
|
|
286
|
+
* ```
|
|
287
|
+
*/
|
|
288
|
+
async listConversations(query: Record<string, any> = {}) {
|
|
289
|
+
const client = await this._getClient()
|
|
290
|
+
const assistantId = this.state.get('telnyxAssistantId')
|
|
291
|
+
const params: any = { ...query }
|
|
292
|
+
if (assistantId && !params['metadata->assistant_id']) {
|
|
293
|
+
params['metadata->assistant_id'] = `eq.${assistantId}`
|
|
294
|
+
}
|
|
295
|
+
const resp = await client.ai.conversations.list(params)
|
|
296
|
+
return resp?.data || resp
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Retrieve a specific conversation by ID.
|
|
301
|
+
*/
|
|
302
|
+
async getConversation(conversationId: string) {
|
|
303
|
+
const client = await this._getClient()
|
|
304
|
+
const resp = await client.ai.conversations.retrieve(conversationId)
|
|
305
|
+
return resp?.data || resp
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* List all messages in a conversation, including assistant tool calls.
|
|
310
|
+
*/
|
|
311
|
+
async getConversationMessages(conversationId: string) {
|
|
312
|
+
const client = await this._getClient()
|
|
313
|
+
const resp = await client.ai.conversations.messages.list(conversationId)
|
|
314
|
+
return resp?.data || resp
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Retrieve post-call insights for a conversation (summaries, extracted data, etc.).
|
|
319
|
+
* Insights are generated asynchronously after the call ends — check `status` field.
|
|
320
|
+
*/
|
|
321
|
+
async getConversationInsights(conversationId: string) {
|
|
322
|
+
const client = await this._getClient()
|
|
323
|
+
const resp = await client.ai.conversations.retrieveConversationsInsights(conversationId)
|
|
324
|
+
return resp?.data || resp
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Manually inject a message into a conversation. Useful for adding context
|
|
329
|
+
* or system messages outside of a live call.
|
|
330
|
+
*/
|
|
331
|
+
async addConversationMessage(conversationId: string, message: {
|
|
332
|
+
role: string
|
|
333
|
+
content?: string
|
|
334
|
+
name?: string
|
|
335
|
+
sent_at?: string
|
|
336
|
+
tool_call_id?: string
|
|
337
|
+
tool_calls?: Array<Record<string, unknown>>
|
|
338
|
+
}) {
|
|
339
|
+
const client = await this._getClient()
|
|
340
|
+
await client.ai.conversations.addMessage(conversationId, message)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Disable AI responses on a conversation so a human agent can take over.
|
|
345
|
+
* While disabled, calls to the Telnyx chat endpoint return 400. Re-enable
|
|
346
|
+
* with `handoffToAI()`.
|
|
347
|
+
*
|
|
348
|
+
* @example
|
|
349
|
+
* ```ts
|
|
350
|
+
* await connector.handoffToHuman(conversationId)
|
|
351
|
+
* ```
|
|
352
|
+
*/
|
|
353
|
+
async handoffToHuman(conversationId: string) {
|
|
354
|
+
const client = await this._getClient()
|
|
355
|
+
await client.ai.conversations.update(conversationId, {
|
|
356
|
+
metadata: { ai_disabled: 'true' },
|
|
357
|
+
})
|
|
358
|
+
this._log(`[telnyx] Conversation ${conversationId} handed off to human (AI disabled)`)
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Re-enable AI responses on a conversation after a human handoff.
|
|
363
|
+
*/
|
|
364
|
+
async handoffToAI(conversationId: string) {
|
|
365
|
+
const client = await this._getClient()
|
|
366
|
+
await client.ai.conversations.update(conversationId, {
|
|
367
|
+
metadata: { ai_disabled: 'false' },
|
|
368
|
+
})
|
|
369
|
+
this._log(`[telnyx] Conversation ${conversationId} handed back to AI`)
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// ── Insight templates ─────────────────────────────────────────────────────
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Create an insight template — a reusable instruction applied to conversations
|
|
376
|
+
* to extract structured data (summaries, action items, sentiment, etc.).
|
|
377
|
+
* Optionally provide a `json_schema` to enforce structured output.
|
|
378
|
+
*
|
|
379
|
+
* @example
|
|
380
|
+
* ```ts
|
|
381
|
+
* await connector.createInsight({
|
|
382
|
+
* name: 'call-summary',
|
|
383
|
+
* instructions: 'Summarize this call in 2-3 sentences.',
|
|
384
|
+
* })
|
|
385
|
+
* await connector.createInsight({
|
|
386
|
+
* name: 'action-items',
|
|
387
|
+
* instructions: 'Extract any action items promised during the call.',
|
|
388
|
+
* json_schema: { type: 'array', items: { type: 'string' } },
|
|
389
|
+
* })
|
|
390
|
+
* ```
|
|
391
|
+
*/
|
|
392
|
+
async createInsight(params: { name: string; instructions: string; json_schema?: unknown; webhook?: string }) {
|
|
393
|
+
const client = await this._getClient()
|
|
394
|
+
const resp = await client.ai.conversations.insights.create(params as any)
|
|
395
|
+
return resp?.data || resp
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* List all insight templates on the account.
|
|
400
|
+
*/
|
|
401
|
+
async listInsights() {
|
|
402
|
+
const client = await this._getClient()
|
|
403
|
+
const results: any[] = []
|
|
404
|
+
for await (const insight of client.ai.conversations.insights.list()) {
|
|
405
|
+
results.push(insight)
|
|
406
|
+
}
|
|
407
|
+
return results
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Delete an insight template by ID.
|
|
412
|
+
*/
|
|
413
|
+
async deleteInsight(insightId: string) {
|
|
414
|
+
const client = await this._getClient()
|
|
415
|
+
await client.ai.conversations.insights.delete(insightId)
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// ── Phone numbers ─────────────────────────────────────────────────────────
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* List all phone numbers on the Telnyx account with their status and connection info.
|
|
422
|
+
*/
|
|
423
|
+
async listPhoneNumbers() {
|
|
424
|
+
const client = await this._getClient()
|
|
425
|
+
const numbers = await client.phoneNumbers.list()
|
|
426
|
+
const results: any[] = []
|
|
427
|
+
for await (const num of numbers) {
|
|
428
|
+
results.push({
|
|
429
|
+
id: num.id,
|
|
430
|
+
phone_number: num.phone_number,
|
|
431
|
+
status: num.status,
|
|
432
|
+
connection_id: num.connection_id,
|
|
433
|
+
connection_name: num.connection_name,
|
|
434
|
+
messaging_profile_id: num.messaging_profile_id,
|
|
435
|
+
tags: num.tags,
|
|
436
|
+
})
|
|
437
|
+
}
|
|
438
|
+
return results
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Get the phone number record (voice + messaging config) for an E.164 number.
|
|
443
|
+
*/
|
|
444
|
+
async getPhoneNumber(phoneNumber: string) {
|
|
445
|
+
const client = await this._getClient()
|
|
446
|
+
const numbers = await client.phoneNumbers.list({ 'filter[phone_number]': phoneNumber })
|
|
447
|
+
let record: any = null
|
|
448
|
+
for await (const num of numbers) {
|
|
449
|
+
record = num
|
|
450
|
+
break
|
|
451
|
+
}
|
|
452
|
+
if (!record) return null
|
|
453
|
+
|
|
454
|
+
let messagingConfig: any = null
|
|
455
|
+
try {
|
|
456
|
+
const msgResp = await client.phoneNumbers.messaging.retrieve(record.id)
|
|
457
|
+
messagingConfig = msgResp?.data || msgResp
|
|
458
|
+
} catch { }
|
|
459
|
+
|
|
460
|
+
return {
|
|
461
|
+
id: record.id,
|
|
462
|
+
phone_number: record.phone_number,
|
|
463
|
+
connection_id: record.connection_id,
|
|
464
|
+
connection_name: record.connection_name,
|
|
465
|
+
messaging_profile_id: record.messaging_profile_id,
|
|
466
|
+
messaging: messagingConfig,
|
|
467
|
+
tags: record.tags,
|
|
468
|
+
status: record.status,
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Get a TeXML application by ID.
|
|
474
|
+
*/
|
|
475
|
+
async getTexmlApp(appId: string) {
|
|
476
|
+
const client = await this._getClient()
|
|
477
|
+
const resp = await client.texmlApplications.retrieve(appId)
|
|
478
|
+
return resp?.data || resp
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* List all TeXML applications on the account.
|
|
483
|
+
*/
|
|
484
|
+
async listTexmlApps() {
|
|
485
|
+
const client = await this._getClient()
|
|
486
|
+
const resp = await client.texmlApplications.list()
|
|
487
|
+
const results: any[] = []
|
|
488
|
+
const items = resp?.data || resp
|
|
489
|
+
if (Array.isArray(items)) {
|
|
490
|
+
for (const app of items) {
|
|
491
|
+
results.push({
|
|
492
|
+
id: app.id,
|
|
493
|
+
friendly_name: app.friendly_name,
|
|
494
|
+
voice_url: app.voice_url,
|
|
495
|
+
status_callback: app.status_callback,
|
|
496
|
+
created_at: app.created_at,
|
|
497
|
+
updated_at: app.updated_at,
|
|
498
|
+
})
|
|
499
|
+
}
|
|
500
|
+
} else if (items?.[Symbol.asyncIterator]) {
|
|
501
|
+
for await (const app of items) {
|
|
502
|
+
results.push({
|
|
503
|
+
id: app.id,
|
|
504
|
+
friendly_name: app.friendly_name,
|
|
505
|
+
voice_url: app.voice_url,
|
|
506
|
+
status_callback: app.status_callback,
|
|
507
|
+
created_at: app.created_at,
|
|
508
|
+
updated_at: app.updated_at,
|
|
509
|
+
})
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
return results
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Delete all TeXML applications on the account.
|
|
517
|
+
* Returns a summary of what was deleted and any failures.
|
|
518
|
+
*/
|
|
519
|
+
async deleteAllTexmlApps() {
|
|
520
|
+
const apps = await this.listTexmlApps()
|
|
521
|
+
const client = await this._getClient()
|
|
522
|
+
const results: { id: string; friendly_name: string; status: 'deleted' | 'failed'; error?: string }[] = []
|
|
523
|
+
|
|
524
|
+
for (const app of apps) {
|
|
525
|
+
try {
|
|
526
|
+
await client.texmlApplications.delete(app.id)
|
|
527
|
+
results.push({ id: app.id, friendly_name: app.friendly_name, status: 'deleted' })
|
|
528
|
+
} catch (err: any) {
|
|
529
|
+
results.push({ id: app.id, friendly_name: app.friendly_name, status: 'failed', error: err.message })
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
return { total: apps.length, deleted: results.filter(r => r.status === 'deleted').length, results }
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Inspect the full live config: the current assistant, its messaging profile,
|
|
538
|
+
* the phone number wiring, and the TeXML app. Pass a phone number to include
|
|
539
|
+
* phone config, or omit to just show assistant + profile.
|
|
540
|
+
*/
|
|
541
|
+
async inspect(phoneNumber?: string) {
|
|
542
|
+
const result: any = {}
|
|
543
|
+
|
|
544
|
+
const assistantId = this.state.get('telnyxAssistantId')
|
|
545
|
+
if (assistantId) {
|
|
546
|
+
result.assistant = await this.getAssistant(assistantId)
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
if (this._messagingProfileId) {
|
|
550
|
+
result.messagingProfile = await this.getMessagingProfile(this._messagingProfileId)
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
if (phoneNumber || this.options.phoneNumber) {
|
|
554
|
+
result.phoneNumber = await this.getPhoneNumber(phoneNumber || this.options.phoneNumber!)
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const texmlAppId = result.assistant?.telephony_settings?.default_texml_app_id
|
|
558
|
+
if (texmlAppId) {
|
|
559
|
+
result.texmlApp = await this.getTexmlApp(texmlAppId)
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
return result
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Start the connector: mount tool endpoints, establish public URL, create Telnyx assistant,
|
|
567
|
+
* and optionally wire a phone number to it.
|
|
568
|
+
*
|
|
569
|
+
* @returns The session info including public URL and Telnyx assistant ID
|
|
570
|
+
*
|
|
571
|
+
* @example
|
|
572
|
+
* ```typescript
|
|
573
|
+
* const info = await connector.start()
|
|
574
|
+
* console.log(info.publicUrl, info.telnyxAssistantId)
|
|
575
|
+
* ```
|
|
576
|
+
*/
|
|
577
|
+
async start() {
|
|
578
|
+
const { Telnyx } = await import('telnyx')
|
|
579
|
+
this._telnyxClient = new Telnyx({ apiKey: process.env.TELNYX_API_KEY! })
|
|
580
|
+
|
|
581
|
+
let publicUrl: string | null = null
|
|
582
|
+
let port: number | null = null
|
|
583
|
+
|
|
584
|
+
if (this.options.noTools) {
|
|
585
|
+
await this._ensureMessagingProfile(null)
|
|
586
|
+
const telnyxAssistant = await this._createTelnyxAssistant(null)
|
|
587
|
+
|
|
588
|
+
if (this.options.phoneNumber) {
|
|
589
|
+
await this._wirePhoneNumber(telnyxAssistant)
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
this.state.set('telnyxAssistantId', telnyxAssistant.id)
|
|
593
|
+
this.state.set('running', true)
|
|
594
|
+
|
|
595
|
+
const info = {
|
|
596
|
+
publicUrl: null as string | null,
|
|
597
|
+
telnyxAssistantId: telnyxAssistant.id,
|
|
598
|
+
port: null as number | null,
|
|
599
|
+
phoneNumber: this.options.phoneNumber,
|
|
600
|
+
}
|
|
601
|
+
this.emit('started', info)
|
|
602
|
+
return info
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
port = await this._findAvailablePort(this.options.port)
|
|
606
|
+
const server = this.container.server('express', { port, cors: true })
|
|
607
|
+
|
|
608
|
+
this._mountToolEndpoints(server)
|
|
609
|
+
this._mountHangupTool(server)
|
|
610
|
+
this._mountCallEventsEndpoint(server)
|
|
611
|
+
this._mountInboundSmsEndpoint(server)
|
|
612
|
+
|
|
613
|
+
await server.start()
|
|
614
|
+
this._server = server
|
|
615
|
+
|
|
616
|
+
if (this.options.domain) {
|
|
617
|
+
publicUrl = `https://${this.options.domain}`
|
|
618
|
+
this._log(`[telnyx] Using pre-configured domain: ${publicUrl}`)
|
|
619
|
+
await this._waitForTunnelReady(publicUrl)
|
|
620
|
+
} else {
|
|
621
|
+
publicUrl = await this._startTunnel(port)
|
|
622
|
+
await this._waitForTunnelReady(publicUrl)
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
await this._ensureMessagingProfile(publicUrl)
|
|
626
|
+
const telnyxAssistant = await this._createTelnyxAssistant(publicUrl)
|
|
627
|
+
|
|
628
|
+
if (this.options.phoneNumber) {
|
|
629
|
+
await this._wirePhoneNumber(telnyxAssistant)
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
this.state.set('publicUrl', publicUrl)
|
|
633
|
+
this.state.set('telnyxAssistantId', telnyxAssistant.id)
|
|
634
|
+
this.state.set('port', port)
|
|
635
|
+
this.state.set('running', true)
|
|
636
|
+
|
|
637
|
+
const info = {
|
|
638
|
+
publicUrl,
|
|
639
|
+
telnyxAssistantId: telnyxAssistant.id,
|
|
640
|
+
port,
|
|
641
|
+
phoneNumber: this.options.phoneNumber,
|
|
642
|
+
}
|
|
643
|
+
this.emit('started', info)
|
|
644
|
+
|
|
645
|
+
return info
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* Stop the connector: restore the phone number's previous connection,
|
|
650
|
+
* delete the Telnyx assistant, kill tunnel (if ephemeral), stop the server.
|
|
651
|
+
*
|
|
652
|
+
* @example
|
|
653
|
+
* ```typescript
|
|
654
|
+
* await connector.stop()
|
|
655
|
+
* ```
|
|
656
|
+
*/
|
|
657
|
+
async stop() {
|
|
658
|
+
const phoneNumberId = this.state.get('phoneNumberId')
|
|
659
|
+
if (phoneNumberId && this._telnyxClient) {
|
|
660
|
+
try {
|
|
661
|
+
if (this._previousConnectionId) {
|
|
662
|
+
await this._telnyxClient.phoneNumbers.update(phoneNumberId, {
|
|
663
|
+
connection_id: this._previousConnectionId,
|
|
664
|
+
})
|
|
665
|
+
}
|
|
666
|
+
if (!this._messagingProfileId) {
|
|
667
|
+
await this._telnyxClient.phoneNumbers.messaging.update(phoneNumberId, {
|
|
668
|
+
messaging_profile_id: '',
|
|
669
|
+
})
|
|
670
|
+
this._log('[telnyx] Unset messaging profile on phone number')
|
|
671
|
+
} else {
|
|
672
|
+
this._log('[telnyx] Leaving persistent messaging profile on phone number')
|
|
673
|
+
}
|
|
674
|
+
} catch (e) {
|
|
675
|
+
// best effort
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
const assistantId = this.state.get('telnyxAssistantId')
|
|
680
|
+
if (assistantId && this._telnyxClient) {
|
|
681
|
+
try {
|
|
682
|
+
await this._telnyxClient.ai.assistants.delete(assistantId)
|
|
683
|
+
} catch (e) {
|
|
684
|
+
// best effort cleanup
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
if (this._tunnelProcess) {
|
|
689
|
+
try { this._tunnelProcess.kill() } catch {}
|
|
690
|
+
this._tunnelProcess = null
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
if (this._server) {
|
|
694
|
+
await this._server.stop()
|
|
695
|
+
this._server = null
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
this.state.set('running', false)
|
|
699
|
+
this.emit('stopped')
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
private _mountToolEndpoints(server: any) {
|
|
703
|
+
const tools = this.assistant.tools
|
|
704
|
+
|
|
705
|
+
for (const [name, tool] of Object.entries(tools) as [string, any][]) {
|
|
706
|
+
server.app.post(`/tools/${name}`, async (req: any, res: any) => {
|
|
707
|
+
try {
|
|
708
|
+
this.emit('toolCall', name, req.body)
|
|
709
|
+
const result = await tool.handler(req.body)
|
|
710
|
+
res.json({ result })
|
|
711
|
+
} catch (err: any) {
|
|
712
|
+
this.emit('toolError', name, err instanceof Error ? err : new Error(String(err)))
|
|
713
|
+
res.status(500).json({ error: err.message })
|
|
714
|
+
}
|
|
715
|
+
})
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
server.app.get('/health', (_req: any, res: any) => {
|
|
719
|
+
res.json({
|
|
720
|
+
status: 'ok',
|
|
721
|
+
assistant: this.assistantName,
|
|
722
|
+
tools: Object.keys(tools),
|
|
723
|
+
})
|
|
724
|
+
})
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
private _mountHangupTool(server: any) {
|
|
728
|
+
server.app.post('/tools/hangup', async (_req: any, res: any) => {
|
|
729
|
+
const callSid = this._activeCallSid
|
|
730
|
+
this._log(`[telnyx] Hangup tool called (callSid: ${callSid})`)
|
|
731
|
+
this.emit('toolCall', 'hangup', {})
|
|
732
|
+
|
|
733
|
+
if (!callSid) {
|
|
734
|
+
res.json({ result: 'No active call to hang up' })
|
|
735
|
+
return
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
try {
|
|
739
|
+
await fetch(`https://api.telnyx.com/v2/calls/${encodeURIComponent(callSid)}/actions/hangup`, {
|
|
740
|
+
method: 'POST',
|
|
741
|
+
headers: {
|
|
742
|
+
Authorization: `Bearer ${process.env.TELNYX_API_KEY!}`,
|
|
743
|
+
'Content-Type': 'application/json',
|
|
744
|
+
},
|
|
745
|
+
body: JSON.stringify({}),
|
|
746
|
+
})
|
|
747
|
+
this._activeCallSid = null
|
|
748
|
+
res.json({ result: 'Call ended' })
|
|
749
|
+
} catch (err: any) {
|
|
750
|
+
this.emit('toolError', 'hangup', err instanceof Error ? err : new Error(String(err)))
|
|
751
|
+
res.json({ result: `Failed to hang up: ${err.message}` })
|
|
752
|
+
}
|
|
753
|
+
})
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
private _mountCallEventsEndpoint(server: any) {
|
|
757
|
+
server.app.post('/call/events', async (req: any, res: any) => {
|
|
758
|
+
try {
|
|
759
|
+
const body = req.body
|
|
760
|
+
const status = body?.CallStatus || body?.DialCallStatus || 'unknown'
|
|
761
|
+
const callSid = body?.CallSid || 'unknown'
|
|
762
|
+
const conversationId = body?.ConversationId || ''
|
|
763
|
+
|
|
764
|
+
this._log(`[telnyx] Call event: ${status} (${callSid})`)
|
|
765
|
+
|
|
766
|
+
const terminalStatuses = ['completed', 'failed', 'busy', 'no-answer', 'canceled', 'conversation_ended', 'analyzed']
|
|
767
|
+
if (callSid && callSid !== 'unknown') {
|
|
768
|
+
if (terminalStatuses.includes(status)) {
|
|
769
|
+
if (this._activeCallSid === callSid) this._activeCallSid = null
|
|
770
|
+
} else {
|
|
771
|
+
this._activeCallSid = callSid
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
let insights: string | null = null
|
|
776
|
+
try {
|
|
777
|
+
const parsed = JSON.parse(body?.ConversationInsights || '[]')
|
|
778
|
+
insights = parsed?.[0]?.conversation_insights?.[0]?.result || null
|
|
779
|
+
} catch {}
|
|
780
|
+
|
|
781
|
+
if (insights) {
|
|
782
|
+
this._log(`[telnyx] Summary: ${insights.slice(0, 200)}${insights.length > 200 ? '...' : ''}`)
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
let cost: any = null
|
|
786
|
+
try { cost = JSON.parse(body?.Cost || '{}') } catch {}
|
|
787
|
+
if (cost?.total) {
|
|
788
|
+
this._log(`[telnyx] Cost: $${cost.total}`)
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
this._saveCallEvent(body, conversationId, status).catch((err: any) =>
|
|
792
|
+
console.error('[telnyx] Failed to save call event:', err.message)
|
|
793
|
+
)
|
|
794
|
+
|
|
795
|
+
res.status(200).json({ status: 'ok' })
|
|
796
|
+
} catch (err: any) {
|
|
797
|
+
console.error('[telnyx] Call event error:', err.message)
|
|
798
|
+
res.status(200).json({ status: 'ok' })
|
|
799
|
+
}
|
|
800
|
+
})
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
private _mountInboundSmsEndpoint(server: any) {
|
|
804
|
+
const assistantsByPhone = new Map<string, any>()
|
|
805
|
+
|
|
806
|
+
server.app.post('/messaging/inbound', async (req: any, res: any) => {
|
|
807
|
+
res.status(200).json({ status: 'ok' })
|
|
808
|
+
|
|
809
|
+
try {
|
|
810
|
+
const payload = req.body?.data?.payload || req.body
|
|
811
|
+
const eventType = req.body?.data?.event_type || ''
|
|
812
|
+
const direction = payload?.direction || ''
|
|
813
|
+
|
|
814
|
+
if (!eventType.includes('inbound') && direction !== 'inbound') return
|
|
815
|
+
|
|
816
|
+
const from = payload?.from?.phone_number || payload?.from || ''
|
|
817
|
+
const to = (payload?.to?.[0]?.phone_number) || payload?.to || ''
|
|
818
|
+
const text = payload?.text || ''
|
|
819
|
+
if (!text || !from) return
|
|
820
|
+
|
|
821
|
+
this._log(`[telnyx] Inbound SMS from ${from}: "${text}"`)
|
|
822
|
+
|
|
823
|
+
let smsAssistant = assistantsByPhone.get(from)
|
|
824
|
+
if (!smsAssistant) {
|
|
825
|
+
const mgr = this.container.feature('assistantsManager')
|
|
826
|
+
smsAssistant = mgr.create(this.assistantName, { historyMode: 'lifecycle' })
|
|
827
|
+
await smsAssistant.start()
|
|
828
|
+
assistantsByPhone.set(from, smsAssistant)
|
|
829
|
+
this._log(`[telnyx] Created local assistant for ${from}`)
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
const reply = await smsAssistant.ask(text)
|
|
833
|
+
|
|
834
|
+
if (!reply) {
|
|
835
|
+
this._log('[telnyx] Assistant returned empty reply')
|
|
836
|
+
return
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
this._log(`[telnyx] Reply to ${from}: "${reply.slice(0, 120)}${reply.length > 120 ? '...' : ''}"`)
|
|
840
|
+
this._log(`[telnyx] Sending SMS: from=${to}, to=${from}, profile=${this._messagingProfileId}`)
|
|
841
|
+
|
|
842
|
+
const sendResult = await this._telnyxClient.messages.send({
|
|
843
|
+
from: to,
|
|
844
|
+
to: from,
|
|
845
|
+
text: reply,
|
|
846
|
+
messaging_profile_id: this._messagingProfileId,
|
|
847
|
+
})
|
|
848
|
+
const sendData = sendResult?.data || sendResult
|
|
849
|
+
this._log(`[telnyx] SMS send response:`, JSON.stringify({
|
|
850
|
+
id: sendData?.id,
|
|
851
|
+
status: sendData?.to?.[0]?.status,
|
|
852
|
+
from: sendData?.from?.phone_number,
|
|
853
|
+
to: sendData?.to?.[0]?.phone_number,
|
|
854
|
+
errors: sendData?.errors,
|
|
855
|
+
}, null, 2))
|
|
856
|
+
this._log(`[telnyx] SMS sent to ${from}`)
|
|
857
|
+
} catch (err: any) {
|
|
858
|
+
console.error('[telnyx] SMS handler error:', err.message)
|
|
859
|
+
}
|
|
860
|
+
})
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
/**
|
|
864
|
+
* Save a call event to docs/calls/{slug}/{status}-{timestamp}.json.
|
|
865
|
+
* Each call gets its own folder (keyed by CallSid). MP3 recordings are
|
|
866
|
+
* downloaded when status is "analyzed".
|
|
867
|
+
*/
|
|
868
|
+
private async _saveCallEvent(body: any, conversationId: string, status: string) {
|
|
869
|
+
const fs = this.container.feature('fs')
|
|
870
|
+
const slug = body?.CallSid || conversationId || new Date().toISOString().replace(/[:.]/g, '-')
|
|
871
|
+
const callDir = this.container.paths.resolve(`docs/calls/${slug}`)
|
|
872
|
+
await fs.ensureFolder(callDir)
|
|
873
|
+
|
|
874
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
|
|
875
|
+
const jsonPath = this.container.paths.join(callDir, `${status}-${timestamp}.json`)
|
|
876
|
+
await fs.writeFile(jsonPath, JSON.stringify(body, null, 2))
|
|
877
|
+
this._log(`[telnyx] Saved call event → ${jsonPath}`)
|
|
878
|
+
|
|
879
|
+
if (status !== 'analyzed') return
|
|
880
|
+
|
|
881
|
+
let recordings: any[] = []
|
|
882
|
+
try { recordings = JSON.parse(body?.Recordings || '[]') } catch {}
|
|
883
|
+
|
|
884
|
+
for (const rec of recordings) {
|
|
885
|
+
const mp3Url = rec?.download_urls?.mp3
|
|
886
|
+
if (!mp3Url) continue
|
|
887
|
+
|
|
888
|
+
try {
|
|
889
|
+
const resp = await fetch(mp3Url)
|
|
890
|
+
if (!resp.ok) {
|
|
891
|
+
this._log(`[telnyx] Failed to download recording: ${resp.status}`)
|
|
892
|
+
continue
|
|
893
|
+
}
|
|
894
|
+
const buffer = Buffer.from(await resp.arrayBuffer())
|
|
895
|
+
const mp3Path = this.container.paths.join(callDir, `recording.mp3`)
|
|
896
|
+
await fs.writeFile(mp3Path, buffer)
|
|
897
|
+
this._log(`[telnyx] Saved recording → ${mp3Path}`)
|
|
898
|
+
} catch (err: any) {
|
|
899
|
+
this._log(`[telnyx] Failed to download recording: ${err.message}`)
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
private async _findAvailablePort(preferred: number): Promise<number> {
|
|
905
|
+
return this.container.feature('networking').findOpenPort(preferred)
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
private async _waitForTunnelReady(url: string): Promise<void> {
|
|
909
|
+
const timeoutMs = 120000
|
|
910
|
+
const start = Date.now()
|
|
911
|
+
const deadline = start + timeoutMs
|
|
912
|
+
let attempt = 0
|
|
913
|
+
let lastLog = 0
|
|
914
|
+
while (Date.now() < deadline) {
|
|
915
|
+
attempt++
|
|
916
|
+
try {
|
|
917
|
+
const r = await fetch(`${url}/health`, { method: 'GET' })
|
|
918
|
+
if (r.ok) {
|
|
919
|
+
this._log(`[telnyx] tunnel ready after ${Date.now() - start}ms (attempt ${attempt})`)
|
|
920
|
+
return
|
|
921
|
+
}
|
|
922
|
+
} catch {
|
|
923
|
+
// not yet routable
|
|
924
|
+
}
|
|
925
|
+
const elapsed = Date.now() - start
|
|
926
|
+
if (elapsed - lastLog >= 10000) {
|
|
927
|
+
this._log(`[telnyx] tunnel not ready yet (${Math.round(elapsed / 1000)}s, attempt ${attempt})`)
|
|
928
|
+
lastLog = elapsed
|
|
929
|
+
}
|
|
930
|
+
await new Promise((r) => setTimeout(r, 1500))
|
|
931
|
+
}
|
|
932
|
+
throw new Error(`Tunnel ${url} did not become reachable within ${timeoutMs / 1000}s`)
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/**
|
|
936
|
+
* Start a cloudflared quick tunnel and capture the public trycloudflare.com URL.
|
|
937
|
+
* Each invocation gets a fresh ephemeral hostname — no config or login required.
|
|
938
|
+
*/
|
|
939
|
+
private async _startTunnel(port: number): Promise<string> {
|
|
940
|
+
const proc = this.container.feature('proc')
|
|
941
|
+
|
|
942
|
+
const child = proc.spawn('cloudflared', [
|
|
943
|
+
'tunnel',
|
|
944
|
+
'--no-autoupdate',
|
|
945
|
+
'--url', `http://localhost:${port}`,
|
|
946
|
+
])
|
|
947
|
+
this._tunnelProcess = child
|
|
948
|
+
this._log(`[telnyx] cloudflared tunneling :${port}`)
|
|
949
|
+
|
|
950
|
+
return await new Promise<string>((resolve, reject) => {
|
|
951
|
+
const timer = setTimeout(() => {
|
|
952
|
+
reject(new Error(`Failed to start cloudflared tunnel for :${port} within 90s`))
|
|
953
|
+
}, 90000)
|
|
954
|
+
|
|
955
|
+
const urlPattern = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i
|
|
956
|
+
let publicUrl: string | null = null
|
|
957
|
+
let registered = false
|
|
958
|
+
let resolved = false
|
|
959
|
+
|
|
960
|
+
const tryResolve = () => {
|
|
961
|
+
if (resolved || !publicUrl || !registered) return
|
|
962
|
+
resolved = true
|
|
963
|
+
clearTimeout(timer)
|
|
964
|
+
this._log(`[telnyx] tunnel registered with edge → ${publicUrl}`)
|
|
965
|
+
resolve(publicUrl)
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
const onChunk = (chunk: any) => {
|
|
969
|
+
const text = String(chunk)
|
|
970
|
+
for (const line of text.split('\n')) {
|
|
971
|
+
const trimmed = line.trim()
|
|
972
|
+
if (!trimmed) continue
|
|
973
|
+
this._log(`[cloudflared:${port}] ${trimmed.slice(0, 500)}`)
|
|
974
|
+
if (!publicUrl) {
|
|
975
|
+
const match = trimmed.match(urlPattern)
|
|
976
|
+
if (match) publicUrl = match[0]
|
|
977
|
+
}
|
|
978
|
+
if (!registered && /Registered tunnel connection/i.test(trimmed)) {
|
|
979
|
+
registered = true
|
|
980
|
+
}
|
|
981
|
+
tryResolve()
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
child.stdout?.on?.('data', onChunk)
|
|
986
|
+
child.stderr?.on?.('data', onChunk)
|
|
987
|
+
})
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
/**
|
|
991
|
+
* Create a Telnyx assistant that mirrors the local assistant's prompt and tools.
|
|
992
|
+
*/
|
|
993
|
+
private async _createTelnyxAssistant(publicUrl: string | null) {
|
|
994
|
+
const webhookTools = []
|
|
995
|
+
|
|
996
|
+
if (publicUrl) {
|
|
997
|
+
const tools = this.assistant.tools
|
|
998
|
+
for (const [name, tool] of Object.entries(tools) as [string, any][]) {
|
|
999
|
+
webhookTools.push({
|
|
1000
|
+
type: 'webhook' as const,
|
|
1001
|
+
webhook: {
|
|
1002
|
+
name,
|
|
1003
|
+
description: tool.description || name,
|
|
1004
|
+
url: `${publicUrl}/tools/${name}`,
|
|
1005
|
+
method: 'POST' as const,
|
|
1006
|
+
body_parameters: tool.parameters || { type: 'object', properties: {} },
|
|
1007
|
+
timeout_ms: 10000,
|
|
1008
|
+
},
|
|
1009
|
+
})
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
webhookTools.push({
|
|
1013
|
+
type: 'webhook' as const,
|
|
1014
|
+
webhook: {
|
|
1015
|
+
name: 'hangup',
|
|
1016
|
+
description: 'End the current phone call. Call this when the conversation is complete or the caller should be disconnected.',
|
|
1017
|
+
url: `${publicUrl}/tools/hangup`,
|
|
1018
|
+
method: 'POST' as const,
|
|
1019
|
+
body_parameters: { type: 'object', properties: {} },
|
|
1020
|
+
timeout_ms: 5000,
|
|
1021
|
+
},
|
|
1022
|
+
})
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
const params: any = {
|
|
1026
|
+
name: `luca-${this.assistantName}`,
|
|
1027
|
+
instructions: this.assistant.effectiveSystemPrompt,
|
|
1028
|
+
model: this.options.model,
|
|
1029
|
+
enabled_features: ['telephony', 'messaging'],
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
const voiceConfig = this.assistant.voiceConfig
|
|
1033
|
+
const isElevenLabs = this.options.ttsProvider === 'elevenlabs' || voiceConfig?.provider === 'elevenlabs'
|
|
1034
|
+
const voiceId = this.options.voice || voiceConfig?.voiceId
|
|
1035
|
+
const apiKeyRef = this.options.apiKeyRef
|
|
1036
|
+
|
|
1037
|
+
this._log('[telnyx] Voice resolution:', JSON.stringify({
|
|
1038
|
+
sources: {
|
|
1039
|
+
'options.voice': this.options.voice,
|
|
1040
|
+
'options.ttsProvider': this.options.ttsProvider,
|
|
1041
|
+
'options.apiKeyRef': this.options.apiKeyRef,
|
|
1042
|
+
'assistant.voiceConfig': voiceConfig,
|
|
1043
|
+
},
|
|
1044
|
+
resolved: { voiceId, isElevenLabs, apiKeyRef },
|
|
1045
|
+
}, null, 2))
|
|
1046
|
+
|
|
1047
|
+
if (voiceId) {
|
|
1048
|
+
let resolvedVoice = voiceId
|
|
1049
|
+
if (isElevenLabs && !/^ElevenLabs\./i.test(voiceId)) {
|
|
1050
|
+
const supported = new Set([
|
|
1051
|
+
'eleven_flash_v2', 'eleven_flash_v2_5', 'eleven_multilingual_v1',
|
|
1052
|
+
'eleven_multilingual_v2', 'eleven_turbo_v2', 'eleven_turbo_v2_5',
|
|
1053
|
+
'eleven_v2_5_flash', 'eleven_v2_flash',
|
|
1054
|
+
])
|
|
1055
|
+
const model = voiceConfig?.modelId
|
|
1056
|
+
resolvedVoice = model && supported.has(model)
|
|
1057
|
+
? `ElevenLabs.${model}.${voiceId}`
|
|
1058
|
+
: `ElevenLabs.${voiceId}`
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
const voiceSettings: any = { voice: resolvedVoice }
|
|
1062
|
+
if (apiKeyRef && isElevenLabs) {
|
|
1063
|
+
voiceSettings.api_key_ref = apiKeyRef
|
|
1064
|
+
}
|
|
1065
|
+
if (isElevenLabs && typeof voiceConfig?.voiceSettings?.speed === 'number') {
|
|
1066
|
+
voiceSettings.voice_speed = voiceConfig.voiceSettings.speed
|
|
1067
|
+
}
|
|
1068
|
+
params.voice_settings = voiceSettings
|
|
1069
|
+
this._log('[telnyx] Sending voice_settings:', JSON.stringify(voiceSettings, null, 2))
|
|
1070
|
+
} else {
|
|
1071
|
+
this._log('[telnyx] No voiceId resolved — using Telnyx default voice')
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
if (this._messagingProfileId) {
|
|
1075
|
+
params.messaging_settings = {
|
|
1076
|
+
default_messaging_profile_id: this._messagingProfileId,
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
if (webhookTools.length > 0) {
|
|
1081
|
+
params.tools = webhookTools
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
if (this.options.greeting) {
|
|
1085
|
+
params.greeting = this.options.greeting
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
this._log('[telnyx] Creating assistant with params:', JSON.stringify(params, null, 2))
|
|
1089
|
+
const result = await this._telnyxClient.ai.assistants.create(params)
|
|
1090
|
+
this._log('[telnyx] Assistant created:', JSON.stringify({
|
|
1091
|
+
id: result.id,
|
|
1092
|
+
name: result.name,
|
|
1093
|
+
enabled_features: result.enabled_features,
|
|
1094
|
+
telephony_settings: result.telephony_settings,
|
|
1095
|
+
messaging_settings: result.messaging_settings,
|
|
1096
|
+
}, null, 2))
|
|
1097
|
+
|
|
1098
|
+
if (publicUrl) {
|
|
1099
|
+
const texmlAppId = result.telephony_settings?.default_texml_app_id
|
|
1100
|
+
if (texmlAppId) {
|
|
1101
|
+
try {
|
|
1102
|
+
await this._telnyxClient.texmlApplications.update(texmlAppId, {
|
|
1103
|
+
status_callback: `${publicUrl}/call/events`,
|
|
1104
|
+
})
|
|
1105
|
+
this._log(`[telnyx] Wired TeXML app status callback → ${publicUrl}/call/events`)
|
|
1106
|
+
} catch (err: any) {
|
|
1107
|
+
this._log(`[telnyx] Could not set TeXML status callback: ${err.message}`)
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
return result
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
/**
|
|
1116
|
+
* Find or create a single persistent messaging profile named after the assistant.
|
|
1117
|
+
*/
|
|
1118
|
+
private async _ensureMessagingProfile(publicUrl: string | null): Promise<string> {
|
|
1119
|
+
const client = this._telnyxClient
|
|
1120
|
+
const profileName = `luca-${this.assistantName}`
|
|
1121
|
+
|
|
1122
|
+
const profiles = await client.messagingProfiles.list()
|
|
1123
|
+
let existing: any = null
|
|
1124
|
+
|
|
1125
|
+
for await (const profile of profiles) {
|
|
1126
|
+
if (profile.name === profileName) {
|
|
1127
|
+
existing = profile
|
|
1128
|
+
break
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
if (existing) {
|
|
1133
|
+
this._log(`[telnyx] Found existing messaging profile "${profileName}" (${existing.id})`)
|
|
1134
|
+
if (existing.webhook_url) {
|
|
1135
|
+
await client.messagingProfiles.update(existing.id, { webhook_url: '' })
|
|
1136
|
+
this._log(`[telnyx] Cleared messaging profile webhook (letting Telnyx assistant handle SMS natively)`)
|
|
1137
|
+
}
|
|
1138
|
+
this._messagingProfileId = existing.id
|
|
1139
|
+
return existing.id
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
this._log(`[telnyx] Creating messaging profile "${profileName}"`)
|
|
1143
|
+
const created = await client.messagingProfiles.create({
|
|
1144
|
+
name: profileName,
|
|
1145
|
+
webhook_url: '',
|
|
1146
|
+
whitelisted_destinations: ['US'],
|
|
1147
|
+
})
|
|
1148
|
+
const profileId = created?.data?.id || created?.id
|
|
1149
|
+
this._log(`[telnyx] Created messaging profile "${profileName}" (${profileId})`)
|
|
1150
|
+
this._messagingProfileId = profileId
|
|
1151
|
+
return profileId
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
/**
|
|
1155
|
+
* Wire a phone number to the assistant's auto-created TeXML app and
|
|
1156
|
+
* the persistent messaging profile. Saves the previous connection_id
|
|
1157
|
+
* so stop() can restore it.
|
|
1158
|
+
*/
|
|
1159
|
+
private async _wirePhoneNumber(telnyxAssistant: any) {
|
|
1160
|
+
const phoneNumber = this.options.phoneNumber!
|
|
1161
|
+
const client = this._telnyxClient
|
|
1162
|
+
|
|
1163
|
+
const numbers = await client.phoneNumbers.list({ 'filter[phone_number]': phoneNumber })
|
|
1164
|
+
let phoneRecord: any = null
|
|
1165
|
+
|
|
1166
|
+
for await (const num of numbers) {
|
|
1167
|
+
phoneRecord = num
|
|
1168
|
+
break
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
if (!phoneRecord) {
|
|
1172
|
+
throw new Error(`Phone number ${phoneNumber} not found in your Telnyx account`)
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
this._log('[telnyx] Phone record:', JSON.stringify({
|
|
1176
|
+
id: phoneRecord.id,
|
|
1177
|
+
phone_number: phoneRecord.phone_number,
|
|
1178
|
+
connection_id: phoneRecord.connection_id,
|
|
1179
|
+
messaging_profile_id: phoneRecord.messaging_profile_id,
|
|
1180
|
+
}, null, 2))
|
|
1181
|
+
|
|
1182
|
+
this._previousConnectionId = phoneRecord.connection_id || null
|
|
1183
|
+
this.state.set('phoneNumberId', phoneRecord.id)
|
|
1184
|
+
|
|
1185
|
+
const texmlAppId = telnyxAssistant.telephony_settings?.default_texml_app_id
|
|
1186
|
+
if (!texmlAppId) {
|
|
1187
|
+
throw new Error('Telnyx assistant did not create a TeXML app — is telephony enabled?')
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
this._log('[telnyx] Wiring voice connection_id:', texmlAppId)
|
|
1191
|
+
await client.phoneNumbers.update(phoneRecord.id, {
|
|
1192
|
+
connection_id: texmlAppId,
|
|
1193
|
+
})
|
|
1194
|
+
|
|
1195
|
+
if (this._messagingProfileId) {
|
|
1196
|
+
this._log('[telnyx] Wiring messaging_profile_id:', this._messagingProfileId)
|
|
1197
|
+
await client.phoneNumbers.messaging.update(phoneRecord.id, {
|
|
1198
|
+
messaging_profile_id: this._messagingProfileId,
|
|
1199
|
+
})
|
|
1200
|
+
} else {
|
|
1201
|
+
this._log('[telnyx] WARNING: No messaging profile available — SMS will not work')
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
export default TelnyxAssistantConnector
|