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.
Files changed (71) hide show
  1. package/dist/assets/{BossLogsModal-S3Rke-8g.js → BossLogsModal-BK6N5fG2.js} +1 -1
  2. package/dist/assets/{BossSpawnModal-BjWGNCnz.js → BossSpawnModal-BTy-lus4.js} +1 -1
  3. package/dist/assets/{ControlsModal-6yfU0XjZ.js → ControlsModal-B4MhaF1V.js} +1 -1
  4. package/dist/assets/{DockerLogsModal-CYq0hNz6.js → DockerLogsModal-C33dAwy1.js} +1 -1
  5. package/dist/assets/{EmbeddedEditor-ZBdqRDqm.js → EmbeddedEditor-BfjjT-GF.js} +1 -1
  6. package/dist/assets/{GmailOAuthSetup-BcV5jAse.js → GmailOAuthSetup-TQyjHs3_.js} +1 -1
  7. package/dist/assets/{GoogleOAuthSetup-DyUW_STE.js → GoogleOAuthSetup-DAIzYKy8.js} +1 -1
  8. package/dist/assets/{IframeModal-D9A3dUUc.js → IframeModal-g8tC4aah.js} +1 -1
  9. package/dist/assets/IntegrationsPanel-CuKr7702.js +2 -0
  10. package/dist/assets/{LogViewerModal-BWkbY7wa.js → LogViewerModal-DO45Kea0.js} +1 -1
  11. package/dist/assets/{MonitoringModal-AZzokZAZ.js → MonitoringModal-OIwmagj2.js} +1 -1
  12. package/dist/assets/{PM2LogsModal-q98eiBfq.js → PM2LogsModal-BRQzSiFN.js} +1 -1
  13. package/dist/assets/{RestoreArchivedAreaModal-CTxRP2qE.js → RestoreArchivedAreaModal-CBRN9Xpb.js} +1 -1
  14. package/dist/assets/{Scene2DCanvas-C11dztp1.js → Scene2DCanvas-4J4ZefT6.js} +1 -1
  15. package/dist/assets/{SceneManager-CsW9MYrD.js → SceneManager-DZsJcYvW.js} +1 -1
  16. package/dist/assets/{SkillsPanel-BeZr9w6E.js → SkillsPanel-DHk7h3Ja.js} +1 -1
  17. package/dist/assets/SlackMultiInstanceSetup-Dp1q2zM1.js +2 -0
  18. package/dist/assets/{SpawnModal-DY_KM6lX.js → SpawnModal-CfozYMNI.js} +1 -1
  19. package/dist/assets/{SubordinateAssignmentModal-D6RvjGX9.js → SubordinateAssignmentModal-BBfbpVUr.js} +1 -1
  20. package/dist/assets/{TriggerManagerPanel-BmqjXv9T.js → TriggerManagerPanel-DQw9nt1r.js} +2 -2
  21. package/dist/assets/{WorkflowEditorPanel-Rd5ZjJmt.js → WorkflowEditorPanel-BM2ec8CS.js} +1 -1
  22. package/dist/assets/{index-DSvJOrb7.js → index-BiAZinYH.js} +2 -2
  23. package/dist/assets/{index-BYVHgVEo.js → index-BqbR55dr.js} +1 -1
  24. package/dist/assets/{index-DRGyDtmm.js → index-CcSJA57k.js} +1 -1
  25. package/dist/assets/{index-BtJyOo4p.js → index-DNEUJDeO.js} +1 -1
  26. package/dist/assets/{index-BoORE9Q1.js → index-DY9w7IcH.js} +1 -1
  27. package/dist/assets/{index-DHHRkTG1.js → index-bcwTXJ6F.js} +1 -1
  28. package/dist/assets/index-fZfyvIUZ.js +2 -0
  29. package/dist/assets/{index-BxaEkSIx.js → index-jXkaBxIq.js} +3 -3
  30. package/dist/assets/index-xEvpFBA8.js +8 -0
  31. package/dist/assets/main-Bw5ZddEN.css +1 -0
  32. package/dist/assets/main-D-YFCprA.js +213 -0
  33. package/dist/assets/{web-D3zCwsS9.js → web-BrBkKQlr.js} +1 -1
  34. package/dist/assets/{web-DS0FHmg8.js → web-DCu3NTho.js} +1 -1
  35. package/dist/assets/{web-DEq3Te_H.js → web-DX588C-g.js} +1 -1
  36. package/dist/index.html +2 -2
  37. package/dist/src/packages/server/data/builtin-skills/explore-database.js +175 -0
  38. package/dist/src/packages/server/data/builtin-skills/index.js +2 -0
  39. package/dist/src/packages/server/data/event-queries.js +2 -0
  40. package/dist/src/packages/server/data/index.js +56 -2
  41. package/dist/src/packages/server/data/migrations/006_slack_messages_integration_instance.sql +9 -0
  42. package/dist/src/packages/server/index.js +2 -1
  43. package/dist/src/packages/server/integrations/gmail/gmail-trigger-handler.js +9 -1
  44. package/dist/src/packages/server/integrations/slack/index.js +65 -19
  45. package/dist/src/packages/server/integrations/slack/slack-client.js +44 -602
  46. package/dist/src/packages/server/integrations/slack/slack-config.js +229 -29
  47. package/dist/src/packages/server/integrations/slack/slack-instance-manifest.js +150 -0
  48. package/dist/src/packages/server/integrations/slack/slack-instance.js +801 -0
  49. package/dist/src/packages/server/integrations/slack/slack-polling-client.js +522 -0
  50. package/dist/src/packages/server/integrations/slack/slack-routes.js +243 -24
  51. package/dist/src/packages/server/integrations/slack/slack-trigger-handler.js +53 -20
  52. package/dist/src/packages/server/integrations/slack/slack-watermark-store.js +124 -0
  53. package/dist/src/packages/server/integrations/whatsapp/index.js +5 -4
  54. package/dist/src/packages/server/integrations/whatsapp/whatsapp-client.js +10 -0
  55. package/dist/src/packages/server/integrations/whatsapp/whatsapp-routes.js +68 -0
  56. package/dist/src/packages/server/integrations/whatsapp/whatsapp-trigger-handler.js +127 -0
  57. package/dist/src/packages/server/routes/database.js +221 -0
  58. package/dist/src/packages/server/routes/files.js +219 -18
  59. package/dist/src/packages/server/routes/index.js +2 -0
  60. package/dist/src/packages/server/services/building-service.js +41 -0
  61. package/dist/src/packages/server/services/database-service.js +61 -9
  62. package/dist/src/packages/server/services/index.js +1 -0
  63. package/dist/src/packages/server/services/ssh-tunnel-service.js +255 -0
  64. package/dist/src/packages/server/websocket/handler.js +2 -1
  65. package/dist/src/packages/server/websocket/handlers/database-handler.js +35 -0
  66. package/package.json +3 -1
  67. package/dist/assets/IntegrationsPanel-CHaNJBJW.js +0 -2
  68. package/dist/assets/index-BOr_tbLK.js +0 -2
  69. package/dist/assets/index-Co7njQ0Q.js +0 -8
  70. package/dist/assets/main-BrZe9Zbd.js +0 -201
  71. package/dist/assets/main-kpU9m5LW.css +0 -1
@@ -0,0 +1,801 @@
1
+ /**
2
+ * SlackInstance — encapsulates the connection + state for ONE Slack
3
+ * workspace/account. All slack-client functionality lives here as instance
4
+ * methods. The single-instance public surface in `slack-client.ts` is a thin
5
+ * facade that delegates to the `default` instance.
6
+ *
7
+ * Why this exists: chunk 1 of the multi-instance Slack rollout. We need state
8
+ * (WebClient, SocketModeClient, polling loop, caches, listeners, reply
9
+ * waiters) keyed by instance so two side-by-side connections can run later.
10
+ * In chunk 1 only the `default` instance is used and behavior is unchanged.
11
+ */
12
+ import { promises as fs } from 'node:fs';
13
+ import * as os from 'node:os';
14
+ import path from 'node:path';
15
+ import { LogLevel, WebClient } from '@slack/web-api';
16
+ import { SocketModeClient } from '@slack/socket-mode';
17
+ import { instanceSecretKey, loadConfig, resolveAuthMode, updateConfig } from './slack-config.js';
18
+ import { SlackPollingClient, asPollingWebClient } from './slack-polling-client.js';
19
+ import { SlackWatermarkStore } from './slack-watermark-store.js';
20
+ // Subtypes we never want to trigger on. Modern file-share has NO subtype, legacy uses `file_share` — both must pass.
21
+ const SKIP_MESSAGE_SUBTYPES = new Set([
22
+ 'bot_message',
23
+ 'message_changed',
24
+ 'message_deleted',
25
+ 'message_replied',
26
+ 'channel_join',
27
+ 'channel_leave',
28
+ 'channel_topic',
29
+ 'channel_purpose',
30
+ 'channel_name',
31
+ 'channel_archive',
32
+ 'channel_unarchive',
33
+ 'group_join',
34
+ 'group_leave',
35
+ 'group_topic',
36
+ 'group_purpose',
37
+ 'group_name',
38
+ 'group_archive',
39
+ 'group_unarchive',
40
+ 'pinned_item',
41
+ 'unpinned_item',
42
+ ]);
43
+ /** The single, well-known instance id used until chunk 2 introduces user-defined ids. */
44
+ export const DEFAULT_INSTANCE_ID = 'default';
45
+ /** Encapsulates one Slack connection + all its caches/listeners/clients. */
46
+ export class SlackInstance {
47
+ id;
48
+ webClient = null;
49
+ socketClient = null;
50
+ pollingClient = null;
51
+ ctx = null;
52
+ userCache = new Map();
53
+ channelNameCache = new Map();
54
+ messageListeners = new Set();
55
+ replyWaiters = new Set();
56
+ constructor(id) {
57
+ this.id = id;
58
+ }
59
+ setContext(ctx) {
60
+ this.ctx = ctx;
61
+ }
62
+ // ─── Init / Shutdown ───
63
+ async init() {
64
+ if (!this.ctx)
65
+ throw new Error('SlackInstance.init: setContext() must be called first');
66
+ const botToken = this.getSecret('SLACK_BOT_TOKEN');
67
+ const appToken = this.getSecret('SLACK_APP_TOKEN');
68
+ const config = loadConfig(this.id);
69
+ if (!config.enabled || !botToken) {
70
+ this.ctx.log.info(`Slack[${this.id}] disabled or missing token, skipping connection`);
71
+ return;
72
+ }
73
+ await this.connect(botToken, appToken);
74
+ }
75
+ /** Read a per-instance secret (e.g. SLACK_BOT_TOKEN). Default instance uses the bare key. */
76
+ getSecret(logicalKey) {
77
+ if (!this.ctx)
78
+ return undefined;
79
+ return this.ctx.secrets.get(instanceSecretKey(logicalKey, this.id));
80
+ }
81
+ async shutdown() {
82
+ await this.disconnect();
83
+ this.userCache.clear();
84
+ this.channelNameCache.clear();
85
+ this.messageListeners.clear();
86
+ for (const waiter of this.replyWaiters) {
87
+ clearTimeout(waiter.timer);
88
+ waiter.resolve(null);
89
+ }
90
+ this.replyWaiters.clear();
91
+ }
92
+ // ─── Connection Management ───
93
+ async connect(botToken, appToken) {
94
+ const config = loadConfig(this.id);
95
+ const decision = resolveAuthMode({
96
+ authMode: config.authMode,
97
+ botToken,
98
+ appToken,
99
+ });
100
+ if ('error' in decision) {
101
+ updateConfig({ status: 'error', lastError: decision.error, currentMode: 'none' }, this.id);
102
+ this.ctx?.log.error(`Slack[${this.id}] mode resolution failed: ${decision.error}`);
103
+ throw new Error(decision.error);
104
+ }
105
+ updateConfig({ status: 'connecting', lastError: undefined, currentMode: decision.mode }, this.id);
106
+ try {
107
+ // logLevel: ERROR — the library logs every 429 at WARN by default which
108
+ // double-reports rate limits we already log via our own polling client.
109
+ // rejectRateLimitedCalls: true — surface 429s to our caller immediately
110
+ // so the polling client's per-channel backoff (which moves on to other
111
+ // channels) handles them, instead of the library serially retrying and
112
+ // blocking the whole conversations.history call.
113
+ this.webClient = new WebClient(botToken, {
114
+ logLevel: LogLevel.ERROR,
115
+ rejectRateLimitedCalls: true,
116
+ });
117
+ // auth.test works for both bot and user tokens. For xoxp- it returns the
118
+ // user's own id/handle, used for "skip self" filtering.
119
+ const authResult = await this.webClient.auth.test();
120
+ const botUserId = authResult.user_id;
121
+ const botName = authResult.user || 'tide-bot';
122
+ updateConfig({
123
+ status: 'connected',
124
+ botUserId,
125
+ botName,
126
+ connectedAt: Date.now(),
127
+ currentMode: decision.mode,
128
+ }, this.id);
129
+ if (decision.mode === 'socket') {
130
+ this.socketClient = new SocketModeClient({ appToken: appToken });
131
+ this.setupSocketHandlers();
132
+ await this.socketClient.start();
133
+ this.ctx?.log.info(`Slack[${this.id}] connected as @${botName} (${botUserId}) via socket mode`);
134
+ }
135
+ else {
136
+ await this.startPollingMode();
137
+ this.ctx?.log.info(`Slack[${this.id}] connected as @${botName} (${botUserId}) via polling mode`);
138
+ }
139
+ }
140
+ catch (err) {
141
+ const errorMsg = err instanceof Error ? err.message : String(err);
142
+ updateConfig({ status: 'error', lastError: errorMsg, currentMode: 'none' }, this.id);
143
+ this.ctx?.log.error(`Slack[${this.id}] connection failed: ${errorMsg}`);
144
+ this.webClient = null;
145
+ this.socketClient = null;
146
+ if (this.pollingClient) {
147
+ await this.pollingClient.stop().catch(() => undefined);
148
+ this.pollingClient = null;
149
+ }
150
+ throw err;
151
+ }
152
+ }
153
+ async startPollingMode() {
154
+ if (!this.webClient)
155
+ throw new Error('Polling mode requires an initialized WebClient');
156
+ const config = loadConfig(this.id);
157
+ const watermarkStore = new SlackWatermarkStore({
158
+ filePath: this.getWatermarkPath(),
159
+ });
160
+ // Parse the allowlist: split on commas / newlines / whitespace, strip empties.
161
+ const allowlistChannelIds = (config.pollingChannelAllowlist ?? '')
162
+ .split(/[\s,]+/)
163
+ .map((s) => s.trim())
164
+ .filter(Boolean);
165
+ this.pollingClient = new SlackPollingClient({
166
+ webClient: asPollingWebClient(this.webClient),
167
+ watermarkStore,
168
+ dispatch: (event) => this.dispatchInboundMessage(event),
169
+ intervalSec: config.pollingIntervalSec ?? 45,
170
+ backfillMessageCap: config.pollingBackfillMessageCap ?? 100,
171
+ backfillSeconds: config.pollingBackfillSeconds ?? 24 * 60 * 60,
172
+ concurrency: config.pollingConcurrency ?? 4,
173
+ channelListRefreshEveryNCycles: 10,
174
+ channelTypes: config.pollingChannelTypes,
175
+ allowlistChannelIds,
176
+ keepAllDms: config.pollingDmsAlways !== false,
177
+ minMsBetweenCalls: config.pollingMinMsBetweenCalls ?? 1500,
178
+ });
179
+ this.pollingClient.setOnFatalError((reason) => {
180
+ updateConfig({ status: 'error', lastError: reason }, this.id);
181
+ this.ctx?.broadcast({
182
+ type: 'slack_polling_halted',
183
+ payload: { instanceId: this.id, reason },
184
+ });
185
+ });
186
+ await this.pollingClient.start();
187
+ }
188
+ /**
189
+ * Backward-compat: the `default` instance keeps the historical filename so
190
+ * existing users don't need migration. Other instances get suffixed names.
191
+ */
192
+ getWatermarkPath() {
193
+ const dataDir = path.join(process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share'), 'tide-commander');
194
+ if (this.id === DEFAULT_INSTANCE_ID) {
195
+ return path.join(dataDir, 'slack-watermarks.json');
196
+ }
197
+ return path.join(dataDir, `slack-watermarks-${this.id}.json`);
198
+ }
199
+ async reconnect() {
200
+ if (!this.ctx)
201
+ throw new Error('SlackInstance.reconnect: not initialized');
202
+ await this.disconnect();
203
+ const botToken = this.getSecret('SLACK_BOT_TOKEN');
204
+ const appToken = this.getSecret('SLACK_APP_TOKEN');
205
+ if (!botToken) {
206
+ throw new Error('Missing Slack token');
207
+ }
208
+ await this.connect(botToken, appToken);
209
+ }
210
+ async disconnect() {
211
+ if (this.socketClient) {
212
+ try {
213
+ await this.socketClient.disconnect();
214
+ }
215
+ catch {
216
+ // Ignore disconnect errors
217
+ }
218
+ this.socketClient = null;
219
+ }
220
+ if (this.pollingClient) {
221
+ await this.pollingClient.stop().catch(() => undefined);
222
+ this.pollingClient = null;
223
+ }
224
+ this.webClient = null;
225
+ updateConfig({ status: 'disconnected', connectedAt: undefined, currentMode: 'none' }, this.id);
226
+ }
227
+ setupSocketHandlers() {
228
+ if (!this.socketClient)
229
+ return;
230
+ this.socketClient.on('message', async ({ event, ack }) => {
231
+ await ack();
232
+ if (!event)
233
+ return;
234
+ await this.dispatchInboundMessage(event);
235
+ });
236
+ this.socketClient.on('disconnect', () => {
237
+ this.ctx?.log.warn(`Slack[${this.id}] Socket Mode disconnected`);
238
+ updateConfig({ status: 'disconnected' }, this.id);
239
+ });
240
+ this.socketClient.on('unable_to_socket_mode_start', (err) => {
241
+ this.ctx?.log.error(`Slack[${this.id}] Socket Mode start failed: ${err}`);
242
+ updateConfig({ status: 'error', lastError: String(err) }, this.id);
243
+ });
244
+ }
245
+ /**
246
+ * Single dispatcher pipeline shared by Socket Mode and Polling. Filters
247
+ * subtypes / self / empty messages, logs to SQLite, broadcasts WS, fires
248
+ * trigger listeners, and resolves reply waiters.
249
+ */
250
+ async dispatchInboundMessage(event) {
251
+ if (event.subtype && SKIP_MESSAGE_SUBTYPES.has(event.subtype))
252
+ return;
253
+ const hasFiles = Array.isArray(event.files) && event.files.length > 0;
254
+ const text = event.text ?? '';
255
+ if (!text && !hasFiles)
256
+ return;
257
+ const config = loadConfig(this.id);
258
+ const isOwnMessage = !!event.user && event.user === config.botUserId;
259
+ // Skip own messages unless this instance is configured to mirror them.
260
+ if (isOwnMessage && !config.mirrorOwnMessages)
261
+ return;
262
+ const userId = event.user ?? '';
263
+ let userName = userId;
264
+ if (userId) {
265
+ try {
266
+ const user = await this.resolveUser(userId);
267
+ userName = user?.displayName || user?.name || userId;
268
+ }
269
+ catch {
270
+ // Use userId as fallback
271
+ }
272
+ }
273
+ const files = hasFiles
274
+ ? event.files.map((f) => normalizeSlackFile(f))
275
+ : undefined;
276
+ const message = {
277
+ ts: event.ts,
278
+ threadTs: event.thread_ts,
279
+ channel: event.channel,
280
+ userId,
281
+ userName,
282
+ text,
283
+ timestamp: parseSlackTs(event.ts),
284
+ files,
285
+ isOwnMessage,
286
+ };
287
+ const direction = isOwnMessage ? 'outbound' : 'inbound';
288
+ // Heartbeat: one line per dispatched message. PII-safe — only ids, ts,
289
+ // direction, file count. No message text, no usernames, no emails.
290
+ 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}` : ''}`);
291
+ this.ctx?.eventDb.logSlackMessage({
292
+ ts: event.ts,
293
+ threadTs: event.thread_ts,
294
+ channelId: event.channel,
295
+ channelName: this.channelNameCache.get(event.channel),
296
+ userId,
297
+ userName,
298
+ text,
299
+ direction,
300
+ rawEvent: event,
301
+ receivedAt: Date.now(),
302
+ integrationInstanceId: this.id,
303
+ });
304
+ this.ctx?.broadcast({
305
+ type: 'slack_message_received',
306
+ payload: {
307
+ instanceId: this.id,
308
+ channel: event.channel,
309
+ userName,
310
+ text,
311
+ ts: event.ts,
312
+ fileCount: files?.length ?? 0,
313
+ direction,
314
+ },
315
+ });
316
+ // Trigger listeners receive own messages too (tagged with isOwnMessage);
317
+ // the slack trigger handler decides whether to fire based on trigger config.
318
+ for (const listener of this.messageListeners) {
319
+ try {
320
+ listener(message);
321
+ }
322
+ catch (err) {
323
+ this.ctx?.log.error(`Slack[${this.id}] message listener error: ${err}`);
324
+ }
325
+ }
326
+ // Reply waiters always skip own messages — a wait-for-reply must not
327
+ // resolve on our own outgoing message.
328
+ if (isOwnMessage)
329
+ return;
330
+ for (const waiter of this.replyWaiters) {
331
+ if (waiter.channel !== message.channel)
332
+ continue;
333
+ if (waiter.threadTs !== message.threadTs && waiter.threadTs !== message.ts)
334
+ continue;
335
+ if (waiter.fromUsers?.length && !waiter.fromUsers.includes(message.userId))
336
+ continue;
337
+ if (waiter.messagePattern && !new RegExp(waiter.messagePattern).test(message.text))
338
+ continue;
339
+ clearTimeout(waiter.timer);
340
+ this.replyWaiters.delete(waiter);
341
+ waiter.resolve(message);
342
+ }
343
+ }
344
+ // ─── Sending ───
345
+ async sendMessage(params) {
346
+ if (!this.webClient)
347
+ throw new Error('Slack not connected');
348
+ const result = await this.webClient.chat.postMessage({
349
+ channel: params.channel,
350
+ text: params.text,
351
+ thread_ts: params.threadTs,
352
+ });
353
+ const ts = result.ts;
354
+ const channel = result.channel;
355
+ const config = loadConfig(this.id);
356
+ this.ctx?.eventDb.logSlackMessage({
357
+ ts,
358
+ threadTs: params.threadTs,
359
+ channelId: channel,
360
+ channelName: this.channelNameCache.get(channel),
361
+ userId: config.botUserId || '',
362
+ userName: config.botName || 'tide-bot',
363
+ text: params.text,
364
+ direction: 'outbound',
365
+ agentId: params.agentId,
366
+ workflowInstanceId: params.workflowInstanceId,
367
+ receivedAt: Date.now(),
368
+ integrationInstanceId: this.id,
369
+ });
370
+ return { ts, channel };
371
+ }
372
+ // ─── Reactions ───
373
+ async addReaction(params) {
374
+ if (!this.webClient)
375
+ throw new Error('Slack not connected');
376
+ const name = normalizeEmojiName(params.name);
377
+ try {
378
+ await this.webClient.reactions.add({
379
+ channel: params.channel,
380
+ timestamp: params.ts,
381
+ name,
382
+ });
383
+ }
384
+ catch (err) {
385
+ const slackErr = err.data?.error;
386
+ if (slackErr === 'already_reacted')
387
+ return;
388
+ throw err;
389
+ }
390
+ }
391
+ // ─── Reading ───
392
+ async getChannelMessages(params) {
393
+ if (!this.webClient)
394
+ throw new Error('Slack not connected');
395
+ const result = await this.webClient.conversations.history({
396
+ channel: params.channel,
397
+ limit: params.limit || 20,
398
+ oldest: params.oldest,
399
+ latest: params.latest,
400
+ });
401
+ return Promise.all((result.messages || []).map((msg) => this.slackApiMessageToSlackMessage(msg, params.channel)));
402
+ }
403
+ async getThreadReplies(params) {
404
+ if (!this.webClient)
405
+ throw new Error('Slack not connected');
406
+ const result = await this.webClient.conversations.replies({
407
+ channel: params.channel,
408
+ ts: params.threadTs,
409
+ limit: params.limit || 50,
410
+ });
411
+ return Promise.all((result.messages || []).map((msg) => this.slackApiMessageToSlackMessage(msg, params.channel)));
412
+ }
413
+ // ─── Wait For Reply (Long-Poll) ───
414
+ waitForReply(params) {
415
+ const timeout = params.timeoutMs || 300000;
416
+ return new Promise((resolve) => {
417
+ const timer = setTimeout(() => {
418
+ this.replyWaiters.delete(waiter);
419
+ resolve(null);
420
+ }, timeout);
421
+ const waiter = {
422
+ channel: params.channel,
423
+ threadTs: params.threadTs,
424
+ fromUsers: params.fromUsers,
425
+ messagePattern: params.messagePattern,
426
+ resolve,
427
+ timer,
428
+ };
429
+ this.replyWaiters.add(waiter);
430
+ });
431
+ }
432
+ // ─── Channel Management ───
433
+ async joinChannel(channel) {
434
+ if (!this.webClient)
435
+ throw new Error('Slack not connected');
436
+ const result = await this.webClient.conversations.join({ channel });
437
+ const ch = result.channel;
438
+ if (!ch)
439
+ throw new Error(`Failed to join channel ${channel}`);
440
+ this.channelNameCache.set(ch.id, ch.name);
441
+ return { id: ch.id, name: ch.name };
442
+ }
443
+ async listChannels() {
444
+ if (!this.webClient)
445
+ throw new Error('Slack not connected');
446
+ const channels = [];
447
+ let cursor;
448
+ do {
449
+ const result = await this.webClient.conversations.list({
450
+ types: 'public_channel,private_channel',
451
+ limit: 200,
452
+ cursor,
453
+ });
454
+ for (const ch of result.channels || []) {
455
+ const channel = {
456
+ id: ch.id,
457
+ name: ch.name,
458
+ isPrivate: ch.is_private,
459
+ isMember: ch.is_member,
460
+ topic: ch.topic?.value,
461
+ purpose: ch.purpose?.value,
462
+ };
463
+ channels.push(channel);
464
+ this.channelNameCache.set(channel.id, channel.name);
465
+ }
466
+ cursor = result.response_metadata?.next_cursor || undefined;
467
+ } while (cursor);
468
+ return channels;
469
+ }
470
+ async resolveUser(userId) {
471
+ const cached = this.userCache.get(userId);
472
+ if (cached)
473
+ return cached;
474
+ if (!this.webClient)
475
+ throw new Error('Slack not connected');
476
+ const result = await this.webClient.users.info({ user: userId });
477
+ const u = result.user;
478
+ if (!u)
479
+ throw new Error(`User not found: ${userId}`);
480
+ const user = {
481
+ id: u.id,
482
+ name: u.name,
483
+ realName: u.real_name || '',
484
+ displayName: u.profile?.display_name || u.name,
485
+ email: u.profile?.email,
486
+ isBot: u.is_bot,
487
+ };
488
+ this.userCache.set(userId, user);
489
+ return user;
490
+ }
491
+ async findUserByEmail(email) {
492
+ if (!this.webClient)
493
+ throw new Error('Slack not connected');
494
+ try {
495
+ const result = await this.webClient.users.lookupByEmail({ email });
496
+ const u = result.user;
497
+ if (!u)
498
+ return null;
499
+ const user = {
500
+ id: u.id,
501
+ name: u.name,
502
+ realName: u.real_name || '',
503
+ displayName: u.profile?.display_name || u.name,
504
+ email: u.profile?.email,
505
+ isBot: u.is_bot,
506
+ };
507
+ this.userCache.set(user.id, user);
508
+ return user;
509
+ }
510
+ catch {
511
+ return null;
512
+ }
513
+ }
514
+ async findUserByName(displayName) {
515
+ if (!this.webClient)
516
+ throw new Error('Slack not connected');
517
+ const result = await this.webClient.users.list({ limit: 500 });
518
+ const lower = displayName.toLowerCase();
519
+ for (const u of result.members || []) {
520
+ const profile = u.profile;
521
+ if (u.name?.toLowerCase() === lower ||
522
+ u.real_name?.toLowerCase() === lower ||
523
+ profile?.display_name?.toLowerCase() === lower) {
524
+ const user = {
525
+ id: u.id,
526
+ name: u.name,
527
+ realName: u.real_name || '',
528
+ displayName: profile?.display_name || u.name,
529
+ email: u.profile?.email,
530
+ isBot: u.is_bot,
531
+ };
532
+ this.userCache.set(user.id, user);
533
+ return user;
534
+ }
535
+ }
536
+ return null;
537
+ }
538
+ async searchUsers(query) {
539
+ if (!this.webClient)
540
+ throw new Error('Slack not connected');
541
+ const result = await this.webClient.users.list({ limit: 500 });
542
+ const lower = query.toLowerCase();
543
+ const matches = [];
544
+ for (const u of result.members || []) {
545
+ if (u.deleted || u.is_bot)
546
+ continue;
547
+ const profile = u.profile;
548
+ const name = u.name || '';
549
+ const realName = u.real_name || '';
550
+ const dispName = profile?.display_name || '';
551
+ const email = profile?.email || '';
552
+ if (name.toLowerCase().includes(lower) ||
553
+ realName.toLowerCase().includes(lower) ||
554
+ dispName.toLowerCase().includes(lower) ||
555
+ email.toLowerCase().includes(lower)) {
556
+ const user = {
557
+ id: u.id,
558
+ name,
559
+ realName,
560
+ displayName: dispName || name,
561
+ email,
562
+ isBot: false,
563
+ };
564
+ this.userCache.set(user.id, user);
565
+ matches.push(user);
566
+ }
567
+ }
568
+ return matches;
569
+ }
570
+ // ─── Direct Messages ───
571
+ async openDmChannel(userId) {
572
+ if (!this.webClient)
573
+ throw new Error('Slack not connected');
574
+ const result = await this.webClient.conversations.open({ users: userId });
575
+ const channelId = result.channel?.id;
576
+ if (!channelId)
577
+ throw new Error(`Failed to open DM channel with user ${userId}`);
578
+ return channelId;
579
+ }
580
+ async sendDm(params) {
581
+ const dmChannel = await this.openDmChannel(params.userId);
582
+ return this.sendMessage({
583
+ channel: dmChannel,
584
+ text: params.text,
585
+ agentId: params.agentId,
586
+ workflowInstanceId: params.workflowInstanceId,
587
+ });
588
+ }
589
+ // ─── File Upload ───
590
+ async uploadFile(params) {
591
+ if (!this.webClient)
592
+ throw new Error('Slack not connected');
593
+ const length = params.bytes instanceof Buffer ? params.bytes.length : params.bytes.byteLength;
594
+ if (!length)
595
+ throw new Error('uploadFile: bytes is empty');
596
+ const step1 = await this.webClient.files.getUploadURLExternal({
597
+ filename: params.filename,
598
+ length,
599
+ });
600
+ if (!step1.ok || !step1.upload_url || !step1.file_id) {
601
+ throw new Error(`Slack files.getUploadURLExternal failed: ${step1.error ?? 'unknown error'}`);
602
+ }
603
+ const bodyBytes = params.bytes instanceof Buffer
604
+ ? new Uint8Array(params.bytes.buffer, params.bytes.byteOffset, params.bytes.byteLength)
605
+ : params.bytes;
606
+ const putResp = await fetch(step1.upload_url, {
607
+ method: 'POST',
608
+ headers: { 'Content-Type': 'application/octet-stream' },
609
+ body: bodyBytes,
610
+ });
611
+ if (!putResp.ok) {
612
+ const detail = await putResp.text().catch(() => '');
613
+ throw new Error(`Slack upload_url POST failed (${putResp.status}): ${detail}`);
614
+ }
615
+ const files = [
616
+ { id: step1.file_id, title: params.title ?? params.filename },
617
+ ];
618
+ const step3 = params.channelId
619
+ ? await this.webClient.files.completeUploadExternal({
620
+ files,
621
+ channel_id: params.channelId,
622
+ initial_comment: params.initialComment,
623
+ thread_ts: params.threadTs,
624
+ })
625
+ : await this.webClient.files.completeUploadExternal({ files });
626
+ if (!step3.ok) {
627
+ throw new Error(`Slack files.completeUploadExternal failed: ${step3.error ?? 'unknown error'}`);
628
+ }
629
+ const file = (step3.files?.[0] ?? { id: step1.file_id });
630
+ return { fileId: step1.file_id, file };
631
+ }
632
+ // ─── File Read / Download ───
633
+ async listFiles(params = {}) {
634
+ if (!this.webClient)
635
+ throw new Error('Slack not connected');
636
+ const result = await this.webClient.files.list({
637
+ channel: params.channelId,
638
+ user: params.userId,
639
+ ts_from: params.tsFrom,
640
+ ts_to: params.tsTo,
641
+ types: params.types,
642
+ count: params.count ?? 50,
643
+ page: params.page,
644
+ });
645
+ return (result.files ?? []).map((f) => normalizeSlackFile(f));
646
+ }
647
+ async getFileInfo(fileId) {
648
+ if (!this.webClient)
649
+ throw new Error('Slack not connected');
650
+ const result = await this.webClient.files.info({ file: fileId });
651
+ if (!result.ok || !result.file) {
652
+ throw new Error(`Slack files.info failed for ${fileId}: ${result.error ?? 'unknown error'}`);
653
+ }
654
+ return normalizeSlackFile(result.file);
655
+ }
656
+ async fetchFileBytes(fileId) {
657
+ const token = this.getSecret('SLACK_BOT_TOKEN');
658
+ if (!token)
659
+ throw new Error('Slack bot token is not configured');
660
+ const info = await this.getFileInfo(fileId);
661
+ const url = info.url_private_download || info.url_private;
662
+ if (!url)
663
+ throw new Error(`Slack file ${fileId} has no url_private`);
664
+ const response = await fetch(url, {
665
+ method: 'GET',
666
+ headers: {
667
+ Authorization: `Bearer ${token}`,
668
+ Accept: '*/*',
669
+ },
670
+ redirect: 'follow',
671
+ });
672
+ if (!response.ok) {
673
+ const detail = await response.text().catch(() => '');
674
+ throw new Error(`Slack file download failed for ${fileId} (${response.status}): ${detail}`);
675
+ }
676
+ return {
677
+ buffer: Buffer.from(await response.arrayBuffer()),
678
+ contentType: response.headers.get('content-type'),
679
+ contentDisposition: response.headers.get('content-disposition'),
680
+ contentLength: response.headers.get('content-length'),
681
+ filename: info.name,
682
+ };
683
+ }
684
+ async downloadFile(file, outputPath) {
685
+ const fileId = typeof file === 'string' ? file : file.id;
686
+ const { buffer, filename, contentType } = await this.fetchFileBytes(fileId);
687
+ await fs.mkdir(path.dirname(path.resolve(outputPath)), { recursive: true });
688
+ await fs.writeFile(outputPath, buffer);
689
+ return {
690
+ path: outputPath,
691
+ bytes: buffer.byteLength,
692
+ filename,
693
+ mimeType: contentType ?? undefined,
694
+ };
695
+ }
696
+ // ─── Event Subscription (for triggers) ───
697
+ onMessage(callback) {
698
+ this.messageListeners.add(callback);
699
+ return () => { this.messageListeners.delete(callback); };
700
+ }
701
+ // ─── Status ───
702
+ getStatus() {
703
+ const config = loadConfig(this.id);
704
+ return {
705
+ connected: config.status === 'connected',
706
+ lastChecked: Date.now(),
707
+ error: config.lastError,
708
+ };
709
+ }
710
+ isConnected() {
711
+ return loadConfig(this.id).status === 'connected' && this.webClient !== null;
712
+ }
713
+ // ─── Internal helper ───
714
+ async slackApiMessageToSlackMessage(msg, channel) {
715
+ const userId = msg.user || '';
716
+ let userName = userId;
717
+ try {
718
+ if (userId) {
719
+ const user = await this.resolveUser(userId);
720
+ userName = user.displayName || user.name;
721
+ }
722
+ }
723
+ catch {
724
+ // Use userId as fallback
725
+ }
726
+ const rawFiles = msg.files;
727
+ const files = rawFiles?.length ? rawFiles.map(normalizeSlackFile) : undefined;
728
+ return {
729
+ ts: msg.ts,
730
+ threadTs: msg.thread_ts,
731
+ channel,
732
+ userId,
733
+ userName,
734
+ text: msg.text || '',
735
+ timestamp: parseSlackTs(msg.ts),
736
+ files,
737
+ };
738
+ }
739
+ }
740
+ // ─── Module-level helpers (stateless) ───
741
+ function parseSlackTs(ts) {
742
+ return Math.floor(parseFloat(ts) * 1000);
743
+ }
744
+ function normalizeEmojiName(input) {
745
+ const trimmed = input.trim().replace(/^:|:$/g, '');
746
+ if (trimmed === '👁' || trimmed === '👁️' || trimmed === '👀')
747
+ return 'eyes';
748
+ return trimmed;
749
+ }
750
+ function normalizeSlackFile(f) {
751
+ return {
752
+ id: f.id,
753
+ name: f.name,
754
+ title: f.title,
755
+ mimetype: f.mimetype,
756
+ size: f.size,
757
+ permalink: f.permalink,
758
+ permalink_public: f.permalink_public,
759
+ url_private: f.url_private,
760
+ url_private_download: f.url_private_download,
761
+ };
762
+ }
763
+ // ─── Instance Registry ───
764
+ const instances = new Map();
765
+ /**
766
+ * Get (or lazily create) the instance with the given id. The registry is
767
+ * seeded from the manifest at boot (slack/index.ts), but unknown ids are
768
+ * created on demand so individual route handlers don't have to special-case
769
+ * "instance was just added but registry isn't reloaded yet".
770
+ */
771
+ export function getInstance(id = DEFAULT_INSTANCE_ID) {
772
+ let inst = instances.get(id);
773
+ if (!inst) {
774
+ inst = new SlackInstance(id);
775
+ instances.set(id, inst);
776
+ }
777
+ return inst;
778
+ }
779
+ /** Like getInstance, but returns undefined instead of creating one. */
780
+ export function getInstanceOrUndefined(id) {
781
+ return instances.get(id);
782
+ }
783
+ /** Remove a single instance from the registry (used when deleting an instance). */
784
+ export async function removeInstance(id) {
785
+ if (id === DEFAULT_INSTANCE_ID) {
786
+ throw new Error('Cannot remove the default Slack instance');
787
+ }
788
+ const inst = instances.get(id);
789
+ if (inst) {
790
+ await inst.shutdown();
791
+ instances.delete(id);
792
+ }
793
+ }
794
+ /** All currently-loaded instances. */
795
+ export function listInstances() {
796
+ return Array.from(instances.values());
797
+ }
798
+ /** Drop every instance (used by shutdown / tests). */
799
+ export function clearInstances() {
800
+ instances.clear();
801
+ }