prefix . 'sarcio_conversations'; } public static function schema(string $collate): string { $table = self::table(); return "CREATE TABLE {$table} ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, user_id bigint(20) unsigned NOT NULL, title varchar(200) NOT NULL DEFAULT '', summary text NULL, messages longtext NULL, previews longtext NULL, created_at datetime NOT NULL, updated_at datetime NOT NULL, PRIMARY KEY (id), KEY user_updated (user_id, updated_at) ) {$collate};"; } /** @return array */ public static function listFor(int $userId, int $limit = self::LIST_LIMIT): array { global $wpdb; $table = self::table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- the plugin's own table. $rows = $wpdb->get_results($wpdb->prepare("SELECT id, title, created_at, updated_at, messages FROM {$table} WHERE user_id = %d ORDER BY updated_at DESC, id DESC LIMIT %d", $userId, max(1, min(self::LIST_LIMIT, $limit))), ARRAY_A); $out = []; foreach (is_array($rows) ? $rows : [] as $row) { $messages = json_decode((string) ($row['messages'] ?? ''), true); $out[] = [ 'createdAt' => (string) $row['created_at'], 'id' => (int) $row['id'], 'messageCount' => is_array($messages) ? count($messages) : 0, 'title' => (string) $row['title'], 'updatedAt' => (string) $row['updated_at'], ]; } return $out; } /** @return array{id:int,title:string,summary:string,messages:array,previews:array,updatedAt:string}|null */ public static function get(int $id, int $userId): ?array { global $wpdb; $table = self::table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- the plugin's own table. $row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$table} WHERE id = %d AND user_id = %d", $id, $userId), ARRAY_A); if (!is_array($row)) { return null; } $messages = json_decode((string) ($row['messages'] ?? ''), true); $previews = json_decode((string) ($row['previews'] ?? ''), true); return [ 'id' => (int) $row['id'], 'messages' => is_array($messages) ? array_values($messages) : [], 'previews' => is_array($previews) ? $previews : [], 'summary' => (string) ($row['summary'] ?? ''), 'title' => (string) $row['title'], 'updatedAt' => (string) $row['updated_at'], ]; } /** * @param array $messages * @param array $previews * @return array{id:int,title:string}|null null when the id is not this person's */ /** * A tool result flagged transient (form entries, other people's details) * is read for the turn that asked and never kept in a saved conversation. * * @param array $messages * @return array */ public static function withoutTransient(array $messages): array { foreach ($messages as $i => $message) { if (!is_array($message)) { continue; } $results = $message['results'] ?? null; if (is_array($results)) { foreach ($results as $j => $result) { if (is_array($result) && is_array($result['output'] ?? null) && !empty($result['output']['transient'])) { $messages[$i]['results'][$j]['output'] = ['transient' => true, 'withheld' => __('Not kept: other people’s details.', 'sarcio')]; } } } } return $messages; } public static function save(int $id, int $userId, string $title, string $summary, array $messages, array $previews): ?array { global $wpdb; $title = mb_substr(sanitize_text_field($title), 0, self::MAX_TITLE); if ($title === '') { $title = self::titleFrom($messages); } $data = [ 'messages' => (string) wp_json_encode(self::withoutTransient(array_slice(array_values($messages), -self::MAX_MESSAGES))), 'previews' => (string) wp_json_encode($previews === [] ? new \stdClass() : $previews), 'summary' => mb_substr($summary, 0, 8000), 'title' => $title, 'updated_at' => current_time('mysql', true), ]; if ($id > 0) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- the plugin's own table. $updated = $wpdb->update(self::table(), $data, ['id' => $id, 'user_id' => $userId], ['%s', '%s', '%s', '%s', '%s'], ['%d', '%d']); if ($updated === false || self::get($id, $userId) === null) { return null; } return ['id' => $id, 'title' => $title]; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- the plugin's own table. $wpdb->insert(self::table(), $data + ['created_at' => current_time('mysql', true), 'user_id' => $userId], ['%s', '%s', '%s', '%s', '%s', '%s', '%d']); return ['id' => (int) $wpdb->insert_id, 'title' => $title]; } public static function delete(int $id, int $userId): bool { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- the plugin's own table. return (int) $wpdb->delete(self::table(), ['id' => $id, 'user_id' => $userId], ['%d', '%d']) > 0; } public static function drop(): void { global $wpdb; $table = self::table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- uninstall drops the plugin's own table. $wpdb->query("DROP TABLE IF EXISTS {$table}"); } /** @param array $messages */ public static function titleFrom(array $messages): string { foreach ($messages as $message) { if (is_array($message) && ($message['role'] ?? '') === 'user' && is_string($message['text'] ?? null) && trim($message['text']) !== '') { $text = trim((string) preg_replace('/\s+/', ' ', $message['text'])); return mb_strlen($text) > 80 ? mb_substr($text, 0, 79) . '…' : $text; } } return __('Conversation', 'sarcio'); } }