@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 +61 -1
- package/dist/index.d.mts +125 -1
- package/dist/index.mjs +110 -2
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @theholocron/discord-client
|
|
2
2
|
|
|
3
|
-
A TypeScript client for
|
|
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.
|
|
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.
|
|
35
|
+
"@theholocron/http-client": "^1.43.0"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
38
|
"@theholocron/cli": "5.0.0-alpha.79",
|