botflare 0.2.0 β 0.2.1
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 +25 -98
- package/dist/gateway.mjs +64 -8
- package/dist/gateway.mjs.map +1 -1
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -1,22 +1,29 @@
|
|
|
1
|
+
<img src="https://raw.githubusercontent.com/pyyupsk/botflare/main/apps/docs/public/favicon.svg" width="56" height="56" alt="botflare logo" />
|
|
2
|
+
|
|
1
3
|
# botflare
|
|
2
4
|
|
|
3
|
-
[](https://www.npmjs.com/package/botflare)
|
|
6
|
+
[](https://www.npmjs.com/package/botflare)
|
|
7
|
+
[](https://github.com/pyyupsk/botflare/actions/workflows/ci.yml)
|
|
8
|
+
[](https://pyyupsk.github.io/botflare/)
|
|
9
|
+
[](https://www.typescriptlang.org)
|
|
10
|
+
[](https://workers.cloudflare.com)
|
|
11
|
+
[](https://github.com/discord/discord-api-spec)
|
|
12
|
+
[](https://github.com/pyyupsk/botflare/blob/main/LICENSE)
|
|
4
13
|
|
|
5
14
|
Build Discord bots that run on Cloudflare Workers, with types from the official Discord API spec.
|
|
6
15
|
|
|
7
|
-
[
|
|
8
|
-
|
|
9
|
-
botflare is an unofficial library. It is not affiliated with, endorsed by, or sponsored by Discord Inc. or Cloudflare, Inc.
|
|
16
|
+
[π Documentation](https://pyyupsk.github.io/botflare/) Β· [π Getting started](https://pyyupsk.github.io/botflare/guides/getting-started/) Β· [π§© API reference](https://pyyupsk.github.io/botflare/api/) Β· [π€ Example bot](https://github.com/pyyupsk/botflare/tree/main/examples/basic) Β· [π Changelog](https://github.com/pyyupsk/botflare/releases)
|
|
10
17
|
|
|
11
18
|
## Features
|
|
12
19
|
|
|
13
|
-
- Slash commands, subcommands, context
|
|
14
|
-
- Option values typed from the options you declare
|
|
15
|
-
- The Discord Gateway in a Durable Object, so a bot gets
|
|
16
|
-
- `rest` for every Discord route, with path, query, body and result typed from the
|
|
17
|
-
- Cron jobs that post to channels
|
|
18
|
-
- Messages ping nobody unless you allow it, even for `@everyone` in the text
|
|
19
|
-
- `botflare sync` sends your commands to Discord from the command line
|
|
20
|
+
- β‘ Slash commands, subcommands, context menus, buttons, select menus, modals and autocomplete
|
|
21
|
+
- π§ Option values typed from the options you declare. A `choices` option gives only its values
|
|
22
|
+
- π The Discord Gateway in a Durable Object, compressed, so a bot gets events without a public URL
|
|
23
|
+
- π°οΈ `rest` for every Discord route, with path, query, body and result typed from the spec
|
|
24
|
+
- β° Cron jobs that post to channels
|
|
25
|
+
- π Messages ping nobody unless you allow it, even for `@everyone` in the text
|
|
26
|
+
- π οΈ `botflare sync` sends your commands to Discord from the command line
|
|
20
27
|
|
|
21
28
|
## Install
|
|
22
29
|
|
|
@@ -28,7 +35,6 @@ npm install botflare
|
|
|
28
35
|
|
|
29
36
|
```ts
|
|
30
37
|
import { bot, command, option } from 'botflare';
|
|
31
|
-
import { gateway } from 'botflare/gateway';
|
|
32
38
|
|
|
33
39
|
const roll = command(
|
|
34
40
|
{
|
|
@@ -36,103 +42,24 @@ const roll = command(
|
|
|
36
42
|
description: 'Roll a die',
|
|
37
43
|
options: { sides: option('integer', { description: 'Sides on the die', min: 2, max: 100 }) },
|
|
38
44
|
},
|
|
39
|
-
(c) => {
|
|
40
|
-
const sides = c.options.sides ?? 6; // number | undefined
|
|
41
|
-
const rolled = 1 + Math.floor(Math.random() * sides);
|
|
42
|
-
|
|
43
|
-
return c.reply(`You rolled ${rolled}`, {
|
|
44
|
-
buttons: [{ id: `again:${sides}`, label: 'Roll again', emoji: 'π²' }],
|
|
45
|
-
});
|
|
46
|
-
},
|
|
45
|
+
(c) => c.reply(`You rolled ${1 + Math.floor(Math.random() * (c.options.sides ?? 6))}`),
|
|
47
46
|
);
|
|
48
47
|
|
|
49
|
-
|
|
50
|
-
commands: [roll],
|
|
51
|
-
buttons: {
|
|
52
|
-
again: (c) => c.update(`You rolled ${1 + Math.floor(Math.random() * Number(c.value))}`),
|
|
53
|
-
},
|
|
54
|
-
gateway: 'GATEWAY',
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
export const Gateway = gateway({ intents: ['Guilds'], bot: app, events: {} });
|
|
58
|
-
|
|
59
|
-
export default app;
|
|
48
|
+
export default bot({ commands: [roll] });
|
|
60
49
|
```
|
|
61
50
|
|
|
62
|
-
Bind the `Gateway` class in `wrangler.jsonc`:
|
|
63
|
-
|
|
64
|
-
```jsonc
|
|
65
|
-
{
|
|
66
|
-
"durable_objects": { "bindings": [{ "name": "GATEWAY", "class_name": "Gateway" }] },
|
|
67
|
-
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["Gateway"] }],
|
|
68
|
-
}
|
|
69
|
-
```
|
|
70
|
-
|
|
71
|
-
Then run it:
|
|
72
|
-
|
|
73
51
|
```sh
|
|
74
52
|
npx botflare sync src/index.ts # send the commands to Discord
|
|
75
|
-
npx wrangler
|
|
53
|
+
npx wrangler deploy # then set the Interactions Endpoint URL to the Worker
|
|
76
54
|
```
|
|
77
55
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
## API
|
|
81
|
-
|
|
82
|
-
| Export | Use |
|
|
83
|
-
| ------------------------------------- | ----------------------------------------------------------------------------------------- |
|
|
84
|
-
| `bot(config)` | The Worker handler. Checks signatures and answers commands, buttons, menus and modals |
|
|
85
|
-
| `command(config, run)` | A slash command, or a command with `subcommands` |
|
|
86
|
-
| `option(type, config)` | An option of a command, with `choices`, limits or `autocomplete` |
|
|
87
|
-
| `contextMenu('user' \| 'message', β¦)` | An item in the Apps menu of a user or a message |
|
|
88
|
-
| `select(type, config, run)` | A select menu of strings, users, roles, channels or mentionables |
|
|
89
|
-
| `modal(config, run)` | A form in a popup |
|
|
90
|
-
| `gateway(config)` | A Durable Object class that holds the Discord Gateway connection, from `botflare/gateway` |
|
|
91
|
-
| `rest` | Calls any Discord route as the bot and returns `[error, data]` |
|
|
92
|
-
| `mention`, `time` | Mentions and timestamps for message text |
|
|
93
|
-
|
|
94
|
-
A handler answers with one of these:
|
|
95
|
-
|
|
96
|
-
| Answer | What Discord shows |
|
|
97
|
-
| ------------------------------ | --------------------------------------------------------------------------------- |
|
|
98
|
-
| `c.reply(text, options)` | A new message |
|
|
99
|
-
| `c.defer(work, { ephemeral })` | "Thinkingβ¦", then the result of `work`, for work over 3 seconds |
|
|
100
|
-
| `c.update(text, options)` | The message of the button or menu, edited |
|
|
101
|
-
| `c.deferUpdate(work)` | The message of the button or menu as it is, then edited with the result of `work` |
|
|
102
|
-
| `c.modal(form, value)` | A form, with `value` back in `c.value` |
|
|
103
|
-
|
|
104
|
-
Message options are `buttons`, `selects`, `embeds`, `allowedMentions` and `ephemeral`. A command takes `permissions`, `contexts`, `installs` and `locales`. Every config has its description in the editor.
|
|
105
|
-
|
|
106
|
-
## Gateway events
|
|
107
|
-
|
|
108
|
-
```ts
|
|
109
|
-
export const Gateway = gateway({
|
|
110
|
-
intents: ['Guilds', 'GuildMessages', 'MessageContent'],
|
|
111
|
-
bot: app,
|
|
112
|
-
events: {
|
|
113
|
-
MESSAGE_CREATE: (message, c) => message.content === 'hi' && c.send(message.channel_id, 'hello'),
|
|
114
|
-
},
|
|
115
|
-
});
|
|
116
|
-
```
|
|
117
|
-
|
|
118
|
-
Turn on privileged intents, such as `MessageContent`, in the Developer Portal first. If the connection is down, a Durable Object alarm opens it again within a minute.
|
|
119
|
-
|
|
120
|
-
## REST
|
|
121
|
-
|
|
122
|
-
```ts
|
|
123
|
-
import { rest } from 'botflare';
|
|
124
|
-
|
|
125
|
-
const [error, member] = await rest.get('/guilds/{guild_id}/members/{user_id}', {
|
|
126
|
-
path: { guild_id, user_id },
|
|
127
|
-
});
|
|
128
|
-
member?.joined_at; // typed from the Discord API spec
|
|
129
|
-
```
|
|
130
|
-
|
|
131
|
-
On a 429, `rest` waits for `retry_after` and tries again, up to three tries in all.
|
|
56
|
+
The guides cover [answers](https://pyyupsk.github.io/botflare/guides/answers/), [components](https://pyyupsk.github.io/botflare/guides/components/), [gateway events](https://pyyupsk.github.io/botflare/guides/gateway-events/) and [REST](https://pyyupsk.github.io/botflare/guides/rest/). They also cover [cron](https://pyyupsk.github.io/botflare/guides/cron/), [deploy](https://pyyupsk.github.io/botflare/guides/deploy/) and a bot without a public URL.
|
|
132
57
|
|
|
133
58
|
## Why?
|
|
134
59
|
|
|
135
|
-
discord.js and discord.py expect a process that stays on, with a WebSocket and a cache in memory. A Worker runs per request and then stops. botflare
|
|
60
|
+
discord.js and discord.py expect a process that stays on, with a WebSocket and a cache in memory. A Worker runs per request and then stops. botflare answers interactions over HTTP, keeps the gateway connection in a Durable Object, and takes its REST types from the spec that Discord publishes.
|
|
61
|
+
|
|
62
|
+
botflare is an unofficial library. It is not affiliated with, endorsed by, or sponsored by Discord Inc. or Cloudflare, Inc.
|
|
136
63
|
|
|
137
64
|
## License
|
|
138
65
|
|
package/dist/gateway.mjs
CHANGED
|
@@ -1,5 +1,52 @@
|
|
|
1
1
|
import { i as sendMessage, n as interactionCallback } from "./discord-DEZe1asd.mjs";
|
|
2
2
|
import { DurableObject } from "cloudflare:workers";
|
|
3
|
+
// src/utils/inflate.ts
|
|
4
|
+
async function quietly(promise) {
|
|
5
|
+
try {
|
|
6
|
+
await promise;
|
|
7
|
+
} catch {}
|
|
8
|
+
}
|
|
9
|
+
function inflater(onPayload, onError) {
|
|
10
|
+
const stream = new DecompressionStream("deflate");
|
|
11
|
+
const writer = stream.writable.getWriter();
|
|
12
|
+
let open = true;
|
|
13
|
+
let buffer = "";
|
|
14
|
+
let depth = 0;
|
|
15
|
+
let inString = false;
|
|
16
|
+
let escaped = false;
|
|
17
|
+
const split = (text) => {
|
|
18
|
+
let from = 0;
|
|
19
|
+
for (let i = 0; i < text.length; i++) {
|
|
20
|
+
const char = text[i];
|
|
21
|
+
if (inString) {
|
|
22
|
+
if (escaped) escaped = false;
|
|
23
|
+
else if (char === "\\") escaped = true;
|
|
24
|
+
else if (char === "\"") inString = false;
|
|
25
|
+
} else if (char === "\"") inString = true;
|
|
26
|
+
else if (char === "{") depth++;
|
|
27
|
+
else if (char === "}" && --depth === 0) {
|
|
28
|
+
onPayload(buffer + text.slice(from, i + 1));
|
|
29
|
+
buffer = "";
|
|
30
|
+
from = i + 1;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
buffer += text.slice(from);
|
|
34
|
+
};
|
|
35
|
+
(async () => {
|
|
36
|
+
try {
|
|
37
|
+
for await (const text of stream.readable.pipeThrough(new TextDecoderStream())) split(text);
|
|
38
|
+
} catch (error) {
|
|
39
|
+
if (open) onError(error);
|
|
40
|
+
}
|
|
41
|
+
})();
|
|
42
|
+
return {
|
|
43
|
+
push: (data) => void quietly(writer.write(new Uint8Array(data))),
|
|
44
|
+
close: () => {
|
|
45
|
+
open = false;
|
|
46
|
+
quietly(writer.abort());
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
}
|
|
3
50
|
// src/utils/intents.ts
|
|
4
51
|
const BITS = {
|
|
5
52
|
Guilds: 1,
|
|
@@ -182,6 +229,7 @@ function gateway(config) {
|
|
|
182
229
|
return class Gateway extends DurableObject {
|
|
183
230
|
ws;
|
|
184
231
|
shard;
|
|
232
|
+
inflate;
|
|
185
233
|
stopped = false;
|
|
186
234
|
async ensure() {
|
|
187
235
|
if (!await this.ctx.storage.getAlarm()) await this.ctx.storage.setAlarm(Date.now() + WATCH_MS);
|
|
@@ -197,16 +245,17 @@ function gateway(config) {
|
|
|
197
245
|
};
|
|
198
246
|
const stored = this.ctx.storage.kv.get("session");
|
|
199
247
|
const saved = stored?.intents === identity.intents ? stored : null;
|
|
200
|
-
const ws = new WebSocket(`${saved?.url ?? GATEWAY}/?v=10&encoding=json`);
|
|
248
|
+
const ws = new WebSocket(`${saved?.url ?? GATEWAY}/?v=10&encoding=json&compress=zlib-stream`);
|
|
201
249
|
const send = (channelId, content, options) => sendMessage(this.env.DISCORD_TOKEN, channelId, content, options);
|
|
250
|
+
const reconnect = (resume) => {
|
|
251
|
+
if (this.ws !== ws) return;
|
|
252
|
+
this.drop();
|
|
253
|
+
ws.close(4e3);
|
|
254
|
+
this.after(resume ? "resume" : "identify");
|
|
255
|
+
};
|
|
202
256
|
const shard = new Shard(this.env.DISCORD_TOKEN, identity, saved, {
|
|
203
257
|
send: (payload) => ws.send(JSON.stringify(payload)),
|
|
204
|
-
reconnect
|
|
205
|
-
if (this.ws !== ws) return;
|
|
206
|
-
this.drop();
|
|
207
|
-
ws.close(4e3);
|
|
208
|
-
this.after(resume ? "resume" : "identify");
|
|
209
|
-
},
|
|
258
|
+
reconnect,
|
|
210
259
|
save: (session) => session ? this.ctx.storage.kv.put("session", session) : this.ctx.storage.kv.delete("session"),
|
|
211
260
|
dispatch: (event) => {
|
|
212
261
|
if (event.t === INTERACTION_CREATE && config.bot) {
|
|
@@ -254,10 +303,15 @@ function gateway(config) {
|
|
|
254
303
|
};
|
|
255
304
|
}
|
|
256
305
|
});
|
|
306
|
+
const inflate = inflater((json) => {
|
|
307
|
+
if (this.ws === ws) shard.handle(JSON.parse(json));
|
|
308
|
+
}, () => reconnect(true));
|
|
257
309
|
this.ws = ws;
|
|
258
310
|
this.shard = shard;
|
|
311
|
+
this.inflate = inflate;
|
|
312
|
+
ws.binaryType = "arraybuffer";
|
|
259
313
|
ws.addEventListener("message", (event) => {
|
|
260
|
-
if (this.ws === ws)
|
|
314
|
+
if (this.ws === ws) inflate.push(event.data);
|
|
261
315
|
});
|
|
262
316
|
ws.addEventListener("close", (event) => {
|
|
263
317
|
if (this.ws !== ws) return;
|
|
@@ -268,6 +322,8 @@ function gateway(config) {
|
|
|
268
322
|
drop() {
|
|
269
323
|
this.shard?.stop();
|
|
270
324
|
this.shard = void 0;
|
|
325
|
+
this.inflate?.close();
|
|
326
|
+
this.inflate = void 0;
|
|
271
327
|
this.ws = void 0;
|
|
272
328
|
}
|
|
273
329
|
after(next) {
|
package/dist/gateway.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"gateway.mjs","names":[],"sources":["../src/utils/intents.ts","../src/utils/shard.ts","../src/gateway.ts"],"sourcesContent":["import type { GatewayIntentBits } from 'discord-api-types/v10';\n\nexport type Intent = Exclude<\n keyof typeof GatewayIntentBits,\n 'GuildBans' | 'GuildEmojisAndStickers'\n>;\n\nconst BITS: Record<Intent, GatewayIntentBits> = {\n Guilds: 1,\n GuildMembers: 2,\n GuildModeration: 4,\n GuildExpressions: 8,\n GuildIntegrations: 16,\n GuildWebhooks: 32,\n GuildInvites: 64,\n GuildVoiceStates: 128,\n GuildPresences: 256,\n GuildMessages: 512,\n GuildMessageReactions: 1024,\n GuildMessageTyping: 2048,\n DirectMessages: 4096,\n DirectMessageReactions: 8192,\n DirectMessageTyping: 16384,\n MessageContent: 32768,\n GuildScheduledEvents: 65536,\n AutoModerationConfiguration: 1048576,\n AutoModerationExecution: 2097152,\n GuildMessagePolls: 16777216,\n DirectMessagePolls: 33554432,\n};\n\nexport function intentBits(names: Intent[]): number {\n return names.reduce((bits, name) => bits | BITS[name], 0);\n}\n","import type {\n GatewayCloseCodes,\n GatewayDispatchEvents,\n GatewayDispatchPayload,\n GatewayOpcodes,\n GatewayPresenceUpdateData,\n GatewayReceivePayload,\n GatewaySendPayload,\n} from 'discord-api-types/v10';\n\nexport type Session = { id: string; url: string; seq: number | null; intents: number };\n\nconst DISPATCH: GatewayOpcodes.Dispatch = 0;\nconst HEARTBEAT: GatewayOpcodes.Heartbeat = 1;\nconst IDENTIFY: GatewayOpcodes.Identify = 2;\nconst PRESENCE_UPDATE: GatewayOpcodes.PresenceUpdate = 3;\nconst RESUME: GatewayOpcodes.Resume = 6;\nconst RECONNECT: GatewayOpcodes.Reconnect = 7;\nconst INVALID_SESSION: GatewayOpcodes.InvalidSession = 9;\nconst HELLO: GatewayOpcodes.Hello = 10;\nconst HEARTBEAT_ACK: GatewayOpcodes.HeartbeatAck = 11;\n\nconst READY = 'READY' as GatewayDispatchEvents.Ready;\nconst RESUMED = 'RESUMED' as GatewayDispatchEvents.Resumed;\n\nconst FATAL = new Set<GatewayCloseCodes>([4004, 4010, 4011, 4012, 4013, 4014]);\nconst FRESH = new Set<GatewayCloseCodes>([4007, 4009]);\n\nexport function afterClose(code: number): 'stop' | 'identify' | 'resume' {\n if (FATAL.has(code)) return 'stop';\n\n return FRESH.has(code) ? 'identify' : 'resume';\n}\n\ntype Identity = { intents: number; presence?: GatewayPresenceUpdateData };\n\ntype Hooks = {\n send: (payload: GatewaySendPayload) => void;\n reconnect: (resume: boolean) => void;\n save: (session: Session | null) => void;\n dispatch: (event: GatewayDispatchPayload) => void;\n every: (ms: number, fn: () => void) => () => void;\n};\n\nexport class Shard {\n private readonly token: string;\n private readonly identity: Identity;\n private readonly hooks: Hooks;\n private session: Session | null;\n private acked = true;\n private stopBeat: (() => void) | undefined;\n\n constructor(token: string, identity: Identity, session: Session | null, hooks: Hooks) {\n this.token = token;\n this.identity = identity;\n this.session = session;\n this.hooks = hooks;\n }\n\n handle(payload: GatewayReceivePayload): void {\n switch (payload.op) {\n case HELLO: {\n this.acked = true;\n this.stopBeat?.();\n this.stopBeat = this.hooks.every(payload.d.heartbeat_interval, () => this.beat());\n\n if (this.session) {\n this.hooks.send({\n op: RESUME,\n d: { token: this.token, session_id: this.session.id, seq: this.session.seq ?? 0 },\n });\n } else {\n this.identify();\n }\n return;\n }\n case HEARTBEAT_ACK:\n this.acked = true;\n return;\n case HEARTBEAT:\n this.hooks.send({ op: HEARTBEAT, d: this.session?.seq ?? null });\n return;\n case RECONNECT:\n this.hooks.reconnect(true);\n return;\n case INVALID_SESSION:\n if (!payload.d) this.forget();\n this.hooks.reconnect(payload.d);\n return;\n case DISPATCH:\n this.onDispatch(payload);\n }\n }\n\n stop(): void {\n this.stopBeat?.();\n this.stopBeat = undefined;\n }\n\n private forget(): void {\n this.session = null;\n this.hooks.save(null);\n }\n\n private onDispatch(payload: GatewayDispatchPayload): void {\n if (payload.t === READY) {\n this.session = {\n id: payload.d.session_id,\n url: payload.d.resume_gateway_url,\n seq: payload.s,\n intents: this.identity.intents,\n };\n } else if (this.session) {\n this.session.seq = payload.s;\n }\n if (this.session) this.hooks.save(this.session);\n\n if (payload.t === RESUMED && this.identity.presence) {\n this.hooks.send({ op: PRESENCE_UPDATE, d: this.identity.presence });\n }\n\n this.hooks.dispatch(payload);\n }\n\n private beat(): void {\n if (!this.acked) {\n this.hooks.reconnect(true);\n return;\n }\n\n this.acked = false;\n this.hooks.send({ op: HEARTBEAT, d: this.session?.seq ?? null });\n }\n\n private identify(): void {\n this.hooks.send({\n op: IDENTIFY,\n d: {\n token: this.token,\n intents: this.identity.intents,\n properties: { os: 'cloudflare-workers', browser: 'botflare', device: 'botflare' },\n ...(this.identity.presence && { presence: this.identity.presence }),\n },\n });\n }\n}\n","/** @module botflare/gateway */\nimport { DurableObject } from 'cloudflare:workers';\nimport type {\n GatewayDispatchEvents,\n GatewayDispatchPayload,\n GatewayPresenceUpdateData,\n GatewayReceivePayload,\n} from 'discord-api-types/v10';\n\nimport type { Bot } from '#bot';\nimport { interactionCallback, type Send, sendMessage } from '#lib/discord';\nimport type { ErrorHandler } from '#types';\nimport { type Intent, intentBits } from '#utils/intents';\nimport { afterClose, type Session, Shard } from '#utils/shard';\n\nconst GATEWAY = 'wss://gateway.discord.gg';\nconst WATCH_MS = 60_000;\nconst INTERACTION_CREATE = 'INTERACTION_CREATE' as GatewayDispatchEvents.InteractionCreate;\n\n/** What a gateway event handler gets besides the event data. */\nexport type EventContext = {\n /** Posts a message to a channel, with optional `buttons` and `embeds`. */\n send: Send;\n /**\n * The storage of the Durable Object that holds the connection, such as `storage.kv.get(key)`.\n * The connection keeps its own state under the `session` key, so leave that key alone.\n */\n storage: DurableObjectStorage;\n};\n\ntype GatewayConfig = {\n /**\n * The names of the gateway intents the bot needs, such as `'GuildMessages'`. Turn on privileged\n * ones, such as `'MessageContent'`, in the Developer Portal first.\n */\n intents: Intent[];\n /** The status the bot shows while it is connected. */\n presence?: GatewayPresenceUpdateData;\n /**\n * The bot from `bot()`. Its handlers answer the commands, buttons and modals that arrive over the\n * gateway, so the application needs no Interactions Endpoint URL.\n */\n bot?: Pick<Bot, 'answer'>;\n /** Gets errors from event handlers and from answering interactions. */\n onError?: ErrorHandler;\n /**\n * A handler per dispatch event name, such as `MESSAGE_CREATE`. `c.send` posts a message to a\n * channel, with optional `buttons` and `embeds`. `c.storage` keeps state between events.\n */\n events: {\n [P in GatewayDispatchPayload as P['t']]?: (data: P['d'], c: EventContext) => unknown;\n };\n};\n\n/**\n * Creates a Durable Object class that keeps one connection to the Discord Gateway open and runs your\n * handler for each event. Export it under the `class_name` of a SQLite Durable Object binding in your\n * Wrangler config, and give that binding name to `bot({ gateway })` so a Cron Trigger starts and\n * reconnects it.\n * It reads `DISCORD_TOKEN` from the Worker environment.\n *\n * @param config What the bot listens to.\n * @returns The Durable Object class.\n * @example\n * import { bot } from 'botflare';\n * import { gateway } from 'botflare/gateway';\n *\n * const app = bot({ commands: [], gateway: 'GATEWAY' });\n *\n * export const Gateway = gateway({\n * intents: ['Guilds', 'GuildMessages'],\n * bot: app,\n * events: { MESSAGE_CREATE: (message, c) => c.send(message.channel_id, 'heard you') },\n * });\n *\n * export default app;\n */\nexport function gateway(\n config: GatewayConfig,\n): new (\n ctx: DurableObjectState,\n env: { DISCORD_TOKEN: string },\n) => DurableObject<{ DISCORD_TOKEN: string }> & { ensure(): Promise<void> } {\n return class Gateway extends DurableObject<{ DISCORD_TOKEN: string }> {\n private ws: WebSocket | undefined;\n private shard: Shard | undefined;\n private stopped = false;\n\n async ensure(): Promise<void> {\n if (!(await this.ctx.storage.getAlarm()))\n await this.ctx.storage.setAlarm(Date.now() + WATCH_MS);\n\n if (!this.ws && !this.stopped) this.dial();\n }\n\n override async alarm(): Promise<void> {\n await this.ensure();\n }\n\n private dial(): void {\n const identity = { intents: intentBits(config.intents), presence: config.presence };\n const stored = this.ctx.storage.kv.get<Session>('session');\n const saved = stored?.intents === identity.intents ? stored : null;\n\n const ws = new WebSocket(`${saved?.url ?? GATEWAY}/?v=10&encoding=json`);\n const send: Send = (channelId, content, options) =>\n sendMessage(this.env.DISCORD_TOKEN, channelId, content, options);\n\n const shard = new Shard(this.env.DISCORD_TOKEN, identity, saved, {\n send: (payload) => ws.send(JSON.stringify(payload)),\n reconnect: (resume) => {\n if (this.ws !== ws) return;\n\n this.drop();\n ws.close(4000);\n this.after(resume ? 'resume' : 'identify');\n },\n save: (session) =>\n session\n ? this.ctx.storage.kv.put('session', session)\n : this.ctx.storage.kv.delete('session'),\n dispatch: (event) => {\n if (event.t === INTERACTION_CREATE && config.bot) {\n const { answer } = config.bot;\n const respond = async (): Promise<void> => {\n try {\n const after: (() => Promise<void>)[] = [];\n const response = await answer(event.d, {\n waitUntil: (promise) => this.ctx.waitUntil(promise),\n afterAnswer: (task) => after.push(task),\n });\n if (!response.ok) return;\n\n const failed = await interactionCallback(event.d.id, event.d.token, response);\n if (failed) await config.onError?.(failed, event.t);\n else for (const task of after) this.ctx.waitUntil(task());\n } catch (error) {\n await config.onError?.(error, event.t);\n }\n };\n this.ctx.waitUntil(respond());\n }\n\n const handler = config.events[event.t] as\n | ((data: unknown, c: EventContext) => unknown)\n | undefined;\n if (!handler) return;\n\n const run = async (): Promise<void> => {\n try {\n await handler(event.d, { send, storage: this.ctx.storage });\n } catch (error) {\n await config.onError?.(error, event.t);\n }\n };\n this.ctx.waitUntil(run());\n },\n every: (ms, fn) => {\n let interval: ReturnType<typeof setInterval> | null = null;\n const first = setTimeout(() => {\n fn();\n interval = setInterval(fn, ms);\n }, ms * Math.random());\n\n return () => {\n clearTimeout(first);\n clearInterval(interval);\n };\n },\n });\n\n this.ws = ws;\n this.shard = shard;\n\n ws.addEventListener('message', (event) => {\n if (this.ws === ws) shard.handle(JSON.parse(String(event.data)) as GatewayReceivePayload);\n });\n ws.addEventListener('close', (event) => {\n if (this.ws !== ws) return;\n\n this.drop();\n this.after(afterClose(event.code));\n });\n }\n\n private drop(): void {\n this.shard?.stop();\n this.shard = undefined;\n this.ws = undefined;\n }\n\n private after(next: 'stop' | 'identify' | 'resume'): void {\n if (next === 'stop') {\n this.stopped = true;\n return;\n }\n\n if (next === 'identify') this.ctx.storage.kv.delete('session');\n setTimeout(() => this.dial(), 1000 + Math.random() * 4000);\n }\n };\n}\n"],"mappings":";;;AAOA,MAAM,OAA0C;CAC9C,QAAQ;CACR,cAAc;CACd,iBAAiB;CACjB,kBAAkB;CAClB,mBAAmB;CACnB,eAAe;CACf,cAAc;CACd,kBAAkB;CAClB,gBAAgB;CAChB,eAAe;CACf,uBAAuB;CACvB,oBAAoB;CACpB,gBAAgB;CAChB,wBAAwB;CACxB,qBAAqB;CACrB,gBAAgB;CAChB,sBAAsB;CACtB,6BAA6B;CAC7B,yBAAyB;CACzB,mBAAmB;CACnB,oBAAoB;AACtB;AAEA,SAAgB,WAAW,OAAyB;CAClD,OAAO,MAAM,QAAQ,MAAM,SAAS,OAAO,KAAK,OAAO,CAAC;AAC1D;;ACrBA,MAAM,WAAoC;AAC1C,MAAM,YAAsC;AAC5C,MAAM,WAAoC;AAC1C,MAAM,kBAAiD;AACvD,MAAM,SAAgC;AACtC,MAAM,YAAsC;AAC5C,MAAM,kBAAiD;AACvD,MAAM,QAA8B;AACpC,MAAM,gBAA6C;AAEnD,MAAM,QAAQ;AACd,MAAM,UAAU;AAEhB,MAAM,wBAAQ,IAAI,IAAuB;CAAC;CAAM;CAAM;CAAM;CAAM;CAAM;AAAI,CAAC;AAC7E,MAAM,wBAAQ,IAAI,IAAuB,CAAC,MAAM,IAAI,CAAC;AAErD,SAAgB,WAAW,MAA8C;CACvE,IAAI,MAAM,IAAI,IAAI,GAAG,OAAO;CAE5B,OAAO,MAAM,IAAI,IAAI,IAAI,aAAa;AACxC;AAYA,IAAa,QAAb,MAAmB;CACjB;CACA;CACA;CACA;CACA,QAAgB;CAChB;CAEA,YAAY,OAAe,UAAoB,SAAyB,OAAc;EACpF,KAAK,QAAQ;EACb,KAAK,WAAW;EAChB,KAAK,UAAU;EACf,KAAK,QAAQ;CACf;CAEA,OAAO,SAAsC;EAC3C,QAAQ,QAAQ,IAAhB;GACE,KAAK;IACH,KAAK,QAAQ;IACb,KAAK,WAAW;IAChB,KAAK,WAAW,KAAK,MAAM,MAAM,QAAQ,EAAE,0BAA0B,KAAK,KAAK,CAAC;IAEhF,IAAI,KAAK,SACP,KAAK,MAAM,KAAK;KACd,IAAI;KACJ,GAAG;MAAE,OAAO,KAAK;MAAO,YAAY,KAAK,QAAQ;MAAI,KAAK,KAAK,QAAQ,OAAO;KAAE;IAClF,CAAC;SAED,KAAK,SAAS;IAEhB;GAEF,KAAK;IACH,KAAK,QAAQ;IACb;GACF,KAAK;IACH,KAAK,MAAM,KAAK;KAAE,IAAI;KAAW,GAAG,KAAK,SAAS,OAAO;IAAK,CAAC;IAC/D;GACF,KAAK;IACH,KAAK,MAAM,UAAU,IAAI;IACzB;GACF,KAAK;IACH,IAAI,CAAC,QAAQ,GAAG,KAAK,OAAO;IAC5B,KAAK,MAAM,UAAU,QAAQ,CAAC;IAC9B;GACF,KAAK,UACH,KAAK,WAAW,OAAO;EAC3B;CACF;CAEA,OAAa;EACX,KAAK,WAAW;EAChB,KAAK,WAAW,KAAA;CAClB;CAEA,SAAuB;EACrB,KAAK,UAAU;EACf,KAAK,MAAM,KAAK,IAAI;CACtB;CAEA,WAAmB,SAAuC;EACxD,IAAI,QAAQ,MAAM,OAChB,KAAK,UAAU;GACb,IAAI,QAAQ,EAAE;GACd,KAAK,QAAQ,EAAE;GACf,KAAK,QAAQ;GACb,SAAS,KAAK,SAAS;EACzB;OACK,IAAI,KAAK,SACd,KAAK,QAAQ,MAAM,QAAQ;EAE7B,IAAI,KAAK,SAAS,KAAK,MAAM,KAAK,KAAK,OAAO;EAE9C,IAAI,QAAQ,MAAM,WAAW,KAAK,SAAS,UACzC,KAAK,MAAM,KAAK;GAAE,IAAI;GAAiB,GAAG,KAAK,SAAS;EAAS,CAAC;EAGpE,KAAK,MAAM,SAAS,OAAO;CAC7B;CAEA,OAAqB;EACnB,IAAI,CAAC,KAAK,OAAO;GACf,KAAK,MAAM,UAAU,IAAI;GACzB;EACF;EAEA,KAAK,QAAQ;EACb,KAAK,MAAM,KAAK;GAAE,IAAI;GAAW,GAAG,KAAK,SAAS,OAAO;EAAK,CAAC;CACjE;CAEA,WAAyB;EACvB,KAAK,MAAM,KAAK;GACd,IAAI;GACJ,GAAG;IACD,OAAO,KAAK;IACZ,SAAS,KAAK,SAAS;IACvB,YAAY;KAAE,IAAI;KAAsB,SAAS;KAAY,QAAQ;IAAW;IAChF,GAAI,KAAK,SAAS,YAAY,EAAE,UAAU,KAAK,SAAS,SAAS;GACnE;EACF,CAAC;CACH;AACF;;;AClIA,MAAM,UAAU;AAChB,MAAM,WAAW;AACjB,MAAM,qBAAqB;;;;;;;;;;;;;;;;;;;;;;;;AA4D3B,SAAgB,QACd,QAI0E;CAC1E,OAAO,MAAM,gBAAgB,cAAyC;EACpE;EACA;EACA,UAAkB;EAElB,MAAM,SAAwB;GAC5B,IAAI,CAAE,MAAM,KAAK,IAAI,QAAQ,SAAS,GACpC,MAAM,KAAK,IAAI,QAAQ,SAAS,KAAK,IAAI,IAAI,QAAQ;GAEvD,IAAI,CAAC,KAAK,MAAM,CAAC,KAAK,SAAS,KAAK,KAAK;EAC3C;EAEA,MAAe,QAAuB;GACpC,MAAM,KAAK,OAAO;EACpB;EAEA,OAAqB;GACnB,MAAM,WAAW;IAAE,SAAS,WAAW,OAAO,OAAO;IAAG,UAAU,OAAO;GAAS;GAClF,MAAM,SAAS,KAAK,IAAI,QAAQ,GAAG,IAAa,SAAS;GACzD,MAAM,QAAQ,QAAQ,YAAY,SAAS,UAAU,SAAS;GAE9D,MAAM,KAAK,IAAI,UAAU,GAAG,OAAO,OAAO,QAAQ,qBAAqB;GACvE,MAAM,QAAc,WAAW,SAAS,YACtC,YAAY,KAAK,IAAI,eAAe,WAAW,SAAS,OAAO;GAEjE,MAAM,QAAQ,IAAI,MAAM,KAAK,IAAI,eAAe,UAAU,OAAO;IAC/D,OAAO,YAAY,GAAG,KAAK,KAAK,UAAU,OAAO,CAAC;IAClD,YAAY,WAAW;KACrB,IAAI,KAAK,OAAO,IAAI;KAEpB,KAAK,KAAK;KACV,GAAG,MAAM,GAAI;KACb,KAAK,MAAM,SAAS,WAAW,UAAU;IAC3C;IACA,OAAO,YACL,UACI,KAAK,IAAI,QAAQ,GAAG,IAAI,WAAW,OAAO,IAC1C,KAAK,IAAI,QAAQ,GAAG,OAAO,SAAS;IAC1C,WAAW,UAAU;KACnB,IAAI,MAAM,MAAM,sBAAsB,OAAO,KAAK;MAChD,MAAM,EAAE,WAAW,OAAO;MAC1B,MAAM,UAAU,YAA2B;OACzC,IAAI;QACF,MAAM,QAAiC,CAAC;QACxC,MAAM,WAAW,MAAM,OAAO,MAAM,GAAG;SACrC,YAAY,YAAY,KAAK,IAAI,UAAU,OAAO;SAClD,cAAc,SAAS,MAAM,KAAK,IAAI;QACxC,CAAC;QACD,IAAI,CAAC,SAAS,IAAI;QAElB,MAAM,SAAS,MAAM,oBAAoB,MAAM,EAAE,IAAI,MAAM,EAAE,OAAO,QAAQ;QAC5E,IAAI,QAAQ,MAAM,OAAO,UAAU,QAAQ,MAAM,CAAC;aAC7C,KAAK,MAAM,QAAQ,OAAO,KAAK,IAAI,UAAU,KAAK,CAAC;OAC1D,SAAS,OAAO;QACd,MAAM,OAAO,UAAU,OAAO,MAAM,CAAC;OACvC;MACF;MACA,KAAK,IAAI,UAAU,QAAQ,CAAC;KAC9B;KAEA,MAAM,UAAU,OAAO,OAAO,MAAM;KAGpC,IAAI,CAAC,SAAS;KAEd,MAAM,MAAM,YAA2B;MACrC,IAAI;OACF,MAAM,QAAQ,MAAM,GAAG;QAAE;QAAM,SAAS,KAAK,IAAI;OAAQ,CAAC;MAC5D,SAAS,OAAO;OACd,MAAM,OAAO,UAAU,OAAO,MAAM,CAAC;MACvC;KACF;KACA,KAAK,IAAI,UAAU,IAAI,CAAC;IAC1B;IACA,QAAQ,IAAI,OAAO;KACjB,IAAI,WAAkD;KACtD,MAAM,QAAQ,iBAAiB;MAC7B,GAAG;MACH,WAAW,YAAY,IAAI,EAAE;KAC/B,GAAG,KAAK,KAAK,OAAO,CAAC;KAErB,aAAa;MACX,aAAa,KAAK;MAClB,cAAc,QAAQ;KACxB;IACF;GACF,CAAC;GAED,KAAK,KAAK;GACV,KAAK,QAAQ;GAEb,GAAG,iBAAiB,YAAY,UAAU;IACxC,IAAI,KAAK,OAAO,IAAI,MAAM,OAAO,KAAK,MAAM,OAAO,MAAM,IAAI,CAAC,CAA0B;GAC1F,CAAC;GACD,GAAG,iBAAiB,UAAU,UAAU;IACtC,IAAI,KAAK,OAAO,IAAI;IAEpB,KAAK,KAAK;IACV,KAAK,MAAM,WAAW,MAAM,IAAI,CAAC;GACnC,CAAC;EACH;EAEA,OAAqB;GACnB,KAAK,OAAO,KAAK;GACjB,KAAK,QAAQ,KAAA;GACb,KAAK,KAAK,KAAA;EACZ;EAEA,MAAc,MAA4C;GACxD,IAAI,SAAS,QAAQ;IACnB,KAAK,UAAU;IACf;GACF;GAEA,IAAI,SAAS,YAAY,KAAK,IAAI,QAAQ,GAAG,OAAO,SAAS;GAC7D,iBAAiB,KAAK,KAAK,GAAG,MAAO,KAAK,OAAO,IAAI,GAAI;EAC3D;CACF;AACF"}
|
|
1
|
+
{"version":3,"file":"gateway.mjs","names":[],"sources":["../src/utils/inflate.ts","../src/utils/intents.ts","../src/utils/shard.ts","../src/gateway.ts"],"sourcesContent":["// The reader in `inflater` already reports the error.\nasync function quietly(promise: Promise<unknown>): Promise<void> {\n try {\n await promise;\n } catch {}\n}\n\n// zlib-stream shares one context per connection, and decoded chunks do not line up with payloads.\nexport function inflater(\n onPayload: (json: string) => void,\n onError: (error: unknown) => void,\n): { push: (data: ArrayBuffer) => void; close: () => void } {\n const stream = new DecompressionStream('deflate');\n const writer = stream.writable.getWriter();\n let open = true;\n let buffer = '';\n let depth = 0;\n let inString = false;\n let escaped = false;\n\n const split = (text: string): void => {\n let from = 0;\n for (let i = 0; i < text.length; i++) {\n const char = text[i];\n if (inString) {\n if (escaped) escaped = false;\n else if (char === '\\\\') escaped = true;\n else if (char === '\"') inString = false;\n } else if (char === '\"') inString = true;\n else if (char === '{') depth++;\n else if (char === '}' && --depth === 0) {\n onPayload(buffer + text.slice(from, i + 1));\n buffer = '';\n from = i + 1;\n }\n }\n buffer += text.slice(from);\n };\n\n void (async () => {\n try {\n for await (const text of stream.readable.pipeThrough(new TextDecoderStream())) split(text);\n } catch (error) {\n if (open) onError(error);\n }\n })();\n\n return {\n push: (data) => void quietly(writer.write(new Uint8Array(data))),\n close: () => {\n open = false;\n void quietly(writer.abort());\n },\n };\n}\n","import type { GatewayIntentBits } from 'discord-api-types/v10';\n\nexport type Intent = Exclude<\n keyof typeof GatewayIntentBits,\n 'GuildBans' | 'GuildEmojisAndStickers'\n>;\n\nconst BITS: Record<Intent, GatewayIntentBits> = {\n Guilds: 1,\n GuildMembers: 2,\n GuildModeration: 4,\n GuildExpressions: 8,\n GuildIntegrations: 16,\n GuildWebhooks: 32,\n GuildInvites: 64,\n GuildVoiceStates: 128,\n GuildPresences: 256,\n GuildMessages: 512,\n GuildMessageReactions: 1024,\n GuildMessageTyping: 2048,\n DirectMessages: 4096,\n DirectMessageReactions: 8192,\n DirectMessageTyping: 16384,\n MessageContent: 32768,\n GuildScheduledEvents: 65536,\n AutoModerationConfiguration: 1048576,\n AutoModerationExecution: 2097152,\n GuildMessagePolls: 16777216,\n DirectMessagePolls: 33554432,\n};\n\nexport function intentBits(names: Intent[]): number {\n return names.reduce((bits, name) => bits | BITS[name], 0);\n}\n","import type {\n GatewayCloseCodes,\n GatewayDispatchEvents,\n GatewayDispatchPayload,\n GatewayOpcodes,\n GatewayPresenceUpdateData,\n GatewayReceivePayload,\n GatewaySendPayload,\n} from 'discord-api-types/v10';\n\nexport type Session = { id: string; url: string; seq: number | null; intents: number };\n\nconst DISPATCH: GatewayOpcodes.Dispatch = 0;\nconst HEARTBEAT: GatewayOpcodes.Heartbeat = 1;\nconst IDENTIFY: GatewayOpcodes.Identify = 2;\nconst PRESENCE_UPDATE: GatewayOpcodes.PresenceUpdate = 3;\nconst RESUME: GatewayOpcodes.Resume = 6;\nconst RECONNECT: GatewayOpcodes.Reconnect = 7;\nconst INVALID_SESSION: GatewayOpcodes.InvalidSession = 9;\nconst HELLO: GatewayOpcodes.Hello = 10;\nconst HEARTBEAT_ACK: GatewayOpcodes.HeartbeatAck = 11;\n\nconst READY = 'READY' as GatewayDispatchEvents.Ready;\nconst RESUMED = 'RESUMED' as GatewayDispatchEvents.Resumed;\n\nconst FATAL = new Set<GatewayCloseCodes>([4004, 4010, 4011, 4012, 4013, 4014]);\nconst FRESH = new Set<GatewayCloseCodes>([4007, 4009]);\n\nexport function afterClose(code: number): 'stop' | 'identify' | 'resume' {\n if (FATAL.has(code)) return 'stop';\n\n return FRESH.has(code) ? 'identify' : 'resume';\n}\n\ntype Identity = { intents: number; presence?: GatewayPresenceUpdateData };\n\ntype Hooks = {\n send: (payload: GatewaySendPayload) => void;\n reconnect: (resume: boolean) => void;\n save: (session: Session | null) => void;\n dispatch: (event: GatewayDispatchPayload) => void;\n every: (ms: number, fn: () => void) => () => void;\n};\n\nexport class Shard {\n private readonly token: string;\n private readonly identity: Identity;\n private readonly hooks: Hooks;\n private session: Session | null;\n private acked = true;\n private stopBeat: (() => void) | undefined;\n\n constructor(token: string, identity: Identity, session: Session | null, hooks: Hooks) {\n this.token = token;\n this.identity = identity;\n this.session = session;\n this.hooks = hooks;\n }\n\n handle(payload: GatewayReceivePayload): void {\n switch (payload.op) {\n case HELLO: {\n this.acked = true;\n this.stopBeat?.();\n this.stopBeat = this.hooks.every(payload.d.heartbeat_interval, () => this.beat());\n\n if (this.session) {\n this.hooks.send({\n op: RESUME,\n d: { token: this.token, session_id: this.session.id, seq: this.session.seq ?? 0 },\n });\n } else {\n this.identify();\n }\n return;\n }\n case HEARTBEAT_ACK:\n this.acked = true;\n return;\n case HEARTBEAT:\n this.hooks.send({ op: HEARTBEAT, d: this.session?.seq ?? null });\n return;\n case RECONNECT:\n this.hooks.reconnect(true);\n return;\n case INVALID_SESSION:\n if (!payload.d) this.forget();\n this.hooks.reconnect(payload.d);\n return;\n case DISPATCH:\n this.onDispatch(payload);\n }\n }\n\n stop(): void {\n this.stopBeat?.();\n this.stopBeat = undefined;\n }\n\n private forget(): void {\n this.session = null;\n this.hooks.save(null);\n }\n\n private onDispatch(payload: GatewayDispatchPayload): void {\n if (payload.t === READY) {\n this.session = {\n id: payload.d.session_id,\n url: payload.d.resume_gateway_url,\n seq: payload.s,\n intents: this.identity.intents,\n };\n } else if (this.session) {\n this.session.seq = payload.s;\n }\n if (this.session) this.hooks.save(this.session);\n\n if (payload.t === RESUMED && this.identity.presence) {\n this.hooks.send({ op: PRESENCE_UPDATE, d: this.identity.presence });\n }\n\n this.hooks.dispatch(payload);\n }\n\n private beat(): void {\n if (!this.acked) {\n this.hooks.reconnect(true);\n return;\n }\n\n this.acked = false;\n this.hooks.send({ op: HEARTBEAT, d: this.session?.seq ?? null });\n }\n\n private identify(): void {\n this.hooks.send({\n op: IDENTIFY,\n d: {\n token: this.token,\n intents: this.identity.intents,\n properties: { os: 'cloudflare-workers', browser: 'botflare', device: 'botflare' },\n ...(this.identity.presence && { presence: this.identity.presence }),\n },\n });\n }\n}\n","/** @module botflare/gateway */\nimport { DurableObject } from 'cloudflare:workers';\nimport type {\n GatewayDispatchEvents,\n GatewayDispatchPayload,\n GatewayPresenceUpdateData,\n GatewayReceivePayload,\n} from 'discord-api-types/v10';\n\nimport type { Bot } from '#bot';\nimport { interactionCallback, type Send, sendMessage } from '#lib/discord';\nimport type { ErrorHandler } from '#types';\nimport { inflater } from '#utils/inflate';\nimport { type Intent, intentBits } from '#utils/intents';\nimport { afterClose, type Session, Shard } from '#utils/shard';\n\nconst GATEWAY = 'wss://gateway.discord.gg';\nconst WATCH_MS = 60_000;\nconst INTERACTION_CREATE = 'INTERACTION_CREATE' as GatewayDispatchEvents.InteractionCreate;\n\n/** What a gateway event handler gets besides the event data. */\nexport type EventContext = {\n /** Posts a message to a channel, with optional `buttons` and `embeds`. */\n send: Send;\n /**\n * The storage of the Durable Object that holds the connection, such as `storage.kv.get(key)`.\n * The connection keeps its own state under the `session` key, so leave that key alone.\n */\n storage: DurableObjectStorage;\n};\n\ntype GatewayConfig = {\n /**\n * The names of the gateway intents the bot needs, such as `'GuildMessages'`. Turn on privileged\n * ones, such as `'MessageContent'`, in the Developer Portal first.\n */\n intents: Intent[];\n /** The status the bot shows while it is connected. */\n presence?: GatewayPresenceUpdateData;\n /**\n * The bot from `bot()`. Its handlers answer the commands, buttons and modals that arrive over the\n * gateway, so the application needs no Interactions Endpoint URL.\n */\n bot?: Pick<Bot, 'answer'>;\n /** Gets errors from event handlers and from answering interactions. */\n onError?: ErrorHandler;\n /**\n * A handler per dispatch event name, such as `MESSAGE_CREATE`. `c.send` posts a message to a\n * channel, with optional `buttons` and `embeds`. `c.storage` keeps state between events.\n */\n events: {\n [P in GatewayDispatchPayload as P['t']]?: (data: P['d'], c: EventContext) => unknown;\n };\n};\n\n/**\n * Creates a Durable Object class that keeps one connection to the Discord Gateway open and runs your\n * handler for each event. Export it under the `class_name` of a SQLite Durable Object binding in your\n * Wrangler config, and give that binding name to `bot({ gateway })` so a Cron Trigger starts and\n * reconnects it.\n * It reads `DISCORD_TOKEN` from the Worker environment.\n *\n * @param config What the bot listens to.\n * @returns The Durable Object class.\n * @example\n * import { bot } from 'botflare';\n * import { gateway } from 'botflare/gateway';\n *\n * const app = bot({ commands: [], gateway: 'GATEWAY' });\n *\n * export const Gateway = gateway({\n * intents: ['Guilds', 'GuildMessages'],\n * bot: app,\n * events: { MESSAGE_CREATE: (message, c) => c.send(message.channel_id, 'heard you') },\n * });\n *\n * export default app;\n */\nexport function gateway(\n config: GatewayConfig,\n): new (\n ctx: DurableObjectState,\n env: { DISCORD_TOKEN: string },\n) => DurableObject<{ DISCORD_TOKEN: string }> & { ensure(): Promise<void> } {\n return class Gateway extends DurableObject<{ DISCORD_TOKEN: string }> {\n private ws: WebSocket | undefined;\n private shard: Shard | undefined;\n private inflate: ReturnType<typeof inflater> | undefined;\n private stopped = false;\n\n async ensure(): Promise<void> {\n if (!(await this.ctx.storage.getAlarm()))\n await this.ctx.storage.setAlarm(Date.now() + WATCH_MS);\n\n if (!this.ws && !this.stopped) this.dial();\n }\n\n override async alarm(): Promise<void> {\n await this.ensure();\n }\n\n private dial(): void {\n const identity = { intents: intentBits(config.intents), presence: config.presence };\n const stored = this.ctx.storage.kv.get<Session>('session');\n const saved = stored?.intents === identity.intents ? stored : null;\n\n const ws = new WebSocket(`${saved?.url ?? GATEWAY}/?v=10&encoding=json&compress=zlib-stream`);\n const send: Send = (channelId, content, options) =>\n sendMessage(this.env.DISCORD_TOKEN, channelId, content, options);\n\n const reconnect = (resume: boolean): void => {\n if (this.ws !== ws) return;\n\n this.drop();\n ws.close(4000);\n this.after(resume ? 'resume' : 'identify');\n };\n\n const shard = new Shard(this.env.DISCORD_TOKEN, identity, saved, {\n send: (payload) => ws.send(JSON.stringify(payload)),\n reconnect,\n save: (session) =>\n session\n ? this.ctx.storage.kv.put('session', session)\n : this.ctx.storage.kv.delete('session'),\n dispatch: (event) => {\n if (event.t === INTERACTION_CREATE && config.bot) {\n const { answer } = config.bot;\n const respond = async (): Promise<void> => {\n try {\n const after: (() => Promise<void>)[] = [];\n const response = await answer(event.d, {\n waitUntil: (promise) => this.ctx.waitUntil(promise),\n afterAnswer: (task) => after.push(task),\n });\n if (!response.ok) return;\n\n const failed = await interactionCallback(event.d.id, event.d.token, response);\n if (failed) await config.onError?.(failed, event.t);\n else for (const task of after) this.ctx.waitUntil(task());\n } catch (error) {\n await config.onError?.(error, event.t);\n }\n };\n this.ctx.waitUntil(respond());\n }\n\n const handler = config.events[event.t] as\n | ((data: unknown, c: EventContext) => unknown)\n | undefined;\n if (!handler) return;\n\n const run = async (): Promise<void> => {\n try {\n await handler(event.d, { send, storage: this.ctx.storage });\n } catch (error) {\n await config.onError?.(error, event.t);\n }\n };\n this.ctx.waitUntil(run());\n },\n every: (ms, fn) => {\n let interval: ReturnType<typeof setInterval> | null = null;\n const first = setTimeout(() => {\n fn();\n interval = setInterval(fn, ms);\n }, ms * Math.random());\n\n return () => {\n clearTimeout(first);\n clearInterval(interval);\n };\n },\n });\n\n const inflate = inflater(\n (json) => {\n if (this.ws === ws) shard.handle(JSON.parse(json) as GatewayReceivePayload);\n },\n () => reconnect(true),\n );\n\n this.ws = ws;\n this.shard = shard;\n this.inflate = inflate;\n\n ws.binaryType = 'arraybuffer';\n ws.addEventListener('message', (event) => {\n if (this.ws === ws) inflate.push(event.data as ArrayBuffer);\n });\n ws.addEventListener('close', (event) => {\n if (this.ws !== ws) return;\n\n this.drop();\n this.after(afterClose(event.code));\n });\n }\n\n private drop(): void {\n this.shard?.stop();\n this.shard = undefined;\n this.inflate?.close();\n this.inflate = undefined;\n this.ws = undefined;\n }\n\n private after(next: 'stop' | 'identify' | 'resume'): void {\n if (next === 'stop') {\n this.stopped = true;\n return;\n }\n\n if (next === 'identify') this.ctx.storage.kv.delete('session');\n setTimeout(() => this.dial(), 1000 + Math.random() * 4000);\n }\n };\n}\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "botflare",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Discord bots on Cloudflare Workers, with gateway events, typed REST and Durable Objects",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cloudflare",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"slash-commands",
|
|
14
14
|
"typescript"
|
|
15
15
|
],
|
|
16
|
-
"homepage": "https://github.
|
|
16
|
+
"homepage": "https://pyyupsk.github.io/botflare/",
|
|
17
17
|
"bugs": "https://github.com/pyyupsk/botflare/issues",
|
|
18
18
|
"license": "MIT",
|
|
19
19
|
"repository": {
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
],
|
|
30
30
|
"type": "module",
|
|
31
31
|
"sideEffects": false,
|
|
32
|
+
"types": "./dist/index.d.mts",
|
|
32
33
|
"imports": {
|
|
33
34
|
"#test/*": "./test/*.ts",
|
|
34
35
|
"#*": "./src/*.ts"
|