@vellumai/vellum-gateway 0.7.0 → 0.7.2
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/AGENTS.md +4 -0
- package/ARCHITECTURE.md +67 -25
- package/Dockerfile +2 -0
- package/README.md +50 -13
- package/bun.lock +16 -2
- package/knip.json +3 -1
- package/package.json +3 -1
- package/src/__tests__/auto-approve-thresholds.test.ts +49 -22
- package/src/__tests__/channel-verification-session-proxy.test.ts +0 -1
- package/src/__tests__/config-file-watcher.test.ts +181 -0
- package/src/__tests__/config.test.ts +0 -1
- package/src/__tests__/contacts-control-plane-proxy.test.ts +0 -1
- package/src/__tests__/credential-watcher-managed-bootstrap.test.ts +10 -2
- package/src/__tests__/credential-watcher.test.ts +30 -2
- package/src/__tests__/db-connection-isolation.test.ts +157 -0
- package/src/__tests__/fake-assistant-ipc.ts +39 -0
- package/src/__tests__/feature-flags-route.test.ts +8 -8
- package/src/__tests__/guardian-init-lockfile.test.ts +30 -4
- package/src/__tests__/ipc-feature-flag-routes.test.ts +1 -1
- package/src/__tests__/live-voice-websocket.test.ts +0 -1
- package/src/__tests__/load-guards.test.ts +0 -1
- package/src/__tests__/migration-teleport-gcs-proxy.test.ts +0 -1
- package/src/__tests__/oauth-callback.test.ts +0 -1
- package/src/__tests__/pair-origin-allowlist.test.ts +155 -0
- package/src/__tests__/rate-limit-loopback.test.ts +1 -1
- package/src/__tests__/remote-feature-flag-sync.test.ts +47 -7
- package/src/__tests__/resolve-assistant.test.ts +0 -1
- package/src/__tests__/route-schema-guard.test.ts +42 -6
- package/src/__tests__/runtime-client.test.ts +0 -1
- package/src/__tests__/runtime-health-proxy.test.ts +0 -1
- package/src/__tests__/runtime-proxy-auth.test.ts +0 -1
- package/src/__tests__/runtime-proxy.test.ts +0 -1
- package/src/__tests__/slack-control-plane-proxy.test.ts +0 -1
- package/src/__tests__/slack-display-name.test.ts +66 -1
- package/src/__tests__/slack-normalize.test.ts +158 -4
- package/src/__tests__/slack-reaction-normalize.test.ts +0 -1
- package/src/__tests__/slack-socket-mode-catchup.test.ts +857 -0
- package/src/__tests__/slack-socket-mode-scopes.test.ts +52 -0
- package/src/__tests__/slack-socket-mode-thread-tracking.test.ts +654 -0
- package/src/__tests__/stt-stream-websocket.test.ts +0 -1
- package/src/__tests__/telegram-control-plane-proxy.test.ts +0 -1
- package/src/__tests__/telegram-send-attachments.test.ts +0 -1
- package/src/__tests__/telegram-webhook-handler.test.ts +0 -1
- package/src/__tests__/text-verification-helpers.test.ts +136 -0
- package/src/__tests__/twilio-media-websocket.test.ts +0 -1
- package/src/__tests__/twilio-relay-websocket.test.ts +0 -1
- package/src/__tests__/twilio-webhooks.test.ts +220 -3
- package/src/__tests__/upstream-transport.test.ts +0 -36
- package/src/__tests__/whatsapp-download.test.ts +0 -1
- package/src/__tests__/whatsapp-webhook.test.ts +0 -1
- package/src/auth/guardian-refresh.ts +4 -18
- package/src/auth/ipc-route-policy.ts +217 -0
- package/src/backup/backup-key.ts +138 -0
- package/src/backup/backup-routes.ts +159 -0
- package/src/backup/backup-worker.ts +374 -0
- package/src/backup/list-snapshots.ts +97 -0
- package/src/backup/local-writer.ts +87 -0
- package/src/backup/offsite-writer.ts +182 -0
- package/src/backup/paths.ts +123 -0
- package/src/backup/stream-crypt.ts +258 -0
- package/src/chrome-extension-origins.ts +28 -0
- package/src/cli/enable-proxy.ts +0 -1
- package/src/config-file-cache.ts +3 -19
- package/src/config-file-utils.ts +124 -0
- package/src/config-file-watcher.ts +57 -25
- package/src/config.ts +4 -7
- package/src/db/connection.ts +65 -3
- package/src/db/contact-store.ts +30 -1
- package/src/db/data-migrations/index.ts +2 -0
- package/src/db/data-migrations/m0003-recover-backup-key.ts +71 -0
- package/src/db/schema.ts +92 -0
- package/src/db/slack-store.ts +144 -11
- package/src/feature-flag-registry.json +40 -152
- package/src/handlers/handle-inbound.ts +123 -0
- package/src/http/middleware/auth.ts +44 -1
- package/src/http/middleware/cors.ts +84 -0
- package/src/http/middleware/rate-limit.ts +6 -8
- package/src/http/routes/auto-approve-thresholds.ts +17 -1
- package/src/http/routes/brain-graph-proxy.ts +1 -1
- package/src/http/routes/channel-readiness-proxy.ts +2 -2
- package/src/http/routes/channel-verification-session-proxy.ts +19 -37
- package/src/http/routes/contact-prompt.ts +149 -0
- package/src/http/routes/contacts-control-plane-proxy.ts +2 -2
- package/src/http/routes/email-webhook.test.ts +0 -1
- package/src/http/routes/ipc-runtime-proxy.test.ts +197 -1
- package/src/http/routes/ipc-runtime-proxy.ts +95 -0
- package/src/http/routes/log-export.test.ts +0 -1
- package/src/http/routes/log-tail.test.ts +336 -0
- package/src/http/routes/log-tail.ts +87 -0
- package/src/http/routes/migration-proxy.ts +1 -2
- package/src/http/routes/oauth-apps-proxy.ts +2 -2
- package/src/http/routes/oauth-providers-proxy.ts +2 -2
- package/src/http/routes/pair.ts +322 -0
- package/src/http/routes/privacy-config.ts +65 -79
- package/src/http/routes/runtime-health-proxy.ts +2 -2
- package/src/http/routes/runtime-proxy.ts +3 -1
- package/src/http/routes/slack-control-plane-proxy.ts +3 -20
- package/src/http/routes/stt-stream-websocket.ts +2 -3
- package/src/http/routes/telegram-control-plane-proxy.ts +2 -2
- package/src/http/routes/telegram-webhook.test.ts +0 -1
- package/src/http/routes/telegram-webhook.ts +6 -0
- package/src/http/routes/trust-rules.suggest.test.ts +25 -0
- package/src/http/routes/trust-rules.ts +7 -0
- package/src/http/routes/twilio-control-plane-proxy.ts +2 -2
- package/src/http/routes/twilio-media-websocket.ts +5 -5
- package/src/http/routes/twilio-voice-verify-callback.ts +310 -0
- package/src/http/routes/twilio-voice-webhook.test.ts +65 -1
- package/src/http/routes/twilio-voice-webhook.ts +45 -1
- package/src/http/routes/whatsapp-webhook.test.ts +0 -1
- package/src/index.ts +357 -278
- package/src/ipc/assistant-client.ts +8 -4
- package/src/ipc/contact-handlers.ts +88 -3
- package/src/ipc/threshold-handlers.ts +2 -0
- package/src/post-assistant-ready.ts +5 -3
- package/src/risk/bash-risk-classifier.test.ts +35 -27
- package/src/risk/bash-risk-classifier.ts +44 -14
- package/src/risk/command-registry/commands/assistant.ts +8 -19
- package/src/risk/command-registry.test.ts +0 -15
- package/src/risk/risk-classifier-parity.test.ts +1 -3
- package/src/runtime/client.ts +58 -3
- package/src/schema.ts +277 -104
- package/src/slack/normalize.test.ts +98 -0
- package/src/slack/normalize.ts +107 -32
- package/src/slack/slack-web.ts +213 -0
- package/src/slack/socket-mode.ts +701 -39
- package/src/telegram/send.test.ts +0 -1
- package/src/twilio/validate-webhook.ts +53 -14
- package/src/twilio/webhook-sync-trigger.ts +58 -0
- package/src/twilio/webhook-sync.test.ts +286 -0
- package/src/twilio/webhook-sync.ts +84 -0
- package/src/util/is-loopback-address.ts +27 -0
- package/src/velay/bridge-utils.ts +228 -0
- package/src/velay/client.test.ts +939 -0
- package/src/velay/client.ts +555 -0
- package/src/velay/http-bridge.test.ts +217 -0
- package/src/velay/http-bridge.ts +83 -0
- package/src/velay/protocol.ts +178 -0
- package/src/velay/test-fake-websocket.ts +69 -0
- package/src/velay/websocket-bridge.test.ts +367 -0
- package/src/velay/websocket-bridge.ts +324 -0
- package/src/verification/binding-helpers.ts +107 -0
- package/src/verification/code-parsing.ts +44 -0
- package/src/verification/contact-helpers.ts +342 -0
- package/src/verification/identity-match.ts +68 -0
- package/src/verification/identity.ts +61 -0
- package/src/verification/rate-limit-helpers.ts +205 -0
- package/src/verification/reply-delivery.ts +109 -0
- package/src/verification/session-helpers.ts +164 -0
- package/src/verification/text-verification.ts +372 -0
- package/src/version.ts +35 -0
- package/src/voice/verification.ts +456 -0
- package/src/webhook-pipeline.ts +4 -0
- package/src/__tests__/browser-relay-websocket.test.ts +0 -698
- package/src/__tests__/telegram-only-default.test.ts +0 -133
- package/src/auth/capability-tokens.ts +0 -248
- package/src/http/routes/browser-extension-pair.ts +0 -455
- package/src/http/routes/browser-relay-websocket.ts +0 -381
- package/src/http/routes/config-file-utils.ts +0 -73
- package/src/ipc/capability-token-handlers.ts +0 -30
- package/src/pairing/approved-devices-store.ts +0 -110
- package/src/pairing/pairing-routes.ts +0 -379
- package/src/pairing/pairing-store.ts +0 -218
package/src/slack/normalize.ts
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import {
|
|
2
|
+
renderSlackTextForModel,
|
|
3
|
+
stripLeadingSlackMentionFallback,
|
|
4
|
+
stripLeadingSlackUserMention,
|
|
5
|
+
} from "@vellumai/slack-text";
|
|
1
6
|
import type { GatewayConfig } from "../config.js";
|
|
2
7
|
import { fetchImpl } from "../fetch.js";
|
|
3
8
|
import { resolveAssistant, isRejection } from "../routing/resolve-assistant.js";
|
|
@@ -275,15 +280,55 @@ export interface SlackMessageDeletedEvent {
|
|
|
275
280
|
};
|
|
276
281
|
}
|
|
277
282
|
|
|
283
|
+
export type SlackTextRenderContext = {
|
|
284
|
+
botUserId?: string;
|
|
285
|
+
userLabels?: Record<string, string>;
|
|
286
|
+
};
|
|
287
|
+
|
|
278
288
|
/**
|
|
279
|
-
* Strip leading bot-mention tokens
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
289
|
+
* Strip leading bot-mention tokens from Slack message text.
|
|
290
|
+
*
|
|
291
|
+
* When the bot user ID is known, only that exact mention is stripped. Without a
|
|
292
|
+
* bot user ID, strip just one leading mention as an app_mention compatibility
|
|
293
|
+
* fallback.
|
|
283
294
|
*/
|
|
284
|
-
export function stripBotMention(text: string): string {
|
|
285
|
-
const stripped =
|
|
286
|
-
|
|
295
|
+
export function stripBotMention(text: string, botUserId?: string): string {
|
|
296
|
+
const stripped = botUserId
|
|
297
|
+
? stripLeadingSlackUserMention(text, botUserId)
|
|
298
|
+
: stripLeadingSlackMentionFallback(text);
|
|
299
|
+
return stripped.trim() || text.trim();
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function renderSlackInboundText(
|
|
303
|
+
text: string,
|
|
304
|
+
context: SlackTextRenderContext = {},
|
|
305
|
+
options: {
|
|
306
|
+
stripLeadingBotMention?: boolean;
|
|
307
|
+
fallbackStripFirstMention?: boolean;
|
|
308
|
+
} = {},
|
|
309
|
+
): string {
|
|
310
|
+
let stripped = text;
|
|
311
|
+
if (options.stripLeadingBotMention) {
|
|
312
|
+
stripped = context.botUserId
|
|
313
|
+
? stripBotMention(text, context.botUserId)
|
|
314
|
+
: options.fallbackStripFirstMention
|
|
315
|
+
? stripBotMention(text)
|
|
316
|
+
: text.trim();
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return renderSlackTextForModel(stripped, {
|
|
320
|
+
userLabels: context.userLabels,
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function withBotUserId(
|
|
325
|
+
botUserId: string | undefined,
|
|
326
|
+
context: SlackTextRenderContext | undefined,
|
|
327
|
+
): SlackTextRenderContext {
|
|
328
|
+
return {
|
|
329
|
+
...context,
|
|
330
|
+
botUserId: context?.botUserId ?? botUserId,
|
|
331
|
+
};
|
|
287
332
|
}
|
|
288
333
|
|
|
289
334
|
function extractSlackAttachments(files: SlackFile[] | undefined): Array<{
|
|
@@ -307,6 +352,18 @@ function extractSlackAttachments(files: SlackFile[] | undefined): Array<{
|
|
|
307
352
|
}));
|
|
308
353
|
}
|
|
309
354
|
|
|
355
|
+
function extractSlackFileMap(
|
|
356
|
+
files: SlackFile[] | undefined,
|
|
357
|
+
): Map<string, SlackFile> | undefined {
|
|
358
|
+
if (!files || files.length === 0) return undefined;
|
|
359
|
+
const downloadableFiles = files.filter(
|
|
360
|
+
(f) => f.url_private_download || f.url_private,
|
|
361
|
+
);
|
|
362
|
+
return downloadableFiles.length
|
|
363
|
+
? new Map(downloadableFiles.map((f) => [f.id, f]))
|
|
364
|
+
: undefined;
|
|
365
|
+
}
|
|
366
|
+
|
|
310
367
|
export type NormalizedSlackEvent = {
|
|
311
368
|
event: GatewayInboundEvent;
|
|
312
369
|
routing: RouteResult;
|
|
@@ -332,6 +389,7 @@ export function normalizeSlackDirectMessage(
|
|
|
332
389
|
config: GatewayConfig,
|
|
333
390
|
botUserId?: string,
|
|
334
391
|
botToken?: string,
|
|
392
|
+
renderContext?: SlackTextRenderContext,
|
|
335
393
|
): NormalizedSlackEvent | null {
|
|
336
394
|
// Ignore messages from the bot itself
|
|
337
395
|
if (botUserId && event.user === botUserId) return null;
|
|
@@ -360,13 +418,7 @@ export function normalizeSlackDirectMessage(
|
|
|
360
418
|
event.client_msg_id ?? event.ts ?? `${event.channel}:${event.ts}`;
|
|
361
419
|
|
|
362
420
|
const attachments = extractSlackAttachments(event.files);
|
|
363
|
-
const slackFiles = event.files
|
|
364
|
-
? new Map(
|
|
365
|
-
event.files
|
|
366
|
-
.filter((f) => f.url_private_download || f.url_private)
|
|
367
|
-
.map((f) => [f.id, f]),
|
|
368
|
-
)
|
|
369
|
-
: undefined;
|
|
421
|
+
const slackFiles = extractSlackFileMap(event.files);
|
|
370
422
|
|
|
371
423
|
// Use cache-only lookup to avoid blocking normalization on network calls.
|
|
372
424
|
// A background fetch warms the cache for subsequent messages from this user.
|
|
@@ -374,6 +426,10 @@ export function normalizeSlackDirectMessage(
|
|
|
374
426
|
botToken && event.user
|
|
375
427
|
? resolveSlackUserSync(event.user, botToken)
|
|
376
428
|
: undefined;
|
|
429
|
+
const content = renderSlackInboundText(
|
|
430
|
+
event.text,
|
|
431
|
+
withBotUserId(botUserId, renderContext),
|
|
432
|
+
);
|
|
377
433
|
|
|
378
434
|
return {
|
|
379
435
|
event: {
|
|
@@ -381,7 +437,7 @@ export function normalizeSlackDirectMessage(
|
|
|
381
437
|
sourceChannel: "slack",
|
|
382
438
|
receivedAt: new Date().toISOString(),
|
|
383
439
|
message: {
|
|
384
|
-
content
|
|
440
|
+
content,
|
|
385
441
|
conversationExternalId: event.channel,
|
|
386
442
|
externalMessageId,
|
|
387
443
|
...(attachments.length > 0 ? { attachments } : {}),
|
|
@@ -396,6 +452,7 @@ export function normalizeSlackDirectMessage(
|
|
|
396
452
|
source: {
|
|
397
453
|
updateId: eventId,
|
|
398
454
|
messageId: event.ts,
|
|
455
|
+
chatType: "im",
|
|
399
456
|
...(event.thread_ts ? { threadId: event.thread_ts } : {}),
|
|
400
457
|
},
|
|
401
458
|
raw: event as unknown as Record<string, unknown>,
|
|
@@ -420,6 +477,7 @@ export function normalizeSlackChannelMessage(
|
|
|
420
477
|
config: GatewayConfig,
|
|
421
478
|
botUserId?: string,
|
|
422
479
|
botToken?: string,
|
|
480
|
+
renderContext?: SlackTextRenderContext,
|
|
423
481
|
): NormalizedSlackEvent | null {
|
|
424
482
|
if (botUserId && event.user === botUserId) return null;
|
|
425
483
|
// file_share is allowed so image/file uploads are delivered to the assistant.
|
|
@@ -429,18 +487,16 @@ export function normalizeSlackChannelMessage(
|
|
|
429
487
|
const routing = resolveAssistant(config, event.channel, event.user);
|
|
430
488
|
if (isRejection(routing)) return null;
|
|
431
489
|
|
|
432
|
-
const content =
|
|
490
|
+
const content = renderSlackInboundText(
|
|
491
|
+
event.text,
|
|
492
|
+
withBotUserId(botUserId, renderContext),
|
|
493
|
+
{ stripLeadingBotMention: true, fallbackStripFirstMention: true },
|
|
494
|
+
);
|
|
433
495
|
const externalMessageId =
|
|
434
496
|
event.client_msg_id ?? event.ts ?? `${event.channel}:${event.ts}`;
|
|
435
497
|
|
|
436
498
|
const attachments = extractSlackAttachments(event.files);
|
|
437
|
-
const slackFiles = event.files
|
|
438
|
-
? new Map(
|
|
439
|
-
event.files
|
|
440
|
-
.filter((f) => f.url_private_download || f.url_private)
|
|
441
|
-
.map((f) => [f.id, f]),
|
|
442
|
-
)
|
|
443
|
-
: undefined;
|
|
499
|
+
const slackFiles = extractSlackFileMap(event.files);
|
|
444
500
|
|
|
445
501
|
const userInfo =
|
|
446
502
|
botToken && event.user
|
|
@@ -491,25 +547,28 @@ export function normalizeSlackAppMention(
|
|
|
491
547
|
event: SlackAppMentionEvent,
|
|
492
548
|
eventId: string,
|
|
493
549
|
config: GatewayConfig,
|
|
550
|
+
botUserId?: string,
|
|
494
551
|
botToken?: string,
|
|
552
|
+
renderContext?: SlackTextRenderContext,
|
|
495
553
|
): NormalizedSlackEvent | null {
|
|
496
554
|
const routing = resolveAssistant(config, event.channel, event.user);
|
|
497
555
|
if (isRejection(routing)) {
|
|
498
556
|
return null;
|
|
499
557
|
}
|
|
500
558
|
|
|
501
|
-
const content =
|
|
559
|
+
const content = renderSlackInboundText(
|
|
560
|
+
event.text,
|
|
561
|
+
withBotUserId(botUserId, renderContext),
|
|
562
|
+
{
|
|
563
|
+
stripLeadingBotMention: true,
|
|
564
|
+
fallbackStripFirstMention: true,
|
|
565
|
+
},
|
|
566
|
+
);
|
|
502
567
|
const externalMessageId =
|
|
503
568
|
event.client_msg_id ?? event.ts ?? `${event.channel}:${event.ts}`;
|
|
504
569
|
|
|
505
570
|
const attachments = extractSlackAttachments(event.files);
|
|
506
|
-
const slackFiles = event.files
|
|
507
|
-
? new Map(
|
|
508
|
-
event.files
|
|
509
|
-
.filter((f) => f.url_private_download || f.url_private)
|
|
510
|
-
.map((f) => [f.id, f]),
|
|
511
|
-
)
|
|
512
|
-
: undefined;
|
|
571
|
+
const slackFiles = extractSlackFileMap(event.files);
|
|
513
572
|
|
|
514
573
|
const userInfo =
|
|
515
574
|
botToken && event.user
|
|
@@ -791,6 +850,7 @@ export function normalizeSlackMessageEdit(
|
|
|
791
850
|
eventId: string,
|
|
792
851
|
config: GatewayConfig,
|
|
793
852
|
botUserId?: string,
|
|
853
|
+
renderContext?: SlackTextRenderContext,
|
|
794
854
|
): NormalizedSlackEvent | null {
|
|
795
855
|
const edited = event.message;
|
|
796
856
|
if (!edited) return null;
|
|
@@ -816,7 +876,14 @@ export function normalizeSlackMessageEdit(
|
|
|
816
876
|
}
|
|
817
877
|
if (isRejection(routing)) return null;
|
|
818
878
|
|
|
819
|
-
const content =
|
|
879
|
+
const content = renderSlackInboundText(
|
|
880
|
+
edited.text,
|
|
881
|
+
withBotUserId(botUserId, renderContext),
|
|
882
|
+
{
|
|
883
|
+
stripLeadingBotMention: true,
|
|
884
|
+
fallbackStripFirstMention: !botUserId,
|
|
885
|
+
},
|
|
886
|
+
);
|
|
820
887
|
|
|
821
888
|
// Each edit event gets a unique externalMessageId so the dedup pipeline
|
|
822
889
|
// does not discard subsequent edits of the same Slack message.
|
|
@@ -875,9 +942,17 @@ export function normalizeSlackMessageDelete(
|
|
|
875
942
|
event: SlackMessageDeletedEvent,
|
|
876
943
|
eventId: string,
|
|
877
944
|
config: GatewayConfig,
|
|
945
|
+
botUserId?: string,
|
|
878
946
|
): NormalizedSlackEvent | null {
|
|
879
947
|
if (!event.deleted_ts) return null;
|
|
880
948
|
|
|
949
|
+
// Drop deletions of the bot's own messages. Slack echoes self-deletes back
|
|
950
|
+
// via Socket Mode; without this filter the bot's user ID flows into the
|
|
951
|
+
// assistant's ACL as the actor, fails member lookup (the bot is never its
|
|
952
|
+
// own trusted contact), and triggers a spurious access-request notification
|
|
953
|
+
// to the guardian. Mirrors the bot-self filter on the edit path above.
|
|
954
|
+
if (botUserId && event.previous_message?.user === botUserId) return null;
|
|
955
|
+
|
|
881
956
|
// Use the previous author for actor identity when available; otherwise fall
|
|
882
957
|
// back to a synthetic identifier so routing/trust still has something to key on.
|
|
883
958
|
const actorId = event.previous_message?.user ?? "slack-system";
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { fetchImpl } from "../fetch.js";
|
|
2
|
+
import { getLogger } from "../logger.js";
|
|
3
|
+
|
|
4
|
+
const log = getLogger("slack-web");
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Subset of the Slack `conversations.history` / `conversations.replies`
|
|
8
|
+
* message payload that the gateway needs for catch-up. Synthesized
|
|
9
|
+
* Socket Mode envelopes are built by mapping these fields onto the
|
|
10
|
+
* `app_mention` / `message` event shapes that the live path already
|
|
11
|
+
* understands.
|
|
12
|
+
*/
|
|
13
|
+
export type SlackHistoryMessage = {
|
|
14
|
+
type?: string;
|
|
15
|
+
subtype?: string;
|
|
16
|
+
user?: string;
|
|
17
|
+
bot_id?: string;
|
|
18
|
+
text?: string;
|
|
19
|
+
ts: string;
|
|
20
|
+
thread_ts?: string;
|
|
21
|
+
team?: string;
|
|
22
|
+
blocks?: unknown[];
|
|
23
|
+
files?: unknown[];
|
|
24
|
+
attachments?: unknown[];
|
|
25
|
+
edited?: { user?: string; ts?: string };
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
type SlackHistoryResponse = {
|
|
29
|
+
ok: boolean;
|
|
30
|
+
error?: string;
|
|
31
|
+
messages?: SlackHistoryMessage[];
|
|
32
|
+
has_more?: boolean;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type FetchSlackHistoryResult = {
|
|
36
|
+
/** Messages returned by Slack. Empty on transient failure. */
|
|
37
|
+
messages: SlackHistoryMessage[];
|
|
38
|
+
/** True when Slack indicates additional pages are available. */
|
|
39
|
+
hasMore: boolean;
|
|
40
|
+
/**
|
|
41
|
+
* `true` when the call succeeded (HTTP 200 + `ok: true`). False results
|
|
42
|
+
* are logged and silently treated as zero messages so a transient outage
|
|
43
|
+
* during catch-up doesn't block reconnect.
|
|
44
|
+
*/
|
|
45
|
+
ok: boolean;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** Slack rate-limit handling — wait this long after a 429 before retrying. */
|
|
49
|
+
const RATE_LIMIT_FALLBACK_MS = 5_000;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Coordinates per-cycle catch-up cancellation. When any worker hits a 429,
|
|
53
|
+
* it calls `abort()`; remaining workers check `aborted` before each call
|
|
54
|
+
* and bail out without issuing further requests, so the catch-up cycle
|
|
55
|
+
* stops cleanly instead of cascading into more rate-limit responses.
|
|
56
|
+
*/
|
|
57
|
+
export class CatchupAbortSignal {
|
|
58
|
+
private flag = false;
|
|
59
|
+
abort(): void {
|
|
60
|
+
this.flag = true;
|
|
61
|
+
}
|
|
62
|
+
get aborted(): boolean {
|
|
63
|
+
return this.flag;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function callSlackApi(
|
|
68
|
+
url: string,
|
|
69
|
+
botToken: string,
|
|
70
|
+
abort: CatchupAbortSignal | undefined,
|
|
71
|
+
): Promise<SlackHistoryResponse | undefined> {
|
|
72
|
+
if (abort?.aborted) return undefined;
|
|
73
|
+
|
|
74
|
+
let resp: Response;
|
|
75
|
+
try {
|
|
76
|
+
resp = await fetchImpl(url, {
|
|
77
|
+
method: "GET",
|
|
78
|
+
headers: { Authorization: `Bearer ${botToken}` },
|
|
79
|
+
});
|
|
80
|
+
} catch (err) {
|
|
81
|
+
log.warn({ err, url: redact(url) }, "Slack catch-up fetch threw");
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (resp.status === 429) {
|
|
86
|
+
const retryHeader = resp.headers.get("retry-after");
|
|
87
|
+
const waitMs = retryHeader
|
|
88
|
+
? Number.parseInt(retryHeader, 10) * 1_000
|
|
89
|
+
: RATE_LIMIT_FALLBACK_MS;
|
|
90
|
+
abort?.abort();
|
|
91
|
+
log.warn(
|
|
92
|
+
{ url: redact(url), waitMs },
|
|
93
|
+
"Slack catch-up rate limited; aborting cycle",
|
|
94
|
+
);
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (!resp.ok) {
|
|
99
|
+
log.warn(
|
|
100
|
+
{ url: redact(url), status: resp.status },
|
|
101
|
+
"Slack catch-up returned non-OK HTTP status",
|
|
102
|
+
);
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let data: SlackHistoryResponse;
|
|
107
|
+
try {
|
|
108
|
+
data = (await resp.json()) as SlackHistoryResponse;
|
|
109
|
+
} catch (err) {
|
|
110
|
+
log.warn({ err, url: redact(url) }, "Slack catch-up response not JSON");
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (!data.ok) {
|
|
115
|
+
log.warn(
|
|
116
|
+
{ url: redact(url), error: data.error },
|
|
117
|
+
"Slack catch-up API returned ok=false",
|
|
118
|
+
);
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return data;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Strip the channel/oldest query for log readability. */
|
|
126
|
+
function redact(url: string): string {
|
|
127
|
+
const idx = url.indexOf("?");
|
|
128
|
+
return idx >= 0 ? url.slice(0, idx) : url;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Fetch messages in `channel` strictly newer than `oldest` (Slack ts).
|
|
133
|
+
* `inclusive=false` is the default; we set it explicitly to make the
|
|
134
|
+
* behavior obvious on read. No pagination — we cap at `limit` messages
|
|
135
|
+
* per channel per reconnect to bound API budget.
|
|
136
|
+
*/
|
|
137
|
+
export async function fetchChannelHistorySince(params: {
|
|
138
|
+
botToken: string;
|
|
139
|
+
channel: string;
|
|
140
|
+
oldest: string;
|
|
141
|
+
limit: number;
|
|
142
|
+
abort?: CatchupAbortSignal;
|
|
143
|
+
}): Promise<FetchSlackHistoryResult> {
|
|
144
|
+
const { botToken, channel, oldest, limit, abort } = params;
|
|
145
|
+
const url =
|
|
146
|
+
"https://slack.com/api/conversations.history" +
|
|
147
|
+
`?channel=${encodeURIComponent(channel)}` +
|
|
148
|
+
`&oldest=${encodeURIComponent(oldest)}` +
|
|
149
|
+
`&limit=${limit}` +
|
|
150
|
+
`&inclusive=false`;
|
|
151
|
+
const data = await callSlackApi(url, botToken, abort);
|
|
152
|
+
if (!data) return { messages: [], hasMore: false, ok: false };
|
|
153
|
+
return {
|
|
154
|
+
messages: data.messages ?? [],
|
|
155
|
+
hasMore: data.has_more === true,
|
|
156
|
+
ok: true,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Fetch replies in a thread strictly newer than `oldest`. Uses
|
|
162
|
+
* `conversations.replies` because it returns a smaller, thread-scoped
|
|
163
|
+
* window than `conversations.history` and avoids re-paging the channel
|
|
164
|
+
* for every active thread on reconnect.
|
|
165
|
+
*/
|
|
166
|
+
export async function fetchThreadRepliesSince(params: {
|
|
167
|
+
botToken: string;
|
|
168
|
+
channel: string;
|
|
169
|
+
threadTs: string;
|
|
170
|
+
oldest: string;
|
|
171
|
+
limit: number;
|
|
172
|
+
abort?: CatchupAbortSignal;
|
|
173
|
+
}): Promise<FetchSlackHistoryResult> {
|
|
174
|
+
const { botToken, channel, threadTs, oldest, limit, abort } = params;
|
|
175
|
+
const url =
|
|
176
|
+
"https://slack.com/api/conversations.replies" +
|
|
177
|
+
`?channel=${encodeURIComponent(channel)}` +
|
|
178
|
+
`&ts=${encodeURIComponent(threadTs)}` +
|
|
179
|
+
`&oldest=${encodeURIComponent(oldest)}` +
|
|
180
|
+
`&limit=${limit}` +
|
|
181
|
+
`&inclusive=false`;
|
|
182
|
+
const data = await callSlackApi(url, botToken, abort);
|
|
183
|
+
if (!data) return { messages: [], hasMore: false, ok: false };
|
|
184
|
+
return {
|
|
185
|
+
messages: data.messages ?? [],
|
|
186
|
+
hasMore: data.has_more === true,
|
|
187
|
+
ok: true,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Drives a list of async tasks with bounded concurrency. Used to fan out
|
|
193
|
+
* catch-up fetches across channels without flooding Slack's rate limits.
|
|
194
|
+
*/
|
|
195
|
+
export async function runWithConcurrency<T>(
|
|
196
|
+
tasks: Array<() => Promise<T>>,
|
|
197
|
+
concurrency: number,
|
|
198
|
+
): Promise<T[]> {
|
|
199
|
+
const results: T[] = new Array(tasks.length);
|
|
200
|
+
let cursor = 0;
|
|
201
|
+
const workers = Array.from(
|
|
202
|
+
{ length: Math.min(concurrency, tasks.length) },
|
|
203
|
+
async () => {
|
|
204
|
+
while (true) {
|
|
205
|
+
const idx = cursor++;
|
|
206
|
+
if (idx >= tasks.length) return;
|
|
207
|
+
results[idx] = await tasks[idx]();
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
);
|
|
211
|
+
await Promise.all(workers);
|
|
212
|
+
return results;
|
|
213
|
+
}
|