@theholocron/holocron-plugin-discord 5.0.0-alpha.18 → 5.0.0-alpha.181

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
@@ -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,30 +1,8 @@
1
- import { AuthError, Notifications, ResolveTokenInput } from "@theholocron/cli";
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";
2
3
  //#region src/auth.d.ts
3
4
  declare const resolveToken: (input?: ResolveTokenInput) => string;
4
- //#endregion
5
- //#region src/rest.d.ts
6
- interface DiscordClientOptions {
7
- /** Override base URL for tests. Default: https://discord.com/api/v10 */
8
- baseUrl?: string;
9
- fetch?: typeof fetch;
10
- }
11
- interface DiscordWebhookInfo {
12
- id: string;
13
- name: string;
14
- guild_id?: string;
15
- }
16
- interface DiscordClient {
17
- webhooks: {
18
- get(id: string, token: string): Promise<DiscordWebhookInfo>;
19
- execute(id: string, token: string, content: string): Promise<void>;
20
- };
21
- }
22
- declare function createDiscordClient({ baseUrl, fetch: fetchImpl }?: DiscordClientOptions): DiscordClient;
23
- /** Parse a Discord webhook URL into its id and token parts. */
24
- declare function parseWebhookUrl(url: string): {
25
- id: string;
26
- token: string;
27
- };
5
+ declare const resolveBotToken: (input?: ResolveTokenInput) => string;
28
6
  //#endregion
29
7
  //#region src/capabilities/notifications.d.ts
30
8
  interface DiscordNotificationsOptions {
@@ -38,42 +16,72 @@ interface DiscordNotificationsOptions {
38
16
  * without an explicit channel, or with an empty string.
39
17
  */
40
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;
41
23
  }
42
24
  declare class DiscordNotifications implements Notifications {
43
25
  private readonly client;
44
26
  private readonly opts;
45
27
  /** Memoized thunk — resolves the default webhook URL on the first `send()`. */
46
28
  private readonly defaultWebhookUrl;
29
+ /** Memoized thunk — resolves the bot token on first provisioning call, never on `send()`. */
30
+ private readonly bot;
47
31
  readonly key: "notifications";
48
32
  readonly providerName = "discord";
49
33
  constructor(client: DiscordClient, opts: DiscordNotificationsOptions,
50
34
  /** Memoized thunk — resolves the default webhook URL on the first `send()`. */
51
- 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;
52
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>;
53
64
  private resolve;
54
65
  }
55
66
  //#endregion
56
67
  //#region src/verify-token.d.ts
57
- interface VerifyTokenSuccess {
58
- ok: true;
59
- subject: string;
60
- }
61
- interface VerifyTokenFailure {
62
- ok: false;
63
- message: string;
64
- }
65
- type VerifyTokenResult = VerifyTokenSuccess | VerifyTokenFailure;
66
68
  interface VerifyTokenOptions {
67
69
  baseUrl?: string;
68
70
  fetch?: typeof fetch;
69
71
  }
70
- declare function verifyToken(webhookUrl: string, opts?: VerifyTokenOptions): Promise<VerifyTokenResult>;
72
+ declare function verifyToken(webhookUrl: string, opts?: VerifyTokenOptions): Promise<VerifyTokenResult$1>;
71
73
  //#endregion
72
74
  //#region src/index.d.ts
73
75
  interface DiscordPluginOptions extends ResolveTokenInput, DiscordNotificationsOptions {
74
76
  /** Override base URL for tests. Default: https://discord.com/api/v10 */
75
77
  baseUrl?: string;
76
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;
77
85
  }
78
86
  interface PluginContext {
79
87
  options: DiscordPluginOptions;
@@ -83,6 +91,8 @@ interface PluginContext {
83
91
  * resolved on first `send()`, not at plugin load. `describe()` never calls it.
84
92
  */
85
93
  defaultWebhookUrl: () => string;
94
+ /** Memoized — resolves the bot token on the first provisioning call; `send()` and `describe()` never touch it. */
95
+ bot: () => DiscordBotClient;
86
96
  }
87
97
  declare function createContext(options?: DiscordPluginOptions): PluginContext;
88
98
  declare function notifications(ctx: PluginContext): Notifications;
@@ -94,4 +104,4 @@ declare function createPlugin(options?: DiscordPluginOptions): {
94
104
  };
95
105
  declare const AUTH_HINT: string;
96
106
  //#endregion
97
- 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,4 +1,6 @@
1
- import { AuthError, ProviderApiError, createResolveToken } from "@theholocron/cli";
1
+ import { AuthError, createResolveToken } from "@theholocron/cli";
2
+ import { ChannelType, createDiscordBotClient, createDiscordClient, parseWebhookUrl } from "@theholocron/discord-client";
3
+ import { errorMessage } from "@theholocron/misc-utils";
2
4
  //#region src/auth.ts
3
5
  const resolveToken = createResolveToken({
4
6
  envName: "HOLOCRON_DISCORD_WEBHOOK",
@@ -6,53 +8,126 @@ const resolveToken = createResolveToken({
6
8
  keyringService: "discord",
7
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/...>"
8
10
  });
9
- //#endregion
10
- //#region src/rest.ts
11
- function createDiscordClient({ baseUrl, fetch: fetchImpl } = {}) {
12
- const base = baseUrl ?? "https://discord.com/api/v10";
13
- const f = fetchImpl ?? globalThis.fetch;
14
- return { webhooks: {
15
- get: async (id, token) => {
16
- const res = await f(`${base}/webhooks/${id}/${token}`, { method: "GET" });
17
- if (!res.ok) throw new ProviderApiError(`Discord webhook not found or invalid (${res.status})`, res.status, void 0);
18
- return res.json();
19
- },
20
- execute: async (id, token, content) => {
21
- const res = await f(`${base}/webhooks/${id}/${token}`, {
22
- method: "POST",
23
- headers: { "content-type": "application/json" },
24
- body: JSON.stringify({ content })
25
- });
26
- if (!res.ok) throw new ProviderApiError(`Discord webhook POST failed (${res.status})`, res.status, void 0);
27
- }
28
- } };
29
- }
30
- /** Parse a Discord webhook URL into its id and token parts. */
31
- function parseWebhookUrl(url) {
32
- const match = url.match(/webhooks\/(\d+)\/([^/?#]+)/);
33
- if (!match) throw new Error(`Invalid Discord webhook URL: ${url}`);
34
- return {
35
- id: match[1],
36
- token: match[2]
37
- };
38
- }
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
+ });
39
17
  //#endregion
40
18
  //#region src/capabilities/notifications.ts
41
- var DiscordNotifications = class {
19
+ var DiscordNotifications = class DiscordNotifications {
42
20
  client;
43
21
  opts;
44
22
  defaultWebhookUrl;
23
+ bot;
45
24
  key = "notifications";
46
25
  providerName = "discord";
47
- constructor(client, opts, defaultWebhookUrl) {
26
+ constructor(client, opts, defaultWebhookUrl, bot = () => {
27
+ throw new Error("no Discord bot client configured");
28
+ }) {
48
29
  this.client = client;
49
30
  this.opts = opts;
50
31
  this.defaultWebhookUrl = defaultWebhookUrl;
32
+ this.bot = bot;
33
+ }
34
+ get layout() {
35
+ return this.opts.layout;
51
36
  }
52
37
  async send(channel, message) {
53
- const { id, token } = parseWebhookUrl(this.resolve(channel || this.opts.defaultChannel || ""));
38
+ const webhookUrl = this.resolve(channel || this.opts.defaultChannel || "");
39
+ const { id, token } = parseWebhookUrl(webhookUrl);
54
40
  await this.client.webhooks.execute(id, token, message);
55
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
+ }
56
131
  resolve(channel) {
57
132
  const alias = this.opts.webhooks?.[channel];
58
133
  if (alias) return alias;
@@ -78,7 +153,7 @@ async function verifyToken(webhookUrl, opts = {}) {
78
153
  } catch (err) {
79
154
  return {
80
155
  ok: false,
81
- message: err instanceof Error ? err.message : String(err)
156
+ message: errorMessage(err)
82
157
  };
83
158
  }
84
159
  }
@@ -86,17 +161,27 @@ async function verifyToken(webhookUrl, opts = {}) {
86
161
  //#region src/index.ts
87
162
  function createContext(options = {}) {
88
163
  let url;
164
+ let bot;
89
165
  return {
90
166
  options,
91
167
  client: createDiscordClient({
92
168
  baseUrl: options.baseUrl,
93
169
  fetch: options.fetch
94
170
  }),
95
- 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
+ })
96
181
  };
97
182
  }
98
183
  function notifications(ctx) {
99
- return new DiscordNotifications(ctx.client, ctx.options, ctx.defaultWebhookUrl);
184
+ return new DiscordNotifications(ctx.client, ctx.options, ctx.defaultWebhookUrl, ctx.bot);
100
185
  }
101
186
  function createPlugin(options = {}) {
102
187
  const ctx = createContext(options);
@@ -107,4 +192,4 @@ function createPlugin(options = {}) {
107
192
  }
108
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/...>";
109
194
  //#endregion
110
- 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.18",
3
+ "version": "5.0.0-alpha.181",
4
4
  "description": "Holocron plugin for Discord. Implements the notifications capability via Discord incoming webhooks.",
5
5
  "keywords": [
6
6
  "discord",
@@ -8,12 +8,12 @@
8
8
  "notifications",
9
9
  "plugin"
10
10
  ],
11
- "homepage": "https://github.com/theholocron/holocron/tree/main/packages/holocron-plugin-discord#readme",
11
+ "homepage": "https://github.com/theholocron/holocron/tree/main/plugins/holocron-plugin-discord#readme",
12
12
  "bugs": "https://github.com/theholocron/holocron/issues",
13
13
  "repository": {
14
14
  "type": "git",
15
15
  "url": "git+https://github.com/theholocron/holocron.git",
16
- "directory": "packages/holocron-plugin-discord"
16
+ "directory": "plugins/holocron-plugin-discord"
17
17
  },
18
18
  "license": "MIT",
19
19
  "author": "Newton Koumantzelis",
@@ -30,11 +30,13 @@
30
30
  "dist"
31
31
  ],
32
32
  "devDependencies": {
33
- "@theholocron/eslint-config": "^8.0.0",
34
- "@theholocron/http-client": "^1.18.0",
35
- "@theholocron/tsconfig": "^8.0.0",
36
- "@theholocron/tsdown-config": "^8.0.0",
37
- "@theholocron/vitest-config": "^8.0.0",
33
+ "@theholocron/discord-client": "^1.43.1",
34
+ "@theholocron/eslint-config": "^8.8.2",
35
+ "@theholocron/http-client": "^1.43.1",
36
+ "@theholocron/misc-utils": "^1.16.0",
37
+ "@theholocron/tsconfig": "^8.8.2",
38
+ "@theholocron/tsdown-config": "^8.8.2",
39
+ "@theholocron/vitest-config": "^8.8.2",
38
40
  "@types/node": "^26",
39
41
  "@vitest/coverage-v8": "^4.1.11",
40
42
  "@vitest/eslint-plugin": "^1.6.27",
@@ -43,13 +45,15 @@
43
45
  "eslint-plugin-n": "^18.3.0",
44
46
  "globals": "^17.11.0",
45
47
  "tsdown": "^0.22.14",
46
- "tsx": "4.23.12",
48
+ "tsx": "4.23.15",
47
49
  "typescript": "^5.9.3",
48
50
  "vitest": "^4.1.11",
49
- "@theholocron/cli": "5.0.0-alpha.18"
51
+ "@theholocron/cli": "5.0.0-alpha.181"
50
52
  },
51
53
  "peerDependencies": {
52
- "@theholocron/cli": "5.0.0-alpha.18"
54
+ "@theholocron/discord-client": "^1.43.1",
55
+ "@theholocron/misc-utils": "^1.16.0",
56
+ "@theholocron/cli": "5.0.0-alpha.181"
53
57
  },
54
58
  "engines": {
55
59
  "node": ">=22"