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.
- package/dist/assets/{BossLogsModal-BTTK3SDM.js → BossLogsModal-CgILsm9V.js} +1 -1
- package/dist/assets/{BossSpawnModal-BNbFVewB.js → BossSpawnModal-BdGy2pC2.js} +1 -1
- package/dist/assets/{ControlsModal-DnKdyYiv.js → ControlsModal-hFIzwgt7.js} +1 -1
- package/dist/assets/{DockerLogsModal-BRFhRQka.js → DockerLogsModal-CDX9xVzn.js} +1 -1
- package/dist/assets/{EmbeddedEditor-BFnSppyy.js → EmbeddedEditor-2DZTiy_9.js} +1 -1
- package/dist/assets/{GmailOAuthSetup-9uMrVRsz.js → GmailOAuthSetup-Cq1DpIPF.js} +1 -1
- package/dist/assets/{GoogleOAuthSetup-CLAy54pj.js → GoogleOAuthSetup-B1UOVYlZ.js} +1 -1
- package/dist/assets/{IframeModal-Cy5KHwqs.js → IframeModal-GZkqd6O6.js} +1 -1
- package/dist/assets/{IntegrationsPanel-C_a9pmU1.js → IntegrationsPanel-CBN_0nAs.js} +2 -2
- package/dist/assets/{LogViewerModal-D68ppO94.js → LogViewerModal-BrZrAYvt.js} +1 -1
- package/dist/assets/{MonitoringModal-CoZ0uDPq.js → MonitoringModal-bIVunlhs.js} +1 -1
- package/dist/assets/{PM2LogsModal-Bbwdouj-.js → PM2LogsModal-Crs7Q2LK.js} +1 -1
- package/dist/assets/{RestoreArchivedAreaModal-Dqi_ofXl.js → RestoreArchivedAreaModal-9thnHbZ3.js} +1 -1
- package/dist/assets/{Scene2DCanvas-CnqwS4QZ.js → Scene2DCanvas-Bj7JLtW8.js} +1 -1
- package/dist/assets/{SceneManager-W7pJrfEW.js → SceneManager-C-ItjZnd.js} +1 -1
- package/dist/assets/{SkillsPanel-Cw-tVu6j.js → SkillsPanel-pTORcpQL.js} +1 -1
- package/dist/assets/{SlackMultiInstanceSetup-DddkR8Zr.js → SlackMultiInstanceSetup-FXuMr1vo.js} +1 -1
- package/dist/assets/{SpawnModal-DZODgZFD.js → SpawnModal-D3Pgos07.js} +1 -1
- package/dist/assets/{SubordinateAssignmentModal-BkhktU8i.js → SubordinateAssignmentModal-6au3Tv5w.js} +1 -1
- package/dist/assets/{TriggerManagerPanel-YcbVJ2Rc.js → TriggerManagerPanel-DletkqtL.js} +1 -1
- package/dist/assets/{WorkflowEditorPanel-ZadWONKw.js → WorkflowEditorPanel-DMu9KdjB.js} +1 -1
- package/dist/assets/{index-Ckn27_lZ.js → index-BGatvpcF.js} +1 -1
- package/dist/assets/{index-CN-XkbYd.js → index-BPox4QjF.js} +1 -1
- package/dist/assets/{index-CyPV_nq1.js → index-CQYizqu9.js} +1 -1
- package/dist/assets/{index-Bi8u2UlO.js → index-DAsi0YrR.js} +1 -1
- package/dist/assets/{index-DRsSsabz.js → index-DIsb3aYA.js} +1 -1
- package/dist/assets/{index-pqOlU6XT.js → index-Hl0I9IIt.js} +1 -1
- package/dist/assets/{index-CF1hOKKa.js → index-ZZtcJoNU.js} +33 -33
- package/dist/assets/{index-CtEyTmv-.js → index-_OjecyuG.js} +1 -1
- package/dist/assets/{index-cK048gKM.js → index-mEu7CM6i.js} +2 -2
- package/dist/assets/{main-jN7LzP4f.js → main-BUO6--48.js} +82 -80
- package/dist/assets/main-BfT_95fk.css +1 -0
- package/dist/assets/{web-DZfyqisM.js → web-BbNfUMzK.js} +1 -1
- package/dist/assets/{web-CfwxlY8x.js → web-CQsQBSkQ.js} +1 -1
- package/dist/assets/{web-DbhP1mtF.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/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 +1 -0
- package/dist/src/packages/server/integrations/slack/slack-polling-client.js +146 -0
- package/dist/src/packages/server/integrations/whatsapp/whatsapp-config.js +46 -0
- package/dist/src/packages/server/integrations/whatsapp/whatsapp-trigger-handler.js +77 -20
- package/dist/src/packages/server/routes/files.js +52 -6
- 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/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",
|
|
@@ -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 !== '********') {
|