@vellumai/vellum-gateway 0.8.3 → 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/package.json +1 -1
- package/src/__tests__/ipc-slack-thread-routes.test.ts +157 -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/channels/inbound-event.ts +4 -0
- package/src/db/slack-store.ts +10 -0
- package/src/feature-flag-registry.json +99 -3
- package/src/handlers/handle-inbound.ts +6 -4
- package/src/index.ts +2 -0
- package/src/ipc/slack-thread-handlers.ts +39 -0
- package/src/slack/normalize.ts +78 -26
- package/src/slack/socket-mode.ts +2 -2
package/package.json
CHANGED
|
@@ -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
|
+
});
|
|
@@ -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 () => {
|
|
@@ -4,6 +4,7 @@ import { drizzle } from "drizzle-orm/bun-sqlite";
|
|
|
4
4
|
import type { GatewayConfig } from "../config.js";
|
|
5
5
|
import { SlackStore } from "../db/slack-store.js";
|
|
6
6
|
import * as schema from "../db/schema.js";
|
|
7
|
+
import type { RuntimeInboundPayload } from "../runtime/client.js";
|
|
7
8
|
import type { NormalizedSlackEvent } from "../slack/normalize.js";
|
|
8
9
|
|
|
9
10
|
type FetchFn = (
|
|
@@ -27,14 +28,42 @@ function makeSlackUserResponse(): Response {
|
|
|
27
28
|
let fetchMock: ReturnType<typeof mock<FetchFn>> = mock(async () =>
|
|
28
29
|
makeSlackUserResponse(),
|
|
29
30
|
);
|
|
31
|
+
const runtimePayloads: RuntimeInboundPayload[] = [];
|
|
30
32
|
|
|
31
33
|
mock.module("../fetch.js", () => ({
|
|
32
34
|
fetchImpl: (...args: Parameters<FetchFn>) => fetchMock(...args),
|
|
33
35
|
}));
|
|
34
36
|
|
|
37
|
+
mock.module("../runtime/client.js", () => ({
|
|
38
|
+
CircuitBreakerOpenError: class CircuitBreakerOpenError extends Error {
|
|
39
|
+
readonly retryAfterSecs: number;
|
|
40
|
+
|
|
41
|
+
constructor(retryAfterSecs: number) {
|
|
42
|
+
super("Circuit breaker is open");
|
|
43
|
+
this.name = "CircuitBreakerOpenError";
|
|
44
|
+
this.retryAfterSecs = retryAfterSecs;
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
forwardToRuntime: mock(
|
|
48
|
+
async (_config: GatewayConfig, payload: RuntimeInboundPayload) => {
|
|
49
|
+
runtimePayloads.push(payload);
|
|
50
|
+
return {
|
|
51
|
+
accepted: true,
|
|
52
|
+
duplicate: false,
|
|
53
|
+
eventId: "runtime-event-1",
|
|
54
|
+
};
|
|
55
|
+
},
|
|
56
|
+
),
|
|
57
|
+
}));
|
|
58
|
+
|
|
59
|
+
mock.module("../verification/text-verification.js", () => ({
|
|
60
|
+
tryTextVerificationIntercept: mock(async () => ({ intercepted: false })),
|
|
61
|
+
}));
|
|
62
|
+
|
|
35
63
|
const { SlackSocketModeClient } = await import("../slack/socket-mode.js");
|
|
36
64
|
const { clearChannelInfoCache, clearUserInfoCache, resolveSlackUser } =
|
|
37
65
|
await import("../slack/normalize.js");
|
|
66
|
+
const { handleInbound } = await import("../handlers/handle-inbound.js");
|
|
38
67
|
import type { SlackSocketModeConfig } from "../slack/socket-mode.js";
|
|
39
68
|
|
|
40
69
|
type SocketModeHarness = {
|
|
@@ -149,6 +178,7 @@ function flushAsyncEventEmission(): Promise<void> {
|
|
|
149
178
|
}
|
|
150
179
|
|
|
151
180
|
beforeEach(() => {
|
|
181
|
+
runtimePayloads.length = 0;
|
|
152
182
|
clearUserInfoCache();
|
|
153
183
|
clearChannelInfoCache();
|
|
154
184
|
fetchMock = mock(async () => makeSlackUserResponse());
|
|
@@ -706,21 +736,85 @@ describe("SlackSocketModeClient thread tracking", () => {
|
|
|
706
736
|
}
|
|
707
737
|
});
|
|
708
738
|
|
|
739
|
+
test("emits a plain app mention without resolving the event channel name", async () => {
|
|
740
|
+
const { rawDb, store } = createSlackStore();
|
|
741
|
+
const config = makeConfig();
|
|
742
|
+
const emitted: NormalizedSlackEvent[] = [];
|
|
743
|
+
const client = createHarness(store, (event) => emitted.push(event));
|
|
744
|
+
const ws = makeOpenSocket();
|
|
745
|
+
const conversationInfoChannels: string[] = [];
|
|
746
|
+
|
|
747
|
+
fetchMock = mock(async (input) => {
|
|
748
|
+
const url = new URL(String(input));
|
|
749
|
+
if (url.pathname.endsWith("/conversations.info")) {
|
|
750
|
+
const channelId = url.searchParams.get("channel");
|
|
751
|
+
if (channelId) {
|
|
752
|
+
conversationInfoChannels.push(channelId);
|
|
753
|
+
}
|
|
754
|
+
return new Response(
|
|
755
|
+
JSON.stringify({
|
|
756
|
+
ok: true,
|
|
757
|
+
channel: { id: channelId, name: "support-triage" },
|
|
758
|
+
}),
|
|
759
|
+
{ status: 200, headers: { "content-type": "application/json" } },
|
|
760
|
+
);
|
|
761
|
+
}
|
|
762
|
+
return makeSlackUserResponse();
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
try {
|
|
766
|
+
client.handleMessage(
|
|
767
|
+
JSON.stringify({
|
|
768
|
+
envelope_id: "env-channel-name",
|
|
769
|
+
type: "events_api",
|
|
770
|
+
payload: {
|
|
771
|
+
event_id: "Ev-channel-name",
|
|
772
|
+
event: {
|
|
773
|
+
type: "app_mention",
|
|
774
|
+
user: "U-actor",
|
|
775
|
+
text: "<@UBOT> please summarize this",
|
|
776
|
+
ts: "1700000000.000950",
|
|
777
|
+
channel: "C-thread",
|
|
778
|
+
},
|
|
779
|
+
},
|
|
780
|
+
}),
|
|
781
|
+
ws,
|
|
782
|
+
);
|
|
783
|
+
await flushAsyncEventEmission();
|
|
784
|
+
|
|
785
|
+
expect(emitted).toHaveLength(1);
|
|
786
|
+
expect(emitted[0].event.source.channelName).toBeUndefined();
|
|
787
|
+
expect(conversationInfoChannels).toEqual([]);
|
|
788
|
+
|
|
789
|
+
await handleInbound(config, emitted[0].event, {
|
|
790
|
+
routingOverride: emitted[0].routing,
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
expect(runtimePayloads).toHaveLength(1);
|
|
794
|
+
expect(runtimePayloads[0].sourceMetadata?.channelName).toBeUndefined();
|
|
795
|
+
} finally {
|
|
796
|
+
rawDb.close();
|
|
797
|
+
}
|
|
798
|
+
});
|
|
799
|
+
|
|
709
800
|
test("keeps embedded Slack channel labels without conversations.info lookup", async () => {
|
|
710
801
|
const { rawDb, store } = createSlackStore();
|
|
711
802
|
const emitted: NormalizedSlackEvent[] = [];
|
|
712
803
|
const client = createHarness(store, (event) => emitted.push(event));
|
|
713
804
|
const ws = makeOpenSocket();
|
|
714
|
-
|
|
805
|
+
const conversationInfoChannels: string[] = [];
|
|
715
806
|
|
|
716
807
|
fetchMock = mock(async (input) => {
|
|
717
808
|
const url = new URL(String(input));
|
|
718
809
|
if (url.pathname.endsWith("/conversations.info")) {
|
|
719
|
-
|
|
810
|
+
const channelId = url.searchParams.get("channel");
|
|
811
|
+
if (channelId) {
|
|
812
|
+
conversationInfoChannels.push(channelId);
|
|
813
|
+
}
|
|
720
814
|
return new Response(
|
|
721
815
|
JSON.stringify({
|
|
722
816
|
ok: true,
|
|
723
|
-
channel: { id:
|
|
817
|
+
channel: { id: channelId, name: "private-name" },
|
|
724
818
|
}),
|
|
725
819
|
{ status: 200, headers: { "content-type": "application/json" } },
|
|
726
820
|
);
|
|
@@ -752,7 +846,7 @@ describe("SlackSocketModeClient thread tracking", () => {
|
|
|
752
846
|
expect(emitted[0].event.message.content).toBe(
|
|
753
847
|
"@Example User continue in #visible-name",
|
|
754
848
|
);
|
|
755
|
-
expect(
|
|
849
|
+
expect(conversationInfoChannels).toEqual([]);
|
|
756
850
|
} finally {
|
|
757
851
|
rawDb.close();
|
|
758
852
|
}
|
|
@@ -40,6 +40,9 @@ interface InboundEventBase<C extends InboundChannelId> {
|
|
|
40
40
|
lastName?: string;
|
|
41
41
|
languageCode?: string;
|
|
42
42
|
isBot?: boolean;
|
|
43
|
+
timezone?: string;
|
|
44
|
+
timezoneLabel?: string;
|
|
45
|
+
timezoneOffsetSeconds?: number;
|
|
43
46
|
};
|
|
44
47
|
source: {
|
|
45
48
|
updateId: string;
|
|
@@ -51,6 +54,7 @@ interface InboundEventBase<C extends InboundChannelId> {
|
|
|
51
54
|
* `In-Reply-To`, etc.) can reuse the field later.
|
|
52
55
|
*/
|
|
53
56
|
threadId?: string;
|
|
57
|
+
channelName?: string;
|
|
54
58
|
};
|
|
55
59
|
raw: Record<string, unknown>;
|
|
56
60
|
}
|
package/src/db/slack-store.ts
CHANGED
|
@@ -67,6 +67,16 @@ export class SlackStore {
|
|
|
67
67
|
return row !== undefined;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
detachThread(threadTs: string, channelId: string): boolean {
|
|
71
|
+
const raw = (this.db as unknown as { $client: Database }).$client;
|
|
72
|
+
const changes = raw
|
|
73
|
+
.prepare(
|
|
74
|
+
"DELETE FROM slack_active_threads WHERE thread_ts = ? AND channel_id = ?",
|
|
75
|
+
)
|
|
76
|
+
.run(threadTs, channelId).changes;
|
|
77
|
+
return changes > 0;
|
|
78
|
+
}
|
|
79
|
+
|
|
70
80
|
/**
|
|
71
81
|
* Returns all unexpired active threads with a known channel for reconnect
|
|
72
82
|
* catch-up. Rows with a NULL `channel_id` (legacy rows from before the
|
|
@@ -9,6 +9,14 @@
|
|
|
9
9
|
"description": "Automatically trigger conversation analysis on the same cadence as memory extraction (batch threshold, idle debounce, end-of-conversation). The analysis agent has full tool access and writes back to memory and skills without user approval.",
|
|
10
10
|
"defaultEnabled": false
|
|
11
11
|
},
|
|
12
|
+
{
|
|
13
|
+
"id": "memory-retrospective-fork",
|
|
14
|
+
"scope": "assistant",
|
|
15
|
+
"key": "memory-retrospective-fork",
|
|
16
|
+
"label": "Fork-based memory retrospective",
|
|
17
|
+
"description": "Fork the source conversation through its latest message for memory retrospectives, instead of rendering the slice into a transcript and waking an empty background conversation. Lets the retrospective hit the provider prompt cache and read compaction summary + tail messages natively.",
|
|
18
|
+
"defaultEnabled": false
|
|
19
|
+
},
|
|
12
20
|
{
|
|
13
21
|
"id": "user-hosted-enabled",
|
|
14
22
|
"scope": "client",
|
|
@@ -43,7 +51,7 @@
|
|
|
43
51
|
},
|
|
44
52
|
{
|
|
45
53
|
"id": "settings-developer-nav",
|
|
46
|
-
"scope": "
|
|
54
|
+
"scope": "assistant",
|
|
47
55
|
"key": "settings-developer-nav",
|
|
48
56
|
"label": "Settings Developer Nav",
|
|
49
57
|
"description": "Control Developer nav visibility in macOS settings",
|
|
@@ -89,6 +97,14 @@
|
|
|
89
97
|
"description": "Surface credential grant and audit inspection endpoints for reviewing active grants and access logs",
|
|
90
98
|
"defaultEnabled": false
|
|
91
99
|
},
|
|
100
|
+
{
|
|
101
|
+
"id": "chatgpt-subscription-auth",
|
|
102
|
+
"scope": "assistant",
|
|
103
|
+
"key": "chatgpt-subscription-auth",
|
|
104
|
+
"label": "ChatGPT Subscription Auth",
|
|
105
|
+
"description": "Enable ChatGPT subscription OAuth as a provider auth type for OpenAI models, using the Codex device-code flow.",
|
|
106
|
+
"defaultEnabled": false
|
|
107
|
+
},
|
|
92
108
|
{
|
|
93
109
|
"id": "deploy-to-vercel",
|
|
94
110
|
"scope": "assistant",
|
|
@@ -275,7 +291,7 @@
|
|
|
275
291
|
},
|
|
276
292
|
{
|
|
277
293
|
"id": "account-deletion",
|
|
278
|
-
"scope": "
|
|
294
|
+
"scope": "assistant",
|
|
279
295
|
"key": "account-deletion",
|
|
280
296
|
"label": "Account Deletion",
|
|
281
297
|
"description": "Surfaces the user-initiated account deletion flow in client settings.",
|
|
@@ -299,7 +315,7 @@
|
|
|
299
315
|
},
|
|
300
316
|
{
|
|
301
317
|
"id": "pro-plan-adjust",
|
|
302
|
-
"scope": "
|
|
318
|
+
"scope": "client",
|
|
303
319
|
"key": "pro-plan-adjust",
|
|
304
320
|
"label": "Pro Plan Adjust",
|
|
305
321
|
"description": "Show the rich Plan card (current plan, features, Manage/Upgrade CTA) at the top of the macOS Settings \u2192 Billing tab.",
|
|
@@ -328,6 +344,86 @@
|
|
|
328
344
|
"label": "Velvet Theme",
|
|
329
345
|
"description": "Show the Velvet theme option in the macOS appearance settings. Velvet is a dark-mode variant with red/pink accent colors.",
|
|
330
346
|
"defaultEnabled": false
|
|
347
|
+
},
|
|
348
|
+
{
|
|
349
|
+
"id": "query-complexity-routing",
|
|
350
|
+
"scope": "assistant",
|
|
351
|
+
"key": "query-complexity-routing",
|
|
352
|
+
"label": "Query Complexity Routing",
|
|
353
|
+
"description": "Automatically route user messages to the most appropriate inference profile based on query complexity. Simple queries use the speed profile, complex queries escalate to the quality profile. The user is notified of each switch and can opt out by pinning a profile on the conversation.",
|
|
354
|
+
"defaultEnabled": false
|
|
355
|
+
},
|
|
356
|
+
{
|
|
357
|
+
"id": "queue-steering",
|
|
358
|
+
"scope": "assistant",
|
|
359
|
+
"key": "queue-steering",
|
|
360
|
+
"label": "Queue Steering",
|
|
361
|
+
"description": "Enable the 'Push to agent' button on queued messages, allowing users to steer the assistant to a specific queued message by aborting the current generation and promoting the message to the head of the queue.",
|
|
362
|
+
"defaultEnabled": false
|
|
363
|
+
},
|
|
364
|
+
{
|
|
365
|
+
"id": "chat-pull-to-refresh-enabled",
|
|
366
|
+
"scope": "client",
|
|
367
|
+
"key": "chat-pull-to-refresh-enabled",
|
|
368
|
+
"label": "Chat Pull to Refresh",
|
|
369
|
+
"description": "Enable pull-to-refresh gesture in the chat view.",
|
|
370
|
+
"defaultEnabled": false
|
|
371
|
+
},
|
|
372
|
+
{
|
|
373
|
+
"id": "doctor",
|
|
374
|
+
"scope": "client",
|
|
375
|
+
"key": "doctor",
|
|
376
|
+
"label": "Doctor",
|
|
377
|
+
"description": "Enable the Doctor diagnostic tab in Debug settings.",
|
|
378
|
+
"defaultEnabled": false
|
|
379
|
+
},
|
|
380
|
+
{
|
|
381
|
+
"id": "home-page",
|
|
382
|
+
"scope": "client",
|
|
383
|
+
"key": "home-page",
|
|
384
|
+
"label": "Home Page",
|
|
385
|
+
"description": "Enable the Home page as the default landing view.",
|
|
386
|
+
"defaultEnabled": false
|
|
387
|
+
},
|
|
388
|
+
{
|
|
389
|
+
"id": "platform-notifications",
|
|
390
|
+
"scope": "client",
|
|
391
|
+
"key": "platform-notifications",
|
|
392
|
+
"label": "Platform Notifications",
|
|
393
|
+
"description": "Enable the Notifications tab in settings.",
|
|
394
|
+
"defaultEnabled": false
|
|
395
|
+
},
|
|
396
|
+
{
|
|
397
|
+
"id": "rollback-enabled",
|
|
398
|
+
"scope": "assistant",
|
|
399
|
+
"key": "rollback-enabled",
|
|
400
|
+
"label": "Rollback Enabled",
|
|
401
|
+
"description": "Show older versions in the version picker, allowing rollback to previous releases.",
|
|
402
|
+
"defaultEnabled": false
|
|
403
|
+
},
|
|
404
|
+
{
|
|
405
|
+
"id": "self-hosted-assistant",
|
|
406
|
+
"scope": "client",
|
|
407
|
+
"key": "self-hosted-assistant",
|
|
408
|
+
"label": "Self-Hosted Assistant",
|
|
409
|
+
"description": "Enable self-hosted assistant configuration.",
|
|
410
|
+
"defaultEnabled": false
|
|
411
|
+
},
|
|
412
|
+
{
|
|
413
|
+
"id": "settings-sleep-policy",
|
|
414
|
+
"scope": "assistant",
|
|
415
|
+
"key": "settings-sleep-policy",
|
|
416
|
+
"label": "Settings Sleep Policy",
|
|
417
|
+
"description": "Enable sleep policy settings.",
|
|
418
|
+
"defaultEnabled": false
|
|
419
|
+
},
|
|
420
|
+
{
|
|
421
|
+
"id": "velvet",
|
|
422
|
+
"scope": "client",
|
|
423
|
+
"key": "velvet",
|
|
424
|
+
"label": "Velvet",
|
|
425
|
+
"description": "Enable the Velvet design theme.",
|
|
426
|
+
"defaultEnabled": false
|
|
331
427
|
}
|
|
332
428
|
]
|
|
333
429
|
}
|
|
@@ -15,7 +15,6 @@ import type { RuntimeInboundResponse } from "../runtime/client.js";
|
|
|
15
15
|
import type { GatewayInboundEvent } from "../types.js";
|
|
16
16
|
import { tryTextVerificationIntercept } from "../verification/text-verification.js";
|
|
17
17
|
|
|
18
|
-
|
|
19
18
|
const log = getLogger("handle-inbound");
|
|
20
19
|
|
|
21
20
|
export type InboundResult = {
|
|
@@ -119,6 +118,7 @@ export async function handleInbound(
|
|
|
119
118
|
options?.transportMetadata?.hints,
|
|
120
119
|
);
|
|
121
120
|
const transportUxBrief = options?.transportMetadata?.uxBrief?.trim();
|
|
121
|
+
const sourceChannelName = event.source.channelName?.trim();
|
|
122
122
|
|
|
123
123
|
try {
|
|
124
124
|
const response = await forwardToRuntime(
|
|
@@ -144,8 +144,12 @@ export async function handleInbound(
|
|
|
144
144
|
messageId: event.source.messageId,
|
|
145
145
|
chatType: event.source.chatType,
|
|
146
146
|
...(event.source.threadId ? { threadId: event.source.threadId } : {}),
|
|
147
|
+
...(sourceChannelName ? { channelName: sourceChannelName } : {}),
|
|
147
148
|
languageCode: event.actor.languageCode,
|
|
148
149
|
isBot: event.actor.isBot,
|
|
150
|
+
timezone: event.actor.timezone,
|
|
151
|
+
timezoneLabel: event.actor.timezoneLabel,
|
|
152
|
+
timezoneOffsetSeconds: event.actor.timezoneOffsetSeconds,
|
|
149
153
|
...(transportHints.length > 0 ? { hints: transportHints } : {}),
|
|
150
154
|
...(transportUxBrief ? { uxBrief: transportUxBrief } : {}),
|
|
151
155
|
...(options?.sourceMetadata ?? {}),
|
|
@@ -176,9 +180,7 @@ export async function handleInbound(
|
|
|
176
180
|
// writes to both assistant DB and gateway DB. Fire-and-forget so
|
|
177
181
|
// IPC failures here cannot leak as unhandled rejections.
|
|
178
182
|
if (!response.denied) {
|
|
179
|
-
void touchContactChannelStats(event, response.duplicate).catch(
|
|
180
|
-
() => {},
|
|
181
|
-
);
|
|
183
|
+
void touchContactChannelStats(event, response.duplicate).catch(() => {});
|
|
182
184
|
}
|
|
183
185
|
|
|
184
186
|
return { forwarded: true, rejected: false, runtimeResponse: response };
|
package/src/index.ts
CHANGED
|
@@ -176,6 +176,7 @@ import {
|
|
|
176
176
|
import { GatewayIpcServer } from "./ipc/server.js";
|
|
177
177
|
import { contactRoutes } from "./ipc/contact-handlers.js";
|
|
178
178
|
import { featureFlagRoutes } from "./ipc/feature-flag-handlers.js";
|
|
179
|
+
import { slackThreadRoutes } from "./ipc/slack-thread-handlers.js";
|
|
179
180
|
import { thresholdRoutes } from "./ipc/threshold-handlers.js";
|
|
180
181
|
|
|
181
182
|
import { riskClassificationRoutes } from "./ipc/risk-classification-handlers.js";
|
|
@@ -2205,6 +2206,7 @@ async function main() {
|
|
|
2205
2206
|
const ipcServer = new GatewayIpcServer([
|
|
2206
2207
|
...featureFlagRoutes,
|
|
2207
2208
|
...contactRoutes,
|
|
2209
|
+
...slackThreadRoutes,
|
|
2208
2210
|
...thresholdRoutes,
|
|
2209
2211
|
...riskClassificationRoutes,
|
|
2210
2212
|
...createVelayRoutes(velayTunnelClient),
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IPC route definitions for Slack active-thread listener control.
|
|
3
|
+
*
|
|
4
|
+
* The gateway owns Slack Socket Mode listener state, so assistant-side
|
|
5
|
+
* controls call here instead of writing gateway storage directly.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
|
|
10
|
+
import { SlackStore } from "../db/slack-store.js";
|
|
11
|
+
import type { IpcRoute } from "./server.js";
|
|
12
|
+
|
|
13
|
+
let store: SlackStore | null = null;
|
|
14
|
+
|
|
15
|
+
function getStore(): SlackStore {
|
|
16
|
+
if (!store) {
|
|
17
|
+
store = new SlackStore();
|
|
18
|
+
}
|
|
19
|
+
return store;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const DetachSlackActiveThreadParamsSchema = z.object({
|
|
23
|
+
channelId: z.string().trim().min(1),
|
|
24
|
+
threadTs: z.string().trim().min(1),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export const slackThreadRoutes: IpcRoute[] = [
|
|
28
|
+
{
|
|
29
|
+
method: "detach_slack_active_thread",
|
|
30
|
+
schema: DetachSlackActiveThreadParamsSchema,
|
|
31
|
+
handler: (params?: Record<string, unknown>) => {
|
|
32
|
+
const { channelId, threadTs } = DetachSlackActiveThreadParamsSchema.parse(
|
|
33
|
+
params ?? {},
|
|
34
|
+
);
|
|
35
|
+
const detached = getStore().detachThread(threadTs, channelId);
|
|
36
|
+
return { detached, channelId, threadTs };
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
];
|
package/src/slack/normalize.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { renderSlackTextForModel } from "@vellumai/slack-text";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
2
3
|
import type { GatewayConfig } from "../config.js";
|
|
3
4
|
import { fetchImpl } from "../fetch.js";
|
|
4
5
|
import { resolveAssistant, isRejection } from "../routing/resolve-assistant.js";
|
|
@@ -11,8 +12,20 @@ import type { GatewayInboundEvent } from "../types.js";
|
|
|
11
12
|
interface SlackUserInfo {
|
|
12
13
|
displayName: string;
|
|
13
14
|
username: string;
|
|
15
|
+
timezone?: string;
|
|
16
|
+
timezoneLabel?: string;
|
|
17
|
+
timezoneOffsetSeconds?: number;
|
|
14
18
|
}
|
|
15
19
|
|
|
20
|
+
export type SlackUserActorFields = Pick<
|
|
21
|
+
SlackUserInfo,
|
|
22
|
+
| "displayName"
|
|
23
|
+
| "username"
|
|
24
|
+
| "timezone"
|
|
25
|
+
| "timezoneLabel"
|
|
26
|
+
| "timezoneOffsetSeconds"
|
|
27
|
+
>;
|
|
28
|
+
|
|
16
29
|
interface SlackChannelInfo {
|
|
17
30
|
name: string;
|
|
18
31
|
}
|
|
@@ -48,6 +61,16 @@ const inFlightChannelFetches = new Map<
|
|
|
48
61
|
Promise<SlackChannelInfo | undefined>
|
|
49
62
|
>();
|
|
50
63
|
|
|
64
|
+
function slackUserCacheKey(userId: string, botToken: string): string {
|
|
65
|
+
const authScope = createHash("sha256").update(botToken).digest("hex");
|
|
66
|
+
return `${authScope}:${userId}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function slackChannelCacheKey(channelId: string, botToken: string): string {
|
|
70
|
+
const authScope = createHash("sha256").update(botToken).digest("hex");
|
|
71
|
+
return `${authScope}:${channelId}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
51
74
|
function evictExpired<T>(cache: Map<string, CacheEntry<T>>): void {
|
|
52
75
|
const now = Date.now();
|
|
53
76
|
for (const [key, entry] of cache) {
|
|
@@ -106,11 +129,12 @@ export async function resolveSlackUser(
|
|
|
106
129
|
userId: string,
|
|
107
130
|
botToken: string,
|
|
108
131
|
): Promise<SlackUserInfo | undefined> {
|
|
109
|
-
const
|
|
132
|
+
const cacheKey = slackUserCacheKey(userId, botToken);
|
|
133
|
+
const cached = cacheGet(userInfoCache, cacheKey);
|
|
110
134
|
if (cached) return cached;
|
|
111
135
|
|
|
112
136
|
// If another caller is already fetching this user, reuse that promise
|
|
113
|
-
const existing = inFlightUserFetches.get(
|
|
137
|
+
const existing = inFlightUserFetches.get(cacheKey);
|
|
114
138
|
if (existing) return existing;
|
|
115
139
|
|
|
116
140
|
const fetchPromise = (async (): Promise<SlackUserInfo | undefined> => {
|
|
@@ -129,6 +153,9 @@ export async function resolveSlackUser(
|
|
|
129
153
|
user?: {
|
|
130
154
|
name?: string;
|
|
131
155
|
real_name?: string;
|
|
156
|
+
tz?: string;
|
|
157
|
+
tz_label?: string;
|
|
158
|
+
tz_offset?: number;
|
|
132
159
|
profile?: { display_name?: string; real_name?: string };
|
|
133
160
|
};
|
|
134
161
|
};
|
|
@@ -141,11 +168,27 @@ export async function resolveSlackUser(
|
|
|
141
168
|
data.user.name ||
|
|
142
169
|
userId;
|
|
143
170
|
const username = data.user.name || userId;
|
|
144
|
-
|
|
145
|
-
|
|
171
|
+
const timezone =
|
|
172
|
+
typeof data.user.tz === "string" ? data.user.tz : undefined;
|
|
173
|
+
const timezoneLabel =
|
|
174
|
+
typeof data.user.tz_label === "string" ? data.user.tz_label : undefined;
|
|
175
|
+
const timezoneOffsetSeconds =
|
|
176
|
+
typeof data.user.tz_offset === "number"
|
|
177
|
+
? data.user.tz_offset
|
|
178
|
+
: undefined;
|
|
179
|
+
|
|
180
|
+
const info: SlackUserInfo = {
|
|
181
|
+
displayName,
|
|
182
|
+
username,
|
|
183
|
+
...(timezone !== undefined ? { timezone } : {}),
|
|
184
|
+
...(timezoneLabel !== undefined ? { timezoneLabel } : {}),
|
|
185
|
+
...(timezoneOffsetSeconds !== undefined
|
|
186
|
+
? { timezoneOffsetSeconds }
|
|
187
|
+
: {}),
|
|
188
|
+
};
|
|
146
189
|
cacheSet(
|
|
147
190
|
userInfoCache,
|
|
148
|
-
|
|
191
|
+
cacheKey,
|
|
149
192
|
info,
|
|
150
193
|
USER_CACHE_TTL_MS,
|
|
151
194
|
USER_CACHE_MAX_SIZE,
|
|
@@ -156,11 +199,11 @@ export async function resolveSlackUser(
|
|
|
156
199
|
}
|
|
157
200
|
})();
|
|
158
201
|
|
|
159
|
-
inFlightUserFetches.set(
|
|
202
|
+
inFlightUserFetches.set(cacheKey, fetchPromise);
|
|
160
203
|
try {
|
|
161
204
|
return await fetchPromise;
|
|
162
205
|
} finally {
|
|
163
|
-
inFlightUserFetches.delete(
|
|
206
|
+
inFlightUserFetches.delete(cacheKey);
|
|
164
207
|
}
|
|
165
208
|
}
|
|
166
209
|
|
|
@@ -175,10 +218,11 @@ export async function resolveSlackChannel(
|
|
|
175
218
|
channelId: string,
|
|
176
219
|
botToken: string,
|
|
177
220
|
): Promise<SlackChannelInfo | undefined> {
|
|
178
|
-
const
|
|
221
|
+
const cacheKey = slackChannelCacheKey(channelId, botToken);
|
|
222
|
+
const cached = cacheGet(channelInfoCache, cacheKey);
|
|
179
223
|
if (cached) return cached;
|
|
180
224
|
|
|
181
|
-
const existing = inFlightChannelFetches.get(
|
|
225
|
+
const existing = inFlightChannelFetches.get(cacheKey);
|
|
182
226
|
if (existing) return existing;
|
|
183
227
|
|
|
184
228
|
const fetchPromise = (async (): Promise<SlackChannelInfo | undefined> => {
|
|
@@ -207,7 +251,7 @@ export async function resolveSlackChannel(
|
|
|
207
251
|
const info: SlackChannelInfo = { name };
|
|
208
252
|
cacheSet(
|
|
209
253
|
channelInfoCache,
|
|
210
|
-
|
|
254
|
+
cacheKey,
|
|
211
255
|
info,
|
|
212
256
|
CHANNEL_CACHE_TTL_MS,
|
|
213
257
|
CHANNEL_CACHE_MAX_SIZE,
|
|
@@ -218,11 +262,11 @@ export async function resolveSlackChannel(
|
|
|
218
262
|
}
|
|
219
263
|
})();
|
|
220
264
|
|
|
221
|
-
inFlightChannelFetches.set(
|
|
265
|
+
inFlightChannelFetches.set(cacheKey, fetchPromise);
|
|
222
266
|
try {
|
|
223
267
|
return await fetchPromise;
|
|
224
268
|
} finally {
|
|
225
|
-
inFlightChannelFetches.delete(
|
|
269
|
+
inFlightChannelFetches.delete(cacheKey);
|
|
226
270
|
}
|
|
227
271
|
}
|
|
228
272
|
|
|
@@ -235,8 +279,9 @@ export function resolveSlackUserSync(
|
|
|
235
279
|
userId: string,
|
|
236
280
|
botToken: string,
|
|
237
281
|
): SlackUserInfo | undefined {
|
|
238
|
-
const
|
|
239
|
-
|
|
282
|
+
const cacheKey = slackUserCacheKey(userId, botToken);
|
|
283
|
+
const cached = cacheGet(userInfoCache, cacheKey);
|
|
284
|
+
if (!cached && !inFlightUserFetches.has(cacheKey)) {
|
|
240
285
|
// Fire-and-forget: warm the cache for next time
|
|
241
286
|
resolveSlackUser(userId, botToken).catch(() => {});
|
|
242
287
|
}
|
|
@@ -395,6 +440,22 @@ function renderSlackInboundText(
|
|
|
395
440
|
});
|
|
396
441
|
}
|
|
397
442
|
|
|
443
|
+
export function slackUserActorFields(
|
|
444
|
+
userInfo: SlackUserInfo,
|
|
445
|
+
): SlackUserActorFields {
|
|
446
|
+
return {
|
|
447
|
+
displayName: userInfo.displayName,
|
|
448
|
+
username: userInfo.username,
|
|
449
|
+
...(userInfo.timezone !== undefined ? { timezone: userInfo.timezone } : {}),
|
|
450
|
+
...(userInfo.timezoneLabel !== undefined
|
|
451
|
+
? { timezoneLabel: userInfo.timezoneLabel }
|
|
452
|
+
: {}),
|
|
453
|
+
...(userInfo.timezoneOffsetSeconds !== undefined
|
|
454
|
+
? { timezoneOffsetSeconds: userInfo.timezoneOffsetSeconds }
|
|
455
|
+
: {}),
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
|
|
398
459
|
function extractSlackAttachments(files: SlackFile[] | undefined): Array<{
|
|
399
460
|
type: "image" | "document";
|
|
400
461
|
fileId: string;
|
|
@@ -505,10 +566,7 @@ export function normalizeSlackDirectMessage(
|
|
|
505
566
|
},
|
|
506
567
|
actor: {
|
|
507
568
|
actorExternalId: event.user,
|
|
508
|
-
...(userInfo
|
|
509
|
-
displayName: userInfo.displayName,
|
|
510
|
-
username: userInfo.username,
|
|
511
|
-
}),
|
|
569
|
+
...(userInfo ? slackUserActorFields(userInfo) : {}),
|
|
512
570
|
},
|
|
513
571
|
source: {
|
|
514
572
|
updateId: eventId,
|
|
@@ -573,10 +631,7 @@ export function normalizeSlackChannelMessage(
|
|
|
573
631
|
},
|
|
574
632
|
actor: {
|
|
575
633
|
actorExternalId: event.user,
|
|
576
|
-
...(userInfo
|
|
577
|
-
displayName: userInfo.displayName,
|
|
578
|
-
username: userInfo.username,
|
|
579
|
-
}),
|
|
634
|
+
...(userInfo ? slackUserActorFields(userInfo) : {}),
|
|
580
635
|
},
|
|
581
636
|
source: {
|
|
582
637
|
updateId: eventId,
|
|
@@ -638,10 +693,7 @@ export function normalizeSlackAppMention(
|
|
|
638
693
|
},
|
|
639
694
|
actor: {
|
|
640
695
|
actorExternalId: event.user,
|
|
641
|
-
...(userInfo
|
|
642
|
-
displayName: userInfo.displayName,
|
|
643
|
-
username: userInfo.username,
|
|
644
|
-
}),
|
|
696
|
+
...(userInfo ? slackUserActorFields(userInfo) : {}),
|
|
645
697
|
},
|
|
646
698
|
source: {
|
|
647
699
|
updateId: eventId,
|
package/src/slack/socket-mode.ts
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
normalizeSlackReactionRemoved,
|
|
26
26
|
resolveSlackChannel,
|
|
27
27
|
resolveSlackUser,
|
|
28
|
+
slackUserActorFields,
|
|
28
29
|
type SlackAppMentionEvent,
|
|
29
30
|
type SlackDirectMessageEvent,
|
|
30
31
|
type SlackChannelMessageEvent,
|
|
@@ -1018,8 +1019,7 @@ export class SlackSocketModeClient {
|
|
|
1018
1019
|
),
|
|
1019
1020
|
]);
|
|
1020
1021
|
if (userInfo) {
|
|
1021
|
-
actor
|
|
1022
|
-
actor.username = userInfo.username;
|
|
1022
|
+
Object.assign(actor, slackUserActorFields(userInfo));
|
|
1023
1023
|
}
|
|
1024
1024
|
}
|
|
1025
1025
|
|