@theholocron/discord-client 1.42.1 → 1.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @theholocron/discord-client
2
2
 
3
- A TypeScript client for the Discord webhooks API — reads a webhook's info and posts messages through it.
3
+ A TypeScript client for Discord — posts messages through webhooks, and (with a bot token) provisions a server's channels, roles and webhooks.
4
4
 
5
5
  ## Installation
6
6
 
@@ -49,6 +49,66 @@ Returns a client with one resource namespace:
49
49
  Parses a Discord webhook URL (e.g. `https://discord.com/api/webhooks/<id>/<token>`)
50
50
  into `{ id, token }`.
51
51
 
52
+ ## Bot client — provisioning a server
53
+
54
+ `createDiscordBotClient` authenticates as a bot (`Authorization: Bot <token>`)
55
+ and covers what you need to set a server up. It does not post messages; use
56
+ `createDiscordClient().webhooks.execute` for that.
57
+
58
+ ```ts
59
+ import { ChannelType, createDiscordBotClient } from "@theholocron/discord-client";
60
+
61
+ const bot = createDiscordBotClient({
62
+ botToken: process.env.DISCORD_BOT_TOKEN!,
63
+ });
64
+
65
+ const category = await bot.guilds.createChannel(guildId, {
66
+ name: "ALERTS",
67
+ type: ChannelType.Category,
68
+ });
69
+ const channel = await bot.guilds.createChannel(guildId, {
70
+ name: "alerts",
71
+ parent_id: category.id,
72
+ });
73
+ const hook = await bot.channels.createWebhook(channel.id, {
74
+ name: "repo-sync",
75
+ });
76
+ console.log(hook.url); // a secret -- Discord returns it only for webhooks this bot created
77
+ ```
78
+
79
+ ### `createDiscordBotClient(opts)`
80
+
81
+ | Option | Type | Default | Description |
82
+ | ------------ | -------------- | ----------------------------- | ----------------------------- |
83
+ | `botToken` | `string` | _(required)_ | Bot token |
84
+ | `baseUrl` | `string` | `https://discord.com/api/v10` | Override base URL for testing |
85
+ | `fetch` | `typeof fetch` | `globalThis.fetch` | Override fetch for testing |
86
+ | `timeoutMs` | `number` | `15000` | Per-request deadline |
87
+ | `maxRetries` | `number` | `3` | Retries after a 429 |
88
+
89
+ `baseUrl` is a test seam: the bot token is sent to whatever host it names, so
90
+ never derive it from untrusted input. Ids passed to any method must be Discord
91
+ snowflakes (digits only); anything else throws before a request is made.
92
+
93
+ A 429 is retried, up to `maxRetries` times, waiting for the `Retry-After` Discord
94
+ sends; a wait longer than 10 seconds fails immediately instead. A request that
95
+ runs past `timeoutMs` is aborted. Other errors are not retried, and the
96
+ `create*` calls are not idempotent: if a response is lost after Discord created
97
+ the resource, look it up by name before creating it again. A webhook created
98
+ that way cannot have its URL read back, since Discord returns the token only on
99
+ the create response.
100
+
101
+ | Resource | Methods |
102
+ | ----------------- | --------------------------------------------------------- |
103
+ | `client.users` | `me()` — `GET /users/@me`, a cheap token check |
104
+ | `client.guilds` | `get`, `channels`, `createChannel`, `roles`, `createRole` |
105
+ | `client.channels` | `modify`, `webhooks`, `createWebhook` |
106
+
107
+ The bot needs Manage Channels, Manage Roles and Manage Webhooks in the server.
108
+ Discord only returns a webhook's `token`/`url` for webhooks the same
109
+ application created, so a pre-existing webhook can be found by name but its URL
110
+ cannot be read back.
111
+
52
112
  ## License
53
113
 
54
114
  GPL-3.0 © [Newton Koumantzelis](https://github.com/iamnewton)
package/dist/index.d.mts CHANGED
@@ -18,6 +18,130 @@ interface DiscordClientOptions {
18
18
  fetch?: typeof fetch;
19
19
  }
20
20
  //#endregion
21
+ //#region ../../../../../src/bot/bot.type.d.ts
22
+ interface DiscordBotClientOptions {
23
+ /** Bot token from the Discord developer portal (Bot → Reset Token). */
24
+ botToken: string;
25
+ /** Override base URL for testing. Defaults to https://discord.com/api/v10. */
26
+ baseUrl?: string;
27
+ /** Override fetch for testing. Defaults to globalThis.fetch. */
28
+ fetch?: typeof fetch;
29
+ /** Per-request deadline in milliseconds. Defaults to 15000. */
30
+ timeoutMs?: number;
31
+ /** Retries after a 429 (rate limit) before giving up. Defaults to 3. */
32
+ maxRetries?: number;
33
+ /** Wait between rate-limit retries; a test seam. Defaults to `setTimeout`. */
34
+ sleep?: (ms: number) => Promise<void>;
35
+ }
36
+ /** Discord channel `type` values this client creates or reads. */
37
+ declare const ChannelType: {
38
+ readonly Text: 0;
39
+ readonly Category: 4;
40
+ readonly Announcement: 5;
41
+ readonly Forum: 15;
42
+ };
43
+ interface DiscordUser {
44
+ id: string;
45
+ username: string;
46
+ bot?: boolean;
47
+ }
48
+ interface DiscordGuild {
49
+ id: string;
50
+ name: string;
51
+ }
52
+ interface DiscordPermissionOverwrite {
53
+ /** Role or user id. */
54
+ id: string;
55
+ /** 0 = role, 1 = member. */
56
+ type: 0 | 1;
57
+ /** Bitfield of permissions to allow, as a string. */
58
+ allow: string;
59
+ /** Bitfield of permissions to deny, as a string. */
60
+ deny: string;
61
+ }
62
+ interface DiscordChannel {
63
+ id: string;
64
+ name: string;
65
+ type: number;
66
+ parent_id?: string | null;
67
+ topic?: string | null;
68
+ position?: number;
69
+ permission_overwrites?: DiscordPermissionOverwrite[];
70
+ }
71
+ interface CreateChannelInput {
72
+ name: string;
73
+ type?: number;
74
+ topic?: string;
75
+ parent_id?: string;
76
+ permission_overwrites?: DiscordPermissionOverwrite[];
77
+ }
78
+ /** `null` clears `topic` or detaches the channel from its category. */
79
+ interface ModifyChannelInput {
80
+ name?: string;
81
+ topic?: string | null;
82
+ parent_id?: string | null;
83
+ permission_overwrites?: DiscordPermissionOverwrite[];
84
+ }
85
+ interface DiscordRole {
86
+ id: string;
87
+ name: string;
88
+ color: number;
89
+ mentionable: boolean;
90
+ managed?: boolean;
91
+ position?: number;
92
+ }
93
+ interface CreateRoleInput {
94
+ name: string;
95
+ color?: number;
96
+ mentionable?: boolean;
97
+ hoist?: boolean;
98
+ /** Bitfield of permissions, as a string. */
99
+ permissions?: string;
100
+ }
101
+ interface DiscordChannelWebhook {
102
+ id: string;
103
+ name: string | null;
104
+ channel_id: string;
105
+ /** Present only on webhooks this application created; Discord omits it otherwise. */
106
+ token?: string;
107
+ /** Present only on webhooks this application created. */
108
+ url?: string;
109
+ }
110
+ //#endregion
111
+ //#region ../../../../../src/bot/bot.d.ts
112
+ /**
113
+ * Bot-authenticated Discord REST resources (`Authorization: Bot <token>`),
114
+ * for provisioning a server: channels, roles, and per-channel webhooks.
115
+ * Webhook posting stays on {@link createDiscordClient}, which needs no token.
116
+ *
117
+ * `http-client` only knows `Bearer`/`Basic`/api-key, so the `Bot` scheme is
118
+ * sent as an api-key header carrying the whole `Bot <token>` value.
119
+ *
120
+ * `baseUrl` is a test seam: the bot token is sent to whatever host it names, so
121
+ * never derive it from untrusted input.
122
+ */
123
+ declare function createDiscordBotClient(opts: DiscordBotClientOptions): {
124
+ users: {
125
+ /** `GET /users/@me` — the bot's own user; a cheap token check. */
126
+ me: () => Promise<DiscordUser>;
127
+ };
128
+ guilds: {
129
+ get: (guildId: string) => Promise<DiscordGuild>;
130
+ channels: (guildId: string) => Promise<DiscordChannel[]>;
131
+ createChannel: (guildId: string, body: CreateChannelInput) => Promise<DiscordChannel>;
132
+ roles: (guildId: string) => Promise<DiscordRole[]>;
133
+ createRole: (guildId: string, body: CreateRoleInput) => Promise<DiscordRole>;
134
+ };
135
+ channels: {
136
+ modify: (channelId: string, body: ModifyChannelInput) => Promise<DiscordChannel>;
137
+ webhooks: (channelId: string) => Promise<DiscordChannelWebhook[]>;
138
+ createWebhook: (channelId: string, body: {
139
+ name: string;
140
+ }) => Promise<DiscordChannelWebhook>;
141
+ };
142
+ };
143
+ type DiscordBotClient = ReturnType<typeof createDiscordBotClient>;
144
+ //#endregion
21
145
  //#region ../../../../../src/index.d.ts
22
146
  declare function createDiscordClient(opts?: DiscordClientOptions): {
23
147
  webhooks: {
@@ -27,4 +151,4 @@ declare function createDiscordClient(opts?: DiscordClientOptions): {
27
151
  };
28
152
  type DiscordClient = ReturnType<typeof createDiscordClient>;
29
153
  //#endregion
30
- export { DiscordClient, type DiscordClientOptions, type DiscordWebhookInfo, createDiscordClient, parseWebhookUrl };
154
+ export { ChannelType, CreateChannelInput, CreateRoleInput, DiscordBotClient, DiscordBotClientOptions, DiscordChannel, DiscordChannelWebhook, DiscordClient, type DiscordClientOptions, DiscordGuild, DiscordPermissionOverwrite, DiscordRole, DiscordUser, type DiscordWebhookInfo, ModifyChannelInput, createDiscordBotClient, createDiscordClient, parseWebhookUrl };
package/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { ProviderApiError } from "@theholocron/http-client";
1
+ import { ProviderApiError, createRestClient } from "@theholocron/http-client";
2
2
  //#endregion
3
3
  //#region ../../../../../src/webhooks/webhooks.ts
4
4
  /**
@@ -45,9 +45,117 @@ function parseWebhookUrl(url) {
45
45
  };
46
46
  }
47
47
  //#endregion
48
+ //#region ../../../../../src/bot/bot.ts
49
+ const DEFAULT_TIMEOUT_MS = 15e3;
50
+ const DEFAULT_MAX_RETRIES = 3;
51
+ /** A rate-limit wait longer than this fails fast instead of stalling the caller. */
52
+ const MAX_RETRY_WAIT_MS = 1e4;
53
+ /** Discord ids are snowflakes; anything else would be spliced into a URL path. */
54
+ function id(value) {
55
+ if (!/^\d{1,20}$/.test(value)) throw new Error(`invalid Discord id: ${JSON.stringify(value)}`);
56
+ return value;
57
+ }
58
+ /** How long Discord asks us to wait after a 429, in ms (header first, then the JSON body). */
59
+ async function retryDelayMs(res) {
60
+ const header = Number(res.headers.get("retry-after"));
61
+ if (Number.isFinite(header) && header > 0) return Math.ceil(header * 1e3);
62
+ const body = await res.clone().json().catch(() => ({}));
63
+ return typeof body.retry_after === "number" && body.retry_after > 0 ? Math.ceil(body.retry_after * 1e3) : 1e3;
64
+ }
65
+ /**
66
+ * Adds the two guards `createRestClient` doesn't have: a per-request deadline,
67
+ * and a bounded retry on 429. Retrying a 429 is safe even for POSTs — Discord
68
+ * rejected the request without acting on it.
69
+ */
70
+ function guardedFetch(f, { timeoutMs, maxRetries, sleep }) {
71
+ return async (input, init) => {
72
+ for (let attempt = 0;; attempt++) {
73
+ const res = await f(input, {
74
+ ...init,
75
+ signal: AbortSignal.timeout(timeoutMs)
76
+ });
77
+ if (res.status !== 429 || attempt >= maxRetries) return res;
78
+ const wait = await retryDelayMs(res);
79
+ if (wait > MAX_RETRY_WAIT_MS) return res;
80
+ await sleep(wait);
81
+ }
82
+ };
83
+ }
84
+ /** Discord includes `url` on webhooks this application created; derive it from id + token if it is ever absent. */
85
+ function withUrl(hook) {
86
+ if (hook.url || !hook.token) return hook;
87
+ return {
88
+ ...hook,
89
+ url: `https://discord.com/api/webhooks/${hook.id}/${hook.token}`
90
+ };
91
+ }
92
+ /**
93
+ * Bot-authenticated Discord REST resources (`Authorization: Bot <token>`),
94
+ * for provisioning a server: channels, roles, and per-channel webhooks.
95
+ * Webhook posting stays on {@link createDiscordClient}, which needs no token.
96
+ *
97
+ * `http-client` only knows `Bearer`/`Basic`/api-key, so the `Bot` scheme is
98
+ * sent as an api-key header carrying the whole `Bot <token>` value.
99
+ *
100
+ * `baseUrl` is a test seam: the bot token is sent to whatever host it names, so
101
+ * never derive it from untrusted input.
102
+ */
103
+ function createDiscordBotClient(opts) {
104
+ const rest = createRestClient({
105
+ baseUrl: opts.baseUrl ?? "https://discord.com/api/v10",
106
+ token: `Bot ${opts.botToken}`,
107
+ tokenScheme: "apikey",
108
+ apiKeyHeader: "authorization",
109
+ vendor: "Discord",
110
+ fetch: guardedFetch(opts.fetch ?? globalThis.fetch, {
111
+ timeoutMs: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
112
+ maxRetries: opts.maxRetries ?? DEFAULT_MAX_RETRIES,
113
+ sleep: opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)))
114
+ })
115
+ });
116
+ return {
117
+ users: {
118
+ /** `GET /users/@me` — the bot's own user; a cheap token check. */
119
+ me: () => rest.request("/users/@me") },
120
+ guilds: {
121
+ get: (guildId) => rest.request(`/guilds/${id(guildId)}`),
122
+ channels: (guildId) => rest.request(`/guilds/${id(guildId)}/channels`),
123
+ createChannel: (guildId, body) => rest.request(`/guilds/${id(guildId)}/channels`, {
124
+ method: "POST",
125
+ body
126
+ }),
127
+ roles: (guildId) => rest.request(`/guilds/${id(guildId)}/roles`),
128
+ createRole: (guildId, body) => rest.request(`/guilds/${id(guildId)}/roles`, {
129
+ method: "POST",
130
+ body
131
+ })
132
+ },
133
+ channels: {
134
+ modify: (channelId, body) => rest.request(`/channels/${id(channelId)}`, {
135
+ method: "PATCH",
136
+ body
137
+ }),
138
+ webhooks: async (channelId) => (await rest.request(`/channels/${id(channelId)}/webhooks`)).map(withUrl),
139
+ createWebhook: async (channelId, body) => withUrl(await rest.request(`/channels/${id(channelId)}/webhooks`, {
140
+ method: "POST",
141
+ body
142
+ }))
143
+ }
144
+ };
145
+ }
146
+ //#endregion
147
+ //#region ../../../../../src/bot/bot.type.ts
148
+ /** Discord channel `type` values this client creates or reads. */
149
+ const ChannelType = {
150
+ Text: 0,
151
+ Category: 4,
152
+ Announcement: 5,
153
+ Forum: 15
154
+ };
155
+ //#endregion
48
156
  //#region ../../../../../src/index.ts
49
157
  function createDiscordClient(opts = {}) {
50
158
  return { webhooks: webhooks(opts.baseUrl ?? "https://discord.com/api/v10", opts.fetch ?? globalThis.fetch) };
51
159
  }
52
160
  //#endregion
53
- export { createDiscordClient, parseWebhookUrl };
161
+ export { ChannelType, createDiscordBotClient, createDiscordClient, parseWebhookUrl };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theholocron/discord-client",
3
- "version": "1.42.1",
3
+ "version": "1.43.0",
4
4
  "description": "A TypeScript client for the Discord webhooks API",
5
5
  "keywords": [
6
6
  "discord",
@@ -32,7 +32,7 @@
32
32
  "dist"
33
33
  ],
34
34
  "dependencies": {
35
- "@theholocron/http-client": "^1.42.1"
35
+ "@theholocron/http-client": "^1.43.0"
36
36
  },
37
37
  "devDependencies": {
38
38
  "@theholocron/cli": "5.0.0-alpha.79",