ChatwootClient.php 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193
  1. <?php
  2. declare(strict_types=1);
  3. namespace EKSRelay\Clients;
  4. use EKSRelay\Core\Env;
  5. use EKSRelay\Core\HttpClient;
  6. use EKSRelay\Core\HttpException;
  7. use EKSRelay\Core\Logger;
  8. final class ChatwootClient
  9. {
  10. private string $baseUrl;
  11. private string $token;
  12. private int $accountId;
  13. public function __construct()
  14. {
  15. $this->baseUrl = rtrim(Env::get('CHATWOOT_BASE_URL'), '/');
  16. $this->token = Env::get('CHATWOOT_API_TOKEN');
  17. $this->accountId = Env::getInt('CHATWOOT_ACCOUNT_ID', 1);
  18. }
  19. // ---------------------------------------------------------------
  20. // API helpers
  21. // ---------------------------------------------------------------
  22. private function apiUrl(string $path): string
  23. {
  24. // Chatwoot v2/v3 API: /api/v1/accounts/{account_id}/...
  25. return "{$this->baseUrl}/api/v1/accounts/{$this->accountId}{$path}";
  26. }
  27. private function authHeaders(): array
  28. {
  29. return ["api_access_token: {$this->token}"];
  30. }
  31. /**
  32. * @return array Decoded JSON response
  33. */
  34. private function api(string $method, string $path, ?array $body = null): array
  35. {
  36. $url = $this->apiUrl($path);
  37. $res = HttpClient::request($method, $url, $this->authHeaders(), $body);
  38. if ($res['status'] >= 400) {
  39. Logger::warn("Chatwoot API error", [
  40. 'method' => $method,
  41. 'path' => $path,
  42. 'status' => $res['status'],
  43. 'body' => mb_substr($res['body'], 0, 500),
  44. ]);
  45. throw new HttpException(502, 'CHATWOOT_API_ERROR', "Chatwoot returned HTTP {$res['status']}");
  46. }
  47. return $res['json'] ?? [];
  48. }
  49. // ---------------------------------------------------------------
  50. // Public methods
  51. // ---------------------------------------------------------------
  52. /**
  53. * Get full conversation details (labels, custom_attributes, etc.).
  54. */
  55. public function getConversation(int $conversationId): array
  56. {
  57. return $this->api('GET', "/conversations/{$conversationId}");
  58. }
  59. /**
  60. * Add a label to a conversation.
  61. * Chatwoot API: POST /conversations/{id}/labels (Chatwoot >= v2.14)
  62. * The endpoint expects { "labels": ["label1","label2"] } and REPLACES all labels,
  63. * so we first fetch existing labels and merge.
  64. *
  65. * NOTE: Chatwoot label API behaviour may vary across versions.
  66. * In v3.x the endpoint path/format is the same, but verify if you upgrade.
  67. */
  68. public function addLabel(int $conversationId, string $label): void
  69. {
  70. $conv = $this->getConversation($conversationId);
  71. $existing = $conv['labels'] ?? [];
  72. if (in_array($label, $existing, true)) {
  73. return; // already present
  74. }
  75. $existing[] = $label;
  76. // Chatwoot expects a JSON body with the complete labels array
  77. $this->api('POST', "/conversations/{$conversationId}/labels", [
  78. 'labels' => $existing,
  79. ]);
  80. Logger::info("Label added", ['conversation_id' => $conversationId, 'label' => $label]);
  81. }
  82. /**
  83. * Set custom attributes on a conversation.
  84. * POST /conversations/{id}/custom_attributes with { "custom_attributes": { ... } }
  85. *
  86. * Chatwoot merges provided keys into existing custom_attributes.
  87. */
  88. public function setCustomAttributes(int $conversationId, array $attrs): void
  89. {
  90. $result = $this->api('POST', "/conversations/{$conversationId}/custom_attributes", [
  91. 'custom_attributes' => $attrs,
  92. ]);
  93. Logger::info("Custom attributes set", ['conversation_id' => $conversationId, 'attrs' => $attrs]);
  94. Logger::debug("Custom attributes API response", [
  95. 'conversation_id' => $conversationId,
  96. 'returned_custom_attrs' => $result['custom_attributes'] ?? '(missing)',
  97. ]);
  98. }
  99. /**
  100. * Assign a specific agent to a conversation.
  101. * POST /conversations/{id}/assignments with { "assignee_id": agent_id }
  102. */
  103. public function assignConversation(int $conversationId, int $agentId): void
  104. {
  105. $this->api('POST', "/conversations/{$conversationId}/assignments", [
  106. 'assignee_id' => $agentId,
  107. ]);
  108. Logger::info("Conversation assigned", ['conversation_id' => $conversationId, 'agent_id' => $agentId]);
  109. }
  110. /**
  111. * Remove the assigned agent from a conversation (un-assign).
  112. *
  113. * Chatwoot API: POST /conversations/{id}/assignments
  114. * with { "assignee_id": null } to un-assign.
  115. *
  116. * NOTE (Chatwoot version): In v2.x/v3.x the assignments endpoint accepts
  117. * assignee_id=null to clear the assignment. If your version behaves
  118. * differently, adjust accordingly.
  119. */
  120. public function unassignConversation(int $conversationId): void
  121. {
  122. // Attempt to un-assign by setting assignee_id to null
  123. $url = $this->apiUrl("/conversations/{$conversationId}/assignments");
  124. $res = HttpClient::request('POST', $url, $this->authHeaders(), [
  125. 'assignee_id' => null,
  126. ]);
  127. if ($res['status'] >= 400) {
  128. Logger::warn("Unassign may have failed", [
  129. 'conversation_id' => $conversationId,
  130. 'status' => $res['status'],
  131. ]);
  132. } else {
  133. Logger::info("Conversation unassigned", ['conversation_id' => $conversationId]);
  134. }
  135. }
  136. /**
  137. * Send an outgoing message in a conversation.
  138. * POST /conversations/{id}/messages
  139. *
  140. * message_type: 1 = outgoing
  141. */
  142. public function sendOutgoingMessage(int $conversationId, string $text): array
  143. {
  144. return $this->api('POST', "/conversations/{$conversationId}/messages", [
  145. 'content' => $text,
  146. 'message_type' => 'outgoing',
  147. 'private' => false,
  148. ]);
  149. }
  150. /**
  151. * Check if conversation is in "ticket/manual" mode.
  152. * Returns true if label=ticket or custom_attributes.handoff=true.
  153. */
  154. public function isTicketMode(int $conversationId, ?array $convData = null): bool
  155. {
  156. $conv = $convData ?? $this->getConversation($conversationId);
  157. $ticketLabel = Env::get('CHATWOOT_TICKET_LABEL', 'ticket');
  158. $labels = $conv['labels'] ?? [];
  159. if (in_array($ticketLabel, $labels, true)) {
  160. return true;
  161. }
  162. $ca = $conv['custom_attributes'] ?? [];
  163. if (isset($ca['handoff']) && ($ca['handoff'] === true || $ca['handoff'] === 'true')) {
  164. return true;
  165. }
  166. return false;
  167. }
  168. }