@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
|
@@ -0,0 +1,857 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
3
|
+
import { drizzle } from "drizzle-orm/bun-sqlite";
|
|
4
|
+
import type { GatewayConfig } from "../config.js";
|
|
5
|
+
import { SlackStore } from "../db/slack-store.js";
|
|
6
|
+
import * as schema from "../db/schema.js";
|
|
7
|
+
import type { NormalizedSlackEvent } from "../slack/normalize.js";
|
|
8
|
+
|
|
9
|
+
type FetchFn = (
|
|
10
|
+
input: string | URL | Request,
|
|
11
|
+
init?: RequestInit,
|
|
12
|
+
) => Promise<Response>;
|
|
13
|
+
|
|
14
|
+
let fetchMock: ReturnType<typeof mock<FetchFn>> = mock(async () => {
|
|
15
|
+
return new Response(JSON.stringify({ ok: true, messages: [] }), {
|
|
16
|
+
status: 200,
|
|
17
|
+
headers: { "content-type": "application/json" },
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
mock.module("../fetch.js", () => ({
|
|
22
|
+
fetchImpl: (...args: Parameters<FetchFn>) => fetchMock(...args),
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
const { SlackSocketModeClient } = await import("../slack/socket-mode.js");
|
|
26
|
+
const { clearUserInfoCache } = await import("../slack/normalize.js");
|
|
27
|
+
import type { SlackSocketModeConfig } from "../slack/socket-mode.js";
|
|
28
|
+
|
|
29
|
+
type CatchupHarness = {
|
|
30
|
+
config: SlackSocketModeConfig;
|
|
31
|
+
onEvent: (event: NormalizedSlackEvent) => void;
|
|
32
|
+
store: SlackStore;
|
|
33
|
+
ws: WebSocket | null;
|
|
34
|
+
handleMessage(raw: string, originWs: WebSocket): void;
|
|
35
|
+
replayMissedEvents(ownerWs: WebSocket): Promise<void>;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function makeConfig(): GatewayConfig {
|
|
39
|
+
return {
|
|
40
|
+
assistantRuntimeBaseUrl: "http://localhost:7821",
|
|
41
|
+
defaultAssistantId: "ast-default",
|
|
42
|
+
gatewayInternalBaseUrl: "http://127.0.0.1:7830",
|
|
43
|
+
logFile: { dir: undefined, retentionDays: 30 },
|
|
44
|
+
maxAttachmentBytes: {
|
|
45
|
+
telegram: 50 * 1024 * 1024,
|
|
46
|
+
slack: 100 * 1024 * 1024,
|
|
47
|
+
whatsapp: 16 * 1024 * 1024,
|
|
48
|
+
default: 50 * 1024 * 1024,
|
|
49
|
+
},
|
|
50
|
+
maxAttachmentConcurrency: 3,
|
|
51
|
+
maxWebhookPayloadBytes: 1024 * 1024,
|
|
52
|
+
port: 7830,
|
|
53
|
+
routingEntries: [
|
|
54
|
+
{
|
|
55
|
+
type: "conversation_id",
|
|
56
|
+
key: "CROUTED01",
|
|
57
|
+
assistantId: "ast-slack",
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
runtimeInitialBackoffMs: 500,
|
|
61
|
+
runtimeMaxRetries: 2,
|
|
62
|
+
runtimeProxyRequireAuth: false,
|
|
63
|
+
runtimeTimeoutMs: 30000,
|
|
64
|
+
shutdownDrainMs: 5000,
|
|
65
|
+
unmappedPolicy: "reject",
|
|
66
|
+
trustProxy: false,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function createSlackStore(): { rawDb: Database; store: SlackStore } {
|
|
71
|
+
const rawDb = new Database(":memory:");
|
|
72
|
+
rawDb.exec(`
|
|
73
|
+
CREATE TABLE slack_active_threads (
|
|
74
|
+
thread_ts TEXT PRIMARY KEY,
|
|
75
|
+
channel_id TEXT,
|
|
76
|
+
tracked_at INTEGER NOT NULL,
|
|
77
|
+
expires_at INTEGER NOT NULL
|
|
78
|
+
);
|
|
79
|
+
CREATE TABLE slack_seen_events (
|
|
80
|
+
event_id TEXT PRIMARY KEY,
|
|
81
|
+
seen_at INTEGER NOT NULL,
|
|
82
|
+
expires_at INTEGER NOT NULL
|
|
83
|
+
);
|
|
84
|
+
CREATE TABLE slack_last_seen_ts (
|
|
85
|
+
key TEXT PRIMARY KEY,
|
|
86
|
+
ts TEXT NOT NULL,
|
|
87
|
+
updated_at INTEGER NOT NULL
|
|
88
|
+
);
|
|
89
|
+
CREATE TABLE contact_channels (
|
|
90
|
+
id TEXT PRIMARY KEY,
|
|
91
|
+
contact_id TEXT NOT NULL,
|
|
92
|
+
type TEXT NOT NULL,
|
|
93
|
+
address TEXT NOT NULL,
|
|
94
|
+
is_primary INTEGER NOT NULL DEFAULT 0,
|
|
95
|
+
external_user_id TEXT,
|
|
96
|
+
external_chat_id TEXT,
|
|
97
|
+
status TEXT NOT NULL DEFAULT 'unverified',
|
|
98
|
+
policy TEXT NOT NULL DEFAULT 'allow',
|
|
99
|
+
revoked_reason TEXT,
|
|
100
|
+
blocked_reason TEXT,
|
|
101
|
+
last_seen_at INTEGER,
|
|
102
|
+
last_interaction INTEGER,
|
|
103
|
+
interaction_count INTEGER NOT NULL DEFAULT 0,
|
|
104
|
+
created_at INTEGER NOT NULL,
|
|
105
|
+
updated_at INTEGER NOT NULL
|
|
106
|
+
);
|
|
107
|
+
`);
|
|
108
|
+
return { rawDb, store: new SlackStore(drizzle(rawDb, { schema })) };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function createHarness(
|
|
112
|
+
store: SlackStore,
|
|
113
|
+
onEvent: (event: NormalizedSlackEvent) => void,
|
|
114
|
+
): CatchupHarness {
|
|
115
|
+
const harness = Object.create(
|
|
116
|
+
SlackSocketModeClient.prototype,
|
|
117
|
+
) as CatchupHarness;
|
|
118
|
+
harness.config = {
|
|
119
|
+
appToken: "xapp-test",
|
|
120
|
+
botToken: "xoxb-test",
|
|
121
|
+
botUserId: "UBOT",
|
|
122
|
+
botUsername: "assistant",
|
|
123
|
+
teamName: "Example Team",
|
|
124
|
+
gatewayConfig: makeConfig(),
|
|
125
|
+
};
|
|
126
|
+
harness.onEvent = onEvent;
|
|
127
|
+
harness.store = store;
|
|
128
|
+
harness.ws = null;
|
|
129
|
+
return harness;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function makeOpenSocket(): WebSocket {
|
|
133
|
+
return {
|
|
134
|
+
readyState: WebSocket.OPEN,
|
|
135
|
+
send: mock(() => {}),
|
|
136
|
+
} as unknown as WebSocket;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function flushAsyncEventEmission(): Promise<void> {
|
|
140
|
+
return new Promise((resolve) => setTimeout(resolve, 0));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function makeHistoryResponse(messages: unknown[]): Response {
|
|
144
|
+
return new Response(JSON.stringify({ ok: true, messages }), {
|
|
145
|
+
status: 200,
|
|
146
|
+
headers: { "content-type": "application/json" },
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
beforeEach(() => {
|
|
151
|
+
clearUserInfoCache();
|
|
152
|
+
fetchMock = mock(async () => makeHistoryResponse([]));
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe("compound dedup across live and replay paths", () => {
|
|
156
|
+
test("replay of an event whose live event_id was already seen is deduped", async () => {
|
|
157
|
+
const { rawDb, store } = createSlackStore();
|
|
158
|
+
const emitted: NormalizedSlackEvent[] = [];
|
|
159
|
+
const client = createHarness(store, (event) => emitted.push(event));
|
|
160
|
+
const ws = makeOpenSocket();
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
client.handleMessage(
|
|
164
|
+
JSON.stringify({
|
|
165
|
+
envelope_id: "env-live",
|
|
166
|
+
type: "events_api",
|
|
167
|
+
payload: {
|
|
168
|
+
event_id: "Ev-live",
|
|
169
|
+
event: {
|
|
170
|
+
type: "app_mention",
|
|
171
|
+
user: "U-author",
|
|
172
|
+
text: "<@UBOT> hi",
|
|
173
|
+
ts: "1700000000.000100",
|
|
174
|
+
channel: "CROUTED01",
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
}),
|
|
178
|
+
ws,
|
|
179
|
+
);
|
|
180
|
+
await flushAsyncEventEmission();
|
|
181
|
+
expect(emitted).toHaveLength(1);
|
|
182
|
+
|
|
183
|
+
// Same message arrives again via the replay path with a synthetic
|
|
184
|
+
// event_id but the same (channel, ts) — the compound key prevents
|
|
185
|
+
// a second emission.
|
|
186
|
+
client.handleMessage(
|
|
187
|
+
JSON.stringify({
|
|
188
|
+
envelope_id: "env-replay",
|
|
189
|
+
type: "events_api",
|
|
190
|
+
payload: {
|
|
191
|
+
event_id: "replay:CROUTED01:1700000000.000100",
|
|
192
|
+
event: {
|
|
193
|
+
type: "app_mention",
|
|
194
|
+
user: "U-author",
|
|
195
|
+
text: "<@UBOT> hi",
|
|
196
|
+
ts: "1700000000.000100",
|
|
197
|
+
channel: "CROUTED01",
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
}),
|
|
201
|
+
ws,
|
|
202
|
+
);
|
|
203
|
+
await flushAsyncEventEmission();
|
|
204
|
+
expect(emitted).toHaveLength(1);
|
|
205
|
+
} finally {
|
|
206
|
+
rawDb.close();
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("live event after replay of the same (channel, ts) is deduped", async () => {
|
|
211
|
+
const { rawDb, store } = createSlackStore();
|
|
212
|
+
const emitted: NormalizedSlackEvent[] = [];
|
|
213
|
+
const client = createHarness(store, (event) => emitted.push(event));
|
|
214
|
+
const ws = makeOpenSocket();
|
|
215
|
+
|
|
216
|
+
try {
|
|
217
|
+
// Replay arrives first.
|
|
218
|
+
client.handleMessage(
|
|
219
|
+
JSON.stringify({
|
|
220
|
+
envelope_id: "env-replay",
|
|
221
|
+
type: "events_api",
|
|
222
|
+
payload: {
|
|
223
|
+
event_id: "replay:CROUTED01:1700000000.000200",
|
|
224
|
+
event: {
|
|
225
|
+
type: "app_mention",
|
|
226
|
+
user: "U-author",
|
|
227
|
+
text: "<@UBOT> hi",
|
|
228
|
+
ts: "1700000000.000200",
|
|
229
|
+
channel: "CROUTED01",
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
}),
|
|
233
|
+
ws,
|
|
234
|
+
);
|
|
235
|
+
await flushAsyncEventEmission();
|
|
236
|
+
expect(emitted).toHaveLength(1);
|
|
237
|
+
|
|
238
|
+
// Now Slack delivers it live — should be deduped via the message key.
|
|
239
|
+
client.handleMessage(
|
|
240
|
+
JSON.stringify({
|
|
241
|
+
envelope_id: "env-live",
|
|
242
|
+
type: "events_api",
|
|
243
|
+
payload: {
|
|
244
|
+
event_id: "Ev-live-late",
|
|
245
|
+
event: {
|
|
246
|
+
type: "app_mention",
|
|
247
|
+
user: "U-author",
|
|
248
|
+
text: "<@UBOT> hi",
|
|
249
|
+
ts: "1700000000.000200",
|
|
250
|
+
channel: "CROUTED01",
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
}),
|
|
254
|
+
ws,
|
|
255
|
+
);
|
|
256
|
+
await flushAsyncEventEmission();
|
|
257
|
+
expect(emitted).toHaveLength(1);
|
|
258
|
+
} finally {
|
|
259
|
+
rawDb.close();
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
describe("catch-up watermark", () => {
|
|
265
|
+
test("watermark advances monotonically across accepted events", async () => {
|
|
266
|
+
const { rawDb, store } = createSlackStore();
|
|
267
|
+
const emitted: NormalizedSlackEvent[] = [];
|
|
268
|
+
const client = createHarness(store, (event) => emitted.push(event));
|
|
269
|
+
const ws = makeOpenSocket();
|
|
270
|
+
|
|
271
|
+
try {
|
|
272
|
+
client.handleMessage(
|
|
273
|
+
JSON.stringify({
|
|
274
|
+
envelope_id: "env-1",
|
|
275
|
+
type: "events_api",
|
|
276
|
+
payload: {
|
|
277
|
+
event_id: "Ev-1",
|
|
278
|
+
event: {
|
|
279
|
+
type: "app_mention",
|
|
280
|
+
user: "U-author",
|
|
281
|
+
text: "<@UBOT> first",
|
|
282
|
+
ts: "1700000010.000000",
|
|
283
|
+
channel: "CROUTED01",
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
}),
|
|
287
|
+
ws,
|
|
288
|
+
);
|
|
289
|
+
await flushAsyncEventEmission();
|
|
290
|
+
|
|
291
|
+
client.handleMessage(
|
|
292
|
+
JSON.stringify({
|
|
293
|
+
envelope_id: "env-2",
|
|
294
|
+
type: "events_api",
|
|
295
|
+
payload: {
|
|
296
|
+
event_id: "Ev-2",
|
|
297
|
+
event: {
|
|
298
|
+
type: "app_mention",
|
|
299
|
+
user: "U-author",
|
|
300
|
+
text: "<@UBOT> second",
|
|
301
|
+
ts: "1700000020.000000",
|
|
302
|
+
channel: "CROUTED01",
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
}),
|
|
306
|
+
ws,
|
|
307
|
+
);
|
|
308
|
+
await flushAsyncEventEmission();
|
|
309
|
+
|
|
310
|
+
expect(store.getLastSeenTs()).toBe("1700000020.000000");
|
|
311
|
+
|
|
312
|
+
// Out-of-order older event must not pull the watermark backwards.
|
|
313
|
+
client.handleMessage(
|
|
314
|
+
JSON.stringify({
|
|
315
|
+
envelope_id: "env-3",
|
|
316
|
+
type: "events_api",
|
|
317
|
+
payload: {
|
|
318
|
+
event_id: "Ev-3",
|
|
319
|
+
event: {
|
|
320
|
+
type: "app_mention",
|
|
321
|
+
user: "U-author",
|
|
322
|
+
text: "<@UBOT> earlier",
|
|
323
|
+
ts: "1700000005.000000",
|
|
324
|
+
channel: "CROUTED01",
|
|
325
|
+
},
|
|
326
|
+
},
|
|
327
|
+
}),
|
|
328
|
+
ws,
|
|
329
|
+
);
|
|
330
|
+
await flushAsyncEventEmission();
|
|
331
|
+
|
|
332
|
+
expect(store.getLastSeenTs()).toBe("1700000020.000000");
|
|
333
|
+
} finally {
|
|
334
|
+
rawDb.close();
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
describe("replayMissedEvents", () => {
|
|
340
|
+
test("bootstraps watermark to now and skips replay on first connect", async () => {
|
|
341
|
+
const { rawDb, store } = createSlackStore();
|
|
342
|
+
const emitted: NormalizedSlackEvent[] = [];
|
|
343
|
+
const client = createHarness(store, (event) => emitted.push(event));
|
|
344
|
+
const ws = makeOpenSocket();
|
|
345
|
+
client.ws = ws;
|
|
346
|
+
|
|
347
|
+
expect(store.getLastSeenTs()).toBeUndefined();
|
|
348
|
+
fetchMock = mock(async () => {
|
|
349
|
+
throw new Error("should not fetch on bootstrap");
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
try {
|
|
353
|
+
await client.replayMissedEvents(ws);
|
|
354
|
+
expect(store.getLastSeenTs()).toBeDefined();
|
|
355
|
+
expect(emitted).toHaveLength(0);
|
|
356
|
+
expect(fetchMock).toHaveBeenCalledTimes(0);
|
|
357
|
+
} finally {
|
|
358
|
+
rawDb.close();
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test("bootstraps watermark even if botUserId is not yet resolved", async () => {
|
|
363
|
+
// If `auth.test` errored transiently in `start()`, `botUserId` is
|
|
364
|
+
// undefined for the rest of that gateway lifetime (reconnects do not
|
|
365
|
+
// retry `auth.test`). Bootstrap must not be gated on identity, or the
|
|
366
|
+
// watermark stays unwritten across the entire degraded session and the
|
|
367
|
+
// next gateway restart bootstraps fresh against "now then" — silently
|
|
368
|
+
// widening the unrecoverable window past the original ws.open.
|
|
369
|
+
const { rawDb, store } = createSlackStore();
|
|
370
|
+
const emitted: NormalizedSlackEvent[] = [];
|
|
371
|
+
const client = createHarness(store, (event) => emitted.push(event));
|
|
372
|
+
client.config.botUserId = undefined;
|
|
373
|
+
const ws = makeOpenSocket();
|
|
374
|
+
client.ws = ws;
|
|
375
|
+
|
|
376
|
+
expect(store.getLastSeenTs()).toBeUndefined();
|
|
377
|
+
fetchMock = mock(async () => {
|
|
378
|
+
throw new Error("should not fetch when botUserId is unresolved");
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
try {
|
|
382
|
+
await client.replayMissedEvents(ws);
|
|
383
|
+
expect(store.getLastSeenTs()).toBeDefined();
|
|
384
|
+
expect(emitted).toHaveLength(0);
|
|
385
|
+
expect(fetchMock).toHaveBeenCalledTimes(0);
|
|
386
|
+
} finally {
|
|
387
|
+
rawDb.close();
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
test("returns without fetching when watermark is set but botUserId is unresolved", async () => {
|
|
392
|
+
const { rawDb, store } = createSlackStore();
|
|
393
|
+
const emitted: NormalizedSlackEvent[] = [];
|
|
394
|
+
const client = createHarness(store, (event) => emitted.push(event));
|
|
395
|
+
client.config.botUserId = undefined;
|
|
396
|
+
const ws = makeOpenSocket();
|
|
397
|
+
client.ws = ws;
|
|
398
|
+
|
|
399
|
+
store.setLastSeenTsIfGreater("1700000000.000000");
|
|
400
|
+
fetchMock = mock(async () => {
|
|
401
|
+
throw new Error("should not fetch when botUserId is unresolved");
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
try {
|
|
405
|
+
await client.replayMissedEvents(ws);
|
|
406
|
+
expect(store.getLastSeenTs()).toBe("1700000000.000000");
|
|
407
|
+
expect(emitted).toHaveLength(0);
|
|
408
|
+
expect(fetchMock).toHaveBeenCalledTimes(0);
|
|
409
|
+
} finally {
|
|
410
|
+
rawDb.close();
|
|
411
|
+
}
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test("bails out when this.ws !== ownerWs (stale generation)", async () => {
|
|
415
|
+
const { rawDb, store } = createSlackStore();
|
|
416
|
+
const emitted: NormalizedSlackEvent[] = [];
|
|
417
|
+
const client = createHarness(store, (event) => emitted.push(event));
|
|
418
|
+
const oldWs = makeOpenSocket();
|
|
419
|
+
const newWs = makeOpenSocket();
|
|
420
|
+
|
|
421
|
+
// Seed a watermark so we wouldn't bootstrap-skip.
|
|
422
|
+
store.setLastSeenTsIfGreater("1700000000.000000");
|
|
423
|
+
|
|
424
|
+
client.ws = newWs;
|
|
425
|
+
fetchMock = mock(async () => {
|
|
426
|
+
throw new Error("should not fetch from a stale generation");
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
try {
|
|
430
|
+
await client.replayMissedEvents(oldWs);
|
|
431
|
+
expect(emitted).toHaveLength(0);
|
|
432
|
+
expect(fetchMock).toHaveBeenCalledTimes(0);
|
|
433
|
+
} finally {
|
|
434
|
+
rawDb.close();
|
|
435
|
+
}
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
test("recovers a missed app_mention from a routed channel", async () => {
|
|
439
|
+
const { rawDb, store } = createSlackStore();
|
|
440
|
+
const emitted: NormalizedSlackEvent[] = [];
|
|
441
|
+
const client = createHarness(store, (event) => emitted.push(event));
|
|
442
|
+
const ws = makeOpenSocket();
|
|
443
|
+
client.ws = ws;
|
|
444
|
+
|
|
445
|
+
store.setLastSeenTsIfGreater("1700000000.000000");
|
|
446
|
+
|
|
447
|
+
fetchMock = mock(async (input) => {
|
|
448
|
+
const url = String(input);
|
|
449
|
+
if (url.includes("conversations.history")) {
|
|
450
|
+
return makeHistoryResponse([
|
|
451
|
+
{
|
|
452
|
+
type: "message",
|
|
453
|
+
user: "U-author",
|
|
454
|
+
text: "<@UBOT> recovered",
|
|
455
|
+
ts: "1700000050.000000",
|
|
456
|
+
},
|
|
457
|
+
]);
|
|
458
|
+
}
|
|
459
|
+
return makeHistoryResponse([]);
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
try {
|
|
463
|
+
await client.replayMissedEvents(ws);
|
|
464
|
+
await flushAsyncEventEmission();
|
|
465
|
+
|
|
466
|
+
expect(emitted).toHaveLength(1);
|
|
467
|
+
expect(emitted[0].event.source.updateId).toBe(
|
|
468
|
+
"replay:CROUTED01:1700000050.000000",
|
|
469
|
+
);
|
|
470
|
+
expect(emitted[0].event.message.content).toContain("recovered");
|
|
471
|
+
expect(store.getLastSeenTs()).toBe("1700000050.000000");
|
|
472
|
+
} finally {
|
|
473
|
+
rawDb.close();
|
|
474
|
+
}
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
test("tolerates conversations.history HTTP 429 without throwing", async () => {
|
|
478
|
+
const { rawDb, store } = createSlackStore();
|
|
479
|
+
const emitted: NormalizedSlackEvent[] = [];
|
|
480
|
+
const client = createHarness(store, (event) => emitted.push(event));
|
|
481
|
+
const ws = makeOpenSocket();
|
|
482
|
+
client.ws = ws;
|
|
483
|
+
|
|
484
|
+
store.setLastSeenTsIfGreater("1700000000.000000");
|
|
485
|
+
|
|
486
|
+
fetchMock = mock(async () => {
|
|
487
|
+
return new Response("", {
|
|
488
|
+
status: 429,
|
|
489
|
+
headers: { "retry-after": "1" },
|
|
490
|
+
});
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
try {
|
|
494
|
+
await client.replayMissedEvents(ws);
|
|
495
|
+
expect(emitted).toHaveLength(0);
|
|
496
|
+
} finally {
|
|
497
|
+
rawDb.close();
|
|
498
|
+
}
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
test("aborts remaining catch-up workers after a single 429", async () => {
|
|
502
|
+
const { rawDb, store } = createSlackStore();
|
|
503
|
+
const emitted: NormalizedSlackEvent[] = [];
|
|
504
|
+
const client = createHarness(store, (event) => emitted.push(event));
|
|
505
|
+
const ws = makeOpenSocket();
|
|
506
|
+
client.ws = ws;
|
|
507
|
+
|
|
508
|
+
store.setLastSeenTsIfGreater("1700000000.000000");
|
|
509
|
+
// Many active threads so the worker pool would otherwise issue many
|
|
510
|
+
// requests in parallel; once one 429s we expect the others to bail.
|
|
511
|
+
for (let i = 0; i < 20; i++) {
|
|
512
|
+
store.trackThread(
|
|
513
|
+
`170000000${i}.000000`,
|
|
514
|
+
`CTHREAD${i}`,
|
|
515
|
+
24 * 60 * 60 * 1_000,
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
let calls = 0;
|
|
520
|
+
fetchMock = mock(async () => {
|
|
521
|
+
calls++;
|
|
522
|
+
return new Response("", {
|
|
523
|
+
status: 429,
|
|
524
|
+
headers: { "retry-after": "1" },
|
|
525
|
+
});
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
try {
|
|
529
|
+
await client.replayMissedEvents(ws);
|
|
530
|
+
// The first 429 trips the abort flag; later workers see `aborted`
|
|
531
|
+
// and skip without firing additional requests. The exact number of
|
|
532
|
+
// pre-flight calls is bounded by the concurrency limit.
|
|
533
|
+
expect(calls).toBeLessThanOrEqual(4);
|
|
534
|
+
expect(emitted).toHaveLength(0);
|
|
535
|
+
} finally {
|
|
536
|
+
rawDb.close();
|
|
537
|
+
}
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
test("ignores routing entries whose key is not a Slack conversation ID", async () => {
|
|
541
|
+
const { rawDb, store } = createSlackStore();
|
|
542
|
+
const emitted: NormalizedSlackEvent[] = [];
|
|
543
|
+
const client = createHarness(store, (event) => emitted.push(event));
|
|
544
|
+
// Replace the default Slack-shaped routing key with a non-Slack one
|
|
545
|
+
// (Telegram chat IDs are numeric strings, e.g. "123456789").
|
|
546
|
+
client.config = {
|
|
547
|
+
...client.config,
|
|
548
|
+
gatewayConfig: {
|
|
549
|
+
...client.config.gatewayConfig,
|
|
550
|
+
routingEntries: [
|
|
551
|
+
{
|
|
552
|
+
type: "conversation_id",
|
|
553
|
+
key: "123456789",
|
|
554
|
+
assistantId: "ast-telegram",
|
|
555
|
+
},
|
|
556
|
+
],
|
|
557
|
+
},
|
|
558
|
+
};
|
|
559
|
+
const ws = makeOpenSocket();
|
|
560
|
+
client.ws = ws;
|
|
561
|
+
|
|
562
|
+
store.setLastSeenTsIfGreater("1700000000.000000");
|
|
563
|
+
|
|
564
|
+
const calls: string[] = [];
|
|
565
|
+
fetchMock = mock(async (input) => {
|
|
566
|
+
calls.push(String(input));
|
|
567
|
+
return makeHistoryResponse([]);
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
try {
|
|
571
|
+
await client.replayMissedEvents(ws);
|
|
572
|
+
expect(calls.some((u) => u.includes("channel=123456789"))).toBe(false);
|
|
573
|
+
} finally {
|
|
574
|
+
rawDb.close();
|
|
575
|
+
}
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
test("recovers a missed reply from an active thread via conversations.replies", async () => {
|
|
579
|
+
const { rawDb, store } = createSlackStore();
|
|
580
|
+
const emitted: NormalizedSlackEvent[] = [];
|
|
581
|
+
const client = createHarness(store, (event) => emitted.push(event));
|
|
582
|
+
const ws = makeOpenSocket();
|
|
583
|
+
client.ws = ws;
|
|
584
|
+
|
|
585
|
+
store.setLastSeenTsIfGreater("1700000000.000000");
|
|
586
|
+
store.trackThread("1700000000.000000", "CROUTED01", 24 * 60 * 60 * 1_000);
|
|
587
|
+
|
|
588
|
+
const calls: string[] = [];
|
|
589
|
+
fetchMock = mock(async (input) => {
|
|
590
|
+
const url = String(input);
|
|
591
|
+
calls.push(url);
|
|
592
|
+
if (url.includes("conversations.replies")) {
|
|
593
|
+
return makeHistoryResponse([
|
|
594
|
+
{
|
|
595
|
+
type: "message",
|
|
596
|
+
user: "U-reply",
|
|
597
|
+
text: "<@UBOT> reply caught up",
|
|
598
|
+
ts: "1700000060.000000",
|
|
599
|
+
thread_ts: "1700000000.000000",
|
|
600
|
+
},
|
|
601
|
+
]);
|
|
602
|
+
}
|
|
603
|
+
return makeHistoryResponse([]);
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
try {
|
|
607
|
+
await client.replayMissedEvents(ws);
|
|
608
|
+
await flushAsyncEventEmission();
|
|
609
|
+
expect(calls.some((u) => u.includes("conversations.replies"))).toBe(true);
|
|
610
|
+
expect(emitted).toHaveLength(1);
|
|
611
|
+
expect(emitted[0].event.source.updateId).toBe(
|
|
612
|
+
"replay:CROUTED01:1700000060.000000",
|
|
613
|
+
);
|
|
614
|
+
} finally {
|
|
615
|
+
rawDb.close();
|
|
616
|
+
}
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
test("replays a DM @-mention as type='message' so default-assistant fallback applies", async () => {
|
|
620
|
+
const { rawDb, store } = createSlackStore();
|
|
621
|
+
const emitted: NormalizedSlackEvent[] = [];
|
|
622
|
+
const client = createHarness(store, (event) => emitted.push(event));
|
|
623
|
+
const ws = makeOpenSocket();
|
|
624
|
+
client.ws = ws;
|
|
625
|
+
|
|
626
|
+
// Seed a known DM channel (D...) that is *not* in routingEntries —
|
|
627
|
+
// mirrors the live shape where DMs hit the default-assistant fallback.
|
|
628
|
+
const now = Date.now();
|
|
629
|
+
rawDb
|
|
630
|
+
.prepare(
|
|
631
|
+
`INSERT INTO contact_channels (id, contact_id, type, address, is_primary, external_chat_id, status, created_at, updated_at) VALUES (?, ?, 'slack', ?, 0, ?, 'verified', ?, ?)`,
|
|
632
|
+
)
|
|
633
|
+
.run("cc-dm", "contact-1", "DABCDEFGHI", "DABCDEFGHI", now, now);
|
|
634
|
+
store.setLastSeenTsIfGreater("1700000000.000000");
|
|
635
|
+
|
|
636
|
+
fetchMock = mock(async (input) => {
|
|
637
|
+
const url = String(input);
|
|
638
|
+
if (url.includes("conversations.history") && url.includes("DABCDEFGHI")) {
|
|
639
|
+
return makeHistoryResponse([
|
|
640
|
+
{
|
|
641
|
+
type: "message",
|
|
642
|
+
user: "U-friend",
|
|
643
|
+
text: "<@UBOT> hello in dm",
|
|
644
|
+
ts: "1700000060.000000",
|
|
645
|
+
},
|
|
646
|
+
]);
|
|
647
|
+
}
|
|
648
|
+
return makeHistoryResponse([]);
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
try {
|
|
652
|
+
await client.replayMissedEvents(ws);
|
|
653
|
+
await flushAsyncEventEmission();
|
|
654
|
+
expect(emitted).toHaveLength(1);
|
|
655
|
+
expect(emitted[0].routing.assistantId).toBe("ast-default");
|
|
656
|
+
expect(emitted[0].routing.routeSource).toBe("default");
|
|
657
|
+
// Synthetic event must use type:"message" so it routes through the
|
|
658
|
+
// DM normalize path (which has the default-assistant fallback) rather
|
|
659
|
+
// than the app_mention path (which doesn't).
|
|
660
|
+
const raw = emitted[0].event.raw as { type?: string };
|
|
661
|
+
expect(raw.type).toBe("message");
|
|
662
|
+
} finally {
|
|
663
|
+
rawDb.close();
|
|
664
|
+
}
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
test("skips the thread parent returned by conversations.replies", async () => {
|
|
668
|
+
const { rawDb, store } = createSlackStore();
|
|
669
|
+
const emitted: NormalizedSlackEvent[] = [];
|
|
670
|
+
const client = createHarness(store, (event) => emitted.push(event));
|
|
671
|
+
const ws = makeOpenSocket();
|
|
672
|
+
client.ws = ws;
|
|
673
|
+
|
|
674
|
+
store.setLastSeenTsIfGreater("1700000000.000000");
|
|
675
|
+
store.trackThread("1700000000.000000", "CROUTED01", 24 * 60 * 60 * 1_000);
|
|
676
|
+
|
|
677
|
+
fetchMock = mock(async (input) => {
|
|
678
|
+
const url = String(input);
|
|
679
|
+
if (url.includes("conversations.replies")) {
|
|
680
|
+
// conversations.replies always returns the parent first regardless
|
|
681
|
+
// of `oldest` — see https://api.slack.com/methods/conversations.replies.
|
|
682
|
+
return makeHistoryResponse([
|
|
683
|
+
{
|
|
684
|
+
type: "message",
|
|
685
|
+
user: "U-author",
|
|
686
|
+
text: "<@UBOT> original parent",
|
|
687
|
+
ts: "1700000000.000000",
|
|
688
|
+
thread_ts: "1700000000.000000",
|
|
689
|
+
},
|
|
690
|
+
{
|
|
691
|
+
type: "message",
|
|
692
|
+
user: "U-reply",
|
|
693
|
+
text: "<@UBOT> a real missed reply",
|
|
694
|
+
ts: "1700000090.000000",
|
|
695
|
+
thread_ts: "1700000000.000000",
|
|
696
|
+
},
|
|
697
|
+
]);
|
|
698
|
+
}
|
|
699
|
+
return makeHistoryResponse([]);
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
try {
|
|
703
|
+
await client.replayMissedEvents(ws);
|
|
704
|
+
await flushAsyncEventEmission();
|
|
705
|
+
expect(emitted).toHaveLength(1);
|
|
706
|
+
expect(emitted[0].event.source.updateId).toBe(
|
|
707
|
+
"replay:CROUTED01:1700000090.000000",
|
|
708
|
+
);
|
|
709
|
+
} finally {
|
|
710
|
+
rawDb.close();
|
|
711
|
+
}
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
test("emits replayed channel messages in chronological order regardless of API order", async () => {
|
|
715
|
+
const { rawDb, store } = createSlackStore();
|
|
716
|
+
const emitted: NormalizedSlackEvent[] = [];
|
|
717
|
+
const client = createHarness(store, (event) => emitted.push(event));
|
|
718
|
+
const ws = makeOpenSocket();
|
|
719
|
+
client.ws = ws;
|
|
720
|
+
|
|
721
|
+
store.setLastSeenTsIfGreater("1700000000.000000");
|
|
722
|
+
|
|
723
|
+
fetchMock = mock(async (input) => {
|
|
724
|
+
const url = String(input);
|
|
725
|
+
if (url.includes("conversations.history")) {
|
|
726
|
+
// Slack's conversations.history returns messages newest-first per
|
|
727
|
+
// https://api.slack.com/methods/conversations.history. The replay
|
|
728
|
+
// path must reorder them so the runtime sees the conversational
|
|
729
|
+
// sequence in the order the user sent it.
|
|
730
|
+
return makeHistoryResponse([
|
|
731
|
+
{
|
|
732
|
+
type: "message",
|
|
733
|
+
user: "U-author",
|
|
734
|
+
text: "<@UBOT> please respond",
|
|
735
|
+
ts: "1700000300.000000",
|
|
736
|
+
},
|
|
737
|
+
{
|
|
738
|
+
type: "message",
|
|
739
|
+
user: "U-author",
|
|
740
|
+
text: "<@UBOT> can you help?",
|
|
741
|
+
ts: "1700000200.000000",
|
|
742
|
+
},
|
|
743
|
+
{
|
|
744
|
+
type: "message",
|
|
745
|
+
user: "U-author",
|
|
746
|
+
text: "<@UBOT> hello",
|
|
747
|
+
ts: "1700000100.000000",
|
|
748
|
+
},
|
|
749
|
+
]);
|
|
750
|
+
}
|
|
751
|
+
return makeHistoryResponse([]);
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
try {
|
|
755
|
+
await client.replayMissedEvents(ws);
|
|
756
|
+
await flushAsyncEventEmission();
|
|
757
|
+
expect(emitted.map((e) => e.event.source.messageId)).toEqual([
|
|
758
|
+
"1700000100.000000",
|
|
759
|
+
"1700000200.000000",
|
|
760
|
+
"1700000300.000000",
|
|
761
|
+
]);
|
|
762
|
+
} finally {
|
|
763
|
+
rawDb.close();
|
|
764
|
+
}
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
test("preserves subtype, files, attachments, and blocks on replayed file_share messages", async () => {
|
|
768
|
+
const { rawDb, store } = createSlackStore();
|
|
769
|
+
const emitted: NormalizedSlackEvent[] = [];
|
|
770
|
+
const client = createHarness(store, (event) => emitted.push(event));
|
|
771
|
+
const ws = makeOpenSocket();
|
|
772
|
+
client.ws = ws;
|
|
773
|
+
|
|
774
|
+
store.setLastSeenTsIfGreater("1700000000.000000");
|
|
775
|
+
|
|
776
|
+
const files = [
|
|
777
|
+
{ id: "F123", name: "report.pdf", mimetype: "application/pdf" },
|
|
778
|
+
];
|
|
779
|
+
const attachments = [{ id: 1, fallback: "legacy attachment" }];
|
|
780
|
+
const blocks = [{ type: "rich_text", block_id: "b1" }];
|
|
781
|
+
|
|
782
|
+
fetchMock = mock(async (input) => {
|
|
783
|
+
if (String(input).includes("conversations.history")) {
|
|
784
|
+
return makeHistoryResponse([
|
|
785
|
+
{
|
|
786
|
+
type: "message",
|
|
787
|
+
subtype: "file_share",
|
|
788
|
+
user: "U-uploader",
|
|
789
|
+
text: "<@UBOT> here's the doc",
|
|
790
|
+
ts: "1700000050.000000",
|
|
791
|
+
files,
|
|
792
|
+
attachments,
|
|
793
|
+
blocks,
|
|
794
|
+
},
|
|
795
|
+
]);
|
|
796
|
+
}
|
|
797
|
+
return makeHistoryResponse([]);
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
try {
|
|
801
|
+
await client.replayMissedEvents(ws);
|
|
802
|
+
await flushAsyncEventEmission();
|
|
803
|
+
expect(emitted).toHaveLength(1);
|
|
804
|
+
const raw = emitted[0].event.raw as {
|
|
805
|
+
subtype?: string;
|
|
806
|
+
files?: unknown;
|
|
807
|
+
attachments?: unknown;
|
|
808
|
+
blocks?: unknown;
|
|
809
|
+
};
|
|
810
|
+
expect(raw.subtype).toBe("file_share");
|
|
811
|
+
expect(raw.files).toEqual(files);
|
|
812
|
+
expect(raw.attachments).toEqual(attachments);
|
|
813
|
+
expect(raw.blocks).toEqual(blocks);
|
|
814
|
+
} finally {
|
|
815
|
+
rawDb.close();
|
|
816
|
+
}
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
test("skips messages with no ts and the bot's own messages", async () => {
|
|
820
|
+
const { rawDb, store } = createSlackStore();
|
|
821
|
+
const emitted: NormalizedSlackEvent[] = [];
|
|
822
|
+
const client = createHarness(store, (event) => emitted.push(event));
|
|
823
|
+
const ws = makeOpenSocket();
|
|
824
|
+
client.ws = ws;
|
|
825
|
+
|
|
826
|
+
store.setLastSeenTsIfGreater("1700000000.000000");
|
|
827
|
+
|
|
828
|
+
fetchMock = mock(async (input) => {
|
|
829
|
+
if (String(input).includes("conversations.history")) {
|
|
830
|
+
return makeHistoryResponse([
|
|
831
|
+
{
|
|
832
|
+
type: "message",
|
|
833
|
+
user: "UBOT",
|
|
834
|
+
text: "from bot",
|
|
835
|
+
ts: "1700000070.000000",
|
|
836
|
+
},
|
|
837
|
+
{ type: "message", user: "U-author", text: "no ts here" },
|
|
838
|
+
{
|
|
839
|
+
type: "message",
|
|
840
|
+
subtype: "channel_join",
|
|
841
|
+
user: "U-x",
|
|
842
|
+
ts: "1700000080.000000",
|
|
843
|
+
},
|
|
844
|
+
]);
|
|
845
|
+
}
|
|
846
|
+
return makeHistoryResponse([]);
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
try {
|
|
850
|
+
await client.replayMissedEvents(ws);
|
|
851
|
+
await flushAsyncEventEmission();
|
|
852
|
+
expect(emitted).toHaveLength(0);
|
|
853
|
+
} finally {
|
|
854
|
+
rawDb.close();
|
|
855
|
+
}
|
|
856
|
+
});
|
|
857
|
+
});
|