using System.Collections.Concurrent; using System.Text; using TL; using WTelegram; namespace GmRelay.E2E.Runner; public sealed class TelegramUserClient : IDisposable { private readonly Client _client; private readonly RunnerConfig _config; private readonly ConcurrentDictionary _knownChannels = new(); 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) { 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."); _knownChannels[channel.id] = channel; 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 DeleteGroupAsync(ChatGroup group, CancellationToken cancellationToken = default) { var channel = await ResolveChannelAsync(group.Id, cancellationToken); await _client.Channels_DeleteChannel(channel); _knownChannels.TryRemove(group.Id, out _); } 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 GetLatestBotMessageAsync(ChatGroup group, CancellationToken cancellationToken = default) { var messages = await GetRecentMessagesAsync(group, limit: 20, cancellationToken); return messages.FirstOrDefault(m => m.from_id is not PeerUser userPeer || userPeer.user_id != _client.UserId); } 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 async Task ClickInlineButtonAsync( ChatGroup group, string callbackData, int? messageId = null, CancellationToken cancellationToken = default) { var channel = await ResolveChannelAsync(group.Id, cancellationToken); var targetMessageId = messageId ?? (await GetLatestBotMessageAsync(group, cancellationToken))?.id ?? throw new InvalidOperationException("No bot message found to click."); await _client.Messages_GetBotCallbackAnswer( channel, targetMessageId, Encoding.UTF8.GetBytes(callbackData)); } public async Task ClickInlineButtonByTextAsync( ChatGroup group, string buttonTextContains, int? messageId = null, CancellationToken cancellationToken = default) { var message = messageId.HasValue ? (await GetRecentMessagesAsync(group, limit: 20, cancellationToken)).FirstOrDefault(m => m.id == messageId.Value) : await GetLatestBotMessageAsync(group, cancellationToken); if (message is null) throw new InvalidOperationException("No bot message found to click."); if (message.reply_markup is not ReplyInlineMarkup markup) throw new InvalidOperationException("Latest bot message has no inline keyboard."); var button = markup.rows .SelectMany(r => r.buttons) .OfType() .FirstOrDefault(b => b.text.Contains(buttonTextContains, StringComparison.OrdinalIgnoreCase)); if (button is null) throw new InvalidOperationException($"No inline button matching '{buttonTextContains}' found."); await ClickInlineButtonAsync(group, Encoding.UTF8.GetString(button.data), message.id, cancellationToken); } public void Dispose() { _client.Dispose(); } private async Task ResolveChannelAsync(long channelId, CancellationToken cancellationToken = default) { if (_knownChannels.TryGetValue(channelId, out var cached)) return cached; var dialogs = await _client.Messages_GetAllDialogs(); var channel = dialogs.chats.Values .OfType() .FirstOrDefault(c => c.id == channelId); if (channel is null) throw new InvalidOperationException($"Could not resolve channel {channelId}."); _knownChannels[channelId] = channel; return channel; } } public sealed record ChatGroup(long Id, string Title);