PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.24
MxChat – AI Chatbot & Content Generation for WordPress v3.2.24
3.2.25 3.2.24 3.2.23 3.2.22 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 All 156 releases
← All changes | includes/class-mxchat-integrator.php +196 -60 3.2.23 → 3.2.24 View file →
@@ -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++;