@vellumai/vellum-gateway 0.7.2 → 0.7.3
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/ARCHITECTURE.md +20 -21
- package/README.md +6 -6
- package/package.json +1 -1
- package/src/__tests__/config-file-watcher.test.ts +1 -1
- package/src/__tests__/contact-prompt-submit.test.ts +349 -0
- package/src/__tests__/ipc-route-policy.test.ts +24 -0
- package/src/__tests__/nonbash-trust-rule-overrides.test.ts +50 -0
- package/src/__tests__/remote-feature-flag-sync.test.ts +16 -14
- package/src/__tests__/slack-display-name.test.ts +6 -2
- package/src/__tests__/slack-normalize.test.ts +36 -56
- package/src/__tests__/slack-socket-mode-thread-tracking.test.ts +4 -2
- package/src/__tests__/telegram-webhook-manager.test.ts +8 -25
- package/src/__tests__/twilio-webhooks.test.ts +2 -6
- package/src/__tests__/upsert-verified-contact-channel.test.ts +173 -0
- package/src/auth/guardian-bootstrap.ts +49 -0
- package/src/auth/ipc-route-policy.ts +5 -0
- package/src/db/contact-store.ts +27 -1
- package/src/email/register-callback.test.ts +4 -4
- package/src/email/register-callback.ts +12 -16
- package/src/feature-flag-registry.json +27 -3
- package/src/handlers/handle-inbound.ts +12 -0
- package/src/http/routes/contact-prompt.ts +134 -23
- package/src/http/routes/contacts-control-plane-proxy.ts +34 -5
- package/src/http/routes/ipc-runtime-proxy.ts +18 -0
- package/src/http/routes/twilio-voice-webhook.test.ts +22 -1
- package/src/http/routes/twilio-voice-webhook.ts +53 -0
- package/src/index.ts +4 -2
- package/src/ipc/velay-handlers.ts +31 -0
- package/src/remote-feature-flag-sync.ts +10 -8
- package/src/risk/command-registry/commands/assistant.ts +1 -0
- package/src/risk/skill-risk-classifier.ts +12 -3
- package/src/runtime/client.ts +25 -12
- package/src/slack/normalize.test.ts +3 -3
- package/src/slack/normalize.ts +6 -69
- package/src/slack/socket-mode.ts +1 -5
- package/src/telegram/webhook-manager.ts +9 -13
- package/src/velay/client.ts +27 -16
- package/src/verification/contact-helpers.ts +6 -3
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
findPendingPhoneSession,
|
|
21
21
|
gatherVerificationTwiml,
|
|
22
22
|
} from "../../voice/verification.js";
|
|
23
|
+
import { ContactStore } from "../../db/contact-store.js";
|
|
23
24
|
|
|
24
25
|
const log = getLogger("twilio-voice-webhook");
|
|
25
26
|
|
|
@@ -29,6 +30,16 @@ const REJECT_TWIML =
|
|
|
29
30
|
|
|
30
31
|
const TWIML_HEADERS = { "Content-Type": "text/xml" };
|
|
31
32
|
|
|
33
|
+
/** Escapes XML special characters so contact display names are safe to embed in TwiML. */
|
|
34
|
+
function escapeXml(str: string): string {
|
|
35
|
+
return str
|
|
36
|
+
.replace(/&/g, "&")
|
|
37
|
+
.replace(/</g, "<")
|
|
38
|
+
.replace(/>/g, ">")
|
|
39
|
+
.replace(/"/g, """)
|
|
40
|
+
.replace(/'/g, "'");
|
|
41
|
+
}
|
|
42
|
+
|
|
32
43
|
export function createTwilioVoiceWebhookHandler(
|
|
33
44
|
config: GatewayConfig,
|
|
34
45
|
caches?: TwilioValidationCaches & { configFile?: ConfigFileCache },
|
|
@@ -123,6 +134,48 @@ export function createTwilioVoiceWebhookHandler(
|
|
|
123
134
|
"Failed to check pending verification session — falling through to assistant",
|
|
124
135
|
);
|
|
125
136
|
}
|
|
137
|
+
|
|
138
|
+
// ── Known-but-unverified caller guidance ─────────────────────────────
|
|
139
|
+
// If the caller's number is registered under a contact's phone channel
|
|
140
|
+
// but has not yet passed DTMF verification, intercept with a helpful
|
|
141
|
+
// message rather than letting the runtime treat them as an unknown caller.
|
|
142
|
+
if (params.From) {
|
|
143
|
+
try {
|
|
144
|
+
const callerRecord = new ContactStore().getContactByPhoneNumber(
|
|
145
|
+
params.From,
|
|
146
|
+
);
|
|
147
|
+
// Only intercept genuinely unverified channels — not blocked ones.
|
|
148
|
+
// A blocked caller should fall through to the runtime's deny path
|
|
149
|
+
// rather than hearing a helpful verification script (which would
|
|
150
|
+
// both leak the contact name and weaken block semantics).
|
|
151
|
+
// The display name is intentionally included: the caller registered
|
|
152
|
+
// this number themselves, so disclosing their own name is expected.
|
|
153
|
+
const unverifiedStatuses = new Set(["unverified", "pending"]);
|
|
154
|
+
if (callerRecord && unverifiedStatuses.has(callerRecord.channel.status)) {
|
|
155
|
+
log.info(
|
|
156
|
+
{
|
|
157
|
+
callSid: params.CallSid,
|
|
158
|
+
contactId: callerRecord.contact.id,
|
|
159
|
+
channelStatus: callerRecord.channel.status,
|
|
160
|
+
},
|
|
161
|
+
"Known-but-unverified caller — returning verification guidance TwiML",
|
|
162
|
+
);
|
|
163
|
+
const name = escapeXml(callerRecord.contact.displayName);
|
|
164
|
+
const twiml =
|
|
165
|
+
`<?xml version="1.0" encoding="UTF-8"?><Response>` +
|
|
166
|
+
`<Say>This number is registered as ${name}'s phone but has not been verified yet. ` +
|
|
167
|
+
`To verify, open your assistant's contacts page, click Verify next to the phone channel, ` +
|
|
168
|
+
`and follow the prompts. Then call back once the verification session is active.</Say>` +
|
|
169
|
+
`</Response>`;
|
|
170
|
+
return new Response(twiml, { status: 200, headers: TWIML_HEADERS });
|
|
171
|
+
}
|
|
172
|
+
} catch (err) {
|
|
173
|
+
log.warn(
|
|
174
|
+
{ err, callSid: params.CallSid },
|
|
175
|
+
"Failed to check unverified caller — falling through to assistant",
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
126
179
|
}
|
|
127
180
|
|
|
128
181
|
try {
|
package/src/index.ts
CHANGED
|
@@ -166,6 +166,7 @@ import { featureFlagRoutes } from "./ipc/feature-flag-handlers.js";
|
|
|
166
166
|
import { thresholdRoutes } from "./ipc/threshold-handlers.js";
|
|
167
167
|
|
|
168
168
|
import { riskClassificationRoutes } from "./ipc/risk-classification-handlers.js";
|
|
169
|
+
import { createVelayRoutes } from "./ipc/velay-handlers.js";
|
|
169
170
|
import { refreshRouteSchema } from "./ipc/route-schema-cache.js";
|
|
170
171
|
import { AvatarChannelSyncer } from "./avatar-sync/avatar-channel-syncer.js";
|
|
171
172
|
import { AvatarSyncWatcher } from "./avatar-sync/avatar-sync-watcher.js";
|
|
@@ -673,8 +674,8 @@ async function main() {
|
|
|
673
674
|
path: /^\/v1\/contacts\/(?!invites$)([^/]+)$/,
|
|
674
675
|
method: "DELETE",
|
|
675
676
|
auth: "edge",
|
|
676
|
-
handler: (
|
|
677
|
-
contactsControlPlaneProxy.handleDeleteContact(
|
|
677
|
+
handler: (_req, params) =>
|
|
678
|
+
contactsControlPlaneProxy.handleDeleteContact(params[0]),
|
|
678
679
|
},
|
|
679
680
|
{
|
|
680
681
|
path: /^\/v1\/contacts\/([^/]+)$/,
|
|
@@ -2037,6 +2038,7 @@ async function main() {
|
|
|
2037
2038
|
...contactRoutes,
|
|
2038
2039
|
...thresholdRoutes,
|
|
2039
2040
|
...riskClassificationRoutes,
|
|
2041
|
+
...createVelayRoutes(velayTunnelClient),
|
|
2040
2042
|
]);
|
|
2041
2043
|
ipcServer.start();
|
|
2042
2044
|
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IPC route definitions for Velay tunnel status.
|
|
3
|
+
*
|
|
4
|
+
* Exports a factory that takes the optional VelayTunnelClient and returns
|
|
5
|
+
* a single `get_velay_status` IPC route. Returns disconnected/null when no
|
|
6
|
+
* client is configured (e.g. VELAY_BASE_URL not set).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { VelayTunnelClient } from "../velay/client.js";
|
|
10
|
+
import type { IpcRoute } from "./server.js";
|
|
11
|
+
|
|
12
|
+
export interface VelayStatus {
|
|
13
|
+
connected: boolean;
|
|
14
|
+
publicUrl: string | null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function createVelayRoutes(
|
|
18
|
+
velayTunnelClient: VelayTunnelClient | undefined,
|
|
19
|
+
): IpcRoute[] {
|
|
20
|
+
return [
|
|
21
|
+
{
|
|
22
|
+
method: "get_velay_status",
|
|
23
|
+
handler: (): VelayStatus => {
|
|
24
|
+
if (!velayTunnelClient) {
|
|
25
|
+
return { connected: false, publicUrl: null };
|
|
26
|
+
}
|
|
27
|
+
return velayTunnelClient.getStatus();
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
];
|
|
31
|
+
}
|
|
@@ -311,23 +311,25 @@ export class RemoteFeatureFlagSync {
|
|
|
311
311
|
return { status: "error" };
|
|
312
312
|
}
|
|
313
313
|
|
|
314
|
-
// Fall back to env vars when
|
|
314
|
+
// Fall back to env vars when managed pod credentials are not yet cached.
|
|
315
315
|
const platformUrl = (
|
|
316
316
|
platformUrlRaw?.trim() ||
|
|
317
317
|
process.env.VELLUM_PLATFORM_URL?.trim() ||
|
|
318
318
|
""
|
|
319
319
|
).replace(/\/+$/, "");
|
|
320
320
|
|
|
321
|
-
// Feature flag sync hits the public platform API
|
|
322
|
-
//
|
|
323
|
-
|
|
324
|
-
|
|
321
|
+
// Feature flag sync hits the public platform API and requires assistant
|
|
322
|
+
// API key auth.
|
|
323
|
+
const assistantCredential =
|
|
324
|
+
assistantApiKeyRaw?.trim() ||
|
|
325
|
+
process.env.ASSISTANT_API_KEY?.trim() ||
|
|
326
|
+
undefined;
|
|
325
327
|
|
|
326
|
-
if (!platformUrl || !
|
|
328
|
+
if (!platformUrl || !assistantCredential) {
|
|
327
329
|
log.debug(
|
|
328
330
|
{
|
|
329
331
|
hasPlatformUrl: !!platformUrl,
|
|
330
|
-
hasApiKey: !!
|
|
332
|
+
hasApiKey: !!assistantCredential,
|
|
331
333
|
},
|
|
332
334
|
"Remote feature flag sync skipped: missing credentials",
|
|
333
335
|
);
|
|
@@ -340,7 +342,7 @@ export class RemoteFeatureFlagSync {
|
|
|
340
342
|
const response = await fetchImpl(url, {
|
|
341
343
|
method: "GET",
|
|
342
344
|
headers: {
|
|
343
|
-
Authorization: `Api-Key ${
|
|
345
|
+
Authorization: `Api-Key ${assistantCredential}`,
|
|
344
346
|
Accept: "application/json",
|
|
345
347
|
},
|
|
346
348
|
signal: AbortSignal.timeout(10_000),
|
|
@@ -176,10 +176,18 @@ export class SkillLoadRiskClassifier implements RiskClassifier<SkillClassifierIn
|
|
|
176
176
|
let assessment: RiskAssessment;
|
|
177
177
|
|
|
178
178
|
switch (toolName) {
|
|
179
|
-
case "skill_load":
|
|
179
|
+
case "skill_load": {
|
|
180
|
+
// Skills with inline command expansions execute shell commands at load
|
|
181
|
+
// time via child_process.spawn, bypassing the normal bash-tool
|
|
182
|
+
// permission pipeline. Elevate to medium so the default auto-approve
|
|
183
|
+
// threshold (low) requires an explicit prompt instead of silently
|
|
184
|
+
// running embedded commands.
|
|
185
|
+
const hasExpansions = resolvedMetadata?.hasInlineExpansions === true;
|
|
180
186
|
assessment = {
|
|
181
|
-
riskLevel: "low",
|
|
182
|
-
reason:
|
|
187
|
+
riskLevel: hasExpansions ? "medium" : "low",
|
|
188
|
+
reason: hasExpansions
|
|
189
|
+
? "Skill load with inline command expansions (executes shell commands at load time)"
|
|
190
|
+
: "Skill load (default)",
|
|
183
191
|
scopeOptions: [],
|
|
184
192
|
matchType: "registry",
|
|
185
193
|
allowlistOptions: buildSkillLoadAllowlistOptions(
|
|
@@ -188,6 +196,7 @@ export class SkillLoadRiskClassifier implements RiskClassifier<SkillClassifierIn
|
|
|
188
196
|
),
|
|
189
197
|
};
|
|
190
198
|
break;
|
|
199
|
+
}
|
|
191
200
|
case "scaffold_managed_skill":
|
|
192
201
|
assessment = {
|
|
193
202
|
riskLevel: "high",
|
package/src/runtime/client.ts
CHANGED
|
@@ -219,6 +219,17 @@ export type RuntimeInboundResponse = {
|
|
|
219
219
|
};
|
|
220
220
|
/** When true, the runtime denied the inbound message (e.g. ACL rejection). */
|
|
221
221
|
denied?: boolean;
|
|
222
|
+
/**
|
|
223
|
+
* When a guardian approved an inbound voice access request, the contact that
|
|
224
|
+
* should be activated. The gateway writes the dual-write on behalf of the
|
|
225
|
+
* runtime so the assistant never triggers contact writes via IPC.
|
|
226
|
+
*/
|
|
227
|
+
activatedContact?: {
|
|
228
|
+
sourceChannel: string;
|
|
229
|
+
externalUserId: string;
|
|
230
|
+
externalChatId?: string;
|
|
231
|
+
displayName?: string;
|
|
232
|
+
};
|
|
222
233
|
/**
|
|
223
234
|
* A user-facing rejection message that the runtime could not deliver via
|
|
224
235
|
* the callback URL (e.g. due to auth failure). When present, the gateway
|
|
@@ -503,11 +514,10 @@ const TWILIO_RELAY_TOKEN_PLACEHOLDER = "__VELLUM_RELAY_TOKEN__";
|
|
|
503
514
|
* Resolve the public base URL as a WebSocket URL (`wss://…`).
|
|
504
515
|
*
|
|
505
516
|
* Sources (in priority order):
|
|
506
|
-
* 1. `
|
|
507
|
-
*
|
|
508
|
-
*
|
|
509
|
-
*
|
|
510
|
-
* on tunnel registration, or set manually for self-hosted.
|
|
517
|
+
* 1. `ingress.publicBaseUrl` from the config file — written by Velay
|
|
518
|
+
* after tunnel registration, or set manually for self-hosted.
|
|
519
|
+
* 2. `VELAY_BASE_URL` + platform assistant ID — fallback for platform
|
|
520
|
+
* sidecars before the config cache has observed the registered URL.
|
|
511
521
|
*
|
|
512
522
|
* Returns `undefined` when no source provides a value — the placeholder
|
|
513
523
|
* will remain in TwiML and Twilio will fail to connect, which is the
|
|
@@ -518,16 +528,19 @@ export function resolvePublicBaseWssUrl(
|
|
|
518
528
|
configFile?: ConfigFileCache,
|
|
519
529
|
platformAssistantId?: string,
|
|
520
530
|
): string | undefined {
|
|
531
|
+
const raw = configFile?.getString("ingress", "publicBaseUrl");
|
|
532
|
+
const normalized = normalizePublicBaseUrl(raw);
|
|
533
|
+
if (normalized) return normalized.replace(/^http(s?)/, "ws$1");
|
|
534
|
+
|
|
521
535
|
if (config.velayBaseUrl && platformAssistantId) {
|
|
522
536
|
const withPath =
|
|
523
537
|
config.velayBaseUrl.replace(/\/+$/, "") + "/" + platformAssistantId;
|
|
524
|
-
const
|
|
525
|
-
if (
|
|
538
|
+
const normalizedVelayUrl = normalizePublicBaseUrl(withPath);
|
|
539
|
+
if (normalizedVelayUrl) {
|
|
540
|
+
return normalizedVelayUrl.replace(/^http(s?)/, "ws$1");
|
|
541
|
+
}
|
|
526
542
|
}
|
|
527
|
-
|
|
528
|
-
const normalized = normalizePublicBaseUrl(raw);
|
|
529
|
-
if (!normalized) return undefined;
|
|
530
|
-
return normalized.replace(/^http(s?)/, "ws$1");
|
|
543
|
+
return undefined;
|
|
531
544
|
}
|
|
532
545
|
|
|
533
546
|
/**
|
|
@@ -597,7 +610,7 @@ export async function forwardTwilioVoiceWebhook(
|
|
|
597
610
|
} else {
|
|
598
611
|
log.error(
|
|
599
612
|
"TwiML contains public URL placeholder but no public base URL is configured. " +
|
|
600
|
-
"Twilio will fail to connect.
|
|
613
|
+
"Twilio will fail to connect. Wait for Velay tunnel registration or set ingress.publicBaseUrl.",
|
|
601
614
|
);
|
|
602
615
|
}
|
|
603
616
|
}
|
|
@@ -802,7 +802,7 @@ describe("attachment extraction in normalize functions", () => {
|
|
|
802
802
|
expect(result!.slackFiles).toBeDefined();
|
|
803
803
|
});
|
|
804
804
|
|
|
805
|
-
it("normalizes channel file_share mentions
|
|
805
|
+
it("normalizes channel file_share mentions and renders the bot mention", () => {
|
|
806
806
|
const config = makeConfig();
|
|
807
807
|
const event = makeChannelEvent({
|
|
808
808
|
subtype: "file_share",
|
|
@@ -821,11 +821,11 @@ describe("attachment extraction in normalize functions", () => {
|
|
|
821
821
|
config,
|
|
822
822
|
"UBOT",
|
|
823
823
|
undefined,
|
|
824
|
-
{ userLabels: { ULEO: "leo" } },
|
|
824
|
+
{ userLabels: { UBOT: "vex", ULEO: "leo" } },
|
|
825
825
|
);
|
|
826
826
|
|
|
827
827
|
expect(result).not.toBeNull();
|
|
828
|
-
expect(result!.event.message.content).toBe("@leo shared this");
|
|
828
|
+
expect(result!.event.message.content).toBe("@vex @leo shared this");
|
|
829
829
|
expect(result!.event.message.content).not.toContain("ULEO");
|
|
830
830
|
expect(result!.event.message.attachments).toHaveLength(1);
|
|
831
831
|
expect(result!.event.message.attachments![0]).toEqual({
|
package/src/slack/normalize.ts
CHANGED
|
@@ -1,8 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
renderSlackTextForModel,
|
|
3
|
-
stripLeadingSlackMentionFallback,
|
|
4
|
-
stripLeadingSlackUserMention,
|
|
5
|
-
} from "@vellumai/slack-text";
|
|
1
|
+
import { renderSlackTextForModel } from "@vellumai/slack-text";
|
|
6
2
|
import type { GatewayConfig } from "../config.js";
|
|
7
3
|
import { fetchImpl } from "../fetch.js";
|
|
8
4
|
import { resolveAssistant, isRejection } from "../routing/resolve-assistant.js";
|
|
@@ -281,56 +277,18 @@ export interface SlackMessageDeletedEvent {
|
|
|
281
277
|
}
|
|
282
278
|
|
|
283
279
|
export type SlackTextRenderContext = {
|
|
284
|
-
botUserId?: string;
|
|
285
280
|
userLabels?: Record<string, string>;
|
|
286
281
|
};
|
|
287
282
|
|
|
288
|
-
/**
|
|
289
|
-
* Strip leading bot-mention tokens from Slack message text.
|
|
290
|
-
*
|
|
291
|
-
* When the bot user ID is known, only that exact mention is stripped. Without a
|
|
292
|
-
* bot user ID, strip just one leading mention as an app_mention compatibility
|
|
293
|
-
* fallback.
|
|
294
|
-
*/
|
|
295
|
-
export function stripBotMention(text: string, botUserId?: string): string {
|
|
296
|
-
const stripped = botUserId
|
|
297
|
-
? stripLeadingSlackUserMention(text, botUserId)
|
|
298
|
-
: stripLeadingSlackMentionFallback(text);
|
|
299
|
-
return stripped.trim() || text.trim();
|
|
300
|
-
}
|
|
301
|
-
|
|
302
283
|
function renderSlackInboundText(
|
|
303
284
|
text: string,
|
|
304
285
|
context: SlackTextRenderContext = {},
|
|
305
|
-
options: {
|
|
306
|
-
stripLeadingBotMention?: boolean;
|
|
307
|
-
fallbackStripFirstMention?: boolean;
|
|
308
|
-
} = {},
|
|
309
286
|
): string {
|
|
310
|
-
|
|
311
|
-
if (options.stripLeadingBotMention) {
|
|
312
|
-
stripped = context.botUserId
|
|
313
|
-
? stripBotMention(text, context.botUserId)
|
|
314
|
-
: options.fallbackStripFirstMention
|
|
315
|
-
? stripBotMention(text)
|
|
316
|
-
: text.trim();
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
return renderSlackTextForModel(stripped, {
|
|
287
|
+
return renderSlackTextForModel(text, {
|
|
320
288
|
userLabels: context.userLabels,
|
|
321
289
|
});
|
|
322
290
|
}
|
|
323
291
|
|
|
324
|
-
function withBotUserId(
|
|
325
|
-
botUserId: string | undefined,
|
|
326
|
-
context: SlackTextRenderContext | undefined,
|
|
327
|
-
): SlackTextRenderContext {
|
|
328
|
-
return {
|
|
329
|
-
...context,
|
|
330
|
-
botUserId: context?.botUserId ?? botUserId,
|
|
331
|
-
};
|
|
332
|
-
}
|
|
333
|
-
|
|
334
292
|
function extractSlackAttachments(files: SlackFile[] | undefined): Array<{
|
|
335
293
|
type: "image" | "document";
|
|
336
294
|
fileId: string;
|
|
@@ -426,10 +384,7 @@ export function normalizeSlackDirectMessage(
|
|
|
426
384
|
botToken && event.user
|
|
427
385
|
? resolveSlackUserSync(event.user, botToken)
|
|
428
386
|
: undefined;
|
|
429
|
-
const content = renderSlackInboundText(
|
|
430
|
-
event.text,
|
|
431
|
-
withBotUserId(botUserId, renderContext),
|
|
432
|
-
);
|
|
387
|
+
const content = renderSlackInboundText(event.text, renderContext);
|
|
433
388
|
|
|
434
389
|
return {
|
|
435
390
|
event: {
|
|
@@ -487,11 +442,7 @@ export function normalizeSlackChannelMessage(
|
|
|
487
442
|
const routing = resolveAssistant(config, event.channel, event.user);
|
|
488
443
|
if (isRejection(routing)) return null;
|
|
489
444
|
|
|
490
|
-
const content = renderSlackInboundText(
|
|
491
|
-
event.text,
|
|
492
|
-
withBotUserId(botUserId, renderContext),
|
|
493
|
-
{ stripLeadingBotMention: true, fallbackStripFirstMention: true },
|
|
494
|
-
);
|
|
445
|
+
const content = renderSlackInboundText(event.text, renderContext);
|
|
495
446
|
const externalMessageId =
|
|
496
447
|
event.client_msg_id ?? event.ts ?? `${event.channel}:${event.ts}`;
|
|
497
448
|
|
|
@@ -556,14 +507,7 @@ export function normalizeSlackAppMention(
|
|
|
556
507
|
return null;
|
|
557
508
|
}
|
|
558
509
|
|
|
559
|
-
const content = renderSlackInboundText(
|
|
560
|
-
event.text,
|
|
561
|
-
withBotUserId(botUserId, renderContext),
|
|
562
|
-
{
|
|
563
|
-
stripLeadingBotMention: true,
|
|
564
|
-
fallbackStripFirstMention: true,
|
|
565
|
-
},
|
|
566
|
-
);
|
|
510
|
+
const content = renderSlackInboundText(event.text, renderContext);
|
|
567
511
|
const externalMessageId =
|
|
568
512
|
event.client_msg_id ?? event.ts ?? `${event.channel}:${event.ts}`;
|
|
569
513
|
|
|
@@ -876,14 +820,7 @@ export function normalizeSlackMessageEdit(
|
|
|
876
820
|
}
|
|
877
821
|
if (isRejection(routing)) return null;
|
|
878
822
|
|
|
879
|
-
const content = renderSlackInboundText(
|
|
880
|
-
edited.text,
|
|
881
|
-
withBotUserId(botUserId, renderContext),
|
|
882
|
-
{
|
|
883
|
-
stripLeadingBotMention: true,
|
|
884
|
-
fallbackStripFirstMention: !botUserId,
|
|
885
|
-
},
|
|
886
|
-
);
|
|
823
|
+
const content = renderSlackInboundText(edited.text, renderContext);
|
|
887
824
|
|
|
888
825
|
// Each edit event gets a unique externalMessageId so the dedup pipeline
|
|
889
826
|
// does not discard subsequent edits of the same Slack message.
|
package/src/slack/socket-mode.ts
CHANGED
|
@@ -762,7 +762,6 @@ export class SlackSocketModeClient {
|
|
|
762
762
|
if (!userInfo) return undefined;
|
|
763
763
|
return userInfo.displayName || userInfo.username;
|
|
764
764
|
},
|
|
765
|
-
{ ignoredUserIds: [this.config.botUserId] },
|
|
766
765
|
);
|
|
767
766
|
}
|
|
768
767
|
|
|
@@ -876,10 +875,7 @@ export class SlackSocketModeClient {
|
|
|
876
875
|
): Promise<void> {
|
|
877
876
|
const text = this.extractTextBearingContent(event);
|
|
878
877
|
const userLabels = text ? await this.resolveMentionLabelsForText(text) : {};
|
|
879
|
-
const renderContext = {
|
|
880
|
-
botUserId: this.config.botUserId,
|
|
881
|
-
userLabels,
|
|
882
|
-
};
|
|
878
|
+
const renderContext = { userLabels };
|
|
883
879
|
|
|
884
880
|
let normalized: NormalizedSlackEvent | null;
|
|
885
881
|
if (isReactionAdded) {
|
|
@@ -31,7 +31,6 @@ interface PlatformCallbackRouteResponse {
|
|
|
31
31
|
async function registerManagedTelegramCallbackRoute(
|
|
32
32
|
caches?: WebhookManagerCaches,
|
|
33
33
|
): Promise<string | undefined> {
|
|
34
|
-
// Read from credential cache when available
|
|
35
34
|
const [platformBaseUrlRaw, assistantApiKeyRaw, assistantIdRaw] =
|
|
36
35
|
caches?.credentials
|
|
37
36
|
? await Promise.all([
|
|
@@ -43,29 +42,26 @@ async function registerManagedTelegramCallbackRoute(
|
|
|
43
42
|
])
|
|
44
43
|
: [undefined, undefined, undefined];
|
|
45
44
|
|
|
46
|
-
// Fall back to env vars when
|
|
47
|
-
// the daemon's resolvePlatformCallbackRegistrationContext()
|
|
45
|
+
// Fall back to env vars when managed pod credentials are not yet cached,
|
|
46
|
+
// matching the daemon's resolvePlatformCallbackRegistrationContext().
|
|
48
47
|
const platformBaseUrl = (
|
|
49
48
|
platformBaseUrlRaw?.trim() ||
|
|
50
49
|
process.env.VELLUM_PLATFORM_URL?.trim() ||
|
|
51
50
|
""
|
|
52
51
|
).replace(/\/+$/, "");
|
|
53
52
|
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
: undefined;
|
|
59
|
-
const authToken = platformInternalApiKey || assistantApiKey;
|
|
60
|
-
const authScheme = platformInternalApiKey ? "Bearer" : "Api-Key";
|
|
53
|
+
const assistantCredential =
|
|
54
|
+
assistantApiKeyRaw?.trim() ||
|
|
55
|
+
process.env.ASSISTANT_API_KEY?.trim() ||
|
|
56
|
+
undefined;
|
|
61
57
|
|
|
62
58
|
const assistantId = assistantIdRaw?.trim() || undefined;
|
|
63
59
|
|
|
64
|
-
if (!platformBaseUrl || !
|
|
60
|
+
if (!platformBaseUrl || !assistantCredential || !assistantId) {
|
|
65
61
|
log.debug(
|
|
66
62
|
{
|
|
67
63
|
hasPlatformBaseUrl: !!platformBaseUrl,
|
|
68
|
-
hasApiKey: !!
|
|
64
|
+
hasApiKey: !!assistantCredential,
|
|
69
65
|
hasAssistantId: !!assistantId,
|
|
70
66
|
},
|
|
71
67
|
"Managed Telegram callback route registration unavailable",
|
|
@@ -95,7 +91,7 @@ async function registerManagedTelegramCallbackRoute(
|
|
|
95
91
|
{
|
|
96
92
|
method: "POST",
|
|
97
93
|
headers: {
|
|
98
|
-
Authorization:
|
|
94
|
+
Authorization: `Api-Key ${assistantCredential}`,
|
|
99
95
|
"Content-Type": "application/json",
|
|
100
96
|
},
|
|
101
97
|
body: JSON.stringify({
|
package/src/velay/client.ts
CHANGED
|
@@ -76,7 +76,7 @@ export class VelayTunnelClient {
|
|
|
76
76
|
private connecting = false;
|
|
77
77
|
private reconnectAttempt = 0;
|
|
78
78
|
private reconnectTimer: unknown = null;
|
|
79
|
-
private
|
|
79
|
+
private publishedPublicBaseUrl: string | undefined;
|
|
80
80
|
private unsubscribeConfigInvalidation: (() => void) | undefined;
|
|
81
81
|
|
|
82
82
|
constructor(private readonly options: VelayTunnelClientOptions) {
|
|
@@ -97,6 +97,17 @@ export class VelayTunnelClient {
|
|
|
97
97
|
this.timerApi = options.timerApi ?? defaultTimerApi;
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
getStatus(): { connected: boolean; publicUrl: string | null } {
|
|
101
|
+
const connected =
|
|
102
|
+
this.ws !== null &&
|
|
103
|
+
this.ws.readyState === WebSocket.OPEN &&
|
|
104
|
+
this.publishedPublicBaseUrl !== undefined;
|
|
105
|
+
return {
|
|
106
|
+
connected,
|
|
107
|
+
publicUrl: this.publishedPublicBaseUrl ?? null,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
100
111
|
start(): void {
|
|
101
112
|
if (this.running) return;
|
|
102
113
|
this.running = true;
|
|
@@ -124,7 +135,7 @@ export class VelayTunnelClient {
|
|
|
124
135
|
const ws = this.ws;
|
|
125
136
|
this.ws = null;
|
|
126
137
|
this.webSocketBridge.closeAll();
|
|
127
|
-
await this.
|
|
138
|
+
await this.clearPublishedPublicBaseUrl();
|
|
128
139
|
if (ws) {
|
|
129
140
|
closeWebSocket(ws, 1000, "gateway shutdown");
|
|
130
141
|
}
|
|
@@ -141,7 +152,7 @@ export class VelayTunnelClient {
|
|
|
141
152
|
|
|
142
153
|
if (this.isPublicIngressDisabled()) {
|
|
143
154
|
this.connecting = false;
|
|
144
|
-
await this.
|
|
155
|
+
await this.clearPublishedPublicBaseUrl();
|
|
145
156
|
log.info("Velay tunnel waiting because public ingress is disabled");
|
|
146
157
|
this.scheduleReconnect();
|
|
147
158
|
return;
|
|
@@ -286,7 +297,7 @@ export class VelayTunnelClient {
|
|
|
286
297
|
if (!publicUrl) {
|
|
287
298
|
log.error(
|
|
288
299
|
{ publicUrl: frame.public_url },
|
|
289
|
-
"Velay registered invalid
|
|
300
|
+
"Velay registered invalid public URL",
|
|
290
301
|
);
|
|
291
302
|
this.disconnectActiveWebSocket(
|
|
292
303
|
originWs,
|
|
@@ -299,14 +310,14 @@ export class VelayTunnelClient {
|
|
|
299
310
|
if (this.isPublicIngressDisabled()) {
|
|
300
311
|
log.info(
|
|
301
312
|
{ publicUrl },
|
|
302
|
-
"Skipping Velay
|
|
313
|
+
"Skipping Velay public URL publish because public ingress is disabled",
|
|
303
314
|
);
|
|
304
315
|
this.disconnectActiveWebSocket(originWs, 1000, "public ingress disabled");
|
|
305
316
|
return;
|
|
306
317
|
}
|
|
307
318
|
|
|
308
319
|
await writeManagedPublicBaseUrl(publicUrl, this.options.configFile);
|
|
309
|
-
this.
|
|
320
|
+
this.publishedPublicBaseUrl = publicUrl;
|
|
310
321
|
this.reconnectAttempt = 0;
|
|
311
322
|
log.info({ publicUrl }, "Velay tunnel registered");
|
|
312
323
|
}
|
|
@@ -332,7 +343,7 @@ export class VelayTunnelClient {
|
|
|
332
343
|
{ code: event.code, reason: event.reason },
|
|
333
344
|
"Velay tunnel disconnected",
|
|
334
345
|
);
|
|
335
|
-
this.
|
|
346
|
+
this.clearPublishedPublicBaseUrlThenReconnect();
|
|
336
347
|
}
|
|
337
348
|
|
|
338
349
|
private disconnectActiveWebSocket(
|
|
@@ -345,24 +356,24 @@ export class VelayTunnelClient {
|
|
|
345
356
|
this.connecting = false;
|
|
346
357
|
this.webSocketBridge.closeAll();
|
|
347
358
|
closeWebSocket(ws, code, reason);
|
|
348
|
-
this.
|
|
359
|
+
this.clearPublishedPublicBaseUrlThenReconnect();
|
|
349
360
|
}
|
|
350
361
|
|
|
351
|
-
private async
|
|
352
|
-
const publicUrl = this.
|
|
362
|
+
private async clearPublishedPublicBaseUrl(): Promise<void> {
|
|
363
|
+
const publicUrl = this.publishedPublicBaseUrl;
|
|
353
364
|
if (!publicUrl) return;
|
|
354
|
-
this.
|
|
365
|
+
this.publishedPublicBaseUrl = undefined;
|
|
355
366
|
try {
|
|
356
367
|
await clearManagedPublicBaseUrl(this.options.configFile, publicUrl);
|
|
357
368
|
} catch (err) {
|
|
358
|
-
log.error({ err }, "Failed to clear Velay
|
|
369
|
+
log.error({ err }, "Failed to clear Velay public URL");
|
|
359
370
|
}
|
|
360
371
|
}
|
|
361
372
|
|
|
362
|
-
private
|
|
363
|
-
void this.
|
|
373
|
+
private clearPublishedPublicBaseUrlThenReconnect(): void {
|
|
374
|
+
void this.clearPublishedPublicBaseUrl()
|
|
364
375
|
.catch((err) => {
|
|
365
|
-
log.error({ err }, "Failed to clear Velay
|
|
376
|
+
log.error({ err }, "Failed to clear Velay public URL");
|
|
366
377
|
})
|
|
367
378
|
.finally(() => {
|
|
368
379
|
this.scheduleReconnect();
|
|
@@ -430,7 +441,7 @@ export function createVelayTunnelClient(
|
|
|
430
441
|
);
|
|
431
442
|
}
|
|
432
443
|
void clearManagedPublicBaseUrl(deps.configFile).catch((err) => {
|
|
433
|
-
log.error({ err }, "Failed to clear disabled Velay
|
|
444
|
+
log.error({ err }, "Failed to clear disabled Velay public URL");
|
|
434
445
|
});
|
|
435
446
|
return undefined;
|
|
436
447
|
}
|
|
@@ -111,9 +111,12 @@ export async function upsertVerifiedContactChannel(params: {
|
|
|
111
111
|
if (existing.length > 0) {
|
|
112
112
|
const row = existing[0];
|
|
113
113
|
|
|
114
|
-
// Don't overwrite blocked channels
|
|
115
|
-
if (row.channelStatus === "blocked") {
|
|
116
|
-
log.warn(
|
|
114
|
+
// Don't overwrite blocked or revoked channels.
|
|
115
|
+
if (row.channelStatus === "blocked" || row.channelStatus === "revoked") {
|
|
116
|
+
log.warn(
|
|
117
|
+
{ sourceChannel, address, status: row.channelStatus },
|
|
118
|
+
"Skipping upsert: channel is blocked or revoked",
|
|
119
|
+
);
|
|
117
120
|
return;
|
|
118
121
|
}
|
|
119
122
|
|