| @@ -2225,14 +2225,39 @@ | ||
| 2225 | 2225 | wp_die(); |
| 2226 | 2226 | } elseif (!$intent_matched) { |
| 2227 | 2227 | // No intent matched, handle live agent message |
| 2228 | 2228 | try { |
| 2229 | - $this->mxchat_send_user_message_to_agent( | |
| 2229 | + $relayed = $this->mxchat_send_user_message_to_agent( | |
| 2230 | 2230 | $this->mxchat_user_message_for_humans($message, $session_id, 'agent'), |
| 2231 | 2231 | $user_id, |
| 2232 | 2232 | $session_id |
| 2233 | 2233 | ); |
| 2234 | 2234 | |
| 2235 | + // The relay reports delivery from the agent channel's own | |
| 2236 | + // answer (plan 2a4ab7). When it was refused, say so in a | |
| 2237 | + // normal bot bubble instead of "sent" — top-level text, so | |
| 2238 | + // every widget build renders it — and carry the session's | |
| 2239 | + // current mode: back to AI when the destination is gone, | |
| 2240 | + // still agent when the failure may pass. | |
| 2241 | + if (!$relayed) { | |
| 2242 | + $unavailable_text = __('We could not reach a live agent just now. Please try again in a moment, or leave your email and we will follow up.', 'mxchat'); | |
| 2243 | + $this->mxchat_save_chat_message($session_id, 'bot', $unavailable_text); | |
| 2244 | + | |
| 2245 | + $agent_response = [ | |
| 2246 | + 'text' => $unavailable_text, | |
| 2247 | + 'html' => '', | |
| 2248 | + 'status' => 'agent_unavailable', | |
| 2249 | + 'session_id' => $session_id, | |
| 2250 | + 'chat_mode' => MxChat_Session_Store::get($session_id, 'mode', 'ai') | |
| 2251 | + ]; | |
| 2252 | + | |
| 2253 | + if ($testing_data !== null) { | |
| 2254 | + $agent_response['testing_data'] = $testing_data; | |
| 2255 | + } | |
| 2256 | + | |
| 2257 | + wp_send_json($agent_response); | |
| 2258 | + } | |
| 2259 | + | |
| 2235 | 2260 | $agent_response = [ |
| 2236 | 2261 | 'status' => 'waiting_for_agent', |
| 2237 | 2262 | 'message' => esc_html__('Message sent to live agent.', 'mxchat') |
| 2238 | 2263 | ]; |
| @@ -4487,8 +4512,13 @@ | ||
| 4487 | 4512 | //error_log("❌ SECURITY: Blocked unsafe PDF URL"); |
| 4488 | 4513 | return false; |
| 4489 | 4514 | } |
| 4490 | 4515 | |
| 4516 | + // wp_tempnam() lives in wp-admin/includes/file.php. admin-ajax has it | |
| 4517 | + // loaded; a REST request (MxChat Anywhere) does not. | |
| 4518 | + if (!function_exists('wp_tempnam')) { | |
| 4519 | + require_once ABSPATH . 'wp-admin/includes/file.php'; | |
| 4520 | + } | |
| 4491 | 4521 | $temp_file = wp_tempnam($pdf_source); |
| 4492 | 4522 | |
| 4493 | 4523 | // SECURITY FIX: Changed from wp_remote_get to wp_safe_remote_get |
| 4494 | 4524 | // Route through the shared MXChat crawler identity (plan bae78f/b6d93c) so |
| @@ -4982,9 +5012,9 @@ | ||
| 4982 | 5012 | |
| 4983 | 5013 | MxChat_Session_Store::set($session_id, 'mode', 'agent'); |
| 4984 | 5014 | |
| 4985 | 5015 | // Send message to channel |
| 4986 | - $channel_message = "🔔 *New Live Agent Request*\n\n"; | |
| 5016 | + $channel_message = "🔔 *New Live Agent Request*\n\n"; | |
| 4987 | 5017 | $channel_message .= "*Session ID:* `{$session_id}`\n"; |
| 4988 | 5018 | $channel_message .= "*User ID:* `{$user_id}`\n"; |
| 4989 | 5019 | |
| 4990 | 5020 | // Surface the captured visitor identity so the agent knows who they're talking to — |
| @@ -5039,43 +5069,29 @@ | ||
| 5039 | 5069 | } |
| 5040 | 5070 | } |
| 5041 | 5071 | |
| 5042 | 5072 | if (!$use_shared_channel) { |
| 5043 | - $handoff_post = wp_remote_post('https://slack.com/api/chat.postMessage', [ | |
| 5044 | - 'headers' => [ | |
| 5045 | - 'Content-Type' => 'application/json', | |
| 5046 | - 'Authorization' => 'Bearer ' . $slack_bot_token | |
| 5047 | - ], | |
| 5048 | - 'body' => json_encode([ | |
| 5049 | - 'channel' => $channel_id, | |
| 5050 | - 'text' => $channel_message, | |
| 5051 | - 'mrkdwn' => true | |
| 5052 | - ]) | |
| 5053 | - ]); | |
| 5073 | + // Through the checked helper (plan 2a4ab7) so a refused post is | |
| 5074 | + // recorded for the Slack tab; the control flow below is unchanged. | |
| 5075 | + $handoff_post = $this->mxchat_slack_api('chat.postMessage', [ | |
| 5076 | + 'channel' => $channel_id, | |
| 5077 | + 'text' => $channel_message, | |
| 5078 | + 'mrkdwn' => true | |
| 5079 | + ], $slack_bot_token); | |
| 5054 | 5080 | // Re-handover edge (plan 7458a7): the stored mxchat_channel_ may point |
| 5055 | 5081 | // at a channel archived by the auto-archive toggle (or deleted by an |
| 5056 | 5082 | // admin). Slack answers is_archived / channel_not_found — clear the |
| 5057 | 5083 | // stale option, mint a fresh channel, and re-post ONCE so the handoff |
| 5058 | 5084 | // is never silently dropped. |
| 5059 | - if (!is_wp_error($handoff_post)) { | |
| 5060 | - $handoff_data = json_decode(wp_remote_retrieve_body($handoff_post), true); | |
| 5061 | - $handoff_err = isset($handoff_data['error']) ? $handoff_data['error'] : ''; | |
| 5062 | - if (isset($handoff_data['ok']) && !$handoff_data['ok'] && in_array($handoff_err, array('is_archived', 'channel_not_found'), true)) { | |
| 5063 | - MxChat_Session_Store::delete($session_id, 'channel'); | |
| 5064 | - $channel_id = $this->mxchat_create_conversation_channel($session_id); | |
| 5065 | - if (!empty($channel_id)) { | |
| 5066 | - wp_remote_post('https://slack.com/api/chat.postMessage', [ | |
| 5067 | - 'headers' => [ | |
| 5068 | - 'Content-Type' => 'application/json', | |
| 5069 | - 'Authorization' => 'Bearer ' . $slack_bot_token | |
| 5070 | - ], | |
| 5071 | - 'body' => json_encode([ | |
| 5072 | - 'channel' => $channel_id, | |
| 5073 | - 'text' => $channel_message, | |
| 5074 | - 'mrkdwn' => true | |
| 5075 | - ]) | |
| 5076 | - ]); | |
| 5077 | - } | |
| 5085 | + if (is_wp_error($handoff_post) && in_array($handoff_post->get_error_code(), array('is_archived', 'channel_not_found'), true)) { | |
| 5086 | + MxChat_Session_Store::delete($session_id, 'channel'); | |
| 5087 | + $channel_id = $this->mxchat_create_conversation_channel($session_id); | |
| 5088 | + if (!empty($channel_id)) { | |
| 5089 | + $this->mxchat_slack_api('chat.postMessage', [ | |
| 5090 | + 'channel' => $channel_id, | |
| 5091 | + 'text' => $channel_message, | |
| 5092 | + 'mrkdwn' => true | |
| 5093 | + ], $slack_bot_token); | |
| 5078 | 5094 | } |
| 5079 | 5095 | } |
| 5080 | 5096 | } |
| 5081 | 5097 | |
| @@ -6336,12 +6352,18 @@ | ||
| 6336 | 6352 | $channel_id = MxChat_Session_Store::get($session_id, 'channel', ''); |
| 6337 | 6353 | } |
| 6338 | 6354 | |
| 6339 | 6355 | if (empty($slack_bot_token) || empty($channel_id)) { |
| 6356 | + // Nothing left to relay into (token removed, or the session's channel | |
| 6357 | + // mapping is gone): same outcome as a dead destination. | |
| 6358 | + $this->mxchat_record_slack_error('chat.postMessage', empty($slack_bot_token) ? 'no_bot_token' : 'no_channel_for_session', (string) $channel_id); | |
| 6359 | + MxChat_Session_Store::delete($session_id, 'channel'); | |
| 6360 | + delete_option("mxchat_thread_{$session_id}"); | |
| 6361 | + MxChat_Session_Store::set($session_id, 'mode', 'ai'); | |
| 6340 | 6362 | return false; |
| 6341 | 6363 | } |
| 6342 | 6364 | |
| 6343 | - $user_message = "💬 *User:* {$message}"; | |
| 6365 | + $user_message = "💬 *User:* {$message}"; | |
| 6344 | 6366 | |
| 6345 | 6367 | $body = [ |
| 6346 | 6368 | 'channel' => $channel_id, |
| 6347 | 6369 | 'text' => $user_message, |
| @@ -6350,18 +6372,106 @@ | ||
| 6350 | 6372 | if (!empty($thread_ts)) { |
| 6351 | 6373 | $body['thread_ts'] = $thread_ts; |
| 6352 | 6374 | } |
| 6353 | 6375 | |
| 6354 | - $response = wp_remote_post('https://slack.com/api/chat.postMessage', [ | |
| 6376 | + $result = $this->mxchat_slack_api('chat.postMessage', $body, $slack_bot_token); | |
| 6377 | + | |
| 6378 | + if (is_wp_error($result)) { | |
| 6379 | + // Slack answers a refused post with HTTP 200 and ok:false. The old | |
| 6380 | + // code returned true for anything that was not a transport error, so | |
| 6381 | + // a deleted or archived channel (or a revoked token) was reported to | |
| 6382 | + // the visitor as "sent to live agent" forever. When the destination | |
| 6383 | + // is gone for good, end the handoff the way the Telegram leg does on | |
| 6384 | + // a dead topic so the next message goes back to the bot; a transient | |
| 6385 | + // refusal (rate limit, Slack outage) leaves the handoff in place. | |
| 6386 | + if ($this->mxchat_is_dead_slack_destination_error($result)) { | |
| 6387 | + MxChat_Session_Store::delete($session_id, 'channel'); | |
| 6388 | + delete_option("mxchat_thread_{$session_id}"); | |
| 6389 | + MxChat_Session_Store::set($session_id, 'mode', 'ai'); | |
| 6390 | + } | |
| 6391 | + return false; | |
| 6392 | + } | |
| 6393 | + | |
| 6394 | + return true; | |
| 6395 | +} | |
| 6396 | + | |
| 6397 | +/** | |
| 6398 | + * One checked door to the Slack Web API for live-agent posts (plan 2a4ab7). | |
| 6399 | + * Slack reports a refused call as HTTP 200 with {"ok":false,"error":"…"}, so | |
| 6400 | + * transport success says nothing about delivery. The body is decoded, ok is | |
| 6401 | + * verified, and a failure is recorded in mxchat_slack_last_error (surfaced on | |
| 6402 | + * the Slack Integrations tab); the next successful call clears it. | |
| 6403 | + * | |
| 6404 | + * @param string $method Slack Web API method, e.g. 'chat.postMessage'. | |
| 6405 | + * @param array $body JSON body for the call. | |
| 6406 | + * @param string $token Bot token. | |
| 6407 | + * @return array|WP_Error The decoded response when ok is true; otherwise a | |
| 6408 | + * WP_Error whose code is Slack's error string (or | |
| 6409 | + * the transport error code). | |
| 6410 | + */ | |
| 6411 | +private function mxchat_slack_api($method, array $body, $token) { | |
| 6412 | + $channel = isset($body['channel']) ? (string) $body['channel'] : ''; | |
| 6413 | + | |
| 6414 | + $response = wp_remote_post('https://slack.com/api/' . $method, [ | |
| 6355 | 6415 | 'headers' => [ |
| 6356 | 6416 | 'Content-Type' => 'application/json', |
| 6357 | - 'Authorization' => 'Bearer ' . $slack_bot_token | |
| 6417 | + 'Authorization' => 'Bearer ' . $token | |
| 6358 | 6418 | ], |
| 6359 | - 'body' => json_encode($body) | |
| 6419 | + 'body' => json_encode($body), | |
| 6420 | + 'timeout' => 15 | |
| 6360 | 6421 | ]); |
| 6361 | 6422 | |
| 6362 | - return !is_wp_error($response); | |
| 6423 | + if (is_wp_error($response)) { | |
| 6424 | + $this->mxchat_record_slack_error($method, $response->get_error_message(), $channel); | |
| 6425 | + return $response; | |
| 6426 | + } | |
| 6427 | + | |
| 6428 | + $data = json_decode(wp_remote_retrieve_body($response), true); | |
| 6429 | + if (!is_array($data) || empty($data['ok'])) { | |
| 6430 | + $error = (is_array($data) && !empty($data['error'])) | |
| 6431 | + ? (string) $data['error'] | |
| 6432 | + : 'unknown_error (HTTP ' . wp_remote_retrieve_response_code($response) . ')'; | |
| 6433 | + $this->mxchat_record_slack_error($method, $error, $channel); | |
| 6434 | + return new WP_Error($error, $error, ['method' => $method, 'channel' => $channel]); | |
| 6435 | + } | |
| 6436 | + | |
| 6437 | + if (get_option('mxchat_slack_last_error') !== false) { | |
| 6438 | + delete_option('mxchat_slack_last_error'); | |
| 6439 | + } | |
| 6440 | + | |
| 6441 | + return $data; | |
| 6363 | 6442 | } |
| 6443 | + | |
| 6444 | +/** | |
| 6445 | + * Record the last failed Slack live-agent post. Same shape as | |
| 6446 | + * mxchat_telegram_last_error, plus the channel the post was aimed at. | |
| 6447 | + */ | |
| 6448 | +private function mxchat_record_slack_error($method, $error, $channel = '') { | |
| 6449 | + update_option('mxchat_slack_last_error', array( | |
| 6450 | + 'error' => (string) $error, | |
| 6451 | + 'method' => (string) $method, | |
| 6452 | + 'channel' => (string) $channel, | |
| 6453 | + 'time' => time(), | |
| 6454 | + ), false); | |
| 6455 | +} | |
| 6456 | + | |
| 6457 | +/** | |
| 6458 | + * Does this WP_Error mean the Slack destination will never accept the | |
| 6459 | + * message (channel deleted or archived, bot removed, token dead)? | |
| 6460 | + */ | |
| 6461 | +private function mxchat_is_dead_slack_destination_error($error) { | |
| 6462 | + if (!is_wp_error($error)) { | |
| 6463 | + return false; | |
| 6464 | + } | |
| 6465 | + return in_array($error->get_error_code(), array( | |
| 6466 | + 'channel_not_found', | |
| 6467 | + 'is_archived', | |
| 6468 | + 'not_in_channel', | |
| 6469 | + 'invalid_auth', | |
| 6470 | + 'account_inactive', | |
| 6471 | + 'token_revoked', | |
| 6472 | + ), true); | |
| 6473 | +} | |
| 6364 | 6474 | public function handle_slack_interaction(WP_REST_Request $request) { |
| 6365 | 6475 | //error_log('Received Slack interaction'); |
| 6366 | 6476 | |
| 6367 | 6477 | $payload = json_decode($request->get_param('payload'), true); |
| @@ -6727,19 +6837,13 @@ | ||
| 6727 | 6837 | } |
| 6728 | 6838 | |
| 6729 | 6839 | // Confirm in Slack channel |
| 6730 | 6840 | if (!empty($slack_bot_token)) { |
| 6731 | - wp_remote_post('https://slack.com/api/chat.postMessage', [ | |
| 6732 | - 'headers' => [ | |
| 6733 | - 'Content-Type' => 'application/json', | |
| 6734 | - 'Authorization' => 'Bearer ' . $slack_bot_token | |
| 6735 | - ], | |
| 6736 | - 'body' => json_encode([ | |
| 6737 | - 'channel' => $channel_id, | |
| 6738 | - 'text' => "✅ *Chat ended.* User has been transferred back to AI mode.", | |
| 6739 | - 'mrkdwn' => true | |
| 6740 | - ]) | |
| 6741 | - ]); | |
| 6841 | + $this->mxchat_slack_api('chat.postMessage', [ | |
| 6842 | + 'channel' => $channel_id, | |
| 6843 | + 'text' => "✅ *Chat ended.* User has been transferred back to AI mode.", | |
| 6844 | + 'mrkdwn' => true | |
| 6845 | + ], $slack_bot_token); | |
| 6742 | 6846 | } |
| 6743 | 6847 | |
| 6744 | 6848 | // Auto-archive the ended conversation's channel (plan 7458a7). |
| 6745 | 6849 | // Toggle-gated, best-effort — never blocks the mode flip. |
| @@ -7737,12 +7841,11 @@ | ||
| 7737 | 7841 | $content .= "## Reference " . $matches_used . " ##\n"; |
| 7738 | 7842 | $content .= $full_text . "\n\n"; |
| 7739 | 7843 | |
| 7740 | 7844 | // Only include citation URLs if citation links are enabled |
| 7741 | - if ($citation_links_enabled) { | |
| 7742 | - $valid_urls[] = $source_url; | |
| 7743 | - $content .= "URL: " . $source_url . "\n\n"; | |
| 7744 | - } | |
| 7845 | + // Uploaded files are cited as their page when one was given, | |
| 7846 | + // else named as plain text and never offered as a link (plan 025ea2). | |
| 7847 | + $this->append_source_citation($content, $valid_urls, $source_url, $bot_id, $citation_links_enabled); | |
| 7745 | 7848 | |
| 7746 | 7849 | // Video-backed source → queue the consent-safe embed (03ba33), |
| 7747 | 7850 | // subject to the card's own confidence floor (f52492). Pass the |
| 7748 | 7851 | // group's true cosine, NOT best_score — see the gate's docblock. |
| @@ -7809,8 +7912,43 @@ | ||
| 7809 | 7912 | return trim($content); |
| 7810 | 7913 | } |
| 7811 | 7914 | |
| 7812 | 7915 | /** |
| 7916 | + * Write the citation line for one knowledge source (plan 025ea2). | |
| 7917 | + * | |
| 7918 | + * A web source gets its "URL:" line and joins the approved-URL list, as | |
| 7919 | + * before. An uploaded file (upload://<filename>) is not a web address: it is | |
| 7920 | + * cited as the page its owner gave on upload, or, when there is none, named | |
| 7921 | + * as plain text with no URL line and no approved-URL entry, so the model has | |
| 7922 | + * the content and nothing to link. | |
| 7923 | + * | |
| 7924 | + * @param string $content Context being built (by reference). | |
| 7925 | + * @param array $valid_urls Approved URLs (by reference). | |
| 7926 | + * @param string $source_url Stored source identity. | |
| 7927 | + * @param string $bot_id Bot answering the turn. | |
| 7928 | + * @param bool $citation_links_enabled Citation links setting. | |
| 7929 | + */ | |
| 7930 | +private function append_source_citation(&$content, &$valid_urls, $source_url, $bot_id, $citation_links_enabled) { | |
| 7931 | + if (!$citation_links_enabled) { | |
| 7932 | + return; | |
| 7933 | + } | |
| 7934 | + if (MxChat_Utils::is_upload_source($source_url)) { | |
| 7935 | + $page_url = MxChat_Utils::get_upload_source_url($source_url, $bot_id); | |
| 7936 | + if ($page_url !== '') { | |
| 7937 | + $valid_urls[] = $page_url; | |
| 7938 | + $content .= "URL: " . $page_url . "\n\n"; | |
| 7939 | + } else { | |
| 7940 | + // A page of an uploaded PDF names its page (plan dcc19d). | |
| 7941 | + $pdf_page = MxChat_Utils::upload_source_page($source_url); | |
| 7942 | + $content .= "Source: " . MxChat_Utils::upload_source_name($source_url) . ($pdf_page > 0 ? ", page " . $pdf_page : "") . " (an uploaded file, not a web page: it has no link)\n\n"; | |
| 7943 | + } | |
| 7944 | + return; | |
| 7945 | + } | |
| 7946 | + $valid_urls[] = $source_url; | |
| 7947 | + $content .= "URL: " . $source_url . "\n\n"; | |
| 7948 | +} | |
| 7949 | + | |
| 7950 | +/** | |
| 7813 | 7951 | * plan-mxchat-20260717-03ba33 — if a KB source used for context is a single |
| 7814 | 7952 | * YouTube video, queue ONE consent-safe embed for the response html channel. |
| 7815 | 7953 | * Called from BOTH retrieval builders (WordPress DB + Pinecone) inside their |
| 7816 | 7954 | * real-URL winner branch, in ranked order — so the first (best) video wins and |
| @@ -8281,12 +8419,11 @@ | ||
| 8281 | 8419 | $content .= "## Reference " . $matches_used . " ##\n"; |
| 8282 | 8420 | $content .= $full_text . "\n\n"; |
| 8283 | 8421 | |
| 8284 | 8422 | // Only include citation URLs if citation links are enabled |
| 8285 | - if ($citation_links_enabled) { | |
| 8286 | - $valid_urls[] = $source_url; | |
| 8287 | - $content .= "URL: " . $source_url . "\n\n"; | |
| 8288 | - } | |
| 8423 | + // Uploaded files are cited as their page when one was given, | |
| 8424 | + // else named as plain text and never offered as a link (plan 025ea2). | |
| 8425 | + $this->append_source_citation($content, $valid_urls, $source_url, $bot_id, $citation_links_enabled); | |
| 8289 | 8426 | |
| 8290 | 8427 | // Video-backed source → queue the consent-safe embed (03ba33), |
| 8291 | 8428 | // subject to the card's own confidence floor (f52492). Pass the |
| 8292 | 8429 | // group's true cosine, NOT best_score — see the gate's docblock. |
| @@ -8794,12 +8931,11 @@ | ||
| 8794 | 8931 | if (!empty($source_url) && $source_url !== '#' && strpos($source_url, 'mxchat://') !== 0) { |
| 8795 | 8932 | $matches_used++; |
| 8796 | 8933 | $content .= "## Reference " . $matches_used . " ##\n"; |
| 8797 | 8934 | $content .= $full_text . "\n\n"; |
| 8798 | - if ($citation_links_enabled) { | |
| 8799 | - $valid_urls[] = $source_url; | |
| 8800 | - $content .= "URL: " . $source_url . "\n\n"; | |
| 8801 | - } | |
| 8935 | + // Uploaded files are cited as their page when one was given, | |
| 8936 | + // else named as plain text and never offered as a link (plan 025ea2). | |
| 8937 | + $this->append_source_citation($content, $valid_urls, $source_url, $bot_id, $citation_links_enabled); | |
| 8802 | 8938 | $this->maybe_queue_youtube_embed($source_url, $full_text, $group['best_similarity'] ?? null); |
| 8803 | 8939 | } else { |
| 8804 | 8940 | // Manual entry — counted as a used source, uncited (plan c1fe6a). |
| 8805 | 8941 | $matches_used++; |