@vellumai/assistant 0.3.3 → 0.3.5
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/Dockerfile +2 -0
- package/README.md +45 -18
- package/package.json +1 -1
- package/scripts/ipc/generate-swift.ts +13 -0
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +100 -0
- package/src/__tests__/approval-hardcoded-copy-guard.test.ts +41 -0
- package/src/__tests__/approval-message-composer.test.ts +253 -0
- package/src/__tests__/call-domain.test.ts +12 -2
- package/src/__tests__/call-orchestrator.test.ts +391 -1
- package/src/__tests__/call-routes-http.test.ts +27 -2
- package/src/__tests__/channel-approval-routes.test.ts +397 -135
- package/src/__tests__/channel-approvals.test.ts +99 -3
- package/src/__tests__/channel-delivery-store.test.ts +30 -4
- package/src/__tests__/channel-guardian.test.ts +261 -22
- package/src/__tests__/channel-readiness-service.test.ts +257 -0
- package/src/__tests__/config-schema.test.ts +2 -1
- package/src/__tests__/credential-security-invariants.test.ts +1 -0
- package/src/__tests__/daemon-lifecycle.test.ts +636 -0
- package/src/__tests__/dictation-mode-detection.test.ts +63 -0
- package/src/__tests__/entity-search.test.ts +615 -0
- package/src/__tests__/gateway-only-enforcement.test.ts +19 -13
- package/src/__tests__/handlers-twilio-config.test.ts +480 -0
- package/src/__tests__/ipc-snapshot.test.ts +63 -0
- package/src/__tests__/messaging-send-tool.test.ts +65 -0
- package/src/__tests__/run-orchestrator-assistant-events.test.ts +4 -0
- package/src/__tests__/run-orchestrator.test.ts +22 -0
- package/src/__tests__/secret-scanner.test.ts +223 -0
- package/src/__tests__/session-runtime-assembly.test.ts +85 -1
- package/src/__tests__/shell-parser-property.test.ts +357 -2
- package/src/__tests__/sms-messaging-provider.test.ts +125 -0
- package/src/__tests__/system-prompt.test.ts +25 -1
- package/src/__tests__/tool-executor-lifecycle-events.test.ts +34 -1
- package/src/__tests__/twilio-routes.test.ts +39 -3
- package/src/__tests__/twitter-cli-error-shaping.test.ts +2 -2
- package/src/__tests__/user-reference.test.ts +68 -0
- package/src/__tests__/web-search.test.ts +1 -1
- package/src/__tests__/work-item-output.test.ts +110 -0
- package/src/calls/call-domain.ts +8 -5
- package/src/calls/call-orchestrator.ts +85 -22
- package/src/calls/twilio-config.ts +17 -11
- package/src/calls/twilio-rest.ts +276 -0
- package/src/calls/twilio-routes.ts +39 -1
- package/src/cli/map.ts +6 -0
- package/src/commands/__tests__/cc-command-registry.test.ts +67 -0
- package/src/commands/cc-command-registry.ts +14 -1
- package/src/config/bundled-skills/claude-code/TOOLS.json +10 -3
- package/src/config/bundled-skills/knowledge-graph/SKILL.md +15 -0
- package/src/config/bundled-skills/knowledge-graph/TOOLS.json +56 -0
- package/src/config/bundled-skills/knowledge-graph/tools/graph-query.ts +185 -0
- package/src/config/bundled-skills/media-processing/SKILL.md +199 -0
- package/src/config/bundled-skills/media-processing/TOOLS.json +320 -0
- package/src/config/bundled-skills/media-processing/services/capability-registry.ts +137 -0
- package/src/config/bundled-skills/media-processing/services/event-detection-service.ts +280 -0
- package/src/config/bundled-skills/media-processing/services/feedback-aggregation.ts +144 -0
- package/src/config/bundled-skills/media-processing/services/feedback-store.ts +136 -0
- package/src/config/bundled-skills/media-processing/services/processing-pipeline.ts +261 -0
- package/src/config/bundled-skills/media-processing/services/retrieval-service.ts +95 -0
- package/src/config/bundled-skills/media-processing/services/timeline-service.ts +267 -0
- package/src/config/bundled-skills/media-processing/tools/analyze-keyframes.ts +301 -0
- package/src/config/bundled-skills/media-processing/tools/detect-events.ts +110 -0
- package/src/config/bundled-skills/media-processing/tools/extract-keyframes.ts +190 -0
- package/src/config/bundled-skills/media-processing/tools/generate-clip.ts +195 -0
- package/src/config/bundled-skills/media-processing/tools/ingest-media.ts +197 -0
- package/src/config/bundled-skills/media-processing/tools/media-diagnostics.ts +166 -0
- package/src/config/bundled-skills/media-processing/tools/media-status.ts +75 -0
- package/src/config/bundled-skills/media-processing/tools/query-media-events.ts +300 -0
- package/src/config/bundled-skills/media-processing/tools/recalibrate.ts +235 -0
- package/src/config/bundled-skills/media-processing/tools/select-tracking-profile.ts +142 -0
- package/src/config/bundled-skills/media-processing/tools/submit-feedback.ts +150 -0
- package/src/config/bundled-skills/messaging/SKILL.md +24 -5
- package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -1
- package/src/config/bundled-skills/phone-calls/SKILL.md +2 -2
- package/src/config/bundled-skills/twitter/SKILL.md +19 -3
- package/src/config/defaults.ts +2 -1
- package/src/config/schema.ts +9 -3
- package/src/config/skills.ts +5 -32
- package/src/config/system-prompt.ts +40 -0
- package/src/config/templates/IDENTITY.md +2 -2
- package/src/config/user-reference.ts +29 -0
- package/src/config/vellum-skills/catalog.json +58 -0
- package/src/config/vellum-skills/google-oauth-setup/SKILL.md +3 -3
- package/src/config/vellum-skills/slack-oauth-setup/SKILL.md +3 -3
- package/src/config/vellum-skills/sms-setup/SKILL.md +118 -0
- package/src/config/vellum-skills/telegram-setup/SKILL.md +6 -1
- package/src/config/vellum-skills/twilio-setup/SKILL.md +76 -6
- package/src/daemon/auth-manager.ts +103 -0
- package/src/daemon/computer-use-session.ts +8 -1
- package/src/daemon/config-watcher.ts +253 -0
- package/src/daemon/handlers/config.ts +819 -22
- package/src/daemon/handlers/dictation.ts +182 -0
- package/src/daemon/handlers/identity.ts +14 -23
- package/src/daemon/handlers/index.ts +2 -0
- package/src/daemon/handlers/sessions.ts +2 -0
- package/src/daemon/handlers/shared.ts +3 -0
- package/src/daemon/handlers/skills.ts +6 -7
- package/src/daemon/handlers/work-items.ts +15 -7
- package/src/daemon/ipc-contract-inventory.json +10 -0
- package/src/daemon/ipc-contract.ts +114 -4
- package/src/daemon/ipc-handler.ts +87 -0
- package/src/daemon/lifecycle.ts +18 -4
- package/src/daemon/ride-shotgun-handler.ts +11 -1
- package/src/daemon/server.ts +111 -504
- package/src/daemon/session-agent-loop.ts +10 -15
- package/src/daemon/session-runtime-assembly.ts +115 -44
- package/src/daemon/session-tool-setup.ts +2 -0
- package/src/daemon/session.ts +19 -2
- package/src/inbound/public-ingress-urls.ts +3 -3
- package/src/memory/channel-guardian-store.ts +2 -1
- package/src/memory/db-connection.ts +28 -0
- package/src/memory/db-init.ts +1163 -0
- package/src/memory/db.ts +2 -2007
- package/src/memory/embedding-backend.ts +79 -11
- package/src/memory/indexer.ts +2 -0
- package/src/memory/job-handlers/media-processing.ts +100 -0
- package/src/memory/job-utils.ts +64 -4
- package/src/memory/jobs-store.ts +2 -1
- package/src/memory/jobs-worker.ts +11 -1
- package/src/memory/media-store.ts +759 -0
- package/src/memory/recall-cache.ts +107 -0
- package/src/memory/retriever.ts +36 -2
- package/src/memory/schema-migration.ts +984 -0
- package/src/memory/schema.ts +99 -0
- package/src/memory/search/entity.ts +208 -25
- package/src/memory/search/ranking.ts +6 -1
- package/src/memory/search/types.ts +26 -0
- package/src/messaging/provider-types.ts +2 -0
- package/src/messaging/providers/sms/adapter.ts +204 -0
- package/src/messaging/providers/sms/client.ts +93 -0
- package/src/messaging/providers/sms/types.ts +7 -0
- package/src/permissions/checker.ts +16 -2
- package/src/permissions/prompter.ts +14 -3
- package/src/permissions/trust-store.ts +7 -0
- package/src/runtime/approval-message-composer.ts +143 -0
- package/src/runtime/channel-approvals.ts +29 -7
- package/src/runtime/channel-guardian-service.ts +44 -18
- package/src/runtime/channel-readiness-service.ts +292 -0
- package/src/runtime/channel-readiness-types.ts +29 -0
- package/src/runtime/gateway-client.ts +2 -1
- package/src/runtime/http-server.ts +65 -28
- package/src/runtime/http-types.ts +3 -0
- package/src/runtime/routes/call-routes.ts +2 -1
- package/src/runtime/routes/channel-routes.ts +237 -103
- package/src/runtime/routes/run-routes.ts +7 -1
- package/src/runtime/run-orchestrator.ts +43 -3
- package/src/security/secret-scanner.ts +218 -0
- package/src/skills/frontmatter.ts +63 -0
- package/src/skills/slash-commands.ts +23 -0
- package/src/skills/vellum-catalog-remote.ts +107 -0
- package/src/tools/assets/materialize.ts +2 -2
- package/src/tools/browser/auto-navigate.ts +132 -24
- package/src/tools/browser/browser-manager.ts +67 -61
- package/src/tools/calls/call-start.ts +1 -0
- package/src/tools/claude-code/claude-code.ts +55 -3
- package/src/tools/credentials/vault.ts +1 -1
- package/src/tools/execution-target.ts +11 -1
- package/src/tools/executor.ts +10 -2
- package/src/tools/network/web-search.ts +1 -1
- package/src/tools/skills/vellum-catalog.ts +61 -156
- package/src/tools/terminal/parser.ts +21 -5
- package/src/tools/types.ts +2 -0
- package/src/twitter/router.ts +1 -1
- package/src/util/platform.ts +43 -1
- package/src/util/retry.ts +4 -4
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import { getPendingConfirmationsByConversation, getRun } from '../memory/runs-store.js';
|
|
14
14
|
import type { PendingRunInfo } from '../memory/runs-store.js';
|
|
15
15
|
import { addRule } from '../permissions/trust-store.js';
|
|
16
|
+
import { getTool } from '../tools/registry.js';
|
|
16
17
|
import type { RunOrchestrator } from './run-orchestrator.js';
|
|
17
18
|
import { DEFAULT_APPROVAL_ACTIONS } from './channel-approval-types.js';
|
|
18
19
|
import type {
|
|
@@ -20,6 +21,7 @@ import type {
|
|
|
20
21
|
ApprovalUIMetadata,
|
|
21
22
|
ApprovalDecisionResult,
|
|
22
23
|
} from './channel-approval-types.js';
|
|
24
|
+
import { composeApprovalMessage } from './approval-message-composer.js';
|
|
23
25
|
|
|
24
26
|
// ---------------------------------------------------------------------------
|
|
25
27
|
// 1. Detect pending confirmations and build prompt
|
|
@@ -46,13 +48,17 @@ export function getChannelApprovalPrompt(
|
|
|
46
48
|
* Internal helper: turn a PendingRunInfo into a ChannelApprovalPrompt.
|
|
47
49
|
*/
|
|
48
50
|
function buildPromptFromRunInfo(info: PendingRunInfo): ChannelApprovalPrompt {
|
|
49
|
-
const promptText =
|
|
51
|
+
const promptText = composeApprovalMessage({
|
|
52
|
+
scenario: 'standard_prompt',
|
|
53
|
+
toolName: info.toolName,
|
|
54
|
+
});
|
|
50
55
|
|
|
51
56
|
// Hide "approve always" when persistent trust rules are disallowed for this invocation.
|
|
52
57
|
const actions = info.persistentDecisionsAllowed === false
|
|
53
58
|
? DEFAULT_APPROVAL_ACTIONS.filter((a) => a.id !== 'approve_always')
|
|
54
59
|
: [...DEFAULT_APPROVAL_ACTIONS];
|
|
55
60
|
|
|
61
|
+
// Plain-text fallback must remain parser-compatible (contains "yes"/"always"/"no" keywords).
|
|
56
62
|
const plainTextFallback = info.persistentDecisionsAllowed === false
|
|
57
63
|
? `${promptText}\n\nReply "yes" to approve or "no" to reject.`
|
|
58
64
|
: `${promptText}\n\nReply "yes" to approve once, "always" to approve always, or "no" to reject.`;
|
|
@@ -101,11 +107,17 @@ export function handleChannelDecision(
|
|
|
101
107
|
conversationId: string,
|
|
102
108
|
decision: ApprovalDecisionResult,
|
|
103
109
|
orchestrator: RunOrchestrator,
|
|
110
|
+
decisionContext?: string,
|
|
104
111
|
): HandleDecisionResult {
|
|
105
112
|
const pending = getPendingConfirmationsByConversation(conversationId);
|
|
106
113
|
if (pending.length === 0) return { applied: false };
|
|
107
114
|
|
|
108
|
-
|
|
115
|
+
// Callback-based decisions include a run ID and must resolve to that exact
|
|
116
|
+
// pending confirmation. Plain-text decisions still apply to the first prompt.
|
|
117
|
+
const info = decision.runId
|
|
118
|
+
? pending.find((candidate) => candidate.runId === decision.runId)
|
|
119
|
+
: pending[0];
|
|
120
|
+
if (!info) return { applied: false };
|
|
109
121
|
|
|
110
122
|
if (decision.action === 'approve_always') {
|
|
111
123
|
// Only persist a trust rule when the confirmation explicitly allows persistence
|
|
@@ -121,8 +133,13 @@ export function handleChannelDecision(
|
|
|
121
133
|
) {
|
|
122
134
|
const pattern = confirmation.allowlistOptions[0].pattern;
|
|
123
135
|
const scope = confirmation.scopeOptions[0].scope;
|
|
136
|
+
// Only persist executionTarget for skill-origin tools — core tools don't
|
|
137
|
+
// set it in their PolicyContext, so a persisted value would prevent the
|
|
138
|
+
// rule from ever matching on subsequent permission checks.
|
|
139
|
+
const tool = getTool(confirmation.toolName);
|
|
140
|
+
const executionTarget = tool?.origin === 'skill' ? confirmation.executionTarget : undefined;
|
|
124
141
|
addRule(confirmation.toolName, pattern, scope, 'allow', 100, {
|
|
125
|
-
executionTarget
|
|
142
|
+
executionTarget,
|
|
126
143
|
});
|
|
127
144
|
}
|
|
128
145
|
// When persistence is not allowed or options are missing, the decision
|
|
@@ -131,7 +148,9 @@ export function handleChannelDecision(
|
|
|
131
148
|
|
|
132
149
|
// Map channel-level action to the permission system's UserDecision type.
|
|
133
150
|
const userDecision = decision.action === 'reject' ? 'deny' as const : 'allow' as const;
|
|
134
|
-
const result =
|
|
151
|
+
const result = decisionContext === undefined
|
|
152
|
+
? orchestrator.submitDecision(info.runId, userDecision)
|
|
153
|
+
: orchestrator.submitDecision(info.runId, userDecision, decisionContext);
|
|
135
154
|
|
|
136
155
|
return {
|
|
137
156
|
applied: result === 'applied',
|
|
@@ -152,8 +171,11 @@ export function buildGuardianApprovalPrompt(
|
|
|
152
171
|
info: PendingRunInfo,
|
|
153
172
|
requesterIdentifier: string,
|
|
154
173
|
): ChannelApprovalPrompt {
|
|
155
|
-
const promptText =
|
|
156
|
-
|
|
174
|
+
const promptText = composeApprovalMessage({
|
|
175
|
+
scenario: 'guardian_prompt',
|
|
176
|
+
toolName: info.toolName,
|
|
177
|
+
requesterIdentifier,
|
|
178
|
+
});
|
|
157
179
|
|
|
158
180
|
// Guardian approvals are always one-time decisions — "approve always"
|
|
159
181
|
// doesn't make sense when the guardian is approving on behalf of someone else.
|
|
@@ -197,7 +219,7 @@ export function channelSupportsRichApprovalUI(channel: string): boolean {
|
|
|
197
219
|
export function buildReminderPrompt(
|
|
198
220
|
pendingPrompt: ChannelApprovalPrompt,
|
|
199
221
|
): ChannelApprovalPrompt {
|
|
200
|
-
const reminderPrefix =
|
|
222
|
+
const reminderPrefix = composeApprovalMessage({ scenario: 'reminder_prompt' });
|
|
201
223
|
return {
|
|
202
224
|
promptText: `${reminderPrefix}\n\n${pendingPrompt.promptText}`,
|
|
203
225
|
actions: pendingPrompt.actions,
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
resetRateLimit,
|
|
21
21
|
} from '../memory/channel-guardian-store.js';
|
|
22
22
|
import type { GuardianBinding } from '../memory/channel-guardian-store.js';
|
|
23
|
+
import { composeApprovalMessage } from './approval-message-composer.js';
|
|
23
24
|
|
|
24
25
|
// ---------------------------------------------------------------------------
|
|
25
26
|
// Constants
|
|
@@ -44,6 +45,8 @@ const RATE_LIMIT_LOCKOUT_MS = 30 * 60 * 1000;
|
|
|
44
45
|
export interface CreateChallengeResult {
|
|
45
46
|
challengeId: string;
|
|
46
47
|
secret: string;
|
|
48
|
+
verifyCommand: string;
|
|
49
|
+
ttlSeconds: number;
|
|
47
50
|
instruction: string;
|
|
48
51
|
}
|
|
49
52
|
|
|
@@ -63,19 +66,6 @@ function hashSecret(secret: string): string {
|
|
|
63
66
|
// Service
|
|
64
67
|
// ---------------------------------------------------------------------------
|
|
65
68
|
|
|
66
|
-
/**
|
|
67
|
-
* Build a human-readable channel label for use in guardian challenge
|
|
68
|
-
* instructions. Avoids hardcoding "Telegram" so SMS and future
|
|
69
|
-
* channels get appropriate wording.
|
|
70
|
-
*/
|
|
71
|
-
function channelLabel(channel: string): string {
|
|
72
|
-
switch (channel) {
|
|
73
|
-
case 'telegram': return 'Telegram';
|
|
74
|
-
case 'sms': return 'SMS';
|
|
75
|
-
default: return channel;
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
69
|
/**
|
|
80
70
|
* Create a new verification challenge for a guardian candidate.
|
|
81
71
|
*
|
|
@@ -102,12 +92,19 @@ export function createVerificationChallenge(
|
|
|
102
92
|
createdBySessionId: sessionId,
|
|
103
93
|
});
|
|
104
94
|
|
|
105
|
-
const
|
|
95
|
+
const verifyCommand = `/guardian_verify ${secret}`;
|
|
96
|
+
const ttlSeconds = CHALLENGE_TTL_MS / 1000;
|
|
106
97
|
|
|
107
98
|
return {
|
|
108
99
|
challengeId,
|
|
109
100
|
secret,
|
|
110
|
-
|
|
101
|
+
verifyCommand,
|
|
102
|
+
ttlSeconds,
|
|
103
|
+
instruction: composeApprovalMessage({
|
|
104
|
+
scenario: 'guardian_verify_challenge_setup',
|
|
105
|
+
verifyCommand,
|
|
106
|
+
ttlSeconds,
|
|
107
|
+
}),
|
|
111
108
|
};
|
|
112
109
|
}
|
|
113
110
|
|
|
@@ -129,13 +126,21 @@ export function validateAndConsumeChallenge(
|
|
|
129
126
|
secret: string,
|
|
130
127
|
actorExternalUserId: string,
|
|
131
128
|
actorChatId: string,
|
|
129
|
+
actorUsername?: string,
|
|
130
|
+
actorDisplayName?: string,
|
|
132
131
|
): ValidateChallengeResult {
|
|
133
132
|
// ── Rate-limit check ──
|
|
134
133
|
const existing = getRateLimit(assistantId, channel, actorExternalUserId, actorChatId);
|
|
135
134
|
if (existing && existing.lockedUntil !== null && Date.now() < existing.lockedUntil) {
|
|
136
135
|
// Use the same generic failure message to avoid leaking whether the
|
|
137
136
|
// actor is rate-limited vs. the code is genuinely wrong.
|
|
138
|
-
return {
|
|
137
|
+
return {
|
|
138
|
+
success: false,
|
|
139
|
+
reason: composeApprovalMessage({
|
|
140
|
+
scenario: 'guardian_verify_failed',
|
|
141
|
+
failureReason: 'The verification code is invalid or has expired.',
|
|
142
|
+
}),
|
|
143
|
+
};
|
|
139
144
|
}
|
|
140
145
|
|
|
141
146
|
const challengeHash = hashSecret(secret);
|
|
@@ -146,7 +151,13 @@ export function validateAndConsumeChallenge(
|
|
|
146
151
|
assistantId, channel, actorExternalUserId, actorChatId,
|
|
147
152
|
RATE_LIMIT_WINDOW_MS, RATE_LIMIT_MAX_ATTEMPTS, RATE_LIMIT_LOCKOUT_MS,
|
|
148
153
|
);
|
|
149
|
-
return {
|
|
154
|
+
return {
|
|
155
|
+
success: false,
|
|
156
|
+
reason: composeApprovalMessage({
|
|
157
|
+
scenario: 'guardian_verify_failed',
|
|
158
|
+
failureReason: 'The verification code is invalid or has expired.',
|
|
159
|
+
}),
|
|
160
|
+
};
|
|
150
161
|
}
|
|
151
162
|
|
|
152
163
|
if (Date.now() > challenge.expiresAt) {
|
|
@@ -154,7 +165,13 @@ export function validateAndConsumeChallenge(
|
|
|
154
165
|
assistantId, channel, actorExternalUserId, actorChatId,
|
|
155
166
|
RATE_LIMIT_WINDOW_MS, RATE_LIMIT_MAX_ATTEMPTS, RATE_LIMIT_LOCKOUT_MS,
|
|
156
167
|
);
|
|
157
|
-
return {
|
|
168
|
+
return {
|
|
169
|
+
success: false,
|
|
170
|
+
reason: composeApprovalMessage({
|
|
171
|
+
scenario: 'guardian_verify_failed',
|
|
172
|
+
failureReason: 'The verification code is invalid or has expired.',
|
|
173
|
+
}),
|
|
174
|
+
};
|
|
158
175
|
}
|
|
159
176
|
|
|
160
177
|
// Consume the challenge so it cannot be reused
|
|
@@ -166,6 +183,14 @@ export function validateAndConsumeChallenge(
|
|
|
166
183
|
// Revoke any existing active binding before creating a new one
|
|
167
184
|
storeRevokeBinding(assistantId, channel);
|
|
168
185
|
|
|
186
|
+
const metadata: Record<string, string> = {};
|
|
187
|
+
if (actorUsername && actorUsername.trim().length > 0) {
|
|
188
|
+
metadata.username = actorUsername.trim();
|
|
189
|
+
}
|
|
190
|
+
if (actorDisplayName && actorDisplayName.trim().length > 0) {
|
|
191
|
+
metadata.displayName = actorDisplayName.trim();
|
|
192
|
+
}
|
|
193
|
+
|
|
169
194
|
// Create the new guardian binding
|
|
170
195
|
const binding = createBinding({
|
|
171
196
|
assistantId,
|
|
@@ -173,6 +198,7 @@ export function validateAndConsumeChallenge(
|
|
|
173
198
|
guardianExternalUserId: actorExternalUserId,
|
|
174
199
|
guardianDeliveryChatId: actorChatId,
|
|
175
200
|
verifiedVia: 'challenge',
|
|
201
|
+
metadataJson: Object.keys(metadata).length > 0 ? JSON.stringify(metadata) : null,
|
|
176
202
|
});
|
|
177
203
|
|
|
178
204
|
return { success: true, bindingId: binding.id };
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ChannelId,
|
|
3
|
+
ChannelProbe,
|
|
4
|
+
ChannelReadinessSnapshot,
|
|
5
|
+
ReadinessCheckResult,
|
|
6
|
+
} from './channel-readiness-types.js';
|
|
7
|
+
import {
|
|
8
|
+
hasTwilioCredentials,
|
|
9
|
+
getTollFreeVerificationStatus,
|
|
10
|
+
getPhoneNumberSid,
|
|
11
|
+
} from '../calls/twilio-rest.js';
|
|
12
|
+
import { getSecureKey } from '../security/secure-keys.js';
|
|
13
|
+
import { loadRawConfig } from '../config/loader.js';
|
|
14
|
+
|
|
15
|
+
/** Remote check results are cached for 5 minutes before being considered stale. */
|
|
16
|
+
export const REMOTE_TTL_MS = 5 * 60 * 1000;
|
|
17
|
+
|
|
18
|
+
// ── SMS Probe ───────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
function hasIngressConfigured(): boolean {
|
|
21
|
+
try {
|
|
22
|
+
const raw = loadRawConfig();
|
|
23
|
+
const ingress = (raw?.ingress ?? {}) as Record<string, unknown>;
|
|
24
|
+
const publicBaseUrl = (ingress.publicBaseUrl as string) ?? '';
|
|
25
|
+
const enabled = (ingress.enabled as boolean | undefined) ?? (publicBaseUrl ? true : false);
|
|
26
|
+
return enabled && publicBaseUrl.length > 0;
|
|
27
|
+
} catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const smsProbe: ChannelProbe = {
|
|
33
|
+
channel: 'sms',
|
|
34
|
+
runLocalChecks(): ReadinessCheckResult[] {
|
|
35
|
+
const results: ReadinessCheckResult[] = [];
|
|
36
|
+
|
|
37
|
+
const hasCreds = hasTwilioCredentials();
|
|
38
|
+
results.push({
|
|
39
|
+
name: 'twilio_credentials',
|
|
40
|
+
passed: hasCreds,
|
|
41
|
+
message: hasCreds
|
|
42
|
+
? 'Twilio credentials are configured'
|
|
43
|
+
: 'Twilio Account SID and Auth Token are not configured',
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
let hasPhone = !!process.env.TWILIO_PHONE_NUMBER;
|
|
47
|
+
if (!hasPhone) {
|
|
48
|
+
try {
|
|
49
|
+
const raw = loadRawConfig();
|
|
50
|
+
const smsConfig = (raw?.sms ?? {}) as Record<string, unknown>;
|
|
51
|
+
hasPhone = !!(smsConfig.phoneNumber as string);
|
|
52
|
+
} catch { /* ignore */ }
|
|
53
|
+
}
|
|
54
|
+
if (!hasPhone) {
|
|
55
|
+
hasPhone = !!getSecureKey('credential:twilio:phone_number');
|
|
56
|
+
}
|
|
57
|
+
results.push({
|
|
58
|
+
name: 'phone_number',
|
|
59
|
+
passed: hasPhone,
|
|
60
|
+
message: hasPhone
|
|
61
|
+
? 'Phone number is assigned'
|
|
62
|
+
: 'No phone number assigned',
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const hasIngress = hasIngressConfigured();
|
|
66
|
+
results.push({
|
|
67
|
+
name: 'ingress',
|
|
68
|
+
passed: hasIngress,
|
|
69
|
+
message: hasIngress
|
|
70
|
+
? 'Public ingress URL is configured'
|
|
71
|
+
: 'Public ingress URL is not configured or disabled',
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
return results;
|
|
75
|
+
},
|
|
76
|
+
async runRemoteChecks(): Promise<ReadinessCheckResult[]> {
|
|
77
|
+
if (!hasTwilioCredentials()) return [];
|
|
78
|
+
|
|
79
|
+
const accountSid = getSecureKey('credential:twilio:account_sid');
|
|
80
|
+
const authToken = getSecureKey('credential:twilio:auth_token');
|
|
81
|
+
if (!accountSid || !authToken) return [];
|
|
82
|
+
|
|
83
|
+
// Resolve the assigned phone number using fallback chain
|
|
84
|
+
const raw = loadRawConfig();
|
|
85
|
+
const smsConfig = (raw?.sms ?? {}) as Record<string, unknown>;
|
|
86
|
+
const phoneNumber = (smsConfig.phoneNumber as string)
|
|
87
|
+
|| getSecureKey('credential:twilio:phone_number')
|
|
88
|
+
|| process.env.TWILIO_PHONE_NUMBER
|
|
89
|
+
|| '';
|
|
90
|
+
if (!phoneNumber) return [];
|
|
91
|
+
|
|
92
|
+
// Only toll-free numbers need verification checks
|
|
93
|
+
const tollFreePrefixes = ['+1800', '+1833', '+1844', '+1855', '+1866', '+1877', '+1888'];
|
|
94
|
+
const isTollFree = tollFreePrefixes.some((prefix) => phoneNumber.startsWith(prefix));
|
|
95
|
+
if (!isTollFree) return [];
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
const phoneSid = await getPhoneNumberSid(accountSid, authToken, phoneNumber);
|
|
99
|
+
if (!phoneSid) {
|
|
100
|
+
return [{
|
|
101
|
+
name: 'toll_free_verification',
|
|
102
|
+
passed: false,
|
|
103
|
+
message: `Phone number ${phoneNumber} not found on Twilio account`,
|
|
104
|
+
}];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const verification = await getTollFreeVerificationStatus(accountSid, authToken, phoneSid);
|
|
108
|
+
if (!verification) {
|
|
109
|
+
return [{
|
|
110
|
+
name: 'toll_free_verification',
|
|
111
|
+
passed: false,
|
|
112
|
+
message: 'No toll-free verification submitted. Verification is required for SMS sending.',
|
|
113
|
+
}];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const approved = verification.status === 'TWILIO_APPROVED';
|
|
117
|
+
return [{
|
|
118
|
+
name: 'toll_free_verification',
|
|
119
|
+
passed: approved,
|
|
120
|
+
message: `toll_free_verification: ${verification.status}`,
|
|
121
|
+
}];
|
|
122
|
+
} catch (err) {
|
|
123
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
124
|
+
return [{
|
|
125
|
+
name: 'toll_free_verification',
|
|
126
|
+
passed: false,
|
|
127
|
+
message: `Failed to check toll-free verification: ${message}`,
|
|
128
|
+
}];
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
// ── Telegram Probe ──────────────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
const telegramProbe: ChannelProbe = {
|
|
136
|
+
channel: 'telegram',
|
|
137
|
+
runLocalChecks(): ReadinessCheckResult[] {
|
|
138
|
+
const results: ReadinessCheckResult[] = [];
|
|
139
|
+
|
|
140
|
+
const hasBotToken = !!getSecureKey('credential:telegram:bot_token');
|
|
141
|
+
results.push({
|
|
142
|
+
name: 'bot_token',
|
|
143
|
+
passed: hasBotToken,
|
|
144
|
+
message: hasBotToken
|
|
145
|
+
? 'Telegram bot token is configured'
|
|
146
|
+
: 'Telegram bot token is not configured',
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const hasWebhookSecret = !!getSecureKey('credential:telegram:webhook_secret');
|
|
150
|
+
results.push({
|
|
151
|
+
name: 'webhook_secret',
|
|
152
|
+
passed: hasWebhookSecret,
|
|
153
|
+
message: hasWebhookSecret
|
|
154
|
+
? 'Telegram webhook secret is configured'
|
|
155
|
+
: 'Telegram webhook secret is not configured',
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const hasIngress = hasIngressConfigured();
|
|
159
|
+
results.push({
|
|
160
|
+
name: 'ingress',
|
|
161
|
+
passed: hasIngress,
|
|
162
|
+
message: hasIngress
|
|
163
|
+
? 'Public ingress URL is configured'
|
|
164
|
+
: 'Public ingress URL is not configured or disabled',
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
return results;
|
|
168
|
+
},
|
|
169
|
+
// Telegram has no remote checks currently
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
// ── Service ─────────────────────────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
export class ChannelReadinessService {
|
|
175
|
+
private probes = new Map<ChannelId, ChannelProbe>();
|
|
176
|
+
private snapshots = new Map<ChannelId, ChannelReadinessSnapshot>();
|
|
177
|
+
|
|
178
|
+
registerProbe(probe: ChannelProbe): void {
|
|
179
|
+
this.probes.set(probe.channel, probe);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Get readiness snapshots for the specified channel (or all registered channels).
|
|
184
|
+
* Local checks always run inline. Remote checks run only when `includeRemote`
|
|
185
|
+
* is true and the cache is stale or missing.
|
|
186
|
+
*/
|
|
187
|
+
async getReadiness(
|
|
188
|
+
channel?: ChannelId,
|
|
189
|
+
includeRemote?: boolean,
|
|
190
|
+
): Promise<ChannelReadinessSnapshot[]> {
|
|
191
|
+
const channels = channel
|
|
192
|
+
? [channel]
|
|
193
|
+
: Array.from(this.probes.keys());
|
|
194
|
+
|
|
195
|
+
const results: ChannelReadinessSnapshot[] = [];
|
|
196
|
+
for (const ch of channels) {
|
|
197
|
+
const probe = this.probes.get(ch);
|
|
198
|
+
if (!probe) {
|
|
199
|
+
results.push(this.unsupportedSnapshot(ch));
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const localChecks = probe.runLocalChecks();
|
|
204
|
+
let remoteChecks: ReadinessCheckResult[] | undefined;
|
|
205
|
+
let remoteChecksFreshlyFetched = false;
|
|
206
|
+
let stale = false;
|
|
207
|
+
|
|
208
|
+
const cached = this.snapshots.get(ch);
|
|
209
|
+
const now = Date.now();
|
|
210
|
+
|
|
211
|
+
if (includeRemote && probe.runRemoteChecks) {
|
|
212
|
+
const cacheExpired = !cached || !cached.remoteChecks || (now - cached.checkedAt) >= REMOTE_TTL_MS;
|
|
213
|
+
if (cacheExpired) {
|
|
214
|
+
remoteChecks = await probe.runRemoteChecks();
|
|
215
|
+
remoteChecksFreshlyFetched = true;
|
|
216
|
+
} else {
|
|
217
|
+
// Reuse cached remote checks
|
|
218
|
+
remoteChecks = cached.remoteChecks;
|
|
219
|
+
}
|
|
220
|
+
} else if (cached?.remoteChecks) {
|
|
221
|
+
// Surface cached remote checks even when not explicitly requested,
|
|
222
|
+
// but mark stale if TTL has elapsed
|
|
223
|
+
remoteChecks = cached.remoteChecks;
|
|
224
|
+
stale = (now - cached.checkedAt) >= REMOTE_TTL_MS;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const allLocalPassed = localChecks.every((c) => c.passed);
|
|
228
|
+
const allRemotePassed = remoteChecks ? remoteChecks.every((c) => c.passed) : true;
|
|
229
|
+
const ready = allLocalPassed && allRemotePassed;
|
|
230
|
+
|
|
231
|
+
const reasons: Array<{ code: string; text: string }> = [];
|
|
232
|
+
for (const check of localChecks) {
|
|
233
|
+
if (!check.passed) {
|
|
234
|
+
reasons.push({ code: check.name, text: check.message });
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
if (remoteChecks) {
|
|
238
|
+
for (const check of remoteChecks) {
|
|
239
|
+
if (!check.passed) {
|
|
240
|
+
reasons.push({ code: check.name, text: check.message });
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const snapshot: ChannelReadinessSnapshot = {
|
|
246
|
+
channel: ch,
|
|
247
|
+
ready,
|
|
248
|
+
checkedAt: (remoteChecks && cached && !remoteChecksFreshlyFetched) ? cached.checkedAt : now,
|
|
249
|
+
stale,
|
|
250
|
+
reasons,
|
|
251
|
+
localChecks,
|
|
252
|
+
remoteChecks,
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
this.snapshots.set(ch, snapshot);
|
|
256
|
+
results.push(snapshot);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return results;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Clear cached snapshot for a specific channel, forcing re-evaluation on next call. */
|
|
263
|
+
invalidateChannel(channel: ChannelId): void {
|
|
264
|
+
this.snapshots.delete(channel);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Clear all cached snapshots. */
|
|
268
|
+
invalidateAll(): void {
|
|
269
|
+
this.snapshots.clear();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private unsupportedSnapshot(channel: ChannelId): ChannelReadinessSnapshot {
|
|
273
|
+
return {
|
|
274
|
+
channel,
|
|
275
|
+
ready: false,
|
|
276
|
+
checkedAt: Date.now(),
|
|
277
|
+
stale: false,
|
|
278
|
+
reasons: [{ code: 'unsupported_channel', text: `Channel ${channel} is not supported` }],
|
|
279
|
+
localChecks: [],
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ── Factory ─────────────────────────────────────────────────────────────────
|
|
285
|
+
|
|
286
|
+
/** Create a service instance with built-in SMS and Telegram probes registered. */
|
|
287
|
+
export function createReadinessService(): ChannelReadinessService {
|
|
288
|
+
const service = new ChannelReadinessService();
|
|
289
|
+
service.registerProbe(smsProbe);
|
|
290
|
+
service.registerProbe(telegramProbe);
|
|
291
|
+
return service;
|
|
292
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Channel readiness types — reusable primitive for all channels.
|
|
2
|
+
|
|
3
|
+
/** Logical channel identifier. Well-known channels have literal types; custom channels use string. */
|
|
4
|
+
export type ChannelId = 'sms' | 'telegram' | string;
|
|
5
|
+
|
|
6
|
+
/** Result of a single readiness check (local or remote). */
|
|
7
|
+
export interface ReadinessCheckResult {
|
|
8
|
+
name: string;
|
|
9
|
+
passed: boolean;
|
|
10
|
+
message: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Point-in-time snapshot of a channel's readiness state. */
|
|
14
|
+
export interface ChannelReadinessSnapshot {
|
|
15
|
+
channel: ChannelId;
|
|
16
|
+
ready: boolean;
|
|
17
|
+
checkedAt: number;
|
|
18
|
+
stale: boolean;
|
|
19
|
+
reasons: Array<{ code: string; text: string }>;
|
|
20
|
+
localChecks: ReadinessCheckResult[];
|
|
21
|
+
remoteChecks?: ReadinessCheckResult[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Probe interface that channels implement to provide readiness checks. */
|
|
25
|
+
export interface ChannelProbe {
|
|
26
|
+
channel: ChannelId;
|
|
27
|
+
runLocalChecks(): ReadinessCheckResult[];
|
|
28
|
+
runRemoteChecks?(): Promise<ReadinessCheckResult[]>;
|
|
29
|
+
}
|
|
@@ -52,7 +52,8 @@ export async function deliverApprovalPrompt(
|
|
|
52
52
|
chatId: string,
|
|
53
53
|
text: string,
|
|
54
54
|
approval: ApprovalUIMetadata,
|
|
55
|
+
assistantId?: string,
|
|
55
56
|
bearerToken?: string,
|
|
56
57
|
): Promise<void> {
|
|
57
|
-
await deliverChannelReply(callbackUrl, { chatId, text, approval }, bearerToken);
|
|
58
|
+
await deliverChannelReply(callbackUrl, { chatId, text, approval, assistantId }, bearerToken);
|
|
58
59
|
}
|