'string', 'required' => true, 'pattern' => '^[A-Za-z0-9_-]{8,64}$']; private const BOT_ID = ['type' => 'integer', 'required' => true, 'minimum' => 1]; public function __construct(private Plugin $plugin, private bool $preview = false) { } public function register(): void { $base = $this->preview ? '/preview/chat' : '/chat'; $permission = $this->preview ? static fn(): bool => current_user_can(\Aiwend\Policy\Capabilities::MANAGE_BOTS) : '__return_true'; register_rest_route(self::NS, $base . '/sessions', [ 'methods' => 'POST', 'callback' => [$this, 'createSession'], 'permission_callback' => $permission, 'args' => ['bot_id' => self::BOT_ID], ]); register_rest_route(self::NS, $base . '/messages', [ [ 'methods' => 'POST', 'callback' => [$this, 'send'], 'permission_callback' => $permission, 'args' => [ 'bot_id' => self::BOT_ID, 'message' => ['type' => 'string', 'required' => true, 'maxLength' => 4000], 'request_id' => self::REQUEST_ID, 'stream' => ['type' => 'boolean', 'default' => false], ], ], [ 'methods' => 'GET', 'callback' => [$this, 'transcript'], 'permission_callback' => $permission, 'args' => ['bot_id' => self::BOT_ID], ], ]); register_rest_route(self::NS, $base . '/cancel', [ 'methods' => 'POST', 'callback' => [$this, 'cancel'], 'permission_callback' => $permission, 'args' => ['bot_id' => self::BOT_ID, 'request_id' => self::REQUEST_ID], ]); register_rest_route(self::NS, $base . '/feedback', [ 'methods' => 'POST', 'callback' => [$this, 'feedback'], 'permission_callback' => $permission, 'args' => ['bot_id' => self::BOT_ID, 'request_id' => self::REQUEST_ID, 'rating' => ['type' => 'integer', 'required' => true, 'enum' => [-1, 0, 1]]], ]); register_rest_route(self::NS, $base . '/session', [ 'methods' => 'DELETE', 'callback' => [$this, 'deleteSession'], 'permission_callback' => $permission, 'args' => ['bot_id' => self::BOT_ID], ]); } public function createSession(\WP_REST_Request $request): \WP_REST_Response|\WP_Error { $p = $this->plugin->currentPrincipal(); $botId = (int) $request->get_param('bot_id'); $bot = $this->plugin->bots->resource($p->siteId, $botId); // Preview: any assistant of this site, for a principal allowed to preview (also checked by // permission_callback). Public: only an active, public assistant. $decision = $this->preview ? ($bot === null ? \Aiwend\Policy\Decision::notFound() : $this->plugin->policy->check($p, 'bots.preview', $bot)) : $this->plugin->policy->check($p, 'chat.session.create', $bot); if (!$decision->allowed) { return Errors::response($decision->code, $decision->httpStatus()); } if (!$this->allowSessionCreation($p)) { return Errors::response('rate_limited', 429); } $session = $this->plugin->sessions->create($p->siteId, $botId, $p->userId, $this->preview ? 3600 : \Aiwend\Chat\SessionService::DEFAULT_TTL); if ($this->plugin->bots->find($p->siteId, $botId) === null) { // The assistant was deleted between the check above and the insert: the deletion may already have listed its sessions, so this one is removed here. $this->plugin->sessions->delete($p->siteId, $session['id']); return Errors::response('not_found', 404); } $this->setCookie($botId, $session['token'], $session['expires_at']); $this->plugin->audit->record($p->siteId, $p->actorRef(), 'chat.session.create', "bot:$botId", 'ok'); return $this->noStore(new \WP_REST_Response(['expires_at' => $session['expires_at']], 201)); } public function feedback(\WP_REST_Request $request): \WP_REST_Response|\WP_Error { $principal = $this->sessionPrincipal($request); if ($principal === null || !(new \Aiwend\Chat\Feedback($this->plugin->db, $this->plugin->clock))->rate($principal, (string) $request['request_id'], (int) $request['rating'])) { return Errors::response('not_found', 404); } return $this->noStore(new \WP_REST_Response(['rating' => (int) $request['rating']])); } public function send(\WP_REST_Request $request): \WP_REST_Response|\WP_Error { $p = $this->sessionPrincipal($request); if ($p === null) { return Errors::response('not_found', 404); } if (!$this->preview && !$this->allowMessage($p)) { return Errors::response('message_rate_limited', 429); } $requestId = (string) $request->get_param('request_id'); $sessionId = (int) $p->chatSessionId; $message = (string) $request->get_param('message'); $flag = $this->cancelFlag($p->siteId, $sessionId, $requestId); // Keep running after a client disconnect so usage is settled; treat the disconnect as a cancel. ignore_user_abort(true); $flagSeen = new \Aiwend\Chat\CancelFlagReader(static function () use ($flag): bool { // Without a persistent object cache WordPress remembers "this option does not exist" for the rest of the request, which hid a // flag written meanwhile by the cancel request (phase 91a, P91a-1). Forget that note for this flag before each (rate-limited) read. $missing = wp_cache_get('notoptions', 'options'); if (is_array($missing)) { unset($missing['_transient_' . $flag], $missing['_transient_timeout_' . $flag]); wp_cache_set('notoptions', $missing, 'options'); } return get_transient($flag) !== false; }, static fn(): float => microtime(true)); $cancel = new CancellationToken(static fn(): bool => connection_aborted() === 1 || $flagSeen()); if (!$request->get_param('stream')) { try { $reply = $this->plugin->chat()->send($p, $sessionId, $message, $requestId, $cancel, null, $this->preview, $this->clientAllowance($p)); $this->notePreview($p, $reply); } catch (ChatException $e) { return $this->error($e->errorCode, $e->status, $e->requestId ?? (RequestId::isValid($requestId) ? $requestId : null)); } finally { delete_transient($flag); } // Answers are plain text; the widget renders them with textContent, never as HTML. return $this->noStore(new \WP_REST_Response($reply->toArray(), 200)); } $stream = new SseStream(); try { $reply = $this->plugin->chat()->send($p, $sessionId, $message, $requestId, $cancel, static function (string $delta) use ($stream): void { $stream->send('delta', ['text' => $delta]); }, $this->preview, $this->clientAllowance($p)); $this->notePreview($p, $reply); if (!$stream->started() && $reply->text !== '') { $stream->send('delta', ['text' => $reply->text]); // replayed or non-streaming adapter } // Final text (fabricated citation markers removed) and the resolved citations. $stream->send('done', $reply->toArray()); } catch (ChatException $e) { if (!$stream->started()) { return $this->error($e->errorCode, $e->status, $e->requestId ?? (RequestId::isValid($requestId) ? $requestId : null)); } $stream->send('error', ['code' => $e->errorCode, 'message' => Errors::message($e->errorCode), 'request_id' => $e->requestId] + $this->hint($e->errorCode)); } finally { delete_transient($flag); } exit; } public function transcript(\WP_REST_Request $request): \WP_REST_Response|\WP_Error { $p = $this->sessionPrincipal($request); if ($p === null) { return Errors::response('not_found', 404); } try { return $this->noStore(new \WP_REST_Response(['messages' => $this->plugin->chat()->transcript($p, (int) $p->chatSessionId)])); } catch (ChatException $e) { return Errors::response($e->errorCode, $e->status); } } /** Marks a running request as cancelled; the streaming request stops at its next chunk. */ public function cancel(\WP_REST_Request $request): \WP_REST_Response|\WP_Error { $p = $this->sessionPrincipal($request); if ($p === null) { return Errors::response('not_found', 404); } $decision = $this->plugin->policy->check($p, 'chat.cancel', $this->plugin->sessions->resource($p->siteId, (int) $p->chatSessionId)); if (!$decision->allowed) { return Errors::response($decision->code, $decision->httpStatus()); } set_transient($this->cancelFlag($p->siteId, (int) $p->chatSessionId, (string) $request->get_param('request_id')), 1, 120); return $this->noStore(new \WP_REST_Response(['cancelling' => true], 202)); } public function deleteSession(\WP_REST_Request $request): \WP_REST_Response|\WP_Error { $p = $this->sessionPrincipal($request); if ($p === null) { return Errors::response('not_found', 404); } $decision = $this->plugin->policy->check($p, 'chat.session.delete', $this->plugin->sessions->resource($p->siteId, (int) $p->chatSessionId)); if (!$decision->allowed) { return Errors::response($decision->code, $decision->httpStatus()); } $this->plugin->sessions->delete($p->siteId, (int) $p->chatSessionId); $this->plugin->audit->record($p->siteId, $p->actorRef(), 'chat.session.delete', 'session:' . $p->chatSessionId, 'ok'); $this->setCookie((int) $request->get_param('bot_id'), '', time() - 3600); return $this->noStore(new \WP_REST_Response(['deleted' => true])); } /** Resolves the presented token for this bot; a token for another bot is treated as absent. */ private function sessionPrincipal(\WP_REST_Request $request): ?Principal { $botId = (int) $request->get_param('bot_id'); $token = (string) ($request->get_header('x_aiwend_session') ?? ''); $cookie = $this->cookiePrefix() . $botId; if ($token === '' && isset($_COOKIE[$cookie])) { $token = sanitize_text_field(wp_unslash((string) $_COOKIE[$cookie])); } if ($token === '') { return null; } $p = $this->plugin->sessions->authenticate($this->plugin->currentPrincipal(), $token); if ($p === null) { return null; } $session = $this->plugin->sessions->resource($p->siteId, (int) $p->chatSessionId); return ($session !== null && $session->attr('bot_id') === $botId) ? $p : null; } /** * Abuse throttle for session creation (not identity, not authorization): * fixed window per client address hash, or per user when logged in. * Behind a reverse proxy REMOTE_ADDR is the proxy; see docs/operations.md. */ private function allowSessionCreation(Principal $p): bool { $limit = $this->throttleLimit('session_create', apply_filters('aiwend_session_create_limit', 30), 30); $window = 600; $client = $this->clientId($p); $key = 'aiwend_rl_' . substr(hash_hmac('sha256', $client, wp_salt('nonce')), 0, 32); return $this->limiter()->allow($key, $limit, $window); } /** * A throttle limit after the filters: an invalid value (0, negative, non-numeric) never disables it; only a scope listed by * aiwend_throttles_disabled does (returns 0, which the limiter reads as "no limit"). */ private function throttleLimit(string $scope, mixed $filtered, int $default): int { return \Aiwend\Chat\LimitSetting::effective($scope, $filtered, $default, apply_filters('aiwend_throttles_disabled', [])); } /** The one throttle counter implementation: transients under a database lock (see lock()). */ private function limiter(): \Aiwend\Chat\FixedWindowLimiter { return new \Aiwend\Chat\FixedWindowLimiter( 'get_transient', 'set_transient', null, fn(string $k): bool => self::lock($k), static function (string $k): void { self::unlock($k); } ); } /** * Abuse throttle for visitor messages (Phase 86): a question that matches no knowledge is answered without reserving any budget, * so the usage limits alone do not bound the work and the stored rows one visitor can cause. Per conversation and per client * (address hash, or user), fixed one-minute windows; both limits can be changed with filters (whole numbers of 1 or more; * anything else falls back to the default). A throttle is switched off only by naming it in aiwend_throttles_disabled. * Operators' private previews are not throttled. */ private function allowMessage(Principal $p): bool { $limiter = $this->limiter(); $perConversation = $this->throttleLimit('conversation', apply_filters('aiwend_message_limit_conversation', 20), 20); $perClient = $this->throttleLimit('client', apply_filters('aiwend_message_limit_client', 60), 60); $conversation = 'aiwend_mrs_' . substr(hash('sha256', $p->siteId . '|' . (int) $p->chatSessionId), 0, 32); $clientId = $this->clientId($p); $client = 'aiwend_mrc_' . substr(hash_hmac('sha256', $clientId, wp_salt('nonce')), 0, 32); return $limiter->allow($conversation, $perConversation, 60) && $limiter->allow($client, $perClient, 60); } /** * Serialises the read-then-write of one throttle counter across simultaneous requests with a MySQL/MariaDB named lock (2 s wait). * true = the lock is held, or locking is unavailable on this database (then the counter is best effort); false = the wait timed * out, which counts as "too many requests". */ private static function lock(string $key): bool { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- a named database lock cannot use the WordPress API or a cache; the statement is prepared and the lock name is a hash $held = $wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s, 2)', 'aiwend_' . substr(md5($key), 0, 32))); return $held === null || (int) $held === 1; } private static function unlock(string $key): void { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- releases the named lock taken in lock(); prepared, no cache applies $wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', 'aiwend_' . substr(md5($key), 0, 32))); } /** * The throttles' notion of "this client": the account for a logged-in user, otherwise the connecting address. Forwarding headers * count only for proxies the operator lists with aiwend_trusted_proxies (see ClientAddress); aiwend_client_address can override. */ private function clientId(Principal $p): string { if ($p->userId !== null) { return 'u' . $p->userId; } $trusted = apply_filters('aiwend_trusted_proxies', []); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- every value is validated as an IP address by ClientAddress before use $address = \Aiwend\Chat\ClientAddress::resolve($_SERVER, is_array($trusted) ? array_values($trusted) : []); $address = (string) apply_filters('aiwend_client_address', $address); // An IPv6 address counts as its /64 network (see ClientAddress::network), so rotating addresses does not dodge a limit. return 'a' . \Aiwend\Chat\ClientAddress::network($address); } /** * The visitor's daily budget bucket (Phase 89, P88-1), or null for an operator's private preview. Logged-in visitors are * counted by account, anonymous ones by network address. A request that cannot be told apart from others (no usable address, * or forwarding headers from an undeclared proxy) goes to a shared bucket with its own larger allowance, so it can never use up * the allowance of identified visitors. Nothing is stored about the address except a keyed one-way token that changes daily. */ private function clientAllowance(Principal $p): ?\Aiwend\Usage\ClientAllowance { if ($this->preview) { return null; } $clientId = $this->clientId($p); $kind = \Aiwend\Usage\ClientBudget::KIND_CLIENT; if ($p->userId === null) { $trusted = apply_filters('aiwend_trusted_proxies', []); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- only header presence and validated addresses are used; no header value is stored [$class] = \Aiwend\Chat\ClientAddress::identify($_SERVER, is_array($trusted) ? array_values($trusted) : []); // A forged header from a public address stays "client" (see ClientAddress::identify): it cannot pick another allowance. $kind = match ($class) { 'shared' => \Aiwend\Usage\ClientBudget::KIND_SHARED, 'unknown' => \Aiwend\Usage\ClientBudget::KIND_UNKNOWN, default => \Aiwend\Usage\ClientBudget::KIND_CLIENT, }; } $options = $this->plugin->options; $ledger = $this->plugin->ledger; $siteId = $p->siteId; $bucket = \Aiwend\Usage\ClientBudget::bucket($kind, $clientId, wp_salt('nonce'), $this->plugin->clock->now()); if ($kind !== \Aiwend\Usage\ClientBudget::KIND_CLIENT) { // Tell the administrator about undeclared proxies / unidentifiable visitors as soon as they appear, not only when a // combined bucket runs out. Bounded: the alert option is rewritten at most every few minutes. $this->plugin->alerts->note($bucket); } return new \Aiwend\Usage\ClientAllowance( $bucket, $kind, static fn(): int => \Aiwend\Usage\ClientBudget::limit( $kind, \Aiwend\Usage\ClientBudget::dailyUnits($options->get(\Aiwend\Usage\ClientBudget::OPTION_DAILY)), \Aiwend\Usage\ClientBudget::sharePercent($options->get(\Aiwend\Usage\ClientBudget::OPTION_SHARE)), (int) ($ledger->balance($siteId, 'site')['limit'] ?? 0) ) ); } private function cancelFlag(int $siteId, int $sessionId, string $requestId): string { // Session-scoped, so one visitor cannot cancel another's request. return 'aiwend_cx_' . md5($siteId . '|' . $sessionId . '|' . $requestId); } private function setCookie(int $botId, string $value, int $expires): void { if (headers_sent()) { return; } setcookie($this->cookiePrefix() . $botId, $value, [ 'expires' => $expires, 'path' => COOKIEPATH ?: '/', 'secure' => is_ssl(), 'httponly' => true, 'samesite' => 'Strict', ]); } private function cookiePrefix(): string { return $this->preview ? self::PREVIEW_COOKIE_PREFIX : self::COOKIE_PREFIX; } /** Records a successful private preview (state for the onboarding steps). */ private function notePreview(Principal $p, \Aiwend\Chat\ChatReply $reply): void { if (!$this->preview || $reply->cancelled || $reply->text === '') { return; } $session = $this->plugin->sessions->resource($p->siteId, (int) $p->chatSessionId); $ok = (array) get_option(self::PREVIEW_OK_OPTION, []); $ok[(int) ($session?->attr('bot_id') ?? 0)] = time(); update_option(self::PREVIEW_OK_OPTION, $ok, false); } private function error(string $code, int $status, ?string $requestId): \WP_Error { $error = Errors::response($code, $status, $requestId); $hint = $this->hint($code); if ($hint !== []) { $error->add_data($hint + (array) $error->get_error_data()); } return $error; } /** * Preview only: what the operator can do about an error. Public visitors never get this * (it names settings screens). * * @return array{hint?:string, action_url?:string, action_label?:string} */ private function hint(string $code): array { if (!$this->preview) { return []; } $settings = admin_url('admin.php?page=aiwend-settings'); $usage = admin_url('admin.php?page=aiwend-usage'); $map = [ 'provider_not_configured' => [__('The assigned connection is unavailable, or no connection is assigned and local demo is off.', 'aiwend'), admin_url('admin.php?page=aiwend-connections'), __('Review AI connections', 'aiwend')], 'budget_not_configured' => [__('No site usage limit is set, so nothing can be answered.', 'aiwend'), $usage, __('Set a limit', 'aiwend')], 'usage_limit_reached' => [__('The site or conversation usage limit is reached.', 'aiwend'), $usage, __('Review limits', 'aiwend')], 'client_limit_reached' => [__('This visitor has used the daily allowance per visitor.', 'aiwend'), $usage, __('Review limits', 'aiwend')], 'spending_paused' => [__('New requests are paused under Usage.', 'aiwend'), $usage, __('Review limits', 'aiwend')], 'knowledge_unavailable' => [__('This assistant uses knowledge, and the Knowledge module is off.', 'aiwend'), $settings, __('Open settings', 'aiwend')], 'knowledge_failed' => [__('Searching this assistant\'s knowledge failed (embedding provider, usage limit or database). Check Usage and Diagnostics; the request was not sent to the model.', 'aiwend'), $usage, __('Review usage', 'aiwend')], ]; if (!isset($map[$code])) { return []; } return ['hint' => $map[$code][0], 'action_url' => esc_url_raw($map[$code][1]), 'action_label' => $map[$code][2]]; } private function noStore(\WP_REST_Response $response): \WP_REST_Response { $response->header('Cache-Control', 'no-store, private'); $response->header('Vary', 'Cookie'); return $response; } }