@bozonx/social-posting-discord 0.8.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/LICENSE +21 -0
- package/README.md +128 -0
- package/dist/capabilities.d.ts +33 -0
- package/dist/capabilities.js +211 -0
- package/dist/capabilities.js.map +1 -0
- package/dist/discord-api.d.ts +59 -0
- package/dist/discord-api.js +81 -0
- package/dist/discord-api.js.map +1 -0
- package/dist/discord-auth.validator.d.ts +31 -0
- package/dist/discord-auth.validator.js +76 -0
- package/dist/discord-auth.validator.js.map +1 -0
- package/dist/discord-error.d.ts +25 -0
- package/dist/discord-error.js +78 -0
- package/dist/discord-error.js.map +1 -0
- package/dist/discord.platform.d.ts +80 -0
- package/dist/discord.platform.js +413 -0
- package/dist/discord.platform.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/package.json +49 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ivan K
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# @bozonx/social-posting-discord
|
|
2
|
+
|
|
3
|
+
Discord support for [`@bozonx/social-posting`](https://www.npmjs.com/package/@bozonx/social-posting).
|
|
4
|
+
Zero runtime dependencies, Web APIs only — it runs on Node, Bun, Deno and Cloudflare Workers.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
pnpm add @bozonx/social-posting @bozonx/social-posting-discord
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
import { createPostingClient } from '@bozonx/social-posting';
|
|
12
|
+
import { discord } from '@bozonx/social-posting-discord';
|
|
13
|
+
|
|
14
|
+
const client = createPostingClient({
|
|
15
|
+
platforms: [discord],
|
|
16
|
+
accounts: {
|
|
17
|
+
announcements: {
|
|
18
|
+
platform: 'discord',
|
|
19
|
+
auth: { webhookUrl: process.env.DISCORD_WEBHOOK_URL },
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
await client.post({ platform: 'discord', account: 'announcements', body: 'Ship it.' });
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Two access models, and why the difference matters
|
|
28
|
+
|
|
29
|
+
Discord is the only network in this set where the credential and the destination can be the same
|
|
30
|
+
string. Pick one per account; an account carrying both is refused rather than guessed at.
|
|
31
|
+
|
|
32
|
+
| | `webhookUrl` | `botToken` |
|
|
33
|
+
| ----------------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
|
|
34
|
+
| Setup | Server settings → Integrations. No OAuth, no app review. | A registered application, invited to the server with `Send Messages` (and `Attach Files`). |
|
|
35
|
+
| Destination | **Baked into the URL.** No `target` needed. | `target.id` is the channel id, and it is required. |
|
|
36
|
+
| Replies | No. | Yes, via `inReplyTo`. |
|
|
37
|
+
| Name and avatar | Overridable per message (`extra.username`, `extra.avatar_url`). | Fixed, the bot's own. |
|
|
38
|
+
| Deletes its own messages | Yes, through the webhook token. | Yes, by channel and message id. |
|
|
39
|
+
| Reads the server's boost tier | No — keeps the unboosted attachment limit. | Yes, through `resolveCapabilities()`. |
|
|
40
|
+
|
|
41
|
+
**A webhook URL is a secret, not a channel id.** Anyone holding it can post to that channel.
|
|
42
|
+
Store it the way you store a token, never in a column named `channelId`.
|
|
43
|
+
|
|
44
|
+
## Addressing
|
|
45
|
+
|
|
46
|
+
The channel is `target.id`. Two further parts are declared in `capabilities.targetSchema`:
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
target: {
|
|
50
|
+
id: '1290000000000000002', // channel id
|
|
51
|
+
guildId: '1280000000000000003', // server: used for permalinks and boost-tier limits
|
|
52
|
+
threadId: '1295000000000000007' // optional: post into a thread of that channel
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
A scalar `target` is accepted as shorthand for `{ id }`, but then no permalink can be built:
|
|
57
|
+
Discord message URLs contain the server id.
|
|
58
|
+
|
|
59
|
+
## What it publishes
|
|
60
|
+
|
|
61
|
+
| Type | Support |
|
|
62
|
+
| ------------------------------------- | ----------------------------------------------------------------- |
|
|
63
|
+
| `post` | Up to 2 000 characters, Discord-flavoured Markdown. |
|
|
64
|
+
| `image`, `video`, `audio`, `document` | One attachment, uploaded as `multipart/form-data`. |
|
|
65
|
+
| `album` | Up to 10 attachments of any mix. |
|
|
66
|
+
| `poll` | Native poll: `title` is the question, `poll.options` the answers. |
|
|
67
|
+
|
|
68
|
+
Not supported, because Discord has no such product: `shortVideo`, `story`, `article`, scheduling
|
|
69
|
+
and drafts. A vertical video is an ordinary attachment.
|
|
70
|
+
|
|
71
|
+
### Media
|
|
72
|
+
|
|
73
|
+
Discord never fetches a URL — every file is uploaded to it. A `url` source still works: this
|
|
74
|
+
adapter downloads it first, which is why the declared transport is `both`. The URL therefore has
|
|
75
|
+
to be reachable from **your** process, not from Discord, and no signed-URL lifetime requirement
|
|
76
|
+
applies.
|
|
77
|
+
|
|
78
|
+
Set `sensitive: true` on a media item to spoiler it. Discord has no flag for this: the adapter
|
|
79
|
+
prefixes the file name with `SPOILER_`, which is the mechanism Discord actually uses.
|
|
80
|
+
`altText` becomes the attachment description (max 1 024 characters).
|
|
81
|
+
|
|
82
|
+
### Attachment size is a property of the server
|
|
83
|
+
|
|
84
|
+
The shipped descriptor states the **unboosted** ceiling of 10 MiB. The real ceiling rises with the
|
|
85
|
+
server's boost tier, so a bot-token account should ask before publishing:
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
const resolved = await platform.resolveCapabilities(accountConfig);
|
|
89
|
+
// resolved.capabilities.media.video.maxBytes → 10 / 50 / 100 MiB by boost tier
|
|
90
|
+
// resolved.cacheableForSecs → 3600
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
The library caches nothing: it hands the reading back with a lifetime and your host decides where
|
|
94
|
+
it lives. A webhook account cannot read the server and keeps the floor.
|
|
95
|
+
|
|
96
|
+
## Platform options (`extra`)
|
|
97
|
+
|
|
98
|
+
`embeds`, `tts`, `flags`, `allowed_mentions`, `components`, plus `username` and `avatar_url` for
|
|
99
|
+
webhook accounts only. Keys the adapter builds itself — `content`, `attachments`, `poll`,
|
|
100
|
+
`message_reference` — are refused rather than silently overwritten.
|
|
101
|
+
|
|
102
|
+
## Errors
|
|
103
|
+
|
|
104
|
+
| Code | When |
|
|
105
|
+
| ------------------ | ------------------------------------------------------------------------ |
|
|
106
|
+
| `RATE_LIMIT_ERROR` | 429, carrying Discord's own `retry_after` as `retryAfterMs`. |
|
|
107
|
+
| `AUTH_ERROR` | 401 (token rejected) and 403 (the bot lacks permission in this channel). |
|
|
108
|
+
| `VALIDATION_ERROR` | Unknown channel or deleted webhook (404), and malformed requests. |
|
|
109
|
+
| `CONTENT_REJECTED` | An attachment or embed Discord refuses (400 with code 50035, 413). |
|
|
110
|
+
| `PLATFORM_ERROR` | 5xx, retryable. |
|
|
111
|
+
|
|
112
|
+
Publishing is a single call, so there is no resume handle and no `processing` state.
|
|
113
|
+
|
|
114
|
+
## A product note
|
|
115
|
+
|
|
116
|
+
Discord is an announcement channel, not a social network. There are no impressions, no reach and
|
|
117
|
+
no post analytics behind a message. Label it accordingly in your UI, or users will expect numbers
|
|
118
|
+
that do not exist.
|
|
119
|
+
|
|
120
|
+
## Sources
|
|
121
|
+
|
|
122
|
+
- [Create Message](https://docs.discord.com/developers/resources/message#create-message)
|
|
123
|
+
- [Execute Webhook](https://docs.discord.com/developers/resources/webhook#execute-webhook)
|
|
124
|
+
- [Uploading files](https://docs.discord.com/developers/reference#uploading-files)
|
|
125
|
+
|
|
126
|
+
## License
|
|
127
|
+
|
|
128
|
+
MIT
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { PlatformCapabilities } from '@bozonx/social-posting';
|
|
2
|
+
/** Largest message content Discord accepts. */
|
|
3
|
+
export declare const MAX_MESSAGE_LENGTH = 2000;
|
|
4
|
+
/** Largest number of attachments one message may carry. */
|
|
5
|
+
export declare const MAX_ATTACHMENTS = 10;
|
|
6
|
+
/** Longest attachment description (alt text). */
|
|
7
|
+
export declare const MAX_ALT_TEXT_LENGTH = 1024;
|
|
8
|
+
/**
|
|
9
|
+
* Attachment size ceiling for a server with no boost.
|
|
10
|
+
*
|
|
11
|
+
* A floor, not a fact: the real ceiling rises with the guild's boost tier, and
|
|
12
|
+
* `resolveCapabilities()` reads it per account. Declaring the highest tier here
|
|
13
|
+
* would let the library promise an upload most servers refuse.
|
|
14
|
+
*/
|
|
15
|
+
export declare const DEFAULT_MAX_ATTACHMENT_BYTES: number;
|
|
16
|
+
/** Attachment ceiling per guild premium tier, in bytes. */
|
|
17
|
+
export declare const ATTACHMENT_BYTES_BY_BOOST_TIER: Record<number, number>;
|
|
18
|
+
/** Discord poll limits. */
|
|
19
|
+
export declare const MAX_POLL_ANSWERS = 10;
|
|
20
|
+
export declare const MAX_POLL_ANSWER_LENGTH = 55;
|
|
21
|
+
export declare const MAX_POLL_QUESTION_LENGTH = 300;
|
|
22
|
+
/** Polls run for whole hours, from one hour to 32 days. */
|
|
23
|
+
export declare const MIN_POLL_DURATION_SECS = 3600;
|
|
24
|
+
export declare const MAX_POLL_DURATION_SECS: number;
|
|
25
|
+
/**
|
|
26
|
+
* What Discord accepts, stated as data.
|
|
27
|
+
*
|
|
28
|
+
* Two things here are deliberately absent. There is no `shortVideo` or `story`:
|
|
29
|
+
* Discord has no such product, and a vertical video is an ordinary attachment.
|
|
30
|
+
* And `maxBytes` is the unboosted floor, because the real ceiling is a property
|
|
31
|
+
* of the server rather than of the network.
|
|
32
|
+
*/
|
|
33
|
+
export declare const discordCapabilities: PlatformCapabilities;
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { PostType } from '@bozonx/social-posting';
|
|
2
|
+
/** Largest message content Discord accepts. */
|
|
3
|
+
export const MAX_MESSAGE_LENGTH = 2_000;
|
|
4
|
+
/** Largest number of attachments one message may carry. */
|
|
5
|
+
export const MAX_ATTACHMENTS = 10;
|
|
6
|
+
/** Longest attachment description (alt text). */
|
|
7
|
+
export const MAX_ALT_TEXT_LENGTH = 1_024;
|
|
8
|
+
const MIB_BYTES = 1024 * 1024;
|
|
9
|
+
/**
|
|
10
|
+
* Attachment size ceiling for a server with no boost.
|
|
11
|
+
*
|
|
12
|
+
* A floor, not a fact: the real ceiling rises with the guild's boost tier, and
|
|
13
|
+
* `resolveCapabilities()` reads it per account. Declaring the highest tier here
|
|
14
|
+
* would let the library promise an upload most servers refuse.
|
|
15
|
+
*/
|
|
16
|
+
export const DEFAULT_MAX_ATTACHMENT_BYTES = 10 * MIB_BYTES;
|
|
17
|
+
/** Attachment ceiling per guild premium tier, in bytes. */
|
|
18
|
+
export const ATTACHMENT_BYTES_BY_BOOST_TIER = {
|
|
19
|
+
0: 10 * MIB_BYTES,
|
|
20
|
+
1: 10 * MIB_BYTES,
|
|
21
|
+
2: 50 * MIB_BYTES,
|
|
22
|
+
3: 100 * MIB_BYTES,
|
|
23
|
+
};
|
|
24
|
+
/** Discord poll limits. */
|
|
25
|
+
export const MAX_POLL_ANSWERS = 10;
|
|
26
|
+
export const MAX_POLL_ANSWER_LENGTH = 55;
|
|
27
|
+
export const MAX_POLL_QUESTION_LENGTH = 300;
|
|
28
|
+
/** Polls run for whole hours, from one hour to 32 days. */
|
|
29
|
+
export const MIN_POLL_DURATION_SECS = 3_600;
|
|
30
|
+
export const MAX_POLL_DURATION_SECS = 768 * 3_600;
|
|
31
|
+
/**
|
|
32
|
+
* Discord never fetches a URL: every file is uploaded to it as
|
|
33
|
+
* `multipart/form-data`. A `url` source is still accepted because this adapter
|
|
34
|
+
* downloads it first — which is why the transport is `both` rather than `push`,
|
|
35
|
+
* and why `requiresPubliclyFetchableUrl` is absent: the URL has to be reachable
|
|
36
|
+
* from this process, not from Discord.
|
|
37
|
+
*/
|
|
38
|
+
const attachment = {
|
|
39
|
+
acceptedSources: ['url', 'bytes', 'blob', 'stream'],
|
|
40
|
+
transport: 'both',
|
|
41
|
+
maxBytes: DEFAULT_MAX_ATTACHMENT_BYTES,
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* What Discord accepts, stated as data.
|
|
45
|
+
*
|
|
46
|
+
* Two things here are deliberately absent. There is no `shortVideo` or `story`:
|
|
47
|
+
* Discord has no such product, and a vertical video is an ordinary attachment.
|
|
48
|
+
* And `maxBytes` is the unboosted floor, because the real ceiling is a property
|
|
49
|
+
* of the server rather than of the network.
|
|
50
|
+
*/
|
|
51
|
+
export const discordCapabilities = {
|
|
52
|
+
name: 'discord',
|
|
53
|
+
displayName: 'Discord',
|
|
54
|
+
sources: [
|
|
55
|
+
{
|
|
56
|
+
url: 'https://docs.discord.com/developers/resources/message#create-message',
|
|
57
|
+
supports: ['message content limit', 'attachment count', 'poll object'],
|
|
58
|
+
verifiedAt: '2026-08-30',
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
url: 'https://docs.discord.com/developers/resources/webhook#execute-webhook',
|
|
62
|
+
supports: ['webhook execution', 'thread_id', 'wait parameter'],
|
|
63
|
+
verifiedAt: '2026-08-30',
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
url: 'https://docs.discord.com/developers/reference#uploading-files',
|
|
67
|
+
supports: ['multipart/form-data attachment upload', 'attachment descriptions'],
|
|
68
|
+
verifiedAt: '2026-08-30',
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
postTypes: {
|
|
72
|
+
[PostType.POST]: {
|
|
73
|
+
requiredFields: ['body'],
|
|
74
|
+
forbiddenFields: ['media', 'poll'],
|
|
75
|
+
},
|
|
76
|
+
[PostType.IMAGE]: {
|
|
77
|
+
requiredFields: ['media'],
|
|
78
|
+
minMediaCount: 1,
|
|
79
|
+
maxMediaCount: 1,
|
|
80
|
+
mediaCounts: { image: { min: 1, max: 1 } },
|
|
81
|
+
},
|
|
82
|
+
[PostType.VIDEO]: {
|
|
83
|
+
requiredFields: ['media'],
|
|
84
|
+
minMediaCount: 1,
|
|
85
|
+
maxMediaCount: 1,
|
|
86
|
+
mediaCounts: { video: { min: 1, max: 1 } },
|
|
87
|
+
},
|
|
88
|
+
[PostType.SHORT_VIDEO]: {
|
|
89
|
+
requiredFields: ['media'],
|
|
90
|
+
minMediaCount: 1,
|
|
91
|
+
maxMediaCount: 1,
|
|
92
|
+
mediaCounts: { video: { min: 1, max: 1 } },
|
|
93
|
+
},
|
|
94
|
+
[PostType.AUDIO]: {
|
|
95
|
+
requiredFields: ['media'],
|
|
96
|
+
minMediaCount: 1,
|
|
97
|
+
maxMediaCount: 1,
|
|
98
|
+
mediaCounts: { audio: { min: 1, max: 1 } },
|
|
99
|
+
},
|
|
100
|
+
[PostType.DOCUMENT]: {
|
|
101
|
+
requiredFields: ['media'],
|
|
102
|
+
minMediaCount: 1,
|
|
103
|
+
maxMediaCount: 1,
|
|
104
|
+
mediaCounts: { document: { min: 1, max: 1 } },
|
|
105
|
+
},
|
|
106
|
+
[PostType.ALBUM]: {
|
|
107
|
+
requiredFields: ['media'],
|
|
108
|
+
minMediaCount: 2,
|
|
109
|
+
maxMediaCount: MAX_ATTACHMENTS,
|
|
110
|
+
// One message carries any mix of files; Discord draws no distinction.
|
|
111
|
+
allowsMixedMedia: true,
|
|
112
|
+
},
|
|
113
|
+
[PostType.POLL]: {
|
|
114
|
+
requiredFields: ['poll'],
|
|
115
|
+
forbiddenFields: ['media'],
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
maxBodyLength: MAX_MESSAGE_LENGTH,
|
|
119
|
+
bodyLengthRule: {},
|
|
120
|
+
// Discord renders its own Markdown flavour; HTML is not a thing there.
|
|
121
|
+
supportedBodyFormats: ['text', 'md'],
|
|
122
|
+
targetBodyFormat: 'md',
|
|
123
|
+
media: {
|
|
124
|
+
image: { ...attachment, acceptedSources: [...attachment.acceptedSources] },
|
|
125
|
+
video: { ...attachment, acceptedSources: [...attachment.acceptedSources] },
|
|
126
|
+
audio: { ...attachment, acceptedSources: [...attachment.acceptedSources] },
|
|
127
|
+
document: { ...attachment, acceptedSources: [...attachment.acceptedSources] },
|
|
128
|
+
},
|
|
129
|
+
altText: { supported: true, maxLength: MAX_ALT_TEXT_LENGTH },
|
|
130
|
+
// Discord has no per-message spoiler flag: a spoilered attachment is one
|
|
131
|
+
// whose file name starts with `SPOILER_`.
|
|
132
|
+
sensitive: { supportedValues: [false, true], default: false },
|
|
133
|
+
// The channel is `target.id`. A guild id is not part of the address Discord
|
|
134
|
+
// needs — the channel is globally unique — but it is what a permalink and the
|
|
135
|
+
// boost-tier lookup are built from, so it is part of the address we keep.
|
|
136
|
+
targetSchema: [
|
|
137
|
+
{
|
|
138
|
+
name: 'guildId',
|
|
139
|
+
type: 'string',
|
|
140
|
+
pattern: '^[0-9]{5,25}$',
|
|
141
|
+
label: 'Server id',
|
|
142
|
+
description: 'Guild the channel belongs to. Used for permalinks and boost-tier limits.',
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
name: 'threadId',
|
|
146
|
+
type: 'string',
|
|
147
|
+
pattern: '^[0-9]{5,25}$',
|
|
148
|
+
label: 'Thread id',
|
|
149
|
+
description: 'Post into a thread of the channel rather than the channel itself.',
|
|
150
|
+
},
|
|
151
|
+
],
|
|
152
|
+
poll: {
|
|
153
|
+
minOptions: 1,
|
|
154
|
+
maxOptions: MAX_POLL_ANSWERS,
|
|
155
|
+
maxOptionLength: MAX_POLL_ANSWER_LENGTH,
|
|
156
|
+
minDurationSecs: MIN_POLL_DURATION_SECS,
|
|
157
|
+
maxDurationSecs: MAX_POLL_DURATION_SECS,
|
|
158
|
+
multiple: { supportedValues: [false, true], default: false },
|
|
159
|
+
anonymous: { supportedValues: [false] },
|
|
160
|
+
},
|
|
161
|
+
supportsReply: true,
|
|
162
|
+
supportsRepost: false,
|
|
163
|
+
supportsQuote: false,
|
|
164
|
+
extraFields: [
|
|
165
|
+
{ name: 'embeds', type: 'object', label: 'Embeds', description: 'Discord embed objects.' },
|
|
166
|
+
{ name: 'tts', type: 'boolean' },
|
|
167
|
+
{ name: 'flags', type: 'number' },
|
|
168
|
+
{ name: 'allowed_mentions', type: 'object' },
|
|
169
|
+
{ name: 'components', type: 'object' },
|
|
170
|
+
{
|
|
171
|
+
name: 'username',
|
|
172
|
+
type: 'string',
|
|
173
|
+
label: 'Override name',
|
|
174
|
+
description: 'Webhook only: the name the message is posted under.',
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
name: 'avatar_url',
|
|
178
|
+
type: 'string',
|
|
179
|
+
label: 'Override avatar',
|
|
180
|
+
description: 'Webhook only: the avatar the message is posted with.',
|
|
181
|
+
},
|
|
182
|
+
],
|
|
183
|
+
allowUnknownExtraFields: false,
|
|
184
|
+
auth: {
|
|
185
|
+
kind: 'apiKey',
|
|
186
|
+
// A webhook URL carries its own destination, so a target is not universally
|
|
187
|
+
// required; `validateExtra()` requires one for bot-token accounts.
|
|
188
|
+
requiresTarget: false,
|
|
189
|
+
docsUrl: 'https://docs.discord.com/developers/resources/webhook#execute-webhook',
|
|
190
|
+
},
|
|
191
|
+
rateLimits: {
|
|
192
|
+
note: 'Per-route buckets; hosts must honour retry_after from 429 responses. Discord does not publish a fixed per-channel message quota.',
|
|
193
|
+
},
|
|
194
|
+
supportsNativeScheduling: false,
|
|
195
|
+
supportsDraft: false,
|
|
196
|
+
supportsIdempotencyKey: false,
|
|
197
|
+
supportsDeletion: true,
|
|
198
|
+
ignoredFields: [
|
|
199
|
+
'title',
|
|
200
|
+
'description',
|
|
201
|
+
'language',
|
|
202
|
+
'tags',
|
|
203
|
+
'thumbnail',
|
|
204
|
+
'visibility',
|
|
205
|
+
'contentWarning',
|
|
206
|
+
'commentsEnabled',
|
|
207
|
+
'location',
|
|
208
|
+
'repostOf',
|
|
209
|
+
],
|
|
210
|
+
};
|
|
211
|
+
//# sourceMappingURL=capabilities.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"capabilities.js","sourceRoot":"","sources":["../src/capabilities.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,wBAAwB,CAAC;AAGlD,+CAA+C;AAC/C,MAAM,CAAC,MAAM,kBAAkB,GAAG,KAAK,CAAC;AACxC,2DAA2D;AAC3D,MAAM,CAAC,MAAM,eAAe,GAAG,EAAE,CAAC;AAClC,iDAAiD;AACjD,MAAM,CAAC,MAAM,mBAAmB,GAAG,KAAK,CAAC;AAEzC,MAAM,SAAS,GAAG,IAAI,GAAG,IAAI,CAAC;AAE9B;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,4BAA4B,GAAG,EAAE,GAAG,SAAS,CAAC;AAE3D,2DAA2D;AAC3D,MAAM,CAAC,MAAM,8BAA8B,GAA2B;IACpE,CAAC,EAAE,EAAE,GAAG,SAAS;IACjB,CAAC,EAAE,EAAE,GAAG,SAAS;IACjB,CAAC,EAAE,EAAE,GAAG,SAAS;IACjB,CAAC,EAAE,GAAG,GAAG,SAAS;CACnB,CAAC;AAEF,2BAA2B;AAC3B,MAAM,CAAC,MAAM,gBAAgB,GAAG,EAAE,CAAC;AACnC,MAAM,CAAC,MAAM,sBAAsB,GAAG,EAAE,CAAC;AACzC,MAAM,CAAC,MAAM,wBAAwB,GAAG,GAAG,CAAC;AAC5C,2DAA2D;AAC3D,MAAM,CAAC,MAAM,sBAAsB,GAAG,KAAK,CAAC;AAC5C,MAAM,CAAC,MAAM,sBAAsB,GAAG,GAAG,GAAG,KAAK,CAAC;AAElD;;;;;;GAMG;AACH,MAAM,UAAU,GAAG;IACjB,eAAe,EAAE,CAAC,KAAK,EAAE,OAAO,EAAE,MAAM,EAAE,QAAQ,CAAU;IAC5D,SAAS,EAAE,MAAe;IAC1B,QAAQ,EAAE,4BAA4B;CACvC,CAAC;AAEF;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,mBAAmB,GAAyB;IACvD,IAAI,EAAE,SAAS;IACf,WAAW,EAAE,SAAS;IACtB,OAAO,EAAE;QACP;YACE,GAAG,EAAE,sEAAsE;YAC3E,QAAQ,EAAE,CAAC,uBAAuB,EAAE,kBAAkB,EAAE,aAAa,CAAC;YACtE,UAAU,EAAE,YAAY;SACzB;QACD;YACE,GAAG,EAAE,uEAAuE;YAC5E,QAAQ,EAAE,CAAC,mBAAmB,EAAE,WAAW,EAAE,gBAAgB,CAAC;YAC9D,UAAU,EAAE,YAAY;SACzB;QACD;YACE,GAAG,EAAE,+DAA+D;YACpE,QAAQ,EAAE,CAAC,uCAAuC,EAAE,yBAAyB,CAAC;YAC9E,UAAU,EAAE,YAAY;SACzB;KACF;IAED,SAAS,EAAE;QACT,CAAC,QAAQ,CAAC,IAAI,CAAC,EAAE;YACf,cAAc,EAAE,CAAC,MAAM,CAAC;YACxB,eAAe,EAAE,CAAC,OAAO,EAAE,MAAM,CAAC;SACnC;QACD,CAAC,QAAQ,CAAC,KAAK,CAAC,EAAE;YAChB,cAAc,EAAE,CAAC,OAAO,CAAC;YACzB,aAAa,EAAE,CAAC;YAChB,aAAa,EAAE,CAAC;YAChB,WAAW,EAAE,EAAE,KAAK,EAAE,EAAE,GAAG,EAAE,CAAC,EAAE,GAAG,EAAE,CAAC,EAAE,EAAE;SAC3C;QACD,CAAC,QAAQ,CAAC,KAAK,CAAC,EAAE;YAChB,cAAc,EAAE,CAAC,OAAO,CAAC;YACzB,aAAa,EAAE,CAAC;YAChB,aAAa,EAAE,CAAC;YAChB,WAAW,EAAE,EAAE,KAAK,EAAE,EAAE,GAAG,EAAE,CAAC,EAAE,GAAG,EAAE,CAAC,EAAE,EAAE;SAC3C;QACD,CAAC,QAAQ,CAAC,WAAW,CAAC,EAAE;YACtB,cAAc,EAAE,CAAC,OAAO,CAAC;YACzB,aAAa,EAAE,CAAC;YAChB,aAAa,EAAE,CAAC;YAChB,WAAW,EAAE,EAAE,KAAK,EAAE,EAAE,GAAG,EAAE,CAAC,EAAE,GAAG,EAAE,CAAC,EAAE,EAAE;SAC3C;QACD,CAAC,QAAQ,CAAC,KAAK,CAAC,EAAE;YAChB,cAAc,EAAE,CAAC,OAAO,CAAC;YACzB,aAAa,EAAE,CAAC;YAChB,aAAa,EAAE,CAAC;YAChB,WAAW,EAAE,EAAE,KAAK,EAAE,EAAE,GAAG,EAAE,CAAC,EAAE,GAAG,EAAE,CAAC,EAAE,EAAE;SAC3C;QACD,CAAC,QAAQ,CAAC,QAAQ,CAAC,EAAE;YACnB,cAAc,EAAE,CAAC,OAAO,CAAC;YACzB,aAAa,EAAE,CAAC;YAChB,aAAa,EAAE,CAAC;YAChB,WAAW,EAAE,EAAE,QAAQ,EAAE,EAAE,GAAG,EAAE,CAAC,EAAE,GAAG,EAAE,CAAC,EAAE,EAAE;SAC9C;QACD,CAAC,QAAQ,CAAC,KAAK,CAAC,EAAE;YAChB,cAAc,EAAE,CAAC,OAAO,CAAC;YACzB,aAAa,EAAE,CAAC;YAChB,aAAa,EAAE,eAAe;YAC9B,sEAAsE;YACtE,gBAAgB,EAAE,IAAI;SACvB;QACD,CAAC,QAAQ,CAAC,IAAI,CAAC,EAAE;YACf,cAAc,EAAE,CAAC,MAAM,CAAC;YACxB,eAAe,EAAE,CAAC,OAAO,CAAC;SAC3B;KACF;IAED,aAAa,EAAE,kBAAkB;IACjC,cAAc,EAAE,EAAE;IAElB,uEAAuE;IACvE,oBAAoB,EAAE,CAAC,MAAM,EAAE,IAAI,CAAC;IACpC,gBAAgB,EAAE,IAAI;IAEtB,KAAK,EAAE;QACL,KAAK,EAAE,EAAE,GAAG,UAAU,EAAE,eAAe,EAAE,CAAC,GAAG,UAAU,CAAC,eAAe,CAAC,EAAE;QAC1E,KAAK,EAAE,EAAE,GAAG,UAAU,EAAE,eAAe,EAAE,CAAC,GAAG,UAAU,CAAC,eAAe,CAAC,EAAE;QAC1E,KAAK,EAAE,EAAE,GAAG,UAAU,EAAE,eAAe,EAAE,CAAC,GAAG,UAAU,CAAC,eAAe,CAAC,EAAE;QAC1E,QAAQ,EAAE,EAAE,GAAG,UAAU,EAAE,eAAe,EAAE,CAAC,GAAG,UAAU,CAAC,eAAe,CAAC,EAAE;KAC9E;IAED,OAAO,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,SAAS,EAAE,mBAAmB,EAAE;IAE5D,yEAAyE;IACzE,0CAA0C;IAC1C,SAAS,EAAE,EAAE,eAAe,EAAE,CAAC,KAAK,EAAE,IAAI,CAAC,EAAE,OAAO,EAAE,KAAK,EAAE;IAE7D,4EAA4E;IAC5E,8EAA8E;IAC9E,0EAA0E;IAC1E,YAAY,EAAE;QACZ;YACE,IAAI,EAAE,SAAS;YACf,IAAI,EAAE,QAAQ;YACd,OAAO,EAAE,eAAe;YACxB,KAAK,EAAE,WAAW;YAClB,WAAW,EAAE,0EAA0E;SACxF;QACD;YACE,IAAI,EAAE,UAAU;YAChB,IAAI,EAAE,QAAQ;YACd,OAAO,EAAE,eAAe;YACxB,KAAK,EAAE,WAAW;YAClB,WAAW,EAAE,mEAAmE;SACjF;KACF;IAED,IAAI,EAAE;QACJ,UAAU,EAAE,CAAC;QACb,UAAU,EAAE,gBAAgB;QAC5B,eAAe,EAAE,sBAAsB;QACvC,eAAe,EAAE,sBAAsB;QACvC,eAAe,EAAE,sBAAsB;QACvC,QAAQ,EAAE,EAAE,eAAe,EAAE,CAAC,KAAK,EAAE,IAAI,CAAC,EAAE,OAAO,EAAE,KAAK,EAAE;QAC5D,SAAS,EAAE,EAAE,eAAe,EAAE,CAAC,KAAK,CAAC,EAAE;KACxC;IAED,aAAa,EAAE,IAAI;IACnB,cAAc,EAAE,KAAK;IACrB,aAAa,EAAE,KAAK;IAEpB,WAAW,EAAE;QACX,EAAE,IAAI,EAAE,QAAQ,EAAE,IAAI,EAAE,QAAQ,EAAE,KAAK,EAAE,QAAQ,EAAE,WAAW,EAAE,wBAAwB,EAAE;QAC1F,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,SAAS,EAAE;QAChC,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE;QACjC,EAAE,IAAI,EAAE,kBAAkB,EAAE,IAAI,EAAE,QAAQ,EAAE;QAC5C,EAAE,IAAI,EAAE,YAAY,EAAE,IAAI,EAAE,QAAQ,EAAE;QACtC;YACE,IAAI,EAAE,UAAU;YAChB,IAAI,EAAE,QAAQ;YACd,KAAK,EAAE,eAAe;YACtB,WAAW,EAAE,qDAAqD;SACnE;QACD;YACE,IAAI,EAAE,YAAY;YAClB,IAAI,EAAE,QAAQ;YACd,KAAK,EAAE,iBAAiB;YACxB,WAAW,EAAE,sDAAsD;SACpE;KACF;IACD,uBAAuB,EAAE,KAAK;IAE9B,IAAI,EAAE;QACJ,IAAI,EAAE,QAAQ;QACd,4EAA4E;QAC5E,mEAAmE;QACnE,cAAc,EAAE,KAAK;QACrB,OAAO,EAAE,uEAAuE;KACjF;IAED,UAAU,EAAE;QACV,IAAI,EAAE,kIAAkI;KACzI;IAED,wBAAwB,EAAE,KAAK;IAC/B,aAAa,EAAE,KAAK;IACpB,sBAAsB,EAAE,KAAK;IAC7B,gBAAgB,EAAE,IAAI;IAEtB,aAAa,EAAE;QACb,OAAO;QACP,aAAa;QACb,UAAU;QACV,MAAM;QACN,WAAW;QACX,YAAY;QACZ,gBAAgB;QAChB,iBAAiB;QACjB,UAAU;QACV,UAAU;KACX;CACF,CAAC"}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/** The API version this package speaks, pinned deliberately. */
|
|
2
|
+
export declare const API_VERSION = "v10";
|
|
3
|
+
/** Default API root. Overridable per account through `apiBaseUrl`. */
|
|
4
|
+
export declare const DEFAULT_API_BASE_URL = "https://discord.com/api";
|
|
5
|
+
/** The message object Discord returns, in the fields this adapter reads. */
|
|
6
|
+
export interface DiscordMessage {
|
|
7
|
+
id: string;
|
|
8
|
+
channel_id: string;
|
|
9
|
+
/** Present on webhook responses; absent on bot responses. */
|
|
10
|
+
guild_id?: string;
|
|
11
|
+
[key: string]: unknown;
|
|
12
|
+
}
|
|
13
|
+
/** How one request authorizes itself. */
|
|
14
|
+
export type DiscordAuthorization = {
|
|
15
|
+
kind: 'bot';
|
|
16
|
+
token: string;
|
|
17
|
+
}
|
|
18
|
+
/** A webhook authorizes by its URL alone; there is no header. */
|
|
19
|
+
| {
|
|
20
|
+
kind: 'webhook';
|
|
21
|
+
};
|
|
22
|
+
export interface DiscordRequest {
|
|
23
|
+
/** Absolute URL to call. */
|
|
24
|
+
url: string;
|
|
25
|
+
method: 'POST' | 'DELETE' | 'GET' | 'PATCH';
|
|
26
|
+
authorization: DiscordAuthorization;
|
|
27
|
+
/** A JSON body, or a `FormData` when the message carries attachments. */
|
|
28
|
+
body?: FormData | Record<string, unknown>;
|
|
29
|
+
signal?: AbortSignal;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Discord's REST API over plain `fetch`.
|
|
33
|
+
*
|
|
34
|
+
* No SDK: discord.js wraps the failure in its own class and loses the HTTP
|
|
35
|
+
* status, Discord's own numeric code and the `retry_after` this library is
|
|
36
|
+
* built on — and it will not run on Workers.
|
|
37
|
+
*/
|
|
38
|
+
export declare class DiscordApi {
|
|
39
|
+
private readonly baseUrl;
|
|
40
|
+
private readonly timeoutMs?;
|
|
41
|
+
private readonly fetch?;
|
|
42
|
+
constructor(options?: {
|
|
43
|
+
baseUrl?: string;
|
|
44
|
+
timeoutSeconds?: number;
|
|
45
|
+
fetch?: typeof fetch;
|
|
46
|
+
});
|
|
47
|
+
/** Build an absolute URL for an API path such as `/channels/1/messages`. */
|
|
48
|
+
endpoint(path: string): string;
|
|
49
|
+
/**
|
|
50
|
+
* Perform one API call.
|
|
51
|
+
*
|
|
52
|
+
* @param request - Target, method, authorization and body.
|
|
53
|
+
* @returns The parsed JSON response, or undefined for a `204`.
|
|
54
|
+
* @throws PlatformError classified by {@link toPlatformError}.
|
|
55
|
+
*/
|
|
56
|
+
call<T>(request: DiscordRequest): Promise<T | undefined>;
|
|
57
|
+
/** Combine the caller's signal with the account's own API timeout. */
|
|
58
|
+
private withTimeout;
|
|
59
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { httpRequest } from '@bozonx/social-posting/platform';
|
|
2
|
+
import { toPlatformError } from './discord-error.js';
|
|
3
|
+
/** The API version this package speaks, pinned deliberately. */
|
|
4
|
+
export const API_VERSION = 'v10';
|
|
5
|
+
/** Default API root. Overridable per account through `apiBaseUrl`. */
|
|
6
|
+
export const DEFAULT_API_BASE_URL = 'https://discord.com/api';
|
|
7
|
+
/**
|
|
8
|
+
* Discord's REST API over plain `fetch`.
|
|
9
|
+
*
|
|
10
|
+
* No SDK: discord.js wraps the failure in its own class and loses the HTTP
|
|
11
|
+
* status, Discord's own numeric code and the `retry_after` this library is
|
|
12
|
+
* built on — and it will not run on Workers.
|
|
13
|
+
*/
|
|
14
|
+
export class DiscordApi {
|
|
15
|
+
baseUrl;
|
|
16
|
+
timeoutMs;
|
|
17
|
+
fetch;
|
|
18
|
+
constructor(options = {}) {
|
|
19
|
+
this.baseUrl = trimTrailingSlash(options.baseUrl ?? DEFAULT_API_BASE_URL);
|
|
20
|
+
this.timeoutMs =
|
|
21
|
+
options.timeoutSeconds === undefined ? undefined : options.timeoutSeconds * 1000;
|
|
22
|
+
this.fetch = options.fetch;
|
|
23
|
+
}
|
|
24
|
+
/** Build an absolute URL for an API path such as `/channels/1/messages`. */
|
|
25
|
+
endpoint(path) {
|
|
26
|
+
return `${this.baseUrl}/${API_VERSION}${path}`;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Perform one API call.
|
|
30
|
+
*
|
|
31
|
+
* @param request - Target, method, authorization and body.
|
|
32
|
+
* @returns The parsed JSON response, or undefined for a `204`.
|
|
33
|
+
* @throws PlatformError classified by {@link toPlatformError}.
|
|
34
|
+
*/
|
|
35
|
+
async call(request) {
|
|
36
|
+
const headers = {};
|
|
37
|
+
if (request.authorization.kind === 'bot') {
|
|
38
|
+
headers.authorization = `Bot ${request.authorization.token}`;
|
|
39
|
+
}
|
|
40
|
+
let body;
|
|
41
|
+
if (request.body instanceof FormData) {
|
|
42
|
+
// Never set content-type by hand for FormData: the boundary is generated
|
|
43
|
+
// by the runtime and a hand-written header loses it.
|
|
44
|
+
body = request.body;
|
|
45
|
+
}
|
|
46
|
+
else if (request.body !== undefined) {
|
|
47
|
+
headers['content-type'] = 'application/json';
|
|
48
|
+
body = JSON.stringify(request.body);
|
|
49
|
+
}
|
|
50
|
+
const response = await httpRequest(request.url, {
|
|
51
|
+
method: request.method,
|
|
52
|
+
headers,
|
|
53
|
+
body,
|
|
54
|
+
// An attachment upload is a one-shot stream and a mutating call: it must
|
|
55
|
+
// never be replayed behind the caller's back.
|
|
56
|
+
replayableBody: !(request.body instanceof FormData),
|
|
57
|
+
signal: this.withTimeout(request.signal),
|
|
58
|
+
fetch: this.fetch,
|
|
59
|
+
});
|
|
60
|
+
if (!response.ok) {
|
|
61
|
+
const parsed = (await response.json().catch(() => undefined));
|
|
62
|
+
throw toPlatformError(response.status, parsed, response.headers.get('retry-after'));
|
|
63
|
+
}
|
|
64
|
+
if (response.status === 204) {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
return (await response.json().catch(() => undefined));
|
|
68
|
+
}
|
|
69
|
+
/** Combine the caller's signal with the account's own API timeout. */
|
|
70
|
+
withTimeout(signal) {
|
|
71
|
+
if (this.timeoutMs === undefined) {
|
|
72
|
+
return signal;
|
|
73
|
+
}
|
|
74
|
+
const timeout = AbortSignal.timeout(this.timeoutMs);
|
|
75
|
+
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function trimTrailingSlash(url) {
|
|
79
|
+
return url.endsWith('/') ? url.slice(0, -1) : url;
|
|
80
|
+
}
|
|
81
|
+
//# sourceMappingURL=discord-api.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"discord-api.js","sourceRoot":"","sources":["../src/discord-api.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { AuthValidation, IAuthValidator } from '@bozonx/social-posting/platform';
|
|
2
|
+
/** The two ways an account may address Discord. */
|
|
3
|
+
export type DiscordAuthMode = 'webhook' | 'bot';
|
|
4
|
+
/** A webhook URL split into the parts every webhook call needs. */
|
|
5
|
+
export interface DiscordWebhookParts {
|
|
6
|
+
id: string;
|
|
7
|
+
token: string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Validates the shape of Discord credentials.
|
|
11
|
+
*
|
|
12
|
+
* Discord is the one network in the set where the credential and the
|
|
13
|
+
* destination can be the same string: a webhook URL has the guild and channel
|
|
14
|
+
* baked into it. That is why this validator reports which of the two models an
|
|
15
|
+
* account uses — the rest of the adapter branches on the answer, and a host
|
|
16
|
+
* storing a webhook URL must treat it as a secret, not as a channel id.
|
|
17
|
+
*/
|
|
18
|
+
export declare class DiscordAuthValidator implements IAuthValidator {
|
|
19
|
+
readonly providerName = "discord";
|
|
20
|
+
validate(auth: Record<string, unknown>): AuthValidation;
|
|
21
|
+
}
|
|
22
|
+
/** Validate Discord auth without constructing an adapter or performing I/O. */
|
|
23
|
+
export declare function validateDiscordAuth(auth: Record<string, unknown>): AuthValidation;
|
|
24
|
+
/**
|
|
25
|
+
* Split a webhook URL into its id and token.
|
|
26
|
+
* @param url - The webhook URL from the account credentials.
|
|
27
|
+
* @returns The parts, or undefined when the URL is not a webhook URL.
|
|
28
|
+
*/
|
|
29
|
+
export declare function parseWebhookUrl(url: string): DiscordWebhookParts | undefined;
|
|
30
|
+
/** Which access model an account's credentials select. */
|
|
31
|
+
export declare function authModeOf(auth: Record<string, unknown>): DiscordAuthMode | undefined;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/** Where a webhook URL lives, and the two ids inside it. */
|
|
2
|
+
const WEBHOOK_URL_PATTERN = /^https:\/\/(?:[a-z0-9-]+\.)?discord(?:app)?\.com\/api(?:\/v\d+)?\/webhooks\/(\d{5,25})\/([\w-]{20,})$/i;
|
|
3
|
+
/** What a bot token looks like: three dot-separated base64url segments. */
|
|
4
|
+
const BOT_TOKEN_PATTERN = /^[\w-]{20,}\.[\w-]{5,}\.[\w-]{20,}$/;
|
|
5
|
+
/**
|
|
6
|
+
* Validates the shape of Discord credentials.
|
|
7
|
+
*
|
|
8
|
+
* Discord is the one network in the set where the credential and the
|
|
9
|
+
* destination can be the same string: a webhook URL has the guild and channel
|
|
10
|
+
* baked into it. That is why this validator reports which of the two models an
|
|
11
|
+
* account uses — the rest of the adapter branches on the answer, and a host
|
|
12
|
+
* storing a webhook URL must treat it as a secret, not as a channel id.
|
|
13
|
+
*/
|
|
14
|
+
export class DiscordAuthValidator {
|
|
15
|
+
providerName = 'discord';
|
|
16
|
+
validate(auth) {
|
|
17
|
+
return validateDiscordAuth(auth);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/** Validate Discord auth without constructing an adapter or performing I/O. */
|
|
21
|
+
export function validateDiscordAuth(auth) {
|
|
22
|
+
const errors = [];
|
|
23
|
+
const { webhookUrl, botToken } = auth;
|
|
24
|
+
const hasWebhook = webhookUrl !== undefined && webhookUrl !== null && webhookUrl !== '';
|
|
25
|
+
const hasBot = botToken !== undefined && botToken !== null && botToken !== '';
|
|
26
|
+
if (!hasWebhook && !hasBot) {
|
|
27
|
+
errors.push("Discord auth requires either 'webhookUrl' or 'botToken'");
|
|
28
|
+
return { errors };
|
|
29
|
+
}
|
|
30
|
+
if (hasWebhook && hasBot) {
|
|
31
|
+
// Not a convenience: the two models produce different messages, different
|
|
32
|
+
// permalinks and different delete calls. Guessing between them is worse
|
|
33
|
+
// than refusing.
|
|
34
|
+
errors.push("Discord auth must carry exactly one of 'webhookUrl' or 'botToken', not both — they are different access models");
|
|
35
|
+
return { errors };
|
|
36
|
+
}
|
|
37
|
+
if (hasWebhook) {
|
|
38
|
+
if (typeof webhookUrl !== 'string') {
|
|
39
|
+
errors.push("Field 'webhookUrl' must be a string");
|
|
40
|
+
}
|
|
41
|
+
else if (!parseWebhookUrl(webhookUrl)) {
|
|
42
|
+
errors.push("Field 'webhookUrl' has invalid format (expected: https://discord.com/api/webhooks/<id>/<token>)");
|
|
43
|
+
}
|
|
44
|
+
return { errors };
|
|
45
|
+
}
|
|
46
|
+
if (typeof botToken !== 'string') {
|
|
47
|
+
errors.push("Field 'botToken' must be a string");
|
|
48
|
+
}
|
|
49
|
+
else if (!BOT_TOKEN_PATTERN.test(botToken)) {
|
|
50
|
+
errors.push("Field 'botToken' has invalid format");
|
|
51
|
+
}
|
|
52
|
+
return { errors };
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Split a webhook URL into its id and token.
|
|
56
|
+
* @param url - The webhook URL from the account credentials.
|
|
57
|
+
* @returns The parts, or undefined when the URL is not a webhook URL.
|
|
58
|
+
*/
|
|
59
|
+
export function parseWebhookUrl(url) {
|
|
60
|
+
const match = WEBHOOK_URL_PATTERN.exec(url.trim());
|
|
61
|
+
if (!match) {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
return { id: match[1], token: match[2] };
|
|
65
|
+
}
|
|
66
|
+
/** Which access model an account's credentials select. */
|
|
67
|
+
export function authModeOf(auth) {
|
|
68
|
+
if (typeof auth.webhookUrl === 'string' && auth.webhookUrl.length > 0) {
|
|
69
|
+
return 'webhook';
|
|
70
|
+
}
|
|
71
|
+
if (typeof auth.botToken === 'string' && auth.botToken.length > 0) {
|
|
72
|
+
return 'bot';
|
|
73
|
+
}
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
//# sourceMappingURL=discord-auth.validator.js.map
|