@plosson/agentio 0.4.2 → 0.4.3
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/README.md +4 -4
- package/package.json +3 -1
- package/src/auth/oauth.ts +14 -2
- package/src/commands/gateway.ts +259 -0
- package/src/commands/gcal.ts +383 -0
- package/src/commands/gtasks.ts +326 -0
- package/src/commands/status.ts +85 -0
- package/src/commands/telegram.ts +209 -1
- package/src/commands/update.ts +2 -2
- package/src/commands/whatsapp.ts +853 -0
- package/src/config/config-manager.ts +1 -1
- package/src/gateway/adapters/telegram.ts +357 -0
- package/src/gateway/adapters/types.ts +147 -0
- package/src/gateway/adapters/whatsapp-auth.ts +172 -0
- package/src/gateway/adapters/whatsapp.ts +723 -0
- package/src/gateway/api.ts +791 -0
- package/src/gateway/client.ts +402 -0
- package/src/gateway/daemon.ts +461 -0
- package/src/gateway/store.ts +637 -0
- package/src/gateway/types.ts +325 -0
- package/src/gateway/webhook.ts +109 -0
- package/src/index.ts +32 -16
- package/src/polyfills.ts +10 -0
- package/src/services/gcal/client.ts +380 -0
- package/src/services/gtasks/client.ts +301 -0
- package/src/types/config.ts +36 -1
- package/src/types/gcal.ts +135 -0
- package/src/types/gtasks.ts +58 -0
- package/src/types/qrcode-terminal.d.ts +8 -0
- package/src/types/whatsapp.ts +116 -0
- package/src/utils/output.ts +505 -0
|
@@ -7,7 +7,7 @@ import type { Config, ServiceName } from '../types/config';
|
|
|
7
7
|
const CONFIG_DIR = join(homedir(), '.config', 'agentio');
|
|
8
8
|
const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
|
|
9
9
|
|
|
10
|
-
const ALL_SERVICES: ServiceName[] = ['gdocs', 'gdrive', 'gmail', 'gchat', 'github', 'jira', 'slack', 'telegram', 'discourse', 'sql'];
|
|
10
|
+
const ALL_SERVICES: ServiceName[] = ['gdocs', 'gdrive', 'gmail', 'gchat', 'github', 'jira', 'slack', 'telegram', 'whatsapp', 'discourse', 'sql'];
|
|
11
11
|
|
|
12
12
|
const DEFAULT_CONFIG: Config = {
|
|
13
13
|
profiles: {},
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
import type { TelegramCredentials } from '../../types/telegram';
|
|
2
|
+
import type { ServiceName } from '../../types/config';
|
|
3
|
+
import { BaseAdapter, type AdapterInboundMessage, type AdapterOutboundMessage, type SendResult } from './types';
|
|
4
|
+
|
|
5
|
+
const TELEGRAM_API_BASE = 'https://api.telegram.org/bot';
|
|
6
|
+
const LONG_POLL_TIMEOUT = 30; // seconds
|
|
7
|
+
|
|
8
|
+
interface TelegramApiResponse<T> {
|
|
9
|
+
ok: boolean;
|
|
10
|
+
result?: T;
|
|
11
|
+
description?: string;
|
|
12
|
+
error_code?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface TelegramUpdate {
|
|
16
|
+
update_id: number;
|
|
17
|
+
message?: TelegramMessageObject;
|
|
18
|
+
edited_message?: TelegramMessageObject;
|
|
19
|
+
channel_post?: TelegramMessageObject;
|
|
20
|
+
edited_channel_post?: TelegramMessageObject;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface TelegramMessageObject {
|
|
24
|
+
message_id: number;
|
|
25
|
+
from?: {
|
|
26
|
+
id: number;
|
|
27
|
+
is_bot: boolean;
|
|
28
|
+
first_name: string;
|
|
29
|
+
last_name?: string;
|
|
30
|
+
username?: string;
|
|
31
|
+
};
|
|
32
|
+
sender_chat?: {
|
|
33
|
+
id: number;
|
|
34
|
+
type: string;
|
|
35
|
+
title?: string;
|
|
36
|
+
username?: string;
|
|
37
|
+
};
|
|
38
|
+
chat: {
|
|
39
|
+
id: number;
|
|
40
|
+
type: 'private' | 'group' | 'supergroup' | 'channel';
|
|
41
|
+
title?: string;
|
|
42
|
+
username?: string;
|
|
43
|
+
first_name?: string;
|
|
44
|
+
last_name?: string;
|
|
45
|
+
};
|
|
46
|
+
date: number;
|
|
47
|
+
text?: string;
|
|
48
|
+
photo?: TelegramPhotoSize[];
|
|
49
|
+
video?: TelegramVideo;
|
|
50
|
+
audio?: TelegramAudio;
|
|
51
|
+
document?: TelegramDocument;
|
|
52
|
+
voice?: TelegramVoice;
|
|
53
|
+
reply_to_message?: TelegramMessageObject;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface TelegramPhotoSize {
|
|
57
|
+
file_id: string;
|
|
58
|
+
file_unique_id: string;
|
|
59
|
+
width: number;
|
|
60
|
+
height: number;
|
|
61
|
+
file_size?: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface TelegramVideo {
|
|
65
|
+
file_id: string;
|
|
66
|
+
file_unique_id: string;
|
|
67
|
+
width: number;
|
|
68
|
+
height: number;
|
|
69
|
+
duration: number;
|
|
70
|
+
file_size?: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface TelegramAudio {
|
|
74
|
+
file_id: string;
|
|
75
|
+
file_unique_id: string;
|
|
76
|
+
duration: number;
|
|
77
|
+
file_size?: number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface TelegramDocument {
|
|
81
|
+
file_id: string;
|
|
82
|
+
file_unique_id: string;
|
|
83
|
+
file_name?: string;
|
|
84
|
+
file_size?: number;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface TelegramVoice {
|
|
88
|
+
file_id: string;
|
|
89
|
+
file_unique_id: string;
|
|
90
|
+
duration: number;
|
|
91
|
+
file_size?: number;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
interface ProfileConnection {
|
|
95
|
+
credentials: TelegramCredentials;
|
|
96
|
+
lastUpdateId: number;
|
|
97
|
+
abortController: AbortController | null;
|
|
98
|
+
pollPromise: Promise<void> | null;
|
|
99
|
+
shouldStop: boolean;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export class TelegramAdapter extends BaseAdapter {
|
|
103
|
+
readonly service: ServiceName = 'telegram';
|
|
104
|
+
|
|
105
|
+
private profiles: Map<string, ProfileConnection> = new Map();
|
|
106
|
+
|
|
107
|
+
async connect(profile: string, credentials: unknown): Promise<void> {
|
|
108
|
+
const creds = credentials as TelegramCredentials;
|
|
109
|
+
|
|
110
|
+
// Stop existing connection if any
|
|
111
|
+
await this.disconnect(profile);
|
|
112
|
+
|
|
113
|
+
const connection: ProfileConnection = {
|
|
114
|
+
credentials: creds,
|
|
115
|
+
lastUpdateId: 0,
|
|
116
|
+
abortController: null,
|
|
117
|
+
pollPromise: null,
|
|
118
|
+
shouldStop: false,
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
this.profiles.set(profile, connection);
|
|
122
|
+
|
|
123
|
+
// Validate credentials first
|
|
124
|
+
try {
|
|
125
|
+
const baseUrl = `${TELEGRAM_API_BASE}${creds.botToken}`;
|
|
126
|
+
const response = await fetch(`${baseUrl}/getMe`);
|
|
127
|
+
const data = (await response.json()) as TelegramApiResponse<unknown>;
|
|
128
|
+
|
|
129
|
+
if (!data.ok) {
|
|
130
|
+
throw new Error(data.description || 'Invalid bot token');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
this.setConnected(profile, true);
|
|
134
|
+
console.log(`[telegram] Connected profile: ${profile}`);
|
|
135
|
+
|
|
136
|
+
// Start long-polling
|
|
137
|
+
this.startPolling(profile);
|
|
138
|
+
} catch (error) {
|
|
139
|
+
const message = error instanceof Error ? error.message : 'Connection failed';
|
|
140
|
+
this.setConnected(profile, false, message);
|
|
141
|
+
this.profiles.delete(profile);
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async disconnect(profile: string): Promise<void> {
|
|
147
|
+
const connection = this.profiles.get(profile);
|
|
148
|
+
if (!connection) return;
|
|
149
|
+
|
|
150
|
+
connection.shouldStop = true;
|
|
151
|
+
|
|
152
|
+
// Abort any pending request
|
|
153
|
+
if (connection.abortController) {
|
|
154
|
+
connection.abortController.abort();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Wait for poll to finish
|
|
158
|
+
if (connection.pollPromise) {
|
|
159
|
+
try {
|
|
160
|
+
await connection.pollPromise;
|
|
161
|
+
} catch {
|
|
162
|
+
// Ignore abort errors
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
this.profiles.delete(profile);
|
|
167
|
+
this.connections.delete(profile);
|
|
168
|
+
console.log(`[telegram] Disconnected profile: ${profile}`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async disconnectAll(): Promise<void> {
|
|
172
|
+
const profiles = Array.from(this.profiles.keys());
|
|
173
|
+
await Promise.all(profiles.map((p) => this.disconnect(p)));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async send(profile: string, message: AdapterOutboundMessage): Promise<SendResult> {
|
|
177
|
+
const connection = this.profiles.get(profile);
|
|
178
|
+
if (!connection) {
|
|
179
|
+
return { success: false, error: 'Profile not connected' };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const baseUrl = `${TELEGRAM_API_BASE}${connection.credentials.botToken}`;
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
// Handle media if present
|
|
186
|
+
if (message.mediaPath) {
|
|
187
|
+
// For now, only support sending text
|
|
188
|
+
// Media sending would require multipart form upload
|
|
189
|
+
return { success: false, error: 'Media sending not yet implemented' };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const params: Record<string, unknown> = {
|
|
193
|
+
chat_id: message.conversationId,
|
|
194
|
+
text: message.content || '',
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
// Add reply reference if present
|
|
198
|
+
if (message.replyToPlatformId) {
|
|
199
|
+
params.reply_to_message_id = parseInt(message.replyToPlatformId, 10);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Add parse mode from metadata
|
|
203
|
+
if (message.metadata?.parse_mode) {
|
|
204
|
+
params.parse_mode = message.metadata.parse_mode;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const response = await fetch(`${baseUrl}/sendMessage`, {
|
|
208
|
+
method: 'POST',
|
|
209
|
+
headers: { 'Content-Type': 'application/json' },
|
|
210
|
+
body: JSON.stringify(params),
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
const data = (await response.json()) as TelegramApiResponse<TelegramMessageObject>;
|
|
214
|
+
|
|
215
|
+
if (!data.ok) {
|
|
216
|
+
return { success: false, error: data.description || 'Send failed' };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
success: true,
|
|
221
|
+
platformId: data.result!.message_id.toString(),
|
|
222
|
+
};
|
|
223
|
+
} catch (error) {
|
|
224
|
+
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
225
|
+
return { success: false, error: errorMessage };
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private startPolling(profile: string): void {
|
|
230
|
+
const connection = this.profiles.get(profile);
|
|
231
|
+
if (!connection || connection.shouldStop) return;
|
|
232
|
+
|
|
233
|
+
connection.pollPromise = this.poll(profile);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
private async poll(profile: string): Promise<void> {
|
|
237
|
+
const connection = this.profiles.get(profile);
|
|
238
|
+
if (!connection) return;
|
|
239
|
+
|
|
240
|
+
while (!connection.shouldStop) {
|
|
241
|
+
try {
|
|
242
|
+
connection.abortController = new AbortController();
|
|
243
|
+
|
|
244
|
+
const baseUrl = `${TELEGRAM_API_BASE}${connection.credentials.botToken}`;
|
|
245
|
+
const params = new URLSearchParams({
|
|
246
|
+
timeout: LONG_POLL_TIMEOUT.toString(),
|
|
247
|
+
allowed_updates: JSON.stringify(['message', 'channel_post']),
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
if (connection.lastUpdateId > 0) {
|
|
251
|
+
params.set('offset', (connection.lastUpdateId + 1).toString());
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const response = await fetch(`${baseUrl}/getUpdates?${params}`, {
|
|
255
|
+
signal: connection.abortController.signal,
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
const data = (await response.json()) as TelegramApiResponse<TelegramUpdate[]>;
|
|
259
|
+
|
|
260
|
+
if (!data.ok) {
|
|
261
|
+
console.error(`[telegram:${profile}] Poll error: ${data.description}`);
|
|
262
|
+
this.setConnected(profile, false, data.description);
|
|
263
|
+
await this.sleep(5000); // Wait before retry
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
this.setConnected(profile, true);
|
|
268
|
+
|
|
269
|
+
const updates = data.result || [];
|
|
270
|
+
for (const update of updates) {
|
|
271
|
+
connection.lastUpdateId = Math.max(connection.lastUpdateId, update.update_id);
|
|
272
|
+
this.processUpdate(profile, update);
|
|
273
|
+
}
|
|
274
|
+
} catch (error) {
|
|
275
|
+
if (error instanceof Error && error.name === 'AbortError') {
|
|
276
|
+
break; // Normal shutdown
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
280
|
+
console.error(`[telegram:${profile}] Poll error: ${message}`);
|
|
281
|
+
this.setConnected(profile, false, message);
|
|
282
|
+
await this.sleep(5000); // Wait before retry
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
private processUpdate(profile: string, update: TelegramUpdate): void {
|
|
288
|
+
// Handle regular messages and channel posts
|
|
289
|
+
const msg = update.message || update.channel_post;
|
|
290
|
+
if (!msg) return;
|
|
291
|
+
|
|
292
|
+
// Determine sender info
|
|
293
|
+
let senderId: string;
|
|
294
|
+
let senderName: string | undefined;
|
|
295
|
+
let senderHandle: string | undefined;
|
|
296
|
+
|
|
297
|
+
if (msg.from) {
|
|
298
|
+
senderId = msg.from.id.toString();
|
|
299
|
+
senderName = [msg.from.first_name, msg.from.last_name].filter(Boolean).join(' ');
|
|
300
|
+
senderHandle = msg.from.username;
|
|
301
|
+
} else if (msg.sender_chat) {
|
|
302
|
+
senderId = msg.sender_chat.id.toString();
|
|
303
|
+
senderName = msg.sender_chat.title;
|
|
304
|
+
senderHandle = msg.sender_chat.username;
|
|
305
|
+
} else {
|
|
306
|
+
senderId = msg.chat.id.toString();
|
|
307
|
+
senderName = msg.chat.title || msg.chat.first_name;
|
|
308
|
+
senderHandle = msg.chat.username;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Determine media type
|
|
312
|
+
let mediaType: AdapterInboundMessage['mediaType'];
|
|
313
|
+
let mediaUrl: string | undefined;
|
|
314
|
+
|
|
315
|
+
if (msg.photo && msg.photo.length > 0) {
|
|
316
|
+
mediaType = 'image';
|
|
317
|
+
// Get largest photo
|
|
318
|
+
const largestPhoto = msg.photo.reduce((a, b) =>
|
|
319
|
+
(a.file_size || 0) > (b.file_size || 0) ? a : b
|
|
320
|
+
);
|
|
321
|
+
mediaUrl = largestPhoto.file_id; // File ID, needs getFile call to download
|
|
322
|
+
} else if (msg.video) {
|
|
323
|
+
mediaType = 'video';
|
|
324
|
+
mediaUrl = msg.video.file_id;
|
|
325
|
+
} else if (msg.audio || msg.voice) {
|
|
326
|
+
mediaType = 'audio';
|
|
327
|
+
mediaUrl = (msg.audio || msg.voice)?.file_id;
|
|
328
|
+
} else if (msg.document) {
|
|
329
|
+
mediaType = 'document';
|
|
330
|
+
mediaUrl = msg.document.file_id;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const inboundMessage: AdapterInboundMessage = {
|
|
334
|
+
conversationId: msg.chat.id.toString(),
|
|
335
|
+
platformId: msg.message_id.toString(),
|
|
336
|
+
senderId,
|
|
337
|
+
senderName,
|
|
338
|
+
senderHandle,
|
|
339
|
+
content: msg.text,
|
|
340
|
+
mediaType,
|
|
341
|
+
mediaUrl,
|
|
342
|
+
receivedAt: msg.date * 1000, // Convert to milliseconds
|
|
343
|
+
replyToId: msg.reply_to_message?.message_id.toString(),
|
|
344
|
+
metadata: {
|
|
345
|
+
chatType: msg.chat.type,
|
|
346
|
+
chatTitle: msg.chat.title,
|
|
347
|
+
updateId: update.update_id,
|
|
348
|
+
},
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
this.emitMessage(profile, inboundMessage);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
private sleep(ms: number): Promise<void> {
|
|
355
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
356
|
+
}
|
|
357
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import type { ServiceName } from '../../types/config';
|
|
2
|
+
import type { InboundMessage, OutboundMessage, MediaType } from '../types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Result from sending a message through an adapter
|
|
6
|
+
*/
|
|
7
|
+
export interface SendResult {
|
|
8
|
+
success: boolean;
|
|
9
|
+
platformId?: string;
|
|
10
|
+
error?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Inbound message data from an adapter (before DB insert)
|
|
15
|
+
*/
|
|
16
|
+
export interface AdapterInboundMessage {
|
|
17
|
+
conversationId: string;
|
|
18
|
+
platformId: string;
|
|
19
|
+
senderId: string;
|
|
20
|
+
senderName?: string;
|
|
21
|
+
senderHandle?: string;
|
|
22
|
+
content?: string;
|
|
23
|
+
mediaType?: MediaType;
|
|
24
|
+
mediaUrl?: string; // URL for downloading media
|
|
25
|
+
receivedAt: number;
|
|
26
|
+
replyToId?: string;
|
|
27
|
+
metadata?: Record<string, unknown>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Outbound message data for an adapter
|
|
32
|
+
*/
|
|
33
|
+
export interface AdapterOutboundMessage {
|
|
34
|
+
conversationId: string;
|
|
35
|
+
content?: string;
|
|
36
|
+
mediaPath?: string;
|
|
37
|
+
mediaType?: MediaType;
|
|
38
|
+
replyToPlatformId?: string;
|
|
39
|
+
metadata?: Record<string, unknown>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Connection state for a profile
|
|
44
|
+
*/
|
|
45
|
+
export interface ConnectionState {
|
|
46
|
+
connected: boolean;
|
|
47
|
+
error?: string;
|
|
48
|
+
lastError?: Date;
|
|
49
|
+
reconnectAttempts?: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Service adapter interface
|
|
54
|
+
* Each service implements this to handle inbound/outbound messages
|
|
55
|
+
*/
|
|
56
|
+
export interface ServiceAdapter {
|
|
57
|
+
/**
|
|
58
|
+
* The service this adapter handles
|
|
59
|
+
*/
|
|
60
|
+
readonly service: ServiceName;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Connect to the service for a specific profile
|
|
64
|
+
*/
|
|
65
|
+
connect(profile: string, credentials: unknown): Promise<void>;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Disconnect a specific profile
|
|
69
|
+
*/
|
|
70
|
+
disconnect(profile: string): Promise<void>;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Disconnect all profiles
|
|
74
|
+
*/
|
|
75
|
+
disconnectAll(): Promise<void>;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Check if a profile is connected
|
|
79
|
+
*/
|
|
80
|
+
isConnected(profile: string): boolean;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Get connection state for a profile
|
|
84
|
+
*/
|
|
85
|
+
getConnectionState(profile: string): ConnectionState;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Get all connected profiles
|
|
89
|
+
*/
|
|
90
|
+
getConnectedProfiles(): string[];
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Send a message through the adapter
|
|
94
|
+
*/
|
|
95
|
+
send(profile: string, message: AdapterOutboundMessage): Promise<SendResult>;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Callback for when a message is received
|
|
99
|
+
* Set by the gateway to handle inbound messages
|
|
100
|
+
*/
|
|
101
|
+
onMessage: ((profile: string, message: AdapterInboundMessage) => void) | null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Base class for service adapters with common functionality
|
|
106
|
+
*/
|
|
107
|
+
export abstract class BaseAdapter implements ServiceAdapter {
|
|
108
|
+
abstract readonly service: ServiceName;
|
|
109
|
+
|
|
110
|
+
protected connections: Map<string, ConnectionState> = new Map();
|
|
111
|
+
public onMessage: ((profile: string, message: AdapterInboundMessage) => void) | null = null;
|
|
112
|
+
|
|
113
|
+
isConnected(profile: string): boolean {
|
|
114
|
+
return this.connections.get(profile)?.connected ?? false;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
getConnectionState(profile: string): ConnectionState {
|
|
118
|
+
return this.connections.get(profile) ?? { connected: false };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
getConnectedProfiles(): string[] {
|
|
122
|
+
return Array.from(this.connections.entries())
|
|
123
|
+
.filter(([_, state]) => state.connected)
|
|
124
|
+
.map(([profile]) => profile);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
protected setConnected(profile: string, connected: boolean, error?: string): void {
|
|
128
|
+
const current = this.connections.get(profile) ?? { connected: false };
|
|
129
|
+
this.connections.set(profile, {
|
|
130
|
+
...current,
|
|
131
|
+
connected,
|
|
132
|
+
error: connected ? undefined : error,
|
|
133
|
+
lastError: error ? new Date() : current.lastError,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
protected emitMessage(profile: string, message: AdapterInboundMessage): void {
|
|
138
|
+
if (this.onMessage) {
|
|
139
|
+
this.onMessage(profile, message);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
abstract connect(profile: string, credentials: unknown): Promise<void>;
|
|
144
|
+
abstract disconnect(profile: string): Promise<void>;
|
|
145
|
+
abstract disconnectAll(): Promise<void>;
|
|
146
|
+
abstract send(profile: string, message: AdapterOutboundMessage): Promise<SendResult>;
|
|
147
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQLite-based auth state store for Baileys
|
|
3
|
+
* Implements the AuthenticationState interface required by Baileys
|
|
4
|
+
*/
|
|
5
|
+
import type { AuthenticationCreds, AuthenticationState, SignalDataTypeMap } from '@whiskeysockets/baileys';
|
|
6
|
+
import { proto } from '@whiskeysockets/baileys';
|
|
7
|
+
import { initAuthCreds, BufferJSON } from '@whiskeysockets/baileys';
|
|
8
|
+
import { getDatabase } from '../store';
|
|
9
|
+
|
|
10
|
+
interface AuthCredsRow {
|
|
11
|
+
profile: string;
|
|
12
|
+
data: string;
|
|
13
|
+
updated_at: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface AuthKeyRow {
|
|
17
|
+
profile: string;
|
|
18
|
+
type: string;
|
|
19
|
+
key_id: string;
|
|
20
|
+
data: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Create a SQLite-based auth state for Baileys
|
|
25
|
+
*/
|
|
26
|
+
export async function useSQLiteAuthState(profile: string): Promise<{
|
|
27
|
+
state: AuthenticationState;
|
|
28
|
+
saveCreds: () => Promise<void>;
|
|
29
|
+
clearState: () => Promise<void>;
|
|
30
|
+
}> {
|
|
31
|
+
const db = getDatabase();
|
|
32
|
+
|
|
33
|
+
// Load or initialize credentials
|
|
34
|
+
const loadCreds = (): AuthenticationCreds => {
|
|
35
|
+
const row = db.query<AuthCredsRow, [string]>(
|
|
36
|
+
'SELECT data FROM whatsapp_auth_creds WHERE profile = ?'
|
|
37
|
+
).get(profile);
|
|
38
|
+
|
|
39
|
+
if (row) {
|
|
40
|
+
return JSON.parse(row.data, BufferJSON.reviver);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Initialize new credentials
|
|
44
|
+
return initAuthCreds();
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// Save credentials
|
|
48
|
+
const saveCreds = async (): Promise<void> => {
|
|
49
|
+
const data = JSON.stringify(creds, BufferJSON.replacer);
|
|
50
|
+
db.run(
|
|
51
|
+
`INSERT OR REPLACE INTO whatsapp_auth_creds (profile, data, updated_at)
|
|
52
|
+
VALUES (?, ?, ?)`,
|
|
53
|
+
[profile, data, Date.now()]
|
|
54
|
+
);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// Clear all auth state for this profile
|
|
58
|
+
const clearState = async (): Promise<void> => {
|
|
59
|
+
db.run('DELETE FROM whatsapp_auth_creds WHERE profile = ?', [profile]);
|
|
60
|
+
db.run('DELETE FROM whatsapp_auth_keys WHERE profile = ?', [profile]);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// Read keys from database
|
|
64
|
+
const readData = <T>(type: string, ids: string[]): { [id: string]: T } => {
|
|
65
|
+
const result: { [id: string]: T } = {};
|
|
66
|
+
|
|
67
|
+
if (ids.length === 0) return result;
|
|
68
|
+
|
|
69
|
+
// Build query with placeholders
|
|
70
|
+
const placeholders = ids.map(() => '?').join(', ');
|
|
71
|
+
const rows = db.query<AuthKeyRow, string[]>(
|
|
72
|
+
`SELECT key_id, data FROM whatsapp_auth_keys
|
|
73
|
+
WHERE profile = ? AND type = ? AND key_id IN (${placeholders})`
|
|
74
|
+
).all(profile, type, ...ids);
|
|
75
|
+
|
|
76
|
+
for (const row of rows) {
|
|
77
|
+
try {
|
|
78
|
+
result[row.key_id] = JSON.parse(row.data, BufferJSON.reviver);
|
|
79
|
+
} catch {
|
|
80
|
+
// Skip invalid data
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return result;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// Write keys to database
|
|
88
|
+
const writeData = <T>(type: string, data: { [id: string]: T }): void => {
|
|
89
|
+
const stmt = db.prepare(
|
|
90
|
+
`INSERT OR REPLACE INTO whatsapp_auth_keys (profile, type, key_id, data)
|
|
91
|
+
VALUES (?, ?, ?, ?)`
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
for (const [id, value] of Object.entries(data)) {
|
|
95
|
+
if (value !== null && value !== undefined) {
|
|
96
|
+
stmt.run(profile, type, id, JSON.stringify(value, BufferJSON.replacer));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// Delete keys from database
|
|
102
|
+
const removeData = (type: string, ids: string[]): void => {
|
|
103
|
+
if (ids.length === 0) return;
|
|
104
|
+
|
|
105
|
+
const placeholders = ids.map(() => '?').join(', ');
|
|
106
|
+
db.run(
|
|
107
|
+
`DELETE FROM whatsapp_auth_keys
|
|
108
|
+
WHERE profile = ? AND type = ? AND key_id IN (${placeholders})`,
|
|
109
|
+
[profile, type, ...ids]
|
|
110
|
+
);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const creds = loadCreds();
|
|
114
|
+
|
|
115
|
+
const state: AuthenticationState = {
|
|
116
|
+
creds,
|
|
117
|
+
keys: {
|
|
118
|
+
get: <T extends keyof SignalDataTypeMap>(type: T, ids: string[]): { [id: string]: SignalDataTypeMap[T] } => {
|
|
119
|
+
return readData<SignalDataTypeMap[T]>(type, ids);
|
|
120
|
+
},
|
|
121
|
+
set: (data: { [T in keyof SignalDataTypeMap]?: { [id: string]: SignalDataTypeMap[T] | null } }): void => {
|
|
122
|
+
for (const [type, typeData] of Object.entries(data)) {
|
|
123
|
+
if (!typeData) continue;
|
|
124
|
+
|
|
125
|
+
const toWrite: { [id: string]: unknown } = {};
|
|
126
|
+
const toDelete: string[] = [];
|
|
127
|
+
|
|
128
|
+
for (const [id, value] of Object.entries(typeData)) {
|
|
129
|
+
if (value === null || value === undefined) {
|
|
130
|
+
toDelete.push(id);
|
|
131
|
+
} else {
|
|
132
|
+
toWrite[id] = value;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (Object.keys(toWrite).length > 0) {
|
|
137
|
+
writeData(type, toWrite);
|
|
138
|
+
}
|
|
139
|
+
if (toDelete.length > 0) {
|
|
140
|
+
removeData(type, toDelete);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
state,
|
|
149
|
+
saveCreds,
|
|
150
|
+
clearState,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Check if a profile has existing auth state
|
|
156
|
+
*/
|
|
157
|
+
export function hasAuthState(profile: string): boolean {
|
|
158
|
+
const db = getDatabase();
|
|
159
|
+
const row = db.query<{ count: number }, [string]>(
|
|
160
|
+
'SELECT COUNT(*) as count FROM whatsapp_auth_creds WHERE profile = ?'
|
|
161
|
+
).get(profile);
|
|
162
|
+
return (row?.count ?? 0) > 0;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Delete auth state for a profile
|
|
167
|
+
*/
|
|
168
|
+
export function deleteAuthState(profile: string): void {
|
|
169
|
+
const db = getDatabase();
|
|
170
|
+
db.run('DELETE FROM whatsapp_auth_creds WHERE profile = ?', [profile]);
|
|
171
|
+
db.run('DELETE FROM whatsapp_auth_keys WHERE profile = ?', [profile]);
|
|
172
|
+
}
|