@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.
- package/LICENSE +191 -0
- package/dist/adapters/bluebubbles.d.ts +63 -0
- package/dist/adapters/bluebubbles.d.ts.map +1 -0
- package/dist/adapters/bluebubbles.js +197 -0
- package/dist/adapters/bluebubbles.js.map +1 -0
- package/dist/adapters/discord.d.ts +27 -0
- package/dist/adapters/discord.d.ts.map +1 -0
- package/dist/adapters/discord.js +202 -0
- package/dist/adapters/discord.js.map +1 -0
- package/dist/adapters/email.d.ts +39 -0
- package/dist/adapters/email.d.ts.map +1 -0
- package/dist/adapters/email.js +359 -0
- package/dist/adapters/email.js.map +1 -0
- package/dist/adapters/googlechat.d.ts +77 -0
- package/dist/adapters/googlechat.d.ts.map +1 -0
- package/dist/adapters/googlechat.js +232 -0
- package/dist/adapters/googlechat.js.map +1 -0
- package/dist/adapters/matrix.d.ts +37 -0
- package/dist/adapters/matrix.d.ts.map +1 -0
- package/dist/adapters/matrix.js +262 -0
- package/dist/adapters/matrix.js.map +1 -0
- package/dist/adapters/signal.d.ts +32 -0
- package/dist/adapters/signal.d.ts.map +1 -0
- package/dist/adapters/signal.js +216 -0
- package/dist/adapters/signal.js.map +1 -0
- package/dist/adapters/slack.d.ts +29 -0
- package/dist/adapters/slack.d.ts.map +1 -0
- package/dist/adapters/slack.js +202 -0
- package/dist/adapters/slack.js.map +1 -0
- package/dist/adapters/teams.d.ts +66 -0
- package/dist/adapters/teams.d.ts.map +1 -0
- package/dist/adapters/teams.js +227 -0
- package/dist/adapters/teams.js.map +1 -0
- package/dist/adapters/telegram.d.ts +28 -0
- package/dist/adapters/telegram.d.ts.map +1 -0
- package/dist/adapters/telegram.js +170 -0
- package/dist/adapters/telegram.js.map +1 -0
- package/dist/adapters/twilio.d.ts +63 -0
- package/dist/adapters/twilio.d.ts.map +1 -0
- package/dist/adapters/twilio.js +193 -0
- package/dist/adapters/twilio.js.map +1 -0
- package/dist/adapters/whatsapp.d.ts +99 -0
- package/dist/adapters/whatsapp.d.ts.map +1 -0
- package/dist/adapters/whatsapp.js +218 -0
- package/dist/adapters/whatsapp.js.map +1 -0
- package/dist/adapters/zalo.d.ts +64 -0
- package/dist/adapters/zalo.d.ts.map +1 -0
- package/dist/adapters/zalo.js +216 -0
- package/dist/adapters/zalo.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/dist/manager.d.ts +35 -0
- package/dist/manager.d.ts.map +1 -0
- package/dist/manager.js +127 -0
- package/dist/manager.js.map +1 -0
- package/dist/types.d.ts +71 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +32 -0
- package/src/adapters/bluebubbles.ts +294 -0
- package/src/adapters/discord.ts +253 -0
- package/src/adapters/email.ts +457 -0
- package/src/adapters/googlechat.ts +364 -0
- package/src/adapters/matrix.ts +376 -0
- package/src/adapters/signal.ts +313 -0
- package/src/adapters/slack.ts +252 -0
- package/src/adapters/teams.ts +320 -0
- package/src/adapters/telegram.ts +208 -0
- package/src/adapters/twilio.ts +256 -0
- package/src/adapters/whatsapp.ts +342 -0
- package/src/adapters/zalo.ts +319 -0
- package/src/index.ts +78 -0
- package/src/manager.ts +180 -0
- package/src/types.ts +84 -0
- package/tests/bluebubbles.test.ts +438 -0
- package/tests/email.test.ts +136 -0
- package/tests/googlechat.test.ts +439 -0
- package/tests/matrix.test.ts +564 -0
- package/tests/signal.test.ts +404 -0
- package/tests/slack.test.ts +343 -0
- package/tests/teams.test.ts +429 -0
- package/tests/twilio.test.ts +269 -0
- package/tests/whatsapp.test.ts +530 -0
- package/tests/zalo.test.ts +499 -0
- package/tsconfig.json +8 -0
- 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
|
+
}
|