neoagent 3.2.1-beta.2 → 3.2.1-beta.4

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 (145) hide show
  1. package/extensions/chrome-browser/background.mjs +318 -88
  2. package/extensions/chrome-browser/http.mjs +136 -0
  3. package/extensions/chrome-browser/protocol.mjs +511 -89
  4. package/flutter_app/lib/main_chat.dart +118 -739
  5. package/flutter_app/lib/main_controller.dart +29 -20
  6. package/flutter_app/lib/main_models.dart +3 -0
  7. package/flutter_app/lib/main_operations.dart +334 -321
  8. package/flutter_app/lib/main_settings.dart +4 -3
  9. package/flutter_app/lib/main_shared.dart +14 -11
  10. package/flutter_app/lib/src/desktop_companion_actions.dart +185 -31
  11. package/flutter_app/lib/src/desktop_companion_io.dart +319 -86
  12. package/flutter_app/windows/runner/flutter_window.cpp +143 -32
  13. package/lib/manager.js +106 -89
  14. package/lib/schema_migrations.js +67 -13
  15. package/package.json +20 -13
  16. package/runtime/paths.js +49 -5
  17. package/server/guest_agent.js +52 -30
  18. package/server/http/middleware.js +24 -0
  19. package/server/http/routes.js +4 -6
  20. package/server/public/.last_build_id +1 -1
  21. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  22. package/server/public/flutter_bootstrap.js +1 -1
  23. package/server/public/main.dart.js +30803 -30805
  24. package/server/routes/admin.js +1 -1
  25. package/server/routes/android.js +30 -34
  26. package/server/routes/browser.js +23 -15
  27. package/server/routes/desktop.js +18 -1
  28. package/server/routes/integrations.js +5 -1
  29. package/server/routes/memory.js +1 -0
  30. package/server/routes/settings.js +16 -5
  31. package/server/routes/social_reach.js +12 -3
  32. package/server/routes/social_video.js +4 -0
  33. package/server/services/ai/compaction.js +7 -2
  34. package/server/services/ai/history.js +44 -5
  35. package/server/services/ai/integrated_tools/http_request.js +8 -0
  36. package/server/services/ai/loop/agent_engine_core.js +347 -162
  37. package/server/services/ai/loop/callbacks.js +1 -0
  38. package/server/services/ai/loop/completion_judge.js +12 -0
  39. package/server/services/ai/loop/conversation_loop.js +348 -242
  40. package/server/services/ai/loop/messaging_delivery.js +129 -57
  41. package/server/services/ai/loop/model_call_guard.js +91 -0
  42. package/server/services/ai/loop/model_io.js +8 -41
  43. package/server/services/ai/loop/tool_dispatch.js +19 -8
  44. package/server/services/ai/loopPolicy.js +24 -19
  45. package/server/services/ai/model_discovery.js +227 -0
  46. package/server/services/ai/model_identity.js +71 -0
  47. package/server/services/ai/models.js +67 -162
  48. package/server/services/ai/providerRetry.js +17 -59
  49. package/server/services/ai/provider_selector.js +111 -0
  50. package/server/services/ai/providers/anthropic.js +2 -2
  51. package/server/services/ai/providers/claudeCode.js +15 -28
  52. package/server/services/ai/providers/githubCopilot.js +36 -16
  53. package/server/services/ai/providers/google.js +23 -5
  54. package/server/services/ai/providers/grok.js +4 -3
  55. package/server/services/ai/providers/grokOauth.js +13 -22
  56. package/server/services/ai/providers/nvidia.js +10 -5
  57. package/server/services/ai/providers/ollama.js +102 -82
  58. package/server/services/ai/providers/ollama_stream.js +142 -0
  59. package/server/services/ai/providers/openai.js +39 -5
  60. package/server/services/ai/providers/openaiCodex.js +11 -4
  61. package/server/services/ai/providers/openrouter.js +29 -7
  62. package/server/services/ai/providers/provider_error.js +36 -0
  63. package/server/services/ai/settings.js +26 -2
  64. package/server/services/ai/taskAnalysis.js +5 -1
  65. package/server/services/ai/terminal_reply.js +45 -0
  66. package/server/services/ai/toolEvidence.js +58 -29
  67. package/server/services/ai/tools.js +124 -111
  68. package/server/services/android/controller.js +770 -237
  69. package/server/services/android/process.js +140 -0
  70. package/server/services/android/sdk_download.js +143 -0
  71. package/server/services/android/uia.js +6 -5
  72. package/server/services/artifacts/store.js +24 -0
  73. package/server/services/browser/controller.js +736 -385
  74. package/server/services/browser/extension/gateway.js +40 -16
  75. package/server/services/browser/extension/protocol.js +12 -1
  76. package/server/services/browser/extension/provider.js +59 -47
  77. package/server/services/browser/extension/registry.js +155 -34
  78. package/server/services/cli/executor.js +62 -9
  79. package/server/services/desktop/gateway.js +41 -4
  80. package/server/services/desktop/protocol.js +3 -0
  81. package/server/services/desktop/provider.js +39 -42
  82. package/server/services/desktop/registry.js +137 -52
  83. package/server/services/integrations/figma/provider.js +78 -12
  84. package/server/services/integrations/github/common.js +11 -6
  85. package/server/services/integrations/github/provider.js +52 -53
  86. package/server/services/integrations/google/provider.js +55 -19
  87. package/server/services/integrations/home_assistant/network.js +17 -20
  88. package/server/services/integrations/home_assistant/provider.js +7 -5
  89. package/server/services/integrations/home_assistant/tools.js +17 -5
  90. package/server/services/integrations/http.js +51 -0
  91. package/server/services/integrations/manager.js +158 -53
  92. package/server/services/integrations/microsoft/provider.js +80 -13
  93. package/server/services/integrations/neoarchive/provider.js +55 -29
  94. package/server/services/integrations/neorecall/client.js +17 -10
  95. package/server/services/integrations/neorecall/provider.js +20 -11
  96. package/server/services/integrations/notion/provider.js +16 -13
  97. package/server/services/integrations/oauth_provider.js +115 -51
  98. package/server/services/integrations/slack/provider.js +98 -9
  99. package/server/services/integrations/spotify/provider.js +67 -71
  100. package/server/services/integrations/trello/provider.js +21 -7
  101. package/server/services/integrations/weather/provider.js +18 -12
  102. package/server/services/integrations/whatsapp/provider.js +76 -16
  103. package/server/services/manager.js +87 -1
  104. package/server/services/memory/embedding_index.js +20 -8
  105. package/server/services/memory/embeddings.js +151 -90
  106. package/server/services/memory/ingestion.js +50 -9
  107. package/server/services/memory/ingestion_documents.js +13 -3
  108. package/server/services/memory/manager.js +52 -19
  109. package/server/services/messaging/automation.js +84 -9
  110. package/server/services/messaging/http_platforms.js +33 -13
  111. package/server/services/messaging/inbound_queue.js +78 -24
  112. package/server/services/messaging/inbound_store.js +224 -0
  113. package/server/services/messaging/manager.js +326 -51
  114. package/server/services/messaging/typing_keepalive.js +5 -2
  115. package/server/services/messaging/whatsapp.js +22 -14
  116. package/server/services/network/http.js +210 -0
  117. package/server/services/network/safe_request.js +307 -0
  118. package/server/services/runtime/backends/local-vm.js +214 -66
  119. package/server/services/runtime/manager.js +17 -12
  120. package/server/services/social_reach/channels/github.js +10 -4
  121. package/server/services/social_reach/channels/reddit.js +4 -4
  122. package/server/services/social_reach/channels/rss.js +2 -2
  123. package/server/services/social_reach/channels/social_video.js +12 -7
  124. package/server/services/social_reach/channels/v2ex.js +21 -8
  125. package/server/services/social_reach/channels/x.js +2 -2
  126. package/server/services/social_reach/channels/xueqiu.js +5 -5
  127. package/server/services/social_reach/service.js +9 -6
  128. package/server/services/social_reach/utils.js +65 -14
  129. package/server/services/social_video/service.js +160 -50
  130. package/server/services/tasks/integration_runtime.js +18 -8
  131. package/server/services/tasks/runtime.js +39 -4
  132. package/server/services/voice/agentBridge.js +17 -4
  133. package/server/services/voice/bufferedLiveRelayAdapter.js +5 -0
  134. package/server/services/voice/liveSession.js +31 -0
  135. package/server/services/voice/openaiSpeech.js +33 -8
  136. package/server/services/voice/providers.js +233 -151
  137. package/server/services/voice/runtimeManager.js +118 -20
  138. package/server/services/voice/turnRunner.js +6 -0
  139. package/server/services/wearable/firmware_manifest.js +51 -13
  140. package/server/services/wearable/service.js +1 -0
  141. package/server/utils/abort.js +96 -0
  142. package/server/utils/cloud-security.js +110 -3
  143. package/server/utils/files.js +31 -0
  144. package/server/utils/image_payload.js +95 -0
  145. package/server/utils/retry.js +107 -0
@@ -1,3 +1,5 @@
1
+ 'use strict';
2
+
1
3
  const EventEmitter = require('events');
2
4
  const db = require('../../db/database');
3
5
  const fs = require('fs');
@@ -36,8 +38,30 @@ const {
36
38
  } = require('./access_policy');
37
39
  const { decryptValue, encryptValue } = require('../integrations/secrets');
38
40
  const { readMeshtasticEnabled } = require('./meshtastic_env');
41
+ const {
42
+ createLinkedAbortController,
43
+ isAbortError,
44
+ throwIfAborted,
45
+ } = require('../../utils/abort');
46
+ const { waitForAbortableResult, waitForBoundedResult } = require('../network/http');
47
+ const {
48
+ claimInboundJob,
49
+ enqueueInboundMessage,
50
+ listPendingInboundJobs,
51
+ payloadForInboundJob,
52
+ reconcileInterruptedInboundJobs,
53
+ settleInboundJob,
54
+ } = require('./inbound_store');
39
55
 
40
56
  const LEGACY_WHATSAPP_AUTH_DIR = path.join(DATA_DIR, 'whatsapp-auth');
57
+ const MESSAGING_OPERATION_TIMEOUT_MS = 60000;
58
+
59
+ function messagingShutdownError() {
60
+ const error = new Error('Messaging is shutting down and cannot accept new work.');
61
+ error.name = 'AbortError';
62
+ error.code = 'MESSAGING_SHUTTING_DOWN';
63
+ return error;
64
+ }
41
65
 
42
66
  class IrcMessagingPlatform extends IrcPlatform {
43
67
  constructor(config = {}) { super('irc', config); }
@@ -64,6 +88,12 @@ class MessagingManager extends EventEmitter {
64
88
  this.accessSuggestions = new Map();
65
89
  this.messageHandlers = [];
66
90
  this.isShuttingDown = false;
91
+ this.shutdownPromise = null;
92
+ this.lifecycleAbortController = new AbortController();
93
+ this.activeOperations = new Set();
94
+ this.activeInboundJobs = new Set();
95
+ this.activeInboundRecoveries = new Map();
96
+ this.inboundJobsReconciled = false;
67
97
  this.platformTypes = {
68
98
  whatsapp: WhatsAppPlatform,
69
99
  telnyx: TelnyxVoicePlatform,
@@ -94,9 +124,48 @@ class MessagingManager extends EventEmitter {
94
124
  }
95
125
 
96
126
  registerHandler(handler) {
127
+ if (this.isShuttingDown) return false;
97
128
  if (!this.messageHandlers.includes(handler)) {
98
129
  this.messageHandlers.push(handler);
99
130
  }
131
+ return true;
132
+ }
133
+
134
+ _assertRunning() {
135
+ if (this.isShuttingDown || this.lifecycleAbortController.signal.aborted) {
136
+ throw messagingShutdownError();
137
+ }
138
+ }
139
+
140
+ async _runOperation(options, serviceName, operation, timeoutMs = MESSAGING_OPERATION_TIMEOUT_MS) {
141
+ this._assertRunning();
142
+ const timeoutController = new AbortController();
143
+ const linked = createLinkedAbortController([
144
+ options?.signal,
145
+ this.lifecycleAbortController.signal,
146
+ timeoutController.signal,
147
+ ]);
148
+ throwIfAborted(linked.signal, `${serviceName} aborted.`);
149
+ const timer = setTimeout(() => {
150
+ const error = new Error(`${serviceName} timed out after ${timeoutMs}ms.`);
151
+ error.code = 'MESSAGING_TIMEOUT';
152
+ timeoutController.abort(error);
153
+ }, timeoutMs);
154
+
155
+ const operationPromise = waitForAbortableResult(
156
+ Promise.resolve().then(() => operation(linked.signal)),
157
+ linked.signal,
158
+ `${serviceName} aborted.`,
159
+ );
160
+ this.activeOperations.add(operationPromise);
161
+ const cleanupOperation = () => this.activeOperations.delete(operationPromise);
162
+ operationPromise.then(cleanupOperation, cleanupOperation);
163
+ try {
164
+ return await operationPromise;
165
+ } finally {
166
+ clearTimeout(timer);
167
+ linked.cleanup();
168
+ }
100
169
  }
101
170
 
102
171
  async ingestMessage(userId, platformName, msg, options = {}) {
@@ -119,53 +188,169 @@ class MessagingManager extends EventEmitter {
119
188
  senderTag: msg.senderTag,
120
189
  isGroup: msg.isGroup,
121
190
  mediaType: msg.mediaType,
191
+ localMediaPath: msg.localMediaPath || null,
122
192
  ...(msg.metadata && typeof msg.metadata === 'object' ? msg.metadata : {}),
123
193
  };
124
- // Deduplicate against platform_msg_id — webhook retries (at-least-once delivery)
125
- // would otherwise trigger a second agent run for the same user message.
126
- if (msg.messageId) {
127
- const already = db.prepare(
128
- "SELECT id FROM messages WHERE user_id = ? AND platform = ? AND platform_msg_id = ? AND role = 'user' LIMIT 1"
129
- ).get(userId, platformName, msg.messageId);
130
- if (already) {
131
- console.warn(`[Messaging] Duplicate platform_msg_id ${msg.messageId} on ${platformName} for user ${userId} — skipping handlers`);
132
- return { ...msg, agentId, platform: platformName };
133
- }
194
+ const enrichedMsg = {
195
+ agentId,
196
+ platform: platformName,
197
+ chatId: msg.chatId,
198
+ messageId: msg.messageId || null,
199
+ sender: msg.sender,
200
+ senderName: msg.senderName || null,
201
+ senderDisplayName: msg.senderDisplayName || null,
202
+ senderUsername: msg.senderUsername || null,
203
+ senderTag: msg.senderTag || null,
204
+ content: normalizedIncomingContent,
205
+ mediaType: msg.mediaType || null,
206
+ localMediaPath: msg.localMediaPath || null,
207
+ isGroup: msg.isGroup === true,
208
+ timestamp: msg.timestamp || new Date().toISOString(),
209
+ channelContext: Array.isArray(msg.channelContext) ? msg.channelContext.slice(-20) : null,
210
+ channelName: msg.channelName || null,
211
+ groupName: msg.groupName || null,
212
+ guildName: msg.guildName || null,
213
+ roomName: msg.roomName || null,
214
+ metadata: msg.metadata && typeof msg.metadata === 'object' ? msg.metadata : null,
215
+ };
216
+ const queued = enqueueInboundMessage({
217
+ userId,
218
+ agentId,
219
+ platform: platformName,
220
+ platformMessageId: msg.messageId || null,
221
+ chatId: msg.chatId,
222
+ content: normalizedIncomingContent,
223
+ metadata,
224
+ createdAt: enrichedMsg.timestamp,
225
+ payload: enrichedMsg,
226
+ });
227
+ const durableMessage = queued.payload || payloadForInboundJob(queued.job) || enrichedMsg;
228
+
229
+ if (this.isShuttingDown) {
230
+ return durableMessage;
134
231
  }
135
232
 
136
- db.prepare('INSERT INTO messages (user_id, agent_id, role, content, platform, platform_msg_id, platform_chat_id, metadata, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)')
137
- .run(
138
- userId,
139
- agentId,
140
- 'user',
141
- normalizedIncomingContent,
142
- platformName,
143
- msg.messageId,
144
- msg.chatId,
145
- JSON.stringify(metadata),
146
- msg.timestamp,
233
+ if (queued.created) {
234
+ this.io.to(`user:${userId}`).emit('messaging:message', durableMessage);
235
+ } else if (!queued.job) {
236
+ console.warn(
237
+ `[Messaging] Duplicate ${platformName} message for user ${userId} predates durable processing state; skipping replay`,
147
238
  );
148
-
149
- const enrichedMsg = { ...msg, content: normalizedIncomingContent, agentId, platform: platformName };
150
-
151
- if (this.isShuttingDown) {
152
- return enrichedMsg;
239
+ return durableMessage;
240
+ } else if (queued.job.status !== 'pending') {
241
+ console.warn(
242
+ `[Messaging] Duplicate ${platformName} message for user ${userId} has durable status ${queued.job.status}; skipping replay`,
243
+ );
244
+ return durableMessage;
153
245
  }
154
246
 
155
- this.io.to(`user:${userId}`).emit('messaging:message', enrichedMsg);
247
+ await this._processInboundJob(queued.job, durableMessage);
248
+ return durableMessage;
249
+ }
250
+
251
+ async _processInboundJob(job, payload) {
252
+ if (this.isShuttingDown || !job || this.messageHandlers.length === 0) return false;
253
+ if (!claimInboundJob(job.id)) return false;
254
+ let finishTracking;
255
+ const tracked = new Promise((resolve) => {
256
+ finishTracking = resolve;
257
+ });
258
+ this.activeInboundJobs.add(tracked);
156
259
 
157
- for (const handler of this.messageHandlers) {
158
- if (this.isShuttingDown) {
159
- break;
260
+ let status = 'completed';
261
+ let failure = null;
262
+ let runId = null;
263
+ try {
264
+ for (const handler of this.messageHandlers) {
265
+ if (this.isShuttingDown) {
266
+ status = 'pending';
267
+ break;
268
+ }
269
+ let handlerResult = await handler(job.user_id, payload);
270
+ if (handlerResult?.completion) handlerResult = await handlerResult.completion;
271
+ const outcome = handlerResult?.outcome || handlerResult || {};
272
+ runId = outcome.runId || runId;
273
+ if (outcome.cancelled === true) {
274
+ status = runId ? 'failed' : 'pending';
275
+ failure = runId
276
+ ? 'The server stopped after this inbound agent run began; it will not be replayed automatically.'
277
+ : null;
278
+ break;
279
+ }
280
+ if (outcome.error) {
281
+ status = 'failed';
282
+ failure = outcome.error;
283
+ break;
284
+ }
160
285
  }
161
- try {
162
- await handler(userId, enrichedMsg);
163
- } catch (err) {
164
- console.error('Message handler error:', err.message);
286
+ } catch (error) {
287
+ const cancelled = this.isShuttingDown
288
+ || isAbortError(error, this.lifecycleAbortController.signal);
289
+ status = cancelled && !runId ? 'pending' : 'failed';
290
+ failure = status === 'failed' ? error : null;
291
+ if (!cancelled) {
292
+ console.error('[Messaging] Inbound message handler failed:', error?.message || error);
165
293
  }
166
294
  }
167
295
 
168
- return enrichedMsg;
296
+ try {
297
+ settleInboundJob(job.id, status, failure?.message || failure || null);
298
+ return status === 'completed';
299
+ } finally {
300
+ finishTracking();
301
+ this.activeInboundJobs.delete(tracked);
302
+ }
303
+ }
304
+
305
+ _platformReadyForInboundJob(job) {
306
+ const platform = this.platforms.get(this._key(job.user_id, job.agent_id, job.platform));
307
+ if (!platform) return false;
308
+ try {
309
+ return String(platform.getStatus?.() || platform.status || '').toLowerCase() === 'connected';
310
+ } catch {
311
+ return false;
312
+ }
313
+ }
314
+
315
+ recoverPendingInbound(filters = {}) {
316
+ if (this.isShuttingDown || this.messageHandlers.length === 0) {
317
+ return Promise.resolve({ recovered: 0, skipped: 0 });
318
+ }
319
+ const key = JSON.stringify({
320
+ userId: filters.userId,
321
+ agentId: filters.agentId,
322
+ platform: filters.platform,
323
+ });
324
+ if (this.activeInboundRecoveries.has(key)) return this.activeInboundRecoveries.get(key);
325
+
326
+ const recovery = Promise.resolve().then(async () => {
327
+ if (!this.inboundJobsReconciled) {
328
+ reconcileInterruptedInboundJobs();
329
+ this.inboundJobsReconciled = true;
330
+ }
331
+ let recovered = 0;
332
+ let skipped = 0;
333
+ for (const job of listPendingInboundJobs(filters)) {
334
+ if (this.isShuttingDown) break;
335
+ if (!this._platformReadyForInboundJob(job)) {
336
+ skipped += 1;
337
+ continue;
338
+ }
339
+ const payload = payloadForInboundJob(job);
340
+ if (!payload) {
341
+ settleInboundJob(job.id, 'failed', 'Stored inbound message payload is invalid.');
342
+ continue;
343
+ }
344
+ if (await this._processInboundJob(job, payload)) recovered += 1;
345
+ }
346
+ return { recovered, skipped };
347
+ }).finally(() => {
348
+ if (this.activeInboundRecoveries.get(key) === recovery) {
349
+ this.activeInboundRecoveries.delete(key);
350
+ }
351
+ });
352
+ this.activeInboundRecoveries.set(key, recovery);
353
+ return recovery;
169
354
  }
170
355
 
171
356
  _agentId(userId, options = {}) {
@@ -315,6 +500,8 @@ class MessagingManager extends EventEmitter {
315
500
  }
316
501
 
317
502
  async connectPlatform(userId, platformName, config = {}, options = {}) {
503
+ this._assertRunning();
504
+ throwIfAborted(options.signal, 'Messaging platform connection aborted.');
318
505
  const agentId = this._agentId(userId, options);
319
506
  config = { ...(config || {}) };
320
507
  config.userId = userId;
@@ -410,6 +597,16 @@ class MessagingManager extends EventEmitter {
410
597
  this.io.to(`user:${userId}`).emit('messaging:connected', { agentId, platform: platformName });
411
598
  db.prepare('UPDATE platform_connections SET status = ?, last_connected = datetime(\'now\') WHERE user_id = ? AND agent_id = ? AND platform = ?')
412
599
  .run('connected', userId, agentId, platformName);
600
+ this.emit('platform_connected', { userId, agentId, platform: platformName });
601
+ void this.recoverPendingInbound({
602
+ userId,
603
+ agentId,
604
+ platform: platformName,
605
+ }).catch((error) => {
606
+ if (!this.isShuttingDown) {
607
+ console.error('[Messaging] Inbound recovery failed:', error?.message || error);
608
+ }
609
+ });
413
610
  });
414
611
 
415
612
  platform.on('disconnected', (info) => {
@@ -455,9 +652,13 @@ class MessagingManager extends EventEmitter {
455
652
  });
456
653
  });
457
654
 
458
- platform.on('message', async (msg) => {
655
+ platform.on('message', (msg) => {
459
656
  if (this.isShuttingDown) return;
460
- await this.ingestMessage(userId, platformName, msg, { agentId });
657
+ void this.ingestMessage(userId, platformName, msg, { agentId }).catch((error) => {
658
+ if (!this.isShuttingDown) {
659
+ console.error('[Messaging] Failed to persist or dispatch inbound message:', error?.message || error);
660
+ }
661
+ });
461
662
  });
462
663
 
463
664
  if (!existingConnection) {
@@ -468,7 +669,19 @@ class MessagingManager extends EventEmitter {
468
669
  .run(storedConfig, 'connecting', userId, agentId, platformName);
469
670
  }
470
671
 
471
- await platform.connect();
672
+ try {
673
+ await this._runOperation(
674
+ options,
675
+ `${platformName} connection`,
676
+ () => platform.connect(),
677
+ );
678
+ } catch (error) {
679
+ if (currentPlatform()) {
680
+ this.platforms.delete(key);
681
+ }
682
+ await Promise.resolve(platform.disconnect?.()).catch(() => {});
683
+ throw error;
684
+ }
472
685
  return { status: platform.getStatus() };
473
686
  }
474
687
 
@@ -489,6 +702,7 @@ class MessagingManager extends EventEmitter {
489
702
  }
490
703
 
491
704
  async sendMessage(userId, platformName, to, content, mediaPathOrOptions) {
705
+ this._assertRunning();
492
706
  const agentId = this._agentId(userId, mediaPathOrOptions || {});
493
707
  const key = this._key(userId, agentId, platformName);
494
708
  const platform = this.platforms.get(key);
@@ -505,6 +719,7 @@ class MessagingManager extends EventEmitter {
505
719
  ? sendOptions.metadata
506
720
  : null;
507
721
  const deliveryKind = sendOptions.deliveryKind || 'final';
722
+ throwIfAborted(sendOptions.signal, 'Message delivery aborted.');
508
723
  const normalizedContent = normalizeOutgoingMessageForPlatform(platformName, content, {
509
724
  stripNoResponseMarker: false
510
725
  });
@@ -514,7 +729,13 @@ class MessagingManager extends EventEmitter {
514
729
  return { success: true, suppressed: true };
515
730
  }
516
731
 
517
- const result = await platform.sendMessage(to, normalizedContent, sendOptions);
732
+ const result = await this._runOperation(
733
+ sendOptions,
734
+ `${platformName} message delivery`,
735
+ (signal) => platform.sendMessage(to, normalizedContent, { ...sendOptions, signal }),
736
+ );
737
+ this._assertRunning();
738
+ throwIfAborted(sendOptions.signal, 'Message delivery aborted.');
518
739
  if (result?.success === false) {
519
740
  const reason = result.error || result.reason || 'platform rejected the message';
520
741
  const error = new Error(`Platform ${platformName} delivery failed: ${reason}`);
@@ -676,7 +897,7 @@ class MessagingManager extends EventEmitter {
676
897
  }
677
898
 
678
899
  async restoreConnections() {
679
- this.isShuttingDown = false;
900
+ this._assertRunning();
680
901
  const rows = db.prepare(
681
902
  "SELECT user_id, agent_id, platform, config FROM platform_connections WHERE status IN ('connected', 'awaiting_qr')"
682
903
  ).all();
@@ -715,17 +936,55 @@ class MessagingManager extends EventEmitter {
715
936
  }
716
937
 
717
938
  async shutdown() {
939
+ if (this.shutdownPromise) return this.shutdownPromise;
718
940
  this.isShuttingDown = true;
719
-
720
- const tasks = [];
721
- for (const platform of this.platforms.values()) {
722
- if (typeof platform.disconnect === 'function') {
723
- tasks.push(platform.disconnect().catch(() => {}));
941
+ this.lifecycleAbortController.abort(messagingShutdownError());
942
+
943
+ this.shutdownPromise = (async () => {
944
+ const activeOperationTasks = Array.from(this.activeOperations, (operation) =>
945
+ waitForBoundedResult(operation, {
946
+ serviceName: 'Messaging operation shutdown',
947
+ timeoutMs: 10000,
948
+ }));
949
+ activeOperationTasks.push(...Array.from(this.activeInboundJobs, (job) =>
950
+ waitForBoundedResult(job, {
951
+ serviceName: 'Messaging inbound job shutdown',
952
+ timeoutMs: 10000,
953
+ })));
954
+ activeOperationTasks.push(...Array.from(this.activeInboundRecoveries.values(), (recovery) =>
955
+ waitForBoundedResult(recovery, {
956
+ serviceName: 'Messaging inbound recovery shutdown',
957
+ timeoutMs: 10000,
958
+ })));
959
+ const disconnectTasks = [];
960
+ for (const platform of this.platforms.values()) {
961
+ if (typeof platform.disconnect === 'function') {
962
+ disconnectTasks.push(waitForBoundedResult(
963
+ Promise.resolve().then(() => platform.disconnect()),
964
+ {
965
+ serviceName: `${platform.name || 'Messaging platform'} disconnect`,
966
+ timeoutMs: 10000,
967
+ },
968
+ ));
969
+ }
724
970
  }
725
- }
726
971
 
727
- await Promise.allSettled(tasks);
728
- this.platforms.clear();
972
+ const [operationResults, disconnectResults] = await Promise.all([
973
+ Promise.allSettled(activeOperationTasks),
974
+ Promise.allSettled(disconnectTasks),
975
+ ]);
976
+ this.platforms.clear();
977
+ return {
978
+ state: 'stopped',
979
+ cancelledOperationCount: operationResults.filter(
980
+ (result) => result.status === 'rejected',
981
+ ).length,
982
+ failedDisconnectCount: disconnectResults.filter(
983
+ (result) => result.status === 'rejected',
984
+ ).length,
985
+ };
986
+ })();
987
+ return this.shutdownPromise;
729
988
  }
730
989
 
731
990
  async makeCall(userId, to, greeting, options = {}) {
@@ -733,23 +992,39 @@ class MessagingManager extends EventEmitter {
733
992
  const platform = this.platforms.get(key);
734
993
  if (!platform) throw new Error('Telnyx Voice is not connected');
735
994
  if (!platform.initiateCall) throw new Error('Telnyx platform does not support outbound calls');
736
- const result = await platform.initiateCall(to, greeting);
995
+ const result = await this._runOperation(
996
+ options,
997
+ 'Telnyx outbound call',
998
+ (signal) => platform.initiateCall(to, greeting, { ...options, signal }),
999
+ );
737
1000
  this.io.to(`user:${userId}`).emit('messaging:call_initiated', { platform: 'telnyx', to, callControlId: result.callControlId });
738
1001
  return { success: true, ...result };
739
1002
  }
740
1003
 
741
1004
  async markRead(userId, platformName, chatId, messageId, options = {}) {
1005
+ this._assertRunning();
742
1006
  const key = this._key(userId, this._agentId(userId, options), platformName);
743
1007
  const platform = this.platforms.get(key);
744
1008
  if (!platform?.markRead) return;
745
- return platform.markRead(chatId, messageId);
1009
+ return this._runOperation(
1010
+ options,
1011
+ `${platformName} read receipt`,
1012
+ (signal) => platform.markRead(chatId, messageId, { ...options, signal }),
1013
+ 15000,
1014
+ );
746
1015
  }
747
1016
 
748
1017
  async sendTyping(userId, platformName, chatId, isTyping, options = {}) {
1018
+ this._assertRunning();
749
1019
  const key = this._key(userId, this._agentId(userId, options), platformName);
750
1020
  const platform = this.platforms.get(key);
751
1021
  if (!platform?.sendTyping) return;
752
- return platform.sendTyping(chatId, isTyping);
1022
+ return this._runOperation(
1023
+ options,
1024
+ `${platformName} typing indicator`,
1025
+ (signal) => platform.sendTyping(chatId, isTyping, { ...options, signal }),
1026
+ 15000,
1027
+ );
753
1028
  }
754
1029
 
755
1030
  /**
@@ -9,6 +9,7 @@ function startTypingKeepalive({
9
9
  runId,
10
10
  platform,
11
11
  chatId,
12
+ signal = null,
12
13
  intervalMs = 4000,
13
14
  onError = null
14
15
  }) {
@@ -65,7 +66,7 @@ function startTypingKeepalive({
65
66
  platform,
66
67
  chatId,
67
68
  isTyping,
68
- { agentId }
69
+ { agentId, signal }
69
70
  );
70
71
  } catch (error) {
71
72
  reportFailure(operation, error);
@@ -97,7 +98,9 @@ function startTypingKeepalive({
97
98
  releaseWait = null;
98
99
  }
99
100
  await loop.catch((error) => reportFailure('typing keepalive loop', error));
100
- await sendTyping(false, 'clear typing indicator');
101
+ if (!signal?.aborted) {
102
+ await sendTyping(false, 'clear typing indicator');
103
+ }
101
104
  })();
102
105
  return stopPromise;
103
106
  };
@@ -54,6 +54,24 @@ class WhatsAppPlatform extends BasePlatform {
54
54
  return [...ownIds].some((id) => text.includes(`@${id}`));
55
55
  }
56
56
 
57
+ _checkMessageAccess(msg, { chatId, isGroup, sender, pushName }) {
58
+ const senderId = normalizeWhatsAppId(sender);
59
+ return this._checkInboundAccess({
60
+ platform: 'whatsapp',
61
+ senderId,
62
+ chatId,
63
+ isDirect: !isGroup,
64
+ isShared: isGroup,
65
+ groupId: isGroup ? chatId : '',
66
+ phoneNumber: senderId,
67
+ wasMentioned: isGroup && this._isGroupAddressedToBot(msg.message || {}),
68
+ }, {
69
+ senderName: pushName || senderId,
70
+ meta: isGroup ? `Group: ${chatId}` : '',
71
+ groupLabel: chatId,
72
+ });
73
+ }
74
+
57
75
  async connect() {
58
76
  this._manualDisconnect = false;
59
77
  if (this._reconnectTimer) {
@@ -180,22 +198,12 @@ class WhatsAppPlatform extends BasePlatform {
180
198
  }
181
199
 
182
200
  if (!content && !mediaType) continue;
183
- if (isGroup && !this._isGroupAddressedToBot(msg.message || {})) continue;
184
201
 
185
- const senderId = normalizeWhatsAppId(sender);
186
- const access = this._checkInboundAccess({
187
- platform: 'whatsapp',
188
- senderId,
202
+ const access = this._checkMessageAccess(msg, {
189
203
  chatId,
190
- isDirect: !isGroup,
191
- isShared: isGroup,
192
- groupId: isGroup ? chatId : '',
193
- phoneNumber: senderId,
194
- wasMentioned: isGroup,
195
- }, {
196
- senderName: pushName || senderId,
197
- meta: isGroup ? `Group: ${chatId}` : '',
198
- groupLabel: chatId,
204
+ isGroup,
205
+ sender,
206
+ pushName,
199
207
  });
200
208
 
201
209
  if (!access.allowed) continue;