@interactive-inc/flume 0.3.0 → 0.6.0
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/README.md +165 -108
- package/dist/connection-error-HUO3PC3G.js +16 -0
- package/dist/discord.d.ts +10 -93
- package/dist/discord.js +446 -217
- package/dist/flume-source-DUvt9aJt.js +341 -0
- package/dist/flume-source-DuUFPhSe.d.ts +228 -0
- package/dist/github.d.ts +7 -54
- package/dist/github.js +354 -158
- package/dist/{http-error-BtXonO-W.js → http-error-CPSKoSie.js} +1 -1
- package/dist/index.d.ts +79 -49
- package/dist/index.js +304 -62
- package/dist/safe-json-parse-CfJjt-RY.js +11 -0
- package/dist/safe-read-text-JQd_5vbd.js +20 -0
- package/dist/safe-stringify-DbWQw9qe.js +157 -0
- package/dist/slack.d.ts +10 -70
- package/dist/slack.js +352 -173
- package/package.json +22 -18
- package/dist/connection-error-BOk97djj.d.ts +0 -6
- package/dist/http-error-K-Ym4lfK.d.ts +0 -11
- package/dist/logger-CpGB9WO_.js +0 -49
- package/dist/parse-error-BAiCLRmk.d.ts +0 -6
- package/dist/reconnector-BDoJ1xNX.js +0 -67
- package/dist/safe-fetch-30ZzOKHL.js +0 -20
- package/dist/safe-json-parse-BWlzGOLl.js +0 -41
- package/dist/serial-queue-ExmlnpzQ.js +0 -16
- package/dist/types-Bm9uKUQz.d.ts +0 -143
package/dist/slack.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { t as FlumeHttpError } from "./http-error-
|
|
4
|
-
import { t as
|
|
5
|
-
import {
|
|
6
|
-
import { t as
|
|
1
|
+
import { a as FlumeStartError, c as safeNormalizeError, i as safeNow, l as safeErrorMessage, o as FlumeParseError, r as FlumeLogger, s as attempt, t as FlumeSource } from "./flume-source-DUvt9aJt.js";
|
|
2
|
+
import { t as FlumeConnectionError } from "./connection-error-HUO3PC3G.js";
|
|
3
|
+
import { t as FlumeHttpError } from "./http-error-CPSKoSie.js";
|
|
4
|
+
import { i as FlumeReconnector, n as isRecord, r as scheduleFlumeReconnect, t as safeStringify } from "./safe-stringify-DbWQw9qe.js";
|
|
5
|
+
import { t as safeJsonParse } from "./safe-json-parse-CfJjt-RY.js";
|
|
6
|
+
import { t as safeReadText } from "./safe-read-text-JQd_5vbd.js";
|
|
7
7
|
import { z } from "zod/v4";
|
|
8
8
|
//#region lib/slack/extract-slack-meta.ts
|
|
9
|
-
function
|
|
9
|
+
function flumeExtractSlackMeta(envelope) {
|
|
10
10
|
const meta = { event_type: envelope.type };
|
|
11
11
|
const eventPayload = isRecord(envelope.payload.event) ? envelope.payload.event : null;
|
|
12
12
|
if (!eventPayload) return meta;
|
|
@@ -19,41 +19,40 @@ function extractSlackMeta(envelope) {
|
|
|
19
19
|
//#endregion
|
|
20
20
|
//#region lib/slack/slack-seen-cache.ts
|
|
21
21
|
/**
|
|
22
|
-
* Slack envelope_id
|
|
23
|
-
* source
|
|
22
|
+
* Slack envelope_id を maxSize / ttlMs で制限するキャッシュ。Slack は ack 失敗時に同じ envelope を
|
|
23
|
+
* 再送してくるため、source 層で重複配送を防ぐ。TTL を過ぎたエントリは has() が false を返し、
|
|
24
|
+
* trim() で容量超過分が古い順に落とされる
|
|
24
25
|
*/
|
|
25
26
|
var FlumeSlackSeenCache = class {
|
|
26
27
|
props;
|
|
27
|
-
seen = /* @__PURE__ */ new
|
|
28
|
+
seen = /* @__PURE__ */ new Map();
|
|
28
29
|
constructor(props) {
|
|
29
30
|
this.props = props;
|
|
30
31
|
}
|
|
31
32
|
has(envelopeId) {
|
|
32
|
-
|
|
33
|
+
const timestamp = this.seen.get(envelopeId);
|
|
34
|
+
if (timestamp === void 0) return false;
|
|
35
|
+
if (safeNow({ deps: this.props.deps }) - timestamp > this.props.ttlMs) {
|
|
36
|
+
this.seen.delete(envelopeId);
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
return true;
|
|
33
40
|
}
|
|
34
41
|
add(envelopeId) {
|
|
35
|
-
this.seen.
|
|
42
|
+
this.seen.set(envelopeId, safeNow({ deps: this.props.deps }));
|
|
36
43
|
}
|
|
37
44
|
trim() {
|
|
45
|
+
const cutoff = safeNow({ deps: this.props.deps }) - this.props.ttlMs;
|
|
46
|
+
for (const [id, timestamp] of this.seen) if (timestamp < cutoff) this.seen.delete(id);
|
|
38
47
|
if (this.seen.size <= this.props.maxSize) return;
|
|
39
|
-
const entries = [...this.seen];
|
|
40
|
-
this.seen = new
|
|
48
|
+
const entries = [...this.seen.entries()];
|
|
49
|
+
this.seen = new Map(entries.slice(entries.length - this.props.maxSize));
|
|
41
50
|
}
|
|
42
51
|
get size() {
|
|
43
52
|
return this.seen.size;
|
|
44
53
|
}
|
|
45
54
|
};
|
|
46
55
|
//#endregion
|
|
47
|
-
//#region lib/slack/slack-envelope-schema.ts
|
|
48
|
-
const FlumeSlackEnvelopeSchema = z.object({
|
|
49
|
-
envelope_id: z.string(),
|
|
50
|
-
type: z.string(),
|
|
51
|
-
payload: z.record(z.string(), z.unknown()),
|
|
52
|
-
accepts_response_payload: z.boolean().optional(),
|
|
53
|
-
retry_attempt: z.number().optional(),
|
|
54
|
-
retry_reason: z.string().optional()
|
|
55
|
-
});
|
|
56
|
-
//#endregion
|
|
57
56
|
//#region lib/slack/slack-connection-response-schema.ts
|
|
58
57
|
const FlumeSlackConnectionResponseSchema = z.object({
|
|
59
58
|
ok: z.boolean(),
|
|
@@ -62,40 +61,65 @@ const FlumeSlackConnectionResponseSchema = z.object({
|
|
|
62
61
|
});
|
|
63
62
|
//#endregion
|
|
64
63
|
//#region lib/slack/obtain-slack-url.ts
|
|
64
|
+
const URL_ENDPOINT = "https://slack.com/api/apps.connections.open";
|
|
65
65
|
async function obtainSlackUrl(props) {
|
|
66
66
|
const log = new FlumeLogger({
|
|
67
67
|
source: "slack.url",
|
|
68
68
|
handler: props.onLog,
|
|
69
69
|
deps: props.deps
|
|
70
70
|
});
|
|
71
|
-
const url = "https://slack.com/api/apps.connections.open";
|
|
72
71
|
log.debug({
|
|
73
72
|
action: "http.request",
|
|
74
|
-
message: `POST ${
|
|
75
|
-
});
|
|
76
|
-
const response = await safeFetch({
|
|
77
|
-
fetch: props.deps.fetch,
|
|
78
|
-
url,
|
|
79
|
-
init: {
|
|
80
|
-
method: "POST",
|
|
81
|
-
headers: { Authorization: `Bearer ${props.appToken}` }
|
|
82
|
-
},
|
|
83
|
-
log
|
|
84
|
-
});
|
|
85
|
-
if (response instanceof Error) return new FlumeHttpError({
|
|
86
|
-
message: response.message,
|
|
87
|
-
status: 0
|
|
73
|
+
message: `POST ${URL_ENDPOINT}`
|
|
88
74
|
});
|
|
75
|
+
const response = await attempt(() => props.deps.fetch(URL_ENDPOINT, {
|
|
76
|
+
method: "POST",
|
|
77
|
+
headers: { Authorization: `Bearer ${props.appToken}` },
|
|
78
|
+
signal: props.signal
|
|
79
|
+
}));
|
|
80
|
+
if (response instanceof Error) {
|
|
81
|
+
const error = safeNormalizeError({ value: response });
|
|
82
|
+
log.error({
|
|
83
|
+
action: "http.error",
|
|
84
|
+
message: safeErrorMessage({ error }),
|
|
85
|
+
error
|
|
86
|
+
});
|
|
87
|
+
return new FlumeConnectionError(`apps.connections.open transport: ${response.message}`, { cause: response });
|
|
88
|
+
}
|
|
89
89
|
log.debug({
|
|
90
90
|
action: "http.response",
|
|
91
91
|
message: `POST ${response.status}`,
|
|
92
92
|
detail: {
|
|
93
93
|
status: response.status,
|
|
94
|
-
url
|
|
94
|
+
url: URL_ENDPOINT
|
|
95
95
|
}
|
|
96
96
|
});
|
|
97
|
-
const
|
|
98
|
-
|
|
97
|
+
const text = await safeReadText({
|
|
98
|
+
response,
|
|
99
|
+
context: "apps.connections.open"
|
|
100
|
+
});
|
|
101
|
+
if (text instanceof FlumeHttpError) {
|
|
102
|
+
log.warn({
|
|
103
|
+
action: "http.body.read",
|
|
104
|
+
message: safeErrorMessage({ error: text }),
|
|
105
|
+
error: text
|
|
106
|
+
});
|
|
107
|
+
return text;
|
|
108
|
+
}
|
|
109
|
+
const json = safeJsonParse(text);
|
|
110
|
+
if (json instanceof FlumeParseError) {
|
|
111
|
+
log.warn({
|
|
112
|
+
action: "http.body.parse",
|
|
113
|
+
message: json.message,
|
|
114
|
+
error: json
|
|
115
|
+
});
|
|
116
|
+
return new FlumeHttpError({
|
|
117
|
+
message: `apps.connections.open: invalid JSON body`,
|
|
118
|
+
status: response.status,
|
|
119
|
+
cause: json
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
const peek = isRecord(json) ? json : {};
|
|
99
123
|
log.debug({
|
|
100
124
|
action: "http.body",
|
|
101
125
|
message: "apps.connections.open response",
|
|
@@ -104,10 +128,10 @@ async function obtainSlackUrl(props) {
|
|
|
104
128
|
error: peek.error
|
|
105
129
|
}
|
|
106
130
|
});
|
|
107
|
-
const parsed = FlumeSlackConnectionResponseSchema.safeParse(
|
|
131
|
+
const parsed = FlumeSlackConnectionResponseSchema.safeParse(json);
|
|
108
132
|
if (!parsed.success) {
|
|
109
133
|
log.warn({
|
|
110
|
-
action: "
|
|
134
|
+
action: "http.body.schema",
|
|
111
135
|
message: "apps.connections.open: invalid response shape",
|
|
112
136
|
detail: { issues: parsed.error.issues.map((i) => ({
|
|
113
137
|
path: i.path,
|
|
@@ -116,12 +140,13 @@ async function obtainSlackUrl(props) {
|
|
|
116
140
|
});
|
|
117
141
|
return new FlumeHttpError({
|
|
118
142
|
message: "apps.connections.open: invalid response shape",
|
|
119
|
-
status: response.status
|
|
143
|
+
status: response.status,
|
|
144
|
+
cause: parsed.error
|
|
120
145
|
});
|
|
121
146
|
}
|
|
122
147
|
if (!parsed.data.ok || !parsed.data.url) {
|
|
123
148
|
log.warn({
|
|
124
|
-
action: "api.
|
|
149
|
+
action: "slack.api.error",
|
|
125
150
|
message: `apps.connections.open failed: ${parsed.data.error ?? "no url"}`
|
|
126
151
|
});
|
|
127
152
|
return new FlumeHttpError({
|
|
@@ -130,21 +155,35 @@ async function obtainSlackUrl(props) {
|
|
|
130
155
|
});
|
|
131
156
|
}
|
|
132
157
|
log.info({
|
|
133
|
-
action: "url.obtained",
|
|
158
|
+
action: "slack.url.obtained",
|
|
134
159
|
message: "WSS URL obtained"
|
|
135
160
|
});
|
|
136
161
|
return parsed.data.url;
|
|
137
162
|
}
|
|
138
163
|
//#endregion
|
|
164
|
+
//#region lib/slack/slack-envelope-schema.ts
|
|
165
|
+
const FlumeSlackEnvelopeSchema = z.object({
|
|
166
|
+
envelope_id: z.string(),
|
|
167
|
+
type: z.string(),
|
|
168
|
+
payload: z.record(z.string(), z.unknown()),
|
|
169
|
+
accepts_response_payload: z.boolean().optional(),
|
|
170
|
+
retry_attempt: z.number().optional(),
|
|
171
|
+
retry_reason: z.string().optional()
|
|
172
|
+
});
|
|
173
|
+
//#endregion
|
|
139
174
|
//#region lib/slack/slack-socket-mode.ts
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
175
|
+
const WS_OPEN = 1;
|
|
176
|
+
/**
|
|
177
|
+
* Slack Socket Mode の最小 WebSocket 実装。`apps.connections.open` で URL を取得し
|
|
178
|
+
* `type: "hello"` を待ってから connected を通知する。
|
|
179
|
+
* IO 境界は全て `attempt` 経由で扱い、`connect()` は決して reject しない
|
|
180
|
+
*/
|
|
143
181
|
var FlumeSlackSocketMode = class {
|
|
144
182
|
props;
|
|
145
183
|
log;
|
|
146
184
|
ws = null;
|
|
147
|
-
|
|
185
|
+
isStoppedFlag = false;
|
|
186
|
+
hasConnected = false;
|
|
148
187
|
pendingResolve = null;
|
|
149
188
|
pendingResolved = false;
|
|
150
189
|
constructor(props) {
|
|
@@ -155,13 +194,17 @@ var FlumeSlackSocketMode = class {
|
|
|
155
194
|
deps: props.deps
|
|
156
195
|
});
|
|
157
196
|
}
|
|
158
|
-
|
|
197
|
+
get isStopped() {
|
|
198
|
+
return this.isStoppedFlag;
|
|
199
|
+
}
|
|
200
|
+
async connect(options) {
|
|
159
201
|
this.log.info({
|
|
160
202
|
action: "connect.start",
|
|
161
203
|
message: "opening WebSocket connection"
|
|
162
204
|
});
|
|
163
205
|
const url = await obtainSlackUrl({
|
|
164
206
|
appToken: this.props.appToken,
|
|
207
|
+
signal: options?.signal,
|
|
165
208
|
onLog: this.props.onLog,
|
|
166
209
|
deps: this.props.deps
|
|
167
210
|
});
|
|
@@ -173,8 +216,24 @@ var FlumeSlackSocketMode = class {
|
|
|
173
216
|
});
|
|
174
217
|
return url;
|
|
175
218
|
}
|
|
219
|
+
if (url instanceof FlumeConnectionError) {
|
|
220
|
+
this.log.error({
|
|
221
|
+
action: "url.error",
|
|
222
|
+
message: url.message,
|
|
223
|
+
error: url
|
|
224
|
+
});
|
|
225
|
+
return url;
|
|
226
|
+
}
|
|
227
|
+
if (this.isStoppedFlag) {
|
|
228
|
+
const error = new FlumeConnectionError("stopped before WebSocket open");
|
|
229
|
+
this.log.info({
|
|
230
|
+
action: "connect.aborted",
|
|
231
|
+
message: safeErrorMessage({ error })
|
|
232
|
+
});
|
|
233
|
+
return error;
|
|
234
|
+
}
|
|
176
235
|
this.log.info({
|
|
177
|
-
action: "url.obtained",
|
|
236
|
+
action: "slack.url.obtained",
|
|
178
237
|
message: "WebSocket URL obtained"
|
|
179
238
|
});
|
|
180
239
|
return this.openSocket(url);
|
|
@@ -184,24 +243,59 @@ var FlumeSlackSocketMode = class {
|
|
|
184
243
|
action: "disconnect",
|
|
185
244
|
message: "stopping socket mode"
|
|
186
245
|
});
|
|
187
|
-
this.
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
this.ws = null;
|
|
191
|
-
}
|
|
246
|
+
this.isStoppedFlag = true;
|
|
247
|
+
this.closeSocket(this.ws);
|
|
248
|
+
this.ws = null;
|
|
192
249
|
}
|
|
193
250
|
isConnected() {
|
|
194
|
-
return this.ws !== null && this.ws.readyState ===
|
|
251
|
+
return this.ws !== null && this.ws.readyState === WS_OPEN;
|
|
195
252
|
}
|
|
196
253
|
openSocket(url) {
|
|
254
|
+
const WS = this.props.deps.WebSocket;
|
|
255
|
+
if (!WS) {
|
|
256
|
+
const error = new FlumeConnectionError("WebSocket runtime not available");
|
|
257
|
+
this.log.error({
|
|
258
|
+
action: "ws.error",
|
|
259
|
+
message: safeErrorMessage({ error }),
|
|
260
|
+
error
|
|
261
|
+
});
|
|
262
|
+
return Promise.resolve(error);
|
|
263
|
+
}
|
|
197
264
|
this.pendingResolved = false;
|
|
265
|
+
this.hasConnected = false;
|
|
198
266
|
return new Promise((resolve) => {
|
|
199
267
|
this.pendingResolve = resolve;
|
|
200
|
-
const
|
|
268
|
+
const socketResult = attempt(() => new WS(url));
|
|
269
|
+
if (socketResult instanceof Error) {
|
|
270
|
+
const error = new FlumeConnectionError(`WebSocket construction failed: ${safeErrorMessage({ error: socketResult })}`, { cause: socketResult });
|
|
271
|
+
this.log.error({
|
|
272
|
+
action: "ws.construct.error",
|
|
273
|
+
message: safeErrorMessage({ error }),
|
|
274
|
+
error
|
|
275
|
+
});
|
|
276
|
+
this.ws = null;
|
|
277
|
+
this.pendingResolved = true;
|
|
278
|
+
resolve(error);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
const socket = socketResult;
|
|
201
282
|
this.ws = socket;
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
283
|
+
const listenerResult = attempt(() => {
|
|
284
|
+
socket.addEventListener("message", (ev) => this.safeOnMessage(ev, socket));
|
|
285
|
+
socket.addEventListener("close", (ev) => this.safeOnClose(ev));
|
|
286
|
+
socket.addEventListener("error", () => this.safeOnError());
|
|
287
|
+
});
|
|
288
|
+
if (listenerResult instanceof Error) {
|
|
289
|
+
const error = new FlumeConnectionError(`WebSocket listener registration failed: ${safeErrorMessage({ error: listenerResult })}`, { cause: listenerResult });
|
|
290
|
+
this.log.error({
|
|
291
|
+
action: "ws.listener.error",
|
|
292
|
+
message: safeErrorMessage({ error }),
|
|
293
|
+
error
|
|
294
|
+
});
|
|
295
|
+
this.ws = null;
|
|
296
|
+
this.pendingResolved = true;
|
|
297
|
+
resolve(error);
|
|
298
|
+
}
|
|
205
299
|
});
|
|
206
300
|
}
|
|
207
301
|
completeConnect(error) {
|
|
@@ -209,25 +303,61 @@ var FlumeSlackSocketMode = class {
|
|
|
209
303
|
this.pendingResolved = true;
|
|
210
304
|
this.pendingResolve(error);
|
|
211
305
|
}
|
|
212
|
-
|
|
213
|
-
this.
|
|
214
|
-
|
|
215
|
-
|
|
306
|
+
safeOnMessage(ev, socket) {
|
|
307
|
+
const r = attempt(() => this.onMessage(String(ev.data), socket));
|
|
308
|
+
if (r instanceof Error) this.log.error({
|
|
309
|
+
action: "ws.message.threw",
|
|
310
|
+
message: safeErrorMessage({ error: r }),
|
|
311
|
+
error: r
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
safeOnClose(ev) {
|
|
315
|
+
const r = attempt(() => this.onClose(ev));
|
|
316
|
+
if (r instanceof Error) this.log.error({
|
|
317
|
+
action: "ws.close.threw",
|
|
318
|
+
message: safeErrorMessage({ error: r }),
|
|
319
|
+
error: r
|
|
216
320
|
});
|
|
321
|
+
}
|
|
322
|
+
safeOnError() {
|
|
323
|
+
const r = attempt(() => this.onError());
|
|
324
|
+
if (r instanceof Error) this.log.error({
|
|
325
|
+
action: "ws.error.threw",
|
|
326
|
+
message: safeErrorMessage({ error: r }),
|
|
327
|
+
error: r
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
onMessage(raw, socket) {
|
|
331
|
+
if (this.isStoppedFlag) return;
|
|
217
332
|
const json = safeJsonParse(raw);
|
|
333
|
+
if (json instanceof FlumeParseError) {
|
|
334
|
+
this.log.error({
|
|
335
|
+
action: "ws.parse.error",
|
|
336
|
+
message: json.message,
|
|
337
|
+
error: json,
|
|
338
|
+
detail: { length: raw.length }
|
|
339
|
+
});
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
218
342
|
if (!isRecord(json)) {
|
|
219
343
|
this.log.error({
|
|
220
|
-
action: "ws.parse
|
|
221
|
-
message: "
|
|
222
|
-
error: new FlumeParseError(
|
|
344
|
+
action: "ws.parse.error",
|
|
345
|
+
message: "expected JSON object",
|
|
346
|
+
error: new FlumeParseError(`non-object frame (${typeof json})`)
|
|
223
347
|
});
|
|
224
348
|
return;
|
|
225
349
|
}
|
|
350
|
+
this.log.debug({
|
|
351
|
+
action: "ws.recv",
|
|
352
|
+
message: `type=${typeof json.type === "string" ? json.type : "-"} length=${raw.length}`,
|
|
353
|
+
detail: { length: raw.length }
|
|
354
|
+
});
|
|
226
355
|
if (json.type === "hello") {
|
|
227
356
|
this.log.info({
|
|
228
|
-
action: "
|
|
357
|
+
action: "socket.hello",
|
|
229
358
|
message: "connection ready"
|
|
230
359
|
});
|
|
360
|
+
this.hasConnected = true;
|
|
231
361
|
this.props.onConnected();
|
|
232
362
|
this.completeConnect(null);
|
|
233
363
|
return;
|
|
@@ -235,11 +365,11 @@ var FlumeSlackSocketMode = class {
|
|
|
235
365
|
if (json.type === "disconnect") {
|
|
236
366
|
const reason = typeof json.reason === "string" ? json.reason : "unknown";
|
|
237
367
|
this.log.info({
|
|
238
|
-
action: "ws.disconnect
|
|
368
|
+
action: "ws.disconnect.requested",
|
|
239
369
|
message: `reason=${reason}`,
|
|
240
370
|
detail: { reason }
|
|
241
371
|
});
|
|
242
|
-
|
|
372
|
+
this.closeSocket(socket);
|
|
243
373
|
return;
|
|
244
374
|
}
|
|
245
375
|
if (typeof json.envelope_id === "string") {
|
|
@@ -247,12 +377,8 @@ var FlumeSlackSocketMode = class {
|
|
|
247
377
|
action: "ws.ack",
|
|
248
378
|
message: `envelope_id=${json.envelope_id}`
|
|
249
379
|
});
|
|
250
|
-
const ack =
|
|
251
|
-
|
|
252
|
-
this.log.debug({
|
|
253
|
-
action: "ws.send",
|
|
254
|
-
message: framePreview(ack)
|
|
255
|
-
});
|
|
380
|
+
const ack = this.safeSerialize({ envelope_id: json.envelope_id });
|
|
381
|
+
if (ack !== null) this.send(socket, ack);
|
|
256
382
|
}
|
|
257
383
|
const envelope = FlumeSlackEnvelopeSchema.safeParse(json);
|
|
258
384
|
if (envelope.success) {
|
|
@@ -268,7 +394,7 @@ var FlumeSlackSocketMode = class {
|
|
|
268
394
|
return;
|
|
269
395
|
}
|
|
270
396
|
this.log.warn({
|
|
271
|
-
action: "envelope.parse
|
|
397
|
+
action: "envelope.parse.error",
|
|
272
398
|
message: "unrecognised envelope shape, dropping",
|
|
273
399
|
detail: {
|
|
274
400
|
type: typeof json.type === "string" ? json.type : "unknown",
|
|
@@ -282,94 +408,139 @@ var FlumeSlackSocketMode = class {
|
|
|
282
408
|
onClose(ev) {
|
|
283
409
|
this.log.info({
|
|
284
410
|
action: "ws.close",
|
|
285
|
-
message: `code=${ev.code} reason=${ev.reason || "none"}
|
|
411
|
+
message: `code=${ev.code} reason=${ev.reason || "none"}`,
|
|
412
|
+
detail: {
|
|
413
|
+
code: ev.code,
|
|
414
|
+
reason: ev.reason
|
|
415
|
+
}
|
|
286
416
|
});
|
|
287
417
|
this.ws = null;
|
|
288
|
-
this.props.onDisconnected();
|
|
289
|
-
this.
|
|
418
|
+
if (this.hasConnected) this.props.onDisconnected();
|
|
419
|
+
if (!this.pendingResolved) {
|
|
420
|
+
const error = new FlumeConnectionError(`WebSocket closed before hello (code=${ev.code})`, { code: ev.code });
|
|
421
|
+
this.completeConnect(error);
|
|
422
|
+
}
|
|
290
423
|
}
|
|
291
424
|
onError() {
|
|
425
|
+
const error = new FlumeConnectionError("WebSocket connection error");
|
|
292
426
|
this.log.error({
|
|
293
427
|
action: "ws.error",
|
|
294
|
-
message:
|
|
428
|
+
message: safeErrorMessage({ error }),
|
|
429
|
+
error
|
|
430
|
+
});
|
|
431
|
+
this.completeConnect(error);
|
|
432
|
+
}
|
|
433
|
+
closeSocket(ws) {
|
|
434
|
+
if (ws === null) return;
|
|
435
|
+
const result = attempt(() => ws.close());
|
|
436
|
+
if (result instanceof Error) this.log.error({
|
|
437
|
+
action: "ws.close.error",
|
|
438
|
+
message: safeErrorMessage({ error: result }),
|
|
439
|
+
error: result
|
|
295
440
|
});
|
|
296
|
-
|
|
441
|
+
}
|
|
442
|
+
send(socket, payload) {
|
|
443
|
+
if (socket.readyState !== WS_OPEN) {
|
|
444
|
+
this.log.warn({
|
|
445
|
+
action: "ws.send",
|
|
446
|
+
message: `ws.send skipped: readyState=${socket.readyState} (not OPEN)`,
|
|
447
|
+
detail: { readyState: socket.readyState }
|
|
448
|
+
});
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
const result = attempt(() => socket.send(payload));
|
|
452
|
+
if (result instanceof Error) this.log.error({
|
|
453
|
+
action: "ws.send",
|
|
454
|
+
message: `ws.send failed: ${safeErrorMessage({ error: result })}`,
|
|
455
|
+
error: result
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
safeSerialize(value) {
|
|
459
|
+
const result = safeStringify(value);
|
|
460
|
+
if (result instanceof Error) {
|
|
461
|
+
this.log.error({
|
|
462
|
+
action: "ws.send.serialize.error",
|
|
463
|
+
message: safeErrorMessage({ error: result }),
|
|
464
|
+
error: result
|
|
465
|
+
});
|
|
466
|
+
return null;
|
|
467
|
+
}
|
|
468
|
+
return result;
|
|
297
469
|
}
|
|
298
470
|
};
|
|
299
471
|
//#endregion
|
|
300
472
|
//#region lib/slack/slack-source.ts
|
|
301
473
|
const SEEN_CACHE_MAX = 1024;
|
|
302
|
-
|
|
474
|
+
const SEEN_CACHE_TTL_MS = 300 * 1e3;
|
|
475
|
+
var FlumeSlackSource = class extends FlumeSource {
|
|
303
476
|
options;
|
|
304
477
|
name = "slack";
|
|
305
478
|
socket = null;
|
|
306
479
|
reconnector = null;
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
log;
|
|
310
|
-
deps;
|
|
311
|
-
queue = new FlumeSerialQueue();
|
|
312
|
-
seen = new FlumeSlackSeenCache({ maxSize: SEEN_CACHE_MAX });
|
|
480
|
+
internalController = null;
|
|
481
|
+
seen = null;
|
|
313
482
|
constructor(options) {
|
|
483
|
+
super();
|
|
314
484
|
this.options = options;
|
|
315
|
-
this.deps = options.deps ?? createFlumeDefaultDeps();
|
|
316
|
-
this.log = new FlumeLogger({
|
|
317
|
-
source: "slack",
|
|
318
|
-
handler: options.onLog,
|
|
319
|
-
deps: this.deps
|
|
320
|
-
});
|
|
321
|
-
const rc = resolveFlumeReconnectConfig(options.reconnect);
|
|
322
|
-
if (rc) this.reconnector = new FlumeReconnector({
|
|
323
|
-
...rc,
|
|
324
|
-
deps: this.deps
|
|
325
|
-
});
|
|
326
485
|
}
|
|
327
|
-
async
|
|
328
|
-
if (this.
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
this.handler = handler;
|
|
334
|
-
this.log.info({
|
|
335
|
-
action: "start",
|
|
336
|
-
message: "starting Slack source"
|
|
486
|
+
async connect(ctx) {
|
|
487
|
+
if (!this.hasWebSocket(ctx)) return new FlumeStartError("Slack source: deps.WebSocket is null (no WebSocket runtime available)");
|
|
488
|
+
this.seen = new FlumeSlackSeenCache({
|
|
489
|
+
maxSize: SEEN_CACHE_MAX,
|
|
490
|
+
ttlMs: SEEN_CACHE_TTL_MS,
|
|
491
|
+
deps: ctx.deps
|
|
337
492
|
});
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
};
|
|
343
|
-
return { ok: true };
|
|
344
|
-
}
|
|
345
|
-
async stop() {
|
|
346
|
-
this.log.info({
|
|
347
|
-
action: "stop",
|
|
348
|
-
message: "stopping Slack source"
|
|
493
|
+
if (ctx.reconnect && !this.reconnector) this.reconnector = new FlumeReconnector({
|
|
494
|
+
...ctx.reconnect,
|
|
495
|
+
log: ctx.log,
|
|
496
|
+
deps: ctx.deps
|
|
349
497
|
});
|
|
350
|
-
|
|
498
|
+
const controllerResult = attempt(() => new AbortController());
|
|
499
|
+
if (controllerResult instanceof Error) {
|
|
500
|
+
const error = safeNormalizeError({ value: controllerResult });
|
|
501
|
+
ctx.log.error({
|
|
502
|
+
action: "slack.abort-controller.new.error",
|
|
503
|
+
message: safeErrorMessage({ error }),
|
|
504
|
+
error
|
|
505
|
+
});
|
|
506
|
+
this.internalController = null;
|
|
507
|
+
} else this.internalController = controllerResult;
|
|
508
|
+
return await this.connectInternal(ctx);
|
|
509
|
+
}
|
|
510
|
+
disconnect() {
|
|
511
|
+
const ctx = this.context;
|
|
512
|
+
if (ctx && this.reconnector && !this.reconnector.aborted) ctx.log.debug({
|
|
351
513
|
action: "reconnect.cancel",
|
|
352
514
|
message: "aborting reconnector"
|
|
353
515
|
});
|
|
354
516
|
this.reconnector?.cancel();
|
|
517
|
+
this.internalController?.abort();
|
|
355
518
|
this.socket?.disconnect();
|
|
356
519
|
this.socket = null;
|
|
357
|
-
this.
|
|
358
|
-
await this.queue.drain();
|
|
359
|
-
this.setStatus("disconnected");
|
|
520
|
+
this.internalController = null;
|
|
360
521
|
}
|
|
361
|
-
|
|
362
|
-
|
|
522
|
+
hasWebSocket(ctx) {
|
|
523
|
+
const result = attempt(() => Boolean(ctx.deps.WebSocket));
|
|
524
|
+
if (result instanceof Error) {
|
|
525
|
+
const error = safeNormalizeError({ value: result });
|
|
526
|
+
ctx.log.error({
|
|
527
|
+
action: "deps.web-socket.read.error",
|
|
528
|
+
message: safeErrorMessage({ error }),
|
|
529
|
+
error
|
|
530
|
+
});
|
|
531
|
+
return false;
|
|
532
|
+
}
|
|
533
|
+
return result;
|
|
363
534
|
}
|
|
364
|
-
async connectInternal() {
|
|
535
|
+
async connectInternal(ctx) {
|
|
365
536
|
this.setStatus("connecting");
|
|
366
537
|
this.socket = new FlumeSlackSocketMode({
|
|
367
538
|
appToken: this.options.appToken,
|
|
368
|
-
onLog:
|
|
369
|
-
deps:
|
|
370
|
-
onMessage: (envelope) => this.handleMessage(envelope),
|
|
539
|
+
onLog: ctx.log.handler,
|
|
540
|
+
deps: ctx.deps,
|
|
541
|
+
onMessage: (envelope) => this.handleMessage(ctx, envelope),
|
|
371
542
|
onConnected: () => {
|
|
372
|
-
if (this.reconnector && this.reconnector.attempt > 0)
|
|
543
|
+
if (this.reconnector && this.reconnector.attempt > 0) ctx.log.info({
|
|
373
544
|
action: "reconnect.reset",
|
|
374
545
|
message: `cleared ${this.reconnector.attempt} attempts`
|
|
375
546
|
});
|
|
@@ -377,27 +548,33 @@ var FlumeSlackSource = class {
|
|
|
377
548
|
this.setStatus("connected");
|
|
378
549
|
},
|
|
379
550
|
onDisconnected: () => {
|
|
380
|
-
if (
|
|
551
|
+
if (this.socket?.isStopped) {
|
|
552
|
+
this.setStatus("disconnected");
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
this.scheduleReconnect(ctx);
|
|
381
556
|
}
|
|
382
557
|
});
|
|
383
|
-
const error = await this.socket.connect();
|
|
558
|
+
const error = await this.socket.connect({ signal: this.internalController?.signal });
|
|
384
559
|
if (error instanceof Error) {
|
|
385
|
-
|
|
560
|
+
ctx.log.error({
|
|
386
561
|
action: "connect.failed",
|
|
387
|
-
message: error
|
|
562
|
+
message: safeErrorMessage({ error }),
|
|
388
563
|
error
|
|
389
564
|
});
|
|
390
|
-
if (!this.reconnector || this.reconnector.aborted) {
|
|
565
|
+
if (this.socket.isStopped || !this.reconnector || this.reconnector.aborted) {
|
|
391
566
|
this.setStatus("disconnected");
|
|
392
567
|
return error;
|
|
393
568
|
}
|
|
394
|
-
this.scheduleReconnect();
|
|
569
|
+
this.scheduleReconnect(ctx);
|
|
395
570
|
}
|
|
396
571
|
return null;
|
|
397
572
|
}
|
|
398
|
-
handleMessage(envelope) {
|
|
399
|
-
|
|
400
|
-
|
|
573
|
+
handleMessage(ctx, envelope) {
|
|
574
|
+
const seen = this.seen;
|
|
575
|
+
if (!seen) return;
|
|
576
|
+
if (seen.has(envelope.envelope_id)) {
|
|
577
|
+
ctx.log.debug({
|
|
401
578
|
action: "dedup.skip",
|
|
402
579
|
message: `duplicate envelope_id=${envelope.envelope_id}`,
|
|
403
580
|
detail: {
|
|
@@ -407,46 +584,48 @@ var FlumeSlackSource = class {
|
|
|
407
584
|
});
|
|
408
585
|
return;
|
|
409
586
|
}
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
587
|
+
seen.add(envelope.envelope_id);
|
|
588
|
+
seen.trim();
|
|
589
|
+
this.emit({
|
|
413
590
|
source: "slack",
|
|
414
591
|
type: envelope.type,
|
|
415
592
|
data: envelope.payload,
|
|
416
|
-
meta:
|
|
417
|
-
receivedAt:
|
|
418
|
-
};
|
|
419
|
-
this.queue.add(async () => {
|
|
420
|
-
try {
|
|
421
|
-
await this.handler?.(event);
|
|
422
|
-
} catch (err) {
|
|
423
|
-
this.log.error({
|
|
424
|
-
action: "handler.error",
|
|
425
|
-
message: "user handler threw",
|
|
426
|
-
error: err instanceof Error ? err : new Error(String(err))
|
|
427
|
-
});
|
|
428
|
-
}
|
|
593
|
+
meta: this.safeExtractMeta(ctx, envelope),
|
|
594
|
+
receivedAt: safeNow({ deps: ctx.deps })
|
|
429
595
|
});
|
|
430
596
|
}
|
|
431
|
-
|
|
597
|
+
safeExtractMeta(ctx, envelope) {
|
|
598
|
+
const result = attempt(() => flumeExtractSlackMeta(envelope));
|
|
599
|
+
if (result instanceof Error) {
|
|
600
|
+
const error = safeNormalizeError({ value: result });
|
|
601
|
+
ctx.log.warn({
|
|
602
|
+
action: "meta.extract.error",
|
|
603
|
+
message: safeErrorMessage({ error }),
|
|
604
|
+
error,
|
|
605
|
+
detail: { envelopeType: envelope.type }
|
|
606
|
+
});
|
|
607
|
+
return { event_type: envelope.type };
|
|
608
|
+
}
|
|
609
|
+
return result;
|
|
610
|
+
}
|
|
611
|
+
scheduleReconnect(ctx) {
|
|
432
612
|
scheduleFlumeReconnect({
|
|
433
613
|
reconnector: this.reconnector,
|
|
434
|
-
log:
|
|
614
|
+
log: ctx.log,
|
|
435
615
|
setStatus: (status) => this.setStatus(status),
|
|
436
616
|
retry: () => {
|
|
437
|
-
this.connectInternal()
|
|
617
|
+
this.connectInternal(ctx).catch((err) => {
|
|
618
|
+
const error = safeNormalizeError({ value: err });
|
|
619
|
+
ctx.log.error({
|
|
620
|
+
action: "reconnect.unhandled",
|
|
621
|
+
message: safeErrorMessage({ error }),
|
|
622
|
+
error
|
|
623
|
+
});
|
|
624
|
+
this.setStatus("disconnected");
|
|
625
|
+
});
|
|
438
626
|
}
|
|
439
627
|
});
|
|
440
628
|
}
|
|
441
|
-
setStatus(next) {
|
|
442
|
-
if (this.currentStatus === next) return;
|
|
443
|
-
this.log.info({
|
|
444
|
-
action: "status",
|
|
445
|
-
message: `${this.currentStatus} → ${next}`
|
|
446
|
-
});
|
|
447
|
-
this.currentStatus = next;
|
|
448
|
-
this.options.onStatus?.(next);
|
|
449
|
-
}
|
|
450
629
|
};
|
|
451
630
|
//#endregion
|
|
452
|
-
export {
|
|
631
|
+
export { FlumeSlackSource, flumeExtractSlackMeta };
|