@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,91 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
buildSlackMessageDeepLinks,
|
|
5
|
+
buildSlackWebChannelUrl,
|
|
6
|
+
} from "./deep-link.js";
|
|
7
|
+
|
|
8
|
+
describe("buildSlackMessageDeepLinks", () => {
|
|
9
|
+
test("builds workspace-branded links when team identity is configured", () => {
|
|
10
|
+
expect(
|
|
11
|
+
buildSlackMessageDeepLinks({
|
|
12
|
+
teamId: "T123",
|
|
13
|
+
teamUrl: "https://example.slack.com",
|
|
14
|
+
channelId: "C123",
|
|
15
|
+
messageTs: "1710000000.000200",
|
|
16
|
+
threadTs: "1710000000.000100",
|
|
17
|
+
}),
|
|
18
|
+
).toEqual({
|
|
19
|
+
appUrl: "slack://channel?team=T123&id=C123&message=1710000000.000200",
|
|
20
|
+
webUrl:
|
|
21
|
+
"https://example.slack.com/archives/C123/p1710000000000200?thread_ts=1710000000.000100&cid=C123",
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("falls back to the workspace-agnostic permalink without a teamUrl", () => {
|
|
26
|
+
expect(
|
|
27
|
+
buildSlackMessageDeepLinks({
|
|
28
|
+
teamId: "",
|
|
29
|
+
teamUrl: "",
|
|
30
|
+
channelId: "C123",
|
|
31
|
+
messageTs: "1710000000.000200",
|
|
32
|
+
threadTs: "1710000000.000100",
|
|
33
|
+
}),
|
|
34
|
+
).toEqual({
|
|
35
|
+
webUrl:
|
|
36
|
+
"https://slack.com/archives/C123/p1710000000000200?thread_ts=1710000000.000100&cid=C123",
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("fallback permalink omits thread params for a thread root", () => {
|
|
41
|
+
expect(
|
|
42
|
+
buildSlackMessageDeepLinks({
|
|
43
|
+
channelId: "C123",
|
|
44
|
+
messageTs: "1710000000.000100",
|
|
45
|
+
threadTs: "1710000000.000100",
|
|
46
|
+
}),
|
|
47
|
+
).toEqual({
|
|
48
|
+
webUrl: "https://slack.com/archives/C123/p1710000000000100",
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("keeps the slack:// app link when only the teamUrl is missing", () => {
|
|
53
|
+
expect(
|
|
54
|
+
buildSlackMessageDeepLinks({
|
|
55
|
+
teamId: "T123",
|
|
56
|
+
channelId: "C123",
|
|
57
|
+
messageTs: "1710000000.000100",
|
|
58
|
+
}),
|
|
59
|
+
).toEqual({
|
|
60
|
+
appUrl: "slack://channel?team=T123&id=C123&message=1710000000.000100",
|
|
61
|
+
webUrl: "https://slack.com/archives/C123/p1710000000000100",
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("rejects a non-https teamUrl and falls back to slack.com", () => {
|
|
66
|
+
expect(
|
|
67
|
+
buildSlackMessageDeepLinks({
|
|
68
|
+
teamUrl: "http://example.slack.com",
|
|
69
|
+
channelId: "C123",
|
|
70
|
+
messageTs: "1710000000.000100",
|
|
71
|
+
}).webUrl,
|
|
72
|
+
).toBe("https://slack.com/archives/C123/p1710000000000100");
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("buildSlackWebChannelUrl", () => {
|
|
77
|
+
test("uses the workspace URL when configured", () => {
|
|
78
|
+
expect(
|
|
79
|
+
buildSlackWebChannelUrl({
|
|
80
|
+
teamUrl: "https://example.slack.com",
|
|
81
|
+
channelId: "C123",
|
|
82
|
+
}),
|
|
83
|
+
).toBe("https://example.slack.com/archives/C123");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("falls back to slack.com without a teamUrl", () => {
|
|
87
|
+
expect(buildSlackWebChannelUrl({ channelId: "C123" })).toBe(
|
|
88
|
+
"https://slack.com/archives/C123",
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -94,28 +94,47 @@ export function buildSlackPermalink(params: {
|
|
|
94
94
|
);
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Web URL for a channel. Workspace-branded when the team URL is known,
|
|
99
|
+
* otherwise the workspace-agnostic `https://slack.com/archives/…` form —
|
|
100
|
+
* Slack resolves the channel id to the right workspace for any
|
|
101
|
+
* authenticated viewer (the web client already synthesizes this exact
|
|
102
|
+
* shape from message permalinks, see `getSlackChannelLinkFromMessageLink`
|
|
103
|
+
* in `clients/web`).
|
|
104
|
+
*/
|
|
97
105
|
export function buildSlackWebChannelUrl(params: {
|
|
98
106
|
teamUrl?: string | null;
|
|
99
107
|
channelId: string;
|
|
100
|
-
}): string
|
|
101
|
-
const teamUrl = normalizeSlackTeamUrl(params.teamUrl);
|
|
102
|
-
if (!teamUrl) return undefined;
|
|
103
|
-
|
|
108
|
+
}): string {
|
|
109
|
+
const teamUrl = normalizeSlackTeamUrl(params.teamUrl) ?? "https://slack.com";
|
|
104
110
|
return `${teamUrl}/archives/${encodeURIComponent(params.channelId)}`;
|
|
105
111
|
}
|
|
106
112
|
|
|
113
|
+
/**
|
|
114
|
+
* Deep-link pair for a message. The web URL always exists: it is
|
|
115
|
+
* workspace-branded when the team URL is configured and otherwise falls
|
|
116
|
+
* back to the workspace-agnostic `buildSlackPermalink` form, so installs
|
|
117
|
+
* that never learned their workspace identity (e.g. gateway-connected
|
|
118
|
+
* Slack) still get working links. The `slack://` app URL still requires
|
|
119
|
+
* a known team id.
|
|
120
|
+
*/
|
|
107
121
|
export function buildSlackMessageDeepLinks(params: {
|
|
108
122
|
teamId?: string | null;
|
|
109
123
|
teamUrl?: string | null;
|
|
110
124
|
channelId: string;
|
|
111
125
|
messageTs: string;
|
|
112
126
|
threadTs?: string;
|
|
113
|
-
}): SlackMessageDeepLinks
|
|
127
|
+
}): SlackMessageDeepLinks {
|
|
114
128
|
const appUrl = buildSlackAppMessageUrl(params);
|
|
115
|
-
const webUrl =
|
|
116
|
-
|
|
129
|
+
const webUrl =
|
|
130
|
+
buildSlackWebMessageUrl(params) ??
|
|
131
|
+
buildSlackPermalink({
|
|
132
|
+
channelId: params.channelId,
|
|
133
|
+
messageTs: params.messageTs,
|
|
134
|
+
...(params.threadTs ? { threadTs: params.threadTs } : {}),
|
|
135
|
+
});
|
|
117
136
|
return {
|
|
118
137
|
...(appUrl ? { appUrl } : {}),
|
|
119
|
-
|
|
138
|
+
webUrl,
|
|
120
139
|
};
|
|
121
140
|
}
|
|
@@ -1407,5 +1407,27 @@ describe("Memory Item Routes", () => {
|
|
|
1407
1407
|
};
|
|
1408
1408
|
expect(body.graph_supported).toBe(false);
|
|
1409
1409
|
});
|
|
1410
|
+
|
|
1411
|
+
// `tier` is what lets a client explain an unavailable graph instead of
|
|
1412
|
+
// stating a bare "not available": the opt-out and a legacy engine are
|
|
1413
|
+
// different problems with different fixes.
|
|
1414
|
+
test.each([
|
|
1415
|
+
["off", { enabled: false, v3: { live: true } }],
|
|
1416
|
+
["v3", { enabled: true, v3: { live: true } }],
|
|
1417
|
+
["v2", { enabled: true, v2: { enabled: true }, v3: { live: false } }],
|
|
1418
|
+
["v1", { enabled: true, v2: { enabled: false }, v3: { live: false } }],
|
|
1419
|
+
] as const)("reports tier %s", async (tier, memory) => {
|
|
1420
|
+
setConfig("memory", memory);
|
|
1421
|
+
const res = await callHandler(route);
|
|
1422
|
+
expect(res.status).toBe(200);
|
|
1423
|
+
const body = (await res.json()) as {
|
|
1424
|
+
tier: string;
|
|
1425
|
+
graph_supported: boolean;
|
|
1426
|
+
};
|
|
1427
|
+
expect(body.tier).toBe(tier);
|
|
1428
|
+
// The capability bit and its explanation are derived from the same gate,
|
|
1429
|
+
// so they can never disagree.
|
|
1430
|
+
expect(body.graph_supported).toBe(tier === "v3");
|
|
1431
|
+
});
|
|
1410
1432
|
});
|
|
1411
1433
|
});
|
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
import { z } from "zod";
|
|
33
33
|
|
|
34
34
|
import { getConfig } from "../../../../config/loader.js";
|
|
35
|
+
import { type MemoryTier, memoryTier } from "../../../../config/memory-tier.js";
|
|
35
36
|
import {
|
|
36
37
|
isV3TierActive,
|
|
37
38
|
usesConceptPageMemory,
|
|
@@ -523,13 +524,24 @@ function handleGetMemoryItem(id: string) {
|
|
|
523
524
|
* `GET /memory-graph` returns `supported: true` (memory enabled + v3 live). It
|
|
524
525
|
* is a cheap config read (no page I/O), so glanceable surfaces can gate the
|
|
525
526
|
* graph entry point on real availability without triggering the graph build.
|
|
527
|
+
*
|
|
528
|
+
* `tier` reports WHY the graph is or isn't available, so a client can say
|
|
529
|
+
* something true instead of a bare "not available": `"off"` is the user's own
|
|
530
|
+
* Memory opt-out (fixed in Settings), while `"v1"`/`"v2"` are legacy engines
|
|
531
|
+
* (fixed by migrating to v3). Both bits derive from the same gate predicates —
|
|
532
|
+
* `graph_supported` is exactly `tier === "v3"` (see `memory-tier.ts`) — so the
|
|
533
|
+
* capability and its explanation can never disagree.
|
|
526
534
|
*/
|
|
527
535
|
async function handleGetMemoryStats(
|
|
528
536
|
config: AssistantConfig,
|
|
529
|
-
): Promise<{ concepts: number; graph_supported: boolean }> {
|
|
537
|
+
): Promise<{ concepts: number; graph_supported: boolean; tier: MemoryTier }> {
|
|
530
538
|
const pageIndex = await getPageIndex(getWorkspaceDir());
|
|
531
539
|
const concepts = pageIndex.entries.filter((e) => e.modifiedAt > 0).length;
|
|
532
|
-
return {
|
|
540
|
+
return {
|
|
541
|
+
concepts,
|
|
542
|
+
graph_supported: isV3TierActive(config),
|
|
543
|
+
tier: memoryTier(config),
|
|
544
|
+
};
|
|
533
545
|
}
|
|
534
546
|
|
|
535
547
|
async function handleCreateMemoryItem(body: Record<string, unknown>) {
|
|
@@ -864,7 +876,9 @@ export const ROUTES: RouteDefinition[] = [
|
|
|
864
876
|
"concept pages only and never builds the memory-concept graph. Also " +
|
|
865
877
|
"reports graph_supported: whether the memory-concept graph is available " +
|
|
866
878
|
"for this assistant (memory enabled and v3 live), so callers can gate " +
|
|
867
|
-
"the graph entry point without building the graph
|
|
879
|
+
"the graph entry point without building the graph, plus tier: the coarse " +
|
|
880
|
+
"memory tier explaining why the graph is unavailable (off = the user's " +
|
|
881
|
+
"Memory opt-out, v1/v2 = a legacy engine that has not migrated to v3).",
|
|
868
882
|
tags: ["memory"],
|
|
869
883
|
responseBody: z.object({
|
|
870
884
|
concepts: z.number().describe("Number of concept pages in memory"),
|
|
@@ -873,6 +887,11 @@ export const ROUTES: RouteDefinition[] = [
|
|
|
873
887
|
.describe(
|
|
874
888
|
"Whether the memory-concept graph is available (memory enabled and v3 live)",
|
|
875
889
|
),
|
|
890
|
+
tier: z
|
|
891
|
+
.enum(["off", "v1", "v2", "v3"])
|
|
892
|
+
.describe(
|
|
893
|
+
"Coarse memory tier for this assistant; graph_supported is exactly tier === 'v3'",
|
|
894
|
+
),
|
|
876
895
|
}),
|
|
877
896
|
handler: () => handleGetMemoryStats(getConfig()),
|
|
878
897
|
},
|
|
@@ -140,14 +140,12 @@ async function checkCredential(
|
|
|
140
140
|
}
|
|
141
141
|
|
|
142
142
|
/** Check that public ingress is configured and enabled. */
|
|
143
|
-
function checkIngress(
|
|
143
|
+
async function checkIngress(
|
|
144
144
|
allowManagedCallbacks = false,
|
|
145
145
|
options: { twilio?: boolean } = {},
|
|
146
|
-
): ReadinessCheckResult {
|
|
147
|
-
const { configured, usesManagedCallbacks } =
|
|
148
|
-
allowManagedCallbacks,
|
|
149
|
-
options,
|
|
150
|
-
);
|
|
146
|
+
): Promise<ReadinessCheckResult> {
|
|
147
|
+
const { configured, usesManagedCallbacks } =
|
|
148
|
+
await hasWebhookRoutingConfigured(allowManagedCallbacks, options);
|
|
151
149
|
return check(
|
|
152
150
|
"ingress",
|
|
153
151
|
configured,
|
|
@@ -173,7 +171,7 @@ const voiceProbe: ChannelProbe = {
|
|
|
173
171
|
async runLocalChecks(): Promise<ReadinessCheckResult[]> {
|
|
174
172
|
const hasCreds = await hasTwilioCredentials();
|
|
175
173
|
const hasPhone = !!resolveTwilioPhoneNumber();
|
|
176
|
-
const ingress = checkIngress(true, { twilio: true });
|
|
174
|
+
const ingress = await checkIngress(true, { twilio: true });
|
|
177
175
|
|
|
178
176
|
return [
|
|
179
177
|
check(
|
|
@@ -211,7 +209,7 @@ const telegramProbe: ChannelProbe = {
|
|
|
211
209
|
"webhook_secret",
|
|
212
210
|
"Telegram webhook secret",
|
|
213
211
|
),
|
|
214
|
-
checkIngress(true),
|
|
212
|
+
await checkIngress(true),
|
|
215
213
|
];
|
|
216
214
|
},
|
|
217
215
|
};
|
|
@@ -234,7 +232,7 @@ const emailProbe: ChannelProbe = {
|
|
|
234
232
|
"Email invite code redemption is enabled",
|
|
235
233
|
"Email invite code redemption is disabled",
|
|
236
234
|
),
|
|
237
|
-
checkIngress(),
|
|
235
|
+
await checkIngress(),
|
|
238
236
|
];
|
|
239
237
|
},
|
|
240
238
|
async runRemoteChecks(): Promise<ReadinessCheckResult[]> {
|
|
@@ -307,7 +305,7 @@ const whatsappProbe: ChannelProbe = {
|
|
|
307
305
|
"WhatsApp invite code redemption is enabled",
|
|
308
306
|
"WhatsApp invite code redemption is disabled",
|
|
309
307
|
),
|
|
310
|
-
checkIngress(),
|
|
308
|
+
await checkIngress(),
|
|
311
309
|
];
|
|
312
310
|
},
|
|
313
311
|
};
|
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
} from "../../inbound/platform-callback-registration.js";
|
|
20
20
|
import {
|
|
21
21
|
getPublicBaseUrl,
|
|
22
|
-
|
|
22
|
+
isPublicIngressDisabled,
|
|
23
23
|
} from "../../inbound/public-ingress-urls.js";
|
|
24
24
|
import { ACTOR_PRINCIPALS } from "../auth/route-policy.js";
|
|
25
25
|
import {
|
|
@@ -101,18 +101,6 @@ async function registerWithPlatform(
|
|
|
101
101
|
return { callbackUrl, type, path: webhookPath, mode: "platform" };
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
/**
|
|
105
|
-
* True when the user has explicitly switched public ingress off.
|
|
106
|
-
*
|
|
107
|
-
* An explicit opt-out is a decision not to accept inbound webhooks at all, so
|
|
108
|
-
* it must not be silently routed around via platform callbacks. An *absent*
|
|
109
|
-
* ingress config is merely "not set up yet" and is eligible for the platform
|
|
110
|
-
* fallback.
|
|
111
|
-
*/
|
|
112
|
-
function isPublicIngressDisabled(config: IngressConfig): boolean {
|
|
113
|
-
return config.ingress?.enabled === false;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
104
|
// ---------------------------------------------------------------------------
|
|
117
105
|
// Handlers
|
|
118
106
|
// ---------------------------------------------------------------------------
|
|
@@ -4,10 +4,14 @@ import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
|
4
4
|
|
|
5
5
|
const secureKeyValues = new Map<string, string>();
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
// Webhook routing is driven through its real inputs rather than by mocking
|
|
8
|
+
// `hasWebhookRoutingConfigured` itself. Stubbing the predicate would have let
|
|
9
|
+
// the sweep and the predicate drift apart unnoticed, which is exactly how
|
|
10
|
+
// LUM-2882 stayed invisible: the predicate stopped matching what
|
|
11
|
+
// `webhooks register` actually does for platform-connected local assistants.
|
|
12
|
+
let ingressConfig: Record<string, unknown> = {};
|
|
13
|
+
let isPlatform = false;
|
|
14
|
+
let platformContextEnabled = false;
|
|
11
15
|
|
|
12
16
|
type FetchOutcome =
|
|
13
17
|
| { kind: "json"; status?: number; body: unknown }
|
|
@@ -34,11 +38,6 @@ mock.module("../../security/credential-key.js", () => ({
|
|
|
34
38
|
`credential/${service}/${field}`,
|
|
35
39
|
}));
|
|
36
40
|
|
|
37
|
-
mock.module("../../config/webhook-routing.js", () => ({
|
|
38
|
-
hasWebhookRoutingConfigured: () => webhookRouting,
|
|
39
|
-
hasIngressConfigured: () => webhookRouting.configured,
|
|
40
|
-
}));
|
|
41
|
-
|
|
42
41
|
mock.module("../bot-username.js", () => ({
|
|
43
42
|
getTelegramBotUsername: () => "test_bot",
|
|
44
43
|
getTelegramBotId: () => "123",
|
|
@@ -46,8 +45,33 @@ mock.module("../bot-username.js", () => ({
|
|
|
46
45
|
|
|
47
46
|
let apiBaseUrl = "https://api.telegram.org";
|
|
48
47
|
|
|
48
|
+
// Spread the real modules below: these are broad barrels shared with peer test
|
|
49
|
+
// files, and replacing one wholesale drops the exports those files import.
|
|
50
|
+
const actualLoader = await import("../../config/loader.js");
|
|
49
51
|
mock.module("../../config/loader.js", () => ({
|
|
50
|
-
|
|
52
|
+
...actualLoader,
|
|
53
|
+
getConfig: () => ({ telegram: { apiBaseUrl }, ingress: ingressConfig }),
|
|
54
|
+
loadRawConfig: () => ({ ingress: ingressConfig }),
|
|
55
|
+
}));
|
|
56
|
+
|
|
57
|
+
const actualEnvRegistry = await import("../../config/env-registry.js");
|
|
58
|
+
mock.module("../../config/env-registry.js", () => ({
|
|
59
|
+
...actualEnvRegistry,
|
|
60
|
+
getIsPlatform: () => isPlatform,
|
|
61
|
+
}));
|
|
62
|
+
|
|
63
|
+
const actualRegistration =
|
|
64
|
+
await import("../../inbound/platform-callback-registration.js");
|
|
65
|
+
mock.module("../../inbound/platform-callback-registration.js", () => ({
|
|
66
|
+
...actualRegistration,
|
|
67
|
+
resolvePlatformCallbackRegistrationContext: async () => ({
|
|
68
|
+
isPlatform,
|
|
69
|
+
platformBaseUrl: "https://api.vellum.ai",
|
|
70
|
+
assistantId: platformContextEnabled ? "assistant-123" : "",
|
|
71
|
+
hasAssistantApiKey: platformContextEnabled,
|
|
72
|
+
authHeader: platformContextEnabled ? "Api-Key secret" : null,
|
|
73
|
+
enabled: platformContextEnabled,
|
|
74
|
+
}),
|
|
51
75
|
}));
|
|
52
76
|
|
|
53
77
|
// Mirrors the real `emitNotificationSignal` contract: it swallows pipeline
|
|
@@ -121,7 +145,9 @@ beforeEach(() => {
|
|
|
121
145
|
secureKeyValues.clear();
|
|
122
146
|
secureKeyValues.set(BOT_TOKEN_KEY, "12345:test-token");
|
|
123
147
|
secureKeyValues.set(WEBHOOK_SECRET_KEY, "s3cret");
|
|
124
|
-
|
|
148
|
+
ingressConfig = { publicBaseUrl: "https://example.test" };
|
|
149
|
+
isPlatform = false;
|
|
150
|
+
platformContextEnabled = false;
|
|
125
151
|
setWebhookInfo({ url: WEBHOOK_URL, pending_update_count: 0 });
|
|
126
152
|
fetchCallCount = 0;
|
|
127
153
|
fetchedUrls.length = 0;
|
|
@@ -158,7 +184,20 @@ describe("gating", () => {
|
|
|
158
184
|
});
|
|
159
185
|
|
|
160
186
|
test("does not run when no webhook routing is configured", async () => {
|
|
161
|
-
|
|
187
|
+
ingressConfig = {};
|
|
188
|
+
|
|
189
|
+
const result = await runTelegramWebhookHealthCheck();
|
|
190
|
+
|
|
191
|
+
expect(result.status).toBe("skipped");
|
|
192
|
+
expect(fetchCallCount).toBe(0);
|
|
193
|
+
expect(emittedSignals).toHaveLength(0);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("does not run when public ingress is explicitly disabled", async () => {
|
|
197
|
+
// An opt-out means no inbound webhook is expected at all, so the sweep has
|
|
198
|
+
// nothing to verify even though platform credentials are present.
|
|
199
|
+
ingressConfig = { enabled: false };
|
|
200
|
+
platformContextEnabled = true;
|
|
162
201
|
|
|
163
202
|
const result = await runTelegramWebhookHealthCheck();
|
|
164
203
|
|
|
@@ -167,8 +206,32 @@ describe("gating", () => {
|
|
|
167
206
|
expect(emittedSignals).toHaveLength(0);
|
|
168
207
|
});
|
|
169
208
|
|
|
209
|
+
test("runs for a platform-connected local assistant with no ingress", async () => {
|
|
210
|
+
// LUM-2882: `webhooks register telegram` registers a platform callback
|
|
211
|
+
// route in this exact configuration, so a broken registration is real and
|
|
212
|
+
// the sweep has to verify it rather than skip.
|
|
213
|
+
ingressConfig = {};
|
|
214
|
+
platformContextEnabled = true;
|
|
215
|
+
setWebhookInfo({
|
|
216
|
+
url: WEBHOOK_URL,
|
|
217
|
+
last_error_date: unixSecondsAgo(30),
|
|
218
|
+
last_error_message: "Wrong response from the webhook: 404 Not Found",
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
const result = await runTelegramWebhookHealthCheck();
|
|
222
|
+
|
|
223
|
+
expect(result.status).toBe("delivery_failing");
|
|
224
|
+
expect(fetchCallCount).toBeGreaterThan(0);
|
|
225
|
+
expect(emittedSignals).toHaveLength(1);
|
|
226
|
+
// The callback route is platform-owned, so the self-hosted remediation
|
|
227
|
+
// (point config at a new tunnel URL) does not apply.
|
|
228
|
+
expect(result.detail).not.toContain("assistant config set");
|
|
229
|
+
expect(result.detail).toContain("contact support");
|
|
230
|
+
});
|
|
231
|
+
|
|
170
232
|
test("runs when platform-managed callbacks stand in for public ingress", async () => {
|
|
171
|
-
|
|
233
|
+
isPlatform = true;
|
|
234
|
+
ingressConfig = {};
|
|
172
235
|
setWebhookInfo({
|
|
173
236
|
url: WEBHOOK_URL,
|
|
174
237
|
last_error_date: unixSecondsAgo(30),
|
|
@@ -191,7 +191,7 @@ export async function checkTelegramWebhookHealth(): Promise<TelegramWebhookHealt
|
|
|
191
191
|
}
|
|
192
192
|
|
|
193
193
|
const { configured, usesManagedCallbacks } =
|
|
194
|
-
hasWebhookRoutingConfigured(true);
|
|
194
|
+
await hasWebhookRoutingConfigured(true);
|
|
195
195
|
if (!configured) {
|
|
196
196
|
return {
|
|
197
197
|
status: "skipped",
|