Files
2026-04-23 04:33:43 +03:30

521 lines
19 KiB
PHP

<?php
namespace CropLogic;
use WP_Error;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class Rest_Controller {
/**
* @var Rest_Controller|null
*/
private static $instance = null;
public static function instance(): Rest_Controller {
if ( null === self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
private function __construct() {
add_action( 'rest_api_init', [ $this, 'register_routes' ] );
}
public function register_routes(): void {
register_rest_route(
'crop-logic/v1',
'/threads',
[
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'get_threads' ],
'permission_callback' => '__return_true',
],
[
'methods' => WP_REST_Server::CREATABLE,
'callback' => [ $this, 'create_thread' ],
'permission_callback' => '__return_true',
],
]
);
register_rest_route(
'crop-logic/v1',
'/threads/(?P<conversation_id>[a-zA-Z0-9-_]+)/messages',
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'get_messages' ],
'permission_callback' => '__return_true',
]
);
register_rest_route(
'crop-logic/v1',
'/threads/(?P<conversation_id>[a-zA-Z0-9-_]+)/send',
[
'methods' => WP_REST_Server::CREATABLE,
'callback' => [ $this, 'create_message' ],
'permission_callback' => '__return_true',
]
);
register_rest_route(
'crop-logic/v1',
'/threads/(?P<conversation_id>[a-zA-Z0-9-_]+)',
[
'methods' => WP_REST_Server::DELETABLE,
'callback' => [ $this, 'delete_thread' ],
'permission_callback' => '__return_true',
]
);
register_rest_route(
'crop-logic/v1',
'/auth/verify',
[
'methods' => WP_REST_Server::CREATABLE,
'callback' => [ $this, 'verify_auth' ],
'permission_callback' => '__return_true',
]
);
foreach ( [ 'login' => 'login', 'register' => 'register_user', 'forgot-password' => 'forgot_password', 'reset-password' => 'reset_password' ] as $route => $method ) {
register_rest_route(
'crop-logic/v1',
'/auth/' . $route,
[
'methods' => WP_REST_Server::CREATABLE,
'callback' => [ $this, $method ],
'permission_callback' => '__return_true',
]
);
}
}
public function get_threads( WP_REST_Request $request ) {
$options = Settings::get_options();
$response = $this->remote_request( 'GET', $this->build_remote_url( $options['endpoint_url'], $options['list_threads_path'] ), [], $options, $this->get_user_token( $request ) );
if ( is_wp_error( $response ) ) {
return $response;
}
return new WP_REST_Response( [ 'threads' => $this->normalize_threads_response( $response ) ], 200 );
}
public function create_thread( WP_REST_Request $request ) {
$options = Settings::get_options();
$body = [
'title' => sanitize_text_field( (string) $request->get_param( 'title' ) ),
'farm_context' => [ 'source' => 'landing' ],
];
$response = $this->remote_request( 'POST', $this->build_remote_url( $options['endpoint_url'], $options['create_thread_path'] ), $body, $options, $this->get_user_token( $request ) );
if ( is_wp_error( $response ) ) {
return $response;
}
$thread = isset( $response['data'] ) && is_array( $response['data'] ) ? $response['data'] : $response;
return new WP_REST_Response( [ 'thread' => $this->normalize_thread( $thread ) ], 200 );
}
public function get_messages( WP_REST_Request $request ) {
$options = Settings::get_options();
$conversation_id = sanitize_text_field( (string) $request['conversation_id'] );
$url = $this->build_remote_url( $options['endpoint_url'], $this->replace_path_tokens( $options['get_messages_path'], [ '{conversation_id}' => rawurlencode( $conversation_id ) ] ) );
$response = $this->remote_request( 'GET', $url, [], $options, $this->get_user_token( $request ) );
if ( is_wp_error( $response ) ) {
return $response;
}
$messages = isset( $response['data']['messages'] ) && is_array( $response['data']['messages'] ) ? $response['data']['messages'] : [];
$normalized = [];
foreach ( $messages as $message ) {
$item = $this->normalize_remote_message( $message );
if ( ! empty( $item ) ) {
$normalized[] = $item;
}
}
return new WP_REST_Response( [ 'messages' => $normalized, 'conversation_id' => $conversation_id ], 200 );
}
public function create_message( WP_REST_Request $request ) {
$limited = $this->check_rate_limit();
if ( is_wp_error( $limited ) ) {
return $limited;
}
$options = Settings::get_options();
$conversation_id = sanitize_text_field( (string) $request['conversation_id'] );
$content = trim( (string) $request->get_param( 'message' ) );
$attachments = $this->normalize_attachments( $request->get_param( 'attachments' ) );
$token = $this->get_user_token( $request );
$verify = $this->perform_verify( $options, $token );
if ( is_wp_error( $verify ) ) {
return $verify;
}
if ( ! $this->verify_is_authenticated( $verify ) ) {
return new WP_REST_Response( [ 'requires_auth' => true, 'verify' => $verify, 'message' => __( 'برای ادامه ابتدا وارد حساب خود شوید.', 'crop-logic' ) ], 401 );
}
if ( '' === $content && empty( $attachments ) ) {
return new WP_Error( 'crop_logic_empty_message', __( 'حداقل متن یا تصویر باید ارسال شود.', 'crop-logic' ), [ 'status' => 400 ] );
}
$payload = [
'content' => $content,
'images' => array_values( array_map( [ $this, 'attachment_to_image_payload' ], $attachments ) ),
'conversation_id' => $conversation_id,
'farm_context' => [
'source' => 'landing',
'page' => 'home',
],
];
if ( '' === $conversation_id ) {
unset( $payload['conversation_id'] );
$payload['title'] = __( 'گفتگوی لندینگ', 'crop-logic' );
}
$task_response = $this->remote_request( 'POST', $this->build_remote_url( $options['endpoint_url'], $options['create_message_path'] ), $payload, $options, $token );
if ( is_wp_error( $task_response ) ) {
return $task_response;
}
$task_data = isset( $task_response['data'] ) && is_array( $task_response['data'] ) ? $task_response['data'] : [];
$task_id = (string) ( $task_data['task_id'] ?? '' );
if ( '' === $task_id ) {
return new WP_Error( 'crop_logic_missing_task_id', __( 'شناسه task از بک‌اند دریافت نشد.', 'crop-logic' ), [ 'status' => 500 ] );
}
$status_data = $this->poll_task_status( $options, $token, $task_id );
if ( is_wp_error( $status_data ) ) {
return $status_data;
}
$result = isset( $status_data['result'] ) && is_array( $status_data['result'] ) ? $status_data['result'] : [];
$final_conv_id = (string) ( $result['conversation_id'] ?? $task_data['conversation_id'] ?? $conversation_id );
$user_message_id = (string) ( $task_data['message_id'] ?? '' );
$assistant = [
'id' => (string) ( $result['message_id'] ?? uniqid( 'msg_', true ) ),
'role' => 'assistant',
'content' => (string) ( $result['content'] ?? '' ),
'sections' => isset( $result['sections'] ) && is_array( $result['sections'] ) ? $this->sanitize_sections( $result['sections'] ) : [],
'createdAt' => time() * 1000,
];
$user = [
'id' => $user_message_id ?: uniqid( 'msg_', true ),
'role' => 'user',
'content' => $content,
'attachments' => array_values( array_map( [ $this, 'attachment_to_front_attachment' ], $attachments ) ),
'createdAt' => time() * 1000,
'sections' => [],
];
return new WP_REST_Response(
[
'conversation_id' => $final_conv_id,
'task_id' => $task_id,
'user_message' => $user,
'assistant_message' => $assistant,
],
200
);
}
public function delete_thread( WP_REST_Request $request ) {
$options = Settings::get_options();
$conversation_id = sanitize_text_field( (string) $request['conversation_id'] );
$url = $this->build_remote_url( $options['endpoint_url'], $this->replace_path_tokens( $options['delete_thread_path'], [ '{conversation_id}' => rawurlencode( $conversation_id ) ] ) );
$response = $this->remote_request( 'DELETE', $url, [], $options, $this->get_user_token( $request ) );
if ( is_wp_error( $response ) ) {
return $response;
}
return new WP_REST_Response( [ 'deleted' => true, 'conversation_id' => $conversation_id ], 200 );
}
public function verify_auth( WP_REST_Request $request ) {
$options = Settings::get_options();
$verify = $this->perform_verify( $options, $this->get_user_token( $request ) );
if ( is_wp_error( $verify ) ) {
return $verify;
}
return new WP_REST_Response( $verify, 200 );
}
public function login( WP_REST_Request $request ) {
return $this->handle_auth_proxy( 'login_path', $request );
}
public function register_user( WP_REST_Request $request ) {
return $this->handle_auth_proxy( 'register_path', $request );
}
public function forgot_password( WP_REST_Request $request ) {
return $this->handle_auth_proxy( 'forgot_path', $request );
}
public function reset_password( WP_REST_Request $request ) {
return $this->handle_auth_proxy( 'reset_path', $request );
}
private function handle_auth_proxy( string $path_key, WP_REST_Request $request ) {
$options = Settings::get_options();
$body = $request->get_json_params();
if ( ! is_array( $body ) ) {
$body = $request->get_params();
}
$response = $this->remote_request( 'POST', $this->build_remote_url( $options['endpoint_url'], $options[ $path_key ] ), is_array( $body ) ? $body : [], $options, $this->get_user_token( $request ) );
if ( is_wp_error( $response ) ) {
return $response;
}
return new WP_REST_Response( $this->normalize_auth_response( $response ), 200 );
}
private function perform_verify( array $options, string $token ) {
$response = $this->remote_request( 'POST', $this->build_remote_url( $options['endpoint_url'], $options['verify_path'] ), [], $options, $token );
if ( is_wp_error( $response ) ) {
return $response;
}
return [ 'authenticated' => $this->verify_is_authenticated( $response ), 'data' => $response ];
}
private function verify_is_authenticated( array $verify ): bool {
if ( isset( $verify['authenticated'] ) ) {
return (bool) $verify['authenticated'];
}
if ( isset( $verify['data']['authenticated'] ) ) {
return (bool) $verify['data']['authenticated'];
}
if ( isset( $verify['data']['code'] ) && 200 === (int) $verify['data']['code'] ) {
return true;
}
return false;
}
private function normalize_auth_response( array $response ): array {
return [
'token' => (string) ( $response['token'] ?? '' ),
'authenticated' => ! empty( $response['token'] ),
'data' => $response['data'] ?? [],
'message' => (string) ( $response['msg'] ?? $response['message'] ?? '' ),
];
}
private function get_user_token( WP_REST_Request $request ): string {
$token = (string) $request->get_header( 'X-Crop-Logic-Token' );
if ( '' === $token ) {
$token = (string) $request->get_param( 'token' );
}
return trim( $token );
}
private function poll_task_status( array $options, string $token, string $task_id ) {
$status_url = $this->build_remote_url( $options['endpoint_url'], $this->replace_path_tokens( $options['task_status_path'], [ '{task_id}' => rawurlencode( $task_id ) ] ) );
for ( $attempt = 0; $attempt < 10; $attempt++ ) {
$response = $this->remote_request( 'GET', $status_url, [], $options, $token );
if ( is_wp_error( $response ) ) {
return $response;
}
$data = isset( $response['data'] ) && is_array( $response['data'] ) ? $response['data'] : [];
$status = (string) ( $data['status'] ?? '' );
if ( 'SUCCESS' === $status ) {
return $data;
}
if ( 'FAILURE' === $status ) {
return new WP_Error( 'crop_logic_task_failed', (string) ( $data['error'] ?? __( 'پردازش پیام ناموفق بود.', 'crop-logic' ) ), [ 'status' => 500 ] );
}
sleep( 2 );
}
return new WP_Error( 'crop_logic_task_timeout', __( 'پاسخ AI در زمان مناسب دریافت نشد.', 'crop-logic' ), [ 'status' => 504 ] );
}
private function normalize_threads_response( array $response ): array {
$items = isset( $response['data'] ) && is_array( $response['data'] ) ? $response['data'] : [];
$threads = [];
foreach ( $items as $item ) {
$normalized = $this->normalize_thread( $item );
if ( ! empty( $normalized ) ) {
$threads[] = $normalized;
}
}
return $threads;
}
private function normalize_thread( $thread ): array {
if ( ! is_array( $thread ) ) {
return [];
}
$id = sanitize_text_field( (string) ( $thread['id'] ?? '' ) );
if ( '' === $id ) {
return [];
}
return [
'id' => $id,
'title' => sanitize_text_field( (string) ( $thread['title'] ?? __( 'گفتگو', 'crop-logic' ) ) ),
'updatedAt' => time() * 1000,
'messages' => [],
];
}
private function normalize_remote_message( array $message ): array {
return [
'id' => sanitize_text_field( (string) ( $message['message_id'] ?? uniqid( 'msg_', true ) ) ),
'role' => in_array( $message['role'] ?? '', [ 'user', 'assistant', 'system' ], true ) ? $message['role'] : 'assistant',
'content' => wp_kses_post( (string) ( $message['content'] ?? '' ) ),
'createdAt' => $this->normalize_timestamp( $message['created_at'] ?? time() ),
'sections' => isset( $message['sections'] ) && is_array( $message['sections'] ) ? $this->sanitize_sections( $message['sections'] ) : [],
'attachments' => [],
];
}
private function normalize_timestamp( $value ): int {
if ( is_numeric( $value ) ) {
return (int) $value;
}
$timestamp = strtotime( (string) $value );
return $timestamp ? $timestamp * 1000 : time() * 1000;
}
private function normalize_attachments( $attachments ): array {
if ( ! is_array( $attachments ) ) {
return [];
}
$normalized = [];
foreach ( $attachments as $item ) {
if ( ! is_array( $item ) ) {
continue;
}
$name = sanitize_file_name( (string) ( $item['name'] ?? '' ) );
$mime = sanitize_text_field( (string) ( $item['type'] ?? '' ) );
$data = trim( (string) ( $item['data'] ?? '' ) );
if ( '' === $data ) {
continue;
}
$normalized[] = [ 'name' => $name, 'mime' => $mime, 'data' => $data ];
}
return array_slice( $normalized, 0, 4 );
}
private function attachment_to_image_payload( array $attachment ): string {
return (string) $attachment['data'];
}
private function attachment_to_front_attachment( array $attachment ): array {
return [ 'name' => (string) $attachment['name'], 'type' => (string) $attachment['mime'] ];
}
private function sanitize_sections( array $sections ): array {
$allowed_types = [ 'text', 'list', 'recommendation', 'warning' ];
$allowed_icons = [ 'droplet', 'leaf', 'warning', 'fertilizer', 'calendar' ];
$cleaned = [];
foreach ( $sections as $section ) {
if ( ! is_array( $section ) ) {
continue;
}
$type = in_array( $section['type'] ?? '', $allowed_types, true ) ? $section['type'] : 'text';
$icon = in_array( $section['icon'] ?? '', $allowed_icons, true ) ? $section['icon'] : 'leaf';
$cleaned[] = [
'type' => $type,
'title' => sanitize_text_field( (string) ( $section['title'] ?? '' ) ),
'content' => wp_kses_post( (string) ( $section['content'] ?? '' ) ),
'items' => array_values( array_filter( array_map( 'sanitize_text_field', is_array( $section['items'] ?? null ) ? $section['items'] : [] ) ) ),
'icon' => $icon,
'frequency' => sanitize_text_field( (string) ( $section['frequency'] ?? '' ) ),
'amount' => sanitize_text_field( (string) ( $section['amount'] ?? '' ) ),
'timing' => sanitize_text_field( (string) ( $section['timing'] ?? '' ) ),
'expandableExplanation' => sanitize_text_field( (string) ( $section['expandableExplanation'] ?? '' ) ),
];
}
return $cleaned;
}
private function build_remote_url( string $base, string $path ): string {
return rtrim( $base, '/' ) . '/' . ltrim( $path, '/' );
}
private function replace_path_tokens( string $path, array $tokens ): string {
return strtr( $path, $tokens );
}
private function remote_request( string $method, string $url, array $payload, array $options, string $user_token = '' ) {
$headers = [ 'Content-Type' => 'application/json' ];
if ( '' !== trim( (string) $options['api_key'] ) ) {
$headers['X-Server-Api-Key'] = trim( (string) $options['api_key'] );
}
if ( '' !== $user_token ) {
$headers[ trim( (string) $options['token_header'] ) ?: 'Authorization' ] = (string) $options['token_prefix'] . $user_token;
}
if ( '' !== trim( (string) $options['custom_headers'] ) ) {
$custom = json_decode( (string) $options['custom_headers'], true );
if ( is_array( $custom ) ) {
foreach ( $custom as $key => $value ) {
if ( is_string( $key ) && ( is_scalar( $value ) || null === $value ) ) {
$headers[ $key ] = (string) $value;
}
}
}
}
$args = [ 'timeout' => max( 5, (int) $options['timeout'] ), 'headers' => $headers, 'method' => $method ];
if ( 'GET' === $method && ! empty( $payload ) ) {
$url = add_query_arg( $payload, $url );
} elseif ( ! empty( $payload ) ) {
$args['body'] = wp_json_encode( $payload );
}
$response = wp_remote_request( $url, $args );
if ( is_wp_error( $response ) ) {
return new WP_Error( 'crop_logic_request_failed', $response->get_error_message(), [ 'status' => 500 ] );
}
$code = wp_remote_retrieve_response_code( $response );
$body = (string) wp_remote_retrieve_body( $response );
if ( '' === $body && 'DELETE' === $method && $code >= 200 && $code < 300 ) {
return [];
}
$decoded = json_decode( $body, true );
if ( $code < 200 || $code >= 300 ) {
$message = is_array( $decoded ) ? (string) ( $decoded['msg'] ?? $decoded['message'] ?? __( 'بک‌اند خطا برگرداند.', 'crop-logic' ) ) : __( 'بک‌اند خطا برگرداند.', 'crop-logic' );
return new WP_Error( 'crop_logic_http_error', $message, [ 'status' => $code ?: 500 ] );
}
if ( '' === $body ) {
return [];
}
if ( ! is_array( $decoded ) ) {
return new WP_Error( 'crop_logic_bad_json', __( 'پاسخ JSON بک‌اند معتبر نیست.', 'crop-logic' ), [ 'status' => 500 ] );
}
return $decoded;
}
private function check_rate_limit() {
$ip = sanitize_text_field( (string) ( $_SERVER['REMOTE_ADDR'] ?? 'guest' ) );
$key = 'crop_logic_rl_' . md5( $ip );
$data = get_transient( $key );
if ( ! is_array( $data ) ) {
$data = [ 'count' => 0, 'time' => time() ];
}
if ( (int) $data['count'] >= 20 ) {
return new WP_Error( 'crop_logic_rate_limit', __( 'تعداد درخواست‌ها زیاد است. یک دقیقه دیگر دوباره تلاش کنید.', 'crop-logic' ), [ 'status' => 429 ] );
}
$data['count'] = (int) $data['count'] + 1;
set_transient( $key, $data, MINUTE_IN_SECONDS );
return true;
}
}