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
|
@@ -6,22 +6,217 @@
|
|
|
6
6
|
import { Router } from 'express';
|
|
7
7
|
import multer from 'multer';
|
|
8
8
|
import * as slackClient from './slack-client.js';
|
|
9
|
-
import { loadConfig } from './slack-config.js';
|
|
9
|
+
import { loadConfig, getConfigValues, setConfigValues, DEFAULT_INSTANCE_ID, instanceSecretKey } from './slack-config.js';
|
|
10
|
+
import { getInstance, removeInstance as unloadInstance, } from './slack-instance.js';
|
|
11
|
+
import { listInstanceMetas, getInstanceMeta, hasInstance, addInstance, renameInstance, removeInstance as removeInstanceMeta, validateInstanceId, } from './slack-instance-manifest.js';
|
|
10
12
|
import { createLogger } from '../../utils/logger.js';
|
|
11
13
|
const log = createLogger('SlackRoutes');
|
|
12
14
|
const router = Router();
|
|
15
|
+
/**
|
|
16
|
+
* Pick the instance id from the request. Order of precedence:
|
|
17
|
+
* 1. ?instance=<id> query param
|
|
18
|
+
* 2. body.instanceId (POST/PATCH only)
|
|
19
|
+
* 3. 'default'
|
|
20
|
+
* Returns null + writes a 404 if the requested id isn't in the manifest.
|
|
21
|
+
*/
|
|
22
|
+
function resolveInstanceIdOr404(req, res) {
|
|
23
|
+
const fromQuery = req.query.instance?.trim();
|
|
24
|
+
const body = (req.body ?? {});
|
|
25
|
+
const fromBody = typeof body.instanceId === 'string' ? body.instanceId.trim() : undefined;
|
|
26
|
+
const id = fromQuery || fromBody || DEFAULT_INSTANCE_ID;
|
|
27
|
+
if (!hasInstance(id)) {
|
|
28
|
+
res.status(404).json({ error: `Slack instance "${id}" not found` });
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
return id;
|
|
32
|
+
}
|
|
33
|
+
function svc(req, res) {
|
|
34
|
+
const id = resolveInstanceIdOr404(req, res);
|
|
35
|
+
if (!id)
|
|
36
|
+
return null;
|
|
37
|
+
return { id, inst: getInstance(id) };
|
|
38
|
+
}
|
|
39
|
+
// ─── /instances CRUD ───
|
|
40
|
+
// GET /api/slack/instances — list all instances + their config + status
|
|
41
|
+
router.get('/instances', (req, res) => {
|
|
42
|
+
void req;
|
|
43
|
+
const metas = listInstanceMetas();
|
|
44
|
+
const result = metas.map((meta) => {
|
|
45
|
+
const inst = getInstance(meta.id);
|
|
46
|
+
return {
|
|
47
|
+
id: meta.id,
|
|
48
|
+
label: meta.label,
|
|
49
|
+
createdAt: meta.createdAt,
|
|
50
|
+
status: inst.getStatus(),
|
|
51
|
+
config: loadConfig(meta.id),
|
|
52
|
+
};
|
|
53
|
+
});
|
|
54
|
+
res.json({ instances: result });
|
|
55
|
+
});
|
|
56
|
+
// POST /api/slack/instances — create a new instance
|
|
57
|
+
router.post('/instances', (req, res) => {
|
|
58
|
+
try {
|
|
59
|
+
const { id, label } = req.body;
|
|
60
|
+
if (!id) {
|
|
61
|
+
res.status(400).json({ error: 'id is required' });
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const validationErr = validateInstanceId(id);
|
|
65
|
+
if (validationErr) {
|
|
66
|
+
res.status(400).json({ error: validationErr });
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const meta = addInstance(id, label || id);
|
|
70
|
+
// Force-create + wire the SlackInstance so subsequent calls (PATCH /:id,
|
|
71
|
+
// POST /:id/connect) can call reconnect() immediately.
|
|
72
|
+
const inst = getInstance(meta.id);
|
|
73
|
+
ensureInstanceWired(inst);
|
|
74
|
+
res.json({ instance: meta });
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
res.status(400).json({ error: err instanceof Error ? err.message : String(err) });
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
// GET /api/slack/instances/:id — single instance status + config
|
|
81
|
+
router.get('/instances/:id', (req, res) => {
|
|
82
|
+
const meta = getInstanceMeta(req.params.id);
|
|
83
|
+
if (!meta) {
|
|
84
|
+
res.status(404).json({ error: 'Instance not found' });
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const inst = getInstance(req.params.id);
|
|
88
|
+
res.json({
|
|
89
|
+
instance: meta,
|
|
90
|
+
status: inst.getStatus(),
|
|
91
|
+
config: loadConfig(req.params.id),
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
// PATCH /api/slack/instances/:id — rename + apply per-instance config / secrets, then reconnect
|
|
95
|
+
router.patch('/instances/:id', async (req, res) => {
|
|
96
|
+
try {
|
|
97
|
+
const meta = getInstanceMeta(req.params.id);
|
|
98
|
+
if (!meta) {
|
|
99
|
+
res.status(404).json({ error: 'Instance not found' });
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const body = (req.body ?? {});
|
|
103
|
+
if (typeof body.label === 'string') {
|
|
104
|
+
renameInstance(req.params.id, body.label);
|
|
105
|
+
}
|
|
106
|
+
if (body.values && typeof body.values === 'object') {
|
|
107
|
+
// The integration context owns the secret store. We expose it via a
|
|
108
|
+
// tiny helper exported from index.ts… but to keep this route file
|
|
109
|
+
// self-contained, we accept that secrets routing happens through
|
|
110
|
+
// setConfigValues which reads ctx.secrets indirectly via the same
|
|
111
|
+
// pattern slackPlugin.setConfig uses. Inline secret setter:
|
|
112
|
+
const secrets = getRequestSecrets(req);
|
|
113
|
+
if (!secrets) {
|
|
114
|
+
res.status(500).json({ error: 'Slack integration not initialized' });
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
await setConfigValues(body.values, secrets, req.params.id);
|
|
118
|
+
}
|
|
119
|
+
// Reconnect this instance with its new config / secrets.
|
|
120
|
+
const inst = getInstance(req.params.id);
|
|
121
|
+
ensureInstanceWired(inst);
|
|
122
|
+
const updated = loadConfig(req.params.id);
|
|
123
|
+
const secrets = getRequestSecrets(req);
|
|
124
|
+
const botToken = secrets?.get(instanceSecretKey('SLACK_BOT_TOKEN', req.params.id));
|
|
125
|
+
if (updated.enabled && botToken) {
|
|
126
|
+
try {
|
|
127
|
+
await inst.reconnect();
|
|
128
|
+
}
|
|
129
|
+
catch (e) {
|
|
130
|
+
log.error(`Slack[${req.params.id}] reconnect failed: ${e instanceof Error ? e.message : e}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
else if (!updated.enabled && inst.isConnected()) {
|
|
134
|
+
await inst.disconnect();
|
|
135
|
+
}
|
|
136
|
+
res.json({
|
|
137
|
+
instance: getInstanceMeta(req.params.id),
|
|
138
|
+
status: inst.getStatus(),
|
|
139
|
+
config: loadConfig(req.params.id),
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
res.status(400).json({ error: err instanceof Error ? err.message : String(err) });
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
// DELETE /api/slack/instances/:id — remove instance (manifest + config + connection)
|
|
147
|
+
router.delete('/instances/:id', async (req, res) => {
|
|
148
|
+
try {
|
|
149
|
+
if (req.params.id === DEFAULT_INSTANCE_ID) {
|
|
150
|
+
res.status(400).json({ error: 'Cannot delete the default instance' });
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (!getInstanceMeta(req.params.id)) {
|
|
154
|
+
res.status(404).json({ error: 'Instance not found' });
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
await unloadInstance(req.params.id);
|
|
158
|
+
removeInstanceMeta(req.params.id);
|
|
159
|
+
// We deliberately do NOT delete secrets here — the secret store has no
|
|
160
|
+
// enumerate API and clients can overwrite the per-instance keys via
|
|
161
|
+
// PATCH if they want them gone. The keys do nothing without a manifest
|
|
162
|
+
// entry.
|
|
163
|
+
res.json({ ok: true });
|
|
164
|
+
}
|
|
165
|
+
catch (err) {
|
|
166
|
+
res.status(400).json({ error: err instanceof Error ? err.message : String(err) });
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
// GET /api/slack/instances/:id/values — config values shaped for the UI form
|
|
170
|
+
router.get('/instances/:id/values', (req, res) => {
|
|
171
|
+
const meta = getInstanceMeta(req.params.id);
|
|
172
|
+
if (!meta) {
|
|
173
|
+
res.status(404).json({ error: 'Instance not found' });
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
const secrets = getRequestSecrets(req);
|
|
177
|
+
if (!secrets) {
|
|
178
|
+
res.status(500).json({ error: 'Slack integration not initialized' });
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
res.json({ values: getConfigValues(secrets, req.params.id) });
|
|
182
|
+
});
|
|
183
|
+
let ctxRef = null;
|
|
184
|
+
export function setIntegrationContextForRoutes(ctx) {
|
|
185
|
+
ctxRef = ctx;
|
|
186
|
+
}
|
|
187
|
+
/** Back-compat alias for older imports — same store, accessed via ctxRef now. */
|
|
188
|
+
export function setSecretStoreForRoutes(store) {
|
|
189
|
+
ctxRef = store ? { secrets: store } : null;
|
|
190
|
+
}
|
|
191
|
+
function getRequestSecrets(_req) {
|
|
192
|
+
return ctxRef?.secrets ?? null;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Ensure a SlackInstance has its integration context wired. Call this any
|
|
196
|
+
* time we create a new instance at runtime (POST /instances) so reconnect
|
|
197
|
+
* doesn't throw "not initialized" before the next server boot.
|
|
198
|
+
*/
|
|
199
|
+
function ensureInstanceWired(inst) {
|
|
200
|
+
if (ctxRef) {
|
|
201
|
+
// setContext is idempotent; safe to call repeatedly.
|
|
202
|
+
inst.setContext(ctxRef);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
13
205
|
// 50 MB cap matches other integrations (docx). Slack's own limit is higher but this keeps memory sane.
|
|
14
206
|
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 50 * 1024 * 1024 } });
|
|
15
207
|
// POST /api/slack/send — Send a message
|
|
16
208
|
router.post('/send', async (req, res) => {
|
|
17
209
|
try {
|
|
210
|
+
const handle = svc(req, res);
|
|
211
|
+
if (!handle)
|
|
212
|
+
return;
|
|
18
213
|
const { channel, text, threadTs, agentId, workflowInstanceId } = req.body;
|
|
19
214
|
if (!channel || !text) {
|
|
20
215
|
res.status(400).json({ error: 'channel and text are required' });
|
|
21
216
|
return;
|
|
22
217
|
}
|
|
23
|
-
const result = await
|
|
24
|
-
res.json({ success: true, ts: result.ts, channel: result.channel });
|
|
218
|
+
const result = await handle.inst.sendMessage({ channel, text, threadTs, agentId, workflowInstanceId });
|
|
219
|
+
res.json({ success: true, ts: result.ts, channel: result.channel, instanceId: handle.id });
|
|
25
220
|
}
|
|
26
221
|
catch (err) {
|
|
27
222
|
log.error(`Slack send error: ${err}`);
|
|
@@ -31,18 +226,21 @@ router.post('/send', async (req, res) => {
|
|
|
31
226
|
// GET /api/slack/messages — Read channel messages
|
|
32
227
|
router.get('/messages', async (req, res) => {
|
|
33
228
|
try {
|
|
229
|
+
const handle = svc(req, res);
|
|
230
|
+
if (!handle)
|
|
231
|
+
return;
|
|
34
232
|
const channel = req.query.channel;
|
|
35
233
|
if (!channel) {
|
|
36
234
|
res.status(400).json({ error: 'channel query param is required' });
|
|
37
235
|
return;
|
|
38
236
|
}
|
|
39
|
-
const messages = await
|
|
237
|
+
const messages = await handle.inst.getChannelMessages({
|
|
40
238
|
channel,
|
|
41
239
|
limit: req.query.limit ? parseInt(req.query.limit, 10) : undefined,
|
|
42
240
|
oldest: req.query.oldest,
|
|
43
241
|
latest: req.query.latest,
|
|
44
242
|
});
|
|
45
|
-
res.json({ messages });
|
|
243
|
+
res.json({ messages, instanceId: handle.id });
|
|
46
244
|
}
|
|
47
245
|
catch (err) {
|
|
48
246
|
log.error(`Slack messages error: ${err}`);
|
|
@@ -52,18 +250,21 @@ router.get('/messages', async (req, res) => {
|
|
|
52
250
|
// GET /api/slack/thread — Read thread replies
|
|
53
251
|
router.get('/thread', async (req, res) => {
|
|
54
252
|
try {
|
|
253
|
+
const handle = svc(req, res);
|
|
254
|
+
if (!handle)
|
|
255
|
+
return;
|
|
55
256
|
const channel = req.query.channel;
|
|
56
257
|
const threadTs = req.query.threadTs;
|
|
57
258
|
if (!channel || !threadTs) {
|
|
58
259
|
res.status(400).json({ error: 'channel and threadTs query params are required' });
|
|
59
260
|
return;
|
|
60
261
|
}
|
|
61
|
-
const messages = await
|
|
262
|
+
const messages = await handle.inst.getThreadReplies({
|
|
62
263
|
channel,
|
|
63
264
|
threadTs,
|
|
64
265
|
limit: req.query.limit ? parseInt(req.query.limit, 10) : undefined,
|
|
65
266
|
});
|
|
66
|
-
res.json({ messages });
|
|
267
|
+
res.json({ messages, instanceId: handle.id });
|
|
67
268
|
}
|
|
68
269
|
catch (err) {
|
|
69
270
|
log.error(`Slack thread error: ${err}`);
|
|
@@ -93,10 +294,13 @@ router.post('/wait-for-reply', async (req, res) => {
|
|
|
93
294
|
}
|
|
94
295
|
});
|
|
95
296
|
// GET /api/slack/channels — List all channels
|
|
96
|
-
router.get('/channels', async (
|
|
297
|
+
router.get('/channels', async (req, res) => {
|
|
97
298
|
try {
|
|
98
|
-
const
|
|
99
|
-
|
|
299
|
+
const handle = svc(req, res);
|
|
300
|
+
if (!handle)
|
|
301
|
+
return;
|
|
302
|
+
const channels = await handle.inst.listChannels();
|
|
303
|
+
res.json({ channels, instanceId: handle.id });
|
|
100
304
|
}
|
|
101
305
|
catch (err) {
|
|
102
306
|
log.error(`Slack channels error: ${err}`);
|
|
@@ -106,13 +310,16 @@ router.get('/channels', async (_req, res) => {
|
|
|
106
310
|
// POST /api/slack/channels/join — Join a channel
|
|
107
311
|
router.post('/channels/join', async (req, res) => {
|
|
108
312
|
try {
|
|
313
|
+
const handle = svc(req, res);
|
|
314
|
+
if (!handle)
|
|
315
|
+
return;
|
|
109
316
|
const { channel } = req.body;
|
|
110
317
|
if (!channel) {
|
|
111
318
|
res.status(400).json({ error: 'channel is required' });
|
|
112
319
|
return;
|
|
113
320
|
}
|
|
114
|
-
const result = await
|
|
115
|
-
res.json({ success: true, channel: result });
|
|
321
|
+
const result = await handle.inst.joinChannel(channel);
|
|
322
|
+
res.json({ success: true, channel: result, instanceId: handle.id });
|
|
116
323
|
}
|
|
117
324
|
catch (err) {
|
|
118
325
|
log.error(`Slack join channel error: ${err}`);
|
|
@@ -149,13 +356,16 @@ router.get('/users/:userId', async (req, res) => {
|
|
|
149
356
|
// POST /api/slack/dm — Send a direct message to a user
|
|
150
357
|
router.post('/dm', async (req, res) => {
|
|
151
358
|
try {
|
|
359
|
+
const handle = svc(req, res);
|
|
360
|
+
if (!handle)
|
|
361
|
+
return;
|
|
152
362
|
const { userId, text, agentId, workflowInstanceId } = req.body;
|
|
153
363
|
if (!userId || !text) {
|
|
154
364
|
res.status(400).json({ error: 'userId and text are required' });
|
|
155
365
|
return;
|
|
156
366
|
}
|
|
157
|
-
const result = await
|
|
158
|
-
res.json({ success: true, ts: result.ts, channel: result.channel });
|
|
367
|
+
const result = await handle.inst.sendDm({ userId, text, agentId, workflowInstanceId });
|
|
368
|
+
res.json({ success: true, ts: result.ts, channel: result.channel, instanceId: handle.id });
|
|
159
369
|
}
|
|
160
370
|
catch (err) {
|
|
161
371
|
log.error(`Slack DM error: ${err}`);
|
|
@@ -307,16 +517,22 @@ router.post('/reactions/add', async (req, res) => {
|
|
|
307
517
|
res.status(500).json({ error: `Failed to add reaction: ${err instanceof Error ? err.message : err}` });
|
|
308
518
|
}
|
|
309
519
|
});
|
|
310
|
-
// GET /api/slack/status — Get connection status
|
|
311
|
-
router.get('/status', (
|
|
312
|
-
const
|
|
313
|
-
|
|
520
|
+
// GET /api/slack/status — Get connection status (per-instance via ?instance=)
|
|
521
|
+
router.get('/status', (req, res) => {
|
|
522
|
+
const handle = svc(req, res);
|
|
523
|
+
if (!handle)
|
|
524
|
+
return;
|
|
525
|
+
const config = loadConfig(handle.id);
|
|
526
|
+
res.json({ ...config, instanceId: handle.id });
|
|
314
527
|
});
|
|
315
528
|
// POST /api/slack/connect — Manually trigger connection
|
|
316
|
-
router.post('/connect', async (
|
|
529
|
+
router.post('/connect', async (req, res) => {
|
|
317
530
|
try {
|
|
318
|
-
|
|
319
|
-
|
|
531
|
+
const handle = svc(req, res);
|
|
532
|
+
if (!handle)
|
|
533
|
+
return;
|
|
534
|
+
await handle.inst.reconnect();
|
|
535
|
+
res.json({ success: true, status: loadConfig(handle.id), instanceId: handle.id });
|
|
320
536
|
}
|
|
321
537
|
catch (err) {
|
|
322
538
|
log.error(`Slack connect error: ${err}`);
|
|
@@ -324,10 +540,13 @@ router.post('/connect', async (_req, res) => {
|
|
|
324
540
|
}
|
|
325
541
|
});
|
|
326
542
|
// POST /api/slack/disconnect — Manually disconnect
|
|
327
|
-
router.post('/disconnect', async (
|
|
543
|
+
router.post('/disconnect', async (req, res) => {
|
|
328
544
|
try {
|
|
329
|
-
|
|
330
|
-
|
|
545
|
+
const handle = svc(req, res);
|
|
546
|
+
if (!handle)
|
|
547
|
+
return;
|
|
548
|
+
await handle.inst.disconnect();
|
|
549
|
+
res.json({ success: true, status: loadConfig(handle.id), instanceId: handle.id });
|
|
331
550
|
}
|
|
332
551
|
catch (err) {
|
|
333
552
|
log.error(`Slack disconnect error: ${err}`);
|
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Slack Trigger Handler
|
|
3
3
|
* Implements TriggerHandler for 'slack' type triggers.
|
|
4
|
-
*
|
|
4
|
+
*
|
|
5
|
+
* Multi-instance: subscribes to onMessage on EVERY known Slack instance and
|
|
6
|
+
* propagates the source instanceId through the ExternalEvent payload so
|
|
7
|
+
* triggers can scope themselves to a specific Slack connection (or accept
|
|
8
|
+
* any).
|
|
5
9
|
*/
|
|
6
10
|
import * as slackClient from './slack-client.js';
|
|
7
|
-
|
|
11
|
+
import { listInstances } from './slack-instance.js';
|
|
12
|
+
import { listInstanceMetas } from './slack-instance-manifest.js';
|
|
13
|
+
const unsubscribers = [];
|
|
8
14
|
/** Env toggle: set SLACK_REACT_ON_TRIGGER=false (or 0/no/off) to disable the auto-:eyes: ack. */
|
|
9
15
|
function reactOnTriggerEnabled() {
|
|
10
16
|
const raw = (process.env.SLACK_REACT_ON_TRIGGER ?? '').toLowerCase().trim();
|
|
@@ -16,34 +22,57 @@ export const slackTriggerHandler = {
|
|
|
16
22
|
triggerType: 'slack',
|
|
17
23
|
async startListening(onEvent) {
|
|
18
24
|
const autoReact = reactOnTriggerEnabled();
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
25
|
+
// Subscribe to every instance the manifest knows about. Unknown instances
|
|
26
|
+
// (created later via the UI) are auto-created with `getInstance()` when
|
|
27
|
+
// reconnect runs, but we re-subscribe on each integration shutdown/init
|
|
28
|
+
// cycle so adding a brand-new instance via /instances triggers re-init.
|
|
29
|
+
const ids = listInstanceMetas().map((m) => m.id);
|
|
30
|
+
const idSet = new Set(ids);
|
|
31
|
+
const allInstances = listInstances().filter((i) => idSet.has(i.id));
|
|
32
|
+
for (const inst of allInstances) {
|
|
33
|
+
const off = inst.onMessage((message) => {
|
|
34
|
+
if (autoReact) {
|
|
35
|
+
// Use the instance-specific reaction so it posts as the right account.
|
|
36
|
+
inst.addReaction({ channel: message.channel, ts: message.ts, name: 'eyes' })
|
|
37
|
+
.catch(() => { });
|
|
38
|
+
}
|
|
39
|
+
const eventData = { ...message, instanceId: inst.id };
|
|
40
|
+
onEvent({
|
|
41
|
+
source: 'slack',
|
|
42
|
+
type: 'message',
|
|
43
|
+
data: eventData,
|
|
44
|
+
timestamp: Date.now(),
|
|
45
|
+
});
|
|
31
46
|
});
|
|
32
|
-
|
|
47
|
+
unsubscribers.push(off);
|
|
48
|
+
}
|
|
33
49
|
},
|
|
34
50
|
async stopListening() {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
51
|
+
for (const off of unsubscribers) {
|
|
52
|
+
try {
|
|
53
|
+
off();
|
|
54
|
+
}
|
|
55
|
+
catch { /* ignore */ }
|
|
38
56
|
}
|
|
57
|
+
unsubscribers.length = 0;
|
|
39
58
|
},
|
|
40
59
|
structuralMatch(trigger, event) {
|
|
41
60
|
const msg = event.data;
|
|
42
61
|
const config = trigger.config;
|
|
62
|
+
if (config.instanceId && msg.instanceId !== config.instanceId)
|
|
63
|
+
return false;
|
|
43
64
|
if (config.channelId && msg.channel !== config.channelId)
|
|
44
65
|
return false;
|
|
66
|
+
if (config.dmOnly && !msg.channel.startsWith('D'))
|
|
67
|
+
return false;
|
|
68
|
+
if (config.excludeDms && msg.channel.startsWith('D'))
|
|
69
|
+
return false;
|
|
70
|
+
if (msg.isOwnMessage && !config.includeOwnMessages)
|
|
71
|
+
return false;
|
|
45
72
|
if (config.userFilter?.length && !config.userFilter.includes(msg.userId))
|
|
46
73
|
return false;
|
|
74
|
+
if (config.excludeUserIds?.length && config.excludeUserIds.includes(msg.userId))
|
|
75
|
+
return false;
|
|
47
76
|
if (config.messagePattern) {
|
|
48
77
|
try {
|
|
49
78
|
if (!new RegExp(config.messagePattern).test(msg.text))
|
|
@@ -59,7 +88,7 @@ export const slackTriggerHandler = {
|
|
|
59
88
|
},
|
|
60
89
|
extractVariables(trigger, event) {
|
|
61
90
|
const msg = event.data;
|
|
62
|
-
void trigger;
|
|
91
|
+
void trigger;
|
|
63
92
|
const files = msg.files ?? [];
|
|
64
93
|
return {
|
|
65
94
|
'slack.user': msg.userName,
|
|
@@ -70,6 +99,7 @@ export const slackTriggerHandler = {
|
|
|
70
99
|
'slack.fileCount': String(files.length),
|
|
71
100
|
'slack.fileIds': files.map((f) => f.id).join(','),
|
|
72
101
|
'slack.fileNames': files.map((f) => f.name ?? '').filter(Boolean).join(','),
|
|
102
|
+
'slack.instanceId': msg.instanceId,
|
|
73
103
|
};
|
|
74
104
|
},
|
|
75
105
|
formatEventForLLM(event) {
|
|
@@ -78,6 +108,9 @@ export const slackTriggerHandler = {
|
|
|
78
108
|
const filesLine = files.length
|
|
79
109
|
? `\nAttachments (${files.length}): ${files.map((f) => `${f.name ?? f.id} [${f.mimetype ?? 'unknown'}]`).join(', ')}`
|
|
80
110
|
: '';
|
|
81
|
-
|
|
111
|
+
const instanceLine = msg.instanceId !== 'default' ? ` [Slack instance: ${msg.instanceId}]` : '';
|
|
112
|
+
return `Slack message from @${msg.userName} (${msg.userId}) in #${msg.channel}${instanceLine}:\n"${msg.text}"${filesLine}`;
|
|
82
113
|
},
|
|
83
114
|
};
|
|
115
|
+
// `slackClient` import kept (re-exports SlackMessage type used above).
|
|
116
|
+
void slackClient;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SlackWatermarkStore
|
|
3
|
+
*
|
|
4
|
+
* Per-channel high-water mark of the latest Slack `ts` we've already
|
|
5
|
+
* dispatched, persisted to ~/.local/share/tide-commander/slack-watermarks.json.
|
|
6
|
+
*
|
|
7
|
+
* Used by the polling-mode inbound client (xoxp- user tokens) so that:
|
|
8
|
+
* - Each `conversations.history` call uses `oldest=lastTs` and only sees
|
|
9
|
+
* messages newer than what we've already processed.
|
|
10
|
+
* - Across server restarts we resume where we left off (no replay storm,
|
|
11
|
+
* no missed messages within the channel-history retention window).
|
|
12
|
+
*
|
|
13
|
+
* Writes are atomic: write to `*.tmp` then `rename` (POSIX atomic on the
|
|
14
|
+
* same filesystem). A corrupt or missing file is treated as "no watermarks"
|
|
15
|
+
* and the polling client applies its first-run backfill cap instead.
|
|
16
|
+
*/
|
|
17
|
+
import { promises as fs } from 'node:fs';
|
|
18
|
+
import * as path from 'node:path';
|
|
19
|
+
const FILE_VERSION = 1;
|
|
20
|
+
/**
|
|
21
|
+
* Async, file-backed store. Holds the full state in memory after the first
|
|
22
|
+
* `load()` and writes the whole file atomically on each `set()`. The state
|
|
23
|
+
* is small (one entry per channel), so full-file rewrites are simpler and
|
|
24
|
+
* safer than incremental updates.
|
|
25
|
+
*/
|
|
26
|
+
export class SlackWatermarkStore {
|
|
27
|
+
filePath;
|
|
28
|
+
now;
|
|
29
|
+
state = { version: FILE_VERSION, channels: {} };
|
|
30
|
+
loaded = false;
|
|
31
|
+
writeChain = Promise.resolve();
|
|
32
|
+
constructor(opts) {
|
|
33
|
+
this.filePath = opts.filePath;
|
|
34
|
+
this.now = opts.now ?? Date.now;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Load state from disk. Missing or unparseable files are treated as
|
|
38
|
+
* "no state" — we start fresh and the polling client falls back to the
|
|
39
|
+
* first-run backfill cap.
|
|
40
|
+
*/
|
|
41
|
+
async load() {
|
|
42
|
+
if (this.loaded)
|
|
43
|
+
return;
|
|
44
|
+
try {
|
|
45
|
+
const raw = await fs.readFile(this.filePath, 'utf-8');
|
|
46
|
+
const parsed = JSON.parse(raw);
|
|
47
|
+
if (parsed && parsed.version === FILE_VERSION && parsed.channels && typeof parsed.channels === 'object') {
|
|
48
|
+
this.state = {
|
|
49
|
+
version: FILE_VERSION,
|
|
50
|
+
channels: { ...parsed.channels },
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// Missing or corrupt → keep the empty default.
|
|
56
|
+
}
|
|
57
|
+
this.loaded = true;
|
|
58
|
+
}
|
|
59
|
+
/** Return the watermark for a channel, or undefined if we've never seen it. */
|
|
60
|
+
get(channelId) {
|
|
61
|
+
return this.state.channels[channelId];
|
|
62
|
+
}
|
|
63
|
+
/** True iff we've persisted a watermark for this channel before. */
|
|
64
|
+
has(channelId) {
|
|
65
|
+
return Object.prototype.hasOwnProperty.call(this.state.channels, channelId);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Set a channel's watermark to `ts` only if it's strictly newer than what
|
|
69
|
+
* we already have. Slack ts is a numeric string ("seconds.microseconds"),
|
|
70
|
+
* lexicographic compare on equal-length strings is correct, but a numeric
|
|
71
|
+
* compare via parseFloat is safer across length differences.
|
|
72
|
+
*
|
|
73
|
+
* Returns true if we updated state and queued a disk write, false if the
|
|
74
|
+
* incoming ts was older or equal.
|
|
75
|
+
*/
|
|
76
|
+
async set(channelId, ts) {
|
|
77
|
+
if (!ts)
|
|
78
|
+
return false;
|
|
79
|
+
const current = this.state.channels[channelId];
|
|
80
|
+
if (current && parseFloat(ts) <= parseFloat(current.lastTs)) {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
this.state.channels[channelId] = {
|
|
84
|
+
lastTs: ts,
|
|
85
|
+
lastSeenAt: this.now(),
|
|
86
|
+
};
|
|
87
|
+
await this.flush();
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
/** Forget a channel — used when the user leaves it or it's archived. */
|
|
91
|
+
async forget(channelId) {
|
|
92
|
+
if (!this.has(channelId))
|
|
93
|
+
return;
|
|
94
|
+
delete this.state.channels[channelId];
|
|
95
|
+
await this.flush();
|
|
96
|
+
}
|
|
97
|
+
/** All channel ids currently tracked. */
|
|
98
|
+
channels() {
|
|
99
|
+
return Object.keys(this.state.channels);
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Atomic write: serialize state, write to `<file>.tmp`, then rename. The
|
|
103
|
+
* rename is atomic on POSIX so a crash mid-write can never leave a
|
|
104
|
+
* truncated JSON file at the canonical path.
|
|
105
|
+
*
|
|
106
|
+
* Concurrent calls are serialized via a promise chain so two parallel
|
|
107
|
+
* `set()` calls can't interleave their writes.
|
|
108
|
+
*/
|
|
109
|
+
flush() {
|
|
110
|
+
const next = this.writeChain.then(() => this.writeFile());
|
|
111
|
+
// Don't propagate write errors into the chain itself — if one write fails,
|
|
112
|
+
// the next one should still be attempted.
|
|
113
|
+
this.writeChain = next.catch(() => undefined);
|
|
114
|
+
return next;
|
|
115
|
+
}
|
|
116
|
+
async writeFile() {
|
|
117
|
+
const dir = path.dirname(this.filePath);
|
|
118
|
+
await fs.mkdir(dir, { recursive: true });
|
|
119
|
+
const tmp = `${this.filePath}.tmp`;
|
|
120
|
+
const body = JSON.stringify(this.state, null, 2);
|
|
121
|
+
await fs.writeFile(tmp, body, 'utf-8');
|
|
122
|
+
await fs.rename(tmp, this.filePath);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { createWhatsAppRoutes } from './whatsapp-routes.js';
|
|
12
12
|
import { whatsappConfigSchema, getConfigValues, setConfigValues, loadConfig, WHATSAPP_API_KEY_SECRET, } from './whatsapp-config.js';
|
|
13
|
-
import { createWhatsAppTriggerHandler, } from './whatsapp-trigger-handler.js';
|
|
13
|
+
import { createWhatsAppTriggerHandler, whatsappTriggerHandler, } from './whatsapp-trigger-handler.js';
|
|
14
14
|
import { whatsappSkill } from './whatsapp-skill.js';
|
|
15
15
|
let integrationCtx = null;
|
|
16
16
|
let lastChecked = 0;
|
|
@@ -99,9 +99,10 @@ export const whatsappPlugin = {
|
|
|
99
99
|
return [whatsappSkill];
|
|
100
100
|
},
|
|
101
101
|
getTriggerHandler() {
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
|
|
102
|
+
// Real trigger-service handler. Subscribes to the in-process bridge's
|
|
103
|
+
// notifyTriggerSubscribers stream so events flow into trigger evaluation
|
|
104
|
+
// even across bridge restarts.
|
|
105
|
+
return whatsappTriggerHandler;
|
|
105
106
|
},
|
|
106
107
|
getStatus() {
|
|
107
108
|
const config = loadConfig();
|
|
@@ -39,6 +39,16 @@ export class WhatsAppClient {
|
|
|
39
39
|
const data = await this.request('GET', `/api/sessions/${encodeURIComponent(sessionId)}/contacts`);
|
|
40
40
|
return Array.isArray(data) ? data : [];
|
|
41
41
|
}
|
|
42
|
+
async syncChatMessages(sessionId, chatId, count = 50) {
|
|
43
|
+
return this.request('POST', `/api/sessions/${encodeURIComponent(sessionId)}/chats/${encodeURIComponent(chatId)}/sync-messages?count=${encodeURIComponent(String(count))}`, {});
|
|
44
|
+
}
|
|
45
|
+
async syncContacts(sessionId) {
|
|
46
|
+
return this.request('POST', `/api/sessions/${encodeURIComponent(sessionId)}/sync-contacts`, {});
|
|
47
|
+
}
|
|
48
|
+
async getChatMessages(sessionId, chatId, limit = 50) {
|
|
49
|
+
const data = await this.request('GET', `/api/sessions/${encodeURIComponent(sessionId)}/chats/${encodeURIComponent(chatId)}/messages?limit=${encodeURIComponent(String(limit))}`);
|
|
50
|
+
return Array.isArray(data) ? data : [];
|
|
51
|
+
}
|
|
42
52
|
async sendMediaUrl(sessionId, to, mediaUrl, caption, options) {
|
|
43
53
|
// Upstream body shape: { to, url, caption?, filename? } (type is auto-detected
|
|
44
54
|
// from the fetched URL's Content-Type header). `type` is forwarded for
|