@vellumai/vellum-gateway 0.5.15 → 0.5.16
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__/credential-watcher-managed-bootstrap.test.ts +5 -5
- package/src/__tests__/slack-normalize.test.ts +26 -0
- package/src/auth/scopes.ts +1 -0
- package/src/auth/types.ts +1 -0
- package/src/config.ts +34 -11
- package/src/feature-flag-registry.json +16 -0
- package/src/index.ts +33 -9
- package/src/runtime/client.ts +10 -5
- package/src/schema.ts +5 -0
- package/src/slack/dm-context.test.ts +170 -0
- package/src/slack/dm-context.ts +135 -0
- package/src/slack/normalize.test.ts +20 -0
- package/src/slack/normalize.ts +5 -4
package/package.json
CHANGED
|
@@ -76,7 +76,7 @@ async function startGateway(): Promise<void> {
|
|
|
76
76
|
stdio: ["ignore", "pipe", "pipe"],
|
|
77
77
|
});
|
|
78
78
|
|
|
79
|
-
const deadline = Date.now() +
|
|
79
|
+
const deadline = Date.now() + 10_000;
|
|
80
80
|
while (Date.now() < deadline) {
|
|
81
81
|
try {
|
|
82
82
|
const res = await fetch(`http://localhost:${gatewayPort}/healthz`);
|
|
@@ -86,7 +86,7 @@ async function startGateway(): Promise<void> {
|
|
|
86
86
|
}
|
|
87
87
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
88
88
|
}
|
|
89
|
-
throw new Error("Gateway failed to start within
|
|
89
|
+
throw new Error("Gateway failed to start within 10 seconds");
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
function startFakeCes(opts: {
|
|
@@ -139,7 +139,7 @@ afterEach(() => {
|
|
|
139
139
|
cesPort = 0;
|
|
140
140
|
|
|
141
141
|
if (gatewayProc) {
|
|
142
|
-
gatewayProc.kill();
|
|
142
|
+
gatewayProc.kill("SIGKILL");
|
|
143
143
|
gatewayProc = null;
|
|
144
144
|
}
|
|
145
145
|
|
|
@@ -176,7 +176,7 @@ describe("gateway managed credential bootstrap retry", () => {
|
|
|
176
176
|
}
|
|
177
177
|
|
|
178
178
|
expect(status).toBe(401);
|
|
179
|
-
},
|
|
179
|
+
}, 20_000);
|
|
180
180
|
|
|
181
181
|
test("keeps retrying until configured credential reads succeed after CES list is already available", async () => {
|
|
182
182
|
mkdirSync(testDir, { recursive: true });
|
|
@@ -220,5 +220,5 @@ describe("gateway managed credential bootstrap retry", () => {
|
|
|
220
220
|
}
|
|
221
221
|
|
|
222
222
|
expect(status).toBe(401);
|
|
223
|
-
},
|
|
223
|
+
}, 20_000);
|
|
224
224
|
});
|
|
@@ -326,6 +326,32 @@ describe("normalizeSlackMessageEdit", () => {
|
|
|
326
326
|
expect(result!.threadTs).toBe("1700000000.000100");
|
|
327
327
|
});
|
|
328
328
|
|
|
329
|
+
test("DM edit without thread_ts omits threadTs", () => {
|
|
330
|
+
const config = makeConfig();
|
|
331
|
+
const event = makeMessageChangedEvent({ channel_type: "im" });
|
|
332
|
+
const result = normalizeSlackMessageEdit(event, "evt-dm-edit-1", config);
|
|
333
|
+
|
|
334
|
+
expect(result).not.toBeNull();
|
|
335
|
+
expect(result!.threadTs).toBeUndefined();
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test("DM edit with thread_ts preserves threadTs", () => {
|
|
339
|
+
const config = makeConfig();
|
|
340
|
+
const event = makeMessageChangedEvent({
|
|
341
|
+
channel_type: "im",
|
|
342
|
+
message: {
|
|
343
|
+
user: "U_USER123",
|
|
344
|
+
text: "edited hello world",
|
|
345
|
+
ts: "1700000000.000100",
|
|
346
|
+
thread_ts: "1700000000.000050",
|
|
347
|
+
},
|
|
348
|
+
});
|
|
349
|
+
const result = normalizeSlackMessageEdit(event, "evt-dm-edit-2", config);
|
|
350
|
+
|
|
351
|
+
expect(result).not.toBeNull();
|
|
352
|
+
expect(result!.threadTs).toBe("1700000000.000050");
|
|
353
|
+
});
|
|
354
|
+
|
|
329
355
|
test("DM edits use default assistant when channel is not in routing table", () => {
|
|
330
356
|
const config = makeConfig({
|
|
331
357
|
unmappedPolicy: "reject",
|
package/src/auth/scopes.ts
CHANGED
package/src/auth/types.ts
CHANGED
package/src/config.ts
CHANGED
|
@@ -39,7 +39,8 @@ type RoutingEntry = {
|
|
|
39
39
|
|
|
40
40
|
/**
|
|
41
41
|
* Read the workspace config file at startup to populate gateway operational
|
|
42
|
-
* settings.
|
|
42
|
+
* settings. In Docker, the daemon writes these values. In local mode, the
|
|
43
|
+
* CLI passes them via env vars (which take precedence in loadConfig()).
|
|
43
44
|
*/
|
|
44
45
|
function readWorkspaceConfig(): Record<string, unknown> {
|
|
45
46
|
try {
|
|
@@ -105,24 +106,46 @@ export function loadConfig(): GatewayConfig {
|
|
|
105
106
|
|
|
106
107
|
const gatewayInternalBaseUrl = `http://127.0.0.1:${port}`;
|
|
107
108
|
|
|
108
|
-
// Read operational settings from workspace config
|
|
109
|
-
// before spawning the gateway (see cli/src/lib/local.ts writeGatewayConfig).
|
|
109
|
+
// Read operational settings from workspace config (Docker) or env vars (CLI).
|
|
110
110
|
const wsConfig = readWorkspaceConfig();
|
|
111
111
|
const gw = (wsConfig.gateway ?? {}) as Record<string, unknown>;
|
|
112
112
|
|
|
113
|
+
// Env vars take precedence over workspace config values. This allows the
|
|
114
|
+
// CLI to pass gateway settings directly via the process environment instead
|
|
115
|
+
// of writing to the workspace config file.
|
|
113
116
|
const runtimeProxyEnabled =
|
|
117
|
+
process.env.RUNTIME_PROXY_ENABLED === "true" ||
|
|
114
118
|
gw.runtimeProxyEnabled === true ||
|
|
115
|
-
gw.runtimeProxyEnabled === "true"
|
|
116
|
-
process.env.RUNTIME_PROXY_ENABLED === "true";
|
|
119
|
+
gw.runtimeProxyEnabled === "true";
|
|
117
120
|
const runtimeProxyRequireAuth =
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
+
process.env.RUNTIME_PROXY_REQUIRE_AUTH !== undefined
|
|
122
|
+
? process.env.RUNTIME_PROXY_REQUIRE_AUTH !== "false"
|
|
123
|
+
: gw.runtimeProxyRequireAuth !== false &&
|
|
124
|
+
gw.runtimeProxyRequireAuth !== "false";
|
|
125
|
+
const unmappedPolicyEnv = process.env.UNMAPPED_POLICY?.trim();
|
|
126
|
+
const unmappedPolicy: "reject" | "default" =
|
|
127
|
+
unmappedPolicyEnv === "default" || unmappedPolicyEnv === "reject"
|
|
128
|
+
? unmappedPolicyEnv
|
|
129
|
+
: gw.unmappedPolicy === "default"
|
|
130
|
+
? "default"
|
|
131
|
+
: "reject";
|
|
121
132
|
const defaultAssistantId =
|
|
122
|
-
|
|
133
|
+
process.env.DEFAULT_ASSISTANT_ID?.trim() ||
|
|
134
|
+
(typeof gw.defaultAssistantId === "string" && gw.defaultAssistantId
|
|
123
135
|
? gw.defaultAssistantId
|
|
124
|
-
: undefined;
|
|
125
|
-
|
|
136
|
+
: undefined);
|
|
137
|
+
let routingEntries: RoutingEntry[] = [];
|
|
138
|
+
if (process.env.ROUTING_ENTRIES) {
|
|
139
|
+
try {
|
|
140
|
+
routingEntries = parseRoutingEntries(
|
|
141
|
+
JSON.parse(process.env.ROUTING_ENTRIES),
|
|
142
|
+
);
|
|
143
|
+
} catch {
|
|
144
|
+
log.warn("Invalid JSON in ROUTING_ENTRIES env var — ignoring");
|
|
145
|
+
}
|
|
146
|
+
} else {
|
|
147
|
+
routingEntries = parseRoutingEntries(gw.routingEntries);
|
|
148
|
+
}
|
|
126
149
|
|
|
127
150
|
const logFile: LogFileConfig = {
|
|
128
151
|
dir: undefined,
|
|
@@ -25,6 +25,14 @@
|
|
|
25
25
|
"description": "Enable the Vellum Cloud hosting option on the Hosting screen",
|
|
26
26
|
"defaultEnabled": false
|
|
27
27
|
},
|
|
28
|
+
{
|
|
29
|
+
"id": "local-docker-enabled",
|
|
30
|
+
"scope": "macos",
|
|
31
|
+
"key": "local-docker-enabled",
|
|
32
|
+
"label": "Local Docker Mode",
|
|
33
|
+
"description": "When enabled, the Local hosting option uses Docker under the hood for sandboxed execution, hiding the separate Docker card",
|
|
34
|
+
"defaultEnabled": false
|
|
35
|
+
},
|
|
28
36
|
{
|
|
29
37
|
"id": "contacts",
|
|
30
38
|
"scope": "assistant",
|
|
@@ -360,6 +368,14 @@
|
|
|
360
368
|
"label": "Fast Mode",
|
|
361
369
|
"description": "Enable Anthropic fast mode for Opus 4.6, delivering up to 2.5x higher output tokens per second at premium pricing",
|
|
362
370
|
"defaultEnabled": false
|
|
371
|
+
},
|
|
372
|
+
{
|
|
373
|
+
"id": "outlook-oauth-integration",
|
|
374
|
+
"scope": "assistant",
|
|
375
|
+
"key": "outlook-oauth-integration",
|
|
376
|
+
"label": "Outlook / Microsoft Integration",
|
|
377
|
+
"description": "Enable the Outlook / Microsoft OAuth provider for connecting to Microsoft Graph APIs (email, calendar)",
|
|
378
|
+
"defaultEnabled": false
|
|
363
379
|
}
|
|
364
380
|
]
|
|
365
381
|
}
|
package/src/index.ts
CHANGED
|
@@ -89,6 +89,7 @@ import {
|
|
|
89
89
|
} from "./slack/socket-mode.js";
|
|
90
90
|
import { downloadSlackFile } from "./slack/download.js";
|
|
91
91
|
import { fetchThreadContext } from "./slack/thread-context.js";
|
|
92
|
+
import { fetchDmContext } from "./slack/dm-context.js";
|
|
92
93
|
import { handleInbound } from "./handlers/handle-inbound.js";
|
|
93
94
|
import { checkAuthRateLimit } from "./http/middleware/rate-limit.js";
|
|
94
95
|
import {
|
|
@@ -780,7 +781,8 @@ async function main() {
|
|
|
780
781
|
{
|
|
781
782
|
path: "/v1/admin/upgrade-broadcast",
|
|
782
783
|
method: "POST",
|
|
783
|
-
auth: "edge",
|
|
784
|
+
auth: "edge-scoped",
|
|
785
|
+
scope: "admin.write",
|
|
784
786
|
handler: (req) => upgradeBroadcastProxy(req),
|
|
785
787
|
},
|
|
786
788
|
|
|
@@ -802,7 +804,8 @@ async function main() {
|
|
|
802
804
|
{
|
|
803
805
|
path: "/v1/admin/workspace-commit",
|
|
804
806
|
method: "POST",
|
|
805
|
-
auth: "edge",
|
|
807
|
+
auth: "edge-scoped",
|
|
808
|
+
scope: "admin.write",
|
|
806
809
|
handler: (req) => workspaceCommitProxy(req),
|
|
807
810
|
},
|
|
808
811
|
|
|
@@ -810,7 +813,8 @@ async function main() {
|
|
|
810
813
|
{
|
|
811
814
|
path: "/v1/admin/rollback-migrations",
|
|
812
815
|
method: "POST",
|
|
813
|
-
auth: "edge",
|
|
816
|
+
auth: "edge-scoped",
|
|
817
|
+
scope: "admin.write",
|
|
814
818
|
handler: (req) => migrationRollbackProxy(req),
|
|
815
819
|
},
|
|
816
820
|
|
|
@@ -1208,7 +1212,13 @@ async function main() {
|
|
|
1208
1212
|
{ appToken, botToken, gatewayConfig: config },
|
|
1209
1213
|
(normalized) => {
|
|
1210
1214
|
const { threadTs, channel } = normalized;
|
|
1211
|
-
const
|
|
1215
|
+
const params = new URLSearchParams({ channel });
|
|
1216
|
+
if (threadTs) params.set("threadTs", threadTs);
|
|
1217
|
+
// For non-threaded DMs, pass the original message ts so the runtime
|
|
1218
|
+
// can target it for emoji-based thinking indicators.
|
|
1219
|
+
const origMessageTs = normalized.event.source.messageId;
|
|
1220
|
+
if (!threadTs && origMessageTs) params.set("messageTs", origMessageTs);
|
|
1221
|
+
const replyCallbackUrl = `${config.gatewayInternalBaseUrl}/deliver/slack?${params}`;
|
|
1212
1222
|
|
|
1213
1223
|
// Check if this is a regular thread reply (not an edit or callback action).
|
|
1214
1224
|
// Edits and callbacks don't benefit from thread context and would just add
|
|
@@ -1245,10 +1255,7 @@ async function main() {
|
|
|
1245
1255
|
|
|
1246
1256
|
// Filter oversized attachments
|
|
1247
1257
|
const eligible = eventAttachments.filter((att) => {
|
|
1248
|
-
if (
|
|
1249
|
-
att.fileSize !== undefined &&
|
|
1250
|
-
att.fileSize > maxBytes
|
|
1251
|
-
) {
|
|
1258
|
+
if (att.fileSize !== undefined && att.fileSize > maxBytes) {
|
|
1252
1259
|
log.warn(
|
|
1253
1260
|
{
|
|
1254
1261
|
fileId: att.fileId,
|
|
@@ -1345,7 +1352,7 @@ async function main() {
|
|
|
1345
1352
|
}
|
|
1346
1353
|
};
|
|
1347
1354
|
|
|
1348
|
-
if (isThreadReply && botToken) {
|
|
1355
|
+
if (isThreadReply && botToken && threadTs) {
|
|
1349
1356
|
fetchThreadContext(channel, threadTs, messageTs, botToken)
|
|
1350
1357
|
.then((context) => context ?? undefined)
|
|
1351
1358
|
.catch(() => undefined)
|
|
@@ -1356,6 +1363,23 @@ async function main() {
|
|
|
1356
1363
|
"Unhandled error in Slack forward (thread reply)",
|
|
1357
1364
|
);
|
|
1358
1365
|
});
|
|
1366
|
+
} else if (
|
|
1367
|
+
channel.startsWith("D") &&
|
|
1368
|
+
botToken &&
|
|
1369
|
+
messageTs &&
|
|
1370
|
+
!isEdit &&
|
|
1371
|
+
!isCallback
|
|
1372
|
+
) {
|
|
1373
|
+
fetchDmContext(channel, messageTs, botToken)
|
|
1374
|
+
.then((context) => context ?? undefined)
|
|
1375
|
+
.catch(() => undefined)
|
|
1376
|
+
.then((context) => forward(context))
|
|
1377
|
+
.catch((err) => {
|
|
1378
|
+
log.error(
|
|
1379
|
+
{ err, channel },
|
|
1380
|
+
"Unhandled error in Slack forward (DM context)",
|
|
1381
|
+
);
|
|
1382
|
+
});
|
|
1359
1383
|
} else {
|
|
1360
1384
|
forward().catch((err) => {
|
|
1361
1385
|
log.error(
|
package/src/runtime/client.ts
CHANGED
|
@@ -551,7 +551,12 @@ export async function uploadAttachment(
|
|
|
551
551
|
): Promise<UploadAttachmentResponse> {
|
|
552
552
|
const skipCb = opts?.skipCircuitBreaker === true;
|
|
553
553
|
|
|
554
|
-
|
|
554
|
+
// Always check the breaker for fail-fast (OPEN/HALF_OPEN rejection).
|
|
555
|
+
// skipCb only suppresses success/failure accounting so attachment errors
|
|
556
|
+
// don't trip the breaker — but half-open probes must always record their
|
|
557
|
+
// outcome to avoid getting stuck in HALF_OPEN permanently.
|
|
558
|
+
const isProbe = cbBeforeRequest();
|
|
559
|
+
const recordOutcome = !skipCb || isProbe;
|
|
555
560
|
|
|
556
561
|
const url = `${config.assistantRuntimeBaseUrl}/v1/attachments`;
|
|
557
562
|
|
|
@@ -566,7 +571,7 @@ export async function uploadAttachment(
|
|
|
566
571
|
signal: AbortSignal.timeout(config.runtimeTimeoutMs),
|
|
567
572
|
});
|
|
568
573
|
} catch (err) {
|
|
569
|
-
if (
|
|
574
|
+
if (recordOutcome) cbOnFailure();
|
|
570
575
|
throw err;
|
|
571
576
|
}
|
|
572
577
|
|
|
@@ -576,16 +581,16 @@ export async function uploadAttachment(
|
|
|
576
581
|
// extension, missing fields). Distinguish from transient 5xx/network errors
|
|
577
582
|
// so callers can decide whether to skip or propagate.
|
|
578
583
|
if (response.status >= 400 && response.status < 500) {
|
|
579
|
-
if (
|
|
584
|
+
if (recordOutcome) cbOnSuccess();
|
|
580
585
|
throw new AttachmentValidationError(
|
|
581
586
|
`Attachment rejected (${response.status}): ${body}`,
|
|
582
587
|
);
|
|
583
588
|
}
|
|
584
|
-
if (
|
|
589
|
+
if (recordOutcome) cbOnFailure();
|
|
585
590
|
throw new Error(`Attachment upload failed (${response.status}): ${body}`);
|
|
586
591
|
}
|
|
587
592
|
|
|
588
|
-
if (
|
|
593
|
+
if (recordOutcome) cbOnSuccess();
|
|
589
594
|
return (await response.json()) as UploadAttachmentResponse;
|
|
590
595
|
}
|
|
591
596
|
|
package/src/schema.ts
CHANGED
|
@@ -3126,6 +3126,11 @@ export function buildSchema(): Record<string, unknown> {
|
|
|
3126
3126
|
type: "array",
|
|
3127
3127
|
items: { type: "string" },
|
|
3128
3128
|
},
|
|
3129
|
+
callback_transport: {
|
|
3130
|
+
type: "string",
|
|
3131
|
+
enum: ["loopback", "gateway"],
|
|
3132
|
+
description: "OAuth callback transport. Defaults to loopback.",
|
|
3133
|
+
},
|
|
3129
3134
|
},
|
|
3130
3135
|
},
|
|
3131
3136
|
OAuthConnectDeferredResponse: {
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, mock } from "bun:test";
|
|
2
|
+
|
|
3
|
+
type FetchFn = (
|
|
4
|
+
input: string | URL | Request,
|
|
5
|
+
init?: RequestInit,
|
|
6
|
+
) => Promise<Response>;
|
|
7
|
+
let fetchMock: ReturnType<typeof mock<FetchFn>> = mock(
|
|
8
|
+
async () => new Response(),
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
mock.module("../fetch.js", () => ({
|
|
12
|
+
fetchImpl: (...args: Parameters<FetchFn>) => fetchMock(...args),
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
const { fetchDmContext } = await import("./dm-context.js");
|
|
16
|
+
const { clearUserInfoCache, clearInFlightFetches } =
|
|
17
|
+
await import("./normalize.js");
|
|
18
|
+
|
|
19
|
+
function mockSlackApi(
|
|
20
|
+
historyMessages: Array<{
|
|
21
|
+
user?: string;
|
|
22
|
+
text?: string;
|
|
23
|
+
ts?: string;
|
|
24
|
+
bot_id?: string;
|
|
25
|
+
username?: string;
|
|
26
|
+
}>,
|
|
27
|
+
userNames: Record<string, string> = {},
|
|
28
|
+
) {
|
|
29
|
+
fetchMock = mock(async (url: string | URL | Request) => {
|
|
30
|
+
const urlStr = typeof url === "string" ? url : url.toString();
|
|
31
|
+
if (urlStr.includes("conversations.history")) {
|
|
32
|
+
return Response.json({ ok: true, messages: historyMessages });
|
|
33
|
+
}
|
|
34
|
+
if (urlStr.includes("users.info")) {
|
|
35
|
+
const userId = new URL(urlStr).searchParams.get("user");
|
|
36
|
+
const name = userNames[userId!] ?? userId;
|
|
37
|
+
return Response.json({
|
|
38
|
+
ok: true,
|
|
39
|
+
user: { name: userId, profile: { display_name: name } },
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
return Response.json({ ok: false });
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe("fetchDmContext", () => {
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
clearUserInfoCache();
|
|
49
|
+
clearInFlightFetches();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("returns formatted context with prior messages", async () => {
|
|
53
|
+
// Slack returns newest-first; include a bot message
|
|
54
|
+
mockSlackApi(
|
|
55
|
+
[
|
|
56
|
+
{
|
|
57
|
+
user: "UBOT",
|
|
58
|
+
text: "Here's what I found",
|
|
59
|
+
ts: "1000.2",
|
|
60
|
+
bot_id: "B123",
|
|
61
|
+
},
|
|
62
|
+
{ user: "U001", text: "Can you look this up?", ts: "1000.1" },
|
|
63
|
+
{ user: "U001", text: "Hey there", ts: "1000.0" },
|
|
64
|
+
],
|
|
65
|
+
{ U001: "Alice", UBOT: "Assistant" },
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const result = await fetchDmContext(
|
|
69
|
+
"D123",
|
|
70
|
+
"1000.3", // current message ts, excluded from context
|
|
71
|
+
"xoxb-test-token",
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
expect(result).toContain(
|
|
75
|
+
"Recent messages in this DM conversation (3 prior messages)",
|
|
76
|
+
);
|
|
77
|
+
// Should be in chronological order (oldest first)
|
|
78
|
+
const aliceHeyIdx = result!.indexOf("[Alice]: Hey there");
|
|
79
|
+
const aliceCanIdx = result!.indexOf("[Alice]: Can you look this up?");
|
|
80
|
+
const botIdx = result!.indexOf("[Assistant]: Here's what I found");
|
|
81
|
+
expect(aliceHeyIdx).toBeLessThan(aliceCanIdx);
|
|
82
|
+
expect(aliceCanIdx).toBeLessThan(botIdx);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("excludes the current message", async () => {
|
|
86
|
+
mockSlackApi(
|
|
87
|
+
[
|
|
88
|
+
{ user: "U001", text: "This is the current message", ts: "1000.1" },
|
|
89
|
+
{ user: "U002", text: "Earlier message", ts: "1000.0" },
|
|
90
|
+
],
|
|
91
|
+
{ U001: "Alice", U002: "Bob" },
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const result = await fetchDmContext(
|
|
95
|
+
"D123",
|
|
96
|
+
"1000.1", // matches first message — should be excluded
|
|
97
|
+
"xoxb-test-token",
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
expect(result).toContain("1 prior messages");
|
|
101
|
+
expect(result).toContain("[Bob]: Earlier message");
|
|
102
|
+
expect(result).not.toContain("This is the current message");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("returns null when no prior messages", async () => {
|
|
106
|
+
// Only the current message in history
|
|
107
|
+
mockSlackApi([
|
|
108
|
+
{ user: "U001", text: "Hello!", ts: "1000.0" },
|
|
109
|
+
]);
|
|
110
|
+
|
|
111
|
+
const result = await fetchDmContext(
|
|
112
|
+
"D123",
|
|
113
|
+
"1000.0", // matches the only message
|
|
114
|
+
"xoxb-test-token",
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
expect(result).toBeNull();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("returns null on API error", async () => {
|
|
121
|
+
fetchMock = mock(async () =>
|
|
122
|
+
Response.json({ ok: false, error: "channel_not_found" }),
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const result = await fetchDmContext(
|
|
126
|
+
"D123",
|
|
127
|
+
"1000.0",
|
|
128
|
+
"xoxb-test-token",
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
expect(result).toBeNull();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("returns null on HTTP error", async () => {
|
|
135
|
+
fetchMock = mock(async () => new Response("Server Error", { status: 500 }));
|
|
136
|
+
|
|
137
|
+
const result = await fetchDmContext(
|
|
138
|
+
"D123",
|
|
139
|
+
"1000.0",
|
|
140
|
+
"xoxb-test-token",
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
expect(result).toBeNull();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("reverses API order to chronological", async () => {
|
|
147
|
+
// Slack API returns newest-first
|
|
148
|
+
mockSlackApi(
|
|
149
|
+
[
|
|
150
|
+
{ user: "U001", text: "Third message", ts: "1000.2" },
|
|
151
|
+
{ user: "U001", text: "Second message", ts: "1000.1" },
|
|
152
|
+
{ user: "U001", text: "First message", ts: "1000.0" },
|
|
153
|
+
],
|
|
154
|
+
{ U001: "Alice" },
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
const result = await fetchDmContext(
|
|
158
|
+
"D123",
|
|
159
|
+
"9999.0", // current message not in list
|
|
160
|
+
"xoxb-test-token",
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
// Verify chronological order in output
|
|
164
|
+
const firstIdx = result!.indexOf("First message");
|
|
165
|
+
const secondIdx = result!.indexOf("Second message");
|
|
166
|
+
const thirdIdx = result!.indexOf("Third message");
|
|
167
|
+
expect(firstIdx).toBeLessThan(secondIdx);
|
|
168
|
+
expect(secondIdx).toBeLessThan(thirdIdx);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fetches recent DM history for inbound Slack direct messages so the
|
|
3
|
+
* assistant has context about prior messages in the conversation.
|
|
4
|
+
*
|
|
5
|
+
* Uses `conversations.history` to retrieve recent messages and formats
|
|
6
|
+
* them as a human-readable context string suitable for transport hints.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { fetchImpl } from "../fetch.js";
|
|
10
|
+
import { getLogger } from "../logger.js";
|
|
11
|
+
import { resolveSlackUser } from "./normalize.js";
|
|
12
|
+
|
|
13
|
+
const log = getLogger("slack-dm-context");
|
|
14
|
+
|
|
15
|
+
/** Maximum number of prior messages to include in context. */
|
|
16
|
+
const MAX_CONTEXT_MESSAGES = 10;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Over-fetch so the current message (which is filtered out) doesn't
|
|
20
|
+
* reduce the effective context window below MAX_CONTEXT_MESSAGES.
|
|
21
|
+
*/
|
|
22
|
+
const FETCH_LIMIT = 15;
|
|
23
|
+
|
|
24
|
+
/** Timeout for the conversations.history API call. */
|
|
25
|
+
const FETCH_TIMEOUT_MS = 5_000;
|
|
26
|
+
|
|
27
|
+
interface SlackDmMessage {
|
|
28
|
+
user?: string;
|
|
29
|
+
text?: string;
|
|
30
|
+
ts?: string;
|
|
31
|
+
bot_id?: string;
|
|
32
|
+
username?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Fetch recent DM history for a Slack direct-message channel.
|
|
37
|
+
*
|
|
38
|
+
* Returns a formatted string containing recent prior messages, or null
|
|
39
|
+
* if the fetch fails or there are no prior messages.
|
|
40
|
+
*
|
|
41
|
+
* @param channel - Slack DM channel ID (starts with "D")
|
|
42
|
+
* @param currentMessageTs - The current message's ts (excluded from context)
|
|
43
|
+
* @param botToken - Bot OAuth token for API calls
|
|
44
|
+
*/
|
|
45
|
+
export async function fetchDmContext(
|
|
46
|
+
channel: string,
|
|
47
|
+
currentMessageTs: string,
|
|
48
|
+
botToken: string,
|
|
49
|
+
): Promise<string | null> {
|
|
50
|
+
try {
|
|
51
|
+
const controller = new AbortController();
|
|
52
|
+
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
53
|
+
|
|
54
|
+
const params = new URLSearchParams({
|
|
55
|
+
channel,
|
|
56
|
+
limit: String(FETCH_LIMIT),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const resp = await fetchImpl(
|
|
60
|
+
`https://slack.com/api/conversations.history?${params.toString()}`,
|
|
61
|
+
{
|
|
62
|
+
method: "GET",
|
|
63
|
+
headers: { Authorization: `Bearer ${botToken}` },
|
|
64
|
+
signal: controller.signal,
|
|
65
|
+
},
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
clearTimeout(timeout);
|
|
69
|
+
|
|
70
|
+
if (!resp.ok) {
|
|
71
|
+
log.debug(
|
|
72
|
+
{ status: resp.status, channel },
|
|
73
|
+
"conversations.history HTTP error",
|
|
74
|
+
);
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const data = (await resp.json()) as {
|
|
79
|
+
ok?: boolean;
|
|
80
|
+
messages?: SlackDmMessage[];
|
|
81
|
+
error?: string;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
if (!data.ok || !data.messages?.length) {
|
|
85
|
+
log.debug(
|
|
86
|
+
{ error: data.error, channel },
|
|
87
|
+
"conversations.history returned no messages",
|
|
88
|
+
);
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Exclude the current message from context
|
|
93
|
+
const priorMessages = data.messages.filter(
|
|
94
|
+
(msg) => msg.ts !== currentMessageTs,
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
if (priorMessages.length === 0) return null;
|
|
98
|
+
|
|
99
|
+
// Slack returns newest-first; reverse to chronological order
|
|
100
|
+
priorMessages.reverse();
|
|
101
|
+
|
|
102
|
+
// Cap at MAX_CONTEXT_MESSAGES (take the most recent N)
|
|
103
|
+
const contextMessages =
|
|
104
|
+
priorMessages.length <= MAX_CONTEXT_MESSAGES
|
|
105
|
+
? priorMessages
|
|
106
|
+
: priorMessages.slice(-MAX_CONTEXT_MESSAGES);
|
|
107
|
+
|
|
108
|
+
// Resolve user display names (best-effort, uses cache)
|
|
109
|
+
const formattedMessages = await Promise.all(
|
|
110
|
+
contextMessages.map(async (msg) => {
|
|
111
|
+
let authorLabel: string;
|
|
112
|
+
if (msg.user) {
|
|
113
|
+
const userInfo = await resolveSlackUser(msg.user, botToken).catch(
|
|
114
|
+
() => undefined,
|
|
115
|
+
);
|
|
116
|
+
authorLabel = userInfo?.displayName ?? msg.user;
|
|
117
|
+
} else {
|
|
118
|
+
authorLabel = msg.username ?? "Unknown";
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const text = msg.text?.trim() || "(no text)";
|
|
122
|
+
return `[${authorLabel}]: ${text}`;
|
|
123
|
+
}),
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
return `Recent messages in this DM conversation (${contextMessages.length} prior messages):\n\n${formattedMessages.join("\n\n")}`;
|
|
127
|
+
} catch (err) {
|
|
128
|
+
// AbortError from timeout or any other fetch failure — non-fatal
|
|
129
|
+
log.debug(
|
|
130
|
+
{ err, channel },
|
|
131
|
+
"Failed to fetch DM context (non-fatal)",
|
|
132
|
+
);
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -260,6 +260,26 @@ describe("normalizeSlackReactionAdded", () => {
|
|
|
260
260
|
});
|
|
261
261
|
});
|
|
262
262
|
|
|
263
|
+
describe("DM threading", () => {
|
|
264
|
+
it("non-threaded DM has no threadTs", () => {
|
|
265
|
+
const config = makeConfig();
|
|
266
|
+
const event = makeDmEvent();
|
|
267
|
+
const result = normalizeSlackDirectMessage(event, "evt-dm-1", config);
|
|
268
|
+
|
|
269
|
+
expect(result).not.toBeNull();
|
|
270
|
+
expect(result!.threadTs).toBeUndefined();
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("threaded DM preserves threadTs", () => {
|
|
274
|
+
const config = makeConfig();
|
|
275
|
+
const event = makeDmEvent({ thread_ts: "1700000000.000050" });
|
|
276
|
+
const result = normalizeSlackDirectMessage(event, "evt-dm-2", config);
|
|
277
|
+
|
|
278
|
+
expect(result).not.toBeNull();
|
|
279
|
+
expect(result!.threadTs).toBe("1700000000.000050");
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
263
283
|
// --- Attachment extraction tests ---
|
|
264
284
|
|
|
265
285
|
function makeSlackFile(overrides?: Partial<SlackFile>): SlackFile {
|
package/src/slack/normalize.ts
CHANGED
|
@@ -291,7 +291,7 @@ export type NormalizedSlackEvent = {
|
|
|
291
291
|
event: GatewayInboundEvent;
|
|
292
292
|
routing: RouteResult;
|
|
293
293
|
/** Thread timestamp for reply threading. */
|
|
294
|
-
threadTs
|
|
294
|
+
threadTs?: string;
|
|
295
295
|
/** Slack channel ID. */
|
|
296
296
|
channel: string;
|
|
297
297
|
/** Original Slack file objects keyed by file ID, for download in the I/O layer. */
|
|
@@ -379,7 +379,7 @@ export function normalizeSlackDirectMessage(
|
|
|
379
379
|
raw: event as unknown as Record<string, unknown>,
|
|
380
380
|
},
|
|
381
381
|
routing,
|
|
382
|
-
threadTs: event.thread_ts
|
|
382
|
+
...(event.thread_ts ? { threadTs: event.thread_ts } : {}),
|
|
383
383
|
channel: event.channel,
|
|
384
384
|
...(slackFiles ? { slackFiles } : {}),
|
|
385
385
|
};
|
|
@@ -754,8 +754,9 @@ export function normalizeSlackMessageEdit(
|
|
|
754
754
|
raw: event as unknown as Record<string, unknown>,
|
|
755
755
|
},
|
|
756
756
|
routing,
|
|
757
|
-
//
|
|
758
|
-
|
|
757
|
+
// For DMs without a thread, omit threadTs so the reply goes directly in conversation.
|
|
758
|
+
// For channels (or DMs already in a thread), fall back to edited.ts.
|
|
759
|
+
...(isDm && !edited.thread_ts ? {} : { threadTs: edited.thread_ts ?? edited.ts }),
|
|
759
760
|
channel: event.channel,
|
|
760
761
|
};
|
|
761
762
|
}
|