@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,376 @@
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 MatrixAdapterConfig {
10
+ homeserverUrl: string;
11
+ userId: string;
12
+ accessToken: string;
13
+ autoJoinRooms?: boolean;
14
+ allowedUsers?: string[];
15
+ allowedRooms?: string[];
16
+ }
17
+
18
+ interface MatrixSyncResponse {
19
+ next_batch: string;
20
+ rooms?: {
21
+ join?: Record<string, MatrixJoinedRoom>;
22
+ invite?: Record<string, MatrixInvitedRoom>;
23
+ };
24
+ }
25
+
26
+ interface MatrixJoinedRoom {
27
+ timeline?: {
28
+ events?: MatrixEvent[];
29
+ };
30
+ }
31
+
32
+ interface MatrixInvitedRoom {
33
+ invite_state?: {
34
+ events?: MatrixEvent[];
35
+ };
36
+ }
37
+
38
+ interface MatrixEvent {
39
+ event_id: string;
40
+ type: string;
41
+ sender: string;
42
+ origin_server_ts: number;
43
+ content: {
44
+ msgtype?: string;
45
+ body?: string;
46
+ 'm.relates_to'?: {
47
+ 'm.in_reply_to'?: {
48
+ event_id: string;
49
+ };
50
+ };
51
+ url?: string;
52
+ info?: {
53
+ mimetype?: string;
54
+ size?: number;
55
+ };
56
+ filename?: string;
57
+ membership?: string;
58
+ };
59
+ }
60
+
61
+ interface MatrixSendResponse {
62
+ event_id: string;
63
+ }
64
+
65
+ const MAX_MESSAGE_LENGTH = 65536;
66
+
67
+ export class MatrixAdapter implements ChannelAdapter {
68
+ readonly type = 'matrix' as const;
69
+ readonly name = 'Matrix';
70
+
71
+ private config: MatrixAdapterConfig;
72
+ private messageHandler?: (message: InboundMessage) => Promise<void>;
73
+ private errorHandler?: (error: Error) => void;
74
+ private connected = false;
75
+ private syncToken?: string;
76
+ private syncAbort?: AbortController;
77
+ private syncLoopRunning = false;
78
+
79
+ constructor(config: MatrixAdapterConfig) {
80
+ this.config = config;
81
+ }
82
+
83
+ private get baseUrl(): string {
84
+ const url = this.config.homeserverUrl.replace(/\/+$/, '');
85
+ return `${url}/_matrix/client/v3`;
86
+ }
87
+
88
+ private get headers(): Record<string, string> {
89
+ return {
90
+ 'Authorization': `Bearer ${this.config.accessToken}`,
91
+ 'Content-Type': 'application/json',
92
+ };
93
+ }
94
+
95
+ private async matrixFetch<T>(
96
+ path: string,
97
+ options: RequestInit = {},
98
+ ): Promise<T> {
99
+ const url = `${this.baseUrl}${path}`;
100
+ const response = await fetch(url, {
101
+ ...options,
102
+ headers: { ...this.headers, ...(options.headers as Record<string, string> || {}) },
103
+ });
104
+
105
+ if (!response.ok) {
106
+ const errorText = await response.text().catch(() => response.statusText);
107
+ throw new Error(`Matrix API error ${response.status}: ${errorText}`);
108
+ }
109
+
110
+ return response.json() as Promise<T>;
111
+ }
112
+
113
+ async connect(): Promise<void> {
114
+ // Verify credentials by calling whoami
115
+ await this.matrixFetch('/account/whoami');
116
+ this.connected = true;
117
+
118
+ audit('channel.connected', {
119
+ channelType: 'matrix',
120
+ userId: this.config.userId,
121
+ });
122
+
123
+ // Start sync loop
124
+ this.startSyncLoop();
125
+ }
126
+
127
+ async disconnect(): Promise<void> {
128
+ this.syncLoopRunning = false;
129
+ this.syncAbort?.abort();
130
+ this.connected = false;
131
+ audit('channel.disconnected', { channelType: 'matrix' });
132
+ }
133
+
134
+ isConnected(): boolean {
135
+ return this.connected;
136
+ }
137
+
138
+ private startSyncLoop(): void {
139
+ this.syncLoopRunning = true;
140
+ void this.syncLoop();
141
+ }
142
+
143
+ private async syncLoop(): Promise<void> {
144
+ while (this.syncLoopRunning && this.connected) {
145
+ try {
146
+ this.syncAbort = new AbortController();
147
+ const params = new URLSearchParams({
148
+ timeout: '30000',
149
+ });
150
+
151
+ if (this.syncToken) {
152
+ params.set('since', this.syncToken);
153
+ }
154
+
155
+ const response = await this.matrixFetch<MatrixSyncResponse>(
156
+ `/sync?${params.toString()}`,
157
+ { signal: this.syncAbort.signal },
158
+ );
159
+
160
+ // Handle invites (auto-join)
161
+ if (this.config.autoJoinRooms && response.rooms?.invite) {
162
+ for (const roomId of Object.keys(response.rooms.invite)) {
163
+ try {
164
+ await this.matrixFetch(`/rooms/${encodeURIComponent(roomId)}/join`, {
165
+ method: 'POST',
166
+ body: '{}',
167
+ });
168
+ } catch {
169
+ // Ignore join failures
170
+ }
171
+ }
172
+ }
173
+
174
+ // Process messages from joined rooms
175
+ if (response.rooms?.join) {
176
+ for (const [roomId, room] of Object.entries(response.rooms.join)) {
177
+ const events = room.timeline?.events || [];
178
+ for (const event of events) {
179
+ await this.handleEvent(roomId, event);
180
+ }
181
+ }
182
+ }
183
+
184
+ this.syncToken = response.next_batch;
185
+ } catch (error) {
186
+ if (error instanceof Error && error.name === 'AbortError') {
187
+ break;
188
+ }
189
+ this.errorHandler?.(error instanceof Error ? error : new Error(String(error)));
190
+ // Brief delay before retrying on error
191
+ await new Promise((resolve) => setTimeout(resolve, 5000));
192
+ }
193
+ }
194
+ }
195
+
196
+ private async handleEvent(roomId: string, event: MatrixEvent): Promise<void> {
197
+ // Only process m.room.message events
198
+ if (event.type !== 'm.room.message') return;
199
+
200
+ // Ignore own messages
201
+ if (event.sender === this.config.userId) return;
202
+
203
+ // Only process text and notice messages
204
+ const msgtype = event.content.msgtype;
205
+ if (msgtype !== 'm.text' && msgtype !== 'm.notice') return;
206
+
207
+ // Check allowed rooms
208
+ if (this.config.allowedRooms?.length && !this.config.allowedRooms.includes(roomId)) {
209
+ audit('message.filtered', { channelType: 'matrix', senderId: event.sender, roomId, reason: 'room_not_allowed' });
210
+ return;
211
+ }
212
+
213
+ // Check allowed users
214
+ if (this.config.allowedUsers?.length && !this.config.allowedUsers.includes(event.sender)) {
215
+ audit('message.filtered', { channelType: 'matrix', senderId: event.sender, roomId, reason: 'user_not_allowed' });
216
+ return;
217
+ }
218
+
219
+ const inbound = this.toInboundMessage(roomId, event);
220
+
221
+ audit('message.received', {
222
+ channelType: 'matrix',
223
+ senderId: inbound.senderId,
224
+ channelId: inbound.channelId,
225
+ });
226
+
227
+ if (this.messageHandler) {
228
+ try {
229
+ await this.messageHandler(inbound);
230
+ } catch (error) {
231
+ this.errorHandler?.(error instanceof Error ? error : new Error(String(error)));
232
+ }
233
+ }
234
+ }
235
+
236
+ private toInboundMessage(roomId: string, event: MatrixEvent): InboundMessage {
237
+ const replyTo = event.content['m.relates_to']?.['m.in_reply_to']?.event_id;
238
+
239
+ // Strip reply fallback from content if present
240
+ let content = event.content.body || '';
241
+ if (replyTo && content.startsWith('> ')) {
242
+ const lines = content.split('\n');
243
+ const nonQuoteIdx = lines.findIndex((l) => !l.startsWith('> ') && l !== '');
244
+ if (nonQuoteIdx > 0) {
245
+ content = lines.slice(nonQuoteIdx).join('\n').trim();
246
+ }
247
+ }
248
+
249
+ return {
250
+ id: event.event_id,
251
+ channelType: 'matrix',
252
+ channelId: roomId,
253
+ senderId: event.sender,
254
+ senderName: event.sender.split(':')[0].replace('@', ''),
255
+ content,
256
+ timestamp: event.origin_server_ts,
257
+ replyToId: replyTo,
258
+ raw: event,
259
+ };
260
+ }
261
+
262
+ async send(channelId: string, message: OutboundMessage): Promise<SendResult> {
263
+ try {
264
+ const chunks = this.chunkMessage(message.content);
265
+ let lastEventId: string | undefined;
266
+
267
+ for (const chunk of chunks) {
268
+ const txnId = `m${Date.now()}.${Math.random().toString(36).slice(2)}`;
269
+ const body: Record<string, unknown> = {
270
+ msgtype: 'm.text',
271
+ body: chunk,
272
+ };
273
+
274
+ // Add reply relation
275
+ if (message.replyToId) {
276
+ body['m.relates_to'] = {
277
+ 'm.in_reply_to': {
278
+ event_id: message.replyToId,
279
+ },
280
+ };
281
+ }
282
+
283
+ const result = await this.matrixFetch<MatrixSendResponse>(
284
+ `/rooms/${encodeURIComponent(channelId)}/send/m.room.message/${encodeURIComponent(txnId)}`,
285
+ { method: 'PUT', body: JSON.stringify(body) },
286
+ );
287
+
288
+ lastEventId = result.event_id;
289
+ }
290
+
291
+ audit('message.sent', {
292
+ channelType: 'matrix',
293
+ channelId,
294
+ messageId: lastEventId,
295
+ });
296
+
297
+ return { success: true, messageId: lastEventId };
298
+ } catch (error) {
299
+ const errorMessage = error instanceof Error ? error.message : String(error);
300
+ audit('channel.error', {
301
+ channelType: 'matrix',
302
+ action: 'send',
303
+ error: errorMessage,
304
+ });
305
+ return { success: false, error: errorMessage };
306
+ }
307
+ }
308
+
309
+ private chunkMessage(content: string): string[] {
310
+ if (content.length <= MAX_MESSAGE_LENGTH) {
311
+ return [content];
312
+ }
313
+
314
+ const chunks: string[] = [];
315
+ let remaining = content;
316
+
317
+ while (remaining.length > 0) {
318
+ if (remaining.length <= MAX_MESSAGE_LENGTH) {
319
+ chunks.push(remaining);
320
+ break;
321
+ }
322
+
323
+ let breakPoint = remaining.lastIndexOf('\n', MAX_MESSAGE_LENGTH);
324
+ if (breakPoint === -1 || breakPoint < MAX_MESSAGE_LENGTH / 2) {
325
+ breakPoint = remaining.lastIndexOf(' ', MAX_MESSAGE_LENGTH);
326
+ }
327
+ if (breakPoint === -1 || breakPoint < MAX_MESSAGE_LENGTH / 2) {
328
+ breakPoint = MAX_MESSAGE_LENGTH;
329
+ }
330
+
331
+ chunks.push(remaining.slice(0, breakPoint));
332
+ remaining = remaining.slice(breakPoint).trimStart();
333
+ }
334
+
335
+ return chunks;
336
+ }
337
+
338
+ async startTyping(channelId: string): Promise<() => void> {
339
+ const userId = this.config.userId;
340
+ const typingPath = `/rooms/${encodeURIComponent(channelId)}/typing/${encodeURIComponent(userId)}`;
341
+
342
+ // Send typing with 30s timeout, repeat every 25s
343
+ let stopped = false;
344
+ const sendTyping = () =>
345
+ this.matrixFetch(typingPath, {
346
+ method: 'PUT',
347
+ body: JSON.stringify({ typing: true, timeout: 30000 }),
348
+ }).catch((e: Error) => {
349
+ audit('channel.error', { channelType: 'matrix', action: 'typing', error: e.message });
350
+ });
351
+
352
+ sendTyping();
353
+ const interval = setInterval(() => {
354
+ if (stopped) return;
355
+ sendTyping();
356
+ }, 25000);
357
+
358
+ return () => {
359
+ stopped = true;
360
+ clearInterval(interval);
361
+ // Send stop-typing signal
362
+ this.matrixFetch(typingPath, {
363
+ method: 'PUT',
364
+ body: JSON.stringify({ typing: false }),
365
+ }).catch(() => {});
366
+ };
367
+ }
368
+
369
+ onMessage(handler: (message: InboundMessage) => Promise<void>): void {
370
+ this.messageHandler = handler;
371
+ }
372
+
373
+ onError(handler: (error: Error) => void): void {
374
+ this.errorHandler = handler;
375
+ }
376
+ }
@@ -0,0 +1,313 @@
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 SignalAdapterConfig {
10
+ signalCliEndpoint: string;
11
+ phoneNumber: string;
12
+ allowedNumbers?: string[];
13
+ }
14
+
15
+ interface SignalJsonRpcResponse<T = unknown> {
16
+ jsonrpc: '2.0';
17
+ id: number;
18
+ result?: T;
19
+ error?: {
20
+ code: number;
21
+ message: string;
22
+ };
23
+ }
24
+
25
+ interface SignalMessage {
26
+ envelope: {
27
+ source: string;
28
+ sourceName?: string;
29
+ sourceNumber?: string;
30
+ timestamp: number;
31
+ dataMessage?: {
32
+ message: string;
33
+ timestamp: number;
34
+ groupInfo?: {
35
+ groupId: string;
36
+ type?: string;
37
+ };
38
+ attachments?: Array<{
39
+ contentType: string;
40
+ filename?: string;
41
+ size?: number;
42
+ id?: string;
43
+ }>;
44
+ quote?: {
45
+ id: number;
46
+ author: string;
47
+ text: string;
48
+ };
49
+ };
50
+ };
51
+ }
52
+
53
+ interface SignalSendResult {
54
+ timestamp: number;
55
+ results?: Array<{
56
+ recipientAddress: { number: string };
57
+ type: string;
58
+ }>;
59
+ }
60
+
61
+ const MAX_MESSAGE_LENGTH = 6000;
62
+
63
+ export class SignalAdapter implements ChannelAdapter {
64
+ readonly type = 'signal' as const;
65
+ readonly name = 'Signal';
66
+
67
+ private config: SignalAdapterConfig;
68
+ private messageHandler?: (message: InboundMessage) => Promise<void>;
69
+ private errorHandler?: (error: Error) => void;
70
+ private connected = false;
71
+ private pollRunning = false;
72
+ private pollAbort?: AbortController;
73
+ private rpcId = 0;
74
+
75
+ constructor(config: SignalAdapterConfig) {
76
+ this.config = config;
77
+ }
78
+
79
+ private get endpoint(): string {
80
+ return this.config.signalCliEndpoint.replace(/\/+$/, '');
81
+ }
82
+
83
+ private async rpcCall<T>(method: string, params: Record<string, unknown> = {}): Promise<T> {
84
+ this.rpcId++;
85
+ const body = {
86
+ jsonrpc: '2.0',
87
+ id: this.rpcId,
88
+ method,
89
+ params: { ...params, account: this.config.phoneNumber },
90
+ };
91
+
92
+ const response = await fetch(`${this.endpoint}/api/v1/rpc`, {
93
+ method: 'POST',
94
+ headers: { 'Content-Type': 'application/json' },
95
+ body: JSON.stringify(body),
96
+ });
97
+
98
+ if (!response.ok) {
99
+ throw new Error(`Signal CLI API error ${response.status}: ${response.statusText}`);
100
+ }
101
+
102
+ const result = await response.json() as SignalJsonRpcResponse<T>;
103
+
104
+ if (result.error) {
105
+ throw new Error(`Signal CLI RPC error: ${result.error.message}`);
106
+ }
107
+
108
+ return result.result as T;
109
+ }
110
+
111
+ async connect(): Promise<void> {
112
+ // Verify connection by listing accounts
113
+ await this.rpcCall('listAccounts');
114
+ this.connected = true;
115
+
116
+ audit('channel.connected', {
117
+ channelType: 'signal',
118
+ phoneNumber: this.config.phoneNumber,
119
+ });
120
+
121
+ this.startPolling();
122
+ }
123
+
124
+ async disconnect(): Promise<void> {
125
+ this.pollRunning = false;
126
+ this.pollAbort?.abort();
127
+ this.connected = false;
128
+ audit('channel.disconnected', { channelType: 'signal' });
129
+ }
130
+
131
+ isConnected(): boolean {
132
+ return this.connected;
133
+ }
134
+
135
+ private startPolling(): void {
136
+ this.pollRunning = true;
137
+ void this.pollLoop();
138
+ }
139
+
140
+ private async pollLoop(): Promise<void> {
141
+ while (this.pollRunning && this.connected) {
142
+ try {
143
+ this.pollAbort = new AbortController();
144
+
145
+ const messages = await this.rpcCall<SignalMessage[]>('receive');
146
+
147
+ if (messages && Array.isArray(messages)) {
148
+ for (const msg of messages) {
149
+ await this.handleMessage(msg);
150
+ }
151
+ }
152
+
153
+ // Brief delay between polls
154
+ await new Promise((resolve) => setTimeout(resolve, 1000));
155
+ } catch (error) {
156
+ if (error instanceof Error && error.name === 'AbortError') {
157
+ break;
158
+ }
159
+ this.errorHandler?.(error instanceof Error ? error : new Error(String(error)));
160
+ await new Promise((resolve) => setTimeout(resolve, 5000));
161
+ }
162
+ }
163
+ }
164
+
165
+ private async handleMessage(msg: SignalMessage): Promise<void> {
166
+ const dataMessage = msg.envelope.dataMessage;
167
+ if (!dataMessage || !dataMessage.message) return;
168
+
169
+ // Ignore own messages
170
+ if (msg.envelope.source === this.config.phoneNumber ||
171
+ msg.envelope.sourceNumber === this.config.phoneNumber) {
172
+ return;
173
+ }
174
+
175
+ // Check allowed numbers
176
+ const senderNumber = msg.envelope.sourceNumber || msg.envelope.source;
177
+ if (this.config.allowedNumbers?.length && !this.config.allowedNumbers.includes(senderNumber)) {
178
+ audit('message.filtered', { channelType: 'signal', senderId: senderNumber, reason: 'number_not_allowed' });
179
+ return;
180
+ }
181
+
182
+ const inbound = this.toInboundMessage(msg);
183
+
184
+ audit('message.received', {
185
+ channelType: 'signal',
186
+ senderId: inbound.senderId,
187
+ channelId: inbound.channelId,
188
+ });
189
+
190
+ if (this.messageHandler) {
191
+ try {
192
+ await this.messageHandler(inbound);
193
+ } catch (error) {
194
+ this.errorHandler?.(error instanceof Error ? error : new Error(String(error)));
195
+ }
196
+ }
197
+ }
198
+
199
+ private toInboundMessage(msg: SignalMessage): InboundMessage {
200
+ const dataMessage = msg.envelope.dataMessage!;
201
+ const isGroup = !!dataMessage.groupInfo;
202
+ const channelId = isGroup
203
+ ? dataMessage.groupInfo!.groupId
204
+ : msg.envelope.sourceNumber || msg.envelope.source;
205
+
206
+ return {
207
+ id: String(dataMessage.timestamp),
208
+ channelType: 'signal',
209
+ channelId,
210
+ senderId: msg.envelope.sourceNumber || msg.envelope.source,
211
+ senderName: msg.envelope.sourceName,
212
+ content: dataMessage.message,
213
+ timestamp: dataMessage.timestamp,
214
+ replyToId: dataMessage.quote ? String(dataMessage.quote.id) : undefined,
215
+ attachments: dataMessage.attachments?.map((a) => ({
216
+ type: a.contentType.startsWith('image/')
217
+ ? 'image' as const
218
+ : a.contentType.startsWith('audio/')
219
+ ? 'audio' as const
220
+ : a.contentType.startsWith('video/')
221
+ ? 'video' as const
222
+ : 'file' as const,
223
+ mimeType: a.contentType,
224
+ filename: a.filename,
225
+ size: a.size,
226
+ })),
227
+ raw: msg,
228
+ };
229
+ }
230
+
231
+ async send(channelId: string, message: OutboundMessage): Promise<SendResult> {
232
+ try {
233
+ const chunks = this.chunkMessage(message.content);
234
+ let lastTimestamp: number | undefined;
235
+
236
+ for (const chunk of chunks) {
237
+ // Determine if group or direct
238
+ const isGroup = !channelId.startsWith('+');
239
+
240
+ const params: Record<string, unknown> = {
241
+ message: chunk,
242
+ };
243
+
244
+ if (isGroup) {
245
+ params.groupId = channelId;
246
+ } else {
247
+ params.recipient = [channelId];
248
+ }
249
+
250
+ if (message.replyToId) {
251
+ params.quoteTimestamp = parseInt(message.replyToId, 10);
252
+ params.quoteAuthor = channelId;
253
+ }
254
+
255
+ const result = await this.rpcCall<SignalSendResult>('send', params);
256
+ lastTimestamp = result.timestamp;
257
+ }
258
+
259
+ audit('message.sent', {
260
+ channelType: 'signal',
261
+ channelId,
262
+ messageId: lastTimestamp,
263
+ });
264
+
265
+ return { success: true, messageId: String(lastTimestamp) };
266
+ } catch (error) {
267
+ const errorMessage = error instanceof Error ? error.message : String(error);
268
+ audit('channel.error', {
269
+ channelType: 'signal',
270
+ action: 'send',
271
+ error: errorMessage,
272
+ });
273
+ return { success: false, error: errorMessage };
274
+ }
275
+ }
276
+
277
+ private chunkMessage(content: string): string[] {
278
+ if (content.length <= MAX_MESSAGE_LENGTH) {
279
+ return [content];
280
+ }
281
+
282
+ const chunks: string[] = [];
283
+ let remaining = content;
284
+
285
+ while (remaining.length > 0) {
286
+ if (remaining.length <= MAX_MESSAGE_LENGTH) {
287
+ chunks.push(remaining);
288
+ break;
289
+ }
290
+
291
+ let breakPoint = remaining.lastIndexOf('\n', MAX_MESSAGE_LENGTH);
292
+ if (breakPoint === -1 || breakPoint < MAX_MESSAGE_LENGTH / 2) {
293
+ breakPoint = remaining.lastIndexOf(' ', MAX_MESSAGE_LENGTH);
294
+ }
295
+ if (breakPoint === -1 || breakPoint < MAX_MESSAGE_LENGTH / 2) {
296
+ breakPoint = MAX_MESSAGE_LENGTH;
297
+ }
298
+
299
+ chunks.push(remaining.slice(0, breakPoint));
300
+ remaining = remaining.slice(breakPoint).trimStart();
301
+ }
302
+
303
+ return chunks;
304
+ }
305
+
306
+ onMessage(handler: (message: InboundMessage) => Promise<void>): void {
307
+ this.messageHandler = handler;
308
+ }
309
+
310
+ onError(handler: (error: Error) => void): void {
311
+ this.errorHandler = handler;
312
+ }
313
+ }