feat(e2e): #146 MTProto Telegram user client runner

- Add standalone C# console runner tests/e2e/runner/ using WTelegramClient
- Provide TelegramUserClient wrapper: login, create supergroup, invite bot,
  send messages/commands, read recent messages, wait for bot reply
- Add .env.example and runner .gitignore to keep secrets/session files out of git
- Update E2E README with runner instructions and status table
- Runner project intentionally excluded from GM-Relay.slnx to avoid CI/AOT impact

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-16 12:07:48 +03:00
parent fcc8514847
commit 4b0f328f2e
8 changed files with 268 additions and 17 deletions
+19
View File
@@ -0,0 +1,19 @@
# Configuration for the GmRelay E2E MTProto runner.
# Copy this file to .env and fill in real values.
# NEVER commit .env or *.session files to git.
# Telegram user account credentials (MTProto)
api_id=12345678
api_hash=abcdef0123456789abcdef0123456789
phone_number=+1234567890
# Bot under test
TELEGRAM_BOT_USERNAME=gmrelay_test_bot
TELEGRAM_BOT_TOKEN=1234567890:ABCDEF...token
# Web dashboard under test
GMRELAY_E2E_BASE_URL=http://localhost:8080
GMRELAY_E2E_TELEGRAM_ID=9000000001
# PostgreSQL connection string (optional, used for seeding/cleanup)
GMRELAY_E2E_DATABASE_URL=Host=localhost;Database=gmrelay;Username=postgres;Password=postgres
+5
View File
@@ -0,0 +1,5 @@
.env
*.session
bin/
obj/
packages.lock.json
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="WTelegramClient" Version="4.3.5" />
<PackageReference Include="dotenv.net" Version="3.2.1" />
</ItemGroup>
</Project>
+27
View File
@@ -0,0 +1,27 @@
using dotenv.net;
using GmRelay.E2E.Runner;
DotEnv.Load(new DotEnvOptions(envFilePaths: [".env"], ignoreExceptions: false));
var config = new RunnerConfig
{
ApiId = int.Parse(Environment.GetEnvironmentVariable("api_id")!),
ApiHash = Environment.GetEnvironmentVariable("api_hash")!,
PhoneNumber = Environment.GetEnvironmentVariable("phone_number")!,
BotUsername = Environment.GetEnvironmentVariable("TELEGRAM_BOT_USERNAME")!,
BotToken = Environment.GetEnvironmentVariable("TELEGRAM_BOT_TOKEN")!,
};
using var runner = new TelegramUserClient(config);
await runner.ConnectAsync();
Console.WriteLine("Connected as Telegram user. Creating test group...");
var group = await runner.CreateGroupAsync("GmRelay E2E Test Group");
Console.WriteLine($"Created group id={group.Id} title='{group.Title}'");
await runner.InviteBotToGroupAsync(group, config.BotUsername);
Console.WriteLine($"Invited @{config.BotUsername} to the group");
// Keep the process alive briefly so the session is persisted.
await Task.Delay(TimeSpan.FromSeconds(2));
Console.WriteLine("Done.");
+10
View File
@@ -0,0 +1,10 @@
namespace GmRelay.E2E.Runner;
public sealed class RunnerConfig
{
public required int ApiId { get; init; }
public required string ApiHash { get; init; }
public required string PhoneNumber { get; init; }
public required string BotUsername { get; init; }
public required string BotToken { get; init; }
}
+114
View File
@@ -0,0 +1,114 @@
using TL;
using WTelegram;
namespace GmRelay.E2E.Runner;
public sealed class TelegramUserClient : IDisposable
{
private readonly Client _client;
private readonly RunnerConfig _config;
public TelegramUserClient(RunnerConfig config)
{
_config = config;
_client = new Client(_ => Environment.GetEnvironmentVariable(_));
}
public async Task ConnectAsync(CancellationToken cancellationToken = default)
{
var me = await _client.LoginUserIfNeeded();
Console.WriteLine($"Logged in as {me.first_name} {me.last_name} (id={me.id})");
}
public async Task<ChatGroup> CreateGroupAsync(string title, string about = "", CancellationToken cancellationToken = default)
{
// Creating a megagroup gives us an UpdatesBase with Chats immediately.
var updates = await _client.Channels_CreateChannel(title, about, megagroup: true);
var channel = updates.Chats.Values.OfType<Channel>().FirstOrDefault()
?? throw new InvalidOperationException("Failed to create a supergroup.");
return new ChatGroup(channel.id, channel.title);
}
public async Task InviteBotToGroupAsync(ChatGroup group, string botUsername, CancellationToken cancellationToken = default)
{
if (botUsername.StartsWith('@'))
botUsername = botUsername[1..];
var resolved = await _client.Contacts_ResolveUsername(botUsername);
if (resolved.User is null)
throw new InvalidOperationException($"Could not resolve bot @{botUsername}.");
var channel = await ResolveChannelAsync(group.Id, cancellationToken);
var inputUser = new InputUser(resolved.User.id, resolved.User.access_hash);
await _client.Channels_InviteToChannel(channel, new InputUserBase[] { inputUser });
}
public async Task SendMessageAsync(ChatGroup group, string text, CancellationToken cancellationToken = default)
{
var channel = await ResolveChannelAsync(group.Id, cancellationToken);
await _client.SendMessageAsync(channel, text);
}
public async Task<IReadOnlyList<Message>> GetRecentMessagesAsync(ChatGroup group, int limit = 30, CancellationToken cancellationToken = default)
{
var channel = await ResolveChannelAsync(group.Id, cancellationToken);
var history = await _client.Messages_GetHistory(channel, limit: limit);
return history.Messages
.OfType<Message>()
.OrderByDescending(m => m.Date)
.ToList();
}
public async Task<Message?> WaitForBotReplyAsync(
ChatGroup group,
string? containsText = null,
TimeSpan? timeout = null,
CancellationToken cancellationToken = default)
{
var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(30));
while (DateTime.UtcNow < deadline)
{
cancellationToken.ThrowIfCancellationRequested();
var messages = await GetRecentMessagesAsync(group, limit: 50, cancellationToken);
var match = messages.FirstOrDefault(m =>
m.from_id is not PeerUser userPeer || userPeer.user_id != _client.UserId);
if (match is not null)
{
if (containsText is null || (match.message?.Contains(containsText, StringComparison.OrdinalIgnoreCase) ?? false))
return match;
}
await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
}
return null;
}
public async Task SendCommandAsync(ChatGroup group, string command, CancellationToken cancellationToken = default)
{
if (!command.StartsWith('/'))
command = "/" + command;
await SendMessageAsync(group, command, cancellationToken);
}
public void Dispose()
{
_client.Dispose();
}
private async Task<Channel> ResolveChannelAsync(long channelId, CancellationToken cancellationToken = default)
{
var dialogs = await _client.Messages_GetAllDialogs();
var channel = dialogs.chats.Values
.OfType<Channel>()
.FirstOrDefault(c => c.id == channelId);
return channel ?? throw new InvalidOperationException($"Could not resolve channel {channelId}.");
}
}
public sealed record ChatGroup(long Id, string Title);