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: - Key files modified:
- `package/secubox/luci-app-jabber/htdocs/luci-static/resources/jabber/api.js` - `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` - `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: {} 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({ return L.Class.extend({
getStatus: function() { return callStatus(); }, getStatus: function() { return callStatus(); },
getExtensions: function() { return callExtensions(); }, getExtensions: function() { return callExtensions(); },
@ -126,5 +173,14 @@ return L.Class.extend({
originateCall: function(from, to) { return callOriginate(from, to); }, originateCall: function(from, to) { return callOriginate(from, to); },
hangupCall: function(ch) { return callHangup(ch); }, hangupCall: function(ch) { return callHangup(ch); },
testTrunk: function() { return callTrunkTest(); }, 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"}, "call_hangup": {"channel": "str"},
"trunk_add": {"provider": "str"}, "trunk_add": {"provider": "str"},
"trunk_test": {}, "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 JSON
;; ;;
@ -240,10 +247,90 @@ JSON
json_load "$input" json_load "$input"
json_get_var extension extension json_get_var extension extension
json_get_var msg_id msg_id json_get_var msg_id msg_id
local output=$($VOIPCTL vm delete "$extension" "$msg_id" 2>&1) local output=$($VOIPCTL vm delete "$extension" "$msg_id" 2>&1)
local rc=$? 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_init
json_add_boolean "success" "$((rc == 0))" json_add_boolean "success" "$((rc == 0))"
json_add_string "output" "$output" json_add_string "output" "$output"

View File

@ -41,5 +41,13 @@
"type": "view", "type": "view",
"path": "voip/dialer" "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", "description": "Grant access to VoIP PBX configuration",
"read": { "read": {
"ubus": { "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"] "uci": ["voip"]
}, },
@ -13,7 +14,8 @@
"ext_add", "ext_del", "ext_passwd", "ext_add", "ext_del", "ext_passwd",
"call_originate", "call_hangup", "call_originate", "call_hangup",
"trunk_add", "trunk_test", "trunk_add", "trunk_test",
"vm_delete"] "vm_delete",
"rec_enable", "rec_disable", "rec_delete", "rec_cleanup"]
}, },
"uci": ["voip"] "uci": ["voip"]
} }

View File

@ -49,3 +49,8 @@ config haproxy 'ssl'
option enabled '0' option enabled '0'
option domain '' option domain ''
option webrtc '1' 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 retry_interval=60
expiration=3600 expiration=3600
contact_user=$username contact_user=$username
line=yes
endpoint=ovh-trunk
[ovh-trunk-auth] [ovh-trunk-auth]
type=auth type=auth
auth_type=userpass auth_type=userpass
username=$username username=$username
password=$password password=$password
realm=$host
[ovh-trunk-aor] [ovh-trunk-aor]
type=aor type=aor
contact=sip:$host contact=sip:$host
qualify_frequency=60
[ovh-trunk] [ovh-trunk]
type=endpoint type=endpoint
@ -98,6 +102,8 @@ rtp_symmetric=yes
force_rport=yes force_rport=yes
rewrite_contact=yes rewrite_contact=yes
ice_support=no ice_support=no
send_pai=yes
trust_id_outbound=yes
[ovh-trunk-identify] [ovh-trunk-identify]
type=identify type=identify
@ -159,6 +165,24 @@ ENDPOINT
# Generate dialplan # Generate dialplan
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 cat > "$AST_CONF/extensions.conf" <<DIALPLAN
[general] [general]
static=yes static=yes
@ -167,16 +191,23 @@ clearglobalvars=no
[globals] [globals]
TRUNK=ovh-trunk 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] [internal]
exten => _XXX,1,NoOp(Internal call to \${EXTEN}) 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,VoiceMail(\${EXTEN}@default,u)
same => n,Hangup() same => n,Hangup()
exten => _XXXX,1,NoOp(Internal call to \${EXTEN}) 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,VoiceMail(\${EXTEN}@default,u)
same => n,Hangup() same => n,Hangup()
@ -188,18 +219,24 @@ exten => *98,1,NoOp(Voicemail access)
; Outbound calls via trunk ; Outbound calls via trunk
exten => _0XXXXXXXXX,1,NoOp(Outbound call to \${EXTEN}) exten => _0XXXXXXXXX,1,NoOp(Outbound call to \${EXTEN})
same => n,Set(CALLERID(num)=\${CALLERID(num)}) 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() same => n,Hangup()
exten => _+XXXXXXXXXXX,1,NoOp(International call to \${EXTEN}) 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() same => n,Hangup()
; Incoming calls from trunk ; Incoming calls from trunk
[from-trunk] [from-trunk]
exten => _X.,1,NoOp(Incoming call from \${CALLERID(num)}) exten => _X.,1,NoOp(Incoming call from \${CALLERID(num)})
same => n,Set(INCOMING_EXT=100) 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,VoiceMail(\${INCOMING_EXT}@default,u)
same => n,Hangup() same => n,Hangup()

View File

@ -21,12 +21,14 @@ ovh_sign() {
local url="$2" local url="$2"
local body="$3" local body="$3"
local timestamp="$4" local timestamp="$4"
local app_secret=$(uci -q get voip.ovh_telephony.app_secret) local app_secret=$(uci -q get voip.ovh_telephony.app_secret)
local consumer_key=$(uci -q get voip.ovh_telephony.consumer_key) local consumer_key=$(uci -q get voip.ovh_telephony.consumer_key)
local to_sign="${app_secret}+${consumer_key}+${method}+${url}+${body}+${timestamp}" 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 # Make OVH API GET request

View File

@ -61,6 +61,16 @@ Voicemail:
vm play <ext> <id> Play voicemail vm play <ext> <id> Play voicemail
vm delete <ext> <id> Delete 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: Configuration:
configure-haproxy Setup WebRTC proxy in HAProxy configure-haproxy Setup WebRTC proxy in HAProxy
emancipate <domain> Full exposure with SSL emancipate <domain> Full exposure with SSL
@ -595,6 +605,287 @@ cmd_vm_delete() {
log_ok "Message deleted" 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 # Configuration
# #
@ -738,6 +1029,20 @@ case "$1" in
*) usage ;; *) usage ;;
esac 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) configure-haproxy)
cmd_configure_haproxy cmd_configure_haproxy
;; ;;