@mantiq/notify 0.1.0 → 0.2.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/package.json +1 -1
- package/src/NotificationManager.ts +18 -8
- package/src/channels/DiscordChannel.ts +70 -0
- package/src/channels/FirebaseChannel.ts +237 -0
- package/src/channels/IMessageChannel.ts +69 -0
- package/src/channels/RcsChannel.ts +83 -0
- package/src/channels/TelegramChannel.ts +76 -0
- package/src/channels/WhatsAppChannel.ts +103 -0
- package/src/contracts/NotifyConfig.ts +30 -0
- package/src/index.ts +7 -1
package/package.json
CHANGED
|
@@ -10,6 +10,12 @@ import { BroadcastChannel } from './channels/BroadcastChannel.ts'
|
|
|
10
10
|
import { SmsChannel } from './channels/SmsChannel.ts'
|
|
11
11
|
import { SlackChannel } from './channels/SlackChannel.ts'
|
|
12
12
|
import { WebhookChannel } from './channels/WebhookChannel.ts'
|
|
13
|
+
import { DiscordChannel } from './channels/DiscordChannel.ts'
|
|
14
|
+
import { TelegramChannel } from './channels/TelegramChannel.ts'
|
|
15
|
+
import { WhatsAppChannel } from './channels/WhatsAppChannel.ts'
|
|
16
|
+
import { IMessageChannel } from './channels/IMessageChannel.ts'
|
|
17
|
+
import { RcsChannel } from './channels/RcsChannel.ts'
|
|
18
|
+
import { FirebaseChannel } from './channels/FirebaseChannel.ts'
|
|
13
19
|
|
|
14
20
|
/**
|
|
15
21
|
* NotificationManager — routes notifications through channels.
|
|
@@ -101,18 +107,22 @@ export class NotificationManager {
|
|
|
101
107
|
// ── Private ───────────────────────────────────────────────────────────────
|
|
102
108
|
|
|
103
109
|
private registerBuiltInChannels(): void {
|
|
110
|
+
// Zero-config channels (no credentials needed)
|
|
104
111
|
this._channels.set('mail', new MailChannel())
|
|
105
112
|
this._channels.set('database', new DatabaseChannel())
|
|
106
113
|
this._channels.set('broadcast', new BroadcastChannel())
|
|
107
114
|
this._channels.set('webhook', new WebhookChannel())
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
if (this.
|
|
114
|
-
|
|
115
|
-
|
|
115
|
+
this._channels.set('discord', new DiscordChannel())
|
|
116
|
+
|
|
117
|
+
// Config-based channels (lazy-loaded when config present)
|
|
118
|
+
const ch = this.config.channels
|
|
119
|
+
if (ch?.sms) this._factories.set('sms', () => new SmsChannel(ch.sms!))
|
|
120
|
+
if (ch?.slack) this._factories.set('slack', () => new SlackChannel(ch.slack!))
|
|
121
|
+
if (ch?.telegram) this._factories.set('telegram', () => new TelegramChannel(ch.telegram!))
|
|
122
|
+
if (ch?.whatsapp) this._factories.set('whatsapp', () => new WhatsAppChannel(ch.whatsapp!))
|
|
123
|
+
if (ch?.imessage) this._factories.set('imessage', () => new IMessageChannel(ch.imessage!))
|
|
124
|
+
if (ch?.rcs) this._factories.set('rcs', () => new RcsChannel(ch.rcs!))
|
|
125
|
+
if (ch?.firebase) this._factories.set('firebase', () => new FirebaseChannel(ch.firebase!))
|
|
116
126
|
}
|
|
117
127
|
|
|
118
128
|
private async queueNotification(notifiable: Notifiable, notification: Notification): Promise<void> {
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { NotificationChannel } from '../contracts/Channel.ts'
|
|
2
|
+
import type { Notifiable } from '../contracts/Notifiable.ts'
|
|
3
|
+
import type { Notification } from '../Notification.ts'
|
|
4
|
+
import { NotifyError } from '../errors/NotifyError.ts'
|
|
5
|
+
|
|
6
|
+
export interface DiscordEmbed {
|
|
7
|
+
title?: string
|
|
8
|
+
description?: string
|
|
9
|
+
url?: string
|
|
10
|
+
color?: number
|
|
11
|
+
fields?: Array<{ name: string; value: string; inline?: boolean }>
|
|
12
|
+
footer?: { text: string; icon_url?: string }
|
|
13
|
+
thumbnail?: { url: string }
|
|
14
|
+
image?: { url: string }
|
|
15
|
+
author?: { name: string; url?: string; icon_url?: string }
|
|
16
|
+
timestamp?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface DiscordPayload {
|
|
20
|
+
webhookUrl: string
|
|
21
|
+
content?: string
|
|
22
|
+
embeds?: DiscordEmbed[]
|
|
23
|
+
username?: string
|
|
24
|
+
avatarUrl?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Sends notifications to Discord via webhook URL.
|
|
29
|
+
*
|
|
30
|
+
* The notification's `toDiscord(notifiable)` method should return a `DiscordPayload`
|
|
31
|
+
* with at minimum a `webhookUrl` and either `content` or `embeds`.
|
|
32
|
+
*
|
|
33
|
+
* Uses native `fetch()` — no Discord SDK required.
|
|
34
|
+
*/
|
|
35
|
+
export class DiscordChannel implements NotificationChannel {
|
|
36
|
+
readonly name = 'discord'
|
|
37
|
+
|
|
38
|
+
async send(notifiable: Notifiable, notification: Notification): Promise<void> {
|
|
39
|
+
const payload = notification.getPayloadFor('discord', notifiable) as DiscordPayload | undefined
|
|
40
|
+
if (!payload) return
|
|
41
|
+
|
|
42
|
+
if (!payload.webhookUrl) {
|
|
43
|
+
throw new NotifyError('Discord payload is missing required webhookUrl', {
|
|
44
|
+
channel: this.name,
|
|
45
|
+
notificationType: notification.type,
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const body: Record<string, any> = {}
|
|
50
|
+
if (payload.content) body.content = payload.content
|
|
51
|
+
if (payload.embeds) body.embeds = payload.embeds
|
|
52
|
+
if (payload.username) body.username = payload.username
|
|
53
|
+
if (payload.avatarUrl) body.avatar_url = payload.avatarUrl
|
|
54
|
+
|
|
55
|
+
const response = await fetch(payload.webhookUrl, {
|
|
56
|
+
method: 'POST',
|
|
57
|
+
headers: { 'Content-Type': 'application/json' },
|
|
58
|
+
body: JSON.stringify(body),
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
if (!response.ok) {
|
|
62
|
+
const errorBody = await response.text().catch(() => 'unknown error')
|
|
63
|
+
throw new NotifyError(`Discord webhook error (${response.status}): ${errorBody}`, {
|
|
64
|
+
channel: this.name,
|
|
65
|
+
notificationType: notification.type,
|
|
66
|
+
statusCode: response.status,
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import type { NotificationChannel } from '../contracts/Channel.ts'
|
|
2
|
+
import type { Notifiable } from '../contracts/Notifiable.ts'
|
|
3
|
+
import type { Notification } from '../Notification.ts'
|
|
4
|
+
import { NotifyError } from '../errors/NotifyError.ts'
|
|
5
|
+
|
|
6
|
+
export interface FirebaseConfig {
|
|
7
|
+
projectId: string
|
|
8
|
+
accessToken?: string
|
|
9
|
+
serviceAccountKey?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface FirebasePayload {
|
|
13
|
+
token?: string
|
|
14
|
+
topic?: string
|
|
15
|
+
title: string
|
|
16
|
+
body: string
|
|
17
|
+
data?: Record<string, string>
|
|
18
|
+
imageUrl?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Sends push notifications via Firebase Cloud Messaging (FCM) v1 API.
|
|
23
|
+
*
|
|
24
|
+
* The notification's `toFirebase(notifiable)` method should return a `FirebasePayload`
|
|
25
|
+
* with at minimum `title` and `body`. Target is resolved from `token`, `topic`, or
|
|
26
|
+
* `notifiable.routeNotificationFor('firebase')` (for the device FCM token).
|
|
27
|
+
*
|
|
28
|
+
* Two authentication modes:
|
|
29
|
+
* 1. **Access Token** — provide `accessToken` directly in config
|
|
30
|
+
* 2. **Service Account Key** — provide `serviceAccountKey` JSON string; the channel
|
|
31
|
+
* will mint an OAuth2 token via Google's token endpoint using JWT assertion
|
|
32
|
+
*
|
|
33
|
+
* Uses native `fetch()` — no Firebase Admin SDK required.
|
|
34
|
+
*/
|
|
35
|
+
export class FirebaseChannel implements NotificationChannel {
|
|
36
|
+
readonly name = 'firebase'
|
|
37
|
+
|
|
38
|
+
private cachedAccessToken: string | undefined
|
|
39
|
+
private tokenExpiresAt = 0
|
|
40
|
+
|
|
41
|
+
constructor(private readonly config: FirebaseConfig) {}
|
|
42
|
+
|
|
43
|
+
async send(notifiable: Notifiable, notification: Notification): Promise<void> {
|
|
44
|
+
const payload = notification.getPayloadFor('firebase', notifiable) as FirebasePayload | undefined
|
|
45
|
+
if (!payload) return
|
|
46
|
+
|
|
47
|
+
// Resolve target: payload.token > payload.topic > notifiable route
|
|
48
|
+
const token = payload.token ?? notifiable.routeNotificationFor('firebase')
|
|
49
|
+
const topic = payload.topic
|
|
50
|
+
|
|
51
|
+
if (!token && !topic) {
|
|
52
|
+
throw new NotifyError('No FCM target: payload.token and payload.topic are empty and notifiable returned null for firebase route', {
|
|
53
|
+
channel: this.name,
|
|
54
|
+
notificationType: notification.type,
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const accessToken = await this.resolveAccessToken()
|
|
59
|
+
|
|
60
|
+
const message: Record<string, any> = {
|
|
61
|
+
notification: {
|
|
62
|
+
title: payload.title,
|
|
63
|
+
body: payload.body,
|
|
64
|
+
...(payload.imageUrl ? { image: payload.imageUrl } : {}),
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (token) {
|
|
69
|
+
message.token = token
|
|
70
|
+
} else if (topic) {
|
|
71
|
+
message.topic = topic
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (payload.data) {
|
|
75
|
+
message.data = payload.data
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const url = `https://fcm.googleapis.com/v1/projects/${this.config.projectId}/messages:send`
|
|
79
|
+
|
|
80
|
+
const response = await fetch(url, {
|
|
81
|
+
method: 'POST',
|
|
82
|
+
headers: {
|
|
83
|
+
'Authorization': `Bearer ${accessToken}`,
|
|
84
|
+
'Content-Type': 'application/json',
|
|
85
|
+
},
|
|
86
|
+
body: JSON.stringify({ message }),
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
if (!response.ok) {
|
|
90
|
+
const errorBody = await response.text().catch(() => 'unknown error')
|
|
91
|
+
throw new NotifyError(`FCM API error (${response.status}): ${errorBody}`, {
|
|
92
|
+
channel: this.name,
|
|
93
|
+
notificationType: notification.type,
|
|
94
|
+
statusCode: response.status,
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Resolve the OAuth2 access token for FCM API calls.
|
|
101
|
+
* If a static `accessToken` was provided, use it directly.
|
|
102
|
+
* Otherwise, mint a token from the service account key via JWT assertion.
|
|
103
|
+
*/
|
|
104
|
+
private async resolveAccessToken(): Promise<string> {
|
|
105
|
+
if (this.config.accessToken) {
|
|
106
|
+
return this.config.accessToken
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Return cached token if still valid (with 60s safety margin)
|
|
110
|
+
if (this.cachedAccessToken && Date.now() < this.tokenExpiresAt - 60_000) {
|
|
111
|
+
return this.cachedAccessToken
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (!this.config.serviceAccountKey) {
|
|
115
|
+
throw new NotifyError('Firebase configuration requires either accessToken or serviceAccountKey', {
|
|
116
|
+
channel: this.name,
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let serviceAccount: {
|
|
121
|
+
client_email: string
|
|
122
|
+
private_key: string
|
|
123
|
+
token_uri?: string
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
serviceAccount = JSON.parse(this.config.serviceAccountKey)
|
|
128
|
+
} catch {
|
|
129
|
+
throw new NotifyError('Firebase serviceAccountKey is not valid JSON', {
|
|
130
|
+
channel: this.name,
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const tokenUri = serviceAccount.token_uri ?? 'https://oauth2.googleapis.com/token'
|
|
135
|
+
const now = Math.floor(Date.now() / 1000)
|
|
136
|
+
const expiry = now + 3600
|
|
137
|
+
|
|
138
|
+
const jwt = await this.createJwt(
|
|
139
|
+
{
|
|
140
|
+
iss: serviceAccount.client_email,
|
|
141
|
+
sub: serviceAccount.client_email,
|
|
142
|
+
aud: tokenUri,
|
|
143
|
+
iat: now,
|
|
144
|
+
exp: expiry,
|
|
145
|
+
scope: 'https://www.googleapis.com/auth/firebase.messaging',
|
|
146
|
+
},
|
|
147
|
+
serviceAccount.private_key,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
const response = await fetch(tokenUri, {
|
|
151
|
+
method: 'POST',
|
|
152
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
153
|
+
body: new URLSearchParams({
|
|
154
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
|
155
|
+
assertion: jwt,
|
|
156
|
+
}),
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
if (!response.ok) {
|
|
160
|
+
const errorBody = await response.text().catch(() => 'unknown error')
|
|
161
|
+
throw new NotifyError(`Failed to obtain FCM access token (${response.status}): ${errorBody}`, {
|
|
162
|
+
channel: this.name,
|
|
163
|
+
statusCode: response.status,
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const result = await response.json()
|
|
168
|
+
this.cachedAccessToken = result.access_token
|
|
169
|
+
this.tokenExpiresAt = Date.now() + (result.expires_in ?? 3600) * 1000
|
|
170
|
+
|
|
171
|
+
return this.cachedAccessToken!
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Create a signed JWT using the Web Crypto API (RS256).
|
|
176
|
+
* Parses a PEM-encoded RSA private key and signs with RSASSA-PKCS1-v1_5.
|
|
177
|
+
*/
|
|
178
|
+
private async createJwt(claims: Record<string, any>, privateKeyPem: string): Promise<string> {
|
|
179
|
+
const header = { alg: 'RS256', typ: 'JWT' }
|
|
180
|
+
|
|
181
|
+
const encodedHeader = this.base64UrlEncode(JSON.stringify(header))
|
|
182
|
+
const encodedClaims = this.base64UrlEncode(JSON.stringify(claims))
|
|
183
|
+
const signingInput = `${encodedHeader}.${encodedClaims}`
|
|
184
|
+
|
|
185
|
+
// Import the PEM private key
|
|
186
|
+
const keyData = this.pemToArrayBuffer(privateKeyPem)
|
|
187
|
+
const cryptoKey = await crypto.subtle.importKey(
|
|
188
|
+
'pkcs8',
|
|
189
|
+
keyData,
|
|
190
|
+
{ name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
|
|
191
|
+
false,
|
|
192
|
+
['sign'],
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
// Sign
|
|
196
|
+
const signatureBuffer = await crypto.subtle.sign(
|
|
197
|
+
'RSASSA-PKCS1-v1_5',
|
|
198
|
+
cryptoKey,
|
|
199
|
+
new TextEncoder().encode(signingInput),
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
const encodedSignature = this.base64UrlEncodeBuffer(new Uint8Array(signatureBuffer))
|
|
203
|
+
|
|
204
|
+
return `${signingInput}.${encodedSignature}`
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Convert a PEM-encoded key to an ArrayBuffer */
|
|
208
|
+
private pemToArrayBuffer(pem: string): ArrayBuffer {
|
|
209
|
+
const base64 = pem
|
|
210
|
+
.replace(/-----BEGIN [A-Z ]+-----/g, '')
|
|
211
|
+
.replace(/-----END [A-Z ]+-----/g, '')
|
|
212
|
+
.replace(/\s/g, '')
|
|
213
|
+
|
|
214
|
+
const binary = atob(base64)
|
|
215
|
+
const bytes = new Uint8Array(binary.length)
|
|
216
|
+
for (let i = 0; i < binary.length; i++) {
|
|
217
|
+
bytes[i] = binary.charCodeAt(i)
|
|
218
|
+
}
|
|
219
|
+
return bytes.buffer
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Base64url-encode a UTF-8 string */
|
|
223
|
+
private base64UrlEncode(str: string): string {
|
|
224
|
+
const base64 = btoa(str)
|
|
225
|
+
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Base64url-encode a Uint8Array */
|
|
229
|
+
private base64UrlEncodeBuffer(buffer: Uint8Array): string {
|
|
230
|
+
let binary = ''
|
|
231
|
+
for (const byte of buffer) {
|
|
232
|
+
binary += String.fromCharCode(byte)
|
|
233
|
+
}
|
|
234
|
+
const base64 = btoa(binary)
|
|
235
|
+
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
236
|
+
}
|
|
237
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { NotificationChannel } from '../contracts/Channel.ts'
|
|
2
|
+
import type { Notifiable } from '../contracts/Notifiable.ts'
|
|
3
|
+
import type { Notification } from '../Notification.ts'
|
|
4
|
+
import { NotifyError } from '../errors/NotifyError.ts'
|
|
5
|
+
|
|
6
|
+
export interface IMessageConfig {
|
|
7
|
+
serviceUrl: string
|
|
8
|
+
authToken: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface IMessagePayload {
|
|
12
|
+
to?: string
|
|
13
|
+
text: string
|
|
14
|
+
interactiveData?: any
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Sends notifications via Apple Business Messages (Messages for Business API).
|
|
19
|
+
*
|
|
20
|
+
* The notification's `toImessage(notifiable)` method should return an `IMessagePayload`
|
|
21
|
+
* with at minimum `text`. If `to` is not provided in the payload, the channel
|
|
22
|
+
* falls back to `notifiable.routeNotificationFor('imessage')`.
|
|
23
|
+
*
|
|
24
|
+
* Uses native `fetch()` — no Apple SDK required.
|
|
25
|
+
*/
|
|
26
|
+
export class IMessageChannel implements NotificationChannel {
|
|
27
|
+
readonly name = 'imessage'
|
|
28
|
+
|
|
29
|
+
constructor(private readonly config: IMessageConfig) {}
|
|
30
|
+
|
|
31
|
+
async send(notifiable: Notifiable, notification: Notification): Promise<void> {
|
|
32
|
+
const payload = notification.getPayloadFor('imessage', notifiable) as IMessagePayload | undefined
|
|
33
|
+
if (!payload) return
|
|
34
|
+
|
|
35
|
+
const to = payload.to ?? notifiable.routeNotificationFor('imessage')
|
|
36
|
+
if (!to) {
|
|
37
|
+
throw new NotifyError('No iMessage recipient: payload.to is empty and notifiable returned null for imessage route', {
|
|
38
|
+
channel: this.name,
|
|
39
|
+
notificationType: notification.type,
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const body: Record<string, any> = {
|
|
44
|
+
to,
|
|
45
|
+
text: payload.text,
|
|
46
|
+
}
|
|
47
|
+
if (payload.interactiveData) body.interactiveData = payload.interactiveData
|
|
48
|
+
|
|
49
|
+
const url = `${this.config.serviceUrl}/messages`
|
|
50
|
+
|
|
51
|
+
const response = await fetch(url, {
|
|
52
|
+
method: 'POST',
|
|
53
|
+
headers: {
|
|
54
|
+
'Authorization': `Bearer ${this.config.authToken}`,
|
|
55
|
+
'Content-Type': 'application/json',
|
|
56
|
+
},
|
|
57
|
+
body: JSON.stringify(body),
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
if (!response.ok) {
|
|
61
|
+
const errorBody = await response.text().catch(() => 'unknown error')
|
|
62
|
+
throw new NotifyError(`iMessage API error (${response.status}): ${errorBody}`, {
|
|
63
|
+
channel: this.name,
|
|
64
|
+
notificationType: notification.type,
|
|
65
|
+
statusCode: response.status,
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { NotificationChannel } from '../contracts/Channel.ts'
|
|
2
|
+
import type { Notifiable } from '../contracts/Notifiable.ts'
|
|
3
|
+
import type { Notification } from '../Notification.ts'
|
|
4
|
+
import { NotifyError } from '../errors/NotifyError.ts'
|
|
5
|
+
|
|
6
|
+
export interface RcsConfig {
|
|
7
|
+
agentId: string
|
|
8
|
+
accessToken: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface RcsPayload {
|
|
12
|
+
to?: string
|
|
13
|
+
text?: string
|
|
14
|
+
richCard?: any
|
|
15
|
+
suggestions?: any[]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Sends notifications via Google RCS Business Messaging (RBM) Agent API.
|
|
20
|
+
*
|
|
21
|
+
* The notification's `toRcs(notifiable)` method should return an `RcsPayload`
|
|
22
|
+
* with either `text` or `richCard`. If `to` is not provided in the payload,
|
|
23
|
+
* the channel falls back to `notifiable.routeNotificationFor('rcs')`.
|
|
24
|
+
*
|
|
25
|
+
* Uses native `fetch()` — no Google SDK required.
|
|
26
|
+
*/
|
|
27
|
+
export class RcsChannel implements NotificationChannel {
|
|
28
|
+
readonly name = 'rcs'
|
|
29
|
+
|
|
30
|
+
constructor(private readonly config: RcsConfig) {}
|
|
31
|
+
|
|
32
|
+
async send(notifiable: Notifiable, notification: Notification): Promise<void> {
|
|
33
|
+
const payload = notification.getPayloadFor('rcs', notifiable) as RcsPayload | undefined
|
|
34
|
+
if (!payload) return
|
|
35
|
+
|
|
36
|
+
const to = payload.to ?? notifiable.routeNotificationFor('rcs')
|
|
37
|
+
if (!to) {
|
|
38
|
+
throw new NotifyError('No RCS recipient: payload.to is empty and notifiable returned null for rcs route', {
|
|
39
|
+
channel: this.name,
|
|
40
|
+
notificationType: notification.type,
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const contentMessage: Record<string, any> = {}
|
|
45
|
+
|
|
46
|
+
if (payload.richCard) {
|
|
47
|
+
contentMessage.richCard = payload.richCard
|
|
48
|
+
} else if (payload.text) {
|
|
49
|
+
contentMessage.text = payload.text
|
|
50
|
+
} else {
|
|
51
|
+
throw new NotifyError('RCS payload must contain either text or richCard', {
|
|
52
|
+
channel: this.name,
|
|
53
|
+
notificationType: notification.type,
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (payload.suggestions) {
|
|
58
|
+
contentMessage.suggestions = payload.suggestions
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const body: Record<string, any> = { contentMessage }
|
|
62
|
+
|
|
63
|
+
const url = `https://rcsbusinessmessaging.googleapis.com/v1/phones/${encodeURIComponent(String(to))}/agentMessages`
|
|
64
|
+
|
|
65
|
+
const response = await fetch(url, {
|
|
66
|
+
method: 'POST',
|
|
67
|
+
headers: {
|
|
68
|
+
'Authorization': `Bearer ${this.config.accessToken}`,
|
|
69
|
+
'Content-Type': 'application/json',
|
|
70
|
+
},
|
|
71
|
+
body: JSON.stringify(body),
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
if (!response.ok) {
|
|
75
|
+
const errorBody = await response.text().catch(() => 'unknown error')
|
|
76
|
+
throw new NotifyError(`RCS API error (${response.status}): ${errorBody}`, {
|
|
77
|
+
channel: this.name,
|
|
78
|
+
notificationType: notification.type,
|
|
79
|
+
statusCode: response.status,
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { NotificationChannel } from '../contracts/Channel.ts'
|
|
2
|
+
import type { Notifiable } from '../contracts/Notifiable.ts'
|
|
3
|
+
import type { Notification } from '../Notification.ts'
|
|
4
|
+
import { NotifyError } from '../errors/NotifyError.ts'
|
|
5
|
+
|
|
6
|
+
export interface TelegramConfig {
|
|
7
|
+
botToken: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface TelegramPayload {
|
|
11
|
+
chatId?: string | number
|
|
12
|
+
text: string
|
|
13
|
+
parseMode?: 'HTML' | 'Markdown' | 'MarkdownV2'
|
|
14
|
+
replyMarkup?: any
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Sends notifications via the Telegram Bot API.
|
|
19
|
+
*
|
|
20
|
+
* The notification's `toTelegram(notifiable)` method should return a `TelegramPayload`
|
|
21
|
+
* with at minimum `text`. If `chatId` is not provided in the payload, the channel
|
|
22
|
+
* falls back to `notifiable.routeNotificationFor('telegram')`.
|
|
23
|
+
*
|
|
24
|
+
* Uses native `fetch()` — no Telegram SDK required.
|
|
25
|
+
*/
|
|
26
|
+
export class TelegramChannel implements NotificationChannel {
|
|
27
|
+
readonly name = 'telegram'
|
|
28
|
+
|
|
29
|
+
constructor(private readonly config: TelegramConfig) {}
|
|
30
|
+
|
|
31
|
+
async send(notifiable: Notifiable, notification: Notification): Promise<void> {
|
|
32
|
+
const payload = notification.getPayloadFor('telegram', notifiable) as TelegramPayload | undefined
|
|
33
|
+
if (!payload) return
|
|
34
|
+
|
|
35
|
+
const chatId = payload.chatId ?? notifiable.routeNotificationFor('telegram')
|
|
36
|
+
if (!chatId) {
|
|
37
|
+
throw new NotifyError('No Telegram chat ID: payload.chatId is empty and notifiable returned null for telegram route', {
|
|
38
|
+
channel: this.name,
|
|
39
|
+
notificationType: notification.type,
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const body: Record<string, any> = {
|
|
44
|
+
chat_id: chatId,
|
|
45
|
+
text: payload.text,
|
|
46
|
+
}
|
|
47
|
+
if (payload.parseMode) body.parse_mode = payload.parseMode
|
|
48
|
+
if (payload.replyMarkup) body.reply_markup = payload.replyMarkup
|
|
49
|
+
|
|
50
|
+
const url = `https://api.telegram.org/bot${this.config.botToken}/sendMessage`
|
|
51
|
+
|
|
52
|
+
const response = await fetch(url, {
|
|
53
|
+
method: 'POST',
|
|
54
|
+
headers: { 'Content-Type': 'application/json' },
|
|
55
|
+
body: JSON.stringify(body),
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
if (!response.ok) {
|
|
59
|
+
const errorBody = await response.text().catch(() => 'unknown error')
|
|
60
|
+
throw new NotifyError(`Telegram API error (${response.status}): ${errorBody}`, {
|
|
61
|
+
channel: this.name,
|
|
62
|
+
notificationType: notification.type,
|
|
63
|
+
statusCode: response.status,
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const result = await response.json().catch(() => null)
|
|
68
|
+
if (result && !result.ok) {
|
|
69
|
+
throw new NotifyError(`Telegram API error: ${result.description ?? 'unknown error'}`, {
|
|
70
|
+
channel: this.name,
|
|
71
|
+
notificationType: notification.type,
|
|
72
|
+
errorCode: result.error_code,
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { NotificationChannel } from '../contracts/Channel.ts'
|
|
2
|
+
import type { Notifiable } from '../contracts/Notifiable.ts'
|
|
3
|
+
import type { Notification } from '../Notification.ts'
|
|
4
|
+
import { NotifyError } from '../errors/NotifyError.ts'
|
|
5
|
+
|
|
6
|
+
export interface WhatsAppConfig {
|
|
7
|
+
accessToken: string
|
|
8
|
+
phoneNumberId: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface WhatsAppPayload {
|
|
12
|
+
to?: string
|
|
13
|
+
template?: {
|
|
14
|
+
name: string
|
|
15
|
+
languageCode: string
|
|
16
|
+
components?: any[]
|
|
17
|
+
}
|
|
18
|
+
text?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Sends notifications via the Meta Cloud API for WhatsApp Business.
|
|
23
|
+
*
|
|
24
|
+
* The notification's `toWhatsapp(notifiable)` method should return a `WhatsAppPayload`
|
|
25
|
+
* with either a `template` or `text` message. If `to` is not provided in the payload,
|
|
26
|
+
* the channel falls back to `notifiable.routeNotificationFor('whatsapp')`.
|
|
27
|
+
*
|
|
28
|
+
* Uses native `fetch()` — no WhatsApp SDK required.
|
|
29
|
+
*/
|
|
30
|
+
export class WhatsAppChannel implements NotificationChannel {
|
|
31
|
+
readonly name = 'whatsapp'
|
|
32
|
+
|
|
33
|
+
constructor(private readonly config: WhatsAppConfig) {}
|
|
34
|
+
|
|
35
|
+
async send(notifiable: Notifiable, notification: Notification): Promise<void> {
|
|
36
|
+
const payload = notification.getPayloadFor('whatsapp', notifiable) as WhatsAppPayload | undefined
|
|
37
|
+
if (!payload) return
|
|
38
|
+
|
|
39
|
+
const to = payload.to ?? notifiable.routeNotificationFor('whatsapp')
|
|
40
|
+
if (!to) {
|
|
41
|
+
throw new NotifyError('No WhatsApp recipient: payload.to is empty and notifiable returned null for whatsapp route', {
|
|
42
|
+
channel: this.name,
|
|
43
|
+
notificationType: notification.type,
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let body: Record<string, any>
|
|
48
|
+
|
|
49
|
+
if (payload.template) {
|
|
50
|
+
body = {
|
|
51
|
+
messaging_product: 'whatsapp',
|
|
52
|
+
to,
|
|
53
|
+
type: 'template',
|
|
54
|
+
template: {
|
|
55
|
+
name: payload.template.name,
|
|
56
|
+
language: { code: payload.template.languageCode },
|
|
57
|
+
...(payload.template.components ? { components: payload.template.components } : {}),
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
} else if (payload.text) {
|
|
61
|
+
body = {
|
|
62
|
+
messaging_product: 'whatsapp',
|
|
63
|
+
to,
|
|
64
|
+
type: 'text',
|
|
65
|
+
text: { body: payload.text },
|
|
66
|
+
}
|
|
67
|
+
} else {
|
|
68
|
+
throw new NotifyError('WhatsApp payload must contain either template or text', {
|
|
69
|
+
channel: this.name,
|
|
70
|
+
notificationType: notification.type,
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const url = `https://graph.facebook.com/v21.0/${this.config.phoneNumberId}/messages`
|
|
75
|
+
|
|
76
|
+
const response = await fetch(url, {
|
|
77
|
+
method: 'POST',
|
|
78
|
+
headers: {
|
|
79
|
+
'Authorization': `Bearer ${this.config.accessToken}`,
|
|
80
|
+
'Content-Type': 'application/json',
|
|
81
|
+
},
|
|
82
|
+
body: JSON.stringify(body),
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
if (!response.ok) {
|
|
86
|
+
const errorBody = await response.text().catch(() => 'unknown error')
|
|
87
|
+
throw new NotifyError(`WhatsApp API error (${response.status}): ${errorBody}`, {
|
|
88
|
+
channel: this.name,
|
|
89
|
+
notificationType: notification.type,
|
|
90
|
+
statusCode: response.status,
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const result = await response.json().catch(() => null)
|
|
95
|
+
if (result?.error) {
|
|
96
|
+
throw new NotifyError(`WhatsApp API error: ${result.error.message ?? 'unknown error'}`, {
|
|
97
|
+
channel: this.name,
|
|
98
|
+
notificationType: notification.type,
|
|
99
|
+
errorCode: result.error.code,
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -9,10 +9,40 @@ export interface SlackConfig {
|
|
|
9
9
|
token?: string
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
export interface TelegramConfig {
|
|
13
|
+
botToken: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface WhatsAppConfig {
|
|
17
|
+
accessToken: string
|
|
18
|
+
phoneNumberId: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface IMessageConfig {
|
|
22
|
+
serviceUrl: string
|
|
23
|
+
authToken: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface RcsConfig {
|
|
27
|
+
agentId: string
|
|
28
|
+
accessToken: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface FirebaseConfig {
|
|
32
|
+
projectId: string
|
|
33
|
+
accessToken?: string
|
|
34
|
+
serviceAccountKey?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
12
37
|
export interface NotifyConfig {
|
|
13
38
|
channels?: {
|
|
14
39
|
sms?: SmsConfig
|
|
15
40
|
slack?: SlackConfig
|
|
41
|
+
telegram?: TelegramConfig
|
|
42
|
+
whatsapp?: WhatsAppConfig
|
|
43
|
+
imessage?: IMessageConfig
|
|
44
|
+
rcs?: RcsConfig
|
|
45
|
+
firebase?: FirebaseConfig
|
|
16
46
|
}
|
|
17
47
|
}
|
|
18
48
|
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// ── Contracts ────────────────────────────────────────────────────────────────
|
|
2
2
|
export type { NotificationChannel } from './contracts/Channel.ts'
|
|
3
3
|
export type { Notifiable } from './contracts/Notifiable.ts'
|
|
4
|
-
export type { NotifyConfig, SmsConfig, SlackConfig } from './contracts/NotifyConfig.ts'
|
|
4
|
+
export type { NotifyConfig, SmsConfig, SlackConfig, TelegramConfig, WhatsAppConfig, IMessageConfig, RcsConfig, FirebaseConfig } from './contracts/NotifyConfig.ts'
|
|
5
5
|
|
|
6
6
|
// ── Core ─────────────────────────────────────────────────────────────────────
|
|
7
7
|
export { Notification } from './Notification.ts'
|
|
@@ -15,6 +15,12 @@ export { BroadcastChannel } from './channels/BroadcastChannel.ts'
|
|
|
15
15
|
export { SmsChannel } from './channels/SmsChannel.ts'
|
|
16
16
|
export { SlackChannel } from './channels/SlackChannel.ts'
|
|
17
17
|
export { WebhookChannel } from './channels/WebhookChannel.ts'
|
|
18
|
+
export { DiscordChannel } from './channels/DiscordChannel.ts'
|
|
19
|
+
export { TelegramChannel } from './channels/TelegramChannel.ts'
|
|
20
|
+
export { WhatsAppChannel } from './channels/WhatsAppChannel.ts'
|
|
21
|
+
export { IMessageChannel } from './channels/IMessageChannel.ts'
|
|
22
|
+
export { RcsChannel } from './channels/RcsChannel.ts'
|
|
23
|
+
export { FirebaseChannel } from './channels/FirebaseChannel.ts'
|
|
18
24
|
|
|
19
25
|
// ── Models ───────────────────────────────────────────────────────────────────
|
|
20
26
|
export { DatabaseNotification } from './models/DatabaseNotification.ts'
|