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 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
- [![NPM version](https://img.shields.io/npm/v/botflare?color=f38020&label=)](https://www.npmjs.com/package/botflare)
5
+ [![NPM version](https://img.shields.io/npm/v/botflare?color=c6f432&label=npm&logo=npm&logoColor=white)](https://www.npmjs.com/package/botflare)
6
+ [![Install size](https://img.shields.io/npm/unpacked-size/botflare?color=c6f432&label=size)](https://www.npmjs.com/package/botflare)
7
+ [![CI](https://github.com/pyyupsk/botflare/actions/workflows/ci.yml/badge.svg)](https://github.com/pyyupsk/botflare/actions/workflows/ci.yml)
8
+ [![Docs](https://github.com/pyyupsk/botflare/actions/workflows/docs.yml/badge.svg)](https://pyyupsk.github.io/botflare/)
9
+ [![TypeScript](https://img.shields.io/badge/TypeScript-strict-3178c6?logo=typescript&logoColor=white)](https://www.typescriptlang.org)
10
+ [![Cloudflare Workers](https://img.shields.io/badge/Cloudflare-Workers-f38020?logo=cloudflareworkers&logoColor=white)](https://workers.cloudflare.com)
11
+ [![Discord API](https://img.shields.io/badge/Discord-API%20spec-5865f2?logo=discord&logoColor=white)](https://github.com/discord/discord-api-spec)
12
+ [![License](https://img.shields.io/github/license/pyyupsk/botflare?color=c6f432)](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
- [πŸ‘‰ Example bot](https://github.com/pyyupsk/botflare/tree/main/examples/basic) Β· [πŸ‘‰ Discord API spec](https://github.com/discord/discord-api-spec)
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 menu commands, buttons, select menus, modals and autocomplete
14
- - Option values typed from the options you declare: a `choices` option gives only its values
15
- - The Discord Gateway in a Durable Object, so a bot gets commands and events without a public URL
16
- - `rest` for every Discord route, with path, query, body and result typed from the Discord API spec
17
- - Cron jobs that post to channels, and `onError` for work that runs after Discord has its answer
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
- const app = bot({
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 dev # open the local URL once to start the gateway
53
+ npx wrangler deploy # then set the Interactions Endpoint URL to the Worker
76
54
  ```
77
55
 
78
- `botflare sync` reads `DISCORD_TOKEN`, `DISCORD_APPLICATION_ID` and, for one test server, `DISCORD_GUILD_ID`. The Worker reads `DISCORD_TOKEN`, and `DISCORD_PUBLIC_KEY` for interactions over HTTP.
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 is built for that model from the start: it answers interactions over HTTP, keeps the gateway connection in a Durable Object, and takes its REST types from the spec that Discord publishes. When Discord adds a route, one script updates the types.
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: (resume) => {
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) shard.handle(JSON.parse(String(event.data)));
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) {
@@ -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":"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"}
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.0",
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.com/pyyupsk/botflare#readme",
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"