@theholocron/holocron-plugin-discord 5.0.0-alpha.180 → 5.0.0-alpha.182
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 +20 -0
- package/dist/index.d.mts +46 -4
- package/dist/index.mjs +118 -6
- package/package.json +6 -6
package/README.md
CHANGED
|
@@ -61,3 +61,23 @@ server membership is required — generate one in Discord under
|
|
|
61
61
|
The webhook id and token are embedded in the URL path — no `Authorization`
|
|
62
62
|
header is sent. `parseWebhookUrl` is exported as a utility for callers
|
|
63
63
|
that need to split the URL into its parts.
|
|
64
|
+
|
|
65
|
+
## Provisioning
|
|
66
|
+
|
|
67
|
+
`holocron notifications setup` and `holocron notifications channel add` create
|
|
68
|
+
channels, roles and webhooks through the `notifications` capability's `ensure*`
|
|
69
|
+
methods. They use the **Holonet bot**, not a webhook URL:
|
|
70
|
+
|
|
71
|
+
- `HOLOCRON_DISCORD_BOT_TOKEN` (or `DISCORD_BOT_TOKEN`) — the bot token from
|
|
72
|
+
the Developer Portal. Only read when a provisioning command runs.
|
|
73
|
+
- `guildId` — the server id (not a secret), set in the provider options.
|
|
74
|
+
- `layout` — categories, channels (each optionally with a `webhook`) and roles.
|
|
75
|
+
|
|
76
|
+
The bot needs Manage Channels, Manage Roles, Manage Webhooks and View Channels.
|
|
77
|
+
Everything is idempotent (looked up by name). Discord returns a webhook's URL
|
|
78
|
+
only to the application that created it, so a webhook the bot did not create
|
|
79
|
+
cannot have its URL read back.
|
|
80
|
+
|
|
81
|
+
`baseUrl` only redirects posting. The bot token is always sent to the real
|
|
82
|
+
Discord API unless `unsafeBotBaseUrl: true` is also set (meant for tests that
|
|
83
|
+
stub the API), so a changed config cannot redirect the token to another host.
|
package/dist/index.d.mts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { AuthError, Notifications, ResolveTokenInput, VerifyTokenFailure, VerifyTokenResult, VerifyTokenResult as VerifyTokenResult$1, VerifyTokenSuccess } from "@theholocron/cli";
|
|
2
|
-
import { DiscordClient, DiscordClientOptions, DiscordWebhookInfo, createDiscordClient, parseWebhookUrl } from "@theholocron/discord-client";
|
|
1
|
+
import { AuthError, EnsureWebhookResult, EnsuredResource, Notifications, NotificationsLayout, ResolveTokenInput, VerifyTokenFailure, VerifyTokenResult, VerifyTokenResult as VerifyTokenResult$1, VerifyTokenSuccess } from "@theholocron/cli";
|
|
2
|
+
import { DiscordBotClient, DiscordClient, DiscordClientOptions, DiscordWebhookInfo, createDiscordClient, parseWebhookUrl } from "@theholocron/discord-client";
|
|
3
3
|
//#region src/auth.d.ts
|
|
4
4
|
declare const resolveToken: (input?: ResolveTokenInput) => string;
|
|
5
|
+
declare const resolveBotToken: (input?: ResolveTokenInput) => string;
|
|
5
6
|
//#endregion
|
|
6
7
|
//#region src/capabilities/notifications.d.ts
|
|
7
8
|
interface DiscordNotificationsOptions {
|
|
@@ -15,18 +16,51 @@ interface DiscordNotificationsOptions {
|
|
|
15
16
|
* without an explicit channel, or with an empty string.
|
|
16
17
|
*/
|
|
17
18
|
defaultChannel?: string;
|
|
19
|
+
/** Discord server (guild) id that `notifications setup` provisions. Not a secret. */
|
|
20
|
+
guildId?: string;
|
|
21
|
+
/** The server layout `notifications setup` applies. */
|
|
22
|
+
layout?: NotificationsLayout;
|
|
18
23
|
}
|
|
19
24
|
declare class DiscordNotifications implements Notifications {
|
|
20
25
|
private readonly client;
|
|
21
26
|
private readonly opts;
|
|
22
27
|
/** Memoized thunk — resolves the default webhook URL on the first `send()`. */
|
|
23
28
|
private readonly defaultWebhookUrl;
|
|
29
|
+
/** Memoized thunk — resolves the bot token on first provisioning call, never on `send()`. */
|
|
30
|
+
private readonly bot;
|
|
24
31
|
readonly key: "notifications";
|
|
25
32
|
readonly providerName = "discord";
|
|
26
33
|
constructor(client: DiscordClient, opts: DiscordNotificationsOptions,
|
|
27
34
|
/** Memoized thunk — resolves the default webhook URL on the first `send()`. */
|
|
28
|
-
defaultWebhookUrl: () => string
|
|
35
|
+
defaultWebhookUrl: () => string,
|
|
36
|
+
/** Memoized thunk — resolves the bot token on first provisioning call, never on `send()`. */
|
|
37
|
+
bot?: () => DiscordBotClient);
|
|
38
|
+
get layout(): NotificationsLayout | undefined;
|
|
29
39
|
send(channel: string, message: string): Promise<void>;
|
|
40
|
+
private guild;
|
|
41
|
+
private static same;
|
|
42
|
+
ensureCategory(name: string): Promise<EnsuredResource>;
|
|
43
|
+
/**
|
|
44
|
+
* Find a channel by name. A name can exist in several categories, so with a
|
|
45
|
+
* parent: prefer the one already there, then an uncategorised one (moved in);
|
|
46
|
+
* never grab a channel that lives under a different category. Announcement
|
|
47
|
+
* channels count: reusing one beats creating a same-named text channel.
|
|
48
|
+
*/
|
|
49
|
+
private static findChannel;
|
|
50
|
+
ensureChannel(input: {
|
|
51
|
+
name: string;
|
|
52
|
+
category?: string;
|
|
53
|
+
topic?: string;
|
|
54
|
+
}): Promise<EnsuredResource>;
|
|
55
|
+
ensureWebhook(input: {
|
|
56
|
+
channel: string;
|
|
57
|
+
name: string;
|
|
58
|
+
category?: string;
|
|
59
|
+
}): Promise<EnsureWebhookResult>;
|
|
60
|
+
ensureRole(input: {
|
|
61
|
+
name: string;
|
|
62
|
+
mentionable?: boolean;
|
|
63
|
+
}): Promise<EnsuredResource>;
|
|
30
64
|
private resolve;
|
|
31
65
|
}
|
|
32
66
|
//#endregion
|
|
@@ -42,6 +76,12 @@ interface DiscordPluginOptions extends ResolveTokenInput, DiscordNotificationsOp
|
|
|
42
76
|
/** Override base URL for tests. Default: https://discord.com/api/v10 */
|
|
43
77
|
baseUrl?: string;
|
|
44
78
|
fetch?: typeof fetch;
|
|
79
|
+
/**
|
|
80
|
+
* Also send the BOT token to `baseUrl`. Off by default: `baseUrl` comes from a
|
|
81
|
+
* repo's config, and a changed config must not be able to redirect the bot
|
|
82
|
+
* token to another host. Tests that stub the API set it.
|
|
83
|
+
*/
|
|
84
|
+
unsafeBotBaseUrl?: boolean;
|
|
45
85
|
}
|
|
46
86
|
interface PluginContext {
|
|
47
87
|
options: DiscordPluginOptions;
|
|
@@ -51,6 +91,8 @@ interface PluginContext {
|
|
|
51
91
|
* resolved on first `send()`, not at plugin load. `describe()` never calls it.
|
|
52
92
|
*/
|
|
53
93
|
defaultWebhookUrl: () => string;
|
|
94
|
+
/** Memoized — resolves the bot token on the first provisioning call; `send()` and `describe()` never touch it. */
|
|
95
|
+
bot: () => DiscordBotClient;
|
|
54
96
|
}
|
|
55
97
|
declare function createContext(options?: DiscordPluginOptions): PluginContext;
|
|
56
98
|
declare function notifications(ctx: PluginContext): Notifications;
|
|
@@ -62,4 +104,4 @@ declare function createPlugin(options?: DiscordPluginOptions): {
|
|
|
62
104
|
};
|
|
63
105
|
declare const AUTH_HINT: string;
|
|
64
106
|
//#endregion
|
|
65
|
-
export { AUTH_HINT, AuthError, type DiscordClient, type DiscordClientOptions, DiscordNotifications, type DiscordNotificationsOptions, DiscordPluginOptions, type DiscordWebhookInfo, PluginContext, type ResolveTokenInput, type VerifyTokenFailure, type VerifyTokenResult, type VerifyTokenSuccess, createContext, createDiscordClient, createPlugin, notifications, parseWebhookUrl, resolveToken, verifyToken };
|
|
107
|
+
export { AUTH_HINT, AuthError, type DiscordClient, type DiscordClientOptions, DiscordNotifications, type DiscordNotificationsOptions, DiscordPluginOptions, type DiscordWebhookInfo, PluginContext, type ResolveTokenInput, type VerifyTokenFailure, type VerifyTokenResult, type VerifyTokenSuccess, createContext, createDiscordClient, createPlugin, notifications, parseWebhookUrl, resolveBotToken, resolveToken, verifyToken };
|
package/dist/index.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { AuthError, createResolveToken } from "@theholocron/cli";
|
|
2
|
-
import { createDiscordClient, parseWebhookUrl } from "@theholocron/discord-client";
|
|
2
|
+
import { ChannelType, createDiscordBotClient, createDiscordClient, parseWebhookUrl } from "@theholocron/discord-client";
|
|
3
3
|
import { errorMessage } from "@theholocron/misc-utils";
|
|
4
4
|
//#region src/auth.ts
|
|
5
5
|
const resolveToken = createResolveToken({
|
|
@@ -8,24 +8,126 @@ const resolveToken = createResolveToken({
|
|
|
8
8
|
keyringService: "discord",
|
|
9
9
|
errorMessage: "no Discord webhook URL found. Pass --token <WEBHOOK_URL>, set HOLOCRON_DISCORD_WEBHOOK / DISCORD_WEBHOOK_URL, or run: holocron auth set discord <https://discord.com/api/webhooks/...>"
|
|
10
10
|
});
|
|
11
|
+
const resolveBotToken = createResolveToken({
|
|
12
|
+
envName: "HOLOCRON_DISCORD_BOT_TOKEN",
|
|
13
|
+
vendorEnvName: "DISCORD_BOT_TOKEN",
|
|
14
|
+
keyringService: "discord-bot",
|
|
15
|
+
errorMessage: "no Discord bot token found. Set HOLOCRON_DISCORD_BOT_TOKEN (or DISCORD_BOT_TOKEN) — it is the bot token from the Discord developer portal (Bot → Reset Token), not a webhook URL."
|
|
16
|
+
});
|
|
11
17
|
//#endregion
|
|
12
18
|
//#region src/capabilities/notifications.ts
|
|
13
|
-
var DiscordNotifications = class {
|
|
19
|
+
var DiscordNotifications = class DiscordNotifications {
|
|
14
20
|
client;
|
|
15
21
|
opts;
|
|
16
22
|
defaultWebhookUrl;
|
|
23
|
+
bot;
|
|
17
24
|
key = "notifications";
|
|
18
25
|
providerName = "discord";
|
|
19
|
-
constructor(client, opts, defaultWebhookUrl) {
|
|
26
|
+
constructor(client, opts, defaultWebhookUrl, bot = () => {
|
|
27
|
+
throw new Error("no Discord bot client configured");
|
|
28
|
+
}) {
|
|
20
29
|
this.client = client;
|
|
21
30
|
this.opts = opts;
|
|
22
31
|
this.defaultWebhookUrl = defaultWebhookUrl;
|
|
32
|
+
this.bot = bot;
|
|
33
|
+
}
|
|
34
|
+
get layout() {
|
|
35
|
+
return this.opts.layout;
|
|
23
36
|
}
|
|
24
37
|
async send(channel, message) {
|
|
25
38
|
const webhookUrl = this.resolve(channel || this.opts.defaultChannel || "");
|
|
26
39
|
const { id, token } = parseWebhookUrl(webhookUrl);
|
|
27
40
|
await this.client.webhooks.execute(id, token, message);
|
|
28
41
|
}
|
|
42
|
+
guild() {
|
|
43
|
+
if (!this.opts.guildId) throw new Error("no Discord server id — set `guildId` in the discord provider options in holocron.config.ts");
|
|
44
|
+
return this.opts.guildId;
|
|
45
|
+
}
|
|
46
|
+
static same(a, b) {
|
|
47
|
+
return a.toLowerCase() === b.toLowerCase();
|
|
48
|
+
}
|
|
49
|
+
async ensureCategory(name) {
|
|
50
|
+
const bot = this.bot();
|
|
51
|
+
const existing = (await bot.guilds.channels(this.guild())).find((c) => c.type === ChannelType.Category && DiscordNotifications.same(c.name, name));
|
|
52
|
+
if (existing) return {
|
|
53
|
+
id: existing.id,
|
|
54
|
+
created: false
|
|
55
|
+
};
|
|
56
|
+
return {
|
|
57
|
+
id: (await bot.guilds.createChannel(this.guild(), {
|
|
58
|
+
name,
|
|
59
|
+
type: ChannelType.Category
|
|
60
|
+
})).id,
|
|
61
|
+
created: true
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Find a channel by name. A name can exist in several categories, so with a
|
|
66
|
+
* parent: prefer the one already there, then an uncategorised one (moved in);
|
|
67
|
+
* never grab a channel that lives under a different category. Announcement
|
|
68
|
+
* channels count: reusing one beats creating a same-named text channel.
|
|
69
|
+
*/
|
|
70
|
+
static findChannel(channels, name, parentId) {
|
|
71
|
+
const named = channels.filter((c) => (c.type === ChannelType.Text || c.type === ChannelType.Announcement) && DiscordNotifications.same(c.name, name));
|
|
72
|
+
if (!parentId) return named[0];
|
|
73
|
+
return named.find((c) => c.parent_id === parentId) ?? named.find((c) => !c.parent_id);
|
|
74
|
+
}
|
|
75
|
+
async ensureChannel(input) {
|
|
76
|
+
const bot = this.bot();
|
|
77
|
+
const parent = input.category ? (await this.ensureCategory(input.category)).id : void 0;
|
|
78
|
+
const existing = DiscordNotifications.findChannel(await bot.guilds.channels(this.guild()), input.name, parent);
|
|
79
|
+
if (existing) {
|
|
80
|
+
const patch = {
|
|
81
|
+
...parent && existing.parent_id !== parent ? { parent_id: parent } : {},
|
|
82
|
+
...input.topic !== void 0 && (existing.topic ?? "") !== input.topic ? { topic: input.topic } : {}
|
|
83
|
+
};
|
|
84
|
+
if (Object.keys(patch).length > 0) await bot.channels.modify(existing.id, patch);
|
|
85
|
+
return {
|
|
86
|
+
id: existing.id,
|
|
87
|
+
created: false
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
id: (await bot.guilds.createChannel(this.guild(), {
|
|
92
|
+
name: input.name,
|
|
93
|
+
type: ChannelType.Text,
|
|
94
|
+
...input.topic ? { topic: input.topic } : {},
|
|
95
|
+
...parent ? { parent_id: parent } : {}
|
|
96
|
+
})).id,
|
|
97
|
+
created: true
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
async ensureWebhook(input) {
|
|
101
|
+
const bot = this.bot();
|
|
102
|
+
const parent = input.category ? (await bot.guilds.channels(this.guild())).find((c) => c.type === ChannelType.Category && DiscordNotifications.same(c.name, input.category))?.id : void 0;
|
|
103
|
+
const channel = DiscordNotifications.findChannel(await bot.guilds.channels(this.guild()), input.channel, parent);
|
|
104
|
+
if (!channel) throw new Error(`no channel named \`${input.channel}\` — create it first`);
|
|
105
|
+
const existing = (await bot.channels.webhooks(channel.id)).find((w) => DiscordNotifications.same(w.name ?? "", input.name));
|
|
106
|
+
if (existing) return {
|
|
107
|
+
created: false,
|
|
108
|
+
...existing.url ? { url: existing.url } : {}
|
|
109
|
+
};
|
|
110
|
+
const made = await bot.channels.createWebhook(channel.id, { name: input.name });
|
|
111
|
+
return {
|
|
112
|
+
created: true,
|
|
113
|
+
...made.url ? { url: made.url } : {}
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
async ensureRole(input) {
|
|
117
|
+
const bot = this.bot();
|
|
118
|
+
const existing = (await bot.guilds.roles(this.guild())).find((r) => DiscordNotifications.same(r.name, input.name));
|
|
119
|
+
if (existing) return {
|
|
120
|
+
id: existing.id,
|
|
121
|
+
created: false
|
|
122
|
+
};
|
|
123
|
+
return {
|
|
124
|
+
id: (await bot.guilds.createRole(this.guild(), {
|
|
125
|
+
name: input.name,
|
|
126
|
+
...input.mentionable !== void 0 ? { mentionable: input.mentionable } : {}
|
|
127
|
+
})).id,
|
|
128
|
+
created: true
|
|
129
|
+
};
|
|
130
|
+
}
|
|
29
131
|
resolve(channel) {
|
|
30
132
|
const alias = this.opts.webhooks?.[channel];
|
|
31
133
|
if (alias) return alias;
|
|
@@ -59,17 +161,27 @@ async function verifyToken(webhookUrl, opts = {}) {
|
|
|
59
161
|
//#region src/index.ts
|
|
60
162
|
function createContext(options = {}) {
|
|
61
163
|
let url;
|
|
164
|
+
let bot;
|
|
62
165
|
return {
|
|
63
166
|
options,
|
|
64
167
|
client: createDiscordClient({
|
|
65
168
|
baseUrl: options.baseUrl,
|
|
66
169
|
fetch: options.fetch
|
|
67
170
|
}),
|
|
68
|
-
defaultWebhookUrl: () => url ??= resolveToken(options)
|
|
171
|
+
defaultWebhookUrl: () => url ??= resolveToken(options),
|
|
172
|
+
bot: () => bot ??= createDiscordBotClient({
|
|
173
|
+
botToken: resolveBotToken({
|
|
174
|
+
env: options.env,
|
|
175
|
+
keyring: options.keyring,
|
|
176
|
+
org: options.org
|
|
177
|
+
}),
|
|
178
|
+
...options.unsafeBotBaseUrl && options.baseUrl ? { baseUrl: options.baseUrl } : {},
|
|
179
|
+
...options.fetch ? { fetch: options.fetch } : {}
|
|
180
|
+
})
|
|
69
181
|
};
|
|
70
182
|
}
|
|
71
183
|
function notifications(ctx) {
|
|
72
|
-
return new DiscordNotifications(ctx.client, ctx.options, ctx.defaultWebhookUrl);
|
|
184
|
+
return new DiscordNotifications(ctx.client, ctx.options, ctx.defaultWebhookUrl, ctx.bot);
|
|
73
185
|
}
|
|
74
186
|
function createPlugin(options = {}) {
|
|
75
187
|
const ctx = createContext(options);
|
|
@@ -80,4 +192,4 @@ function createPlugin(options = {}) {
|
|
|
80
192
|
}
|
|
81
193
|
const AUTH_HINT = "create a webhook in Discord: open your server → channel settings → Integrations → Webhooks → New Webhook, copy the webhook URL, then run: holocron auth set discord <https://discord.com/api/webhooks/...>";
|
|
82
194
|
//#endregion
|
|
83
|
-
export { AUTH_HINT, AuthError, DiscordNotifications, createContext, createDiscordClient, createPlugin, notifications, parseWebhookUrl, resolveToken, verifyToken };
|
|
195
|
+
export { AUTH_HINT, AuthError, DiscordNotifications, createContext, createDiscordClient, createPlugin, notifications, parseWebhookUrl, resolveBotToken, resolveToken, verifyToken };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@theholocron/holocron-plugin-discord",
|
|
3
|
-
"version": "5.0.0-alpha.
|
|
3
|
+
"version": "5.0.0-alpha.182",
|
|
4
4
|
"description": "Holocron plugin for Discord. Implements the notifications capability via Discord incoming webhooks.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"discord",
|
|
@@ -30,9 +30,9 @@
|
|
|
30
30
|
"dist"
|
|
31
31
|
],
|
|
32
32
|
"devDependencies": {
|
|
33
|
-
"@theholocron/discord-client": "^1.
|
|
33
|
+
"@theholocron/discord-client": "^1.43.1",
|
|
34
34
|
"@theholocron/eslint-config": "^8.8.2",
|
|
35
|
-
"@theholocron/http-client": "^1.
|
|
35
|
+
"@theholocron/http-client": "^1.43.1",
|
|
36
36
|
"@theholocron/misc-utils": "^1.16.0",
|
|
37
37
|
"@theholocron/tsconfig": "^8.8.2",
|
|
38
38
|
"@theholocron/tsdown-config": "^8.8.2",
|
|
@@ -48,12 +48,12 @@
|
|
|
48
48
|
"tsx": "4.23.15",
|
|
49
49
|
"typescript": "^5.9.3",
|
|
50
50
|
"vitest": "^4.1.11",
|
|
51
|
-
"@theholocron/cli": "5.0.0-alpha.
|
|
51
|
+
"@theholocron/cli": "5.0.0-alpha.182"
|
|
52
52
|
},
|
|
53
53
|
"peerDependencies": {
|
|
54
|
-
"@theholocron/discord-client": "^1.
|
|
54
|
+
"@theholocron/discord-client": "^1.43.1",
|
|
55
55
|
"@theholocron/misc-utils": "^1.16.0",
|
|
56
|
-
"@theholocron/cli": "5.0.0-alpha.
|
|
56
|
+
"@theholocron/cli": "5.0.0-alpha.182"
|
|
57
57
|
},
|
|
58
58
|
"engines": {
|
|
59
59
|
"node": ">=22"
|