Initial commit: ClawdDotNet

Import des bestehenden Projektstands in Git.
- .NET 10 WinForms Anwendung (Multi-Agent / Tool-System)
- .gitignore fuer Build-Artefakte, Secrets und Runtime-Daten ergaenzt

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-07-26 18:21:46 +02:00
co-authored by Claude Opus 4.8
commit 2fed388c99
154 changed files with 29736 additions and 0 deletions
+718
View File
@@ -0,0 +1,718 @@
# 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
<PackageReference Include="WTelegramClient" Version="4.*" />
```
---
## 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<TelegramClientManager> _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<string, Task<string>>? OnLoginCodeRequired;
public event Func<Task<string>>? On2FAPasswordRequired;
public bool IsConnected => _client?.User != null;
public User? Self => _self;
public TelegramClientManager(
Config.InstanceConfig config,
ILogger<TelegramClientManager> 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<Messages_Dialogs> 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<Messages_Chats> 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<Messages_MessagesBase> 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<IPeerInfo> 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<ToolResult> ExecuteAsync(
JsonElement input, AgentToolContext ctx, CancellationToken ct)
{
// ---- Permission-Check: Welche Chats darf dieser Agent lesen? ----
var config = ctx.ToolConfig.GetValueOrDefault("TelegramClient")
as Dictionary<string, object?> ?? new();
var allowedChats = config.GetValueOrDefault("allowedChatIds")
as List<long>; // null = alle erlaubt
var allowedUsernames = config.GetValueOrDefault("allowedUsernames")
as List<string>;
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<ToolResult> ListChatsAsync(
List<long>? allowedChats, CancellationToken ct)
{
var dialogs = await _tg.GetAllDialogsAsync(ct);
var chatList = new List<object>();
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<ToolResult> ReadMessagesAsync(
JsonElement input, List<long>? allowedChats,
Dictionary<string, object?> 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<ToolResult> ReadNewAsync(
JsonElement input, List<long>? allowedChats,
Dictionary<string, object?> 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<long>? 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<FormattedMessage> FormatMessages(Messages_MessagesBase messages)
{
var result = new List<FormattedMessage>();
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<TelegramClientManager>();
// 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.**