Files
gott/Services/TelegramService.cs
T
MelekiandClaude Opus 5 7305a5a50f OpenAI: Server-Key-Setting, Filter-Fix fuer o-Reihe, ehrlicher Modell-Refresh
Der Modell-Refresh war fuer OpenAI faktisch ein No-Op: der Key wurde nur aus
der api_keys-Tabelle geholt, und ohne Key blieb die alte Liste im Speicher
stehen und wurde direkt wieder in models_cache.json geschrieben - waehrend der
Endpoint pauschal Erfolg meldete.

- ServerSettingsService: neues Setting OpenAiApiKey (OpenAI war der einzige
  Provider ohne Server-Key, analog Gemini/MiniMax/z.ai)
- OpenAiService: ResolveOpenAiKey() (Setting -> api_keys), HasServerOpenAiKey(),
  ResolveProviderConfig zieht den Setting-Key auch im Chat
- OpenAiService: IsOpenAiChatModel() erfasst die o-Reihe inkl. bare IDs
  (o1, o3, kuenftig o5) - StartsWith("o3-") hat die verschluckt
- RefreshModelsFromApis() liefert ProviderRefreshResult[] pro Provider;
  ModelsEndpoint gibt providers[] zurueck, agent-page.js meldet uebersprungene
  Provider als Fehler-Toast statt pauschalem Erfolg
- ChatEndpoint/TelegramService/CronjobService: OpenAI ohne Agent-Key faellt auf
  den Server-Key zurueck statt hart abzubrechen
- ARCHITECTURE.md nachgezogen

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 11:55:22 +02:00

385 lines
15 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.Linq;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using Gott.Models;
namespace Gott.Services;
public class TelegramService
{
private readonly IHttpClientFactory _httpFactory;
private readonly DatabaseService _db;
private readonly ChatService _chatService;
private readonly AnthropicService _anthropic;
private readonly OpenAiService _openAi;
private readonly ServerSettingsService _settings;
public TelegramService(
IHttpClientFactory httpFactory,
DatabaseService db,
ChatService chatService,
AnthropicService anthropic,
OpenAiService openAi,
ServerSettingsService settings)
{
_httpFactory = httpFactory;
_db = db;
_chatService = chatService;
_anthropic = anthropic;
_openAi = openAi;
_settings = settings;
}
// ── Webhook-Update verarbeiten ──
public async Task HandleUpdate(JsonElement update, TelegramBotRecord bot)
{
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;
// Session für diesen Chat finden (1 Session = max 1 Chat)
var session = await _db.FindSessionForTelegramChat(bot.BotId, chatId);
if (text.StartsWith("/start"))
{
await HandleStart(chatId, bot, session);
return;
}
if (session == null)
{
// Gibt es genau 1 unverknüpfte Session für diesen Bot? → Auto-Link
var unlinked = (await _db.FindUnlinkedSessionsForBot(bot.BotId)).ToList();
if (unlinked.Count == 1)
{
session = unlinked[0];
await _db.UpdateSessionTelegramChat(session.SessionId, chatId.ToString());
var sessionName = session.Name ?? session.SessionId;
await SendMessage(chatId, bot.BotToken,
$"✅ Automatisch verbunden mit Session: {sessionName}");
}
else
{
await SendMessage(chatId, bot.BotToken,
$"🔒 Keine Session mit diesem Chat verknüpft.\n\nDeine Chat-ID: `{chatId}`\nBitte weise diese Chat-ID im Web-UI einer Session zu.",
parseMode: "Markdown");
return;
}
}
if (text.StartsWith("/"))
{
await HandleCommand(chatId, text, bot.BotToken, session);
return;
}
await HandleMessage(chatId, text, bot.BotToken, bot.UserId, session);
}
private async Task HandleStart(long chatId, TelegramBotRecord bot, SessionRecord session)
{
if (session != null)
{
// Bereits verknüpft – Willkommensinfo
var sessionName = session.Name ?? session.SessionId;
await SendMessage(chatId, bot.BotToken,
$"✅ *Verbunden mit Session: {EscapeMd(sessionName)}*\n\n" +
"Du kannst jetzt einfach drauflos schreiben\\!\n\n" +
"Befehle:\n" +
"/clear – History archivieren\n" +
"/status – Aktuelle Session\\-Info",
parseMode: "MarkdownV2");
return;
}
// Nicht verknüpft – gibt es Sessions mit diesem Bot ohne Chat-ID?
var unlinked = (await _db.FindUnlinkedSessionsForBot(bot.BotId)).ToList();
if (unlinked.Count == 1)
{
// Genau 1 Session ohne Chat-ID → automatisch zuweisen
var target = unlinked[0];
await _db.UpdateSessionTelegramChat(target.SessionId, chatId.ToString());
var sessionName = target.Name ?? target.SessionId;
await SendMessage(chatId, bot.BotToken,
$"✅ *Automatisch verbunden mit Session: {EscapeMd(sessionName)}*\n\n" +
"Du kannst jetzt einfach drauflos schreiben\\!\n\n" +
"Befehle:\n" +
"/clear – History archivieren\n" +
"/status – Aktuelle Session\\-Info",
parseMode: "MarkdownV2");
return;
}
// Keine oder mehrere unverknüpfte Sessions → Chat-ID anzeigen
await SendMessage(chatId, bot.BotToken,
$"📋 Deine Chat-ID: `{chatId}`\n\n" +
"Bitte weise diese Chat-ID im Web-UI einer Session zu, " +
"die diesen Bot verwendet.",
parseMode: "Markdown");
}
// ── Commands ──
private async Task HandleCommand(long chatId, string text, string botToken, SessionRecord session)
{
var parts = text.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
var cmd = parts[0].ToLowerInvariant().Split('@')[0];
switch (cmd)
{
case "/clear":
var archive = await _db.CreateArchive(session.SessionId);
await SendMessage(chatId, botToken, archive == null
? "History ist bereits leer."
: $"✅ History archiviert ({archive.MessageCount} Nachrichten).");
break;
case "/status":
var agent = !string.IsNullOrEmpty(session.AgentId) ? await _db.GetAgent(session.AgentId) : null;
var sb = new StringBuilder("📊 *Status:*\n\n");
sb.AppendLine($"Session: *{EscapeMd(session.Name ?? session.SessionId)}*");
if (agent != null)
sb.AppendLine($"Agent: *{EscapeMd(agent.Name)}* \\({EscapeMd(agent.Model)}\\)");
await SendMessage(chatId, botToken, sb.ToString(), parseMode: "MarkdownV2");
break;
default:
await SendMessage(chatId, botToken, "❓ Unbekannter Befehl. Sende /start für Hilfe.");
break;
}
}
// ── Nachricht an Claude weiterleiten ──
private async Task HandleMessage(long chatId, string userText, string botToken, string botUserId, SessionRecord session)
{
// Kontext mit dem Session-Besitzer aufbauen, nicht mit dem Bot-Besitzer:
// bei einem geteilten Bot können sich beide unterscheiden, und PrepareContext
// lehnt fremde Sessions ab. Credentials/CLI-Berechtigung bleiben beim Bot-Besitzer (botUserId, s.u.).
var ctx = await _chatService.PrepareContext(session.SessionId, session.UserId, _settings.GetInt("MaxIterations", 100));
if (ctx == null)
{
await SendMessage(chatId, botToken, "❌ Kein Agent konfiguriert. Bitte weise der Session im Web-UI einen Agent zu.");
return;
}
// API-Key des Bot-Owners für diesen Agent auflösen
var userApiKey = await _db.GetAgentApiKeyRecord(botUserId, ctx.AgentId);
bool useCliFallback = false;
if (userApiKey == null)
{
// Kein User-Key – Verhalten exakt wie ChatEndpoint:
// - Anthropic (Provider leer) → Claude-CLI-Fallback (sofern berechtigt/verfügbar)
// - "openai" → abbrechen (kein Server-Key, kein CLI-Äquivalent)
// - nvidia/gemini/minimax/pollinations → durchlassen: Server-Key via ResolveProviderConfig
if (string.IsNullOrEmpty(ctx.Provider))
{
var botOwner = await _db.GetUser(botUserId);
if (botOwner != null && !botOwner.CanUseCli && !botOwner.IsAdmin)
{
await SendMessage(chatId, botToken, "❌ Kein API-Key für diesen Agent zugewiesen und der Bot-Besitzer hat keine CLI-Berechtigung.");
return;
}
if (!_anthropic.IsCliAvailable)
{
await SendMessage(chatId, botToken, "❌ Kein API-Key für diesen Agent zugewiesen und Claude CLI nicht verfügbar. Der Bot-Besitzer muss im Web-UI einen Key zuweisen oder `claude login` auf dem Server ausführen.");
return;
}
useCliFallback = true;
}
else if (ctx.Provider == "openai" && string.IsNullOrEmpty(_settings.Get("OpenAiApiKey")))
{
// Ohne Agent-Key greift der Server-Key aus dem Setting 'OpenAiApiKey'
// (analog Gemini/MiniMax). Fehlt auch der, ist OpenAI nicht nutzbar.
await SendMessage(chatId, botToken, "❌ Kein OpenAI-Key vorhanden: weder dem Agent zugewiesen noch als Server-Setting 'OpenAiApiKey' gesetzt.");
return;
}
// sonst: nvidia/gemini/minimax/pollinations → ctx.ApiToken bleibt leer (Server-Key-Auflösung).
}
else
{
ctx.ApiToken = userApiKey.ApiKey;
}
await SendChatAction(chatId, botToken, "typing");
try
{
// includeThinking=true für OpenAI-kompatible Provider → <think>...</think>-Prefix
// im Assistant-Text (essential für MiniMax/DeepSeek/QwQ Tool-Calling-Reliability).
var history = await _chatService.BuildHistoryFromDb(session.SessionId, promptBlocks: ctx.PromptBlocks, includeThinking: !string.IsNullOrEmpty(ctx.Provider));
history.Add(new Dictionary<string, object>
{
["role"] = "user",
["content"] = userText
});
await _db.SaveMessage(new MessageRecord
{
ChainId = ctx.ChainId,
Iteration = 0,
Role = "user",
Type = "text",
Content = userText
});
string reply;
if (!string.IsNullOrEmpty(ctx.Provider))
{
reply = await _openAi.ChatNonStreaming(ctx, history, onIteration: async (iteration) =>
{
if (iteration % 3 == 0)
await SendChatAction(chatId, botToken, "typing");
});
}
else if (useCliFallback)
{
reply = await _anthropic.ChatNonStreamingViaCli(ctx, history, onIteration: async (iteration) =>
{
await SendChatAction(chatId, botToken, "typing");
});
}
else
{
reply = await _anthropic.ChatNonStreaming(ctx, history, onIteration: async (iteration) =>
{
if (iteration % 3 == 0)
await SendChatAction(chatId, botToken, "typing");
});
}
if (string.IsNullOrEmpty(reply))
reply = "⚠️ Keine Antwort vom Modell erhalten.";
await SendLongMessage(chatId, botToken, reply);
}
catch (Exception ex)
{
await SendMessage(chatId, botToken, $"❌ Fehler: {ex.Message}");
}
}
// ── Notification senden (für Cronjobs) ──
public async Task SendNotification(string sessionId, string text)
{
var session = await _db.GetSession(sessionId);
if (session == null || string.IsNullOrEmpty(session.TelegramBotId) || string.IsNullOrEmpty(session.TelegramChatId))
return;
var bot = await _db.GetTelegramBot(session.TelegramBotId);
if (bot == null || string.IsNullOrEmpty(bot.BotToken)) return;
var chatId = long.Parse(session.TelegramChatId);
// SendLongMessage statt SendMessage: lange Ergebnisse (z.B. Cronjob-Antworten) werden in
// 4096-Zeichen-Chunks zerlegt. Sonst lehnt die Telegram-API zu lange Texte komplett ab
// (400 → der User bekäme gar nichts).
await SendLongMessage(chatId, bot.BotToken, text);
}
// ── Telegram Bot API ──
public async Task SendMessage(long chatId, string botToken, string text, string parseMode = null)
{
var client = _httpFactory.CreateClient();
var payload = new Dictionary<string, object>
{
["chat_id"] = chatId,
["text"] = text
};
if (!string.IsNullOrEmpty(parseMode))
payload["parse_mode"] = parseMode;
await client.PostAsync(
$"https://api.telegram.org/bot{botToken}/sendMessage",
new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"));
}
public async Task SendLongMessage(long chatId, string botToken, string text)
{
const int maxLen = 4096;
if (text.Length <= maxLen)
{
await SendMessage(chatId, botToken, text);
return;
}
for (var i = 0; i < text.Length; i += maxLen)
{
var chunk = text.Substring(i, Math.Min(maxLen, text.Length - i));
await SendMessage(chatId, botToken, chunk);
await Task.Delay(100);
}
}
public async Task SendChatAction(long chatId, string botToken, string action)
{
var client = _httpFactory.CreateClient();
await client.PostAsync(
$"https://api.telegram.org/bot{botToken}/sendChatAction",
new StringContent(JsonSerializer.Serialize(new { chat_id = chatId, action }), Encoding.UTF8, "application/json"));
}
// ── Webhook-Verwaltung (pro Bot) ──
public async Task<string> RegisterWebhook(string baseUrl, TelegramBotRecord bot)
{
var webhookUrl = $"{baseUrl.TrimEnd('/')}/api/telegram/webhook/{bot.BotId}";
var client = _httpFactory.CreateClient();
var payload = new Dictionary<string, object>
{
["url"] = webhookUrl,
["allowed_updates"] = new[] { "message" }
};
if (!string.IsNullOrEmpty(bot.WebhookSecret))
payload["secret_token"] = bot.WebhookSecret;
var resp = await client.PostAsync(
$"https://api.telegram.org/bot{bot.BotToken}/setWebhook",
new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"));
return await resp.Content.ReadAsStringAsync();
}
public async Task<string> GetWebhookInfo(TelegramBotRecord bot)
{
var client = _httpFactory.CreateClient();
var resp = await client.GetAsync($"https://api.telegram.org/bot{bot.BotToken}/getWebhookInfo");
return await resp.Content.ReadAsStringAsync();
}
public async Task<string> DeleteWebhook(TelegramBotRecord bot)
{
var client = _httpFactory.CreateClient();
var resp = await client.PostAsync(
$"https://api.telegram.org/bot{bot.BotToken}/deleteWebhook",
new StringContent("{}", Encoding.UTF8, "application/json"));
return await resp.Content.ReadAsStringAsync();
}
// ── Helper ──
private static string EscapeMd(string text)
{
if (string.IsNullOrEmpty(text)) return "";
foreach (var c in new[] { '_', '*', '[', ']', '(', ')', '~', '`', '>', '#', '+', '-', '=', '|', '{', '}', '.', '!' })
text = text.Replace(c.ToString(), "\\" + c);
return text;
}
}