tide-commander 1.92.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-BTTK3SDM.js → BossLogsModal-CgILsm9V.js} +1 -1
- package/dist/assets/{BossSpawnModal-BNbFVewB.js → BossSpawnModal-BdGy2pC2.js} +1 -1
- package/dist/assets/{ControlsModal-DnKdyYiv.js → ControlsModal-hFIzwgt7.js} +1 -1
- package/dist/assets/{DockerLogsModal-BRFhRQka.js → DockerLogsModal-CDX9xVzn.js} +1 -1
- package/dist/assets/{EmbeddedEditor-BFnSppyy.js → EmbeddedEditor-2DZTiy_9.js} +1 -1
- package/dist/assets/{GmailOAuthSetup-9uMrVRsz.js → GmailOAuthSetup-Cq1DpIPF.js} +1 -1
- package/dist/assets/{GoogleOAuthSetup-CLAy54pj.js → GoogleOAuthSetup-B1UOVYlZ.js} +1 -1
- package/dist/assets/{IframeModal-Cy5KHwqs.js → IframeModal-GZkqd6O6.js} +1 -1
- package/dist/assets/{IntegrationsPanel-C_a9pmU1.js → IntegrationsPanel-CBN_0nAs.js} +2 -2
- package/dist/assets/{LogViewerModal-D68ppO94.js → LogViewerModal-BrZrAYvt.js} +1 -1
- package/dist/assets/{MonitoringModal-CoZ0uDPq.js → MonitoringModal-bIVunlhs.js} +1 -1
- package/dist/assets/{PM2LogsModal-Bbwdouj-.js → PM2LogsModal-Crs7Q2LK.js} +1 -1
- package/dist/assets/{RestoreArchivedAreaModal-Dqi_ofXl.js → RestoreArchivedAreaModal-9thnHbZ3.js} +1 -1
- package/dist/assets/{Scene2DCanvas-CnqwS4QZ.js → Scene2DCanvas-Bj7JLtW8.js} +1 -1
- package/dist/assets/{SceneManager-W7pJrfEW.js → SceneManager-C-ItjZnd.js} +1 -1
- package/dist/assets/{SkillsPanel-Cw-tVu6j.js → SkillsPanel-pTORcpQL.js} +1 -1
- package/dist/assets/{SlackMultiInstanceSetup-DddkR8Zr.js → SlackMultiInstanceSetup-FXuMr1vo.js} +1 -1
- package/dist/assets/{SpawnModal-DZODgZFD.js → SpawnModal-D3Pgos07.js} +1 -1
- package/dist/assets/{SubordinateAssignmentModal-BkhktU8i.js → SubordinateAssignmentModal-6au3Tv5w.js} +1 -1
- package/dist/assets/{TriggerManagerPanel-YcbVJ2Rc.js → TriggerManagerPanel-DletkqtL.js} +1 -1
- package/dist/assets/{WorkflowEditorPanel-ZadWONKw.js → WorkflowEditorPanel-DMu9KdjB.js} +1 -1
- package/dist/assets/{index-Ckn27_lZ.js → index-BGatvpcF.js} +1 -1
- package/dist/assets/{index-CN-XkbYd.js → index-BPox4QjF.js} +1 -1
- package/dist/assets/{index-CyPV_nq1.js → index-CQYizqu9.js} +1 -1
- package/dist/assets/{index-Bi8u2UlO.js → index-DAsi0YrR.js} +1 -1
- package/dist/assets/{index-DRsSsabz.js → index-DIsb3aYA.js} +1 -1
- package/dist/assets/{index-pqOlU6XT.js → index-Hl0I9IIt.js} +1 -1
- package/dist/assets/{index-CF1hOKKa.js → index-ZZtcJoNU.js} +33 -33
- package/dist/assets/{index-CtEyTmv-.js → index-_OjecyuG.js} +1 -1
- package/dist/assets/{index-cK048gKM.js → index-mEu7CM6i.js} +2 -2
- package/dist/assets/{main-jN7LzP4f.js → main-BUO6--48.js} +82 -80
- package/dist/assets/main-BfT_95fk.css +1 -0
- package/dist/assets/{web-DZfyqisM.js → web-BbNfUMzK.js} +1 -1
- package/dist/assets/{web-CfwxlY8x.js → web-CQsQBSkQ.js} +1 -1
- package/dist/assets/{web-DbhP1mtF.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/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 +1 -0
- package/dist/src/packages/server/integrations/slack/slack-polling-client.js +146 -0
- package/dist/src/packages/server/integrations/whatsapp/whatsapp-config.js +46 -0
- package/dist/src/packages/server/integrations/whatsapp/whatsapp-trigger-handler.js +77 -20
- package/dist/src/packages/server/routes/files.js +52 -6
- 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/main-CrGeO0Sc.css +0 -1
|
@@ -16,6 +16,7 @@ import { GroupNameCache } from './group-name-cache.js';
|
|
|
16
16
|
import { WhatsAppClient } from './whatsapp-client.js';
|
|
17
17
|
import { createLogger } from '../../utils/logger.js';
|
|
18
18
|
import { downloadAttachment, formatAttachmentLine, MAX_ATTACHMENT_BYTES } from '../../services/attachment-downloader.js';
|
|
19
|
+
import { isWhisperAvailable, transcribeAudio } from '../../services/audio-transcription.js';
|
|
19
20
|
import * as crypto from 'crypto';
|
|
20
21
|
const log = createLogger('WhatsAppTrigger');
|
|
21
22
|
// Module-level subscriber set fed by the in-process bridge whenever a normalized
|
|
@@ -137,7 +138,12 @@ export function createWhatsAppTriggerHandler(ctx) {
|
|
|
137
138
|
if (msg.type === 'event' && (msg.event === 'message' || msg.event === 'message_create')) {
|
|
138
139
|
const messageId = extractMessageId(msg.data);
|
|
139
140
|
if (dedupe.isDuplicate(msg.sessionId, messageId)) {
|
|
140
|
-
//
|
|
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'}`);
|
|
141
147
|
return;
|
|
142
148
|
}
|
|
143
149
|
const config = loadConfig();
|
|
@@ -265,6 +271,24 @@ export function createWhatsAppTriggerHandler(ctx) {
|
|
|
265
271
|
detail: String(err),
|
|
266
272
|
};
|
|
267
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
|
+
}
|
|
268
292
|
})()
|
|
269
293
|
: Promise.resolve();
|
|
270
294
|
void Promise.all([contactPromise, groupPromise, mediaPromise]).then(() => {
|
|
@@ -387,27 +411,27 @@ function extractContent(envelope, message) {
|
|
|
387
411
|
const directMediaType = normalizeMediaType(pickString(envelope.mediaType) ?? pickString(envelope.type));
|
|
388
412
|
const directMediaUrl = pickString(envelope.mediaUrl) ??
|
|
389
413
|
pickString(envelope.media?.url);
|
|
390
|
-
// 1a.
|
|
391
|
-
//
|
|
392
|
-
//
|
|
393
|
-
//
|
|
394
|
-
//
|
|
395
|
-
//
|
|
396
|
-
// intentionally left undefined here — the downloader fills them from the
|
|
397
|
-
// proxy's response headers (Content-Type, Content-Disposition).
|
|
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.
|
|
398
420
|
//
|
|
399
|
-
//
|
|
400
|
-
//
|
|
401
|
-
//
|
|
402
|
-
//
|
|
403
|
-
//
|
|
404
|
-
//
|
|
405
|
-
//
|
|
406
|
-
//
|
|
407
|
-
|
|
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.
|
|
408
431
|
const flatHasMedia = pickBoolean(envelope.hasMedia) === true;
|
|
409
|
-
const
|
|
410
|
-
|
|
432
|
+
const flatType = pickString(envelope.type);
|
|
433
|
+
const flatTypeIsKnownMedia = !!flatType && KNOWN_MEDIA_TYPES.has(flatType);
|
|
434
|
+
if ((flatHasMedia || flatTypeIsKnownMedia) && !directMediaType && !directMediaUrl) {
|
|
411
435
|
return {
|
|
412
436
|
body: directBody,
|
|
413
437
|
mediaType: classifyFlatType(flatType),
|
|
@@ -533,6 +557,25 @@ function normalizeMediaType(value) {
|
|
|
533
557
|
return v;
|
|
534
558
|
return undefined;
|
|
535
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
|
+
]);
|
|
536
579
|
/**
|
|
537
580
|
* Map an upstream `data.type` (Baileys-style, suffixed `Message`) to one of
|
|
538
581
|
* the supported MediaType buckets. Known variants:
|
|
@@ -755,6 +798,9 @@ export const whatsappTriggerHandler = {
|
|
|
755
798
|
// Build the attachments block (single line — WA messages carry at most one
|
|
756
799
|
// media payload). Use the downloaded local path when present; otherwise
|
|
757
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.
|
|
758
804
|
let attachmentsBlock = '';
|
|
759
805
|
if (msg.attachment) {
|
|
760
806
|
attachmentsBlock = formatAttachmentLine(msg.attachment);
|
|
@@ -764,6 +810,10 @@ export const whatsappTriggerHandler = {
|
|
|
764
810
|
const sizeMb = typeof sk.size === 'number' ? `${Math.round(sk.size / (1024 * 1024))}MB` : 'unknown';
|
|
765
811
|
attachmentsBlock = `[attachment-skipped: ${sk.reason} name=${sk.filename ?? 'unknown'} size=${sizeMb}${sk.detail ? ` detail=${sk.detail}` : ''}]`;
|
|
766
812
|
}
|
|
813
|
+
if (msg.audioTranscription) {
|
|
814
|
+
const tline = `[audio transcription: ${msg.audioTranscription}]`;
|
|
815
|
+
attachmentsBlock = attachmentsBlock ? `${attachmentsBlock}\n${tline}` : tline;
|
|
816
|
+
}
|
|
767
817
|
return {
|
|
768
818
|
'whatsapp.from': msg.from,
|
|
769
819
|
'whatsapp.fromName': fromName,
|
|
@@ -782,6 +832,10 @@ export const whatsappTriggerHandler = {
|
|
|
782
832
|
'whatsapp.attachmentName': msg.attachment?.filename ?? msg.mediaFilename ?? '',
|
|
783
833
|
'whatsapp.attachmentSize': msg.attachment ? String(msg.attachment.size) : (msg.mediaSize !== undefined ? String(msg.mediaSize) : ''),
|
|
784
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 ?? '',
|
|
785
839
|
'whatsapp.timestamp': new Date(msg.timestamp).toISOString(),
|
|
786
840
|
};
|
|
787
841
|
},
|
|
@@ -805,6 +859,9 @@ export const whatsappTriggerHandler = {
|
|
|
805
859
|
else if (msg.mediaType) {
|
|
806
860
|
mediaSuffix = `\n[${msg.mediaType} attachment${msg.mediaUrl ? ` ${msg.mediaUrl}` : ''}]`;
|
|
807
861
|
}
|
|
862
|
+
if (msg.audioTranscription) {
|
|
863
|
+
mediaSuffix += `\n[audio transcription: ${msg.audioTranscription}]`;
|
|
864
|
+
}
|
|
808
865
|
return `WhatsApp message ${verb} in ${channel}\nFrom: ${sender}\nSession: ${msg.sessionId}\nTime: ${new Date(msg.timestamp).toISOString()}\n\n${msg.body}${mediaSuffix}`;
|
|
809
866
|
},
|
|
810
867
|
};
|
|
@@ -786,6 +786,47 @@ router.post('/rename', (req, res) => {
|
|
|
786
786
|
res.status(500).json({ error: err.message });
|
|
787
787
|
}
|
|
788
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
|
+
});
|
|
789
830
|
// POST /api/files/copy - Copy file/folder into target directory
|
|
790
831
|
router.post('/copy', (req, res) => {
|
|
791
832
|
try {
|
|
@@ -2861,10 +2902,10 @@ router.post('/git-fetch', async (req, res) => {
|
|
|
2861
2902
|
res.status(500).json({ error: err.message });
|
|
2862
2903
|
}
|
|
2863
2904
|
});
|
|
2864
|
-
// POST /api/files/delete - Delete a file
|
|
2905
|
+
// POST /api/files/delete - Delete a file or (with recursive: true) a directory
|
|
2865
2906
|
router.post('/delete', (req, res) => {
|
|
2866
2907
|
try {
|
|
2867
|
-
const filePath = req.body
|
|
2908
|
+
const { path: filePath, recursive } = req.body;
|
|
2868
2909
|
if (!filePath || typeof filePath !== 'string') {
|
|
2869
2910
|
res.status(400).json({ error: 'Missing path parameter' });
|
|
2870
2911
|
return;
|
|
@@ -2879,14 +2920,19 @@ router.post('/delete', (req, res) => {
|
|
|
2879
2920
|
}
|
|
2880
2921
|
const stat = fs.statSync(filePath);
|
|
2881
2922
|
if (stat.isDirectory()) {
|
|
2882
|
-
|
|
2883
|
-
|
|
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);
|
|
2884
2931
|
}
|
|
2885
|
-
fs.unlinkSync(filePath);
|
|
2886
2932
|
res.json({ success: true });
|
|
2887
2933
|
}
|
|
2888
2934
|
catch (err) {
|
|
2889
|
-
log.error(' Failed to delete
|
|
2935
|
+
log.error(' Failed to delete path:', err);
|
|
2890
2936
|
res.status(500).json({ error: err.message });
|
|
2891
2937
|
}
|
|
2892
2938
|
});
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audio Transcription Service
|
|
3
|
+
*
|
|
4
|
+
* Wraps the OpenAI `whisper` CLI when it's installed on the host, so inbound
|
|
5
|
+
* audio attachments from integrations (WhatsApp voice notes, Slack audio
|
|
6
|
+
* uploads, …) can be converted to text in addition to the file itself.
|
|
7
|
+
*
|
|
8
|
+
* Design constraints:
|
|
9
|
+
* - SAFE NO-OP when whisper is missing. The integration pipeline must keep
|
|
10
|
+
* working unchanged on machines without it — the only effect is that
|
|
11
|
+
* `audioTranscription` stays undefined.
|
|
12
|
+
* - NEVER THROWS. Errors are logged and surfaced as `null`.
|
|
13
|
+
* - Whisper detection is cached for the process lifetime; installing or
|
|
14
|
+
* removing whisper requires a server restart to re-detect (matches the
|
|
15
|
+
* behavior of every other CLI-probe we have).
|
|
16
|
+
* - We invoke whisper via `--output_format txt --output_dir <same dir as
|
|
17
|
+
* audio>` and read the resulting `<base>.txt` back. Stdout could be
|
|
18
|
+
* parsed instead but file-based is simpler and avoids buffering large
|
|
19
|
+
* progress logs in memory.
|
|
20
|
+
*/
|
|
21
|
+
import { spawn } from 'child_process';
|
|
22
|
+
import * as fs from 'fs/promises';
|
|
23
|
+
import * as path from 'path';
|
|
24
|
+
import { createLogger } from '../utils/logger.js';
|
|
25
|
+
const log = createLogger('AudioTranscription');
|
|
26
|
+
let whisperAvailable = null;
|
|
27
|
+
/**
|
|
28
|
+
* Probe for the `whisper` CLI. Cached after the first call.
|
|
29
|
+
*/
|
|
30
|
+
export async function isWhisperAvailable() {
|
|
31
|
+
if (whisperAvailable !== null)
|
|
32
|
+
return whisperAvailable;
|
|
33
|
+
whisperAvailable = await detectWhisper();
|
|
34
|
+
return whisperAvailable;
|
|
35
|
+
}
|
|
36
|
+
function detectWhisper() {
|
|
37
|
+
return new Promise((resolve) => {
|
|
38
|
+
let proc;
|
|
39
|
+
try {
|
|
40
|
+
proc = spawn('whisper', ['--help'], { stdio: 'ignore' });
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
log.debug('whisper not found on PATH; audio transcription disabled');
|
|
44
|
+
resolve(false);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
proc.on('error', () => {
|
|
48
|
+
log.debug('whisper not found on PATH; audio transcription disabled');
|
|
49
|
+
resolve(false);
|
|
50
|
+
});
|
|
51
|
+
proc.on('exit', (code) => {
|
|
52
|
+
const ok = code === 0;
|
|
53
|
+
if (ok) {
|
|
54
|
+
log.log('whisper detected on PATH; audio transcription enabled');
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
log.debug(`whisper --help exited ${code}; audio transcription disabled`);
|
|
58
|
+
}
|
|
59
|
+
resolve(ok);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Transcribe an audio file via whisper. Returns the transcribed text, or
|
|
65
|
+
* `null` on any failure (whisper missing, non-zero exit, timeout, missing
|
|
66
|
+
* file). Never throws.
|
|
67
|
+
*/
|
|
68
|
+
export async function transcribeAudio(audioPath, opts = {}) {
|
|
69
|
+
if (!(await isWhisperAvailable()))
|
|
70
|
+
return null;
|
|
71
|
+
try {
|
|
72
|
+
await fs.access(audioPath);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
log.warn(`transcribeAudio: file not found ${audioPath}`);
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
const outputDir = path.dirname(audioPath);
|
|
79
|
+
const baseName = path.basename(audioPath, path.extname(audioPath));
|
|
80
|
+
const txtPath = path.join(outputDir, `${baseName}.txt`);
|
|
81
|
+
// Idempotent cache hit: if a previous run already produced the txt file,
|
|
82
|
+
// skip the (slow) whisper invocation. Same dedup intent as the attachment
|
|
83
|
+
// downloader's existingSizeMatches.
|
|
84
|
+
try {
|
|
85
|
+
const stat = await fs.stat(txtPath);
|
|
86
|
+
if (stat.isFile() && stat.size > 0) {
|
|
87
|
+
const cachedText = (await fs.readFile(txtPath, 'utf-8')).trim();
|
|
88
|
+
if (cachedText) {
|
|
89
|
+
log.debug(`transcribeAudio: cache-hit ${txtPath}`);
|
|
90
|
+
return cachedText;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
catch { /* no cache, fall through */ }
|
|
95
|
+
const model = opts.model ?? 'medium';
|
|
96
|
+
const timeoutMs = opts.timeoutMs ?? 5 * 60_000;
|
|
97
|
+
const args = [
|
|
98
|
+
audioPath,
|
|
99
|
+
'--model', model,
|
|
100
|
+
'--output_format', 'txt',
|
|
101
|
+
'--output_dir', outputDir,
|
|
102
|
+
'--verbose', 'False',
|
|
103
|
+
// `--fp16 False` keeps the FP16-on-CPU UserWarning out of stderr and
|
|
104
|
+
// matches what whisper itself falls back to on CPU-only hosts anyway.
|
|
105
|
+
'--fp16', 'False',
|
|
106
|
+
];
|
|
107
|
+
if (opts.language) {
|
|
108
|
+
args.push('--language', opts.language);
|
|
109
|
+
}
|
|
110
|
+
const started = Date.now();
|
|
111
|
+
return new Promise((resolve) => {
|
|
112
|
+
let proc;
|
|
113
|
+
try {
|
|
114
|
+
proc = spawn('whisper', args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
log.warn(`transcribeAudio: spawn threw: ${err}`);
|
|
118
|
+
resolve(null);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
let stderr = '';
|
|
122
|
+
let killed = false;
|
|
123
|
+
const timer = setTimeout(() => {
|
|
124
|
+
killed = true;
|
|
125
|
+
try {
|
|
126
|
+
proc.kill('SIGTERM');
|
|
127
|
+
}
|
|
128
|
+
catch { /* ignore */ }
|
|
129
|
+
log.warn(`transcribeAudio: timeout after ${timeoutMs}ms for ${audioPath}`);
|
|
130
|
+
}, timeoutMs);
|
|
131
|
+
proc.stderr?.on('data', (chunk) => {
|
|
132
|
+
stderr += chunk.toString();
|
|
133
|
+
if (stderr.length > 8192)
|
|
134
|
+
stderr = stderr.slice(-4096);
|
|
135
|
+
});
|
|
136
|
+
proc.on('error', (err) => {
|
|
137
|
+
clearTimeout(timer);
|
|
138
|
+
log.warn(`transcribeAudio: spawn error: ${err}`);
|
|
139
|
+
resolve(null);
|
|
140
|
+
});
|
|
141
|
+
proc.on('exit', async (code) => {
|
|
142
|
+
clearTimeout(timer);
|
|
143
|
+
if (killed) {
|
|
144
|
+
resolve(null);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (code !== 0) {
|
|
148
|
+
log.warn(`transcribeAudio: whisper exit ${code} for ${audioPath}: ${stderr.slice(-500)}`);
|
|
149
|
+
resolve(null);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
try {
|
|
153
|
+
const raw = await fs.readFile(txtPath, 'utf-8');
|
|
154
|
+
const text = raw.trim();
|
|
155
|
+
const tookSec = ((Date.now() - started) / 1000).toFixed(1);
|
|
156
|
+
log.log(`transcribed ${audioPath} in ${tookSec}s (${text.length} chars, model=${model}${opts.language ? ` lang=${opts.language}` : ''})`);
|
|
157
|
+
resolve(text || null);
|
|
158
|
+
}
|
|
159
|
+
catch (err) {
|
|
160
|
+
log.warn(`transcribeAudio: failed to read ${txtPath}: ${err}`);
|
|
161
|
+
resolve(null);
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
}
|
|
@@ -491,9 +491,12 @@ export async function fireTrigger(id, variables, opts) {
|
|
|
491
491
|
log.error(`Cannot fire unknown trigger: ${id}`);
|
|
492
492
|
return;
|
|
493
493
|
}
|
|
494
|
-
// Rate limit check
|
|
495
|
-
|
|
496
|
-
|
|
494
|
+
// Rate limit check. Per-trigger `rateLimitPerMinute` overrides the global
|
|
495
|
+
// default (10/min). Set to 0 / negative to disable entirely for this
|
|
496
|
+
// trigger (used by high-volume local sources like personal WA/Slack).
|
|
497
|
+
const effectiveLimit = typeof trigger.rateLimitPerMinute === 'number' ? trigger.rateLimitPerMinute : RATE_LIMIT_MAX;
|
|
498
|
+
if (effectiveLimit > 0 && !checkRateLimit(id, effectiveLimit)) {
|
|
499
|
+
log.warn(`Trigger ${trigger.name} rate-limited (>${effectiveLimit}/min)`);
|
|
497
500
|
return;
|
|
498
501
|
}
|
|
499
502
|
const startTime = Date.now();
|
|
@@ -575,7 +578,7 @@ export async function fireTrigger(id, variables, opts) {
|
|
|
575
578
|
}
|
|
576
579
|
}
|
|
577
580
|
// ─── Rate Limiting ───
|
|
578
|
-
function checkRateLimit(triggerId) {
|
|
581
|
+
function checkRateLimit(triggerId, limit = RATE_LIMIT_MAX) {
|
|
579
582
|
const now = Date.now();
|
|
580
583
|
let timestamps = rateLimitMap.get(triggerId);
|
|
581
584
|
if (!timestamps) {
|
|
@@ -587,7 +590,7 @@ function checkRateLimit(triggerId) {
|
|
|
587
590
|
while (timestamps.length > 0 && timestamps[0] < cutoff) {
|
|
588
591
|
timestamps.shift();
|
|
589
592
|
}
|
|
590
|
-
if (timestamps.length >=
|
|
593
|
+
if (timestamps.length >= limit) {
|
|
591
594
|
return false;
|
|
592
595
|
}
|
|
593
596
|
timestamps.push(now);
|