diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f97bf7..57093c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,6 +67,7 @@ - Event handlers are still invoked without an await, so that a slow subscriber cannot hold up other updates — but a failure inside one is now logged instead of being lost with the unobserved task. - The `FastBotTemplate` console template held the process open with an empty `while(true) { }`, which spins a CPU core at 100% for as long as the bot runs. It now awaits `Task.Delay(Timeout.Infinite)`. In the same template `bot.StartAsync()` was fire-and-forget (`_ = ...`), so a failure while the bot was starting vanished without a trace; it is awaited now. - The confirmation example in `ConsoleExample` wrapped a button whose header was `ExampleThree`, but the handler for that header reads `EntityTCommand` while the button carried `EntityTCommand`. Pressing "Yes" therefore threw a `JsonException` inside the converter, which logged it and returned null, so the handler quietly did nothing. The example now uses `ExampleTwo`, whose handler reads the type the button actually carries. +- `ValidateTelegramBotAttribute` in the ASP.NET webhook example accepted every request. A stray semicolon after the `if` turned the secret-token comparison into an empty statement, so `return true` ran unconditionally on the first bot in the list — any request carrying the header passed the filter, whatever the header held. `BotController` compares the token again before handling an update, so the shipped example was not itself exploitable, but the filter is what people copy into their own projects, and on its own it protected nothing. The same code was printed on the webhook page of the documentation. - `FileInlineConverter(string path)` ignored the folder name it was given and always used a folder literally called `path`. It now uses the name passed in, and rejects an empty one. Anyone who used this constructor was writing to the wrong folder; after upgrading, the inline payloads move to the folder they asked for, so any confirmation still pending at the moment of the upgrade will not be found. - `InlineConfirmation.ActionWithConfirmation` dereferenced the parsed command without checking it. When the callback data could not be parsed — for example when a handler reads a different `EntityTCommand` than the button carries — the converter returned null and the handler threw a `NullReferenceException` into the log instead of telling the user anything. It now falls through to the same "something went wrong" message as an unknown confirmation. - Removed a duplicated byte order mark from 32 source files. It was introduced by the tooling used for the comment translation; the files compiled, but each one began with a stray zero-width character. diff --git a/CHANGELOG.ru.md b/CHANGELOG.ru.md index 87c4197..937ba63 100644 --- a/CHANGELOG.ru.md +++ b/CHANGELOG.ru.md @@ -67,6 +67,7 @@ - Обработчики событий по-прежнему вызываются без `await`, чтобы медленный подписчик не задерживал остальные update, — но сбой внутри такого обработчика теперь логируется, а не теряется вместе с необслуженной задачей. - Консольный шаблон `FastBotTemplate` удерживал процесс пустым `while(true) { }`, который всё время работы бота крутит ядро процессора на 100%. Теперь используется `await Task.Delay(Timeout.Infinite)`. Там же `bot.StartAsync()` вызывался без ожидания (`_ = ...`), поэтому сбой при запуске бота пропадал бесследно; теперь вызов ожидается. - Пример подтверждения в `ConsoleExample` оборачивал кнопку с заголовком `ExampleThree`, но обработчик этого заголовка читает `EntityTCommand`, тогда как кнопка несла `EntityTCommand`. Нажатие «Yes» из-за этого приводило к `JsonException` внутри конвертера, тот его логировал и возвращал null, и обработчик молча ничего не делал. Теперь пример использует `ExampleTwo`, чей обработчик читает тот тип, который кнопка и передаёт. +- `ValidateTelegramBotAttribute` в примере с webhook на ASP.NET пропускал любой запрос. Лишняя точка с запятой после `if` превращала сравнение секретного токена в пустой оператор, поэтому `return true` выполнялся безусловно на первом же боте из списка — фильтр проходил любой запрос с этим заголовком, каким бы ни было его значение. `BotController` сверяет токен ещё раз перед обработкой update, поэтому сам пример проэксплуатировать было нельзя, но именно фильтр люди копируют к себе в проекты, а сам по себе он не защищал ни от чего. Тот же код был напечатан на странице документации про webhook. - `FileInlineConverter(string path)` игнорировал переданное имя папки и всегда использовал папку с буквальным именем `path`. Теперь используется переданное имя, а пустое отвергается. Кто пользовался этим конструктором, писал не в ту папку; после обновления данные inline-кнопок переедут в запрошенную папку, поэтому подтверждения, ожидавшие ответа на момент обновления, найдены не будут. - `InlineConfirmation.ActionWithConfirmation` обращался к разобранной команде без проверки. Если callback_data разобрать не удавалось — например, когда обработчик читает не тот `EntityTCommand`, который несёт кнопка, — конвертер возвращал null, а обработчик писал в лог `NullReferenceException` вместо того, чтобы что-то сказать пользователю. Теперь это приводит к тому же сообщению «что-то пошло не так», что и неизвестное подтверждение. - Из 32 файлов исходников убран продублированный BOM. Он появился из-за инструмента, которым переводились комментарии; файлы компилировались, но каждый начинался с лишнего символа нулевой ширины. diff --git a/Examples/AspNetWebHookExample/Filter/ValidateTelegramBotAttribute.cs b/Examples/AspNetWebHookExample/Filter/ValidateTelegramBotAttribute.cs index c8ec825..d35b86b 100644 --- a/Examples/AspNetWebHookExample/Filter/ValidateTelegramBotAttribute.cs +++ b/Examples/AspNetWebHookExample/Filter/ValidateTelegramBotAttribute.cs @@ -48,7 +48,7 @@ private bool IsValidRequest(HttpRequest request) foreach (var bot in bots) { var secretToken = bot.Options.WebHookOptions.SecretToken; - if (string.Equals(secretTokenHeader, secretToken, StringComparison.Ordinal)); + if (string.Equals(secretTokenHeader, secretToken, StringComparison.Ordinal)) return true; } return false; diff --git a/docs/en/SUMMARY.md b/docs/en/SUMMARY.md index 285cfe9..592054d 100644 --- a/docs/en/SUMMARY.md +++ b/docs/en/SUMMARY.md @@ -1,4 +1,8 @@ # Table of contents * [PRTelegramBot](README.md) +* [F.A.Q.](faq.md) * [Migrating to 1.0](migrating-to-1.0.md) +* [Getting started](getting-started/README.md) + * [Webhook](getting-started/webhook/README.md) + * [Debugging a webhook](getting-started/webhook/debugging-webhook.md) diff --git a/docs/en/faq.md b/docs/en/faq.md new file mode 100644 index 0000000..c1895f1 --- /dev/null +++ b/docs/en/faq.md @@ -0,0 +1,43 @@ +--- +description: Problems people run into most often, and what fixes them. +--- + +# F.A.Q. + +## The bot fails to start with "404 not found" + +The token is not valid. Check that it was copied from BotFather in full, with no stray whitespace, and that it still belongs to an existing bot. + +## The bot ignores inline commands, or does not respond at all + +Several people have hit this and the fix each time was to regenerate the bot's token in BotFather with **/revoke**, then use the new one. + +If only *some* commands are ignored, check the **BotId** instead: a handler attribute with a bot id that does not match the one on the builder is simply never called. + +## "Unable to find package Telegram.Bot with version xxx" + +From version 20 the Telegram.Bot team published to their own feed at `https://nuget.voids.site/`, and NuGet could not find the package on nuget.org. + +This is no longer relevant — from version 22 the package is back on nuget.org, and PRTelegramBot 1.0.0 uses Telegram.Bot 22.10.2.1. + +If you are pinned to an older version and hit this, add the extra source once: + +```sh +dotnet nuget add source https://nuget.voids.site/v3/index.json -n voids +``` + +In Visual Studio the same setting lives under **Tools → Options → NuGet Package Manager → Package Sources**. + +## A callback button does nothing, and the log shows a JsonException + +Something is reading the callback data as a different type than the button carries. `EntityTCommand` and `EntityTCommand` serialise the identifier differently, so a handler expecting one and a button sending the other cannot be parsed: + +``` +System.Text.Json.JsonException: The JSON value could not be converted to System.String. Path: $.d.1 +``` + +The converter catches that, logs it and returns `null`, so the handler quietly does nothing. Check that the type in `context.GetCommandByCallbackOrNull()` matches the type used when the button was built. + +## Where do I report a bug or ask a question? + +Questions go to the [Telegram chat](https://t.me/prethinkdev). Bugs and feature requests go to [GitHub issues](https://github.com/prethink/PRTelegramBot/issues). diff --git a/docs/en/getting-started/README.md b/docs/en/getting-started/README.md new file mode 100644 index 0000000..65f9bc8 --- /dev/null +++ b/docs/en/getting-started/README.md @@ -0,0 +1,107 @@ +--- +description: Everything you need to do to get your first bot running. +--- + +# Getting started + +## Create a bot in BotFather + +Every Telegram bot is registered through [@BotFather](https://t.me/botfather), the official Telegram service for that. + +1. Open Telegram and find **BotFather**. +2. Start the conversation with **/start**. +3. Send **/newbot** to create a new bot. +4. Give the bot a name and a username when asked. +5. BotFather replies with an access token, something like `1234567890:ABCDEFGHIJKLMNOPQRSTUVXYZ`. +6. Copy it. That token is unique to your bot and is what authenticates every call to the Telegram API. + +{% hint style="warning" %} +Keep the token out of version control. Use [user secrets](https://learn.microsoft.com/en-us/aspnet/core/security/app-secrets), environment variables, or a configuration file that is excluded from the repository. +{% endhint %} + +## Install the package + +The library targets .NET 6.0 and runs on any newer version. + +```sh +dotnet new console -o MyBot +cd MyBot +dotnet add package PRTelegramBot +``` + +Or install **PRTelegramBot** from the NuGet package manager in your IDE. The package page is [here](https://www.nuget.org/packages/PRTelegramBot), and the source is on [GitHub](https://github.com/prethink/PRTelegramBot). + +## Start the bot + +```csharp +using PRTelegramBot.Builders; +using PRTelegramBot.Models.EventsArgs; + +// A PRTelegramBot instance. +var telegram = new PRBotBuilder("Token").Build(); + +// Ordinary log messages. +telegram.Events.OnCommonLog += Telegram_OnLogCommon; +// Errors. +telegram.Events.OnErrorLog += Telegram_OnLogError; + +// Start the bot. +await telegram.StartAsync(); + +async Task Telegram_OnLogError(ErrorLogEventArgs e) +{ + // Handle errors. +} + +async Task Telegram_OnLogCommon(CommonLogEventArgs e) +{ + // Handle log messages. +} +``` + +Everything the builder can configure — admins, white lists, middleware, converters, background tasks, webhook settings — is passed through `PRBotBuilder`. That page has not been translated yet; until it is, see the Russian [PRBotBuilder](https://prethink.gitbook.io/prtelegrambot/prbotbuilder-sozdanie-botov) page, where the code is language-neutral. + +## Add a command + +A handler is an ordinary method marked with an attribute. Nothing registers it by hand — the framework finds it by reflection when the bot starts, so adding a command means adding a method. + +```csharp +using PRTelegramBot.Attributes; +using PRTelegramBot.Interfaces; +using PRTelegramBot.Services.Messages; + +public static class Commands +{ + // Runs when the user sends /start. + [SlashHandler("/start")] + public static async Task Start(IBotContext context) + { + await MessageSender.Send(context, "Hello, World!"); + } + + // Runs when the message text is exactly "Ping", ignoring case. + [ReplyMenuHandler("Ping")] + public static async Task Ping(IBotContext context) + { + await MessageSender.Send(context, "Pong"); + } +} +``` + +Run the project, send `/start` to your bot, and it answers. + +By default updates arrive through [polling](https://core.telegram.org/bots/faq#how-do-i-get-updates), which needs no public address and is the quickest way to start developing. Running behind a public URL instead is described under [Webhook](webhook/). + +## Several bots in one project + +One project can run any number of bots. They are told apart by **BotId**, which you set on the builder and repeat on the handler attributes. + +You might run five bots that all do the same thing, or five that each do something different — both work. + +## Examples + +| Example | What it shows | +| --- | --- | +| [Console](https://github.com/prethink/PRTelegramBot/tree/master/Examples/ConsoleExample) | Most of the framework in one place: commands of every kind, menus, events, middleware, background tasks. Start here. | +| [ASP.NET](https://github.com/prethink/PRTelegramBot/tree/master/Examples/AspNetExample) | A bot inside ASP.NET Core with everything resolved through dependency injection. Polling. | +| [ASP.NET webhook](https://github.com/prethink/PRTelegramBot/tree/master/Examples/AspNetWebHookExample) | Two bots on a single webhook endpoint, told apart by their secret token. | diff --git a/docs/en/getting-started/webhook/README.md b/docs/en/getting-started/webhook/README.md new file mode 100644 index 0000000..93174b3 --- /dev/null +++ b/docs/en/getting-started/webhook/README.md @@ -0,0 +1,235 @@ +--- +description: Running a bot behind a public URL instead of polling. +--- + +# Webhook + +The example below is based on the [ASP.NET webhook example](https://github.com/prethink/PRTelegramBot/tree/master/Examples/AspNetWebHookExample), which runs two bots on a single endpoint. + +A webhook bot needs a **secret token**. It is the only thing that proves a request really came from Telegram. If you do not set one on the builder, the framework generates it for you. + +## Program.cs + +```csharp +... +builder.Services.AddControllers().AddNewtonsoftJson(); +... +new PRBotBuilder("5623652365:Token") + .UseFactory(new PRBotWebHookFactory()) + .SetUrlWebHook("https://domain.ru/botendpoint") + .SetClearUpdatesOnStart(true) + .Build(); +// The bot instance can be found later through the BotCollection class. +... +// The service that starts the bots once the application is up. +builder.Services.AddHostedService(); +... +// Registers the route that receives updates over the webhook. +// With the code above that is https://domain.ru/botendpoint — +// it must match the URL passed to SetUrlWebHook. +app.MapBotWebhookRoute("/botendpoint"); +... +app.Run(); +``` + +## WebHookExtensions.cs + +```csharp +using Microsoft.AspNetCore.Mvc; + +namespace AspNetWebHook +{ + /// + /// Extension methods for routing webhooks. + /// + public static class WebHookExtensions + { + /// + /// Maps a webhook route to the given controller action. + /// + /// Controller type. + /// The object the route is added to. + /// Route template. + /// A builder for configuring the controller action endpoint. + public static ControllerActionEndpointConventionBuilder MapBotWebhookRoute(this IEndpointRouteBuilder endpoints, string route) + where TContoller : Controller + { + // The controller name without the "Controller" suffix. + var controllerName = typeof(TContoller).Name.Replace("Controller", "", StringComparison.Ordinal); + + // The method that will handle the route. + var actionName = typeof(TContoller).GetMethods()[0].Name; + + return endpoints.MapControllerRoute( + name: "bot_webhook", + pattern: route, + defaults: new { controller = controllerName, action = actionName }); + } + } +} +``` + +## Constants.cs + +```csharp +public class Constants +{ + /// + /// The request header carrying the secret token. + /// + public const string TELEGRAM_SECRET_TOKEN_HEADER = "X-Telegram-Bot-Api-Secret-Token"; +} +``` + +## BotHostedService.cs + +Starts the bots once the application has started. + +```csharp +public class BotHostedService : IHostedService +{ + private readonly IServiceProvider serviceProvider; + + public BotHostedService(IServiceProvider serviceProvider) + { + this.serviceProvider = serviceProvider; + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + StartBots(); + } + + private async Task StartBots() + { + // A short delay before starting, just in case. + await Task.Delay(2000); + var bots = BotCollection.Instance.GetBots(); + foreach (var bot in bots) + { + // Pass the serviceProvider through for DI. + bot.Options.ServiceProvider = serviceProvider; + // Refresh the handlers, just in case. + bot.ReloadHandlers(); + await bot.StartAsync(); + + if (bot.DataRetrieval == DataRetrievalMethod.WebHook) + { + // For a webhook bot, report a failure through the log. + var webHookResult = await ((PRBotWebHook)bot).GetWebHookInfo(); + if (!string.IsNullOrEmpty(webHookResult.LastErrorMessage)) + bot.Events.OnErrorLogInvoke(new Exception(webHookResult.LastErrorMessage)); + } + } + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + var bots = BotCollection.Instance.GetBots(); + foreach (var bot in bots) + { + await bot.Stop(); + } + } +} +``` + +## ValidateTelegramBotAttribute.cs + +```csharp +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using PRTelegramBot.Configs; +using PRTelegramBot.Core; +using PRTelegramBot.Models.Enums; + +namespace AspNetWebHook.Filter +{ + /// + /// Checks the "X-Telegram-Bot-Api-Secret-Token" header while handling a webhook request. + /// See , "secret_token". + /// + [AttributeUsage(AttributeTargets.Method)] + public sealed class ValidateTelegramBotAttribute : TypeFilterAttribute + { + public ValidateTelegramBotAttribute() : base(typeof(ValidateTelegramBotFilter)) { } + + private class ValidateTelegramBotFilter : IActionFilter + { + public ValidateTelegramBotFilter() { } + + public void OnActionExecuted(ActionExecutedContext context) { } + + public void OnActionExecuting(ActionExecutingContext context) + { + if (!IsValidRequest(context.HttpContext.Request)) + { + context.Result = new ObjectResult($"\"{Constants.TELEGRAM_SECRET_TOKEN_HEADER}\" is invalid") + { + StatusCode = 403 + }; + } + } + + /// + /// Validates the secret token of an incoming webhook request. + /// + /// The request. + /// True if the request is valid, False otherwise. + private bool IsValidRequest(HttpRequest request) + { + var bots = BotCollection.Instance.GetBots().Where(x => x.DataRetrieval == DataRetrievalMethod.WebHook); + if (!bots.Any()) + return false; + + var isSecretTokenProvided = request.Headers.TryGetValue(Constants.TELEGRAM_SECRET_TOKEN_HEADER, out var secretTokenHeader); + if (!isSecretTokenProvided) return false; + + foreach (var bot in bots) + { + var secretToken = bot.Options.WebHookOptions.SecretToken; + if (string.Equals(secretTokenHeader, secretToken, StringComparison.Ordinal)) + return true; + } + return false; + } + } + } +} +``` + +{% hint style="warning" %} +Note the shape of that last comparison. A stray semicolon after the `if` turns the check into an empty statement and makes `return true` unconditional — the filter would then accept any request that merely carries the header, whatever its value. This exact typo lived in the example project until version 1.0.0. +{% endhint %} + +## BotController.cs + +```csharp +public class BotController : Controller +{ + [HttpPost] + [ValidateTelegramBot] + public async Task Post([FromBody] Update update) + { + // Read the secret token, if present. + if (Request.Headers.TryGetValue(Constants.TELEGRAM_SECRET_TOKEN_HEADER, out var secretTokenHeader)) + { + // Only the webhook bots. + var webHookbots = BotCollection.Instance.GetBots().Where(x => x.DataRetrieval == DataRetrievalMethod.WebHook); + foreach (var bot in webHookbots) + { + // Compare the secret tokens; on a match, handle the update. + var secretToken = bot.Options.WebHookOptions.SecretToken; + if (string.Equals(secretTokenHeader, secretToken, StringComparison.Ordinal)) + { + await bot.Handler.HandleUpdateAsync(bot.BotClient, update, bot.Options.CancellationTokenSource.Token); + return Ok(); + } + } + } + return BadRequest(); + } +} +``` + +This is what lets several bots share one endpoint: the secret token decides which bot an update belongs to. diff --git a/docs/en/getting-started/webhook/debugging-webhook.md b/docs/en/getting-started/webhook/debugging-webhook.md new file mode 100644 index 0000000..de82bc1 --- /dev/null +++ b/docs/en/getting-started/webhook/debugging-webhook.md @@ -0,0 +1,38 @@ +--- +description: Testing a webhook bot from your development machine. +--- + +# Debugging a webhook + +A webhook needs a public HTTPS address, which your development machine does not have. **ngrok** solves that: it opens a public URL and forwards every request that arrives there to a local port. + +Download it from [https://ngrok.com/download](https://ngrok.com/download). + +## Steps + +1. Start your application and note the port it listens on. +2. Run ngrok against that port: + +```sh +ngrok http 8443 +``` + +Replace `8443` with your own port. + +3. ngrok prints a **Forwarding** line with a public HTTPS address, something like `https://a1b2-c3d4.ngrok-free.app`. +4. Pass that address to the builder, adding the route you registered: + +```csharp +new PRBotBuilder("Token") + .UseFactory(new PRBotWebHookFactory()) + .SetUrlWebHook("https://a1b2-c3d4.ngrok-free.app/botendpoint") + .Build(); +``` + +5. Restart the application. Telegram now delivers updates to ngrok, which forwards them to your machine, and you can set breakpoints as usual. + +{% hint style="info" %} +On the free plan ngrok gives you a new address every restart, so the URL has to be updated in the builder each time. +{% endhint %} + +ngrok also serves a local inspector, by default at `http://127.0.0.1:4040`, where you can see every request Telegram sent and replay it — useful when you want to hit the same update again without asking a person to press the button once more. diff --git a/docs/ru/bystryi-start/README.md b/docs/ru/bystryi-start/README.md index 0f7e2e1..cbc1cbf 100644 --- a/docs/ru/bystryi-start/README.md +++ b/docs/ru/bystryi-start/README.md @@ -55,9 +55,9 @@ Telegram.Bot начиная с версии 20, стали размещать с var telegram = new PRBotBuilder("Token").Build(); //Подписка на простые логи -telegram.OnLogCommon += Telegram_OnLogCommon; +telegram.Events.OnCommonLog += Telegram_OnLogCommon; //Подписка на логи с ошибками -telegram.OnLogError += Telegram_OnLogError; +telegram.Events.OnErrorLog += Telegram_OnLogError; //Запуск бота await telegram.StartAsync(); diff --git a/docs/ru/bystryi-start/webhook/README.md b/docs/ru/bystryi-start/webhook/README.md index 71d8b2c..7319c15 100644 --- a/docs/ru/bystryi-start/webhook/README.md +++ b/docs/ru/bystryi-start/webhook/README.md @@ -187,7 +187,7 @@ namespace AspNetWebHook.Filter foreach (var bot in bots) { var secretToken = ((WebHookTelegramOptions)bot.Options).SecretToken; - if (string.Equals(secretTokenHeader, secretToken, StringComparison.Ordinal)); + if (string.Equals(secretTokenHeader, secretToken, StringComparison.Ordinal)) return true; } return false;