@leg3ndy/otto-bridge 0.8.1 → 0.8.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.
package/dist/http.js CHANGED
@@ -29,6 +29,12 @@ async function requestJson(apiBaseUrl, pathname, init) {
29
29
  export async function getJson(apiBaseUrl, pathname) {
30
30
  return await requestJson(apiBaseUrl, pathname);
31
31
  }
32
+ export async function getDeviceJson(apiBaseUrl, deviceToken, pathname) {
33
+ return await requestJson(apiBaseUrl, pathname, {
34
+ method: "GET",
35
+ headers: buildDeviceAuthHeaders(deviceToken),
36
+ });
37
+ }
32
38
  export async function postJson(apiBaseUrl, pathname, body) {
33
39
  return await requestJson(apiBaseUrl, pathname, {
34
40
  method: "POST",
@@ -0,0 +1,559 @@
1
+ import { createHash } from "node:crypto";
2
+ import { loadManagedBridgeExtensionState } from "./extensions.js";
3
+ import { getDeviceJson, postDeviceJson } from "./http.js";
4
+ import { WhatsAppBackgroundBrowser } from "./whatsapp_background.js";
5
+ const AUTOMATION_SYNC_INTERVAL_MS = 30_000;
6
+ const AUTOMATION_TICK_INTERVAL_MS = 5_000;
7
+ const DEFAULT_WHATSAPP_MESSAGE_LIMIT = 24;
8
+ const DEFAULT_WHATSAPP_INBOX_SCAN_LIMIT = 12;
9
+ const MAX_WHATSAPP_INBOX_CONVERSATIONS_PER_TICK = 5;
10
+ const DEFAULT_LOCAL_AUTOMATION_TIMEZONE = "America/Sao_Paulo";
11
+ const WEEKDAY_TO_INDEX = {
12
+ sun: 0,
13
+ mon: 1,
14
+ tue: 2,
15
+ wed: 3,
16
+ thu: 4,
17
+ fri: 5,
18
+ sat: 6,
19
+ };
20
+ const zonedFormatterCache = new Map();
21
+ function clipText(value, maxLength) {
22
+ const text = String(value || "").trim();
23
+ if (text.length <= maxLength) {
24
+ return text;
25
+ }
26
+ return `${text.slice(0, Math.max(0, maxLength - 1)).trimEnd()}…`;
27
+ }
28
+ function normalizeStatus(value) {
29
+ return String(value || "").trim().toLowerCase();
30
+ }
31
+ function normalizeChannel(value) {
32
+ return String(value || "").trim().toLowerCase();
33
+ }
34
+ function normalizeWeekday(value) {
35
+ return String(value || "").trim().toLowerCase().slice(0, 3);
36
+ }
37
+ function isActiveAutomation(automation) {
38
+ return normalizeStatus(automation.status) === "active";
39
+ }
40
+ function sanitizeMessages(value, limit = DEFAULT_WHATSAPP_MESSAGE_LIMIT) {
41
+ if (!Array.isArray(value)) {
42
+ return [];
43
+ }
44
+ const messages = [];
45
+ const startIndex = Math.max(0, value.length - Math.max(1, Math.min(limit, 40)));
46
+ for (const item of value.slice(startIndex)) {
47
+ if (!item || typeof item !== "object") {
48
+ continue;
49
+ }
50
+ const rawAuthor = "author" in item ? item.author : "Contato";
51
+ const rawText = "text" in item ? item.text : "";
52
+ const text = clipText(rawText, 500);
53
+ if (!text) {
54
+ continue;
55
+ }
56
+ messages.push({
57
+ author: clipText(rawAuthor || "Contato", 80) || "Contato",
58
+ text,
59
+ });
60
+ }
61
+ return messages;
62
+ }
63
+ function computeDeltaHash(contact, messages) {
64
+ const payload = JSON.stringify({
65
+ contact: contact.trim(),
66
+ messages,
67
+ }, null, 0);
68
+ return createHash("sha256").update(payload).digest("hex");
69
+ }
70
+ function normalizeThreadKey(value) {
71
+ const normalized = String(value || "")
72
+ .normalize("NFD")
73
+ .replace(/[\u0300-\u036f]/g, "")
74
+ .trim();
75
+ const slug = normalized.replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase();
76
+ return slug || "whatsapp-thread";
77
+ }
78
+ function parseIsoTimestamp(value) {
79
+ const text = String(value || "").trim();
80
+ if (!text) {
81
+ return null;
82
+ }
83
+ const timestamp = Date.parse(text);
84
+ return Number.isFinite(timestamp) ? timestamp : null;
85
+ }
86
+ function getFormatter(timeZone) {
87
+ const cacheKey = timeZone || DEFAULT_LOCAL_AUTOMATION_TIMEZONE;
88
+ const existing = zonedFormatterCache.get(cacheKey);
89
+ if (existing) {
90
+ return existing;
91
+ }
92
+ const formatter = new Intl.DateTimeFormat("en-US", {
93
+ timeZone: cacheKey,
94
+ year: "numeric",
95
+ month: "2-digit",
96
+ day: "2-digit",
97
+ hour: "2-digit",
98
+ minute: "2-digit",
99
+ second: "2-digit",
100
+ weekday: "short",
101
+ hour12: false,
102
+ });
103
+ zonedFormatterCache.set(cacheKey, formatter);
104
+ return formatter;
105
+ }
106
+ function getZonedDateParts(epochMs, timeZone) {
107
+ const formatter = getFormatter(timeZone);
108
+ const parts = formatter.formatToParts(new Date(epochMs));
109
+ const values = {};
110
+ for (const part of parts) {
111
+ if (part.type !== "literal") {
112
+ values[part.type] = part.value;
113
+ }
114
+ }
115
+ const weekdayToken = normalizeWeekday(values.weekday);
116
+ return {
117
+ year: Number(values.year || "0"),
118
+ month: Number(values.month || "0"),
119
+ day: Number(values.day || "0"),
120
+ hour: Number(values.hour || "0"),
121
+ minute: Number(values.minute || "0"),
122
+ second: Number(values.second || "0"),
123
+ weekday: WEEKDAY_TO_INDEX[weekdayToken] ?? 0,
124
+ };
125
+ }
126
+ function getTimeZoneOffsetMs(epochMs, timeZone) {
127
+ const zoned = getZonedDateParts(epochMs, timeZone);
128
+ const asUtc = Date.UTC(zoned.year, zoned.month - 1, zoned.day, zoned.hour, zoned.minute, zoned.second);
129
+ return asUtc - epochMs;
130
+ }
131
+ function zonedDateTimeToUtcMs(date, hour, minute, timeZone) {
132
+ const targetUtc = Date.UTC(date.year, date.month - 1, date.day, hour, minute, 0);
133
+ let current = targetUtc;
134
+ for (let iteration = 0; iteration < 3; iteration += 1) {
135
+ const offset = getTimeZoneOffsetMs(current, timeZone);
136
+ const adjusted = targetUtc - offset;
137
+ if (Math.abs(adjusted - current) < 1000) {
138
+ return adjusted;
139
+ }
140
+ current = adjusted;
141
+ }
142
+ return current;
143
+ }
144
+ function addCalendarDays(date, days) {
145
+ const value = new Date(Date.UTC(date.year, date.month - 1, date.day, 12, 0, 0));
146
+ value.setUTCDate(value.getUTCDate() + days);
147
+ return {
148
+ year: value.getUTCFullYear(),
149
+ month: value.getUTCMonth() + 1,
150
+ day: value.getUTCDate(),
151
+ };
152
+ }
153
+ function parseScheduleTime(value) {
154
+ const text = String(value || "").trim();
155
+ const match = text.match(/^(\d{1,2}):(\d{2})$/);
156
+ if (!match) {
157
+ return null;
158
+ }
159
+ const hour = Number(match[1]);
160
+ const minute = Number(match[2]);
161
+ if (!Number.isFinite(hour) || !Number.isFinite(minute) || hour < 0 || hour > 23 || minute < 0 || minute > 59) {
162
+ return null;
163
+ }
164
+ return { hour, minute };
165
+ }
166
+ function resolveTimeZone(scheduleConfig) {
167
+ return String(scheduleConfig?.timezone || DEFAULT_LOCAL_AUTOMATION_TIMEZONE).trim() || DEFAULT_LOCAL_AUTOMATION_TIMEZONE;
168
+ }
169
+ function computeNextDueAt(automation, nowMs) {
170
+ const scheduleConfig = automation.schedule_config || {};
171
+ const scheduleType = String(scheduleConfig.type || "").trim().toLowerCase();
172
+ if (scheduleType === "interval") {
173
+ const minutes = Math.max(1, Number(scheduleConfig.minutes || 0));
174
+ return nowMs + minutes * 60_000;
175
+ }
176
+ const time = parseScheduleTime(scheduleConfig.time);
177
+ if (!time) {
178
+ return nowMs + 5 * 60_000;
179
+ }
180
+ const timeZone = resolveTimeZone(scheduleConfig);
181
+ const localNow = getZonedDateParts(nowMs, timeZone);
182
+ if (scheduleType === "daily") {
183
+ let candidate = zonedDateTimeToUtcMs(localNow, time.hour, time.minute, timeZone);
184
+ if (candidate <= nowMs) {
185
+ candidate = zonedDateTimeToUtcMs(addCalendarDays(localNow, 1), time.hour, time.minute, timeZone);
186
+ }
187
+ return candidate;
188
+ }
189
+ if (scheduleType === "weekly") {
190
+ const allowedDays = new Set(Array.isArray(scheduleConfig.days)
191
+ ? scheduleConfig.days
192
+ .map((item) => WEEKDAY_TO_INDEX[normalizeWeekday(item)])
193
+ .filter((item) => item !== undefined)
194
+ : []);
195
+ if (!allowedDays.size) {
196
+ return nowMs + 24 * 60 * 60_000;
197
+ }
198
+ for (let offset = 0; offset < 8; offset += 1) {
199
+ const candidateDate = addCalendarDays(localNow, offset);
200
+ const candidateWeekday = new Date(Date.UTC(candidateDate.year, candidateDate.month - 1, candidateDate.day, 12, 0, 0)).getUTCDay();
201
+ if (!allowedDays.has(candidateWeekday)) {
202
+ continue;
203
+ }
204
+ const candidate = zonedDateTimeToUtcMs(candidateDate, time.hour, time.minute, timeZone);
205
+ if (candidate > nowMs) {
206
+ return candidate;
207
+ }
208
+ }
209
+ }
210
+ return nowMs + 24 * 60 * 60_000;
211
+ }
212
+ function computeInitialDueAt(automation, nowMs) {
213
+ const nextRunAt = parseIsoTimestamp(automation.next_run_at);
214
+ if (nextRunAt !== null) {
215
+ return nextRunAt;
216
+ }
217
+ return nowMs;
218
+ }
219
+ function isSupportedWhatsAppContactAutomation(automation) {
220
+ if (normalizeChannel(automation.channel) !== "whatsapp") {
221
+ return false;
222
+ }
223
+ const bridgeConfig = automation.bridge_config || {};
224
+ return String(bridgeConfig.monitor_scope || "contact").trim().toLowerCase() === "contact"
225
+ && Boolean(String(bridgeConfig.contact || "").trim());
226
+ }
227
+ function isSupportedWhatsAppInboxAutomation(automation) {
228
+ if (normalizeChannel(automation.channel) !== "whatsapp") {
229
+ return false;
230
+ }
231
+ const bridgeConfig = automation.bridge_config || {};
232
+ return String(bridgeConfig.monitor_scope || "").trim().toLowerCase() === "inbox";
233
+ }
234
+ export class LocalAutomationRuntime {
235
+ config;
236
+ automations = new Map();
237
+ states = new Map();
238
+ syncTimer = null;
239
+ tickTimer = null;
240
+ syncInFlight = false;
241
+ tickInFlight = false;
242
+ started = false;
243
+ stopped = false;
244
+ whatsappBrowser = null;
245
+ constructor(config) {
246
+ this.config = config;
247
+ }
248
+ async start() {
249
+ if (this.started) {
250
+ return;
251
+ }
252
+ this.started = true;
253
+ this.stopped = false;
254
+ await this.syncAutomations().catch((error) => {
255
+ const detail = error instanceof Error ? error.message : String(error);
256
+ console.warn(`[otto-bridge] local automations sync failed: ${detail}`);
257
+ });
258
+ await this.tick().catch((error) => {
259
+ const detail = error instanceof Error ? error.message : String(error);
260
+ console.warn(`[otto-bridge] local automations tick failed: ${detail}`);
261
+ });
262
+ this.syncTimer = setInterval(() => {
263
+ void this.syncAutomations();
264
+ }, AUTOMATION_SYNC_INTERVAL_MS);
265
+ this.tickTimer = setInterval(() => {
266
+ void this.tick();
267
+ }, AUTOMATION_TICK_INTERVAL_MS);
268
+ }
269
+ async stop() {
270
+ this.stopped = true;
271
+ if (this.syncTimer) {
272
+ clearInterval(this.syncTimer);
273
+ this.syncTimer = null;
274
+ }
275
+ if (this.tickTimer) {
276
+ clearInterval(this.tickTimer);
277
+ this.tickTimer = null;
278
+ }
279
+ const browser = this.whatsappBrowser;
280
+ this.whatsappBrowser = null;
281
+ if (browser) {
282
+ await browser.close().catch(() => undefined);
283
+ }
284
+ }
285
+ async syncAutomations() {
286
+ if (this.syncInFlight || this.stopped) {
287
+ return;
288
+ }
289
+ this.syncInFlight = true;
290
+ try {
291
+ const response = await getDeviceJson(this.config.apiBaseUrl, this.config.deviceToken, "/v1/devices/automations/local");
292
+ const nowMs = Date.now();
293
+ const activeIds = new Set();
294
+ const automations = Array.isArray(response.automations) ? response.automations : [];
295
+ for (const automation of automations) {
296
+ const automationId = String(automation.id || "").trim();
297
+ if (!automationId) {
298
+ continue;
299
+ }
300
+ activeIds.add(automationId);
301
+ this.automations.set(automationId, automation);
302
+ const current = this.states.get(automationId);
303
+ const serverDeltaHash = automation.local_state?.last_known_delta_hash
304
+ ? String(automation.local_state.last_known_delta_hash).trim()
305
+ : null;
306
+ const threadDeltaHashes = {
307
+ ...(current?.threadDeltaHashes || {}),
308
+ ...(automation.local_state?.thread_delta_hashes && typeof automation.local_state.thread_delta_hashes === "object"
309
+ ? automation.local_state.thread_delta_hashes
310
+ : {}),
311
+ };
312
+ const configuredContact = String(automation.bridge_config?.contact || "").trim();
313
+ if (configuredContact && serverDeltaHash) {
314
+ threadDeltaHashes[normalizeThreadKey(configuredContact)] = threadDeltaHashes[normalizeThreadKey(configuredContact)] || serverDeltaHash;
315
+ }
316
+ this.states.set(automationId, {
317
+ nextDueAtMs: current?.nextDueAtMs ?? computeInitialDueAt(automation, nowMs),
318
+ running: current?.running ?? false,
319
+ lastKnownDeltaHash: current?.lastKnownDeltaHash ?? serverDeltaHash,
320
+ threadDeltaHashes,
321
+ });
322
+ }
323
+ for (const automationId of Array.from(this.automations.keys())) {
324
+ if (!activeIds.has(automationId)) {
325
+ this.automations.delete(automationId);
326
+ this.states.delete(automationId);
327
+ }
328
+ }
329
+ }
330
+ catch (error) {
331
+ const detail = error instanceof Error ? error.message : String(error);
332
+ console.warn(`[otto-bridge] local automations sync failed: ${detail}`);
333
+ }
334
+ finally {
335
+ this.syncInFlight = false;
336
+ }
337
+ }
338
+ async tick() {
339
+ if (this.tickInFlight || this.stopped) {
340
+ return;
341
+ }
342
+ this.tickInFlight = true;
343
+ try {
344
+ const nowMs = Date.now();
345
+ for (const automation of this.automations.values()) {
346
+ const automationId = String(automation.id || "").trim();
347
+ if (!automationId) {
348
+ continue;
349
+ }
350
+ const state = this.states.get(automationId);
351
+ if (!state || state.running || !isActiveAutomation(automation) || nowMs < state.nextDueAtMs) {
352
+ continue;
353
+ }
354
+ state.running = true;
355
+ try {
356
+ if (isSupportedWhatsAppContactAutomation(automation)) {
357
+ await this.handleWhatsAppContactAutomation(automation, state);
358
+ }
359
+ else if (isSupportedWhatsAppInboxAutomation(automation)) {
360
+ await this.handleWhatsAppInboxAutomation(automation, state);
361
+ }
362
+ }
363
+ catch (error) {
364
+ const detail = error instanceof Error ? error.message : String(error);
365
+ console.warn(`[otto-bridge] local automation failed id=${automationId}: ${detail}`);
366
+ }
367
+ finally {
368
+ state.running = false;
369
+ state.nextDueAtMs = computeNextDueAt(automation, Date.now());
370
+ this.states.set(automationId, state);
371
+ }
372
+ }
373
+ }
374
+ finally {
375
+ this.tickInFlight = false;
376
+ }
377
+ }
378
+ async ensureWhatsAppBrowser() {
379
+ if (!this.whatsappBrowser) {
380
+ this.whatsappBrowser = new WhatsAppBackgroundBrowser({ background: true });
381
+ }
382
+ return this.whatsappBrowser;
383
+ }
384
+ async handleWhatsAppContactAutomation(automation, state) {
385
+ const extensionState = await loadManagedBridgeExtensionState("whatsappweb");
386
+ if (!extensionState || extensionState.status !== "connected") {
387
+ return;
388
+ }
389
+ const bridgeConfig = automation.bridge_config || {};
390
+ const contact = String(bridgeConfig.contact || "").trim();
391
+ if (!contact) {
392
+ return;
393
+ }
394
+ const browser = await this.ensureWhatsAppBrowser();
395
+ await browser.ensureReady();
396
+ const selected = await browser.selectConversation(contact);
397
+ if (!selected) {
398
+ console.warn(`[otto-bridge] local whatsapp automation could not find contact="${contact}"`);
399
+ return;
400
+ }
401
+ await this.processWhatsAppConversation(automation, state, browser, contact, { alreadySelected: true });
402
+ }
403
+ async handleWhatsAppInboxAutomation(automation, state) {
404
+ const extensionState = await loadManagedBridgeExtensionState("whatsappweb");
405
+ if (!extensionState || extensionState.status !== "connected") {
406
+ return;
407
+ }
408
+ const browser = await this.ensureWhatsAppBrowser();
409
+ await browser.ensureReady();
410
+ const conversations = await browser.scanInboxConversations(DEFAULT_WHATSAPP_INBOX_SCAN_LIMIT, {
411
+ unreadOnly: true,
412
+ });
413
+ if (!Array.isArray(conversations) || conversations.length === 0) {
414
+ return;
415
+ }
416
+ const selectedConversations = conversations
417
+ .filter((item) => item && String(item.contact || "").trim())
418
+ .slice(0, MAX_WHATSAPP_INBOX_CONVERSATIONS_PER_TICK);
419
+ for (const conversation of selectedConversations) {
420
+ const contact = String(conversation.contact || "").trim();
421
+ if (!contact) {
422
+ continue;
423
+ }
424
+ try {
425
+ const selected = await browser.selectConversation(contact);
426
+ if (!selected) {
427
+ console.warn(`[otto-bridge] local whatsapp inbox automation could not find contact="${contact}"`);
428
+ continue;
429
+ }
430
+ await this.processWhatsAppConversation(automation, state, browser, contact, {
431
+ alreadySelected: true,
432
+ inboxConversation: conversation,
433
+ });
434
+ }
435
+ catch (error) {
436
+ const detail = error instanceof Error ? error.message : String(error);
437
+ console.warn(`[otto-bridge] local whatsapp inbox conversation failed contact="${contact}": ${detail}`);
438
+ }
439
+ }
440
+ }
441
+ isPrimaryContactForAutomation(automation, contact) {
442
+ if (!isSupportedWhatsAppContactAutomation(automation)) {
443
+ return false;
444
+ }
445
+ const configuredContact = String(automation.bridge_config?.contact || "").trim();
446
+ if (!configuredContact) {
447
+ return true;
448
+ }
449
+ return normalizeThreadKey(configuredContact) === normalizeThreadKey(contact);
450
+ }
451
+ getKnownDeltaHash(automation, state, contact) {
452
+ const threadHash = state.threadDeltaHashes[normalizeThreadKey(contact)];
453
+ if (threadHash) {
454
+ return threadHash;
455
+ }
456
+ if (this.isPrimaryContactForAutomation(automation, contact) && state.lastKnownDeltaHash) {
457
+ return state.lastKnownDeltaHash;
458
+ }
459
+ return null;
460
+ }
461
+ rememberDeltaHash(automation, state, contact, deltaHash) {
462
+ const normalizedHash = String(deltaHash || "").trim();
463
+ if (!normalizedHash) {
464
+ return;
465
+ }
466
+ state.threadDeltaHashes[normalizeThreadKey(contact)] = normalizedHash;
467
+ if (this.isPrimaryContactForAutomation(automation, contact)) {
468
+ state.lastKnownDeltaHash = normalizedHash;
469
+ }
470
+ }
471
+ shouldRememberDispositionDelta(disposition) {
472
+ return disposition === "session_created"
473
+ || disposition === "skipped_no_delta"
474
+ || disposition === "skipped_rate_limited"
475
+ || disposition === "skipped_plan_disabled";
476
+ }
477
+ async processWhatsAppConversation(automation, state, browser, contact, options) {
478
+ if (!options?.alreadySelected) {
479
+ const selected = await browser.selectConversation(contact);
480
+ if (!selected) {
481
+ console.warn(`[otto-bridge] local whatsapp automation could not find contact="${contact}"`);
482
+ return;
483
+ }
484
+ }
485
+ await browser.waitForTimeout(500);
486
+ const chat = await browser.readVisibleConversation(DEFAULT_WHATSAPP_MESSAGE_LIMIT);
487
+ const messages = sanitizeMessages(chat.messages, DEFAULT_WHATSAPP_MESSAGE_LIMIT);
488
+ if (!messages.length) {
489
+ return;
490
+ }
491
+ const deltaHash = computeDeltaHash(contact, messages);
492
+ const previousDeltaHash = this.getKnownDeltaHash(automation, state, contact);
493
+ if (previousDeltaHash && previousDeltaHash === deltaHash) {
494
+ return;
495
+ }
496
+ const eventResponse = await postDeviceJson(this.config.apiBaseUrl, this.config.deviceToken, "/v1/devices/automations/local/events", {
497
+ automation_id: automation.id,
498
+ event_type: "whatsapp_snapshot",
499
+ contact,
500
+ delta_hash: deltaHash,
501
+ summary: chat.summary || options?.inboxConversation?.preview || undefined,
502
+ messages,
503
+ });
504
+ const disposition = String(eventResponse.disposition || "").trim().toLowerCase();
505
+ if (this.shouldRememberDispositionDelta(disposition)) {
506
+ this.rememberDeltaHash(automation, state, contact, String(eventResponse.last_delta_hash || deltaHash || "").trim() || deltaHash);
507
+ return;
508
+ }
509
+ if (disposition !== "requires_auto_reply_send") {
510
+ return;
511
+ }
512
+ const runId = String(eventResponse.run?.id || "").trim();
513
+ const replyText = clipText(eventResponse.reply_action?.text || "", 2000);
514
+ if (!runId || !replyText) {
515
+ this.rememberDeltaHash(automation, state, contact, deltaHash);
516
+ return;
517
+ }
518
+ let sent = false;
519
+ let errorMessage = "";
520
+ let completionMessages = messages;
521
+ try {
522
+ await browser.sendMessage(replyText);
523
+ await browser.waitForTimeout(900);
524
+ const verification = await browser.verifyLastMessage(replyText, messages);
525
+ const afterChat = await browser.readVisibleConversation(Math.max(DEFAULT_WHATSAPP_MESSAGE_LIMIT, messages.length + 4));
526
+ completionMessages = sanitizeMessages(afterChat.messages, Math.max(DEFAULT_WHATSAPP_MESSAGE_LIMIT, messages.length + 4));
527
+ sent = verification.ok;
528
+ if (!sent) {
529
+ errorMessage = verification.reason || "Nao consegui confirmar o envio da resposta automatica.";
530
+ }
531
+ }
532
+ catch (error) {
533
+ errorMessage = error instanceof Error ? error.message : String(error);
534
+ const afterChat = await browser.readVisibleConversation(Math.max(DEFAULT_WHATSAPP_MESSAGE_LIMIT, messages.length + 4))
535
+ .catch(() => ({ messages, summary: chat.summary }));
536
+ completionMessages = sanitizeMessages(afterChat.messages, Math.max(DEFAULT_WHATSAPP_MESSAGE_LIMIT, messages.length + 4));
537
+ }
538
+ const completionDeltaHash = computeDeltaHash(contact, completionMessages);
539
+ try {
540
+ const completionResponse = await postDeviceJson(this.config.apiBaseUrl, this.config.deviceToken, `/v1/devices/automations/local/runs/${encodeURIComponent(runId)}/complete`, {
541
+ automation_id: automation.id,
542
+ contact,
543
+ sent,
544
+ delta_hash: completionDeltaHash,
545
+ summary: chat.summary || undefined,
546
+ sent_text: replyText,
547
+ error_message: errorMessage || undefined,
548
+ messages: completionMessages,
549
+ });
550
+ const persistedDeltaHash = String(completionResponse.last_delta_hash || "").trim();
551
+ this.rememberDeltaHash(automation, state, contact, persistedDeltaHash || (sent ? completionDeltaHash : deltaHash));
552
+ }
553
+ catch (error) {
554
+ const detail = error instanceof Error ? error.message : String(error);
555
+ console.warn(`[otto-bridge] local whatsapp completion failed id=${automation.id}: ${detail}`);
556
+ this.rememberDeltaHash(automation, state, contact, sent ? completionDeltaHash : deltaHash);
557
+ }
558
+ }
559
+ }
@@ -629,6 +629,117 @@ export class MacOSWhatsAppHelperRuntime {
629
629
  : "(sem mensagens visiveis na conversa)",
630
630
  };
631
631
  }
632
+ async scanInboxConversations(limit, options) {
633
+ await this.ensureReady();
634
+ const unreadOnly = options?.unreadOnly === true;
635
+ const result = await this.evaluate(`
636
+ (() => {
637
+ const maxItems = ${Math.max(1, Number(limit || 10))};
638
+ const unreadOnly = ${JSON.stringify(unreadOnly)};
639
+ const normalize = (value) => String(value || "").normalize("NFD").replace(/[\\u0300-\\u036f]/g, "").toLowerCase().trim();
640
+
641
+ function isVisible(element) {
642
+ if (!(element instanceof HTMLElement)) return false;
643
+ const rect = element.getBoundingClientRect();
644
+ if (rect.width < 6 || rect.height < 6) return false;
645
+ const style = window.getComputedStyle(element);
646
+ if (style.visibility === "hidden" || style.display === "none" || Number(style.opacity || "1") === 0) return false;
647
+ return rect.bottom >= 0 && rect.right >= 0 && rect.top <= window.innerHeight && rect.left <= window.innerWidth;
648
+ }
649
+
650
+ const uniqueContainers = new Set();
651
+ const containers = Array.from(document.querySelectorAll(
652
+ '#pane-side [role="listitem"], #pane-side [role="gridcell"], #pane-side [data-testid="cell-frame-container"], #pane-side div[tabindex]',
653
+ ))
654
+ .filter((node) => node instanceof HTMLElement)
655
+ .filter((node) => {
656
+ if (!isVisible(node) || uniqueContainers.has(node)) {
657
+ return false;
658
+ }
659
+ uniqueContainers.add(node);
660
+ return true;
661
+ });
662
+
663
+ const conversations = containers.map((container) => {
664
+ const titleCandidates = Array.from(container.querySelectorAll('span[title], div[title]'))
665
+ .filter((node) => node instanceof HTMLElement)
666
+ .filter((node) => isVisible(node))
667
+ .map((node) => {
668
+ const text = String(node.getAttribute("title") || node.textContent || "").trim();
669
+ const score = text ? (normalize(text).length >= 2 ? 100 : 0) : 0;
670
+ return { text, score };
671
+ })
672
+ .filter((item) => item.score > 0)
673
+ .sort((left, right) => right.score - left.score);
674
+
675
+ const contact = String(titleCandidates[0]?.text || "").trim();
676
+ if (!contact) {
677
+ return null;
678
+ }
679
+
680
+ const lines = String(container.innerText || "")
681
+ .split(/\\n+/)
682
+ .map((item) => item.trim())
683
+ .filter(Boolean);
684
+ const preview = lines.find((line) => normalize(line) !== normalize(contact) && !/^\\d+$/.test(line)) || "";
685
+
686
+ const unreadSources = [
687
+ ...Array.from(container.querySelectorAll('[aria-label*="unread"], [aria-label*="Unread"], [aria-label*="não l"], [data-testid*="unread"], [data-icon*="unread"]')),
688
+ container,
689
+ ];
690
+ let unreadCount = 0;
691
+ for (const source of unreadSources) {
692
+ if (!(source instanceof HTMLElement)) {
693
+ continue;
694
+ }
695
+ const label = \`\${source.getAttribute("aria-label") || ""} \${source.innerText || ""}\`.trim();
696
+ const lower = normalize(label);
697
+ if (!lower || (!lower.includes("unread") && !lower.includes("nao l") && !lower.includes("não l") && !/^\\d+$/.test(lower))) {
698
+ continue;
699
+ }
700
+ const match = label.match(/(\\d{1,3})/);
701
+ if (match) {
702
+ unreadCount = Math.max(unreadCount, Number(match[1] || 0));
703
+ } else if (lower.includes("unread") || lower.includes("nao l") || lower.includes("não l")) {
704
+ unreadCount = Math.max(unreadCount, 1);
705
+ }
706
+ }
707
+
708
+ return {
709
+ contact,
710
+ preview: String(preview || "").slice(0, 240),
711
+ unreadCount,
712
+ };
713
+ }).filter((item) => item !== null);
714
+
715
+ const deduped = new Map();
716
+ for (const item of conversations) {
717
+ const key = normalize(item.contact);
718
+ const current = deduped.get(key);
719
+ if (!current || item.unreadCount > current.unreadCount || item.preview.length > current.preview.length) {
720
+ deduped.set(key, item);
721
+ }
722
+ }
723
+
724
+ return {
725
+ conversations: Array.from(deduped.values())
726
+ .filter((item) => !unreadOnly || item.unreadCount > 0)
727
+ .sort((left, right) => right.unreadCount - left.unreadCount || left.contact.localeCompare(right.contact))
728
+ .slice(0, maxItems),
729
+ };
730
+ })()
731
+ `);
732
+ const rawConversations = Array.isArray(result.conversations)
733
+ ? result.conversations
734
+ : [];
735
+ return rawConversations
736
+ .map((item) => ({
737
+ contact: String(item.contact || "").trim().slice(0, 120),
738
+ preview: String(item.preview || "").trim().slice(0, 240),
739
+ unreadCount: Math.max(0, Number(item.unreadCount || 0)),
740
+ }))
741
+ .filter((item) => item.contact);
742
+ }
632
743
  async verifyLastMessage(expectedText, previousMessages) {
633
744
  const baseline = Array.isArray(previousMessages) ? previousMessages : [];
634
745
  const chat = await this.readVisibleConversation(Math.max(8, baseline.length + 2));