diff --git a/CHANGELOG.md b/CHANGELOG.md
index a8b4378..c89754e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,62 @@ 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`; с `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
+
+- **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` следующей страницы; `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` для назначения администраторов.
+
+### 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`); повтор добавлен для `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` поток перематывается в начало, а не поддерживающие 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`.
+- **Samples**: исправлена испорченная кодировка текстов в примерах; `FileBotSample` использует `UploadAsync`.
+
## [0.6.3-alpha] - 2026-06-08
### Fixed
@@ -327,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/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..261dbb5 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,8 @@
# Max.Bot — C# библиотека для Max Messenger Bot API
[](https://dotnet.microsoft.com/)
-[](https://www.nuget.org/packages/MaxMessenger.Bot)
-[](https://github.com/MaxBotNet/MaxBotNet/actions)
+[](https://www.nuget.org/packages/MaxMessenger.Bot)
+[](https://github.com/NanoAgents/MaxBotNet/actions)
[](LICENSE)
Полнофункциональная библиотека для работы с [Max Messenger Bot API](https://dev.max.ru/docs-api) на .NET 10, .NET 9 и .NET 8. Проект фокусируется на типобезопасности, удобстве интеграции и масштабируемости корпоративных ботов.
@@ -18,30 +18,95 @@
## ⚡ Быстрый старт
```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`: `SendMessageAsync(SendMessageRequest)`,
+`SendMessageWithAttachment(s)Async` и `EditMessageAsync` с вложениями повторяют запрос автоматически, а при исчерпании попыток выбрасывается `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(...)]`.
+Секрет должен состоять из 5-256 символов `A-Z`, `a-z`, `0-9`, `_` и `-`; запросы без верного заголовка отклоняются ещё до чтения тела.
+
## 🧠 Архитектурные ориентиры
- [Telegram.Bot](https://github.com/TelegramBots/Telegram.Bot) — структура клиента, подход к документации и примерам.
@@ -52,7 +117,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 +133,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 +163,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 +194,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/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 51f3c11..9c4a485 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;
@@ -26,15 +28,41 @@ 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);
}
///
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;
}
///
@@ -54,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);
}
@@ -70,29 +98,49 @@ 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 response = await HttpClient.SendAsync>(request, cancellationToken).ConfigureAwait(false);
+ var request = CreateRequest(HttpMethod.Get, string.Create(CultureInfo.InvariantCulture, $"/chats/{chatId}/pin"));
- 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);
+ }
}
///
@@ -100,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 ?? [];
}
@@ -138,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);
}
@@ -147,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;
}
///
@@ -174,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);
}
@@ -185,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 86d5342..5b0488c 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,18 +47,65 @@ public async Task UploadFileDataAsync(string uploadUrl, Stream
ValidateNotEmpty(uploadUrl, nameof(uploadUrl));
ArgumentNullException.ThrowIfNull(fileStream);
+ // 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()
{
+ 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)
+ {
+ 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)
@@ -68,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)
@@ -97,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;
}
@@ -108,23 +158,45 @@ 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);
}
- 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..87578a5 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.
///
@@ -197,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 1e8283a..6107b54 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.
///
@@ -53,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/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..c263115 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)
@@ -124,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)
{
@@ -148,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)
@@ -177,16 +192,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 +270,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 +350,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 +521,35 @@ 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);
+ // * SendMessageAsync(SendMessageRequest) already retries attachment.not.ready.
+ return SendMessageAsync(request, chatId, userId, disableLinkPreview, 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 +557,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/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 8df522f..19f6625 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).
@@ -42,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.
///
@@ -75,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
new file mode 100644
index 0000000..061c528
--- /dev/null
+++ b/src/Max.Bot/Exceptions/MaxAttachmentNotReadyException.cs
@@ -0,0 +1,68 @@
+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. 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
+{
+ ///
+ /// 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 @@
enableMaxMessenger.Bot
- 0.6.3-alpha
+ 0.7.0-alphaMaxBotNet ContributorsC# 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..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; }
@@ -201,54 +206,84 @@ 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, 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 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, 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)
{
- 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;
+ if (!string.IsNullOrWhiteSpace(webhookSecret) && !MaxWebhookOptions.IsValidSecret(webhookSecret))
+ {
+ throw new ArgumentException("Webhook secret must be 5-256 characters long and contain only A-Z, a-z, 0-9, '_' and '-'.", nameof(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);
+ if (!response.Success)
+ {
+ return response;
+ }
+
+ // Keep incoming webhook validation in sync with the subscription that MAX now uses.
+ _options.Webhook.Endpoint = webhookUrl;
+ if (!string.IsNullOrWhiteSpace(secret))
+ {
+ _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..795e15b 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,17 +263,76 @@ 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))
{
var value = values.FirstOrDefault();
- if (int.TryParse(value, out var seconds)) return TimeSpan.FromSeconds(seconds);
- if (DateTimeOffset.TryParse(value, out var date)) return date - DateTimeOffset.UtcNow;
+ if (int.TryParse(value, out var seconds)) return TimeSpan.FromSeconds(Math.Max(0, seconds));
+ if (DateTimeOffset.TryParse(value, out var date))
+ {
+ // A date in the past (clock skew) must not produce a negative Task.Delay.
+ var delta = date - DateTimeOffset.UtcNow;
+ return delta > TimeSpan.Zero ? delta : TimeSpan.Zero;
+ }
}
return null;
}
@@ -320,6 +358,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..5975aa3
--- /dev/null
+++ b/src/Max.Bot/Polling/MaxWebhookEndpointRouteBuilderExtensions.cs
@@ -0,0 +1,87 @@
+using System;
+using System.Threading.Tasks;
+using Max.Bot.Configuration;
+using Max.Bot.Polling;
+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 (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
+ /// before the body is read; bodies larger than are rejected with 413.
+ ///
+ /// 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 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");
+
+ var statusCode = await WebhookRequestProcessor.ProcessAsync(context, options, pipeline, handler, logger).ConfigureAwait(false);
+ context.Response.StatusCode = statusCode;
+ if (statusCode == StatusCodes.Status200OK)
+ {
+ await context.Response.WriteAsJsonAsync(new { success = true }, context.RequestAborted).ConfigureAwait(false);
+ }
+ }
+
+ private static MaxBotOptions ResolveOptions(IServiceProvider services, IUpdatePipeline pipeline)
+ {
+ var options = services.GetService();
+ if (options != null)
+ {
+ return options;
+ }
+
+ if (pipeline is Max.Bot.MaxClient client)
+ {
+ return client.Options;
+ }
+
+ // * 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))
+ {
+ return configured;
+ }
+
+ 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 1ac3ef8..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;
@@ -187,42 +188,39 @@ private async Task PollAsync(CancellationToken cancellationToken)
var response = await FetchUpdatesAsync(_marker, cancellationToken).ConfigureAwait(false);
_consecutiveErrors = 0;
- long? lastProcessedUpdateId = null;
-
if (response.Updates?.Length > 0)
{
+ var batchCompleted = true;
foreach (var update in response.Updates)
{
if (cancellationToken.IsCancellationRequested)
{
+ batchCompleted = false;
break;
}
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;
}
}
- if (_options.Polling.PersistMarkers)
- {
- _marker = lastProcessedUpdateId ?? response.Marker ?? _marker;
- }
- else
+ // * MAX updates carry no update_id: the next page is addressed only by the marker returned with the batch.
+ // Advance it only when the whole batch was walked, so a stop mid-batch re-fetches the rest (at-least-once).
+ if (batchCompleted)
{
- _marker = lastProcessedUpdateId ?? response.Marker ?? _options.Polling.InitialMarker;
+ _marker = _options.Polling.PersistMarkers
+ ? response.Marker ?? _marker
+ : response.Marker ?? _options.Polling.InitialMarker;
}
}
else
@@ -248,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)
@@ -258,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)
@@ -307,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)
{
@@ -335,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 c09f88d..cda9bda 100644
--- a/src/Max.Bot/Polling/WebhookController.cs
+++ b/src/Max.Bot/Polling/WebhookController.cs
@@ -1,10 +1,7 @@
using System;
-using System.Security.Cryptography;
-using System.Text;
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;
@@ -12,11 +9,33 @@
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. The request body is read by the controller
+/// itself, after the size limit and the secret header have been checked.
+///
+///
[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));
@@ -38,81 +57,11 @@ public WebhookController(IUpdatePipeline pipeline, IUpdateHandler handler, MaxBo
/// 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 (!ValidatePayloadSize())
- {
- return StatusCode(StatusCodes.Status413PayloadTooLarge);
- }
-
- if (!ValidateSignature())
- {
- return Unauthorized();
- }
-
- 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);
+ 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/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..b151aa8 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,132 @@ 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)
- where T : Attachment
+ ///
+ /// 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, new()
{
- if (root.TryGetProperty(payloadPropertyName, out var payload) && payload.ValueKind == JsonValueKind.Object)
+ try
{
- var attachment = JsonSerializer.Deserialize(payload.GetRawText(), options);
- if (attachment != null && root.TryGetProperty("type", out var typeElement) && typeElement.ValueKind == JsonValueKind.String)
+ 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))
{
- attachment.Type = typeElement.GetString() ?? attachment.Type;
+ fallback.Type = typeString;
}
- return attachment;
+ return fallback;
}
-
- return JsonSerializer.Deserialize(root.GetRawText(), options);
}
- private static PhotoAttachment? DeserializePhotoAttachment(JsonElement root, JsonSerializerOptions options)
+ private static T? DeserializeMergedCore(JsonElement root, string? typeString, string? legacyPayloadPropertyName, JsonSerializerOptions options)
+ where T : Attachment
{
- if (root.TryGetProperty("payload", out var payload) && payload.ValueKind == JsonValueKind.Object)
- {
- var attachment = new PhotoAttachment();
+ var merged = new JsonObject();
- 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;
- }
+ 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;
+ }
- if (payload.TryGetProperty("file_id", out var fileIdElement) && fileIdElement.ValueKind == JsonValueKind.String)
- {
- attachment.FileId = fileIdElement.GetString() ?? string.Empty;
- }
- else if (payload.TryGetProperty("token", out var tokenElement) && tokenElement.ValueKind == JsonValueKind.String)
+ if (hasPayload)
+ {
+ foreach (var property in payload.EnumerateObject())
{
- 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/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/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..c1d9660 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,111 @@ 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)
+ {
+ if (update.ChatId == null && update.Chat != null)
+ {
+ // message_chat_created carries a full chat object but no chat_id.
+ update.SetInferredChat(update.Chat.ChatId, null);
+ }
+
+ var recipient = update.Message?.Recipient;
+ if (recipient?.ChatId is not { } recipientChatId)
+ {
+ return;
+ }
+
+ var inferredChatId = update.ChatId == null ? recipientChatId : (long?)null;
+ Chat? inferredChat = null;
+ if (update.Chat == null && Update.TryParseChatType(recipient.ChatType, out var chatType))
+ {
+ inferredChat = new Chat
+ {
+ ChatId = recipientChatId,
+ Type = chatType
+ };
+ }
+
+ update.SetInferredChat(inferredChatId, inferredChat);
+ }
+
+ 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 +208,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 +228,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/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/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..749c635 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,91 @@ 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
{
+ private string? _token;
+ private bool _tokenDerivedFromPhotos;
+
///
- /// Gets or sets the uploaded media token.
+ /// 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).
+ ///
+ [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));
+ _tokenDerivedFromPhotos = _token != null;
+ }
+}
+
+///
+/// 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/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/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..34ea014 100644
--- a/src/Max.Bot/Types/Requests/AddChatAdminRequest.cs
+++ b/src/Max.Bot/Types/Requests/AddChatAdminRequest.cs
@@ -1,26 +1,73 @@
+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.
+ ///
+ ///
+ /// 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.")]
- [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..98e52e3 100644
--- a/src/Max.Bot/Types/Update.cs
+++ b/src/Max.Bot/Types/Update.cs
@@ -69,10 +69,24 @@ 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; }
+ public Chat? Chat
+ {
+ get => _chat;
+ set
+ {
+ _chat = value;
+ IsChatInferred = false;
+ }
+ }
+
+ private Chat? _chat;
///
/// Gets or sets the user who triggered the update.
@@ -90,10 +104,129 @@ 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 => _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) it is or .
+ ///
+ [JsonIgnore]
+ public ChatType? ChatType
+ {
+ get
+ {
+ if (Chat != null)
+ {
+ return Chat.Type;
+ }
+
+ if (TryParseChatType(Message?.Recipient?.ChatType, out var chatType))
+ {
+ return chatType;
+ }
+
+ // * 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
+ };
+ }
+ }
+
+ ///
+ /// 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? ChatId { get; set; }
+ 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; private set; }
+
+ ///
+ /// Gets or sets a value indicating whether was taken from message.recipient.
+ ///
+ [JsonIgnore]
+ 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.
@@ -103,7 +236,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 +316,7 @@ public MessageRemovedUpdate? MessageRemovedUpdate
{
get
{
- if (Message == null || Type != UpdateType.MessageRemoved)
+ if (Type != UpdateType.MessageRemoved)
return null;
return new MessageRemovedUpdate
@@ -190,7 +324,10 @@ public MessageRemovedUpdate? MessageRemovedUpdate
UpdateId = UpdateId,
Timestamp = Timestamp,
UserLocale = UserLocale,
- Message = Message
+ Message = Message,
+ MessageId = MessageId ?? Message?.Mid,
+ ChatId = ChatId,
+ UserId = UserId
};
}
}
@@ -211,8 +348,10 @@ public BotAddedUpdate? BotAddedUpdate
UpdateId = UpdateId,
Timestamp = Timestamp,
Chat = Chat,
+ ChatId = ChatId ?? Chat?.ChatId,
User = User,
- InviterId = InviterId
+ InviterId = InviterId,
+ IsChannel = IsChannel
};
}
}
@@ -233,7 +372,9 @@ public BotRemovedUpdate? BotRemovedUpdate
UpdateId = UpdateId,
Timestamp = Timestamp,
Chat = Chat,
- User = User
+ ChatId = ChatId ?? Chat?.ChatId,
+ User = User,
+ IsChannel = IsChannel
};
}
}
@@ -277,7 +418,8 @@ public BotStoppedUpdate? BotStoppedUpdate
UpdateId = UpdateId,
Timestamp = Timestamp,
ChatId = ChatId,
- User = User
+ User = User,
+ UserLocale = UserLocale
};
}
}
@@ -298,8 +440,10 @@ public UserAddedUpdate? UserAddedUpdate
UpdateId = UpdateId,
Timestamp = Timestamp,
Chat = Chat,
+ ChatId = ChatId ?? Chat?.ChatId,
User = User,
- InviterId = InviterId
+ InviterId = InviterId,
+ IsChannel = IsChannel
};
}
}
@@ -320,7 +464,10 @@ public UserRemovedUpdate? UserRemovedUpdate
UpdateId = UpdateId,
Timestamp = Timestamp,
Chat = Chat,
- User = User
+ ChatId = ChatId ?? Chat?.ChatId,
+ User = User,
+ AdminId = AdminId,
+ IsChannel = IsChannel
};
}
}
@@ -341,7 +488,9 @@ public ChatTitleChangedUpdate? ChatTitleChangedUpdate
UpdateId = UpdateId,
Timestamp = Timestamp,
Chat = Chat,
- User = User
+ ChatId = ChatId ?? Chat?.ChatId,
+ User = User,
+ Title = Title ?? Chat?.Title
};
}
}
@@ -362,7 +511,10 @@ public DialogMutedUpdate? DialogMutedUpdate
UpdateId = UpdateId,
Timestamp = Timestamp,
ChatId = ChatId,
- IsMuted = IsMuted
+ IsMuted = IsMuted,
+ MutedUntil = MutedUntil,
+ User = User,
+ UserLocale = UserLocale
};
}
}
@@ -383,7 +535,9 @@ public DialogUnmutedUpdate? DialogUnmutedUpdate
UpdateId = UpdateId,
Timestamp = Timestamp,
ChatId = ChatId,
- IsMuted = IsMuted
+ IsMuted = IsMuted,
+ User = User,
+ UserLocale = UserLocale
};
}
}
@@ -403,7 +557,9 @@ public DialogClearedUpdate? DialogClearedUpdate
{
UpdateId = UpdateId,
Timestamp = Timestamp,
- ChatId = ChatId
+ ChatId = ChatId,
+ User = User,
+ UserLocale = UserLocale
};
}
}
@@ -423,7 +579,9 @@ public DialogRemovedUpdate? DialogRemovedUpdate
{
UpdateId = UpdateId,
Timestamp = Timestamp,
- ChatId = ChatId
+ ChatId = ChatId,
+ User = User,
+ UserLocale = UserLocale
};
}
}
@@ -436,7 +594,7 @@ public MessageChatCreatedUpdate? MessageChatCreatedUpdate
{
get
{
- if (Message == null || Type != UpdateType.MessageChatCreated)
+ if (Type != UpdateType.MessageChatCreated)
return null;
return new MessageChatCreatedUpdate
@@ -444,13 +602,36 @@ public MessageChatCreatedUpdate? MessageChatCreatedUpdate
UpdateId = UpdateId,
Timestamp = Timestamp,
UserLocale = UserLocale,
- Message = Message
+ Message = Message,
+ Chat = Chat,
+ ChatId = ChatId ?? Chat?.ChatId,
+ 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..f267968 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,27 @@ 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 created chat.
+ ///
+ public long? ChatId { 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