PluginProbe
Fluent Support – Helpdesk & Customer Support Ticket System / trunk
Fluent Support – Helpdesk & Customer Support Ticket System vtrunk
2.3.2 2.3.1 2.3.0 2.2.1 2.2.0 trunk 1.10.0 1.10.1 1.10.2 1.10.3 1.10.4 1.10.5 1.4.0 1.4.1 1.4.2 1.4.5 1.4.6 1.4.7 1.5.0 1.5.1 1.5.2 1.5.3 1.5.4 1.5.5 1.5.6 All 67 releases
fluent-support / app / Modules / MCP / Helpers / MCPHelper.php

MCPHelper.php in Fluent Support – Helpdesk & Customer Support Ticket System trunk, at app/Modules/MCP/Helpers/MCPHelper.php

651 lines 24.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentSupport\App\Modules\MCP\Helpers;
4
5 use FluentSupport\App\Models\Agent;
6 use FluentSupport\App\Models\Ticket;
7 use FluentSupport\App\Modules\MCP\Support\TicketAccessGuard;
8 use FluentSupport\App\Modules\PermissionManager;
9 use FluentSupport\App\Services\Helper;
10 use FluentSupport\App\Services\Parser\Parsedown;
11 use FluentSupport\App\Services\Tickets\TicketService;
12
13 class MCPHelper
14 {
15 /** Default per_page ceiling. Individual tools may raise their own limit up to HARD_MAX_PER_PAGE. */
16 const MAX_PER_PAGE = 100;
17
18 /** Absolute ceiling no tool can exceed, however large a per_page it requests. */
19 const HARD_MAX_PER_PAGE = 200;
20
21 /** Maximum chars kept in a content preview before truncation. */
22 const PREVIEW_CHARS = 150;
23
24 public static function resolveAgent()
25 {
26 $userId = get_current_user_id();
27 if (!$userId) {
28 return null;
29 }
30
31 return Helper::getAgentByUserId($userId);
32 }
33
34 public static function formatTicketForMCP($ticket)
35 {
36 $data = [
37 'id' => $ticket->id,
38 'title' => $ticket->title,
39 'status' => $ticket->status ?: 'new',
40 'priority' => self::normalizePriority($ticket->priority),
41 'client_priority' => $ticket->client_priority ?: 'normal',
42 'source' => $ticket->source,
43 'response_count' => (int) $ticket->response_count,
44 'created_at' => self::toIso8601($ticket->created_at),
45 'updated_at' => self::toIso8601($ticket->updated_at),
46 'waiting_since' => self::toIso8601($ticket->waiting_since),
47 'resolved_at' => self::toIso8601($ticket->resolved_at),
48 ];
49
50 if ($ticket->relationLoaded('customer') && $ticket->customer) {
51 $data['customer'] = self::formatPersonSummary($ticket->customer);
52 }
53
54 if ($ticket->relationLoaded('agent') && $ticket->agent) {
55 $data['agent'] = self::formatPersonSummary($ticket->agent);
56 }
57
58 if ($ticket->relationLoaded('product') && $ticket->product) {
59 $data['product'] = [
60 'id' => $ticket->product->id,
61 'title' => $ticket->product->title,
62 ];
63 }
64
65 if ($ticket->relationLoaded('mailbox') && $ticket->mailbox) {
66 $data['mailbox'] = [
67 'id' => $ticket->mailbox->id,
68 'name' => $ticket->mailbox->name,
69 ];
70 }
71
72 if ($ticket->relationLoaded('tags')) {
73 $data['tags'] = $ticket->tags->map(function ($tag) {
74 return ['id' => $tag->id, 'title' => $tag->title];
75 })->toArray();
76 }
77
78 $data['content'] = self::htmlToText($ticket->content);
79
80 return $data;
81 }
82
83 public static function formatTicketList($paginated, $customerMeta = [])
84 {
85 // Accepts either a WPFluent paginator (page/per_page mode) or a plain
86 // collection/array (keyset cursor mode, which fetches rows directly).
87 $items = is_object($paginated) && method_exists($paginated, 'items') ? $paginated->items() : $paginated;
88
89 if (!is_iterable($items)) {
90 $type = is_object($items) ? get_class($items) : gettype($items);
91 throw new \Exception('MCPHelper::formatTicketList() expects a paginator, Collection, or array of tickets, got ' . esc_html($type));
92 }
93
94 $tickets = [];
95 foreach ($items as $ticket) {
96 $item = [
97 'id' => $ticket->id,
98 'title' => $ticket->title,
99 'preview' => self::preview($ticket->content),
100 'status' => $ticket->status ?: 'new',
101 'priority' => self::normalizePriority($ticket->priority),
102 'client_priority' => $ticket->client_priority ?: 'normal',
103 'response_count' => (int) $ticket->response_count,
104 'last_reply_by' => $ticket->last_reply_by,
105 'created_at' => self::toIso8601($ticket->created_at),
106 'waiting_since' => self::toIso8601($ticket->waiting_since),
107 ];
108
109 if ($ticket->relationLoaded('customer') && $ticket->customer) {
110 $item['customer'] = self::formatPersonSummary($ticket->customer);
111 }
112
113 if ($ticket->relationLoaded('agent') && $ticket->agent) {
114 $item['agent'] = self::formatPersonSummary($ticket->agent);
115 }
116
117 if ($ticket->relationLoaded('product') && $ticket->product) {
118 $item['product'] = $ticket->product->title;
119 }
120
121 if ($ticket->relationLoaded('tags')) {
122 $item['tags'] = $ticket->tags->pluck('title')->toArray();
123 }
124
125 // Optional integration-provided customer one-liner (gated upstream by fst_sensitive_data).
126 if ($ticket->customer_id && isset($customerMeta[$ticket->customer_id])) {
127 $item['customer_summary'] = $customerMeta[$ticket->customer_id];
128 }
129
130 $tickets[] = $item;
131 }
132
133 return $tickets;
134 }
135
136 /**
137 * Wrap a tool response in a consistent envelope.
138 *
139 * Every tool response has:
140 * summary — one-line the agent can quote ("12 tickets found, page 1 of 3")
141 * data — the actual payload
142 * meta — schema_version, generated_at, plus any caller-supplied keys (e.g. paging)
143 */
144 public static function envelope($summary, $data, $meta = [])
145 {
146 return [
147 'summary' => $summary,
148 'data' => $data,
149 'meta' => array_merge(['schema_version' => 1, 'generated_at' => gmdate('c')], $meta),
150 ];
151 }
152
153 /**
154 * Extract and clamp page/per_page from $params.
155 * Returns ['page' => int, 'per_page' => int].
156 */
157 public static function pagination($params, $default = 15, $max = null)
158 {
159 $max = min($max ?? self::MAX_PER_PAGE, self::HARD_MAX_PER_PAGE);
160 return [
161 'page' => max((int) ($params['page'] ?? 1), 1),
162 'per_page' => min(max((int) ($params['per_page'] ?? $default), 1), $max),
163 ];
164 }
165
166 /**
167 * Build the paging meta block from a WPFluent paginator.
168 * Merge this into the $meta arg of envelope().
169 */
170 public static function pagingMeta($paginator)
171 {
172 return [
173 'paging' => [
174 'current' => $paginator->currentPage(),
175 'per_page' => $paginator->perPage(),
176 'total' => $paginator->total(),
177 'pages' => $paginator->lastPage(),
178 'has_more' => $paginator->hasMorePages(),
179 ],
180 ];
181 }
182
183 public static function formatResponseThread($responses)
184 {
185 $thread = [];
186 foreach ($responses as $response) {
187 $entry = [
188 'id' => $response->id,
189 'type' => $response->conversation_type,
190 'content' => self::htmlToText($response->content),
191 'source' => $response->source,
192 'created_at' => self::toIso8601($response->created_at),
193 ];
194
195 if ($response->relationLoaded('person') && $response->person) {
196 $entry['person'] = [
197 'name' => self::personName($response->person),
198 'type' => $response->person->person_type,
199 ];
200 }
201
202 $thread[] = $entry;
203 }
204
205 return $thread;
206 }
207
208 public static function formatCustomerForMCP($customer)
209 {
210 return [
211 'id' => $customer->id,
212 'first_name' => $customer->first_name,
213 'last_name' => $customer->last_name,
214 'email' => $customer->email,
215 'status' => $customer->status,
216 'city' => $customer->city,
217 'state' => $customer->state,
218 'country' => $customer->country,
219 'created_at' => self::toIso8601($customer->created_at),
220 ];
221 }
222
223 public static function personName($model)
224 {
225 if (!$model) {
226 return null;
227 }
228 $name = trim(($model->first_name ?? '') . ' ' . ($model->last_name ?? ''));
229 return $name !== '' ? $name : null;
230 }
231
232 public static function formatPersonSummary($person)
233 {
234 return [
235 'id' => $person->id,
236 'name' => self::personName($person),
237 'email' => $person->email,
238 ];
239 }
240
241 public static function normalizePriority($priority)
242 {
243 if (!$priority || $priority === '') {
244 return 'normal';
245 }
246
247 $aliases = [
248 'low' => 'normal',
249 'high' => 'critical',
250 ];
251
252 $normalized = strtolower(trim($priority));
253 $normalized = $aliases[$normalized] ?? $normalized;
254
255 $valid = ['normal', 'medium', 'critical'];
256 return in_array($normalized, $valid, true) ? $normalized : 'normal';
257 }
258
259 public static function toIso8601($value)
260 {
261 if (!$value) {
262 return null;
263 }
264
265 if ($value instanceof \DateTimeInterface) {
266 return $value->format('c');
267 }
268
269 try {
270 if (is_object($value) && isset($value->date)) {
271 // WPFluent sometimes returns a DateTimeImmutable-style object.
272 $str = (string) $value->date;
273 if (self::isZeroDate($str)) {
274 return null;
275 }
276 $dt = new \DateTime($str, new \DateTimeZone($value->timezone ?? 'UTC'));
277 if ((int) $dt->format('Y') < 1) {
278 return null;
279 }
280 return $dt->format('c');
281 }
282
283 if (is_string($value)) {
284 if (self::isZeroDate($value)) {
285 return null;
286 }
287 $dt = new \DateTime($value);
288 if ((int) $dt->format('Y') < 1) {
289 return null;
290 }
291 return $dt->format('c');
292 }
293 } catch (\Exception $e) {
294 // Malformed date string — return null rather than throwing.
295 }
296
297 return null;
298 }
299
300 /** True for MySQL zero-dates that PHP's DateTime constructor would misparse. */
301 private static function isZeroDate($str)
302 {
303 return $str === '' || strpos($str, '0000-00-00') === 0;
304 }
305
306 public static function formatExtraWidgets($widgets)
307 {
308 $formatted = [];
309
310 foreach ($widgets as $key => $widget) {
311 if (!empty($widget['orders'])) {
312 $orders = [];
313 foreach ($widget['orders'] as $order) {
314 $item = [
315 'order_id' => $order['id'] ?? $order['order_id'] ?? null,
316 'status' => $order['status'] ?? '',
317 'total' => $order['total'] ?? $order['amount'] ?? '',
318 'date' => $order['date'] ?? $order['date_created'] ?? '',
319 ];
320 $orders[] = array_filter($item);
321 }
322 $formatted[$key] = [
323 'title' => $widget['title'] ?? $key,
324 'orders' => $orders,
325 ];
326 } elseif (!empty($widget['products'])) {
327 $products = [];
328 foreach ($widget['products'] as $product) {
329 $item = [
330 'name' => $product['title'] ?? $product['name'] ?? '',
331 'status' => $product['status'] ?? '',
332 'price' => $product['price'] ?? '',
333 ];
334 $products[] = array_filter($item);
335 }
336 $entry = [
337 'title' => $widget['title'] ?? $key,
338 'products' => $products,
339 ];
340 if (!empty($widget['summary'])) {
341 $entry['summary'] = $widget['summary'];
342 }
343 $formatted[$key] = $entry;
344 } else {
345 $skipKeys = ['title', 'header', 'render', 'content_type'];
346 $data = array_diff_key($widget, array_flip($skipKeys));
347
348 if (isset($data['body_html'])) {
349 // Raw dashboard HTML (often with an inline <style> block) is
350 // token-expensive and repeated on every fetch — plain-text it,
351 // keeping links (e.g. "view order") instead of dropping them.
352 // Don't clobber a summary the integration already curated.
353 if (empty($data['summary'])) {
354 $html = $data['body_html'];
355 $data['summary'] = self::htmlToTextWithLinks($html);
356 }
357 unset($data['body_html']);
358 }
359
360 $formatted[$key] = [
361 'title' => $widget['title'] ?? $key,
362 'data' => $data,
363 ];
364 }
365 }
366
367 return $formatted;
368 }
369
370 /**
371 * Truncate plain-text content to PREVIEW_CHARS with an ellipsis.
372 *
373 * @param string $html Raw HTML or plain text.
374 * @return string
375 */
376 public static function preview($html, $chars = self::PREVIEW_CHARS)
377 {
378 $text = self::htmlToText($html);
379
380 if (mb_strlen($text) <= $chars) {
381 return $text;
382 }
383
384 return mb_substr($text, 0, $chars) . '...';
385 }
386
387 // Returns unsanitized HTML — callers must run through wp_kses_post.
388 public static function processContent($content, $format = 'markdown')
389 {
390 if ($content === '' || $content === null) {
391 return '';
392 }
393
394 switch ($format) {
395 case 'html':
396 return $content;
397 case 'text':
398 return wpautop(esc_html($content));
399 default:
400 return self::markdownToHtml($content);
401 }
402 }
403
404 private static function markdownToHtml($content)
405 {
406 $parsedown = new Parsedown();
407 $parsedown->setSafeMode(false);
408
409 return $parsedown->text($content);
410 }
411
412 public static function htmlToText($html)
413 {
414 if (!$html) {
415 return '';
416 }
417
418 $text = wp_strip_all_tags($html);
419 $text = html_entity_decode($text, ENT_QUOTES, 'UTF-8');
420 $text = preg_replace('/\s+/', ' ', $text);
421
422 return trim($text);
423 }
424
425 // Like htmlToText(), but keeps <a href> targets instead of dropping them —
426 // integration widgets (order/license links) lose meaning without the URL.
427 // Parses via DOMDocument (same approach as Emogrifier::createRawXmlDocument())
428 // rather than regex, so malformed/nested markup degrades gracefully.
429 public static function htmlToTextWithLinks($html)
430 {
431 if (!$html) {
432 return '';
433 }
434
435 $dom = new \DOMDocument();
436 $dom->encoding = 'UTF-8';
437 $dom->strictErrorChecking = false;
438
439 $libXmlState = libxml_use_internal_errors(true);
440 $loaded = $dom->loadHTML(
441 '<?xml encoding="UTF-8"?><div>' . $html . '</div>',
442 LIBXML_NOERROR | LIBXML_NOWARNING
443 );
444 libxml_clear_errors();
445 libxml_use_internal_errors($libXmlState);
446
447 if (!$loaded) {
448 return self::htmlToText($html);
449 }
450
451 // textContent doesn't know style/script are non-visible — drop them first,
452 // otherwise the CSS this whole fix exists to remove leaks back in.
453 foreach (['style', 'script'] as $tag) {
454 foreach (iterator_to_array($dom->getElementsByTagName($tag)) as $node) {
455 $node->parentNode->removeChild($node);
456 }
457 }
458
459 foreach (iterator_to_array($dom->getElementsByTagName('a')) as $anchor) {
460 $url = trim($anchor->getAttribute('href'));
461 $text = trim($anchor->textContent);
462
463 if ($text !== '' && $url !== '') {
464 $replacement = "{$text} ({$url})";
465 } else {
466 $replacement = $text !== '' ? $text : $url;
467 }
468
469 // Pad with boundary spaces — otherwise adjacent anchors (no
470 // whitespace between them in source) run together, e.g.
471 // "...(url)Next link...". Collapsed back down below.
472 $anchor->parentNode->replaceChild($dom->createTextNode(" {$replacement} "), $anchor);
473 }
474
475 // block-level tags carry no implicit whitespace of their own — textContent
476 // concatenates "<div>$0.00</div><div>Free user</div>" as "$0.00Free user"
477 // with nothing between them. Insert a separator on BOTH sides of each one:
478 // "after" alone closes the gap between two sibling blocks, but misses text
479 // immediately preceding a nested block with no source whitespace, e.g.
480 // "<div>TextBefore<p>TextAfter</p></div>" would still squash to
481 // "TextBeforeTextAfter" with an after-only separator. Newline for most
482 // block tags, but just a space for td/th — those sit inside a <tr> that
483 // already gets a newline, and a full break between them would wrongly
484 // split a label from its value (e.g. "Credits" / "19,790"). Any resulting
485 // doubled-up separators (two adjacent blocks each contributing one) are
486 // collapsed by the normalization pass below.
487 $cellTags = ['td' => true, 'th' => true];
488 $separatorTags = array_merge(array_keys($cellTags), [
489 'div', 'p', 'br', 'hr', 'li', 'tr', 'table', 'ul', 'ol',
490 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
491 'section', 'article', 'header', 'footer', 'blockquote',
492 ]);
493
494 // A single unioned XPath query, not one getElementsByTagName() call per
495 // tag — that would re-traverse the whole DOM once per tag name.
496 $xpath = new \DOMXPath($dom);
497 $query = implode(' | ', array_map(fn($tag) => "//{$tag}", $separatorTags));
498 foreach (iterator_to_array($xpath->query($query)) as $node) {
499 $separator = isset($cellTags[$node->tagName]) ? ' ' : "\n";
500 $node->parentNode->insertBefore($dom->createTextNode($separator), $node);
501 $node->parentNode->insertBefore($dom->createTextNode($separator), $node->nextSibling);
502 }
503
504 // /u so \s also matches multi-byte whitespace (e.g. &nbsp; / U+00A0),
505 // which PCRE's \s ignores in byte mode. Collapse horizontal whitespace
506 // first, then fold the line breaks we just inserted down to one each —
507 // this keeps the block boundaries as real newlines instead of squashing
508 // them into a single space along with everything else.
509 $text = preg_replace('/[^\S\n]+/u', ' ', $dom->textContent);
510 $text = preg_replace('/ *\n */u', "\n", $text);
511 $text = preg_replace('/\n{2,}/u', "\n", $text);
512
513 return trim($text);
514 }
515
516 /**
517 * Return a structured WP_Error whose message is a JSON-encoded error envelope.
518 *
519 * The MCP adapter forwards only the WP_Error message string to the agent,
520 * dropping error_data. By encoding into the message the agent receives a
521 * machine-readable structure it can branch on (code, fields, next_step, retryable).
522 *
523 * Supported $details keys:
524 * fields => string[] Input parameter names that caused the error.
525 * next_step => string What the agent should do to recover.
526 * hint => string Extra context.
527 * retryable => bool Whether retrying the same call might succeed (default false).
528 */
529 public static function error($code, $message, $details = [])
530 {
531 $payload = array_merge(['code' => $code, 'message' => $message, 'retryable' => false], $details);
532 return new \WP_Error($code, wp_json_encode(['error' => $payload]));
533 }
534
535 /**
536 * Resolve and authorize an explicit agent-assignment target for an MCP tool.
537 *
538 * Enforces the fst_assign_agents capability, validates the agent exists, and
539 * — when a ticket is supplied — that the agent isn't restricted from the
540 * ticket's mailbox. This is the single gate every MCP path uses before
541 * assigning a ticket to a specific agent.
542 *
543 * @param int $agentId The requested agent ID.
544 * @param Ticket|null $ticket Ticket the agent will be assigned to, for the
545 * mailbox-restriction check; null skips it (e.g.
546 * bulk assign, where each ticket is checked in
547 * the loop, or ticket creation).
548 * @param string $fieldName Input field name to report in error details.
549 * @return Agent|\WP_Error The resolved agent, or a WP_Error to return as-is.
550 */
551 public static function resolveAssignmentTarget($agentId, $ticket = null, $fieldName = 'agent_id')
552 {
553 if (!PermissionManager::currentUserCan('fst_assign_agents')) {
554 return self::error('forbidden', __('You do not have permission to assign agents', 'fluent-support'));
555 }
556
557 $agentId = (int) $agentId;
558 if ($agentId <= 0) {
559 return self::error('invalid_param', sprintf(__('%s must be a positive integer', 'fluent-support'), $fieldName), ['fields' => [$fieldName]]);
560 }
561
562 $targetAgent = Agent::find($agentId);
563 if (!$targetAgent) {
564 return self::error('invalid_param', __('The specified agent does not exist', 'fluent-support'), ['fields' => [$fieldName], 'next_step' => 'Use get-support-context to see available agents and their IDs']);
565 }
566
567 if ($ticket instanceof Ticket) {
568 if ($err = TicketAccessGuard::assertAssignableAgent($ticket, $targetAgent)) {
569 return $err;
570 }
571 }
572
573 return $targetAgent;
574 }
575
576 /**
577 * Assign $ticket to $targetAgent and fire the canonical side effects: the
578 * assignment internal-note conversation (TicketService::onAgentChange) and
579 * the fluent_support/agent_assigned_to_ticket hook.
580 *
581 * Always persists the ticket — so callers may set other field changes on the
582 * model first and let this save them in one write — but only fires the
583 * assignment side effects when the assignee actually changes. Callers must
584 * authorize $targetAgent first (see resolveAssignmentTarget);
585 * self-assignment may skip that gate.
586 *
587 * When called inside a DB transaction that can roll back, pass
588 * $deferSideEffects = true so the non-transactional side effects (assignment
589 * email, webhooks) do NOT fire before the transaction commits. The caller
590 * must then invoke fireAgentAssignmentSideEffects() after a successful
591 * commit — capturing $ticket->agent_id BEFORE this call as the previous
592 * agent id, since this method overwrites it.
593 *
594 * @return bool True if the assignee changed, false if it was already $targetAgent.
595 */
596 public static function applyAgentAssignment(Ticket $ticket, Agent $targetAgent, Agent $actingAgent, $deferSideEffects = false)
597 {
598 $changed = (int) $ticket->agent_id !== (int) $targetAgent->id;
599 $previousAgentId = $ticket->agent_id;
600
601 if ($changed) {
602 $ticket->agent_id = $targetAgent->id;
603 }
604
605 $ticket->save();
606
607 if ($changed && !$deferSideEffects) {
608 $ticket->load('agent');
609 self::fireAgentAssignmentSideEffects($ticket, $actingAgent, $previousAgentId);
610 }
611
612 return $changed;
613 }
614
615 /**
616 * Fire the canonical assignment side effects: the assignment internal-note
617 * conversation (TicketService::onAgentChange) and the
618 * fluent_support/agent_assigned_to_ticket hook (which sends the assignment
619 * email synchronously). These are NOT transactional — only call this after
620 * the assignment has been durably committed. $ticket must already carry the
621 * new agent (its 'agent' relation loaded).
622 */
623 public static function fireAgentAssignmentSideEffects(Ticket $ticket, Agent $actingAgent, $previousAgentId)
624 {
625 (new TicketService())->onAgentChange($ticket, $actingAgent);
626 do_action('fluent_support/agent_assigned_to_ticket', $ticket->agent, $ticket, $actingAgent, $previousAgentId);
627 }
628
629 public static function formatDuration($seconds)
630 {
631 if (!$seconds) {
632 return '0m';
633 }
634 $seconds = (int) round($seconds);
635 if ($seconds < 60) {
636 return $seconds . 's';
637 }
638 if ($seconds < 3600) {
639 return round($seconds / 60) . 'm';
640 }
641 if ($seconds < 86400) {
642 $h = floor($seconds / 3600);
643 $m = round(($seconds % 3600) / 60);
644 return $h . 'h ' . $m . 'm';
645 }
646 $d = floor($seconds / 86400);
647 $h = round(($seconds % 86400) / 3600);
648 return $d . 'd ' . $h . 'h';
649 }
650 }
651