tide-commander 1.146.0 → 1.147.1
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/CHANGELOG.md +16 -0
- package/dist/assets/{BossLogsModal-BR0IXO2N.js → BossLogsModal-Cn8IVdqn.js} +1 -1
- package/dist/assets/{BossSpawnModal-x75-j2da.js → BossSpawnModal-Don-RZyh.js} +1 -1
- package/dist/assets/{ControlsModal-DVil4qEv.js → ControlsModal-C9--xWPi.js} +1 -1
- package/dist/assets/{DockerLogsModal-CFREftxm.js → DockerLogsModal-DSGnpfm5.js} +1 -1
- package/dist/assets/{EmbeddedEditor-BB1dXjgA.js → EmbeddedEditor-DfM3SrO7.js} +1 -1
- package/dist/assets/{GmailOAuthSetup-Bt4lYIib.js → GmailOAuthSetup-DBWs7uzU.js} +1 -1
- package/dist/assets/{GoogleOAuthSetup-D9WbR8pi.js → GoogleOAuthSetup-yQjT5QzU.js} +1 -1
- package/dist/assets/{IframeModal-VRxo8uW0.js → IframeModal-DUTIyP5I.js} +1 -1
- package/dist/assets/{IntegrationsPanel-CPGny7vx.js → IntegrationsPanel-DPURuiaz.js} +2 -2
- package/dist/assets/{LogViewerModal-bkAbzlbh.js → LogViewerModal-CDZvTdfO.js} +1 -1
- package/dist/assets/{MonitoringModal-CkpuOazc.js → MonitoringModal-Dq2EJgbH.js} +1 -1
- package/dist/assets/{PM2LogsModal-Cllh-p5p.js → PM2LogsModal-C3dlYlGv.js} +1 -1
- package/dist/assets/{RestoreArchivedAreaModal-DLjiody0.js → RestoreArchivedAreaModal-BMFk0G8D.js} +1 -1
- package/dist/assets/{Scene2DCanvas-UxkEcgli.js → Scene2DCanvas-DlumJCbl.js} +1 -1
- package/dist/assets/{SceneManager-3z1c1ca8.js → SceneManager-UnhMFja8.js} +1 -1
- package/dist/assets/{SkillsPanel-Opv6_qsA.js → SkillsPanel-D326VBDa.js} +1 -1
- package/dist/assets/{SlackMultiInstanceSetup-BRBXqcBK.js → SlackMultiInstanceSetup-CA4nm925.js} +1 -1
- package/dist/assets/{SpawnModal-Cgr2So6H.js → SpawnModal-B1QOjZPe.js} +1 -1
- package/dist/assets/{StatisticsModal-CQ9zX_te.js → StatisticsModal-BUpvkJXA.js} +1 -1
- package/dist/assets/{SubordinateAssignmentModal-DYl_KFut.js → SubordinateAssignmentModal-DtVsKDqV.js} +1 -1
- package/dist/assets/{TriggerManagerPanel-DteLW6l2.js → TriggerManagerPanel-OD14xVMw.js} +1 -1
- package/dist/assets/{WorkflowEditorPanel-C_OQpYuO.js → WorkflowEditorPanel-B4UF3rcS.js} +1 -1
- package/dist/assets/{area-logos-CcA80suT.js → area-logos-Uc9SAeYg.js} +1 -1
- package/dist/assets/{index-CtzZOKXq.js → index-BC3Y_BAQ.js} +1 -1
- package/dist/assets/{index-DQhrNqbn.js → index-BD6swoQU.js} +3 -3
- package/dist/assets/index-C4mf6B5U.js +1 -0
- package/dist/assets/index-COHImd74.js +1 -0
- package/dist/assets/{index-CIuAGpI4.js → index-CScFEdm8.js} +1 -1
- package/dist/assets/{index-CCFfkD_H.js → index-CmmpVZ8w.js} +1 -1
- package/dist/assets/{index-BMCNBI0L.js → index-CuHJm43U.js} +1 -1
- package/dist/assets/{index-DhL1siJA.js → index-DkDzXtdj.js} +1 -1
- package/dist/assets/{index-DtUDyvyC.js → index-xBtKu0I8.js} +2 -2
- package/dist/assets/{main-BhpZ8DMy.js → main-Bp6V_-sL.js} +6 -6
- package/dist/assets/{web-5BrjMiIU.js → web-B-xc0c6e.js} +1 -1
- package/dist/assets/{web-Cx4v8f8d.js → web-BuDeKWBN.js} +1 -1
- package/dist/assets/{web-DpsRRzsh.js → web-DlzqHyn8.js} +1 -1
- package/dist/index.html +1 -1
- package/dist/locales/en/config.json +2 -1
- package/dist/locales/es/config.json +2 -1
- package/dist/src/packages/server/data/event-queries.js +24 -0
- package/dist/src/packages/server/data/migrations/008_slack_messages_chan_ts_index.sql +5 -0
- package/dist/src/packages/server/index.js +2 -0
- package/dist/src/packages/server/integrations/slack/slack-config.js +13 -0
- package/dist/src/packages/server/integrations/slack/slack-instance.js +67 -3
- package/dist/src/packages/server/integrations/slack/slack-polling-client.js +149 -68
- package/package.json +1 -1
- package/dist/assets/index-ClQaYJNq.js +0 -1
- package/dist/assets/index-k4vAPWgE.js +0 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
import{c$ as t}from"./main-
|
|
1
|
+
import{c$ as t}from"./main-Bp6V_-sL.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{c$ as a}from"./main-
|
|
1
|
+
import{c$ as a}from"./main-Bp6V_-sL.js";import{ImpactStyle as i,NotificationType as r}from"./index-xBtKu0I8.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};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{c$ as s}from"./main-
|
|
1
|
+
import{c$ as s}from"./main-Bp6V_-sL.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};
|
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-Bp6V_-sL.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-ijcbyjBn.css">
|
|
@@ -568,7 +568,8 @@
|
|
|
568
568
|
"autoUpdatePackageManagerNotice": "Auto-update only supports npm-installed globals. Detected: {{pm}}.",
|
|
569
569
|
"unattendedTitle": "Automatic updates",
|
|
570
570
|
"unattendedDesc": "Install updates automatically when no agent is working (checks every 30 min)",
|
|
571
|
-
"unattendedPendingRestart": "v{{version}} installed — will restart as soon as all agents are idle"
|
|
571
|
+
"unattendedPendingRestart": "v{{version}} installed — will restart as soon as all agents are idle",
|
|
572
|
+
"unattendedUnsupported": "Only available for npm global installs (runs on the server, not in dev mode)"
|
|
572
573
|
},
|
|
573
574
|
"updateBanner": {
|
|
574
575
|
"newVersion": "New version v{{version}} available",
|
|
@@ -315,7 +315,8 @@
|
|
|
315
315
|
"claudeCodeCredit": "por Anthropic, el motor de IA",
|
|
316
316
|
"unattendedTitle": "Actualizaciones automáticas",
|
|
317
317
|
"unattendedDesc": "Instalar actualizaciones automáticamente cuando ningún agente esté trabajando (revisa cada 30 min)",
|
|
318
|
-
"unattendedPendingRestart": "v{{version}} instalada — se reiniciará en cuanto todos los agentes estén libres"
|
|
318
|
+
"unattendedPendingRestart": "v{{version}} instalada — se reiniciará en cuanto todos los agentes estén libres",
|
|
319
|
+
"unattendedUnsupported": "Solo disponible en instalaciones globales de npm (corre en el servidor, no en modo dev)"
|
|
319
320
|
},
|
|
320
321
|
"shortcuts": {
|
|
321
322
|
"pressKeys": "Presione teclas...",
|
|
@@ -78,6 +78,30 @@ function slackRowToEvent(row) {
|
|
|
78
78
|
integrationInstanceId: row.integration_instance_id ?? 'default',
|
|
79
79
|
};
|
|
80
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* Channels + threads with Slack activity since `sinceMs` for one integration
|
|
83
|
+
* instance. Seeds the polling reconciler's thread registry on startup so
|
|
84
|
+
* per-thread sweeps survive process restarts. Only rows that carry a
|
|
85
|
+
* thread_ts count — a thread where an agent engaged always has at least the
|
|
86
|
+
* bot's own mirrored reply.
|
|
87
|
+
*/
|
|
88
|
+
export function recentSlackThreadActivity(instanceId, sinceMs) {
|
|
89
|
+
const rows = queryMany(`SELECT channel_id, thread_ts, MAX(received_at) AS last_ms
|
|
90
|
+
FROM slack_messages
|
|
91
|
+
WHERE integration_instance_id = ? AND thread_ts IS NOT NULL AND received_at > ?
|
|
92
|
+
GROUP BY channel_id, thread_ts`, [instanceId, sinceMs]);
|
|
93
|
+
return rows.map((r) => ({ channelId: r.channel_id, threadTs: r.thread_ts, lastActivityMs: r.last_ms }));
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* True when a message with this (channel, ts) is already logged for the given
|
|
97
|
+
* integration instance. Used by the Slack socket-mode reconciler to skip
|
|
98
|
+
* messages the live socket already dispatched — including across process
|
|
99
|
+
* restarts, which the in-memory dedup set alone can't cover.
|
|
100
|
+
*/
|
|
101
|
+
export function hasSlackMessage(instanceId, channelId, ts) {
|
|
102
|
+
const row = queryOne('SELECT id FROM slack_messages WHERE channel_id = ? AND ts = ? AND integration_instance_id = ? LIMIT 1', [channelId, ts, instanceId]);
|
|
103
|
+
return row !== undefined;
|
|
104
|
+
}
|
|
81
105
|
export function logSlackMessage(msg) {
|
|
82
106
|
return insertOne('slack_messages', {
|
|
83
107
|
ts: msg.ts,
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
-- Fast (channel_id, ts) point lookups for the Slack socket-mode reconciler
|
|
2
|
+
-- dedup check: the reconciler re-fetches recent history every cycle and must
|
|
3
|
+
-- cheaply skip messages Socket Mode already dispatched, including across
|
|
4
|
+
-- process restarts (where the in-memory dedup set is empty).
|
|
5
|
+
CREATE INDEX IF NOT EXISTS idx_slack_messages_chan_ts ON slack_messages(channel_id, ts);
|
|
@@ -79,6 +79,8 @@ async function main() {
|
|
|
79
79
|
eventDb: {
|
|
80
80
|
logTriggerFire: eventQueries.logTriggerFire,
|
|
81
81
|
logSlackMessage: eventQueries.logSlackMessage,
|
|
82
|
+
hasSlackMessage: eventQueries.hasSlackMessage,
|
|
83
|
+
recentSlackThreadActivity: eventQueries.recentSlackThreadActivity,
|
|
82
84
|
logWhatsAppMessage: eventQueries.logWhatsAppMessage,
|
|
83
85
|
logEmailMessage: eventQueries.logEmailMessage,
|
|
84
86
|
logApprovalEvent: eventQueries.logApprovalEvent,
|
|
@@ -18,6 +18,7 @@ const DEFAULT_CONFIG = {
|
|
|
18
18
|
pollingDmsAlways: true,
|
|
19
19
|
pollingMinMsBetweenCalls: 1500,
|
|
20
20
|
pollingUseSearch: false,
|
|
21
|
+
socketReconcileEnabled: true,
|
|
21
22
|
mirrorOwnMessages: false,
|
|
22
23
|
reactOnTrigger: true,
|
|
23
24
|
currentMode: 'none',
|
|
@@ -194,6 +195,14 @@ export const slackConfigSchema = [
|
|
|
194
195
|
defaultValue: false,
|
|
195
196
|
group: 'Polling',
|
|
196
197
|
},
|
|
198
|
+
{
|
|
199
|
+
key: 'socketReconcileEnabled',
|
|
200
|
+
label: 'Socket Mode: polling reconciler',
|
|
201
|
+
type: 'boolean',
|
|
202
|
+
description: 'When inbound mode is Socket, also run the polling sweep in the background to recover messages the socket dropped (disconnects, ping-timeout flaps, process restarts). Uses the Polling settings above; duplicates are filtered by (channel, ts). Default on.',
|
|
203
|
+
defaultValue: true,
|
|
204
|
+
group: 'Polling',
|
|
205
|
+
},
|
|
197
206
|
{
|
|
198
207
|
key: 'mirrorOwnMessages',
|
|
199
208
|
label: 'Mirror messages I send too',
|
|
@@ -251,6 +260,7 @@ export function getConfigValues(secrets, instanceId = DEFAULT_INSTANCE_ID) {
|
|
|
251
260
|
pollingDmsAlways: config.pollingDmsAlways ?? true,
|
|
252
261
|
pollingMinMsBetweenCalls: config.pollingMinMsBetweenCalls ?? 1500,
|
|
253
262
|
pollingUseSearch: config.pollingUseSearch ?? false,
|
|
263
|
+
socketReconcileEnabled: config.socketReconcileEnabled ?? true,
|
|
254
264
|
mirrorOwnMessages: config.mirrorOwnMessages ?? false,
|
|
255
265
|
reactOnTrigger: config.reactOnTrigger ?? true,
|
|
256
266
|
currentMode: config.currentMode ?? 'none',
|
|
@@ -303,6 +313,9 @@ export async function setConfigValues(values, secrets, instanceId = DEFAULT_INST
|
|
|
303
313
|
if (typeof values.pollingUseSearch === 'boolean') {
|
|
304
314
|
updates.pollingUseSearch = values.pollingUseSearch;
|
|
305
315
|
}
|
|
316
|
+
if (typeof values.socketReconcileEnabled === 'boolean') {
|
|
317
|
+
updates.socketReconcileEnabled = values.socketReconcileEnabled;
|
|
318
|
+
}
|
|
306
319
|
if (typeof values.mirrorOwnMessages === 'boolean') {
|
|
307
320
|
updates.mirrorOwnMessages = values.mirrorOwnMessages;
|
|
308
321
|
}
|
|
@@ -62,9 +62,26 @@ export class SlackInstance {
|
|
|
62
62
|
nameCache = new SlackNameCache();
|
|
63
63
|
messageListeners = new Set();
|
|
64
64
|
replyWaiters = new Set();
|
|
65
|
+
/**
|
|
66
|
+
* (channel:ts) keys already dispatched by THIS process. With the socket +
|
|
67
|
+
* reconciler hybrid both transports funnel into dispatchInboundMessage, so
|
|
68
|
+
* every message can arrive twice — the Set (insertion-ordered, capped)
|
|
69
|
+
* short-circuits the overlap synchronously; the SQLite check in the
|
|
70
|
+
* dispatcher covers keys dispatched before a restart wiped this Set.
|
|
71
|
+
*/
|
|
72
|
+
dispatchedKeys = new Set();
|
|
73
|
+
static DISPATCHED_KEYS_MAX = 4096;
|
|
65
74
|
constructor(id) {
|
|
66
75
|
this.id = id;
|
|
67
76
|
}
|
|
77
|
+
rememberDispatched(key) {
|
|
78
|
+
this.dispatchedKeys.add(key);
|
|
79
|
+
if (this.dispatchedKeys.size > SlackInstance.DISPATCHED_KEYS_MAX) {
|
|
80
|
+
const oldest = this.dispatchedKeys.values().next().value;
|
|
81
|
+
if (oldest !== undefined)
|
|
82
|
+
this.dispatchedKeys.delete(oldest);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
68
85
|
setContext(ctx) {
|
|
69
86
|
this.ctx = ctx;
|
|
70
87
|
}
|
|
@@ -140,6 +157,16 @@ export class SlackInstance {
|
|
|
140
157
|
this.setupSocketHandlers();
|
|
141
158
|
await this.socketClient.start();
|
|
142
159
|
this.ctx?.log.info(`Slack[${this.id}] connected as @${botName} (${botUserId}) via socket mode`);
|
|
160
|
+
if (config.socketReconcileEnabled !== false) {
|
|
161
|
+
// Socket Mode has no replay: envelopes dropped during disconnects,
|
|
162
|
+
// ping-timeout flaps, or process restarts are lost forever. Run the
|
|
163
|
+
// polling sweep alongside as a reconciler — the (channel, ts) dedup
|
|
164
|
+
// in dispatchInboundMessage suppresses the overlap, so agents see
|
|
165
|
+
// each message exactly once: live via socket, or ≤1 poll cycle
|
|
166
|
+
// later if the socket dropped it.
|
|
167
|
+
await this.startPollingMode();
|
|
168
|
+
this.ctx?.log.info(`Slack[${this.id}] reconciler armed: polling sweep running alongside Socket Mode`);
|
|
169
|
+
}
|
|
143
170
|
}
|
|
144
171
|
else {
|
|
145
172
|
await this.startPollingMode();
|
|
@@ -174,7 +201,7 @@ export class SlackInstance {
|
|
|
174
201
|
this.pollingClient = new SlackPollingClient({
|
|
175
202
|
webClient: asPollingWebClient(this.webClient),
|
|
176
203
|
watermarkStore,
|
|
177
|
-
dispatch: (event) => this.dispatchInboundMessage(event),
|
|
204
|
+
dispatch: (event) => this.dispatchInboundMessage(event, 'poll'),
|
|
178
205
|
intervalSec: config.pollingIntervalSec ?? 45,
|
|
179
206
|
backfillMessageCap: config.pollingBackfillMessageCap ?? 100,
|
|
180
207
|
backfillSeconds: config.pollingBackfillSeconds ?? 24 * 60 * 60,
|
|
@@ -193,6 +220,20 @@ export class SlackInstance {
|
|
|
193
220
|
payload: { instanceId: this.id, reason },
|
|
194
221
|
});
|
|
195
222
|
});
|
|
223
|
+
// Seed thread tracking from recent SQLite history so threads that were
|
|
224
|
+
// active before this process started are still swept — the in-memory
|
|
225
|
+
// registry dies with the process, and tsx-watch restarts are frequent.
|
|
226
|
+
// New registrations re-read from the thread parent and the (channel, ts)
|
|
227
|
+
// dedup drops what was already delivered, so this also retro-recovers
|
|
228
|
+
// replies that were lost while no reconciler was watching.
|
|
229
|
+
const seedSinceMs = Date.now() - 6 * 60 * 60 * 1000;
|
|
230
|
+
const seeds = this.ctx?.eventDb.recentSlackThreadActivity?.(this.id, seedSinceMs) ?? [];
|
|
231
|
+
for (const seed of seeds) {
|
|
232
|
+
this.pollingClient.noteThreadActivity(seed.channelId, seed.threadTs, seed.lastActivityMs);
|
|
233
|
+
}
|
|
234
|
+
if (seeds.length > 0) {
|
|
235
|
+
this.ctx?.log.info(`Slack[${this.id}] thread registry seeded with ${seeds.length} recent thread(s)`);
|
|
236
|
+
}
|
|
196
237
|
await this.pollingClient.start();
|
|
197
238
|
}
|
|
198
239
|
/**
|
|
@@ -257,13 +298,36 @@ export class SlackInstance {
|
|
|
257
298
|
* subtypes / self / empty messages, logs to SQLite, broadcasts WS, fires
|
|
258
299
|
* trigger listeners, and resolves reply waiters.
|
|
259
300
|
*/
|
|
260
|
-
async dispatchInboundMessage(event) {
|
|
301
|
+
async dispatchInboundMessage(event, source = 'socket') {
|
|
261
302
|
if (event.subtype && SKIP_MESSAGE_SUBTYPES.has(event.subtype))
|
|
262
303
|
return;
|
|
263
304
|
const hasFiles = Array.isArray(event.files) && event.files.length > 0;
|
|
264
305
|
const text = event.text ?? '';
|
|
265
306
|
if (!text && !hasFiles)
|
|
266
307
|
return;
|
|
308
|
+
// Hybrid dedup: with the reconciler running alongside Socket Mode, every
|
|
309
|
+
// message can reach this dispatcher twice (live socket now, polling sweep
|
|
310
|
+
// ≤1 cycle later). Key on (channel, ts) — unique per Slack message. Both
|
|
311
|
+
// checks are synchronous (Set + better-sqlite3), so marking BEFORE the
|
|
312
|
+
// first await closes the in-process socket/poller race; the SQLite check
|
|
313
|
+
// covers messages dispatched before a restart wiped the in-memory Set.
|
|
314
|
+
if (event.ts) {
|
|
315
|
+
const dedupKey = `${event.channel}:${event.ts}`;
|
|
316
|
+
if (this.dispatchedKeys.has(dedupKey))
|
|
317
|
+
return;
|
|
318
|
+
if (this.ctx?.eventDb.hasSlackMessage?.(this.id, event.channel, event.ts)) {
|
|
319
|
+
this.rememberDispatched(dedupKey);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
this.rememberDispatched(dedupKey);
|
|
323
|
+
}
|
|
324
|
+
// Feed the reconciler's thread registry: any message carrying a thread_ts
|
|
325
|
+
// marks that thread active so the per-thread sweep keeps watching it —
|
|
326
|
+
// replies on parents behind the channel watermark never resurface in
|
|
327
|
+
// conversations.history, so this is the only way polling can see them.
|
|
328
|
+
if (event.thread_ts && event.thread_ts !== event.ts) {
|
|
329
|
+
this.pollingClient?.noteThreadActivity(event.channel, event.thread_ts);
|
|
330
|
+
}
|
|
267
331
|
const config = loadConfig(this.id);
|
|
268
332
|
const isOwnMessage = !!event.user && event.user === config.botUserId;
|
|
269
333
|
// Skip own messages unless this instance is configured to mirror them.
|
|
@@ -322,7 +386,7 @@ export class SlackInstance {
|
|
|
322
386
|
// direction, file count, and resolved-flag for name lookups (Y/N), no
|
|
323
387
|
// names/emails/text.
|
|
324
388
|
const resolvedFlag = `userResolved=${userName && userName !== userId ? 'Y' : 'N'} channelResolved=${channelName ? 'Y' : 'N'}`;
|
|
325
|
-
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}`);
|
|
389
|
+
this.ctx?.log.info(`Slack[${this.id}] dispatch: channel=${event.channel} user=${userId || '-'} ts=${event.ts} direction=${direction} via=${source} files=${files?.length ?? 0}${event.thread_ts && event.thread_ts !== event.ts ? ` thread=${event.thread_ts}` : ''} ${resolvedFlag}`);
|
|
326
390
|
this.ctx?.eventDb.logSlackMessage({
|
|
327
391
|
ts: event.ts,
|
|
328
392
|
threadTs: event.thread_ts,
|
|
@@ -30,6 +30,10 @@
|
|
|
30
30
|
*/
|
|
31
31
|
import { createLogger } from '../../utils/logger.js';
|
|
32
32
|
const log = createLogger('SlackPolling');
|
|
33
|
+
/** Tracked threads expire after this much inactivity. */
|
|
34
|
+
const THREAD_TTL_MS = 6 * 60 * 60 * 1000;
|
|
35
|
+
/** Per-channel cap on tracked threads — oldest activity evicted first. */
|
|
36
|
+
const MAX_TRACKED_THREADS_PER_CHANNEL = 10;
|
|
33
37
|
const DEFAULT_SCHEDULER = {
|
|
34
38
|
setTimeout: (cb, ms) => setTimeout(cb, ms),
|
|
35
39
|
clearTimeout: (h) => clearTimeout(h),
|
|
@@ -95,6 +99,16 @@ export class SlackPollingClient {
|
|
|
95
99
|
channelBackoffUntil = new Map();
|
|
96
100
|
/** ChannelId set known from the most recent list refresh. */
|
|
97
101
|
knownChannels = new Set();
|
|
102
|
+
/**
|
|
103
|
+
* Per-thread watermarks ("option 2" of the parent heuristic): channelId →
|
|
104
|
+
* threadTs → tracking state. `lastTs` is sweep-owned — it only advances as
|
|
105
|
+
* sweepThreads() reads replies in order, so a mid-thread reply the live
|
|
106
|
+
* socket dropped is still fetched on the next sweep (the dispatcher's
|
|
107
|
+
* (channel, ts) dedup drops the ones already delivered). New registrations
|
|
108
|
+
* start at lastTs = threadTs, i.e. the first sweep re-reads the whole
|
|
109
|
+
* thread and recovers any hole.
|
|
110
|
+
*/
|
|
111
|
+
threadRegistry = new Map();
|
|
98
112
|
/**
|
|
99
113
|
* Search-mode only: the newest message `ts` observed in the previous search
|
|
100
114
|
* cycle (dispatched or not). Lets the next cycle stop paging as soon as it
|
|
@@ -370,6 +384,119 @@ export class SlackPollingClient {
|
|
|
370
384
|
*
|
|
371
385
|
* Returns the number of messages dispatched.
|
|
372
386
|
*/
|
|
387
|
+
/**
|
|
388
|
+
* Register/refresh a tracked thread. Fed by three sources: the parent
|
|
389
|
+
* heuristic in pollChannel, every dispatched message carrying a thread_ts
|
|
390
|
+
* (both transports — the instance dispatcher calls this), and the SQLite
|
|
391
|
+
* seed on startup so tracking survives process restarts.
|
|
392
|
+
*
|
|
393
|
+
* Deliberately does NOT advance `lastTs` for known threads — that stays
|
|
394
|
+
* sweep-owned so a mid-thread reply the socket dropped can't be skipped
|
|
395
|
+
* because a newer reply arrived live first. `activityMs` defaults to now;
|
|
396
|
+
* the startup seed passes the stored time so stale threads still expire.
|
|
397
|
+
*/
|
|
398
|
+
noteThreadActivity(channelId, threadTs, activityMs) {
|
|
399
|
+
let threads = this.threadRegistry.get(channelId);
|
|
400
|
+
if (!threads) {
|
|
401
|
+
threads = new Map();
|
|
402
|
+
this.threadRegistry.set(channelId, threads);
|
|
403
|
+
}
|
|
404
|
+
const existing = threads.get(threadTs);
|
|
405
|
+
if (existing) {
|
|
406
|
+
existing.lastActivityMs = Math.max(existing.lastActivityMs, activityMs ?? this.now());
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
if (threads.size >= MAX_TRACKED_THREADS_PER_CHANNEL) {
|
|
410
|
+
let oldestKey = null;
|
|
411
|
+
let oldestMs = Infinity;
|
|
412
|
+
for (const [key, state] of threads) {
|
|
413
|
+
if (state.lastActivityMs < oldestMs) {
|
|
414
|
+
oldestMs = state.lastActivityMs;
|
|
415
|
+
oldestKey = key;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
if (oldestKey)
|
|
419
|
+
threads.delete(oldestKey);
|
|
420
|
+
}
|
|
421
|
+
threads.set(threadTs, { lastTs: threadTs, lastActivityMs: activityMs ?? this.now() });
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Per-thread watermark sweep: fetch new replies for every tracked thread in
|
|
425
|
+
* this channel, independently of whether the parent resurfaces in
|
|
426
|
+
* conversations.history (it never does once the channel watermark passes
|
|
427
|
+
* it — the exact gap that made "thread replies on old parents" invisible
|
|
428
|
+
* to polling). Downstream (channel, ts) dedup makes overlap with the live
|
|
429
|
+
* socket harmless.
|
|
430
|
+
*/
|
|
431
|
+
async sweepThreads(channelId) {
|
|
432
|
+
const threads = this.threadRegistry.get(channelId);
|
|
433
|
+
if (!threads || threads.size === 0)
|
|
434
|
+
return 0;
|
|
435
|
+
let dispatched = 0;
|
|
436
|
+
const nowMs = this.now();
|
|
437
|
+
for (const [threadTs, state] of threads) {
|
|
438
|
+
if (!this.running)
|
|
439
|
+
break;
|
|
440
|
+
if (nowMs - state.lastActivityMs > THREAD_TTL_MS) {
|
|
441
|
+
threads.delete(threadTs);
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
try {
|
|
445
|
+
const res = await this.paceCall(() => this.webClient.conversations.replies({
|
|
446
|
+
channel: channelId,
|
|
447
|
+
ts: threadTs,
|
|
448
|
+
oldest: state.lastTs,
|
|
449
|
+
limit: 100,
|
|
450
|
+
}));
|
|
451
|
+
const replies = (res.messages ?? []).filter((r) => !!r && typeof r.ts === 'string' && r.ts !== threadTs && tsGt(r.ts, state.lastTs));
|
|
452
|
+
if (replies.length === 0)
|
|
453
|
+
continue;
|
|
454
|
+
const ordered = [...replies].sort((a, b) => tsCmp(a.ts, b.ts));
|
|
455
|
+
for (const reply of ordered) {
|
|
456
|
+
const replyEvent = {
|
|
457
|
+
ts: reply.ts,
|
|
458
|
+
thread_ts: reply.thread_ts ?? threadTs,
|
|
459
|
+
channel: channelId,
|
|
460
|
+
user: reply.user,
|
|
461
|
+
text: reply.text,
|
|
462
|
+
subtype: reply.subtype,
|
|
463
|
+
files: Array.isArray(reply.files) ? reply.files : undefined,
|
|
464
|
+
};
|
|
465
|
+
try {
|
|
466
|
+
await this.dispatch(replyEvent);
|
|
467
|
+
dispatched += 1;
|
|
468
|
+
}
|
|
469
|
+
catch (err) {
|
|
470
|
+
log.error(`dispatch error (thread sweep) channel=${channelId} ts=${reply.ts}: ${describeErr(err)}`);
|
|
471
|
+
}
|
|
472
|
+
if (tsGt(reply.ts, state.lastTs)) {
|
|
473
|
+
state.lastTs = reply.ts;
|
|
474
|
+
state.lastActivityMs = nowMs;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
catch (err) {
|
|
479
|
+
if (isInvalidAuthError(err))
|
|
480
|
+
throw err;
|
|
481
|
+
if (isRateLimitedError(err)) {
|
|
482
|
+
const seconds = readRetryAfter(err) ?? 60;
|
|
483
|
+
const until = this.now() + seconds * 1000;
|
|
484
|
+
if (until > this.globalPauseUntil)
|
|
485
|
+
this.globalPauseUntil = until;
|
|
486
|
+
log.warn(`429 on thread sweep channel=${channelId} thread=${threadTs}, deferred ${seconds}s`);
|
|
487
|
+
break; // Remaining threads would hit the same pause — next cycle retries.
|
|
488
|
+
}
|
|
489
|
+
const msg = describeErr(err);
|
|
490
|
+
if (msg.includes('thread_not_found') || msg.includes('message_not_found')) {
|
|
491
|
+
threads.delete(threadTs); // Parent deleted — stop tracking.
|
|
492
|
+
}
|
|
493
|
+
else {
|
|
494
|
+
log.warn(`thread sweep failed channel=${channelId} thread=${threadTs}: ${msg}`);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
return dispatched;
|
|
499
|
+
}
|
|
373
500
|
async pollChannel(channelId) {
|
|
374
501
|
const wm = this.watermarkStore.get(channelId);
|
|
375
502
|
const isFirstSight = !wm;
|
|
@@ -388,8 +515,15 @@ export class SlackPollingClient {
|
|
|
388
515
|
limit,
|
|
389
516
|
}));
|
|
390
517
|
const messages = (res.messages ?? []).filter((m) => !!m && typeof m.ts === 'string');
|
|
391
|
-
if (messages.length === 0)
|
|
392
|
-
|
|
518
|
+
if (messages.length === 0) {
|
|
519
|
+
// No new channel-level messages — but replies on tracked threads never
|
|
520
|
+
// resurface in history, so the thread sweep must still run.
|
|
521
|
+
const swept = await this.sweepThreads(channelId);
|
|
522
|
+
if (this.channelBackoffUntil.has(channelId)) {
|
|
523
|
+
this.channelBackoffUntil.delete(channelId);
|
|
524
|
+
}
|
|
525
|
+
return swept;
|
|
526
|
+
}
|
|
393
527
|
// Slack returns newest-first; dispatch in chronological order so trigger
|
|
394
528
|
// listeners and dedupe see them in real-world order. BigInt sort avoids
|
|
395
529
|
// the precision loss `parseFloat` had on Slack's 16-sig-fig timestamps.
|
|
@@ -420,74 +554,21 @@ export class SlackPollingClient {
|
|
|
420
554
|
if (tsGt(msg.ts, highestTs)) {
|
|
421
555
|
highestTs = msg.ts;
|
|
422
556
|
}
|
|
423
|
-
// ───
|
|
424
|
-
//
|
|
425
|
-
//
|
|
426
|
-
//
|
|
427
|
-
//
|
|
428
|
-
//
|
|
429
|
-
//
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
const replyCount = msg.reply_count ?? 0;
|
|
433
|
-
const latestReply = msg.latest_reply;
|
|
434
|
-
const repliesAreNew = !!latestReply && tsGt(latestReply, wm?.lastTs ?? '0');
|
|
435
|
-
if (replyCount > 0 && repliesAreNew) {
|
|
436
|
-
const oldestForReplies = wm?.lastTs ?? parentTs;
|
|
437
|
-
try {
|
|
438
|
-
const repliesRes = await this.paceCall(() => this.webClient.conversations.replies({
|
|
439
|
-
channel: channelId,
|
|
440
|
-
ts: parentTs,
|
|
441
|
-
oldest: oldestForReplies,
|
|
442
|
-
limit: 100,
|
|
443
|
-
}));
|
|
444
|
-
const replies = (repliesRes.messages ?? []).filter((r) => !!r && typeof r.ts === 'string' && r.ts !== parentTs);
|
|
445
|
-
// conversations.replies returns oldest-first, but be defensive.
|
|
446
|
-
const orderedReplies = [...replies].sort((a, b) => tsCmp(a.ts, b.ts));
|
|
447
|
-
for (const reply of orderedReplies) {
|
|
448
|
-
// Defense: skip ts already at or below the watermark (handles overlap).
|
|
449
|
-
if (wm && tsLte(reply.ts, wm.lastTs))
|
|
450
|
-
continue;
|
|
451
|
-
const replyEvent = {
|
|
452
|
-
ts: reply.ts,
|
|
453
|
-
thread_ts: reply.thread_ts ?? parentTs,
|
|
454
|
-
channel: channelId,
|
|
455
|
-
user: reply.user,
|
|
456
|
-
text: reply.text,
|
|
457
|
-
subtype: reply.subtype,
|
|
458
|
-
files: Array.isArray(reply.files) ? reply.files : undefined,
|
|
459
|
-
};
|
|
460
|
-
try {
|
|
461
|
-
await this.dispatch(replyEvent);
|
|
462
|
-
dispatched += 1;
|
|
463
|
-
}
|
|
464
|
-
catch (err) {
|
|
465
|
-
log.error(`dispatch error (reply) channel=${channelId} ts=${reply.ts}: ${describeErr(err)}`);
|
|
466
|
-
}
|
|
467
|
-
if (tsGt(reply.ts, highestTs)) {
|
|
468
|
-
highestTs = reply.ts;
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
|
-
}
|
|
472
|
-
catch (err) {
|
|
473
|
-
// 429 / invalid_auth on replies fetch are non-fatal for this cycle —
|
|
474
|
-
// we already dispatched the parent. Bubble auth errors to the outer
|
|
475
|
-
// caller so the loop halts; rate limits just skip this thread.
|
|
476
|
-
if (isInvalidAuthError(err))
|
|
477
|
-
throw err;
|
|
478
|
-
if (isRateLimitedError(err)) {
|
|
479
|
-
const seconds = readRetryAfter(err) ?? 60;
|
|
480
|
-
const until = this.now() + seconds * 1000;
|
|
481
|
-
if (until > this.globalPauseUntil)
|
|
482
|
-
this.globalPauseUntil = until;
|
|
483
|
-
log.warn(`429 on conversations.replies channel=${channelId} thread=${parentTs}, deferred ${seconds}s`);
|
|
484
|
-
}
|
|
485
|
-
else {
|
|
486
|
-
log.warn(`replies fetch failed channel=${channelId} thread=${parentTs}: ${describeErr(err)}`);
|
|
487
|
-
}
|
|
488
|
-
}
|
|
557
|
+
// ─── Thread tracking ───
|
|
558
|
+
// Any parent with replies gets registered; the per-thread sweep at the
|
|
559
|
+
// end of this cycle (sweepThreads) is the single path that fetches and
|
|
560
|
+
// dispatches replies. Once the channel watermark passes the parent it
|
|
561
|
+
// never resurfaces in history — the registry is what keeps its future
|
|
562
|
+
// replies visible (the old inline "latest_reply" heuristic could not
|
|
563
|
+
// see replies on parents behind the watermark).
|
|
564
|
+
if ((msg.reply_count ?? 0) > 0) {
|
|
565
|
+
this.noteThreadActivity(channelId, msg.ts);
|
|
489
566
|
}
|
|
490
567
|
}
|
|
568
|
+
// Sweep tracked threads too. Thread-reply ts values deliberately do NOT
|
|
569
|
+
// advance the channel watermark here — that stays owned by the history
|
|
570
|
+
// fetch above; the sweep keeps its own per-thread watermarks.
|
|
571
|
+
dispatched += await this.sweepThreads(channelId);
|
|
491
572
|
if (highestTs !== (wm?.lastTs ?? '0')) {
|
|
492
573
|
await this.watermarkStore.set(channelId, highestTs);
|
|
493
574
|
}
|