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.
- package/dist/assets/{BossLogsModal-XsTxfWM8.js → BossLogsModal-CgILsm9V.js} +1 -1
- package/dist/assets/{BossSpawnModal-DqQMPxHu.js → BossSpawnModal-BdGy2pC2.js} +1 -1
- package/dist/assets/{ControlsModal-5mzDDdS5.js → ControlsModal-hFIzwgt7.js} +1 -1
- package/dist/assets/{DockerLogsModal-2eHlxyKa.js → DockerLogsModal-CDX9xVzn.js} +1 -1
- package/dist/assets/{EmbeddedEditor-Bi9Ysd99.js → EmbeddedEditor-2DZTiy_9.js} +1 -1
- package/dist/assets/{GmailOAuthSetup-5u85N8Br.js → GmailOAuthSetup-Cq1DpIPF.js} +1 -1
- package/dist/assets/{GoogleOAuthSetup-OxT_QwZL.js → GoogleOAuthSetup-B1UOVYlZ.js} +1 -1
- package/dist/assets/{IframeModal-Bn1kdP1S.js → IframeModal-GZkqd6O6.js} +1 -1
- package/dist/assets/{IntegrationsPanel-BehHkKJu.js → IntegrationsPanel-CBN_0nAs.js} +2 -2
- package/dist/assets/{LogViewerModal-JuUpWFPL.js → LogViewerModal-BrZrAYvt.js} +1 -1
- package/dist/assets/{MonitoringModal-CLk3uqDa.js → MonitoringModal-bIVunlhs.js} +1 -1
- package/dist/assets/{PM2LogsModal-C_NpOsos.js → PM2LogsModal-Crs7Q2LK.js} +1 -1
- package/dist/assets/{RestoreArchivedAreaModal-Cbcg2Fm8.js → RestoreArchivedAreaModal-9thnHbZ3.js} +1 -1
- package/dist/assets/{Scene2DCanvas-4C-jHERv.js → Scene2DCanvas-Bj7JLtW8.js} +1 -1
- package/dist/assets/{SceneManager-BoRV8xt3.js → SceneManager-C-ItjZnd.js} +1 -1
- package/dist/assets/{SkillsPanel-Bwk3UEY_.js → SkillsPanel-pTORcpQL.js} +1 -1
- package/dist/assets/{SlackMultiInstanceSetup-t-g3hdbr.js → SlackMultiInstanceSetup-FXuMr1vo.js} +1 -1
- package/dist/assets/{SpawnModal-BOXkPtaJ.js → SpawnModal-D3Pgos07.js} +1 -1
- package/dist/assets/{SubordinateAssignmentModal-CLHq5a9b.js → SubordinateAssignmentModal-6au3Tv5w.js} +1 -1
- package/dist/assets/TriggerManagerPanel-DletkqtL.js +9 -0
- package/dist/assets/{WorkflowEditorPanel-Bevs1fpc.js → WorkflowEditorPanel-DMu9KdjB.js} +1 -1
- package/dist/assets/{index-CJuTMFz9.js → index-BGatvpcF.js} +1 -1
- package/dist/assets/{index-CdKOXIM2.js → index-BPox4QjF.js} +1 -1
- package/dist/assets/{index-H8kj1tuO.js → index-CQYizqu9.js} +1 -1
- package/dist/assets/{index-CiXA-Zp-.js → index-DAsi0YrR.js} +1 -1
- package/dist/assets/{index-Dd063aRs.js → index-DIsb3aYA.js} +1 -1
- package/dist/assets/{index-vFrHpR5s.js → index-Hl0I9IIt.js} +1 -1
- package/dist/assets/{index-DxHwQ6CI.js → index-ZZtcJoNU.js} +33 -33
- package/dist/assets/{index-DBt10C9K.js → index-_OjecyuG.js} +1 -1
- package/dist/assets/{index-B4JdUiAe.js → index-mEu7CM6i.js} +2 -2
- package/dist/assets/{main-5eyR3isL.js → main-BUO6--48.js} +99 -98
- package/dist/assets/main-BfT_95fk.css +1 -0
- package/dist/assets/{web-DMjkVCWy.js → web-BbNfUMzK.js} +1 -1
- package/dist/assets/{web-Cx_ySRHK.js → web-CQsQBSkQ.js} +1 -1
- package/dist/assets/{web-DGO1VHbi.js → web-bKiHKTT6.js} +1 -1
- package/dist/index.html +2 -2
- package/dist/locales/en/terminal.json +2 -1
- package/dist/src/packages/server/index.js +4 -0
- package/dist/src/packages/server/integrations/gmail/gmail-client.js +82 -1
- package/dist/src/packages/server/integrations/gmail/gmail-trigger-handler.js +61 -1
- package/dist/src/packages/server/integrations/slack/slack-config.js +13 -0
- package/dist/src/packages/server/integrations/slack/slack-instance.js +90 -0
- package/dist/src/packages/server/integrations/slack/slack-polling-client.js +296 -42
- package/dist/src/packages/server/integrations/slack/slack-skill.js +16 -0
- package/dist/src/packages/server/integrations/slack/slack-trigger-handler.js +19 -0
- package/dist/src/packages/server/integrations/whatsapp/whatsapp-config.js +46 -0
- package/dist/src/packages/server/integrations/whatsapp/whatsapp-skill.js +12 -0
- package/dist/src/packages/server/integrations/whatsapp/whatsapp-trigger-handler.js +284 -17
- package/dist/src/packages/server/routes/files.js +56 -8
- package/dist/src/packages/server/services/attachment-downloader.js +317 -0
- package/dist/src/packages/server/services/attachment-janitor.js +110 -0
- package/dist/src/packages/server/services/audio-transcription.js +165 -0
- package/dist/src/packages/server/services/trigger-service.js +8 -5
- package/package.json +1 -1
- package/dist/assets/TriggerManagerPanel-DuWagsLi.js +0 -3
- package/dist/assets/main-CrGeO0Sc.css +0 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
import{ck as t}from"./main-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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)
|