PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.18
MxChat – AI Chatbot & Content Generation for WordPress v3.2.18
3.2.21 3.2.20 3.2.19 3.2.18 3.2.17 3.2.16 3.2.15 3.2.14 3.2.12 3.2.13 3.2.11 3.2.10 3.2.9 3.2.8 3.2.7 3.2.6 3.2.5 3.2.4 3.2.3 3.2.2 3.2.1 2.0.3 2.0.4 2.0.5 2.0.6 All 152 releases
mxchat-basic / includes / class-mxchat-word-handler.php

class-mxchat-word-handler.php in MxChat – AI Chatbot & Content Generation for WordPress 3.2.18, at includes/class-mxchat-word-handler.php

397 lines 14.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Word document handler and processor for MXChat
4 * Can be directly bundled in WordPress plugins
5 */
6 class MXChat_Word_Handler {
7 private $temp_dir;
8 private $options;
9
10 public function __construct($options) {
11 $this->options = $options;
12 $this->temp_dir = wp_upload_dir()['path'];
13 }
14
15 /**
16 * Handle Word document upload and processing
17 */
18 public function mxchat_handle_word_upload() {
19 // Match the PDF handler's nonce verification: the widget sends the chat-send
20 // nonce (action 'mxchat_chat_send'), which the old check_ajax_referer('mxchat_chat_nonce')
21 // rejected with -1. mxchat_verify_chat_send_nonce accepts both chat-send and chat nonces.
22 if (!isset($_POST['nonce']) || !MxChat_Integrator::mxchat_verify_chat_send_nonce(wp_unslash((string) $_POST['nonce']))) {
23 wp_send_json_error(array('message' => esc_html__('Invalid nonce.', 'mxchat')), 403);
24 }
25
26 if (!isset($_FILES['word_file']) || !isset($_POST['session_id'])) {
27 wp_send_json_error(esc_html__('Missing required parameters.', 'mxchat'));
28 return;
29 }
30
31 // SECURITY FIX: Check if Word uploads are enabled in settings
32 $options = get_option('mxchat_options', array());
33 $show_word_button = isset($options['show_word_upload_button']) ? $options['show_word_upload_button'] : 'on';
34
35 if ($show_word_button !== 'on') {
36 wp_send_json_error(esc_html__('Word document uploads are currently disabled.', 'mxchat'));
37 return;
38 }
39
40 $file = $_FILES['word_file'];
41 $session_id = MxChat_Utils::sanitize_session_id(wp_unslash($_POST['session_id']));
42 $original_filename = sanitize_text_field($file['name']);
43
44 // Update session owner if it changed (e.g. IP changed due to network switch)
45 $current_user_identifier = MxChat_User::mxchat_get_user_identifier();
46 $session_owner = MxChat_Session_Store::get($session_id, 'owner');
47
48 if (!$session_owner || $session_owner !== $current_user_identifier) {
49 MxChat_Session_Store::set($session_id, 'owner', $current_user_identifier);
50 }
51
52 // Check file type
53 $allowed_types = array(
54 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
55 );
56 $file_type = wp_check_filetype($file['name'], $allowed_types);
57
58 if (!$file_type['type']) {
59 wp_send_json_error(esc_html__('Invalid file type. Only .docx files are allowed.', 'mxchat'));
60 return;
61 }
62
63 // SECURITY FIX: Generate random filename without exposing session_id
64 $random_string = wp_generate_password(20, false, false); // 20 char alphanumeric string
65 $word_filename = 'mxchat_word_' . $random_string . '_' . time() . '.docx';
66 $word_path = $this->temp_dir . '/' . $word_filename;
67
68 if (!move_uploaded_file($file['tmp_name'], $word_path)) {
69 wp_send_json_error(esc_html__('Failed to upload file.', 'mxchat'));
70 return;
71 }
72
73 $this->mxchat_clear_word_transients($session_id);
74
75 // Process the document
76 $embeddings = $this->mxchat_process_word_document($word_path);
77
78 if ($embeddings === false || empty($embeddings)) {
79 unlink($word_path);
80 $error_message = $this->options['word_intent_error_text'] ??
81 esc_html__('The uploaded document appears to be empty or contains unsupported content.', 'mxchat');
82 wp_send_json_error($error_message);
83 return;
84 }
85
86 // Store the mapping between session and the random filename
87 set_transient('mxchat_word_url_' . $session_id, $word_path, HOUR_IN_SECONDS);
88 set_transient('mxchat_word_filename_' . $session_id, $original_filename, HOUR_IN_SECONDS);
89 set_transient('mxchat_word_embeddings_' . $session_id, $embeddings, HOUR_IN_SECONDS);
90 set_transient('mxchat_include_word_in_context_' . $session_id, true, HOUR_IN_SECONDS);
91
92 $success_message = $this->options['pdf_intent_success_text'] ??
93 __("I've processed the document. What questions do you have about it?", 'mxchat');
94
95 wp_send_json_success([
96 'message' => $success_message,
97 'filename' => $original_filename
98 ]);
99 }
100
101 /**
102 * Process Word document and generate embeddings
103 */
104 private function mxchat_process_word_document($file_path) {
105 // Get the maximum number of pages allowed from admin settings
106 $max_pages = isset($this->options['pdf_max_pages']) ? intval($this->options['pdf_max_pages']) : 69; // Use same setting as PDF
107
108 try {
109 $zip = new ZipArchive();
110 if ($zip->open($file_path) !== true) {
111 return false;
112 }
113
114 // Extract main document content
115 $content = $zip->getFromName('word/document.xml');
116 $zip->close();
117
118 if ($content === false) {
119 return false;
120 }
121
122 // Clean up the content
123 $text = $this->mxchat_clean_word_content($content);
124
125 // Count pages (roughly estimate based on paragraphs)
126 $paragraphs = explode("\n\n", $text);
127 $estimated_pages = ceil(count($paragraphs) / 3); // Assume ~3 paragraphs per page
128
129 if ($estimated_pages > $max_pages) {
130 return esc_html__('too_many_pages', 'mxchat');
131 }
132
133 // Split into chunks and continue processing...
134 $chunks = $this->mxchat_split_word_into_chunks($text, 1000);
135
136 $embeddings = [];
137 foreach ($chunks as $chunk_number => $chunk) {
138 if (empty(trim($chunk))) {
139 continue;
140 }
141
142 $embedding = $this->mxchat_generate_embedding_word(
143 esc_html__('Chunk ', 'mxchat') . ($chunk_number + 1) . ': ' . $chunk,
144 $this->options['api_key']
145 );
146
147 if ($embedding) {
148 $embeddings[] = [
149 'chunk_number' => $chunk_number + 1,
150 'embedding' => $embedding,
151 'text' => $chunk,
152 ];
153 }
154 }
155
156 return $embeddings;
157
158 } catch (Exception $e) {
159 return false;
160 }
161 }
162 /**
163 * Clean Word XML content
164 */
165 private function mxchat_clean_word_content($content) {
166 // Remove XML namespaces
167 $content = preg_replace('/xmlns[^=]*="[^"]*"/i', '', $content);
168
169 // Convert Word XML elements to text
170 $content = str_replace('</w:p>', "\n", $content);
171 $content = str_replace('</w:tr>', "\n", $content);
172
173 // Strip remaining XML tags
174 $content = strip_tags($content);
175
176 // Clean up whitespace
177 $content = preg_replace('/\s+/', ' ', $content);
178 $content = preg_replace('/\n\s*\n/', "\n\n", $content);
179
180 return trim($content);
181 }
182
183 /**
184 * Split text into manageable chunks
185 */
186 private function mxchat_split_word_into_chunks($text, $chunk_size) {
187 $chunks = [];
188 $paragraphs = explode("\n\n", $text);
189
190 $current_chunk = '';
191 foreach ($paragraphs as $paragraph) {
192 if (strlen($current_chunk) + strlen($paragraph) > $chunk_size) {
193 if (!empty($current_chunk)) {
194 $chunks[] = trim($current_chunk);
195 }
196 $current_chunk = $paragraph;
197 } else {
198 $current_chunk .= (!empty($current_chunk) ? "\n\n" : '') . $paragraph;
199 }
200 }
201
202 if (!empty($current_chunk)) {
203 $chunks[] = trim($current_chunk);
204 }
205
206 return $chunks;
207 }
208
209 /**
210 * Remove Word document and clean up transients
211 */
212 public function mxchat_handle_word_remove() {
213 check_ajax_referer('mxchat_chat_nonce', 'nonce');
214
215 if (empty($_POST['session_id'])) {
216 wp_send_json_error(esc_html__('Session ID missing.', 'mxchat'));
217 return;
218 }
219
220 $session_id = MxChat_Utils::sanitize_session_id(wp_unslash($_POST['session_id']));
221 if ($session_id === '') {
222 wp_send_json_error(esc_html__('Session ID missing.', 'mxchat'));
223 return;
224 }
225
226 // Session-ownership bookkeeping — same rule as handle_pdf_remove() and the
227 // history endpoint (plan-mxchat-20260731-d42bec). Possession of the session
228 // id is the credential, so this records/refreshes the owner rather than
229 // refusing a mismatch; see the fuller note in handle_pdf_remove().
230 $current_user_identifier = MxChat_User::mxchat_get_user_identifier();
231 $session_owner = MxChat_Session_Store::get($session_id, 'owner');
232 if (!$session_owner || $session_owner !== $current_user_identifier) {
233 MxChat_Session_Store::set($session_id, 'owner', $current_user_identifier);
234 }
235
236 $word_path = get_transient('mxchat_word_url_' . $session_id);
237
238 if ($word_path && file_exists($word_path)) {
239 unlink($word_path);
240 }
241
242 $this->mxchat_clear_word_transients($session_id);
243
244 wp_send_json_success([
245 'message' => esc_html__('Document removed successfully.', 'mxchat')
246 ]);
247 }
248
249 /**
250 * Clear all Word-related transients
251 */
252 private function mxchat_clear_word_transients($session_id) {
253 delete_transient('mxchat_word_url_' . $session_id);
254 delete_transient('mxchat_word_filename_' . $session_id);
255 delete_transient('mxchat_word_embeddings_' . $session_id);
256 delete_transient('mxchat_include_word_in_context_' . $session_id);
257 }
258
259 /**
260 * Find relevant chunks from the Word document
261 */
262 public function mxchat_find_relevant_word_chunks($query_embedding, $embeddings) {
263 $most_relevant = null;
264 $highest_similarity = -INF;
265
266 foreach ($embeddings as $chunk_data) {
267 $similarity = $this->mxchat_calculate_cosine_similarity_word($query_embedding, $chunk_data['embedding']);
268
269 if ($similarity > $highest_similarity) {
270 $highest_similarity = $similarity;
271 $most_relevant = $chunk_data['chunk_number'];
272 }
273 }
274
275 if (!is_null($most_relevant)) {
276 $chunk_numbers = range(
277 max(1, $most_relevant - 1),
278 min(count($embeddings), $most_relevant + 1)
279 );
280 return array_filter($embeddings, function ($chunk) use ($chunk_numbers) {
281 return in_array($chunk['chunk_number'], $chunk_numbers);
282 });
283 }
284
285 return [];
286 }
287
288 /**
289 * Handle Word document discussion similar to PDF discussion
290 */
291 public function mxchat_handle_word_discussion($message, $user_id, $session_id) {
292 // Get stored embeddings for the session
293 $embeddings = get_transient('mxchat_word_embeddings_' . $session_id);
294 $word_path = get_transient('mxchat_word_url_' . $session_id);
295
296 if (!$embeddings || !$word_path) {
297 $trigger_text = $this->options['word_intent_trigger_text'] ??
298 __("Please upload a Word document (.docx) that you'd like to discuss.", 'mxchat');
299 set_transient('mxchat_waiting_for_word_' . $session_id, true, HOUR_IN_SECONDS);
300 $this->fallbackResponse['text'] = $trigger_text;
301 return;
302 }
303
304 // Set context flag for including Word content in conversation
305 set_transient('mxchat_include_word_in_context_' . $session_id, true, HOUR_IN_SECONDS);
306 $this->fallbackResponse['text'] = ''; // Proceed without additional message
307 }
308
309
310 private function mxchat_generate_embedding_word($text, $api_key) {
311 $endpoint = 'https://api.openai.com/v1/embeddings';
312
313 $body = wp_json_encode([
314 'input' => $text,
315 'model' => 'text-embedding-ada-002'
316 ]);
317
318 $args = [
319 'body' => $body,
320 'headers' => [
321 'Content-Type' => 'application/json',
322 'Authorization' => 'Bearer ' . $api_key,
323 ],
324 'timeout' => 60,
325 'redirection' => 5,
326 'blocking' => true,
327 'httpversion' => '1.0',
328 'sslverify' => true,
329 ];
330
331 $response = wp_remote_post($endpoint, $args);
332
333 if (is_wp_error($response)) {
334 return null;
335 }
336
337 $response_body = json_decode(wp_remote_retrieve_body($response), true);
338
339 if (isset($response_body['data'][0]['embedding']) && is_array($response_body['data'][0]['embedding'])) {
340 return $response_body['data'][0]['embedding'];
341 } else {
342 return null;
343 }
344 }
345
346
347 private function mxchat_calculate_cosine_similarity_word($vectorA, $vectorB) {
348 if (!is_array($vectorA) || !is_array($vectorB) || empty($vectorA) || empty($vectorB)) {
349 return 0;
350 }
351
352 $dotProduct = array_sum(array_map(function ($a, $b) {
353 return $a * $b;
354 }, $vectorA, $vectorB));
355 $normA = sqrt(array_sum(array_map(function ($a) {
356 return $a * $a;
357 }, $vectorA)));
358 $normB = sqrt(array_sum(array_map(function ($b) {
359 return $b * $b;
360 }, $vectorB)));
361
362 if ($normA == 0 || $normB == 0) {
363 return 0;
364 }
365
366 return $dotProduct / ($normA * $normB);
367 }
368
369 /**
370 * Check the status of a Word document for the current session
371 */
372 public function mxchat_check_word_status() {
373 check_ajax_referer('mxchat_chat_nonce', 'nonce');
374
375 if (empty($_POST['session_id'])) {
376 wp_send_json_error(esc_html__('Session ID missing.', 'mxchat'));
377 return;
378 }
379
380 $session_id = MxChat_Utils::sanitize_session_id(wp_unslash($_POST['session_id']));
381 $word_path = get_transient('mxchat_word_url_' . $session_id);
382 $filename = get_transient('mxchat_word_filename_' . $session_id);
383
384 if ($word_path && file_exists($word_path) && $filename) {
385 wp_send_json_success([
386 'has_word' => true,
387 'filename' => $filename
388 ]);
389 } else {
390 wp_send_json_success([
391 'has_word' => false
392 ]);
393 }
394 }
395
396
397 }