@interactive-inc/flume 0.1.0 → 0.3.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/dist/slack.js CHANGED
@@ -1,2 +1,452 @@
1
- import { t as FlumeSlackSource } from "./slack-source-CszepStG.js";
2
- export { FlumeSlackSource };
1
+ import { n as createFlumeDefaultDeps, t as FlumeLogger } from "./logger-CpGB9WO_.js";
2
+ import { i as FlumeConnectionError, n as resolveFlumeReconnectConfig, r as FlumeParseError, t as FlumeReconnector } from "./reconnector-BDoJ1xNX.js";
3
+ import { t as FlumeHttpError } from "./http-error-BtXonO-W.js";
4
+ import { t as FlumeSerialQueue } from "./serial-queue-ExmlnpzQ.js";
5
+ import { n as isRecord, r as scheduleFlumeReconnect, t as safeJsonParse } from "./safe-json-parse-BWlzGOLl.js";
6
+ import { t as safeFetch } from "./safe-fetch-30ZzOKHL.js";
7
+ import { z } from "zod/v4";
8
+ //#region lib/slack/extract-slack-meta.ts
9
+ function extractSlackMeta(envelope) {
10
+ const meta = { event_type: envelope.type };
11
+ const eventPayload = isRecord(envelope.payload.event) ? envelope.payload.event : null;
12
+ if (!eventPayload) return meta;
13
+ if (typeof eventPayload.channel === "string") meta.channel_id = eventPayload.channel;
14
+ if (typeof eventPayload.user === "string") meta.user_id = eventPayload.user;
15
+ if (typeof eventPayload.thread_ts === "string") meta.thread_ts = eventPayload.thread_ts;
16
+ if (typeof eventPayload.type === "string") meta.slack_event_type = eventPayload.type;
17
+ return meta;
18
+ }
19
+ //#endregion
20
+ //#region lib/slack/slack-seen-cache.ts
21
+ /**
22
+ * Slack envelope_id の LRU 風キャッシュ。Slack は ack 失敗時に同じ envelope を再送するため、
23
+ * source レイヤで handler への重複配送を防ぐ
24
+ */
25
+ var FlumeSlackSeenCache = class {
26
+ props;
27
+ seen = /* @__PURE__ */ new Set();
28
+ constructor(props) {
29
+ this.props = props;
30
+ }
31
+ has(envelopeId) {
32
+ return this.seen.has(envelopeId);
33
+ }
34
+ add(envelopeId) {
35
+ this.seen.add(envelopeId);
36
+ }
37
+ trim() {
38
+ if (this.seen.size <= this.props.maxSize) return;
39
+ const entries = [...this.seen];
40
+ this.seen = new Set(entries.slice(entries.length - this.props.maxSize));
41
+ }
42
+ get size() {
43
+ return this.seen.size;
44
+ }
45
+ };
46
+ //#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
+ //#region lib/slack/slack-connection-response-schema.ts
58
+ const FlumeSlackConnectionResponseSchema = z.object({
59
+ ok: z.boolean(),
60
+ url: z.string().optional(),
61
+ error: z.string().optional()
62
+ });
63
+ //#endregion
64
+ //#region lib/slack/obtain-slack-url.ts
65
+ async function obtainSlackUrl(props) {
66
+ const log = new FlumeLogger({
67
+ source: "slack.url",
68
+ handler: props.onLog,
69
+ deps: props.deps
70
+ });
71
+ const url = "https://slack.com/api/apps.connections.open";
72
+ log.debug({
73
+ action: "http.request",
74
+ message: `POST ${url}`
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
88
+ });
89
+ log.debug({
90
+ action: "http.response",
91
+ message: `POST ${response.status}`,
92
+ detail: {
93
+ status: response.status,
94
+ url
95
+ }
96
+ });
97
+ const raw = await response.json();
98
+ const peek = isRecord(raw) ? raw : {};
99
+ log.debug({
100
+ action: "http.body",
101
+ message: "apps.connections.open response",
102
+ detail: {
103
+ ok: peek.ok,
104
+ error: peek.error
105
+ }
106
+ });
107
+ const parsed = FlumeSlackConnectionResponseSchema.safeParse(raw);
108
+ if (!parsed.success) {
109
+ log.warn({
110
+ action: "parse.fail",
111
+ message: "apps.connections.open: invalid response shape",
112
+ detail: { issues: parsed.error.issues.map((i) => ({
113
+ path: i.path,
114
+ message: i.message
115
+ })) }
116
+ });
117
+ return new FlumeHttpError({
118
+ message: "apps.connections.open: invalid response shape",
119
+ status: response.status
120
+ });
121
+ }
122
+ if (!parsed.data.ok || !parsed.data.url) {
123
+ log.warn({
124
+ action: "api.fail",
125
+ message: `apps.connections.open failed: ${parsed.data.error ?? "no url"}`
126
+ });
127
+ return new FlumeHttpError({
128
+ message: `apps.connections.open failed: ${parsed.data.error ?? "no url"}`,
129
+ status: response.status
130
+ });
131
+ }
132
+ log.info({
133
+ action: "url.obtained",
134
+ message: "WSS URL obtained"
135
+ });
136
+ return parsed.data.url;
137
+ }
138
+ //#endregion
139
+ //#region lib/slack/slack-socket-mode.ts
140
+ function framePreview(raw) {
141
+ return raw.length > 200 ? `${raw.slice(0, 200)}... (${raw.length} bytes)` : raw;
142
+ }
143
+ var FlumeSlackSocketMode = class {
144
+ props;
145
+ log;
146
+ ws = null;
147
+ stopped = false;
148
+ pendingResolve = null;
149
+ pendingResolved = false;
150
+ constructor(props) {
151
+ this.props = props;
152
+ this.log = new FlumeLogger({
153
+ source: "slack.socket-mode",
154
+ handler: props.onLog,
155
+ deps: props.deps
156
+ });
157
+ }
158
+ async connect() {
159
+ this.log.info({
160
+ action: "connect.start",
161
+ message: "opening WebSocket connection"
162
+ });
163
+ const url = await obtainSlackUrl({
164
+ appToken: this.props.appToken,
165
+ onLog: this.props.onLog,
166
+ deps: this.props.deps
167
+ });
168
+ if (url instanceof FlumeHttpError) {
169
+ this.log.error({
170
+ action: "http.error",
171
+ message: url.message,
172
+ error: url
173
+ });
174
+ return url;
175
+ }
176
+ this.log.info({
177
+ action: "url.obtained",
178
+ message: "WebSocket URL obtained"
179
+ });
180
+ return this.openSocket(url);
181
+ }
182
+ disconnect() {
183
+ this.log.info({
184
+ action: "disconnect",
185
+ message: "stopping socket mode"
186
+ });
187
+ this.stopped = true;
188
+ if (this.ws) {
189
+ this.ws.close();
190
+ this.ws = null;
191
+ }
192
+ }
193
+ isConnected() {
194
+ return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
195
+ }
196
+ openSocket(url) {
197
+ this.pendingResolved = false;
198
+ return new Promise((resolve) => {
199
+ this.pendingResolve = resolve;
200
+ const socket = new this.props.deps.WebSocket(url);
201
+ this.ws = socket;
202
+ socket.addEventListener("message", (ev) => this.onMessage(String(ev.data), socket));
203
+ socket.addEventListener("close", (ev) => this.onClose(ev));
204
+ socket.addEventListener("error", () => this.onError());
205
+ });
206
+ }
207
+ completeConnect(error) {
208
+ if (this.pendingResolved || !this.pendingResolve) return;
209
+ this.pendingResolved = true;
210
+ this.pendingResolve(error);
211
+ }
212
+ onMessage(raw, socket) {
213
+ this.log.debug({
214
+ action: "ws.recv",
215
+ message: framePreview(raw)
216
+ });
217
+ const json = safeJsonParse(raw);
218
+ if (!isRecord(json)) {
219
+ this.log.error({
220
+ action: "ws.parse-error",
221
+ message: "invalid JSON",
222
+ error: new FlumeParseError(raw.slice(0, 200))
223
+ });
224
+ return;
225
+ }
226
+ if (json.type === "hello") {
227
+ this.log.info({
228
+ action: "ws.hello",
229
+ message: "connection ready"
230
+ });
231
+ this.props.onConnected();
232
+ this.completeConnect(null);
233
+ return;
234
+ }
235
+ if (json.type === "disconnect") {
236
+ const reason = typeof json.reason === "string" ? json.reason : "unknown";
237
+ this.log.info({
238
+ action: "ws.disconnect-requested",
239
+ message: `reason=${reason}`,
240
+ detail: { reason }
241
+ });
242
+ socket.close();
243
+ return;
244
+ }
245
+ if (typeof json.envelope_id === "string") {
246
+ this.log.debug({
247
+ action: "ws.ack",
248
+ message: `envelope_id=${json.envelope_id}`
249
+ });
250
+ const ack = JSON.stringify({ envelope_id: json.envelope_id });
251
+ socket.send(ack);
252
+ this.log.debug({
253
+ action: "ws.send",
254
+ message: framePreview(ack)
255
+ });
256
+ }
257
+ const envelope = FlumeSlackEnvelopeSchema.safeParse(json);
258
+ if (envelope.success) {
259
+ this.log.debug({
260
+ action: "envelope.recv",
261
+ message: `type=${envelope.data.type} envelope_id=${envelope.data.envelope_id}`,
262
+ detail: {
263
+ type: envelope.data.type,
264
+ envelopeId: envelope.data.envelope_id
265
+ }
266
+ });
267
+ this.props.onMessage(envelope.data);
268
+ return;
269
+ }
270
+ this.log.warn({
271
+ action: "envelope.parse-fail",
272
+ message: "unrecognised envelope shape, dropping",
273
+ detail: {
274
+ type: typeof json.type === "string" ? json.type : "unknown",
275
+ issues: envelope.error.issues.map((i) => ({
276
+ path: i.path,
277
+ message: i.message
278
+ }))
279
+ }
280
+ });
281
+ }
282
+ onClose(ev) {
283
+ this.log.info({
284
+ action: "ws.close",
285
+ message: `code=${ev.code} reason=${ev.reason || "none"}`
286
+ });
287
+ this.ws = null;
288
+ this.props.onDisconnected();
289
+ this.completeConnect(new FlumeConnectionError(`WebSocket closed before hello (code=${ev.code})`));
290
+ }
291
+ onError() {
292
+ this.log.error({
293
+ action: "ws.error",
294
+ message: "WebSocket error event"
295
+ });
296
+ this.completeConnect(new FlumeConnectionError("WebSocket connection error"));
297
+ }
298
+ };
299
+ //#endregion
300
+ //#region lib/slack/slack-source.ts
301
+ const SEEN_CACHE_MAX = 1024;
302
+ var FlumeSlackSource = class {
303
+ options;
304
+ name = "slack";
305
+ socket = null;
306
+ reconnector = null;
307
+ handler = null;
308
+ currentStatus = "disconnected";
309
+ log;
310
+ deps;
311
+ queue = new FlumeSerialQueue();
312
+ seen = new FlumeSlackSeenCache({ maxSize: SEEN_CACHE_MAX });
313
+ constructor(options) {
314
+ 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
+ }
327
+ async start(handler) {
328
+ if (this.options.signal?.aborted) return {
329
+ ok: false,
330
+ error: /* @__PURE__ */ new Error("Slack source: signal already aborted")
331
+ };
332
+ this.options.signal?.addEventListener("abort", () => this.stop(), { once: true });
333
+ this.handler = handler;
334
+ this.log.info({
335
+ action: "start",
336
+ message: "starting Slack source"
337
+ });
338
+ const result = await this.connectInternal();
339
+ if (result instanceof Error) return {
340
+ ok: false,
341
+ error: result
342
+ };
343
+ return { ok: true };
344
+ }
345
+ async stop() {
346
+ this.log.info({
347
+ action: "stop",
348
+ message: "stopping Slack source"
349
+ });
350
+ if (this.reconnector && !this.reconnector.aborted) this.log.debug({
351
+ action: "reconnect.cancel",
352
+ message: "aborting reconnector"
353
+ });
354
+ this.reconnector?.cancel();
355
+ this.socket?.disconnect();
356
+ this.socket = null;
357
+ this.handler = null;
358
+ await this.queue.drain();
359
+ this.setStatus("disconnected");
360
+ }
361
+ status() {
362
+ return this.currentStatus;
363
+ }
364
+ async connectInternal() {
365
+ this.setStatus("connecting");
366
+ this.socket = new FlumeSlackSocketMode({
367
+ appToken: this.options.appToken,
368
+ onLog: this.options.onLog,
369
+ deps: this.deps,
370
+ onMessage: (envelope) => this.handleMessage(envelope),
371
+ onConnected: () => {
372
+ if (this.reconnector && this.reconnector.attempt > 0) this.log.info({
373
+ action: "reconnect.reset",
374
+ message: `cleared ${this.reconnector.attempt} attempts`
375
+ });
376
+ this.reconnector?.reset();
377
+ this.setStatus("connected");
378
+ },
379
+ onDisconnected: () => {
380
+ if (!this.socket?.stopped) this.scheduleReconnect();
381
+ }
382
+ });
383
+ const error = await this.socket.connect();
384
+ if (error instanceof Error) {
385
+ this.log.error({
386
+ action: "connect.failed",
387
+ message: error.message,
388
+ error
389
+ });
390
+ if (!this.reconnector || this.reconnector.aborted) {
391
+ this.setStatus("disconnected");
392
+ return error;
393
+ }
394
+ this.scheduleReconnect();
395
+ }
396
+ return null;
397
+ }
398
+ handleMessage(envelope) {
399
+ if (this.seen.has(envelope.envelope_id)) {
400
+ this.log.debug({
401
+ action: "dedup.skip",
402
+ message: `duplicate envelope_id=${envelope.envelope_id}`,
403
+ detail: {
404
+ envelope_id: envelope.envelope_id,
405
+ retry_attempt: envelope.retry_attempt
406
+ }
407
+ });
408
+ return;
409
+ }
410
+ this.seen.add(envelope.envelope_id);
411
+ this.seen.trim();
412
+ const event = {
413
+ source: "slack",
414
+ type: envelope.type,
415
+ data: envelope.payload,
416
+ meta: extractSlackMeta(envelope),
417
+ receivedAt: this.deps.now()
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
+ }
429
+ });
430
+ }
431
+ scheduleReconnect() {
432
+ scheduleFlumeReconnect({
433
+ reconnector: this.reconnector,
434
+ log: this.log,
435
+ setStatus: (status) => this.setStatus(status),
436
+ retry: () => {
437
+ this.connectInternal();
438
+ }
439
+ });
440
+ }
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
+ };
451
+ //#endregion
452
+ export { FlumeSlackConnectionResponseSchema, FlumeSlackEnvelopeSchema, FlumeSlackSeenCache, FlumeSlackSocketMode, FlumeSlackSource, extractSlackMeta, obtainSlackUrl };
@@ -1,25 +1,14 @@
1
1
  import { z } from "zod/v4";
2
2
 
3
- //#region lib/schema.d.ts
3
+ //#region lib/discord/discord-gateway-message-schema.d.ts
4
4
  declare const FlumeGatewayMessageSchema: z.ZodObject<{
5
5
  op: z.ZodNumber;
6
6
  d: z.ZodNullable<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
7
7
  s: z.ZodNullable<z.ZodNumber>;
8
8
  t: z.ZodNullable<z.ZodString>;
9
9
  }, z.core.$strip>;
10
- declare const FlumeSlackEnvelopeSchema: z.ZodObject<{
11
- envelope_id: z.ZodString;
12
- type: z.ZodString;
13
- payload: z.ZodRecord<z.ZodString, z.ZodUnknown>;
14
- accepts_response_payload: z.ZodOptional<z.ZodBoolean>;
15
- retry_attempt: z.ZodOptional<z.ZodNumber>;
16
- retry_reason: z.ZodOptional<z.ZodString>;
17
- }, z.core.$strip>;
18
- declare const FlumeSlackConnectionResponseSchema: z.ZodObject<{
19
- ok: z.ZodBoolean;
20
- url: z.ZodOptional<z.ZodString>;
21
- error: z.ZodOptional<z.ZodString>;
22
- }, z.core.$strip>;
10
+ //#endregion
11
+ //#region lib/github/github-notification-schema.d.ts
23
12
  declare const FlumeGitHubNotificationSchema: z.ZodObject<{
24
13
  id: z.ZodString;
25
14
  reason: z.ZodString;
@@ -35,6 +24,23 @@ declare const FlumeGitHubNotificationSchema: z.ZodObject<{
35
24
  }, z.core.$strip>;
36
25
  }, z.core.$strip>;
37
26
  //#endregion
27
+ //#region lib/slack/slack-connection-response-schema.d.ts
28
+ declare const FlumeSlackConnectionResponseSchema: z.ZodObject<{
29
+ ok: z.ZodBoolean;
30
+ url: z.ZodOptional<z.ZodString>;
31
+ error: z.ZodOptional<z.ZodString>;
32
+ }, z.core.$strip>;
33
+ //#endregion
34
+ //#region lib/slack/slack-envelope-schema.d.ts
35
+ declare const FlumeSlackEnvelopeSchema: z.ZodObject<{
36
+ envelope_id: z.ZodString;
37
+ type: z.ZodString;
38
+ payload: z.ZodRecord<z.ZodString, z.ZodUnknown>;
39
+ accepts_response_payload: z.ZodOptional<z.ZodBoolean>;
40
+ retry_attempt: z.ZodOptional<z.ZodNumber>;
41
+ retry_reason: z.ZodOptional<z.ZodString>;
42
+ }, z.core.$strip>;
43
+ //#endregion
38
44
  //#region lib/types.d.ts
39
45
  type FlumeTimerHandle = ReturnType<typeof setTimeout>;
40
46
  type FlumeRuntimeDeps = {
@@ -56,6 +62,24 @@ type FlumeEvent = {
56
62
  receivedAt: number;
57
63
  };
58
64
  type FlumeHandler = (event: FlumeEvent) => void | Promise<void>;
65
+ type FlumeStartOk = {
66
+ ok: true;
67
+ };
68
+ type FlumeStartErr = {
69
+ ok: false;
70
+ error: Error;
71
+ };
72
+ type FlumeStartResult = FlumeStartOk | FlumeStartErr;
73
+ type FlumeSource = {
74
+ readonly name: FlumeSourceName;
75
+ start(handler: FlumeHandler): Promise<FlumeStartResult>;
76
+ stop(): Promise<void>;
77
+ status(): FlumeStatus;
78
+ };
79
+ type FlumeSourceStatus = {
80
+ name: FlumeSourceName;
81
+ status: FlumeStatus;
82
+ };
59
83
  type FlumeStatus = "disconnected" | "connecting" | "connected" | "reconnecting";
60
84
  type FlumeStatusHandler = (status: FlumeStatus, detail?: string) => void;
61
85
  type FlumeLogLevel = "debug" | "info" | "warn" | "error";
@@ -90,7 +114,7 @@ type FlumeSourceOptions = {
90
114
  onStatus?: FlumeStatusHandler;
91
115
  onLog?: FlumeLogHandler;
92
116
  signal?: AbortSignal;
93
- deps: FlumeRuntimeDeps;
117
+ deps?: FlumeRuntimeDeps;
94
118
  };
95
119
  type FlumeDiscordSourceOptions = FlumeSourceOptions & {
96
120
  token: string;
@@ -98,7 +122,14 @@ type FlumeDiscordSourceOptions = FlumeSourceOptions & {
98
122
  };
99
123
  type FlumeSlackSourceOptions = FlumeSourceOptions & {
100
124
  appToken: string;
101
- botToken?: string;
125
+ /**
126
+ * Bot token (`xoxb-`). Required — used by the host (e.g. funnel) to call
127
+ * `auth.test` for self-detection and to post replies. Flume's Socket Mode
128
+ * transport only needs `appToken` to open the socket, but every realistic
129
+ * consumer needs the bot token too, so the type forces it to be present
130
+ * rather than leaving it optional and failing at runtime.
131
+ */
132
+ botToken: string;
102
133
  };
103
134
  type FlumeGitHubSourceOptions = FlumeSourceOptions & {
104
135
  token: string;
@@ -109,4 +140,4 @@ type FlumeSlackEnvelope = z.infer<typeof FlumeSlackEnvelopeSchema>;
109
140
  type FlumeSlackConnectionResponse = z.infer<typeof FlumeSlackConnectionResponseSchema>;
110
141
  type FlumeGitHubNotification = z.infer<typeof FlumeGitHubNotificationSchema>;
111
142
  //#endregion
112
- export { FlumeGitHubNotificationSchema as C, FlumeGatewayMessageSchema as S, FlumeSlackEnvelopeSchema as T, FlumeSourceName as _, FlumeGitHubSourceOptions as a, FlumeStatusHandler as b, FlumeLogHandler as c, FlumeReconnectConfig as d, FlumeReconnectOptions as f, FlumeSlackSourceOptions as g, FlumeSlackEnvelope as h, FlumeGitHubNotification as i, FlumeLogInput as l, FlumeSlackConnectionResponse as m, FlumeEvent as n, FlumeHandler as o, FlumeRuntimeDeps as p, FlumeGatewayMessage as r, FlumeLog as s, FlumeDiscordSourceOptions as t, FlumeLogLevel as u, FlumeSourceOptions as v, FlumeSlackConnectionResponseSchema as w, FlumeTimerHandle as x, FlumeStatus as y };
143
+ export { FlumeGatewayMessageSchema as A, FlumeStartResult as C, FlumeSlackEnvelopeSchema as D, FlumeTimerHandle as E, FlumeSlackConnectionResponseSchema as O, FlumeStartOk as S, FlumeStatusHandler as T, FlumeSource as _, FlumeGitHubSourceOptions as a, FlumeSourceStatus as b, FlumeLogHandler as c, FlumeReconnectConfig as d, FlumeReconnectOptions as f, FlumeSlackSourceOptions as g, FlumeSlackEnvelope as h, FlumeGitHubNotification as i, FlumeGitHubNotificationSchema as k, FlumeLogInput as l, FlumeSlackConnectionResponse as m, FlumeEvent as n, FlumeHandler as o, FlumeRuntimeDeps as p, FlumeGatewayMessage as r, FlumeLog as s, FlumeDiscordSourceOptions as t, FlumeLogLevel as u, FlumeSourceName as v, FlumeStatus as w, FlumeStartErr as x, FlumeSourceOptions as y };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@interactive-inc/flume",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Unified notification listener for Discord, Slack, and GitHub. Raw WebSocket + fetch + Zod. No SDK dependencies.",
5
5
  "keywords": [
6
6
  "discord",