@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/socket-mode.ts
CHANGED
|
@@ -1,7 +1,16 @@
|
|
|
1
|
+
import { buildSlackUserLabelMap } from "@vellumai/slack-text";
|
|
1
2
|
import { getLogger } from "../logger.js";
|
|
2
3
|
import { fetchImpl } from "../fetch.js";
|
|
3
4
|
import type { GatewayConfig } from "../config.js";
|
|
4
5
|
import { SlackStore } from "../db/slack-store.js";
|
|
6
|
+
import { isRejection, resolveAssistant } from "../routing/resolve-assistant.js";
|
|
7
|
+
import {
|
|
8
|
+
CatchupAbortSignal,
|
|
9
|
+
fetchChannelHistorySince,
|
|
10
|
+
fetchThreadRepliesSince,
|
|
11
|
+
runWithConcurrency,
|
|
12
|
+
type SlackHistoryMessage,
|
|
13
|
+
} from "./slack-web.js";
|
|
5
14
|
import {
|
|
6
15
|
normalizeSlackAppMention,
|
|
7
16
|
normalizeSlackDirectMessage,
|
|
@@ -30,6 +39,26 @@ const MAX_BACKOFF_MS = 30_000;
|
|
|
30
39
|
const DEDUP_TTL_MS = 24 * 60 * 60 * 1_000;
|
|
31
40
|
const DEDUP_CLEANUP_INTERVAL_MS = 60 * 60 * 1_000;
|
|
32
41
|
const ACTIVE_THREAD_TTL_MS = 24 * 60 * 60 * 1_000;
|
|
42
|
+
const USER_RESOLVE_TIMEOUT_MS = 3_000;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Reconnect catch-up bounds.
|
|
46
|
+
*
|
|
47
|
+
* `MAX_LOOKBACK_MS` caps how far back we'll ask Slack for missed messages.
|
|
48
|
+
* Sleeps longer than this fall back to the daemon's existing inbound-
|
|
49
|
+
* triggered backfill (JARVIS-643) once new live events resume.
|
|
50
|
+
*
|
|
51
|
+
* `SAFETY_OVERLAP_MS` widens the `oldest` window slightly past the
|
|
52
|
+
* persisted watermark so a non-mention event that advanced the watermark
|
|
53
|
+
* cannot silently mask an earlier missed mention. Resulting overlap is
|
|
54
|
+
* absorbed by the compound `msg:${channel}:${ts}` dedup key.
|
|
55
|
+
*
|
|
56
|
+
* `HISTORY_LIMIT` and `CONCURRENCY` bound API budget per reconnect.
|
|
57
|
+
*/
|
|
58
|
+
const CATCHUP_MAX_LOOKBACK_MS = 60 * 60 * 1_000;
|
|
59
|
+
const CATCHUP_SAFETY_OVERLAP_MS = 60 * 1_000;
|
|
60
|
+
const CATCHUP_HISTORY_LIMIT = 50;
|
|
61
|
+
const CATCHUP_CONCURRENCY = 4;
|
|
33
62
|
|
|
34
63
|
export type SlackSocketModeConfig = {
|
|
35
64
|
appToken: string;
|
|
@@ -61,6 +90,7 @@ export class SlackSocketModeClient {
|
|
|
61
90
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
62
91
|
private dedupCleanupTimer: ReturnType<typeof setInterval> | null = null;
|
|
63
92
|
private store: SlackStore;
|
|
93
|
+
private emitQueues: Map<string, Promise<void>> | undefined = new Map();
|
|
64
94
|
|
|
65
95
|
constructor(
|
|
66
96
|
config: SlackSocketModeConfig,
|
|
@@ -107,15 +137,7 @@ export class SlackSocketModeClient {
|
|
|
107
137
|
if (data.team) {
|
|
108
138
|
this.config.teamName = data.team;
|
|
109
139
|
}
|
|
110
|
-
|
|
111
|
-
const scopes = resp.headers.get("x-oauth-scopes") ?? "";
|
|
112
|
-
if (!scopes.split(",").some((s) => s.trim() === "files:read")) {
|
|
113
|
-
log.warn(
|
|
114
|
-
"Slack bot token is missing the 'files:read' scope — file/image " +
|
|
115
|
-
"attachments will not be downloaded. Add 'files:read' to your " +
|
|
116
|
-
"Slack app's Bot Token Scopes and reinstall the app.",
|
|
117
|
-
);
|
|
118
|
-
}
|
|
140
|
+
warnOnMissingSlackScopes(resp.headers.get("x-oauth-scopes") ?? "");
|
|
119
141
|
|
|
120
142
|
log.info(
|
|
121
143
|
{
|
|
@@ -247,10 +269,12 @@ export class SlackSocketModeClient {
|
|
|
247
269
|
}
|
|
248
270
|
|
|
249
271
|
/**
|
|
250
|
-
* Register a thread as active so future replies (without @mention) are
|
|
272
|
+
* Register a thread as active so future replies (without @mention) are
|
|
273
|
+
* forwarded. `channelId` is required so reconnect catch-up can scope a
|
|
274
|
+
* `conversations.replies` fetch to the right channel.
|
|
251
275
|
*/
|
|
252
|
-
trackThread(threadTs: string): void {
|
|
253
|
-
this.store.trackThread(threadTs, ACTIVE_THREAD_TTL_MS);
|
|
276
|
+
trackThread(threadTs: string, channelId: string): void {
|
|
277
|
+
this.store.trackThread(threadTs, channelId, ACTIVE_THREAD_TTL_MS);
|
|
254
278
|
}
|
|
255
279
|
|
|
256
280
|
/**
|
|
@@ -294,6 +318,13 @@ export class SlackSocketModeClient {
|
|
|
294
318
|
ws.addEventListener("open", () => {
|
|
295
319
|
log.info("Slack Socket Mode connected");
|
|
296
320
|
this.reconnectAttempt = 0;
|
|
321
|
+
// Recover messages that arrived during the reconnect gap (Slack
|
|
322
|
+
// does not buffer Socket Mode events during disconnects). Runs
|
|
323
|
+
// off the open handler so initial-start, normal reconnect, and
|
|
324
|
+
// sleep/wake force-reconnect all share the same recovery path.
|
|
325
|
+
// Errors are swallowed inside replayMissedEvents — a failed
|
|
326
|
+
// catch-up should never destabilize the live socket.
|
|
327
|
+
void this.replayMissedEvents(ws);
|
|
297
328
|
});
|
|
298
329
|
|
|
299
330
|
ws.addEventListener("message", (messageEvent) => {
|
|
@@ -364,6 +395,7 @@ export class SlackSocketModeClient {
|
|
|
364
395
|
type?: string;
|
|
365
396
|
payload?: {
|
|
366
397
|
event_id?: string;
|
|
398
|
+
event_time?: number;
|
|
367
399
|
event?:
|
|
368
400
|
| SlackAppMentionEvent
|
|
369
401
|
| SlackDirectMessageEvent
|
|
@@ -429,11 +461,40 @@ export class SlackSocketModeClient {
|
|
|
429
461
|
|
|
430
462
|
const eventPayload = envelope.payload;
|
|
431
463
|
if (!eventPayload?.event) return;
|
|
432
|
-
|
|
433
|
-
const event = eventPayload.event;
|
|
434
|
-
|
|
435
464
|
if (!eventPayload.event_id) return;
|
|
436
465
|
|
|
466
|
+
this.processEventPayload({
|
|
467
|
+
event_id: eventPayload.event_id,
|
|
468
|
+
event_time: eventPayload.event_time,
|
|
469
|
+
event: eventPayload.event,
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Filter, deduplicate, advance the watermark, and dispatch a single
|
|
475
|
+
* Slack event payload. Shared by the live Socket Mode path
|
|
476
|
+
* (`handleMessage`) and the reconnect catch-up path
|
|
477
|
+
* (`replayMissedEvents`) so both flows enforce identical filters,
|
|
478
|
+
* dedup, and ordering semantics.
|
|
479
|
+
*
|
|
480
|
+
* The `event_id` may be either a real Slack ID (live path) or a
|
|
481
|
+
* synthetic `replay:${channel}:${ts}` ID (replay path). Both flow
|
|
482
|
+
* through the same compound dedup table so the two paths never
|
|
483
|
+
* double-emit a message that arrived on both.
|
|
484
|
+
*/
|
|
485
|
+
private processEventPayload(eventPayload: {
|
|
486
|
+
event_id: string;
|
|
487
|
+
event_time?: number;
|
|
488
|
+
event:
|
|
489
|
+
| SlackAppMentionEvent
|
|
490
|
+
| SlackDirectMessageEvent
|
|
491
|
+
| SlackChannelMessageEvent
|
|
492
|
+
| SlackMessageChangedEvent
|
|
493
|
+
| SlackMessageDeletedEvent
|
|
494
|
+
| SlackReactionAddedEvent
|
|
495
|
+
| SlackReactionRemovedEvent;
|
|
496
|
+
}): void {
|
|
497
|
+
const event = eventPayload.event;
|
|
437
498
|
const dmEvent = event as SlackDirectMessageEvent;
|
|
438
499
|
const channelEvent = event as SlackChannelMessageEvent;
|
|
439
500
|
const messageChangedEvent = event as SlackMessageChangedEvent;
|
|
@@ -578,15 +639,78 @@ export class SlackSocketModeClient {
|
|
|
578
639
|
"Slack event accepted by filter",
|
|
579
640
|
);
|
|
580
641
|
|
|
581
|
-
//
|
|
642
|
+
// Compound dedup. Live events are keyed by Slack `event_id`; replay
|
|
643
|
+
// events are keyed by `replay:${channel}:${ts}`. Both also write a
|
|
644
|
+
// `msg:${channel}:${ts}` key when the event has a stable
|
|
645
|
+
// (channel, ts) identity, so a message that arrives via both paths
|
|
646
|
+
// is deduped on the second arrival regardless of which came first.
|
|
582
647
|
const eventId = eventPayload.event_id;
|
|
648
|
+
const messageKey = computeMessageDedupKey(event);
|
|
583
649
|
if (this.store.hasEvent(eventId)) {
|
|
584
650
|
log.debug({ eventId }, "Duplicate Slack event, skipping");
|
|
585
651
|
return;
|
|
586
652
|
}
|
|
653
|
+
if (messageKey && this.store.hasEvent(messageKey)) {
|
|
654
|
+
log.debug(
|
|
655
|
+
{ eventId, messageKey },
|
|
656
|
+
"Slack event already seen via paired path, skipping",
|
|
657
|
+
);
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
587
660
|
this.store.markEventSeen(eventId, DEDUP_TTL_MS);
|
|
661
|
+
if (messageKey) {
|
|
662
|
+
this.store.markEventSeen(messageKey, DEDUP_TTL_MS);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// Advance the catch-up watermark before dispatch.
|
|
666
|
+
//
|
|
667
|
+
// Trade-off: emit happens off the per-channel `emitQueues` chain, which
|
|
668
|
+
// is in-memory and not persisted. The cases worth thinking about are:
|
|
669
|
+
//
|
|
670
|
+
// - daemon wedged, gateway alive: the queue stalls but does not drop;
|
|
671
|
+
// it drains when the daemon recovers. No loss.
|
|
672
|
+
// - gateway crash with daemon healthy: messages on the wire that have
|
|
673
|
+
// not yet been dedup-written are lost in memory, but the next
|
|
674
|
+
// reconnect refetches them via the watermark + 60s overlap. No loss.
|
|
675
|
+
// - gateway crash AND daemon outage simultaneously: the in-memory
|
|
676
|
+
// queue evaporates AND this watermark write has already advanced
|
|
677
|
+
// past the unsent messages, so the next reconnect will not refetch
|
|
678
|
+
// them. Genuinely lost.
|
|
679
|
+
//
|
|
680
|
+
// We accept the third case because the alternatives all regress
|
|
681
|
+
// something else: advancing after successful emit makes a slow emit
|
|
682
|
+
// stall the watermark and trigger wasteful refetch loops on every
|
|
683
|
+
// reconnect during transient slowness, and a later message in the same
|
|
684
|
+
// queue can still leapfrog the failed earlier one, so it does not
|
|
685
|
+
// actually fix the silent-skip. A persistent emit outbox would cover
|
|
686
|
+
// it, but that is a larger feature. The compensating daemon-side
|
|
687
|
+
// reactive backfill (`triggerSlackThreadBackfillIfNeeded`) hydrates
|
|
688
|
+
// thread context as soon as any follow-up message arrives, narrowing
|
|
689
|
+
// the user-visible blast radius to "fully missed mention with no
|
|
690
|
+
// follow-up, during a simultaneous gateway crash + daemon outage".
|
|
691
|
+
const watermarkTs = extractEventWatermarkTs(event, eventPayload.event_time);
|
|
692
|
+
if (watermarkTs) {
|
|
693
|
+
this.store.setLastSeenTsIfGreater(watermarkTs);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
if (isAppMention) {
|
|
697
|
+
const appMentionEvent = event as SlackAppMentionEvent;
|
|
698
|
+
const threadTs = appMentionEvent.thread_ts ?? appMentionEvent.ts;
|
|
699
|
+
const routing = resolveAssistant(
|
|
700
|
+
this.config.gatewayConfig,
|
|
701
|
+
appMentionEvent.channel,
|
|
702
|
+
appMentionEvent.user,
|
|
703
|
+
);
|
|
704
|
+
if (threadTs && !isRejection(routing) && appMentionEvent.channel) {
|
|
705
|
+
this.store.trackThread(
|
|
706
|
+
threadTs,
|
|
707
|
+
appMentionEvent.channel,
|
|
708
|
+
ACTIVE_THREAD_TTL_MS,
|
|
709
|
+
);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
588
712
|
|
|
589
|
-
this.
|
|
713
|
+
this.enqueueNormalizeAndEmit(
|
|
590
714
|
event,
|
|
591
715
|
eventId,
|
|
592
716
|
isAppMention,
|
|
@@ -599,7 +723,50 @@ export class SlackSocketModeClient {
|
|
|
599
723
|
);
|
|
600
724
|
}
|
|
601
725
|
|
|
602
|
-
private
|
|
726
|
+
private extractTextBearingContent(
|
|
727
|
+
event:
|
|
728
|
+
| SlackAppMentionEvent
|
|
729
|
+
| SlackDirectMessageEvent
|
|
730
|
+
| SlackChannelMessageEvent
|
|
731
|
+
| SlackMessageChangedEvent
|
|
732
|
+
| SlackMessageDeletedEvent
|
|
733
|
+
| SlackReactionAddedEvent
|
|
734
|
+
| SlackReactionRemovedEvent,
|
|
735
|
+
): string | undefined {
|
|
736
|
+
if (
|
|
737
|
+
event.type === "message" &&
|
|
738
|
+
(event as SlackMessageChangedEvent).subtype === "message_changed"
|
|
739
|
+
) {
|
|
740
|
+
return (event as SlackMessageChangedEvent).message?.text;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
if (event.type === "app_mention" || event.type === "message") {
|
|
744
|
+
return (event as SlackAppMentionEvent | SlackDirectMessageEvent).text;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
return undefined;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
private async resolveMentionLabelsForText(
|
|
751
|
+
text: string,
|
|
752
|
+
): Promise<Record<string, string>> {
|
|
753
|
+
return buildSlackUserLabelMap(
|
|
754
|
+
[text],
|
|
755
|
+
async (id): Promise<string | undefined> => {
|
|
756
|
+
const userInfo = await Promise.race([
|
|
757
|
+
resolveSlackUser(id, this.config.botToken),
|
|
758
|
+
new Promise<undefined>((resolve) =>
|
|
759
|
+
setTimeout(resolve, USER_RESOLVE_TIMEOUT_MS),
|
|
760
|
+
),
|
|
761
|
+
]);
|
|
762
|
+
if (!userInfo) return undefined;
|
|
763
|
+
return userInfo.displayName || userInfo.username;
|
|
764
|
+
},
|
|
765
|
+
{ ignoredUserIds: [this.config.botUserId] },
|
|
766
|
+
);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
private enqueueNormalizeAndEmit(
|
|
603
770
|
event:
|
|
604
771
|
| SlackAppMentionEvent
|
|
605
772
|
| SlackDirectMessageEvent
|
|
@@ -617,6 +784,103 @@ export class SlackSocketModeClient {
|
|
|
617
784
|
isMessageDeleted: boolean,
|
|
618
785
|
isDm: boolean,
|
|
619
786
|
): void {
|
|
787
|
+
const queues = (this.emitQueues ??= new Map());
|
|
788
|
+
const orderingKey = this.getEventOrderingKey(event, eventId);
|
|
789
|
+
const previous = queues.get(orderingKey) ?? Promise.resolve();
|
|
790
|
+
const current = previous
|
|
791
|
+
.catch(() => undefined)
|
|
792
|
+
.then(() =>
|
|
793
|
+
this.normalizeAndEmit(
|
|
794
|
+
event,
|
|
795
|
+
eventId,
|
|
796
|
+
isAppMention,
|
|
797
|
+
isActiveThreadReply,
|
|
798
|
+
isReactionAdded,
|
|
799
|
+
isReactionRemoved,
|
|
800
|
+
isMessageChanged,
|
|
801
|
+
isMessageDeleted,
|
|
802
|
+
isDm,
|
|
803
|
+
),
|
|
804
|
+
);
|
|
805
|
+
|
|
806
|
+
queues.set(orderingKey, current);
|
|
807
|
+
void current
|
|
808
|
+
.catch((err: unknown) => {
|
|
809
|
+
log.error({ err, eventId }, "Slack event normalization failed");
|
|
810
|
+
})
|
|
811
|
+
.finally(() => {
|
|
812
|
+
if (queues.get(orderingKey) === current) {
|
|
813
|
+
queues.delete(orderingKey);
|
|
814
|
+
}
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
private getEventOrderingKey(
|
|
819
|
+
event:
|
|
820
|
+
| SlackAppMentionEvent
|
|
821
|
+
| SlackDirectMessageEvent
|
|
822
|
+
| SlackChannelMessageEvent
|
|
823
|
+
| SlackMessageChangedEvent
|
|
824
|
+
| SlackMessageDeletedEvent
|
|
825
|
+
| SlackReactionAddedEvent
|
|
826
|
+
| SlackReactionRemovedEvent,
|
|
827
|
+
eventId: string,
|
|
828
|
+
): string {
|
|
829
|
+
if (event.type === "reaction_added" || event.type === "reaction_removed") {
|
|
830
|
+
const reaction = event as
|
|
831
|
+
| SlackReactionAddedEvent
|
|
832
|
+
| SlackReactionRemovedEvent;
|
|
833
|
+
return `${reaction.item.channel}:${reaction.item.ts}`;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
if (
|
|
837
|
+
event.type === "message" &&
|
|
838
|
+
(event as SlackMessageChangedEvent).subtype === "message_changed"
|
|
839
|
+
) {
|
|
840
|
+
const changed = event as SlackMessageChangedEvent;
|
|
841
|
+
return `${changed.channel}:${changed.message.thread_ts ?? changed.message.ts ?? eventId}`;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
if (
|
|
845
|
+
event.type === "message" &&
|
|
846
|
+
(event as SlackMessageDeletedEvent).subtype === "message_deleted"
|
|
847
|
+
) {
|
|
848
|
+
const deleted = event as SlackMessageDeletedEvent;
|
|
849
|
+
return `${deleted.channel}:${deleted.previous_message?.thread_ts ?? deleted.deleted_ts ?? eventId}`;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
const message = event as
|
|
853
|
+
| SlackAppMentionEvent
|
|
854
|
+
| SlackDirectMessageEvent
|
|
855
|
+
| SlackChannelMessageEvent;
|
|
856
|
+
return `${message.channel}:${message.thread_ts ?? message.ts ?? eventId}`;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
private async normalizeAndEmit(
|
|
860
|
+
event:
|
|
861
|
+
| SlackAppMentionEvent
|
|
862
|
+
| SlackDirectMessageEvent
|
|
863
|
+
| SlackChannelMessageEvent
|
|
864
|
+
| SlackMessageChangedEvent
|
|
865
|
+
| SlackMessageDeletedEvent
|
|
866
|
+
| SlackReactionAddedEvent
|
|
867
|
+
| SlackReactionRemovedEvent,
|
|
868
|
+
eventId: string,
|
|
869
|
+
isAppMention: boolean,
|
|
870
|
+
isActiveThreadReply: boolean,
|
|
871
|
+
isReactionAdded: boolean,
|
|
872
|
+
isReactionRemoved: boolean,
|
|
873
|
+
isMessageChanged: boolean,
|
|
874
|
+
isMessageDeleted: boolean,
|
|
875
|
+
isDm: boolean,
|
|
876
|
+
): Promise<void> {
|
|
877
|
+
const text = this.extractTextBearingContent(event);
|
|
878
|
+
const userLabels = text ? await this.resolveMentionLabelsForText(text) : {};
|
|
879
|
+
const renderContext = {
|
|
880
|
+
botUserId: this.config.botUserId,
|
|
881
|
+
userLabels,
|
|
882
|
+
};
|
|
883
|
+
|
|
620
884
|
let normalized: NormalizedSlackEvent | null;
|
|
621
885
|
if (isReactionAdded) {
|
|
622
886
|
normalized = normalizeSlackReactionAdded(
|
|
@@ -637,7 +901,9 @@ export class SlackSocketModeClient {
|
|
|
637
901
|
event as SlackAppMentionEvent,
|
|
638
902
|
eventId,
|
|
639
903
|
this.config.gatewayConfig,
|
|
904
|
+
this.config.botUserId,
|
|
640
905
|
this.config.botToken,
|
|
906
|
+
renderContext,
|
|
641
907
|
);
|
|
642
908
|
} else if (isMessageChanged) {
|
|
643
909
|
normalized = normalizeSlackMessageEdit(
|
|
@@ -645,12 +911,14 @@ export class SlackSocketModeClient {
|
|
|
645
911
|
eventId,
|
|
646
912
|
this.config.gatewayConfig,
|
|
647
913
|
this.config.botUserId,
|
|
914
|
+
renderContext,
|
|
648
915
|
);
|
|
649
916
|
} else if (isMessageDeleted) {
|
|
650
917
|
normalized = normalizeSlackMessageDelete(
|
|
651
918
|
event as SlackMessageDeletedEvent,
|
|
652
919
|
eventId,
|
|
653
920
|
this.config.gatewayConfig,
|
|
921
|
+
this.config.botUserId,
|
|
654
922
|
);
|
|
655
923
|
} else if (isActiveThreadReply) {
|
|
656
924
|
normalized = normalizeSlackChannelMessage(
|
|
@@ -659,6 +927,7 @@ export class SlackSocketModeClient {
|
|
|
659
927
|
this.config.gatewayConfig,
|
|
660
928
|
this.config.botUserId,
|
|
661
929
|
this.config.botToken,
|
|
930
|
+
renderContext,
|
|
662
931
|
);
|
|
663
932
|
} else if (isDm) {
|
|
664
933
|
normalized = normalizeSlackDirectMessage(
|
|
@@ -667,6 +936,7 @@ export class SlackSocketModeClient {
|
|
|
667
936
|
this.config.gatewayConfig,
|
|
668
937
|
this.config.botUserId,
|
|
669
938
|
this.config.botToken,
|
|
939
|
+
renderContext,
|
|
670
940
|
);
|
|
671
941
|
} else {
|
|
672
942
|
log.warn(
|
|
@@ -692,12 +962,14 @@ export class SlackSocketModeClient {
|
|
|
692
962
|
return;
|
|
693
963
|
}
|
|
694
964
|
|
|
695
|
-
// Track threads
|
|
696
|
-
//
|
|
697
|
-
//
|
|
698
|
-
const threadTs =
|
|
699
|
-
|
|
700
|
-
|
|
965
|
+
// Track threads only for real participation signals so follow-up replies
|
|
966
|
+
// continue after app mentions and admitted messages, without reactions,
|
|
967
|
+
// edits, or deletes arming unrelated threads.
|
|
968
|
+
const threadTs = normalized.threadTs;
|
|
969
|
+
const channelId = normalized.event.message.conversationExternalId;
|
|
970
|
+
const shouldTrackActiveThread = isAppMention || isActiveThreadReply;
|
|
971
|
+
if (shouldTrackActiveThread && threadTs && channelId) {
|
|
972
|
+
this.store.trackThread(threadTs, channelId, ACTIVE_THREAD_TTL_MS);
|
|
701
973
|
}
|
|
702
974
|
|
|
703
975
|
// Enrich actor display name if the sync cache missed.
|
|
@@ -706,27 +978,250 @@ export class SlackSocketModeClient {
|
|
|
706
978
|
// ensures the event is always emitted even if the Slack API hangs.
|
|
707
979
|
const actor = normalized.event.actor;
|
|
708
980
|
if (actor?.actorExternalId && !actor.displayName) {
|
|
709
|
-
const
|
|
710
|
-
|
|
981
|
+
const mentionedLabel = userLabels[actor.actorExternalId];
|
|
982
|
+
if (mentionedLabel) {
|
|
983
|
+
actor.displayName = mentionedLabel;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
const userInfo = await Promise.race([
|
|
711
987
|
resolveSlackUser(actor.actorExternalId, this.config.botToken),
|
|
712
|
-
new Promise<undefined>((
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
})
|
|
721
|
-
.catch(() => {
|
|
722
|
-
this.onEvent(normalized!);
|
|
723
|
-
});
|
|
724
|
-
return;
|
|
988
|
+
new Promise<undefined>((resolve) =>
|
|
989
|
+
setTimeout(resolve, USER_RESOLVE_TIMEOUT_MS),
|
|
990
|
+
),
|
|
991
|
+
]);
|
|
992
|
+
if (userInfo) {
|
|
993
|
+
actor.displayName = userInfo.displayName;
|
|
994
|
+
actor.username = userInfo.username;
|
|
995
|
+
}
|
|
725
996
|
}
|
|
726
997
|
|
|
727
998
|
this.onEvent(normalized);
|
|
728
999
|
}
|
|
729
1000
|
|
|
1001
|
+
/**
|
|
1002
|
+
* Catch up on messages that arrived during the reconnect window.
|
|
1003
|
+
*
|
|
1004
|
+
* Slack does not buffer Socket Mode events for disconnected clients
|
|
1005
|
+
* (see https://api.slack.com/apis/socket-mode), so on reconnect we
|
|
1006
|
+
* fetch a bounded slice of `conversations.history` /
|
|
1007
|
+
* `conversations.replies` since the persisted watermark and feed any
|
|
1008
|
+
* recovered messages back through `processEventPayload`. Compound
|
|
1009
|
+
* dedup (`msg:${channel}:${ts}`) prevents double-emit if the same
|
|
1010
|
+
* message also arrives via the live socket.
|
|
1011
|
+
*
|
|
1012
|
+
* Scope:
|
|
1013
|
+
* - Routed channels (gateway routing entries)
|
|
1014
|
+
* - Active threads (`slack_active_threads`)
|
|
1015
|
+
* - Known DM channels (`contact_channels` rows of type `slack` with
|
|
1016
|
+
* a `D…` external chat id)
|
|
1017
|
+
*
|
|
1018
|
+
* Brand-new mentions in unrouted, never-engaged channels are not
|
|
1019
|
+
* recoverable here — the daemon's existing inbound-triggered backfill
|
|
1020
|
+
* (`triggerSlackThreadBackfillIfNeeded`, `tryBackfillSlackDmIfCold`)
|
|
1021
|
+
* will hydrate context once the next live event arrives.
|
|
1022
|
+
*/
|
|
1023
|
+
private async replayMissedEvents(ownerWs: WebSocket): Promise<void> {
|
|
1024
|
+
// Bail if a fresh forceReconnect has replaced the active socket
|
|
1025
|
+
// before the async work began. Without this gate, a stale generation
|
|
1026
|
+
// could fan out catch-up traffic that races with the new connection.
|
|
1027
|
+
if (this.ws !== ownerWs) return;
|
|
1028
|
+
|
|
1029
|
+
const botToken = this.config.botToken;
|
|
1030
|
+
if (!botToken) return;
|
|
1031
|
+
|
|
1032
|
+
// Bootstrap before the bot-identity check. The bot-identity check below
|
|
1033
|
+
// can keep returning early across reconnects if `auth.test` failed
|
|
1034
|
+
// transiently in `start()` and never retried — gating bootstrap on it
|
|
1035
|
+
// would leave the watermark unwritten for the entire degraded session,
|
|
1036
|
+
// and the eventual restart with a working `auth.test` would bootstrap
|
|
1037
|
+
// fresh against "now then" rather than "now at first ws.open", silently
|
|
1038
|
+
// widening the unrecoverable window. Bootstrap is identity-agnostic, so
|
|
1039
|
+
// run it first; the actual replay still requires `botUserId` and is
|
|
1040
|
+
// gated below.
|
|
1041
|
+
const persisted = this.store.getLastSeenTs();
|
|
1042
|
+
if (!persisted) {
|
|
1043
|
+
this.store.setLastSeenTsIfGreater(toSlackTs(Date.now()));
|
|
1044
|
+
log.info(
|
|
1045
|
+
"Slack catch-up: bootstrapped watermark, skipping initial replay",
|
|
1046
|
+
);
|
|
1047
|
+
return;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
const botUserId = this.config.botUserId;
|
|
1051
|
+
if (!botUserId) {
|
|
1052
|
+
log.debug("Skipping reconnect catch-up: bot user id not yet resolved");
|
|
1053
|
+
return;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
const minOldestMs = Date.now() - CATCHUP_MAX_LOOKBACK_MS;
|
|
1057
|
+
const persistedMs = Math.floor(Number(persisted) * 1_000);
|
|
1058
|
+
const overlapMs = Math.max(persistedMs - CATCHUP_SAFETY_OVERLAP_MS, 0);
|
|
1059
|
+
const oldestMs = Math.max(overlapMs, minOldestMs);
|
|
1060
|
+
const oldest = toSlackTs(oldestMs);
|
|
1061
|
+
|
|
1062
|
+
const routedChannels = new Set<string>();
|
|
1063
|
+
for (const entry of this.config.gatewayConfig.routingEntries) {
|
|
1064
|
+
// routingEntries is shared across channels (Slack, Telegram, WhatsApp,
|
|
1065
|
+
// …), so filter to keys that look like Slack conversation IDs. Slack
|
|
1066
|
+
// IDs always begin with C (public channel), D (DM/IM), or G (private
|
|
1067
|
+
// channel / multi-person IM) — see
|
|
1068
|
+
// https://api.slack.com/types/conversation.
|
|
1069
|
+
if (
|
|
1070
|
+
entry.type === "conversation_id" &&
|
|
1071
|
+
isSlackConversationId(entry.key)
|
|
1072
|
+
) {
|
|
1073
|
+
routedChannels.add(entry.key);
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
const dmChannels = this.store.listKnownSlackDmChannels();
|
|
1077
|
+
for (const channel of dmChannels) routedChannels.add(channel);
|
|
1078
|
+
|
|
1079
|
+
const activeThreads = this.store.listActiveThreadsWithChannel();
|
|
1080
|
+
|
|
1081
|
+
log.info(
|
|
1082
|
+
{
|
|
1083
|
+
oldest,
|
|
1084
|
+
channels: routedChannels.size,
|
|
1085
|
+
threads: activeThreads.length,
|
|
1086
|
+
},
|
|
1087
|
+
"Slack reconnect catch-up starting",
|
|
1088
|
+
);
|
|
1089
|
+
|
|
1090
|
+
let recovered = 0;
|
|
1091
|
+
const abort = new CatchupAbortSignal();
|
|
1092
|
+
|
|
1093
|
+
// Channel/DM history fan-out. We use conversations.history rather than
|
|
1094
|
+
// conversations.replies for top-level channels because we want
|
|
1095
|
+
// any unseen top-level message — replies in tracked threads are
|
|
1096
|
+
// covered separately below.
|
|
1097
|
+
const channelTasks = Array.from(routedChannels).map((channel) => {
|
|
1098
|
+
return async () => {
|
|
1099
|
+
if (this.ws !== ownerWs || abort.aborted) return;
|
|
1100
|
+
const result = await fetchChannelHistorySince({
|
|
1101
|
+
botToken,
|
|
1102
|
+
channel,
|
|
1103
|
+
oldest,
|
|
1104
|
+
limit: CATCHUP_HISTORY_LIMIT,
|
|
1105
|
+
abort,
|
|
1106
|
+
});
|
|
1107
|
+
if (this.ws !== ownerWs) return;
|
|
1108
|
+
for (const msg of sortMessagesAscendingByTs(result.messages)) {
|
|
1109
|
+
if (this.injectReplayMessage(channel, msg, botUserId)) recovered++;
|
|
1110
|
+
}
|
|
1111
|
+
};
|
|
1112
|
+
});
|
|
1113
|
+
|
|
1114
|
+
const threadTasks = activeThreads.map(({ channelId, threadTs }) => {
|
|
1115
|
+
return async () => {
|
|
1116
|
+
if (this.ws !== ownerWs || abort.aborted) return;
|
|
1117
|
+
const result = await fetchThreadRepliesSince({
|
|
1118
|
+
botToken,
|
|
1119
|
+
channel: channelId,
|
|
1120
|
+
threadTs,
|
|
1121
|
+
oldest,
|
|
1122
|
+
limit: CATCHUP_HISTORY_LIMIT,
|
|
1123
|
+
abort,
|
|
1124
|
+
});
|
|
1125
|
+
if (this.ws !== ownerWs) return;
|
|
1126
|
+
for (const msg of sortMessagesAscendingByTs(result.messages)) {
|
|
1127
|
+
// conversations.replies always returns the thread parent as the
|
|
1128
|
+
// first element regardless of `oldest` / `inclusive` — see
|
|
1129
|
+
// https://api.slack.com/methods/conversations.replies. The parent
|
|
1130
|
+
// was already processed when the thread was first tracked; replay
|
|
1131
|
+
// is for catching up on missed *replies*. Compound dedup would
|
|
1132
|
+
// catch a same-day re-emission, but for long-lived active threads
|
|
1133
|
+
// (TTL refreshed past the dedup window) the dedup row could have
|
|
1134
|
+
// expired, so filter explicitly.
|
|
1135
|
+
if (msg.ts === threadTs) continue;
|
|
1136
|
+
if (this.injectReplayMessage(channelId, msg, botUserId)) recovered++;
|
|
1137
|
+
}
|
|
1138
|
+
};
|
|
1139
|
+
});
|
|
1140
|
+
|
|
1141
|
+
try {
|
|
1142
|
+
await runWithConcurrency(
|
|
1143
|
+
[...channelTasks, ...threadTasks],
|
|
1144
|
+
CATCHUP_CONCURRENCY,
|
|
1145
|
+
);
|
|
1146
|
+
} catch (err) {
|
|
1147
|
+
log.warn({ err }, "Slack reconnect catch-up encountered an error");
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
log.info({ recovered, oldest }, "Slack reconnect catch-up complete");
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
/**
|
|
1154
|
+
* Build a synthetic events_api envelope for a recovered message and
|
|
1155
|
+
* dispatch it through the shared `processEventPayload` path. Returns
|
|
1156
|
+
* true if the message was passed through to processing (subject to
|
|
1157
|
+
* filter/dedup), false if it was skipped at this stage (no `ts`,
|
|
1158
|
+
* bot's own message, or other shape that the live filter would also
|
|
1159
|
+
* drop).
|
|
1160
|
+
*/
|
|
1161
|
+
private injectReplayMessage(
|
|
1162
|
+
channel: string,
|
|
1163
|
+
msg: SlackHistoryMessage,
|
|
1164
|
+
botUserId: string,
|
|
1165
|
+
): boolean {
|
|
1166
|
+
if (!msg.ts) return false;
|
|
1167
|
+
|
|
1168
|
+
// Skip the bot's own outbound messages and edits/deletes — the live
|
|
1169
|
+
// filter would already drop these and replaying them risks loops.
|
|
1170
|
+
if (msg.user === botUserId) return false;
|
|
1171
|
+
if (msg.bot_id) return false;
|
|
1172
|
+
if (
|
|
1173
|
+
msg.subtype &&
|
|
1174
|
+
msg.subtype !== "thread_broadcast" &&
|
|
1175
|
+
msg.subtype !== "file_share"
|
|
1176
|
+
) {
|
|
1177
|
+
return false;
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
const mentionsBot = msg.text?.includes(`<@${botUserId}>`) ?? false;
|
|
1181
|
+
const isDm = channel.startsWith("D");
|
|
1182
|
+
// DMs are always delivered as `type: "message"` with `channel_type: "im"`
|
|
1183
|
+
// by live Slack, even when the bot is `<@U…>`-mentioned in the body —
|
|
1184
|
+
// Slack only emits `app_mention` for non-DM channels. Synthesizing a DM
|
|
1185
|
+
// as `app_mention` would route through `normalizeSlackAppMention`, which
|
|
1186
|
+
// (intentionally) lacks the DM default-assistant fallback that
|
|
1187
|
+
// `normalizeSlackDirectMessage` provides, so an unrouted DM @-mention
|
|
1188
|
+
// would silently drop in `unmappedPolicy: "reject"` deployments.
|
|
1189
|
+
const eventType: "app_mention" | "message" =
|
|
1190
|
+
mentionsBot && !isDm ? "app_mention" : "message";
|
|
1191
|
+
|
|
1192
|
+
// Pass through `subtype`, `files`, `attachments`, and `blocks` so the
|
|
1193
|
+
// synthetic event has the same shape as a live Slack event for the
|
|
1194
|
+
// same message. Without this, recovered `file_share` messages would be
|
|
1195
|
+
// emitted as text-only and downstream attachment handling would diverge
|
|
1196
|
+
// between the live and replay paths. See
|
|
1197
|
+
// https://api.slack.com/events/message and
|
|
1198
|
+
// https://api.slack.com/events/app_mention for the live event shape.
|
|
1199
|
+
const syntheticEvent = {
|
|
1200
|
+
type: eventType,
|
|
1201
|
+
user: msg.user ?? "",
|
|
1202
|
+
text: msg.text ?? "",
|
|
1203
|
+
ts: msg.ts,
|
|
1204
|
+
thread_ts: msg.thread_ts,
|
|
1205
|
+
channel,
|
|
1206
|
+
channel_type: isDm ? "im" : "channel",
|
|
1207
|
+
team: msg.team,
|
|
1208
|
+
...(msg.subtype ? { subtype: msg.subtype } : {}),
|
|
1209
|
+
...(msg.files ? { files: msg.files } : {}),
|
|
1210
|
+
...(msg.attachments ? { attachments: msg.attachments } : {}),
|
|
1211
|
+
...(msg.blocks ? { blocks: msg.blocks } : {}),
|
|
1212
|
+
} as unknown as
|
|
1213
|
+
| SlackAppMentionEvent
|
|
1214
|
+
| SlackDirectMessageEvent
|
|
1215
|
+
| SlackChannelMessageEvent;
|
|
1216
|
+
|
|
1217
|
+
this.processEventPayload({
|
|
1218
|
+
event_id: `replay:${channel}:${msg.ts}`,
|
|
1219
|
+
event_time: Math.floor(Number(msg.ts)) || undefined,
|
|
1220
|
+
event: syntheticEvent,
|
|
1221
|
+
});
|
|
1222
|
+
return true;
|
|
1223
|
+
}
|
|
1224
|
+
|
|
730
1225
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
731
1226
|
private handleInteractive(payload: Record<string, any> | undefined): void {
|
|
732
1227
|
if (!payload) return;
|
|
@@ -793,6 +1288,173 @@ export class SlackSocketModeClient {
|
|
|
793
1288
|
}
|
|
794
1289
|
}
|
|
795
1290
|
|
|
1291
|
+
/**
|
|
1292
|
+
* Compute a stable `msg:${channel}:${ts}` dedup key for events that carry
|
|
1293
|
+
* a (channel, ts) identity. Used so the live and reconnect-replay paths
|
|
1294
|
+
* dedup symmetrically — a message that arrives via both paths is rejected
|
|
1295
|
+
* on the second arrival regardless of which came first.
|
|
1296
|
+
*
|
|
1297
|
+
* Returns undefined for events without a stable message identity (e.g.
|
|
1298
|
+
* `message_changed`, `message_deleted`, reactions). Those rely on their
|
|
1299
|
+
* Slack `event_id` for dedup; replay never synthesizes them.
|
|
1300
|
+
*/
|
|
1301
|
+
function computeMessageDedupKey(event: {
|
|
1302
|
+
type?: string;
|
|
1303
|
+
subtype?: string;
|
|
1304
|
+
channel?: string;
|
|
1305
|
+
ts?: string;
|
|
1306
|
+
}): string | undefined {
|
|
1307
|
+
// Restrict to top-level message-shaped events. Edits/deletes carry a
|
|
1308
|
+
// separate `previous_message`/`message` payload and don't need this key
|
|
1309
|
+
// because the replay path doesn't synthesize them.
|
|
1310
|
+
if (event.type !== "message" && event.type !== "app_mention") {
|
|
1311
|
+
return undefined;
|
|
1312
|
+
}
|
|
1313
|
+
if (
|
|
1314
|
+
event.subtype === "message_changed" ||
|
|
1315
|
+
event.subtype === "message_deleted"
|
|
1316
|
+
) {
|
|
1317
|
+
return undefined;
|
|
1318
|
+
}
|
|
1319
|
+
if (!event.channel || !event.ts) return undefined;
|
|
1320
|
+
return `msg:${event.channel}:${event.ts}`;
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
/**
|
|
1324
|
+
* Extract the watermark timestamp for an event. Prefers the message ts,
|
|
1325
|
+
* falling back to envelope `event_time` for events that don't carry their
|
|
1326
|
+
* own ts (reactions). Returns a Slack-format `<seconds>.<micros>` string
|
|
1327
|
+
* or undefined when no usable timestamp is present.
|
|
1328
|
+
*/
|
|
1329
|
+
function extractEventWatermarkTs(
|
|
1330
|
+
event: {
|
|
1331
|
+
ts?: string;
|
|
1332
|
+
item?: { ts?: string };
|
|
1333
|
+
deleted_ts?: string;
|
|
1334
|
+
message?: { ts?: string };
|
|
1335
|
+
},
|
|
1336
|
+
envelopeEventTime: number | undefined,
|
|
1337
|
+
): string | undefined {
|
|
1338
|
+
if (event.ts) return event.ts;
|
|
1339
|
+
if (event.message?.ts) return event.message.ts;
|
|
1340
|
+
if (event.deleted_ts) return event.deleted_ts;
|
|
1341
|
+
if (event.item?.ts) return event.item.ts;
|
|
1342
|
+
if (envelopeEventTime) return `${envelopeEventTime}.000000`;
|
|
1343
|
+
return undefined;
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
/** Convert millisecond epoch to a Slack `<seconds>.<micros>` timestamp string. */
|
|
1347
|
+
function toSlackTs(ms: number): string {
|
|
1348
|
+
const secs = Math.floor(ms / 1_000);
|
|
1349
|
+
const micros = Math.floor((ms % 1_000) * 1_000);
|
|
1350
|
+
return `${secs}.${String(micros).padStart(6, "0")}`;
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
/**
|
|
1354
|
+
* True if `id` looks like a Slack conversation ID. Slack IDs are 9–11
|
|
1355
|
+
* uppercase-alphanumeric characters prefixed with `C` (public channel),
|
|
1356
|
+
* `D` (direct message / IM), or `G` (private channel / multi-person IM).
|
|
1357
|
+
* See https://api.slack.com/types/conversation.
|
|
1358
|
+
*/
|
|
1359
|
+
function isSlackConversationId(id: string): boolean {
|
|
1360
|
+
return /^[CDG][A-Z0-9]+$/.test(id);
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
/**
|
|
1364
|
+
* Result of inspecting a bot-token scope header. Exposed so callers can
|
|
1365
|
+
* decide how to surface missing scopes (logging, telemetry, both) without
|
|
1366
|
+
* coupling the inspection logic to a specific logger.
|
|
1367
|
+
*/
|
|
1368
|
+
export interface SlackScopeCheckResult {
|
|
1369
|
+
filesReadMissing: boolean;
|
|
1370
|
+
missingHistoryScopes: string[];
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
/**
|
|
1374
|
+
* Inspect a bot-token scope header and return which optional scopes are
|
|
1375
|
+
* absent. Pure / no side effects — exists alongside
|
|
1376
|
+
* `warnOnMissingSlackScopes` so it can be unit-tested without observing
|
|
1377
|
+
* logger output.
|
|
1378
|
+
*
|
|
1379
|
+
* - `files:read` — required for downloading file/image attachments.
|
|
1380
|
+
* - `*:history` (channels/im/groups/mpim) — required for
|
|
1381
|
+
* `conversations.history` and `conversations.replies`. Slack returns
|
|
1382
|
+
* `ok: false, error: "missing_scope"` per channel type that is missing
|
|
1383
|
+
* the corresponding scope (see
|
|
1384
|
+
* https://api.slack.com/methods/conversations.history), and the
|
|
1385
|
+
* catch-up error handler treats that as zero messages.
|
|
1386
|
+
*/
|
|
1387
|
+
export function inspectSlackScopes(
|
|
1388
|
+
scopesHeader: string,
|
|
1389
|
+
): SlackScopeCheckResult {
|
|
1390
|
+
const scopes = new Set(
|
|
1391
|
+
scopesHeader
|
|
1392
|
+
.split(",")
|
|
1393
|
+
.map((s) => s.trim())
|
|
1394
|
+
.filter(Boolean),
|
|
1395
|
+
);
|
|
1396
|
+
return {
|
|
1397
|
+
filesReadMissing: !scopes.has("files:read"),
|
|
1398
|
+
missingHistoryScopes: [
|
|
1399
|
+
"channels:history",
|
|
1400
|
+
"im:history",
|
|
1401
|
+
"groups:history",
|
|
1402
|
+
"mpim:history",
|
|
1403
|
+
].filter((scope) => !scopes.has(scope)),
|
|
1404
|
+
};
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
/**
|
|
1408
|
+
* Emit warnings for any bot-token scopes whose absence makes the gateway
|
|
1409
|
+
* silently degrade rather than fail loudly. Without this startup check the
|
|
1410
|
+
* user sees a successful boot followed by quiet "recovered: 0" log lines on
|
|
1411
|
+
* every reconnect, with no signal that catch-up is no-op'ing on
|
|
1412
|
+
* `missing_scope`.
|
|
1413
|
+
*/
|
|
1414
|
+
export function warnOnMissingSlackScopes(scopesHeader: string): void {
|
|
1415
|
+
const { filesReadMissing, missingHistoryScopes } =
|
|
1416
|
+
inspectSlackScopes(scopesHeader);
|
|
1417
|
+
if (filesReadMissing) {
|
|
1418
|
+
log.warn(
|
|
1419
|
+
"Slack bot token is missing the 'files:read' scope — file/image " +
|
|
1420
|
+
"attachments will not be downloaded. Add 'files:read' to your " +
|
|
1421
|
+
"Slack app's Bot Token Scopes and reinstall the app.",
|
|
1422
|
+
);
|
|
1423
|
+
}
|
|
1424
|
+
if (missingHistoryScopes.length > 0) {
|
|
1425
|
+
log.warn(
|
|
1426
|
+
{ missingHistoryScopes },
|
|
1427
|
+
"Slack bot token is missing one or more *:history scopes — " +
|
|
1428
|
+
"reconnect catch-up will not recover messages from the affected " +
|
|
1429
|
+
"channel types. Add the missing scopes to your Slack app's Bot " +
|
|
1430
|
+
"Token Scopes and reinstall the app.",
|
|
1431
|
+
);
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
/**
|
|
1436
|
+
* Sort Slack messages by `ts` ascending so they replay through the
|
|
1437
|
+
* per-channel emit queue in chronological order. `conversations.history`
|
|
1438
|
+
* returns messages newest-first
|
|
1439
|
+
* (https://api.slack.com/methods/conversations.history) and
|
|
1440
|
+
* `conversations.replies` makes no strict ordering guarantee beyond
|
|
1441
|
+
* "parent first", so we sort defensively rather than rely on either API's
|
|
1442
|
+
* order. Without this, a flurry of missed messages emits in reverse
|
|
1443
|
+
* order — the runtime sees the latest user message before the earlier
|
|
1444
|
+
* ones it depends on. Messages without a `ts` are dropped by
|
|
1445
|
+
* `injectReplayMessage` anyway; sort them last so they don't perturb
|
|
1446
|
+
* the order of the rest.
|
|
1447
|
+
*/
|
|
1448
|
+
function sortMessagesAscendingByTs<T extends { ts?: string }>(
|
|
1449
|
+
messages: readonly T[],
|
|
1450
|
+
): T[] {
|
|
1451
|
+
return [...messages].sort((a, b) => {
|
|
1452
|
+
const aTs = a.ts ? Number(a.ts) : Number.POSITIVE_INFINITY;
|
|
1453
|
+
const bTs = b.ts ? Number(b.ts) : Number.POSITIVE_INFINITY;
|
|
1454
|
+
return aTs - bTs;
|
|
1455
|
+
});
|
|
1456
|
+
}
|
|
1457
|
+
|
|
796
1458
|
/**
|
|
797
1459
|
* Factory function for creating a Slack Socket Mode client.
|
|
798
1460
|
*/
|