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:
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>ClawdDotNet.Tools.TelegramClient</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="WTelegramClient" Version="4.*" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ClawdDotNet.Core\ClawdDotNet.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,146 @@
|
||||
using ClawdDotNet.Core.Config;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using TL;
|
||||
using WTelegram;
|
||||
|
||||
namespace ClawdDotNet.Tools.TelegramClient;
|
||||
|
||||
public sealed class TelegramClientManager : IAsyncDisposable
|
||||
{
|
||||
private Client? _client;
|
||||
private User? _self;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
private readonly ILogger _logger;
|
||||
|
||||
private readonly int _apiId;
|
||||
private readonly string _apiHash;
|
||||
private readonly string _phoneNumber;
|
||||
private readonly string _sessionPath;
|
||||
private readonly string? _2faPassword;
|
||||
|
||||
private readonly Dictionary<long, User> _users = new();
|
||||
private readonly Dictionary<long, ChatBase> _chats = new();
|
||||
|
||||
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(
|
||||
InstanceConfig config,
|
||||
string instancePath,
|
||||
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(instancePath, "sessions", $"telegram_{config.InstanceId}.session");
|
||||
_2faPassword = tgConfig.Password2FA;
|
||||
}
|
||||
|
||||
public async Task ConnectAsync(CancellationToken ct)
|
||||
{
|
||||
var sessionDir = Path.GetDirectoryName(_sessionPath)!;
|
||||
Directory.CreateDirectory(sessionDir);
|
||||
|
||||
_client = new Client(ConfigCallback);
|
||||
|
||||
Helpers.Log = (lvl, msg) =>
|
||||
_logger.Log((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,
|
||||
|
||||
"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."),
|
||||
|
||||
"password" => _2faPassword
|
||||
?? (On2FAPasswordRequired != null
|
||||
? On2FAPasswordRequired().Result
|
||||
: throw new InvalidOperationException(
|
||||
"2FA password required but not configured.")),
|
||||
|
||||
_ => null
|
||||
};
|
||||
|
||||
public async Task<Messages_Dialogs> GetAllDialogsAsync(CancellationToken ct)
|
||||
{
|
||||
await _gate.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
var dialogs = await _client!.Messages_GetAllDialogs();
|
||||
dialogs.CollectUsersChats(_users, _chats);
|
||||
return dialogs;
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
|
||||
public async Task<Messages_Chats> GetAllChatsAsync(CancellationToken ct)
|
||||
{
|
||||
await _gate.WaitAsync(ct);
|
||||
try { return await _client!.Messages_GetAllChats(); }
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
|
||||
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(); }
|
||||
}
|
||||
|
||||
public async Task<Contacts_ResolvedPeer> ResolveUsernameAsync(string username, CancellationToken ct)
|
||||
{
|
||||
await _gate.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
var resolved = await _client!.Contacts_ResolveUsername(username.TrimStart('@'));
|
||||
foreach (var u in resolved.users.Values)
|
||||
_users[u.id] = u;
|
||||
foreach (var c in resolved.chats.Values)
|
||||
_chats[c.ID] = c;
|
||||
return resolved;
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
|
||||
public InputPeer? GetInputPeerFromCache(long chatId)
|
||||
{
|
||||
if (_users.TryGetValue(chatId, out var user))
|
||||
return user;
|
||||
if (_chats.TryGetValue(chatId, out var chat))
|
||||
return chat;
|
||||
return null;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_client?.Dispose();
|
||||
_gate.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
using System.Text.Json;
|
||||
using ClawdDotNet.Core.Tools;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using TL;
|
||||
|
||||
namespace ClawdDotNet.Tools.TelegramClient;
|
||||
|
||||
public sealed class TelegramClientTool : IAgentTool
|
||||
{
|
||||
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 { get; } = 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.Clone();
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(
|
||||
JsonElement input, AgentToolContext context, CancellationToken ct)
|
||||
{
|
||||
var allowedChats = ParseLongList(context.ToolConfig, "allowedChatIds");
|
||||
var allowedUsernames = ParseStringList(context.ToolConfig, "allowedUsernames");
|
||||
|
||||
if (!_tg.IsConnected)
|
||||
return ToolResult.Fail(
|
||||
"Telegram-Client ist nicht verbunden. Bitte zuerst authentifizieren.");
|
||||
|
||||
var action = input.GetProperty("action").GetString()
|
||||
?? throw new ArgumentException("'action' is required");
|
||||
|
||||
context.Logger.LogInformation("TelegramClient executing action: {Action}", action);
|
||||
|
||||
return action switch
|
||||
{
|
||||
"list_chats" => await ListChatsAsync(allowedChats, ct),
|
||||
"read_messages" => await ReadMessagesAsync(input, allowedChats, context, ct),
|
||||
"read_new" => await ReadNewAsync(input, allowedChats, context, ct),
|
||||
_ => ToolResult.Fail($"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 (var dialog in dialogs.dialogs.OfType<Dialog>())
|
||||
{
|
||||
var peer = dialogs.UserOrChat(dialog);
|
||||
if (peer == null) continue;
|
||||
|
||||
var chatId = dialog.Peer.ID;
|
||||
|
||||
if (allowedChats != null && !allowedChats.Contains(chatId))
|
||||
continue;
|
||||
|
||||
var info = peer switch
|
||||
{
|
||||
User user when user.IsActive => new
|
||||
{
|
||||
chatId,
|
||||
type = "user",
|
||||
name = $"{user.first_name} {user.last_name}".Trim(),
|
||||
username = user.MainUsername,
|
||||
unread = dialog.unread_count,
|
||||
lastMsgId = dialog.TopMessage
|
||||
} as object,
|
||||
|
||||
ChatBase chat when chat.IsActive => new
|
||||
{
|
||||
chatId,
|
||||
type = chat is Channel ch
|
||||
? (ch.IsGroup ? "supergroup" : "channel")
|
||||
: "group",
|
||||
name = chat.Title,
|
||||
username = (chat as Channel)?.MainUsername,
|
||||
unread = dialog.unread_count,
|
||||
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 ToolResult.Ok(JsonSerializer.Serialize(result));
|
||||
}
|
||||
|
||||
private async Task<ToolResult> ReadMessagesAsync(
|
||||
JsonElement input, List<long>? allowedChats,
|
||||
AgentToolContext ctx, CancellationToken ct)
|
||||
{
|
||||
var (peer, chatId, error) = await ResolvePeerAsync(input, allowedChats, ct);
|
||||
if (error != null) return ToolResult.Fail(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,
|
||||
count = msgList.Count,
|
||||
messages = msgList
|
||||
}
|
||||
};
|
||||
|
||||
return ToolResult.Ok(JsonSerializer.Serialize(result));
|
||||
}
|
||||
|
||||
private async Task<ToolResult> ReadNewAsync(
|
||||
JsonElement input, List<long>? allowedChats,
|
||||
AgentToolContext ctx, CancellationToken ct)
|
||||
{
|
||||
var (peer, chatId, error) = await ResolvePeerAsync(input, allowedChats, ct);
|
||||
if (error != null) return ToolResult.Fail(error);
|
||||
|
||||
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;
|
||||
|
||||
var messages = await _tg.GetMessagesAsync(peer!, minId: lastId, limit: limit, ct: ct);
|
||||
var msgList = FormatMessages(messages);
|
||||
|
||||
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,
|
||||
sinceId = lastId,
|
||||
newCount = msgList.Count,
|
||||
messages = msgList
|
||||
}
|
||||
};
|
||||
|
||||
return ToolResult.Ok(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)
|
||||
{
|
||||
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.UserOrChat switch
|
||||
{
|
||||
User u => (InputPeer)u,
|
||||
ChatBase c => (InputPeer)c,
|
||||
_ => null
|
||||
};
|
||||
chatId = resolved.peer.ID;
|
||||
|
||||
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(
|
||||
msg.ID,
|
||||
msg.Date,
|
||||
fromName,
|
||||
msgBase.From?.ID ?? 0,
|
||||
msg.message,
|
||||
msg.media != null,
|
||||
msg.media?.GetType().Name,
|
||||
(msg.reply_to as MessageReplyHeader)?.reply_to_msg_id,
|
||||
msg.fwd_from != null
|
||||
? msg.fwd_from.from_name ?? "forwarded"
|
||||
: null,
|
||||
msg.views));
|
||||
}
|
||||
}
|
||||
|
||||
return result.OrderBy(m => m.MessageId).ToList();
|
||||
}
|
||||
|
||||
private static List<long>? ParseLongList(
|
||||
IReadOnlyDictionary<string, object?> config, string key)
|
||||
{
|
||||
if (!config.TryGetValue(key, out var val) || val == null)
|
||||
return null;
|
||||
|
||||
if (val is JsonElement je && je.ValueKind == JsonValueKind.Array)
|
||||
return je.EnumerateArray().Select(e => e.GetInt64()).ToList();
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static List<string>? ParseStringList(
|
||||
IReadOnlyDictionary<string, object?> config, string key)
|
||||
{
|
||||
if (!config.TryGetValue(key, out var val) || val == null)
|
||||
return null;
|
||||
|
||||
if (val is JsonElement je && je.ValueKind == JsonValueKind.Array)
|
||||
return je.EnumerateArray().Select(e => e.GetString()!).ToList();
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private sealed record FormattedMessage(
|
||||
int MessageId,
|
||||
DateTime Date,
|
||||
string From,
|
||||
long FromId,
|
||||
string? Text,
|
||||
bool HasMedia,
|
||||
string? MediaType,
|
||||
int? ReplyToId,
|
||||
string? ForwardFrom,
|
||||
int? Views);
|
||||
}
|
||||
Reference in New Issue
Block a user