tide-commander 1.89.0 → 1.90.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-BK6N5fG2.js → BossLogsModal-CbmJrD24.js} +1 -1
- package/dist/assets/{BossSpawnModal-BTy-lus4.js → BossSpawnModal-DU0qQ0rG.js} +1 -1
- package/dist/assets/{ControlsModal-B4MhaF1V.js → ControlsModal-D1g7JwQt.js} +1 -1
- package/dist/assets/{DockerLogsModal-C33dAwy1.js → DockerLogsModal-CkyvuwA2.js} +1 -1
- package/dist/assets/{EmbeddedEditor-BfjjT-GF.js → EmbeddedEditor-DbEqhrGZ.js} +1 -1
- package/dist/assets/{GmailOAuthSetup-TQyjHs3_.js → GmailOAuthSetup-w1td375-.js} +1 -1
- package/dist/assets/{GoogleOAuthSetup-DAIzYKy8.js → GoogleOAuthSetup-Bh7zsyVT.js} +1 -1
- package/dist/assets/{IframeModal-g8tC4aah.js → IframeModal-BVjYTjHT.js} +1 -1
- package/dist/assets/{IntegrationsPanel-CuKr7702.js → IntegrationsPanel-C8C4RTuV.js} +2 -2
- package/dist/assets/{LogViewerModal-DO45Kea0.js → LogViewerModal-Hl2pWr16.js} +1 -1
- package/dist/assets/{MonitoringModal-OIwmagj2.js → MonitoringModal-DyjG8VYN.js} +1 -1
- package/dist/assets/{PM2LogsModal-BRQzSiFN.js → PM2LogsModal-Cxo0h_TY.js} +1 -1
- package/dist/assets/{RestoreArchivedAreaModal-CBRN9Xpb.js → RestoreArchivedAreaModal-B5d3KfEX.js} +1 -1
- package/dist/assets/{Scene2DCanvas-4J4ZefT6.js → Scene2DCanvas-Bz4d5QPH.js} +1 -1
- package/dist/assets/{SceneManager-DZsJcYvW.js → SceneManager-Dio2xh20.js} +1 -1
- package/dist/assets/{SkillsPanel-DHk7h3Ja.js → SkillsPanel-BF_nctIH.js} +1 -1
- package/dist/assets/{SlackMultiInstanceSetup-Dp1q2zM1.js → SlackMultiInstanceSetup-BhzqLsTN.js} +1 -1
- package/dist/assets/{SpawnModal-CfozYMNI.js → SpawnModal-o42cyCtw.js} +1 -1
- package/dist/assets/{SubordinateAssignmentModal-BBfbpVUr.js → SubordinateAssignmentModal-BN1fhaoP.js} +1 -1
- package/dist/assets/{TriggerManagerPanel-DQw9nt1r.js → TriggerManagerPanel-DYYfw4yH.js} +1 -1
- package/dist/assets/{WorkflowEditorPanel-BM2ec8CS.js → WorkflowEditorPanel-W-O_xV8J.js} +1 -1
- package/dist/assets/{index-xEvpFBA8.js → index-BREmErtZ.js} +1 -1
- package/dist/assets/{index-jXkaBxIq.js → index-BiFafZOt.js} +3 -3
- package/dist/assets/{index-BqbR55dr.js → index-Bms941O_.js} +1 -1
- package/dist/assets/{index-fZfyvIUZ.js → index-CA6Z2DnN.js} +1 -1
- package/dist/assets/{index-BiAZinYH.js → index-Ce3gEeTm.js} +2 -2
- package/dist/assets/{index-DY9w7IcH.js → index-CpjE7hlE.js} +1 -1
- package/dist/assets/{index-bcwTXJ6F.js → index-DIp_vmTu.js} +1 -1
- package/dist/assets/{index-DNEUJDeO.js → index-DZ9nmuXW.js} +1 -1
- package/dist/assets/{index-CcSJA57k.js → index-Dd46f0FP.js} +1 -1
- package/dist/assets/{main-Bw5ZddEN.css → main-BwtifKg3.css} +1 -1
- package/dist/assets/{main-D-YFCprA.js → main-zTbYBQt_.js} +27 -27
- package/dist/assets/{web-BrBkKQlr.js → web-BUClwxRF.js} +1 -1
- package/dist/assets/{web-DX588C-g.js → web-BovL85Jz.js} +1 -1
- package/dist/assets/{web-DCu3NTho.js → web-EQqNdAMY.js} +1 -1
- package/dist/index.html +2 -2
- package/dist/src/packages/server/integrations/slack/slack-instance.js +100 -2
- package/dist/src/packages/server/integrations/slack/slack-name-cache.js +153 -0
- package/dist/src/packages/server/integrations/slack/slack-trigger-handler.js +17 -1
- package/dist/src/packages/server/integrations/whatsapp/group-name-cache.js +91 -0
- package/dist/src/packages/server/integrations/whatsapp/whatsapp-client.js +4 -0
- package/dist/src/packages/server/integrations/whatsapp/whatsapp-trigger-handler.js +135 -19
- package/dist/src/packages/server/routes/files.js +99 -7
- package/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
import{ck as s}from"./main-
|
|
1
|
+
import{ck as s}from"./main-zTbYBQt_.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-zTbYBQt_.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-zTbYBQt_.js";import{ImpactStyle as i,NotificationType as r}from"./index-Ce3gEeTm.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-zTbYBQt_.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-BwtifKg3.css">
|
|
29
29
|
</head>
|
|
30
30
|
<body>
|
|
31
31
|
<div id="app"></div>
|
|
@@ -17,6 +17,7 @@ import { SocketModeClient } from '@slack/socket-mode';
|
|
|
17
17
|
import { instanceSecretKey, loadConfig, resolveAuthMode, updateConfig } from './slack-config.js';
|
|
18
18
|
import { SlackPollingClient, asPollingWebClient } from './slack-polling-client.js';
|
|
19
19
|
import { SlackWatermarkStore } from './slack-watermark-store.js';
|
|
20
|
+
import { SlackNameCache } from './slack-name-cache.js';
|
|
20
21
|
// Subtypes we never want to trigger on. Modern file-share has NO subtype, legacy uses `file_share` — both must pass.
|
|
21
22
|
const SKIP_MESSAGE_SUBTYPES = new Set([
|
|
22
23
|
'bot_message',
|
|
@@ -51,6 +52,13 @@ export class SlackInstance {
|
|
|
51
52
|
ctx = null;
|
|
52
53
|
userCache = new Map();
|
|
53
54
|
channelNameCache = new Map();
|
|
55
|
+
/**
|
|
56
|
+
* TTL+LRU cache of resolved labels for the trigger template (separate from
|
|
57
|
+
* the unbounded `userCache` / `channelNameCache` above which back the
|
|
58
|
+
* SlackUser objects + raw conversation names). Lives per-instance, so the
|
|
59
|
+
* `default` and `personal` instances never share resolved names.
|
|
60
|
+
*/
|
|
61
|
+
nameCache = new SlackNameCache();
|
|
54
62
|
messageListeners = new Set();
|
|
55
63
|
replyWaiters = new Set();
|
|
56
64
|
constructor(id) {
|
|
@@ -265,11 +273,17 @@ export class SlackInstance {
|
|
|
265
273
|
try {
|
|
266
274
|
const user = await this.resolveUser(userId);
|
|
267
275
|
userName = user?.displayName || user?.name || userId;
|
|
276
|
+
// Prime the lightweight cache so the trigger pipeline + future
|
|
277
|
+
// resolveChannelLabel calls can hit it synchronously.
|
|
278
|
+
this.nameCache.primeUser(userId, userName);
|
|
268
279
|
}
|
|
269
280
|
catch {
|
|
270
281
|
// Use userId as fallback
|
|
271
282
|
}
|
|
272
283
|
}
|
|
284
|
+
// Best-effort channel-label resolve. Empty string fallback lets consumers
|
|
285
|
+
// see the raw id when name metadata is unavailable.
|
|
286
|
+
const channelName = await this.resolveChannelLabel(event.channel).catch(() => '');
|
|
273
287
|
const files = hasFiles
|
|
274
288
|
? event.files.map((f) => normalizeSlackFile(f))
|
|
275
289
|
: undefined;
|
|
@@ -277,6 +291,7 @@ export class SlackInstance {
|
|
|
277
291
|
ts: event.ts,
|
|
278
292
|
threadTs: event.thread_ts,
|
|
279
293
|
channel: event.channel,
|
|
294
|
+
channelName: channelName ?? '',
|
|
280
295
|
userId,
|
|
281
296
|
userName,
|
|
282
297
|
text,
|
|
@@ -286,8 +301,10 @@ export class SlackInstance {
|
|
|
286
301
|
};
|
|
287
302
|
const direction = isOwnMessage ? 'outbound' : 'inbound';
|
|
288
303
|
// Heartbeat: one line per dispatched message. PII-safe — only ids, ts,
|
|
289
|
-
// direction, file count
|
|
290
|
-
|
|
304
|
+
// direction, file count, and resolved-flag for name lookups (Y/N), no
|
|
305
|
+
// names/emails/text.
|
|
306
|
+
const resolvedFlag = `userResolved=${userName && userName !== userId ? 'Y' : 'N'} channelResolved=${channelName ? 'Y' : 'N'}`;
|
|
307
|
+
this.ctx?.log.info(`Slack[${this.id}] dispatch: channel=${event.channel} user=${userId || '-'} ts=${event.ts} direction=${direction} files=${files?.length ?? 0}${event.thread_ts && event.thread_ts !== event.ts ? ` thread=${event.thread_ts}` : ''} ${resolvedFlag}`);
|
|
291
308
|
this.ctx?.eventDb.logSlackMessage({
|
|
292
309
|
ts: event.ts,
|
|
293
310
|
threadTs: event.thread_ts,
|
|
@@ -488,6 +505,81 @@ export class SlackInstance {
|
|
|
488
505
|
this.userCache.set(userId, user);
|
|
489
506
|
return user;
|
|
490
507
|
}
|
|
508
|
+
/**
|
|
509
|
+
* Resolve a channel id to a human-readable label for trigger templates and
|
|
510
|
+
* the Guake bubble. Cached via SlackNameCache (10 min TTL, LRU 500).
|
|
511
|
+
* - public/private channels: `#name`
|
|
512
|
+
* - 1:1 DM (im): `DM con @counterparty`
|
|
513
|
+
* - multi-party DM (mpim): `Grupo: @user1, @user2, +N more`
|
|
514
|
+
* Returns the empty string when metadata can't be fetched — caller should
|
|
515
|
+
* fall back to the raw id.
|
|
516
|
+
*/
|
|
517
|
+
async resolveChannelLabel(channelId) {
|
|
518
|
+
if (!channelId)
|
|
519
|
+
return '';
|
|
520
|
+
const cached = this.nameCache.peekChannel(channelId);
|
|
521
|
+
if (cached !== undefined)
|
|
522
|
+
return cached ?? '';
|
|
523
|
+
const label = await this.nameCache.lookupChannel(channelId, async () => {
|
|
524
|
+
if (!this.webClient)
|
|
525
|
+
return null;
|
|
526
|
+
const info = await this.webClient.conversations.info({
|
|
527
|
+
channel: channelId,
|
|
528
|
+
include_num_members: false,
|
|
529
|
+
});
|
|
530
|
+
const ch = info.channel;
|
|
531
|
+
if (!ch)
|
|
532
|
+
return null;
|
|
533
|
+
// Public/private channel → `#name`. Slack stores both kinds with `is_channel`
|
|
534
|
+
// / `is_group` flags + a `name` field.
|
|
535
|
+
const name = typeof ch.name === 'string' ? ch.name : undefined;
|
|
536
|
+
if (ch.is_im) {
|
|
537
|
+
const counterpartyId = typeof ch.user === 'string' ? ch.user : '';
|
|
538
|
+
if (!counterpartyId)
|
|
539
|
+
return 'DM';
|
|
540
|
+
const counterparty = await this.resolveUserNameSafe(counterpartyId);
|
|
541
|
+
return counterparty ? `DM con @${counterparty}` : `DM con @${counterpartyId}`;
|
|
542
|
+
}
|
|
543
|
+
if (ch.is_mpim) {
|
|
544
|
+
// members may be returned via include_num_members or via a follow-up
|
|
545
|
+
// conversations.members call; mpim usually returns it inline.
|
|
546
|
+
const members = Array.isArray(ch.members) ? ch.members : [];
|
|
547
|
+
if (members.length === 0 && name)
|
|
548
|
+
return `Grupo: ${name}`;
|
|
549
|
+
const labels = await Promise.all(members.slice(0, 3).map(async (uid) => {
|
|
550
|
+
const n = await this.resolveUserNameSafe(uid);
|
|
551
|
+
return n ? `@${n}` : `@${uid}`;
|
|
552
|
+
}));
|
|
553
|
+
const more = members.length > 3 ? `, +${members.length - 3} more` : '';
|
|
554
|
+
return labels.length > 0 ? `Grupo: ${labels.join(', ')}${more}` : (name ? `Grupo: ${name}` : 'Grupo');
|
|
555
|
+
}
|
|
556
|
+
if (name)
|
|
557
|
+
return `#${name}`;
|
|
558
|
+
return null;
|
|
559
|
+
});
|
|
560
|
+
return label ?? '';
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* Resolve a userId to its preferred display string (display_name → name →
|
|
564
|
+
* real_name) without throwing. Returns null when the lookup fails. Cached.
|
|
565
|
+
*/
|
|
566
|
+
async resolveUserNameSafe(userId) {
|
|
567
|
+
if (!userId)
|
|
568
|
+
return null;
|
|
569
|
+
return this.nameCache.lookupUser(userId, async () => {
|
|
570
|
+
try {
|
|
571
|
+
const user = await this.resolveUser(userId);
|
|
572
|
+
return user?.displayName || user?.name || user?.realName || null;
|
|
573
|
+
}
|
|
574
|
+
catch {
|
|
575
|
+
return null;
|
|
576
|
+
}
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
/** Test/diagnostic accessor for the per-instance name cache. */
|
|
580
|
+
getNameCache() {
|
|
581
|
+
return this.nameCache;
|
|
582
|
+
}
|
|
491
583
|
async findUserByEmail(email) {
|
|
492
584
|
if (!this.webClient)
|
|
493
585
|
throw new Error('Slack not connected');
|
|
@@ -725,10 +817,16 @@ export class SlackInstance {
|
|
|
725
817
|
}
|
|
726
818
|
const rawFiles = msg.files;
|
|
727
819
|
const files = rawFiles?.length ? rawFiles.map(normalizeSlackFile) : undefined;
|
|
820
|
+
// Best-effort channel-name lookup. This path is the historical
|
|
821
|
+
// getChannelMessages / getThreadReplies API surface, used by skills not
|
|
822
|
+
// by the trigger pipeline; if the resolver fails we leave channelName
|
|
823
|
+
// empty and the caller falls back to `channel` (the raw id).
|
|
824
|
+
const channelName = await this.resolveChannelLabel(channel).catch(() => '');
|
|
728
825
|
return {
|
|
729
826
|
ts: msg.ts,
|
|
730
827
|
threadTs: msg.thread_ts,
|
|
731
828
|
channel,
|
|
829
|
+
channelName: channelName ?? '',
|
|
732
830
|
userId,
|
|
733
831
|
userName,
|
|
734
832
|
text: msg.text || '',
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SlackNameCache — caches resolved display names for Slack user IDs and
|
|
3
|
+
* channel IDs so the trigger template can render `@David` / `#navi` instead
|
|
4
|
+
* of cryptic `U078UV85AEM` / `C02JE0A23BJ`.
|
|
5
|
+
*
|
|
6
|
+
* One instance is owned by each `SlackInstance`, giving free per-instance
|
|
7
|
+
* isolation: the `default` (xoxb- bot) and `personal` (xoxp- user token) caches
|
|
8
|
+
* never bleed into each other even when the same user/channel id is visible
|
|
9
|
+
* to both tokens.
|
|
10
|
+
*
|
|
11
|
+
* The `users.info` / `conversations.info` Slack endpoints are Tier 4
|
|
12
|
+
* (~100 req/min) so we don't bother routing through the polling pacer; with
|
|
13
|
+
* cache hit-rate ≈100% after warm-up the additional traffic is negligible.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Insertion-ordered Map gives free LRU when we evict the oldest on overflow,
|
|
17
|
+
* same trick used by message-dedupe.ts in the WhatsApp integration.
|
|
18
|
+
*/
|
|
19
|
+
class TtlLruCache {
|
|
20
|
+
ttlMs;
|
|
21
|
+
maxEntries;
|
|
22
|
+
now;
|
|
23
|
+
map = new Map();
|
|
24
|
+
constructor(ttlMs, maxEntries, now) {
|
|
25
|
+
this.ttlMs = ttlMs;
|
|
26
|
+
this.maxEntries = maxEntries;
|
|
27
|
+
this.now = now;
|
|
28
|
+
}
|
|
29
|
+
get(key) {
|
|
30
|
+
const entry = this.map.get(key);
|
|
31
|
+
if (!entry)
|
|
32
|
+
return undefined;
|
|
33
|
+
if (this.now() - entry.storedAt > this.ttlMs) {
|
|
34
|
+
this.map.delete(key);
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
return entry.value;
|
|
38
|
+
}
|
|
39
|
+
set(key, value) {
|
|
40
|
+
if (this.map.has(key))
|
|
41
|
+
this.map.delete(key);
|
|
42
|
+
this.map.set(key, { value, storedAt: this.now() });
|
|
43
|
+
if (this.map.size > this.maxEntries) {
|
|
44
|
+
// Drop the oldest (insertion order) to bound memory.
|
|
45
|
+
const oldest = this.map.keys().next();
|
|
46
|
+
if (!oldest.done)
|
|
47
|
+
this.map.delete(oldest.value);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
size() {
|
|
51
|
+
return this.map.size;
|
|
52
|
+
}
|
|
53
|
+
clear() {
|
|
54
|
+
this.map.clear();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
export class SlackNameCache {
|
|
58
|
+
users;
|
|
59
|
+
channels;
|
|
60
|
+
userInflight = new Map();
|
|
61
|
+
channelInflight = new Map();
|
|
62
|
+
constructor(opts = {}) {
|
|
63
|
+
const ttl = opts.ttlMs ?? 10 * 60_000;
|
|
64
|
+
const max = opts.maxEntries ?? 500;
|
|
65
|
+
const now = opts.now ?? Date.now;
|
|
66
|
+
this.users = new TtlLruCache(ttl, max, now);
|
|
67
|
+
this.channels = new TtlLruCache(ttl, max, now);
|
|
68
|
+
}
|
|
69
|
+
/** Synchronous read — returns the cached value or undefined when stale/absent. */
|
|
70
|
+
peekUser(userId) {
|
|
71
|
+
return this.users.get(userId);
|
|
72
|
+
}
|
|
73
|
+
peekChannel(channelId) {
|
|
74
|
+
return this.channels.get(channelId);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Resolve a userId to a display name with cache + in-flight coalescing. The
|
|
78
|
+
* fetcher is provided by `SlackInstance` so this module stays decoupled from
|
|
79
|
+
* the WebClient. Returns null when the name can't be resolved — callers
|
|
80
|
+
* fall back to the bare userId.
|
|
81
|
+
*/
|
|
82
|
+
async lookupUser(userId, fetcher) {
|
|
83
|
+
if (!userId)
|
|
84
|
+
return null;
|
|
85
|
+
const cached = this.users.get(userId);
|
|
86
|
+
if (cached !== undefined)
|
|
87
|
+
return cached;
|
|
88
|
+
const pending = this.userInflight.get(userId);
|
|
89
|
+
if (pending)
|
|
90
|
+
return pending;
|
|
91
|
+
const promise = (async () => {
|
|
92
|
+
try {
|
|
93
|
+
const name = await fetcher();
|
|
94
|
+
this.users.set(userId, name ?? null);
|
|
95
|
+
return name ?? null;
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
// Don't poison the cache on transient errors — a future call retries.
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
this.userInflight.delete(userId);
|
|
103
|
+
}
|
|
104
|
+
})();
|
|
105
|
+
this.userInflight.set(userId, promise);
|
|
106
|
+
return promise;
|
|
107
|
+
}
|
|
108
|
+
async lookupChannel(channelId, fetcher) {
|
|
109
|
+
if (!channelId)
|
|
110
|
+
return null;
|
|
111
|
+
const cached = this.channels.get(channelId);
|
|
112
|
+
if (cached !== undefined)
|
|
113
|
+
return cached;
|
|
114
|
+
const pending = this.channelInflight.get(channelId);
|
|
115
|
+
if (pending)
|
|
116
|
+
return pending;
|
|
117
|
+
const promise = (async () => {
|
|
118
|
+
try {
|
|
119
|
+
const name = await fetcher();
|
|
120
|
+
this.channels.set(channelId, name ?? null);
|
|
121
|
+
return name ?? null;
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
finally {
|
|
127
|
+
this.channelInflight.delete(channelId);
|
|
128
|
+
}
|
|
129
|
+
})();
|
|
130
|
+
this.channelInflight.set(channelId, promise);
|
|
131
|
+
return promise;
|
|
132
|
+
}
|
|
133
|
+
/** Force-prime an entry — used when other code paths already know the name. */
|
|
134
|
+
primeUser(userId, name) {
|
|
135
|
+
if (!userId)
|
|
136
|
+
return;
|
|
137
|
+
this.users.set(userId, name);
|
|
138
|
+
}
|
|
139
|
+
primeChannel(channelId, name) {
|
|
140
|
+
if (!channelId)
|
|
141
|
+
return;
|
|
142
|
+
this.channels.set(channelId, name);
|
|
143
|
+
}
|
|
144
|
+
size() {
|
|
145
|
+
return { users: this.users.size(), channels: this.channels.size() };
|
|
146
|
+
}
|
|
147
|
+
clear() {
|
|
148
|
+
this.users.clear();
|
|
149
|
+
this.channels.clear();
|
|
150
|
+
this.userInflight.clear();
|
|
151
|
+
this.channelInflight.clear();
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -90,16 +90,31 @@ export const slackTriggerHandler = {
|
|
|
90
90
|
const msg = event.data;
|
|
91
91
|
void trigger;
|
|
92
92
|
const files = msg.files ?? [];
|
|
93
|
+
// Resolved channel label: `#name` / `DM con @user` / `Grupo: @a, @b…`.
|
|
94
|
+
// Falls back to the raw id when the resolver couldn't fetch metadata
|
|
95
|
+
// (network blip, missing scope, deleted channel).
|
|
96
|
+
const channelLabel = msg.channelName || msg.channel;
|
|
93
97
|
return {
|
|
94
98
|
'slack.user': msg.userName,
|
|
99
|
+
// fromName/fromId are the boss-canonical names mirroring the Slack
|
|
100
|
+
// template the trigger renderer uses. Aliases for slack.user/userId so
|
|
101
|
+
// user-authored templates can pick whichever reads better.
|
|
102
|
+
'slack.fromName': msg.userName,
|
|
95
103
|
'slack.userId': msg.userId,
|
|
104
|
+
'slack.fromId': msg.userId,
|
|
96
105
|
'slack.message': msg.text,
|
|
106
|
+
'slack.body': msg.text,
|
|
97
107
|
'slack.channel': msg.channel,
|
|
108
|
+
'slack.channelId': msg.channel,
|
|
109
|
+
'slack.channelName': channelLabel,
|
|
98
110
|
'slack.threadTs': msg.threadTs || msg.ts,
|
|
99
111
|
'slack.fileCount': String(files.length),
|
|
112
|
+
'slack.attachmentsCount': String(files.length),
|
|
100
113
|
'slack.fileIds': files.map((f) => f.id).join(','),
|
|
101
114
|
'slack.fileNames': files.map((f) => f.name ?? '').filter(Boolean).join(','),
|
|
115
|
+
'slack.attachmentsList': files.map((f) => f.name ?? '').filter(Boolean).join(','),
|
|
102
116
|
'slack.instanceId': msg.instanceId,
|
|
117
|
+
'slack.instanceName': msg.instanceId,
|
|
103
118
|
};
|
|
104
119
|
},
|
|
105
120
|
formatEventForLLM(event) {
|
|
@@ -109,7 +124,8 @@ export const slackTriggerHandler = {
|
|
|
109
124
|
? `\nAttachments (${files.length}): ${files.map((f) => `${f.name ?? f.id} [${f.mimetype ?? 'unknown'}]`).join(', ')}`
|
|
110
125
|
: '';
|
|
111
126
|
const instanceLine = msg.instanceId !== 'default' ? ` [Slack instance: ${msg.instanceId}]` : '';
|
|
112
|
-
|
|
127
|
+
const channelDisplay = msg.channelName || msg.channel;
|
|
128
|
+
return `Slack message from @${msg.userName} (${msg.userId}) in ${channelDisplay} (${msg.channel})${instanceLine}:\n"${msg.text}"${filesLine}`;
|
|
113
129
|
},
|
|
114
130
|
};
|
|
115
131
|
// `slackClient` import kept (re-exports SlackMessage type used above).
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GroupNameCache — resolves a Baileys group JID (`*@g.us`) to its current
|
|
3
|
+
* subject via the upstream `GET /api/sessions/:id/groups` endpoint.
|
|
4
|
+
*
|
|
5
|
+
* Why this exists: per-message webhook payloads from the upstream WA service
|
|
6
|
+
* do not carry the group subject (verified against trigger_events for the
|
|
7
|
+
* Bolba group: `groupName` was always NULL). The upstream DOES expose the
|
|
8
|
+
* subject through the groups list endpoint — one call warms many JIDs at
|
|
9
|
+
* once. Without this cache, the bridge would always fall back to
|
|
10
|
+
* `humanizeGroupJid()` and bubbles would render `Grupo <last4>` instead of
|
|
11
|
+
* the real subject.
|
|
12
|
+
*
|
|
13
|
+
* Mirrors ContactNameCache deliberately — same TTL semantics, same fetch
|
|
14
|
+
* coalescing, same negative caching, same prime() escape hatch.
|
|
15
|
+
*/
|
|
16
|
+
export class GroupNameCache {
|
|
17
|
+
entries = new Map();
|
|
18
|
+
ttlMs;
|
|
19
|
+
now;
|
|
20
|
+
fetchGroups;
|
|
21
|
+
inflight = new Map();
|
|
22
|
+
constructor(opts) {
|
|
23
|
+
this.ttlMs = opts.ttlMs ?? 10 * 60_000;
|
|
24
|
+
this.now = opts.now ?? Date.now;
|
|
25
|
+
this.fetchGroups = opts.fetchGroups;
|
|
26
|
+
}
|
|
27
|
+
async lookup(sessionId, jid) {
|
|
28
|
+
if (!sessionId || !jid)
|
|
29
|
+
return null;
|
|
30
|
+
const key = this.cacheKey(sessionId, jid);
|
|
31
|
+
const cached = this.entries.get(key);
|
|
32
|
+
if (cached && this.now() - cached.ts < this.ttlMs) {
|
|
33
|
+
return cached.name;
|
|
34
|
+
}
|
|
35
|
+
await this.refreshSession(sessionId);
|
|
36
|
+
const after = this.entries.get(key);
|
|
37
|
+
if (after)
|
|
38
|
+
return after.name;
|
|
39
|
+
// Negative-cache so we don't refetch on every message from this group.
|
|
40
|
+
this.entries.set(key, { name: null, ts: this.now() });
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
peek(sessionId, jid) {
|
|
44
|
+
return this.entries.get(this.cacheKey(sessionId, jid));
|
|
45
|
+
}
|
|
46
|
+
size() {
|
|
47
|
+
return this.entries.size;
|
|
48
|
+
}
|
|
49
|
+
clear() {
|
|
50
|
+
this.entries.clear();
|
|
51
|
+
this.inflight.clear();
|
|
52
|
+
}
|
|
53
|
+
prime(sessionId, groups) {
|
|
54
|
+
const t = this.now();
|
|
55
|
+
for (const g of groups) {
|
|
56
|
+
if (!g?.id)
|
|
57
|
+
continue;
|
|
58
|
+
const key = this.cacheKey(sessionId, g.id);
|
|
59
|
+
const name = typeof g.name === 'string' && g.name ? g.name : null;
|
|
60
|
+
this.entries.set(key, { name, ts: t });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
cacheKey(sessionId, jid) {
|
|
64
|
+
return `${sessionId}:${jid}`;
|
|
65
|
+
}
|
|
66
|
+
async refreshSession(sessionId) {
|
|
67
|
+
const pending = this.inflight.get(sessionId);
|
|
68
|
+
if (pending) {
|
|
69
|
+
await pending;
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const promise = (async () => {
|
|
73
|
+
try {
|
|
74
|
+
const groups = await this.fetchGroups(sessionId);
|
|
75
|
+
if (Array.isArray(groups))
|
|
76
|
+
this.prime(sessionId, groups);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// Swallow — caller falls back to humanizeGroupJid. Don't cache an empty
|
|
80
|
+
// result on error; let the next call retry.
|
|
81
|
+
}
|
|
82
|
+
})();
|
|
83
|
+
this.inflight.set(sessionId, promise);
|
|
84
|
+
try {
|
|
85
|
+
await promise;
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
this.inflight.delete(sessionId);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -39,6 +39,10 @@ export class WhatsAppClient {
|
|
|
39
39
|
const data = await this.request('GET', `/api/sessions/${encodeURIComponent(sessionId)}/contacts`);
|
|
40
40
|
return Array.isArray(data) ? data : [];
|
|
41
41
|
}
|
|
42
|
+
async getGroups(sessionId) {
|
|
43
|
+
const data = await this.request('GET', `/api/sessions/${encodeURIComponent(sessionId)}/groups`);
|
|
44
|
+
return Array.isArray(data) ? data : [];
|
|
45
|
+
}
|
|
42
46
|
async syncChatMessages(sessionId, chatId, count = 50) {
|
|
43
47
|
return this.request('POST', `/api/sessions/${encodeURIComponent(sessionId)}/chats/${encodeURIComponent(chatId)}/sync-messages?count=${encodeURIComponent(String(count))}`, {});
|
|
44
48
|
}
|