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
← All changes | app/Modules/MCP/Helpers/MCPHelper.php +284 -157 2.3.1 → 2.4.5 View file →
@@ -14,12 +14,23 @@
14 14 {
15 15 /** Default per_page ceiling. Individual tools may raise their own limit up to HARD_MAX_PER_PAGE. */
16 16 const MAX_PER_PAGE = 100;
17 17
18 - /** Absolute ceiling no tool can exceed, however large a per_page it requests. */
18 + /** Ceiling no tool can exceed. */
19 19 const HARD_MAX_PER_PAGE = 200;
20 20
21 - /** Maximum chars kept in a content preview before truncation. */
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. */
22 33 const PREVIEW_CHARS = 150;
23 34
24 35 public static function resolveAgent()
25 36 {
@@ -30,8 +41,33 @@
30 41
31 42 return Helper::getAgentByUserId($userId);
32 43 }
33 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 +
34 70 public static function formatTicketForMCP($ticket)
35 71 {
36 72 $data = [
37 73 'id' => $ticket->id,
@@ -40,8 +76,9 @@
40 76 'priority' => self::normalizePriority($ticket->priority),
41 77 'client_priority' => $ticket->client_priority ?: 'normal',
42 78 'source' => $ticket->source,
43 79 'response_count' => (int) $ticket->response_count,
80 + 'waiting_on' => self::waitingOn($ticket),
44 81 'created_at' => self::toIso8601($ticket->created_at),
45 82 'updated_at' => self::toIso8601($ticket->updated_at),
46 83 'waiting_since' => self::toIso8601($ticket->waiting_since),
47 84 'resolved_at' => self::toIso8601($ticket->resolved_at),
@@ -74,9 +111,9 @@
74 111 return ['id' => $tag->id, 'title' => $tag->title];
75 112 })->toArray();
76 113 }
77 114
78 - $data['content'] = self::htmlToText($ticket->content);
115 + $data['content'] = self::htmlToMarkdown($ticket->content);
79 116
80 117 return $data;
81 118 }
82 119
@@ -81,15 +118,14 @@
81 118 }
82 119
83 120 public static function formatTicketList($paginated, $customerMeta = [])
84 121 {
85 - // Accepts either a WPFluent paginator (page/per_page mode) or a plain
86 - // collection/array (keyset cursor mode, which fetches rows directly).
122 + // A paginator in page mode, a plain collection/array in keyset cursor mode.
87 123 $items = is_object($paginated) && method_exists($paginated, 'items') ? $paginated->items() : $paginated;
88 124
89 125 if (!is_iterable($items)) {
90 126 $type = is_object($items) ? get_class($items) : gettype($items);
91 - throw new \Exception('MCPHelper::formatTicketList() expects a paginator, Collection, or array of tickets, got ' . $type);
127 + throw new \Exception('MCPHelper::formatTicketList() expects a paginator, Collection, or array of tickets, got ' . esc_html($type));
92 128 }
93 129
94 130 $tickets = [];
95 131 foreach ($items as $ticket) {
@@ -100,9 +136,9 @@
100 136 'status' => $ticket->status ?: 'new',
101 137 'priority' => self::normalizePriority($ticket->priority),
102 138 'client_priority' => $ticket->client_priority ?: 'normal',
103 139 'response_count' => (int) $ticket->response_count,
104 - 'last_reply_by' => $ticket->last_reply_by,
140 + 'waiting_on' => self::waitingOn($ticket),
105 141 'created_at' => self::toIso8601($ticket->created_at),
106 142 'waiting_since' => self::toIso8601($ticket->waiting_since),
107 143 ];
108 144
@@ -121,9 +157,9 @@
121 157 if ($ticket->relationLoaded('tags')) {
122 158 $item['tags'] = $ticket->tags->pluck('title')->toArray();
123 159 }
124 160
125 - // Optional integration-provided customer one-liner (gated upstream by fst_sensitive_data).
161 + // Integration-provided one-liner, gated upstream by fst_sensitive_data.
126 162 if ($ticket->customer_id && isset($customerMeta[$ticket->customer_id])) {
127 163 $item['customer_summary'] = $customerMeta[$ticket->customer_id];
128 164 }
129 165
@@ -133,14 +169,10 @@
133 169 return $tickets;
134 170 }
135 171
136 172 /**
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)
173 + * Standard tool response: a quotable one-line summary, the data, and meta
174 + * (schema_version, generated_at, plus caller keys such as paging).
143 175 */
144 176 public static function envelope($summary, $data, $meta = [])
145 177 {
146 178 return [
@@ -145,15 +177,14 @@
145 177 {
146 178 return [
147 179 'summary' => $summary,
148 180 'data' => $data,
149 - 'meta' => array_merge(['schema_version' => 1, 'generated_at' => gmdate('c')], $meta),
181 + 'meta' => array_merge(['schema_version' => self::SCHEMA_VERSION, 'generated_at' => wp_date('c')], $meta),
150 182 ];
151 183 }
152 184
153 185 /**
154 - * Extract and clamp page/per_page from $params.
155 - * Returns ['page' => int, 'per_page' => int].
186 + * Clamped ['page' => int, 'per_page' => int] from $params.
156 187 */
157 188 public static function pagination($params, $default = 15, $max = null)
158 189 {
159 190 $max = min($max ?? self::MAX_PER_PAGE, self::HARD_MAX_PER_PAGE);
@@ -163,10 +194,9 @@
163 194 ];
164 195 }
165 196
166 197 /**
167 - * Build the paging meta block from a WPFluent paginator.
168 - * Merge this into the $meta arg of envelope().
198 + * Paging block for envelope()'s $meta, from a paginator.
169 199 */
170 200 public static function pagingMeta($paginator)
171 201 {
172 202 return [
@@ -179,9 +209,13 @@
179 209 ],
180 210 ];
181 211 }
182 212
183 - public static function formatResponseThread($responses)
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)
184 218 {
185 219 $thread = [];
186 220 foreach ($responses as $response) {
187 221 $entry = [
@@ -186,13 +220,17 @@
186 220 foreach ($responses as $response) {
187 221 $entry = [
188 222 'id' => $response->id,
189 223 'type' => $response->conversation_type,
190 - 'content' => self::htmlToText($response->content),
191 224 'source' => $response->source,
192 225 'created_at' => self::toIso8601($response->created_at),
193 226 ];
227 + $entry = array_merge($entry, self::truncatedContent(self::htmlToMarkdown($response->content), $maxChars));
194 228
229 + if ($response->relationLoaded('attachments') && !$response->attachments->isEmpty()) {
230 + $entry['attachments'] = self::formatAttachments($response->attachments);
231 + }
232 +
195 233 if ($response->relationLoaded('person') && $response->person) {
196 234 $entry['person'] = [
197 235 'name' => self::personName($response->person),
198 236 'type' => $response->person->person_type,
@@ -204,8 +242,59 @@
204 242
205 243 return $thread;
206 244 }
207 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 +
208 297 public static function formatCustomerForMCP($customer)
209 298 {
210 299 return [
211 300 'id' => $customer->id,
@@ -267,9 +356,9 @@
267 356 }
268 357
269 358 try {
270 359 if (is_object($value) && isset($value->date)) {
271 - // WPFluent sometimes returns a DateTimeImmutable-style object.
360 + // WPFluent sometimes returns a DateTime-like plain object.
272 361 $str = (string) $value->date;
273 362 if (self::isZeroDate($str)) {
274 363 return null;
275 364 }
@@ -283,9 +372,10 @@
283 372 if (is_string($value)) {
284 373 if (self::isZeroDate($value)) {
285 374 return null;
286 375 }
287 - $dt = new \DateTime($value);
376 + // Stored dates are site time; PHP's default zone in WordPress is UTC.
377 + $dt = new \DateTime($value, wp_timezone());
288 378 if ((int) $dt->format('Y') < 1) {
289 379 return null;
290 380 }
291 381 return $dt->format('c');
@@ -290,9 +380,9 @@
290 380 }
291 381 return $dt->format('c');
292 382 }
293 383 } catch (\Exception $e) {
294 - // Malformed date string — return null rather than throwing.
384 + // Malformed date: fall through to null.
295 385 }
296 386
297 387 return null;
298 388 }
@@ -302,13 +392,63 @@
302 392 {
303 393 return $str === '' || strpos($str, '0000-00-00') === 0;
304 394 }
305 395
306 - public static function formatExtraWidgets($widgets)
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)
307 416 {
308 417 $formatted = [];
309 418
310 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 +
311 451 if (!empty($widget['orders'])) {
312 452 $orders = [];
313 453 foreach ($widget['orders'] as $order) {
314 454 $item = [
@@ -345,15 +485,13 @@
345 485 $skipKeys = ['title', 'header', 'render', 'content_type'];
346 486 $data = array_diff_key($widget, array_flip($skipKeys));
347 487
348 488 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.
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.
353 492 if (empty($data['summary'])) {
354 - $html = $data['body_html'];
355 - $data['summary'] = self::htmlToTextWithLinks($html);
493 + $data['summary'] = self::htmlToMarkdown($data['body_html']);
356 494 }
357 495 unset($data['body_html']);
358 496 }
359 497
@@ -367,8 +505,79 @@
367 505 return $formatted;
368 506 }
369 507
370 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 + /**
371 580 * Truncate plain-text content to PREVIEW_CHARS with an ellipsis.
372 581 *
373 582 * @param string $html Raw HTML or plain text.
374 583 * @return string
@@ -404,12 +613,26 @@
404 613 private static function markdownToHtml($content)
405 614 {
406 615 $parsedown = new Parsedown();
407 616 $parsedown->setSafeMode(false);
617 + // Agents write emails: a single newline ("Thanks,\nJewel") is a line break.
618 + $parsedown->setBreaksEnabled(true);
408 619
409 - return $parsedown->text($content);
620 + return self::escapeStrayAngleBrackets($parsedown->text($content));
410 621 }
411 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 +
412 635 public static function htmlToText($html)
413 636 {
414 637 if (!$html) {
415 638 return '';
@@ -414,8 +637,12 @@
414 637 if (!$html) {
415 638 return '';
416 639 }
417 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 +
418 645 $text = wp_strip_all_tags($html);
419 646 $text = html_entity_decode($text, ENT_QUOTES, 'UTF-8');
420 647 $text = preg_replace('/\s+/', ' ', $text);
421 648
@@ -421,106 +648,24 @@
421 648
422 649 return trim($text);
423 650 }
424 651
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)
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)
430 660 {
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);
661 + return HtmlToMarkdown::convert($html);
514 662 }
515 663
516 664 /**
517 - * Return a structured WP_Error whose message is a JSON-encoded error envelope.
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.
518 667 *
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 668 * Supported $details keys:
524 669 * fields => string[] Input parameter names that caused the error.
525 670 * next_step => string What the agent should do to recover.
526 671 * hint => string Extra context.
@@ -532,20 +677,14 @@
532 677 return new \WP_Error($code, wp_json_encode(['error' => $payload]));
533 678 }
534 679
535 680 /**
536 - * Resolve and authorize an explicit agent-assignment target for an MCP tool.
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.
537 684 *
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).
685 + * @param int $agentId
686 + * @param Ticket|null $ticket Null skips the mailbox check (bulk assign checks per ticket).
548 687 * @param string $fieldName Input field name to report in error details.
549 688 * @return Agent|\WP_Error The resolved agent, or a WP_Error to return as-is.
550 689 */
551 690 public static function resolveAssignmentTarget($agentId, $ticket = null, $fieldName = 'agent_id')
@@ -573,25 +712,16 @@
573 712 return $targetAgent;
574 713 }
575 714
576 715 /**
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.
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).
580 719 *
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.
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.
586 723 *
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 724 * @return bool True if the assignee changed, false if it was already $targetAgent.
595 725 */
596 726 public static function applyAgentAssignment(Ticket $ticket, Agent $targetAgent, Agent $actingAgent, $deferSideEffects = false)
597 727 {
@@ -612,14 +742,11 @@
612 742 return $changed;
613 743 }
614 744
615 745 /**
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).
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.
622 749 */
623 750 public static function fireAgentAssignmentSideEffects(Ticket $ticket, Agent $actingAgent, $previousAgentId)
624 751 {
625 752 (new TicketService())->onAgentChange($ticket, $actingAgent);