@notifkit/provider-discord 0.1.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 devkitshq
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,59 @@
1
+ # @notifkit/provider-discord
2
+
3
+ Discord webhook transport for [notifkit](https://github.com/devkitshq/notifkit).
4
+ Sends messages through an incoming Discord webhook and reports deleted/invalid
5
+ webhooks back so notifkit can deactivate the contact.
6
+
7
+ ```bash
8
+ npm install @notifkit/provider-discord
9
+ ```
10
+
11
+ ```ts
12
+ import { NotifkitServer } from "notifkit";
13
+ import { DiscordTransport } from "@notifkit/provider-discord";
14
+
15
+ const server = new NotifkitServer({
16
+ services: ["all"],
17
+ providers: [new DiscordTransport()],
18
+ });
19
+ ```
20
+
21
+ ## Getting a webhook URL
22
+
23
+ In the target Discord channel: **Edit Channel → Integrations → Webhooks →
24
+ New Webhook**, then copy its URL. Unlike email or push, the webhook URL _is_
25
+ the destination — register it as the contact target directly:
26
+
27
+ ```ts
28
+ await notifkit.addContact("usr_123", {
29
+ channel: "discord",
30
+ target: "https://discord.com/api/webhooks/123456789012345678/abcdef...",
31
+ });
32
+ ```
33
+
34
+ Then register a template on the `discord` channel and send:
35
+
36
+ ```ts
37
+ await notifkit.syncTemplates({
38
+ templates: [
39
+ {
40
+ id: "order-shipped",
41
+ channel: "discord",
42
+ content: { text: "Order #{{orderId}} shipped!" },
43
+ },
44
+ ],
45
+ });
46
+
47
+ await notifkit.notify({
48
+ user: "usr_123",
49
+ template: "order-shipped",
50
+ channels: ["discord"],
51
+ data: { orderId: "9481" },
52
+ });
53
+ ```
54
+
55
+ A template's content may set `username` and `avatarUrl` to override the
56
+ webhook's default identity per message, and `embeds` (an array of [Discord
57
+ embed objects](https://discord.com/developers/docs/resources/webhook#execute-webhook))
58
+ for richer formatting. Requires `notifkit` as a peer dependency.
59
+ Documentation: [notifkit.dev/docs](https://notifkit.dev/docs/). MIT licensed.
@@ -0,0 +1,33 @@
1
+ import type { DeliveryResult, Transport } from "notifkit";
2
+ import type { NotificationDispatchedPayload, Logger } from "notifkit";
3
+ export interface DiscordTransportOptions {
4
+ /**
5
+ * Default sender name shown on messages, overridable per template via
6
+ * `content.username`. Left unset, Discord uses the webhook's own name.
7
+ */
8
+ username?: string;
9
+ /** Default avatar URL, overridable per template via `content.avatarUrl`. */
10
+ avatarUrl?: string;
11
+ logger?: Logger;
12
+ /**
13
+ * Discord enforces roughly 5 requests / 2 seconds per webhook. Tighten
14
+ * this if several templates share one webhook and could burst together.
15
+ */
16
+ limits?: {
17
+ limit: number;
18
+ windowSeconds: number;
19
+ };
20
+ }
21
+ export declare class DiscordTransport implements Transport {
22
+ readonly channel: "discord";
23
+ readonly limits?: {
24
+ limit: number;
25
+ windowSeconds: number;
26
+ };
27
+ private readonly username?;
28
+ private readonly avatarUrl?;
29
+ private readonly logger?;
30
+ constructor({ username, avatarUrl, logger, limits, }?: DiscordTransportOptions);
31
+ send(task: NotificationDispatchedPayload): Promise<DeliveryResult>;
32
+ }
33
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,SAAS,EAAE,MAAM,UAAU,CAAC;AAC1D,OAAO,KAAK,EAAE,6BAA6B,EAAE,MAAM,EAAE,MAAM,UAAU,CAAC;AAEtE,MAAM,WAAW,uBAAuB;IACtC;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,4EAA4E;IAC5E,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;OAGG;IACH,MAAM,CAAC,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,aAAa,EAAE,MAAM,CAAA;KAAE,CAAC;CACnD;AAED,qBAAa,gBAAiB,YAAW,SAAS;IAChD,QAAQ,CAAC,OAAO,EAAG,SAAS,CAAU;IACtC,QAAQ,CAAC,MAAM,CAAC,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,aAAa,EAAE,MAAM,CAAA;KAAE,CAAC;IAE3D,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAS;IACnC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAC,CAAS;IACpC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAS;gBAErB,EACV,QAAQ,EACR,SAAS,EACT,MAAM,EACN,MAAuC,GACxC,GAAE,uBAA4B;IAOzB,IAAI,CAAC,IAAI,EAAE,6BAA6B,GAAG,OAAO,CAAC,cAAc,CAAC;CA2EzE"}
package/dist/index.js ADDED
@@ -0,0 +1,75 @@
1
+ export class DiscordTransport {
2
+ channel = "discord";
3
+ limits;
4
+ username;
5
+ avatarUrl;
6
+ logger;
7
+ constructor({ username, avatarUrl, logger, limits = { limit: 5, windowSeconds: 2 }, } = {}) {
8
+ this.username = username;
9
+ this.avatarUrl = avatarUrl;
10
+ this.logger = logger;
11
+ this.limits = limits;
12
+ }
13
+ async send(task) {
14
+ // `destination` is optional on the contract, so a malformed task can reach
15
+ // a transport without one. Failing here costs one clear error; passing
16
+ // `undefined` as the webhook URL costs an opaque fetch failure instead.
17
+ const webhookUrl = task.destination;
18
+ if (!webhookUrl) {
19
+ return { success: false, error: "No destination (Discord webhook URL) on task" };
20
+ }
21
+ const content = task.renderedContent.content;
22
+ const body = content.text || content.body;
23
+ const subject = content.subject;
24
+ // Discord messages have no subject line of their own, so a template that
25
+ // renders one is bolded onto the first line rather than dropped.
26
+ const text = subject ? `**${subject}**\n${body}` : body;
27
+ // A template may carry its own sender, so one webhook can post as
28
+ // different bots for different alert types.
29
+ const username = typeof content.username === "string" && content.username ? content.username : this.username;
30
+ const avatarUrl = typeof content.avatarUrl === "string" && content.avatarUrl
31
+ ? content.avatarUrl
32
+ : this.avatarUrl;
33
+ const embeds = Array.isArray(content.embeds) ? content.embeds : undefined;
34
+ try {
35
+ // `wait=true` makes Discord return the created message (with its id)
36
+ // in the response body instead of an empty 204 — without it there is
37
+ // no providerMessageId to record in the delivery log.
38
+ const url = new URL(webhookUrl);
39
+ url.searchParams.set("wait", "true");
40
+ const res = await fetch(url, {
41
+ method: "POST",
42
+ headers: { "Content-Type": "application/json" },
43
+ body: JSON.stringify({
44
+ content: text,
45
+ ...(username ? { username } : {}),
46
+ ...(avatarUrl ? { avatar_url: avatarUrl } : {}),
47
+ ...(embeds ? { embeds } : {}),
48
+ }),
49
+ });
50
+ if (!res.ok) {
51
+ const errorBody = await res.text();
52
+ // A deleted or regenerated webhook answers 404/401 for every future
53
+ // request — the target itself is dead, not a transient failure, so
54
+ // the caller should deactivate the contact rather than retry it.
55
+ const invalidToken = res.status === 404 || res.status === 401;
56
+ this.logger?.warn({ taskId: task.taskId, status: res.status, errorBody }, "Discord send failed");
57
+ return {
58
+ success: false,
59
+ invalidToken,
60
+ error: `Discord webhook error ${res.status}: ${errorBody}`,
61
+ };
62
+ }
63
+ const data = (await res.json().catch(() => null));
64
+ const providerMessageId = data?.id ?? "";
65
+ this.logger?.debug({ taskId: task.taskId, providerMessageId }, "Discord message sent");
66
+ return { success: true, providerMessageId };
67
+ }
68
+ catch (err) {
69
+ const error = err;
70
+ this.logger?.error({ taskId: task.taskId, error: error.message }, "Discord unexpected error");
71
+ return { success: false, error: error.message };
72
+ }
73
+ }
74
+ }
75
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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","sourcesContent":["import type { DeliveryResult, Transport } from \"notifkit\";\nimport type { NotificationDispatchedPayload, Logger } from \"notifkit\";\n\nexport interface DiscordTransportOptions {\n /**\n * Default sender name shown on messages, overridable per template via\n * `content.username`. Left unset, Discord uses the webhook's own name.\n */\n username?: string;\n /** Default avatar URL, overridable per template via `content.avatarUrl`. */\n avatarUrl?: string;\n logger?: Logger;\n /**\n * Discord enforces roughly 5 requests / 2 seconds per webhook. Tighten\n * this if several templates share one webhook and could burst together.\n */\n limits?: { limit: number; windowSeconds: number };\n}\n\nexport class DiscordTransport implements Transport {\n readonly channel = \"discord\" as const;\n readonly limits?: { limit: number; windowSeconds: number };\n\n private readonly username?: string;\n private readonly avatarUrl?: string;\n private readonly logger?: Logger;\n\n constructor({\n username,\n avatarUrl,\n logger,\n limits = { limit: 5, windowSeconds: 2 },\n }: DiscordTransportOptions = {}) {\n this.username = username;\n this.avatarUrl = avatarUrl;\n this.logger = logger;\n this.limits = limits;\n }\n\n async send(task: NotificationDispatchedPayload): Promise<DeliveryResult> {\n // `destination` is optional on the contract, so a malformed task can reach\n // a transport without one. Failing here costs one clear error; passing\n // `undefined` as the webhook URL costs an opaque fetch failure instead.\n const webhookUrl = task.destination;\n if (!webhookUrl) {\n return { success: false, error: \"No destination (Discord webhook URL) on task\" };\n }\n\n const content = task.renderedContent.content as any;\n const body = content.text || content.body;\n const subject = content.subject;\n // Discord messages have no subject line of their own, so a template that\n // renders one is bolded onto the first line rather than dropped.\n const text = subject ? `**${subject}**\\n${body}` : body;\n\n // A template may carry its own sender, so one webhook can post as\n // different bots for different alert types.\n const username =\n typeof content.username === \"string\" && content.username ? content.username : this.username;\n const avatarUrl =\n typeof content.avatarUrl === \"string\" && content.avatarUrl\n ? content.avatarUrl\n : this.avatarUrl;\n const embeds = Array.isArray(content.embeds) ? content.embeds : undefined;\n\n try {\n // `wait=true` makes Discord return the created message (with its id)\n // in the response body instead of an empty 204 — without it there is\n // no providerMessageId to record in the delivery log.\n const url = new URL(webhookUrl);\n url.searchParams.set(\"wait\", \"true\");\n\n const res = await fetch(url, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({\n content: text,\n ...(username ? { username } : {}),\n ...(avatarUrl ? { avatar_url: avatarUrl } : {}),\n ...(embeds ? { embeds } : {}),\n }),\n });\n\n if (!res.ok) {\n const errorBody = await res.text();\n // A deleted or regenerated webhook answers 404/401 for every future\n // request — the target itself is dead, not a transient failure, so\n // the caller should deactivate the contact rather than retry it.\n const invalidToken = res.status === 404 || res.status === 401;\n\n this.logger?.warn(\n { taskId: task.taskId, status: res.status, errorBody },\n \"Discord send failed\",\n );\n\n return {\n success: false,\n invalidToken,\n error: `Discord webhook error ${res.status}: ${errorBody}`,\n };\n }\n\n const data = (await res.json().catch(() => null)) as { id?: string } | null;\n const providerMessageId = data?.id ?? \"\";\n\n this.logger?.debug({ taskId: task.taskId, providerMessageId }, \"Discord message sent\");\n return { success: true, providerMessageId };\n } catch (err) {\n const error = err as Error;\n this.logger?.error({ taskId: task.taskId, error: error.message }, \"Discord unexpected error\");\n\n return { success: false, error: error.message };\n }\n }\n}\n"]}
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@notifkit/provider-discord",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "files": [
8
+ "dist",
9
+ "src",
10
+ "README.md",
11
+ "LICENSE"
12
+ ],
13
+ "scripts": {
14
+ "build": "tsc",
15
+ "dev": "tsc --watch",
16
+ "typecheck": "tsc --noEmit"
17
+ },
18
+ "peerDependencies": {
19
+ "notifkit": "^0.1.0"
20
+ },
21
+ "devDependencies": {
22
+ "notifkit": "file:../../",
23
+ "typescript": "^5.8.3"
24
+ },
25
+ "description": "Discord webhook transport for notifkit — sends messages through an incoming Discord webhook.",
26
+ "license": "MIT",
27
+ "author": "devkitshq",
28
+ "homepage": "https://notifkit.dev",
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/devkitshq/notifkit.git",
32
+ "directory": "packages/provider-discord"
33
+ },
34
+ "bugs": {
35
+ "url": "https://github.com/devkitshq/notifkit/issues"
36
+ },
37
+ "publishConfig": {
38
+ "access": "public"
39
+ }
40
+ }
package/src/index.ts ADDED
@@ -0,0 +1,115 @@
1
+ import type { DeliveryResult, Transport } from "notifkit";
2
+ import type { NotificationDispatchedPayload, Logger } from "notifkit";
3
+
4
+ export interface DiscordTransportOptions {
5
+ /**
6
+ * Default sender name shown on messages, overridable per template via
7
+ * `content.username`. Left unset, Discord uses the webhook's own name.
8
+ */
9
+ username?: string;
10
+ /** Default avatar URL, overridable per template via `content.avatarUrl`. */
11
+ avatarUrl?: string;
12
+ logger?: Logger;
13
+ /**
14
+ * Discord enforces roughly 5 requests / 2 seconds per webhook. Tighten
15
+ * this if several templates share one webhook and could burst together.
16
+ */
17
+ limits?: { limit: number; windowSeconds: number };
18
+ }
19
+
20
+ export class DiscordTransport implements Transport {
21
+ readonly channel = "discord" as const;
22
+ readonly limits?: { limit: number; windowSeconds: number };
23
+
24
+ private readonly username?: string;
25
+ private readonly avatarUrl?: string;
26
+ private readonly logger?: Logger;
27
+
28
+ constructor({
29
+ username,
30
+ avatarUrl,
31
+ logger,
32
+ limits = { limit: 5, windowSeconds: 2 },
33
+ }: DiscordTransportOptions = {}) {
34
+ this.username = username;
35
+ this.avatarUrl = avatarUrl;
36
+ this.logger = logger;
37
+ this.limits = limits;
38
+ }
39
+
40
+ async send(task: NotificationDispatchedPayload): Promise<DeliveryResult> {
41
+ // `destination` is optional on the contract, so a malformed task can reach
42
+ // a transport without one. Failing here costs one clear error; passing
43
+ // `undefined` as the webhook URL costs an opaque fetch failure instead.
44
+ const webhookUrl = task.destination;
45
+ if (!webhookUrl) {
46
+ return { success: false, error: "No destination (Discord webhook URL) on task" };
47
+ }
48
+
49
+ const content = task.renderedContent.content as any;
50
+ const body = content.text || content.body;
51
+ const subject = content.subject;
52
+ // Discord messages have no subject line of their own, so a template that
53
+ // renders one is bolded onto the first line rather than dropped.
54
+ const text = subject ? `**${subject}**\n${body}` : body;
55
+
56
+ // A template may carry its own sender, so one webhook can post as
57
+ // different bots for different alert types.
58
+ const username =
59
+ typeof content.username === "string" && content.username ? content.username : this.username;
60
+ const avatarUrl =
61
+ typeof content.avatarUrl === "string" && content.avatarUrl
62
+ ? content.avatarUrl
63
+ : this.avatarUrl;
64
+ const embeds = Array.isArray(content.embeds) ? content.embeds : undefined;
65
+
66
+ try {
67
+ // `wait=true` makes Discord return the created message (with its id)
68
+ // in the response body instead of an empty 204 — without it there is
69
+ // no providerMessageId to record in the delivery log.
70
+ const url = new URL(webhookUrl);
71
+ url.searchParams.set("wait", "true");
72
+
73
+ const res = await fetch(url, {
74
+ method: "POST",
75
+ headers: { "Content-Type": "application/json" },
76
+ body: JSON.stringify({
77
+ content: text,
78
+ ...(username ? { username } : {}),
79
+ ...(avatarUrl ? { avatar_url: avatarUrl } : {}),
80
+ ...(embeds ? { embeds } : {}),
81
+ }),
82
+ });
83
+
84
+ if (!res.ok) {
85
+ const errorBody = await res.text();
86
+ // A deleted or regenerated webhook answers 404/401 for every future
87
+ // request — the target itself is dead, not a transient failure, so
88
+ // the caller should deactivate the contact rather than retry it.
89
+ const invalidToken = res.status === 404 || res.status === 401;
90
+
91
+ this.logger?.warn(
92
+ { taskId: task.taskId, status: res.status, errorBody },
93
+ "Discord send failed",
94
+ );
95
+
96
+ return {
97
+ success: false,
98
+ invalidToken,
99
+ error: `Discord webhook error ${res.status}: ${errorBody}`,
100
+ };
101
+ }
102
+
103
+ const data = (await res.json().catch(() => null)) as { id?: string } | null;
104
+ const providerMessageId = data?.id ?? "";
105
+
106
+ this.logger?.debug({ taskId: task.taskId, providerMessageId }, "Discord message sent");
107
+ return { success: true, providerMessageId };
108
+ } catch (err) {
109
+ const error = err as Error;
110
+ this.logger?.error({ taskId: task.taskId, error: error.message }, "Discord unexpected error");
111
+
112
+ return { success: false, error: error.message };
113
+ }
114
+ }
115
+ }