Files
gott/Services/TelegramService.cs
T
MelekiandClaude Opus 4.7 ecddbe5f36 fix(openai): ask_user_choice-Intercept, Interleaved Thinking in History, MiniMax-Debug-Log
OpenAI-kompatible Provider (MiniMax, DeepSeek-R1, QwQ, Kimi K2, etc.) verloren bei
jeder neuen Iteration ihre eigene Chain-of-Thought, weil thinking-Messages aus der
DB-History rausgefiltert wurden. Folge: Modell kündigt Action an, ruft Tool aber
nicht auf → Stream endet mit finish_reason:stop ohne Tool-Call. Zusätzlich fehlte
der ask_user_choice-Intercept (Tool wurde als unbekannt abgelehnt) und es gab keine
Diagnose-Möglichkeit für unerwartete Stop-Verhalten.

- ChatService.BuildHistoryFromDb(includeThinking): neuer Parameter, fügt
  thinking-Messages als {type:"thinking"}-Block in die Assistant-Message ein.
  Text/Tool-Use derselben Iteration werden via AppendToLastMessage angehängt
  → ein Assistant-Block enthält [thinking, text, tool_use…].
- OpenAiService.ConvertMessage + StreamChat + ChatNonStreaming: Thinking als
  <think>...</think>-Prefix dem Assistant-Content voranstellen (Per-Iteration und
  beim DB-History-Aufbau). Konvention von MiniMax-Docs, wird auch von DeepSeek-R1,
  QwQ, Kimi K2 verstanden.
- ChatEndpoint, SessionEndpoint, CronjobService, TelegramService, ToolService:
  includeThinking je nach Provider durchgereicht. Anthropic-Pfad bleibt aus
  (lehnt thinking-Blöcke ohne Signature ab).
- OpenAiService: ask_user_choice-Intercept im Streaming-Pfad (analog
  AnthropicService.Streaming) – Tool nicht ausführen, SSE user_choice rausschicken,
  mit stopReason awaiting_user_choice finalisieren und return.
- OpenAiService: MiniMax-SSE-Debug-Log in data/openai-debug/ (JSONL: Header +
  Roh-data-Zeilen + Summary mit promptTokens/completionTokens/finishReason/
  toolCalls/duration) – nur für provider=="minimax", nicht-blockierend.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 10:19:06 +02:00

337 lines
12 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)
{
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);
if (userApiKey == null)
{
await SendMessage(chatId, botToken, "❌ Kein API-Key für diesen Agent zugewiesen. Der Bot-Besitzer muss im Web-UI einen Key zuweisen.");
return;
}
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
{
reply = await _anthropic.ChatNonStreaming(ctx, history, onIteration: async (iteration) =>
{
if (iteration % 3 == 0)
await SendChatAction(chatId, botToken, "typing");
});
}
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);
await SendMessage(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;
}
}