From d146176954691d8a2e11a8f2dfe8763e4323f00a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 19:13:56 +0000 Subject: [PATCH 1/2] =?UTF-8?q?release:=20v0.7.0-alpha=20=E2=80=94=20align?= =?UTF-8?q?=20library=20with=20MAX=20Bot=20API=20and=20fix=20reported=20is?= =?UTF-8?q?sues?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of the whole library against the MAX Bot API schemas (official Go SDK) plus fixes for all open issues. Fixes: - #34 AnswerCallbackQueryAsync sends callback_id; NewMessageBody uses AttachmentRequest[]/NewMessageLink - #38 FileUploadResult exposes Photos/FileId again, Token is filled from photos, XML CDN responses are not treated as tokens; new IFilesApi.UploadAsync helper - #22 flat {"code","message"} errors are parsed; MaxAttachmentNotReadyException; only attachment.not.ready is retried (send + edit) - #23 GetChatsAsync parses {"chats","marker"}; GetChatListAsync for pagination - #35 WebhookController is abstract (no implicit endpoint); app.MapMaxWebhook() - #36 ConfigureWebhookAsync/DeleteWebhookAsync fall back to Webhook.Endpoint/SecretToken - #37 Update.ChatId/Chat/ChatType filled from message.recipient - #15 AttachmentRequest factories (ImageFromUrl, Image, Video, File, Location, ...) - #18 #20 #21 #25 #16 regression tests and richer attachment/update models - Polling marker comes from the response (MAX updates have no update_id) - Default webhook secret header is X-Max-Bot-Api-Secret - GetPinnedMessageAsync parses {"message"}; AddChatAdminRequest sends {"admins":[...]} - Lenient enums for chat status / admin permissions, real User-Agent version - Upload retries rewind the stream - Bump Microsoft.SourceLink.GitHub to 10.0.401 (NU1902 broke restore) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JWng6Vbuo8rweWS5x8PX56 --- CHANGELOG.md | 44 ++ Directory.Build.props | 2 +- README.md | 90 ++- .../Samples/CommandBotSample.cs | 2 +- .../Samples/ContactBotSample.cs | 12 +- .../Max.Bot.Examples/Samples/FileBotSample.cs | 15 +- .../Samples/KeyboardBotSample.cs | 12 +- src/Max.Bot/Api/ChatsApi.cs | 72 ++- src/Max.Bot/Api/FilesApi.cs | 65 +- src/Max.Bot/Api/IChatsApi.cs | 16 +- src/Max.Bot/Api/IFilesApi.cs | 25 +- src/Max.Bot/Api/IMessagesApi.cs | 33 +- src/Max.Bot/Api/MessagesApi.cs | 136 +++- .../Configuration/MaxWebhookOptions.cs | 14 +- .../MaxAttachmentNotReadyException.cs | 67 ++ src/Max.Bot/Max.Bot.csproj | 2 +- src/Max.Bot/MaxClient.cs | 69 ++- src/Max.Bot/Networking/MaxHttpClient.cs | 113 +++- ...axWebhookEndpointRouteBuilderExtensions.cs | 119 ++++ src/Max.Bot/Polling/UpdatePoller.cs | 10 +- src/Max.Bot/Polling/WebhookController.cs | 90 +-- .../Polling/WebhookRequestValidator.cs | 69 +++ src/Max.Bot/Types/Attachment.cs | 126 +++- src/Max.Bot/Types/Chat.cs | 7 + src/Max.Bot/Types/ChatListResponse.cs | 21 + src/Max.Bot/Types/ChatMember.cs | 1 + .../Converters/AttachmentJsonConverter.cs | 204 +++--- .../Types/Converters/LenientEnumConverters.cs | 127 ++++ .../Types/Converters/UpdateJsonConverter.cs | 149 ++++- src/Max.Bot/Types/Enums/ChatStatus.cs | 7 +- src/Max.Bot/Types/FileUploadResult.cs | 52 +- src/Max.Bot/Types/Message.cs | 55 ++ .../Types/Requests/AddChatAdminRequest.cs | 65 +- .../Requests/AnswerCallbackQueryRequest.cs | 6 +- .../Types/Requests/AttachmentRequest.cs | 358 ++++++++++- src/Max.Bot/Types/Subscription.cs | 12 + src/Max.Bot/Types/Update.cs | 169 ++++- src/Max.Bot/Types/UpdateTypes.cs | 139 ++++- src/Max.Bot/Types/Video.cs | 12 + .../Max.Bot.Tests/Examples/SampleBotsTests.cs | 9 +- tests/Max.Bot.Tests/Unit/Api/ChatsApiTests.cs | 28 +- .../Unit/Api/MessagesApiTests.cs | 4 +- .../Unit/Polling/WebhookEndpointTests.cs | 263 ++++++++ .../Unit/Regression/IssueRegressionTests.cs | 580 ++++++++++++++++++ .../Unit/Types/AttachmentFormatTests.cs | 3 +- .../Unit/Types/AttachmentRequestTests.cs | 13 +- 46 files changed, 3065 insertions(+), 422 deletions(-) create mode 100644 src/Max.Bot/Exceptions/MaxAttachmentNotReadyException.cs create mode 100644 src/Max.Bot/Polling/MaxWebhookEndpointRouteBuilderExtensions.cs create mode 100644 src/Max.Bot/Polling/WebhookRequestValidator.cs create mode 100644 src/Max.Bot/Types/ChatListResponse.cs create mode 100644 src/Max.Bot/Types/Converters/LenientEnumConverters.cs create mode 100644 tests/Max.Bot.Tests/Unit/Polling/WebhookEndpointTests.cs create mode 100644 tests/Max.Bot.Tests/Unit/Regression/IssueRegressionTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index a8b4378..4c664bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,50 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] + +## [0.7.0-alpha] - 2026-10-11 + +Ревью всей библиотеки на соответствие MAX Bot API (схемы официального Go SDK `max-bot-api-client-go`) и исправление открытых issue. + +### Breaking Changes + +- **Webhook endpoint is opt-in** (#35): `WebhookController` стал `abstract`, поэтому подключение пакета больше не добавляет `POST /api/max/webhook` (и запись в Swagger) в каждое ASP.NET Core приложение. Подключайте эндпоинт явно через `app.MapMaxWebhook()` или унаследуйтесь от `WebhookController` с `[Route(...)]`. +- **Webhook secret header**: `MaxWebhookOptions.SignatureHeaderName` по умолчанию теперь `X-Max-Bot-Api-Secret` — именно этот заголовок присылает MAX. Раньше при заданном `SecretToken` все реальные webhook-запросы отклонялись с 401. +- **Callback answer body**: `NewMessageBody.Attachments` теперь `AttachmentRequest[]`, а `NewMessageBody.Link` — `NewMessageLink` (формат запроса `POST /answers`). +- **AttachmentRequest**: `Payload` стал nullable (у `location` нет payload), добавлены `Latitude`/`Longitude`; атрибуты `[Required]` убраны. +- **Typed updates**: `MessageRemovedUpdate.Message` и `MessageChatCreatedUpdate.Message` стали nullable — MAX присылает для этих событий идентификаторы, а не сообщение. + +### Added + +- **Upload helper** (#38): `IFilesApi.UploadAsync(type, stream, fileName)` делает `POST /uploads` + загрузку и возвращает готовый токен (для `image` — из `photos`, для `video`/`audio` — токен из `POST /uploads`). +- **Attachment factories** (#15): `AttachmentRequest.ImageFromUrl`, `Image`, `Video`, `Audio`, `File`, `FromUpload`, `Sticker`, `Contact`, `Location`, `Share`, `Keyboard` и типизированные payload-классы. +- **MapMaxWebhook** (#35): minimal-API эндпоинт `app.MapMaxWebhook(pattern)` с проверкой секрета и лимита размера тела. +- **MaxAttachmentNotReadyException** (#22): отдельное исключение для ошибки `attachment.not.ready`. +- **Messages pagination** (#18): `GetMessagesAsync(chatId, count, from, to)` и `GetMessagesByIdsAsync(messageIds)`. +- **Chats pagination** (#23): `GetChatListAsync(count, marker)` возвращает `ChatListResponse` с `marker` следующей страницы. +- **Chat info in updates** (#37): `Update.ChatId`, `Update.Chat` (частично: id и тип) и `Update.ChatType` заполняются из `message.recipient` для `message_created`, `message_edited` и `message_callback`. +- **Update fields**: `message_id`, `user_id`, `admin_id`, `title`, `is_channel`, `muted_until`, `start_payload` и соответствующие свойства в типизированных обёртках (`MessageRemovedUpdate`, `UserRemovedUpdate`, `ChatTitleChangedUpdate`, `BotAddedUpdate` и др.). +- **Attachments**: `StickerAttachment`, `ShareAttachment`; `Token` у фото/видео/аудио/файлов, `VideoAttachment.Thumbnail`, `AudioAttachment.Transcription`. +- **Models**: `MessageBody.Markup`, `MessageStat.Views`, `Chat.MessagesCount`, `ChatStatus.Suspended`, `Subscription.Time`/`Version`, `ChatAdmin` для назначения администраторов. + +### Fixed + +- **Callback answers** (#34): `AnswerCallbackQueryAsync` передаёт `callback_id` вместо устаревшего `callback_query_id`. +- **Image upload token** (#38): `FileUploadResult` снова содержит типизированные `Photos` и `FileId`, а `Token` заполняется из `photos`; XML-ответ CDN (`1`) больше не считается токеном. +- **API error parsing** (#22): ошибки формата `{"code": "...", "message": "..."}` разбираются в `MaxApiException.ErrorCode`/`Message` вместо сырого JSON в тексте исключения. +- **Attachment retry**: повтор отправки/редактирования выполняется только для `attachment.not.ready` (раньше повторялся любой `400 Bad Request`); добавлен повтор для `EditMessageAsync`. +- **Get chats** (#23): `GetChatsAsync` корректно разбирает ответ `{"chats": [...], "marker": ...}` вместо исключения `The JSON value could not be converted to Max.Bot.Types.Chat[]`. +- **Pinned message**: `GetPinnedMessageAsync` разбирает ответ `{"message": ...}` и возвращает `null`, если ничего не закреплено. +- **Add chat admins**: `AddChatAdminRequest` сериализуется как `{"admins": [{"user_id", "permissions", "alias"}]}`. +- **Incoming attachments** (#20, #21, #25): поля из `payload` и корня вложения объединяются — у файлов теперь заполняются `FileName`/`FileSize` (`filename`/`size`), у видео — размеры, длительность и превью. +- **Long polling marker**: следующий запрос `GET /updates` использует `marker` из ответа. Раньше подставлялся `update_id`, которого нет в MAX API (всегда `0`). +- **Webhook configuration** (#36): `ConfigureWebhookAsync()` и `DeleteWebhookAsync()` берут URL из `MaxWebhookOptions.Endpoint`, а секрет — из `MaxWebhookOptions.SecretToken`, если они не переданы явно. +- **Upload retries**: при повторе `UploadFileDataAsync` поток перематывается в начало, чтобы не отправлять пустой файл. +- **Unknown enum values**: неизвестные статусы чата и права администраторов больше не ломают десериализацию ответа. +- **User-Agent**: заголовок содержит фактическую версию пакета вместо зашитой `0.5.1-alpha`. +- **Build**: `Microsoft.SourceLink.GitHub` обновлён до `10.0.401` — версия `8.0.0` тянула уязвимый `Microsoft.Build.Tasks.Git` (GHSA-23fw-v26w-5fgq), из-за чего `dotnet restore` падал с `NU1902` при `TreatWarningsAsErrors`. +- **Samples**: исправлена испорченная кодировка текстов в примерах; `FileBotSample` использует `UploadAsync`. + ## [0.6.3-alpha] - 2026-06-08 ### Fixed diff --git a/Directory.Build.props b/Directory.Build.props index a750bf1..c00272f 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -22,7 +22,7 @@ - + diff --git a/README.md b/README.md index 122a627..cbf7ff0 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Max.Bot — C# библиотека для Max Messenger Bot API [![.NET](https://img.shields.io/badge/.NET-10%20%7C%209%20%7C%208-purple.svg)](https://dotnet.microsoft.com/) -[![NuGet](https://img.shields.io/badge/nuget-v0.6.1--alpha-blue)](https://www.nuget.org/packages/MaxMessenger.Bot) -[![Build](https://img.shields.io/github/actions/workflow/status/MaxBotNet/MaxBotNet/ci.yml?label=CI)](https://github.com/MaxBotNet/MaxBotNet/actions) +[![NuGet](https://img.shields.io/nuget/vpre/MaxMessenger.Bot)](https://www.nuget.org/packages/MaxMessenger.Bot) +[![Build](https://img.shields.io/github/actions/workflow/status/NanoAgents/MaxBotNet/ci.yml?label=CI)](https://github.com/NanoAgents/MaxBotNet/actions) [![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE) Полнофункциональная библиотека для работы с [Max Messenger Bot API](https://dev.max.ru/docs-api) на .NET 10, .NET 9 и .NET 8. Проект фокусируется на типобезопасности, удобстве интеграции и масштабируемости корпоративных ботов. @@ -18,30 +18,94 @@ ## ⚡ Быстрый старт ```powershell -dotnet add package MaxMessenger.Bot +dotnet add package MaxMessenger.Bot --prerelease ``` ```csharp using Max.Bot; using Max.Bot.Configuration; +using Max.Bot.Polling; using Max.Bot.Types.Enums; -var client = new MaxClient(new MaxBotOptions +using var client = new MaxClient(new MaxBotOptions { - Token = Environment.GetEnvironmentVariable("MAX_BOT_TOKEN")!, - DispatchAllowedUpdateTypes = new[] { UpdateType.Message } + Token = Environment.GetEnvironmentVariable("MAX_BOT_TOKEN")! }); var me = await client.Bot.GetMeAsync(); Console.WriteLine($"Hello from {me.Username}"); -await client.Messages.SendMessageAsync( - chatId: 1234567890, - text: "It works! ✅"); +var handler = new DelegatingUpdateHandler( + onMessage: async (context, ct) => + { + var update = context.Update; + if (update.Type != UpdateType.MessageCreated || update.ChatId is not { } chatId) + { + return; + } + + // update.ChatType: Dialog, Chat или Channel (берётся из message.recipient) + await context.Api.Messages.SendMessageAsync(chatId, $"Echo: {update.Message?.Text}", ct); + }); + +await client.StartPollingAsync(handler); +Console.ReadLine(); +await client.StopPollingAsync(); ``` ✅ Полноценные примеры (echo-бот, команды, кнопки, файлы, контакты) живут в `examples/Max.Bot.Examples` и доступны как `RunAsync()` сценарии. +## 📎 Файлы и вложения + +```csharp +using Max.Bot.Types.Requests; + +// Картинка по URL — MAX скачает её сам +await client.Messages.SendMessageWithAttachmentAsync( + AttachmentRequest.ImageFromUrl("https://example.com/cat.jpg"), chatId: chatId, text: "Котик"); + +// Загрузка файла: UploadAsync сам получит upload URL и токен (для image — из photos, для video/audio — из POST /uploads) +await using var stream = File.OpenRead("report.pdf"); +var upload = await client.Files.UploadAsync(UploadType.File, stream, "report.pdf"); +await client.Messages.SendMessageWithAttachmentAsync(AttachmentRequest.FromUpload(UploadType.File, upload), chatId: chatId); +``` + +Также доступны `AttachmentRequest.Image(token)`, `Video`, `Audio`, `File`, `Sticker`, `Contact`, `Location`, `Share` и `Keyboard`. +Если MAX ещё обрабатывает только что загруженный файл, API отвечает `attachment.not.ready`: отправка/редактирование сообщения +повторяются автоматически, а при исчерпании попыток выбрасывается `MaxAttachmentNotReadyException`. + +## 🔘 Callback-кнопки + +```csharp +var callback = update.Callback!; +await client.Messages.AnswerCallbackQueryAsync( + callback.CallbackId!, + new AnswerCallbackQueryRequest { Notification = "Принято!" }); +``` + +## 🌐 Webhook + +Библиотека больше не добавляет эндпоинт в приложение автоматически. Подключите его явно: + +```csharp +builder.Services.AddSingleton(new MaxBotOptions +{ + Token = builder.Configuration["Max:Token"]!, + Webhook = { Endpoint = "https://bot.example.com/api/max/webhook", SecretToken = builder.Configuration["Max:WebhookSecret"] } +}); +builder.Services.AddSingleton(sp => new MaxClient(sp.GetRequiredService())); +builder.Services.AddSingleton(sp => sp.GetRequiredService()); +builder.Services.AddSingleton(); + +var app = builder.Build(); +app.MapMaxWebhook(); // POST /api/max/webhook, проверяет заголовок X-Max-Bot-Api-Secret + +// URL и секрет берутся из MaxBotOptions.Webhook, если не переданы явно +await app.Services.GetRequiredService().ConfigureWebhookAsync(); +``` + +Для MVC-проектов можно унаследоваться от абстрактного `WebhookController` и пометить наследника `[Route(...)]`. + ## 🧠 Архитектурные ориентиры - [Telegram.Bot](https://github.com/TelegramBots/Telegram.Bot) — структура клиента, подход к документации и примерам. @@ -52,7 +116,7 @@ await client.Messages.SendMessageAsync( ## 🧪 Тесты и качество - `dotnet build -warnaserror` — проверяет, что все публичные API имеют XML-доки. -- `dotnet test` — 635+ модульных тестов, включая `DocumentationCoverageTests` и проверки сериализации. +- `dotnet test` — 690+ модульных тестов, включая регрессионные тесты по issue и проверки сериализации на реальных ответах MAX API. - GitHub Actions workflow `.github/workflows/ci.yml` (Ubuntu + Windows) гоняет форматирование, `dotnet format analyzers`, покрытие (минимум 60 % линий, целимся в 85 %) и `dotnet pack`, загружая артефакты для ревью. - Smoke-тесты для примеров используют `LoopbackSampleRuntime` и проходят весь `SampleRegistry`, повторяя подход Telegram.Bot и VkNet к офлайн-интеграциям, чтобы код примеров и документация не расходились. - Фикстуры API (`tests/Max.Bot.Tests/Integration/Fixtures`) помогают детерминированно воспроизводить ответы `/subscriptions` и `/updates` без реального HTTP. @@ -68,7 +132,7 @@ await client.Messages.SendMessageAsync( ## 📚 Документация - Автогенерируемый XML-файл: `src/Max.Bot/bin/Debug//Max.Bot.xml` (включён в NuGet). -- Официальный Max Messenger API: [dev.max.ru/docs-api](https://dev.max.ru/docs-api) (локальная копия — `docs/max-api-docs/`). +- Официальный Max Messenger API: [dev.max.ru/docs-api](https://dev.max.ru/docs-api). - Другие реализации для сравнения и проверки совместимости: - TypeScript — [max-bot-api-client-ts](https://github.com/max-messenger/max-bot-api-client-ts) - Go — [max-bot-api-client-go](https://github.com/max-messenger/max-bot-api-client-go) @@ -98,7 +162,7 @@ await client.Messages.SendMessageAsync( 1. Установите .NET 10 SDK или SDK целевого TFM (`net10.0`, `net9.0`, `net8.0`). 2. В проекте выполните: ```powershell - dotnet add package MaxMessenger.Bot + dotnet add package MaxMessenger.Bot --prerelease ``` 3. Создайте `MaxClient` вручную или зарегистрируйте через DI (например, в ASP.NET Core). 4. Токен и webhook URL держите в Secret Manager/Key Vault. Добавьте `.env`/секретные файлы в `.gitignore`. @@ -129,6 +193,6 @@ Apache License 2.0 — см. [LICENSE](LICENSE). --- -**Версия:** 0.6.2-alpha +**Версия:** 0.7.0-alpha **Статус:** Активная разработка **Контакты:** issues/PR в репозитории \ No newline at end of file diff --git a/examples/Max.Bot.Examples/Samples/CommandBotSample.cs b/examples/Max.Bot.Examples/Samples/CommandBotSample.cs index ec06e81..5ed4da1 100644 --- a/examples/Max.Bot.Examples/Samples/CommandBotSample.cs +++ b/examples/Max.Bot.Examples/Samples/CommandBotSample.cs @@ -78,7 +78,7 @@ private static async Task HandleStartAsync(UpdateContext context, CancellationTo await context.Api.Messages.SendMessageAsync( chatId.Value, - "рџ‘‹ Welcome! Use /help to discover available commands.", + "👋 Welcome! Use /help to discover available commands.", cancellationToken).ConfigureAwait(false); } diff --git a/examples/Max.Bot.Examples/Samples/ContactBotSample.cs b/examples/Max.Bot.Examples/Samples/ContactBotSample.cs index a06a7f7..c86e1b5 100644 --- a/examples/Max.Bot.Examples/Samples/ContactBotSample.cs +++ b/examples/Max.Bot.Examples/Samples/ContactBotSample.cs @@ -80,15 +80,15 @@ private static async Task HandleContactAsync( var parsedFullName = ContactHelpers.GetFullName(contactAttachment); var response = $""" - Новый контакт получен! РІСќСџС'С‘ + Новый контакт получен! 📇 - Телефон: {phoneNumber ?? "РќРµ указан"} - РРјСЏ: {fullName ?? "РќРµ указано"} + Телефон: {phoneNumber ?? "Не указан"} + Имя: {fullName ?? "Не указано"} - Дополнительная информация: + Дополнительная информация: - User ID: {maxInfo?.Id} - - Username: {maxInfo?.Username ?? "Нет"} - - Bot: {(maxInfo?.IsBot == true ? "Да" : "Нет")} + - Username: {maxInfo?.Username ?? "Нет"} + - Bot: {(maxInfo?.IsBot == true ? "Да" : "Нет")} """; await context.Api.Messages.SendMessageAsync( diff --git a/examples/Max.Bot.Examples/Samples/FileBotSample.cs b/examples/Max.Bot.Examples/Samples/FileBotSample.cs index 0842913..6248851 100644 --- a/examples/Max.Bot.Examples/Samples/FileBotSample.cs +++ b/examples/Max.Bot.Examples/Samples/FileBotSample.cs @@ -54,7 +54,7 @@ await context.Api.Messages.SendMessageAsync( await context.Api.Messages.SendMessageWithAttachmentAsync( attachment, chatId: chatId.Value, - text: "Here is your file upload рџ“Ћ", + text: "Here is your file upload 📎", cancellationToken: cancellationToken).ConfigureAwait(false); sampleContext.Output.WriteLine($"File '{sampleContext.Settings.UploadFilePath}' uploaded for chat {chatId.Value}."); } @@ -62,18 +62,15 @@ await context.Api.Messages.SendMessageWithAttachmentAsync( private static async Task BuildAttachmentAsync(UpdateContext context, string filePath, CancellationToken cancellationToken) { await using var stream = File.OpenRead(filePath); - var uploadResponse = await context.Api.Files.UploadFileAsync(UploadType.File, cancellationToken).ConfigureAwait(false); - var payload = await context.Api.Files.UploadFileDataAsync( - uploadResponse.Url, + + // UploadAsync requests an upload URL, sends the data and resolves the attachment token. + var upload = await context.Api.Files.UploadAsync( + UploadType.File, stream, Path.GetFileName(filePath), cancellationToken).ConfigureAwait(false); - return new AttachmentRequest - { - Type = "file", - Payload = payload - }; + return AttachmentRequest.FromUpload(UploadType.File, upload); } } diff --git a/examples/Max.Bot.Examples/Samples/KeyboardBotSample.cs b/examples/Max.Bot.Examples/Samples/KeyboardBotSample.cs index 2670489..cfe66b1 100644 --- a/examples/Max.Bot.Examples/Samples/KeyboardBotSample.cs +++ b/examples/Max.Bot.Examples/Samples/KeyboardBotSample.cs @@ -45,12 +45,12 @@ private static async Task HandleMessageAsync(SampleExecutionContext sampleContex { new[] { - new InlineKeyboardButton { Text = "рџ‘Ќ Approve", CallbackData = "vote:approve" }, - new InlineKeyboardButton { Text = "рџ‘Ћ Reject", CallbackData = "vote:reject" } + new InlineKeyboardButton { Text = "👍 Approve", CallbackData = "vote:approve" }, + new InlineKeyboardButton { Text = "👎 Reject", CallbackData = "vote:reject" } }, new[] { - new InlineKeyboardButton { Text = "рџ“љ Docs", Url = "https://dev.max.ru/docs-api" } + new InlineKeyboardButton { Text = "📚 Docs", Url = "https://dev.max.ru/docs-api" } } }); @@ -72,9 +72,9 @@ private static async Task HandleCallbackAsync(SampleExecutionContext sampleConte var responseText = callback.Payload switch { - "vote:approve" => "вњ… Approved", - "vote:reject" => "РІСњРЉ Rejected", - _ => "в„№пС'РЏ Received." + "vote:approve" => "✅ Approved", + "vote:reject" => "❌ Rejected", + _ => "ℹ️ Received." }; var request = new AnswerCallbackQueryRequest diff --git a/src/Max.Bot/Api/ChatsApi.cs b/src/Max.Bot/Api/ChatsApi.cs index 51f3c11..2b70bbb 100644 --- a/src/Max.Bot/Api/ChatsApi.cs +++ b/src/Max.Bot/Api/ChatsApi.cs @@ -1,5 +1,7 @@ using System.Collections.Generic; +using System.Globalization; using System.Net.Http; +using System.Text.Json; using Max.Bot.Configuration; using Max.Bot.Networking; using Max.Bot.Types; @@ -33,8 +35,34 @@ public async Task GetChatAsync(long chatId, CancellationToken cancellation /// public async Task GetChatsAsync(CancellationToken cancellationToken = default) { - var request = CreateRequest(HttpMethod.Get, "/chats"); - return await ExecuteRequestAsync(request, cancellationToken).ConfigureAwait(false); + var response = await GetChatListAsync(cancellationToken: cancellationToken).ConfigureAwait(false); + return response.Chats ?? Array.Empty(); + } + + /// + public async Task GetChatListAsync(int? count = null, long? marker = null, CancellationToken cancellationToken = default) + { + if (count is < 1 or > 100) + { + throw new ArgumentOutOfRangeException(nameof(count), count, "Count must be between 1 and 100."); + } + + var queryParams = new Dictionary(); + if (count.HasValue) + { + queryParams["count"] = count.Value.ToString(CultureInfo.InvariantCulture); + } + + if (marker.HasValue) + { + queryParams["marker"] = marker.Value.ToString(CultureInfo.InvariantCulture); + } + + // * GET /chats returns {"chats":[...],"marker":...} + var request = CreateRequest(HttpMethod.Get, "/chats", null, queryParams.Count > 0 ? queryParams : null); + var response = await ExecuteRequestAsync(request, cancellationToken).ConfigureAwait(false); + response.Chats ??= Array.Empty(); + return response; } /// @@ -78,21 +106,41 @@ public async Task SendChatActionAsync(long chatId, SendChatActionReque public async Task GetPinnedMessageAsync(long chatId, CancellationToken cancellationToken = default) { var request = CreateRequest(HttpMethod.Get, $"/chats/{chatId}/pin"); - var response = await HttpClient.SendAsync>(request, cancellationToken).ConfigureAwait(false); - if (response == null || !response.Ok) + // * GET /chats/{chatId}/pin returns {"message": {...}} or {"message": null} when nothing is pinned. + var responseBody = await HttpClient.SendAsyncRaw(request, cancellationToken).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(responseBody)) { - if (response != null && !response.Ok) - { - throw new Exceptions.MaxApiException( - "API request failed. The response indicates an error.", - null, - System.Net.HttpStatusCode.BadRequest); - } return null; } - return response.Result; + try + { + using var document = JsonDocument.Parse(responseBody); + var root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object) + { + return null; + } + + // Legacy {"ok": true, "result": {...}} envelope. + if (root.TryGetProperty("result", out var result) && result.ValueKind == JsonValueKind.Object) + { + return result.Deserialize(MaxJsonSerializer.Options); + } + + return root.TryGetProperty("message", out var message) && message.ValueKind == JsonValueKind.Object + ? message.Deserialize(MaxJsonSerializer.Options) + : null; + } + catch (JsonException ex) + { + throw new Exceptions.MaxApiException( + $"Failed to parse pinned message response: {ex.Message}", + null, + System.Net.HttpStatusCode.BadRequest, + ex); + } } /// diff --git a/src/Max.Bot/Api/FilesApi.cs b/src/Max.Bot/Api/FilesApi.cs index 86d5342..38d521e 100644 --- a/src/Max.Bot/Api/FilesApi.cs +++ b/src/Max.Bot/Api/FilesApi.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Net.Http; using System.Net.Http.Headers; using System.Text; @@ -46,9 +47,23 @@ public async Task UploadFileDataAsync(string uploadUrl, Stream ValidateNotEmpty(uploadUrl, nameof(uploadUrl)); ArgumentNullException.ThrowIfNull(fileStream); + var initialPosition = fileStream.CanSeek ? fileStream.Position : -1; + var attempt = 0; + // Factory to create content for retries HttpContent CreateContent() { + if (attempt++ > 0) + { + // A retry must send the whole file again, so rewind the stream to where the first attempt started. + if (!fileStream.CanSeek) + { + throw new InvalidOperationException("File upload failed and cannot be retried because the stream is not seekable."); + } + + fileStream.Position = initialPosition; + } + var content = new MultipartFormDataContent(); var streamContent = new StreamContent(new NonDisposingStreamWrapper(fileStream)); streamContent.Headers.ContentType = new MediaTypeHeaderValue(MimeTypes.FromFileName(fileName) ?? "application/octet-stream"); @@ -60,6 +75,28 @@ HttpContent CreateContent() return ParseUploadResponse(responseBody); } + public async Task UploadAsync(UploadType uploadType, Stream fileStream, string? fileName = null, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(fileStream); + + var upload = await UploadFileAsync(uploadType, cancellationToken).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(upload?.Url)) + { + throw new MaxApiException("POST /uploads did not return an upload URL.", null, System.Net.HttpStatusCode.BadRequest); + } + + var result = await UploadFileDataAsync(upload.Url, fileStream, fileName, cancellationToken).ConfigureAwait(false); + + // * For video and audio the token is issued by POST /uploads, the CDN response does not contain it. + if (!string.IsNullOrEmpty(upload.Token) && + (uploadType is UploadType.Video or UploadType.Audio || string.IsNullOrEmpty(result.Token))) + { + result.Token = upload.Token; + } + + return result; + } + public async Task UploadFileResumableAsync(string uploadUrl, Stream fileStream, long chunkSize = 1024 * 1024, string? fileName = null, CancellationToken cancellationToken = default) { ValidateNotEmpty(uploadUrl, nameof(uploadUrl)); @@ -113,18 +150,34 @@ HttpContent CreateChunkContent() return ParseUploadResponse(finalResponseBody); } - private static FileUploadResult ParseUploadResponse(string responseBody) + internal static FileUploadResult ParseUploadResponse(string responseBody) { if (string.IsNullOrWhiteSpace(responseBody)) return new FileUploadResult(); - try + + var trimmed = responseBody.Trim(); + if (trimmed.StartsWith('{')) { - return JsonSerializer.Deserialize(responseBody, MaxJsonSerializer.Options) ?? new FileUploadResult(); + try + { + var result = JsonSerializer.Deserialize(trimmed, MaxJsonSerializer.Options) ?? new FileUploadResult(); + result.NormalizeToken(); + return result; + } + catch (JsonException) + { + return new FileUploadResult(); + } } - catch (JsonException) + + // * Video/audio CDN endpoints answer with XML such as "1": that is not a token, + // the real token comes from POST /uploads (see UploadAsync). + if (trimmed.StartsWith('<') || trimmed.StartsWith('[') || trimmed.Any(char.IsWhiteSpace)) { - // Fallback for raw token strings - return new FileUploadResult { Token = responseBody }; + return new FileUploadResult(); } + + // Fallback for raw token strings + return new FileUploadResult { Token = trimmed.Trim('"') }; } private sealed class NonDisposingStreamWrapper : Stream diff --git a/src/Max.Bot/Api/IChatsApi.cs b/src/Max.Bot/Api/IChatsApi.cs index 9be79cf..36183de 100644 --- a/src/Max.Bot/Api/IChatsApi.cs +++ b/src/Max.Bot/Api/IChatsApi.cs @@ -21,7 +21,8 @@ public interface IChatsApi Task GetChatAsync(long chatId, CancellationToken cancellationToken = default); /// - /// Gets a list of all chats for the bot. + /// Gets the first page of group chats and channels the bot participates in (GET /chats). + /// Use to paginate with count and marker. /// /// A cancellation token to cancel the operation. /// A task that represents the asynchronous operation. The task result contains an array of chats. @@ -30,6 +31,19 @@ public interface IChatsApi /// Thrown when authentication fails. Task GetChatsAsync(CancellationToken cancellationToken = default); + /// + /// Gets a page of group chats and channels the bot participates in (GET /chats). + /// + /// Number of chats to return (1-100). Defaults to 50 on the server side. + /// Marker of the page to return, taken from of the previous page. Optional. + /// A cancellation token to cancel the operation. + /// A task that represents the asynchronous operation. The task result contains the chats and the next page marker. + /// Thrown when count is outside 1..100. + /// Thrown when the API returns an error response. + /// Thrown when a network error occurs. + /// Thrown when authentication fails. + Task GetChatListAsync(int? count = null, long? marker = null, CancellationToken cancellationToken = default); + /// /// Gets information about a chat by its public link or username. /// diff --git a/src/Max.Bot/Api/IFilesApi.cs b/src/Max.Bot/Api/IFilesApi.cs index 1e8283a..f0947a0 100644 --- a/src/Max.Bot/Api/IFilesApi.cs +++ b/src/Max.Bot/Api/IFilesApi.cs @@ -38,13 +38,36 @@ public interface IFilesApi /// The stream containing the file data to upload. /// A cancellation token to cancel the operation. /// The name of the file. Used to determine the MIME type. If the extension is unknown, falls back to "application/octet-stream". Optional. - /// A task that represents the asynchronous operation. The task result contains the upload result with a token. + /// + /// A task that represents the asynchronous operation. The task result contains the upload result. + /// For images is filled and contains the first photo token. + /// For video and audio the CDN does not return a token — use or . + /// /// Thrown when uploadUrl is null or empty, or fileStream is not readable. /// Thrown when fileStream is null. /// Thrown when the upload fails. /// Thrown when a network error occurs. Task UploadFileDataAsync(string uploadUrl, Stream fileStream, string? fileName = null, CancellationToken cancellationToken = default); + /// + /// Uploads a file in one call: requests an upload URL (POST /uploads), sends the data and + /// returns a result whose is ready to be used in an attachment. + /// + /// + /// For images the token is taken from the returned photos map, for video and audio it is the token issued by + /// POST /uploads. Use to build the attachment. + /// + /// The type of file to upload. + /// The stream containing the file data to upload. + /// The name of the file. Used for the multipart file name and MIME type detection. Optional. + /// A cancellation token to cancel the operation. + /// A task that represents the asynchronous operation. The task result contains the upload result with a token. + /// Thrown when fileStream is null. + /// Thrown when the API returns an error response. + /// Thrown when a network error occurs. + /// Thrown when authentication fails. + Task UploadAsync(UploadType uploadType, Stream fileStream, string? fileName = null, CancellationToken cancellationToken = default); + /// /// Uploads file data in chunks using resumable upload method. /// diff --git a/src/Max.Bot/Api/IMessagesApi.cs b/src/Max.Bot/Api/IMessagesApi.cs index 6237356..0fe6749 100644 --- a/src/Max.Bot/Api/IMessagesApi.cs +++ b/src/Max.Bot/Api/IMessagesApi.cs @@ -84,6 +84,35 @@ public interface IMessagesApi /// Thrown when authentication fails. Task GetMessagesAsync(long chatId, CancellationToken cancellationToken = default); + /// + /// Gets a page of messages from the specified chat (GET /messages). + /// Messages are returned in reverse order: the latest message comes first. + /// + /// The unique identifier of the chat. + /// Maximum number of messages to return (1-100). Defaults to 50 on the server side. + /// Upper bound of the message timestamp (Unix time in milliseconds). Optional. + /// Lower bound of the message timestamp (Unix time in milliseconds). Must be less than . Optional. + /// A cancellation token to cancel the operation. + /// A task that represents the asynchronous operation. The task result contains an array of messages. + /// Thrown when count is outside 1..100. + /// Thrown when the API returns an error response. + /// Thrown when a network error occurs. + /// Thrown when authentication fails. + Task GetMessagesAsync(long chatId, int? count, long? from = null, long? to = null, CancellationToken cancellationToken = default); + + /// + /// Gets messages by their identifiers (GET /messages?message_ids=...). + /// + /// The message identifiers (mid) to load. + /// A cancellation token to cancel the operation. + /// A task that represents the asynchronous operation. The task result contains an array of messages. + /// Thrown when messageIds is null. + /// Thrown when messageIds contains no identifiers. + /// Thrown when the API returns an error response. + /// Thrown when a network error occurs. + /// Thrown when authentication fails. + Task GetMessagesByIdsAsync(IEnumerable messageIds, CancellationToken cancellationToken = default); + /// /// Edits a message in the specified chat. /// @@ -151,8 +180,8 @@ public interface IMessagesApi /// /// Answers a callback query from an inline keyboard button press. /// - /// The unique identifier of the callback query. - /// The answer request containing message or notification. + /// The callback identifier (), sent to MAX API as the callback_id query parameter. + /// The answer request containing a new message body and/or a one-time notification. /// A cancellation token to cancel the operation. /// A task that represents the asynchronous operation. The task result contains the response with success status. /// Thrown when callbackQueryId is null or empty. diff --git a/src/Max.Bot/Api/MessagesApi.cs b/src/Max.Bot/Api/MessagesApi.cs index 4095bb0..b783881 100644 --- a/src/Max.Bot/Api/MessagesApi.cs +++ b/src/Max.Bot/Api/MessagesApi.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Net; using System.Net.Http; using Max.Bot.Configuration; @@ -13,6 +14,10 @@ namespace Max.Bot.Api; /// internal class MessagesApi : BaseApi, IMessagesApi { + private const int AttachmentRetryCount = 5; + + private static readonly TimeSpan AttachmentRetryInitialDelay = TimeSpan.FromMilliseconds(500); + /// /// Initializes a new instance of the class. /// @@ -37,7 +42,7 @@ public async Task SendMessageAsync(long chatId, string text, Cancellati var queryParams = new Dictionary { - { "chat_id", chatId.ToString() } + { "chat_id", chatId.ToString(CultureInfo.InvariantCulture) } }; var request = CreateRequest(HttpMethod.Post, "/messages", sendRequest, queryParams); @@ -77,7 +82,7 @@ public async Task SendMessageAsync(long chatId, string text, InlineKeyb var queryParams = new Dictionary { - { "chat_id", chatId.ToString() } + { "chat_id", chatId.ToString(CultureInfo.InvariantCulture) } }; if (disableLinkPreview.HasValue) @@ -177,16 +182,67 @@ public async Task SendMessageAsync(SendMessageRequest request, long? ch } /// - public async Task GetMessagesAsync(long chatId, CancellationToken cancellationToken = default) + public Task GetMessagesAsync(long chatId, CancellationToken cancellationToken = default) { + return GetMessagesAsync(chatId, count: null, from: null, to: null, cancellationToken); + } + + /// + public async Task GetMessagesAsync(long chatId, int? count, long? from = null, long? to = null, CancellationToken cancellationToken = default) + { + if (count is < 1 or > 100) + { + throw new ArgumentOutOfRangeException(nameof(count), count, "Count must be between 1 and 100."); + } + var queryParams = new Dictionary { - { "chat_id", chatId.ToString() } + { "chat_id", chatId.ToString(CultureInfo.InvariantCulture) } }; + if (from.HasValue) + { + queryParams["from"] = from.Value.ToString(CultureInfo.InvariantCulture); + } + + if (to.HasValue) + { + queryParams["to"] = to.Value.ToString(CultureInfo.InvariantCulture); + } + + if (count.HasValue) + { + queryParams["count"] = count.Value.ToString(CultureInfo.InvariantCulture); + } + + return await GetMessagesCoreAsync(queryParams, cancellationToken).ConfigureAwait(false); + } + + /// + public async Task GetMessagesByIdsAsync(IEnumerable messageIds, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(messageIds); + + var ids = messageIds.Where(id => !string.IsNullOrWhiteSpace(id)).ToArray(); + if (ids.Length == 0) + { + throw new ArgumentException("At least one message ID must be provided.", nameof(messageIds)); + } + + var queryParams = new Dictionary + { + { "message_ids", string.Join(",", ids) } + }; + + return await GetMessagesCoreAsync(queryParams, cancellationToken).ConfigureAwait(false); + } + + private async Task GetMessagesCoreAsync(Dictionary queryParams, CancellationToken cancellationToken) + { + // * GET /messages returns {"messages":[...]} var request = CreateRequest(HttpMethod.Get, "/messages", null, queryParams); var response = await ExecuteRequestAsync(request, cancellationToken).ConfigureAwait(false); - return response.Messages; + return response.Messages ?? Array.Empty(); } /// @@ -204,8 +260,15 @@ public async Task EditMessageAsync(string messageId, EditMessageReques { "message_id", messageId } }; - var apiRequest = CreateRequest(HttpMethod.Put, "/messages", request, queryParams); - return await ExecuteRequestAsync(apiRequest, cancellationToken).ConfigureAwait(false); + var hasAttachments = request.Attachments is { Length: > 0 }; + return await ExecuteWithAttachmentRetryAsync( + hasAttachments, + () => + { + var apiRequest = CreateRequest(HttpMethod.Put, "/messages", request, queryParams); + return ExecuteRequestAsync(apiRequest, cancellationToken); + }, + cancellationToken).ConfigureAwait(false); } /// @@ -277,9 +340,10 @@ public async Task AnswerCallbackQueryAsync(string callbackQueryId, Ans ArgumentNullException.ThrowIfNull(request); + // * POST /answers expects the callback identifier in the "callback_id" query parameter. var queryParams = new Dictionary { - { "callback_query_id", callbackQueryId } + { "callback_id", callbackQueryId } }; var apiRequest = CreateRequest(HttpMethod.Post, "/answers", request, queryParams); @@ -447,23 +511,38 @@ public async Task ReplyToMessageAsync( return await SendMessageAsync(request, chatId, userId, disableLinkPreview, cancellationToken).ConfigureAwait(false); } - private async Task SendMessageWithAttachmentRetryAsync( + private Task SendMessageWithAttachmentRetryAsync( SendMessageRequest request, long? chatId, long? userId, bool? disableLinkPreview, CancellationToken cancellationToken) { - const int retryCount = 3; - var delay = TimeSpan.FromMilliseconds(500); + var hasAttachments = request.Attachments is { Length: > 0 }; + return ExecuteWithAttachmentRetryAsync( + hasAttachments, + () => SendMessageAsync(request, chatId, userId, disableLinkPreview, cancellationToken), + cancellationToken); + } + + /// + /// Executes a send/edit operation and retries it with exponential backoff while MAX API reports + /// that a freshly uploaded attachment is still being processed (attachment.not.ready). + /// + private static async Task ExecuteWithAttachmentRetryAsync( + bool hasAttachments, + Func> operation, + CancellationToken cancellationToken) + { + var delay = AttachmentRetryInitialDelay; for (var attempt = 0; ; attempt++) { try { - return await SendMessageAsync(request, chatId, userId, disableLinkPreview, cancellationToken).ConfigureAwait(false); + return await operation().ConfigureAwait(false); } - catch (Exceptions.MaxApiException ex) when (ex.HttpStatusCode == HttpStatusCode.BadRequest && attempt < retryCount) + catch (Exceptions.MaxApiException ex) when (hasAttachments && attempt < AttachmentRetryCount && IsAttachmentNotReady(ex)) { await Task.Delay(delay, cancellationToken).ConfigureAwait(false); delay = TimeSpan.FromMilliseconds(delay.TotalMilliseconds * 2); @@ -471,24 +550,31 @@ private async Task SendMessageWithAttachmentRetryAsync( } } - private static AttachmentRequest CreateInlineKeyboardAttachment(InlineKeyboard keyboard) + internal static bool IsAttachmentNotReady(Exceptions.MaxApiException exception) { - ArgumentNullException.ThrowIfNull(keyboard); - - var sourceRows = keyboard.Buttons ?? Array.Empty(); - var normalizedRows = new InlineKeyboardButton[sourceRows.Length][]; - for (var i = 0; i < sourceRows.Length; i++) + if (exception is Exceptions.MaxAttachmentNotReadyException) { - normalizedRows[i] = sourceRows[i] ?? Array.Empty(); + return true; } - var payloadKeyboard = new InlineKeyboard(normalizedRows); + if (exception.HttpStatusCode != HttpStatusCode.BadRequest) + { + return false; + } - return new AttachmentRequest + if (Exceptions.MaxAttachmentNotReadyException.IsAttachmentNotReadyCode(exception.ErrorCode)) { - Type = AttachmentTypeNames.InlineKeyboard, - Payload = payloadKeyboard - }; + return true; + } + + var message = exception.Message; + return message.Contains("attachment.not.ready", StringComparison.OrdinalIgnoreCase) + || message.Contains("not.processed", StringComparison.OrdinalIgnoreCase) + || message.Contains("not ready", StringComparison.OrdinalIgnoreCase); } + private static AttachmentRequest CreateInlineKeyboardAttachment(InlineKeyboard keyboard) + { + return AttachmentRequest.Keyboard(keyboard); + } } diff --git a/src/Max.Bot/Configuration/MaxWebhookOptions.cs b/src/Max.Bot/Configuration/MaxWebhookOptions.cs index 8df522f..7feb032 100644 --- a/src/Max.Bot/Configuration/MaxWebhookOptions.cs +++ b/src/Max.Bot/Configuration/MaxWebhookOptions.cs @@ -9,6 +9,7 @@ public sealed class MaxWebhookOptions { /// /// Gets or sets the public HTTPS endpoint that MAX should call (maps to subscription url payload). + /// Used by and when no URL is passed. /// public string? Endpoint { get; set; } @@ -18,14 +19,21 @@ public sealed class MaxWebhookOptions public bool EnforceHttps { get; set; } = true; /// - /// Gets or sets the shared secret MAX will include in webhook requests (recommended by Telegram/Vk best practices). + /// Gets or sets the shared secret (5-256 characters: A-Z, a-z, 0-9, _ and -) passed to MAX as the subscription + /// secret. MAX sends it back in the header of every webhook request, + /// and incoming requests without a matching header are rejected. /// public string? SecretToken { get; set; } /// - /// Gets or sets the header name storing the webhook signature or secret token. + /// The header MAX uses to send the subscription secret with every webhook request. /// - public string SignatureHeaderName { get; set; } = "X-Max-Signature"; + public const string DefaultSignatureHeaderName = "X-Max-Bot-Api-Secret"; + + /// + /// Gets or sets the header name storing the webhook secret token. Defaults to X-Max-Bot-Api-Secret. + /// + public string SignatureHeaderName { get; set; } = DefaultSignatureHeaderName; /// /// Gets or sets the maximum accepted request body size for webhook payloads (in KiB). diff --git a/src/Max.Bot/Exceptions/MaxAttachmentNotReadyException.cs b/src/Max.Bot/Exceptions/MaxAttachmentNotReadyException.cs new file mode 100644 index 0000000..643f889 --- /dev/null +++ b/src/Max.Bot/Exceptions/MaxAttachmentNotReadyException.cs @@ -0,0 +1,67 @@ +using System.Net; + +namespace Max.Bot.Exceptions; + +/// +/// Exception thrown when MAX API rejects a message because an attachment uploaded moments ago +/// has not been processed yet (error code attachment.not.ready). +/// +/// +/// The request can be retried after a short delay. SendMessageWithAttachmentAsync, +/// SendMessageWithAttachmentsAsync and EditMessageAsync already retry this error automatically. +/// +public class MaxAttachmentNotReadyException : MaxApiException +{ + /// + /// The error code returned by MAX API when an uploaded attachment is not processed yet. + /// + public const string Code = "attachment.not.ready"; + + /// + /// Initializes a new instance of the class. + /// + public MaxAttachmentNotReadyException() + : base("Attachment is not ready yet.", Code, System.Net.HttpStatusCode.BadRequest) + { + } + + /// + /// Initializes a new instance of the class with a specified error message. + /// + /// The message that describes the error. + public MaxAttachmentNotReadyException(string message) + : base(message, Code, System.Net.HttpStatusCode.BadRequest) + { + } + + /// + /// Initializes a new instance of the class with a specified error message and inner exception. + /// + /// The message that describes the error. + /// The exception that is the cause of the current exception. + public MaxAttachmentNotReadyException(string message, Exception innerException) + : base(message, Code, System.Net.HttpStatusCode.BadRequest, innerException) + { + } + + /// + /// Initializes a new instance of the class with a specified error message, error code, and HTTP status code. + /// + /// The message that describes the error. + /// The error code from the API response. + /// The HTTP status code from the API response. + public MaxAttachmentNotReadyException(string message, string? errorCode, HttpStatusCode? httpStatusCode) + : base(message, errorCode ?? Code, httpStatusCode) + { + } + + /// + /// Determines whether an API error code means that an uploaded attachment is not processed yet. + /// + /// The error code from the API response. + /// true when the error code is attachment.not.ready; otherwise, false. + public static bool IsAttachmentNotReadyCode(string? errorCode) + { + return string.Equals(errorCode, Code, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/Max.Bot/Max.Bot.csproj b/src/Max.Bot/Max.Bot.csproj index c5b21f3..c484498 100644 --- a/src/Max.Bot/Max.Bot.csproj +++ b/src/Max.Bot/Max.Bot.csproj @@ -5,7 +5,7 @@ enable MaxMessenger.Bot - 0.6.3-alpha + 0.7.0-alpha MaxBotNet Contributors C# library for Max Messenger Bot API (.NET 10, .NET 9, .NET 8) Apache-2.0 diff --git a/src/Max.Bot/MaxClient.cs b/src/Max.Bot/MaxClient.cs index 28a9005..f2d494f 100644 --- a/src/Max.Bot/MaxClient.cs +++ b/src/Max.Bot/MaxClient.cs @@ -201,54 +201,75 @@ public async Task ProcessWebhookAsync(Update update, IUpdateHandler handler, ISe /// /// Configures the webhook endpoint by calling POST /subscriptions. /// - /// The webhook URL where updates will be sent. + /// + /// The webhook URL where updates will be sent. Optional: when omitted, + /// from is used. + /// /// Optional list of update types to receive. If null, all update types will be received. - /// Optional secret that will be sent in the X-Max-Bot-Api-Secret header. + /// + /// Optional secret that MAX sends back in the X-Max-Bot-Api-Secret header. When omitted, + /// is used; when provided and no secret is configured yet, it is stored there + /// so that incoming webhooks are validated against it. + /// /// A cancellation token to cancel the operation. /// A task that represents the asynchronous operation. The task result contains the response with success status. - /// Thrown when url is null, empty, or invalid. - public async Task ConfigureWebhookAsync(string url, List? updateTypes = null, string? secret = null, CancellationToken cancellationToken = default) + /// Thrown when neither url nor is set, or the URL is invalid. + public async Task ConfigureWebhookAsync(string? url = null, List? updateTypes = null, string? secret = null, CancellationToken cancellationToken = default) { - if (string.IsNullOrWhiteSpace(url)) + var webhookUrl = string.IsNullOrWhiteSpace(url) ? _options.Webhook.Endpoint : url; + if (string.IsNullOrWhiteSpace(webhookUrl)) { - throw new ArgumentException("Webhook URL must be provided.", nameof(url)); + throw new ArgumentException("Webhook URL must be provided either as an argument or via MaxBotOptions.Webhook.Endpoint.", nameof(url)); } - if (_options.Webhook.EnforceHttps) + if (!Uri.TryCreate(webhookUrl, UriKind.Absolute, out var parsed)) { - if (!Uri.TryCreate(url, UriKind.Absolute, out var parsed)) - { - throw new ArgumentException("Webhook URL must be an absolute URI.", nameof(url)); - } + throw new ArgumentException("Webhook URL must be an absolute URI.", nameof(url)); + } - if (!Uri.UriSchemeHttps.Equals(parsed.Scheme, StringComparison.OrdinalIgnoreCase)) - { - throw new ArgumentException("Webhook URL must use HTTPS when EnforceHttps is enabled.", nameof(url)); - } + if (_options.Webhook.EnforceHttps && !Uri.UriSchemeHttps.Equals(parsed.Scheme, StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException("Webhook URL must use HTTPS when EnforceHttps is enabled.", nameof(url)); } + var webhookSecret = string.IsNullOrWhiteSpace(secret) ? _options.Webhook.SecretToken : secret; + var request = new SetWebhookRequest { - Url = url, + Url = webhookUrl, UpdateTypes = updateTypes, - Secret = secret + Secret = string.IsNullOrWhiteSpace(webhookSecret) ? null : webhookSecret }; - _options.Webhook.Endpoint = url; - return await Subscriptions.SetWebhookAsync(request, cancellationToken).ConfigureAwait(false); + var response = await Subscriptions.SetWebhookAsync(request, cancellationToken).ConfigureAwait(false); + + _options.Webhook.Endpoint = webhookUrl; + if (!string.IsNullOrWhiteSpace(secret) && string.IsNullOrWhiteSpace(_options.Webhook.SecretToken)) + { + _options.Webhook.SecretToken = secret; + } + + return response; } /// /// Deletes the webhook subscription by calling DELETE /subscriptions. /// - /// The webhook URL to remove from subscriptions. + /// + /// The webhook URL to remove from subscriptions. Optional: when omitted, is used. + /// /// A cancellation token to cancel the operation. /// A task that represents the asynchronous operation. The task result contains the response with success status. - /// Thrown when url is null or empty. - public Task DeleteWebhookAsync(string url, CancellationToken cancellationToken = default) + /// Thrown when neither url nor is set. + public Task DeleteWebhookAsync(string? url = null, CancellationToken cancellationToken = default) { - ArgumentException.ThrowIfNullOrWhiteSpace(url); - return Subscriptions.DeleteWebhookAsync(url, cancellationToken); + var webhookUrl = string.IsNullOrWhiteSpace(url) ? _options.Webhook.Endpoint : url; + if (string.IsNullOrWhiteSpace(webhookUrl)) + { + throw new ArgumentException("Webhook URL must be provided either as an argument or via MaxBotOptions.Webhook.Endpoint.", nameof(url)); + } + + return Subscriptions.DeleteWebhookAsync(webhookUrl, cancellationToken); } /// diff --git a/src/Max.Bot/Networking/MaxHttpClient.cs b/src/Max.Bot/Networking/MaxHttpClient.cs index e140ae5..b611bc7 100644 --- a/src/Max.Bot/Networking/MaxHttpClient.cs +++ b/src/Max.Bot/Networking/MaxHttpClient.cs @@ -6,6 +6,7 @@ using System.Net.Http; using System.Net.Http.Headers; using System.Text; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Max.Bot.Configuration; @@ -24,6 +25,11 @@ public class MaxHttpClient : IMaxHttpClient private readonly MaxBotClientOptions _options; private readonly ILogger? _logger; + /// + /// Gets the User-Agent header value sent with every API request (MAX API requires it). + /// + internal static string UserAgent { get; } = BuildUserAgent(); + /// /// Initializes a new instance of the class. /// @@ -156,19 +162,7 @@ public async Task SendAsyncRaw( { using var httpRequest = new HttpRequestMessage(requestMethod, absoluteUrl); - // Max API requires Authorization header even for upload URLs (often) - // We extract it from DefaultRequestHeaders if it was set there, or rely on options - // In our implementation, we don't store token in DefaultRequestHeaders, we add it per request. - // But wait, the token is in MaxBotOptions. - // Let's ensure the token is passed. Since MaxHttpClient doesn't have MaxBotOptions (only client options), - // we assume the token is already managed. - // Correction: In MaxClient, we pass token via MaxApiRequest. - // For absolute URLs, we should probably have the token accessible. - - // For now, let's assume if the user didn't provide a token in the URL, they might need the header. - // However, without access to the token here, we can't add it. - // Let's check where the token is stored. It's in MaxBotOptions. - + // Upload URLs returned by POST /uploads are pre-signed, so no Authorization header is needed. if (contentFactory != null) { httpRequest.Content = contentFactory(); @@ -233,7 +227,7 @@ private HttpRequestMessage BuildHttpRequestMessage(MaxApiRequest request, Cancel var httpRequest = new HttpRequestMessage(request.Method, url); // MAX API requires User-Agent header (same as Go reference client) - httpRequest.Headers.TryAddWithoutValidation("User-Agent", "MaxMessenger.Bot/0.5.1-alpha"); + httpRequest.Headers.TryAddWithoutValidation("User-Agent", UserAgent); if (request.Headers != null) { @@ -257,22 +251,7 @@ private HttpRequestMessage BuildHttpRequestMessage(MaxApiRequest request, Cancel private void HandleHttpResponseError(HttpResponseMessage response, string? responseBody) { - string? errorMessage = null; - string? errorCode = null; - - if (!string.IsNullOrWhiteSpace(responseBody)) - { - try - { - var errorResponse = MaxJsonSerializer.Deserialize(responseBody); - if (errorResponse?.Error != null) - { - errorMessage = errorResponse.Error.Message; - errorCode = errorResponse.Error.Code; - } - } - catch { /* Not a standard ErrorResponse */ } - } + var (errorCode, errorMessage) = ParseErrorBody(responseBody); errorMessage ??= string.IsNullOrWhiteSpace(responseBody) ? $"HTTP {(int)response.StatusCode} {response.ReasonPhrase}" @@ -284,10 +263,64 @@ private void HandleHttpResponseError(HttpResponseMessage response, string? respo HttpStatusCode.TooManyRequests => new MaxRateLimitException(errorMessage, errorCode, response.StatusCode, GetRetryAfter(response)), HttpStatusCode.RequestTimeout => new MaxNetworkException(errorMessage, errorCode, response.StatusCode), _ when (int)response.StatusCode >= 500 => new MaxNetworkException(errorMessage, errorCode, response.StatusCode), + _ when MaxAttachmentNotReadyException.IsAttachmentNotReadyCode(errorCode) => new MaxAttachmentNotReadyException(errorMessage, errorCode, response.StatusCode), _ => new MaxApiException(errorMessage, errorCode, response.StatusCode), }; } + /// + /// Extracts the error code and message from an error response body. + /// MAX API returns errors as {"code": "...", "message": "..."}; the legacy + /// {"ok": false, "error": {"code": "...", "message": "..."}} envelope is still supported. + /// + internal static (string? Code, string? Message) ParseErrorBody(string? responseBody) + { + if (string.IsNullOrWhiteSpace(responseBody)) + { + return (null, null); + } + + try + { + using var document = JsonDocument.Parse(responseBody); + var root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object) + { + return (null, null); + } + + if (root.TryGetProperty("error", out var errorElement) && errorElement.ValueKind == JsonValueKind.Object) + { + var nestedCode = GetString(errorElement, "code"); + var nestedMessage = GetString(errorElement, "message"); + if (nestedCode != null || nestedMessage != null) + { + return (nestedCode, nestedMessage); + } + } + + var code = GetString(root, "code"); + var message = GetString(root, "message"); + if (code == null && message == null && root.TryGetProperty("error", out var errorText) && errorText.ValueKind == JsonValueKind.String) + { + message = errorText.GetString(); + } + + return (code, message); + } + catch (JsonException) + { + return (null, null); + } + + static string? GetString(JsonElement element, string propertyName) + { + return element.TryGetProperty(propertyName, out var value) && value.ValueKind == JsonValueKind.String + ? value.GetString() + : null; + } + } + private TimeSpan? GetRetryAfter(HttpResponseMessage response) { if (response.Headers.TryGetValues("Retry-After", out var values)) @@ -320,6 +353,26 @@ private TimeSpan CalculateRetryDelay(Exception ex, int attempt) return delay > _options.MaxRetryDelay ? _options.MaxRetryDelay : delay; } + private static string BuildUserAgent() + { + var assembly = typeof(MaxHttpClient).Assembly; + var version = assembly + .GetCustomAttributes(typeof(System.Reflection.AssemblyInformationalVersionAttribute), false) + .OfType() + .FirstOrDefault()?.InformationalVersion + ?? assembly.GetName().Version?.ToString() + ?? "0.0.0"; + + // Strip SourceLink commit metadata (e.g. "0.7.0-alpha+abc123"). + var metadataIndex = version.IndexOf('+'); + if (metadataIndex >= 0) + { + version = version[..metadataIndex]; + } + + return $"MaxMessenger.Bot/{version}"; + } + /// /// Disposes the HTTP client. /// diff --git a/src/Max.Bot/Polling/MaxWebhookEndpointRouteBuilderExtensions.cs b/src/Max.Bot/Polling/MaxWebhookEndpointRouteBuilderExtensions.cs new file mode 100644 index 0000000..985c3f2 --- /dev/null +++ b/src/Max.Bot/Polling/MaxWebhookEndpointRouteBuilderExtensions.cs @@ -0,0 +1,119 @@ +using System; +using System.IO; +using System.Text.Json; +using System.Threading.Tasks; +using Max.Bot.Configuration; +using Max.Bot.Networking; +using Max.Bot.Polling; +using Max.Bot.Types; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.Builder; + +/// +/// Extension methods that map the MAX webhook endpoint explicitly. +/// +public static class MaxWebhookEndpointRouteBuilderExtensions +{ + /// + /// The default webhook route. + /// + public const string DefaultPattern = "/api/max/webhook"; + + /// + /// Maps a POST endpoint that receives MAX webhook updates and dispatches them to the registered . + /// + /// + /// Requires the following services in the DI container: + /// (or ), and + /// (or IOptions<MaxBotOptions>). + /// When is set, requests without a matching + /// header (X-Max-Bot-Api-Secret by default) are rejected with 401. + /// + /// The endpoint route builder. + /// The route pattern. Defaults to . + /// A builder that can be used to further customize the endpoint. + public static IEndpointConventionBuilder MapMaxWebhook(this IEndpointRouteBuilder endpoints, string pattern = DefaultPattern) + { + ArgumentNullException.ThrowIfNull(endpoints); + ArgumentException.ThrowIfNullOrWhiteSpace(pattern); + + return endpoints.MapPost(pattern, HandleWebhookAsync); + } + + private static async Task HandleWebhookAsync(HttpContext context) + { + var services = context.RequestServices; + var options = services.GetService() + ?? services.GetService>()?.Value + ?? throw new InvalidOperationException("MaxBotOptions is not registered in the service container."); + var pipeline = services.GetService() + ?? services.GetService() + ?? throw new InvalidOperationException("IUpdatePipeline (for example MaxClient) is not registered in the service container."); + var handler = services.GetService() + ?? throw new InvalidOperationException("IUpdateHandler is not registered in the service container."); + var logger = services.GetService()?.CreateLogger("Max.Bot.Webhook"); + + if (!WebhookRequestValidator.ValidatePayloadSize(context.Request, options, logger)) + { + context.Response.StatusCode = StatusCodes.Status413PayloadTooLarge; + return; + } + + if (!WebhookRequestValidator.ValidateSignature(context.Request, options, logger)) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + return; + } + + var body = await ReadBodyAsync(context, WebhookRequestValidator.GetMaxBodyBytes(options)).ConfigureAwait(false); + if (body == null) + { + logger?.LogWarning("Webhook payload rejected: body exceeds the configured size limit."); + context.Response.StatusCode = StatusCodes.Status413PayloadTooLarge; + return; + } + + Update? update; + try + { + update = body.Length == 0 ? null : JsonSerializer.Deserialize(body, MaxJsonSerializer.Options); + } + catch (JsonException ex) + { + logger?.LogWarning(ex, "Webhook payload rejected: invalid JSON."); + update = null; + } + + if (update == null) + { + context.Response.StatusCode = StatusCodes.Status400BadRequest; + return; + } + + await pipeline.ProcessWebhookAsync(update, handler, services, context.RequestAborted).ConfigureAwait(false); + await context.Response.WriteAsJsonAsync(new { success = true }, context.RequestAborted).ConfigureAwait(false); + } + + private static async Task ReadBodyAsync(HttpContext context, long maxBytes) + { + using var buffer = new MemoryStream(); + var chunk = new byte[81920]; + int read; + while ((read = await context.Request.Body.ReadAsync(chunk, context.RequestAborted).ConfigureAwait(false)) > 0) + { + if (buffer.Length + read > maxBytes) + { + return null; + } + + buffer.Write(chunk, 0, read); + } + + return buffer.ToArray(); + } +} diff --git a/src/Max.Bot/Polling/UpdatePoller.cs b/src/Max.Bot/Polling/UpdatePoller.cs index 1ac3ef8..f29b378 100644 --- a/src/Max.Bot/Polling/UpdatePoller.cs +++ b/src/Max.Bot/Polling/UpdatePoller.cs @@ -187,8 +187,6 @@ private async Task PollAsync(CancellationToken cancellationToken) var response = await FetchUpdatesAsync(_marker, cancellationToken).ConfigureAwait(false); _consecutiveErrors = 0; - long? lastProcessedUpdateId = null; - if (response.Updates?.Length > 0) { foreach (var update in response.Updates) @@ -200,29 +198,27 @@ private async Task PollAsync(CancellationToken cancellationToken) if (!ShouldDispatch(update)) { - lastProcessedUpdateId = update.UpdateId; continue; } try { await DispatchAsync(update, cancellationToken).ConfigureAwait(false); - lastProcessedUpdateId = update.UpdateId; } catch (Exception ex) { _logger?.LogError(ex, "Failed to dispatch update {UpdateId}. Skipping.", update.UpdateId); - lastProcessedUpdateId = update.UpdateId; } } + // * MAX updates carry no update_id: the next page is addressed only by the marker returned with the batch. if (_options.Polling.PersistMarkers) { - _marker = lastProcessedUpdateId ?? response.Marker ?? _marker; + _marker = response.Marker ?? _marker; } else { - _marker = lastProcessedUpdateId ?? response.Marker ?? _options.Polling.InitialMarker; + _marker = response.Marker ?? _options.Polling.InitialMarker; } } else diff --git a/src/Max.Bot/Polling/WebhookController.cs b/src/Max.Bot/Polling/WebhookController.cs index c09f88d..15887ad 100644 --- a/src/Max.Bot/Polling/WebhookController.cs +++ b/src/Max.Bot/Polling/WebhookController.cs @@ -1,6 +1,4 @@ using System; -using System.Security.Cryptography; -using System.Text; using System.Threading; using System.Threading.Tasks; using Max.Bot.Configuration; @@ -12,11 +10,32 @@ namespace Max.Bot.Polling; /// -/// Default ASP.NET Core controller that receives webhook callbacks from MAX. +/// Base ASP.NET Core controller that receives webhook callbacks from MAX. /// +/// +/// +/// The controller is abstract, so referencing the library no longer adds a POST /api/max/webhook endpoint +/// to every ASP.NET Core application. To receive webhooks either map the minimal endpoint with +/// app.MapMaxWebhook(), or opt in to the controller by deriving from it: +/// +/// +/// [Route("api/max/webhook")] +/// public sealed class MaxWebhookController : WebhookController +/// { +/// public MaxWebhookController(IUpdatePipeline pipeline, IUpdateHandler handler, MaxBotOptions options, ILogger<WebhookController>? logger = null) +/// : base(pipeline, handler, options, logger) +/// { +/// } +/// } +/// +/// +/// (for example a singleton ), +/// and must be registered in the DI container. +/// +/// [ApiController] [Route("api/max/webhook")] -public class WebhookController : ControllerBase +public abstract class WebhookController : ControllerBase { private readonly IUpdatePipeline _pipeline; private readonly IUpdateHandler _handler; @@ -26,7 +45,7 @@ public class WebhookController : ControllerBase /// /// Initializes a new instance of the class. /// - public WebhookController(IUpdatePipeline pipeline, IUpdateHandler handler, MaxBotOptions options, ILogger? logger = null) + protected WebhookController(IUpdatePipeline pipeline, IUpdateHandler handler, MaxBotOptions options, ILogger? logger = null) { _pipeline = pipeline ?? throw new ArgumentNullException(nameof(pipeline)); _handler = handler ?? throw new ArgumentNullException(nameof(handler)); @@ -45,12 +64,12 @@ public virtual async Task PostAsync([FromBody] Update update, Can return BadRequest(ModelState); } - if (!ValidatePayloadSize()) + if (!WebhookRequestValidator.ValidatePayloadSize(Request, _options, _logger)) { return StatusCode(StatusCodes.Status413PayloadTooLarge); } - if (!ValidateSignature()) + if (!WebhookRequestValidator.ValidateSignature(Request, _options, _logger)) { return Unauthorized(); } @@ -58,61 +77,4 @@ public virtual async Task PostAsync([FromBody] Update update, Can await _pipeline.ProcessWebhookAsync(update, _handler, HttpContext.RequestServices, cancellationToken).ConfigureAwait(false); return Ok(new { success = true }); } - - private bool ValidatePayloadSize() - { - if (Request.ContentLength is null) - { - return true; - } - - var maxBytes = _options.Webhook.MaxBodySizeKilobytes * 1024L; - if (Request.ContentLength > maxBytes) - { - _logger?.LogWarning("Webhook payload rejected: {Length} bytes exceeds limit {Limit} bytes.", Request.ContentLength, maxBytes); - return false; - } - - return true; - } - - private bool ValidateSignature() - { - var secret = _options.Webhook.SecretToken; - if (string.IsNullOrWhiteSpace(secret)) - { - return true; - } - - if (!Request.Headers.TryGetValue(_options.Webhook.SignatureHeaderName, out var headerValues)) - { - _logger?.LogWarning("Webhook payload rejected: missing header {HeaderName}.", _options.Webhook.SignatureHeaderName); - return false; - } - - var provided = headerValues.ToString(); - if (!SecureEquals(provided, secret)) - { - _logger?.LogWarning("Webhook payload rejected: signature validation failed."); - return false; - } - - return true; - } - - private static bool SecureEquals(string provided, string expected) - { - var providedBytes = Encoding.UTF8.GetBytes(provided); - var expectedBytes = Encoding.UTF8.GetBytes(expected); - - if (providedBytes.Length != expectedBytes.Length) - { - return false; - } - - return CryptographicOperations.FixedTimeEquals(providedBytes, expectedBytes); - } } - - - diff --git a/src/Max.Bot/Polling/WebhookRequestValidator.cs b/src/Max.Bot/Polling/WebhookRequestValidator.cs new file mode 100644 index 0000000..02686ca --- /dev/null +++ b/src/Max.Bot/Polling/WebhookRequestValidator.cs @@ -0,0 +1,69 @@ +using System.Security.Cryptography; +using System.Text; +using Max.Bot.Configuration; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +namespace Max.Bot.Polling; + +/// +/// Shared validation of incoming MAX webhook requests (payload size and secret header). +/// +internal static class WebhookRequestValidator +{ + public static long GetMaxBodyBytes(MaxBotOptions options) => options.Webhook.MaxBodySizeKilobytes * 1024L; + + public static bool ValidatePayloadSize(HttpRequest request, MaxBotOptions options, ILogger? logger) + { + if (request.ContentLength is null) + { + return true; + } + + var maxBytes = GetMaxBodyBytes(options); + if (request.ContentLength > maxBytes) + { + logger?.LogWarning("Webhook payload rejected: {Length} bytes exceeds limit {Limit} bytes.", request.ContentLength, maxBytes); + return false; + } + + return true; + } + + public static bool ValidateSignature(HttpRequest request, MaxBotOptions options, ILogger? logger) + { + var secret = options.Webhook.SecretToken; + if (string.IsNullOrWhiteSpace(secret)) + { + return true; + } + + var headerName = options.Webhook.SignatureHeaderName; + if (!request.Headers.TryGetValue(headerName, out var headerValues)) + { + logger?.LogWarning("Webhook payload rejected: missing header {HeaderName}.", headerName); + return false; + } + + if (!SecureEquals(headerValues.ToString(), secret)) + { + logger?.LogWarning("Webhook payload rejected: signature validation failed."); + return false; + } + + return true; + } + + private static bool SecureEquals(string provided, string expected) + { + var providedBytes = Encoding.UTF8.GetBytes(provided); + var expectedBytes = Encoding.UTF8.GetBytes(expected); + + if (providedBytes.Length != expectedBytes.Length) + { + return false; + } + + return CryptographicOperations.FixedTimeEquals(providedBytes, expectedBytes); + } +} diff --git a/src/Max.Bot/Types/Attachment.cs b/src/Max.Bot/Types/Attachment.cs index 0a2b57f..69fd60b 100644 --- a/src/Max.Bot/Types/Attachment.cs +++ b/src/Max.Bot/Types/Attachment.cs @@ -25,13 +25,14 @@ public abstract class Attachment public class PhotoAttachment : Attachment { /// - /// Gets or sets the unique identifier of the photo. + /// Gets or sets the unique identifier of the photo (payload.photo_id). /// [JsonPropertyName("id")] public long Id { get; set; } /// /// Gets or sets the file identifier of the photo. + /// For MAX API payloads this contains the same value as (kept for backward compatibility). /// [JsonPropertyName("file_id")] public string FileId { get; set; } = string.Empty; @@ -60,6 +61,13 @@ public class PhotoAttachment : Attachment [JsonPropertyName("url")] public string? Url { get; set; } + /// + /// Gets or sets the attachment token. Use it to send the same media again + /// (for example with ). + /// + [JsonPropertyName("token")] + public string? Token { get; set; } + /// /// Initializes a new instance of the class. /// @@ -80,6 +88,7 @@ public class VideoAttachment : Attachment /// /// Gets or sets the file identifier of the video. + /// For MAX API payloads this contains the same value as (kept for backward compatibility). /// [JsonPropertyName("file_id")] public string FileId { get; set; } = string.Empty; @@ -120,6 +129,19 @@ public class VideoAttachment : Attachment [JsonPropertyName("url")] public string? Url { get; set; } + /// + /// Gets or sets the attachment token. Use it to send the same media again + /// (for example with ). + /// + [JsonPropertyName("token")] + public string? Token { get; set; } + + /// + /// Gets or sets the video thumbnail. + /// + [JsonPropertyName("thumbnail")] + public VideoThumbnail? Thumbnail { get; set; } + /// /// Initializes a new instance of the class. /// @@ -140,6 +162,7 @@ public class AudioAttachment : Attachment /// /// Gets or sets the file identifier of the audio file. + /// For MAX API payloads this contains the same value as (kept for backward compatibility). /// [JsonPropertyName("file_id")] public string FileId { get; set; } = string.Empty; @@ -168,6 +191,19 @@ public class AudioAttachment : Attachment [JsonPropertyName("url")] public string? Url { get; set; } + /// + /// Gets or sets the attachment token. Use it to send the same media again + /// (for example with ). + /// + [JsonPropertyName("token")] + public string? Token { get; set; } + + /// + /// Gets or sets the audio transcription, if MAX provides it. + /// + [JsonPropertyName("transcription")] + public string? Transcription { get; set; } + /// /// Initializes a new instance of the class. /// @@ -188,12 +224,13 @@ public class DocumentAttachment : Attachment /// /// Gets or sets the file identifier of the document. + /// For MAX API payloads this contains the same value as (kept for backward compatibility). /// [JsonPropertyName("file_id")] public string FileId { get; set; } = string.Empty; /// - /// Gets or sets the name of the document file. + /// Gets or sets the name of the document file (filename in MAX API). /// [JsonPropertyName("file_name")] public string? FileName { get; set; } @@ -216,6 +253,13 @@ public class DocumentAttachment : Attachment [JsonPropertyName("url")] public string? Url { get; set; } + /// + /// Gets or sets the attachment token. Use it to send the same media again + /// (for example with ). + /// + [JsonPropertyName("token")] + public string? Token { get; set; } + /// /// Initializes a new instance of the class. /// @@ -313,6 +357,82 @@ public class InlineKeyboardAttachment : Attachment public InlineKeyboardAttachment() => Type = AttachmentTypeNames.InlineKeyboard; } +/// +/// Represents a sticker attachment. +/// +public class StickerAttachment : Attachment +{ + /// + /// Gets or sets the sticker image URL. + /// + [JsonPropertyName("url")] + public string? Url { get; set; } + + /// + /// Gets or sets the sticker code. Use it with to send the same sticker. + /// + [JsonPropertyName("code")] + public string? Code { get; set; } + + /// + /// Gets or sets the sticker width in pixels. + /// + [JsonPropertyName("width")] + public int? Width { get; set; } + + /// + /// Gets or sets the sticker height in pixels. + /// + [JsonPropertyName("height")] + public int? Height { get; set; } + + /// + /// Initializes a new instance of the class. + /// + public StickerAttachment() => Type = AttachmentTypeNames.Sticker; +} + +/// +/// Represents a link preview (share) attachment. +/// +public class ShareAttachment : Attachment +{ + /// + /// Gets or sets the shared URL. + /// + [JsonPropertyName("url")] + public string? Url { get; set; } + + /// + /// Gets or sets the attachment token. + /// + [JsonPropertyName("token")] + public string? Token { get; set; } + + /// + /// Gets or sets the link preview title. + /// + [JsonPropertyName("title")] + public string? Title { get; set; } + + /// + /// Gets or sets the link preview description. + /// + [JsonPropertyName("description")] + public string? Description { get; set; } + + /// + /// Gets or sets the link preview image URL. + /// + [JsonPropertyName("image_url")] + public string? ImageUrl { get; set; } + + /// + /// Initializes a new instance of the class. + /// + public ShareAttachment() => Type = AttachmentTypeNames.Share; +} + internal static class AttachmentTypeNames { public const string Image = "image"; @@ -322,4 +442,6 @@ internal static class AttachmentTypeNames public const string InlineKeyboard = "inline_keyboard"; public const string Location = "location"; public const string Contact = "contact"; + public const string Sticker = "sticker"; + public const string Share = "share"; } diff --git a/src/Max.Bot/Types/Chat.cs b/src/Max.Bot/Types/Chat.cs index 67dca91..9bac338 100644 --- a/src/Max.Bot/Types/Chat.cs +++ b/src/Max.Bot/Types/Chat.cs @@ -35,6 +35,7 @@ public class Chat /// Possible values: "active", "removed", "left", "closed". /// [JsonPropertyName("status")] + [JsonConverter(typeof(Converters.LenientNullableEnumConverter))] public ChatStatus? Status { get; set; } /// @@ -120,4 +121,10 @@ public class Chat /// [JsonPropertyName("pinned_message")] public Message? PinnedMessage { get; set; } + + /// + /// Gets or sets the number of messages in the chat. Only for group chats and channels. + /// + [JsonPropertyName("messages_count")] + public long? MessagesCount { get; set; } } diff --git a/src/Max.Bot/Types/ChatListResponse.cs b/src/Max.Bot/Types/ChatListResponse.cs new file mode 100644 index 0000000..e68b3af --- /dev/null +++ b/src/Max.Bot/Types/ChatListResponse.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; + +namespace Max.Bot.Types; + +/// +/// Represents a page of chats returned by GET /chats. +/// +public class ChatListResponse +{ + /// + /// Gets or sets the chats on this page. + /// + [JsonPropertyName("chats")] + public Chat[] Chats { get; set; } = Array.Empty(); + + /// + /// Gets or sets the marker of the next page, or null when there are no more chats. + /// + [JsonPropertyName("marker")] + public long? Marker { get; set; } +} diff --git a/src/Max.Bot/Types/ChatMember.cs b/src/Max.Bot/Types/ChatMember.cs index d5dd044..06f5e4f 100644 --- a/src/Max.Bot/Types/ChatMember.cs +++ b/src/Max.Bot/Types/ChatMember.cs @@ -105,6 +105,7 @@ public class ChatMember /// Only populated if the member is an admin; otherwise null. /// [JsonPropertyName("permissions")] + [JsonConverter(typeof(Converters.LenientEnumArrayConverter))] public ChatAdminPermission[]? Permissions { get; set; } /// diff --git a/src/Max.Bot/Types/Converters/AttachmentJsonConverter.cs b/src/Max.Bot/Types/Converters/AttachmentJsonConverter.cs index c582997..f651c8f 100644 --- a/src/Max.Bot/Types/Converters/AttachmentJsonConverter.cs +++ b/src/Max.Bot/Types/Converters/AttachmentJsonConverter.cs @@ -1,5 +1,6 @@ using System; using System.Text.Json; +using System.Text.Json.Nodes; using System.Text.Json.Serialization; using Max.Bot.Types; @@ -36,43 +37,53 @@ public class AttachmentJsonConverter : JsonConverter } // Route by type name — primary and most reliable method + if (IsType(typeString, AttachmentTypeNames.InlineKeyboard)) + { + return JsonSerializer.Deserialize(root.GetRawText(), options); + } + if (IsType(typeString, AttachmentTypeNames.Image)) { - return DeserializePhotoAttachment(root, options); + return DeserializeMerged(root, typeString, "photo", options); } - if (IsType(typeString, AttachmentTypeNames.InlineKeyboard)) + if (IsType(typeString, AttachmentTypeNames.Video)) { - return JsonSerializer.Deserialize(root.GetRawText(), options); + return DeserializeMerged(root, typeString, "video", options); } - if (IsType(typeString, AttachmentTypeNames.Location)) + if (IsType(typeString, AttachmentTypeNames.Audio)) + { + return DeserializeMerged(root, typeString, "audio", options); + } + + if (IsType(typeString, AttachmentTypeNames.File)) { - return JsonSerializer.Deserialize(root.GetRawText(), options); + return DeserializeMerged(root, typeString, "document", options); } if (IsType(typeString, AttachmentTypeNames.Contact)) { - return DeserializeContactAttachment(root, options); + return DeserializeMerged(root, typeString, null, options); } - if (IsType(typeString, AttachmentTypeNames.Video)) + if (IsType(typeString, AttachmentTypeNames.Location)) { - return DeserializeMediaAttachment(root, "video", options); + return DeserializeMerged(root, typeString, null, options); } - if (IsType(typeString, AttachmentTypeNames.Audio)) + if (IsType(typeString, AttachmentTypeNames.Sticker)) { - return DeserializeMediaAttachment(root, "audio", options); + return DeserializeMerged(root, typeString, null, options); } - if (IsType(typeString, AttachmentTypeNames.File)) + if (IsType(typeString, AttachmentTypeNames.Share)) { - return DeserializeMediaAttachment(root, "document", options); + return DeserializeMerged(root, typeString, null, options); } - // Fallback for unknown types — use DocumentAttachment as it has the most generic fields - return JsonSerializer.Deserialize(root.GetRawText(), options); + // Fallback for unknown types — use DocumentAttachment as it has the most generic fields (url, token) + return DeserializeMerged(root, typeString, null, options); } /// @@ -101,139 +112,112 @@ public override void Write(Utf8JsonWriter writer, Attachment value, JsonSerializ case InlineKeyboardAttachment inlineKeyboardAttachment: JsonSerializer.Serialize(writer, inlineKeyboardAttachment, options); break; + case StickerAttachment stickerAttachment: + JsonSerializer.Serialize(writer, stickerAttachment, options); + break; + case ShareAttachment shareAttachment: + JsonSerializer.Serialize(writer, shareAttachment, options); + break; default: throw new JsonException($"Unknown attachment type: {value.GetType()}"); } } - private static T? DeserializeAttachment(JsonElement root, string payloadPropertyName, JsonSerializerOptions options) + /// + /// MAX API splits attachment data between the payload object (url, token, photo_id, vcf_info, ...) + /// and the attachment root (filename, size, width, height, duration, thumbnail, ...). + /// This method merges both levels into one object and deserializes it into the flat attachment model. + /// + private static T? DeserializeMerged(JsonElement root, string? typeString, string? legacyPayloadPropertyName, JsonSerializerOptions options) where T : Attachment { - if (root.TryGetProperty(payloadPropertyName, out var payload) && payload.ValueKind == JsonValueKind.Object) - { - var attachment = JsonSerializer.Deserialize(payload.GetRawText(), options); - if (attachment != null && root.TryGetProperty("type", out var typeElement) && typeElement.ValueKind == JsonValueKind.String) - { - attachment.Type = typeElement.GetString() ?? attachment.Type; - } + var merged = new JsonObject(); - return attachment; + JsonElement payload = default; + var hasPayload = root.TryGetProperty("payload", out payload) && payload.ValueKind == JsonValueKind.Object; + if (!hasPayload && legacyPayloadPropertyName != null) + { + // Legacy nested format, e.g. {"type":"image","photo":{...}} + hasPayload = root.TryGetProperty(legacyPayloadPropertyName, out payload) && payload.ValueKind == JsonValueKind.Object; } - return JsonSerializer.Deserialize(root.GetRawText(), options); - } - - private static PhotoAttachment? DeserializePhotoAttachment(JsonElement root, JsonSerializerOptions options) - { - if (root.TryGetProperty("payload", out var payload) && payload.ValueKind == JsonValueKind.Object) + if (hasPayload) { - var attachment = new PhotoAttachment(); - - if (payload.TryGetProperty("id", out var idElement) && idElement.ValueKind == JsonValueKind.Number && idElement.TryGetInt64(out var id)) - { - attachment.Id = id; - } - else if (payload.TryGetProperty("photo_id", out var photoIdElement) && photoIdElement.ValueKind == JsonValueKind.Number && photoIdElement.TryGetInt64(out var photoId)) - { - attachment.Id = photoId; - } - - if (payload.TryGetProperty("file_id", out var fileIdElement) && fileIdElement.ValueKind == JsonValueKind.String) + foreach (var property in payload.EnumerateObject()) { - attachment.FileId = fileIdElement.GetString() ?? string.Empty; - } - else if (payload.TryGetProperty("token", out var tokenElement) && tokenElement.ValueKind == JsonValueKind.String) - { - attachment.FileId = tokenElement.GetString() ?? string.Empty; + AddProperty(merged, property); } + } - if (payload.TryGetProperty("width", out var widthElement) && widthElement.ValueKind == JsonValueKind.Number && widthElement.TryGetInt32(out var width)) + foreach (var property in root.EnumerateObject()) + { + if (property.NameEquals("payload") || + (legacyPayloadPropertyName != null && property.NameEquals(legacyPayloadPropertyName) && property.Value.ValueKind == JsonValueKind.Object)) { - attachment.Width = width; + continue; } - if (payload.TryGetProperty("height", out var heightElement) && heightElement.ValueKind == JsonValueKind.Number && heightElement.TryGetInt32(out var height)) - { - attachment.Height = height; - } + AddProperty(merged, property); + } - if (payload.TryGetProperty("file_size", out var fileSizeElement) && fileSizeElement.ValueKind == JsonValueKind.Number && fileSizeElement.TryGetInt64(out var fileSize)) - { - attachment.FileSize = fileSize; - } + // Map MAX API field names onto the model property names. + CopyAlias(merged, "photo_id", "id"); + CopyAlias(merged, "filename", "file_name"); + CopyAlias(merged, "size", "file_size"); - if (payload.TryGetProperty("url", out var urlElement) && urlElement.ValueKind == JsonValueKind.String) - { - attachment.Url = urlElement.GetString(); - } + if (merged.TryGetPropertyValue("file_id", out var fileIdNode) && fileIdNode is JsonValue fileIdValue && fileIdValue.TryGetValue(out var numericFileId)) + { + merged["file_id"] = numericFileId.ToString(System.Globalization.CultureInfo.InvariantCulture); + } - return attachment; + var attachment = merged.Deserialize(options); + if (attachment == null) + { + return null; } - if (root.TryGetProperty("photo", out var photo) && photo.ValueKind == JsonValueKind.Object) + if (!string.IsNullOrWhiteSpace(typeString)) { - var attachment = JsonSerializer.Deserialize(photo.GetRawText(), options); - if (attachment != null) - { - attachment.Type = AttachmentTypeNames.Image; - } + attachment.Type = typeString; + } - return attachment; + // Backward compatibility: FileId historically carried the media token. + switch (attachment) + { + case PhotoAttachment photo when string.IsNullOrEmpty(photo.FileId) && !string.IsNullOrEmpty(photo.Token): + photo.FileId = photo.Token; + break; + case VideoAttachment video when string.IsNullOrEmpty(video.FileId) && !string.IsNullOrEmpty(video.Token): + video.FileId = video.Token; + break; + case AudioAttachment audio when string.IsNullOrEmpty(audio.FileId) && !string.IsNullOrEmpty(audio.Token): + audio.FileId = audio.Token; + break; + case DocumentAttachment document when string.IsNullOrEmpty(document.FileId) && !string.IsNullOrEmpty(document.Token): + document.FileId = document.Token; + break; } - return JsonSerializer.Deserialize(root.GetRawText(), options); + return attachment; } - private static ContactAttachment? DeserializeContactAttachment(JsonElement root, JsonSerializerOptions options) + private static void AddProperty(JsonObject target, JsonProperty property) { - if (root.TryGetProperty("payload", out var payload) && payload.ValueKind == JsonValueKind.Object) + // Skip nulls so that non-nullable model properties keep their defaults. + if (property.Value.ValueKind == JsonValueKind.Null || target.ContainsKey(property.Name)) { - var attachment = JsonSerializer.Deserialize(payload.GetRawText(), options); - if (attachment != null) - { - // Ensure type is always set even when payload does not include it. - attachment.Type = AttachmentTypeNames.Contact; - } - return attachment; + return; } - return JsonSerializer.Deserialize(root.GetRawText(), options); + target[property.Name] = JsonNode.Parse(property.Value.GetRawText()); } - private static T? DeserializeMediaAttachment(JsonElement root, string payloadPropertyName, JsonSerializerOptions options) - where T : Attachment + private static void CopyAlias(JsonObject target, string sourceName, string targetName) { - if (root.TryGetProperty("payload", out var payload) && payload.ValueKind == JsonValueKind.Object) + if (!target.ContainsKey(targetName) && target.TryGetPropertyValue(sourceName, out var value) && value != null) { - var attachment = JsonSerializer.Deserialize(payload.GetRawText(), options); - if (attachment == null) - { - return null; - } - - switch (attachment) - { - case AudioAttachment audio when string.IsNullOrWhiteSpace(audio.FileId) - && payload.TryGetProperty("token", out var audioToken) - && audioToken.ValueKind == JsonValueKind.String: - audio.FileId = audioToken.GetString() ?? string.Empty; - break; - case VideoAttachment video when string.IsNullOrWhiteSpace(video.FileId) - && payload.TryGetProperty("token", out var videoToken) - && videoToken.ValueKind == JsonValueKind.String: - video.FileId = videoToken.GetString() ?? string.Empty; - break; - case DocumentAttachment document when string.IsNullOrWhiteSpace(document.FileId) - && payload.TryGetProperty("token", out var documentToken) - && documentToken.ValueKind == JsonValueKind.String: - document.FileId = documentToken.GetString() ?? string.Empty; - break; - } - - return attachment; + target[targetName] = value.DeepClone(); } - - return DeserializeAttachment(root, payloadPropertyName, options); } private static bool IsType(string? actualType, string expectedType) diff --git a/src/Max.Bot/Types/Converters/LenientEnumConverters.cs b/src/Max.Bot/Types/Converters/LenientEnumConverters.cs new file mode 100644 index 0000000..5c87e5b --- /dev/null +++ b/src/Max.Bot/Types/Converters/LenientEnumConverters.cs @@ -0,0 +1,127 @@ +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Max.Bot.Types.Converters; + +/// +/// Reads a nullable enum from a snake_case string and returns null for values unknown to this library version, +/// so that new values added by MAX API do not break deserialization of the whole response. +/// +/// The enum type. +public sealed class LenientNullableEnumConverter : JsonConverter + where TEnum : struct, Enum +{ + /// + public override TEnum? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.String) + { + return LenientEnumParser.TryParse(reader.GetString(), out var value) ? value : null; + } + + if (reader.TokenType == JsonTokenType.Number && reader.TryGetInt32(out var number) && Enum.IsDefined(typeof(TEnum), number)) + { + return (TEnum)Enum.ToObject(typeof(TEnum), number); + } + + reader.Skip(); + return null; + } + + /// + public override void Write(Utf8JsonWriter writer, TEnum? value, JsonSerializerOptions options) + { + if (value.HasValue) + { + writer.WriteStringValue(LenientEnumParser.ToSnakeCase(value.Value.ToString())); + } + else + { + writer.WriteNullValue(); + } + } +} + +/// +/// Reads an array of enums from snake_case strings, skipping values unknown to this library version. +/// +/// The enum type. +public sealed class LenientEnumArrayConverter : JsonConverter + where TEnum : struct, Enum +{ + /// + public override TEnum[]? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + { + return null; + } + + if (reader.TokenType != JsonTokenType.StartArray) + { + reader.Skip(); + return null; + } + + var values = new List(); + while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) + { + if (reader.TokenType == JsonTokenType.String && LenientEnumParser.TryParse(reader.GetString(), out var value)) + { + values.Add(value); + } + else if (reader.TokenType is JsonTokenType.StartObject or JsonTokenType.StartArray) + { + reader.Skip(); + } + } + + return values.ToArray(); + } + + /// + public override void Write(Utf8JsonWriter writer, TEnum[]? value, JsonSerializerOptions options) + { + if (value == null) + { + writer.WriteNullValue(); + return; + } + + writer.WriteStartArray(); + foreach (var item in value) + { + writer.WriteStringValue(LenientEnumParser.ToSnakeCase(item.ToString())); + } + + writer.WriteEndArray(); + } +} + +internal static class LenientEnumParser +{ + public static bool TryParse(string? value, out TEnum result) + where TEnum : struct, Enum + { + result = default; + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + var normalized = value.Replace("_", string.Empty, StringComparison.Ordinal).Trim(); + foreach (var candidate in Enum.GetValues()) + { + if (string.Equals(candidate.ToString(), normalized, StringComparison.OrdinalIgnoreCase)) + { + result = candidate; + return true; + } + } + + return false; + } + + public static string ToSnakeCase(string value) => JsonNamingPolicy.SnakeCaseLower.ConvertName(value); +} diff --git a/src/Max.Bot/Types/Converters/UpdateJsonConverter.cs b/src/Max.Bot/Types/Converters/UpdateJsonConverter.cs index ed2e529..bc14b3f 100644 --- a/src/Max.Bot/Types/Converters/UpdateJsonConverter.cs +++ b/src/Max.Bot/Types/Converters/UpdateJsonConverter.cs @@ -28,7 +28,7 @@ public override Update Read(ref Utf8JsonReader reader, Type typeToConvert, JsonS var update = new Update(); // Read base fields present in all update types - if (root.TryGetProperty("update_id", out var updateIdElement)) + if (root.TryGetProperty("update_id", out var updateIdElement) && updateIdElement.ValueKind == JsonValueKind.Number) { update.UpdateId = updateIdElement.GetInt64(); } @@ -38,15 +38,9 @@ public override Update Read(ref Utf8JsonReader reader, Type typeToConvert, JsonS update.UpdateTypeRaw = updateTypeElement.GetString(); } - if (root.TryGetProperty("timestamp", out var timestampElement)) - { - update.Timestamp = timestampElement.GetInt64(); - } + update.Timestamp = GetInt64(root, "timestamp"); - if (root.TryGetProperty("user_locale", out var userLocaleElement)) - { - update.UserLocale = userLocaleElement.GetString(); - } + update.UserLocale = GetString(root, "user_locale"); // Read message (message_created, message_edited, message_removed, message_chat_created) if (root.TryGetProperty("message", out var messageElement)) @@ -73,31 +67,108 @@ public override Update Read(ref Utf8JsonReader reader, Type typeToConvert, JsonS } // Read inviter_id (bot_added, user_added) - if (root.TryGetProperty("inviter_id", out var inviterIdElement)) + update.InviterId = GetInt64(root, "inviter_id"); + + // Read chat_id (bot_added, bot_removed, bot_started, bot_stopped, user_added, user_removed, + // chat_title_changed, message_removed, dialog_* events) + update.ChatId = GetInt64(root, "chat_id"); + + // Read is_muted (dialog_muted, dialog_unmuted) + if (root.TryGetProperty("is_muted", out var isMutedElement) && + isMutedElement.ValueKind is JsonValueKind.True or JsonValueKind.False) { - update.InviterId = inviterIdElement.GetInt64(); + update.IsMuted = isMutedElement.GetBoolean(); } - // Read chat_id (dialog_muted, dialog_unmuted, dialog_cleared, dialog_removed, bot_started, bot_stopped) - if (root.TryGetProperty("chat_id", out var chatIdElement)) + // Read message_id / user_id (message_removed, message_chat_created) + update.MessageId = GetString(root, "message_id"); + update.UserId = GetInt64(root, "user_id"); + + // Read admin_id (user_removed), title (chat_title_changed, message_chat_created), is_channel, muted_until + update.AdminId = GetInt64(root, "admin_id"); + update.Title = GetString(root, "title"); + update.MutedUntil = GetInt64(root, "muted_until"); + if (root.TryGetProperty("is_channel", out var isChannelElement) && + isChannelElement.ValueKind is JsonValueKind.True or JsonValueKind.False) { - update.ChatId = chatIdElement.GetInt64(); + update.IsChannel = isChannelElement.GetBoolean(); } - // Read is_muted (dialog_muted, dialog_unmuted) - if (root.TryGetProperty("is_muted", out var isMutedElement)) + // Read deep-link payload (bot_started: payload, message_chat_created: start_payload) + if (root.TryGetProperty("payload", out var payloadElement) && payloadElement.ValueKind != JsonValueKind.Null) { - update.IsMuted = isMutedElement.GetBoolean(); + update.Payload = payloadElement.ValueKind == JsonValueKind.String ? payloadElement.GetString() : payloadElement.GetRawText(); } - - if (root.TryGetProperty("payload", out var payloadElement)) + else if (root.TryGetProperty("start_payload", out var startPayloadElement) && startPayloadElement.ValueKind == JsonValueKind.String) { - update.Payload = payloadElement.ValueKind == JsonValueKind.Null ? null : payloadElement.GetString(); + update.Payload = startPayloadElement.GetString(); } + InferChatFromMessage(update); + return update; } + /// + /// Message updates (message_created, message_edited, message_callback) carry no chat object: + /// MAX sends the chat identifier and type in message.recipient. Expose them via + /// and a partial . + /// + private static void InferChatFromMessage(Update update) + { + var recipient = update.Message?.Recipient; + if (recipient?.ChatId is not { } recipientChatId) + { + return; + } + + if (update.ChatId == null) + { + update.ChatId = recipientChatId; + update.IsChatIdInferred = true; + } + + if (update.Chat == null && Update.TryParseChatType(recipient.ChatType, out var chatType)) + { + update.Chat = new Chat + { + ChatId = recipientChatId, + Type = chatType + }; + update.IsChatInferred = true; + } + } + + private static long? GetInt64(JsonElement root, string propertyName) + { + if (!root.TryGetProperty(propertyName, out var element)) + { + return null; + } + + return element.ValueKind switch + { + JsonValueKind.Number when element.TryGetInt64(out var value) => value, + JsonValueKind.String when long.TryParse(element.GetString(), out var parsed) => parsed, + _ => null + }; + } + + private static string? GetString(JsonElement root, string propertyName) + { + if (!root.TryGetProperty(propertyName, out var element)) + { + return null; + } + + return element.ValueKind switch + { + JsonValueKind.String => element.GetString(), + JsonValueKind.Number => element.GetRawText(), + _ => null + }; + } + /// public override void Write(Utf8JsonWriter writer, Update value, JsonSerializerOptions options) { @@ -134,8 +205,8 @@ public override void Write(Utf8JsonWriter writer, Update value, JsonSerializerOp JsonSerializer.Serialize(writer, value.Callback, options); } - // Write chat - if (value.Chat != null) + // Write chat (skip the partial chat inferred from message.recipient) + if (value.Chat != null && !value.IsChatInferred) { writer.WritePropertyName("chat"); JsonSerializer.Serialize(writer, value.Chat, options); @@ -154,12 +225,42 @@ public override void Write(Utf8JsonWriter writer, Update value, JsonSerializerOp writer.WriteNumber("inviter_id", value.InviterId.Value); } - // Write chat_id - if (value.ChatId.HasValue) + // Write chat_id (skip the value inferred from message.recipient) + if (value.ChatId.HasValue && !value.IsChatIdInferred) { writer.WriteNumber("chat_id", value.ChatId.Value); } + if (value.MessageId != null) + { + writer.WriteString("message_id", value.MessageId); + } + + if (value.UserId.HasValue) + { + writer.WriteNumber("user_id", value.UserId.Value); + } + + if (value.AdminId.HasValue) + { + writer.WriteNumber("admin_id", value.AdminId.Value); + } + + if (value.Title != null) + { + writer.WriteString("title", value.Title); + } + + if (value.IsChannel.HasValue) + { + writer.WriteBoolean("is_channel", value.IsChannel.Value); + } + + if (value.MutedUntil.HasValue) + { + writer.WriteNumber("muted_until", value.MutedUntil.Value); + } + // Write is_muted if (value.IsMuted.HasValue) { diff --git a/src/Max.Bot/Types/Enums/ChatStatus.cs b/src/Max.Bot/Types/Enums/ChatStatus.cs index 44cdca1..836e932 100644 --- a/src/Max.Bot/Types/Enums/ChatStatus.cs +++ b/src/Max.Bot/Types/Enums/ChatStatus.cs @@ -26,6 +26,11 @@ public enum ChatStatus /// /// Chat was closed. /// - Closed + Closed, + + /// + /// Chat was suspended. + /// + Suspended } diff --git a/src/Max.Bot/Types/FileUploadResult.cs b/src/Max.Bot/Types/FileUploadResult.cs index cfb91ef..9d2ecb8 100644 --- a/src/Max.Bot/Types/FileUploadResult.cs +++ b/src/Max.Bot/Types/FileUploadResult.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Linq; using System.Text.Json.Serialization; namespace Max.Bot.Types; @@ -6,18 +7,67 @@ namespace Max.Bot.Types; /// /// Represents the result of uploading file data to an upload URL. /// +/// +/// MAX API returns different shapes depending on the upload type: +/// +/// image: {"photos": {"<key>": {"token": "..."}}} — is filled and is set to the first photo token. +/// file: {"file_id": ..., "token": "..."}. +/// video/audio: the token is issued by POST /uploads (); +/// use IFilesApi.UploadAsync to get it filled automatically. +/// +/// public class FileUploadResult { /// - /// Gets or sets the uploaded media token. + /// Gets or sets the uploaded media token that can be used in an attachment payload. /// [JsonPropertyName("token")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? Token { get; set; } + /// + /// Gets or sets the uploaded file identifier (returned for file uploads). + /// + [JsonPropertyName("file_id")] + [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? FileId { get; set; } + + /// + /// Gets or sets the photo tokens returned after an image upload, keyed by photo identifier. + /// + [JsonPropertyName("photos")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Photos { get; set; } + /// /// Gets or sets additional properties received in the response. /// [JsonExtensionData] public Dictionary? AdditionalProperties { get; set; } + + /// + /// Fills from when the response contains only photo tokens. + /// + internal void NormalizeToken() + { + if (!string.IsNullOrEmpty(Token) || Photos == null || Photos.Count == 0) + { + return; + } + + Token = Photos.Values.Select(photo => photo?.Token).FirstOrDefault(token => !string.IsNullOrEmpty(token)); + } +} + +/// +/// Represents a token of an uploaded image returned in . +/// +public class PhotoSizeToken +{ + /// + /// Gets or sets the encoded information of the uploaded image. + /// + [JsonPropertyName("token")] + public string Token { get; set; } = string.Empty; } diff --git a/src/Max.Bot/Types/Message.cs b/src/Max.Bot/Types/Message.cs index 734d137..3af8bf4 100644 --- a/src/Max.Bot/Types/Message.cs +++ b/src/Max.Bot/Types/Message.cs @@ -172,6 +172,12 @@ public class MessageBody /// [JsonPropertyName("attachments")] public Attachment[]? Attachments { get; set; } + + /// + /// Gets or sets the text markup (bold, links, mentions, ...) applied to . + /// + [JsonPropertyName("markup")] + public MarkupElement[]? Markup { get; set; } } /// @@ -186,4 +192,53 @@ public class MessageStat [Range(0, int.MaxValue, ErrorMessage = "ReadCount cannot be negative.")] [JsonPropertyName("read_count")] public int? ReadCount { get; set; } + + /// + /// Gets or sets the number of message views (returned by MAX API for channel messages). + /// + [Range(0, int.MaxValue, ErrorMessage = "Views cannot be negative.")] + [JsonPropertyName("views")] + public int? Views { get; set; } +} + +/// +/// Represents a formatting element of a message text. +/// +public class MarkupElement +{ + /// + /// Gets or sets the markup type (strong, emphasized, monospaced, link, strikethrough, underline, user_mention, heading, highlighted). + /// + [JsonPropertyName("type")] + public string? Type { get; set; } + + /// + /// Gets or sets the start position (in UTF-16 code units) of the element in the text. + /// + [JsonPropertyName("from")] + public int From { get; set; } + + /// + /// Gets or sets the length of the element in UTF-16 code units. + /// + [JsonPropertyName("length")] + public int Length { get; set; } + + /// + /// Gets or sets the URL for link markup. + /// + [JsonPropertyName("url")] + public string? Url { get; set; } + + /// + /// Gets or sets the mentioned user identifier for user_mention markup. + /// + [JsonPropertyName("user_id")] + public long? UserId { get; set; } + + /// + /// Gets or sets the mentioned user link for user_mention markup. + /// + [JsonPropertyName("user_link")] + public string? UserLink { get; set; } } diff --git a/src/Max.Bot/Types/Requests/AddChatAdminRequest.cs b/src/Max.Bot/Types/Requests/AddChatAdminRequest.cs index fd51449..f861d45 100644 --- a/src/Max.Bot/Types/Requests/AddChatAdminRequest.cs +++ b/src/Max.Bot/Types/Requests/AddChatAdminRequest.cs @@ -1,26 +1,71 @@ +using System.Collections.Generic; using System.ComponentModel.DataAnnotations; +using System.Linq; using System.Text.Json.Serialization; +using Max.Bot.Types.Enums; namespace Max.Bot.Types.Requests; /// -/// Represents a request to add an admin to a chat. +/// Represents a request to assign chat administrators (POST /chats/{chatId}/members/admins). /// public class AddChatAdminRequest { /// - /// Gets or sets the user ID to make an admin. + /// Gets or sets the administrators to assign together with their permissions. /// - /// - /// The user ID. Must be greater than zero. - /// + [JsonPropertyName("admins")] + public ChatAdmin[] Admins { get; set; } = Array.Empty(); + + /// + /// Gets or sets the identifier of a single user to make an administrator. + /// + /// + /// Convenience shortcut for a single administrator: setting it replaces with one entry + /// that keeps the permissions of the previous first entry. Prefer to set permissions explicitly. + /// + [JsonIgnore] [Range(1, long.MaxValue, ErrorMessage = "User ID must be greater than zero.")] - [JsonPropertyName("user_id")] - public long UserId { get; set; } + public long UserId + { + get => Admins.FirstOrDefault()?.UserId ?? 0; + set + { + var existing = Admins.FirstOrDefault(); + Admins = new[] + { + new ChatAdmin + { + UserId = value, + Permissions = existing?.Permissions ?? Array.Empty(), + Alias = existing?.Alias + } + }; + } + } } +/// +/// Represents a chat administrator with permissions. +/// +public class ChatAdmin +{ + /// + /// Gets or sets the administrator user identifier. + /// + [Range(1, long.MaxValue, ErrorMessage = "User ID must be greater than zero.")] + [JsonPropertyName("user_id")] + public long UserId { get; set; } + /// + /// Gets or sets the administrator permissions. + /// + [JsonPropertyName("permissions")] + public ChatAdminPermission[] Permissions { get; set; } = Array.Empty(); - - - + /// + /// Gets or sets the administrator title shown in the chat. Optional. + /// + [JsonPropertyName("alias")] + public string? Alias { get; set; } +} diff --git a/src/Max.Bot/Types/Requests/AnswerCallbackQueryRequest.cs b/src/Max.Bot/Types/Requests/AnswerCallbackQueryRequest.cs index 271d853..0f950e5 100644 --- a/src/Max.Bot/Types/Requests/AnswerCallbackQueryRequest.cs +++ b/src/Max.Bot/Types/Requests/AnswerCallbackQueryRequest.cs @@ -43,18 +43,18 @@ public class NewMessageBody /// Gets or sets the attachments for the message. /// /// - /// The attachments array. If null, existing attachments are not changed. + /// The attachments array (see factory methods). If null, existing attachments are not changed. /// If empty array, all attachments will be removed. /// [JsonPropertyName("attachments")] - public Attachment[]? Attachments { get; set; } + public AttachmentRequest[]? Attachments { get; set; } /// /// Gets or sets the link to a message (forwarded or reply). /// /// The linked message, or null if not applicable. [JsonPropertyName("link")] - public Message? Link { get; set; } + public NewMessageLink? Link { get; set; } /// /// Gets or sets a value indicating whether to notify chat participants. diff --git a/src/Max.Bot/Types/Requests/AttachmentRequest.cs b/src/Max.Bot/Types/Requests/AttachmentRequest.cs index beb9b22..96944ae 100644 --- a/src/Max.Bot/Types/Requests/AttachmentRequest.cs +++ b/src/Max.Bot/Types/Requests/AttachmentRequest.cs @@ -1,17 +1,24 @@ +using System.Collections.Generic; using System.ComponentModel.DataAnnotations; +using System.Linq; using System.Text.Json.Serialization; +using Max.Bot.Types.Enums; namespace Max.Bot.Types.Requests; /// /// Represents a request to create an attachment for a message. /// +/// +/// Use the static factory methods (, , , +/// , , and others) to build payloads in the format expected by MAX API. +/// public class AttachmentRequest { /// /// Gets or sets the type of the attachment. /// - /// The type of the attachment (image, video, audio, or file). + /// The type of the attachment (image, video, audio, file, sticker, contact, share, location or inline_keyboard). [Required(ErrorMessage = "Attachment type is required.")] [JsonPropertyName("type")] public string Type { get; set; } = null!; @@ -20,11 +27,352 @@ public class AttachmentRequest /// Gets or sets the payload of the attachment. /// /// - /// The payload object containing attachment data (e.g., token for video/audio, - /// or JSON object returned after file upload for image/file). + /// The payload object containing attachment data (e.g., {"token": "..."} for uploaded media, + /// {"url": "..."} for an image by URL). Not used for location attachments. /// - [Required(ErrorMessage = "Attachment payload is required.")] [JsonPropertyName("payload")] - public object Payload { get; set; } = null!; + public object? Payload { get; set; } + + /// + /// Gets or sets the latitude for location attachments. + /// + [JsonPropertyName("latitude")] + public double? Latitude { get; set; } + + /// + /// Gets or sets the longitude for location attachments. + /// + [JsonPropertyName("longitude")] + public double? Longitude { get; set; } + + /// + /// Creates an image attachment from any public image URL. MAX downloads the image itself. + /// + /// The absolute URL of the image. + /// The attachment request. + /// Thrown when url is null, empty or not an absolute URL. + public static AttachmentRequest ImageFromUrl(string url) + { + ArgumentException.ThrowIfNullOrWhiteSpace(url); + if (!Uri.TryCreate(url, UriKind.Absolute, out _)) + { + throw new ArgumentException("Image URL must be an absolute URL.", nameof(url)); + } + + return new AttachmentRequest + { + Type = AttachmentTypeNames.Image, + Payload = new ImageAttachmentRequestPayload { Url = url } + }; + } + + /// + /// Creates an image attachment from the token of an uploaded or previously sent image. + /// + /// The image token. + /// The attachment request. + public static AttachmentRequest Image(string token) + { + ArgumentException.ThrowIfNullOrWhiteSpace(token); + return new AttachmentRequest + { + Type = AttachmentTypeNames.Image, + Payload = new ImageAttachmentRequestPayload { Token = token } + }; + } + + /// + /// Creates an image attachment from the result of an image upload. + /// Uses the photos map when available, otherwise the token. + /// + /// The upload result returned by IFilesApi.UploadAsync or IFilesApi.UploadFileDataAsync. + /// The attachment request. + /// Thrown when the upload result contains neither photos nor a token. + public static AttachmentRequest Image(FileUploadResult upload) + { + ArgumentNullException.ThrowIfNull(upload); + + if (upload.Photos is { Count: > 0 }) + { + return new AttachmentRequest + { + Type = AttachmentTypeNames.Image, + Payload = new ImageAttachmentRequestPayload { Photos = upload.Photos } + }; + } + + if (string.IsNullOrWhiteSpace(upload.Token)) + { + throw new ArgumentException("Upload result does not contain photo tokens.", nameof(upload)); + } + + return Image(upload.Token); + } + + /// + /// Creates a video attachment from an uploaded video token. + /// + /// The video token ( or ). + /// The attachment request. + public static AttachmentRequest Video(string token) => CreateTokenAttachment(AttachmentTypeNames.Video, token); + + /// + /// Creates an audio attachment from an uploaded audio token. + /// + /// The audio token ( or ). + /// The attachment request. + public static AttachmentRequest Audio(string token) => CreateTokenAttachment(AttachmentTypeNames.Audio, token); + + /// + /// Creates a file attachment from an uploaded file token. + /// + /// The file token returned after the upload. + /// The attachment request. + public static AttachmentRequest File(string token) => CreateTokenAttachment(AttachmentTypeNames.File, token); + + /// + /// Creates an attachment of the matching type from an upload result. + /// + /// The type used when uploading the file. + /// The upload result. + /// The attachment request. + /// Thrown when the upload result does not contain a token. + public static AttachmentRequest FromUpload(UploadType uploadType, FileUploadResult upload) + { + ArgumentNullException.ThrowIfNull(upload); + + if (uploadType == UploadType.Image) + { + return Image(upload); + } + + if (string.IsNullOrWhiteSpace(upload.Token)) + { + throw new ArgumentException( + "Upload result does not contain a token. For video and audio use IFilesApi.UploadAsync or UploadResponse.Token.", + nameof(upload)); + } + + return uploadType switch + { + UploadType.Video => Video(upload.Token), + UploadType.Audio => Audio(upload.Token), + UploadType.File => File(upload.Token), + _ => throw new ArgumentOutOfRangeException(nameof(uploadType), uploadType, "Unknown upload type.") + }; + } + + /// + /// Creates a sticker attachment. + /// + /// The sticker code. + /// The attachment request. + public static AttachmentRequest Sticker(string code) + { + ArgumentException.ThrowIfNullOrWhiteSpace(code); + return new AttachmentRequest + { + Type = AttachmentTypeNames.Sticker, + Payload = new StickerAttachmentRequestPayload { Code = code } + }; + } + + /// + /// Creates a contact attachment. + /// + /// The contact name. + /// The MAX user identifier of the contact. Optional. + /// Full contact information in VCF format. Optional. + /// The contact phone in VCF format. Optional. + /// The attachment request. + public static AttachmentRequest Contact(string? name = null, long? contactId = null, string? vcfInfo = null, string? vcfPhone = null) + { + if (string.IsNullOrWhiteSpace(name) && contactId == null && string.IsNullOrWhiteSpace(vcfInfo) && string.IsNullOrWhiteSpace(vcfPhone)) + { + throw new ArgumentException("At least one of name, contactId, vcfInfo or vcfPhone must be provided."); + } + + return new AttachmentRequest + { + Type = AttachmentTypeNames.Contact, + Payload = new ContactAttachmentRequestPayload + { + Name = name, + ContactId = contactId, + VcfInfo = vcfInfo, + VcfPhone = vcfPhone + } + }; + } + + /// + /// Creates a location attachment. + /// + /// The latitude. + /// The longitude. + /// The attachment request. + public static AttachmentRequest Location(double latitude, double longitude) + { + if (latitude is < -90 or > 90) + { + throw new ArgumentOutOfRangeException(nameof(latitude), latitude, "Latitude must be between -90 and 90."); + } + + if (longitude is < -180 or > 180) + { + throw new ArgumentOutOfRangeException(nameof(longitude), longitude, "Longitude must be between -180 and 180."); + } + + return new AttachmentRequest + { + Type = AttachmentTypeNames.Location, + Latitude = latitude, + Longitude = longitude + }; + } + + /// + /// Creates a link preview (share) attachment. + /// + /// The URL to share. Optional if token is provided. + /// The token of an existing share attachment. Optional if url is provided. + /// The attachment request. + public static AttachmentRequest Share(string? url = null, string? token = null) + { + if (string.IsNullOrWhiteSpace(url) && string.IsNullOrWhiteSpace(token)) + { + throw new ArgumentException("Either url or token must be provided."); + } + + return new AttachmentRequest + { + Type = AttachmentTypeNames.Share, + Payload = new ShareAttachmentRequestPayload { Url = url, Token = token } + }; + } + + /// + /// Creates an inline keyboard attachment. + /// + /// The keyboard. + /// The attachment request. + public static AttachmentRequest Keyboard(InlineKeyboard keyboard) + { + ArgumentNullException.ThrowIfNull(keyboard); + + var sourceRows = keyboard.Buttons ?? Array.Empty(); + var normalizedRows = sourceRows.Select(row => row ?? Array.Empty()).ToArray(); + + return new AttachmentRequest + { + Type = AttachmentTypeNames.InlineKeyboard, + Payload = new InlineKeyboard(normalizedRows) + }; + } + + private static AttachmentRequest CreateTokenAttachment(string type, string token) + { + ArgumentException.ThrowIfNullOrWhiteSpace(token); + return new AttachmentRequest + { + Type = type, + Payload = new TokenAttachmentRequestPayload { Token = token } + }; + } +} + +/// +/// Payload of an image attachment request. Only one of the properties should be set. +/// +public class ImageAttachmentRequestPayload +{ + /// + /// Gets or sets any external image URL to attach. + /// + [JsonPropertyName("url")] + public string? Url { get; set; } + + /// + /// Gets or sets the token of an existing image attachment. + /// + [JsonPropertyName("token")] + public string? Token { get; set; } + + /// + /// Gets or sets the tokens obtained after uploading images. + /// + [JsonPropertyName("photos")] + public Dictionary? Photos { get; set; } +} + +/// +/// Payload of video, audio and file attachment requests. +/// +public class TokenAttachmentRequestPayload +{ + /// + /// Gets or sets the uploaded media token. + /// + [JsonPropertyName("token")] + public string Token { get; set; } = string.Empty; +} + +/// +/// Payload of a sticker attachment request. +/// +public class StickerAttachmentRequestPayload +{ + /// + /// Gets or sets the sticker code. + /// + [JsonPropertyName("code")] + public string Code { get; set; } = string.Empty; } +/// +/// Payload of a contact attachment request. +/// +public class ContactAttachmentRequestPayload +{ + /// + /// Gets or sets the contact name. + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// + /// Gets or sets the MAX user identifier of the contact. + /// + [JsonPropertyName("contact_id")] + public long? ContactId { get; set; } + + /// + /// Gets or sets full information about the contact in VCF format. + /// + [JsonPropertyName("vcf_info")] + public string? VcfInfo { get; set; } + + /// + /// Gets or sets the contact phone in VCF format. + /// + [JsonPropertyName("vcf_phone")] + public string? VcfPhone { get; set; } +} + +/// +/// Payload of a share attachment request. +/// +public class ShareAttachmentRequestPayload +{ + /// + /// Gets or sets the URL attached to the message as a media preview. + /// + [JsonPropertyName("url")] + public string? Url { get; set; } + + /// + /// Gets or sets the attachment token. + /// + [JsonPropertyName("token")] + public string? Token { get; set; } +} diff --git a/src/Max.Bot/Types/Subscription.cs b/src/Max.Bot/Types/Subscription.cs index 63dde72..46f5df0 100644 --- a/src/Max.Bot/Types/Subscription.cs +++ b/src/Max.Bot/Types/Subscription.cs @@ -46,6 +46,18 @@ public class Subscription [JsonPropertyName("updated_at")] [JsonConverter(typeof(Converters.UnixTimestampJsonConverter))] public DateTime? UpdatedAt { get; set; } + + /// + /// Gets or sets the Unix time (milliseconds) when the subscription was created, as returned by MAX API. + /// + [JsonPropertyName("time")] + public long? Time { get; set; } + + /// + /// Gets or sets the API version of the subscription. + /// + [JsonPropertyName("version")] + public string? Version { get; set; } } diff --git a/src/Max.Bot/Types/Update.cs b/src/Max.Bot/Types/Update.cs index b5a2fae..b838b20 100644 --- a/src/Max.Bot/Types/Update.cs +++ b/src/Max.Bot/Types/Update.cs @@ -69,8 +69,12 @@ public class Update /// /// Gets or sets the chat in this update. - /// Present in: bot_added, bot_removed, user_added, user_removed, chat_title_changed. /// + /// + /// Filled from the chat object when MAX sends it (message_chat_created). For message updates + /// (message_created, message_edited, message_callback) MAX sends only message.recipient, so the library + /// fills a partial with and taken from it. + /// [JsonIgnore] public Chat? Chat { get; set; } @@ -90,11 +94,95 @@ public class Update /// /// Gets or sets the chat ID. - /// Present in: dialog_muted, dialog_unmuted, dialog_cleared, dialog_removed. /// + /// + /// Taken from chat_id (bot_added, bot_removed, bot_started, bot_stopped, user_added, user_removed, + /// chat_title_changed, message_removed, dialog_* events) or, for message updates, from message.recipient.chat_id. + /// [JsonIgnore] public long? ChatId { get; set; } + /// + /// Gets the type of the chat where the update happened (dialog, chat or channel), if known. + /// + /// + /// Resolved from or message.recipient.chat_type. For events that only carry + /// is_channel (bot_added, bot_removed, user_added, user_removed) a channel is reported as . + /// + [JsonIgnore] + public ChatType? ChatType + { + get + { + if (Chat != null) + { + return Chat.Type; + } + + if (TryParseChatType(Message?.Recipient?.ChatType, out var chatType)) + { + return chatType; + } + + return IsChannel == true ? Enums.ChatType.Channel : null; + } + } + + /// + /// Gets or sets the identifier of the removed message. + /// Present in: message_removed, message_chat_created. + /// + [JsonIgnore] + public string? MessageId { get; set; } + + /// + /// Gets or sets the identifier of the user who removed a message. + /// Present in: message_removed. + /// + [JsonIgnore] + public long? UserId { get; set; } + + /// + /// Gets or sets the identifier of the administrator who removed a user from a chat. + /// Present in: user_removed. + /// + [JsonIgnore] + public long? AdminId { get; set; } + + /// + /// Gets or sets the new chat title. + /// Present in: chat_title_changed, message_chat_created. + /// + [JsonIgnore] + public string? Title { get; set; } + + /// + /// Gets or sets a value indicating whether the event happened in a channel. + /// Present in: bot_added, bot_removed, user_added, user_removed. + /// + [JsonIgnore] + public bool? IsChannel { get; set; } + + /// + /// Gets or sets the time (Unix milliseconds) until which the dialog is muted. + /// Present in: dialog_muted. + /// + [JsonIgnore] + public long? MutedUntil { get; set; } + + /// + /// Gets or sets a value indicating whether was built from message.recipient + /// rather than received from MAX API. + /// + [JsonIgnore] + internal bool IsChatInferred { get; set; } + + /// + /// Gets or sets a value indicating whether was taken from message.recipient. + /// + [JsonIgnore] + internal bool IsChatIdInferred { get; set; } + /// /// Gets or sets whether the chat is muted. /// Present in: dialog_muted, dialog_unmuted. @@ -103,7 +191,8 @@ public class Update public bool? IsMuted { get; set; } /// - /// Gets or sets the bot start payload. + /// Gets or sets the deep-link payload passed in https://max.ru/<bot>?start=<payload>. + /// Present in: bot_started (payload) and message_chat_created (start_payload). /// [JsonIgnore] public string? Payload { get; set; } @@ -182,7 +271,7 @@ public MessageRemovedUpdate? MessageRemovedUpdate { get { - if (Message == null || Type != UpdateType.MessageRemoved) + if (Type != UpdateType.MessageRemoved) return null; return new MessageRemovedUpdate @@ -190,7 +279,10 @@ public MessageRemovedUpdate? MessageRemovedUpdate UpdateId = UpdateId, Timestamp = Timestamp, UserLocale = UserLocale, - Message = Message + Message = Message, + MessageId = MessageId ?? Message?.Mid, + ChatId = ChatId, + UserId = UserId }; } } @@ -211,8 +303,10 @@ public BotAddedUpdate? BotAddedUpdate UpdateId = UpdateId, Timestamp = Timestamp, Chat = Chat, + ChatId = ChatId ?? Chat?.ChatId, User = User, - InviterId = InviterId + InviterId = InviterId, + IsChannel = IsChannel }; } } @@ -233,7 +327,9 @@ public BotRemovedUpdate? BotRemovedUpdate UpdateId = UpdateId, Timestamp = Timestamp, Chat = Chat, - User = User + ChatId = ChatId ?? Chat?.ChatId, + User = User, + IsChannel = IsChannel }; } } @@ -277,7 +373,8 @@ public BotStoppedUpdate? BotStoppedUpdate UpdateId = UpdateId, Timestamp = Timestamp, ChatId = ChatId, - User = User + User = User, + UserLocale = UserLocale }; } } @@ -298,8 +395,10 @@ public UserAddedUpdate? UserAddedUpdate UpdateId = UpdateId, Timestamp = Timestamp, Chat = Chat, + ChatId = ChatId ?? Chat?.ChatId, User = User, - InviterId = InviterId + InviterId = InviterId, + IsChannel = IsChannel }; } } @@ -320,7 +419,10 @@ public UserRemovedUpdate? UserRemovedUpdate UpdateId = UpdateId, Timestamp = Timestamp, Chat = Chat, - User = User + ChatId = ChatId ?? Chat?.ChatId, + User = User, + AdminId = AdminId, + IsChannel = IsChannel }; } } @@ -341,7 +443,9 @@ public ChatTitleChangedUpdate? ChatTitleChangedUpdate UpdateId = UpdateId, Timestamp = Timestamp, Chat = Chat, - User = User + ChatId = ChatId ?? Chat?.ChatId, + User = User, + Title = Title ?? Chat?.Title }; } } @@ -362,7 +466,10 @@ public DialogMutedUpdate? DialogMutedUpdate UpdateId = UpdateId, Timestamp = Timestamp, ChatId = ChatId, - IsMuted = IsMuted + IsMuted = IsMuted, + MutedUntil = MutedUntil, + User = User, + UserLocale = UserLocale }; } } @@ -383,7 +490,9 @@ public DialogUnmutedUpdate? DialogUnmutedUpdate UpdateId = UpdateId, Timestamp = Timestamp, ChatId = ChatId, - IsMuted = IsMuted + IsMuted = IsMuted, + User = User, + UserLocale = UserLocale }; } } @@ -403,7 +512,9 @@ public DialogClearedUpdate? DialogClearedUpdate { UpdateId = UpdateId, Timestamp = Timestamp, - ChatId = ChatId + ChatId = ChatId, + User = User, + UserLocale = UserLocale }; } } @@ -423,7 +534,9 @@ public DialogRemovedUpdate? DialogRemovedUpdate { UpdateId = UpdateId, Timestamp = Timestamp, - ChatId = ChatId + ChatId = ChatId, + User = User, + UserLocale = UserLocale }; } } @@ -436,7 +549,7 @@ public MessageChatCreatedUpdate? MessageChatCreatedUpdate { get { - if (Message == null || Type != UpdateType.MessageChatCreated) + if (Type != UpdateType.MessageChatCreated) return null; return new MessageChatCreatedUpdate @@ -444,13 +557,35 @@ public MessageChatCreatedUpdate? MessageChatCreatedUpdate UpdateId = UpdateId, Timestamp = Timestamp, UserLocale = UserLocale, - Message = Message + Message = Message, + Chat = Chat, + MessageId = MessageId, + StartPayload = Payload }; } } #endregion + internal static bool TryParseChatType(string? value, out ChatType chatType) + { + switch (value?.Trim().ToLowerInvariant()) + { + case "dialog": + chatType = Enums.ChatType.Dialog; + return true; + case "chat": + chatType = Enums.ChatType.Chat; + return true; + case "channel": + chatType = Enums.ChatType.Channel; + return true; + default: + chatType = default; + return false; + } + } + /// /// Parses the raw update_type string to UpdateType enum. /// diff --git a/src/Max.Bot/Types/UpdateTypes.cs b/src/Max.Bot/Types/UpdateTypes.cs index 8c5379b..aa962f2 100644 --- a/src/Max.Bot/Types/UpdateTypes.cs +++ b/src/Max.Bot/Types/UpdateTypes.cs @@ -55,7 +55,22 @@ public class MessageRemovedUpdate /// /// Gets or sets the removed message. /// - public Message Message { get; set; } = null!; + public Message? Message { get; set; } + + /// + /// Gets or sets the identifier of the removed message (message_id). + /// + public string? MessageId { get; set; } + + /// + /// Gets or sets the chat identifier where the message was removed. + /// + public long? ChatId { get; set; } + + /// + /// Gets or sets the identifier of the user who removed the message. + /// + public long? UserId { get; set; } } /// @@ -90,6 +105,16 @@ public class BotAddedUpdate /// Gets or sets the ID of the user who invited the bot. /// public long? InviterId { get; set; } + + /// + /// Gets or sets the identifier of the chat the bot was added to. + /// + public long? ChatId { get; set; } + + /// + /// Gets or sets a value indicating whether the bot was added to a channel. + /// + public bool? IsChannel { get; set; } } /// @@ -119,6 +144,16 @@ public class BotRemovedUpdate /// Gets or sets the user (bot) that was removed. /// public User? User { get; set; } + + /// + /// Gets or sets the identifier of the chat the bot was removed from. + /// + public long? ChatId { get; set; } + + /// + /// Gets or sets a value indicating whether the bot was removed from a channel. + /// + public bool? IsChannel { get; set; } } /// @@ -187,6 +222,11 @@ public class BotStoppedUpdate /// Gets or sets the user who stopped the bot. /// public User? User { get; set; } + + /// + /// Gets or sets the user locale. + /// + public string? UserLocale { get; set; } } /// @@ -221,6 +261,16 @@ public class UserAddedUpdate /// Gets or sets the ID of the user who invited the user. /// public long? InviterId { get; set; } + + /// + /// Gets or sets the identifier of the chat the user was added to. + /// + public long? ChatId { get; set; } + + /// + /// Gets or sets a value indicating whether the user was added to a channel. + /// + public bool? IsChannel { get; set; } } /// @@ -250,6 +300,21 @@ public class UserRemovedUpdate /// Gets or sets the user that was removed. /// public User? User { get; set; } + + /// + /// Gets or sets the identifier of the chat the user was removed from. + /// + public long? ChatId { get; set; } + + /// + /// Gets or sets the identifier of the administrator who removed the user. + /// + public long? AdminId { get; set; } + + /// + /// Gets or sets a value indicating whether the user was removed from a channel. + /// + public bool? IsChannel { get; set; } } /// @@ -279,6 +344,16 @@ public class ChatTitleChangedUpdate /// Gets or sets the user who changed the title. /// public User? User { get; set; } + + /// + /// Gets or sets the identifier of the chat whose title changed. + /// + public long? ChatId { get; set; } + + /// + /// Gets or sets the new chat title. + /// + public string? Title { get; set; } } /// @@ -308,6 +383,21 @@ public class DialogMutedUpdate /// Gets or sets whether the dialog is muted. /// public bool? IsMuted { get; set; } + + /// + /// Gets or sets the time (Unix milliseconds) until which the dialog is muted. + /// + public long? MutedUntil { get; set; } + + /// + /// Gets or sets the user who muted the dialog. + /// + public User? User { get; set; } + + /// + /// Gets or sets the user locale. + /// + public string? UserLocale { get; set; } } /// @@ -337,6 +427,16 @@ public class DialogUnmutedUpdate /// Gets or sets whether the dialog is muted. /// public bool? IsMuted { get; set; } + + /// + /// Gets or sets the user who unmuted the dialog. + /// + public User? User { get; set; } + + /// + /// Gets or sets the user locale. + /// + public string? UserLocale { get; set; } } /// @@ -361,6 +461,16 @@ public class DialogClearedUpdate /// Gets or sets the chat ID of the cleared dialog. /// public long? ChatId { get; set; } + + /// + /// Gets or sets the user who cleared the dialog. + /// + public User? User { get; set; } + + /// + /// Gets or sets the user locale. + /// + public string? UserLocale { get; set; } } /// @@ -385,6 +495,16 @@ public class DialogRemovedUpdate /// Gets or sets the chat ID of the removed dialog. /// public long? ChatId { get; set; } + + /// + /// Gets or sets the user who removed the dialog. + /// + public User? User { get; set; } + + /// + /// Gets or sets the user locale. + /// + public string? UserLocale { get; set; } } /// @@ -413,7 +533,22 @@ public class MessageChatCreatedUpdate /// /// Gets or sets the message that created the chat. /// - public Message Message { get; set; } = null!; + public Message? Message { get; set; } + + /// + /// Gets or sets the created chat. + /// + public Chat? Chat { get; set; } + + /// + /// Gets or sets the identifier of the message where the button was clicked. + /// + public string? MessageId { get; set; } + + /// + /// Gets or sets the payload from the chat button (start_payload). + /// + public string? StartPayload { get; set; } } diff --git a/src/Max.Bot/Types/Video.cs b/src/Max.Bot/Types/Video.cs index 8fc66e4..33dfd1d 100644 --- a/src/Max.Bot/Types/Video.cs +++ b/src/Max.Bot/Types/Video.cs @@ -104,5 +104,17 @@ public class VideoThumbnail /// [JsonPropertyName("url")] public string? Url { get; set; } + + /// + /// Gets or sets the thumbnail photo identifier. + /// + [JsonPropertyName("photo_id")] + public long? PhotoId { get; set; } + + /// + /// Gets or sets the thumbnail token. + /// + [JsonPropertyName("token")] + public string? Token { get; set; } } diff --git a/tests/Max.Bot.Tests/Examples/SampleBotsTests.cs b/tests/Max.Bot.Tests/Examples/SampleBotsTests.cs index cc36261..fdddd5d 100644 --- a/tests/Max.Bot.Tests/Examples/SampleBotsTests.cs +++ b/tests/Max.Bot.Tests/Examples/SampleBotsTests.cs @@ -110,9 +110,7 @@ public static IEnumerable SampleScenarioData() using var tempFile = new TempFile(); await File.WriteAllTextAsync(tempFile.Path, "payload"); - harness.FilesMock.Setup(f => f.UploadFileAsync(UploadType.File, It.IsAny())) - .ReturnsAsync(new UploadResponse { Url = "https://upload" }); - harness.FilesMock.Setup(f => f.UploadFileDataAsync("https://upload", It.IsAny(), It.IsAny(), It.IsAny())) + harness.FilesMock.Setup(f => f.UploadAsync(UploadType.File, It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new FileUploadResult { Token = "file-token" }); harness.MessagesMock.Setup(m => m.SendMessageWithAttachmentAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) @@ -123,9 +121,8 @@ public static IEnumerable SampleScenarioData() var handler = await harness.StartAsync(); await handler.HandleMessageAsync(CreateUpdateContext(harness.Api, CreateMessageUpdate("/file")), CancellationToken.None); - harness.FilesMock.Verify(f => f.UploadFileAsync(UploadType.File, It.IsAny()), Times.Once); - harness.FilesMock.Verify(f => f.UploadFileDataAsync("https://upload", It.IsAny(), Path.GetFileName(tempFile.Path), It.IsAny()), Times.Once); - harness.MessagesMock.Verify(m => m.SendMessageWithAttachmentAsync(It.IsAny(), 1337, null, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + harness.FilesMock.Verify(f => f.UploadAsync(UploadType.File, It.IsAny(), Path.GetFileName(tempFile.Path), It.IsAny()), Times.Once); + harness.MessagesMock.Verify(m => m.SendMessageWithAttachmentAsync(It.Is(a => a.Type == "file"), 1337, null, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); }) }; } diff --git a/tests/Max.Bot.Tests/Unit/Api/ChatsApiTests.cs b/tests/Max.Bot.Tests/Unit/Api/ChatsApiTests.cs index f1ddae1..dabd920 100644 --- a/tests/Max.Bot.Tests/Unit/Api/ChatsApiTests.cs +++ b/tests/Max.Bot.Tests/Unit/Api/ChatsApiTests.cs @@ -80,10 +80,11 @@ public async Task GetChatsAsync_ShouldReturnChats_WhenRequestSucceeds() new Chat { ChatId = 2, Type = ChatType.Chat } }; - var response = new Response + // * GET /chats returns {"chats":[...],"marker":...} + var response = new ChatListResponse { - Ok = true, - Result = expectedChats + Chats = expectedChats, + Marker = 100 }; var responseJson = MaxJsonSerializer.Serialize(response); @@ -340,19 +341,16 @@ public async Task GetPinnedMessageAsync_ShouldReturnMessage_WhenMessageIsPinned( Body = new MessageBody { Mid = "mid.789", Text = "Pinned message" } }; - var response = new Response - { - Ok = true, - Result = expectedMessage - }; + // * GET /chats/{chatId}/pin returns {"message":{...}} + var responseJson = MaxJsonSerializer.Serialize(new MessageResponse { Message = expectedMessage }); _mockHttpClient - .Setup(x => x.SendAsync>( + .Setup(x => x.SendAsyncRaw( It.Is(req => req.Method == HttpMethod.Get && req.Endpoint == $"/chats/{chatId}/pin"), It.IsAny())) - .ReturnsAsync(response); + .ReturnsAsync(responseJson); var chatsApi = new ChatsApi(_mockHttpClient.Object, _options); @@ -371,19 +369,13 @@ public async Task GetPinnedMessageAsync_ShouldReturnNull_WhenNoMessageIsPinned() // Arrange var chatId = 123456L; - var response = new Response - { - Ok = true, - Result = null - }; - _mockHttpClient - .Setup(x => x.SendAsync>( + .Setup(x => x.SendAsyncRaw( It.Is(req => req.Method == HttpMethod.Get && req.Endpoint == $"/chats/{chatId}/pin"), It.IsAny())) - .ReturnsAsync(response); + .ReturnsAsync("""{"message":null}"""); var chatsApi = new ChatsApi(_mockHttpClient.Object, _options); diff --git a/tests/Max.Bot.Tests/Unit/Api/MessagesApiTests.cs b/tests/Max.Bot.Tests/Unit/Api/MessagesApiTests.cs index 25e8523..495795b 100644 --- a/tests/Max.Bot.Tests/Unit/Api/MessagesApiTests.cs +++ b/tests/Max.Bot.Tests/Unit/Api/MessagesApiTests.cs @@ -586,8 +586,8 @@ public async Task AnswerCallbackQueryAsync_ShouldReturnResponse_WhenRequestSucce req.Method == HttpMethod.Post && req.Endpoint == "/answers" && req.QueryParameters != null && - req.QueryParameters.ContainsKey("callback_query_id") && - req.QueryParameters["callback_query_id"] == callbackQueryId && + req.QueryParameters.ContainsKey("callback_id") && + req.QueryParameters["callback_id"] == callbackQueryId && req.Body != null), It.IsAny())) .ReturnsAsync(response); diff --git a/tests/Max.Bot.Tests/Unit/Polling/WebhookEndpointTests.cs b/tests/Max.Bot.Tests/Unit/Polling/WebhookEndpointTests.cs new file mode 100644 index 0000000..b383fa6 --- /dev/null +++ b/tests/Max.Bot.Tests/Unit/Polling/WebhookEndpointTests.cs @@ -0,0 +1,263 @@ +// 📁 WebhookEndpointTests.cs - Tests for opt-in webhook endpoint, webhook configuration and polling marker +// 🎯 Core function: Guards issues #35 (no implicit endpoint), #36 (optional webhook URL) and the X-Max-Bot-Api-Secret header. +// 🔗 Key dependencies: MapMaxWebhook, WebhookController, MaxClient, UpdatePoller. +// 💡 Usage: Exercises the endpoint request delegate directly without a test server. + +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using FluentAssertions; +using Max.Bot.Api; +using Max.Bot.Configuration; +using Max.Bot.Networking; +using Max.Bot.Polling; +using Max.Bot.Types; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using Xunit; + +namespace Max.Bot.Tests.Unit.Polling; + +public class WebhookEndpointTests +{ + private const string UpdateJson = """{"update_type":"message_created","timestamp":1,"message":{"recipient":{"chat_id":1,"chat_type":"dialog"},"body":{"mid":"mid.1","text":"hi"}}}"""; + + [Fact] + public void Issue35_WebhookController_ShouldNotBeDiscoveredAutomatically() + { + typeof(WebhookController).IsAbstract.Should().BeTrue(); + + var feature = new ControllerFeature(); + var partManager = new Microsoft.AspNetCore.Mvc.ApplicationParts.ApplicationPartManager(); + partManager.ApplicationParts.Add(new Microsoft.AspNetCore.Mvc.ApplicationParts.AssemblyPart(typeof(WebhookController).Assembly)); + partManager.FeatureProviders.Add(new ControllerFeatureProvider()); + partManager.PopulateFeature(feature); + + feature.Controllers.Should().BeEmpty(); + } + + [Fact] + public void WebhookOptions_ShouldUseMaxSecretHeaderByDefault() + { + new MaxWebhookOptions().SignatureHeaderName.Should().Be("X-Max-Bot-Api-Secret"); + } + + [Fact] + public async Task MapMaxWebhook_ShouldDispatchUpdate_WhenSecretMatches() + { + var (endpoint, pipeline, handler, services) = CreateEndpoint(secret: "s3cret_token"); + var context = CreateContext(services, UpdateJson); + context.Request.Headers["X-Max-Bot-Api-Secret"] = "s3cret_token"; + + await endpoint(context); + + context.Response.StatusCode.Should().Be(StatusCodes.Status200OK); + pipeline.Verify(p => p.ProcessWebhookAsync( + It.Is(u => u.Message!.Mid == "mid.1"), + handler.Object, + It.IsAny(), + It.IsAny()), Times.Once); + } + + [Fact] + public async Task MapMaxWebhook_ShouldReturnUnauthorized_WhenSecretMissing() + { + var (endpoint, pipeline, _, services) = CreateEndpoint(secret: "s3cret_token"); + var context = CreateContext(services, UpdateJson); + + await endpoint(context); + + context.Response.StatusCode.Should().Be(StatusCodes.Status401Unauthorized); + pipeline.VerifyNoOtherCalls(); + } + + [Fact] + public async Task MapMaxWebhook_ShouldReturnBadRequest_WhenBodyIsInvalid() + { + var (endpoint, pipeline, _, services) = CreateEndpoint(secret: null); + var context = CreateContext(services, "not json"); + + await endpoint(context); + + context.Response.StatusCode.Should().Be(StatusCodes.Status400BadRequest); + pipeline.VerifyNoOtherCalls(); + } + + [Fact] + public async Task MapMaxWebhook_ShouldReturnPayloadTooLarge_WhenBodyExceedsLimit() + { + var (endpoint, pipeline, _, services) = CreateEndpoint(secret: null, maxBodyKilobytes: 1); + var context = CreateContext(services, "{\"update_type\":\"" + new string('x', 4096) + "\"}", setContentLength: false); + + await endpoint(context); + + context.Response.StatusCode.Should().Be(StatusCodes.Status413PayloadTooLarge); + pipeline.VerifyNoOtherCalls(); + } + + [Fact] + public async Task Issue36_ConfigureWebhookAsync_ShouldUseEndpointAndSecretFromOptions() + { + string? sentBody = null; + var handler = new CapturingHandler(async request => + { + sentBody = request.Content == null ? null : await request.Content.ReadAsStringAsync(); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("""{"success":true}""", Encoding.UTF8, "application/json") + }; + }); + + var options = new MaxBotOptions + { + Token = "token", + Webhook = { Endpoint = "https://bot.example.com/api/max/webhook", SecretToken = "s3cret_token" } + }; + using var client = new MaxClient(options, new HttpClient(handler)); + + var response = await client.ConfigureWebhookAsync(); + + response.Success.Should().BeTrue(); + using var document = JsonDocument.Parse(sentBody!); + document.RootElement.GetProperty("url").GetString().Should().Be("https://bot.example.com/api/max/webhook"); + document.RootElement.GetProperty("secret").GetString().Should().Be("s3cret_token"); + } + + [Fact] + public async Task Issue36_ConfigureWebhookAsync_ShouldRequireUrl_WhenEndpointNotConfigured() + { + using var client = new MaxClient(new MaxBotOptions { Token = "token" }); + + var act = () => client.ConfigureWebhookAsync(); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task ConfigureWebhookAsync_ShouldStoreExplicitSecretForValidation() + { + var handler = new CapturingHandler(_ => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("""{"success":true}""", Encoding.UTF8, "application/json") + })); + var options = new MaxBotOptions { Token = "token" }; + using var client = new MaxClient(options, new HttpClient(handler)); + + await client.ConfigureWebhookAsync("https://bot.example.com/hook", secret: "explicit_secret"); + + options.Webhook.Endpoint.Should().Be("https://bot.example.com/hook"); + options.Webhook.SecretToken.Should().Be("explicit_secret"); + } + + [Fact] + public async Task UpdatePoller_ShouldUseMarkerFromResponse() + { + var options = new MaxBotOptions { Token = "token" }; + options.Polling.IdleDelay = TimeSpan.FromMilliseconds(10); + + var requests = new List(); + var secondRequestSeen = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var pollClient = new Mock(); + pollClient + .Setup(x => x.SendAsyncRaw(It.IsAny(), It.IsAny())) + .Returns((req, _) => + { + lock (requests) + { + requests.Add(req); + if (requests.Count == 1) + { + return Task.FromResult("{\"updates\":[" + UpdateJson + "],\"marker\":42}"); + } + + secondRequestSeen.TrySetResult(); + return Task.FromResult("{\"updates\":[],\"marker\":42}"); + } + }); + + var poller = new UpdatePoller(Mock.Of(), Mock.Of(), options, pollClient.Object); + await poller.StartAsync(Mock.Of(), CancellationToken.None); + await secondRequestSeen.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await poller.StopAsync(CancellationToken.None); + + MaxApiRequest second; + lock (requests) + { + requests[0].QueryParameters.Should().NotContainKey("marker"); + second = requests[1]; + } + + second.QueryParameters.Should().ContainKey("marker").WhoseValue.Should().Be("42"); + } + + private static (RequestDelegate Endpoint, Mock Pipeline, Mock Handler, IServiceProvider Services) CreateEndpoint(string? secret, int maxBodyKilobytes = 512) + { + var options = new MaxBotOptions { Token = "token" }; + options.Webhook.SecretToken = secret; + options.Webhook.MaxBodySizeKilobytes = maxBodyKilobytes; + + var pipeline = new Mock(); + var handler = new Mock(); + + var services = new ServiceCollection() + .AddLogging() + .AddRouting() + .AddSingleton(options) + .AddSingleton(pipeline.Object) + .AddSingleton(handler.Object) + .BuildServiceProvider(); + + var routeBuilder = new TestEndpointRouteBuilder(services); + routeBuilder.MapMaxWebhook(); + + var endpoint = routeBuilder.DataSources + .SelectMany(source => source.Endpoints) + .OfType() + .Single(); + + endpoint.RoutePattern.RawText.Should().Be("/api/max/webhook"); + return (endpoint.RequestDelegate!, pipeline, handler, services); + } + + private static DefaultHttpContext CreateContext(IServiceProvider services, string body, bool setContentLength = true) + { + var bytes = Encoding.UTF8.GetBytes(body); + var context = new DefaultHttpContext { RequestServices = services }; + context.Request.Method = HttpMethods.Post; + context.Request.ContentType = "application/json"; + context.Request.Body = new MemoryStream(bytes); + if (setContentLength) + { + context.Request.ContentLength = bytes.Length; + } + + context.Response.Body = new MemoryStream(); + return context; + } + + private sealed class TestEndpointRouteBuilder : IEndpointRouteBuilder + { + public TestEndpointRouteBuilder(IServiceProvider serviceProvider) => ServiceProvider = serviceProvider; + + public IServiceProvider ServiceProvider { get; } + + public ICollection DataSources { get; } = new List(); + + public IApplicationBuilder CreateApplicationBuilder() => new ApplicationBuilder(ServiceProvider); + } + + private sealed class CapturingHandler : HttpMessageHandler + { + private readonly Func> _responder; + + public CapturingHandler(Func> responder) => _responder = responder; + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + => _responder(request); + } +} diff --git a/tests/Max.Bot.Tests/Unit/Regression/IssueRegressionTests.cs b/tests/Max.Bot.Tests/Unit/Regression/IssueRegressionTests.cs new file mode 100644 index 0000000..5f42683 --- /dev/null +++ b/tests/Max.Bot.Tests/Unit/Regression/IssueRegressionTests.cs @@ -0,0 +1,580 @@ +// 📁 IssueRegressionTests.cs - Regression tests for reported GitHub issues +// 🎯 Core function: Pins the MAX API contracts reported in issues #15-#38 with real payload shapes. +// 🔗 Key dependencies: MaxJsonSerializer, MessagesApi, ChatsApi, FilesApi, MaxHttpClient, MaxClient. +// 💡 Usage: Each test names the issue it guards against. + +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using FluentAssertions; +using Max.Bot.Api; +using Max.Bot.Configuration; +using Max.Bot.Exceptions; +using Max.Bot.Networking; +using Max.Bot.Types; +using Max.Bot.Types.Enums; +using Max.Bot.Types.Requests; +using Moq; +using Xunit; + +namespace Max.Bot.Tests.Unit.Regression; + +public class IssueRegressionTests +{ + private readonly Mock _http = new(); + private readonly MaxBotOptions _options = new() + { + Token = "test-token-123", + BaseUrl = "https://platform-api.max.ru" + }; + + #region #34 AnswerCallbackQueryAsync uses callback_id + + [Fact] + public async Task Issue34_AnswerCallbackQueryAsync_ShouldSendCallbackIdQueryParameter() + { + MaxApiRequest? captured = null; + _http.Setup(x => x.SendAsync(It.IsAny(), It.IsAny())) + .Callback((req, _) => captured = req) + .ReturnsAsync(new Response { Success = true }); + + var api = new MessagesApi(_http.Object, _options); + var result = await api.AnswerCallbackQueryAsync("cb-123", new AnswerCallbackQueryRequest { Notification = "Done" }); + + result.Success.Should().BeTrue(); + captured.Should().NotBeNull(); + captured!.Endpoint.Should().Be("/answers"); + captured.QueryParameters.Should().ContainKey("callback_id").WhoseValue.Should().Be("cb-123"); + captured.QueryParameters.Should().NotContainKey("callback_query_id"); + } + + [Fact] + public void Issue34_NewMessageBody_ShouldSerializeAttachmentRequestsAndLink() + { + var keyboard = new InlineKeyboard(new[] + { + new[] { new InlineKeyboardButton { Text = "OK", CallbackData = "ok" } } + }); + var request = new AnswerCallbackQueryRequest + { + Message = new NewMessageBody + { + Text = "Updated", + Attachments = new[] { AttachmentRequest.Keyboard(keyboard) }, + Link = new NewMessageLink { Type = MessageLinkType.Reply, Id = "mid.1" } + } + }; + + var json = MaxJsonSerializer.Serialize(request); + + using var document = JsonDocument.Parse(json); + var message = document.RootElement.GetProperty("message"); + var attachment = message.GetProperty("attachments")[0]; + attachment.GetProperty("type").GetString().Should().Be("inline_keyboard"); + attachment.GetProperty("payload").GetProperty("buttons")[0][0].GetProperty("payload").GetString().Should().Be("ok"); + message.GetProperty("link").GetProperty("mid").GetString().Should().Be("mid.1"); + message.GetProperty("link").GetProperty("type").GetString().Should().Be("reply"); + } + + #endregion + + #region #18 GetMessagesAsync parses {"messages": [...]} + + [Fact] + public async Task Issue18_GetMessagesAsync_ShouldParseMessagesWrapper() + { + const string body = """{"messages":[{"recipient":{"chat_id":10,"chat_type":"chat"},"timestamp":1,"body":{"mid":"mid.1","seq":1,"text":"hi"}}]}"""; + MaxApiRequest? captured = null; + _http.Setup(x => x.SendAsyncRaw(It.IsAny(), It.IsAny())) + .Callback((req, _) => captured = req) + .ReturnsAsync(body); + + var api = new MessagesApi(_http.Object, _options); + var messages = await api.GetMessagesAsync(10, count: 20, from: 2000, to: 1000); + + messages.Should().ContainSingle(); + messages[0].Mid.Should().Be("mid.1"); + captured!.QueryParameters.Should().Contain(new KeyValuePair("chat_id", "10")); + captured.QueryParameters.Should().Contain(new KeyValuePair("count", "20")); + captured.QueryParameters.Should().Contain(new KeyValuePair("from", "2000")); + captured.QueryParameters.Should().Contain(new KeyValuePair("to", "1000")); + } + + [Fact] + public async Task Issue18_GetMessagesByIdsAsync_ShouldJoinIds() + { + MaxApiRequest? captured = null; + _http.Setup(x => x.SendAsyncRaw(It.IsAny(), It.IsAny())) + .Callback((req, _) => captured = req) + .ReturnsAsync("""{"messages":[]}"""); + + var api = new MessagesApi(_http.Object, _options); + var messages = await api.GetMessagesByIdsAsync(new[] { "mid.1", "mid.2" }); + + messages.Should().BeEmpty(); + captured!.QueryParameters.Should().Contain(new KeyValuePair("message_ids", "mid.1,mid.2")); + captured.QueryParameters.Should().NotContainKey("chat_id"); + } + + #endregion + + #region #22 attachment.not.ready + + [Fact] + public async Task Issue22_MaxHttpClient_ShouldParseFlatErrorBody_AndThrowAttachmentNotReady() + { + var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.BadRequest) + { + Content = new StringContent("""{"code":"attachment.not.ready","message":"Key: errors.process.attachment.file.not.processed"}""", Encoding.UTF8, "application/json") + }); + using var client = new MaxHttpClient(new HttpClient(handler), new MaxBotClientOptions { BaseUrl = "https://platform-api.max.ru", RetryCount = 0 }); + + var act = () => client.SendAsyncRaw(new MaxApiRequest { Method = HttpMethod.Post, Endpoint = "/messages", Body = new { } }); + + var exception = await act.Should().ThrowAsync(); + exception.Which.ErrorCode.Should().Be("attachment.not.ready"); + exception.Which.Message.Should().Be("Key: errors.process.attachment.file.not.processed"); + exception.Which.HttpStatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task Issue22_MaxHttpClient_ShouldExposeCodeAndMessage_ForOtherFlatErrors() + { + var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.BadRequest) + { + Content = new StringContent("""{"code":"proto.payload","message":"text: size must be between 0 and 4000"}""", Encoding.UTF8, "application/json") + }); + using var client = new MaxHttpClient(new HttpClient(handler), new MaxBotClientOptions { BaseUrl = "https://platform-api.max.ru", RetryCount = 0 }); + + var act = () => client.SendAsyncRaw(new MaxApiRequest { Method = HttpMethod.Post, Endpoint = "/messages", Body = new { } }); + + var exception = await act.Should().ThrowAsync(); + exception.Which.Should().NotBeOfType(); + exception.Which.ErrorCode.Should().Be("proto.payload"); + exception.Which.Message.Should().Be("text: size must be between 0 and 4000"); + } + + [Fact] + public async Task Issue22_SendMessageWithAttachmentAsync_ShouldRetryAttachmentNotReady() + { + var expected = new Message { Body = new MessageBody { Mid = "mid.ok" } }; + _http.SetupSequence(x => x.SendAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new MaxAttachmentNotReadyException("Key: errors.process.attachment.file.not.processed")) + .ReturnsAsync(new MessageResponse { Message = expected }); + + var api = new MessagesApi(_http.Object, _options); + var result = await api.SendMessageWithAttachmentAsync(AttachmentRequest.Video("video-token"), chatId: 1); + + result.Mid.Should().Be("mid.ok"); + _http.Verify(x => x.SendAsync(It.IsAny(), It.IsAny()), Times.Exactly(2)); + } + + [Fact] + public async Task Issue22_SendMessageWithAttachmentAsync_ShouldNotRetryOtherBadRequests() + { + _http.Setup(x => x.SendAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new MaxApiException("text: size must be between 0 and 4000", "proto.payload", HttpStatusCode.BadRequest)); + + var api = new MessagesApi(_http.Object, _options); + var act = () => api.SendMessageWithAttachmentAsync(AttachmentRequest.Video("video-token"), chatId: 1); + + await act.Should().ThrowAsync().Where(ex => ex.ErrorCode == "proto.payload"); + _http.Verify(x => x.SendAsync(It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task Issue22_EditMessageAsync_ShouldRetryAttachmentNotReady() + { + _http.SetupSequence(x => x.SendAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new MaxAttachmentNotReadyException()) + .ReturnsAsync(new Response { Success = true }); + + var api = new MessagesApi(_http.Object, _options); + var result = await api.EditMessageAsync("mid.1", new EditMessageRequest { Attachments = new[] { AttachmentRequest.Image("img-token") } }); + + result.Success.Should().BeTrue(); + _http.Verify(x => x.SendAsync(It.IsAny(), It.IsAny()), Times.Exactly(2)); + } + + #endregion + + #region #23 GetChatsAsync parses {"chats": [...], "marker": ...} + + [Fact] + public async Task Issue23_GetChatsAsync_ShouldParseChatList() + { + const string body = """{"chats":[{"chat_id":-1001,"type":"chat","status":"active","title":"Team","is_public":false,"participants_count":3,"messages_count":42}],"marker":777}"""; + _http.Setup(x => x.SendAsyncRaw(It.IsAny(), It.IsAny())).ReturnsAsync(body); + + var api = new ChatsApi(_http.Object, _options); + var chats = await api.GetChatsAsync(); + + chats.Should().ContainSingle(); + chats[0].ChatId.Should().Be(-1001); + chats[0].Title.Should().Be("Team"); + chats[0].MessagesCount.Should().Be(42); + } + + [Fact] + public async Task Issue23_GetChatListAsync_ShouldPassPaginationAndReturnMarker() + { + MaxApiRequest? captured = null; + _http.Setup(x => x.SendAsyncRaw(It.IsAny(), It.IsAny())) + .Callback((req, _) => captured = req) + .ReturnsAsync("""{"chats":[],"marker":null}"""); + + var api = new ChatsApi(_http.Object, _options); + var page = await api.GetChatListAsync(count: 10, marker: 5); + + page.Chats.Should().BeEmpty(); + page.Marker.Should().BeNull(); + captured!.QueryParameters.Should().Contain(new KeyValuePair("count", "10")); + captured.QueryParameters.Should().Contain(new KeyValuePair("marker", "5")); + } + + [Fact] + public void Chat_ShouldTolerateUnknownStatus() + { + var suspended = MaxJsonSerializer.Deserialize("""{"chat_id":1,"type":"channel","status":"suspended"}"""); + var unknown = MaxJsonSerializer.Deserialize("""{"chat_id":1,"type":"channel","status":"something_new"}"""); + + suspended.Status.Should().Be(ChatStatus.Suspended); + unknown.Status.Should().BeNull(); + } + + [Fact] + public void ChatMember_ShouldSkipUnknownPermissions() + { + var member = MaxJsonSerializer.Deserialize("""{"user_id":1,"is_admin":true,"permissions":["read_all_messages","brand_new_permission","pin_message"]}"""); + + member.Permissions.Should().Equal(ChatAdminPermission.ReadAllMessages, ChatAdminPermission.PinMessage); + } + + [Fact] + public async Task GetPinnedMessageAsync_ShouldParseMessageWrapper() + { + _http.SetupSequence(x => x.SendAsyncRaw(It.IsAny(), It.IsAny())) + .ReturnsAsync("""{"message":{"body":{"mid":"mid.pinned","text":"rules"}}}""") + .ReturnsAsync("""{"message":null}"""); + + var api = new ChatsApi(_http.Object, _options); + + var pinned = await api.GetPinnedMessageAsync(1); + var none = await api.GetPinnedMessageAsync(1); + + pinned!.Mid.Should().Be("mid.pinned"); + none.Should().BeNull(); + } + + [Fact] + public void AddChatAdminRequest_ShouldSerializeAdminsArray() + { + var request = new AddChatAdminRequest + { + Admins = new[] + { + new ChatAdmin { UserId = 42, Permissions = new[] { ChatAdminPermission.ReadAllMessages, ChatAdminPermission.PinMessage }, Alias = "Mod" } + } + }; + + var json = MaxJsonSerializer.Serialize(request); + + json.Should().Be("""{"admins":[{"user_id":42,"permissions":["read_all_messages","pin_message"],"alias":"Mod"}]}"""); + request.UserId.Should().Be(42); + } + + #endregion + + #region #20 #21 #25 incoming attachments + + [Fact] + public void Issue20_ImageAttachment_ShouldReadPayload() + { + const string json = """{"payload":{"photo_id":9290792871,"token":"photo-token","url":"https://i.oneme.ru/i?r=abc"},"type":"image"}"""; + + var attachment = MaxJsonSerializer.Deserialize(json); + + var photo = attachment.Should().BeOfType().Subject; + photo.Id.Should().Be(9290792871); + photo.Token.Should().Be("photo-token"); + photo.FileId.Should().Be("photo-token"); + photo.Url.Should().Be("https://i.oneme.ru/i?r=abc"); + } + + [Fact] + public void FileAttachment_ShouldMergeRootFilenameAndSize() + { + const string json = """{"type":"file","payload":{"url":"https://files/doc.pdf","token":"file-token"},"filename":"doc.pdf","size":2048}"""; + + var document = MaxJsonSerializer.Deserialize(json).Should().BeOfType().Subject; + + document.FileName.Should().Be("doc.pdf"); + document.FileSize.Should().Be(2048); + document.Token.Should().Be("file-token"); + document.Url.Should().Be("https://files/doc.pdf"); + } + + [Fact] + public void VideoAttachment_ShouldMergeRootMetadata() + { + const string json = """{"type":"video","payload":{"url":"https://video","token":"video-token"},"thumbnail":{"url":"https://thumb"},"width":1280,"height":720,"duration":15}"""; + + var video = MaxJsonSerializer.Deserialize(json).Should().BeOfType().Subject; + + video.Token.Should().Be("video-token"); + video.Thumbnail!.Url.Should().Be("https://thumb"); + video.Width.Should().Be(1280); + video.Height.Should().Be(720); + video.Duration.Should().Be(15); + } + + [Fact] + public void StickerAndShareAttachments_ShouldBeRecognized() + { + var sticker = MaxJsonSerializer.Deserialize("""{"type":"sticker","payload":{"url":"https://sticker","code":"abc"},"width":128,"height":128}"""); + var share = MaxJsonSerializer.Deserialize("""{"type":"share","payload":{"url":"https://example.com"},"title":"Example","description":"Desc","image_url":"https://img"}"""); + + sticker.Should().BeOfType().Which.Code.Should().Be("abc"); + var preview = share.Should().BeOfType().Subject; + preview.Url.Should().Be("https://example.com"); + preview.Title.Should().Be("Example"); + preview.ImageUrl.Should().Be("https://img"); + } + + [Fact] + public void Issue25_ContactAttachment_ShouldReadPayload() + { + const string json = """{"type":"contact","payload":{"vcf_info":"BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Ivan Petrov\r\nTEL;TYPE=cell:+79001234567\r\nEND:VCARD","max_info":{"user_id":5,"first_name":"Ivan","last_name":"Petrov","name":"Ivan Petrov"},"hash":"h1"}}"""; + + var contact = MaxJsonSerializer.Deserialize(json).Should().BeOfType().Subject; + + contact.MaxInfo!.Id.Should().Be(5); + contact.Hash.Should().Be("h1"); + contact.PhoneNumber.Should().Be("+79001234567"); + contact.FullName.Should().Be("Ivan Petrov"); + } + + [Fact] + public async Task Issue21_GetVideoAsync_ShouldReadUrlsObject() + { + const string body = """{"token":"video-token","urls":{"mp4_480":"https://v/480","mp4_360":"https://v/360","mp4_240":"https://v/240","mp4_144":"https://v/144"},"thumbnail":{"url":"https://thumb"},"width":640,"height":480,"duration":12}"""; + _http.Setup(x => x.SendAsyncRaw(It.IsAny(), It.IsAny())).ReturnsAsync(body); + + var api = new MessagesApi(_http.Object, _options); + var video = await api.GetVideoAsync("video-token"); + + video.Urls.Should().ContainKey("mp4_480").WhoseValue.Should().Be("https://v/480"); + video.Thumbnail!.Url.Should().Be("https://thumb"); + video.Duration.Should().Be(12); + } + + #endregion + + #region #16 #37 updates + + [Fact] + public void Issue16_BotStarted_ShouldExposeDeepLinkPayload() + { + const string json = """{"update_type":"bot_started","timestamp":1,"chat_id":100,"user":{"user_id":5,"name":"Ivan"},"payload":"123","user_locale":"ru"}"""; + + var update = MaxJsonSerializer.Deserialize(json); + + update.Payload.Should().Be("123"); + update.BotStartedUpdate!.Payload.Should().Be("123"); + update.BotStartedUpdate.ChatId.Should().Be(100); + } + + [Fact] + public void Issue37_MessageCreated_ShouldExposeChatFromRecipient() + { + const string json = """{"update_type":"message_created","timestamp":1,"message":{"sender":{"user_id":5},"recipient":{"chat_id":-700,"chat_type":"chat"},"body":{"mid":"mid.1","text":"hi"}}}"""; + + var update = MaxJsonSerializer.Deserialize(json); + + update.Chat.Should().NotBeNull(); + update.Chat!.ChatId.Should().Be(-700); + update.Chat.Type.Should().Be(ChatType.Chat); + update.ChatId.Should().Be(-700); + update.ChatType.Should().Be(ChatType.Chat); + + // The inferred chat is not written back as if MAX had sent it. + using var serialized = JsonDocument.Parse(MaxJsonSerializer.Serialize(update)); + serialized.RootElement.TryGetProperty("chat", out _).Should().BeFalse(); + serialized.RootElement.TryGetProperty("chat_id", out _).Should().BeFalse(); + } + + [Fact] + public void Issue37_DialogMessage_ShouldReportDialogChatType() + { + const string json = """{"update_type":"message_created","timestamp":1,"message":{"recipient":{"chat_id":79313411,"chat_type":"dialog","user_id":94399782},"body":{"mid":"mid.1","text":"/start"}}}"""; + + var update = MaxJsonSerializer.Deserialize(json); + + update.ChatType.Should().Be(ChatType.Dialog); + } + + [Fact] + public void MessageRemoved_ShouldExposeIdentifiers() + { + const string json = """{"update_type":"message_removed","timestamp":1,"message_id":"mid.removed","chat_id":10,"user_id":20}"""; + + var update = MaxJsonSerializer.Deserialize(json); + + var removed = update.MessageRemovedUpdate; + removed.Should().NotBeNull(); + removed!.MessageId.Should().Be("mid.removed"); + removed.ChatId.Should().Be(10); + removed.UserId.Should().Be(20); + } + + [Fact] + public void ChatEvents_ShouldExposeChatIdAndExtraFields() + { + var botAdded = MaxJsonSerializer.Deserialize("""{"update_type":"bot_added","timestamp":1,"chat_id":10,"user":{"user_id":2},"is_channel":true}"""); + var userRemoved = MaxJsonSerializer.Deserialize("""{"update_type":"user_removed","timestamp":1,"chat_id":10,"user":{"user_id":3},"admin_id":4,"is_channel":false}"""); + var titleChanged = MaxJsonSerializer.Deserialize("""{"update_type":"chat_title_changed","timestamp":1,"chat_id":10,"user":{"user_id":2},"title":"New title"}"""); + + botAdded.BotAddedUpdate!.ChatId.Should().Be(10); + botAdded.BotAddedUpdate.IsChannel.Should().BeTrue(); + botAdded.ChatType.Should().Be(ChatType.Channel); + userRemoved.UserRemovedUpdate!.AdminId.Should().Be(4); + userRemoved.UserRemovedUpdate.ChatId.Should().Be(10); + titleChanged.ChatTitleChangedUpdate!.Title.Should().Be("New title"); + titleChanged.ChatTitleChangedUpdate.ChatId.Should().Be(10); + } + + #endregion + + #region #15 #38 outgoing attachments and uploads + + [Fact] + public void Issue15_ImageFromUrl_ShouldSerializeUrlPayload() + { + var json = MaxJsonSerializer.Serialize(new SendMessageRequest + { + Text = "pic", + Attachments = new[] { AttachmentRequest.ImageFromUrl("https://example.com/cat.jpg") } + }); + + json.Should().Contain("""{"type":"image","payload":{"url":"https://example.com/cat.jpg"}}"""); + } + + [Fact] + public void AttachmentRequest_Location_ShouldSerializeFlatCoordinates() + { + var json = MaxJsonSerializer.Serialize(AttachmentRequest.Location(55.75, 37.62)); + + json.Should().Be("""{"type":"location","latitude":55.75,"longitude":37.62}"""); + } + + [Fact] + public void AttachmentRequest_Contact_ShouldUseSnakeCasePayload() + { + using var json = JsonDocument.Parse(MaxJsonSerializer.Serialize(AttachmentRequest.Contact(name: "Ivan", vcfPhone: "+79001234567"))); + + json.RootElement.GetProperty("type").GetString().Should().Be("contact"); + var payload = json.RootElement.GetProperty("payload"); + payload.GetProperty("name").GetString().Should().Be("Ivan"); + payload.GetProperty("vcf_phone").GetString().Should().Be("+79001234567"); + payload.TryGetProperty("contact_id", out _).Should().BeFalse(); + } + + [Fact] + public void Issue38_ImageUploadResponse_ShouldExposePhotosAndToken() + { + const string body = """{"photos":{"aBcD123":{"token":"photo-token"}}}"""; + + var result = FilesApi.ParseUploadResponse(body); + + result.Photos.Should().ContainKey("aBcD123").WhoseValue.Token.Should().Be("photo-token"); + result.Token.Should().Be("photo-token"); + + var attachmentJson = MaxJsonSerializer.Serialize(AttachmentRequest.FromUpload(UploadType.Image, result)); + attachmentJson.Should().Be("""{"type":"image","payload":{"photos":{"aBcD123":{"token":"photo-token"}}}}"""); + } + + [Fact] + public void Issue38_FileUploadResponse_ShouldExposeFileIdAndToken() + { + var result = FilesApi.ParseUploadResponse("""{"file_id":123456,"token":"file-token"}"""); + + result.FileId.Should().Be(123456); + result.Token.Should().Be("file-token"); + } + + [Fact] + public void Issue38_XmlUploadResponse_ShouldNotBeTreatedAsToken() + { + var result = FilesApi.ParseUploadResponse("1"); + + result.Token.Should().BeNull(); + } + + [Fact] + public async Task Issue38_UploadAsync_ShouldUseUploadTokenForVideo() + { + _http.Setup(x => x.SendAsyncRaw(It.Is(r => r.Endpoint == "/uploads"), It.IsAny())) + .ReturnsAsync("""{"url":"https://vu.oneme.ru/upload?id=1","token":"video-token"}"""); + _http.Setup(x => x.SendAsyncRaw("https://vu.oneme.ru/upload?id=1", It.IsAny?>(), It.IsAny(), It.IsAny())) + .ReturnsAsync("1"); + + var api = new FilesApi(_http.Object, _options); + using var stream = new MemoryStream(new byte[] { 1, 2, 3 }); + var result = await api.UploadAsync(UploadType.Video, stream, "clip.mp4"); + + result.Token.Should().Be("video-token"); + MaxJsonSerializer.Serialize(AttachmentRequest.FromUpload(UploadType.Video, result)) + .Should().Be("""{"type":"video","payload":{"token":"video-token"}}"""); + } + + [Fact] + public async Task UploadFileDataAsync_ShouldRewindStreamOnRetry() + { + var uploadedLengths = new List(); + _http.Setup(x => x.SendAsyncRaw(It.IsAny(), It.IsAny?>(), It.IsAny(), It.IsAny())) + .Returns?, CancellationToken, HttpMethod?>(async (_, factory, _, _) => + { + // Simulate a failed first attempt followed by a retry. + for (var i = 0; i < 2; i++) + { + using var content = factory!(); + var bytes = await content!.ReadAsByteArrayAsync(); + uploadedLengths.Add(bytes.Length); + } + + return """{"token":"t"}"""; + }); + + var api = new FilesApi(_http.Object, _options); + using var stream = new MemoryStream(Enumerable.Repeat((byte)7, 1000).ToArray()); + await api.UploadFileDataAsync("https://upload", stream, "file.bin"); + + uploadedLengths.Should().HaveCount(2); + uploadedLengths[1].Should().Be(uploadedLengths[0]); + } + + #endregion + + #region Networking + + [Fact] + public void UserAgent_ShouldUsePackageVersion() + { + MaxHttpClient.UserAgent.Should().StartWith("MaxMessenger.Bot/"); + MaxHttpClient.UserAgent.Should().NotContain("0.5.1-alpha"); + MaxHttpClient.UserAgent.Should().NotContain("+"); + } + + #endregion + + private sealed class StubHandler : HttpMessageHandler + { + private readonly Func _responder; + + public StubHandler(Func responder) => _responder = responder; + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + => Task.FromResult(_responder(request)); + } +} diff --git a/tests/Max.Bot.Tests/Unit/Types/AttachmentFormatTests.cs b/tests/Max.Bot.Tests/Unit/Types/AttachmentFormatTests.cs index c1f1da0..c0a2f4a 100644 --- a/tests/Max.Bot.Tests/Unit/Types/AttachmentFormatTests.cs +++ b/tests/Max.Bot.Tests/Unit/Types/AttachmentFormatTests.cs @@ -227,11 +227,12 @@ public void InlineKeyboard_ShouldDeserialize_CallbackIdAndPayload() [Fact] public void UnknownType_ShouldDeserialize_AsDocumentAttachment() { - var json = """{"type":"sticker","id":999,"file_id":"sticker1"}"""; + var json = """{"type":"future_media","id":999,"file_id":"media1"}"""; var attachment = MaxJsonSerializer.Deserialize(json); attachment.Should().BeOfType(); + attachment.Type.Should().Be("future_media"); } // ==================== FULL MESSAGE ==================== diff --git a/tests/Max.Bot.Tests/Unit/Types/AttachmentRequestTests.cs b/tests/Max.Bot.Tests/Unit/Types/AttachmentRequestTests.cs index 4845adf..7401321 100644 --- a/tests/Max.Bot.Tests/Unit/Types/AttachmentRequestTests.cs +++ b/tests/Max.Bot.Tests/Unit/Types/AttachmentRequestTests.cs @@ -29,20 +29,17 @@ public void Type_ShouldBeRequired() } [Fact] - public void Payload_ShouldBeRequired() + public void Payload_ShouldBeOptional_ForLocationAttachments() { - // Arrange - var request = new AttachmentRequest - { - Type = "image", - Payload = null! - }; + // Arrange — MAX API location attachments carry latitude/longitude instead of a payload + var request = AttachmentRequest.Location(55.75, 37.62); // Act var validationResults = ValidateModel(request); // Assert - validationResults.Should().Contain(v => v.MemberNames.Contains("Payload") && v.ErrorMessage != null && v.ErrorMessage.Contains("required", StringComparison.OrdinalIgnoreCase)); + request.Payload.Should().BeNull(); + validationResults.Should().BeEmpty(); } [Fact] From c73004ae091618dad7aab6675bee0f9281835f5a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 20:30:15 +0000 Subject: [PATCH 2/2] fix: address findings from the multi-agent review of v0.7.0-alpha - Format ids and numeric query values with InvariantCulture (U+2212 minus broke negative group chat ids on sv-SE/nb-NO/fi-FI hosts) - Webhook: controller and MapMaxWebhook share one pipeline that checks size and secret before reading/parsing the body; no fallback to unconfigured IOptions (it disabled the secret check); Webhook.HandlerTimeout is applied; secret format validated; ConfigureWebhookAsync only updates options on success and always stores an explicit secret - Polling: tolerant batch parsing skips poison updates instead of looping forever; marker advances only after the whole batch; StopAsync waits for parallel handlers; AllowedUsernames uses the callback presser - Attachments with unexpected field types no longer break deserialization - attachment.not.ready is retried by SendMessageAsync(SendMessageRequest) too - Uploads: non-seekable streams are buffered for retries; derived photo token is not serialized next to photos; resumable video/audio upload skips trailing GET - Retry-After in the past no longer throws; Update.Chat/ChatId setters clear inference flags; message_chat_created exposes ChatId - GetChatMembersPageAsync returns the marker; open_app/clipboard buttons; ChatType from is_channel; AddChatAdminRequest.UserId marked obsolete - CHANGELOG/README accuracy fixes Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JWng6Vbuo8rweWS5x8PX56 --- CHANGELOG.md | 26 +- README.md | 5 +- src/Max.Bot/Api/BaseApi.cs | 2 +- src/Max.Bot/Api/ChatsApi.cs | 46 +- src/Max.Bot/Api/FilesApi.cs | 53 ++- src/Max.Bot/Api/IChatsApi.cs | 13 + src/Max.Bot/Api/IFilesApi.cs | 5 +- src/Max.Bot/Api/MessagesApi.cs | 23 +- src/Max.Bot/Api/SubscriptionsApi.cs | 7 +- src/Max.Bot/Api/UsersApi.cs | 3 +- .../Configuration/MaxWebhookOptions.cs | 28 ++ .../MaxAttachmentNotReadyException.cs | 5 +- src/Max.Bot/MaxClient.cs | 22 +- src/Max.Bot/Networking/MaxHttpClient.cs | 9 +- ...axWebhookEndpointRouteBuilderExtensions.cs | 80 +--- src/Max.Bot/Polling/UpdateFilterUtilities.cs | 5 +- src/Max.Bot/Polling/UpdatePoller.cs | 28 +- src/Max.Bot/Polling/WebhookController.cs | 27 +- .../Polling/WebhookRequestProcessor.cs | 102 ++++ .../Converters/AttachmentJsonConverter.cs | 20 + .../InlineKeyboardButtonJsonConverter.cs | 35 +- .../Types/Converters/UpdateJsonConverter.cs | 19 +- src/Max.Bot/Types/Enums/ButtonType.cs | 11 +- src/Max.Bot/Types/FileUploadResult.cs | 28 +- src/Max.Bot/Types/GetUpdatesResponse.cs | 62 +++ src/Max.Bot/Types/InlineKeyboardButton.cs | 19 + .../Types/Requests/AddChatAdminRequest.cs | 6 +- src/Max.Bot/Types/Update.cs | 58 ++- src/Max.Bot/Types/UpdateTypes.cs | 5 + .../Unit/Polling/WebhookControllerTests.cs | 89 +++- .../Unit/Regression/ReviewRegressionTests.cs | 448 ++++++++++++++++++ 31 files changed, 1086 insertions(+), 203 deletions(-) create mode 100644 src/Max.Bot/Polling/WebhookRequestProcessor.cs create mode 100644 tests/Max.Bot.Tests/Unit/Regression/ReviewRegressionTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c664bb..c89754e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,8 +16,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Webhook endpoint is opt-in** (#35): `WebhookController` стал `abstract`, поэтому подключение пакета больше не добавляет `POST /api/max/webhook` (и запись в Swagger) в каждое ASP.NET Core приложение. Подключайте эндпоинт явно через `app.MapMaxWebhook()` или унаследуйтесь от `WebhookController` с `[Route(...)]`. - **Webhook secret header**: `MaxWebhookOptions.SignatureHeaderName` по умолчанию теперь `X-Max-Bot-Api-Secret` — именно этот заголовок присылает MAX. Раньше при заданном `SecretToken` все реальные webhook-запросы отклонялись с 401. - **Callback answer body**: `NewMessageBody.Attachments` теперь `AttachmentRequest[]`, а `NewMessageBody.Link` — `NewMessageLink` (формат запроса `POST /answers`). -- **AttachmentRequest**: `Payload` стал nullable (у `location` нет payload), добавлены `Latitude`/`Longitude`; атрибуты `[Required]` убраны. +- **AttachmentRequest**: `Payload` стал nullable (у `location` нет payload), добавлены `Latitude`/`Longitude`; с `Payload` снят атрибут `[Required]` (`Type` по-прежнему обязателен). - **Typed updates**: `MessageRemovedUpdate.Message` и `MessageChatCreatedUpdate.Message` стали nullable — MAX присылает для этих событий идентификаторы, а не сообщение. +- **WebhookController action**: `PostAsync(CancellationToken)` больше не принимает `[FromBody] Update` — контроллер сам читает тело после проверки размера и секрета, поэтому неавторизованные запросы не парсятся. +- **Webhook secret format**: `MaxWebhookOptions.Validate()` и `ConfigureWebhookAsync` отклоняют секрет, не соответствующий формату MAX (5-256 символов `A-Z`, `a-z`, `0-9`, `_`, `-`). +- **Upload result**: `photos` и `file_id` разбираются в типизированные `FileUploadResult.Photos` / `FileId` и больше не попадают в `AdditionalProperties`. +- **New interface members**: `IMessagesApi.GetMessagesAsync(chatId, count, from, to)`, `IMessagesApi.GetMessagesByIdsAsync`, `IChatsApi.GetChatListAsync`, `IChatsApi.GetChatMembersPageAsync`, `IFilesApi.UploadAsync` — собственные реализации этих интерфейсов нужно дополнить. +- **AddChatAdminRequest.UserId** помечен `[Obsolete]`: MAX требует список прав, используйте `Admins` с `ChatAdmin.Permissions`. ### Added @@ -26,8 +31,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **MapMaxWebhook** (#35): minimal-API эндпоинт `app.MapMaxWebhook(pattern)` с проверкой секрета и лимита размера тела. - **MaxAttachmentNotReadyException** (#22): отдельное исключение для ошибки `attachment.not.ready`. - **Messages pagination** (#18): `GetMessagesAsync(chatId, count, from, to)` и `GetMessagesByIdsAsync(messageIds)`. -- **Chats pagination** (#23): `GetChatListAsync(count, marker)` возвращает `ChatListResponse` с `marker` следующей страницы. -- **Chat info in updates** (#37): `Update.ChatId`, `Update.Chat` (частично: id и тип) и `Update.ChatType` заполняются из `message.recipient` для `message_created`, `message_edited` и `message_callback`. +- **Chats pagination** (#23): `GetChatListAsync(count, marker)` возвращает `ChatListResponse` с `marker` следующей страницы; `GetChatMembersPageAsync(chatId, marker, count)` возвращает участников вместе с `marker`. +- **Inline buttons**: `ButtonType.Clipboard`, кнопка `open_app` (`ButtonType.OpenApp` + `InlineKeyboardButton.WebApp`/`ContactId`) и `Quick` для `request_geo_location`. `OpenApp` без `WebApp` по-прежнему отправляется как `link`. +- **Chat info in updates** (#37): `Update.ChatId`, `Update.Chat` (частично: id и тип) и `Update.ChatType` заполняются из `message.recipient` для `message_created`, `message_edited` и `message_callback`; для `bot_added`/`bot_removed`/`user_added`/`user_removed` `ChatType` вычисляется из `is_channel`. - **Update fields**: `message_id`, `user_id`, `admin_id`, `title`, `is_channel`, `muted_until`, `start_payload` и соответствующие свойства в типизированных обёртках (`MessageRemovedUpdate`, `UserRemovedUpdate`, `ChatTitleChangedUpdate`, `BotAddedUpdate` и др.). - **Attachments**: `StickerAttachment`, `ShareAttachment`; `Token` у фото/видео/аудио/файлов, `VideoAttachment.Thumbnail`, `AudioAttachment.Transcription`. - **Models**: `MessageBody.Markup`, `MessageStat.Views`, `Chat.MessagesCount`, `ChatStatus.Suspended`, `Subscription.Time`/`Version`, `ChatAdmin` для назначения администраторов. @@ -37,14 +43,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Callback answers** (#34): `AnswerCallbackQueryAsync` передаёт `callback_id` вместо устаревшего `callback_query_id`. - **Image upload token** (#38): `FileUploadResult` снова содержит типизированные `Photos` и `FileId`, а `Token` заполняется из `photos`; XML-ответ CDN (`1`) больше не считается токеном. - **API error parsing** (#22): ошибки формата `{"code": "...", "message": "..."}` разбираются в `MaxApiException.ErrorCode`/`Message` вместо сырого JSON в тексте исключения. -- **Attachment retry**: повтор отправки/редактирования выполняется только для `attachment.not.ready` (раньше повторялся любой `400 Bad Request`); добавлен повтор для `EditMessageAsync`. +- **Attachment retry**: повтор отправки/редактирования выполняется только для `attachment.not.ready` (раньше повторялся любой `400 Bad Request`); повтор добавлен для `SendMessageAsync(SendMessageRequest)` с вложениями и для `EditMessageAsync`. - **Get chats** (#23): `GetChatsAsync` корректно разбирает ответ `{"chats": [...], "marker": ...}` вместо исключения `The JSON value could not be converted to Max.Bot.Types.Chat[]`. - **Pinned message**: `GetPinnedMessageAsync` разбирает ответ `{"message": ...}` и возвращает `null`, если ничего не закреплено. - **Add chat admins**: `AddChatAdminRequest` сериализуется как `{"admins": [{"user_id", "permissions", "alias"}]}`. - **Incoming attachments** (#20, #21, #25): поля из `payload` и корня вложения объединяются — у файлов теперь заполняются `FileName`/`FileSize` (`filename`/`size`), у видео — размеры, длительность и превью. - **Long polling marker**: следующий запрос `GET /updates` использует `marker` из ответа. Раньше подставлялся `update_id`, которого нет в MAX API (всегда `0`). - **Webhook configuration** (#36): `ConfigureWebhookAsync()` и `DeleteWebhookAsync()` берут URL из `MaxWebhookOptions.Endpoint`, а секрет — из `MaxWebhookOptions.SecretToken`, если они не переданы явно. -- **Upload retries**: при повторе `UploadFileDataAsync` поток перематывается в начало, чтобы не отправлять пустой файл. +- **Upload retries**: при повторе `UploadFileDataAsync` поток перематывается в начало, а не поддерживающие seek потоки один раз буферизуются, чтобы не отправлять пустой файл. Токен, вычисленный из `photos`, не сериализуется обратно (в image payload `token` и `photos` взаимоисключающие). `UploadFileResumableAsync` больше не делает лишний GET после загрузки видео/аудио. +- **Culture-invariant ids**: id чатов/пользователей и числовые query-параметры форматируются через `InvariantCulture` — в культурах с минусом U+2212 (sv-SE, nb-NO, fi-FI) запросы к групповым чатам (отрицательные id) ломались. +- **Poison updates**: апдейт, который не удалось разобрать, пропускается с логированием — раньше он ронял всю пачку, маркер не сдвигался и long polling зацикливался. Вложения с неожиданными типами полей превращаются в «пустое» вложение нужного типа вместо исключения. +- **Polling**: маркер сдвигается только после обработки всей пачки (остановка посреди пачки не теряет апдейты); `StopAsync` дожидается параллельных обработчиков; фильтр `AllowedUsernames` для `message_callback` проверяет нажавшего кнопку, а не автора сообщения-бота. +- **Webhook hardening**: `MapMaxWebhook` не подставляет пустые `IOptions` (это отключало проверку секрета) и берёт опции из `MaxClient`; `MaxWebhookOptions.HandlerTimeout` теперь применяется; `ConfigureWebhookAsync` обновляет `Endpoint`/`SecretToken` только при `success: true` и всегда сохраняет переданный секрет. +- **Retry-After**: дата в прошлом или отрицательное значение больше не приводят к `ArgumentOutOfRangeException` в цикле повторов. +- **message_chat_created**: `Update.ChatId` и `MessageChatCreatedUpdate.ChatId` заполняются из объекта `chat`. - **Unknown enum values**: неизвестные статусы чата и права администраторов больше не ломают десериализацию ответа. - **User-Agent**: заголовок содержит фактическую версию пакета вместо зашитой `0.5.1-alpha`. - **Build**: `Microsoft.SourceLink.GitHub` обновлён до `10.0.401` — версия `8.0.0` тянула уязвимый `Microsoft.Build.Tasks.Git` (GHSA-23fw-v26w-5fgq), из-за чего `dotnet restore` падал с `NU1902` при `TreatWarningsAsErrors`. @@ -371,7 +383,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- -[Unreleased]: https://github.com/NanoAgents/MaxBotNet/compare/v0.6.2-alpha...HEAD +[Unreleased]: https://github.com/NanoAgents/MaxBotNet/compare/v0.7.0-alpha...HEAD +[0.7.0-alpha]: https://github.com/NanoAgents/MaxBotNet/compare/v0.6.3-alpha...v0.7.0-alpha +[0.6.3-alpha]: https://github.com/NanoAgents/MaxBotNet/compare/v0.6.2-alpha...v0.6.3-alpha [0.6.2-alpha]: https://github.com/NanoAgents/MaxBotNet/compare/v0.6.1-alpha...v0.6.2-alpha [0.6.1-alpha]: https://github.com/NanoAgents/MaxBotNet/compare/v0.5.6-alpha...v0.6.1-alpha [0.5.6-alpha]: https://github.com/NanoAgents/MaxBotNet/compare/v0.5.5-alpha...v0.5.6-alpha diff --git a/README.md b/README.md index cbf7ff0..261dbb5 100644 --- a/README.md +++ b/README.md @@ -71,8 +71,8 @@ await client.Messages.SendMessageWithAttachmentAsync(AttachmentRequest.FromUploa ``` Также доступны `AttachmentRequest.Image(token)`, `Video`, `Audio`, `File`, `Sticker`, `Contact`, `Location`, `Share` и `Keyboard`. -Если MAX ещё обрабатывает только что загруженный файл, API отвечает `attachment.not.ready`: отправка/редактирование сообщения -повторяются автоматически, а при исчерпании попыток выбрасывается `MaxAttachmentNotReadyException`. +Если MAX ещё обрабатывает только что загруженный файл, API отвечает `attachment.not.ready`: `SendMessageAsync(SendMessageRequest)`, +`SendMessageWithAttachment(s)Async` и `EditMessageAsync` с вложениями повторяют запрос автоматически, а при исчерпании попыток выбрасывается `MaxAttachmentNotReadyException`. ## 🔘 Callback-кнопки @@ -105,6 +105,7 @@ await app.Services.GetRequiredService().ConfigureWebhookAsync(); ``` Для MVC-проектов можно унаследоваться от абстрактного `WebhookController` и пометить наследника `[Route(...)]`. +Секрет должен состоять из 5-256 символов `A-Z`, `a-z`, `0-9`, `_` и `-`; запросы без верного заголовка отклоняются ещё до чтения тела. ## 🧠 Архитектурные ориентиры diff --git a/src/Max.Bot/Api/BaseApi.cs b/src/Max.Bot/Api/BaseApi.cs index 0840128..a9aeea1 100644 --- a/src/Max.Bot/Api/BaseApi.cs +++ b/src/Max.Bot/Api/BaseApi.cs @@ -134,7 +134,7 @@ protected async Task ExecuteRequestAsync( { try { - var updatesResponse = MaxJsonSerializer.Deserialize(responseBody); + var updatesResponse = GetUpdatesResponse.ParseTolerant(responseBody); if (updatesResponse?.Updates != null) { return (T)(object)updatesResponse; diff --git a/src/Max.Bot/Api/ChatsApi.cs b/src/Max.Bot/Api/ChatsApi.cs index 2b70bbb..9c4a485 100644 --- a/src/Max.Bot/Api/ChatsApi.cs +++ b/src/Max.Bot/Api/ChatsApi.cs @@ -28,7 +28,7 @@ public ChatsApi(IMaxHttpClient httpClient, MaxBotOptions options) /// public async Task GetChatAsync(long chatId, CancellationToken cancellationToken = default) { - var request = CreateRequest(HttpMethod.Get, $"/chats/{chatId}"); + var request = CreateRequest(HttpMethod.Get, string.Create(CultureInfo.InvariantCulture, $"/chats/{chatId}")); return await ExecuteRequestAsync(request, cancellationToken).ConfigureAwait(false); } @@ -82,14 +82,14 @@ public async Task UpdateChatAsync(long chatId, UpdateChatRequest request, { ArgumentNullException.ThrowIfNull(request); - var apiRequest = CreateRequest(HttpMethod.Patch, $"/chats/{chatId}", request); + var apiRequest = CreateRequest(HttpMethod.Patch, string.Create(CultureInfo.InvariantCulture, $"/chats/{chatId}"), request); return await ExecuteRequestAsync(apiRequest, cancellationToken).ConfigureAwait(false); } /// public async Task DeleteChatAsync(long chatId, CancellationToken cancellationToken = default) { - var request = CreateRequest(HttpMethod.Delete, $"/chats/{chatId}"); + var request = CreateRequest(HttpMethod.Delete, string.Create(CultureInfo.InvariantCulture, $"/chats/{chatId}")); return await ExecuteRequestAsync(request, cancellationToken).ConfigureAwait(false); } @@ -98,14 +98,14 @@ public async Task SendChatActionAsync(long chatId, SendChatActionReque { ArgumentNullException.ThrowIfNull(request); - var apiRequest = CreateRequest(HttpMethod.Post, $"/chats/{chatId}/actions", request); + var apiRequest = CreateRequest(HttpMethod.Post, string.Create(CultureInfo.InvariantCulture, $"/chats/{chatId}/actions"), request); return await ExecuteRequestAsync(apiRequest, cancellationToken).ConfigureAwait(false); } /// public async Task GetPinnedMessageAsync(long chatId, CancellationToken cancellationToken = default) { - var request = CreateRequest(HttpMethod.Get, $"/chats/{chatId}/pin"); + var request = CreateRequest(HttpMethod.Get, string.Create(CultureInfo.InvariantCulture, $"/chats/{chatId}/pin")); // * GET /chats/{chatId}/pin returns {"message": {...}} or {"message": null} when nothing is pinned. var responseBody = await HttpClient.SendAsyncRaw(request, cancellationToken).ConfigureAwait(false); @@ -148,35 +148,35 @@ public async Task PinMessageAsync(long chatId, PinMessageRequest reque { ArgumentNullException.ThrowIfNull(request); - var apiRequest = CreateRequest(HttpMethod.Put, $"/chats/{chatId}/pin", request); + var apiRequest = CreateRequest(HttpMethod.Put, string.Create(CultureInfo.InvariantCulture, $"/chats/{chatId}/pin"), request); return await ExecuteRequestAsync(apiRequest, cancellationToken).ConfigureAwait(false); } /// public async Task UnpinMessageAsync(long chatId, CancellationToken cancellationToken = default) { - var request = CreateRequest(HttpMethod.Delete, $"/chats/{chatId}/pin"); + var request = CreateRequest(HttpMethod.Delete, string.Create(CultureInfo.InvariantCulture, $"/chats/{chatId}/pin")); return await ExecuteRequestAsync(request, cancellationToken).ConfigureAwait(false); } /// public async Task GetChatMembershipAsync(long chatId, CancellationToken cancellationToken = default) { - var request = CreateRequest(HttpMethod.Get, $"/chats/{chatId}/members/me"); + var request = CreateRequest(HttpMethod.Get, string.Create(CultureInfo.InvariantCulture, $"/chats/{chatId}/members/me")); return await ExecuteRequestAsync(request, cancellationToken).ConfigureAwait(false); } /// public async Task LeaveChatAsync(long chatId, CancellationToken cancellationToken = default) { - var request = CreateRequest(HttpMethod.Delete, $"/chats/{chatId}/members/me"); + var request = CreateRequest(HttpMethod.Delete, string.Create(CultureInfo.InvariantCulture, $"/chats/{chatId}/members/me")); return await ExecuteRequestAsync(request, cancellationToken).ConfigureAwait(false); } /// public async Task GetChatAdminsAsync(long chatId, CancellationToken cancellationToken = default) { - var request = CreateRequest(HttpMethod.Get, $"/chats/{chatId}/members/admins"); + var request = CreateRequest(HttpMethod.Get, string.Create(CultureInfo.InvariantCulture, $"/chats/{chatId}/members/admins")); var response = await ExecuteRequestAsync(request, cancellationToken).ConfigureAwait(false); return response.Members ?? []; } @@ -186,7 +186,7 @@ public async Task AddChatAdminAsync(long chatId, AddChatAdminRequest r { ArgumentNullException.ThrowIfNull(request); - var apiRequest = CreateRequest(HttpMethod.Post, $"/chats/{chatId}/members/admins", request); + var apiRequest = CreateRequest(HttpMethod.Post, string.Create(CultureInfo.InvariantCulture, $"/chats/{chatId}/members/admins"), request); return await ExecuteRequestAsync(apiRequest, cancellationToken).ConfigureAwait(false); } @@ -195,26 +195,34 @@ public async Task RemoveChatAdminAsync(long chatId, long userId, Cance { ValidateUserIdStrict(userId); - var request = CreateRequest(HttpMethod.Delete, $"/chats/{chatId}/members/admins/{userId}"); + var request = CreateRequest(HttpMethod.Delete, string.Create(CultureInfo.InvariantCulture, $"/chats/{chatId}/members/admins/{userId}")); return await ExecuteRequestAsync(request, cancellationToken).ConfigureAwait(false); } /// public async Task GetChatMembersAsync(long chatId, long? marker = null, int? count = null, CancellationToken cancellationToken = default) + { + var response = await GetChatMembersPageAsync(chatId, marker, count, cancellationToken).ConfigureAwait(false); + return response.Members ?? []; + } + + /// + public async Task GetChatMembersPageAsync(long chatId, long? marker = null, int? count = null, CancellationToken cancellationToken = default) { var queryParams = new Dictionary(); if (marker.HasValue) { - queryParams["marker"] = marker.Value.ToString(); + queryParams["marker"] = marker.Value.ToString(CultureInfo.InvariantCulture); } if (count.HasValue) { - queryParams["count"] = count.Value.ToString(); + queryParams["count"] = count.Value.ToString(CultureInfo.InvariantCulture); } - var request = CreateRequest(HttpMethod.Get, $"/chats/{chatId}/members", null, queryParams.Count > 0 ? queryParams : null); + var request = CreateRequest(HttpMethod.Get, string.Create(CultureInfo.InvariantCulture, $"/chats/{chatId}/members"), null, queryParams.Count > 0 ? queryParams : null); var response = await ExecuteRequestAsync(request, cancellationToken).ConfigureAwait(false); - return response.Members ?? []; + response.Members ??= []; + return response; } /// @@ -222,7 +230,7 @@ public async Task AddChatMembersAsync(long chatId, AddChatMembersReque { ArgumentNullException.ThrowIfNull(request); - var apiRequest = CreateRequest(HttpMethod.Post, $"/chats/{chatId}/members", request); + var apiRequest = CreateRequest(HttpMethod.Post, string.Create(CultureInfo.InvariantCulture, $"/chats/{chatId}/members"), request); return await ExecuteRequestAsync(apiRequest, cancellationToken).ConfigureAwait(false); } @@ -233,10 +241,10 @@ public async Task RemoveChatMemberAsync(long chatId, long userId, Canc var queryParams = new Dictionary { - { "user_id", userId.ToString() } + { "user_id", userId.ToString(CultureInfo.InvariantCulture) } }; - var request = CreateRequest(HttpMethod.Delete, $"/chats/{chatId}/members", null, queryParams); + var request = CreateRequest(HttpMethod.Delete, string.Create(CultureInfo.InvariantCulture, $"/chats/{chatId}/members"), null, queryParams); return await ExecuteRequestAsync(request, cancellationToken).ConfigureAwait(false); } } diff --git a/src/Max.Bot/Api/FilesApi.cs b/src/Max.Bot/Api/FilesApi.cs index 38d521e..5b0488c 100644 --- a/src/Max.Bot/Api/FilesApi.cs +++ b/src/Max.Bot/Api/FilesApi.cs @@ -47,32 +47,43 @@ public async Task UploadFileDataAsync(string uploadUrl, Stream ValidateNotEmpty(uploadUrl, nameof(uploadUrl)); ArgumentNullException.ThrowIfNull(fileStream); - var initialPosition = fileStream.CanSeek ? fileStream.Position : -1; - var attempt = 0; + // A retry must send the whole file again. Non-seekable streams (network, pipes) are buffered once, + // as the official Go SDK does, so that every attempt can start from the beginning. + MemoryStream? bufferedStream = null; + if (!fileStream.CanSeek) + { + bufferedStream = new MemoryStream(); + await fileStream.CopyToAsync(bufferedStream, cancellationToken).ConfigureAwait(false); + bufferedStream.Position = 0; + } + + var uploadStream = bufferedStream ?? fileStream; + var initialPosition = uploadStream.Position; // Factory to create content for retries HttpContent CreateContent() { - if (attempt++ > 0) - { - // A retry must send the whole file again, so rewind the stream to where the first attempt started. - if (!fileStream.CanSeek) - { - throw new InvalidOperationException("File upload failed and cannot be retried because the stream is not seekable."); - } - - fileStream.Position = initialPosition; - } + uploadStream.Position = initialPosition; var content = new MultipartFormDataContent(); - var streamContent = new StreamContent(new NonDisposingStreamWrapper(fileStream)); + var streamContent = new StreamContent(new NonDisposingStreamWrapper(uploadStream)); streamContent.Headers.ContentType = new MediaTypeHeaderValue(MimeTypes.FromFileName(fileName) ?? "application/octet-stream"); content.Add(streamContent, "data", fileName ?? "file"); return content; } - var responseBody = await HttpClient.SendAsyncRaw(uploadUrl, CreateContent, cancellationToken).ConfigureAwait(false); - return ParseUploadResponse(responseBody); + try + { + var responseBody = await HttpClient.SendAsyncRaw(uploadUrl, CreateContent, cancellationToken).ConfigureAwait(false); + return ParseUploadResponse(responseBody); + } + finally + { + if (bufferedStream != null) + { + await bufferedStream.DisposeAsync().ConfigureAwait(false); + } + } } public async Task UploadAsync(UploadType uploadType, Stream fileStream, string? fileName = null, CancellationToken cancellationToken = default) @@ -105,6 +116,7 @@ public async Task UploadFileResumableAsync(string uploadUrl, S var buffer = new byte[chunkSize]; long totalBytesRead = 0; + string? lastResponseBody = null; long fileLength = fileStream.CanSeek ? fileStream.Length : -1; while (true) @@ -134,10 +146,11 @@ HttpContent CreateChunkContent() var responseBody = await HttpClient.SendAsyncRaw(uploadUrl, CreateChunkContent, cancellationToken).ConfigureAwait(false); totalBytesRead += bytesRead; + lastResponseBody = responseBody; // Check if this chunk's response already contains the token/result var result = ParseUploadResponse(responseBody); - if (result.Token != null) + if (result.Token != null || result.FileId != null) { return result; } @@ -145,7 +158,13 @@ HttpContent CreateChunkContent() if (fileLength > 0 && totalBytesRead >= fileLength) break; } - // Finalize: If we reached here without a token, try a final GET + // * Video/audio CDN acknowledges chunks without a token (e.g. "1"): the token comes from POST /uploads. + if (!string.IsNullOrWhiteSpace(lastResponseBody)) + { + return ParseUploadResponse(lastResponseBody); + } + + // Finalize: If the server returned nothing for the chunks, try a final GET var finalResponseBody = await HttpClient.SendAsyncRaw(uploadUrl, null, cancellationToken, HttpMethod.Get).ConfigureAwait(false); return ParseUploadResponse(finalResponseBody); } diff --git a/src/Max.Bot/Api/IChatsApi.cs b/src/Max.Bot/Api/IChatsApi.cs index 36183de..87578a5 100644 --- a/src/Max.Bot/Api/IChatsApi.cs +++ b/src/Max.Bot/Api/IChatsApi.cs @@ -211,6 +211,19 @@ public interface IChatsApi /// Thrown when authentication fails. Task GetChatMembersAsync(long chatId, long? marker = null, int? count = null, CancellationToken cancellationToken = default); + /// + /// Gets a page of chat members together with the marker of the next page (GET /chats/{chatId}/members). + /// + /// The unique identifier of the chat. + /// Marker of the page to return, taken from of the previous page. Optional. + /// Number of members to return (1-100). Optional. + /// A cancellation token to cancel the operation. + /// A task that represents the asynchronous operation. The task result contains the members and the next page marker. + /// Thrown when the API returns an error response. + /// Thrown when a network error occurs. + /// Thrown when authentication fails. + Task GetChatMembersPageAsync(long chatId, long? marker = null, int? count = null, CancellationToken cancellationToken = default); + /// /// Adds members to a chat. /// diff --git a/src/Max.Bot/Api/IFilesApi.cs b/src/Max.Bot/Api/IFilesApi.cs index f0947a0..6107b54 100644 --- a/src/Max.Bot/Api/IFilesApi.cs +++ b/src/Max.Bot/Api/IFilesApi.cs @@ -76,7 +76,10 @@ public interface IFilesApi /// The size of each chunk in bytes. Default is 1 MB. /// The name of the file. Optional. /// A cancellation token to cancel the operation. - /// A task that represents the asynchronous operation. The task result contains the upload result with a token. + /// + /// A task that represents the asynchronous operation. The task result contains the upload result. + /// For video and audio the CDN does not return a token — use from . + /// /// Thrown when uploadUrl is null or empty, fileStream is not readable, or chunkSize is less than or equal to zero. /// Thrown when fileStream is null. /// Thrown when the upload fails. diff --git a/src/Max.Bot/Api/MessagesApi.cs b/src/Max.Bot/Api/MessagesApi.cs index b783881..c263115 100644 --- a/src/Max.Bot/Api/MessagesApi.cs +++ b/src/Max.Bot/Api/MessagesApi.cs @@ -129,10 +129,20 @@ public async Task SendMessageToUserAsync(long userId, string text, Inli /// - public async Task SendMessageAsync(SendMessageRequest request, long? chatId = null, long? userId = null, bool? disableLinkPreview = null, CancellationToken cancellationToken = default) + public Task SendMessageAsync(SendMessageRequest request, long? chatId = null, long? userId = null, bool? disableLinkPreview = null, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); + var hasAttachments = request.Attachments is { Length: > 0 }; + return ExecuteWithAttachmentRetryAsync( + hasAttachments, + () => SendMessageCoreAsync(request, chatId, userId, disableLinkPreview, cancellationToken), + cancellationToken); + } + + private async Task SendMessageCoreAsync(SendMessageRequest request, long? chatId, long? userId, bool? disableLinkPreview, CancellationToken cancellationToken) + { + // Validate that exactly one of chatId or userId is provided if (chatId.HasValue && userId.HasValue) { @@ -153,12 +163,12 @@ public async Task SendMessageAsync(SendMessageRequest request, long? ch var queryParams = new Dictionary(); if (chatId.HasValue) { - queryParams["chat_id"] = chatId.Value.ToString(); + queryParams["chat_id"] = chatId.Value.ToString(CultureInfo.InvariantCulture); } if (userId.HasValue) { - queryParams["user_id"] = userId.Value.ToString(); + queryParams["user_id"] = userId.Value.ToString(CultureInfo.InvariantCulture); } if (disableLinkPreview.HasValue) @@ -518,11 +528,8 @@ private Task SendMessageWithAttachmentRetryAsync( bool? disableLinkPreview, CancellationToken cancellationToken) { - var hasAttachments = request.Attachments is { Length: > 0 }; - return ExecuteWithAttachmentRetryAsync( - hasAttachments, - () => SendMessageAsync(request, chatId, userId, disableLinkPreview, cancellationToken), - cancellationToken); + // * SendMessageAsync(SendMessageRequest) already retries attachment.not.ready. + return SendMessageAsync(request, chatId, userId, disableLinkPreview, cancellationToken); } /// diff --git a/src/Max.Bot/Api/SubscriptionsApi.cs b/src/Max.Bot/Api/SubscriptionsApi.cs index 685544c..459cc8b 100644 --- a/src/Max.Bot/Api/SubscriptionsApi.cs +++ b/src/Max.Bot/Api/SubscriptionsApi.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Net.Http; using System.Threading; @@ -93,17 +94,17 @@ public async Task GetUpdatesAsync(GetUpdatesRequest request, if (request.Limit.HasValue) { - queryParams["limit"] = request.Limit.Value.ToString(); + queryParams["limit"] = request.Limit.Value.ToString(CultureInfo.InvariantCulture); } if (request.Timeout.HasValue) { - queryParams["timeout"] = request.Timeout.Value.ToString(); + queryParams["timeout"] = request.Timeout.Value.ToString(CultureInfo.InvariantCulture); } if (request.Marker.HasValue) { - queryParams["marker"] = request.Marker.Value.ToString(); + queryParams["marker"] = request.Marker.Value.ToString(CultureInfo.InvariantCulture); } if (request.Types != null && request.Types.Count > 0) diff --git a/src/Max.Bot/Api/UsersApi.cs b/src/Max.Bot/Api/UsersApi.cs index b46b445..2b6c6ec 100644 --- a/src/Max.Bot/Api/UsersApi.cs +++ b/src/Max.Bot/Api/UsersApi.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Net.Http; using Max.Bot.Configuration; using Max.Bot.Networking; @@ -27,7 +28,7 @@ public async Task GetUserAsync(long userId, CancellationToken cancellation { ValidateUserId(userId); - var request = CreateRequest(HttpMethod.Get, $"/users/{userId}"); + var request = CreateRequest(HttpMethod.Get, string.Create(CultureInfo.InvariantCulture, $"/users/{userId}")); return await ExecuteRequestAsync(request, cancellationToken).ConfigureAwait(false); } } diff --git a/src/Max.Bot/Configuration/MaxWebhookOptions.cs b/src/Max.Bot/Configuration/MaxWebhookOptions.cs index 7feb032..19f6625 100644 --- a/src/Max.Bot/Configuration/MaxWebhookOptions.cs +++ b/src/Max.Bot/Configuration/MaxWebhookOptions.cs @@ -50,6 +50,29 @@ public sealed class MaxWebhookOptions /// public bool RequireSubscriptionConfirmation { get; set; } = true; + /// + /// Determines whether a webhook secret matches the MAX API format (5-256 characters: A-Z, a-z, 0-9, _ and -). + /// + /// The secret to check. + /// true when the secret can be used for a subscription; otherwise, false. + public static bool IsValidSecret(string? secret) + { + if (secret is null || secret.Length is < 5 or > 256) + { + return false; + } + + foreach (var ch in secret) + { + if (!(char.IsAsciiLetterOrDigit(ch) || ch is '_' or '-')) + { + return false; + } + } + + return true; + } + /// /// Validates option values. /// @@ -83,6 +106,11 @@ public void Validate() { throw new ArgumentException("SignatureHeaderName cannot be null or whitespace.", nameof(SignatureHeaderName)); } + + if (!string.IsNullOrWhiteSpace(SecretToken) && !IsValidSecret(SecretToken)) + { + throw new ArgumentException("SecretToken must be 5-256 characters long and contain only A-Z, a-z, 0-9, '_' and '-'.", nameof(SecretToken)); + } } } diff --git a/src/Max.Bot/Exceptions/MaxAttachmentNotReadyException.cs b/src/Max.Bot/Exceptions/MaxAttachmentNotReadyException.cs index 643f889..061c528 100644 --- a/src/Max.Bot/Exceptions/MaxAttachmentNotReadyException.cs +++ b/src/Max.Bot/Exceptions/MaxAttachmentNotReadyException.cs @@ -7,8 +7,9 @@ namespace Max.Bot.Exceptions; /// has not been processed yet (error code attachment.not.ready). /// /// -/// The request can be retried after a short delay. SendMessageWithAttachmentAsync, -/// SendMessageWithAttachmentsAsync and EditMessageAsync already retry this error automatically. +/// The request can be retried after a short delay. SendMessageAsync(SendMessageRequest), +/// SendMessageWithAttachmentAsync, SendMessageWithAttachmentsAsync and EditMessageAsync already retry +/// this error automatically when the message has attachments; the exception is thrown when the retries are exhausted. /// public class MaxAttachmentNotReadyException : MaxApiException { diff --git a/src/Max.Bot/MaxClient.cs b/src/Max.Bot/MaxClient.cs index f2d494f..8a3ccc9 100644 --- a/src/Max.Bot/MaxClient.cs +++ b/src/Max.Bot/MaxClient.cs @@ -30,6 +30,11 @@ public class MaxClient : IMaxBotApi, IUpdatePipeline, IDisposable private readonly object _pollerLock = new(); private UpdatePoller? _updatePoller; + /// + /// Gets the options this client was created with. + /// + internal MaxBotOptions Options => _options; + /// public IBotApi Bot { get; } @@ -208,12 +213,12 @@ public async Task ProcessWebhookAsync(Update update, IUpdateHandler handler, ISe /// Optional list of update types to receive. If null, all update types will be received. /// /// Optional secret that MAX sends back in the X-Max-Bot-Api-Secret header. When omitted, - /// is used; when provided and no secret is configured yet, it is stored there - /// so that incoming webhooks are validated against it. + /// is used; when provided, it replaces + /// after a successful subscription so that incoming webhooks are validated against the secret MAX actually sends. /// /// A cancellation token to cancel the operation. /// A task that represents the asynchronous operation. The task result contains the response with success status. - /// Thrown when neither url nor is set, or the URL is invalid. + /// Thrown when neither url nor is set, the URL is invalid, or the secret does not match the MAX format. public async Task ConfigureWebhookAsync(string? url = null, List? updateTypes = null, string? secret = null, CancellationToken cancellationToken = default) { var webhookUrl = string.IsNullOrWhiteSpace(url) ? _options.Webhook.Endpoint : url; @@ -233,6 +238,10 @@ public async Task ConfigureWebhookAsync(string? url = null, List ConfigureWebhookAsync(string? url = null, List TimeSpan.Zero ? delta : TimeSpan.Zero; + } } return null; } diff --git a/src/Max.Bot/Polling/MaxWebhookEndpointRouteBuilderExtensions.cs b/src/Max.Bot/Polling/MaxWebhookEndpointRouteBuilderExtensions.cs index 985c3f2..5975aa3 100644 --- a/src/Max.Bot/Polling/MaxWebhookEndpointRouteBuilderExtensions.cs +++ b/src/Max.Bot/Polling/MaxWebhookEndpointRouteBuilderExtensions.cs @@ -1,11 +1,7 @@ using System; -using System.IO; -using System.Text.Json; using System.Threading.Tasks; using Max.Bot.Configuration; -using Max.Bot.Networking; using Max.Bot.Polling; -using Max.Bot.Types; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; @@ -28,11 +24,12 @@ public static class MaxWebhookEndpointRouteBuilderExtensions /// Maps a POST endpoint that receives MAX webhook updates and dispatches them to the registered . /// /// - /// Requires the following services in the DI container: - /// (or ), and - /// (or IOptions<MaxBotOptions>). + /// Requires (or ) and in the DI container. + /// Options are taken from a registered , otherwise from the pipeline, + /// otherwise from a configured IOptions<MaxBotOptions> (with a non-empty token). /// When is set, requests without a matching - /// header (X-Max-Bot-Api-Secret by default) are rejected with 401. + /// header (X-Max-Bot-Api-Secret by default) are rejected with 401 + /// before the body is read; bodies larger than are rejected with 413. /// /// The endpoint route builder. /// The route pattern. Defaults to . @@ -48,72 +45,43 @@ public static IEndpointConventionBuilder MapMaxWebhook(this IEndpointRouteBuilde private static async Task HandleWebhookAsync(HttpContext context) { var services = context.RequestServices; - var options = services.GetService() - ?? services.GetService>()?.Value - ?? throw new InvalidOperationException("MaxBotOptions is not registered in the service container."); var pipeline = services.GetService() ?? services.GetService() ?? throw new InvalidOperationException("IUpdatePipeline (for example MaxClient) is not registered in the service container."); var handler = services.GetService() ?? throw new InvalidOperationException("IUpdateHandler is not registered in the service container."); + var options = ResolveOptions(services, pipeline); var logger = services.GetService()?.CreateLogger("Max.Bot.Webhook"); - if (!WebhookRequestValidator.ValidatePayloadSize(context.Request, options, logger)) + var statusCode = await WebhookRequestProcessor.ProcessAsync(context, options, pipeline, handler, logger).ConfigureAwait(false); + context.Response.StatusCode = statusCode; + if (statusCode == StatusCodes.Status200OK) { - context.Response.StatusCode = StatusCodes.Status413PayloadTooLarge; - return; - } - - if (!WebhookRequestValidator.ValidateSignature(context.Request, options, logger)) - { - context.Response.StatusCode = StatusCodes.Status401Unauthorized; - return; - } - - var body = await ReadBodyAsync(context, WebhookRequestValidator.GetMaxBodyBytes(options)).ConfigureAwait(false); - if (body == null) - { - logger?.LogWarning("Webhook payload rejected: body exceeds the configured size limit."); - context.Response.StatusCode = StatusCodes.Status413PayloadTooLarge; - return; + await context.Response.WriteAsJsonAsync(new { success = true }, context.RequestAborted).ConfigureAwait(false); } + } - Update? update; - try - { - update = body.Length == 0 ? null : JsonSerializer.Deserialize(body, MaxJsonSerializer.Options); - } - catch (JsonException ex) + private static MaxBotOptions ResolveOptions(IServiceProvider services, IUpdatePipeline pipeline) + { + var options = services.GetService(); + if (options != null) { - logger?.LogWarning(ex, "Webhook payload rejected: invalid JSON."); - update = null; + return options; } - if (update == null) + if (pipeline is Max.Bot.MaxClient client) { - context.Response.StatusCode = StatusCodes.Status400BadRequest; - return; + return client.Options; } - await pipeline.ProcessWebhookAsync(update, handler, services, context.RequestAborted).ConfigureAwait(false); - await context.Response.WriteAsJsonAsync(new { success = true }, context.RequestAborted).ConfigureAwait(false); - } - - private static async Task ReadBodyAsync(HttpContext context, long maxBytes) - { - using var buffer = new MemoryStream(); - var chunk = new byte[81920]; - int read; - while ((read = await context.Request.Body.ReadAsync(chunk, context.RequestAborted).ConfigureAwait(false)) > 0) + // * IOptions always resolves (it is registered as an open generic): an unconfigured instance would + // silently disable the secret check, so only accept options that were actually configured. + var configured = services.GetService>()?.Value; + if (configured != null && !string.IsNullOrWhiteSpace(configured.Token)) { - if (buffer.Length + read > maxBytes) - { - return null; - } - - buffer.Write(chunk, 0, read); + return configured; } - return buffer.ToArray(); + throw new InvalidOperationException("MaxBotOptions is not registered in the service container."); } } diff --git a/src/Max.Bot/Polling/UpdateFilterUtilities.cs b/src/Max.Bot/Polling/UpdateFilterUtilities.cs index 9c0a303..3d0b191 100644 --- a/src/Max.Bot/Polling/UpdateFilterUtilities.cs +++ b/src/Max.Bot/Polling/UpdateFilterUtilities.cs @@ -59,8 +59,9 @@ public static bool ShouldDispatch(Update update, HashSet? typeFilter public static string? ExtractUsername(Update update) { - return update.Message?.Sender?.Username ?? - update.Callback?.User?.Username ?? + // * In message_callback the message is the bot's own message with the keyboard: the presser is callback.user. + return update.Callback?.User?.Username ?? + update.Message?.Sender?.Username ?? update.User?.Username; } } diff --git a/src/Max.Bot/Polling/UpdatePoller.cs b/src/Max.Bot/Polling/UpdatePoller.cs index f29b378..0c7bcc0 100644 --- a/src/Max.Bot/Polling/UpdatePoller.cs +++ b/src/Max.Bot/Polling/UpdatePoller.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -189,10 +190,12 @@ private async Task PollAsync(CancellationToken cancellationToken) if (response.Updates?.Length > 0) { + var batchCompleted = true; foreach (var update in response.Updates) { if (cancellationToken.IsCancellationRequested) { + batchCompleted = false; break; } @@ -212,13 +215,12 @@ private async Task PollAsync(CancellationToken cancellationToken) } // * MAX updates carry no update_id: the next page is addressed only by the marker returned with the batch. - if (_options.Polling.PersistMarkers) + // Advance it only when the whole batch was walked, so a stop mid-batch re-fetches the rest (at-least-once). + if (batchCompleted) { - _marker = response.Marker ?? _marker; - } - else - { - _marker = response.Marker ?? _options.Polling.InitialMarker; + _marker = _options.Polling.PersistMarkers + ? response.Marker ?? _marker + : response.Marker ?? _options.Polling.InitialMarker; } } else @@ -244,7 +246,8 @@ private async Task PollAsync(CancellationToken cancellationToken) } } - await DrainHandlersAsync(cancellationToken).ConfigureAwait(false); + // * The poll token is already cancelled here: wait for the handlers themselves, StopAsync bounds the wait. + await DrainHandlersAsync(CancellationToken.None).ConfigureAwait(false); } private async Task FetchUpdatesAsync(long? marker, CancellationToken cancellationToken) @@ -254,13 +257,13 @@ private async Task FetchUpdatesAsync(long? marker, Cancellat // the dedicated polling client with its own (larger) timeout. var queryParams = new Dictionary { - { "limit", _options.Polling.BatchSize.ToString() }, - { "timeout", ((int)Math.Round(_options.Polling.LongPollingTimeout.TotalSeconds)).ToString() } + { "limit", _options.Polling.BatchSize.ToString(CultureInfo.InvariantCulture) }, + { "timeout", ((int)Math.Round(_options.Polling.LongPollingTimeout.TotalSeconds)).ToString(CultureInfo.InvariantCulture) } }; if (marker.HasValue) { - queryParams["marker"] = marker.Value.ToString(); + queryParams["marker"] = marker.Value.ToString(CultureInfo.InvariantCulture); } if (_typeQueryFilter != null && _typeQueryFilter.Count > 0) @@ -303,7 +306,8 @@ private async Task FetchUpdatesAsync(long? marker, Cancellat try { - return MaxJsonSerializer.Deserialize(body); + return GetUpdatesResponse.ParseTolerant(body, (ex, raw) => + _logger?.LogError(ex, "Skipping update that could not be deserialized: {Update}", raw)); } catch (Exception ex) { @@ -331,7 +335,7 @@ private async Task DispatchAsync(Update update, CancellationToken cancellationTo { _logger?.LogError(t.Exception, "Unhandled exception in update handler for update {UpdateId}.", update.UpdateId); } - }, cancellationToken, TaskContinuationOptions.None, TaskScheduler.Default); + }, CancellationToken.None, TaskContinuationOptions.None, TaskScheduler.Default); _inFlightHandlers.Add(handlerTask); diff --git a/src/Max.Bot/Polling/WebhookController.cs b/src/Max.Bot/Polling/WebhookController.cs index 15887ad..cda9bda 100644 --- a/src/Max.Bot/Polling/WebhookController.cs +++ b/src/Max.Bot/Polling/WebhookController.cs @@ -2,7 +2,6 @@ using System.Threading; using System.Threading.Tasks; using Max.Bot.Configuration; -using Max.Bot.Types; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; @@ -30,7 +29,8 @@ namespace Max.Bot.Polling; /// /// /// (for example a singleton ), -/// and must be registered in the DI container. +/// and must be registered in the DI container. The request body is read by the controller +/// itself, after the size limit and the secret header have been checked. /// /// [ApiController] @@ -57,24 +57,11 @@ protected WebhookController(IUpdatePipeline pipeline, IUpdateHandler handler, Ma /// Handles incoming webhook updates. /// [HttpPost] - public virtual async Task PostAsync([FromBody] Update update, CancellationToken cancellationToken) + public virtual async Task PostAsync(CancellationToken cancellationToken) { - if (!ModelState.IsValid) - { - return BadRequest(ModelState); - } - - if (!WebhookRequestValidator.ValidatePayloadSize(Request, _options, _logger)) - { - return StatusCode(StatusCodes.Status413PayloadTooLarge); - } - - if (!WebhookRequestValidator.ValidateSignature(Request, _options, _logger)) - { - return Unauthorized(); - } - - await _pipeline.ProcessWebhookAsync(update, _handler, HttpContext.RequestServices, cancellationToken).ConfigureAwait(false); - return Ok(new { success = true }); + var statusCode = await WebhookRequestProcessor.ProcessAsync(HttpContext, _options, _pipeline, _handler, _logger).ConfigureAwait(false); + return statusCode == StatusCodes.Status200OK + ? Ok(new { success = true }) + : StatusCode(statusCode); } } diff --git a/src/Max.Bot/Polling/WebhookRequestProcessor.cs b/src/Max.Bot/Polling/WebhookRequestProcessor.cs new file mode 100644 index 0000000..bcb6aa4 --- /dev/null +++ b/src/Max.Bot/Polling/WebhookRequestProcessor.cs @@ -0,0 +1,102 @@ +using System; +using System.IO; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Max.Bot.Configuration; +using Max.Bot.Networking; +using Max.Bot.Types; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +namespace Max.Bot.Polling; + +/// +/// Shared request pipeline for and MapMaxWebhook: +/// size limit and secret are checked before the body is read and parsed. +/// +internal static class WebhookRequestProcessor +{ + public static async Task ProcessAsync( + HttpContext context, + MaxBotOptions options, + IUpdatePipeline pipeline, + IUpdateHandler handler, + ILogger? logger) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(pipeline); + ArgumentNullException.ThrowIfNull(handler); + + if (!WebhookRequestValidator.ValidatePayloadSize(context.Request, options, logger)) + { + return StatusCodes.Status413PayloadTooLarge; + } + + if (!WebhookRequestValidator.ValidateSignature(context.Request, options, logger)) + { + return StatusCodes.Status401Unauthorized; + } + + var body = await ReadBodyAsync(context, WebhookRequestValidator.GetMaxBodyBytes(options)).ConfigureAwait(false); + if (body == null) + { + logger?.LogWarning("Webhook payload rejected: body exceeds the configured size limit."); + return StatusCodes.Status413PayloadTooLarge; + } + + Update? update; + try + { + update = body.Length == 0 ? null : JsonSerializer.Deserialize(body, MaxJsonSerializer.Options); + } + catch (JsonException ex) + { + logger?.LogWarning(ex, "Webhook payload rejected: invalid JSON."); + update = null; + } + + if (update == null) + { + return StatusCodes.Status400BadRequest; + } + + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(context.RequestAborted); + var handlerTimeout = options.Webhook.HandlerTimeout; + if (handlerTimeout > TimeSpan.Zero && handlerTimeout != Timeout.InfiniteTimeSpan) + { + timeoutCts.CancelAfter(handlerTimeout); + } + + try + { + await pipeline.ProcessWebhookAsync(update, handler, context.RequestServices, timeoutCts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException ex) when (timeoutCts.IsCancellationRequested && !context.RequestAborted.IsCancellationRequested) + { + // * Answer MAX anyway: a non-2xx status would make MAX deliver the same update again. + logger?.LogWarning(ex, "Webhook update handling exceeded Webhook.HandlerTimeout ({Timeout}).", handlerTimeout); + } + + return StatusCodes.Status200OK; + } + + private static async Task ReadBodyAsync(HttpContext context, long maxBytes) + { + using var buffer = new MemoryStream(); + var chunk = new byte[81920]; + int read; + while ((read = await context.Request.Body.ReadAsync(chunk, context.RequestAborted).ConfigureAwait(false)) > 0) + { + if (buffer.Length + read > maxBytes) + { + return null; + } + + buffer.Write(chunk, 0, read); + } + + return buffer.ToArray(); + } +} diff --git a/src/Max.Bot/Types/Converters/AttachmentJsonConverter.cs b/src/Max.Bot/Types/Converters/AttachmentJsonConverter.cs index f651c8f..b151aa8 100644 --- a/src/Max.Bot/Types/Converters/AttachmentJsonConverter.cs +++ b/src/Max.Bot/Types/Converters/AttachmentJsonConverter.cs @@ -129,6 +129,26 @@ public override void Write(Utf8JsonWriter writer, Attachment value, JsonSerializ /// This method merges both levels into one object and deserializes it into the flat attachment model. /// private static T? DeserializeMerged(JsonElement root, string? typeString, string? legacyPayloadPropertyName, JsonSerializerOptions options) + where T : Attachment, new() + { + try + { + return DeserializeMergedCore(root, typeString, legacyPayloadPropertyName, options); + } + catch (Exception ex) when (ex is JsonException or InvalidOperationException or FormatException) + { + // An attachment with unexpected field types must not break the whole message/update batch. + var fallback = new T(); + if (!string.IsNullOrWhiteSpace(typeString)) + { + fallback.Type = typeString; + } + + return fallback; + } + } + + private static T? DeserializeMergedCore(JsonElement root, string? typeString, string? legacyPayloadPropertyName, JsonSerializerOptions options) where T : Attachment { var merged = new JsonObject(); diff --git a/src/Max.Bot/Types/Converters/InlineKeyboardButtonJsonConverter.cs b/src/Max.Bot/Types/Converters/InlineKeyboardButtonJsonConverter.cs index 2d0c99d..9b62675 100644 --- a/src/Max.Bot/Types/Converters/InlineKeyboardButtonJsonConverter.cs +++ b/src/Max.Bot/Types/Converters/InlineKeyboardButtonJsonConverter.cs @@ -74,6 +74,21 @@ public override InlineKeyboardButton Read(ref Utf8JsonReader reader, Type typeTo } } + if (root.TryGetProperty("web_app", out var webAppElement) && webAppElement.ValueKind == JsonValueKind.String) + { + button.WebApp = webAppElement.GetString(); + } + + if (root.TryGetProperty("contact_id", out var contactIdElement) && contactIdElement.ValueKind == JsonValueKind.Number && contactIdElement.TryGetInt64(out var contactId)) + { + button.ContactId = contactId; + } + + if (root.TryGetProperty("quick", out var quickElement) && quickElement.ValueKind is JsonValueKind.True or JsonValueKind.False) + { + button.Quick = quickElement.GetBoolean(); + } + // Read intent (for callback buttons) if (root.TryGetProperty("intent", out var intentElement) && intentElement.ValueKind == JsonValueKind.String) { @@ -99,7 +114,10 @@ public override void Write(Utf8JsonWriter writer, InlineKeyboardButton value, Js writer.WriteStartObject(); // Write type (required) - convert to snake_case to match API format - var typeString = (int)value.Type == 5 ? "link" : ConvertToSnakeCase(value.Type.ToString()); + // * OpenApp without web_app keeps the legacy "link" representation (0.6.2 behaviour). + var typeString = value.Type == ButtonType.OpenApp && string.IsNullOrEmpty(value.WebApp) + ? "link" + : ConvertToSnakeCase(value.Type.ToString()); writer.WriteString("type", typeString); // Write text (required) @@ -117,6 +135,21 @@ public override void Write(Utf8JsonWriter writer, InlineKeyboardButton value, Js writer.WriteString("url", value.Url); } + if (!string.IsNullOrEmpty(value.WebApp)) + { + writer.WriteString("web_app", value.WebApp); + } + + if (value.ContactId.HasValue) + { + writer.WriteNumber("contact_id", value.ContactId.Value); + } + + if (value.Quick.HasValue) + { + writer.WriteBoolean("quick", value.Quick.Value); + } + // Write intent (for callback buttons) if (value.Intent.HasValue) { diff --git a/src/Max.Bot/Types/Converters/UpdateJsonConverter.cs b/src/Max.Bot/Types/Converters/UpdateJsonConverter.cs index bc14b3f..c1d9660 100644 --- a/src/Max.Bot/Types/Converters/UpdateJsonConverter.cs +++ b/src/Max.Bot/Types/Converters/UpdateJsonConverter.cs @@ -116,27 +116,30 @@ public override Update Read(ref Utf8JsonReader reader, Type typeToConvert, JsonS /// private static void InferChatFromMessage(Update update) { - var recipient = update.Message?.Recipient; - if (recipient?.ChatId is not { } recipientChatId) + if (update.ChatId == null && update.Chat != null) { - return; + // message_chat_created carries a full chat object but no chat_id. + update.SetInferredChat(update.Chat.ChatId, null); } - if (update.ChatId == null) + var recipient = update.Message?.Recipient; + if (recipient?.ChatId is not { } recipientChatId) { - update.ChatId = recipientChatId; - update.IsChatIdInferred = true; + return; } + var inferredChatId = update.ChatId == null ? recipientChatId : (long?)null; + Chat? inferredChat = null; if (update.Chat == null && Update.TryParseChatType(recipient.ChatType, out var chatType)) { - update.Chat = new Chat + inferredChat = new Chat { ChatId = recipientChatId, Type = chatType }; - update.IsChatInferred = true; } + + update.SetInferredChat(inferredChatId, inferredChat); } private static long? GetInt64(JsonElement root, string propertyName) diff --git a/src/Max.Bot/Types/Enums/ButtonType.cs b/src/Max.Bot/Types/Enums/ButtonType.cs index 3197357..0d293df 100644 --- a/src/Max.Bot/Types/Enums/ButtonType.cs +++ b/src/Max.Bot/Types/Enums/ButtonType.cs @@ -34,10 +34,15 @@ public enum ButtonType RequestGeoLocation, /// - /// Open app button - opens a mini application. + /// Open app button - opens a mini application (open_app) set by . + /// Without the button is sent as a link for backward compatibility. /// - [Obsolete("MAX API currently accepts URL buttons as link. Use ButtonType.Link instead.")] - OpenApp + OpenApp, + + /// + /// Clipboard button - copies to the user's clipboard. + /// + Clipboard } diff --git a/src/Max.Bot/Types/FileUploadResult.cs b/src/Max.Bot/Types/FileUploadResult.cs index 9d2ecb8..749c635 100644 --- a/src/Max.Bot/Types/FileUploadResult.cs +++ b/src/Max.Bot/Types/FileUploadResult.cs @@ -18,12 +18,35 @@ namespace Max.Bot.Types; /// public class FileUploadResult { + private string? _token; + private bool _tokenDerivedFromPhotos; + /// /// Gets or sets the uploaded media token that can be used in an attachment payload. /// + /// + /// For image uploads it contains the first token from . Such a derived token is not written + /// back when the result is serialized, because token and photos are mutually exclusive in an image payload. + /// + [JsonIgnore] + public string? Token + { + get => _token; + set + { + _token = value; + _tokenDerivedFromPhotos = false; + } + } + + [JsonInclude] [JsonPropertyName("token")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Token { get; set; } + private string? SerializedToken + { + get => _tokenDerivedFromPhotos ? null : _token; + set => Token = value; + } /// /// Gets or sets the uploaded file identifier (returned for file uploads). @@ -56,7 +79,8 @@ internal void NormalizeToken() return; } - Token = Photos.Values.Select(photo => photo?.Token).FirstOrDefault(token => !string.IsNullOrEmpty(token)); + _token = Photos.Values.Select(photo => photo?.Token).FirstOrDefault(token => !string.IsNullOrEmpty(token)); + _tokenDerivedFromPhotos = _token != null; } } diff --git a/src/Max.Bot/Types/GetUpdatesResponse.cs b/src/Max.Bot/Types/GetUpdatesResponse.cs index 3c26d3e..143c394 100644 --- a/src/Max.Bot/Types/GetUpdatesResponse.cs +++ b/src/Max.Bot/Types/GetUpdatesResponse.cs @@ -1,5 +1,7 @@ using System.Collections.Generic; +using System.Text.Json; using System.Text.Json.Serialization; +using Max.Bot.Networking; namespace Max.Bot.Types; @@ -21,6 +23,66 @@ public class GetUpdatesResponse /// The marker for the next page, or null if no more updates. [JsonPropertyName("marker")] public long? Marker { get; set; } + + /// + /// Parses a GET /updates response update by update, so that one update the library cannot deserialize + /// does not block the whole batch (the marker would otherwise never advance and polling would loop forever). + /// + /// The raw response body. + /// Called for every update that could not be deserialized. + /// The parsed response. + /// Thrown when the body is not a JSON object. + internal static GetUpdatesResponse ParseTolerant(string json, Action? onSkipped = null) + { + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object) + { + throw new JsonException("GET /updates response must be a JSON object."); + } + + // Legacy {"ok": true, "result": {"updates": [...], "marker": ...}} envelope. + if (!root.TryGetProperty("updates", out _) && + root.TryGetProperty("result", out var wrapped) && wrapped.ValueKind == JsonValueKind.Object) + { + root = wrapped; + } + + var response = new GetUpdatesResponse { Updates = Array.Empty() }; + + if (root.TryGetProperty("marker", out var markerElement) && markerElement.ValueKind == JsonValueKind.Number && markerElement.TryGetInt64(out var marker)) + { + response.Marker = marker; + } + + if (root.TryGetProperty("updates", out var updatesElement) && updatesElement.ValueKind == JsonValueKind.Array) + { + var updates = new List(updatesElement.GetArrayLength()); + foreach (var element in updatesElement.EnumerateArray()) + { + try + { + var update = element.Deserialize(MaxJsonSerializer.Options); + if (update != null) + { + updates.Add(update); + } + } + catch (JsonException ex) + { + onSkipped?.Invoke(ex, element.GetRawText()); + } + catch (InvalidOperationException ex) + { + onSkipped?.Invoke(new JsonException(ex.Message, ex), element.GetRawText()); + } + } + + response.Updates = updates.ToArray(); + } + + return response; + } } diff --git a/src/Max.Bot/Types/InlineKeyboardButton.cs b/src/Max.Bot/Types/InlineKeyboardButton.cs index 332c6c2..e3ffba9 100644 --- a/src/Max.Bot/Types/InlineKeyboardButton.cs +++ b/src/Max.Bot/Types/InlineKeyboardButton.cs @@ -66,6 +66,25 @@ public string? Url [JsonPropertyName("intent")] public ButtonIntent? Intent { get; set; } + /// + /// Gets or sets the mini application to open (bot username or link) for buttons. + /// + [JsonPropertyName("web_app")] + public string? WebApp { get; set; } + + /// + /// Gets or sets the identifier of the bot whose mini application is opened by an button. + /// + [JsonPropertyName("contact_id")] + public long? ContactId { get; set; } + + /// + /// Gets or sets a value indicating whether a button sends the location + /// without asking the user for confirmation. + /// + [JsonPropertyName("quick")] + public bool? Quick { get; set; } + /// /// Gets or sets the callback data sent when the button is pressed. /// This property is for backward compatibility and automatically sets Type to Callback and Payload. diff --git a/src/Max.Bot/Types/Requests/AddChatAdminRequest.cs b/src/Max.Bot/Types/Requests/AddChatAdminRequest.cs index f861d45..34ea014 100644 --- a/src/Max.Bot/Types/Requests/AddChatAdminRequest.cs +++ b/src/Max.Bot/Types/Requests/AddChatAdminRequest.cs @@ -21,9 +21,11 @@ public class AddChatAdminRequest /// Gets or sets the identifier of a single user to make an administrator. /// /// - /// Convenience shortcut for a single administrator: setting it replaces with one entry - /// that keeps the permissions of the previous first entry. Prefer to set permissions explicitly. + /// Kept for backward compatibility: setting it replaces with one entry that keeps the permissions + /// of the previous first entry. MAX API requires the list of permissions, so use with + /// instead. /// + [Obsolete("MAX API requires explicit permissions. Use Admins with ChatAdmin.Permissions instead.")] [JsonIgnore] [Range(1, long.MaxValue, ErrorMessage = "User ID must be greater than zero.")] public long UserId diff --git a/src/Max.Bot/Types/Update.cs b/src/Max.Bot/Types/Update.cs index b838b20..98e52e3 100644 --- a/src/Max.Bot/Types/Update.cs +++ b/src/Max.Bot/Types/Update.cs @@ -76,7 +76,17 @@ public class Update /// fills a partial with and taken from it. /// [JsonIgnore] - public Chat? Chat { get; set; } + public Chat? Chat + { + get => _chat; + set + { + _chat = value; + IsChatInferred = false; + } + } + + private Chat? _chat; /// /// Gets or sets the user who triggered the update. @@ -100,14 +110,24 @@ public class Update /// chat_title_changed, message_removed, dialog_* events) or, for message updates, from message.recipient.chat_id. /// [JsonIgnore] - public long? ChatId { get; set; } + public long? ChatId + { + get => _chatId; + set + { + _chatId = value; + IsChatIdInferred = false; + } + } + + private long? _chatId; /// /// Gets the type of the chat where the update happened (dialog, chat or channel), if known. /// /// /// Resolved from or message.recipient.chat_type. For events that only carry - /// is_channel (bot_added, bot_removed, user_added, user_removed) a channel is reported as . + /// is_channel (bot_added, bot_removed, user_added, user_removed) it is or . /// [JsonIgnore] public ChatType? ChatType @@ -124,7 +144,13 @@ public ChatType? ChatType return chatType; } - return IsChannel == true ? Enums.ChatType.Channel : null; + // * bot_added/bot_removed/user_added/user_removed happen only in group chats and channels. + return IsChannel switch + { + true => Enums.ChatType.Channel, + false => Enums.ChatType.Chat, + null => null + }; } } @@ -175,13 +201,32 @@ public ChatType? ChatType /// rather than received from MAX API. /// [JsonIgnore] - internal bool IsChatInferred { get; set; } + internal bool IsChatInferred { get; private set; } /// /// Gets or sets a value indicating whether was taken from message.recipient. /// [JsonIgnore] - internal bool IsChatIdInferred { get; set; } + internal bool IsChatIdInferred { get; private set; } + + /// + /// Fills and from values inferred from message.recipient + /// without marking them as received from MAX API. + /// + internal void SetInferredChat(long? chatId, Chat? chat) + { + if (chatId.HasValue) + { + _chatId = chatId; + IsChatIdInferred = true; + } + + if (chat != null) + { + _chat = chat; + IsChatInferred = true; + } + } /// /// Gets or sets whether the chat is muted. @@ -559,6 +604,7 @@ public MessageChatCreatedUpdate? MessageChatCreatedUpdate UserLocale = UserLocale, Message = Message, Chat = Chat, + ChatId = ChatId ?? Chat?.ChatId, MessageId = MessageId, StartPayload = Payload }; diff --git a/src/Max.Bot/Types/UpdateTypes.cs b/src/Max.Bot/Types/UpdateTypes.cs index aa962f2..f267968 100644 --- a/src/Max.Bot/Types/UpdateTypes.cs +++ b/src/Max.Bot/Types/UpdateTypes.cs @@ -540,6 +540,11 @@ public class MessageChatCreatedUpdate /// public Chat? Chat { get; set; } + /// + /// Gets or sets the identifier of the created chat. + /// + public long? ChatId { get; set; } + /// /// Gets or sets the identifier of the message where the button was clicked. /// diff --git a/tests/Max.Bot.Tests/Unit/Polling/WebhookControllerTests.cs b/tests/Max.Bot.Tests/Unit/Polling/WebhookControllerTests.cs index db0b104..55a2534 100644 --- a/tests/Max.Bot.Tests/Unit/Polling/WebhookControllerTests.cs +++ b/tests/Max.Bot.Tests/Unit/Polling/WebhookControllerTests.cs @@ -1,8 +1,9 @@ // 📁 WebhookControllerTests.cs - Tests for webhook controller behavior -// 🎯 Core function: Ensures webhook auth and pipeline dispatch work correctly. +// 🎯 Core function: Ensures webhook auth, size limits and pipeline dispatch work correctly. // 🔗 Key dependencies: WebhookController, IUpdatePipeline, ASP.NET Core MVC. // 💡 Usage: Verifies webhook ingestion for message_created updates. +using System.Text; using System.Threading; using System.Threading.Tasks; using FluentAssertions; @@ -19,18 +20,21 @@ namespace Max.Bot.Tests.Unit.Polling; public class WebhookControllerTests { + private const string UpdateJson = """{"update_type":"message_created","timestamp":1,"message":{"sender":{"user_id":1,"username":"tester"},"recipient":{"chat_id":1,"chat_type":"dialog"},"body":{"mid":"mid.1","text":"hi"}}}"""; + [Fact] public async Task PostAsync_ShouldReturnUnauthorized_WhenSignatureMissing() { // Arrange - var (controller, _, _) = CreateController(secret: "secret"); - controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }; + var (controller, pipelineMock, _) = CreateController(secret: "secret"); + controller.ControllerContext = new ControllerContext { HttpContext = CreateContext(UpdateJson) }; // Act - var result = await controller.PostAsync(CreateUpdate(), CancellationToken.None); + var result = await controller.PostAsync(CancellationToken.None); // Assert - result.Should().BeOfType(); + result.As().StatusCode.Should().Be(StatusCodes.Status401Unauthorized); + pipelineMock.VerifyNoOtherCalls(); } [Fact] @@ -38,34 +42,68 @@ public async Task PostAsync_ShouldReturnPayloadTooLarge_WhenBodyExceedsLimit() { // Arrange var (controller, _, _) = CreateController(secret: null); - var context = new DefaultHttpContext(); - context.Request.ContentLength = (controller.Options.Webhook.MaxBodySizeKilobytes + 1) * 1024L; + var context = CreateContext(UpdateJson); + context.Request.ContentLength = (controller.WebhookOptions.MaxBodySizeKilobytes + 1) * 1024L; controller.ControllerContext = new ControllerContext { HttpContext = context }; // Act - var result = await controller.PostAsync(CreateUpdate(), CancellationToken.None); + var result = await controller.PostAsync(CancellationToken.None); // Assert result.As().StatusCode.Should().Be(StatusCodes.Status413PayloadTooLarge); } + [Fact] + public async Task PostAsync_ShouldReturnPayloadTooLarge_WhenChunkedBodyExceedsLimit() + { + // Arrange — no Content-Length (chunked transfer), the body itself is over the limit + var (controller, pipelineMock, _) = CreateController(secret: null); + var context = CreateContext("{\"update_type\":\"" + new string('x', 4096) + "\"}", setContentLength: false); + controller.ControllerContext = new ControllerContext { HttpContext = context }; + + // Act + var result = await controller.PostAsync(CancellationToken.None); + + // Assert + result.As().StatusCode.Should().Be(StatusCodes.Status413PayloadTooLarge); + pipelineMock.VerifyNoOtherCalls(); + } + + [Fact] + public async Task PostAsync_ShouldReturnBadRequest_WhenBodyIsInvalidJson() + { + // Arrange + var (controller, pipelineMock, _) = CreateController(secret: null); + controller.ControllerContext = new ControllerContext { HttpContext = CreateContext("not json") }; + + // Act + var result = await controller.PostAsync(CancellationToken.None); + + // Assert + result.As().StatusCode.Should().Be(StatusCodes.Status400BadRequest); + pipelineMock.VerifyNoOtherCalls(); + } + [Fact] public async Task PostAsync_ShouldProcess_WhenSignatureValid() { // Arrange var secret = "secret-token"; var (controller, pipelineMock, handlerMock) = CreateController(secret); - var context = new DefaultHttpContext(); - context.Request.Headers[controller.Options.Webhook.SignatureHeaderName] = secret; - context.Request.ContentLength = 100; + var context = CreateContext(UpdateJson); + context.Request.Headers[controller.WebhookOptions.SignatureHeaderName] = secret; controller.ControllerContext = new ControllerContext { HttpContext = context }; // Act - var result = await controller.PostAsync(CreateUpdate(), CancellationToken.None); + var result = await controller.PostAsync(CancellationToken.None); // Assert result.Should().BeOfType(); - pipelineMock.Verify(p => p.ProcessWebhookAsync(It.IsAny(), handlerMock.Object, context.RequestServices, It.IsAny()), Times.Once); + pipelineMock.Verify(p => p.ProcessWebhookAsync( + It.Is(u => u.Message!.Mid == "mid.1"), + handlerMock.Object, + context.RequestServices, + It.IsAny()), Times.Once); } private static (TestableWebhookController Controller, Mock Pipeline, Mock Handler) CreateController(string? secret) @@ -89,14 +127,19 @@ private static (TestableWebhookController Controller, Mock Pipe return (controller, pipeline, handler); } - private static Update CreateUpdate() + private static DefaultHttpContext CreateContext(string body, bool setContentLength = true) { - return new Update + var bytes = Encoding.UTF8.GetBytes(body); + var context = new DefaultHttpContext(); + context.Request.Method = HttpMethods.Post; + context.Request.ContentType = "application/json"; + context.Request.Body = new MemoryStream(bytes); + if (setContentLength) { - UpdateId = 1, - UpdateTypeRaw = "message_created", - Message = new Message { Sender = new User { Username = "tester" } } - }; + context.Request.ContentLength = bytes.Length; + } + + return context; } private sealed class TestableWebhookController : WebhookController @@ -104,13 +147,9 @@ private sealed class TestableWebhookController : WebhookController public TestableWebhookController(IUpdatePipeline pipeline, IUpdateHandler handler, MaxBotOptions options, ILogger? logger) : base(pipeline, handler, options, logger) { - Options = options; + WebhookOptions = options.Webhook; } - public MaxBotOptions Options { get; } + public MaxWebhookOptions WebhookOptions { get; } } } - - - - diff --git a/tests/Max.Bot.Tests/Unit/Regression/ReviewRegressionTests.cs b/tests/Max.Bot.Tests/Unit/Regression/ReviewRegressionTests.cs new file mode 100644 index 0000000..8427332 --- /dev/null +++ b/tests/Max.Bot.Tests/Unit/Regression/ReviewRegressionTests.cs @@ -0,0 +1,448 @@ +// 📁 ReviewRegressionTests.cs - Regression tests for problems found in the v0.7.0-alpha review +// 🎯 Core function: Pins culture-invariant ids, webhook hardening, tolerant parsing and upload retries. +// 🔗 Key dependencies: ChatsApi, MessagesApi, FilesApi, MaxHttpClient, MaxClient, MapMaxWebhook, GetUpdatesResponse. +// 💡 Usage: Each test documents the failure scenario it guards against. + +using System.Globalization; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using FluentAssertions; +using Max.Bot.Api; +using Max.Bot.Configuration; +using Max.Bot.Exceptions; +using Max.Bot.Networking; +using Max.Bot.Polling; +using Max.Bot.Types; +using Max.Bot.Types.Enums; +using Max.Bot.Types.Requests; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using Xunit; + +namespace Max.Bot.Tests.Unit.Regression; + +public class ReviewRegressionTests +{ + private const string UpdateJson = """{"update_type":"message_created","timestamp":1,"message":{"recipient":{"chat_id":1,"chat_type":"dialog"},"body":{"mid":"mid.1","text":"hi"}}}"""; + + private readonly Mock _http = new(); + private readonly MaxBotOptions _options = new() { Token = "token" }; + + #region Culture-invariant identifiers + + [Fact] + public async Task NegativeChatIds_ShouldUseAsciiMinus_RegardlessOfCurrentCulture() + { + var requests = new List(); + _http.Setup(x => x.SendAsyncRaw(It.IsAny(), It.IsAny())) + .Callback((req, _) => requests.Add(req)) + .ReturnsAsync("""{"chat_id":-1001,"type":"chat"}"""); + _http.Setup(x => x.SendAsync(It.IsAny(), It.IsAny())) + .Callback((req, _) => requests.Add(req)) + .ReturnsAsync(new MessageResponse { Message = new Message() }); + + var culture = (CultureInfo)CultureInfo.InvariantCulture.Clone(); + culture.NumberFormat.NegativeSign = "−"; // as in sv-SE, nb-NO, fi-FI + var previous = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = culture; + + await new ChatsApi(_http.Object, _options).GetChatAsync(-1001); + await new MessagesApi(_http.Object, _options).SendMessageAsync(new SendMessageRequest { Text = "hi" }, chatId: -1001); + } + finally + { + CultureInfo.CurrentCulture = previous; + } + + requests[0].Endpoint.Should().Be("/chats/-1001"); + requests[1].QueryParameters!["chat_id"].Should().Be("-1001"); + } + + #endregion + + #region Webhook configuration and endpoint + + [Fact] + public async Task ConfigureWebhookAsync_ShouldReplaceConfiguredSecret_WithExplicitSecret() + { + var options = new MaxBotOptions + { + Token = "token", + Webhook = { Endpoint = "https://bot.example.com/hook", SecretToken = "old_secret" } + }; + using var client = new MaxClient(options, new HttpClient(new StubHandler(_ => Json("""{"success":true}""")))); + + await client.ConfigureWebhookAsync(secret: "new_secret"); + + options.Webhook.SecretToken.Should().Be("new_secret"); + } + + [Fact] + public async Task ConfigureWebhookAsync_ShouldNotChangeOptions_WhenMaxRejectsSubscription() + { + var options = new MaxBotOptions { Token = "token", Webhook = { SecretToken = "old_secret" } }; + using var client = new MaxClient(options, new HttpClient(new StubHandler(_ => Json("""{"success":false,"message":"bad url"}""")))); + + var response = await client.ConfigureWebhookAsync("https://bot.example.com/hook", secret: "new_secret"); + + response.Success.Should().BeFalse(); + options.Webhook.Endpoint.Should().BeNull(); + options.Webhook.SecretToken.Should().Be("old_secret"); + } + + [Theory] + [InlineData("abc")] + [InlineData("has space")] + [InlineData("пароль123")] + public async Task ConfigureWebhookAsync_ShouldRejectSecretsOutsideMaxFormat(string secret) + { + using var client = new MaxClient(new MaxBotOptions { Token = "token" }, new HttpClient(new StubHandler(_ => Json("""{"success":true}""")))); + + var act = () => client.ConfigureWebhookAsync("https://bot.example.com/hook", secret: secret); + + await act.Should().ThrowAsync(); + } + + [Fact] + public void WebhookOptions_Validate_ShouldRejectInvalidSecret() + { + var options = new MaxWebhookOptions { SecretToken = "a b" }; + + var act = () => options.Validate(); + + act.Should().Throw().WithParameterName("SecretToken"); + } + + [Fact] + public async Task MapMaxWebhook_ShouldNotFallBackToUnconfiguredOptions() + { + // IOptions always resolves in ASP.NET Core; an empty instance would disable the secret check. + var services = new ServiceCollection() + .AddOptions() + .AddSingleton(Mock.Of()) + .AddSingleton(Mock.Of()) + .BuildServiceProvider(); + var endpoint = BuildEndpoint(services); + var context = CreateContext(services, UpdateJson); + + var act = () => endpoint(context); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task MapMaxWebhook_ShouldUseMaxClientOptions_WhenOptionsAreNotRegistered() + { + var options = new MaxBotOptions { Token = "token", Webhook = { SecretToken = "s3cret_token" } }; + using var client = new MaxClient(options); + var services = new ServiceCollection() + .AddOptions() + .AddSingleton(client) + .AddSingleton(Mock.Of()) + .BuildServiceProvider(); + var endpoint = BuildEndpoint(services); + var context = CreateContext(services, UpdateJson); + + await endpoint(context); + + context.Response.StatusCode.Should().Be(StatusCodes.Status401Unauthorized); + } + + [Fact] + public async Task MapMaxWebhook_ShouldApplyWebhookHandlerTimeout() + { + var options = new MaxBotOptions { Token = "token", Webhook = { HandlerTimeout = TimeSpan.FromMilliseconds(50) } }; + var pipeline = new Mock(); + pipeline.Setup(p => p.ProcessWebhookAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((_, _, _, ct) => Task.Delay(Timeout.Infinite, ct)); + var services = new ServiceCollection() + .AddSingleton(options) + .AddSingleton(pipeline.Object) + .AddSingleton(Mock.Of()) + .BuildServiceProvider(); + var endpoint = BuildEndpoint(services); + var context = CreateContext(services, UpdateJson); + + await endpoint(context).WaitAsync(TimeSpan.FromSeconds(10)); + + context.Response.StatusCode.Should().Be(StatusCodes.Status200OK); + } + + #endregion + + #region Tolerant parsing + + [Fact] + public void FileUploadResult_ShouldNotSerializeTokenDerivedFromPhotos() + { + var result = FilesApi.ParseUploadResponse("""{"photos":{"p1":{"token":"photo-token"}}}"""); + + result.Token.Should().Be("photo-token"); + MaxJsonSerializer.Serialize(result).Should().Be("""{"photos":{"p1":{"token":"photo-token"}}}"""); + + result.Token = "explicit"; + MaxJsonSerializer.Serialize(result).Should().Contain("\"token\":\"explicit\""); + } + + [Fact] + public void Attachments_WithUnexpectedFieldTypes_ShouldNotBreakDeserialization() + { + var unknown = MaxJsonSerializer.Deserialize("""{"type":"future_media","payload":{"id":"not-a-number","url":["array"]}}"""); + var image = MaxJsonSerializer.Deserialize("""{"type":"image","payload":{"photo_id":1,"token":"t"},"width":"wide"}"""); + + unknown.Type.Should().Be("future_media"); + image.Should().BeOfType().Which.Type.Should().Be("image"); + } + + [Fact] + public void GetUpdatesResponse_ShouldSkipUpdatesThatCannotBeDeserialized() + { + const string body = """{"updates":[{"update_type":"message_created","timestamp":"not-a-number","message":{"timestamp":"bad"}},""" + UpdateJson + """],"marker":42}"""; + var skipped = new List(); + + var response = GetUpdatesResponse.ParseTolerant(body, (_, raw) => skipped.Add(raw)); + + response.Marker.Should().Be(42); + response.Updates.Should().ContainSingle().Which.Message!.Mid.Should().Be("mid.1"); + skipped.Should().ContainSingle(); + } + + [Fact] + public void Update_ShouldSerializeChat_AssignedAfterInference() + { + var update = MaxJsonSerializer.Deserialize(UpdateJson); + + update.Chat = new Chat { ChatId = 5, Type = ChatType.Chat, Title = "Assigned" }; + update.ChatId = 5; + + using var json = JsonDocument.Parse(MaxJsonSerializer.Serialize(update)); + json.RootElement.GetProperty("chat").GetProperty("title").GetString().Should().Be("Assigned"); + json.RootElement.GetProperty("chat_id").GetInt64().Should().Be(5); + } + + [Fact] + public void MessageChatCreated_ShouldExposeChatIdFromChatObject() + { + var update = MaxJsonSerializer.Deserialize("""{"update_type":"message_chat_created","timestamp":1,"chat":{"chat_id":-55,"type":"chat","title":"New"},"title":"New","message_id":"mid.btn","start_payload":"ref"}"""); + + update.ChatId.Should().Be(-55); + update.MessageChatCreatedUpdate!.ChatId.Should().Be(-55); + update.MessageChatCreatedUpdate.StartPayload.Should().Be("ref"); + update.MessageChatCreatedUpdate.MessageId.Should().Be("mid.btn"); + } + + [Fact] + public void AllowedUsernames_ShouldUseCallbackPresser_NotBotMessageSender() + { + var update = MaxJsonSerializer.Deserialize("""{"update_type":"message_callback","timestamp":1,"callback":{"callback_id":"cb","payload":"x","user":{"user_id":7,"username":"alice"}},"message":{"sender":{"user_id":1,"username":"my_bot","is_bot":true},"recipient":{"chat_id":7,"chat_type":"dialog"},"body":{"mid":"mid.kb"}}}"""); + var options = new MaxBotOptions { Token = "token" }; + options.Handling.AllowedUsernames.Add("alice"); + + var shouldDispatch = UpdateFilterUtilities.ShouldDispatch(update, null, UpdateFilterUtilities.BuildAllowedUsernames(options)); + + UpdateFilterUtilities.ExtractUsername(update).Should().Be("alice"); + shouldDispatch.Should().BeTrue(); + } + + [Fact] + public void GroupMembershipEvents_ShouldReportChatType() + { + var userAdded = MaxJsonSerializer.Deserialize("""{"update_type":"user_added","timestamp":1,"chat_id":-10,"user":{"user_id":2},"inviter_id":3,"is_channel":false}"""); + + userAdded.ChatType.Should().Be(ChatType.Chat); + } + + [Fact] + public void OpenAppAndClipboardButtons_ShouldUseMaxFormat() + { + var openApp = new InlineKeyboardButton { Type = ButtonType.OpenApp, Text = "App", WebApp = "my_bot", ContactId = 42, Payload = "start" }; + var clipboard = new InlineKeyboardButton { Type = ButtonType.Clipboard, Text = "Copy", Payload = "PROMO2026" }; + var geo = new InlineKeyboardButton { Type = ButtonType.RequestGeoLocation, Text = "Where", Quick = true }; + + using var openAppJson = JsonDocument.Parse(MaxJsonSerializer.Serialize(openApp)); + using var clipboardJson = JsonDocument.Parse(MaxJsonSerializer.Serialize(clipboard)); + using var geoJson = JsonDocument.Parse(MaxJsonSerializer.Serialize(geo)); + + openAppJson.RootElement.GetProperty("type").GetString().Should().Be("open_app"); + openAppJson.RootElement.GetProperty("web_app").GetString().Should().Be("my_bot"); + openAppJson.RootElement.GetProperty("contact_id").GetInt64().Should().Be(42); + clipboardJson.RootElement.GetProperty("type").GetString().Should().Be("clipboard"); + clipboardJson.RootElement.GetProperty("payload").GetString().Should().Be("PROMO2026"); + geoJson.RootElement.GetProperty("type").GetString().Should().Be("request_geo_location"); + geoJson.RootElement.GetProperty("quick").GetBoolean().Should().BeTrue(); + + var roundTrip = MaxJsonSerializer.Deserialize(openAppJson.RootElement.GetRawText()); + roundTrip.Type.Should().Be(ButtonType.OpenApp); + roundTrip.WebApp.Should().Be("my_bot"); + MaxJsonSerializer.Deserialize(clipboardJson.RootElement.GetRawText()).Type.Should().Be(ButtonType.Clipboard); + } + + [Fact] + public async Task GetChatMembersPageAsync_ShouldReturnMarker() + { + MaxApiRequest? captured = null; + _http.Setup(x => x.SendAsyncRaw(It.IsAny(), It.IsAny())) + .Callback((req, _) => captured = req) + .ReturnsAsync("""{"members":[{"user_id":1,"name":"A"}],"marker":99}"""); + + var page = await new ChatsApi(_http.Object, _options).GetChatMembersPageAsync(-10, marker: 5, count: 20); + + page.Members.Should().ContainSingle(); + page.Marker.Should().Be(99); + captured!.Endpoint.Should().Be("/chats/-10/members"); + captured.QueryParameters!["marker"].Should().Be("5"); + captured.QueryParameters["count"].Should().Be("20"); + } + + #endregion + + #region Retries + + [Fact] + public async Task SendMessageAsync_WithAttachments_ShouldRetryAttachmentNotReady() + { + _http.SetupSequence(x => x.SendAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new MaxAttachmentNotReadyException()) + .ReturnsAsync(new MessageResponse { Message = new Message { Body = new MessageBody { Mid = "mid.ok" } } }); + + var api = new MessagesApi(_http.Object, _options); + var message = await api.SendMessageAsync(new SendMessageRequest { Attachments = new[] { AttachmentRequest.File("t") } }, chatId: 1); + + message.Mid.Should().Be("mid.ok"); + _http.Verify(x => x.SendAsync(It.IsAny(), It.IsAny()), Times.Exactly(2)); + } + + [Fact] + public async Task UploadFileDataAsync_ShouldResendWholeNonSeekableStreamOnRetry() + { + var uploadedLengths = new List(); + _http.Setup(x => x.SendAsyncRaw(It.IsAny(), It.IsAny?>(), It.IsAny(), It.IsAny())) + .Returns?, CancellationToken, HttpMethod?>(async (_, factory, _, _) => + { + for (var i = 0; i < 2; i++) + { + using var content = factory!(); + uploadedLengths.Add((await content!.ReadAsByteArrayAsync()).Length); + } + + return """{"token":"t"}"""; + }); + + var api = new FilesApi(_http.Object, _options); + using var stream = new NonSeekableStream(Enumerable.Repeat((byte)1, 5000).ToArray()); + var result = await api.UploadFileDataAsync("https://upload", stream, "file.bin"); + + result.Token.Should().Be("t"); + uploadedLengths.Should().HaveCount(2); + uploadedLengths[0].Should().BeGreaterThan(5000); + uploadedLengths[1].Should().Be(uploadedLengths[0]); + } + + [Fact] + public async Task UploadFileResumableAsync_ShouldNotIssueTrailingGet_WhenCdnAcknowledgedChunks() + { + _http.Setup(x => x.SendAsyncRaw(It.IsAny(), It.IsAny?>(), It.IsAny(), It.IsAny())) + .ReturnsAsync("1"); + + var api = new FilesApi(_http.Object, _options); + using var stream = new MemoryStream(new byte[10]); + var result = await api.UploadFileResumableAsync("https://vu.oneme.ru/upload", stream, chunkSize: 4, fileName: "clip.mp4"); + + result.Token.Should().BeNull(); + _http.Verify(x => x.SendAsyncRaw(It.IsAny(), null, It.IsAny(), HttpMethod.Get), Times.Never); + } + + [Fact] + public async Task RetryAfter_InThePast_ShouldNotBreakRetries() + { + var calls = 0; + var handler = new StubHandler(_ => + { + if (calls++ == 0) + { + var limited = new HttpResponseMessage(HttpStatusCode.TooManyRequests) { Content = new StringContent("""{"code":"too.many.requests","message":"slow down"}""") }; + limited.Headers.TryAddWithoutValidation("Retry-After", DateTimeOffset.UtcNow.AddMinutes(-5).ToString("R")); + return limited; + } + + return Json("""{"ok":true}"""); + }); + using var client = new MaxHttpClient(new HttpClient(handler), new MaxBotClientOptions { BaseUrl = "https://platform-api.max.ru", RetryCount = 1 }); + + var body = await client.SendAsyncRaw(new MaxApiRequest { Method = HttpMethod.Get, Endpoint = "/me" }); + + body.Should().Be("""{"ok":true}"""); + calls.Should().Be(2); + } + + #endregion + + private static RequestDelegate BuildEndpoint(IServiceProvider services) + { + var builder = new TestEndpointRouteBuilder(services); + builder.MapMaxWebhook(); + return builder.DataSources.SelectMany(source => source.Endpoints).OfType().Single().RequestDelegate!; + } + + private static DefaultHttpContext CreateContext(IServiceProvider services, string body) + { + var bytes = Encoding.UTF8.GetBytes(body); + var context = new DefaultHttpContext { RequestServices = services }; + context.Request.Method = HttpMethods.Post; + context.Request.Body = new MemoryStream(bytes); + context.Request.ContentLength = bytes.Length; + context.Response.Body = new MemoryStream(); + return context; + } + + private static HttpResponseMessage Json(string body) => new(HttpStatusCode.OK) + { + Content = new StringContent(body, Encoding.UTF8, "application/json") + }; + + private sealed class StubHandler : HttpMessageHandler + { + private readonly Func _responder; + + public StubHandler(Func responder) => _responder = responder; + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + => Task.FromResult(_responder(request)); + } + + private sealed class TestEndpointRouteBuilder : IEndpointRouteBuilder + { + public TestEndpointRouteBuilder(IServiceProvider serviceProvider) => ServiceProvider = serviceProvider; + + public IServiceProvider ServiceProvider { get; } + + public ICollection DataSources { get; } = new List(); + + public IApplicationBuilder CreateApplicationBuilder() => new ApplicationBuilder(ServiceProvider); + } + + private sealed class NonSeekableStream : MemoryStream + { + public NonSeekableStream(byte[] data) + : base(data) + { + } + + public override bool CanSeek => false; + + public override long Position + { + get => base.Position; + set => throw new NotSupportedException(); + } + + public override long Seek(long offset, SeekOrigin loc) => throw new NotSupportedException(); + } +}