Add voice name flow controls and analytics
This commit is contained in:
+217
-6
@@ -10,6 +10,7 @@ const state = {
|
||||
loginPath: '/auth/oidc/start?return_mode=popup',
|
||||
providerLabel: 'Keycloak',
|
||||
},
|
||||
voiceNameConfig: null,
|
||||
};
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
@@ -290,8 +291,8 @@ function defaultIvrFlowDocument() {
|
||||
invalid_target_node_id: 'root',
|
||||
no_input_target_node_id: 'root',
|
||||
options: [
|
||||
{ digit: '1', target_node_id: 'menu_kz' },
|
||||
{ digit: '2', target_node_id: 'menu_ru' },
|
||||
{ digit: '1', target_node_id: 'voice_start_kz' },
|
||||
{ digit: '2', target_node_id: 'voice_start_ru' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -362,9 +363,60 @@ function defaultIvrFlowDocument() {
|
||||
};
|
||||
}
|
||||
|
||||
function defaultVoiceStartIvrFlowDocument() {
|
||||
return {
|
||||
nodes: [
|
||||
{
|
||||
node_id: 'root',
|
||||
prompt_text:
|
||||
'\u0421\u0430\u043b\u0430\u043c\u0430\u0442\u0441\u044b\u0437 \u0431\u0430! \u049a\u0430\u0437\u0430\u049b \u0442\u0456\u043b\u0456\u043d \u0442\u0430\u04a3\u0434\u0430\u0443 \u04af\u0448\u0456\u043d \u0431\u0456\u0440 \u0446\u0438\u0444\u0440\u044b\u043d \u0442\u0435\u0440\u0456\u04a3\u0456\u0437. \u0417\u0434\u0440\u0430\u0432\u0441\u0442\u0432\u0443\u0439\u0442\u0435! \u0414\u043e\u0431\u0440\u043e \u043f\u043e\u0436\u0430\u043b\u043e\u0432\u0430\u0442\u044c \u0432 \u043a\u043e\u043d\u0442\u0430\u043a\u0442-\u0446\u0435\u043d\u0442\u0440. \u0414\u043b\u044f \u0440\u0443\u0441\u0441\u043a\u043e\u0433\u043e \u044f\u0437\u044b\u043a\u0430 \u043d\u0430\u0436\u043c\u0438\u0442\u0435 \u0446\u0438\u0444\u0440\u0443 \u0434\u0432\u0430.',
|
||||
prompt_sequence: [
|
||||
{
|
||||
prompt_audio_key: 'ivr/demo-language-kz',
|
||||
prompt_text:
|
||||
'\u0421\u0430\u043b\u0430\u043c\u0430\u0442\u0441\u044b\u0437 \u0431\u0430! \u049a\u0430\u0437\u0430\u049b \u0442\u0456\u043b\u0456\u043d \u0442\u0430\u04a3\u0434\u0430\u0443 \u04af\u0448\u0456\u043d \u0431\u0456\u0440 \u0446\u0438\u0444\u0440\u044b\u043d \u0442\u0435\u0440\u0456\u04a3\u0456\u0437.',
|
||||
language: 'kz',
|
||||
},
|
||||
{
|
||||
prompt_audio_key: 'ivr/demo-language-ru',
|
||||
prompt_text:
|
||||
'\u0417\u0434\u0440\u0430\u0432\u0441\u0442\u0432\u0443\u0439\u0442\u0435! \u0414\u043e\u0431\u0440\u043e \u043f\u043e\u0436\u0430\u043b\u043e\u0432\u0430\u0442\u044c \u0432 \u043a\u043e\u043d\u0442\u0430\u043a\u0442-\u0446\u0435\u043d\u0442\u0440. \u0414\u043b\u044f \u0440\u0443\u0441\u0441\u043a\u043e\u0433\u043e \u044f\u0437\u044b\u043a\u0430 \u043d\u0430\u0436\u043c\u0438\u0442\u0435 \u0446\u0438\u0444\u0440\u0443 \u0434\u0432\u0430.',
|
||||
language: 'ru',
|
||||
},
|
||||
],
|
||||
is_terminal: false,
|
||||
invalid_target_node_id: 'root',
|
||||
no_input_target_node_id: 'root',
|
||||
options: [
|
||||
{ digit: '1', target_node_id: 'voice_start_kz' },
|
||||
{ digit: '2', target_node_id: 'voice_start_ru' },
|
||||
],
|
||||
},
|
||||
{
|
||||
node_id: 'voice_start_kz',
|
||||
prompt_text: '\u049a\u043e\u04a3\u044b\u0440\u0430\u0443\u0434\u044b \u049b\u0430\u0437\u0430\u049b \u0442\u0456\u043b\u0456\u043d\u0434\u0435\u0433\u0456 \u0431\u0430\u0441\u0442\u0430\u043f\u049b\u044b voice-\u0441\u0446\u0435\u043d\u0430\u0440\u0438\u0439\u0433\u0435 \u04e9\u0442\u043a\u0456\u0437\u0456\u043f \u0436\u0430\u0442\u044b\u0440\u043c\u044b\u0437.',
|
||||
is_terminal: true,
|
||||
outcome_code: 'voice_start_kz',
|
||||
resolved_queue_id: 'voice_start_kz_line',
|
||||
resolved_queue_code: 'voice_start_kz',
|
||||
options: [],
|
||||
},
|
||||
{
|
||||
node_id: 'voice_start_ru',
|
||||
prompt_text: '\u041f\u0435\u0440\u0435\u0434\u0430\u0435\u043c \u0437\u0432\u043e\u043d\u043e\u043a \u0432 \u0440\u0443\u0441\u0441\u043a\u043e\u044f\u0437\u044b\u0447\u043d\u044b\u0439 \u0441\u0442\u0430\u0440\u0442\u043e\u0432\u044b\u0439 voice-\u0441\u0446\u0435\u043d\u0430\u0440\u0438\u0439.',
|
||||
is_terminal: true,
|
||||
outcome_code: 'voice_start_ru',
|
||||
resolved_queue_id: 'voice_start_ru_line',
|
||||
resolved_queue_code: 'voice_start_ru',
|
||||
options: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function ensureDefaultIvrFlowJson() {
|
||||
if (!$('ivrFlowJson').value.trim()) {
|
||||
$('ivrFlowJson').value = JSON.stringify(defaultIvrFlowDocument(), null, 2);
|
||||
$('ivrFlowJson').value = JSON.stringify(defaultVoiceStartIvrFlowDocument(), null, 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -444,7 +496,7 @@ function syncIvrFlowFields(flow) {
|
||||
$('ivrQueueId').value = flow.queue_id || $('ivrQueueId').value;
|
||||
$('ivrEntryNodeId').value = flow.entry_node_id || $('ivrEntryNodeId').value;
|
||||
$('ivrIsActive').checked = Boolean(flow.is_active);
|
||||
$('ivrFlowJson').value = JSON.stringify(flow.flow_json || defaultIvrFlowDocument(), null, 2);
|
||||
$('ivrFlowJson').value = JSON.stringify(flow.flow_json || defaultVoiceStartIvrFlowDocument(), null, 2);
|
||||
}
|
||||
|
||||
function syncIvrSessionFields(payload) {
|
||||
@@ -458,6 +510,146 @@ function syncIvrSessionFields(payload) {
|
||||
$('ivrTestInteractionId').value = session.interaction_id || $('ivrTestInteractionId').value;
|
||||
}
|
||||
|
||||
function serializeVoiceNameConfigForm() {
|
||||
return {
|
||||
enabled: $('voiceNameEnabled').checked,
|
||||
start: {
|
||||
ask_name_on_start: $('voiceNameAskOnStart').checked,
|
||||
known_customer_behavior: $('voiceNameKnownCustomerBehavior').value,
|
||||
unknown_customer_behavior: $('voiceNameUnknownCustomerBehavior').value,
|
||||
},
|
||||
downstream: {
|
||||
missing_name_behavior: $('voiceNameMissingNameBehavior').value,
|
||||
uncertain_name_behavior: $('voiceNameUncertainNameBehavior').value,
|
||||
finalize_on_explicit_name: $('voiceNameFinalizeOnExplicitName').checked,
|
||||
finalize_on_confirmation: $('voiceNameFinalizeOnConfirmation').checked,
|
||||
},
|
||||
texts: {
|
||||
ru: {
|
||||
start_prompt: $('voiceNameRuStartPrompt').value.trim(),
|
||||
personalized_greeting_template: $('voiceNameRuPersonalizedGreeting').value.trim(),
|
||||
confirmation_greeting_template: $('voiceNameRuConfirmationGreeting').value.trim(),
|
||||
inline_followup_prompt: $('voiceNameRuInlineFollowup').value.trim(),
|
||||
},
|
||||
kz: {
|
||||
start_prompt: $('voiceNameKzStartPrompt').value.trim(),
|
||||
personalized_greeting_template: $('voiceNameKzPersonalizedGreeting').value.trim(),
|
||||
confirmation_greeting_template: $('voiceNameKzConfirmationGreeting').value.trim(),
|
||||
inline_followup_prompt: $('voiceNameKzInlineFollowup').value.trim(),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function syncVoiceNameConfigFields(payload) {
|
||||
const config = payload?.config || payload;
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
$('voiceNameEnabled').checked = Boolean(config.enabled);
|
||||
$('voiceNameAskOnStart').checked = Boolean(config.start?.ask_name_on_start);
|
||||
$('voiceNameKnownCustomerBehavior').value = config.start?.known_customer_behavior || 'trust_and_handoff';
|
||||
$('voiceNameUnknownCustomerBehavior').value = config.start?.unknown_customer_behavior || 'ask_on_start';
|
||||
$('voiceNameMissingNameBehavior').value = config.downstream?.missing_name_behavior || 'ask_inline_once';
|
||||
$('voiceNameUncertainNameBehavior').value = config.downstream?.uncertain_name_behavior || 'confirm_then_finalize';
|
||||
$('voiceNameFinalizeOnExplicitName').checked = Boolean(config.downstream?.finalize_on_explicit_name);
|
||||
$('voiceNameFinalizeOnConfirmation').checked = Boolean(config.downstream?.finalize_on_confirmation);
|
||||
$('voiceNameRuStartPrompt').value = config.texts?.ru?.start_prompt || '';
|
||||
$('voiceNameRuPersonalizedGreeting').value = config.texts?.ru?.personalized_greeting_template || '';
|
||||
$('voiceNameRuConfirmationGreeting').value = config.texts?.ru?.confirmation_greeting_template || '';
|
||||
$('voiceNameRuInlineFollowup').value = config.texts?.ru?.inline_followup_prompt || '';
|
||||
$('voiceNameKzStartPrompt').value = config.texts?.kz?.start_prompt || '';
|
||||
$('voiceNameKzPersonalizedGreeting').value = config.texts?.kz?.personalized_greeting_template || '';
|
||||
$('voiceNameKzConfirmationGreeting').value = config.texts?.kz?.confirmation_greeting_template || '';
|
||||
$('voiceNameKzInlineFollowup').value = config.texts?.kz?.inline_followup_prompt || '';
|
||||
}
|
||||
|
||||
function renderVoiceNameConfigSummary(payload) {
|
||||
const config = payload?.config || payload;
|
||||
const source = payload?.source || 'defaults';
|
||||
const updatedAt = payload?.updated_at || 'не сохранялось';
|
||||
const knownLabels = {
|
||||
trust_and_handoff: 'известное имя сразу принимается и передаётся дальше',
|
||||
confirm_in_downstream: 'известное имя уходит на подтверждение в downstream',
|
||||
ask_on_start: 'известного клиента всё равно спрашиваем на старте',
|
||||
};
|
||||
const unknownLabels = {
|
||||
ask_on_start: 'неизвестного клиента спрашиваем на старте',
|
||||
skip_to_downstream: 'неизвестного клиента сразу передаём в downstream',
|
||||
};
|
||||
const missingLabels = {
|
||||
ask_inline_once: 'если имя не получено, AI спросит inline один раз',
|
||||
do_not_ask: 'если имя не получено, AI inline не спрашивает',
|
||||
};
|
||||
const uncertainLabels = {
|
||||
confirm_then_finalize: 'неуверенное имя нужно подтвердить',
|
||||
finalize_immediately: 'неуверенное имя считается финальным сразу',
|
||||
discard_and_collect: 'неуверенное имя сбрасывается и собирается заново',
|
||||
};
|
||||
$('voiceNameSummary').textContent = [
|
||||
`Сценарий: ${config?.enabled ? 'включён' : 'выключен'}`,
|
||||
`Стартовый вопрос про имя: ${config?.start?.ask_name_on_start ? 'да' : 'нет'}`,
|
||||
`Известный клиент: ${knownLabels[config?.start?.known_customer_behavior] || config?.start?.known_customer_behavior || 'не задано'}`,
|
||||
`Неизвестный клиент: ${unknownLabels[config?.start?.unknown_customer_behavior] || config?.start?.unknown_customer_behavior || 'не задано'}`,
|
||||
`Если имя не получено: ${missingLabels[config?.downstream?.missing_name_behavior] || config?.downstream?.missing_name_behavior || 'не задано'}`,
|
||||
`Если имя неуверенное: ${uncertainLabels[config?.downstream?.uncertain_name_behavior] || config?.downstream?.uncertain_name_behavior || 'не задано'}`,
|
||||
`Финализация по явному имени: ${config?.downstream?.finalize_on_explicit_name ? 'да' : 'нет'}`,
|
||||
`Финализация по подтверждению: ${config?.downstream?.finalize_on_confirmation ? 'да' : 'нет'}`,
|
||||
`Источник конфигурации: ${source}`,
|
||||
`Последнее обновление: ${updatedAt}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
async function loadVoiceNameConfig() {
|
||||
try {
|
||||
const data = await api('ai', 'ai/voice/config/name-collection');
|
||||
state.voiceNameConfig = data;
|
||||
syncVoiceNameConfigFields(data);
|
||||
renderVoiceNameConfigSummary(data);
|
||||
$('voiceNameConfigOutput').textContent = JSON.stringify(data, null, 2);
|
||||
log('Настройки voice name collection загружены', {
|
||||
source: data.source,
|
||||
updated_at: data.updated_at,
|
||||
});
|
||||
} catch (err) {
|
||||
$('voiceNameConfigOutput').textContent = err.message;
|
||||
log('Не удалось загрузить настройки voice name collection', { error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
async function saveVoiceNameConfig() {
|
||||
try {
|
||||
const payload = serializeVoiceNameConfigForm();
|
||||
const data = await api('ai', 'ai/voice/config/name-collection', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
state.voiceNameConfig = data;
|
||||
syncVoiceNameConfigFields(data);
|
||||
renderVoiceNameConfigSummary(data);
|
||||
$('voiceNameConfigOutput').textContent = JSON.stringify(data, null, 2);
|
||||
log('Настройки voice name collection сохранены', {
|
||||
source: data.source,
|
||||
updated_at: data.updated_at,
|
||||
});
|
||||
} catch (err) {
|
||||
$('voiceNameConfigOutput').textContent = err.message;
|
||||
log('Не удалось сохранить настройки voice name collection', { error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
function resetVoiceNameConfigForm() {
|
||||
const snapshot = state.voiceNameConfig;
|
||||
if (!snapshot) {
|
||||
$('voiceNameConfigOutput').textContent = 'Сначала загрузите текущие настройки voice name collection.';
|
||||
return;
|
||||
}
|
||||
syncVoiceNameConfigFields(snapshot);
|
||||
renderVoiceNameConfigSummary(snapshot);
|
||||
$('voiceNameConfigOutput').textContent = JSON.stringify(snapshot, null, 2);
|
||||
log('Форма voice name collection сброшена к текущим настройкам');
|
||||
}
|
||||
|
||||
function parseIvrFlowJson() {
|
||||
ensureDefaultIvrFlowJson();
|
||||
return JSON.parse($('ivrFlowJson').value || '{}');
|
||||
@@ -980,7 +1172,14 @@ function wire() {
|
||||
$('loginBtn').addEventListener('click', login);
|
||||
$('corporateLoginBtn').addEventListener('click', startCorporateLogin);
|
||||
$('refreshBtn').addEventListener('click', async () => {
|
||||
await Promise.all([loadUsers(), loadQueues(), loadIvrFlows(), loadAsteriskStatus(), loadAsteriskEvents()]);
|
||||
await Promise.all([
|
||||
loadUsers(),
|
||||
loadQueues(),
|
||||
loadIvrFlows(),
|
||||
loadVoiceNameConfig(),
|
||||
loadAsteriskStatus(),
|
||||
loadAsteriskEvents(),
|
||||
]);
|
||||
log('Данные админ-консоли обновлены');
|
||||
});
|
||||
$('loadUsersBtn').addEventListener('click', loadUsers);
|
||||
@@ -1002,6 +1201,9 @@ function wire() {
|
||||
$('sendIvrDigitBtn').addEventListener('click', sendIvrDigit);
|
||||
$('loadIvrSessionBtn').addEventListener('click', loadIvrSession);
|
||||
$('previewIvrRouteBtn').addEventListener('click', previewIvrRoute);
|
||||
$('loadVoiceNameConfigBtn').addEventListener('click', loadVoiceNameConfig);
|
||||
$('saveVoiceNameConfigBtn').addEventListener('click', saveVoiceNameConfig);
|
||||
$('resetVoiceNameConfigBtn').addEventListener('click', resetVoiceNameConfigForm);
|
||||
$('loadAsteriskStatusBtn').addEventListener('click', loadAsteriskStatus);
|
||||
$('loadAsteriskEventsBtn').addEventListener('click', loadAsteriskEvents);
|
||||
$('loadAsteriskEventDetailBtn').addEventListener('click', loadAsteriskEventDetail);
|
||||
@@ -1022,7 +1224,16 @@ async function init() {
|
||||
wire();
|
||||
ensureDefaultIvrFlowJson();
|
||||
updateSessionInfo();
|
||||
await Promise.all([checkGateway(), loadOidcConfig(), loadUsers(), loadQueues(), loadIvrFlows(), loadAsteriskStatus(), loadAsteriskEvents()]);
|
||||
await Promise.all([
|
||||
checkGateway(),
|
||||
loadOidcConfig(),
|
||||
loadUsers(),
|
||||
loadQueues(),
|
||||
loadIvrFlows(),
|
||||
loadVoiceNameConfig(),
|
||||
loadAsteriskStatus(),
|
||||
loadAsteriskEvents(),
|
||||
]);
|
||||
log('Админ-консоль готова');
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
<a class="nav-link" href="#queues"><span class="nav-bullet"></span>Очереди</a>
|
||||
<a class="nav-link" href="#routes"><span class="nav-bullet"></span>Маршрутизация</a>
|
||||
<a class="nav-link" href="#ivr"><span class="nav-bullet"></span>IVR</a>
|
||||
<a class="nav-link" href="#voiceNameCollection"><span class="nav-bullet"></span>Voice AI</a>
|
||||
<a class="nav-link" href="#asterisk"><span class="nav-bullet"></span>Asterisk</a>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -250,6 +251,81 @@
|
||||
<pre id="ivrRouteOutput" class="output">Предпросмотр IVR-маршрута появится здесь.</pre>
|
||||
</article>
|
||||
|
||||
<article class="panel reveal" id="voiceNameCollection">
|
||||
<h2>Voice AI: сбор имени</h2>
|
||||
<p class="hint">Глобальная админ-настройка сценария определения имени клиента для voice_start и downstream AI.</p>
|
||||
<pre id="voiceNameSummary" class="output">Сводка по текущим настройкам появится здесь.</pre>
|
||||
|
||||
<div class="inline-form split-two">
|
||||
<label class="checkbox-label"><input id="voiceNameEnabled" type="checkbox" checked /> Сценарий включён</label>
|
||||
<label class="checkbox-label"><input id="voiceNameAskOnStart" type="checkbox" checked /> Спрашивать имя на старте</label>
|
||||
</div>
|
||||
|
||||
<div class="inline-form split-two">
|
||||
<label>
|
||||
Известный клиент
|
||||
<select id="voiceNameKnownCustomerBehavior">
|
||||
<option value="trust_and_handoff">Сразу доверять имени и переводить дальше</option>
|
||||
<option value="confirm_in_downstream">Передавать имя на подтверждение в downstream</option>
|
||||
<option value="ask_on_start">Переспрашивать имя на старте</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Неизвестный клиент
|
||||
<select id="voiceNameUnknownCustomerBehavior">
|
||||
<option value="ask_on_start">Спрашивать имя на старте</option>
|
||||
<option value="skip_to_downstream">Сразу переводить в downstream</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="inline-form split-two">
|
||||
<label>
|
||||
Если имя не получено
|
||||
<select id="voiceNameMissingNameBehavior">
|
||||
<option value="ask_inline_once">Спросить inline один раз</option>
|
||||
<option value="do_not_ask">Не спрашивать inline</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Если имя неуверенное
|
||||
<select id="voiceNameUncertainNameBehavior">
|
||||
<option value="confirm_then_finalize">Подтвердить и только потом финализировать</option>
|
||||
<option value="finalize_immediately">Считать имя финальным сразу</option>
|
||||
<option value="discard_and_collect">Сбросить кандидат и собирать заново</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="inline-form split-two">
|
||||
<label class="checkbox-label"><input id="voiceNameFinalizeOnExplicitName" type="checkbox" checked /> Финализировать имя при явном ответе</label>
|
||||
<label class="checkbox-label"><input id="voiceNameFinalizeOnConfirmation" type="checkbox" checked /> Финализировать имя после подтверждения</label>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label for="voiceNameRuStartPrompt">Тексты RU</label>
|
||||
<textarea id="voiceNameRuStartPrompt" rows="2" class="json-textarea" placeholder="Стартовый prompt"></textarea>
|
||||
<textarea id="voiceNameRuPersonalizedGreeting" rows="3" class="json-textarea" placeholder="Персонализированное приветствие с {name}"></textarea>
|
||||
<textarea id="voiceNameRuConfirmationGreeting" rows="3" class="json-textarea" placeholder="Подтверждение имени с {name}"></textarea>
|
||||
<textarea id="voiceNameRuInlineFollowup" rows="2" class="json-textarea" placeholder="Inline follow-up"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label for="voiceNameKzStartPrompt">Тексты KZ</label>
|
||||
<textarea id="voiceNameKzStartPrompt" rows="2" class="json-textarea" placeholder="Стартовый prompt"></textarea>
|
||||
<textarea id="voiceNameKzPersonalizedGreeting" rows="3" class="json-textarea" placeholder="Персонализированное приветствие с {name}"></textarea>
|
||||
<textarea id="voiceNameKzConfirmationGreeting" rows="3" class="json-textarea" placeholder="Подтверждение имени с {name}"></textarea>
|
||||
<textarea id="voiceNameKzInlineFollowup" rows="2" class="json-textarea" placeholder="Inline follow-up"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="actions compact-actions">
|
||||
<button id="loadVoiceNameConfigBtn" class="btn ghost">Загрузить настройки</button>
|
||||
<button id="saveVoiceNameConfigBtn" class="btn">Сохранить настройки</button>
|
||||
<button id="resetVoiceNameConfigBtn" class="btn ghost">Сбросить форму к текущим</button>
|
||||
</div>
|
||||
<pre id="voiceNameConfigOutput" class="output">Raw payload voice name collection появится здесь.</pre>
|
||||
</article>
|
||||
|
||||
<article class="panel reveal" id="asterisk">
|
||||
<h2>Мост Asterisk</h2>
|
||||
<p class="hint">Диагностика AMI-подключения, пересланных bridge-событий и ручного повтора.</p>
|
||||
|
||||
Reference in New Issue
Block a user