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
fluent-support / app / Modules / MCP / Tools / ResponseTools.php

ResponseTools.php in Fluent Support – Helpdesk & Customer Support Ticket System 2.4.5, at app/Modules/MCP/Tools/ResponseTools.php

701 lines 29.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentSupport\App\Modules\MCP\Tools;
4
5 use FluentSupport\App\Models\Conversation;
6 use FluentSupport\App\Models\SavedReply;
7 use FluentSupport\App\Models\Ticket;
8 use FluentSupport\App\Modules\MCP\Helpers\MCPHelper;
9 use FluentSupport\App\Modules\MCP\Support\AbilityGuard;
10 use FluentSupport\App\Modules\MCP\Support\AttachmentStager;
11 use FluentSupport\App\Modules\MCP\Support\TicketAccessGuard;
12 use FluentSupport\App\Modules\PermissionManager;
13 use FluentSupport\App\Services\Tickets\ResponseService;
14 use FluentSupport\App\Services\Tickets\TicketService;
15
16 class ResponseTools
17 {
18 /** Seconds within which an identical MCP reply is treated as a retry, not a new reply. */
19 const DUPLICATE_WINDOW = 600;
20
21 public static function listSavedReplies($params)
22 {
23 $agent = MCPHelper::resolveAgent();
24 if (!$agent) {
25 return MCPHelper::error('unauthorized', __('No agent found for current user', 'fluent-support'));
26 }
27
28 $query = SavedReply::where('created_by', $agent->id);
29
30 if (!empty($params['search'])) {
31 $search = sanitize_text_field($params['search']);
32 $query->where(function ($q) use ($search) {
33 $q->where('title', 'LIKE', "%{$search}%")
34 ->orWhere('content', 'LIKE', "%{$search}%");
35 });
36 }
37
38 if (!empty($params['product_id'])) {
39 $query->where('product_id', (int) $params['product_id']);
40 }
41
42 $replies = $query->orderBy('title', 'ASC')->get();
43
44 $count = $replies->count();
45
46 return MCPHelper::envelope(
47 sprintf(_n('Found %d saved reply', 'Found %d saved replies', $count, 'fluent-support'), $count),
48 ['saved_replies' => $replies->map(function ($reply) {
49 return self::formatSavedReply($reply);
50 })->toArray()],
51 ['total' => $count]
52 );
53 }
54
55 private static function formatSavedReply($reply)
56 {
57 return [
58 'id' => $reply->id,
59 'title' => $reply->title,
60 'content' => MCPHelper::htmlToMarkdown($reply->content),
61 'product_id' => $reply->product_id,
62 ];
63 }
64
65 public static function replyToTicket($params)
66 {
67 $agent = MCPHelper::resolveAgent();
68 if (!$agent) {
69 return MCPHelper::error('unauthorized', __('No agent found for current user', 'fluent-support'));
70 }
71
72 $ticketId = (int) ($params['ticket_id'] ?? 0);
73 if (!$ticketId) {
74 return MCPHelper::error('invalid_param', __('ticket_id is required', 'fluent-support'), ['fields' => ['ticket_id']]);
75 }
76
77 $content = '';
78
79 if (!empty($params['saved_reply_id'])) {
80 $savedReply = SavedReply::where('id', (int) $params['saved_reply_id'])
81 ->where('created_by', $agent->id)
82 ->first();
83 if (!$savedReply) {
84 return MCPHelper::error('not_found', __('Saved reply not found', 'fluent-support'), ['next_step' => 'Use list-saved-replies to find valid IDs']);
85 }
86 $content = wp_kses_post($savedReply->content);
87 }
88
89 if (!empty($params['content'])) {
90 $format = $params['content_format'] ?? 'markdown';
91 $content = wp_kses_post(MCPHelper::processContent($params['content'], $format));
92 }
93
94 if (!$content) {
95 return MCPHelper::error('invalid_param', __('content or saved_reply_id is required', 'fluent-support'), ['fields' => ['content', 'saved_reply_id']]);
96 }
97
98 $ticket = Ticket::find($ticketId);
99 if (!$ticket) {
100 return MCPHelper::error('not_found', __('Ticket not found', 'fluent-support'), ['next_step' => 'Use list-tickets to find valid ticket IDs']);
101 }
102
103 if ($err = TicketAccessGuard::assert($ticket)) {
104 return $err;
105 }
106
107 // Draft-only agents (fst_draft_reply without manage permissions) can
108 // only save drafts, the same as in the ticket view.
109 $draftOnly = !PermissionManager::userCan(AbilityGuard::MANAGE_TICKET_CAPS);
110 $isDraft = $draftOnly || !empty($params['draft']);
111
112 // The flag only applies while the reply is sent; a draft is sent later.
113 if ($isDraft && !empty($params['informational'])) {
114 return MCPHelper::error(
115 'invalid_param',
116 __('A draft reply cannot be informational', 'fluent-support'),
117 ['fields' => ['informational', 'draft'], 'next_step' => 'Send the reply without draft, or drop informational']
118 );
119 }
120
121 // Clients retry when a response is lost, and a retry must not email the
122 // customer twice. Same agent, ticket and request within the window = retry.
123 $requestHash = self::requestHash($ticket, $content, $params, $isDraft);
124 if ($duplicate = self::findDuplicateReply($ticket, $agent, $requestHash)) {
125 return MCPHelper::envelope(
126 /* translators: %d is the ticket ID */
127 sprintf(__('This reply was already added to ticket #%d a moment ago, so it was not sent again.', 'fluent-support'), $ticket->id),
128 [
129 'response' => [
130 'id' => $duplicate->id,
131 'content' => MCPHelper::htmlToMarkdown($duplicate->content),
132 'type' => $duplicate->conversation_type,
133 'created_at' => MCPHelper::toIso8601($duplicate->created_at),
134 ],
135 'ticket_status' => $ticket->status,
136 'duplicate' => true,
137 ]
138 );
139 }
140
141 $attachments = AttachmentStager::stage($params['attachments'] ?? null, $ticket, $agent);
142 if (is_wp_error($attachments)) {
143 return $attachments;
144 }
145
146 if ($isDraft) {
147 return self::saveDraftReply($ticket, $agent, $content, $params, $draftOnly, $requestHash, $attachments);
148 }
149
150 // Replying to a closed ticket reopens it, unless close_ticket is set:
151 // then the reply goes out and the ticket simply stays closed.
152 $reopen = $ticket->status === 'closed' && empty($params['close_ticket']);
153
154 // Resolve assignment intent so assign + reply happen in one call.
155 // Explicit assignee_id requires fst_assign_agents (resolveAssignmentTarget).
156 // Otherwise, replying to an unassigned ticket takes ownership (self),
157 // which needs no extra permission. A ticket already assigned to someone
158 // else is never silently reassigned.
159 $assignTarget = null;
160 if (array_key_exists('assignee_id', $params) && $params['assignee_id'] !== null && $params['assignee_id'] !== '') {
161 $assignTarget = MCPHelper::resolveAssignmentTarget($params['assignee_id'], $ticket, 'assignee_id');
162 if (is_wp_error($assignTarget)) {
163 return $assignTarget;
164 }
165 } elseif (empty($ticket->agent_id)) {
166 $assignTarget = $agent; // taking ownership — no fst_assign_agents needed
167 }
168
169 $data = [
170 'content' => $content,
171 'conversation_type' => 'response',
172 'source' => 'mcp',
173 ];
174
175 if (!empty($params['close_ticket'])) {
176 $data['close_ticket'] = 'yes';
177 }
178
179 // A holding reply ("we've escalated this"): sent as usual, but the ticket
180 // keeps waiting on an agent (waiting_on stays "agent").
181 if (!empty($params['informational'])) {
182 $data['informational_reply'] = true;
183 }
184
185 if ($attachments) {
186 $data['attachments'] = $attachments;
187 }
188
189 // Assign + reply (+ optional close) must be atomic: a failure in the
190 // reply rolls back the assignment too. Every notification side effect
191 // (assignment, the reply email, close) is deferred until AFTER the
192 // transaction commits — firing them inside would leak an email for a
193 // reply that is then rolled back, and a retry of that reply would find
194 // no request hash and email the customer again.
195 $result = null;
196 $assigned = false;
197 $previousAgentId = $ticket->agent_id;
198 try {
199 (new Ticket())->getConnection()->transaction(function () use (&$result, &$assigned, $assignTarget, $ticket, $agent, $data, $reopen, $requestHash) {
200 if ($reopen) {
201 (new TicketService())->reopen($ticket, $agent, true);
202 }
203
204 if ($assignTarget) {
205 $assigned = MCPHelper::applyAgentAssignment($ticket, $assignTarget, $agent, true);
206 }
207
208 $result = (new ResponseService())->createResponse($data, $agent, $ticket, true);
209
210 if (!$result) {
211 throw new \Exception('reply_failed'); // roll back the reopen and assignment
212 }
213
214 $result['response']->updateSettingsValue('mcp_request_hash', $requestHash);
215 });
216 } catch (\Throwable $e) {
217 return MCPHelper::error('failed', __('Failed to create response', 'fluent-support'), ['retryable' => true]);
218 }
219
220 // Reply, reopen and assignment committed. Now fire their side effects,
221 // in the order the ticket view produces them: reopened, assigned, replied, closed.
222 if ($reopen) {
223 (new TicketService())->fireReopenHooks($ticket, $agent);
224 }
225
226 if ($assigned) {
227 $ticket->load('agent');
228 MCPHelper::fireAgentAssignmentSideEffects($ticket, $agent, $previousAgentId);
229 }
230
231 (new ResponseService())->fireResponseHooks($result['response'], $result['ticket'], $agent, $result['closed']);
232
233 $ticketStatus = $result['ticket']->status;
234
235 $isClosed = $ticketStatus === 'closed';
236
237 if (!$assigned) {
238 $summary = $isClosed
239 /* translators: %d is the ticket ID */
240 ? sprintf(__('Reply sent and ticket #%d closed', 'fluent-support'), $ticketId)
241 /* translators: %d is the ticket ID */
242 : sprintf(__('Reply added to ticket #%d', 'fluent-support'), $ticketId);
243 } elseif ((int) $assignTarget->id === (int) $agent->id) {
244 $summary = $isClosed
245 /* translators: %d is the ticket ID */
246 ? sprintf(__('Reply sent, ticket #%d closed and assigned to you', 'fluent-support'), $ticketId)
247 /* translators: %d is the ticket ID */
248 : sprintf(__('Reply added to ticket #%d and assigned to you', 'fluent-support'), $ticketId);
249 } else {
250 $assigneeName = MCPHelper::personName($assignTarget);
251 $summary = $isClosed
252 /* translators: %1$d is the ticket ID, %2$s is the assigned agent's name */
253 ? sprintf(__('Reply sent, ticket #%1$d closed and assigned to %2$s', 'fluent-support'), $ticketId, $assigneeName)
254 /* translators: %1$d is the ticket ID, %2$s is the assigned agent's name */
255 : sprintf(__('Reply added to ticket #%1$d and assigned to %2$s', 'fluent-support'), $ticketId, $assigneeName);
256 }
257
258 if ($reopen) {
259 /* translators: %d is the ticket ID */
260 $summary = sprintf(__('Ticket #%d was closed and has been reopened.', 'fluent-support'), $ticketId) . ' ' . $summary;
261 }
262
263 $payload = [
264 'response' => [
265 'id' => $result['response']->id,
266 'content' => MCPHelper::htmlToMarkdown($result['response']->content),
267 'type' => $result['response']->conversation_type,
268 'created_at' => MCPHelper::toIso8601($result['response']->created_at),
269 ],
270 'ticket_status' => $ticketStatus,
271 'reopened' => $reopen,
272 ];
273
274 if (!empty($params['informational'])) {
275 $payload['informational'] = true;
276 }
277
278 if ($attachments) {
279 $payload['response']['attachments'] = self::attachmentTitles($result['response']);
280 }
281
282 if ($assigned) {
283 $payload['assigned_to'] = [
284 'id' => (int) $assignTarget->id,
285 'name' => MCPHelper::personName($assignTarget),
286 ];
287 }
288
289 return MCPHelper::envelope($summary, $payload);
290 }
291
292 /**
293 * Save an unsent reply for a human to review and approve in the ticket view.
294 * Nothing reaches the customer, and status and assignee don't change.
295 */
296 private static function saveDraftReply(Ticket $ticket, $agent, $content, $params, $draftOnly, $requestHash, $attachments = [])
297 {
298 $conflicts = array_values(array_filter(['close_ticket', 'assignee_id'], function ($field) use ($params) {
299 return !empty($params[$field]);
300 }));
301
302 if ($conflicts) {
303 return MCPHelper::error(
304 'invalid_param',
305 $draftOnly
306 ? __('You can only save draft replies, which cannot close or assign the ticket', 'fluent-support')
307 : __('A draft reply cannot close or assign the ticket', 'fluent-support'),
308 ['fields' => $conflicts, 'next_step' => 'Remove close_ticket and assignee_id, or send the reply without draft']
309 );
310 }
311
312 $draft = [
313 'content' => $content,
314 'conversation_type' => 'draft_response',
315 'source' => 'mcp',
316 ];
317
318 if ($attachments) {
319 $draft['attachments'] = $attachments;
320 }
321
322 $result = (new ResponseService())->createResponse($draft, $agent, $ticket);
323
324 if (!$result) {
325 return MCPHelper::error('failed', __('Failed to save draft reply', 'fluent-support'), ['retryable' => true]);
326 }
327
328 $result['response']->updateSettingsValue('mcp_request_hash', $requestHash);
329
330 return MCPHelper::envelope(
331 $draftOnly
332 /* translators: %d is the ticket ID */
333 ? sprintf(__('Draft reply saved on ticket #%d. You only have the Draft Reply permission, so your replies are saved as drafts until another agent approves them.', 'fluent-support'), $ticket->id)
334 /* translators: %d is the ticket ID */
335 : sprintf(__('Draft reply saved on ticket #%d. It is not sent until an agent approves it.', 'fluent-support'), $ticket->id),
336 [
337 'response' => [
338 'id' => $result['response']->id,
339 'content' => MCPHelper::htmlToMarkdown($result['response']->content),
340 'type' => 'draft_response',
341 'created_at' => MCPHelper::toIso8601($result['response']->created_at),
342 ],
343 'ticket_status' => $ticket->status,
344 'draft' => true,
345 'draft_forced' => $draftOnly,
346 ]
347 );
348 }
349
350 /**
351 * @param \FluentSupport\App\Models\Conversation $response
352 * @return array<int, array{id:int, title:string}>
353 */
354 private static function attachmentTitles($response)
355 {
356 $response->load('attachments');
357
358 return $response->attachments->map(function ($attachment) {
359 return ['id' => (int) $attachment->id, 'title' => (string) $attachment->title];
360 })->values()->toArray();
361 }
362
363 /**
364 * Who a note mentions, and whether they get notified: mentions only notify when
365 * internal notifications are on, which the agent can't see otherwise. Null when
366 * the note mentions nobody.
367 *
368 * @param string $content Note HTML.
369 * @return array|null
370 */
371 private static function mentionReport($content)
372 {
373 $ids = (new \FluentSupport\App\Services\Notifications\MentionParser())->extractMentionIds($content);
374 if (!$ids) {
375 return null;
376 }
377
378 $agents = \FluentSupport\App\Models\Agent::whereIn('id', $ids)->get(['id', 'first_name', 'last_name'])->keyBy('id');
379 $settings = new \FluentSupport\App\Services\Notifications\NotificationSettings();
380 $notifies = $settings->isEnabled()
381 && $settings->isEventEnabled(\FluentSupport\App\Services\Notifications\NotificationEventMap::AGENT_MENTIONED);
382
383 return [
384 'agents' => array_map(function ($id) use ($agents) {
385 $agent = $agents->get($id);
386 return ['agent_id' => (int) $id, 'name' => $agent ? MCPHelper::personName($agent) : null, 'found' => (bool) $agent];
387 }, $ids),
388 'notified' => $notifies,
389 'note' => $notifies
390 ? 'Mentioned agents who can see this ticket get an in-app notification.'
391 : 'Internal notifications are turned off on this site, so nobody is notified. Assign the ticket or ask a person to tell them.',
392 ];
393 }
394
395 private static function requestHash(Ticket $ticket, $content, $params, $isDraft)
396 {
397 return md5(wp_json_encode([
398 (int) $ticket->id,
399 $content,
400 !empty($params['close_ticket']),
401 (bool) $isDraft,
402 (int) ($params['assignee_id'] ?? 0),
403 !empty($params['informational']),
404 AttachmentStager::fingerprint($params['attachments'] ?? null),
405 ]));
406 }
407
408 /**
409 * A reply this agent made through MCP on this ticket within DUPLICATE_WINDOW
410 * from the same request, or null.
411 *
412 * @return Conversation|null
413 */
414 private static function findDuplicateReply(Ticket $ticket, $agent, $requestHash)
415 {
416 $model = new Conversation();
417 $since = $model->freshTimestamp()->modify('-' . self::DUPLICATE_WINDOW . ' seconds');
418
419 $recent = Conversation::where('ticket_id', $ticket->id)
420 ->where('person_id', $agent->id)
421 ->where('source', 'mcp')
422 ->whereIn('conversation_type', ['response', 'draft_response'])
423 ->where('created_at', '>=', $model->fromDateTime($since))
424 ->orderBy('id', 'desc')
425 ->limit(5)
426 ->get();
427
428 foreach ($recent as $conversation) {
429 if ($conversation->getSettingsValue('mcp_request_hash') === $requestHash) {
430 return $conversation;
431 }
432 }
433
434 return null;
435 }
436
437 /** Rows update-response and delete-response may touch: drafts and notes, never sent replies. */
438 const EDITABLE_TYPES = ['draft_response', 'note', 'internal_info'];
439
440 public static function updateResponse($params)
441 {
442 $found = self::findOwnConversation($params, 'response_id', self::EDITABLE_TYPES, __('Draft or note', 'fluent-support'), [__CLASS__, 'scopeEditable']);
443 if (is_wp_error($found)) {
444 return $found;
445 }
446 [$ticket, $row] = $found;
447
448 $format = $params['content_format'] ?? 'markdown';
449 $content = wp_kses_post(MCPHelper::processContent($params['content'] ?? '', $format));
450 if (!$content) {
451 return MCPHelper::error('invalid_param', __('content is required', 'fluent-support'), ['fields' => ['content']]);
452 }
453
454 $row->content = self::parseSmartcodes($content, $ticket);
455 $row->save();
456
457 $summary = $row->conversation_type === 'draft_response'
458 /* translators: %d is the ticket ID */
459 ? sprintf(__('Draft reply updated on ticket #%d. It is still not sent.', 'fluent-support'), $ticket->id)
460 /* translators: %d is the ticket ID */
461 : sprintf(__('Internal note updated on ticket #%d.', 'fluent-support'), $ticket->id);
462
463 return MCPHelper::envelope($summary, ['response' => [
464 'id' => $row->id,
465 'content' => MCPHelper::htmlToMarkdown($row->content),
466 'type' => $row->conversation_type,
467 'created_at' => MCPHelper::toIso8601($row->created_at),
468 ]]);
469 }
470
471 public static function deleteResponse($params)
472 {
473 $found = self::findOwnConversation($params, 'response_id', self::EDITABLE_TYPES, __('Draft or note', 'fluent-support'), [__CLASS__, 'scopeEditable']);
474 if (is_wp_error($found)) {
475 return $found;
476 }
477 [$ticket, $row] = $found;
478
479 // Like the admin, deleting a note needs the delete permission. An author may
480 // always discard their own unsent draft.
481 if ($row->conversation_type !== 'draft_response' && !PermissionManager::currentUserCan('fst_delete_tickets')) {
482 return MCPHelper::error('forbidden', __('Deleting a note needs the "Delete Tickets & Individual Responses" permission.', 'fluent-support'));
483 }
484
485 $deleted = ['id' => $row->id, 'type' => $row->conversation_type];
486 $row->delete();
487 $row->ccinfo()->delete();
488
489 return MCPHelper::envelope(
490 /* translators: %d is the ticket ID */
491 sprintf(__('Deleted from ticket #%d.', 'fluent-support'), $ticket->id),
492 ['deleted' => $deleted]
493 );
494 }
495
496 /**
497 * Fill {{customer.*}} / {{agent.*}} placeholders, as createResponse() does for new
498 * replies, so edited text doesn't reach the customer with literal braces.
499 */
500 private static function parseSmartcodes($content, Ticket $ticket)
501 {
502 $content = apply_filters('fluent_support/parse_smartcode_data', $content, [
503 'customer' => $ticket->customer,
504 'agent' => MCPHelper::resolveAgent(),
505 ]);
506
507 return wp_kses_post($content);
508 }
509
510 /**
511 * Notes are 'note'. Older versions of add-internal-note wrote 'internal_info',
512 * which system lines ("Ticket has been closed") also use, so only MCP-made
513 * rows of that type count.
514 */
515 public static function scopeEditable($query)
516 {
517 $query->where(function ($q) {
518 $q->whereIn('conversation_type', ['draft_response', 'note'])
519 ->orWhere(function ($q) {
520 $q->where('conversation_type', 'internal_info')->where('source', 'mcp');
521 });
522 });
523 }
524
525 /**
526 * Send a draft to the customer. Anyone with "Approve Draft Reply" may publish
527 * any draft; an agent who can manage tickets may publish their own. Draft-only
528 * agents never can: their drafts exist to be reviewed by someone else.
529 */
530 public static function publishDraftReply($params)
531 {
532 $agent = MCPHelper::resolveAgent();
533 $canApprove = PermissionManager::currentUserCan('fst_approve_draft_reply');
534 $canManage = PermissionManager::userCan(AbilityGuard::MANAGE_TICKET_CAPS);
535
536 $found = self::findOwnConversation(
537 $params,
538 'response_id',
539 ['draft_response'],
540 __('Draft reply', 'fluent-support'),
541 null,
542 $canApprove // an approver may publish anyone's draft
543 );
544 if (is_wp_error($found)) {
545 return $found;
546 }
547 [$ticket, $draft] = $found;
548
549 if (!$canApprove && !($canManage && $agent && (int) $draft->person_id === (int) $agent->id)) {
550 return MCPHelper::error('forbidden', __('You can only save drafts. An agent with the "Approve Draft Reply" permission must publish this one.', 'fluent-support'));
551 }
552
553 $content = $draft->content;
554 if (!empty($params['content'])) {
555 $content = self::parseSmartcodes(wp_kses_post(MCPHelper::processContent($params['content'], $params['content_format'] ?? 'markdown')), $ticket);
556 }
557
558 $published = (new ResponseService())->publishDraft($ticket, $draft, $agent, $content);
559
560 return MCPHelper::envelope(
561 /* translators: %d is the ticket ID */
562 sprintf(__('Draft reply published on ticket #%d and sent to the customer.', 'fluent-support'), $ticket->id),
563 [
564 'response' => [
565 'id' => $published->id,
566 'content' => MCPHelper::htmlToMarkdown($published->content),
567 'type' => $published->conversation_type,
568 'created_at' => MCPHelper::toIso8601($published->created_at),
569 ],
570 'ticket_status' => $ticket->status,
571 ]
572 );
573 }
574
575 /**
576 * Row gate for editing or deleting a conversation: it must be on a ticket the
577 * agent can access, of an allowed type, and written by this agent. Anyone else's
578 * draft or note is off limits here, whatever the agent's other permissions.
579 *
580 * @return array|\WP_Error [Ticket, Conversation]
581 */
582 private static function findOwnConversation($params, $idField, array $types, $label, $scope = null, $anyAuthor = false)
583 {
584 $agent = MCPHelper::resolveAgent();
585 if (!$agent) {
586 return MCPHelper::error('unauthorized', __('No agent found for current user', 'fluent-support'));
587 }
588
589 $ticketId = (int) ($params['ticket_id'] ?? 0);
590 $rowId = (int) ($params[$idField] ?? 0);
591 if (!$ticketId || !$rowId) {
592 /* translators: %s is a parameter name */
593 return MCPHelper::error('invalid_param', sprintf(__('ticket_id and %s are required', 'fluent-support'), $idField), ['fields' => ['ticket_id', $idField]]);
594 }
595
596 $ticket = Ticket::find($ticketId);
597 if (!$ticket) {
598 return MCPHelper::error('not_found', __('Ticket not found', 'fluent-support'), ['next_step' => 'Use list-tickets to find valid ticket IDs']);
599 }
600
601 if ($err = TicketAccessGuard::assert($ticket)) {
602 return $err;
603 }
604
605 $query = Conversation::where('id', $rowId)
606 ->where('ticket_id', $ticket->id)
607 ->whereIn('conversation_type', $types);
608 if ($scope) {
609 $scope($query);
610 }
611 $row = $query->first();
612
613 if (!$row && Conversation::where('id', $rowId)->where('ticket_id', $ticket->id)->where('conversation_type', 'response')->exists()) {
614 return MCPHelper::error(
615 'not_editable',
616 __('This is a sent reply. The customer already has it, so it cannot be changed or deleted here.', 'fluent-support'),
617 ['fields' => [$idField], 'retryable' => false]
618 );
619 }
620
621 if (!$row) {
622 return MCPHelper::error(
623 'not_found',
624 /* translators: %s is "Draft reply" or "Internal note" */
625 sprintf(__('%s not found on this ticket', 'fluent-support'), $label),
626 ['fields' => [$idField], 'next_step' => 'Use get-ticket to find it: your drafts are type draft_response and your notes type note. Sent replies and internal_info activity lines cannot be changed.']
627 );
628 }
629
630 if (!$anyAuthor && (int) $row->person_id !== (int) $agent->id) {
631 return MCPHelper::error('forbidden', __('This was written by another agent. Only its author can change or delete it.', 'fluent-support'));
632 }
633
634 return [$ticket, $row];
635 }
636
637 public static function addInternalNote($params)
638 {
639 $agent = MCPHelper::resolveAgent();
640 if (!$agent) {
641 return MCPHelper::error('unauthorized', __('No agent found for current user', 'fluent-support'));
642 }
643
644 $ticketId = (int) ($params['ticket_id'] ?? 0);
645 if (!$ticketId) {
646 return MCPHelper::error('invalid_param', __('ticket_id is required', 'fluent-support'), ['fields' => ['ticket_id']]);
647 }
648
649 $format = $params['content_format'] ?? 'markdown';
650 $content = wp_kses_post(MCPHelper::processContent($params['content'] ?? '', $format));
651 if (!$content) {
652 return MCPHelper::error('invalid_param', __('content is required', 'fluent-support'), ['fields' => ['content']]);
653 }
654
655 $ticket = Ticket::find($ticketId);
656 if (!$ticket) {
657 return MCPHelper::error('not_found', __('Ticket not found', 'fluent-support'), ['next_step' => 'Use list-tickets to find valid ticket IDs']);
658 }
659
660 if ($err = TicketAccessGuard::assert($ticket)) {
661 return $err;
662 }
663
664 // 'note', as the ticket view writes it: it renders as an agent note and fires
665 // note_added_by_agent, which notifies @mentioned agents and logs the activity.
666 $attachments = AttachmentStager::stage($params['attachments'] ?? null, $ticket, $agent);
667 if (is_wp_error($attachments)) {
668 return $attachments;
669 }
670
671 $data = [
672 'content' => $content,
673 'conversation_type' => 'note',
674 'source' => 'mcp',
675 ];
676
677 if ($attachments) {
678 $data['attachments'] = $attachments;
679 }
680
681 $result = (new ResponseService())->createResponse($data, $agent, $ticket, true);
682
683 if (!$result) {
684 return MCPHelper::error('failed', __('Failed to create internal note', 'fluent-support'), ['retryable' => true]);
685 }
686
687 (new ResponseService())->fireResponseHooks($result['response'], $result['ticket'], $agent, false);
688
689 return MCPHelper::envelope(
690 "Internal note added to ticket #{$ticketId}",
691 ['note' => array_filter([
692 'id' => $result['response']->id,
693 'content' => MCPHelper::htmlToMarkdown($result['response']->content),
694 'created_at' => MCPHelper::toIso8601($result['response']->created_at),
695 'attachments' => $attachments ? self::attachmentTitles($result['response']) : null,
696 'mentions' => self::mentionReport($content),
697 ], function ($value) { return $value !== null; })]
698 );
699 }
700 }
701