The cronjob notification ran the notify-agent on the result summary and sent only its reaction to Telegram. When the agent merely acknowledged the summary (e.g. "👍"), the actual cronjob result never reached Telegram — the user got a header plus an emoji and no information. Send the real result as the guaranteed first Telegram message, then run the notify-agent best-effort and send its reaction as a separate second message. The agent run is wrapped in try/catch with a canRunNotifyAgent guard instead of the old return-aborts, so a missing key / OpenAI without key / unavailable CLI only skips the reaction — the result is already delivered. The notify-agent still persists its reaction to the web history automatically (ChatNonStreaming / ChatNonStreamingViaCli). TelegramService.SendNotification now uses SendLongMessage so long results are chunked into 4096-char messages instead of being rejected whole. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
380 lines
15 KiB
C#
380 lines
15 KiB
C#
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)
|
||
{
|
||
var ctx = await _chatService.PrepareContext(session.SessionId, botUserId, _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")
|
||
{
|
||
await SendMessage(chatId, botToken, "❌ Kein API-Key für diesen Agent zugewiesen. OpenAI benötigt zwingend einen Key – der Bot-Besitzer muss im Web-UI einen Key zuweisen.");
|
||
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;
|
||
}
|
||
}
|