feat(ucp): NexusPBX v2.5.0 Portail Utilisateur isolé (Refs #4)
This commit is contained in:
1 parent
7922e338e3
commit
72e43f1a9a
18 files changed
+3499
-62
No files matched your search
@@ -0,0 +1,787 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
use PDO;
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
|
||||
class UcpManager
|
||||
{
|
||||
public const VOICEMAIL_SPOOL_BASE = '/var/spool/asterisk/voicemail/default';
|
||||
|
||||
// =========================================================================
|
||||
// 1. GESTION ADMIN DES UTILISATEURS UCP
|
||||
// =========================================================================
|
||||
|
||||
public static function listUsers(PDO $db): array
|
||||
{
|
||||
$users = $db->query('
|
||||
SELECT id, username, display_name, email, extensions_json, enabled, last_login_at, last_login_ip, created_at, updated_at
|
||||
FROM ucp_users
|
||||
ORDER BY username ASC
|
||||
')->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
||||
|
||||
$extRows = $db->query('SELECT extension, name, enabled FROM extensions ORDER BY extension ASC')->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
||||
$extMap = [];
|
||||
foreach ($extRows as $e) {
|
||||
$extMap[(string)$e['extension']] = [
|
||||
'extension' => (string)$e['extension'],
|
||||
'name' => (string)$e['name'],
|
||||
'enabled' => (int)$e['enabled'],
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($users as &$u) {
|
||||
$exts = json_decode((string)($u['extensions_json'] ?? '[]'), true);
|
||||
$u['extensions'] = is_array($exts) ? array_values(array_map('strval', $exts)) : [];
|
||||
$u['extensions_meta'] = array_values(array_filter(array_map(
|
||||
fn(string $num) => $extMap[$num] ?? ['extension' => $num, 'name' => 'Poste inconnu', 'enabled' => 0],
|
||||
$u['extensions']
|
||||
)));
|
||||
$u['enabled'] = (int)$u['enabled'];
|
||||
unset($u['extensions_json']);
|
||||
}
|
||||
unset($u);
|
||||
|
||||
return $users;
|
||||
}
|
||||
|
||||
public static function saveUser(PDO $db, array $data): array
|
||||
{
|
||||
$id = isset($data['id']) && (int)$data['id'] > 0 ? (int)$data['id'] : null;
|
||||
$username = trim((string)($data['username'] ?? ''));
|
||||
$displayName = trim((string)($data['display_name'] ?? ''));
|
||||
$email = trim((string)($data['email'] ?? ''));
|
||||
$password = (string)($data['password'] ?? '');
|
||||
$enabled = isset($data['enabled']) ? (!empty($data['enabled']) ? 1 : 0) : 1;
|
||||
$rawExts = is_array($data['extensions'] ?? null) ? $data['extensions'] : [];
|
||||
|
||||
if (!preg_match('/^[a-zA-Z0-9._@\-]{2,64}$/', $username)) {
|
||||
throw new InvalidArgumentException("L'identifiant UCP doit contenir 2 à 64 caractères (lettres, chiffres, . _ @ -).");
|
||||
}
|
||||
if ($email !== '' && !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
throw new InvalidArgumentException("Adresse e-mail invalide.");
|
||||
}
|
||||
if (!$id && strlen($password) < 6) {
|
||||
throw new InvalidArgumentException("Le mot de passe initial doit contenir au moins 6 caractères.");
|
||||
}
|
||||
if ($id && $password !== '' && strlen($password) < 6) {
|
||||
throw new InvalidArgumentException("Le nouveau mot de passe doit contenir au moins 6 caractères.");
|
||||
}
|
||||
|
||||
// Ne conserver que des extensions valides existantes
|
||||
$validExts = $db->query('SELECT extension FROM extensions')->fetchAll(PDO::FETCH_COLUMN) ?: [];
|
||||
$validSet = array_map('strval', $validExts);
|
||||
$cleanExts = [];
|
||||
foreach ($rawExts as $ext) {
|
||||
$extStr = trim((string)$ext);
|
||||
if ($extStr !== '' && in_array($extStr, $validSet, true) && !in_array($extStr, $cleanExts, true)) {
|
||||
$cleanExts[] = $extStr;
|
||||
}
|
||||
}
|
||||
if (empty($cleanExts)) {
|
||||
throw new InvalidArgumentException("Veuillez lier au moins un poste SIP à cet utilisateur UCP.");
|
||||
}
|
||||
|
||||
$extJson = json_encode($cleanExts);
|
||||
|
||||
// Vérification d'unicité du username
|
||||
$dupStmt = $db->prepare('SELECT id FROM ucp_users WHERE username = :u' . ($id ? ' AND id != :id' : ''));
|
||||
$dupParams = [':u' => $username];
|
||||
if ($id) {
|
||||
$dupParams[':id'] = $id;
|
||||
}
|
||||
$dupStmt->execute($dupParams);
|
||||
if ($dupStmt->fetchColumn()) {
|
||||
throw new InvalidArgumentException("Un compte UCP avec l'identifiant « {$username} » existe déjà.");
|
||||
}
|
||||
|
||||
if ($id) {
|
||||
if ($password !== '') {
|
||||
$stmt = $db->prepare('
|
||||
UPDATE ucp_users
|
||||
SET username = :u, display_name = :dn, email = :em, password_hash = :ph,
|
||||
extensions_json = :exts, enabled = :en, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = :id
|
||||
');
|
||||
$stmt->execute([
|
||||
':id' => $id,
|
||||
':u' => $username,
|
||||
':dn' => $displayName ?: $username,
|
||||
':em' => $email,
|
||||
':ph' => password_hash($password, PASSWORD_DEFAULT),
|
||||
':exts' => $extJson,
|
||||
':en' => $enabled,
|
||||
]);
|
||||
} else {
|
||||
$stmt = $db->prepare('
|
||||
UPDATE ucp_users
|
||||
SET username = :u, display_name = :dn, email = :em,
|
||||
extensions_json = :exts, enabled = :en, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = :id
|
||||
');
|
||||
$stmt->execute([
|
||||
':id' => $id,
|
||||
':u' => $username,
|
||||
':dn' => $displayName ?: $username,
|
||||
':em' => $email,
|
||||
':exts' => $extJson,
|
||||
':en' => $enabled,
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
$stmt = $db->prepare('
|
||||
INSERT INTO ucp_users (username, display_name, email, password_hash, extensions_json, enabled)
|
||||
VALUES (:u, :dn, :em, :ph, :exts, :en)
|
||||
');
|
||||
$stmt->execute([
|
||||
':u' => $username,
|
||||
':dn' => $displayName ?: $username,
|
||||
':em' => $email,
|
||||
':ph' => password_hash($password, PASSWORD_DEFAULT),
|
||||
':exts' => $extJson,
|
||||
':en' => $enabled,
|
||||
]);
|
||||
}
|
||||
|
||||
return self::listUsers($db);
|
||||
}
|
||||
|
||||
public static function deleteUser(PDO $db, int $id): array
|
||||
{
|
||||
$db->prepare('DELETE FROM ucp_users WHERE id = :id')->execute([':id' => $id]);
|
||||
return self::listUsers($db);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 2. ÉTAT TEMPS RÉEL DES POSTES & RENVOIS (DND / CF / CFU)
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Retourne l'état live (en ligne, latence, IP, DND, CF, CFU, compteurs messagerie) des extensions autorisées.
|
||||
*/
|
||||
public static function getExtensionsState(PDO $db, array $allowedExts): array
|
||||
{
|
||||
$extNums = array_map(fn($e) => (string)$e['extension'], $allowedExts);
|
||||
if (empty($extNums)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$contactsByExt = [];
|
||||
$dndMap = [];
|
||||
$cfMap = [];
|
||||
$cfuMap = [];
|
||||
|
||||
try {
|
||||
$ami = AmiClient::fromDatabase($db);
|
||||
|
||||
// Contacts PJSIP & métadonnées AstDB
|
||||
$rawContacts = $ami->runCommand('pjsip show contacts');
|
||||
$rawRegMeta = $ami->runCommand('database show registrar/contact');
|
||||
$contactMeta = [];
|
||||
foreach (preg_split('/\r?\n/', $rawRegMeta) ?: [] as $dbLine) {
|
||||
if (preg_match('/^\/registrar\/contact\/([^;]+);@[a-f0-9]+:\s*(\{.*\})/i', $dbLine, $dbM)) {
|
||||
$parsed = json_decode($dbM[2], true);
|
||||
if ($parsed) {
|
||||
$contactMeta[$dbM[1]] = $parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (preg_split('/\r?\n/', $rawContacts) ?: [] as $line) {
|
||||
if (preg_match('/Contact:\s+([^\s\/]+)\/(?:sips?:)?([^\s]+)\s+([a-zA-Z0-9]+)\s+(\S+)(?:\s+(\S+))?/i', $line, $m)) {
|
||||
$extNum = $m[1];
|
||||
if (!in_array($extNum, $extNums, true)) {
|
||||
continue;
|
||||
}
|
||||
$status = $m[4];
|
||||
$rttRaw = $m[5] ?? null;
|
||||
$isAvail = (stripos($status, 'Avail') !== false && stripos($status, 'Unavail') === false) || stripos($status, 'NonQual') !== false;
|
||||
$meta = $contactMeta[$extNum] ?? [];
|
||||
$fullUri = $meta['uri'] ?? ('sip:' . $m[2]);
|
||||
|
||||
$ip = '';
|
||||
if (preg_match('/@([^:;>]+)/', $fullUri, $ipM)) {
|
||||
$ip = $ipM[1];
|
||||
}
|
||||
|
||||
$contactsByExt[$extNum] = [
|
||||
'online' => $isAvail,
|
||||
'status' => $status,
|
||||
'rtt_ms' => ($rttRaw !== null && is_numeric($rttRaw)) ? round((float)$rttRaw, 1) : null,
|
||||
'ip' => $ip,
|
||||
'user_agent' => (string)($meta['user_agent'] ?? ''),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// États DND / CF / CFU depuis AstDB
|
||||
$dndMap = self::parseAstDbPrefix($ami->runCommand('database show DND'), '/DND/');
|
||||
$cfMap = self::parseAstDbPrefix($ami->runCommand('database show CF'), '/CF/');
|
||||
$cfuMap = self::parseAstDbPrefix($ami->runCommand('database show CFU'), '/CFU/');
|
||||
} catch (\Throwable) {
|
||||
// Asterisk indisponible : on renvoie les métadonnées locales sans bloquer l'interface
|
||||
}
|
||||
|
||||
// Photo & email annuaire liés
|
||||
$pbByExtId = [];
|
||||
try {
|
||||
$pbRows = $db->query('SELECT extension_id, email, photo_url FROM phonebook_contacts WHERE is_internal = 1 AND extension_id IS NOT NULL')
|
||||
->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
||||
foreach ($pbRows as $p) {
|
||||
$pbByExtId[(int)$p['extension_id']] = $p;
|
||||
}
|
||||
} catch (\Throwable) {}
|
||||
|
||||
$result = [];
|
||||
foreach ($allowedExts as $ext) {
|
||||
$num = (string)$ext['extension'];
|
||||
$extId = (int)$ext['id'];
|
||||
$cInfo = $contactsByExt[$num] ?? ['online' => false, 'status' => 'Unavailable', 'rtt_ms' => null, 'ip' => '', 'user_agent' => ''];
|
||||
$vmCounts = !empty($ext['voicemail_enabled']) ? self::countVoicemails($num) : ['inbox' => 0, 'old' => 0];
|
||||
$pb = $pbByExtId[$extId] ?? [];
|
||||
|
||||
$result[] = [
|
||||
'id' => $extId,
|
||||
'extension' => $num,
|
||||
'name' => (string)$ext['name'],
|
||||
'transport' => (string)$ext['transport'],
|
||||
'online' => $cInfo['online'],
|
||||
'status_label' => $cInfo['status'],
|
||||
'rtt_ms' => $cInfo['rtt_ms'],
|
||||
'ip' => $cInfo['ip'],
|
||||
'user_agent' => $cInfo['user_agent'],
|
||||
'dnd' => strtoupper((string)($dndMap[$num] ?? '')) === 'YES',
|
||||
'cf' => (string)($cfMap[$num] ?? ''),
|
||||
'cfu' => (string)($cfuMap[$num] ?? ''),
|
||||
'voicemail_enabled' => (int)$ext['voicemail_enabled'],
|
||||
'voicemail_email' => (string)($ext['voicemail_email'] ?? ''),
|
||||
'vm_inbox_count' => $vmCounts['inbox'],
|
||||
'vm_old_count' => $vmCounts['old'],
|
||||
'profile_email' => (string)($pb['email'] ?? ''),
|
||||
'photo_url' => (string)($pb['photo_url'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne la liste des cibles de renvoi autorisées (postes internes + répondeurs vm-<ext>).
|
||||
*/
|
||||
public static function getForwardTargets(PDO $db): array
|
||||
{
|
||||
$rows = $db->query('SELECT extension, name, voicemail_enabled FROM extensions WHERE enabled = 1 ORDER BY extension ASC')
|
||||
->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
||||
$targets = [];
|
||||
foreach ($rows as $r) {
|
||||
$targets[] = [
|
||||
'extension' => (string)$r['extension'],
|
||||
'name' => (string)$r['name'],
|
||||
'voicemail_enabled' => (int)$r['voicemail_enabled'],
|
||||
];
|
||||
}
|
||||
return $targets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applique DND, CF et CFU sur une extension autorisée via AMI + mise à jour BLF *76<ext>.
|
||||
*/
|
||||
public static function setCallFeatures(PDO $db, array $allowedExts, string $ext, array $features): void
|
||||
{
|
||||
$ext = self::assertAllowedExtension($allowedExts, $ext);
|
||||
|
||||
$dnd = !empty($features['dnd']);
|
||||
$cf = trim((string)($features['cf'] ?? ''));
|
||||
$cfu = trim((string)($features['cfu'] ?? ''));
|
||||
|
||||
self::validateForwardTarget($db, $ext, $cf, 'Renvoi inconditionnel (CF)');
|
||||
self::validateForwardTarget($db, $ext, $cfu, 'Renvoi sur non-réponse (CFU)');
|
||||
|
||||
$ami = AmiClient::fromDatabase($db);
|
||||
|
||||
// DND + mise à jour état BLF *76<ext> en direct
|
||||
if ($dnd) {
|
||||
$ami->runCommand("database put DND {$ext} YES");
|
||||
$ami->runCommand("devstate change Custom:DND{$ext} BUSY");
|
||||
} else {
|
||||
$ami->runCommand("database del DND {$ext}");
|
||||
$ami->runCommand("devstate change Custom:DND{$ext} NOT_INUSE");
|
||||
}
|
||||
|
||||
// CF (Renvoi inconditionnel)
|
||||
if ($cf !== '') {
|
||||
$ami->runCommand("database put CF {$ext} {$cf}");
|
||||
} else {
|
||||
$ami->runCommand("database del CF {$ext}");
|
||||
}
|
||||
|
||||
// CFU (Renvoi non-réponse / indisponible)
|
||||
if ($cfu !== '') {
|
||||
$ami->runCommand("database put CFU {$ext} {$cfu}");
|
||||
} else {
|
||||
$ami->runCommand("database del CFU {$ext}");
|
||||
}
|
||||
}
|
||||
|
||||
private static function validateForwardTarget(PDO $db, string $sourceExt, string $target, string $label): void
|
||||
{
|
||||
if ($target === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Cible répondeur direct vm-<ext>
|
||||
if (str_starts_with($target, 'vm-')) {
|
||||
$vmExt = substr($target, 3);
|
||||
if (!preg_match('/^\d{2,6}$/', $vmExt)) {
|
||||
throw new InvalidArgumentException("{$label} : boîte vocale cible invalide.");
|
||||
}
|
||||
$stmt = $db->prepare('SELECT voicemail_enabled FROM extensions WHERE extension = :ext AND enabled = 1 LIMIT 1');
|
||||
$stmt->execute([':ext' => $vmExt]);
|
||||
if (!(int)$stmt->fetchColumn()) {
|
||||
throw new InvalidArgumentException("{$label} : le répondeur du poste {$vmExt} n'est pas activé.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Cible poste interne uniquement (sécurité anti-fraude UCP)
|
||||
if (!preg_match('/^\d{2,6}$/', $target)) {
|
||||
throw new InvalidArgumentException("{$label} : seuls les postes internes ou répondeurs sont autorisés.");
|
||||
}
|
||||
if ($target === $sourceExt) {
|
||||
throw new InvalidArgumentException("{$label} : impossible de renvoyer un poste vers lui-même.");
|
||||
}
|
||||
$stmt = $db->prepare('SELECT id FROM extensions WHERE extension = :ext AND enabled = 1 LIMIT 1');
|
||||
$stmt->execute([':ext' => $target]);
|
||||
if (!$stmt->fetchColumn()) {
|
||||
throw new InvalidArgumentException("{$label} : le poste interne {$target} n'existe pas ou est désactivé.");
|
||||
}
|
||||
}
|
||||
|
||||
private static function parseAstDbPrefix(string $rawOutput, string $prefix): array
|
||||
{
|
||||
$map = [];
|
||||
foreach (preg_split('/\r?\n/', $rawOutput) ?: [] as $line) {
|
||||
$pos = strpos($line, $prefix);
|
||||
if ($pos === 0 && str_contains($line, ':')) {
|
||||
$rest = substr($line, strlen($prefix));
|
||||
[$k, $v] = explode(':', $rest, 2);
|
||||
$k = trim($k);
|
||||
$v = trim($v);
|
||||
if ($k !== '' && !str_contains($k, ' ')) {
|
||||
$map[$k] = $v;
|
||||
}
|
||||
}
|
||||
}
|
||||
return $map;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 3. MESSAGERIE VOCALE (LECTURE, TÉLÉCHARGEMENT, ARCHIVAGE, SUPPRESSION, PIN)
|
||||
// =========================================================================
|
||||
|
||||
public static function countVoicemails(string $ext): array
|
||||
{
|
||||
return [
|
||||
'inbox' => count(glob(self::VOICEMAIL_SPOOL_BASE . "/{$ext}/INBOX/msg*.txt") ?: []),
|
||||
'old' => count(glob(self::VOICEMAIL_SPOOL_BASE . "/{$ext}/Old/msg*.txt") ?: []),
|
||||
];
|
||||
}
|
||||
|
||||
public static function getVoicemails(array $allowedExts, string $ext): array
|
||||
{
|
||||
$ext = self::assertAllowedExtension($allowedExts, $ext);
|
||||
$messages = [];
|
||||
|
||||
foreach (['INBOX', 'Old'] as $folder) {
|
||||
$dir = self::VOICEMAIL_SPOOL_BASE . "/{$ext}/{$folder}";
|
||||
if (!is_dir($dir)) {
|
||||
continue;
|
||||
}
|
||||
$txtFiles = glob($dir . '/msg[0-9][0-9][0-9][0-9].txt') ?: [];
|
||||
foreach ($txtFiles as $txtFile) {
|
||||
$msgId = basename($txtFile, '.txt');
|
||||
if (!preg_match('/^msg\d{4}$/', $msgId)) {
|
||||
continue;
|
||||
}
|
||||
$meta = self::parseVoicemailTxt($txtFile);
|
||||
$audioPath = self::findVoicemailAudioFile($dir, $msgId);
|
||||
if (!$audioPath) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$origTime = (int)($meta['origtime'] ?? @filemtime($audioPath));
|
||||
$messages[] = [
|
||||
'id' => $msgId,
|
||||
'extension' => $ext,
|
||||
'folder' => $folder,
|
||||
'is_new' => ($folder === 'INBOX'),
|
||||
'callerid' => self::formatVmCallerId((string)($meta['callerid'] ?? 'Inconnu')),
|
||||
'origtime' => $origTime,
|
||||
'date_iso' => $origTime > 0 ? date('c', $origTime) : '',
|
||||
'date_human' => $origTime > 0 ? date('d/m/Y H:i', $origTime) : '—',
|
||||
'duration' => (int)($meta['duration'] ?? 0),
|
||||
'size_bytes' => (int)@filesize($audioPath),
|
||||
'format' => strtolower(pathinfo($audioPath, PATHINFO_EXTENSION)),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
usort($messages, fn($a, $b) => $b['origtime'] <=> $a['origtime']);
|
||||
return $messages;
|
||||
}
|
||||
|
||||
public static function getVoicemailAudioPath(array $allowedExts, string $ext, string $folder, string $msgId): string
|
||||
{
|
||||
$ext = self::assertAllowedExtension($allowedExts, $ext);
|
||||
$folder = self::assertValidVmFolder($folder);
|
||||
$msgId = self::assertValidMsgId($msgId);
|
||||
|
||||
$dir = self::VOICEMAIL_SPOOL_BASE . "/{$ext}/{$folder}";
|
||||
$path = self::findVoicemailAudioFile($dir, $msgId);
|
||||
if (!$path || !is_readable($path)) {
|
||||
throw new RuntimeException("Message vocal introuvable sur le disque.");
|
||||
}
|
||||
return $path;
|
||||
}
|
||||
|
||||
public static function moveVoicemailFolder(PDO $db, array $allowedExts, string $ext, string $fromFolder, string $msgId): void
|
||||
{
|
||||
$ext = self::assertAllowedExtension($allowedExts, $ext);
|
||||
$fromFolder = self::assertValidVmFolder($fromFolder);
|
||||
$msgId = self::assertValidMsgId($msgId);
|
||||
$toFolder = ($fromFolder === 'INBOX') ? 'Old' : 'INBOX';
|
||||
|
||||
$srcDir = self::VOICEMAIL_SPOOL_BASE . "/{$ext}/{$fromFolder}";
|
||||
$dstDir = self::VOICEMAIL_SPOOL_BASE . "/{$ext}/{$toFolder}";
|
||||
|
||||
if (!is_dir($dstDir)) {
|
||||
@mkdir($dstDir, 0775, true);
|
||||
@chown($dstDir, 'asterisk');
|
||||
@chgrp($dstDir, 'asterisk');
|
||||
}
|
||||
|
||||
$srcFiles = glob("{$srcDir}/{$msgId}.*") ?: [];
|
||||
if (empty($srcFiles)) {
|
||||
throw new RuntimeException("Message vocal {$msgId} introuvable dans {$fromFolder}.");
|
||||
}
|
||||
|
||||
// Trouver le prochain index msgNNNN libre dans le dossier cible
|
||||
$nextIdx = 0;
|
||||
while (
|
||||
!empty(glob(sprintf("{$dstDir}/msg%04d.*", $nextIdx)))
|
||||
&& $nextIdx < 9999
|
||||
) {
|
||||
$nextIdx++;
|
||||
}
|
||||
$newMsgId = sprintf('msg%04d', $nextIdx);
|
||||
|
||||
foreach ($srcFiles as $srcFile) {
|
||||
$fileExt = pathinfo($srcFile, PATHINFO_EXTENSION);
|
||||
$dstFile = "{$dstDir}/{$newMsgId}.{$fileExt}";
|
||||
if (!@rename($srcFile, $dstFile)) {
|
||||
if (@copy($srcFile, $dstFile)) {
|
||||
@unlink($srcFile);
|
||||
} else {
|
||||
throw new RuntimeException("Impossible de déplacer le fichier vocal ({$fileExt}).");
|
||||
}
|
||||
}
|
||||
@chown($dstFile, 'asterisk');
|
||||
@chgrp($dstFile, 'asterisk');
|
||||
@chmod($dstFile, 0664);
|
||||
}
|
||||
|
||||
self::reloadVoicemailQuiet($db);
|
||||
}
|
||||
|
||||
public static function deleteVoicemail(PDO $db, array $allowedExts, string $ext, string $folder, string $msgId): void
|
||||
{
|
||||
$ext = self::assertAllowedExtension($allowedExts, $ext);
|
||||
$folder = self::assertValidVmFolder($folder);
|
||||
$msgId = self::assertValidMsgId($msgId);
|
||||
|
||||
$dir = self::VOICEMAIL_SPOOL_BASE . "/{$ext}/{$folder}";
|
||||
$files = glob("{$dir}/{$msgId}.*") ?: [];
|
||||
if (empty($files)) {
|
||||
throw new RuntimeException("Message vocal {$msgId} introuvable.");
|
||||
}
|
||||
foreach ($files as $f) {
|
||||
@unlink($f);
|
||||
}
|
||||
|
||||
self::reloadVoicemailQuiet($db);
|
||||
}
|
||||
|
||||
public static function updateVoicemailPrefs(PDO $db, array $allowedExts, string $ext, string $newPin, string $vmEmail): void
|
||||
{
|
||||
$ext = self::assertAllowedExtension($allowedExts, $ext);
|
||||
$cleanPin = trim($newPin);
|
||||
$cleanEmail = trim($vmEmail);
|
||||
|
||||
if ($cleanPin !== '' && !preg_match('/^\d{4,12}$/', $cleanPin)) {
|
||||
throw new InvalidArgumentException("Le code PIN messagerie doit contenir entre 4 et 12 chiffres.");
|
||||
}
|
||||
if ($cleanEmail !== '' && !filter_var($cleanEmail, FILTER_VALIDATE_EMAIL)) {
|
||||
throw new InvalidArgumentException("Adresse e-mail de notification invalide.");
|
||||
}
|
||||
|
||||
if ($cleanPin !== '') {
|
||||
$db->prepare('UPDATE extensions SET voicemail_pin = :pin, voicemail_email = :em, force_pin_change = 0 WHERE extension = :ext')
|
||||
->execute([':pin' => $cleanPin, ':em' => $cleanEmail, ':ext' => $ext]);
|
||||
|
||||
$secFile = self::VOICEMAIL_SPOOL_BASE . "/{$ext}/secret.conf";
|
||||
if (is_dir(dirname($secFile))) {
|
||||
@file_put_contents($secFile, "[general]\npassword={$cleanPin}\n");
|
||||
@chown($secFile, 'asterisk');
|
||||
@chgrp($secFile, 'asterisk');
|
||||
@chmod($secFile, 0664);
|
||||
}
|
||||
} else {
|
||||
$db->prepare('UPDATE extensions SET voicemail_email = :em WHERE extension = :ext')
|
||||
->execute([':em' => $cleanEmail, ':ext' => $ext]);
|
||||
}
|
||||
|
||||
// Régénération ciblée de voicemail.custom.conf + rechargement à chaud
|
||||
$gen = new ConfigGenerator($db);
|
||||
$vmContent = $gen->generateVoicemailConf();
|
||||
file_put_contents(ConfigGenerator::VOICEMAIL_CONF_PATH, $vmContent);
|
||||
@chgrp(ConfigGenerator::VOICEMAIL_CONF_PATH, 'asterisk');
|
||||
@chmod(ConfigGenerator::VOICEMAIL_CONF_PATH, 0664);
|
||||
|
||||
self::reloadVoicemailQuiet($db);
|
||||
}
|
||||
|
||||
private static function reloadVoicemailQuiet(PDO $db): void
|
||||
{
|
||||
try {
|
||||
AmiClient::fromDatabase($db)->runCommand('voicemail reload');
|
||||
} catch (\Throwable) {}
|
||||
}
|
||||
|
||||
private static function parseVoicemailTxt(string $path): array
|
||||
{
|
||||
$data = [];
|
||||
$lines = @file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
|
||||
foreach ($lines as $line) {
|
||||
if (str_starts_with($line, ';') || str_starts_with($line, '[')) {
|
||||
continue;
|
||||
}
|
||||
if (str_contains($line, '=')) {
|
||||
[$k, $v] = explode('=', $line, 2);
|
||||
$data[trim(strtolower($k))] = trim($v);
|
||||
}
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
private static function findVoicemailAudioFile(string $dir, string $msgId): ?string
|
||||
{
|
||||
foreach (['wav', 'WAV', 'gsm'] as $audioExt) {
|
||||
$candidate = "{$dir}/{$msgId}.{$audioExt}";
|
||||
if (is_file($candidate)) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static function formatVmCallerId(string $raw): string
|
||||
{
|
||||
$clean = trim($raw, " \t\n\r\0\x0B\"");
|
||||
if ($clean === '' || $clean === '<>') {
|
||||
return 'Appelant inconnu';
|
||||
}
|
||||
return $clean;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 4. JOURNAL D'APPELS PERSONNEL & ANNUAIRE / PROFIL
|
||||
// =========================================================================
|
||||
|
||||
public static function getPersonalCdr(PDO $db, array $allowedExts, array $filters = []): array
|
||||
{
|
||||
$extSet = array_map(fn($e) => (string)$e['extension'], $allowedExts);
|
||||
if (empty($extSet)) {
|
||||
return ['calls' => [], 'stats' => ['total' => 0, 'answered' => 0, 'missed' => 0]];
|
||||
}
|
||||
|
||||
$res = CdrManager::getCalls($db, [
|
||||
'limit' => 500,
|
||||
'direction' => (string)($filters['direction'] ?? 'all'),
|
||||
'disposition' => (string)($filters['disposition'] ?? 'all'),
|
||||
'period' => (string)($filters['period'] ?? 'all'),
|
||||
'search' => (string)($filters['search'] ?? ''),
|
||||
]);
|
||||
|
||||
$filtered = [];
|
||||
$answered = 0;
|
||||
$missed = 0;
|
||||
|
||||
foreach ($res['calls'] as $call) {
|
||||
$srcMatch = in_array((string)($call['src'] ?? ''), $extSet, true);
|
||||
$dstMatch = in_array((string)($call['dst'] ?? ''), $extSet, true);
|
||||
if (!$srcMatch && !$dstMatch) {
|
||||
continue;
|
||||
}
|
||||
if (($call['disposition'] ?? '') === 'ANSWERED') {
|
||||
$answered++;
|
||||
} else {
|
||||
$missed++;
|
||||
}
|
||||
$filtered[] = $call;
|
||||
if (count($filtered) >= 120) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'calls' => $filtered,
|
||||
'stats' => [
|
||||
'total' => count($filtered),
|
||||
'answered' => $answered,
|
||||
'missed' => $missed,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPhonebookForUser(PDO $db, array $allowedExts): array
|
||||
{
|
||||
$allowedExtIds = array_map(fn($e) => (int)$e['id'], $allowedExts);
|
||||
$allowedMap = [];
|
||||
foreach ($allowedExts as $e) {
|
||||
$allowedMap[(int)$e['id']] = (string)$e['extension'];
|
||||
}
|
||||
|
||||
$contacts = PhonebookManager::getContacts($db);
|
||||
foreach ($contacts as &$c) {
|
||||
$eid = isset($c['extension_id']) ? (int)$c['extension_id'] : 0;
|
||||
$c['is_mine'] = ($eid > 0 && in_array($eid, $allowedExtIds, true));
|
||||
$c['mine_extension'] = $c['is_mine'] ? $allowedMap[$eid] : null;
|
||||
}
|
||||
unset($c);
|
||||
|
||||
return $contacts;
|
||||
}
|
||||
|
||||
public static function updateMyContactEmail(PDO $db, array $allowedExts, string $ext, string $email): void
|
||||
{
|
||||
$ext = self::assertAllowedExtension($allowedExts, $ext);
|
||||
$cleanEmail = trim($email);
|
||||
if ($cleanEmail !== '' && !filter_var($cleanEmail, FILTER_VALIDATE_EMAIL)) {
|
||||
throw new InvalidArgumentException("Adresse e-mail invalide.");
|
||||
}
|
||||
|
||||
PhonebookManager::syncExtensions($db);
|
||||
$extId = self::getExtensionId($allowedExts, $ext);
|
||||
|
||||
// Maintenir phonebook_contacts et extensions.voicemail_email alignés pour éviter un écrasement lors des syncExtensions()
|
||||
$db->prepare('UPDATE extensions SET voicemail_email = :em WHERE id = :eid')
|
||||
->execute([':em' => $cleanEmail, ':eid' => $extId]);
|
||||
$db->prepare('UPDATE phonebook_contacts SET email = :em, updated_at = CURRENT_TIMESTAMP WHERE extension_id = :eid AND is_internal = 1')
|
||||
->execute([':em' => $cleanEmail, ':eid' => $extId]);
|
||||
|
||||
$gen = new ConfigGenerator($db);
|
||||
$vmContent = $gen->generateVoicemailConf();
|
||||
file_put_contents(ConfigGenerator::VOICEMAIL_CONF_PATH, $vmContent);
|
||||
@chgrp(ConfigGenerator::VOICEMAIL_CONF_PATH, 'asterisk');
|
||||
@chmod(ConfigGenerator::VOICEMAIL_CONF_PATH, 0664);
|
||||
self::reloadVoicemailQuiet($db);
|
||||
}
|
||||
|
||||
public static function uploadMyAvatar(PDO $db, array $allowedExts, string $ext, array $file): string
|
||||
{
|
||||
$ext = self::assertAllowedExtension($allowedExts, $ext);
|
||||
|
||||
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
||||
throw new RuntimeException("Erreur lors de l'envoi de la photo.");
|
||||
}
|
||||
if (($file['size'] ?? 0) > 5 * 1024 * 1024) {
|
||||
throw new InvalidArgumentException("La photo ne doit pas dépasser 5 Mo.");
|
||||
}
|
||||
|
||||
$finfo = new \finfo(FILEINFO_MIME_TYPE);
|
||||
$mime = $finfo->file((string)$file['tmp_name']);
|
||||
$allowed = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp'];
|
||||
if (!isset($allowed[$mime])) {
|
||||
throw new InvalidArgumentException("Format non supporté. Utilisez JPG, PNG ou WebP.");
|
||||
}
|
||||
|
||||
PhonebookManager::initDirectory();
|
||||
PhonebookManager::syncExtensions($db);
|
||||
|
||||
// Nettoyer l'ancien avatar UCP de ce poste
|
||||
foreach (glob(PhonebookManager::AVATARS_DIR . "/avatar_ucp_{$ext}_*") ?: [] as $oldFile) {
|
||||
@unlink($oldFile);
|
||||
}
|
||||
|
||||
$fileExt = $allowed[$mime];
|
||||
$filename = "avatar_ucp_{$ext}_" . time() . ".{$fileExt}";
|
||||
$target = PhonebookManager::AVATARS_DIR . '/' . $filename;
|
||||
|
||||
if (!move_uploaded_file((string)$file['tmp_name'], $target)) {
|
||||
throw new RuntimeException("Impossible d'enregistrer la photo sur le serveur.");
|
||||
}
|
||||
@chmod($target, 0664);
|
||||
|
||||
$avatarUrl = "/avatars/{$filename}";
|
||||
$extId = self::getExtensionId($allowedExts, $ext);
|
||||
|
||||
$db->prepare('UPDATE phonebook_contacts SET photo_url = :url, updated_at = CURRENT_TIMESTAMP WHERE extension_id = :eid AND is_internal = 1')
|
||||
->execute([':url' => $avatarUrl, ':eid' => $extId]);
|
||||
|
||||
return $avatarUrl;
|
||||
}
|
||||
|
||||
public static function resolveAvatarPath(string $filename): ?string
|
||||
{
|
||||
$base = basename($filename);
|
||||
if (!preg_match('/^[a-zA-Z0-9._\-]+\.(jpg|jpeg|png|webp)$/i', $base)) {
|
||||
return null;
|
||||
}
|
||||
$full = PhonebookManager::AVATARS_DIR . '/' . $base;
|
||||
return is_file($full) && is_readable($full) ? $full : null;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// HELPERS VALIDATION
|
||||
// =========================================================================
|
||||
|
||||
private static function assertAllowedExtension(array $allowedExts, string $ext): string
|
||||
{
|
||||
$clean = trim($ext);
|
||||
foreach ($allowedExts as $e) {
|
||||
if ((string)$e['extension'] === $clean) {
|
||||
return $clean;
|
||||
}
|
||||
}
|
||||
throw new InvalidArgumentException("Accès refusé à ce poste SIP ({$clean}).");
|
||||
}
|
||||
|
||||
private static function getExtensionId(array $allowedExts, string $ext): int
|
||||
{
|
||||
foreach ($allowedExts as $e) {
|
||||
if ((string)$e['extension'] === $ext) {
|
||||
return (int)$e['id'];
|
||||
}
|
||||
}
|
||||
throw new InvalidArgumentException("Poste introuvable.");
|
||||
}
|
||||
|
||||
private static function assertValidVmFolder(string $folder): string
|
||||
{
|
||||
if (!in_array($folder, ['INBOX', 'Old'], true)) {
|
||||
throw new InvalidArgumentException("Dossier messagerie invalide.");
|
||||
}
|
||||
return $folder;
|
||||
}
|
||||
|
||||
private static function assertValidMsgId(string $msgId): string
|
||||
{
|
||||
if (!preg_match('/^msg\d{4}$/', $msgId)) {
|
||||
throw new InvalidArgumentException("Identifiant de message vocal invalide.");
|
||||
}
|
||||
return $msgId;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user