apiClient = $apiClient ?? new PBXApiClient(); } // ------------------------------------------------------------------------- // CRUD operations // ------------------------------------------------------------------------- /** * Create a new extension in FreePBX. * * @param array $extensionData Keys: extension, secret, firstname, lastname, email (optional) * @return array * @throws ExtensionException */ public function createExtension(array $extensionData): array { $this->validateExtensionData($extensionData); $extensionId = (string) $extensionData['extension']; $secret = $extensionData['secret']; $name = trim(($extensionData['firstname'] ?? 'User') . ' ' . ($extensionData['lastname'] ?? $extensionId)); $email = $extensionData['email'] ?? ($extensionId . '@localhost'); try { Log::info('Creating PBX extension via GraphQL', [ 'extension' => $extensionId, 'name' => $name, ]); // Step 1 — create the extension record. // If the extension already exists (e.g. from a prior failed job) we skip // the addExtension call and fall straight through to the password update, // making this operation idempotent. try { $this->apiClient->graphql( 'mutation AddExtension($input: addExtensionInput!) { addExtension(input: $input) { status message } }', [ 'input' => [ 'extensionId' => $extensionId, 'name' => $name, 'email' => $email, 'tech' => 'pjsip', 'vmEnable' => false, ], ] ); } catch (\App\Exceptions\PBX\PBXException $e) { if (stripos($e->getMessage(), 'already in use') === false) { throw $e; } Log::info('PBX extension already exists, updating password only', [ 'extension' => $extensionId, ]); } // Step 2 — set the SIP authentication password $this->apiClient->graphql( 'mutation SetExtPassword($input: updateExtensionInput!) { updateExtension(input: $input) { status message } }', [ 'input' => [ 'extensionId' => $extensionId, 'extPassword' => $secret, ], ] ); // Step 3 — disable call waiting (FreePBX enables it by default) $this->disableCallWaiting($extensionId); Log::info('PBX extension created successfully', ['extension' => $extensionId]); return ['status' => true, 'extension' => $extensionId, 'message' => 'Extension created successfully']; } catch (\Exception $e) { Log::error('Failed to create PBX extension', [ 'extension' => $extensionId, 'error' => $e->getMessage(), ]); throw new ExtensionException( 'Failed to create extension: ' . $e->getMessage(), 0, $e, ['extension_data' => $extensionData] ); } } /** * Update an existing extension. * * @param string $extension Extension number * @param array $extensionData Updatable fields (secret → extPassword, name, etc.) * @return array * @throws ExtensionException */ public function updateExtension(string $extension, array $extensionData): array { try { Log::info('Updating PBX extension', ['extension' => $extension]); $input = ['extensionId' => $extension]; if (!empty($extensionData['secret'])) { $input['extPassword'] = $extensionData['secret']; } // Build 'name' from firstname/lastname if provided, otherwise use explicit 'name' if (!empty($extensionData['firstname']) || !empty($extensionData['lastname'])) { $combined = trim(($extensionData['firstname'] ?? '') . ' ' . ($extensionData['lastname'] ?? '')); if ($combined !== '') { $input['name'] = $combined; } } if (!empty($extensionData['name'])) { $input['name'] = $extensionData['name']; } if (array_key_exists('vmEnable', $extensionData)) { $input['vmEnable'] = (bool) $extensionData['vmEnable']; } if (!empty($extensionData['vmPassword'])) { $input['vmPassword'] = $extensionData['vmPassword']; } if (!empty($extensionData['outboundCid'])) { $input['outboundCid'] = $extensionData['outboundCid']; } $result = $this->apiClient->graphql( 'mutation UpdateExtension($input: updateExtensionInput!) { updateExtension(input: $input) { status message } }', ['input' => $input] ); Log::info('PBX extension updated successfully', ['extension' => $extension]); return $result['updateExtension'] ?? ['status' => true]; } catch (\Exception $e) { Log::error('Failed to update PBX extension', [ 'extension' => $extension, 'error' => $e->getMessage(), ]); throw new ExtensionException( 'Failed to update extension: ' . $e->getMessage(), 0, $e, ['extension' => $extension] ); } } /** * Delete an extension. * * @param string $extension Extension number * @return array * @throws ExtensionException */ public function deleteExtension(string $extension): array { try { Log::info('Deleting PBX extension', ['extension' => $extension]); $result = $this->apiClient->graphql( 'mutation DeleteExtension($input: deleteExtensionInput!) { deleteExtension(input: $input) { status message } }', ['input' => ['extensionId' => $extension]] ); Log::info('PBX extension deleted', ['extension' => $extension]); return $result['deleteExtension'] ?? ['status' => true]; } catch (\Exception $e) { Log::error('Failed to delete PBX extension', [ 'extension' => $extension, 'error' => $e->getMessage(), ]); throw new ExtensionException( 'Failed to delete extension: ' . $e->getMessage(), 0, $e, ['extension' => $extension] ); } } /** * Get extension details. * * @param string $extension Extension number * @return array * @throws ExtensionException */ public function getExtension(string $extension): array { try { $result = $this->apiClient->graphql( 'query FetchExtension($id: ID!) { fetchExtension(extensionId: $id) { extensionId tech status user { extension name extPassword outboundCid } coreDevice { deviceId tech dial } } }', ['id' => $extension] ); return $result['fetchExtension'] ?? []; } catch (\Exception $e) { throw new ExtensionException( 'Failed to get extension: ' . $e->getMessage(), 0, $e, ['extension' => $extension] ); } } // ------------------------------------------------------------------------- // Helpers // ------------------------------------------------------------------------- /** * Apply configuration changes (reload Asterisk dialplan). */ public function applyChanges(): bool { try { return $this->apiClient->applyChanges(); } catch (\Exception $e) { throw new ExtensionException( 'Failed to apply changes: ' . $e->getMessage(), 0, $e ); } } /** * Check API connectivity. */ public function checkConnection(): bool { return $this->apiClient->testConnection(); } /** * Disable call waiting for an extension by removing its CW entry from the * Asterisk internal database via the AMI rawman HTTP interface. * * FreePBX stores call-waiting state as AstDB key CW/{extension}=ENABLED. * Deleting the key disables call waiting (matching Core.class.php behaviour * where callwaiting=disabled does database_del("CW", $extension)). * * This cannot be done via the FreePBX GraphQL / REST API, so we call the * Asterisk Manager Interface rawman endpoint directly. * * @param string $extension * @return void */ protected function disableCallWaiting(string $extension): void { $rawmanUrl = rtrim(config('pbx.ami.rawman_url', 'http://127.0.0.1:8088/rawman'), '/'); $username = config('pbx.ami.username', 'theAMIManager'); $secret = config('pbx.ami.secret', 'DS3joFO49d0DNkkskjngj4j'); try { $cookieJar = tempnam(sys_get_temp_dir(), 'ami_cw_'); $ch = curl_init(); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_COOKIEFILE => $cookieJar, CURLOPT_COOKIEJAR => $cookieJar, CURLOPT_TIMEOUT => 5, ]); // Step 1 — authenticate curl_setopt($ch, CURLOPT_URL, $rawmanUrl . '?action=login&username=' . urlencode($username) . '&secret=' . urlencode($secret)); $loginResp = curl_exec($ch); if (!$loginResp || !str_contains($loginResp, 'Authentication accepted')) { curl_close($ch); @unlink($cookieJar); Log::warning('AMI login failed when disabling call waiting', [ 'extension' => $extension, 'response' => substr((string) $loginResp, 0, 200), ]); return; } // Step 2 — delete the CW AstDB entry (absence = call waiting disabled) curl_setopt($ch, CURLOPT_URL, $rawmanUrl . '?action=DBDel&Family=CW&Key=' . urlencode($extension)); $delResp = curl_exec($ch); curl_close($ch); @unlink($cookieJar); Log::info('Call waiting disabled for PBX extension', [ 'extension' => $extension, 'ami_response' => trim((string) $delResp), ]); } catch (\Exception $e) { // Non-fatal: log a warning but don't fail the extension creation Log::warning('Could not disable call waiting via AMI', [ 'extension' => $extension, 'error' => $e->getMessage(), ]); } } /** * Validate required fields in extension data. * * @throws ExtensionException */ protected function validateExtensionData(array $data): void { if (empty($data['extension'])) { throw new ExtensionException('Extension number is required'); } if (empty($data['secret'])) { throw new ExtensionException('Extension secret/password is required'); } if (!ctype_digit((string) $data['extension'])) { throw new ExtensionException('Extension number must be numeric'); } } }