| | | 1 | | using System.Globalization; |
| | | 2 | | using GistBackend.Handlers.MariaDbHandler; |
| | | 3 | | using GistBackend.Handlers.TelegramBotClientHandler; |
| | | 4 | | using GistBackend.Types; |
| | | 5 | | using GistBackend.Utils; |
| | | 6 | | using Microsoft.Extensions.Hosting; |
| | | 7 | | using Microsoft.Extensions.Logging; |
| | | 8 | | using Microsoft.Extensions.Options; |
| | | 9 | | using Prometheus; |
| | | 10 | | using Telegram.Bot.Polling; |
| | | 11 | | using Telegram.Bot.Types; |
| | | 12 | | using Telegram.Bot.Types.Enums; |
| | | 13 | | using static System.Web.HttpUtility; |
| | | 14 | | using static GistBackend.Utils.LogEvents; |
| | | 15 | | using static GistBackend.Utils.ServiceUtils; |
| | | 16 | | |
| | | 17 | | namespace GistBackend.Services; |
| | | 18 | | |
| | | 19 | | public class TelegramService : BackgroundService |
| | | 20 | | { |
| | | 21 | | protected CancellationToken? ServiceCancellationToken; |
| | | 22 | | private readonly IMariaDbHandler _mariaDbHandler; |
| | | 23 | | private readonly ITelegramBotClientHandler _telegramBotClientHandler; |
| | | 24 | | private readonly string _appBaseUrl; |
| | | 25 | | private readonly ILogger<TelegramService>? _logger; |
| | | 26 | | |
| | 9 | 27 | | public TelegramService(IMariaDbHandler mariaDbHandler, |
| | 9 | 28 | | ITelegramBotClientHandler telegramBotClientHandler, |
| | 9 | 29 | | IOptions<TelegramServiceOptions> options, |
| | 9 | 30 | | ILogger<TelegramService>? logger = null) |
| | | 31 | | { |
| | 9 | 32 | | if (string.IsNullOrWhiteSpace(options.Value.AppBaseUrl)) |
| | 0 | 33 | | throw new ArgumentException("App base URL is not set in the options."); |
| | 9 | 34 | | _mariaDbHandler = mariaDbHandler; |
| | 9 | 35 | | _telegramBotClientHandler = telegramBotClientHandler; |
| | 9 | 36 | | _appBaseUrl = options.Value.AppBaseUrl; |
| | 9 | 37 | | _logger = logger; |
| | 9 | 38 | | } |
| | | 39 | | |
| | 1 | 40 | | private static readonly BotCommand StartCommand = new("start", "Register to receive messages"); |
| | 1 | 41 | | private static readonly BotCommand StopCommand = new("stop", "Unregister to stop receiving messages"); |
| | 1 | 42 | | private static readonly List<BotCommand> Commands = [StartCommand, StopCommand]; |
| | 3 | 43 | | private static readonly string AvailableCommands = string.Join(", ", Commands.Select(c => $"/{c.Command}")); |
| | | 44 | | |
| | 1 | 45 | | private static readonly Gauge ProcessChatsGauge = |
| | 1 | 46 | | Metrics.CreateGauge("process_chats_seconds", "Time spent to process all chats"); |
| | | 47 | | |
| | | 48 | | protected override async Task ExecuteAsync(CancellationToken ct) |
| | | 49 | | { |
| | 2 | 50 | | ServiceCancellationToken = ct; |
| | 2 | 51 | | _telegramBotClientHandler.StartBotClient([StartCommand, StopCommand], OnMessageAsync, OnErrorAsync, ct); |
| | 2 | 52 | | while (!ct.IsCancellationRequested) |
| | | 53 | | { |
| | 2 | 54 | | var startTime = DateTime.UtcNow; |
| | 4 | 55 | | using (new SelfReportingStopwatch(elapsed => ProcessChatsGauge.Set(elapsed))) |
| | | 56 | | { |
| | 2 | 57 | | await ProcessAllChatsAsync(ct); |
| | 2 | 58 | | } |
| | 2 | 59 | | await DelayUntilNextExecutionAsync(startTime, 1, null, ct); |
| | | 60 | | } |
| | 0 | 61 | | } |
| | | 62 | | |
| | | 63 | | protected async Task OnMessageAsync(Message message, UpdateType updateType) |
| | | 64 | | { |
| | 7 | 65 | | if (message.Type != MessageType.Text || message.Text is null) |
| | 0 | 66 | | return; |
| | 7 | 67 | | if (message.Text.StartsWith('/')) |
| | | 68 | | { |
| | 5 | 69 | | await HandleCommandAsync(message); |
| | | 70 | | } |
| | | 71 | | else |
| | | 72 | | { |
| | 2 | 73 | | _logger?.LogInformation(TelegramCommandNotRecognized, "Received message without command: {MessageText}", |
| | 2 | 74 | | message.Text); |
| | 2 | 75 | | await _telegramBotClientHandler.SendMessageAsync(message.Chat.Id, |
| | 2 | 76 | | $"Please use one of the following commands to interact with me: {AvailableCommands}"); |
| | | 77 | | } |
| | 7 | 78 | | } |
| | | 79 | | |
| | | 80 | | private async Task HandleCommandAsync(Message message) |
| | | 81 | | { |
| | 5 | 82 | | var command = message.Text![1..]; |
| | 7 | 83 | | if (command == StartCommand.Command) await HandleStartCommandAsync(message); |
| | 5 | 84 | | else if (command == StopCommand.Command) await HandleStopCommandAsync(message); |
| | | 85 | | else |
| | | 86 | | { |
| | 1 | 87 | | await _telegramBotClientHandler.SendMessageAsync(message.Chat.Id, |
| | 1 | 88 | | $"Unknown command. Please use one of the following commands: {AvailableCommands}"); |
| | | 89 | | } |
| | 5 | 90 | | } |
| | | 91 | | |
| | | 92 | | private async Task HandleStartCommandAsync(Message message) |
| | | 93 | | { |
| | 2 | 94 | | if (await _mariaDbHandler.IsChatRegisteredAsync(message.Chat.Id, ServiceCancellationToken!.Value)) |
| | | 95 | | { |
| | 1 | 96 | | _logger?.LogInformation(StartCommandButAlreadyRegistered, |
| | 1 | 97 | | "Chat {ChatId} tried to register but is already registered", message.Chat.Id); |
| | 1 | 98 | | await _telegramBotClientHandler.SendMessageAsync(message.Chat.Id, |
| | 1 | 99 | | "You are already registered. I will continue to send you gists. Happy reading!"); |
| | | 100 | | } |
| | | 101 | | else |
| | | 102 | | { |
| | 1 | 103 | | _logger?.LogInformation(StartCommandForNewChat, "Registering new chat {ChatId}", message.Chat.Id); |
| | 1 | 104 | | await _mariaDbHandler.RegisterChatAsync(message.Chat.Id, ServiceCancellationToken!.Value); |
| | 1 | 105 | | await _telegramBotClientHandler.SendMessageAsync(message.Chat.Id, |
| | 1 | 106 | | "Welcome to The Gist of IT Sec! I registered your chat. " + |
| | 1 | 107 | | "I will regularly send you gists of the freshest news of selected outlets."); |
| | | 108 | | } |
| | 2 | 109 | | } |
| | | 110 | | |
| | | 111 | | private async Task HandleStopCommandAsync(Message message) |
| | | 112 | | { |
| | 2 | 113 | | if (await _mariaDbHandler.IsChatRegisteredAsync(message.Chat.Id, ServiceCancellationToken!.Value)) |
| | | 114 | | { |
| | 1 | 115 | | _logger?.LogInformation(StopCommandForExistingChat, "Deregistering chat {ChatId}", message.Chat.Id); |
| | 1 | 116 | | await _mariaDbHandler.DeregisterChatAsync(message.Chat.Id, ServiceCancellationToken.Value); |
| | 1 | 117 | | await _telegramBotClientHandler.SendMessageAsync(message.Chat.Id, |
| | 1 | 118 | | "Such a shame to see you go. I deregistered you. Goodbye."); |
| | | 119 | | } |
| | | 120 | | else |
| | | 121 | | { |
| | 1 | 122 | | _logger?.LogInformation(StopCommandButNotRegistered, |
| | 1 | 123 | | "Chat {ChatId} tried to deregister but is not registered", message.Chat.Id); |
| | 1 | 124 | | await _telegramBotClientHandler.SendMessageAsync(message.Chat.Id, |
| | 1 | 125 | | "Seems like you were not registered to begin with. I will not send you gists."); |
| | | 126 | | } |
| | 2 | 127 | | } |
| | | 128 | | |
| | | 129 | | private Task OnErrorAsync(Exception exception, HandleErrorSource source) |
| | | 130 | | { |
| | 0 | 131 | | _logger?.LogError(UnexpectedTelegramError, exception, "An error occurred in the Telegram service: {Source}", |
| | 0 | 132 | | source); |
| | 0 | 133 | | return Task.CompletedTask; |
| | | 134 | | } |
| | | 135 | | |
| | | 136 | | private async Task ProcessAllChatsAsync(CancellationToken ct) |
| | | 137 | | { |
| | 2 | 138 | | var chats = await _mariaDbHandler.GetAllChatsAsync(ct); |
| | 2 | 139 | | var gistsToSendByGistIdLastSent = new Dictionary<int, List<ConstructedGist>>(); |
| | 9 | 140 | | foreach (var gistId in chats.Select(chat => chat.GistIdLastSent).Distinct()) |
| | | 141 | | { |
| | 1 | 142 | | gistsToSendByGistIdLastSent[gistId] = |
| | 1 | 143 | | await _mariaDbHandler.GetNextFiveConstructedGistsAsync(gistId, LanguageMode.Original, ct); |
| | | 144 | | } |
| | 10 | 145 | | foreach (var chat in chats) |
| | | 146 | | { |
| | 3 | 147 | | await SendGistsToChatAsync(chat.Id, gistsToSendByGistIdLastSent[chat.GistIdLastSent], ct); |
| | | 148 | | } |
| | 2 | 149 | | } |
| | | 150 | | |
| | | 151 | | private async Task SendGistsToChatAsync(long chatId, IEnumerable<ConstructedGist> gists, CancellationToken ct) |
| | | 152 | | { |
| | 51 | 153 | | foreach (var gist in gists) await SendGistToChatAsync(chatId, gist, ct); |
| | 3 | 154 | | } |
| | | 155 | | |
| | | 156 | | private async Task SendGistToChatAsync(long chatId, ConstructedGist constructedGist, CancellationToken ct) |
| | | 157 | | { |
| | | 158 | | try |
| | | 159 | | { |
| | 15 | 160 | | _logger?.LogInformation(SendingGistToChat, "Sending gist {GistId} to chat {ChatId}", constructedGist.Id, cha |
| | 15 | 161 | | var message = BuildGistMessage(constructedGist); |
| | 15 | 162 | | await _telegramBotClientHandler.SendMessageAsync(chatId, message, ParseMode.Html); |
| | 15 | 163 | | await _mariaDbHandler.SetGistIdLastSentForChatAsync(chatId, constructedGist.Id, ct); |
| | 15 | 164 | | } |
| | 0 | 165 | | catch (Exception ex) |
| | | 166 | | { |
| | 0 | 167 | | _logger?.LogError(SendingGistToChatFailed, ex, |
| | 0 | 168 | | "Failed to send gist {GistId} to chat {ChatId}", constructedGist.Id, chatId); |
| | 0 | 169 | | } |
| | 15 | 170 | | } |
| | | 171 | | |
| | | 172 | | private string BuildGistMessage(ConstructedGist constructedGist) |
| | | 173 | | { |
| | 15 | 174 | | var updatedString = DateTime |
| | 15 | 175 | | .ParseExact(constructedGist.Updated, "yyyy-MM-ddTHH:mm:ss.FFFFFFFZ", CultureInfo.InvariantCulture, |
| | 15 | 176 | | DateTimeStyles.AssumeUniversal).ToString("dd.MM.yyyy HH:mm 'UTC'"); |
| | 15 | 177 | | var gistUrl = $"{_appBaseUrl}/?gist={constructedGist.Id}"; |
| | 15 | 178 | | return $"<b>{HtmlEncode(constructedGist.Title)}</b>\n" + |
| | 15 | 179 | | $"{HtmlEncode(updatedString)}\n\n" + |
| | 15 | 180 | | $"{HtmlEncode(constructedGist.Summary)}\n\n" + |
| | 15 | 181 | | $"Tags: <i>{HtmlEncode(string.Join(", ", constructedGist.Tags))}</i>\n\n" + |
| | 15 | 182 | | $"{HtmlEncode(constructedGist.FeedTitle)} - {HtmlEncode(constructedGist.Author)}\n" + |
| | 15 | 183 | | $"More details: {HtmlEncode(gistUrl)}"; |
| | | 184 | | } |
| | | 185 | | } |