baseUrl = rtrim(Env::get('CHATWOOT_BASE_URL'), '/'); $this->token = Env::get('CHATWOOT_API_TOKEN'); $this->accountId = Env::getInt('CHATWOOT_ACCOUNT_ID', 1); } // --------------------------------------------------------------- // API helpers // --------------------------------------------------------------- private function apiUrl(string $path): string { // Chatwoot v2/v3 API: /api/v1/accounts/{account_id}/... return "{$this->baseUrl}/api/v1/accounts/{$this->accountId}{$path}"; } private function authHeaders(): array { return ["api_access_token: {$this->token}"]; } /** * @return array Decoded JSON response */ private function api(string $method, string $path, ?array $body = null): array { $url = $this->apiUrl($path); $res = HttpClient::request($method, $url, $this->authHeaders(), $body); if ($res['status'] >= 400) { Logger::warn("Chatwoot API error", [ 'method' => $method, 'path' => $path, 'status' => $res['status'], 'body' => mb_substr($res['body'], 0, 500), ]); throw new HttpException(502, 'CHATWOOT_API_ERROR', "Chatwoot returned HTTP {$res['status']}"); } return $res['json'] ?? []; } // --------------------------------------------------------------- // Public methods // --------------------------------------------------------------- /** * Get full conversation details (labels, custom_attributes, etc.). */ public function getConversation(int $conversationId): array { return $this->api('GET', "/conversations/{$conversationId}"); } /** * Add a label to a conversation. * Chatwoot API: POST /conversations/{id}/labels (Chatwoot >= v2.14) * The endpoint expects { "labels": ["label1","label2"] } and REPLACES all labels, * so we first fetch existing labels and merge. * * NOTE: Chatwoot label API behaviour may vary across versions. * In v3.x the endpoint path/format is the same, but verify if you upgrade. */ public function addLabel(int $conversationId, string $label): void { $conv = $this->getConversation($conversationId); $existing = $conv['labels'] ?? []; if (in_array($label, $existing, true)) { return; // already present } $existing[] = $label; // Chatwoot expects a JSON body with the complete labels array $this->api('POST', "/conversations/{$conversationId}/labels", [ 'labels' => $existing, ]); Logger::info("Label added", ['conversation_id' => $conversationId, 'label' => $label]); } /** * Set custom attributes on a conversation. * POST /conversations/{id}/custom_attributes with { "custom_attributes": { ... } } * * Chatwoot merges provided keys into existing custom_attributes. */ public function setCustomAttributes(int $conversationId, array $attrs): void { $result = $this->api('POST', "/conversations/{$conversationId}/custom_attributes", [ 'custom_attributes' => $attrs, ]); Logger::info("Custom attributes set", ['conversation_id' => $conversationId, 'attrs' => $attrs]); Logger::debug("Custom attributes API response", [ 'conversation_id' => $conversationId, 'returned_custom_attrs' => $result['custom_attributes'] ?? '(missing)', ]); } /** * Assign a specific agent to a conversation. * POST /conversations/{id}/assignments with { "assignee_id": agent_id } */ public function assignConversation(int $conversationId, int $agentId): void { $this->api('POST', "/conversations/{$conversationId}/assignments", [ 'assignee_id' => $agentId, ]); Logger::info("Conversation assigned", ['conversation_id' => $conversationId, 'agent_id' => $agentId]); } /** * Remove the assigned agent from a conversation (un-assign). * * Chatwoot API: POST /conversations/{id}/assignments * with { "assignee_id": null } to un-assign. * * NOTE (Chatwoot version): In v2.x/v3.x the assignments endpoint accepts * assignee_id=null to clear the assignment. If your version behaves * differently, adjust accordingly. */ public function unassignConversation(int $conversationId): void { // Attempt to un-assign by setting assignee_id to null $url = $this->apiUrl("/conversations/{$conversationId}/assignments"); $res = HttpClient::request('POST', $url, $this->authHeaders(), [ 'assignee_id' => null, ]); if ($res['status'] >= 400) { Logger::warn("Unassign may have failed", [ 'conversation_id' => $conversationId, 'status' => $res['status'], ]); } else { Logger::info("Conversation unassigned", ['conversation_id' => $conversationId]); } } /** * Send an outgoing message in a conversation. * POST /conversations/{id}/messages * * message_type: 1 = outgoing */ public function sendOutgoingMessage(int $conversationId, string $text): array { return $this->api('POST', "/conversations/{$conversationId}/messages", [ 'content' => $text, 'message_type' => 'outgoing', 'private' => false, ]); } /** * Check if conversation is in "ticket/manual" mode. * Returns true if label=ticket or custom_attributes.handoff=true. */ public function isTicketMode(int $conversationId, ?array $convData = null): bool { $conv = $convData ?? $this->getConversation($conversationId); $ticketLabel = Env::get('CHATWOOT_TICKET_LABEL', 'ticket'); $labels = $conv['labels'] ?? []; if (in_array($ticketLabel, $labels, true)) { return true; } $ca = $conv['custom_attributes'] ?? []; if (isset($ca['handoff']) && ($ca['handoff'] === true || $ca['handoff'] === 'true')) { return true; } return false; } }