tide-commander 1.87.0 → 1.89.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-S3Rke-8g.js → BossLogsModal-BK6N5fG2.js} +1 -1
- package/dist/assets/{BossSpawnModal-BjWGNCnz.js → BossSpawnModal-BTy-lus4.js} +1 -1
- package/dist/assets/{ControlsModal-6yfU0XjZ.js → ControlsModal-B4MhaF1V.js} +1 -1
- package/dist/assets/{DockerLogsModal-CYq0hNz6.js → DockerLogsModal-C33dAwy1.js} +1 -1
- package/dist/assets/{EmbeddedEditor-ZBdqRDqm.js → EmbeddedEditor-BfjjT-GF.js} +1 -1
- package/dist/assets/{GmailOAuthSetup-BcV5jAse.js → GmailOAuthSetup-TQyjHs3_.js} +1 -1
- package/dist/assets/{GoogleOAuthSetup-DyUW_STE.js → GoogleOAuthSetup-DAIzYKy8.js} +1 -1
- package/dist/assets/{IframeModal-D9A3dUUc.js → IframeModal-g8tC4aah.js} +1 -1
- package/dist/assets/IntegrationsPanel-CuKr7702.js +2 -0
- package/dist/assets/{LogViewerModal-BWkbY7wa.js → LogViewerModal-DO45Kea0.js} +1 -1
- package/dist/assets/{MonitoringModal-AZzokZAZ.js → MonitoringModal-OIwmagj2.js} +1 -1
- package/dist/assets/{PM2LogsModal-q98eiBfq.js → PM2LogsModal-BRQzSiFN.js} +1 -1
- package/dist/assets/{RestoreArchivedAreaModal-CTxRP2qE.js → RestoreArchivedAreaModal-CBRN9Xpb.js} +1 -1
- package/dist/assets/{Scene2DCanvas-C11dztp1.js → Scene2DCanvas-4J4ZefT6.js} +1 -1
- package/dist/assets/{SceneManager-CsW9MYrD.js → SceneManager-DZsJcYvW.js} +1 -1
- package/dist/assets/{SkillsPanel-BeZr9w6E.js → SkillsPanel-DHk7h3Ja.js} +1 -1
- package/dist/assets/SlackMultiInstanceSetup-Dp1q2zM1.js +2 -0
- package/dist/assets/{SpawnModal-DY_KM6lX.js → SpawnModal-CfozYMNI.js} +1 -1
- package/dist/assets/{SubordinateAssignmentModal-D6RvjGX9.js → SubordinateAssignmentModal-BBfbpVUr.js} +1 -1
- package/dist/assets/{TriggerManagerPanel-BmqjXv9T.js → TriggerManagerPanel-DQw9nt1r.js} +2 -2
- package/dist/assets/{WorkflowEditorPanel-Rd5ZjJmt.js → WorkflowEditorPanel-BM2ec8CS.js} +1 -1
- package/dist/assets/{index-DSvJOrb7.js → index-BiAZinYH.js} +2 -2
- package/dist/assets/{index-BYVHgVEo.js → index-BqbR55dr.js} +1 -1
- package/dist/assets/{index-DRGyDtmm.js → index-CcSJA57k.js} +1 -1
- package/dist/assets/{index-BtJyOo4p.js → index-DNEUJDeO.js} +1 -1
- package/dist/assets/{index-BoORE9Q1.js → index-DY9w7IcH.js} +1 -1
- package/dist/assets/{index-DHHRkTG1.js → index-bcwTXJ6F.js} +1 -1
- package/dist/assets/index-fZfyvIUZ.js +2 -0
- package/dist/assets/{index-BxaEkSIx.js → index-jXkaBxIq.js} +3 -3
- package/dist/assets/index-xEvpFBA8.js +8 -0
- package/dist/assets/main-Bw5ZddEN.css +1 -0
- package/dist/assets/main-D-YFCprA.js +213 -0
- package/dist/assets/{web-D3zCwsS9.js → web-BrBkKQlr.js} +1 -1
- package/dist/assets/{web-DS0FHmg8.js → web-DCu3NTho.js} +1 -1
- package/dist/assets/{web-DEq3Te_H.js → web-DX588C-g.js} +1 -1
- package/dist/index.html +2 -2
- package/dist/src/packages/server/data/builtin-skills/explore-database.js +175 -0
- package/dist/src/packages/server/data/builtin-skills/index.js +2 -0
- package/dist/src/packages/server/data/event-queries.js +2 -0
- package/dist/src/packages/server/data/index.js +56 -2
- package/dist/src/packages/server/data/migrations/006_slack_messages_integration_instance.sql +9 -0
- package/dist/src/packages/server/index.js +2 -1
- package/dist/src/packages/server/integrations/gmail/gmail-trigger-handler.js +9 -1
- package/dist/src/packages/server/integrations/slack/index.js +65 -19
- package/dist/src/packages/server/integrations/slack/slack-client.js +44 -602
- package/dist/src/packages/server/integrations/slack/slack-config.js +229 -29
- package/dist/src/packages/server/integrations/slack/slack-instance-manifest.js +150 -0
- package/dist/src/packages/server/integrations/slack/slack-instance.js +801 -0
- package/dist/src/packages/server/integrations/slack/slack-polling-client.js +522 -0
- package/dist/src/packages/server/integrations/slack/slack-routes.js +243 -24
- package/dist/src/packages/server/integrations/slack/slack-trigger-handler.js +53 -20
- package/dist/src/packages/server/integrations/slack/slack-watermark-store.js +124 -0
- package/dist/src/packages/server/integrations/whatsapp/index.js +5 -4
- package/dist/src/packages/server/integrations/whatsapp/whatsapp-client.js +10 -0
- package/dist/src/packages/server/integrations/whatsapp/whatsapp-routes.js +68 -0
- package/dist/src/packages/server/integrations/whatsapp/whatsapp-trigger-handler.js +127 -0
- package/dist/src/packages/server/routes/database.js +221 -0
- package/dist/src/packages/server/routes/files.js +219 -18
- package/dist/src/packages/server/routes/index.js +2 -0
- package/dist/src/packages/server/services/building-service.js +41 -0
- package/dist/src/packages/server/services/database-service.js +61 -9
- package/dist/src/packages/server/services/index.js +1 -0
- package/dist/src/packages/server/services/ssh-tunnel-service.js +255 -0
- package/dist/src/packages/server/websocket/handler.js +2 -1
- package/dist/src/packages/server/websocket/handlers/database-handler.js +35 -0
- package/package.json +3 -1
- package/dist/assets/IntegrationsPanel-CHaNJBJW.js +0 -2
- package/dist/assets/index-BOr_tbLK.js +0 -2
- package/dist/assets/index-Co7njQ0Q.js +0 -8
- package/dist/assets/main-BrZe9Zbd.js +0 -201
- package/dist/assets/main-kpU9m5LW.css +0 -1
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SlackPollingClient
|
|
3
|
+
*
|
|
4
|
+
* Polling-mode inbound transport for Slack USER tokens (xoxp-). User tokens
|
|
5
|
+
* cannot use Socket Mode, so we periodically poll the Web API instead.
|
|
6
|
+
*
|
|
7
|
+
* Pipeline per cycle:
|
|
8
|
+
* 1. (lazy) refresh channel list via `conversations.list` every
|
|
9
|
+
* `channelListRefreshEveryNCycles` ticks. Includes public/private
|
|
10
|
+
* channels, IMs, and MPIMs that the user is a member of.
|
|
11
|
+
* 2. For each tracked channel, fetch newer messages via
|
|
12
|
+
* `conversations.history` with `oldest=lastTs` (exclusive). On the
|
|
13
|
+
* first sight of a channel apply a backfill cap.
|
|
14
|
+
* 3. Hand each message to `dispatchInboundMessage(event)` shaped exactly
|
|
15
|
+
* like a Socket-Mode `message` event so the existing dispatcher pipeline
|
|
16
|
+
* (subtype filter, self-skip, user resolve, SQLite log, WS broadcast,
|
|
17
|
+
* trigger listeners, reply waiters) works unchanged.
|
|
18
|
+
* 4. Advance the channel's watermark to the newest dispatched ts and
|
|
19
|
+
* persist via the watermark store (atomic write).
|
|
20
|
+
*
|
|
21
|
+
* Rate-limit handling: on a Slack 429 with `Retry-After`, we wait that many
|
|
22
|
+
* seconds before retrying THAT channel and continue with the rest. The
|
|
23
|
+
* Slack header is authoritative — we don't add jittered backoff on top.
|
|
24
|
+
*
|
|
25
|
+
* Shutdown: `stop()` clears the timer, sets running=false, and awaits any
|
|
26
|
+
* in-flight cycle. The integration's main shutdown calls this.
|
|
27
|
+
*
|
|
28
|
+
* PII safety: never log message bodies, channel names, user names/emails, or
|
|
29
|
+
* DM contents. Logs only carry channel ids, ts values, and cycle metadata.
|
|
30
|
+
*/
|
|
31
|
+
import { createLogger } from '../../utils/logger.js';
|
|
32
|
+
const log = createLogger('SlackPolling');
|
|
33
|
+
const DEFAULT_SCHEDULER = {
|
|
34
|
+
setTimeout: (cb, ms) => setTimeout(cb, ms),
|
|
35
|
+
clearTimeout: (h) => clearTimeout(h),
|
|
36
|
+
};
|
|
37
|
+
/** Pull a Retry-After value (seconds) from a Slack 429 error, if present. */
|
|
38
|
+
function readRetryAfter(err) {
|
|
39
|
+
const e = err;
|
|
40
|
+
if (typeof e?.retryAfter === 'number')
|
|
41
|
+
return e.retryAfter;
|
|
42
|
+
if (typeof e?.data?.retry_after === 'number')
|
|
43
|
+
return e.data.retry_after;
|
|
44
|
+
const headerVal = e?.headers?.['retry-after'];
|
|
45
|
+
if (typeof headerVal === 'string') {
|
|
46
|
+
const n = parseInt(headerVal, 10);
|
|
47
|
+
if (Number.isFinite(n))
|
|
48
|
+
return n;
|
|
49
|
+
}
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
function isRateLimitedError(err) {
|
|
53
|
+
const e = err;
|
|
54
|
+
if (e?.code === 'slack_webapi_rate_limited_error')
|
|
55
|
+
return true;
|
|
56
|
+
if (e?.data?.error === 'ratelimited')
|
|
57
|
+
return true;
|
|
58
|
+
return readRetryAfter(err) !== undefined;
|
|
59
|
+
}
|
|
60
|
+
function isInvalidAuthError(err) {
|
|
61
|
+
const slackErr = err?.data?.error;
|
|
62
|
+
return slackErr === 'invalid_auth' || slackErr === 'token_revoked' || slackErr === 'account_inactive';
|
|
63
|
+
}
|
|
64
|
+
// ─── The client ───
|
|
65
|
+
export class SlackPollingClient {
|
|
66
|
+
webClient;
|
|
67
|
+
watermarkStore;
|
|
68
|
+
dispatch;
|
|
69
|
+
intervalMs;
|
|
70
|
+
backfillMessageCap;
|
|
71
|
+
backfillSeconds;
|
|
72
|
+
concurrency;
|
|
73
|
+
channelListRefreshEveryNCycles;
|
|
74
|
+
channelTypes;
|
|
75
|
+
allowlistChannelIds;
|
|
76
|
+
keepAllDms;
|
|
77
|
+
minMsBetweenCalls;
|
|
78
|
+
scheduler;
|
|
79
|
+
now;
|
|
80
|
+
/** Pacer state: serializes outbound API calls through a promise chain. */
|
|
81
|
+
rateGateChain = Promise.resolve();
|
|
82
|
+
/** When > now(), all paced calls sleep until that wall-clock ms. Set on 429. */
|
|
83
|
+
globalPauseUntil = 0;
|
|
84
|
+
running = false;
|
|
85
|
+
timer = null;
|
|
86
|
+
inFlight = null;
|
|
87
|
+
cycleCount = 0;
|
|
88
|
+
/** ChannelId → seconds to skip until (epoch ms) after a 429. */
|
|
89
|
+
channelBackoffUntil = new Map();
|
|
90
|
+
/** ChannelId set known from the most recent list refresh. */
|
|
91
|
+
knownChannels = new Set();
|
|
92
|
+
/** Set when we've hit a fatal auth error so the loop self-stops. */
|
|
93
|
+
fatalError = null;
|
|
94
|
+
/** Optional callback the wrapper can subscribe to for fatal errors. */
|
|
95
|
+
onFatalError = null;
|
|
96
|
+
constructor(opts) {
|
|
97
|
+
this.webClient = opts.webClient;
|
|
98
|
+
this.watermarkStore = opts.watermarkStore;
|
|
99
|
+
this.dispatch = opts.dispatch;
|
|
100
|
+
this.intervalMs = clamp(opts.intervalSec, 10, 600) * 1000;
|
|
101
|
+
this.backfillMessageCap = Math.max(1, opts.backfillMessageCap);
|
|
102
|
+
this.backfillSeconds = Math.max(60, opts.backfillSeconds);
|
|
103
|
+
this.concurrency = clamp(opts.concurrency, 1, 8);
|
|
104
|
+
this.channelListRefreshEveryNCycles = Math.max(1, opts.channelListRefreshEveryNCycles);
|
|
105
|
+
this.channelTypes = opts.channelTypes ?? 'public_channel,private_channel,im,mpim';
|
|
106
|
+
this.allowlistChannelIds = new Set((opts.allowlistChannelIds ?? []).map((s) => s.trim()).filter(Boolean));
|
|
107
|
+
this.keepAllDms = opts.keepAllDms !== false;
|
|
108
|
+
this.minMsBetweenCalls = Math.max(0, opts.minMsBetweenCalls ?? 0);
|
|
109
|
+
this.scheduler = opts.scheduler ?? DEFAULT_SCHEDULER;
|
|
110
|
+
this.now = opts.now ?? Date.now;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Throttle gate for outbound Slack API calls. All calls (history + replies)
|
|
114
|
+
* go through here so the per-cycle burst is smeared across the cycle. When
|
|
115
|
+
* `minMsBetweenCalls` is 0 the gate is a no-op — used by tests.
|
|
116
|
+
*
|
|
117
|
+
* Honors `globalPauseUntil` set by 429 handlers so the entire client backs
|
|
118
|
+
* off when Slack tells us to.
|
|
119
|
+
*/
|
|
120
|
+
async paceCall(fn) {
|
|
121
|
+
// Stop() was called while we were queued — bail without consuming the
|
|
122
|
+
// pacing slot or making the API call.
|
|
123
|
+
if (!this.running) {
|
|
124
|
+
throw new Error('SlackPollingClient stopped');
|
|
125
|
+
}
|
|
126
|
+
if (this.minMsBetweenCalls === 0 && this.globalPauseUntil <= this.now()) {
|
|
127
|
+
return fn();
|
|
128
|
+
}
|
|
129
|
+
const prev = this.rateGateChain;
|
|
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.
|
|
134
|
+
if (!this.running) {
|
|
135
|
+
release();
|
|
136
|
+
throw new Error('SlackPollingClient stopped');
|
|
137
|
+
}
|
|
138
|
+
// Sleep through any global pause first.
|
|
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
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/** Subscribe to fatal-error notifications (e.g. invalid_auth). */
|
|
158
|
+
setOnFatalError(cb) {
|
|
159
|
+
this.onFatalError = cb;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Start the polling loop. Returns once the watermark store is loaded and
|
|
163
|
+
* the first cycle is scheduled — does NOT wait for the first cycle to
|
|
164
|
+
* complete (matches Socket Mode's start-then-listen contract).
|
|
165
|
+
*/
|
|
166
|
+
async start() {
|
|
167
|
+
if (this.running)
|
|
168
|
+
return;
|
|
169
|
+
this.running = true;
|
|
170
|
+
this.fatalError = null;
|
|
171
|
+
await this.watermarkStore.load();
|
|
172
|
+
log.log(`Polling started: interval=${this.intervalMs / 1000}s, concurrency=${this.concurrency}, paceMs=${this.minMsBetweenCalls}, backfillCap=${this.backfillMessageCap}`);
|
|
173
|
+
// Kick the first cycle immediately so we don't wait `intervalMs` to see
|
|
174
|
+
// anything. Subsequent cycles are scheduled at the END of each cycle.
|
|
175
|
+
this.scheduleNextCycle(0);
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Stop the loop. Awaits the in-flight cycle so callers don't see ghost
|
|
179
|
+
* dispatches after `stop()` resolves.
|
|
180
|
+
*/
|
|
181
|
+
async stop() {
|
|
182
|
+
this.running = false;
|
|
183
|
+
if (this.timer) {
|
|
184
|
+
this.scheduler.clearTimeout(this.timer);
|
|
185
|
+
this.timer = null;
|
|
186
|
+
}
|
|
187
|
+
if (this.inFlight) {
|
|
188
|
+
await this.inFlight.catch(() => undefined);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
/** Force one cycle to run NOW. Returns when the cycle finishes. */
|
|
192
|
+
async runOnce() {
|
|
193
|
+
if (!this.running)
|
|
194
|
+
return;
|
|
195
|
+
if (this.inFlight) {
|
|
196
|
+
await this.inFlight.catch(() => undefined);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
await this.runCycleSafely();
|
|
200
|
+
}
|
|
201
|
+
// ─── Internals ───
|
|
202
|
+
scheduleNextCycle(delayMs) {
|
|
203
|
+
if (!this.running)
|
|
204
|
+
return;
|
|
205
|
+
if (this.timer) {
|
|
206
|
+
this.scheduler.clearTimeout(this.timer);
|
|
207
|
+
}
|
|
208
|
+
this.timer = this.scheduler.setTimeout(() => {
|
|
209
|
+
this.timer = null;
|
|
210
|
+
void this.runCycleSafely();
|
|
211
|
+
}, delayMs);
|
|
212
|
+
}
|
|
213
|
+
runCycleSafely() {
|
|
214
|
+
if (!this.running)
|
|
215
|
+
return Promise.resolve();
|
|
216
|
+
if (this.inFlight)
|
|
217
|
+
return this.inFlight;
|
|
218
|
+
const p = this.runCycle()
|
|
219
|
+
.catch((err) => {
|
|
220
|
+
log.error(`Polling cycle failed: ${describeErr(err)}`);
|
|
221
|
+
})
|
|
222
|
+
.finally(() => {
|
|
223
|
+
this.inFlight = null;
|
|
224
|
+
if (this.running && !this.fatalError) {
|
|
225
|
+
this.scheduleNextCycle(this.intervalMs);
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
this.inFlight = p;
|
|
229
|
+
return p;
|
|
230
|
+
}
|
|
231
|
+
async runCycle() {
|
|
232
|
+
if (!this.running)
|
|
233
|
+
return;
|
|
234
|
+
this.cycleCount += 1;
|
|
235
|
+
const cycleStart = this.now();
|
|
236
|
+
// Refresh channel list on first cycle and every N cycles after.
|
|
237
|
+
if (this.cycleCount === 1 || this.cycleCount % this.channelListRefreshEveryNCycles === 0) {
|
|
238
|
+
try {
|
|
239
|
+
await this.refreshChannelList();
|
|
240
|
+
}
|
|
241
|
+
catch (err) {
|
|
242
|
+
if (isInvalidAuthError(err)) {
|
|
243
|
+
this.handleFatal(`invalid_auth on conversations.list (cycle=${this.cycleCount})`);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
log.warn(`channel list refresh failed (cycle=${this.cycleCount}): ${describeErr(err)}`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
const channels = Array.from(this.knownChannels);
|
|
250
|
+
if (channels.length === 0) {
|
|
251
|
+
log.log(`cycle=${this.cycleCount}: no channels visible, skipping fetch`);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
// One-line heartbeat at the start of every cycle so the user can see the
|
|
255
|
+
// poller is ticking even when no new messages are dispatched.
|
|
256
|
+
log.log(`cycle=${this.cycleCount} starting: channels=${channels.length} paceMs=${this.minMsBetweenCalls} (~${Math.round(channels.length * this.minMsBetweenCalls / 1000)}s expected)`);
|
|
257
|
+
// First-cycle backfill is gentler: serialize all channels so we don't
|
|
258
|
+
// hit `conversations.history` for hundreds of channels in parallel.
|
|
259
|
+
const effectiveConcurrency = this.cycleCount === 1 ? 1 : this.concurrency;
|
|
260
|
+
let dispatched = 0;
|
|
261
|
+
let skippedBackoff = 0;
|
|
262
|
+
await mapWithConcurrency(channels, effectiveConcurrency, async (channelId) => {
|
|
263
|
+
// Bail mid-cycle if stop() was called. Lets reconnect() be near-instant
|
|
264
|
+
// instead of waiting up to ~5 minutes for a long pace-throttled cycle
|
|
265
|
+
// to drain.
|
|
266
|
+
if (!this.running)
|
|
267
|
+
return;
|
|
268
|
+
// Skip channels still under 429 backoff.
|
|
269
|
+
const backoffUntil = this.channelBackoffUntil.get(channelId);
|
|
270
|
+
if (backoffUntil && this.now() < backoffUntil) {
|
|
271
|
+
skippedBackoff += 1;
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
try {
|
|
275
|
+
const n = await this.pollChannel(channelId);
|
|
276
|
+
dispatched += n;
|
|
277
|
+
}
|
|
278
|
+
catch (err) {
|
|
279
|
+
if (isInvalidAuthError(err)) {
|
|
280
|
+
this.handleFatal(`invalid_auth on conversations.history (channel=${channelId})`);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
if (isRateLimitedError(err)) {
|
|
284
|
+
const seconds = readRetryAfter(err) ?? 60;
|
|
285
|
+
const until = this.now() + seconds * 1000;
|
|
286
|
+
this.channelBackoffUntil.set(channelId, until);
|
|
287
|
+
// Apply a workspace-wide pause too — subsequent paceCall()s wait
|
|
288
|
+
// until the Retry-After window clears so we don't burst more 429s.
|
|
289
|
+
if (until > this.globalPauseUntil)
|
|
290
|
+
this.globalPauseUntil = until;
|
|
291
|
+
log.warn(`429 on channel=${channelId}, backoff=${seconds}s (cycle=${this.cycleCount})`);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
log.warn(`history failed channel=${channelId}: ${describeErr(err)}`);
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
const elapsed = this.now() - cycleStart;
|
|
298
|
+
// log() not debug() — single line per cycle is the user's heartbeat that
|
|
299
|
+
// polling is alive. ~1 line per pollingIntervalSec is not noisy.
|
|
300
|
+
log.log(`cycle=${this.cycleCount} channels=${channels.length} dispatched=${dispatched} skippedBackoff=${skippedBackoff} elapsedMs=${elapsed}`);
|
|
301
|
+
}
|
|
302
|
+
async refreshChannelList() {
|
|
303
|
+
const next = new Set();
|
|
304
|
+
let cursor;
|
|
305
|
+
let pages = 0;
|
|
306
|
+
do {
|
|
307
|
+
const res = await this.paceCall(() => this.webClient.conversations.list({
|
|
308
|
+
types: this.channelTypes,
|
|
309
|
+
limit: 200,
|
|
310
|
+
cursor,
|
|
311
|
+
}));
|
|
312
|
+
for (const ch of res.channels ?? []) {
|
|
313
|
+
if (!ch?.id)
|
|
314
|
+
continue;
|
|
315
|
+
if (ch.is_archived)
|
|
316
|
+
continue;
|
|
317
|
+
// For shared/public channels Slack provides `is_member`; for IM/MPIM
|
|
318
|
+
// membership is implicit (the call only returns DMs you're in).
|
|
319
|
+
const memberOrDm = ch.is_member !== false || ch.is_im || ch.is_mpim;
|
|
320
|
+
if (!memberOrDm)
|
|
321
|
+
continue;
|
|
322
|
+
// Apply allowlist if any. DMs (id starts with 'D' or is_im) bypass
|
|
323
|
+
// the allowlist when keepAllDms is true.
|
|
324
|
+
if (this.allowlistChannelIds.size > 0) {
|
|
325
|
+
const isDm = !!ch.is_im || ch.id.startsWith('D');
|
|
326
|
+
const allowed = this.allowlistChannelIds.has(ch.id) || (this.keepAllDms && isDm);
|
|
327
|
+
if (!allowed)
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
next.add(ch.id);
|
|
331
|
+
}
|
|
332
|
+
cursor = res.response_metadata?.next_cursor || undefined;
|
|
333
|
+
pages += 1;
|
|
334
|
+
if (pages > 10)
|
|
335
|
+
break; // safety: 10 pages × 200 = 2000 channels max per refresh
|
|
336
|
+
} while (cursor);
|
|
337
|
+
this.knownChannels = next;
|
|
338
|
+
log.log(`channel list refresh: ${next.size} channels visible (cycle=${this.cycleCount})`);
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Poll one channel: fetch all messages newer than the watermark (or apply
|
|
342
|
+
* backfill cap on first sight), shape each into a Socket-Mode-like event,
|
|
343
|
+
* dispatch, and advance the watermark to the newest seen ts.
|
|
344
|
+
*
|
|
345
|
+
* Returns the number of messages dispatched.
|
|
346
|
+
*/
|
|
347
|
+
async pollChannel(channelId) {
|
|
348
|
+
const wm = this.watermarkStore.get(channelId);
|
|
349
|
+
const isFirstSight = !wm;
|
|
350
|
+
let oldest = wm?.lastTs;
|
|
351
|
+
if (isFirstSight) {
|
|
352
|
+
// Cap lookback to backfillSeconds ago.
|
|
353
|
+
const sinceMs = this.now() - this.backfillSeconds * 1000;
|
|
354
|
+
oldest = (sinceMs / 1000).toFixed(6);
|
|
355
|
+
}
|
|
356
|
+
// Slack returns messages newest-first. We accumulate one page at most for
|
|
357
|
+
// ongoing polling; on first sight we still cap by backfillMessageCap.
|
|
358
|
+
const limit = isFirstSight ? this.backfillMessageCap : 100;
|
|
359
|
+
const res = await this.paceCall(() => this.webClient.conversations.history({
|
|
360
|
+
channel: channelId,
|
|
361
|
+
oldest,
|
|
362
|
+
limit,
|
|
363
|
+
}));
|
|
364
|
+
const messages = (res.messages ?? []).filter((m) => !!m && typeof m.ts === 'string');
|
|
365
|
+
if (messages.length === 0)
|
|
366
|
+
return 0;
|
|
367
|
+
// Slack returns newest-first; dispatch in chronological order so trigger
|
|
368
|
+
// listeners and dedupe see them in real-world order.
|
|
369
|
+
const ordered = [...messages].sort((a, b) => parseFloat(a.ts) - parseFloat(b.ts));
|
|
370
|
+
let dispatched = 0;
|
|
371
|
+
let highestTs = wm?.lastTs ?? '0';
|
|
372
|
+
for (const msg of ordered) {
|
|
373
|
+
// Defense-in-depth: never re-dispatch ts <= watermark even if the
|
|
374
|
+
// server returned an unexpected row.
|
|
375
|
+
if (wm && parseFloat(msg.ts) <= parseFloat(wm.lastTs))
|
|
376
|
+
continue;
|
|
377
|
+
const event = {
|
|
378
|
+
ts: msg.ts,
|
|
379
|
+
thread_ts: msg.thread_ts,
|
|
380
|
+
channel: channelId,
|
|
381
|
+
user: msg.user,
|
|
382
|
+
text: msg.text,
|
|
383
|
+
subtype: msg.subtype,
|
|
384
|
+
files: Array.isArray(msg.files) ? msg.files : undefined,
|
|
385
|
+
};
|
|
386
|
+
try {
|
|
387
|
+
await this.dispatch(event);
|
|
388
|
+
dispatched += 1;
|
|
389
|
+
}
|
|
390
|
+
catch (err) {
|
|
391
|
+
log.error(`dispatch error channel=${channelId} ts=${msg.ts}: ${describeErr(err)}`);
|
|
392
|
+
}
|
|
393
|
+
if (parseFloat(msg.ts) > parseFloat(highestTs)) {
|
|
394
|
+
highestTs = msg.ts;
|
|
395
|
+
}
|
|
396
|
+
// ─── Cheap thread-reply heuristic ───
|
|
397
|
+
// If this message is a thread parent (reply_count > 0) and the latest
|
|
398
|
+
// reply is newer than what we've already seen for this channel, fetch
|
|
399
|
+
// the new replies. This catches replies in threads whose parent is in
|
|
400
|
+
// the current cycle's history window — which is the majority case for
|
|
401
|
+
// active conversations. Older threads with new replies are still
|
|
402
|
+
// missed (their parent doesn't re-surface in history) — option 2 would
|
|
403
|
+
// fix that with per-thread watermarks.
|
|
404
|
+
const parentTs = msg.ts;
|
|
405
|
+
const replyCount = msg.reply_count ?? 0;
|
|
406
|
+
const latestReply = msg.latest_reply;
|
|
407
|
+
const repliesAreNew = !!latestReply && parseFloat(latestReply) > parseFloat(wm?.lastTs ?? '0');
|
|
408
|
+
if (replyCount > 0 && repliesAreNew) {
|
|
409
|
+
const oldestForReplies = wm?.lastTs ?? parentTs;
|
|
410
|
+
try {
|
|
411
|
+
const repliesRes = await this.paceCall(() => this.webClient.conversations.replies({
|
|
412
|
+
channel: channelId,
|
|
413
|
+
ts: parentTs,
|
|
414
|
+
oldest: oldestForReplies,
|
|
415
|
+
limit: 100,
|
|
416
|
+
}));
|
|
417
|
+
const replies = (repliesRes.messages ?? []).filter((r) => !!r && typeof r.ts === 'string' && r.ts !== parentTs);
|
|
418
|
+
// conversations.replies returns oldest-first, but be defensive.
|
|
419
|
+
const orderedReplies = [...replies].sort((a, b) => parseFloat(a.ts) - parseFloat(b.ts));
|
|
420
|
+
for (const reply of orderedReplies) {
|
|
421
|
+
// Defense: skip ts already at or below the watermark (handles overlap).
|
|
422
|
+
if (wm && parseFloat(reply.ts) <= parseFloat(wm.lastTs))
|
|
423
|
+
continue;
|
|
424
|
+
const replyEvent = {
|
|
425
|
+
ts: reply.ts,
|
|
426
|
+
thread_ts: reply.thread_ts ?? parentTs,
|
|
427
|
+
channel: channelId,
|
|
428
|
+
user: reply.user,
|
|
429
|
+
text: reply.text,
|
|
430
|
+
subtype: reply.subtype,
|
|
431
|
+
files: Array.isArray(reply.files) ? reply.files : undefined,
|
|
432
|
+
};
|
|
433
|
+
try {
|
|
434
|
+
await this.dispatch(replyEvent);
|
|
435
|
+
dispatched += 1;
|
|
436
|
+
}
|
|
437
|
+
catch (err) {
|
|
438
|
+
log.error(`dispatch error (reply) channel=${channelId} ts=${reply.ts}: ${describeErr(err)}`);
|
|
439
|
+
}
|
|
440
|
+
if (parseFloat(reply.ts) > parseFloat(highestTs)) {
|
|
441
|
+
highestTs = reply.ts;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
catch (err) {
|
|
446
|
+
// 429 / invalid_auth on replies fetch are non-fatal for this cycle —
|
|
447
|
+
// we already dispatched the parent. Bubble auth errors to the outer
|
|
448
|
+
// caller so the loop halts; rate limits just skip this thread.
|
|
449
|
+
if (isInvalidAuthError(err))
|
|
450
|
+
throw err;
|
|
451
|
+
if (isRateLimitedError(err)) {
|
|
452
|
+
const seconds = readRetryAfter(err) ?? 60;
|
|
453
|
+
const until = this.now() + seconds * 1000;
|
|
454
|
+
if (until > this.globalPauseUntil)
|
|
455
|
+
this.globalPauseUntil = until;
|
|
456
|
+
log.warn(`429 on conversations.replies channel=${channelId} thread=${parentTs}, deferred ${seconds}s`);
|
|
457
|
+
}
|
|
458
|
+
else {
|
|
459
|
+
log.warn(`replies fetch failed channel=${channelId} thread=${parentTs}: ${describeErr(err)}`);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
if (highestTs !== (wm?.lastTs ?? '0')) {
|
|
465
|
+
await this.watermarkStore.set(channelId, highestTs);
|
|
466
|
+
}
|
|
467
|
+
// Clear any prior backoff record on success.
|
|
468
|
+
if (this.channelBackoffUntil.has(channelId)) {
|
|
469
|
+
this.channelBackoffUntil.delete(channelId);
|
|
470
|
+
}
|
|
471
|
+
return dispatched;
|
|
472
|
+
}
|
|
473
|
+
handleFatal(reason) {
|
|
474
|
+
this.fatalError = reason;
|
|
475
|
+
this.running = false;
|
|
476
|
+
if (this.timer) {
|
|
477
|
+
this.scheduler.clearTimeout(this.timer);
|
|
478
|
+
this.timer = null;
|
|
479
|
+
}
|
|
480
|
+
log.error(`Polling halted: ${reason}`);
|
|
481
|
+
this.onFatalError?.(reason);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
// ─── Helpers ───
|
|
485
|
+
function clamp(n, lo, hi) {
|
|
486
|
+
if (Number.isNaN(n))
|
|
487
|
+
return lo;
|
|
488
|
+
return Math.min(Math.max(n, lo), hi);
|
|
489
|
+
}
|
|
490
|
+
function describeErr(err) {
|
|
491
|
+
if (err instanceof Error)
|
|
492
|
+
return err.message;
|
|
493
|
+
return String(err);
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* Run `worker(item)` for each item with a max of `concurrency` in flight.
|
|
497
|
+
* Errors thrown by individual workers are swallowed by the caller's
|
|
498
|
+
* try/catch around `worker` — this helper itself never rejects.
|
|
499
|
+
*/
|
|
500
|
+
async function mapWithConcurrency(items, concurrency, worker) {
|
|
501
|
+
if (items.length === 0)
|
|
502
|
+
return;
|
|
503
|
+
const limit = Math.max(1, Math.min(concurrency, items.length));
|
|
504
|
+
let cursor = 0;
|
|
505
|
+
const runners = [];
|
|
506
|
+
for (let i = 0; i < limit; i += 1) {
|
|
507
|
+
runners.push((async () => {
|
|
508
|
+
while (true) {
|
|
509
|
+
const idx = cursor;
|
|
510
|
+
cursor += 1;
|
|
511
|
+
if (idx >= items.length)
|
|
512
|
+
return;
|
|
513
|
+
await worker(items[idx]);
|
|
514
|
+
}
|
|
515
|
+
})());
|
|
516
|
+
}
|
|
517
|
+
await Promise.all(runners);
|
|
518
|
+
}
|
|
519
|
+
/** Adapter so the production code can pass a real Slack WebClient. */
|
|
520
|
+
export function asPollingWebClient(client) {
|
|
521
|
+
return client;
|
|
522
|
+
}
|