Files
gott/Services/DatabaseService.cs
T
Meleki 0dd7c38751 feat: Telegram Bot Integration
- TelegramService: Non-Streaming Claude Chat mit Tool-Loop,
  Commands (/start, /link, /session, /new, /clear, /status, /unlink),
  Nachrichten-Splitting bei >4096 Zeichen, Typing-Indicator,
  History-Aufbau aus DB, Token-Usage-Tracking
- TelegramEndpoint: Webhook (POST, no-auth), Link-Code-Generierung
  (POST, auth), Webhook-Registrierung + Info (Admin)
- DatabaseService: telegram_links Tabelle mit Link-Code-Flow
  (6-stelliger Code, 5 Min Ablauf), GetUserSessions
- AuthMiddleware: /api/telegram/webhook von Auth ausgenommen
- Session-Verknüpfung via Link-Code (Web-UI generiert Code,
  Telegram /link CODE löst ein)
- TelegramLinkRecord Model
2026-03-03 06:30:05 +01:00

845 lines
35 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Dapper;
using Gott.Models;
using Microsoft.Data.Sqlite;
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, "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, "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_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, "agents", "user_id", "TEXT");
TryAddColumn(conn, "users", "is_admin", "INTEGER NOT NULL DEFAULT 0");
TryAddColumn(conn, "users", "persona", "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,
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);
");
}
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 */ }
}
// ── 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, description, model, tool_choice, custom_commands, auto_commit, use_soul, use_identity, use_user_info, use_memory, use_web_search, web_search_version, is_deleted, created_at, updated_at)
VALUES (@AgentId, @UserId, @Name, @Avatar, @Description, @Model, @ToolChoice, @CustomCommands, @AutoCommit, @UseSoul, @UseIdentity, @UseUserInfo, @UseMemory, @UseWebSearch, @WebSearchVersion, 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<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, 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_web_search = @UseWebSearch, web_search_version = @WebSearchVersion,
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 agentId = null, string workspace = null, string context = null, string customCommands = 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,
AgentId = agentId,
Workspace = workspace,
Context = context,
CustomCommands = customCommands,
CreatedAt = now,
UpdatedAt = now
};
await conn.ExecuteAsync(@"
INSERT INTO sessions (session_id, user_id, name, description, image, agent_id, workspace, context, custom_commands, created_at, updated_at)
VALUES (@SessionId, @UserId, @Name, @Description, @Image, @AgentId, @Workspace, @Context, @CustomCommands, @CreatedAt, @UpdatedAt)
", session);
return session;
}
public async Task<IEnumerable<SessionRecord>> GetSessions(string userId)
{
using var conn = CreateConnection();
return await conn.QueryAsync<SessionRecord>(
"SELECT * FROM sessions WHERE user_id = @userId AND is_deleted = 0 ORDER BY updated_at DESC", new { userId });
}
public async Task<IEnumerable<object>> 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<object>();
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",
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",
new { sessionId = s.SessionId });
results.Add(new
{
s.SessionId,
s.Name,
s.Description,
s.Image,
s.AgentId,
s.Workspace,
s.Context,
s.CustomCommands,
s.IsDeleted,
s.CreatedAt,
s.UpdatedAt,
MessageCount = activeCount,
TotalMessageCount = totalCount
});
}
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) 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()); }
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 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, created_at)
VALUES (@ChainId, @SessionId, @ArchiveId, @AgentId, @Model, @SystemPrompt, @ToolChoice, @ToolNames, @InputTokens, @OutputTokens, @Iterations, @StopReason, @DurationMs, @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,
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, created_at)
VALUES (@ChainId, @Role, @Type, @Content, @ToolName, @ToolCallId, @IsError, @Iteration, @AttachmentCount, @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, created_at)
VALUES (@ChainId, @Role, @Type, @Content, @ToolName, @ToolCallId, @IsError, @Iteration, @AttachmentCount, @AttachmentNames, @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;
}
// ── 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)
{
using var conn = CreateConnection();
return await conn.QueryAsync<MessageRecord>(
"SELECT * FROM messages WHERE chain_id = @chainId ORDER BY id", new { chainId }
);
}
public async Task<int> GetActiveMessageCount(string sessionId)
{
using var conn = CreateConnection();
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",
new { sessionId });
}
public async Task<List<MessageRecord>> GetLatestActiveMessages(string sessionId, int limit)
{
using var conn = CreateConnection();
var messages = await conn.QueryAsync<MessageRecord>(@"
SELECT m.* FROM messages m
JOIN chains c ON m.chain_id = c.chain_id
WHERE c.archive_id IS NULL AND c.session_id = @sessionId
ORDER BY m.id DESC LIMIT @limit",
new { sessionId, limit });
return messages.Reverse().ToList();
}
public async Task<int> GetActiveMessageCountBeforeId(string sessionId, long beforeId)
{
using var conn = CreateConnection();
return await conn.ExecuteScalarAsync<int>(@"
SELECT COUNT(*) FROM messages m
JOIN chains c ON m.chain_id = c.chain_id
WHERE m.id < @beforeId AND c.archive_id IS NULL AND c.session_id = @sessionId",
new { sessionId, beforeId });
}
public async Task<List<MessageRecord>> GetActiveMessagesBeforeId(string sessionId, long beforeId, int limit)
{
using var conn = CreateConnection();
var messages = await conn.QueryAsync<MessageRecord>(@"
SELECT m.* FROM messages m
JOIN chains c ON m.chain_id = c.chain_id
WHERE m.id < @beforeId AND c.archive_id IS NULL AND c.session_id = @sessionId
ORDER BY m.id DESC LIMIT @limit",
new { sessionId, beforeId, limit });
return messages.Reverse().ToList();
}
public async Task<IEnumerable<MessageRecord>> GetActiveMessagesAfterId(string sessionId, long afterId, int limit = 200)
{
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 m.id > @afterId AND c.archive_id IS NULL AND c.session_id = @sessionId
ORDER BY m.id LIMIT @limit",
new { sessionId, afterId, 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 });
}
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") });
}
// ── Telegram ──
public async Task<string> CreateTelegramLinkCode(string userId, string sessionId, string agentId)
{
using var conn = CreateConnection();
var code = Guid.NewGuid().ToString("N")[..6].ToUpperInvariant();
var now = DateTime.UtcNow.ToString("o");
var expires = DateTime.UtcNow.AddMinutes(5).ToString("o");
// Alten Code für diese Session löschen
await conn.ExecuteAsync(
"DELETE FROM telegram_links WHERE link_code IS NOT NULL AND user_id = @userId AND session_id = @sessionId",
new { userId, sessionId });
// Neuen Pending-Link-Code erstellen (noch ohne telegram_chat_id)
await conn.ExecuteAsync(@"
INSERT INTO telegram_links (telegram_chat_id, user_id, session_id, agent_id, link_code, expires_at, created_at, updated_at)
VALUES (0, @userId, @sessionId, @agentId, @code, @expires, @now, @now)",
new { userId, sessionId, agentId, code, expires, now });
return code;
}
public async Task<TelegramLinkRecord> RedeemTelegramLinkCode(string code, long chatId)
{
using var conn = CreateConnection();
var now = DateTime.UtcNow.ToString("o");
var link = await conn.QuerySingleOrDefaultAsync<TelegramLinkRecord>(
"SELECT * FROM telegram_links WHERE link_code = @code AND telegram_chat_id = 0",
new { code });
if (link == null) return null;
if (string.Compare(link.ExpiresAt, now, StringComparison.Ordinal) < 0) return null;
// Bestehende Verknüpfung für diese chatId entfernen
await conn.ExecuteAsync(
"DELETE FROM telegram_links WHERE telegram_chat_id = @chatId",
new { chatId });
// Pending-Eintrag aktivieren
await conn.ExecuteAsync(@"
UPDATE telegram_links SET telegram_chat_id = @chatId, link_code = NULL, expires_at = NULL, updated_at = @now
WHERE id = @id",
new { chatId, now, id = link.Id });
link.TelegramChatId = chatId;
link.LinkCode = null;
return link;
}
public async Task<TelegramLinkRecord> GetTelegramLink(long chatId)
{
using var conn = CreateConnection();
return await conn.QuerySingleOrDefaultAsync<TelegramLinkRecord>(
"SELECT * FROM telegram_links WHERE telegram_chat_id = @chatId",
new { chatId });
}
public async Task UpdateTelegramSession(long chatId, string sessionId, string agentId)
{
using var conn = CreateConnection();
await conn.ExecuteAsync(
"UPDATE telegram_links SET session_id = @sessionId, agent_id = @agentId, updated_at = @now WHERE telegram_chat_id = @chatId",
new { chatId, sessionId, agentId, now = DateTime.UtcNow.ToString("o") });
}
public async Task DeleteTelegramLink(long chatId)
{
using var conn = CreateConnection();
await conn.ExecuteAsync(
"DELETE FROM telegram_links WHERE telegram_chat_id = @chatId",
new { chatId });
}
public async Task<IEnumerable<SessionRecord>> GetUserSessions(string userId)
{
using var conn = CreateConnection();
return await conn.QueryAsync<SessionRecord>(
"SELECT * FROM sessions WHERE user_id = @userId AND is_deleted = 0 ORDER BY updated_at DESC",
new { userId });
}
}