@theholocron/discord-client 1.45.0 → 1.47.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 +27 -7
- package/dist/index.d.mts +193 -1
- package/dist/index.mjs +242 -7
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -99,13 +99,13 @@ the resource, look it up by name before creating it again. A webhook created
|
|
|
99
99
|
that way cannot have its URL read back, since Discord returns the token only on
|
|
100
100
|
the create response.
|
|
101
101
|
|
|
102
|
-
| Resource | Methods
|
|
103
|
-
| -------------------- |
|
|
104
|
-
| `client.users` | `me()` — `GET /users/@me`, a cheap token check
|
|
105
|
-
| `client.application` | `get`, `modify` (the bot's own application: description, tags, install settings, URLs)
|
|
106
|
-
| `client.guilds` | `get`, `modify`, `channels`, `createChannel`, `roles`, `createRole`, `modifyRole`, `welcomeScreen`, `modifyWelcomeScreen`, `onboarding`, `modifyOnboarding` |
|
|
107
|
-
| `client.channels` | `modify`, `webhooks`, `createWebhook`, `createMessage`
|
|
108
|
-
| `client.webhooks` | `modify` (name, avatar), `delete`
|
|
102
|
+
| Resource | Methods |
|
|
103
|
+
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
104
|
+
| `client.users` | `me()` — `GET /users/@me`, a cheap token check; `get(id)` — a user's name |
|
|
105
|
+
| `client.application` | `get`, `modify` (the bot's own application: description, tags, install settings, URLs) |
|
|
106
|
+
| `client.guilds` | `get`, `modify`, `channels`, `createChannel`, `roles`, `createRole`, `modifyRole`, `create`, `delete`, `emojis`, `member`, `members`, `membersWithRole`, `addMemberRole`, `removeMemberRole`, `welcomeScreen`, `modifyWelcomeScreen`, `onboarding`, `modifyOnboarding` |
|
|
107
|
+
| `client.channels` | `modify`, `webhooks`, `createWebhook`, `createMessage`, `createInvite`, `messages`, `pin`, `deleteMessage` |
|
|
108
|
+
| `client.webhooks` | `modify` (name, avatar), `delete` |
|
|
109
109
|
|
|
110
110
|
`permissions([...names])` builds the decimal-string bitfield Discord expects, and
|
|
111
111
|
`permissionNames(bitfield)` reads one back; `Permission` holds the bits themselves.
|
|
@@ -115,6 +115,26 @@ features on, and `modifyOnboarding` is a `PUT` that replaces the whole config.
|
|
|
115
115
|
otherwise.
|
|
116
116
|
|
|
117
117
|
The bot needs Manage Channels, Manage Roles, Manage Webhooks and (for server settings, welcome screen and onboarding) Manage Server in the server, plus View Channel and Send Messages in any channel it posts to with `channels.createMessage`.
|
|
118
|
+
|
|
119
|
+
### Linking members to the role
|
|
120
|
+
|
|
121
|
+
`createDiscordOAuthClient({ clientId, clientSecret, redirectUri })` is "sign in with Discord": `authorizeUrl({ state })`
|
|
122
|
+
builds the link a member opens, `exchangeCode(code)` trades what Discord sends back for a token, and `user(token)` and
|
|
123
|
+
`connections(token)` read who they are and which accounts they have linked. `findVerifiedConnection(connections, "github")`
|
|
124
|
+
picks the GitHub account Discord has confirmed they own (an unverified name proves nothing). `revoke(token)` discards the
|
|
125
|
+
token once it has been read; nothing needs to keep it. `signOAuthState(secret)` and `verifyOAuthState(secret, state)` make
|
|
126
|
+
and check a `state` that expires and cannot be forged, so a server with no storage can still refuse a forged callback.
|
|
127
|
+
|
|
128
|
+
`guilds.membersWithRole(guildId, roleId)` lists who holds a role; like `guilds.members` it needs the privileged **Server
|
|
129
|
+
Members Intent**, switched on in the developer portal. `guilds.member(guildId, userId)` reads one member and needs no intent.
|
|
130
|
+
|
|
131
|
+
### Servers and checks
|
|
132
|
+
|
|
133
|
+
`guilds.create` makes a server the bot owns (only a bot in fewer than ten servers may), `guilds.modify(id, { owner_id })`
|
|
134
|
+
hands it over, `channels.createInvite` makes the link to join it, and `guilds.delete` removes it. On the webhook resource,
|
|
135
|
+
`executeAndWait` posts and returns the message id and `deleteMessage` removes it, so a check can post a line, read it back
|
|
136
|
+
with `channels.messages`, and clean up. `channels.pin` pins a message.
|
|
137
|
+
|
|
118
138
|
Discord only returns a webhook's `token`/`url` for webhooks the same
|
|
119
139
|
application created, so a pre-existing webhook can be found by name but its URL
|
|
120
140
|
cannot be read back.
|
package/dist/index.d.mts
CHANGED
|
@@ -67,6 +67,8 @@ interface DiscordGuild {
|
|
|
67
67
|
}
|
|
68
68
|
interface ModifyGuildInput {
|
|
69
69
|
name?: string;
|
|
70
|
+
/** Hands the server to another user; only the current owner (never a bot that does not own it) may. */
|
|
71
|
+
owner_id?: string;
|
|
70
72
|
verification_level?: number;
|
|
71
73
|
default_message_notifications?: number;
|
|
72
74
|
explicit_content_filter?: number;
|
|
@@ -164,6 +166,16 @@ interface ModifyChannelInput {
|
|
|
164
166
|
parent_id?: string | null;
|
|
165
167
|
permission_overwrites?: DiscordPermissionOverwrite[];
|
|
166
168
|
}
|
|
169
|
+
/** A member of a server. Only what provisioning needs: who they are and which roles they hold. */
|
|
170
|
+
interface DiscordGuildMember {
|
|
171
|
+
user?: {
|
|
172
|
+
id: string;
|
|
173
|
+
username?: string;
|
|
174
|
+
};
|
|
175
|
+
/** Ids of the roles the member holds. */
|
|
176
|
+
roles: string[];
|
|
177
|
+
nick?: string | null;
|
|
178
|
+
}
|
|
167
179
|
interface DiscordRole {
|
|
168
180
|
id: string;
|
|
169
181
|
name: string;
|
|
@@ -262,6 +274,50 @@ interface ModifyWebhookInput {
|
|
|
262
274
|
/** A data URI (`data:image/png;base64,...`); `null` clears it. */
|
|
263
275
|
avatar?: string | null;
|
|
264
276
|
}
|
|
277
|
+
/** What `POST /guilds` takes. Only a bot in fewer than ten servers may create one, and it becomes the owner. */
|
|
278
|
+
interface CreateGuildInput {
|
|
279
|
+
name: string;
|
|
280
|
+
/** A data URI (`data:image/png;base64,...`). */
|
|
281
|
+
icon?: string;
|
|
282
|
+
verification_level?: number;
|
|
283
|
+
default_message_notifications?: number;
|
|
284
|
+
explicit_content_filter?: number;
|
|
285
|
+
}
|
|
286
|
+
/** Page options for `guilds.members`; Discord caps `limit` at 1000. */
|
|
287
|
+
interface ListMembersInput {
|
|
288
|
+
limit?: number;
|
|
289
|
+
/** Return members after this user id. */
|
|
290
|
+
after?: string;
|
|
291
|
+
}
|
|
292
|
+
interface DiscordEmoji {
|
|
293
|
+
id: string | null;
|
|
294
|
+
name: string | null;
|
|
295
|
+
animated?: boolean;
|
|
296
|
+
available?: boolean;
|
|
297
|
+
}
|
|
298
|
+
interface CreateInviteInput {
|
|
299
|
+
/** Seconds until it expires; `0` never. Discord's default is 86400. */
|
|
300
|
+
max_age?: number;
|
|
301
|
+
/** Uses before it is revoked; `0` unlimited. */
|
|
302
|
+
max_uses?: number;
|
|
303
|
+
temporary?: boolean;
|
|
304
|
+
unique?: boolean;
|
|
305
|
+
}
|
|
306
|
+
interface DiscordInvite {
|
|
307
|
+
code: string;
|
|
308
|
+
channel_id?: string;
|
|
309
|
+
uses?: number;
|
|
310
|
+
}
|
|
311
|
+
/** A message listed back from a channel, with who wrote it. */
|
|
312
|
+
interface DiscordChannelMessage extends DiscordMessage {
|
|
313
|
+
author?: {
|
|
314
|
+
id: string;
|
|
315
|
+
username?: string;
|
|
316
|
+
bot?: boolean;
|
|
317
|
+
};
|
|
318
|
+
webhook_id?: string;
|
|
319
|
+
pinned?: boolean;
|
|
320
|
+
}
|
|
265
321
|
//#endregion
|
|
266
322
|
//#region ../../../../../src/bot/bot.d.ts
|
|
267
323
|
/**
|
|
@@ -279,9 +335,15 @@ declare function createDiscordBotClient(opts: DiscordBotClientOptions): {
|
|
|
279
335
|
users: {
|
|
280
336
|
/** `GET /users/@me` — the bot's own user; a cheap token check. */
|
|
281
337
|
me: () => Promise<DiscordUser>;
|
|
338
|
+
/** `GET /users/:id` — a user's name, for reports that would otherwise show a bare id. */
|
|
339
|
+
get: (userId: string) => Promise<DiscordUser>;
|
|
282
340
|
};
|
|
283
341
|
guilds: {
|
|
284
342
|
get: (guildId: string) => Promise<DiscordGuild>;
|
|
343
|
+
/** `POST /guilds` — a new server the bot owns. Not idempotent: look one up by name before retrying. */
|
|
344
|
+
create: (body: CreateGuildInput) => Promise<DiscordGuild>;
|
|
345
|
+
/** `DELETE /guilds/:id` — only the owner may; a 404 counts as done. */
|
|
346
|
+
delete: (guildId: string) => Promise<void>;
|
|
285
347
|
/** `PATCH /guilds/:id` — verification, notification defaults, content filter, system channels. */
|
|
286
348
|
modify: (guildId: string, body: ModifyGuildInput) => Promise<DiscordGuild>;
|
|
287
349
|
welcomeScreen: (guildId: string) => Promise<DiscordWelcomeScreen>;
|
|
@@ -298,12 +360,34 @@ declare function createDiscordBotClient(opts: DiscordBotClientOptions): {
|
|
|
298
360
|
roles: (guildId: string) => Promise<DiscordRole[]>;
|
|
299
361
|
createRole: (guildId: string, body: CreateRoleInput) => Promise<DiscordRole>;
|
|
300
362
|
modifyRole: (guildId: string, roleId: string, body: ModifyRoleInput) => Promise<DiscordRole>;
|
|
363
|
+
/** `GET /guilds/:id/members/:id` — one member and the roles they hold. A 404 means either they are not in the server or the guild id is unknown; read the error body to tell them apart. */
|
|
364
|
+
member: (guildId: string, userId: string) => Promise<DiscordGuildMember>;
|
|
365
|
+
/**
|
|
366
|
+
* `GET /guilds/:id/members` — one page. Needs the privileged Server Members Intent, switched on in the
|
|
367
|
+
* developer portal; without it Discord answers 403.
|
|
368
|
+
*/
|
|
369
|
+
members: (guildId: string, opts?: ListMembersInput) => Promise<DiscordGuildMember[]>;
|
|
370
|
+
/** Every member holding the role, read page by page; needs the same intent as `members`. */
|
|
371
|
+
membersWithRole: (guildId: string, roleId: string) => Promise<DiscordGuildMember[]>;
|
|
372
|
+
/** `GET /guilds/:id/emojis` — custom emoji, for welcome screen and onboarding ids. */
|
|
373
|
+
emojis: (guildId: string) => Promise<DiscordEmoji[]>;
|
|
374
|
+
/** `PUT /guilds/:id/members/:id/roles/:id` — gives the member the role; giving one they have is a no-op. */
|
|
375
|
+
addMemberRole: (guildId: string, userId: string, roleId: string) => Promise<void>;
|
|
376
|
+
/** `DELETE /guilds/:id/members/:id/roles/:id` — takes the role away; taking one they lack is a no-op. */
|
|
377
|
+
removeMemberRole: (guildId: string, userId: string, roleId: string) => Promise<void>;
|
|
301
378
|
};
|
|
302
379
|
channels: {
|
|
303
380
|
modify: (channelId: string, body: ModifyChannelInput) => Promise<DiscordChannel>;
|
|
304
381
|
webhooks: (channelId: string) => Promise<DiscordChannelWebhook[]>;
|
|
305
382
|
/** `POST /channels/:id/messages` as the bot. Mentions are off unless `allowed_mentions` asks for them. */
|
|
306
383
|
createMessage: (channelId: string, body: CreateMessageInput) => Promise<DiscordMessage>;
|
|
384
|
+
/** `POST /channels/:id/invites` — a join link for the server, e.g. to hand over a newly created one. */
|
|
385
|
+
createInvite: (channelId: string, body?: CreateInviteInput) => Promise<DiscordInvite>;
|
|
386
|
+
/** `GET /channels/:id/messages` — the newest messages first, to read a post back. */
|
|
387
|
+
messages: (channelId: string, limit?: number) => Promise<DiscordChannelMessage[]>;
|
|
388
|
+
/** `PUT /channels/:id/pins/:id` — pins a message; one already pinned is a no-op. */
|
|
389
|
+
pin: (channelId: string, messageId: string) => Promise<void>;
|
|
390
|
+
deleteMessage: (channelId: string, messageId: string) => Promise<void>;
|
|
307
391
|
createWebhook: (channelId: string, body: {
|
|
308
392
|
name: string;
|
|
309
393
|
}) => Promise<DiscordChannelWebhook>;
|
|
@@ -388,13 +472,121 @@ declare function permissions(names: readonly PermissionName[]): string;
|
|
|
388
472
|
/** The permission names set in a bitfield, in declaration order (unknown bits are ignored). */
|
|
389
473
|
declare function permissionNames(bitfield: string): PermissionName[];
|
|
390
474
|
//#endregion
|
|
475
|
+
//#region ../../../../../src/oauth/oauth.type.d.ts
|
|
476
|
+
interface DiscordOAuthClientOptions {
|
|
477
|
+
/** The application's client id (the same as its application id). Not a secret. */
|
|
478
|
+
clientId: string;
|
|
479
|
+
/** The application's client secret, from the developer portal (OAuth2 → Reset Secret). */
|
|
480
|
+
clientSecret: string;
|
|
481
|
+
/** One of the redirect URIs registered on the application; Discord refuses any other. */
|
|
482
|
+
redirectUri: string;
|
|
483
|
+
/** Override the API base for testing. Defaults to https://discord.com/api/v10. */
|
|
484
|
+
baseUrl?: string;
|
|
485
|
+
/** Override the authorize page for testing. Defaults to https://discord.com/oauth2/authorize. */
|
|
486
|
+
authorizeUrl?: string;
|
|
487
|
+
/** Override fetch for testing. Defaults to globalThis.fetch. */
|
|
488
|
+
fetch?: typeof fetch;
|
|
489
|
+
/** Per-request deadline in milliseconds. Defaults to 15000. */
|
|
490
|
+
timeoutMs?: number;
|
|
491
|
+
}
|
|
492
|
+
/** What a member's token may do: `identify` reads who they are, `connections` reads their linked accounts. */
|
|
493
|
+
type DiscordOAuthScope = "identify" | "connections";
|
|
494
|
+
interface DiscordOAuthToken {
|
|
495
|
+
access_token: string;
|
|
496
|
+
token_type: string;
|
|
497
|
+
/** Seconds until `access_token` stops working. */
|
|
498
|
+
expires_in: number;
|
|
499
|
+
refresh_token?: string;
|
|
500
|
+
/** The scopes granted, space separated. */
|
|
501
|
+
scope: string;
|
|
502
|
+
}
|
|
503
|
+
/** A linked account on a member's profile, as `GET /users/@me/connections` returns it. */
|
|
504
|
+
interface DiscordConnection {
|
|
505
|
+
/** The id on the other service; for GitHub, the numeric user id. */
|
|
506
|
+
id: string;
|
|
507
|
+
/** The name on the other service; for GitHub, the login. */
|
|
508
|
+
name: string;
|
|
509
|
+
/** The service, lower case: `github`, `twitch`, … */
|
|
510
|
+
type: string;
|
|
511
|
+
/** Whether Discord confirmed the member owns the account. */
|
|
512
|
+
verified: boolean;
|
|
513
|
+
visibility?: number;
|
|
514
|
+
}
|
|
515
|
+
/** The member who signed in, as `GET /users/@me` returns it with their token. */
|
|
516
|
+
interface DiscordOAuthUser {
|
|
517
|
+
id: string;
|
|
518
|
+
username: string;
|
|
519
|
+
global_name?: string | null;
|
|
520
|
+
}
|
|
521
|
+
//#endregion
|
|
522
|
+
//#region ../../../../../src/oauth/oauth.d.ts
|
|
523
|
+
/**
|
|
524
|
+
* Discord OAuth2 for "sign in with Discord": build the link a member follows, trade the code Discord sends
|
|
525
|
+
* back for a token, and read who they are and which accounts they have linked. Used to learn a member's
|
|
526
|
+
* Discord id together with their GitHub account, which Discord has verified.
|
|
527
|
+
*
|
|
528
|
+
* The token endpoints take a form body and the client's own credentials, which `createDiscordBotClient`'s
|
|
529
|
+
* JSON + bot-token client cannot send, so this has its own small transport.
|
|
530
|
+
*/
|
|
531
|
+
declare function createDiscordOAuthClient(opts: DiscordOAuthClientOptions): {
|
|
532
|
+
/**
|
|
533
|
+
* The page a member opens to approve the application. `state` must be unguessable and checked when
|
|
534
|
+
* Discord sends the member back (see {@link signOAuthState}). `prompt: "none"` skips the approval
|
|
535
|
+
* screen for a member who already approved.
|
|
536
|
+
*/
|
|
537
|
+
authorizeUrl: (input: {
|
|
538
|
+
state: string;
|
|
539
|
+
scopes?: readonly DiscordOAuthScope[];
|
|
540
|
+
prompt?: "consent" | "none";
|
|
541
|
+
}) => string;
|
|
542
|
+
/** `POST /oauth2/token` — trades the one-use `code` from the redirect for a token. */
|
|
543
|
+
exchangeCode: (code: string) => Promise<DiscordOAuthToken>;
|
|
544
|
+
/** `POST /oauth2/token` — a fresh token from a `refresh_token`. */
|
|
545
|
+
refresh: (refreshToken: string) => Promise<DiscordOAuthToken>;
|
|
546
|
+
/** `POST /oauth2/token/revoke` — discards a token once it has been used; nothing needs to keep it. */
|
|
547
|
+
revoke: (token: string) => Promise<void>;
|
|
548
|
+
/** `GET /users/@me` as the member (needs `identify`). */
|
|
549
|
+
user: (accessToken: string) => Promise<DiscordOAuthUser>;
|
|
550
|
+
/** `GET /users/@me/connections` as the member (needs `connections`). */
|
|
551
|
+
connections: (accessToken: string) => Promise<DiscordConnection[]>;
|
|
552
|
+
};
|
|
553
|
+
type DiscordOAuthClient = ReturnType<typeof createDiscordOAuthClient>;
|
|
554
|
+
/**
|
|
555
|
+
* The verified connection of one service, or `undefined`. Unverified ones are ignored: anyone can type a
|
|
556
|
+
* GitHub name into their profile, and only a verified link proves they own it. When a member has several
|
|
557
|
+
* of the service, the first verified one wins.
|
|
558
|
+
*/
|
|
559
|
+
declare function findVerifiedConnection(connections: readonly DiscordConnection[], type: string): DiscordConnection | undefined;
|
|
560
|
+
//#endregion
|
|
561
|
+
//#region ../../../../../src/oauth/state.d.ts
|
|
562
|
+
/** How long a sign-in link stays valid by default, in seconds. */
|
|
563
|
+
declare const DEFAULT_STATE_TTL_SECONDS = 600;
|
|
564
|
+
/**
|
|
565
|
+
* An OAuth `state` that a stateless server can check without storing anything: a random nonce and an
|
|
566
|
+
* expiry, signed with a server-side secret. Tampering with either, or using a different secret, fails
|
|
567
|
+
* {@link verifyOAuthState}. It does not bind the link to one browser: set the nonce in a cookie as well
|
|
568
|
+
* when that matters.
|
|
569
|
+
*/
|
|
570
|
+
declare function signOAuthState(secret: string, opts?: {
|
|
571
|
+
now?: number;
|
|
572
|
+
ttlSeconds?: number;
|
|
573
|
+
}): string;
|
|
574
|
+
/** Whether `state` was signed with `secret` and has not expired. Never throws on malformed input. */
|
|
575
|
+
declare function verifyOAuthState(secret: string, state: string, opts?: {
|
|
576
|
+
now?: number;
|
|
577
|
+
}): boolean;
|
|
578
|
+
//#endregion
|
|
391
579
|
//#region ../../../../../src/index.d.ts
|
|
392
580
|
declare function createDiscordClient(opts?: DiscordClientOptions): {
|
|
393
581
|
webhooks: {
|
|
394
582
|
get: (id: string, token: string) => Promise<DiscordWebhookInfo>;
|
|
395
583
|
execute: (id: string, token: string, content: string) => Promise<void>;
|
|
584
|
+
executeAndWait: (id: string, token: string, content: string) => Promise<{
|
|
585
|
+
id: string;
|
|
586
|
+
}>;
|
|
587
|
+
deleteMessage: (id: string, token: string, messageId: string) => Promise<void>;
|
|
396
588
|
};
|
|
397
589
|
};
|
|
398
590
|
type DiscordClient = ReturnType<typeof createDiscordClient>;
|
|
399
591
|
//#endregion
|
|
400
|
-
export { ChannelType, CreateChannelInput, CreateMessageInput, CreateRoleInput, DiscordApplication, DiscordBotClient, DiscordBotClientOptions, DiscordChannel, DiscordChannelWebhook, DiscordClient, type DiscordClientOptions, DiscordEmoji, DiscordGuild, DiscordInstallParams, DiscordMessage, DiscordOnboarding, DiscordOnboardingOption, DiscordOnboardingPrompt, DiscordPermissionOverwrite, DiscordRole, DiscordUser, type DiscordWebhookInfo, DiscordWelcomeChannel, DiscordWelcomeScreen, ModifyApplicationInput, ModifyChannelInput, ModifyGuildInput, ModifyRoleInput, ModifyWebhookInput, ModifyWelcomeScreenInput, Permission, PermissionName, createDiscordBotClient, createDiscordClient, parseWebhookUrl, permissionNames, permissions };
|
|
592
|
+
export { ChannelType, CreateChannelInput, CreateGuildInput, CreateInviteInput, CreateMessageInput, CreateRoleInput, DEFAULT_STATE_TTL_SECONDS, DiscordApplication, DiscordBotClient, DiscordBotClientOptions, DiscordChannel, DiscordChannelMessage, DiscordChannelWebhook, DiscordClient, type DiscordClientOptions, DiscordConnection, DiscordEmoji, DiscordGuild, DiscordGuildMember, DiscordInstallParams, DiscordInvite, DiscordMessage, DiscordOAuthClient, DiscordOAuthClientOptions, DiscordOAuthScope, DiscordOAuthToken, DiscordOAuthUser, DiscordOnboarding, DiscordOnboardingOption, DiscordOnboardingPrompt, DiscordPermissionOverwrite, DiscordRole, DiscordUser, type DiscordWebhookInfo, DiscordWelcomeChannel, DiscordWelcomeScreen, ListMembersInput, ModifyApplicationInput, ModifyChannelInput, ModifyGuildInput, ModifyRoleInput, ModifyWebhookInput, ModifyWelcomeScreenInput, Permission, PermissionName, createDiscordBotClient, createDiscordClient, createDiscordOAuthClient, findVerifiedConnection, parseWebhookUrl, permissionNames, permissions, signOAuthState, verifyOAuthState };
|
package/dist/index.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ProviderApiError, createRestClient } from "@theholocron/http-client";
|
|
2
|
+
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
3
|
//#endregion
|
|
3
4
|
//#region ../../../../../src/webhooks/webhooks.ts
|
|
4
5
|
/**
|
|
@@ -32,6 +33,36 @@ function webhooks(base, f) {
|
|
|
32
33
|
throw new ProviderApiError(`Discord POST /webhooks failed: ${detail}`, 0, void 0);
|
|
33
34
|
}
|
|
34
35
|
if (!res.ok) throw new ProviderApiError(`Discord webhook POST failed (${res.status})`, res.status, void 0);
|
|
36
|
+
},
|
|
37
|
+
/** Posts and waits for Discord to answer with the message, so a check can read it back or delete it. */
|
|
38
|
+
executeAndWait: async (id, token, content) => {
|
|
39
|
+
let res;
|
|
40
|
+
try {
|
|
41
|
+
res = await f(`${base}/webhooks/${id}/${token}?wait=true`, {
|
|
42
|
+
method: "POST",
|
|
43
|
+
headers: { "content-type": "application/json" },
|
|
44
|
+
body: JSON.stringify({
|
|
45
|
+
content,
|
|
46
|
+
allowed_mentions: { parse: [] }
|
|
47
|
+
})
|
|
48
|
+
});
|
|
49
|
+
} catch (err) {
|
|
50
|
+
const detail = err instanceof Error ? `${err.name}: ${err.message}` : String(err);
|
|
51
|
+
throw new ProviderApiError(`Discord POST /webhooks failed: ${detail}`, 0, void 0);
|
|
52
|
+
}
|
|
53
|
+
if (!res.ok) throw new ProviderApiError(`Discord webhook POST failed (${res.status})`, res.status, void 0);
|
|
54
|
+
return res.json();
|
|
55
|
+
},
|
|
56
|
+
/** Removes a message this webhook posted, e.g. a smoke-test line. A 404 counts as done. */
|
|
57
|
+
deleteMessage: async (id, token, messageId) => {
|
|
58
|
+
let res;
|
|
59
|
+
try {
|
|
60
|
+
res = await f(`${base}/webhooks/${id}/${token}/messages/${messageId}`, { method: "DELETE" });
|
|
61
|
+
} catch (err) {
|
|
62
|
+
const detail = err instanceof Error ? `${err.name}: ${err.message}` : String(err);
|
|
63
|
+
throw new ProviderApiError(`Discord DELETE /webhooks failed: ${detail}`, 0, void 0);
|
|
64
|
+
}
|
|
65
|
+
if (!res.ok && res.status !== 404) throw new ProviderApiError(`Discord webhook DELETE failed (${res.status})`, res.status, void 0);
|
|
35
66
|
}
|
|
36
67
|
};
|
|
37
68
|
}
|
|
@@ -46,10 +77,12 @@ function parseWebhookUrl(url) {
|
|
|
46
77
|
}
|
|
47
78
|
//#endregion
|
|
48
79
|
//#region ../../../../../src/bot/bot.ts
|
|
49
|
-
const DEFAULT_TIMEOUT_MS = 15e3;
|
|
80
|
+
const DEFAULT_TIMEOUT_MS$1 = 15e3;
|
|
50
81
|
const DEFAULT_MAX_RETRIES = 3;
|
|
51
82
|
/** A rate-limit wait longer than this fails fast instead of stalling the caller. */
|
|
52
83
|
const MAX_RETRY_WAIT_MS = 1e4;
|
|
84
|
+
/** A guard on `membersWithRole`, so a runaway cursor cannot loop forever. */
|
|
85
|
+
const MAX_MEMBER_PAGES = 50;
|
|
53
86
|
/** Discord ids are snowflakes; anything else would be spliced into a URL path. */
|
|
54
87
|
function id(value) {
|
|
55
88
|
if (!/^\d{1,20}$/.test(value)) throw new Error(`invalid Discord id: ${JSON.stringify(value)}`);
|
|
@@ -115,20 +148,40 @@ function createDiscordBotClient(opts) {
|
|
|
115
148
|
apiKeyHeader: "authorization",
|
|
116
149
|
vendor: "Discord",
|
|
117
150
|
fetch: guardedFetch(opts.fetch ?? globalThis.fetch, {
|
|
118
|
-
timeoutMs: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
151
|
+
timeoutMs: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS$1,
|
|
119
152
|
maxRetries: opts.maxRetries ?? DEFAULT_MAX_RETRIES,
|
|
120
153
|
sleep: opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)))
|
|
121
154
|
})
|
|
122
155
|
});
|
|
123
156
|
return {
|
|
124
|
-
users: {
|
|
125
|
-
|
|
126
|
-
me: () => rest.request("/users/@me")
|
|
157
|
+
users: {
|
|
158
|
+
/** `GET /users/@me` — the bot's own user; a cheap token check. */
|
|
159
|
+
me: () => rest.request("/users/@me"),
|
|
160
|
+
/** `GET /users/:id` — a user's name, for reports that would otherwise show a bare id. */
|
|
161
|
+
get: (userId) => rest.request(`/users/${id(userId)}`)
|
|
162
|
+
},
|
|
127
163
|
guilds: {
|
|
128
164
|
get: (guildId) => rest.request(`/guilds/${id(guildId)}`),
|
|
165
|
+
/** `POST /guilds` — a new server the bot owns. Not idempotent: look one up by name before retrying. */
|
|
166
|
+
create: (body) => rest.request("/guilds", {
|
|
167
|
+
method: "POST",
|
|
168
|
+
body
|
|
169
|
+
}),
|
|
170
|
+
/** `DELETE /guilds/:id` — only the owner may; a 404 counts as done. */
|
|
171
|
+
delete: async (guildId) => {
|
|
172
|
+
try {
|
|
173
|
+
await rest.request(`/guilds/${id(guildId)}`, {
|
|
174
|
+
method: "DELETE",
|
|
175
|
+
expectNoContent: true
|
|
176
|
+
});
|
|
177
|
+
} catch (err) {
|
|
178
|
+
if (err instanceof ProviderApiError && err.status === 404) return;
|
|
179
|
+
throw err;
|
|
180
|
+
}
|
|
181
|
+
},
|
|
129
182
|
/** `PATCH /guilds/:id` — verification, notification defaults, content filter, system channels. */
|
|
130
183
|
modify: (guildId, body) => {
|
|
131
|
-
bodyIds(body.system_channel_id, body.rules_channel_id, body.public_updates_channel_id);
|
|
184
|
+
bodyIds(body.owner_id, body.system_channel_id, body.rules_channel_id, body.public_updates_channel_id);
|
|
132
185
|
return rest.request(`/guilds/${id(guildId)}`, {
|
|
133
186
|
method: "PATCH",
|
|
134
187
|
body
|
|
@@ -169,6 +222,46 @@ me: () => rest.request("/users/@me") },
|
|
|
169
222
|
modifyRole: (guildId, roleId, body) => rest.request(`/guilds/${id(guildId)}/roles/${id(roleId)}`, {
|
|
170
223
|
method: "PATCH",
|
|
171
224
|
body
|
|
225
|
+
}),
|
|
226
|
+
/** `GET /guilds/:id/members/:id` — one member and the roles they hold. A 404 means either they are not in the server or the guild id is unknown; read the error body to tell them apart. */
|
|
227
|
+
member: (guildId, userId) => rest.request(`/guilds/${id(guildId)}/members/${id(userId)}`),
|
|
228
|
+
/**
|
|
229
|
+
* `GET /guilds/:id/members` — one page. Needs the privileged Server Members Intent, switched on in the
|
|
230
|
+
* developer portal; without it Discord answers 403.
|
|
231
|
+
*/
|
|
232
|
+
members: (guildId, opts = {}) => {
|
|
233
|
+
const query = { limit: String(opts.limit ?? 1e3) };
|
|
234
|
+
if (opts.after !== void 0) query.after = id(opts.after);
|
|
235
|
+
return rest.request(`/guilds/${id(guildId)}/members`, { query });
|
|
236
|
+
},
|
|
237
|
+
/** Every member holding the role, read page by page; needs the same intent as `members`. */
|
|
238
|
+
membersWithRole: async (guildId, roleId) => {
|
|
239
|
+
const wanted = id(roleId);
|
|
240
|
+
const found = [];
|
|
241
|
+
let after;
|
|
242
|
+
for (let page = 0; page < MAX_MEMBER_PAGES; page++) {
|
|
243
|
+
const batch = await rest.request(`/guilds/${id(guildId)}/members`, { query: {
|
|
244
|
+
limit: "1000",
|
|
245
|
+
...after === void 0 ? {} : { after }
|
|
246
|
+
} });
|
|
247
|
+
for (const m of batch) if (m.roles.includes(wanted)) found.push(m);
|
|
248
|
+
const last = batch[batch.length - 1]?.user?.id;
|
|
249
|
+
if (batch.length < 1e3 || last === void 0) return found;
|
|
250
|
+
after = last;
|
|
251
|
+
}
|
|
252
|
+
throw new Error(`more than ${MAX_MEMBER_PAGES * 1e3} members; refusing to read them all`);
|
|
253
|
+
},
|
|
254
|
+
/** `GET /guilds/:id/emojis` — custom emoji, for welcome screen and onboarding ids. */
|
|
255
|
+
emojis: (guildId) => rest.request(`/guilds/${id(guildId)}/emojis`),
|
|
256
|
+
/** `PUT /guilds/:id/members/:id/roles/:id` — gives the member the role; giving one they have is a no-op. */
|
|
257
|
+
addMemberRole: (guildId, userId, roleId) => rest.request(`/guilds/${id(guildId)}/members/${id(userId)}/roles/${id(roleId)}`, {
|
|
258
|
+
method: "PUT",
|
|
259
|
+
expectNoContent: true
|
|
260
|
+
}),
|
|
261
|
+
/** `DELETE /guilds/:id/members/:id/roles/:id` — takes the role away; taking one they lack is a no-op. */
|
|
262
|
+
removeMemberRole: (guildId, userId, roleId) => rest.request(`/guilds/${id(guildId)}/members/${id(userId)}/roles/${id(roleId)}`, {
|
|
263
|
+
method: "DELETE",
|
|
264
|
+
expectNoContent: true
|
|
172
265
|
})
|
|
173
266
|
},
|
|
174
267
|
channels: {
|
|
@@ -188,6 +281,22 @@ me: () => rest.request("/users/@me") },
|
|
|
188
281
|
}
|
|
189
282
|
});
|
|
190
283
|
},
|
|
284
|
+
/** `POST /channels/:id/invites` — a join link for the server, e.g. to hand over a newly created one. */
|
|
285
|
+
createInvite: (channelId, body = {}) => rest.request(`/channels/${id(channelId)}/invites`, {
|
|
286
|
+
method: "POST",
|
|
287
|
+
body
|
|
288
|
+
}),
|
|
289
|
+
/** `GET /channels/:id/messages` — the newest messages first, to read a post back. */
|
|
290
|
+
messages: (channelId, limit = 50) => rest.request(`/channels/${id(channelId)}/messages`, { query: { limit: String(limit) } }),
|
|
291
|
+
/** `PUT /channels/:id/pins/:id` — pins a message; one already pinned is a no-op. */
|
|
292
|
+
pin: (channelId, messageId) => rest.request(`/channels/${id(channelId)}/pins/${id(messageId)}`, {
|
|
293
|
+
method: "PUT",
|
|
294
|
+
expectNoContent: true
|
|
295
|
+
}),
|
|
296
|
+
deleteMessage: (channelId, messageId) => rest.request(`/channels/${id(channelId)}/messages/${id(messageId)}`, {
|
|
297
|
+
method: "DELETE",
|
|
298
|
+
expectNoContent: true
|
|
299
|
+
}),
|
|
191
300
|
createWebhook: async (channelId, body) => withUrl(await rest.request(`/channels/${id(channelId)}/webhooks`, {
|
|
192
301
|
method: "POST",
|
|
193
302
|
body
|
|
@@ -309,9 +418,135 @@ function permissionNames(bitfield) {
|
|
|
309
418
|
return Object.keys(Permission).filter((name) => (bits & Permission[name]) !== 0n);
|
|
310
419
|
}
|
|
311
420
|
//#endregion
|
|
421
|
+
//#region ../../../../../src/oauth/oauth.ts
|
|
422
|
+
const DEFAULT_AUTHORIZE_URL = "https://discord.com/oauth2/authorize";
|
|
423
|
+
const DEFAULT_TIMEOUT_MS = 15e3;
|
|
424
|
+
/**
|
|
425
|
+
* Discord OAuth2 for "sign in with Discord": build the link a member follows, trade the code Discord sends
|
|
426
|
+
* back for a token, and read who they are and which accounts they have linked. Used to learn a member's
|
|
427
|
+
* Discord id together with their GitHub account, which Discord has verified.
|
|
428
|
+
*
|
|
429
|
+
* The token endpoints take a form body and the client's own credentials, which `createDiscordBotClient`'s
|
|
430
|
+
* JSON + bot-token client cannot send, so this has its own small transport.
|
|
431
|
+
*/
|
|
432
|
+
function createDiscordOAuthClient(opts) {
|
|
433
|
+
let base = opts.baseUrl ?? "https://discord.com/api/v10";
|
|
434
|
+
while (base.endsWith("/")) base = base.slice(0, -1);
|
|
435
|
+
const f = opts.fetch ?? globalThis.fetch;
|
|
436
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
437
|
+
const basic = `Basic ${btoa(`${opts.clientId}:${opts.clientSecret}`)}`;
|
|
438
|
+
async function call(path, init) {
|
|
439
|
+
const tag = `Discord ${init.method ?? "GET"} ${path}`;
|
|
440
|
+
let res;
|
|
441
|
+
try {
|
|
442
|
+
res = await f(`${base}${path}`, {
|
|
443
|
+
...init,
|
|
444
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
445
|
+
});
|
|
446
|
+
} catch (err) {
|
|
447
|
+
const detail = err instanceof Error ? `${err.name}: ${err.message}` : String(err);
|
|
448
|
+
throw new ProviderApiError(`${tag} failed: ${detail}`, 0, void 0);
|
|
449
|
+
}
|
|
450
|
+
if (!res.ok) {
|
|
451
|
+
const body = await res.text().catch(() => "");
|
|
452
|
+
throw new ProviderApiError(`${tag} → ${res.status}`, res.status, body);
|
|
453
|
+
}
|
|
454
|
+
return await res.json();
|
|
455
|
+
}
|
|
456
|
+
function form(fields) {
|
|
457
|
+
return {
|
|
458
|
+
method: "POST",
|
|
459
|
+
headers: {
|
|
460
|
+
authorization: basic,
|
|
461
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
462
|
+
accept: "application/json"
|
|
463
|
+
},
|
|
464
|
+
body: new URLSearchParams(fields).toString()
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
function bearer(accessToken) {
|
|
468
|
+
return { headers: {
|
|
469
|
+
authorization: `Bearer ${accessToken}`,
|
|
470
|
+
accept: "application/json"
|
|
471
|
+
} };
|
|
472
|
+
}
|
|
473
|
+
return {
|
|
474
|
+
/**
|
|
475
|
+
* The page a member opens to approve the application. `state` must be unguessable and checked when
|
|
476
|
+
* Discord sends the member back (see {@link signOAuthState}). `prompt: "none"` skips the approval
|
|
477
|
+
* screen for a member who already approved.
|
|
478
|
+
*/
|
|
479
|
+
authorizeUrl: (input) => {
|
|
480
|
+
const url = new URL(opts.authorizeUrl ?? DEFAULT_AUTHORIZE_URL);
|
|
481
|
+
url.searchParams.set("client_id", opts.clientId);
|
|
482
|
+
url.searchParams.set("response_type", "code");
|
|
483
|
+
url.searchParams.set("redirect_uri", opts.redirectUri);
|
|
484
|
+
url.searchParams.set("scope", (input.scopes ?? ["identify", "connections"]).join(" "));
|
|
485
|
+
url.searchParams.set("state", input.state);
|
|
486
|
+
if (input.prompt) url.searchParams.set("prompt", input.prompt);
|
|
487
|
+
return url.toString();
|
|
488
|
+
},
|
|
489
|
+
/** `POST /oauth2/token` — trades the one-use `code` from the redirect for a token. */
|
|
490
|
+
exchangeCode: (code) => call("/oauth2/token", form({
|
|
491
|
+
grant_type: "authorization_code",
|
|
492
|
+
code,
|
|
493
|
+
redirect_uri: opts.redirectUri
|
|
494
|
+
})),
|
|
495
|
+
/** `POST /oauth2/token` — a fresh token from a `refresh_token`. */
|
|
496
|
+
refresh: (refreshToken) => call("/oauth2/token", form({
|
|
497
|
+
grant_type: "refresh_token",
|
|
498
|
+
refresh_token: refreshToken
|
|
499
|
+
})),
|
|
500
|
+
/** `POST /oauth2/token/revoke` — discards a token once it has been used; nothing needs to keep it. */
|
|
501
|
+
revoke: async (token) => {
|
|
502
|
+
await call("/oauth2/token/revoke", form({ token }));
|
|
503
|
+
},
|
|
504
|
+
/** `GET /users/@me` as the member (needs `identify`). */
|
|
505
|
+
user: (accessToken) => call("/users/@me", bearer(accessToken)),
|
|
506
|
+
/** `GET /users/@me/connections` as the member (needs `connections`). */
|
|
507
|
+
connections: (accessToken) => call("/users/@me/connections", bearer(accessToken))
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* The verified connection of one service, or `undefined`. Unverified ones are ignored: anyone can type a
|
|
512
|
+
* GitHub name into their profile, and only a verified link proves they own it. When a member has several
|
|
513
|
+
* of the service, the first verified one wins.
|
|
514
|
+
*/
|
|
515
|
+
function findVerifiedConnection(connections, type) {
|
|
516
|
+
return connections.find((c) => c.type === type && c.verified);
|
|
517
|
+
}
|
|
518
|
+
//#endregion
|
|
519
|
+
//#region ../../../../../src/oauth/state.ts
|
|
520
|
+
function mac(secret, message) {
|
|
521
|
+
return createHmac("sha256", secret).update(message).digest("hex");
|
|
522
|
+
}
|
|
523
|
+
/** How long a sign-in link stays valid by default, in seconds. */
|
|
524
|
+
const DEFAULT_STATE_TTL_SECONDS = 600;
|
|
525
|
+
/**
|
|
526
|
+
* An OAuth `state` that a stateless server can check without storing anything: a random nonce and an
|
|
527
|
+
* expiry, signed with a server-side secret. Tampering with either, or using a different secret, fails
|
|
528
|
+
* {@link verifyOAuthState}. It does not bind the link to one browser: set the nonce in a cookie as well
|
|
529
|
+
* when that matters.
|
|
530
|
+
*/
|
|
531
|
+
function signOAuthState(secret, opts = {}) {
|
|
532
|
+
const body = `${Math.floor((opts.now ?? Date.now()) / 1e3) + (opts.ttlSeconds ?? 600)}.${randomBytes(16).toString("hex")}`;
|
|
533
|
+
return `${body}.${mac(secret, body)}`;
|
|
534
|
+
}
|
|
535
|
+
/** Whether `state` was signed with `secret` and has not expired. Never throws on malformed input. */
|
|
536
|
+
function verifyOAuthState(secret, state, opts = {}) {
|
|
537
|
+
const parts = state.split(".");
|
|
538
|
+
if (parts.length !== 3) return false;
|
|
539
|
+
const [expires, nonce, sig] = parts;
|
|
540
|
+
if (!/^\d+$/.test(expires) || !/^[0-9a-f]{32}$/.test(nonce)) return false;
|
|
541
|
+
const want = Buffer.from(mac(secret, `${expires}.${nonce}`));
|
|
542
|
+
const got = Buffer.from(sig);
|
|
543
|
+
if (got.length !== want.length || !timingSafeEqual(got, want)) return false;
|
|
544
|
+
return Number(expires) >= Math.floor((opts.now ?? Date.now()) / 1e3);
|
|
545
|
+
}
|
|
546
|
+
//#endregion
|
|
312
547
|
//#region ../../../../../src/index.ts
|
|
313
548
|
function createDiscordClient(opts = {}) {
|
|
314
549
|
return { webhooks: webhooks(opts.baseUrl ?? "https://discord.com/api/v10", opts.fetch ?? globalThis.fetch) };
|
|
315
550
|
}
|
|
316
551
|
//#endregion
|
|
317
|
-
export { ChannelType, Permission, createDiscordBotClient, createDiscordClient, parseWebhookUrl, permissionNames, permissions };
|
|
552
|
+
export { ChannelType, DEFAULT_STATE_TTL_SECONDS, Permission, createDiscordBotClient, createDiscordClient, createDiscordOAuthClient, findVerifiedConnection, parseWebhookUrl, permissionNames, permissions, signOAuthState, verifyOAuthState };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@theholocron/discord-client",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.47.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.47.0"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
38
|
"@theholocron/cli": "5.0.0-alpha.79",
|