--- a/backend/app/Http/Controllers/Api/WhatsAppController.php +++ b/backend/app/Http/Controllers/Api/WhatsAppController.php @@ -10,6 +10,7 @@ use App\Models\SystemNotification; use App\Services\EvolutionApiService; use App\Services\UazapiService; +use App\Services\WhatsApp\EvolutionGoWhatsAppService; use Illuminate\Http\Request; use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Log; @@ -861,6 +862,8 @@ { if ($integration->provider === 'evolution') { return new EvolutionApiService($integration->api_url, $integration->api_key); + } elseif ($integration->provider === 'evolutiongo') { + return new EvolutionGoWhatsAppService($integration->api_url, $integration->api_key); } else { return new UazapiService($integration->api_url, $integration->api_key); } --- a/backend/app/Http/Controllers/Api/WhatsAppIntegrationController.php +++ b/backend/app/Http/Controllers/Api/WhatsAppIntegrationController.php @@ -9,6 +9,7 @@ use Illuminate\Http\JsonResponse; use App\Services\WhatsApp\EvolutionWhatsAppService; use App\Services\WhatsApp\UazapiWhatsAppService; +use App\Services\WhatsApp\EvolutionGoWhatsAppService; class WhatsAppIntegrationController extends Controller { @@ -181,6 +182,15 @@ } elseif ($provider === 'uazapi') { $service = new UazapiWhatsAppService($apiUrl, $apiKey); $result = $service->testConnection(); + } elseif ($provider === 'evolutiongo') { + $service = new EvolutionGoWhatsAppService($apiUrl, $apiKey); + $result = $service->testConnection(); + + return [ + 'success' => $result['success'], + 'message' => $result['message'], + 'details' => $result + ]; } else { return [ 'success' => false, --- /dev/null +++ b/backend/app/Http/Controllers/Webhooks/EvoGoWebhookController.php @@ -0,0 +1,144 @@ +isMethod('get')) { + return response()->json([ + 'success' => true, + 'message' => 'EvolutionGO Webhook Endpoint is working! The URL is configured automatically on each instance.', + 'endpoint' => '/api/v1/webhooks/EvoGO', + ]); + } + + try { + $payload = $request->json()->all() ?: $request->all(); + $event = (string) ($payload['event'] ?? $payload['type'] ?? ''); + $data = is_array($payload['data'] ?? null) ? $payload['data'] : []; + + $connection = $this->resolveConnection($request, $payload); + + if (!$connection) { + Log::warning('EvoGO Webhook: connection not found or invalid signature', [ + 'instance' => $request->query('instance'), + 'event' => $event, + ]); + + return response()->json([ + 'success' => false, + 'message' => 'Connection not found or invalid signature', + ], 404); + } + + $normalized = strtolower(preg_replace('/[^a-zA-Z]/', '', $event)); + + Log::info('EvoGO Webhook: event received', [ + 'connection_id' => $connection->id, + 'instance' => $connection->instance_name, + 'event' => $event, + ]); + + $oldStatus = $connection->status; + + if (in_array($normalized, ['connected', 'pairsuccess'], true)) { + $phone = EvolutionGoWhatsAppService::phoneFromJid( + $data['ID'] ?? $data['id'] ?? $data['jid'] ?? $data['JID'] ?? $payload['jid'] ?? null + ); + + if ($oldStatus !== 'connected') { + $connection->markAsConnected($phone ?: (string) ($connection->phone_number ?? '')); + } elseif ($phone && $phone !== $connection->phone_number) { + $connection->update(['phone_number' => $phone]); + } + } elseif (in_array($normalized, ['loggedout', 'disconnected', 'streamreplaced', 'temporaryban', 'connectfailure', 'clientoutdated'], true)) { + if ($oldStatus === 'connected') { + $connection->markAsDisconnected('Disconnected by EvolutionGO webhook (' . $event . ')'); + + try { + SystemNotification::createWhatsAppDisconnected( + $connection->user, + $connection->instance_name, + $connection->id + ); + } catch (\Exception $notifError) { + Log::error('EvoGO Webhook: failed to create notification', [ + 'error' => $notifError->getMessage(), + ]); + } + } + } elseif (in_array($normalized, ['qrcode', 'qr'], true)) { + if ($oldStatus !== 'connected') { + $connection->update(['status' => 'connecting', 'error_message' => null]); + } + } else { + return response()->json([ + 'success' => true, + 'message' => 'Event ignored', + ]); + } + + return response()->json([ + 'success' => true, + 'message' => 'Connection status processed', + 'instance' => $connection->instance_name, + 'old_status' => $oldStatus, + 'new_status' => $connection->fresh()->status, + ]); + } catch (\Exception $e) { + Log::error('EvoGO Webhook: exception', [ + 'error' => $e->getMessage(), + ]); + + return response()->json([ + 'success' => false, + 'message' => 'Error processing webhook', + ], 500); + } + } + + /** + * Localizar a conexão de forma autenticada: + * 1) pela assinatura HMAC presente na URL (instance + sig), ou + * 2) pelo token da instância enviado no payload (segredo conhecido só pelo EvolutionGO e por nós). + */ + private function resolveConnection(Request $request, array $payload): ?WhatsAppConnection + { + $query = WhatsAppConnection::whereHas('integration', function ($q) { + $q->where('provider', 'evolutiongo'); + }); + + $instance = (string) $request->query('instance', ''); + $sig = (string) $request->query('sig', ''); + + if ($instance !== '' && $sig !== '' + && hash_equals(EvolutionGoWhatsAppService::webhookSignature($instance), $sig)) { + return (clone $query)->where('instance_name', $instance)->first(); + } + + $token = $payload['instanceToken'] ?? $payload['token'] ?? null; + if (is_string($token) && $token !== '') { + return (clone $query)->where('token', $token)->first(); + } + + return null; + } +} --- a/backend/app/Http/Requests/WhatsAppIntegrationRequest.php +++ b/backend/app/Http/Requests/WhatsAppIntegrationRequest.php @@ -22,7 +22,7 @@ { $rules = [ 'name' => ['required', 'string', 'max:255'], - 'provider' => ['required', 'string', 'in:evolution,uazapi'], + 'provider' => ['required', 'string', 'in:evolution,uazapi,evolutiongo'], 'api_url' => ['required', 'string', 'url', 'max:500'], 'api_key' => ['required', 'string', 'min:1', 'max:500'], 'max_users' => ['required', 'integer', 'min:1', 'max:10000'], @@ -32,7 +32,7 @@ if ($this->isMethod('PUT') || $this->isMethod('PATCH')) { $integration = $this->route('integration'); if ($integration) { - $rules['provider'] = ['sometimes', 'string', 'in:evolution,uazapi']; + $rules['provider'] = ['sometimes', 'string', 'in:evolution,uazapi,evolutiongo']; } } @@ -49,7 +49,7 @@ 'name.max' => 'O nome não pode ter mais de 255 caracteres.', 'provider.required' => 'O provedor é obrigatório.', - 'provider.in' => 'O provedor deve ser "evolution" ou "uazapi".', + 'provider.in' => 'O provedor deve ser "evolution", "uazapi" ou "evolutiongo".', 'api_url.required' => 'A URL da API é obrigatória.', 'api_url.url' => 'A URL da API não é válida.', --- a/backend/app/Jobs/CheckWhatsAppConnections.php +++ b/backend/app/Jobs/CheckWhatsAppConnections.php @@ -5,6 +5,7 @@ use App\Models\WhatsAppConnection; use App\Services\WhatsApp\EvolutionWhatsAppService; use App\Services\WhatsApp\UazapiWhatsAppService; +use App\Services\WhatsApp\EvolutionGoWhatsAppService; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -59,9 +60,14 @@ return; } + // EvolutionGO: além do webhook, consultar o status na API (fallback) + if ($connection->integration->provider === 'evolutiongo') { + $this->syncEvolutionGoStatus($connection); + } + // Para Evolution API, o status é atualizado via webhook // Este job apenas verifica se há reconexões e retoma a fila se necessário - if ($connection->integration->provider === 'evolution') { + if (in_array($connection->integration->provider, ['evolution', 'evolutiongo'], true)) { // Se estava desconectado e agora está conectado (atualizado pelo webhook), retomar fila if ($connection->status === 'connected') { $user = $connection->user; @@ -96,6 +102,54 @@ } /** + * Atualizar status de uma conexão EvolutionGO consultando /instance/status. + * Só consulta conexões "connected" ou "connecting" e só altera o status quando a API + * responde com sucesso (falhas de rede não derrubam a conexão no banco). + */ + private function syncEvolutionGoStatus(WhatsAppConnection $connection): void + { + if (!$connection->token || !in_array($connection->status, ['connected', 'connecting'], true)) { + return; + } + + $status = (new EvolutionGoWhatsAppService($connection))->getConnectionStatus(); + + if (!($status['success'] ?? false)) { + Log::debug('CheckWhatsAppConnections: EvolutionGO status check failed', [ + 'connection_id' => $connection->id, + 'error' => $status['error'] ?? null, + ]); + return; + } + + if ($status['state'] === 'open' && $connection->status !== 'connected') { + $connection->markAsConnected($status['phone'] ?: (string) ($connection->phone_number ?? '')); + + Log::info('CheckWhatsAppConnections: EvolutionGO connection marked as connected', [ + 'connection_id' => $connection->id, + ]); + } elseif ($status['logged_in'] === false && $connection->status === 'connected') { + $connection->markAsDisconnected('Disconnected (EvolutionGO status check)'); + + try { + \App\Models\SystemNotification::createWhatsAppDisconnected( + $connection->user, + $connection->instance_name, + $connection->id + ); + } catch (\Exception $e) { + Log::error('CheckWhatsAppConnections: failed to create notification', [ + 'error' => $e->getMessage(), + ]); + } + + Log::warning('CheckWhatsAppConnections: EvolutionGO connection marked as disconnected', [ + 'connection_id' => $connection->id, + ]); + } + } + + /** * Get appropriate WhatsApp service based on integration provider. */ private function getService(WhatsAppConnection $connection) --- a/backend/app/Jobs/ProcessWhatsAppQueue.php +++ b/backend/app/Jobs/ProcessWhatsAppQueue.php @@ -4,6 +4,7 @@ use App\Models\WhatsAppQueueJob; use App\Services\WhatsApp\EvolutionWhatsAppService; +use App\Services\WhatsApp\EvolutionGoWhatsAppService; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -133,7 +134,9 @@ } // Initialize WhatsApp service - $service = new EvolutionWhatsAppService($connection); + $service = $connection->integration->provider === 'evolutiongo' + ? new EvolutionGoWhatsAppService($connection) + : new EvolutionWhatsAppService($connection); // First check database status - trust it if it's 'connected' $dbStatus = $connection->status; --- /dev/null +++ b/backend/app/Services/WhatsApp/EvolutionGoWhatsAppService.php @@ -0,0 +1,738 @@ +relationLoaded('integration')) { + $connection->load('integration'); + } + + $integration = $connection->integration; + $this->baseUrl = rtrim((string) ($integration->api_url ?? ''), '/'); + $this->apiKey = (string) ($integration->api_key ?? ''); + $this->instanceName = (string) ($connection->instance_name ?? ''); + $this->instanceToken = $connection->token ?: null; + } else { + $this->baseUrl = rtrim((string) ($connection ?? ''), '/'); + $this->apiKey = (string) ($apiKey ?? ''); + } + } + + /** + * Definir token da instância (usado pelo WhatsAppController após criar/carregar a conexão). + */ + public function setInstanceToken(string $token): void + { + $this->instanceToken = $token; + } + + /** + * URL do webhook deste sistema para uma instância EvolutionGO. + * Inclui assinatura HMAC para que o webhook público não aceite chamadas forjadas. + */ + public static function webhookUrlFor(string $instanceName): string + { + try { + $systemUrl = \App\Models\SystemSetting::get('system_url') ?? config('app.url'); + } catch (\Throwable $e) { + $systemUrl = config('app.url'); + } + + return rtrim((string) $systemUrl, '/') . '/api/v1/webhooks/EvoGO?' . http_build_query([ + 'instance' => $instanceName, + 'sig' => static::webhookSignature($instanceName), + ]); + } + + public static function webhookSignature(string $instanceName): string + { + return hash_hmac('sha256', 'evogo|' . $instanceName, (string) config('app.key')); + } + + // ------------------------------------------------------------------ + // Ciclo de vida da instância + // ------------------------------------------------------------------ + + /** + * Criar instância (rota admin, usa GLOBAL_API_KEY). + * O token da instância é gerado aqui e devolvido em 'token' para ser salvo na conexão. + */ + public function createInstance(string $instanceName, ?string $phoneNumber = null): array + { + $this->instanceName = $instanceName; + $this->pendingPhone = $phoneNumber ? preg_replace('/[^0-9]/', '', $phoneNumber) : null; + $token = (string) Str::uuid(); + + try { + $response = $this->http($this->apiKey)->post("{$this->baseUrl}/instance/create", [ + 'name' => $instanceName, + 'token' => $token, + ]); + + if ($response->successful()) { + $this->instanceToken = $token; + + return [ + 'success' => true, + 'data' => $this->unwrap($response->json()), + 'token' => $token, + ]; + } + + Log::error('EvolutionGO createInstance failed', [ + 'instance' => $instanceName, + 'status' => $response->status(), + 'body' => $response->body(), + ]); + + return ['success' => false, 'error' => $this->errorFrom($response)]; + } catch (\Exception $e) { + Log::error('EvolutionGO createInstance error: ' . $e->getMessage()); + return ['success' => false, 'error' => $e->getMessage()]; + } + } + + /** + * Conectar instância (configura webhook) e obter QR Code / Pairing Code. + */ + public function connectInstance(?string $instanceName = null): array + { + $instanceName = $instanceName ?: $this->instanceName; + $this->instanceName = $instanceName; + + try { + $response = $this->http($this->instanceKey())->post("{$this->baseUrl}/instance/connect", [ + 'webhookUrl' => static::webhookUrlFor($instanceName), + 'subscribe' => ['CONNECTION', 'QRCODE'], + 'immediate' => true, + ]); + + if (!$response->successful()) { + Log::error('EvolutionGO connectInstance failed', [ + 'instance' => $instanceName, + 'status' => $response->status(), + 'body' => $response->body(), + ]); + + return [ + 'success' => false, + 'error' => $this->errorFrom($response), + 'status_code' => $response->status(), + ]; + } + + $connectData = $this->unwrap($response->json()); + + $pairingCode = null; + if ($this->pendingPhone) { + $pairingCode = $this->requestPairingCode($this->pendingPhone); + } + + // O QR é gerado de forma assíncrona após o connect; tentar algumas vezes. + $qrCode = null; + for ($attempt = 0; $attempt < 6 && !$qrCode; $attempt++) { + if ($attempt > 0) { + usleep(1000000); + } + $qrCode = $this->fetchQrCode(); + } + + if (!$qrCode && !$pairingCode) { + Log::warning('EvolutionGO connectInstance: no QR code or pairing code returned', [ + 'instance' => $instanceName, + 'connect_response' => $connectData, + ]); + } + + return [ + 'success' => true, + 'qrcode' => $qrCode, + 'pairing_code' => $pairingCode, + 'data' => is_array($connectData) ? $connectData : [], + ]; + } catch (\Exception $e) { + Log::error('EvolutionGO connectInstance error: ' . $e->getMessage()); + return ['success' => false, 'error' => $e->getMessage()]; + } + } + + /** + * Buscar QR Code atual da instância (data URL base64) ou null. + */ + public function fetchQrCode(): ?string + { + try { + $response = $this->http($this->instanceKey(), 10)->get("{$this->baseUrl}/instance/qr"); + if (!$response->successful()) { + return null; + } + + $data = $this->unwrap($response->json()); + $qr = is_array($data) ? $this->pick($data, ['qrcode', 'base64', 'qr']) : null; + + if (!is_string($qr) || $qr === '') { + return null; + } + + if (str_starts_with($qr, 'data:image')) { + return $qr; + } + + // Base64 puro de imagem PNG + if (preg_match('/^[A-Za-z0-9+\/=\s]+$/', $qr) && str_starts_with(ltrim($qr), 'iVBOR')) { + return 'data:image/png;base64,' . trim($qr); + } + + return null; + } catch (\Exception $e) { + Log::warning('EvolutionGO fetchQrCode error: ' . $e->getMessage()); + return null; + } + } + + /** + * Solicitar código de pareamento para um número. + */ + public function requestPairingCode(string $phone): ?string + { + try { + $response = $this->http($this->instanceKey())->post("{$this->baseUrl}/instance/pair", [ + 'phone' => preg_replace('/[^0-9]/', '', $phone), + ]); + + if ($response->successful()) { + $data = $this->unwrap($response->json()); + $code = is_array($data) ? $this->pick($data, ['pairingcode', 'code', 'paircode']) : null; + return is_string($code) && $code !== '' ? $code : null; + } + + Log::warning('EvolutionGO requestPairingCode failed', [ + 'instance' => $this->instanceName, + 'status' => $response->status(), + 'body' => $response->body(), + ]); + } catch (\Exception $e) { + Log::warning('EvolutionGO requestPairingCode error: ' . $e->getMessage()); + } + + return null; + } + + /** + * Status da instância. + * Retorna 'state' no padrão Evolution ('open', 'connecting', 'close') e + * 'logged_in' (null quando a API não informou). + */ + public function getConnectionStatus(?string $instanceName = null): array + { + try { + $response = $this->http($this->instanceKey(), 10)->get("{$this->baseUrl}/instance/status"); + + if (!$response->successful()) { + return [ + 'success' => false, + 'error' => $this->errorFrom($response), + 'status_code' => $response->status(), + ]; + } + + $data = $this->unwrap($response->json()); + $data = is_array($data) ? $data : []; + + $connected = $this->pick($data, ['connected']); + $loggedIn = $this->pick($data, ['loggedin', 'logged_in']); + + if ($connected === true && $loggedIn !== false) { + $state = 'open'; + } elseif ($loggedIn === false) { + $state = 'close'; + } else { + $state = 'connecting'; + } + + return [ + 'success' => true, + 'state' => $state, + 'connected' => $connected === true, + 'logged_in' => is_bool($loggedIn) ? $loggedIn : null, + 'phone' => static::phoneFromJid($this->pick($data, ['myjid', 'jid', 'wuid'])), + 'data' => $data, + ]; + } catch (\Exception $e) { + return ['success' => false, 'error' => $e->getMessage()]; + } + } + + /** + * Desconectar (logout) a instância. + */ + public function disconnectInstance(?string $instanceName = null): array + { + try { + $response = $this->http($this->instanceKey())->delete("{$this->baseUrl}/instance/logout"); + + if ($response->successful()) { + return ['success' => true]; + } + + return ['success' => false, 'error' => $this->errorFrom($response)]; + } catch (\Exception $e) { + return ['success' => false, 'error' => $e->getMessage()]; + } + } + + /** + * Reconectar a instância. + */ + public function restartInstance(?string $instanceName = null): array + { + try { + $response = $this->http($this->instanceKey())->post("{$this->baseUrl}/instance/reconnect"); + + if (!$response->successful()) { + return ['success' => false, 'error' => $this->errorFrom($response)]; + } + + $status = $this->getConnectionStatus(); + + return [ + 'success' => true, + 'state' => ($status['success'] ?? false) ? $status['state'] : 'connecting', + 'data' => $this->unwrap($response->json()), + ]; + } catch (\Exception $e) { + Log::error('EvolutionGO restartInstance error: ' . $e->getMessage()); + return ['success' => false, 'error' => $e->getMessage()]; + } + } + + /** + * Deletar instância (rota admin). A API exige o ID interno, obtido via /instance/all. + */ + public function deleteInstance(?string $instanceName = null): array + { + $instanceName = $instanceName ?: $this->instanceName; + + try { + $instance = $this->findInstance($instanceName); + if (!$instance) { + return ['success' => false, 'error' => 'Instance not found (404)']; + } + + $id = $this->pick($instance, ['id', 'instanceid']); + if (!$id) { + return ['success' => false, 'error' => 'Instance id not found']; + } + + $response = $this->http($this->apiKey)->delete("{$this->baseUrl}/instance/delete/" . rawurlencode((string) $id)); + + if ($response->successful()) { + return ['success' => true]; + } + + return ['success' => false, 'error' => $this->errorFrom($response)]; + } catch (\Exception $e) { + return ['success' => false, 'error' => $e->getMessage()]; + } + } + + /** + * Listar instâncias (rota admin). + */ + public function listInstances(): ?array + { + $response = $this->http($this->apiKey, 15)->get("{$this->baseUrl}/instance/all"); + if (!$response->successful()) { + return null; + } + + $data = $this->unwrap($response->json()); + return is_array($data) ? array_values(array_filter($data, 'is_array')) : []; + } + + // ------------------------------------------------------------------ + // Mensagens (mesma assinatura do EvolutionWhatsAppService, usada pela fila) + // ------------------------------------------------------------------ + + public function sendTextMessage(string $number, string $text): array + { + if ($error = $this->sendPreconditionError($number)) { + return ['success' => false, 'error' => $error]; + } + + if ($text === '') { + return ['success' => false, 'error' => 'Message text is required']; + } + + return $this->sendRequest('/send/text', [ + 'number' => $this->cleanNumber($number), + 'text' => $text, + ], 'text'); + } + + public function sendMediaMessage( + string $number, + string $mediaUrl, + ?string $caption = null, + ?string $mediaType = 'image', + ?string $mimeType = null + ): array { + if ($error = $this->sendPreconditionError($number)) { + return ['success' => false, 'error' => $error]; + } + + $mediaType = $mediaType ?: 'image'; + $payload = [ + 'number' => $this->cleanNumber($number), + 'url' => $mediaUrl, + 'type' => $mediaType, + ]; + + if ($caption) { + $payload['caption'] = $caption; + } + + if ($mediaType === 'document') { + $fileName = basename((string) parse_url($mediaUrl, PHP_URL_PATH)); + if ($fileName !== '') { + $payload['filename'] = $fileName; + } + } + + return $this->sendRequest('/send/media', $payload, 'media'); + } + + public function sendAudioMessage(string $number, string $audioUrl): array + { + if ($error = $this->sendPreconditionError($number)) { + return ['success' => false, 'error' => $error]; + } + + return $this->sendRequest('/send/media', [ + 'number' => $this->cleanNumber($number), + 'url' => $audioUrl, + 'type' => 'audio', + ], 'audio'); + } + + /** + * Verificar se números possuem WhatsApp. + * Retorna no mesmo formato usado pelo ProcessWhatsAppQueue: + * ['success' => true, 'data' => [['exists' => bool, 'jid' => ..., 'number' => ...], ...]] + */ + public function checkWhatsAppNumbers(array $numbers): array + { + $cleaned = []; + foreach ($numbers as $number) { + $n = $this->cleanNumber((string) $number); + if (strlen($n) >= 8 && strlen($n) <= 15) { + $cleaned[] = $n; + } + } + + if (empty($cleaned)) { + return ['success' => false, 'error' => 'No valid phone numbers provided']; + } + + if (empty($this->baseUrl) || empty($this->instanceKey())) { + return ['success' => false, 'error' => 'EvolutionGO API is not configured']; + } + + try { + $response = $this->http($this->instanceKey(), 15)->post("{$this->baseUrl}/user/check", [ + 'number' => $cleaned, + ]); + + if (!$response->successful()) { + return ['success' => false, 'error' => $this->errorFrom($response)]; + } + + $data = $this->unwrap($response->json()); + $users = is_array($data) ? ($this->pick($data, ['users']) ?? $data) : []; + $users = is_array($users) ? $users : []; + + $result = []; + foreach ($users as $i => $user) { + if (!is_array($user)) { + continue; + } + + $jid = $this->pick($user, ['jid']); + $query = $this->pick($user, ['query', 'number']); + $exists = $this->pick($user, ['isinwhatsapp', 'exists']); + + $result[] = [ + 'exists' => (bool) $exists, + 'jid' => is_string($jid) ? $jid : null, + 'number' => static::phoneFromJid($jid) ?: $this->cleanNumber((string) ($query ?? ($cleaned[$i] ?? ''))), + ]; + } + + if (empty($result)) { + return ['success' => false, 'error' => 'Unexpected response from EvolutionGO /user/check']; + } + + return ['success' => true, 'data' => $result]; + } catch (\Exception $e) { + return ['success' => false, 'error' => $e->getMessage()]; + } + } + + // ------------------------------------------------------------------ + // WhatsAppServiceInterface + // ------------------------------------------------------------------ + + public function sendText(string $number, string $message): bool + { + return $this->sendTextMessage($number, $message)['success'] ?? false; + } + + public function testConnection(): array + { + if (empty($this->baseUrl) || empty($this->apiKey)) { + return [ + 'success' => false, + 'message' => 'Configuração incompleta (URL ou GLOBAL_API_KEY faltando)', + 'details' => null, + ]; + } + + try { + $response = $this->http($this->apiKey, 10)->get("{$this->baseUrl}/instance/all"); + + if ($response->successful()) { + $instances = $this->unwrap($response->json()); + + return [ + 'success' => true, + 'message' => 'Conexão com EvolutionGO estabelecida com sucesso', + 'details' => [ + 'base_url' => $this->baseUrl, + 'instances' => is_array($instances) ? count($instances) : null, + ], + ]; + } + + if ($response->status() === 401 || $response->status() === 403) { + return [ + 'success' => false, + 'message' => 'API Key inválida. Use a GLOBAL_API_KEY do EvolutionGO.', + 'details' => ['status' => $response->status()], + ]; + } + + return [ + 'success' => false, + 'message' => 'Não foi possível conectar ao EvolutionGO (HTTP ' . $response->status() . ').', + 'details' => ['status' => $response->status()], + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'message' => 'Erro ao testar conexão: ' . $e->getMessage(), + 'details' => null, + ]; + } + } + + // ------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------ + + /** + * Extrair apenas os dígitos do número a partir de um JID (5511999999999:12@s.whatsapp.net). + */ + public static function phoneFromJid($jid): ?string + { + if (!is_string($jid) || $jid === '') { + return null; + } + + $user = explode('@', $jid)[0]; + $user = explode(':', $user)[0]; + $digits = preg_replace('/[^0-9]/', '', $user); + + return $digits !== '' ? $digits : null; + } + + protected function sendRequest(string $endpoint, array $payload, string $kind): array + { + try { + $response = $this->http($this->instanceKey(), 30)->post("{$this->baseUrl}{$endpoint}", $payload); + + if ($response->successful()) { + $data = $this->unwrap($response->json()); + $info = is_array($data) ? ($this->pick($data, ['info']) ?? []) : []; + $messageId = is_array($info) ? $this->pick($info, ['id']) : null; + $messageId = $messageId ?? (is_array($data) ? $this->pick($data, ['id', 'messageid']) : null); + + Log::info("EvolutionGO: {$kind} message sent", [ + 'instance' => $this->instanceName, + 'number' => $payload['number'] ?? null, + 'message_id' => $messageId, + ]); + + $data = is_array($data) ? $data : ['response' => $data]; + // ProcessWhatsAppQueue lê o ID em data.key.id (formato da Evolution API) + $data['key'] = $data['key'] ?? ['id' => $messageId]; + + return [ + 'success' => true, + 'messageId' => $messageId, + 'data' => $data, + ]; + } + + $error = $this->errorFrom($response); + Log::error("EvolutionGO: {$kind} message failed", [ + 'instance' => $this->instanceName, + 'status' => $response->status(), + 'number' => $payload['number'] ?? null, + 'error' => $error, + ]); + + return ['success' => false, 'error' => $error]; + } catch (\Illuminate\Http\Client\ConnectionException $e) { + return ['success' => false, 'error' => 'Connection timeout: ' . $e->getMessage()]; + } catch (\Exception $e) { + Log::error("EvolutionGO Exception: {$kind}", [ + 'instance' => $this->instanceName, + 'error' => $e->getMessage(), + ]); + return ['success' => false, 'error' => $e->getMessage()]; + } + } + + protected function sendPreconditionError(string $number): ?string + { + if (empty($this->baseUrl)) { + return 'EvolutionGO base URL is not configured. Please check your WhatsApp integration settings.'; + } + if (empty($this->instanceToken)) { + return 'EvolutionGO instance token is missing for this connection. Reconnect the WhatsApp.'; + } + if ($this->cleanNumber($number) === '') { + return 'Phone number is required'; + } + + return null; + } + + protected function instanceKey(): string + { + return (string) ($this->instanceToken ?: $this->apiKey); + } + + protected function http(string $key, int $timeout = 30) + { + return Http::timeout($timeout)->withHeaders([ + 'apikey' => $key, + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + ]); + } + + protected function cleanNumber(string $number): string + { + return static::phoneFromJid($number) ?? ''; + } + + /** + * Respostas do EvolutionGO vêm como {"message": "...", "data": ...}. + */ + protected function unwrap($json) + { + if (is_array($json) && array_key_exists('data', $json)) { + return $json['data']; + } + + return $json; + } + + /** + * Ler uma chave ignorando maiúsculas/minúsculas e underscores (a API mistura "Qrcode", "LoggedIn", "myJid"...). + */ + protected function pick(array $data, array $keys) + { + $normalized = []; + foreach ($data as $k => $v) { + if (is_string($k)) { + $normalized[strtolower(str_replace('_', '', $k))] = $v; + } + } + + foreach ($keys as $key) { + $key = strtolower(str_replace('_', '', $key)); + if (array_key_exists($key, $normalized)) { + return $normalized[$key]; + } + } + + return null; + } + + protected function findInstance(string $instanceName): ?array + { + $instances = $this->listInstances() ?? []; + + foreach ($instances as $instance) { + $name = $this->pick($instance, ['name', 'instancename']); + $token = $this->pick($instance, ['token']); + + if ($name === $instanceName || ($this->instanceToken && $token === $this->instanceToken)) { + return $instance; + } + } + + return null; + } + + protected function errorFrom($response): string + { + $json = $response->json(); + + if (is_array($json)) { + foreach (['error', 'message'] as $key) { + if (!empty($json[$key]) && is_string($json[$key])) { + return $json[$key]; + } + } + } + + return 'HTTP ' . $response->status() . ': ' . mb_substr((string) $response->body(), 0, 500); + } +} --- /dev/null +++ b/backend/database/migrations/2026_10_05_000000_add_evolutiongo_provider_to_whatsapp_integrations.php @@ -0,0 +1,33 @@ +where('provider', 'evolutiongo')->exists()) { + throw new \RuntimeException('Existem integrações EvolutionGO cadastradas. Remova-as antes de reverter esta migration.'); + } + + DB::statement("ALTER TABLE whatsapp_integrations MODIFY provider ENUM('evolution', 'uazapi') NOT NULL"); + } +}; --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -619,6 +619,9 @@ // Evolution API webhook (GET para teste navegador, POST para webhook real) Route::match(['get', 'post'], 'EvoAPI', [\App\Http\Controllers\Webhooks\EvoAPIWebhookController::class, 'handle']); + + // EvolutionGO webhook (URL assinada configurada automaticamente em cada instância) + Route::match(['get', 'post'], 'EvoGO', [\App\Http\Controllers\Webhooks\EvoGoWebhookController::class, 'handle']); }); // LEGACY: Webhooks sem prefixo v1 (mantido para compatibilidade com configurações antigas) --- a/frontend/src/components/WhatsAppIntegrationModal.tsx +++ b/frontend/src/components/WhatsAppIntegrationModal.tsx @@ -5,7 +5,7 @@ interface WhatsAppIntegration { id: number; name: string; - provider: 'evolution' | 'uazapi'; + provider: 'evolution' | 'uazapi' | 'evolutiongo'; api_url: string; api_key: string; max_users: number; @@ -20,7 +20,7 @@ const WhatsAppIntegrationModal: React.FC = ({ integration, onClose }) => { const [formData, setFormData] = useState({ name: '', - provider: 'evolution' as 'evolution' | 'uazapi', + provider: 'evolution' as 'evolution' | 'uazapi' | 'evolutiongo', api_url: '', api_key: '', max_users: 10, @@ -131,6 +131,7 @@ > + --- a/frontend/src/pages/WhatsAppIntegrationsPage.tsx +++ b/frontend/src/pages/WhatsAppIntegrationsPage.tsx @@ -7,7 +7,7 @@ interface WhatsAppIntegration { id: number; name: string; - provider: 'evolution' | 'uazapi'; + provider: 'evolution' | 'uazapi' | 'evolutiongo'; api_url: string; api_key: string; max_users: number; @@ -26,6 +26,7 @@ by_provider: { evolution: number; uazapi: number; + evolutiongo?: number; }; } @@ -197,6 +198,7 @@
Evolution: {statistics.by_provider.evolution}
Uazapi: {statistics.by_provider.uazapi}
+
EvolutionGO: {statistics.by_provider.evolutiongo ?? 0}