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
|
@@ -1,660 +1,102 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Slack Client
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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 {
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
618
|
-
return () => { messageListeners.delete(callback); };
|
|
94
|
+
return getInstance().onMessage(callback);
|
|
619
95
|
}
|
|
620
96
|
// ─── Status ───
|
|
621
97
|
export function getStatus() {
|
|
622
|
-
|
|
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
|
|
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
|
}
|