@vellumai/assistant 0.4.2 → 0.4.4

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.
Files changed (221) hide show
  1. package/.env.example +3 -0
  2. package/ARCHITECTURE.md +124 -10
  3. package/README.md +43 -35
  4. package/docs/trusted-contact-access.md +20 -0
  5. package/package.json +1 -1
  6. package/scripts/ipc/generate-swift.ts +1 -0
  7. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +58 -120
  8. package/src/__tests__/access-request-decision.test.ts +0 -1
  9. package/src/__tests__/actor-token-service.test.ts +1099 -0
  10. package/src/__tests__/agent-loop.test.ts +51 -0
  11. package/src/__tests__/approval-routes-http.test.ts +2 -0
  12. package/src/__tests__/assistant-events-sse-hardening.test.ts +7 -5
  13. package/src/__tests__/assistant-id-boundary-guard.test.ts +415 -0
  14. package/src/__tests__/call-controller.test.ts +49 -0
  15. package/src/__tests__/call-pointer-message-composer.test.ts +171 -0
  16. package/src/__tests__/call-pointer-messages.test.ts +93 -3
  17. package/src/__tests__/call-pointer-no-hardcoded-copy.guard.test.ts +42 -0
  18. package/src/__tests__/call-routes-http.test.ts +0 -25
  19. package/src/__tests__/callback-handoff-copy.test.ts +186 -0
  20. package/src/__tests__/channel-approval-routes.test.ts +133 -12
  21. package/src/__tests__/channel-guardian.test.ts +0 -86
  22. package/src/__tests__/channel-readiness-service.test.ts +10 -16
  23. package/src/__tests__/checker.test.ts +33 -12
  24. package/src/__tests__/config-schema.test.ts +6 -0
  25. package/src/__tests__/confirmation-request-guardian-bridge.test.ts +410 -0
  26. package/src/__tests__/conversation-routes-guardian-reply.test.ts +256 -0
  27. package/src/__tests__/conversation-routes.test.ts +12 -3
  28. package/src/__tests__/credential-security-invariants.test.ts +1 -1
  29. package/src/__tests__/daemon-server-session-init.test.ts +4 -0
  30. package/src/__tests__/deterministic-verification-control-plane.test.ts +0 -1
  31. package/src/__tests__/guardian-actions-endpoint.test.ts +39 -13
  32. package/src/__tests__/guardian-dispatch.test.ts +8 -0
  33. package/src/__tests__/guardian-outbound-http.test.ts +4 -5
  34. package/src/__tests__/guardian-question-mode.test.ts +200 -0
  35. package/src/__tests__/guardian-routing-invariants.test.ts +178 -0
  36. package/src/__tests__/guardian-routing-state.test.ts +525 -0
  37. package/src/__tests__/handle-user-message-secret-resume.test.ts +2 -0
  38. package/src/__tests__/handlers-telegram-config.test.ts +0 -83
  39. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +55 -0
  40. package/src/__tests__/headless-browser-navigate.test.ts +2 -0
  41. package/src/__tests__/inbound-invite-redemption.test.ts +0 -1
  42. package/src/__tests__/ingress-routes-http.test.ts +55 -0
  43. package/src/__tests__/ipc-snapshot.test.ts +18 -51
  44. package/src/__tests__/non-member-access-request.test.ts +159 -9
  45. package/src/__tests__/notification-decision-fallback.test.ts +129 -4
  46. package/src/__tests__/notification-decision-strategy.test.ts +106 -2
  47. package/src/__tests__/notification-guardian-path.test.ts +3 -0
  48. package/src/__tests__/recording-intent-handler.test.ts +1 -0
  49. package/src/__tests__/relay-server.test.ts +1475 -33
  50. package/src/__tests__/send-endpoint-busy.test.ts +5 -0
  51. package/src/__tests__/session-agent-loop.test.ts +1 -0
  52. package/src/__tests__/session-confirmation-signals.test.ts +523 -0
  53. package/src/__tests__/session-init.benchmark.test.ts +0 -2
  54. package/src/__tests__/session-runtime-assembly.test.ts +4 -1
  55. package/src/__tests__/session-surfaces-task-progress.test.ts +44 -1
  56. package/src/__tests__/session-tool-setup-app-refresh.test.ts +81 -2
  57. package/src/__tests__/session-tool-setup-memory-scope.test.ts +1 -1
  58. package/src/__tests__/session-tool-setup-side-effect-flag.test.ts +1 -1
  59. package/src/__tests__/tool-executor.test.ts +21 -2
  60. package/src/__tests__/tool-grant-request-escalation.test.ts +333 -27
  61. package/src/__tests__/trusted-contact-approval-notifier.test.ts +678 -0
  62. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +1064 -0
  63. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +11 -1
  64. package/src/__tests__/trusted-contact-multichannel.test.ts +0 -1
  65. package/src/__tests__/trusted-contact-verification.test.ts +0 -1
  66. package/src/__tests__/twilio-config.test.ts +2 -13
  67. package/src/__tests__/twilio-routes.test.ts +4 -3
  68. package/src/__tests__/update-bulletin.test.ts +0 -1
  69. package/src/agent/loop.ts +1 -1
  70. package/src/approvals/guardian-decision-primitive.ts +12 -3
  71. package/src/approvals/guardian-request-resolvers.ts +169 -11
  72. package/src/calls/call-constants.ts +29 -0
  73. package/src/calls/call-controller.ts +11 -3
  74. package/src/calls/call-domain.ts +33 -11
  75. package/src/calls/call-pointer-message-composer.ts +154 -0
  76. package/src/calls/call-pointer-messages.ts +106 -27
  77. package/src/calls/guardian-dispatch.ts +4 -2
  78. package/src/calls/relay-server.ts +921 -112
  79. package/src/calls/twilio-config.ts +4 -11
  80. package/src/calls/twilio-routes.ts +4 -6
  81. package/src/calls/types.ts +3 -1
  82. package/src/calls/voice-session-bridge.ts +4 -3
  83. package/src/cli/core-commands.ts +7 -4
  84. package/src/cli.ts +5 -4
  85. package/src/config/bundled-skills/agentmail/SKILL.md +4 -0
  86. package/src/config/bundled-skills/app-builder/SKILL.md +309 -10
  87. package/src/config/bundled-skills/app-builder/TOOLS.json +1 -1
  88. package/src/config/bundled-skills/email-setup/SKILL.md +1 -1
  89. package/src/config/bundled-skills/google-oauth-setup/SKILL.md +105 -81
  90. package/src/config/bundled-skills/messaging/SKILL.md +61 -12
  91. package/src/config/bundled-skills/messaging/TOOLS.json +58 -0
  92. package/src/config/bundled-skills/messaging/tools/gmail-sender-digest.ts +6 -1
  93. package/src/config/bundled-skills/messaging/tools/messaging-archive-by-sender.ts +35 -0
  94. package/src/config/bundled-skills/messaging/tools/messaging-sender-digest.ts +52 -0
  95. package/src/config/bundled-skills/phone-calls/SKILL.md +30 -39
  96. package/src/config/bundled-skills/twitter/SKILL.md +3 -3
  97. package/src/config/bundled-skills/vercel-token-setup/SKILL.md +215 -0
  98. package/src/config/calls-schema.ts +36 -0
  99. package/src/config/env.ts +22 -0
  100. package/src/config/feature-flag-registry.json +8 -8
  101. package/src/config/schema.ts +2 -2
  102. package/src/config/skills.ts +11 -0
  103. package/src/config/system-prompt.ts +11 -1
  104. package/src/config/templates/SOUL.md +2 -0
  105. package/src/config/vellum-skills/sms-setup/SKILL.md +71 -82
  106. package/src/config/vellum-skills/trusted-contacts/SKILL.md +8 -1
  107. package/src/config/vellum-skills/twilio-setup/SKILL.md +88 -73
  108. package/src/daemon/call-pointer-generators.ts +59 -0
  109. package/src/daemon/computer-use-session.ts +2 -5
  110. package/src/daemon/handlers/apps.ts +76 -20
  111. package/src/daemon/handlers/config-channels.ts +9 -61
  112. package/src/daemon/handlers/config-inbox.ts +11 -3
  113. package/src/daemon/handlers/config-ingress.ts +28 -3
  114. package/src/daemon/handlers/config-telegram.ts +12 -0
  115. package/src/daemon/handlers/config.ts +2 -6
  116. package/src/daemon/handlers/index.ts +2 -1
  117. package/src/daemon/handlers/pairing.ts +2 -0
  118. package/src/daemon/handlers/publish.ts +11 -46
  119. package/src/daemon/handlers/sessions.ts +59 -5
  120. package/src/daemon/handlers/shared.ts +17 -2
  121. package/src/daemon/ipc-contract/apps.ts +1 -0
  122. package/src/daemon/ipc-contract/inbox.ts +4 -0
  123. package/src/daemon/ipc-contract/integrations.ts +1 -97
  124. package/src/daemon/ipc-contract/messages.ts +47 -1
  125. package/src/daemon/ipc-contract/notifications.ts +11 -0
  126. package/src/daemon/ipc-contract-inventory.json +2 -4
  127. package/src/daemon/lifecycle.ts +17 -0
  128. package/src/daemon/server.ts +16 -2
  129. package/src/daemon/session-agent-loop-handlers.ts +20 -0
  130. package/src/daemon/session-agent-loop.ts +24 -12
  131. package/src/daemon/session-lifecycle.ts +1 -1
  132. package/src/daemon/session-process.ts +11 -1
  133. package/src/daemon/session-runtime-assembly.ts +6 -1
  134. package/src/daemon/session-surfaces.ts +32 -3
  135. package/src/daemon/session.ts +88 -1
  136. package/src/daemon/tool-side-effects.ts +22 -0
  137. package/src/home-base/prebuilt/brain-graph.html +1483 -0
  138. package/src/home-base/prebuilt/index.html +40 -0
  139. package/src/inbound/platform-callback-registration.ts +157 -0
  140. package/src/memory/canonical-guardian-store.ts +1 -1
  141. package/src/memory/conversation-crud.ts +2 -1
  142. package/src/memory/conversation-title-service.ts +16 -2
  143. package/src/memory/db-init.ts +8 -0
  144. package/src/memory/delivery-crud.ts +2 -1
  145. package/src/memory/guardian-action-store.ts +2 -1
  146. package/src/memory/guardian-approvals.ts +3 -2
  147. package/src/memory/ingress-invite-store.ts +12 -2
  148. package/src/memory/ingress-member-store.ts +4 -3
  149. package/src/memory/migrations/038-actor-token-records.ts +39 -0
  150. package/src/memory/migrations/124-voice-invite-display-metadata.ts +14 -0
  151. package/src/memory/migrations/index.ts +2 -0
  152. package/src/memory/schema.ts +26 -5
  153. package/src/messaging/provider-types.ts +24 -0
  154. package/src/messaging/provider.ts +7 -0
  155. package/src/messaging/providers/gmail/adapter.ts +127 -0
  156. package/src/messaging/providers/sms/adapter.ts +40 -37
  157. package/src/notifications/adapters/macos.ts +45 -2
  158. package/src/notifications/broadcaster.ts +16 -0
  159. package/src/notifications/copy-composer.ts +50 -2
  160. package/src/notifications/decision-engine.ts +22 -9
  161. package/src/notifications/destination-resolver.ts +16 -2
  162. package/src/notifications/emit-signal.ts +18 -9
  163. package/src/notifications/guardian-question-mode.ts +419 -0
  164. package/src/notifications/signal.ts +14 -3
  165. package/src/permissions/checker.ts +13 -1
  166. package/src/permissions/prompter.ts +14 -0
  167. package/src/providers/anthropic/client.ts +20 -0
  168. package/src/providers/provider-send-message.ts +15 -3
  169. package/src/runtime/access-request-helper.ts +82 -4
  170. package/src/runtime/actor-token-service.ts +234 -0
  171. package/src/runtime/actor-token-store.ts +236 -0
  172. package/src/runtime/actor-trust-resolver.ts +2 -2
  173. package/src/runtime/assistant-scope.ts +10 -0
  174. package/src/runtime/channel-approvals.ts +5 -3
  175. package/src/runtime/channel-readiness-service.ts +23 -64
  176. package/src/runtime/channel-readiness-types.ts +3 -4
  177. package/src/runtime/channel-retry-sweep.ts +4 -1
  178. package/src/runtime/confirmation-request-guardian-bridge.ts +197 -0
  179. package/src/runtime/guardian-action-followup-executor.ts +1 -1
  180. package/src/runtime/guardian-context-resolver.ts +82 -0
  181. package/src/runtime/guardian-outbound-actions.ts +5 -7
  182. package/src/runtime/guardian-reply-router.ts +67 -30
  183. package/src/runtime/guardian-vellum-migration.ts +57 -0
  184. package/src/runtime/http-server.ts +75 -31
  185. package/src/runtime/http-types.ts +13 -0
  186. package/src/runtime/ingress-service.ts +14 -0
  187. package/src/runtime/invite-redemption-service.ts +10 -1
  188. package/src/runtime/local-actor-identity.ts +76 -0
  189. package/src/runtime/middleware/actor-token.ts +271 -0
  190. package/src/runtime/middleware/twilio-validation.ts +2 -4
  191. package/src/runtime/routes/approval-routes.ts +82 -7
  192. package/src/runtime/routes/brain-graph-routes.ts +222 -0
  193. package/src/runtime/routes/call-routes.ts +2 -1
  194. package/src/runtime/routes/channel-readiness-routes.ts +71 -0
  195. package/src/runtime/routes/channel-route-shared.ts +3 -3
  196. package/src/runtime/routes/conversation-attention-routes.ts +2 -1
  197. package/src/runtime/routes/conversation-routes.ts +142 -53
  198. package/src/runtime/routes/events-routes.ts +22 -8
  199. package/src/runtime/routes/guardian-action-routes.ts +45 -3
  200. package/src/runtime/routes/guardian-approval-interception.ts +29 -0
  201. package/src/runtime/routes/guardian-bootstrap-routes.ts +145 -0
  202. package/src/runtime/routes/inbound-conversation.ts +4 -3
  203. package/src/runtime/routes/inbound-message-handler.ts +147 -5
  204. package/src/runtime/routes/ingress-routes.ts +2 -0
  205. package/src/runtime/routes/integration-routes.ts +7 -15
  206. package/src/runtime/routes/pairing-routes.ts +163 -0
  207. package/src/runtime/routes/twilio-routes.ts +934 -0
  208. package/src/runtime/tool-grant-request-helper.ts +3 -1
  209. package/src/security/oauth2.ts +27 -2
  210. package/src/security/token-manager.ts +46 -10
  211. package/src/tools/browser/browser-execution.ts +4 -3
  212. package/src/tools/browser/browser-handoff.ts +10 -18
  213. package/src/tools/browser/browser-manager.ts +80 -25
  214. package/src/tools/browser/browser-screencast.ts +35 -119
  215. package/src/tools/calls/call-start.ts +2 -1
  216. package/src/tools/permission-checker.ts +15 -4
  217. package/src/tools/terminal/parser.ts +12 -0
  218. package/src/tools/tool-approval-handler.ts +244 -19
  219. package/src/workspace/git-service.ts +19 -0
  220. package/src/__tests__/handlers-twilio-config.test.ts +0 -1928
  221. package/src/daemon/handlers/config-twilio.ts +0 -1082
@@ -1,1082 +0,0 @@
1
- import * as net from 'node:net';
2
-
3
- import {
4
- deleteTollFreeVerification,
5
- fetchMessageStatus,
6
- getPhoneNumberSid,
7
- getTollFreeVerificationBySid,
8
- getTollFreeVerificationStatus,
9
- hasTwilioCredentials,
10
- listIncomingPhoneNumbers,
11
- provisionPhoneNumber,
12
- releasePhoneNumber,
13
- searchAvailableNumbers,
14
- submitTollFreeVerification,
15
- type TollFreeVerificationSubmitParams,
16
- updateTollFreeVerification,
17
- } from '../../calls/twilio-rest.js';
18
- import { getGatewayInternalBaseUrl } from '../../config/env.js';
19
- import { loadRawConfig, saveRawConfig } from '../../config/loader.js';
20
- import type { IngressConfig } from '../../inbound/public-ingress-urls.js';
21
- import { deleteSecureKey,getSecureKey, setSecureKey } from '../../security/secure-keys.js';
22
- import { deleteCredentialMetadata,upsertCredentialMetadata } from '../../tools/credentials/metadata-store.js';
23
- import { readHttpToken } from '../../util/platform.js';
24
- import type { TwilioConfigRequest } from '../ipc-protocol.js';
25
- import { getReadinessService } from './config-channels.js';
26
- import { syncTwilioWebhooks } from './config-ingress.js';
27
- import { CONFIG_RELOAD_DEBOUNCE_MS, defineHandlers, type HandlerContext,log } from './shared.js';
28
-
29
- /** In-memory store for the last SMS send test result. Shared between sms_send_test and sms_doctor. */
30
- let _lastTestResult: {
31
- messageSid: string;
32
- to: string;
33
- initialStatus: string;
34
- finalStatus: string;
35
- errorCode?: string;
36
- errorMessage?: string;
37
- timestamp: number;
38
- } | undefined;
39
-
40
- /** Map a Twilio error code to a human-readable remediation suggestion. */
41
- function mapTwilioErrorRemediation(errorCode: string | undefined): string | undefined {
42
- if (!errorCode) return undefined;
43
- const map: Record<string, string> = {
44
- '30003': 'Unreachable destination. The handset may be off or out of service.',
45
- '30004': 'Message blocked by carrier or recipient.',
46
- '30005': 'Unknown destination phone number. Verify the number is valid.',
47
- '30006': 'Landline or unreachable carrier. SMS cannot be delivered to this number.',
48
- '30007': 'Message flagged as spam by carrier. Adjust content or register for A2P.',
49
- '30008': 'Unknown error from the carrier network.',
50
- '21610': 'Recipient has opted out (STOP). Cannot send until they opt back in.',
51
- };
52
- return map[errorCode];
53
- }
54
-
55
- const TWILIO_USE_CASE_ALIASES: Record<string, string> = {
56
- ACCOUNT_NOTIFICATION: 'ACCOUNT_NOTIFICATIONS',
57
- DELIVERY_NOTIFICATION: 'DELIVERY_NOTIFICATIONS',
58
- FRAUD_ALERT: 'FRAUD_ALERT_MESSAGING',
59
- POLLING_AND_VOTING: 'POLLING_AND_VOTING_NON_POLITICAL',
60
- };
61
-
62
- const TWILIO_VALID_USE_CASE_CATEGORIES = [
63
- 'TWO_FACTOR_AUTHENTICATION',
64
- 'ACCOUNT_NOTIFICATIONS',
65
- 'CUSTOMER_CARE',
66
- 'CHARITY_NONPROFIT',
67
- 'DELIVERY_NOTIFICATIONS',
68
- 'FRAUD_ALERT_MESSAGING',
69
- 'EVENTS',
70
- 'HIGHER_EDUCATION',
71
- 'K12',
72
- 'MARKETING',
73
- 'POLLING_AND_VOTING_NON_POLITICAL',
74
- 'POLITICAL_ELECTION_CAMPAIGNS',
75
- 'PUBLIC_SERVICE_ANNOUNCEMENT',
76
- 'SECURITY_ALERT',
77
- ] as const;
78
-
79
- function normalizeUseCaseCategories(rawCategories: string[]): string[] {
80
- const normalized = rawCategories.map((value) => TWILIO_USE_CASE_ALIASES[value] ?? value);
81
- return Array.from(new Set(normalized));
82
- }
83
-
84
- export async function handleTwilioConfig(
85
- msg: TwilioConfigRequest,
86
- socket: net.Socket,
87
- ctx: HandlerContext,
88
- ): Promise<void> {
89
- try {
90
- if (msg.action === 'get') {
91
- const hasCredentials = hasTwilioCredentials();
92
- const raw = loadRawConfig();
93
- const sms = (raw?.sms ?? {}) as Record<string, unknown>;
94
- // When assistantId is provided, look up in assistantPhoneNumbers first,
95
- // fall back to the legacy phoneNumber field
96
- let phoneNumber: string;
97
- if (msg.assistantId) {
98
- const mapping = (sms.assistantPhoneNumbers as Record<string, string> | undefined) ?? {};
99
- phoneNumber = mapping[msg.assistantId] ?? (sms.phoneNumber as string) ?? '';
100
- } else {
101
- phoneNumber = (sms.phoneNumber as string) ?? '';
102
- }
103
- ctx.send(socket, {
104
- type: 'twilio_config_response',
105
- success: true,
106
- hasCredentials,
107
- phoneNumber: phoneNumber || undefined,
108
- });
109
- } else if (msg.action === 'set_credentials') {
110
- if (!msg.accountSid || !msg.authToken) {
111
- ctx.send(socket, {
112
- type: 'twilio_config_response',
113
- success: false,
114
- hasCredentials: hasTwilioCredentials(),
115
- error: 'accountSid and authToken are required for set_credentials action',
116
- });
117
- return;
118
- }
119
-
120
- // Validate credentials by calling the Twilio API
121
- const authHeader = 'Basic ' + Buffer.from(`${msg.accountSid}:${msg.authToken}`).toString('base64');
122
- try {
123
- const res = await fetch(
124
- `https://api.twilio.com/2010-04-01/Accounts/${msg.accountSid}.json`,
125
- {
126
- method: 'GET',
127
- headers: { Authorization: authHeader },
128
- },
129
- );
130
- if (!res.ok) {
131
- const body = await res.text();
132
- ctx.send(socket, {
133
- type: 'twilio_config_response',
134
- success: false,
135
- hasCredentials: hasTwilioCredentials(),
136
- error: `Twilio API validation failed (${res.status}): ${body}`,
137
- });
138
- return;
139
- }
140
- } catch (err) {
141
- const message = err instanceof Error ? err.message : String(err);
142
- ctx.send(socket, {
143
- type: 'twilio_config_response',
144
- success: false,
145
- hasCredentials: hasTwilioCredentials(),
146
- error: `Failed to validate Twilio credentials: ${message}`,
147
- });
148
- return;
149
- }
150
-
151
- // Store credentials securely
152
- const sidStored = setSecureKey('credential:twilio:account_sid', msg.accountSid);
153
- if (!sidStored) {
154
- ctx.send(socket, {
155
- type: 'twilio_config_response',
156
- success: false,
157
- hasCredentials: false,
158
- error: 'Failed to store Account SID in secure storage',
159
- });
160
- return;
161
- }
162
-
163
- const tokenStored = setSecureKey('credential:twilio:auth_token', msg.authToken);
164
- if (!tokenStored) {
165
- // Roll back the Account SID
166
- deleteSecureKey('credential:twilio:account_sid');
167
- ctx.send(socket, {
168
- type: 'twilio_config_response',
169
- success: false,
170
- hasCredentials: false,
171
- error: 'Failed to store Auth Token in secure storage',
172
- });
173
- return;
174
- }
175
-
176
- upsertCredentialMetadata('twilio', 'account_sid', {});
177
- upsertCredentialMetadata('twilio', 'auth_token', {});
178
-
179
- ctx.send(socket, {
180
- type: 'twilio_config_response',
181
- success: true,
182
- hasCredentials: true,
183
- });
184
- } else if (msg.action === 'clear_credentials') {
185
- // Only clear authentication credentials (Account SID and Auth Token).
186
- // Preserve the phone number in both config (sms.phoneNumber) and secure
187
- // key (credential:twilio:phone_number) so that re-entering credentials
188
- // resumes working without needing to reassign the number.
189
- deleteSecureKey('credential:twilio:account_sid');
190
- deleteSecureKey('credential:twilio:auth_token');
191
- deleteCredentialMetadata('twilio', 'account_sid');
192
- deleteCredentialMetadata('twilio', 'auth_token');
193
-
194
- ctx.send(socket, {
195
- type: 'twilio_config_response',
196
- success: true,
197
- hasCredentials: false,
198
- });
199
- } else if (msg.action === 'provision_number') {
200
- if (!hasTwilioCredentials()) {
201
- ctx.send(socket, {
202
- type: 'twilio_config_response',
203
- success: false,
204
- hasCredentials: false,
205
- error: 'Twilio credentials not configured. Set credentials first.',
206
- });
207
- return;
208
- }
209
-
210
- const accountSid = getSecureKey('credential:twilio:account_sid')!;
211
- const authToken = getSecureKey('credential:twilio:auth_token')!;
212
- const country = msg.country ?? 'US';
213
-
214
- // Search for an available number
215
- const available = await searchAvailableNumbers(accountSid, authToken, country, msg.areaCode);
216
- if (available.length === 0) {
217
- ctx.send(socket, {
218
- type: 'twilio_config_response',
219
- success: false,
220
- hasCredentials: true,
221
- error: `No available phone numbers found for country=${country}${msg.areaCode ? ` areaCode=${msg.areaCode}` : ''}`,
222
- });
223
- return;
224
- }
225
-
226
- // Purchase the first available number
227
- const purchased = await provisionPhoneNumber(accountSid, authToken, available[0].phoneNumber);
228
-
229
- // Auto-assign: persist the purchased number in secure storage and config
230
- // (same persistence as assign_number for consistency)
231
- const phoneStored = setSecureKey('credential:twilio:phone_number', purchased.phoneNumber);
232
- if (!phoneStored) {
233
- ctx.send(socket, {
234
- type: 'twilio_config_response',
235
- success: false,
236
- hasCredentials: hasTwilioCredentials(),
237
- phoneNumber: purchased.phoneNumber,
238
- error: `Phone number ${purchased.phoneNumber} was purchased but could not be saved. Use assign_number to assign it manually.`,
239
- });
240
- return;
241
- }
242
-
243
- const raw = loadRawConfig();
244
- const sms = (raw?.sms ?? {}) as Record<string, unknown>;
245
- // When assistantId is provided, only set the legacy global phoneNumber
246
- // if it's not already set — this prevents multi-assistant assignments
247
- // from clobbering each other's outbound SMS number.
248
- if (msg.assistantId) {
249
- if (!sms.phoneNumber) {
250
- sms.phoneNumber = purchased.phoneNumber;
251
- }
252
- } else {
253
- sms.phoneNumber = purchased.phoneNumber;
254
- }
255
- // When assistantId is provided, also persist into the per-assistant mapping
256
- if (msg.assistantId) {
257
- const mapping = (sms.assistantPhoneNumbers as Record<string, string> | undefined) ?? {};
258
- mapping[msg.assistantId] = purchased.phoneNumber;
259
- sms.assistantPhoneNumbers = mapping;
260
- }
261
-
262
- const wasSuppressed = ctx.suppressConfigReload;
263
- ctx.setSuppressConfigReload(true);
264
- try {
265
- saveRawConfig({ ...raw, sms });
266
- } catch (err) {
267
- ctx.setSuppressConfigReload(wasSuppressed);
268
- throw err;
269
- }
270
- ctx.debounceTimers.schedule('__suppress_reset__', () => { ctx.setSuppressConfigReload(false); }, CONFIG_RELOAD_DEBOUNCE_MS);
271
-
272
- // Best-effort webhook configuration — non-fatal so the number is
273
- // still usable even if ingress isn't configured yet.
274
- const webhookResult = await syncTwilioWebhooks(
275
- purchased.phoneNumber,
276
- accountSid,
277
- authToken,
278
- loadRawConfig() as IngressConfig,
279
- );
280
-
281
- ctx.send(socket, {
282
- type: 'twilio_config_response',
283
- success: true,
284
- hasCredentials: true,
285
- phoneNumber: purchased.phoneNumber,
286
- warning: webhookResult.warning,
287
- });
288
- } else if (msg.action === 'assign_number') {
289
- if (!msg.phoneNumber) {
290
- ctx.send(socket, {
291
- type: 'twilio_config_response',
292
- success: false,
293
- hasCredentials: hasTwilioCredentials(),
294
- error: 'phoneNumber is required for assign_number action',
295
- });
296
- return;
297
- }
298
-
299
- // Persist the phone number in the secure credential store so the
300
- // active Twilio runtime can read it via credential:twilio:phone_number
301
- const phoneStored = setSecureKey('credential:twilio:phone_number', msg.phoneNumber);
302
- if (!phoneStored) {
303
- ctx.send(socket, {
304
- type: 'twilio_config_response',
305
- success: false,
306
- hasCredentials: hasTwilioCredentials(),
307
- error: 'Failed to store phone number in secure storage',
308
- });
309
- return;
310
- }
311
-
312
- // Also persist in assistant config (non-secret) for the UI
313
- const raw = loadRawConfig();
314
- const sms = (raw?.sms ?? {}) as Record<string, unknown>;
315
- // When assistantId is provided, only set the legacy global phoneNumber
316
- // if it's not already set — this prevents multi-assistant assignments
317
- // from clobbering each other's outbound SMS number.
318
- if (msg.assistantId) {
319
- if (!sms.phoneNumber) {
320
- sms.phoneNumber = msg.phoneNumber;
321
- }
322
- } else {
323
- sms.phoneNumber = msg.phoneNumber;
324
- }
325
- // When assistantId is provided, also persist into the per-assistant mapping
326
- if (msg.assistantId) {
327
- const mapping = (sms.assistantPhoneNumbers as Record<string, string> | undefined) ?? {};
328
- mapping[msg.assistantId] = msg.phoneNumber;
329
- sms.assistantPhoneNumbers = mapping;
330
- }
331
-
332
- const wasSuppressed = ctx.suppressConfigReload;
333
- ctx.setSuppressConfigReload(true);
334
- try {
335
- saveRawConfig({ ...raw, sms });
336
- } catch (err) {
337
- ctx.setSuppressConfigReload(wasSuppressed);
338
- throw err;
339
- }
340
- ctx.debounceTimers.schedule('__suppress_reset__', () => { ctx.setSuppressConfigReload(false); }, CONFIG_RELOAD_DEBOUNCE_MS);
341
-
342
- // Best-effort webhook configuration when credentials are available
343
- let webhookWarning: string | undefined;
344
- if (hasTwilioCredentials()) {
345
- const acctSid = getSecureKey('credential:twilio:account_sid')!;
346
- const acctToken = getSecureKey('credential:twilio:auth_token')!;
347
- const webhookResult = await syncTwilioWebhooks(
348
- msg.phoneNumber,
349
- acctSid,
350
- acctToken,
351
- loadRawConfig() as IngressConfig,
352
- );
353
- webhookWarning = webhookResult.warning;
354
- }
355
-
356
- ctx.send(socket, {
357
- type: 'twilio_config_response',
358
- success: true,
359
- hasCredentials: hasTwilioCredentials(),
360
- phoneNumber: msg.phoneNumber,
361
- warning: webhookWarning,
362
- });
363
- } else if (msg.action === 'list_numbers') {
364
- if (!hasTwilioCredentials()) {
365
- ctx.send(socket, {
366
- type: 'twilio_config_response',
367
- success: false,
368
- hasCredentials: false,
369
- error: 'Twilio credentials not configured. Set credentials first.',
370
- });
371
- return;
372
- }
373
-
374
- const accountSid = getSecureKey('credential:twilio:account_sid')!;
375
- const authToken = getSecureKey('credential:twilio:auth_token')!;
376
- const numbers = await listIncomingPhoneNumbers(accountSid, authToken);
377
-
378
- ctx.send(socket, {
379
- type: 'twilio_config_response',
380
- success: true,
381
- hasCredentials: true,
382
- numbers,
383
- });
384
- } else if (msg.action === 'sms_compliance_status') {
385
- if (!hasTwilioCredentials()) {
386
- ctx.send(socket, {
387
- type: 'twilio_config_response',
388
- success: false,
389
- hasCredentials: false,
390
- error: 'Twilio credentials not configured. Set credentials first.',
391
- });
392
- return;
393
- }
394
-
395
- const raw = loadRawConfig();
396
- const sms = (raw?.sms ?? {}) as Record<string, unknown>;
397
- let phoneNumber: string;
398
- if (msg.assistantId) {
399
- const mapping = (sms.assistantPhoneNumbers as Record<string, string> | undefined) ?? {};
400
- phoneNumber = mapping[msg.assistantId] ?? (sms.phoneNumber as string) ?? '';
401
- } else {
402
- phoneNumber = (sms.phoneNumber as string) ?? '';
403
- }
404
-
405
- if (!phoneNumber) {
406
- ctx.send(socket, {
407
- type: 'twilio_config_response',
408
- success: false,
409
- hasCredentials: true,
410
- error: 'No phone number assigned. Assign a number first.',
411
- });
412
- return;
413
- }
414
-
415
- const accountSid = getSecureKey('credential:twilio:account_sid')!;
416
- const authToken = getSecureKey('credential:twilio:auth_token')!;
417
-
418
- // Determine number type from prefix
419
- const tollFreePrefixes = ['+1800', '+1833', '+1844', '+1855', '+1866', '+1877', '+1888'];
420
- const isTollFree = tollFreePrefixes.some((prefix) => phoneNumber.startsWith(prefix));
421
- const numberType = isTollFree ? 'toll_free' : 'local_10dlc';
422
-
423
- if (!isTollFree) {
424
- // Non-toll-free numbers don't need toll-free verification
425
- ctx.send(socket, {
426
- type: 'twilio_config_response',
427
- success: true,
428
- hasCredentials: true,
429
- phoneNumber,
430
- compliance: { numberType },
431
- });
432
- return;
433
- }
434
-
435
- // Look up the phone number SID and check verification status
436
- const phoneSid = await getPhoneNumberSid(accountSid, authToken, phoneNumber);
437
- if (!phoneSid) {
438
- ctx.send(socket, {
439
- type: 'twilio_config_response',
440
- success: false,
441
- hasCredentials: true,
442
- phoneNumber,
443
- error: `Phone number ${phoneNumber} not found on Twilio account`,
444
- });
445
- return;
446
- }
447
-
448
- const verification = await getTollFreeVerificationStatus(accountSid, authToken, phoneSid);
449
-
450
- ctx.send(socket, {
451
- type: 'twilio_config_response',
452
- success: true,
453
- hasCredentials: true,
454
- phoneNumber,
455
- compliance: {
456
- numberType,
457
- tollfreePhoneNumberSid: phoneSid,
458
- verificationSid: verification?.sid,
459
- verificationStatus: verification?.status,
460
- rejectionReason: verification?.rejectionReason,
461
- rejectionReasons: verification?.rejectionReasons,
462
- errorCode: verification?.errorCode,
463
- editAllowed: verification?.editAllowed,
464
- editExpiration: verification?.editExpiration,
465
- },
466
- });
467
- } else if (msg.action === 'sms_submit_tollfree_verification') {
468
- if (!hasTwilioCredentials()) {
469
- ctx.send(socket, {
470
- type: 'twilio_config_response',
471
- success: false,
472
- hasCredentials: false,
473
- error: 'Twilio credentials not configured. Set credentials first.',
474
- });
475
- return;
476
- }
477
-
478
- const vp = msg.verificationParams;
479
- if (!vp) {
480
- ctx.send(socket, {
481
- type: 'twilio_config_response',
482
- success: false,
483
- hasCredentials: true,
484
- error: 'verificationParams is required for sms_submit_tollfree_verification action',
485
- });
486
- return;
487
- }
488
-
489
- // Validate required fields
490
- const requiredFields: Array<[string, unknown]> = [
491
- ['tollfreePhoneNumberSid', vp.tollfreePhoneNumberSid],
492
- ['businessName', vp.businessName],
493
- ['businessWebsite', vp.businessWebsite],
494
- ['notificationEmail', vp.notificationEmail],
495
- ['useCaseCategories', vp.useCaseCategories],
496
- ['useCaseSummary', vp.useCaseSummary],
497
- ['productionMessageSample', vp.productionMessageSample],
498
- ['optInImageUrls', vp.optInImageUrls],
499
- ['optInType', vp.optInType],
500
- ['messageVolume', vp.messageVolume],
501
- ];
502
-
503
- const missing = requiredFields
504
- .filter(([, v]) => v == null || v === '' || (Array.isArray(v) && v.length === 0))
505
- .map(([name]) => name);
506
-
507
- if (missing.length > 0) {
508
- ctx.send(socket, {
509
- type: 'twilio_config_response',
510
- success: false,
511
- hasCredentials: true,
512
- error: `Missing required verification fields: ${missing.join(', ')}`,
513
- });
514
- return;
515
- }
516
-
517
- // Validate enum values
518
- const normalizedUseCaseCategories = normalizeUseCaseCategories(vp.useCaseCategories ?? []);
519
- const invalidCategories = normalizedUseCaseCategories.filter(
520
- (c) => !TWILIO_VALID_USE_CASE_CATEGORIES.includes(c as (typeof TWILIO_VALID_USE_CASE_CATEGORIES)[number]),
521
- );
522
- if (invalidCategories.length > 0) {
523
- ctx.send(socket, {
524
- type: 'twilio_config_response',
525
- success: false,
526
- hasCredentials: true,
527
- error: `Invalid useCaseCategories: ${invalidCategories.join(', ')}. Valid values: ${TWILIO_VALID_USE_CASE_CATEGORIES.join(', ')}`,
528
- });
529
- return;
530
- }
531
-
532
- const validOptInTypes = ['VERBAL', 'WEB_FORM', 'PAPER_FORM', 'VIA_TEXT', 'MOBILE_QR_CODE'];
533
- if (!validOptInTypes.includes(vp.optInType!)) {
534
- ctx.send(socket, {
535
- type: 'twilio_config_response',
536
- success: false,
537
- hasCredentials: true,
538
- error: `Invalid optInType: ${vp.optInType}. Valid values: ${validOptInTypes.join(', ')}`,
539
- });
540
- return;
541
- }
542
-
543
- const validMessageVolumes = [
544
- '10', '100', '1,000', '10,000', '100,000', '250,000',
545
- '500,000', '750,000', '1,000,000', '5,000,000', '10,000,000+',
546
- ];
547
- if (!validMessageVolumes.includes(vp.messageVolume!)) {
548
- ctx.send(socket, {
549
- type: 'twilio_config_response',
550
- success: false,
551
- hasCredentials: true,
552
- error: `Invalid messageVolume: ${vp.messageVolume}. Valid values: ${validMessageVolumes.join(', ')}`,
553
- });
554
- return;
555
- }
556
-
557
- const accountSid = getSecureKey('credential:twilio:account_sid')!;
558
- const authToken = getSecureKey('credential:twilio:auth_token')!;
559
-
560
- const submitParams: TollFreeVerificationSubmitParams = {
561
- tollfreePhoneNumberSid: vp.tollfreePhoneNumberSid!,
562
- businessName: vp.businessName!,
563
- businessWebsite: vp.businessWebsite!,
564
- notificationEmail: vp.notificationEmail!,
565
- useCaseCategories: normalizedUseCaseCategories,
566
- useCaseSummary: vp.useCaseSummary!,
567
- productionMessageSample: vp.productionMessageSample!,
568
- optInImageUrls: vp.optInImageUrls!,
569
- optInType: vp.optInType!,
570
- messageVolume: vp.messageVolume!,
571
- businessType: vp.businessType ?? 'SOLE_PROPRIETOR',
572
- customerProfileSid: vp.customerProfileSid,
573
- };
574
-
575
- const verification = await submitTollFreeVerification(accountSid, authToken, submitParams);
576
-
577
- ctx.send(socket, {
578
- type: 'twilio_config_response',
579
- success: true,
580
- hasCredentials: true,
581
- compliance: {
582
- numberType: 'toll_free',
583
- verificationSid: verification.sid,
584
- verificationStatus: verification.status,
585
- },
586
- });
587
- } else if (msg.action === 'sms_update_tollfree_verification') {
588
- if (!hasTwilioCredentials()) {
589
- ctx.send(socket, {
590
- type: 'twilio_config_response',
591
- success: false,
592
- hasCredentials: false,
593
- error: 'Twilio credentials not configured. Set credentials first.',
594
- });
595
- return;
596
- }
597
-
598
- if (!msg.verificationSid) {
599
- ctx.send(socket, {
600
- type: 'twilio_config_response',
601
- success: false,
602
- hasCredentials: true,
603
- error: 'verificationSid is required for sms_update_tollfree_verification action',
604
- });
605
- return;
606
- }
607
-
608
- const accountSid = getSecureKey('credential:twilio:account_sid')!;
609
- const authToken = getSecureKey('credential:twilio:auth_token')!;
610
-
611
- const currentVerification = await getTollFreeVerificationBySid(accountSid, authToken, msg.verificationSid);
612
- if (!currentVerification) {
613
- ctx.send(socket, {
614
- type: 'twilio_config_response',
615
- success: false,
616
- hasCredentials: true,
617
- error: `Verification ${msg.verificationSid} was not found on this Twilio account.`,
618
- });
619
- return;
620
- }
621
-
622
- if (currentVerification.status === 'TWILIO_REJECTED') {
623
- const expirationMillis = currentVerification.editExpiration
624
- ? Date.parse(currentVerification.editExpiration)
625
- : Number.NaN;
626
- const editExpired = Number.isFinite(expirationMillis) && Date.now() > expirationMillis;
627
- if (currentVerification.editAllowed === false || editExpired) {
628
- const detail = editExpired
629
- ? `edit_expiration=${currentVerification.editExpiration}`
630
- : 'edit_allowed=false';
631
- ctx.send(socket, {
632
- type: 'twilio_config_response',
633
- success: false,
634
- hasCredentials: true,
635
- error: `Verification ${msg.verificationSid} cannot be updated (${detail}). Delete and resubmit instead.`,
636
- compliance: {
637
- numberType: 'toll_free',
638
- verificationSid: currentVerification.sid,
639
- verificationStatus: currentVerification.status,
640
- editAllowed: currentVerification.editAllowed,
641
- editExpiration: currentVerification.editExpiration,
642
- },
643
- });
644
- return;
645
- }
646
- }
647
-
648
- const updateParams = { ...(msg.verificationParams ?? {}) };
649
- if (updateParams.useCaseCategories) {
650
- updateParams.useCaseCategories = normalizeUseCaseCategories(updateParams.useCaseCategories);
651
- }
652
-
653
- const verification = await updateTollFreeVerification(
654
- accountSid,
655
- authToken,
656
- msg.verificationSid,
657
- updateParams,
658
- );
659
-
660
- ctx.send(socket, {
661
- type: 'twilio_config_response',
662
- success: true,
663
- hasCredentials: true,
664
- compliance: {
665
- numberType: 'toll_free',
666
- verificationSid: verification.sid,
667
- verificationStatus: verification.status,
668
- editAllowed: verification.editAllowed,
669
- editExpiration: verification.editExpiration,
670
- },
671
- });
672
- } else if (msg.action === 'sms_delete_tollfree_verification') {
673
- if (!hasTwilioCredentials()) {
674
- ctx.send(socket, {
675
- type: 'twilio_config_response',
676
- success: false,
677
- hasCredentials: false,
678
- error: 'Twilio credentials not configured. Set credentials first.',
679
- });
680
- return;
681
- }
682
-
683
- if (!msg.verificationSid) {
684
- ctx.send(socket, {
685
- type: 'twilio_config_response',
686
- success: false,
687
- hasCredentials: true,
688
- error: 'verificationSid is required for sms_delete_tollfree_verification action',
689
- });
690
- return;
691
- }
692
-
693
- const accountSid = getSecureKey('credential:twilio:account_sid')!;
694
- const authToken = getSecureKey('credential:twilio:auth_token')!;
695
-
696
- await deleteTollFreeVerification(accountSid, authToken, msg.verificationSid);
697
-
698
- ctx.send(socket, {
699
- type: 'twilio_config_response',
700
- success: true,
701
- hasCredentials: true,
702
- warning: 'Toll-free verification deleted. Re-submitting may reset your position in the review queue.',
703
- });
704
- } else if (msg.action === 'release_number') {
705
- if (!hasTwilioCredentials()) {
706
- ctx.send(socket, {
707
- type: 'twilio_config_response',
708
- success: false,
709
- hasCredentials: false,
710
- error: 'Twilio credentials not configured. Set credentials first.',
711
- });
712
- return;
713
- }
714
-
715
- const raw = loadRawConfig();
716
- const sms = (raw?.sms ?? {}) as Record<string, unknown>;
717
- let phoneNumber: string;
718
- if (msg.phoneNumber) {
719
- phoneNumber = msg.phoneNumber;
720
- } else if (msg.assistantId) {
721
- const mapping = (sms.assistantPhoneNumbers as Record<string, string> | undefined) ?? {};
722
- phoneNumber = mapping[msg.assistantId] ?? (sms.phoneNumber as string) ?? '';
723
- } else {
724
- phoneNumber = (sms.phoneNumber as string) ?? '';
725
- }
726
-
727
- if (!phoneNumber) {
728
- ctx.send(socket, {
729
- type: 'twilio_config_response',
730
- success: false,
731
- hasCredentials: true,
732
- error: 'No phone number to release. Specify phoneNumber or ensure one is assigned.',
733
- });
734
- return;
735
- }
736
-
737
- const accountSid = getSecureKey('credential:twilio:account_sid')!;
738
- const authToken = getSecureKey('credential:twilio:auth_token')!;
739
-
740
- await releasePhoneNumber(accountSid, authToken, phoneNumber);
741
-
742
- // Clear the number from config and secure key store
743
- if (sms.phoneNumber === phoneNumber) {
744
- delete sms.phoneNumber;
745
- }
746
- const assistantPhoneNumbers = sms.assistantPhoneNumbers as Record<string, string> | undefined;
747
- if (assistantPhoneNumbers) {
748
- for (const [id, num] of Object.entries(assistantPhoneNumbers)) {
749
- if (num === phoneNumber) {
750
- delete assistantPhoneNumbers[id];
751
- }
752
- }
753
- if (Object.keys(assistantPhoneNumbers).length === 0) {
754
- delete sms.assistantPhoneNumbers;
755
- }
756
- }
757
-
758
- const wasSuppressed = ctx.suppressConfigReload;
759
- ctx.setSuppressConfigReload(true);
760
- try {
761
- saveRawConfig({ ...raw, sms });
762
- } catch (err) {
763
- ctx.setSuppressConfigReload(wasSuppressed);
764
- throw err;
765
- }
766
- ctx.debounceTimers.schedule('__suppress_reset__', () => { ctx.setSuppressConfigReload(false); }, CONFIG_RELOAD_DEBOUNCE_MS);
767
-
768
- // Clear the phone number from secure key store if it matches
769
- const storedPhone = getSecureKey('credential:twilio:phone_number');
770
- if (storedPhone === phoneNumber) {
771
- deleteSecureKey('credential:twilio:phone_number');
772
- }
773
-
774
- ctx.send(socket, {
775
- type: 'twilio_config_response',
776
- success: true,
777
- hasCredentials: true,
778
- warning: 'Phone number released from Twilio. Any associated toll-free verification context is lost.',
779
- });
780
- } else if (msg.action === 'sms_send_test') {
781
- // ── SMS send test ────────────────────────────────────────────────
782
- if (!hasTwilioCredentials()) {
783
- ctx.send(socket, {
784
- type: 'twilio_config_response',
785
- success: false,
786
- hasCredentials: false,
787
- error: 'Twilio credentials not configured. Set credentials first.',
788
- });
789
- return;
790
- }
791
-
792
- const to = msg.phoneNumber;
793
- if (!to) {
794
- ctx.send(socket, {
795
- type: 'twilio_config_response',
796
- success: false,
797
- hasCredentials: true,
798
- error: 'phoneNumber is required for sms_send_test action.',
799
- });
800
- return;
801
- }
802
-
803
- const raw = loadRawConfig();
804
- const smsSection = (raw?.sms ?? {}) as Record<string, unknown>;
805
- let from = '';
806
- // When assistantId is provided, check assistant-scoped phone mapping first
807
- if (msg.assistantId) {
808
- const mapping = (smsSection.assistantPhoneNumbers as Record<string, string> | undefined) ?? {};
809
- from = mapping[msg.assistantId] ?? '';
810
- }
811
- // Fall back to global phone number
812
- if (!from) {
813
- from = (smsSection.phoneNumber as string | undefined)
814
- || getSecureKey('credential:twilio:phone_number')
815
- || '';
816
- }
817
- if (!from) {
818
- ctx.send(socket, {
819
- type: 'twilio_config_response',
820
- success: false,
821
- hasCredentials: true,
822
- error: 'No phone number assigned. Run the twilio-setup skill to assign a number.',
823
- });
824
- return;
825
- }
826
-
827
- const accountSid = getSecureKey('credential:twilio:account_sid')!;
828
- const authToken = getSecureKey('credential:twilio:auth_token')!;
829
- const text = msg.text || 'Test SMS from your Vellum assistant';
830
-
831
- // Send via gateway's /deliver/sms endpoint
832
- const bearerToken = readHttpToken();
833
- const gatewayUrl = getGatewayInternalBaseUrl();
834
-
835
- const sendResp = await fetch(`${gatewayUrl}/deliver/sms`, {
836
- method: 'POST',
837
- headers: {
838
- 'Content-Type': 'application/json',
839
- ...(bearerToken ? { Authorization: `Bearer ${bearerToken}` } : {}),
840
- },
841
- body: JSON.stringify({ to, text, ...(msg.assistantId ? { assistantId: msg.assistantId } : {}) }),
842
- signal: AbortSignal.timeout(30_000),
843
- });
844
-
845
- if (!sendResp.ok) {
846
- const errBody = await sendResp.text().catch(() => '<unreadable>');
847
- ctx.send(socket, {
848
- type: 'twilio_config_response',
849
- success: false,
850
- hasCredentials: true,
851
- error: `SMS send failed (${sendResp.status}): ${errBody}`,
852
- });
853
- return;
854
- }
855
-
856
- const sendData = await sendResp.json().catch(() => ({})) as {
857
- messageSid?: string;
858
- status?: string;
859
- };
860
- const messageSid = sendData.messageSid || '';
861
- const initialStatus = sendData.status || 'unknown';
862
-
863
- // Poll Twilio for final status (up to 3 times, 2s apart)
864
- let finalStatus = initialStatus;
865
- let errorCode: string | undefined;
866
- let errorMessage: string | undefined;
867
-
868
- if (messageSid) {
869
- for (let i = 0; i < 3; i++) {
870
- await new Promise((r) => setTimeout(r, 2000));
871
- try {
872
- const pollResult = await fetchMessageStatus(accountSid, authToken, messageSid);
873
- finalStatus = pollResult.status;
874
- errorCode = pollResult.errorCode;
875
- errorMessage = pollResult.errorMessage;
876
- // Stop polling if we've reached a terminal status
877
- if (['delivered', 'undelivered', 'failed'].includes(finalStatus)) break;
878
- } catch {
879
- // Polling failure is non-fatal; we'll use the last known status
880
- break;
881
- }
882
- }
883
- }
884
-
885
- const testResult = {
886
- messageSid,
887
- to,
888
- initialStatus,
889
- finalStatus,
890
- ...(errorCode ? { errorCode } : {}),
891
- ...(errorMessage ? { errorMessage } : {}),
892
- };
893
-
894
- // Store for sms_doctor
895
- _lastTestResult = { ...testResult, timestamp: Date.now() };
896
-
897
- ctx.send(socket, {
898
- type: 'twilio_config_response',
899
- success: true,
900
- hasCredentials: true,
901
- testResult,
902
- });
903
-
904
- } else if (msg.action === 'sms_doctor') {
905
- // ── SMS doctor diagnostic ────────────────────────────────────────
906
- const hasCredentials = hasTwilioCredentials();
907
-
908
- // 1. Channel readiness check
909
- let readinessReady = false;
910
- const readinessIssues: string[] = [];
911
- try {
912
- const readinessService = getReadinessService();
913
- const snapshots = await readinessService.getReadiness('sms', false, msg.assistantId);
914
- const snapshot = snapshots[0];
915
- if (snapshot) {
916
- readinessReady = snapshot.ready;
917
- for (const r of snapshot.reasons) {
918
- readinessIssues.push(r.text);
919
- }
920
- } else {
921
- readinessIssues.push('No readiness snapshot returned for SMS channel');
922
- }
923
- } catch (err) {
924
- readinessIssues.push(`Readiness check failed: ${err instanceof Error ? err.message : String(err)}`);
925
- }
926
-
927
- // 2. Compliance status
928
- let complianceStatus = 'unknown';
929
- let complianceDetail: string | undefined;
930
- let complianceRemediation: string | undefined;
931
- if (hasCredentials) {
932
- try {
933
- const raw = loadRawConfig();
934
- const smsSection = (raw?.sms ?? {}) as Record<string, unknown>;
935
- let phoneNumber = '';
936
- if (msg.assistantId) {
937
- const mapping = (smsSection.assistantPhoneNumbers as Record<string, string> | undefined) ?? {};
938
- phoneNumber = mapping[msg.assistantId] ?? '';
939
- }
940
- if (!phoneNumber) {
941
- phoneNumber = (smsSection.phoneNumber as string | undefined) || getSecureKey('credential:twilio:phone_number') || '';
942
- }
943
- if (phoneNumber) {
944
- const accountSid = getSecureKey('credential:twilio:account_sid')!;
945
- const authToken = getSecureKey('credential:twilio:auth_token')!;
946
- // Determine number type and verification status
947
- const isTollFree = phoneNumber.startsWith('+1') && ['800','888','877','866','855','844','833'].some(
948
- (p) => phoneNumber.startsWith(`+1${p}`),
949
- );
950
- if (isTollFree) {
951
- try {
952
- const phoneSid = await getPhoneNumberSid(accountSid, authToken, phoneNumber);
953
- if (!phoneSid) {
954
- complianceStatus = 'check_failed';
955
- complianceDetail = `Assigned number ${phoneNumber} was not found on the Twilio account`;
956
- complianceRemediation = 'Reassign the number in twilio-setup or update credentials to the matching account.';
957
- } else {
958
- const verification = await getTollFreeVerificationStatus(accountSid, authToken, phoneSid);
959
- if (verification) {
960
- const status = verification.status;
961
- complianceStatus = status;
962
- complianceDetail = `Toll-free verification: ${status}`;
963
- if (status === 'TWILIO_APPROVED') {
964
- complianceRemediation = undefined;
965
- } else if (status === 'PENDING_REVIEW' || status === 'IN_REVIEW') {
966
- complianceRemediation = 'Toll-free verification is pending. Messaging may have limited throughput until approved.';
967
- } else if (status === 'TWILIO_REJECTED') {
968
- if (verification.editAllowed) {
969
- complianceRemediation = verification.editExpiration
970
- ? `Toll-free verification was rejected but can still be edited until ${verification.editExpiration}. Update and resubmit it.`
971
- : 'Toll-free verification was rejected but can still be edited. Update and resubmit it.';
972
- } else {
973
- complianceRemediation = 'Toll-free verification was rejected and is no longer editable. Delete and resubmit it.';
974
- }
975
- } else {
976
- complianceRemediation = 'Submit a toll-free verification to enable full messaging throughput.';
977
- }
978
- } else {
979
- complianceStatus = 'unverified';
980
- complianceDetail = 'Toll-free number without verification';
981
- complianceRemediation = 'Submit a toll-free verification request to avoid filtering.';
982
- }
983
- }
984
- } catch {
985
- complianceStatus = 'check_failed';
986
- complianceDetail = 'Could not retrieve toll-free verification status';
987
- }
988
- } else {
989
- complianceStatus = 'local_10dlc';
990
- complianceDetail = 'Local/10DLC number — carrier registration handled externally';
991
- }
992
- } else {
993
- complianceStatus = 'no_number';
994
- complianceDetail = 'No phone number assigned';
995
- complianceRemediation = 'Assign a phone number via the twilio-setup skill.';
996
- }
997
- } catch {
998
- complianceStatus = 'check_failed';
999
- complianceDetail = 'Could not determine compliance status';
1000
- }
1001
- } else {
1002
- complianceStatus = 'no_credentials';
1003
- complianceDetail = 'Twilio credentials are not configured';
1004
- complianceRemediation = 'Set Twilio credentials via the twilio-setup skill.';
1005
- }
1006
-
1007
- // 3. Last send test result
1008
- let lastSend: { status: string; errorCode?: string; remediation?: string } | undefined;
1009
- if (_lastTestResult) {
1010
- lastSend = {
1011
- status: _lastTestResult.finalStatus,
1012
- ...((_lastTestResult.errorCode) ? { errorCode: _lastTestResult.errorCode } : {}),
1013
- ...((_lastTestResult.errorCode) ? { remediation: mapTwilioErrorRemediation(_lastTestResult.errorCode) } : {}),
1014
- };
1015
- }
1016
-
1017
- // 4. Determine overall status
1018
- const actionItems: string[] = [];
1019
- let overallStatus: 'healthy' | 'degraded' | 'broken' = 'healthy';
1020
-
1021
- if (!hasCredentials) {
1022
- overallStatus = 'broken';
1023
- actionItems.push('Configure Twilio credentials.');
1024
- }
1025
- if (!readinessReady) {
1026
- overallStatus = 'broken';
1027
- for (const issue of readinessIssues) actionItems.push(issue);
1028
- }
1029
- if (complianceStatus === 'unverified' || complianceStatus === 'PENDING_REVIEW' || complianceStatus === 'IN_REVIEW') {
1030
- if (overallStatus === 'healthy') overallStatus = 'degraded';
1031
- if (complianceRemediation) actionItems.push(complianceRemediation);
1032
- }
1033
- if (complianceStatus === 'TWILIO_REJECTED' || complianceStatus === 'no_number') {
1034
- overallStatus = 'broken';
1035
- if (complianceRemediation) actionItems.push(complianceRemediation);
1036
- }
1037
- if (_lastTestResult && ['failed', 'undelivered'].includes(_lastTestResult.finalStatus)) {
1038
- if (overallStatus === 'healthy') overallStatus = 'degraded';
1039
- const remediation = mapTwilioErrorRemediation(_lastTestResult.errorCode);
1040
- actionItems.push(remediation || `Last test SMS ${_lastTestResult.finalStatus}. Check Twilio logs for details.`);
1041
- }
1042
-
1043
- ctx.send(socket, {
1044
- type: 'twilio_config_response',
1045
- success: true,
1046
- hasCredentials,
1047
- diagnostics: {
1048
- readiness: { ready: readinessReady, issues: readinessIssues },
1049
- compliance: {
1050
- status: complianceStatus,
1051
- ...(complianceDetail ? { detail: complianceDetail } : {}),
1052
- ...(complianceRemediation ? { remediation: complianceRemediation } : {}),
1053
- },
1054
- ...(lastSend ? { lastSend } : {}),
1055
- overallStatus,
1056
- actionItems,
1057
- },
1058
- });
1059
-
1060
- } else {
1061
- ctx.send(socket, {
1062
- type: 'twilio_config_response',
1063
- success: false,
1064
- hasCredentials: hasTwilioCredentials(),
1065
- error: `Unknown action: ${String(msg.action)}`,
1066
- });
1067
- }
1068
- } catch (err) {
1069
- const message = err instanceof Error ? err.message : String(err);
1070
- log.error({ err }, 'Failed to handle Twilio config');
1071
- ctx.send(socket, {
1072
- type: 'twilio_config_response',
1073
- success: false,
1074
- hasCredentials: hasTwilioCredentials(),
1075
- error: message,
1076
- });
1077
- }
1078
- }
1079
-
1080
- export const twilioHandlers = defineHandlers({
1081
- twilio_config: handleTwilioConfig,
1082
- });