# ClawdDotNet – Prompt-Anhang: Tool "TelegramClient" Dieser Abschnitt ergänzt die bestehenden Prompt-Anhänge und definiert das Tool `TelegramClient`, das über die Telegram Client API (MTProto) auf den persönlichen Telegram-Account des Nutzers zugreift. Dieses Tool ist NICHT der bereits vorhandene Telegram Bot — es nutzt die User-API und kann damit auch Nachrichten aus privaten Gruppen lesen, in denen der Nutzer Mitglied ist. **Nur Lese-Zugriff. Kein Senden von Nachrichten.** --- ## Voraussetzungen ### Telegram API Credentials Der Nutzer muss einmalig auf https://my.telegram.org/apps eine App registrieren. Ergebnis: `api_id` (Integer) und `api_hash` (String). Diese Werte repräsentieren die Anwendung (nicht den User) und werden in der InstanceConfig gespeichert. ### Erstmalige Authentifizierung Beim allerersten Start muss der Nutzer sich interaktiv authentifizieren: 1. Telefonnummer eingeben 2. Verifizierungscode eingeben (kommt per Telegram-App, SMS oder Anruf) 3. Optional: 2FA-Passwort eingeben Danach wird eine Session-Datei gespeichert. Alle weiteren Starts verwenden diese Session automatisch — kein erneuter Login nötig. ### NuGet ```xml ``` --- ## Architektur: Shared Client, Read-Only Access ### Warum ein Shared Client? Die Telegram Client API erlaubt pro Telefonnummer nur EINE aktive MTProto-Verbindung. Mehrere Agent-Runs dürfen NICHT jeweils einen eigenen WTelegram.Client instanziieren — das würde die Session invalidieren und den Login auf dem echten Telegram-Client killen. Lösung: Ein einziger `WTelegram.Client` wird im Host instanziiert und als Singleton an alle Agenten weitergegeben. Das Tool selbst ist stateless und greift über den Shared Client auf Telegram zu. ``` Host (Program.cs) └─ TelegramClientManager (Singleton) └─ WTelegram.Client (eine Instanz pro Prozess) ├─ Agent A: TelegramClient-Tool → liest Gruppe "Aktien-Chat" ├─ Agent B: TelegramClient-Tool → liest Gruppe "Krypto-Signals" └─ Agent C: TelegramClient-Tool → liest DMs ``` ### Concurrency WTelegram.Client ist NICHT thread-safe für gleichzeitige API-Calls. Der `TelegramClientManager` muss alle Aufrufe über einen `SemaphoreSlim(1,1)` serialisieren. Da wir nur lesen und die Calls schnell sind (<500ms), ist die Serialisierung kein Bottleneck. --- ## TelegramClientManager **Datei: `Host/Services/TelegramClientManager.cs`** Verwaltet die einzige WTelegram.Client-Instanz. Wird im Host als Singleton registriert. ```csharp namespace ClawdDotNet.Host.Services; using WTelegram; using TL; public sealed class TelegramClientManager : IAsyncDisposable { private Client? _client; private User? _self; private readonly SemaphoreSlim _gate = new(1, 1); private readonly ILogger _logger; // Config-Werte aus InstanceConfig private readonly int _apiId; private readonly string _apiHash; private readonly string _phoneNumber; private readonly string _sessionPath; private readonly string? _2faPassword; // Event für interaktive Login-Aufforderung (Code-Eingabe via UI) public event Func>? OnLoginCodeRequired; public event Func>? On2FAPasswordRequired; public bool IsConnected => _client?.User != null; public User? Self => _self; public TelegramClientManager( Config.InstanceConfig config, ILogger logger) { _logger = logger; var tgConfig = config.TelegramClient ?? throw new InvalidOperationException("TelegramClient config missing in InstanceConfig"); _apiId = tgConfig.ApiId; _apiHash = tgConfig.ApiHash; _phoneNumber = tgConfig.PhoneNumber; _sessionPath = Path.Combine(config.WorkingDirectory, $"telegram_{config.InstanceId}.session"); _2faPassword = tgConfig.Password2FA; } public async Task ConnectAsync(CancellationToken ct) { // WTelegram.Client mit Config-Callback instanziieren _client = new Client(ConfigCallback, _sessionPath); // Logging an ILogger umleiten Helpers.Log = (lvl, msg) => _logger.Log((Microsoft.Extensions.Logging.LogLevel)lvl, "WTelegram: {Message}", msg); _self = await _client.LoginUserIfNeeded(); _logger.LogInformation( "Telegram: logged in as {Name} (id {Id})", _self.first_name, _self.id); } private string? ConfigCallback(string what) => what switch { "api_id" => _apiId.ToString(), "api_hash" => _apiHash, "phone_number" => _phoneNumber, "session_pathname" => _sessionPath, // Interaktiver Code — wird über Event an die UI weitergeleitet "verification_code" => OnLoginCodeRequired != null ? OnLoginCodeRequired("Bitte Telegram-Verifizierungscode eingeben:").Result : throw new InvalidOperationException( "Verification code required but no UI handler registered. " + "Connect OnLoginCodeRequired to prompt the user."), // 2FA-Passwort — aus Config oder interaktiv "password" => _2faPassword ?? (On2FAPasswordRequired != null ? On2FAPasswordRequired().Result : throw new InvalidOperationException( "2FA password required but not configured.")), _ => null // Defaults für alles andere }; /// Alle Dialoge (Chats, Gruppen, Kanäle, DMs) auflisten public async Task GetAllDialogsAsync(CancellationToken ct) { await _gate.WaitAsync(ct); try { return await _client!.Messages_GetAllDialogs(); } finally { _gate.Release(); } } /// Alle Gruppen/Kanäle auflisten (ohne DMs) public async Task GetAllChatsAsync(CancellationToken ct) { await _gate.WaitAsync(ct); try { return await _client!.Messages_GetAllChats(); } finally { _gate.Release(); } } /// Nachrichten aus einem Chat/Kanal/Gruppe lesen /// peer: Chat-ID oder Username /// minId: nur Nachrichten neuer als diese ID (für Delta-Abfragen) /// limit: max. Anzahl Nachrichten public async Task GetMessagesAsync( InputPeer peer, int minId = 0, int limit = 50, CancellationToken ct = default) { await _gate.WaitAsync(ct); try { return await _client!.Messages_GetHistory( peer, offset_id: 0, offset_date: default, add_offset: 0, limit: limit, max_id: 0, min_id: minId, hash: 0); } finally { _gate.Release(); } } /// Peer über Username oder Chat-ID auflösen public async Task ResolveUsernameAsync(string username, CancellationToken ct) { await _gate.WaitAsync(ct); try { return await _client!.Contacts_ResolveUsername(username.TrimStart('@')); } finally { _gate.Release(); } } /// Peer über bekannte Chat-ID auflösen (benötigt vorherigen GetAllChats/Dialogs Aufruf) public InputPeer? GetInputPeerFromCache(long chatId) => _client!.GetInputPeerID(chatId); public async ValueTask DisposeAsync() { _client?.Dispose(); _gate.Dispose(); } } ``` --- ## TelegramClientTool — das IAgentTool **Datei: `ClawdDotNet.Tools.TelegramClient/TelegramClientTool.cs`** ```csharp namespace ClawdDotNet.Tools.TelegramClient; using ClawdDotNet.Core.Tools; using ClawdDotNet.Host.Services; // TelegramClientManager using TL; using System.Text.Json; public sealed class TelegramClientTool : IAgentTool { // Manager wird per DI injiziert (Singleton im Host) private readonly TelegramClientManager _tg; public TelegramClientTool(TelegramClientManager tg) => _tg = tg; public string Name => "TelegramClient"; public string Description => """ Liest Nachrichten aus dem persönlichen Telegram-Account des Nutzers. Zugriff auf alle Chats, Gruppen und Kanäle in denen der Nutzer Mitglied ist. NUR LESEN — kein Senden, kein Löschen, kein Bearbeiten. Aktionen: list_chats, read_messages, read_new """; public JsonElement InputSchema => JsonDocument.Parse(""" { "type": "object", "required": ["action"], "properties": { "action": { "type": "string", "enum": ["list_chats", "read_messages", "read_new"], "description": "list_chats: alle Chats/Gruppen/Kanäle auflisten. read_messages: letzte N Nachrichten aus einem Chat lesen. read_new: nur neue Nachrichten seit letztem Abruf." }, "chatId": { "type": "integer", "description": "Chat-ID aus list_chats Ergebnis. Erforderlich für read_messages und read_new." }, "username": { "type": "string", "description": "Alternativ zu chatId: @username einer Gruppe/Person auflösen." }, "limit": { "type": "integer", "description": "Max. Anzahl Nachrichten (default: 30, max: 100)" } } } """).RootElement; public async Task ExecuteAsync( JsonElement input, AgentToolContext ctx, CancellationToken ct) { // ---- Permission-Check: Welche Chats darf dieser Agent lesen? ---- var config = ctx.ToolConfig.GetValueOrDefault("TelegramClient") as Dictionary ?? new(); var allowedChats = config.GetValueOrDefault("allowedChatIds") as List; // null = alle erlaubt var allowedUsernames = config.GetValueOrDefault("allowedUsernames") as List; if (!_tg.IsConnected) return new ToolResult(false, "", "Telegram-Client ist nicht verbunden. Bitte zuerst authentifizieren."); var action = input.GetProperty("action").GetString()!; return action switch { "list_chats" => await ListChatsAsync(allowedChats, ct), "read_messages" => await ReadMessagesAsync(input, allowedChats, config, ctx, ct), "read_new" => await ReadNewAsync(input, allowedChats, config, ctx, ct), _ => new ToolResult(false, "", $"Unknown action: {action}") }; } private async Task ListChatsAsync( List? allowedChats, CancellationToken ct) { var dialogs = await _tg.GetAllDialogsAsync(ct); var chatList = new List(); foreach (Dialog dialog in dialogs.dialogs) { var peer = dialogs.UserOrChat(dialog); if (peer == null) continue; var chatId = dialog.Peer.ID; // Filter: nur erlaubte Chats anzeigen (wenn Whitelist definiert) if (allowedChats != null && !allowedChats.Contains(chatId)) continue; var info = peer switch { User user when user.IsActive => new { chatId = chatId, type = "user", name = $"{user.first_name} {user.last_name}".Trim(), username = user.MainUsername, unread = dialog.UnreadCount, lastMsgId = dialog.TopMessage } as object, ChatBase chat when chat.IsActive => new { chatId = chatId, type = chat is Channel ch ? (ch.IsGroup ? "supergroup" : "channel") : "group", name = chat.Title, username = (chat as Channel)?.MainUsername, unread = dialog.UnreadCount, lastMsgId = dialog.TopMessage } as object, _ => null }; if (info != null) chatList.Add(info); } var result = new { fetchedAt = DateTime.UtcNow, dataAsOf = DateTime.UtcNow, source = "telegram_client_api", data = new { totalChats = chatList.Count, chats = chatList } }; return new ToolResult(true, JsonSerializer.Serialize(result)); } private async Task ReadMessagesAsync( JsonElement input, List? allowedChats, Dictionary config, AgentToolContext ctx, CancellationToken ct) { var (peer, chatId, error) = await ResolvePeerAsync(input, allowedChats, ct); if (error != null) return new ToolResult(false, "", error); var limit = input.TryGetProperty("limit", out var l) ? Math.Clamp(l.GetInt32(), 1, 100) : 30; var messages = await _tg.GetMessagesAsync(peer!, minId: 0, limit: limit, ct: ct); var msgList = FormatMessages(messages); var result = new { fetchedAt = DateTime.UtcNow, dataAsOf = DateTime.UtcNow, source = $"telegram_chat_{chatId}", data = new { chatId = chatId, count = msgList.Count, messages = msgList } }; return new ToolResult(true, JsonSerializer.Serialize(result)); } private async Task ReadNewAsync( JsonElement input, List? allowedChats, Dictionary config, AgentToolContext ctx, CancellationToken ct) { var (peer, chatId, error) = await ResolvePeerAsync(input, allowedChats, ct); if (error != null) return new ToolResult(false, "", error); // Letzte bekannte Message-ID aus StateStore laden var stateKey = $"tgclient:{ctx.AgentId}:chat_{chatId}:lastMsgId"; var lastIdStr = await ctx.StateStore.GetAsync(stateKey, ct); var lastId = int.TryParse(lastIdStr, out var id) ? id : 0; var limit = input.TryGetProperty("limit", out var l) ? Math.Clamp(l.GetInt32(), 1, 100) : 50; // min_id = lastId → nur Nachrichten neuer als lastId var messages = await _tg.GetMessagesAsync(peer!, minId: lastId, limit: limit, ct: ct); var msgList = FormatMessages(messages); // Neue Max-ID persistieren if (msgList.Count > 0) { var newMaxId = msgList.Max(m => m.messageId); await ctx.StateStore.SetAsync(stateKey, newMaxId.ToString(), ct); } var result = new { fetchedAt = DateTime.UtcNow, dataAsOf = DateTime.UtcNow, source = $"telegram_chat_{chatId}", data = new { chatId = chatId, sinceId = lastId, newCount = msgList.Count, messages = msgList } }; return new ToolResult(true, JsonSerializer.Serialize(result)); } private async Task<(InputPeer? peer, long chatId, string? error)> ResolvePeerAsync( JsonElement input, List? allowedChats, CancellationToken ct) { long chatId = 0; InputPeer? peer = null; if (input.TryGetProperty("chatId", out var cid)) { chatId = cid.GetInt64(); if (allowedChats != null && !allowedChats.Contains(chatId)) return (null, chatId, $"Agent hat keinen Zugriff auf Chat {chatId}."); peer = _tg.GetInputPeerFromCache(chatId); if (peer == null) { // Cache befüllen durch einmaligen GetAllDialogs-Aufruf await _tg.GetAllDialogsAsync(ct); peer = _tg.GetInputPeerFromCache(chatId); } } else if (input.TryGetProperty("username", out var uname)) { var resolved = await _tg.ResolveUsernameAsync(uname.GetString()!, ct); peer = resolved?.ToInputPeer(); chatId = peer?.ID ?? 0; if (allowedChats != null && !allowedChats.Contains(chatId)) return (null, chatId, $"Agent hat keinen Zugriff auf Chat @{uname.GetString()}."); } if (peer == null) return (null, 0, "chatId oder username muss angegeben werden."); return (peer, chatId, null); } private static List FormatMessages(Messages_MessagesBase messages) { var result = new List(); foreach (var msgBase in messages.Messages) { var from = messages.UserOrChat(msgBase.From ?? msgBase.Peer); var fromName = from switch { User u => $"{u.first_name} {u.last_name}".Trim(), ChatBase c => c.Title, _ => "Unknown" }; if (msgBase is Message msg) { result.Add(new FormattedMessage( messageId: msg.ID, date: msg.Date, from: fromName, fromId: msgBase.From?.ID ?? 0, text: msg.message, hasMedia: msg.media != null, mediaType: msg.media?.GetType().Name, replyToId: (msg.reply_to as MessageReplyHeader)?.reply_to_msg_id, forwardFrom: msg.fwd_from != null ? msg.fwd_from.from_name ?? "forwarded" : null, views: msg.views )); } } return result.OrderBy(m => m.messageId).ToList(); } private sealed record FormattedMessage( int messageId, DateTime date, string from, long fromId, string? text, bool hasMedia, string? mediaType, int? replyToId, string? forwardFrom, int? views ); } ``` --- ## AgentConfig-Beispiel ```json { "agentId": "telegram-scout", "displayName": "Telegram News-Scout", "model": "google/gemini-flash-1.5", "systemPrompt": "Du überwachst Telegram-Gruppen auf relevante Finanznachrichten und Trading-Signale. Fasse neue Nachrichten zusammen und bewerte ihre Relevanz. Verwende niemals Daten ohne fetchedAt-Feld.", "tools": { "TelegramClient": { "allowedChatIds": [1001234567890, 1009876543210], "allowedUsernames": ["aktien_chat", "crypto_signals_de"] }, "Database": { "connectionString": "...", "allowedTables": ["telegram_messages", "signal_archive"] } }, "scheduler": { "cron": "*/15 * * * *", "runOnStart": true }, "loopGuard": { "maxSteps": 10, "maxTokens": 30000 } } ``` Ein Agent ohne `TelegramClient`-Eintrag in seiner Config bekommt das Tool gar nicht erst in seinem LLM-Tool-Set angezeigt (normales Permission-Verhalten). Ein Agent MIT Config aber ohne `allowedChatIds` (= null) darf alle Chats lesen. --- ## InstanceConfig-Erweiterung ```csharp // Config/InstanceConfig.cs — neues optionales Feld: public sealed class InstanceConfig { // ... bestehende Felder ... public TelegramClientConfig? TelegramClient { get; set; } } public sealed class TelegramClientConfig { public int ApiId { get; set; } // von https://my.telegram.org/apps public string ApiHash { get; set; } = ""; // von https://my.telegram.org/apps public string PhoneNumber { get; set; } = ""; // z.B. "+491701234567" public string? Password2FA { get; set; } // optional, nur bei aktivierter 2FA } ``` ```json // In stock-team.json: { "instanceId": "stock-01", "instanceName": "Aktien-Team", "openRouterApiKey": "sk-or-...", "workingDirectory": "./data/stock/", "webServerPort": 8081, "telegramClient": { "apiId": 12345678, "apiHash": "abcdef1234567890abcdef1234567890", "phoneNumber": "+491701234567" }, "agents": [ ... ] } ``` --- ## Interaktiver Login in WinForms Der erste Login erfordert einen Verifizierungscode. Dieser wird über das bestehende Chat-UI in `frm_main` abgefragt — nicht über die Konsole. **In `frm_main` oder `Program.cs` beim Start:** ```csharp var tgManager = provider.GetRequiredService(); // UI-Handler für Code-Eingabe registrieren tgManager.OnLoginCodeRequired = async (prompt) => { // Auf UI-Thread: InputBox oder Chat-Nachricht anzeigen string? code = null; mainForm.Invoke(() => { code = Microsoft.VisualBasic.Interaction.InputBox( prompt, "Telegram Verifizierung", ""); }); return code ?? ""; }; tgManager.On2FAPasswordRequired = async () => { string? pw = null; mainForm.Invoke(() => { pw = Microsoft.VisualBasic.Interaction.InputBox( "Bitte 2FA-Passwort eingeben:", "Telegram 2FA", ""); }); return pw ?? ""; }; // Verbindung herstellen (nutzt Session-Datei wenn vorhanden) try { await tgManager.ConnectAsync(CancellationToken.None); } catch (Exception ex) { _logger.LogError(ex, "Telegram: Login fehlgeschlagen"); // App startet trotzdem — TelegramClient-Tool meldet "nicht verbunden" } ``` Nach erfolgreichem Login wird die Session-Datei `./data/{instanceId}/telegram_{instanceId}.session` gespeichert. Alle weiteren Starts loggen automatisch ein — kein Code mehr nötig. --- ## Sicherheitsregeln 1. **NUR LESEN** — Das Tool implementiert keine Sende-Funktionen. Es gibt keine `send_message`-Action. Der `TelegramClientManager` exponiert bewusst keine `SendMessageAsync`-Methode. 2. **Session-Datei ist sensibel** — Sie enthält die Auth-Keys für den Telegram-Account. Die Datei liegt im `WorkingDirectory` und darf NICHT vom FileRW-Tool erreichbar sein. In der AgentConfig für FileRW darf der `rootPath` NIEMALS auf das WorkingDirectory zeigen wenn dort die Session-Datei liegt. Empfehlung: Session-Datei in einem Unterordner `./data/{instanceId}/sessions/` speichern, der für kein FileRW-Tool als rootPath konfiguriert ist. 3. **Chat-Whitelist pro Agent** — Über `allowedChatIds` kann eingeschränkt werden welche Chats ein Agent lesen darf. Ein Finanzmarkt-Agent hat keinen Zugriff auf private DMs. Ein SEO-Agent hat keinen Zugriff auf Trading-Gruppen. 4. **Rate Limiting** — Die Telegram Client API hat undokumentierte Rate-Limits. Bei zu vielen Requests kommt ein `FLOOD_WAIT_X` Error. Der `TelegramClientManager` muss `FloodException` abfangen und `await Task.Delay(ex.X * 1000)` warten bevor er den Call wiederholt. Empfehlung: mindestens 1 Sekunde Pause zwischen aufeinanderfolgenden API-Calls (der SemaphoreSlim allein reicht nicht). --- ## Besonderheiten von WTelegramClient ### Terminology-Mapping In der Telegram Client API unterscheiden sich die Begriffe von der Benutzeroberfläche: | Telegram-App | API-Bezeichnung | C#-Typ | |---|---|---| | Gruppe (klein) | Chat | `Chat` | | Gruppe (groß) | Channel mit IsGroup | `Channel` (IsGroup) | | Kanal | Channel ohne IsGroup | `Channel` (!IsGroup) | | Privatnachricht | User | `User` | ### access_hash-Problem Telegram-API-Calls benötigen für die meisten Peers einen `access_hash`. Dieser wird automatisch gecacht wenn vorher `Messages_GetAllDialogs()` oder `Messages_GetAllChats()` aufgerufen wurde. Deshalb MUSS bei jedem Start (nach Login) einmalig `GetAllDialogsAsync()` aufgerufen werden, bevor `GetMessagesAsync()` funktioniert. ### Session-Datei - Pfad konfigurierbar über `session_pathname` in der Config-Callback - Verschlüsselt (Standard-Verschlüsselung von WTelegramClient) - NICHT zwischen Rechnern portierbar (an Hardware gebunden) - Bei Session-Problemen: Datei löschen → neuer Login erforderlich --- ## Implementierungsreihenfolge (für Claude Code) 1. `TelegramClientConfig` zu `InstanceConfig` hinzufügen 2. `TelegramClientManager` implementieren (Singleton, SemaphoreSlim, Rate-Limit-Schutz) 3. `TelegramClientTool` implementieren (list_chats, read_messages, read_new) 4. Host: Login-Flow in `Program.cs` / `frm_main` integrieren (InputBox für Code) 5. Sicherheits-Check: Session-Pfad darf nicht in FileRW-rootPath liegen 6. xUnit-Tests: FormatMessages-Serialisierung, Chat-Whitelist-Filter, Rate-Limit-Handling 7. Beispiel-Config ergänzen: `stock-team.json` mit TelegramClient-Eintrag **Beginne mit Schritt 1 dieses Abschnitts.**