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 +333 -255 2.3.2 → 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,14 +75,22 @@
71 75 ->map(function ($p) {
72 76 return ['id' => $p->id, 'title' => $p->title, 'description' => $p->description ?: ''];
73 77 })->toArray();
74 78
79 + $signature = \FluentSupport\App\Services\Tickets\ResponseService::getAppendedSignature($currentAgent);
80 +
75 81 $mailboxes = MailBox::getAccessibleBoxes($canSeeSensitive)
76 - ->map(function ($mb) use ($canSeeSensitive) {
82 + ->map(function ($mb) use ($canSeeSensitive, $currentAgent, $signature) {
77 83 $entry = ['id' => $mb->id, 'name' => $mb->name];
78 84 if ($canSeeSensitive) {
79 85 $entry['email'] = $mb->email;
80 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 + ];
81 93 return $entry;
82 94 })->toArray();
83 95
84 96 $tags = TicketTag::select(['id', 'title'])
@@ -85,10 +97,9 @@
85 97 ->orderBy('title', 'ASC')
86 98 ->get()
87 99 ->toArray();
88 100
89 - $statusCounts = Ticket::selectRaw('status, COUNT(*) as cnt')
90 - ->when($restrictedMailboxes, fn($q) => $q->whereNotIn('mailbox_id', $restrictedMailboxes))
101 + $statusCounts = $access->applyMailboxRestrictionScope(Ticket::selectRaw('status, COUNT(*) as cnt'))
91 102 ->groupBy('status')
92 103 ->get()
93 104 ->pluck('cnt', 'status');
94 105
@@ -96,11 +107,10 @@
96 107 'total' => (int) $statusCounts->sum(),
97 108 'new' => (int) ($statusCounts['new'] ?? 0),
98 109 'active' => (int) ($statusCounts['active'] ?? 0),
99 110 'closed' => (int) ($statusCounts['closed'] ?? 0),
100 - 'unassigned' => Ticket::whereNull('agent_id')
111 + 'unassigned' => $access->applyMailboxRestrictionScope(Ticket::whereNull('agent_id'))
101 112 ->whereNotIn('status', ['closed'])
102 - ->when($restrictedMailboxes, fn($q) => $q->whereNotIn('mailbox_id', $restrictedMailboxes))
103 113 ->count(),
104 114 ];
105 115
106 116 $ticketFields = ['id', 'title', 'status', 'priority', 'customer_id', 'agent_id', 'waiting_since', 'created_at', 'response_count', 'last_agent_response', 'last_customer_response'];
@@ -115,13 +125,13 @@
115 125 $now = strtotime(current_time('mysql'));
116 126 $firstResponseCutoff = date('Y-m-d H:i:s', $now - ($firstResponseHours * 3600));
117 127 $resolutionCutoff = date('Y-m-d H:i:s', $now - ($resolutionHours * 3600));
118 128
119 - $myQueueBase = static::scopedTicketQuery($ticketFields, $eagerLoad, $restrictedMailboxes)
129 + $myQueueBase = static::scopedTicketQuery($ticketFields, $eagerLoad)
120 130 ->where('agent_id', $currentAgent->id)
121 131 ->whereNotIn('status', ['closed']);
122 132
123 - // 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:
124 134 // an agent-last ticket idle for 12 days and a customer-last ticket waiting 12 days both
125 135 // have an old waiting_since, but only the latter needs a reply. Mixing them let stale
126 136 // agent-last tickets crowd the actionable ones out of the capped list.
127 137 $awaitingYourReplyQuery = (clone $myQueueBase)->waitingOnly();
@@ -152,9 +162,9 @@
152 162 'awaiting_customer_total' => $awaitingCustomerTotal,
153 163 'awaiting_customer_truncated' => $awaitingCustomerTotal > count($awaitingCustomer),
154 164 ];
155 165
156 - $unassigned = static::scopedTicketQuery($ticketFields, $eagerLoad, $restrictedMailboxes)
166 + $unassigned = static::scopedTicketQuery($ticketFields, $eagerLoad)
157 167 ->whereNull('agent_id')
158 168 ->whereNotIn('status', ['closed'])
159 169 ->orderBy('created_at', 'ASC')
160 170 ->limit(5)
@@ -161,11 +171,14 @@
161 171 ->get()
162 172 ->map(function ($t) use ($now) { return self::formatContextTicket($t, $now); })
163 173 ->toArray();
164 174
165 - $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)
166 178 ->whereNotIn('status', ['closed'])
167 179 ->whereNotNull('waiting_since')
180 + ->waitingOnly()
168 181 ->orderBy('waiting_since', 'ASC')
169 182 ->limit(5)
170 183 ->get()
171 184 ->map(function ($t) use ($now) { return self::formatContextTicket($t, $now); })
@@ -170,9 +183,9 @@
170 183 ->get()
171 184 ->map(function ($t) use ($now) { return self::formatContextTicket($t, $now); })
172 185 ->toArray();
173 186
174 - $critical = static::scopedTicketQuery($ticketFields, $eagerLoad, $restrictedMailboxes)
187 + $critical = static::scopedTicketQuery($ticketFields, $eagerLoad)
175 188 ->where('priority', 'critical')
176 189 ->whereNotIn('status', ['closed'])
177 190 ->orderBy('created_at', 'ASC')
178 191 ->limit(5)
@@ -179,9 +192,9 @@
179 192 ->get()
180 193 ->map(function ($t) use ($now) { return self::formatContextTicket($t, $now); })
181 194 ->toArray();
182 195
183 - $slaFirstResponse = static::scopedTicketQuery($ticketFields, $eagerLoad, $restrictedMailboxes)
196 + $slaFirstResponse = static::scopedTicketQuery($ticketFields, $eagerLoad)
184 197 ->whereNotIn('status', ['closed'])
185 198 ->where('response_count', 0)
186 199 ->where('created_at', '<=', $firstResponseCutoff)
187 200 ->orderBy('created_at', 'ASC')
@@ -189,11 +202,14 @@
189 202 ->get()
190 203 ->map(function ($t) use ($now) { return self::formatContextTicket($t, $now); })
191 204 ->toArray();
192 205
193 - $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)
194 209 ->whereNotIn('status', ['closed'])
195 210 ->where('created_at', '<=', $resolutionCutoff)
211 + ->waitingOnly()
196 212 ->orderBy('created_at', 'ASC')
197 213 ->limit(5)
198 214 ->get()
199 215 ->map(function ($t) use ($now) { return self::formatContextTicket($t, $now); })
@@ -200,12 +216,12 @@
200 216 ->toArray();
201 217
202 218 $guidelines = apply_filters('fluent_support/mcp_ai_guidelines', PermissionGate::getAiGuidelines());
203 219 if (!$guidelines) {
220 + // Tone and conduct only: the triage order is in the server instructions.
204 221 $guidelines = 'Be professional and empathetic. Address customers by name. '
205 - . 'For triage, prioritize: critical priority first, then longest waiting, then unassigned. '
206 - . 'Always check the conversation history before replying. '
207 - . '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.';
208 224 }
209 225
210 226 $openCount = $stats['new'] + $stats['active'];
211 227
@@ -211,13 +227,23 @@
211 227
212 228 return MCPHelper::envelope(
213 229 "Support context: {$openCount} open ticket(s), {$stats['unassigned']} unassigned",
214 230 [
215 - 'you' => [
216 - 'agent_id' => $currentAgent->id,
217 - 'name' => MCPHelper::personName($currentAgent),
218 - 'email' => $currentAgent->email,
219 - ],
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 + ]),
220 246 'my_queue' => $myQueue,
221 247 'needs_attention' => [
222 248 'unassigned' => $unassigned,
223 249 'longest_waiting' => $longestWaiting,
@@ -237,22 +263,157 @@
237 263 'tags' => $tags,
238 264 'stats' => $stats,
239 265 'priorities' => ['normal', 'medium', 'critical'],
240 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(),
241 270 'guidelines' => $guidelines,
242 271 ]
243 272 );
244 273 }
245 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 +
246 413 public static function invalidateSupportContextCache()
247 414 {
248 - global $wpdb;
249 - $like = $wpdb->esc_like('_transient_' . self::CACHE_PREFIX) . '%';
250 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
251 - $wpdb->query($wpdb->prepare("DELETE FROM {$wpdb->options} WHERE option_name LIKE %s", $like));
252 - $like = $wpdb->esc_like('_transient_timeout_' . self::CACHE_PREFIX) . '%';
253 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
254 - $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);
255 416 }
256 417
257 418 /**
258 419 * Build a ticket query scoped to the current agent's visibility, mirroring listTickets().
@@ -262,19 +423,15 @@
262 423 * customer names/emails they serialize — outside the agent's visibility scope. The
263 424 * restricted-mailbox filter alone is not enough: an agent scoped to only their own tickets
264 425 * must not enumerate other customers' tickets across the rest of the instance.
265 426 */
266 - private static function scopedTicketQuery($ticketFields, $eagerLoad, $restrictedMailboxes)
427 + private static function scopedTicketQuery($ticketFields, $eagerLoad)
267 428 {
268 429 $query = Ticket::select($ticketFields)->with($eagerLoad);
269 430
270 431 do_action_ref_array('fluent_support/tickets_query_by_permission_ref', [&$query]);
271 432
272 - if ($restrictedMailboxes) {
273 - $query->whereNotIn('mailbox_id', $restrictedMailboxes);
274 - }
275 -
276 - return $query;
433 + return (new AgentTicketAccess())->applyMailboxRestrictionScope($query);
277 434 }
278 435
279 436 private static function formatContextTicket($ticket, int $now)
280 437 {
@@ -283,9 +440,9 @@
283 440 'title' => $ticket->title,
284 441 'status' => $ticket->status ?: 'new',
285 442 'priority' => MCPHelper::normalizePriority($ticket->priority),
286 443 'response_count' => (int) $ticket->response_count,
287 - 'last_reply_by' => $ticket->last_reply_by,
444 + 'waiting_on' => MCPHelper::waitingOn($ticket),
288 445 ];
289 446
290 447 if ($ticket->relationLoaded('customer') && $ticket->customer) {
291 448 $item['customer'] = MCPHelper::formatPersonSummary($ticket->customer);
@@ -298,46 +455,8 @@
298 455
299 456 return $item;
300 457 }
301 458
302 - public static function assignTicket($params)
303 - {
304 - $agent = MCPHelper::resolveAgent();
305 - if (!$agent) {
306 - return MCPHelper::error('unauthorized', __('No agent found for current user', 'fluent-support'));
307 - }
308 -
309 - $ticketId = (int) ($params['ticket_id'] ?? 0);
310 - $newAgentId = (int) ($params['agent_id'] ?? 0);
311 -
312 - if (!$ticketId || !$newAgentId) {
313 - return MCPHelper::error('invalid_param', __('ticket_id and agent_id are required', 'fluent-support'), ['fields' => ['ticket_id', 'agent_id']]);
314 - }
315 -
316 - $ticket = Ticket::find($ticketId);
317 - if (!$ticket) {
318 - return MCPHelper::error('not_found', __('Ticket not found', 'fluent-support'), ['next_step' => 'Use list-tickets to find valid ticket IDs']);
319 - }
320 -
321 - if ($err = TicketAccessGuard::assert($ticket)) {
322 - return $err;
323 - }
324 -
325 - $newAgent = MCPHelper::resolveAssignmentTarget($newAgentId, $ticket, 'agent_id');
326 - if (is_wp_error($newAgent)) {
327 - return $newAgent;
328 - }
329 -
330 - MCPHelper::applyAgentAssignment($ticket, $newAgent, $agent);
331 -
332 - $agentName = MCPHelper::personName($newAgent);
333 -
334 - return MCPHelper::envelope(
335 - "Ticket #{$ticketId} assigned to {$agentName}",
336 - ['agent' => MCPHelper::formatPersonSummary($newAgent)]
337 - );
338 - }
339 -
340 459 public static function tagTicket($params)
341 460 {
342 461 $agent = MCPHelper::resolveAgent();
343 462 if (!$agent) {
@@ -368,114 +487,93 @@
368 487 if (count($addTagNames) > self::MAX_TAG_NAMES) {
369 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]);
370 489 }
371 490
372 - $namesResult = self::findOrCreateTagsByTitles($addTagNames);
373 - $failedTagNames = $namesResult['failed'];
374 - $createdAnyTag = $namesResult['created_any'];
375 - foreach ($namesResult['tags'] as $tag) {
376 - $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 + );
377 505 }
506 + foreach ($resolved['ids'] as $tagId) {
507 + $addTagIds[] = $tagId;
508 + }
378 509
510 + // Only real ticket tags: fs_taggables also holds agent groups and other types.
379 511 if ($addTagIds) {
380 - $ticket->applyTags(array_unique($addTagIds));
512 + $addTagIds = TicketTag::whereIn('id', array_unique($addTagIds))->pluck('id')->toArray();
381 513 }
382 514
515 + if ($addTagIds) {
516 + $ticket->applyTags($addTagIds);
517 + }
518 +
383 519 if ($removeTagIds) {
384 520 $ticket->detachTags($removeTagIds);
385 521 }
386 522
387 - if ($createdAnyTag) {
388 - self::invalidateSupportContextCache();
389 - }
390 -
391 523 $ticket->load('tags');
392 524
393 - $summary = "Tags updated on ticket #{$ticketId}";
394 - if ($failedTagNames) {
395 - $summary .= sprintf(__(' (failed to create: %s)', 'fluent-support'), implode(', ', $failedTagNames));
396 - }
525 + /* translators: %d is the ticket ID */
526 + $summary = sprintf(__('Tags updated on ticket #%d', 'fluent-support'), $ticketId);
397 527
398 528 $data = ['tags' => $ticket->tags->map(function ($tag) {
399 529 return ['id' => $tag->id, 'title' => $tag->title];
400 530 })->toArray()];
401 531
402 - if ($failedTagNames) {
403 - $data['failed_tag_names'] = array_values($failedTagNames);
404 - }
405 -
406 532 return MCPHelper::envelope($summary, $data);
407 533 }
408 534
409 535 /**
410 - * Find an existing ticket tag by exact title match, or create one.
411 - * created_by is left unset so TicketTag::boot() applies its own
412 - * get_current_user_id() default — the same convention every other
413 - * tag-creation path (REST, UI) relies on; setting it here to an
414 - * 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.
415 538 *
416 - * @return array{tag: ?TicketTag, created: bool}|null
539 + * @param string[] $names
540 + * @return array{ids: int[], unknown: string[], available: string[]}
417 541 */
418 - private static function findOrCreateTag($title, $description = null)
542 + private static function resolveTagNames(array $names)
419 543 {
420 - $title = trim($title);
421 - if ($title === '') {
422 - return null;
544 + if (!$names) {
545 + return ['ids' => [], 'unknown' => [], 'available' => []];
423 546 }
424 547
425 - $existing = TicketTag::where('title', $title)->first();
426 - if ($existing) {
427 - 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 + }
428 555 }
429 556
430 - $data = ['title' => $title];
431 - if ($description !== null) {
432 - $data['description'] = $description;
433 - }
434 -
435 - return ['tag' => TicketTag::create($data), 'created' => true];
436 - }
437 -
438 - /**
439 - * Batched sibling of findOrCreateTag() for tag-ticket's add_tag_names —
440 - * resolves all titles with a single whereIn() lookup instead of one
441 - * query per name, then creates only the titles that didn't already
442 - * exist. Bounded by MAX_TAG_NAMES at the call site.
443 - *
444 - * @return array{tags: TicketTag[], created_any: bool, failed: string[]}
445 - */
446 - private static function findOrCreateTagsByTitles(array $titles)
447 - {
448 - if (!$titles) {
449 - return ['tags' => [], 'created_any' => false, 'failed' => []];
450 - }
451 -
452 - $existingByTitle = TicketTag::whereIn('title', $titles)->get()->keyBy('title');
453 -
454 - $tags = [];
455 - $failed = [];
456 - $createdAny = false;
457 -
458 - foreach ($titles as $title) {
459 - $existing = $existingByTitle->get($title);
460 - if ($existing) {
461 - $tags[] = $existing;
462 - continue;
463 - }
464 -
465 - $tag = TicketTag::create(['title' => $title]);
466 - if ($tag && $tag->id) {
467 - $tags[] = $tag;
468 - $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];
469 563 } else {
470 - $failed[] = $title;
564 + $unknown[] = $name;
471 565 }
472 566 }
473 567
474 - 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 + ];
475 573 }
476 574
477 - public static function createTag($params)
575 + public static function getSupportInsights($params)
478 576 {
479 577 $agent = MCPHelper::resolveAgent();
480 578 if (!$agent) {
481 579 return MCPHelper::error('unauthorized', __('No agent found for current user', 'fluent-support'));
@@ -480,56 +578,24 @@
480 578 if (!$agent) {
481 579 return MCPHelper::error('unauthorized', __('No agent found for current user', 'fluent-support'));
482 580 }
483 581
484 - $title = sanitize_text_field($params['title'] ?? '');
485 - if ($title === '') {
486 - 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';
487 586 }
488 587
489 - $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);
490 593
491 - $resolved = self::findOrCreateTag($title, $description);
492 - $tag = $resolved['tag'] ?? null;
493 -
494 - if (!$tag || !$tag->id) {
495 - return MCPHelper::error('failed', __('Failed to create tag', 'fluent-support'), ['retryable' => true]);
496 - }
497 -
498 - if ($resolved['created']) {
499 - self::invalidateSupportContextCache();
500 - }
501 -
502 - $summary = $resolved['created']
503 - ? sprintf(__('Tag "%s" created', 'fluent-support'), $tag->title)
504 - : sprintf(__('Tag "%s" already exists', 'fluent-support'), $tag->title);
505 -
506 - return MCPHelper::envelope(
507 - $summary,
508 - ['tag' => ['id' => $tag->id, 'title' => $tag->title], 'created' => $resolved['created']]
509 - );
510 - }
511 -
512 - public static function getSupportInsights($params)
513 - {
514 - $agent = MCPHelper::resolveAgent();
515 - if (!$agent) {
516 - return MCPHelper::error('unauthorized', __('No agent found for current user', 'fluent-support'));
517 - }
518 -
519 - $period = sanitize_text_field($params['period'] ?? '7d');
520 - $periodMap = ['24h' => 1, '7d' => 7, '30d' => 30, '90d' => 90];
521 - $days = $periodMap[$period] ?? 7;
522 - $since = gmdate('Y-m-d H:i:s', strtotime("-{$days} days"));
523 -
524 - $restrictedMailboxes = PermissionManager::getRestrictedMailboxIds();
525 - $scoped = function () use ($restrictedMailboxes) {
594 + $scoped = function () {
526 595 $query = Ticket::query();
527 596 do_action_ref_array('fluent_support/tickets_query_by_permission_ref', [&$query]);
528 - if ($restrictedMailboxes) {
529 - $query->whereNotIn('mailbox_id', $restrictedMailboxes);
530 - }
531 - return $query;
597 + return (new AgentTicketAccess())->applyMailboxRestrictionScope($query);
532 598 };
533 599
534 600 $periodTicketsQuery = $scoped()->where('created_at', '>=', $since);
535 601 $closedInPeriodQuery = $scoped()->where('resolved_at', '>=', $since);
@@ -547,10 +613,10 @@
547 613 $ctSample = ($ctAgg && (int) $ctAgg->cnt > 0)
548 614 ? (clone $ctBase)->orderByDesc('id')->limit(500)->pluck('total_close_time')->toArray()
549 615 : [];
550 616
551 - // Waiting — split by who's turn it is. last_reply_by=customer tickets are genuinely
552 - // 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
553 619 // has been idle since our last reply. A single blended average over both populations
554 620 // measures mostly customer silence and hides how many tickets actually need a reply.
555 621 $waitOnAgentBase = $scoped()->whereNotIn('status', ['closed'])->whereNotNull('waiting_since')->waitingOnly();
556 622 $waitOnCustomerBase = $scoped()->whereNotIn('status', ['closed'])->whereNotNull('waiting_since')->where(function ($q) {
@@ -629,9 +695,15 @@
629 695 }
630 696
631 697 usort($performance, fn($a, $b) => $b['open_tickets'] - $a['open_tickets']);
632 698
633 - $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;
634 706 if ($truncatedAgents) {
635 707 $performance = array_slice($performance, 0, self::MAX_AGENTS);
636 708 }
637 709
@@ -641,8 +713,10 @@
641 713 return MCPHelper::envelope(
642 714 "Support insights for {$period}: {$created} created, {$closed} closed",
643 715 [
644 716 'period' => $period,
717 + 'from' => wp_date('c', $sinceTs),
718 + 'to' => wp_date('c', $nowTs),
645 719 'volume' => ['created' => $created, 'closed' => $closed],
646 720 'first_response_time' => self::computeTimeStatsFromAgg($rtAgg, $rtSample),
647 721 'resolution_time' => self::computeTimeStatsFromAgg($ctAgg, $ctSample),
648 722 'waiting' => [
@@ -649,8 +723,9 @@
649 723 'waiting_on_agent' => $waitingOnAgent,
650 724 'waiting_on_customer' => $waitingOnCustomer,
651 725 ],
652 726 'agent_performance' => $performance,
727 + 'agent_performance_total' => $agentTotal,
653 728 'agent_performance_truncated' => $truncatedAgents,
654 729 ]
655 730 );
656 731 }
@@ -677,13 +752,15 @@
677 752 * stored duration column.
678 753 */
679 754 private static function computeWaitStats($baseQuery)
680 755 {
756 + // waiting_since is stored in site time, so measure against site time, not UTC.
757 + $now = current_time('mysql');
681 758 $agg = (clone $baseQuery)
682 - ->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])
683 760 ->first();
684 761 $sample = ($agg && (int) $agg->cnt > 0)
685 - ? (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()
686 763 : [];
687 764
688 765 return [
689 766 'count' => $agg ? (int) $agg->cnt : 0,
@@ -728,9 +805,9 @@
728 805 if (count($ticketIds) > self::MAX_BULK) {
729 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]);
730 807 }
731 808
732 - $allowedActions = ['close', 'assign', 'tag'];
809 + $allowedActions = ['close', 'assign', 'tag', 'reply'];
733 810 if (!in_array($action, $allowedActions)) {
734 811 return MCPHelper::error('invalid_param', sprintf(__('action must be one of: %s', 'fluent-support'), implode(', ', $allowedActions)), ['fields' => ['action']]);
735 812 }
736 813
@@ -743,15 +820,25 @@
743 820 return $assignTarget;
744 821 }
745 822 }
746 823
824 + $bulkTagIds = [];
747 825 if ($action === 'tag') {
748 826 $tagIds = array_map('intval', (array) ($params['tag_ids'] ?? []));
749 827 if (empty($tagIds)) {
750 828 return MCPHelper::error('invalid_param', __('tag_ids is required for the tag action', 'fluent-support'), ['fields' => ['tag_ids']]);
751 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 + }
752 835 }
753 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 +
754 841 $tickets = Ticket::whereIn('id', $ticketIds)->get();
755 842 $ticketService = new TicketService();
756 843 $results = [];
757 844 $foundIds = $tickets->pluck('id')->toArray();
@@ -775,9 +862,10 @@
775 862 case 'close':
776 863 if ($ticket->status === 'closed') {
777 864 $results[] = ['id' => $ticket->id, 'status' => 'already_closed'];
778 865 } else {
779 - $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');
780 868 $results[] = ['id' => $ticket->id, 'status' => 'closed'];
781 869 }
782 870 break;
783 871
@@ -790,12 +878,15 @@
790 878 $results[] = ['id' => $ticket->id, 'status' => 'assigned'];
791 879 break;
792 880
793 881 case 'tag':
794 - $tagIds = array_map('intval', (array) ($params['tag_ids'] ?? []));
795 - $ticket->applyTags($tagIds);
882 + $ticket->applyTags($bulkTagIds);
796 883 $results[] = ['id' => $ticket->id, 'status' => 'tagged'];
797 884 break;
885 +
886 + case 'reply':
887 + $results[] = self::bulkReply($ticket, $params);
888 + break;
798 889 }
799 890 } catch (\Exception $e) {
800 891 $results[] = ['id' => $ticket->id, 'status' => 'error', 'message' => $e->getMessage()];
801 892 }
@@ -801,13 +892,13 @@
801 892 }
802 893 }
803 894
804 895 $processed = count(array_filter($results, function ($r) {
805 - 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);
806 897 }));
807 898
808 899 $total = count($ticketIds);
809 - $verbMap = ['close' => 'closed', 'assign' => 'assigned', 'tag' => 'tagged'];
900 + $verbMap = ['close' => 'closed', 'assign' => 'assigned', 'tag' => 'tagged', 'reply' => 'replied to'];
810 901 $verb = $verbMap[$action] ?? $action;
811 902
812 903 return MCPHelper::envelope(
813 904 "{$processed} of {$total} ticket(s) {$verb}",
@@ -815,8 +906,48 @@
815 906 ['processed' => $processed, 'total' => $total]
816 907 );
817 908 }
818 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 +
819 950 public static function getMentions($params)
820 951 {
821 952 $agent = MCPHelper::resolveAgent();
822 953 if (!$agent) {
@@ -909,58 +1040,5 @@
909 1040 MCPHelper::pagingMeta($paginated)
910 1041 );
911 1042 }
912 1043
913 - public static function listWorkflows($params)
914 - {
915 - if (!class_exists('\FluentSupportPro\App\Models\Workflow')) {
916 - return MCPHelper::error('not_available', __('Workflows require Fluent Support Pro', 'fluent-support'));
917 - }
918 -
919 - ['page' => $page, 'per_page' => $perPage] = MCPHelper::pagination($params);
920 -
921 - $query = \FluentSupportPro\App\Models\Workflow::query();
922 -
923 - $status = sanitize_text_field($params['status'] ?? 'published');
924 - if ($status !== 'all') {
925 - $query->where('status', $status);
926 - }
927 -
928 - if (!empty($params['trigger_type'])) {
929 - $query->where('trigger_type', sanitize_text_field($params['trigger_type']));
930 - }
931 -
932 - $paginated = $query->with([
933 - 'actions' => fn($q) => $q->select(['id', 'workflow_id', 'title', 'action_name']),
934 - ])->orderBy('priority', 'ASC')->paginate($perPage, ['*'], 'page', $page);
935 -
936 - $triggerLabels = [
937 - 'fluent_support/ticket_created' => 'When a new ticket is created',
938 - 'fluent_support/response_added_by_customer' => 'When a customer replies',
939 - 'fluent_support/ticket_closed' => 'When a ticket is closed',
940 - ];
941 -
942 - $total = $paginated->total();
943 -
944 - $workflows = array_map(function ($wf) use ($triggerLabels) {
945 - return [
946 - 'id' => $wf->id,
947 - 'title' => $wf->title,
948 - 'status' => $wf->status,
949 - 'trigger_type' => $wf->trigger_type,
950 - 'trigger_key' => sanitize_text_field($wf->trigger_key),
951 - 'trigger_label' => $triggerLabels[$wf->trigger_key] ?? sanitize_text_field($wf->trigger_key),
952 - 'actions' => $wf->actions->map(fn($a) => [
953 - 'action' => $a->action_name,
954 - 'title' => $a->title,
955 - ])->toArray(),
956 - 'last_ran_at' => MCPHelper::toIso8601($wf->last_ran_at),
957 - ];
958 - }, $paginated->items());
959 -
960 - return MCPHelper::envelope(
961 - sprintf(_n('Found %d workflow', 'Found %d workflows', $total, 'fluent-support'), $total),
962 - ['workflows' => $workflows],
963 - MCPHelper::pagingMeta($paginated)
964 - );
965 - }
966 1044 }