tide-commander 1.91.0 → 1.92.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 (47) hide show
  1. package/dist/assets/{BossLogsModal-XsTxfWM8.js → BossLogsModal-BTTK3SDM.js} +1 -1
  2. package/dist/assets/{BossSpawnModal-DqQMPxHu.js → BossSpawnModal-BNbFVewB.js} +1 -1
  3. package/dist/assets/{ControlsModal-5mzDDdS5.js → ControlsModal-DnKdyYiv.js} +1 -1
  4. package/dist/assets/{DockerLogsModal-2eHlxyKa.js → DockerLogsModal-BRFhRQka.js} +1 -1
  5. package/dist/assets/{EmbeddedEditor-Bi9Ysd99.js → EmbeddedEditor-BFnSppyy.js} +1 -1
  6. package/dist/assets/{GmailOAuthSetup-5u85N8Br.js → GmailOAuthSetup-9uMrVRsz.js} +1 -1
  7. package/dist/assets/{GoogleOAuthSetup-OxT_QwZL.js → GoogleOAuthSetup-CLAy54pj.js} +1 -1
  8. package/dist/assets/{IframeModal-Bn1kdP1S.js → IframeModal-Cy5KHwqs.js} +1 -1
  9. package/dist/assets/{IntegrationsPanel-BehHkKJu.js → IntegrationsPanel-C_a9pmU1.js} +2 -2
  10. package/dist/assets/{LogViewerModal-JuUpWFPL.js → LogViewerModal-D68ppO94.js} +1 -1
  11. package/dist/assets/{MonitoringModal-CLk3uqDa.js → MonitoringModal-CoZ0uDPq.js} +1 -1
  12. package/dist/assets/{PM2LogsModal-C_NpOsos.js → PM2LogsModal-Bbwdouj-.js} +1 -1
  13. package/dist/assets/{RestoreArchivedAreaModal-Cbcg2Fm8.js → RestoreArchivedAreaModal-Dqi_ofXl.js} +1 -1
  14. package/dist/assets/{Scene2DCanvas-4C-jHERv.js → Scene2DCanvas-CnqwS4QZ.js} +1 -1
  15. package/dist/assets/{SceneManager-BoRV8xt3.js → SceneManager-W7pJrfEW.js} +1 -1
  16. package/dist/assets/{SkillsPanel-Bwk3UEY_.js → SkillsPanel-Cw-tVu6j.js} +1 -1
  17. package/dist/assets/{SlackMultiInstanceSetup-t-g3hdbr.js → SlackMultiInstanceSetup-DddkR8Zr.js} +1 -1
  18. package/dist/assets/{SpawnModal-BOXkPtaJ.js → SpawnModal-DZODgZFD.js} +1 -1
  19. package/dist/assets/{SubordinateAssignmentModal-CLHq5a9b.js → SubordinateAssignmentModal-BkhktU8i.js} +1 -1
  20. package/dist/assets/TriggerManagerPanel-YcbVJ2Rc.js +9 -0
  21. package/dist/assets/{WorkflowEditorPanel-Bevs1fpc.js → WorkflowEditorPanel-ZadWONKw.js} +1 -1
  22. package/dist/assets/{index-CiXA-Zp-.js → index-Bi8u2UlO.js} +1 -1
  23. package/dist/assets/{index-DxHwQ6CI.js → index-CF1hOKKa.js} +3 -3
  24. package/dist/assets/{index-CdKOXIM2.js → index-CN-XkbYd.js} +1 -1
  25. package/dist/assets/{index-CJuTMFz9.js → index-Ckn27_lZ.js} +1 -1
  26. package/dist/assets/{index-DBt10C9K.js → index-CtEyTmv-.js} +1 -1
  27. package/dist/assets/{index-H8kj1tuO.js → index-CyPV_nq1.js} +1 -1
  28. package/dist/assets/{index-Dd063aRs.js → index-DRsSsabz.js} +1 -1
  29. package/dist/assets/{index-B4JdUiAe.js → index-cK048gKM.js} +2 -2
  30. package/dist/assets/{index-vFrHpR5s.js → index-pqOlU6XT.js} +1 -1
  31. package/dist/assets/{main-5eyR3isL.js → main-jN7LzP4f.js} +95 -96
  32. package/dist/assets/{web-Cx_ySRHK.js → web-CfwxlY8x.js} +1 -1
  33. package/dist/assets/{web-DMjkVCWy.js → web-DZfyqisM.js} +1 -1
  34. package/dist/assets/{web-DGO1VHbi.js → web-DbhP1mtF.js} +1 -1
  35. package/dist/index.html +1 -1
  36. package/dist/src/packages/server/index.js +4 -0
  37. package/dist/src/packages/server/integrations/slack/slack-instance.js +89 -0
  38. package/dist/src/packages/server/integrations/slack/slack-polling-client.js +150 -42
  39. package/dist/src/packages/server/integrations/slack/slack-skill.js +16 -0
  40. package/dist/src/packages/server/integrations/slack/slack-trigger-handler.js +19 -0
  41. package/dist/src/packages/server/integrations/whatsapp/whatsapp-skill.js +12 -0
  42. package/dist/src/packages/server/integrations/whatsapp/whatsapp-trigger-handler.js +226 -16
  43. package/dist/src/packages/server/routes/files.js +4 -2
  44. package/dist/src/packages/server/services/attachment-downloader.js +317 -0
  45. package/dist/src/packages/server/services/attachment-janitor.js +110 -0
  46. package/package.json +1 -1
  47. package/dist/assets/TriggerManagerPanel-DuWagsLi.js +0 -3
@@ -1 +1 @@
1
- import{ck as s}from"./main-5eyR3isL.js";import"./vendor-react--Eh9ivFN.js";import"./vendor-three-Chj50gSY.js";class l extends s{constructor(){super(...arguments),this.pending=[],this.deliveredNotifications=[],this.hasNotificationSupport=()=>{if(!("Notification"in window)||!Notification.requestPermission)return!1;if(Notification.permission!=="granted")try{new Notification("")}catch(i){if(i instanceof Error&&i.name==="TypeError")return!1}return!0}}async getDeliveredNotifications(){const i=[];for(const t of this.deliveredNotifications){const e={title:t.title,id:parseInt(t.tag),body:t.body};i.push(e)}return{notifications:i}}async removeDeliveredNotifications(i){for(const t of i.notifications){const e=this.deliveredNotifications.find(n=>n.tag===String(t.id));e==null||e.close(),this.deliveredNotifications=this.deliveredNotifications.filter(()=>!e)}}async removeAllDeliveredNotifications(){for(const i of this.deliveredNotifications)i.close();this.deliveredNotifications=[]}async createChannel(){throw this.unimplemented("Not implemented on web.")}async deleteChannel(){throw this.unimplemented("Not implemented on web.")}async listChannels(){throw this.unimplemented("Not implemented on web.")}async schedule(i){if(!this.hasNotificationSupport())throw this.unavailable("Notifications not supported in this browser.");for(const t of i.notifications)this.sendNotification(t);return{notifications:i.notifications.map(t=>({id:t.id}))}}async getPending(){return{notifications:this.pending}}async registerActionTypes(){throw this.unimplemented("Not implemented on web.")}async cancel(i){this.pending=this.pending.filter(t=>!i.notifications.find(e=>e.id===t.id))}async areEnabled(){const{display:i}=await this.checkPermissions();return{value:i==="granted"}}async changeExactNotificationSetting(){throw this.unimplemented("Not implemented on web.")}async checkExactNotificationSetting(){throw this.unimplemented("Not implemented on web.")}async requestPermissions(){if(!this.hasNotificationSupport())throw this.unavailable("Notifications not supported in this browser.");return{display:this.transformNotificationPermission(await Notification.requestPermission())}}async checkPermissions(){if(!this.hasNotificationSupport())throw this.unavailable("Notifications not supported in this browser.");return{display:this.transformNotificationPermission(Notification.permission)}}transformNotificationPermission(i){switch(i){case"granted":return"granted";case"denied":return"denied";default:return"prompt"}}sendPending(){var i;const t=[],e=new Date().getTime();for(const n of this.pending)!((i=n.schedule)===null||i===void 0)&&i.at&&n.schedule.at.getTime()<=e&&(this.buildNotification(n),t.push(n));this.pending=this.pending.filter(n=>!t.find(o=>o===n))}sendNotification(i){var t;if(!((t=i.schedule)===null||t===void 0)&&t.at){const e=i.schedule.at.getTime()-new Date().getTime();this.pending.push(i),setTimeout(()=>{this.sendPending()},e);return}this.buildNotification(i)}buildNotification(i){const t=new Notification(i.title,{body:i.body,tag:String(i.id)});return t.addEventListener("click",this.onClick.bind(this,i),!1),t.addEventListener("show",this.onShow.bind(this,i),!1),t.addEventListener("close",()=>{this.deliveredNotifications=this.deliveredNotifications.filter(()=>!this)},!1),this.deliveredNotifications.push(t),t}onClick(i){const t={actionId:"tap",notification:i};this.notifyListeners("localNotificationActionPerformed",t)}onShow(i){this.notifyListeners("localNotificationReceived",i)}}export{l as LocalNotificationsWeb};
1
+ import{ck as s}from"./main-jN7LzP4f.js";import"./vendor-react--Eh9ivFN.js";import"./vendor-three-Chj50gSY.js";class l extends s{constructor(){super(...arguments),this.pending=[],this.deliveredNotifications=[],this.hasNotificationSupport=()=>{if(!("Notification"in window)||!Notification.requestPermission)return!1;if(Notification.permission!=="granted")try{new Notification("")}catch(i){if(i instanceof Error&&i.name==="TypeError")return!1}return!0}}async getDeliveredNotifications(){const i=[];for(const t of this.deliveredNotifications){const e={title:t.title,id:parseInt(t.tag),body:t.body};i.push(e)}return{notifications:i}}async removeDeliveredNotifications(i){for(const t of i.notifications){const e=this.deliveredNotifications.find(n=>n.tag===String(t.id));e==null||e.close(),this.deliveredNotifications=this.deliveredNotifications.filter(()=>!e)}}async removeAllDeliveredNotifications(){for(const i of this.deliveredNotifications)i.close();this.deliveredNotifications=[]}async createChannel(){throw this.unimplemented("Not implemented on web.")}async deleteChannel(){throw this.unimplemented("Not implemented on web.")}async listChannels(){throw this.unimplemented("Not implemented on web.")}async schedule(i){if(!this.hasNotificationSupport())throw this.unavailable("Notifications not supported in this browser.");for(const t of i.notifications)this.sendNotification(t);return{notifications:i.notifications.map(t=>({id:t.id}))}}async getPending(){return{notifications:this.pending}}async registerActionTypes(){throw this.unimplemented("Not implemented on web.")}async cancel(i){this.pending=this.pending.filter(t=>!i.notifications.find(e=>e.id===t.id))}async areEnabled(){const{display:i}=await this.checkPermissions();return{value:i==="granted"}}async changeExactNotificationSetting(){throw this.unimplemented("Not implemented on web.")}async checkExactNotificationSetting(){throw this.unimplemented("Not implemented on web.")}async requestPermissions(){if(!this.hasNotificationSupport())throw this.unavailable("Notifications not supported in this browser.");return{display:this.transformNotificationPermission(await Notification.requestPermission())}}async checkPermissions(){if(!this.hasNotificationSupport())throw this.unavailable("Notifications not supported in this browser.");return{display:this.transformNotificationPermission(Notification.permission)}}transformNotificationPermission(i){switch(i){case"granted":return"granted";case"denied":return"denied";default:return"prompt"}}sendPending(){var i;const t=[],e=new Date().getTime();for(const n of this.pending)!((i=n.schedule)===null||i===void 0)&&i.at&&n.schedule.at.getTime()<=e&&(this.buildNotification(n),t.push(n));this.pending=this.pending.filter(n=>!t.find(o=>o===n))}sendNotification(i){var t;if(!((t=i.schedule)===null||t===void 0)&&t.at){const e=i.schedule.at.getTime()-new Date().getTime();this.pending.push(i),setTimeout(()=>{this.sendPending()},e);return}this.buildNotification(i)}buildNotification(i){const t=new Notification(i.title,{body:i.body,tag:String(i.id)});return t.addEventListener("click",this.onClick.bind(this,i),!1),t.addEventListener("show",this.onShow.bind(this,i),!1),t.addEventListener("close",()=>{this.deliveredNotifications=this.deliveredNotifications.filter(()=>!this)},!1),this.deliveredNotifications.push(t),t}onClick(i){const t={actionId:"tap",notification:i};this.notifyListeners("localNotificationActionPerformed",t)}onShow(i){this.notifyListeners("localNotificationReceived",i)}}export{l as LocalNotificationsWeb};
@@ -1 +1 @@
1
- import{ck as t}from"./main-5eyR3isL.js";import"./vendor-react--Eh9ivFN.js";import"./vendor-three-Chj50gSY.js";class o extends t{constructor(){super(),this.handleVisibilityChange=()=>{const e={isActive:document.hidden!==!0};this.notifyListeners("appStateChange",e),document.hidden?this.notifyListeners("pause",null):this.notifyListeners("resume",null)},document.addEventListener("visibilitychange",this.handleVisibilityChange,!1)}exitApp(){throw this.unimplemented("Not implemented on web.")}async getInfo(){throw this.unimplemented("Not implemented on web.")}async getLaunchUrl(){return{url:""}}async getState(){return{isActive:document.hidden!==!0}}async minimizeApp(){throw this.unimplemented("Not implemented on web.")}async toggleBackButtonHandler(){throw this.unimplemented("Not implemented on web.")}async getAppLanguage(){return{value:navigator.language.split("-")[0].toLowerCase()}}}export{o as AppWeb};
1
+ import{ck as t}from"./main-jN7LzP4f.js";import"./vendor-react--Eh9ivFN.js";import"./vendor-three-Chj50gSY.js";class o extends t{constructor(){super(),this.handleVisibilityChange=()=>{const e={isActive:document.hidden!==!0};this.notifyListeners("appStateChange",e),document.hidden?this.notifyListeners("pause",null):this.notifyListeners("resume",null)},document.addEventListener("visibilitychange",this.handleVisibilityChange,!1)}exitApp(){throw this.unimplemented("Not implemented on web.")}async getInfo(){throw this.unimplemented("Not implemented on web.")}async getLaunchUrl(){return{url:""}}async getState(){return{isActive:document.hidden!==!0}}async minimizeApp(){throw this.unimplemented("Not implemented on web.")}async toggleBackButtonHandler(){throw this.unimplemented("Not implemented on web.")}async getAppLanguage(){return{value:navigator.language.split("-")[0].toLowerCase()}}}export{o as AppWeb};
@@ -1 +1 @@
1
- import{ck as a}from"./main-5eyR3isL.js";import{ImpactStyle as i,NotificationType as r}from"./index-B4JdUiAe.js";import"./vendor-react--Eh9ivFN.js";import"./vendor-three-Chj50gSY.js";class h extends a{constructor(){super(...arguments),this.selectionStarted=!1}async impact(t){const e=this.patternForImpact(t==null?void 0:t.style);this.vibrateWithPattern(e)}async notification(t){const e=this.patternForNotification(t==null?void 0:t.type);this.vibrateWithPattern(e)}async vibrate(t){const e=(t==null?void 0:t.duration)||300;this.vibrateWithPattern([e])}async selectionStart(){this.selectionStarted=!0}async selectionChanged(){this.selectionStarted&&this.vibrateWithPattern([70])}async selectionEnd(){this.selectionStarted=!1}patternForImpact(t=i.Heavy){return t===i.Medium?[43]:t===i.Light?[20]:[61]}patternForNotification(t=r.Success){return t===r.Warning?[30,40,30,50,60]:t===r.Error?[27,45,50]:[35,65,21]}vibrateWithPattern(t){if(navigator.vibrate)navigator.vibrate(t);else throw this.unavailable("Browser does not support the vibrate API")}}export{h as HapticsWeb};
1
+ import{ck as a}from"./main-jN7LzP4f.js";import{ImpactStyle as i,NotificationType as r}from"./index-cK048gKM.js";import"./vendor-react--Eh9ivFN.js";import"./vendor-three-Chj50gSY.js";class h extends a{constructor(){super(...arguments),this.selectionStarted=!1}async impact(t){const e=this.patternForImpact(t==null?void 0:t.style);this.vibrateWithPattern(e)}async notification(t){const e=this.patternForNotification(t==null?void 0:t.type);this.vibrateWithPattern(e)}async vibrate(t){const e=(t==null?void 0:t.duration)||300;this.vibrateWithPattern([e])}async selectionStart(){this.selectionStarted=!0}async selectionChanged(){this.selectionStarted&&this.vibrateWithPattern([70])}async selectionEnd(){this.selectionStarted=!1}patternForImpact(t=i.Heavy){return t===i.Medium?[43]:t===i.Light?[20]:[61]}patternForNotification(t=r.Success){return t===r.Warning?[30,40,30,50,60]:t===r.Error?[27,45,50]:[35,65,21]}vibrateWithPattern(t){if(navigator.vibrate)navigator.vibrate(t);else throw this.unavailable("Browser does not support the vibrate API")}}export{h as HapticsWeb};
package/dist/index.html CHANGED
@@ -22,7 +22,7 @@
22
22
  <link rel="icon" type="image/png" sizes="16x16" href="/assets/icons/favicon-16x16.png" />
23
23
  <link rel="apple-touch-icon" sizes="180x180" href="/assets/icons/apple-touch-icon.png" />
24
24
  <title>Tide Commander</title>
25
- <script type="module" crossorigin src="/assets/main-5eyR3isL.js"></script>
25
+ <script type="module" crossorigin src="/assets/main-jN7LzP4f.js"></script>
26
26
  <link rel="modulepreload" crossorigin href="/assets/vendor-react--Eh9ivFN.js">
27
27
  <link rel="modulepreload" crossorigin href="/assets/vendor-three-Chj50gSY.js">
28
28
  <link rel="stylesheet" crossorigin href="/assets/main-CrGeO0Sc.css">
@@ -16,6 +16,7 @@ import { logger, closeFileLogging, getLogFilePath, createLogger } from './utils/
16
16
  import { setupTerminalWsProxy } from './services/terminal-proxy.js';
17
17
  import { initIntegrations, shutdownIntegrations, getIntegrationTriggerHandlers } from './integrations/integration-registry.js';
18
18
  import { initBackupService, shutdownBackupService } from './services/backup-service.js';
19
+ import { initAttachmentJanitor, shutdownAttachmentJanitor } from './services/attachment-janitor.js';
19
20
  // Configuration
20
21
  const PORT = process.env.PORT || 6200;
21
22
  const HOST = process.env.HOST || (process.env.LISTEN_ALL_INTERFACES ? '::' : '127.0.0.1');
@@ -141,6 +142,8 @@ async function main() {
141
142
  }
142
143
  // Start hourly backup scheduler (reads persisted enabled/disabled setting)
143
144
  initBackupService();
145
+ // Start hourly sweeper for the trigger-attachment temp dir.
146
+ initAttachmentJanitor();
144
147
  logger.server.log(`Data directory: ${getDataDir()}`);
145
148
  logger.server.log(`Log file: ${getLogFilePath()}`);
146
149
  // Create Express app and HTTP server
@@ -203,6 +206,7 @@ async function main() {
203
206
  forceShutdownTimer.unref();
204
207
  try {
205
208
  shutdownBackupService();
209
+ shutdownAttachmentJanitor();
206
210
  triggerService.shutdown();
207
211
  workflowService.shutdown();
208
212
  await shutdownIntegrations();
@@ -14,6 +14,7 @@ import * as os from 'node:os';
14
14
  import path from 'node:path';
15
15
  import { LogLevel, WebClient } from '@slack/web-api';
16
16
  import { SocketModeClient } from '@slack/socket-mode';
17
+ import { downloadAttachment, MAX_ATTACHMENT_BYTES } from '../../services/attachment-downloader.js';
17
18
  import { instanceSecretKey, loadConfig, resolveAuthMode, updateConfig } from './slack-config.js';
18
19
  import { SlackPollingClient, asPollingWebClient } from './slack-polling-client.js';
19
20
  import { SlackWatermarkStore } from './slack-watermark-store.js';
@@ -287,6 +288,20 @@ export class SlackInstance {
287
288
  const files = hasFiles
288
289
  ? event.files.map((f) => normalizeSlackFile(f))
289
290
  : undefined;
291
+ // Download any attached files locally so triggered agents can read them
292
+ // directly with the Read tool (no need to re-fetch via Slack auth).
293
+ //
294
+ // We download regardless of direction. Earlier this branch had an
295
+ // `!isOwnMessage` filter, which broke the real-world case: when a Slack
296
+ // user uploads a file from their OWN workspace account (the one that
297
+ // owns the xoxp- token), Slack reports `direction='outbound'` and the
298
+ // attachment was silently skipped. Same root cause as the inbound-only
299
+ // filter that bit us on WhatsApp. If the bot itself (chat.postMessage
300
+ // with files) is the originator the echo lands here too — that self-
301
+ // fetch is benign and bounded by the 25 MB cap and the 24 h janitor.
302
+ const { attachmentPaths, skippedAttachments } = files && files.length > 0
303
+ ? await this.downloadInboundFiles(event.ts, files)
304
+ : { attachmentPaths: undefined, skippedAttachments: undefined };
290
305
  const message = {
291
306
  ts: event.ts,
292
307
  threadTs: event.thread_ts,
@@ -297,6 +312,8 @@ export class SlackInstance {
297
312
  text,
298
313
  timestamp: parseSlackTs(event.ts),
299
314
  files,
315
+ attachmentPaths,
316
+ skippedAttachments,
300
317
  isOwnMessage,
301
318
  };
302
319
  const direction = isOwnMessage ? 'outbound' : 'inbound';
@@ -358,6 +375,78 @@ export class SlackInstance {
358
375
  waiter.resolve(message);
359
376
  }
360
377
  }
378
+ /**
379
+ * Download up to MAX_INBOUND_FILES_PER_MESSAGE files locally so triggered
380
+ * agents can read them with the Read tool. Files beyond the cap, or whose
381
+ * size hint exceeds the 25 MB cap, are recorded under `skippedAttachments`.
382
+ * Never throws.
383
+ */
384
+ async downloadInboundFiles(messageTs, files) {
385
+ const MAX_INBOUND_FILES_PER_MESSAGE = 10;
386
+ const token = this.getSecret('SLACK_BOT_TOKEN');
387
+ if (!token) {
388
+ this.ctx?.log.warn(`Slack[${this.id}] cannot download inbound files — SLACK_BOT_TOKEN not configured`);
389
+ return { attachmentPaths: undefined, skippedAttachments: undefined };
390
+ }
391
+ const downloaded = [];
392
+ const skipped = [];
393
+ for (let i = 0; i < files.length; i++) {
394
+ const file = files[i];
395
+ if (i >= MAX_INBOUND_FILES_PER_MESSAGE) {
396
+ this.ctx?.log.info(`Slack[${this.id}] skipping file ${i + 1}/${files.length} — exceeds per-message cap`);
397
+ skipped.push({
398
+ reason: 'unsupported',
399
+ filename: file.name,
400
+ size: file.size,
401
+ detail: 'per-message cap (10 files)',
402
+ });
403
+ continue;
404
+ }
405
+ const url = file.url_private_download ?? file.url_private;
406
+ if (!url) {
407
+ skipped.push({
408
+ reason: 'fetch-failed',
409
+ filename: file.name,
410
+ size: file.size,
411
+ detail: 'no url_private',
412
+ });
413
+ continue;
414
+ }
415
+ if (typeof file.size === 'number' && file.size > MAX_ATTACHMENT_BYTES) {
416
+ skipped.push({
417
+ reason: 'too-large',
418
+ filename: file.name,
419
+ size: file.size,
420
+ sourceUrl: url,
421
+ });
422
+ continue;
423
+ }
424
+ const result = await downloadAttachment({
425
+ source: 'slack',
426
+ messageId: `${messageTs}-${file.id}`,
427
+ url,
428
+ headers: { Authorization: `Bearer ${token}` },
429
+ suggestedFilename: file.name,
430
+ mimetype: file.mimetype,
431
+ sizeHintBytes: file.size,
432
+ });
433
+ if (result) {
434
+ downloaded.push(result);
435
+ }
436
+ else {
437
+ skipped.push({
438
+ reason: 'fetch-failed',
439
+ filename: file.name,
440
+ size: file.size,
441
+ sourceUrl: url,
442
+ });
443
+ }
444
+ }
445
+ return {
446
+ attachmentPaths: downloaded.length > 0 ? downloaded : undefined,
447
+ skippedAttachments: skipped.length > 0 ? skipped : undefined,
448
+ };
449
+ }
361
450
  // ─── Sending ───
362
451
  async sendMessage(params) {
363
452
  if (!this.webClient)
@@ -77,9 +77,14 @@ export class SlackPollingClient {
77
77
  minMsBetweenCalls;
78
78
  scheduler;
79
79
  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. */
80
+ /** Token-bucket rate limiter shared by every paced API call. Concurrent
81
+ * workers really run in parallel — only the *rate* (tokens/sec) is capped.
82
+ * Replaces the older serial promise-chain gate which silently nullified
83
+ * the `concurrency` option (every paced call was forced to run one at a
84
+ * time spaced by `minMsBetweenCalls`, so `concurrency: 8` behaved like
85
+ * `concurrency: 1`). */
86
+ bucket = null;
87
+ /** When > now(), every paced call sleeps until that wall-clock ms. Set on 429. */
83
88
  globalPauseUntil = 0;
84
89
  running = false;
85
90
  timer = null;
@@ -108,51 +113,44 @@ export class SlackPollingClient {
108
113
  this.minMsBetweenCalls = Math.max(0, opts.minMsBetweenCalls ?? 0);
109
114
  this.scheduler = opts.scheduler ?? DEFAULT_SCHEDULER;
110
115
  this.now = opts.now ?? Date.now;
116
+ if (this.minMsBetweenCalls > 0) {
117
+ // Tier 3 spec: ~50/min sustained with bursts up to ~5/sec. Refill rate
118
+ // = minMsBetweenCalls (e.g. 1200ms → 50/min). Burst capacity caps the
119
+ // initial flood from a cycle wake-up without us having to throttle every
120
+ // single call serially.
121
+ this.bucket = new TokenBucket({
122
+ capacity: 5,
123
+ refillIntervalMs: this.minMsBetweenCalls,
124
+ now: this.now,
125
+ globalPauseUntil: () => this.globalPauseUntil,
126
+ isRunning: () => this.running,
127
+ });
128
+ }
111
129
  }
112
130
  /**
113
131
  * 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.
132
+ * go through here. With `minMsBetweenCalls > 0` we acquire a token from the
133
+ * shared bucket; multiple workers can hold tokens concurrently up to the
134
+ * burst capacity, so `concurrency: 8` actually parallelizes (unlike the
135
+ * older serial promise-chain gate). With `minMsBetweenCalls === 0` we are
136
+ * in test mode and skip throttling entirely.
116
137
  *
117
- * Honors `globalPauseUntil` set by 429 handlers so the entire client backs
118
- * off when Slack tells us to.
138
+ * The bucket honors `globalPauseUntil` set by 429 handlers so the entire
139
+ * client backs off when Slack tells us to.
119
140
  */
120
141
  async paceCall(fn) {
121
- // Stop() was called while we were queued — bail without consuming the
122
- // pacing slot or making the API call.
123
142
  if (!this.running) {
124
143
  throw new Error('SlackPollingClient stopped');
125
144
  }
126
- if (this.minMsBetweenCalls === 0 && this.globalPauseUntil <= this.now()) {
145
+ if (!this.bucket) {
146
+ // Test mode: no throttling at all.
127
147
  return fn();
128
148
  }
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.
149
+ await this.bucket.acquire();
134
150
  if (!this.running) {
135
- release();
136
151
  throw new Error('SlackPollingClient stopped');
137
152
  }
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
- }
153
+ return fn();
156
154
  }
157
155
  /** Subscribe to fatal-error notifications (e.g. invalid_auth). */
158
156
  setOnFatalError(cb) {
@@ -365,14 +363,15 @@ export class SlackPollingClient {
365
363
  if (messages.length === 0)
366
364
  return 0;
367
365
  // 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));
366
+ // listeners and dedupe see them in real-world order. BigInt sort avoids
367
+ // the precision loss `parseFloat` had on Slack's 16-sig-fig timestamps.
368
+ const ordered = [...messages].sort((a, b) => tsCmp(a.ts, b.ts));
370
369
  let dispatched = 0;
371
370
  let highestTs = wm?.lastTs ?? '0';
372
371
  for (const msg of ordered) {
373
372
  // Defense-in-depth: never re-dispatch ts <= watermark even if the
374
373
  // server returned an unexpected row.
375
- if (wm && parseFloat(msg.ts) <= parseFloat(wm.lastTs))
374
+ if (wm && tsLte(msg.ts, wm.lastTs))
376
375
  continue;
377
376
  const event = {
378
377
  ts: msg.ts,
@@ -390,7 +389,7 @@ export class SlackPollingClient {
390
389
  catch (err) {
391
390
  log.error(`dispatch error channel=${channelId} ts=${msg.ts}: ${describeErr(err)}`);
392
391
  }
393
- if (parseFloat(msg.ts) > parseFloat(highestTs)) {
392
+ if (tsGt(msg.ts, highestTs)) {
394
393
  highestTs = msg.ts;
395
394
  }
396
395
  // ─── Cheap thread-reply heuristic ───
@@ -404,7 +403,7 @@ export class SlackPollingClient {
404
403
  const parentTs = msg.ts;
405
404
  const replyCount = msg.reply_count ?? 0;
406
405
  const latestReply = msg.latest_reply;
407
- const repliesAreNew = !!latestReply && parseFloat(latestReply) > parseFloat(wm?.lastTs ?? '0');
406
+ const repliesAreNew = !!latestReply && tsGt(latestReply, wm?.lastTs ?? '0');
408
407
  if (replyCount > 0 && repliesAreNew) {
409
408
  const oldestForReplies = wm?.lastTs ?? parentTs;
410
409
  try {
@@ -416,10 +415,10 @@ export class SlackPollingClient {
416
415
  }));
417
416
  const replies = (repliesRes.messages ?? []).filter((r) => !!r && typeof r.ts === 'string' && r.ts !== parentTs);
418
417
  // conversations.replies returns oldest-first, but be defensive.
419
- const orderedReplies = [...replies].sort((a, b) => parseFloat(a.ts) - parseFloat(b.ts));
418
+ const orderedReplies = [...replies].sort((a, b) => tsCmp(a.ts, b.ts));
420
419
  for (const reply of orderedReplies) {
421
420
  // Defense: skip ts already at or below the watermark (handles overlap).
422
- if (wm && parseFloat(reply.ts) <= parseFloat(wm.lastTs))
421
+ if (wm && tsLte(reply.ts, wm.lastTs))
423
422
  continue;
424
423
  const replyEvent = {
425
424
  ts: reply.ts,
@@ -437,7 +436,7 @@ export class SlackPollingClient {
437
436
  catch (err) {
438
437
  log.error(`dispatch error (reply) channel=${channelId} ts=${reply.ts}: ${describeErr(err)}`);
439
438
  }
440
- if (parseFloat(reply.ts) > parseFloat(highestTs)) {
439
+ if (tsGt(reply.ts, highestTs)) {
441
440
  highestTs = reply.ts;
442
441
  }
443
442
  }
@@ -520,3 +519,112 @@ async function mapWithConcurrency(items, concurrency, worker) {
520
519
  export function asPollingWebClient(client) {
521
520
  return client;
522
521
  }
522
+ // ─── Slack TS comparison helpers ───
523
+ //
524
+ // Slack timestamps are strings shaped like `XXXXXXXXXX.YYYYYY` — 10-digit
525
+ // seconds + 6-digit microseconds. Using `parseFloat()` on them silently loses
526
+ // precision in the last digits (JS doubles top out around 15–17 sig figs),
527
+ // which can cause two adjacent timestamps to compare equal and a real message
528
+ // to be silently dropped (`msg.ts <= wm.lastTs` accepting it as already-seen
529
+ // and `msg.ts > highestTs` refusing to advance the watermark). We use BigInt
530
+ // instead so the comparison is exact.
531
+ function tsToBigInt(ts) {
532
+ if (!ts)
533
+ return 0n;
534
+ const dot = ts.indexOf('.');
535
+ const sec = dot === -1 ? ts : ts.slice(0, dot);
536
+ const micro = dot === -1 ? '' : ts.slice(dot + 1);
537
+ // Pad / truncate microseconds to exactly 6 digits so concatenation always
538
+ // produces a consistent-magnitude integer.
539
+ const microPadded = (micro + '000000').slice(0, 6);
540
+ try {
541
+ return BigInt(sec + microPadded);
542
+ }
543
+ catch {
544
+ return 0n;
545
+ }
546
+ }
547
+ /** True if `a <= b`. */
548
+ function tsLte(a, b) {
549
+ return tsToBigInt(a) <= tsToBigInt(b);
550
+ }
551
+ /** True if `a > b`. */
552
+ function tsGt(a, b) {
553
+ return tsToBigInt(a) > tsToBigInt(b);
554
+ }
555
+ /** Compare two ts strings for Array.sort. Returns -1, 0, or 1. */
556
+ function tsCmp(a, b) {
557
+ const ab = tsToBigInt(a);
558
+ const bb = tsToBigInt(b);
559
+ return ab < bb ? -1 : ab > bb ? 1 : 0;
560
+ }
561
+ class TokenBucket {
562
+ tokens;
563
+ lastRefillMs;
564
+ waiters = [];
565
+ pumpScheduled = false;
566
+ capacity;
567
+ refillIntervalMs;
568
+ nowFn;
569
+ globalPauseUntilFn;
570
+ isRunningFn;
571
+ constructor(opts) {
572
+ this.capacity = Math.max(1, opts.capacity);
573
+ this.refillIntervalMs = Math.max(1, opts.refillIntervalMs);
574
+ this.nowFn = opts.now;
575
+ this.globalPauseUntilFn = opts.globalPauseUntil;
576
+ this.isRunningFn = opts.isRunning;
577
+ this.tokens = this.capacity;
578
+ this.lastRefillMs = opts.now();
579
+ }
580
+ refillNow() {
581
+ const elapsed = this.nowFn() - this.lastRefillMs;
582
+ if (elapsed <= 0)
583
+ return;
584
+ const gained = elapsed / this.refillIntervalMs;
585
+ if (gained > 0) {
586
+ this.tokens = Math.min(this.capacity, this.tokens + gained);
587
+ this.lastRefillMs = this.nowFn();
588
+ }
589
+ }
590
+ async acquire() {
591
+ if (!this.isRunningFn())
592
+ throw new Error('SlackPollingClient stopped');
593
+ // Wait through any global 429 pause before consuming a token so the
594
+ // entire workspace honors Retry-After.
595
+ const pauseRemaining = this.globalPauseUntilFn() - this.nowFn();
596
+ if (pauseRemaining > 0) {
597
+ await new Promise((r) => setTimeout(r, pauseRemaining));
598
+ if (!this.isRunningFn())
599
+ throw new Error('SlackPollingClient stopped');
600
+ }
601
+ this.refillNow();
602
+ if (this.tokens >= 1 && this.waiters.length === 0) {
603
+ this.tokens -= 1;
604
+ return;
605
+ }
606
+ return new Promise((resolve) => {
607
+ this.waiters.push(resolve);
608
+ this.schedulePump();
609
+ });
610
+ }
611
+ schedulePump() {
612
+ if (this.pumpScheduled)
613
+ return;
614
+ this.pumpScheduled = true;
615
+ const needed = Math.max(0, 1 - this.tokens);
616
+ const waitMs = Math.max(10, Math.ceil(needed * this.refillIntervalMs));
617
+ setTimeout(() => {
618
+ this.pumpScheduled = false;
619
+ this.refillNow();
620
+ while (this.tokens >= 1 && this.waiters.length > 0) {
621
+ this.tokens -= 1;
622
+ const resolve = this.waiters.shift();
623
+ resolve();
624
+ }
625
+ if (this.waiters.length > 0) {
626
+ this.schedulePump();
627
+ }
628
+ }, waitMs);
629
+ }
630
+ }
@@ -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
  };
@@ -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)