Files
NexusPBX/src/ConfigGenerator.php
T

2390 lines
120 KiB
PHP

<?php
declare(strict_types=1);
namespace App;
use PDO;
use RuntimeException;
require_once __DIR__ . '/SystemChecker.php';
class ConfigGenerator
{
public const PJSIP_CONF_PATH = '/etc/asterisk/pjsip.custom.conf';
public const EXTENSIONS_CONF_PATH = '/etc/asterisk/extensions.custom.conf';
public const MOH_CONF_PATH = '/etc/asterisk/musiconhold.custom.conf';
public const RTP_CONF_PATH = '/etc/asterisk/rtp.conf';
public const FEATURES_CONF_PATH = '/etc/asterisk/features.conf';
public const VOICEMAIL_CONF_PATH = '/etc/asterisk/voicemail.custom.conf';
public const PJSIP_NOTIFY_CONF_PATH = '/etc/asterisk/pjsip_notify.conf';
public const BACKUP_DIR = __DIR__ . '/../storage/backups';
public function __construct(private PDO $db) {}
/**
* Helper statique pour générer et appliquer toutes les configurations Asterisk.
*/
public static function generateAndApply(PDO $db): array
{
$generator = new self($db);
return $generator->writeAllConfigs();
}
/**
* Génère le contenu complet de /etc/asterisk/pjsip.custom.conf
*/
public function generatePjsipConf(): string
{
$settings = $this->db->query('SELECT * FROM settings WHERE id = 1')->fetch() ?: [];
$extensions = $this->db->query('SELECT * FROM extensions WHERE enabled = 1 ORDER BY extension ASC')->fetchAll();
$trunks = $this->db->query('SELECT * FROM trunks WHERE enabled = 1 ORDER BY id ASC')->fetchAll();
$mohSounds = $this->db->query("SELECT id, slug, name FROM sounds WHERE usage_type = 'moh'")->fetchAll();
$mohMap = [];
foreach ($mohSounds as $ms) {
$mohMap[(int)$ms['id']] = $ms;
}
$date = date('Y-m-d H:i:s');
$udpPort = (int)($settings['udp_port'] ?? 5060);
$tlsPort = (int)($settings['tls_port'] ?? 5061);
$extUdpPort = (int)($settings['external_udp_port'] ?? 0);
$extTlsPort = (int)($settings['external_tls_port'] ?? 0);
$leDomain = trim((string)($settings['letsencrypt_domain'] ?? ''));
$extHost = trim((string)($settings['external_ip'] ?? ''));
// Multi-réseaux locaux (Local Networks)
$localNetworks = json_decode((string)($settings['local_networks_json'] ?? '[]'), true) ?: [];
if (empty($localNetworks)) {
$rawNet = trim((string)($settings['local_net'] ?? ''));
if ($rawNet !== '') {
$localNetworks = [$rawNet];
}
}
$lines = [];
$lines[] = "; ===========================================================================";
$lines[] = "; FICHIER GÉNÉRÉ AUTOMATIQUEMENT PAR NEXUSPBX ($date)";
$lines[] = "; NE PAS MODIFIER MANUELLEMENT - SOURCE UNIQUE DE VÉRITÉ : SQLITE";
$lines[] = "; ===========================================================================";
$lines[] = "";
// 1. Transport UDP (Postes internes LAN & Trunks standards)
$lines[] = "; --- TRANSPORT UDP (LAN / INTERNE) ---";
$lines[] = "[transport-udp]";
$lines[] = "type = transport";
$lines[] = "protocol = udp";
$lines[] = "bind = 0.0.0.0:{$udpPort}";
$lines[] = "symmetric_transport = yes";
foreach ($localNetworks as $net) {
$net = trim((string)$net);
if ($net !== '') {
$lines[] = "local_net = {$net}";
}
}
if ($extHost !== '') {
$lines[] = "external_media_address = {$extHost}";
$lines[] = "external_signaling_address = {$extHost}";
if ($extUdpPort > 0) {
$lines[] = "external_signaling_port = {$extUdpPort}";
}
}
$lines[] = "allow_reload = no";
$lines[] = "";
// 1b. Transport TCP (Trunks opérateurs TCP)
$lines[] = "; --- TRANSPORT TCP (OPÉRATEURS / TRUNKS TCP) ---";
$lines[] = "[transport-tcp]";
$lines[] = "type = transport";
$lines[] = "protocol = tcp";
$lines[] = "bind = 0.0.0.0:{$udpPort}";
$lines[] = "symmetric_transport = yes";
foreach ($localNetworks as $net) {
$net = trim((string)$net);
if ($net !== '') {
$lines[] = "local_net = {$net}";
}
}
if ($extHost !== '') {
$lines[] = "external_media_address = {$extHost}";
$lines[] = "external_signaling_address = {$extHost}";
if ($extUdpPort > 0) {
$lines[] = "external_signaling_port = {$extUdpPort}";
}
}
$lines[] = "allow_reload = no";
$lines[] = "";
// 2. Transport TLS (Certificat sélectionné dans le Gestionnaire SSL / TLS)
$activeCert = null;
try {
$stmt = $this->db->query("SELECT * FROM certificates WHERE is_default = 1 LIMIT 1");
$activeCert = $stmt->fetch();
} catch (\Throwable) {
$activeCert = null;
}
$certPath = "/etc/asterisk/keys/asterisk.crt";
$keyPath = "/etc/asterisk/keys/asterisk.key";
$caPath = "";
if ($activeCert) {
$candidateCert = !empty($activeCert['fullchain_path']) && file_exists($activeCert['fullchain_path'])
? $activeCert['fullchain_path']
: ($activeCert['cert_path'] ?? '');
$candidateKey = $activeCert['key_path'] ?? '';
$candidateCa = $activeCert['chain_path'] ?? '';
if (file_exists($candidateCert) && file_exists($candidateKey)) {
$certPath = $candidateCert;
$keyPath = $candidateKey;
if (!empty($candidateCa) && file_exists($candidateCa)) {
$caPath = $candidateCa;
}
}
} elseif ($leDomain !== '' && file_exists("/etc/letsencrypt/live/{$leDomain}/fullchain.pem")) {
$certPath = "/etc/letsencrypt/live/{$leDomain}/fullchain.pem";
$keyPath = "/etc/letsencrypt/live/{$leDomain}/privkey.pem";
}
$hasTlsCerts = (file_exists($certPath) && file_exists($keyPath));
$lines[] = "; --- TRANSPORT TLS (SIGNALISATION SIP SÉCURISÉE PORT {$tlsPort}) ---";
$verifyClient = (!empty($settings['tls_verify_client']) && (int)$settings['tls_verify_client'] === 1) ? 'yes' : 'no';
$lines[] = "[transport-tls]";
$lines[] = "type = transport";
$lines[] = "protocol = tls";
$lines[] = "bind = 0.0.0.0:{$tlsPort}";
$lines[] = "symmetric_transport = yes";
$lines[] = "cert_file = {$certPath}";
$lines[] = "priv_key_file = {$keyPath}";
// En Asterisk PJSIP, ca_list_file ne doit être renseigné QUE si la vérification
// des certificats clients (mTLS) est requise. Si verify_client=no, renseigner ca_list_file
// force OpenSSL à envoyer une requête CertificateRequest au téléphone, ce qui fait échouer
// le handshake TLS sur la plupart des postes IP (Yealink, Fanvil, Grandstream...).
if ($caPath !== '' && $verifyClient === 'yes') {
$lines[] = "ca_list_file = {$caPath}";
}
// method = sslv23 permet la négociation automatique TLSv1.2 et TLSv1.3 selon le client
$lines[] = "method = sslv23";
$lines[] = "verify_client = {$verifyClient}";
$lines[] = "verify_server = no";
foreach ($localNetworks as $net) {
$net = trim((string)$net);
if ($net !== '') {
$lines[] = "local_net = {$net}";
}
}
if ($extHost !== '') {
$lines[] = "external_media_address = {$extHost}";
$lines[] = "external_signaling_address = {$extHost}";
if ($extTlsPort > 0) {
$lines[] = "external_signaling_port = {$extTlsPort}";
}
}
$lines[] = "allow_reload = no";
$lines[] = "";
// 3. Extensions (Postes téléphoniques hybrides UDP vs TLS+SRTP)
$lines[] = "; ===========================================================================";
$lines[] = "; POSTES TÉLÉPHONIQUES (EXTENSIONS)";
$lines[] = "; ===========================================================================";
// Détection de l'état du Cluster Haute Disponibilité (HA)
$clusterSt = [];
try {
$clusterSt = $this->db->query('SELECT enabled, blf_expiry FROM cluster_settings WHERE id = 1')->fetch(PDO::FETCH_ASSOC) ?: [];
} catch (\Throwable $e) {}
$isHaActive = !empty($clusterSt['enabled']);
// En mode Haute Disponibilité, on plafonne l'expiration des AOR/souscriptions sur la valeur configurée (par défaut 120s)
// afin que les téléphones se réabonnent très vite en cas de bascule. Sans HA, on conserve le standard 1800s (30 min).
$regExpiration = $isHaActive ? max(30, min(3600, (int)($clusterSt['blf_expiry'] ?? 120))) : 1800;
$subMinExpiry = $isHaActive ? 30 : 0;
foreach ($extensions as $ext) {
$num = trim((string)$ext['extension']);
$name = str_replace('"', '', trim((string)$ext['name']));
$secret = trim((string)$ext['secret']);
$wantsTls = ($ext['transport'] === 'tls');
$transport = ($wantsTls && $hasTlsCerts) ? 'transport-tls' : 'transport-udp';
$encMode = (int)($ext['encryption'] ?? 0);
if ($wantsTls && $encMode === 0) {
$encMode = 1; // Sécurisation par défaut si transport TLS
}
$codecs = array_filter(array_map('trim', explode(',', (string)$ext['codecs'])));
$maxContacts = max(1, (int)$ext['max_contacts']);
$modeLabel = ($encMode === 2) ? 'SRTP FORCÉ' : (($encMode === 1) ? 'SRTP OPTIONNEL' : 'RTP CLAIR');
$sysLang = trim((string)($settings['system_language'] ?? 'fr')) ?: 'fr';
$hasVm = !empty($ext['voicemail_enabled']);
$lines[] = "; --- Poste {$num} : {$name} [{$modeLabel}] ---";
$lines[] = "[{$num}]";
$lines[] = "type = endpoint";
$lines[] = "context = from-internal";
$lines[] = "subscribe_context = from-internal";
$lines[] = "auth = {$num}-auth";
$lines[] = "aors = {$num}";
$lines[] = "callerid = \"{$name}\" <{$num}>";
$lines[] = "disallow = all";
foreach ($codecs as $codec) {
$lines[] = "allow = {$codec}";
}
$lines[] = "dtmf_mode = rfc4733";
$lines[] = "rtp_symmetric = yes";
$lines[] = "force_rport = yes";
$lines[] = "rewrite_contact = yes";
$lines[] = "direct_media = no";
// Détection automatique et purge des flux RTP fantômes / morts (ex: basculement HA, coupure réseau)
$rtpTimeout = max(5, (int)($settings['rtp_timeout'] ?? 30));
$rtpHoldTimeout = max(0, (int)($settings['rtp_timeout_hold'] ?? 0));
$rtpKeepalive = max(0, (int)($settings['rtp_keepalive'] ?? 15));
$lines[] = "rtp_timeout = {$rtpTimeout}";
$lines[] = "rtp_timeout_hold = {$rtpHoldTimeout}";
if ($rtpKeepalive > 0) {
$lines[] = "rtp_keepalive = {$rtpKeepalive}";
}
$lines[] = "timers = yes";
$lines[] = "timers_min_se = 90";
$lines[] = "timers_sess_expires = 1800";
if ($wantsTls && $hasTlsCerts) {
// Verrouillage strict du transport TLS : refuse le repli sur UDP
$lines[] = "transport = transport-tls";
$lines[] = "media_use_received_transport = no";
} else {
$lines[] = "media_use_received_transport = yes";
}
$lines[] = "named_call_group = default";
$lines[] = "named_pickup_group = default";
$lines[] = "notify_early_inuse_ringing = yes";
if ($encMode === 2) {
$lines[] = "media_encryption = sdes";
$lines[] = "media_encryption_optimistic = no";
$lines[] = "srtp_tag_32 = yes";
} elseif ($encMode === 1) {
$lines[] = "media_encryption = sdes";
$lines[] = "media_encryption_optimistic = yes";
$lines[] = "srtp_tag_32 = yes";
} else {
$lines[] = "media_encryption = no";
}
$lines[] = "language = {$sysLang}";
if ($hasVm) {
$lines[] = "mailboxes = {$num}@default";
$lines[] = "voicemail_extension = *97";
}
$lines[] = "moh_suggest = default";
$lines[] = "";
$lines[] = "[{$num}-auth]";
$lines[] = "type = auth";
$lines[] = "auth_type = userpass";
$lines[] = "username = {$num}";
$lines[] = "password = {$secret}";
$lines[] = "";
$lines[] = "[{$num}]";
$lines[] = "type = aor";
$lines[] = "max_contacts = {$maxContacts}";
$lines[] = "remove_existing = yes";
$lines[] = "default_expiration = {$regExpiration}";
$lines[] = "maximum_expiration = {$regExpiration}";
$lines[] = "minimum_expiration = " . ($isHaActive ? 30 : 60);
$lines[] = "qualify_frequency = 30";
if ($hasVm) {
$lines[] = "mailboxes = {$num}@default";
}
$lines[] = "";
}
// 4. Trunks SIP (Opérateurs entrants / sortants - Configuration PJSIP complète)
if (!empty($trunks)) {
$lines[] = "; ===========================================================================";
$lines[] = "; TRUNKS OPÉRATEURS SIP (CONFIGURATION AVANCÉE)";
$lines[] = "; ===========================================================================";
foreach ($trunks as $trunk) {
$slug = $this->slugify($trunk['name']);
$host = trim((string)$trunk['host']);
$port = (int)($trunk['port'] ?? 5060);
$outboundProxy = trim((string)($trunk['outbound_proxy'] ?? ''));
$authMode = trim((string)($trunk['auth_mode'] ?? 'userpass'));
$username = trim((string)($trunk['username'] ?? ''));
$authUsername = trim((string)($trunk['auth_username'] ?? '')) ?: $username;
$secret = trim((string)($trunk['secret'] ?? ''));
$trProto = strtolower(trim((string)($trunk['transport'] ?? 'udp')));
if ($trProto === 'tls' && $hasTlsCerts) {
$transport = 'transport-tls';
} elseif ($trProto === 'tcp') {
$transport = 'transport-tcp';
} else {
$transport = 'transport-udp';
}
$context = trim((string)($trunk['context'] ?? 'from-external')) ?: 'from-external';
$fromUser = trim((string)($trunk['from_user'] ?? ''));
$fromDomain = trim((string)($trunk['from_domain'] ?? '')) ?: $host;
$callerId = trim((string)($trunk['outbound_callerid'] ?? ''));
$sendPai = ((int)($trunk['send_pai'] ?? 1) === 1) ? 'yes' : 'no';
$sendRpid = ((int)($trunk['send_rpid'] ?? 0) === 1) ? 'yes' : 'no';
$sendDiversion = ((int)($trunk['send_diversion'] ?? 1) === 1) ? 'yes' : 'no';
$trustInbound = ((int)($trunk['trust_id_inbound'] ?? 1) === 1) ? 'yes' : 'no';
$trustOutbound = (!isset($trunk['trust_id_outbound']) || (int)$trunk['trust_id_outbound'] === 1) ? 'yes' : 'no';
$dtmfMode = trim((string)($trunk['dtmf_mode'] ?? 'rfc4733')) ?: 'rfc4733';
$mediaEnc = trim((string)($trunk['media_encryption'] ?? 'no')) ?: 'no';
$natSym = ((int)($trunk['nat_symmetric'] ?? 1) === 1) ? 'yes' : 'no';
$directMedia = ((int)($trunk['direct_media'] ?? 0) === 1) ? 'yes' : 'no';
$qualifyFreq = max(0, (int)($trunk['qualify_frequency'] ?? 60));
$timers = trim((string)($trunk['timers'] ?? 'yes')) ?: 'yes';
$language = trim((string)($trunk['language'] ?? 'fr')) ?: 'fr';
$rtpKeepalive = max(0, (int)($trunk['rtp_keepalive'] ?? 15));
$t38Udptl = trim((string)($trunk['t38_udptl'] ?? 'no')) ?: 'no';
$faxDetect = ((int)($trunk['fax_detect'] ?? 0) === 1) ? 'yes' : 'no';
$inbandProg = ((int)($trunk['inband_progress'] ?? 0) === 1) ? 'yes' : 'no';
$codecs = array_filter(array_map('trim', explode(',', (string)($trunk['codecs'] ?? 'alaw,ulaw'))));
$hasAuth = ($authMode !== 'ip' && $username !== '' && $secret !== '');
$regMode = trim((string)($trunk['registration_mode'] ?? ''));
if (!in_array($regMode, ['send', 'receive', 'none'], true)) {
$regMode = ((int)($trunk['register'] ?? 1) === 1) ? 'send' : 'none';
}
$matchIpsRaw = trim((string)($trunk['match_ips'] ?? ''));
$epName = ($regMode === 'receive' && $username !== '') ? $username : "trunk-{$slug}";
$authName = "{$epName}-auth";
$aorName = $epName;
$hostPortDisplay = $host !== '' ? "{$host}:{$port}" : "Passerelle Réception Dynamique";
$lines[] = "; --- Trunk : {$trunk['name']} ({$hostPortDisplay} [{$trProto}] - Mode: {$regMode}) ---";
$lines[] = "[{$epName}]";
$lines[] = "type = endpoint";
$lines[] = "context = {$context}";
$lines[] = "transport = {$transport}";
if ($regMode === 'receive') {
// Passerelle locale (ex: Grandstream HT813, Cisco SPA, ATA, Ligne Box) s'enregistrant sur Asterisk
$lines[] = "auth = {$authName}";
$lines[] = "outbound_auth = {$authName}";
$lines[] = "aors = {$aorName}";
if ($host !== '' || $matchIpsRaw !== '') {
$lines[] = "identify_by = auth_username,username,ip";
} else {
$lines[] = "identify_by = auth_username,username";
}
} else {
if ($hasAuth) {
$lines[] = "outbound_auth = {$authName}";
}
$lines[] = "aors = {$aorName}";
}
if ($outboundProxy !== '') {
$lines[] = "outbound_proxy = {$outboundProxy}";
}
// Note : Ne JAMAIS positionner la directive "callerid" dans la section endpoint d'un Trunk PJSIP.
// Dans Asterisk, "callerid" sur un endpoint sert de valeur par défaut pour les appels ENTRANTS,
// ce qui écraserait le numéro de l'appelant externe (ex: mobile 06...).
// Le CallerID sortant du Trunk (outbound_callerid) est appliqué uniquement dans le Dialplan sortant.
$lines[] = "disallow = all";
foreach ($codecs as $codec) {
$lines[] = "allow = {$codec}";
}
$lines[] = "dtmf_mode = {$dtmfMode}";
$lines[] = "rtp_symmetric = {$natSym}";
$lines[] = "force_rport = {$natSym}";
$lines[] = "rewrite_contact = {$natSym}";
$lines[] = "direct_media = {$directMedia}";
$lines[] = "media_encryption = {$mediaEnc}";
$lines[] = "send_pai = {$sendPai}";
$lines[] = "send_rpid = {$sendRpid}";
$lines[] = "send_diversion = {$sendDiversion}";
$lines[] = "trust_id_inbound = {$trustInbound}";
$lines[] = "trust_id_outbound = {$trustOutbound}";
$lines[] = "timers = {$timers}";
$rtpTimeout = max(5, (int)($settings['rtp_timeout'] ?? 30));
$rtpHoldTimeout = max(0, (int)($settings['rtp_timeout_hold'] ?? 0));
$lines[] = "rtp_timeout = {$rtpTimeout}";
$lines[] = "rtp_timeout_hold = {$rtpHoldTimeout}";
if ($rtpKeepalive > 0) {
$lines[] = "rtp_keepalive = {$rtpKeepalive}";
}
if ($t38Udptl !== 'no') {
$lines[] = "t38_udptl = {$t38Udptl}";
$lines[] = "t38_udptl_ec = redundancy";
}
if ($faxDetect === 'yes') {
$lines[] = "fax_detect = yes";
}
if ($inbandProg === 'yes') {
$lines[] = "inband_progress = yes";
}
if ($fromUser !== '') {
$lines[] = "from_user = {$fromUser}";
}
if ($fromDomain !== '') {
$lines[] = "from_domain = {$fromDomain}";
}
$lines[] = "language = {$language}";
$mohSoundId = (int)($trunk['moh_sound_id'] ?? 0);
$trunkMohClass = ($mohSoundId > 0 && isset($mohMap[$mohSoundId]))
? 'moh-sound-' . $mohMap[$mohSoundId]['slug']
: 'default';
$lines[] = "moh_suggest = {$trunkMohClass}";
// Directives PJSIP additionnelles personnalisées sur l'endpoint
$customParams = trim((string)($trunk['custom_endpoint_params'] ?? ''));
if ($customParams !== '') {
foreach (preg_split('/\r?\n/', $customParams) ?: [] as $cpLine) {
$cpLine = trim($cpLine);
if ($cpLine !== '' && !str_starts_with($cpLine, '[') && str_contains($cpLine, '=')) {
$lines[] = $cpLine;
}
}
}
$lines[] = "";
// Section AOR du Trunk
$lines[] = "[{$aorName}]";
$lines[] = "type = aor";
if ($regMode === 'receive') {
// En mode réception, l'IP/port de la passerelle est découvert dynamiquement lors du REGISTER
$lines[] = "max_contacts = 1";
$lines[] = "remove_existing = yes";
} else {
$lines[] = "contact = sip:{$host}:{$port}";
}
$lines[] = "qualify_frequency = {$qualifyFreq}";
if ($outboundProxy !== '') {
$lines[] = "outbound_proxy = {$outboundProxy}";
}
$customAor = trim((string)($trunk['custom_aor_params'] ?? ''));
if ($customAor !== '') {
foreach (preg_split('/\r?\n/', $customAor) ?: [] as $caLine) {
$caLine = trim($caLine);
if ($caLine !== '' && !str_starts_with($caLine, '[') && str_contains($caLine, '=')) {
$lines[] = $caLine;
}
}
}
$lines[] = "";
// Section Auth du Trunk
if ($hasAuth || $regMode === 'receive') {
$lines[] = "[{$authName}]";
$lines[] = "type = auth";
$lines[] = "auth_type = userpass";
$lines[] = "username = {$authUsername}";
$lines[] = "password = {$secret}";
$lines[] = "";
}
// Section Registration du Trunk (uniquement en mode émission 'send')
if ($regMode === 'send' && (int)($trunk['register'] ?? 1) === 1 && $hasAuth) {
$serverUri = trim((string)($trunk['server_uri'] ?? '')) ?: "sip:{$host}:{$port}";
$clientUri = trim((string)($trunk['client_uri'] ?? '')) ?: "sip:{$username}@{$fromDomain}:{$port}";
$contactUser = trim((string)($trunk['contact_user'] ?? ''));
$retryInterval = max(10, (int)($trunk['retry_interval'] ?? 60));
$maxRetries = max(0, (int)($trunk['max_retries'] ?? 10000));
$forbiddenInt = max(0, (int)($trunk['forbidden_retry_interval'] ?? 600));
$fatalInt = max(0, (int)($trunk['fatal_retry_interval'] ?? 0));
$expiration = max(60, (int)($trunk['expiration'] ?? 3600));
$lineSupport = ((int)($trunk['line_support'] ?? 1) === 1);
$lines[] = "[{$epName}-reg]";
$lines[] = "type = registration";
$lines[] = "transport = {$transport}";
$lines[] = "outbound_auth = {$authName}";
$lines[] = "server_uri = {$serverUri}";
$lines[] = "client_uri = {$clientUri}";
if ($contactUser !== '') {
$lines[] = "contact_user = {$contactUser}";
}
if ($outboundProxy !== '') {
$lines[] = "outbound_proxy = {$outboundProxy}";
}
$lines[] = "retry_interval = {$retryInterval}";
$lines[] = "max_retries = {$maxRetries}";
$lines[] = "forbidden_retry_interval = {$forbiddenInt}";
$lines[] = "fatal_retry_interval = {$fatalInt}";
$lines[] = "expiration = {$expiration}";
$lines[] = "auth_rejection_permanent = no";
$lines[] = "max_random_initial_delay = 0";
if ($lineSupport) {
$lines[] = "line = yes";
$lines[] = "endpoint = {$epName}";
}
$customReg = trim((string)($trunk['custom_reg_params'] ?? ''));
if ($customReg !== '') {
foreach (preg_split('/\r?\n/', $customReg) ?: [] as $crLine) {
$crLine = trim($crLine);
if ($crLine !== '' && !str_starts_with($crLine, '[') && str_contains($crLine, '=')) {
$lines[] = $crLine;
}
}
}
$lines[] = "";
}
// Section Identify (Reconnaissance d'IPs / sous-réseaux opérateur ou passerelle)
if ($matchIpsRaw !== '' || ($regMode !== 'receive' && $host !== '') || ($regMode === 'receive' && $host !== '')) {
$matchList = $matchIpsRaw !== ''
? array_filter(array_map('trim', preg_split('/[\s,;]+/', $matchIpsRaw) ?: []))
: ($host !== '' ? [$host] : []);
if (!empty($matchList)) {
$lines[] = "[{$epName}-identify]";
$lines[] = "type = identify";
$lines[] = "endpoint = {$epName}";
foreach ($matchList as $mIp) {
$lines[] = "match = {$mIp}";
}
$lines[] = "";
}
}
}
}
return implode("\n", $lines) . "\n";
}
/**
* Génère le contenu complet de /etc/asterisk/extensions.custom.conf (Dialplan)
*/
public function generateExtensionsConf(): string
{
$settings = $this->db->query('SELECT * FROM settings WHERE id = 1')->fetch() ?: [];
$sysLang = trim((string)($settings['system_language'] ?? 'fr')) ?: 'fr';
$extensions = $this->db->query('SELECT * FROM extensions WHERE enabled = 1 ORDER BY extension ASC')->fetchAll();
$groups = $this->db->query('SELECT * FROM groups ORDER BY extension ASC')->fetchAll();
$pagingGroups = $this->db->query('SELECT * FROM paging_groups WHERE enabled = 1 ORDER BY CAST(extension AS INTEGER) ASC')->fetchAll();
$extGroups = $this->db->query('SELECT * FROM extension_groups ORDER BY id ASC')->fetchAll();
$trunks = $this->db->query('SELECT * FROM trunks WHERE enabled = 1 ORDER BY id ASC')->fetchAll();
$ivrs = $this->db->query('SELECT * FROM ivrs ORDER BY name ASC')->fetchAll();
$outboundRoutes = $this->db->query('SELECT * FROM outbound_routes WHERE enabled = 1 ORDER BY priority ASC, id ASC')->fetchAll();
$mohSounds = $this->db->query("SELECT id, slug, name FROM sounds WHERE usage_type = 'moh'")->fetchAll();
$mohSoundsMap = [];
foreach ($mohSounds as $ms) {
$mohSoundsMap[(int)$ms['id']] = $ms;
}
$date = date('Y-m-d H:i:s');
$lines = [];
$lines[] = "; ===========================================================================";
$lines[] = "; DIALPLAN GÉNÉRÉ AUTOMATIQUEMENT PAR NEXUSPBX ($date)";
$lines[] = "; NE PAS MODIFIER MANUELLEMENT - SOURCE UNIQUE DE VÉRITÉ : SQLITE";
$lines[] = "; ===========================================================================";
$lines[] = "";
// 1. Contexte [from-internal]
$lines[] = "[from-internal]";
// Récupération des codes de fonctions actifs depuis la base SQLite
$fcMap = [];
try {
$fcRows = $this->db->query("SELECT feature_key, code, enabled FROM feature_codes")->fetchAll();
foreach ($fcRows as $r) {
if ((int)$r['enabled'] === 1) {
$fcMap[$r['feature_key']] = trim($r['code']);
}
}
} catch (\Throwable) {
$fcMap = [];
}
// Hints pour supervision d'état & Touches BLF Yealink (Vert=Libre, Rouge clignotant=Sonne, Rouge fixe=Occupé/DND)
$lines[] = "; --- Hints de supervision d'état (Presence, DND & Touches BLF Yealink) ---";
foreach ($extensions as $ext) {
$num = trim((string)$ext['extension']);
$lines[] = "exten => {$num},hint,PJSIP/{$num}&Custom:DND{$num}";
$lines[] = "exten => *76{$num},hint,Custom:DND{$num}";
}
$lines[] = "";
// Interception d'appel (Call Pickup)
$blfPrefix = $fcMap['pickup_blf'] ?? '**';
$groupCode = $fcMap['pickup_group'] ?? '*8';
$lines[] = "; --- Interception d'appel (Call Pickup & Touches BLF Yealink) ---";
if ($blfPrefix !== '') {
$lines[] = "exten => _{$blfPrefix}.,1,NoOp(Interception d'appel dirigee BLF vers \${EXTEN:" . strlen($blfPrefix) . "})";
$lines[] = " same => n,Pickup(\${EXTEN:" . strlen($blfPrefix) . "}@from-internal)";
$lines[] = " same => n,Hangup()";
}
if ($groupCode !== '') {
$lines[] = "exten => {$groupCode},1,NoOp(Interception d'appel de groupe)";
$lines[] = " same => n,Pickup()";
$lines[] = " same => n,Hangup()";
}
$lines[] = "";
// Ne Pas Déranger (DND)
$dndOn = $fcMap['dnd_activate'] ?? '*78';
$dndOff = $fcMap['dnd_deactivate'] ?? '*79';
$dndTog = $fcMap['dnd_toggle'] ?? '*76';
$lines[] = "; --- Mode Ne Pas Déranger (DND) ---";
if ($dndOn !== '') {
$dndOnLen = strlen($dndOn);
$lines[] = "exten => {$dndOn},1,NoOp(Activation DND pour \${CALLERID(num)})";
$lines[] = " same => n,Set(DB(DND/\${CALLERID(num)})=YES)";
$lines[] = " same => n,Set(DEVICE_STATE(Custom:DND\${CALLERID(num)})=BUSY)";
$lines[] = " same => n,Answer()";
$lines[] = " same => n,Playback(do-not-disturb)";
$lines[] = " same => n,Playback(activated)";
$lines[] = " same => n,Hangup()";
$lines[] = "exten => _{$dndOn}.,1,NoOp(Activation DND pour extension \${EXTEN:{$dndOnLen}})";
$lines[] = " same => n,Set(DB(DND/\${EXTEN:{$dndOnLen}})=YES)";
$lines[] = " same => n,Set(DEVICE_STATE(Custom:DND\${EXTEN:{$dndOnLen}})=BUSY)";
$lines[] = " same => n,Answer()";
$lines[] = " same => n,Playback(do-not-disturb)";
$lines[] = " same => n,Playback(activated)";
$lines[] = " same => n,Hangup()";
}
if ($dndOff !== '') {
$dndOffLen = strlen($dndOff);
$lines[] = "exten => {$dndOff},1,NoOp(Desactivation DND pour \${CALLERID(num)})";
$lines[] = " same => n,Set(DB(DND/\${CALLERID(num)})=)";
$lines[] = " same => n,Set(DEVICE_STATE(Custom:DND\${CALLERID(num)})=NOT_INUSE)";
$lines[] = " same => n,Answer()";
$lines[] = " same => n,Playback(do-not-disturb)";
$lines[] = " same => n,Playback(de-activated)";
$lines[] = " same => n,Hangup()";
$lines[] = "exten => _{$dndOff}.,1,NoOp(Desactivation DND pour extension \${EXTEN:{$dndOffLen}})";
$lines[] = " same => n,Set(DB(DND/\${EXTEN:{$dndOffLen}})=)";
$lines[] = " same => n,Set(DEVICE_STATE(Custom:DND\${EXTEN:{$dndOffLen}})=NOT_INUSE)";
$lines[] = " same => n,Answer()";
$lines[] = " same => n,Playback(do-not-disturb)";
$lines[] = " same => n,Playback(de-activated)";
$lines[] = " same => n,Hangup()";
}
if ($dndTog !== '') {
$dndTogLen = strlen($dndTog);
$lines[] = "exten => {$dndTog},1,NoOp(Toggle DND pour \${CALLERID(num)})";
$lines[] = " same => n,GotoIf(\$[\"\${DB(DND/\${CALLERID(num)})}\" = \"YES\"]?dnd_turn_off:dnd_turn_on)";
$lines[] = " same => n(dnd_turn_on),Set(DB(DND/\${CALLERID(num)})=YES)";
$lines[] = " same => n,Set(DEVICE_STATE(Custom:DND\${CALLERID(num)})=BUSY)";
$lines[] = " same => n,Answer()";
$lines[] = " same => n,Playback(do-not-disturb)";
$lines[] = " same => n,Playback(activated)";
$lines[] = " same => n,Hangup()";
$lines[] = " same => n(dnd_turn_off),Set(DB(DND/\${CALLERID(num)})=)";
$lines[] = " same => n,Set(DEVICE_STATE(Custom:DND\${CALLERID(num)})=NOT_INUSE)";
$lines[] = " same => n,Answer()";
$lines[] = " same => n,Playback(do-not-disturb)";
$lines[] = " same => n,Playback(de-activated)";
$lines[] = " same => n,Hangup()";
$lines[] = "exten => _{$dndTog}.,1,NoOp(Toggle DND pour extension \${EXTEN:{$dndTogLen}})";
$lines[] = " same => n,GotoIf(\$[\"\${DB(DND/\${EXTEN:{$dndTogLen}})}\" = \"YES\"]?dnd_ext_off:dnd_ext_on)";
$lines[] = " same => n(dnd_ext_on),Set(DB(DND/\${EXTEN:{$dndTogLen}})=YES)";
$lines[] = " same => n,Set(DEVICE_STATE(Custom:DND\${EXTEN:{$dndTogLen}})=BUSY)";
$lines[] = " same => n,Answer()";
$lines[] = " same => n,Playback(do-not-disturb)";
$lines[] = " same => n,Playback(activated)";
$lines[] = " same => n,Hangup()";
$lines[] = " same => n(dnd_ext_off),Set(DB(DND/\${EXTEN:{$dndTogLen}})=)";
$lines[] = " same => n,Set(DEVICE_STATE(Custom:DND\${EXTEN:{$dndTogLen}})=NOT_INUSE)";
$lines[] = " same => n,Answer()";
$lines[] = " same => n,Playback(do-not-disturb)";
$lines[] = " same => n,Playback(de-activated)";
$lines[] = " same => n,Hangup()";
}
$lines[] = "";
// Renvois d'appels
$cfOn = $fcMap['cf_activate'] ?? '*72';
$cfOff = $fcMap['cf_deactivate'] ?? '*73';
$cfuOn = $fcMap['cfu_activate'] ?? '*52';
$cfuOff = $fcMap['cfu_deactivate'] ?? '*53';
$lines[] = "; --- Gestion des Renvois d'Appels ---";
if ($cfOn !== '') {
$lines[] = "exten => _{$cfOn}.,1,NoOp(Renvoi inconditionnel vers \${EXTEN:" . strlen($cfOn) . "})";
$lines[] = " same => n,Answer()";
$lines[] = " same => n,Wait(1)";
$lines[] = " same => n,Set(DB(CF/\${CALLERID(num)})=\${EXTEN:" . strlen($cfOn) . "})";
$lines[] = " same => n,Playback(call-fwd-unconditional)";
$lines[] = " same => n,Playback(activated)";
$lines[] = " same => n,Hangup()";
$lines[] = "exten => {$cfOn},1,NoOp(Renvoi inconditionnel interactif)";
$lines[] = " same => n,Answer()";
$lines[] = " same => n,Wait(1)";
$lines[] = " same => n,Read(targetNum,vm-enter-num-to-call,15,,,5)";
$lines[] = " same => n,GotoIf(\$[\"\${targetNum}\" = \"\"]?cf_abort)";
$lines[] = " same => n,Set(DB(CF/\${CALLERID(num)})=\${targetNum})";
$lines[] = " same => n,Playback(call-fwd-unconditional)";
$lines[] = " same => n,Playback(activated)";
$lines[] = " same => n,Hangup()";
$lines[] = " same => n(cf_abort),Playback(cancelled)";
$lines[] = " same => n,Hangup()";
}
if ($cfOff !== '') {
$lines[] = "exten => {$cfOff},1,NoOp(Desactivation Renvoi inconditionnel)";
$lines[] = " same => n,Answer()";
$lines[] = " same => n,Wait(1)";
$lines[] = " same => n,Set(DB(CF/\${CALLERID(num)})=)";
$lines[] = " same => n,Playback(call-fwd-unconditional)";
$lines[] = " same => n,Playback(de-activated)";
$lines[] = " same => n,Hangup()";
}
if ($cfuOn !== '') {
$lines[] = "exten => _{$cfuOn}.,1,NoOp(Renvoi indisponible vers \${EXTEN:" . strlen($cfuOn) . "})";
$lines[] = " same => n,Answer()";
$lines[] = " same => n,Wait(1)";
$lines[] = " same => n,Set(DB(CFU/\${CALLERID(num)})=\${EXTEN:" . strlen($cfuOn) . "})";
$lines[] = " same => n,Playback(call-fwd-no-ans)";
$lines[] = " same => n,Playback(activated)";
$lines[] = " same => n,Hangup()";
$lines[] = "exten => {$cfuOn},1,NoOp(Renvoi indisponible interactif)";
$lines[] = " same => n,Answer()";
$lines[] = " same => n,Wait(1)";
$lines[] = " same => n,Read(targetNum,vm-enter-num-to-call,15,,,5)";
$lines[] = " same => n,GotoIf(\$[\"\${targetNum}\" = \"\"]?cfu_abort)";
$lines[] = " same => n,Set(DB(CFU/\${CALLERID(num)})=\${targetNum})";
$lines[] = " same => n,Playback(call-fwd-no-ans)";
$lines[] = " same => n,Playback(activated)";
$lines[] = " same => n,Hangup()";
$lines[] = " same => n(cfu_abort),Playback(cancelled)";
$lines[] = " same => n,Hangup()";
}
if ($cfuOff !== '') {
$lines[] = "exten => {$cfuOff},1,NoOp(Desactivation Renvoi indisponible)";
$lines[] = " same => n,Answer()";
$lines[] = " same => n,Wait(1)";
$lines[] = " same => n,Set(DB(CFU/\${CALLERID(num)})=)";
$lines[] = " same => n,Playback(call-fwd-no-ans)";
$lines[] = " same => n,Playback(de-activated)";
$lines[] = " same => n,Hangup()";
}
$lines[] = "";
// Sécurité & Liste Noire (Blacklist)
$blAdd = $fcMap['blacklist_add'] ?? '*30';
$blRem = $fcMap['blacklist_remove'] ?? '*31';
$blLast = $fcMap['blacklist_last'] ?? '*32';
$lines[] = "; --- Sécurité & Filtrage Liste Noire (Blacklist) ---";
if ($blAdd !== '') {
$lines[] = "exten => _{$blAdd}.,1,NoOp(Ajout a la blacklist \${EXTEN:" . strlen($blAdd) . "})";
$lines[] = " same => n,Answer()";
$lines[] = " same => n,Wait(1)";
$lines[] = " same => n,Set(DB(blacklist/\${EXTEN:" . strlen($blAdd) . "})=1)";
$lines[] = " same => n,Playback(num-was-successfully)";
$lines[] = " same => n,Playback(added)";
$lines[] = " same => n,Hangup()";
$lines[] = "exten => {$blAdd},1,NoOp(Ajout a la blacklist interactif)";
$lines[] = " same => n,Answer()";
$lines[] = " same => n,Wait(1)";
$lines[] = " same => n,Read(targetNum,enter-num-blacklist,15,,,5)";
$lines[] = " same => n,GotoIf(\$[\"\${targetNum}\" = \"\"]?bl_abort)";
$lines[] = " same => n,Set(DB(blacklist/\${targetNum})=1)";
$lines[] = " same => n,Playback(num-was-successfully)";
$lines[] = " same => n,Playback(added)";
$lines[] = " same => n,Hangup()";
$lines[] = " same => n(bl_abort),Playback(cancelled)";
$lines[] = " same => n,Hangup()";
}
if ($blRem !== '') {
$lines[] = "exten => _{$blRem}.,1,NoOp(Retrait de la blacklist \${EXTEN:" . strlen($blRem) . "})";
$lines[] = " same => n,Answer()";
$lines[] = " same => n,Wait(1)";
$lines[] = " same => n,Set(DB(blacklist/\${EXTEN:" . strlen($blRem) . "})=)";
$lines[] = " same => n,Playback(num-was-successfully)";
$lines[] = " same => n,Playback(removed)";
$lines[] = " same => n,Hangup()";
$lines[] = "exten => {$blRem},1,NoOp(Retrait de la blacklist interactif)";
$lines[] = " same => n,Answer()";
$lines[] = " same => n,Wait(1)";
$lines[] = " same => n,Read(targetNum,entr-num-rmv-blklist,15,,,5)";
$lines[] = " same => n,GotoIf(\$[\"\${targetNum}\" = \"\"]?blrem_abort)";
$lines[] = " same => n,Set(DB(blacklist/\${targetNum})=)";
$lines[] = " same => n,Playback(num-was-successfully)";
$lines[] = " same => n,Playback(removed)";
$lines[] = " same => n,Hangup()";
$lines[] = " same => n(blrem_abort),Playback(cancelled)";
$lines[] = " same => n,Hangup()";
}
if ($blLast !== '') {
$lines[] = "exten => {$blLast},1,NoOp(Blacklist dernier appelant)";
$lines[] = " same => n,Answer()";
$lines[] = " same => n,Wait(1)";
$lines[] = " same => n,Set(lastCaller=\${DB(LASTCALLER/\${CALLERID(num)})})";
$lines[] = " same => n,GotoIf(\$[\"\${lastCaller}\" = \"\"]?bllast_abort)";
$lines[] = " same => n,Set(DB(blacklist/\${lastCaller})=1)";
$lines[] = " same => n,Playback(num-was-successfully)";
$lines[] = " same => n,Playback(added)";
$lines[] = " same => n,Hangup()";
$lines[] = " same => n(bllast_abort),Playback(cancelled)";
$lines[] = " same => n,Hangup()";
}
$lines[] = "";
// Outils & Messagerie
$vmDirect = $fcMap['voicemail_direct'] ?? '*97';
$vmGeneral = $fcMap['voicemail_general'] ?? '*98';
$spkExten = $fcMap['speak_exten'] ?? '*65';
$lastCall = $fcMap['last_caller'] ?? '*69';
$echoCode = $fcMap['echo_test'] ?? '*43';
$clockCode = $fcMap['speaking_clock'] ?? '*60';
$mohCode = $fcMap['moh_test'] ?? '*44';
$lines[] = "; --- Messagerie Vocale & Utilitaires ---";
if ($vmDirect !== '') {
$lines[] = "exten => {$vmDirect},1,NoOp(Messagerie Vocale Directe - \${CALLERID(num)})";
$lines[] = " same => n,Set(CHANNEL(language)={$sysLang})";
$lines[] = " same => n,Answer()";
$lines[] = " same => n,Wait(1)";
$lines[] = " same => n,GotoIf(\$[\"\${VM_INFO(\${CALLERID(num)}@default,exists)}\" = \"1\"]?vm_ok:vm_none)";
$lines[] = " same => n(vm_ok),Goto(from-vm-direct,\${CALLERID(num)},1)";
$lines[] = " same => n(vm_none),NoOp(Aucune boite vocale active pour \${CALLERID(num)})";
$lines[] = " same => n,Playback(vm-incorrect-mailbox)";
$lines[] = " same => n,Hangup()";
}
if ($vmGeneral !== '') {
$lines[] = "exten => {$vmGeneral},1,NoOp(Messagerie Vocale Générale)";
$lines[] = " same => n,Set(CHANNEL(language)={$sysLang})";
$lines[] = " same => n,Answer()";
$lines[] = " same => n,Wait(1)";
$lines[] = " same => n,VoiceMailMain(@default)";
$lines[] = " same => n,Hangup()";
}
if ($spkExten !== '') {
$lines[] = "exten => {$spkExten},1,NoOp(Speak your extension)";
$lines[] = " same => n,Set(CHANNEL(language)={$sysLang})";
$lines[] = " same => n,Answer()";
$lines[] = " same => n,Wait(1)";
$lines[] = " same => n,Playback(your)";
$lines[] = " same => n,Playback(extension)";
$lines[] = " same => n,Playback(number)";
$lines[] = " same => n,Playback(is)";
$lines[] = " same => n,SayDigits(\${CALLERID(num)})";
$lines[] = " same => n,Wait(1)";
$lines[] = " same => n,Hangup()";
}
if ($lastCall !== '') {
$lines[] = "exten => {$lastCall},1,NoOp(Dernier appelant reçu)";
$lines[] = " same => n,Set(CHANNEL(language)={$sysLang})";
$lines[] = " same => n,Answer()";
$lines[] = " same => n,Wait(1)";
$lines[] = " same => n,Set(lastCaller=\${DB(LASTCALLER/\${CALLERID(num)})})";
$lines[] = " same => n,GotoIf(\$[\"\${lastCaller}\" = \"\"]?nolast)";
$lines[] = " same => n,Playback(telephone-number)";
$lines[] = " same => n,SayDigits(\${lastCaller})";
$lines[] = " same => n,Wait(1)";
$lines[] = " same => n,Goto(from-internal,\${lastCaller},1)";
$lines[] = " same => n(nolast),Playback(cannot-complete-as-dialed)";
$lines[] = " same => n,Hangup()";
}
if ($echoCode !== '') {
$lines[] = "exten => {$echoCode},1,NoOp(Test Echo Audio)";
$lines[] = " same => n,Set(CHANNEL(language)={$sysLang})";
$lines[] = " same => n,Answer()";
$lines[] = " same => n,Wait(1)";
$lines[] = " same => n,Playback(demo-echotest)";
$lines[] = " same => n,Echo()";
$lines[] = " same => n,Hangup()";
}
if ($clockCode !== '') {
$lines[] = "exten => {$clockCode},1,NoOp(Horloge Parlante / Test Audio)";
$lines[] = " same => n,Set(CHANNEL(language)={$sysLang})";
$lines[] = " same => n,Answer()";
$lines[] = " same => n,Wait(1)";
$lines[] = " same => n,SayUnixTime()";
$lines[] = " same => n,Hangup()";
}
if ($mohCode !== '') {
$lines[] = "exten => {$mohCode},1,NoOp(Test Musique d'Attente MoH par defaut)";
$lines[] = " same => n,Set(CHANNEL(language)={$sysLang})";
$lines[] = " same => n,Answer()";
$lines[] = " same => n,Wait(1)";
$lines[] = " same => n,MusicOnHold(default)";
$lines[] = " same => n,Hangup()";
}
$lines[] = "";
// Appels entre postes internes & Gestion Renvois / DND / Voicemail
$lines[] = "; --- Appels entre postes internes & Gestion Renvois / DND / Voicemail ---";
foreach ($extensions as $ext) {
$num = trim((string)$ext['extension']);
$name = str_replace('"', '', trim((string)$ext['name']));
$ringSec = max(5, min(120, (int)($ext['ring_time'] ?? 20)));
$hasVm = !empty($ext['voicemail_enabled']);
$lines[] = "exten => {$num},1,NoOp(Appel interne vers {$num} - {$name})";
$lines[] = " same => n,Set(CHANNEL(language)={$sysLang})";
$lines[] = " same => n,Set(DB(LASTCALLER/{$num})=\${CALLERID(num)})";
$lines[] = " same => n,GotoIf(\$[\"\${DB(DND/{$num})}\" = \"YES\"]?dnd_{$num})";
$lines[] = " same => n,GotoIf(\$[\"\${DB(CF/{$num})}\" != \"\"]?cf_{$num})";
$lines[] = " same => n,Dial(PJSIP/{$num},{$ringSec},tTkK)";
$lines[] = " same => n,GotoIf(\$[\"\${DB(CFU/{$num})}\" != \"\"]?cfu_{$num})";
if ($hasVm) {
$lines[] = " same => n,GotoIf(\$[\"\${DIALSTATUS}\" = \"BUSY\"]?busy_vm_{$num})";
$lines[] = " same => n,GotoIf(\$[\"\${DIALSTATUS}\" = \"NOANSWER\" | \"\${DIALSTATUS}\" = \"CHANUNAVAIL\" | \"\${DIALSTATUS}\" = \"CONGESTION\"]?unavail_vm_{$num})";
$lines[] = " same => n,Hangup()";
$lines[] = " same => n(busy_vm_{$num}),VoiceMail({$num}@default,b)";
$lines[] = " same => n,Hangup()";
$lines[] = " same => n(unavail_vm_{$num}),VoiceMail({$num}@default,u)";
$lines[] = " same => n,Hangup()";
} else {
$lines[] = " same => n,Hangup()";
}
$lines[] = " same => n(dnd_{$num}),NoOp(Poste {$num} en mode Ne Pas Deranger DND)";
if ($hasVm) {
$lines[] = " same => n,VoiceMail({$num}@default,b)";
$lines[] = " same => n,Hangup()";
} else {
$lines[] = " same => n,Playtones(busy)";
$lines[] = " same => n,Busy(3)";
$lines[] = " same => n,Hangup()";
}
$lines[] = " same => n(cf_{$num}),NoOp(Poste {$num} renvoi inconditionnel vers \${DB(CF/{$num})})";
$lines[] = " same => n,Goto(from-internal,\${DB(CF/{$num})},1)";
$lines[] = " same => n(cfu_{$num}),NoOp(Poste {$num} indisponible, renvoi vers \${DB(CFU/{$num})})";
$lines[] = " same => n,Goto(from-internal,\${DB(CFU/{$num})},1)";
$lines[] = "";
}
// Groupes d'appel (Ring Groups)
if (!empty($groups)) {
$lines[] = "; --- Groupes d'appel (Ring Groups) ---";
foreach ($groups as $grp) {
$gExt = trim((string)$grp['extension']);
$gName = str_replace('"', '', trim((string)$grp['name']));
$strategy = $grp['strategy'];
$ringTime = max(5, (int)$grp['ring_time']);
$members = json_decode((string)$grp['members'], true) ?: [];
$members = array_values(array_filter(array_map('trim', $members)));
$failover = trim((string)$grp['failover_dest']);
$lines[] = "exten => {$gExt},1,NoOp(Groupe {$gExt} - {$gName} [{$strategy}])";
if (empty($members)) {
$lines[] = " same => n,Hangup()";
} elseif ($strategy === 'hunt') {
// Sonnerie séquentielle poste par poste
foreach ($members as $m) {
$lines[] = " same => n,Dial(PJSIP/{$m},{$ringTime},tT)";
}
$lines[] = " same => n," . $this->buildDestinationDialplan($failover);
} else {
// ringall (simultané)
$dialStr = implode('&', array_map(fn($m) => "PJSIP/{$m}", $members));
$lines[] = " same => n,Dial({$dialStr},{$ringTime},tT)";
$lines[] = " same => n," . $this->buildDestinationDialplan($failover);
}
$lines[] = " same => n,Hangup()";
$lines[] = "";
}
}
// Groupes de téléavertissement & Interphonie (Paging)
if (!empty($pagingGroups)) {
$lines[] = "; --- Groupes de téléavertissement & Interphonie (Paging) ---";
foreach ($pagingGroups as $pg) {
$pExt = trim((string)$pg['extension']);
$pName = str_replace('"', '', trim((string)$pg['name']));
$pDuplex = !empty($pg['duplex']);
$pBusyMode = trim((string)($pg['busy_mode'] ?? 'skip'));
$pBeep = !empty($pg['play_beep']);
$pTimeout = max(10, min(600, (int)($pg['timeout'] ?? 120)));
$members = json_decode((string)$pg['members'], true) ?: [];
$members = array_values(array_filter(array_map('trim', $members)));
$lines[] = "exten => {$pExt},1,NoOp(Teleavertissement Groupe {$pExt} - {$pName})";
$lines[] = " same => n,Set(CHANNEL(language)={$sysLang})";
$lines[] = " same => n,Answer()";
if ($pBeep) {
$lines[] = " same => n,Playback(beep)";
}
if (empty($members)) {
$lines[] = " same => n,Playback(beeperr)";
$lines[] = " same => n,Hangup()";
} else {
$devStr = implode('&', array_map(fn($m) => "PJSIP/{$m}", $members));
// Options Page():
// b(sub-paging-call^s^1) : configure les en-têtes SIP d'auto-réponse (Auto-Answer) sur chaque branche
// q : muet (pas de musique de fond confbridge)
// d : duplex (interphonie bidirectionnelle) si activé, sinon simplex (haut-parleur seul)
// s : skip channels if state != NOT_INUSE (évite d'interrompre un appel en cours si busy_mode = skip)
$pageOpts = "b(sub-paging-call^s^1)q";
if ($pDuplex) {
$pageOpts .= "d";
}
if ($pBusyMode === 'skip') {
$pageOpts .= "s";
}
$lines[] = " same => n,Set(PAGE_DEVICES={$devStr})";
$lines[] = " same => n,Page(\${PAGE_DEVICES},{$pageOpts},{$pTimeout})";
$lines[] = " same => n,Hangup()";
}
$lines[] = "";
}
// Interphonie directe 1-à-1 (*80 + numéro de poste, ex: *80101 pour le poste 101)
// Pattern _*80XX. pour ne cibler que les numéros de postes (au moins 2 chiffres) et ne pas chevaucher *801
$lines[] = "; --- Interphonie directe 1-à-1 (*80 + numéro de poste) ---";
$lines[] = "exten => _*80XX.,1,NoOp(Interphonie directe vers poste \${EXTEN:3})";
$lines[] = " same => n,Set(CHANNEL(language)={$sysLang})";
$lines[] = " same => n,Answer()";
$lines[] = " same => n,Playback(beep)";
$lines[] = " same => n,Page(PJSIP/\${EXTEN:3},b(sub-paging-call^s^1)qd,120)";
$lines[] = " same => n,Hangup()";
$lines[] = "";
}
// Accès direct aux IVR depuis l'interne via *8 + id (*801, *802...)
if (!empty($ivrs)) {
$lines[] = "; --- Test direct des SVI/IVR depuis l'interne (*801, *802...) ---";
foreach ($ivrs as $ivr) {
$ivrSlug = $this->slugify($ivr['name']);
$testCode = '*8' . str_pad((string)$ivr['id'], 2, '0', STR_PAD_LEFT);
$lines[] = "exten => {$testCode},1,NoOp(Test SVI {$ivr['name']})";
$lines[] = " same => n,Set(CHANNEL(language)={$sysLang})";
$lines[] = " same => n,Goto(ivr-{$ivrSlug},s,1)";
}
$lines[] = "";
}
// 1b. Sortie vers l'extérieur : UNIQUEMENT via les Routes Sortantes actives
// Si aucune route sortante active n'existe ou n'autorise le poste, aucun appel externe ne peut sortir !
if (!empty($outboundRoutes)) {
$lines[] = "; --- Inclusion du moteur de Routes Sortantes (Outbound Routes) ---";
$lines[] = "include => outbound-allroutes";
$lines[] = "";
} else {
$lines[] = "; --- Aucune Route Sortante active : appels externes verrouilles ---";
$lines[] = "; Creez et activez au moins une Route Sortante dans l'onglet 'Routes Sortantes' pour autoriser les appels vers l'exterieur.";
$lines[] = "";
}
// 1b. Contextes [outbound-allroutes] & Sous-routines [sub-outroute-{id}]
if (!empty($outboundRoutes)) {
// Indexation des membres des Groupes d'extensions (Sites / Droits de routes : Nice, Paris...)
$groupMembersMap = [];
foreach ($extGroups as $eg) {
$egId = (string)$eg['id'];
$egName = trim((string)$eg['name']);
$mList = json_decode((string)$eg['members'], true) ?: [];
$mList = array_values(array_filter(array_map('trim', $mList)));
$groupMembersMap[$egId] = $mList;
$groupMembersMap[$egName] = $mList;
}
// Compatibilité avec les ring groups
foreach ($groups as $grp) {
$gExt = trim((string)$grp['extension']);
$gName = trim((string)$grp['name']);
$mList = json_decode((string)$grp['members'], true) ?: [];
$mList = array_values(array_filter(array_map('trim', $mList)));
if (!isset($groupMembersMap[$gExt])) $groupMembersMap[$gExt] = $mList;
if (!isset($groupMembersMap[$gName])) $groupMembersMap[$gName] = $mList;
}
// Indexation des Trunks actifs
$trunkByName = [];
foreach ($trunks as $t) {
$trunkByName[trim((string)$t['name'])] = $t;
$trunkByName['trunk-' . $this->slugify($t['name'])] = $t;
if (!empty($t['username'])) {
$trunkByName[trim((string)$t['username'])] = $t;
}
}
$patternDispatches = [];
$subroutineBlocks = [];
foreach ($outboundRoutes as $rIdx => $route) {
$rId = (int)$route['id'];
$rPriority = (int)($route['priority'] ?? ($rIdx + 1));
$rName = preg_replace('/[^a-zA-Z0-9 _\-]/', '', trim((string)$route['name'])) ?: "Route-{$rId}";
$rCid = trim((string)($route['outbound_callerid'] ?? ''));
$overrideCid = ((int)($route['override_ext_cid'] ?? 0) === 1);
$rPass = preg_replace('/[^0-9*#]/', '', trim((string)($route['route_password'] ?? '')));
$rType = trim((string)($route['route_type'] ?? 'normal'));
$permMode = trim((string)($route['permission_mode'] ?? 'all'));
$contNext = ((int)($route['continue_next_route'] ?? 1) === 1);
$congDest = trim((string)($route['congestion_dest'] ?? 'terminate-congestion')) ?: 'terminate-congestion';
$rMohClass = preg_replace('/[^a-zA-Z0-9_\-]/', '', trim((string)($route['moh_class'] ?? 'default'))) ?: 'default';
$trunkSeq = json_decode((string)($route['trunk_sequence_json'] ?? '[]'), true) ?: [];
$dialPats = json_decode((string)($route['dial_patterns_json'] ?? '[]'), true) ?: [];
$allowGrps = json_decode((string)($route['allowed_groups_json'] ?? '[]'), true) ?: [];
$allowExts = json_decode((string)($route['allowed_extensions_json'] ?? '[]'), true) ?: [];
// Enregistrement des patterns de cette route dans l'ordonnanceur global
foreach ($dialPats as $pat) {
if (!is_array($pat)) {
continue;
}
$prepend = preg_replace('/[^0-9*#+]/', '', trim((string)($pat['prepend'] ?? '')));
$prefix = preg_replace('/[^0-9*#+]/', '', trim((string)($pat['prefix'] ?? '')));
$matchRaw = ltrim(trim((string)($pat['match_pattern'] ?? '')), '_');
$matchRaw = preg_replace('/[^0-9XZN.*#+\[\]\-!]/i', '', $matchRaw);
$patCid = preg_replace('/[^0-9XZN.*#+\[\]\-]/i', '', ltrim(trim((string)($pat['callerid'] ?? '')), '_'));
if ($matchRaw === '') {
continue;
}
$combined = $prefix . $matchRaw;
$needsUnderscore = (bool)preg_match('/[XZN\[\].!]/i', $combined);
$astExten = ($needsUnderscore ? '_' : '') . $combined;
$patternDispatches[$astExten][] = [
'route_id' => $rId,
'route_name' => $rName,
'priority' => $rPriority,
'prepend' => $prepend,
'prefix_len' => strlen($prefix),
'callerid' => $patCid,
'cong_dest' => $congDest,
];
}
// Construction de la sous-routine [sub-outroute-{id}]
$sub = [];
$sub[] = "; --- Sous-routine Route Sortante #{$rPriority} : {$rName} (Type: {$rType}) ---";
$sub[] = "[sub-outroute-{$rId}]";
$sub[] = "exten => s,1,NoOp(Evaluation Route Sortante #{$rPriority} [{$rName}] -> Numero=\${ARG1} | Poste=\${CALLERID(num)})";
$sub[] = " same => n,Set(OUTNUM=\${ARG1})";
$sub[] = " same => n,Set(CHANNEL(musicclass)={$rMohClass})";
$sub[] = " same => n,Set(__MOHCLASS={$rMohClass})";
// 1. Contrôle des droits d'accès (Groupes Autorisés ex: Nice/Paris + Postes Autorisés)
if ($permMode === 'restricted') {
$effectiveExts = [];
foreach ($allowExts as $ae) {
$ae = preg_replace('/[^0-9a-zA-Z_-]/', '', trim((string)$ae));
if ($ae !== '') {
$effectiveExts[$ae] = true;
}
}
foreach ($allowGrps as $ag) {
$agKey = trim((string)$ag);
foreach ($groupMembersMap[$agKey] ?? [] as $gm) {
$gm = preg_replace('/[^0-9a-zA-Z_-]/', '', trim((string)$gm));
if ($gm !== '') {
$effectiveExts[$gm] = true;
}
}
}
$allowedList = array_keys($effectiveExts);
if (empty($allowedList)) {
$sub[] = " same => n,NoOp(Route [{$rName}] restreinte sans aucun poste autorise -> Passage a la route suivante)";
$sub[] = " same => n,Return()";
} else {
$regexStr = '^(' . implode('|', $allowedList) . ')$';
$sub[] = " same => n,GotoIf(\$[\${REGEX(\"{$regexStr}\" \${CALLERID(num)})} = 1]?auth_ok)";
$sub[] = " same => n,NoOp(Poste \${CALLERID(num)} non autorise sur la route [{$rName}] -> Passage a la route sortante suivante)";
$sub[] = " same => n,Return()";
$sub[] = " same => n(auth_ok),NoOp(Droits valides pour le poste \${CALLERID(num)} sur [{$rName}])";
}
}
// 2. Mot de passe / Code PIN éventuel sur la route
if ($rPass !== '') {
$sub[] = " same => n,Authenticate({$rPass})";
}
// 3. Positionnement du CallerID Sortant de la route
if ($rCid !== '') {
foreach ($this->buildCallerIdDialplan($rCid) as $cidStmt) {
$sub[] = $cidStmt;
}
}
// 4. Séquence de Trunks avec bascule automatique (Failover)
$validTrunkCount = 0;
foreach ($trunkSeq as $tSeqName) {
$tKey = trim((string)$tSeqName);
if ($tKey === '') {
continue;
}
$tObj = $trunkByName[$tKey] ?? null;
$tSlug = $tObj ? $this->slugify($tObj['name']) : $this->slugify($tKey);
$tFallbackCid = $tObj ? trim((string)($tObj['outbound_callerid'] ?? '')) : '';
if ($rCid === '' && $tFallbackCid !== '') {
foreach ($this->buildCallerIdDialplan($tFallbackCid) as $cidStmt) {
$sub[] = $cidStmt;
}
}
$validTrunkCount++;
$tEpName = ($tObj && ($tObj['registration_mode'] ?? '') === 'receive' && !empty($tObj['username']))
? $tObj['username']
: "trunk-{$tSlug}";
$dialOpts = ($tObj && !empty($tObj['dial_options'])) ? trim((string)$tObj['dial_options']) : 'tTR';
$sub[] = " same => n,NoOp(Route [{$rName}] - Tentative Trunk #{$validTrunkCount} [{$tEpName}] vers \${OUTNUM})";
$sub[] = " same => n,Dial(PJSIP/\${OUTNUM}@{$tEpName},60,{$dialOpts})";
$sub[] = " same => n,GotoIf(\$[\"\${DIALSTATUS}\" = \"ANSWER\" | \"\${DIALSTATUS}\" = \"BUSY\" | \"\${DIALSTATUS}\" = \"NOANSWER\" | \"\${DIALSTATUS}\" = \"CANCEL\"]?call_done)";
$sub[] = " same => n,NoOp(Trunk [{$tEpName}] indisponible [\${DIALSTATUS}] -> Passage au Trunk / Route suivant)";
}
// 5. Si tous les Trunks de cette route sont indisponibles
$sub[] = " same => n,Set(CALLERID(all)=\${ORIG_CID})";
if ($contNext) {
$sub[] = " same => n,NoOp(Fin de sequence pour [{$rName}] -> Passage a la route sortante suivante)";
$sub[] = " same => n,Return()";
} else {
$sub[] = " same => n," . $this->buildDestinationDialplan($congDest);
$sub[] = " same => n,Hangup()";
}
$sub[] = " same => n(call_done),Hangup()";
$sub[] = "";
$subroutineBlocks[] = implode("\n", $sub);
}
// Génération du contexte [outbound-allroutes]
$lines[] = "[outbound-allroutes]";
foreach ($patternDispatches as $astExten => $dispatches) {
$lines[] = "; --- Masque sortant : {$astExten} ---";
$lines[] = "exten => {$astExten},1,NoOp(Appel Sortant vers \${EXTEN} depuis le poste \${CALLERID(num)})";
$lines[] = " same => n,Set(ORIG_CID=\${CALLERID(all)})";
$lastCongDest = 'terminate-congestion';
foreach ($dispatches as $d) {
$rId = $d['route_id'];
$prepend = $d['prepend'];
$prefixLen = (int)$d['prefix_len'];
$patCid = $d['callerid'];
$lastCongDest = $d['cong_dest'] ?: $lastCongDest;
$numExpr = $prefixLen > 0 ? "{$prepend}\${EXTEN:{$prefixLen}}" : "{$prepend}\${EXTEN}";
if ($patCid !== '') {
$lines[] = " same => n,GosubIf(\$[\"\${CALLERID(num)}\" = \"{$patCid}\"]?sub-outroute-{$rId},s,1({$numExpr}))";
} else {
$lines[] = " same => n,Gosub(sub-outroute-{$rId},s,1({$numExpr}))";
}
}
$lines[] = " same => n," . $this->buildDestinationDialplan($lastCongDest);
$lines[] = " same => n,Hangup()";
$lines[] = "";
}
foreach ($subroutineBlocks as $block) {
$lines[] = $block;
}
}
// 2. Contexte [app-terminate] (Actions Terminate Call style FreePBX : Occupation, Congestion, Zapateller, Hangup)
$lines[] = "[app-terminate]";
$lines[] = "exten => busy,1,NoOp(Terminate Call: Mettre definitivement l appelant sur occupation)";
$lines[] = " same => n,Playtones(busy)";
$lines[] = " same => n,Busy(20)";
$lines[] = " same => n,Hangup()";
$lines[] = "";
$lines[] = "exten => congestion,1,NoOp(Terminate Call: Tonalite de congestion)";
$lines[] = " same => n,Playtones(congestion)";
$lines[] = " same => n,Congestion(20)";
$lines[] = " same => n,Hangup()";
$lines[] = "";
$lines[] = "exten => all-circuits-busy,1,NoOp(Terminate Call: Message vocal toutes les lignes sont occupees)";
$lines[] = " same => n,Answer()";
$lines[] = " same => n,Playback(all-circuits-busy-now)";
$lines[] = " same => n,Congestion(10)";
$lines[] = " same => n,Hangup()";
$lines[] = "";
$lines[] = "exten => _play-custom-.,1,NoOp(Terminate Call: Lecture annonce personnalisee \${EXTEN:12})";
$lines[] = " same => n,Answer()";
$lines[] = " same => n,Playback(custom/\${EXTEN:12})";
$lines[] = " same => n,Congestion(10)";
$lines[] = " same => n,Hangup()";
$lines[] = "";
$lines[] = "exten => _play-.,1,NoOp(Terminate Call: Lecture annonce systeme \${EXTEN:5})";
$lines[] = " same => n,Answer()";
$lines[] = " same => n,Playback(\${EXTEN:5})";
$lines[] = " same => n,Congestion(10)";
$lines[] = " same => n,Hangup()";
$lines[] = "";
$lines[] = "exten => zapateller,1,NoOp(Terminate Call: Tonalite SIT Anti-Robot)";
$lines[] = " same => n,Answer()";
$lines[] = " same => n,Zapateller()";
$lines[] = " same => n,Playback(ss-noservice)";
$lines[] = " same => n,Hangup()";
$lines[] = "";
$lines[] = "exten => ringforever,1,NoOp(Terminate Call: Sonnerie dans le vide)";
$lines[] = " same => n,Ringing()";
$lines[] = " same => n,Wait(300)";
$lines[] = " same => n,Hangup()";
$lines[] = "";
$lines[] = "exten => hangup,1,NoOp(Terminate Call: Raccrocher immediatement)";
$lines[] = " same => n,Hangup()";
$lines[] = "";
// 2a. Sous-routine d'interphonie / Auto-Answer Téléavertissement (Paging)
$lines[] = "; --- Sous-routine d'interphonie / Auto-Answer Téléavertissement ---";
$lines[] = "[sub-paging-call]";
$lines[] = "exten => s,1,NoOp(Preparation entetes SIP Auto-Answer pour Paging)";
$lines[] = " same => n,Set(PJSIP_HEADER(add,Alert-Info)=info=alert-autoanswer\\;delay=0)";
$lines[] = " same => n,Set(PJSIP_HEADER(add,Alert-Info)=<http://www.notused.com>\\;info=alert-autoanswer\\;delay=0)";
$lines[] = " same => n,Set(PJSIP_HEADER(add,Call-Info)=\\;answer-after=0)";
$lines[] = " same => n,Set(PJSIP_HEADER(add,P-Auto-Answer)=normal)";
$lines[] = " same => n,Set(PJSIP_HEADER(add,Answer-After)=0)";
$lines[] = " same => n,Set(PJSIP_HEADER(add,SIP-Auto-Answer)=true)";
$lines[] = " same => n,Return()";
$lines[] = "";
// 2a-bis. Contexte [app-scam-tarpit] (Pièges à Scammers : Musique d'attente en boucle & Fausse voix en boucle infinie)
$lines[] = "; --- Pièges à Scammers & Démarchage (Tarpit Anti-Spam) ---";
$lines[] = "[app-scam-tarpit]";
$lines[] = "exten => moh-default,1,NoOp(Tarpit Scammer - Musique par defaut en boucle)";
$lines[] = " same => n,Answer()";
$lines[] = " same => n,Wait(1)";
$lines[] = " same => n,MusicOnHold(default)";
$lines[] = " same => n,Hangup()";
$lines[] = "";
$lines[] = "exten => _moh-.,1,NoOp(Tarpit Scammer - Musique dediee en boucle: \${EXTEN:4})";
$lines[] = " same => n,Answer()";
$lines[] = " same => n,Wait(1)";
$lines[] = " same => n,MusicOnHold(\${EXTEN:4})";
$lines[] = " same => n,Hangup()";
$lines[] = "";
$lines[] = "exten => _loop-.,1,NoOp(Tarpit Scammer - Message/Voix en boucle infinie: \${EXTEN:5})";
$lines[] = " same => n,Answer()";
$lines[] = " same => n,Wait(1)";
$lines[] = " same => n(playloop),Playback(custom/\${EXTEN:5})";
$lines[] = " same => n,Wait(1)";
$lines[] = " same => n,Goto(playloop)";
$lines[] = " same => n,Hangup()";
$lines[] = "";
// 2b. Contexte [from-pstn-toheader] (Extrait le SDA depuis l'en-tête SIP To + Normalise le CallerID +33/0033 en 0X)
$lines[] = "[from-pstn-toheader]";
$lines[] = "exten => _X.,1,NoOp(Extraction SDA depuis SIP To Header - Appelant=\${CALLERID(num)})";
$lines[] = " same => n,ExecIf(\$[\"\${CALLERID(num):0:3}\" = \"+33\"]?Set(CALLERID(num)=0\${CALLERID(num):3}))";
$lines[] = " same => n,ExecIf(\$[\"\${CALLERID(num):0:4}\" = \"0033\"]?Set(CALLERID(num)=0\${CALLERID(num):4}))";
$lines[] = " same => n,GotoIf(\$[\"\${DB(blacklist/\${CALLERID(num)})}\" != \"\"]?app-blacklisted,s,1)";
$lines[] = " same => n,Set(DID_NUM=\${CUT(CUT(PJSIP_HEADER(read,To),@,1),:,2)})";
$lines[] = " same => n,GotoIf(\$[\"\${DID_NUM}\" != \"\"]?from-external,\${DID_NUM},1:from-external,\${EXTEN},1)";
$lines[] = "exten => s,1,NoOp(Extraction SDA depuis SIP To Header sur s - Appelant=\${CALLERID(num)})";
$lines[] = " same => n,ExecIf(\$[\"\${CALLERID(num):0:3}\" = \"+33\"]?Set(CALLERID(num)=0\${CALLERID(num):3}))";
$lines[] = " same => n,ExecIf(\$[\"\${CALLERID(num):0:4}\" = \"0033\"]?Set(CALLERID(num)=0\${CALLERID(num):4}))";
$lines[] = " same => n,GotoIf(\$[\"\${DB(blacklist/\${CALLERID(num)})}\" != \"\"]?app-blacklisted,s,1)";
$lines[] = " same => n,Set(DID_NUM=\${CUT(CUT(PJSIP_HEADER(read,To),@,1),:,2)})";
$lines[] = " same => n,GotoIf(\$[\"\${DID_NUM}\" != \"\"]?from-external,\${DID_NUM},1:from-external,s,1)";
$lines[] = "";
// 2b-bis. Contextes standards FreePBX / Passerelles FXO : [from-trunk] et [from-pstn]
$lines[] = "[from-trunk]";
$lines[] = "exten => _[+*#0-9a-zA-Z].,1,NoOp(Appel entrant trunk [from-trunk] EXTEN=\${EXTEN} Endpoint=\${CHANNEL(endpoint)} Appelant=\${CALLERID(num)})";
$lines[] = " same => n,ExecIf(\$[\"\${CALLERID(num):0:3}\" = \"+33\"]?Set(CALLERID(num)=0\${CALLERID(num):3}))";
$lines[] = " same => n,ExecIf(\$[\"\${CALLERID(num):0:4}\" = \"0033\"]?Set(CALLERID(num)=0\${CALLERID(num):4}))";
$lines[] = " same => n,GotoIf(\$[\"\${DB(blacklist/\${CALLERID(num)})}\" != \"\"]?app-blacklisted,s,1)";
$lines[] = " same => n,ExecIf(\$[\"\${EXTEN:0:3}\" = \"+33\"]?Set(EXTEN=0\${EXTEN:3}))";
$lines[] = " same => n,GotoIf(\$[\"\${DIALPLAN_EXISTS(from-trunk-\${CHANNEL(endpoint)},\${EXTEN},1)}\" = \"1\"]?from-trunk-\${CHANNEL(endpoint)},\${EXTEN},1)";
$lines[] = " same => n,GotoIf(\$[\"\${DIALPLAN_EXISTS(from-trunk-\${CHANNEL(endpoint)},s,1)}\" = \"1\"]?from-trunk-\${CHANNEL(endpoint)},s,1)";
$lines[] = " same => n,Goto(from-external,\${EXTEN},1)";
$lines[] = "exten => s,1,NoOp(Appel entrant trunk [from-trunk] sur s - Endpoint=\${CHANNEL(endpoint)} Appelant=\${CALLERID(num)})";
$lines[] = " same => n,ExecIf(\$[\"\${CALLERID(num):0:3}\" = \"+33\"]?Set(CALLERID(num)=0\${CALLERID(num):3}))";
$lines[] = " same => n,ExecIf(\$[\"\${CALLERID(num):0:4}\" = \"0033\"]?Set(CALLERID(num)=0\${CALLERID(num):4}))";
$lines[] = " same => n,GotoIf(\$[\"\${DB(blacklist/\${CALLERID(num)})}\" != \"\"]?app-blacklisted,s,1)";
$lines[] = " same => n,GotoIf(\$[\"\${DIALPLAN_EXISTS(from-trunk-\${CHANNEL(endpoint)},s,1)}\" = \"1\"]?from-trunk-\${CHANNEL(endpoint)},s,1)";
$lines[] = " same => n,Goto(from-external,s,1)";
$lines[] = "";
$lines[] = "[from-pstn]";
$lines[] = "exten => _[+*#0-9a-zA-Z].,1,Goto(from-trunk,\${EXTEN},1)";
$lines[] = "exten => s,1,Goto(from-trunk,s,1)";
$lines[] = "";
// 2b-ter. Contextes dédiés par Trunk pour isolation des routes et préfixe CID
foreach ($trunks as $t) {
$epName = ($t['registration_mode'] === 'receive' && trim((string)($t['username'] ?? '')) !== '')
? trim((string)$t['username'])
: trim((string)$t['name']);
if ($epName === '') {
continue;
}
$trunkCtx = "from-trunk-{$epName}";
$lines[] = "; --- Contexte Entrant dédié au Trunk {$t['name']} (Endpoint: {$epName}) ---";
$lines[] = "[{$trunkCtx}]";
foreach ($this->generateTrunkInboundRoutes($t, $mohSoundsMap, "trunk_{$t['id']}") as $l) {
$lines[] = $l;
}
$lines[] = "";
if ($t['name'] !== $epName) {
$lines[] = "[from-trunk-{$t['name']}]";
$lines[] = "exten => _[+*#0-9a-zA-Z].,1,Goto({$trunkCtx},\${EXTEN},1)";
$lines[] = "exten => s,1,Goto({$trunkCtx},s,1)";
$lines[] = "";
}
}
// Contexte pour appels rejetés par la liste noire
$lines[] = "[app-blacklisted]";
$lines[] = "exten => s,1,NoOp(Appelant \${CALLERID(num)} bloque par la liste noire)";
$lines[] = " same => n,Answer()";
$lines[] = " same => n,Wait(1)";
$lines[] = " same => n,Playtones(busy)";
$lines[] = " same => n,Busy(3)";
$lines[] = " same => n,Hangup()";
$lines[] = "";
// 2c. Contexte [from-external] (Routes Entrantes avec filtrage ID Appelant [CallerID] & SDA [DID])
$lines[] = "[from-external]";
$hasCatchAllRoute = false;
foreach ($trunks as $t) {
$mohSoundId = (int)($t['moh_sound_id'] ?? 0);
$trunkMohClass = ($mohSoundId > 0 && isset($mohSoundsMap[$mohSoundId]))
? 'moh-sound-' . $mohSoundsMap[$mohSoundId]['slug']
: 'default';
$setMohLines = [];
if ($trunkMohClass !== 'default') {
$setMohLines[] = " same => n,Set(__MOHCLASS={$trunkMohClass})";
$setMohLines[] = " same => n,Set(CHANNEL(musicclass)={$trunkMohClass})";
}
$trunkCidPrefix = trim((string)($t['cid_name_prefix'] ?? ''));
$routes = self::normalizeInboundRoutes((string)($t['did_routes_json'] ?? '[]'));
$routeIdx = 0;
foreach ($routes as $rule) {
$routeIdx++;
$cidRaw = trim((string)($rule['cid'] ?? 'Any'));
$didRaw = trim((string)($rule['did'] ?? 'Any'));
$desc = trim((string)($rule['description'] ?? 'Route entrante'));
$dest = trim((string)($rule['destination'] ?? $t['inbound_dest'] ?? 'hangup'));
$isAnyDid = ($didRaw === '' || strcasecmp($didRaw, 'any') === 0 || $didRaw === '*' || $didRaw === '_X.');
$isAnyCid = ($cidRaw === '' || strcasecmp($cidRaw, 'any') === 0 || $cidRaw === '*');
$didPattern = $isAnyDid ? '_X.' : preg_replace('/[^0-9+XZN._\[\]\-]/', '', $didRaw);
if ($didPattern === '') {
$didPattern = '_X.';
}
$timeInfo = !empty($rule['time_condition_enabled'])
? " [Horaires: {$rule['time_start']}-{$rule['time_finish']} {$rule['weekdays']}]"
: "";
if ($isAnyCid) {
if ($didPattern === '_X.') {
if ($hasCatchAllRoute) {
continue;
}
$hasCatchAllRoute = true;
}
$lines[] = "; Inbound Route [{$desc}] ({$t['name']}) : DID={$didPattern} / Appelant=Any{$timeInfo}";
$lines[] = "exten => {$didPattern},1,NoOp(Inbound [{$desc}] Appelant=\${CALLERID(num)})";
foreach ($this->buildInboundRouteLines($rule, $setMohLines, $desc, "{$t['id']}_{$routeIdx}_did", $trunkCidPrefix) as $rl) {
$lines[] = $rl;
}
if ($isAnyDid) {
$lines[] = "exten => s,1,NoOp(Inbound [{$desc}] sur s Appelant=\${CALLERID(num)})";
foreach ($this->buildInboundRouteLines($rule, $setMohLines, $desc, "{$t['id']}_{$routeIdx}_s", $trunkCidPrefix) as $rl) {
$lines[] = $rl;
}
}
} else {
$cidClean = preg_replace('/[^0-9+XZN._\[\]\-]/', '', $cidRaw);
if ($cidClean === '') {
continue;
}
$cidVariants = [$cidClean];
// Si le motif commence par _0 (ex: _0162XXXXXX), couvrir aussi 0033 et +33 automatiquement
if (str_starts_with($cidClean, '_0') && strlen($cidClean) > 2 && $cidClean[2] !== '0') {
$rest = substr($cidClean, 2);
$cidVariants[] = '_0033' . $rest;
$cidVariants[] = '_+33' . $rest;
}
$lines[] = "; Inbound Route Filtrage Appelant [{$desc}] ({$t['name']}) : DID={$didPattern} / CID={$cidClean}{$timeInfo}";
$vIdx = 0;
foreach ($cidVariants as $cidVar) {
$vIdx++;
$lines[] = "exten => {$didPattern}/{$cidVar},1,NoOp(Inbound Filter [{$desc}] CID=\${CALLERID(num)})";
foreach ($this->buildInboundRouteLines($rule, $setMohLines, $desc, "{$t['id']}_{$routeIdx}_cid_{$vIdx}", $trunkCidPrefix) as $rl) {
$lines[] = $rl;
}
if ($isAnyDid) {
$lines[] = "exten => s/{$cidVar},1,NoOp(Inbound Filter [{$desc}] sur s CID=\${CALLERID(num)})";
foreach ($this->buildInboundRouteLines($rule, $setMohLines, $desc, "{$t['id']}_{$routeIdx}_scid_{$vIdx}", $trunkCidPrefix) as $rl) {
$lines[] = $rl;
}
}
}
}
}
}
if (!$hasCatchAllRoute) {
$defaultDest = !empty($trunks) ? trim((string)$trunks[0]['inbound_dest']) : (!empty($ivrs) ? $ivrs[0]['name'] : 'hangup');
$lines[] = "; Route entrante par défaut (Fallback Any/Any)";
$lines[] = "exten => _X.,1,NoOp(Appel entrant externe par defaut vers \${EXTEN} [CID=\${CALLERID(num)}])";
$lines[] = " same => n," . $this->buildDestinationDialplan($defaultDest);
$lines[] = " same => n,Hangup()";
$lines[] = "";
$lines[] = "exten => s,1,NoOp(Appel entrant externe sur s [CID=\${CALLERID(num)}])";
$lines[] = " same => n," . $this->buildDestinationDialplan($defaultDest);
$lines[] = " same => n,Hangup()";
}
$lines[] = "";
// 3. Contextes SVI / IVR
foreach ($ivrs as $ivr) {
$ivrSlug = $this->slugify($ivr['name']);
$audio = $this->resolveSoundFile($ivr['audio_file'] ?? '', 'demo-instruct');
$timeout = max(2, min(60, (int)($ivr['timeout'] ?? 10)));
$directDial = !empty($ivr['direct_dial']);
$invalidRetries = max(1, min(10, (int)($ivr['invalid_retries'] ?? 3)));
$invalidSound = $this->resolveSoundFile($ivr['invalid_sound_id'] ?? 0, 'pbx-invalid');
$invalidDest = trim((string)($ivr['invalid_dest'] ?? 'hangup')) ?: 'hangup';
$timeoutRetries = max(1, min(10, (int)($ivr['timeout_retries'] ?? 2)));
$timeoutSound = $this->resolveSoundFile($ivr['timeout_sound_id'] ?? 0, '');
$timeoutDest = trim((string)($ivr['timeout_dest'] ?? 'hangup')) ?: 'hangup';
$options = json_decode((string)$ivr['options_json'], true) ?: [];
$lines[] = "; --- Serveur Vocal Interactif : {$ivr['name']} ---";
$lines[] = "[ivr-{$ivrSlug}]";
$lines[] = "exten => s,1,NoOp(SVI {$ivr['name']})";
$lines[] = " same => n,Set(CHANNEL(language)={$sysLang})";
$lines[] = " same => n,Answer()";
$lines[] = " same => n,Wait(1)";
$lines[] = " same => n,Set(IVR_INVALID_COUNT=0)";
$lines[] = " same => n,Set(IVR_TIMEOUT_COUNT=0)";
$lines[] = " same => n(loop),Background({$audio})";
$lines[] = " same => n,WaitExten({$timeout})";
$lines[] = "";
// Numérotation directe des postes internes si activée
if ($directDial && !empty($extensions)) {
$lines[] = "; Numérotation directe des postes autorisée";
foreach ($extensions as $ext) {
$extNum = trim((string)$ext['extension']);
if ($extNum !== '') {
$lines[] = "exten => {$extNum},1,NoOp(SVI {$ivr['name']} - Numérotation directe poste {$extNum})";
$lines[] = " same => n,Goto(from-internal,{$extNum},1)";
}
}
$lines[] = "";
}
// Touches DTMF configurées
if (!empty($options)) {
$lines[] = "; Touches DTMF configurées";
foreach ($options as $digit => $dest) {
$digit = preg_replace('/[^0-9*#]/', '', (string)$digit);
if ($digit === '') {
continue;
}
$lines[] = "exten => {$digit},1,NoOp(SVI {$ivr['name']} - Touche {$digit} -> {$dest})";
$lines[] = " same => n," . $this->buildDestinationDialplan((string)$dest);
$lines[] = " same => n,Hangup()";
}
$lines[] = "";
}
// Gestion de touche invalide (i)
$lines[] = "; Gestion touche invalide (max {$invalidRetries} tentative" . ($invalidRetries > 1 ? 's' : '') . ")";
$lines[] = "exten => i,1,NoOp(SVI {$ivr['name']} - Touche invalide pressee)";
$lines[] = " same => n,Set(IVR_INVALID_COUNT=$[ \${IVR_INVALID_COUNT} + 1 ])";
$lines[] = " same => n,GotoIf($[ \${IVR_INVALID_COUNT} >= {$invalidRetries} ]?invalid_fail)";
$lines[] = " same => n,Playback({$invalidSound})";
$lines[] = " same => n,Goto(s,loop)";
$lines[] = " same => n(invalid_fail),NoOp(SVI {$ivr['name']} - Echec invalide max atteint)";
$lines[] = " same => n,Playback({$invalidSound})";
$lines[] = " same => n," . $this->buildDestinationDialplan($invalidDest);
$lines[] = " same => n,Hangup()";
$lines[] = "";
// Gestion timeout attente (t)
$lines[] = "; Gestion expiration attente (max {$timeoutRetries} tentative" . ($timeoutRetries > 1 ? 's' : '') . ")";
$lines[] = "exten => t,1,NoOp(SVI {$ivr['name']} - Timeout attente expire)";
$lines[] = " same => n,Set(IVR_TIMEOUT_COUNT=$[ \${IVR_TIMEOUT_COUNT} + 1 ])";
$lines[] = " same => n,GotoIf($[ \${IVR_TIMEOUT_COUNT} >= {$timeoutRetries} ]?timeout_fail)";
if ($timeoutSound !== '') {
$lines[] = " same => n,Playback({$timeoutSound})";
}
$lines[] = " same => n,Goto(s,loop)";
$lines[] = " same => n(timeout_fail),NoOp(SVI {$ivr['name']} - Echec timeout max atteint)";
if ($timeoutSound !== '') {
$lines[] = " same => n,Playback({$timeoutSound})";
}
$lines[] = " same => n," . $this->buildDestinationDialplan($timeoutDest);
$lines[] = " same => n,Hangup()";
$lines[] = "";
}
// 5. Contexte dédié Accès Sécurisé Boîte Vocale Directe (*97)
$lines[] = "; ===========================================================================";
$lines[] = "; ACCÈS SÉCURISÉ BOÎTE VOCALE DIRECTE (*97)";
$lines[] = "; ===========================================================================";
$lines[] = "[from-vm-direct]";
foreach ($extensions as $ext) {
$num = trim((string)$ext['extension']);
if (empty($ext['voicemail_enabled'])) {
continue;
}
$forcePin = !empty($ext['force_pin_change']);
$skipPin = !empty($ext['voicemail_skip_pin']);
// Si premier appel (force_pin_change) OU accès direct sans PIN activé depuis son combiné :
// On appelle VoiceMailMain avec le flag ,s pour ne pas demander de mot de passe à la connexion.
// Asterisk détecte alors automatiquement qu'il s'agit d'un nouvel utilisateur (password == extension)
// et démarre directement l'assistant vocal pour choisir son code PIN et enregistrer ses annonces !
if ($forcePin || $skipPin) {
$lines[] = "exten => {$num},1,VoiceMailMain({$num}@default,s)";
$lines[] = " same => n,Hangup()";
} else {
// Demande le mot de passe PIN configuré
$lines[] = "exten => {$num},1,VoiceMailMain({$num}@default)";
$lines[] = " same => n,Hangup()";
}
}
$lines[] = "exten => _X.,1,Playback(vm-incorrect-mailbox)";
$lines[] = " same => n,Hangup()";
$lines[] = "";
return implode("\n", $lines) . "\n";
}
/**
* Génère les classes Musique d'attente (MOH) personnalisées par site/catégorie
*/
public function generateMusiconholdConf(): string
{
$rows = $this->db->query("SELECT * FROM sounds WHERE usage_type = 'moh' ORDER BY id ASC")->fetchAll();
$bySite = [];
foreach ($rows as $r) {
$site = trim((string)$r['site_category']) ?: 'Global';
$bySite[$site][] = $r;
}
// Synchronisation du fichier MOH par défaut dans /var/lib/asterisk/moh/default
$defId = (int)$this->db->query("SELECT default_moh_sound_id FROM settings WHERE id = 1")->fetchColumn();
if ($defId > 0) {
$stmtDef = $this->db->prepare("SELECT * FROM sounds WHERE id = :id");
$stmtDef->execute([':id' => $defId]);
$defSound = $stmtDef->fetch();
if ($defSound) {
$defDir = "/var/lib/asterisk/moh/default";
if (!is_dir($defDir)) {
@mkdir($defDir, 0775, true);
}
@chown($defDir, 'www-data');
@chgrp($defDir, 'asterisk');
@chmod($defDir, 0775);
// Nettoyage des anciens fichiers pour ne garder QUE la musique par défaut active
foreach (glob("{$defDir}/*") ?: [] as $oldF) {
if (is_file($oldF)) @unlink($oldF);
}
$srcWav = "/var/lib/asterisk/sounds/custom/{$defSound['slug']}.wav";
$srcAlaw = "/var/lib/asterisk/sounds/custom/{$defSound['slug']}.alaw";
if (file_exists($srcWav)) {
$dstW = "{$defDir}/{$defSound['slug']}.wav";
@copy($srcWav, $dstW);
@chmod($dstW, 0664);
@chown($dstW, 'www-data');
@chgrp($dstW, 'asterisk');
}
if (file_exists($srcAlaw)) {
$dstA = "{$defDir}/{$defSound['slug']}.alaw";
@copy($srcAlaw, $dstA);
@chmod($dstA, 0664);
@chown($dstA, 'www-data');
@chgrp($dstA, 'asterisk');
}
}
}
$lines = [];
$lines[] = "; ===========================================================================";
$lines[] = "; CLASSES MUSIQUE D'ATTENTE (MOH) GÉNÉRÉES PAR NEXUSPBX (" . date('Y-m-d H:i:s') . ")";
$lines[] = "; ===========================================================================";
$lines[] = "";
// Classe par défaut Asterisk
$lines[] = "[default]";
$lines[] = "mode=files";
$lines[] = "directory=/var/lib/asterisk/moh/default";
$lines[] = "sort=alpha";
$lines[] = "";
// Classes individuelles pour chaque musique d'attente (assignation spécifique Trunk / Route)
foreach ($rows as $r) {
$soundClass = "moh-sound-{$r['slug']}";
$soundDir = "/var/lib/asterisk/moh/sound_{$r['slug']}";
if (!is_dir($soundDir)) {
@mkdir($soundDir, 0775, true);
}
@chown($soundDir, 'www-data');
@chgrp($soundDir, 'asterisk');
@chmod($soundDir, 0775);
$srcWav = "/var/lib/asterisk/sounds/custom/{$r['slug']}.wav";
$srcAlaw = "/var/lib/asterisk/sounds/custom/{$r['slug']}.alaw";
if (file_exists($srcWav)) {
$dstW = "{$soundDir}/{$r['slug']}.wav";
if (!file_exists($dstW) || filesize($dstW) !== filesize($srcWav)) {
@copy($srcWav, $dstW);
@chmod($dstW, 0664);
@chown($dstW, 'www-data');
@chgrp($dstW, 'asterisk');
}
}
if (file_exists($srcAlaw)) {
$dstA = "{$soundDir}/{$r['slug']}.alaw";
if (!file_exists($dstA) || filesize($dstA) !== filesize($srcAlaw)) {
@copy($srcAlaw, $dstA);
@chmod($dstA, 0664);
@chown($dstA, 'www-data');
@chgrp($dstA, 'asterisk');
}
}
$lines[] = "[{$soundClass}]";
$lines[] = "mode=files";
$lines[] = "directory={$soundDir}";
$lines[] = "sort=alpha";
$lines[] = "";
}
foreach ($bySite as $siteName => $sounds) {
$siteSlug = $this->slugify($siteName);
if ($siteSlug === 'global' || $siteSlug === 'default') {
continue;
}
$mohClass = "moh-{$siteSlug}";
$dir = "/var/lib/asterisk/moh/{$siteSlug}";
if (!is_dir($dir)) {
@mkdir($dir, 0775, true);
@chown($dir, 'www-data');
@chgrp($dir, 'asterisk');
}
$lines[] = "[{$mohClass}]";
$lines[] = "mode=files";
$lines[] = "directory={$dir}";
$lines[] = "sort=alpha";
$lines[] = "";
}
return implode("\n", $lines) . "\n";
}
/**
* Génère le fichier de configuration des ports audio / NAT / RTP (/etc/asterisk/rtp.conf)
*/
public function generateRtpConf(): string
{
$settings = $this->db->query('SELECT * FROM settings WHERE id = 1')->fetch() ?: [];
$rtpStart = max(1024, min(65530, (int)($settings['rtp_start'] ?? 10000)));
$rtpEnd = max($rtpStart + 4, min(65535, (int)($settings['rtp_end'] ?? 20000)));
$checksums = !empty($settings['rtp_checksums']) ? 'yes' : 'no';
$strict = !empty($settings['rtp_strict']) ? 'yes' : 'no';
$timeout = max(5, (int)($settings['rtp_timeout'] ?? 30));
$holdTimeout = max(0, (int)($settings['rtp_timeout_hold'] ?? 0));
$keepalive = max(0, (int)($settings['rtp_keepalive'] ?? 15));
$date = date('Y-m-d H:i:s');
$lines = [];
$lines[] = "; ===========================================================================";
$lines[] = "; CONFIGURATION DES PORTS FLUX VOIX AUDIO (RTP) - NexusPBX ($date)";
$lines[] = "; SOURCE UNIQUE DE VÉRITÉ : SQLITE (Paramètres SIP & NAT)";
$lines[] = "; ===========================================================================";
$lines[] = "[general]";
$lines[] = "rtpstart = {$rtpStart}";
$lines[] = "rtpend = {$rtpEnd}";
$lines[] = "rtpchecksums = {$checksums}";
$lines[] = "strictrtp = {$strict}";
$lines[] = "rtptimeout = {$timeout}";
$lines[] = "rtpholdtimeout = {$holdTimeout}";
if ($keepalive > 0) {
$lines[] = "rtpkeepalive = {$keepalive}";
}
$lines[] = "";
return implode("\n", $lines) . "\n";
}
/**
* Génère le fichier de configuration des touches en cours d'appel (/etc/asterisk/features.conf)
*/
public function generateFeaturesConf(): string
{
$date = date('Y-m-d H:i:s');
$lines = [];
$lines[] = "; ===========================================================================";
$lines[] = "; CONFIGURATION DES TOUCHES EN COURS D'APPEL (FEATURES) - NexusPBX ($date)";
$lines[] = "; ===========================================================================";
$lines[] = "[general]";
$lines[] = "featuredigittimeout => 1500";
$lines[] = "atxfernoanswertimeout => 15";
$lines[] = "atxferdropcall => no";
$lines[] = "";
$lines[] = "[featuremap]";
$lines[] = "blindxfer => ##";
$lines[] = "atxfer => *2";
$lines[] = "disconnect => *0";
$lines[] = "automixmon => *1";
$lines[] = "";
$lines[] = "[applicationmap]";
$lines[] = "";
return implode("\n", $lines) . "\n";
}
/**
* Génère le fichier de messagerie vocale (/etc/asterisk/voicemail.custom.conf)
*/
public function generateVoicemailConf(): string
{
$settings = $this->db->query('SELECT * FROM settings WHERE id = 1')->fetch() ?: [];
$extensions = $this->db->query('SELECT * FROM extensions WHERE enabled = 1 ORDER BY CAST(extension AS INTEGER) ASC, extension ASC')->fetchAll();
$date = date('Y-m-d H:i:s');
$lines = [];
$lines[] = "; ===========================================================================";
$lines[] = "; FICHIER GÉNÉRÉ AUTOMATIQUEMENT PAR NEXUSPBX ({$date})";
$lines[] = "; MESSAGERIE VOCALE (VOICEMAIL) - CONTEXTE [default]";
$lines[] = "; ===========================================================================";
$lines[] = "";
$lines[] = "[default](+)";
foreach ($extensions as $ext) {
if (empty($ext['voicemail_enabled'])) {
continue;
}
$num = trim((string)$ext['extension']);
$name = str_replace([',', '|', '"'], ' ', trim((string)$ext['name']));
$email = trim((string)($ext['voicemail_email'] ?? ''));
$pin = trim((string)($ext['voicemail_pin'] ?? ''));
$forcePin = !empty($ext['force_pin_change']);
$attach = !empty($ext['voicemail_attach']) ? 'yes' : 'no';
$delete = !empty($ext['voicemail_delete']) ? 'yes' : 'no';
// Si force_pin_change est actif ou PIN vide, mot de passe initial = numéro d'extension
// ce qui déclenche automatiquement le tutoriel de premier appel Asterisk
$spoolBoxDir = "/var/spool/asterisk/voicemail/default/{$num}";
if ($forcePin || $pin === '') {
$effectivePin = $num;
$options = "attach={$attach}|delete={$delete}|forcename=yes|forcegreetings=yes";
// Supprimer un éventuel secret.conf antérieur pour forcer Asterisk à réinitialiser la boîte au premier appel
if (file_exists("{$spoolBoxDir}/secret.conf")) {
@unlink("{$spoolBoxDir}/secret.conf");
}
} else {
$effectivePin = $pin;
$options = "attach={$attach}|delete={$delete}";
// Mettre à jour secret.conf avec le PIN configuré
if (is_dir($spoolBoxDir)) {
@file_put_contents("{$spoolBoxDir}/secret.conf", $pin . "\n");
@chown("{$spoolBoxDir}/secret.conf", 'asterisk');
@chgrp("{$spoolBoxDir}/secret.conf", 'asterisk');
}
}
$lines[] = "{$num} => {$effectivePin},{$name},{$email},,{$options}";
}
$lines[] = "";
return implode("\n", $lines) . "\n";
}
/**
* Génère la configuration des événements SIP Notify (/etc/asterisk/pjsip_notify.conf)
* Profils check-sync pour Yealink, Fanvil, Snom, Polycom, etc.
*/
public function generatePjsipNotifyConf(): string
{
return <<<EOF
; ===========================================================================
; CONFIGURATION DES NOTIFICATIONS SIP PJSIP - NexusPBX
; Profils de resynchronisation immédiate (check-sync) Yealink, Fanvil, Snom, etc.
; ===========================================================================
[check-sync]
Event=>check-sync\;reboot=false
[yealink-check-cfg]
Event=>check-sync\;reboot=false
[snom-check-cfg]
Event=>check-sync\;reboot=false
[polycom-check-cfg]
Event=>check-sync
[cisco-check-cfg]
Event=>check-sync
[aastra-check-cfg]
Event=>check-sync
EOF;
}
public function writeAllConfigs(): array
{
if (!is_dir(self::BACKUP_DIR)) {
@mkdir(self::BACKUP_DIR, 0770, true);
}
$pjsipContent = $this->generatePjsipConf();
$extensionsContent = $this->generateExtensionsConf();
$mohContent = $this->generateMusiconholdConf();
$rtpContent = $this->generateRtpConf();
$featuresContent = $this->generateFeaturesConf();
$voicemailContent = $this->generateVoicemailConf();
$notifyContent = $this->generatePjsipNotifyConf();
$this->writeAtomicWithPermissions(self::PJSIP_CONF_PATH, $pjsipContent);
$this->writeAtomicWithPermissions(self::EXTENSIONS_CONF_PATH, $extensionsContent);
$this->writeAtomicWithPermissions(self::MOH_CONF_PATH, $mohContent);
$this->writeAtomicWithPermissions(self::RTP_CONF_PATH, $rtpContent);
$this->writeAtomicWithPermissions(self::FEATURES_CONF_PATH, $featuresContent);
$this->writeAtomicWithPermissions(self::VOICEMAIL_CONF_PATH, $voicemailContent);
$this->writeAtomicWithPermissions(self::PJSIP_NOTIFY_CONF_PATH, $notifyContent);
// S'assurer que pjsip.conf et extensions.conf incluent bien les fichiers .custom.conf
SystemChecker::ensureAsteriskIncludes();
$this->db->exec("UPDATE settings SET last_applied_at = CURRENT_TIMESTAMP WHERE id = 1");
return [
'pjsip_path' => self::PJSIP_CONF_PATH,
'extensions_path' => self::EXTENSIONS_CONF_PATH,
'moh_path' => self::MOH_CONF_PATH,
'rtp_path' => self::RTP_CONF_PATH,
'features_path' => self::FEATURES_CONF_PATH,
'voicemail_path' => self::VOICEMAIL_CONF_PATH,
'pjsip_bytes' => strlen($pjsipContent),
'extensions_bytes'=> strlen($extensionsContent),
'moh_bytes' => strlen($mohContent),
'rtp_bytes' => strlen($rtpContent),
'features_bytes' => strlen($featuresContent),
'voicemail_bytes' => strlen($voicemailContent),
];
}
private function writeAtomicWithPermissions(string $targetPath, string $content): void
{
$dir = dirname($targetPath);
if (!is_dir($dir)) {
throw new RuntimeException("Le dossier {$dir} n'existe pas sur ce serveur Debian.");
}
// Sauvegarde horodatée si le fichier existe déjà
if (file_exists($targetPath)) {
$stamp = date('Ymd_His');
$base = basename($targetPath);
@copy($targetPath, self::BACKUP_DIR . "/{$base}.{$stamp}.bak");
}
$tmpPath = $targetPath . '.tmp.' . getmypid();
if (@file_put_contents($tmpPath, $content, LOCK_EX) === false) {
// Repli en écriture directe si le fichier existe déjà et est accessible
if (file_exists($targetPath) && is_writable($targetPath)) {
if (@file_put_contents($targetPath, $content, LOCK_EX) !== false) {
@chgrp($targetPath, 'asterisk');
@chmod($targetPath, 0664);
return;
}
}
throw new RuntimeException("Impossible d'écrire dans {$targetPath}. Vérifiez que www-data fait partie du groupe asterisk et que /etc/asterisk est accessible en écriture de groupe (chmod 2775).");
}
@chgrp($tmpPath, 'asterisk');
@chmod($tmpPath, 0664);
if (!@rename($tmpPath, $targetPath)) {
@unlink($tmpPath);
// Repli en écriture directe si le renommage échoue
if (@file_put_contents($targetPath, $content, LOCK_EX) !== false) {
@chgrp($targetPath, 'asterisk');
@chmod($targetPath, 0664);
return;
}
throw new RuntimeException("Échec du remplacement atomique de {$targetPath}.");
}
@chgrp($targetPath, 'asterisk');
@chmod($targetPath, 0664);
}
public static function normalizeInboundRoutes(string $json): array
{
$decoded = json_decode($json, true);
if (!is_array($decoded) || empty($decoded)) {
return [];
}
// Format liste structurée : [ ['cid' => '...', 'did' => '...', 'description' => '...', 'destination' => '...'], ... ]
if (array_is_list($decoded)) {
$out = [];
foreach ($decoded as $item) {
if (!is_array($item)) {
continue;
}
$cid = trim((string)($item['cid'] ?? 'Any')) ?: 'Any';
$did = trim((string)($item['did'] ?? 'Any')) ?: 'Any';
$desc = trim((string)($item['description'] ?? ''));
$dest = trim((string)($item['destination'] ?? ''));
if ($dest === '') {
continue;
}
$timeCondition = !empty($item['time_condition_enabled']);
$timeStart = preg_match('/^\d{2}:\d{2}$/', (string)($item['time_start'] ?? '')) ? (string)$item['time_start'] : '08:30';
$timeFinish = preg_match('/^\d{2}:\d{2}$/', (string)($item['time_finish'] ?? '')) ? (string)$item['time_finish'] : '18:00';
$weekdays = trim((string)($item['weekdays'] ?? 'mon-fri')) ?: 'mon-fri';
$destClosed = trim((string)($item['time_dest_closed'] ?? 'terminate-hangup')) ?: 'terminate-hangup';
$out[] = [
'cid' => $cid,
'did' => $did,
'description' => $desc ?: ($cid !== 'Any' ? "Filtre {$cid}" : "Route {$did}"),
'destination' => $dest,
'cid_prefix' => trim((string)($item['cid_prefix'] ?? '')),
'time_condition_enabled' => (int)$timeCondition,
'time_start' => $timeStart,
'time_finish' => $timeFinish,
'weekdays' => $weekdays,
'time_dest_closed' => $destClosed,
];
}
return $out;
}
// Rétrocompatibilité ancien format clé/valeur : { "0123456789": "ext-101" }
$out = [];
foreach ($decoded as $did => $dest) {
$d = trim((string)$did);
$v = trim((string)$dest);
if ($d !== '' && $v !== '') {
$out[] = [
'cid' => 'Any',
'did' => $d,
'description' => "SDA {$d}",
'destination' => $v,
'time_condition_enabled' => 0,
'time_start' => '08:30',
'time_finish' => '18:00',
'weekdays' => 'mon-fri',
'time_dest_closed' => 'terminate-hangup',
];
}
}
return $out;
}
private function generateTrunkInboundRoutes(array $t, array $mohSoundsMap, string $tagPrefix): array
{
$lines = [];
$mohSoundId = (int)($t['moh_sound_id'] ?? 0);
$trunkMohClass = ($mohSoundId > 0 && isset($mohSoundsMap[$mohSoundId]))
? 'moh-sound-' . $mohSoundsMap[$mohSoundId]['slug']
: 'default';
$setMohLines = [];
if ($trunkMohClass !== 'default') {
$setMohLines[] = " same => n,Set(__MOHCLASS={$trunkMohClass})";
$setMohLines[] = " same => n,Set(CHANNEL(musicclass)={$trunkMohClass})";
}
$trunkCidPrefix = trim((string)($t['cid_name_prefix'] ?? ''));
$routes = self::normalizeInboundRoutes((string)($t['did_routes_json'] ?? '[]'));
$routeIdx = 0;
$hasAnyRoute = false;
foreach ($routes as $rule) {
$routeIdx++;
$cidRaw = trim((string)($rule['cid'] ?? 'Any'));
$didRaw = trim((string)($rule['did'] ?? 'Any'));
$desc = trim((string)($rule['description'] ?? 'Route entrante'));
$dest = trim((string)($rule['destination'] ?? $t['inbound_dest'] ?? 'hangup'));
$isAnyDid = ($didRaw === '' || strcasecmp($didRaw, 'any') === 0 || $didRaw === '*' || $didRaw === '_X.');
$isAnyCid = ($cidRaw === '' || strcasecmp($cidRaw, 'any') === 0 || $cidRaw === '*');
$didPattern = $isAnyDid ? '_X.' : preg_replace('/[^0-9+XZN._\[\]\-]/', '', $didRaw);
if ($didPattern === '') {
$didPattern = '_X.';
}
$timeInfo = !empty($rule['time_condition_enabled'])
? " [Horaires: {$rule['time_start']}-{$rule['time_finish']} {$rule['weekdays']}]"
: "";
if ($isAnyCid) {
if ($didPattern === '_X.') {
$hasAnyRoute = true;
}
$lines[] = "; Inbound Route [{$desc}] : DID={$didPattern} / Appelant=Any{$timeInfo}";
$lines[] = "exten => {$didPattern},1,NoOp(Inbound [{$desc}] Appelant=\${CALLERID(num)})";
foreach ($this->buildInboundRouteLines($rule, $setMohLines, $desc, "{$tagPrefix}_{$routeIdx}_did", $trunkCidPrefix) as $rl) {
$lines[] = $rl;
}
if ($isAnyDid) {
$lines[] = "exten => s,1,NoOp(Inbound [{$desc}] sur s Appelant=\${CALLERID(num)})";
foreach ($this->buildInboundRouteLines($rule, $setMohLines, $desc, "{$tagPrefix}_{$routeIdx}_s", $trunkCidPrefix) as $rl) {
$lines[] = $rl;
}
}
} else {
$cidClean = preg_replace('/[^0-9+XZN._\[\]\-]/', '', $cidRaw);
if ($cidClean === '') {
continue;
}
$cidVariants = [$cidClean];
if (str_starts_with($cidClean, '_0') && strlen($cidClean) > 2 && $cidClean[2] !== '0') {
$rest = substr($cidClean, 2);
$cidVariants[] = '_0033' . $rest;
$cidVariants[] = '_+33' . $rest;
}
$lines[] = "; Inbound Route Filtrage Appelant [{$desc}] : DID={$didPattern} / CID={$cidClean}{$timeInfo}";
$vIdx = 0;
foreach ($cidVariants as $cidVar) {
$vIdx++;
$lines[] = "exten => {$didPattern}/{$cidVar},1,NoOp(Inbound Filter [{$desc}] CID=\${CALLERID(num)})";
foreach ($this->buildInboundRouteLines($rule, $setMohLines, $desc, "{$tagPrefix}_{$routeIdx}_cid_{$vIdx}", $trunkCidPrefix) as $rl) {
$lines[] = $rl;
}
if ($isAnyDid) {
$lines[] = "exten => s/{$cidVar},1,NoOp(Inbound Filter [{$desc}] sur s CID=\${CALLERID(num)})";
foreach ($this->buildInboundRouteLines($rule, $setMohLines, $desc, "{$tagPrefix}_{$routeIdx}_scid_{$vIdx}", $trunkCidPrefix) as $rl) {
$lines[] = $rl;
}
}
}
}
}
if (!$hasAnyRoute) {
$defaultDest = trim((string)($t['inbound_dest'] ?? 'hangup')) ?: 'hangup';
$lines[] = "; Fallback Inbound Trunk {$t['name']}";
$lines[] = "exten => _X.,1,NoOp(Inbound Trunk {$t['name']} par defaut vers \${EXTEN})";
foreach ($this->buildInboundRouteLines(['destination' => $defaultDest], $setMohLines, "Fallback {$t['name']}", "{$tagPrefix}_fb", $trunkCidPrefix) as $rl) {
$lines[] = $rl;
}
$lines[] = "exten => s,1,NoOp(Inbound Trunk {$t['name']} sur s)";
foreach ($this->buildInboundRouteLines(['destination' => $defaultDest], $setMohLines, "Fallback {$t['name']}", "{$tagPrefix}_fbs", $trunkCidPrefix) as $rl) {
$lines[] = $rl;
}
}
return $lines;
}
private function buildInboundRouteLines(array $rule, array $setMohLines, string $desc, string $tagId, string $trunkCidPrefix = ''): array
{
$dest = trim((string)($rule['destination'] ?? 'hangup'));
$timeEnabled = !empty($rule['time_condition_enabled']);
$timeStart = trim((string)($rule['time_start'] ?? '08:30'));
$timeFinish = trim((string)($rule['time_finish'] ?? '18:00'));
$weekdays = trim((string)($rule['weekdays'] ?? 'mon-fri'));
$destClosed = trim((string)($rule['time_dest_closed'] ?? 'terminate-hangup'));
$timeRange = "{$timeStart}-{$timeFinish}";
$out = [];
foreach ($setMohLines as $sml) {
$out[] = $sml;
}
$cidPrefix = trim((string)($rule['cid_prefix'] ?? '')) ?: $trunkCidPrefix;
if ($cidPrefix !== '' && !str_starts_with($dest, 'terminate-') && $dest !== 'hangup' && $dest !== 'busy' && $dest !== 'congestion') {
$formattedPrefix = rtrim($cidPrefix) . ' ';
$out[] = " same => n,ExecIf(\$[\"\${CALLERID(name)}\" = \"\"]?Set(CALLERID(name)=\${CALLERID(num)}))";
$out[] = " same => n,Set(CALLERID(name)={$formattedPrefix}\${CALLERID(name)})";
}
if ($timeEnabled) {
$tag = "tc_{$tagId}";
$out[] = " same => n,GotoIfTime({$timeRange},{$weekdays},*,*?{$tag}_open)";
$out[] = " same => n,NoOp(Inbound [{$desc}] - Fermé / Hors horaires [{$timeRange}/{$weekdays}] -> {$destClosed})";
$out[] = " same => n," . $this->buildDestinationDialplan($destClosed);
$out[] = " same => n,Hangup()";
$out[] = " same => n({$tag}_open),NoOp(Inbound [{$desc}] - Ouvert [{$timeRange}/{$weekdays}] -> {$dest})";
$out[] = " same => n," . $this->buildDestinationDialplan($dest);
$out[] = " same => n,Hangup()";
} else {
$out[] = " same => n," . $this->buildDestinationDialplan($dest);
$out[] = " same => n,Hangup()";
}
return $out;
}
private function buildDestinationDialplan(string $dest): string
{
$dest = trim($dest);
if ($dest === '' || $dest === 'hangup' || $dest === 'terminate-hangup') {
return 'Goto(app-terminate,hangup,1)';
}
if ($dest === 'busy' || $dest === 'terminate-busy') {
return 'Goto(app-terminate,busy,1)';
}
if ($dest === 'congestion' || $dest === 'terminate-congestion') {
return 'Goto(app-terminate,congestion,1)';
}
if ($dest === 'all-circuits-busy' || $dest === 'voice-all-circuits-busy') {
return 'Goto(app-terminate,all-circuits-busy,1)';
}
if ($dest === 'zapateller' || $dest === 'terminate-zapateller') {
return 'Goto(app-terminate,zapateller,1)';
}
if ($dest === 'ringforever' || $dest === 'terminate-ringforever') {
return 'Goto(app-terminate,ringforever,1)';
}
if ($dest === 'moh-default') {
return 'Goto(app-scam-tarpit,moh-default,1)';
}
if (str_starts_with($dest, 'moh-')) {
$mohId = substr($dest, 4);
$stmt = $this->db->prepare('SELECT slug FROM sounds WHERE id = :id');
$stmt->execute([':id' => (int)$mohId]);
$slug = $stmt->fetchColumn();
$mohClass = $slug ? "moh-sound-{$slug}" : 'default';
return "Goto(app-scam-tarpit,moh-{$mohClass},1)";
}
if (str_starts_with($dest, 'sound-loop-')) {
$sndId = substr($dest, 11);
$stmt = $this->db->prepare('SELECT slug FROM sounds WHERE id = :id');
$stmt->execute([':id' => (int)$sndId]);
$slug = $stmt->fetchColumn();
if ($slug) {
return "Goto(app-scam-tarpit,loop-{$slug},1)";
}
return 'Goto(app-terminate,hangup,1)';
}
if (str_starts_with($dest, 'sound-')) {
$snd = $this->resolveSoundFile(substr($dest, 6));
if ($snd !== '') {
$safeTarget = str_replace('/', '-', $snd);
return "Goto(app-terminate,play-{$safeTarget},1)";
}
return 'Goto(app-terminate,hangup,1)';
}
if (str_starts_with($dest, 'custom-') || str_starts_with($dest, 'mobile-') || str_starts_with($dest, 'external-')) {
$num = preg_replace('/^(custom|mobile|external)-/', '', $dest);
return "Goto(from-internal,{$num},1)";
}
if (preg_match('/^\d+$/', $dest)) {
return "Goto(from-internal,{$dest},1)";
}
if (str_starts_with($dest, 'page-')) {
$pageExt = substr($dest, 5);
return "Goto(from-internal,{$pageExt},1)";
}
if (str_starts_with($dest, 'ext-')) {
$ext = substr($dest, 4);
return "Goto(from-internal,{$ext},1)";
}
if (str_starts_with($dest, 'grp-')) {
$grp = substr($dest, 4);
return "Goto(from-internal,{$grp},1)";
}
if (str_starts_with($dest, 'ivr-')) {
$slug = $this->slugify($dest);
return "Goto(ivr-{$slug},s,1)";
}
// Si c'est directement le nom d'un IVR
$slug = $this->slugify($dest);
return "Goto(ivr-{$slug},s,1)";
}
private function resolveSoundFile($soundRef, string $fallback = ''): string
{
if (empty($soundRef)) {
return $fallback;
}
if (is_numeric($soundRef)) {
$stmt = $this->db->prepare('SELECT slug FROM sounds WHERE id = :id');
$stmt->execute([':id' => (int)$soundRef]);
$slug = $stmt->fetchColumn();
return $slug ? "/var/lib/asterisk/sounds/custom/{$slug}" : $fallback;
}
$ref = trim((string)$soundRef);
if (str_starts_with($ref, 'sound-')) {
return $this->resolveSoundFile(substr($ref, 6), $fallback);
}
if (str_starts_with($ref, 'custom/')) {
return "/var/lib/asterisk/sounds/{$ref}";
}
if (str_starts_with($ref, '/var/lib/asterisk/sounds/custom/')) {
return $ref;
}
if (!str_contains($ref, '/') && !in_array($ref, ['demo-instruct', 'demo-echotest', 'pbx-invalid', 'hello-world', 'beep', 'silence/1', 'vm-goodbye'])) {
$stmt = $this->db->prepare('SELECT slug FROM sounds WHERE slug = :slug OR name = :name');
$stmt->execute([':slug' => $ref, ':name' => $ref]);
$slug = $stmt->fetchColumn();
if ($slug) {
return "/var/lib/asterisk/sounds/custom/{$slug}";
}
}
return $ref ?: $fallback;
}
private function slugify(string $text): string
{
$slug = strtolower(trim($text));
$slug = preg_replace('/^ivr-/', '', $slug);
$slug = preg_replace('/[^a-z0-9]+/', '-', $slug);
return trim($slug, '-') ?: 'default';
}
/**
* Formate les instructions Dialplan pour le positionnement du Caller ID (Nom, Numéro, Présentation, CallerID all)
* afin de garantir la transmission du CNAM (Caller ID Name) et du CLI par Asterisk PJSIP.
*
* @param string $cidString Chaîne du type '"Sytelis Consulting" <0422560185>', 'Sytelis <0422560185>', '0422560185' ou 'Sytelis'
* @param string $indent
* @return array<string>
*/
private function buildCallerIdDialplan(string $cidString, string $indent = ' same => n,'): array
{
$cidString = trim($cidString);
if ($cidString === '') {
return [];
}
$name = '';
$num = '';
// Formats reconnus :
// 1. "Nom Complet" <0422560185> ou Nom Complet <0422560185>
if (preg_match('/^(?:\"?([^\"<>]+?)\"?\s*)?<([^>]+)>$/u', $cidString, $m)) {
$name = trim($m[1] ?? '');
$num = trim($m[2] ?? '');
} elseif (preg_match('/^<([^>]+)>$/', $cidString, $m)) {
$num = trim($m[1]);
} elseif (preg_match('/^\+?[0-9*#]+$/', $cidString)) {
$num = $cidString;
} else {
// Uniquement un nom
$name = trim($cidString, " \t\n\r\0\x0B\"");
}
$lines = [];
if ($num !== '') {
$lines[] = "{$indent}Set(CALLERID(num)={$num})";
$lines[] = "{$indent}Set(CALLERID(num-pres)=allowed_passed_screen)";
}
if ($name !== '') {
$cleanName = str_replace('"', '', $name);
$lines[] = "{$indent}Set(CALLERID(name)=\"{$cleanName}\")";
$lines[] = "{$indent}Set(CALLERID(name-pres)=allowed_passed_screen)";
}
if ($name !== '' && $num !== '') {
$cleanName = str_replace('"', '', $name);
$lines[] = "{$indent}Set(CALLERID(all)=\"{$cleanName}\" <{$num}>)";
} elseif ($num !== '') {
$lines[] = "{$indent}Set(CALLERID(all)={$num})";
} elseif ($name !== '') {
$cleanName = str_replace('"', '', $name);
$lines[] = "{$indent}Set(CALLERID(all)=\"{$cleanName}\")";
}
return $lines;
}
}