diff --git a/CHANGELOG.md b/CHANGELOG.md index afe4123..fd5757d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ All notable changes to this project will be documented in this file. ### Added - Live chat transfer (escalated-laravel `escalated.admin.chat.transfer`): `POST /wp-json/escalated/v1/chat/{session_id}/transfer`, with an API token like the other agent chat endpoints. The body is `{agent_id, department_id}` (either or both); the shared `ChatActionBar`'s `{type: "agent"|"department", id}` is accepted too. With an agent, the session and ticket are reassigned to them. With only a department, the ticket moves there and the chat goes back to the queue (unassigned, waiting), then chat routing runs again for the new department. A missing or non-integer target, an unknown department, an ended chat, or an agent who is not staff or cannot see the ticket is a 422; an unknown session is a 404. New `ChatSessionService::transfer()` and `escalated_chat_transferred` action (`$session, $from_agent_id, $to_agent_id, $to_department_id`). - `chat.transferred` runs workflows (new trigger, "Chat transferred") and is sent to webhooks subscribed to it; the webhook form offers it. Its payload is the ticket plus `chat {session_id, ticket_id, status, agent_id, department_id, from_agent_id, to_agent_id, to_department_id}`, as escalated-laravel's. Existing webhooks are not subscribed to it automatically. +- Webhooks now receive the other live chat events, as escalated-laravel's `DispatchWebhook`: `chat.started` (a chat begins), `chat.assigned` (an agent accepts it), `chat.message` (an agent or visitor message) and `chat.ended`. Until now only workflows ran on them. The webhook form offers them. Each payload is the ticket plus `chat {session_id, ticket_id, status, agent_id, department_id}`; `chat.message` adds `reply_id` and `author_type` (`agent` or `visitor`) but never the body, and `chat.ended` adds `ended_by` (`agent`, `customer` or `system`). Existing webhooks, including one folded from the old single webhook URL, are not subscribed to them automatically; tick them on the webhook to receive them. +- `escalated_chat_message` passes a third argument, `$author_type` (`agent` or `visitor`), and `escalated_chat_ended` a third, `$ended_by`. `ChatSessionService::end()` takes an optional third `$ended_by` (default `system`); the agent and widget end endpoints pass `agent` and `customer`. Existing listeners registered for fewer arguments are unaffected. ## [1.6.4] - 2026-10-22 diff --git a/docs/webhooks.md b/docs/webhooks.md index 63c625f..af60f45 100644 --- a/docs/webhooks.md +++ b/docs/webhooks.md @@ -29,7 +29,9 @@ The `escalated_webhook_args` filter can change the request arguments. `escalated The canonical names from the shared event vocabulary: -`ticket.created`, `ticket.updated`, `ticket.status_changed`, `ticket.resolved`, `ticket.closed`, `ticket.reopened`, `ticket.assigned`, `ticket.unassigned`, `ticket.escalated`, `ticket.priority_changed`, `ticket.department_changed`, `reply.created`, `note.created`, `sla.breached`, `sla.warning`, `ticket.tag_added`, `ticket.tag_removed`, `chat.transferred`. +`ticket.created`, `ticket.updated`, `ticket.status_changed`, `ticket.resolved`, `ticket.closed`, `ticket.reopened`, `ticket.assigned`, `ticket.unassigned`, `ticket.escalated`, `ticket.priority_changed`, `ticket.department_changed`, `reply.created`, `note.created`, `sla.breached`, `sla.warning`, `ticket.tag_added`, `ticket.tag_removed`, `chat.started`, `chat.assigned`, `chat.transferred`, `chat.message`, `chat.ended`. + +Live chat events carry the ticket plus a `chat` block with escalated-laravel's fields: `session_id`, `ticket_id`, `status`, `agent_id`, `department_id`; `chat.transferred` adds `from_agent_id`, `to_agent_id`, `to_department_id`; `chat.message` adds `reply_id` and `author_type` (`agent` or `visitor`), never the message body; `chat.ended` adds `ended_by` (`agent`, `customer` or `system`). The visitor's email and chat token are never sent. A webhook saved with an older name (`ticket.tagged`, `internal_note.added`, ...) receives the event under its current name. diff --git a/includes/Api/class-chat-controller.php b/includes/Api/class-chat-controller.php index 0f134f4..aa78358 100644 --- a/includes/Api/class-chat-controller.php +++ b/includes/Api/class-chat-controller.php @@ -225,7 +225,7 @@ public function end_session(WP_REST_Request $request) $session_id = (int) $request->get_param('session_id'); try { - $session = $service->end($session_id); + $session = $service->end($session_id, null, 'agent'); return $this->success($session); } catch (\Throwable $e) { diff --git a/includes/Api/class-widget-chat-controller.php b/includes/Api/class-widget-chat-controller.php index 0bf57b2..3871fd8 100644 --- a/includes/Api/class-widget-chat-controller.php +++ b/includes/Api/class-widget-chat-controller.php @@ -369,7 +369,7 @@ public function end_chat(WP_REST_Request $request) return $session; } - $session = $service->end($session_id); + $session = $service->end($session_id, null, 'customer'); return $this->success([ 'id' => (int) $session->id, @@ -497,7 +497,7 @@ public function end_by_id(WP_REST_Request $request) return $this->error('escalated_chat_ended', __('Chat has already ended.', 'escalated'), 422); } - $session = (new ChatSessionService)->end((int) $session->id); + $session = (new ChatSessionService)->end((int) $session->id, null, 'customer'); return $this->success([ 'message' => __('Chat ended.', 'escalated'), diff --git a/includes/Services/ChatSessionService.php b/includes/Services/ChatSessionService.php index 6a27862..945a1f6 100644 --- a/includes/Services/ChatSessionService.php +++ b/includes/Services/ChatSessionService.php @@ -185,6 +185,8 @@ public function transfer(int $session_id, ?int $to_agent_id = null, ?int $to_dep /** * Send a message within a chat session. Stored as a reply on the ticket. + * Fires escalated_chat_message ($session, $reply, $author_type), where + * $author_type is 'agent' or 'visitor'. */ public function send_message(int $session_id, string $body, ?int $author_id = null, string $author_type = 'visitor'): object { @@ -204,15 +206,18 @@ public function send_message(int $session_id, string $body, ?int $author_id = nu 'last_activity_at' => current_time('mysql'), ]); - do_action('escalated_chat_message', $session, $reply); + do_action('escalated_chat_message', $session, $reply, $author_type === 'agent' ? 'agent' : 'visitor'); return $reply; } /** - * End a chat session. The underlying ticket is resolved. + * End a chat session. The underlying ticket is resolved. Fires + * escalated_chat_ended ($session, $causer_id, $ended_by), where $ended_by + * is who ended it, as escalated-laravel's ChatEnded: 'agent', 'customer' + * (the visitor) or 'system' (idle cleanup). */ - public function end(int $session_id, ?int $causer_id = null): object + public function end(int $session_id, ?int $causer_id = null, string $ended_by = 'system'): object { $session = ChatSession::find($session_id); @@ -233,7 +238,7 @@ public function end(int $session_id, ?int $causer_id = null): object $session = ChatSession::find($session_id); - do_action('escalated_chat_ended', $session, $causer_id); + do_action('escalated_chat_ended', $session, $causer_id, $ended_by); return $session; } diff --git a/includes/Services/EventVocabulary.php b/includes/Services/EventVocabulary.php index f49d0fa..31a52de 100644 --- a/includes/Services/EventVocabulary.php +++ b/includes/Services/EventVocabulary.php @@ -49,7 +49,11 @@ final class EventVocabulary 'sla.warning', 'ticket.tag_added', 'ticket.tag_removed', + 'chat.started', + 'chat.assigned', 'chat.transferred', + 'chat.message', + 'chat.ended', ]; /** diff --git a/includes/Services/NotificationService.php b/includes/Services/NotificationService.php index 17f8674..cf0f169 100644 --- a/includes/Services/NotificationService.php +++ b/includes/Services/NotificationService.php @@ -35,7 +35,11 @@ public function register(): void add_action('escalated_internal_note_added', [$this, 'on_note_created'], 100, 2); add_action('escalated_tag_added', [$this, 'on_tag_added'], 100, 2); add_action('escalated_tag_removed', [$this, 'on_tag_removed'], 100, 2); + add_action('escalated_chat_started', [$this, 'on_chat_started'], 100, 1); + add_action('escalated_chat_accepted', [$this, 'on_chat_assigned'], 100, 2); add_action('escalated_chat_transferred', [$this, 'on_chat_transferred'], 100, 4); + add_action('escalated_chat_message', [$this, 'on_chat_message'], 100, 3); + add_action('escalated_chat_ended', [$this, 'on_chat_ended'], 100, 3); } public function on_ticket_resolved($ticket, $causer_id = null): void @@ -88,26 +92,62 @@ public function on_note_created($note, $ticket = null): void ]); } + public function on_chat_started($session): void + { + $this->dispatch_chat_event('chat.started', $session); + } + + public function on_chat_assigned($session, $agent_id = null): void + { + $this->dispatch_chat_event('chat.assigned', $session); + } + + public function on_chat_transferred($session, $from_agent_id = null, $to_agent_id = null, $to_department_id = null): void + { + $this->dispatch_chat_event('chat.transferred', $session, [ + 'from_agent_id' => self::nullable_int($from_agent_id), + 'to_agent_id' => self::nullable_int($to_agent_id), + 'to_department_id' => self::nullable_int($to_department_id), + ]); + } + /** - * chat.transferred, with escalated-laravel's DispatchWebhook chat fields. - * The visitor's email and chat token stay out. + * chat.message carries the reply's id and who wrote it, never the body: + * a subscriber reads that through the API. */ - public function on_chat_transferred($session, $from_agent_id = null, $to_agent_id = null, $to_department_id = null): void + public function on_chat_message($session, $reply = null, $author_type = 'visitor'): void + { + $this->dispatch_chat_event('chat.message', $session, [ + 'reply_id' => is_object($reply) && ! empty($reply->id) ? (int) $reply->id : null, + 'author_type' => $author_type === 'agent' ? 'agent' : 'visitor', + ]); + } + + public function on_chat_ended($session, $causer_id = null, $ended_by = 'system'): void + { + $this->dispatch_chat_event('chat.ended', $session, [ + 'ended_by' => in_array($ended_by, ['agent', 'customer', 'system'], true) ? $ended_by : 'system', + ]); + } + + /** + * A chat event with escalated-laravel's DispatchWebhook chat fields, plus + * the ticket. The visitor's email, the chat token and message bodies + * stay out. + */ + protected function dispatch_chat_event(string $event, $session, array $extra = []): void { if (! is_object($session) || empty($session->ticket_id)) { return; } - $this->dispatch_ticket_event('chat.transferred', Ticket::find((int) $session->ticket_id), [ + $this->dispatch_ticket_event($event, Ticket::find((int) $session->ticket_id), [ 'chat' => [ 'session_id' => (int) $session->id, 'ticket_id' => (int) $session->ticket_id, 'status' => $session->status ?? null, 'agent_id' => self::nullable_int($session->agent_id ?? null), 'department_id' => self::nullable_int($session->department_id ?? null), - 'from_agent_id' => self::nullable_int($from_agent_id), - 'to_agent_id' => self::nullable_int($to_agent_id), - 'to_department_id' => self::nullable_int($to_department_id), - ], + ] + $extra, ]); } diff --git a/includes/Services/WorkflowListener.php b/includes/Services/WorkflowListener.php index 4378790..1fb8db5 100644 --- a/includes/Services/WorkflowListener.php +++ b/includes/Services/WorkflowListener.php @@ -52,8 +52,8 @@ public function register(): void add_action('escalated_chat_started', [$this, 'on_chat_started'], 50, 1); add_action('escalated_chat_accepted', [$this, 'on_chat_assigned'], 50, 2); add_action('escalated_chat_transferred', [$this, 'on_chat_transferred'], 50, 4); - add_action('escalated_chat_message', [$this, 'on_chat_message'], 50, 2); - add_action('escalated_chat_ended', [$this, 'on_chat_ended'], 50, 2); + add_action('escalated_chat_message', [$this, 'on_chat_message'], 50, 3); + add_action('escalated_chat_ended', [$this, 'on_chat_ended'], 50, 3); } public function on_ticket_created($ticket): void @@ -129,12 +129,12 @@ public function on_chat_transferred($session, $from_agent_id = null, $to_agent_i $this->run_for_chat('chat.transferred', $session); } - public function on_chat_message($session, $reply = null): void + public function on_chat_message($session, $reply = null, $author_type = 'visitor'): void { $this->run_for_chat('chat.message', $session); } - public function on_chat_ended($session, $causer_id = null): void + public function on_chat_ended($session, $causer_id = null, $ended_by = 'system'): void { $this->run_for_chat('chat.ended', $session); } diff --git a/tests/Test_Chat_Webhooks.php b/tests/Test_Chat_Webhooks.php new file mode 100644 index 0000000..de8a681 --- /dev/null +++ b/tests/Test_Chat_Webhooks.php @@ -0,0 +1,293 @@ + */ + private array $http = []; + + /** @var array */ + private array $saved_server = []; + + public function set_up(): void + { + parent::set_up(); + + \Escalated\Activator::activate(); + + $admin = $this->factory->user->create(['role' => 'administrator']); + $this->agent = $this->factory->user->create(['role' => 'escalated_agent']); + + $this->token = wp_generate_password(64, false); + global $wpdb; + $wpdb->insert(\Escalated\Escalated::table('api_tokens'), [ + 'user_id' => $admin, + 'name' => 'Test Token', + 'token' => hash('sha256', $this->token), + 'abilities' => wp_json_encode(['*']), + 'created_at' => current_time('mysql'), + ]); + + Setting::set('widget_enabled', '1'); + Setting::set('guest_tickets_enabled', '1'); + $this->saved_server = $_SERVER; + $_SERVER['REMOTE_ADDR'] = '198.51.100.'.wp_rand(1, 250); + + Webhook::create([ + 'url' => self::WEBHOOK_URL, + 'events' => self::CHAT_EVENTS, + 'secret' => 'webhook-secret', + 'active' => 1, + ]); + $this->http = []; + add_filter('pre_http_request', [$this, 'record_http'], 10, 3); + add_filter('escalated_outbound_url_resolve', [$this, 'resolve_host'], 10, 2); + + global $wp_rest_server; + $this->server = $wp_rest_server = new WP_REST_Server; + do_action('rest_api_init'); + } + + public function tear_down(): void + { + global $wp_rest_server; + $wp_rest_server = null; + $_SERVER = $this->saved_server; + remove_filter('pre_http_request', [$this, 'record_http'], 10); + remove_filter('escalated_outbound_url_resolve', [$this, 'resolve_host'], 10); + + parent::tear_down(); + } + + public function resolve_host($addresses, $host) + { + return $host === 'hooks.example.com' ? ['93.184.216.34'] : $addresses; + } + + public function record_http($preempt, $args, $url) + { + $this->http[] = ['url' => $url, 'args' => $args]; + + return [ + 'headers' => [], + 'body' => '', + 'response' => ['code' => 200, 'message' => 'OK'], + 'cookies' => [], + 'filename' => null, + ]; + } + + public function test_a_widget_chat_start_sends_chat_started(): void + { + $chat = $this->widget_start(); + + $this->assertSame(['chat.started'], $this->delivered()); + $session = ChatSession::find($chat['session_id']); + $payload = $this->payload(0); + $this->assertSame((int) $session->ticket_id, $payload['ticket']['id']); + $this->assertSame([ + 'session_id' => (int) $session->id, + 'ticket_id' => (int) $session->ticket_id, + 'status' => 'waiting', + 'agent_id' => null, + 'department_id' => null, + ], $payload['chat']); + + $body = $this->bodies(); + $this->assertStringNotContainsString('wendy@example.com', $body); + $this->assertStringNotContainsString($chat['id'], $body, 'the chat token never leaves'); + } + + public function test_an_agent_accepting_a_chat_sends_chat_assigned(): void + { + $chat = $this->widget_start(); + $this->settle(); + + $response = $this->agent_post('/chat/'.$chat['session_id'].'/accept', ['agent_id' => $this->agent]); + $this->assertSame(200, $response->get_status()); + + $this->assertSame(['chat.assigned'], $this->delivered()); + $payload = $this->payload(0)['chat']; + $this->assertSame($chat['session_id'], $payload['session_id']); + $this->assertSame($this->agent, $payload['agent_id']); + $this->assertSame('active', $payload['status']); + } + + public function test_messages_send_chat_message_with_the_author_type(): void + { + $chat = $this->widget_start(); + $this->agent_post('/chat/'.$chat['session_id'].'/accept', ['agent_id' => $this->agent]); + $this->settle(); + + $agent = $this->agent_post('/chat/'.$chat['session_id'].'/messages', ['agent_id' => $this->agent, 'body' => 'Agent says hi']); + $this->assertSame(201, $agent->get_status()); + $visitor = $this->widget_post('/chat/'.$chat['id'].'/messages', ['body' => 'Visitor says hi']); + $this->assertSame(201, $visitor->get_status()); + + $this->assertSame(['chat.message', 'chat.message'], $this->delivered()); + + $first = $this->payload(0)['chat']; + $this->assertSame($chat['session_id'], $first['session_id']); + $this->assertSame('agent', $first['author_type']); + $this->assertIsInt($first['reply_id']); + $this->assertSame('Agent says hi', $this->reply_body($first['reply_id'])); + + $second = $this->payload(1)['chat']; + $this->assertSame('visitor', $second['author_type']); + $this->assertSame((int) $visitor->get_data()['reply_id'], $second['reply_id']); + + $this->assertStringNotContainsString('says hi', $this->bodies(), 'message bodies are read through the API'); + } + + public function test_ending_a_chat_sends_chat_ended_with_who_ended_it(): void + { + $by_agent = $this->widget_start(); + $by_visitor = $this->widget_start(); + $idle = $this->widget_start(); + $this->settle(); + + $this->assertSame(200, $this->agent_post('/chat/'.$by_agent['session_id'].'/end', [])->get_status()); + $this->assertSame(200, $this->widget_post('/chat/'.$by_visitor['id'].'/end', [])->get_status()); + ChatSession::update($idle['session_id'], ['last_activity_at' => gmdate('Y-m-d H:i:s', time() - DAY_IN_SECONDS)]); + ChatSession::update($by_agent['session_id'], ['last_activity_at' => current_time('mysql')]); + (new \Escalated\Cron\Chat_Cleanup)->run(); + + $this->assertSame(['chat.ended', 'chat.ended', 'chat.ended'], $this->delivered()); + $ended = []; + foreach ([0, 1, 2] as $i) { + $chat = $this->payload($i)['chat']; + $this->assertSame('ended', $chat['status']); + $ended[$chat['session_id']] = $chat['ended_by']; + } + $this->assertSame([ + $by_agent['session_id'] => 'agent', + $by_visitor['session_id'] => 'customer', + $idle['session_id'] => 'system', + ], $ended); + } + + public function test_a_webhook_not_subscribed_to_chat_events_gets_none(): void + { + global $wpdb; + $wpdb->query('DELETE FROM '.Webhook::table()); + Webhook::create([ + 'url' => self::WEBHOOK_URL, + 'events' => ['ticket.created'], + 'secret' => 'webhook-secret', + 'active' => 1, + ]); + + $chat = $this->widget_start(); + $this->widget_post('/chat/'.$chat['id'].'/messages', ['body' => 'Hello?']); + $this->widget_post('/chat/'.$chat['id'].'/end', []); + + $this->assertSame([], array_values(array_filter($this->delivered(), static fn ($e) => str_starts_with($e, 'chat.')))); + } + + public function test_chat_events_are_offered_to_webhooks(): void + { + foreach ([...self::CHAT_EVENTS, 'chat.transferred'] as $event) { + $this->assertContains($event, EventVocabulary::WEBHOOK_EVENTS); + } + } + + // ========================================================================= + // Helpers + // ========================================================================= + + /** @return array */ + private function widget_start(): array + { + $response = $this->widget_post('/chat/start', array_merge([ + 'name' => 'Wendy Widget', + 'email' => 'wendy@example.com', + 'message' => 'My order is late.', + ], escalated_test_guest_proof('wendy@example.com', 'chat'))); + $this->assertSame(201, $response->get_status(), (string) wp_json_encode($response->get_data())); + + return $response->get_data(); + } + + private function widget_post(string $path, array $body): WP_REST_Response + { + $request = new WP_REST_Request('POST', '/escalated/v1/widget'.$path); + $request->set_header('Content-Type', 'application/json'); + $request->set_body(wp_json_encode((object) $body)); + + return rest_ensure_response($this->server->dispatch($request)); + } + + private function agent_post(string $path, array $body): WP_REST_Response + { + $request = new WP_REST_Request('POST', '/escalated/v1'.$path); + $request->set_header('Authorization', 'Bearer '.$this->token); + $request->set_header('Content-Type', 'application/json'); + $request->set_body(wp_json_encode((object) $body)); + + return rest_ensure_response($this->server->dispatch($request)); + } + + private function reply_body(int $reply_id): string + { + global $wpdb; + + return (string) $wpdb->get_var($wpdb->prepare('SELECT body FROM '.\Escalated\Escalated::table('replies').' WHERE id = %d', $reply_id)); + } + + /** Sends what earlier steps queued and forgets it. */ + private function settle(): void + { + (new WebhookDispatcher)->process_due(); + $this->http = []; + } + + /** @return list */ + private function calls(): array + { + (new WebhookDispatcher)->process_due(); + + return array_values(array_filter($this->http, static fn ($c) => $c['url'] === self::WEBHOOK_URL)); + } + + /** @return list */ + private function delivered(): array + { + return array_map(static fn ($c) => json_decode($c['args']['body'], true)['event'], $this->calls()); + } + + private function payload(int $index): array + { + return json_decode($this->calls()[$index]['args']['body'], true)['payload']; + } + + private function bodies(): string + { + return implode("\n", array_map(static fn ($c) => (string) $c['args']['body'], $this->calls())); + } +}