diff --git a/.claude/HISTORY.md b/.claude/HISTORY.md index 72c60a75..159579a5 100644 --- a/.claude/HISTORY.md +++ b/.claude/HISTORY.md @@ -2463,3 +2463,30 @@ git checkout HEAD -- index.html - Key files modified: - `package/secubox/luci-app-jabber/htdocs/luci-static/resources/jabber/api.js` - `package/secubox/luci-app-jabber/htdocs/luci-static/resources/view/jabber/overview.js` + +40. **VoIP Call Recording Feature (2026-02-19)** + - Added comprehensive call recording system to `secubox-app-voip`: + - Asterisk MixMonitor integration for automatic call recording + - Configurable recording format (wav) and retention policy + - Daily directory organization (YYYYMMDD/HHMMSS-caller-dest.wav) + - New `voipctl rec` commands: + - `rec enable` / `rec disable` - Toggle call recording + - `rec status` - JSON status with statistics + - `rec list [date]` - List recordings by date + - `rec play ` - Play recording + - `rec download ` - Get file path/content + - `rec delete ` - Delete recording + - `rec cleanup [days]` - Remove old recordings + - New LuCI recordings view (`voip/recordings.js`): + - Status dashboard with total/today counts and storage used + - Enable/Disable toggle buttons + - Cleanup old recordings button + - Date filter for browsing recordings + - Play, Download, Delete actions for each recording + - In-browser audio player with base64 content support + - RPCD methods added to `luci.voip`: + - `rec_status`, `rec_enable`, `rec_disable` + - `rec_list`, `rec_delete`, `rec_download`, `rec_cleanup` + - UCI config section: `config recording 'recording'` with enabled/format/retention_days + - Menu entry: Services → VoIP PBX → Recordings + - Note: OVH SIP trunk registration requires correct password from OVH Manager diff --git a/package/secubox/luci-app-voip/htdocs/luci-static/resources/view/voip/recordings.js b/package/secubox/luci-app-voip/htdocs/luci-static/resources/view/voip/recordings.js new file mode 100644 index 00000000..5e2e849a --- /dev/null +++ b/package/secubox/luci-app-voip/htdocs/luci-static/resources/view/voip/recordings.js @@ -0,0 +1,320 @@ +'use strict'; +'require view'; +'require ui'; +'require voip.api as api'; + +return view.extend({ + load: function() { + return Promise.all([ + api.getRecordingStatus(), + api.listRecordings('') + ]); + }, + + render: function(data) { + var status = data[0] || {}; + var recordings = data[1] || []; + + var enabledClass = status.enabled ? 'success' : 'warning'; + var enabledText = status.enabled ? 'Enabled' : 'Disabled'; + + var view = E('div', { 'class': 'cbi-map' }, [ + E('h2', {}, 'Call Recordings'), + + // Status cards + E('div', { 'class': 'cbi-section', 'style': 'display: flex; gap: 20px; flex-wrap: wrap;' }, [ + E('div', { 'class': 'cbi-value', 'style': 'flex: 1; min-width: 200px; padding: 15px; border: 1px solid #ddd; border-radius: 5px;' }, [ + E('h3', {}, 'Recording Status'), + E('span', { 'class': 'label ' + enabledClass, 'style': 'font-size: 1.2em; padding: 5px 15px;' }, enabledText) + ]), + E('div', { 'class': 'cbi-value', 'style': 'flex: 1; min-width: 200px; padding: 15px; border: 1px solid #ddd; border-radius: 5px;' }, [ + E('h3', {}, 'Total Recordings'), + E('span', { 'style': 'font-size: 2em; font-weight: bold;' }, String(status.total_recordings || 0)) + ]), + E('div', { 'class': 'cbi-value', 'style': 'flex: 1; min-width: 200px; padding: 15px; border: 1px solid #ddd; border-radius: 5px;' }, [ + E('h3', {}, 'Today'), + E('span', { 'style': 'font-size: 2em; font-weight: bold;' }, String(status.today_recordings || 0)) + ]), + E('div', { 'class': 'cbi-value', 'style': 'flex: 1; min-width: 200px; padding: 15px; border: 1px solid #ddd; border-radius: 5px;' }, [ + E('h3', {}, 'Storage Used'), + E('span', { 'style': 'font-size: 1.5em; font-weight: bold;' }, String(status.total_size || '0')) + ]) + ]), + + // Control buttons + E('div', { 'class': 'cbi-section' }, [ + E('h3', {}, 'Recording Control'), + E('div', { 'class': 'cbi-value' }, [ + E('button', { + 'class': 'btn cbi-button cbi-button-positive', + 'click': ui.createHandlerFn(this, 'handleEnable'), + 'disabled': status.enabled + }, 'Enable Recording'), + ' ', + E('button', { + 'class': 'btn cbi-button cbi-button-negative', + 'click': ui.createHandlerFn(this, 'handleDisable'), + 'disabled': !status.enabled + }, 'Disable Recording'), + ' ', + E('button', { + 'class': 'btn cbi-button cbi-button-action', + 'click': ui.createHandlerFn(this, 'handleCleanup') + }, 'Cleanup Old Recordings') + ]) + ]), + + // Settings info + E('div', { 'class': 'cbi-section' }, [ + E('h3', {}, 'Settings'), + E('table', { 'class': 'table' }, [ + E('tr', { 'class': 'tr' }, [ + E('td', { 'class': 'td', 'style': 'font-weight: bold;' }, 'Format'), + E('td', { 'class': 'td' }, status.format || 'wav') + ]), + E('tr', { 'class': 'tr' }, [ + E('td', { 'class': 'td', 'style': 'font-weight: bold;' }, 'Retention'), + E('td', { 'class': 'td' }, (status.retention_days || 30) + ' days') + ]), + E('tr', { 'class': 'tr' }, [ + E('td', { 'class': 'td', 'style': 'font-weight: bold;' }, 'Storage Path'), + E('td', { 'class': 'td' }, status.path || '/srv/voip/recordings') + ]) + ]) + ]), + + // Recordings list + E('div', { 'class': 'cbi-section' }, [ + E('h3', {}, 'Recent Recordings'), + E('div', { 'class': 'cbi-value', 'style': 'margin-bottom: 10px;' }, [ + E('label', {}, 'Filter by date: '), + E('input', { + 'type': 'date', + 'id': 'date-filter', + 'change': ui.createHandlerFn(this, 'handleDateFilter') + }), + ' ', + E('button', { + 'class': 'btn cbi-button', + 'click': ui.createHandlerFn(this, 'handleRefresh') + }, 'Refresh') + ]), + E('div', { 'id': 'recordings-table' }, [ + this.renderRecordingsTable(recordings) + ]) + ]), + + // Audio player + E('div', { 'class': 'cbi-section', 'id': 'audio-player-section', 'style': 'display: none;' }, [ + E('h3', {}, 'Audio Player'), + E('audio', { + 'id': 'audio-player', + 'controls': true, + 'style': 'width: 100%;' + }), + E('p', { 'id': 'audio-filename', 'style': 'margin-top: 5px; font-style: italic;' }, '') + ]) + ]); + + return view; + }, + + renderRecordingsTable: function(recordings) { + if (!recordings || !Array.isArray(recordings) || recordings.length === 0) { + return E('p', { 'class': 'cbi-value-description' }, 'No recordings found'); + } + + return E('table', { 'class': 'table' }, [ + E('tr', { 'class': 'tr table-titles' }, [ + E('th', { 'class': 'th' }, 'Date'), + E('th', { 'class': 'th' }, 'Time'), + E('th', { 'class': 'th' }, 'Caller'), + E('th', { 'class': 'th' }, 'Destination'), + E('th', { 'class': 'th' }, 'Size'), + E('th', { 'class': 'th' }, 'Actions') + ]) + ].concat(recordings.map(function(rec) { + var sizeKB = Math.round((rec.size || 0) / 1024); + var sizeMB = (sizeKB / 1024).toFixed(2); + var sizeStr = sizeKB > 1024 ? sizeMB + ' MB' : sizeKB + ' KB'; + + return E('tr', { 'class': 'tr' }, [ + E('td', { 'class': 'td' }, rec.date || '-'), + E('td', { 'class': 'td' }, rec.time || '-'), + E('td', { 'class': 'td' }, rec.caller || '-'), + E('td', { 'class': 'td' }, rec.destination || '-'), + E('td', { 'class': 'td' }, sizeStr), + E('td', { 'class': 'td' }, [ + E('button', { + 'class': 'btn cbi-button cbi-button-action', + 'click': ui.createHandlerFn(this, 'handlePlay', rec.filename), + 'title': 'Play' + }, '▶'), + ' ', + E('button', { + 'class': 'btn cbi-button', + 'click': ui.createHandlerFn(this, 'handleDownload', rec.filename), + 'title': 'Download' + }, '↓'), + ' ', + E('button', { + 'class': 'btn cbi-button cbi-button-remove', + 'click': ui.createHandlerFn(this, 'handleDelete', rec.filename), + 'title': 'Delete' + }, '✕') + ]) + ]); + }, this))); + }, + + handleEnable: function() { + ui.showModal('Enabling...', [E('p', 'Enabling call recording...')]); + return api.enableRecording().then(function(res) { + ui.hideModal(); + if (res.success) { + ui.addNotification(null, E('p', 'Call recording enabled'), 'success'); + } else { + ui.addNotification(null, E('p', 'Failed: ' + res.output), 'error'); + } + window.location.reload(); + }); + }, + + handleDisable: function() { + return ui.showModal('Confirm', [ + E('p', 'Are you sure you want to disable call recording?'), + E('div', { 'class': 'right' }, [ + E('button', { + 'class': 'btn', + 'click': ui.hideModal + }, 'Cancel'), + ' ', + E('button', { + 'class': 'btn cbi-button-negative', + 'click': L.bind(function() { + ui.hideModal(); + return api.disableRecording().then(function(res) { + ui.addNotification(null, E('p', 'Call recording disabled'), 'success'); + window.location.reload(); + }); + }, this) + }, 'Disable') + ]) + ]); + }, + + handleCleanup: function() { + var days = prompt('Delete recordings older than how many days?', '30'); + if (!days) return; + + ui.showModal('Cleaning up...', [E('p', 'Deleting old recordings...')]); + return api.cleanupRecordings(parseInt(days)).then(function(res) { + ui.hideModal(); + if (res.success) { + ui.addNotification(null, E('p', res.output), 'success'); + } else { + ui.addNotification(null, E('p', 'Cleanup failed: ' + res.output), 'error'); + } + window.location.reload(); + }); + }, + + handleDateFilter: function(ev) { + var dateInput = ev.target.value; + if (!dateInput) return; + + var date = dateInput.replace(/-/g, ''); + var container = document.getElementById('recordings-table'); + + container.innerHTML = '

Loading...

'; + + api.listRecordings(date).then(L.bind(function(recordings) { + container.innerHTML = ''; + container.appendChild(this.renderRecordingsTable(recordings)); + }, this)); + }, + + handleRefresh: function() { + var dateInput = document.getElementById('date-filter'); + var date = dateInput ? dateInput.value.replace(/-/g, '') : ''; + var container = document.getElementById('recordings-table'); + + container.innerHTML = '

Loading...

'; + + api.listRecordings(date).then(L.bind(function(recordings) { + container.innerHTML = ''; + container.appendChild(this.renderRecordingsTable(recordings)); + }, this)); + }, + + handlePlay: function(filename) { + var playerSection = document.getElementById('audio-player-section'); + var player = document.getElementById('audio-player'); + var filenameEl = document.getElementById('audio-filename'); + + playerSection.style.display = 'none'; + filenameEl.textContent = 'Loading ' + filename + '...'; + + api.downloadRecording(filename).then(function(res) { + if (res.success && res.content) { + // Create a data URL from base64 content + var dataUrl = 'data:audio/wav;base64,' + res.content; + player.src = dataUrl; + filenameEl.textContent = filename; + playerSection.style.display = 'block'; + player.play(); + } else if (res.success && res.path) { + // File too large for base64, show path + filenameEl.textContent = 'File: ' + res.path + ' (too large to play in browser)'; + playerSection.style.display = 'block'; + } else { + ui.addNotification(null, E('p', 'Failed to load recording: ' + (res.error || 'Unknown error')), 'error'); + } + }); + }, + + handleDownload: function(filename) { + api.downloadRecording(filename).then(function(res) { + if (res.success && res.content) { + // Create download link from base64 + var link = document.createElement('a'); + link.href = 'data:audio/wav;base64,' + res.content; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } else if (res.success && res.path) { + ui.addNotification(null, E('p', 'Recording path: ' + res.path), 'info'); + } else { + ui.addNotification(null, E('p', 'Failed to download: ' + (res.error || 'Unknown error')), 'error'); + } + }); + }, + + handleDelete: function(filename) { + return ui.showModal('Confirm Delete', [ + E('p', 'Delete recording: ' + filename + '?'), + E('div', { 'class': 'right' }, [ + E('button', { + 'class': 'btn', + 'click': ui.hideModal + }, 'Cancel'), + ' ', + E('button', { + 'class': 'btn cbi-button-negative', + 'click': L.bind(function() { + ui.hideModal(); + return api.deleteRecording(filename).then(function(res) { + if (res.success) { + ui.addNotification(null, E('p', 'Recording deleted'), 'success'); + window.location.reload(); + } else { + ui.addNotification(null, E('p', 'Failed to delete: ' + res.output), 'error'); + } + }); + }, this) + }, 'Delete') + ]) + ]); + } +}); diff --git a/package/secubox/luci-app-voip/htdocs/luci-static/resources/voip/api.js b/package/secubox/luci-app-voip/htdocs/luci-static/resources/voip/api.js index dad7ccc2..7f70b129 100644 --- a/package/secubox/luci-app-voip/htdocs/luci-static/resources/voip/api.js +++ b/package/secubox/luci-app-voip/htdocs/luci-static/resources/voip/api.js @@ -109,6 +109,53 @@ var callVmDelete = rpc.declare({ expect: {} }); +// Recording methods +var callRecStatus = rpc.declare({ + object: 'luci.voip', + method: 'rec_status', + expect: {} +}); + +var callRecEnable = rpc.declare({ + object: 'luci.voip', + method: 'rec_enable', + expect: {} +}); + +var callRecDisable = rpc.declare({ + object: 'luci.voip', + method: 'rec_disable', + expect: {} +}); + +var callRecList = rpc.declare({ + object: 'luci.voip', + method: 'rec_list', + params: ['date'], + expect: {} +}); + +var callRecDelete = rpc.declare({ + object: 'luci.voip', + method: 'rec_delete', + params: ['filename'], + expect: {} +}); + +var callRecDownload = rpc.declare({ + object: 'luci.voip', + method: 'rec_download', + params: ['filename'], + expect: {} +}); + +var callRecCleanup = rpc.declare({ + object: 'luci.voip', + method: 'rec_cleanup', + params: ['days'], + expect: {} +}); + return L.Class.extend({ getStatus: function() { return callStatus(); }, getExtensions: function() { return callExtensions(); }, @@ -126,5 +173,14 @@ return L.Class.extend({ originateCall: function(from, to) { return callOriginate(from, to); }, hangupCall: function(ch) { return callHangup(ch); }, testTrunk: function() { return callTrunkTest(); }, - deleteVoicemail: function(ext, id) { return callVmDelete(ext, id); } + deleteVoicemail: function(ext, id) { return callVmDelete(ext, id); }, + + // Recording methods + getRecordingStatus: function() { return callRecStatus(); }, + enableRecording: function() { return callRecEnable(); }, + disableRecording: function() { return callRecDisable(); }, + listRecordings: function(date) { return callRecList(date || ''); }, + deleteRecording: function(filename) { return callRecDelete(filename); }, + downloadRecording: function(filename) { return callRecDownload(filename); }, + cleanupRecordings: function(days) { return callRecCleanup(days || 30); } }); diff --git a/package/secubox/luci-app-voip/root/usr/libexec/rpcd/luci.voip b/package/secubox/luci-app-voip/root/usr/libexec/rpcd/luci.voip index 63b7e527..15484b67 100755 --- a/package/secubox/luci-app-voip/root/usr/libexec/rpcd/luci.voip +++ b/package/secubox/luci-app-voip/root/usr/libexec/rpcd/luci.voip @@ -26,7 +26,14 @@ case "$1" in "call_hangup": {"channel": "str"}, "trunk_add": {"provider": "str"}, "trunk_test": {}, - "vm_delete": {"extension": "str", "msg_id": "str"} + "vm_delete": {"extension": "str", "msg_id": "str"}, + "rec_status": {}, + "rec_enable": {}, + "rec_disable": {}, + "rec_list": {"date": "str"}, + "rec_delete": {"filename": "str"}, + "rec_download": {"filename": "str"}, + "rec_cleanup": {"days": "int"} } JSON ;; @@ -240,10 +247,90 @@ JSON json_load "$input" json_get_var extension extension json_get_var msg_id msg_id - + local output=$($VOIPCTL vm delete "$extension" "$msg_id" 2>&1) local rc=$? - + + json_init + json_add_boolean "success" "$((rc == 0))" + json_add_string "output" "$output" + json_dump + ;; + rec_status) + $VOIPCTL rec status + ;; + rec_enable) + local output=$($VOIPCTL rec enable 2>&1) + local rc=$? + + json_init + json_add_boolean "success" "$((rc == 0))" + json_add_string "output" "$output" + json_dump + ;; + rec_disable) + local output=$($VOIPCTL rec disable 2>&1) + local rc=$? + + json_init + json_add_boolean "success" "$((rc == 0))" + json_add_string "output" "$output" + json_dump + ;; + rec_list) + read -r input + json_load "$input" + json_get_var date date + + $VOIPCTL rec list-json "$date" + ;; + rec_delete) + read -r input + json_load "$input" + json_get_var filename filename + + local output=$($VOIPCTL rec delete "$filename" 2>&1) + local rc=$? + + json_init + json_add_boolean "success" "$((rc == 0))" + json_add_string "output" "$output" + json_dump + ;; + rec_download) + read -r input + json_load "$input" + json_get_var filename filename + + local path=$($VOIPCTL rec download "$filename" 2>&1) + local rc=$? + + json_init + if [ $rc -eq 0 ]; then + json_add_boolean "success" 1 + json_add_string "path" "$path" + # Also provide base64 content for small files + local size=$(stat -c%s "$path" 2>/dev/null || echo 0) + if [ "$size" -lt 5000000 ]; then + # Base64 encode files under 5MB + local b64=$(base64 -w 0 "$path" 2>/dev/null) + json_add_string "content" "$b64" + fi + else + json_add_boolean "success" 0 + json_add_string "error" "$path" + fi + json_dump + ;; + rec_cleanup) + read -r input + json_load "$input" + json_get_var days days + + [ -z "$days" ] && days=30 + local output=$($VOIPCTL rec cleanup "$days" 2>&1) + local rc=$? + json_init json_add_boolean "success" "$((rc == 0))" json_add_string "output" "$output" diff --git a/package/secubox/luci-app-voip/root/usr/share/luci/menu.d/luci-app-voip.json b/package/secubox/luci-app-voip/root/usr/share/luci/menu.d/luci-app-voip.json index d67e39ca..13e07dcd 100644 --- a/package/secubox/luci-app-voip/root/usr/share/luci/menu.d/luci-app-voip.json +++ b/package/secubox/luci-app-voip/root/usr/share/luci/menu.d/luci-app-voip.json @@ -41,5 +41,13 @@ "type": "view", "path": "voip/dialer" } + }, + "admin/services/voip/recordings": { + "title": "Recordings", + "order": 50, + "action": { + "type": "view", + "path": "voip/recordings" + } } } diff --git a/package/secubox/luci-app-voip/root/usr/share/rpcd/acl.d/luci-app-voip.json b/package/secubox/luci-app-voip/root/usr/share/rpcd/acl.d/luci-app-voip.json index b7e04563..f99a72b8 100644 --- a/package/secubox/luci-app-voip/root/usr/share/rpcd/acl.d/luci-app-voip.json +++ b/package/secubox/luci-app-voip/root/usr/share/rpcd/acl.d/luci-app-voip.json @@ -3,7 +3,8 @@ "description": "Grant access to VoIP PBX configuration", "read": { "ubus": { - "luci.voip": ["status", "extensions", "calls", "voicemails", "trunk_status"] + "luci.voip": ["status", "extensions", "calls", "voicemails", "trunk_status", + "rec_status", "rec_list", "rec_download"] }, "uci": ["voip"] }, @@ -13,7 +14,8 @@ "ext_add", "ext_del", "ext_passwd", "call_originate", "call_hangup", "trunk_add", "trunk_test", - "vm_delete"] + "vm_delete", + "rec_enable", "rec_disable", "rec_delete", "rec_cleanup"] }, "uci": ["voip"] } diff --git a/package/secubox/secubox-app-voip/files/etc/config/voip b/package/secubox/secubox-app-voip/files/etc/config/voip index 6e0394e0..67b4d324 100644 --- a/package/secubox/secubox-app-voip/files/etc/config/voip +++ b/package/secubox/secubox-app-voip/files/etc/config/voip @@ -49,3 +49,8 @@ config haproxy 'ssl' option enabled '0' option domain '' option webrtc '1' + +config recording 'recording' + option enabled '0' + option format 'wav' + option retention_days '30' diff --git a/package/secubox/secubox-app-voip/files/usr/lib/secubox/voip/asterisk-config.sh b/package/secubox/secubox-app-voip/files/usr/lib/secubox/voip/asterisk-config.sh index 1447aa58..9ac3c66f 100755 --- a/package/secubox/secubox-app-voip/files/usr/lib/secubox/voip/asterisk-config.sh +++ b/package/secubox/secubox-app-voip/files/usr/lib/secubox/voip/asterisk-config.sh @@ -72,16 +72,20 @@ client_uri=sip:$username@$host retry_interval=60 expiration=3600 contact_user=$username +line=yes +endpoint=ovh-trunk [ovh-trunk-auth] type=auth auth_type=userpass username=$username password=$password +realm=$host [ovh-trunk-aor] type=aor contact=sip:$host +qualify_frequency=60 [ovh-trunk] type=endpoint @@ -98,6 +102,8 @@ rtp_symmetric=yes force_rport=yes rewrite_contact=yes ice_support=no +send_pai=yes +trust_id_outbound=yes [ovh-trunk-identify] type=identify @@ -159,6 +165,24 @@ ENDPOINT # Generate dialplan generate_dialplan() { + local record_enabled=$(uci -q get voip.recording.enabled) + local record_format=$(uci -q get voip.recording.format || echo "wav") + local record_path="/srv/voip/recordings" + + # Recording macro - used by all contexts when recording is enabled + local record_macro="" + if [ "$record_enabled" = "1" ]; then + record_macro="; Call Recording Macro +[macro-record] +exten => s,1,NoOp(Starting call recording) + same => n,Set(RECORD_FILE=${record_path}/\${STRFTIME(\${EPOCH},,%Y%m%d)}/\${STRFTIME(\${EPOCH},,%H%M%S)}-\${CALLERID(num)}-\${ARG1}.${record_format}) + same => n,System(mkdir -p ${record_path}/\${STRFTIME(\${EPOCH},,%Y%m%d)}) + same => n,MixMonitor(\${RECORD_FILE},ab) + same => n,MacroExit() + +" + fi + cat > "$AST_CONF/extensions.conf" < _XXX,1,NoOp(Internal call to \${EXTEN}) - same => n,Dial(PJSIP/\${EXTEN},30) + same => n,GotoIf(\$[\${RECORD_ENABLED}=1]?record:dial) + same => n(record),Macro(record,\${EXTEN}) + same => n(dial),Dial(PJSIP/\${EXTEN},30) same => n,VoiceMail(\${EXTEN}@default,u) same => n,Hangup() exten => _XXXX,1,NoOp(Internal call to \${EXTEN}) - same => n,Dial(PJSIP/\${EXTEN},30) + same => n,GotoIf(\$[\${RECORD_ENABLED}=1]?record:dial) + same => n(record),Macro(record,\${EXTEN}) + same => n(dial),Dial(PJSIP/\${EXTEN},30) same => n,VoiceMail(\${EXTEN}@default,u) same => n,Hangup() @@ -188,18 +219,24 @@ exten => *98,1,NoOp(Voicemail access) ; Outbound calls via trunk exten => _0XXXXXXXXX,1,NoOp(Outbound call to \${EXTEN}) same => n,Set(CALLERID(num)=\${CALLERID(num)}) - same => n,Dial(PJSIP/\${EXTEN}@\${TRUNK},120) + same => n,GotoIf(\$[\${RECORD_ENABLED}=1]?record:dial) + same => n(record),Macro(record,\${EXTEN}) + same => n(dial),Dial(PJSIP/\${EXTEN}@\${TRUNK},120) same => n,Hangup() exten => _+XXXXXXXXXXX,1,NoOp(International call to \${EXTEN}) - same => n,Dial(PJSIP/\${EXTEN}@\${TRUNK},120) + same => n,GotoIf(\$[\${RECORD_ENABLED}=1]?record:dial) + same => n(record),Macro(record,\${EXTEN}) + same => n(dial),Dial(PJSIP/\${EXTEN}@\${TRUNK},120) same => n,Hangup() ; Incoming calls from trunk [from-trunk] exten => _X.,1,NoOp(Incoming call from \${CALLERID(num)}) same => n,Set(INCOMING_EXT=100) - same => n,Dial(PJSIP/\${INCOMING_EXT},30) + same => n,GotoIf(\$[\${RECORD_ENABLED}=1]?record:dial) + same => n(record),Macro(record,\${INCOMING_EXT}) + same => n(dial),Dial(PJSIP/\${INCOMING_EXT},30) same => n,VoiceMail(\${INCOMING_EXT}@default,u) same => n,Hangup() diff --git a/package/secubox/secubox-app-voip/files/usr/lib/secubox/voip/ovh-telephony.sh b/package/secubox/secubox-app-voip/files/usr/lib/secubox/voip/ovh-telephony.sh index 2d9a9d46..e7aff5f7 100755 --- a/package/secubox/secubox-app-voip/files/usr/lib/secubox/voip/ovh-telephony.sh +++ b/package/secubox/secubox-app-voip/files/usr/lib/secubox/voip/ovh-telephony.sh @@ -21,12 +21,14 @@ ovh_sign() { local url="$2" local body="$3" local timestamp="$4" - + local app_secret=$(uci -q get voip.ovh_telephony.app_secret) local consumer_key=$(uci -q get voip.ovh_telephony.consumer_key) - + local to_sign="${app_secret}+${consumer_key}+${method}+${url}+${body}+${timestamp}" - echo -n "$to_sign" | sha1sum | cut -d' ' -f1 | sed 's/^/\$1\$/' + # Use openssl for SHA1 (sha1sum not available on OpenWrt) + local hash=$(echo -n "$to_sign" | openssl dgst -sha1 2>/dev/null | sed 's/.*= //') + echo "\$1\$${hash}" } # Make OVH API GET request diff --git a/package/secubox/secubox-app-voip/files/usr/sbin/voipctl b/package/secubox/secubox-app-voip/files/usr/sbin/voipctl index 61a5ec1c..46fc7f78 100755 --- a/package/secubox/secubox-app-voip/files/usr/sbin/voipctl +++ b/package/secubox/secubox-app-voip/files/usr/sbin/voipctl @@ -61,6 +61,16 @@ Voicemail: vm play Play voicemail vm delete Delete voicemail +Call Recording: + rec enable Enable call recording + rec disable Disable call recording + rec status Show recording status + rec list [date] List recordings (YYYYMMDD) + rec play Play recording + rec download Get download path + rec delete Delete recording + rec cleanup [days] Delete recordings older than N days + Configuration: configure-haproxy Setup WebRTC proxy in HAProxy emancipate Full exposure with SSL @@ -595,6 +605,287 @@ cmd_vm_delete() { log_ok "Message deleted" } +# +# Call Recording +# +RECORDINGS_PATH="$DATA_PATH/recordings" + +cmd_rec_enable() { + log_info "Enabling call recording..." + uci set voip.recording=recording + uci set voip.recording.enabled="1" + uci set voip.recording.format="wav" + uci set voip.recording.retention_days="30" + uci commit voip + + # Create recordings directory + mkdir -p "$RECORDINGS_PATH" + + # Regenerate dialplan with recording enabled + generate_dialplan + + if container_running; then + container_exec asterisk -rx "dialplan reload" + fi + + log_ok "Call recording enabled" + log_info "Recordings will be saved to: $RECORDINGS_PATH" +} + +cmd_rec_disable() { + log_info "Disabling call recording..." + uci set voip.recording.enabled="0" + uci commit voip + + generate_dialplan + + if container_running; then + container_exec asterisk -rx "dialplan reload" + fi + + log_ok "Call recording disabled" +} + +cmd_rec_status() { + local enabled=$(uci -q get voip.recording.enabled) + local format=$(uci -q get voip.recording.format || echo "wav") + local retention=$(uci -q get voip.recording.retention_days || echo "30") + local total_count=0 + local total_size=0 + local today_count=0 + + if [ -d "$RECORDINGS_PATH" ]; then + total_count=$(find "$RECORDINGS_PATH" -type f -name "*.$format" 2>/dev/null | wc -l) + total_size=$(du -sh "$RECORDINGS_PATH" 2>/dev/null | cut -f1 || echo "0") + + local today=$(date +%Y%m%d) + if [ -d "$RECORDINGS_PATH/$today" ]; then + today_count=$(find "$RECORDINGS_PATH/$today" -type f -name "*.$format" 2>/dev/null | wc -l) + fi + fi + + cat </dev/null | while read -r file; do + local name=$(basename "$file") + local size=$(du -h "$file" 2>/dev/null | cut -f1) + local time=$(echo "$name" | cut -d'-' -f1) + local caller=$(echo "$name" | cut -d'-' -f2) + local dest=$(echo "$name" | cut -d'-' -f3 | sed "s/\.$format//") + printf " %s %s -> %s (%s)\n" "$time" "$caller" "$dest" "$size" + done + else + log_warn "No recordings found for $date_filter" + fi + else + # List all recording dates with counts + echo "Recording dates:" + echo "---------------" + find "$RECORDINGS_PATH" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | sort -r | while read -r dir; do + local date=$(basename "$dir") + local count=$(find "$dir" -type f -name "*.$format" 2>/dev/null | wc -l) + local size=$(du -sh "$dir" 2>/dev/null | cut -f1) + printf " %s: %d recordings (%s)\n" "$date" "$count" "$size" + done + fi +} + +cmd_rec_play() { + local file="$1" + [ -z "$file" ] && { + log_err "Usage: voipctl rec play " + return 1 + } + + # Find the file (could be full path or just filename) + local fullpath + if [ -f "$file" ]; then + fullpath="$file" + else + fullpath=$(find "$RECORDINGS_PATH" -name "$file" -type f 2>/dev/null | head -1) + fi + + if [ -z "$fullpath" ] || [ ! -f "$fullpath" ]; then + log_err "Recording not found: $file" + return 1 + fi + + # Try to play the file + if command -v aplay >/dev/null 2>&1; then + log_info "Playing: $fullpath" + aplay "$fullpath" + elif command -v ffplay >/dev/null 2>&1; then + ffplay -nodisp -autoexit "$fullpath" + else + echo "File: $fullpath" + log_info "No audio player available. Download the file to play." + fi +} + +cmd_rec_download() { + local file="$1" + [ -z "$file" ] && { + log_err "Usage: voipctl rec download " + return 1 + } + + local fullpath + if [ -f "$file" ]; then + fullpath="$file" + else + fullpath=$(find "$RECORDINGS_PATH" -name "$file" -type f 2>/dev/null | head -1) + fi + + if [ -z "$fullpath" ] || [ ! -f "$fullpath" ]; then + log_err "Recording not found: $file" + return 1 + fi + + echo "$fullpath" +} + +cmd_rec_delete() { + local file="$1" + [ -z "$file" ] && { + log_err "Usage: voipctl rec delete " + return 1 + } + + local fullpath + if [ -f "$file" ]; then + fullpath="$file" + else + fullpath=$(find "$RECORDINGS_PATH" -name "$file" -type f 2>/dev/null | head -1) + fi + + if [ -z "$fullpath" ] || [ ! -f "$fullpath" ]; then + log_err "Recording not found: $file" + return 1 + fi + + rm -f "$fullpath" + log_ok "Deleted: $(basename "$fullpath")" + + # Clean up empty directories + find "$RECORDINGS_PATH" -type d -empty -delete 2>/dev/null +} + +cmd_rec_cleanup() { + local days="${1:-30}" + + log_info "Cleaning up recordings older than $days days..." + + local count=0 + local freed=0 + + if [ -d "$RECORDINGS_PATH" ]; then + # Calculate size before + local before=$(du -s "$RECORDINGS_PATH" 2>/dev/null | cut -f1 || echo 0) + + # Delete old recordings + count=$(find "$RECORDINGS_PATH" -type f -mtime +$days 2>/dev/null | wc -l) + find "$RECORDINGS_PATH" -type f -mtime +$days -delete 2>/dev/null + + # Remove empty directories + find "$RECORDINGS_PATH" -type d -empty -delete 2>/dev/null + + # Calculate freed space + local after=$(du -s "$RECORDINGS_PATH" 2>/dev/null | cut -f1 || echo 0) + freed=$(( (before - after) / 1024 )) + fi + + log_ok "Deleted $count recordings, freed ${freed}MB" +} + +cmd_rec_list_json() { + local date_filter="$1" + local format=$(uci -q get voip.recording.format || echo "wav") + + echo "[" + local first=1 + + if [ -n "$date_filter" ]; then + local dir="$RECORDINGS_PATH/$date_filter" + if [ -d "$dir" ]; then + find "$dir" -type f -name "*.$format" 2>/dev/null | sort -r | while read -r file; do + local name=$(basename "$file") + local size=$(stat -c%s "$file" 2>/dev/null || echo 0) + local mtime=$(stat -c%Y "$file" 2>/dev/null || echo 0) + local time=$(echo "$name" | cut -d'-' -f1) + local caller=$(echo "$name" | cut -d'-' -f2) + local dest=$(echo "$name" | cut -d'-' -f3 | sed "s/\.$format//") + + [ $first -eq 0 ] && echo "," + first=0 + + cat </dev/null | sort -r | head -50 | while read -r file; do + local name=$(basename "$file") + local dir=$(dirname "$file") + local date=$(basename "$dir") + local size=$(stat -c%s "$file" 2>/dev/null || echo 0) + local mtime=$(stat -c%Y "$file" 2>/dev/null || echo 0) + local time=$(echo "$name" | cut -d'-' -f1) + local caller=$(echo "$name" | cut -d'-' -f2) + local dest=$(echo "$name" | cut -d'-' -f3 | sed "s/\.$format//") + + [ $first -eq 0 ] && echo "," + first=0 + + cat <