| @@ -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] : '<' . 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. / 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); |