feat(ring-groups): allow direct voicemail redirection on no-answer failover

Add vm-<ext> destination support in UI destination picker, API validation, and [app-voicemail] Asterisk dialplan context. Refs #2
This commit is contained in:
sorcier committed 2026-11-11 12:40:28 +01:00
1 parent 3ef7ed0010
commit d229aaf816
3 files changed
+64 -1

No files matched your search

+8
View File
@@ -544,6 +544,14 @@ try {
if ($name === '') {
throw new InvalidArgumentException("Le nom du groupe d'appel est obligatoire.");
}
if (str_starts_with($failover, 'vm-')) {
$vmTarget = substr($failover, 3);
$vmStmt = $db->prepare('SELECT voicemail_enabled FROM extensions WHERE extension = :ext AND enabled = 1');
$vmStmt->execute([':ext' => $vmTarget]);
if (!(int)$vmStmt->fetchColumn()) {
throw new InvalidArgumentException("Le répondeur du poste {$vmTarget} n'existe pas ou n'est pas activé.");
}
}
if ($id) {
$stmt = $db->prepare('
+21 -1
View File
@@ -1234,6 +1234,9 @@ function renderGroupsTable() {
const badges = members.length
? members.map(m => `<span class="px-2 py-0.5 rounded bg-indigo-500/20 text-indigo-200 border border-indigo-500/30 font-mono text-xs font-semibold">${escapeHtml(m)}</span>`).join(' ')
: '<span class="text-slate-500 text-xs italic">Aucun membre</span>';
const failoverLabel = String(grp.failover_dest || '').startsWith('vm-')
? `<span class="px-2 py-0.5 rounded bg-violet-500/20 text-violet-200 border border-violet-500/30 font-mono text-xs font-semibold">📬 Répondeur ${escapeHtml(String(grp.failover_dest).slice(3))}</span>`
: `<code class="text-slate-300">${escapeHtml(grp.failover_dest)}</code>`;
return `
<tr class="hover:bg-anthracite-750/60 transition">
@@ -1241,7 +1244,7 @@ function renderGroupsTable() {
<td class="py-3.5 px-4 font-bold text-white">${escapeHtml(grp.name)}</td>
<td class="py-3.5 px-4"><code class="text-xs text-cyan-300 font-mono">${escapeHtml(grp.strategy)}</code></td>
<td class="py-3.5 px-4"><div class="flex flex-wrap gap-1">${badges}</div></td>
<td class="py-3.5 px-4 text-xs text-slate-200">${Number(grp.ring_time)}s → <code class="text-slate-300">${escapeHtml(grp.failover_dest)}</code></td>
<td class="py-3.5 px-4 text-xs text-slate-200">${Number(grp.ring_time)}s → ${failoverLabel}</td>
<td class="py-3.5 px-4 text-right space-x-2">
<button type="button" onclick="editGroup(${Number(grp.id)})" class="text-xs font-semibold text-indigo-300 hover:text-indigo-200">Modifier</button>
<button type="button" onclick="deleteGroup(${Number(grp.id)})" class="text-xs font-semibold text-red-400 hover:text-red-300">Supprimer</button>
@@ -1893,6 +1896,21 @@ function buildDestinationOptionsHtml(selectedValue = 'ext-101') {
html += `</optgroup>`;
}
const vmExts = exts.filter(e => Number(e.voicemail_enabled) === 1);
const hasMissingVm = normVal.startsWith('vm-') && !vmExts.some(e => `vm-${e.extension}` === normVal);
if (vmExts.length > 0 || hasMissingVm) {
html += `<optgroup label="📬 Messagerie Vocale Directe (Répondeur)">`;
if (hasMissingVm) {
const missingExt = normVal.slice(3);
html += `<option value="${escapeHtml(normVal)}" selected>Répondeur: ${escapeHtml(missingExt)} (messagerie désactivée)</option>`;
}
vmExts.forEach(e => {
const val = `vm-${e.extension}`;
html += `<option value="${escapeHtml(val)}" ${normVal === val ? 'selected' : ''}>Répondeur: ${escapeHtml(e.extension)} — ${escapeHtml(e.name)}</option>`;
});
html += `</optgroup>`;
}
// Piège à Spammeurs / Musique d'attente en boucle continue (MoH)
const mohSounds = sounds.filter(s => s.usage_type === 'moh');
html += `<optgroup label="🎵 Piège à Spammeurs (Musique d'attente en boucle infinie)">`;
@@ -1948,6 +1966,8 @@ function styleDestinationSelect(selectEl) {
selectEl.className = 'trunk-did-dest w-full px-2.5 py-1.5 rounded-lg bg-fuchsia-500/15 border border-fuchsia-500/40 text-xs text-fuchsia-300 font-semibold';
} else if (val.startsWith('grp-')) {
selectEl.className = 'trunk-did-dest w-full px-2.5 py-1.5 rounded-lg bg-cyan-500/10 border border-cyan-500/40 text-xs text-cyan-300 font-semibold';
} else if (val.startsWith('vm-')) {
selectEl.className = 'trunk-did-dest w-full px-2.5 py-1.5 rounded-lg bg-violet-500/15 border border-violet-500/40 text-xs text-violet-300 font-semibold';
} else {
selectEl.className = 'trunk-did-dest w-full px-2.5 py-1.5 rounded-lg bg-emerald-500/10 border border-emerald-500/40 text-xs text-emerald-300 font-semibold';
}
+35
View File
@@ -1686,6 +1686,30 @@ class ConfigGenerator
$lines[] = " same => n,Hangup()";
$lines[] = "";
// 5b. Contexte dédié Dépôt Direct sur Boîte Vocale (Redirection sans sonnerie)
$lines[] = "; ===========================================================================";
$lines[] = "; DÉPÔT DIRECT SUR BOÎTE VOCALE (REDIRECTION / DÉBORDEMENT)";
$lines[] = "; ===========================================================================";
$lines[] = "[app-voicemail]";
foreach ($extensions as $ext) {
$num = trim((string)$ext['extension']);
if (empty($ext['voicemail_enabled'])) {
continue;
}
$lines[] = "exten => {$num},1,NoOp(Redirection directe vers messagerie vocale du poste {$num})";
$lines[] = " same => n,Set(CHANNEL(language)={$sysLang})";
$lines[] = " same => n,Answer()";
$lines[] = " same => n,Wait(0.5)";
$lines[] = " same => n,VoiceMail({$num}@default,u)";
$lines[] = " same => n,Hangup()";
}
$lines[] = "exten => _X.,1,NoOp(Boite vocale inexistante ou desactivee pour \${EXTEN})";
$lines[] = " same => n,Set(CHANNEL(language)={$sysLang})";
$lines[] = " same => n,Answer()";
$lines[] = " same => n,Playback(vm-incorrect-mailbox)";
$lines[] = " same => n,Hangup()";
$lines[] = "";
return implode("\n", $lines) . "\n";
}
@@ -2328,6 +2352,17 @@ EOF;
if (preg_match('/^\d+$/', $dest)) {
return "Goto(from-internal,{$dest},1)";
}
if (str_starts_with($dest, 'vm-')) {
$vmExt = preg_replace('/[^0-9A-Za-z_-]/', '', substr($dest, 3));
if ($vmExt !== '') {
$stmt = $this->db->prepare('SELECT voicemail_enabled FROM extensions WHERE extension = :ext AND enabled = 1');
$stmt->execute([':ext' => $vmExt]);
if ($stmt->fetchColumn()) {
return "Goto(app-voicemail,{$vmExt},1)";
}
}
return 'Goto(app-terminate,hangup,1)';
}
if (str_starts_with($dest, 'page-')) {
$pageExt = substr($dest, 5);
return "Goto(from-internal,{$pageExt},1)";