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
@@ -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 slackClient.sendMessage({ channel, text, threadTs, agentId, workflowInstanceId });
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 slackClient.getChannelMessages({
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 slackClient.getThreadReplies({
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 (_req, res) => {
297
+ router.get('/channels', async (req, res) => {
97
298
  try {
98
- const channels = await slackClient.listChannels();
99
- res.json({ channels });
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 slackClient.joinChannel(channel);
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 slackClient.sendDm({ userId, text, agentId, workflowInstanceId });
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', (_req, res) => {
312
- const config = loadConfig();
313
- res.json(config);
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 (_req, res) => {
529
+ router.post('/connect', async (req, res) => {
317
530
  try {
318
- await slackClient.reconnect();
319
- res.json({ success: true, status: loadConfig() });
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 (_req, res) => {
543
+ router.post('/disconnect', async (req, res) => {
328
544
  try {
329
- await slackClient.disconnect();
330
- res.json({ success: true, status: loadConfig() });
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
- * Delegates event listening to slack-client's Socket Mode connection.
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
- let unsubscribe = null;
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
- unsubscribe = slackClient.onMessage((message) => {
20
- // Fire-and-forget :eyes: reaction to ack that the bot saw it. Failure MUST NOT block triggers.
21
- if (autoReact) {
22
- slackClient
23
- .addReaction({ channel: message.channel, ts: message.ts, name: 'eyes' })
24
- .catch(() => { });
25
- }
26
- onEvent({
27
- source: 'slack',
28
- type: 'message',
29
- data: message,
30
- timestamp: Date.now(),
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
- if (unsubscribe) {
36
- unsubscribe();
37
- unsubscribe = null;
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; // trigger config not needed for basic extraction
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
- return `Slack message from @${msg.userName} (${msg.userId}) in #${msg.channel}:\n"${msg.text}"${filesLine}`;
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
- // The TriggerHandler interface here is the trigger-service one.
103
- // Our incoming-message bridge is in-process and not surfaced through it.
104
- return null;
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