PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.20
MxChat – AI Chatbot & Content Generation for WordPress v3.2.20
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.20, at includes/class-mxchat-word-handler.php

412 lines 14.8 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 * Extract plain text from a .docx file. THE one .docx parser — the
103 * visitor-facing toolbar upload (via mxchat_process_word_document) and the
104 * admin knowledge importer (plan 0485e5) both call this; do not fork a
105 * second copy. Returns the cleaned text, or false on unreadable/empty
106 * files (not a Zip, no word/document.xml, no text content).
107 */
108 public static function extract_docx_text($file_path) {
109 try {
110 $zip = new ZipArchive();
111 if ($zip->open($file_path) !== true) {
112 return false;
113 }
114 $content = $zip->getFromName('word/document.xml');
115 $zip->close();
116 if ($content === false) {
117 return false;
118 }
119 $text = self::clean_word_xml($content);
120 return $text === '' ? false : $text;
121 } catch (Exception $e) {
122 return false;
123 }
124 }
125
126 /**
127 * Process Word document and generate embeddings
128 */
129 private function mxchat_process_word_document($file_path) {
130 // Get the maximum number of pages allowed from admin settings
131 $max_pages = isset($this->options['pdf_max_pages']) ? intval($this->options['pdf_max_pages']) : 69; // Use same setting as PDF
132
133 try {
134 $text = self::extract_docx_text($file_path);
135 if ($text === false) {
136 return false;
137 }
138
139 // Count pages (roughly estimate based on paragraphs)
140 $paragraphs = explode("\n\n", $text);
141 $estimated_pages = ceil(count($paragraphs) / 3); // Assume ~3 paragraphs per page
142
143 if ($estimated_pages > $max_pages) {
144 return esc_html__('too_many_pages', 'mxchat');
145 }
146
147 // Split into chunks and continue processing...
148 $chunks = $this->mxchat_split_word_into_chunks($text, 1000);
149
150 $embeddings = [];
151 foreach ($chunks as $chunk_number => $chunk) {
152 if (empty(trim($chunk))) {
153 continue;
154 }
155
156 $embedding = $this->mxchat_generate_embedding_word(
157 esc_html__('Chunk ', 'mxchat') . ($chunk_number + 1) . ': ' . $chunk,
158 $this->options['api_key']
159 );
160
161 if ($embedding) {
162 $embeddings[] = [
163 'chunk_number' => $chunk_number + 1,
164 'embedding' => $embedding,
165 'text' => $chunk,
166 ];
167 }
168 }
169
170 return $embeddings;
171
172 } catch (Exception $e) {
173 return false;
174 }
175 }
176 /**
177 * Clean Word XML content (word/document.xml) to plain text.
178 * Static so both callers of extract_docx_text share one implementation.
179 */
180 public static function clean_word_xml($content) {
181 // Remove XML namespaces
182 $content = preg_replace('/xmlns[^=]*="[^"]*"/i', '', $content);
183
184 // Convert Word XML elements to text
185 $content = str_replace('</w:p>', "\n", $content);
186 $content = str_replace('</w:tr>', "\n", $content);
187
188 // Strip remaining XML tags
189 $content = strip_tags($content);
190
191 // Clean up whitespace
192 $content = preg_replace('/\s+/', ' ', $content);
193 $content = preg_replace('/\n\s*\n/', "\n\n", $content);
194
195 return trim($content);
196 }
197
198 /**
199 * Split text into manageable chunks
200 */
201 private function mxchat_split_word_into_chunks($text, $chunk_size) {
202 $chunks = [];
203 $paragraphs = explode("\n\n", $text);
204
205 $current_chunk = '';
206 foreach ($paragraphs as $paragraph) {
207 if (strlen($current_chunk) + strlen($paragraph) > $chunk_size) {
208 if (!empty($current_chunk)) {
209 $chunks[] = trim($current_chunk);
210 }
211 $current_chunk = $paragraph;
212 } else {
213 $current_chunk .= (!empty($current_chunk) ? "\n\n" : '') . $paragraph;
214 }
215 }
216
217 if (!empty($current_chunk)) {
218 $chunks[] = trim($current_chunk);
219 }
220
221 return $chunks;
222 }
223
224 /**
225 * Remove Word document and clean up transients
226 */
227 public function mxchat_handle_word_remove() {
228 check_ajax_referer('mxchat_chat_nonce', 'nonce');
229
230 if (empty($_POST['session_id'])) {
231 wp_send_json_error(esc_html__('Session ID missing.', 'mxchat'));
232 return;
233 }
234
235 $session_id = MxChat_Utils::sanitize_session_id(wp_unslash($_POST['session_id']));
236 if ($session_id === '') {
237 wp_send_json_error(esc_html__('Session ID missing.', 'mxchat'));
238 return;
239 }
240
241 // Session-ownership bookkeeping — same rule as handle_pdf_remove() and the
242 // history endpoint (plan-mxchat-20260731-d42bec). Possession of the session
243 // id is the credential, so this records/refreshes the owner rather than
244 // refusing a mismatch; see the fuller note in handle_pdf_remove().
245 $current_user_identifier = MxChat_User::mxchat_get_user_identifier();
246 $session_owner = MxChat_Session_Store::get($session_id, 'owner');
247 if (!$session_owner || $session_owner !== $current_user_identifier) {
248 MxChat_Session_Store::set($session_id, 'owner', $current_user_identifier);
249 }
250
251 $word_path = get_transient('mxchat_word_url_' . $session_id);
252
253 if ($word_path && file_exists($word_path)) {
254 unlink($word_path);
255 }
256
257 $this->mxchat_clear_word_transients($session_id);
258
259 wp_send_json_success([
260 'message' => esc_html__('Document removed successfully.', 'mxchat')
261 ]);
262 }
263
264 /**
265 * Clear all Word-related transients
266 */
267 private function mxchat_clear_word_transients($session_id) {
268 delete_transient('mxchat_word_url_' . $session_id);
269 delete_transient('mxchat_word_filename_' . $session_id);
270 delete_transient('mxchat_word_embeddings_' . $session_id);
271 delete_transient('mxchat_include_word_in_context_' . $session_id);
272 }
273
274 /**
275 * Find relevant chunks from the Word document
276 */
277 public function mxchat_find_relevant_word_chunks($query_embedding, $embeddings) {
278 $most_relevant = null;
279 $highest_similarity = -INF;
280
281 foreach ($embeddings as $chunk_data) {
282 $similarity = $this->mxchat_calculate_cosine_similarity_word($query_embedding, $chunk_data['embedding']);
283
284 if ($similarity > $highest_similarity) {
285 $highest_similarity = $similarity;
286 $most_relevant = $chunk_data['chunk_number'];
287 }
288 }
289
290 if (!is_null($most_relevant)) {
291 $chunk_numbers = range(
292 max(1, $most_relevant - 1),
293 min(count($embeddings), $most_relevant + 1)
294 );
295 return array_filter($embeddings, function ($chunk) use ($chunk_numbers) {
296 return in_array($chunk['chunk_number'], $chunk_numbers);
297 });
298 }
299
300 return [];
301 }
302
303 /**
304 * Handle Word document discussion similar to PDF discussion
305 */
306 public function mxchat_handle_word_discussion($message, $user_id, $session_id) {
307 // Get stored embeddings for the session
308 $embeddings = get_transient('mxchat_word_embeddings_' . $session_id);
309 $word_path = get_transient('mxchat_word_url_' . $session_id);
310
311 if (!$embeddings || !$word_path) {
312 $trigger_text = $this->options['word_intent_trigger_text'] ??
313 __("Please upload a Word document (.docx) that you'd like to discuss.", 'mxchat');
314 set_transient('mxchat_waiting_for_word_' . $session_id, true, HOUR_IN_SECONDS);
315 $this->fallbackResponse['text'] = $trigger_text;
316 return;
317 }
318
319 // Set context flag for including Word content in conversation
320 set_transient('mxchat_include_word_in_context_' . $session_id, true, HOUR_IN_SECONDS);
321 $this->fallbackResponse['text'] = ''; // Proceed without additional message
322 }
323
324
325 private function mxchat_generate_embedding_word($text, $api_key) {
326 $endpoint = 'https://api.openai.com/v1/embeddings';
327
328 $body = wp_json_encode([
329 'input' => $text,
330 'model' => 'text-embedding-ada-002'
331 ]);
332
333 $args = [
334 'body' => $body,
335 'headers' => [
336 'Content-Type' => 'application/json',
337 'Authorization' => 'Bearer ' . $api_key,
338 ],
339 'timeout' => 60,
340 'redirection' => 5,
341 'blocking' => true,
342 'httpversion' => '1.0',
343 'sslverify' => true,
344 ];
345
346 $response = wp_remote_post($endpoint, $args);
347
348 if (is_wp_error($response)) {
349 return null;
350 }
351
352 $response_body = json_decode(wp_remote_retrieve_body($response), true);
353
354 if (isset($response_body['data'][0]['embedding']) && is_array($response_body['data'][0]['embedding'])) {
355 return $response_body['data'][0]['embedding'];
356 } else {
357 return null;
358 }
359 }
360
361
362 private function mxchat_calculate_cosine_similarity_word($vectorA, $vectorB) {
363 if (!is_array($vectorA) || !is_array($vectorB) || empty($vectorA) || empty($vectorB)) {
364 return 0;
365 }
366
367 $dotProduct = array_sum(array_map(function ($a, $b) {
368 return $a * $b;
369 }, $vectorA, $vectorB));
370 $normA = sqrt(array_sum(array_map(function ($a) {
371 return $a * $a;
372 }, $vectorA)));
373 $normB = sqrt(array_sum(array_map(function ($b) {
374 return $b * $b;
375 }, $vectorB)));
376
377 if ($normA == 0 || $normB == 0) {
378 return 0;
379 }
380
381 return $dotProduct / ($normA * $normB);
382 }
383
384 /**
385 * Check the status of a Word document for the current session
386 */
387 public function mxchat_check_word_status() {
388 check_ajax_referer('mxchat_chat_nonce', 'nonce');
389
390 if (empty($_POST['session_id'])) {
391 wp_send_json_error(esc_html__('Session ID missing.', 'mxchat'));
392 return;
393 }
394
395 $session_id = MxChat_Utils::sanitize_session_id(wp_unslash($_POST['session_id']));
396 $word_path = get_transient('mxchat_word_url_' . $session_id);
397 $filename = get_transient('mxchat_word_filename_' . $session_id);
398
399 if ($word_path && file_exists($word_path) && $filename) {
400 wp_send_json_success([
401 'has_word' => true,
402 'filename' => $filename
403 ]);
404 } else {
405 wp_send_json_success([
406 'has_word' => false
407 ]);
408 }
409 }
410
411
412 }