feat(voip): Add call recording feature with LuCI management

- Add MixMonitor integration for automatic call recording
- Add voipctl rec commands: enable/disable/status/list/play/download/delete/cleanup
- Add recordings.js LuCI view with audio player and date filtering
- Add RPCD methods for recording management
- Add UCI config section for recording settings (format, retention)
- Fix OVH API signature to use openssl instead of sha1sum
- Improve PJSIP trunk config with realm and qualify settings

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
CyberMind-FR 2026-02-19 15:11:46 +01:00
parent 7b4cf2dfe6
commit 91cfd35d7a
10 changed files with 864 additions and 15 deletions

View File

@ -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 <file>` - Play recording
- `rec download <file>` - Get file path/content
- `rec delete <file>` - 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

View File

@ -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 = '<p>Loading...</p>';
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 = '<p>Loading...</p>';
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')
])
]);
}
});

View File

@ -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); }
});

View File

@ -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"

View File

@ -41,5 +41,13 @@
"type": "view",
"path": "voip/dialer"
}
},
"admin/services/voip/recordings": {
"title": "Recordings",
"order": 50,
"action": {
"type": "view",
"path": "voip/recordings"
}
}
}

View File

@ -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"]
}

View File

@ -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'

View File

@ -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" <<DIALPLAN
[general]
static=yes
@ -167,16 +191,23 @@ clearglobalvars=no
[globals]
TRUNK=ovh-trunk
RECORD_ENABLED=${record_enabled:-0}
RECORD_PATH=${record_path}
RECORD_FORMAT=${record_format}
; Internal calls between extensions
${record_macro}; Internal calls between extensions
[internal]
exten => _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()

View File

@ -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

View File

@ -61,6 +61,16 @@ Voicemail:
vm play <ext> <id> Play voicemail
vm delete <ext> <id> 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 <file> Play recording
rec download <file> Get download path
rec delete <file> Delete recording
rec cleanup [days] Delete recordings older than N days
Configuration:
configure-haproxy Setup WebRTC proxy in HAProxy
emancipate <domain> 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 <<EOF
{
"enabled": ${enabled:-0},
"format": "$format",
"retention_days": $retention,
"path": "$RECORDINGS_PATH",
"total_recordings": $total_count,
"total_size": "$total_size",
"today_recordings": $today_count
}
EOF
}
cmd_rec_list() {
local date_filter="$1"
local format=$(uci -q get voip.recording.format || echo "wav")
if [ -n "$date_filter" ]; then
# List recordings for specific date
local dir="$RECORDINGS_PATH/$date_filter"
if [ -d "$dir" ]; then
echo "Recordings for $date_filter:"
echo "-------------------------"
find "$dir" -type f -name "*.$format" 2>/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 <filename>"
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 <filename>"
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 <filename>"
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 <<ENTRY
{
"filename": "$name",
"path": "$file",
"date": "$date_filter",
"time": "$time",
"caller": "$caller",
"destination": "$dest",
"size": $size,
"timestamp": $mtime
}
ENTRY
done
fi
else
# List recent recordings (last 7 days)
find "$RECORDINGS_PATH" -type f -name "*.$format" -mtime -7 2>/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 <<ENTRY
{
"filename": "$name",
"path": "$file",
"date": "$date",
"time": "$time",
"caller": "$caller",
"destination": "$dest",
"size": $size,
"timestamp": $mtime
}
ENTRY
done
fi
echo "]"
}
#
# Configuration
#
@ -738,6 +1029,20 @@ case "$1" in
*) usage ;;
esac
;;
rec)
case "$2" in
enable) cmd_rec_enable ;;
disable) cmd_rec_disable ;;
status) cmd_rec_status ;;
list) shift 2; cmd_rec_list "$@" ;;
list-json) shift 2; cmd_rec_list_json "$@" ;;
play) shift 2; cmd_rec_play "$@" ;;
download) shift 2; cmd_rec_download "$@" ;;
delete) shift 2; cmd_rec_delete "$@" ;;
cleanup) shift 2; cmd_rec_cleanup "$@" ;;
*) usage ;;
esac
;;
configure-haproxy)
cmd_configure_haproxy
;;