twinclaw 1.1.1 → 1.1.2

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 (35) hide show
  1. package/README.md +3 -3
  2. package/dist/api/handlers/agents.js +82 -0
  3. package/dist/api/handlers/debug.js +69 -0
  4. package/dist/api/handlers/devices.js +79 -0
  5. package/dist/api/handlers/jobs.js +99 -0
  6. package/dist/api/handlers/status.js +149 -0
  7. package/dist/api/router.js +272 -2
  8. package/dist/api/runtime-event-producer.js +20 -0
  9. package/dist/api/shared.js +34 -0
  10. package/dist/api/websocket-hub.js +18 -7
  11. package/dist/config/json-config.js +393 -1
  12. package/dist/core/heartbeat.js +304 -8
  13. package/dist/core/lane-executor.js +18 -0
  14. package/dist/core/onboarding.js +2 -2
  15. package/dist/core/self-improve-cli.js +212 -0
  16. package/dist/core/simplified-onboarding.js +158 -16
  17. package/dist/index.js +61 -33
  18. package/dist/interfaces/dispatcher.js +4 -2
  19. package/dist/interfaces/whatsapp_handler.js +243 -2
  20. package/dist/services/auto-configurer.js +591 -0
  21. package/dist/services/device-pairing.js +378 -0
  22. package/dist/services/hooks.js +130 -0
  23. package/dist/services/job-scheduler.js +133 -1
  24. package/dist/services/learning-system.js +226 -0
  25. package/dist/services/self-healing.js +267 -0
  26. package/dist/services/skill-acquisition/intent-parser.js +115 -0
  27. package/dist/services/skill-acquisition/research-engine.js +319 -0
  28. package/dist/services/skill-builder.js +235 -0
  29. package/dist/services/sub-agent-service.js +215 -0
  30. package/dist/services/web-service.js +70 -0
  31. package/dist/services/webhook-service.js +127 -0
  32. package/dist/skills/builtin.js +827 -0
  33. package/dist/tools/agent-improvement.js +208 -0
  34. package/dist/types/skill-acquisition.js +4 -0
  35. package/package.json +3 -1
@@ -1,15 +1,45 @@
1
1
  import WAWebJS from 'whatsapp-web.js';
2
2
  import qrcode from 'qrcode-terminal';
3
+ import axios from 'axios';
3
4
  import os from 'node:os';
4
5
  import path from 'node:path';
5
6
  import fs from 'node:fs/promises';
6
7
  import { randomUUID } from 'node:crypto';
8
+ import { logThought } from '../utils/logger.js';
7
9
  const { Client, LocalAuth, MessageMedia } = WAWebJS;
8
10
  const RATE_LIMIT_MS = 1500;
11
+ /**
12
+ * Create MessageMedia from URL
13
+ */
14
+ async function MessageMediaFromUrl(url, caption, mimeType, filename) {
15
+ try {
16
+ const response = await axios.get(url, { responseType: 'arraybuffer' });
17
+ const buffer = Buffer.from(response.data);
18
+ const mime = mimeType || response.headers['content-type'] || 'application/octet-stream';
19
+ const name = filename || url.split('/').pop() || 'file';
20
+ return new MessageMedia(mime, buffer.toString('base64'), name);
21
+ }
22
+ catch (err) {
23
+ console.error('[WhatsAppHandler] Failed to fetch media from URL:', err);
24
+ throw err;
25
+ }
26
+ }
9
27
  export class WhatsAppHandler {
10
28
  #client;
11
29
  #lastMessageAt = 0;
12
- constructor() {
30
+ // Session recovery
31
+ #connectionState = 'disconnected';
32
+ #reconnectAttempts = 0;
33
+ #maxReconnectAttempts = 5;
34
+ #reconnectDelayMs = 1000;
35
+ #maxReconnectDelayMs = 60000;
36
+ // Message tracking for status updates
37
+ #trackedMessages = new Map();
38
+ // Event callbacks
39
+ onConnectionStateChange;
40
+ onMessageStatus;
41
+ onQrCode;
42
+ constructor(options = {}) {
13
43
  const disableChromiumSandbox = process.env.WHATSAPP_DISABLE_CHROMIUM_SANDBOX === 'true';
14
44
  const clientConfig = {
15
45
  authStrategy: new LocalAuth({ dataPath: './memory/whatsapp_auth' }),
@@ -20,6 +50,10 @@ export class WhatsAppHandler {
20
50
  };
21
51
  }
22
52
  this.#client = new Client(clientConfig);
53
+ // Configure reconnection options
54
+ this.#maxReconnectAttempts = options.maxReconnectAttempts ?? 5;
55
+ this.#reconnectDelayMs = options.reconnectDelayMs ?? 1000;
56
+ this.#maxReconnectDelayMs = options.maxReconnectDelayMs ?? 60000;
23
57
  this.#registerListeners();
24
58
  }
25
59
  onMessage;
@@ -31,13 +65,55 @@ export class WhatsAppHandler {
31
65
  this.#lastMessageAt = Date.now();
32
66
  }
33
67
  #registerListeners() {
68
+ // QR Code generation
34
69
  this.#client.on('qr', (qr) => {
35
70
  console.log('[WhatsAppHandler] Scan this QR code to authenticate:');
36
71
  qrcode.generate(qr, { small: true });
72
+ this.onQrCode?.(qr);
73
+ });
74
+ // Authentication success
75
+ this.#client.on('authenticated', () => {
76
+ console.log('[WhatsAppHandler] Session authenticated successfully');
77
+ this.#connectionState = 'connected';
78
+ this.#reconnectAttempts = 0;
79
+ this.onConnectionStateChange?.('connected');
37
80
  });
81
+ // Client ready
38
82
  this.#client.on('ready', () => {
39
83
  console.log('[WhatsAppHandler] Client is ready!');
84
+ this.#connectionState = 'connected';
85
+ this.#reconnectAttempts = 0;
86
+ this.onConnectionStateChange?.('connected');
87
+ });
88
+ // Connection failure
89
+ this.#client.on('failure', (error) => {
90
+ console.error('[WhatsAppHandler] Client failed:', error);
91
+ this.#connectionState = 'failed';
92
+ this.onConnectionStateChange?.('failed');
93
+ this.#handleDisconnection();
94
+ });
95
+ // Disconnected event
96
+ this.#client.on('disconnected', (reason) => {
97
+ console.log('[WhatsAppHandler] Client disconnected:', reason);
98
+ this.#connectionState = 'disconnected';
99
+ this.onConnectionStateChange?.('disconnected');
100
+ this.#handleDisconnection();
101
+ });
102
+ // Connection state changes
103
+ this.#client.on('change_state', (state) => {
104
+ console.log('[WhatsAppHandler] Connection state changed:', state);
105
+ if (state === 'close' || state === 'disconnected' || state === 'open') {
106
+ // Only trigger disconnection for non-connected states
107
+ if (state !== 'open' && this.#connectionState === 'connected') {
108
+ this.#handleDisconnection();
109
+ }
110
+ }
111
+ });
112
+ // Message acknowledgment (acks)
113
+ this.#client.on('message_ack', (message, ack) => {
114
+ this.#handleMessageAck(message, ack);
40
115
  });
116
+ // Incoming messages
41
117
  this.#client.on('message', async (msg) => {
42
118
  await this.#applyRateLimit();
43
119
  const base = {
@@ -81,10 +157,175 @@ export class WhatsAppHandler {
81
157
  });
82
158
  this.#client.initialize().catch(err => {
83
159
  console.error('[WhatsAppHandler] Failed to initialize client:', err);
160
+ this.#connectionState = 'failed';
161
+ this.onConnectionStateChange?.('failed');
84
162
  });
85
163
  }
164
+ /**
165
+ * Handle disconnection with exponential backoff reconnection
166
+ */
167
+ async #handleDisconnection() {
168
+ if (this.#reconnectAttempts >= this.#maxReconnectAttempts) {
169
+ console.error('[WhatsAppHandler] Max reconnection attempts reached');
170
+ await logThought('[WhatsAppHandler] Failed to reconnect after multiple attempts. Please scan a new QR code.');
171
+ return;
172
+ }
173
+ this.#connectionState = 'connecting';
174
+ this.onConnectionStateChange?.('connecting');
175
+ const delay = Math.min(this.#reconnectDelayMs * Math.pow(2, this.#reconnectAttempts), this.#maxReconnectDelayMs);
176
+ console.log(`[WhatsAppHandler] Attempting to reconnect in ${delay}ms (attempt ${this.#reconnectAttempts + 1}/${this.#maxReconnectAttempts})`);
177
+ await logThought(`[WhatsAppHandler] Reconnecting... attempt ${this.#reconnectAttempts + 1}`);
178
+ this.#reconnectAttempts++;
179
+ setTimeout(async () => {
180
+ try {
181
+ await this.#client.initialize();
182
+ }
183
+ catch (err) {
184
+ console.error('[WhatsAppHandler] Reconnection failed:', err);
185
+ // The failure event will trigger another attempt via the event handler
186
+ }
187
+ }, delay);
188
+ }
189
+ /**
190
+ * Handle message acknowledgment status updates
191
+ */
192
+ #handleMessageAck(message, ack) {
193
+ // Use _serialized which is the reliable message ID in whatsapp-web.js
194
+ const messageKey = message.id._serialized;
195
+ const tracked = this.#trackedMessages.get(messageKey);
196
+ let status;
197
+ // Map WhatsApp ack values to our status
198
+ // -1: Error, 0: Pending, 1: Server, 2: Device, 3: Read, 4: Played
199
+ switch (ack) {
200
+ case -1:
201
+ status = 'failed';
202
+ break;
203
+ case 0:
204
+ status = 'queued';
205
+ break;
206
+ case 1:
207
+ case 2:
208
+ status = 'sent';
209
+ break;
210
+ case 3:
211
+ status = 'delivered';
212
+ break;
213
+ case 4:
214
+ status = 'read';
215
+ break;
216
+ default:
217
+ status = 'sent';
218
+ }
219
+ if (tracked) {
220
+ tracked.ackStatus = ack;
221
+ tracked.status = status;
222
+ this.onMessageStatus?.(messageKey, message.from, status);
223
+ }
224
+ }
225
+ /** Get current connection state */
226
+ get connectionState() {
227
+ return this.#connectionState;
228
+ }
86
229
  async sendText(chatId, text) {
87
- await this.#client.sendMessage(chatId, text);
230
+ const message = await this.#client.sendMessage(chatId, text);
231
+ // Use _serialized which is the reliable message ID in whatsapp-web.js
232
+ const messageId = message.id._serialized;
233
+ // Track the message for status updates
234
+ this.#trackedMessages.set(messageId, {
235
+ id: messageId,
236
+ chatId,
237
+ status: 'sent',
238
+ sentAt: new Date(),
239
+ });
240
+ return messageId;
241
+ }
242
+ /**
243
+ * Send an image with optional caption
244
+ */
245
+ async sendImage(chatId, imagePath, caption) {
246
+ const media = await MessageMedia.fromFilePath(imagePath);
247
+ const message = await this.#client.sendMessage(chatId, media, { caption });
248
+ const messageId = message.id._serialized;
249
+ this.#trackedMessages.set(messageId, {
250
+ id: messageId,
251
+ chatId,
252
+ status: 'sent',
253
+ sentAt: new Date(),
254
+ });
255
+ return messageId;
256
+ }
257
+ /**
258
+ * Send an image from URL
259
+ */
260
+ async sendImageFromUrl(chatId, url, caption) {
261
+ const media = await MessageMediaFromUrl(url, caption);
262
+ const message = await this.#client.sendMessage(chatId, media);
263
+ const messageId = message.id._serialized;
264
+ this.#trackedMessages.set(messageId, {
265
+ id: messageId,
266
+ chatId,
267
+ status: 'sent',
268
+ sentAt: new Date(),
269
+ });
270
+ return messageId;
271
+ }
272
+ /**
273
+ * Send a video with optional caption
274
+ */
275
+ async sendVideo(chatId, videoPath, caption) {
276
+ const media = await MessageMedia.fromFilePath(videoPath);
277
+ const message = await this.#client.sendMessage(chatId, media, { caption });
278
+ const messageId = message.id._serialized;
279
+ this.#trackedMessages.set(messageId, {
280
+ id: messageId,
281
+ chatId,
282
+ status: 'sent',
283
+ sentAt: new Date(),
284
+ });
285
+ return messageId;
286
+ }
287
+ /**
288
+ * Send a document/file
289
+ */
290
+ async sendDocument(chatId, filePath, caption) {
291
+ const media = await MessageMedia.fromFilePath(filePath);
292
+ const message = await this.#client.sendMessage(chatId, media, { caption });
293
+ const messageId = message.id._serialized;
294
+ this.#trackedMessages.set(messageId, {
295
+ id: messageId,
296
+ chatId,
297
+ status: 'sent',
298
+ sentAt: new Date(),
299
+ });
300
+ return messageId;
301
+ }
302
+ /**
303
+ * Send a document from URL
304
+ */
305
+ async sendDocumentFromUrl(chatId, url, filename, caption) {
306
+ const media = await MessageMediaFromUrl(url, caption, undefined, filename);
307
+ const message = await this.#client.sendMessage(chatId, media);
308
+ const messageId = message.id._serialized;
309
+ this.#trackedMessages.set(messageId, {
310
+ id: messageId,
311
+ chatId,
312
+ status: 'sent',
313
+ sentAt: new Date(),
314
+ });
315
+ return messageId;
316
+ }
317
+ /**
318
+ * Get message status
319
+ */
320
+ getMessageStatus(messageId) {
321
+ return this.#trackedMessages.get(messageId)?.status;
322
+ }
323
+ /**
324
+ * Force reconnection
325
+ */
326
+ async reconnect() {
327
+ this.#reconnectAttempts = 0;
328
+ await this.#client.initialize();
88
329
  }
89
330
  async sendVoice(chatId, audio) {
90
331
  const media = new MessageMedia('audio/wav', audio.toString('base64'), 'response.wav');