may()) { $n = AiFormsConfig::store($this->plugin)->unread(get_current_blog_id(), $this->plugin->clock->now()); if ($n > 0) { $label .= ' ' . ($n >= 100 ? '99+' : (string) $n) . ''; } } add_submenu_page('aiwend', __('AI Forms', 'aiwend'), $label, Capabilities::MANAGE_FORMS, self::SLUG, [$this, 'render']); }); foreach (['save' => 'doSave', 'delete' => 'doDelete', 'retention' => 'doRetention', 'delete_submission' => 'doDeleteSubmission', 'analyze' => 'doAnalyze'] as $action => $method) { add_action('admin_post_aiwend_forms_' . $action, [$this, $method]); } add_action('admin_enqueue_scripts', function (): void { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation parameter; no state is changed if (sanitize_key(Input::get('page')) === self::SLUG) { wp_add_inline_script('aiwend-admin', "document.addEventListener('submit',function(e){var f=e.target;if(!f.hasAttribute||!f.hasAttribute('data-aiwend-busy')){return;}var b=f.querySelector('button[type=submit]');var s=f.querySelector('[data-aiwend-busy-text]');if(b){setTimeout(function(){b.disabled=true;},0);}if(s){s.hidden=false;}});"); } }, 20); } private function may(): bool { return $this->plugin->modules->isEnabled(AiFormsModule::ID) && current_user_can(Capabilities::MANAGE_FORMS); } private function guard(string $nonce): void { if (!$this->may()) { wp_die(esc_html__('Not authorized.', 'aiwend'), '', ['response' => 403]); } check_admin_referer($nonce); } /** @param array $args */ private function back(string $code, array $args = []): never { wp_safe_redirect(add_query_arg(array_merge(['page' => self::SLUG, 'fm' => $code], $args), admin_url('admin.php'))); exit; } private static function requestId(int $id, string $token): string { return 'fm_' . substr(hash_hmac('sha256', get_current_blog_id() . '|' . $id . '|' . $token, wp_salt('auth')), 0, 24); } /** @return list */ private function choices(): array { $site = get_current_blog_id(); $out = []; $repo = $this->plugin->connections(); foreach ($repo === null ? [] : $repo->list($site) as $c) { if ($c['status'] !== 'verified') { continue; } foreach ($this->plugin->ai()->pickerModels((int) $c['id']) as $m) { $out[] = ['value' => (int) $c['id'] . ':' . $m['id'], 'label' => (string) $c['label'] . ' · ' . $m['id']]; } } if ($this->plugin->mockProviderEnabled()) { $out[] = ['value' => 'demo', 'label' => __('Simulated demo (no AI provider, no charge)', 'aiwend')]; } return $out; } /** True when a verified xAI connection exists on this site (then the Analyze form shows the extra xAI confirmation). */ private function hasXaiChoice(): bool { $repo = $this->plugin->connections(); foreach ($repo === null ? [] : $repo->list(get_current_blog_id()) as $c) { if ($c['status'] === 'verified' && (string) $c['provider'] === \Aiwend\Provider\XaiPublicChat::PROVIDER) { return true; } } return false; } // ------------------------------------------------------------------ handlers // phpcs:disable WordPress.Security.NonceVerification.Missing -- every handler below calls guard() first: capability, enabled module and check_admin_referer() with the handler's own nonce public function doSave(): void { $this->guard('aiwend_forms_save'); $id = absint($_POST['form_id'] ?? 0); $in = [ // Strings only (an array or any other shape becomes ''), sanitized here and normalized again by FormDefinitions::normalize() before anything is stored. 'title' => isset($_POST['title']) && is_string($_POST['title']) ? sanitize_text_field(wp_unslash($_POST['title'])) : '', 'intro' => isset($_POST['intro']) && is_string($_POST['intro']) ? sanitize_textarea_field(wp_unslash($_POST['intro'])) : '', 'topics' => isset($_POST['topics']) && is_string($_POST['topics']) ? sanitize_textarea_field(wp_unslash($_POST['topics'])) : '', // one topic per line; the line breaks are kept 'show_name' => !empty($_POST['show_name']), 'ack' => !empty($_POST['ack']), 'active' => !empty($_POST['active']), ]; $problem = AiFormsConfig::save($id, $in); $this->back($problem === '' ? 'saved' : $problem, ['view' => 'forms']); } public function doDelete(): void { $this->guard('aiwend_forms_delete'); AiFormsConfig::delete(absint($_POST['form_id'] ?? 0)); $this->back('form_deleted', ['view' => 'forms']); } public function doRetention(): void { $this->guard('aiwend_forms_retention'); $days = max(AiFormsConfig::RETENTION_MIN, min(AiFormsConfig::RETENTION_MAX, absint($_POST['days'] ?? AiFormsConfig::RETENTION_DEFAULT))); update_option(AiFormsConfig::RETENTION_OPTION, $days, false); AiFormsConfig::store($this->plugin)->shorten(get_current_blog_id(), $days); $this->back('retention_saved', ['view' => 'forms']); } public function doDeleteSubmission(): void { $this->guard('aiwend_forms_delete_submission'); AiFormsConfig::store($this->plugin)->delete(get_current_blog_id(), absint($_POST['id'] ?? 0)); $this->back('submission_deleted'); } public function doAnalyze(): void { $this->guard('aiwend_forms_analyze'); $site = get_current_blog_id(); $id = absint($_POST['id'] ?? 0); $store = AiFormsConfig::store($this->plugin); $now = $this->plugin->clock->now(); $row = $store->find($site, $id, $now); if ($row === null) { $this->back('gone'); } if (empty($_POST['confirm'])) { $this->back('unconfirmed', ['id' => $id]); } $token = isset($_POST['token']) && is_string($_POST['token']) ? sanitize_key(wp_unslash($_POST['token'])) : ''; // accepted only if exactly 24 lowercase hex characters if (preg_match('/^[a-f0-9]{24}$/', $token) !== 1) { $this->back('invalid', ['id' => $id]); } $target = isset($_POST['target']) && is_string($_POST['target']) ? sanitize_text_field(wp_unslash($_POST['target'])) : ''; // then a strict pattern and the site's own verified connections below $ai = $this->plugin->ai(); $connectionId = null; if ($target === 'demo' && $this->plugin->mockProviderEnabled()) { $model = 'mock-1'; $adapter = (new MockAdapter(false, 0))->queue(['text' => "SUMMARY: Simulated summary of the message (demo mode, not a real AI).\nCATEGORY: other\nREPLY:\nThank you for your message. This is a simulated reply draft for review; no real AI wrote it."]); } elseif (preg_match('/^([1-9][0-9]{0,9}):(.{1,128})$/', $target, $m) === 1) { $connectionId = (int) $m[1]; $model = $m[2]; $connection = $this->plugin->connections()?->find($site, $connectionId); if ($connection === null || $connection['status'] !== 'verified' || !Providers::validModelIdFor((string) $connection['provider'], $model) || !$ai->modelKnown($connectionId, $model)) { $this->back('connection', ['id' => $id]); } // 0.9.0: a visitor's stored message is free text that can hold personal data. An administrator sending it to xAI must tick a separate // box (checked here, on the server, before any reservation or request). This is a deliberate-action safeguard, not a ZDR check. if ((string) $connection['provider'] === \Aiwend\Provider\XaiPublicChat::PROVIDER && empty($_POST['confirm_xai'])) { $this->back('xai_unconfirmed', ['id' => $id]); } try { $adapter = $ai->adapter($site, $connection); } catch (\Throwable) { $this->back('connection', ['id' => $id]); } } else { $this->back('connection', ['id' => $id]); } $requestId = self::requestId($id, $token); $code = $this->runAnalysis($id, $requestId, $adapter, $model, $connectionId, !empty($_POST['again'])); $this->back($code, $code === 'gone' ? [] : ['id' => $id]); } /** * Claims the submission, runs ONE analysis and stores the result. Returns a notice code: analysed | gone | busy | not_claimable | a failure code. * Never throws: whatever goes wrong after the claim leaves the row failed (nothing billable happened) or uncertain (it may have been dispatched), * never "running", and nothing is retried. Separate from the request handler so the failure paths can be tested without redirects. */ public function runAnalysis(int $id, string $requestId, \Aiwend\Provider\ModelAdapter $adapter, string $model, ?int $connectionId, bool $again): string { $site = get_current_blog_id(); $store = AiFormsConfig::store($this->plugin); $now = $this->plugin->clock->now(); $row = $store->find($site, $id, $now); if ($row === null) { return 'gone'; } if (!$store->claim($site, $id, $requestId, $now, $again)) { return SubmissionStore::effectiveState($row, $now) === SubmissionStore::RUNNING ? 'busy' : 'not_claimable'; } try { $result = (new FormAnalysis($this->plugin->ledger, $this->plugin->audit))->analyze($this->plugin->currentPrincipal(), $requestId, (string) $row['topic'], (string) $row['message'], $adapter, $model, $connectionId, $id); } catch (FormException $e) { $uncertain = $e->reason === 'outcome_uncertain' || str_starts_with($e->reason, 'uncertain_'); $store->fail($site, $id, $requestId, $uncertain ? SubmissionStore::UNCERTAIN : SubmissionStore::FAILED, $this->plugin->clock->now()); return self::noticeFor($e->reason); } catch (\Throwable) { // Anything unexpected between the claim and the result (a database error, a bug): whether a request was dispatched is unknown, so the // row is marked uncertain at once (a plain retry is refused, an explicit repeat is possible) instead of showing "running" for minutes. $store->fail($site, $id, $requestId, SubmissionStore::UNCERTAIN, $this->plugin->clock->now()); return 'outcome_uncertain'; } if (!$store->finish($site, $id, $requestId, $result['summary'], $result['category'], $result['reply'], $result['model'], $this->plugin->clock->now())) { return 'gone'; // the submission was erased or deleted while the request ran: nothing is stored } return 'analysed'; } // phpcs:enable WordPress.Security.NonceVerification.Missing private static function noticeFor(string $reason): string { return match (true) { in_array($reason, ['no_budget', 'budget_exceeded', 'paused', 'duplicate', 'outcome_uncertain', 'empty_output', 'forbidden'], true) => $reason, str_starts_with($reason, 'uncertain_') => 'outcome_uncertain', $reason === 'provider_timeout' => 'timeout', $reason === 'provider_auth' => 'auth', $reason === 'provider_rate_limited' || $reason === 'provider_quota_exhausted' => 'rate', default => 'failed', }; } /** @return array notice code => [text, tone] */ private static function notices(): array { return [ 'saved' => [__('The form was saved.', 'aiwend'), 'success'], 'form_deleted' => [__('The form was deleted. Messages already received stay in the inbox until they expire.', 'aiwend'), 'success'], 'retention_saved' => [__('The retention period was saved. Messages kept longer than this were set to expire now.', 'aiwend'), 'success'], 'submission_deleted' => [__('The message was deleted.', 'aiwend'), 'success'], 'analysed' => [__('Analysis finished. Check it before you rely on it: it can be wrong.', 'aiwend'), 'success'], 'invalid' => [__('That form could not be saved. Give it a title.', 'aiwend'), 'error'], 'unknown' => [__('That form no longer exists.', 'aiwend'), 'error'], 'too_many_active' => [sprintf( /* translators: %d: maximum number of active forms */ __('Only %d forms can be active at a time. Turn another one off first.', 'aiwend'), FormDefinitions::MAX_ACTIVE ), 'error'], 'too_many_forms' => [sprintf( /* translators: %d: maximum number of forms */ __('You can keep at most %d forms. Delete one first.', 'aiwend'), FormDefinitions::MAX_TOTAL ), 'error'], 'gone' => [__('That message no longer exists (it was deleted, erased or expired). Nothing was stored.', 'aiwend'), 'error'], 'unconfirmed' => [__('Tick the confirmation box to send this message to the AI connection. Nothing was sent.', 'aiwend'), 'error'], 'connection' => [__('Choose an AI connection that is verified and ready. Nothing was sent.', 'aiwend'), 'error'], 'xai_unconfirmed' => [__('To send this message to xAI, tick the extra xAI box first. Nothing was sent.', 'aiwend'), 'error'], 'busy' => [__('An analysis of this message is already running. Wait for it to finish.', 'aiwend'), 'warn'], 'not_claimable' => [__('This message already has an analysis or an unfinished attempt. To run it again, tick the repeat box. Nothing was sent.', 'aiwend'), 'warn'], 'no_budget' => [__('No usage limit is set, so nothing can be analysed yet.', 'aiwend'), 'error'], 'budget_exceeded' => [__('The usage limit is used up. Nothing was sent.', 'aiwend'), 'error'], 'paused' => [__('AI spending is paused. Nothing was sent.', 'aiwend'), 'warn'], 'duplicate' => [__('That request was already handled. Reload the message.', 'aiwend'), 'warn'], 'outcome_uncertain' => [__('The result of the request is not known; it may have been billed. It is NOT retried automatically. Check the Usage page before repeating it.', 'aiwend'), 'warn'], 'empty_output' => [__('The AI returned nothing usable. Try again, or write the reply yourself.', 'aiwend'), 'error'], 'timeout' => [__('The AI connection did not answer in time. The message is still saved.', 'aiwend'), 'warn'], 'auth' => [__('The AI connection was refused. Check the key on the Connections page. The message is still saved.', 'aiwend'), 'error'], 'rate' => [__('The AI provider is limiting requests. Try again later. The message is still saved.', 'aiwend'), 'warn'], 'failed' => [__('The analysis failed. The message is still saved.', 'aiwend'), 'error'], 'forbidden' => [__('You are not allowed to do that.', 'aiwend'), 'error'], ]; } // ------------------------------------------------------------------ screens public function render(): void { if (!$this->may()) { wp_die(esc_html__('Not authorized.', 'aiwend'), '', ['response' => 403]); } nocache_headers(); AdminView::open($this->plugin, self::SLUG, __('AI Forms', 'aiwend'), __('Simple inquiry forms for your pages. Messages stay on this site for you to read. Nothing is emailed, and nothing goes to an AI provider unless you press Analyze on a message.', 'aiwend')); // phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only navigation parameters; no state is changed $code = sanitize_key(Input::get('fm')); $view = sanitize_key(Input::get('view')); $id = absint($_GET['id'] ?? 0); $formFilter = absint($_GET['form'] ?? 0); $paged = max(1, absint($_GET['paged'] ?? 1)); // phpcs:enable WordPress.Security.NonceVerification.Recommended $notices = self::notices(); if (isset($notices[$code])) { $tone = $notices[$code][1] === 'error' ? 'warn' : $notices[$code][1]; echo '

' . esc_html($notices[$code][0]) . '

'; } if (AiFormsFront::privacyPolicyUrl() === '' && array_filter(AiFormsConfig::forms(), static fn(array $f): bool => !empty($f['active'])) !== []) { echo '

' . esc_html__('No privacy policy page is published.', 'aiwend') . ' ' . esc_html__('Visitors see how a message is used next to the form, but no link to a privacy policy, because none is set. Choose or write a policy page so the form can link to it.', 'aiwend'); if (current_user_can('manage_privacy_options')) { echo ' ' . esc_html__('Open Settings > Privacy', 'aiwend') . ''; } echo '

'; } echo ''; if ($view === 'forms') { $this->formsView(); } elseif ($id > 0) { $this->detail($id); } else { $this->inbox($formFilter, $paged); } AdminView::close(); } private function inbox(int $formFilter, int $paged): void { $site = get_current_blog_id(); $now = $this->plugin->clock->now(); $store = AiFormsConfig::store($this->plugin); $forms = AiFormsConfig::forms(); $unread = $store->unreadByForm($site, $now); $days = AiFormsConfig::retentionDays(); echo '

' . esc_html__('Inbox', 'aiwend') . '

'; /* translators: 1: number of unread messages, 2: retention days */ echo '

' . esc_html(sprintf(__('%1$d unread. Messages are deleted automatically after %2$d days.', 'aiwend'), array_sum($unread), $days)) . ' ' . esc_html__('Change this', 'aiwend') . '

'; if ($forms === []) { echo '

' . esc_html__('You have not created a form yet.', 'aiwend') . ' ' . esc_html__('Create a form', 'aiwend') . '

'; } echo '

'; $rows = $store->recent($site, $formFilter, $now, self::PAGE_SIZE + 1, ($paged - 1) * self::PAGE_SIZE); $more = count($rows) > self::PAGE_SIZE; $rows = array_slice($rows, 0, self::PAGE_SIZE); if ($rows === []) { echo '

' . esc_html__('No messages.', 'aiwend') . '

'; return; } echo '
    '; foreach ($rows as $r) { $title = $forms[(int) $r['form_id']]['title'] ?? sprintf( /* translators: %d: form number */ __('Deleted form #%d', 'aiwend'), (int) $r['form_id'] ); $state = SubmissionStore::effectiveState($r, $now); echo wp_kses( '
  • ' . esc_html(mb_substr(trim((string) preg_replace('/\s+/u', ' ', (string) $r['message'])), 0, 90)) . ' ' . ($r['read_at'] === null ? AdminView::badge(__('New', 'aiwend'), 'info') : '') . ($state === SubmissionStore::DONE ? ' ' . AdminView::badge(__('Analysed', 'aiwend'), 'success') : '') . '
    ' . esc_html($title . ' · ' . ((string) $r['name'] !== '' ? $r['name'] . ' · ' : '') . $r['email'] . ' · ' . wp_date('Y-m-d H:i', (int) $r['created_at'])) . '
  • ', AdminView::allowedHtml()); } echo '

'; if ($paged > 1) { echo '' . esc_html__('Newer', 'aiwend') . ' '; } if ($more) { echo '' . esc_html__('Older', 'aiwend') . ''; } echo '

'; } private function detail(int $id): void { $site = get_current_blog_id(); $now = $this->plugin->clock->now(); $store = AiFormsConfig::store($this->plugin); $r = $store->find($site, $id, $now); if ($r === null) { echo '

' . esc_html__('That message does not exist or has expired.', 'aiwend') . ' ' . esc_html__('Back to the inbox', 'aiwend') . '

'; return; } $store->markRead($site, $id, $now); $forms = AiFormsConfig::forms(); echo '

' . esc_html__('Message', 'aiwend') . '

' . esc_html__('Back to the inbox', 'aiwend') . '

'; $line = static function (string $k, string $v): void { echo '
' . esc_html($k) . '
' . esc_html($v) . '
'; }; $line(__('Form', 'aiwend'), $forms[(int) $r['form_id']]['title'] ?? ('#' . (int) $r['form_id'])); $line(__('Received', 'aiwend'), wp_date('Y-m-d H:i', (int) $r['created_at'])); $line(__('Deleted automatically on', 'aiwend'), wp_date('Y-m-d H:i', (int) $r['expires_at'])); $line(__('Name', 'aiwend'), (string) $r['name'] !== '' ? (string) $r['name'] : '—'); $line(__('Email', 'aiwend'), (string) $r['email']); $line(__('Topic', 'aiwend'), (string) $r['topic'] !== '' ? (string) $r['topic'] : '—'); echo '

' . esc_html__('Message text', 'aiwend') . '

' . esc_html((string) $r['message']) . '
'; echo '

'; $this->analysisPanel($r, $now); } /** @param array $r */ private function analysisPanel(array $r, int $now): void { $id = (int) $r['id']; $state = SubmissionStore::effectiveState($r, $now); echo '

' . esc_html__('AI analysis (optional)', 'aiwend') . '

'; if ($state === SubmissionStore::DONE) { echo '

' . esc_html__('AI-generated, not reviewed.', 'aiwend') . ' ' . esc_html__('It can be wrong or invent details. It is never sent to the visitor by Aiwend.', 'aiwend') . '

'; echo '

' . esc_html__('Summary', 'aiwend') . '

' . esc_html((string) $r['summary']) . '

'; echo '

' . esc_html__('Category', 'aiwend') . '

' . esc_html((string) $r['category']) . '

'; echo '

' . esc_html__('Suggested reply draft', 'aiwend') . '

' . esc_html((string) $r['reply']) . '
'; echo '

' . esc_html(sprintf( /* translators: %s: model name */ __('Model: %s', 'aiwend'), (string) $r['analysis_model'] )) . '

'; } elseif ($state === SubmissionStore::RUNNING) { echo '

' . esc_html__('An analysis is running. Reload in a minute.', 'aiwend') . '

'; return; } elseif ($state === SubmissionStore::UNCERTAIN) { echo ''; } elseif ($state === SubmissionStore::FAILED) { echo '

' . esc_html__('The last attempt failed. The message is still saved.', 'aiwend') . '

'; } $site = get_current_blog_id(); $choices = $this->choices(); $balance = $this->plugin->ledger->balance($site, 'site'); if ($this->plugin->ledger->isHalted()) { echo '

' . esc_html__('AI spending is paused, so nothing can be analysed. You can still read and delete messages.', 'aiwend') . '

'; return; } if ($choices === []) { echo '

' . esc_html__('There is no AI connection ready to use. You can still read and delete messages.', 'aiwend') . ' ' . esc_html__('Add or check a connection', 'aiwend') . '

'; return; } if ($balance === null || (int) ($balance['limit'] ?? 0) <= 0) { echo '

' . esc_html__('No usage limit is set, so nothing can be analysed yet.', 'aiwend') . ' ' . esc_html__('Set a limit', 'aiwend') . '

'; return; } echo '

' . esc_html__('Only the topic and the message text shown above are sent to the AI connection you choose. The name, email address and other messages are not sent. The message text can still contain personal or sensitive details, so do not analyse it unless you are allowed to share it with that provider. In the simulated demo nothing leaves this site. A real request uses your provider account and counts against your usage limit.', 'aiwend') . '

'; echo '
'; echo '

'; if (in_array($state, [SubmissionStore::DONE, SubmissionStore::UNCERTAIN], true)) { echo '

'; } if ($this->hasXaiChoice()) { echo '

' . esc_html__('If you choose an xAI connection above: xAI\'s terms for API use may restrict sending personal data unless Zero Data Retention applies, and Aiwend cannot check that setting. This message was written by a visitor and may contain personal details.', 'aiwend') . '

'; echo '

'; } echo '

'; echo '

'; } private function formsView(): void { $forms = AiFormsConfig::forms(); $active = count(array_filter($forms, static fn(array $f): bool => $f['active'])); echo '

' . esc_html__('Retention', 'aiwend') . '

'; echo '
'; echo wp_kses( '

' . esc_html__('Messages, and any AI summary or draft saved with them, are deleted automatically when this time has passed. Lowering the number also shortens messages already stored. Visitors are told this number on the form. Backups of your site and copies held by an AI provider are not covered.', 'aiwend') . '

', AdminView::allowedHtml()); echo '

' . esc_html__('Forms', 'aiwend') . '

' . esc_html(sprintf( /* translators: 1: active forms, 2: maximum active forms */ __('%1$d of %2$d active forms. Put a form on a page with its shortcode. The fields are fixed: name (optional), email, topic (optional list) and message.', 'aiwend'), $active, FormDefinitions::MAX_ACTIVE )) . '

'; foreach ($forms as $f) { $this->formEditor($f); } $this->formEditor(null); echo '
'; } /** @param array|null $f */ private function formEditor(?array $f): void { $id = (int) ($f['id'] ?? 0); $p = 'aw-fm-' . $id; echo wp_kses( '
' . esc_html($f === null ? __('Add a form', 'aiwend') : $f['title']) . ' ' . ($f === null ? '' : ($f['active'] ? AdminView::badge(__('Active', 'aiwend'), 'success') : AdminView::badge(__('Off', 'aiwend'), 'neutral'))) . '', AdminView::allowedHtml()); if ($f !== null) { echo '

' . esc_html__('Shortcode:', 'aiwend') . ' [aiwend_form id="' . (int) $id . '"]

'; } echo wp_kses( '
', AdminView::allowedHtml()); echo wp_kses( '

', AdminView::allowedHtml()); echo wp_kses( '

', AdminView::allowedHtml()); echo '

'; echo '



'; echo '

'; if ($f !== null) { echo wp_kses( '

', AdminView::allowedHtml()); } echo '
'; } }