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
This commit is contained in:
1 parent
81714b1f4c
commit
0dd7c38751
7 files changed
+1108
-5
No files matched your search
@@ -0,0 +1,131 @@
|
|||||||
|
using System;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Gott.Extensions;
|
||||||
|
using Gott.Services;
|
||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Routing;
|
||||||
|
|
||||||
|
namespace Gott.Endpoints;
|
||||||
|
|
||||||
|
public static class TelegramEndpoint
|
||||||
|
{
|
||||||
|
public static void MapTelegramEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
// Webhook (von Telegram aufgerufen – keine Cookie-Auth!)
|
||||||
|
app.MapPost("/api/telegram/webhook", HandleWebhook);
|
||||||
|
|
||||||
|
// Link-Code generieren (aus Web-UI, braucht Auth)
|
||||||
|
app.MapPost("/api/telegram/link", HandleCreateLink);
|
||||||
|
|
||||||
|
// Webhook registrieren (Admin)
|
||||||
|
app.MapPost("/api/telegram/register-webhook", HandleRegisterWebhook);
|
||||||
|
|
||||||
|
// Webhook-Info (Admin)
|
||||||
|
app.MapGet("/api/telegram/webhook-info", HandleWebhookInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task HandleWebhook(HttpRequest req, HttpResponse res, TelegramService telegram)
|
||||||
|
{
|
||||||
|
if (!telegram.IsConfigured)
|
||||||
|
{
|
||||||
|
res.StatusCode = 404;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Secret-Token prüfen
|
||||||
|
var secret = req.Headers["X-Telegram-Bot-Api-Secret-Token"].ToString();
|
||||||
|
if (secret != telegram.WebhookSecret)
|
||||||
|
{
|
||||||
|
res.StatusCode = 403;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var body = await JsonSerializer.DeserializeAsync<JsonElement>(req.Body);
|
||||||
|
// Fire-and-forget – Telegram erwartet schnelle 200 OK Antwort
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try { await telegram.HandleUpdate(body); }
|
||||||
|
catch (Exception ex) { Console.Error.WriteLine($"[Telegram] Fehler: {ex.Message}"); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Malformed JSON – ignorieren
|
||||||
|
}
|
||||||
|
|
||||||
|
res.StatusCode = 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task HandleCreateLink(HttpRequest req, HttpResponse res, DatabaseService db, TelegramService telegram)
|
||||||
|
{
|
||||||
|
if (!telegram.IsConfigured)
|
||||||
|
{
|
||||||
|
res.StatusCode = 400;
|
||||||
|
await res.WriteAsJsonAsync(new { error = "Telegram Bot nicht konfiguriert" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var userId = req.HttpContext.GetUserId();
|
||||||
|
var body = await JsonSerializer.DeserializeAsync<JsonElement>(req.Body);
|
||||||
|
|
||||||
|
if (!body.TryGetProperty("sessionId", out var sidEl))
|
||||||
|
{
|
||||||
|
res.StatusCode = 400;
|
||||||
|
await res.WriteAsJsonAsync(new { error = "sessionId required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sessionId = sidEl.GetString();
|
||||||
|
var session = await db.GetSession(sessionId);
|
||||||
|
if (session == null || session.UserId != userId)
|
||||||
|
{
|
||||||
|
res.StatusCode = 404;
|
||||||
|
await res.WriteAsJsonAsync(new { error = "Session nicht gefunden" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var agentId = session.AgentId;
|
||||||
|
var code = await db.CreateTelegramLinkCode(userId, sessionId, agentId);
|
||||||
|
|
||||||
|
await res.WriteAsJsonAsync(new { code, expiresInSeconds = 300 });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task HandleRegisterWebhook(HttpRequest req, HttpResponse res, TelegramService telegram)
|
||||||
|
{
|
||||||
|
if (!req.HttpContext.IsAdmin())
|
||||||
|
{
|
||||||
|
res.StatusCode = 403;
|
||||||
|
await res.WriteAsJsonAsync(new { error = "Admin required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var body = await JsonSerializer.DeserializeAsync<JsonElement>(req.Body);
|
||||||
|
if (!body.TryGetProperty("baseUrl", out var urlEl))
|
||||||
|
{
|
||||||
|
res.StatusCode = 400;
|
||||||
|
await res.WriteAsJsonAsync(new { error = "baseUrl required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await telegram.RegisterWebhook(urlEl.GetString());
|
||||||
|
await res.WriteAsJsonAsync(new { result });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task HandleWebhookInfo(HttpRequest req, HttpResponse res, TelegramService telegram)
|
||||||
|
{
|
||||||
|
if (!req.HttpContext.IsAdmin())
|
||||||
|
{
|
||||||
|
res.StatusCode = 403;
|
||||||
|
await res.WriteAsJsonAsync(new { error = "Admin required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await telegram.GetWebhookInfo();
|
||||||
|
res.ContentType = "application/json";
|
||||||
|
await res.WriteAsync(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,8 +17,8 @@ public class AuthMiddleware
|
|||||||
{
|
{
|
||||||
var path = ctx.Request.Path.Value ?? "";
|
var path = ctx.Request.Path.Value ?? "";
|
||||||
|
|
||||||
// Static files and login endpoint don't require auth
|
// Static files, login and telegram webhook don't require auth
|
||||||
if (!path.StartsWith("/api/") || path == "/api/auth/login")
|
if (!path.StartsWith("/api/") || path == "/api/auth/login" || path == "/api/telegram/webhook")
|
||||||
{
|
{
|
||||||
await _next(ctx);
|
await _next(ctx);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace Gott.Models;
|
||||||
|
|
||||||
|
public class TelegramLinkRecord
|
||||||
|
{
|
||||||
|
public long Id { get; set; }
|
||||||
|
public long TelegramChatId { get; set; }
|
||||||
|
public string UserId { get; set; }
|
||||||
|
public string SessionId { get; set; }
|
||||||
|
public string AgentId { get; set; }
|
||||||
|
public string LinkCode { get; set; }
|
||||||
|
public string ExpiresAt { get; set; }
|
||||||
|
public string CreatedAt { get; set; }
|
||||||
|
public string UpdatedAt { get; set; }
|
||||||
|
}
|
||||||
+18
-2
@@ -1,9 +1,11 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Net.Http;
|
||||||
using Gott.Endpoints;
|
using Gott.Endpoints;
|
||||||
using Gott.Middleware;
|
using Gott.Middleware;
|
||||||
using Gott.Services;
|
using Gott.Services;
|
||||||
using Microsoft.AspNetCore.Builder;
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
@@ -20,8 +22,21 @@ builder.Services.AddSingleton(dbService);
|
|||||||
builder.Services.AddSingleton(new ToolService());
|
builder.Services.AddSingleton(new ToolService());
|
||||||
builder.Services.AddSingleton(new ImageService());
|
builder.Services.AddSingleton(new ImageService());
|
||||||
builder.Services.AddSingleton(new StreamingTracker());
|
builder.Services.AddSingleton(new StreamingTracker());
|
||||||
builder.Services.AddSingleton(new MemoryService(dataDir));
|
var memoryService = new MemoryService(dataDir);
|
||||||
builder.Services.AddSingleton(new SkillService(dataDir));
|
var skillService = new SkillService(dataDir);
|
||||||
|
builder.Services.AddSingleton(memoryService);
|
||||||
|
builder.Services.AddSingleton(skillService);
|
||||||
|
|
||||||
|
// Telegram Bot (optional – nur wenn Token konfiguriert)
|
||||||
|
var telegramToken = builder.Configuration["TelegramBotToken"] ?? "";
|
||||||
|
var telegramSecret = builder.Configuration["TelegramWebhookSecret"] ?? Guid.NewGuid().ToString("N");
|
||||||
|
var anthropicToken = builder.Configuration["AnthropicToken"] ?? "";
|
||||||
|
builder.Services.AddSingleton<TelegramService>(sp =>
|
||||||
|
new TelegramService(
|
||||||
|
telegramToken, telegramSecret, anthropicToken,
|
||||||
|
sp.GetRequiredService<IHttpClientFactory>(),
|
||||||
|
dbService, new ToolService(), memoryService, skillService,
|
||||||
|
builder.Configuration.GetValue<int>("MaxIterations", 100)));
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
app.UseDefaultFiles();
|
app.UseDefaultFiles();
|
||||||
@@ -38,5 +53,6 @@ app.MapInfoEndpoint();
|
|||||||
app.MapBackupEndpoint();
|
app.MapBackupEndpoint();
|
||||||
app.MapSkillEndpoints();
|
app.MapSkillEndpoints();
|
||||||
app.MapProfileEndpoints();
|
app.MapProfileEndpoints();
|
||||||
|
app.MapTelegramEndpoints();
|
||||||
|
|
||||||
app.Run("http://0.0.0.0:19000");
|
app.Run("http://0.0.0.0:19000");
|
||||||
@@ -183,6 +183,22 @@ public class DatabaseService
|
|||||||
updated_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)
|
private void TryAddColumn(SqliteConnection conn, string table, string column, string type)
|
||||||
@@ -742,4 +758,87 @@ public class DatabaseService
|
|||||||
"UPDATE users SET display_name = @displayName, persona = @persona, updated_at = @now WHERE user_id = @userId",
|
"UPDATE users SET display_name = @displayName, persona = @persona, updated_at = @now WHERE user_id = @userId",
|
||||||
new { userId, displayName, persona, now = DateTime.UtcNow.ToString("o") });
|
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 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,840 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Gott.Models;
|
||||||
|
|
||||||
|
namespace Gott.Services;
|
||||||
|
|
||||||
|
public class TelegramService
|
||||||
|
{
|
||||||
|
private readonly string _botToken;
|
||||||
|
private readonly string _webhookSecret;
|
||||||
|
private readonly string _anthropicToken;
|
||||||
|
private readonly IHttpClientFactory _httpFactory;
|
||||||
|
private readonly DatabaseService _db;
|
||||||
|
private readonly ToolService _toolService;
|
||||||
|
private readonly MemoryService _memoryService;
|
||||||
|
private readonly SkillService _skillService;
|
||||||
|
private readonly int _maxIterations;
|
||||||
|
|
||||||
|
public TelegramService(
|
||||||
|
string botToken,
|
||||||
|
string webhookSecret,
|
||||||
|
string anthropicToken,
|
||||||
|
IHttpClientFactory httpFactory,
|
||||||
|
DatabaseService db,
|
||||||
|
ToolService toolService,
|
||||||
|
MemoryService memoryService,
|
||||||
|
SkillService skillService,
|
||||||
|
int maxIterations = 100)
|
||||||
|
{
|
||||||
|
_botToken = botToken;
|
||||||
|
_webhookSecret = webhookSecret;
|
||||||
|
_anthropicToken = anthropicToken;
|
||||||
|
_httpFactory = httpFactory;
|
||||||
|
_db = db;
|
||||||
|
_toolService = toolService;
|
||||||
|
_memoryService = memoryService;
|
||||||
|
_skillService = skillService;
|
||||||
|
_maxIterations = maxIterations;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string WebhookSecret => _webhookSecret;
|
||||||
|
public bool IsConfigured => !string.IsNullOrEmpty(_botToken);
|
||||||
|
|
||||||
|
// ── Webhook-Update verarbeiten ──
|
||||||
|
|
||||||
|
public async Task HandleUpdate(JsonElement update)
|
||||||
|
{
|
||||||
|
if (!update.TryGetProperty("message", out var message)) return;
|
||||||
|
if (!message.TryGetProperty("chat", out var chat)) return;
|
||||||
|
if (!message.TryGetProperty("text", out var textEl)) return;
|
||||||
|
|
||||||
|
var chatId = chat.GetProperty("id").GetInt64();
|
||||||
|
var text = textEl.GetString()?.Trim() ?? "";
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(text)) return;
|
||||||
|
|
||||||
|
// Commands
|
||||||
|
if (text.StartsWith("/"))
|
||||||
|
{
|
||||||
|
await HandleCommand(chatId, text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normale Nachricht → an Claude weiterleiten
|
||||||
|
await HandleMessage(chatId, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Commands ──
|
||||||
|
|
||||||
|
private async Task HandleCommand(long chatId, string text)
|
||||||
|
{
|
||||||
|
var parts = text.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
var cmd = parts[0].ToLowerInvariant().Split('@')[0]; // /command@botname → /command
|
||||||
|
var arg = parts.Length > 1 ? parts[1].Trim() : "";
|
||||||
|
|
||||||
|
switch (cmd)
|
||||||
|
{
|
||||||
|
case "/start":
|
||||||
|
await SendMessage(chatId,
|
||||||
|
"👋 *Willkommen bei Gott\\!*\n\n" +
|
||||||
|
"Verknüpfe mich mit einer Session:\n" +
|
||||||
|
"1\\. Öffne im Web\\-UI eine Session\n" +
|
||||||
|
"2\\. Klicke auf 🔗 *Telegram*\n" +
|
||||||
|
"3\\. Sende mir den Code: `/link CODE`\n\n" +
|
||||||
|
"Befehle:\n" +
|
||||||
|
"/link CODE – Session verknüpfen\n" +
|
||||||
|
"/session – Session wechseln\n" +
|
||||||
|
"/new – Neue Session erstellen\n" +
|
||||||
|
"/clear – History archivieren\n" +
|
||||||
|
"/status – Aktuelle Verknüpfung\n" +
|
||||||
|
"/unlink – Verknüpfung aufheben",
|
||||||
|
parseMode: "MarkdownV2");
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "/link":
|
||||||
|
await HandleLink(chatId, arg);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "/session":
|
||||||
|
await HandleSessionSwitch(chatId, arg);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "/new":
|
||||||
|
await HandleNewSession(chatId, arg);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "/clear":
|
||||||
|
await HandleClear(chatId);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "/status":
|
||||||
|
await HandleStatus(chatId);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "/unlink":
|
||||||
|
await HandleUnlink(chatId);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
await SendMessage(chatId, "❓ Unbekannter Befehl. Sende /start für Hilfe.");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleLink(long chatId, string code)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(code))
|
||||||
|
{
|
||||||
|
await SendMessage(chatId, "Bitte sende: `/link CODE`\n\nDen Code findest du im Web\\-UI unter 🔗 Telegram\\.", parseMode: "MarkdownV2");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var link = await _db.RedeemTelegramLinkCode(code.ToUpperInvariant(), chatId);
|
||||||
|
if (link == null)
|
||||||
|
{
|
||||||
|
await SendMessage(chatId, "❌ Code ungültig oder abgelaufen. Bitte generiere einen neuen im Web-UI.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var session = await _db.GetSession(link.SessionId);
|
||||||
|
var sessionName = session?.Name ?? link.SessionId;
|
||||||
|
await SendMessage(chatId, $"✅ Verknüpft mit Session: *{EscapeMd(sessionName)}*\n\nDu kannst jetzt einfach drauflos schreiben\\!", parseMode: "MarkdownV2");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleSessionSwitch(long chatId, string arg)
|
||||||
|
{
|
||||||
|
var link = await _db.GetTelegramLink(chatId);
|
||||||
|
if (link == null)
|
||||||
|
{
|
||||||
|
await SendMessage(chatId, "❌ Nicht verknüpft. Sende /link CODE um zu starten.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wenn eine Session-Nummer angegeben wurde
|
||||||
|
if (!string.IsNullOrEmpty(arg))
|
||||||
|
{
|
||||||
|
var sessions = (await _db.GetUserSessions(link.UserId)).ToList();
|
||||||
|
if (int.TryParse(arg, out var idx) && idx >= 1 && idx <= sessions.Count)
|
||||||
|
{
|
||||||
|
var target = sessions[idx - 1];
|
||||||
|
var agent = !string.IsNullOrEmpty(target.AgentId) ? await _db.GetAgent(target.AgentId) : null;
|
||||||
|
await _db.UpdateTelegramSession(chatId, target.SessionId, target.AgentId);
|
||||||
|
await SendMessage(chatId, $"✅ Gewechselt zu: *{EscapeMd(target.Name ?? target.SessionId)}*", parseMode: "MarkdownV2");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vielleicht ein Name?
|
||||||
|
var match = sessions.FirstOrDefault(s =>
|
||||||
|
(s.Name ?? "").Contains(arg, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (match != null)
|
||||||
|
{
|
||||||
|
await _db.UpdateTelegramSession(chatId, match.SessionId, match.AgentId);
|
||||||
|
await SendMessage(chatId, $"✅ Gewechselt zu: *{EscapeMd(match.Name ?? match.SessionId)}*", parseMode: "MarkdownV2");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await SendMessage(chatId, "❌ Session nicht gefunden.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ohne Argument: Liste anzeigen
|
||||||
|
var allSessions = (await _db.GetUserSessions(link.UserId)).ToList();
|
||||||
|
if (allSessions.Count == 0)
|
||||||
|
{
|
||||||
|
await SendMessage(chatId, "Keine Sessions vorhanden. Erstelle eine mit /new");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb = new StringBuilder("📋 *Deine Sessions:*\n\n");
|
||||||
|
for (int i = 0; i < allSessions.Count && i < 20; i++)
|
||||||
|
{
|
||||||
|
var s = allSessions[i];
|
||||||
|
var marker = s.SessionId == link.SessionId ? " ◀️" : "";
|
||||||
|
sb.AppendLine($"`{i + 1}` {EscapeMd(s.Name ?? s.SessionId)}{marker}");
|
||||||
|
}
|
||||||
|
sb.AppendLine($"\nWechseln: `/session NUMMER`");
|
||||||
|
await SendMessage(chatId, sb.ToString(), parseMode: "MarkdownV2");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleNewSession(long chatId, string name)
|
||||||
|
{
|
||||||
|
var link = await _db.GetTelegramLink(chatId);
|
||||||
|
if (link == null)
|
||||||
|
{
|
||||||
|
await SendMessage(chatId, "❌ Nicht verknüpft. Sende /link CODE um zu starten.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(name))
|
||||||
|
name = $"Telegram {DateTime.UtcNow:dd.MM.yyyy HH:mm}";
|
||||||
|
|
||||||
|
var session = await _db.CreateSession(link.UserId, name: name, agentId: link.AgentId);
|
||||||
|
await _db.UpdateTelegramSession(chatId, session.SessionId, link.AgentId);
|
||||||
|
await SendMessage(chatId, $"✅ Neue Session erstellt: *{EscapeMd(name)}*", parseMode: "MarkdownV2");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleClear(long chatId)
|
||||||
|
{
|
||||||
|
var link = await _db.GetTelegramLink(chatId);
|
||||||
|
if (link == null)
|
||||||
|
{
|
||||||
|
await SendMessage(chatId, "❌ Nicht verknüpft.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var archive = await _db.CreateArchive(link.SessionId);
|
||||||
|
if (archive == null)
|
||||||
|
{
|
||||||
|
await SendMessage(chatId, "History ist bereits leer.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await SendMessage(chatId, $"✅ History archiviert ({archive.MessageCount} Nachrichten).");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleStatus(long chatId)
|
||||||
|
{
|
||||||
|
var link = await _db.GetTelegramLink(chatId);
|
||||||
|
if (link == null)
|
||||||
|
{
|
||||||
|
await SendMessage(chatId, "❌ Nicht verknüpft. Sende /link CODE um zu starten.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var session = await _db.GetSession(link.SessionId);
|
||||||
|
var agent = !string.IsNullOrEmpty(link.AgentId) ? await _db.GetAgent(link.AgentId) : null;
|
||||||
|
var sb = new StringBuilder("📊 *Status:*\n\n");
|
||||||
|
sb.AppendLine($"Session: *{EscapeMd(session?.Name ?? link.SessionId)}*");
|
||||||
|
if (agent != null)
|
||||||
|
sb.AppendLine($"Agent: *{EscapeMd(agent.Name)}* \\({EscapeMd(agent.Model)}\\)");
|
||||||
|
await SendMessage(chatId, sb.ToString(), parseMode: "MarkdownV2");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleUnlink(long chatId)
|
||||||
|
{
|
||||||
|
await _db.DeleteTelegramLink(chatId);
|
||||||
|
await SendMessage(chatId, "✅ Verknüpfung aufgehoben. Sende /link CODE um erneut zu verknüpfen.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Nachricht an Claude weiterleiten (Non-Streaming) ──
|
||||||
|
|
||||||
|
private async Task HandleMessage(long chatId, string userText)
|
||||||
|
{
|
||||||
|
var link = await _db.GetTelegramLink(chatId);
|
||||||
|
if (link == null)
|
||||||
|
{
|
||||||
|
await SendMessage(chatId, "❌ Nicht verknüpft. Sende /start für Hilfe.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var session = await _db.GetSession(link.SessionId);
|
||||||
|
if (session == null)
|
||||||
|
{
|
||||||
|
await SendMessage(chatId, "❌ Session nicht mehr verfügbar. Nutze /session zum Wechseln.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var agent = !string.IsNullOrEmpty(link.AgentId) ? await _db.GetAgent(link.AgentId) : null;
|
||||||
|
if (agent == null && !string.IsNullOrEmpty(session.AgentId))
|
||||||
|
agent = await _db.GetAgent(session.AgentId);
|
||||||
|
|
||||||
|
if (agent == null)
|
||||||
|
{
|
||||||
|
await SendMessage(chatId, "❌ Kein Agent konfiguriert. Bitte weise der Session im Web-UI einen Agent zu.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Typing" anzeigen
|
||||||
|
await SendChatAction(chatId, "typing");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var reply = await ChatNonStreaming(session, agent, userText, chatId);
|
||||||
|
await SendLongMessage(chatId, reply);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await SendMessage(chatId, $"❌ Fehler: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Non-Streaming Claude Chat ──
|
||||||
|
|
||||||
|
private async Task<string> ChatNonStreaming(SessionRecord session, AgentRecord agent, string userText, long chatId)
|
||||||
|
{
|
||||||
|
var modelId = agent.Model switch
|
||||||
|
{
|
||||||
|
"opus" => "claude-opus-4-6",
|
||||||
|
"sonnet" => "claude-sonnet-4-6",
|
||||||
|
"haiku" => "claude-haiku-4-5",
|
||||||
|
_ => "claude-sonnet-4-6"
|
||||||
|
};
|
||||||
|
|
||||||
|
// History aus DB laden (aktive Chains)
|
||||||
|
var history = await BuildHistory(session.SessionId);
|
||||||
|
|
||||||
|
// User-Nachricht anhängen
|
||||||
|
history.Add(new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["role"] = "user",
|
||||||
|
["content"] = userText
|
||||||
|
});
|
||||||
|
|
||||||
|
// System-Prompt
|
||||||
|
var markedMessages = await _db.GetMarkedMessages(session.SessionId);
|
||||||
|
var agentMemoryDir = _memoryService.GetAgentMemoryDir(agent.AgentId);
|
||||||
|
var agentSkills = await _db.GetAgentSkills(agent.AgentId);
|
||||||
|
var user = await _db.GetUser(session.UserId);
|
||||||
|
var systemPromptText = SystemPromptBuilder.Build(
|
||||||
|
agent, session, markedMessages, _memoryService, agentMemoryDir,
|
||||||
|
agentSkills, _skillService, userPersona: user?.GetEffectivePersona());
|
||||||
|
|
||||||
|
var systemPrompt = new List<object>();
|
||||||
|
if (!string.IsNullOrWhiteSpace(systemPromptText))
|
||||||
|
systemPrompt.Add(new { type = "text", text = systemPromptText });
|
||||||
|
|
||||||
|
// Chain erstellen
|
||||||
|
var chainId = Guid.NewGuid().ToString("N");
|
||||||
|
var workspace = session.Workspace;
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(workspace) && agentSkills.Any())
|
||||||
|
workspace = "/tmp";
|
||||||
|
|
||||||
|
var toolChoice = agent.ToolChoice ?? "any";
|
||||||
|
var hasWorkspace = !string.IsNullOrEmpty(workspace);
|
||||||
|
var includeMemory = agent.UseMemory && !string.IsNullOrEmpty(agent.AgentId);
|
||||||
|
|
||||||
|
var toolDefs = (hasWorkspace || includeMemory)
|
||||||
|
? _toolService.GetToolDefinitions(includeFileTools: hasWorkspace, includeMemory: includeMemory)
|
||||||
|
: Array.Empty<object>();
|
||||||
|
var toolNames = toolDefs.Length > 0
|
||||||
|
? JsonSerializer.Serialize(toolDefs.Select(td =>
|
||||||
|
{
|
||||||
|
var json = JsonSerializer.Serialize(td);
|
||||||
|
var el = JsonSerializer.Deserialize<JsonElement>(json);
|
||||||
|
return el.GetProperty("name").GetString();
|
||||||
|
}).ToArray())
|
||||||
|
: null;
|
||||||
|
|
||||||
|
await _db.CreateChain(new ChainRecord
|
||||||
|
{
|
||||||
|
ChainId = chainId,
|
||||||
|
SessionId = session.SessionId,
|
||||||
|
AgentId = agent.AgentId,
|
||||||
|
Model = modelId,
|
||||||
|
SystemPrompt = systemPromptText,
|
||||||
|
ToolChoice = (hasWorkspace || includeMemory) ? toolChoice : null,
|
||||||
|
ToolNames = toolNames
|
||||||
|
});
|
||||||
|
|
||||||
|
// User-Nachricht speichern
|
||||||
|
await _db.SaveMessage(new MessageRecord
|
||||||
|
{
|
||||||
|
ChainId = chainId,
|
||||||
|
Iteration = 0,
|
||||||
|
Role = "user",
|
||||||
|
Type = "text",
|
||||||
|
Content = userText
|
||||||
|
});
|
||||||
|
|
||||||
|
await _db.UpdateSessionTimestamp(session.SessionId);
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(workspace))
|
||||||
|
Directory.CreateDirectory(workspace);
|
||||||
|
|
||||||
|
// Iterativer Claude-Loop (Non-Streaming)
|
||||||
|
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||||
|
var usage = new TokenUsage();
|
||||||
|
int iterations = 0;
|
||||||
|
var finalText = new StringBuilder();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (iterations < _maxIterations)
|
||||||
|
{
|
||||||
|
iterations++;
|
||||||
|
|
||||||
|
var payload = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["model"] = modelId,
|
||||||
|
["max_tokens"] = 16384,
|
||||||
|
["messages"] = history,
|
||||||
|
["stream"] = false
|
||||||
|
};
|
||||||
|
|
||||||
|
if (systemPrompt.Count > 0)
|
||||||
|
payload["system"] = systemPrompt;
|
||||||
|
|
||||||
|
var hasTools = hasWorkspace || includeMemory;
|
||||||
|
var hasWebSearch = !string.IsNullOrEmpty(agent.WebSearchVersion);
|
||||||
|
|
||||||
|
if (hasTools || hasWebSearch)
|
||||||
|
{
|
||||||
|
var tools = new List<object>();
|
||||||
|
if (hasTools)
|
||||||
|
tools.AddRange(_toolService.GetToolDefinitions(includeFileTools: hasWorkspace, includeMemory: includeMemory));
|
||||||
|
if (hasWebSearch)
|
||||||
|
tools.Add(new { type = agent.WebSearchVersion, name = "web_search", max_uses = 5 });
|
||||||
|
payload["tools"] = tools;
|
||||||
|
var effectiveChoice = (toolChoice == "any" && iterations == 1) ? "any" : "auto";
|
||||||
|
payload["tool_choice"] = new { type = effectiveChoice };
|
||||||
|
}
|
||||||
|
|
||||||
|
var client = CreateAnthropicClient();
|
||||||
|
var json = JsonSerializer.Serialize(payload);
|
||||||
|
var httpContent = new StringContent(json, Encoding.UTF8, "application/json");
|
||||||
|
|
||||||
|
var httpResp = await client.SendAsync(
|
||||||
|
new HttpRequestMessage(HttpMethod.Post, "https://api.anthropic.com/v1/messages") { Content = httpContent });
|
||||||
|
|
||||||
|
var respBody = await httpResp.Content.ReadAsStringAsync();
|
||||||
|
|
||||||
|
if (!httpResp.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
await SaveError(chainId, iterations, $"API {(int)httpResp.StatusCode}: {respBody}");
|
||||||
|
await FinalizeChain(chainId, usage, iterations, "error", sw);
|
||||||
|
throw new Exception($"Claude API Fehler: {(int)httpResp.StatusCode}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp = JsonSerializer.Deserialize<JsonElement>(respBody);
|
||||||
|
|
||||||
|
// Usage tracken
|
||||||
|
if (resp.TryGetProperty("usage", out var u))
|
||||||
|
{
|
||||||
|
if (u.TryGetProperty("input_tokens", out var it)) usage.InputTokens += it.GetInt64();
|
||||||
|
if (u.TryGetProperty("output_tokens", out var ot)) usage.OutputTokens += ot.GetInt64();
|
||||||
|
if (u.TryGetProperty("cache_creation_input_tokens", out var cc)) usage.CacheCreationTokens += cc.GetInt64();
|
||||||
|
if (u.TryGetProperty("cache_read_input_tokens", out var cr)) usage.CacheReadTokens += cr.GetInt64();
|
||||||
|
}
|
||||||
|
|
||||||
|
var stopReason = resp.TryGetProperty("stop_reason", out var sr) ? sr.GetString() ?? "" : "";
|
||||||
|
var content = resp.GetProperty("content");
|
||||||
|
|
||||||
|
// Content verarbeiten
|
||||||
|
var assistantContent = new List<object>();
|
||||||
|
var toolResults = new List<object>();
|
||||||
|
var executedTools = new List<ToolResult>();
|
||||||
|
|
||||||
|
foreach (var block in content.EnumerateArray())
|
||||||
|
{
|
||||||
|
var blockType = block.GetProperty("type").GetString() ?? "";
|
||||||
|
|
||||||
|
if (blockType == "text")
|
||||||
|
{
|
||||||
|
var text = block.GetProperty("text").GetString() ?? "";
|
||||||
|
assistantContent.Add(new { type = "text", text });
|
||||||
|
finalText.Append(text);
|
||||||
|
|
||||||
|
await _db.SaveMessage(new MessageRecord
|
||||||
|
{
|
||||||
|
ChainId = chainId, Iteration = iterations,
|
||||||
|
Role = "assistant", Type = "text",
|
||||||
|
Content = text
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else if (blockType == "tool_use")
|
||||||
|
{
|
||||||
|
var toolId = block.GetProperty("id").GetString() ?? "";
|
||||||
|
var toolName = block.GetProperty("name").GetString() ?? "";
|
||||||
|
var toolInput = block.GetProperty("input");
|
||||||
|
|
||||||
|
assistantContent.Add(new { type = "tool_use", id = toolId, name = toolName, input = toolInput });
|
||||||
|
|
||||||
|
await _db.SaveMessage(new MessageRecord
|
||||||
|
{
|
||||||
|
ChainId = chainId, Iteration = iterations,
|
||||||
|
Role = "assistant", Type = "tool_use",
|
||||||
|
ToolName = toolName, ToolCallId = toolId,
|
||||||
|
Content = toolInput.ToString()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tool ausführen
|
||||||
|
string toolResult;
|
||||||
|
bool isError = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
toolResult = await _toolService.ExecuteTool(toolName, toolInput, session.Workspace, _memoryService, agent.AgentId);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
toolResult = $"Fehler: {ex.Message}";
|
||||||
|
isError = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
executedTools.Add(new ToolResult(toolId, toolResult, isError));
|
||||||
|
|
||||||
|
await _db.SaveMessage(new MessageRecord
|
||||||
|
{
|
||||||
|
ChainId = chainId, Iteration = iterations,
|
||||||
|
Role = "user", Type = "tool_result",
|
||||||
|
ToolCallId = toolId, Content = toolResult, IsError = isError
|
||||||
|
});
|
||||||
|
|
||||||
|
const int MaxToolResultChars = 20_000;
|
||||||
|
var truncResult = toolResult.Length > MaxToolResultChars
|
||||||
|
? toolResult[..MaxToolResultChars] + $"\n\n[... gekürzt, {toolResult.Length - MaxToolResultChars} weitere Zeichen ...]"
|
||||||
|
: toolResult;
|
||||||
|
|
||||||
|
toolResults.Add(new
|
||||||
|
{
|
||||||
|
type = "tool_result",
|
||||||
|
tool_use_id = toolId,
|
||||||
|
content = truncResult,
|
||||||
|
is_error = isError
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assistant-Antwort in History aufnehmen
|
||||||
|
history.Add(new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["role"] = "assistant",
|
||||||
|
["content"] = assistantContent
|
||||||
|
});
|
||||||
|
|
||||||
|
if (stopReason != "tool_use")
|
||||||
|
{
|
||||||
|
await FinalizeChain(chainId, usage, iterations, stopReason, sw);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tool-Results zurücksenden
|
||||||
|
history.Add(new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["role"] = "user",
|
||||||
|
["content"] = toolResults
|
||||||
|
});
|
||||||
|
|
||||||
|
// Typing-Indikator aufrechterhalten
|
||||||
|
await SendChatAction(chatId, "typing");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (iterations >= _maxIterations)
|
||||||
|
{
|
||||||
|
await FinalizeChain(chainId, usage, iterations, "max_iterations", sw);
|
||||||
|
finalText.Append("\n\n⚠️ Max Iterationen erreicht.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception) when (finalText.Length > 0)
|
||||||
|
{
|
||||||
|
// Partial result
|
||||||
|
}
|
||||||
|
|
||||||
|
return finalText.Length > 0 ? finalText.ToString() : "🤷 Keine Antwort erhalten.";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── History aus DB aufbauen ──
|
||||||
|
|
||||||
|
private async Task<List<Dictionary<string, object>>> BuildHistory(string sessionId)
|
||||||
|
{
|
||||||
|
var chains = await _db.GetActiveChains(sessionId);
|
||||||
|
var messages = new List<Dictionary<string, object>>();
|
||||||
|
|
||||||
|
foreach (var chain in chains.Reverse())
|
||||||
|
{
|
||||||
|
var chainMsgs = (await _db.GetChainMessages(chain.ChainId)).ToList();
|
||||||
|
|
||||||
|
foreach (var msg in chainMsgs)
|
||||||
|
{
|
||||||
|
if (msg.Role == "user" && msg.Type == "text")
|
||||||
|
{
|
||||||
|
messages.Add(new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["role"] = "user",
|
||||||
|
["content"] = msg.Content ?? ""
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else if (msg.Role == "assistant" && msg.Type == "text")
|
||||||
|
{
|
||||||
|
// Aufeinanderfolgende Assistant-Text-Blöcke zusammenfassen
|
||||||
|
if (messages.Count > 0 && messages[^1]["role"] as string == "assistant")
|
||||||
|
{
|
||||||
|
var existing = messages[^1]["content"];
|
||||||
|
if (existing is List<object> blocks)
|
||||||
|
{
|
||||||
|
blocks.Add(new { type = "text", text = msg.Content ?? "" });
|
||||||
|
}
|
||||||
|
else if (existing is string s)
|
||||||
|
{
|
||||||
|
messages[^1]["content"] = new List<object>
|
||||||
|
{
|
||||||
|
new { type = "text", text = s },
|
||||||
|
new { type = "text", text = msg.Content ?? "" }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
messages.Add(new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["role"] = "assistant",
|
||||||
|
["content"] = msg.Content ?? ""
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (msg.Role == "assistant" && msg.Type == "tool_use")
|
||||||
|
{
|
||||||
|
JsonElement inputEl;
|
||||||
|
try { inputEl = JsonSerializer.Deserialize<JsonElement>(msg.Content ?? "{}"); }
|
||||||
|
catch { inputEl = JsonSerializer.Deserialize<JsonElement>("{}"); }
|
||||||
|
|
||||||
|
var toolBlock = new { type = "tool_use", id = msg.ToolCallId, name = msg.ToolName, input = inputEl };
|
||||||
|
|
||||||
|
if (messages.Count > 0 && messages[^1]["role"] as string == "assistant")
|
||||||
|
{
|
||||||
|
var existing = messages[^1]["content"];
|
||||||
|
if (existing is List<object> blocks)
|
||||||
|
{
|
||||||
|
blocks.Add(toolBlock);
|
||||||
|
}
|
||||||
|
else if (existing is string s)
|
||||||
|
{
|
||||||
|
messages[^1]["content"] = new List<object>
|
||||||
|
{
|
||||||
|
new { type = "text", text = s },
|
||||||
|
toolBlock
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
messages.Add(new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["role"] = "assistant",
|
||||||
|
["content"] = new List<object> { toolBlock }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (msg.Role == "user" && msg.Type == "tool_result")
|
||||||
|
{
|
||||||
|
var toolResultBlock = new
|
||||||
|
{
|
||||||
|
type = "tool_result",
|
||||||
|
tool_use_id = msg.ToolCallId,
|
||||||
|
content = msg.Content ?? "",
|
||||||
|
is_error = msg.IsError
|
||||||
|
};
|
||||||
|
|
||||||
|
if (messages.Count > 0 && messages[^1]["role"] as string == "user" &&
|
||||||
|
messages[^1]["content"] is List<object> existingResults)
|
||||||
|
{
|
||||||
|
existingResults.Add(toolResultBlock);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
messages.Add(new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["role"] = "user",
|
||||||
|
["content"] = new List<object> { toolResultBlock }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Telegram API ──
|
||||||
|
|
||||||
|
public async Task SendMessage(long chatId, string text, string parseMode = null)
|
||||||
|
{
|
||||||
|
var payload = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["chat_id"] = chatId,
|
||||||
|
["text"] = text
|
||||||
|
};
|
||||||
|
if (!string.IsNullOrEmpty(parseMode))
|
||||||
|
payload["parse_mode"] = parseMode;
|
||||||
|
|
||||||
|
await CallTelegramApi("sendMessage", payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SendLongMessage(long chatId, string text)
|
||||||
|
{
|
||||||
|
// Telegram-Limit: 4096 Zeichen
|
||||||
|
const int maxLen = 4096;
|
||||||
|
|
||||||
|
if (text.Length <= maxLen)
|
||||||
|
{
|
||||||
|
await SendMessage(chatId, text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// An Zeilengrenzen splitten
|
||||||
|
var remaining = text;
|
||||||
|
while (remaining.Length > 0)
|
||||||
|
{
|
||||||
|
string chunk;
|
||||||
|
if (remaining.Length <= maxLen)
|
||||||
|
{
|
||||||
|
chunk = remaining;
|
||||||
|
remaining = "";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var splitAt = remaining.LastIndexOf('\n', maxLen - 1);
|
||||||
|
if (splitAt < maxLen / 2) splitAt = maxLen;
|
||||||
|
chunk = remaining[..splitAt];
|
||||||
|
remaining = remaining[splitAt..].TrimStart('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
await SendMessage(chatId, chunk);
|
||||||
|
if (remaining.Length > 0)
|
||||||
|
await Task.Delay(100); // Rate-Limiting
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SendChatAction(long chatId, string action)
|
||||||
|
{
|
||||||
|
await CallTelegramApi("sendChatAction", new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["chat_id"] = chatId,
|
||||||
|
["action"] = action ?? "typing"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CallTelegramApi(string method, object payload)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var client = _httpFactory.CreateClient();
|
||||||
|
var json = JsonSerializer.Serialize(payload);
|
||||||
|
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||||
|
await client.PostAsync($"https://api.telegram.org/bot{_botToken}/{method}", content);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Telegram API Fehler nicht propagieren
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ──
|
||||||
|
|
||||||
|
private HttpClient CreateAnthropicClient()
|
||||||
|
{
|
||||||
|
var client = _httpFactory.CreateClient();
|
||||||
|
client.DefaultRequestHeaders.Add("x-api-key", _anthropicToken);
|
||||||
|
client.DefaultRequestHeaders.Add("anthropic-version", "2023-06-01");
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveError(string chainId, int iteration, string error)
|
||||||
|
{
|
||||||
|
await _db.SaveMessage(new MessageRecord
|
||||||
|
{
|
||||||
|
ChainId = chainId, Iteration = iteration,
|
||||||
|
Role = "system", Type = "error",
|
||||||
|
Content = error, IsError = true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task FinalizeChain(string chainId, TokenUsage usage, int iterations, string stopReason, System.Diagnostics.Stopwatch sw)
|
||||||
|
{
|
||||||
|
await _db.UpdateChain(new ChainRecord
|
||||||
|
{
|
||||||
|
ChainId = chainId,
|
||||||
|
InputTokens = usage.InputTokens,
|
||||||
|
OutputTokens = usage.OutputTokens,
|
||||||
|
CacheCreationTokens = usage.CacheCreationTokens,
|
||||||
|
CacheReadTokens = usage.CacheReadTokens,
|
||||||
|
Iterations = iterations,
|
||||||
|
StopReason = stopReason,
|
||||||
|
DurationMs = sw.ElapsedMilliseconds
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string EscapeMd(string text)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(text)) return "";
|
||||||
|
// MarkdownV2 escape: _ * [ ] ( ) ~ ` > # + - = | { } . !
|
||||||
|
var chars = new[] { '_', '*', '[', ']', '(', ')', '~', '`', '>', '#', '+', '-', '=', '|', '{', '}', '.', '!' };
|
||||||
|
var sb = new StringBuilder(text.Length);
|
||||||
|
foreach (var c in text)
|
||||||
|
{
|
||||||
|
if (Array.IndexOf(chars, c) >= 0) sb.Append('\\');
|
||||||
|
sb.Append(c);
|
||||||
|
}
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Webhook registrieren ──
|
||||||
|
|
||||||
|
public async Task<string> RegisterWebhook(string baseUrl)
|
||||||
|
{
|
||||||
|
if (!IsConfigured) return "Telegram Bot nicht konfiguriert.";
|
||||||
|
|
||||||
|
var url = $"{baseUrl.TrimEnd('/')}/api/telegram/webhook";
|
||||||
|
var payload = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["url"] = url,
|
||||||
|
["secret_token"] = _webhookSecret,
|
||||||
|
["allowed_updates"] = new[] { "message" }
|
||||||
|
};
|
||||||
|
|
||||||
|
var client = _httpFactory.CreateClient();
|
||||||
|
var json = JsonSerializer.Serialize(payload);
|
||||||
|
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||||
|
var resp = await client.PostAsync($"https://api.telegram.org/bot{_botToken}/setWebhook", content);
|
||||||
|
return await resp.Content.ReadAsStringAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> GetWebhookInfo()
|
||||||
|
{
|
||||||
|
if (!IsConfigured) return "Telegram Bot nicht konfiguriert.";
|
||||||
|
|
||||||
|
var client = _httpFactory.CreateClient();
|
||||||
|
var resp = await client.GetAsync($"https://api.telegram.org/bot{_botToken}/getWebhookInfo");
|
||||||
|
return await resp.Content.ReadAsStringAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenUsage und ToolResult werden aus AnthropicService.cs verwendet
|
||||||
+4
-1
@@ -6,5 +6,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"AllowedHosts": "*",
|
"AllowedHosts": "*",
|
||||||
"MaxIterations": 100
|
"AnthropicToken": "",
|
||||||
|
"MaxIterations": 100,
|
||||||
|
"TelegramBotToken": "",
|
||||||
|
"TelegramWebhookSecret": ""
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user