diff --git a/tests/e2e/.gitignore b/tests/e2e/.gitignore
index 4e750fa..154cafd 100644
--- a/tests/e2e/.gitignore
+++ b/tests/e2e/.gitignore
@@ -1,18 +1,31 @@
-# Python cache and virtual environments
+# Python
__pycache__/
-*.pyc
-*.pyo
-*.pyd
-.env
+*.py[cod]
+*$py.class
.venv/
+env/
venv/
-# Playwright artifacts
-screenshots/
-test-results/
-playwright-report/
+# Secrets
+.env
+*.env
-# E2E runtime state
+# Telegram sessions
*.session
*.session-journal
-session-*.json
+
+# Playwright artifacts
+test-results/
+playwright-report/
+playwright/.cache/
+
+# .NET build artifacts
+bin/
+obj/
+packages.lock.json
+
+# IDE
+.vscode/
+.idea/
+*.user
+*.suo
diff --git a/tests/e2e/README.md b/tests/e2e/README.md
index d71c02e..6b7904b 100644
--- a/tests/e2e/README.md
+++ b/tests/e2e/README.md
@@ -10,8 +10,8 @@ Tracked as a Gitea milestone: [E2E Automation](https://git.codeanddice.ru/toutsu
| Issue | Title | Status |
|-------|-------|--------|
| #144 | initData / Login Widget helper for mock Telegram auth | ✅ Done |
-| #145 | Playwright tests for Blazor dashboard with mocked Telegram auth | 🚧 In progress |
-| #146 | Telegram user client (MTProto) | ⏳ Planned |
+| #145 | Playwright tests for Blazor dashboard with mocked Telegram auth | ✅ Done |
+| #146 | Telegram user client (MTProto) | 🚧 In progress |
| #147 | Automate group creation and bot invitation | ⏳ Planned |
| #148 | Scenario: /newsession from creation to publication | ⏳ Planned |
| #149 | Join/leave, waitlist, reschedule and notification scenarios | ⏳ Planned |
@@ -29,13 +29,23 @@ tests/e2e/
│ ├── telegram_init_data.py # Build valid Telegram auth payloads
│ ├── test_telegram_init_data.py # Self-contained sanity tests for the helper
│ └── __init__.py
-└── dashboard/
- ├── test_dashboard_auth_and_sessions.py # Playwright tests for the Blazor dashboard
- └── __init__.py
+├── dashboard/
+│ ├── test_dashboard_auth_and_sessions.py # Playwright tests for the Blazor dashboard
+│ └── __init__.py
+└── runner/
+ ├── GmRelay.E2E.Runner.csproj # C# console runner using WTelegramClient (MTProto)
+ ├── Program.cs # Entry point for quick manual checks
+ ├── TelegramUserClient.cs # Reusable MTProto user client wrapper
+ ├── RunnerConfig.cs # Configuration model
+ ├── .env.example # Required environment variables
+ ├── .gitignore # Ignore .env and session files
+ └── packages.lock.json # Restored lock file for the runner project
```
## Install dependencies
+### Python (dashboard tests)
+
```bash
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
@@ -43,6 +53,12 @@ pip install -r tests/e2e/requirements.txt
playwright install chromium
```
+### C# runner (MTProto)
+
+```bash
+dotnet restore tests/e2e/runner/GmRelay.E2E.Runner.csproj
+```
+
## Run helper tests
```bash
@@ -67,6 +83,26 @@ python tests/e2e/helpers/test_telegram_init_data.py
python tests/e2e/dashboard/test_dashboard_auth_and_sessions.py
```
+## Run the MTProto user client runner
+
+The runner logs in to a real Telegram user account, creates a supergroup, and invites the test bot.
+
+1. Copy the example environment file and fill in real values:
+ ```bash
+ cp tests/e2e/runner/.env.example tests/e2e/runner/.env
+ ```
+2. Edit `tests/e2e/runner/.env` with your Telegram `api_id`, `api_hash`, `phone_number`, the bot username/token, and Web URL.
+3. Run:
+ ```bash
+ dotnet run --project tests/e2e/runner/GmRelay.E2E.Runner.csproj
+ ```
+
+**Security notes:**
+- Never commit `.env` or `*.session` files.
+- Use a dedicated test Telegram account, never your personal or production account.
+- The first run will prompt for the Telegram verification code (sent to the phone number).
+- Subsequent runs reuse the persisted `.session` file.
+
## What the dashboard tests cover
- `test_dashboard_authenticates_and_shows_groups`
@@ -74,8 +110,17 @@ python tests/e2e/helpers/test_telegram_init_data.py
- `test_dashboard_session_edit_flow`
Seeds a player, group, and session directly in PostgreSQL, opens the group details page, clicks through to the session editor, changes the title, and asserts the updated title appears on the page.
+## What the MTProto runner currently covers
+
+- Login as a Telegram user.
+- Create a supergroup (`Channels_CreateChannel` with `megagroup: true`).
+- Resolve a bot by username and invite it to the group.
+- Send messages/commands and read recent messages.
+- Wait for a bot reply.
+
## Notes
- Authentication is mocked using `helpers/telegram_init_data.py`, which mirrors `GmRelay.Shared.Telegram.TelegramAuthPayloadBuilder`.
- The Web instance validates HMAC-SHA256 with the same bot token, so the test payload is indistinguishable from a real Telegram Mini App payload.
-- For headful debugging, change `headless=True` to `headless=False` in the test file.
+- The runner project is intentionally **not** included in `GM-Relay.slnx` so it does not participate in CI builds or Native AOT trimming.
+- For headful debugging, change `headless=True` to `headless=False` in the dashboard test file.
diff --git a/tests/e2e/runner/.env.example b/tests/e2e/runner/.env.example
new file mode 100644
index 0000000..eeb0de4
--- /dev/null
+++ b/tests/e2e/runner/.env.example
@@ -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
diff --git a/tests/e2e/runner/.gitignore b/tests/e2e/runner/.gitignore
new file mode 100644
index 0000000..cf74b01
--- /dev/null
+++ b/tests/e2e/runner/.gitignore
@@ -0,0 +1,5 @@
+.env
+*.session
+bin/
+obj/
+packages.lock.json
diff --git a/tests/e2e/runner/GmRelay.E2E.Runner.csproj b/tests/e2e/runner/GmRelay.E2E.Runner.csproj
new file mode 100644
index 0000000..9e90869
--- /dev/null
+++ b/tests/e2e/runner/GmRelay.E2E.Runner.csproj
@@ -0,0 +1,18 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+ preview
+ true
+ false
+
+
+
+
+
+
+
+
diff --git a/tests/e2e/runner/Program.cs b/tests/e2e/runner/Program.cs
new file mode 100644
index 0000000..bc5547f
--- /dev/null
+++ b/tests/e2e/runner/Program.cs
@@ -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.");
diff --git a/tests/e2e/runner/RunnerConfig.cs b/tests/e2e/runner/RunnerConfig.cs
new file mode 100644
index 0000000..14a0385
--- /dev/null
+++ b/tests/e2e/runner/RunnerConfig.cs
@@ -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; }
+}
diff --git a/tests/e2e/runner/TelegramUserClient.cs b/tests/e2e/runner/TelegramUserClient.cs
new file mode 100644
index 0000000..863daf4
--- /dev/null
+++ b/tests/e2e/runner/TelegramUserClient.cs
@@ -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 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().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> 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()
+ .OrderByDescending(m => m.Date)
+ .ToList();
+ }
+
+ public async Task 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 ResolveChannelAsync(long channelId, CancellationToken cancellationToken = default)
+ {
+ var dialogs = await _client.Messages_GetAllDialogs();
+ var channel = dialogs.chats.Values
+ .OfType()
+ .FirstOrDefault(c => c.id == channelId);
+
+ return channel ?? throw new InvalidOperationException($"Could not resolve channel {channelId}.");
+ }
+}
+
+public sealed record ChatGroup(long Id, string Title);