@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,294 @@
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 BlueBubblesAdapterConfig {
10
+ serverUrl: string;
11
+ password: string;
12
+ allowedAddresses?: string[];
13
+ }
14
+
15
+ interface BlueBubblesMessage {
16
+ guid: string;
17
+ text: string;
18
+ handle?: {
19
+ address: string;
20
+ service: string;
21
+ uncanonicalizedId?: string;
22
+ };
23
+ chats?: Array<{
24
+ guid: string;
25
+ chatIdentifier: string;
26
+ displayName?: string;
27
+ participants?: Array<{
28
+ address: string;
29
+ }>;
30
+ }>;
31
+ dateCreated: number;
32
+ isFromMe: boolean;
33
+ threadOriginatorGuid?: string;
34
+ attachments?: Array<{
35
+ guid: string;
36
+ mimeType: string;
37
+ transferName: string;
38
+ totalBytes: number;
39
+ }>;
40
+ }
41
+
42
+ interface BlueBubblesWebhookEvent {
43
+ type: string;
44
+ data: BlueBubblesMessage;
45
+ }
46
+
47
+ interface BlueBubblesSendResponse {
48
+ status: number;
49
+ message: string;
50
+ data?: BlueBubblesMessage;
51
+ }
52
+
53
+ const MAX_MESSAGE_LENGTH = 20000;
54
+
55
+ export class BlueBubblesAdapter implements ChannelAdapter {
56
+ readonly type = 'bluebubbles' as const;
57
+ readonly name = 'BlueBubbles (iMessage)';
58
+
59
+ private config: BlueBubblesAdapterConfig;
60
+ private messageHandler?: (message: InboundMessage) => Promise<void>;
61
+ private errorHandler?: (error: Error) => void;
62
+ private connected = false;
63
+
64
+ constructor(config: BlueBubblesAdapterConfig) {
65
+ this.config = config;
66
+ }
67
+
68
+ private get baseUrl(): string {
69
+ return this.config.serverUrl.replace(/\/+$/, '');
70
+ }
71
+
72
+ private async apiRequest<T>(
73
+ method: string,
74
+ path: string,
75
+ body?: Record<string, unknown>,
76
+ ): Promise<T> {
77
+ const url = new URL(path, this.baseUrl);
78
+ url.searchParams.set('password', this.config.password);
79
+
80
+ const response = await fetch(url.toString(), {
81
+ method,
82
+ headers: body ? { 'Content-Type': 'application/json' } : undefined,
83
+ body: body ? JSON.stringify(body) : undefined,
84
+ });
85
+
86
+ if (!response.ok) {
87
+ throw new Error(
88
+ `BlueBubbles API error ${response.status}: ${response.statusText}`,
89
+ );
90
+ }
91
+
92
+ return (await response.json()) as T;
93
+ }
94
+
95
+ async connect(): Promise<void> {
96
+ // Verify connection by fetching server info
97
+ await this.apiRequest<{ status: number; message: string }>('GET', '/api/v1/server/info');
98
+ this.connected = true;
99
+
100
+ audit('channel.connected', {
101
+ channelType: 'bluebubbles',
102
+ serverUrl: this.baseUrl,
103
+ });
104
+ }
105
+
106
+ async disconnect(): Promise<void> {
107
+ this.connected = false;
108
+ audit('channel.disconnected', { channelType: 'bluebubbles' });
109
+ }
110
+
111
+ isConnected(): boolean {
112
+ return this.connected;
113
+ }
114
+
115
+ /**
116
+ * Handle incoming webhook from BlueBubbles server.
117
+ * Configure your BlueBubbles server to send webhooks to your endpoint.
118
+ */
119
+ async handleWebhook(event: BlueBubblesWebhookEvent): Promise<void> {
120
+ if (event.type !== 'new-message') return;
121
+
122
+ const msg = event.data;
123
+
124
+ // Ignore own messages
125
+ if (msg.isFromMe) return;
126
+
127
+ // Must have text content
128
+ if (!msg.text) return;
129
+
130
+ // Check allowed addresses
131
+ const senderAddress = msg.handle?.address;
132
+ if (
133
+ senderAddress &&
134
+ this.config.allowedAddresses?.length &&
135
+ !this.config.allowedAddresses.includes(senderAddress)
136
+ ) {
137
+ audit('message.filtered', {
138
+ channelType: 'bluebubbles',
139
+ senderId: senderAddress,
140
+ reason: 'address_not_allowed',
141
+ });
142
+ return;
143
+ }
144
+
145
+ const inbound = this.toInboundMessage(msg);
146
+
147
+ audit('message.received', {
148
+ channelType: 'bluebubbles',
149
+ senderId: inbound.senderId,
150
+ channelId: inbound.channelId,
151
+ });
152
+
153
+ if (this.messageHandler) {
154
+ try {
155
+ await this.messageHandler(inbound);
156
+ } catch (error) {
157
+ this.errorHandler?.(error instanceof Error ? error : new Error(String(error)));
158
+ }
159
+ }
160
+ }
161
+
162
+ private toInboundMessage(msg: BlueBubblesMessage): InboundMessage {
163
+ const chatGuid = msg.chats?.[0]?.guid || msg.handle?.address || 'unknown';
164
+
165
+ return {
166
+ id: msg.guid,
167
+ channelType: 'bluebubbles',
168
+ channelId: chatGuid,
169
+ senderId: msg.handle?.address || 'unknown',
170
+ senderName: msg.handle?.uncanonicalizedId || msg.handle?.address,
171
+ content: msg.text,
172
+ timestamp: msg.dateCreated,
173
+ replyToId: msg.threadOriginatorGuid,
174
+ attachments: msg.attachments?.map((a) => ({
175
+ type: a.mimeType.startsWith('image/')
176
+ ? ('image' as const)
177
+ : a.mimeType.startsWith('audio/')
178
+ ? ('audio' as const)
179
+ : a.mimeType.startsWith('video/')
180
+ ? ('video' as const)
181
+ : ('file' as const),
182
+ mimeType: a.mimeType,
183
+ filename: a.transferName,
184
+ size: a.totalBytes,
185
+ })),
186
+ raw: msg,
187
+ };
188
+ }
189
+
190
+ async send(channelId: string, message: OutboundMessage): Promise<SendResult> {
191
+ try {
192
+ const chunks = this.chunkMessage(message.content);
193
+ let lastGuid: string | undefined;
194
+
195
+ for (const chunk of chunks) {
196
+ const body: Record<string, unknown> = {
197
+ chatGuid: channelId,
198
+ message: chunk,
199
+ method: 'private-api',
200
+ };
201
+
202
+ if (message.replyToId) {
203
+ body.selectedMessageGuid = message.replyToId;
204
+ }
205
+
206
+ const result = await this.apiRequest<BlueBubblesSendResponse>(
207
+ 'POST',
208
+ '/api/v1/message/text',
209
+ body,
210
+ );
211
+
212
+ if (result.status !== 200) {
213
+ throw new Error(`BlueBubbles send error: ${result.message}`);
214
+ }
215
+
216
+ lastGuid = result.data?.guid;
217
+ }
218
+
219
+ audit('message.sent', {
220
+ channelType: 'bluebubbles',
221
+ channelId,
222
+ messageId: lastGuid,
223
+ });
224
+
225
+ return { success: true, messageId: lastGuid };
226
+ } catch (error) {
227
+ const errorMessage = error instanceof Error ? error.message : String(error);
228
+ audit('channel.error', {
229
+ channelType: 'bluebubbles',
230
+ action: 'send',
231
+ error: errorMessage,
232
+ });
233
+ return { success: false, error: errorMessage };
234
+ }
235
+ }
236
+
237
+ private chunkMessage(content: string): string[] {
238
+ if (content.length <= MAX_MESSAGE_LENGTH) {
239
+ return [content];
240
+ }
241
+
242
+ const chunks: string[] = [];
243
+ let remaining = content;
244
+
245
+ while (remaining.length > 0) {
246
+ if (remaining.length <= MAX_MESSAGE_LENGTH) {
247
+ chunks.push(remaining);
248
+ break;
249
+ }
250
+
251
+ let breakPoint = remaining.lastIndexOf('\n', MAX_MESSAGE_LENGTH);
252
+ if (breakPoint === -1 || breakPoint < MAX_MESSAGE_LENGTH / 2) {
253
+ breakPoint = remaining.lastIndexOf(' ', MAX_MESSAGE_LENGTH);
254
+ }
255
+ if (breakPoint === -1 || breakPoint < MAX_MESSAGE_LENGTH / 2) {
256
+ breakPoint = MAX_MESSAGE_LENGTH;
257
+ }
258
+
259
+ chunks.push(remaining.slice(0, breakPoint));
260
+ remaining = remaining.slice(breakPoint).trimStart();
261
+ }
262
+
263
+ return chunks;
264
+ }
265
+
266
+ async startTyping(channelId: string): Promise<() => void> {
267
+ // BlueBubbles supports typing indicators via the private API
268
+ this.apiRequest('POST', '/api/v1/chat/typing', {
269
+ chatGuid: channelId,
270
+ status: 'typing',
271
+ }).catch((e: Error) => {
272
+ audit('channel.error', {
273
+ channelType: 'bluebubbles',
274
+ action: 'typing',
275
+ error: e.message,
276
+ });
277
+ });
278
+
279
+ return () => {
280
+ this.apiRequest('POST', '/api/v1/chat/typing', {
281
+ chatGuid: channelId,
282
+ status: 'idle',
283
+ }).catch(() => {});
284
+ };
285
+ }
286
+
287
+ onMessage(handler: (message: InboundMessage) => Promise<void>): void {
288
+ this.messageHandler = handler;
289
+ }
290
+
291
+ onError(handler: (error: Error) => void): void {
292
+ this.errorHandler = handler;
293
+ }
294
+ }
@@ -0,0 +1,253 @@
1
+ import {
2
+ Client,
3
+ GatewayIntentBits,
4
+ Partials,
5
+ Message as DiscordMessage,
6
+ ChannelType as DiscordChannelType,
7
+ } from 'discord.js';
8
+ import { audit } from '@auxiora/audit';
9
+ import type {
10
+ ChannelAdapter,
11
+ InboundMessage,
12
+ OutboundMessage,
13
+ SendResult,
14
+ } from '../types.js';
15
+
16
+ export interface DiscordAdapterConfig {
17
+ token: string;
18
+ mentionOnly?: boolean;
19
+ allowedGuilds?: string[];
20
+ }
21
+
22
+ const MAX_MESSAGE_LENGTH = 2000;
23
+
24
+ export class DiscordAdapter implements ChannelAdapter {
25
+ readonly type = 'discord' as const;
26
+ readonly name = 'Discord';
27
+
28
+ private client: Client;
29
+ private config: DiscordAdapterConfig;
30
+ private messageHandler?: (message: InboundMessage) => Promise<void>;
31
+ private errorHandler?: (error: Error) => void;
32
+ private connected = false;
33
+
34
+ constructor(config: DiscordAdapterConfig) {
35
+ this.config = config;
36
+ this.client = new Client({
37
+ intents: [
38
+ GatewayIntentBits.Guilds,
39
+ GatewayIntentBits.GuildMessages,
40
+ GatewayIntentBits.DirectMessages,
41
+ GatewayIntentBits.MessageContent,
42
+ ],
43
+ partials: [Partials.Channel, Partials.Message],
44
+ });
45
+
46
+ this.setupEventHandlers();
47
+ }
48
+
49
+ private setupEventHandlers(): void {
50
+ this.client.on('clientReady', () => {
51
+ audit('channel.connected', {
52
+ channelType: 'discord',
53
+ username: this.client.user?.tag,
54
+ });
55
+ this.connected = true;
56
+ });
57
+
58
+ this.client.on('messageCreate', async (message: DiscordMessage) => {
59
+ // Ignore bot messages
60
+ if (message.author.bot) return;
61
+
62
+ // Check guild allowlist
63
+ if (
64
+ message.guild &&
65
+ this.config.allowedGuilds?.length &&
66
+ !this.config.allowedGuilds.includes(message.guild.id)
67
+ ) {
68
+ return;
69
+ }
70
+
71
+ // Check mention requirement for guild messages
72
+ if (
73
+ this.config.mentionOnly &&
74
+ message.guild &&
75
+ !message.mentions.has(this.client.user!.id)
76
+ ) {
77
+ return;
78
+ }
79
+
80
+ // Convert to inbound message
81
+ const inbound = this.toInboundMessage(message);
82
+
83
+ audit('message.received', {
84
+ channelType: 'discord',
85
+ senderId: inbound.senderId,
86
+ channelId: inbound.channelId,
87
+ });
88
+
89
+ if (this.messageHandler) {
90
+ try {
91
+ await this.messageHandler(inbound);
92
+ } catch (error) {
93
+ this.errorHandler?.(error instanceof Error ? error : new Error(String(error)));
94
+ }
95
+ }
96
+ });
97
+
98
+ this.client.on('error', (error) => {
99
+ audit('channel.error', { channelType: 'discord', error: error.message });
100
+ this.errorHandler?.(error);
101
+ });
102
+
103
+ this.client.on('disconnect', () => {
104
+ this.connected = false;
105
+ audit('channel.disconnected', { channelType: 'discord' });
106
+ });
107
+ }
108
+
109
+ private toInboundMessage(message: DiscordMessage): InboundMessage {
110
+ // Strip bot mention from content
111
+ let content = message.content;
112
+ if (this.client.user) {
113
+ content = content.replace(new RegExp(`<@!?${this.client.user.id}>`, 'g'), '').trim();
114
+ }
115
+
116
+ return {
117
+ id: message.id,
118
+ channelType: 'discord',
119
+ channelId: message.channel.id,
120
+ senderId: message.author.id,
121
+ senderName: message.author.displayName || message.author.username,
122
+ content,
123
+ timestamp: message.createdTimestamp,
124
+ replyToId: message.reference?.messageId,
125
+ attachments: message.attachments.map((a) => ({
126
+ type: a.contentType?.startsWith('image/')
127
+ ? 'image'
128
+ : a.contentType?.startsWith('audio/')
129
+ ? 'audio'
130
+ : a.contentType?.startsWith('video/')
131
+ ? 'video'
132
+ : 'file',
133
+ url: a.url,
134
+ mimeType: a.contentType || undefined,
135
+ filename: a.name,
136
+ size: a.size,
137
+ })),
138
+ raw: message,
139
+ };
140
+ }
141
+
142
+ async connect(): Promise<void> {
143
+ await this.client.login(this.config.token);
144
+ }
145
+
146
+ async disconnect(): Promise<void> {
147
+ this.client.destroy();
148
+ this.connected = false;
149
+ }
150
+
151
+ isConnected(): boolean {
152
+ return this.connected;
153
+ }
154
+
155
+ async send(channelId: string, message: OutboundMessage): Promise<SendResult> {
156
+ try {
157
+ const channel = await this.client.channels.fetch(channelId);
158
+
159
+ if (!channel || !channel.isTextBased() || !('send' in channel)) {
160
+ return { success: false, error: 'Channel not found or not text-based' };
161
+ }
162
+
163
+ // Chunk long messages
164
+ const chunks = this.chunkMessage(message.content);
165
+ let lastMessageId: string | undefined;
166
+
167
+ for (const chunk of chunks) {
168
+ const sent = await channel.send({
169
+ content: chunk,
170
+ reply: message.replyToId
171
+ ? { messageReference: message.replyToId }
172
+ : undefined,
173
+ });
174
+ lastMessageId = sent.id;
175
+ }
176
+
177
+ audit('message.sent', {
178
+ channelType: 'discord',
179
+ channelId,
180
+ messageId: lastMessageId,
181
+ });
182
+
183
+ return { success: true, messageId: lastMessageId };
184
+ } catch (error) {
185
+ const errorMessage = error instanceof Error ? error.message : String(error);
186
+ audit('channel.error', {
187
+ channelType: 'discord',
188
+ action: 'send',
189
+ error: errorMessage,
190
+ });
191
+ return { success: false, error: errorMessage };
192
+ }
193
+ }
194
+
195
+ private chunkMessage(content: string): string[] {
196
+ if (content.length <= MAX_MESSAGE_LENGTH) {
197
+ return [content];
198
+ }
199
+
200
+ const chunks: string[] = [];
201
+ let remaining = content;
202
+
203
+ while (remaining.length > 0) {
204
+ if (remaining.length <= MAX_MESSAGE_LENGTH) {
205
+ chunks.push(remaining);
206
+ break;
207
+ }
208
+
209
+ // Find a good break point
210
+ let breakPoint = remaining.lastIndexOf('\n', MAX_MESSAGE_LENGTH);
211
+ if (breakPoint === -1 || breakPoint < MAX_MESSAGE_LENGTH / 2) {
212
+ breakPoint = remaining.lastIndexOf(' ', MAX_MESSAGE_LENGTH);
213
+ }
214
+ if (breakPoint === -1 || breakPoint < MAX_MESSAGE_LENGTH / 2) {
215
+ breakPoint = MAX_MESSAGE_LENGTH;
216
+ }
217
+
218
+ chunks.push(remaining.slice(0, breakPoint));
219
+ remaining = remaining.slice(breakPoint).trimStart();
220
+ }
221
+
222
+ return chunks;
223
+ }
224
+
225
+ async startTyping(channelId: string): Promise<() => void> {
226
+ // Use cache (populated by messageCreate) to avoid extra API call
227
+ const channel = this.client.channels.cache.get(channelId);
228
+ if (!channel || !channel.isTextBased() || !('sendTyping' in channel)) {
229
+ return () => {};
230
+ }
231
+ // Send immediately, then repeat every 8s (Discord typing expires after ~10s)
232
+ let stopped = false;
233
+ channel.sendTyping().catch((e: Error) => {
234
+ audit('channel.error', { channelType: 'discord', action: 'typing', error: e.message });
235
+ });
236
+ const interval = setInterval(() => {
237
+ if (stopped) return;
238
+ channel.sendTyping().catch(() => {});
239
+ }, 8000);
240
+ return () => {
241
+ stopped = true;
242
+ clearInterval(interval);
243
+ };
244
+ }
245
+
246
+ onMessage(handler: (message: InboundMessage) => Promise<void>): void {
247
+ this.messageHandler = handler;
248
+ }
249
+
250
+ onError(handler: (error: Error) => void): void {
251
+ this.errorHandler = handler;
252
+ }
253
+ }