521 lines
19 KiB
PHP
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;
|
|
}
|
|
}
|