Fruehjahrsputz: WinForms-Altlast entfernt, Dokumentation nachgezogen
Die Avalonia-Portierung ist abgeschlossen, damit ist die in ClawdDotNet.slnx angekuendigte Aufgabe "WinForms-Oberflaeche entfernen" faellig. Der Stand davor liegt unter dem Tag vor-fruehjahrsputz-2026-08. Entfernt (56 Dateien, seit dem Herausloesen der Anwendungsschicht nicht mehr Teil des Builds): ClawdDotNet.csproj, Program.cs, sieben frm_*-Formulare, UI/, Models/, EmbeddedUI/, Properties/, Resources/, Services/, das alte Anwendungssymbol und Deploy-Build.ps1 (ersetzt durch deploy/publish.py). Dazu configs/*.json - Beispielkonfigurationen aus der Zeit vor dem Instanzverzeichnis, auf die nur noch die alten Prompts verwiesen. Die vier Entwicklungs-Prompts der Anfangszeit ziehen nach docs/archiv/ um, mit README, das ihren Stand einordnet. Eine Regel darin gilt weiter - die Pflichtfelder fetchedAt/dataAsOf/source der Internet-Tools -, deshalb Archiv statt Loeschen; der WebSearch-Plan verweist auf den neuen Pfad. Toter Code - PlaceholderPageViewModel samt Ansicht: Es gibt keinen Platzhalter-Bereich mehr, seit alle neun Seiten portiert sind. - Snappier als direkter Paketverweis: MongoDB.Driver loest es ohnehin auf dieselbe Fassung auf, der Verweis hob nichts an. Zwei Fehler, die dabei sichtbar wurden - Die taegliche Sicherung lief ins Leere. Die Oberflaeche bot sie an und schrieb Uhrzeit, Zielordner und Anzahl in die Einstellungen, aber der BackupScheduler wurde nirgends erzeugt. Jetzt am AppHost verdrahtet und in den geordneten Abbau aufgenommen. - SettingsPageViewModel hielt die vier Sicherungs-Einstellungen doppelt. Aus der Ansicht waren sie laengst verschwunden, gelesen und beim Speichern zurueckgeschrieben wurden sie weiter: Wer die Uhrzeit auf der Sicherungs-Seite aenderte und danach die Einstellungen speicherte, bekam den alten Wert zurueck. Pakete: keine bekannten Sicherheitsluecken mehr - SQLitePCLRaw.bundle_e_sqlite3 auf 2.1.13 angehoben. Microsoft.Data.Sqlite bringt 2.1.11 mit, darin steckt GHSA-2m69-gcr7-jv3q (NU1903, hoch). - SharpCompress bleibt als direkter Verweis stehen. Beim Aufraeumen erst als ungenutzt entfernt - dabei kam die von MongoDB.Driver gezogene Fassung 0.30.1 mit GHSA-6c8g-7p36-r338 zurueck. Der Verweis ist eine Anhebung, kein Ballast; das steht jetzt als Kommentar dabei. Dokumentation - Roadmap mit Statusblock: A1 und A3 erledigt, A2 nur zur Haelfte - Gate, Policy und Dienst greifen, aber keine Ansicht ruft ApproveAsync auf, ein gestagter Aufruf liegt unbeantwortet. Das ist jetzt Punkt 1 der Reihung. Rocket.Chat steht und kollidiert mit A5 (Matrix) - Entscheidung faellig. - Avalonia-Portierungsleitfaden -> Oberflaechen-Leitfaden: kein Auftrag mehr, sondern Beschreibung des Stands. - Bestandsaufnahme und Linux-Analyse als datierte Befunde gekennzeichnet; der teure Teil der Linux-Analyse (8.900 Zeilen WinForms) ist hinfaellig. - Verweise auf frm_*, WebView2 und ClawdDotNet.csproj in den lebenden Dokumenten richtiggestellt. Build fehlerfrei, 585 Tests gruen (6 uebersprungen). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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.**
|
||||
Reference in New Issue
Block a user