PluginProbe
Fluent Support – Helpdesk & Customer Support Ticket System / trunk
Fluent Support – Helpdesk & Customer Support Ticket System vtrunk
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 1.5.5 1.5.6 All 67 releases
fluent-support / app / Modules / MCP / Tools / ResponseTools.php

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

367 lines 14.9 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\SavedReply;
6 use FluentSupport\App\Models\Ticket;
7 use FluentSupport\App\Modules\MCP\Helpers\MCPHelper;
8 use FluentSupport\App\Modules\MCP\Support\TicketAccessGuard;
9 use FluentSupport\App\Modules\PermissionManager;
10 use FluentSupport\App\Services\Tickets\ResponseService;
11
12 class ResponseTools
13 {
14 public static function listSavedReplies($params)
15 {
16 $agent = MCPHelper::resolveAgent();
17 if (!$agent) {
18 return MCPHelper::error('unauthorized', __('No agent found for current user', 'fluent-support'));
19 }
20
21 $query = SavedReply::where('created_by', $agent->id);
22
23 if (!empty($params['search'])) {
24 $search = sanitize_text_field($params['search']);
25 $query->where(function ($q) use ($search) {
26 $q->where('title', 'LIKE', "%{$search}%")
27 ->orWhere('content', 'LIKE', "%{$search}%");
28 });
29 }
30
31 if (!empty($params['product_id'])) {
32 $query->where('product_id', (int) $params['product_id']);
33 }
34
35 $replies = $query->orderBy('title', 'ASC')->get();
36
37 $count = $replies->count();
38
39 return MCPHelper::envelope(
40 sprintf(_n('Found %d saved reply', 'Found %d saved replies', $count, 'fluent-support'), $count),
41 ['saved_replies' => $replies->map(function ($reply) {
42 return self::formatSavedReply($reply);
43 })->toArray()],
44 ['total' => $count]
45 );
46 }
47
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 private static function formatSavedReply($reply)
170 {
171 return [
172 'id' => $reply->id,
173 'title' => $reply->title,
174 'content' => MCPHelper::htmlToText($reply->content),
175 'product_id' => $reply->product_id,
176 ];
177 }
178
179 public static function replyToTicket($params)
180 {
181 $agent = MCPHelper::resolveAgent();
182 if (!$agent) {
183 return MCPHelper::error('unauthorized', __('No agent found for current user', 'fluent-support'));
184 }
185
186 $ticketId = (int) ($params['ticket_id'] ?? 0);
187 if (!$ticketId) {
188 return MCPHelper::error('invalid_param', __('ticket_id is required', 'fluent-support'), ['fields' => ['ticket_id']]);
189 }
190
191 $content = '';
192
193 if (!empty($params['saved_reply_id'])) {
194 $savedReply = SavedReply::where('id', (int) $params['saved_reply_id'])
195 ->where('created_by', $agent->id)
196 ->first();
197 if (!$savedReply) {
198 return MCPHelper::error('not_found', __('Saved reply not found', 'fluent-support'), ['next_step' => 'Use list-saved-replies to find valid IDs']);
199 }
200 $content = wp_kses_post($savedReply->content);
201 }
202
203 if (!empty($params['content'])) {
204 $format = $params['content_format'] ?? 'markdown';
205 $content = wp_kses_post(MCPHelper::processContent($params['content'], $format));
206 }
207
208 if (!$content) {
209 return MCPHelper::error('invalid_param', __('content or saved_reply_id is required', 'fluent-support'), ['fields' => ['content', 'saved_reply_id']]);
210 }
211
212 $ticket = Ticket::find($ticketId);
213 if (!$ticket) {
214 return MCPHelper::error('not_found', __('Ticket not found', 'fluent-support'), ['next_step' => 'Use list-tickets to find valid ticket IDs']);
215 }
216
217 if ($err = TicketAccessGuard::assert($ticket)) {
218 return $err;
219 }
220
221 if ($ticket->status === 'closed') {
222 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]
226 );
227 }
228
229 // Resolve assignment intent so assign + reply happen in one call.
230 // Explicit assignee_id requires fst_assign_agents (resolveAssignmentTarget).
231 // Otherwise, replying to an unassigned ticket takes ownership (self),
232 // which needs no extra permission. A ticket already assigned to someone
233 // else is never silently reassigned.
234 $assignTarget = null;
235 if (array_key_exists('assignee_id', $params) && $params['assignee_id'] !== null && $params['assignee_id'] !== '') {
236 $assignTarget = MCPHelper::resolveAssignmentTarget($params['assignee_id'], $ticket, 'assignee_id');
237 if (is_wp_error($assignTarget)) {
238 return $assignTarget;
239 }
240 } elseif (empty($ticket->agent_id)) {
241 $assignTarget = $agent; // taking ownership — no fst_assign_agents needed
242 }
243
244 $data = [
245 'content' => $content,
246 'conversation_type' => 'response',
247 'source' => 'mcp',
248 ];
249
250 if (!empty($params['close_ticket'])) {
251 $data['close_ticket'] = 'yes';
252 }
253
254 // 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.
260 $result = null;
261 $assigned = false;
262 $previousAgentId = $ticket->agent_id;
263 try {
264 (new Ticket())->getConnection()->transaction(function () use (&$result, &$assigned, $assignTarget, $ticket, $agent, $data) {
265 if ($assignTarget) {
266 $assigned = MCPHelper::applyAgentAssignment($ticket, $assignTarget, $agent, true);
267 }
268
269 $result = (new ResponseService())->createResponse($data, $agent, $ticket);
270
271 if (!$result) {
272 throw new \Exception('reply_failed'); // roll back the assignment
273 }
274 });
275 } catch (\Throwable $e) {
276 return MCPHelper::error('failed', __('Failed to create response', 'fluent-support'), ['retryable' => true]);
277 }
278
279 // Reply + assignment committed — now fire the assignment side effects.
280 if ($assigned) {
281 $ticket->load('agent');
282 MCPHelper::fireAgentAssignmentSideEffects($ticket, $agent, $previousAgentId);
283 }
284
285 $ticketStatus = $result['ticket']->status;
286
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));
292 }
293
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;
297
298 $payload = [
299 'response' => [
300 'id' => $result['response']->id,
301 'content' => MCPHelper::htmlToText($result['response']->content),
302 'type' => $result['response']->conversation_type,
303 'created_at' => MCPHelper::toIso8601($result['response']->created_at),
304 ],
305 'ticket_status' => $ticketStatus,
306 ];
307
308 if ($assigned) {
309 $payload['assigned_to'] = [
310 'id' => (int) $assignTarget->id,
311 'name' => MCPHelper::personName($assignTarget),
312 ];
313 }
314
315 return MCPHelper::envelope($summary, $payload);
316 }
317
318 public static function addInternalNote($params)
319 {
320 $agent = MCPHelper::resolveAgent();
321 if (!$agent) {
322 return MCPHelper::error('unauthorized', __('No agent found for current user', 'fluent-support'));
323 }
324
325 $ticketId = (int) ($params['ticket_id'] ?? 0);
326 if (!$ticketId) {
327 return MCPHelper::error('invalid_param', __('ticket_id is required', 'fluent-support'), ['fields' => ['ticket_id']]);
328 }
329
330 $format = $params['content_format'] ?? 'markdown';
331 $content = wp_kses_post(MCPHelper::processContent($params['content'] ?? '', $format));
332 if (!$content) {
333 return MCPHelper::error('invalid_param', __('content is required', 'fluent-support'), ['fields' => ['content']]);
334 }
335
336 $ticket = Ticket::find($ticketId);
337 if (!$ticket) {
338 return MCPHelper::error('not_found', __('Ticket not found', 'fluent-support'), ['next_step' => 'Use list-tickets to find valid ticket IDs']);
339 }
340
341 if ($err = TicketAccessGuard::assert($ticket)) {
342 return $err;
343 }
344
345 $data = [
346 'content' => $content,
347 'conversation_type' => 'internal_info',
348 'source' => 'mcp',
349 ];
350
351 $result = (new ResponseService())->createResponse($data, $agent, $ticket, true);
352
353 if (!$result) {
354 return MCPHelper::error('failed', __('Failed to create internal note', 'fluent-support'), ['retryable' => true]);
355 }
356
357 return MCPHelper::envelope(
358 "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 ]]
364 );
365 }
366 }
367