@vellumai/assistant 0.11.0-staging.2 → 0.11.0
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/docs/architecture/memory.md +7 -0
- package/openapi.yaml +12 -1
- package/package.json +1 -1
- package/src/__tests__/channel-readiness-service.test.ts +107 -1
- package/src/__tests__/platform-callback-registration.test.ts +132 -2
- package/src/__tests__/plugin-import-boundary-guard.test.ts +6 -0
- package/src/__tests__/telegram-config.test.ts +96 -2
- package/src/config/__tests__/webhook-routing.test.ts +229 -0
- package/src/config/webhook-routing.ts +55 -14
- package/src/daemon/handlers/config-telegram.ts +12 -7
- package/src/inbound/platform-callback-registration.ts +44 -10
- package/src/inbound/public-ingress-urls.ts +40 -9
- package/src/messaging/providers/slack/binding-metadata.test.ts +37 -8
- package/src/messaging/providers/slack/binding-metadata.ts +21 -21
- package/src/messaging/providers/slack/deep-link.test.ts +91 -0
- package/src/messaging/providers/slack/deep-link.ts +27 -8
- package/src/plugins/defaults/memory/src/memory-item-routes.test.ts +22 -0
- package/src/plugins/defaults/memory/src/memory-item-routes.ts +22 -3
- package/src/runtime/channel-readiness-service.ts +8 -10
- package/src/runtime/routes/webhook-routes.ts +1 -13
- package/src/telegram/__tests__/webhook-health.test.ts +76 -13
- package/src/telegram/webhook-health.ts +1 -1
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for `hasWebhookRoutingConfigured`'s resolution order (LUM-2882).
|
|
3
|
+
*
|
|
4
|
+
* The predicate has to agree with `handleWebhooksRegister` in
|
|
5
|
+
* `runtime/routes/webhook-routes.ts` tier for tier. Where they disagree, the
|
|
6
|
+
* status surfaces (channel readiness, the Telegram webhook health sweep) report
|
|
7
|
+
* a state the registration path contradicts, which hides a broken registration
|
|
8
|
+
* instead of surfacing it. The tier cases here mirror the ones in
|
|
9
|
+
* `runtime/routes/__tests__/webhook-routes.test.ts`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
13
|
+
|
|
14
|
+
let isPlatform = false;
|
|
15
|
+
let rawConfig: Record<string, unknown> = {};
|
|
16
|
+
let platformContextEnabled = false;
|
|
17
|
+
|
|
18
|
+
// Spread the real modules: these are broad barrels and replacing them wholesale
|
|
19
|
+
// breaks unrelated importers pulled in by the module under test.
|
|
20
|
+
const actualEnvRegistry = await import("../env-registry.js");
|
|
21
|
+
mock.module("../env-registry.js", () => ({
|
|
22
|
+
...actualEnvRegistry,
|
|
23
|
+
getIsPlatform: () => isPlatform,
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
const actualLoader = await import("../loader.js");
|
|
27
|
+
mock.module("../loader.js", () => ({
|
|
28
|
+
...actualLoader,
|
|
29
|
+
loadRawConfig: () => rawConfig,
|
|
30
|
+
getConfig: () => rawConfig,
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
const actualRegistration =
|
|
34
|
+
await import("../../inbound/platform-callback-registration.js");
|
|
35
|
+
mock.module("../../inbound/platform-callback-registration.js", () => ({
|
|
36
|
+
...actualRegistration,
|
|
37
|
+
resolvePlatformCallbackRegistrationContext: async () => ({
|
|
38
|
+
isPlatform,
|
|
39
|
+
platformBaseUrl: "https://api.vellum.ai",
|
|
40
|
+
assistantId: platformContextEnabled ? "assistant-123" : "",
|
|
41
|
+
hasAssistantApiKey: platformContextEnabled,
|
|
42
|
+
authHeader: platformContextEnabled ? "Api-Key secret" : null,
|
|
43
|
+
enabled: platformContextEnabled,
|
|
44
|
+
}),
|
|
45
|
+
}));
|
|
46
|
+
|
|
47
|
+
const { hasIngressConfigured, hasWebhookRoutingConfigured } =
|
|
48
|
+
await import("../webhook-routing.js");
|
|
49
|
+
|
|
50
|
+
describe("hasWebhookRoutingConfigured resolution order", () => {
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
isPlatform = false;
|
|
53
|
+
rawConfig = {};
|
|
54
|
+
platformContextEnabled = false;
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// ── Tier 1: platform pods ────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
test("platform pods use managed callbacks", async () => {
|
|
60
|
+
isPlatform = true;
|
|
61
|
+
|
|
62
|
+
expect(await hasWebhookRoutingConfigured(true)).toEqual({
|
|
63
|
+
configured: true,
|
|
64
|
+
usesManagedCallbacks: true,
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("platform pods report managed even with an ingress URL configured", async () => {
|
|
69
|
+
isPlatform = true;
|
|
70
|
+
rawConfig = { ingress: { publicBaseUrl: "https://tunnel.example.com" } };
|
|
71
|
+
|
|
72
|
+
// `handleWebhooksRegister` registers with the platform gateway before it
|
|
73
|
+
// ever reads the ingress config on a pod, so reporting the ingress URL
|
|
74
|
+
// here would name a URL no webhook is actually registered against.
|
|
75
|
+
expect(await hasWebhookRoutingConfigured(true)).toEqual({
|
|
76
|
+
configured: true,
|
|
77
|
+
usesManagedCallbacks: true,
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// ── Tier 2: a configured ingress wins ────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
test("ingress beats the platform-connected fallback", async () => {
|
|
84
|
+
platformContextEnabled = true;
|
|
85
|
+
rawConfig = { ingress: { publicBaseUrl: "https://tunnel.example.com" } };
|
|
86
|
+
|
|
87
|
+
// Any logged-in local assistant holds platform credentials for the LLM
|
|
88
|
+
// proxy, so credential presence must not reroute an explicitly configured
|
|
89
|
+
// self-hosted webhook through the platform.
|
|
90
|
+
expect(await hasWebhookRoutingConfigured(true)).toEqual({
|
|
91
|
+
configured: true,
|
|
92
|
+
usesManagedCallbacks: false,
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("the twilio option resolves ingress before the fallback too", async () => {
|
|
97
|
+
platformContextEnabled = true;
|
|
98
|
+
rawConfig = { ingress: { publicBaseUrl: "https://twilio.example.com" } };
|
|
99
|
+
|
|
100
|
+
expect(await hasWebhookRoutingConfigured(true, { twilio: true })).toEqual({
|
|
101
|
+
configured: true,
|
|
102
|
+
usesManagedCallbacks: false,
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("the twilio option falls back to managed callbacks with no ingress", async () => {
|
|
107
|
+
platformContextEnabled = true;
|
|
108
|
+
|
|
109
|
+
expect(await hasWebhookRoutingConfigured(true, { twilio: true })).toEqual({
|
|
110
|
+
configured: true,
|
|
111
|
+
usesManagedCallbacks: true,
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// ── Tier 3: platform-connected fallback ──────────────────────────────────
|
|
116
|
+
|
|
117
|
+
test("a platform-connected assistant with no ingress uses managed callbacks", async () => {
|
|
118
|
+
platformContextEnabled = true;
|
|
119
|
+
|
|
120
|
+
// The LUM-2882 case: `webhooks register` hands back a platform callback
|
|
121
|
+
// URL here, so the status surfaces must not report missing ingress.
|
|
122
|
+
expect(await hasWebhookRoutingConfigured(true)).toEqual({
|
|
123
|
+
configured: true,
|
|
124
|
+
usesManagedCallbacks: true,
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("the fallback applies when the ingress URL is present but empty", async () => {
|
|
129
|
+
platformContextEnabled = true;
|
|
130
|
+
rawConfig = { ingress: { publicBaseUrl: "" } };
|
|
131
|
+
|
|
132
|
+
expect(await hasWebhookRoutingConfigured(true)).toEqual({
|
|
133
|
+
configured: true,
|
|
134
|
+
usesManagedCallbacks: true,
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("an explicit ingress.enabled false blocks the fallback", async () => {
|
|
139
|
+
platformContextEnabled = true;
|
|
140
|
+
rawConfig = { ingress: { enabled: false } };
|
|
141
|
+
|
|
142
|
+
// Opting out is a decision not to accept inbound webhooks at all, not an
|
|
143
|
+
// invitation to route them through the platform instead.
|
|
144
|
+
expect(await hasWebhookRoutingConfigured(true)).toEqual({
|
|
145
|
+
configured: false,
|
|
146
|
+
usesManagedCallbacks: false,
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("ingress.enabled false blocks the fallback even with a URL configured", async () => {
|
|
151
|
+
platformContextEnabled = true;
|
|
152
|
+
rawConfig = {
|
|
153
|
+
ingress: { enabled: false, publicBaseUrl: "https://tunnel.example.com" },
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
expect(await hasWebhookRoutingConfigured(true)).toEqual({
|
|
157
|
+
configured: false,
|
|
158
|
+
usesManagedCallbacks: false,
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// ── Tier 4: nothing configured ───────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
test("no ingress and no platform connectivity is not configured", async () => {
|
|
165
|
+
expect(await hasWebhookRoutingConfigured(true)).toEqual({
|
|
166
|
+
configured: false,
|
|
167
|
+
usesManagedCallbacks: false,
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// ── allowManagedCallbacks gating ─────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
test("allowManagedCallbacks false hides the platform-connected fallback", async () => {
|
|
174
|
+
platformContextEnabled = true;
|
|
175
|
+
|
|
176
|
+
// Channels that can only be served by a self-hosted ingress pass `false`
|
|
177
|
+
// and must never be told a managed route stands in for one.
|
|
178
|
+
expect(await hasWebhookRoutingConfigured(false)).toEqual({
|
|
179
|
+
configured: false,
|
|
180
|
+
usesManagedCallbacks: false,
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("allowManagedCallbacks false hides the platform-pod tier", async () => {
|
|
185
|
+
isPlatform = true;
|
|
186
|
+
|
|
187
|
+
expect(await hasWebhookRoutingConfigured(false)).toEqual({
|
|
188
|
+
configured: false,
|
|
189
|
+
usesManagedCallbacks: false,
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("allowManagedCallbacks false still honors a configured ingress", async () => {
|
|
194
|
+
rawConfig = { ingress: { publicBaseUrl: "https://tunnel.example.com" } };
|
|
195
|
+
|
|
196
|
+
expect(await hasWebhookRoutingConfigured(false)).toEqual({
|
|
197
|
+
configured: true,
|
|
198
|
+
usesManagedCallbacks: false,
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
describe("hasIngressConfigured", () => {
|
|
204
|
+
beforeEach(() => {
|
|
205
|
+
isPlatform = false;
|
|
206
|
+
rawConfig = {};
|
|
207
|
+
platformContextEnabled = false;
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("is unaffected by platform connectivity", () => {
|
|
211
|
+
platformContextEnabled = true;
|
|
212
|
+
|
|
213
|
+
expect(hasIngressConfigured()).toBe(false);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("treats an unset enabled flag with a URL as enabled", () => {
|
|
217
|
+
rawConfig = { ingress: { publicBaseUrl: "https://tunnel.example.com" } };
|
|
218
|
+
|
|
219
|
+
expect(hasIngressConfigured()).toBe(true);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("treats an explicit enabled false as not configured", () => {
|
|
223
|
+
rawConfig = {
|
|
224
|
+
ingress: { enabled: false, publicBaseUrl: "https://tunnel.example.com" },
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
expect(hasIngressConfigured()).toBe(false);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
@@ -14,8 +14,10 @@ import {
|
|
|
14
14
|
resolveTwilioPublicBaseUrl,
|
|
15
15
|
} from "@vellumai/service-contracts/twilio-ingress";
|
|
16
16
|
|
|
17
|
+
import { resolvePlatformCallbackRegistrationContext } from "../inbound/platform-callback-registration.js";
|
|
18
|
+
import { isPublicIngressDisabled } from "../inbound/public-ingress-urls.js";
|
|
17
19
|
import { getIsPlatform } from "./env-registry.js";
|
|
18
|
-
import { loadRawConfig } from "./loader.js";
|
|
20
|
+
import { getConfig, loadRawConfig } from "./loader.js";
|
|
19
21
|
|
|
20
22
|
/**
|
|
21
23
|
* True when a public ingress base URL is set and ingress is enabled.
|
|
@@ -43,25 +45,64 @@ export function hasIngressConfigured(
|
|
|
43
45
|
}
|
|
44
46
|
|
|
45
47
|
/**
|
|
46
|
-
* True when
|
|
47
|
-
*
|
|
48
|
-
*
|
|
48
|
+
* True when the user has explicitly switched public ingress off.
|
|
49
|
+
*
|
|
50
|
+
* Reads the validated config rather than the raw file so the check matches
|
|
51
|
+
* what `getPublicBaseUrl` enforces. A config that fails to load is treated as
|
|
52
|
+
* "not explicitly disabled": absence of a decision is not an opt-out.
|
|
49
53
|
*/
|
|
50
|
-
|
|
54
|
+
function isIngressExplicitlyDisabled(): boolean {
|
|
55
|
+
try {
|
|
56
|
+
return isPublicIngressDisabled(getConfig());
|
|
57
|
+
} catch {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* True when inbound webhooks have somewhere to land.
|
|
64
|
+
*
|
|
65
|
+
* Mirrors the resolution order `handleWebhooksRegister` uses in
|
|
66
|
+
* `runtime/routes/webhook-routes.ts`, because the two must agree: a probe that
|
|
67
|
+
* reports "no ingress" while `webhooks register` hands back a working callback
|
|
68
|
+
* URL hides a broken registration instead of surfacing it.
|
|
69
|
+
*
|
|
70
|
+
* 1. **Platform pods** (`IS_PLATFORM`) always use managed callbacks.
|
|
71
|
+
* 2. **A configured public ingress wins** for everyone else.
|
|
72
|
+
* 3. **Platform-connected assistants with no ingress** fall back to managed
|
|
73
|
+
* callbacks. Connectivity is decided by credentials (platform base URL +
|
|
74
|
+
* assistant ID + assistant API key), not by `IS_PLATFORM`, which is only
|
|
75
|
+
* ever true on a platform pod.
|
|
76
|
+
* 4. Otherwise nothing is configured.
|
|
77
|
+
*
|
|
78
|
+
* Ingress deliberately precedes the platform fallback: any logged-in local
|
|
79
|
+
* assistant holds platform credentials for the LLM proxy, so treating
|
|
80
|
+
* credential presence as "managed" would misreport an explicitly configured
|
|
81
|
+
* self-hosted webhook as platform-routed. An explicit `ingress.enabled: false`
|
|
82
|
+
* is a decision not to accept inbound webhooks at all and blocks the fallback.
|
|
83
|
+
*
|
|
84
|
+
* `allowManagedCallbacks` gates both platform tiers: channels that can only be
|
|
85
|
+
* served by a self-hosted ingress pass `false` and never see them.
|
|
86
|
+
*/
|
|
87
|
+
export async function hasWebhookRoutingConfigured(
|
|
51
88
|
allowManagedCallbacks = false,
|
|
52
89
|
options: { twilio?: boolean } = {},
|
|
53
|
-
): {
|
|
90
|
+
): Promise<{
|
|
54
91
|
configured: boolean;
|
|
55
92
|
usesManagedCallbacks: boolean;
|
|
56
|
-
} {
|
|
57
|
-
|
|
58
|
-
|
|
93
|
+
}> {
|
|
94
|
+
if (allowManagedCallbacks && getIsPlatform()) {
|
|
95
|
+
return { configured: true, usesManagedCallbacks: true };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (hasIngressConfigured(options)) {
|
|
59
99
|
return { configured: true, usesManagedCallbacks: false };
|
|
60
100
|
}
|
|
61
101
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
};
|
|
102
|
+
if (!allowManagedCallbacks || isIngressExplicitlyDisabled()) {
|
|
103
|
+
return { configured: false, usesManagedCallbacks: false };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const { enabled } = await resolvePlatformCallbackRegistrationContext();
|
|
107
|
+
return { configured: enabled, usesManagedCallbacks: enabled };
|
|
67
108
|
}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
|
|
3
|
-
import { getIsPlatform } from "../../config/env-registry.js";
|
|
4
3
|
import {
|
|
5
4
|
invalidateConfigCache,
|
|
6
5
|
loadRawConfig,
|
|
7
6
|
saveRawConfig,
|
|
8
7
|
setNestedValue,
|
|
9
8
|
} from "../../config/loader.js";
|
|
9
|
+
import { hasWebhookRoutingConfigured } from "../../config/webhook-routing.js";
|
|
10
10
|
import { registerCallbackRoute } from "../../inbound/platform-callback-registration.js";
|
|
11
11
|
import {
|
|
12
12
|
ensureManualTokenConnection,
|
|
@@ -249,12 +249,17 @@ export async function setTelegramConfig(
|
|
|
249
249
|
hasWebhookSecret,
|
|
250
250
|
};
|
|
251
251
|
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
// only way to receive Telegram webhooks.
|
|
257
|
-
|
|
252
|
+
// Register the Telegram callback route so the platform knows how to
|
|
253
|
+
// forward Telegram webhooks. This applies whenever webhooks are delivered
|
|
254
|
+
// via managed callbacks: platform pods, and platform-connected local
|
|
255
|
+
// assistants with no public ingress. For both, platform callbacks are the
|
|
256
|
+
// only way to receive Telegram webhooks. `hasWebhookRoutingConfigured`
|
|
257
|
+
// encodes the resolution order (a configured ingress wins; an explicit
|
|
258
|
+
// `ingress.enabled: false` blocks the platform fallback) and is the same
|
|
259
|
+
// derivation the Telegram status checks read, so registration and reported
|
|
260
|
+
// status stay in agreement.
|
|
261
|
+
const { usesManagedCallbacks } = await hasWebhookRoutingConfigured(true);
|
|
262
|
+
if (usesManagedCallbacks) {
|
|
258
263
|
registerCallbackRoute("webhooks/telegram", "telegram").catch((err) => {
|
|
259
264
|
log.warn({ err }, "Failed to register Telegram platform callback route");
|
|
260
265
|
});
|
|
@@ -21,6 +21,7 @@ import { getIsPlatform } from "../config/env-registry.js";
|
|
|
21
21
|
import { credentialKey } from "../security/credential-key.js";
|
|
22
22
|
import { getSecureKeyAsync } from "../security/secure-keys.js";
|
|
23
23
|
import { getLogger } from "../util/logger.js";
|
|
24
|
+
import { PublicIngressDisabledError } from "./public-ingress-urls.js";
|
|
24
25
|
|
|
25
26
|
const log = getLogger("platform-callback-registration");
|
|
26
27
|
|
|
@@ -151,18 +152,35 @@ export async function registerCallbackRoute(
|
|
|
151
152
|
}
|
|
152
153
|
|
|
153
154
|
/**
|
|
154
|
-
* Resolve a callback URL, registering with the platform when
|
|
155
|
+
* Resolve a callback URL, registering with the platform when appropriate.
|
|
155
156
|
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
157
|
+
* Resolution order, matching `handleWebhooksRegister` in
|
|
158
|
+
* `runtime/routes/webhook-routes.ts` and `hasWebhookRoutingConfigured` in
|
|
159
|
+
* `config/webhook-routing.ts`:
|
|
160
|
+
*
|
|
161
|
+
* 1. **Platform pods** (`IS_PLATFORM`) always register with the platform
|
|
162
|
+
* gateway: they have no ingress of their own to advertise.
|
|
163
|
+
* 2. **A configured public ingress wins** for everyone else, so the direct
|
|
164
|
+
* supplier is tried first and its value returned when it resolves.
|
|
165
|
+
* 3. **Platform-connected assistants with no ingress** register with the
|
|
166
|
+
* platform gateway rather than surfacing the direct builder's error.
|
|
167
|
+
* Connectivity is decided by credentials (platform base URL + assistant
|
|
168
|
+
* ID + assistant API key), not by `IS_PLATFORM`, which is only ever true
|
|
169
|
+
* on a platform pod.
|
|
170
|
+
*
|
|
171
|
+
* An explicit `ingress.enabled: false` is a decision not to accept inbound
|
|
172
|
+
* webhooks at all, so `PublicIngressDisabledError` propagates instead of being
|
|
173
|
+
* routed around. Ingress precedes the platform fallback because any logged-in
|
|
174
|
+
* local assistant holds platform credentials for the LLM proxy: treating
|
|
175
|
+
* credential presence as "managed" would silently reroute an explicitly
|
|
176
|
+
* configured self-hosted callback through the platform.
|
|
159
177
|
*
|
|
160
178
|
* The `directUrl` parameter is a **lazy supplier** (a function returning a
|
|
161
179
|
* string) rather than an eagerly-evaluated string. This is critical because
|
|
162
180
|
* the direct URL builders (e.g. `getTwilioVoiceWebhookUrl`) call
|
|
163
181
|
* `getPublicBaseUrl()` which throws when no public ingress URL is configured.
|
|
164
|
-
*
|
|
165
|
-
*
|
|
182
|
+
* On a platform pod the direct URL is never needed, and deferring evaluation
|
|
183
|
+
* avoids the throw.
|
|
166
184
|
*
|
|
167
185
|
* @param directUrl - Lazy supplier for the direct callback URL.
|
|
168
186
|
* @param callbackPath - The path to register (e.g. "webhooks/twilio/voice").
|
|
@@ -179,7 +197,22 @@ export async function resolveCallbackUrl(
|
|
|
179
197
|
sourceIdentifier?: string,
|
|
180
198
|
): Promise<string> {
|
|
181
199
|
if (!getIsPlatform()) {
|
|
182
|
-
|
|
200
|
+
let ingressError: unknown;
|
|
201
|
+
try {
|
|
202
|
+
return directUrl();
|
|
203
|
+
} catch (err) {
|
|
204
|
+
if (err instanceof PublicIngressDisabledError) {
|
|
205
|
+
throw err;
|
|
206
|
+
}
|
|
207
|
+
ingressError = err;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// No ingress configured. Fall back to the platform gateway when this
|
|
211
|
+
// assistant is connected to the platform.
|
|
212
|
+
const context = await resolvePlatformCallbackRegistrationContext();
|
|
213
|
+
if (!context.enabled) {
|
|
214
|
+
throw ingressError;
|
|
215
|
+
}
|
|
183
216
|
}
|
|
184
217
|
|
|
185
218
|
try {
|
|
@@ -191,9 +224,10 @@ export async function resolveCallbackUrl(
|
|
|
191
224
|
}
|
|
192
225
|
return url;
|
|
193
226
|
} catch (err) {
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
// user) understand this is a platform-side issue, not a
|
|
227
|
+
// Registration is only attempted once the local ingress has been ruled
|
|
228
|
+
// out, so there is nothing left to fall back to. Surface a clear error so
|
|
229
|
+
// callers (and the user) understand this is a platform-side issue, not a
|
|
230
|
+
// tunnel problem.
|
|
197
231
|
const detail = err instanceof Error ? err.message : String(err);
|
|
198
232
|
throw new Error(
|
|
199
233
|
`Managed callback route registration failed: ${detail}. ` +
|
|
@@ -43,11 +43,41 @@ export interface IngressConfig {
|
|
|
43
43
|
};
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
46
|
+
/**
|
|
47
|
+
* True when the user has explicitly switched public ingress off.
|
|
48
|
+
*
|
|
49
|
+
* An explicit opt-out is a decision not to accept inbound webhooks at all, so
|
|
50
|
+
* it must not be silently routed around via platform callbacks. An *absent*
|
|
51
|
+
* ingress config is merely "not set up yet" and is eligible for the platform
|
|
52
|
+
* fallback, so only a literal `false` counts here.
|
|
53
|
+
*
|
|
54
|
+
* Every consumer that offers a platform-callback fallback has to consult this
|
|
55
|
+
* before falling back, which is why it lives here rather than in each caller.
|
|
56
|
+
*/
|
|
57
|
+
export function isPublicIngressDisabled(config: IngressConfig): boolean {
|
|
58
|
+
return config.ingress?.enabled === false;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Thrown when a URL builder is asked for a URL while ingress is opted out.
|
|
63
|
+
*
|
|
64
|
+
* Distinct from the "no URL configured" error so callers with a
|
|
65
|
+
* platform-callback fallback can tell the two apart: "not set up yet" is
|
|
66
|
+
* eligible for the fallback, an explicit opt-out is not. Matching on the
|
|
67
|
+
* message text would break the moment the copy is reworded.
|
|
68
|
+
*/
|
|
69
|
+
export class PublicIngressDisabledError extends Error {
|
|
70
|
+
constructor() {
|
|
71
|
+
super(
|
|
49
72
|
"Public ingress is disabled. Ask the assistant to enable it, or update it from the Settings page.",
|
|
50
73
|
);
|
|
74
|
+
this.name = "PublicIngressDisabledError";
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function assertPublicIngressEnabled(config: IngressConfig): void {
|
|
79
|
+
if (isPublicIngressDisabled(config)) {
|
|
80
|
+
throw new PublicIngressDisabledError();
|
|
51
81
|
}
|
|
52
82
|
}
|
|
53
83
|
|
|
@@ -66,11 +96,15 @@ export function getPublicBaseUrl(config: IngressConfig): string {
|
|
|
66
96
|
|
|
67
97
|
const ingressValue = config.ingress?.publicBaseUrl;
|
|
68
98
|
const normalizedIngressValue = normalizePublicBaseUrl(ingressValue);
|
|
69
|
-
if (normalizedIngressValue)
|
|
99
|
+
if (normalizedIngressValue) {
|
|
100
|
+
return normalizedIngressValue;
|
|
101
|
+
}
|
|
70
102
|
|
|
71
103
|
const ingressEnvValue = getIngressPublicBaseUrl();
|
|
72
104
|
const normalizedIngressEnvValue = normalizePublicBaseUrl(ingressEnvValue);
|
|
73
|
-
if (normalizedIngressEnvValue)
|
|
105
|
+
if (normalizedIngressEnvValue) {
|
|
106
|
+
return normalizedIngressEnvValue;
|
|
107
|
+
}
|
|
74
108
|
|
|
75
109
|
throw new Error(
|
|
76
110
|
"No public base URL configured. Set ingress.publicBaseUrl in config.",
|
|
@@ -90,10 +124,7 @@ export function getTwilioVoiceWebhookUrl(
|
|
|
90
124
|
config: IngressConfig,
|
|
91
125
|
callSessionId?: string,
|
|
92
126
|
): string {
|
|
93
|
-
return buildTwilioVoiceWebhookUrl(
|
|
94
|
-
getPublicBaseUrl(config),
|
|
95
|
-
callSessionId,
|
|
96
|
-
);
|
|
127
|
+
return buildTwilioVoiceWebhookUrl(getPublicBaseUrl(config), callSessionId);
|
|
97
128
|
}
|
|
98
129
|
|
|
99
130
|
/**
|
|
@@ -3,9 +3,9 @@ import { beforeEach, describe, expect, test } from "bun:test";
|
|
|
3
3
|
import { setConfig } from "../../../__tests__/helpers/set-config.js";
|
|
4
4
|
import type { ExternalConversationBinding } from "../../../persistence/external-conversation-store.js";
|
|
5
5
|
|
|
6
|
-
// Seed the Slack workspace identity that the deep-link builders read.
|
|
7
|
-
//
|
|
8
|
-
//
|
|
6
|
+
// Seed the Slack workspace identity that the deep-link builders read. Empty
|
|
7
|
+
// `teamId`/`teamUrl` (the schema defaults) stand in for installs that never
|
|
8
|
+
// learned their workspace identity.
|
|
9
9
|
function seedSlack(
|
|
10
10
|
overrides: { teamId?: string; teamUrl?: string } = {},
|
|
11
11
|
): void {
|
|
@@ -55,11 +55,40 @@ describe("buildSlackBindingMetadata sourceLink", () => {
|
|
|
55
55
|
});
|
|
56
56
|
});
|
|
57
57
|
|
|
58
|
-
test("
|
|
59
|
-
// An empty teamUrl is the schema default —
|
|
60
|
-
//
|
|
61
|
-
|
|
58
|
+
test("falls back to workspace-agnostic slack.com links without a teamUrl", () => {
|
|
59
|
+
// An empty teamUrl is the schema default — e.g. installs whose Slack
|
|
60
|
+
// connection came through the gateway and never ran the local bot-token
|
|
61
|
+
// setup. Links must still be produced via the slack.com permalink form.
|
|
62
|
+
seedSlack({ teamId: "", teamUrl: "" });
|
|
62
63
|
const metadata = buildSlackBindingMetadata(makeBinding({}));
|
|
63
|
-
expect(metadata.sourceLink).
|
|
64
|
+
expect(metadata.sourceLink).toEqual({
|
|
65
|
+
webUrl: "https://slack.com/archives/C0CHANNEL",
|
|
66
|
+
});
|
|
67
|
+
expect(metadata.slackChannel?.link).toEqual({
|
|
68
|
+
webUrl: "https://slack.com/archives/C0CHANNEL",
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("thread link falls back to the slack.com permalink without a teamUrl", () => {
|
|
73
|
+
seedSlack({ teamId: "", teamUrl: "" });
|
|
74
|
+
const metadata = buildSlackBindingMetadata(
|
|
75
|
+
makeBinding({ externalThreadId: "1720000000.000100" }),
|
|
76
|
+
);
|
|
77
|
+
expect(metadata.slackThread?.link).toEqual({
|
|
78
|
+
webUrl: "https://slack.com/archives/C0CHANNEL/p1720000000000100",
|
|
79
|
+
});
|
|
80
|
+
expect(metadata.sourceLink).toEqual(metadata.slackThread?.link);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("keeps the slack:// app link when only the teamUrl is missing", () => {
|
|
84
|
+
seedSlack({ teamUrl: "" });
|
|
85
|
+
const metadata = buildSlackBindingMetadata(
|
|
86
|
+
makeBinding({ externalThreadId: "1720000000.000100" }),
|
|
87
|
+
);
|
|
88
|
+
expect(metadata.slackThread?.link).toEqual({
|
|
89
|
+
appUrl:
|
|
90
|
+
"slack://channel?team=T0EXAMPLE&id=C0CHANNEL&message=1720000000.000100",
|
|
91
|
+
webUrl: "https://slack.com/archives/C0CHANNEL/p1720000000000100",
|
|
92
|
+
});
|
|
64
93
|
});
|
|
65
94
|
});
|
|
@@ -16,8 +16,8 @@ import {
|
|
|
16
16
|
* (`ChannelBindingMetadata`) — the single source of truth that also drives
|
|
17
17
|
* `openapi.yaml` and the web client's generated types — so this builder cannot
|
|
18
18
|
* drift from the wire contract. Slack is the only channel that can currently
|
|
19
|
-
* produce message-level deep links, because the link inputs (
|
|
20
|
-
*
|
|
19
|
+
* produce message-level deep links, because the link inputs (a channel id and
|
|
20
|
+
* a stable per-message timestamp) only exist for Slack.
|
|
21
21
|
*/
|
|
22
22
|
export function buildSlackBindingMetadata(
|
|
23
23
|
binding: ExternalConversationBinding,
|
|
@@ -26,15 +26,18 @@ export function buildSlackBindingMetadata(
|
|
|
26
26
|
binding.externalChatName?.trim() || binding.externalChatId;
|
|
27
27
|
const slackConfig = getConfig().slack;
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
29
|
+
// The deep-link builders fall back to workspace-agnostic slack.com URLs
|
|
30
|
+
// when the workspace identity (teamId/teamUrl) is not configured — e.g.
|
|
31
|
+
// installs whose Slack connection came through the gateway and never ran
|
|
32
|
+
// the local bot-token setup — so links are always produced.
|
|
33
|
+
const threadLink = binding.externalThreadId
|
|
34
|
+
? buildSlackMessageDeepLinks({
|
|
35
|
+
teamId: slackConfig?.teamId,
|
|
36
|
+
teamUrl: slackConfig?.teamUrl,
|
|
37
|
+
channelId: binding.externalChatId,
|
|
38
|
+
messageTs: binding.externalThreadId,
|
|
39
|
+
})
|
|
40
|
+
: undefined;
|
|
38
41
|
const slackThread = binding.externalThreadId
|
|
39
42
|
? {
|
|
40
43
|
channelId: binding.externalChatId,
|
|
@@ -43,17 +46,14 @@ export function buildSlackBindingMetadata(
|
|
|
43
46
|
}
|
|
44
47
|
: undefined;
|
|
45
48
|
|
|
46
|
-
const channelWebUrl =
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
})
|
|
51
|
-
: undefined;
|
|
49
|
+
const channelWebUrl = buildSlackWebChannelUrl({
|
|
50
|
+
teamUrl: slackConfig?.teamUrl,
|
|
51
|
+
channelId: binding.externalChatId,
|
|
52
|
+
});
|
|
52
53
|
|
|
53
54
|
// Channel-neutral source link: prefer the bound thread, fall back to the
|
|
54
55
|
// channel, so clients land on the most specific source available.
|
|
55
|
-
const sourceLink =
|
|
56
|
-
threadLink ?? (channelWebUrl ? { webUrl: channelWebUrl } : undefined);
|
|
56
|
+
const sourceLink = threadLink ?? { webUrl: channelWebUrl };
|
|
57
57
|
|
|
58
58
|
return {
|
|
59
59
|
externalChatName,
|
|
@@ -61,8 +61,8 @@ export function buildSlackBindingMetadata(
|
|
|
61
61
|
slackChannel: {
|
|
62
62
|
channelId: binding.externalChatId,
|
|
63
63
|
name: externalChatName,
|
|
64
|
-
|
|
64
|
+
link: { webUrl: channelWebUrl },
|
|
65
65
|
},
|
|
66
|
-
|
|
66
|
+
sourceLink,
|
|
67
67
|
};
|
|
68
68
|
}
|