@theholocron/discord-client 1.45.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,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
- /** `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,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.45.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.45.0"
35
+ "@theholocron/http-client": "^1.46.0"
36
36
  },
37
37
  "devDependencies": {
38
38
  "@theholocron/cli": "5.0.0-alpha.79",