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.
- package/dist/assets/{BossLogsModal-XsTxfWM8.js → BossLogsModal-CgILsm9V.js} +1 -1
- package/dist/assets/{BossSpawnModal-DqQMPxHu.js → BossSpawnModal-BdGy2pC2.js} +1 -1
- package/dist/assets/{ControlsModal-5mzDDdS5.js → ControlsModal-hFIzwgt7.js} +1 -1
- package/dist/assets/{DockerLogsModal-2eHlxyKa.js → DockerLogsModal-CDX9xVzn.js} +1 -1
- package/dist/assets/{EmbeddedEditor-Bi9Ysd99.js → EmbeddedEditor-2DZTiy_9.js} +1 -1
- package/dist/assets/{GmailOAuthSetup-5u85N8Br.js → GmailOAuthSetup-Cq1DpIPF.js} +1 -1
- package/dist/assets/{GoogleOAuthSetup-OxT_QwZL.js → GoogleOAuthSetup-B1UOVYlZ.js} +1 -1
- package/dist/assets/{IframeModal-Bn1kdP1S.js → IframeModal-GZkqd6O6.js} +1 -1
- package/dist/assets/{IntegrationsPanel-BehHkKJu.js → IntegrationsPanel-CBN_0nAs.js} +2 -2
- package/dist/assets/{LogViewerModal-JuUpWFPL.js → LogViewerModal-BrZrAYvt.js} +1 -1
- package/dist/assets/{MonitoringModal-CLk3uqDa.js → MonitoringModal-bIVunlhs.js} +1 -1
- package/dist/assets/{PM2LogsModal-C_NpOsos.js → PM2LogsModal-Crs7Q2LK.js} +1 -1
- package/dist/assets/{RestoreArchivedAreaModal-Cbcg2Fm8.js → RestoreArchivedAreaModal-9thnHbZ3.js} +1 -1
- package/dist/assets/{Scene2DCanvas-4C-jHERv.js → Scene2DCanvas-Bj7JLtW8.js} +1 -1
- package/dist/assets/{SceneManager-BoRV8xt3.js → SceneManager-C-ItjZnd.js} +1 -1
- package/dist/assets/{SkillsPanel-Bwk3UEY_.js → SkillsPanel-pTORcpQL.js} +1 -1
- package/dist/assets/{SlackMultiInstanceSetup-t-g3hdbr.js → SlackMultiInstanceSetup-FXuMr1vo.js} +1 -1
- package/dist/assets/{SpawnModal-BOXkPtaJ.js → SpawnModal-D3Pgos07.js} +1 -1
- package/dist/assets/{SubordinateAssignmentModal-CLHq5a9b.js → SubordinateAssignmentModal-6au3Tv5w.js} +1 -1
- package/dist/assets/TriggerManagerPanel-DletkqtL.js +9 -0
- package/dist/assets/{WorkflowEditorPanel-Bevs1fpc.js → WorkflowEditorPanel-DMu9KdjB.js} +1 -1
- package/dist/assets/{index-CJuTMFz9.js → index-BGatvpcF.js} +1 -1
- package/dist/assets/{index-CdKOXIM2.js → index-BPox4QjF.js} +1 -1
- package/dist/assets/{index-H8kj1tuO.js → index-CQYizqu9.js} +1 -1
- package/dist/assets/{index-CiXA-Zp-.js → index-DAsi0YrR.js} +1 -1
- package/dist/assets/{index-Dd063aRs.js → index-DIsb3aYA.js} +1 -1
- package/dist/assets/{index-vFrHpR5s.js → index-Hl0I9IIt.js} +1 -1
- package/dist/assets/{index-DxHwQ6CI.js → index-ZZtcJoNU.js} +33 -33
- package/dist/assets/{index-DBt10C9K.js → index-_OjecyuG.js} +1 -1
- package/dist/assets/{index-B4JdUiAe.js → index-mEu7CM6i.js} +2 -2
- package/dist/assets/{main-5eyR3isL.js → main-BUO6--48.js} +99 -98
- package/dist/assets/main-BfT_95fk.css +1 -0
- package/dist/assets/{web-DMjkVCWy.js → web-BbNfUMzK.js} +1 -1
- package/dist/assets/{web-Cx_ySRHK.js → web-CQsQBSkQ.js} +1 -1
- package/dist/assets/{web-DGO1VHbi.js → web-bKiHKTT6.js} +1 -1
- package/dist/index.html +2 -2
- package/dist/locales/en/terminal.json +2 -1
- package/dist/src/packages/server/index.js +4 -0
- package/dist/src/packages/server/integrations/gmail/gmail-client.js +82 -1
- package/dist/src/packages/server/integrations/gmail/gmail-trigger-handler.js +61 -1
- package/dist/src/packages/server/integrations/slack/slack-config.js +13 -0
- package/dist/src/packages/server/integrations/slack/slack-instance.js +90 -0
- package/dist/src/packages/server/integrations/slack/slack-polling-client.js +296 -42
- package/dist/src/packages/server/integrations/slack/slack-skill.js +16 -0
- package/dist/src/packages/server/integrations/slack/slack-trigger-handler.js +19 -0
- package/dist/src/packages/server/integrations/whatsapp/whatsapp-config.js +46 -0
- package/dist/src/packages/server/integrations/whatsapp/whatsapp-skill.js +12 -0
- package/dist/src/packages/server/integrations/whatsapp/whatsapp-trigger-handler.js +284 -17
- package/dist/src/packages/server/routes/files.js +56 -8
- package/dist/src/packages/server/services/attachment-downloader.js +317 -0
- package/dist/src/packages/server/services/attachment-janitor.js +110 -0
- package/dist/src/packages/server/services/audio-transcription.js +165 -0
- package/dist/src/packages/server/services/trigger-service.js +8 -5
- package/package.json +1 -1
- package/dist/assets/TriggerManagerPanel-DuWagsLi.js +0 -3
- 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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
2881
|
-
|
|
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
|
|
2935
|
+
log.error(' Failed to delete path:', err);
|
|
2888
2936
|
res.status(500).json({ error: err.message });
|
|
2889
2937
|
}
|
|
2890
2938
|
});
|