tide-commander 1.92.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 (49) hide show
  1. package/dist/assets/{BossLogsModal-BTTK3SDM.js → BossLogsModal-CgILsm9V.js} +1 -1
  2. package/dist/assets/{BossSpawnModal-BNbFVewB.js → BossSpawnModal-BdGy2pC2.js} +1 -1
  3. package/dist/assets/{ControlsModal-DnKdyYiv.js → ControlsModal-hFIzwgt7.js} +1 -1
  4. package/dist/assets/{DockerLogsModal-BRFhRQka.js → DockerLogsModal-CDX9xVzn.js} +1 -1
  5. package/dist/assets/{EmbeddedEditor-BFnSppyy.js → EmbeddedEditor-2DZTiy_9.js} +1 -1
  6. package/dist/assets/{GmailOAuthSetup-9uMrVRsz.js → GmailOAuthSetup-Cq1DpIPF.js} +1 -1
  7. package/dist/assets/{GoogleOAuthSetup-CLAy54pj.js → GoogleOAuthSetup-B1UOVYlZ.js} +1 -1
  8. package/dist/assets/{IframeModal-Cy5KHwqs.js → IframeModal-GZkqd6O6.js} +1 -1
  9. package/dist/assets/{IntegrationsPanel-C_a9pmU1.js → IntegrationsPanel-CBN_0nAs.js} +2 -2
  10. package/dist/assets/{LogViewerModal-D68ppO94.js → LogViewerModal-BrZrAYvt.js} +1 -1
  11. package/dist/assets/{MonitoringModal-CoZ0uDPq.js → MonitoringModal-bIVunlhs.js} +1 -1
  12. package/dist/assets/{PM2LogsModal-Bbwdouj-.js → PM2LogsModal-Crs7Q2LK.js} +1 -1
  13. package/dist/assets/{RestoreArchivedAreaModal-Dqi_ofXl.js → RestoreArchivedAreaModal-9thnHbZ3.js} +1 -1
  14. package/dist/assets/{Scene2DCanvas-CnqwS4QZ.js → Scene2DCanvas-Bj7JLtW8.js} +1 -1
  15. package/dist/assets/{SceneManager-W7pJrfEW.js → SceneManager-C-ItjZnd.js} +1 -1
  16. package/dist/assets/{SkillsPanel-Cw-tVu6j.js → SkillsPanel-pTORcpQL.js} +1 -1
  17. package/dist/assets/{SlackMultiInstanceSetup-DddkR8Zr.js → SlackMultiInstanceSetup-FXuMr1vo.js} +1 -1
  18. package/dist/assets/{SpawnModal-DZODgZFD.js → SpawnModal-D3Pgos07.js} +1 -1
  19. package/dist/assets/{SubordinateAssignmentModal-BkhktU8i.js → SubordinateAssignmentModal-6au3Tv5w.js} +1 -1
  20. package/dist/assets/{TriggerManagerPanel-YcbVJ2Rc.js → TriggerManagerPanel-DletkqtL.js} +1 -1
  21. package/dist/assets/{WorkflowEditorPanel-ZadWONKw.js → WorkflowEditorPanel-DMu9KdjB.js} +1 -1
  22. package/dist/assets/{index-Ckn27_lZ.js → index-BGatvpcF.js} +1 -1
  23. package/dist/assets/{index-CN-XkbYd.js → index-BPox4QjF.js} +1 -1
  24. package/dist/assets/{index-CyPV_nq1.js → index-CQYizqu9.js} +1 -1
  25. package/dist/assets/{index-Bi8u2UlO.js → index-DAsi0YrR.js} +1 -1
  26. package/dist/assets/{index-DRsSsabz.js → index-DIsb3aYA.js} +1 -1
  27. package/dist/assets/{index-pqOlU6XT.js → index-Hl0I9IIt.js} +1 -1
  28. package/dist/assets/{index-CF1hOKKa.js → index-ZZtcJoNU.js} +33 -33
  29. package/dist/assets/{index-CtEyTmv-.js → index-_OjecyuG.js} +1 -1
  30. package/dist/assets/{index-cK048gKM.js → index-mEu7CM6i.js} +2 -2
  31. package/dist/assets/{main-jN7LzP4f.js → main-BUO6--48.js} +82 -80
  32. package/dist/assets/main-BfT_95fk.css +1 -0
  33. package/dist/assets/{web-DZfyqisM.js → web-BbNfUMzK.js} +1 -1
  34. package/dist/assets/{web-CfwxlY8x.js → web-CQsQBSkQ.js} +1 -1
  35. package/dist/assets/{web-DbhP1mtF.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/integrations/gmail/gmail-client.js +82 -1
  39. package/dist/src/packages/server/integrations/gmail/gmail-trigger-handler.js +61 -1
  40. package/dist/src/packages/server/integrations/slack/slack-config.js +13 -0
  41. package/dist/src/packages/server/integrations/slack/slack-instance.js +1 -0
  42. package/dist/src/packages/server/integrations/slack/slack-polling-client.js +146 -0
  43. package/dist/src/packages/server/integrations/whatsapp/whatsapp-config.js +46 -0
  44. package/dist/src/packages/server/integrations/whatsapp/whatsapp-trigger-handler.js +77 -20
  45. package/dist/src/packages/server/routes/files.js +52 -6
  46. package/dist/src/packages/server/services/audio-transcription.js +165 -0
  47. package/dist/src/packages/server/services/trigger-service.js +8 -5
  48. package/package.json +1 -1
  49. package/dist/assets/main-CrGeO0Sc.css +0 -1
@@ -1 +1 @@
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
+ 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-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
+ 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-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};
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-jN7LzP4f.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",
@@ -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
  }
@@ -184,6 +184,7 @@ export class SlackInstance {
184
184
  allowlistChannelIds,
185
185
  keepAllDms: config.pollingDmsAlways !== false,
186
186
  minMsBetweenCalls: config.pollingMinMsBetweenCalls ?? 1500,
187
+ useSearch: config.pollingUseSearch === true,
187
188
  });
188
189
  this.pollingClient.setOnFatalError((reason) => {
189
190
  updateConfig({ status: 'error', lastError: reason }, this.id);
@@ -75,6 +75,7 @@ export class SlackPollingClient {
75
75
  allowlistChannelIds;
76
76
  keepAllDms;
77
77
  minMsBetweenCalls;
78
+ useSearch;
78
79
  scheduler;
79
80
  now;
80
81
  /** Token-bucket rate limiter shared by every paced API call. Concurrent
@@ -111,6 +112,7 @@ export class SlackPollingClient {
111
112
  this.allowlistChannelIds = new Set((opts.allowlistChannelIds ?? []).map((s) => s.trim()).filter(Boolean));
112
113
  this.keepAllDms = opts.keepAllDms !== false;
113
114
  this.minMsBetweenCalls = Math.max(0, opts.minMsBetweenCalls ?? 0);
115
+ this.useSearch = opts.useSearch === true;
114
116
  this.scheduler = opts.scheduler ?? DEFAULT_SCHEDULER;
115
117
  this.now = opts.now ?? Date.now;
116
118
  if (this.minMsBetweenCalls > 0) {
@@ -231,6 +233,25 @@ export class SlackPollingClient {
231
233
  return;
232
234
  this.cycleCount += 1;
233
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
+ }
234
255
  // Refresh channel list on first cycle and every N cycles after.
235
256
  if (this.cycleCount === 1 || this.cycleCount % this.channelListRefreshEveryNCycles === 0) {
236
257
  try {
@@ -469,6 +490,131 @@ export class SlackPollingClient {
469
490
  }
470
491
  return dispatched;
471
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
+ }
472
618
  handleFatal(reason) {
473
619
  this.fatalError = reason;
474
620
  this.running = false;
@@ -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 !== '********') {