@auxiora/channels 1.0.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.
Files changed (89) hide show
  1. package/LICENSE +191 -0
  2. package/dist/adapters/bluebubbles.d.ts +63 -0
  3. package/dist/adapters/bluebubbles.d.ts.map +1 -0
  4. package/dist/adapters/bluebubbles.js +197 -0
  5. package/dist/adapters/bluebubbles.js.map +1 -0
  6. package/dist/adapters/discord.d.ts +27 -0
  7. package/dist/adapters/discord.d.ts.map +1 -0
  8. package/dist/adapters/discord.js +202 -0
  9. package/dist/adapters/discord.js.map +1 -0
  10. package/dist/adapters/email.d.ts +39 -0
  11. package/dist/adapters/email.d.ts.map +1 -0
  12. package/dist/adapters/email.js +359 -0
  13. package/dist/adapters/email.js.map +1 -0
  14. package/dist/adapters/googlechat.d.ts +77 -0
  15. package/dist/adapters/googlechat.d.ts.map +1 -0
  16. package/dist/adapters/googlechat.js +232 -0
  17. package/dist/adapters/googlechat.js.map +1 -0
  18. package/dist/adapters/matrix.d.ts +37 -0
  19. package/dist/adapters/matrix.d.ts.map +1 -0
  20. package/dist/adapters/matrix.js +262 -0
  21. package/dist/adapters/matrix.js.map +1 -0
  22. package/dist/adapters/signal.d.ts +32 -0
  23. package/dist/adapters/signal.d.ts.map +1 -0
  24. package/dist/adapters/signal.js +216 -0
  25. package/dist/adapters/signal.js.map +1 -0
  26. package/dist/adapters/slack.d.ts +29 -0
  27. package/dist/adapters/slack.d.ts.map +1 -0
  28. package/dist/adapters/slack.js +202 -0
  29. package/dist/adapters/slack.js.map +1 -0
  30. package/dist/adapters/teams.d.ts +66 -0
  31. package/dist/adapters/teams.d.ts.map +1 -0
  32. package/dist/adapters/teams.js +227 -0
  33. package/dist/adapters/teams.js.map +1 -0
  34. package/dist/adapters/telegram.d.ts +28 -0
  35. package/dist/adapters/telegram.d.ts.map +1 -0
  36. package/dist/adapters/telegram.js +170 -0
  37. package/dist/adapters/telegram.js.map +1 -0
  38. package/dist/adapters/twilio.d.ts +63 -0
  39. package/dist/adapters/twilio.d.ts.map +1 -0
  40. package/dist/adapters/twilio.js +193 -0
  41. package/dist/adapters/twilio.js.map +1 -0
  42. package/dist/adapters/whatsapp.d.ts +99 -0
  43. package/dist/adapters/whatsapp.d.ts.map +1 -0
  44. package/dist/adapters/whatsapp.js +218 -0
  45. package/dist/adapters/whatsapp.js.map +1 -0
  46. package/dist/adapters/zalo.d.ts +64 -0
  47. package/dist/adapters/zalo.d.ts.map +1 -0
  48. package/dist/adapters/zalo.js +216 -0
  49. package/dist/adapters/zalo.js.map +1 -0
  50. package/dist/index.d.ts +15 -0
  51. package/dist/index.d.ts.map +1 -0
  52. package/dist/index.js +16 -0
  53. package/dist/index.js.map +1 -0
  54. package/dist/manager.d.ts +35 -0
  55. package/dist/manager.d.ts.map +1 -0
  56. package/dist/manager.js +127 -0
  57. package/dist/manager.js.map +1 -0
  58. package/dist/types.d.ts +71 -0
  59. package/dist/types.d.ts.map +1 -0
  60. package/dist/types.js +2 -0
  61. package/dist/types.js.map +1 -0
  62. package/package.json +32 -0
  63. package/src/adapters/bluebubbles.ts +294 -0
  64. package/src/adapters/discord.ts +253 -0
  65. package/src/adapters/email.ts +457 -0
  66. package/src/adapters/googlechat.ts +364 -0
  67. package/src/adapters/matrix.ts +376 -0
  68. package/src/adapters/signal.ts +313 -0
  69. package/src/adapters/slack.ts +252 -0
  70. package/src/adapters/teams.ts +320 -0
  71. package/src/adapters/telegram.ts +208 -0
  72. package/src/adapters/twilio.ts +256 -0
  73. package/src/adapters/whatsapp.ts +342 -0
  74. package/src/adapters/zalo.ts +319 -0
  75. package/src/index.ts +78 -0
  76. package/src/manager.ts +180 -0
  77. package/src/types.ts +84 -0
  78. package/tests/bluebubbles.test.ts +438 -0
  79. package/tests/email.test.ts +136 -0
  80. package/tests/googlechat.test.ts +439 -0
  81. package/tests/matrix.test.ts +564 -0
  82. package/tests/signal.test.ts +404 -0
  83. package/tests/slack.test.ts +343 -0
  84. package/tests/teams.test.ts +429 -0
  85. package/tests/twilio.test.ts +269 -0
  86. package/tests/whatsapp.test.ts +530 -0
  87. package/tests/zalo.test.ts +499 -0
  88. package/tsconfig.json +8 -0
  89. package/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,342 @@
1
+ import { audit } from '@auxiora/audit';
2
+ import type {
3
+ ChannelAdapter,
4
+ InboundMessage,
5
+ OutboundMessage,
6
+ SendResult,
7
+ } from '../types.js';
8
+
9
+ export interface WhatsAppAdapterConfig {
10
+ phoneNumberId: string;
11
+ accessToken: string;
12
+ verifyToken: string;
13
+ allowedNumbers?: string[];
14
+ }
15
+
16
+ interface WhatsAppWebhookBody {
17
+ object: string;
18
+ entry: Array<{
19
+ id: string;
20
+ changes: Array<{
21
+ value: {
22
+ messaging_product: string;
23
+ metadata: {
24
+ display_phone_number: string;
25
+ phone_number_id: string;
26
+ };
27
+ contacts?: Array<{
28
+ profile: { name: string };
29
+ wa_id: string;
30
+ }>;
31
+ messages?: Array<WhatsAppIncomingMessage>;
32
+ statuses?: Array<{
33
+ id: string;
34
+ status: string;
35
+ timestamp: string;
36
+ }>;
37
+ };
38
+ field: string;
39
+ }>;
40
+ }>;
41
+ }
42
+
43
+ interface WhatsAppIncomingMessage {
44
+ from: string;
45
+ id: string;
46
+ timestamp: string;
47
+ type: string;
48
+ text?: { body: string };
49
+ image?: {
50
+ id: string;
51
+ mime_type: string;
52
+ sha256: string;
53
+ caption?: string;
54
+ };
55
+ document?: {
56
+ id: string;
57
+ mime_type: string;
58
+ filename?: string;
59
+ sha256: string;
60
+ caption?: string;
61
+ };
62
+ audio?: {
63
+ id: string;
64
+ mime_type: string;
65
+ };
66
+ video?: {
67
+ id: string;
68
+ mime_type: string;
69
+ };
70
+ context?: {
71
+ from: string;
72
+ id: string;
73
+ };
74
+ }
75
+
76
+ interface WhatsAppSendResponse {
77
+ messaging_product: string;
78
+ contacts: Array<{ wa_id: string }>;
79
+ messages: Array<{ id: string }>;
80
+ }
81
+
82
+ const GRAPH_API_BASE = 'https://graph.facebook.com/v18.0';
83
+ const MAX_MESSAGE_LENGTH = 4096;
84
+
85
+ export class WhatsAppAdapter implements ChannelAdapter {
86
+ readonly type = 'whatsapp' as const;
87
+ readonly name = 'WhatsApp';
88
+
89
+ private config: WhatsAppAdapterConfig;
90
+ private messageHandler?: (message: InboundMessage) => Promise<void>;
91
+ private errorHandler?: (error: Error) => void;
92
+ private connected = false;
93
+
94
+ constructor(config: WhatsAppAdapterConfig) {
95
+ this.config = config;
96
+ }
97
+
98
+ async connect(): Promise<void> {
99
+ // Verify credentials by checking the phone number ID
100
+ const response = await fetch(
101
+ `${GRAPH_API_BASE}/${this.config.phoneNumberId}`,
102
+ {
103
+ headers: {
104
+ 'Authorization': `Bearer ${this.config.accessToken}`,
105
+ },
106
+ },
107
+ );
108
+
109
+ if (!response.ok) {
110
+ throw new Error(`Failed to verify WhatsApp credentials: ${response.status} ${response.statusText}`);
111
+ }
112
+
113
+ this.connected = true;
114
+
115
+ audit('channel.connected', {
116
+ channelType: 'whatsapp',
117
+ phoneNumberId: this.config.phoneNumberId,
118
+ });
119
+ }
120
+
121
+ async disconnect(): Promise<void> {
122
+ this.connected = false;
123
+ audit('channel.disconnected', { channelType: 'whatsapp' });
124
+ }
125
+
126
+ isConnected(): boolean {
127
+ return this.connected;
128
+ }
129
+
130
+ /**
131
+ * Verify webhook subscription from Meta.
132
+ * Returns the challenge string if the verify token matches.
133
+ */
134
+ verifyWebhook(mode: string, token: string, challenge: string): string | null {
135
+ if (mode === 'subscribe' && token === this.config.verifyToken) {
136
+ return challenge;
137
+ }
138
+ return null;
139
+ }
140
+
141
+ /**
142
+ * Handle incoming webhook from WhatsApp Business API.
143
+ * Call this from your HTTP server when receiving POST to your webhook URL.
144
+ */
145
+ async handleWebhook(body: WhatsAppWebhookBody): Promise<void> {
146
+ if (body.object !== 'whatsapp_business_account') return;
147
+
148
+ for (const entry of body.entry) {
149
+ for (const change of entry.changes) {
150
+ if (change.field !== 'messages') continue;
151
+
152
+ const messages = change.value.messages;
153
+ if (!messages) continue;
154
+
155
+ const contacts = change.value.contacts;
156
+
157
+ for (const msg of messages) {
158
+ const contact = contacts?.find((c) => c.wa_id === msg.from);
159
+ await this.handleMessage(msg, contact);
160
+ }
161
+ }
162
+ }
163
+ }
164
+
165
+ private async handleMessage(
166
+ msg: WhatsAppIncomingMessage,
167
+ contact?: { profile: { name: string }; wa_id: string },
168
+ ): Promise<void> {
169
+ // Only process text messages for now
170
+ if (msg.type !== 'text' && msg.type !== 'image' && msg.type !== 'document') return;
171
+
172
+ // Check allowed numbers
173
+ if (this.config.allowedNumbers?.length && !this.config.allowedNumbers.includes(msg.from)) {
174
+ audit('message.filtered', { channelType: 'whatsapp', senderId: msg.from, reason: 'number_not_allowed' });
175
+ return;
176
+ }
177
+
178
+ const inbound = this.toInboundMessage(msg, contact);
179
+
180
+ audit('message.received', {
181
+ channelType: 'whatsapp',
182
+ senderId: inbound.senderId,
183
+ channelId: inbound.channelId,
184
+ });
185
+
186
+ if (this.messageHandler) {
187
+ try {
188
+ await this.messageHandler(inbound);
189
+ } catch (error) {
190
+ this.errorHandler?.(error instanceof Error ? error : new Error(String(error)));
191
+ }
192
+ }
193
+ }
194
+
195
+ private toInboundMessage(
196
+ msg: WhatsAppIncomingMessage,
197
+ contact?: { profile: { name: string }; wa_id: string },
198
+ ): InboundMessage {
199
+ let content = '';
200
+ const attachments: InboundMessage['attachments'] = [];
201
+
202
+ switch (msg.type) {
203
+ case 'text':
204
+ content = msg.text?.body || '';
205
+ break;
206
+ case 'image':
207
+ content = msg.image?.caption || '';
208
+ attachments.push({
209
+ type: 'image',
210
+ mimeType: msg.image?.mime_type,
211
+ });
212
+ break;
213
+ case 'document':
214
+ content = msg.document?.caption || '';
215
+ attachments.push({
216
+ type: 'file',
217
+ mimeType: msg.document?.mime_type,
218
+ filename: msg.document?.filename,
219
+ });
220
+ break;
221
+ case 'audio':
222
+ attachments.push({
223
+ type: 'audio',
224
+ mimeType: msg.audio?.mime_type,
225
+ });
226
+ break;
227
+ case 'video':
228
+ attachments.push({
229
+ type: 'video',
230
+ mimeType: msg.video?.mime_type,
231
+ });
232
+ break;
233
+ }
234
+
235
+ return {
236
+ id: msg.id,
237
+ channelType: 'whatsapp',
238
+ channelId: msg.from,
239
+ senderId: msg.from,
240
+ senderName: contact?.profile.name,
241
+ content,
242
+ timestamp: parseInt(msg.timestamp, 10) * 1000,
243
+ replyToId: msg.context?.id,
244
+ attachments: attachments.length > 0 ? attachments : undefined,
245
+ raw: msg,
246
+ };
247
+ }
248
+
249
+ async send(channelId: string, message: OutboundMessage): Promise<SendResult> {
250
+ try {
251
+ const chunks = this.chunkMessage(message.content);
252
+ let lastMessageId: string | undefined;
253
+
254
+ for (const chunk of chunks) {
255
+ const body: Record<string, unknown> = {
256
+ messaging_product: 'whatsapp',
257
+ recipient_type: 'individual',
258
+ to: channelId,
259
+ type: 'text',
260
+ text: { body: chunk },
261
+ };
262
+
263
+ if (message.replyToId) {
264
+ body.context = { message_id: message.replyToId };
265
+ }
266
+
267
+ const response = await fetch(
268
+ `${GRAPH_API_BASE}/${this.config.phoneNumberId}/messages`,
269
+ {
270
+ method: 'POST',
271
+ headers: {
272
+ 'Authorization': `Bearer ${this.config.accessToken}`,
273
+ 'Content-Type': 'application/json',
274
+ },
275
+ body: JSON.stringify(body),
276
+ },
277
+ );
278
+
279
+ if (!response.ok) {
280
+ const errorText = await response.text().catch(() => response.statusText);
281
+ throw new Error(`WhatsApp API error ${response.status}: ${errorText}`);
282
+ }
283
+
284
+ const result = await response.json() as WhatsAppSendResponse;
285
+ lastMessageId = result.messages?.[0]?.id;
286
+ }
287
+
288
+ audit('message.sent', {
289
+ channelType: 'whatsapp',
290
+ channelId,
291
+ messageId: lastMessageId,
292
+ });
293
+
294
+ return { success: true, messageId: lastMessageId };
295
+ } catch (error) {
296
+ const errorMessage = error instanceof Error ? error.message : String(error);
297
+ audit('channel.error', {
298
+ channelType: 'whatsapp',
299
+ action: 'send',
300
+ error: errorMessage,
301
+ });
302
+ return { success: false, error: errorMessage };
303
+ }
304
+ }
305
+
306
+ private chunkMessage(content: string): string[] {
307
+ if (content.length <= MAX_MESSAGE_LENGTH) {
308
+ return [content];
309
+ }
310
+
311
+ const chunks: string[] = [];
312
+ let remaining = content;
313
+
314
+ while (remaining.length > 0) {
315
+ if (remaining.length <= MAX_MESSAGE_LENGTH) {
316
+ chunks.push(remaining);
317
+ break;
318
+ }
319
+
320
+ let breakPoint = remaining.lastIndexOf('\n', MAX_MESSAGE_LENGTH);
321
+ if (breakPoint === -1 || breakPoint < MAX_MESSAGE_LENGTH / 2) {
322
+ breakPoint = remaining.lastIndexOf(' ', MAX_MESSAGE_LENGTH);
323
+ }
324
+ if (breakPoint === -1 || breakPoint < MAX_MESSAGE_LENGTH / 2) {
325
+ breakPoint = MAX_MESSAGE_LENGTH;
326
+ }
327
+
328
+ chunks.push(remaining.slice(0, breakPoint));
329
+ remaining = remaining.slice(breakPoint).trimStart();
330
+ }
331
+
332
+ return chunks;
333
+ }
334
+
335
+ onMessage(handler: (message: InboundMessage) => Promise<void>): void {
336
+ this.messageHandler = handler;
337
+ }
338
+
339
+ onError(handler: (error: Error) => void): void {
340
+ this.errorHandler = handler;
341
+ }
342
+ }
@@ -0,0 +1,319 @@
1
+ import { createHmac } from 'node:crypto';
2
+ import { audit } from '@auxiora/audit';
3
+ import type {
4
+ ChannelAdapter,
5
+ InboundMessage,
6
+ OutboundMessage,
7
+ SendResult,
8
+ } from '../types.js';
9
+
10
+ export interface ZaloAdapterConfig {
11
+ oaAccessToken: string;
12
+ oaSecretKey: string;
13
+ allowedUserIds?: string[];
14
+ }
15
+
16
+ interface ZaloWebhookEvent {
17
+ app_id: string;
18
+ sender: {
19
+ id: string;
20
+ };
21
+ recipient: {
22
+ id: string;
23
+ };
24
+ event_name: string;
25
+ message?: {
26
+ msg_id: string;
27
+ text?: string;
28
+ attachments?: Array<{
29
+ type: string;
30
+ payload: {
31
+ url?: string;
32
+ thumbnail?: string;
33
+ id?: string;
34
+ size?: number;
35
+ name?: string;
36
+ type?: string;
37
+ };
38
+ }>;
39
+ quote_msg_id?: string;
40
+ };
41
+ timestamp: string;
42
+ }
43
+
44
+ interface ZaloSendResponse {
45
+ error: number;
46
+ message: string;
47
+ data?: {
48
+ message_id: string;
49
+ };
50
+ }
51
+
52
+ interface ZaloUserProfileResponse {
53
+ error: number;
54
+ message: string;
55
+ data?: {
56
+ display_name: string;
57
+ user_id: string;
58
+ avatar?: string;
59
+ };
60
+ }
61
+
62
+ const ZALO_OA_API_BASE = 'https://openapi.zalo.me/v3.0/oa';
63
+ const MAX_MESSAGE_LENGTH = 2000;
64
+
65
+ export class ZaloAdapter implements ChannelAdapter {
66
+ readonly type = 'zalo' as const;
67
+ readonly name = 'Zalo';
68
+
69
+ private config: ZaloAdapterConfig;
70
+ private messageHandler?: (message: InboundMessage) => Promise<void>;
71
+ private errorHandler?: (error: Error) => void;
72
+ private connected = false;
73
+ private userNameCache: Map<string, string> = new Map();
74
+
75
+ constructor(config: ZaloAdapterConfig) {
76
+ this.config = config;
77
+ }
78
+
79
+ async connect(): Promise<void> {
80
+ // Verify credentials by fetching OA info
81
+ const response = await fetch(`${ZALO_OA_API_BASE}/getoa`, {
82
+ headers: {
83
+ access_token: this.config.oaAccessToken,
84
+ },
85
+ });
86
+
87
+ if (!response.ok) {
88
+ throw new Error(
89
+ `Failed to verify Zalo credentials: ${response.status} ${response.statusText}`,
90
+ );
91
+ }
92
+
93
+ const result = (await response.json()) as { error: number; message: string };
94
+ if (result.error !== 0) {
95
+ throw new Error(`Zalo API error: ${result.message}`);
96
+ }
97
+
98
+ this.connected = true;
99
+
100
+ audit('channel.connected', { channelType: 'zalo' });
101
+ }
102
+
103
+ async disconnect(): Promise<void> {
104
+ this.connected = false;
105
+ this.userNameCache.clear();
106
+ audit('channel.disconnected', { channelType: 'zalo' });
107
+ }
108
+
109
+ isConnected(): boolean {
110
+ return this.connected;
111
+ }
112
+
113
+ /**
114
+ * Verify the webhook signature from Zalo.
115
+ * Returns true if the signature is valid.
116
+ */
117
+ verifyWebhookSignature(body: string, signature: string): boolean {
118
+ const hmac = createHmac('sha256', this.config.oaSecretKey);
119
+ hmac.update(body);
120
+ const expected = hmac.digest('hex');
121
+ return expected === signature;
122
+ }
123
+
124
+ /**
125
+ * Handle incoming webhook from Zalo OA.
126
+ * Call this from your HTTP server when receiving POST to your webhook URL.
127
+ */
128
+ async handleWebhook(event: ZaloWebhookEvent): Promise<void> {
129
+ if (event.event_name !== 'user_send_text' && event.event_name !== 'user_send_image') {
130
+ return;
131
+ }
132
+
133
+ if (!event.message) return;
134
+
135
+ // Check allowed users
136
+ const senderId = event.sender.id;
137
+ if (
138
+ this.config.allowedUserIds?.length &&
139
+ !this.config.allowedUserIds.includes(senderId)
140
+ ) {
141
+ audit('message.filtered', {
142
+ channelType: 'zalo',
143
+ senderId,
144
+ reason: 'user_not_allowed',
145
+ });
146
+ return;
147
+ }
148
+
149
+ const senderName = await this.getUserName(senderId);
150
+ const inbound = this.toInboundMessage(event, senderName);
151
+
152
+ audit('message.received', {
153
+ channelType: 'zalo',
154
+ senderId: inbound.senderId,
155
+ channelId: inbound.channelId,
156
+ });
157
+
158
+ if (this.messageHandler) {
159
+ try {
160
+ await this.messageHandler(inbound);
161
+ } catch (error) {
162
+ this.errorHandler?.(error instanceof Error ? error : new Error(String(error)));
163
+ }
164
+ }
165
+ }
166
+
167
+ private async getUserName(userId: string): Promise<string | undefined> {
168
+ const cached = this.userNameCache.get(userId);
169
+ if (cached) return cached;
170
+
171
+ try {
172
+ const response = await fetch(
173
+ `${ZALO_OA_API_BASE}/getprofile?data=${encodeURIComponent(JSON.stringify({ user_id: userId }))}`,
174
+ {
175
+ headers: {
176
+ access_token: this.config.oaAccessToken,
177
+ },
178
+ },
179
+ );
180
+
181
+ if (response.ok) {
182
+ const result = (await response.json()) as ZaloUserProfileResponse;
183
+ if (result.data?.display_name) {
184
+ this.userNameCache.set(userId, result.data.display_name);
185
+ return result.data.display_name;
186
+ }
187
+ }
188
+ } catch {
189
+ // Silently fail - name is optional
190
+ }
191
+
192
+ return undefined;
193
+ }
194
+
195
+ private toInboundMessage(
196
+ event: ZaloWebhookEvent,
197
+ senderName?: string,
198
+ ): InboundMessage {
199
+ const message = event.message!;
200
+ const content = message.text || '';
201
+
202
+ return {
203
+ id: message.msg_id,
204
+ channelType: 'zalo',
205
+ channelId: event.sender.id,
206
+ senderId: event.sender.id,
207
+ senderName,
208
+ content,
209
+ timestamp: parseInt(event.timestamp, 10),
210
+ replyToId: message.quote_msg_id,
211
+ attachments: message.attachments?.map((a) => ({
212
+ type: a.type === 'image'
213
+ ? ('image' as const)
214
+ : a.type === 'audio'
215
+ ? ('audio' as const)
216
+ : a.type === 'video'
217
+ ? ('video' as const)
218
+ : ('file' as const),
219
+ url: a.payload.url || a.payload.thumbnail,
220
+ mimeType: a.payload.type,
221
+ filename: a.payload.name,
222
+ size: a.payload.size,
223
+ })),
224
+ raw: event,
225
+ };
226
+ }
227
+
228
+ async send(channelId: string, message: OutboundMessage): Promise<SendResult> {
229
+ try {
230
+ const chunks = this.chunkMessage(message.content);
231
+ let lastMessageId: string | undefined;
232
+
233
+ for (const chunk of chunks) {
234
+ const body: Record<string, unknown> = {
235
+ recipient: { user_id: channelId },
236
+ message: { text: chunk },
237
+ };
238
+
239
+ if (message.replyToId) {
240
+ body.quote_message_id = message.replyToId;
241
+ }
242
+
243
+ const response = await fetch(`${ZALO_OA_API_BASE}/message/cs`, {
244
+ method: 'POST',
245
+ headers: {
246
+ 'Content-Type': 'application/json',
247
+ access_token: this.config.oaAccessToken,
248
+ },
249
+ body: JSON.stringify(body),
250
+ });
251
+
252
+ if (!response.ok) {
253
+ const errorText = await response.text().catch(() => response.statusText);
254
+ throw new Error(`Zalo API error ${response.status}: ${errorText}`);
255
+ }
256
+
257
+ const result = (await response.json()) as ZaloSendResponse;
258
+ if (result.error !== 0) {
259
+ throw new Error(`Zalo send error: ${result.message}`);
260
+ }
261
+
262
+ lastMessageId = result.data?.message_id;
263
+ }
264
+
265
+ audit('message.sent', {
266
+ channelType: 'zalo',
267
+ channelId,
268
+ messageId: lastMessageId,
269
+ });
270
+
271
+ return { success: true, messageId: lastMessageId };
272
+ } catch (error) {
273
+ const errorMessage = error instanceof Error ? error.message : String(error);
274
+ audit('channel.error', {
275
+ channelType: 'zalo',
276
+ action: 'send',
277
+ error: errorMessage,
278
+ });
279
+ return { success: false, error: errorMessage };
280
+ }
281
+ }
282
+
283
+ private chunkMessage(content: string): string[] {
284
+ if (content.length <= MAX_MESSAGE_LENGTH) {
285
+ return [content];
286
+ }
287
+
288
+ const chunks: string[] = [];
289
+ let remaining = content;
290
+
291
+ while (remaining.length > 0) {
292
+ if (remaining.length <= MAX_MESSAGE_LENGTH) {
293
+ chunks.push(remaining);
294
+ break;
295
+ }
296
+
297
+ let breakPoint = remaining.lastIndexOf('\n', MAX_MESSAGE_LENGTH);
298
+ if (breakPoint === -1 || breakPoint < MAX_MESSAGE_LENGTH / 2) {
299
+ breakPoint = remaining.lastIndexOf(' ', MAX_MESSAGE_LENGTH);
300
+ }
301
+ if (breakPoint === -1 || breakPoint < MAX_MESSAGE_LENGTH / 2) {
302
+ breakPoint = MAX_MESSAGE_LENGTH;
303
+ }
304
+
305
+ chunks.push(remaining.slice(0, breakPoint));
306
+ remaining = remaining.slice(breakPoint).trimStart();
307
+ }
308
+
309
+ return chunks;
310
+ }
311
+
312
+ onMessage(handler: (message: InboundMessage) => Promise<void>): void {
313
+ this.messageHandler = handler;
314
+ }
315
+
316
+ onError(handler: (error: Error) => void): void {
317
+ this.errorHandler = handler;
318
+ }
319
+ }