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
|
@@ -75,11 +75,17 @@ export class SlackPollingClient {
|
|
|
75
75
|
allowlistChannelIds;
|
|
76
76
|
keepAllDms;
|
|
77
77
|
minMsBetweenCalls;
|
|
78
|
+
useSearch;
|
|
78
79
|
scheduler;
|
|
79
80
|
now;
|
|
80
|
-
/**
|
|
81
|
-
|
|
82
|
-
|
|
81
|
+
/** Token-bucket rate limiter shared by every paced API call. Concurrent
|
|
82
|
+
* workers really run in parallel — only the *rate* (tokens/sec) is capped.
|
|
83
|
+
* Replaces the older serial promise-chain gate which silently nullified
|
|
84
|
+
* the `concurrency` option (every paced call was forced to run one at a
|
|
85
|
+
* time spaced by `minMsBetweenCalls`, so `concurrency: 8` behaved like
|
|
86
|
+
* `concurrency: 1`). */
|
|
87
|
+
bucket = null;
|
|
88
|
+
/** When > now(), every paced call sleeps until that wall-clock ms. Set on 429. */
|
|
83
89
|
globalPauseUntil = 0;
|
|
84
90
|
running = false;
|
|
85
91
|
timer = null;
|
|
@@ -106,53 +112,47 @@ export class SlackPollingClient {
|
|
|
106
112
|
this.allowlistChannelIds = new Set((opts.allowlistChannelIds ?? []).map((s) => s.trim()).filter(Boolean));
|
|
107
113
|
this.keepAllDms = opts.keepAllDms !== false;
|
|
108
114
|
this.minMsBetweenCalls = Math.max(0, opts.minMsBetweenCalls ?? 0);
|
|
115
|
+
this.useSearch = opts.useSearch === true;
|
|
109
116
|
this.scheduler = opts.scheduler ?? DEFAULT_SCHEDULER;
|
|
110
117
|
this.now = opts.now ?? Date.now;
|
|
118
|
+
if (this.minMsBetweenCalls > 0) {
|
|
119
|
+
// Tier 3 spec: ~50/min sustained with bursts up to ~5/sec. Refill rate
|
|
120
|
+
// = minMsBetweenCalls (e.g. 1200ms → 50/min). Burst capacity caps the
|
|
121
|
+
// initial flood from a cycle wake-up without us having to throttle every
|
|
122
|
+
// single call serially.
|
|
123
|
+
this.bucket = new TokenBucket({
|
|
124
|
+
capacity: 5,
|
|
125
|
+
refillIntervalMs: this.minMsBetweenCalls,
|
|
126
|
+
now: this.now,
|
|
127
|
+
globalPauseUntil: () => this.globalPauseUntil,
|
|
128
|
+
isRunning: () => this.running,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
111
131
|
}
|
|
112
132
|
/**
|
|
113
133
|
* Throttle gate for outbound Slack API calls. All calls (history + replies)
|
|
114
|
-
* go through here
|
|
115
|
-
*
|
|
134
|
+
* go through here. With `minMsBetweenCalls > 0` we acquire a token from the
|
|
135
|
+
* shared bucket; multiple workers can hold tokens concurrently up to the
|
|
136
|
+
* burst capacity, so `concurrency: 8` actually parallelizes (unlike the
|
|
137
|
+
* older serial promise-chain gate). With `minMsBetweenCalls === 0` we are
|
|
138
|
+
* in test mode and skip throttling entirely.
|
|
116
139
|
*
|
|
117
|
-
*
|
|
118
|
-
* off when Slack tells us to.
|
|
140
|
+
* The bucket honors `globalPauseUntil` set by 429 handlers so the entire
|
|
141
|
+
* client backs off when Slack tells us to.
|
|
119
142
|
*/
|
|
120
143
|
async paceCall(fn) {
|
|
121
|
-
// Stop() was called while we were queued — bail without consuming the
|
|
122
|
-
// pacing slot or making the API call.
|
|
123
144
|
if (!this.running) {
|
|
124
145
|
throw new Error('SlackPollingClient stopped');
|
|
125
146
|
}
|
|
126
|
-
if (this.
|
|
147
|
+
if (!this.bucket) {
|
|
148
|
+
// Test mode: no throttling at all.
|
|
127
149
|
return fn();
|
|
128
150
|
}
|
|
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.
|
|
151
|
+
await this.bucket.acquire();
|
|
134
152
|
if (!this.running) {
|
|
135
|
-
release();
|
|
136
153
|
throw new Error('SlackPollingClient stopped');
|
|
137
154
|
}
|
|
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
|
-
}
|
|
155
|
+
return fn();
|
|
156
156
|
}
|
|
157
157
|
/** Subscribe to fatal-error notifications (e.g. invalid_auth). */
|
|
158
158
|
setOnFatalError(cb) {
|
|
@@ -233,6 +233,25 @@ export class SlackPollingClient {
|
|
|
233
233
|
return;
|
|
234
234
|
this.cycleCount += 1;
|
|
235
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
|
+
}
|
|
236
255
|
// Refresh channel list on first cycle and every N cycles after.
|
|
237
256
|
if (this.cycleCount === 1 || this.cycleCount % this.channelListRefreshEveryNCycles === 0) {
|
|
238
257
|
try {
|
|
@@ -365,14 +384,15 @@ export class SlackPollingClient {
|
|
|
365
384
|
if (messages.length === 0)
|
|
366
385
|
return 0;
|
|
367
386
|
// Slack returns newest-first; dispatch in chronological order so trigger
|
|
368
|
-
// listeners and dedupe see them in real-world order.
|
|
369
|
-
|
|
387
|
+
// listeners and dedupe see them in real-world order. BigInt sort avoids
|
|
388
|
+
// the precision loss `parseFloat` had on Slack's 16-sig-fig timestamps.
|
|
389
|
+
const ordered = [...messages].sort((a, b) => tsCmp(a.ts, b.ts));
|
|
370
390
|
let dispatched = 0;
|
|
371
391
|
let highestTs = wm?.lastTs ?? '0';
|
|
372
392
|
for (const msg of ordered) {
|
|
373
393
|
// Defense-in-depth: never re-dispatch ts <= watermark even if the
|
|
374
394
|
// server returned an unexpected row.
|
|
375
|
-
if (wm &&
|
|
395
|
+
if (wm && tsLte(msg.ts, wm.lastTs))
|
|
376
396
|
continue;
|
|
377
397
|
const event = {
|
|
378
398
|
ts: msg.ts,
|
|
@@ -390,7 +410,7 @@ export class SlackPollingClient {
|
|
|
390
410
|
catch (err) {
|
|
391
411
|
log.error(`dispatch error channel=${channelId} ts=${msg.ts}: ${describeErr(err)}`);
|
|
392
412
|
}
|
|
393
|
-
if (
|
|
413
|
+
if (tsGt(msg.ts, highestTs)) {
|
|
394
414
|
highestTs = msg.ts;
|
|
395
415
|
}
|
|
396
416
|
// ─── Cheap thread-reply heuristic ───
|
|
@@ -404,7 +424,7 @@ export class SlackPollingClient {
|
|
|
404
424
|
const parentTs = msg.ts;
|
|
405
425
|
const replyCount = msg.reply_count ?? 0;
|
|
406
426
|
const latestReply = msg.latest_reply;
|
|
407
|
-
const repliesAreNew = !!latestReply &&
|
|
427
|
+
const repliesAreNew = !!latestReply && tsGt(latestReply, wm?.lastTs ?? '0');
|
|
408
428
|
if (replyCount > 0 && repliesAreNew) {
|
|
409
429
|
const oldestForReplies = wm?.lastTs ?? parentTs;
|
|
410
430
|
try {
|
|
@@ -416,10 +436,10 @@ export class SlackPollingClient {
|
|
|
416
436
|
}));
|
|
417
437
|
const replies = (repliesRes.messages ?? []).filter((r) => !!r && typeof r.ts === 'string' && r.ts !== parentTs);
|
|
418
438
|
// conversations.replies returns oldest-first, but be defensive.
|
|
419
|
-
const orderedReplies = [...replies].sort((a, b) =>
|
|
439
|
+
const orderedReplies = [...replies].sort((a, b) => tsCmp(a.ts, b.ts));
|
|
420
440
|
for (const reply of orderedReplies) {
|
|
421
441
|
// Defense: skip ts already at or below the watermark (handles overlap).
|
|
422
|
-
if (wm &&
|
|
442
|
+
if (wm && tsLte(reply.ts, wm.lastTs))
|
|
423
443
|
continue;
|
|
424
444
|
const replyEvent = {
|
|
425
445
|
ts: reply.ts,
|
|
@@ -437,7 +457,7 @@ export class SlackPollingClient {
|
|
|
437
457
|
catch (err) {
|
|
438
458
|
log.error(`dispatch error (reply) channel=${channelId} ts=${reply.ts}: ${describeErr(err)}`);
|
|
439
459
|
}
|
|
440
|
-
if (
|
|
460
|
+
if (tsGt(reply.ts, highestTs)) {
|
|
441
461
|
highestTs = reply.ts;
|
|
442
462
|
}
|
|
443
463
|
}
|
|
@@ -470,6 +490,131 @@ export class SlackPollingClient {
|
|
|
470
490
|
}
|
|
471
491
|
return dispatched;
|
|
472
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
|
+
}
|
|
473
618
|
handleFatal(reason) {
|
|
474
619
|
this.fatalError = reason;
|
|
475
620
|
this.running = false;
|
|
@@ -520,3 +665,112 @@ async function mapWithConcurrency(items, concurrency, worker) {
|
|
|
520
665
|
export function asPollingWebClient(client) {
|
|
521
666
|
return client;
|
|
522
667
|
}
|
|
668
|
+
// ─── Slack TS comparison helpers ───
|
|
669
|
+
//
|
|
670
|
+
// Slack timestamps are strings shaped like `XXXXXXXXXX.YYYYYY` — 10-digit
|
|
671
|
+
// seconds + 6-digit microseconds. Using `parseFloat()` on them silently loses
|
|
672
|
+
// precision in the last digits (JS doubles top out around 15–17 sig figs),
|
|
673
|
+
// which can cause two adjacent timestamps to compare equal and a real message
|
|
674
|
+
// to be silently dropped (`msg.ts <= wm.lastTs` accepting it as already-seen
|
|
675
|
+
// and `msg.ts > highestTs` refusing to advance the watermark). We use BigInt
|
|
676
|
+
// instead so the comparison is exact.
|
|
677
|
+
function tsToBigInt(ts) {
|
|
678
|
+
if (!ts)
|
|
679
|
+
return 0n;
|
|
680
|
+
const dot = ts.indexOf('.');
|
|
681
|
+
const sec = dot === -1 ? ts : ts.slice(0, dot);
|
|
682
|
+
const micro = dot === -1 ? '' : ts.slice(dot + 1);
|
|
683
|
+
// Pad / truncate microseconds to exactly 6 digits so concatenation always
|
|
684
|
+
// produces a consistent-magnitude integer.
|
|
685
|
+
const microPadded = (micro + '000000').slice(0, 6);
|
|
686
|
+
try {
|
|
687
|
+
return BigInt(sec + microPadded);
|
|
688
|
+
}
|
|
689
|
+
catch {
|
|
690
|
+
return 0n;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
/** True if `a <= b`. */
|
|
694
|
+
function tsLte(a, b) {
|
|
695
|
+
return tsToBigInt(a) <= tsToBigInt(b);
|
|
696
|
+
}
|
|
697
|
+
/** True if `a > b`. */
|
|
698
|
+
function tsGt(a, b) {
|
|
699
|
+
return tsToBigInt(a) > tsToBigInt(b);
|
|
700
|
+
}
|
|
701
|
+
/** Compare two ts strings for Array.sort. Returns -1, 0, or 1. */
|
|
702
|
+
function tsCmp(a, b) {
|
|
703
|
+
const ab = tsToBigInt(a);
|
|
704
|
+
const bb = tsToBigInt(b);
|
|
705
|
+
return ab < bb ? -1 : ab > bb ? 1 : 0;
|
|
706
|
+
}
|
|
707
|
+
class TokenBucket {
|
|
708
|
+
tokens;
|
|
709
|
+
lastRefillMs;
|
|
710
|
+
waiters = [];
|
|
711
|
+
pumpScheduled = false;
|
|
712
|
+
capacity;
|
|
713
|
+
refillIntervalMs;
|
|
714
|
+
nowFn;
|
|
715
|
+
globalPauseUntilFn;
|
|
716
|
+
isRunningFn;
|
|
717
|
+
constructor(opts) {
|
|
718
|
+
this.capacity = Math.max(1, opts.capacity);
|
|
719
|
+
this.refillIntervalMs = Math.max(1, opts.refillIntervalMs);
|
|
720
|
+
this.nowFn = opts.now;
|
|
721
|
+
this.globalPauseUntilFn = opts.globalPauseUntil;
|
|
722
|
+
this.isRunningFn = opts.isRunning;
|
|
723
|
+
this.tokens = this.capacity;
|
|
724
|
+
this.lastRefillMs = opts.now();
|
|
725
|
+
}
|
|
726
|
+
refillNow() {
|
|
727
|
+
const elapsed = this.nowFn() - this.lastRefillMs;
|
|
728
|
+
if (elapsed <= 0)
|
|
729
|
+
return;
|
|
730
|
+
const gained = elapsed / this.refillIntervalMs;
|
|
731
|
+
if (gained > 0) {
|
|
732
|
+
this.tokens = Math.min(this.capacity, this.tokens + gained);
|
|
733
|
+
this.lastRefillMs = this.nowFn();
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
async acquire() {
|
|
737
|
+
if (!this.isRunningFn())
|
|
738
|
+
throw new Error('SlackPollingClient stopped');
|
|
739
|
+
// Wait through any global 429 pause before consuming a token so the
|
|
740
|
+
// entire workspace honors Retry-After.
|
|
741
|
+
const pauseRemaining = this.globalPauseUntilFn() - this.nowFn();
|
|
742
|
+
if (pauseRemaining > 0) {
|
|
743
|
+
await new Promise((r) => setTimeout(r, pauseRemaining));
|
|
744
|
+
if (!this.isRunningFn())
|
|
745
|
+
throw new Error('SlackPollingClient stopped');
|
|
746
|
+
}
|
|
747
|
+
this.refillNow();
|
|
748
|
+
if (this.tokens >= 1 && this.waiters.length === 0) {
|
|
749
|
+
this.tokens -= 1;
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
return new Promise((resolve) => {
|
|
753
|
+
this.waiters.push(resolve);
|
|
754
|
+
this.schedulePump();
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
schedulePump() {
|
|
758
|
+
if (this.pumpScheduled)
|
|
759
|
+
return;
|
|
760
|
+
this.pumpScheduled = true;
|
|
761
|
+
const needed = Math.max(0, 1 - this.tokens);
|
|
762
|
+
const waitMs = Math.max(10, Math.ceil(needed * this.refillIntervalMs));
|
|
763
|
+
setTimeout(() => {
|
|
764
|
+
this.pumpScheduled = false;
|
|
765
|
+
this.refillNow();
|
|
766
|
+
while (this.tokens >= 1 && this.waiters.length > 0) {
|
|
767
|
+
this.tokens -= 1;
|
|
768
|
+
const resolve = this.waiters.shift();
|
|
769
|
+
resolve();
|
|
770
|
+
}
|
|
771
|
+
if (this.waiters.length > 0) {
|
|
772
|
+
this.schedulePump();
|
|
773
|
+
}
|
|
774
|
+
}, waitMs);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
@@ -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
|
};
|
|
@@ -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 !== '********') {
|
|
@@ -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)
|