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)=\\;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 <<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 */ 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; } }