- Neue Tabelle api_keys (Provider, Name, ApiKey, IsActive) - Admin-CRUD: GET/POST/PUT/DELETE /api/admin/api-keys - Aktivieren/Deaktivieren pro Provider (nur einer aktiv) - Live-Token-Update im AnthropicService ohne Neustart - Migration: AnthropicToken aus appsettings.Production.json → DB - AnthropicToken aus ServerSettings-Definitionen + appsettings entfernt - Frontend: API-Keys-Seite mit Erstellen/Bearbeiten/Löschen/Toggle - Dashboard: API-Keys-Link für Admins
1544 lines
69 KiB
C#
1544 lines
69 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.IO;
|
||
using System.Linq;
|
||
using System.Threading.Tasks;
|
||
using Dapper;
|
||
using Gott.Models;
|
||
using Microsoft.Data.Sqlite;
|
||
using SixLabors.ImageSharp;
|
||
using SixLabors.ImageSharp.Formats.Webp;
|
||
using SixLabors.ImageSharp.Processing;
|
||
|
||
namespace Gott.Services;
|
||
|
||
public class DatabaseService
|
||
{
|
||
private readonly string _connectionString;
|
||
|
||
public DatabaseService(string dbPath)
|
||
{
|
||
_connectionString = $"Data Source={dbPath}";
|
||
InitializeDatabase();
|
||
}
|
||
|
||
private SqliteConnection CreateConnection()
|
||
{
|
||
var conn = new SqliteConnection(_connectionString);
|
||
conn.Open();
|
||
return conn;
|
||
}
|
||
|
||
private void InitializeDatabase()
|
||
{
|
||
using var conn = CreateConnection();
|
||
|
||
Dapper.DefaultTypeMap.MatchNamesWithUnderscores = true;
|
||
|
||
conn.Execute(@"
|
||
CREATE TABLE IF NOT EXISTS sessions (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
session_id TEXT NOT NULL UNIQUE,
|
||
name TEXT,
|
||
created_at TEXT NOT NULL,
|
||
updated_at TEXT NOT NULL
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS archives (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
archive_id TEXT NOT NULL UNIQUE,
|
||
session_id TEXT NOT NULL,
|
||
name TEXT,
|
||
chain_count INTEGER NOT NULL DEFAULT 0,
|
||
message_count INTEGER NOT NULL DEFAULT 0,
|
||
created_at TEXT NOT NULL
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS chains (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
chain_id TEXT NOT NULL UNIQUE,
|
||
session_id TEXT,
|
||
archive_id TEXT,
|
||
model TEXT NOT NULL,
|
||
system_prompt TEXT,
|
||
input_tokens INTEGER NOT NULL DEFAULT 0,
|
||
output_tokens INTEGER NOT NULL DEFAULT 0,
|
||
iterations INTEGER NOT NULL DEFAULT 0,
|
||
stop_reason TEXT,
|
||
duration_ms INTEGER NOT NULL DEFAULT 0,
|
||
created_at TEXT NOT NULL
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS messages (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
chain_id TEXT NOT NULL,
|
||
role TEXT NOT NULL,
|
||
type TEXT NOT NULL,
|
||
content TEXT,
|
||
tool_name TEXT,
|
||
tool_call_id TEXT,
|
||
is_error INTEGER NOT NULL DEFAULT 0,
|
||
iteration INTEGER NOT NULL DEFAULT 0,
|
||
created_at TEXT NOT NULL,
|
||
FOREIGN KEY (chain_id) REFERENCES chains(chain_id)
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS agents (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
agent_id TEXT NOT NULL UNIQUE,
|
||
name TEXT NOT NULL,
|
||
avatar TEXT,
|
||
description TEXT,
|
||
model TEXT NOT NULL DEFAULT 'sonnet',
|
||
persona TEXT,
|
||
instructions TEXT,
|
||
is_deleted INTEGER NOT NULL DEFAULT 0,
|
||
created_at TEXT NOT NULL,
|
||
updated_at TEXT NOT NULL
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_chains_session ON chains(session_id);
|
||
CREATE INDEX IF NOT EXISTS idx_chains_archive ON chains(archive_id);
|
||
CREATE INDEX IF NOT EXISTS idx_messages_chain ON messages(chain_id);
|
||
");
|
||
|
||
conn.Execute(@"
|
||
CREATE TABLE IF NOT EXISTS attachments (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
message_id INTEGER NOT NULL,
|
||
type TEXT NOT NULL,
|
||
media_type TEXT NOT NULL,
|
||
file_name TEXT,
|
||
data TEXT NOT NULL,
|
||
created_at TEXT NOT NULL,
|
||
FOREIGN KEY (message_id) REFERENCES messages(id)
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_attachments_message ON attachments(message_id);
|
||
");
|
||
|
||
// Migrations
|
||
TryAddColumn(conn, "sessions", "agent_id", "TEXT");
|
||
TryAddColumn(conn, "sessions", "description", "TEXT");
|
||
TryAddColumn(conn, "sessions", "image", "TEXT");
|
||
TryAddColumn(conn, "sessions", "workspace", "TEXT");
|
||
TryAddColumn(conn, "sessions", "context", "TEXT");
|
||
TryAddColumn(conn, "sessions", "is_deleted", "INTEGER NOT NULL DEFAULT 0");
|
||
TryAddColumn(conn, "sessions", "custom_commands", "TEXT");
|
||
TryAddColumn(conn, "chains", "agent_id", "TEXT");
|
||
TryAddColumn(conn, "chains", "cache_creation_tokens", "INTEGER NOT NULL DEFAULT 0");
|
||
TryAddColumn(conn, "chains", "cache_read_tokens", "INTEGER NOT NULL DEFAULT 0");
|
||
TryAddColumn(conn, "chains", "tool_choice", "TEXT");
|
||
TryAddColumn(conn, "chains", "tool_names", "TEXT");
|
||
TryAddColumn(conn, "messages", "attachment_count", "INTEGER NOT NULL DEFAULT 0");
|
||
TryAddColumn(conn, "messages", "attachment_names", "TEXT");
|
||
TryAddColumn(conn, "agents", "tool_choice", "TEXT DEFAULT 'any'");
|
||
TryAddColumn(conn, "agents", "custom_commands", "TEXT");
|
||
TryAddColumn(conn, "messages", "is_marked", "INTEGER NOT NULL DEFAULT 0");
|
||
TryAddColumn(conn, "messages", "thought_signature", "TEXT");
|
||
TryAddColumn(conn, "agents", "auto_commit", "INTEGER NOT NULL DEFAULT 0");
|
||
|
||
// Migration: auto_commit von INTEGER (0/1) zu TEXT ("", "mention", "forbid")
|
||
try
|
||
{
|
||
conn.Execute("UPDATE agents SET auto_commit = '' WHERE auto_commit = '0' OR auto_commit = 0");
|
||
conn.Execute("UPDATE agents SET auto_commit = 'enforce' WHERE auto_commit = '1' OR auto_commit = 1");
|
||
}
|
||
catch { /* already migrated */ }
|
||
TryAddColumn(conn, "chains", "stream_error_info", "TEXT");
|
||
TryAddColumn(conn, "agents", "use_soul", "INTEGER NOT NULL DEFAULT 1");
|
||
TryAddColumn(conn, "agents", "use_identity", "INTEGER NOT NULL DEFAULT 1");
|
||
TryAddColumn(conn, "agents", "use_user_info", "INTEGER NOT NULL DEFAULT 1");
|
||
TryAddColumn(conn, "agents", "use_memory", "INTEGER NOT NULL DEFAULT 1");
|
||
TryAddColumn(conn, "agents", "use_auto_context", "INTEGER NOT NULL DEFAULT 1");
|
||
|
||
TryAddColumn(conn, "messages", "deleted_on", "TEXT");
|
||
TryAddColumn(conn, "agents", "use_web_search", "INTEGER NOT NULL DEFAULT 0");
|
||
TryAddColumn(conn, "agents", "web_search_version", "TEXT DEFAULT ''");
|
||
|
||
// Migration: use_web_search (bool) → web_search_version (text)
|
||
try
|
||
{
|
||
conn.Execute("UPDATE agents SET web_search_version = 'web_search_20260209' WHERE use_web_search = 1 AND (web_search_version IS NULL OR web_search_version = '')");
|
||
}
|
||
catch { /* already migrated */ }
|
||
|
||
// User-Zuordnung
|
||
TryAddColumn(conn, "sessions", "user_id", "TEXT");
|
||
TryAddColumn(conn, "sessions", "telegram_bot_token", "TEXT");
|
||
TryAddColumn(conn, "sessions", "telegram_webhook_secret", "TEXT");
|
||
TryAddColumn(conn, "sessions", "telegram_chat_id", "TEXT");
|
||
TryAddColumn(conn, "sessions", "telegram_allowed_chat_ids", "TEXT");
|
||
TryAddColumn(conn, "sessions", "telegram_bot_id", "TEXT");
|
||
TryAddColumn(conn, "agents", "user_id", "TEXT");
|
||
TryAddColumn(conn, "users", "is_admin", "INTEGER NOT NULL DEFAULT 0");
|
||
TryAddColumn(conn, "users", "persona", "TEXT");
|
||
TryAddColumn(conn, "users", "settings", "TEXT");
|
||
TryAddColumn(conn, "users", "default_path", "TEXT");
|
||
TryAddColumn(conn, "sessions", "avatar_scale", "REAL NOT NULL DEFAULT 1.0");
|
||
TryAddColumn(conn, "agents", "avatar_scale", "REAL NOT NULL DEFAULT 1.0");
|
||
TryAddColumn(conn, "cronjobs", "notify_session_id", "TEXT");
|
||
TryAddColumn(conn, "sessions", "review_agent_id", "TEXT");
|
||
TryAddColumn(conn, "sessions", "max_review_iterations", "INTEGER NOT NULL DEFAULT 3");
|
||
TryAddColumn(conn, "sessions", "avatar_offset_x", "REAL NOT NULL DEFAULT 0");
|
||
TryAddColumn(conn, "sessions", "avatar_offset_y", "REAL NOT NULL DEFAULT 0");
|
||
TryAddColumn(conn, "agents", "avatar_offset_x", "REAL NOT NULL DEFAULT 0");
|
||
TryAddColumn(conn, "agents", "avatar_offset_y", "REAL NOT NULL DEFAULT 0");
|
||
TryAddColumn(conn, "sessions", "finalizer_agent_id", "TEXT");
|
||
TryAddColumn(conn, "agents", "allow_user_choice", "INTEGER NOT NULL DEFAULT 1");
|
||
TryAddColumn(conn, "agents", "task_instruction", "TEXT");
|
||
TryAddColumn(conn, "chains", "plan_mode", "INTEGER NOT NULL DEFAULT 0");
|
||
TryAddColumn(conn, "sessions", "preparer_agent_id", "TEXT");
|
||
TryAddColumn(conn, "messages", "tag", "TEXT");
|
||
|
||
conn.Execute(@"
|
||
CREATE TABLE IF NOT EXISTS agent_skills (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
agent_id TEXT NOT NULL,
|
||
skill_slug TEXT NOT NULL,
|
||
created_at TEXT NOT NULL,
|
||
UNIQUE(agent_id, skill_slug)
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_agent_skills_agent ON agent_skills(agent_id);
|
||
");
|
||
|
||
conn.Execute(@"
|
||
CREATE TABLE IF NOT EXISTS users (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
user_id TEXT NOT NULL UNIQUE,
|
||
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||
password_hash TEXT NOT NULL,
|
||
display_name TEXT,
|
||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||
persona TEXT,
|
||
settings TEXT,
|
||
default_path TEXT,
|
||
created_at TEXT NOT NULL,
|
||
updated_at TEXT NOT NULL
|
||
);
|
||
");
|
||
|
||
conn.Execute(@"
|
||
CREATE TABLE IF NOT EXISTS telegram_links (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
telegram_chat_id INTEGER NOT NULL,
|
||
user_id TEXT NOT NULL,
|
||
session_id TEXT NOT NULL,
|
||
agent_id TEXT,
|
||
link_code TEXT,
|
||
expires_at TEXT,
|
||
created_at TEXT NOT NULL,
|
||
updated_at TEXT NOT NULL,
|
||
UNIQUE(telegram_chat_id)
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_telegram_links_code ON telegram_links(link_code);
|
||
");
|
||
|
||
conn.Execute(@"
|
||
CREATE TABLE IF NOT EXISTS telegram_bots (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
bot_id TEXT NOT NULL UNIQUE,
|
||
user_id TEXT NOT NULL,
|
||
name TEXT NOT NULL,
|
||
bot_token TEXT NOT NULL,
|
||
webhook_secret TEXT NOT NULL,
|
||
created_at TEXT NOT NULL,
|
||
updated_at TEXT NOT NULL
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_telegram_bots_user ON telegram_bots(user_id);
|
||
");
|
||
|
||
TryAddColumn(conn, "telegram_bots", "is_public", "INTEGER NOT NULL DEFAULT 0");
|
||
|
||
// Migration: Bestehende Session-Telegram-Daten in telegram_bots überführen
|
||
MigrateTelegramBotsFromSessions(conn);
|
||
|
||
conn.Execute(@"
|
||
CREATE TABLE IF NOT EXISTS cronjobs (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
cronjob_id TEXT NOT NULL UNIQUE,
|
||
user_id TEXT NOT NULL,
|
||
session_id TEXT NOT NULL,
|
||
name TEXT NOT NULL,
|
||
cron_expression TEXT NOT NULL,
|
||
prompt TEXT NOT NULL,
|
||
notify_session_id TEXT,
|
||
is_enabled INTEGER NOT NULL DEFAULT 1,
|
||
last_run_at TEXT,
|
||
next_run_at TEXT,
|
||
last_run_status TEXT,
|
||
last_run_error TEXT,
|
||
created_at TEXT NOT NULL,
|
||
updated_at TEXT NOT NULL
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_cronjobs_user ON cronjobs(user_id);
|
||
CREATE INDEX IF NOT EXISTS idx_cronjobs_next_run ON cronjobs(next_run_at);
|
||
");
|
||
|
||
conn.Execute(@"
|
||
CREATE TABLE IF NOT EXISTS shared_messages (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
share_token TEXT NOT NULL UNIQUE,
|
||
message_id INTEGER NOT NULL,
|
||
user_id TEXT NOT NULL,
|
||
include_context INTEGER NOT NULL DEFAULT 0,
|
||
expires_at TEXT,
|
||
created_at TEXT NOT NULL
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_shared_messages_token ON shared_messages(share_token);
|
||
CREATE INDEX IF NOT EXISTS idx_shared_messages_message ON shared_messages(message_id);
|
||
");
|
||
|
||
conn.Execute(@"
|
||
CREATE TABLE IF NOT EXISTS api_keys (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
key_id TEXT NOT NULL UNIQUE,
|
||
provider TEXT NOT NULL,
|
||
name TEXT NOT NULL,
|
||
api_key TEXT NOT NULL,
|
||
is_active INTEGER NOT NULL DEFAULT 0,
|
||
created_at TEXT NOT NULL,
|
||
updated_at TEXT NOT NULL
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_api_keys_provider ON api_keys(provider);
|
||
");
|
||
|
||
// Migration: AnthropicToken aus appsettings.Production.json → api_keys Tabelle
|
||
MigrateAnthropicTokenFromConfig(conn);
|
||
|
||
TryAddColumn(conn, "agents", "avatar_small", "TEXT");
|
||
TryAddColumn(conn, "agents", "is_public", "INTEGER NOT NULL DEFAULT 0");
|
||
TryAddColumn(conn, "sessions", "image_small", "TEXT");
|
||
|
||
// Migration: Bestehende PNG/JPEG-Avatare/Images zu WebP konvertieren
|
||
MigrateImagesToWebP(conn);
|
||
|
||
// Migration: Small-Thumbnails für bestehende Bilder generieren
|
||
MigrateImageThumbnails(conn);
|
||
}
|
||
|
||
private void MigrateImagesToWebP(SqliteConnection conn)
|
||
{
|
||
const int webPQuality = 80;
|
||
|
||
// Agent Avatare
|
||
var agentImages = conn.Query<(string AgentId, string Avatar)>(
|
||
"SELECT agent_id, avatar FROM agents WHERE avatar IS NOT NULL AND avatar != '' AND avatar NOT LIKE 'data:image/webp%'")
|
||
.ToList();
|
||
|
||
foreach (var (agentId, avatar) in agentImages)
|
||
{
|
||
var converted = ConvertDataUrlToWebP(avatar, webPQuality);
|
||
if (converted != null)
|
||
conn.Execute("UPDATE agents SET avatar = @converted WHERE agent_id = @agentId", new { converted, agentId });
|
||
}
|
||
|
||
// Session Images
|
||
var sessionImages = conn.Query<(string SessionId, string Image)>(
|
||
"SELECT session_id, image FROM sessions WHERE image IS NOT NULL AND image != '' AND image NOT LIKE 'data:image/webp%'")
|
||
.ToList();
|
||
|
||
foreach (var (sessionId, image) in sessionImages)
|
||
{
|
||
var converted = ConvertDataUrlToWebP(image, webPQuality);
|
||
if (converted != null)
|
||
conn.Execute("UPDATE sessions SET image = @converted WHERE session_id = @sessionId", new { converted, sessionId });
|
||
}
|
||
|
||
if (agentImages.Count > 0 || sessionImages.Count > 0)
|
||
Console.WriteLine($"[Migration] Converted {agentImages.Count} agent avatars and {sessionImages.Count} session images to WebP");
|
||
}
|
||
|
||
private void MigrateImageThumbnails(SqliteConnection conn)
|
||
{
|
||
const int smallSize = 256;
|
||
const int smallQuality = 70;
|
||
|
||
// Agent Avatare ohne Thumbnail
|
||
var agentsWithoutThumb = conn.Query<(string AgentId, string Avatar)>(
|
||
"SELECT agent_id, avatar FROM agents WHERE avatar IS NOT NULL AND avatar != '' AND (avatar_small IS NULL OR avatar_small = '')")
|
||
.ToList();
|
||
|
||
foreach (var (agentId, avatar) in agentsWithoutThumb)
|
||
{
|
||
var small = ConvertDataUrlToWebP(avatar, smallQuality, maxSide: smallSize);
|
||
if (small != null)
|
||
conn.Execute("UPDATE agents SET avatar_small = @small WHERE agent_id = @agentId", new { small, agentId });
|
||
}
|
||
|
||
// Session Images ohne Thumbnail
|
||
var sessionsWithoutThumb = conn.Query<(string SessionId, string Image)>(
|
||
"SELECT session_id, image FROM sessions WHERE image IS NOT NULL AND image != '' AND (image_small IS NULL OR image_small = '')")
|
||
.ToList();
|
||
|
||
foreach (var (sessionId, image) in sessionsWithoutThumb)
|
||
{
|
||
var small = ConvertDataUrlToWebP(image, smallQuality, maxSide: smallSize);
|
||
if (small != null)
|
||
conn.Execute("UPDATE sessions SET image_small = @small WHERE session_id = @sessionId", new { small, sessionId });
|
||
}
|
||
|
||
if (agentsWithoutThumb.Count > 0 || sessionsWithoutThumb.Count > 0)
|
||
Console.WriteLine($"[Migration] Generated {agentsWithoutThumb.Count} agent thumbnails and {sessionsWithoutThumb.Count} session thumbnails");
|
||
}
|
||
|
||
/// <summary>
|
||
/// Konvertiert ein DataUrl-Bild zu WebP. Optional Resize wenn maxSide > 0.
|
||
/// </summary>
|
||
private static string ConvertDataUrlToWebP(string dataUrl, int quality, int maxSide = 0)
|
||
{
|
||
try
|
||
{
|
||
var commaIndex = dataUrl.IndexOf(',');
|
||
if (commaIndex < 0) return null;
|
||
|
||
var base64 = dataUrl.Substring(commaIndex + 1);
|
||
var bytes = Convert.FromBase64String(base64);
|
||
|
||
using var image = Image.Load(bytes);
|
||
if (maxSide > 0 && (image.Width > maxSide || image.Height > maxSide))
|
||
{
|
||
image.Mutate(x => x.Resize(new ResizeOptions
|
||
{
|
||
Size = new Size(maxSide, maxSide),
|
||
Mode = ResizeMode.Max
|
||
}));
|
||
}
|
||
|
||
using var ms = new MemoryStream();
|
||
image.Save(ms, new WebpEncoder { Quality = quality });
|
||
|
||
return "data:image/webp;base64," + Convert.ToBase64String(ms.ToArray());
|
||
}
|
||
catch
|
||
{
|
||
return null;
|
||
}
|
||
}
|
||
|
||
private void TryAddColumn(SqliteConnection conn, string table, string column, string type)
|
||
{
|
||
try { conn.Execute($"ALTER TABLE {table} ADD COLUMN {column} {type}"); }
|
||
catch { /* column already exists */ }
|
||
}
|
||
|
||
// ── Shared Messages ──
|
||
|
||
public async Task<SharedMessageRecord> CreateSharedMessage(long messageId, string userId, bool includeContext, string expiresAt = null)
|
||
{
|
||
using var conn = CreateConnection();
|
||
var record = new SharedMessageRecord
|
||
{
|
||
ShareToken = Guid.NewGuid().ToString("N"),
|
||
MessageId = messageId,
|
||
UserId = userId,
|
||
IncludeContext = includeContext,
|
||
ExpiresAt = expiresAt,
|
||
CreatedAt = DateTime.UtcNow.ToString("o")
|
||
};
|
||
await conn.ExecuteAsync(@"
|
||
INSERT INTO shared_messages (share_token, message_id, user_id, include_context, expires_at, created_at)
|
||
VALUES (@ShareToken, @MessageId, @UserId, @IncludeContext, @ExpiresAt, @CreatedAt)
|
||
", record);
|
||
return record;
|
||
}
|
||
|
||
public async Task<SharedMessageRecord> GetSharedMessage(string shareToken)
|
||
{
|
||
using var conn = CreateConnection();
|
||
return await conn.QuerySingleOrDefaultAsync<SharedMessageRecord>(
|
||
"SELECT * FROM shared_messages WHERE share_token = @shareToken", new { shareToken });
|
||
}
|
||
|
||
public async Task<IEnumerable<SharedMessageRecord>> GetSharedMessagesByUser(string userId)
|
||
{
|
||
using var conn = CreateConnection();
|
||
return await conn.QueryAsync<SharedMessageRecord>(
|
||
"SELECT * FROM shared_messages WHERE user_id = @userId ORDER BY created_at DESC", new { userId });
|
||
}
|
||
|
||
public async Task DeleteSharedMessage(string shareToken, string userId)
|
||
{
|
||
using var conn = CreateConnection();
|
||
await conn.ExecuteAsync(
|
||
"DELETE FROM shared_messages WHERE share_token = @shareToken AND user_id = @userId",
|
||
new { shareToken, userId });
|
||
}
|
||
|
||
public async Task<MessageRecord> GetMessage(long messageId)
|
||
{
|
||
using var conn = CreateConnection();
|
||
return await conn.QuerySingleOrDefaultAsync<MessageRecord>(
|
||
"SELECT * FROM messages WHERE id = @messageId", new { messageId });
|
||
}
|
||
|
||
public async Task<List<MessageRecord>> GetChainMessagesUpTo(string chainId, long messageId)
|
||
{
|
||
using var conn = CreateConnection();
|
||
var result = await conn.QueryAsync<MessageRecord>(
|
||
"SELECT * FROM messages WHERE chain_id = @chainId AND id <= @messageId ORDER BY id ASC",
|
||
new { chainId, messageId });
|
||
return result.AsList();
|
||
}
|
||
|
||
// ── Agents ──
|
||
|
||
public async Task<AgentRecord> CreateAgent(AgentRecord agent)
|
||
{
|
||
using var conn = CreateConnection();
|
||
var now = DateTime.UtcNow.ToString("o");
|
||
agent.AgentId = Guid.NewGuid().ToString("N");
|
||
agent.CreatedAt = now;
|
||
agent.UpdatedAt = now;
|
||
await conn.ExecuteAsync(@"
|
||
INSERT INTO agents (agent_id, user_id, name, avatar, avatar_small, description, model, tool_choice, custom_commands, auto_commit, use_soul, use_identity, use_user_info, use_memory, use_auto_context, use_web_search, web_search_version, allow_user_choice, task_instruction, avatar_scale, avatar_offset_x, avatar_offset_y, is_deleted, created_at, updated_at)
|
||
VALUES (@AgentId, @UserId, @Name, @Avatar, @AvatarSmall, @Description, @Model, @ToolChoice, @CustomCommands, @AutoCommit, @UseSoul, @UseIdentity, @UseUserInfo, @UseMemory, @UseAutoContext, @UseWebSearch, @WebSearchVersion, @AllowUserChoice, @TaskInstruction, @AvatarScale, @AvatarOffsetX, @AvatarOffsetY, 0, @CreatedAt, @UpdatedAt)
|
||
", agent);
|
||
return agent;
|
||
}
|
||
|
||
public async Task<IEnumerable<AgentRecord>> GetAgents(string userId)
|
||
{
|
||
using var conn = CreateConnection();
|
||
return await conn.QueryAsync<AgentRecord>(
|
||
"SELECT * FROM agents WHERE user_id = @userId AND is_deleted = 0 ORDER BY name", new { userId });
|
||
}
|
||
|
||
public async Task<IEnumerable<AgentRecord>> GetAvailableAgents(string userId)
|
||
{
|
||
using var conn = CreateConnection();
|
||
return await conn.QueryAsync<AgentRecord>(
|
||
"SELECT * FROM agents WHERE (user_id = @userId OR is_public = 1) AND is_deleted = 0 ORDER BY name", new { userId });
|
||
}
|
||
|
||
public async Task<AgentRecord> GetAgent(string agentId)
|
||
{
|
||
using var conn = CreateConnection();
|
||
return await conn.QuerySingleOrDefaultAsync<AgentRecord>(
|
||
"SELECT * FROM agents WHERE agent_id = @agentId AND is_deleted = 0", new { agentId });
|
||
}
|
||
|
||
public async Task UpdateAgent(AgentRecord agent)
|
||
{
|
||
using var conn = CreateConnection();
|
||
agent.UpdatedAt = DateTime.UtcNow.ToString("o");
|
||
await conn.ExecuteAsync(@"
|
||
UPDATE agents SET name = @Name, avatar = @Avatar, avatar_small = @AvatarSmall, description = @Description,
|
||
model = @Model, tool_choice = @ToolChoice,
|
||
custom_commands = @CustomCommands, auto_commit = @AutoCommit,
|
||
use_soul = @UseSoul, use_identity = @UseIdentity, use_user_info = @UseUserInfo, use_memory = @UseMemory, use_auto_context = @UseAutoContext, use_web_search = @UseWebSearch, web_search_version = @WebSearchVersion,
|
||
allow_user_choice = @AllowUserChoice, task_instruction = @TaskInstruction,
|
||
avatar_scale = @AvatarScale, avatar_offset_x = @AvatarOffsetX, avatar_offset_y = @AvatarOffsetY,
|
||
is_public = @IsPublic, updated_at = @UpdatedAt
|
||
WHERE agent_id = @AgentId AND user_id = @UserId
|
||
", agent);
|
||
}
|
||
|
||
public async Task DeleteAgent(string agentId, string userId)
|
||
{
|
||
using var conn = CreateConnection();
|
||
await conn.ExecuteAsync(
|
||
"UPDATE agents SET is_deleted = 1, updated_at = @now WHERE agent_id = @agentId AND user_id = @userId",
|
||
new { agentId, userId, now = DateTime.UtcNow.ToString("o") });
|
||
}
|
||
|
||
// ── Sessions ──
|
||
|
||
public async Task<SessionRecord> CreateSession(string userId, string name = null, string description = null, string image = null, string imageSmall = null, string agentId = null, string workspace = null, string context = null, string customCommands = null, double avatarScale = 1.0, double avatarOffsetX = 0, double avatarOffsetY = 0, string reviewAgentId = null, int maxReviewIterations = 3, string finalizerAgentId = null, string preparerAgentId = null)
|
||
{
|
||
using var conn = CreateConnection();
|
||
var now = DateTime.UtcNow.ToString("o");
|
||
var session = new SessionRecord
|
||
{
|
||
SessionId = Guid.NewGuid().ToString("N"),
|
||
UserId = userId,
|
||
Name = name,
|
||
Description = description,
|
||
Image = image,
|
||
ImageSmall = imageSmall,
|
||
AgentId = agentId,
|
||
Workspace = workspace,
|
||
Context = context,
|
||
CustomCommands = customCommands,
|
||
AvatarScale = avatarScale,
|
||
AvatarOffsetX = avatarOffsetX,
|
||
AvatarOffsetY = avatarOffsetY,
|
||
ReviewAgentId = reviewAgentId,
|
||
MaxReviewIterations = maxReviewIterations,
|
||
FinalizerAgentId = finalizerAgentId,
|
||
PreparerAgentId = preparerAgentId,
|
||
CreatedAt = now,
|
||
UpdatedAt = now
|
||
};
|
||
await conn.ExecuteAsync(@"
|
||
INSERT INTO sessions (session_id, user_id, name, description, image, image_small, agent_id, workspace, context, custom_commands, avatar_scale, avatar_offset_x, avatar_offset_y, review_agent_id, max_review_iterations, finalizer_agent_id, preparer_agent_id, created_at, updated_at)
|
||
VALUES (@SessionId, @UserId, @Name, @Description, @Image, @ImageSmall, @AgentId, @Workspace, @Context, @CustomCommands, @AvatarScale, @AvatarOffsetX, @AvatarOffsetY, @ReviewAgentId, @MaxReviewIterations, @FinalizerAgentId, @PreparerAgentId, @CreatedAt, @UpdatedAt)
|
||
", session);
|
||
return session;
|
||
}
|
||
|
||
public async Task<SessionRecord> GetSessionByName(string name)
|
||
{
|
||
using var conn = CreateConnection();
|
||
var sessions = await conn.QueryAsync<SessionRecord>(
|
||
"SELECT * FROM sessions WHERE is_deleted = 0 AND LOWER(name) = LOWER(@name) ORDER BY updated_at DESC LIMIT 1",
|
||
new { name });
|
||
return sessions.FirstOrDefault();
|
||
}
|
||
|
||
public async Task<List<SessionWithStats>> GetSessionsWithStats(string userId)
|
||
{
|
||
using var conn = CreateConnection();
|
||
var sessions = await conn.QueryAsync<SessionRecord>(
|
||
"SELECT * FROM sessions WHERE user_id = @userId AND is_deleted = 0 ORDER BY updated_at DESC", new { userId });
|
||
|
||
var results = new List<SessionWithStats>();
|
||
foreach (var s in sessions)
|
||
{
|
||
var activeCount = await conn.ExecuteScalarAsync<int>(@"
|
||
SELECT COUNT(*) FROM messages m
|
||
JOIN chains c ON m.chain_id = c.chain_id
|
||
WHERE c.session_id = @sessionId AND c.archive_id IS NULL AND m.type = 'text'",
|
||
new { sessionId = s.SessionId });
|
||
|
||
var totalCount = await conn.ExecuteScalarAsync<int>(@"
|
||
SELECT COUNT(*) FROM messages m
|
||
JOIN chains c ON m.chain_id = c.chain_id
|
||
WHERE c.session_id = @sessionId AND m.type = 'text'",
|
||
new { sessionId = s.SessionId });
|
||
|
||
results.Add(new SessionWithStats
|
||
{
|
||
Session = s,
|
||
MessageCount = activeCount,
|
||
ArchiveMessageCount = totalCount - activeCount,
|
||
TotalMessageCount = totalCount,
|
||
TelegramConfigured = !string.IsNullOrEmpty(s.TelegramBotId),
|
||
TelegramLinked = !string.IsNullOrEmpty(s.TelegramChatId)
|
||
});
|
||
}
|
||
return results;
|
||
}
|
||
|
||
public async Task<SessionRecord> GetSession(string sessionId)
|
||
{
|
||
using var conn = CreateConnection();
|
||
return await conn.QuerySingleOrDefaultAsync<SessionRecord>(
|
||
"SELECT * FROM sessions WHERE session_id = @sessionId AND is_deleted = 0", new { sessionId });
|
||
}
|
||
|
||
public async Task UpdateSessionTimestamp(string sessionId)
|
||
{
|
||
using var conn = CreateConnection();
|
||
await conn.ExecuteAsync(
|
||
"UPDATE sessions SET updated_at = @now WHERE session_id = @sessionId",
|
||
new { sessionId, now = DateTime.UtcNow.ToString("o") });
|
||
}
|
||
|
||
public async Task UpdateSession(string sessionId, System.Text.Json.JsonElement body, ImageService imageService = null)
|
||
{
|
||
using var conn = CreateConnection();
|
||
var sets = new List<string> { "updated_at = @now" };
|
||
var p = new Dapper.DynamicParameters();
|
||
p.Add("sessionId", sessionId);
|
||
p.Add("now", DateTime.UtcNow.ToString("o"));
|
||
|
||
if (body.TryGetProperty("name", out var n)) { sets.Add("name = @name"); p.Add("name", n.GetString()); }
|
||
if (body.TryGetProperty("description", out var d)) { sets.Add("description = @desc"); p.Add("desc", d.GetString()); }
|
||
if (body.TryGetProperty("image", out var img))
|
||
{
|
||
var imageData = img.GetString();
|
||
if (imageService != null)
|
||
{
|
||
sets.Add("image_small = @imageSmall");
|
||
p.Add("imageSmall", imageService.ResizeDataUrlSmall(imageData));
|
||
imageData = imageService.ResizeDataUrl(imageData);
|
||
}
|
||
sets.Add("image = @image");
|
||
p.Add("image", imageData);
|
||
}
|
||
if (body.TryGetProperty("agentId", out var a)) { sets.Add("agent_id = @agentId2"); p.Add("agentId2", a.GetString()); }
|
||
if (body.TryGetProperty("workspace", out var w)) { sets.Add("workspace = @workspace"); p.Add("workspace", w.GetString()); }
|
||
if (body.TryGetProperty("context", out var ctx)) { sets.Add("context = @context"); p.Add("context", ctx.GetString()); }
|
||
if (body.TryGetProperty("customCommands", out var cc)) { sets.Add("custom_commands = @customCommands"); p.Add("customCommands", cc.ValueKind == System.Text.Json.JsonValueKind.Null ? null : cc.GetRawText()); }
|
||
if (body.TryGetProperty("avatarScale", out var asc)) { sets.Add("avatar_scale = @avatarScale"); p.Add("avatarScale", asc.GetDouble()); }
|
||
if (body.TryGetProperty("avatarOffsetX", out var aox)) { sets.Add("avatar_offset_x = @avatarOffsetX"); p.Add("avatarOffsetX", aox.GetDouble()); }
|
||
if (body.TryGetProperty("avatarOffsetY", out var aoy)) { sets.Add("avatar_offset_y = @avatarOffsetY"); p.Add("avatarOffsetY", aoy.GetDouble()); }
|
||
if (body.TryGetProperty("reviewAgentId", out var ra)) { sets.Add("review_agent_id = @reviewAgentId"); p.Add("reviewAgentId", ra.ValueKind == System.Text.Json.JsonValueKind.Null ? null : ra.GetString()); }
|
||
if (body.TryGetProperty("maxReviewIterations", out var mri)) { sets.Add("max_review_iterations = @maxReviewIterations"); p.Add("maxReviewIterations", mri.GetInt32()); }
|
||
if (body.TryGetProperty("finalizerAgentId", out var fa)) { sets.Add("finalizer_agent_id = @finalizerAgentId"); p.Add("finalizerAgentId", fa.ValueKind == System.Text.Json.JsonValueKind.Null ? null : fa.GetString()); }
|
||
if (body.TryGetProperty("preparerAgentId", out var pa)) { sets.Add("preparer_agent_id = @preparerAgentId"); p.Add("preparerAgentId", pa.ValueKind == System.Text.Json.JsonValueKind.Null ? null : pa.GetString()); }
|
||
|
||
await conn.ExecuteAsync($"UPDATE sessions SET {string.Join(", ", sets)} WHERE session_id = @sessionId", p);
|
||
}
|
||
|
||
public async Task DeleteSession(string sessionId, string userId)
|
||
{
|
||
using var conn = CreateConnection();
|
||
await conn.ExecuteAsync(
|
||
"UPDATE sessions SET is_deleted = 1, updated_at = @now WHERE session_id = @sessionId AND user_id = @userId",
|
||
new { sessionId, userId, now = DateTime.UtcNow.ToString("o") });
|
||
}
|
||
|
||
// ── Archives ──
|
||
|
||
public async Task<ArchiveRecord> CreateArchive(string sessionId, string name = null)
|
||
{
|
||
using var conn = CreateConnection();
|
||
|
||
var chainCount = await conn.ExecuteScalarAsync<int>(
|
||
"SELECT COUNT(*) FROM chains WHERE archive_id IS NULL AND session_id = @sessionId",
|
||
new { sessionId });
|
||
if (chainCount == 0) return null;
|
||
|
||
var messageCount = await conn.ExecuteScalarAsync<int>(@"
|
||
SELECT COUNT(*) FROM messages
|
||
WHERE type = 'text' AND chain_id IN (SELECT chain_id FROM chains WHERE archive_id IS NULL AND session_id = @sessionId)",
|
||
new { sessionId });
|
||
|
||
var archive = new ArchiveRecord
|
||
{
|
||
ArchiveId = Guid.NewGuid().ToString("N"),
|
||
SessionId = sessionId,
|
||
Name = name,
|
||
ChainCount = chainCount,
|
||
MessageCount = messageCount,
|
||
CreatedAt = DateTime.UtcNow.ToString("o")
|
||
};
|
||
|
||
await conn.ExecuteAsync(@"
|
||
INSERT INTO archives (archive_id, session_id, name, chain_count, message_count, created_at)
|
||
VALUES (@ArchiveId, @SessionId, @Name, @ChainCount, @MessageCount, @CreatedAt)
|
||
", archive);
|
||
|
||
await conn.ExecuteAsync(
|
||
"UPDATE chains SET archive_id = @archiveId WHERE archive_id IS NULL AND session_id = @sessionId",
|
||
new { archiveId = archive.ArchiveId, sessionId });
|
||
|
||
return archive;
|
||
}
|
||
|
||
public async Task<IEnumerable<ArchiveRecord>> GetArchives(string sessionId)
|
||
{
|
||
using var conn = CreateConnection();
|
||
return await conn.QueryAsync<ArchiveRecord>(
|
||
"SELECT * FROM archives WHERE session_id = @sessionId ORDER BY id DESC",
|
||
new { sessionId });
|
||
}
|
||
|
||
public async Task<IEnumerable<ChainRecord>> GetArchiveChains(string archiveId)
|
||
{
|
||
using var conn = CreateConnection();
|
||
return await conn.QueryAsync<ChainRecord>(
|
||
"SELECT * FROM chains WHERE archive_id = @archiveId ORDER BY id",
|
||
new { archiveId });
|
||
}
|
||
|
||
// ── Chains ──
|
||
|
||
public async Task CreateChain(ChainRecord chain)
|
||
{
|
||
using var conn = CreateConnection();
|
||
chain.CreatedAt = DateTime.UtcNow.ToString("o");
|
||
await conn.ExecuteAsync(@"
|
||
INSERT INTO chains (chain_id, session_id, archive_id, agent_id, model, system_prompt, tool_choice, tool_names, input_tokens, output_tokens, iterations, stop_reason, duration_ms, plan_mode, created_at)
|
||
VALUES (@ChainId, @SessionId, @ArchiveId, @AgentId, @Model, @SystemPrompt, @ToolChoice, @ToolNames, @InputTokens, @OutputTokens, @Iterations, @StopReason, @DurationMs, @PlanMode, @CreatedAt)
|
||
", chain);
|
||
}
|
||
|
||
public async Task UpdateChain(ChainRecord chain)
|
||
{
|
||
using var conn = CreateConnection();
|
||
await conn.ExecuteAsync(@"
|
||
UPDATE chains SET
|
||
input_tokens = @InputTokens,
|
||
output_tokens = @OutputTokens,
|
||
cache_creation_tokens = @CacheCreationTokens,
|
||
cache_read_tokens = @CacheReadTokens,
|
||
iterations = @Iterations,
|
||
stop_reason = @StopReason,
|
||
stream_error_info = @StreamErrorInfo,
|
||
duration_ms = @DurationMs
|
||
WHERE chain_id = @ChainId
|
||
", chain);
|
||
}
|
||
|
||
public async Task<ChainRecord> GetChain(string chainId)
|
||
{
|
||
using var conn = CreateConnection();
|
||
return await conn.QuerySingleOrDefaultAsync<ChainRecord>(
|
||
"SELECT * FROM chains WHERE chain_id = @chainId", new { chainId }
|
||
);
|
||
}
|
||
|
||
public async Task<IEnumerable<ChainRecord>> GetActiveChains(string sessionId)
|
||
{
|
||
using var conn = CreateConnection();
|
||
return await conn.QueryAsync<ChainRecord>(
|
||
"SELECT * FROM chains WHERE session_id = @sessionId AND archive_id IS NULL ORDER BY id DESC",
|
||
new { sessionId });
|
||
}
|
||
|
||
// ── Messages ──
|
||
|
||
public async Task<long> SaveMessage(MessageRecord record)
|
||
{
|
||
using var conn = CreateConnection();
|
||
record.CreatedAt = DateTime.UtcNow.ToString("o");
|
||
return await conn.ExecuteScalarAsync<long>(@"
|
||
INSERT INTO messages (chain_id, role, type, content, tool_name, tool_call_id, is_error, iteration, attachment_count, tag, thought_signature, created_at)
|
||
VALUES (@ChainId, @Role, @Type, @Content, @ToolName, @ToolCallId, @IsError, @Iteration, @AttachmentCount, @Tag, @ThoughtSignature, @CreatedAt);
|
||
SELECT last_insert_rowid();
|
||
", record);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Speichert Message + Attachments in einer Transaktion (1x fsync statt N+1).
|
||
/// </summary>
|
||
public async Task<long> SaveMessageWithAttachments(MessageRecord record, List<AttachmentRecord> attachments)
|
||
{
|
||
using var conn = CreateConnection();
|
||
using var tx = conn.BeginTransaction();
|
||
|
||
record.CreatedAt = DateTime.UtcNow.ToString("o");
|
||
record.AttachmentCount = attachments?.Count ?? 0;
|
||
if (attachments != null && attachments.Count > 0)
|
||
{
|
||
var names = attachments.Select(a => a.FileName ?? "").ToArray();
|
||
record.AttachmentNames = System.Text.Json.JsonSerializer.Serialize(names);
|
||
}
|
||
var messageId = await conn.ExecuteScalarAsync<long>(@"
|
||
INSERT INTO messages (chain_id, role, type, content, tool_name, tool_call_id, is_error, iteration, attachment_count, attachment_names, tag, created_at)
|
||
VALUES (@ChainId, @Role, @Type, @Content, @ToolName, @ToolCallId, @IsError, @Iteration, @AttachmentCount, @AttachmentNames, @Tag, @CreatedAt);
|
||
SELECT last_insert_rowid();
|
||
", record, tx);
|
||
|
||
if (attachments != null)
|
||
{
|
||
var now = DateTime.UtcNow.ToString("o");
|
||
foreach (var att in attachments)
|
||
{
|
||
att.MessageId = messageId;
|
||
att.CreatedAt = now;
|
||
await conn.ExecuteAsync(@"
|
||
INSERT INTO attachments (message_id, type, media_type, file_name, data, created_at)
|
||
VALUES (@MessageId, @Type, @MediaType, @FileName, @Data, @CreatedAt)
|
||
", att, tx);
|
||
}
|
||
}
|
||
|
||
tx.Commit();
|
||
return messageId;
|
||
}
|
||
|
||
// ── OpenClaw Import ──
|
||
|
||
public async Task CreateChainForImport(string chainId, string sessionId, string agentId, string model, int inputTokens, int outputTokens, int cacheCreationTokens, int cacheReadTokens, string createdAt)
|
||
{
|
||
using var conn = CreateConnection();
|
||
await conn.ExecuteAsync(@"
|
||
INSERT INTO chains (chain_id, session_id, agent_id, model, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, iterations, stop_reason, duration_ms, plan_mode, created_at)
|
||
VALUES (@chainId, @sessionId, @agentId, @model, @inputTokens, @outputTokens, @cacheCreationTokens, @cacheReadTokens, 0, 'end_turn', 0, 0, @createdAt)
|
||
", new { chainId, sessionId, agentId, model, inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens, createdAt });
|
||
}
|
||
|
||
public async Task CreateMessageForImport(string chainId, string role, string type, string content, string createdAt, string tag)
|
||
{
|
||
using var conn = CreateConnection();
|
||
await conn.ExecuteAsync(@"
|
||
INSERT INTO messages (chain_id, role, type, content, is_error, iteration, attachment_count, tag, created_at)
|
||
VALUES (@chainId, @role, @type, @content, 0, 0, 0, @tag, @createdAt)
|
||
", new { chainId, role, type, content, tag, createdAt });
|
||
}
|
||
|
||
public async Task DeleteOpenClawImport(string sessionId)
|
||
{
|
||
using var conn = CreateConnection();
|
||
using var tx = conn.BeginTransaction();
|
||
|
||
// Get all chain_ids that belong to this session and have imported messages
|
||
var importedChainIds = (await conn.QueryAsync<string>(@"
|
||
SELECT DISTINCT c.chain_id
|
||
FROM chains c
|
||
INNER JOIN messages m ON m.chain_id = c.chain_id
|
||
WHERE c.session_id = @sessionId AND m.tag = 'openclaw-import'
|
||
", new { sessionId }, tx)).ToList();
|
||
|
||
if (importedChainIds.Count > 0)
|
||
{
|
||
// Delete messages for these chains
|
||
await conn.ExecuteAsync(
|
||
"DELETE FROM messages WHERE chain_id IN @chainIds",
|
||
new { chainIds = importedChainIds }, tx);
|
||
|
||
// Delete the chains themselves
|
||
await conn.ExecuteAsync(
|
||
"DELETE FROM chains WHERE chain_id IN @chainIds",
|
||
new { chainIds = importedChainIds }, tx);
|
||
}
|
||
|
||
tx.Commit();
|
||
}
|
||
|
||
// ── Attachments ──
|
||
|
||
public async Task<IEnumerable<AttachmentRecord>> GetAttachments(long messageId)
|
||
{
|
||
using var conn = CreateConnection();
|
||
return await conn.QueryAsync<AttachmentRecord>(
|
||
"SELECT * FROM attachments WHERE message_id = @messageId ORDER BY id",
|
||
new { messageId });
|
||
}
|
||
|
||
public async Task<IEnumerable<AttachmentRecord>> GetAttachmentsForMessages(IEnumerable<long> messageIds)
|
||
{
|
||
using var conn = CreateConnection();
|
||
return await conn.QueryAsync<AttachmentRecord>(
|
||
"SELECT * FROM attachments WHERE message_id IN @messageIds ORDER BY id",
|
||
new { messageIds });
|
||
}
|
||
|
||
public async Task<IEnumerable<MessageRecord>> GetChainMessages(string chainId, bool includeDeleted = false)
|
||
{
|
||
using var conn = CreateConnection();
|
||
var deletedFilter = includeDeleted ? "" : " AND deleted_on IS NULL";
|
||
return await conn.QueryAsync<MessageRecord>(
|
||
$"SELECT * FROM messages WHERE chain_id = @chainId{deletedFilter} ORDER BY id", new { chainId }
|
||
);
|
||
}
|
||
|
||
public async Task<int> GetActiveMessageCount(string sessionId, bool includeDeleted = false)
|
||
{
|
||
using var conn = CreateConnection();
|
||
var deletedFilter = includeDeleted ? "" : "AND m.deleted_on IS NULL";
|
||
return await conn.ExecuteScalarAsync<int>($@"
|
||
SELECT COUNT(*) FROM messages m
|
||
JOIN chains c ON m.chain_id = c.chain_id
|
||
WHERE c.archive_id IS NULL AND c.session_id = @sessionId AND m.type = 'text' {deletedFilter}",
|
||
new { sessionId });
|
||
}
|
||
|
||
public async Task<List<MessageRecord>> GetLatestActiveMessages(string sessionId, int limit, bool includeDeleted = false)
|
||
{
|
||
using var conn = CreateConnection();
|
||
var deletedFilter = includeDeleted ? "" : "AND m.deleted_on IS NULL";
|
||
var messages = await conn.QueryAsync<MessageRecord>($@"
|
||
SELECT m.*, (CASE WHEN m.tag = 'openclaw-import' THEN m.id - 10000000000 ELSE m.id END) AS sort_key FROM messages m
|
||
JOIN chains c ON m.chain_id = c.chain_id
|
||
WHERE c.archive_id IS NULL AND c.session_id = @sessionId {deletedFilter}
|
||
ORDER BY sort_key DESC LIMIT @limit",
|
||
new { sessionId, limit });
|
||
return messages.Reverse().ToList();
|
||
}
|
||
|
||
public async Task<int> GetActiveMessageCountBeforeSortKey(string sessionId, long beforeSortKey, bool includeDeleted = false)
|
||
{
|
||
using var conn = CreateConnection();
|
||
var deletedFilter = includeDeleted ? "" : "AND m.deleted_on IS NULL";
|
||
return await conn.ExecuteScalarAsync<int>($@"
|
||
SELECT COUNT(*) FROM messages m
|
||
JOIN chains c ON m.chain_id = c.chain_id
|
||
WHERE (CASE WHEN m.tag = 'openclaw-import' THEN m.id - 10000000000 ELSE m.id END) < @beforeSortKey AND c.archive_id IS NULL AND c.session_id = @sessionId AND m.type = 'text' {deletedFilter}",
|
||
new { sessionId, beforeSortKey });
|
||
}
|
||
|
||
public async Task<List<MessageRecord>> GetActiveMessagesBeforeSortKey(string sessionId, long beforeSortKey, int limit, bool includeDeleted = false)
|
||
{
|
||
using var conn = CreateConnection();
|
||
var deletedFilter = includeDeleted ? "" : "AND m.deleted_on IS NULL";
|
||
var messages = await conn.QueryAsync<MessageRecord>($@"
|
||
SELECT m.*, (CASE WHEN m.tag = 'openclaw-import' THEN m.id - 10000000000 ELSE m.id END) AS sort_key FROM messages m
|
||
JOIN chains c ON m.chain_id = c.chain_id
|
||
WHERE (CASE WHEN m.tag = 'openclaw-import' THEN m.id - 10000000000 ELSE m.id END) < @beforeSortKey AND c.archive_id IS NULL AND c.session_id = @sessionId {deletedFilter}
|
||
ORDER BY sort_key DESC LIMIT @limit",
|
||
new { sessionId, beforeSortKey, limit });
|
||
return messages.Reverse().ToList();
|
||
}
|
||
|
||
public async Task<IEnumerable<MessageRecord>> GetActiveMessagesAfterSortKey(string sessionId, long afterSortKey, int limit = 200, bool includeDeleted = false)
|
||
{
|
||
using var conn = CreateConnection();
|
||
var deletedFilter = includeDeleted ? "" : "AND m.deleted_on IS NULL";
|
||
return await conn.QueryAsync<MessageRecord>($@"
|
||
SELECT m.*, (CASE WHEN m.tag = 'openclaw-import' THEN m.id - 10000000000 ELSE m.id END) AS sort_key FROM messages m
|
||
JOIN chains c ON m.chain_id = c.chain_id
|
||
WHERE (CASE WHEN m.tag = 'openclaw-import' THEN m.id - 10000000000 ELSE m.id END) > @afterSortKey AND c.archive_id IS NULL AND c.session_id = @sessionId {deletedFilter}
|
||
ORDER BY sort_key LIMIT @limit",
|
||
new { sessionId, afterSortKey, limit });
|
||
}
|
||
|
||
// ── Message Marking ──
|
||
|
||
public async Task<bool> ToggleMessageMark(long messageId)
|
||
{
|
||
using var conn = CreateConnection();
|
||
await conn.ExecuteAsync(
|
||
"UPDATE messages SET is_marked = CASE WHEN is_marked = 1 THEN 0 ELSE 1 END WHERE id = @messageId",
|
||
new { messageId });
|
||
return await conn.ExecuteScalarAsync<bool>(
|
||
"SELECT is_marked FROM messages WHERE id = @messageId",
|
||
new { messageId });
|
||
}
|
||
|
||
// ── Message Deletion ──
|
||
|
||
public async Task<string> ToggleMessageDelete(long messageId)
|
||
{
|
||
using var conn = CreateConnection();
|
||
var current = await conn.ExecuteScalarAsync<string>(
|
||
"SELECT deleted_on FROM messages WHERE id = @messageId", new { messageId });
|
||
string newValue;
|
||
if (current == null)
|
||
{
|
||
newValue = DateTime.UtcNow.ToString("o");
|
||
await conn.ExecuteAsync(
|
||
"UPDATE messages SET deleted_on = @newValue WHERE id = @messageId",
|
||
new { messageId, newValue });
|
||
}
|
||
else
|
||
{
|
||
newValue = null;
|
||
await conn.ExecuteAsync(
|
||
"UPDATE messages SET deleted_on = NULL WHERE id = @messageId",
|
||
new { messageId });
|
||
}
|
||
return newValue;
|
||
}
|
||
|
||
public async Task<IEnumerable<MessageRecord>> GetMarkedMessages(string sessionId)
|
||
{
|
||
using var conn = CreateConnection();
|
||
return await conn.QueryAsync<MessageRecord>(@"
|
||
SELECT m.* FROM messages m
|
||
JOIN chains c ON m.chain_id = c.chain_id
|
||
WHERE c.session_id = @sessionId AND m.is_marked = 1
|
||
ORDER BY m.id",
|
||
new { sessionId });
|
||
}
|
||
|
||
// ── Agent Skills ──
|
||
|
||
public async Task<IEnumerable<string>> GetAgentSkills(string agentId)
|
||
{
|
||
using var conn = CreateConnection();
|
||
return await conn.QueryAsync<string>(
|
||
"SELECT skill_slug FROM agent_skills WHERE agent_id = @agentId ORDER BY skill_slug",
|
||
new { agentId });
|
||
}
|
||
|
||
public async Task SetAgentSkills(string agentId, IEnumerable<string> skillSlugs)
|
||
{
|
||
using var conn = CreateConnection();
|
||
using var tx = conn.BeginTransaction();
|
||
|
||
await conn.ExecuteAsync("DELETE FROM agent_skills WHERE agent_id = @agentId", new { agentId }, tx);
|
||
|
||
var now = DateTime.UtcNow.ToString("o");
|
||
foreach (var slug in skillSlugs)
|
||
{
|
||
await conn.ExecuteAsync(@"
|
||
INSERT INTO agent_skills (agent_id, skill_slug, created_at)
|
||
VALUES (@agentId, @slug, @now)",
|
||
new { agentId, slug, now }, tx);
|
||
}
|
||
|
||
tx.Commit();
|
||
}
|
||
|
||
public async Task AddAgentSkill(string agentId, string skillSlug)
|
||
{
|
||
using var conn = CreateConnection();
|
||
var now = DateTime.UtcNow.ToString("o");
|
||
await conn.ExecuteAsync(@"
|
||
INSERT OR IGNORE INTO agent_skills (agent_id, skill_slug, created_at)
|
||
VALUES (@agentId, @skillSlug, @now)",
|
||
new { agentId, skillSlug, now });
|
||
}
|
||
|
||
public async Task RemoveAgentSkill(string agentId, string skillSlug)
|
||
{
|
||
using var conn = CreateConnection();
|
||
await conn.ExecuteAsync(
|
||
"DELETE FROM agent_skills WHERE agent_id = @agentId AND skill_slug = @skillSlug",
|
||
new { agentId, skillSlug });
|
||
}
|
||
|
||
// ── Users ──
|
||
|
||
public void EnsureDefaultUser(AuthService auth)
|
||
{
|
||
using var conn = CreateConnection();
|
||
var count = conn.ExecuteScalar<int>("SELECT COUNT(*) FROM users");
|
||
string adminUserId;
|
||
if (count == 0)
|
||
{
|
||
adminUserId = Guid.NewGuid().ToString("N");
|
||
var now = DateTime.UtcNow.ToString("o");
|
||
conn.Execute(@"
|
||
INSERT INTO users (user_id, username, password_hash, display_name, is_admin, created_at, updated_at)
|
||
VALUES (@userId, @username, @hash, @displayName, 1, @now, @now)",
|
||
new
|
||
{
|
||
userId = adminUserId,
|
||
username = "admin",
|
||
hash = auth.HashPassword("admin"),
|
||
displayName = "Admin",
|
||
now
|
||
});
|
||
}
|
||
else
|
||
{
|
||
adminUserId = conn.ExecuteScalar<string>("SELECT user_id FROM users ORDER BY id LIMIT 1");
|
||
}
|
||
|
||
// Bestehende Sessions/Agents ohne User dem ersten User zuweisen
|
||
conn.Execute("UPDATE sessions SET user_id = @userId WHERE user_id IS NULL", new { userId = adminUserId });
|
||
conn.Execute("UPDATE agents SET user_id = @userId WHERE user_id IS NULL", new { userId = adminUserId });
|
||
|
||
// Erster User ist immer Admin
|
||
conn.Execute("UPDATE users SET is_admin = 1 WHERE user_id = @userId AND is_admin = 0", new { userId = adminUserId });
|
||
}
|
||
|
||
public async Task<UserRecord> GetUserByUsername(string username)
|
||
{
|
||
using var conn = CreateConnection();
|
||
return await conn.QuerySingleOrDefaultAsync<UserRecord>(
|
||
"SELECT * FROM users WHERE username = @username COLLATE NOCASE",
|
||
new { username });
|
||
}
|
||
|
||
public async Task<UserRecord> GetUser(string userId)
|
||
{
|
||
using var conn = CreateConnection();
|
||
return await conn.QuerySingleOrDefaultAsync<UserRecord>(
|
||
"SELECT * FROM users WHERE user_id = @userId",
|
||
new { userId });
|
||
}
|
||
|
||
public async Task<IEnumerable<UserRecord>> GetUsers()
|
||
{
|
||
using var conn = CreateConnection();
|
||
return await conn.QueryAsync<UserRecord>(
|
||
"SELECT * FROM users ORDER BY username");
|
||
}
|
||
|
||
public async Task UpdateUserPassword(string userId, string passwordHash)
|
||
{
|
||
using var conn = CreateConnection();
|
||
await conn.ExecuteAsync(
|
||
"UPDATE users SET password_hash = @passwordHash, updated_at = @now WHERE user_id = @userId",
|
||
new { userId, passwordHash, now = DateTime.UtcNow.ToString("o") });
|
||
}
|
||
|
||
public async Task UpdateUserProfile(string userId, string displayName, string persona)
|
||
{
|
||
using var conn = CreateConnection();
|
||
await conn.ExecuteAsync(
|
||
"UPDATE users SET display_name = @displayName, persona = @persona, updated_at = @now WHERE user_id = @userId",
|
||
new { userId, displayName, persona, now = DateTime.UtcNow.ToString("o") });
|
||
}
|
||
|
||
public async Task<string> GetUserSettings(string userId)
|
||
{
|
||
using var conn = CreateConnection();
|
||
return await conn.QuerySingleOrDefaultAsync<string>(
|
||
"SELECT settings FROM users WHERE user_id = @userId",
|
||
new { userId });
|
||
}
|
||
|
||
public async Task UpdateUserSettings(string userId, string settings)
|
||
{
|
||
using var conn = CreateConnection();
|
||
await conn.ExecuteAsync(
|
||
"UPDATE users SET settings = @settings, updated_at = @now WHERE user_id = @userId",
|
||
new { userId, settings, now = DateTime.UtcNow.ToString("o") });
|
||
}
|
||
|
||
public async Task<UserRecord> CreateUser(string username, string passwordHash, string displayName, bool isAdmin, string defaultPath = null)
|
||
{
|
||
using var conn = CreateConnection();
|
||
var now = DateTime.UtcNow.ToString("o");
|
||
var userId = Guid.NewGuid().ToString("N");
|
||
await conn.ExecuteAsync(@"
|
||
INSERT INTO users (user_id, username, password_hash, display_name, is_admin, default_path, created_at, updated_at)
|
||
VALUES (@userId, @username, @passwordHash, @displayName, @isAdmin, @defaultPath, @now, @now)",
|
||
new { userId, username, passwordHash, displayName, isAdmin, defaultPath, now });
|
||
return await GetUser(userId);
|
||
}
|
||
|
||
public async Task UpdateUserAdmin(string userId, string username, string displayName, bool isAdmin, string defaultPath)
|
||
{
|
||
using var conn = CreateConnection();
|
||
await conn.ExecuteAsync(
|
||
"UPDATE users SET username = @username, display_name = @displayName, is_admin = @isAdmin, default_path = @defaultPath, updated_at = @now WHERE user_id = @userId",
|
||
new { userId, username, displayName, isAdmin, defaultPath, now = DateTime.UtcNow.ToString("o") });
|
||
}
|
||
|
||
public async Task DeleteUser(string userId)
|
||
{
|
||
using var conn = CreateConnection();
|
||
await conn.ExecuteAsync("DELETE FROM tokens WHERE user_id = @userId", new { userId });
|
||
await conn.ExecuteAsync("DELETE FROM shared_messages WHERE user_id = @userId", new { userId });
|
||
await conn.ExecuteAsync("DELETE FROM users WHERE user_id = @userId", new { userId });
|
||
}
|
||
|
||
// ── Telegram Bots ──
|
||
|
||
private void MigrateTelegramBotsFromSessions(SqliteConnection conn)
|
||
{
|
||
// Prüfe ob es Sessions mit bot_token gibt, die noch kein telegram_bot_id haben
|
||
var sessions = conn.Query<dynamic>(
|
||
"SELECT session_id, user_id, name, telegram_bot_token, telegram_webhook_secret FROM sessions WHERE telegram_bot_token IS NOT NULL AND telegram_bot_token != '' AND (telegram_bot_id IS NULL OR telegram_bot_id = '')").ToList();
|
||
|
||
if (sessions.Count == 0) return;
|
||
|
||
var now = DateTime.UtcNow.ToString("o");
|
||
foreach (var s in sessions)
|
||
{
|
||
string token = s.telegram_bot_token;
|
||
string userId = s.user_id ?? "";
|
||
string sessionName = s.name ?? "Unbenannt";
|
||
string secret = s.telegram_webhook_secret ?? Guid.NewGuid().ToString("N");
|
||
|
||
// Prüfe ob für diesen Token bereits ein Bot existiert
|
||
var existing = conn.QuerySingleOrDefault<dynamic>(
|
||
"SELECT bot_id FROM telegram_bots WHERE bot_token = @token", new { token });
|
||
|
||
string botId;
|
||
if (existing != null)
|
||
{
|
||
botId = existing.bot_id;
|
||
}
|
||
else
|
||
{
|
||
botId = Guid.NewGuid().ToString();
|
||
conn.Execute(@"
|
||
INSERT INTO telegram_bots (bot_id, user_id, name, bot_token, webhook_secret, created_at, updated_at)
|
||
VALUES (@botId, @userId, @name, @token, @secret, @now, @now)",
|
||
new { botId, userId, name = $"Bot von {sessionName}", token, secret, now });
|
||
}
|
||
|
||
conn.Execute(
|
||
"UPDATE sessions SET telegram_bot_id = @botId, updated_at = @now WHERE session_id = @sessionId",
|
||
new { botId, sessionId = (string)s.session_id, now });
|
||
}
|
||
}
|
||
|
||
public async Task<IEnumerable<TelegramBotRecord>> GetTelegramBots(string userId)
|
||
{
|
||
using var conn = CreateConnection();
|
||
return await conn.QueryAsync<TelegramBotRecord>(
|
||
"SELECT * FROM telegram_bots WHERE user_id = @userId ORDER BY created_at DESC",
|
||
new { userId });
|
||
}
|
||
|
||
public async Task<IEnumerable<TelegramBotRecord>> GetAvailableTelegramBots(string userId)
|
||
{
|
||
using var conn = CreateConnection();
|
||
return await conn.QueryAsync<TelegramBotRecord>(
|
||
"SELECT * FROM telegram_bots WHERE user_id = @userId OR is_public = 1 ORDER BY created_at DESC",
|
||
new { userId });
|
||
}
|
||
|
||
public async Task<TelegramBotRecord> GetTelegramBot(string botId)
|
||
{
|
||
using var conn = CreateConnection();
|
||
return await conn.QuerySingleOrDefaultAsync<TelegramBotRecord>(
|
||
"SELECT * FROM telegram_bots WHERE bot_id = @botId",
|
||
new { botId });
|
||
}
|
||
|
||
public async Task<TelegramBotRecord> CreateTelegramBot(string userId, string name, string botToken)
|
||
{
|
||
using var conn = CreateConnection();
|
||
var botId = Guid.NewGuid().ToString();
|
||
var secret = Guid.NewGuid().ToString("N");
|
||
var now = DateTime.UtcNow.ToString("o");
|
||
await conn.ExecuteAsync(@"
|
||
INSERT INTO telegram_bots (bot_id, user_id, name, bot_token, webhook_secret, created_at, updated_at)
|
||
VALUES (@botId, @userId, @name, @botToken, @secret, @now, @now)",
|
||
new { botId, userId, name, botToken, secret, now });
|
||
return await GetTelegramBot(botId);
|
||
}
|
||
|
||
public async Task UpdateTelegramBot(string botId, string name, string botToken, bool isPublic)
|
||
{
|
||
using var conn = CreateConnection();
|
||
var now = DateTime.UtcNow.ToString("o");
|
||
await conn.ExecuteAsync(@"
|
||
UPDATE telegram_bots SET name = @name, bot_token = @botToken, is_public = @isPublic, updated_at = @now
|
||
WHERE bot_id = @botId",
|
||
new { botId, name, botToken, isPublic, now });
|
||
}
|
||
|
||
public async Task DeleteTelegramBot(string botId)
|
||
{
|
||
using var conn = CreateConnection();
|
||
var now = DateTime.UtcNow.ToString("o");
|
||
// Erst alle Session-Verknüpfungen entfernen
|
||
await conn.ExecuteAsync(
|
||
"UPDATE sessions SET telegram_bot_id = NULL, updated_at = @now WHERE telegram_bot_id = @botId",
|
||
new { botId, now });
|
||
await conn.ExecuteAsync("DELETE FROM telegram_bots WHERE bot_id = @botId", new { botId });
|
||
}
|
||
|
||
public async Task<int> GetTelegramBotSessionCount(string botId)
|
||
{
|
||
using var conn = CreateConnection();
|
||
return await conn.ExecuteScalarAsync<int>(
|
||
"SELECT COUNT(*) FROM sessions WHERE telegram_bot_id = @botId AND is_deleted = 0",
|
||
new { botId });
|
||
}
|
||
|
||
public async Task SetSessionTelegramBot(string sessionId, string botId)
|
||
{
|
||
using var conn = CreateConnection();
|
||
var now = DateTime.UtcNow.ToString("o");
|
||
await conn.ExecuteAsync(
|
||
"UPDATE sessions SET telegram_bot_id = @botId, updated_at = @now WHERE session_id = @sessionId",
|
||
new { sessionId, botId = (object)botId ?? DBNull.Value, now });
|
||
}
|
||
|
||
public async Task UpdateSessionTelegramChat(string sessionId, string chatId)
|
||
{
|
||
using var conn = CreateConnection();
|
||
var now = DateTime.UtcNow.ToString("o");
|
||
await conn.ExecuteAsync(@"
|
||
UPDATE sessions SET telegram_chat_id = @chatId, updated_at = @now
|
||
WHERE session_id = @sessionId",
|
||
new { sessionId, chatId, now });
|
||
}
|
||
|
||
public async Task<IEnumerable<SessionRecord>> GetSessionsByTelegramBot(string botId)
|
||
{
|
||
using var conn = CreateConnection();
|
||
return await conn.QueryAsync<SessionRecord>(
|
||
"SELECT * FROM sessions WHERE telegram_bot_id = @botId AND is_deleted = 0",
|
||
new { botId });
|
||
}
|
||
|
||
public async Task<SessionRecord> FindSessionForTelegramChat(string botId, long chatId)
|
||
{
|
||
using var conn = CreateConnection();
|
||
var chatIdStr = chatId.ToString();
|
||
return await conn.QuerySingleOrDefaultAsync<SessionRecord>(
|
||
"SELECT * FROM sessions WHERE telegram_bot_id = @botId AND telegram_chat_id = @chatIdStr AND is_deleted = 0",
|
||
new { botId, chatIdStr });
|
||
}
|
||
|
||
public async Task<IEnumerable<SessionRecord>> FindUnlinkedSessionsForBot(string botId)
|
||
{
|
||
using var conn = CreateConnection();
|
||
return await conn.QueryAsync<SessionRecord>(
|
||
"SELECT * FROM sessions WHERE telegram_bot_id = @botId AND (telegram_chat_id IS NULL OR telegram_chat_id = '') AND is_deleted = 0",
|
||
new { botId });
|
||
}
|
||
|
||
// ── Cronjobs ──
|
||
|
||
public async Task<CronjobRecord> CreateCronjob(CronjobRecord cronjob)
|
||
{
|
||
using var conn = CreateConnection();
|
||
var now = DateTime.UtcNow.ToString("o");
|
||
cronjob.CronjobId = Guid.NewGuid().ToString("N");
|
||
cronjob.CreatedAt = now;
|
||
cronjob.UpdatedAt = now;
|
||
await conn.ExecuteAsync(@"
|
||
INSERT INTO cronjobs (cronjob_id, user_id, session_id, name, cron_expression, prompt, notify_session_id, is_enabled, next_run_at, created_at, updated_at)
|
||
VALUES (@CronjobId, @UserId, @SessionId, @Name, @CronExpression, @Prompt, @NotifySessionId, @IsEnabled, @NextRunAt, @CreatedAt, @UpdatedAt)
|
||
", cronjob);
|
||
return cronjob;
|
||
}
|
||
|
||
public async Task<IEnumerable<CronjobRecord>> GetCronjobs(string userId)
|
||
{
|
||
using var conn = CreateConnection();
|
||
return await conn.QueryAsync<CronjobRecord>(@"
|
||
SELECT c.*, s.name AS session_name, ns.name AS notify_session_name
|
||
FROM cronjobs c
|
||
LEFT JOIN sessions s ON c.session_id = s.session_id
|
||
LEFT JOIN sessions ns ON c.notify_session_id = ns.session_id
|
||
WHERE c.user_id = @userId
|
||
ORDER BY c.name",
|
||
new { userId });
|
||
}
|
||
|
||
public async Task<CronjobRecord> GetCronjob(string cronjobId)
|
||
{
|
||
using var conn = CreateConnection();
|
||
return await conn.QuerySingleOrDefaultAsync<CronjobRecord>(@"
|
||
SELECT c.*, s.name AS session_name, ns.name AS notify_session_name
|
||
FROM cronjobs c
|
||
LEFT JOIN sessions s ON c.session_id = s.session_id
|
||
LEFT JOIN sessions ns ON c.notify_session_id = ns.session_id
|
||
WHERE c.cronjob_id = @cronjobId",
|
||
new { cronjobId });
|
||
}
|
||
|
||
public async Task UpdateCronjob(CronjobRecord cronjob)
|
||
{
|
||
using var conn = CreateConnection();
|
||
cronjob.UpdatedAt = DateTime.UtcNow.ToString("o");
|
||
await conn.ExecuteAsync(@"
|
||
UPDATE cronjobs SET name = @Name, session_id = @SessionId, cron_expression = @CronExpression,
|
||
prompt = @Prompt, notify_session_id = @NotifySessionId, is_enabled = @IsEnabled, next_run_at = @NextRunAt, updated_at = @UpdatedAt
|
||
WHERE cronjob_id = @CronjobId AND user_id = @UserId",
|
||
cronjob);
|
||
}
|
||
|
||
public async Task DeleteCronjob(string cronjobId, string userId)
|
||
{
|
||
using var conn = CreateConnection();
|
||
await conn.ExecuteAsync(
|
||
"DELETE FROM cronjobs WHERE cronjob_id = @cronjobId AND user_id = @userId",
|
||
new { cronjobId, userId });
|
||
}
|
||
|
||
public async Task UpdateCronjobRun(string cronjobId, string status, string error, string nextRunAt)
|
||
{
|
||
using var conn = CreateConnection();
|
||
await conn.ExecuteAsync(@"
|
||
UPDATE cronjobs SET last_run_at = @now, last_run_status = @status,
|
||
last_run_error = @error, next_run_at = @nextRunAt, updated_at = @now
|
||
WHERE cronjob_id = @cronjobId",
|
||
new { cronjobId, status, error, nextRunAt, now = DateTime.UtcNow.ToString("o") });
|
||
}
|
||
|
||
public async Task<IEnumerable<CronjobRecord>> GetDueCronjobs()
|
||
{
|
||
using var conn = CreateConnection();
|
||
var now = DateTime.UtcNow.ToString("o");
|
||
return await conn.QueryAsync<CronjobRecord>(@"
|
||
SELECT c.*, s.name AS session_name, ns.name AS notify_session_name
|
||
FROM cronjobs c
|
||
LEFT JOIN sessions s ON c.session_id = s.session_id
|
||
LEFT JOIN sessions ns ON c.notify_session_id = ns.session_id
|
||
WHERE c.is_enabled = 1 AND c.next_run_at IS NOT NULL AND c.next_run_at <= @now",
|
||
new { now });
|
||
}
|
||
|
||
// ── API Keys ──
|
||
|
||
private void MigrateAnthropicTokenFromConfig(SqliteConnection conn)
|
||
{
|
||
// Nur migrieren wenn noch keine Anthropic-Keys existieren
|
||
var count = conn.ExecuteScalar<int>("SELECT COUNT(*) FROM api_keys WHERE provider = 'anthropic'");
|
||
if (count > 0) return;
|
||
|
||
// Token aus appsettings.Production.json lesen
|
||
var configPath = Path.Combine(Path.GetDirectoryName(_connectionString.Replace("Data Source=", "")) ?? "", "..", "appsettings.Production.json");
|
||
// Normaler Pfad: neben der DB-Datei im übergeordneten Ordner
|
||
var basePath = Path.GetDirectoryName(_connectionString.Replace("Data Source=", ""));
|
||
if (basePath != null)
|
||
{
|
||
var prodPath = Path.Combine(basePath, "..", "appsettings.Production.json");
|
||
if (File.Exists(prodPath))
|
||
{
|
||
try
|
||
{
|
||
var json = File.ReadAllText(prodPath);
|
||
using var doc = System.Text.Json.JsonDocument.Parse(json);
|
||
if (doc.RootElement.TryGetProperty("AnthropicToken", out var tokenEl))
|
||
{
|
||
var token = tokenEl.GetString();
|
||
if (!string.IsNullOrWhiteSpace(token))
|
||
{
|
||
var now = DateTime.UtcNow.ToString("o");
|
||
conn.Execute(@"
|
||
INSERT INTO api_keys (key_id, provider, name, api_key, is_active, created_at, updated_at)
|
||
VALUES (@keyId, 'anthropic', 'Migriert aus Config', @token, 1, @now, @now)",
|
||
new { keyId = Guid.NewGuid().ToString("N"), token, now });
|
||
Console.WriteLine("[Migration] AnthropicToken aus appsettings.Production.json → api_keys migriert");
|
||
}
|
||
}
|
||
}
|
||
catch { /* Config nicht lesbar – ignorieren */ }
|
||
}
|
||
}
|
||
}
|
||
|
||
public async Task<IEnumerable<ApiKeyRecord>> GetApiKeys()
|
||
{
|
||
using var conn = CreateConnection();
|
||
return await conn.QueryAsync<ApiKeyRecord>(
|
||
"SELECT * FROM api_keys ORDER BY provider, name");
|
||
}
|
||
|
||
public async Task<IEnumerable<ApiKeyRecord>> GetApiKeysByProvider(string provider)
|
||
{
|
||
using var conn = CreateConnection();
|
||
return await conn.QueryAsync<ApiKeyRecord>(
|
||
"SELECT * FROM api_keys WHERE provider = @provider ORDER BY name",
|
||
new { provider });
|
||
}
|
||
|
||
public async Task<ApiKeyRecord> GetActiveApiKey(string provider)
|
||
{
|
||
using var conn = CreateConnection();
|
||
return await conn.QuerySingleOrDefaultAsync<ApiKeyRecord>(
|
||
"SELECT * FROM api_keys WHERE provider = @provider AND is_active = 1 LIMIT 1",
|
||
new { provider });
|
||
}
|
||
|
||
public async Task<ApiKeyRecord> GetApiKey(string keyId)
|
||
{
|
||
using var conn = CreateConnection();
|
||
return await conn.QuerySingleOrDefaultAsync<ApiKeyRecord>(
|
||
"SELECT * FROM api_keys WHERE key_id = @keyId",
|
||
new { keyId });
|
||
}
|
||
|
||
public async Task CreateApiKey(ApiKeyRecord record)
|
||
{
|
||
using var conn = CreateConnection();
|
||
await conn.ExecuteAsync(@"
|
||
INSERT INTO api_keys (key_id, provider, name, api_key, is_active, created_at, updated_at)
|
||
VALUES (@KeyId, @Provider, @Name, @ApiKey, @IsActive, @CreatedAt, @UpdatedAt)",
|
||
record);
|
||
}
|
||
|
||
public async Task UpdateApiKey(string keyId, string name, string apiKey = null)
|
||
{
|
||
using var conn = CreateConnection();
|
||
if (!string.IsNullOrWhiteSpace(apiKey))
|
||
{
|
||
await conn.ExecuteAsync(
|
||
"UPDATE api_keys SET name = @name, api_key = @apiKey, updated_at = @now WHERE key_id = @keyId",
|
||
new { keyId, name, apiKey, now = DateTime.UtcNow.ToString("o") });
|
||
}
|
||
else
|
||
{
|
||
await conn.ExecuteAsync(
|
||
"UPDATE api_keys SET name = @name, updated_at = @now WHERE key_id = @keyId",
|
||
new { keyId, name, now = DateTime.UtcNow.ToString("o") });
|
||
}
|
||
}
|
||
|
||
public async Task SetActiveApiKey(string keyId, string provider)
|
||
{
|
||
using var conn = CreateConnection();
|
||
// Erst alle deaktivieren
|
||
await conn.ExecuteAsync(
|
||
"UPDATE api_keys SET is_active = 0, updated_at = @now WHERE provider = @provider",
|
||
new { provider, now = DateTime.UtcNow.ToString("o") });
|
||
// Dann den gewählten aktivieren
|
||
await conn.ExecuteAsync(
|
||
"UPDATE api_keys SET is_active = 1, updated_at = @now WHERE key_id = @keyId",
|
||
new { keyId, now = DateTime.UtcNow.ToString("o") });
|
||
}
|
||
|
||
public async Task DeactivateApiKey(string keyId)
|
||
{
|
||
using var conn = CreateConnection();
|
||
await conn.ExecuteAsync(
|
||
"UPDATE api_keys SET is_active = 0, updated_at = @now WHERE key_id = @keyId",
|
||
new { keyId, now = DateTime.UtcNow.ToString("o") });
|
||
}
|
||
|
||
public async Task DeleteApiKey(string keyId)
|
||
{
|
||
using var conn = CreateConnection();
|
||
await conn.ExecuteAsync("DELETE FROM api_keys WHERE key_id = @keyId", new { keyId });
|
||
}
|
||
}
|