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:
parent
7b4cf2dfe6
commit
91cfd35d7a
@ -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
|
||||
|
||||
@ -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')
|
||||
])
|
||||
]);
|
||||
}
|
||||
});
|
||||
@ -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); }
|
||||
});
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -41,5 +41,13 @@
|
||||
"type": "view",
|
||||
"path": "voip/dialer"
|
||||
}
|
||||
},
|
||||
"admin/services/voip/recordings": {
|
||||
"title": "Recordings",
|
||||
"order": 50,
|
||||
"action": {
|
||||
"type": "view",
|
||||
"path": "voip/recordings"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"]
|
||||
}
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
;;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user