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
@@ -1,660 +1,102 @@
1
1
  /**
2
- * Slack Client
3
- * Manages Slack Web API + Socket Mode connection, message sending/receiving,
4
- * channel/user lookup, and long-poll reply waiting.
2
+ * Slack Client (single-instance facade)
3
+ *
4
+ * Thin re-export layer over the `default` SlackInstance. Existing callers
5
+ * (slack-routes, slack-trigger-handler, slack/index.ts) keep using these
6
+ * exports unchanged. Multi-instance plumbing lives in slack-instance.ts and
7
+ * gets surfaced in chunk 2.
5
8
  */
6
- import { promises as fs } from 'node:fs';
7
- import path from 'node:path';
8
- import { WebClient } from '@slack/web-api';
9
- import { SocketModeClient } from '@slack/socket-mode';
10
- import { loadConfig, updateConfig } from './slack-config.js';
11
- // Subtypes we never want to trigger on: edits, deletions, channel housekeeping, bot echoes.
12
- // NOTE: `file_share` (legacy) and undefined (modern file-share) MUST NOT be in this set.
13
- const SKIP_MESSAGE_SUBTYPES = new Set([
14
- 'bot_message',
15
- 'message_changed',
16
- 'message_deleted',
17
- 'message_replied',
18
- 'channel_join',
19
- 'channel_leave',
20
- 'channel_topic',
21
- 'channel_purpose',
22
- 'channel_name',
23
- 'channel_archive',
24
- 'channel_unarchive',
25
- 'group_join',
26
- 'group_leave',
27
- 'group_topic',
28
- 'group_purpose',
29
- 'group_name',
30
- 'group_archive',
31
- 'group_unarchive',
32
- 'pinned_item',
33
- 'unpinned_item',
34
- ]);
35
- // ─── State ───
36
- let webClient = null;
37
- let socketClient = null;
38
- let ctx = null;
39
- // Caches
40
- const userCache = new Map();
41
- const channelNameCache = new Map();
42
- // Message listeners (for trigger system)
43
- const messageListeners = new Set();
44
- const replyWaiters = new Set();
9
+ import { getInstance, listInstances, clearInstances, } from './slack-instance.js';
45
10
  // ─── Init / Shutdown ───
46
11
  export async function init(integrationCtx) {
47
- ctx = integrationCtx;
48
- const botToken = ctx.secrets.get('SLACK_BOT_TOKEN');
49
- const appToken = ctx.secrets.get('SLACK_APP_TOKEN');
50
- const config = loadConfig();
51
- if (!config.enabled || !botToken || !appToken) {
52
- ctx.log.info('Slack integration disabled or missing tokens, skipping connection');
53
- return;
54
- }
55
- await connect(botToken, appToken);
12
+ const inst = getInstance();
13
+ inst.setContext(integrationCtx);
14
+ await inst.init();
56
15
  }
57
16
  export async function shutdown() {
58
- await disconnect();
59
- userCache.clear();
60
- channelNameCache.clear();
61
- messageListeners.clear();
62
- // Cancel all pending waiters
63
- for (const waiter of replyWaiters) {
64
- clearTimeout(waiter.timer);
65
- waiter.resolve(null);
17
+ // Shut down every loaded instance (only `default` exists in chunk 1).
18
+ for (const inst of listInstances()) {
19
+ await inst.shutdown();
66
20
  }
67
- replyWaiters.clear();
21
+ clearInstances();
68
22
  }
69
23
  // ─── Connection Management ───
70
- async function connect(botToken, appToken) {
71
- updateConfig({ status: 'connecting', lastError: undefined });
72
- try {
73
- webClient = new WebClient(botToken);
74
- socketClient = new SocketModeClient({ appToken });
75
- // Test auth and get bot info
76
- const authResult = await webClient.auth.test();
77
- const botUserId = authResult.user_id;
78
- const botName = authResult.user || 'tide-bot';
79
- updateConfig({
80
- status: 'connected',
81
- botUserId,
82
- botName,
83
- connectedAt: Date.now(),
84
- });
85
- // Set up Socket Mode event handling
86
- setupSocketHandlers();
87
- // Start Socket Mode connection
88
- await socketClient.start();
89
- ctx?.log.info(`Slack connected as @${botName} (${botUserId})`);
90
- }
91
- catch (err) {
92
- const errorMsg = err instanceof Error ? err.message : String(err);
93
- updateConfig({ status: 'error', lastError: errorMsg });
94
- ctx?.log.error(`Slack connection failed: ${errorMsg}`);
95
- webClient = null;
96
- socketClient = null;
97
- throw err;
98
- }
99
- }
100
24
  export async function reconnect() {
101
- if (!ctx)
102
- throw new Error('Slack not initialized');
103
- await disconnect();
104
- const botToken = ctx.secrets.get('SLACK_BOT_TOKEN');
105
- const appToken = ctx.secrets.get('SLACK_APP_TOKEN');
106
- if (!botToken || !appToken) {
107
- throw new Error('Missing Slack tokens');
108
- }
109
- await connect(botToken, appToken);
25
+ await getInstance().reconnect();
110
26
  }
111
27
  export async function disconnect() {
112
- if (socketClient) {
113
- try {
114
- await socketClient.disconnect();
115
- }
116
- catch {
117
- // Ignore disconnect errors
118
- }
119
- socketClient = null;
120
- }
121
- webClient = null;
122
- updateConfig({ status: 'disconnected', connectedAt: undefined });
123
- }
124
- function setupSocketHandlers() {
125
- if (!socketClient)
126
- return;
127
- socketClient.on('message', async ({ event, ack }) => {
128
- await ack();
129
- if (!event)
130
- return;
131
- // Drop non-trigger-worthy subtypes (edits, joins, bot echoes, topic changes, etc.).
132
- // Modern file-share messages have NO subtype; legacy ones use `file_share` — both must pass.
133
- if (event.subtype && SKIP_MESSAGE_SUBTYPES.has(event.subtype))
134
- return;
135
- const hasFiles = Array.isArray(event.files) && event.files.length > 0;
136
- const text = event.text ?? '';
137
- // Require either text or attached files — otherwise there's nothing useful to trigger on.
138
- if (!text && !hasFiles)
139
- return;
140
- // Skip bot's own messages
141
- const config = loadConfig();
142
- if (event.user === config.botUserId)
143
- return;
144
- // Resolve username
145
- const user = await resolveUser(event.user);
146
- const userName = user?.displayName || user?.name || event.user;
147
- const files = hasFiles
148
- ? event.files.map((f) => normalizeSlackFile(f))
149
- : undefined;
150
- const message = {
151
- ts: event.ts,
152
- threadTs: event.thread_ts,
153
- channel: event.channel,
154
- userId: event.user,
155
- userName,
156
- text,
157
- timestamp: parseSlackTs(event.ts),
158
- files,
159
- };
160
- // Log to SQLite
161
- ctx?.eventDb.logSlackMessage({
162
- ts: event.ts,
163
- threadTs: event.thread_ts,
164
- channelId: event.channel,
165
- channelName: channelNameCache.get(event.channel),
166
- userId: event.user,
167
- userName,
168
- text,
169
- direction: 'inbound',
170
- rawEvent: event,
171
- receivedAt: Date.now(),
172
- });
173
- // Broadcast to WS clients
174
- ctx?.broadcast({
175
- type: 'slack_message_received',
176
- payload: {
177
- channel: event.channel,
178
- userName,
179
- text,
180
- ts: event.ts,
181
- fileCount: files?.length ?? 0,
182
- },
183
- });
184
- // Notify trigger listeners
185
- for (const listener of messageListeners) {
186
- try {
187
- listener(message);
188
- }
189
- catch (err) {
190
- ctx?.log.error(`Slack message listener error: ${err}`);
191
- }
192
- }
193
- // Check reply waiters
194
- for (const waiter of replyWaiters) {
195
- if (waiter.channel !== message.channel)
196
- continue;
197
- if (waiter.threadTs !== message.threadTs && waiter.threadTs !== message.ts)
198
- continue;
199
- if (waiter.fromUsers?.length && !waiter.fromUsers.includes(message.userId))
200
- continue;
201
- if (waiter.messagePattern && !new RegExp(waiter.messagePattern).test(message.text))
202
- continue;
203
- clearTimeout(waiter.timer);
204
- replyWaiters.delete(waiter);
205
- waiter.resolve(message);
206
- }
207
- });
208
- socketClient.on('disconnect', () => {
209
- ctx?.log.warn('Slack Socket Mode disconnected');
210
- updateConfig({ status: 'disconnected' });
211
- });
212
- socketClient.on('unable_to_socket_mode_start', (err) => {
213
- ctx?.log.error(`Slack Socket Mode start failed: ${err}`);
214
- updateConfig({ status: 'error', lastError: String(err) });
215
- });
28
+ await getInstance().disconnect();
216
29
  }
30
+ // ─── Sending ───
217
31
  export async function sendMessage(params) {
218
- if (!webClient)
219
- throw new Error('Slack not connected');
220
- const result = await webClient.chat.postMessage({
221
- channel: params.channel,
222
- text: params.text,
223
- thread_ts: params.threadTs,
224
- });
225
- const ts = result.ts;
226
- const channel = result.channel;
227
- const config = loadConfig();
228
- // Log outbound message to SQLite
229
- ctx?.eventDb.logSlackMessage({
230
- ts,
231
- threadTs: params.threadTs,
232
- channelId: channel,
233
- channelName: channelNameCache.get(channel),
234
- userId: config.botUserId || '',
235
- userName: config.botName || 'tide-bot',
236
- text: params.text,
237
- direction: 'outbound',
238
- agentId: params.agentId,
239
- workflowInstanceId: params.workflowInstanceId,
240
- receivedAt: Date.now(),
241
- });
242
- return { ts, channel };
32
+ return getInstance().sendMessage(params);
243
33
  }
244
- /**
245
- * Add an emoji reaction to a message. Requires `reactions:write`.
246
- * `already_reacted` responses are swallowed silently.
247
- */
34
+ // ─── Reactions ───
248
35
  export async function addReaction(params) {
249
- if (!webClient)
250
- throw new Error('Slack not connected');
251
- const name = normalizeEmojiName(params.name);
252
- try {
253
- await webClient.reactions.add({
254
- channel: params.channel,
255
- timestamp: params.ts,
256
- name,
257
- });
258
- }
259
- catch (err) {
260
- const slackErr = err.data?.error;
261
- if (slackErr === 'already_reacted')
262
- return;
263
- throw err;
264
- }
265
- }
266
- /** Map raw emoji chars to Slack slugs; strip surrounding colons if caller passed `:eyes:`. */
267
- function normalizeEmojiName(input) {
268
- const trimmed = input.trim().replace(/^:|:$/g, '');
269
- // Any eye-related emoji char collapses to the common `eyes` slug.
270
- if (trimmed === '👁' || trimmed === '👁️' || trimmed === '👀')
271
- return 'eyes';
272
- return trimmed;
36
+ return getInstance().addReaction(params);
273
37
  }
38
+ // ─── Reading ───
274
39
  export async function getChannelMessages(params) {
275
- if (!webClient)
276
- throw new Error('Slack not connected');
277
- const result = await webClient.conversations.history({
278
- channel: params.channel,
279
- limit: params.limit || 20,
280
- oldest: params.oldest,
281
- latest: params.latest,
282
- });
283
- return Promise.all((result.messages || []).map((msg) => slackApiMessageToSlackMessage(msg, params.channel)));
40
+ return getInstance().getChannelMessages(params);
284
41
  }
285
42
  export async function getThreadReplies(params) {
286
- if (!webClient)
287
- throw new Error('Slack not connected');
288
- const result = await webClient.conversations.replies({
289
- channel: params.channel,
290
- ts: params.threadTs,
291
- limit: params.limit || 50,
292
- });
293
- return Promise.all((result.messages || []).map((msg) => slackApiMessageToSlackMessage(msg, params.channel)));
43
+ return getInstance().getThreadReplies(params);
294
44
  }
45
+ // ─── Wait For Reply (Long-Poll) ───
295
46
  export function waitForReply(params) {
296
- const timeout = params.timeoutMs || 300000; // 5 min default
297
- return new Promise((resolve) => {
298
- const timer = setTimeout(() => {
299
- replyWaiters.delete(waiter);
300
- resolve(null);
301
- }, timeout);
302
- const waiter = {
303
- channel: params.channel,
304
- threadTs: params.threadTs,
305
- fromUsers: params.fromUsers,
306
- messagePattern: params.messagePattern,
307
- resolve,
308
- timer,
309
- };
310
- replyWaiters.add(waiter);
311
- });
47
+ return getInstance().waitForReply(params);
312
48
  }
313
49
  // ─── Channel Management ───
314
50
  export async function joinChannel(channel) {
315
- if (!webClient)
316
- throw new Error('Slack not connected');
317
- const result = await webClient.conversations.join({ channel });
318
- const ch = result.channel;
319
- if (!ch)
320
- throw new Error(`Failed to join channel ${channel}`);
321
- // Update cache
322
- channelNameCache.set(ch.id, ch.name);
323
- return { id: ch.id, name: ch.name };
51
+ return getInstance().joinChannel(channel);
324
52
  }
325
53
  // ─── Lookup ───
326
54
  export async function listChannels() {
327
- if (!webClient)
328
- throw new Error('Slack not connected');
329
- const channels = [];
330
- let cursor;
331
- do {
332
- const result = await webClient.conversations.list({
333
- types: 'public_channel,private_channel',
334
- limit: 200,
335
- cursor,
336
- });
337
- for (const ch of result.channels || []) {
338
- const channel = {
339
- id: ch.id,
340
- name: ch.name,
341
- isPrivate: ch.is_private,
342
- isMember: ch.is_member,
343
- topic: ch.topic?.value,
344
- purpose: ch.purpose?.value,
345
- };
346
- channels.push(channel);
347
- channelNameCache.set(channel.id, channel.name);
348
- }
349
- cursor = result.response_metadata?.next_cursor || undefined;
350
- } while (cursor);
351
- return channels;
55
+ return getInstance().listChannels();
352
56
  }
353
57
  export async function resolveUser(userId) {
354
- // Check cache
355
- const cached = userCache.get(userId);
356
- if (cached)
357
- return cached;
358
- if (!webClient)
359
- throw new Error('Slack not connected');
360
- const result = await webClient.users.info({ user: userId });
361
- const u = result.user;
362
- if (!u)
363
- throw new Error(`User not found: ${userId}`);
364
- const user = {
365
- id: u.id,
366
- name: u.name,
367
- realName: u.real_name || '',
368
- displayName: u.profile?.display_name || u.name,
369
- email: u.profile?.email,
370
- isBot: u.is_bot,
371
- };
372
- userCache.set(userId, user);
373
- return user;
58
+ return getInstance().resolveUser(userId);
374
59
  }
375
60
  export async function findUserByEmail(email) {
376
- if (!webClient)
377
- throw new Error('Slack not connected');
378
- try {
379
- const result = await webClient.users.lookupByEmail({ email });
380
- const u = result.user;
381
- if (!u)
382
- return null;
383
- const user = {
384
- id: u.id,
385
- name: u.name,
386
- realName: u.real_name || '',
387
- displayName: u.profile?.display_name || u.name,
388
- email: u.profile?.email,
389
- isBot: u.is_bot,
390
- };
391
- userCache.set(user.id, user);
392
- return user;
393
- }
394
- catch {
395
- return null;
396
- }
61
+ return getInstance().findUserByEmail(email);
397
62
  }
398
63
  export async function findUserByName(displayName) {
399
- if (!webClient)
400
- throw new Error('Slack not connected');
401
- const result = await webClient.users.list({ limit: 500 });
402
- const lower = displayName.toLowerCase();
403
- for (const u of result.members || []) {
404
- const profile = u.profile;
405
- if (u.name?.toLowerCase() === lower ||
406
- u.real_name?.toLowerCase() === lower ||
407
- profile?.display_name?.toLowerCase() === lower) {
408
- const user = {
409
- id: u.id,
410
- name: u.name,
411
- realName: u.real_name || '',
412
- displayName: profile?.display_name || u.name,
413
- email: u.profile?.email,
414
- isBot: u.is_bot,
415
- };
416
- userCache.set(user.id, user);
417
- return user;
418
- }
419
- }
420
- return null;
64
+ return getInstance().findUserByName(displayName);
65
+ }
66
+ export async function searchUsers(query) {
67
+ return getInstance().searchUsers(query);
421
68
  }
422
69
  // ─── Direct Messages ───
423
70
  export async function openDmChannel(userId) {
424
- if (!webClient)
425
- throw new Error('Slack not connected');
426
- const result = await webClient.conversations.open({ users: userId });
427
- const channelId = result.channel?.id;
428
- if (!channelId)
429
- throw new Error(`Failed to open DM channel with user ${userId}`);
430
- return channelId;
71
+ return getInstance().openDmChannel(userId);
431
72
  }
432
73
  export async function sendDm(params) {
433
- const dmChannel = await openDmChannel(params.userId);
434
- return sendMessage({
435
- channel: dmChannel,
436
- text: params.text,
437
- agentId: params.agentId,
438
- workflowInstanceId: params.workflowInstanceId,
439
- });
74
+ return getInstance().sendDm(params);
440
75
  }
441
- /**
442
- * Upload a file to Slack via the new two-step flow:
443
- * 1. files.getUploadURLExternal (filename, length) → { upload_url, file_id }
444
- * 2. PUT raw bytes to upload_url
445
- * 3. files.completeUploadExternal (files[], channel_id?, initial_comment?, thread_ts?)
446
- * Requires the bot token to have the `files:write` scope.
447
- */
76
+ // ─── File Upload / Read / Download ───
448
77
  export async function uploadFile(params) {
449
- if (!webClient)
450
- throw new Error('Slack not connected');
451
- const length = params.bytes instanceof Buffer ? params.bytes.length : params.bytes.byteLength;
452
- if (!length)
453
- throw new Error('uploadFile: bytes is empty');
454
- // Step 1: get an external upload URL
455
- const step1 = await webClient.files.getUploadURLExternal({
456
- filename: params.filename,
457
- length,
458
- });
459
- if (!step1.ok || !step1.upload_url || !step1.file_id) {
460
- throw new Error(`Slack files.getUploadURLExternal failed: ${step1.error ?? 'unknown error'}`);
461
- }
462
- // Step 2: POST the raw bytes to the signed upload URL (no Slack auth on this call).
463
- // Node's global fetch accepts Buffer/Uint8Array at runtime; BodyInit types require a cast.
464
- const bodyBytes = params.bytes instanceof Buffer
465
- ? new Uint8Array(params.bytes.buffer, params.bytes.byteOffset, params.bytes.byteLength)
466
- : params.bytes;
467
- const putResp = await fetch(step1.upload_url, {
468
- method: 'POST',
469
- headers: { 'Content-Type': 'application/octet-stream' },
470
- body: bodyBytes,
471
- });
472
- if (!putResp.ok) {
473
- const detail = await putResp.text().catch(() => '');
474
- throw new Error(`Slack upload_url POST failed (${putResp.status}): ${detail}`);
475
- }
476
- // Step 3: finalize and (optionally) share
477
- const files = [
478
- { id: step1.file_id, title: params.title ?? params.filename },
479
- ];
480
- const step3 = params.channelId
481
- ? await webClient.files.completeUploadExternal({
482
- files,
483
- channel_id: params.channelId,
484
- initial_comment: params.initialComment,
485
- thread_ts: params.threadTs,
486
- })
487
- : await webClient.files.completeUploadExternal({ files });
488
- if (!step3.ok) {
489
- throw new Error(`Slack files.completeUploadExternal failed: ${step3.error ?? 'unknown error'}`);
490
- }
491
- const file = (step3.files?.[0] ?? { id: step1.file_id });
492
- return { fileId: step1.file_id, file };
78
+ return getInstance().uploadFile(params);
493
79
  }
494
- /**
495
- * List files visible to the bot, optionally filtered by channel/user/time/type.
496
- * Requires the bot token to have `files:read`.
497
- */
498
80
  export async function listFiles(params = {}) {
499
- if (!webClient)
500
- throw new Error('Slack not connected');
501
- const result = await webClient.files.list({
502
- channel: params.channelId,
503
- user: params.userId,
504
- ts_from: params.tsFrom,
505
- ts_to: params.tsTo,
506
- types: params.types,
507
- count: params.count ?? 50,
508
- page: params.page,
509
- });
510
- return (result.files ?? []).map((f) => normalizeSlackFile(f));
81
+ return getInstance().listFiles(params);
511
82
  }
512
- /** Fetch metadata for a single file (title, permalink, url_private, mimetype, size, ...). */
513
83
  export async function getFileInfo(fileId) {
514
- if (!webClient)
515
- throw new Error('Slack not connected');
516
- const result = await webClient.files.info({ file: fileId });
517
- if (!result.ok || !result.file) {
518
- throw new Error(`Slack files.info failed for ${fileId}: ${result.error ?? 'unknown error'}`);
519
- }
520
- return normalizeSlackFile(result.file);
84
+ return getInstance().getFileInfo(fileId);
521
85
  }
522
- /**
523
- * Fetch a file's raw bytes using the bot token Bearer auth.
524
- * Slack's `url_private` redirects to the CDN but requires the bot token on the initial request.
525
- */
526
86
  export async function fetchFileBytes(fileId) {
527
- const token = ctx?.secrets.get('SLACK_BOT_TOKEN');
528
- if (!token)
529
- throw new Error('Slack bot token is not configured');
530
- const info = await getFileInfo(fileId);
531
- const url = info.url_private_download || info.url_private;
532
- if (!url)
533
- throw new Error(`Slack file ${fileId} has no url_private`);
534
- const response = await fetch(url, {
535
- method: 'GET',
536
- headers: {
537
- Authorization: `Bearer ${token}`,
538
- Accept: '*/*',
539
- },
540
- redirect: 'follow',
541
- });
542
- if (!response.ok) {
543
- const detail = await response.text().catch(() => '');
544
- throw new Error(`Slack file download failed for ${fileId} (${response.status}): ${detail}`);
545
- }
546
- return {
547
- buffer: Buffer.from(await response.arrayBuffer()),
548
- contentType: response.headers.get('content-type'),
549
- contentDisposition: response.headers.get('content-disposition'),
550
- contentLength: response.headers.get('content-length'),
551
- filename: info.name,
552
- };
87
+ return getInstance().fetchFileBytes(fileId);
553
88
  }
554
- /**
555
- * Download a Slack file to disk. Accepts a file id or a full {@link SlackFile} object.
556
- * Parent directory is created if missing; filename collisions overwrite.
557
- */
558
89
  export async function downloadFile(file, outputPath) {
559
- const fileId = typeof file === 'string' ? file : file.id;
560
- const { buffer, filename, contentType } = await fetchFileBytes(fileId);
561
- await fs.mkdir(path.dirname(path.resolve(outputPath)), { recursive: true });
562
- await fs.writeFile(outputPath, buffer);
563
- return {
564
- path: outputPath,
565
- bytes: buffer.byteLength,
566
- filename,
567
- mimeType: contentType ?? undefined,
568
- };
569
- }
570
- function normalizeSlackFile(f) {
571
- return {
572
- id: f.id,
573
- name: f.name,
574
- title: f.title,
575
- mimetype: f.mimetype,
576
- size: f.size,
577
- permalink: f.permalink,
578
- permalink_public: f.permalink_public,
579
- url_private: f.url_private,
580
- url_private_download: f.url_private_download,
581
- };
582
- }
583
- export async function searchUsers(query) {
584
- if (!webClient)
585
- throw new Error('Slack not connected');
586
- const result = await webClient.users.list({ limit: 500 });
587
- const lower = query.toLowerCase();
588
- const matches = [];
589
- for (const u of result.members || []) {
590
- if (u.deleted || u.is_bot)
591
- continue;
592
- const profile = u.profile;
593
- const name = u.name || '';
594
- const realName = u.real_name || '';
595
- const displayName = profile?.display_name || '';
596
- const email = profile?.email || '';
597
- if (name.toLowerCase().includes(lower) ||
598
- realName.toLowerCase().includes(lower) ||
599
- displayName.toLowerCase().includes(lower) ||
600
- email.toLowerCase().includes(lower)) {
601
- const user = {
602
- id: u.id,
603
- name,
604
- realName,
605
- displayName: displayName || name,
606
- email,
607
- isBot: false,
608
- };
609
- userCache.set(user.id, user);
610
- matches.push(user);
611
- }
612
- }
613
- return matches;
90
+ return getInstance().downloadFile(file, outputPath);
614
91
  }
615
92
  // ─── Event Subscription (for triggers) ───
616
93
  export function onMessage(callback) {
617
- messageListeners.add(callback);
618
- return () => { messageListeners.delete(callback); };
94
+ return getInstance().onMessage(callback);
619
95
  }
620
96
  // ─── Status ───
621
97
  export function getStatus() {
622
- const config = loadConfig();
623
- return {
624
- connected: config.status === 'connected',
625
- lastChecked: Date.now(),
626
- error: config.lastError,
627
- };
98
+ return getInstance().getStatus();
628
99
  }
629
100
  export function isConnected() {
630
- return loadConfig().status === 'connected' && webClient !== null;
631
- }
632
- // ─── Helpers ───
633
- function parseSlackTs(ts) {
634
- return Math.floor(parseFloat(ts) * 1000);
635
- }
636
- async function slackApiMessageToSlackMessage(msg, channel) {
637
- const userId = msg.user || '';
638
- let userName = userId;
639
- try {
640
- if (userId) {
641
- const user = await resolveUser(userId);
642
- userName = user.displayName || user.name;
643
- }
644
- }
645
- catch {
646
- // Use userId as fallback
647
- }
648
- const rawFiles = msg.files;
649
- const files = rawFiles?.length ? rawFiles.map(normalizeSlackFile) : undefined;
650
- return {
651
- ts: msg.ts,
652
- threadTs: msg.thread_ts,
653
- channel,
654
- userId,
655
- userName,
656
- text: msg.text || '',
657
- timestamp: parseSlackTs(msg.ts),
658
- files,
659
- };
101
+ return getInstance().isConnected();
660
102
  }