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/ResponseTools.php +484 -150 2.3.0 → 2.4.5 View file →
@@ -1,17 +1,24 @@
1 1 <?php
2 2
3 3 namespace FluentSupport\App\Modules\MCP\Tools;
4 4
5 +use FluentSupport\App\Models\Conversation;
5 6 use FluentSupport\App\Models\SavedReply;
6 7 use FluentSupport\App\Models\Ticket;
7 8 use FluentSupport\App\Modules\MCP\Helpers\MCPHelper;
9 +use FluentSupport\App\Modules\MCP\Support\AbilityGuard;
10 +use FluentSupport\App\Modules\MCP\Support\AttachmentStager;
8 11 use FluentSupport\App\Modules\MCP\Support\TicketAccessGuard;
9 12 use FluentSupport\App\Modules\PermissionManager;
10 13 use FluentSupport\App\Services\Tickets\ResponseService;
14 +use FluentSupport\App\Services\Tickets\TicketService;
11 15
12 16 class ResponseTools
13 17 {
18 + /** Seconds within which an identical MCP reply is treated as a retry, not a new reply. */
19 + const DUPLICATE_WINDOW = 600;
20 +
14 21 public static function listSavedReplies($params)
15 22 {
16 23 $agent = MCPHelper::resolveAgent();
17 24 if (!$agent) {
@@ -44,135 +51,14 @@
44 51 ['total' => $count]
45 52 );
46 53 }
47 54
48 - public static function createSavedReply($params)
49 - {
50 - $agent = MCPHelper::resolveAgent();
51 - if (!$agent) {
52 - return MCPHelper::error('unauthorized', __('No agent found for current user', 'fluent-support'));
53 - }
54 -
55 - $title = sanitize_text_field($params['title'] ?? '');
56 - $hasContent = isset($params['content']) && $params['content'] !== '';
57 - if ($title === '' || !$hasContent) {
58 - return MCPHelper::error('invalid_param', __('title and content are required', 'fluent-support'), ['fields' => ['title', 'content']]);
59 - }
60 -
61 - $productId = null;
62 - if (array_key_exists('product_id', $params) && $params['product_id'] !== null && $params['product_id'] !== '') {
63 - $productId = (int) $params['product_id'];
64 - if (!\FluentSupport\App\Models\Product::find($productId)) {
65 - return MCPHelper::error('invalid_param', __('The specified product does not exist', 'fluent-support'), ['fields' => ['product_id'], 'next_step' => 'Use get-support-context to see available products and their IDs']);
66 - }
67 - }
68 -
69 - $format = $params['content_format'] ?? 'markdown';
70 - $content = wp_kses_post(MCPHelper::processContent($params['content'], $format));
71 -
72 - $reply = SavedReply::create([
73 - 'created_by' => $agent->id,
74 - 'title' => $title,
75 - 'content' => $content,
76 - 'product_id' => $productId,
77 - ]);
78 -
79 - return MCPHelper::envelope(
80 - sprintf(__('Saved reply "%s" created', 'fluent-support'), $title),
81 - ['saved_reply' => self::formatSavedReply($reply)]
82 - );
83 - }
84 -
85 - public static function updateSavedReply($params)
86 - {
87 - $agent = MCPHelper::resolveAgent();
88 - if (!$agent) {
89 - return MCPHelper::error('unauthorized', __('No agent found for current user', 'fluent-support'));
90 - }
91 -
92 - $id = (int) ($params['id'] ?? 0);
93 - if (!$id) {
94 - return MCPHelper::error('invalid_param', __('id is required', 'fluent-support'), ['fields' => ['id']]);
95 - }
96 -
97 - $reply = SavedReply::find($id);
98 - if (!$reply) {
99 - return MCPHelper::error('not_found', __('Saved reply not found', 'fluent-support'), ['next_step' => 'Use list-saved-replies to find valid IDs']);
100 - }
101 -
102 - if ((int) $reply->created_by !== (int) $agent->id && !PermissionManager::currentUserCan('fst_manage_settings')) {
103 - return MCPHelper::error('forbidden', __('You do not have permission to update this saved reply', 'fluent-support'), ['retryable' => false]);
104 - }
105 -
106 - if (array_key_exists('title', $params)) {
107 - $title = sanitize_text_field($params['title']);
108 - if ($title === '') {
109 - return MCPHelper::error('invalid_param', __('title cannot be empty', 'fluent-support'), ['fields' => ['title']]);
110 - }
111 - $reply->title = $title;
112 - }
113 -
114 - if (array_key_exists('content', $params)) {
115 - if ($params['content'] === '' || $params['content'] === null) {
116 - return MCPHelper::error('invalid_param', __('content cannot be empty', 'fluent-support'), ['fields' => ['content']]);
117 - }
118 - $format = $params['content_format'] ?? 'markdown';
119 - $reply->content = wp_kses_post(MCPHelper::processContent($params['content'], $format));
120 - }
121 -
122 - if (array_key_exists('product_id', $params)) {
123 - if ($params['product_id'] === null || $params['product_id'] === '') {
124 - $reply->product_id = null;
125 - } else {
126 - $productId = (int) $params['product_id'];
127 - if (!\FluentSupport\App\Models\Product::find($productId)) {
128 - return MCPHelper::error('invalid_param', __('The specified product does not exist', 'fluent-support'), ['fields' => ['product_id'], 'next_step' => 'Use get-support-context to see available products and their IDs']);
129 - }
130 - $reply->product_id = $productId;
131 - }
132 - }
133 -
134 - $reply->save();
135 -
136 - return MCPHelper::envelope(
137 - sprintf(__('Saved reply "%s" updated', 'fluent-support'), $reply->title),
138 - ['saved_reply' => self::formatSavedReply($reply)]
139 - );
140 - }
141 -
142 - public static function deleteSavedReply($params)
143 - {
144 - $agent = MCPHelper::resolveAgent();
145 - if (!$agent) {
146 - return MCPHelper::error('unauthorized', __('No agent found for current user', 'fluent-support'));
147 - }
148 -
149 - $id = (int) ($params['id'] ?? 0);
150 - if (!$id) {
151 - return MCPHelper::error('invalid_param', __('id is required', 'fluent-support'), ['fields' => ['id']]);
152 - }
153 -
154 - $reply = SavedReply::find($id);
155 - if (!$reply) {
156 - return MCPHelper::error('not_found', __('Saved reply not found', 'fluent-support'), ['next_step' => 'Use list-saved-replies to find valid IDs']);
157 - }
158 -
159 - if ((int) $reply->created_by !== (int) $agent->id && !PermissionManager::currentUserCan('fst_manage_settings')) {
160 - return MCPHelper::error('forbidden', __('You do not have permission to delete this saved reply', 'fluent-support'), ['retryable' => false]);
161 - }
162 -
163 - $title = $reply->title;
164 - $reply->delete();
165 -
166 - return MCPHelper::envelope(sprintf(__('Saved reply "%s" deleted', 'fluent-support'), $title), ['id' => $id]);
167 - }
168 -
169 55 private static function formatSavedReply($reply)
170 56 {
171 57 return [
172 58 'id' => $reply->id,
173 59 'title' => $reply->title,
174 - 'content' => MCPHelper::htmlToText($reply->content),
60 + 'content' => MCPHelper::htmlToMarkdown($reply->content),
175 61 'product_id' => $reply->product_id,
176 62 ];
177 63 }
178 64
@@ -217,16 +103,55 @@
217 103 if ($err = TicketAccessGuard::assert($ticket)) {
218 104 return $err;
219 105 }
220 106
221 - if ($ticket->status === 'closed') {
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'])) {
222 114 return MCPHelper::error(
223 - 'ticket_closed',
224 - __('Cannot reply to a closed ticket. Reopen it first, then reply.', 'fluent-support'),
225 - ['next_step' => 'Call reopen-ticket to reopen this ticket before replying', 'retryable' => false]
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']
226 118 );
227 119 }
228 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 +
229 154 // Resolve assignment intent so assign + reply happen in one call.
230 155 // Explicit assignee_id requires fst_assign_agents (resolveAssignmentTarget).
231 156 // Otherwise, replying to an unassigned ticket takes ownership (self),
232 157 // which needs no extra permission. A ticket already assigned to someone
@@ -250,62 +175,111 @@
250 175 if (!empty($params['close_ticket'])) {
251 176 $data['close_ticket'] = 'yes';
252 177 }
253 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 +
254 189 // Assign + reply (+ optional close) must be atomic: a failure in the
255 - // reply rolls back the assignment too. The assignment notification side
256 - // effects (email/webhooks) are deferred until AFTER the transaction
257 - // commits — firing them inside the transaction would leak an
258 - // "assigned to you" email even when the reply fails and the assignment
259 - // is rolled back.
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.
260 195 $result = null;
261 196 $assigned = false;
262 197 $previousAgentId = $ticket->agent_id;
263 198 try {
264 - (new Ticket())->getConnection()->transaction(function () use (&$result, &$assigned, $assignTarget, $ticket, $agent, $data) {
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 +
265 204 if ($assignTarget) {
266 205 $assigned = MCPHelper::applyAgentAssignment($ticket, $assignTarget, $agent, true);
267 206 }
268 207
269 - $result = (new ResponseService())->createResponse($data, $agent, $ticket);
208 + $result = (new ResponseService())->createResponse($data, $agent, $ticket, true);
270 209
271 210 if (!$result) {
272 - throw new \Exception('reply_failed'); // roll back the assignment
211 + throw new \Exception('reply_failed'); // roll back the reopen and assignment
273 212 }
213 +
214 + $result['response']->updateSettingsValue('mcp_request_hash', $requestHash);
274 215 });
275 216 } catch (\Throwable $e) {
276 217 return MCPHelper::error('failed', __('Failed to create response', 'fluent-support'), ['retryable' => true]);
277 218 }
278 219
279 - // Reply + assignment committed — now fire the assignment side effects.
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 +
280 226 if ($assigned) {
281 227 $ticket->load('agent');
282 228 MCPHelper::fireAgentAssignmentSideEffects($ticket, $agent, $previousAgentId);
283 229 }
284 230
231 + (new ResponseService())->fireResponseHooks($result['response'], $result['ticket'], $agent, $result['closed']);
232 +
285 233 $ticketStatus = $result['ticket']->status;
286 234
287 - $assignNote = '';
288 - if ($assigned) {
289 - $assignNote = ((int) $assignTarget->id === (int) $agent->id)
290 - ? __(' and assigned to you', 'fluent-support')
291 - : sprintf(__(' and assigned to %s', 'fluent-support'), MCPHelper::personName($assignTarget));
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);
292 256 }
293 257
294 - $summary = $ticketStatus === 'closed'
295 - ? sprintf(__('Reply sent and ticket #%d closed', 'fluent-support'), $ticketId) . $assignNote
296 - : sprintf(__('Reply added to ticket #%d', 'fluent-support'), $ticketId) . $assignNote;
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 + }
297 262
298 263 $payload = [
299 264 'response' => [
300 265 'id' => $result['response']->id,
301 - 'content' => MCPHelper::htmlToText($result['response']->content),
266 + 'content' => MCPHelper::htmlToMarkdown($result['response']->content),
302 267 'type' => $result['response']->conversation_type,
303 268 'created_at' => MCPHelper::toIso8601($result['response']->created_at),
304 269 ],
305 270 'ticket_status' => $ticketStatus,
271 + 'reopened' => $reopen,
306 272 ];
307 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 +
308 282 if ($assigned) {
309 283 $payload['assigned_to'] = [
310 284 'id' => (int) $assignTarget->id,
311 285 'name' => MCPHelper::personName($assignTarget),
@@ -314,8 +288,353 @@
314 288
315 289 return MCPHelper::envelope($summary, $payload);
316 290 }
317 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 +
318 637 public static function addInternalNote($params)
319 638 {
320 639 $agent = MCPHelper::resolveAgent();
321 640 if (!$agent) {
@@ -341,14 +660,25 @@
341 660 if ($err = TicketAccessGuard::assert($ticket)) {
342 661 return $err;
343 662 }
344 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 +
345 671 $data = [
346 672 'content' => $content,
347 - 'conversation_type' => 'internal_info',
673 + 'conversation_type' => 'note',
348 674 'source' => 'mcp',
349 675 ];
350 676
677 + if ($attachments) {
678 + $data['attachments'] = $attachments;
679 + }
680 +
351 681 $result = (new ResponseService())->createResponse($data, $agent, $ticket, true);
352 682
353 683 if (!$result) {
354 684 return MCPHelper::error('failed', __('Failed to create internal note', 'fluent-support'), ['retryable' => true]);
@@ -353,14 +683,18 @@
353 683 if (!$result) {
354 684 return MCPHelper::error('failed', __('Failed to create internal note', 'fluent-support'), ['retryable' => true]);
355 685 }
356 686
687 + (new ResponseService())->fireResponseHooks($result['response'], $result['ticket'], $agent, false);
688 +
357 689 return MCPHelper::envelope(
358 690 "Internal note added to ticket #{$ticketId}",
359 - ['note' => [
360 - 'id' => $result['response']->id,
361 - 'content' => MCPHelper::htmlToText($result['response']->content),
362 - 'created_at' => MCPHelper::toIso8601($result['response']->created_at),
363 - ]]
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; })]
364 698 );
365 699 }
366 700 }