diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..2aeccb6 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,27 @@ +root = true + +[*] +indent_style = space +indent_size = 2 +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +# Microsoft .NET properties +csharp_preferred_modifier_order = public, private, protected, internal, file, new, static, abstract, virtual, sealed, readonly, override, extern, unsafe, volatile, async, required:suggestion +dotnet_style_qualification_for_event = false:warning +dotnet_style_qualification_for_field = false:warning +dotnet_style_qualification_for_method = false:warning +dotnet_style_qualification_for_property = false:warning + +csharp_style_namespace_declarations = file_scoped:warning + +# ReSharper properties +resharper_namespace_body = file_scoped + +[*.cs] +charset = utf-8-bom +indent_size = 4 + +[*.md] +trim_trailing_whitespace = false diff --git a/.gitignore b/.gitignore index 320fc6e..0cfc608 100644 --- a/.gitignore +++ b/.gitignore @@ -398,3 +398,4 @@ FodyWeavers.xsd *.sln.iml .idea/ +Place4.TelegramBot/appsettings.Development.json diff --git a/Place4.AppHost/Program.cs b/Place4.AppHost/Program.cs index ba89173..6063e9c 100644 --- a/Place4.AppHost/Program.cs +++ b/Place4.AppHost/Program.cs @@ -1,4 +1,6 @@ +using Projects; + var builder = DistributedApplication.CreateBuilder(args); -builder.AddProject("bot", launchProfileName: "https"); +builder.AddProject("bot", launchProfileName: "https"); builder.Build().Run(); \ No newline at end of file diff --git a/Place4.ServiceDefaults/Extensions.cs b/Place4.ServiceDefaults/Extensions.cs index 2b52e8e..f4e9ba7 100644 --- a/Place4.ServiceDefaults/Extensions.cs +++ b/Place4.ServiceDefaults/Extensions.cs @@ -1,3 +1,5 @@ +namespace Place4.ServiceDefaults; + using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Microsoft.Extensions.DependencyInjection; @@ -8,8 +10,6 @@ using OpenTelemetry.Metrics; using OpenTelemetry.Trace; -namespace Place4.ServiceDefaults; - // Adds common .NET Aspire services: service discovery, resilience, health checks, and OpenTelemetry. // This project should be referenced by each service project in your solution. // To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults diff --git a/Place4.TelegramBot/BotConfiguration.cs b/Place4.TelegramBot/BotConfiguration.cs new file mode 100644 index 0000000..eaf0efa --- /dev/null +++ b/Place4.TelegramBot/BotConfiguration.cs @@ -0,0 +1,8 @@ +namespace Place4.TelegramBot; + +public class BotConfiguration +{ + public required string BotToken { get; init; } + public required string LogChatId { get; init; } + public int LogChatMessageThreadId { get; init; } +} \ No newline at end of file diff --git a/Place4.TelegramBot/Logging/TelegramLogger.cs b/Place4.TelegramBot/Logging/TelegramLogger.cs new file mode 100644 index 0000000..fd9d3f6 --- /dev/null +++ b/Place4.TelegramBot/Logging/TelegramLogger.cs @@ -0,0 +1,25 @@ +namespace Place4.TelegramBot.Logging; + +using Telegram.Bot; + +public class TelegramLogger(string name, ITelegramBotClient telegramBotClient, BotConfiguration configuration) : ILogger +{ + public IDisposable? BeginScope(TState state) where TState: notnull => default; + + public bool IsEnabled(LogLevel logLevel) + { + return logLevel > LogLevel.Information; + } + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + if (!IsEnabled(logLevel)) + { + return; + } + telegramBotClient.SendMessage( + configuration.LogChatId, + formatter(state, exception), + messageThreadId: configuration.LogChatMessageThreadId).GetAwaiter().GetResult(); + } +} diff --git a/Place4.TelegramBot/Logging/TelegramLoggerProvider.cs b/Place4.TelegramBot/Logging/TelegramLoggerProvider.cs new file mode 100644 index 0000000..76a3351 --- /dev/null +++ b/Place4.TelegramBot/Logging/TelegramLoggerProvider.cs @@ -0,0 +1,34 @@ +namespace Place4.TelegramBot.Logging; + +using System.Collections.Concurrent; +using Microsoft.Extensions.Options; +using Telegram.Bot; + +public class TelegramLoggerProvider : ILoggerProvider +{ + private readonly ITelegramBotClient _telegramBotClient; + private readonly IDisposable? _onChangeToken; + private BotConfiguration _currentConfig; + private readonly ConcurrentDictionary _loggers = + new(StringComparer.OrdinalIgnoreCase); + + public TelegramLoggerProvider( + IOptionsMonitor config, + ITelegramBotClient telegramBotClient) + { + _telegramBotClient = telegramBotClient; + _currentConfig = config.CurrentValue; + _onChangeToken = config.OnChange(updatedConfig => _currentConfig = updatedConfig); + } + + public ILogger CreateLogger(string categoryName) => + _loggers.GetOrAdd(categoryName, name => new TelegramLogger(name, _telegramBotClient, GetCurrentConfig())); + + private BotConfiguration GetCurrentConfig() => _currentConfig; + + public void Dispose() + { + _loggers.Clear(); + _onChangeToken?.Dispose(); + } +} \ No newline at end of file diff --git a/Place4.TelegramBot/Program.cs b/Place4.TelegramBot/Program.cs index 7a9b670..9b7db73 100644 --- a/Place4.TelegramBot/Program.cs +++ b/Place4.TelegramBot/Program.cs @@ -1,10 +1,30 @@ +using Microsoft.Extensions.Options; using Place4.TelegramBot; +using Place4.TelegramBot.Logging; +using Place4.TelegramBot.UpdateHandlers; +using Telegram.Bot; +using Telegram.Bot.Polling; var builder = WebApplication.CreateBuilder(args); // Add services to the container. // Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi builder.Services.AddOpenApi(); +builder.Services.AddHttpClient("telegram_bot_client").RemoveAllLoggers() + .AddTypedClient((httpClient, sp) => + { + var botConfiguration = sp.GetService>()?.Value; + ArgumentNullException.ThrowIfNull(botConfiguration); + TelegramBotClientOptions options = new(botConfiguration.BotToken); + return new TelegramBotClient(options, httpClient); + }); +builder.Services.AddHostedService(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +builder.Services.Configure(builder.Configuration.GetSection("BotConfiguration")); +builder.Services.AddSingleton(); var app = builder.Build(); diff --git a/Place4.TelegramBot/TelegramPollingJob.cs b/Place4.TelegramBot/TelegramPollingJob.cs new file mode 100644 index 0000000..e81b47f --- /dev/null +++ b/Place4.TelegramBot/TelegramPollingJob.cs @@ -0,0 +1,41 @@ +namespace Place4.TelegramBot; + +using Telegram.Bot; +using Telegram.Bot.Polling; +using Telegram.Bot.Types.Enums; + +public class TelegramPollingJob( + ITelegramBotClient telegramBotClient, + IUpdateHandler updateHandler, + ILogger logger) : + BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + logger.LogInformation("Starting polling service"); + await DoWork(stoppingToken); + } + + private async Task DoWork(CancellationToken stoppingToken) + { + var receiverOptions = new ReceiverOptions + { + AllowedUpdates = [UpdateType.Message, UpdateType.ChatMember], + DropPendingUpdates = true + }; + // Make sure we receive updates until Cancellation Requested + while (!stoppingToken.IsCancellationRequested) + { + try + { + await telegramBotClient.ReceiveAsync(updateHandler, receiverOptions, stoppingToken); + } + catch (Exception ex) + { + logger.LogError("Polling failed with exception: {Exception}", ex); + // Cooldown if something goes wrong + await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken); + } + } + } +} \ No newline at end of file diff --git a/Place4.TelegramBot/UpdateHandler.cs b/Place4.TelegramBot/UpdateHandler.cs new file mode 100644 index 0000000..e0458ae --- /dev/null +++ b/Place4.TelegramBot/UpdateHandler.cs @@ -0,0 +1,30 @@ +namespace Place4.TelegramBot; + +using Telegram.Bot; +using Telegram.Bot.Polling; +using Telegram.Bot.Types; +using UpdateHandlers; + +public class UpdateHandler(IEnumerable updateHandlers, ILogger logger) : IUpdateHandler +{ + public async Task HandleUpdateAsync(ITelegramBotClient botClient, Update update, CancellationToken cancellationToken) + { + foreach (var updateHandler in updateHandlers) + { + try + { + await updateHandler.HandleUpdateAsync(botClient, update, cancellationToken); + } + catch (Exception ex) + { + await HandleErrorAsync(botClient, ex, HandleErrorSource.HandleUpdateError, cancellationToken); + } + } + } + + public Task HandleErrorAsync(ITelegramBotClient botClient, Exception exception, HandleErrorSource source, CancellationToken cancellationToken) + { + logger.LogError(exception, "Error occured in {Source}", source); + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/Place4.TelegramBot/UpdateHandlers/ITelegramUpdateHandler.cs b/Place4.TelegramBot/UpdateHandlers/ITelegramUpdateHandler.cs new file mode 100644 index 0000000..0f19c5f --- /dev/null +++ b/Place4.TelegramBot/UpdateHandlers/ITelegramUpdateHandler.cs @@ -0,0 +1,9 @@ +namespace Place4.TelegramBot.UpdateHandlers; + +using Telegram.Bot; +using Telegram.Bot.Types; + +public interface ITelegramUpdateHandler +{ + Task HandleUpdateAsync(ITelegramBotClient telegramBotClient, Update update, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/Place4.TelegramBot/UpdateHandlers/NewUserUpdateHandler.cs b/Place4.TelegramBot/UpdateHandlers/NewUserUpdateHandler.cs new file mode 100644 index 0000000..f54e880 --- /dev/null +++ b/Place4.TelegramBot/UpdateHandlers/NewUserUpdateHandler.cs @@ -0,0 +1,37 @@ +namespace Place4.TelegramBot.UpdateHandlers; + +using Telegram.Bot; +using Telegram.Bot.Types; +using Telegram.Bot.Types.Enums; + +public class NewUserUpdateHandler : ITelegramUpdateHandler +{ + public async Task HandleUpdateAsync(ITelegramBotClient telegramBotClient, Update update, CancellationToken cancellationToken) + { + var newUserUpdate = IsNewUserUpdate(update); + if (newUserUpdate.chat == null || newUserUpdate.user == null) + { + return; + } + + var chatId = newUserUpdate.chat.Id; + var message = $"Welcome, [{newUserUpdate.user.FirstName} {newUserUpdate.user.LastName}](tg://user?id={newUserUpdate.user.Id})"; + await telegramBotClient.SendMessage( + chatId, + message, + cancellationToken: cancellationToken, + parseMode: ParseMode.Markdown); + } + + private static (Chat? chat, User? user) IsNewUserUpdate(Update update) + { + var isFakeNewMember = update is { Type: UpdateType.Message, Message.Text: "I am new" }; // for test only + var isNewMember = update is { Type: UpdateType.ChatMember, ChatMember.NewChatMember: not null }; + if (isNewMember) + { + return (update.ChatMember?.Chat, update.ChatMember?.NewChatMember?.User); + } + + return isFakeNewMember ? (update.Message?.Chat, update.Message?.From) : (null, null); + } +} diff --git a/Place4.TelegramBot/UpdateHandlers/ParrotUpdateHandler.cs b/Place4.TelegramBot/UpdateHandlers/ParrotUpdateHandler.cs new file mode 100644 index 0000000..e101538 --- /dev/null +++ b/Place4.TelegramBot/UpdateHandlers/ParrotUpdateHandler.cs @@ -0,0 +1,25 @@ +namespace Place4.TelegramBot.UpdateHandlers; + +using Telegram.Bot; +using Telegram.Bot.Types; +using Telegram.Bot.Types.Enums; + +public class ParrotUpdateHandler : ITelegramUpdateHandler +{ + public async Task HandleUpdateAsync(ITelegramBotClient telegramBotClient, Update update, CancellationToken cancellationToken) + { + if (update.Type != UpdateType.Message || update.Message?.From == null) + { + return; + } + var chatId = update.Message.Chat.Id; + var message = $"Hi, [{update.Message.From.FirstName} {update.Message.From.LastName}](tg://user?id={update.Message.From.Id}). You said: {update.Message.Text}"; + await telegramBotClient.SendMessage( + chatId, + message, + cancellationToken: cancellationToken, + messageThreadId: update.Message.MessageThreadId, + parseMode: ParseMode.Markdown); + } + +} \ No newline at end of file diff --git a/Place4.TelegramBot/appsettings.json b/Place4.TelegramBot/appsettings.json index 10f68b8..45d64e9 100644 --- a/Place4.TelegramBot/appsettings.json +++ b/Place4.TelegramBot/appsettings.json @@ -5,5 +5,10 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "BotConfiguration": { + "BotToken": "your_token", + "LogChatId": "your_chat_id", + "LogChatMessageThreadId": 0 + } }