tide-commander 1.91.0 → 1.93.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 (56) hide show
  1. package/dist/assets/{BossLogsModal-XsTxfWM8.js → BossLogsModal-CgILsm9V.js} +1 -1
  2. package/dist/assets/{BossSpawnModal-DqQMPxHu.js → BossSpawnModal-BdGy2pC2.js} +1 -1
  3. package/dist/assets/{ControlsModal-5mzDDdS5.js → ControlsModal-hFIzwgt7.js} +1 -1
  4. package/dist/assets/{DockerLogsModal-2eHlxyKa.js → DockerLogsModal-CDX9xVzn.js} +1 -1
  5. package/dist/assets/{EmbeddedEditor-Bi9Ysd99.js → EmbeddedEditor-2DZTiy_9.js} +1 -1
  6. package/dist/assets/{GmailOAuthSetup-5u85N8Br.js → GmailOAuthSetup-Cq1DpIPF.js} +1 -1
  7. package/dist/assets/{GoogleOAuthSetup-OxT_QwZL.js → GoogleOAuthSetup-B1UOVYlZ.js} +1 -1
  8. package/dist/assets/{IframeModal-Bn1kdP1S.js → IframeModal-GZkqd6O6.js} +1 -1
  9. package/dist/assets/{IntegrationsPanel-BehHkKJu.js → IntegrationsPanel-CBN_0nAs.js} +2 -2
  10. package/dist/assets/{LogViewerModal-JuUpWFPL.js → LogViewerModal-BrZrAYvt.js} +1 -1
  11. package/dist/assets/{MonitoringModal-CLk3uqDa.js → MonitoringModal-bIVunlhs.js} +1 -1
  12. package/dist/assets/{PM2LogsModal-C_NpOsos.js → PM2LogsModal-Crs7Q2LK.js} +1 -1
  13. package/dist/assets/{RestoreArchivedAreaModal-Cbcg2Fm8.js → RestoreArchivedAreaModal-9thnHbZ3.js} +1 -1
  14. package/dist/assets/{Scene2DCanvas-4C-jHERv.js → Scene2DCanvas-Bj7JLtW8.js} +1 -1
  15. package/dist/assets/{SceneManager-BoRV8xt3.js → SceneManager-C-ItjZnd.js} +1 -1
  16. package/dist/assets/{SkillsPanel-Bwk3UEY_.js → SkillsPanel-pTORcpQL.js} +1 -1
  17. package/dist/assets/{SlackMultiInstanceSetup-t-g3hdbr.js → SlackMultiInstanceSetup-FXuMr1vo.js} +1 -1
  18. package/dist/assets/{SpawnModal-BOXkPtaJ.js → SpawnModal-D3Pgos07.js} +1 -1
  19. package/dist/assets/{SubordinateAssignmentModal-CLHq5a9b.js → SubordinateAssignmentModal-6au3Tv5w.js} +1 -1
  20. package/dist/assets/TriggerManagerPanel-DletkqtL.js +9 -0
  21. package/dist/assets/{WorkflowEditorPanel-Bevs1fpc.js → WorkflowEditorPanel-DMu9KdjB.js} +1 -1
  22. package/dist/assets/{index-CJuTMFz9.js → index-BGatvpcF.js} +1 -1
  23. package/dist/assets/{index-CdKOXIM2.js → index-BPox4QjF.js} +1 -1
  24. package/dist/assets/{index-H8kj1tuO.js → index-CQYizqu9.js} +1 -1
  25. package/dist/assets/{index-CiXA-Zp-.js → index-DAsi0YrR.js} +1 -1
  26. package/dist/assets/{index-Dd063aRs.js → index-DIsb3aYA.js} +1 -1
  27. package/dist/assets/{index-vFrHpR5s.js → index-Hl0I9IIt.js} +1 -1
  28. package/dist/assets/{index-DxHwQ6CI.js → index-ZZtcJoNU.js} +33 -33
  29. package/dist/assets/{index-DBt10C9K.js → index-_OjecyuG.js} +1 -1
  30. package/dist/assets/{index-B4JdUiAe.js → index-mEu7CM6i.js} +2 -2
  31. package/dist/assets/{main-5eyR3isL.js → main-BUO6--48.js} +99 -98
  32. package/dist/assets/main-BfT_95fk.css +1 -0
  33. package/dist/assets/{web-DMjkVCWy.js → web-BbNfUMzK.js} +1 -1
  34. package/dist/assets/{web-Cx_ySRHK.js → web-CQsQBSkQ.js} +1 -1
  35. package/dist/assets/{web-DGO1VHbi.js → web-bKiHKTT6.js} +1 -1
  36. package/dist/index.html +2 -2
  37. package/dist/locales/en/terminal.json +2 -1
  38. package/dist/src/packages/server/index.js +4 -0
  39. package/dist/src/packages/server/integrations/gmail/gmail-client.js +82 -1
  40. package/dist/src/packages/server/integrations/gmail/gmail-trigger-handler.js +61 -1
  41. package/dist/src/packages/server/integrations/slack/slack-config.js +13 -0
  42. package/dist/src/packages/server/integrations/slack/slack-instance.js +90 -0
  43. package/dist/src/packages/server/integrations/slack/slack-polling-client.js +296 -42
  44. package/dist/src/packages/server/integrations/slack/slack-skill.js +16 -0
  45. package/dist/src/packages/server/integrations/slack/slack-trigger-handler.js +19 -0
  46. package/dist/src/packages/server/integrations/whatsapp/whatsapp-config.js +46 -0
  47. package/dist/src/packages/server/integrations/whatsapp/whatsapp-skill.js +12 -0
  48. package/dist/src/packages/server/integrations/whatsapp/whatsapp-trigger-handler.js +284 -17
  49. package/dist/src/packages/server/routes/files.js +56 -8
  50. package/dist/src/packages/server/services/attachment-downloader.js +317 -0
  51. package/dist/src/packages/server/services/attachment-janitor.js +110 -0
  52. package/dist/src/packages/server/services/audio-transcription.js +165 -0
  53. package/dist/src/packages/server/services/trigger-service.js +8 -5
  54. package/package.json +1 -1
  55. package/dist/assets/TriggerManagerPanel-DuWagsLi.js +0 -3
  56. package/dist/assets/main-CrGeO0Sc.css +0 -1
@@ -15,6 +15,8 @@ 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';
19
+ import { isWhisperAvailable, transcribeAudio } from '../../services/audio-transcription.js';
18
20
  import * as crypto from 'crypto';
19
21
  const log = createLogger('WhatsAppTrigger');
20
22
  // Module-level subscriber set fed by the in-process bridge whenever a normalized
@@ -136,7 +138,12 @@ export function createWhatsAppTriggerHandler(ctx) {
136
138
  if (msg.type === 'event' && (msg.event === 'message' || msg.event === 'message_create')) {
137
139
  const messageId = extractMessageId(msg.data);
138
140
  if (dedupe.isDuplicate(msg.sessionId, messageId)) {
139
- // Silent drop — duplicates are expected (upstream dual-fire).
141
+ // Duplicates are expected (upstream dual-fires `message_create`
142
+ // immediately followed by `message` for inbound DMs). Log at debug
143
+ // with the messageId hash so we can confirm in production when
144
+ // diagnosing rate-limit spikes — otherwise we silently drop and the
145
+ // operator has no visibility into how many duplicates are happening.
146
+ log.debug(`dedup.drop session=${msg.sessionId} event=${msg.event} messageId=${messageId ?? 'unknown'}`);
140
147
  return;
141
148
  }
142
149
  const config = loadConfig();
@@ -164,7 +171,26 @@ export function createWhatsAppTriggerHandler(ctx) {
164
171
  payload.isGroup &&
165
172
  !payload.groupName &&
166
173
  !!payload.chatId;
167
- if (!needsContactEnrich && !needsGroupEnrich) {
174
+ // Media → download bytes via the upstream proxy (NOT the raw Baileys
175
+ // `mediaUrl`, which points at an encrypted Meta CDN). The upstream
176
+ // exposes `/api/sessions/<sid>/messages/<mid>/media?download=true`.
177
+ //
178
+ // We download for BOTH directions:
179
+ // - inbound (`fromMe=false`): a contact sent media into our number.
180
+ // - outbound (`fromMe=true`): the WhatsApp account itself originated
181
+ // the media — this is the real-world case where David sends an
182
+ // attachment from his own WA so Bolba can process it. The fact that
183
+ // Baileys reports `fromMe=true` on messages we ourselves typed in
184
+ // the WA app is exactly why filtering on direction was wrong.
185
+ //
186
+ // The only case where this is wasteful is Tide Commander itself sending
187
+ // media via `/api/whatsapp/send-media-url` (upstream echoes back the
188
+ // `message_create` with `fromMe=true` + mediaType). That's a localhost
189
+ // self-fetch that costs almost nothing, so we don't bother
190
+ // distinguishing it from the user-typing case.
191
+ const needsMediaDownload = !!payload.mediaType &&
192
+ !!payload.messageId;
193
+ if (!needsContactEnrich && !needsGroupEnrich && !needsMediaDownload) {
168
194
  ctx.broadcast({ type: 'whatsapp_message', payload });
169
195
  notifyTriggerSubscribers(payload);
170
196
  return;
@@ -189,7 +215,83 @@ export function createWhatsAppTriggerHandler(ctx) {
189
215
  ctx.log.warn(`WhatsApp group lookup failed: ${err}`);
190
216
  })
191
217
  : Promise.resolve();
192
- void Promise.all([contactPromise, groupPromise]).then(() => {
218
+ const mediaPromise = needsMediaDownload
219
+ ? (async () => {
220
+ const apiKey = ctx.secrets.get(WHATSAPP_API_KEY_SECRET);
221
+ if (!apiKey) {
222
+ payload.skippedAttachment = {
223
+ reason: 'fetch-failed',
224
+ filename: payload.mediaFilename,
225
+ size: payload.mediaSize,
226
+ detail: 'whatsapp.apiKey secret missing',
227
+ };
228
+ return;
229
+ }
230
+ const base = config.baseUrl.replace(/\/+$/, '');
231
+ const downloadUrl = `${base}/api/sessions/${encodeURIComponent(payload.sessionId)}/messages/${encodeURIComponent(payload.messageId)}/media?download=true`;
232
+ if (typeof payload.mediaSize === 'number' && payload.mediaSize > MAX_ATTACHMENT_BYTES) {
233
+ payload.skippedAttachment = {
234
+ reason: 'too-large',
235
+ filename: payload.mediaFilename,
236
+ size: payload.mediaSize,
237
+ sourceUrl: downloadUrl,
238
+ };
239
+ return;
240
+ }
241
+ try {
242
+ const result = await downloadAttachment({
243
+ source: 'whatsapp',
244
+ messageId: payload.messageId,
245
+ url: downloadUrl,
246
+ headers: { 'X-API-Key': apiKey },
247
+ suggestedFilename: payload.mediaFilename,
248
+ mimetype: payload.mediaMimetype,
249
+ sizeHintBytes: payload.mediaSize,
250
+ });
251
+ if (result) {
252
+ payload.mediaPath = result.path;
253
+ payload.attachment = result;
254
+ }
255
+ else {
256
+ payload.skippedAttachment = {
257
+ reason: 'fetch-failed',
258
+ filename: payload.mediaFilename,
259
+ size: payload.mediaSize,
260
+ sourceUrl: downloadUrl,
261
+ };
262
+ }
263
+ }
264
+ catch (err) {
265
+ ctx.log.warn(`WhatsApp media download threw: ${err}`);
266
+ payload.skippedAttachment = {
267
+ reason: 'fetch-failed',
268
+ filename: payload.mediaFilename,
269
+ size: payload.mediaSize,
270
+ sourceUrl: downloadUrl,
271
+ detail: String(err),
272
+ };
273
+ }
274
+ // Audio → whisper transcription. Runs inline so the trigger
275
+ // template renders with the transcription present (otherwise
276
+ // bolba/agents would fire with the audio file but no text). We
277
+ // gate on `mediaType==='audio'` (covers audioMessage / pttMessage
278
+ // / voiceNoteMessage via classifyFlatType) AND on whisper being
279
+ // installed — config.transcribeAudio defaults true but the probe
280
+ // is the real cutoff so machines without whisper just no-op.
281
+ if (payload.mediaType === 'audio' &&
282
+ payload.attachment &&
283
+ config.transcribeAudio !== false &&
284
+ (await isWhisperAvailable())) {
285
+ const text = await transcribeAudio(payload.attachment.path, {
286
+ model: config.whisperModel || 'medium',
287
+ language: config.whisperLanguage?.trim() || undefined,
288
+ });
289
+ if (text)
290
+ payload.audioTranscription = text;
291
+ }
292
+ })()
293
+ : Promise.resolve();
294
+ void Promise.all([contactPromise, groupPromise, mediaPromise]).then(() => {
193
295
  ctx.broadcast({ type: 'whatsapp_message', payload });
194
296
  notifyTriggerSubscribers(payload);
195
297
  });
@@ -272,9 +374,12 @@ function normalizeBaileysMessage(sessionId, eventName, data, ts, baseUrl) {
272
374
  ? (pickString(d.groupName) ?? pickString(d.subject) ?? pickString(d.chatName))
273
375
  : undefined;
274
376
  // Body / media extraction
275
- const { body, mediaType, mediaUrl: rawMediaUrl } = extractContent(d, message);
377
+ const { body, mediaType, mediaUrl: rawMediaUrl, mediaMimetype, mediaSize, mediaFilename } = extractContent(d, message);
276
378
  // Resolve relative mediaUrl against baseUrl
277
379
  const mediaUrl = rawMediaUrl ? resolveMediaUrl(rawMediaUrl, baseUrl) : undefined;
380
+ // Stable Baileys message id — needed to ask the upstream for the decrypted bytes.
381
+ const messageId = pickString(d.id) ??
382
+ (key ? pickString(key.id) : undefined);
278
383
  // Timestamp resolution. Baileys messageTimestamp is in seconds; envelope `ts`
279
384
  // might already be ms. Heuristic: anything < 10^12 is treated as seconds.
280
385
  const rawTs = pickNumber(d.timestamp) ??
@@ -294,6 +399,10 @@ function normalizeBaileysMessage(sessionId, eventName, data, ts, baseUrl) {
294
399
  mediaUrl,
295
400
  direction,
296
401
  chatId: remoteJid ?? from,
402
+ messageId,
403
+ mediaMimetype,
404
+ mediaSize,
405
+ mediaFilename,
297
406
  };
298
407
  }
299
408
  function extractContent(envelope, message) {
@@ -302,16 +411,61 @@ function extractContent(envelope, message) {
302
411
  const directMediaType = normalizeMediaType(pickString(envelope.mediaType) ?? pickString(envelope.type));
303
412
  const directMediaUrl = pickString(envelope.mediaUrl) ??
304
413
  pickString(envelope.media?.url);
305
- if (directBody !== undefined || directMediaType || directMediaUrl) {
414
+ // 1a. Flat-event detection for the Baileys upstream we ship today. Triggers
415
+ // when EITHER:
416
+ // (a) `data.hasMedia === true` (the upstream's explicit "has media" flag),
417
+ // OR
418
+ // (b) `data.type` is in a tight allowlist of known media-bearing Baileys
419
+ // proto names.
420
+ //
421
+ // We need both because the upstream isn't fully consistent: some media
422
+ // types arrive with `hasMedia: true` (images observed in the wild), others
423
+ // arrive with only the `type` field set and no `hasMedia` at all (documents
424
+ // in the regression David flagged on 2026-05-13).
425
+ //
426
+ // The allowlist is precise — it EXCLUDES text/non-media types
427
+ // (`extendedTextMessage`, `conversation`, `protocolMessage`,
428
+ // `senderKeyDistributionMessage`, `reactionMessage`, …) that bit us with
429
+ // the earlier `/Message$/` suffix matcher: those produced bogus
430
+ // `[attachment-skipped: fetch-failed]` markers for plain text messages.
431
+ const flatHasMedia = pickBoolean(envelope.hasMedia) === true;
432
+ const flatType = pickString(envelope.type);
433
+ const flatTypeIsKnownMedia = !!flatType && KNOWN_MEDIA_TYPES.has(flatType);
434
+ if ((flatHasMedia || flatTypeIsKnownMedia) && !directMediaType && !directMediaUrl) {
435
+ return {
436
+ body: directBody,
437
+ mediaType: classifyFlatType(flatType),
438
+ mediaUrl: undefined,
439
+ mediaMimetype: undefined,
440
+ mediaFilename: undefined,
441
+ mediaSize: undefined,
442
+ };
443
+ }
444
+ // Short-circuit ONLY when the upstream pre-normalized actual media metadata
445
+ // at the top level. Earlier this branch also fired on `directBody` alone,
446
+ // which meant a Baileys envelope carrying `body='caption text'` + a real
447
+ // `message.imageMessage` proto was returned as text-only — the media
448
+ // extraction below was skipped and downstream `payload.mediaType` came back
449
+ // undefined. Fall through when only the body is set so the Baileys proto
450
+ // gets a chance to provide the media info.
451
+ if (directMediaType || directMediaUrl) {
452
+ const mediaObj = envelope.media;
306
453
  return {
307
454
  body: directBody,
308
455
  mediaType: directMediaType,
309
456
  mediaUrl: directMediaUrl,
457
+ mediaMimetype: pickString(envelope.mediaMimetype) ?? pickString(envelope.mimetype) ?? pickString(mediaObj?.mimetype),
458
+ mediaSize: pickNumber(envelope.mediaSize) ?? pickNumber(envelope.fileLength) ?? pickNumber(mediaObj?.size),
459
+ mediaFilename: pickString(envelope.mediaFilename) ?? pickString(envelope.fileName) ?? pickString(mediaObj?.fileName),
310
460
  };
311
461
  }
312
462
  // 2. Baileys `message` proto shape.
313
- if (!message)
314
- return {};
463
+ if (!message) {
464
+ // No proto to inspect but we may still have a plain text body at the top
465
+ // level. Return it so the trigger gets *something*; mediaType remains
466
+ // unset which is correct.
467
+ return directBody !== undefined ? { body: directBody } : {};
468
+ }
315
469
  const conversation = pickString(message.conversation);
316
470
  if (conversation)
317
471
  return { body: conversation };
@@ -324,34 +478,53 @@ function extractContent(envelope, message) {
324
478
  const image = message.imageMessage;
325
479
  if (image) {
326
480
  return {
327
- body: pickString(image.caption) ?? '',
481
+ body: pickString(image.caption) ?? directBody ?? '',
328
482
  mediaType: 'image',
329
483
  mediaUrl: pickString(image.url),
484
+ mediaMimetype: pickString(image.mimetype),
485
+ mediaSize: pickNumber(image.fileLength),
330
486
  };
331
487
  }
332
488
  const video = message.videoMessage;
333
489
  if (video) {
334
490
  return {
335
- body: pickString(video.caption) ?? '',
491
+ body: pickString(video.caption) ?? directBody ?? '',
336
492
  mediaType: 'video',
337
493
  mediaUrl: pickString(video.url),
494
+ mediaMimetype: pickString(video.mimetype),
495
+ mediaSize: pickNumber(video.fileLength),
338
496
  };
339
497
  }
340
498
  const audio = message.audioMessage;
341
499
  if (audio) {
342
- return { body: '', mediaType: 'audio', mediaUrl: pickString(audio.url) };
500
+ return {
501
+ body: directBody ?? '',
502
+ mediaType: 'audio',
503
+ mediaUrl: pickString(audio.url),
504
+ mediaMimetype: pickString(audio.mimetype),
505
+ mediaSize: pickNumber(audio.fileLength),
506
+ };
343
507
  }
344
508
  const doc = message.documentMessage;
345
509
  if (doc) {
346
510
  return {
347
- body: pickString(doc.fileName) ?? pickString(doc.caption) ?? '',
511
+ body: pickString(doc.fileName) ?? pickString(doc.caption) ?? directBody ?? '',
348
512
  mediaType: 'document',
349
513
  mediaUrl: pickString(doc.url),
514
+ mediaMimetype: pickString(doc.mimetype),
515
+ mediaSize: pickNumber(doc.fileLength),
516
+ mediaFilename: pickString(doc.fileName) ?? pickString(doc.title),
350
517
  };
351
518
  }
352
519
  const sticker = message.stickerMessage;
353
520
  if (sticker) {
354
- return { body: '', mediaType: 'sticker', mediaUrl: pickString(sticker.url) };
521
+ return {
522
+ body: directBody ?? '',
523
+ mediaType: 'sticker',
524
+ mediaUrl: pickString(sticker.url),
525
+ mediaMimetype: pickString(sticker.mimetype),
526
+ mediaSize: pickNumber(sticker.fileLength),
527
+ };
355
528
  }
356
529
  // Some Baileys variants nest the actual message under ephemeralMessage / viewOnceMessage.
357
530
  const ephemeral = message.ephemeralMessage?.message;
@@ -360,7 +533,9 @@ function extractContent(envelope, message) {
360
533
  const viewOnce = message.viewOnceMessage?.message;
361
534
  if (viewOnce)
362
535
  return extractContent(envelope, viewOnce);
363
- return {};
536
+ // Unknown proto shape (reaction, protocol, etc.) — preserve the top-level
537
+ // body so the trigger still receives the text.
538
+ return directBody !== undefined ? { body: directBody } : {};
364
539
  }
365
540
  function resolveMediaUrl(url, baseUrl) {
366
541
  if (/^[a-z][a-z0-9+\-.]*:\/\//i.test(url))
@@ -382,6 +557,53 @@ function normalizeMediaType(value) {
382
557
  return v;
383
558
  return undefined;
384
559
  }
560
+ /**
561
+ * Tight allowlist of Baileys proto names that genuinely carry media bytes.
562
+ * Excludes text/control events (`extendedTextMessage`, `conversation`,
563
+ * `protocolMessage`, `senderKeyDistributionMessage`, `reactionMessage`, …)
564
+ * which were the false positives that broke text messages when we used a
565
+ * loose `/Message$/` suffix check. If the upstream emits a new media kind
566
+ * not in this list, add it here.
567
+ */
568
+ const KNOWN_MEDIA_TYPES = new Set([
569
+ 'imageMessage',
570
+ 'videoMessage',
571
+ 'audioMessage',
572
+ 'documentMessage',
573
+ 'documentWithCaptionMessage',
574
+ 'stickerMessage',
575
+ 'pttMessage',
576
+ 'videoNoteMessage',
577
+ 'voiceNoteMessage',
578
+ ]);
579
+ /**
580
+ * Map an upstream `data.type` (Baileys-style, suffixed `Message`) to one of
581
+ * the supported MediaType buckets. Known variants:
582
+ * - imageMessage → image
583
+ * - videoMessage, videoNoteMessage → video
584
+ * - audioMessage, pttMessage → audio
585
+ * - documentMessage, documentWithCaptionMessage → document
586
+ * - stickerMessage → sticker
587
+ * - any other `*Message` or unknown signal → document (catch-all "it's a file")
588
+ * Returns `'document'` as a safe default rather than `undefined` so the
589
+ * download predicate (`!!payload.mediaType && !!payload.messageId`) stays
590
+ * true and the proxy is given a chance to deliver the bytes.
591
+ */
592
+ function classifyFlatType(type) {
593
+ const t = (type ?? '').toLowerCase();
594
+ if (t.startsWith('image'))
595
+ return 'image';
596
+ if (t.startsWith('video'))
597
+ return 'video';
598
+ if (t.startsWith('audio') || t.startsWith('ptt') || t.startsWith('voice'))
599
+ return 'audio';
600
+ if (t.startsWith('sticker'))
601
+ return 'sticker';
602
+ // documentMessage, documentWithCaptionMessage, and ANY other `*Message`
603
+ // variant we don't explicitly know about. Better to attempt the download
604
+ // and let the proxy decide than to silently skip an unknown media kind.
605
+ return 'document';
606
+ }
385
607
  function pickString(value) {
386
608
  return typeof value === 'string' && value.length > 0 ? value : undefined;
387
609
  }
@@ -573,6 +795,25 @@ export const whatsappTriggerHandler = {
573
795
  const groupName = msg.isGroup
574
796
  ? (msg.groupName?.trim() || humanizeGroupJid(msg.chatId))
575
797
  : '';
798
+ // Build the attachments block (single line — WA messages carry at most one
799
+ // media payload). Use the downloaded local path when present; otherwise
800
+ // emit a `[attachment-skipped: …]` marker if we tried and failed.
801
+ // When an audio transcription is available we append it on its own line
802
+ // so existing templates referencing `{{whatsapp.attachmentsBlock}}` see
803
+ // both the file chip and the spoken text without any template edits.
804
+ let attachmentsBlock = '';
805
+ if (msg.attachment) {
806
+ attachmentsBlock = formatAttachmentLine(msg.attachment);
807
+ }
808
+ else if (msg.skippedAttachment) {
809
+ const sk = msg.skippedAttachment;
810
+ const sizeMb = typeof sk.size === 'number' ? `${Math.round(sk.size / (1024 * 1024))}MB` : 'unknown';
811
+ attachmentsBlock = `[attachment-skipped: ${sk.reason} name=${sk.filename ?? 'unknown'} size=${sizeMb}${sk.detail ? ` detail=${sk.detail}` : ''}]`;
812
+ }
813
+ if (msg.audioTranscription) {
814
+ const tline = `[audio transcription: ${msg.audioTranscription}]`;
815
+ attachmentsBlock = attachmentsBlock ? `${attachmentsBlock}\n${tline}` : tline;
816
+ }
576
817
  return {
577
818
  'whatsapp.from': msg.from,
578
819
  'whatsapp.fromName': fromName,
@@ -584,6 +825,17 @@ export const whatsappTriggerHandler = {
584
825
  'whatsapp.direction': msg.direction,
585
826
  'whatsapp.mediaType': msg.mediaType ?? '',
586
827
  'whatsapp.mediaUrl': msg.mediaUrl ?? '',
828
+ // New: local path + metadata for inbound media, plus a ready-to-paste
829
+ // attachments block. Additive — old templates ignore these.
830
+ 'whatsapp.attachmentPath': msg.attachment?.path ?? '',
831
+ 'whatsapp.attachmentMime': msg.attachment?.mimetype ?? msg.mediaMimetype ?? '',
832
+ 'whatsapp.attachmentName': msg.attachment?.filename ?? msg.mediaFilename ?? '',
833
+ 'whatsapp.attachmentSize': msg.attachment ? String(msg.attachment.size) : (msg.mediaSize !== undefined ? String(msg.mediaSize) : ''),
834
+ 'whatsapp.attachmentsBlock': attachmentsBlock,
835
+ // Whisper transcription for audio messages. Empty string when
836
+ // transcription was disabled, whisper is missing, or the audio failed
837
+ // to transcribe — never undefined so template substitution is clean.
838
+ 'whatsapp.audioTranscription': msg.audioTranscription ?? '',
587
839
  'whatsapp.timestamp': new Date(msg.timestamp).toISOString(),
588
840
  };
589
841
  },
@@ -592,9 +844,24 @@ export const whatsappTriggerHandler = {
592
844
  const sender = msg.fromName ? `${msg.fromName} (${msg.from})` : msg.from;
593
845
  const channel = msg.isGroup ? `group "${msg.groupName ?? msg.from}"` : 'DM';
594
846
  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}`;
847
+ // If we downloaded the media, surface the local path as the agent-facing
848
+ // attachment marker (it supersedes the raw mediaUrl). If we tried but
849
+ // failed, surface that explicitly so the agent doesn't silently miss it.
850
+ let mediaSuffix = '';
851
+ if (msg.attachment) {
852
+ mediaSuffix = `\n\n${formatAttachmentLine(msg.attachment)}`;
853
+ }
854
+ else if (msg.skippedAttachment) {
855
+ const sk = msg.skippedAttachment;
856
+ const sizeMb = typeof sk.size === 'number' ? `${Math.round(sk.size / (1024 * 1024))}MB` : 'unknown';
857
+ mediaSuffix = `\n\n[attachment-skipped: ${sk.reason} name=${sk.filename ?? 'unknown'} size=${sizeMb}${sk.detail ? ` detail=${sk.detail}` : ''}]`;
858
+ }
859
+ else if (msg.mediaType) {
860
+ mediaSuffix = `\n[${msg.mediaType} attachment${msg.mediaUrl ? ` ${msg.mediaUrl}` : ''}]`;
861
+ }
862
+ if (msg.audioTranscription) {
863
+ mediaSuffix += `\n[audio transcription: ${msg.audioTranscription}]`;
864
+ }
865
+ return `WhatsApp message ${verb} in ${channel}\nFrom: ${sender}\nSession: ${msg.sessionId}\nTime: ${new Date(msg.timestamp).toISOString()}\n\n${msg.body}${mediaSuffix}`;
599
866
  },
600
867
  };
@@ -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
  }
@@ -784,6 +786,47 @@ router.post('/rename', (req, res) => {
784
786
  res.status(500).json({ error: err.message });
785
787
  }
786
788
  });
789
+ // POST /api/files/create - Create a new file or folder inside a target directory
790
+ router.post('/create', (req, res) => {
791
+ try {
792
+ const { parentDir, name, isDirectory } = req.body;
793
+ const validatedParent = parentDir && ensureAbsoluteExistingPath(parentDir);
794
+ if (!validatedParent) {
795
+ res.status(400).json({ error: 'Invalid or missing parent directory' });
796
+ return;
797
+ }
798
+ const parentStats = fs.statSync(validatedParent);
799
+ if (!parentStats.isDirectory()) {
800
+ res.status(400).json({ error: 'Parent path is not a directory' });
801
+ return;
802
+ }
803
+ const nextName = (name || '').trim();
804
+ if (!nextName || nextName === '.' || nextName === '..') {
805
+ res.status(400).json({ error: 'Invalid name' });
806
+ return;
807
+ }
808
+ if (nextName.includes('/') || nextName.includes('\\')) {
809
+ res.status(400).json({ error: 'Name must not contain path separators' });
810
+ return;
811
+ }
812
+ const destinationPath = path.join(validatedParent, nextName);
813
+ if (fs.existsSync(destinationPath)) {
814
+ res.status(409).json({ error: 'Target already exists' });
815
+ return;
816
+ }
817
+ if (isDirectory) {
818
+ fs.mkdirSync(destinationPath);
819
+ }
820
+ else {
821
+ fs.writeFileSync(destinationPath, '', { flag: 'wx' });
822
+ }
823
+ res.json({ success: true, path: destinationPath, isDirectory: !!isDirectory });
824
+ }
825
+ catch (err) {
826
+ log.error(' Failed to create path:', err);
827
+ res.status(500).json({ error: err.message });
828
+ }
829
+ });
787
830
  // POST /api/files/copy - Copy file/folder into target directory
788
831
  router.post('/copy', (req, res) => {
789
832
  try {
@@ -2859,10 +2902,10 @@ router.post('/git-fetch', async (req, res) => {
2859
2902
  res.status(500).json({ error: err.message });
2860
2903
  }
2861
2904
  });
2862
- // POST /api/files/delete - Delete a file from disk
2905
+ // POST /api/files/delete - Delete a file or (with recursive: true) a directory
2863
2906
  router.post('/delete', (req, res) => {
2864
2907
  try {
2865
- const filePath = req.body.path;
2908
+ const { path: filePath, recursive } = req.body;
2866
2909
  if (!filePath || typeof filePath !== 'string') {
2867
2910
  res.status(400).json({ error: 'Missing path parameter' });
2868
2911
  return;
@@ -2877,14 +2920,19 @@ router.post('/delete', (req, res) => {
2877
2920
  }
2878
2921
  const stat = fs.statSync(filePath);
2879
2922
  if (stat.isDirectory()) {
2880
- res.status(400).json({ error: 'Cannot delete directories' });
2881
- return;
2923
+ if (!recursive) {
2924
+ res.status(400).json({ error: 'Pass recursive: true to delete directories' });
2925
+ return;
2926
+ }
2927
+ fs.rmSync(filePath, { recursive: true, force: true });
2928
+ }
2929
+ else {
2930
+ fs.unlinkSync(filePath);
2882
2931
  }
2883
- fs.unlinkSync(filePath);
2884
2932
  res.json({ success: true });
2885
2933
  }
2886
2934
  catch (err) {
2887
- log.error(' Failed to delete file:', err);
2935
+ log.error(' Failed to delete path:', err);
2888
2936
  res.status(500).json({ error: err.message });
2889
2937
  }
2890
2938
  });