clock->now(); return $this->db->insert('bots', [ 'site_id' => $siteId, 'name' => $this->text($data['name'], 191, 'Name'), 'status' => $this->status($data['status'] ?? 'draft'), 'connection_id' => $data['connection_id'] ?? null, 'model' => $this->text($data['model'], 191, 'Model'), 'instruction' => $this->text($data['instruction'], 20000, 'Instruction', true), 'instruction_version' => 1, 'is_public' => !empty($data['is_public']) ? 1 : 0, 'max_output_tokens' => max(16, min(8192, (int) ($data['max_output_tokens'] ?? 800))), 'response_language' => $this->language($data['response_language'] ?? 'auto'), 'created_at' => $now, 'updated_at' => $now, ]); } /** @param array $changes */ public function update(int $siteId, int $id, array $changes): bool { $current = $this->find($siteId, $id); if ($current === null) { return false; } $sets = []; $params = []; if (array_key_exists('name', $changes)) { $sets[] = 'name = ?'; $params[] = $this->text((string) $changes['name'], 191, 'Name'); } if (array_key_exists('status', $changes)) { $sets[] = 'status = ?'; $params[] = $this->status((string) $changes['status']); } if (array_key_exists('is_public', $changes)) { $sets[] = 'is_public = ?'; $params[] = $changes['is_public'] ? 1 : 0; } if (array_key_exists('instruction', $changes)) { $sets[] = 'instruction = ?'; $sets[] = 'instruction_version = instruction_version + 1'; $params[] = $this->text((string) $changes['instruction'], 20000, 'Instruction', true); } if (array_key_exists('max_output_tokens', $changes)) { $sets[] = 'max_output_tokens = ?'; $params[] = max(16, min(8192, (int) $changes['max_output_tokens'])); } if ($sets === []) { return true; } $sets[] = 'updated_at = ?'; $params[] = $this->clock->now(); $params[] = $id; $params[] = $siteId; return $this->db->execute('UPDATE {bots} SET ' . implode(', ', $sets) . ' WHERE id = ? AND site_id = ?' . self::LIVE, $params) === 1; } /** * Accent colours offered for the widget. A fixed palette (not free CSS): each passes * WCAG AA contrast (>= 4.5:1) for white text and focus rings on a white page. */ public const ACCENTS = [ '#5746d9' => 'Violet', '#2563eb' => 'Blue', '#0f766e' => 'Teal', '#b45309' => 'Amber', '#be185d' => 'Pink', '#b91c1c' => 'Red', '#475569' => 'Slate', '#4f46e5' => 'Indigo', // Aiwend brand default for new demo assistants; not the fallback. ]; /** Inline embeds remain available when an assistant also has a floating launcher. */ public const PLACEMENTS = ['inline', 'floating']; /** * AI mode without a schema change (bots.model when connection_id is NULL): * MODEL_INHERIT follows the site default connection/model; MODEL_DEMO is the simulated demo. * With a connection_id, model is the explicit provider model ID. */ public const MODEL_INHERIT = 'inherit'; public const MODEL_DEMO = 'mock-1'; /** Providers whose connections may be assigned (kept in sync with Provider\Providers). */ public const PROVIDERS = ['openai', 'anthropic', 'gemini', 'deepseek', 'xai', 'openrouter', 'groq']; /** Colour mode: follow the visitor's system setting, or force light or dark. */ public const THEMES = ['auto', 'light', 'dark']; /** Header avatar from a fixed set; no uploaded or remote images. */ public const AVATARS = ['mark', 'initial', 'none', 'custom']; /** * Normalised appearance of a bot (stored JSON merged over defaults). * * @param array $bot a row from find() * @return array{display_name:string, welcome:string, accent:string, placement:string} */ public static function appearance(array $bot): array { $stored = json_decode((string) ($bot['appearance'] ?? ''), true); $stored = is_array($stored) ? $stored : []; $accent = strtolower((string) ($stored['accent'] ?? '')); try { $rules = DisplayRules::normalize($stored); } catch (\InvalidArgumentException) { $rules = DisplayRules::DEFAULTS; $stored['placement'] = 'inline'; // Corrupt rules must not broaden public display. } try { $suggestions = self::suggestions($stored['suggestions'] ?? []); } catch (\InvalidArgumentException) { $suggestions = []; } try { $colors = Palette::normalize($stored['colors'] ?? []); } catch (\InvalidArgumentException) { $colors = Palette::normalize([]); // corrupt colours fall back to the built-in palette } return [ 'display_name' => is_string($stored['display_name'] ?? null) && $stored['display_name'] !== '' ? $stored['display_name'] : (string) ($bot['name'] ?? ''), 'welcome' => is_string($stored['welcome'] ?? null) ? $stored['welcome'] : '', // A preset, or a custom brand colour (any valid #rrggbb; contrast is advised in the editor). 'accent' => isset(self::ACCENTS[$accent]) || Palette::isHex($accent) ? $accent : array_key_first(self::ACCENTS), 'placement' => in_array($stored['placement'] ?? '', self::PLACEMENTS, true) ? (string) $stored['placement'] : 'inline', 'theme' => in_array($stored['theme'] ?? '', self::THEMES, true) ? (string) $stored['theme'] : 'auto', // 'custom' needs a positive Media Library attachment ID (validated where WordPress is available). 'avatar' => in_array($stored['avatar'] ?? '', self::AVATARS, true) && ($stored['avatar'] !== 'custom' || (int) ($stored['avatar_id'] ?? 0) > 0) ? (string) $stored['avatar'] : 'mark', 'avatar_id' => max(0, (int) ($stored['avatar_id'] ?? 0)), 'suggestions' => $suggestions, 'colors' => $colors, 'theme_toggle' => !empty($stored['theme_toggle']), ] + $rules; } /** * Suggested questions: up to 4 plain-text lines of 1–120 characters, given as a list or as * newline-separated text. Blank lines are ignored; duplicates are removed. * * @param mixed $input * @return list * @throws \InvalidArgumentException */ public static function suggestions(mixed $input): array { $lines = is_array($input) ? $input : explode("\n", str_replace("\r", '', (string) $input)); $out = []; foreach ($lines as $line) { if (!is_string($line)) { throw new \InvalidArgumentException('Invalid suggested question.'); } $line = trim((string) preg_replace('/\s+/u', ' ', $line)); if ($line === '') { continue; } if (!mb_check_encoding($line, 'UTF-8') || mb_strlen($line) > 120 || preg_match('/[<>\x00-\x1f]/', $line)) { throw new \InvalidArgumentException('Suggested questions must be plain text, up to 120 characters each.'); } $out[$line] = true; } if (count($out) > 4) { throw new \InvalidArgumentException('Use at most 4 suggested questions.'); } return array_keys($out); } /** * Validates and stores appearance. Plain text only (no HTML, no URLs are interpreted), * colours from the fixed palette, placement from the supported list. * * @param array{display_name?:string, welcome?:string, accent?:string, placement?:string} $input * @throws \InvalidArgumentException */ public function setAppearance(int $siteId, int $id, array $input): bool { if ($this->find($siteId, $id) === null) { return false; } $display = trim((string) preg_replace('/\s+/u', ' ', (string) ($input['display_name'] ?? ''))); $welcome = trim(str_replace("\r", '', (string) ($input['welcome'] ?? ''))); $accent = strtolower((string) ($input['accent'] ?? '')); $placement = (string) ($input['placement'] ?? 'inline'); foreach (['display_name' => [$display, 80], 'welcome' => [$welcome, 300]] as $field => [$value, $max]) { if (!mb_check_encoding($value, 'UTF-8') || mb_strlen($value) > $max || preg_match('/[<>]/', $value)) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception messages go to logs and callers, never echoed; public errors are fixed codes throw new \InvalidArgumentException("Invalid $field."); } } if (!isset(self::ACCENTS[$accent]) && !Palette::isHex($accent)) { throw new \InvalidArgumentException('Invalid accent colour.'); } if (!in_array($placement, self::PLACEMENTS, true)) { throw new \InvalidArgumentException('Unsupported placement.'); } $current = self::appearance($this->find($siteId, $id)); $theme = (string) ($input['theme'] ?? $current['theme']); $avatar = (string) ($input['avatar'] ?? $current['avatar']); if (!in_array($theme, self::THEMES, true) || !in_array($avatar, self::AVATARS, true)) { throw new \InvalidArgumentException('Unsupported colour mode or avatar.'); } $avatarId = filter_var($input['avatar_id'] ?? $current['avatar_id'], FILTER_VALIDATE_INT); if ($avatarId === false || $avatarId < 0 || ($avatar === 'custom' && $avatarId === 0)) { throw new \InvalidArgumentException('A custom avatar needs a selected image.'); } $suggestions = self::suggestions($input['suggestions'] ?? $current['suggestions']); $colors = Palette::normalize($input['colors'] ?? $current['colors']); $toggle = array_key_exists('theme_toggle', $input) ? (bool) $input['theme_toggle'] : $current['theme_toggle']; $rules = DisplayRules::normalize(array_replace($current, $input)); $json = (string) json_encode(['display_name' => $display, 'welcome' => $welcome, 'accent' => $accent, 'placement' => $placement, 'theme' => $theme, 'avatar' => $avatar, 'avatar_id' => $avatar === 'custom' ? $avatarId : 0, 'suggestions' => $suggestions, 'colors' => $colors, 'theme_toggle' => $toggle] + $rules, JSON_UNESCAPED_UNICODE); return $this->db->execute('UPDATE {bots} SET appearance = ?, updated_at = ? WHERE id = ? AND site_id = ?' . self::LIVE, [$json, $this->clock->now(), $id, $siteId]) === 1; } /** * Lifecycle states of an assistant that is being deleted (AssistantDeletion). They are not editable states: every ordinary reader below * treats such an assistant as gone, so it cannot be edited, attached to, shown, previewed or answered from. */ public const DELETING = 'deleting'; public const DELETED = 'deleted'; /** Appended to every UPDATE of an assistant, so a write that raced with its deletion can never put content back into a tombstone. */ private const LIVE = " AND status NOT IN ('deleting', 'deleted')"; /** @return list> assistants of the site, without those being deleted or deleted */ public function all(int $siteId): array { $ids = $this->db->select('SELECT id FROM {bots} WHERE site_id = ? AND status NOT IN (?, ?) ORDER BY id', [$siteId, self::DELETING, self::DELETED]); return array_map(fn(array $r): array => $this->find($siteId, (int) $r['id']) ?? [], $ids); } /** @return list> assistants whose deletion has started and is not finished (conversations still being erased) */ public function deleting(int $siteId): array { return array_values(array_filter(array_map( fn(array $r): ?array => $this->findAny($siteId, (int) $r['id']), $this->db->select('SELECT id FROM {bots} WHERE site_id = ? AND status = ? ORDER BY id', [$siteId, self::DELETING]) ))); } /** @return array|null null for a missing, deleting or deleted assistant */ public function find(int $siteId, int $id): ?array { $row = $this->findAny($siteId, $id); return $row === null || in_array($row['status'], [self::DELETING, self::DELETED], true) ? null : $row; } /** Like find(), but also returns an assistant that is being deleted or is a deletion tombstone. For the deletion workflow only. @return array|null */ public function findAny(int $siteId, int $id): ?array { $row = $this->db->selectOne('SELECT * FROM {bots} WHERE id = ? AND site_id = ?', [$id, $siteId]); if ($row === null) { return null; } $row['id'] = (int) $row['id']; $row['site_id'] = (int) $row['site_id']; $row['is_public'] = (bool) $row['is_public']; $row['max_output_tokens'] = (int) $row['max_output_tokens']; $row['connection_id'] = $row['connection_id'] === null ? null : (int) $row['connection_id']; return $row; } public function resource(int $siteId, int $id): ?Resource { $bot = $this->find($siteId, $id); return $bot === null ? null : new Resource('bot', $bot['id'], $bot['site_id'], null, null, [ 'status' => $bot['status'], 'is_public' => $bot['is_public'], ]); } /** Follow the site default connection and model. */ public function setInherit(int $siteId, int $id): bool { return $this->find($siteId, $id) !== null && $this->db->execute('UPDATE {bots} SET connection_id = NULL, model = ?, updated_at = ? WHERE id = ? AND site_id = ?' . self::LIVE, [self::MODEL_INHERIT, $this->clock->now(), $id, $siteId]) === 1; } /** Simulated demo (answers only where the demo is allowed and switched on). */ public function setDemo(int $siteId, int $id): bool { return $this->find($siteId, $id) !== null && $this->db->execute('UPDATE {bots} SET connection_id = NULL, model = ?, updated_at = ? WHERE id = ? AND site_id = ?' . self::LIVE, [self::MODEL_DEMO, $this->clock->now(), $id, $siteId]) === 1; } /** Binding is server-owned and requires a verified connection on the same site. */ public function setProvider(int $siteId, int $id, ?int $connectionId, string $model): bool { if ($this->find($siteId, $id) === null) { return false; } $in = implode(', ', array_fill(0, count(self::PROVIDERS), '?')); // The connection row is read with a lock and the assistant is updated in the same transaction, so a concurrent delete of that // connection (ConnectionRepository::delete takes the same lock first) either finishes before this check, which then fails, or waits // until the bind is committed and then refuses because an assistant uses the connection. $lock = $this->db->driver() === 'mysql' ? ' FOR UPDATE' : ''; return (bool) $this->db->transaction(function () use ($siteId, $id, $connectionId, $model, $in, $lock): bool { if ($connectionId !== null && $this->db->selectOne("SELECT id FROM {connections} WHERE id = ? AND site_id = ? AND status = ? AND provider IN ($in)" . $lock, array_merge([$connectionId, $siteId, 'verified'], self::PROVIDERS)) === null) { throw new \InvalidArgumentException('Choose a verified connection from this site.'); } if (!preg_match('/^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,190}$/D', $model)) { throw new \InvalidArgumentException('Invalid model identifier.'); } return $this->db->execute('UPDATE {bots} SET connection_id = ?, model = ?, updated_at = ? WHERE id = ? AND site_id = ?' . self::LIVE, [$connectionId, $model, $this->clock->now(), $id, $siteId]) === 1; }); } private function text(string $value, int $max, string $field, bool $allowNewlines = false): string { $value = trim($value); if ($value === '' || !mb_check_encoding($value, 'UTF-8')) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception messages go to logs and callers, never echoed; public errors are fixed codes throw new \InvalidArgumentException("$field is required."); } if (!$allowNewlines) { $value = (string) preg_replace('/\s+/u', ' ', $value); } if (mb_strlen($value) > $max) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception messages go to logs and callers, never echoed; public errors are fixed codes throw new \InvalidArgumentException("$field is too long."); } return $value; } private function status(string $status): string { if (!in_array($status, ['draft', 'active', 'paused'], true)) { throw new \InvalidArgumentException('Invalid bot status.'); } return $status; } private function language(string $tag): string { if ($tag !== 'auto' && !preg_match('/^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/', $tag)) { throw new \InvalidArgumentException('Response language must be "auto" or a BCP 47 tag.'); } return $tag; } }