@vellumai/vellum-gateway 0.8.2 → 0.8.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +1 -1
- package/package.json +1 -1
- package/src/__tests__/config-file-watcher.test.ts +57 -0
- package/src/__tests__/ipc-slack-thread-routes.test.ts +157 -0
- package/src/__tests__/route-schema-guard.test.ts +4 -0
- package/src/__tests__/slack-display-name.test.ts +218 -0
- package/src/__tests__/slack-socket-mode-thread-tracking.test.ts +98 -4
- package/src/__tests__/twilio-webhooks.test.ts +47 -0
- package/src/auth/ipc-route-policy.ts +6 -0
- package/src/channels/inbound-event.ts +8 -2
- package/src/channels/types.ts +2 -0
- package/src/config-file-watcher.ts +44 -1
- package/src/db/slack-store.ts +10 -0
- package/src/feature-flag-registry.json +111 -23
- package/src/handlers/handle-inbound.ts +6 -4
- package/src/http/routes/a2a-routes.test.ts +129 -0
- package/src/http/routes/a2a-routes.ts +121 -0
- package/src/http/routes/twilio-voice-verify-callback.ts +41 -12
- package/src/http/routes/twilio-voice-webhook.test.ts +55 -0
- package/src/http/routes/twilio-voice-webhook.ts +10 -2
- package/src/index.ts +16 -0
- package/src/ipc/slack-thread-handlers.ts +39 -0
- package/src/risk/bash-risk-classifier.test.ts +24 -0
- package/src/risk/command-registry/commands/assistant.ts +33 -0
- package/src/risk/command-registry.test.ts +5 -0
- package/src/runtime/client.ts +66 -14
- package/src/slack/normalize.ts +78 -26
- package/src/slack/socket-mode.ts +2 -2
- package/src/twilio/validate-webhook.ts +7 -1
- package/src/types.ts +1 -0
- package/src/velay/client.test.ts +100 -0
- package/src/velay/client.ts +73 -0
package/ARCHITECTURE.md
CHANGED
|
@@ -298,7 +298,7 @@ The assistant runtime reads this URL via the centralized `public-ingress-urls.ts
|
|
|
298
298
|
|
|
299
299
|
Velay is a platform-managed tunnel for assistant-hosted HTTP and WebSocket traffic. When it is active, Velay publishes the registered public assistant URL to `ingress.publicBaseUrl` and marks it with `ingress.publicBaseUrlManagedBy: "velay"`.
|
|
300
300
|
|
|
301
|
-
When `VELAY_BASE_URL` is present in the gateway environment, the gateway creates `VelayTunnelClient` but starts it only after Twilio setup has been started in the workspace. On boot, existing Twilio credentials or existing `twilio.accountSid` / `twilio.phoneNumber` config count as prior setup
|
|
301
|
+
When `VELAY_BASE_URL` is present in the gateway environment, the gateway creates `VelayTunnelClient` but starts it only after Twilio setup has been started in the workspace. The Twilio setup skill writes `twilio.setupStarted: true` at the beginning of setup so the tunnel can open while credentials and phone-number selection are still in progress. On boot, existing Twilio credentials or existing `twilio.accountSid` / `twilio.phoneNumber` config also count as prior setup. Before credential-backed startup side effects run, the gateway clears any stale Velay-managed `ingress.publicBaseUrl`; if setup has not started, it does this without opening a tunnel. The client registers with Velay over `GET /v1/register` using the assistant API key, then receives a `registered` frame containing a public assistant URL such as `https://velay.vellum.ai/<assistant-id>`. If Vellum platform credentials change while the tunnel is already running or waiting on backoff, the gateway asks the client to reconnect with fresh credentials instead of waiting for process restart. The gateway writes the registered URL to `ingress.publicBaseUrl`. When the tunnel disconnects, it clears that value only if the Velay ownership marker is still present and the URL still matches what the tunnel published, leaving manual URLs intact.
|
|
302
302
|
|
|
303
303
|
Velay forwards both HTTP request frames and WebSocket frames into the local gateway loopback listener:
|
|
304
304
|
|
package/package.json
CHANGED
|
@@ -42,6 +42,31 @@ function makeEvent(
|
|
|
42
42
|
};
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
function makeManualIntervalTimer() {
|
|
46
|
+
let intervalFn: (() => void) | undefined;
|
|
47
|
+
let cleared = false;
|
|
48
|
+
type IntervalHandle = ReturnType<typeof setInterval>;
|
|
49
|
+
const timer = 1 as unknown as IntervalHandle;
|
|
50
|
+
return {
|
|
51
|
+
timerApi: {
|
|
52
|
+
setInterval: (fn: () => void, _delayMs: number) => {
|
|
53
|
+
intervalFn = fn;
|
|
54
|
+
return timer;
|
|
55
|
+
},
|
|
56
|
+
clearInterval: (_timer: IntervalHandle) => {
|
|
57
|
+
cleared = true;
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
runInterval: () => {
|
|
61
|
+
if (!intervalFn) {
|
|
62
|
+
throw new Error("interval was not scheduled");
|
|
63
|
+
}
|
|
64
|
+
intervalFn();
|
|
65
|
+
},
|
|
66
|
+
isCleared: () => cleared,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
45
70
|
afterEach(() => {
|
|
46
71
|
try {
|
|
47
72
|
if (existsSync(configPath)) unlinkSync(configPath);
|
|
@@ -51,6 +76,38 @@ afterEach(() => {
|
|
|
51
76
|
});
|
|
52
77
|
|
|
53
78
|
describe("ConfigFileWatcher", () => {
|
|
79
|
+
test("polls config changes when file watcher events are missed", () => {
|
|
80
|
+
const events: ConfigChangeEvent[] = [];
|
|
81
|
+
const timer = makeManualIntervalTimer();
|
|
82
|
+
const watcher = new ConfigFileWatcher(
|
|
83
|
+
(event) => {
|
|
84
|
+
events.push(event);
|
|
85
|
+
},
|
|
86
|
+
{ pollIntervalMs: 10, timerApi: timer.timerApi },
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
watcher.start();
|
|
91
|
+
writeConfig({
|
|
92
|
+
twilio: {
|
|
93
|
+
setupStarted: true,
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
timer.runInterval();
|
|
98
|
+
|
|
99
|
+
expect(events).toHaveLength(1);
|
|
100
|
+
expect(events[0].changedKeys).toEqual(new Set(["twilio"]));
|
|
101
|
+
expect(events[0].changedFields.get("twilio")).toEqual(
|
|
102
|
+
new Set(["setupStarted"]),
|
|
103
|
+
);
|
|
104
|
+
} finally {
|
|
105
|
+
watcher.stop();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
expect(timer.isCleared()).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
|
|
54
111
|
test("reports shallow ingress fields changed by Velay-managed URL writes", () => {
|
|
55
112
|
writeConfig({
|
|
56
113
|
ingress: {
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import {
|
|
2
|
+
afterAll,
|
|
3
|
+
afterEach,
|
|
4
|
+
beforeAll,
|
|
5
|
+
beforeEach,
|
|
6
|
+
describe,
|
|
7
|
+
expect,
|
|
8
|
+
test,
|
|
9
|
+
} from "bun:test";
|
|
10
|
+
import { randomBytes } from "node:crypto";
|
|
11
|
+
import { createConnection, type Socket } from "node:net";
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
getGatewayDb,
|
|
15
|
+
initGatewayDb,
|
|
16
|
+
resetGatewayDb,
|
|
17
|
+
} from "../db/connection.js";
|
|
18
|
+
import { SlackStore } from "../db/slack-store.js";
|
|
19
|
+
import { slackActiveThreads } from "../db/schema.js";
|
|
20
|
+
import { GatewayIpcServer } from "../ipc/server.js";
|
|
21
|
+
import { slackThreadRoutes } from "../ipc/slack-thread-handlers.js";
|
|
22
|
+
|
|
23
|
+
const CHANNEL_ID = "CFAKE00001";
|
|
24
|
+
const OTHER_CHANNEL_ID = "COTHER0001";
|
|
25
|
+
const THREAD_TS = "1700000000.000000";
|
|
26
|
+
const OTHER_THREAD_TS = "1700000001.000000";
|
|
27
|
+
|
|
28
|
+
beforeAll(async () => {
|
|
29
|
+
await initGatewayDb();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
getGatewayDb().delete(slackActiveThreads).run();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterAll(() => {
|
|
37
|
+
resetGatewayDb();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
function connectClient(path: string): Promise<Socket> {
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
const client = createConnection(path, () => resolve(client));
|
|
43
|
+
client.on("error", reject);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function sendRequest(
|
|
48
|
+
client: Socket,
|
|
49
|
+
method: string,
|
|
50
|
+
params?: Record<string, unknown>,
|
|
51
|
+
): Promise<{ id: string; result?: unknown; error?: string }> {
|
|
52
|
+
return new Promise((resolve, reject) => {
|
|
53
|
+
const id = randomBytes(4).toString("hex");
|
|
54
|
+
let buffer = "";
|
|
55
|
+
|
|
56
|
+
const onData = (chunk: Buffer) => {
|
|
57
|
+
buffer += chunk.toString();
|
|
58
|
+
const newlineIdx = buffer.indexOf("\n");
|
|
59
|
+
if (newlineIdx !== -1) {
|
|
60
|
+
const line = buffer.slice(0, newlineIdx).trim();
|
|
61
|
+
buffer = buffer.slice(newlineIdx + 1);
|
|
62
|
+
client.off("data", onData);
|
|
63
|
+
try {
|
|
64
|
+
resolve(JSON.parse(line));
|
|
65
|
+
} catch (err) {
|
|
66
|
+
reject(err);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
client.on("data", onData);
|
|
72
|
+
client.write(JSON.stringify({ id, method, params }) + "\n");
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function activeThreadRows(): Array<{ threadTs: string; channelId: string }> {
|
|
77
|
+
return new SlackStore(getGatewayDb()).listActiveThreadsWithChannel();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function trackThread(): void {
|
|
81
|
+
new SlackStore(getGatewayDb()).trackThread(THREAD_TS, CHANNEL_ID, 60_000);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
describe("IPC Slack thread routes", () => {
|
|
85
|
+
let server: InstanceType<typeof GatewayIpcServer>;
|
|
86
|
+
let client: Socket;
|
|
87
|
+
|
|
88
|
+
afterEach(() => {
|
|
89
|
+
client?.destroy();
|
|
90
|
+
server?.stop();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
async function startServerAndConnect(): Promise<void> {
|
|
94
|
+
server = new GatewayIpcServer([...slackThreadRoutes]);
|
|
95
|
+
server.start();
|
|
96
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
97
|
+
client = await connectClient(server.getSocketPath());
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
test("detach_slack_active_thread removes a matching active thread", async () => {
|
|
101
|
+
trackThread();
|
|
102
|
+
|
|
103
|
+
await startServerAndConnect();
|
|
104
|
+
const res = await sendRequest(client, "detach_slack_active_thread", {
|
|
105
|
+
channelId: CHANNEL_ID,
|
|
106
|
+
threadTs: THREAD_TS,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
expect(res.error).toBeUndefined();
|
|
110
|
+
expect(res.result).toEqual({
|
|
111
|
+
detached: true,
|
|
112
|
+
channelId: CHANNEL_ID,
|
|
113
|
+
threadTs: THREAD_TS,
|
|
114
|
+
});
|
|
115
|
+
expect(activeThreadRows()).toEqual([]);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("detach_slack_active_thread is idempotent for an unknown thread", async () => {
|
|
119
|
+
trackThread();
|
|
120
|
+
|
|
121
|
+
await startServerAndConnect();
|
|
122
|
+
const res = await sendRequest(client, "detach_slack_active_thread", {
|
|
123
|
+
channelId: CHANNEL_ID,
|
|
124
|
+
threadTs: OTHER_THREAD_TS,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
expect(res.error).toBeUndefined();
|
|
128
|
+
expect(res.result).toEqual({
|
|
129
|
+
detached: false,
|
|
130
|
+
channelId: CHANNEL_ID,
|
|
131
|
+
threadTs: OTHER_THREAD_TS,
|
|
132
|
+
});
|
|
133
|
+
expect(activeThreadRows()).toEqual([
|
|
134
|
+
{ threadTs: THREAD_TS, channelId: CHANNEL_ID },
|
|
135
|
+
]);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("detach_slack_active_thread does not remove channel mismatches", async () => {
|
|
139
|
+
trackThread();
|
|
140
|
+
|
|
141
|
+
await startServerAndConnect();
|
|
142
|
+
const res = await sendRequest(client, "detach_slack_active_thread", {
|
|
143
|
+
channelId: OTHER_CHANNEL_ID,
|
|
144
|
+
threadTs: THREAD_TS,
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
expect(res.error).toBeUndefined();
|
|
148
|
+
expect(res.result).toEqual({
|
|
149
|
+
detached: false,
|
|
150
|
+
channelId: OTHER_CHANNEL_ID,
|
|
151
|
+
threadTs: THREAD_TS,
|
|
152
|
+
});
|
|
153
|
+
expect(activeThreadRows()).toEqual([
|
|
154
|
+
{ threadTs: THREAD_TS, channelId: CHANNEL_ID },
|
|
155
|
+
]);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
TWILIO_STATUS_WEBHOOK_PATH,
|
|
9
9
|
TWILIO_VOICE_WEBHOOK_PATH,
|
|
10
10
|
} from "@vellumai/service-contracts/twilio-ingress";
|
|
11
|
+
import { A2A_AGENT_CARD_PATH } from "../http/routes/a2a-routes.js";
|
|
11
12
|
import { buildSchema } from "../schema.js";
|
|
12
13
|
|
|
13
14
|
/** A route extracted from source: path + optional HTTP method. */
|
|
@@ -17,6 +18,7 @@ interface ExtractedRoute {
|
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
const ROUTE_PATH_CONSTANTS: Record<string, string> = {
|
|
21
|
+
A2A_AGENT_CARD_PATH,
|
|
20
22
|
TWILIO_CONNECT_ACTION_WEBHOOK_PATH,
|
|
21
23
|
TWILIO_MEDIA_STREAM_WEBHOOK_PATH,
|
|
22
24
|
TWILIO_RELAY_WEBHOOK_PATH,
|
|
@@ -169,6 +171,8 @@ const EXCLUDED_FROM_SCHEMA = new Set([
|
|
|
169
171
|
"catch-all",
|
|
170
172
|
// Loopback-only pairing endpoint — not part of the public gateway API
|
|
171
173
|
"/v1/pair",
|
|
174
|
+
// A2A agent card discovery — read-only, unauthenticated per spec
|
|
175
|
+
"/.well-known/agent-card.json",
|
|
172
176
|
]);
|
|
173
177
|
|
|
174
178
|
// ── Schema paths that don't map to a discrete route definition ──
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { describe, test, expect, mock, beforeEach } from "bun:test";
|
|
2
2
|
import type { GatewayConfig } from "../config.js";
|
|
3
|
+
import type {
|
|
4
|
+
RuntimeInboundPayload,
|
|
5
|
+
RuntimeInboundResponse,
|
|
6
|
+
} from "../runtime/client.js";
|
|
3
7
|
|
|
4
8
|
type FetchFn = (
|
|
5
9
|
input: string | URL | Request,
|
|
@@ -8,20 +12,46 @@ type FetchFn = (
|
|
|
8
12
|
let fetchMock: ReturnType<typeof mock<FetchFn>> = mock(
|
|
9
13
|
async () => new Response(),
|
|
10
14
|
);
|
|
15
|
+
let runtimePayloads: RuntimeInboundPayload[] = [];
|
|
16
|
+
const forwardToRuntimeMock = mock(
|
|
17
|
+
async (
|
|
18
|
+
_config: GatewayConfig,
|
|
19
|
+
payload: RuntimeInboundPayload,
|
|
20
|
+
): Promise<RuntimeInboundResponse> => {
|
|
21
|
+
runtimePayloads.push(payload);
|
|
22
|
+
return { accepted: true, duplicate: false, eventId: "runtime-event-1" };
|
|
23
|
+
},
|
|
24
|
+
);
|
|
11
25
|
|
|
12
26
|
mock.module("../fetch.js", () => ({
|
|
13
27
|
fetchImpl: (...args: Parameters<FetchFn>) => fetchMock(...args),
|
|
14
28
|
}));
|
|
15
29
|
|
|
30
|
+
mock.module("../runtime/client.js", () => ({
|
|
31
|
+
CircuitBreakerOpenError: class CircuitBreakerOpenError extends Error {
|
|
32
|
+
readonly retryAfterSecs: number;
|
|
33
|
+
|
|
34
|
+
constructor(retryAfterSecs: number) {
|
|
35
|
+
super("Circuit breaker is open");
|
|
36
|
+
this.retryAfterSecs = retryAfterSecs;
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
forwardToRuntime: (...args: Parameters<typeof forwardToRuntimeMock>) =>
|
|
40
|
+
forwardToRuntimeMock(...args),
|
|
41
|
+
}));
|
|
42
|
+
|
|
16
43
|
const {
|
|
17
44
|
normalizeSlackAppMention,
|
|
18
45
|
resolveSlackChannel,
|
|
19
46
|
resolveSlackUser,
|
|
47
|
+
resolveSlackUserSync,
|
|
20
48
|
clearChannelInfoCache,
|
|
49
|
+
clearInFlightFetches,
|
|
21
50
|
clearUserInfoCache,
|
|
22
51
|
getChannelInfoCacheSize,
|
|
23
52
|
getUserInfoCacheSize,
|
|
24
53
|
} = await import("../slack/normalize.js");
|
|
54
|
+
const { handleInbound } = await import("../handlers/handle-inbound.js");
|
|
25
55
|
import type { SlackAppMentionEvent } from "../slack/normalize.js";
|
|
26
56
|
|
|
27
57
|
function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
|
|
@@ -67,6 +97,9 @@ function makeEvent(
|
|
|
67
97
|
beforeEach(() => {
|
|
68
98
|
clearUserInfoCache();
|
|
69
99
|
clearChannelInfoCache();
|
|
100
|
+
clearInFlightFetches();
|
|
101
|
+
runtimePayloads = [];
|
|
102
|
+
forwardToRuntimeMock.mockClear();
|
|
70
103
|
});
|
|
71
104
|
|
|
72
105
|
describe("resolveSlackUser", () => {
|
|
@@ -78,6 +111,9 @@ describe("resolveSlackUser", () => {
|
|
|
78
111
|
user: {
|
|
79
112
|
name: "jdoe",
|
|
80
113
|
real_name: "Jane Doe",
|
|
114
|
+
tz: "America/New_York",
|
|
115
|
+
tz_label: "Eastern Daylight Time",
|
|
116
|
+
tz_offset: -14400,
|
|
81
117
|
profile: { display_name: "Jane D", real_name: "Jane Doe" },
|
|
82
118
|
},
|
|
83
119
|
}),
|
|
@@ -89,6 +125,9 @@ describe("resolveSlackUser", () => {
|
|
|
89
125
|
expect(info).not.toBeUndefined();
|
|
90
126
|
expect(info!.displayName).toBe("Jane D");
|
|
91
127
|
expect(info!.username).toBe("jdoe");
|
|
128
|
+
expect(info!.timezone).toBe("America/New_York");
|
|
129
|
+
expect(info!.timezoneLabel).toBe("Eastern Daylight Time");
|
|
130
|
+
expect(info!.timezoneOffsetSeconds).toBe(-14400);
|
|
92
131
|
});
|
|
93
132
|
|
|
94
133
|
test("falls back to real_name when display_name is empty", async () => {
|
|
@@ -151,6 +190,100 @@ describe("resolveSlackUser", () => {
|
|
|
151
190
|
expect(callCount).toBe(1);
|
|
152
191
|
expect(getUserInfoCacheSize()).toBe(1);
|
|
153
192
|
});
|
|
193
|
+
|
|
194
|
+
test("scopes cached user info by bot token", async () => {
|
|
195
|
+
let callCount = 0;
|
|
196
|
+
fetchMock = mock(async (_input, init) => {
|
|
197
|
+
callCount++;
|
|
198
|
+
const auth = new Headers(init?.headers).get("authorization");
|
|
199
|
+
const user =
|
|
200
|
+
auth === "Bearer xoxb-team-a"
|
|
201
|
+
? {
|
|
202
|
+
name: "alice",
|
|
203
|
+
tz: "America/New_York",
|
|
204
|
+
tz_label: "Eastern Daylight Time",
|
|
205
|
+
tz_offset: -14400,
|
|
206
|
+
profile: { display_name: "Alice" },
|
|
207
|
+
}
|
|
208
|
+
: {
|
|
209
|
+
name: "bob",
|
|
210
|
+
tz: "America/Los_Angeles",
|
|
211
|
+
tz_label: "Pacific Daylight Time",
|
|
212
|
+
tz_offset: -25200,
|
|
213
|
+
profile: { display_name: "Bob" },
|
|
214
|
+
};
|
|
215
|
+
return new Response(JSON.stringify({ ok: true, user }), {
|
|
216
|
+
status: 200,
|
|
217
|
+
headers: { "content-type": "application/json" },
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
const [teamAInfo, teamBInfo] = await Promise.all([
|
|
222
|
+
resolveSlackUser("U_SHARED", "xoxb-team-a"),
|
|
223
|
+
resolveSlackUser("U_SHARED", "xoxb-team-b"),
|
|
224
|
+
]);
|
|
225
|
+
|
|
226
|
+
expect(teamAInfo!.displayName).toBe("Alice");
|
|
227
|
+
expect(teamAInfo!.timezone).toBe("America/New_York");
|
|
228
|
+
expect(teamBInfo!.displayName).toBe("Bob");
|
|
229
|
+
expect(teamBInfo!.timezone).toBe("America/Los_Angeles");
|
|
230
|
+
expect(callCount).toBe(2);
|
|
231
|
+
expect(getUserInfoCacheSize()).toBe(2);
|
|
232
|
+
|
|
233
|
+
await resolveSlackUser("U_SHARED", "xoxb-team-a");
|
|
234
|
+
await resolveSlackUser("U_SHARED", "xoxb-team-b");
|
|
235
|
+
expect(callCount).toBe(2);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("sync cache lookup uses the bot token scope", async () => {
|
|
239
|
+
let callCount = 0;
|
|
240
|
+
fetchMock = mock(async (_input, init) => {
|
|
241
|
+
callCount++;
|
|
242
|
+
const auth = new Headers(init?.headers).get("authorization");
|
|
243
|
+
const user =
|
|
244
|
+
auth === "Bearer xoxb-team-a"
|
|
245
|
+
? {
|
|
246
|
+
name: "alice",
|
|
247
|
+
tz: "America/Denver",
|
|
248
|
+
tz_label: "Mountain Daylight Time",
|
|
249
|
+
tz_offset: -21600,
|
|
250
|
+
profile: { display_name: "Alice" },
|
|
251
|
+
}
|
|
252
|
+
: {
|
|
253
|
+
name: "bob",
|
|
254
|
+
tz: "Europe/London",
|
|
255
|
+
tz_label: "British Summer Time",
|
|
256
|
+
tz_offset: 3600,
|
|
257
|
+
profile: { display_name: "Bob" },
|
|
258
|
+
};
|
|
259
|
+
return new Response(JSON.stringify({ ok: true, user }), {
|
|
260
|
+
status: 200,
|
|
261
|
+
headers: { "content-type": "application/json" },
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
await resolveSlackUser("U_SHARED_SYNC", "xoxb-team-a");
|
|
266
|
+
|
|
267
|
+
const teamACached = resolveSlackUserSync("U_SHARED_SYNC", "xoxb-team-a");
|
|
268
|
+
expect(teamACached!.displayName).toBe("Alice");
|
|
269
|
+
expect(teamACached!.timezone).toBe("America/Denver");
|
|
270
|
+
|
|
271
|
+
const teamBMiss = resolveSlackUserSync("U_SHARED_SYNC", "xoxb-team-b");
|
|
272
|
+
expect(teamBMiss).toBeUndefined();
|
|
273
|
+
|
|
274
|
+
const teamBResolved = await resolveSlackUser(
|
|
275
|
+
"U_SHARED_SYNC",
|
|
276
|
+
"xoxb-team-b",
|
|
277
|
+
);
|
|
278
|
+
expect(teamBResolved!.displayName).toBe("Bob");
|
|
279
|
+
expect(teamBResolved!.timezone).toBe("Europe/London");
|
|
280
|
+
|
|
281
|
+
const teamBCached = resolveSlackUserSync("U_SHARED_SYNC", "xoxb-team-b");
|
|
282
|
+
expect(teamBCached!.displayName).toBe("Bob");
|
|
283
|
+
expect(teamBCached!.timezone).toBe("Europe/London");
|
|
284
|
+
expect(callCount).toBe(2);
|
|
285
|
+
expect(getUserInfoCacheSize()).toBe(2);
|
|
286
|
+
});
|
|
154
287
|
});
|
|
155
288
|
|
|
156
289
|
describe("resolveSlackChannel", () => {
|
|
@@ -217,6 +350,36 @@ describe("resolveSlackChannel", () => {
|
|
|
217
350
|
expect(callCount).toBe(1);
|
|
218
351
|
expect(getChannelInfoCacheSize()).toBe(1);
|
|
219
352
|
});
|
|
353
|
+
|
|
354
|
+
test("scopes cached channel names by bot token", async () => {
|
|
355
|
+
let callCount = 0;
|
|
356
|
+
fetchMock = mock(async (_input, init) => {
|
|
357
|
+
callCount++;
|
|
358
|
+
const auth = new Headers(init?.headers).get("authorization");
|
|
359
|
+
const channel =
|
|
360
|
+
auth === "Bearer xoxb-team-a"
|
|
361
|
+
? { id: "C_SHARED", name: "team-a-channel" }
|
|
362
|
+
: { id: "C_SHARED", name: "team-b-channel" };
|
|
363
|
+
return new Response(JSON.stringify({ ok: true, channel }), {
|
|
364
|
+
status: 200,
|
|
365
|
+
headers: { "content-type": "application/json" },
|
|
366
|
+
});
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
const [teamAInfo, teamBInfo] = await Promise.all([
|
|
370
|
+
resolveSlackChannel("C_SHARED", "xoxb-team-a"),
|
|
371
|
+
resolveSlackChannel("C_SHARED", "xoxb-team-b"),
|
|
372
|
+
]);
|
|
373
|
+
|
|
374
|
+
expect(teamAInfo!.name).toBe("team-a-channel");
|
|
375
|
+
expect(teamBInfo!.name).toBe("team-b-channel");
|
|
376
|
+
expect(callCount).toBe(2);
|
|
377
|
+
expect(getChannelInfoCacheSize()).toBe(2);
|
|
378
|
+
|
|
379
|
+
await resolveSlackChannel("C_SHARED", "xoxb-team-a");
|
|
380
|
+
await resolveSlackChannel("C_SHARED", "xoxb-team-b");
|
|
381
|
+
expect(callCount).toBe(2);
|
|
382
|
+
});
|
|
220
383
|
});
|
|
221
384
|
|
|
222
385
|
describe("normalizeSlackAppMention with display name", () => {
|
|
@@ -273,6 +436,9 @@ describe("normalizeSlackAppMention with display name", () => {
|
|
|
273
436
|
user: {
|
|
274
437
|
name: "testuser",
|
|
275
438
|
real_name: "Test User",
|
|
439
|
+
tz: "America/Denver",
|
|
440
|
+
tz_label: "Mountain Daylight Time",
|
|
441
|
+
tz_offset: -21600,
|
|
276
442
|
profile: { display_name: "Test U" },
|
|
277
443
|
},
|
|
278
444
|
}),
|
|
@@ -296,6 +462,58 @@ describe("normalizeSlackAppMention with display name", () => {
|
|
|
296
462
|
expect(result).not.toBeNull();
|
|
297
463
|
expect(result!.event.actor.displayName).toBe("Test U");
|
|
298
464
|
expect(result!.event.actor.username).toBe("testuser");
|
|
465
|
+
expect(result!.event.actor.timezone).toBe("America/Denver");
|
|
466
|
+
expect(result!.event.actor.timezoneLabel).toBe("Mountain Daylight Time");
|
|
467
|
+
expect(result!.event.actor.timezoneOffsetSeconds).toBe(-21600);
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
test("forwards cached Slack timezone fields in runtime source metadata", async () => {
|
|
471
|
+
fetchMock = mock(async () => {
|
|
472
|
+
return new Response(
|
|
473
|
+
JSON.stringify({
|
|
474
|
+
ok: true,
|
|
475
|
+
user: {
|
|
476
|
+
name: "testuser",
|
|
477
|
+
real_name: "Test User",
|
|
478
|
+
tz: "America/Los_Angeles",
|
|
479
|
+
tz_label: "Pacific Daylight Time",
|
|
480
|
+
tz_offset: -25200,
|
|
481
|
+
profile: { display_name: "Test U" },
|
|
482
|
+
},
|
|
483
|
+
}),
|
|
484
|
+
{ status: 200, headers: { "content-type": "application/json" } },
|
|
485
|
+
);
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
const config = makeConfig();
|
|
489
|
+
const event = makeEvent({ user: "U_WITH_TZ" });
|
|
490
|
+
await resolveSlackUser("U_WITH_TZ", "xoxb-test");
|
|
491
|
+
|
|
492
|
+
const result = normalizeSlackAppMention(
|
|
493
|
+
event,
|
|
494
|
+
"evt-tz-forward",
|
|
495
|
+
config,
|
|
496
|
+
undefined,
|
|
497
|
+
"xoxb-test",
|
|
498
|
+
);
|
|
499
|
+
expect(result).not.toBeNull();
|
|
500
|
+
|
|
501
|
+
await handleInbound(config, result!.event, {
|
|
502
|
+
routingOverride: result!.routing,
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
expect(forwardToRuntimeMock).toHaveBeenCalledTimes(1);
|
|
506
|
+
const forwardedPayload = runtimePayloads[0];
|
|
507
|
+
expect(forwardedPayload).toBeDefined();
|
|
508
|
+
expect(forwardedPayload!.sourceMetadata!.timezone).toBe(
|
|
509
|
+
"America/Los_Angeles",
|
|
510
|
+
);
|
|
511
|
+
expect(forwardedPayload!.sourceMetadata!.timezoneLabel).toBe(
|
|
512
|
+
"Pacific Daylight Time",
|
|
513
|
+
);
|
|
514
|
+
expect(forwardedPayload!.sourceMetadata!.timezoneOffsetSeconds).toBe(
|
|
515
|
+
-25200,
|
|
516
|
+
);
|
|
299
517
|
});
|
|
300
518
|
|
|
301
519
|
test("renders cache-warmed mention labels in model-facing content", async () => {
|