tide-commander 1.91.0 → 1.93.0

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 (56) hide show
  1. package/dist/assets/{BossLogsModal-XsTxfWM8.js → BossLogsModal-CgILsm9V.js} +1 -1
  2. package/dist/assets/{BossSpawnModal-DqQMPxHu.js → BossSpawnModal-BdGy2pC2.js} +1 -1
  3. package/dist/assets/{ControlsModal-5mzDDdS5.js → ControlsModal-hFIzwgt7.js} +1 -1
  4. package/dist/assets/{DockerLogsModal-2eHlxyKa.js → DockerLogsModal-CDX9xVzn.js} +1 -1
  5. package/dist/assets/{EmbeddedEditor-Bi9Ysd99.js → EmbeddedEditor-2DZTiy_9.js} +1 -1
  6. package/dist/assets/{GmailOAuthSetup-5u85N8Br.js → GmailOAuthSetup-Cq1DpIPF.js} +1 -1
  7. package/dist/assets/{GoogleOAuthSetup-OxT_QwZL.js → GoogleOAuthSetup-B1UOVYlZ.js} +1 -1
  8. package/dist/assets/{IframeModal-Bn1kdP1S.js → IframeModal-GZkqd6O6.js} +1 -1
  9. package/dist/assets/{IntegrationsPanel-BehHkKJu.js → IntegrationsPanel-CBN_0nAs.js} +2 -2
  10. package/dist/assets/{LogViewerModal-JuUpWFPL.js → LogViewerModal-BrZrAYvt.js} +1 -1
  11. package/dist/assets/{MonitoringModal-CLk3uqDa.js → MonitoringModal-bIVunlhs.js} +1 -1
  12. package/dist/assets/{PM2LogsModal-C_NpOsos.js → PM2LogsModal-Crs7Q2LK.js} +1 -1
  13. package/dist/assets/{RestoreArchivedAreaModal-Cbcg2Fm8.js → RestoreArchivedAreaModal-9thnHbZ3.js} +1 -1
  14. package/dist/assets/{Scene2DCanvas-4C-jHERv.js → Scene2DCanvas-Bj7JLtW8.js} +1 -1
  15. package/dist/assets/{SceneManager-BoRV8xt3.js → SceneManager-C-ItjZnd.js} +1 -1
  16. package/dist/assets/{SkillsPanel-Bwk3UEY_.js → SkillsPanel-pTORcpQL.js} +1 -1
  17. package/dist/assets/{SlackMultiInstanceSetup-t-g3hdbr.js → SlackMultiInstanceSetup-FXuMr1vo.js} +1 -1
  18. package/dist/assets/{SpawnModal-BOXkPtaJ.js → SpawnModal-D3Pgos07.js} +1 -1
  19. package/dist/assets/{SubordinateAssignmentModal-CLHq5a9b.js → SubordinateAssignmentModal-6au3Tv5w.js} +1 -1
  20. package/dist/assets/TriggerManagerPanel-DletkqtL.js +9 -0
  21. package/dist/assets/{WorkflowEditorPanel-Bevs1fpc.js → WorkflowEditorPanel-DMu9KdjB.js} +1 -1
  22. package/dist/assets/{index-CJuTMFz9.js → index-BGatvpcF.js} +1 -1
  23. package/dist/assets/{index-CdKOXIM2.js → index-BPox4QjF.js} +1 -1
  24. package/dist/assets/{index-H8kj1tuO.js → index-CQYizqu9.js} +1 -1
  25. package/dist/assets/{index-CiXA-Zp-.js → index-DAsi0YrR.js} +1 -1
  26. package/dist/assets/{index-Dd063aRs.js → index-DIsb3aYA.js} +1 -1
  27. package/dist/assets/{index-vFrHpR5s.js → index-Hl0I9IIt.js} +1 -1
  28. package/dist/assets/{index-DxHwQ6CI.js → index-ZZtcJoNU.js} +33 -33
  29. package/dist/assets/{index-DBt10C9K.js → index-_OjecyuG.js} +1 -1
  30. package/dist/assets/{index-B4JdUiAe.js → index-mEu7CM6i.js} +2 -2
  31. package/dist/assets/{main-5eyR3isL.js → main-BUO6--48.js} +99 -98
  32. package/dist/assets/main-BfT_95fk.css +1 -0
  33. package/dist/assets/{web-DMjkVCWy.js → web-BbNfUMzK.js} +1 -1
  34. package/dist/assets/{web-Cx_ySRHK.js → web-CQsQBSkQ.js} +1 -1
  35. package/dist/assets/{web-DGO1VHbi.js → web-bKiHKTT6.js} +1 -1
  36. package/dist/index.html +2 -2
  37. package/dist/locales/en/terminal.json +2 -1
  38. package/dist/src/packages/server/index.js +4 -0
  39. package/dist/src/packages/server/integrations/gmail/gmail-client.js +82 -1
  40. package/dist/src/packages/server/integrations/gmail/gmail-trigger-handler.js +61 -1
  41. package/dist/src/packages/server/integrations/slack/slack-config.js +13 -0
  42. package/dist/src/packages/server/integrations/slack/slack-instance.js +90 -0
  43. package/dist/src/packages/server/integrations/slack/slack-polling-client.js +296 -42
  44. package/dist/src/packages/server/integrations/slack/slack-skill.js +16 -0
  45. package/dist/src/packages/server/integrations/slack/slack-trigger-handler.js +19 -0
  46. package/dist/src/packages/server/integrations/whatsapp/whatsapp-config.js +46 -0
  47. package/dist/src/packages/server/integrations/whatsapp/whatsapp-skill.js +12 -0
  48. package/dist/src/packages/server/integrations/whatsapp/whatsapp-trigger-handler.js +284 -17
  49. package/dist/src/packages/server/routes/files.js +56 -8
  50. package/dist/src/packages/server/services/attachment-downloader.js +317 -0
  51. package/dist/src/packages/server/services/attachment-janitor.js +110 -0
  52. package/dist/src/packages/server/services/audio-transcription.js +165 -0
  53. package/dist/src/packages/server/services/trigger-service.js +8 -5
  54. package/package.json +1 -1
  55. package/dist/assets/TriggerManagerPanel-DuWagsLi.js +0 -3
  56. package/dist/assets/main-CrGeO0Sc.css +0 -1
@@ -75,11 +75,17 @@ export class SlackPollingClient {
75
75
  allowlistChannelIds;
76
76
  keepAllDms;
77
77
  minMsBetweenCalls;
78
+ useSearch;
78
79
  scheduler;
79
80
  now;
80
- /** Pacer state: serializes outbound API calls through a promise chain. */
81
- rateGateChain = Promise.resolve();
82
- /** When > now(), all paced calls sleep until that wall-clock ms. Set on 429. */
81
+ /** Token-bucket rate limiter shared by every paced API call. Concurrent
82
+ * workers really run in parallel — only the *rate* (tokens/sec) is capped.
83
+ * Replaces the older serial promise-chain gate which silently nullified
84
+ * the `concurrency` option (every paced call was forced to run one at a
85
+ * time spaced by `minMsBetweenCalls`, so `concurrency: 8` behaved like
86
+ * `concurrency: 1`). */
87
+ bucket = null;
88
+ /** When > now(), every paced call sleeps until that wall-clock ms. Set on 429. */
83
89
  globalPauseUntil = 0;
84
90
  running = false;
85
91
  timer = null;
@@ -106,53 +112,47 @@ export class SlackPollingClient {
106
112
  this.allowlistChannelIds = new Set((opts.allowlistChannelIds ?? []).map((s) => s.trim()).filter(Boolean));
107
113
  this.keepAllDms = opts.keepAllDms !== false;
108
114
  this.minMsBetweenCalls = Math.max(0, opts.minMsBetweenCalls ?? 0);
115
+ this.useSearch = opts.useSearch === true;
109
116
  this.scheduler = opts.scheduler ?? DEFAULT_SCHEDULER;
110
117
  this.now = opts.now ?? Date.now;
118
+ if (this.minMsBetweenCalls > 0) {
119
+ // Tier 3 spec: ~50/min sustained with bursts up to ~5/sec. Refill rate
120
+ // = minMsBetweenCalls (e.g. 1200ms → 50/min). Burst capacity caps the
121
+ // initial flood from a cycle wake-up without us having to throttle every
122
+ // single call serially.
123
+ this.bucket = new TokenBucket({
124
+ capacity: 5,
125
+ refillIntervalMs: this.minMsBetweenCalls,
126
+ now: this.now,
127
+ globalPauseUntil: () => this.globalPauseUntil,
128
+ isRunning: () => this.running,
129
+ });
130
+ }
111
131
  }
112
132
  /**
113
133
  * Throttle gate for outbound Slack API calls. All calls (history + replies)
114
- * go through here so the per-cycle burst is smeared across the cycle. When
115
- * `minMsBetweenCalls` is 0 the gate is a no-op used by tests.
134
+ * go through here. With `minMsBetweenCalls > 0` we acquire a token from the
135
+ * shared bucket; multiple workers can hold tokens concurrently up to the
136
+ * burst capacity, so `concurrency: 8` actually parallelizes (unlike the
137
+ * older serial promise-chain gate). With `minMsBetweenCalls === 0` we are
138
+ * in test mode and skip throttling entirely.
116
139
  *
117
- * Honors `globalPauseUntil` set by 429 handlers so the entire client backs
118
- * off when Slack tells us to.
140
+ * The bucket honors `globalPauseUntil` set by 429 handlers so the entire
141
+ * client backs off when Slack tells us to.
119
142
  */
120
143
  async paceCall(fn) {
121
- // Stop() was called while we were queued — bail without consuming the
122
- // pacing slot or making the API call.
123
144
  if (!this.running) {
124
145
  throw new Error('SlackPollingClient stopped');
125
146
  }
126
- if (this.minMsBetweenCalls === 0 && this.globalPauseUntil <= this.now()) {
147
+ if (!this.bucket) {
148
+ // Test mode: no throttling at all.
127
149
  return fn();
128
150
  }
129
- const prev = this.rateGateChain;
130
- let release;
131
- this.rateGateChain = new Promise((r) => { release = r; });
132
- await prev;
133
- // Re-check after waiting on the gate — stop() could have fired meanwhile.
151
+ await this.bucket.acquire();
134
152
  if (!this.running) {
135
- release();
136
153
  throw new Error('SlackPollingClient stopped');
137
154
  }
138
- // Sleep through any global pause first.
139
- const pauseRemainingMs = this.globalPauseUntil - this.now();
140
- if (pauseRemainingMs > 0) {
141
- await new Promise((r) => setTimeout(r, pauseRemainingMs));
142
- }
143
- try {
144
- return await fn();
145
- }
146
- finally {
147
- // Release the gate after the min interval — the next paced call will
148
- // unblock then. We don't await this; it's fire-and-forget.
149
- if (this.minMsBetweenCalls > 0) {
150
- setTimeout(release, this.minMsBetweenCalls);
151
- }
152
- else {
153
- release();
154
- }
155
- }
155
+ return fn();
156
156
  }
157
157
  /** Subscribe to fatal-error notifications (e.g. invalid_auth). */
158
158
  setOnFatalError(cb) {
@@ -233,6 +233,25 @@ export class SlackPollingClient {
233
233
  return;
234
234
  this.cycleCount += 1;
235
235
  const cycleStart = this.now();
236
+ // Search-mode: one `search.messages` call replaces the entire per-channel
237
+ // history sweep. Covers thread replies for old parents that history would
238
+ // miss. Done in its own branch so the legacy per-channel path stays
239
+ // intact for tokens without `search:read` scope.
240
+ if (this.useSearch) {
241
+ try {
242
+ const { dispatched, pages } = await this.pollViaSearch();
243
+ const elapsed = this.now() - cycleStart;
244
+ log.log(`cycle=${this.cycleCount} via=search pages=${pages} dispatched=${dispatched} elapsedMs=${elapsed}`);
245
+ }
246
+ catch (err) {
247
+ if (isInvalidAuthError(err)) {
248
+ this.handleFatal(`invalid_auth on search.messages (cycle=${this.cycleCount})`);
249
+ return;
250
+ }
251
+ log.warn(`search-mode cycle failed (cycle=${this.cycleCount}): ${describeErr(err)}`);
252
+ }
253
+ return;
254
+ }
236
255
  // Refresh channel list on first cycle and every N cycles after.
237
256
  if (this.cycleCount === 1 || this.cycleCount % this.channelListRefreshEveryNCycles === 0) {
238
257
  try {
@@ -365,14 +384,15 @@ export class SlackPollingClient {
365
384
  if (messages.length === 0)
366
385
  return 0;
367
386
  // Slack returns newest-first; dispatch in chronological order so trigger
368
- // listeners and dedupe see them in real-world order.
369
- const ordered = [...messages].sort((a, b) => parseFloat(a.ts) - parseFloat(b.ts));
387
+ // listeners and dedupe see them in real-world order. BigInt sort avoids
388
+ // the precision loss `parseFloat` had on Slack's 16-sig-fig timestamps.
389
+ const ordered = [...messages].sort((a, b) => tsCmp(a.ts, b.ts));
370
390
  let dispatched = 0;
371
391
  let highestTs = wm?.lastTs ?? '0';
372
392
  for (const msg of ordered) {
373
393
  // Defense-in-depth: never re-dispatch ts <= watermark even if the
374
394
  // server returned an unexpected row.
375
- if (wm && parseFloat(msg.ts) <= parseFloat(wm.lastTs))
395
+ if (wm && tsLte(msg.ts, wm.lastTs))
376
396
  continue;
377
397
  const event = {
378
398
  ts: msg.ts,
@@ -390,7 +410,7 @@ export class SlackPollingClient {
390
410
  catch (err) {
391
411
  log.error(`dispatch error channel=${channelId} ts=${msg.ts}: ${describeErr(err)}`);
392
412
  }
393
- if (parseFloat(msg.ts) > parseFloat(highestTs)) {
413
+ if (tsGt(msg.ts, highestTs)) {
394
414
  highestTs = msg.ts;
395
415
  }
396
416
  // ─── Cheap thread-reply heuristic ───
@@ -404,7 +424,7 @@ export class SlackPollingClient {
404
424
  const parentTs = msg.ts;
405
425
  const replyCount = msg.reply_count ?? 0;
406
426
  const latestReply = msg.latest_reply;
407
- const repliesAreNew = !!latestReply && parseFloat(latestReply) > parseFloat(wm?.lastTs ?? '0');
427
+ const repliesAreNew = !!latestReply && tsGt(latestReply, wm?.lastTs ?? '0');
408
428
  if (replyCount > 0 && repliesAreNew) {
409
429
  const oldestForReplies = wm?.lastTs ?? parentTs;
410
430
  try {
@@ -416,10 +436,10 @@ export class SlackPollingClient {
416
436
  }));
417
437
  const replies = (repliesRes.messages ?? []).filter((r) => !!r && typeof r.ts === 'string' && r.ts !== parentTs);
418
438
  // conversations.replies returns oldest-first, but be defensive.
419
- const orderedReplies = [...replies].sort((a, b) => parseFloat(a.ts) - parseFloat(b.ts));
439
+ const orderedReplies = [...replies].sort((a, b) => tsCmp(a.ts, b.ts));
420
440
  for (const reply of orderedReplies) {
421
441
  // Defense: skip ts already at or below the watermark (handles overlap).
422
- if (wm && parseFloat(reply.ts) <= parseFloat(wm.lastTs))
442
+ if (wm && tsLte(reply.ts, wm.lastTs))
423
443
  continue;
424
444
  const replyEvent = {
425
445
  ts: reply.ts,
@@ -437,7 +457,7 @@ export class SlackPollingClient {
437
457
  catch (err) {
438
458
  log.error(`dispatch error (reply) channel=${channelId} ts=${reply.ts}: ${describeErr(err)}`);
439
459
  }
440
- if (parseFloat(reply.ts) > parseFloat(highestTs)) {
460
+ if (tsGt(reply.ts, highestTs)) {
441
461
  highestTs = reply.ts;
442
462
  }
443
463
  }
@@ -470,6 +490,131 @@ export class SlackPollingClient {
470
490
  }
471
491
  return dispatched;
472
492
  }
493
+ /**
494
+ * Search-mode cycle: pull every message (channels + DMs + thread replies)
495
+ * since "yesterday" with a single `search.messages` call (paginated). Each
496
+ * match is filtered against the allowlist (and keepAllDms for DMs), checked
497
+ * against the per-channel watermark, then dispatched. Watermark advances
498
+ * per channel just like the legacy path so a switch back to per-channel
499
+ * polling stays correct.
500
+ *
501
+ * Why "yesterday" and not the global watermark? Slack search's date
502
+ * operators are day-precision only (`after:YYYY-MM-DD`). We pick a 1-day
503
+ * lookback to make sure we always overlap with the last-seen ts. The
504
+ * per-channel watermark filter drops duplicates so we never re-dispatch.
505
+ */
506
+ async pollViaSearch() {
507
+ if (!this.webClient.search) {
508
+ log.error('useSearch=true but webClient has no `search.messages` method; bailing.');
509
+ return { dispatched: 0, pages: 0 };
510
+ }
511
+ const searchApi = this.webClient.search;
512
+ // Slack's date operators want YYYY-MM-DD. Use UTC; mismatched local TZ
513
+ // would shift the boundary by ≤1 day which is harmless (watermark filter
514
+ // dedupes anyway).
515
+ const yesterday = new Date(this.now() - 24 * 60 * 60 * 1000);
516
+ const y = yesterday.getUTCFullYear();
517
+ const m = String(yesterday.getUTCMonth() + 1).padStart(2, '0');
518
+ const d = String(yesterday.getUTCDate()).padStart(2, '0');
519
+ const query = `after:${y}-${m}-${d}`;
520
+ let dispatched = 0;
521
+ let page = 1;
522
+ const MAX_PAGES = 10; // ~1000 messages per cycle ceiling
523
+ // Track highest seen ts per channel so we advance watermark at the end.
524
+ const highestByChannel = new Map();
525
+ while (page <= MAX_PAGES) {
526
+ if (!this.running)
527
+ break;
528
+ let res;
529
+ try {
530
+ res = await this.paceCall(() => searchApi.messages({
531
+ query,
532
+ count: 100,
533
+ page,
534
+ sort: 'timestamp',
535
+ sort_dir: 'asc',
536
+ }));
537
+ }
538
+ catch (err) {
539
+ if (isInvalidAuthError(err))
540
+ throw err;
541
+ if (isRateLimitedError(err)) {
542
+ const seconds = readRetryAfter(err) ?? 60;
543
+ const until = this.now() + seconds * 1000;
544
+ if (until > this.globalPauseUntil)
545
+ this.globalPauseUntil = until;
546
+ log.warn(`429 on search.messages page=${page}, deferred ${seconds}s`);
547
+ break;
548
+ }
549
+ log.warn(`search.messages page=${page} failed: ${describeErr(err)}`);
550
+ break;
551
+ }
552
+ const matches = res.messages?.matches ?? [];
553
+ if (matches.length === 0)
554
+ break;
555
+ for (const match of matches) {
556
+ const channelId = match.channel?.id;
557
+ if (!channelId || !match.ts || typeof match.ts !== 'string')
558
+ continue;
559
+ // Allowlist filter: respect the same rule as the legacy path so the
560
+ // user's "only these channels + DMs" config still gates everything.
561
+ if (this.allowlistChannelIds.size > 0) {
562
+ const isDm = !!match.channel?.is_im || channelId.startsWith('D');
563
+ const allowed = this.allowlistChannelIds.has(channelId) ||
564
+ (this.keepAllDms && isDm);
565
+ if (!allowed)
566
+ continue;
567
+ }
568
+ // Watermark filter: drop anything we've already dispatched. Each
569
+ // channel keeps its own watermark so a switch back to per-channel
570
+ // polling later picks up exactly where search left off.
571
+ const wm = this.watermarkStore.get(channelId);
572
+ if (wm && tsLte(match.ts, wm.lastTs))
573
+ continue;
574
+ const seen = highestByChannel.get(channelId);
575
+ if (seen && tsLte(match.ts, seen))
576
+ continue;
577
+ const event = {
578
+ ts: match.ts,
579
+ thread_ts: match.thread_ts,
580
+ channel: channelId,
581
+ user: match.user,
582
+ text: match.text,
583
+ subtype: match.subtype,
584
+ files: Array.isArray(match.files) ? match.files : undefined,
585
+ };
586
+ try {
587
+ await this.dispatch(event);
588
+ dispatched += 1;
589
+ }
590
+ catch (err) {
591
+ log.error(`dispatch error (search) channel=${channelId} ts=${match.ts}: ${describeErr(err)}`);
592
+ }
593
+ if (!seen || tsGt(match.ts, seen)) {
594
+ highestByChannel.set(channelId, match.ts);
595
+ }
596
+ }
597
+ // Pagination control. Slack returns `pagination.page_count` or the
598
+ // legacy `paging.pages`; bail when we're past the last page or when
599
+ // matches < count (no more left).
600
+ const total = res.messages?.pagination?.page_count
601
+ ?? res.messages?.paging?.pages
602
+ ?? page;
603
+ if (matches.length < 100)
604
+ break;
605
+ if (page >= total)
606
+ break;
607
+ page += 1;
608
+ }
609
+ // Persist per-channel watermarks for everything we dispatched this cycle.
610
+ for (const [channelId, highest] of highestByChannel) {
611
+ const existing = this.watermarkStore.get(channelId)?.lastTs;
612
+ if (!existing || tsGt(highest, existing)) {
613
+ await this.watermarkStore.set(channelId, highest);
614
+ }
615
+ }
616
+ return { dispatched, pages: page };
617
+ }
473
618
  handleFatal(reason) {
474
619
  this.fatalError = reason;
475
620
  this.running = false;
@@ -520,3 +665,112 @@ async function mapWithConcurrency(items, concurrency, worker) {
520
665
  export function asPollingWebClient(client) {
521
666
  return client;
522
667
  }
668
+ // ─── Slack TS comparison helpers ───
669
+ //
670
+ // Slack timestamps are strings shaped like `XXXXXXXXXX.YYYYYY` — 10-digit
671
+ // seconds + 6-digit microseconds. Using `parseFloat()` on them silently loses
672
+ // precision in the last digits (JS doubles top out around 15–17 sig figs),
673
+ // which can cause two adjacent timestamps to compare equal and a real message
674
+ // to be silently dropped (`msg.ts <= wm.lastTs` accepting it as already-seen
675
+ // and `msg.ts > highestTs` refusing to advance the watermark). We use BigInt
676
+ // instead so the comparison is exact.
677
+ function tsToBigInt(ts) {
678
+ if (!ts)
679
+ return 0n;
680
+ const dot = ts.indexOf('.');
681
+ const sec = dot === -1 ? ts : ts.slice(0, dot);
682
+ const micro = dot === -1 ? '' : ts.slice(dot + 1);
683
+ // Pad / truncate microseconds to exactly 6 digits so concatenation always
684
+ // produces a consistent-magnitude integer.
685
+ const microPadded = (micro + '000000').slice(0, 6);
686
+ try {
687
+ return BigInt(sec + microPadded);
688
+ }
689
+ catch {
690
+ return 0n;
691
+ }
692
+ }
693
+ /** True if `a <= b`. */
694
+ function tsLte(a, b) {
695
+ return tsToBigInt(a) <= tsToBigInt(b);
696
+ }
697
+ /** True if `a > b`. */
698
+ function tsGt(a, b) {
699
+ return tsToBigInt(a) > tsToBigInt(b);
700
+ }
701
+ /** Compare two ts strings for Array.sort. Returns -1, 0, or 1. */
702
+ function tsCmp(a, b) {
703
+ const ab = tsToBigInt(a);
704
+ const bb = tsToBigInt(b);
705
+ return ab < bb ? -1 : ab > bb ? 1 : 0;
706
+ }
707
+ class TokenBucket {
708
+ tokens;
709
+ lastRefillMs;
710
+ waiters = [];
711
+ pumpScheduled = false;
712
+ capacity;
713
+ refillIntervalMs;
714
+ nowFn;
715
+ globalPauseUntilFn;
716
+ isRunningFn;
717
+ constructor(opts) {
718
+ this.capacity = Math.max(1, opts.capacity);
719
+ this.refillIntervalMs = Math.max(1, opts.refillIntervalMs);
720
+ this.nowFn = opts.now;
721
+ this.globalPauseUntilFn = opts.globalPauseUntil;
722
+ this.isRunningFn = opts.isRunning;
723
+ this.tokens = this.capacity;
724
+ this.lastRefillMs = opts.now();
725
+ }
726
+ refillNow() {
727
+ const elapsed = this.nowFn() - this.lastRefillMs;
728
+ if (elapsed <= 0)
729
+ return;
730
+ const gained = elapsed / this.refillIntervalMs;
731
+ if (gained > 0) {
732
+ this.tokens = Math.min(this.capacity, this.tokens + gained);
733
+ this.lastRefillMs = this.nowFn();
734
+ }
735
+ }
736
+ async acquire() {
737
+ if (!this.isRunningFn())
738
+ throw new Error('SlackPollingClient stopped');
739
+ // Wait through any global 429 pause before consuming a token so the
740
+ // entire workspace honors Retry-After.
741
+ const pauseRemaining = this.globalPauseUntilFn() - this.nowFn();
742
+ if (pauseRemaining > 0) {
743
+ await new Promise((r) => setTimeout(r, pauseRemaining));
744
+ if (!this.isRunningFn())
745
+ throw new Error('SlackPollingClient stopped');
746
+ }
747
+ this.refillNow();
748
+ if (this.tokens >= 1 && this.waiters.length === 0) {
749
+ this.tokens -= 1;
750
+ return;
751
+ }
752
+ return new Promise((resolve) => {
753
+ this.waiters.push(resolve);
754
+ this.schedulePump();
755
+ });
756
+ }
757
+ schedulePump() {
758
+ if (this.pumpScheduled)
759
+ return;
760
+ this.pumpScheduled = true;
761
+ const needed = Math.max(0, 1 - this.tokens);
762
+ const waitMs = Math.max(10, Math.ceil(needed * this.refillIntervalMs));
763
+ setTimeout(() => {
764
+ this.pumpScheduled = false;
765
+ this.refillNow();
766
+ while (this.tokens >= 1 && this.waiters.length > 0) {
767
+ this.tokens -= 1;
768
+ const resolve = this.waiters.shift();
769
+ resolve();
770
+ }
771
+ if (this.waiters.length > 0) {
772
+ this.schedulePump();
773
+ }
774
+ }, waitMs);
775
+ }
776
+ }
@@ -230,6 +230,22 @@ Disable the auto-ack by setting \`SLACK_REACT_ON_TRIGGER=false\` (accepts \`fals
230
230
  curl -s http://localhost:5174/api/slack/status
231
231
  \`\`\`
232
232
 
233
+ ## Reading Inbound Attachments from a Slack Trigger
234
+
235
+ When a Slack message that fires your trigger carries file attachments (\`file_share\`, screenshots, PDFs, etc.), Tide Commander downloads the bytes server-side so you don't have to fetch them yourself. The local paths are surfaced in your prompt template under \`{{slack.attachmentsBlock}}\` (and \`{{slack.filePaths}}\` for a flat comma list).
236
+
237
+ Each downloaded attachment renders as one line in the block:
238
+
239
+ \`\`\`
240
+ [attachment: /tmp/tide-commander-uploads/triggers/slack/<msgTs>-<fileId>/<filename> mimetype=image/png name=screenshot.png size=124300]
241
+ \`\`\`
242
+
243
+ Use the **Read** tool directly on that absolute path — do NOT call the Slack file API yourself. Files older than 24 hours are swept automatically.
244
+
245
+ If a file is too large (>25 MB) or the bot couldn't download it, the line becomes \`[attachment-skipped: too-large …]\` or \`[attachment-skipped: fetch-failed …]\` instead. In that case, surface the limitation to the user rather than silently ignoring the attachment.
246
+
247
+ Per-message cap: at most 10 files are downloaded; additional files are emitted as \`[attachment-skipped: unsupported …]\`.
248
+
233
249
  ## Notes
234
250
  - Channel IDs look like \`C0123456789\`. Use the list channels endpoint to find them.
235
251
  - Thread timestamps look like \`1234567890.123456\`.
@@ -10,6 +10,7 @@
10
10
  import * as slackClient from './slack-client.js';
11
11
  import { listInstances } from './slack-instance.js';
12
12
  import { listInstanceMetas } from './slack-instance-manifest.js';
13
+ import { formatAttachmentLine } from '../../services/attachment-downloader.js';
13
14
  const unsubscribers = [];
14
15
  /** Env toggle: set SLACK_REACT_ON_TRIGGER=false (or 0/no/off) to disable the auto-:eyes: ack. */
15
16
  function reactOnTriggerEnabled() {
@@ -90,10 +91,24 @@ export const slackTriggerHandler = {
90
91
  const msg = event.data;
91
92
  void trigger;
92
93
  const files = msg.files ?? [];
94
+ const downloaded = msg.attachmentPaths ?? [];
95
+ const skipped = msg.skippedAttachments ?? [];
93
96
  // Resolved channel label: `#name` / `DM con @user` / `Grupo: @a, @b…`.
94
97
  // Falls back to the raw id when the resolver couldn't fetch metadata
95
98
  // (network blip, missing scope, deleted channel).
96
99
  const channelLabel = msg.channelName || msg.channel;
100
+ // Build the "attachments block" agents can ingest verbatim — one line per
101
+ // downloaded file with absolute path, plus a line per skipped file with
102
+ // the reason. Empty string when there are no attachments.
103
+ const blockLines = [];
104
+ for (const att of downloaded) {
105
+ blockLines.push(formatAttachmentLine(att));
106
+ }
107
+ for (const sk of skipped) {
108
+ const sizeMb = typeof sk.size === 'number' ? `${Math.round(sk.size / (1024 * 1024))}MB` : 'unknown';
109
+ blockLines.push(`[attachment-skipped: ${sk.reason} name=${sk.filename ?? 'unknown'} size=${sizeMb}${sk.detail ? ` detail=${sk.detail}` : ''}]`);
110
+ }
111
+ const attachmentsBlock = blockLines.join('\n');
97
112
  return {
98
113
  'slack.user': msg.userName,
99
114
  // fromName/fromId are the boss-canonical names mirroring the Slack
@@ -113,6 +128,10 @@ export const slackTriggerHandler = {
113
128
  'slack.fileIds': files.map((f) => f.id).join(','),
114
129
  'slack.fileNames': files.map((f) => f.name ?? '').filter(Boolean).join(','),
115
130
  'slack.attachmentsList': files.map((f) => f.name ?? '').filter(Boolean).join(','),
131
+ // New: absolute paths and ready-to-paste attachments block. Additive —
132
+ // existing user templates that don't reference these vars keep working.
133
+ 'slack.filePaths': downloaded.map((a) => a.path).join(','),
134
+ 'slack.attachmentsBlock': attachmentsBlock,
116
135
  'slack.instanceId': msg.instanceId,
117
136
  'slack.instanceName': msg.instanceId,
118
137
  };
@@ -11,6 +11,9 @@ const DEFAULT_CONFIG = {
11
11
  baseUrl: 'http://localhost:3007',
12
12
  enrichContactName: true,
13
13
  showIncomingToasts: true,
14
+ transcribeAudio: true,
15
+ whisperModel: 'medium',
16
+ whisperLanguage: 'Spanish',
14
17
  updatedAt: 0,
15
18
  version: '1',
16
19
  };
@@ -82,6 +85,39 @@ export const whatsappConfigSchema = [
82
85
  defaultValue: true,
83
86
  group: 'General',
84
87
  },
88
+ {
89
+ key: 'transcribeAudio',
90
+ label: 'Transcribe inbound audio (whisper)',
91
+ type: 'boolean',
92
+ description: 'Run the local `whisper` CLI on inbound voice notes and audio attachments. Transcription is exposed as {{whatsapp.audioTranscription}} and appended to {{whatsapp.attachmentsBlock}}. Safe no-op if whisper is not installed.',
93
+ defaultValue: true,
94
+ group: 'Audio Transcription',
95
+ },
96
+ {
97
+ key: 'whisperModel',
98
+ label: 'Whisper model',
99
+ type: 'select',
100
+ description: 'Whisper model name. Larger models are more accurate but slower on CPU.',
101
+ defaultValue: 'medium',
102
+ options: [
103
+ { label: 'tiny (fastest)', value: 'tiny' },
104
+ { label: 'base', value: 'base' },
105
+ { label: 'small', value: 'small' },
106
+ { label: 'medium (default)', value: 'medium' },
107
+ { label: 'large', value: 'large' },
108
+ { label: 'turbo', value: 'turbo' },
109
+ ],
110
+ group: 'Audio Transcription',
111
+ },
112
+ {
113
+ key: 'whisperLanguage',
114
+ label: 'Whisper language',
115
+ type: 'text',
116
+ description: 'Hint for whisper (e.g. "Spanish", "English"). Leave blank to let whisper auto-detect — slower but works for mixed inboxes.',
117
+ defaultValue: 'Spanish',
118
+ placeholder: 'Spanish',
119
+ group: 'Audio Transcription',
120
+ },
85
121
  {
86
122
  key: 'baseUrl',
87
123
  label: 'WhatsApp API Base URL',
@@ -126,6 +162,9 @@ export function getConfigValues(secrets) {
126
162
  defaultSessionId: config.defaultSessionId || '',
127
163
  enrichContactName: config.enrichContactName !== false,
128
164
  showIncomingToasts: config.showIncomingToasts !== false,
165
+ transcribeAudio: config.transcribeAudio !== false,
166
+ whisperModel: config.whisperModel || 'medium',
167
+ whisperLanguage: config.whisperLanguage ?? 'Spanish',
129
168
  // Mask secret values for UI display
130
169
  whatsappApiKey: secrets.get(WHATSAPP_API_KEY_SECRET) ? '********' : '',
131
170
  webhookVerifyToken: config.webhookVerifyToken ? '********' : '',
@@ -151,6 +190,13 @@ export async function setConfigValues(values, secrets) {
151
190
  updates.enrichContactName = values.enrichContactName;
152
191
  if (typeof values.showIncomingToasts === 'boolean')
153
192
  updates.showIncomingToasts = values.showIncomingToasts;
193
+ if (typeof values.transcribeAudio === 'boolean')
194
+ updates.transcribeAudio = values.transcribeAudio;
195
+ if (typeof values.whisperModel === 'string' && values.whisperModel)
196
+ updates.whisperModel = values.whisperModel;
197
+ if (typeof values.whisperLanguage === 'string') {
198
+ updates.whisperLanguage = values.whisperLanguage;
199
+ }
154
200
  if (typeof values.webhookVerifyToken === 'string' &&
155
201
  values.webhookVerifyToken &&
156
202
  values.webhookVerifyToken !== '********') {
@@ -53,6 +53,18 @@ curl -s -X POST -H "X-Auth-Token: abcd" http://localhost:5174/api/whatsapp/send-
53
53
  -d '{"to":"5215532967210","mediaUrl":"https://example.com/cat.png","caption":"meow","type":"image"}'
54
54
  \`\`\`
55
55
 
56
+ ### 4. Read an attachment from an inbound WhatsApp trigger
57
+
58
+ When your trigger fires from an inbound WhatsApp message that has media, Tide Commander downloads the bytes server-side and surfaces the local path in your prompt template under \`{{whatsapp.attachmentsBlock}}\` (and \`{{whatsapp.attachmentPath}}\` individually). The block looks like:
59
+
60
+ \`\`\`
61
+ [attachment: /tmp/tide-commander-uploads/triggers/whatsapp/<msgId>/<filename> mimetype=image/jpeg name=photo.jpg size=124300]
62
+ \`\`\`
63
+
64
+ Use the **Read** tool directly on that path — do NOT try to fetch the original \`mediaUrl\` (it's an encrypted Meta CDN URL that you can't decrypt). Files older than 24 hours are swept automatically.
65
+
66
+ If the line says \`[attachment-skipped: too-large …]\` or \`[attachment-skipped: fetch-failed …]\` instead, the bytes were not saved and you should respond accordingly (ask the user to re-send, or proceed without the attachment).
67
+
56
68
  ---
57
69
 
58
70
  ## Send a Text Message (full reference)