@nixxie-cms/notifications 1.0.1
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 +23 -0
- package/README.md +34 -0
- package/dist/declarations/src/channels.d.ts +7 -0
- package/dist/declarations/src/channels.d.ts.map +1 -0
- package/dist/declarations/src/index.d.ts +14 -0
- package/dist/declarations/src/index.d.ts.map +1 -0
- package/dist/declarations/src/types.d.ts +43 -0
- package/dist/declarations/src/types.d.ts.map +1 -0
- package/dist/nixxie-cms-notifications.cjs.d.ts +2 -0
- package/dist/nixxie-cms-notifications.cjs.js +146 -0
- package/dist/nixxie-cms-notifications.esm.js +140 -0
- package/package.json +33 -0
- package/src/channels.ts +92 -0
- package/src/index.ts +60 -0
- package/src/types.ts +57 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nixxie International DMCC
|
|
4
|
+
Portions Copyright (c) 2023 Thinkmill Labs Pty Ltd and contributors
|
|
5
|
+
(this software is derived from the KeystoneJS project, https://keystonejs.com)
|
|
6
|
+
|
|
7
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
8
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
9
|
+
in the Software without restriction, including without limitation the rights
|
|
10
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
11
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
12
|
+
furnished to do so, subject to the following conditions:
|
|
13
|
+
|
|
14
|
+
The above copyright notice and this permission notice shall be included in all
|
|
15
|
+
copies or substantial portions of the Software.
|
|
16
|
+
|
|
17
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
18
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
19
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
20
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
21
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
22
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
23
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# @nixxie-cms/notifications
|
|
2
|
+
|
|
3
|
+
Multi-channel notifications for Nixxie CMS: Slack, Discord, SMS (Twilio), generic webhooks and your
|
|
4
|
+
own custom channels. No SDK dependencies — everything goes over `fetch`.
|
|
5
|
+
|
|
6
|
+
```ts
|
|
7
|
+
import { config } from '@nixxie-cms/core'
|
|
8
|
+
import { createNotifications } from '@nixxie-cms/notifications'
|
|
9
|
+
|
|
10
|
+
export default config({
|
|
11
|
+
notifications: createNotifications({
|
|
12
|
+
channels: {
|
|
13
|
+
slack: { type: 'slack', webhookUrl: process.env.SLACK_WEBHOOK! },
|
|
14
|
+
ops: { type: 'sms', accountSid: '...', authToken: '...', from: '+1555...' },
|
|
15
|
+
},
|
|
16
|
+
default: 'slack',
|
|
17
|
+
}),
|
|
18
|
+
})
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Available everywhere as `context.services.notifications`:
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
await context.services.notifications.send({
|
|
25
|
+
channel: 'slack',
|
|
26
|
+
title: 'New signup',
|
|
27
|
+
body: `${user.email} just registered`,
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
// SMS needs a `to`:
|
|
31
|
+
await context.services.notifications.send({ channel: 'ops', to: '+1555...', body: 'Deploy done' })
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Add a `{ type: 'custom', send }` channel to deliver anywhere else (push, email queue, etc.).
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { NixxieNotification } from '@nixxie-cms/core';
|
|
2
|
+
import type { ChannelConfig } from "./types.js";
|
|
3
|
+
/** Deliver a notification on a single configured channel. Returns the provider message id. */
|
|
4
|
+
export declare function deliver(channel: ChannelConfig, notification: NixxieNotification): Promise<{
|
|
5
|
+
id?: string;
|
|
6
|
+
}>;
|
|
7
|
+
//# sourceMappingURL=channels.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"channels.d.ts","sourceRoot":"../../../src","sources":["channels.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAA;AAC1D,OAAO,KAAK,EAAE,aAAa,EAAE,mBAAe;AAE5C,8FAA8F;AAC9F,wBAAsB,OAAO,CAC3B,OAAO,EAAE,aAAa,EACtB,YAAY,EAAE,kBAAkB,GAC/B,OAAO,CAAC;IAAE,EAAE,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CAiB1B"}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { NixxieNotification, NixxieNotificationResult, NixxieNotificationsService } from '@nixxie-cms/core';
|
|
2
|
+
import type { NotificationsConfig } from "./types.js";
|
|
3
|
+
export declare class NotificationsService implements NixxieNotificationsService {
|
|
4
|
+
private config;
|
|
5
|
+
constructor(config: NotificationsConfig);
|
|
6
|
+
channels(): string[];
|
|
7
|
+
private targetChannels;
|
|
8
|
+
send(notification: NixxieNotification): Promise<NixxieNotificationResult[]>;
|
|
9
|
+
}
|
|
10
|
+
export declare function createNotifications(config: NotificationsConfig): NotificationsService;
|
|
11
|
+
export { deliver } from "./channels.js";
|
|
12
|
+
export type { NotificationsConfig, ChannelConfig, SlackChannelConfig, DiscordChannelConfig, WebhookChannelConfig, SmsChannelConfig, CustomChannelConfig, } from "./types.js";
|
|
13
|
+
export type { NixxieNotification, NixxieNotificationResult, NixxieNotificationsService, } from '@nixxie-cms/core';
|
|
14
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"../../../src","sources":["index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,kBAAkB,EAClB,wBAAwB,EACxB,0BAA0B,EAC3B,MAAM,kBAAkB,CAAA;AAEzB,OAAO,KAAK,EAAE,mBAAmB,EAAE,mBAAe;AAElD,qBAAa,oBAAqB,YAAW,0BAA0B;IACrE,OAAO,CAAC,MAAM,CAAqB;gBAEvB,MAAM,EAAE,mBAAmB;IAIvC,QAAQ,IAAI,MAAM,EAAE;IAIpB,OAAO,CAAC,cAAc;IAKhB,IAAI,CAAC,YAAY,EAAE,kBAAkB,GAAG,OAAO,CAAC,wBAAwB,EAAE,CAAC;CAelF;AAED,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,mBAAmB,GAAG,oBAAoB,CAErF;AAED,OAAO,EAAE,OAAO,EAAE,sBAAkB;AACpC,YAAY,EACV,mBAAmB,EACnB,aAAa,EACb,kBAAkB,EAClB,oBAAoB,EACpB,oBAAoB,EACpB,gBAAgB,EAChB,mBAAmB,GACpB,mBAAe;AAChB,YAAY,EACV,kBAAkB,EAClB,wBAAwB,EACxB,0BAA0B,GAC3B,MAAM,kBAAkB,CAAA"}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { NixxieNotification, NixxieNotificationResult, NixxieNotificationsService } from '@nixxie-cms/core';
|
|
2
|
+
export type { NixxieNotification, NixxieNotificationResult, NixxieNotificationsService };
|
|
3
|
+
export type SlackChannelConfig = {
|
|
4
|
+
type: 'slack';
|
|
5
|
+
/** Slack Incoming Webhook URL. */
|
|
6
|
+
webhookUrl: string;
|
|
7
|
+
};
|
|
8
|
+
export type DiscordChannelConfig = {
|
|
9
|
+
type: 'discord';
|
|
10
|
+
/** Discord webhook URL. */
|
|
11
|
+
webhookUrl: string;
|
|
12
|
+
};
|
|
13
|
+
export type WebhookChannelConfig = {
|
|
14
|
+
type: 'webhook';
|
|
15
|
+
/** Endpoint to POST the notification JSON to. */
|
|
16
|
+
url: string;
|
|
17
|
+
/** Extra headers (e.g. an auth token). */
|
|
18
|
+
headers?: Record<string, string>;
|
|
19
|
+
};
|
|
20
|
+
export type SmsChannelConfig = {
|
|
21
|
+
type: 'sms';
|
|
22
|
+
/** Twilio Account SID. */
|
|
23
|
+
accountSid: string;
|
|
24
|
+
/** Twilio Auth Token. */
|
|
25
|
+
authToken: string;
|
|
26
|
+
/** Sending phone number in E.164 format. */
|
|
27
|
+
from: string;
|
|
28
|
+
};
|
|
29
|
+
export type CustomChannelConfig = {
|
|
30
|
+
type: 'custom';
|
|
31
|
+
/** Implement delivery yourself. Return the provider message id on success. */
|
|
32
|
+
send: (notification: NixxieNotification) => Promise<{
|
|
33
|
+
id?: string;
|
|
34
|
+
} | void>;
|
|
35
|
+
};
|
|
36
|
+
export type ChannelConfig = SlackChannelConfig | DiscordChannelConfig | WebhookChannelConfig | SmsChannelConfig | CustomChannelConfig;
|
|
37
|
+
export type NotificationsConfig = {
|
|
38
|
+
/** Named channels. Reference them by key via `notification.channel`. */
|
|
39
|
+
channels: Record<string, ChannelConfig>;
|
|
40
|
+
/** Channel(s) used when a notification doesn't specify one. Default: every configured channel. */
|
|
41
|
+
default?: string | string[];
|
|
42
|
+
};
|
|
43
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"../../../src","sources":["types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,kBAAkB,EAClB,wBAAwB,EACxB,0BAA0B,EAC3B,MAAM,kBAAkB,CAAA;AAEzB,YAAY,EAAE,kBAAkB,EAAE,wBAAwB,EAAE,0BAA0B,EAAE,CAAA;AAExF,MAAM,MAAM,kBAAkB,GAAG;IAC/B,IAAI,EAAE,OAAO,CAAA;IACb,kCAAkC;IAClC,UAAU,EAAE,MAAM,CAAA;CACnB,CAAA;AAED,MAAM,MAAM,oBAAoB,GAAG;IACjC,IAAI,EAAE,SAAS,CAAA;IACf,2BAA2B;IAC3B,UAAU,EAAE,MAAM,CAAA;CACnB,CAAA;AAED,MAAM,MAAM,oBAAoB,GAAG;IACjC,IAAI,EAAE,SAAS,CAAA;IACf,iDAAiD;IACjD,GAAG,EAAE,MAAM,CAAA;IACX,0CAA0C;IAC1C,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CACjC,CAAA;AAED,MAAM,MAAM,gBAAgB,GAAG;IAC7B,IAAI,EAAE,KAAK,CAAA;IACX,0BAA0B;IAC1B,UAAU,EAAE,MAAM,CAAA;IAClB,yBAAyB;IACzB,SAAS,EAAE,MAAM,CAAA;IACjB,4CAA4C;IAC5C,IAAI,EAAE,MAAM,CAAA;CACb,CAAA;AAED,MAAM,MAAM,mBAAmB,GAAG;IAChC,IAAI,EAAE,QAAQ,CAAA;IACd,8EAA8E;IAC9E,IAAI,EAAE,CAAC,YAAY,EAAE,kBAAkB,KAAK,OAAO,CAAC;QAAE,EAAE,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC,CAAA;CAC5E,CAAA;AAED,MAAM,MAAM,aAAa,GACrB,kBAAkB,GAClB,oBAAoB,GACpB,oBAAoB,GACpB,gBAAgB,GAChB,mBAAmB,CAAA;AAEvB,MAAM,MAAM,mBAAmB,GAAG;IAChC,wEAAwE;IACxE,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC,CAAA;IACvC,kGAAkG;IAClG,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAA;CAC5B,CAAA"}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
export * from "./declarations/src/index.js";
|
|
2
|
+
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoibml4eGllLWNtcy1ub3RpZmljYXRpb25zLmNqcy5kLnRzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi9kZWNsYXJhdGlvbnMvc3JjL2luZGV4LmQudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEifQ==
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, '__esModule', { value: true });
|
|
4
|
+
|
|
5
|
+
/** Deliver a notification on a single configured channel. Returns the provider message id. */
|
|
6
|
+
async function deliver(channel, notification) {
|
|
7
|
+
var _await$channel$send;
|
|
8
|
+
switch (channel.type) {
|
|
9
|
+
case 'slack':
|
|
10
|
+
return deliverSlack(channel.webhookUrl, notification);
|
|
11
|
+
case 'discord':
|
|
12
|
+
return deliverDiscord(channel.webhookUrl, notification);
|
|
13
|
+
case 'webhook':
|
|
14
|
+
return deliverWebhook(channel.url, channel.headers, notification);
|
|
15
|
+
case 'sms':
|
|
16
|
+
return deliverSms(channel, notification);
|
|
17
|
+
case 'custom':
|
|
18
|
+
return (_await$channel$send = await channel.send(notification)) !== null && _await$channel$send !== void 0 ? _await$channel$send : {};
|
|
19
|
+
default:
|
|
20
|
+
{
|
|
21
|
+
const exhaustive = channel;
|
|
22
|
+
throw new Error(`Unknown channel type: ${exhaustive.type}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function text(n) {
|
|
27
|
+
return n.title ? `*${n.title}*\n${n.body}` : n.body;
|
|
28
|
+
}
|
|
29
|
+
async function deliverSlack(url, n) {
|
|
30
|
+
var _n$data;
|
|
31
|
+
const res = await fetch(url, {
|
|
32
|
+
method: 'POST',
|
|
33
|
+
headers: {
|
|
34
|
+
'Content-Type': 'application/json'
|
|
35
|
+
},
|
|
36
|
+
// Spread custom data first so it can add Slack fields (blocks, attachments, …) without
|
|
37
|
+
// clobbering the composed message text.
|
|
38
|
+
body: JSON.stringify({
|
|
39
|
+
...((_n$data = n.data) !== null && _n$data !== void 0 ? _n$data : {}),
|
|
40
|
+
text: text(n)
|
|
41
|
+
})
|
|
42
|
+
});
|
|
43
|
+
if (!res.ok) throw new Error(`Slack delivery failed (${res.status}): ${await res.text()}`);
|
|
44
|
+
return {};
|
|
45
|
+
}
|
|
46
|
+
async function deliverDiscord(url, n) {
|
|
47
|
+
const res = await fetch(url, {
|
|
48
|
+
method: 'POST',
|
|
49
|
+
headers: {
|
|
50
|
+
'Content-Type': 'application/json'
|
|
51
|
+
},
|
|
52
|
+
body: JSON.stringify({
|
|
53
|
+
content: text(n)
|
|
54
|
+
})
|
|
55
|
+
});
|
|
56
|
+
if (!res.ok) throw new Error(`Discord delivery failed (${res.status}): ${await res.text()}`);
|
|
57
|
+
return {};
|
|
58
|
+
}
|
|
59
|
+
async function deliverWebhook(url, headers, n) {
|
|
60
|
+
const res = await fetch(url, {
|
|
61
|
+
method: 'POST',
|
|
62
|
+
headers: {
|
|
63
|
+
'Content-Type': 'application/json',
|
|
64
|
+
...headers
|
|
65
|
+
},
|
|
66
|
+
body: JSON.stringify({
|
|
67
|
+
title: n.title,
|
|
68
|
+
body: n.body,
|
|
69
|
+
to: n.to,
|
|
70
|
+
data: n.data
|
|
71
|
+
})
|
|
72
|
+
});
|
|
73
|
+
if (!res.ok) throw new Error(`Webhook delivery failed (${res.status}): ${await res.text()}`);
|
|
74
|
+
return {};
|
|
75
|
+
}
|
|
76
|
+
async function deliverSms(channel, n) {
|
|
77
|
+
if (!n.to) throw new Error('SMS notifications require `to` (a destination phone number)');
|
|
78
|
+
const auth = Buffer.from(`${channel.accountSid}:${channel.authToken}`).toString('base64');
|
|
79
|
+
const body = new URLSearchParams({
|
|
80
|
+
To: n.to,
|
|
81
|
+
From: channel.from,
|
|
82
|
+
Body: n.title ? `${n.title}: ${n.body}` : n.body
|
|
83
|
+
});
|
|
84
|
+
const res = await fetch(`https://api.twilio.com/2010-04-01/Accounts/${channel.accountSid}/Messages.json`, {
|
|
85
|
+
method: 'POST',
|
|
86
|
+
headers: {
|
|
87
|
+
Authorization: `Basic ${auth}`,
|
|
88
|
+
'Content-Type': 'application/x-www-form-urlencoded'
|
|
89
|
+
},
|
|
90
|
+
body
|
|
91
|
+
});
|
|
92
|
+
if (!res.ok) throw new Error(`SMS delivery failed (${res.status}): ${await res.text()}`);
|
|
93
|
+
const data = await res.json();
|
|
94
|
+
return {
|
|
95
|
+
id: data.sid
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
class NotificationsService {
|
|
100
|
+
constructor(config) {
|
|
101
|
+
this.config = config;
|
|
102
|
+
}
|
|
103
|
+
channels() {
|
|
104
|
+
return Object.keys(this.config.channels);
|
|
105
|
+
}
|
|
106
|
+
targetChannels(notification) {
|
|
107
|
+
var _ref, _notification$channel;
|
|
108
|
+
const requested = (_ref = (_notification$channel = notification.channel) !== null && _notification$channel !== void 0 ? _notification$channel : this.config.default) !== null && _ref !== void 0 ? _ref : this.channels();
|
|
109
|
+
return Array.isArray(requested) ? requested : [requested];
|
|
110
|
+
}
|
|
111
|
+
async send(notification) {
|
|
112
|
+
const targets = this.targetChannels(notification);
|
|
113
|
+
return Promise.all(targets.map(async name => {
|
|
114
|
+
const channel = this.config.channels[name];
|
|
115
|
+
if (!channel) return {
|
|
116
|
+
channel: name,
|
|
117
|
+
ok: false,
|
|
118
|
+
error: `Unknown channel: ${name}`
|
|
119
|
+
};
|
|
120
|
+
try {
|
|
121
|
+
const {
|
|
122
|
+
id
|
|
123
|
+
} = await deliver(channel, notification);
|
|
124
|
+
return {
|
|
125
|
+
channel: name,
|
|
126
|
+
ok: true,
|
|
127
|
+
id
|
|
128
|
+
};
|
|
129
|
+
} catch (err) {
|
|
130
|
+
var _err$message;
|
|
131
|
+
return {
|
|
132
|
+
channel: name,
|
|
133
|
+
ok: false,
|
|
134
|
+
error: (_err$message = err === null || err === void 0 ? void 0 : err.message) !== null && _err$message !== void 0 ? _err$message : String(err)
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
}));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
function createNotifications(config) {
|
|
141
|
+
return new NotificationsService(config);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
exports.NotificationsService = NotificationsService;
|
|
145
|
+
exports.createNotifications = createNotifications;
|
|
146
|
+
exports.deliver = deliver;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/** Deliver a notification on a single configured channel. Returns the provider message id. */
|
|
2
|
+
async function deliver(channel, notification) {
|
|
3
|
+
var _await$channel$send;
|
|
4
|
+
switch (channel.type) {
|
|
5
|
+
case 'slack':
|
|
6
|
+
return deliverSlack(channel.webhookUrl, notification);
|
|
7
|
+
case 'discord':
|
|
8
|
+
return deliverDiscord(channel.webhookUrl, notification);
|
|
9
|
+
case 'webhook':
|
|
10
|
+
return deliverWebhook(channel.url, channel.headers, notification);
|
|
11
|
+
case 'sms':
|
|
12
|
+
return deliverSms(channel, notification);
|
|
13
|
+
case 'custom':
|
|
14
|
+
return (_await$channel$send = await channel.send(notification)) !== null && _await$channel$send !== void 0 ? _await$channel$send : {};
|
|
15
|
+
default:
|
|
16
|
+
{
|
|
17
|
+
const exhaustive = channel;
|
|
18
|
+
throw new Error(`Unknown channel type: ${exhaustive.type}`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function text(n) {
|
|
23
|
+
return n.title ? `*${n.title}*\n${n.body}` : n.body;
|
|
24
|
+
}
|
|
25
|
+
async function deliverSlack(url, n) {
|
|
26
|
+
var _n$data;
|
|
27
|
+
const res = await fetch(url, {
|
|
28
|
+
method: 'POST',
|
|
29
|
+
headers: {
|
|
30
|
+
'Content-Type': 'application/json'
|
|
31
|
+
},
|
|
32
|
+
// Spread custom data first so it can add Slack fields (blocks, attachments, …) without
|
|
33
|
+
// clobbering the composed message text.
|
|
34
|
+
body: JSON.stringify({
|
|
35
|
+
...((_n$data = n.data) !== null && _n$data !== void 0 ? _n$data : {}),
|
|
36
|
+
text: text(n)
|
|
37
|
+
})
|
|
38
|
+
});
|
|
39
|
+
if (!res.ok) throw new Error(`Slack delivery failed (${res.status}): ${await res.text()}`);
|
|
40
|
+
return {};
|
|
41
|
+
}
|
|
42
|
+
async function deliverDiscord(url, n) {
|
|
43
|
+
const res = await fetch(url, {
|
|
44
|
+
method: 'POST',
|
|
45
|
+
headers: {
|
|
46
|
+
'Content-Type': 'application/json'
|
|
47
|
+
},
|
|
48
|
+
body: JSON.stringify({
|
|
49
|
+
content: text(n)
|
|
50
|
+
})
|
|
51
|
+
});
|
|
52
|
+
if (!res.ok) throw new Error(`Discord delivery failed (${res.status}): ${await res.text()}`);
|
|
53
|
+
return {};
|
|
54
|
+
}
|
|
55
|
+
async function deliverWebhook(url, headers, n) {
|
|
56
|
+
const res = await fetch(url, {
|
|
57
|
+
method: 'POST',
|
|
58
|
+
headers: {
|
|
59
|
+
'Content-Type': 'application/json',
|
|
60
|
+
...headers
|
|
61
|
+
},
|
|
62
|
+
body: JSON.stringify({
|
|
63
|
+
title: n.title,
|
|
64
|
+
body: n.body,
|
|
65
|
+
to: n.to,
|
|
66
|
+
data: n.data
|
|
67
|
+
})
|
|
68
|
+
});
|
|
69
|
+
if (!res.ok) throw new Error(`Webhook delivery failed (${res.status}): ${await res.text()}`);
|
|
70
|
+
return {};
|
|
71
|
+
}
|
|
72
|
+
async function deliverSms(channel, n) {
|
|
73
|
+
if (!n.to) throw new Error('SMS notifications require `to` (a destination phone number)');
|
|
74
|
+
const auth = Buffer.from(`${channel.accountSid}:${channel.authToken}`).toString('base64');
|
|
75
|
+
const body = new URLSearchParams({
|
|
76
|
+
To: n.to,
|
|
77
|
+
From: channel.from,
|
|
78
|
+
Body: n.title ? `${n.title}: ${n.body}` : n.body
|
|
79
|
+
});
|
|
80
|
+
const res = await fetch(`https://api.twilio.com/2010-04-01/Accounts/${channel.accountSid}/Messages.json`, {
|
|
81
|
+
method: 'POST',
|
|
82
|
+
headers: {
|
|
83
|
+
Authorization: `Basic ${auth}`,
|
|
84
|
+
'Content-Type': 'application/x-www-form-urlencoded'
|
|
85
|
+
},
|
|
86
|
+
body
|
|
87
|
+
});
|
|
88
|
+
if (!res.ok) throw new Error(`SMS delivery failed (${res.status}): ${await res.text()}`);
|
|
89
|
+
const data = await res.json();
|
|
90
|
+
return {
|
|
91
|
+
id: data.sid
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
class NotificationsService {
|
|
96
|
+
constructor(config) {
|
|
97
|
+
this.config = config;
|
|
98
|
+
}
|
|
99
|
+
channels() {
|
|
100
|
+
return Object.keys(this.config.channels);
|
|
101
|
+
}
|
|
102
|
+
targetChannels(notification) {
|
|
103
|
+
var _ref, _notification$channel;
|
|
104
|
+
const requested = (_ref = (_notification$channel = notification.channel) !== null && _notification$channel !== void 0 ? _notification$channel : this.config.default) !== null && _ref !== void 0 ? _ref : this.channels();
|
|
105
|
+
return Array.isArray(requested) ? requested : [requested];
|
|
106
|
+
}
|
|
107
|
+
async send(notification) {
|
|
108
|
+
const targets = this.targetChannels(notification);
|
|
109
|
+
return Promise.all(targets.map(async name => {
|
|
110
|
+
const channel = this.config.channels[name];
|
|
111
|
+
if (!channel) return {
|
|
112
|
+
channel: name,
|
|
113
|
+
ok: false,
|
|
114
|
+
error: `Unknown channel: ${name}`
|
|
115
|
+
};
|
|
116
|
+
try {
|
|
117
|
+
const {
|
|
118
|
+
id
|
|
119
|
+
} = await deliver(channel, notification);
|
|
120
|
+
return {
|
|
121
|
+
channel: name,
|
|
122
|
+
ok: true,
|
|
123
|
+
id
|
|
124
|
+
};
|
|
125
|
+
} catch (err) {
|
|
126
|
+
var _err$message;
|
|
127
|
+
return {
|
|
128
|
+
channel: name,
|
|
129
|
+
ok: false,
|
|
130
|
+
error: (_err$message = err === null || err === void 0 ? void 0 : err.message) !== null && _err$message !== void 0 ? _err$message : String(err)
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
}));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
function createNotifications(config) {
|
|
137
|
+
return new NotificationsService(config);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export { NotificationsService, createNotifications, deliver };
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nixxie-cms/notifications",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"main": "dist/nixxie-cms-notifications.cjs.js",
|
|
6
|
+
"module": "dist/nixxie-cms-notifications.esm.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/nixxie-cms-notifications.cjs.js",
|
|
10
|
+
"module": "./dist/nixxie-cms-notifications.esm.js",
|
|
11
|
+
"default": "./dist/nixxie-cms-notifications.cjs.js"
|
|
12
|
+
},
|
|
13
|
+
"./package.json": "./package.json"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@babel/runtime": "^7.24.7"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@nixxie-cms/core": "^1.0.1"
|
|
20
|
+
},
|
|
21
|
+
"peerDependencies": {
|
|
22
|
+
"@nixxie-cms/core": "^1.0.1"
|
|
23
|
+
},
|
|
24
|
+
"preconstruct": {
|
|
25
|
+
"entrypoints": [
|
|
26
|
+
"index.ts"
|
|
27
|
+
]
|
|
28
|
+
},
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "https://github.com/nixxiecms/nixxie/tree/main/packages/notifications"
|
|
32
|
+
}
|
|
33
|
+
}
|
package/src/channels.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { NixxieNotification } from '@nixxie-cms/core'
|
|
2
|
+
import type { ChannelConfig } from './types'
|
|
3
|
+
|
|
4
|
+
/** Deliver a notification on a single configured channel. Returns the provider message id. */
|
|
5
|
+
export async function deliver(
|
|
6
|
+
channel: ChannelConfig,
|
|
7
|
+
notification: NixxieNotification
|
|
8
|
+
): Promise<{ id?: string }> {
|
|
9
|
+
switch (channel.type) {
|
|
10
|
+
case 'slack':
|
|
11
|
+
return deliverSlack(channel.webhookUrl, notification)
|
|
12
|
+
case 'discord':
|
|
13
|
+
return deliverDiscord(channel.webhookUrl, notification)
|
|
14
|
+
case 'webhook':
|
|
15
|
+
return deliverWebhook(channel.url, channel.headers, notification)
|
|
16
|
+
case 'sms':
|
|
17
|
+
return deliverSms(channel, notification)
|
|
18
|
+
case 'custom':
|
|
19
|
+
return (await channel.send(notification)) ?? {}
|
|
20
|
+
default: {
|
|
21
|
+
const exhaustive: never = channel
|
|
22
|
+
throw new Error(`Unknown channel type: ${(exhaustive as any).type}`)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function text(n: NixxieNotification): string {
|
|
28
|
+
return n.title ? `*${n.title}*\n${n.body}` : n.body
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function deliverSlack(url: string, n: NixxieNotification): Promise<{ id?: string }> {
|
|
32
|
+
const res = await fetch(url, {
|
|
33
|
+
method: 'POST',
|
|
34
|
+
headers: { 'Content-Type': 'application/json' },
|
|
35
|
+
// Spread custom data first so it can add Slack fields (blocks, attachments, …) without
|
|
36
|
+
// clobbering the composed message text.
|
|
37
|
+
body: JSON.stringify({ ...(n.data ?? {}), text: text(n) }),
|
|
38
|
+
})
|
|
39
|
+
if (!res.ok) throw new Error(`Slack delivery failed (${res.status}): ${await res.text()}`)
|
|
40
|
+
return {}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function deliverDiscord(url: string, n: NixxieNotification): Promise<{ id?: string }> {
|
|
44
|
+
const res = await fetch(url, {
|
|
45
|
+
method: 'POST',
|
|
46
|
+
headers: { 'Content-Type': 'application/json' },
|
|
47
|
+
body: JSON.stringify({ content: text(n) }),
|
|
48
|
+
})
|
|
49
|
+
if (!res.ok) throw new Error(`Discord delivery failed (${res.status}): ${await res.text()}`)
|
|
50
|
+
return {}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function deliverWebhook(
|
|
54
|
+
url: string,
|
|
55
|
+
headers: Record<string, string> | undefined,
|
|
56
|
+
n: NixxieNotification
|
|
57
|
+
): Promise<{ id?: string }> {
|
|
58
|
+
const res = await fetch(url, {
|
|
59
|
+
method: 'POST',
|
|
60
|
+
headers: { 'Content-Type': 'application/json', ...headers },
|
|
61
|
+
body: JSON.stringify({ title: n.title, body: n.body, to: n.to, data: n.data }),
|
|
62
|
+
})
|
|
63
|
+
if (!res.ok) throw new Error(`Webhook delivery failed (${res.status}): ${await res.text()}`)
|
|
64
|
+
return {}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function deliverSms(
|
|
68
|
+
channel: { accountSid: string; authToken: string; from: string },
|
|
69
|
+
n: NixxieNotification
|
|
70
|
+
): Promise<{ id?: string }> {
|
|
71
|
+
if (!n.to) throw new Error('SMS notifications require `to` (a destination phone number)')
|
|
72
|
+
const auth = Buffer.from(`${channel.accountSid}:${channel.authToken}`).toString('base64')
|
|
73
|
+
const body = new URLSearchParams({
|
|
74
|
+
To: n.to,
|
|
75
|
+
From: channel.from,
|
|
76
|
+
Body: n.title ? `${n.title}: ${n.body}` : n.body,
|
|
77
|
+
})
|
|
78
|
+
const res = await fetch(
|
|
79
|
+
`https://api.twilio.com/2010-04-01/Accounts/${channel.accountSid}/Messages.json`,
|
|
80
|
+
{
|
|
81
|
+
method: 'POST',
|
|
82
|
+
headers: {
|
|
83
|
+
Authorization: `Basic ${auth}`,
|
|
84
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
85
|
+
},
|
|
86
|
+
body,
|
|
87
|
+
}
|
|
88
|
+
)
|
|
89
|
+
if (!res.ok) throw new Error(`SMS delivery failed (${res.status}): ${await res.text()}`)
|
|
90
|
+
const data: any = await res.json()
|
|
91
|
+
return { id: data.sid }
|
|
92
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
NixxieNotification,
|
|
3
|
+
NixxieNotificationResult,
|
|
4
|
+
NixxieNotificationsService,
|
|
5
|
+
} from '@nixxie-cms/core'
|
|
6
|
+
import { deliver } from './channels'
|
|
7
|
+
import type { NotificationsConfig } from './types'
|
|
8
|
+
|
|
9
|
+
export class NotificationsService implements NixxieNotificationsService {
|
|
10
|
+
private config: NotificationsConfig
|
|
11
|
+
|
|
12
|
+
constructor(config: NotificationsConfig) {
|
|
13
|
+
this.config = config
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
channels(): string[] {
|
|
17
|
+
return Object.keys(this.config.channels)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
private targetChannels(notification: NixxieNotification): string[] {
|
|
21
|
+
const requested = notification.channel ?? this.config.default ?? this.channels()
|
|
22
|
+
return Array.isArray(requested) ? requested : [requested]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async send(notification: NixxieNotification): Promise<NixxieNotificationResult[]> {
|
|
26
|
+
const targets = this.targetChannels(notification)
|
|
27
|
+
return Promise.all(
|
|
28
|
+
targets.map(async (name): Promise<NixxieNotificationResult> => {
|
|
29
|
+
const channel = this.config.channels[name]
|
|
30
|
+
if (!channel) return { channel: name, ok: false, error: `Unknown channel: ${name}` }
|
|
31
|
+
try {
|
|
32
|
+
const { id } = await deliver(channel, notification)
|
|
33
|
+
return { channel: name, ok: true, id }
|
|
34
|
+
} catch (err: any) {
|
|
35
|
+
return { channel: name, ok: false, error: err?.message ?? String(err) }
|
|
36
|
+
}
|
|
37
|
+
})
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function createNotifications(config: NotificationsConfig): NotificationsService {
|
|
43
|
+
return new NotificationsService(config)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export { deliver } from './channels'
|
|
47
|
+
export type {
|
|
48
|
+
NotificationsConfig,
|
|
49
|
+
ChannelConfig,
|
|
50
|
+
SlackChannelConfig,
|
|
51
|
+
DiscordChannelConfig,
|
|
52
|
+
WebhookChannelConfig,
|
|
53
|
+
SmsChannelConfig,
|
|
54
|
+
CustomChannelConfig,
|
|
55
|
+
} from './types'
|
|
56
|
+
export type {
|
|
57
|
+
NixxieNotification,
|
|
58
|
+
NixxieNotificationResult,
|
|
59
|
+
NixxieNotificationsService,
|
|
60
|
+
} from '@nixxie-cms/core'
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
NixxieNotification,
|
|
3
|
+
NixxieNotificationResult,
|
|
4
|
+
NixxieNotificationsService,
|
|
5
|
+
} from '@nixxie-cms/core'
|
|
6
|
+
|
|
7
|
+
export type { NixxieNotification, NixxieNotificationResult, NixxieNotificationsService }
|
|
8
|
+
|
|
9
|
+
export type SlackChannelConfig = {
|
|
10
|
+
type: 'slack'
|
|
11
|
+
/** Slack Incoming Webhook URL. */
|
|
12
|
+
webhookUrl: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type DiscordChannelConfig = {
|
|
16
|
+
type: 'discord'
|
|
17
|
+
/** Discord webhook URL. */
|
|
18
|
+
webhookUrl: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type WebhookChannelConfig = {
|
|
22
|
+
type: 'webhook'
|
|
23
|
+
/** Endpoint to POST the notification JSON to. */
|
|
24
|
+
url: string
|
|
25
|
+
/** Extra headers (e.g. an auth token). */
|
|
26
|
+
headers?: Record<string, string>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type SmsChannelConfig = {
|
|
30
|
+
type: 'sms'
|
|
31
|
+
/** Twilio Account SID. */
|
|
32
|
+
accountSid: string
|
|
33
|
+
/** Twilio Auth Token. */
|
|
34
|
+
authToken: string
|
|
35
|
+
/** Sending phone number in E.164 format. */
|
|
36
|
+
from: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type CustomChannelConfig = {
|
|
40
|
+
type: 'custom'
|
|
41
|
+
/** Implement delivery yourself. Return the provider message id on success. */
|
|
42
|
+
send: (notification: NixxieNotification) => Promise<{ id?: string } | void>
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type ChannelConfig =
|
|
46
|
+
| SlackChannelConfig
|
|
47
|
+
| DiscordChannelConfig
|
|
48
|
+
| WebhookChannelConfig
|
|
49
|
+
| SmsChannelConfig
|
|
50
|
+
| CustomChannelConfig
|
|
51
|
+
|
|
52
|
+
export type NotificationsConfig = {
|
|
53
|
+
/** Named channels. Reference them by key via `notification.channel`. */
|
|
54
|
+
channels: Record<string, ChannelConfig>
|
|
55
|
+
/** Channel(s) used when a notification doesn't specify one. Default: every configured channel. */
|
|
56
|
+
default?: string | string[]
|
|
57
|
+
}
|