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
@@ -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-BUO6--48.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 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-BUO6--48.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 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-BUO6--48.js";import{ImpactStyle as i,NotificationType as r}from"./index-mEu7CM6i.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,10 +22,10 @@
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-BUO6--48.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
- <link rel="stylesheet" crossorigin href="/assets/main-CrGeO0Sc.css">
28
+ <link rel="stylesheet" crossorigin href="/assets/main-BfT_95fk.css">
29
29
  </head>
30
30
  <body>
31
31
  <div id="app"></div>
@@ -602,7 +602,8 @@
602
602
  "contextLabel": "Context",
603
603
  "percentFree": "{{percent}}% free",
604
604
  "msgsLabel": "msgs: {{tokens}}k",
605
- "clickForContextStats": "Click for context stats"
605
+ "clickForContextStats": "Click for context stats",
606
+ "compactedLabel": "Context compacted"
606
607
  },
607
608
  "controls": {
608
609
  "title": "Controls",
@@ -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();
@@ -339,13 +339,24 @@ function parseGmailMessage(msg) {
339
339
  }
340
340
  }
341
341
  extractParts(msg.payload);
342
- // Check attachments
342
+ // Check attachments — capture filename AND the metadata we need to fetch
343
+ // the bytes later via gmail.users.messages.attachments.get.
343
344
  const attachmentNames = [];
345
+ const attachmentsMeta = [];
344
346
  function findAttachments(payload) {
345
347
  if (!payload)
346
348
  return;
347
349
  if (payload.filename && payload.filename.length > 0) {
348
350
  attachmentNames.push(payload.filename);
351
+ const attachmentId = payload.body?.attachmentId;
352
+ if (attachmentId) {
353
+ attachmentsMeta.push({
354
+ attachmentId,
355
+ filename: payload.filename,
356
+ mimeType: payload.mimeType || 'application/octet-stream',
357
+ size: payload.body?.size ?? 0,
358
+ });
359
+ }
349
360
  }
350
361
  if (payload.parts) {
351
362
  for (const part of payload.parts) {
@@ -369,8 +380,78 @@ function parseGmailMessage(msg) {
369
380
  labels: msg.labelIds || undefined,
370
381
  hasAttachments: attachmentNames.length > 0,
371
382
  attachmentNames: attachmentNames.length > 0 ? attachmentNames : undefined,
383
+ attachmentsMeta: attachmentsMeta.length > 0 ? attachmentsMeta : undefined,
372
384
  };
373
385
  }
386
+ /**
387
+ * Download a single Gmail attachment by id and persist it to disk under
388
+ * `<TEMP_DIR>/triggers/gmail/<messageId>/<sanitized-filename>`. Mirrors what
389
+ * the WA/Slack pipelines do with `attachment-downloader.downloadAttachment`,
390
+ * but Gmail's binary doesn't come from a URL — it comes from the SDK call
391
+ * `gmail.users.messages.attachments.get` which returns base64url-encoded
392
+ * bytes. Hard 25 MB cap, idempotent, never throws.
393
+ */
394
+ export async function downloadGmailAttachment(messageId, meta) {
395
+ if (!gmail) {
396
+ ctx?.log.warn('Gmail not authenticated — skipping attachment download');
397
+ return null;
398
+ }
399
+ // Lazy-import the shared cap + temp root so we keep one source of truth.
400
+ const { MAX_ATTACHMENT_BYTES, TRIGGER_ATTACHMENT_ROOT } = await import('../../services/attachment-downloader.js');
401
+ if (meta.size > MAX_ATTACHMENT_BYTES) {
402
+ ctx?.log.warn(`Gmail attachment ${meta.filename} (${meta.size}B) exceeds cap; skipping`);
403
+ return null;
404
+ }
405
+ const path = await import('path');
406
+ const fs = await import('fs/promises');
407
+ // Sanitize segments the same way attachment-downloader does — strip
408
+ // path separators / `..` / control chars so a hostile filename can't
409
+ // escape the trigger dir.
410
+ const safe = (s) => s.replace(/[\\/\x00-\x1f\x7f]/g, '_').replace(/\.{2,}/g, '_').slice(0, 200) || 'file';
411
+ const targetDir = path.join(TRIGGER_ATTACHMENT_ROOT, 'gmail', safe(messageId));
412
+ const finalName = safe(meta.filename) || 'file';
413
+ const targetPath = path.join(targetDir, finalName);
414
+ // Idempotency: if a file with the expected size already lives at the
415
+ // target path, skip the network round-trip.
416
+ try {
417
+ const st = await fs.stat(targetPath);
418
+ if (st.isFile() && (meta.size === 0 || st.size === meta.size)) {
419
+ return { path: targetPath, bytesOnDisk: st.size, filename: finalName, mimeType: meta.mimeType };
420
+ }
421
+ }
422
+ catch { /* not cached */ }
423
+ await fs.mkdir(targetDir, { recursive: true });
424
+ let res;
425
+ try {
426
+ res = await gmail.users.messages.attachments.get({
427
+ userId: 'me',
428
+ messageId,
429
+ id: meta.attachmentId,
430
+ });
431
+ }
432
+ catch (err) {
433
+ ctx?.log.warn(`Gmail attachments.get failed for ${meta.filename}: ${err}`);
434
+ return null;
435
+ }
436
+ const b64 = res.data?.data;
437
+ if (!b64) {
438
+ ctx?.log.warn(`Gmail attachment ${meta.filename} returned empty data`);
439
+ return null;
440
+ }
441
+ const buf = Buffer.from(b64, 'base64url');
442
+ if (buf.byteLength > MAX_ATTACHMENT_BYTES) {
443
+ ctx?.log.warn(`Gmail attachment ${meta.filename} decoded to ${buf.byteLength}B (exceeds cap)`);
444
+ return null;
445
+ }
446
+ try {
447
+ await fs.writeFile(targetPath, buf);
448
+ }
449
+ catch (err) {
450
+ ctx?.log.warn(`Gmail attachment write failed for ${targetPath}: ${err}`);
451
+ return null;
452
+ }
453
+ return { path: targetPath, bytesOnDisk: buf.byteLength, filename: finalName, mimeType: meta.mimeType };
454
+ }
374
455
  export async function getThread(threadId) {
375
456
  if (!gmail)
376
457
  throw new Error('Gmail not authenticated');
@@ -4,11 +4,37 @@
4
4
  * Delegates event listening to gmail-client's polling and onNewMessage callback.
5
5
  */
6
6
  import * as gmailClient from './gmail-client.js';
7
+ import { formatAttachmentLine } from '../../services/attachment-downloader.js';
8
+ /** Labels we always drop unless the trigger explicitly opts in. Gmail tags
9
+ * these on messages the user has already classified as junk. */
10
+ const ALWAYS_DROP_LABELS = new Set(['SPAM', 'TRASH']);
7
11
  let unsubscribe = null;
8
12
  export const gmailTriggerHandler = {
9
13
  triggerType: 'email',
10
14
  async startListening(onEvent) {
11
- unsubscribe = gmailClient.onNewMessage((message) => {
15
+ unsubscribe = gmailClient.onNewMessage(async (message) => {
16
+ // Download any attachments BEFORE we hand the event to the trigger
17
+ // service so `{{email.attachmentsBlock}}` is populated when the
18
+ // template renders. Mirrors the WA/Slack pipelines — the agent gets
19
+ // a local path it can open with the Read tool, not just a filename.
20
+ if (message.attachmentsMeta && message.attachmentsMeta.length > 0) {
21
+ const downloaded = [];
22
+ for (const meta of message.attachmentsMeta) {
23
+ const result = await gmailClient.downloadGmailAttachment(message.messageId, meta);
24
+ if (result) {
25
+ downloaded.push({
26
+ ...meta,
27
+ filename: result.filename,
28
+ mimeType: result.mimeType,
29
+ path: result.path,
30
+ bytesOnDisk: result.bytesOnDisk,
31
+ });
32
+ }
33
+ }
34
+ if (downloaded.length > 0) {
35
+ message.downloadedAttachments = downloaded;
36
+ }
37
+ }
12
38
  onEvent({
13
39
  source: 'email',
14
40
  type: 'message',
@@ -26,6 +52,24 @@ export const gmailTriggerHandler = {
26
52
  structuralMatch(trigger, event) {
27
53
  const msg = event.data;
28
54
  const config = trigger.config;
55
+ const labels = msg.labels ?? [];
56
+ // Always drop SPAM / TRASH unless this trigger explicitly opted in to
57
+ // spam delivery. Saves the cost of LLM evaluation / agent prompts for
58
+ // junk that the user has already classified.
59
+ if (!config.includeSpam) {
60
+ for (const lbl of labels) {
61
+ if (ALWAYS_DROP_LABELS.has(lbl))
62
+ return false;
63
+ }
64
+ }
65
+ // Per-trigger extra exclusions (e.g. CATEGORY_PROMOTIONS).
66
+ if (config.excludeLabels?.length) {
67
+ const exclude = new Set(config.excludeLabels);
68
+ for (const lbl of labels) {
69
+ if (exclude.has(lbl))
70
+ return false;
71
+ }
72
+ }
29
73
  if (config.fromFilter?.length) {
30
74
  const fromLower = msg.from.toLowerCase();
31
75
  if (!config.fromFilter.some(f => fromLower.includes(f.toLowerCase())))
@@ -51,6 +95,19 @@ export const gmailTriggerHandler = {
51
95
  // Gmail tags sent messages with the SENT label. Anything else is treated
52
96
  // as inbound (covers INBOX, drafts, all-mail, etc.).
53
97
  const direction = labels.includes('SENT') ? 'outbound' : 'inbound';
98
+ // Build the attachments block from the downloaded files. Each line is
99
+ // shaped the same way as the WA / Slack pipelines so the existing
100
+ // `AttachmentChip` parser on the frontend picks them up unchanged.
101
+ const downloaded = msg.downloadedAttachments ?? [];
102
+ const attachmentsBlock = downloaded
103
+ .map((a) => formatAttachmentLine({
104
+ path: a.path,
105
+ filename: a.filename,
106
+ mimetype: a.mimeType,
107
+ size: a.bytesOnDisk,
108
+ sourceUrl: `gmail://attachment/${msg.messageId}/${a.attachmentId}`,
109
+ }))
110
+ .join('\n');
54
111
  return {
55
112
  'email.from': msg.from,
56
113
  'email.to': msg.to.join(', '),
@@ -61,6 +118,9 @@ export const gmailTriggerHandler = {
61
118
  'email.date': new Date(msg.date).toISOString(),
62
119
  'email.hasAttachments': String(msg.hasAttachments),
63
120
  'email.attachments': msg.attachmentNames?.join(', ') || '',
121
+ // New: local downloaded paths + ready-to-render attachment chips block.
122
+ 'email.filePaths': downloaded.map((a) => a.path).join(','),
123
+ 'email.attachmentsBlock': attachmentsBlock,
64
124
  'email.direction': direction,
65
125
  'email.labels': labels.join(', '),
66
126
  };
@@ -17,6 +17,7 @@ const DEFAULT_CONFIG = {
17
17
  pollingChannelAllowlist: '',
18
18
  pollingDmsAlways: true,
19
19
  pollingMinMsBetweenCalls: 1500,
20
+ pollingUseSearch: false,
20
21
  mirrorOwnMessages: false,
21
22
  currentMode: 'none',
22
23
  };
@@ -184,6 +185,14 @@ export const slackConfigSchema = [
184
185
  defaultValue: true,
185
186
  group: 'Polling',
186
187
  },
188
+ {
189
+ key: 'pollingUseSearch',
190
+ label: 'Use search.messages (catches thread replies)',
191
+ type: 'boolean',
192
+ description: 'When on, polling uses a single search.messages call per cycle instead of one conversations.history per channel. Catches replies on old threads that the per-channel sweep misses. Allowlist + DM filter still apply. Requires the user (xoxp-) token to have the search:read scope. Default off.',
193
+ defaultValue: false,
194
+ group: 'Polling',
195
+ },
187
196
  {
188
197
  key: 'mirrorOwnMessages',
189
198
  label: 'Mirror messages I send too',
@@ -232,6 +241,7 @@ export function getConfigValues(secrets, instanceId = DEFAULT_INSTANCE_ID) {
232
241
  pollingChannelAllowlist: config.pollingChannelAllowlist ?? '',
233
242
  pollingDmsAlways: config.pollingDmsAlways ?? true,
234
243
  pollingMinMsBetweenCalls: config.pollingMinMsBetweenCalls ?? 1500,
244
+ pollingUseSearch: config.pollingUseSearch ?? false,
235
245
  mirrorOwnMessages: config.mirrorOwnMessages ?? false,
236
246
  currentMode: config.currentMode ?? 'none',
237
247
  // Mask secret values for UI display
@@ -280,6 +290,9 @@ export async function setConfigValues(values, secrets, instanceId = DEFAULT_INST
280
290
  if (typeof values.pollingMinMsBetweenCalls === 'number' && Number.isFinite(values.pollingMinMsBetweenCalls)) {
281
291
  updates.pollingMinMsBetweenCalls = Math.max(0, values.pollingMinMsBetweenCalls);
282
292
  }
293
+ if (typeof values.pollingUseSearch === 'boolean') {
294
+ updates.pollingUseSearch = values.pollingUseSearch;
295
+ }
283
296
  if (typeof values.mirrorOwnMessages === 'boolean') {
284
297
  updates.mirrorOwnMessages = values.mirrorOwnMessages;
285
298
  }
@@ -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';
@@ -183,6 +184,7 @@ export class SlackInstance {
183
184
  allowlistChannelIds,
184
185
  keepAllDms: config.pollingDmsAlways !== false,
185
186
  minMsBetweenCalls: config.pollingMinMsBetweenCalls ?? 1500,
187
+ useSearch: config.pollingUseSearch === true,
186
188
  });
187
189
  this.pollingClient.setOnFatalError((reason) => {
188
190
  updateConfig({ status: 'error', lastError: reason }, this.id);
@@ -287,6 +289,20 @@ export class SlackInstance {
287
289
  const files = hasFiles
288
290
  ? event.files.map((f) => normalizeSlackFile(f))
289
291
  : undefined;
292
+ // Download any attached files locally so triggered agents can read them
293
+ // directly with the Read tool (no need to re-fetch via Slack auth).
294
+ //
295
+ // We download regardless of direction. Earlier this branch had an
296
+ // `!isOwnMessage` filter, which broke the real-world case: when a Slack
297
+ // user uploads a file from their OWN workspace account (the one that
298
+ // owns the xoxp- token), Slack reports `direction='outbound'` and the
299
+ // attachment was silently skipped. Same root cause as the inbound-only
300
+ // filter that bit us on WhatsApp. If the bot itself (chat.postMessage
301
+ // with files) is the originator the echo lands here too — that self-
302
+ // fetch is benign and bounded by the 25 MB cap and the 24 h janitor.
303
+ const { attachmentPaths, skippedAttachments } = files && files.length > 0
304
+ ? await this.downloadInboundFiles(event.ts, files)
305
+ : { attachmentPaths: undefined, skippedAttachments: undefined };
290
306
  const message = {
291
307
  ts: event.ts,
292
308
  threadTs: event.thread_ts,
@@ -297,6 +313,8 @@ export class SlackInstance {
297
313
  text,
298
314
  timestamp: parseSlackTs(event.ts),
299
315
  files,
316
+ attachmentPaths,
317
+ skippedAttachments,
300
318
  isOwnMessage,
301
319
  };
302
320
  const direction = isOwnMessage ? 'outbound' : 'inbound';
@@ -358,6 +376,78 @@ export class SlackInstance {
358
376
  waiter.resolve(message);
359
377
  }
360
378
  }
379
+ /**
380
+ * Download up to MAX_INBOUND_FILES_PER_MESSAGE files locally so triggered
381
+ * agents can read them with the Read tool. Files beyond the cap, or whose
382
+ * size hint exceeds the 25 MB cap, are recorded under `skippedAttachments`.
383
+ * Never throws.
384
+ */
385
+ async downloadInboundFiles(messageTs, files) {
386
+ const MAX_INBOUND_FILES_PER_MESSAGE = 10;
387
+ const token = this.getSecret('SLACK_BOT_TOKEN');
388
+ if (!token) {
389
+ this.ctx?.log.warn(`Slack[${this.id}] cannot download inbound files — SLACK_BOT_TOKEN not configured`);
390
+ return { attachmentPaths: undefined, skippedAttachments: undefined };
391
+ }
392
+ const downloaded = [];
393
+ const skipped = [];
394
+ for (let i = 0; i < files.length; i++) {
395
+ const file = files[i];
396
+ if (i >= MAX_INBOUND_FILES_PER_MESSAGE) {
397
+ this.ctx?.log.info(`Slack[${this.id}] skipping file ${i + 1}/${files.length} — exceeds per-message cap`);
398
+ skipped.push({
399
+ reason: 'unsupported',
400
+ filename: file.name,
401
+ size: file.size,
402
+ detail: 'per-message cap (10 files)',
403
+ });
404
+ continue;
405
+ }
406
+ const url = file.url_private_download ?? file.url_private;
407
+ if (!url) {
408
+ skipped.push({
409
+ reason: 'fetch-failed',
410
+ filename: file.name,
411
+ size: file.size,
412
+ detail: 'no url_private',
413
+ });
414
+ continue;
415
+ }
416
+ if (typeof file.size === 'number' && file.size > MAX_ATTACHMENT_BYTES) {
417
+ skipped.push({
418
+ reason: 'too-large',
419
+ filename: file.name,
420
+ size: file.size,
421
+ sourceUrl: url,
422
+ });
423
+ continue;
424
+ }
425
+ const result = await downloadAttachment({
426
+ source: 'slack',
427
+ messageId: `${messageTs}-${file.id}`,
428
+ url,
429
+ headers: { Authorization: `Bearer ${token}` },
430
+ suggestedFilename: file.name,
431
+ mimetype: file.mimetype,
432
+ sizeHintBytes: file.size,
433
+ });
434
+ if (result) {
435
+ downloaded.push(result);
436
+ }
437
+ else {
438
+ skipped.push({
439
+ reason: 'fetch-failed',
440
+ filename: file.name,
441
+ size: file.size,
442
+ sourceUrl: url,
443
+ });
444
+ }
445
+ }
446
+ return {
447
+ attachmentPaths: downloaded.length > 0 ? downloaded : undefined,
448
+ skippedAttachments: skipped.length > 0 ? skipped : undefined,
449
+ };
450
+ }
361
451
  // ─── Sending ───
362
452
  async sendMessage(params) {
363
453
  if (!this.webClient)