@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.
@@ -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
+ }