tide-commander 1.91.0 → 1.92.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{BossLogsModal-XsTxfWM8.js → BossLogsModal-BTTK3SDM.js} +1 -1
- package/dist/assets/{BossSpawnModal-DqQMPxHu.js → BossSpawnModal-BNbFVewB.js} +1 -1
- package/dist/assets/{ControlsModal-5mzDDdS5.js → ControlsModal-DnKdyYiv.js} +1 -1
- package/dist/assets/{DockerLogsModal-2eHlxyKa.js → DockerLogsModal-BRFhRQka.js} +1 -1
- package/dist/assets/{EmbeddedEditor-Bi9Ysd99.js → EmbeddedEditor-BFnSppyy.js} +1 -1
- package/dist/assets/{GmailOAuthSetup-5u85N8Br.js → GmailOAuthSetup-9uMrVRsz.js} +1 -1
- package/dist/assets/{GoogleOAuthSetup-OxT_QwZL.js → GoogleOAuthSetup-CLAy54pj.js} +1 -1
- package/dist/assets/{IframeModal-Bn1kdP1S.js → IframeModal-Cy5KHwqs.js} +1 -1
- package/dist/assets/{IntegrationsPanel-BehHkKJu.js → IntegrationsPanel-C_a9pmU1.js} +2 -2
- package/dist/assets/{LogViewerModal-JuUpWFPL.js → LogViewerModal-D68ppO94.js} +1 -1
- package/dist/assets/{MonitoringModal-CLk3uqDa.js → MonitoringModal-CoZ0uDPq.js} +1 -1
- package/dist/assets/{PM2LogsModal-C_NpOsos.js → PM2LogsModal-Bbwdouj-.js} +1 -1
- package/dist/assets/{RestoreArchivedAreaModal-Cbcg2Fm8.js → RestoreArchivedAreaModal-Dqi_ofXl.js} +1 -1
- package/dist/assets/{Scene2DCanvas-4C-jHERv.js → Scene2DCanvas-CnqwS4QZ.js} +1 -1
- package/dist/assets/{SceneManager-BoRV8xt3.js → SceneManager-W7pJrfEW.js} +1 -1
- package/dist/assets/{SkillsPanel-Bwk3UEY_.js → SkillsPanel-Cw-tVu6j.js} +1 -1
- package/dist/assets/{SlackMultiInstanceSetup-t-g3hdbr.js → SlackMultiInstanceSetup-DddkR8Zr.js} +1 -1
- package/dist/assets/{SpawnModal-BOXkPtaJ.js → SpawnModal-DZODgZFD.js} +1 -1
- package/dist/assets/{SubordinateAssignmentModal-CLHq5a9b.js → SubordinateAssignmentModal-BkhktU8i.js} +1 -1
- package/dist/assets/TriggerManagerPanel-YcbVJ2Rc.js +9 -0
- package/dist/assets/{WorkflowEditorPanel-Bevs1fpc.js → WorkflowEditorPanel-ZadWONKw.js} +1 -1
- package/dist/assets/{index-CiXA-Zp-.js → index-Bi8u2UlO.js} +1 -1
- package/dist/assets/{index-DxHwQ6CI.js → index-CF1hOKKa.js} +3 -3
- package/dist/assets/{index-CdKOXIM2.js → index-CN-XkbYd.js} +1 -1
- package/dist/assets/{index-CJuTMFz9.js → index-Ckn27_lZ.js} +1 -1
- package/dist/assets/{index-DBt10C9K.js → index-CtEyTmv-.js} +1 -1
- package/dist/assets/{index-H8kj1tuO.js → index-CyPV_nq1.js} +1 -1
- package/dist/assets/{index-Dd063aRs.js → index-DRsSsabz.js} +1 -1
- package/dist/assets/{index-B4JdUiAe.js → index-cK048gKM.js} +2 -2
- package/dist/assets/{index-vFrHpR5s.js → index-pqOlU6XT.js} +1 -1
- package/dist/assets/{main-5eyR3isL.js → main-jN7LzP4f.js} +95 -96
- package/dist/assets/{web-Cx_ySRHK.js → web-CfwxlY8x.js} +1 -1
- package/dist/assets/{web-DMjkVCWy.js → web-DZfyqisM.js} +1 -1
- package/dist/assets/{web-DGO1VHbi.js → web-DbhP1mtF.js} +1 -1
- package/dist/index.html +1 -1
- package/dist/src/packages/server/index.js +4 -0
- package/dist/src/packages/server/integrations/slack/slack-instance.js +89 -0
- package/dist/src/packages/server/integrations/slack/slack-polling-client.js +150 -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-skill.js +12 -0
- package/dist/src/packages/server/integrations/whatsapp/whatsapp-trigger-handler.js +226 -16
- package/dist/src/packages/server/routes/files.js +4 -2
- package/dist/src/packages/server/services/attachment-downloader.js +317 -0
- package/dist/src/packages/server/services/attachment-janitor.js +110 -0
- package/package.json +1 -1
- package/dist/assets/TriggerManagerPanel-DuWagsLi.js +0 -3
|
@@ -1 +1 @@
|
|
|
1
|
-
import{ck as s}from"./main-
|
|
1
|
+
import{ck as s}from"./main-jN7LzP4f.js";import"./vendor-react--Eh9ivFN.js";import"./vendor-three-Chj50gSY.js";class l extends s{constructor(){super(...arguments),this.pending=[],this.deliveredNotifications=[],this.hasNotificationSupport=()=>{if(!("Notification"in window)||!Notification.requestPermission)return!1;if(Notification.permission!=="granted")try{new Notification("")}catch(i){if(i instanceof Error&&i.name==="TypeError")return!1}return!0}}async getDeliveredNotifications(){const i=[];for(const t of this.deliveredNotifications){const e={title:t.title,id:parseInt(t.tag),body:t.body};i.push(e)}return{notifications:i}}async removeDeliveredNotifications(i){for(const t of i.notifications){const e=this.deliveredNotifications.find(n=>n.tag===String(t.id));e==null||e.close(),this.deliveredNotifications=this.deliveredNotifications.filter(()=>!e)}}async removeAllDeliveredNotifications(){for(const i of this.deliveredNotifications)i.close();this.deliveredNotifications=[]}async createChannel(){throw this.unimplemented("Not implemented on web.")}async deleteChannel(){throw this.unimplemented("Not implemented on web.")}async listChannels(){throw this.unimplemented("Not implemented on web.")}async schedule(i){if(!this.hasNotificationSupport())throw this.unavailable("Notifications not supported in this browser.");for(const t of i.notifications)this.sendNotification(t);return{notifications:i.notifications.map(t=>({id:t.id}))}}async getPending(){return{notifications:this.pending}}async registerActionTypes(){throw this.unimplemented("Not implemented on web.")}async cancel(i){this.pending=this.pending.filter(t=>!i.notifications.find(e=>e.id===t.id))}async areEnabled(){const{display:i}=await this.checkPermissions();return{value:i==="granted"}}async changeExactNotificationSetting(){throw this.unimplemented("Not implemented on web.")}async checkExactNotificationSetting(){throw this.unimplemented("Not implemented on web.")}async requestPermissions(){if(!this.hasNotificationSupport())throw this.unavailable("Notifications not supported in this browser.");return{display:this.transformNotificationPermission(await Notification.requestPermission())}}async checkPermissions(){if(!this.hasNotificationSupport())throw this.unavailable("Notifications not supported in this browser.");return{display:this.transformNotificationPermission(Notification.permission)}}transformNotificationPermission(i){switch(i){case"granted":return"granted";case"denied":return"denied";default:return"prompt"}}sendPending(){var i;const t=[],e=new Date().getTime();for(const n of this.pending)!((i=n.schedule)===null||i===void 0)&&i.at&&n.schedule.at.getTime()<=e&&(this.buildNotification(n),t.push(n));this.pending=this.pending.filter(n=>!t.find(o=>o===n))}sendNotification(i){var t;if(!((t=i.schedule)===null||t===void 0)&&t.at){const e=i.schedule.at.getTime()-new Date().getTime();this.pending.push(i),setTimeout(()=>{this.sendPending()},e);return}this.buildNotification(i)}buildNotification(i){const t=new Notification(i.title,{body:i.body,tag:String(i.id)});return t.addEventListener("click",this.onClick.bind(this,i),!1),t.addEventListener("show",this.onShow.bind(this,i),!1),t.addEventListener("close",()=>{this.deliveredNotifications=this.deliveredNotifications.filter(()=>!this)},!1),this.deliveredNotifications.push(t),t}onClick(i){const t={actionId:"tap",notification:i};this.notifyListeners("localNotificationActionPerformed",t)}onShow(i){this.notifyListeners("localNotificationReceived",i)}}export{l as LocalNotificationsWeb};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{ck as t}from"./main-
|
|
1
|
+
import{ck as t}from"./main-jN7LzP4f.js";import"./vendor-react--Eh9ivFN.js";import"./vendor-three-Chj50gSY.js";class o extends t{constructor(){super(),this.handleVisibilityChange=()=>{const e={isActive:document.hidden!==!0};this.notifyListeners("appStateChange",e),document.hidden?this.notifyListeners("pause",null):this.notifyListeners("resume",null)},document.addEventListener("visibilitychange",this.handleVisibilityChange,!1)}exitApp(){throw this.unimplemented("Not implemented on web.")}async getInfo(){throw this.unimplemented("Not implemented on web.")}async getLaunchUrl(){return{url:""}}async getState(){return{isActive:document.hidden!==!0}}async minimizeApp(){throw this.unimplemented("Not implemented on web.")}async toggleBackButtonHandler(){throw this.unimplemented("Not implemented on web.")}async getAppLanguage(){return{value:navigator.language.split("-")[0].toLowerCase()}}}export{o as AppWeb};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{ck as a}from"./main-
|
|
1
|
+
import{ck as a}from"./main-jN7LzP4f.js";import{ImpactStyle as i,NotificationType as r}from"./index-cK048gKM.js";import"./vendor-react--Eh9ivFN.js";import"./vendor-three-Chj50gSY.js";class h extends a{constructor(){super(...arguments),this.selectionStarted=!1}async impact(t){const e=this.patternForImpact(t==null?void 0:t.style);this.vibrateWithPattern(e)}async notification(t){const e=this.patternForNotification(t==null?void 0:t.type);this.vibrateWithPattern(e)}async vibrate(t){const e=(t==null?void 0:t.duration)||300;this.vibrateWithPattern([e])}async selectionStart(){this.selectionStarted=!0}async selectionChanged(){this.selectionStarted&&this.vibrateWithPattern([70])}async selectionEnd(){this.selectionStarted=!1}patternForImpact(t=i.Heavy){return t===i.Medium?[43]:t===i.Light?[20]:[61]}patternForNotification(t=r.Success){return t===r.Warning?[30,40,30,50,60]:t===r.Error?[27,45,50]:[35,65,21]}vibrateWithPattern(t){if(navigator.vibrate)navigator.vibrate(t);else throw this.unavailable("Browser does not support the vibrate API")}}export{h as HapticsWeb};
|
package/dist/index.html
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
<link rel="icon" type="image/png" sizes="16x16" href="/assets/icons/favicon-16x16.png" />
|
|
23
23
|
<link rel="apple-touch-icon" sizes="180x180" href="/assets/icons/apple-touch-icon.png" />
|
|
24
24
|
<title>Tide Commander</title>
|
|
25
|
-
<script type="module" crossorigin src="/assets/main-
|
|
25
|
+
<script type="module" crossorigin src="/assets/main-jN7LzP4f.js"></script>
|
|
26
26
|
<link rel="modulepreload" crossorigin href="/assets/vendor-react--Eh9ivFN.js">
|
|
27
27
|
<link rel="modulepreload" crossorigin href="/assets/vendor-three-Chj50gSY.js">
|
|
28
28
|
<link rel="stylesheet" crossorigin href="/assets/main-CrGeO0Sc.css">
|
|
@@ -16,6 +16,7 @@ import { logger, closeFileLogging, getLogFilePath, createLogger } from './utils/
|
|
|
16
16
|
import { setupTerminalWsProxy } from './services/terminal-proxy.js';
|
|
17
17
|
import { initIntegrations, shutdownIntegrations, getIntegrationTriggerHandlers } from './integrations/integration-registry.js';
|
|
18
18
|
import { initBackupService, shutdownBackupService } from './services/backup-service.js';
|
|
19
|
+
import { initAttachmentJanitor, shutdownAttachmentJanitor } from './services/attachment-janitor.js';
|
|
19
20
|
// Configuration
|
|
20
21
|
const PORT = process.env.PORT || 6200;
|
|
21
22
|
const HOST = process.env.HOST || (process.env.LISTEN_ALL_INTERFACES ? '::' : '127.0.0.1');
|
|
@@ -141,6 +142,8 @@ async function main() {
|
|
|
141
142
|
}
|
|
142
143
|
// Start hourly backup scheduler (reads persisted enabled/disabled setting)
|
|
143
144
|
initBackupService();
|
|
145
|
+
// Start hourly sweeper for the trigger-attachment temp dir.
|
|
146
|
+
initAttachmentJanitor();
|
|
144
147
|
logger.server.log(`Data directory: ${getDataDir()}`);
|
|
145
148
|
logger.server.log(`Log file: ${getLogFilePath()}`);
|
|
146
149
|
// Create Express app and HTTP server
|
|
@@ -203,6 +206,7 @@ async function main() {
|
|
|
203
206
|
forceShutdownTimer.unref();
|
|
204
207
|
try {
|
|
205
208
|
shutdownBackupService();
|
|
209
|
+
shutdownAttachmentJanitor();
|
|
206
210
|
triggerService.shutdown();
|
|
207
211
|
workflowService.shutdown();
|
|
208
212
|
await shutdownIntegrations();
|
|
@@ -14,6 +14,7 @@ import * as os from 'node:os';
|
|
|
14
14
|
import path from 'node:path';
|
|
15
15
|
import { LogLevel, WebClient } from '@slack/web-api';
|
|
16
16
|
import { SocketModeClient } from '@slack/socket-mode';
|
|
17
|
+
import { downloadAttachment, MAX_ATTACHMENT_BYTES } from '../../services/attachment-downloader.js';
|
|
17
18
|
import { instanceSecretKey, loadConfig, resolveAuthMode, updateConfig } from './slack-config.js';
|
|
18
19
|
import { SlackPollingClient, asPollingWebClient } from './slack-polling-client.js';
|
|
19
20
|
import { SlackWatermarkStore } from './slack-watermark-store.js';
|
|
@@ -287,6 +288,20 @@ export class SlackInstance {
|
|
|
287
288
|
const files = hasFiles
|
|
288
289
|
? event.files.map((f) => normalizeSlackFile(f))
|
|
289
290
|
: undefined;
|
|
291
|
+
// Download any attached files locally so triggered agents can read them
|
|
292
|
+
// directly with the Read tool (no need to re-fetch via Slack auth).
|
|
293
|
+
//
|
|
294
|
+
// We download regardless of direction. Earlier this branch had an
|
|
295
|
+
// `!isOwnMessage` filter, which broke the real-world case: when a Slack
|
|
296
|
+
// user uploads a file from their OWN workspace account (the one that
|
|
297
|
+
// owns the xoxp- token), Slack reports `direction='outbound'` and the
|
|
298
|
+
// attachment was silently skipped. Same root cause as the inbound-only
|
|
299
|
+
// filter that bit us on WhatsApp. If the bot itself (chat.postMessage
|
|
300
|
+
// with files) is the originator the echo lands here too — that self-
|
|
301
|
+
// fetch is benign and bounded by the 25 MB cap and the 24 h janitor.
|
|
302
|
+
const { attachmentPaths, skippedAttachments } = files && files.length > 0
|
|
303
|
+
? await this.downloadInboundFiles(event.ts, files)
|
|
304
|
+
: { attachmentPaths: undefined, skippedAttachments: undefined };
|
|
290
305
|
const message = {
|
|
291
306
|
ts: event.ts,
|
|
292
307
|
threadTs: event.thread_ts,
|
|
@@ -297,6 +312,8 @@ export class SlackInstance {
|
|
|
297
312
|
text,
|
|
298
313
|
timestamp: parseSlackTs(event.ts),
|
|
299
314
|
files,
|
|
315
|
+
attachmentPaths,
|
|
316
|
+
skippedAttachments,
|
|
300
317
|
isOwnMessage,
|
|
301
318
|
};
|
|
302
319
|
const direction = isOwnMessage ? 'outbound' : 'inbound';
|
|
@@ -358,6 +375,78 @@ export class SlackInstance {
|
|
|
358
375
|
waiter.resolve(message);
|
|
359
376
|
}
|
|
360
377
|
}
|
|
378
|
+
/**
|
|
379
|
+
* Download up to MAX_INBOUND_FILES_PER_MESSAGE files locally so triggered
|
|
380
|
+
* agents can read them with the Read tool. Files beyond the cap, or whose
|
|
381
|
+
* size hint exceeds the 25 MB cap, are recorded under `skippedAttachments`.
|
|
382
|
+
* Never throws.
|
|
383
|
+
*/
|
|
384
|
+
async downloadInboundFiles(messageTs, files) {
|
|
385
|
+
const MAX_INBOUND_FILES_PER_MESSAGE = 10;
|
|
386
|
+
const token = this.getSecret('SLACK_BOT_TOKEN');
|
|
387
|
+
if (!token) {
|
|
388
|
+
this.ctx?.log.warn(`Slack[${this.id}] cannot download inbound files — SLACK_BOT_TOKEN not configured`);
|
|
389
|
+
return { attachmentPaths: undefined, skippedAttachments: undefined };
|
|
390
|
+
}
|
|
391
|
+
const downloaded = [];
|
|
392
|
+
const skipped = [];
|
|
393
|
+
for (let i = 0; i < files.length; i++) {
|
|
394
|
+
const file = files[i];
|
|
395
|
+
if (i >= MAX_INBOUND_FILES_PER_MESSAGE) {
|
|
396
|
+
this.ctx?.log.info(`Slack[${this.id}] skipping file ${i + 1}/${files.length} — exceeds per-message cap`);
|
|
397
|
+
skipped.push({
|
|
398
|
+
reason: 'unsupported',
|
|
399
|
+
filename: file.name,
|
|
400
|
+
size: file.size,
|
|
401
|
+
detail: 'per-message cap (10 files)',
|
|
402
|
+
});
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
const url = file.url_private_download ?? file.url_private;
|
|
406
|
+
if (!url) {
|
|
407
|
+
skipped.push({
|
|
408
|
+
reason: 'fetch-failed',
|
|
409
|
+
filename: file.name,
|
|
410
|
+
size: file.size,
|
|
411
|
+
detail: 'no url_private',
|
|
412
|
+
});
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
if (typeof file.size === 'number' && file.size > MAX_ATTACHMENT_BYTES) {
|
|
416
|
+
skipped.push({
|
|
417
|
+
reason: 'too-large',
|
|
418
|
+
filename: file.name,
|
|
419
|
+
size: file.size,
|
|
420
|
+
sourceUrl: url,
|
|
421
|
+
});
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
const result = await downloadAttachment({
|
|
425
|
+
source: 'slack',
|
|
426
|
+
messageId: `${messageTs}-${file.id}`,
|
|
427
|
+
url,
|
|
428
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
429
|
+
suggestedFilename: file.name,
|
|
430
|
+
mimetype: file.mimetype,
|
|
431
|
+
sizeHintBytes: file.size,
|
|
432
|
+
});
|
|
433
|
+
if (result) {
|
|
434
|
+
downloaded.push(result);
|
|
435
|
+
}
|
|
436
|
+
else {
|
|
437
|
+
skipped.push({
|
|
438
|
+
reason: 'fetch-failed',
|
|
439
|
+
filename: file.name,
|
|
440
|
+
size: file.size,
|
|
441
|
+
sourceUrl: url,
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
return {
|
|
446
|
+
attachmentPaths: downloaded.length > 0 ? downloaded : undefined,
|
|
447
|
+
skippedAttachments: skipped.length > 0 ? skipped : undefined,
|
|
448
|
+
};
|
|
449
|
+
}
|
|
361
450
|
// ─── Sending ───
|
|
362
451
|
async sendMessage(params) {
|
|
363
452
|
if (!this.webClient)
|
|
@@ -77,9 +77,14 @@ export class SlackPollingClient {
|
|
|
77
77
|
minMsBetweenCalls;
|
|
78
78
|
scheduler;
|
|
79
79
|
now;
|
|
80
|
-
/**
|
|
81
|
-
|
|
82
|
-
|
|
80
|
+
/** Token-bucket rate limiter shared by every paced API call. Concurrent
|
|
81
|
+
* workers really run in parallel — only the *rate* (tokens/sec) is capped.
|
|
82
|
+
* Replaces the older serial promise-chain gate which silently nullified
|
|
83
|
+
* the `concurrency` option (every paced call was forced to run one at a
|
|
84
|
+
* time spaced by `minMsBetweenCalls`, so `concurrency: 8` behaved like
|
|
85
|
+
* `concurrency: 1`). */
|
|
86
|
+
bucket = null;
|
|
87
|
+
/** When > now(), every paced call sleeps until that wall-clock ms. Set on 429. */
|
|
83
88
|
globalPauseUntil = 0;
|
|
84
89
|
running = false;
|
|
85
90
|
timer = null;
|
|
@@ -108,51 +113,44 @@ export class SlackPollingClient {
|
|
|
108
113
|
this.minMsBetweenCalls = Math.max(0, opts.minMsBetweenCalls ?? 0);
|
|
109
114
|
this.scheduler = opts.scheduler ?? DEFAULT_SCHEDULER;
|
|
110
115
|
this.now = opts.now ?? Date.now;
|
|
116
|
+
if (this.minMsBetweenCalls > 0) {
|
|
117
|
+
// Tier 3 spec: ~50/min sustained with bursts up to ~5/sec. Refill rate
|
|
118
|
+
// = minMsBetweenCalls (e.g. 1200ms → 50/min). Burst capacity caps the
|
|
119
|
+
// initial flood from a cycle wake-up without us having to throttle every
|
|
120
|
+
// single call serially.
|
|
121
|
+
this.bucket = new TokenBucket({
|
|
122
|
+
capacity: 5,
|
|
123
|
+
refillIntervalMs: this.minMsBetweenCalls,
|
|
124
|
+
now: this.now,
|
|
125
|
+
globalPauseUntil: () => this.globalPauseUntil,
|
|
126
|
+
isRunning: () => this.running,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
111
129
|
}
|
|
112
130
|
/**
|
|
113
131
|
* Throttle gate for outbound Slack API calls. All calls (history + replies)
|
|
114
|
-
* go through here
|
|
115
|
-
*
|
|
132
|
+
* go through here. With `minMsBetweenCalls > 0` we acquire a token from the
|
|
133
|
+
* shared bucket; multiple workers can hold tokens concurrently up to the
|
|
134
|
+
* burst capacity, so `concurrency: 8` actually parallelizes (unlike the
|
|
135
|
+
* older serial promise-chain gate). With `minMsBetweenCalls === 0` we are
|
|
136
|
+
* in test mode and skip throttling entirely.
|
|
116
137
|
*
|
|
117
|
-
*
|
|
118
|
-
* off when Slack tells us to.
|
|
138
|
+
* The bucket honors `globalPauseUntil` set by 429 handlers so the entire
|
|
139
|
+
* client backs off when Slack tells us to.
|
|
119
140
|
*/
|
|
120
141
|
async paceCall(fn) {
|
|
121
|
-
// Stop() was called while we were queued — bail without consuming the
|
|
122
|
-
// pacing slot or making the API call.
|
|
123
142
|
if (!this.running) {
|
|
124
143
|
throw new Error('SlackPollingClient stopped');
|
|
125
144
|
}
|
|
126
|
-
if (this.
|
|
145
|
+
if (!this.bucket) {
|
|
146
|
+
// Test mode: no throttling at all.
|
|
127
147
|
return fn();
|
|
128
148
|
}
|
|
129
|
-
|
|
130
|
-
let release;
|
|
131
|
-
this.rateGateChain = new Promise((r) => { release = r; });
|
|
132
|
-
await prev;
|
|
133
|
-
// Re-check after waiting on the gate — stop() could have fired meanwhile.
|
|
149
|
+
await this.bucket.acquire();
|
|
134
150
|
if (!this.running) {
|
|
135
|
-
release();
|
|
136
151
|
throw new Error('SlackPollingClient stopped');
|
|
137
152
|
}
|
|
138
|
-
|
|
139
|
-
const pauseRemainingMs = this.globalPauseUntil - this.now();
|
|
140
|
-
if (pauseRemainingMs > 0) {
|
|
141
|
-
await new Promise((r) => setTimeout(r, pauseRemainingMs));
|
|
142
|
-
}
|
|
143
|
-
try {
|
|
144
|
-
return await fn();
|
|
145
|
-
}
|
|
146
|
-
finally {
|
|
147
|
-
// Release the gate after the min interval — the next paced call will
|
|
148
|
-
// unblock then. We don't await this; it's fire-and-forget.
|
|
149
|
-
if (this.minMsBetweenCalls > 0) {
|
|
150
|
-
setTimeout(release, this.minMsBetweenCalls);
|
|
151
|
-
}
|
|
152
|
-
else {
|
|
153
|
-
release();
|
|
154
|
-
}
|
|
155
|
-
}
|
|
153
|
+
return fn();
|
|
156
154
|
}
|
|
157
155
|
/** Subscribe to fatal-error notifications (e.g. invalid_auth). */
|
|
158
156
|
setOnFatalError(cb) {
|
|
@@ -365,14 +363,15 @@ export class SlackPollingClient {
|
|
|
365
363
|
if (messages.length === 0)
|
|
366
364
|
return 0;
|
|
367
365
|
// Slack returns newest-first; dispatch in chronological order so trigger
|
|
368
|
-
// listeners and dedupe see them in real-world order.
|
|
369
|
-
|
|
366
|
+
// listeners and dedupe see them in real-world order. BigInt sort avoids
|
|
367
|
+
// the precision loss `parseFloat` had on Slack's 16-sig-fig timestamps.
|
|
368
|
+
const ordered = [...messages].sort((a, b) => tsCmp(a.ts, b.ts));
|
|
370
369
|
let dispatched = 0;
|
|
371
370
|
let highestTs = wm?.lastTs ?? '0';
|
|
372
371
|
for (const msg of ordered) {
|
|
373
372
|
// Defense-in-depth: never re-dispatch ts <= watermark even if the
|
|
374
373
|
// server returned an unexpected row.
|
|
375
|
-
if (wm &&
|
|
374
|
+
if (wm && tsLte(msg.ts, wm.lastTs))
|
|
376
375
|
continue;
|
|
377
376
|
const event = {
|
|
378
377
|
ts: msg.ts,
|
|
@@ -390,7 +389,7 @@ export class SlackPollingClient {
|
|
|
390
389
|
catch (err) {
|
|
391
390
|
log.error(`dispatch error channel=${channelId} ts=${msg.ts}: ${describeErr(err)}`);
|
|
392
391
|
}
|
|
393
|
-
if (
|
|
392
|
+
if (tsGt(msg.ts, highestTs)) {
|
|
394
393
|
highestTs = msg.ts;
|
|
395
394
|
}
|
|
396
395
|
// ─── Cheap thread-reply heuristic ───
|
|
@@ -404,7 +403,7 @@ export class SlackPollingClient {
|
|
|
404
403
|
const parentTs = msg.ts;
|
|
405
404
|
const replyCount = msg.reply_count ?? 0;
|
|
406
405
|
const latestReply = msg.latest_reply;
|
|
407
|
-
const repliesAreNew = !!latestReply &&
|
|
406
|
+
const repliesAreNew = !!latestReply && tsGt(latestReply, wm?.lastTs ?? '0');
|
|
408
407
|
if (replyCount > 0 && repliesAreNew) {
|
|
409
408
|
const oldestForReplies = wm?.lastTs ?? parentTs;
|
|
410
409
|
try {
|
|
@@ -416,10 +415,10 @@ export class SlackPollingClient {
|
|
|
416
415
|
}));
|
|
417
416
|
const replies = (repliesRes.messages ?? []).filter((r) => !!r && typeof r.ts === 'string' && r.ts !== parentTs);
|
|
418
417
|
// conversations.replies returns oldest-first, but be defensive.
|
|
419
|
-
const orderedReplies = [...replies].sort((a, b) =>
|
|
418
|
+
const orderedReplies = [...replies].sort((a, b) => tsCmp(a.ts, b.ts));
|
|
420
419
|
for (const reply of orderedReplies) {
|
|
421
420
|
// Defense: skip ts already at or below the watermark (handles overlap).
|
|
422
|
-
if (wm &&
|
|
421
|
+
if (wm && tsLte(reply.ts, wm.lastTs))
|
|
423
422
|
continue;
|
|
424
423
|
const replyEvent = {
|
|
425
424
|
ts: reply.ts,
|
|
@@ -437,7 +436,7 @@ export class SlackPollingClient {
|
|
|
437
436
|
catch (err) {
|
|
438
437
|
log.error(`dispatch error (reply) channel=${channelId} ts=${reply.ts}: ${describeErr(err)}`);
|
|
439
438
|
}
|
|
440
|
-
if (
|
|
439
|
+
if (tsGt(reply.ts, highestTs)) {
|
|
441
440
|
highestTs = reply.ts;
|
|
442
441
|
}
|
|
443
442
|
}
|
|
@@ -520,3 +519,112 @@ async function mapWithConcurrency(items, concurrency, worker) {
|
|
|
520
519
|
export function asPollingWebClient(client) {
|
|
521
520
|
return client;
|
|
522
521
|
}
|
|
522
|
+
// ─── Slack TS comparison helpers ───
|
|
523
|
+
//
|
|
524
|
+
// Slack timestamps are strings shaped like `XXXXXXXXXX.YYYYYY` — 10-digit
|
|
525
|
+
// seconds + 6-digit microseconds. Using `parseFloat()` on them silently loses
|
|
526
|
+
// precision in the last digits (JS doubles top out around 15–17 sig figs),
|
|
527
|
+
// which can cause two adjacent timestamps to compare equal and a real message
|
|
528
|
+
// to be silently dropped (`msg.ts <= wm.lastTs` accepting it as already-seen
|
|
529
|
+
// and `msg.ts > highestTs` refusing to advance the watermark). We use BigInt
|
|
530
|
+
// instead so the comparison is exact.
|
|
531
|
+
function tsToBigInt(ts) {
|
|
532
|
+
if (!ts)
|
|
533
|
+
return 0n;
|
|
534
|
+
const dot = ts.indexOf('.');
|
|
535
|
+
const sec = dot === -1 ? ts : ts.slice(0, dot);
|
|
536
|
+
const micro = dot === -1 ? '' : ts.slice(dot + 1);
|
|
537
|
+
// Pad / truncate microseconds to exactly 6 digits so concatenation always
|
|
538
|
+
// produces a consistent-magnitude integer.
|
|
539
|
+
const microPadded = (micro + '000000').slice(0, 6);
|
|
540
|
+
try {
|
|
541
|
+
return BigInt(sec + microPadded);
|
|
542
|
+
}
|
|
543
|
+
catch {
|
|
544
|
+
return 0n;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
/** True if `a <= b`. */
|
|
548
|
+
function tsLte(a, b) {
|
|
549
|
+
return tsToBigInt(a) <= tsToBigInt(b);
|
|
550
|
+
}
|
|
551
|
+
/** True if `a > b`. */
|
|
552
|
+
function tsGt(a, b) {
|
|
553
|
+
return tsToBigInt(a) > tsToBigInt(b);
|
|
554
|
+
}
|
|
555
|
+
/** Compare two ts strings for Array.sort. Returns -1, 0, or 1. */
|
|
556
|
+
function tsCmp(a, b) {
|
|
557
|
+
const ab = tsToBigInt(a);
|
|
558
|
+
const bb = tsToBigInt(b);
|
|
559
|
+
return ab < bb ? -1 : ab > bb ? 1 : 0;
|
|
560
|
+
}
|
|
561
|
+
class TokenBucket {
|
|
562
|
+
tokens;
|
|
563
|
+
lastRefillMs;
|
|
564
|
+
waiters = [];
|
|
565
|
+
pumpScheduled = false;
|
|
566
|
+
capacity;
|
|
567
|
+
refillIntervalMs;
|
|
568
|
+
nowFn;
|
|
569
|
+
globalPauseUntilFn;
|
|
570
|
+
isRunningFn;
|
|
571
|
+
constructor(opts) {
|
|
572
|
+
this.capacity = Math.max(1, opts.capacity);
|
|
573
|
+
this.refillIntervalMs = Math.max(1, opts.refillIntervalMs);
|
|
574
|
+
this.nowFn = opts.now;
|
|
575
|
+
this.globalPauseUntilFn = opts.globalPauseUntil;
|
|
576
|
+
this.isRunningFn = opts.isRunning;
|
|
577
|
+
this.tokens = this.capacity;
|
|
578
|
+
this.lastRefillMs = opts.now();
|
|
579
|
+
}
|
|
580
|
+
refillNow() {
|
|
581
|
+
const elapsed = this.nowFn() - this.lastRefillMs;
|
|
582
|
+
if (elapsed <= 0)
|
|
583
|
+
return;
|
|
584
|
+
const gained = elapsed / this.refillIntervalMs;
|
|
585
|
+
if (gained > 0) {
|
|
586
|
+
this.tokens = Math.min(this.capacity, this.tokens + gained);
|
|
587
|
+
this.lastRefillMs = this.nowFn();
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
async acquire() {
|
|
591
|
+
if (!this.isRunningFn())
|
|
592
|
+
throw new Error('SlackPollingClient stopped');
|
|
593
|
+
// Wait through any global 429 pause before consuming a token so the
|
|
594
|
+
// entire workspace honors Retry-After.
|
|
595
|
+
const pauseRemaining = this.globalPauseUntilFn() - this.nowFn();
|
|
596
|
+
if (pauseRemaining > 0) {
|
|
597
|
+
await new Promise((r) => setTimeout(r, pauseRemaining));
|
|
598
|
+
if (!this.isRunningFn())
|
|
599
|
+
throw new Error('SlackPollingClient stopped');
|
|
600
|
+
}
|
|
601
|
+
this.refillNow();
|
|
602
|
+
if (this.tokens >= 1 && this.waiters.length === 0) {
|
|
603
|
+
this.tokens -= 1;
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
return new Promise((resolve) => {
|
|
607
|
+
this.waiters.push(resolve);
|
|
608
|
+
this.schedulePump();
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
schedulePump() {
|
|
612
|
+
if (this.pumpScheduled)
|
|
613
|
+
return;
|
|
614
|
+
this.pumpScheduled = true;
|
|
615
|
+
const needed = Math.max(0, 1 - this.tokens);
|
|
616
|
+
const waitMs = Math.max(10, Math.ceil(needed * this.refillIntervalMs));
|
|
617
|
+
setTimeout(() => {
|
|
618
|
+
this.pumpScheduled = false;
|
|
619
|
+
this.refillNow();
|
|
620
|
+
while (this.tokens >= 1 && this.waiters.length > 0) {
|
|
621
|
+
this.tokens -= 1;
|
|
622
|
+
const resolve = this.waiters.shift();
|
|
623
|
+
resolve();
|
|
624
|
+
}
|
|
625
|
+
if (this.waiters.length > 0) {
|
|
626
|
+
this.schedulePump();
|
|
627
|
+
}
|
|
628
|
+
}, waitMs);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
@@ -230,6 +230,22 @@ Disable the auto-ack by setting \`SLACK_REACT_ON_TRIGGER=false\` (accepts \`fals
|
|
|
230
230
|
curl -s http://localhost:5174/api/slack/status
|
|
231
231
|
\`\`\`
|
|
232
232
|
|
|
233
|
+
## Reading Inbound Attachments from a Slack Trigger
|
|
234
|
+
|
|
235
|
+
When a Slack message that fires your trigger carries file attachments (\`file_share\`, screenshots, PDFs, etc.), Tide Commander downloads the bytes server-side so you don't have to fetch them yourself. The local paths are surfaced in your prompt template under \`{{slack.attachmentsBlock}}\` (and \`{{slack.filePaths}}\` for a flat comma list).
|
|
236
|
+
|
|
237
|
+
Each downloaded attachment renders as one line in the block:
|
|
238
|
+
|
|
239
|
+
\`\`\`
|
|
240
|
+
[attachment: /tmp/tide-commander-uploads/triggers/slack/<msgTs>-<fileId>/<filename> mimetype=image/png name=screenshot.png size=124300]
|
|
241
|
+
\`\`\`
|
|
242
|
+
|
|
243
|
+
Use the **Read** tool directly on that absolute path — do NOT call the Slack file API yourself. Files older than 24 hours are swept automatically.
|
|
244
|
+
|
|
245
|
+
If a file is too large (>25 MB) or the bot couldn't download it, the line becomes \`[attachment-skipped: too-large …]\` or \`[attachment-skipped: fetch-failed …]\` instead. In that case, surface the limitation to the user rather than silently ignoring the attachment.
|
|
246
|
+
|
|
247
|
+
Per-message cap: at most 10 files are downloaded; additional files are emitted as \`[attachment-skipped: unsupported …]\`.
|
|
248
|
+
|
|
233
249
|
## Notes
|
|
234
250
|
- Channel IDs look like \`C0123456789\`. Use the list channels endpoint to find them.
|
|
235
251
|
- Thread timestamps look like \`1234567890.123456\`.
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import * as slackClient from './slack-client.js';
|
|
11
11
|
import { listInstances } from './slack-instance.js';
|
|
12
12
|
import { listInstanceMetas } from './slack-instance-manifest.js';
|
|
13
|
+
import { formatAttachmentLine } from '../../services/attachment-downloader.js';
|
|
13
14
|
const unsubscribers = [];
|
|
14
15
|
/** Env toggle: set SLACK_REACT_ON_TRIGGER=false (or 0/no/off) to disable the auto-:eyes: ack. */
|
|
15
16
|
function reactOnTriggerEnabled() {
|
|
@@ -90,10 +91,24 @@ export const slackTriggerHandler = {
|
|
|
90
91
|
const msg = event.data;
|
|
91
92
|
void trigger;
|
|
92
93
|
const files = msg.files ?? [];
|
|
94
|
+
const downloaded = msg.attachmentPaths ?? [];
|
|
95
|
+
const skipped = msg.skippedAttachments ?? [];
|
|
93
96
|
// Resolved channel label: `#name` / `DM con @user` / `Grupo: @a, @b…`.
|
|
94
97
|
// Falls back to the raw id when the resolver couldn't fetch metadata
|
|
95
98
|
// (network blip, missing scope, deleted channel).
|
|
96
99
|
const channelLabel = msg.channelName || msg.channel;
|
|
100
|
+
// Build the "attachments block" agents can ingest verbatim — one line per
|
|
101
|
+
// downloaded file with absolute path, plus a line per skipped file with
|
|
102
|
+
// the reason. Empty string when there are no attachments.
|
|
103
|
+
const blockLines = [];
|
|
104
|
+
for (const att of downloaded) {
|
|
105
|
+
blockLines.push(formatAttachmentLine(att));
|
|
106
|
+
}
|
|
107
|
+
for (const sk of skipped) {
|
|
108
|
+
const sizeMb = typeof sk.size === 'number' ? `${Math.round(sk.size / (1024 * 1024))}MB` : 'unknown';
|
|
109
|
+
blockLines.push(`[attachment-skipped: ${sk.reason} name=${sk.filename ?? 'unknown'} size=${sizeMb}${sk.detail ? ` detail=${sk.detail}` : ''}]`);
|
|
110
|
+
}
|
|
111
|
+
const attachmentsBlock = blockLines.join('\n');
|
|
97
112
|
return {
|
|
98
113
|
'slack.user': msg.userName,
|
|
99
114
|
// fromName/fromId are the boss-canonical names mirroring the Slack
|
|
@@ -113,6 +128,10 @@ export const slackTriggerHandler = {
|
|
|
113
128
|
'slack.fileIds': files.map((f) => f.id).join(','),
|
|
114
129
|
'slack.fileNames': files.map((f) => f.name ?? '').filter(Boolean).join(','),
|
|
115
130
|
'slack.attachmentsList': files.map((f) => f.name ?? '').filter(Boolean).join(','),
|
|
131
|
+
// New: absolute paths and ready-to-paste attachments block. Additive —
|
|
132
|
+
// existing user templates that don't reference these vars keep working.
|
|
133
|
+
'slack.filePaths': downloaded.map((a) => a.path).join(','),
|
|
134
|
+
'slack.attachmentsBlock': attachmentsBlock,
|
|
116
135
|
'slack.instanceId': msg.instanceId,
|
|
117
136
|
'slack.instanceName': msg.instanceId,
|
|
118
137
|
};
|
|
@@ -53,6 +53,18 @@ curl -s -X POST -H "X-Auth-Token: abcd" http://localhost:5174/api/whatsapp/send-
|
|
|
53
53
|
-d '{"to":"5215532967210","mediaUrl":"https://example.com/cat.png","caption":"meow","type":"image"}'
|
|
54
54
|
\`\`\`
|
|
55
55
|
|
|
56
|
+
### 4. Read an attachment from an inbound WhatsApp trigger
|
|
57
|
+
|
|
58
|
+
When your trigger fires from an inbound WhatsApp message that has media, Tide Commander downloads the bytes server-side and surfaces the local path in your prompt template under \`{{whatsapp.attachmentsBlock}}\` (and \`{{whatsapp.attachmentPath}}\` individually). The block looks like:
|
|
59
|
+
|
|
60
|
+
\`\`\`
|
|
61
|
+
[attachment: /tmp/tide-commander-uploads/triggers/whatsapp/<msgId>/<filename> mimetype=image/jpeg name=photo.jpg size=124300]
|
|
62
|
+
\`\`\`
|
|
63
|
+
|
|
64
|
+
Use the **Read** tool directly on that path — do NOT try to fetch the original \`mediaUrl\` (it's an encrypted Meta CDN URL that you can't decrypt). Files older than 24 hours are swept automatically.
|
|
65
|
+
|
|
66
|
+
If the line says \`[attachment-skipped: too-large …]\` or \`[attachment-skipped: fetch-failed …]\` instead, the bytes were not saved and you should respond accordingly (ask the user to re-send, or proceed without the attachment).
|
|
67
|
+
|
|
56
68
|
---
|
|
57
69
|
|
|
58
70
|
## Send a Text Message (full reference)
|