PluginProbe
Fluent Support – Helpdesk & Customer Support Ticket System / 2.4.5
Fluent Support – Helpdesk & Customer Support Ticket System v2.4.5
2.4.5 2.4.0 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 All 69 releases
fluent-support / app / Modules / MCP / Helpers / MCPHelper.php

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

778 lines 27.7 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 /** Ceiling no tool can exceed. */
19 const HARD_MAX_PER_PAGE = 200;
20
21 /**
22 * Version of the shape of tool results, sent as meta.schema_version so scripts
23 * can tell when it changed. Bump it when a result field is renamed or removed
24 * or changes meaning; adding a field does not need a bump.
25 *
26 * 2 (2026-10-07): last_reply_by became waiting_on; reply_email shows the
27 * template instead of guessed greeting/sign-off flags; uploads lists
28 * allowed_extensions; the customer email can no longer be changed.
29 */
30 const SCHEMA_VERSION = 2;
31
32 /** Max chars in a content preview. */
33 const PREVIEW_CHARS = 150;
34
35 public static function resolveAgent()
36 {
37 $userId = get_current_user_id();
38 if (!$userId) {
39 return null;
40 }
41
42 return Helper::getAgentByUserId($userId);
43 }
44
45 /**
46 * Whose turn it is on an open ticket: "agent" when the customer is owed a reply
47 * (a new ticket counts), "customer" when we answered and wait for them. Null on a
48 * closed ticket, and on an imported one with no reply history.
49 *
50 * Ticket::last_reply_by holds the same fact under an older, inverted name;
51 * informational replies and notes do not change it.
52 *
53 * @param \FluentSupport\App\Models\Ticket $ticket
54 * @return string|null
55 */
56 public static function waitingOn($ticket)
57 {
58 if ($ticket->status === 'closed') {
59 return null;
60 }
61
62 $lastReplyBy = $ticket->last_reply_by;
63 if ($lastReplyBy === 'customer') {
64 return 'agent';
65 }
66
67 return $lastReplyBy === 'agent' ? 'customer' : null;
68 }
69
70 public static function formatTicketForMCP($ticket)
71 {
72 $data = [
73 'id' => $ticket->id,
74 'title' => $ticket->title,
75 'status' => $ticket->status ?: 'new',
76 'priority' => self::normalizePriority($ticket->priority),
77 'client_priority' => $ticket->client_priority ?: 'normal',
78 'source' => $ticket->source,
79 'response_count' => (int) $ticket->response_count,
80 'waiting_on' => self::waitingOn($ticket),
81 'created_at' => self::toIso8601($ticket->created_at),
82 'updated_at' => self::toIso8601($ticket->updated_at),
83 'waiting_since' => self::toIso8601($ticket->waiting_since),
84 'resolved_at' => self::toIso8601($ticket->resolved_at),
85 ];
86
87 if ($ticket->relationLoaded('customer') && $ticket->customer) {
88 $data['customer'] = self::formatPersonSummary($ticket->customer);
89 }
90
91 if ($ticket->relationLoaded('agent') && $ticket->agent) {
92 $data['agent'] = self::formatPersonSummary($ticket->agent);
93 }
94
95 if ($ticket->relationLoaded('product') && $ticket->product) {
96 $data['product'] = [
97 'id' => $ticket->product->id,
98 'title' => $ticket->product->title,
99 ];
100 }
101
102 if ($ticket->relationLoaded('mailbox') && $ticket->mailbox) {
103 $data['mailbox'] = [
104 'id' => $ticket->mailbox->id,
105 'name' => $ticket->mailbox->name,
106 ];
107 }
108
109 if ($ticket->relationLoaded('tags')) {
110 $data['tags'] = $ticket->tags->map(function ($tag) {
111 return ['id' => $tag->id, 'title' => $tag->title];
112 })->toArray();
113 }
114
115 $data['content'] = self::htmlToMarkdown($ticket->content);
116
117 return $data;
118 }
119
120 public static function formatTicketList($paginated, $customerMeta = [])
121 {
122 // A paginator in page mode, a plain collection/array in keyset cursor mode.
123 $items = is_object($paginated) && method_exists($paginated, 'items') ? $paginated->items() : $paginated;
124
125 if (!is_iterable($items)) {
126 $type = is_object($items) ? get_class($items) : gettype($items);
127 throw new \Exception('MCPHelper::formatTicketList() expects a paginator, Collection, or array of tickets, got ' . esc_html($type));
128 }
129
130 $tickets = [];
131 foreach ($items as $ticket) {
132 $item = [
133 'id' => $ticket->id,
134 'title' => $ticket->title,
135 'preview' => self::preview($ticket->content),
136 'status' => $ticket->status ?: 'new',
137 'priority' => self::normalizePriority($ticket->priority),
138 'client_priority' => $ticket->client_priority ?: 'normal',
139 'response_count' => (int) $ticket->response_count,
140 'waiting_on' => self::waitingOn($ticket),
141 'created_at' => self::toIso8601($ticket->created_at),
142 'waiting_since' => self::toIso8601($ticket->waiting_since),
143 ];
144
145 if ($ticket->relationLoaded('customer') && $ticket->customer) {
146 $item['customer'] = self::formatPersonSummary($ticket->customer);
147 }
148
149 if ($ticket->relationLoaded('agent') && $ticket->agent) {
150 $item['agent'] = self::formatPersonSummary($ticket->agent);
151 }
152
153 if ($ticket->relationLoaded('product') && $ticket->product) {
154 $item['product'] = $ticket->product->title;
155 }
156
157 if ($ticket->relationLoaded('tags')) {
158 $item['tags'] = $ticket->tags->pluck('title')->toArray();
159 }
160
161 // Integration-provided one-liner, gated upstream by fst_sensitive_data.
162 if ($ticket->customer_id && isset($customerMeta[$ticket->customer_id])) {
163 $item['customer_summary'] = $customerMeta[$ticket->customer_id];
164 }
165
166 $tickets[] = $item;
167 }
168
169 return $tickets;
170 }
171
172 /**
173 * Standard tool response: a quotable one-line summary, the data, and meta
174 * (schema_version, generated_at, plus caller keys such as paging).
175 */
176 public static function envelope($summary, $data, $meta = [])
177 {
178 return [
179 'summary' => $summary,
180 'data' => $data,
181 'meta' => array_merge(['schema_version' => self::SCHEMA_VERSION, 'generated_at' => wp_date('c')], $meta),
182 ];
183 }
184
185 /**
186 * Clamped ['page' => int, 'per_page' => int] from $params.
187 */
188 public static function pagination($params, $default = 15, $max = null)
189 {
190 $max = min($max ?? self::MAX_PER_PAGE, self::HARD_MAX_PER_PAGE);
191 return [
192 'page' => max((int) ($params['page'] ?? 1), 1),
193 'per_page' => min(max((int) ($params['per_page'] ?? $default), 1), $max),
194 ];
195 }
196
197 /**
198 * Paging block for envelope()'s $meta, from a paginator.
199 */
200 public static function pagingMeta($paginator)
201 {
202 return [
203 'paging' => [
204 'current' => $paginator->currentPage(),
205 'per_page' => $paginator->perPage(),
206 'total' => $paginator->total(),
207 'pages' => $paginator->lastPage(),
208 'has_more' => $paginator->hasMorePages(),
209 ],
210 ];
211 }
212
213 /**
214 * @param iterable $responses Conversations, oldest first.
215 * @param int $maxChars Truncate each body to this many characters (0 = no limit).
216 */
217 public static function formatResponseThread($responses, $maxChars = 0)
218 {
219 $thread = [];
220 foreach ($responses as $response) {
221 $entry = [
222 'id' => $response->id,
223 'type' => $response->conversation_type,
224 'source' => $response->source,
225 'created_at' => self::toIso8601($response->created_at),
226 ];
227 $entry = array_merge($entry, self::truncatedContent(self::htmlToMarkdown($response->content), $maxChars));
228
229 if ($response->relationLoaded('attachments') && !$response->attachments->isEmpty()) {
230 $entry['attachments'] = self::formatAttachments($response->attachments);
231 }
232
233 if ($response->relationLoaded('person') && $response->person) {
234 $entry['person'] = [
235 'name' => self::personName($response->person),
236 'type' => $response->person->person_type,
237 ];
238 }
239
240 $thread[] = $entry;
241 }
242
243 return $thread;
244 }
245
246 /**
247 * ['content' => ...], plus truncated/full_length when it was cut.
248 *
249 * @param string $text
250 * @param int $maxChars 0 = no limit.
251 * @return array
252 */
253 public static function truncatedContent($text, $maxChars = 0)
254 {
255 $maxChars = (int) $maxChars;
256 $length = mb_strlen($text);
257
258 if ($maxChars <= 0 || $length <= $maxChars) {
259 return ['content' => $text];
260 }
261
262 return [
263 'content' => rtrim(mb_substr($text, 0, $maxChars)) . "\u{2026}",
264 'truncated' => true,
265 'full_length' => $length,
266 ];
267 }
268
269 /**
270 * Attachment metadata with a freshly signed download URL.
271 *
272 * @param iterable $attachments
273 * @return array
274 */
275 public static function formatAttachments($attachments)
276 {
277 $items = [];
278 foreach ($attachments as $attachment) {
279 if ($attachment->status !== 'active') {
280 continue;
281 }
282
283 $expires = \FluentSupport\App\Models\Attachment::signedUrlExpiry();
284 $items[] = [
285 'id' => (int) $attachment->id,
286 'title' => $attachment->title,
287 'file_type' => $attachment->file_type,
288 'file_size' => (int) $attachment->file_size,
289 'url' => $attachment->signedUrl($expires),
290 'url_expires_at' => gmdate('c', $expires),
291 ];
292 }
293
294 return $items;
295 }
296
297 public static function formatCustomerForMCP($customer)
298 {
299 return [
300 'id' => $customer->id,
301 'first_name' => $customer->first_name,
302 'last_name' => $customer->last_name,
303 'email' => $customer->email,
304 'status' => $customer->status,
305 'city' => $customer->city,
306 'state' => $customer->state,
307 'country' => $customer->country,
308 'created_at' => self::toIso8601($customer->created_at),
309 ];
310 }
311
312 public static function personName($model)
313 {
314 if (!$model) {
315 return null;
316 }
317 $name = trim(($model->first_name ?? '') . ' ' . ($model->last_name ?? ''));
318 return $name !== '' ? $name : null;
319 }
320
321 public static function formatPersonSummary($person)
322 {
323 return [
324 'id' => $person->id,
325 'name' => self::personName($person),
326 'email' => $person->email,
327 ];
328 }
329
330 public static function normalizePriority($priority)
331 {
332 if (!$priority || $priority === '') {
333 return 'normal';
334 }
335
336 $aliases = [
337 'low' => 'normal',
338 'high' => 'critical',
339 ];
340
341 $normalized = strtolower(trim($priority));
342 $normalized = $aliases[$normalized] ?? $normalized;
343
344 $valid = ['normal', 'medium', 'critical'];
345 return in_array($normalized, $valid, true) ? $normalized : 'normal';
346 }
347
348 public static function toIso8601($value)
349 {
350 if (!$value) {
351 return null;
352 }
353
354 if ($value instanceof \DateTimeInterface) {
355 return $value->format('c');
356 }
357
358 try {
359 if (is_object($value) && isset($value->date)) {
360 // WPFluent sometimes returns a DateTime-like plain object.
361 $str = (string) $value->date;
362 if (self::isZeroDate($str)) {
363 return null;
364 }
365 $dt = new \DateTime($str, new \DateTimeZone($value->timezone ?? 'UTC'));
366 if ((int) $dt->format('Y') < 1) {
367 return null;
368 }
369 return $dt->format('c');
370 }
371
372 if (is_string($value)) {
373 if (self::isZeroDate($value)) {
374 return null;
375 }
376 // Stored dates are site time; PHP's default zone in WordPress is UTC.
377 $dt = new \DateTime($value, wp_timezone());
378 if ((int) $dt->format('Y') < 1) {
379 return null;
380 }
381 return $dt->format('c');
382 }
383 } catch (\Exception $e) {
384 // Malformed date: fall through to null.
385 }
386
387 return null;
388 }
389
390 /** True for MySQL zero-dates that PHP's DateTime constructor would misparse. */
391 private static function isZeroDate($str)
392 {
393 return $str === '' || strpos($str, '0000-00-00') === 0;
394 }
395
396 /**
397 * Customer profile widgets (fluent_support/customer_extra_widgets) for MCP.
398 *
399 * MCP requests widgets with context format 'mcp' (see ProfileInfoService::
400 * getProfileExtraWidgets()). A widget then passes an 'mcp' array instead of
401 * body_html, and MCP returns it as `data`, so agents get fields, not prose:
402 *
403 * if (ProfileInfoService::isMcpWidgetRequest($context)) {
404 * $widgets['my_plugin'] = ['title' => 'Account', 'mcp' => ['plan' => 'paid', 'teams' => [...]]];
405 * }
406 *
407 * It's untrusted integration output: only scalars are kept, tags are
408 * stripped, and depth, list sizes and string lengths are capped
409 * (sanitizeWidgetData()). Without 'mcp', body_html is sent as Markdown.
410 *
411 * @param array $widgets
412 * @param array|null $onlyKeys Widget keys to keep (null = all).
413 * @return array
414 */
415 public static function formatExtraWidgets($widgets, $onlyKeys = null)
416 {
417 $formatted = [];
418
419 foreach ($widgets as $key => $widget) {
420 if (!is_array($widget) || ($onlyKeys !== null && !in_array((string) $key, $onlyKeys, true))) {
421 continue;
422 }
423
424 if (isset($widget['mcp']) && is_array($widget['mcp'])) {
425 $formatted[$key] = [
426 'title' => sanitize_text_field($widget['title'] ?? $widget['header'] ?? $key),
427 'data' => self::sanitizeWidgetData($widget['mcp']),
428 ];
429 continue;
430 }
431
432 // Structured widgets are already plain data.
433 // Same test as isStructuredWidget() in _StructuredWidget.vue: a non-empty list.
434 $structuredKeys = ['badges', 'stats', 'facts', 'sections', 'links'];
435 $isStructured = false;
436 foreach ($structuredKeys as $structuredKey) {
437 if (!empty($widget[$structuredKey]) && is_array($widget[$structuredKey])) {
438 $isStructured = true;
439 break;
440 }
441 }
442
443 if ($isStructured) {
444 $formatted[$key] = [
445 'title' => sanitize_text_field($widget['title'] ?? $widget['header'] ?? $key),
446 'data' => self::sanitizeWidgetData(self::flattenItemBadges(array_intersect_key($widget, array_flip($structuredKeys)))),
447 ];
448 continue;
449 }
450
451 if (!empty($widget['orders'])) {
452 $orders = [];
453 foreach ($widget['orders'] as $order) {
454 $item = [
455 'order_id' => $order['id'] ?? $order['order_id'] ?? null,
456 'status' => $order['status'] ?? '',
457 'total' => $order['total'] ?? $order['amount'] ?? '',
458 'date' => $order['date'] ?? $order['date_created'] ?? '',
459 ];
460 $orders[] = array_filter($item);
461 }
462 $formatted[$key] = [
463 'title' => $widget['title'] ?? $key,
464 'orders' => $orders,
465 ];
466 } elseif (!empty($widget['products'])) {
467 $products = [];
468 foreach ($widget['products'] as $product) {
469 $item = [
470 'name' => $product['title'] ?? $product['name'] ?? '',
471 'status' => $product['status'] ?? '',
472 'price' => $product['price'] ?? '',
473 ];
474 $products[] = array_filter($item);
475 }
476 $entry = [
477 'title' => $widget['title'] ?? $key,
478 'products' => $products,
479 ];
480 if (!empty($widget['summary'])) {
481 $entry['summary'] = $widget['summary'];
482 }
483 $formatted[$key] = $entry;
484 } else {
485 $skipKeys = ['title', 'header', 'render', 'content_type'];
486 $data = array_diff_key($widget, array_flip($skipKeys));
487
488 if (isset($data['body_html'])) {
489 // Raw widget HTML is token-heavy, so send Markdown: tables and
490 // lists keep their shape, links keep their targets.
491 // A summary the integration already provides wins.
492 if (empty($data['summary'])) {
493 $data['summary'] = self::htmlToMarkdown($data['body_html']);
494 }
495 unset($data['body_html']);
496 }
497
498 $formatted[$key] = [
499 'title' => $widget['title'] ?? $key,
500 'data' => $data,
501 ];
502 }
503 }
504
505 return $formatted;
506 }
507
508 /**
509 * sections[].items[].badge sits one level past WIDGET_MAX_DEPTH; keep its text.
510 *
511 * @param array $data
512 * @return array
513 */
514 private static function flattenItemBadges($data)
515 {
516 if (empty($data['sections']) || !is_array($data['sections'])) {
517 return $data;
518 }
519
520 foreach ($data['sections'] as $sectionKey => $section) {
521 if (empty($section['items']) || !is_array($section['items'])) {
522 continue;
523 }
524
525 foreach ($section['items'] as $itemKey => $item) {
526 if (is_array($item) && isset($item['badge']) && is_array($item['badge'])) {
527 $data['sections'][$sectionKey]['items'][$itemKey]['badge'] = $item['badge']['text'] ?? null;
528 }
529 }
530 }
531
532 return $data;
533 }
534
535 // Counts the mcp array itself, so orders[] => items[] => {name, qty} fits.
536 const WIDGET_MAX_DEPTH = 5;
537 const WIDGET_MAX_ITEMS = 50;
538 const WIDGET_MAX_STRING = 500;
539
540 /**
541 * Keep scalars only, strip tags, cap depth, list sizes and string lengths.
542 *
543 * @param array $data
544 * @param int $depth
545 * @return array
546 */
547 public static function sanitizeWidgetData($data, $depth = 1)
548 {
549 $clean = [];
550 foreach (array_slice($data, 0, self::WIDGET_MAX_ITEMS, true) as $key => $value) {
551 $key = is_int($key) ? $key : sanitize_key($key);
552 if ($key === '') {
553 continue;
554 }
555
556 if (is_array($value)) {
557 if ($depth < self::WIDGET_MAX_DEPTH) {
558 $clean[$key] = self::sanitizeWidgetData($value, $depth + 1);
559 }
560 } elseif (is_bool($value) || is_int($value) || is_float($value) || $value === null) {
561 $clean[$key] = $value;
562 } elseif (is_string($value)) {
563 // Decode first, so encoded tags are stripped too.
564 $value = wp_strip_all_tags(html_entity_decode($value, ENT_QUOTES | ENT_HTML5, 'UTF-8'));
565 $value = trim(preg_replace('/[\x00-\x1F\x7F]+/u', ' ', $value));
566
567 // Links come from other sites: web addresses only.
568 if ($key === 'url' && !preg_match('#^https?://#i', $value)) {
569 continue;
570 }
571
572 $clean[$key] = mb_substr($value, 0, self::WIDGET_MAX_STRING);
573 }
574 }
575
576 return $clean;
577 }
578
579 /**
580 * Truncate plain-text content to PREVIEW_CHARS with an ellipsis.
581 *
582 * @param string $html Raw HTML or plain text.
583 * @return string
584 */
585 public static function preview($html, $chars = self::PREVIEW_CHARS)
586 {
587 $text = self::htmlToText($html);
588
589 if (mb_strlen($text) <= $chars) {
590 return $text;
591 }
592
593 return mb_substr($text, 0, $chars) . '...';
594 }
595
596 // Returns unsanitized HTML — callers must run through wp_kses_post.
597 public static function processContent($content, $format = 'markdown')
598 {
599 if ($content === '' || $content === null) {
600 return '';
601 }
602
603 switch ($format) {
604 case 'html':
605 return $content;
606 case 'text':
607 return wpautop(esc_html($content));
608 default:
609 return self::markdownToHtml($content);
610 }
611 }
612
613 private static function markdownToHtml($content)
614 {
615 $parsedown = new Parsedown();
616 $parsedown->setSafeMode(false);
617 // Agents write emails: a single newline ("Thanks,\nJewel") is a line break.
618 $parsedown->setBreaksEnabled(true);
619
620 return self::escapeStrayAngleBrackets($parsedown->text($content));
621 }
622
623 // Escape a "<" that doesn't open a kses-allowed tag, so text like x<5 or List<String> isn't stripped as a tag.
624 private static function escapeStrayAngleBrackets($html)
625 {
626 $allowedTags = wp_kses_allowed_html('post');
627
628 return preg_replace_callback('/<(\/?)([a-zA-Z][a-zA-Z0-9-]*)?/', function ($match) use ($allowedTags) {
629 $tag = isset($match[2]) ? strtolower($match[2]) : '';
630
631 return ($tag !== '' && isset($allowedTags[$tag])) ? $match[0] : '&lt;' . substr($match[0], 1);
632 }, $html);
633 }
634
635 public static function htmlToText($html)
636 {
637 if (!$html) {
638 return '';
639 }
640
641 // Line breaks and block boundaries separate words; stripping them alone glues
642 // "Name</p><p>Pedro" into "NamePedro".
643 $html = preg_replace('#<br\s*/?>|</?(p|div|li|ul|ol|tr|td|th|h[1-6]|blockquote|pre|table|section|article|header|footer)\b[^>]*>#i', ' ', $html);
644
645 $text = wp_strip_all_tags($html);
646 $text = html_entity_decode($text, ENT_QUOTES, 'UTF-8');
647 $text = preg_replace('/\s+/', ' ', $text);
648
649 return trim($text);
650 }
651
652 /**
653 * HTML to Markdown for agents: keeps line breaks, lists, tables, code
654 * blocks and link targets that htmlToText() flattens.
655 *
656 * @param string $html
657 * @return string
658 */
659 public static function htmlToMarkdown($html)
660 {
661 return HtmlToMarkdown::convert($html);
662 }
663
664 /**
665 * WP_Error whose message is a JSON error envelope. The MCP adapter forwards
666 * only the message, not error_data, so the structure has to live there.
667 *
668 * Supported $details keys:
669 * fields => string[] Input parameter names that caused the error.
670 * next_step => string What the agent should do to recover.
671 * hint => string Extra context.
672 * retryable => bool Whether retrying the same call might succeed (default false).
673 */
674 public static function error($code, $message, $details = [])
675 {
676 $payload = array_merge(['code' => $code, 'message' => $message, 'retryable' => false], $details);
677 return new \WP_Error($code, wp_json_encode(['error' => $payload]));
678 }
679
680 /**
681 * The gate every MCP path uses before assigning a ticket to a specific agent:
682 * checks fst_assign_agents, that the agent exists, and, when a ticket is given,
683 * that the agent isn't restricted from its mailbox.
684 *
685 * @param int $agentId
686 * @param Ticket|null $ticket Null skips the mailbox check (bulk assign checks per ticket).
687 * @param string $fieldName Input field name to report in error details.
688 * @return Agent|\WP_Error The resolved agent, or a WP_Error to return as-is.
689 */
690 public static function resolveAssignmentTarget($agentId, $ticket = null, $fieldName = 'agent_id')
691 {
692 if (!PermissionManager::currentUserCan('fst_assign_agents')) {
693 return self::error('forbidden', __('You do not have permission to assign agents', 'fluent-support'));
694 }
695
696 $agentId = (int) $agentId;
697 if ($agentId <= 0) {
698 return self::error('invalid_param', sprintf(__('%s must be a positive integer', 'fluent-support'), $fieldName), ['fields' => [$fieldName]]);
699 }
700
701 $targetAgent = Agent::find($agentId);
702 if (!$targetAgent) {
703 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']);
704 }
705
706 if ($ticket instanceof Ticket) {
707 if ($err = TicketAccessGuard::assertAssignableAgent($ticket, $targetAgent)) {
708 return $err;
709 }
710 }
711
712 return $targetAgent;
713 }
714
715 /**
716 * Assign the ticket and fire the assignment side effects if the assignee changed.
717 * Always saves, so callers can stage other field changes on the model first.
718 * Authorize $targetAgent first with resolveAssignmentTarget() (self-assignment may skip it).
719 *
720 * Inside a transaction, pass $deferSideEffects and call fireAgentAssignmentSideEffects()
721 * after commit, so emails and webhooks don't fire for a rolled-back change. Capture the
722 * previous agent_id before calling, since this overwrites it.
723 *
724 * @return bool True if the assignee changed, false if it was already $targetAgent.
725 */
726 public static function applyAgentAssignment(Ticket $ticket, Agent $targetAgent, Agent $actingAgent, $deferSideEffects = false)
727 {
728 $changed = (int) $ticket->agent_id !== (int) $targetAgent->id;
729 $previousAgentId = $ticket->agent_id;
730
731 if ($changed) {
732 $ticket->agent_id = $targetAgent->id;
733 }
734
735 $ticket->save();
736
737 if ($changed && !$deferSideEffects) {
738 $ticket->load('agent');
739 self::fireAgentAssignmentSideEffects($ticket, $actingAgent, $previousAgentId);
740 }
741
742 return $changed;
743 }
744
745 /**
746 * Add the assignment note and fire fluent_support/agent_assigned_to_ticket.
747 * Not transactional, so call only after the assignment is committed.
748 * $ticket must have the new 'agent' relation loaded.
749 */
750 public static function fireAgentAssignmentSideEffects(Ticket $ticket, Agent $actingAgent, $previousAgentId)
751 {
752 (new TicketService())->onAgentChange($ticket, $actingAgent);
753 do_action('fluent_support/agent_assigned_to_ticket', $ticket->agent, $ticket, $actingAgent, $previousAgentId);
754 }
755
756 public static function formatDuration($seconds)
757 {
758 if (!$seconds) {
759 return '0m';
760 }
761 $seconds = (int) round($seconds);
762 if ($seconds < 60) {
763 return $seconds . 's';
764 }
765 if ($seconds < 3600) {
766 return round($seconds / 60) . 'm';
767 }
768 if ($seconds < 86400) {
769 $h = floor($seconds / 3600);
770 $m = round(($seconds % 3600) / 60);
771 return $h . 'h ' . $m . 'm';
772 }
773 $d = floor($seconds / 86400);
774 $h = round(($seconds % 86400) / 3600);
775 return $d . 'd ' . $h . 'h';
776 }
777 }
778