tide-commander 1.91.0 → 1.92.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 (47) hide show
  1. package/dist/assets/{BossLogsModal-XsTxfWM8.js → BossLogsModal-BTTK3SDM.js} +1 -1
  2. package/dist/assets/{BossSpawnModal-DqQMPxHu.js → BossSpawnModal-BNbFVewB.js} +1 -1
  3. package/dist/assets/{ControlsModal-5mzDDdS5.js → ControlsModal-DnKdyYiv.js} +1 -1
  4. package/dist/assets/{DockerLogsModal-2eHlxyKa.js → DockerLogsModal-BRFhRQka.js} +1 -1
  5. package/dist/assets/{EmbeddedEditor-Bi9Ysd99.js → EmbeddedEditor-BFnSppyy.js} +1 -1
  6. package/dist/assets/{GmailOAuthSetup-5u85N8Br.js → GmailOAuthSetup-9uMrVRsz.js} +1 -1
  7. package/dist/assets/{GoogleOAuthSetup-OxT_QwZL.js → GoogleOAuthSetup-CLAy54pj.js} +1 -1
  8. package/dist/assets/{IframeModal-Bn1kdP1S.js → IframeModal-Cy5KHwqs.js} +1 -1
  9. package/dist/assets/{IntegrationsPanel-BehHkKJu.js → IntegrationsPanel-C_a9pmU1.js} +2 -2
  10. package/dist/assets/{LogViewerModal-JuUpWFPL.js → LogViewerModal-D68ppO94.js} +1 -1
  11. package/dist/assets/{MonitoringModal-CLk3uqDa.js → MonitoringModal-CoZ0uDPq.js} +1 -1
  12. package/dist/assets/{PM2LogsModal-C_NpOsos.js → PM2LogsModal-Bbwdouj-.js} +1 -1
  13. package/dist/assets/{RestoreArchivedAreaModal-Cbcg2Fm8.js → RestoreArchivedAreaModal-Dqi_ofXl.js} +1 -1
  14. package/dist/assets/{Scene2DCanvas-4C-jHERv.js → Scene2DCanvas-CnqwS4QZ.js} +1 -1
  15. package/dist/assets/{SceneManager-BoRV8xt3.js → SceneManager-W7pJrfEW.js} +1 -1
  16. package/dist/assets/{SkillsPanel-Bwk3UEY_.js → SkillsPanel-Cw-tVu6j.js} +1 -1
  17. package/dist/assets/{SlackMultiInstanceSetup-t-g3hdbr.js → SlackMultiInstanceSetup-DddkR8Zr.js} +1 -1
  18. package/dist/assets/{SpawnModal-BOXkPtaJ.js → SpawnModal-DZODgZFD.js} +1 -1
  19. package/dist/assets/{SubordinateAssignmentModal-CLHq5a9b.js → SubordinateAssignmentModal-BkhktU8i.js} +1 -1
  20. package/dist/assets/TriggerManagerPanel-YcbVJ2Rc.js +9 -0
  21. package/dist/assets/{WorkflowEditorPanel-Bevs1fpc.js → WorkflowEditorPanel-ZadWONKw.js} +1 -1
  22. package/dist/assets/{index-CiXA-Zp-.js → index-Bi8u2UlO.js} +1 -1
  23. package/dist/assets/{index-DxHwQ6CI.js → index-CF1hOKKa.js} +3 -3
  24. package/dist/assets/{index-CdKOXIM2.js → index-CN-XkbYd.js} +1 -1
  25. package/dist/assets/{index-CJuTMFz9.js → index-Ckn27_lZ.js} +1 -1
  26. package/dist/assets/{index-DBt10C9K.js → index-CtEyTmv-.js} +1 -1
  27. package/dist/assets/{index-H8kj1tuO.js → index-CyPV_nq1.js} +1 -1
  28. package/dist/assets/{index-Dd063aRs.js → index-DRsSsabz.js} +1 -1
  29. package/dist/assets/{index-B4JdUiAe.js → index-cK048gKM.js} +2 -2
  30. package/dist/assets/{index-vFrHpR5s.js → index-pqOlU6XT.js} +1 -1
  31. package/dist/assets/{main-5eyR3isL.js → main-jN7LzP4f.js} +95 -96
  32. package/dist/assets/{web-Cx_ySRHK.js → web-CfwxlY8x.js} +1 -1
  33. package/dist/assets/{web-DMjkVCWy.js → web-DZfyqisM.js} +1 -1
  34. package/dist/assets/{web-DGO1VHbi.js → web-DbhP1mtF.js} +1 -1
  35. package/dist/index.html +1 -1
  36. package/dist/src/packages/server/index.js +4 -0
  37. package/dist/src/packages/server/integrations/slack/slack-instance.js +89 -0
  38. package/dist/src/packages/server/integrations/slack/slack-polling-client.js +150 -42
  39. package/dist/src/packages/server/integrations/slack/slack-skill.js +16 -0
  40. package/dist/src/packages/server/integrations/slack/slack-trigger-handler.js +19 -0
  41. package/dist/src/packages/server/integrations/whatsapp/whatsapp-skill.js +12 -0
  42. package/dist/src/packages/server/integrations/whatsapp/whatsapp-trigger-handler.js +226 -16
  43. package/dist/src/packages/server/routes/files.js +4 -2
  44. package/dist/src/packages/server/services/attachment-downloader.js +317 -0
  45. package/dist/src/packages/server/services/attachment-janitor.js +110 -0
  46. package/package.json +1 -1
  47. package/dist/assets/TriggerManagerPanel-DuWagsLi.js +0 -3
@@ -15,6 +15,7 @@ import { ContactNameCache } from './contact-name-cache.js';
15
15
  import { GroupNameCache } from './group-name-cache.js';
16
16
  import { WhatsAppClient } from './whatsapp-client.js';
17
17
  import { createLogger } from '../../utils/logger.js';
18
+ import { downloadAttachment, formatAttachmentLine, MAX_ATTACHMENT_BYTES } from '../../services/attachment-downloader.js';
18
19
  import * as crypto from 'crypto';
19
20
  const log = createLogger('WhatsAppTrigger');
20
21
  // Module-level subscriber set fed by the in-process bridge whenever a normalized
@@ -164,7 +165,26 @@ export function createWhatsAppTriggerHandler(ctx) {
164
165
  payload.isGroup &&
165
166
  !payload.groupName &&
166
167
  !!payload.chatId;
167
- if (!needsContactEnrich && !needsGroupEnrich) {
168
+ // Media → download bytes via the upstream proxy (NOT the raw Baileys
169
+ // `mediaUrl`, which points at an encrypted Meta CDN). The upstream
170
+ // exposes `/api/sessions/<sid>/messages/<mid>/media?download=true`.
171
+ //
172
+ // We download for BOTH directions:
173
+ // - inbound (`fromMe=false`): a contact sent media into our number.
174
+ // - outbound (`fromMe=true`): the WhatsApp account itself originated
175
+ // the media — this is the real-world case where David sends an
176
+ // attachment from his own WA so Bolba can process it. The fact that
177
+ // Baileys reports `fromMe=true` on messages we ourselves typed in
178
+ // the WA app is exactly why filtering on direction was wrong.
179
+ //
180
+ // The only case where this is wasteful is Tide Commander itself sending
181
+ // media via `/api/whatsapp/send-media-url` (upstream echoes back the
182
+ // `message_create` with `fromMe=true` + mediaType). That's a localhost
183
+ // self-fetch that costs almost nothing, so we don't bother
184
+ // distinguishing it from the user-typing case.
185
+ const needsMediaDownload = !!payload.mediaType &&
186
+ !!payload.messageId;
187
+ if (!needsContactEnrich && !needsGroupEnrich && !needsMediaDownload) {
168
188
  ctx.broadcast({ type: 'whatsapp_message', payload });
169
189
  notifyTriggerSubscribers(payload);
170
190
  return;
@@ -189,7 +209,65 @@ export function createWhatsAppTriggerHandler(ctx) {
189
209
  ctx.log.warn(`WhatsApp group lookup failed: ${err}`);
190
210
  })
191
211
  : Promise.resolve();
192
- void Promise.all([contactPromise, groupPromise]).then(() => {
212
+ const mediaPromise = needsMediaDownload
213
+ ? (async () => {
214
+ const apiKey = ctx.secrets.get(WHATSAPP_API_KEY_SECRET);
215
+ if (!apiKey) {
216
+ payload.skippedAttachment = {
217
+ reason: 'fetch-failed',
218
+ filename: payload.mediaFilename,
219
+ size: payload.mediaSize,
220
+ detail: 'whatsapp.apiKey secret missing',
221
+ };
222
+ return;
223
+ }
224
+ const base = config.baseUrl.replace(/\/+$/, '');
225
+ const downloadUrl = `${base}/api/sessions/${encodeURIComponent(payload.sessionId)}/messages/${encodeURIComponent(payload.messageId)}/media?download=true`;
226
+ if (typeof payload.mediaSize === 'number' && payload.mediaSize > MAX_ATTACHMENT_BYTES) {
227
+ payload.skippedAttachment = {
228
+ reason: 'too-large',
229
+ filename: payload.mediaFilename,
230
+ size: payload.mediaSize,
231
+ sourceUrl: downloadUrl,
232
+ };
233
+ return;
234
+ }
235
+ try {
236
+ const result = await downloadAttachment({
237
+ source: 'whatsapp',
238
+ messageId: payload.messageId,
239
+ url: downloadUrl,
240
+ headers: { 'X-API-Key': apiKey },
241
+ suggestedFilename: payload.mediaFilename,
242
+ mimetype: payload.mediaMimetype,
243
+ sizeHintBytes: payload.mediaSize,
244
+ });
245
+ if (result) {
246
+ payload.mediaPath = result.path;
247
+ payload.attachment = result;
248
+ }
249
+ else {
250
+ payload.skippedAttachment = {
251
+ reason: 'fetch-failed',
252
+ filename: payload.mediaFilename,
253
+ size: payload.mediaSize,
254
+ sourceUrl: downloadUrl,
255
+ };
256
+ }
257
+ }
258
+ catch (err) {
259
+ ctx.log.warn(`WhatsApp media download threw: ${err}`);
260
+ payload.skippedAttachment = {
261
+ reason: 'fetch-failed',
262
+ filename: payload.mediaFilename,
263
+ size: payload.mediaSize,
264
+ sourceUrl: downloadUrl,
265
+ detail: String(err),
266
+ };
267
+ }
268
+ })()
269
+ : Promise.resolve();
270
+ void Promise.all([contactPromise, groupPromise, mediaPromise]).then(() => {
193
271
  ctx.broadcast({ type: 'whatsapp_message', payload });
194
272
  notifyTriggerSubscribers(payload);
195
273
  });
@@ -272,9 +350,12 @@ function normalizeBaileysMessage(sessionId, eventName, data, ts, baseUrl) {
272
350
  ? (pickString(d.groupName) ?? pickString(d.subject) ?? pickString(d.chatName))
273
351
  : undefined;
274
352
  // Body / media extraction
275
- const { body, mediaType, mediaUrl: rawMediaUrl } = extractContent(d, message);
353
+ const { body, mediaType, mediaUrl: rawMediaUrl, mediaMimetype, mediaSize, mediaFilename } = extractContent(d, message);
276
354
  // Resolve relative mediaUrl against baseUrl
277
355
  const mediaUrl = rawMediaUrl ? resolveMediaUrl(rawMediaUrl, baseUrl) : undefined;
356
+ // Stable Baileys message id — needed to ask the upstream for the decrypted bytes.
357
+ const messageId = pickString(d.id) ??
358
+ (key ? pickString(key.id) : undefined);
278
359
  // Timestamp resolution. Baileys messageTimestamp is in seconds; envelope `ts`
279
360
  // might already be ms. Heuristic: anything < 10^12 is treated as seconds.
280
361
  const rawTs = pickNumber(d.timestamp) ??
@@ -294,6 +375,10 @@ function normalizeBaileysMessage(sessionId, eventName, data, ts, baseUrl) {
294
375
  mediaUrl,
295
376
  direction,
296
377
  chatId: remoteJid ?? from,
378
+ messageId,
379
+ mediaMimetype,
380
+ mediaSize,
381
+ mediaFilename,
297
382
  };
298
383
  }
299
384
  function extractContent(envelope, message) {
@@ -302,16 +387,61 @@ function extractContent(envelope, message) {
302
387
  const directMediaType = normalizeMediaType(pickString(envelope.mediaType) ?? pickString(envelope.type));
303
388
  const directMediaUrl = pickString(envelope.mediaUrl) ??
304
389
  pickString(envelope.media?.url);
305
- if (directBody !== undefined || directMediaType || directMediaUrl) {
390
+ // 1a. The Baileys upstream we ship today emits a *flat* event shape where
391
+ // the media signal lives at `data.type` (e.g. "imageMessage") with no
392
+ // nested `message.imageMessage` proto, no `mediaUrl`, and `media: null`.
393
+ // Detect that case and surface mediaType so the downloader's
394
+ // `${baseUrl}/api/sessions/<sid>/messages/<mid>/media?download=true` URL
395
+ // gets constructed from sessionId+messageId. mimetype/filename/size are
396
+ // intentionally left undefined here — the downloader fills them from the
397
+ // proxy's response headers (Content-Type, Content-Disposition).
398
+ //
399
+ // Trigger conditions, in priority order:
400
+ // 1. `data.type` matches `*Message` (image/video/audio/document/sticker
401
+ // and all known variants like pttMessage, documentWithCaptionMessage,
402
+ // videoNoteMessage, etc.)
403
+ // 2. `data.hasMedia === true` regardless of how `data.type` is spelled.
404
+ // We default unknown variants to `document` (the catch-all "it's a file"
405
+ // category) so the download proceeds; the UI chip picks an icon from the
406
+ // mimetype the downloader fills in.
407
+ const flatType = pickString(envelope.type);
408
+ const flatHasMedia = pickBoolean(envelope.hasMedia) === true;
409
+ const flatTypeIsMessage = !!flatType && /Message$/i.test(flatType);
410
+ if (flatTypeIsMessage || (flatHasMedia && !directMediaType && !directMediaUrl)) {
411
+ return {
412
+ body: directBody,
413
+ mediaType: classifyFlatType(flatType),
414
+ mediaUrl: undefined,
415
+ mediaMimetype: undefined,
416
+ mediaFilename: undefined,
417
+ mediaSize: undefined,
418
+ };
419
+ }
420
+ // Short-circuit ONLY when the upstream pre-normalized actual media metadata
421
+ // at the top level. Earlier this branch also fired on `directBody` alone,
422
+ // which meant a Baileys envelope carrying `body='caption text'` + a real
423
+ // `message.imageMessage` proto was returned as text-only — the media
424
+ // extraction below was skipped and downstream `payload.mediaType` came back
425
+ // undefined. Fall through when only the body is set so the Baileys proto
426
+ // gets a chance to provide the media info.
427
+ if (directMediaType || directMediaUrl) {
428
+ const mediaObj = envelope.media;
306
429
  return {
307
430
  body: directBody,
308
431
  mediaType: directMediaType,
309
432
  mediaUrl: directMediaUrl,
433
+ mediaMimetype: pickString(envelope.mediaMimetype) ?? pickString(envelope.mimetype) ?? pickString(mediaObj?.mimetype),
434
+ mediaSize: pickNumber(envelope.mediaSize) ?? pickNumber(envelope.fileLength) ?? pickNumber(mediaObj?.size),
435
+ mediaFilename: pickString(envelope.mediaFilename) ?? pickString(envelope.fileName) ?? pickString(mediaObj?.fileName),
310
436
  };
311
437
  }
312
438
  // 2. Baileys `message` proto shape.
313
- if (!message)
314
- return {};
439
+ if (!message) {
440
+ // No proto to inspect but we may still have a plain text body at the top
441
+ // level. Return it so the trigger gets *something*; mediaType remains
442
+ // unset which is correct.
443
+ return directBody !== undefined ? { body: directBody } : {};
444
+ }
315
445
  const conversation = pickString(message.conversation);
316
446
  if (conversation)
317
447
  return { body: conversation };
@@ -324,34 +454,53 @@ function extractContent(envelope, message) {
324
454
  const image = message.imageMessage;
325
455
  if (image) {
326
456
  return {
327
- body: pickString(image.caption) ?? '',
457
+ body: pickString(image.caption) ?? directBody ?? '',
328
458
  mediaType: 'image',
329
459
  mediaUrl: pickString(image.url),
460
+ mediaMimetype: pickString(image.mimetype),
461
+ mediaSize: pickNumber(image.fileLength),
330
462
  };
331
463
  }
332
464
  const video = message.videoMessage;
333
465
  if (video) {
334
466
  return {
335
- body: pickString(video.caption) ?? '',
467
+ body: pickString(video.caption) ?? directBody ?? '',
336
468
  mediaType: 'video',
337
469
  mediaUrl: pickString(video.url),
470
+ mediaMimetype: pickString(video.mimetype),
471
+ mediaSize: pickNumber(video.fileLength),
338
472
  };
339
473
  }
340
474
  const audio = message.audioMessage;
341
475
  if (audio) {
342
- return { body: '', mediaType: 'audio', mediaUrl: pickString(audio.url) };
476
+ return {
477
+ body: directBody ?? '',
478
+ mediaType: 'audio',
479
+ mediaUrl: pickString(audio.url),
480
+ mediaMimetype: pickString(audio.mimetype),
481
+ mediaSize: pickNumber(audio.fileLength),
482
+ };
343
483
  }
344
484
  const doc = message.documentMessage;
345
485
  if (doc) {
346
486
  return {
347
- body: pickString(doc.fileName) ?? pickString(doc.caption) ?? '',
487
+ body: pickString(doc.fileName) ?? pickString(doc.caption) ?? directBody ?? '',
348
488
  mediaType: 'document',
349
489
  mediaUrl: pickString(doc.url),
490
+ mediaMimetype: pickString(doc.mimetype),
491
+ mediaSize: pickNumber(doc.fileLength),
492
+ mediaFilename: pickString(doc.fileName) ?? pickString(doc.title),
350
493
  };
351
494
  }
352
495
  const sticker = message.stickerMessage;
353
496
  if (sticker) {
354
- return { body: '', mediaType: 'sticker', mediaUrl: pickString(sticker.url) };
497
+ return {
498
+ body: directBody ?? '',
499
+ mediaType: 'sticker',
500
+ mediaUrl: pickString(sticker.url),
501
+ mediaMimetype: pickString(sticker.mimetype),
502
+ mediaSize: pickNumber(sticker.fileLength),
503
+ };
355
504
  }
356
505
  // Some Baileys variants nest the actual message under ephemeralMessage / viewOnceMessage.
357
506
  const ephemeral = message.ephemeralMessage?.message;
@@ -360,7 +509,9 @@ function extractContent(envelope, message) {
360
509
  const viewOnce = message.viewOnceMessage?.message;
361
510
  if (viewOnce)
362
511
  return extractContent(envelope, viewOnce);
363
- return {};
512
+ // Unknown proto shape (reaction, protocol, etc.) — preserve the top-level
513
+ // body so the trigger still receives the text.
514
+ return directBody !== undefined ? { body: directBody } : {};
364
515
  }
365
516
  function resolveMediaUrl(url, baseUrl) {
366
517
  if (/^[a-z][a-z0-9+\-.]*:\/\//i.test(url))
@@ -382,6 +533,34 @@ function normalizeMediaType(value) {
382
533
  return v;
383
534
  return undefined;
384
535
  }
536
+ /**
537
+ * Map an upstream `data.type` (Baileys-style, suffixed `Message`) to one of
538
+ * the supported MediaType buckets. Known variants:
539
+ * - imageMessage → image
540
+ * - videoMessage, videoNoteMessage → video
541
+ * - audioMessage, pttMessage → audio
542
+ * - documentMessage, documentWithCaptionMessage → document
543
+ * - stickerMessage → sticker
544
+ * - any other `*Message` or unknown signal → document (catch-all "it's a file")
545
+ * Returns `'document'` as a safe default rather than `undefined` so the
546
+ * download predicate (`!!payload.mediaType && !!payload.messageId`) stays
547
+ * true and the proxy is given a chance to deliver the bytes.
548
+ */
549
+ function classifyFlatType(type) {
550
+ const t = (type ?? '').toLowerCase();
551
+ if (t.startsWith('image'))
552
+ return 'image';
553
+ if (t.startsWith('video'))
554
+ return 'video';
555
+ if (t.startsWith('audio') || t.startsWith('ptt') || t.startsWith('voice'))
556
+ return 'audio';
557
+ if (t.startsWith('sticker'))
558
+ return 'sticker';
559
+ // documentMessage, documentWithCaptionMessage, and ANY other `*Message`
560
+ // variant we don't explicitly know about. Better to attempt the download
561
+ // and let the proxy decide than to silently skip an unknown media kind.
562
+ return 'document';
563
+ }
385
564
  function pickString(value) {
386
565
  return typeof value === 'string' && value.length > 0 ? value : undefined;
387
566
  }
@@ -573,6 +752,18 @@ export const whatsappTriggerHandler = {
573
752
  const groupName = msg.isGroup
574
753
  ? (msg.groupName?.trim() || humanizeGroupJid(msg.chatId))
575
754
  : '';
755
+ // Build the attachments block (single line — WA messages carry at most one
756
+ // media payload). Use the downloaded local path when present; otherwise
757
+ // emit a `[attachment-skipped: …]` marker if we tried and failed.
758
+ let attachmentsBlock = '';
759
+ if (msg.attachment) {
760
+ attachmentsBlock = formatAttachmentLine(msg.attachment);
761
+ }
762
+ else if (msg.skippedAttachment) {
763
+ const sk = msg.skippedAttachment;
764
+ const sizeMb = typeof sk.size === 'number' ? `${Math.round(sk.size / (1024 * 1024))}MB` : 'unknown';
765
+ attachmentsBlock = `[attachment-skipped: ${sk.reason} name=${sk.filename ?? 'unknown'} size=${sizeMb}${sk.detail ? ` detail=${sk.detail}` : ''}]`;
766
+ }
576
767
  return {
577
768
  'whatsapp.from': msg.from,
578
769
  'whatsapp.fromName': fromName,
@@ -584,6 +775,13 @@ export const whatsappTriggerHandler = {
584
775
  'whatsapp.direction': msg.direction,
585
776
  'whatsapp.mediaType': msg.mediaType ?? '',
586
777
  'whatsapp.mediaUrl': msg.mediaUrl ?? '',
778
+ // New: local path + metadata for inbound media, plus a ready-to-paste
779
+ // attachments block. Additive — old templates ignore these.
780
+ 'whatsapp.attachmentPath': msg.attachment?.path ?? '',
781
+ 'whatsapp.attachmentMime': msg.attachment?.mimetype ?? msg.mediaMimetype ?? '',
782
+ 'whatsapp.attachmentName': msg.attachment?.filename ?? msg.mediaFilename ?? '',
783
+ 'whatsapp.attachmentSize': msg.attachment ? String(msg.attachment.size) : (msg.mediaSize !== undefined ? String(msg.mediaSize) : ''),
784
+ 'whatsapp.attachmentsBlock': attachmentsBlock,
587
785
  'whatsapp.timestamp': new Date(msg.timestamp).toISOString(),
588
786
  };
589
787
  },
@@ -592,9 +790,21 @@ export const whatsappTriggerHandler = {
592
790
  const sender = msg.fromName ? `${msg.fromName} (${msg.from})` : msg.from;
593
791
  const channel = msg.isGroup ? `group "${msg.groupName ?? msg.from}"` : 'DM';
594
792
  const verb = msg.direction === 'outbound' ? 'sent' : 'received';
595
- const media = msg.mediaType
596
- ? `\n[${msg.mediaType} attachment${msg.mediaUrl ? ` ${msg.mediaUrl}` : ''}]`
597
- : '';
598
- return `WhatsApp message ${verb} in ${channel}\nFrom: ${sender}\nSession: ${msg.sessionId}\nTime: ${new Date(msg.timestamp).toISOString()}${media}\n\n${msg.body}`;
793
+ // If we downloaded the media, surface the local path as the agent-facing
794
+ // attachment marker (it supersedes the raw mediaUrl). If we tried but
795
+ // failed, surface that explicitly so the agent doesn't silently miss it.
796
+ let mediaSuffix = '';
797
+ if (msg.attachment) {
798
+ mediaSuffix = `\n\n${formatAttachmentLine(msg.attachment)}`;
799
+ }
800
+ else if (msg.skippedAttachment) {
801
+ const sk = msg.skippedAttachment;
802
+ const sizeMb = typeof sk.size === 'number' ? `${Math.round(sk.size / (1024 * 1024))}MB` : 'unknown';
803
+ mediaSuffix = `\n\n[attachment-skipped: ${sk.reason} name=${sk.filename ?? 'unknown'} size=${sizeMb}${sk.detail ? ` detail=${sk.detail}` : ''}]`;
804
+ }
805
+ else if (msg.mediaType) {
806
+ mediaSuffix = `\n[${msg.mediaType} attachment${msg.mediaUrl ? ` ${msg.mediaUrl}` : ''}]`;
807
+ }
808
+ return `WhatsApp message ${verb} in ${channel}\nFrom: ${sender}\nSession: ${msg.sessionId}\nTime: ${new Date(msg.timestamp).toISOString()}\n\n${msg.body}${mediaSuffix}`;
599
809
  },
600
810
  };
@@ -10,8 +10,10 @@ import * as os from 'os';
10
10
  import { logger } from '../utils/logger.js';
11
11
  import { loadAreas } from '../data/index.js';
12
12
  const log = logger.files;
13
- // Get or create temp directory for tide-commander uploads
14
- const TEMP_DIR = path.join(os.tmpdir(), 'tide-commander-uploads');
13
+ // Get or create temp directory for tide-commander uploads. Exported so other
14
+ // modules (attachment-downloader, attachment-janitor) put their files under the
15
+ // same root that Express already serves statically at `/uploads/`.
16
+ export const TEMP_DIR = path.join(os.tmpdir(), 'tide-commander-uploads');
15
17
  if (!fs.existsSync(TEMP_DIR)) {
16
18
  fs.mkdirSync(TEMP_DIR, { recursive: true });
17
19
  }