@interactive-inc/flume 0.2.0 → 0.4.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 +60 -39
- package/dist/connection-error-HUO3PC3G.js +16 -0
- package/dist/discord.d.ts +10 -85
- package/dist/discord.js +474 -175
- package/dist/github.d.ts +9 -48
- package/dist/github.js +391 -128
- package/dist/{http-error-BtXonO-W.js → http-error-CPSKoSie.js} +1 -1
- package/dist/index.d.ts +76 -45
- package/dist/index.js +225 -40
- package/dist/safe-invoke-callback-EpWXwfwp.js +170 -0
- package/dist/safe-read-text-DgrJ4Uhl.js +20 -0
- package/dist/safe-stringify-BWS-uXZP.js +172 -0
- package/dist/serial-queue-B9LoBc64.js +162 -0
- package/dist/slack.d.ts +10 -62
- package/dist/slack.js +377 -131
- package/dist/{types-tnOPBc1p.d.ts → types-D-tO-Mh2.d.ts} +36 -10
- package/package.json +21 -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/safe-fetch-30ZzOKHL.js +0 -20
- package/dist/safe-json-parse-WCg_x1JS.js +0 -15
- package/dist/schedule-reconnect-DSxZJG3h.js +0 -93
- package/dist/serial-queue-ExmlnpzQ.js +0 -16
package/dist/discord.js
CHANGED
|
@@ -1,50 +1,8 @@
|
|
|
1
|
-
import { n as createFlumeDefaultDeps, t as
|
|
2
|
-
import {
|
|
3
|
-
import { t as
|
|
4
|
-
import { n as
|
|
1
|
+
import { a as FlumeParseError, c as safeNormalizeError, i as FlumeStartError, l as safeErrorMessage, n as FlumeLogger, o as createFlumeDefaultDeps, r as safeNow, s as attempt, t as safeInvokeCallback } from "./safe-invoke-callback-EpWXwfwp.js";
|
|
2
|
+
import { t as FlumeConnectionError } from "./connection-error-HUO3PC3G.js";
|
|
3
|
+
import { a as FlumeReconnector, i as resolveFlumeReconnectConfig, n as isRecord, o as safeRandom, r as scheduleFlumeReconnect, t as safeStringify } from "./safe-stringify-BWS-uXZP.js";
|
|
4
|
+
import { i as safeJsonParse, n as FlumeStatusEmitter, r as FlumeSignalRegistry, t as FlumeSerialQueue } from "./serial-queue-B9LoBc64.js";
|
|
5
5
|
import { z } from "zod/v4";
|
|
6
|
-
//#region lib/discord/extract-discord-meta.ts
|
|
7
|
-
function extractDiscordMeta(eventName, eventData) {
|
|
8
|
-
const meta = { event_type: eventName };
|
|
9
|
-
if (typeof eventData.channel_id === "string") meta.channel_id = eventData.channel_id;
|
|
10
|
-
if (typeof eventData.guild_id === "string") meta.guild_id = eventData.guild_id;
|
|
11
|
-
if (isRecord(eventData.author) && typeof eventData.author.id === "string") meta.user_id = eventData.author.id;
|
|
12
|
-
return meta;
|
|
13
|
-
}
|
|
14
|
-
//#endregion
|
|
15
|
-
//#region lib/discord/discord-heartbeat.ts
|
|
16
|
-
var FlumeDiscordHeartbeat = class {
|
|
17
|
-
props;
|
|
18
|
-
timer = null;
|
|
19
|
-
ackReceived = true;
|
|
20
|
-
constructor(props) {
|
|
21
|
-
this.props = props;
|
|
22
|
-
}
|
|
23
|
-
start(intervalMs) {
|
|
24
|
-
this.stop();
|
|
25
|
-
this.ackReceived = true;
|
|
26
|
-
this.timer = this.props.deps.setInterval(() => {
|
|
27
|
-
if (!this.ackReceived) {
|
|
28
|
-
this.props.onZombie();
|
|
29
|
-
return;
|
|
30
|
-
}
|
|
31
|
-
this.ackReceived = false;
|
|
32
|
-
this.props.onSend();
|
|
33
|
-
}, intervalMs);
|
|
34
|
-
}
|
|
35
|
-
stop() {
|
|
36
|
-
if (this.timer === null) return;
|
|
37
|
-
this.props.deps.clearInterval(this.timer);
|
|
38
|
-
this.timer = null;
|
|
39
|
-
}
|
|
40
|
-
ack() {
|
|
41
|
-
this.ackReceived = true;
|
|
42
|
-
}
|
|
43
|
-
isRunning() {
|
|
44
|
-
return this.timer !== null;
|
|
45
|
-
}
|
|
46
|
-
};
|
|
47
|
-
//#endregion
|
|
48
6
|
//#region lib/discord/discord-gateway-session.ts
|
|
49
7
|
var FlumeDiscordGatewaySession = class FlumeDiscordGatewaySession {
|
|
50
8
|
sessionId;
|
|
@@ -85,27 +43,116 @@ var FlumeDiscordGatewaySession = class FlumeDiscordGatewaySession {
|
|
|
85
43
|
}
|
|
86
44
|
};
|
|
87
45
|
//#endregion
|
|
46
|
+
//#region lib/discord/discord-heartbeat.ts
|
|
47
|
+
/**
|
|
48
|
+
* Discord Gateway のハートビート。
|
|
49
|
+
* 初回送信は `random()*heartbeat_interval` ms 後 (spec)、以降は heartbeat_interval ごと。
|
|
50
|
+
* 前回送信に対する ACK が来ていなければ zombie 通知。すべての callback / timer body は throw を
|
|
51
|
+
* 飲み込みログ記録する (host の timer queue に uncaught を漏らさない)
|
|
52
|
+
*/
|
|
53
|
+
var FlumeDiscordHeartbeat = class {
|
|
54
|
+
props;
|
|
55
|
+
initialTimer = null;
|
|
56
|
+
intervalTimer = null;
|
|
57
|
+
ackReceived = true;
|
|
58
|
+
constructor(props) {
|
|
59
|
+
this.props = props;
|
|
60
|
+
}
|
|
61
|
+
start(intervalMs) {
|
|
62
|
+
this.stop();
|
|
63
|
+
this.ackReceived = true;
|
|
64
|
+
const initialDelay = safeRandom({ deps: this.props.deps }) * intervalMs;
|
|
65
|
+
const initialResult = attempt(() => this.props.deps.setTimeout(() => {
|
|
66
|
+
this.initialTimer = null;
|
|
67
|
+
this.safeFire();
|
|
68
|
+
const intervalResult = attempt(() => this.props.deps.setInterval(() => this.safeFire(), intervalMs));
|
|
69
|
+
if (intervalResult instanceof Error) {
|
|
70
|
+
this.props.log.error({
|
|
71
|
+
action: "heartbeat.interval.schedule.error",
|
|
72
|
+
message: safeErrorMessage({ error: intervalResult }),
|
|
73
|
+
error: intervalResult
|
|
74
|
+
});
|
|
75
|
+
this.intervalTimer = null;
|
|
76
|
+
} else this.intervalTimer = intervalResult;
|
|
77
|
+
}, initialDelay));
|
|
78
|
+
if (initialResult instanceof Error) {
|
|
79
|
+
this.props.log.error({
|
|
80
|
+
action: "heartbeat.initial.schedule.error",
|
|
81
|
+
message: safeErrorMessage({ error: initialResult }),
|
|
82
|
+
error: initialResult
|
|
83
|
+
});
|
|
84
|
+
this.initialTimer = null;
|
|
85
|
+
} else this.initialTimer = initialResult;
|
|
86
|
+
}
|
|
87
|
+
stop() {
|
|
88
|
+
if (this.initialTimer !== null) {
|
|
89
|
+
const handle = this.initialTimer;
|
|
90
|
+
const r = attempt(() => this.props.deps.clearTimeout(handle));
|
|
91
|
+
if (r instanceof Error) this.props.log.error({
|
|
92
|
+
action: "heartbeat.initial.clear.error",
|
|
93
|
+
message: safeErrorMessage({ error: r }),
|
|
94
|
+
error: r
|
|
95
|
+
});
|
|
96
|
+
this.initialTimer = null;
|
|
97
|
+
}
|
|
98
|
+
if (this.intervalTimer !== null) {
|
|
99
|
+
const handle = this.intervalTimer;
|
|
100
|
+
const r = attempt(() => this.props.deps.clearInterval(handle));
|
|
101
|
+
if (r instanceof Error) this.props.log.error({
|
|
102
|
+
action: "heartbeat.interval.clear.error",
|
|
103
|
+
message: safeErrorMessage({ error: r }),
|
|
104
|
+
error: r
|
|
105
|
+
});
|
|
106
|
+
this.intervalTimer = null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
ack() {
|
|
110
|
+
this.ackReceived = true;
|
|
111
|
+
}
|
|
112
|
+
isRunning() {
|
|
113
|
+
return this.initialTimer !== null || this.intervalTimer !== null;
|
|
114
|
+
}
|
|
115
|
+
safeFire() {
|
|
116
|
+
safeInvokeCallback({
|
|
117
|
+
fn: () => this.fire(),
|
|
118
|
+
onError: (error) => {
|
|
119
|
+
this.props.log.error({
|
|
120
|
+
action: "heartbeat.fire.error",
|
|
121
|
+
message: safeErrorMessage({ error }),
|
|
122
|
+
error
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
fire() {
|
|
128
|
+
if (!this.ackReceived) {
|
|
129
|
+
this.props.onZombie();
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
this.ackReceived = false;
|
|
133
|
+
this.props.onSend();
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
//#endregion
|
|
88
137
|
//#region lib/discord/discord-gateway-message-schema.ts
|
|
89
138
|
const FlumeGatewayMessageSchema = z.object({
|
|
90
139
|
op: z.number(),
|
|
91
|
-
d: z.
|
|
140
|
+
d: z.unknown(),
|
|
92
141
|
s: z.number().nullable(),
|
|
93
142
|
t: z.string().nullable()
|
|
94
143
|
});
|
|
95
144
|
//#endregion
|
|
96
145
|
//#region lib/discord/parse-discord-gateway-message.ts
|
|
97
|
-
function
|
|
146
|
+
function parseFlumeDiscordGatewayMessage(raw) {
|
|
98
147
|
const json = safeJsonParse(raw);
|
|
148
|
+
if (json instanceof FlumeParseError) return json;
|
|
99
149
|
const parsed = FlumeGatewayMessageSchema.safeParse(json);
|
|
100
|
-
if (!parsed.success) return new FlumeParseError(`invalid gateway message
|
|
150
|
+
if (!parsed.success) return new FlumeParseError(`invalid gateway message frame (${raw.length} bytes)`, { cause: parsed.error });
|
|
101
151
|
return parsed.data;
|
|
102
152
|
}
|
|
103
153
|
//#endregion
|
|
104
154
|
//#region lib/discord/discord-gateway.ts
|
|
105
155
|
const GATEWAY_URL = "wss://gateway.discord.gg/?v=10&encoding=json";
|
|
106
|
-
function framePreview(raw) {
|
|
107
|
-
return raw.length > 200 ? `${raw.slice(0, 200)}... (${raw.length} bytes)` : raw;
|
|
108
|
-
}
|
|
109
156
|
const OP_DISPATCH = 0;
|
|
110
157
|
const OP_HEARTBEAT = 1;
|
|
111
158
|
const OP_IDENTIFY = 2;
|
|
@@ -124,15 +171,33 @@ const OP_NAMES = {
|
|
|
124
171
|
[OP_HELLO]: "HELLO",
|
|
125
172
|
[OP_HEARTBEAT_ACK]: "HEARTBEAT_ACK"
|
|
126
173
|
};
|
|
174
|
+
const WS_OPEN = 1;
|
|
175
|
+
const TERMINAL_CLOSE_CODES = new Set([
|
|
176
|
+
4004,
|
|
177
|
+
4010,
|
|
178
|
+
4011,
|
|
179
|
+
4012,
|
|
180
|
+
4013,
|
|
181
|
+
4014
|
|
182
|
+
]);
|
|
183
|
+
/**
|
|
184
|
+
* Discord Gateway v10 の最小実装。HELLO -> IDENTIFY/RESUME -> READY/RESUMED -> dispatch を扱う。
|
|
185
|
+
* READY 後の WebSocket 切断のみ `onStatus("disconnected")` を発火し source 側で再接続する。
|
|
186
|
+
* 終端 close code (4004 / 401x) を受けた場合は stopped 化して再接続を抑止。
|
|
187
|
+
* IO 境界は全て `attempt` 経由で扱い、コンストラクタ throw も `FlumeConnectionError` として返す
|
|
188
|
+
* (`connect()` は決して reject しない)
|
|
189
|
+
*/
|
|
127
190
|
var FlumeDiscordGateway = class {
|
|
128
191
|
props;
|
|
129
192
|
log;
|
|
130
193
|
ws = null;
|
|
131
194
|
heartbeat = null;
|
|
132
|
-
|
|
133
|
-
|
|
195
|
+
currentSession = FlumeDiscordGatewaySession.empty();
|
|
196
|
+
isStoppedFlag = false;
|
|
197
|
+
hasConnected = false;
|
|
134
198
|
pendingResolve = null;
|
|
135
199
|
pendingResolved = false;
|
|
200
|
+
invalidSessionTimer = null;
|
|
136
201
|
constructor(props) {
|
|
137
202
|
this.props = props;
|
|
138
203
|
this.log = new FlumeLogger({
|
|
@@ -141,20 +206,65 @@ var FlumeDiscordGateway = class {
|
|
|
141
206
|
deps: props.deps
|
|
142
207
|
});
|
|
143
208
|
}
|
|
209
|
+
get session() {
|
|
210
|
+
return this.currentSession;
|
|
211
|
+
}
|
|
212
|
+
get isStopped() {
|
|
213
|
+
return this.isStoppedFlag;
|
|
214
|
+
}
|
|
144
215
|
connect(url) {
|
|
216
|
+
const WS = this.props.deps.WebSocket;
|
|
217
|
+
if (!WS) {
|
|
218
|
+
const error = new FlumeConnectionError("WebSocket runtime not available");
|
|
219
|
+
this.log.error({
|
|
220
|
+
action: "ws.error",
|
|
221
|
+
message: safeErrorMessage({ error }),
|
|
222
|
+
error
|
|
223
|
+
});
|
|
224
|
+
return Promise.resolve(error);
|
|
225
|
+
}
|
|
145
226
|
const target = url ?? GATEWAY_URL;
|
|
227
|
+
const hostResult = attempt(() => new URL(target).hostname);
|
|
228
|
+
const host = hostResult instanceof Error ? "unknown" : hostResult;
|
|
146
229
|
this.log.info({
|
|
147
230
|
action: "connect.start",
|
|
148
|
-
message: `
|
|
231
|
+
message: `host=${host}`
|
|
149
232
|
});
|
|
150
233
|
this.pendingResolved = false;
|
|
234
|
+
this.hasConnected = false;
|
|
151
235
|
return new Promise((resolve) => {
|
|
152
236
|
this.pendingResolve = resolve;
|
|
153
|
-
const
|
|
237
|
+
const socketResult = attempt(() => new WS(target));
|
|
238
|
+
if (socketResult instanceof Error) {
|
|
239
|
+
const error = new FlumeConnectionError(`WebSocket construction failed: ${safeErrorMessage({ error: socketResult })}`, { cause: socketResult });
|
|
240
|
+
this.log.error({
|
|
241
|
+
action: "ws.construct.error",
|
|
242
|
+
message: safeErrorMessage({ error }),
|
|
243
|
+
error
|
|
244
|
+
});
|
|
245
|
+
this.ws = null;
|
|
246
|
+
this.pendingResolved = true;
|
|
247
|
+
resolve(error);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
const socket = socketResult;
|
|
154
251
|
this.ws = socket;
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
252
|
+
const listenerResult = attempt(() => {
|
|
253
|
+
socket.addEventListener("message", (ev) => this.safeOnMessage(ev, socket));
|
|
254
|
+
socket.addEventListener("close", (ev) => this.safeOnClose(ev));
|
|
255
|
+
socket.addEventListener("error", () => this.safeOnError());
|
|
256
|
+
});
|
|
257
|
+
if (listenerResult instanceof Error) {
|
|
258
|
+
const error = new FlumeConnectionError(`WebSocket listener registration failed: ${safeErrorMessage({ error: listenerResult })}`, { cause: listenerResult });
|
|
259
|
+
this.log.error({
|
|
260
|
+
action: "ws.listener.error",
|
|
261
|
+
message: safeErrorMessage({ error }),
|
|
262
|
+
error
|
|
263
|
+
});
|
|
264
|
+
this.ws = null;
|
|
265
|
+
this.pendingResolved = true;
|
|
266
|
+
resolve(error);
|
|
267
|
+
}
|
|
158
268
|
});
|
|
159
269
|
}
|
|
160
270
|
disconnect() {
|
|
@@ -162,45 +272,71 @@ var FlumeDiscordGateway = class {
|
|
|
162
272
|
action: "disconnect",
|
|
163
273
|
message: "shutting down gateway"
|
|
164
274
|
});
|
|
165
|
-
this.
|
|
275
|
+
this.isStoppedFlag = true;
|
|
166
276
|
this.heartbeat?.stop();
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
this.ws
|
|
170
|
-
|
|
277
|
+
this.clearInvalidSessionTimer();
|
|
278
|
+
this.closeSocket({
|
|
279
|
+
ws: this.ws,
|
|
280
|
+
code: 1e3,
|
|
281
|
+
reason: "shutdown"
|
|
282
|
+
});
|
|
283
|
+
this.ws = null;
|
|
171
284
|
}
|
|
172
285
|
isConnected() {
|
|
173
|
-
return this.ws !== null && this.ws.readyState ===
|
|
286
|
+
return this.ws !== null && this.ws.readyState === WS_OPEN;
|
|
174
287
|
}
|
|
175
288
|
completeConnect(error) {
|
|
176
289
|
if (this.pendingResolved || !this.pendingResolve) return;
|
|
177
290
|
this.pendingResolved = true;
|
|
178
291
|
this.pendingResolve(error);
|
|
179
292
|
}
|
|
180
|
-
|
|
181
|
-
this.
|
|
182
|
-
|
|
183
|
-
|
|
293
|
+
safeOnMessage(ev, socket) {
|
|
294
|
+
const r = attempt(() => this.onMessage(String(ev.data), socket));
|
|
295
|
+
if (r instanceof Error) this.log.error({
|
|
296
|
+
action: "ws.message.threw",
|
|
297
|
+
message: safeErrorMessage({ error: r }),
|
|
298
|
+
error: r
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
safeOnClose(ev) {
|
|
302
|
+
const r = attempt(() => this.onClose(ev));
|
|
303
|
+
if (r instanceof Error) this.log.error({
|
|
304
|
+
action: "ws.close.threw",
|
|
305
|
+
message: safeErrorMessage({ error: r }),
|
|
306
|
+
error: r
|
|
184
307
|
});
|
|
185
|
-
|
|
308
|
+
}
|
|
309
|
+
safeOnError() {
|
|
310
|
+
const r = attempt(() => this.onError());
|
|
311
|
+
if (r instanceof Error) this.log.error({
|
|
312
|
+
action: "ws.error.threw",
|
|
313
|
+
message: safeErrorMessage({ error: r }),
|
|
314
|
+
error: r
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
onMessage(raw, socket) {
|
|
318
|
+
if (this.isStoppedFlag) return;
|
|
319
|
+
const parsed = parseFlumeDiscordGatewayMessage(raw);
|
|
186
320
|
if (parsed instanceof FlumeParseError) {
|
|
187
321
|
this.log.error({
|
|
188
|
-
action: "ws.parse
|
|
322
|
+
action: "ws.parse.error",
|
|
189
323
|
message: parsed.message,
|
|
190
|
-
error: parsed
|
|
324
|
+
error: parsed,
|
|
325
|
+
detail: { length: raw.length }
|
|
191
326
|
});
|
|
192
327
|
return;
|
|
193
328
|
}
|
|
194
329
|
this.log.debug({
|
|
195
|
-
action: "ws.
|
|
196
|
-
message: `op=${OP_NAMES[parsed.op] ?? parsed.op} t=${parsed.t ?? "-"} s=${parsed.s ?? "-"}`,
|
|
330
|
+
action: "ws.recv",
|
|
331
|
+
message: `op=${OP_NAMES[parsed.op] ?? parsed.op} t=${parsed.t ?? "-"} s=${parsed.s ?? "-"} length=${raw.length}`,
|
|
197
332
|
detail: {
|
|
198
333
|
op: parsed.op,
|
|
199
334
|
t: parsed.t,
|
|
200
|
-
s: parsed.s
|
|
335
|
+
s: parsed.s,
|
|
336
|
+
length: raw.length
|
|
201
337
|
}
|
|
202
338
|
});
|
|
203
|
-
if (parsed.s !== null) this.
|
|
339
|
+
if (parsed.s !== null) this.currentSession = this.currentSession.withSeq(parsed.s);
|
|
204
340
|
if (parsed.op === OP_HELLO) return this.onHello(parsed);
|
|
205
341
|
if (parsed.op === OP_HEARTBEAT_ACK) return this.onHeartbeatAck();
|
|
206
342
|
if (parsed.op === OP_HEARTBEAT) return this.onHeartbeatRequest();
|
|
@@ -208,27 +344,31 @@ var FlumeDiscordGateway = class {
|
|
|
208
344
|
if (parsed.op === OP_INVALID_SESSION) return this.onInvalidSession(parsed, socket);
|
|
209
345
|
if (parsed.op === OP_DISPATCH) return this.onDispatch(parsed);
|
|
210
346
|
this.log.warn({
|
|
211
|
-
action: "ws.unknown
|
|
347
|
+
action: "ws.op.unknown",
|
|
212
348
|
message: `unknown op=${parsed.op}`,
|
|
213
349
|
detail: { op: parsed.op }
|
|
214
350
|
});
|
|
215
351
|
}
|
|
216
352
|
onHello(msg) {
|
|
217
|
-
const
|
|
353
|
+
const d = isRecord(msg.d) ? msg.d : null;
|
|
354
|
+
const interval = d && typeof d.heartbeat_interval === "number" ? d.heartbeat_interval : 0;
|
|
218
355
|
this.log.info({
|
|
219
|
-
action: "hello",
|
|
220
|
-
message: `heartbeat_interval=${interval}ms
|
|
356
|
+
action: "gateway.hello",
|
|
357
|
+
message: `heartbeat_interval=${interval}ms`,
|
|
358
|
+
detail: { interval }
|
|
221
359
|
});
|
|
360
|
+
this.heartbeat?.stop();
|
|
222
361
|
this.heartbeat = new FlumeDiscordHeartbeat({
|
|
362
|
+
log: this.log,
|
|
223
363
|
deps: this.props.deps,
|
|
224
364
|
onSend: () => {
|
|
225
365
|
this.log.debug({
|
|
226
366
|
action: "heartbeat.send",
|
|
227
|
-
message: `seq=${this.
|
|
367
|
+
message: `seq=${this.currentSession.seq}`
|
|
228
368
|
});
|
|
229
369
|
this.send({
|
|
230
370
|
op: OP_HEARTBEAT,
|
|
231
|
-
d: this.
|
|
371
|
+
d: this.currentSession.seq
|
|
232
372
|
});
|
|
233
373
|
},
|
|
234
374
|
onZombie: () => {
|
|
@@ -236,11 +376,15 @@ var FlumeDiscordGateway = class {
|
|
|
236
376
|
action: "heartbeat.zombie",
|
|
237
377
|
message: "no ACK received, closing connection"
|
|
238
378
|
});
|
|
239
|
-
this.
|
|
379
|
+
this.closeSocket({
|
|
380
|
+
ws: this.ws,
|
|
381
|
+
code: 4009,
|
|
382
|
+
reason: "zombie connection"
|
|
383
|
+
});
|
|
240
384
|
}
|
|
241
385
|
});
|
|
242
386
|
this.heartbeat.start(interval);
|
|
243
|
-
if (this.
|
|
387
|
+
if (this.currentSession.canResume()) this.sendResume();
|
|
244
388
|
else this.sendIdentify();
|
|
245
389
|
}
|
|
246
390
|
onHeartbeatAck() {
|
|
@@ -257,95 +401,185 @@ var FlumeDiscordGateway = class {
|
|
|
257
401
|
});
|
|
258
402
|
this.send({
|
|
259
403
|
op: OP_HEARTBEAT,
|
|
260
|
-
d: this.
|
|
404
|
+
d: this.currentSession.seq
|
|
261
405
|
});
|
|
262
406
|
}
|
|
263
407
|
onReconnectRequest(socket) {
|
|
264
408
|
this.log.info({
|
|
265
|
-
action: "reconnect.requested",
|
|
409
|
+
action: "ws.reconnect.requested",
|
|
266
410
|
message: "server requested reconnect"
|
|
267
411
|
});
|
|
268
|
-
|
|
412
|
+
this.closeSocket({
|
|
413
|
+
ws: socket,
|
|
414
|
+
code: 4e3,
|
|
415
|
+
reason: "reconnect requested"
|
|
416
|
+
});
|
|
269
417
|
}
|
|
270
418
|
onInvalidSession(msg, socket) {
|
|
271
|
-
const resumable =
|
|
419
|
+
const resumable = msg.d === true || isRecord(msg.d) && msg.d.resumable === true;
|
|
272
420
|
this.log.warn({
|
|
273
|
-
action: "invalid
|
|
274
|
-
message: `resumable=${resumable}
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
421
|
+
action: "session.invalid",
|
|
422
|
+
message: `resumable=${resumable}`,
|
|
423
|
+
detail: { resumable }
|
|
424
|
+
});
|
|
425
|
+
const delay = 1e3 + safeRandom({ deps: this.props.deps }) * 4e3;
|
|
426
|
+
if (!resumable) this.currentSession = this.currentSession.withReset();
|
|
427
|
+
this.clearInvalidSessionTimer();
|
|
428
|
+
const timerResult = attempt(() => this.props.deps.setTimeout(() => {
|
|
429
|
+
this.invalidSessionTimer = null;
|
|
430
|
+
this.closeSocket({
|
|
431
|
+
ws: socket,
|
|
432
|
+
code: 4e3,
|
|
433
|
+
reason: "invalid session"
|
|
434
|
+
});
|
|
435
|
+
}, delay));
|
|
436
|
+
if (timerResult instanceof Error) {
|
|
437
|
+
this.log.error({
|
|
438
|
+
action: "session.invalid.timer.error",
|
|
439
|
+
message: safeErrorMessage({ error: timerResult }),
|
|
440
|
+
error: timerResult
|
|
282
441
|
});
|
|
283
|
-
this.
|
|
284
|
-
} else
|
|
442
|
+
this.invalidSessionTimer = null;
|
|
443
|
+
} else this.invalidSessionTimer = timerResult;
|
|
285
444
|
}
|
|
286
445
|
onDispatch(msg) {
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
const
|
|
290
|
-
|
|
446
|
+
const d = isRecord(msg.d) ? msg.d : null;
|
|
447
|
+
if (msg.t === "READY" && d) {
|
|
448
|
+
const sessionId = typeof d.session_id === "string" ? d.session_id : "";
|
|
449
|
+
const resumeUrl = typeof d.resume_gateway_url === "string" ? d.resume_gateway_url : "";
|
|
450
|
+
this.currentSession = this.currentSession.withReady(sessionId, resumeUrl);
|
|
291
451
|
this.log.info({
|
|
292
|
-
action: "ready",
|
|
293
|
-
message: `session
|
|
452
|
+
action: "gateway.ready",
|
|
453
|
+
message: `session ready`,
|
|
454
|
+
detail: { hasResumeUrl: resumeUrl !== "" }
|
|
294
455
|
});
|
|
456
|
+
this.hasConnected = true;
|
|
295
457
|
this.props.onStatus("connected");
|
|
296
458
|
this.completeConnect(null);
|
|
297
459
|
}
|
|
298
460
|
if (msg.t === "RESUMED") {
|
|
299
461
|
this.log.info({
|
|
300
|
-
action: "resumed",
|
|
301
|
-
message: `
|
|
462
|
+
action: "gateway.resumed",
|
|
463
|
+
message: `seq=${this.currentSession.seq}`
|
|
302
464
|
});
|
|
465
|
+
this.hasConnected = true;
|
|
303
466
|
this.props.onStatus("connected");
|
|
304
467
|
this.completeConnect(null);
|
|
305
468
|
}
|
|
306
|
-
if (msg.t &&
|
|
307
|
-
else if (msg.t) this.
|
|
469
|
+
if (msg.t && d) this.props.onDispatch(msg.t, d);
|
|
470
|
+
else if (msg.t) this.log.debug({
|
|
471
|
+
action: "dispatch.empty",
|
|
472
|
+
message: `dropped ${msg.t} (no payload)`,
|
|
473
|
+
detail: { type: msg.t }
|
|
474
|
+
});
|
|
308
475
|
}
|
|
309
476
|
onClose(ev) {
|
|
477
|
+
const terminal = TERMINAL_CLOSE_CODES.has(ev.code);
|
|
310
478
|
this.log.info({
|
|
311
479
|
action: "ws.close",
|
|
312
|
-
message: `code=${ev.code} reason=${ev.reason || "none"}`,
|
|
480
|
+
message: `code=${ev.code} reason=${ev.reason || "none"}${terminal ? " (terminal)" : ""}`,
|
|
313
481
|
detail: {
|
|
314
482
|
code: ev.code,
|
|
315
|
-
reason: ev.reason
|
|
483
|
+
reason: ev.reason,
|
|
484
|
+
terminal
|
|
316
485
|
}
|
|
317
486
|
});
|
|
318
487
|
this.ws = null;
|
|
319
488
|
this.heartbeat?.stop();
|
|
320
|
-
this.
|
|
321
|
-
|
|
489
|
+
this.clearInvalidSessionTimer();
|
|
490
|
+
if (terminal) this.isStoppedFlag = true;
|
|
491
|
+
if (this.hasConnected || terminal) this.props.onStatus("disconnected");
|
|
492
|
+
if (!this.pendingResolved) {
|
|
493
|
+
const error = new FlumeConnectionError(`WebSocket closed before ready (code=${ev.code})`, { code: ev.code });
|
|
494
|
+
this.completeConnect(error);
|
|
495
|
+
}
|
|
322
496
|
}
|
|
323
497
|
onError() {
|
|
498
|
+
const error = new FlumeConnectionError("WebSocket connection error");
|
|
324
499
|
this.log.error({
|
|
325
500
|
action: "ws.error",
|
|
326
|
-
message:
|
|
501
|
+
message: safeErrorMessage({ error }),
|
|
502
|
+
error
|
|
327
503
|
});
|
|
328
|
-
this.completeConnect(
|
|
504
|
+
this.completeConnect(error);
|
|
329
505
|
}
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
506
|
+
clearInvalidSessionTimer() {
|
|
507
|
+
if (this.invalidSessionTimer === null) return;
|
|
508
|
+
const handle = this.invalidSessionTimer;
|
|
509
|
+
const result = attempt(() => this.props.deps.clearTimeout(handle));
|
|
510
|
+
if (result instanceof Error) this.log.error({
|
|
511
|
+
action: "session.invalid.timer.clear.error",
|
|
512
|
+
message: safeErrorMessage({ error: result }),
|
|
513
|
+
error: result
|
|
334
514
|
});
|
|
515
|
+
this.invalidSessionTimer = null;
|
|
516
|
+
}
|
|
517
|
+
closeSocket(input) {
|
|
518
|
+
if (input.ws === null) return;
|
|
519
|
+
const ws = input.ws;
|
|
520
|
+
const result = attempt(() => {
|
|
521
|
+
if (input.code !== void 0) ws.close(input.code, input.reason ?? "");
|
|
522
|
+
else ws.close();
|
|
523
|
+
});
|
|
524
|
+
if (result instanceof Error) this.log.error({
|
|
525
|
+
action: "ws.close.error",
|
|
526
|
+
message: safeErrorMessage({ error: result }),
|
|
527
|
+
error: result
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
send(input) {
|
|
531
|
+
const payload = this.safeSerialize(input);
|
|
532
|
+
if (payload === null) return;
|
|
335
533
|
this.log.debug({
|
|
336
534
|
action: "ws.send",
|
|
337
|
-
message: `op=${OP_NAMES[input.op] ?? input.op}`,
|
|
338
|
-
detail: {
|
|
535
|
+
message: `op=${OP_NAMES[input.op] ?? input.op} length=${payload.length}`,
|
|
536
|
+
detail: {
|
|
537
|
+
op: input.op,
|
|
538
|
+
length: payload.length
|
|
539
|
+
}
|
|
339
540
|
});
|
|
340
|
-
this.ws
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
541
|
+
const ws = this.ws;
|
|
542
|
+
if (ws === null) {
|
|
543
|
+
this.log.warn({
|
|
544
|
+
action: "ws.send",
|
|
545
|
+
message: "ws.send skipped: socket is null"
|
|
546
|
+
});
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
if (ws.readyState !== WS_OPEN) {
|
|
550
|
+
this.log.warn({
|
|
551
|
+
action: "ws.send",
|
|
552
|
+
message: `ws.send skipped: readyState=${ws.readyState} (not OPEN)`,
|
|
553
|
+
detail: { readyState: ws.readyState }
|
|
554
|
+
});
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
const result = attempt(() => ws.send(payload));
|
|
558
|
+
if (result instanceof Error) this.log.error({
|
|
559
|
+
action: "ws.send",
|
|
560
|
+
message: `ws.send failed: ${safeErrorMessage({ error: result })}`,
|
|
561
|
+
error: result
|
|
344
562
|
});
|
|
345
563
|
}
|
|
564
|
+
safeSerialize(input) {
|
|
565
|
+
const result = safeStringify({
|
|
566
|
+
op: input.op,
|
|
567
|
+
d: input.d ?? null
|
|
568
|
+
});
|
|
569
|
+
if (result instanceof Error) {
|
|
570
|
+
this.log.error({
|
|
571
|
+
action: "ws.send.serialize.error",
|
|
572
|
+
message: safeErrorMessage({ error: result }),
|
|
573
|
+
error: result,
|
|
574
|
+
detail: { op: input.op }
|
|
575
|
+
});
|
|
576
|
+
return null;
|
|
577
|
+
}
|
|
578
|
+
return result;
|
|
579
|
+
}
|
|
346
580
|
sendIdentify() {
|
|
347
581
|
this.log.info({
|
|
348
|
-
action: "identify",
|
|
582
|
+
action: "gateway.identify",
|
|
349
583
|
message: `intents=${this.props.intents}`
|
|
350
584
|
});
|
|
351
585
|
this.send({
|
|
@@ -363,15 +597,15 @@ var FlumeDiscordGateway = class {
|
|
|
363
597
|
}
|
|
364
598
|
sendResume() {
|
|
365
599
|
this.log.info({
|
|
366
|
-
action: "resume",
|
|
367
|
-
message: `
|
|
600
|
+
action: "gateway.resume",
|
|
601
|
+
message: `seq=${this.currentSession.seq}`
|
|
368
602
|
});
|
|
369
603
|
this.send({
|
|
370
604
|
op: OP_RESUME,
|
|
371
605
|
d: {
|
|
372
606
|
token: this.props.token,
|
|
373
|
-
session_id: this.
|
|
374
|
-
seq: this.
|
|
607
|
+
session_id: this.currentSession.sessionId,
|
|
608
|
+
seq: this.currentSession.seq
|
|
375
609
|
}
|
|
376
610
|
});
|
|
377
611
|
}
|
|
@@ -402,18 +636,40 @@ const FlumeDiscordGatewayIntents = {
|
|
|
402
636
|
DirectMessagePolls: 1 << 25
|
|
403
637
|
};
|
|
404
638
|
//#endregion
|
|
639
|
+
//#region lib/discord/extract-discord-meta.ts
|
|
640
|
+
function flumeExtractDiscordMeta(eventName, eventData) {
|
|
641
|
+
const meta = { event_type: eventName };
|
|
642
|
+
if (typeof eventData.channel_id === "string") meta.channel_id = eventData.channel_id;
|
|
643
|
+
if (typeof eventData.guild_id === "string") meta.guild_id = eventData.guild_id;
|
|
644
|
+
if (isRecord(eventData.author) && typeof eventData.author.id === "string") meta.user_id = eventData.author.id;
|
|
645
|
+
return meta;
|
|
646
|
+
}
|
|
647
|
+
//#endregion
|
|
405
648
|
//#region lib/discord/discord-source.ts
|
|
406
|
-
const DEFAULT_INTENTS = FlumeDiscordGatewayIntents.Guilds | FlumeDiscordGatewayIntents.GuildMessages | FlumeDiscordGatewayIntents.DirectMessages
|
|
649
|
+
const DEFAULT_INTENTS = FlumeDiscordGatewayIntents.Guilds | FlumeDiscordGatewayIntents.GuildMessages | FlumeDiscordGatewayIntents.DirectMessages;
|
|
407
650
|
var FlumeDiscordSource = class {
|
|
408
651
|
options;
|
|
409
652
|
name = "discord";
|
|
410
653
|
gateway = null;
|
|
411
654
|
reconnector = null;
|
|
412
655
|
handler = null;
|
|
413
|
-
currentStatus = "disconnected";
|
|
414
656
|
log;
|
|
415
657
|
deps;
|
|
416
658
|
queue = new FlumeSerialQueue();
|
|
659
|
+
signals;
|
|
660
|
+
statusEmitter;
|
|
661
|
+
onSignalAbort = () => {
|
|
662
|
+
safeInvokeCallback({
|
|
663
|
+
fn: () => this.stop(),
|
|
664
|
+
onError: (error) => {
|
|
665
|
+
this.log.error({
|
|
666
|
+
action: "signal.abort.stop.failed",
|
|
667
|
+
message: safeErrorMessage({ error }),
|
|
668
|
+
error
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
});
|
|
672
|
+
};
|
|
417
673
|
constructor(options) {
|
|
418
674
|
this.options = options;
|
|
419
675
|
this.deps = options.deps ?? createFlumeDefaultDeps();
|
|
@@ -422,25 +678,37 @@ var FlumeDiscordSource = class {
|
|
|
422
678
|
handler: options.onLog,
|
|
423
679
|
deps: this.deps
|
|
424
680
|
});
|
|
681
|
+
this.signals = new FlumeSignalRegistry({
|
|
682
|
+
log: this.log,
|
|
683
|
+
onAbort: this.onSignalAbort
|
|
684
|
+
});
|
|
685
|
+
this.statusEmitter = new FlumeStatusEmitter({
|
|
686
|
+
log: this.log,
|
|
687
|
+
onStatus: options.onStatus
|
|
688
|
+
});
|
|
425
689
|
const rc = resolveFlumeReconnectConfig(options.reconnect);
|
|
426
690
|
if (rc) this.reconnector = new FlumeReconnector({
|
|
427
691
|
...rc,
|
|
692
|
+
log: this.log,
|
|
428
693
|
deps: this.deps
|
|
429
694
|
});
|
|
430
695
|
}
|
|
431
|
-
async start(handler) {
|
|
432
|
-
if (this.options.signal?.
|
|
433
|
-
this.
|
|
696
|
+
async start(handler, options) {
|
|
697
|
+
if (this.signals.isAnyAborted(this.options.signal) || this.signals.isAnyAborted(options?.signal)) return new FlumeStartError("Discord source: signal already aborted");
|
|
698
|
+
if (!this.hasWebSocket()) return new FlumeStartError("Discord source: deps.WebSocket is null (no WebSocket runtime available)");
|
|
699
|
+
this.signals.register(this.options.signal);
|
|
700
|
+
this.signals.register(options?.signal);
|
|
434
701
|
this.handler = handler;
|
|
435
702
|
this.log.info({
|
|
436
|
-
action: "start",
|
|
703
|
+
action: "source.start",
|
|
437
704
|
message: "starting Discord source"
|
|
438
705
|
});
|
|
439
|
-
return this.connectInternal();
|
|
706
|
+
return await this.connectInternal();
|
|
440
707
|
}
|
|
441
708
|
async stop() {
|
|
709
|
+
this.signals.unregisterAll();
|
|
442
710
|
this.log.info({
|
|
443
|
-
action: "stop",
|
|
711
|
+
action: "source.stop",
|
|
444
712
|
message: "stopping Discord source"
|
|
445
713
|
});
|
|
446
714
|
if (this.reconnector && !this.reconnector.aborted) this.log.debug({
|
|
@@ -449,16 +717,29 @@ var FlumeDiscordSource = class {
|
|
|
449
717
|
});
|
|
450
718
|
this.reconnector?.cancel();
|
|
451
719
|
this.gateway?.disconnect();
|
|
720
|
+
await this.queue.drain();
|
|
452
721
|
this.gateway = null;
|
|
453
722
|
this.handler = null;
|
|
454
|
-
|
|
455
|
-
this.setStatus("disconnected");
|
|
723
|
+
this.statusEmitter.set("disconnected");
|
|
456
724
|
}
|
|
457
725
|
status() {
|
|
458
|
-
return this.
|
|
726
|
+
return this.statusEmitter.value;
|
|
727
|
+
}
|
|
728
|
+
hasWebSocket() {
|
|
729
|
+
const result = attempt(() => Boolean(this.deps.WebSocket));
|
|
730
|
+
if (result instanceof Error) {
|
|
731
|
+
const error = safeNormalizeError({ value: result });
|
|
732
|
+
this.log.error({
|
|
733
|
+
action: "deps.web-socket.read.error",
|
|
734
|
+
message: safeErrorMessage({ error }),
|
|
735
|
+
error
|
|
736
|
+
});
|
|
737
|
+
return false;
|
|
738
|
+
}
|
|
739
|
+
return result;
|
|
459
740
|
}
|
|
460
741
|
async connectInternal(resumeUrl) {
|
|
461
|
-
this.
|
|
742
|
+
this.statusEmitter.set("connecting");
|
|
462
743
|
this.gateway = new FlumeDiscordGateway({
|
|
463
744
|
token: this.options.token,
|
|
464
745
|
intents: this.options.intents ?? DEFAULT_INTENTS,
|
|
@@ -471,36 +752,50 @@ var FlumeDiscordSource = class {
|
|
|
471
752
|
if (error instanceof FlumeConnectionError) {
|
|
472
753
|
this.log.error({
|
|
473
754
|
action: "connect.failed",
|
|
474
|
-
message: error
|
|
755
|
+
message: safeErrorMessage({ error }),
|
|
475
756
|
error
|
|
476
757
|
});
|
|
477
|
-
if (!this.reconnector || this.reconnector.aborted) {
|
|
478
|
-
this.
|
|
758
|
+
if (this.gateway.isStopped || !this.reconnector || this.reconnector.aborted) {
|
|
759
|
+
this.statusEmitter.set("disconnected");
|
|
479
760
|
return error;
|
|
480
761
|
}
|
|
481
762
|
this.scheduleReconnect();
|
|
482
763
|
}
|
|
764
|
+
return null;
|
|
483
765
|
}
|
|
484
766
|
handleDispatch(eventName, eventData) {
|
|
485
|
-
const
|
|
486
|
-
|
|
487
|
-
type: eventName,
|
|
488
|
-
data: eventData,
|
|
489
|
-
meta: extractDiscordMeta(eventName, eventData),
|
|
490
|
-
receivedAt: this.deps.now()
|
|
491
|
-
};
|
|
767
|
+
const handler = this.handler;
|
|
768
|
+
if (!handler) return;
|
|
492
769
|
this.queue.add(async () => {
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
770
|
+
const event = {
|
|
771
|
+
source: "discord",
|
|
772
|
+
type: eventName,
|
|
773
|
+
data: eventData,
|
|
774
|
+
meta: this.safeExtractMeta(eventName, eventData),
|
|
775
|
+
receivedAt: safeNow({ deps: this.deps })
|
|
776
|
+
};
|
|
777
|
+
const r = await attempt(() => Promise.resolve(handler(event)));
|
|
778
|
+
if (r instanceof Error) this.log.error({
|
|
779
|
+
action: "handler.error",
|
|
780
|
+
message: safeErrorMessage({ error: r }),
|
|
781
|
+
error: r
|
|
782
|
+
});
|
|
502
783
|
});
|
|
503
784
|
}
|
|
785
|
+
safeExtractMeta(eventName, eventData) {
|
|
786
|
+
const result = attempt(() => flumeExtractDiscordMeta(eventName, eventData));
|
|
787
|
+
if (result instanceof Error) {
|
|
788
|
+
const error = safeNormalizeError({ value: result });
|
|
789
|
+
this.log.warn({
|
|
790
|
+
action: "meta.extract.error",
|
|
791
|
+
message: safeErrorMessage({ error }),
|
|
792
|
+
error,
|
|
793
|
+
detail: { eventName }
|
|
794
|
+
});
|
|
795
|
+
return { event_type: eventName };
|
|
796
|
+
}
|
|
797
|
+
return result;
|
|
798
|
+
}
|
|
504
799
|
handleGatewayStatus(status) {
|
|
505
800
|
if (status === "connected") {
|
|
506
801
|
if (this.reconnector && this.reconnector.attempt > 0) this.log.info({
|
|
@@ -508,30 +803,34 @@ var FlumeDiscordSource = class {
|
|
|
508
803
|
message: `cleared ${this.reconnector.attempt} attempts`
|
|
509
804
|
});
|
|
510
805
|
this.reconnector?.reset();
|
|
511
|
-
this.
|
|
806
|
+
this.statusEmitter.set("connected");
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
if (this.gateway?.isStopped) {
|
|
810
|
+
this.statusEmitter.set("disconnected");
|
|
811
|
+
return;
|
|
512
812
|
}
|
|
513
|
-
|
|
813
|
+
this.scheduleReconnect();
|
|
514
814
|
}
|
|
515
815
|
scheduleReconnect() {
|
|
516
816
|
const url = this.gateway?.session.resumeUrl ?? void 0;
|
|
517
817
|
scheduleFlumeReconnect({
|
|
518
818
|
reconnector: this.reconnector,
|
|
519
819
|
log: this.log,
|
|
520
|
-
setStatus: (status) => this.
|
|
820
|
+
setStatus: (status) => this.statusEmitter.set(status),
|
|
521
821
|
retry: () => {
|
|
522
|
-
this.connectInternal(url)
|
|
822
|
+
this.connectInternal(url).catch((err) => {
|
|
823
|
+
const error = safeNormalizeError({ value: err });
|
|
824
|
+
this.log.error({
|
|
825
|
+
action: "reconnect.unhandled",
|
|
826
|
+
message: safeErrorMessage({ error }),
|
|
827
|
+
error
|
|
828
|
+
});
|
|
829
|
+
this.statusEmitter.set("disconnected");
|
|
830
|
+
});
|
|
523
831
|
}
|
|
524
832
|
});
|
|
525
833
|
}
|
|
526
|
-
setStatus(next) {
|
|
527
|
-
if (this.currentStatus === next) return;
|
|
528
|
-
this.log.info({
|
|
529
|
-
action: "status",
|
|
530
|
-
message: `${this.currentStatus} → ${next}`
|
|
531
|
-
});
|
|
532
|
-
this.currentStatus = next;
|
|
533
|
-
this.options.onStatus?.(next);
|
|
534
|
-
}
|
|
535
834
|
};
|
|
536
835
|
//#endregion
|
|
537
|
-
export {
|
|
836
|
+
export { FlumeDiscordGatewayIntents, FlumeDiscordSource, flumeExtractDiscordMeta };
|