/** * Call recordings for a contact (lead / user / partner) — FreePBX CDR rows with * a `recordingfile`, matched by the contact's phone numbers, served by the small * LAN service on the PBX (`/opt/logship-ivr/recordings.js`, systemd * `logship-recordings`, runtimeConfig.pbxRecordings) and transcribed HERE with * the same whisper.cpp pipeline the ticket voice memos / staff chat use * (`server/utils/chatTranscribe.ts`). Transcripts are cached per recording in * `data/call-transcripts/.json` (git-ignored) so a recording is only ever * transcribed once. Whisper runs ONE recording at a time per process (serial * queue) so a batch never starves the app of CPU. * * Transcription is ASYNCHRONOUS towards HTTP: the edge nginx (ISPConfig vhost on * 192.168.12.51, `location /` without proxy_read_timeout → 60 s default) cuts any * request that stays open longer, which every call beyond a few minutes did — the * browser got a 504 while whisper kept running and cached the result. So * `startTranscription()` writes a pending marker (`.pending.json`, shared * between the PM2 instances via disk), runs the job in the background and the * route answers 202; the modal polls `transcriptionStatus()` until the cache file * exists or the marker carries an error. Keep every request well under 60 s. */ import { existsSync } from 'fs' import { mkdir, readFile, writeFile, unlink } from 'fs/promises' import { join } from 'path' import os from 'os' import { exec } from 'child_process' import { promisify } from 'util' import { getPool, extFromChannel } from './cdr' import { transcribeAudioDetailed, type TranscriptSegment } from '../chatTranscribe' const pexec = promisify(exec) export interface CallRecording { file: string at: string direction: 'in' | 'out' | 'internal' number: string // the contact's side extension: string // our side answered: boolean duration: number // talk seconds legs: number linkedid: string size?: number hasTranscript?: boolean } export interface CallTranscript { file: string language: string text: string segments: TranscriptSegment[] createdAt: string audioSeconds?: number } const NAME_RE = /^[A-Za-z0-9._#+@-]{8,220}\.(wav|WAV|mp3|gsm)$/ export const validRecordingName = (name: any): string | null => { const n = String(name || '').trim() return NAME_RE.test(n) && !n.includes('..') ? n : null } // ---- number normalisation ------------------------------------------------ export const digitsOf = (n: any) => String(n ?? '').replace(/\D+/g, '') /** National significant number: strips 00 / + / country code 49 / trunk 0. */ export const nsn = (raw: any): string => { let d = digitsOf(raw) if (d.startsWith('00')) d = d.slice(2) if (d.startsWith('49') && d.length >= 11) d = d.slice(2) else if (d.startsWith('0')) d = d.slice(1) return d } const sameNumber = (a: string, b: string): boolean => { const x = nsn(a), y = nsn(b) if (!x || !y) return false const len = Math.min(x.length, y.length) if (len < 7) return x === y return x.slice(-len) === y.slice(-len) } // ---- CDR lookup ---------------------------------------------------------- export const findRecordings = async (numbers: string[], limit = 300): Promise<{ available: boolean; rows: CallRecording[] }> => { const nums = [...new Set(numbers.map((n) => nsn(n)).filter((n) => n.length >= 6))] if (!nums.length) return { available: true, rows: [] } const pool = getPool() if (!pool) return { available: false, rows: [] } const where = nums.map(() => '(src LIKE ? OR dst LIKE ?)').join(' OR ') const params: string[] = [] for (const n of nums) { const tail = '%' + n.slice(-7); params.push(tail, tail) } const [res] = await pool.query( `SELECT calldate, clid, src, dst, channel, dstchannel, disposition, billsec, duration, recordingfile, linkedid FROM cdr WHERE recordingfile <> '' AND (${where}) ORDER BY calldate DESC LIMIT 4000`, params ) const rows: any[] = Array.isArray(res) ? (res as any[]) : [] const byFile = new Map() for (const r of rows) { const file = String(r.recordingfile || '') if (!validRecordingName(file)) continue const srcMatch = nums.some((n) => sameNumber(r.src, n)) const dstMatch = nums.some((n) => sameNumber(r.dst, n)) if (!srcMatch && !dstMatch) continue const at = r.calldate instanceof Date ? r.calldate.toISOString() : new Date(r.calldate).toISOString() const answered = String(r.disposition || '') === 'ANSWERED' && Number(r.billsec) > 0 const ext = [r.channel, r.dstchannel].map((c) => extFromChannel(String(c || ''))).find((e) => e && !/^FM/.test(e)) || '' const cur = byFile.get(file) if (!cur) { byFile.set(file, { file, at, direction: srcMatch && dstMatch ? 'internal' : srcMatch ? 'in' : 'out', number: String(srcMatch ? r.src : r.dst || ''), extension: ext, answered, duration: Number(r.billsec) || 0, legs: 1, linkedid: String(r.linkedid || '') }) } else { cur.legs++ if (at < cur.at) cur.at = at if (answered) { cur.answered = true; cur.duration = Math.max(cur.duration, Number(r.billsec) || 0) } if (!cur.extension && ext) cur.extension = ext } } const out = [...byFile.values()].sort((a, b) => (a.at < b.at ? 1 : -1)).slice(0, limit) return { available: true, rows: out } } // ---- PBX recordings service ---------------------------------------------- const pbxCfg = () => { const cfg: any = (useRuntimeConfig() as any).pbxRecordings || {} return { baseUrl: String(cfg.baseUrl || '').replace(/\/+$/, ''), secret: String(cfg.secret || '') } } export const pbxAvailable = () => { const c = pbxCfg(); return !!(c.baseUrl && c.secret) } export const statRecordings = async (names: string[]): Promise> => { const { baseUrl, secret } = pbxCfg() if (!baseUrl || !secret || !names.length) return {} const out: Record = {} // POST body (Node 8 on the PBX caps request headers at 8 KB — a GET query string overflows). for (let i = 0; i < names.length; i += 500) { const chunk = names.slice(i, i + 500) try { const res: any = await $fetch(`${baseUrl}/recordings/stat`, { method: 'POST', body: { names: chunk }, headers: { 'X-IVR-Secret': secret }, timeout: 15000, retry: 0 }) Object.assign(out, res || {}) } catch (err: any) { console.warn('[recordings] stat failed:', err?.message || err) for (const n of chunk) out[n] = null } } return out } export const fetchRecording = async (name: string): Promise<{ buffer: Buffer; type: string }> => { const { baseUrl, secret } = pbxCfg() if (!baseUrl || !secret) throw new Error('Recordings service not configured') const res = await $fetch.raw(`${baseUrl}/recordings/file/${encodeURIComponent(name)}`, { headers: { 'X-IVR-Secret': secret }, responseType: 'arrayBuffer', timeout: 120000, retry: 0 }) const type = String(res.headers.get('content-type') || 'audio/wav') return { buffer: Buffer.from(res._data as ArrayBuffer), type } } // No browser ships a GSM 06.10 decoder, so a .gsm recording's