@mantiq/notify 0.1.0 → 0.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mantiq/notify",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Multi-channel notifications — mail, database, broadcast, SMS, Slack, webhook",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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
- // Lazy-load SMS and Slack (need config)
110
- if (this.config.channels?.sms) {
111
- this._factories.set('sms', () => new SmsChannel(this.config.channels!.sms!))
112
- }
113
- if (this.config.channels?.slack) {
114
- this._factories.set('slack', () => new SlackChannel(this.config.channels!.slack!))
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'