@vellumai/vellum-gateway 0.5.13 → 0.5.15
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 +6 -6
- package/package.json +1 -1
- package/src/__tests__/schema.test.ts +1 -0
- package/src/feature-flag-registry.json +16 -0
- package/src/http/routes/oauth-providers-proxy.ts +114 -0
- package/src/index.ts +162 -15
- package/src/runtime/client.ts +8 -5
- package/src/schema.ts +114 -0
- package/src/slack/download.test.ts +183 -0
- package/src/slack/download.ts +50 -0
- package/src/slack/normalize.test.ts +220 -0
- package/src/slack/normalize.ts +71 -0
package/ARCHITECTURE.md
CHANGED
|
@@ -53,14 +53,14 @@ The gateway exposes a REST API for reading and mutating assistant feature flags.
|
|
|
53
53
|
|
|
54
54
|
**Token separation (authentication boundary):**
|
|
55
55
|
|
|
56
|
-
The assistant feature flags API uses
|
|
56
|
+
The assistant feature flags API uses scope-based JWT auth. The gateway issues JWTs with `feature_flags.read` and `feature_flags.write` scopes to control access.
|
|
57
57
|
|
|
58
|
-
| Operation |
|
|
59
|
-
| ------------------------------ |
|
|
60
|
-
| `GET /v1/feature-flags` |
|
|
61
|
-
| `PATCH /v1/feature-flags/:key` |
|
|
58
|
+
| Operation | Required scope |
|
|
59
|
+
| ------------------------------ | --------------------- |
|
|
60
|
+
| `GET /v1/feature-flags` | `feature_flags.read` |
|
|
61
|
+
| `PATCH /v1/feature-flags/:key` | `feature_flags.write` |
|
|
62
62
|
|
|
63
|
-
The
|
|
63
|
+
The assistant daemon does not read or distribute a feature-flag token. All feature-flag auth flows go through the gateway's scoped JWT mechanism.
|
|
64
64
|
|
|
65
65
|
**Protected feature flag store:** The canonical storage for assistant feature flag overrides is `~/.vellum/protected/feature-flags.json` (local) or `GATEWAY_SECURITY_DIR/feature-flags.json` (Docker). The store is managed by `gateway/src/feature-flag-store.ts` and uses a versioned JSON format with `Record<string, boolean>` values keyed by canonical flag keys (simple kebab-case, e.g., `browser`). The gateway's PATCH handler writes exclusively to this store. The daemon's resolver reads it with highest priority, falling back to the defaults registry. Undeclared keys are ignored by the resolver.
|
|
66
66
|
|
package/package.json
CHANGED
|
@@ -55,6 +55,7 @@ describe("/schema route", () => {
|
|
|
55
55
|
expect(body.paths["/readyz"]).toBeDefined();
|
|
56
56
|
expect(body.paths["/schema"]).toBeDefined();
|
|
57
57
|
expect(body.paths["/v1/health"]).toBeDefined();
|
|
58
|
+
expect(body.paths["/v1/healthz"]).toBeDefined();
|
|
58
59
|
expect(body.paths["/webhooks/telegram"]).toBeDefined();
|
|
59
60
|
expect(body.paths["/webhooks/twilio/voice"]).toBeDefined();
|
|
60
61
|
expect(body.paths["/webhooks/twilio/status"]).toBeDefined();
|
|
@@ -289,6 +289,14 @@
|
|
|
289
289
|
"description": "Auto-expand completed tool call step groups instead of showing them collapsed",
|
|
290
290
|
"defaultEnabled": false
|
|
291
291
|
},
|
|
292
|
+
{
|
|
293
|
+
"id": "show-thinking-blocks",
|
|
294
|
+
"scope": "macos",
|
|
295
|
+
"key": "show-thinking-blocks",
|
|
296
|
+
"label": "Show Thinking Blocks",
|
|
297
|
+
"description": "Display the assistant's thinking/reasoning inline in chat messages as collapsible blocks",
|
|
298
|
+
"defaultEnabled": false
|
|
299
|
+
},
|
|
292
300
|
{
|
|
293
301
|
"id": "inline-skill-commands",
|
|
294
302
|
"scope": "assistant",
|
|
@@ -344,6 +352,14 @@
|
|
|
344
352
|
"label": "Voice Mode",
|
|
345
353
|
"description": "Show the live voice conversation button in the composer",
|
|
346
354
|
"defaultEnabled": false
|
|
355
|
+
},
|
|
356
|
+
{
|
|
357
|
+
"id": "fast-mode",
|
|
358
|
+
"scope": "assistant",
|
|
359
|
+
"key": "fast-mode",
|
|
360
|
+
"label": "Fast Mode",
|
|
361
|
+
"description": "Enable Anthropic fast mode for Opus 4.6, delivering up to 2.5x higher output tokens per second at premium pricing",
|
|
362
|
+
"defaultEnabled": false
|
|
347
363
|
}
|
|
348
364
|
]
|
|
349
365
|
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gateway proxy endpoints for OAuth provider discovery routes.
|
|
3
|
+
*
|
|
4
|
+
* These routes remain available even when the broad runtime proxy is
|
|
5
|
+
* disabled, so skills and clients can use gateway URLs exclusively.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { mintServiceToken } from "../../auth/token-exchange.js";
|
|
9
|
+
import type { GatewayConfig } from "../../config.js";
|
|
10
|
+
import { fetchImpl } from "../../fetch.js";
|
|
11
|
+
import { getLogger } from "../../logger.js";
|
|
12
|
+
import { stripHopByHop } from "../../util/strip-hop-by-hop.js";
|
|
13
|
+
|
|
14
|
+
const log = getLogger("oauth-providers-proxy");
|
|
15
|
+
|
|
16
|
+
export function createOAuthProvidersProxyHandler(config: GatewayConfig) {
|
|
17
|
+
async function proxyToRuntime(
|
|
18
|
+
req: Request,
|
|
19
|
+
upstreamPath: string,
|
|
20
|
+
upstreamSearch: string,
|
|
21
|
+
): Promise<Response> {
|
|
22
|
+
const start = performance.now();
|
|
23
|
+
const upstream = `${config.assistantRuntimeBaseUrl}${upstreamPath}${upstreamSearch}`;
|
|
24
|
+
|
|
25
|
+
const reqHeaders = stripHopByHop(new Headers(req.headers));
|
|
26
|
+
reqHeaders.delete("host");
|
|
27
|
+
reqHeaders.delete("authorization");
|
|
28
|
+
|
|
29
|
+
reqHeaders.set("authorization", `Bearer ${mintServiceToken()}`);
|
|
30
|
+
|
|
31
|
+
const hasBody = req.method !== "GET" && req.method !== "HEAD";
|
|
32
|
+
const bodyBuffer = hasBody ? await req.arrayBuffer() : null;
|
|
33
|
+
if (bodyBuffer !== null) {
|
|
34
|
+
reqHeaders.set("content-length", String(bodyBuffer.byteLength));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const controller = new AbortController();
|
|
38
|
+
const timeoutId = setTimeout(() => {
|
|
39
|
+
controller.abort(
|
|
40
|
+
new DOMException(
|
|
41
|
+
"The operation was aborted due to timeout",
|
|
42
|
+
"TimeoutError",
|
|
43
|
+
),
|
|
44
|
+
);
|
|
45
|
+
}, config.runtimeTimeoutMs);
|
|
46
|
+
|
|
47
|
+
let response: Response;
|
|
48
|
+
try {
|
|
49
|
+
response = await fetchImpl(upstream, {
|
|
50
|
+
method: req.method,
|
|
51
|
+
headers: reqHeaders,
|
|
52
|
+
body: bodyBuffer,
|
|
53
|
+
signal: controller.signal,
|
|
54
|
+
});
|
|
55
|
+
clearTimeout(timeoutId);
|
|
56
|
+
} catch (err) {
|
|
57
|
+
clearTimeout(timeoutId);
|
|
58
|
+
const duration = Math.round(performance.now() - start);
|
|
59
|
+
if (err instanceof DOMException && err.name === "TimeoutError") {
|
|
60
|
+
log.error(
|
|
61
|
+
{ path: upstreamPath, duration },
|
|
62
|
+
"OAuth providers proxy upstream timed out",
|
|
63
|
+
);
|
|
64
|
+
return Response.json({ error: "Gateway Timeout" }, { status: 504 });
|
|
65
|
+
}
|
|
66
|
+
log.error(
|
|
67
|
+
{ err, path: upstreamPath, duration },
|
|
68
|
+
"OAuth providers proxy upstream connection failed",
|
|
69
|
+
);
|
|
70
|
+
return Response.json({ error: "Bad Gateway" }, { status: 502 });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const resHeaders = stripHopByHop(new Headers(response.headers));
|
|
74
|
+
const duration = Math.round(performance.now() - start);
|
|
75
|
+
|
|
76
|
+
if (response.status >= 400) {
|
|
77
|
+
const body = await response.text();
|
|
78
|
+
log.warn(
|
|
79
|
+
{ path: upstreamPath, status: response.status, duration },
|
|
80
|
+
"OAuth providers proxy upstream error",
|
|
81
|
+
);
|
|
82
|
+
return new Response(body, {
|
|
83
|
+
status: response.status,
|
|
84
|
+
headers: resHeaders,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
log.info(
|
|
89
|
+
{ path: upstreamPath, status: response.status, duration },
|
|
90
|
+
"OAuth providers proxy completed",
|
|
91
|
+
);
|
|
92
|
+
return new Response(response.body, {
|
|
93
|
+
status: response.status,
|
|
94
|
+
headers: resHeaders,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
async handleListProviders(req: Request): Promise<Response> {
|
|
100
|
+
return proxyToRuntime(
|
|
101
|
+
req,
|
|
102
|
+
"/v1/oauth/providers",
|
|
103
|
+
new URL(req.url).search,
|
|
104
|
+
);
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
async handleGetProvider(
|
|
108
|
+
req: Request,
|
|
109
|
+
providerKey: string,
|
|
110
|
+
): Promise<Response> {
|
|
111
|
+
return proxyToRuntime(req, `/v1/oauth/providers/${providerKey}`, "");
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -56,6 +56,7 @@ import { createVercelControlPlaneProxyHandler } from "./http/routes/vercel-contr
|
|
|
56
56
|
import { createContactsControlPlaneProxyHandler } from "./http/routes/contacts-control-plane-proxy.js";
|
|
57
57
|
import { createSlackControlPlaneProxyHandler } from "./http/routes/slack-control-plane-proxy.js";
|
|
58
58
|
import { createOAuthAppsProxyHandler } from "./http/routes/oauth-apps-proxy.js";
|
|
59
|
+
import { createOAuthProvidersProxyHandler } from "./http/routes/oauth-providers-proxy.js";
|
|
59
60
|
import { createChannelReadinessProxyHandler } from "./http/routes/channel-readiness-proxy.js";
|
|
60
61
|
import { createRuntimeHealthProxyHandler } from "./http/routes/runtime-health-proxy.js";
|
|
61
62
|
import { createUpgradeBroadcastProxyHandler } from "./http/routes/upgrade-broadcast-proxy.js";
|
|
@@ -76,12 +77,17 @@ import {
|
|
|
76
77
|
createTrustRulesStarterBundleHandler,
|
|
77
78
|
} from "./http/routes/trust-rules.js";
|
|
78
79
|
import { getLogger, initLogger } from "./logger.js";
|
|
79
|
-
import {
|
|
80
|
+
import {
|
|
81
|
+
AttachmentValidationError,
|
|
82
|
+
CircuitBreakerOpenError,
|
|
83
|
+
uploadAttachment,
|
|
84
|
+
} from "./runtime/client.js";
|
|
80
85
|
import { buildSchema } from "./schema.js";
|
|
81
86
|
import {
|
|
82
87
|
createSlackSocketModeClient,
|
|
83
88
|
type SlackSocketModeClient,
|
|
84
89
|
} from "./slack/socket-mode.js";
|
|
90
|
+
import { downloadSlackFile } from "./slack/download.js";
|
|
85
91
|
import { fetchThreadContext } from "./slack/thread-context.js";
|
|
86
92
|
import { handleInbound } from "./handlers/handle-inbound.js";
|
|
87
93
|
import { checkAuthRateLimit } from "./http/middleware/rate-limit.js";
|
|
@@ -276,6 +282,7 @@ async function main() {
|
|
|
276
282
|
const twilioControlPlaneProxy = createTwilioControlPlaneProxyHandler(config);
|
|
277
283
|
const slackControlPlaneProxy = createSlackControlPlaneProxyHandler(config);
|
|
278
284
|
const oauthAppsProxy = createOAuthAppsProxyHandler(config);
|
|
285
|
+
const oauthProvidersProxy = createOAuthProvidersProxyHandler(config);
|
|
279
286
|
const channelReadinessProxy = createChannelReadinessProxyHandler(config);
|
|
280
287
|
const runtimeHealthProxy = createRuntimeHealthProxyHandler(config);
|
|
281
288
|
const upgradeBroadcastProxy = createUpgradeBroadcastProxyHandler(config);
|
|
@@ -417,6 +424,12 @@ async function main() {
|
|
|
417
424
|
auth: "edge",
|
|
418
425
|
handler: (req) => runtimeHealthProxy.handleRuntimeHealth(req),
|
|
419
426
|
},
|
|
427
|
+
{
|
|
428
|
+
path: "/v1/healthz",
|
|
429
|
+
method: "GET",
|
|
430
|
+
auth: "edge",
|
|
431
|
+
handler: (req) => runtimeHealthProxy.handleRuntimeHealth(req),
|
|
432
|
+
},
|
|
420
433
|
|
|
421
434
|
// ── Brain graph ──
|
|
422
435
|
{
|
|
@@ -708,6 +721,21 @@ async function main() {
|
|
|
708
721
|
handler: (req) => slackControlPlaneProxy.handleShareToSlack(req),
|
|
709
722
|
},
|
|
710
723
|
|
|
724
|
+
// ── OAuth providers ──
|
|
725
|
+
{
|
|
726
|
+
path: "/v1/oauth/providers",
|
|
727
|
+
method: "GET",
|
|
728
|
+
auth: "edge",
|
|
729
|
+
handler: (req) => oauthProvidersProxy.handleListProviders(req),
|
|
730
|
+
},
|
|
731
|
+
{
|
|
732
|
+
path: /^\/v1\/oauth\/providers\/([^/]+)\/?$/,
|
|
733
|
+
method: "GET",
|
|
734
|
+
auth: "edge",
|
|
735
|
+
handler: (req, params) =>
|
|
736
|
+
oauthProvidersProxy.handleGetProvider(req, params[0]),
|
|
737
|
+
},
|
|
738
|
+
|
|
711
739
|
// ── OAuth apps ──
|
|
712
740
|
{
|
|
713
741
|
path: "/v1/oauth/apps",
|
|
@@ -1194,28 +1222,147 @@ async function main() {
|
|
|
1194
1222
|
!isEdit &&
|
|
1195
1223
|
!isCallback;
|
|
1196
1224
|
|
|
1197
|
-
const forward = (threadContextHint?: string) => {
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1225
|
+
const forward = async (threadContextHint?: string) => {
|
|
1226
|
+
try {
|
|
1227
|
+
const hints: string[] = [];
|
|
1228
|
+
if (threadContextHint) hints.push(threadContextHint);
|
|
1229
|
+
|
|
1230
|
+
// Download and upload attachments if present (skip for edits and
|
|
1231
|
+
// callback actions — edits only update text, callbacks have no media)
|
|
1232
|
+
let attachmentIds: string[] | undefined;
|
|
1233
|
+
const eventAttachments = normalized.event.message.attachments;
|
|
1234
|
+
if (
|
|
1235
|
+
eventAttachments &&
|
|
1236
|
+
eventAttachments.length > 0 &&
|
|
1237
|
+
normalized.slackFiles &&
|
|
1238
|
+
!isEdit &&
|
|
1239
|
+
!isCallback
|
|
1240
|
+
) {
|
|
1241
|
+
attachmentIds = [];
|
|
1242
|
+
const maxBytes =
|
|
1243
|
+
config.maxAttachmentBytes.slack ??
|
|
1244
|
+
config.maxAttachmentBytes.default;
|
|
1245
|
+
|
|
1246
|
+
// Filter oversized attachments
|
|
1247
|
+
const eligible = eventAttachments.filter((att) => {
|
|
1248
|
+
if (
|
|
1249
|
+
att.fileSize !== undefined &&
|
|
1250
|
+
att.fileSize > maxBytes
|
|
1251
|
+
) {
|
|
1252
|
+
log.warn(
|
|
1253
|
+
{
|
|
1254
|
+
fileId: att.fileId,
|
|
1255
|
+
fileSize: att.fileSize,
|
|
1256
|
+
limit: maxBytes,
|
|
1257
|
+
},
|
|
1258
|
+
"Skipping oversized Slack attachment",
|
|
1259
|
+
);
|
|
1260
|
+
return false;
|
|
1261
|
+
}
|
|
1262
|
+
return true;
|
|
1263
|
+
});
|
|
1264
|
+
|
|
1265
|
+
// Process with bounded concurrency. Socket Mode has no retry
|
|
1266
|
+
// mechanism, so all errors (validation and transient) are logged
|
|
1267
|
+
// and skipped — the message is still delivered without the
|
|
1268
|
+
// failed attachment.
|
|
1269
|
+
for (
|
|
1270
|
+
let i = 0;
|
|
1271
|
+
i < eligible.length;
|
|
1272
|
+
i += config.maxAttachmentConcurrency
|
|
1273
|
+
) {
|
|
1274
|
+
const batch = eligible.slice(
|
|
1275
|
+
i,
|
|
1276
|
+
i + config.maxAttachmentConcurrency,
|
|
1277
|
+
);
|
|
1278
|
+
const results = await Promise.allSettled(
|
|
1279
|
+
batch.map(async (att) => {
|
|
1280
|
+
const slackFile = normalized.slackFiles?.get(att.fileId);
|
|
1281
|
+
if (!slackFile) {
|
|
1282
|
+
throw new Error(
|
|
1283
|
+
`No SlackFile found for attachment ${att.fileId}`,
|
|
1284
|
+
);
|
|
1285
|
+
}
|
|
1286
|
+
const downloaded = await downloadSlackFile(
|
|
1287
|
+
slackFile,
|
|
1288
|
+
botToken,
|
|
1289
|
+
);
|
|
1290
|
+
return uploadAttachment(config, downloaded, {
|
|
1291
|
+
skipCircuitBreaker: true,
|
|
1292
|
+
});
|
|
1293
|
+
}),
|
|
1294
|
+
);
|
|
1295
|
+
for (const result of results) {
|
|
1296
|
+
if (result.status === "fulfilled") {
|
|
1297
|
+
attachmentIds.push(result.value.id);
|
|
1298
|
+
} else if (
|
|
1299
|
+
result.reason instanceof AttachmentValidationError
|
|
1300
|
+
) {
|
|
1301
|
+
log.warn(
|
|
1302
|
+
{ err: result.reason },
|
|
1303
|
+
"Skipping Slack attachment with validation error",
|
|
1304
|
+
);
|
|
1305
|
+
} else {
|
|
1306
|
+
log.warn(
|
|
1307
|
+
{ err: result.reason },
|
|
1308
|
+
"Skipping Slack attachment due to download/upload failure",
|
|
1309
|
+
);
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
handleInbound(config, normalized.event, {
|
|
1316
|
+
replyCallbackUrl,
|
|
1317
|
+
routingOverride: normalized.routing,
|
|
1318
|
+
...(attachmentIds && attachmentIds.length > 0
|
|
1319
|
+
? { attachmentIds }
|
|
1320
|
+
: {}),
|
|
1321
|
+
...(hints.length > 0 ? { transportMetadata: { hints } } : {}),
|
|
1322
|
+
}).catch((err) => {
|
|
1323
|
+
log.error(
|
|
1324
|
+
{ err, channel, threadTs },
|
|
1325
|
+
"Failed to forward Slack event to runtime",
|
|
1326
|
+
);
|
|
1327
|
+
});
|
|
1328
|
+
} catch (err) {
|
|
1206
1329
|
log.error(
|
|
1207
1330
|
{ err, channel, threadTs },
|
|
1208
|
-
"Failed to
|
|
1331
|
+
"Failed to process Slack event — delivering message without attachments",
|
|
1209
1332
|
);
|
|
1210
|
-
|
|
1333
|
+
handleInbound(config, normalized.event, {
|
|
1334
|
+
replyCallbackUrl,
|
|
1335
|
+
routingOverride: normalized.routing,
|
|
1336
|
+
...(threadContextHint
|
|
1337
|
+
? { transportMetadata: { hints: [threadContextHint] } }
|
|
1338
|
+
: {}),
|
|
1339
|
+
}).catch((fwdErr) => {
|
|
1340
|
+
log.error(
|
|
1341
|
+
{ err: fwdErr, channel, threadTs },
|
|
1342
|
+
"Failed to forward Slack event to runtime (fallback)",
|
|
1343
|
+
);
|
|
1344
|
+
});
|
|
1345
|
+
}
|
|
1211
1346
|
};
|
|
1212
1347
|
|
|
1213
1348
|
if (isThreadReply && botToken) {
|
|
1214
1349
|
fetchThreadContext(channel, threadTs, messageTs, botToken)
|
|
1215
|
-
.then((context) =>
|
|
1216
|
-
.catch(() =>
|
|
1350
|
+
.then((context) => context ?? undefined)
|
|
1351
|
+
.catch(() => undefined)
|
|
1352
|
+
.then((context) => forward(context))
|
|
1353
|
+
.catch((err) => {
|
|
1354
|
+
log.error(
|
|
1355
|
+
{ err, channel, threadTs },
|
|
1356
|
+
"Unhandled error in Slack forward (thread reply)",
|
|
1357
|
+
);
|
|
1358
|
+
});
|
|
1217
1359
|
} else {
|
|
1218
|
-
forward()
|
|
1360
|
+
forward().catch((err) => {
|
|
1361
|
+
log.error(
|
|
1362
|
+
{ err, channel, threadTs },
|
|
1363
|
+
"Unhandled error in Slack forward",
|
|
1364
|
+
);
|
|
1365
|
+
});
|
|
1219
1366
|
}
|
|
1220
1367
|
|
|
1221
1368
|
// When an approval button is clicked, store the approval message ts
|
package/src/runtime/client.ts
CHANGED
|
@@ -547,8 +547,11 @@ export async function forwardTwilioConnectActionWebhook(
|
|
|
547
547
|
export async function uploadAttachment(
|
|
548
548
|
config: GatewayConfig,
|
|
549
549
|
input: UploadAttachmentInput,
|
|
550
|
+
opts?: { skipCircuitBreaker?: boolean },
|
|
550
551
|
): Promise<UploadAttachmentResponse> {
|
|
551
|
-
|
|
552
|
+
const skipCb = opts?.skipCircuitBreaker === true;
|
|
553
|
+
|
|
554
|
+
if (!skipCb) cbBeforeRequest();
|
|
552
555
|
|
|
553
556
|
const url = `${config.assistantRuntimeBaseUrl}/v1/attachments`;
|
|
554
557
|
|
|
@@ -563,7 +566,7 @@ export async function uploadAttachment(
|
|
|
563
566
|
signal: AbortSignal.timeout(config.runtimeTimeoutMs),
|
|
564
567
|
});
|
|
565
568
|
} catch (err) {
|
|
566
|
-
cbOnFailure();
|
|
569
|
+
if (!skipCb) cbOnFailure();
|
|
567
570
|
throw err;
|
|
568
571
|
}
|
|
569
572
|
|
|
@@ -573,16 +576,16 @@ export async function uploadAttachment(
|
|
|
573
576
|
// extension, missing fields). Distinguish from transient 5xx/network errors
|
|
574
577
|
// so callers can decide whether to skip or propagate.
|
|
575
578
|
if (response.status >= 400 && response.status < 500) {
|
|
576
|
-
cbOnSuccess();
|
|
579
|
+
if (!skipCb) cbOnSuccess();
|
|
577
580
|
throw new AttachmentValidationError(
|
|
578
581
|
`Attachment rejected (${response.status}): ${body}`,
|
|
579
582
|
);
|
|
580
583
|
}
|
|
581
|
-
cbOnFailure();
|
|
584
|
+
if (!skipCb) cbOnFailure();
|
|
582
585
|
throw new Error(`Attachment upload failed (${response.status}): ${body}`);
|
|
583
586
|
}
|
|
584
587
|
|
|
585
|
-
cbOnSuccess();
|
|
588
|
+
if (!skipCb) cbOnSuccess();
|
|
586
589
|
return (await response.json()) as UploadAttachmentResponse;
|
|
587
590
|
}
|
|
588
591
|
|
package/src/schema.ts
CHANGED
|
@@ -153,6 +153,52 @@ export function buildSchema(): Record<string, unknown> {
|
|
|
153
153
|
},
|
|
154
154
|
},
|
|
155
155
|
},
|
|
156
|
+
"/v1/healthz": {
|
|
157
|
+
get: {
|
|
158
|
+
summary: "Runtime health (via gateway, alias)",
|
|
159
|
+
description:
|
|
160
|
+
"Alias for `/v1/health`. Authenticated gateway endpoint that proxies runtime health checks to `/v1/health` on the assistant runtime.",
|
|
161
|
+
operationId: "runtimeHealthz",
|
|
162
|
+
security: [{ BearerAuth: [] }],
|
|
163
|
+
responses: {
|
|
164
|
+
"200": {
|
|
165
|
+
description: "Runtime health returned",
|
|
166
|
+
},
|
|
167
|
+
"401": {
|
|
168
|
+
description: "Unauthorized — missing or invalid bearer token",
|
|
169
|
+
content: {
|
|
170
|
+
"application/json": {
|
|
171
|
+
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
"503": {
|
|
176
|
+
description: "Bearer token not configured",
|
|
177
|
+
content: {
|
|
178
|
+
"application/json": {
|
|
179
|
+
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
"502": {
|
|
184
|
+
description: "Failed to reach assistant runtime",
|
|
185
|
+
content: {
|
|
186
|
+
"application/json": {
|
|
187
|
+
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
"504": {
|
|
192
|
+
description: "Assistant runtime request timed out",
|
|
193
|
+
content: {
|
|
194
|
+
"application/json": {
|
|
195
|
+
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
},
|
|
156
202
|
"/v1/brain-graph": {
|
|
157
203
|
get: {
|
|
158
204
|
summary: "Brain graph data",
|
|
@@ -1725,6 +1771,74 @@ export function buildSchema(): Record<string, unknown> {
|
|
|
1725
1771
|
},
|
|
1726
1772
|
},
|
|
1727
1773
|
},
|
|
1774
|
+
"/v1/oauth/providers": {
|
|
1775
|
+
get: {
|
|
1776
|
+
summary: "List OAuth providers",
|
|
1777
|
+
description:
|
|
1778
|
+
"Authenticated gateway endpoint that lists available OAuth providers by proxying to the assistant runtime.",
|
|
1779
|
+
operationId: "oauthProvidersList",
|
|
1780
|
+
parameters: [
|
|
1781
|
+
{
|
|
1782
|
+
name: "supports_managed_mode",
|
|
1783
|
+
in: "query",
|
|
1784
|
+
required: false,
|
|
1785
|
+
schema: { type: "boolean" },
|
|
1786
|
+
description:
|
|
1787
|
+
"When true, only return providers that support managed mode.",
|
|
1788
|
+
},
|
|
1789
|
+
],
|
|
1790
|
+
security: [{ BearerAuth: [] }],
|
|
1791
|
+
responses: {
|
|
1792
|
+
"200": {
|
|
1793
|
+
description: "OAuth providers returned",
|
|
1794
|
+
content: {
|
|
1795
|
+
"application/json": {
|
|
1796
|
+
schema: { type: "object" },
|
|
1797
|
+
},
|
|
1798
|
+
},
|
|
1799
|
+
},
|
|
1800
|
+
"401": {
|
|
1801
|
+
description: "Unauthorized — missing or invalid bearer token",
|
|
1802
|
+
},
|
|
1803
|
+
"502": { description: "Failed to reach assistant runtime" },
|
|
1804
|
+
"504": { description: "Assistant runtime request timed out" },
|
|
1805
|
+
},
|
|
1806
|
+
},
|
|
1807
|
+
},
|
|
1808
|
+
"/v1/oauth/providers/{providerKey}": {
|
|
1809
|
+
get: {
|
|
1810
|
+
summary: "Get OAuth provider",
|
|
1811
|
+
description:
|
|
1812
|
+
"Authenticated gateway endpoint that retrieves a single OAuth provider by key by proxying to the assistant runtime.",
|
|
1813
|
+
operationId: "oauthProvidersGet",
|
|
1814
|
+
parameters: [
|
|
1815
|
+
{
|
|
1816
|
+
name: "providerKey",
|
|
1817
|
+
in: "path",
|
|
1818
|
+
required: true,
|
|
1819
|
+
schema: { type: "string" },
|
|
1820
|
+
description: "The provider key, for example `google`.",
|
|
1821
|
+
},
|
|
1822
|
+
],
|
|
1823
|
+
security: [{ BearerAuth: [] }],
|
|
1824
|
+
responses: {
|
|
1825
|
+
"200": {
|
|
1826
|
+
description: "OAuth provider returned",
|
|
1827
|
+
content: {
|
|
1828
|
+
"application/json": {
|
|
1829
|
+
schema: { type: "object" },
|
|
1830
|
+
},
|
|
1831
|
+
},
|
|
1832
|
+
},
|
|
1833
|
+
"401": {
|
|
1834
|
+
description: "Unauthorized — missing or invalid bearer token",
|
|
1835
|
+
},
|
|
1836
|
+
"404": { description: "OAuth provider not found" },
|
|
1837
|
+
"502": { description: "Failed to reach assistant runtime" },
|
|
1838
|
+
"504": { description: "Assistant runtime request timed out" },
|
|
1839
|
+
},
|
|
1840
|
+
},
|
|
1841
|
+
},
|
|
1728
1842
|
"/v1/oauth/apps": {
|
|
1729
1843
|
get: {
|
|
1730
1844
|
summary: "List OAuth apps",
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { afterEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import type { SlackFile } from "./normalize.js";
|
|
3
|
+
|
|
4
|
+
type FetchFn = (
|
|
5
|
+
input: string | URL | Request,
|
|
6
|
+
init?: RequestInit,
|
|
7
|
+
) => Promise<Response>;
|
|
8
|
+
let fetchMock: ReturnType<typeof mock<FetchFn>> = mock(
|
|
9
|
+
async () => new Response(),
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
mock.module("../fetch.js", () => ({
|
|
13
|
+
fetchImpl: (...args: Parameters<FetchFn>) => fetchMock(...args),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
const { downloadSlackFile } = await import("./download.js");
|
|
17
|
+
|
|
18
|
+
function makeSlackFile(overrides?: Partial<SlackFile>): SlackFile {
|
|
19
|
+
return {
|
|
20
|
+
id: "F12345",
|
|
21
|
+
name: "test-image.png",
|
|
22
|
+
mimetype: "image/png",
|
|
23
|
+
url_private_download: "https://files.slack.com/download/F12345",
|
|
24
|
+
url_private: "https://files.slack.com/files-pri/F12345",
|
|
25
|
+
...overrides,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Create a minimal valid PNG buffer that file-type can detect. */
|
|
30
|
+
function makePngBuffer(): ArrayBuffer {
|
|
31
|
+
// PNG signature (8 bytes) + minimal IHDR chunk (25 bytes)
|
|
32
|
+
const png = new Uint8Array([
|
|
33
|
+
// PNG signature
|
|
34
|
+
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
|
35
|
+
// IHDR chunk: length (13)
|
|
36
|
+
0x00, 0x00, 0x00, 0x0d,
|
|
37
|
+
// IHDR type
|
|
38
|
+
0x49, 0x48, 0x44, 0x52,
|
|
39
|
+
// Width: 1
|
|
40
|
+
0x00, 0x00, 0x00, 0x01,
|
|
41
|
+
// Height: 1
|
|
42
|
+
0x00, 0x00, 0x00, 0x01,
|
|
43
|
+
// Bit depth, color type, compression, filter, interlace
|
|
44
|
+
0x08, 0x02, 0x00, 0x00, 0x00,
|
|
45
|
+
// CRC (placeholder)
|
|
46
|
+
0x90, 0x77, 0x53, 0xde,
|
|
47
|
+
]);
|
|
48
|
+
return png.buffer;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Create a plain text buffer (undetectable by file-type). */
|
|
52
|
+
function makeTextBuffer(): ArrayBuffer {
|
|
53
|
+
return new TextEncoder().encode("hello world").buffer;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe("downloadSlackFile", () => {
|
|
57
|
+
afterEach(() => {
|
|
58
|
+
fetchMock = mock(async () => new Response());
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("downloads file using url_private_download", async () => {
|
|
62
|
+
const fileBuffer = makePngBuffer();
|
|
63
|
+
fetchMock = mock(async () => new Response(fileBuffer));
|
|
64
|
+
|
|
65
|
+
const file = makeSlackFile();
|
|
66
|
+
const result = await downloadSlackFile(file, "xoxb-test-token");
|
|
67
|
+
|
|
68
|
+
expect(result.filename).toBe("test-image.png");
|
|
69
|
+
expect(result.mimeType).toBe("image/png");
|
|
70
|
+
expect(result.data).toBe(
|
|
71
|
+
Buffer.from(fileBuffer).toString("base64"),
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
// Verify the correct URL and auth header were used
|
|
75
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
76
|
+
const [url, init] = fetchMock.mock.calls[0];
|
|
77
|
+
expect(url).toBe("https://files.slack.com/download/F12345");
|
|
78
|
+
expect((init as RequestInit).headers).toEqual({
|
|
79
|
+
Authorization: "Bearer xoxb-test-token",
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("falls back to url_private when url_private_download is absent", async () => {
|
|
84
|
+
const fileBuffer = makePngBuffer();
|
|
85
|
+
fetchMock = mock(async () => new Response(fileBuffer));
|
|
86
|
+
|
|
87
|
+
const file = makeSlackFile({
|
|
88
|
+
url_private_download: undefined,
|
|
89
|
+
});
|
|
90
|
+
const result = await downloadSlackFile(file, "xoxb-test-token");
|
|
91
|
+
|
|
92
|
+
expect(result.filename).toBe("test-image.png");
|
|
93
|
+
|
|
94
|
+
const [url] = fetchMock.mock.calls[0];
|
|
95
|
+
expect(url).toBe("https://files.slack.com/files-pri/F12345");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("throws when neither URL is present", async () => {
|
|
99
|
+
const file = makeSlackFile({
|
|
100
|
+
url_private_download: undefined,
|
|
101
|
+
url_private: undefined,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
await expect(
|
|
105
|
+
downloadSlackFile(file, "xoxb-test-token"),
|
|
106
|
+
).rejects.toThrow("Slack file F12345 has no download URL");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("throws on HTTP error response", async () => {
|
|
110
|
+
fetchMock = mock(
|
|
111
|
+
async () => new Response("Forbidden", { status: 403, statusText: "Forbidden" }),
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
const file = makeSlackFile();
|
|
115
|
+
|
|
116
|
+
await expect(
|
|
117
|
+
downloadSlackFile(file, "xoxb-test-token"),
|
|
118
|
+
).rejects.toThrow(
|
|
119
|
+
"Failed to download Slack file F12345: 403 Forbidden",
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("file.mimetype wins over detected and Content-Type", async () => {
|
|
124
|
+
// Return a PNG buffer but set file.mimetype to a custom type
|
|
125
|
+
const fileBuffer = makePngBuffer();
|
|
126
|
+
fetchMock = mock(
|
|
127
|
+
async () =>
|
|
128
|
+
new Response(fileBuffer, {
|
|
129
|
+
headers: { "Content-Type": "application/octet-stream" },
|
|
130
|
+
}),
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
const file = makeSlackFile({ mimetype: "image/webp" });
|
|
134
|
+
const result = await downloadSlackFile(file, "xoxb-test-token");
|
|
135
|
+
|
|
136
|
+
expect(result.mimeType).toBe("image/webp");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("falls back to detected MIME when file.mimetype is absent", async () => {
|
|
140
|
+
const fileBuffer = makePngBuffer();
|
|
141
|
+
fetchMock = mock(async () => new Response(fileBuffer));
|
|
142
|
+
|
|
143
|
+
const file = makeSlackFile({ mimetype: undefined });
|
|
144
|
+
const result = await downloadSlackFile(file, "xoxb-test-token");
|
|
145
|
+
|
|
146
|
+
expect(result.mimeType).toBe("image/png");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("falls back to Content-Type when mimetype is absent and type is undetectable", async () => {
|
|
150
|
+
const fileBuffer = makeTextBuffer();
|
|
151
|
+
fetchMock = mock(
|
|
152
|
+
async () =>
|
|
153
|
+
new Response(fileBuffer, {
|
|
154
|
+
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
|
155
|
+
}),
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
const file = makeSlackFile({ mimetype: undefined });
|
|
159
|
+
const result = await downloadSlackFile(file, "xoxb-test-token");
|
|
160
|
+
|
|
161
|
+
expect(result.mimeType).toBe("text/plain");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("falls back to application/octet-stream when nothing else is available", async () => {
|
|
165
|
+
const fileBuffer = makeTextBuffer();
|
|
166
|
+
fetchMock = mock(async () => new Response(fileBuffer));
|
|
167
|
+
|
|
168
|
+
const file = makeSlackFile({ mimetype: undefined });
|
|
169
|
+
const result = await downloadSlackFile(file, "xoxb-test-token");
|
|
170
|
+
|
|
171
|
+
expect(result.mimeType).toBe("application/octet-stream");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("falls back to slack_file_{id} when file.name is absent", async () => {
|
|
175
|
+
const fileBuffer = makeTextBuffer();
|
|
176
|
+
fetchMock = mock(async () => new Response(fileBuffer));
|
|
177
|
+
|
|
178
|
+
const file = makeSlackFile({ name: undefined, mimetype: "text/plain" });
|
|
179
|
+
const result = await downloadSlackFile(file, "xoxb-test-token");
|
|
180
|
+
|
|
181
|
+
expect(result.filename).toBe("slack_file_F12345");
|
|
182
|
+
});
|
|
183
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { fileTypeFromBuffer } from "file-type";
|
|
2
|
+
import { fetchImpl } from "../fetch.js";
|
|
3
|
+
import type { SlackFile } from "./normalize.js";
|
|
4
|
+
|
|
5
|
+
export interface DownloadedFile {
|
|
6
|
+
filename: string;
|
|
7
|
+
mimeType: string;
|
|
8
|
+
data: string; // base64-encoded
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const DOWNLOAD_TIMEOUT_MS = 30_000;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Download a Slack file using the bot token for authentication.
|
|
15
|
+
* Prefers url_private_download; falls back to url_private.
|
|
16
|
+
*/
|
|
17
|
+
export async function downloadSlackFile(
|
|
18
|
+
file: SlackFile,
|
|
19
|
+
botToken: string,
|
|
20
|
+
): Promise<DownloadedFile> {
|
|
21
|
+
const url = file.url_private_download || file.url_private;
|
|
22
|
+
if (!url) {
|
|
23
|
+
throw new Error(`Slack file ${file.id} has no download URL`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const response = await fetchImpl(url, {
|
|
27
|
+
headers: { Authorization: `Bearer ${botToken}` },
|
|
28
|
+
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
if (!response.ok) {
|
|
32
|
+
throw new Error(
|
|
33
|
+
`Failed to download Slack file ${file.id}: ${response.status} ${response.statusText}`,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const buffer = await response.arrayBuffer();
|
|
38
|
+
const detected = await fileTypeFromBuffer(new Uint8Array(buffer));
|
|
39
|
+
|
|
40
|
+
const mimeType =
|
|
41
|
+
file.mimetype ||
|
|
42
|
+
detected?.mime ||
|
|
43
|
+
response.headers.get("Content-Type")?.split(";")[0].trim() ||
|
|
44
|
+
"application/octet-stream";
|
|
45
|
+
|
|
46
|
+
const filename = file.name || `slack_file_${file.id}`;
|
|
47
|
+
const data = Buffer.from(buffer).toString("base64");
|
|
48
|
+
|
|
49
|
+
return { filename, mimeType, data };
|
|
50
|
+
}
|
|
@@ -2,8 +2,15 @@ import { describe, it, expect } from "bun:test";
|
|
|
2
2
|
import {
|
|
3
3
|
normalizeSlackBlockActions,
|
|
4
4
|
normalizeSlackReactionAdded,
|
|
5
|
+
normalizeSlackDirectMessage,
|
|
6
|
+
normalizeSlackChannelMessage,
|
|
7
|
+
normalizeSlackAppMention,
|
|
5
8
|
type SlackBlockActionsPayload,
|
|
6
9
|
type SlackReactionAddedEvent,
|
|
10
|
+
type SlackDirectMessageEvent,
|
|
11
|
+
type SlackChannelMessageEvent,
|
|
12
|
+
type SlackAppMentionEvent,
|
|
13
|
+
type SlackFile,
|
|
7
14
|
} from "./normalize.js";
|
|
8
15
|
import type { GatewayConfig } from "../config.js";
|
|
9
16
|
|
|
@@ -252,3 +259,216 @@ describe("normalizeSlackReactionAdded", () => {
|
|
|
252
259
|
);
|
|
253
260
|
});
|
|
254
261
|
});
|
|
262
|
+
|
|
263
|
+
// --- Attachment extraction tests ---
|
|
264
|
+
|
|
265
|
+
function makeSlackFile(overrides?: Partial<SlackFile>): SlackFile {
|
|
266
|
+
return {
|
|
267
|
+
id: "F001",
|
|
268
|
+
name: "photo.png",
|
|
269
|
+
mimetype: "image/png",
|
|
270
|
+
size: 12345,
|
|
271
|
+
url_private_download: "https://files.slack.com/download/photo.png",
|
|
272
|
+
url_private: "https://files.slack.com/files/photo.png",
|
|
273
|
+
...overrides,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function makeDmEvent(
|
|
278
|
+
overrides?: Partial<SlackDirectMessageEvent>,
|
|
279
|
+
): SlackDirectMessageEvent {
|
|
280
|
+
return {
|
|
281
|
+
type: "message",
|
|
282
|
+
user: "U123",
|
|
283
|
+
text: "hello",
|
|
284
|
+
ts: "1234567890.123456",
|
|
285
|
+
channel: "D789",
|
|
286
|
+
channel_type: "im",
|
|
287
|
+
...overrides,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function makeChannelEvent(
|
|
292
|
+
overrides?: Partial<SlackChannelMessageEvent>,
|
|
293
|
+
): SlackChannelMessageEvent {
|
|
294
|
+
return {
|
|
295
|
+
type: "message",
|
|
296
|
+
user: "U123",
|
|
297
|
+
text: "hello",
|
|
298
|
+
ts: "1234567890.123456",
|
|
299
|
+
channel: "C456",
|
|
300
|
+
channel_type: "channel",
|
|
301
|
+
...overrides,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function makeAppMentionEvent(
|
|
306
|
+
overrides?: Partial<SlackAppMentionEvent>,
|
|
307
|
+
): SlackAppMentionEvent {
|
|
308
|
+
return {
|
|
309
|
+
type: "app_mention",
|
|
310
|
+
user: "U123",
|
|
311
|
+
text: "<@UBOT> hello",
|
|
312
|
+
ts: "1234567890.123456",
|
|
313
|
+
channel: "C456",
|
|
314
|
+
...overrides,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
describe("attachment extraction in normalize functions", () => {
|
|
319
|
+
describe("normalizeSlackDirectMessage", () => {
|
|
320
|
+
it("populates attachments with type 'image' for image files", () => {
|
|
321
|
+
const config = makeConfig();
|
|
322
|
+
const event = makeDmEvent({
|
|
323
|
+
files: [
|
|
324
|
+
makeSlackFile({ id: "F001", mimetype: "image/png", name: "photo.png" }),
|
|
325
|
+
makeSlackFile({ id: "F002", mimetype: "image/jpeg", name: "pic.jpg" }),
|
|
326
|
+
],
|
|
327
|
+
});
|
|
328
|
+
const result = normalizeSlackDirectMessage(event, "evt-1", config);
|
|
329
|
+
|
|
330
|
+
expect(result).not.toBeNull();
|
|
331
|
+
expect(result!.event.message.attachments).toHaveLength(2);
|
|
332
|
+
expect(result!.event.message.attachments![0]).toEqual({
|
|
333
|
+
type: "image",
|
|
334
|
+
fileId: "F001",
|
|
335
|
+
fileName: "photo.png",
|
|
336
|
+
mimeType: "image/png",
|
|
337
|
+
fileSize: 12345,
|
|
338
|
+
});
|
|
339
|
+
expect(result!.event.message.attachments![1]).toEqual({
|
|
340
|
+
type: "image",
|
|
341
|
+
fileId: "F002",
|
|
342
|
+
fileName: "pic.jpg",
|
|
343
|
+
mimeType: "image/jpeg",
|
|
344
|
+
fileSize: 12345,
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
// slackFiles map should be populated
|
|
348
|
+
expect(result!.slackFiles).toBeDefined();
|
|
349
|
+
expect(result!.slackFiles!.size).toBe(2);
|
|
350
|
+
expect(result!.slackFiles!.get("F001")!.id).toBe("F001");
|
|
351
|
+
expect(result!.slackFiles!.get("F002")!.id).toBe("F002");
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it("populates attachments with type 'document' for non-image files", () => {
|
|
355
|
+
const config = makeConfig();
|
|
356
|
+
const event = makeDmEvent({
|
|
357
|
+
files: [
|
|
358
|
+
makeSlackFile({
|
|
359
|
+
id: "F003",
|
|
360
|
+
mimetype: "application/pdf",
|
|
361
|
+
name: "doc.pdf",
|
|
362
|
+
size: 99999,
|
|
363
|
+
}),
|
|
364
|
+
],
|
|
365
|
+
});
|
|
366
|
+
const result = normalizeSlackDirectMessage(event, "evt-2", config);
|
|
367
|
+
|
|
368
|
+
expect(result).not.toBeNull();
|
|
369
|
+
expect(result!.event.message.attachments).toHaveLength(1);
|
|
370
|
+
expect(result!.event.message.attachments![0]).toEqual({
|
|
371
|
+
type: "document",
|
|
372
|
+
fileId: "F003",
|
|
373
|
+
fileName: "doc.pdf",
|
|
374
|
+
mimeType: "application/pdf",
|
|
375
|
+
fileSize: 99999,
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it("filters out files missing download URLs", () => {
|
|
380
|
+
const config = makeConfig();
|
|
381
|
+
const event = makeDmEvent({
|
|
382
|
+
files: [
|
|
383
|
+
makeSlackFile({ id: "F004" }),
|
|
384
|
+
makeSlackFile({
|
|
385
|
+
id: "F005",
|
|
386
|
+
url_private_download: undefined,
|
|
387
|
+
url_private: undefined,
|
|
388
|
+
}),
|
|
389
|
+
],
|
|
390
|
+
});
|
|
391
|
+
const result = normalizeSlackDirectMessage(event, "evt-3", config);
|
|
392
|
+
|
|
393
|
+
expect(result).not.toBeNull();
|
|
394
|
+
// Only F004 has download URLs
|
|
395
|
+
expect(result!.event.message.attachments).toHaveLength(1);
|
|
396
|
+
expect(result!.event.message.attachments![0].fileId).toBe("F004");
|
|
397
|
+
expect(result!.slackFiles!.size).toBe(1);
|
|
398
|
+
expect(result!.slackFiles!.has("F005")).toBe(false);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it("omits attachments field when files is empty", () => {
|
|
402
|
+
const config = makeConfig();
|
|
403
|
+
const event = makeDmEvent({ files: [] });
|
|
404
|
+
const result = normalizeSlackDirectMessage(event, "evt-4", config);
|
|
405
|
+
|
|
406
|
+
expect(result).not.toBeNull();
|
|
407
|
+
expect(result!.event.message.attachments).toBeUndefined();
|
|
408
|
+
expect(result!.slackFiles).toBeUndefined();
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it("omits attachments field when files is undefined", () => {
|
|
412
|
+
const config = makeConfig();
|
|
413
|
+
const event = makeDmEvent();
|
|
414
|
+
const result = normalizeSlackDirectMessage(event, "evt-5", config);
|
|
415
|
+
|
|
416
|
+
expect(result).not.toBeNull();
|
|
417
|
+
expect(result!.event.message.attachments).toBeUndefined();
|
|
418
|
+
expect(result!.slackFiles).toBeUndefined();
|
|
419
|
+
});
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
describe("normalizeSlackChannelMessage", () => {
|
|
423
|
+
it("populates attachments for channel messages with files", () => {
|
|
424
|
+
const config = makeConfig();
|
|
425
|
+
const event = makeChannelEvent({
|
|
426
|
+
files: [
|
|
427
|
+
makeSlackFile({ id: "F010", mimetype: "image/gif", name: "anim.gif" }),
|
|
428
|
+
],
|
|
429
|
+
});
|
|
430
|
+
const result = normalizeSlackChannelMessage(event, "evt-ch-1", config);
|
|
431
|
+
|
|
432
|
+
expect(result).not.toBeNull();
|
|
433
|
+
expect(result!.event.message.attachments).toHaveLength(1);
|
|
434
|
+
expect(result!.event.message.attachments![0]).toEqual({
|
|
435
|
+
type: "image",
|
|
436
|
+
fileId: "F010",
|
|
437
|
+
fileName: "anim.gif",
|
|
438
|
+
mimeType: "image/gif",
|
|
439
|
+
fileSize: 12345,
|
|
440
|
+
});
|
|
441
|
+
expect(result!.slackFiles).toBeDefined();
|
|
442
|
+
expect(result!.slackFiles!.get("F010")!.name).toBe("anim.gif");
|
|
443
|
+
});
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
describe("normalizeSlackAppMention", () => {
|
|
447
|
+
it("populates attachments for app mention events with files", () => {
|
|
448
|
+
const config = makeConfig();
|
|
449
|
+
const event = makeAppMentionEvent({
|
|
450
|
+
files: [
|
|
451
|
+
makeSlackFile({
|
|
452
|
+
id: "F020",
|
|
453
|
+
mimetype: "text/plain",
|
|
454
|
+
name: "notes.txt",
|
|
455
|
+
size: 500,
|
|
456
|
+
}),
|
|
457
|
+
],
|
|
458
|
+
});
|
|
459
|
+
const result = normalizeSlackAppMention(event, "evt-am-1", config);
|
|
460
|
+
|
|
461
|
+
expect(result).not.toBeNull();
|
|
462
|
+
expect(result!.event.message.attachments).toHaveLength(1);
|
|
463
|
+
expect(result!.event.message.attachments![0]).toEqual({
|
|
464
|
+
type: "document",
|
|
465
|
+
fileId: "F020",
|
|
466
|
+
fileName: "notes.txt",
|
|
467
|
+
mimeType: "text/plain",
|
|
468
|
+
fileSize: 500,
|
|
469
|
+
});
|
|
470
|
+
expect(result!.slackFiles).toBeDefined();
|
|
471
|
+
expect(result!.slackFiles!.get("F020")!.id).toBe("F020");
|
|
472
|
+
});
|
|
473
|
+
});
|
|
474
|
+
});
|
package/src/slack/normalize.ts
CHANGED
|
@@ -166,6 +166,16 @@ export function getUserInfoCacheSize(): number {
|
|
|
166
166
|
return userInfoCache.size;
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
+
/** Slack file object (subset relevant to attachment handling). */
|
|
170
|
+
export interface SlackFile {
|
|
171
|
+
id: string;
|
|
172
|
+
name?: string;
|
|
173
|
+
mimetype?: string;
|
|
174
|
+
size?: number;
|
|
175
|
+
url_private_download?: string;
|
|
176
|
+
url_private?: string;
|
|
177
|
+
}
|
|
178
|
+
|
|
169
179
|
/**
|
|
170
180
|
* Slack `app_mention` event shape (subset relevant to normalization).
|
|
171
181
|
*/
|
|
@@ -178,6 +188,7 @@ export interface SlackAppMentionEvent {
|
|
|
178
188
|
thread_ts?: string;
|
|
179
189
|
client_msg_id?: string;
|
|
180
190
|
event_ts?: string;
|
|
191
|
+
files?: SlackFile[];
|
|
181
192
|
}
|
|
182
193
|
|
|
183
194
|
/**
|
|
@@ -194,6 +205,7 @@ export interface SlackDirectMessageEvent {
|
|
|
194
205
|
thread_ts?: string;
|
|
195
206
|
client_msg_id?: string;
|
|
196
207
|
event_ts?: string;
|
|
208
|
+
files?: SlackFile[];
|
|
197
209
|
}
|
|
198
210
|
|
|
199
211
|
/**
|
|
@@ -211,6 +223,7 @@ export interface SlackChannelMessageEvent {
|
|
|
211
223
|
thread_ts?: string;
|
|
212
224
|
client_msg_id?: string;
|
|
213
225
|
event_ts?: string;
|
|
226
|
+
files?: SlackFile[];
|
|
214
227
|
}
|
|
215
228
|
|
|
216
229
|
/**
|
|
@@ -251,6 +264,29 @@ export function stripBotMention(text: string): string {
|
|
|
251
264
|
return stripped || text.trim();
|
|
252
265
|
}
|
|
253
266
|
|
|
267
|
+
function extractSlackAttachments(
|
|
268
|
+
files: SlackFile[] | undefined,
|
|
269
|
+
): Array<{
|
|
270
|
+
type: "image" | "document";
|
|
271
|
+
fileId: string;
|
|
272
|
+
fileName?: string;
|
|
273
|
+
mimeType?: string;
|
|
274
|
+
fileSize?: number;
|
|
275
|
+
}> {
|
|
276
|
+
if (!files || files.length === 0) return [];
|
|
277
|
+
return files
|
|
278
|
+
.filter((f) => f.url_private_download || f.url_private)
|
|
279
|
+
.map((f) => ({
|
|
280
|
+
type: f.mimetype?.startsWith("image/")
|
|
281
|
+
? ("image" as const)
|
|
282
|
+
: ("document" as const),
|
|
283
|
+
fileId: f.id,
|
|
284
|
+
fileName: f.name,
|
|
285
|
+
mimeType: f.mimetype,
|
|
286
|
+
fileSize: f.size,
|
|
287
|
+
}));
|
|
288
|
+
}
|
|
289
|
+
|
|
254
290
|
export type NormalizedSlackEvent = {
|
|
255
291
|
event: GatewayInboundEvent;
|
|
256
292
|
routing: RouteResult;
|
|
@@ -258,6 +294,8 @@ export type NormalizedSlackEvent = {
|
|
|
258
294
|
threadTs: string;
|
|
259
295
|
/** Slack channel ID. */
|
|
260
296
|
channel: string;
|
|
297
|
+
/** Original Slack file objects keyed by file ID, for download in the I/O layer. */
|
|
298
|
+
slackFiles?: Map<string, SlackFile>;
|
|
261
299
|
};
|
|
262
300
|
|
|
263
301
|
/**
|
|
@@ -300,6 +338,15 @@ export function normalizeSlackDirectMessage(
|
|
|
300
338
|
const externalMessageId =
|
|
301
339
|
event.client_msg_id ?? event.ts ?? `${event.channel}:${event.ts}`;
|
|
302
340
|
|
|
341
|
+
const attachments = extractSlackAttachments(event.files);
|
|
342
|
+
const slackFiles = event.files?.length
|
|
343
|
+
? new Map(
|
|
344
|
+
event.files
|
|
345
|
+
.filter((f) => f.url_private_download || f.url_private)
|
|
346
|
+
.map((f) => [f.id, f]),
|
|
347
|
+
)
|
|
348
|
+
: undefined;
|
|
349
|
+
|
|
303
350
|
// Use cache-only lookup to avoid blocking normalization on network calls.
|
|
304
351
|
// A background fetch warms the cache for subsequent messages from this user.
|
|
305
352
|
const userInfo =
|
|
@@ -316,6 +363,7 @@ export function normalizeSlackDirectMessage(
|
|
|
316
363
|
content: event.text,
|
|
317
364
|
conversationExternalId: event.channel,
|
|
318
365
|
externalMessageId,
|
|
366
|
+
...(attachments.length > 0 ? { attachments } : {}),
|
|
319
367
|
},
|
|
320
368
|
actor: {
|
|
321
369
|
actorExternalId: event.user,
|
|
@@ -333,6 +381,7 @@ export function normalizeSlackDirectMessage(
|
|
|
333
381
|
routing,
|
|
334
382
|
threadTs: event.thread_ts ?? event.ts,
|
|
335
383
|
channel: event.channel,
|
|
384
|
+
...(slackFiles ? { slackFiles } : {}),
|
|
336
385
|
};
|
|
337
386
|
}
|
|
338
387
|
|
|
@@ -361,6 +410,15 @@ export function normalizeSlackChannelMessage(
|
|
|
361
410
|
const externalMessageId =
|
|
362
411
|
event.client_msg_id ?? event.ts ?? `${event.channel}:${event.ts}`;
|
|
363
412
|
|
|
413
|
+
const attachments = extractSlackAttachments(event.files);
|
|
414
|
+
const slackFiles = event.files?.length
|
|
415
|
+
? new Map(
|
|
416
|
+
event.files
|
|
417
|
+
.filter((f) => f.url_private_download || f.url_private)
|
|
418
|
+
.map((f) => [f.id, f]),
|
|
419
|
+
)
|
|
420
|
+
: undefined;
|
|
421
|
+
|
|
364
422
|
const userInfo =
|
|
365
423
|
botToken && event.user
|
|
366
424
|
? resolveSlackUserSync(event.user, botToken)
|
|
@@ -375,6 +433,7 @@ export function normalizeSlackChannelMessage(
|
|
|
375
433
|
content,
|
|
376
434
|
conversationExternalId: event.channel,
|
|
377
435
|
externalMessageId,
|
|
436
|
+
...(attachments.length > 0 ? { attachments } : {}),
|
|
378
437
|
},
|
|
379
438
|
actor: {
|
|
380
439
|
actorExternalId: event.user,
|
|
@@ -393,6 +452,7 @@ export function normalizeSlackChannelMessage(
|
|
|
393
452
|
routing,
|
|
394
453
|
threadTs: event.thread_ts ?? event.ts,
|
|
395
454
|
channel: event.channel,
|
|
455
|
+
...(slackFiles ? { slackFiles } : {}),
|
|
396
456
|
};
|
|
397
457
|
}
|
|
398
458
|
|
|
@@ -418,6 +478,15 @@ export function normalizeSlackAppMention(
|
|
|
418
478
|
const externalMessageId =
|
|
419
479
|
event.client_msg_id ?? event.ts ?? `${event.channel}:${event.ts}`;
|
|
420
480
|
|
|
481
|
+
const attachments = extractSlackAttachments(event.files);
|
|
482
|
+
const slackFiles = event.files?.length
|
|
483
|
+
? new Map(
|
|
484
|
+
event.files
|
|
485
|
+
.filter((f) => f.url_private_download || f.url_private)
|
|
486
|
+
.map((f) => [f.id, f]),
|
|
487
|
+
)
|
|
488
|
+
: undefined;
|
|
489
|
+
|
|
421
490
|
const userInfo =
|
|
422
491
|
botToken && event.user
|
|
423
492
|
? resolveSlackUserSync(event.user, botToken)
|
|
@@ -432,6 +501,7 @@ export function normalizeSlackAppMention(
|
|
|
432
501
|
content,
|
|
433
502
|
conversationExternalId: event.channel,
|
|
434
503
|
externalMessageId,
|
|
504
|
+
...(attachments.length > 0 ? { attachments } : {}),
|
|
435
505
|
},
|
|
436
506
|
actor: {
|
|
437
507
|
actorExternalId: event.user,
|
|
@@ -449,6 +519,7 @@ export function normalizeSlackAppMention(
|
|
|
449
519
|
routing,
|
|
450
520
|
threadTs: event.thread_ts ?? event.ts,
|
|
451
521
|
channel: event.channel,
|
|
522
|
+
...(slackFiles ? { slackFiles } : {}),
|
|
452
523
|
};
|
|
453
524
|
}
|
|
454
525
|
|