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/Tools/ManagementTools.php +334 -257 2.3.0 → 2.4.5 View file →
@@ -12,8 +12,9 @@
12 12 use FluentSupport\App\Modules\MCP\Support\TicketAccessGuard;
13 13 use FluentSupport\App\Modules\PermissionManager;
14 14 use FluentSupport\App\Services\Helper;
15 15 use FluentSupport\App\Services\TicketHelper;
16 +use FluentSupport\App\Services\Tickets\AgentTicketAccess;
16 17 use FluentSupport\App\Services\Tickets\TicketService;
17 18
18 19 class ManagementTools
19 20 {
@@ -18,8 +19,11 @@
18 19 class ManagementTools
19 20 {
20 21 const CACHE_PREFIX = 'fsmcp_ctx_';
21 22 const CACHE_TTL = 60;
23 +
24 + /** Bumped to invalidate every agent's cached context; it is part of the cache key. */
25 + const CACHE_VERSION_OPTION = '_fluent_support_mcp_ctx_version';
22 26 const MAX_AGENTS = 50;
23 27 const MAX_BULK = 50;
24 28 const MAX_TAG_NAMES = 20;
25 29
@@ -29,9 +33,9 @@
29 33 if (!$currentAgent) {
30 34 return MCPHelper::error('unauthorized', __('No agent found for current user', 'fluent-support'));
31 35 }
32 36
33 - $cacheKey = self::CACHE_PREFIX . $currentAgent->id;
37 + $cacheKey = self::CACHE_PREFIX . $currentAgent->id . '_' . (int) get_option(self::CACHE_VERSION_OPTION, 0);
34 38 $cached = get_transient($cacheKey);
35 39 if ($cached !== false) {
36 40 return $cached;
37 41 }
@@ -43,10 +47,10 @@
43 47 }
44 48
45 49 private static function buildContext($currentAgent)
46 50 {
47 - $canSeeSensitive = PermissionManager::userCan('fst_sensitive_data');
48 - $restrictedMailboxes = PermissionManager::getRestrictedMailboxIds();
51 + $canSeeSensitive = PermissionManager::userCan('fst_sensitive_data');
52 + $access = new AgentTicketAccess();
49 53
50 54 $agents = Agent::select(['id', 'first_name', 'last_name', 'email', 'status'])
51 55 ->where('person_type', 'agent')
52 56 ->where('first_name', '!=', '')
@@ -71,15 +75,22 @@
71 75 ->map(function ($p) {
72 76 return ['id' => $p->id, 'title' => $p->title, 'description' => $p->description ?: ''];
73 77 })->toArray();
74 78
75 - $mailboxes = MailBox::select(['id', 'name', 'email'])
76 - ->get()
77 - ->map(function ($mb) use ($canSeeSensitive) {
79 + $signature = \FluentSupport\App\Services\Tickets\ResponseService::getAppendedSignature($currentAgent);
80 +
81 + $mailboxes = MailBox::getAccessibleBoxes($canSeeSensitive)
82 + ->map(function ($mb) use ($canSeeSensitive, $currentAgent, $signature) {
78 83 $entry = ['id' => $mb->id, 'name' => $mb->name];
79 84 if ($canSeeSensitive) {
80 85 $entry['email'] = $mb->email;
81 86 }
87 + // What the customer receives around the agent's text, so replies
88 + // don't repeat the template's greeting, sign-off or signature.
89 + $entry['emails'] = [
90 + 'reply' => self::describeAgentEmail($mb, 'ticket_replied_by_agent_email_to_customer', $currentAgent, $signature, true),
91 + 'outreach' => self::describeAgentEmail($mb, 'ticket_created_by_agent_on_behalf_email_to_customer', $currentAgent, $signature, true),
92 + ];
82 93 return $entry;
83 94 })->toArray();
84 95
85 96 $tags = TicketTag::select(['id', 'title'])
@@ -86,10 +97,9 @@
86 97 ->orderBy('title', 'ASC')
87 98 ->get()
88 99 ->toArray();
89 100
90 - $statusCounts = Ticket::selectRaw('status, COUNT(*) as cnt')
91 - ->when($restrictedMailboxes, fn($q) => $q->whereNotIn('mailbox_id', $restrictedMailboxes))
101 + $statusCounts = $access->applyMailboxRestrictionScope(Ticket::selectRaw('status, COUNT(*) as cnt'))
92 102 ->groupBy('status')
93 103 ->get()
94 104 ->pluck('cnt', 'status');
95 105
@@ -97,11 +107,10 @@
97 107 'total' => (int) $statusCounts->sum(),
98 108 'new' => (int) ($statusCounts['new'] ?? 0),
99 109 'active' => (int) ($statusCounts['active'] ?? 0),
100 110 'closed' => (int) ($statusCounts['closed'] ?? 0),
101 - 'unassigned' => Ticket::whereNull('agent_id')
111 + 'unassigned' => $access->applyMailboxRestrictionScope(Ticket::whereNull('agent_id'))
102 112 ->whereNotIn('status', ['closed'])
103 - ->when($restrictedMailboxes, fn($q) => $q->whereNotIn('mailbox_id', $restrictedMailboxes))
104 113 ->count(),
105 114 ];
106 115
107 116 $ticketFields = ['id', 'title', 'status', 'priority', 'customer_id', 'agent_id', 'waiting_since', 'created_at', 'response_count', 'last_agent_response', 'last_customer_response'];
@@ -116,13 +125,13 @@
116 125 $now = strtotime(current_time('mysql'));
117 126 $firstResponseCutoff = date('Y-m-d H:i:s', $now - ($firstResponseHours * 3600));
118 127 $resolutionCutoff = date('Y-m-d H:i:s', $now - ($resolutionHours * 3600));
119 128
120 - $myQueueBase = static::scopedTicketQuery($ticketFields, $eagerLoad, $restrictedMailboxes)
129 + $myQueueBase = static::scopedTicketQuery($ticketFields, $eagerLoad)
121 130 ->where('agent_id', $currentAgent->id)
122 131 ->whereNotIn('status', ['closed']);
123 132
124 - // Split by who's turn it is instead of ranking both populations by raw waiting_since:
133 + // Split by whose turn it is instead of ranking both populations by raw waiting_since:
125 134 // an agent-last ticket idle for 12 days and a customer-last ticket waiting 12 days both
126 135 // have an old waiting_since, but only the latter needs a reply. Mixing them let stale
127 136 // agent-last tickets crowd the actionable ones out of the capped list.
128 137 $awaitingYourReplyQuery = (clone $myQueueBase)->waitingOnly();
@@ -153,9 +162,9 @@
153 162 'awaiting_customer_total' => $awaitingCustomerTotal,
154 163 'awaiting_customer_truncated' => $awaitingCustomerTotal > count($awaitingCustomer),
155 164 ];
156 165
157 - $unassigned = static::scopedTicketQuery($ticketFields, $eagerLoad, $restrictedMailboxes)
166 + $unassigned = static::scopedTicketQuery($ticketFields, $eagerLoad)
158 167 ->whereNull('agent_id')
159 168 ->whereNotIn('status', ['closed'])
160 169 ->orderBy('created_at', 'ASC')
161 170 ->limit(5)
@@ -162,11 +171,14 @@
162 171 ->get()
163 172 ->map(function ($t) use ($now) { return self::formatContextTicket($t, $now); })
164 173 ->toArray();
165 174
166 - $longestWaiting = static::scopedTicketQuery($ticketFields, $eagerLoad, $restrictedMailboxes)
175 + // Only tickets waiting on an agent: a ticket we answered months ago has the
176 + // oldest waiting_since but needs nothing from us.
177 + $longestWaiting = static::scopedTicketQuery($ticketFields, $eagerLoad)
167 178 ->whereNotIn('status', ['closed'])
168 179 ->whereNotNull('waiting_since')
180 + ->waitingOnly()
169 181 ->orderBy('waiting_since', 'ASC')
170 182 ->limit(5)
171 183 ->get()
172 184 ->map(function ($t) use ($now) { return self::formatContextTicket($t, $now); })
@@ -171,9 +183,9 @@
171 183 ->get()
172 184 ->map(function ($t) use ($now) { return self::formatContextTicket($t, $now); })
173 185 ->toArray();
174 186
175 - $critical = static::scopedTicketQuery($ticketFields, $eagerLoad, $restrictedMailboxes)
187 + $critical = static::scopedTicketQuery($ticketFields, $eagerLoad)
176 188 ->where('priority', 'critical')
177 189 ->whereNotIn('status', ['closed'])
178 190 ->orderBy('created_at', 'ASC')
179 191 ->limit(5)
@@ -180,9 +192,9 @@
180 192 ->get()
181 193 ->map(function ($t) use ($now) { return self::formatContextTicket($t, $now); })
182 194 ->toArray();
183 195
184 - $slaFirstResponse = static::scopedTicketQuery($ticketFields, $eagerLoad, $restrictedMailboxes)
196 + $slaFirstResponse = static::scopedTicketQuery($ticketFields, $eagerLoad)
185 197 ->whereNotIn('status', ['closed'])
186 198 ->where('response_count', 0)
187 199 ->where('created_at', '<=', $firstResponseCutoff)
188 200 ->orderBy('created_at', 'ASC')
@@ -190,11 +202,14 @@
190 202 ->get()
191 203 ->map(function ($t) use ($now) { return self::formatContextTicket($t, $now); })
192 204 ->toArray();
193 205
194 - $slaResolution = static::scopedTicketQuery($ticketFields, $eagerLoad, $restrictedMailboxes)
206 + // Past the resolution target and still waiting on an agent. Tickets waiting on the
207 + // customer are excluded: nothing we can do moves them.
208 + $slaResolution = static::scopedTicketQuery($ticketFields, $eagerLoad)
195 209 ->whereNotIn('status', ['closed'])
196 210 ->where('created_at', '<=', $resolutionCutoff)
211 + ->waitingOnly()
197 212 ->orderBy('created_at', 'ASC')
198 213 ->limit(5)
199 214 ->get()
200 215 ->map(function ($t) use ($now) { return self::formatContextTicket($t, $now); })
@@ -201,12 +216,12 @@
201 216 ->toArray();
202 217
203 218 $guidelines = apply_filters('fluent_support/mcp_ai_guidelines', PermissionGate::getAiGuidelines());
204 219 if (!$guidelines) {
220 + // Tone and conduct only: the triage order is in the server instructions.
205 221 $guidelines = 'Be professional and empathetic. Address customers by name. '
206 - . 'For triage, prioritize: critical priority first, then longest waiting, then unassigned. '
207 - . 'Always check the conversation history before replying. '
208 - . 'Use internal notes to document decisions or escalation reasons.';
222 + . 'Read the whole conversation before replying. '
223 + . 'Use internal notes to record decisions and escalations.';
209 224 }
210 225
211 226 $openCount = $stats['new'] + $stats['active'];
212 227
@@ -212,13 +227,23 @@
212 227
213 228 return MCPHelper::envelope(
214 229 "Support context: {$openCount} open ticket(s), {$stats['unassigned']} unassigned",
215 230 [
216 - 'you' => [
217 - 'agent_id' => $currentAgent->id,
218 - 'name' => MCPHelper::personName($currentAgent),
219 - 'email' => $currentAgent->email,
220 - ],
231 + 'you' => array_merge([
232 + 'agent_id' => $currentAgent->id,
233 + 'name' => MCPHelper::personName($currentAgent),
234 + 'email' => $currentAgent->email,
235 + ], self::replyMode(), [
236 + // Added only where an inbox email template uses {{agent.signature}};
237 + // each mailbox's emails say whether theirs does.
238 + 'signature' => $signature !== '' ? [
239 + 'enabled' => true,
240 + 'text' => mb_substr(MCPHelper::htmlToMarkdown($signature), 0, self::SIGNATURE_MAX_CHARS),
241 + ] : [
242 + 'enabled' => false,
243 + 'note' => 'You have no signature set.',
244 + ],
245 + ]),
221 246 'my_queue' => $myQueue,
222 247 'needs_attention' => [
223 248 'unassigned' => $unassigned,
224 249 'longest_waiting' => $longestWaiting,
@@ -238,22 +263,157 @@
238 263 'tags' => $tags,
239 264 'stats' => $stats,
240 265 'priorities' => ['normal', 'medium', 'critical'],
241 266 'statuses' => ['new', 'active', 'closed'],
267 + 'uploads' => self::uploadLimits(),
268 + // Every date the MCP returns is in this zone, shown by its offset.
269 + 'site_timezone' => wp_timezone_string(),
242 270 'guidelines' => $guidelines,
243 271 ]
244 272 );
245 273 }
246 274
275 + /**
276 + * How this agent's replies go out, as reply-to-ticket applies it. Only an agent
277 + * who cannot send directly gets a sentence explaining why.
278 + *
279 + * @return array
280 + */
281 + private static function replyMode()
282 + {
283 + if (PermissionManager::userCan(\FluentSupport\App\Modules\MCP\Support\AbilityGuard::MANAGE_TICKET_CAPS)) {
284 + return ['replies' => 'send'];
285 + }
286 +
287 + if (PermissionManager::currentUserCan('fst_draft_reply')) {
288 + return [
289 + 'replies' => 'draft_only',
290 + 'replies_note' => 'You only have the Draft Reply permission: reply-to-ticket saves your replies as drafts for another agent to approve.',
291 + ];
292 + }
293 +
294 + return [
295 + 'replies' => 'none',
296 + 'replies_note' => 'You can read tickets but not reply to them.',
297 + ];
298 + }
299 +
300 + /** How much of an inbox email template the MCP shows, in characters of Markdown. */
301 + const TEMPLATE_MAX_CHARS = 2000;
302 +
303 + /** How much of the agent's signature the MCP shows, in characters of Markdown. */
304 + const SIGNATURE_MAX_CHARS = 1000;
305 +
306 + /**
307 + * The email the customer gets when the agent writes from this mailbox: the
308 + * template itself, with [YOUR MESSAGE] where the agent's text goes and
309 + * [YOUR SIGNATURE] where their signature is added. The agent reads the template
310 + * and decides what to leave out; nothing here guesses at greetings or sign-offs.
311 + *
312 + * adds_signature is true only when the template has {{agent.signature}} and the
313 + * agent has a signature; then [YOUR SIGNATURE] marks it.
314 + *
315 + * @param MailBox $mailbox
316 + * @param string $emailKey
317 + * @param Agent $agent
318 + * @param string|null $signature The agent's signature HTML. Pass it when describing several mailboxes for one agent.
319 + * @param bool $compact For get-support-context: leave out the signature text (it is under
320 + * you.signature) and the shared note (it is in the server instructions).
321 + * @return array
322 + */
323 + public static function describeAgentEmail($mailbox, $emailKey, $agent, $signature = null, $compact = false)
324 + {
325 + if ($signature === null) {
326 + $signature = \FluentSupport\App\Services\Tickets\ResponseService::getAppendedSignature($agent);
327 + }
328 + $hasSignature = (string) $signature !== '';
329 +
330 + $settings = (new \FluentSupport\App\Services\EmailNotification\Settings())->getBoxEmailSettings($mailbox, $emailKey);
331 + if (!$settings) {
332 + return ['sent' => false, 'note' => 'This inbox does not send this email.'];
333 + }
334 +
335 + $body = (string) ($settings['email_body'] ?? '');
336 +
337 + // The outreach email carries the agent's text as {{ticket.content}}: for an
338 + // agent-initiated ticket that is the agent's first message.
339 + $contentPattern = $emailKey === 'ticket_created_by_agent_on_behalf_email_to_customer'
340 + ? '/{{\s*(response\.(full_)?content|ticket\.content)\s*}}/'
341 + : '/{{\s*response\.(full_)?content\s*}}/';
342 + $signaturePattern = '/{{\s*agent\.signature\s*}}/';
343 +
344 + $includesMessage = (bool) preg_match($contentPattern, $body);
345 + $hasSignatureSlot = (bool) preg_match($signaturePattern, $body);
346 + $addsSignature = $hasSignature && $hasSignatureSlot;
347 +
348 + // The signature placeholder renders empty for an agent without one.
349 + $shown = preg_replace($contentPattern, '[YOUR MESSAGE]', $body);
350 + $shown = preg_replace($signaturePattern, $hasSignature ? '[YOUR SIGNATURE]' : '', $shown);
351 + $template = MCPHelper::htmlToMarkdown($shown);
352 + $truncated = mb_strlen($template) > self::TEMPLATE_MAX_CHARS;
353 + if ($truncated) {
354 + $template = mb_substr($template, 0, self::TEMPLATE_MAX_CHARS) . '...';
355 + }
356 +
357 + // From the agent's side a signature is either added automatically or not;
358 + // why not (no slot in the template, or no signature set) doesn't change what to write.
359 + if (!$includesMessage) {
360 + $note = 'This email does not include your message: it tells the customer to read it in the portal.';
361 + } elseif ($compact) {
362 + // The shared rule is in the server instructions; adds_signature carries the rest.
363 + $note = '';
364 + } else {
365 + $note = 'The customer gets this template with [YOUR MESSAGE] replaced by your text, so leave out any greeting or sign-off it already has. '
366 + . ($addsSignature
367 + ? 'Your signature, shown in signature, is added automatically at [YOUR SIGNATURE], so do not sign your message. '
368 + : 'No signature is added automatically. ')
369 + . 'The [...] markers are for reading the template only; never type them in a reply.';
370 + }
371 +
372 + $description = [
373 + 'sent' => ($settings['status'] ?? 'no') === 'yes',
374 + 'includes_reply' => $includesMessage,
375 + 'adds_signature' => $addsSignature,
376 + 'subject' => (string) ($settings['email_subject'] ?? ''),
377 + 'template' => $template,
378 + 'from_name' => $mailbox->getFromName($agent),
379 + ];
380 +
381 + if ($truncated) {
382 + $description['template_truncated'] = true;
383 + }
384 +
385 + if ($addsSignature && !$compact) {
386 + $description['signature'] = mb_substr(MCPHelper::htmlToMarkdown($signature), 0, self::SIGNATURE_MAX_CHARS);
387 + }
388 +
389 + if ($note !== '') {
390 + $description['note'] = $note;
391 + }
392 +
393 + return $description;
394 + }
395 +
396 + /**
397 + * The limits AttachmentStager applies to files sent with a reply or note.
398 + *
399 + * @return array
400 + */
401 + private static function uploadLimits()
402 + {
403 + $settings = (new \FluentSupport\App\Services\EmailNotification\Settings())->globalBusinessSettings();
404 +
405 + return [
406 + 'max_files' => \FluentSupport\App\Modules\MCP\Support\AttachmentStager::maxFiles(),
407 + 'max_file_size_mb' => floatval($settings['max_file_size'] ?? 0) ?: null,
408 + 'allowed_extensions' => \FluentSupport\App\Modules\MCP\Support\AttachmentStager::allowedExtensions(),
409 + 'note' => 'Per reply or note: up to max_files files, each up to max_file_size_mb as a file (base64 text is about a third larger).',
410 + ];
411 + }
412 +
247 413 public static function invalidateSupportContextCache()
248 414 {
249 - global $wpdb;
250 - $like = $wpdb->esc_like('_transient_' . self::CACHE_PREFIX) . '%';
251 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
252 - $wpdb->query($wpdb->prepare("DELETE FROM {$wpdb->options} WHERE option_name LIKE %s", $like));
253 - $like = $wpdb->esc_like('_transient_timeout_' . self::CACHE_PREFIX) . '%';
254 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
255 - $wpdb->query($wpdb->prepare("DELETE FROM {$wpdb->options} WHERE option_name LIKE %s", $like));
415 + update_option(self::CACHE_VERSION_OPTION, (int) get_option(self::CACHE_VERSION_OPTION, 0) + 1, false);
256 416 }
257 417
258 418 /**
259 419 * Build a ticket query scoped to the current agent's visibility, mirroring listTickets().
@@ -263,19 +423,15 @@
263 423 * customer names/emails they serialize — outside the agent's visibility scope. The
264 424 * restricted-mailbox filter alone is not enough: an agent scoped to only their own tickets
265 425 * must not enumerate other customers' tickets across the rest of the instance.
266 426 */
267 - private static function scopedTicketQuery($ticketFields, $eagerLoad, $restrictedMailboxes)
427 + private static function scopedTicketQuery($ticketFields, $eagerLoad)
268 428 {
269 429 $query = Ticket::select($ticketFields)->with($eagerLoad);
270 430
271 431 do_action_ref_array('fluent_support/tickets_query_by_permission_ref', [&$query]);
272 432
273 - if ($restrictedMailboxes) {
274 - $query->whereNotIn('mailbox_id', $restrictedMailboxes);
275 - }
276 -
277 - return $query;
433 + return (new AgentTicketAccess())->applyMailboxRestrictionScope($query);
278 434 }
279 435
280 436 private static function formatContextTicket($ticket, int $now)
281 437 {
@@ -284,9 +440,9 @@
284 440 'title' => $ticket->title,
285 441 'status' => $ticket->status ?: 'new',
286 442 'priority' => MCPHelper::normalizePriority($ticket->priority),
287 443 'response_count' => (int) $ticket->response_count,
288 - 'last_reply_by' => $ticket->last_reply_by,
444 + 'waiting_on' => MCPHelper::waitingOn($ticket),
289 445 ];
290 446
291 447 if ($ticket->relationLoaded('customer') && $ticket->customer) {
292 448 $item['customer'] = MCPHelper::formatPersonSummary($ticket->customer);
@@ -299,46 +455,8 @@
299 455
300 456 return $item;
301 457 }
302 458
303 - public static function assignTicket($params)
304 - {
305 - $agent = MCPHelper::resolveAgent();
306 - if (!$agent) {
307 - return MCPHelper::error('unauthorized', __('No agent found for current user', 'fluent-support'));
308 - }
309 -
310 - $ticketId = (int) ($params['ticket_id'] ?? 0);
311 - $newAgentId = (int) ($params['agent_id'] ?? 0);
312 -
313 - if (!$ticketId || !$newAgentId) {
314 - return MCPHelper::error('invalid_param', __('ticket_id and agent_id are required', 'fluent-support'), ['fields' => ['ticket_id', 'agent_id']]);
315 - }
316 -
317 - $ticket = Ticket::find($ticketId);
318 - if (!$ticket) {
319 - return MCPHelper::error('not_found', __('Ticket not found', 'fluent-support'), ['next_step' => 'Use list-tickets to find valid ticket IDs']);
320 - }
321 -
322 - if ($err = TicketAccessGuard::assert($ticket)) {
323 - return $err;
324 - }
325 -
326 - $newAgent = MCPHelper::resolveAssignmentTarget($newAgentId, $ticket, 'agent_id');
327 - if (is_wp_error($newAgent)) {
328 - return $newAgent;
329 - }
330 -
331 - MCPHelper::applyAgentAssignment($ticket, $newAgent, $agent);
332 -
333 - $agentName = MCPHelper::personName($newAgent);
334 -
335 - return MCPHelper::envelope(
336 - "Ticket #{$ticketId} assigned to {$agentName}",
337 - ['agent' => MCPHelper::formatPersonSummary($newAgent)]
338 - );
339 - }
340 -
341 459 public static function tagTicket($params)
342 460 {
343 461 $agent = MCPHelper::resolveAgent();
344 462 if (!$agent) {
@@ -369,114 +487,93 @@
369 487 if (count($addTagNames) > self::MAX_TAG_NAMES) {
370 488 return MCPHelper::error('invalid_param', sprintf(__('add_tag_names may not exceed %d items per request. Split into multiple calls.', 'fluent-support'), self::MAX_TAG_NAMES), ['fields' => ['add_tag_names'], 'limit' => self::MAX_TAG_NAMES]);
371 489 }
372 490
373 - $namesResult = self::findOrCreateTagsByTitles($addTagNames);
374 - $failedTagNames = $namesResult['failed'];
375 - $createdAnyTag = $namesResult['created_any'];
376 - foreach ($namesResult['tags'] as $tag) {
377 - $addTagIds[] = (int) $tag->id;
491 + // Names only pick existing tags: the tag list is configuration that people
492 + // manage in the admin. Any unknown name stops the call before anything changes.
493 + $resolved = self::resolveTagNames($addTagNames);
494 + if ($resolved['unknown']) {
495 + return MCPHelper::error(
496 + 'unknown_tag',
497 + /* translators: %s is a comma-separated list of tag names */
498 + sprintf(__('No tag is called: %s', 'fluent-support'), implode(', ', $resolved['unknown'])),
499 + [
500 + 'fields' => ['add_tag_names'],
501 + 'available_tags' => $resolved['available'],
502 + 'next_step' => 'Use one of these tags, or ask a person to create a new tag in the admin.',
503 + ]
504 + );
378 505 }
506 + foreach ($resolved['ids'] as $tagId) {
507 + $addTagIds[] = $tagId;
508 + }
379 509
510 + // Only real ticket tags: fs_taggables also holds agent groups and other types.
380 511 if ($addTagIds) {
381 - $ticket->applyTags(array_unique($addTagIds));
512 + $addTagIds = TicketTag::whereIn('id', array_unique($addTagIds))->pluck('id')->toArray();
382 513 }
383 514
515 + if ($addTagIds) {
516 + $ticket->applyTags($addTagIds);
517 + }
518 +
384 519 if ($removeTagIds) {
385 520 $ticket->detachTags($removeTagIds);
386 521 }
387 522
388 - if ($createdAnyTag) {
389 - self::invalidateSupportContextCache();
390 - }
391 -
392 523 $ticket->load('tags');
393 524
394 - $summary = "Tags updated on ticket #{$ticketId}";
395 - if ($failedTagNames) {
396 - $summary .= sprintf(__(' (failed to create: %s)', 'fluent-support'), implode(', ', $failedTagNames));
397 - }
525 + /* translators: %d is the ticket ID */
526 + $summary = sprintf(__('Tags updated on ticket #%d', 'fluent-support'), $ticketId);
398 527
399 528 $data = ['tags' => $ticket->tags->map(function ($tag) {
400 529 return ['id' => $tag->id, 'title' => $tag->title];
401 530 })->toArray()];
402 531
403 - if ($failedTagNames) {
404 - $data['failed_tag_names'] = array_values($failedTagNames);
405 - }
406 -
407 532 return MCPHelper::envelope($summary, $data);
408 533 }
409 534
410 535 /**
411 - * Find an existing ticket tag by exact title match, or create one.
412 - * created_by is left unset so TicketTag::boot() applies its own
413 - * get_current_user_id() default — the same convention every other
414 - * tag-creation path (REST, UI) relies on; setting it here to an
415 - * agent/person id would mix two incompatible id spaces in the column.
536 + * Existing tags for tag-ticket's add_tag_names, matched by title ignoring case
537 + * and surrounding spaces. Nothing is created.
416 538 *
417 - * @return array{tag: ?TicketTag, created: bool}|null
539 + * @param string[] $names
540 + * @return array{ids: int[], unknown: string[], available: string[]}
418 541 */
419 - private static function findOrCreateTag($title, $description = null)
542 + private static function resolveTagNames(array $names)
420 543 {
421 - $title = trim($title);
422 - if ($title === '') {
423 - return null;
544 + if (!$names) {
545 + return ['ids' => [], 'unknown' => [], 'available' => []];
424 546 }
425 547
426 - $existing = TicketTag::where('title', $title)->first();
427 - if ($existing) {
428 - return ['tag' => $existing, 'created' => false];
548 + $tags = TicketTag::select(['id', 'title'])->orderBy('title', 'ASC')->get();
549 + $byKey = [];
550 + foreach ($tags as $tag) {
551 + $key = mb_strtolower(trim((string) $tag->title));
552 + if (!isset($byKey[$key])) {
553 + $byKey[$key] = (int) $tag->id;
554 + }
429 555 }
430 556
431 - $data = ['title' => $title];
432 - if ($description !== null) {
433 - $data['description'] = $description;
434 - }
435 -
436 - return ['tag' => TicketTag::create($data), 'created' => true];
437 - }
438 -
439 - /**
440 - * Batched sibling of findOrCreateTag() for tag-ticket's add_tag_names —
441 - * resolves all titles with a single whereIn() lookup instead of one
442 - * query per name, then creates only the titles that didn't already
443 - * exist. Bounded by MAX_TAG_NAMES at the call site.
444 - *
445 - * @return array{tags: TicketTag[], created_any: bool, failed: string[]}
446 - */
447 - private static function findOrCreateTagsByTitles(array $titles)
448 - {
449 - if (!$titles) {
450 - return ['tags' => [], 'created_any' => false, 'failed' => []];
451 - }
452 -
453 - $existingByTitle = TicketTag::whereIn('title', $titles)->get()->keyBy('title');
454 -
455 - $tags = [];
456 - $failed = [];
457 - $createdAny = false;
458 -
459 - foreach ($titles as $title) {
460 - $existing = $existingByTitle->get($title);
461 - if ($existing) {
462 - $tags[] = $existing;
463 - continue;
464 - }
465 -
466 - $tag = TicketTag::create(['title' => $title]);
467 - if ($tag && $tag->id) {
468 - $tags[] = $tag;
469 - $createdAny = true;
557 + $ids = [];
558 + $unknown = [];
559 + foreach ($names as $name) {
560 + $key = mb_strtolower(trim($name));
561 + if (isset($byKey[$key])) {
562 + $ids[] = $byKey[$key];
470 563 } else {
471 - $failed[] = $title;
564 + $unknown[] = $name;
472 565 }
473 566 }
474 567
475 - return ['tags' => $tags, 'created_any' => $createdAny, 'failed' => $failed];
568 + return [
569 + 'ids' => array_values(array_unique($ids)),
570 + 'unknown' => $unknown,
571 + 'available' => $unknown ? array_slice($tags->pluck('title')->all(), 0, 200) : [],
572 + ];
476 573 }
477 574
478 - public static function createTag($params)
575 + public static function getSupportInsights($params)
479 576 {
480 577 $agent = MCPHelper::resolveAgent();
481 578 if (!$agent) {
482 579 return MCPHelper::error('unauthorized', __('No agent found for current user', 'fluent-support'));
@@ -481,56 +578,24 @@
481 578 if (!$agent) {
482 579 return MCPHelper::error('unauthorized', __('No agent found for current user', 'fluent-support'));
483 580 }
484 581
485 - $title = sanitize_text_field($params['title'] ?? '');
486 - if ($title === '') {
487 - return MCPHelper::error('invalid_param', __('title is required', 'fluent-support'), ['fields' => ['title']]);
582 + $periodMap = ['24h' => 1, '7d' => 7, '30d' => 30, '90d' => 90];
583 + $period = sanitize_text_field($params['period'] ?? '7d');
584 + if (!isset($periodMap[$period])) {
585 + $period = '7d';
488 586 }
489 587
490 - $description = isset($params['description']) ? sanitize_textarea_field($params['description']) : null;
588 + // Ticket dates are stored in site time (current_time('mysql')), so the
589 + // cut-off must be site time too; a UTC cut-off shifts the whole window.
590 + $nowTs = time();
591 + $sinceTs = $nowTs - $periodMap[$period] * DAY_IN_SECONDS;
592 + $since = wp_date('Y-m-d H:i:s', $sinceTs);
491 593
492 - $resolved = self::findOrCreateTag($title, $description);
493 - $tag = $resolved['tag'] ?? null;
494 -
495 - if (!$tag || !$tag->id) {
496 - return MCPHelper::error('failed', __('Failed to create tag', 'fluent-support'), ['retryable' => true]);
497 - }
498 -
499 - if ($resolved['created']) {
500 - self::invalidateSupportContextCache();
501 - }
502 -
503 - $summary = $resolved['created']
504 - ? sprintf(__('Tag "%s" created', 'fluent-support'), $tag->title)
505 - : sprintf(__('Tag "%s" already exists', 'fluent-support'), $tag->title);
506 -
507 - return MCPHelper::envelope(
508 - $summary,
509 - ['tag' => ['id' => $tag->id, 'title' => $tag->title], 'created' => $resolved['created']]
510 - );
511 - }
512 -
513 - public static function getSupportInsights($params)
514 - {
515 - $agent = MCPHelper::resolveAgent();
516 - if (!$agent) {
517 - return MCPHelper::error('unauthorized', __('No agent found for current user', 'fluent-support'));
518 - }
519 -
520 - $period = sanitize_text_field($params['period'] ?? '7d');
521 - $periodMap = ['24h' => 1, '7d' => 7, '30d' => 30, '90d' => 90];
522 - $days = $periodMap[$period] ?? 7;
523 - $since = gmdate('Y-m-d H:i:s', strtotime("-{$days} days"));
524 -
525 - $restrictedMailboxes = PermissionManager::getRestrictedMailboxIds();
526 - $scoped = function () use ($restrictedMailboxes) {
594 + $scoped = function () {
527 595 $query = Ticket::query();
528 596 do_action_ref_array('fluent_support/tickets_query_by_permission_ref', [&$query]);
529 - if ($restrictedMailboxes) {
530 - $query->whereNotIn('mailbox_id', $restrictedMailboxes);
531 - }
532 - return $query;
597 + return (new AgentTicketAccess())->applyMailboxRestrictionScope($query);
533 598 };
534 599
535 600 $periodTicketsQuery = $scoped()->where('created_at', '>=', $since);
536 601 $closedInPeriodQuery = $scoped()->where('resolved_at', '>=', $since);
@@ -548,10 +613,10 @@
548 613 $ctSample = ($ctAgg && (int) $ctAgg->cnt > 0)
549 614 ? (clone $ctBase)->orderByDesc('id')->limit(500)->pluck('total_close_time')->toArray()
550 615 : [];
551 616
552 - // Waiting — split by who's turn it is. last_reply_by=customer tickets are genuinely
553 - // waiting on an agent; last_reply_by=agent tickets only reflect how long the customer
617 + // Waiting, split by whose turn it is. waiting_on=agent tickets genuinely need
618 + // a reply; waiting_on=customer tickets only reflect how long the customer
554 619 // has been idle since our last reply. A single blended average over both populations
555 620 // measures mostly customer silence and hides how many tickets actually need a reply.
556 621 $waitOnAgentBase = $scoped()->whereNotIn('status', ['closed'])->whereNotNull('waiting_since')->waitingOnly();
557 622 $waitOnCustomerBase = $scoped()->whereNotIn('status', ['closed'])->whereNotNull('waiting_since')->where(function ($q) {
@@ -630,9 +695,15 @@
630 695 }
631 696
632 697 usort($performance, fn($a, $b) => $b['open_tickets'] - $a['open_tickets']);
633 698
634 - $truncatedAgents = count($performance) > self::MAX_AGENTS;
699 + if (!empty($params['agent_id'])) {
700 + $onlyAgent = (int) $params['agent_id'];
701 + $performance = array_values(array_filter($performance, fn($row) => (int) $row['agent_id'] === $onlyAgent));
702 + }
703 +
704 + $agentTotal = count($performance);
705 + $truncatedAgents = $agentTotal > self::MAX_AGENTS;
635 706 if ($truncatedAgents) {
636 707 $performance = array_slice($performance, 0, self::MAX_AGENTS);
637 708 }
638 709
@@ -642,8 +713,10 @@
642 713 return MCPHelper::envelope(
643 714 "Support insights for {$period}: {$created} created, {$closed} closed",
644 715 [
645 716 'period' => $period,
717 + 'from' => wp_date('c', $sinceTs),
718 + 'to' => wp_date('c', $nowTs),
646 719 'volume' => ['created' => $created, 'closed' => $closed],
647 720 'first_response_time' => self::computeTimeStatsFromAgg($rtAgg, $rtSample),
648 721 'resolution_time' => self::computeTimeStatsFromAgg($ctAgg, $ctSample),
649 722 'waiting' => [
@@ -650,8 +723,9 @@
650 723 'waiting_on_agent' => $waitingOnAgent,
651 724 'waiting_on_customer' => $waitingOnCustomer,
652 725 ],
653 726 'agent_performance' => $performance,
727 + 'agent_performance_total' => $agentTotal,
654 728 'agent_performance_truncated' => $truncatedAgents,
655 729 ]
656 730 );
657 731 }
@@ -678,13 +752,15 @@
678 752 * stored duration column.
679 753 */
680 754 private static function computeWaitStats($baseQuery)
681 755 {
756 + // waiting_since is stored in site time, so measure against site time, not UTC.
757 + $now = current_time('mysql');
682 758 $agg = (clone $baseQuery)
683 - ->selectRaw('COUNT(*) as cnt, AVG(TIMESTAMPDIFF(SECOND, waiting_since, UTC_TIMESTAMP())) as avg_val, MAX(TIMESTAMPDIFF(SECOND, waiting_since, UTC_TIMESTAMP())) as max_val')
759 + ->selectRaw('COUNT(*) as cnt, AVG(TIMESTAMPDIFF(SECOND, waiting_since, ?)) as avg_val, MAX(TIMESTAMPDIFF(SECOND, waiting_since, ?)) as max_val', [$now, $now])
684 760 ->first();
685 761 $sample = ($agg && (int) $agg->cnt > 0)
686 - ? (clone $baseQuery)->selectRaw('TIMESTAMPDIFF(SECOND, waiting_since, UTC_TIMESTAMP()) as wait_sec')->orderByDesc('id')->limit(500)->pluck('wait_sec')->toArray()
762 + ? (clone $baseQuery)->selectRaw('TIMESTAMPDIFF(SECOND, waiting_since, ?) as wait_sec', [$now])->orderByDesc('id')->limit(500)->pluck('wait_sec')->toArray()
687 763 : [];
688 764
689 765 return [
690 766 'count' => $agg ? (int) $agg->cnt : 0,
@@ -729,9 +805,9 @@
729 805 if (count($ticketIds) > self::MAX_BULK) {
730 806 return MCPHelper::error('invalid_param', sprintf(__('ticket_ids may not exceed %d items per request. Split into multiple calls.', 'fluent-support'), self::MAX_BULK), ['fields' => ['ticket_ids'], 'limit' => self::MAX_BULK]);
731 807 }
732 808
733 - $allowedActions = ['close', 'assign', 'tag'];
809 + $allowedActions = ['close', 'assign', 'tag', 'reply'];
734 810 if (!in_array($action, $allowedActions)) {
735 811 return MCPHelper::error('invalid_param', sprintf(__('action must be one of: %s', 'fluent-support'), implode(', ', $allowedActions)), ['fields' => ['action']]);
736 812 }
737 813
@@ -744,15 +820,25 @@
744 820 return $assignTarget;
745 821 }
746 822 }
747 823
824 + $bulkTagIds = [];
748 825 if ($action === 'tag') {
749 826 $tagIds = array_map('intval', (array) ($params['tag_ids'] ?? []));
750 827 if (empty($tagIds)) {
751 828 return MCPHelper::error('invalid_param', __('tag_ids is required for the tag action', 'fluent-support'), ['fields' => ['tag_ids']]);
752 829 }
830 + // Only real ticket tags: fs_taggables also holds agent groups and other types.
831 + $bulkTagIds = TicketTag::whereIn('id', $tagIds)->pluck('id')->toArray();
832 + if (!$bulkTagIds) {
833 + return MCPHelper::error('invalid_param', __('None of the tag_ids is a ticket tag', 'fluent-support'), ['fields' => ['tag_ids'], 'next_step' => 'Use get-support-context to see available tags and their IDs']);
834 + }
753 835 }
754 836
837 + if ($action === 'reply' && empty($params['content']) && empty($params['saved_reply_id'])) {
838 + return MCPHelper::error('invalid_param', __('content or saved_reply_id is required for the reply action', 'fluent-support'), ['fields' => ['content', 'saved_reply_id']]);
839 + }
840 +
755 841 $tickets = Ticket::whereIn('id', $ticketIds)->get();
756 842 $ticketService = new TicketService();
757 843 $results = [];
758 844 $foundIds = $tickets->pluck('id')->toArray();
@@ -776,9 +862,10 @@
776 862 case 'close':
777 863 if ($ticket->status === 'closed') {
778 864 $results[] = ['id' => $ticket->id, 'status' => 'already_closed'];
779 865 } else {
780 - $ticketService->close($ticket, $agent);
866 + // close() takes 'yes'/'no'; only 'no' sends the closing email.
867 + $ticketService->close($ticket, $agent, '', !empty($params['silent']) ? 'yes' : 'no');
781 868 $results[] = ['id' => $ticket->id, 'status' => 'closed'];
782 869 }
783 870 break;
784 871
@@ -791,12 +878,15 @@
791 878 $results[] = ['id' => $ticket->id, 'status' => 'assigned'];
792 879 break;
793 880
794 881 case 'tag':
795 - $tagIds = array_map('intval', (array) ($params['tag_ids'] ?? []));
796 - $ticket->applyTags($tagIds);
882 + $ticket->applyTags($bulkTagIds);
797 883 $results[] = ['id' => $ticket->id, 'status' => 'tagged'];
798 884 break;
885 +
886 + case 'reply':
887 + $results[] = self::bulkReply($ticket, $params);
888 + break;
799 889 }
800 890 } catch (\Exception $e) {
801 891 $results[] = ['id' => $ticket->id, 'status' => 'error', 'message' => $e->getMessage()];
802 892 }
@@ -802,13 +892,13 @@
802 892 }
803 893 }
804 894
805 895 $processed = count(array_filter($results, function ($r) {
806 - return !in_array($r['status'], ['not_found', 'forbidden', 'mailbox_restricted', 'error']);
896 + return in_array($r['status'], ['closed', 'already_closed', 'assigned', 'tagged', 'replied', 'drafted', 'duplicate'], true);
807 897 }));
808 898
809 899 $total = count($ticketIds);
810 - $verbMap = ['close' => 'closed', 'assign' => 'assigned', 'tag' => 'tagged'];
900 + $verbMap = ['close' => 'closed', 'assign' => 'assigned', 'tag' => 'tagged', 'reply' => 'replied to'];
811 901 $verb = $verbMap[$action] ?? $action;
812 902
813 903 return MCPHelper::envelope(
814 904 "{$processed} of {$total} ticket(s) {$verb}",
@@ -816,8 +906,48 @@
816 906 ['processed' => $processed, 'total' => $total]
817 907 );
818 908 }
819 909
910 + /**
911 + * One ticket of a bulk reply, through the same path as reply-to-ticket
912 + * (access, reopen, auto-assign, drafts, duplicate guard).
913 + */
914 + private static function bulkReply(Ticket $ticket, $params)
915 + {
916 + $replyParams = array_intersect_key($params, array_flip(['content', 'content_format', 'saved_reply_id', 'close_ticket', 'draft']));
917 + $replyParams['ticket_id'] = $ticket->id;
918 +
919 + $result = ResponseTools::replyToTicket($replyParams);
920 +
921 + if (is_wp_error($result)) {
922 + $error = json_decode($result->get_error_message(), true);
923 + return [
924 + 'id' => $ticket->id,
925 + 'status' => $error['error']['code'] ?? 'error',
926 + 'message' => $error['error']['message'] ?? $result->get_error_message(),
927 + ];
928 + }
929 +
930 + $data = $result['data'];
931 + if (!empty($data['duplicate'])) {
932 + $status = 'duplicate';
933 + } elseif (!empty($data['draft'])) {
934 + $status = 'drafted';
935 + } else {
936 + $status = 'replied';
937 + }
938 +
939 + return array_filter([
940 + 'id' => $ticket->id,
941 + 'status' => $status,
942 + 'response_id' => $data['response']['id'] ?? null,
943 + 'ticket_status' => $data['ticket_status'] ?? null,
944 + 'reopened' => !empty($data['reopened']) ?: null,
945 + ], function ($value) {
946 + return $value !== null;
947 + });
948 + }
949 +
820 950 public static function getMentions($params)
821 951 {
822 952 $agent = MCPHelper::resolveAgent();
823 953 if (!$agent) {
@@ -910,58 +1040,5 @@
910 1040 MCPHelper::pagingMeta($paginated)
911 1041 );
912 1042 }
913 1043
914 - public static function listWorkflows($params)
915 - {
916 - if (!class_exists('\FluentSupportPro\App\Models\Workflow')) {
917 - return MCPHelper::error('not_available', __('Workflows require Fluent Support Pro', 'fluent-support'));
918 - }
919 -
920 - ['page' => $page, 'per_page' => $perPage] = MCPHelper::pagination($params);
921 -
922 - $query = \FluentSupportPro\App\Models\Workflow::query();
923 -
924 - $status = sanitize_text_field($params['status'] ?? 'published');
925 - if ($status !== 'all') {
926 - $query->where('status', $status);
927 - }
928 -
929 - if (!empty($params['trigger_type'])) {
930 - $query->where('trigger_type', sanitize_text_field($params['trigger_type']));
931 - }
932 -
933 - $paginated = $query->with([
934 - 'actions' => fn($q) => $q->select(['id', 'workflow_id', 'title', 'action_name']),
935 - ])->orderBy('priority', 'ASC')->paginate($perPage, ['*'], 'page', $page);
936 -
937 - $triggerLabels = [
938 - 'fluent_support/ticket_created' => 'When a new ticket is created',
939 - 'fluent_support/response_added_by_customer' => 'When a customer replies',
940 - 'fluent_support/ticket_closed' => 'When a ticket is closed',
941 - ];
942 -
943 - $total = $paginated->total();
944 -
945 - $workflows = array_map(function ($wf) use ($triggerLabels) {
946 - return [
947 - 'id' => $wf->id,
948 - 'title' => $wf->title,
949 - 'status' => $wf->status,
950 - 'trigger_type' => $wf->trigger_type,
951 - 'trigger_key' => sanitize_text_field($wf->trigger_key),
952 - 'trigger_label' => $triggerLabels[$wf->trigger_key] ?? sanitize_text_field($wf->trigger_key),
953 - 'actions' => $wf->actions->map(fn($a) => [
954 - 'action' => $a->action_name,
955 - 'title' => $a->title,
956 - ])->toArray(),
957 - 'last_ran_at' => MCPHelper::toIso8601($wf->last_ran_at),
958 - ];
959 - }, $paginated->items());
960 -
961 - return MCPHelper::envelope(
962 - sprintf(_n('Found %d workflow', 'Found %d workflows', $total, 'fluent-support'), $total),
963 - ['workflows' => $workflows],
964 - MCPHelper::pagingMeta($paginated)
965 - );
966 - }
967 1044 }