Files
gott/Services/DatabaseService.cs
T
Meleki fa8deb15b6 feat: API-Keys-Verwaltung in DB statt appsettings
- 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
2026-03-12 12:07:22 +01:00

1544 lines
69 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 });
}
}