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