@theholocron/discord-client 1.44.0 → 1.46.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
@@ -99,12 +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.guilds` | `get`, `modify`, `channels`, `createChannel`, `roles`, `createRole`, `modifyRole`, `welcomeScreen`, `modifyWelcomeScreen`, `onboarding`, `modifyOnboarding` |
106
- | `client.channels` | `modify`, `webhooks`, `createWebhook`, `createMessage` |
107
- | `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` |
108
109
 
109
110
  `permissions([...names])` builds the decimal-string bitfield Discord expects, and
110
111
  `permissionNames(bitfield)` reads one back; `Permission` holds the bits themselves.
@@ -114,6 +115,26 @@ features on, and `modifyOnboarding` is a `PUT` that replaces the whole config.
114
115
  otherwise.
115
116
 
116
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
+
117
138
  Discord only returns a webhook's `token`/`url` for webhooks the same
118
139
  application created, so a pre-existing webhook can be found by name but its URL
119
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;
@@ -219,11 +231,93 @@ interface DiscordMessage {
219
231
  channel_id: string;
220
232
  content: string;
221
233
  }
234
+ /** How the bot is added to a server or an account. */
235
+ interface DiscordInstallParams {
236
+ /** OAuth scopes, e.g. `bot`, `applications.commands`. */
237
+ scopes: string[];
238
+ /** Permission bitfield the default install link asks for, as a string. */
239
+ permissions: string;
240
+ }
241
+ /** The bot's own application, as `GET /applications/@me` returns it. Fields Discord leaves out are absent or null. */
242
+ interface DiscordApplication {
243
+ id: string;
244
+ name: string;
245
+ description?: string | null;
246
+ /** Up to five short labels shown on the application's page. */
247
+ tags?: string[];
248
+ /** The default install link's scopes and permissions. */
249
+ install_params?: DiscordInstallParams;
250
+ /** Used instead of the default install link when set. */
251
+ custom_install_url?: string | null;
252
+ role_connections_verification_url?: string | null;
253
+ terms_of_service_url?: string | null;
254
+ privacy_policy_url?: string | null;
255
+ interactions_endpoint_url?: string | null;
256
+ /** Whether anyone can add the bot (`true`) or only its owner. */
257
+ bot_public?: boolean;
258
+ bot_require_code_grant?: boolean;
259
+ flags?: number;
260
+ }
261
+ /** What `PATCH /applications/@me` takes; a field left out is left alone, and `null` clears the ones that can be cleared. */
262
+ interface ModifyApplicationInput {
263
+ description?: string;
264
+ tags?: string[];
265
+ install_params?: DiscordInstallParams;
266
+ custom_install_url?: string | null;
267
+ role_connections_verification_url?: string | null;
268
+ terms_of_service_url?: string | null;
269
+ privacy_policy_url?: string | null;
270
+ interactions_endpoint_url?: string | null;
271
+ }
222
272
  interface ModifyWebhookInput {
223
273
  name?: string;
224
274
  /** A data URI (`data:image/png;base64,...`); `null` clears it. */
225
275
  avatar?: string | null;
226
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
+ }
227
321
  //#endregion
228
322
  //#region ../../../../../src/bot/bot.d.ts
229
323
  /**
@@ -241,9 +335,15 @@ declare function createDiscordBotClient(opts: DiscordBotClientOptions): {
241
335
  users: {
242
336
  /** `GET /users/@me` — the bot's own user; a cheap token check. */
243
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>;
244
340
  };
245
341
  guilds: {
246
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>;
247
347
  /** `PATCH /guilds/:id` — verification, notification defaults, content filter, system channels. */
248
348
  modify: (guildId: string, body: ModifyGuildInput) => Promise<DiscordGuild>;
249
349
  welcomeScreen: (guildId: string) => Promise<DiscordWelcomeScreen>;
@@ -260,16 +360,44 @@ declare function createDiscordBotClient(opts: DiscordBotClientOptions): {
260
360
  roles: (guildId: string) => Promise<DiscordRole[]>;
261
361
  createRole: (guildId: string, body: CreateRoleInput) => Promise<DiscordRole>;
262
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>;
263
378
  };
264
379
  channels: {
265
380
  modify: (channelId: string, body: ModifyChannelInput) => Promise<DiscordChannel>;
266
381
  webhooks: (channelId: string) => Promise<DiscordChannelWebhook[]>;
267
382
  /** `POST /channels/:id/messages` as the bot. Mentions are off unless `allowed_mentions` asks for them. */
268
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>;
269
391
  createWebhook: (channelId: string, body: {
270
392
  name: string;
271
393
  }) => Promise<DiscordChannelWebhook>;
272
394
  };
395
+ application: {
396
+ /** `GET /applications/@me` — the bot's own application: description, tags, install settings, URLs. */
397
+ get: () => Promise<DiscordApplication>;
398
+ /** `PATCH /applications/@me` — only what is in the body changes. */
399
+ modify: (body: ModifyApplicationInput) => Promise<DiscordApplication>;
400
+ };
273
401
  webhooks: {
274
402
  /** `PATCH /webhooks/:id` — name and avatar of a webhook this bot manages. */
275
403
  modify: (webhookId: string, body: ModifyWebhookInput) => Promise<DiscordChannelWebhook>;
@@ -344,13 +472,121 @@ declare function permissions(names: readonly PermissionName[]): string;
344
472
  /** The permission names set in a bitfield, in declaration order (unknown bits are ignored). */
345
473
  declare function permissionNames(bitfield: string): PermissionName[];
346
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
347
579
  //#region ../../../../../src/index.d.ts
348
580
  declare function createDiscordClient(opts?: DiscordClientOptions): {
349
581
  webhooks: {
350
582
  get: (id: string, token: string) => Promise<DiscordWebhookInfo>;
351
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>;
352
588
  };
353
589
  };
354
590
  type DiscordClient = ReturnType<typeof createDiscordClient>;
355
591
  //#endregion
356
- export { ChannelType, CreateChannelInput, CreateMessageInput, CreateRoleInput, DiscordBotClient, DiscordBotClientOptions, DiscordChannel, DiscordChannelWebhook, DiscordClient, type DiscordClientOptions, DiscordEmoji, DiscordGuild, DiscordMessage, DiscordOnboarding, DiscordOnboardingOption, DiscordOnboardingPrompt, DiscordPermissionOverwrite, DiscordRole, DiscordUser, type DiscordWebhookInfo, DiscordWelcomeChannel, DiscordWelcomeScreen, 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
- /** `GET /users/@me` — the bot's own user; a cheap token check. */
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,11 +281,36 @@ 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
194
303
  }))
195
304
  },
305
+ application: {
306
+ /** `GET /applications/@me` — the bot's own application: description, tags, install settings, URLs. */
307
+ get: () => rest.request("/applications/@me"),
308
+ /** `PATCH /applications/@me` — only what is in the body changes. */
309
+ modify: (body) => rest.request("/applications/@me", {
310
+ method: "PATCH",
311
+ body
312
+ })
313
+ },
196
314
  webhooks: {
197
315
  /** `PATCH /webhooks/:id` — name and avatar of a webhook this bot manages. */
198
316
  modify: async (webhookId, body) => withUrl(await rest.request(`/webhooks/${id(webhookId)}`, {
@@ -300,9 +418,135 @@ function permissionNames(bitfield) {
300
418
  return Object.keys(Permission).filter((name) => (bits & Permission[name]) !== 0n);
301
419
  }
302
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
303
547
  //#region ../../../../../src/index.ts
304
548
  function createDiscordClient(opts = {}) {
305
549
  return { webhooks: webhooks(opts.baseUrl ?? "https://discord.com/api/v10", opts.fetch ?? globalThis.fetch) };
306
550
  }
307
551
  //#endregion
308
- 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.44.0",
3
+ "version": "1.46.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.44.0"
35
+ "@theholocron/http-client": "^1.46.0"
36
36
  },
37
37
  "devDependencies": {
38
38
  "@theholocron/cli": "5.0.0-alpha.79",