rest_url( 'ago-ai-chatbot/v1' ),
'nonce' => wp_create_nonce( 'wp_rest' ),
'settings' => self::get_settings(),
] );
}
public function frontend_assets(): void {
$s = self::get_settings();
if ( empty( $s['enabled'] ) || empty( $s['api_key'] ) ) return;
wp_enqueue_style( 'agoaichat-widget', AGOAICHAT_URL . 'assets/css/widget.css', [], AGOAICHAT_VERSION );
wp_enqueue_script( 'agoaichat-widget', AGOAICHAT_URL . 'assets/js/widget.js', [], AGOAICHAT_VERSION, true );
wp_localize_script( 'agoaichat-widget', 'agoaichatWidget', [
'restUrl' => rest_url( 'ago-ai-chatbot/v1' ),
'nonce' => wp_create_nonce( 'wp_rest' ),
'botName' => $s['bot_name'] ?: __( 'Assistant', 'ago-ai-chatbot' ),
'avatarUrl' => $s['avatar_url'] ?: AGOAICHAT_URL . 'assets/img/bot-avatar.svg',
'welcomeMsg'=> $s['welcome_message'] ?: __( 'Hi! How can I help you?', 'ago-ai-chatbot' ),
'maxIterations' => 20,
'position' => $s['widget_position'] ?? 'right',
'offset' => (int) ( $s['widget_offset'] ?? 0 ),
'color' => $s['widget_color'] ?? '#2271b1',
'i18n' => [
'placeholder' => __( 'Type a message...', 'ago-ai-chatbot' ),
'send' => __( 'Send', 'ago-ai-chatbot' ),
'thinking' => __( 'Thinking...', 'ago-ai-chatbot' ),
'error' => __( 'Sorry, something went wrong. Please try again.', 'ago-ai-chatbot' ),
'rateLimit' => __( 'Too many messages in a short time. Please wait a moment and try again.', 'ago-ai-chatbot' ),
],
] );
}
/* ───── Widget ───── */
public function render_widget(): void {
$s = self::get_settings();
if ( empty( $s['enabled'] ) || empty( $s['api_key'] ) ) return;
echo '
';
}
/* ───── REST API ───── */
public function register_routes(): void {
$ns = 'ago-ai-chatbot/v1';
register_rest_route( $ns, '/settings', [
[ 'methods' => 'GET', 'callback' => [ $this, 'rest_get_settings' ], 'permission_callback' => [ $this, 'can_manage' ] ],
[ 'methods' => 'POST', 'callback' => [ $this, 'rest_save_settings' ], 'permission_callback' => [ $this, 'can_manage' ] ],
] );
/* Public by design: the chat widget serves anonymous front-end visitors,
* so this endpoint cannot require authentication. Access is gated inside
* the callback (chat must be enabled) and throttled per IP. */
register_rest_route( $ns, '/chat', [
'methods' => 'POST', 'callback' => [ $this, 'rest_chat' ], 'permission_callback' => '__return_true',
] );
register_rest_route( $ns, '/files/upload', [
'methods' => 'POST', 'callback' => [ $this, 'rest_upload_file' ], 'permission_callback' => [ $this, 'can_manage' ],
] );
register_rest_route( $ns, '/files', [
'methods' => 'GET', 'callback' => [ $this, 'rest_list_files' ], 'permission_callback' => [ $this, 'can_manage' ],
] );
register_rest_route( $ns, '/files/(?P.+)', [
'methods' => 'DELETE', 'callback' => [ $this, 'rest_delete_file' ], 'permission_callback' => [ $this, 'can_manage' ],
] );
register_rest_route( $ns, '/models', [
'methods' => 'GET', 'callback' => [ $this, 'rest_list_models' ], 'permission_callback' => [ $this, 'can_manage' ],
] );
}
public function can_manage(): bool { return current_user_can( 'manage_options' ); }
/* ── REST: Settings ── */
public function rest_get_settings(): \WP_REST_Response {
$s = self::get_settings();
$s['api_key'] = $s['api_key'] ? '********' : '';
return new \WP_REST_Response( [ 'settings' => $s ] );
}
public function rest_save_settings( \WP_REST_Request $request ): \WP_REST_Response {
$data = $request->get_json_params();
$settings = self::sanitize_settings( $data );
// Don't overwrite API key if masked
if ( $settings['api_key'] === '********' ) {
$old = get_option( 'agoaichat_settings', [] );
$settings['api_key'] = $old['api_key'] ?? '';
}
update_option( 'agoaichat_settings', $settings );
$settings['api_key'] = $settings['api_key'] ? '********' : '';
return new \WP_REST_Response( [ 'saved' => true, 'settings' => $settings ] );
}
/* ── REST: Chat ── */
public function rest_chat( \WP_REST_Request $request ): \WP_REST_Response {
$data = $request->get_json_params();
$settings = self::get_settings();
// Gate: the widget must be enabled and configured before proxying any request.
if ( empty( $settings['enabled'] ) ) {
return new \WP_REST_Response( [ 'error' => 'Chat is not enabled' ], 403 );
}
if ( empty( $settings['api_key'] ) ) {
return new \WP_REST_Response( [ 'error' => 'API key not configured' ], 500 );
}
// Anti-abuse rate limit per IP per minute (NOT a feature gating limit).
// Plugin Lite has NO chat usage caps. This only prevents flood from one IP.
$ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '0.0.0.0';
$rate_key = 'agoaichat_rate_' . md5( $ip );
$count = (int) get_transient( $rate_key );
$limit = max( 1, (int) ( $settings['rate_limit_per_minute'] ?? 60 ) );
if ( $count >= $limit ) {
return new \WP_REST_Response( [ 'error' => __( 'Too many messages in a short time. Please wait a moment and try again.', 'ago-ai-chatbot' ) ], 429 );
}
set_transient( $rate_key, $count + 1, MINUTE_IN_SECONDS );
$message = sanitize_textarea_field( $data['message'] ?? '' );
$history = $data['history'] ?? [];
if ( ! $message ) {
return new \WP_REST_Response( [ 'error' => 'Empty message' ], 400 );
}
$response = GeminiAPI::chat( $settings, $message, $history );
return new \WP_REST_Response( [
'text' => $response['text'] ?? '',
'error' => $response['error'] ?? null,
] );
}
/* ── REST: Files ── */
public function rest_upload_file( \WP_REST_Request $request ): \WP_REST_Response {
$settings = self::get_settings();
if ( empty( $settings['api_key'] ) ) {
return new \WP_REST_Response( [ 'error' => 'API key not configured' ], 500 );
}
$files = $request->get_file_params();
if ( empty( $files['file'] ) ) {
return new \WP_REST_Response( [ 'error' => 'No file provided' ], 400 );
}
$file = $files['file'];
$allowed = [ 'application/pdf', 'text/plain', 'text/csv', 'text/markdown', 'application/json' ];
if ( ! in_array( $file['type'], $allowed, true ) ) {
return new \WP_REST_Response( [ 'error' => 'File type not allowed. Supported: PDF, TXT, CSV, MD, JSON' ], 400 );
}
// Ensure File Search Store exists
$store_name = get_option( 'agoaichat_store_name', '' );
if ( ! $store_name ) {
$site_slug = sanitize_title( get_bloginfo( 'name' ) ) ?: 'ago-ai-chatbot';
$store_result = GeminiAPI::create_store( $settings['api_key'], 'ago-ai-' . $site_slug );
if ( ! empty( $store_result['error'] ) ) {
return new \WP_REST_Response( [ 'error' => 'Store creation failed: ' . $store_result['error'] ], 500 );
}
$store_name = $store_result['name'];
update_option( 'agoaichat_store_name', $store_name );
}
$result = GeminiAPI::upload_to_store( $settings['api_key'], $store_name, $file['tmp_name'], $file['name'], $file['type'] );
if ( ! empty( $result['error'] ) ) {
return new \WP_REST_Response( [ 'error' => $result['error'] ], 500 );
}
$saved_files = get_option( 'agoaichat_files', [] );
$saved_files[] = [
'name' => $result['name'],
'display_name' => $file['name'],
'uri' => $result['uri'],
'mime_type' => $file['type'],
'size' => $file['size'],
'in_store' => true,
'uploaded_at' => current_time( 'mysql' ),
];
update_option( 'agoaichat_files', $saved_files );
return new \WP_REST_Response( [ 'ok' => true, 'file' => end( $saved_files ), 'store' => $store_name ] );
}
public function rest_list_files(): \WP_REST_Response {
return new \WP_REST_Response( [
'files' => get_option( 'agoaichat_files', [] ),
'store' => get_option( 'agoaichat_store_name', '' ),
] );
}
public function rest_delete_file( \WP_REST_Request $request ): \WP_REST_Response {
$name = $request['name'];
$settings = self::get_settings();
GeminiAPI::delete_file( $settings['api_key'], $name );
$files = get_option( 'agoaichat_files', [] );
$files = array_filter( $files, fn( $f ) => $f['name'] !== $name );
update_option( 'agoaichat_files', array_values( $files ) );
if ( empty( $files ) ) {
$store_name = get_option( 'agoaichat_store_name', '' );
if ( $store_name ) {
GeminiAPI::delete_store( $settings['api_key'], $store_name );
delete_option( 'agoaichat_store_name' );
}
}
return new \WP_REST_Response( [ 'ok' => true ] );
}
public function rest_list_models(): \WP_REST_Response {
$s = self::get_settings();
if ( empty( $s['api_key'] ) ) return new \WP_REST_Response( [] );
return new \WP_REST_Response( GeminiAPI::list_models( $s['api_key'] ) );
}
/* ───── Settings ───── */
public static function get_settings(): array {
$defaults = [
'enabled' => false,
'api_key' => '',
'model' => 'gemini-2.0-flash-lite',
'system_prompt' => '',
'tone' => 'friendly',
'bot_name' => '',
'welcome_message' => '',
'avatar_url' => '',
'widget_position' => 'right',
'widget_offset' => 0,
'widget_color' => '#2271b1',
'rate_limit_per_minute' => 60,
'max_input_tokens' => 1000,
'max_output_tokens' => 1000,
'response_style' => 'friendly_emoji',
];
return wp_parse_args( get_option( 'agoaichat_settings', [] ), $defaults );
}
private static function sanitize_settings( array $d ): array {
return [
'enabled' => ! empty( $d['enabled'] ),
'api_key' => sanitize_text_field( $d['api_key'] ?? '' ),
'model' => sanitize_text_field( $d['model'] ?? 'gemini-2.0-flash-lite' ),
'system_prompt' => sanitize_textarea_field( $d['system_prompt'] ?? '' ),
'tone' => in_array( $d['tone'] ?? '', [ 'friendly', 'professional', 'casual', 'formal' ], true ) ? $d['tone'] : 'friendly',
'bot_name' => sanitize_text_field( $d['bot_name'] ?? '' ),
'welcome_message' => sanitize_text_field( $d['welcome_message'] ?? '' ),
'avatar_url' => esc_url_raw( $d['avatar_url'] ?? '' ),
'widget_position' => in_array( $d['widget_position'] ?? '', [ 'left', 'right' ], true ) ? $d['widget_position'] : 'right',
'widget_offset' => in_array( (int) ( $d['widget_offset'] ?? 0 ), [ 0, 1, 2 ], true ) ? (int) $d['widget_offset'] : 0,
'widget_color' => sanitize_hex_color( $d['widget_color'] ?? '#2271b1' ) ?: '#2271b1',
'rate_limit_per_minute' => max( 10, min( 600, (int) ( $d['rate_limit_per_minute'] ?? 60 ) ) ),
'max_input_tokens' => max( 100, min( 10000, (int) ( $d['max_input_tokens'] ?? 1000 ) ) ),
'max_output_tokens' => max( 100, min( 10000, (int) ( $d['max_output_tokens'] ?? 1000 ) ) ),
'response_style' => in_array( $d['response_style'] ?? '', [ 'friendly_emoji', 'friendly_plain', 'professional', 'formal' ], true ) ? $d['response_style'] : 'friendly_emoji',
];
}
}