vellum 0.2.0 → 0.2.2
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/README.md +15 -2
- package/bun.lock +5 -2
- package/package.json +4 -2
- package/scripts/capture-x-graphql.ts +562 -0
- package/scripts/ipc/check-swift-decoder-drift.ts +2 -1
- package/scripts/test.sh +5 -0
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +161 -34
- package/src/__tests__/account-registry.test.ts +2 -1
- package/src/__tests__/agent-heartbeat-service.test.ts +250 -0
- package/src/__tests__/app-bundler.test.ts +12 -33
- package/src/__tests__/asset-materialize-tool.test.ts +16 -15
- package/src/__tests__/asset-search-tool.test.ts +23 -22
- package/src/__tests__/attachments-store.test.ts +56 -127
- package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +5 -4
- package/src/__tests__/browser-skill-endstate.test.ts +5 -8
- package/src/__tests__/call-bridge.test.ts +385 -0
- package/src/__tests__/call-constants.test.ts +40 -0
- package/src/__tests__/call-orchestrator.test.ts +454 -0
- package/src/__tests__/call-recovery.test.ts +518 -0
- package/src/__tests__/call-routes-http.test.ts +459 -0
- package/src/__tests__/call-state-machine.test.ts +143 -0
- package/src/__tests__/call-state.test.ts +133 -0
- package/src/__tests__/call-store.test.ts +691 -0
- package/src/__tests__/cli-discover.test.ts +1 -1
- package/src/__tests__/commit-message-enrichment-service.test.ts +550 -0
- package/src/__tests__/compaction.benchmark.test.ts +176 -0
- package/src/__tests__/computer-use-tools.test.ts +250 -0
- package/src/__tests__/config-schema.test.ts +348 -3
- package/src/__tests__/conflict-store.test.ts +2 -1
- package/src/__tests__/contacts-tools.test.ts +331 -0
- package/src/__tests__/conversation-store.test.ts +30 -32
- package/src/__tests__/credential-security-invariants.test.ts +4 -0
- package/src/__tests__/date-context.test.ts +373 -0
- package/src/__tests__/db-schedule-syntax-migration.test.ts +129 -0
- package/src/__tests__/doordash-session.test.ts +9 -0
- package/src/__tests__/fixtures/media-reuse-fixtures.ts +3 -3
- package/src/__tests__/followup-tools.test.ts +303 -0
- package/src/__tests__/handlers-twitter-config.test.ts +718 -0
- package/src/__tests__/intent-routing.test.ts +64 -57
- package/src/__tests__/ipc-roundtrip.benchmark.test.ts +237 -0
- package/src/__tests__/ipc-snapshot.test.ts +96 -28
- package/src/__tests__/llm-usage-store.test.ts +3 -8
- package/src/__tests__/media-generate-image.test.ts +1 -1
- package/src/__tests__/media-reuse-story.e2e.test.ts +7 -7
- package/src/__tests__/memory-retrieval.benchmark.test.ts +430 -0
- package/src/__tests__/parallel-tool.benchmark.test.ts +294 -0
- package/src/__tests__/playbook-tools.test.ts +342 -0
- package/src/__tests__/profile-compiler.test.ts +2 -1
- package/src/__tests__/provider-streaming.benchmark.test.ts +773 -0
- package/src/__tests__/recurrence-engine-rruleset.test.ts +78 -0
- package/src/__tests__/recurrence-engine.test.ts +69 -0
- package/src/__tests__/recurrence-types.test.ts +71 -0
- package/src/__tests__/registry.test.ts +17 -10
- package/src/__tests__/relay-server.test.ts +633 -0
- package/src/__tests__/reminder-store.test.ts +6 -3
- package/src/__tests__/reminder.test.ts +43 -77
- package/src/__tests__/run-orchestrator-assistant-events.test.ts +222 -0
- package/src/__tests__/run-orchestrator.test.ts +7 -7
- package/src/__tests__/runtime-attachment-metadata.test.ts +19 -20
- package/src/__tests__/runtime-runs-http.test.ts +5 -23
- package/src/__tests__/runtime-runs.test.ts +11 -11
- package/src/__tests__/schedule-store.test.ts +482 -0
- package/src/__tests__/schedule-tools.test.ts +700 -0
- package/src/__tests__/scheduler-recurrence.test.ts +329 -0
- package/src/__tests__/server-history-render.test.ts +14 -13
- package/src/__tests__/session-error.test.ts +28 -0
- package/src/__tests__/session-init.benchmark.test.ts +462 -0
- package/src/__tests__/session-queue.test.ts +89 -16
- package/src/__tests__/session-runtime-assembly.test.ts +161 -0
- package/src/__tests__/session-surfaces-task-progress.test.ts +104 -0
- package/src/__tests__/signup-e2e.test.ts +2 -1
- package/src/__tests__/skill-projection.benchmark.test.ts +328 -0
- package/src/__tests__/skill-script-runner.test.ts +159 -0
- package/src/__tests__/speaker-identification.test.ts +52 -0
- package/src/__tests__/subagent-manager-notify.test.ts +42 -10
- package/src/__tests__/subagent-tools.test.ts +141 -41
- package/src/__tests__/task-compiler.test.ts +2 -1
- package/src/__tests__/task-runner.test.ts +2 -1
- package/src/__tests__/task-scheduler.test.ts +2 -1
- package/src/__tests__/task-tools.test.ts +49 -56
- package/src/__tests__/tool-audit-listener.test.ts +1 -0
- package/src/__tests__/tool-domain-event-publisher.test.ts +2 -0
- package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +500 -0
- package/src/__tests__/tool-executor.test.ts +13 -17
- package/src/__tests__/turn-commit.test.ts +273 -2
- package/src/__tests__/twilio-provider.test.ts +143 -0
- package/src/__tests__/twilio-routes.test.ts +789 -0
- package/src/__tests__/twitter-auth-handler.test.ts +581 -0
- package/src/__tests__/view-image-tool.test.ts +217 -0
- package/src/__tests__/workspace-git-service.test.ts +403 -0
- package/src/__tests__/workspace-heartbeat-service.test.ts +141 -2
- package/src/agent-heartbeat/agent-heartbeat-service.ts +155 -0
- package/src/bundler/app-bundler.ts +35 -14
- package/src/calls/call-bridge.ts +95 -0
- package/src/calls/call-constants.ts +48 -0
- package/src/calls/call-domain.ts +276 -0
- package/src/calls/call-orchestrator.ts +390 -0
- package/src/calls/call-recovery.ts +207 -0
- package/src/calls/call-state-machine.ts +68 -0
- package/src/calls/call-state.ts +64 -0
- package/src/calls/call-store.ts +416 -0
- package/src/calls/relay-server.ts +335 -0
- package/src/calls/speaker-identification.ts +213 -0
- package/src/calls/twilio-config.ts +34 -0
- package/src/calls/twilio-provider.ts +173 -0
- package/src/calls/twilio-routes.ts +250 -0
- package/src/calls/types.ts +37 -0
- package/src/calls/voice-provider.ts +14 -0
- package/src/cli/config-commands.ts +334 -0
- package/src/cli/core-commands.ts +776 -0
- package/src/cli/doordash.ts +256 -25
- package/src/cli/ipc-client.ts +82 -0
- package/src/cli/map.ts +246 -0
- package/src/cli/twitter.ts +575 -0
- package/src/cli.ts +7 -5
- package/src/commands/__tests__/cc-command-registry.test.ts +319 -0
- package/src/commands/cc-command-registry.ts +209 -0
- package/src/config/bundled-skills/contacts/SKILL.md +39 -0
- package/src/config/bundled-skills/contacts/TOOLS.json +122 -0
- package/src/config/bundled-skills/contacts/tools/contact-merge.ts +9 -0
- package/src/config/bundled-skills/contacts/tools/contact-search.ts +9 -0
- package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +9 -0
- package/src/config/bundled-skills/document/SKILL.md +18 -0
- package/src/config/bundled-skills/document/TOOLS.json +53 -0
- package/src/config/bundled-skills/document/tools/document-create.ts +9 -0
- package/src/config/bundled-skills/document/tools/document-update.ts +9 -0
- package/src/config/bundled-skills/doordash/SKILL.md +163 -0
- package/src/config/bundled-skills/followups/SKILL.md +32 -0
- package/src/config/bundled-skills/followups/TOOLS.json +100 -0
- package/src/config/bundled-skills/followups/tools/followup-create.ts +9 -0
- package/src/config/bundled-skills/followups/tools/followup-list.ts +9 -0
- package/src/config/bundled-skills/followups/tools/followup-resolve.ts +9 -0
- package/src/config/bundled-skills/image-studio/TOOLS.json +2 -2
- package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +2 -24
- package/src/config/bundled-skills/messaging/tools/messaging-analyze-style.ts +2 -1
- package/src/config/bundled-skills/playbooks/SKILL.md +31 -0
- package/src/config/bundled-skills/playbooks/TOOLS.json +126 -0
- package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +9 -0
- package/src/config/bundled-skills/playbooks/tools/playbook-delete.ts +9 -0
- package/src/config/bundled-skills/playbooks/tools/playbook-list.ts +9 -0
- package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +9 -0
- package/src/config/bundled-skills/reminder/SKILL.md +20 -0
- package/src/config/bundled-skills/reminder/TOOLS.json +67 -0
- package/src/config/bundled-skills/reminder/tools/reminder-cancel.ts +9 -0
- package/src/config/bundled-skills/reminder/tools/reminder-create.ts +9 -0
- package/src/config/bundled-skills/reminder/tools/reminder-list.ts +9 -0
- package/src/config/bundled-skills/schedule/SKILL.md +74 -0
- package/src/config/bundled-skills/schedule/TOOLS.json +135 -0
- package/src/config/bundled-skills/schedule/tools/schedule-create.ts +9 -0
- package/src/config/bundled-skills/schedule/tools/schedule-delete.ts +9 -0
- package/src/config/bundled-skills/schedule/tools/schedule-list.ts +9 -0
- package/src/config/bundled-skills/schedule/tools/schedule-update.ts +9 -0
- package/src/config/bundled-skills/subagent/SKILL.md +25 -0
- package/src/config/bundled-skills/subagent/TOOLS.json +107 -0
- package/src/config/bundled-skills/subagent/tools/subagent-abort.ts +9 -0
- package/src/config/bundled-skills/subagent/tools/subagent-message.ts +9 -0
- package/src/config/bundled-skills/subagent/tools/subagent-read.ts +9 -0
- package/src/config/bundled-skills/subagent/tools/subagent-spawn.ts +9 -0
- package/src/config/bundled-skills/subagent/tools/subagent-status.ts +9 -0
- package/src/config/bundled-skills/tasks/SKILL.md +28 -0
- package/src/config/bundled-skills/tasks/TOOLS.json +256 -0
- package/src/config/bundled-skills/tasks/tools/task-delete.ts +9 -0
- package/src/config/bundled-skills/tasks/tools/task-list-add.ts +9 -0
- package/src/config/bundled-skills/tasks/tools/task-list-remove.ts +9 -0
- package/src/config/bundled-skills/tasks/tools/task-list-show.ts +9 -0
- package/src/config/bundled-skills/tasks/tools/task-list-update.ts +9 -0
- package/src/config/bundled-skills/tasks/tools/task-list.ts +9 -0
- package/src/config/bundled-skills/tasks/tools/task-run.ts +9 -0
- package/src/config/bundled-skills/tasks/tools/task-save.ts +9 -0
- package/src/config/bundled-skills/twitter/SKILL.md +134 -0
- package/src/config/bundled-skills/watcher/SKILL.md +27 -0
- package/src/config/bundled-skills/watcher/TOOLS.json +147 -0
- package/src/config/bundled-skills/watcher/tools/watcher-create.ts +9 -0
- package/src/config/bundled-skills/watcher/tools/watcher-delete.ts +9 -0
- package/src/config/bundled-skills/watcher/tools/watcher-digest.ts +9 -0
- package/src/config/bundled-skills/watcher/tools/watcher-list.ts +9 -0
- package/src/config/bundled-skills/watcher/tools/watcher-update.ts +9 -0
- package/src/config/defaults.ts +44 -0
- package/src/config/loader.ts +4 -1
- package/src/config/schema.ts +218 -1
- package/src/config/system-prompt.ts +100 -6
- package/src/config/templates/IDENTITY.md +7 -0
- package/src/config/types.ts +5 -0
- package/src/contacts/contact-store.ts +4 -4
- package/src/daemon/assistant-attachments.ts +10 -0
- package/src/daemon/classifier.ts +3 -1
- package/src/daemon/computer-use-session.ts +3 -1
- package/src/daemon/date-context.ts +136 -0
- package/src/daemon/handlers/apps.ts +16 -1
- package/src/daemon/handlers/browser.ts +54 -0
- package/src/daemon/handlers/computer-use.ts +7 -1
- package/src/daemon/handlers/config.ts +192 -4
- package/src/daemon/handlers/diagnostics.ts +5 -1
- package/src/daemon/handlers/documents.ts +18 -29
- package/src/daemon/handlers/home-base.ts +5 -1
- package/src/daemon/handlers/index.ts +40 -271
- package/src/daemon/handlers/misc.ts +9 -1
- package/src/daemon/handlers/publish.ts +6 -1
- package/src/daemon/handlers/sessions.ts +65 -12
- package/src/daemon/handlers/shared.ts +36 -1
- package/src/daemon/handlers/signing.ts +37 -0
- package/src/daemon/handlers/skills.ts +20 -6
- package/src/daemon/handlers/subagents.ts +8 -3
- package/src/daemon/handlers/twitter-auth.ts +169 -0
- package/src/daemon/handlers/work-items.ts +495 -39
- package/src/daemon/ipc-contract-inventory.json +40 -4
- package/src/daemon/ipc-contract.ts +185 -37
- package/src/daemon/ipc-protocol.ts +7 -2
- package/src/daemon/lifecycle.ts +48 -5
- package/src/daemon/main.ts +10 -4
- package/src/daemon/ride-shotgun-handler.ts +74 -10
- package/src/daemon/server.ts +144 -29
- package/src/daemon/session-agent-loop.ts +887 -0
- package/src/daemon/session-attachments.ts +28 -5
- package/src/daemon/session-error.ts +24 -3
- package/src/daemon/session-lifecycle.ts +147 -0
- package/src/daemon/session-media-retry.ts +147 -0
- package/src/daemon/session-messaging.ts +145 -0
- package/src/daemon/session-notifiers.ts +164 -0
- package/src/daemon/session-process.ts +2 -2
- package/src/daemon/session-queue-manager.ts +1 -0
- package/src/daemon/session-runtime-assembly.ts +52 -0
- package/src/daemon/session-skill-tools.ts +124 -5
- package/src/daemon/session-slash.ts +3 -0
- package/src/daemon/session-surfaces.ts +77 -2
- package/src/daemon/session-tool-setup.ts +222 -2
- package/src/daemon/session-usage.ts +0 -2
- package/src/daemon/session.ts +114 -1365
- package/src/daemon/video-thumbnail.ts +60 -0
- package/src/doordash/client.ts +121 -27
- package/src/doordash/queries.ts +1 -2
- package/src/export/formatter.ts +3 -1
- package/src/followups/followup-store.ts +4 -2
- package/src/followups/types.ts +6 -0
- package/src/hooks/templates.ts +1 -1
- package/src/index.ts +32 -1151
- package/src/media/gemini-image-service.ts +1 -1
- package/src/memory/attachments-store.ts +28 -83
- package/src/memory/channel-delivery-store.ts +7 -21
- package/src/memory/clarification-resolver.ts +6 -5
- package/src/memory/contradiction-checker.ts +3 -2
- package/src/memory/conversation-key-store.ts +10 -29
- package/src/memory/conversation-store.ts +2 -1
- package/src/memory/db.ts +362 -2
- package/src/memory/entity-extractor.ts +6 -3
- package/src/memory/items-extractor.ts +5 -4
- package/src/memory/jobs-store.ts +3 -2
- package/src/memory/llm-usage-store.ts +1 -2
- package/src/memory/runs-store.ts +1 -2
- package/src/memory/schema.ts +65 -2
- package/src/messaging/style-analyzer.ts +3 -2
- package/src/messaging/thread-summarizer.ts +8 -12
- package/src/messaging/triage-engine.ts +4 -2
- package/src/providers/openrouter/client.ts +20 -0
- package/src/providers/registry.ts +8 -0
- package/src/runtime/http-server.ts +277 -25
- package/src/runtime/http-types.ts +0 -2
- package/src/runtime/routes/attachment-routes.ts +5 -6
- package/src/runtime/routes/call-routes.ts +140 -0
- package/src/runtime/routes/channel-routes.ts +12 -19
- package/src/runtime/routes/conversation-routes.ts +5 -9
- package/src/runtime/routes/run-routes.ts +4 -8
- package/src/runtime/run-orchestrator.ts +39 -6
- package/src/schedule/recurrence-engine.ts +138 -0
- package/src/schedule/recurrence-types.ts +67 -0
- package/src/schedule/schedule-store.ts +102 -57
- package/src/schedule/scheduler.ts +9 -6
- package/src/security/oauth2.ts +29 -4
- package/src/security/secret-allowlist.ts +46 -0
- package/src/skills/clawhub.ts +1 -1
- package/src/subagent/manager.ts +40 -8
- package/src/swarm/backend-claude-code.ts +64 -9
- package/src/swarm/worker-prompts.ts +2 -1
- package/src/tasks/SPEC.md +34 -28
- package/src/tasks/ephemeral-permissions.ts +16 -7
- package/src/tasks/task-compiler.ts +5 -4
- package/src/tasks/task-runner.ts +10 -5
- package/src/tasks/task-scheduler.ts +1 -1
- package/src/tasks/tool-sanitizer.ts +36 -0
- package/src/tools/assets/search.ts +4 -4
- package/src/tools/browser/api-map.ts +220 -0
- package/src/tools/browser/auto-navigate.ts +270 -0
- package/src/tools/browser/browser-execution.ts +2 -1
- package/src/tools/browser/browser-manager.ts +2 -2
- package/src/tools/browser/network-recorder.ts +5 -4
- package/src/tools/browser/x-auto-navigate.ts +207 -0
- package/src/tools/calls/call-end.ts +67 -0
- package/src/tools/calls/call-start.ts +73 -0
- package/src/tools/calls/call-status.ts +81 -0
- package/src/tools/claude-code/claude-code.ts +77 -11
- package/src/tools/contacts/contact-merge.ts +46 -78
- package/src/tools/contacts/contact-search.ts +35 -79
- package/src/tools/contacts/contact-upsert.ts +35 -108
- package/src/tools/credentials/vault.ts +21 -5
- package/src/tools/document/document-tool.ts +71 -144
- package/src/tools/executor.ts +129 -10
- package/src/tools/followups/followup_create.ts +46 -88
- package/src/tools/followups/followup_list.ts +34 -74
- package/src/tools/followups/followup_resolve.ts +31 -66
- package/src/tools/host-terminal/cli-discover.ts +2 -1
- package/src/tools/host-terminal/host-shell.ts +10 -0
- package/src/tools/memory/handlers.ts +5 -4
- package/src/tools/network/__tests__/web-search.test.ts +427 -0
- package/src/tools/network/script-proxy/__tests__/logging.test.ts +248 -0
- package/src/tools/network/script-proxy/__tests__/policy.test.ts +234 -0
- package/src/tools/network/script-proxy/__tests__/router.test.ts +76 -0
- package/src/tools/network/web-fetch.ts +18 -6
- package/src/tools/playbooks/index.ts +4 -5
- package/src/tools/playbooks/playbook-create.ts +3 -47
- package/src/tools/playbooks/playbook-delete.ts +1 -25
- package/src/tools/playbooks/playbook-list.ts +1 -28
- package/src/tools/playbooks/playbook-update.ts +3 -51
- package/src/tools/registry.ts +2 -4
- package/src/tools/reminder/reminder.ts +5 -78
- package/src/tools/schedule/create.ts +69 -74
- package/src/tools/schedule/delete.ts +21 -47
- package/src/tools/schedule/list.ts +55 -74
- package/src/tools/schedule/update.ts +77 -84
- package/src/tools/subagent/abort.ts +29 -58
- package/src/tools/subagent/message.ts +30 -63
- package/src/tools/subagent/read.ts +53 -84
- package/src/tools/subagent/spawn.ts +43 -82
- package/src/tools/subagent/status.ts +42 -71
- package/src/tools/swarm/delegate.ts +2 -1
- package/src/tools/tasks/index.ts +8 -6
- package/src/tools/tasks/task-delete.ts +69 -56
- package/src/tools/tasks/task-list.ts +31 -52
- package/src/tools/tasks/task-run.ts +74 -102
- package/src/tools/tasks/task-save.ts +33 -65
- package/src/tools/tasks/work-item-enqueue.ts +192 -134
- package/src/tools/tasks/work-item-list.ts +33 -78
- package/src/tools/tasks/work-item-remove.ts +60 -0
- package/src/tools/tasks/work-item-update.ts +114 -0
- package/src/tools/terminal/backends/native.ts +3 -1
- package/src/tools/tool-manifest.ts +20 -74
- package/src/tools/types.ts +6 -0
- package/src/tools/ui-surface/definitions.ts +6 -1
- package/src/tools/watch/screen-watch.ts +3 -1
- package/src/tools/watcher/create.ts +52 -98
- package/src/tools/watcher/delete.ts +20 -46
- package/src/tools/watcher/digest.ts +36 -70
- package/src/tools/watcher/list.ts +49 -79
- package/src/tools/watcher/update.ts +45 -91
- package/src/twitter/client.ts +690 -0
- package/src/twitter/session.ts +91 -0
- package/src/usage/types.ts +0 -1
- package/src/util/truncate.ts +6 -0
- package/src/watcher/providers/slack.ts +2 -1
- package/src/watcher/watcher-store.ts +3 -2
- package/src/work-items/work-item-store.ts +236 -2
- package/src/workspace/commit-message-enrichment-service.ts +284 -0
- package/src/workspace/commit-message-provider.ts +95 -0
- package/src/workspace/git-service.ts +272 -52
- package/src/workspace/heartbeat-service.ts +70 -13
- package/src/workspace/provider-commit-message-generator.ts +242 -0
- package/src/workspace/turn-commit.ts +100 -51
- package/src/tools/contacts/index.ts +0 -4
- package/src/tools/document/index.ts +0 -5
- package/src/tools/followups/index.ts +0 -3
- package/src/tools/subagent/index.ts +0 -5
- /package/src/__tests__/{memory-context-benchmark.test.ts → memory-context-benchmark.benchmark.test.ts} +0 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { getLogger } from '../util/logger.js';
|
|
3
|
+
import { getSecureKey } from '../security/secure-keys.js';
|
|
4
|
+
import type { VoiceProvider, InitiateCallOptions } from './voice-provider.js';
|
|
5
|
+
|
|
6
|
+
const log = getLogger('twilio-provider');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Twilio ConversationRelay voice provider.
|
|
10
|
+
*
|
|
11
|
+
* Uses the Twilio REST API directly via fetch() — no twilio npm package.
|
|
12
|
+
* Credentials are resolved lazily from the secure key store on each call.
|
|
13
|
+
*/
|
|
14
|
+
export class TwilioConversationRelayProvider implements VoiceProvider {
|
|
15
|
+
readonly name = 'twilio';
|
|
16
|
+
|
|
17
|
+
// ── Credential helpers ──────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
private getCredentials(): { accountSid: string; authToken: string } {
|
|
20
|
+
const accountSid = getSecureKey('twilio_account_sid');
|
|
21
|
+
const authToken = getSecureKey('twilio_auth_token');
|
|
22
|
+
if (!accountSid || !authToken) {
|
|
23
|
+
throw new Error(
|
|
24
|
+
'Twilio credentials not configured. Set twilio_account_sid and twilio_auth_token via the credential_store tool.',
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
return { accountSid, authToken };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
private authHeader(accountSid: string, authToken: string): string {
|
|
31
|
+
return 'Basic ' + Buffer.from(`${accountSid}:${authToken}`).toString('base64');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
private baseUrl(accountSid: string): string {
|
|
35
|
+
return `https://api.twilio.com/2010-04-01/Accounts/${accountSid}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── VoiceProvider interface ─────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
async initiateCall(opts: InitiateCallOptions): Promise<{ callSid: string }> {
|
|
41
|
+
const { accountSid, authToken } = this.getCredentials();
|
|
42
|
+
|
|
43
|
+
const body = new URLSearchParams({
|
|
44
|
+
From: opts.from,
|
|
45
|
+
To: opts.to,
|
|
46
|
+
Url: opts.webhookUrl,
|
|
47
|
+
StatusCallback: opts.statusCallbackUrl,
|
|
48
|
+
StatusCallbackEvent: 'initiated ringing answered completed',
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const reservedKeys = new Set(['From', 'To', 'Url', 'StatusCallback', 'StatusCallbackEvent']);
|
|
52
|
+
if (opts.customParams) {
|
|
53
|
+
for (const [key, value] of Object.entries(opts.customParams)) {
|
|
54
|
+
if (reservedKeys.has(key)) {
|
|
55
|
+
log.warn({ key }, 'Ignoring reserved Twilio parameter in customParams');
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
body.set(key, value);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
log.info({ from: opts.from, to: opts.to }, 'Initiating Twilio call');
|
|
63
|
+
|
|
64
|
+
const res = await fetch(`${this.baseUrl(accountSid)}/Calls.json`, {
|
|
65
|
+
method: 'POST',
|
|
66
|
+
headers: {
|
|
67
|
+
Authorization: this.authHeader(accountSid, authToken),
|
|
68
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
69
|
+
},
|
|
70
|
+
body: body.toString(),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
if (!res.ok) {
|
|
74
|
+
const text = await res.text();
|
|
75
|
+
log.error({ status: res.status, body: text }, 'Twilio initiateCall failed');
|
|
76
|
+
throw new Error(`Twilio API error ${res.status}: ${text}`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const data = (await res.json()) as { sid: string };
|
|
80
|
+
log.info({ callSid: data.sid }, 'Twilio call initiated');
|
|
81
|
+
return { callSid: data.sid };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async endCall(callSid: string): Promise<void> {
|
|
85
|
+
const { accountSid, authToken } = this.getCredentials();
|
|
86
|
+
|
|
87
|
+
log.info({ callSid }, 'Ending Twilio call');
|
|
88
|
+
|
|
89
|
+
const body = new URLSearchParams({ Status: 'completed' });
|
|
90
|
+
|
|
91
|
+
const res = await fetch(`${this.baseUrl(accountSid)}/Calls/${callSid}.json`, {
|
|
92
|
+
method: 'POST',
|
|
93
|
+
headers: {
|
|
94
|
+
Authorization: this.authHeader(accountSid, authToken),
|
|
95
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
96
|
+
},
|
|
97
|
+
body: body.toString(),
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
if (!res.ok) {
|
|
101
|
+
const text = await res.text();
|
|
102
|
+
log.error({ status: res.status, body: text, callSid }, 'Twilio endCall failed');
|
|
103
|
+
throw new Error(`Twilio API error ${res.status}: ${text}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
log.info({ callSid }, 'Twilio call ended');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async getCallStatus(callSid: string): Promise<string> {
|
|
110
|
+
const { accountSid, authToken } = this.getCredentials();
|
|
111
|
+
|
|
112
|
+
const res = await fetch(`${this.baseUrl(accountSid)}/Calls/${callSid}.json`, {
|
|
113
|
+
method: 'GET',
|
|
114
|
+
headers: {
|
|
115
|
+
Authorization: this.authHeader(accountSid, authToken),
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
if (!res.ok) {
|
|
120
|
+
const text = await res.text();
|
|
121
|
+
log.error({ status: res.status, body: text, callSid }, 'Twilio getCallStatus failed');
|
|
122
|
+
throw new Error(`Twilio API error ${res.status}: ${text}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const data = (await res.json()) as { status: string };
|
|
126
|
+
return data.status;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── Webhook signature verification ──────────────────────────────────
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Returns the Twilio auth token from the secure key store, or null if
|
|
133
|
+
* not configured. Exposed as a static method so callers (e.g. the
|
|
134
|
+
* HTTP server webhook middleware) can check availability independently.
|
|
135
|
+
*/
|
|
136
|
+
static getAuthToken(): string | null {
|
|
137
|
+
return getSecureKey('twilio_auth_token') ?? null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Validates an X-Twilio-Signature header using HMAC-SHA1.
|
|
142
|
+
*
|
|
143
|
+
* Algorithm (from Twilio docs):
|
|
144
|
+
* 1. Take the full URL of the request.
|
|
145
|
+
* 2. Sort the POST parameters alphabetically by key.
|
|
146
|
+
* 3. Concatenate the URL with each key-value pair (key + value, no delimiters).
|
|
147
|
+
* 4. HMAC-SHA1 the result using the auth token as the key.
|
|
148
|
+
* 5. Base64-encode the hash.
|
|
149
|
+
* 6. Compare to the X-Twilio-Signature header value.
|
|
150
|
+
*/
|
|
151
|
+
static verifyWebhookSignature(
|
|
152
|
+
url: string,
|
|
153
|
+
params: Record<string, string>,
|
|
154
|
+
signature: string,
|
|
155
|
+
authToken: string,
|
|
156
|
+
): boolean {
|
|
157
|
+
const sortedKeys = Object.keys(params).sort();
|
|
158
|
+
let data = url;
|
|
159
|
+
for (const key of sortedKeys) {
|
|
160
|
+
data += key + params[key];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const computed = createHmac('sha1', authToken)
|
|
164
|
+
.update(data)
|
|
165
|
+
.digest('base64');
|
|
166
|
+
|
|
167
|
+
// Constant-time comparison to prevent timing attacks
|
|
168
|
+
const a = Buffer.from(computed);
|
|
169
|
+
const b = Buffer.from(signature);
|
|
170
|
+
if (a.length !== b.length) return false;
|
|
171
|
+
return timingSafeEqual(a, b);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP route handlers for Twilio voice webhooks.
|
|
3
|
+
*
|
|
4
|
+
* - handleVoiceWebhook: initial voice webhook; returns TwiML to connect ConversationRelay
|
|
5
|
+
* - handleStatusCallback: call status updates (ringing, in-progress, completed, etc.)
|
|
6
|
+
* - handleConnectAction: called when the ConversationRelay connection ends
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { getLogger } from '../util/logger.js';
|
|
10
|
+
import {
|
|
11
|
+
getCallSession,
|
|
12
|
+
getCallSessionByCallSid,
|
|
13
|
+
updateCallSession,
|
|
14
|
+
recordCallEvent,
|
|
15
|
+
expirePendingQuestions,
|
|
16
|
+
buildCallbackDedupeKey,
|
|
17
|
+
claimCallback,
|
|
18
|
+
releaseCallbackClaim,
|
|
19
|
+
finalizeCallbackClaim,
|
|
20
|
+
} from './call-store.js';
|
|
21
|
+
import type { CallStatus } from './types.js';
|
|
22
|
+
import { logDeadLetterEvent } from './call-recovery.js';
|
|
23
|
+
import { isTerminalState } from './call-state-machine.js';
|
|
24
|
+
import { getTwilioConfig } from './twilio-config.js';
|
|
25
|
+
|
|
26
|
+
const log = getLogger('twilio-routes');
|
|
27
|
+
|
|
28
|
+
// ── Helpers ──────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
function escapeXml(str: string): string {
|
|
31
|
+
return str
|
|
32
|
+
.replace(/&/g, '&')
|
|
33
|
+
.replace(/</g, '<')
|
|
34
|
+
.replace(/>/g, '>')
|
|
35
|
+
.replace(/"/g, '"')
|
|
36
|
+
.replace(/'/g, ''');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function generateTwiML(callSessionId: string, relayUrl: string, welcomeGreeting: string): string {
|
|
40
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
41
|
+
<Response>
|
|
42
|
+
<Connect>
|
|
43
|
+
<ConversationRelay
|
|
44
|
+
url="${escapeXml(relayUrl)}?callSessionId=${escapeXml(callSessionId)}"
|
|
45
|
+
welcomeGreeting="${escapeXml(welcomeGreeting)}"
|
|
46
|
+
voice="Google.en-US-Journey-O"
|
|
47
|
+
language="en-US"
|
|
48
|
+
transcriptionProvider="Deepgram"
|
|
49
|
+
ttsProvider="Google"
|
|
50
|
+
interruptible="true"
|
|
51
|
+
dtmfDetection="true"
|
|
52
|
+
/>
|
|
53
|
+
</Connect>
|
|
54
|
+
</Response>`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Resolve the WebSocket relay URL from Twilio config.
|
|
59
|
+
*
|
|
60
|
+
* Treats wssBaseUrl as present only when it is non-empty after trimming.
|
|
61
|
+
* Falls back to webhookBaseUrl, normalizing the scheme from http(s) to ws(s)
|
|
62
|
+
* and stripping any trailing slash.
|
|
63
|
+
*/
|
|
64
|
+
export function resolveRelayUrl(wssBaseUrl: string, webhookBaseUrl: string): string {
|
|
65
|
+
const base = wssBaseUrl.trim() || webhookBaseUrl;
|
|
66
|
+
const normalized = base.replace(/\/$/, '').replace(/^http(s?)/, 'ws$1');
|
|
67
|
+
return `${normalized}/v1/calls/relay`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Map Twilio call status strings to our internal CallStatus.
|
|
72
|
+
*/
|
|
73
|
+
function mapTwilioStatus(twilioStatus: string): CallStatus | null {
|
|
74
|
+
switch (twilioStatus) {
|
|
75
|
+
case 'queued':
|
|
76
|
+
case 'ringing':
|
|
77
|
+
return 'ringing';
|
|
78
|
+
case 'in-progress':
|
|
79
|
+
return 'in_progress';
|
|
80
|
+
case 'completed':
|
|
81
|
+
return 'completed';
|
|
82
|
+
case 'failed':
|
|
83
|
+
case 'busy':
|
|
84
|
+
case 'no-answer':
|
|
85
|
+
case 'canceled':
|
|
86
|
+
return 'failed';
|
|
87
|
+
default:
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── Route handlers ───────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Receives the initial voice webhook when Twilio connects the call.
|
|
96
|
+
* Returns TwiML XML that tells Twilio to open a ConversationRelay WebSocket.
|
|
97
|
+
*/
|
|
98
|
+
export async function handleVoiceWebhook(req: Request): Promise<Response> {
|
|
99
|
+
const url = new URL(req.url);
|
|
100
|
+
const callSessionId = url.searchParams.get('callSessionId');
|
|
101
|
+
|
|
102
|
+
if (!callSessionId) {
|
|
103
|
+
log.warn('Voice webhook called without callSessionId');
|
|
104
|
+
return new Response('Missing callSessionId', { status: 400 });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const session = getCallSession(callSessionId);
|
|
108
|
+
if (!session) {
|
|
109
|
+
log.warn({ callSessionId }, 'Voice webhook: call session not found');
|
|
110
|
+
return new Response('Call session not found', { status: 404 });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (isTerminalState(session.status)) {
|
|
114
|
+
log.warn({ callSessionId, status: session.status }, 'Voice webhook: call session is in terminal state');
|
|
115
|
+
return new Response('Call session is no longer active', { status: 410 });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Parse the Twilio POST body to capture CallSid immediately, so status
|
|
119
|
+
// callbacks (keyed by CallSid) can locate this session even if the
|
|
120
|
+
// WebSocket relay hasn't been set up yet.
|
|
121
|
+
const formBody = new URLSearchParams(await req.text());
|
|
122
|
+
const callSid = formBody.get('CallSid');
|
|
123
|
+
if (callSid && callSid !== session.providerCallSid) {
|
|
124
|
+
updateCallSession(callSessionId, { providerCallSid: callSid });
|
|
125
|
+
log.info({ callSessionId, callSid }, 'Stored CallSid from voice webhook');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const config = getTwilioConfig();
|
|
129
|
+
const relayUrl = resolveRelayUrl(config.wssBaseUrl, config.webhookBaseUrl);
|
|
130
|
+
const welcomeGreeting = process.env.CALL_WELCOME_GREETING ?? 'Hello, how can I help you today?';
|
|
131
|
+
|
|
132
|
+
const twiml = generateTwiML(callSessionId, relayUrl, welcomeGreeting);
|
|
133
|
+
|
|
134
|
+
log.info({ callSessionId }, 'Returning ConversationRelay TwiML');
|
|
135
|
+
|
|
136
|
+
return new Response(twiml, {
|
|
137
|
+
status: 200,
|
|
138
|
+
headers: { 'Content-Type': 'text/xml' },
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Receives call status updates from Twilio (POST with form-urlencoded body).
|
|
144
|
+
* Updates the call session status and records events.
|
|
145
|
+
*/
|
|
146
|
+
export async function handleStatusCallback(req: Request): Promise<Response> {
|
|
147
|
+
const formBody = new URLSearchParams(await req.text());
|
|
148
|
+
const callSid = formBody.get('CallSid');
|
|
149
|
+
const callStatus = formBody.get('CallStatus');
|
|
150
|
+
|
|
151
|
+
if (!callSid || !callStatus) {
|
|
152
|
+
const rawPayload = Object.fromEntries(formBody.entries());
|
|
153
|
+
logDeadLetterEvent('Status callback missing CallSid or CallStatus', rawPayload, log);
|
|
154
|
+
return new Response(null, { status: 200 });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
log.info({ callSid, callStatus }, 'Twilio status callback received');
|
|
158
|
+
|
|
159
|
+
const session = getCallSessionByCallSid(callSid);
|
|
160
|
+
if (!session) {
|
|
161
|
+
log.warn({ callSid, callStatus }, 'Status callback: no call session found for CallSid');
|
|
162
|
+
return new Response(null, { status: 200 });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const mappedStatus = mapTwilioStatus(callStatus);
|
|
166
|
+
if (!mappedStatus) {
|
|
167
|
+
const rawPayload = Object.fromEntries(formBody.entries());
|
|
168
|
+
logDeadLetterEvent(`Unknown Twilio status: ${callStatus}`, rawPayload, log);
|
|
169
|
+
return new Response(null, { status: 200 });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ── Atomic idempotency claim ────────────────────────────────────
|
|
173
|
+
const timestamp = formBody.get('Timestamp');
|
|
174
|
+
const sequenceNumber = formBody.get('SequenceNumber');
|
|
175
|
+
const dedupeKey = buildCallbackDedupeKey(callSid, callStatus, timestamp, sequenceNumber);
|
|
176
|
+
|
|
177
|
+
const claimId = claimCallback(dedupeKey, session.id);
|
|
178
|
+
if (!claimId) {
|
|
179
|
+
log.info({ callSid, callStatus, dedupeKey }, 'Duplicate status callback — skipping');
|
|
180
|
+
return new Response(null, { status: 200 });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
// Build updates
|
|
185
|
+
const updates: Parameters<typeof updateCallSession>[1] = {
|
|
186
|
+
status: mappedStatus,
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
if (mappedStatus === 'in_progress' && !session.startedAt) {
|
|
190
|
+
updates.startedAt = Date.now();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const isTerminal = mappedStatus === 'completed' || mappedStatus === 'failed';
|
|
194
|
+
if (isTerminal) {
|
|
195
|
+
updates.endedAt = Date.now();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
updateCallSession(session.id, updates);
|
|
199
|
+
|
|
200
|
+
// Record event
|
|
201
|
+
const eventType = isTerminal
|
|
202
|
+
? (mappedStatus === 'completed' ? 'call_ended' : 'call_failed')
|
|
203
|
+
: (mappedStatus === 'in_progress' ? 'call_connected' : 'call_started');
|
|
204
|
+
|
|
205
|
+
recordCallEvent(session.id, eventType, {
|
|
206
|
+
twilioStatus: callStatus,
|
|
207
|
+
callSid,
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// Expire pending questions on terminal status
|
|
211
|
+
if (isTerminal) {
|
|
212
|
+
expirePendingQuestions(session.id);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Mark the claim as permanently processed so it never expires.
|
|
216
|
+
// If finalization returns false, another handler reclaimed this key
|
|
217
|
+
// after our claim expired — our business writes already landed but
|
|
218
|
+
// the dedupe row now belongs to the other handler, risking duplicate
|
|
219
|
+
// processing on later retries.
|
|
220
|
+
const finalized = finalizeCallbackClaim(dedupeKey, claimId);
|
|
221
|
+
if (!finalized) {
|
|
222
|
+
log.warn(
|
|
223
|
+
{ dedupeKey, claimId, callSid, callStatus },
|
|
224
|
+
'Lost claim during finalization — business writes committed but dedupe ownership was taken by another handler',
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
} catch (err) {
|
|
228
|
+
// Release claim so Twilio retries can reprocess
|
|
229
|
+
releaseCallbackClaim(dedupeKey, claimId);
|
|
230
|
+
throw err;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return new Response(null, { status: 200 });
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Called when the ConversationRelay connection ends.
|
|
238
|
+
* Returns empty TwiML to acknowledge.
|
|
239
|
+
*/
|
|
240
|
+
export async function handleConnectAction(_req: Request): Promise<Response> {
|
|
241
|
+
log.info('ConversationRelay connect-action callback received');
|
|
242
|
+
return new Response(
|
|
243
|
+
'<?xml version="1.0" encoding="UTF-8"?><Response/>',
|
|
244
|
+
{
|
|
245
|
+
status: 200,
|
|
246
|
+
headers: { 'Content-Type': 'text/xml' },
|
|
247
|
+
},
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export type CallStatus = 'initiated' | 'ringing' | 'in_progress' | 'waiting_on_user' | 'completed' | 'failed' | 'cancelled';
|
|
2
|
+
export type CallEventType = 'call_started' | 'call_connected' | 'caller_spoke' | 'assistant_spoke' | 'user_question_asked' | 'user_answered' | 'call_ended' | 'call_failed';
|
|
3
|
+
export type PendingQuestionStatus = 'pending' | 'answered' | 'expired' | 'cancelled';
|
|
4
|
+
|
|
5
|
+
export interface CallSession {
|
|
6
|
+
id: string;
|
|
7
|
+
conversationId: string;
|
|
8
|
+
provider: string;
|
|
9
|
+
providerCallSid: string | null;
|
|
10
|
+
fromNumber: string;
|
|
11
|
+
toNumber: string;
|
|
12
|
+
task: string | null;
|
|
13
|
+
status: CallStatus;
|
|
14
|
+
startedAt: number | null;
|
|
15
|
+
endedAt: number | null;
|
|
16
|
+
lastError: string | null;
|
|
17
|
+
createdAt: number;
|
|
18
|
+
updatedAt: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface CallEvent {
|
|
22
|
+
id: string;
|
|
23
|
+
callSessionId: string;
|
|
24
|
+
eventType: CallEventType;
|
|
25
|
+
payloadJson: string;
|
|
26
|
+
createdAt: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface CallPendingQuestion {
|
|
30
|
+
id: string;
|
|
31
|
+
callSessionId: string;
|
|
32
|
+
questionText: string;
|
|
33
|
+
status: PendingQuestionStatus;
|
|
34
|
+
askedAt: number;
|
|
35
|
+
answeredAt: number | null;
|
|
36
|
+
answerText: string | null;
|
|
37
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface InitiateCallOptions {
|
|
2
|
+
from: string;
|
|
3
|
+
to: string;
|
|
4
|
+
webhookUrl: string;
|
|
5
|
+
statusCallbackUrl: string;
|
|
6
|
+
customParams?: Record<string, string>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface VoiceProvider {
|
|
10
|
+
name: string;
|
|
11
|
+
initiateCall(opts: InitiateCallOptions): Promise<{ callSid: string }>;
|
|
12
|
+
endCall(callSid: string): Promise<void>;
|
|
13
|
+
getCallStatus(callSid: string): Promise<string>;
|
|
14
|
+
}
|