status === 'closed') {
return null;
}
$lastReplyBy = $ticket->last_reply_by;
if ($lastReplyBy === 'customer') {
return 'agent';
}
return $lastReplyBy === 'agent' ? 'customer' : null;
}
public static function formatTicketForMCP($ticket)
{
$data = [
'id' => $ticket->id,
'title' => $ticket->title,
'status' => $ticket->status ?: 'new',
'priority' => self::normalizePriority($ticket->priority),
'client_priority' => $ticket->client_priority ?: 'normal',
'source' => $ticket->source,
'response_count' => (int) $ticket->response_count,
'waiting_on' => self::waitingOn($ticket),
'created_at' => self::toIso8601($ticket->created_at),
'updated_at' => self::toIso8601($ticket->updated_at),
'waiting_since' => self::toIso8601($ticket->waiting_since),
'resolved_at' => self::toIso8601($ticket->resolved_at),
];
if ($ticket->relationLoaded('customer') && $ticket->customer) {
$data['customer'] = self::formatPersonSummary($ticket->customer);
}
if ($ticket->relationLoaded('agent') && $ticket->agent) {
$data['agent'] = self::formatPersonSummary($ticket->agent);
}
if ($ticket->relationLoaded('product') && $ticket->product) {
$data['product'] = [
'id' => $ticket->product->id,
'title' => $ticket->product->title,
];
}
if ($ticket->relationLoaded('mailbox') && $ticket->mailbox) {
$data['mailbox'] = [
'id' => $ticket->mailbox->id,
'name' => $ticket->mailbox->name,
];
}
if ($ticket->relationLoaded('tags')) {
$data['tags'] = $ticket->tags->map(function ($tag) {
return ['id' => $tag->id, 'title' => $tag->title];
})->toArray();
}
$data['content'] = self::htmlToMarkdown($ticket->content);
return $data;
}
public static function formatTicketList($paginated, $customerMeta = [])
{
// A paginator in page mode, a plain collection/array in keyset cursor mode.
$items = is_object($paginated) && method_exists($paginated, 'items') ? $paginated->items() : $paginated;
if (!is_iterable($items)) {
$type = is_object($items) ? get_class($items) : gettype($items);
throw new \Exception('MCPHelper::formatTicketList() expects a paginator, Collection, or array of tickets, got ' . esc_html($type));
}
$tickets = [];
foreach ($items as $ticket) {
$item = [
'id' => $ticket->id,
'title' => $ticket->title,
'preview' => self::preview($ticket->content),
'status' => $ticket->status ?: 'new',
'priority' => self::normalizePriority($ticket->priority),
'client_priority' => $ticket->client_priority ?: 'normal',
'response_count' => (int) $ticket->response_count,
'waiting_on' => self::waitingOn($ticket),
'created_at' => self::toIso8601($ticket->created_at),
'waiting_since' => self::toIso8601($ticket->waiting_since),
];
if ($ticket->relationLoaded('customer') && $ticket->customer) {
$item['customer'] = self::formatPersonSummary($ticket->customer);
}
if ($ticket->relationLoaded('agent') && $ticket->agent) {
$item['agent'] = self::formatPersonSummary($ticket->agent);
}
if ($ticket->relationLoaded('product') && $ticket->product) {
$item['product'] = $ticket->product->title;
}
if ($ticket->relationLoaded('tags')) {
$item['tags'] = $ticket->tags->pluck('title')->toArray();
}
// Integration-provided one-liner, gated upstream by fst_sensitive_data.
if ($ticket->customer_id && isset($customerMeta[$ticket->customer_id])) {
$item['customer_summary'] = $customerMeta[$ticket->customer_id];
}
$tickets[] = $item;
}
return $tickets;
}
/**
* Standard tool response: a quotable one-line summary, the data, and meta
* (schema_version, generated_at, plus caller keys such as paging).
*/
public static function envelope($summary, $data, $meta = [])
{
return [
'summary' => $summary,
'data' => $data,
'meta' => array_merge(['schema_version' => self::SCHEMA_VERSION, 'generated_at' => wp_date('c')], $meta),
];
}
/**
* Clamped ['page' => int, 'per_page' => int] from $params.
*/
public static function pagination($params, $default = 15, $max = null)
{
$max = min($max ?? self::MAX_PER_PAGE, self::HARD_MAX_PER_PAGE);
return [
'page' => max((int) ($params['page'] ?? 1), 1),
'per_page' => min(max((int) ($params['per_page'] ?? $default), 1), $max),
];
}
/**
* Paging block for envelope()'s $meta, from a paginator.
*/
public static function pagingMeta($paginator)
{
return [
'paging' => [
'current' => $paginator->currentPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
'pages' => $paginator->lastPage(),
'has_more' => $paginator->hasMorePages(),
],
];
}
/**
* @param iterable $responses Conversations, oldest first.
* @param int $maxChars Truncate each body to this many characters (0 = no limit).
*/
public static function formatResponseThread($responses, $maxChars = 0)
{
$thread = [];
foreach ($responses as $response) {
$entry = [
'id' => $response->id,
'type' => $response->conversation_type,
'source' => $response->source,
'created_at' => self::toIso8601($response->created_at),
];
$entry = array_merge($entry, self::truncatedContent(self::htmlToMarkdown($response->content), $maxChars));
if ($response->relationLoaded('attachments') && !$response->attachments->isEmpty()) {
$entry['attachments'] = self::formatAttachments($response->attachments);
}
if ($response->relationLoaded('person') && $response->person) {
$entry['person'] = [
'name' => self::personName($response->person),
'type' => $response->person->person_type,
];
}
$thread[] = $entry;
}
return $thread;
}
/**
* ['content' => ...], plus truncated/full_length when it was cut.
*
* @param string $text
* @param int $maxChars 0 = no limit.
* @return array
*/
public static function truncatedContent($text, $maxChars = 0)
{
$maxChars = (int) $maxChars;
$length = mb_strlen($text);
if ($maxChars <= 0 || $length <= $maxChars) {
return ['content' => $text];
}
return [
'content' => rtrim(mb_substr($text, 0, $maxChars)) . "\u{2026}",
'truncated' => true,
'full_length' => $length,
];
}
/**
* Attachment metadata with a freshly signed download URL.
*
* @param iterable $attachments
* @return array
*/
public static function formatAttachments($attachments)
{
$items = [];
foreach ($attachments as $attachment) {
if ($attachment->status !== 'active') {
continue;
}
$expires = \FluentSupport\App\Models\Attachment::signedUrlExpiry();
$items[] = [
'id' => (int) $attachment->id,
'title' => $attachment->title,
'file_type' => $attachment->file_type,
'file_size' => (int) $attachment->file_size,
'url' => $attachment->signedUrl($expires),
'url_expires_at' => gmdate('c', $expires),
];
}
return $items;
}
public static function formatCustomerForMCP($customer)
{
return [
'id' => $customer->id,
'first_name' => $customer->first_name,
'last_name' => $customer->last_name,
'email' => $customer->email,
'status' => $customer->status,
'city' => $customer->city,
'state' => $customer->state,
'country' => $customer->country,
'created_at' => self::toIso8601($customer->created_at),
];
}
public static function personName($model)
{
if (!$model) {
return null;
}
$name = trim(($model->first_name ?? '') . ' ' . ($model->last_name ?? ''));
return $name !== '' ? $name : null;
}
public static function formatPersonSummary($person)
{
return [
'id' => $person->id,
'name' => self::personName($person),
'email' => $person->email,
];
}
public static function normalizePriority($priority)
{
if (!$priority || $priority === '') {
return 'normal';
}
$aliases = [
'low' => 'normal',
'high' => 'critical',
];
$normalized = strtolower(trim($priority));
$normalized = $aliases[$normalized] ?? $normalized;
$valid = ['normal', 'medium', 'critical'];
return in_array($normalized, $valid, true) ? $normalized : 'normal';
}
public static function toIso8601($value)
{
if (!$value) {
return null;
}
if ($value instanceof \DateTimeInterface) {
return $value->format('c');
}
try {
if (is_object($value) && isset($value->date)) {
// WPFluent sometimes returns a DateTime-like plain object.
$str = (string) $value->date;
if (self::isZeroDate($str)) {
return null;
}
$dt = new \DateTime($str, new \DateTimeZone($value->timezone ?? 'UTC'));
if ((int) $dt->format('Y') < 1) {
return null;
}
return $dt->format('c');
}
if (is_string($value)) {
if (self::isZeroDate($value)) {
return null;
}
// Stored dates are site time; PHP's default zone in WordPress is UTC.
$dt = new \DateTime($value, wp_timezone());
if ((int) $dt->format('Y') < 1) {
return null;
}
return $dt->format('c');
}
} catch (\Exception $e) {
// Malformed date: fall through to null.
}
return null;
}
/** True for MySQL zero-dates that PHP's DateTime constructor would misparse. */
private static function isZeroDate($str)
{
return $str === '' || strpos($str, '0000-00-00') === 0;
}
/**
* Customer profile widgets (fluent_support/customer_extra_widgets) for MCP.
*
* MCP requests widgets with context format 'mcp' (see ProfileInfoService::
* getProfileExtraWidgets()). A widget then passes an 'mcp' array instead of
* body_html, and MCP returns it as `data`, so agents get fields, not prose:
*
* if (ProfileInfoService::isMcpWidgetRequest($context)) {
* $widgets['my_plugin'] = ['title' => 'Account', 'mcp' => ['plan' => 'paid', 'teams' => [...]]];
* }
*
* It's untrusted integration output: only scalars are kept, tags are
* stripped, and depth, list sizes and string lengths are capped
* (sanitizeWidgetData()). Without 'mcp', body_html is sent as Markdown.
*
* @param array $widgets
* @param array|null $onlyKeys Widget keys to keep (null = all).
* @return array
*/
public static function formatExtraWidgets($widgets, $onlyKeys = null)
{
$formatted = [];
foreach ($widgets as $key => $widget) {
if (!is_array($widget) || ($onlyKeys !== null && !in_array((string) $key, $onlyKeys, true))) {
continue;
}
if (isset($widget['mcp']) && is_array($widget['mcp'])) {
$formatted[$key] = [
'title' => sanitize_text_field($widget['title'] ?? $widget['header'] ?? $key),
'data' => self::sanitizeWidgetData($widget['mcp']),
];
continue;
}
// Structured widgets are already plain data.
// Same test as isStructuredWidget() in _StructuredWidget.vue: a non-empty list.
$structuredKeys = ['badges', 'stats', 'facts', 'sections', 'links'];
$isStructured = false;
foreach ($structuredKeys as $structuredKey) {
if (!empty($widget[$structuredKey]) && is_array($widget[$structuredKey])) {
$isStructured = true;
break;
}
}
if ($isStructured) {
$formatted[$key] = [
'title' => sanitize_text_field($widget['title'] ?? $widget['header'] ?? $key),
'data' => self::sanitizeWidgetData(self::flattenItemBadges(array_intersect_key($widget, array_flip($structuredKeys)))),
];
continue;
}
if (!empty($widget['orders'])) {
$orders = [];
foreach ($widget['orders'] as $order) {
$item = [
'order_id' => $order['id'] ?? $order['order_id'] ?? null,
'status' => $order['status'] ?? '',
'total' => $order['total'] ?? $order['amount'] ?? '',
'date' => $order['date'] ?? $order['date_created'] ?? '',
];
$orders[] = array_filter($item);
}
$formatted[$key] = [
'title' => $widget['title'] ?? $key,
'orders' => $orders,
];
} elseif (!empty($widget['products'])) {
$products = [];
foreach ($widget['products'] as $product) {
$item = [
'name' => $product['title'] ?? $product['name'] ?? '',
'status' => $product['status'] ?? '',
'price' => $product['price'] ?? '',
];
$products[] = array_filter($item);
}
$entry = [
'title' => $widget['title'] ?? $key,
'products' => $products,
];
if (!empty($widget['summary'])) {
$entry['summary'] = $widget['summary'];
}
$formatted[$key] = $entry;
} else {
$skipKeys = ['title', 'header', 'render', 'content_type'];
$data = array_diff_key($widget, array_flip($skipKeys));
if (isset($data['body_html'])) {
// Raw widget HTML is token-heavy, so send Markdown: tables and
// lists keep their shape, links keep their targets.
// A summary the integration already provides wins.
if (empty($data['summary'])) {
$data['summary'] = self::htmlToMarkdown($data['body_html']);
}
unset($data['body_html']);
}
$formatted[$key] = [
'title' => $widget['title'] ?? $key,
'data' => $data,
];
}
}
return $formatted;
}
/**
* sections[].items[].badge sits one level past WIDGET_MAX_DEPTH; keep its text.
*
* @param array $data
* @return array
*/
private static function flattenItemBadges($data)
{
if (empty($data['sections']) || !is_array($data['sections'])) {
return $data;
}
foreach ($data['sections'] as $sectionKey => $section) {
if (empty($section['items']) || !is_array($section['items'])) {
continue;
}
foreach ($section['items'] as $itemKey => $item) {
if (is_array($item) && isset($item['badge']) && is_array($item['badge'])) {
$data['sections'][$sectionKey]['items'][$itemKey]['badge'] = $item['badge']['text'] ?? null;
}
}
}
return $data;
}
// Counts the mcp array itself, so orders[] => items[] => {name, qty} fits.
const WIDGET_MAX_DEPTH = 5;
const WIDGET_MAX_ITEMS = 50;
const WIDGET_MAX_STRING = 500;
/**
* Keep scalars only, strip tags, cap depth, list sizes and string lengths.
*
* @param array $data
* @param int $depth
* @return array
*/
public static function sanitizeWidgetData($data, $depth = 1)
{
$clean = [];
foreach (array_slice($data, 0, self::WIDGET_MAX_ITEMS, true) as $key => $value) {
$key = is_int($key) ? $key : sanitize_key($key);
if ($key === '') {
continue;
}
if (is_array($value)) {
if ($depth < self::WIDGET_MAX_DEPTH) {
$clean[$key] = self::sanitizeWidgetData($value, $depth + 1);
}
} elseif (is_bool($value) || is_int($value) || is_float($value) || $value === null) {
$clean[$key] = $value;
} elseif (is_string($value)) {
// Decode first, so encoded tags are stripped too.
$value = wp_strip_all_tags(html_entity_decode($value, ENT_QUOTES | ENT_HTML5, 'UTF-8'));
$value = trim(preg_replace('/[\x00-\x1F\x7F]+/u', ' ', $value));
// Links come from other sites: web addresses only.
if ($key === 'url' && !preg_match('#^https?://#i', $value)) {
continue;
}
$clean[$key] = mb_substr($value, 0, self::WIDGET_MAX_STRING);
}
}
return $clean;
}
/**
* Truncate plain-text content to PREVIEW_CHARS with an ellipsis.
*
* @param string $html Raw HTML or plain text.
* @return string
*/
public static function preview($html, $chars = self::PREVIEW_CHARS)
{
$text = self::htmlToText($html);
if (mb_strlen($text) <= $chars) {
return $text;
}
return mb_substr($text, 0, $chars) . '...';
}
// Returns unsanitized HTML — callers must run through wp_kses_post.
public static function processContent($content, $format = 'markdown')
{
if ($content === '' || $content === null) {
return '';
}
switch ($format) {
case 'html':
return $content;
case 'text':
return wpautop(esc_html($content));
default:
return self::markdownToHtml($content);
}
}
private static function markdownToHtml($content)
{
$parsedown = new Parsedown();
$parsedown->setSafeMode(false);
// Agents write emails: a single newline ("Thanks,\nJewel") is a line break.
$parsedown->setBreaksEnabled(true);
return self::escapeStrayAngleBrackets($parsedown->text($content));
}
// Escape a "<" that doesn't open a kses-allowed tag, so text like x<5 or List
Pedro" into "NamePedro".
$html = preg_replace('#
|?(p|div|li|ul|ol|tr|td|th|h[1-6]|blockquote|pre|table|section|article|header|footer)\b[^>]*>#i', ' ', $html);
$text = wp_strip_all_tags($html);
$text = html_entity_decode($text, ENT_QUOTES, 'UTF-8');
$text = preg_replace('/\s+/', ' ', $text);
return trim($text);
}
/**
* HTML to Markdown for agents: keeps line breaks, lists, tables, code
* blocks and link targets that htmlToText() flattens.
*
* @param string $html
* @return string
*/
public static function htmlToMarkdown($html)
{
return HtmlToMarkdown::convert($html);
}
/**
* WP_Error whose message is a JSON error envelope. The MCP adapter forwards
* only the message, not error_data, so the structure has to live there.
*
* Supported $details keys:
* fields => string[] Input parameter names that caused the error.
* next_step => string What the agent should do to recover.
* hint => string Extra context.
* retryable => bool Whether retrying the same call might succeed (default false).
*/
public static function error($code, $message, $details = [])
{
$payload = array_merge(['code' => $code, 'message' => $message, 'retryable' => false], $details);
return new \WP_Error($code, wp_json_encode(['error' => $payload]));
}
/**
* The gate every MCP path uses before assigning a ticket to a specific agent:
* checks fst_assign_agents, that the agent exists, and, when a ticket is given,
* that the agent isn't restricted from its mailbox.
*
* @param int $agentId
* @param Ticket|null $ticket Null skips the mailbox check (bulk assign checks per ticket).
* @param string $fieldName Input field name to report in error details.
* @return Agent|\WP_Error The resolved agent, or a WP_Error to return as-is.
*/
public static function resolveAssignmentTarget($agentId, $ticket = null, $fieldName = 'agent_id')
{
if (!PermissionManager::currentUserCan('fst_assign_agents')) {
return self::error('forbidden', __('You do not have permission to assign agents', 'fluent-support'));
}
$agentId = (int) $agentId;
if ($agentId <= 0) {
return self::error('invalid_param', sprintf(__('%s must be a positive integer', 'fluent-support'), $fieldName), ['fields' => [$fieldName]]);
}
$targetAgent = Agent::find($agentId);
if (!$targetAgent) {
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']);
}
if ($ticket instanceof Ticket) {
if ($err = TicketAccessGuard::assertAssignableAgent($ticket, $targetAgent)) {
return $err;
}
}
return $targetAgent;
}
/**
* Assign the ticket and fire the assignment side effects if the assignee changed.
* Always saves, so callers can stage other field changes on the model first.
* Authorize $targetAgent first with resolveAssignmentTarget() (self-assignment may skip it).
*
* Inside a transaction, pass $deferSideEffects and call fireAgentAssignmentSideEffects()
* after commit, so emails and webhooks don't fire for a rolled-back change. Capture the
* previous agent_id before calling, since this overwrites it.
*
* @return bool True if the assignee changed, false if it was already $targetAgent.
*/
public static function applyAgentAssignment(Ticket $ticket, Agent $targetAgent, Agent $actingAgent, $deferSideEffects = false)
{
$changed = (int) $ticket->agent_id !== (int) $targetAgent->id;
$previousAgentId = $ticket->agent_id;
if ($changed) {
$ticket->agent_id = $targetAgent->id;
}
$ticket->save();
if ($changed && !$deferSideEffects) {
$ticket->load('agent');
self::fireAgentAssignmentSideEffects($ticket, $actingAgent, $previousAgentId);
}
return $changed;
}
/**
* Add the assignment note and fire fluent_support/agent_assigned_to_ticket.
* Not transactional, so call only after the assignment is committed.
* $ticket must have the new 'agent' relation loaded.
*/
public static function fireAgentAssignmentSideEffects(Ticket $ticket, Agent $actingAgent, $previousAgentId)
{
(new TicketService())->onAgentChange($ticket, $actingAgent);
do_action('fluent_support/agent_assigned_to_ticket', $ticket->agent, $ticket, $actingAgent, $previousAgentId);
}
public static function formatDuration($seconds)
{
if (!$seconds) {
return '0m';
}
$seconds = (int) round($seconds);
if ($seconds < 60) {
return $seconds . 's';
}
if ($seconds < 3600) {
return round($seconds / 60) . 'm';
}
if ($seconds < 86400) {
$h = floor($seconds / 3600);
$m = round(($seconds % 3600) / 60);
return $h . 'h ' . $m . 'm';
}
$d = floor($seconds / 86400);
$h = round(($seconds % 86400) / 3600);
return $d . 'd ' . $h . 'h';
}
}