Initial commit - NexusPBX IP Telephony Platform

This commit is contained in:
sorcier committed 2026-11-02 02:24:47 +01:00
commit c5de8d0c71
25 files changed
+18123

No files matched your search

+303
View File
@@ -0,0 +1,303 @@
<?php
declare(strict_types=1);
namespace App;
use PDO;
use Exception;
use RuntimeException;
use InvalidArgumentException;
class PhonebookManager
{
public const AVATARS_DIR = '/var/www/nexus-pbx/public/avatars';
/**
* Initialise le dossier des avatars.
*/
public static function initDirectory(): void
{
if (!is_dir(self::AVATARS_DIR)) {
@mkdir(self::AVATARS_DIR, 0775, true);
}
@chown(self::AVATARS_DIR, 'www-data');
@chgrp(self::AVATARS_DIR, 'www-data');
}
/**
* Synchronise automatiquement les postes d'extensions vers l'annuaire d'entreprise.
*/
public static function syncExtensions(PDO $db): void
{
// 1. Récupère la cartographie des groupes d'extensions
$egRows = $db->query("SELECT id, name, members FROM extension_groups")->fetchAll();
$extToGroup = [];
foreach ($egRows as $eg) {
$members = json_decode((string)$eg['members'], true) ?: [];
foreach ($members as $extNum) {
if (!isset($extToGroup[(string)$extNum])) {
$extToGroup[(string)$extNum] = trim((string)$eg['name']);
}
}
}
// 2. Récupère tous les postes
$exts = $db->query("SELECT id, extension, name, voicemail_email FROM extensions")->fetchAll();
$activeExtIds = [];
$findStmt = $db->prepare("SELECT id, display_name, internal_number, email, group_name FROM phonebook_contacts WHERE extension_id = :ext_id OR (extension_id IS NULL AND is_internal = 1 AND internal_number = :num)");
$insertStmt = $db->prepare("
INSERT INTO phonebook_contacts (extension_id, display_name, internal_number, email, group_name, is_internal)
VALUES (:ext_id, :name, :num, :email, :grp, 1)
");
$updateStmt = $db->prepare("
UPDATE phonebook_contacts
SET extension_id = :ext_id, display_name = :name, internal_number = :num, email = :email, group_name = :grp, updated_at = CURRENT_TIMESTAMP
WHERE id = :id
");
foreach ($exts as $ext) {
$extId = (int)$ext['id'];
$extNum = trim((string)$ext['extension']);
$extName = trim((string)$ext['name']);
$email = trim((string)($ext['voicemail_email'] ?? ''));
$group = $extToGroup[$extNum] ?? 'Général';
$activeExtIds[] = $extId;
$findStmt->execute([':ext_id' => $extId, ':num' => $extNum]);
$existing = $findStmt->fetch(PDO::FETCH_ASSOC);
if ($existing) {
$updateStmt->execute([
':id' => (int)$existing['id'],
':ext_id' => $extId,
':name' => $extName,
':num' => $extNum,
':email' => $email,
':grp' => $group,
]);
} else {
$insertStmt->execute([
':ext_id' => $extId,
':name' => $extName,
':num' => $extNum,
':email' => $email,
':grp' => $group,
]);
}
}
// 3. Supprime les contacts internes orphelins (si extension supprimée)
if (!empty($activeExtIds)) {
$inClause = implode(',', $activeExtIds);
$db->exec("DELETE FROM phonebook_contacts WHERE is_internal = 1 AND extension_id IS NOT NULL AND extension_id NOT IN ({$inClause})");
}
}
/**
* Récupère la liste des contacts filtrée ou non par groupe.
*/
public static function getContacts(PDO $db, ?string $group = null): array
{
self::syncExtensions($db);
$params = [];
$sql = "SELECT * FROM phonebook_contacts";
if ($group !== null && trim($group) !== '' && strtolower(trim($group)) !== 'all' && strtolower(trim($group)) !== 'tous') {
$cleanGroup = trim($group);
$sql .= " WHERE LOWER(group_name) = LOWER(:grp) OR LOWER(group_name) LIKE LOWER(:grp_like) OR LOWER(group_name) IN ('tous', 'all')";
$params[':grp'] = $cleanGroup;
$params[':grp_like'] = "%{$cleanGroup}%";
}
$sql .= " ORDER BY display_name COLLATE NOCASE ASC";
$stmt = $db->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
/**
* Récupère la liste de tous les groupes d'annuaire disponibles.
*/
public static function getGroups(PDO $db): array
{
$groups = [];
$egRows = $db->query("SELECT name FROM extension_groups ORDER BY name ASC")->fetchAll(PDO::FETCH_COLUMN);
foreach ($egRows as $g) {
$g = trim((string)$g);
if ($g !== '') $groups[$g] = true;
}
$pbRows = $db->query("SELECT DISTINCT group_name FROM phonebook_contacts WHERE group_name != ''")->fetchAll(PDO::FETCH_COLUMN);
foreach ($pbRows as $g) {
$g = trim((string)$g);
if ($g !== '') $groups[$g] = true;
}
// Ajoute Issy-Les-Moulineaux si pas encore présent
if (!isset($groups['Issy-Les-Moulineaux'])) {
$groups['Issy-Les-Moulineaux'] = true;
}
$result = array_keys($groups);
sort($result, SORT_NATURAL | SORT_FLAG_CASE);
return $result;
}
/**
* Enregistre un contact (création ou mise à jour).
*/
public static function saveContact(PDO $db, array $data): int
{
$id = isset($data['id']) && $data['id'] !== '' ? (int)$data['id'] : null;
$name = trim((string)($data['display_name'] ?? ''));
$internal = trim((string)($data['internal_number'] ?? ''));
$mobile = trim((string)($data['mobile_number'] ?? ''));
$other = trim((string)($data['other_number'] ?? ''));
$email = trim((string)($data['email'] ?? ''));
$photo = trim((string)($data['photo_url'] ?? ''));
$group = trim((string)($data['group_name'] ?? 'Général')) ?: 'Général';
$isInternal = !empty($data['is_internal']) ? 1 : 0;
$extensionId = !empty($data['extension_id']) ? (int)$data['extension_id'] : null;
if ($name === '') {
throw new InvalidArgumentException("Le nom du contact est obligatoire.");
}
if ($internal === '' && $mobile === '' && $other === '') {
throw new InvalidArgumentException("Veuillez renseigner au moins un numéro de téléphone (Poste, Mobile ou Autre).");
}
if ($id) {
$stmt = $db->prepare("
UPDATE phonebook_contacts
SET display_name = :name, internal_number = :internal, mobile_number = :mobile,
other_number = :other, email = :email, photo_url = :photo, group_name = :grp,
is_internal = :is_int, extension_id = :ext_id, updated_at = CURRENT_TIMESTAMP
WHERE id = :id
");
$stmt->execute([
':id' => $id,
':name' => $name,
':internal' => $internal,
':mobile' => $mobile,
':other' => $other,
':email' => $email,
':photo' => $photo,
':grp' => $group,
':is_int' => $isInternal,
':ext_id' => $extensionId,
]);
return $id;
} else {
$stmt = $db->prepare("
INSERT INTO phonebook_contacts (display_name, internal_number, mobile_number, other_number, email, photo_url, group_name, is_internal, extension_id)
VALUES (:name, :internal, :mobile, :other, :email, :photo, :grp, :is_int, :ext_id)
");
$stmt->execute([
':name' => $name,
':internal' => $internal,
':mobile' => $mobile,
':other' => $other,
':email' => $email,
':photo' => $photo,
':grp' => $group,
':is_int' => $isInternal,
':ext_id' => $extensionId,
]);
return (int)$db->lastInsertId();
}
}
/**
* Supprime un contact d'annuaire.
*/
public static function deleteContact(PDO $db, int $id): bool
{
$stmt = $db->prepare("DELETE FROM phonebook_contacts WHERE id = :id");
return $stmt->execute([':id' => $id]);
}
/**
* Détermine l'URL de base pour l'accès aux photos et avatars depuis les postes IP.
*/
public static function getBaseUrl(?PDO $db = null): string
{
if (!empty($_SERVER['HTTP_HOST'])) {
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
return "{$scheme}://" . $_SERVER['HTTP_HOST'];
}
if ($db) {
try {
$st = $db->query("SELECT domain, external_ip FROM settings WHERE id = 1")->fetch(PDO::FETCH_ASSOC);
if (!empty($st['external_ip'])) {
return "http://" . $st['external_ip'] . ":8080";
}
if (!empty($st['domain']) && $st['domain'] !== 'pbx.local') {
return "http://" . $st['domain'] . ":8080";
}
} catch (\Throwable $e) {}
}
$serverIp = !empty($_SERVER['SERVER_ADDR']) ? $_SERVER['SERVER_ADDR'] : '127.0.0.1';
return "http://{$serverIp}:8080";
}
/**
* Génère le flux XML officiel Yealink IP Phone Remote Address Book.
*/
public static function renderYealinkXml(PDO $db, ?string $group = null): string
{
$contacts = self::getContacts($db, $group);
$baseUrl = self::getBaseUrl($db);
$groupTitle = ($group !== null && trim($group) !== '' && strtolower(trim($group)) !== 'all')
? trim($group)
: 'NexusPBX';
$xml = new \SimpleXMLElement('<?xml version="1.0" encoding="UTF-8"?><YealinkIPPhoneDirectory/>');
$xml->addChild('Title', "Annuaire " . htmlspecialchars($groupTitle, ENT_XML1, 'UTF-8'));
foreach ($contacts as $c) {
$name = trim((string)$c['display_name']);
$phone1 = trim((string)$c['internal_number']);
$phone2 = trim((string)$c['mobile_number']);
$phone3 = trim((string)$c['other_number']);
$email = trim((string)($c['email'] ?? ''));
$photo = trim((string)($c['photo_url'] ?? ''));
if ($phone1 === '' && $phone2 !== '') {
$phone1 = $phone2;
$phone2 = '';
}
if ($phone1 === '' && $phone2 === '' && $phone3 === '') {
continue;
}
$entry = $xml->addChild('DirectoryEntry');
$entry->addChild('Name', htmlspecialchars($name, ENT_XML1, 'UTF-8'));
$entry->addChild('Telephone', htmlspecialchars($phone1, ENT_XML1, 'UTF-8'));
if ($phone2 !== '') {
$entry->addChild('Mobile', htmlspecialchars($phone2, ENT_XML1, 'UTF-8'));
}
if ($phone3 !== '') {
$entry->addChild('OtherTelephone', htmlspecialchars($phone3, ENT_XML1, 'UTF-8'));
}
if ($email !== '') {
$entry->addChild('Email', htmlspecialchars($email, ENT_XML1, 'UTF-8'));
}
if ($photo !== '') {
$fullPhotoUrl = (strpos($photo, '://') === false)
? rtrim($baseUrl, '/') . '/' . ltrim($photo, '/')
: $photo;
$entry->addChild('Photo', htmlspecialchars($fullPhotoUrl, ENT_XML1, 'UTF-8'));
$entry->addChild('Picture', htmlspecialchars($fullPhotoUrl, ENT_XML1, 'UTF-8'));
}
}
$dom = dom_import_simplexml($xml)->ownerDocument;
$dom->formatOutput = true;
return $dom->saveXML();
}
}