From fb201946f7027fbf4eca68674168d72a9e3b136a Mon Sep 17 00:00:00 2001 From: Aleksandr Semenenko <112961081+Deskri@users.noreply.github.com> Date: Thu, 19 Feb 2026 23:32:48 +0400 Subject: [PATCH 01/18] feat: init project --- CLAUDE.md | 226 ++++ Max.BotClient.slnx | 3 + src/Max.BotClient/DTOs/Attachments.cs | 349 ++++++ src/Max.BotClient/DTOs/Buttons.cs | 164 +++ src/Max.BotClient/DTOs/Chat.cs | 215 ++++ src/Max.BotClient/DTOs/Markup.cs | 193 +++ src/Max.BotClient/DTOs/Message.cs | 168 +++ src/Max.BotClient/DTOs/Requests.cs | 325 +++++ src/Max.BotClient/DTOs/Updates.cs | 540 ++++++++ src/Max.BotClient/DTOs/User.cs | 98 ++ src/Max.BotClient/DTOs/Video.cs | 88 ++ .../Mapping/AttachmentMappingExtensions.cs | 199 +++ .../Mapping/ChatMappingExtensions.cs | 164 +++ .../Mapping/MessageMappingExtensions.cs | 69 ++ .../Mapping/RequestMappingExtensions.cs | 234 ++++ .../Mapping/ResponseMappingExtensions.cs | 231 ++++ .../Mapping/UniversalMappingExtensions.cs | 63 + .../Mapping/UpdateChatMappingExtensions.cs | 52 + .../Mapping/UserMappingExtensions.cs | 47 + .../Mapping/VideoMappingExtensions.cs | 50 + .../Max.BotClient.ApiException.cs | 45 + .../Max.BotClient.ApiExtensions.cs | 38 + .../Max.BotClient.ApiMethods.Chats.cs | 237 ++++ .../Max.BotClient.ApiMethods.Members.cs | 250 ++++ .../Max.BotClient.ApiMethods.Messages.cs | 233 ++++ .../Max.BotClient.ApiMethods.Misc.cs | 70 ++ .../Max.BotClient.ApiMethods.Subscriptions.cs | 110 ++ src/Max.BotClient/Max.BotClient.Extensions.cs | 11 + .../Max.BotClient.JsonOptions.cs | 30 + src/Max.BotClient/Max.BotClient.Options.cs | 32 + src/Max.BotClient/Max.BotClient.Polling.cs | 154 +++ src/Max.BotClient/Max.BotClient.cs | 102 ++ src/Max.BotClient/Max.BotClient.csproj | 13 + src/Max.BotClient/Types/AttachmentRequest.cs | 162 +++ src/Max.BotClient/Types/Attachments.cs | 324 +++++ src/Max.BotClient/Types/BotInfo.cs | 29 + .../Types/Builders/InlineKeyboardBuilder.cs | 173 +++ src/Max.BotClient/Types/Callback.cs | 29 + src/Max.BotClient/Types/Chat.cs | 340 ++++++ src/Max.BotClient/Types/Message.cs | 1087 +++++++++++++++++ src/Max.BotClient/Types/NewMessageBody.cs | 34 + src/Max.BotClient/Types/SendMessage.cs | 47 + src/Max.BotClient/Types/Subscription.cs | 94 ++ src/Max.BotClient/Types/Update.cs | 100 ++ src/Max.BotClient/Types/UpdateChat.cs | 75 ++ src/Max.BotClient/Types/Upload.cs | 30 + src/Max.BotClient/Types/User.cs | 64 + src/Max.BotClient/Types/Video.cs | 80 ++ 48 files changed, 7471 insertions(+) create mode 100644 CLAUDE.md create mode 100644 Max.BotClient.slnx create mode 100644 src/Max.BotClient/DTOs/Attachments.cs create mode 100644 src/Max.BotClient/DTOs/Buttons.cs create mode 100644 src/Max.BotClient/DTOs/Chat.cs create mode 100644 src/Max.BotClient/DTOs/Markup.cs create mode 100644 src/Max.BotClient/DTOs/Message.cs create mode 100644 src/Max.BotClient/DTOs/Requests.cs create mode 100644 src/Max.BotClient/DTOs/Updates.cs create mode 100644 src/Max.BotClient/DTOs/User.cs create mode 100644 src/Max.BotClient/DTOs/Video.cs create mode 100644 src/Max.BotClient/Mapping/AttachmentMappingExtensions.cs create mode 100644 src/Max.BotClient/Mapping/ChatMappingExtensions.cs create mode 100644 src/Max.BotClient/Mapping/MessageMappingExtensions.cs create mode 100644 src/Max.BotClient/Mapping/RequestMappingExtensions.cs create mode 100644 src/Max.BotClient/Mapping/ResponseMappingExtensions.cs create mode 100644 src/Max.BotClient/Mapping/UniversalMappingExtensions.cs create mode 100644 src/Max.BotClient/Mapping/UpdateChatMappingExtensions.cs create mode 100644 src/Max.BotClient/Mapping/UserMappingExtensions.cs create mode 100644 src/Max.BotClient/Mapping/VideoMappingExtensions.cs create mode 100644 src/Max.BotClient/Max.BotClient.ApiException.cs create mode 100644 src/Max.BotClient/Max.BotClient.ApiExtensions.cs create mode 100644 src/Max.BotClient/Max.BotClient.ApiMethods.Chats.cs create mode 100644 src/Max.BotClient/Max.BotClient.ApiMethods.Members.cs create mode 100644 src/Max.BotClient/Max.BotClient.ApiMethods.Messages.cs create mode 100644 src/Max.BotClient/Max.BotClient.ApiMethods.Misc.cs create mode 100644 src/Max.BotClient/Max.BotClient.ApiMethods.Subscriptions.cs create mode 100644 src/Max.BotClient/Max.BotClient.Extensions.cs create mode 100644 src/Max.BotClient/Max.BotClient.JsonOptions.cs create mode 100644 src/Max.BotClient/Max.BotClient.Options.cs create mode 100644 src/Max.BotClient/Max.BotClient.Polling.cs create mode 100644 src/Max.BotClient/Max.BotClient.cs create mode 100644 src/Max.BotClient/Max.BotClient.csproj create mode 100644 src/Max.BotClient/Types/AttachmentRequest.cs create mode 100644 src/Max.BotClient/Types/Attachments.cs create mode 100644 src/Max.BotClient/Types/BotInfo.cs create mode 100644 src/Max.BotClient/Types/Builders/InlineKeyboardBuilder.cs create mode 100644 src/Max.BotClient/Types/Callback.cs create mode 100644 src/Max.BotClient/Types/Chat.cs create mode 100644 src/Max.BotClient/Types/Message.cs create mode 100644 src/Max.BotClient/Types/NewMessageBody.cs create mode 100644 src/Max.BotClient/Types/SendMessage.cs create mode 100644 src/Max.BotClient/Types/Subscription.cs create mode 100644 src/Max.BotClient/Types/Update.cs create mode 100644 src/Max.BotClient/Types/UpdateChat.cs create mode 100644 src/Max.BotClient/Types/Upload.cs create mode 100644 src/Max.BotClient/Types/User.cs create mode 100644 src/Max.BotClient/Types/Video.cs diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b067b30 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,226 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Build Commands + +```bash +# Build the library +dotnet build src/Max.BotClient/Max.BotClient.csproj + +# Build in Release mode +dotnet build src/Max.BotClient/Max.BotClient.csproj -c Release +``` + +## Project Overview + +Max.BotClient is a .NET Standard 2.0 class library SDK for MAX Bot API (for broad compatibility across .NET Framework 4.6.1+ and .NET Core 2.0+). + +**Solution structure:** +- `Max.BotClient.slnx` - Solution file (XML format) +- `src/Max.BotClient/` - Main library project + +**Core files:** +- `Max.BotClient.cs` - Main `BotClient` class (`partial class`), implements `IBotClient`, contains `SendRequest` with retry/exponential backoff +- `Max.BotClient.Options.cs` - `BotClientOptions` (token, API URL, retry settings) +- `Max.BotClient.JsonOptions.cs` - `BotClientJsonOptions` with snake_case naming policy +- `Max.BotClient.ApiException.cs` - `MaxBotClientApiException` with `IsRetryable` (429/5xx) +- `Max.BotClient.ApiExtensions.cs` - Internal `ProcessApi` helper methods +- `Max.BotClient.Extensions.cs` - Internal `ToSnakeCase()` utility + +**API method files (partial class `BotClientApiMethods`):** +- `Max.BotClient.ApiMethods.Messages.cs` - GetMessages, GetMessage, SendMessage, EditMessage, DeleteMessage, AnswerCallback +- `Max.BotClient.ApiMethods.Chats.cs` - GetChats, GetChat, UpdateChat, DeleteChat, SendAction, PinMessage, UnpinMessage, GetPinnedMessage +- `Max.BotClient.ApiMethods.Members.cs` - GetMyMembership, LeaveChat, GetChatMembers, AddChatMembers, RemoveChatMember, GetChatAdmins, AddChatAdmins, RemoveChatAdmin +- `Max.BotClient.ApiMethods.Subscriptions.cs` - GetSubscriptions, Subscribe, Unsubscribe, GetUpdates +- `Max.BotClient.ApiMethods.Misc.cs` - GetMe, GetUploadUrl, GetVideo +- `Max.BotClient.Polling.cs` - StartReceiving, ReceiveAsync (long polling) + +**Types/ folder** - Public API types: +- `Message.cs` - Unified Message class (builder + read + edit), Attachment, MarkupElement, Button, enums +- `Attachments.cs` - IAttachment, PhotoAttachment, VideoAttachment, AudioAttachment, FileAttachment, StickerAttachment, ContactAttachment, ShareAttachment, LocationAttachment, InlineKeyboardAttachment +- `User.cs` - User, UserWithPhoto +- `Update.cs` - Update, GetUpdatesResponse +- `Chat.cs` - Chat, ChatMember, ChatAdmin, GetChatsResponse, GetChatMembersResponse, enums +- `Callback.cs` - Callback +- `BotInfo.cs` - BotInfo, BotCommand +- `Video.cs` - VideoInfo, VideoUrls +- `Upload.cs` - UploadType, UploadResult +- `Subscription.cs` - Subscription, SubscribeResult, UpdateType enum +- `SendMessage.cs` - SendMessageResponse, NewMessageLink, TextFormat, MessageLinkRequestType +- `NewMessageBody.cs` - NewMessageBody +- `AttachmentRequest.cs` - AttachmentRequest, AttachmentPayload, ButtonRequest, enums +- `UpdateChat.cs` - UpdateChatRequest, ChatIcon, PhotoToken +- `Builders/InlineKeyboardBuilder.cs` - Fluent builder for inline keyboards + +**DTOs/ folder** - Internal data transfer objects for API JSON serialization: +- `Message.cs`, `User.cs`, `Attachments.cs`, `Buttons.cs`, `Markup.cs`, `Requests.cs`, `Updates.cs`, `Chat.cs`, `Video.cs` + +**Mapping/ folder** - Converters between Types and DTOs: +- `UniversalMappingExtensions.cs` - Central `ToResult()` dispatcher used by `ProcessApi` +- `MessageMappingExtensions.cs` - DTOs.Message -> Types.Message +- `ResponseMappingExtensions.cs` - Response DTOs -> Types (GetMessagesResponse, SendMessageResponse, GetUpdatesResponse, BotInfo, Update mappings) +- `AttachmentMappingExtensions.cs` - DTOs.IAttachment -> Types.Attachment, markup, buttons +- `RequestMappingExtensions.cs` - Types -> DTOs for sending (NewMessageBody, AttachmentRequests, Buttons) +- `UserMappingExtensions.cs` - DTOs.User -> Types.User +- `ChatMappingExtensions.cs` - DTOs.Chat -> Types.Chat, ChatMember, ChatAdmin +- `VideoMappingExtensions.cs` - DTOs.VideoInfo -> Types.VideoInfo +- `UpdateChatMappingExtensions.cs` - Types.UpdateChatRequest -> DTO + +## Architecture Principles + +### Unified Message Architecture + +**IMPORTANT:** We use a **single unified `Message` class** for all message operations: +- **Creating** new messages (builder pattern) +- **Receiving** messages from API (read-only properties) +- **Editing** existing messages (builder pattern) + +DO NOT CREATE separate classes like `NewMessage`, `EditMessageRequest`, or `MessageBuilder`. +USE the single `Message` class for all scenarios. + +### Message Class Design + +The `Message` class serves three purposes: + +1. **Builder Mode** (Creating/Editing): + - Internal state: `_builderText`, `_builderAttachments`, `_isBuilderMode` + - Methods: `WithText()`, `WithPhoto()`, `AddPhoto()`, `ReplacePhotos()`, `ClearPhotos()`, etc. + - Fluent API for building messages + +2. **Read Mode** (Receiving): + - Public properties: `Text`, `Mid`, `Sender`, `Timestamp`, etc. + - Properties are `internal set` - only SDK can set them + - Methods: `GetPhotos()`, `GetVideos()`, `GetFiles()`, etc. + - Returns typed attachment collections + +3. **Conversion** (Internal): + - `ToMessageBody()` - converts to DTOs for API + - `ConvertToAttachmentRequest()` - converts typed attachments to DTOs + +### DTOs vs Types + +- **DTOs** (`DTOs/` namespace) - must be `internal`. Used only for JSON serialization with the API. Polymorphic via interfaces (`IAttachment`, `IButton`, `IMarkupElement`, `IUpdate`). +- **Types** (`Types/` namespace) - `public`. Flat/unified classes. Properties from API responses should use `internal set`. User-created request objects (e.g. `UpdateChatRequest`, `ChatAdmin`) can use regular `set`. + +### Mapping Layer + +- `ProcessApi()` deserializes JSON into DTO, then calls `UniversalMappingExtensions.ToResult()` to convert. +- **IMPORTANT:** When adding a new `ProcessApi` call, you MUST add the corresponding mapping case in `UniversalMappingExtensions.ToResult()`, otherwise it throws `NotSupportedException` at runtime. +- Individual mapping methods live in dedicated files (MessageMappingExtensions, ChatMappingExtensions, etc.). + +### Attachment System + +**Typed Attachment Classes** (in `Types/Attachments.cs`): +- `IAttachment` - base interface +- `PhotoAttachment`, `VideoAttachment`, `AudioAttachment`, `FileAttachment` +- `StickerAttachment`, `ContactAttachment`, `ShareAttachment`, `LocationAttachment` +- `InlineKeyboardAttachment` + +**Key Features:** +- Properties are `internal set` - immutable from user code +- Static factory methods: `FromUrl()`, `FromToken()`, `FromCode()`, `Create()` +- Used both for receiving (parsed from API) and sending (builder creates them) + +### API Methods Pattern + +All API methods are extension methods on `BotClient` in `static partial class BotClientApiMethods`: + +```csharp +public static async Task MethodName( + this BotClient botClient, + // ... parameters ... + CancellationToken cancellationToken = default +) +{ + var queryParams = new List(); + // ... build query ... + + var response = await botClient.ProcessApi( + HttpMethod, path, requestBody, cancellationToken + ); + + return response.Property; +} +``` + +**Key principles:** +- Extension methods on `BotClient` +- Accept `Message` class directly (not separate request types) +- Use `cancellationToken` for async operations +- Map between DTOs (internal) and Types (public API) + +### Long Polling + +`Max.BotClient.Polling.cs` provides: +- `ReceiverOptions` - Limit, Timeout, AllowedUpdates, DropPendingUpdates +- `StartReceiving()` - non-blocking, launches polling in background Task +- `ReceiveAsync()` - blocking (awaitable), main polling loop with marker-based pagination + +Uses existing `GetUpdates()` method. MAX API uses marker (not offset) for pagination. + +## Usage Examples + +### Creating and Sending Message + +```csharp +var message = new Message("Hello World!") + .WithPhoto("https://example.com/photo.jpg") + .AddPhoto("https://example.com/photo2.jpg") + .WithVideo("video_token") + .WithKeyboard(kb => kb + .AddRow() + .AddCallbackButton("Button 1", "payload1")) + .ToChat(); + +await botClient.SendMessage(chatId, message); +``` + +### Long Polling + +```csharp +var cts = new CancellationTokenSource(); + +botClient.StartReceiving( + updateHandler: async (bot, update, ct) => { + Console.WriteLine(update.UpdateType); + }, + errorHandler: async (bot, ex, ct) => { + Console.WriteLine(ex.Message); + }, + options: new ReceiverOptions { Timeout = 30 }, + cancellationToken: cts.Token +); + +// Or blocking: +await botClient.ReceiveAsync(updateHandler, errorHandler, options, cts.Token); +``` + +## Implementation Guidelines + +### When Adding New API Methods + +1. Add method to the appropriate `BotClientApiMethods` partial class file +2. If using `ProcessApi`, add mapping case in `UniversalMappingExtensions.ToResult()` +3. Use existing types (especially `Message` for message operations) +4. Follow async pattern with `CancellationToken` +5. Add XML documentation with `` to API docs + +### When Adding New Types + +1. Add to `Types/` folder - these are public API +2. Add corresponding DTOs to `DTOs/` folder - **must be internal** +3. Add mapping in `Mapping/` folder +4. Register mapping in `UniversalMappingExtensions.ToResult()` if used with `ProcessApi` +5. Use `internal set` for properties that come from API responses +6. Follow builder pattern for mutable operations + +### When Working with Messages + +- Use `Message` class for all message operations +- Use `With*()` methods to replace attachments +- Use `Add*()` methods to append attachments +- Use `Get*()` methods to retrieve typed attachments +- Do NOT create separate builder classes +- Do NOT expose internal `Attachment` class (use typed classes) +- Do NOT make attachment properties publicly settable diff --git a/Max.BotClient.slnx b/Max.BotClient.slnx new file mode 100644 index 0000000..c93f801 --- /dev/null +++ b/Max.BotClient.slnx @@ -0,0 +1,3 @@ + + + diff --git a/src/Max.BotClient/DTOs/Attachments.cs b/src/Max.BotClient/DTOs/Attachments.cs new file mode 100644 index 0000000..2b04b93 --- /dev/null +++ b/src/Max.BotClient/DTOs/Attachments.cs @@ -0,0 +1,349 @@ +namespace Max.BotClient.DTOs +{ + /// + /// Тип вложения. + /// + internal enum AttachmentType + { + Image, + Video, + Audio, + File, + Sticker, + Contact, + Share, + Location, + InlineKeyboard + } + + /// + /// Интерфейс вложения. + /// + /// + internal interface IAttachment + { + /// + /// Тип вложения. + /// + AttachmentType Type { get; } + } + + /// + /// Вложение с изображением. + /// + /// + internal class PhotoAttachment : IAttachment + { + /// + public AttachmentType Type => AttachmentType.Image; + + /// + /// Данные изображения. + /// + public PhotoAttachmentPayload Payload { get; set; } + } + + /// + /// Данные вложения с изображением. + /// + /// + internal class PhotoAttachmentPayload + { + /// + /// Уникальный ID изображения. + /// + public long PhotoId { get; set; } + + /// + /// Токен для повторного использования вложения. + /// + public string Token { get; set; } + + /// + /// URL изображения. + /// + public string Url { get; set; } + } + + /// + /// Вложение с видео. + /// + /// + internal class VideoAttachment : IAttachment + { + /// + public AttachmentType Type => AttachmentType.Video; + + /// + /// Данные видео. + /// + public MediaAttachmentPayload Payload { get; set; } + + /// + /// Миниатюра видео. + /// + public VideoThumbnail? Thumbnail { get; set; } + + /// + /// Ширина видео. + /// + public int? Width { get; set; } + + /// + /// Высота видео. + /// + public int? Height { get; set; } + + /// + /// Длина видео в секундах. + /// + public int? Duration { get; set; } + } + + /// + /// Вложение с аудио. + /// + /// + internal class AudioAttachment : IAttachment + { + /// + public AttachmentType Type => AttachmentType.Audio; + + /// + /// Данные аудио. + /// + public MediaAttachmentPayload Payload { get; set; } + + /// + /// Аудио транскрипция. + /// + public string? Transcription { get; set; } + } + + /// + /// Вложение с файлом. + /// + /// + internal class FileAttachment : IAttachment + { + /// + public AttachmentType Type => AttachmentType.File; + + /// + /// Данные файла. + /// + public FileAttachmentPayload Payload { get; set; } + + /// + /// Имя загруженного файла. + /// + public string Filename { get; set; } + + /// + /// Размер файла в байтах. + /// + public long Size { get; set; } + } + + /// + /// Данные вложения с файлом. + /// + /// + internal class FileAttachmentPayload + { + /// + /// URL файла. + /// + public string Url { get; set; } + + /// + /// Токен для повторного использования вложения. + /// + public string Token { get; set; } + } + + /// + /// Вложение со стикером. + /// + /// + internal class StickerAttachment : IAttachment + { + /// + public AttachmentType Type => AttachmentType.Sticker; + + /// + /// Данные стикера. + /// + public StickerAttachmentPayload Payload { get; set; } + + /// + /// Ширина стикера. + /// + public int Width { get; set; } + + /// + /// Высота стикера. + /// + public int Height { get; set; } + } + + /// + /// Данные вложения со стикером. + /// + /// + internal class StickerAttachmentPayload + { + /// + /// URL стикера. + /// + public string Url { get; set; } + + /// + /// ID стикера. + /// + public string Code { get; set; } + } + + /// + /// Вложение с контактом. + /// + /// + internal class ContactAttachment : IAttachment + { + /// + public AttachmentType Type => AttachmentType.Contact; + + /// + /// Данные контакта. + /// + public ContactAttachmentPayload Payload { get; set; } + } + + /// + /// Данные вложения с контактом. + /// + /// + internal class ContactAttachmentPayload + { + /// + /// Информация о пользователе в формате VCF. + /// + public string? VcfInfo { get; set; } + + /// + /// Информация о пользователе. + /// + public User? MaxInfo { get; set; } + } + + /// + /// Вложение с предпросмотром ссылки. + /// + /// + internal class ShareAttachment : IAttachment + { + /// + public AttachmentType Type => AttachmentType.Share; + + /// + /// Данные ссылки. + /// + public ShareAttachmentPayload Payload { get; set; } + + /// + /// Заголовок предпросмотра ссылки. + /// + public string? Title { get; set; } + + /// + /// Описание предпросмотра ссылки. + /// + public string? Description { get; set; } + + /// + /// Изображение предпросмотра ссылки. + /// + public string? ImageUrl { get; set; } + } + + /// + /// Данные вложения с предпросмотром ссылки. + /// + /// + internal class ShareAttachmentPayload + { + /// + /// URL, прикрепленный к сообщению в качестве предпросмотра медиа. + /// + public string? Url { get; set; } + + /// + /// Токен вложения. + /// + public string? Token { get; set; } + } + + /// + /// Вложение с геолокацией. + /// + /// + internal class LocationAttachment : IAttachment + { + /// + public AttachmentType Type => AttachmentType.Location; + + /// + /// Широта. + /// + public double Latitude { get; set; } + + /// + /// Долгота. + /// + public double Longitude { get; set; } + } + + /// + /// Вложение с inline-клавиатурой. + /// + /// + internal class InlineKeyboardAttachment : IAttachment + { + /// + public AttachmentType Type => AttachmentType.InlineKeyboard; + + /// + /// Клавиатура. + /// + public Keyboard Payload { get; set; } + } + + /// + /// Данные медиа-вложения. + /// + /// + internal class MediaAttachmentPayload + { + /// + /// URL медиа-вложения. + /// + public string Url { get; set; } + + /// + /// Токен для повторного использования вложения. + /// + public string Token { get; set; } + } + + /// + /// Миниатюра видео. + /// + /// + internal class VideoThumbnail + { + /// + /// URL изображения миниатюры. + /// + public string Url { get; set; } + } +} diff --git a/src/Max.BotClient/DTOs/Buttons.cs b/src/Max.BotClient/DTOs/Buttons.cs new file mode 100644 index 0000000..d909220 --- /dev/null +++ b/src/Max.BotClient/DTOs/Buttons.cs @@ -0,0 +1,164 @@ +namespace Max.BotClient.DTOs +{ + /// + /// Клавиатура - двумерный массив кнопок. + /// + /// + internal class Keyboard + { + /// + /// Двумерный массив кнопок. + /// + public IButton[][] Buttons { get; set; } + } + + /// + /// Тип кнопки. + /// + internal enum ButtonType + { + Callback, + Link, + RequestGeoLocation, + RequestContact, + OpenApp, + Message + } + + /// + /// Интерфейс кнопки. + /// + /// + internal interface IButton + { + /// + /// Тип кнопки. + /// + ButtonType Type { get; } + + /// + /// Видимый текст кнопки (1-128 символов). + /// + string Text { get; } + } + + /// + /// Кнопка с callback. + /// + /// + internal class CallbackButton : IButton + { + /// + public ButtonType Type => ButtonType.Callback; + + /// + /// Видимый текст кнопки (1-128 символов). + /// + public string Text { get; set; } + + /// + /// Токен кнопки (до 1024 символов). + /// + public string Payload { get; set; } + } + + /// + /// Кнопка со ссылкой. + /// + /// + internal class LinkButton : IButton + { + /// + public ButtonType Type => ButtonType.Link; + + /// + /// Видимый текст кнопки (1-128 символов). + /// + public string Text { get; set; } + + /// + /// URL ссылки (до 2048 символов). + /// + public string Url { get; set; } + } + + /// + /// Кнопка запроса геолокации. + /// + /// + internal class RequestGeoLocationButton : IButton + { + /// + public ButtonType Type => ButtonType.RequestGeoLocation; + + /// + /// Видимый текст кнопки (1-128 символов). + /// + public string Text { get; set; } + + /// + /// Если true, отправляет местоположение без запроса подтверждения пользователя. + /// + public bool? Quick { get; set; } + } + + /// + /// Кнопка запроса контакта. + /// + /// + internal class RequestContactButton : IButton + { + /// + public ButtonType Type => ButtonType.RequestContact; + + /// + /// Видимый текст кнопки (1-128 символов). + /// + public string Text { get; set; } + } + + /// + /// Кнопка открытия мини-приложения. + /// + /// + internal class OpenAppButton : IButton + { + /// + public ButtonType Type => ButtonType.OpenApp; + + /// + /// Видимый текст кнопки (1-128 символов). + /// + public string Text { get; set; } + + /// + /// Публичное имя (username) бота или ссылка на него, чьё мини-приложение надо запустить. + /// + public string? WebApp { get; set; } + + /// + /// Идентификатор бота, чьё мини-приложение надо запустить. + /// + public long? ContactId { get; set; } + + /// + /// Параметр запуска, который будет передан в initData мини-приложения. + /// + public string? Payload { get; set; } + } + + /// + /// Кнопка отправки сообщения. + /// + /// + internal class MessageButton : IButton + { + /// + public ButtonType Type => ButtonType.Message; + + /// + /// Текст кнопки, который будет отправлен в чат от лица пользователя (1-128 символов). + /// + public string Text { get; set; } + } +} diff --git a/src/Max.BotClient/DTOs/Chat.cs b/src/Max.BotClient/DTOs/Chat.cs new file mode 100644 index 0000000..c7d8f9b --- /dev/null +++ b/src/Max.BotClient/DTOs/Chat.cs @@ -0,0 +1,215 @@ +namespace Max.BotClient.DTOs +{ + /// + /// Тип чата. + /// + internal enum ChatType + { + Chat + } + + /// + /// Статус чата. + /// + internal enum ChatStatus + { + /// + /// Бот является активным участником чата. + /// + Active, + + /// + /// Бот был удалён из чата. + /// + Removed, + + /// + /// Бот покинул чат. + /// + Left, + + /// + /// Чат был закрыт. + /// + Closed + } + + /// + /// Чат. + /// + /// + internal class Chat + { + /// + /// ID чата. + /// + public long ChatId { get; set; } + + /// + /// Тип чата. + /// + public ChatType Type { get; set; } + + /// + /// Статус чата. + /// + public ChatStatus Status { get; set; } + + /// + /// Отображаемое название чата. Может быть null для диалогов. + /// + public string? Title { get; set; } + + /// + /// Иконка чата. + /// + public Image? Icon { get; set; } + + /// + /// Время последнего события в чате. + /// + public long LastEventTime { get; set; } + + /// + /// Количество участников чата. Для диалогов всегда 2. + /// + public int ParticipantsCount { get; set; } + + /// + /// ID владельца чата. + /// + public long? OwnerId { get; set; } + + /// + /// Участники чата с временем последней активности. Может быть null, если запрашивается список чатов. + /// + public object? Participants { get; set; } + + /// + /// Доступен ли чат публично (для диалогов всегда false). + /// + public bool IsPublic { get; set; } + + /// + /// Ссылка на чат. + /// + public string? Link { get; set; } + + /// + /// Описание чата. + /// + public string? Description { get; set; } + + /// + /// Данные о пользователе в диалоге (только для чатов типа "dialog"). + /// + public UserWithPhoto? DialogWithUser { get; set; } + + /// + /// ID сообщения, содержащего кнопку, через которую был инициирован чат. + /// + public string? ChatMessageId { get; set; } + + /// + /// Закреплённое сообщение в чате (возвращается только при запросе конкретного чата). + /// + public Message? PinnedMessage { get; set; } + } + + /// + /// Изображение. + /// + /// + internal class Image + { + /// + /// URL изображения. + /// + public string Url { get; set; } + } + + /// + /// Ответ на запрос списка чатов. + /// + internal class GetChatsResponse + { + /// + /// Список запрашиваемых чатов. + /// + public Chat[]? Chats { get; set; } + + /// + /// Указатель на следующую страницу запрашиваемых чатов. + /// + public long? Marker { get; set; } + } + + /// + /// Ответ на запрос закреплённого сообщения. + /// + internal class GetPinnedMessageResponse + { + /// + /// Закреплённое сообщение. Может быть null, если в чате нет закреплённого сообщения. + /// + public Message? Message { get; set; } + } + + /// + /// Права администратора чата. + /// + internal enum ChatAdminPermission + { + ReadAllMessages, + AddRemoveMembers, + AddAdmins, + ChangeChatInfo, + PinMessage, + Write, + CanCall, + EditLink, + PostEditDeleteMessage, + EditMessage, + DeleteMessage + } + + /// + /// Участник чата с информацией о членстве. + /// + internal class ChatMember : UserWithPhoto + { + public long LastAccessTime { get; set; } + public bool IsOwner { get; set; } + public bool IsAdmin { get; set; } + public long JoinTime { get; set; } + public ChatAdminPermission[]? Permissions { get; set; } + public string? Alias { get; set; } + } + + /// + /// Ответ на запрос списка участников чата. + /// + internal class GetChatMembersResponse + { + public ChatMember[]? Members { get; set; } + public long? Marker { get; set; } + } + + /// + /// Администратор чата для запроса назначения. + /// + internal class ChatAdmin + { + public long UserId { get; set; } + public ChatAdminPermission[]? Permissions { get; set; } + public string? Alias { get; set; } + } + + /// + /// Запрос на добавление администраторов. + /// + internal class AddChatAdminsRequest + { + public ChatAdmin[]? Admins { get; set; } + } +} diff --git a/src/Max.BotClient/DTOs/Markup.cs b/src/Max.BotClient/DTOs/Markup.cs new file mode 100644 index 0000000..421a05f --- /dev/null +++ b/src/Max.BotClient/DTOs/Markup.cs @@ -0,0 +1,193 @@ +namespace Max.BotClient.DTOs +{ + /// + /// Тип элемента разметки. + /// + internal enum MarkupType + { + Strong, + Emphasized, + Monospaced, + Link, + Strikethrough, + Underline, + UserMention + } + + /// + /// Интерфейс элемента разметки сообщения. + /// + /// + internal interface IMarkupElement + { + /// + /// Тип элемента разметки. + /// + MarkupType Type { get; } + + /// + /// Индекс начала элемента разметки в тексте. Нумерация с нуля. + /// + int From { get; } + + /// + /// Длина элемента разметки. + /// + int Length { get; } + } + + /// + /// Элемент разметки - жирный текст. + /// + /// + internal class StrongMarkupElement : IMarkupElement + { + /// + public MarkupType Type => MarkupType.Strong; + + /// + /// Индекс начала элемента разметки в тексте. Нумерация с нуля. + /// + public int From { get; set; } + + /// + /// Длина элемента разметки. + /// + public int Length { get; set; } + } + + /// + /// Элемент разметки - курсив. + /// + /// + internal class EmphasizedMarkupElement : IMarkupElement + { + /// + public MarkupType Type => MarkupType.Emphasized; + + /// + /// Индекс начала элемента разметки в тексте. Нумерация с нуля. + /// + public int From { get; set; } + + /// + /// Длина элемента разметки. + /// + public int Length { get; set; } + } + + /// + /// Элемент разметки - моноширинный текст. + /// + /// + internal class MonospacedMarkupElement : IMarkupElement + { + /// + public MarkupType Type => MarkupType.Monospaced; + + /// + /// Индекс начала элемента разметки в тексте. Нумерация с нуля. + /// + public int From { get; set; } + + /// + /// Длина элемента разметки. + /// + public int Length { get; set; } + } + + /// + /// Элемент разметки - ссылка. + /// + /// + internal class LinkMarkupElement : IMarkupElement + { + /// + public MarkupType Type => MarkupType.Link; + + /// + /// Индекс начала элемента разметки в тексте. Нумерация с нуля. + /// + public int From { get; set; } + + /// + /// Длина элемента разметки. + /// + public int Length { get; set; } + + /// + /// URL ссылки (1-2048 символов). + /// + public string Url { get; set; } + } + + /// + /// Элемент разметки - зачёркнутый текст. + /// + /// + internal class StrikethroughMarkupElement : IMarkupElement + { + /// + public MarkupType Type => MarkupType.Strikethrough; + + /// + /// Индекс начала элемента разметки в тексте. Нумерация с нуля. + /// + public int From { get; set; } + + /// + /// Длина элемента разметки. + /// + public int Length { get; set; } + } + + /// + /// Элемент разметки - подчёркнутый текст. + /// + /// + internal class UnderlineMarkupElement : IMarkupElement + { + /// + public MarkupType Type => MarkupType.Underline; + + /// + /// Индекс начала элемента разметки в тексте. Нумерация с нуля. + /// + public int From { get; set; } + + /// + /// Длина элемента разметки. + /// + public int Length { get; set; } + } + + /// + /// Элемент разметки - упоминание пользователя. + /// + /// + internal class UserMentionMarkupElement : IMarkupElement + { + /// + public MarkupType Type => MarkupType.UserMention; + + /// + /// Индекс начала элемента разметки в тексте. Нумерация с нуля. + /// + public int From { get; set; } + + /// + /// Длина элемента разметки. + /// + public int Length { get; set; } + + /// + /// @username упомянутого пользователя. + /// + public string? UserLink { get; set; } + + /// + /// Идентификатор упомянутого пользователя. + /// + public long? UserId { get; set; } + } +} diff --git a/src/Max.BotClient/DTOs/Message.cs b/src/Max.BotClient/DTOs/Message.cs new file mode 100644 index 0000000..062d372 --- /dev/null +++ b/src/Max.BotClient/DTOs/Message.cs @@ -0,0 +1,168 @@ +namespace Max.BotClient.DTOs +{ + /// + /// Ответ метода GET /messages. + /// + internal class GetMessagesResponse + { + /// + /// Массив сообщений. + /// + public Message[]? Messages { get; set; } + } + + /// + /// Ответ метода POST /messages. + /// + internal class SendMessageResponse + { + /// + /// Отправленное сообщение. + /// + public Message? Message { get; set; } + } + + /// + /// Сообщение в чате. + /// + /// + internal class Message + { + /// + /// Пользователь, отправивший сообщение. + /// + public User? Sender { get; set; } + + /// + /// Получатель сообщения. Может быть пользователем или чатом. + /// + public Recipient Recipient { get; set; } + + /// + /// Время создания сообщения в формате Unix-time. + /// + public long Timestamp { get; set; } + + /// + /// Пересланное или ответное сообщение. + /// + public LinkedMessage? Link { get; set; } + + /// + /// Содержимое сообщения. Текст + вложения. + /// + public MessageBody? Body { get; set; } + + /// + /// Статистика сообщения. + /// + public MessageStat? Stat { get; set; } + + /// + /// Публичная ссылка на пост в канале. Отсутствует для диалогов и групповых чатов. + /// + public string? Url { get; set; } + } + + /// + /// Получатель сообщения. + /// + /// + internal class Recipient + { + /// + /// ID чата. + /// + public long? ChatId { get; set; } + + /// + /// Тип чата. + /// + public ChatType ChatType { get; set; } + + /// + /// ID пользователя. + /// + public long? UserId { get; set; } + } + + /// + /// Тип связи сообщения. + /// + internal enum MessageLinkType + { + Forward, + Reply + } + + /// + /// Пересланное или ответное сообщение. + /// + /// + internal class LinkedMessage + { + /// + /// Тип связанного сообщения. + /// + public MessageLinkType Type { get; set; } + + /// + /// Пользователь, отправивший сообщение. + /// + public User? Sender { get; set; } + + /// + /// Чат, в котором сообщение было изначально опубликовано. Только для пересланных сообщений. + /// + public long? ChatId { get; set; } + + /// + /// Тело сообщения. + /// + public MessageBody Message { get; set; } + } + + /// + /// Схема, представляющая тело сообщения. + /// + /// + internal class MessageBody + { + /// + /// Уникальный ID сообщения. + /// + public string Mid { get; set; } + + /// + /// ID последовательности сообщения в чате. + /// + public long Seq { get; set; } + + /// + /// Текст сообщения. + /// + public string? Text { get; set; } + + /// + /// Вложения сообщения. Могут быть одним из типов Attachment. + /// + public IAttachment[]? Attachments { get; set; } + + /// + /// Разметка сообщения. + /// + public IMarkupElement[]? Markup { get; set; } + } + + /// + /// Статистика сообщения. + /// + /// + internal class MessageStat + { + /// + /// Количество просмотров. + /// + public int Views { get; set; } + } +} diff --git a/src/Max.BotClient/DTOs/Requests.cs b/src/Max.BotClient/DTOs/Requests.cs new file mode 100644 index 0000000..07c2d7e --- /dev/null +++ b/src/Max.BotClient/DTOs/Requests.cs @@ -0,0 +1,325 @@ +namespace Max.BotClient.DTOs +{ + /// + /// Базовый ответ API. + /// + internal class ApiResponse + { + /// + /// true, если запрос был успешным, false в противном случае. + /// + public bool Success { get; set; } + + /// + /// Объяснительное сообщение, если результат не был успешным. + /// + public string? Message { get; set; } + } + + /// + /// Формат текста сообщения. + /// + internal enum TextFormat + { + Markdown, + Html + } + + /// + /// Тело нового сообщения. + /// + /// + internal class NewMessageBody + { + /// + /// Текст сообщения (до 4000 символов). + /// + public string? Text { get; set; } + + /// + /// Вложения сообщения. Если пусто, все вложения будут удалены. + /// + public IAttachmentRequest[]? Attachments { get; set; } + + /// + /// Ссылка на сообщение. + /// + public NewMessageLink? Link { get; set; } + + /// + /// Если false, участники чата не будут уведомлены (по умолчанию true). + /// + public bool? Notify { get; set; } + + /// + /// Формат текста сообщения. + /// + public TextFormat? Format { get; set; } + } + + /// + /// Ссылка на сообщение. + /// + /// + internal class NewMessageLink + { + /// + /// Тип ссылки сообщения. + /// + public MessageLinkType Type { get; set; } + + /// + /// ID исходного сообщения. + /// + public string Mid { get; set; } + } + + /// + /// Интерфейс запроса вложения. + /// + /// + internal interface IAttachmentRequest + { + /// + /// Тип вложения. + /// + AttachmentType Type { get; } + } + + /// + /// Запрос на прикрепление изображения. + /// + /// + internal class PhotoAttachmentRequest : IAttachmentRequest + { + /// + public AttachmentType Type => AttachmentType.Image; + + /// + /// Данные изображения. + /// + public PhotoAttachmentRequestPayload Payload { get; set; } + } + + /// + /// Данные запроса на прикрепление изображения (все поля являются взаимоисключающими). + /// + /// + internal class PhotoAttachmentRequestPayload + { + /// + /// Любой внешний URL изображения, которое вы хотите прикрепить. + /// + public string? Url { get; set; } + + /// + /// Токен существующего вложения. + /// + public string? Token { get; set; } + + /// + /// Токены, полученные после загрузки изображений. + /// + public PhotoToken? Photos { get; set; } + } + + /// + /// Токен загруженного изображения. + /// + /// + internal class PhotoToken + { + /// + /// Закодированная информация загруженного изображения. + /// + public string Token { get; set; } + } + + /// + /// Запрос на прикрепление видео. + /// + /// + internal class VideoAttachmentRequest : IAttachmentRequest + { + /// + public AttachmentType Type => AttachmentType.Video; + + /// + /// Данные загруженного видео. + /// + public UploadedInfo Payload { get; set; } + } + + /// + /// Информация о загруженном аудио/видео. + /// + /// + internal class UploadedInfo + { + /// + /// Токен — уникальный ID загруженного медиафайла. + /// + public string? Token { get; set; } + } + + /// + /// Запрос на прикрепление аудио. + /// + /// + internal class AudioAttachmentRequest : IAttachmentRequest + { + /// + public AttachmentType Type => AttachmentType.Audio; + + /// + /// Данные загруженного аудио. + /// + public UploadedInfo Payload { get; set; } + } + + /// + /// Запрос на прикрепление файла. + /// + /// + internal class FileAttachmentRequest : IAttachmentRequest + { + /// + public AttachmentType Type => AttachmentType.File; + + /// + /// Данные загруженного файла. + /// + public UploadedInfo Payload { get; set; } + } + + /// + /// Запрос на прикрепление стикера. + /// + /// + internal class StickerAttachmentRequest : IAttachmentRequest + { + /// + public AttachmentType Type => AttachmentType.Sticker; + + /// + /// Данные стикера. + /// + public StickerAttachmentRequestPayload Payload { get; set; } + } + + /// + /// Данные запроса на прикрепление стикера. + /// + /// + internal class StickerAttachmentRequestPayload + { + /// + /// Код стикера. + /// + public string Code { get; set; } + } + + /// + /// Запрос на прикрепление контакта. + /// + /// + internal class ContactAttachmentRequest : IAttachmentRequest + { + /// + public AttachmentType Type => AttachmentType.Contact; + + /// + /// Данные контакта. + /// + public ContactAttachmentRequestPayload Payload { get; set; } + } + + /// + /// Данные запроса на прикрепление контакта. + /// + /// + internal class ContactAttachmentRequestPayload + { + /// + /// Имя контакта. + /// + public string? Name { get; set; } + + /// + /// ID контакта, если он зарегистрирован в MAX. + /// + public long? ContactId { get; set; } + + /// + /// Полная информация о контакте в формате VCF. + /// + public string? VcfInfo { get; set; } + + /// + /// Телефон контакта в формате VCF. + /// + public string? VcfPhone { get; set; } + } + + /// + /// Запрос на прикрепление inline-клавиатуры. + /// + /// + internal class InlineKeyboardAttachmentRequest : IAttachmentRequest + { + /// + public AttachmentType Type => AttachmentType.InlineKeyboard; + + /// + /// Данные клавиатуры. + /// + public InlineKeyboardAttachmentRequestPayload Payload { get; set; } + } + + /// + /// Данные запроса на прикрепление inline-клавиатуры. + /// + /// + internal class InlineKeyboardAttachmentRequestPayload + { + /// + /// Двумерный массив кнопок. + /// + public IButton[][] Buttons { get; set; } + } + + /// + /// Запрос на прикрепление геолокации. + /// + /// + internal class LocationAttachmentRequest : IAttachmentRequest + { + /// + public AttachmentType Type => AttachmentType.Location; + + /// + /// Широта. + /// + public double Latitude { get; set; } + + /// + /// Долгота. + /// + public double Longitude { get; set; } + } + + /// + /// Запрос на прикрепление ссылки. + /// + /// + internal class ShareAttachmentRequest : IAttachmentRequest + { + /// + public AttachmentType Type => AttachmentType.Share; + + /// + /// Данные ссылки. + /// + public ShareAttachmentPayload Payload { get; set; } + } +} diff --git a/src/Max.BotClient/DTOs/Updates.cs b/src/Max.BotClient/DTOs/Updates.cs new file mode 100644 index 0000000..d63fc0f --- /dev/null +++ b/src/Max.BotClient/DTOs/Updates.cs @@ -0,0 +1,540 @@ +namespace Max.BotClient.DTOs +{ + /// + /// Тип обновления. + /// + internal enum UpdateType + { + MessageCreated, + MessageCallback, + MessageEdited, + MessageRemoved, + BotAdded, + BotRemoved, + DialogMuted, + DialogUnmuted, + DialogCleared, + DialogRemoved, + UserAdded, + UserRemoved, + BotStarted, + BotStopped, + ChatTitleChanged + } + + /// + /// Интерфейс обновления. + /// + /// + internal interface IUpdate + { + /// + /// Тип обновления. + /// + UpdateType UpdateType { get; } + + /// + /// Unix-время, когда произошло событие. + /// + long Timestamp { get; } + } + + /// + /// Данные callback. + /// + /// + internal class Callback + { + /// + /// Unix-время, когда пользователь нажал кнопку. + /// + public long Timestamp { get; set; } + + /// + /// Текущий ID клавиатуры. + /// + public string CallbackId { get; set; } + + /// + /// Токен кнопки. + /// + public string? Payload { get; set; } + + /// + /// Пользователь, нажавший на кнопку. + /// + public User User { get; set; } + } + + /// + /// Обновление о создании нового сообщения. + /// + /// + internal class MessageCreatedUpdate : IUpdate + { + /// + public UpdateType UpdateType => UpdateType.MessageCreated; + + /// + /// Unix-время, когда произошло событие. + /// + public long Timestamp { get; set; } + + /// + /// Новое созданное сообщение. + /// + public Message Message { get; set; } + + /// + /// Текущий язык пользователя в формате IETF BCP 47. Доступно только в диалогах. + /// + public string? UserLocale { get; set; } + } + + /// + /// Обновление о нажатии на callback-кнопку. + /// + /// + internal class MessageCallbackUpdate : IUpdate + { + /// + public UpdateType UpdateType => UpdateType.MessageCallback; + + /// + /// Unix-время, когда произошло событие. + /// + public long Timestamp { get; set; } + + /// + /// Данные callback. + /// + public Callback Callback { get; set; } + + /// + /// Изначальное сообщение, содержащее встроенную клавиатуру. Может быть null, если оно было удалено. + /// + public Message? Message { get; set; } + + /// + /// Текущий язык пользователя в формате IETF BCP 47. + /// + public string? UserLocale { get; set; } + } + + /// + /// Обновление о редактировании сообщения. + /// + /// + internal class MessageEditedUpdate : IUpdate + { + /// + public UpdateType UpdateType => UpdateType.MessageEdited; + + /// + /// Unix-время, когда произошло событие. + /// + public long Timestamp { get; set; } + + /// + /// Отредактированное сообщение. + /// + public Message Message { get; set; } + } + + /// + /// Обновление об удалении сообщения. + /// + /// + internal class MessageRemovedUpdate : IUpdate + { + /// + public UpdateType UpdateType => UpdateType.MessageRemoved; + + /// + /// Unix-время, когда произошло событие. + /// + public long Timestamp { get; set; } + + /// + /// ID удаленного сообщения. + /// + public string MessageId { get; set; } + + /// + /// ID чата, где сообщение было удалено. + /// + public long ChatId { get; set; } + + /// + /// Пользователь, удаливший сообщение. + /// + public long UserId { get; set; } + } + + /// + /// Обновление о добавлении бота в чат. + /// + /// + internal class BotAddedUpdate : IUpdate + { + /// + public UpdateType UpdateType => UpdateType.BotAdded; + + /// + /// Unix-время, когда произошло событие. + /// + public long Timestamp { get; set; } + + /// + /// ID чата, куда был добавлен бот. + /// + public long ChatId { get; set; } + + /// + /// Пользователь, добавивший бота в чат. + /// + public User User { get; set; } + + /// + /// Указывает, был ли бот добавлен в канал или нет. + /// + public bool IsChannel { get; set; } + } + + /// + /// Обновление об удалении бота из чата. + /// + /// + internal class BotRemovedUpdate : IUpdate + { + /// + public UpdateType UpdateType => UpdateType.BotRemoved; + + /// + /// Unix-время, когда произошло событие. + /// + public long Timestamp { get; set; } + + /// + /// ID чата, откуда был удалён бот. + /// + public long ChatId { get; set; } + + /// + /// Пользователь, удаливший бота из чата. + /// + public User User { get; set; } + + /// + /// Указывает, был ли бот удалён из канала или нет. + /// + public bool IsChannel { get; set; } + } + + /// + /// Обновление об отключении уведомлений в диалоге. + /// + /// + internal class DialogMutedUpdate : IUpdate + { + /// + public UpdateType UpdateType => UpdateType.DialogMuted; + + /// + /// Unix-время, когда произошло событие. + /// + public long Timestamp { get; set; } + + /// + /// ID чата, где произошло событие. + /// + public long ChatId { get; set; } + + /// + /// Пользователь, который отключил уведомления. + /// + public User User { get; set; } + + /// + /// Время в формате Unix, до наступления которого диалог был отключён. + /// + public long MutedUntil { get; set; } + + /// + /// Текущий язык пользователя в формате IETF BCP 47. + /// + public string? UserLocale { get; set; } + } + + /// + /// Обновление о включении уведомлений в диалоге. + /// + /// + internal class DialogUnmutedUpdate : IUpdate + { + /// + public UpdateType UpdateType => UpdateType.DialogUnmuted; + + /// + /// Unix-время, когда произошло событие. + /// + public long Timestamp { get; set; } + + /// + /// ID чата, где произошло событие. + /// + public long ChatId { get; set; } + + /// + /// Пользователь, который включил уведомления. + /// + public User User { get; set; } + + /// + /// Текущий язык пользователя в формате IETF BCP 47. + /// + public string? UserLocale { get; set; } + } + + /// + /// Обновление об очистке диалога. + /// + /// + internal class DialogClearedUpdate : IUpdate + { + /// + public UpdateType UpdateType => UpdateType.DialogCleared; + + /// + /// Unix-время, когда произошло событие. + /// + public long Timestamp { get; set; } + + /// + /// ID чата, где произошло событие. + /// + public long ChatId { get; set; } + + /// + /// Пользователь, который очистил диалог. + /// + public User User { get; set; } + + /// + /// Текущий язык пользователя в формате IETF BCP 47. + /// + public string? UserLocale { get; set; } + } + + /// + /// Обновление об удалении диалога. + /// + /// + internal class DialogRemovedUpdate : IUpdate + { + /// + public UpdateType UpdateType => UpdateType.DialogRemoved; + + /// + /// Unix-время, когда произошло событие. + /// + public long Timestamp { get; set; } + + /// + /// ID чата, где произошло событие. + /// + public long ChatId { get; set; } + + /// + /// Пользователь, который удалил чат. + /// + public User User { get; set; } + + /// + /// Текущий язык пользователя в формате IETF BCP 47. + /// + public string? UserLocale { get; set; } + } + + /// + /// Обновление о добавлении пользователя в чат. + /// + /// + internal class UserAddedUpdate : IUpdate + { + /// + public UpdateType UpdateType => UpdateType.UserAdded; + + /// + /// Unix-время, когда произошло событие. + /// + public long Timestamp { get; set; } + + /// + /// ID чата, где произошло событие. + /// + public long ChatId { get; set; } + + /// + /// Пользователь, добавленный в чат. + /// + public User User { get; set; } + + /// + /// Пользователь, который добавил пользователя в чат. Может быть null, если пользователь присоединился по ссылке. + /// + public long? InviterId { get; set; } + + /// + /// Указывает, был ли пользователь добавлен в канал или нет. + /// + public bool IsChannel { get; set; } + } + + /// + /// Обновление об удалении пользователя из чата. + /// + /// + internal class UserRemovedUpdate : IUpdate + { + /// + public UpdateType UpdateType => UpdateType.UserRemoved; + + /// + /// Unix-время, когда произошло событие. + /// + public long Timestamp { get; set; } + + /// + /// ID чата, где произошло событие. + /// + public long ChatId { get; set; } + + /// + /// Пользователь, удалённый из чата. + /// + public User User { get; set; } + + /// + /// Администратор, который удалил пользователя из чата. Может быть null, если пользователь покинул чат сам. + /// + public long? AdminId { get; set; } + + /// + /// Указывает, был ли пользователь удалён из канала или нет. + /// + public bool IsChannel { get; set; } + } + + /// + /// Обновление о запуске бота пользователем. + /// + /// + internal class BotStartedUpdate : IUpdate + { + /// + public UpdateType UpdateType => UpdateType.BotStarted; + + /// + /// Unix-время, когда произошло событие. + /// + public long Timestamp { get; set; } + + /// + /// ID диалога, где произошло событие. + /// + public long ChatId { get; set; } + + /// + /// Пользователь, который нажал кнопку 'Start'. + /// + public User User { get; set; } + + /// + /// Дополнительные данные из дип-линков, переданные при запуске бота (до 512 символов). + /// + public string? Payload { get; set; } + + /// + /// Текущий язык пользователя в формате IETF BCP 47. + /// + public string? UserLocale { get; set; } + } + + /// + /// Обновление об остановке бота пользователем. + /// + /// + internal class BotStoppedUpdate : IUpdate + { + /// + public UpdateType UpdateType => UpdateType.BotStopped; + + /// + /// Unix-время, когда произошло событие. + /// + public long Timestamp { get; set; } + + /// + /// ID диалога, где произошло событие. + /// + public long ChatId { get; set; } + + /// + /// Пользователь, который остановил бота. + /// + public User User { get; set; } + + /// + /// Текущий язык пользователя в формате IETF BCP 47. + /// + public string? UserLocale { get; set; } + } + + /// + /// Обновление об изменении названия чата. + /// + /// + internal class ChatTitleChangedUpdate : IUpdate + { + /// + public UpdateType UpdateType => UpdateType.ChatTitleChanged; + + /// + /// Unix-время, когда произошло событие. + /// + public long Timestamp { get; set; } + + /// + /// ID чата, где произошло событие. + /// + public long ChatId { get; set; } + + /// + /// Пользователь, который изменил название. + /// + public User User { get; set; } + + /// + /// Новое название. + /// + public string Title { get; set; } + } + + /// + /// Ответ с обновлениями. + /// + /// + internal class GetUpdatesResponse + { + /// + /// Список обновлений. + /// + public IUpdate[] Updates { get; set; } = []; + + /// + /// Маркер для следующего запроса. + /// + public long? Marker { get; set; } + } +} diff --git a/src/Max.BotClient/DTOs/User.cs b/src/Max.BotClient/DTOs/User.cs new file mode 100644 index 0000000..6ac5582 --- /dev/null +++ b/src/Max.BotClient/DTOs/User.cs @@ -0,0 +1,98 @@ +using System; + +namespace Max.BotClient.DTOs +{ + /// + /// Пользователь или бот. + /// + /// + internal class User + { + /// + /// Идентификатор пользователя или бота. + /// + public long UserId { get; set; } + + /// + /// Отображаемое имя пользователя или бота. + /// + public string FirstName { get; set; } + + /// + /// Отображаемая фамилия пользователя. Для ботов это поле не возвращается. + /// + public string? LastName { get; set; } + + /// + /// Публичный username пользователя. Может быть null, если недоступен. + /// + public string? Username { get; set; } + + /// + /// Признак того, что аккаунт является ботом. + /// + public bool IsBot { get; set; } + + /// + /// Время последней активности пользователя (Unix timestamp в миллисекундах). + /// Может отсутствовать из-за настроек приватности. + /// + public long? LastActivityTime { get; set; } + + /// + /// Устаревшее поле, скоро будет удалено. + /// + [Obsolete("Deprecated field, will be removed soon")] + public string? Name { get; set; } + } + + /// + /// Пользователь с фото. + /// + /// + internal class UserWithPhoto : User + { + /// + /// Описание пользователя или бота. Может быть null, если описание не заполнено. + /// + public string? Description { get; set; } + + /// + /// URL аватара пользователя или бота в уменьшенном размере. + /// + public string? AvatarUrl { get; set; } + + /// + /// URL аватара пользователя или бота в полном размере. + /// + public string? FullAvatarUrl { get; set; } + } + + /// + /// Информация о боте (ответ GET /me). + /// + /// + internal class UserBotInfo : UserWithPhoto + { + /// + /// Команды бота (максимум 32). + /// + public BotCommandInfo[]? Commands { get; set; } + } + + /// + /// Команда бота. + /// + internal class BotCommandInfo + { + /// + /// Название команды. + /// + public string Name { get; set; } + + /// + /// Описание команды. + /// + public string? Description { get; set; } + } +} diff --git a/src/Max.BotClient/DTOs/Video.cs b/src/Max.BotClient/DTOs/Video.cs new file mode 100644 index 0000000..e32c50e --- /dev/null +++ b/src/Max.BotClient/DTOs/Video.cs @@ -0,0 +1,88 @@ +using System.Text.Json.Serialization; + +namespace Max.BotClient.DTOs +{ + /// + /// URL-адреса для скачивания или воспроизведения видео. + /// + internal class VideoUrls + { + /// + /// URL видео в формате MP4 с разрешением 1080p, если доступно. + /// + [JsonPropertyName("mp4_1080")] + public string? Mp4Resolution1080p { get; set; } + + /// + /// URL видео в формате MP4 с разрешением 720p, если доступно. + /// + [JsonPropertyName("mp4_720")] + public string? Mp4Resolution720p { get; set; } + + /// + /// URL видео в формате MP4 с разрешением 480p, если доступно. + /// + [JsonPropertyName("mp4_480")] + public string? Mp4Resolution480p { get; set; } + + /// + /// URL видео в формате MP4 с разрешением 360p, если доступно. + /// + [JsonPropertyName("mp4_360")] + public string? Mp4Resolution360p { get; set; } + + /// + /// URL видео в формате MP4 с разрешением 240p, если доступно. + /// + [JsonPropertyName("mp4_240")] + public string? Mp4Resolution240p { get; set; } + + /// + /// URL видео в формате MP4 с разрешением 144p, если доступно. + /// + [JsonPropertyName("mp4_144")] + public string? Mp4Resolution144p { get; set; } + + /// + /// URL потоковой трансляции в формате HLS, если доступна. + /// + [JsonPropertyName("hls")] + public string? HlsStream { get; set; } + } + + /// + /// Подробная информация о прикреплённом видео (DTO). + /// + internal class VideoInfo + { + /// + /// Токен видео-вложения. + /// + public string? Token { get; set; } + + /// + /// URL-адреса для скачивания или воспроизведения видео. + /// + public VideoUrls? Urls { get; set; } + + /// + /// Миниатюра видео. + /// + public PhotoAttachmentPayload? Thumbnail { get; set; } + + /// + /// Ширина видео. + /// + public int? Width { get; set; } + + /// + /// Высота видео. + /// + public int? Height { get; set; } + + /// + /// Длина видео в секундах. + /// + public int? Duration { get; set; } + } +} diff --git a/src/Max.BotClient/Mapping/AttachmentMappingExtensions.cs b/src/Max.BotClient/Mapping/AttachmentMappingExtensions.cs new file mode 100644 index 0000000..55c97f3 --- /dev/null +++ b/src/Max.BotClient/Mapping/AttachmentMappingExtensions.cs @@ -0,0 +1,199 @@ +using Max.BotClient.DTOs; +using Max.BotClient.Types; + +namespace Max.BotClient.Mapping +{ + /// + /// Методы расширения для маппинга типов Attachment между DTOs и Types. + /// + internal static class AttachmentMappingExtensions + { + /// + /// Преобразует DTO IAttachment[] в Types Attachment[] (интерфейсы в конкретные классы). + /// + public static Types.Attachment[]? ToAttachments(this DTOs.IAttachment[]? dtos) + { + if (dtos == null) return null; + + var result = new Types.Attachment[dtos.Length]; + for (int i = 0; i < dtos.Length; i++) + { + result[i] = dtos[i].ToAttachment(); + } + return result; + } + + /// + /// Преобразует одно DTO IAttachment в Types Attachment. + /// + private static Types.Attachment ToAttachment(this DTOs.IAttachment dto) + { + var result = new Types.Attachment + { + Type = (Types.AttachmentType?)dto.Type + }; + + switch (dto) + { + case DTOs.PhotoAttachment photo: + result.PhotoId = photo.Payload?.PhotoId; + result.Url = photo.Payload?.Url; + result.Token = photo.Payload?.Token; + break; + + case DTOs.VideoAttachment video: + result.Url = video.Payload?.Url; + result.Token = video.Payload?.Token; + result.Width = video.Width; + result.Height = video.Height; + result.Duration = video.Duration; + result.ThumbnailUrl = video.Thumbnail?.Url; + break; + + case DTOs.AudioAttachment audio: + result.Url = audio.Payload?.Url; + result.Token = audio.Payload?.Token; + result.Transcription = audio.Transcription; + break; + + case DTOs.FileAttachment file: + result.Url = file.Payload?.Url; + result.Token = file.Payload?.Token; + result.Filename = file.Filename; + result.Size = file.Size; + break; + + case DTOs.StickerAttachment sticker: + result.Url = sticker.Payload?.Url; + result.Code = sticker.Payload?.Code; + result.Width = sticker.Width; + result.Height = sticker.Height; + break; + + case DTOs.ContactAttachment contact: + result.VcfInfo = contact.Payload?.VcfInfo; + result.ContactUser = contact.Payload?.MaxInfo?.ToUser(); + break; + + case DTOs.ShareAttachment share: + result.Url = share.Payload?.Url; + result.Token = share.Payload?.Token; + result.Title = share.Title; + result.Description = share.Description; + result.ImageUrl = share.ImageUrl; + break; + + case DTOs.LocationAttachment location: + result.Latitude = location.Latitude; + result.Longitude = location.Longitude; + break; + + case DTOs.InlineKeyboardAttachment keyboard: + result.Buttons = keyboard.Payload?.Buttons?.ToButtons(); + break; + } + + return result; + } + + /// + /// Преобразует DTO IMarkupElement[] в Types MarkupElement[]. + /// + public static Types.MarkupElement[]? ToMarkupElements(this IMarkupElement[]? dtos) + { + if (dtos == null) return null; + + var result = new Types.MarkupElement[dtos.Length]; + for (int i = 0; i < dtos.Length; i++) + { + result[i] = dtos[i].ToMarkupElement(); + } + return result; + } + + /// + /// Преобразует один DTO IMarkupElement в Types MarkupElement. + /// + private static Types.MarkupElement ToMarkupElement(this IMarkupElement dto) + { + var result = new Types.MarkupElement + { + Type = (Types.MarkupType?)dto.Type, + From = dto.From, + Length = dto.Length + }; + + if (dto is DTOs.LinkMarkupElement link) + { + result.Url = link.Url; + } + else if (dto is DTOs.UserMentionMarkupElement mention) + { + result.UserLink = mention.UserLink; + result.UserId = mention.UserId; + } + + return result; + } + + /// + /// Преобразует DTO IButton[][] в Types Button[][]. + /// + public static Types.Button[][]? ToButtons(this IButton[][]? dtos) + { + if (dtos == null) return null; + + var result = new Types.Button[dtos.Length][]; + for (int i = 0; i < dtos.Length; i++) + { + if (dtos[i] != null) + { + result[i] = new Types.Button[dtos[i].Length]; + for (int j = 0; j < dtos[i].Length; j++) + { + result[i][j] = dtos[i][j].ToButton(); + } + } + else + { + result[i] = null!; + } + } + return result; + } + + /// + /// Преобразует одну DTO IButton в Types Button. + /// + private static Types.Button ToButton(this IButton dto) + { + var result = new Types.Button + { + Type = (Types.ButtonType?)dto.Type, + Text = dto.Text + }; + + switch (dto) + { + case DTOs.CallbackButton callback: + result.Payload = callback.Payload; + break; + + case DTOs.LinkButton link: + result.Url = link.Url; + break; + + case DTOs.RequestGeoLocationButton geo: + result.Quick = geo.Quick; + break; + + case DTOs.OpenAppButton app: + result.WebApp = app.WebApp; + result.ContactId = app.ContactId; + break; + } + + return result; + } + } +} diff --git a/src/Max.BotClient/Mapping/ChatMappingExtensions.cs b/src/Max.BotClient/Mapping/ChatMappingExtensions.cs new file mode 100644 index 0000000..0b6c1b2 --- /dev/null +++ b/src/Max.BotClient/Mapping/ChatMappingExtensions.cs @@ -0,0 +1,164 @@ +using System.Linq; + +namespace Max.BotClient.Mapping +{ + /// + /// Методы расширения для маппинга типов Chat между DTOs и Types. + /// + internal static class ChatMappingExtensions + { + /// + /// Преобразует DTO Chat в Types Chat. + /// + public static Types.Chat? ToChat(this DTOs.Chat? dto) + { + if (dto == null) return null; + + return new Types.Chat + { + ChatId = dto.ChatId, + Type = (Types.ChatType)dto.Type, + Status = (Types.ChatStatus)dto.Status, + Title = dto.Title, + Icon = dto.Icon?.ToImage(), + LastEventTime = dto.LastEventTime, + ParticipantsCount = dto.ParticipantsCount, + OwnerId = dto.OwnerId, + Participants = dto.Participants, + IsPublic = dto.IsPublic, + Link = dto.Link, + Description = dto.Description, + DialogWithUser = dto.DialogWithUser?.ToUserWithPhoto(), + ChatMessageId = dto.ChatMessageId, + PinnedMessage = dto.PinnedMessage?.ToMessage() + }; + } + + /// + /// Преобразует массив DTO Chats в Types Chats. + /// + public static Types.Chat[]? ToChats(this DTOs.Chat[]? dtos) + { + if (dtos == null) return null; + + var result = new Types.Chat[dtos.Length]; + for (int i = 0; i < dtos.Length; i++) + { + result[i] = dtos[i].ToChat()!; + } + return result; + } + + /// + /// Преобразует DTO Image в Types Image. + /// + public static Types.Image? ToImage(this DTOs.Image? dto) + { + if (dto == null) return null; + + return new Types.Image + { + Url = dto.Url + }; + } + + /// + /// Преобразует DTO GetChatsResponse в Types GetChatsResponse. + /// + public static Types.GetChatsResponse? ToGetChatsResponse(this DTOs.GetChatsResponse? dto) + { + if (dto == null) return null; + + return new Types.GetChatsResponse + { + Chats = dto.Chats?.ToChats(), + Marker = dto.Marker + }; + } + + /// + /// Преобразует DTO GetPinnedMessageResponse в Types GetPinnedMessageResponse. + /// + public static Types.GetPinnedMessageResponse? ToGetPinnedMessageResponse(this DTOs.GetPinnedMessageResponse? dto) + { + if (dto == null) return null; + + return new Types.GetPinnedMessageResponse + { + Message = dto.Message?.ToMessage() + }; + } + + /// + /// Преобразует DTO ChatMember в Types ChatMember. + /// + public static Types.ChatMember? ToChatMember(this DTOs.ChatMember? dto) + { + if (dto == null) return null; + + return new Types.ChatMember + { + UserId = dto.UserId, + FirstName = dto.FirstName, + LastName = dto.LastName, + Username = dto.Username, + IsBot = dto.IsBot, + LastActivityTime = dto.LastActivityTime, + Description = dto.Description, + AvatarUrl = dto.AvatarUrl, + FullAvatarUrl = dto.FullAvatarUrl, + LastAccessTime = dto.LastAccessTime, + IsOwner = dto.IsOwner, + IsAdmin = dto.IsAdmin, + JoinTime = dto.JoinTime, + Permissions = dto.Permissions?.Select(p => (Types.ChatAdminPermission)p).ToArray(), + Alias = dto.Alias + }; + } + + /// + /// Преобразует массив DTO ChatMember в Types ChatMember. + /// + public static Types.ChatMember[]? ToChatMembers(this DTOs.ChatMember[]? dtos) + { + if (dtos == null) return null; + + return dtos.Select(d => d.ToChatMember()!).ToArray(); + } + + /// + /// Преобразует DTO GetChatMembersResponse в Types GetChatMembersResponse. + /// + public static Types.GetChatMembersResponse? ToGetChatMembersResponse(this DTOs.GetChatMembersResponse? dto) + { + if (dto == null) return null; + + return new Types.GetChatMembersResponse + { + Members = dto.Members?.ToChatMembers(), + Marker = dto.Marker + }; + } + + /// + /// Преобразует Types ChatAdmin в DTO ChatAdmin. + /// + public static DTOs.ChatAdmin ToDto(this Types.ChatAdmin admin) + { + return new DTOs.ChatAdmin + { + UserId = admin.UserId, + Permissions = admin.Permissions?.Select(p => (DTOs.ChatAdminPermission)p).ToArray(), + Alias = admin.Alias + }; + } + + /// + /// Преобразует массив Types ChatAdmin в DTO. + /// + public static DTOs.ChatAdmin[] ToDto(this Types.ChatAdmin[] admins) + { + return admins.Select(a => a.ToDto()).ToArray(); + } + } +} diff --git a/src/Max.BotClient/Mapping/MessageMappingExtensions.cs b/src/Max.BotClient/Mapping/MessageMappingExtensions.cs new file mode 100644 index 0000000..2fc9c21 --- /dev/null +++ b/src/Max.BotClient/Mapping/MessageMappingExtensions.cs @@ -0,0 +1,69 @@ +using Max.BotClient.DTOs; +using Max.BotClient.Types; + +namespace Max.BotClient.Mapping +{ + /// + /// Методы расширения для маппинга типов Message между DTOs и Types. + /// + internal static class MessageMappingExtensions + { + /// + /// Преобразует DTO Message в Types Message (выравнивание вложенной структуры). + /// + public static Types.Message? ToMessage(this DTOs.Message? dto) + { + if (dto == null) return null; + + return new Types.Message + { + // Sender (преобразование в Types.User) + Sender = dto.Sender?.ToUser(), + + // Recipient (выравнивание) + RecipientChatId = dto.Recipient?.ChatId, + RecipientChatType = (Types.ChatType?)dto.Recipient?.ChatType, + RecipientUserId = dto.Recipient?.UserId, + + // Свойства уровня сообщения + Timestamp = dto.Timestamp, + Url = dto.Url, + + // Link (выравнивание LinkedMessage) + LinkType = (Types.MessageLinkType?)dto.Link?.Type, + LinkSender = dto.Link?.Sender?.ToUser(), + LinkChatId = dto.Link?.ChatId, + LinkMid = dto.Link?.Message?.Mid, + LinkSeq = dto.Link?.Message?.Seq, + LinkText = dto.Link?.Message?.Text, + LinkAttachments = dto.Link?.Message?.Attachments?.ToAttachments(), + LinkMarkup = dto.Link?.Message?.Markup?.ToMarkupElements(), + + // Body (выравнивание MessageBody) + Mid = dto.Body?.Mid, + Seq = dto.Body?.Seq, + Text = dto.Body?.Text, + Attachments = dto.Body?.Attachments?.ToAttachments(), + Markup = dto.Body?.Markup?.ToMarkupElements(), + + // Stat (выравнивание MessageStat) + Views = dto.Stat?.Views + }; + } + + /// + /// Преобразует массив DTO Messages в Types Messages. + /// + public static Types.Message[]? ToMessages(this DTOs.Message[]? dtos) + { + if (dtos == null) return null; + + var result = new Types.Message[dtos.Length]; + for (int i = 0; i < dtos.Length; i++) + { + result[i] = dtos[i].ToMessage()!; + } + return result; + } + } +} diff --git a/src/Max.BotClient/Mapping/RequestMappingExtensions.cs b/src/Max.BotClient/Mapping/RequestMappingExtensions.cs new file mode 100644 index 0000000..40c80dc --- /dev/null +++ b/src/Max.BotClient/Mapping/RequestMappingExtensions.cs @@ -0,0 +1,234 @@ +using Max.BotClient.DTOs; +using Max.BotClient.Types; + +namespace Max.BotClient.Mapping +{ + /// + /// Методы расширения для маппинга типов Request из Types в DTOs. + /// + internal static class RequestMappingExtensions + { + /// + /// Преобразует Types NewMessageBody в DTO NewMessageBody. + /// + public static DTOs.NewMessageBody ToDto(this Types.NewMessageBody? type) + { + if (type == null) return null!; + + return new DTOs.NewMessageBody + { + Text = type.Text, + Attachments = type.Attachments?.ToDtoAttachments(), + Link = type.Link?.ToDto(), + Notify = type.Notify, + Format = (DTOs.TextFormat?)type.Format + }; + } + + /// + /// Преобразует Types NewMessageLink в DTO NewMessageLink. + /// + public static DTOs.NewMessageLink? ToDto(this Types.NewMessageLink? type) + { + if (type == null) return null; + + return new DTOs.NewMessageLink + { + Type = (DTOs.MessageLinkType)type.Type, + Mid = type.Mid + }; + } + + /// + /// Преобразует Types AttachmentRequest[] в DTO IAttachmentRequest[] (конкретные классы в интерфейсы). + /// + public static IAttachmentRequest[]? ToDtoAttachments(this Types.AttachmentRequest[]? types) + { + if (types == null) return null; + + var result = new IAttachmentRequest[types.Length]; + for (int i = 0; i < types.Length; i++) + { + result[i] = types[i].ToDtoAttachment(); + } + return result; + } + + /// + /// Преобразует один Types AttachmentRequest в DTO IAttachmentRequest. + /// + private static IAttachmentRequest ToDtoAttachment(this Types.AttachmentRequest type) + { + switch (type.Type) + { + case Types.AttachmentRequestType.Image: + return new PhotoAttachmentRequest + { + Payload = new PhotoAttachmentRequestPayload + { + Url = type.Payload?.Url, + Token = type.Payload?.Token, + Photos = type.Payload?.Token != null + ? new DTOs.PhotoToken { Token = type.Payload.Token } + : null + } + }; + + case Types.AttachmentRequestType.Video: + return new VideoAttachmentRequest + { + Payload = new UploadedInfo + { + Token = type.Payload?.Token + } + }; + + case Types.AttachmentRequestType.Audio: + return new AudioAttachmentRequest + { + Payload = new UploadedInfo + { + Token = type.Payload?.Token + } + }; + + case Types.AttachmentRequestType.File: + return new FileAttachmentRequest + { + Payload = new UploadedInfo + { + Token = type.Payload?.Token + } + }; + + case Types.AttachmentRequestType.Sticker: + return new StickerAttachmentRequest + { + Payload = new StickerAttachmentRequestPayload + { + Code = type.Payload?.Code! + } + }; + + case Types.AttachmentRequestType.Contact: + return new ContactAttachmentRequest + { + Payload = new ContactAttachmentRequestPayload + { + Name = type.Payload?.Name, + ContactId = type.Payload?.ContactId, + VcfInfo = type.Payload?.VcfInfo, + VcfPhone = type.Payload?.VcfPhone + } + }; + + case Types.AttachmentRequestType.Location: + return new LocationAttachmentRequest + { + Latitude = type.Payload?.Latitude ?? 0, + Longitude = type.Payload?.Longitude ?? 0 + }; + + case Types.AttachmentRequestType.Share: + return new ShareAttachmentRequest + { + Payload = new ShareAttachmentPayload + { + Url = type.Payload?.ShareUrl + } + }; + + case Types.AttachmentRequestType.InlineKeyboard: + return new InlineKeyboardAttachmentRequest + { + Payload = new InlineKeyboardAttachmentRequestPayload + { + Buttons = type.Payload?.Buttons?.ToDtoButtons()! + } + }; + + default: + return null!; + } + } + + /// + /// Преобразует Types ButtonRequest[][] в DTO IButton[][]. + /// + private static IButton[][]? ToDtoButtons(this Types.ButtonRequest[][]? types) + { + if (types == null) return null; + + var result = new IButton[types.Length][]; + for (int i = 0; i < types.Length; i++) + { + if (types[i] != null) + { + result[i] = new IButton[types[i].Length]; + for (int j = 0; j < types[i].Length; j++) + { + result[i][j] = types[i][j].ToDtoButton(); + } + } + else + { + result[i] = null!; + } + } + return result; + } + + /// + /// Преобразует один Types ButtonRequest в DTO IButton. + /// + private static IButton ToDtoButton(this Types.ButtonRequest type) + { + switch (type.Type) + { + case Types.ButtonRequestType.Callback: + return new CallbackButton + { + Text = type.Text, + Payload = type.Payload! + }; + + case Types.ButtonRequestType.Link: + return new LinkButton + { + Text = type.Text, + Url = type.Url! + }; + + case Types.ButtonRequestType.RequestGeoLocation: + return new RequestGeoLocationButton + { + Text = type.Text, + Quick = type.Quick + }; + + case Types.ButtonRequestType.RequestContact: + return new RequestContactButton + { + Text = type.Text + }; + + case Types.ButtonRequestType.OpenApp: + return new OpenAppButton + { + Text = type.Text, + WebApp = type.WebApp, + ContactId = type.ChatId + }; + + case Types.ButtonRequestType.Message: + return new MessageButton + { + Text = type.Text + }; + + default: + return null!; + } + } + } +} diff --git a/src/Max.BotClient/Mapping/ResponseMappingExtensions.cs b/src/Max.BotClient/Mapping/ResponseMappingExtensions.cs new file mode 100644 index 0000000..1077631 --- /dev/null +++ b/src/Max.BotClient/Mapping/ResponseMappingExtensions.cs @@ -0,0 +1,231 @@ +using Max.BotClient.DTOs; +using Max.BotClient.Types; + +namespace Max.BotClient.Mapping +{ + /// + /// Методы расширения для маппинга типов Response из DTOs в Types. + /// + internal static class ResponseMappingExtensions + { + /// + /// Преобразует DTO GetMessagesResponse в Types GetMessagesResponse. + /// + public static Types.GetMessagesResponse ToResponse(this DTOs.GetMessagesResponse? dto) + { + if (dto == null) return null!; + + return new Types.GetMessagesResponse + { + Messages = dto.Messages?.ToMessages() + }; + } + + /// + /// Преобразует DTO SendMessageResponse в Types SendMessageResponse. + /// + public static Types.SendMessageResponse ToResponse(this DTOs.SendMessageResponse? dto) + { + if (dto == null) return null!; + + return new Types.SendMessageResponse + { + Message = dto.Message?.ToMessage() + }; + } + + /// + /// Преобразует DTO GetUpdatesResponse в Types GetUpdatesResponse. + /// + public static Types.GetUpdatesResponse ToResponse(this DTOs.GetUpdatesResponse? dto) + { + if (dto == null) return null!; + + return new Types.GetUpdatesResponse + { + Updates = dto.Updates?.ToUpdates() ?? System.Array.Empty(), + Marker = dto.Marker + }; + } + + /// + /// Преобразует DTO IUpdate[] в Types Update[]. + /// + private static Types.Update[]? ToUpdates(this IUpdate[]? dtos) + { + if (dtos == null) return null; + + var result = new Types.Update[dtos.Length]; + for (int i = 0; i < dtos.Length; i++) + { + result[i] = dtos[i].ToUpdate(); + } + return result; + } + + /// + /// Преобразует одно DTO IUpdate в Types Update. + /// + private static Types.Update ToUpdate(this IUpdate dto) + { + var result = new Types.Update + { + UpdateType = (Types.UpdateType)dto.UpdateType, + Timestamp = dto.Timestamp + }; + + switch (dto) + { + case MessageCreatedUpdate messageCreated: + result.Message = messageCreated.Message?.ToMessage(); + result.UserLocale = messageCreated.UserLocale; + break; + + case MessageCallbackUpdate messageCallback: + result.Callback = messageCallback.Callback?.ToCallback(); + result.Message = messageCallback.Message?.ToMessage(); + result.UserLocale = messageCallback.UserLocale; + break; + + case MessageEditedUpdate messageEdited: + result.Message = messageEdited.Message?.ToMessage(); + break; + + case MessageRemovedUpdate messageRemoved: + result.MessageId = messageRemoved.MessageId; + result.ChatId = messageRemoved.ChatId; + result.UserId = messageRemoved.UserId; + break; + + case BotAddedUpdate botAdded: + result.ChatId = botAdded.ChatId; + result.User = botAdded.User?.ToUser(); + result.IsChannel = botAdded.IsChannel; + break; + + case BotRemovedUpdate botRemoved: + result.ChatId = botRemoved.ChatId; + result.User = botRemoved.User?.ToUser(); + result.IsChannel = botRemoved.IsChannel; + break; + + case DialogMutedUpdate dialogMuted: + result.ChatId = dialogMuted.ChatId; + result.User = dialogMuted.User?.ToUser(); + result.MutedUntil = dialogMuted.MutedUntil; + result.UserLocale = dialogMuted.UserLocale; + break; + + case DialogUnmutedUpdate dialogUnmuted: + result.ChatId = dialogUnmuted.ChatId; + result.User = dialogUnmuted.User?.ToUser(); + result.UserLocale = dialogUnmuted.UserLocale; + break; + + case DialogClearedUpdate dialogCleared: + result.ChatId = dialogCleared.ChatId; + result.User = dialogCleared.User?.ToUser(); + result.UserLocale = dialogCleared.UserLocale; + break; + + case DialogRemovedUpdate dialogRemoved: + result.ChatId = dialogRemoved.ChatId; + result.User = dialogRemoved.User?.ToUser(); + result.UserLocale = dialogRemoved.UserLocale; + break; + + case UserAddedUpdate userAdded: + result.ChatId = userAdded.ChatId; + result.User = userAdded.User?.ToUser(); + result.InviterId = userAdded.InviterId; + result.IsChannel = userAdded.IsChannel; + break; + + case UserRemovedUpdate userRemoved: + result.ChatId = userRemoved.ChatId; + result.User = userRemoved.User?.ToUser(); + result.AdminId = userRemoved.AdminId; + result.IsChannel = userRemoved.IsChannel; + break; + + case BotStartedUpdate botStarted: + result.ChatId = botStarted.ChatId; + result.User = botStarted.User?.ToUser(); + result.Payload = botStarted.Payload; + result.UserLocale = botStarted.UserLocale; + break; + + case BotStoppedUpdate botStopped: + result.ChatId = botStopped.ChatId; + result.User = botStopped.User?.ToUser(); + result.UserLocale = botStopped.UserLocale; + break; + + case ChatTitleChangedUpdate chatTitleChanged: + result.ChatId = chatTitleChanged.ChatId; + result.User = chatTitleChanged.User?.ToUser(); + result.Title = chatTitleChanged.Title; + break; + } + + return result; + } + + /// + /// Преобразует DTO Callback в Types Callback. + /// + private static Types.Callback? ToCallback(this DTOs.Callback? dto) + { + if (dto == null) return null; + + return new Types.Callback + { + Timestamp = dto.Timestamp, + CallbackId = dto.CallbackId, + Payload = dto.Payload, + User = dto.User?.ToUser() + }; + } + + /// + /// Преобразует DTO UserBotInfo в Types BotInfo. + /// + public static Types.BotInfo ToBotInfo(this DTOs.UserBotInfo? dto) + { + if (dto == null) return null!; + + return new Types.BotInfo + { + UserId = dto.UserId, + FirstName = dto.FirstName, + LastName = dto.LastName, + Username = dto.Username, + IsBot = dto.IsBot, + LastActivityTime = dto.LastActivityTime, + Description = dto.Description, + AvatarUrl = dto.AvatarUrl, + FullAvatarUrl = dto.FullAvatarUrl, + Commands = dto.Commands?.ToBotCommands() + }; + } + + /// + /// Преобразует DTO BotCommandInfo[] в Types BotCommand[]. + /// + private static Types.BotCommand[]? ToBotCommands(this DTOs.BotCommandInfo[]? dtos) + { + if (dtos == null) return null; + + var result = new Types.BotCommand[dtos.Length]; + for (int i = 0; i < dtos.Length; i++) + { + result[i] = new Types.BotCommand + { + Name = dtos[i].Name, + Description = dtos[i].Description + }; + } + return result; + } + } +} diff --git a/src/Max.BotClient/Mapping/UniversalMappingExtensions.cs b/src/Max.BotClient/Mapping/UniversalMappingExtensions.cs new file mode 100644 index 0000000..04e7b73 --- /dev/null +++ b/src/Max.BotClient/Mapping/UniversalMappingExtensions.cs @@ -0,0 +1,63 @@ +using System; + +namespace Max.BotClient.Mapping +{ + /// + /// Универсальный маппер для преобразования DTO типов в Types. + /// + internal static class UniversalMappingExtensions + { + /// + /// Универсальный метод маппинга, преобразующий DTO типы в Types с использованием pattern matching. + /// Используется в ApiExtensions для маппинга ответов API. + /// + public static TResult ToResult(this TDto dto) + { + return (dto, typeof(TResult)) switch + { + // Messages + (DTOs.GetMessagesResponse msgs, _) when typeof(TResult) == typeof(Types.GetMessagesResponse) + => (TResult)(object)msgs.ToResponse(), + + (DTOs.SendMessageResponse msg, _) when typeof(TResult) == typeof(Types.SendMessageResponse) + => (TResult)(object)msg.ToResponse(), + + (DTOs.Message message, _) when typeof(TResult) == typeof(Types.Message) + => (TResult)(object)message.ToMessage()!, + + // Updates + (DTOs.GetUpdatesResponse upd, _) when typeof(TResult) == typeof(Types.GetUpdatesResponse) + => (TResult)(object)upd.ToResponse(), + + // Bot info + (DTOs.UserBotInfo bot, _) when typeof(TResult) == typeof(Types.BotInfo) + => (TResult)(object)bot.ToBotInfo(), + + // Chats + (DTOs.Chat chat, _) when typeof(TResult) == typeof(Types.Chat) + => (TResult)(object)chat.ToChat()!, + + (DTOs.GetChatsResponse chats, _) when typeof(TResult) == typeof(Types.GetChatsResponse) + => (TResult)(object)chats.ToGetChatsResponse()!, + + (DTOs.GetPinnedMessageResponse pin, _) when typeof(TResult) == typeof(Types.GetPinnedMessageResponse) + => (TResult)(object)pin.ToGetPinnedMessageResponse()!, + + // Members + (DTOs.ChatMember member, _) when typeof(TResult) == typeof(Types.ChatMember) + => (TResult)(object)member.ToChatMember()!, + + (DTOs.GetChatMembersResponse members, _) when typeof(TResult) == typeof(Types.GetChatMembersResponse) + => (TResult)(object)members.ToGetChatMembersResponse()!, + + // Video + (DTOs.VideoInfo video, _) when typeof(TResult) == typeof(Types.VideoInfo) + => (TResult)(object)video.ToVideoInfo()!, + + _ => throw new NotSupportedException( + $"Маппинг из {typeof(TDto).Name} в {typeof(TResult).Name} не поддерживается. " + + "Добавьте новый случай в UniversalMappingExtensions.ToResult() если этот маппинг необходим.") + }; + } + } +} diff --git a/src/Max.BotClient/Mapping/UpdateChatMappingExtensions.cs b/src/Max.BotClient/Mapping/UpdateChatMappingExtensions.cs new file mode 100644 index 0000000..9c0b5a2 --- /dev/null +++ b/src/Max.BotClient/Mapping/UpdateChatMappingExtensions.cs @@ -0,0 +1,52 @@ +namespace Max.BotClient.Mapping +{ + /// + /// Методы расширения для маппинга UpdateChat запросов между Types и DTOs. + /// + internal static class UpdateChatMappingExtensions + { + /// + /// Преобразует Types UpdateChatRequest в DTO. + /// + public static object? ToDto(this Types.UpdateChatRequest? request) + { + if (request == null) return null; + + return new + { + icon = request.Icon?.ToChatIconDto(), + title = request.Title, + pin = request.Pin, + notify = request.Notify + }; + } + + /// + /// Преобразует Types ChatIcon в DTO PhotoAttachmentRequestPayload. + /// + private static DTOs.PhotoAttachmentRequestPayload? ToChatIconDto(this Types.ChatIcon? icon) + { + if (icon == null) return null; + + return new DTOs.PhotoAttachmentRequestPayload + { + Url = icon.Url, + Token = icon.Token, + Photos = icon.Photos?.ToPhotoTokenDto() + }; + } + + /// + /// Преобразует Types PhotoToken в DTO PhotoToken. + /// + private static DTOs.PhotoToken? ToPhotoTokenDto(this Types.PhotoToken? photoToken) + { + if (photoToken == null) return null; + + return new DTOs.PhotoToken + { + Token = photoToken.Token + }; + } + } +} diff --git a/src/Max.BotClient/Mapping/UserMappingExtensions.cs b/src/Max.BotClient/Mapping/UserMappingExtensions.cs new file mode 100644 index 0000000..f7bc3b9 --- /dev/null +++ b/src/Max.BotClient/Mapping/UserMappingExtensions.cs @@ -0,0 +1,47 @@ +namespace Max.BotClient.Mapping +{ + /// + /// Методы расширения для маппинга типов User между DTOs и Types. + /// + internal static class UserMappingExtensions + { + /// + /// Преобразует DTO User в Types User. + /// + public static Types.User? ToUser(this DTOs.User? dto) + { + if (dto == null) return null; + + return new Types.User + { + UserId = dto.UserId, + FirstName = dto.FirstName, + LastName = dto.LastName, + Username = dto.Username, + IsBot = dto.IsBot, + LastActivityTime = dto.LastActivityTime + }; + } + + /// + /// Преобразует DTO UserWithPhoto в Types UserWithPhoto. + /// + public static Types.UserWithPhoto? ToUserWithPhoto(this DTOs.UserWithPhoto? dto) + { + if (dto == null) return null; + + return new Types.UserWithPhoto + { + UserId = dto.UserId, + FirstName = dto.FirstName, + LastName = dto.LastName, + Username = dto.Username, + IsBot = dto.IsBot, + LastActivityTime = dto.LastActivityTime, + Description = dto.Description, + AvatarUrl = dto.AvatarUrl, + FullAvatarUrl = dto.FullAvatarUrl + }; + } + } +} diff --git a/src/Max.BotClient/Mapping/VideoMappingExtensions.cs b/src/Max.BotClient/Mapping/VideoMappingExtensions.cs new file mode 100644 index 0000000..2b01ea9 --- /dev/null +++ b/src/Max.BotClient/Mapping/VideoMappingExtensions.cs @@ -0,0 +1,50 @@ +namespace Max.BotClient.Mapping +{ + /// + /// Методы расширения для маппинга типов Video между DTOs и Types. + /// + internal static class VideoMappingExtensions + { + /// + /// Преобразует DTO VideoInfo в Types VideoInfo. + /// + public static Types.VideoInfo? ToVideoInfo(this DTOs.VideoInfo? dto) + { + if (dto == null) return null; + + return new Types.VideoInfo + { + Token = dto.Token, + Urls = dto.Urls?.ToVideoUrls(), + Thumbnail = dto.Thumbnail != null ? new Types.PhotoAttachment + { + Url = dto.Thumbnail.Url, + Token = dto.Thumbnail.Token, + PhotoId = dto.Thumbnail.PhotoId + } : null, + Width = dto.Width, + Height = dto.Height, + Duration = dto.Duration + }; + } + + /// + /// Преобразует DTO VideoUrls в Types VideoUrls. + /// + public static Types.VideoUrls? ToVideoUrls(this DTOs.VideoUrls? dto) + { + if (dto == null) return null; + + return new Types.VideoUrls + { + Mp4Resolution1080p = dto.Mp4Resolution1080p, + Mp4Resolution720p = dto.Mp4Resolution720p, + Mp4Resolution480p = dto.Mp4Resolution480p, + Mp4Resolution360p = dto.Mp4Resolution360p, + Mp4Resolution240p = dto.Mp4Resolution240p, + Mp4Resolution144p = dto.Mp4Resolution144p, + HlsStream = dto.HlsStream + }; + } + } +} diff --git a/src/Max.BotClient/Max.BotClient.ApiException.cs b/src/Max.BotClient/Max.BotClient.ApiException.cs new file mode 100644 index 0000000..3f41f6e --- /dev/null +++ b/src/Max.BotClient/Max.BotClient.ApiException.cs @@ -0,0 +1,45 @@ +using System; +using System.Net; + +namespace Max.BotClient +{ + /// + /// Исключение, возникающее при ошибке API. + /// + public class MaxBotClientApiException : Exception + { + /// + /// HTTP статус код ответа. + /// + public HttpStatusCode StatusCode { get; } + + /// + /// Тело ответа с ошибкой. + /// + public string? ResponseBody { get; } + + /// + /// Можно ли повторить запрос. + /// + public bool IsRetryable => (int)StatusCode == 429 || (int)StatusCode >= 500; + + public MaxBotClientApiException( + HttpStatusCode statusCode, + string? responseBody = null + ) : base($"API request failed with status {(int)statusCode} ({statusCode})") + { + StatusCode = statusCode; + ResponseBody = responseBody; + } + + public MaxBotClientApiException( + HttpStatusCode statusCode, + string? responseBody, + Exception innerException + ) : base($"API request failed with status {(int)statusCode} ({statusCode})", innerException) + { + StatusCode = statusCode; + ResponseBody = responseBody; + } + } +} diff --git a/src/Max.BotClient/Max.BotClient.ApiExtensions.cs b/src/Max.BotClient/Max.BotClient.ApiExtensions.cs new file mode 100644 index 0000000..c9a27e7 --- /dev/null +++ b/src/Max.BotClient/Max.BotClient.ApiExtensions.cs @@ -0,0 +1,38 @@ +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Max.BotClient.Mapping; + +namespace Max.BotClient +{ + internal static class BotClientApiMethodsExtensions + { + public static async Task ProcessApi( + this BotClient botClient, + HttpMethod method, + string path, + object? body = null, + CancellationToken cancellationToken = default + ) + { + var dto = await botClient.SendRequest(method, path, body, cancellationToken); + return dto.ToResult(); + } + + public static async Task ProcessApi( + this BotClient botClient, + HttpMethod method, + string path, + object? body = null, + CancellationToken cancellationToken = default + ) => await botClient.SendRequest(method, path, body, cancellationToken); + + public static async Task ProcessApi( + this BotClient botClient, + HttpMethod method, + string path, + object? body = null, + CancellationToken cancellationToken = default + ) => await botClient.SendRequest(method, path, body, cancellationToken); + } +} \ No newline at end of file diff --git a/src/Max.BotClient/Max.BotClient.ApiMethods.Chats.cs b/src/Max.BotClient/Max.BotClient.ApiMethods.Chats.cs new file mode 100644 index 0000000..201d9ae --- /dev/null +++ b/src/Max.BotClient/Max.BotClient.ApiMethods.Chats.cs @@ -0,0 +1,237 @@ +using System.Collections.Generic; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Max.BotClient.DTOs; +using Max.BotClient.Mapping; + +namespace Max.BotClient +{ + public static partial class BotClientApiMethods + { + /// + /// Получить список групповых чатов, в которых участвовал бот. + /// + /// + /// Клиент бота. + /// Количество запрашиваемых чатов (1-100, по умолчанию 50). + /// Указатель на следующую страницу данных. Для первой страницы передайте null. + /// Токен отмены. + /// Список чатов и маркер для следующей страницы. + public static async Task GetChats( + this BotClient botClient, + int? count = null, + long? marker = null, + CancellationToken cancellationToken = default + ) + { + var queryParams = new List(); + if (count.HasValue) + queryParams.Add($"count={count.Value}"); + if (marker.HasValue) + queryParams.Add($"marker={marker.Value}"); + + var path = queryParams.Count > 0 + ? $"/chats?{string.Join("&", queryParams)}" + : "/chats"; + + var response = await botClient.ProcessApi( + HttpMethod.Get, + path, + cancellationToken: cancellationToken + ); + + return response; + } + + /// + /// Получить информацию о групповом чате по его ID. + /// + /// + /// Клиент бота. + /// ID запрашиваемого чата. + /// Токен отмены. + /// Информация о чате, включая закреплённое сообщение (если есть). + public static async Task GetChat( + this BotClient botClient, + long chatId, + CancellationToken cancellationToken = default + ) + { + var path = $"/chats/{chatId}"; + + var response = await botClient.ProcessApi( + HttpMethod.Get, + path, + cancellationToken: cancellationToken + ); + + return response; + } + + /// + /// Изменить информацию о групповом чате. + /// + /// + /// Клиент бота. + /// ID чата. + /// Параметры для обновления чата. + /// Токен отмены. + /// Обновлённая информация о чате. + public static async Task UpdateChat( + this BotClient botClient, + long chatId, + Types.UpdateChatRequest request, + CancellationToken cancellationToken = default + ) + { + var path = $"/chats/{chatId}"; + + var response = await botClient.ProcessApi( + new HttpMethod("PATCH"), + path, + request.ToDto(), + cancellationToken + ); + + return response; + } + + /// + /// Удалить групповой чат для всех участников. + /// + /// + /// Клиент бота. + /// ID чата. + /// Токен отмены. + /// true, если запрос был успешным. + public static async Task DeleteChat( + this BotClient botClient, + long chatId, + CancellationToken cancellationToken = default + ) + { + var path = $"/chats/{chatId}"; + + var response = await botClient.ProcessApi( + HttpMethod.Delete, + path, + cancellationToken: cancellationToken + ); + + return response.Success; + } + + /// + /// Отправить действие бота в групповой чат. + /// + /// + /// Клиент бота. + /// ID чата. + /// Действие бота (например: набор текста, отправка фото). + /// Токен отмены. + /// true, если запрос был успешным. + public static async Task SendAction( + this BotClient botClient, + long chatId, + Types.SenderAction action, + CancellationToken cancellationToken = default + ) + { + var path = $"/chats/{chatId}/actions"; + + var response = await botClient.ProcessApi( + HttpMethod.Post, + path, + new { action = action.ToString().ToSnakeCase() }, + cancellationToken + ); + + return response.Success; + } + + /// + /// Получить закреплённое сообщение в групповом чате. + /// + /// + /// Клиент бота. + /// ID чата. + /// Токен отмены. + /// Закреплённое сообщение или null, если в чате нет закреплённого сообщения. + public static async Task GetPinnedMessage( + this BotClient botClient, + long chatId, + CancellationToken cancellationToken = default + ) + { + var path = $"/chats/{chatId}/pin"; + + var response = await botClient.ProcessApi( + HttpMethod.Get, + path, + cancellationToken: cancellationToken + ); + + return response?.Message; + } + + /// + /// Закрепить сообщение в групповом чате. + /// + /// + /// Клиент бота. + /// ID чата. + /// ID сообщения для закрепления (Message.Mid). + /// Если true, участники получат уведомление о закреплении. По умолчанию true. + /// Токен отмены. + /// true, если запрос был успешным. + public static async Task PinMessage( + this BotClient botClient, + long chatId, + string messageId, + bool? notify = null, + CancellationToken cancellationToken = default + ) + { + var path = $"/chats/{chatId}/pin"; + + var requestBody = notify.HasValue + ? new { message_id = messageId, notify = notify.Value } + : (object)new { message_id = messageId }; + + var response = await botClient.ProcessApi( + new HttpMethod("PUT"), + path, + requestBody, + cancellationToken + ); + + return response.Success; + } + + /// + /// Удалить закреплённое сообщение в групповом чате. + /// + /// + /// Клиент бота. + /// ID чата. + /// Токен отмены. + /// true, если запрос был успешным. + public static async Task UnpinMessage( + this BotClient botClient, + long chatId, + CancellationToken cancellationToken = default + ) + { + var path = $"/chats/{chatId}/pin"; + + var response = await botClient.ProcessApi( + HttpMethod.Delete, + path, + cancellationToken: cancellationToken + ); + + return response.Success; + } + } +} diff --git a/src/Max.BotClient/Max.BotClient.ApiMethods.Members.cs b/src/Max.BotClient/Max.BotClient.ApiMethods.Members.cs new file mode 100644 index 0000000..e80acc8 --- /dev/null +++ b/src/Max.BotClient/Max.BotClient.ApiMethods.Members.cs @@ -0,0 +1,250 @@ +using System.Collections.Generic; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Max.BotClient.DTOs; +using Max.BotClient.Types; +using Max.BotClient.Mapping; + +namespace Max.BotClient +{ + public static partial class BotClientApiMethods + { + /// + /// Получить информацию о членстве текущего бота в групповом чате. + /// + /// + /// Клиент бота. + /// ID чата. + /// Токен отмены. + /// Информация о членстве бота в чате. + public static async Task GetMyMembership( + this BotClient botClient, + long chatId, + CancellationToken cancellationToken = default + ) + { + var path = $"/chats/{chatId}/members/me"; + + var response = await botClient.ProcessApi( + HttpMethod.Get, + path, + cancellationToken: cancellationToken + ); + + return response; + } + + /// + /// Удалить бота из участников группового чата. + /// + /// + /// Клиент бота. + /// ID чата. + /// Токен отмены. + /// true, если запрос был успешным. + public static async Task LeaveChat( + this BotClient botClient, + long chatId, + CancellationToken cancellationToken = default + ) + { + var path = $"/chats/{chatId}/members/me"; + + var response = await botClient.ProcessApi( + HttpMethod.Delete, + path, + cancellationToken: cancellationToken + ); + + return response.Success; + } + + /// + /// Получение участников группового чата. + /// + /// + /// Клиент бота. + /// ID чата. + /// Список ID пользователей, чье членство нужно получить. При передаче параметры count и marker игнорируются. + /// Количество участников (1-100, по умолчанию 20). + /// Указатель на следующую страницу данных. + /// Токен отмены. + /// Список участников чата и маркер для следующей страницы. + public static async Task GetChatMembers( + this BotClient botClient, + long chatId, + long[]? userIds = null, + int? count = null, + long? marker = null, + CancellationToken cancellationToken = default + ) + { + var queryParams = new List(); + + if (userIds != null && userIds.Length > 0) + queryParams.Add($"user_ids={string.Join(",", userIds)}"); + if (count.HasValue) + queryParams.Add($"count={count.Value}"); + if (marker.HasValue) + queryParams.Add($"marker={marker.Value}"); + + var path = $"/chats/{chatId}/members"; + if (queryParams.Count > 0) + path += "?" + string.Join("&", queryParams); + + var response = await botClient.ProcessApi( + HttpMethod.Get, + path, + cancellationToken: cancellationToken + ); + + return response; + } + + /// + /// Добавить участников в групповой чат. + /// + /// + /// Клиент бота. + /// ID чата. + /// Массив ID пользователей для добавления в чат. + /// Токен отмены. + /// true, если запрос был успешным. + /// Для добавления участников могут потребоваться дополнительные права. + public static async Task AddChatMembers( + this BotClient botClient, + long chatId, + long[] userIds, + CancellationToken cancellationToken = default + ) + { + var path = $"/chats/{chatId}/members"; + + var response = await botClient.ProcessApi( + HttpMethod.Post, + path, + new { user_ids = userIds }, + cancellationToken + ); + + return response.Success; + } + + /// + /// Удалить участника из группового чата. + /// + /// + /// Клиент бота. + /// ID чата. + /// ID пользователя, которого нужно удалить из чата. + /// Если true, пользователь будет заблокирован в чате. Применяется только для чатов с публичной или приватной ссылкой. + /// Токен отмены. + /// true, если запрос был успешным. + /// Для удаления участников могут потребоваться дополнительные права. + public static async Task RemoveChatMember( + this BotClient botClient, + long chatId, + long userId, + bool? block = null, + CancellationToken cancellationToken = default + ) + { + var queryParams = new List { $"user_id={userId}" }; + + if (block.HasValue) + queryParams.Add($"block={block.Value.ToString().ToLowerInvariant()}"); + + var path = $"/chats/{chatId}/members?" + string.Join("&", queryParams); + + var response = await botClient.ProcessApi( + HttpMethod.Delete, + path, + cancellationToken: cancellationToken + ); + + return response.Success; + } + + /// + /// Получить список администраторов группового чата. + /// + /// + /// Клиент бота. + /// ID чата. + /// Токен отмены. + /// Список администраторов чата с информацией о членстве. + /// Бот должен быть администратором в запрашиваемом чате. + public static async Task GetChatAdmins( + this BotClient botClient, + long chatId, + CancellationToken cancellationToken = default + ) + { + var path = $"/chats/{chatId}/members/admins"; + + var response = await botClient.ProcessApi( + HttpMethod.Get, + path, + cancellationToken: cancellationToken + ); + + return response; + } + + /// + /// Назначить администраторов группового чата. + /// + /// + /// Клиент бота. + /// ID чата. + /// Список пользователей для назначения администраторами (максимум 50). + /// Токен отмены. + /// true, если все администраторы успешно добавлены. + public static async Task AddChatAdmins( + this BotClient botClient, + long chatId, + Types.ChatAdmin[] admins, + CancellationToken cancellationToken = default + ) + { + var path = $"/chats/{chatId}/members/admins"; + + var response = await botClient.ProcessApi( + HttpMethod.Post, + path, + new DTOs.AddChatAdminsRequest { Admins = admins.ToDto() }, + cancellationToken + ); + + return response.Success; + } + + /// + /// Отменить права администратора у пользователя в групповом чате. + /// + /// + /// Клиент бота. + /// ID чата. + /// ID пользователя. + /// Токен отмены. + /// true, если запрос был успешным. + public static async Task RemoveChatAdmin( + this BotClient botClient, + long chatId, + long userId, + CancellationToken cancellationToken = default + ) + { + var path = $"/chats/{chatId}/members/admins/{userId}"; + + var response = await botClient.ProcessApi( + HttpMethod.Delete, + path, + cancellationToken: cancellationToken + ); + + return response.Success; + } + } +} diff --git a/src/Max.BotClient/Max.BotClient.ApiMethods.Messages.cs b/src/Max.BotClient/Max.BotClient.ApiMethods.Messages.cs new file mode 100644 index 0000000..b50a483 --- /dev/null +++ b/src/Max.BotClient/Max.BotClient.ApiMethods.Messages.cs @@ -0,0 +1,233 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Max.BotClient.DTOs; +using Max.BotClient.Mapping; + +namespace Max.BotClient +{ + public static partial class BotClientApiMethods + { + /// + /// Получить сообщения из чата. + /// + /// + /// Клиент бота. + /// ID чата. + /// Время начала (Unix timestamp). + /// Время окончания (Unix timestamp). + /// Максимальное количество сообщений (1-100, по умолчанию 50). + /// Токен отмены. + /// Массив сообщений (последние сообщения первыми). + public static async Task GetMessages( + this BotClient botClient, + long chatId, + long? from = null, + long? to = null, + int? count = null, + CancellationToken cancellationToken = default + ) + { + var queryParams = new List { $"chat_id={chatId}" }; + + if (from.HasValue) + queryParams.Add($"from={from.Value}"); + if (to.HasValue) + queryParams.Add($"to={to.Value}"); + if (count.HasValue) + queryParams.Add($"count={count.Value}"); + + var path = "/messages?" + string.Join("&", queryParams); + + var response = await botClient.ProcessApi( + HttpMethod.Get, + path, + cancellationToken: cancellationToken + ); + + return response.Messages ?? Array.Empty(); + } + + /// + /// Получить сообщения по их ID. + /// + /// + /// Клиент бота. + /// Список ID сообщений. + /// Токен отмены. + /// Массив сообщений. + public static async Task GetMessages( + this BotClient botClient, + string[] messageIds, + CancellationToken cancellationToken = default + ) + { + var path = $"/messages?message_ids={string.Join(",", messageIds)}"; + + var response = await botClient.ProcessApi( + HttpMethod.Get, + path, + cancellationToken: cancellationToken + ); + + return response.Messages ?? Array.Empty(); + } + + /// + /// Получить сообщение по его ID. + /// + /// + /// Клиент бота. + /// ID сообщения (mid). + /// Токен отмены. + /// Сообщение с указанным ID. + public static async Task GetMessage( + this BotClient botClient, + string messageId, + CancellationToken cancellationToken = default + ) + { + var path = $"/messages/{Uri.EscapeDataString(messageId)}"; + + var response = await botClient.ProcessApi( + HttpMethod.Get, + path, + cancellationToken: cancellationToken + ); + + return response; + } + + /// + /// Отправить сообщение пользователю или в чат. + /// + /// + /// Клиент бота. + /// ID получателя (пользователя или чата). + /// Сообщение для отправки. Используйте .ToChat() для отправки в чат. + /// Отключить превью ссылок. + /// Токен отмены. + /// Отправленное сообщение. + public static async Task SendMessage( + this BotClient botClient, + long id, + Types.Message message, + bool? disableLinkPreview = null, + CancellationToken cancellationToken = default + ) + { + var recipientParam = message.GetRecipientType() == Types.RecipientType.Chat + ? $"chat_id={id}" + : $"user_id={id}"; + + var queryParams = new List { recipientParam }; + if (disableLinkPreview.HasValue) + queryParams.Add($"disable_link_preview={disableLinkPreview.Value.ToString().ToLowerInvariant()}"); + + var path = "/messages?" + string.Join("&", queryParams); + + var response = await botClient.ProcessApi( + HttpMethod.Post, + path, + message.ToMessageBody().ToDto(), + cancellationToken + ); + + return response.Message; + } + + /// + /// Редактировать сообщение в чате. + /// + /// + /// Клиент бота. + /// ID редактируемого сообщения. + /// Сообщение с измененными данными. Все вложения указанного типа будут заменены. + /// Токен отмены. + /// true, если запрос был успешным. + /// С помощью метода можно отредактировать сообщения, которые отправлены менее 24 часов назад. + public static async Task EditMessage( + this BotClient botClient, + string messageId, + Types.Message message, + CancellationToken cancellationToken = default + ) + { + var path = $"/messages?message_id={Uri.EscapeDataString(messageId)}"; + + var response = await botClient.ProcessApi( + new HttpMethod("PUT"), + path, + message.ToMessageBody().ToDto(), + cancellationToken + ); + + return response.Success; + } + + /// + /// Удалить сообщение в диалоге или чате. + /// + /// + /// Клиент бота. + /// ID удаляемого сообщения. + /// Токен отмены. + /// true, если запрос был успешным. + /// С помощью метода можно удалять сообщения, которые отправлены менее 24 часов назад. Бот должен иметь разрешение на удаление сообщений. + public static async Task DeleteMessage( + this BotClient botClient, + string messageId, + CancellationToken cancellationToken = default + ) + { + var path = $"/messages?message_id={Uri.EscapeDataString(messageId)}"; + + var response = await botClient.ProcessApi( + HttpMethod.Delete, + path, + cancellationToken: cancellationToken + ); + + return response.Success; + } + + /// + /// Отправить ответ на нажатие кнопки пользователем. + /// + /// + /// Клиент бота. + /// Идентификатор кнопки (получен из update с типом message_callback). + /// Новое сообщение для обновления текущего (необязательно). + /// Текст одноразового уведомления для пользователя (необязательно). + /// Токен отмены. + /// true, если запрос был успешным. + /// Хотя бы один из параметров message или notification должен быть указан. + public static async Task AnswerCallback( + this BotClient botClient, + string callbackId, + Types.Message? message = null, + string? notification = null, + CancellationToken cancellationToken = default + ) + { + var path = $"/answers?callback_id={Uri.EscapeDataString(callbackId)}"; + + var requestBody = new + { + message = message?.ToMessageBody().ToDto(), + notification = notification + }; + + var response = await botClient.ProcessApi( + HttpMethod.Post, + path, + requestBody, + cancellationToken + ); + + return response.Success; + } + } +} diff --git a/src/Max.BotClient/Max.BotClient.ApiMethods.Misc.cs b/src/Max.BotClient/Max.BotClient.ApiMethods.Misc.cs new file mode 100644 index 0000000..3d60ebf --- /dev/null +++ b/src/Max.BotClient/Max.BotClient.ApiMethods.Misc.cs @@ -0,0 +1,70 @@ +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Max.BotClient.DTOs; +using Max.BotClient.Types; + +namespace Max.BotClient +{ + public static partial class BotClientApiMethods + { + /// + /// Получить информацию о текущем боте. + /// + /// + /// Клиент бота. + /// Токен отмены. + public static async Task GetMe( + this BotClient botClient, + CancellationToken cancellationToken = default + ) => await botClient.ProcessApi( + HttpMethod.Get, + "/me", + cancellationToken: cancellationToken + ); + + /// + /// Получить URL для загрузки файла. + /// + /// + /// Клиент бота. + /// Тип загружаемого файла. + /// Токен отмены. + /// URL для загрузки и токен (для video/audio). + public static async Task GetUploadUrl( + this BotClient botClient, + UploadType type, + CancellationToken cancellationToken = default + ) => await botClient.ProcessApi( + HttpMethod.Post, + $"/uploads?type={type.ToString().ToSnakeCase()}", + cancellationToken: cancellationToken + ); + + /// + /// Получить подробную информацию о прикреплённом видео. + /// + /// + /// Клиент бота. + /// Токен видео-вложения. + /// Токен отмены. + /// Подробная информация о видео, включая URL-адреса воспроизведения и метаданные. + public static async Task GetVideo( + this BotClient botClient, + string videoToken, + CancellationToken cancellationToken = default + ) + { + var path = $"/videos/{Uri.EscapeDataString(videoToken)}"; + + var response = await botClient.ProcessApi( + HttpMethod.Get, + path, + cancellationToken: cancellationToken + ); + + return response; + } + } +} diff --git a/src/Max.BotClient/Max.BotClient.ApiMethods.Subscriptions.cs b/src/Max.BotClient/Max.BotClient.ApiMethods.Subscriptions.cs new file mode 100644 index 0000000..2189c10 --- /dev/null +++ b/src/Max.BotClient/Max.BotClient.ApiMethods.Subscriptions.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Max.BotClient.Types; + +namespace Max.BotClient +{ + public static partial class BotClientApiMethods + { + /// + /// Получить список подписок на вебхуки. + /// + /// + /// Клиент бота. + /// Токен отмены. + public static async Task GetSubscriptions( + this BotClient botClient, + CancellationToken cancellationToken = default + ) => (await botClient.ProcessApi( + HttpMethod.Get, + "/subscriptions", + cancellationToken: cancellationToken + )).Subscriptions; + + /// + /// Подписаться на получение обновлений через WebHook. + /// + /// + /// Клиент бота. + /// URL вебхука (должен начинаться с http(s)://). + /// Типы обновлений для подписки. + /// Секрет для заголовка X-Max-Bot-Api-Secret (5-256 символов, A-Z, a-z, 0-9, -, _). + /// Токен отмены. + public static async Task Subscribe( + this BotClient botClient, + string url, + Types.UpdateType[]? updateTypes = null, + string? secret = null, + CancellationToken cancellationToken = default + ) => await botClient.ProcessApi( + HttpMethod.Post, + "/subscriptions", + new SubscribeRequest { Url = url, UpdateTypes = updateTypes, Secret = secret }, + cancellationToken + ); + + /// + /// Отписаться от получения обновлений через WebHook. + /// + /// + /// Клиент бота. + /// URL вебхука для удаления из подписок. + /// Токен отмены. + public static async Task Unsubscribe( + this BotClient botClient, + string url, + CancellationToken cancellationToken = default + ) => await botClient.ProcessApi( + HttpMethod.Delete, + $"/subscriptions?url={Uri.EscapeDataString(url)}", + cancellationToken: cancellationToken + ); + + /// + /// Получить обновления через long polling. + /// + /// + /// Клиент бота. + /// Максимальное количество обновлений (1-1000, по умолчанию 100). + /// Тайм-аут в секундах для long polling (0-90, по умолчанию 30). + /// Маркер для получения обновлений после указанной позиции. + /// Типы обновлений для получения. + /// Токен отмены. + public static async Task<(Update[], long?)> GetUpdates( + this BotClient botClient, + int? limit = null, + int? timeout = null, + long? marker = null, + Types.UpdateType[]? types = null, + CancellationToken cancellationToken = default + ) + { + var queryParams = new List(); + + if (limit.HasValue) + queryParams.Add($"limit={limit.Value}"); + if (timeout.HasValue) + queryParams.Add($"timeout={timeout.Value}"); + if (marker.HasValue) + queryParams.Add($"marker={marker.Value}"); + if (types != null && types.Length > 0) + queryParams.Add($"types={string.Join(",", types.Select(t => t.ToString().ToSnakeCase()))}"); + + var path = "/updates"; + if (queryParams.Count > 0) + path += "?" + string.Join("&", queryParams); + + var response = await botClient.ProcessApi( + HttpMethod.Get, + path, + cancellationToken: cancellationToken + ); + + return (response.Updates, response.Marker); + } + } +} diff --git a/src/Max.BotClient/Max.BotClient.Extensions.cs b/src/Max.BotClient/Max.BotClient.Extensions.cs new file mode 100644 index 0000000..12418db --- /dev/null +++ b/src/Max.BotClient/Max.BotClient.Extensions.cs @@ -0,0 +1,11 @@ +using System.Linq; + +namespace Max.BotClient +{ + internal static class BotClientExtensions + { + public static string ToSnakeCase(this string str) => + string.Concat(str.Select((c, i) => i > 0 && char.IsUpper(c) ? "_" + c : c.ToString())).ToLower(); + } +} + diff --git a/src/Max.BotClient/Max.BotClient.JsonOptions.cs b/src/Max.BotClient/Max.BotClient.JsonOptions.cs new file mode 100644 index 0000000..4f304ca --- /dev/null +++ b/src/Max.BotClient/Max.BotClient.JsonOptions.cs @@ -0,0 +1,30 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Max.BotClient +{ + /// + /// Общие настройки JSON сериализации для Max API. + /// + public static class BotClientJsonOptions + { + /// + /// Настройки по умолчанию для десериализации ответов Max API. + /// Использует snake_case для имён свойств. + /// + public static JsonSerializerOptions Default { get; } = CreateDefault(); + + private static JsonSerializerOptions CreateDefault() + { + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower)); + + return options; + } + } +} diff --git a/src/Max.BotClient/Max.BotClient.Options.cs b/src/Max.BotClient/Max.BotClient.Options.cs new file mode 100644 index 0000000..15d1895 --- /dev/null +++ b/src/Max.BotClient/Max.BotClient.Options.cs @@ -0,0 +1,32 @@ +using System; + +namespace Max.BotClient +{ + public class BotClientOptions + { + private const string BaseUrl = "https://platform-api.max.ru"; + public string Token { get; } + public string ApiUrl { get; } + /// + /// Начальная задержка между повторными запросами в секундах (exponential backoff). + /// + public int RetryDelaySeconds { get; set; } = 1; + + /// + /// Максимальное количество повторных попыток при ошибках 429/5xx. + /// + public int RetryCount { get; set; } = 3; + + public BotClientOptions( + string token, + string apiUrl = null + ) + { + if (string.IsNullOrWhiteSpace(token)) + throw new ArgumentNullException(nameof(token)); + + Token = token; + ApiUrl = string.IsNullOrWhiteSpace(apiUrl) ? BaseUrl : apiUrl; + } + } +} \ No newline at end of file diff --git a/src/Max.BotClient/Max.BotClient.Polling.cs b/src/Max.BotClient/Max.BotClient.Polling.cs new file mode 100644 index 0000000..16d5b64 --- /dev/null +++ b/src/Max.BotClient/Max.BotClient.Polling.cs @@ -0,0 +1,154 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Max.BotClient.Types; + +namespace Max.BotClient +{ + /// + /// Опции настройки polling. + /// + public class ReceiverOptions + { + /// + /// Максимальное количество обновлений за один запрос (1-1000, по умолчанию 100). + /// + public int? Limit { get; set; } + + /// + /// Тайм-аут в секундах для long polling (0-90, по умолчанию 30). + /// + public int Timeout { get; set; } = 30; + + /// + /// Фильтр типов обновлений. Если null — получать все типы. + /// + public UpdateType[]? AllowedUpdates { get; set; } + + /// + /// Пропустить накопившиеся обновления при старте. + /// + public bool DropPendingUpdates { get; set; } + } + + public static partial class BotClientApiMethods + { + /// + /// Запускает получение обновлений через long polling в фоновом режиме (не блокирует). + /// + /// + /// Клиент бота. + /// Обработчик обновлений. + /// Обработчик ошибок (необязательно). + /// Опции polling. + /// Токен отмены. + public static void StartReceiving( + this BotClient botClient, + Func updateHandler, + Func? errorHandler = null, + ReceiverOptions? options = null, + CancellationToken cancellationToken = default + ) => Task.Run(() => + botClient.ReceiveAsync( + updateHandler, + errorHandler, + options, + cancellationToken + ), + cancellationToken + ); + + /// + /// Получает обновления через long polling (блокирует до отмены). + /// + /// + /// Клиент бота. + /// Обработчик обновлений. + /// Обработчик ошибок (необязательно). + /// Опции polling. + /// Токен отмены. + public static async Task ReceiveAsync( + this BotClient botClient, + Func updateHandler, + Func? errorHandler = null, + ReceiverOptions? options = null, + CancellationToken cancellationToken = default + ) + { + options = options ?? new ReceiverOptions(); + long? marker = null; + + // Сброс накопившихся обновлений + if (options.DropPendingUpdates) + { + try + { + var (_, newMarker) = await botClient.GetUpdates( + limit: 1, + timeout: 0, + marker: null, + types: options.AllowedUpdates, + cancellationToken: cancellationToken + ); + marker = newMarker; + } + catch (OperationCanceledException) + { + return; + } + catch (Exception ex) + { + if (errorHandler != null) + await errorHandler(botClient, ex, cancellationToken); + } + } + + while (!cancellationToken.IsCancellationRequested) + { + Update[] updates; + + try + { + var result = await botClient.GetUpdates( + limit: options.Limit, + timeout: options.Timeout, + marker: marker, + types: options.AllowedUpdates, + cancellationToken: cancellationToken + ); + + updates = result.Item1; + marker = result.Item2 ?? marker; + } + catch (OperationCanceledException) + { + break; + } + catch (Exception ex) + { + if (errorHandler != null) + await errorHandler(botClient, ex, cancellationToken); + + continue; + } + + foreach (var update in updates) + { + try + { + await updateHandler(botClient, update, cancellationToken); + } + catch (OperationCanceledException) + { + return; + } + catch (Exception ex) + { + if (errorHandler != null) + await errorHandler(botClient, ex, cancellationToken); + } + } + } + } + } +} diff --git a/src/Max.BotClient/Max.BotClient.cs b/src/Max.BotClient/Max.BotClient.cs new file mode 100644 index 0000000..2c48f07 --- /dev/null +++ b/src/Max.BotClient/Max.BotClient.cs @@ -0,0 +1,102 @@ +using System; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace Max.BotClient +{ + public interface IBotClient + { + CancellationToken GlobalCancelToken { get; } + + Task SendRequest( + HttpMethod method, + string path, + object? body = null, + CancellationToken cancellationToken = default + ); + } + + public partial class BotClient : IBotClient + { + private readonly BotClientOptions _options; + private readonly HttpClient _httpClient; + + public string Token => _options.Token; + public CancellationToken GlobalCancelToken { get; } + + public BotClient( + BotClientOptions options, + HttpClient? httpClient = null, + CancellationToken cancellationToken = default + ) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + GlobalCancelToken = cancellationToken; + + _httpClient = httpClient ?? new HttpClient(); + _httpClient.BaseAddress = new Uri(_options.ApiUrl); + _httpClient.DefaultRequestHeaders.TryAddWithoutValidation("Authorization", _options.Token); + } + + public BotClient( + string token, + HttpClient? httpClient = null, + CancellationToken cancellationToken = default + ) : this(new BotClientOptions(token), httpClient, cancellationToken) + { + } + + public async Task SendRequest( + HttpMethod method, + string path, + object? body = null, + CancellationToken cancellationToken = default + ) + { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(GlobalCancelToken, cancellationToken); + var token = cts.Token; + + MaxBotClientApiException? lastException = null; + + for (int attempt = 0; attempt <= _options.RetryCount; attempt++) + { + if (attempt > 0 && lastException != null) + { + // Exponential backoff: 1s, 2s, 4s... + var delay = _options.RetryDelaySeconds * (1 << (attempt - 1)); + await Task.Delay(TimeSpan.FromSeconds(delay), token); + } + + using var request = new HttpRequestMessage(method, path); + + if (body != null) + { + var json = JsonSerializer.Serialize(body, BotClientJsonOptions.Default); + request.Content = new StringContent(json, Encoding.UTF8, "application/json"); + } + + using var response = await _httpClient.SendAsync(request, token); + var responseBody = await response.Content.ReadAsStringAsync(); + + if (response.IsSuccessStatusCode) + { + return JsonSerializer.Deserialize(responseBody, BotClientJsonOptions.Default)!; + } + + var exception = new MaxBotClientApiException(response.StatusCode, responseBody); + + if (!exception.IsRetryable) + { + throw exception; + } + + lastException = exception; + } + + throw lastException!; + } + } +} diff --git a/src/Max.BotClient/Max.BotClient.csproj b/src/Max.BotClient/Max.BotClient.csproj new file mode 100644 index 0000000..389f686 --- /dev/null +++ b/src/Max.BotClient/Max.BotClient.csproj @@ -0,0 +1,13 @@ + + + + netstandard2.0 + latest + enable + + + + + + + diff --git a/src/Max.BotClient/Types/AttachmentRequest.cs b/src/Max.BotClient/Types/AttachmentRequest.cs new file mode 100644 index 0000000..0f34589 --- /dev/null +++ b/src/Max.BotClient/Types/AttachmentRequest.cs @@ -0,0 +1,162 @@ +namespace Max.BotClient.Types +{ + /// + /// Тип вложения для отправки. + /// + public enum AttachmentRequestType + { + Image, + Video, + Audio, + File, + Sticker, + Contact, + Location, + Share, + InlineKeyboard + } + + /// + /// Вложение для отправки в сообщении. + /// + /// + public class AttachmentRequest + { + /// + /// Тип вложения. + /// + public AttachmentRequestType Type { get; set; } + + /// + /// Данные вложения. + /// + public AttachmentPayload? Payload { get; set; } + } + + /// + /// Данные вложения. + /// + public class AttachmentPayload + { + // === Image/Video/Audio/File === + + /// + /// URL файла для загрузки (для image). + /// + public string? Url { get; set; } + + /// + /// Токен загруженного файла. + /// + public string? Token { get; set; } + + // === Sticker === + + /// + /// Код стикера. + /// + public string? Code { get; set; } + + // === Contact === + + /// + /// Имя контакта. + /// + public string? Name { get; set; } + + /// + /// ID контакта (если пользователь MAX). + /// + public long? ContactId { get; set; } + + /// + /// Информация о контакте в формате VCF. + /// + public string? VcfInfo { get; set; } + + /// + /// Телефон контакта. + /// + public string? VcfPhone { get; set; } + + // === Location === + + /// + /// Широта. + /// + public double? Latitude { get; set; } + + /// + /// Долгота. + /// + public double? Longitude { get; set; } + + // === Share === + + /// + /// URL для share. + /// + public string? ShareUrl { get; set; } + + // === InlineKeyboard === + + /// + /// Кнопки клавиатуры. + /// + public ButtonRequest[][]? Buttons { get; set; } + } + + /// + /// Тип кнопки для отправки. + /// + public enum ButtonRequestType + { + Callback, + Link, + RequestGeoLocation, + RequestContact, + OpenApp, + Message + } + + /// + /// Кнопка для отправки. + /// + public class ButtonRequest + { + /// + /// Тип кнопки. + /// + public ButtonRequestType Type { get; set; } + + /// + /// Видимый текст кнопки. + /// + public string Text { get; set; } = null!; + + /// + /// Payload данные для callback. + /// + public string? Payload { get; set; } + + /// + /// URL для кнопки-ссылки. + /// + public string? Url { get; set; } + + /// + /// Быстрая отправка геолокации без подтверждения. + /// + public bool? Quick { get; set; } + + /// + /// Username бота для мини-приложения. + /// + public string? WebApp { get; set; } + + /// + /// ID чата для мини-приложения. + /// + public long? ChatId { get; set; } + } +} diff --git a/src/Max.BotClient/Types/Attachments.cs b/src/Max.BotClient/Types/Attachments.cs new file mode 100644 index 0000000..ab354bc --- /dev/null +++ b/src/Max.BotClient/Types/Attachments.cs @@ -0,0 +1,324 @@ +namespace Max.BotClient.Types +{ + /// + /// Базовый интерфейс для всех типов вложений. + /// + public interface IAttachment + { + /// + /// Тип вложения. + /// + AttachmentType Type { get; } + } + + /// + /// Вложение с изображением. + /// + public class PhotoAttachment : IAttachment + { + /// + /// Тип вложения. + /// + public AttachmentType Type => AttachmentType.Image; + + /// + /// ID изображения. + /// + public long? PhotoId { get; internal set; } + + /// + /// URL изображения. + /// + public string? Url { get; internal set; } + + /// + /// Токен изображения. + /// + public string? Token { get; internal set; } + + /// + /// Создает вложение изображения по URL. + /// + public static PhotoAttachment FromUrl(string url) => new PhotoAttachment { Url = url }; + + /// + /// Создает вложение изображения по токену. + /// + public static PhotoAttachment FromToken(string token) => new PhotoAttachment { Token = token }; + } + + /// + /// Вложение с видео. + /// + public class VideoAttachment : IAttachment + { + /// + /// Тип вложения. + /// + public AttachmentType Type => AttachmentType.Video; + + /// + /// URL видео. + /// + public string? Url { get; internal set; } + + /// + /// Токен видео. + /// + public string? Token { get; internal set; } + + /// + /// Ширина видео. + /// + public int? Width { get; internal set; } + + /// + /// Высота видео. + /// + public int? Height { get; internal set; } + + /// + /// Длительность видео в секундах. + /// + public int? Duration { get; internal set; } + + /// + /// URL миниатюры видео. + /// + public string? ThumbnailUrl { get; internal set; } + + /// + /// Создает вложение видео по токену. + /// + public static VideoAttachment FromToken(string token) => new VideoAttachment { Token = token }; + } + + /// + /// Вложение с аудио. + /// + public class AudioAttachment : IAttachment + { + /// + /// Тип вложения. + /// + public AttachmentType Type => AttachmentType.Audio; + + /// + /// URL аудио. + /// + public string? Url { get; internal set; } + + /// + /// Токен аудио. + /// + public string? Token { get; internal set; } + + /// + /// Транскрипция аудио. + /// + public string? Transcription { get; internal set; } + + /// + /// Создает вложение аудио по токену. + /// + public static AudioAttachment FromToken(string token) => new AudioAttachment { Token = token }; + } + + /// + /// Вложение с файлом. + /// + public class FileAttachment : IAttachment + { + /// + /// Тип вложения. + /// + public AttachmentType Type => AttachmentType.File; + + /// + /// URL файла. + /// + public string? Url { get; internal set; } + + /// + /// Токен файла. + /// + public string? Token { get; internal set; } + + /// + /// Имя файла. + /// + public string? Filename { get; internal set; } + + /// + /// Размер файла в байтах. + /// + public long? Size { get; internal set; } + + /// + /// Создает вложение файла по токену. + /// + public static FileAttachment FromToken(string token) => new FileAttachment { Token = token }; + } + + /// + /// Вложение со стикером. + /// + public class StickerAttachment : IAttachment + { + /// + /// Тип вложения. + /// + public AttachmentType Type => AttachmentType.Sticker; + + /// + /// URL стикера. + /// + public string? Url { get; internal set; } + + /// + /// Код стикера. + /// + public string? Code { get; internal set; } + + /// + /// Ширина стикера. + /// + public int? Width { get; internal set; } + + /// + /// Высота стикера. + /// + public int? Height { get; internal set; } + + /// + /// Создает вложение стикера по коду. + /// + public static StickerAttachment FromCode(string code) => new StickerAttachment { Code = code }; + } + + /// + /// Вложение с контактом. + /// + public class ContactAttachment : IAttachment + { + /// + /// Тип вложения. + /// + public AttachmentType Type => AttachmentType.Contact; + + /// + /// Информация о контакте в формате VCF. + /// + public string? VcfInfo { get; internal set; } + + /// + /// Информация о пользователе контакта. + /// + public User? ContactUser { get; internal set; } + + /// + /// Имя контакта. + /// + public string? Name { get; internal set; } + + /// + /// ID контакта. + /// + public long? ContactId { get; internal set; } + + /// + /// Телефон контакта в формате VCF. + /// + public string? VcfPhone { get; internal set; } + + /// + /// Создает вложение контакта. + /// + public static ContactAttachment Create(string name, long? contactId = null, string? vcfInfo = null, string? vcfPhone = null) => + new ContactAttachment { Name = name, ContactId = contactId, VcfInfo = vcfInfo, VcfPhone = vcfPhone }; + } + + /// + /// Вложение с предпросмотром ссылки. + /// + public class ShareAttachment : IAttachment + { + /// + /// Тип вложения. + /// + public AttachmentType Type => AttachmentType.Share; + + /// + /// URL ссылки. + /// + public string? Url { get; internal set; } + + /// + /// Токен. + /// + public string? Token { get; internal set; } + + /// + /// Заголовок предпросмотра. + /// + public string? Title { get; internal set; } + + /// + /// Описание предпросмотра. + /// + public string? Description { get; internal set; } + + /// + /// URL изображения предпросмотра. + /// + public string? ImageUrl { get; internal set; } + + /// + /// Создает вложение ссылки. + /// + public static ShareAttachment FromUrl(string url) => new ShareAttachment { Url = url }; + } + + /// + /// Вложение с геолокацией. + /// + public class LocationAttachment : IAttachment + { + /// + /// Тип вложения. + /// + public AttachmentType Type => AttachmentType.Location; + + /// + /// Широта. + /// + public double? Latitude { get; internal set; } + + /// + /// Долгота. + /// + public double? Longitude { get; internal set; } + + /// + /// Создает вложение геолокации. + /// + public static LocationAttachment Create(double latitude, double longitude) => + new LocationAttachment { Latitude = latitude, Longitude = longitude }; + } + + /// + /// Вложение с inline клавиатурой. + /// + public class InlineKeyboardAttachment : IAttachment + { + /// + /// Тип вложения. + /// + public AttachmentType Type => AttachmentType.InlineKeyboard; + + /// + /// Кнопки клавиатуры. + /// + public Button[][]? Buttons { get; internal set; } + } +} diff --git a/src/Max.BotClient/Types/BotInfo.cs b/src/Max.BotClient/Types/BotInfo.cs new file mode 100644 index 0000000..fdc9fdd --- /dev/null +++ b/src/Max.BotClient/Types/BotInfo.cs @@ -0,0 +1,29 @@ +namespace Max.BotClient.Types +{ + /// + /// Информация о боте. + /// + /// + public class BotInfo + { + public long UserId { get; set; } + public string FirstName { get; set; } = string.Empty; + public string? LastName { get; set; } + public string? Username { get; set; } + public bool IsBot { get; set; } + public long? LastActivityTime { get; set; } + public string? Description { get; set; } + public string? AvatarUrl { get; set; } + public string? FullAvatarUrl { get; set; } + public BotCommand[]? Commands { get; set; } + } + + /// + /// Команда бота. + /// + public class BotCommand + { + public string Name { get; set; } = string.Empty; + public string? Description { get; set; } + } +} diff --git a/src/Max.BotClient/Types/Builders/InlineKeyboardBuilder.cs b/src/Max.BotClient/Types/Builders/InlineKeyboardBuilder.cs new file mode 100644 index 0000000..ac9294b --- /dev/null +++ b/src/Max.BotClient/Types/Builders/InlineKeyboardBuilder.cs @@ -0,0 +1,173 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Max.BotClient.Types.Builders +{ + /// + /// Builder для создания inline клавиатуры с кнопками. + /// + public class InlineKeyboardBuilder + { + private readonly List> _rows = new List>(); + private List? _currentRow; + + /// + /// Начинает новый ряд кнопок. + /// + public InlineKeyboardBuilder AddRow() + { + _currentRow = new List(); + _rows.Add(_currentRow); + return this; + } + + /// + /// Добавляет кнопку с callback в текущий ряд. + /// + /// Текст на кнопке + /// Данные callback (до 4000 символов) + public InlineKeyboardBuilder AddCallbackButton(string text, string payload) + { + EnsureCurrentRow(); + _currentRow!.Add(new ButtonRequest + { + Type = ButtonRequestType.Callback, + Text = text, + Payload = payload + }); + return this; + } + + /// + /// Добавляет кнопку со ссылкой в текущий ряд. + /// + /// Текст на кнопке + /// URL ссылки + public InlineKeyboardBuilder AddLinkButton(string text, string url) + { + EnsureCurrentRow(); + _currentRow!.Add(new ButtonRequest + { + Type = ButtonRequestType.Link, + Text = text, + Url = url + }); + return this; + } + + /// + /// Добавляет кнопку запроса геолокации в текущий ряд. + /// + /// Текст на кнопке + /// Быстрый запрос (без подтверждения) + public InlineKeyboardBuilder AddRequestGeoLocationButton(string text, bool quick = false) + { + EnsureCurrentRow(); + _currentRow!.Add(new ButtonRequest + { + Type = ButtonRequestType.RequestGeoLocation, + Text = text, + Quick = quick + }); + return this; + } + + /// + /// Добавляет кнопку запроса контакта в текущий ряд. + /// + /// Текст на кнопке + public InlineKeyboardBuilder AddRequestContactButton(string text) + { + EnsureCurrentRow(); + _currentRow!.Add(new ButtonRequest + { + Type = ButtonRequestType.RequestContact, + Text = text + }); + return this; + } + + /// + /// Добавляет кнопку открытия веб-приложения в текущий ряд. + /// + /// Текст на кнопке + /// URL веб-приложения + /// ID чата (опционально) + public InlineKeyboardBuilder AddOpenAppButton(string text, string webApp, long? chatId = null) + { + EnsureCurrentRow(); + _currentRow!.Add(new ButtonRequest + { + Type = ButtonRequestType.OpenApp, + Text = text, + WebApp = webApp, + ChatId = chatId + }); + return this; + } + + /// + /// Добавляет кнопку отправки текстового сообщения в текущий ряд. + /// + /// Текст на кнопке (будет отправлен как сообщение) + public InlineKeyboardBuilder AddMessageButton(string text) + { + EnsureCurrentRow(); + _currentRow!.Add(new ButtonRequest + { + Type = ButtonRequestType.Message, + Text = text + }); + return this; + } + + /// + /// Конвертирует builder в InlineKeyboardAttachment для чтения. + /// + internal InlineKeyboardAttachment ToKeyboardAttachment() + { + return new InlineKeyboardAttachment + { + Buttons = _rows.Select(row => row.Select(br => new Button + { + Type = (ButtonType)(int)br.Type, + Text = br.Text, + Payload = br.Payload, + Url = br.Url, + Quick = br.Quick, + WebApp = br.WebApp, + ContactId = br.ChatId + }).ToArray()).ToArray() + }; + } + + /// + /// Возвращает AttachmentRequest для использования в отправке сообщения. + /// + internal AttachmentRequest ToAttachmentRequest() + { + if (_rows.Count == 0) + { + throw new InvalidOperationException("Клавиатура не содержит ни одного ряда кнопок. Используйте AddRow() для добавления ряда."); + } + + return new AttachmentRequest + { + Type = AttachmentRequestType.InlineKeyboard, + Payload = new AttachmentPayload + { + Buttons = _rows.ConvertAll(row => row.ToArray()).ToArray() + } + }; + } + + private void EnsureCurrentRow() + { + if (_currentRow == null) + { + AddRow(); + } + } + } +} diff --git a/src/Max.BotClient/Types/Callback.cs b/src/Max.BotClient/Types/Callback.cs new file mode 100644 index 0000000..cb1d1c9 --- /dev/null +++ b/src/Max.BotClient/Types/Callback.cs @@ -0,0 +1,29 @@ +namespace Max.BotClient.Types +{ + /// + /// Данные callback. + /// + /// + public class Callback + { + /// + /// Unix-время, когда пользователь нажал кнопку. + /// + public long? Timestamp { get; set; } + + /// + /// Текущий ID клавиатуры. + /// + public string? CallbackId { get; set; } + + /// + /// Токен кнопки. + /// + public string? Payload { get; set; } + + /// + /// Пользователь, нажавший на кнопку. + /// + public User? User { get; set; } + } +} diff --git a/src/Max.BotClient/Types/Chat.cs b/src/Max.BotClient/Types/Chat.cs new file mode 100644 index 0000000..3c4c9bd --- /dev/null +++ b/src/Max.BotClient/Types/Chat.cs @@ -0,0 +1,340 @@ +namespace Max.BotClient.Types +{ + /// + /// Статус чата. + /// + public enum ChatStatus + { + /// + /// Бот является активным участником чата. + /// + Active, + + /// + /// Бот был удалён из чата. + /// + Removed, + + /// + /// Бот покинул чат. + /// + Left, + + /// + /// Чат был закрыт. + /// + Closed + } + + /// + /// Права администратора чата. + /// + public enum ChatAdminPermission + { + /// + /// Читать все сообщения. + /// + ReadAllMessages, + + /// + /// Добавлять/удалять участников. + /// + AddRemoveMembers, + + /// + /// Добавлять администраторов. + /// + AddAdmins, + + /// + /// Изменять информацию о чате. + /// + ChangeChatInfo, + + /// + /// Закреплять сообщения. + /// + PinMessage, + + /// + /// Писать сообщения. + /// + Write, + + /// + /// Совершать звонки. + /// + CanCall, + + /// + /// Изменять ссылку на чат. + /// + EditLink, + + /// + /// Публиковать, редактировать и удалять сообщения. + /// + PostEditDeleteMessage, + + /// + /// Редактировать сообщения. + /// + EditMessage, + + /// + /// Удалять сообщения. + /// + DeleteMessage + } + + /// + /// Действие бота в чате. + /// + /// + public enum SenderAction + { + /// + /// Бот набирает сообщение. + /// + TypingOn, + + /// + /// Бот отправляет фото. + /// + SendingPhoto, + + /// + /// Бот отправляет видео. + /// + SendingVideo, + + /// + /// Бот отправляет аудиофайл. + /// + SendingAudio, + + /// + /// Бот отправляет файл. + /// + SendingFile, + + /// + /// Бот помечает сообщения как прочитанные. + /// + MarkSeen + } + + /// + /// Изображение. + /// + public class Image + { + /// + /// URL изображения. + /// + public string? Url { get; internal set; } + } + + /// + /// Чат. + /// + /// + public class Chat + { + /// + /// ID чата. + /// + public long ChatId { get; internal set; } + + /// + /// Тип чата. + /// + public ChatType Type { get; internal set; } + + /// + /// Статус чата. + /// + public ChatStatus Status { get; internal set; } + + /// + /// Отображаемое название чата. Может быть null для диалогов. + /// + public string? Title { get; internal set; } + + /// + /// Иконка чата. + /// + public Image? Icon { get; internal set; } + + /// + /// Время последнего события в чате (Unix-время в миллисекундах). + /// + public long LastEventTime { get; internal set; } + + /// + /// Количество участников чата. Для диалогов всегда 2. + /// + public int ParticipantsCount { get; internal set; } + + /// + /// ID владельца чата. + /// + public long? OwnerId { get; internal set; } + + /// + /// Участники чата с временем последней активности. Может быть null, если запрашивается список чатов. + /// + public object? Participants { get; internal set; } + + /// + /// Доступен ли чат публично (для диалогов всегда false). + /// + public bool IsPublic { get; internal set; } + + /// + /// Ссылка на чат. + /// + public string? Link { get; internal set; } + + /// + /// Описание чата. + /// + public string? Description { get; internal set; } + + /// + /// Данные о пользователе в диалоге (только для чатов типа "dialog"). + /// + public UserWithPhoto? DialogWithUser { get; internal set; } + + /// + /// ID сообщения, содержащего кнопку, через которую был инициирован чат. + /// + public string? ChatMessageId { get; internal set; } + + /// + /// Закреплённое сообщение в чате (возвращается только при запросе конкретного чата). + /// + public Message? PinnedMessage { get; internal set; } + } + + /// + /// Ответ на запрос списка чатов. + /// + public class GetChatsResponse + { + /// + /// Список запрашиваемых чатов. + /// + public Chat[]? Chats { get; internal set; } + + /// + /// Указатель на следующую страницу запрашиваемых чатов. + /// + public long? Marker { get; internal set; } + } + + /// + /// Ответ на запрос закреплённого сообщения. + /// + public class GetPinnedMessageResponse + { + /// + /// Закреплённое сообщение. Может быть null, если в чате нет закреплённого сообщения. + /// + public Message? Message { get; internal set; } + } + + /// + /// Участник чата с информацией о членстве. + /// + /// + public class ChatMember : UserWithPhoto + { + /// + /// Время последней активности пользователя в чате (Unix-время в миллисекундах). + /// + public long LastAccessTime { get; internal set; } + + /// + /// Является ли пользователь владельцем чата. + /// + public bool IsOwner { get; internal set; } + + /// + /// Является ли пользователь администратором чата. + /// + public bool IsAdmin { get; internal set; } + + /// + /// Дата присоединения к чату (Unix-время в миллисекундах). + /// + public long JoinTime { get; internal set; } + + /// + /// Перечень прав пользователя в чате. + /// + public ChatAdminPermission[]? Permissions { get; internal set; } + + /// + /// Заголовок, который будет показан на клиенте (например, "владелец", "админ"). + /// + public string? Alias { get; internal set; } + } + + /// + /// Ответ на запрос списка участников чата. + /// + public class GetChatMembersResponse + { + /// + /// Список участников чата. + /// + public ChatMember[]? Members { get; internal set; } + + /// + /// Указатель на следующую страницу данных. + /// + public long? Marker { get; internal set; } + } + + /// + /// Администратор чата для назначения. + /// + /// + public class ChatAdmin + { + /// + /// Идентификатор пользователя-участника чата. Максимум 50 администраторов в чате. + /// + public long UserId { get; set; } + + /// + /// Перечень прав доступа. Право "ReadAllMessages" важно для ботов — без него бот не получает апдейты. + /// + public ChatAdminPermission[]? Permissions { get; set; } + + /// + /// Заголовок, который будет показан на клиенте (например, "модератор"). + /// + public string? Alias { get; set; } + + /// + /// Создать администратора с указанными правами. + /// + public ChatAdmin(long userId, params ChatAdminPermission[] permissions) + { + UserId = userId; + Permissions = permissions; + } + + /// + /// Создать администратора с указанными правами и заголовком. + /// + public ChatAdmin(long userId, string alias, params ChatAdminPermission[] permissions) + { + UserId = userId; + Alias = alias; + Permissions = permissions; + } + } +} diff --git a/src/Max.BotClient/Types/Message.cs b/src/Max.BotClient/Types/Message.cs new file mode 100644 index 0000000..4eb5cfe --- /dev/null +++ b/src/Max.BotClient/Types/Message.cs @@ -0,0 +1,1087 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Max.BotClient.Types.Builders; + +namespace Max.BotClient.Types +{ + /// + /// Ответ метода GET /messages. + /// + public class GetMessagesResponse + { + /// + /// Массив сообщений. + /// + public Message[]? Messages { get; set; } + } + + /// + /// Тип связи сообщения. + /// + public enum MessageLinkType + { + Forward, + Reply + } + + /// + /// Тип чата. + /// + public enum ChatType + { + Chat + } + + /// + /// Тип вложения. + /// + public enum AttachmentType + { + Image, + Video, + Audio, + File, + Sticker, + Contact, + Share, + Location, + InlineKeyboard + } + + /// + /// Тип элемента разметки. + /// + public enum MarkupType + { + Strong, + Emphasized, + Monospaced, + Link, + Strikethrough, + Underline, + UserMention + } + + /// + /// Тип получателя сообщения. + /// + public enum RecipientType + { + /// + /// Отправка пользователю (по умолчанию). + /// + User, + + /// + /// Отправка в чат. + /// + Chat + } + + /// + /// Сообщение в чате. Используется для получения, создания и редактирования сообщений. + /// + /// + public class Message + { + // === Builder state (для создания и редактирования) === + private string? _builderText; + private readonly Dictionary> _builderAttachments = new Dictionary>(); + private InlineKeyboardBuilder? _builderKeyboard; + private NewMessageLink? _builderLink; + private bool? _builderNotify = true; + private TextFormat? _builderFormat = TextFormat.Markdown; + private RecipientType _builderRecipientType = RecipientType.User; + private bool _isBuilderMode; + + // === Sender === + + /// + /// Пользователь, отправивший сообщение. + /// + public User? Sender { get; internal set; } + + // === Recipient === + + /// + /// ID чата получателя. + /// + public long? RecipientChatId { get; internal set; } + + /// + /// Тип чата получателя. + /// + public ChatType? RecipientChatType { get; internal set; } + + /// + /// ID пользователя получателя. + /// + public long? RecipientUserId { get; internal set; } + + // === Message === + + /// + /// Время создания сообщения в формате Unix-time. + /// + public long? Timestamp { get; internal set; } + + /// + /// Публичная ссылка на пост в канале. + /// + public string? Url { get; internal set; } + + // === Link (LinkedMessage) === + + /// + /// Тип связанного сообщения. + /// + public MessageLinkType? LinkType { get; internal set; } + + /// + /// Отправитель связанного сообщения. + /// + public User? LinkSender { get; internal set; } + + /// + /// ID чата связанного сообщения. + /// + public long? LinkChatId { get; internal set; } + + /// + /// ID связанного сообщения. + /// + public string? LinkMid { get; internal set; } + + /// + /// Seq связанного сообщения. + /// + public long? LinkSeq { get; internal set; } + + /// + /// Текст связанного сообщения. + /// + public string? LinkText { get; internal set; } + + /// + /// Вложения связанного сообщения. + /// + public Attachment[]? LinkAttachments { get; internal set; } + + /// + /// Разметка связанного сообщения. + /// + public MarkupElement[]? LinkMarkup { get; internal set; } + + // === Body (MessageBody) === + + /// + /// Уникальный ID сообщения. + /// + public string? Mid { get; internal set; } + + /// + /// ID последовательности сообщения в чате. + /// + public long? Seq { get; internal set; } + + /// + /// Текст сообщения. + /// + public string? Text { get; internal set; } + + /// + /// Вложения сообщения (сырые данные из API). + /// + internal Attachment[]? Attachments { get; set; } + + /// + /// Разметка сообщения. + /// + public MarkupElement[]? Markup { get; internal set; } + + // === Stat (MessageStat) === + + /// + /// Количество просмотров. + /// + public int? Views { get; internal set; } + + // === Конструкторы === + + /// + /// Создает новое пустое сообщение для отправки. + /// + public Message() + { + _isBuilderMode = true; + } + + /// + /// Создает новое сообщение с текстом. + /// + /// Текст сообщения + public Message(string text) : this() + { + _builderText = text; + } + + // === Методы получения вложений (Get*) === + + /// + /// Получает все вложения типа Photo. + /// + public IReadOnlyList GetPhotos() + { + if (_isBuilderMode && _builderAttachments.TryGetValue(AttachmentType.Image, out var builderPhotos)) + { + return builderPhotos.Cast().ToList(); + } + + return Attachments? + .Where(a => a.Type == AttachmentType.Image) + .Select(a => new PhotoAttachment + { + PhotoId = a.PhotoId, + Url = a.Url, + Token = a.Token + }) + .ToList() ?? new List(); + } + + /// + /// Получает все вложения типа Video. + /// + public IReadOnlyList GetVideos() + { + if (_isBuilderMode && _builderAttachments.TryGetValue(AttachmentType.Video, out var builderVideos)) + { + return builderVideos.Cast().ToList(); + } + + return Attachments? + .Where(a => a.Type == AttachmentType.Video) + .Select(a => new VideoAttachment + { + Url = a.Url, + Token = a.Token, + Width = a.Width, + Height = a.Height, + Duration = a.Duration, + ThumbnailUrl = a.ThumbnailUrl + }) + .ToList() ?? new List(); + } + + /// + /// Получает все вложения типа Audio. + /// + public IReadOnlyList GetAudio() + { + if (_isBuilderMode && _builderAttachments.TryGetValue(AttachmentType.Audio, out var builderAudio)) + { + return builderAudio.Cast().ToList(); + } + + return Attachments? + .Where(a => a.Type == AttachmentType.Audio) + .Select(a => new AudioAttachment + { + Url = a.Url, + Token = a.Token, + Transcription = a.Transcription + }) + .ToList() ?? new List(); + } + + /// + /// Получает все вложения типа File. + /// + public IReadOnlyList GetFiles() + { + if (_isBuilderMode && _builderAttachments.TryGetValue(AttachmentType.File, out var builderFiles)) + { + return builderFiles.Cast().ToList(); + } + + return Attachments? + .Where(a => a.Type == AttachmentType.File) + .Select(a => new FileAttachment + { + Url = a.Url, + Token = a.Token, + Filename = a.Filename, + Size = a.Size + }) + .ToList() ?? new List(); + } + + /// + /// Получает все вложения типа Sticker. + /// + public IReadOnlyList GetStickers() + { + if (_isBuilderMode && _builderAttachments.TryGetValue(AttachmentType.Sticker, out var builderStickers)) + { + return builderStickers.Cast().ToList(); + } + + return Attachments? + .Where(a => a.Type == AttachmentType.Sticker) + .Select(a => new StickerAttachment + { + Url = a.Url, + Code = a.Code, + Width = a.Width, + Height = a.Height + }) + .ToList() ?? new List(); + } + + /// + /// Получает все вложения типа Contact. + /// + public IReadOnlyList GetContacts() + { + if (_isBuilderMode && _builderAttachments.TryGetValue(AttachmentType.Contact, out var builderContacts)) + { + return builderContacts.Cast().ToList(); + } + + return Attachments? + .Where(a => a.Type == AttachmentType.Contact) + .Select(a => new ContactAttachment + { + VcfInfo = a.VcfInfo, + ContactUser = a.ContactUser + }) + .ToList() ?? new List(); + } + + /// + /// Получает все вложения типа Share. + /// + public IReadOnlyList GetShares() + { + if (_isBuilderMode && _builderAttachments.TryGetValue(AttachmentType.Share, out var builderShares)) + { + return builderShares.Cast().ToList(); + } + + return Attachments? + .Where(a => a.Type == AttachmentType.Share) + .Select(a => new ShareAttachment + { + Url = a.Url, + Token = a.Token, + Title = a.Title, + Description = a.Description, + ImageUrl = a.ImageUrl + }) + .ToList() ?? new List(); + } + + /// + /// Получает все вложения типа Location. + /// + public IReadOnlyList GetLocations() + { + if (_isBuilderMode && _builderAttachments.TryGetValue(AttachmentType.Location, out var builderLocations)) + { + return builderLocations.Cast().ToList(); + } + + return Attachments? + .Where(a => a.Type == AttachmentType.Location) + .Select(a => new LocationAttachment + { + Latitude = a.Latitude, + Longitude = a.Longitude + }) + .ToList() ?? new List(); + } + + /// + /// Получает inline клавиатуру. + /// + public InlineKeyboardAttachment? GetKeyboard() + { + if (_isBuilderMode && _builderKeyboard != null) + { + return _builderKeyboard.ToKeyboardAttachment(); + } + + var keyboardAttachment = Attachments?.FirstOrDefault(a => a.Type == AttachmentType.InlineKeyboard); + if (keyboardAttachment == null) return null; + + return new InlineKeyboardAttachment + { + Buttons = keyboardAttachment.Buttons + }; + } + + // === Методы построения (With*) === + + /// + /// Устанавливает текст сообщения. + /// + public Message WithText(string text) + { + _isBuilderMode = true; + _builderText = text; + return this; + } + + /// + /// Устанавливает формат текста (Markdown или HTML). + /// + public Message WithFormat(TextFormat format) + { + _isBuilderMode = true; + _builderFormat = format; + return this; + } + + /// + /// Добавляет или заменяет изображения. При первом вызове заменяет все существующие изображения. + /// + public Message WithPhoto(string url) + { + _isBuilderMode = true; + if (!_builderAttachments.ContainsKey(AttachmentType.Image)) + _builderAttachments[AttachmentType.Image] = new List(); + else + _builderAttachments[AttachmentType.Image].Clear(); + + _builderAttachments[AttachmentType.Image].Add(PhotoAttachment.FromUrl(url)); + return this; + } + + /// + /// Добавляет изображение к уже существующим. + /// + public Message AddPhoto(string url) + { + _isBuilderMode = true; + if (!_builderAttachments.ContainsKey(AttachmentType.Image)) + _builderAttachments[AttachmentType.Image] = new List(); + + _builderAttachments[AttachmentType.Image].Add(PhotoAttachment.FromUrl(url)); + return this; + } + + /// + /// Добавляет изображение по токену. + /// + public Message WithPhotoToken(string token) + { + _isBuilderMode = true; + if (!_builderAttachments.ContainsKey(AttachmentType.Image)) + _builderAttachments[AttachmentType.Image] = new List(); + else + _builderAttachments[AttachmentType.Image].Clear(); + + _builderAttachments[AttachmentType.Image].Add(PhotoAttachment.FromToken(token)); + return this; + } + + /// + /// Заменяет все изображения на указанные. + /// + public Message ReplacePhotos(params PhotoAttachment[] photos) + { + _isBuilderMode = true; + _builderAttachments[AttachmentType.Image] = new List(photos); + return this; + } + + /// + /// Удаляет все изображения. + /// + public Message ClearPhotos() + { + _isBuilderMode = true; + _builderAttachments.Remove(AttachmentType.Image); + return this; + } + + /// + /// Добавляет или заменяет видео. + /// + public Message WithVideo(string token) + { + _isBuilderMode = true; + if (!_builderAttachments.ContainsKey(AttachmentType.Video)) + _builderAttachments[AttachmentType.Video] = new List(); + else + _builderAttachments[AttachmentType.Video].Clear(); + + _builderAttachments[AttachmentType.Video].Add(VideoAttachment.FromToken(token)); + return this; + } + + /// + /// Добавляет видео к уже существующим. + /// + public Message AddVideo(string token) + { + _isBuilderMode = true; + if (!_builderAttachments.ContainsKey(AttachmentType.Video)) + _builderAttachments[AttachmentType.Video] = new List(); + + _builderAttachments[AttachmentType.Video].Add(VideoAttachment.FromToken(token)); + return this; + } + + /// + /// Заменяет все видео на указанные. + /// + public Message ReplaceVideos(params VideoAttachment[] videos) + { + _isBuilderMode = true; + _builderAttachments[AttachmentType.Video] = new List(videos); + return this; + } + + /// + /// Удаляет все видео. + /// + public Message ClearVideos() + { + _isBuilderMode = true; + _builderAttachments.Remove(AttachmentType.Video); + return this; + } + + /// + /// Добавляет или заменяет аудио. + /// + public Message WithAudio(string token) + { + _isBuilderMode = true; + if (!_builderAttachments.ContainsKey(AttachmentType.Audio)) + _builderAttachments[AttachmentType.Audio] = new List(); + else + _builderAttachments[AttachmentType.Audio].Clear(); + + _builderAttachments[AttachmentType.Audio].Add(AudioAttachment.FromToken(token)); + return this; + } + + /// + /// Добавляет аудио к уже существующим. + /// + public Message AddAudio(string token) + { + _isBuilderMode = true; + if (!_builderAttachments.ContainsKey(AttachmentType.Audio)) + _builderAttachments[AttachmentType.Audio] = new List(); + + _builderAttachments[AttachmentType.Audio].Add(AudioAttachment.FromToken(token)); + return this; + } + + /// + /// Заменяет все аудио на указанные. + /// + public Message ReplaceAudio(params AudioAttachment[] audio) + { + _isBuilderMode = true; + _builderAttachments[AttachmentType.Audio] = new List(audio); + return this; + } + + /// + /// Удаляет все аудио. + /// + public Message ClearAudio() + { + _isBuilderMode = true; + _builderAttachments.Remove(AttachmentType.Audio); + return this; + } + + /// + /// Добавляет или заменяет файлы. + /// + public Message WithFile(string token) + { + _isBuilderMode = true; + if (!_builderAttachments.ContainsKey(AttachmentType.File)) + _builderAttachments[AttachmentType.File] = new List(); + else + _builderAttachments[AttachmentType.File].Clear(); + + _builderAttachments[AttachmentType.File].Add(FileAttachment.FromToken(token)); + return this; + } + + /// + /// Добавляет файл к уже существующим. + /// + public Message AddFile(string token) + { + _isBuilderMode = true; + if (!_builderAttachments.ContainsKey(AttachmentType.File)) + _builderAttachments[AttachmentType.File] = new List(); + + _builderAttachments[AttachmentType.File].Add(FileAttachment.FromToken(token)); + return this; + } + + /// + /// Заменяет все файлы на указанные. + /// + public Message ReplaceFiles(params FileAttachment[] files) + { + _isBuilderMode = true; + _builderAttachments[AttachmentType.File] = new List(files); + return this; + } + + /// + /// Удаляет все файлы. + /// + public Message ClearFiles() + { + _isBuilderMode = true; + _builderAttachments.Remove(AttachmentType.File); + return this; + } + + /// + /// Добавляет стикер. + /// + public Message WithSticker(string code) + { + _isBuilderMode = true; + if (!_builderAttachments.ContainsKey(AttachmentType.Sticker)) + _builderAttachments[AttachmentType.Sticker] = new List(); + else + _builderAttachments[AttachmentType.Sticker].Clear(); + + _builderAttachments[AttachmentType.Sticker].Add(StickerAttachment.FromCode(code)); + return this; + } + + /// + /// Удаляет все стикеры. + /// + public Message ClearStickers() + { + _isBuilderMode = true; + _builderAttachments.Remove(AttachmentType.Sticker); + return this; + } + + /// + /// Добавляет контакт. + /// + public Message WithContact(string name, long? contactId = null, string? vcfInfo = null, string? vcfPhone = null) + { + _isBuilderMode = true; + if (!_builderAttachments.ContainsKey(AttachmentType.Contact)) + _builderAttachments[AttachmentType.Contact] = new List(); + else + _builderAttachments[AttachmentType.Contact].Clear(); + + _builderAttachments[AttachmentType.Contact].Add(ContactAttachment.Create(name, contactId, vcfInfo, vcfPhone)); + return this; + } + + /// + /// Удаляет все контакты. + /// + public Message ClearContacts() + { + _isBuilderMode = true; + _builderAttachments.Remove(AttachmentType.Contact); + return this; + } + + /// + /// Добавляет геолокацию. + /// + public Message WithLocation(double latitude, double longitude) + { + _isBuilderMode = true; + if (!_builderAttachments.ContainsKey(AttachmentType.Location)) + _builderAttachments[AttachmentType.Location] = new List(); + else + _builderAttachments[AttachmentType.Location].Clear(); + + _builderAttachments[AttachmentType.Location].Add(LocationAttachment.Create(latitude, longitude)); + return this; + } + + /// + /// Удаляет все геолокации. + /// + public Message ClearLocations() + { + _isBuilderMode = true; + _builderAttachments.Remove(AttachmentType.Location); + return this; + } + + /// + /// Добавляет ссылку для предпросмотра. + /// + public Message WithShare(string url) + { + _isBuilderMode = true; + if (!_builderAttachments.ContainsKey(AttachmentType.Share)) + _builderAttachments[AttachmentType.Share] = new List(); + else + _builderAttachments[AttachmentType.Share].Clear(); + + _builderAttachments[AttachmentType.Share].Add(ShareAttachment.FromUrl(url)); + return this; + } + + /// + /// Удаляет все ссылки. + /// + public Message ClearShares() + { + _isBuilderMode = true; + _builderAttachments.Remove(AttachmentType.Share); + return this; + } + + /// + /// Добавляет inline клавиатуру к сообщению. + /// + public Message WithKeyboard(InlineKeyboardBuilder keyboard) + { + _isBuilderMode = true; + _builderKeyboard = keyboard; + return this; + } + + /// + /// Добавляет inline клавиатуру к сообщению через функцию конфигурации. + /// + public Message WithKeyboard(Action configure) + { + _isBuilderMode = true; + _builderKeyboard = new InlineKeyboardBuilder(); + configure(_builderKeyboard); + return this; + } + + /// + /// Удаляет клавиатуру. + /// + public Message ClearKeyboard() + { + _isBuilderMode = true; + _builderKeyboard = null; + return this; + } + + /// + /// Добавляет ссылку на другое сообщение (Reply/Forward). + /// + public Message WithLink(MessageLinkRequestType type, string messageId) + { + _isBuilderMode = true; + _builderLink = new NewMessageLink + { + Type = type, + Mid = messageId + }; + return this; + } + + /// + /// Отключает уведомление для получателя. + /// + public Message WithoutNotification() + { + _isBuilderMode = true; + _builderNotify = false; + return this; + } + + /// + /// Включает уведомление для получателя (по умолчанию включено). + /// + public Message WithNotification() + { + _isBuilderMode = true; + _builderNotify = true; + return this; + } + + /// + /// Указывает, что сообщение должно быть отправлено в чат. + /// + public Message ToChat() + { + _isBuilderMode = true; + _builderRecipientType = RecipientType.Chat; + return this; + } + + /// + /// Указывает, что сообщение должно быть отправлено пользователю (по умолчанию). + /// + public Message ToUser() + { + _isBuilderMode = true; + _builderRecipientType = RecipientType.User; + return this; + } + + // === Внутренние методы для API === + + /// + /// Возвращает тип получателя сообщения. + /// + internal RecipientType GetRecipientType() => _builderRecipientType; + + /// + /// Преобразует сообщение в NewMessageBody для отправки через API. + /// + internal NewMessageBody ToMessageBody() + { + var attachments = new List(); + + // Собираем все вложения из builder'а + foreach (var kvp in _builderAttachments) + { + foreach (var attachment in kvp.Value) + { + attachments.Add(ConvertToAttachmentRequest(attachment)); + } + } + + // Добавляем клавиатуру как attachment, если она есть + if (_builderKeyboard != null) + { + attachments.Add(_builderKeyboard.ToAttachmentRequest()); + } + + return new NewMessageBody + { + Text = _builderText ?? Text, + Attachments = attachments.Count > 0 ? attachments.ToArray() : null, + Link = _builderLink, + Notify = _builderNotify, + Format = _builderFormat + }; + } + + private AttachmentRequest ConvertToAttachmentRequest(IAttachment attachment) + { + switch (attachment) + { + case PhotoAttachment photo: + return new AttachmentRequest + { + Type = AttachmentRequestType.Image, + Payload = new AttachmentPayload + { + Url = photo.Url, + Token = photo.Token + } + }; + + case VideoAttachment video: + return new AttachmentRequest + { + Type = AttachmentRequestType.Video, + Payload = new AttachmentPayload + { + Token = video.Token + } + }; + + case AudioAttachment audio: + return new AttachmentRequest + { + Type = AttachmentRequestType.Audio, + Payload = new AttachmentPayload + { + Token = audio.Token + } + }; + + case FileAttachment file: + return new AttachmentRequest + { + Type = AttachmentRequestType.File, + Payload = new AttachmentPayload + { + Token = file.Token + } + }; + + case StickerAttachment sticker: + return new AttachmentRequest + { + Type = AttachmentRequestType.Sticker, + Payload = new AttachmentPayload + { + Code = sticker.Code + } + }; + + case ContactAttachment contact: + return new AttachmentRequest + { + Type = AttachmentRequestType.Contact, + Payload = new AttachmentPayload + { + Name = contact.Name, + ContactId = contact.ContactId, + VcfInfo = contact.VcfInfo, + VcfPhone = contact.VcfPhone + } + }; + + case LocationAttachment location: + return new AttachmentRequest + { + Type = AttachmentRequestType.Location, + Payload = new AttachmentPayload + { + Latitude = location.Latitude, + Longitude = location.Longitude + } + }; + + case ShareAttachment share: + return new AttachmentRequest + { + Type = AttachmentRequestType.Share, + Payload = new AttachmentPayload + { + ShareUrl = share.Url + } + }; + + default: + throw new NotSupportedException($"Attachment type {attachment.GetType().Name} is not supported"); + } + } + } + + /// + /// Вложение сообщения (сырые данные из API). + /// + public class Attachment + { + public AttachmentType? Type { get; set; } + public long? PhotoId { get; set; } + public int? Width { get; set; } + public int? Height { get; set; } + public int? Duration { get; set; } + public string? ThumbnailUrl { get; set; } + public string? Transcription { get; set; } + public string? Filename { get; set; } + public long? Size { get; set; } + public string? Code { get; set; } + public string? VcfInfo { get; set; } + public User? ContactUser { get; set; } + public string? Title { get; set; } + public string? Description { get; set; } + public string? ImageUrl { get; set; } + public double? Latitude { get; set; } + public double? Longitude { get; set; } + public Button[][]? Buttons { get; set; } + public string? Url { get; set; } + public string? Token { get; set; } + } + + /// + /// Элемент разметки сообщения. + /// + public class MarkupElement + { + /// + /// Тип элемента разметки. + /// + public MarkupType? Type { get; set; } + + /// + /// Индекс начала элемента разметки в тексте. + /// + public int? From { get; set; } + + /// + /// Длина элемента разметки. + /// + public int? Length { get; set; } + + /// + /// URL ссылки (для Link). + /// + public string? Url { get; set; } + + /// + /// @username упомянутого пользователя (для UserMention). + /// + public string? UserLink { get; set; } + + /// + /// ID упомянутого пользователя (для UserMention). + /// + public long? UserId { get; set; } + } + + /// + /// Тип кнопки. + /// + public enum ButtonType + { + Callback, + Link, + RequestGeoLocation, + RequestContact, + OpenApp, + Message + } + + /// + /// Кнопка клавиатуры. + /// + public class Button + { + /// + /// Тип кнопки. + /// + public ButtonType? Type { get; set; } + + /// + /// Видимый текст кнопки. + /// + public string? Text { get; set; } + + /// + /// Токен кнопки (для Callback). + /// + public string? Payload { get; set; } + + /// + /// URL ссылки (для Link). + /// + public string? Url { get; set; } + + /// + /// Отправить местоположение без подтверждения (для RequestGeoLocation). + /// + public bool? Quick { get; set; } + + /// + /// Username бота для мини-приложения (для OpenApp). + /// + public string? WebApp { get; set; } + + /// + /// ID бота для мини-приложения (для OpenApp). + /// + public long? ContactId { get; set; } + } +} diff --git a/src/Max.BotClient/Types/NewMessageBody.cs b/src/Max.BotClient/Types/NewMessageBody.cs new file mode 100644 index 0000000..2fe538b --- /dev/null +++ b/src/Max.BotClient/Types/NewMessageBody.cs @@ -0,0 +1,34 @@ +namespace Max.BotClient.Types +{ + /// + /// Тело нового сообщения для отправки. + /// + /// + public class NewMessageBody + { + /// + /// Текст сообщения (до 4000 символов). + /// + public string? Text { get; set; } + + /// + /// Вложения сообщения. + /// + public AttachmentRequest[]? Attachments { get; set; } + + /// + /// Ссылка на сообщение (reply/forward). + /// + public NewMessageLink? Link { get; set; } + + /// + /// Уведомлять участников чата (по умолчанию true). + /// + public bool? Notify { get; set; } + + /// + /// Формат текста (markdown/html). + /// + public TextFormat? Format { get; set; } + } +} diff --git a/src/Max.BotClient/Types/SendMessage.cs b/src/Max.BotClient/Types/SendMessage.cs new file mode 100644 index 0000000..cbcec9f --- /dev/null +++ b/src/Max.BotClient/Types/SendMessage.cs @@ -0,0 +1,47 @@ +namespace Max.BotClient.Types +{ + /// + /// Формат текста сообщения. + /// + public enum TextFormat + { + Markdown, + Html + } + + /// + /// Тип ссылки на сообщение. + /// + public enum MessageLinkRequestType + { + Reply, + Forward + } + + /// + /// Ссылка на сообщение для отправки. + /// + public class NewMessageLink + { + /// + /// Тип ссылки. + /// + public MessageLinkRequestType Type { get; set; } + + /// + /// ID сообщения, на которое ссылается. + /// + public string Mid { get; set; } = null!; + } + + /// + /// Ответ на отправку сообщения. + /// + public class SendMessageResponse + { + /// + /// Отправленное сообщение. + /// + public Message? Message { get; set; } + } +} diff --git a/src/Max.BotClient/Types/Subscription.cs b/src/Max.BotClient/Types/Subscription.cs new file mode 100644 index 0000000..2e101ef --- /dev/null +++ b/src/Max.BotClient/Types/Subscription.cs @@ -0,0 +1,94 @@ +namespace Max.BotClient.Types +{ + /// + /// Тип обновления. + /// + public enum UpdateType + { + MessageCreated, + MessageCallback, + MessageEdited, + MessageRemoved, + BotAdded, + BotRemoved, + DialogMuted, + DialogUnmuted, + DialogCleared, + DialogRemoved, + UserAdded, + UserRemoved, + BotStarted, + BotStopped, + ChatTitleChanged + } + + /// + /// Подписка на вебхук. + /// + /// + public class Subscription + { + /// + /// URL вебхука. + /// + public string Url { get; set; } = string.Empty; + + /// + /// Unix-время создания подписки. + /// + public long Time { get; set; } + + /// + /// Типы обновлений, на которые подписан бот. + /// + public UpdateType[]? UpdateTypes { get; set; } + } + + /// + /// Результат операции подписки. + /// + public class SubscribeResult + { + /// + /// true, если запрос был успешным. + /// + public bool Success { get; set; } + + /// + /// Объяснительное сообщение, если результат не был успешным. + /// + public string? Message { get; set; } + } + + /// + /// Ответ со списком подписок. + /// + internal class GetSubscriptionsResponse + { + /// + /// Список текущих подписок. + /// + public Subscription[] Subscriptions { get; set; } = []; + } + + /// + /// Запрос на подписку. + /// + internal class SubscribeRequest + { + /// + /// URL вебхука. + /// + public string Url { get; set; } = string.Empty; + + /// + /// Типы обновлений для подписки. + /// + public UpdateType[]? UpdateTypes { get; set; } + + /// + /// Секрет для заголовка X-Max-Bot-Api-Secret. + /// + public string? Secret { get; set; } + } +} diff --git a/src/Max.BotClient/Types/Update.cs b/src/Max.BotClient/Types/Update.cs new file mode 100644 index 0000000..c8f9003 --- /dev/null +++ b/src/Max.BotClient/Types/Update.cs @@ -0,0 +1,100 @@ +namespace Max.BotClient.Types +{ + /// + /// Обновление от API. + /// + /// + public class Update + { + /// + /// Тип обновления. + /// + public UpdateType UpdateType { get; set; } + + /// + /// Unix-время события. + /// + public long Timestamp { get; set; } + + /// + /// Сообщение (для MessageCreated, MessageCallback, MessageEdited). + /// + public Message? Message { get; set; } + + /// + /// Данные callback (для MessageCallback). + /// + public Callback? Callback { get; set; } + + /// + /// ID сообщения (для MessageRemoved). + /// + public string? MessageId { get; set; } + + /// + /// ID чата. + /// + public long? ChatId { get; set; } + + /// + /// ID пользователя (для MessageRemoved). + /// + public long? UserId { get; set; } + + /// + /// Пользователь. + /// + public User? User { get; set; } + + /// + /// Является ли чат каналом (для BotAdded, BotRemoved, UserAdded, UserRemoved). + /// + public bool? IsChannel { get; set; } + + /// + /// Время окончания mute (для DialogMuted). + /// + public long? MutedUntil { get; set; } + + /// + /// ID пригласившего пользователя (для UserAdded). + /// + public long? InviterId { get; set; } + + /// + /// ID администратора (для UserRemoved). + /// + public long? AdminId { get; set; } + + /// + /// Payload данные (для BotStarted). + /// + public string? Payload { get; set; } + + /// + /// Новое название чата (для ChatTitleChanged). + /// + public string? Title { get; set; } + + /// + /// Локаль пользователя в формате IETF BCP 47. + /// + public string? UserLocale { get; set; } + } + + /// + /// Ответ с обновлениями. + /// + internal class GetUpdatesResponse + { + /// + /// Список обновлений. + /// + public Update[] Updates { get; set; } = []; + + /// + /// Маркер для следующего запроса. + /// + public long? Marker { get; set; } + } +} diff --git a/src/Max.BotClient/Types/UpdateChat.cs b/src/Max.BotClient/Types/UpdateChat.cs new file mode 100644 index 0000000..f6ff07d --- /dev/null +++ b/src/Max.BotClient/Types/UpdateChat.cs @@ -0,0 +1,75 @@ +namespace Max.BotClient.Types +{ + /// + /// Параметры для обновления информации о чате. + /// + public class UpdateChatRequest + { + /// + /// Иконка чата (изображение). + /// + public ChatIcon? Icon { get; set; } + + /// + /// Название чата (от 1 до 200 символов). + /// + public string? Title { get; set; } + + /// + /// ID сообщения для закрепления в чате. Для удаления закреплённого сообщения используйте метод Unpin. + /// + public string? Pin { get; set; } + + /// + /// Если true, участники получат системное уведомление об изменении (по умолчанию true). + /// + public bool? Notify { get; set; } + } + + /// + /// Иконка чата (изображение). + /// + public class ChatIcon + { + /// + /// URL внешнего изображения. + /// + public string? Url { get; set; } + + /// + /// Токен существующего вложения. + /// + public string? Token { get; set; } + + /// + /// Токены, полученные после загрузки изображений. + /// + public PhotoToken? Photos { get; set; } + + /// + /// Создать иконку с URL изображения. + /// + public static ChatIcon FromUrl(string url) => new ChatIcon { Url = url }; + + /// + /// Создать иконку с токеном изображения. + /// + public static ChatIcon FromToken(string token) => new ChatIcon { Token = token }; + + /// + /// Создать иконку с токеном загруженного изображения. + /// + public static ChatIcon FromPhotos(PhotoToken photos) => new ChatIcon { Photos = photos }; + } + + /// + /// Токен загруженного изображения. + /// + public class PhotoToken + { + /// + /// Закодированная информация загруженного изображения. + /// + public string? Token { get; set; } + } +} diff --git a/src/Max.BotClient/Types/Upload.cs b/src/Max.BotClient/Types/Upload.cs new file mode 100644 index 0000000..b5b0d05 --- /dev/null +++ b/src/Max.BotClient/Types/Upload.cs @@ -0,0 +1,30 @@ +namespace Max.BotClient.Types +{ + /// + /// Тип загружаемого файла. + /// + public enum UploadType + { + Image, + Video, + Audio, + File + } + + /// + /// Результат запроса URL для загрузки файла. + /// + /// + public class UploadResult + { + /// + /// URL для загрузки файла. + /// + public string? Url { get; set; } + + /// + /// Токен для video/audio (используется при отправке сообщения). + /// + public string? Token { get; set; } + } +} diff --git a/src/Max.BotClient/Types/User.cs b/src/Max.BotClient/Types/User.cs new file mode 100644 index 0000000..2958273 --- /dev/null +++ b/src/Max.BotClient/Types/User.cs @@ -0,0 +1,64 @@ +namespace Max.BotClient.Types +{ + /// + /// Пользователь или бот. + /// + /// + public class User + { + /// + /// Идентификатор пользователя или бота. + /// + public long? UserId { get; set; } + + /// + /// Отображаемое имя пользователя или бота. + /// + public string? FirstName { get; set; } + + /// + /// Отображаемая фамилия пользователя. Для ботов это поле не возвращается. + /// + public string? LastName { get; set; } + + /// + /// Публичный username пользователя. Может быть null, если недоступен. + /// + public string? Username { get; set; } + + /// + /// Признак того, что аккаунт является ботом. + /// + public bool? IsBot { get; set; } + + /// + /// Время последней активности пользователя (Unix timestamp в миллисекундах). + /// + public long? LastActivityTime { get; set; } + + /// + /// Описание пользователя или бота. + /// + public string? Description { get; set; } + + /// + /// URL аватара пользователя или бота в уменьшенном размере. + /// + public string? AvatarUrl { get; set; } + + /// + /// URL аватара пользователя или бота в полном размере. + /// + public string? FullAvatarUrl { get; set; } + } + + /// + /// Пользователь с фотографией (расширенная информация). + /// + /// + public class UserWithPhoto : User + { + // Наследует все поля от User + // В базовом классе User уже есть Description, AvatarUrl, FullAvatarUrl + } +} diff --git a/src/Max.BotClient/Types/Video.cs b/src/Max.BotClient/Types/Video.cs new file mode 100644 index 0000000..50d34ec --- /dev/null +++ b/src/Max.BotClient/Types/Video.cs @@ -0,0 +1,80 @@ +namespace Max.BotClient.Types +{ + /// + /// URL-адреса для скачивания или воспроизведения видео. + /// + public class VideoUrls + { + /// + /// URL видео в формате MP4 с разрешением 1080p, если доступно. + /// + public string? Mp4Resolution1080p { get; internal set; } + + /// + /// URL видео в формате MP4 с разрешением 720p, если доступно. + /// + public string? Mp4Resolution720p { get; internal set; } + + /// + /// URL видео в формате MP4 с разрешением 480p, если доступно. + /// + public string? Mp4Resolution480p { get; internal set; } + + /// + /// URL видео в формате MP4 с разрешением 360p, если доступно. + /// + public string? Mp4Resolution360p { get; internal set; } + + /// + /// URL видео в формате MP4 с разрешением 240p, если доступно. + /// + public string? Mp4Resolution240p { get; internal set; } + + /// + /// URL видео в формате MP4 с разрешением 144p, если доступно. + /// + public string? Mp4Resolution144p { get; internal set; } + + /// + /// URL потоковой трансляции в формате HLS, если доступна. + /// + public string? HlsStream { get; internal set; } + } + + /// + /// Подробная информация о прикреплённом видео. + /// + /// + public class VideoInfo + { + /// + /// Токен видео-вложения. + /// + public string? Token { get; internal set; } + + /// + /// URL-адреса для скачивания или воспроизведения видео. Может быть null, если видео недоступно. + /// + public VideoUrls? Urls { get; internal set; } + + /// + /// Миниатюра видео. + /// + public PhotoAttachment? Thumbnail { get; internal set; } + + /// + /// Ширина видео. + /// + public int? Width { get; internal set; } + + /// + /// Высота видео. + /// + public int? Height { get; internal set; } + + /// + /// Длина видео в секундах. + /// + public int? Duration { get; internal set; } + } +} From 817224de93f51ffc12a3aa01bd77c7a6e91f5e1c Mon Sep 17 00:00:00 2001 From: Aleksandr Semenenko <112961081+Deskri@users.noreply.github.com> Date: Fri, 20 Feb 2026 00:12:48 +0400 Subject: [PATCH 02/18] feat: add api methods params --- .gitignore | 3 + .../Max.BotClient.ApiExtensions.cs | 42 ++++- .../Max.BotClient.ApiMethods.Chats.cs | 156 +++++------------ .../Max.BotClient.ApiMethods.Members.cs | 161 +++++------------ .../Max.BotClient.ApiMethods.Messages.cs | 162 ++++++------------ .../Max.BotClient.ApiMethods.Misc.cs | 34 ++-- .../Max.BotClient.ApiMethods.Subscriptions.cs | 32 +--- src/Max.BotClient/Max.BotClient.ApiRequest.cs | 118 +++++++++++++ .../Max.BotClient.ApiRequests.cs | 94 ++++++++++ 9 files changed, 415 insertions(+), 387 deletions(-) create mode 100644 .gitignore create mode 100644 src/Max.BotClient/Max.BotClient.ApiRequest.cs create mode 100644 src/Max.BotClient/Max.BotClient.ApiRequests.cs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..62274da --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/src/Max.BotClient/bin/ +/src/Max.BotClient/obj/ +/.idea/ \ No newline at end of file diff --git a/src/Max.BotClient/Max.BotClient.ApiExtensions.cs b/src/Max.BotClient/Max.BotClient.ApiExtensions.cs index c9a27e7..aab0231 100644 --- a/src/Max.BotClient/Max.BotClient.ApiExtensions.cs +++ b/src/Max.BotClient/Max.BotClient.ApiExtensions.cs @@ -1,4 +1,5 @@ -using System.Net.Http; +using System; +using System.Net.Http; using System.Threading; using System.Threading.Tasks; using Max.BotClient.Mapping; @@ -34,5 +35,42 @@ public static async Task ProcessApi( object? body = null, CancellationToken cancellationToken = default ) => await botClient.SendRequest(method, path, body, cancellationToken); + + public static async Task ProcessApi( + this BotClient botClient, + HttpMethod method, + string basePath, + Func createParams, + CancellationToken cancellationToken = default + ) where TParams : class + { + ApiRequestBinder.Bind(createParams(), basePath, out var path, out var body); + var dto = await botClient.SendRequest(method, path, body, cancellationToken); + return dto.ToResult(); + } + + public static async Task ProcessApi( + this BotClient botClient, + HttpMethod method, + string basePath, + Func createParams, + CancellationToken cancellationToken = default + ) where TParams : class + { + ApiRequestBinder.Bind(createParams(), basePath, out var path, out var body); + return await botClient.SendRequest(method, path, body, cancellationToken); + } + + public static async Task ProcessApi( + this BotClient botClient, + HttpMethod method, + string basePath, + Func createParams, + CancellationToken cancellationToken = default + ) where TParams : class + { + ApiRequestBinder.Bind(createParams(), basePath, out var path, out var body); + await botClient.SendRequest(method, path, body, cancellationToken); + } } -} \ No newline at end of file +} diff --git a/src/Max.BotClient/Max.BotClient.ApiMethods.Chats.cs b/src/Max.BotClient/Max.BotClient.ApiMethods.Chats.cs index 201d9ae..9c51071 100644 --- a/src/Max.BotClient/Max.BotClient.ApiMethods.Chats.cs +++ b/src/Max.BotClient/Max.BotClient.ApiMethods.Chats.cs @@ -1,4 +1,3 @@ -using System.Collections.Generic; using System.Net.Http; using System.Threading; using System.Threading.Tasks; @@ -23,26 +22,12 @@ public static partial class BotClientApiMethods int? count = null, long? marker = null, CancellationToken cancellationToken = default - ) - { - var queryParams = new List(); - if (count.HasValue) - queryParams.Add($"count={count.Value}"); - if (marker.HasValue) - queryParams.Add($"marker={marker.Value}"); - - var path = queryParams.Count > 0 - ? $"/chats?{string.Join("&", queryParams)}" - : "/chats"; - - var response = await botClient.ProcessApi( - HttpMethod.Get, - path, - cancellationToken: cancellationToken - ); - - return response; - } + ) => await botClient.ProcessApi( + HttpMethod.Get, + "/chats", + () => new GetChatsParams { Count = count, Marker = marker }, + cancellationToken + ); /// /// Получить информацию о групповом чате по его ID. @@ -56,18 +41,11 @@ public static partial class BotClientApiMethods this BotClient botClient, long chatId, CancellationToken cancellationToken = default - ) - { - var path = $"/chats/{chatId}"; - - var response = await botClient.ProcessApi( - HttpMethod.Get, - path, - cancellationToken: cancellationToken - ); - - return response; - } + ) => await botClient.ProcessApi( + HttpMethod.Get, + $"/chats/{chatId}", + cancellationToken: cancellationToken + ); /// /// Изменить информацию о групповом чате. @@ -83,19 +61,12 @@ public static partial class BotClientApiMethods long chatId, Types.UpdateChatRequest request, CancellationToken cancellationToken = default - ) - { - var path = $"/chats/{chatId}"; - - var response = await botClient.ProcessApi( - new HttpMethod("PATCH"), - path, - request.ToDto(), - cancellationToken - ); - - return response; - } + ) => await botClient.ProcessApi( + new HttpMethod("PATCH"), + $"/chats/{chatId}", + request.ToDto(), + cancellationToken + ); /// /// Удалить групповой чат для всех участников. @@ -109,18 +80,11 @@ public static async Task DeleteChat( this BotClient botClient, long chatId, CancellationToken cancellationToken = default - ) - { - var path = $"/chats/{chatId}"; - - var response = await botClient.ProcessApi( - HttpMethod.Delete, - path, - cancellationToken: cancellationToken - ); - - return response.Success; - } + ) => (await botClient.ProcessApi( + HttpMethod.Delete, + $"/chats/{chatId}", + cancellationToken: cancellationToken + )).Success; /// /// Отправить действие бота в групповой чат. @@ -136,19 +100,12 @@ public static async Task SendAction( long chatId, Types.SenderAction action, CancellationToken cancellationToken = default - ) - { - var path = $"/chats/{chatId}/actions"; - - var response = await botClient.ProcessApi( - HttpMethod.Post, - path, - new { action = action.ToString().ToSnakeCase() }, - cancellationToken - ); - - return response.Success; - } + ) => (await botClient.ProcessApi( + HttpMethod.Post, + $"/chats/{chatId}/actions", + () => new SendActionParams { Action = action }, + cancellationToken + )).Success; /// /// Получить закреплённое сообщение в групповом чате. @@ -162,18 +119,11 @@ public static async Task SendAction( this BotClient botClient, long chatId, CancellationToken cancellationToken = default - ) - { - var path = $"/chats/{chatId}/pin"; - - var response = await botClient.ProcessApi( - HttpMethod.Get, - path, - cancellationToken: cancellationToken - ); - - return response?.Message; - } + ) => (await botClient.ProcessApi( + HttpMethod.Get, + $"/chats/{chatId}/pin", + cancellationToken: cancellationToken + )).Message; /// /// Закрепить сообщение в групповом чате. @@ -191,23 +141,12 @@ public static async Task PinMessage( string messageId, bool? notify = null, CancellationToken cancellationToken = default - ) - { - var path = $"/chats/{chatId}/pin"; - - var requestBody = notify.HasValue - ? new { message_id = messageId, notify = notify.Value } - : (object)new { message_id = messageId }; - - var response = await botClient.ProcessApi( - new HttpMethod("PUT"), - path, - requestBody, - cancellationToken - ); - - return response.Success; - } + ) => (await botClient.ProcessApi( + new HttpMethod("PUT"), + $"/chats/{chatId}/pin", + () => new PinMessageParams { MessageId = messageId, Notify = notify }, + cancellationToken + )).Success; /// /// Удалить закреплённое сообщение в групповом чате. @@ -221,17 +160,10 @@ public static async Task UnpinMessage( this BotClient botClient, long chatId, CancellationToken cancellationToken = default - ) - { - var path = $"/chats/{chatId}/pin"; - - var response = await botClient.ProcessApi( - HttpMethod.Delete, - path, - cancellationToken: cancellationToken - ); - - return response.Success; - } + ) => (await botClient.ProcessApi( + HttpMethod.Delete, + $"/chats/{chatId}/pin", + cancellationToken: cancellationToken + )).Success; } } diff --git a/src/Max.BotClient/Max.BotClient.ApiMethods.Members.cs b/src/Max.BotClient/Max.BotClient.ApiMethods.Members.cs index e80acc8..ed9ddc5 100644 --- a/src/Max.BotClient/Max.BotClient.ApiMethods.Members.cs +++ b/src/Max.BotClient/Max.BotClient.ApiMethods.Members.cs @@ -1,9 +1,6 @@ -using System.Collections.Generic; using System.Net.Http; using System.Threading; using System.Threading.Tasks; -using Max.BotClient.DTOs; -using Max.BotClient.Types; using Max.BotClient.Mapping; namespace Max.BotClient @@ -22,18 +19,11 @@ public static partial class BotClientApiMethods this BotClient botClient, long chatId, CancellationToken cancellationToken = default - ) - { - var path = $"/chats/{chatId}/members/me"; - - var response = await botClient.ProcessApi( - HttpMethod.Get, - path, - cancellationToken: cancellationToken - ); - - return response; - } + ) => await botClient.ProcessApi( + HttpMethod.Get, + $"/chats/{chatId}/members/me", + cancellationToken: cancellationToken + ); /// /// Удалить бота из участников группового чата. @@ -47,18 +37,11 @@ public static async Task LeaveChat( this BotClient botClient, long chatId, CancellationToken cancellationToken = default - ) - { - var path = $"/chats/{chatId}/members/me"; - - var response = await botClient.ProcessApi( - HttpMethod.Delete, - path, - cancellationToken: cancellationToken - ); - - return response.Success; - } + ) => (await botClient.ProcessApi( + HttpMethod.Delete, + $"/chats/{chatId}/members/me", + cancellationToken: cancellationToken + )).Success; /// /// Получение участников группового чата. @@ -78,29 +61,12 @@ public static async Task LeaveChat( int? count = null, long? marker = null, CancellationToken cancellationToken = default - ) - { - var queryParams = new List(); - - if (userIds != null && userIds.Length > 0) - queryParams.Add($"user_ids={string.Join(",", userIds)}"); - if (count.HasValue) - queryParams.Add($"count={count.Value}"); - if (marker.HasValue) - queryParams.Add($"marker={marker.Value}"); - - var path = $"/chats/{chatId}/members"; - if (queryParams.Count > 0) - path += "?" + string.Join("&", queryParams); - - var response = await botClient.ProcessApi( - HttpMethod.Get, - path, - cancellationToken: cancellationToken - ); - - return response; - } + ) => await botClient.ProcessApi( + HttpMethod.Get, + $"/chats/{chatId}/members", + () => new GetChatMembersParams { UserIds = userIds, Count = count, Marker = marker }, + cancellationToken + ); /// /// Добавить участников в групповой чат. @@ -117,19 +83,12 @@ public static async Task AddChatMembers( long chatId, long[] userIds, CancellationToken cancellationToken = default - ) - { - var path = $"/chats/{chatId}/members"; - - var response = await botClient.ProcessApi( - HttpMethod.Post, - path, - new { user_ids = userIds }, - cancellationToken - ); - - return response.Success; - } + ) => (await botClient.ProcessApi( + HttpMethod.Post, + $"/chats/{chatId}/members", + () => new AddChatMembersParams { UserIds = userIds }, + cancellationToken + )).Success; /// /// Удалить участника из группового чата. @@ -148,23 +107,12 @@ public static async Task RemoveChatMember( long userId, bool? block = null, CancellationToken cancellationToken = default - ) - { - var queryParams = new List { $"user_id={userId}" }; - - if (block.HasValue) - queryParams.Add($"block={block.Value.ToString().ToLowerInvariant()}"); - - var path = $"/chats/{chatId}/members?" + string.Join("&", queryParams); - - var response = await botClient.ProcessApi( - HttpMethod.Delete, - path, - cancellationToken: cancellationToken - ); - - return response.Success; - } + ) => (await botClient.ProcessApi( + HttpMethod.Delete, + $"/chats/{chatId}/members", + () => new RemoveChatMemberParams { UserId = userId, Block = block }, + cancellationToken + )).Success; /// /// Получить список администраторов группового чата. @@ -179,18 +127,11 @@ public static async Task RemoveChatMember( this BotClient botClient, long chatId, CancellationToken cancellationToken = default - ) - { - var path = $"/chats/{chatId}/members/admins"; - - var response = await botClient.ProcessApi( - HttpMethod.Get, - path, - cancellationToken: cancellationToken - ); - - return response; - } + ) => await botClient.ProcessApi( + HttpMethod.Get, + $"/chats/{chatId}/members/admins", + cancellationToken: cancellationToken + ); /// /// Назначить администраторов группового чата. @@ -206,19 +147,12 @@ public static async Task AddChatAdmins( long chatId, Types.ChatAdmin[] admins, CancellationToken cancellationToken = default - ) - { - var path = $"/chats/{chatId}/members/admins"; - - var response = await botClient.ProcessApi( - HttpMethod.Post, - path, - new DTOs.AddChatAdminsRequest { Admins = admins.ToDto() }, - cancellationToken - ); - - return response.Success; - } + ) => (await botClient.ProcessApi( + HttpMethod.Post, + $"/chats/{chatId}/members/admins", + new DTOs.AddChatAdminsRequest { Admins = admins.ToDto() }, + cancellationToken + )).Success; /// /// Отменить права администратора у пользователя в групповом чате. @@ -234,17 +168,10 @@ public static async Task RemoveChatAdmin( long chatId, long userId, CancellationToken cancellationToken = default - ) - { - var path = $"/chats/{chatId}/members/admins/{userId}"; - - var response = await botClient.ProcessApi( - HttpMethod.Delete, - path, - cancellationToken: cancellationToken - ); - - return response.Success; - } + ) => (await botClient.ProcessApi( + HttpMethod.Delete, + $"/chats/{chatId}/members/admins/{userId}", + cancellationToken: cancellationToken + )).Success; } } diff --git a/src/Max.BotClient/Max.BotClient.ApiMethods.Messages.cs b/src/Max.BotClient/Max.BotClient.ApiMethods.Messages.cs index b50a483..f31263b 100644 --- a/src/Max.BotClient/Max.BotClient.ApiMethods.Messages.cs +++ b/src/Max.BotClient/Max.BotClient.ApiMethods.Messages.cs @@ -1,9 +1,7 @@ using System; -using System.Collections.Generic; using System.Net.Http; using System.Threading; using System.Threading.Tasks; -using Max.BotClient.DTOs; using Max.BotClient.Mapping; namespace Max.BotClient @@ -28,27 +26,12 @@ public static partial class BotClientApiMethods long? to = null, int? count = null, CancellationToken cancellationToken = default - ) - { - var queryParams = new List { $"chat_id={chatId}" }; - - if (from.HasValue) - queryParams.Add($"from={from.Value}"); - if (to.HasValue) - queryParams.Add($"to={to.Value}"); - if (count.HasValue) - queryParams.Add($"count={count.Value}"); - - var path = "/messages?" + string.Join("&", queryParams); - - var response = await botClient.ProcessApi( - HttpMethod.Get, - path, - cancellationToken: cancellationToken - ); - - return response.Messages ?? Array.Empty(); - } + ) => (await botClient.ProcessApi( + HttpMethod.Get, + "/messages", + () => new GetMessagesByChatParams { ChatId = chatId, From = from, To = to, Count = count }, + cancellationToken + )).Messages ?? Array.Empty(); /// /// Получить сообщения по их ID. @@ -62,18 +45,12 @@ public static partial class BotClientApiMethods this BotClient botClient, string[] messageIds, CancellationToken cancellationToken = default - ) - { - var path = $"/messages?message_ids={string.Join(",", messageIds)}"; - - var response = await botClient.ProcessApi( - HttpMethod.Get, - path, - cancellationToken: cancellationToken - ); - - return response.Messages ?? Array.Empty(); - } + ) => (await botClient.ProcessApi( + HttpMethod.Get, + "/messages", + () => new GetMessagesByIdsParams { MessageIds = messageIds }, + cancellationToken + )).Messages ?? Array.Empty(); /// /// Получить сообщение по его ID. @@ -87,18 +64,11 @@ public static partial class BotClientApiMethods this BotClient botClient, string messageId, CancellationToken cancellationToken = default - ) - { - var path = $"/messages/{Uri.EscapeDataString(messageId)}"; - - var response = await botClient.ProcessApi( - HttpMethod.Get, - path, - cancellationToken: cancellationToken - ); - - return response; - } + ) => await botClient.ProcessApi( + HttpMethod.Get, + $"/messages/{Uri.EscapeDataString(messageId)}", + cancellationToken: cancellationToken + ); /// /// Отправить сообщение пользователю или в чат. @@ -116,27 +86,18 @@ public static partial class BotClientApiMethods Types.Message message, bool? disableLinkPreview = null, CancellationToken cancellationToken = default - ) - { - var recipientParam = message.GetRecipientType() == Types.RecipientType.Chat - ? $"chat_id={id}" - : $"user_id={id}"; - - var queryParams = new List { recipientParam }; - if (disableLinkPreview.HasValue) - queryParams.Add($"disable_link_preview={disableLinkPreview.Value.ToString().ToLowerInvariant()}"); - - var path = "/messages?" + string.Join("&", queryParams); - - var response = await botClient.ProcessApi( - HttpMethod.Post, - path, - message.ToMessageBody().ToDto(), - cancellationToken - ); - - return response.Message; - } + ) => (await botClient.ProcessApi( + HttpMethod.Post, + "/messages", + () => new SendMessageParams + { + ChatId = message.GetRecipientType() == Types.RecipientType.Chat ? id : null, + UserId = message.GetRecipientType() != Types.RecipientType.Chat ? id : null, + DisableLinkPreview = disableLinkPreview, + Body = message.ToMessageBody().ToDto() + }, + cancellationToken + )).Message; /// /// Редактировать сообщение в чате. @@ -153,19 +114,12 @@ public static async Task EditMessage( string messageId, Types.Message message, CancellationToken cancellationToken = default - ) - { - var path = $"/messages?message_id={Uri.EscapeDataString(messageId)}"; - - var response = await botClient.ProcessApi( - new HttpMethod("PUT"), - path, - message.ToMessageBody().ToDto(), - cancellationToken - ); - - return response.Success; - } + ) => (await botClient.ProcessApi( + new HttpMethod("PUT"), + "/messages", + () => new EditMessageParams { MessageId = messageId, Body = message.ToMessageBody().ToDto() }, + cancellationToken + )).Success; /// /// Удалить сообщение в диалоге или чате. @@ -180,18 +134,12 @@ public static async Task DeleteMessage( this BotClient botClient, string messageId, CancellationToken cancellationToken = default - ) - { - var path = $"/messages?message_id={Uri.EscapeDataString(messageId)}"; - - var response = await botClient.ProcessApi( - HttpMethod.Delete, - path, - cancellationToken: cancellationToken - ); - - return response.Success; - } + ) => (await botClient.ProcessApi( + HttpMethod.Delete, + "/messages", + () => new DeleteMessageParams { MessageId = messageId }, + cancellationToken + )).Success; /// /// Отправить ответ на нажатие кнопки пользователем. @@ -210,24 +158,16 @@ public static async Task AnswerCallback( Types.Message? message = null, string? notification = null, CancellationToken cancellationToken = default - ) - { - var path = $"/answers?callback_id={Uri.EscapeDataString(callbackId)}"; - - var requestBody = new + ) => (await botClient.ProcessApi( + HttpMethod.Post, + "/answers", + () => new AnswerCallbackParams { - message = message?.ToMessageBody().ToDto(), - notification = notification - }; - - var response = await botClient.ProcessApi( - HttpMethod.Post, - path, - requestBody, - cancellationToken - ); - - return response.Success; - } + CallbackId = callbackId, + Message = message?.ToMessageBody().ToDto(), + Notification = notification + }, + cancellationToken + )).Success; } } diff --git a/src/Max.BotClient/Max.BotClient.ApiMethods.Misc.cs b/src/Max.BotClient/Max.BotClient.ApiMethods.Misc.cs index 3d60ebf..f763b06 100644 --- a/src/Max.BotClient/Max.BotClient.ApiMethods.Misc.cs +++ b/src/Max.BotClient/Max.BotClient.ApiMethods.Misc.cs @@ -2,8 +2,6 @@ using System.Net.Http; using System.Threading; using System.Threading.Tasks; -using Max.BotClient.DTOs; -using Max.BotClient.Types; namespace Max.BotClient { @@ -15,10 +13,10 @@ public static partial class BotClientApiMethods /// /// Клиент бота. /// Токен отмены. - public static async Task GetMe( + public static async Task GetMe( this BotClient botClient, CancellationToken cancellationToken = default - ) => await botClient.ProcessApi( + ) => await botClient.ProcessApi( HttpMethod.Get, "/me", cancellationToken: cancellationToken @@ -32,14 +30,15 @@ public static async Task GetMe( /// Тип загружаемого файла. /// Токен отмены. /// URL для загрузки и токен (для video/audio). - public static async Task GetUploadUrl( + public static async Task GetUploadUrl( this BotClient botClient, - UploadType type, + Types.UploadType type, CancellationToken cancellationToken = default - ) => await botClient.ProcessApi( + ) => await botClient.ProcessApi( HttpMethod.Post, - $"/uploads?type={type.ToString().ToSnakeCase()}", - cancellationToken: cancellationToken + "/uploads", + () => new GetUploadUrlParams { Type = type }, + cancellationToken ); /// @@ -54,17 +53,10 @@ public static async Task GetUploadUrl( this BotClient botClient, string videoToken, CancellationToken cancellationToken = default - ) - { - var path = $"/videos/{Uri.EscapeDataString(videoToken)}"; - - var response = await botClient.ProcessApi( - HttpMethod.Get, - path, - cancellationToken: cancellationToken - ); - - return response; - } + ) => await botClient.ProcessApi( + HttpMethod.Get, + $"/videos/{Uri.EscapeDataString(videoToken)}", + cancellationToken: cancellationToken + ); } } diff --git a/src/Max.BotClient/Max.BotClient.ApiMethods.Subscriptions.cs b/src/Max.BotClient/Max.BotClient.ApiMethods.Subscriptions.cs index 2189c10..27d2571 100644 --- a/src/Max.BotClient/Max.BotClient.ApiMethods.Subscriptions.cs +++ b/src/Max.BotClient/Max.BotClient.ApiMethods.Subscriptions.cs @@ -1,6 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Linq; using System.Net.Http; using System.Threading; using System.Threading.Tasks; @@ -58,10 +55,11 @@ public static async Task Unsubscribe( this BotClient botClient, string url, CancellationToken cancellationToken = default - ) => await botClient.ProcessApi( + ) => await botClient.ProcessApi( HttpMethod.Delete, - $"/subscriptions?url={Uri.EscapeDataString(url)}", - cancellationToken: cancellationToken + "/subscriptions", + () => new UnsubscribeParams { Url = url }, + cancellationToken ); /// @@ -83,25 +81,11 @@ public static async Task Unsubscribe( CancellationToken cancellationToken = default ) { - var queryParams = new List(); - - if (limit.HasValue) - queryParams.Add($"limit={limit.Value}"); - if (timeout.HasValue) - queryParams.Add($"timeout={timeout.Value}"); - if (marker.HasValue) - queryParams.Add($"marker={marker.Value}"); - if (types != null && types.Length > 0) - queryParams.Add($"types={string.Join(",", types.Select(t => t.ToString().ToSnakeCase()))}"); - - var path = "/updates"; - if (queryParams.Count > 0) - path += "?" + string.Join("&", queryParams); - - var response = await botClient.ProcessApi( + var response = await botClient.ProcessApi( HttpMethod.Get, - path, - cancellationToken: cancellationToken + "/updates", + () => new GetUpdatesParams { Limit = limit, Timeout = timeout, Marker = marker, UpdateTypes = types }, + cancellationToken ); return (response.Updates, response.Marker); diff --git a/src/Max.BotClient/Max.BotClient.ApiRequest.cs b/src/Max.BotClient/Max.BotClient.ApiRequest.cs new file mode 100644 index 0000000..285c2fa --- /dev/null +++ b/src/Max.BotClient/Max.BotClient.ApiRequest.cs @@ -0,0 +1,118 @@ +using System; +using System.Collections.Generic; +using System.Reflection; + +namespace Max.BotClient +{ + [AttributeUsage(AttributeTargets.Property)] + internal sealed class QueryParamAttribute : Attribute + { + public string? Name { get; } + public QueryParamAttribute(string? name = null) => Name = name; + } + + [AttributeUsage(AttributeTargets.Property)] + internal sealed class BodyParamAttribute : Attribute + { + public string? Name { get; } + public BodyParamAttribute(string? name = null) => Name = name; + } + + [AttributeUsage(AttributeTargets.Property)] + internal sealed class BodyAttribute : Attribute { } + + internal static class ApiRequestBinder + { + public static void Bind(T request, string basePath, out string path, out object? body) + where T : class + { + var queryParts = new List(); + var bodyDict = new Dictionary(); + object? directBody = null; + var hasBodyProp = false; + + foreach (var prop in typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + var value = prop.GetValue(request); + if (value == null) + continue; + + var queryAttr = prop.GetCustomAttribute(); + if (queryAttr != null) + { + var str = ConvertToQueryString(value); + if (str != null) + { + var name = queryAttr.Name ?? prop.Name.ToSnakeCase(); + queryParts.Add($"{name}={str}"); + } + continue; + } + + var bodyParamAttr = prop.GetCustomAttribute(); + if (bodyParamAttr != null) + { + var name = bodyParamAttr.Name ?? prop.Name.ToSnakeCase(); + bodyDict[name] = value; + continue; + } + + if (prop.GetCustomAttribute() != null) + { + directBody = value; + hasBodyProp = true; + } + } + + path = queryParts.Count > 0 + ? basePath + "?" + string.Join("&", queryParts) + : basePath; + + if (hasBodyProp) + body = directBody; + else if (bodyDict.Count > 0) + body = bodyDict; + else + body = null; + } + + private static string? ConvertToQueryString(object value) + { + var type = value.GetType(); + + if (type.IsEnum) + return value.ToString()!.ToSnakeCase(); + + if (type == typeof(bool)) + return ((bool)value).ToString().ToLowerInvariant(); + + if (type.IsArray) + { + var elementType = type.GetElementType()!; + var arr = (Array)value; + if (arr.Length == 0) + return null; + + var parts = new List(arr.Length); + foreach (var elem in arr) + { + if (elem == null) + continue; + if (elementType.IsEnum) + parts.Add(elem.ToString()!.ToSnakeCase()); + else if (elementType == typeof(string)) + parts.Add(Uri.EscapeDataString((string)elem)); + else + parts.Add(elem.ToString()!); + } + + return parts.Count > 0 ? string.Join(",", parts) : null; + } + + if (type == typeof(string)) + return Uri.EscapeDataString((string)value); + + return value.ToString(); + } + } +} diff --git a/src/Max.BotClient/Max.BotClient.ApiRequests.cs b/src/Max.BotClient/Max.BotClient.ApiRequests.cs new file mode 100644 index 0000000..f5677bd --- /dev/null +++ b/src/Max.BotClient/Max.BotClient.ApiRequests.cs @@ -0,0 +1,94 @@ +namespace Max.BotClient +{ + internal class GetChatsParams + { + [QueryParam] public int? Count { get; set; } + [QueryParam] public long? Marker { get; set; } + } + + internal class SendActionParams + { + [BodyParam] public Types.SenderAction? Action { get; set; } + } + + internal class PinMessageParams + { + [BodyParam] public string? MessageId { get; set; } + [BodyParam] public bool? Notify { get; set; } + } + + internal class GetMessagesByChatParams + { + [QueryParam] public long? ChatId { get; set; } + [QueryParam] public long? From { get; set; } + [QueryParam] public long? To { get; set; } + [QueryParam] public int? Count { get; set; } + } + + internal class GetMessagesByIdsParams + { + [QueryParam] public string[]? MessageIds { get; set; } + } + + internal class SendMessageParams + { + [QueryParam] public long? ChatId { get; set; } + [QueryParam] public long? UserId { get; set; } + [QueryParam] public bool? DisableLinkPreview { get; set; } + [Body] public DTOs.NewMessageBody? Body { get; set; } + } + + internal class EditMessageParams + { + [QueryParam] public string? MessageId { get; set; } + [Body] public DTOs.NewMessageBody? Body { get; set; } + } + + internal class DeleteMessageParams + { + [QueryParam] public string? MessageId { get; set; } + } + + internal class AnswerCallbackParams + { + [QueryParam] public string? CallbackId { get; set; } + [BodyParam] public DTOs.NewMessageBody? Message { get; set; } + [BodyParam] public string? Notification { get; set; } + } + + internal class GetChatMembersParams + { + [QueryParam] public long[]? UserIds { get; set; } + [QueryParam] public int? Count { get; set; } + [QueryParam] public long? Marker { get; set; } + } + + internal class AddChatMembersParams + { + [BodyParam] public long[]? UserIds { get; set; } + } + + internal class RemoveChatMemberParams + { + [QueryParam] public long? UserId { get; set; } + [QueryParam] public bool? Block { get; set; } + } + + internal class UnsubscribeParams + { + [QueryParam] public string? Url { get; set; } + } + + internal class GetUpdatesParams + { + [QueryParam] public int? Limit { get; set; } + [QueryParam] public int? Timeout { get; set; } + [QueryParam] public long? Marker { get; set; } + [QueryParam("types")] public Types.UpdateType[]? UpdateTypes { get; set; } + } + + internal class GetUploadUrlParams + { + [QueryParam] public Types.UploadType? Type { get; set; } + } +} From c162407a5692c97873df57de09c4b29dd1c2262d Mon Sep 17 00:00:00 2001 From: Aleksandr Semenenko <112961081+Deskri@users.noreply.github.com> Date: Fri, 27 Feb 2026 17:13:00 +0400 Subject: [PATCH 03/18] feat: add tests --- .gitignore | 4 +- Max.BotClient.slnx | 1 + src/Max.BotClient/Properties/AssemblyInfo.cs | 3 + .../Integration/ApiMethods/ChatsTests.cs | 163 +++++++++ .../Integration/ApiMethods/MembersTests.cs | 137 +++++++ .../Integration/ApiMethods/MessagesTests.cs | 192 ++++++++++ .../Integration/ApiMethods/MiscTests.cs | 75 ++++ .../Integration/Helpers/FakeHttpHandler.cs | 40 +++ .../Max.BotClient.Tests.csproj | 24 ++ .../Unit/ApiRequestBinderTests.cs | 339 ++++++++++++++++++ .../Unit/Mapping/ChatMappingTests.cs | 167 +++++++++ .../Unit/Mapping/MessageMappingTests.cs | 223 ++++++++++++ .../Unit/ToSnakeCaseTests.cs | 22 ++ 13 files changed, 1389 insertions(+), 1 deletion(-) create mode 100644 src/Max.BotClient/Properties/AssemblyInfo.cs create mode 100644 tests/Max.BotClient.Tests/Integration/ApiMethods/ChatsTests.cs create mode 100644 tests/Max.BotClient.Tests/Integration/ApiMethods/MembersTests.cs create mode 100644 tests/Max.BotClient.Tests/Integration/ApiMethods/MessagesTests.cs create mode 100644 tests/Max.BotClient.Tests/Integration/ApiMethods/MiscTests.cs create mode 100644 tests/Max.BotClient.Tests/Integration/Helpers/FakeHttpHandler.cs create mode 100644 tests/Max.BotClient.Tests/Max.BotClient.Tests.csproj create mode 100644 tests/Max.BotClient.Tests/Unit/ApiRequestBinderTests.cs create mode 100644 tests/Max.BotClient.Tests/Unit/Mapping/ChatMappingTests.cs create mode 100644 tests/Max.BotClient.Tests/Unit/Mapping/MessageMappingTests.cs create mode 100644 tests/Max.BotClient.Tests/Unit/ToSnakeCaseTests.cs diff --git a/.gitignore b/.gitignore index 62274da..47101c6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ /src/Max.BotClient/bin/ /src/Max.BotClient/obj/ -/.idea/ \ No newline at end of file +/.idea/ +/tests/Max.BotClient.Tests/bin/ +/tests/Max.BotClient.Tests/obj/ \ No newline at end of file diff --git a/Max.BotClient.slnx b/Max.BotClient.slnx index c93f801..6c2ce1c 100644 --- a/Max.BotClient.slnx +++ b/Max.BotClient.slnx @@ -1,3 +1,4 @@ + diff --git a/src/Max.BotClient/Properties/AssemblyInfo.cs b/src/Max.BotClient/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..578955f --- /dev/null +++ b/src/Max.BotClient/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Max.BotClient.Tests")] diff --git a/tests/Max.BotClient.Tests/Integration/ApiMethods/ChatsTests.cs b/tests/Max.BotClient.Tests/Integration/ApiMethods/ChatsTests.cs new file mode 100644 index 0000000..bd7ab82 --- /dev/null +++ b/tests/Max.BotClient.Tests/Integration/ApiMethods/ChatsTests.cs @@ -0,0 +1,163 @@ +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; +using FluentAssertions; +using Max.BotClient.Tests.Integration.Helpers; +using Max.BotClient.Types; +using Xunit; + +namespace Max.BotClient.Tests.Integration.ApiMethods; + +public class ChatsTests +{ + private const string ApiResponseOk = """{"success":true}"""; + private const string EmptyChats = """{"chats":[],"marker":null}"""; + private const string MinimalChat = """{"chat_id":0,"type":"chat","status":"active","last_event_time":0,"participants_count":0,"is_public":false}"""; + + // ─── GetChats ───────────────────────────────────────────────────────────── + + [Fact] + public async Task GetChats_SendsGetToChatsPath() + { + var (client, handler) = FakeHttpHandler.Create(EmptyChats); + + await client.GetChats(); + + handler.LastRequest!.Method.Should().Be(HttpMethod.Get); + handler.LastRequest.RequestUri!.AbsolutePath.Should().Be("/chats"); + } + + [Fact] + public async Task GetChats_WithParams_IncludesCountAndMarker() + { + var (client, handler) = FakeHttpHandler.Create(EmptyChats); + + await client.GetChats(count: 10, marker: 500L); + + var query = handler.LastRequest!.RequestUri!.Query; + query.Should().Contain("count=10"); + query.Should().Contain("marker=500"); + } + + [Fact] + public async Task GetChats_NullParams_NoQueryString() + { + var (client, handler) = FakeHttpHandler.Create(EmptyChats); + + await client.GetChats(); + + handler.LastRequest!.RequestUri!.Query.Should().BeEmpty(); + } + + // ─── GetChat ────────────────────────────────────────────────────────────── + + [Fact] + public async Task GetChat_SendsGetToChatPath() + { + var (client, handler) = FakeHttpHandler.Create(MinimalChat); + + await client.GetChat(99L); + + handler.LastRequest!.Method.Should().Be(HttpMethod.Get); + handler.LastRequest.RequestUri!.AbsolutePath.Should().Be("/chats/99"); + } + + // ─── DeleteChat ─────────────────────────────────────────────────────────── + + [Fact] + public async Task DeleteChat_SendsDeleteToChatPath() + { + var (client, handler) = FakeHttpHandler.Create(ApiResponseOk); + + await client.DeleteChat(77L); + + handler.LastRequest!.Method.Should().Be(HttpMethod.Delete); + handler.LastRequest.RequestUri!.AbsolutePath.Should().Be("/chats/77"); + } + + // ─── SendAction ─────────────────────────────────────────────────────────── + + [Fact] + public async Task SendAction_SendsPostToActionsPath() + { + var (client, handler) = FakeHttpHandler.Create(ApiResponseOk); + + await client.SendAction(55L, SenderAction.TypingOn); + + handler.LastRequest!.Method.Should().Be(HttpMethod.Post); + handler.LastRequest.RequestUri!.AbsolutePath.Should().Be("/chats/55/actions"); + } + + [Fact] + public async Task SendAction_ActionSnakeCaseInBody() + { + var (client, handler) = FakeHttpHandler.Create(ApiResponseOk); + + await client.SendAction(1L, SenderAction.TypingOn); + + var body = JsonDocument.Parse(handler.CapturedBody!); + body.RootElement.GetProperty("action").GetString().Should().Be("typing_on"); + } + + // ─── PinMessage ─────────────────────────────────────────────────────────── + + [Fact] + public async Task PinMessage_SendsPutToPinPath() + { + var (client, handler) = FakeHttpHandler.Create(ApiResponseOk); + + await client.PinMessage(10L, "msg1", notify: true); + + handler.LastRequest!.Method.Should().Be(new HttpMethod("PUT")); + handler.LastRequest.RequestUri!.AbsolutePath.Should().Be("/chats/10/pin"); + } + + [Fact] + public async Task PinMessage_MessageIdAndNotifyInBody() + { + var (client, handler) = FakeHttpHandler.Create(ApiResponseOk); + + await client.PinMessage(1L, "msgABC", notify: true); + + var body = JsonDocument.Parse(handler.CapturedBody!); + body.RootElement.GetProperty("message_id").GetString().Should().Be("msgABC"); + body.RootElement.GetProperty("notify").GetBoolean().Should().BeTrue(); + } + + [Fact] + public async Task PinMessage_NullNotify_NotInBody() + { + var (client, handler) = FakeHttpHandler.Create(ApiResponseOk); + + await client.PinMessage(1L, "msg1"); + + var body = JsonDocument.Parse(handler.CapturedBody!); + body.RootElement.TryGetProperty("notify", out _).Should().BeFalse(); + } + + // ─── UnpinMessage ───────────────────────────────────────────────────────── + + [Fact] + public async Task UnpinMessage_SendsDeleteToPinPath() + { + var (client, handler) = FakeHttpHandler.Create(ApiResponseOk); + + await client.UnpinMessage(33L); + + handler.LastRequest!.Method.Should().Be(HttpMethod.Delete); + handler.LastRequest.RequestUri!.AbsolutePath.Should().Be("/chats/33/pin"); + } + + // ─── GetPinnedMessage ───────────────────────────────────────────────────── + + [Fact] + public async Task GetPinnedMessage_SendsGetToPinPath() + { + var (client, handler) = FakeHttpHandler.Create("""{"message":null}"""); + + await client.GetPinnedMessage(22L); + + handler.LastRequest!.Method.Should().Be(HttpMethod.Get); + handler.LastRequest.RequestUri!.AbsolutePath.Should().Be("/chats/22/pin"); + } +} \ No newline at end of file diff --git a/tests/Max.BotClient.Tests/Integration/ApiMethods/MembersTests.cs b/tests/Max.BotClient.Tests/Integration/ApiMethods/MembersTests.cs new file mode 100644 index 0000000..48b1990 --- /dev/null +++ b/tests/Max.BotClient.Tests/Integration/ApiMethods/MembersTests.cs @@ -0,0 +1,137 @@ +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; +using FluentAssertions; +using Max.BotClient.Tests.Integration.Helpers; +using Xunit; + +namespace Max.BotClient.Tests.Integration.ApiMethods; + +public class MembersTests +{ + private const string ApiResponseOk = """{"success":true}"""; + private const string EmptyMembers = """{"members":[],"marker":null}"""; + private const string MinimalMember = """{"user_id":1,"first_name":"Bot","is_bot":false,"last_access_time":0,"is_owner":false,"is_admin":false,"join_time":0}"""; + + // ─── GetMyMembership ────────────────────────────────────────────────────── + + [Fact] + public async Task GetMyMembership_SendsGetToMePath() + { + var (client, handler) = FakeHttpHandler.Create(MinimalMember); + + await client.GetMyMembership(100L); + + handler.LastRequest!.Method.Should().Be(HttpMethod.Get); + handler.LastRequest.RequestUri!.AbsolutePath.Should().Be("/chats/100/members/me"); + } + + // ─── LeaveChat ──────────────────────────────────────────────────────────── + + [Fact] + public async Task LeaveChat_SendsDeleteToMePath() + { + var (client, handler) = FakeHttpHandler.Create(ApiResponseOk); + + await client.LeaveChat(200L); + + handler.LastRequest!.Method.Should().Be(HttpMethod.Delete); + handler.LastRequest.RequestUri!.AbsolutePath.Should().Be("/chats/200/members/me"); + } + + // ─── GetChatMembers ─────────────────────────────────────────────────────── + + [Fact] + public async Task GetChatMembers_SendsGetToMembersPath() + { + var (client, handler) = FakeHttpHandler.Create(EmptyMembers); + + await client.GetChatMembers(50L); + + handler.LastRequest!.Method.Should().Be(HttpMethod.Get); + handler.LastRequest.RequestUri!.AbsolutePath.Should().Be("/chats/50/members"); + } + + [Fact] + public async Task GetChatMembers_WithCountAndMarker_InQuery() + { + var (client, handler) = FakeHttpHandler.Create(EmptyMembers); + + await client.GetChatMembers(50L, count: 20, marker: 999L); + + var query = handler.LastRequest!.RequestUri!.Query; + query.Should().Contain("count=20"); + query.Should().Contain("marker=999"); + } + + [Fact] + public async Task GetChatMembers_WithUserIds_InQuery() + { + var (client, handler) = FakeHttpHandler.Create(EmptyMembers); + + await client.GetChatMembers(50L, userIds: new[] { 1L, 2L, 3L }); + + handler.LastRequest!.RequestUri!.Query.Should().Contain("user_ids=1,2,3"); + } + + // ─── AddChatMembers ─────────────────────────────────────────────────────── + + [Fact] + public async Task AddChatMembers_SendsPostToMembersPath() + { + var (client, handler) = FakeHttpHandler.Create(ApiResponseOk); + + await client.AddChatMembers(10L, new[] { 5L, 6L }); + + handler.LastRequest!.Method.Should().Be(HttpMethod.Post); + handler.LastRequest.RequestUri!.AbsolutePath.Should().Be("/chats/10/members"); + } + + [Fact] + public async Task AddChatMembers_UserIdsInBody() + { + var (client, handler) = FakeHttpHandler.Create(ApiResponseOk); + + await client.AddChatMembers(10L, new[] { 5L, 6L }); + + var body = JsonDocument.Parse(handler.CapturedBody!); + var ids = body.RootElement.GetProperty("user_ids"); + ids[0].GetInt64().Should().Be(5L); + ids[1].GetInt64().Should().Be(6L); + } + + // ─── RemoveChatMember ───────────────────────────────────────────────────── + + [Fact] + public async Task RemoveChatMember_SendsDeleteToMembersPath() + { + var (client, handler) = FakeHttpHandler.Create(ApiResponseOk); + + await client.RemoveChatMember(30L, 7L); + + handler.LastRequest!.Method.Should().Be(HttpMethod.Delete); + handler.LastRequest.RequestUri!.AbsolutePath.Should().Be("/chats/30/members"); + } + + [Fact] + public async Task RemoveChatMember_UserIdAndBlockInQuery() + { + var (client, handler) = FakeHttpHandler.Create(ApiResponseOk); + + await client.RemoveChatMember(30L, 7L, block: true); + + var query = handler.LastRequest!.RequestUri!.Query; + query.Should().Contain("user_id=7"); + query.Should().Contain("block=true"); + } + + [Fact] + public async Task RemoveChatMember_NullBlock_NotInQuery() + { + var (client, handler) = FakeHttpHandler.Create(ApiResponseOk); + + await client.RemoveChatMember(30L, 7L); + + handler.LastRequest!.RequestUri!.Query.Should().NotContain("block="); + } +} \ No newline at end of file diff --git a/tests/Max.BotClient.Tests/Integration/ApiMethods/MessagesTests.cs b/tests/Max.BotClient.Tests/Integration/ApiMethods/MessagesTests.cs new file mode 100644 index 0000000..6e82014 --- /dev/null +++ b/tests/Max.BotClient.Tests/Integration/ApiMethods/MessagesTests.cs @@ -0,0 +1,192 @@ +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; +using FluentAssertions; +using Max.BotClient.Tests.Integration.Helpers; +using Max.BotClient.Types; +using Xunit; + +namespace Max.BotClient.Tests.Integration.ApiMethods; + +public class MessagesTests +{ + private const string ApiResponseOk = """{"success":true}"""; + private const string EmptyMessages = """{"messages":[]}"""; + private const string MinimalMessage = """{"recipient":{"chat_type":"chat"},"body":{"mid":"m1","seq":1}}"""; + + // ─── GetMessages (by chat) ──────────────────────────────────────────────── + + [Fact] + public async Task GetMessages_ByChat_SendsGetWithChatIdParam() + { + var (client, handler) = FakeHttpHandler.Create(EmptyMessages); + + await client.GetMessages(chatId: 5L, from: 100L, to: 200L, count: 10); + + handler.LastRequest!.Method.Should().Be(HttpMethod.Get); + handler.LastRequest.RequestUri!.AbsolutePath.Should().Be("/messages"); + var query = handler.LastRequest.RequestUri.Query; + query.Should().Contain("chat_id=5"); + query.Should().Contain("from=100"); + query.Should().Contain("to=200"); + query.Should().Contain("count=10"); + } + + [Fact] + public async Task GetMessages_NullOptionals_OnlyChatIdInQuery() + { + var (client, handler) = FakeHttpHandler.Create(EmptyMessages); + + await client.GetMessages(chatId: 7L); + + var query = handler.LastRequest!.RequestUri!.Query; + query.Should().Contain("chat_id=7"); + query.Should().NotContain("from="); + query.Should().NotContain("to="); + query.Should().NotContain("count="); + } + + // ─── GetMessages (by IDs) ───────────────────────────────────────────────── + + [Fact] + public async Task GetMessages_ByIds_SendsGetWithMessageIdsParam() + { + var (client, handler) = FakeHttpHandler.Create(EmptyMessages); + + await client.GetMessages(new[] { "mid1", "mid2" }); + + handler.LastRequest!.Method.Should().Be(HttpMethod.Get); + handler.LastRequest.RequestUri!.AbsolutePath.Should().Be("/messages"); + handler.LastRequest.RequestUri.Query.Should().Contain("message_ids=mid1,mid2"); + } + + // ─── GetMessage ─────────────────────────────────────────────────────────── + + [Fact] + public async Task GetMessage_SendsGetToMessagePath() + { + var (client, handler) = FakeHttpHandler.Create(MinimalMessage); + + await client.GetMessage("abc123"); + + handler.LastRequest!.Method.Should().Be(HttpMethod.Get); + handler.LastRequest.RequestUri!.AbsolutePath.Should().Be("/messages/abc123"); + } + + [Fact] + public async Task GetMessage_SpecialCharsInId_UriEncoded() + { + var (client, handler) = FakeHttpHandler.Create(MinimalMessage); + + await client.GetMessage("mid/with/slashes"); + + handler.LastRequest!.RequestUri!.AbsolutePath.Should().Be("/messages/mid%2Fwith%2Fslashes"); + } + + // ─── SendMessage ────────────────────────────────────────────────────────── + + [Fact] + public async Task SendMessage_ToChat_UsesChatIdQueryParam() + { + var (client, handler) = FakeHttpHandler.Create("""{"message":null}"""); + var message = new Message("Hello").ToChat(); + + await client.SendMessage(42L, message); + + handler.LastRequest!.Method.Should().Be(HttpMethod.Post); + handler.LastRequest.RequestUri!.AbsolutePath.Should().Be("/messages"); + var query = handler.LastRequest.RequestUri.Query; + query.Should().Contain("chat_id=42"); + query.Should().NotContain("user_id="); + } + + [Fact] + public async Task SendMessage_ToUser_UsesUserIdQueryParam() + { + var (client, handler) = FakeHttpHandler.Create("""{"message":null}"""); + var message = new Message("Hi"); // default RecipientType.User + + await client.SendMessage(99L, message); + + var query = handler.LastRequest!.RequestUri!.Query; + query.Should().Contain("user_id=99"); + query.Should().NotContain("chat_id="); + } + + [Fact] + public async Task SendMessage_HasTextInBody() + { + var (client, handler) = FakeHttpHandler.Create("""{"message":null}"""); + var message = new Message("Test text").ToChat(); + + await client.SendMessage(1L, message); + + var body = JsonDocument.Parse(handler.CapturedBody!); + body.RootElement.GetProperty("text").GetString().Should().Be("Test text"); + } + + [Fact] + public async Task SendMessage_DisableLinkPreview_InQuery() + { + var (client, handler) = FakeHttpHandler.Create("""{"message":null}"""); + var message = new Message("msg").ToChat(); + + await client.SendMessage(1L, message, disableLinkPreview: true); + + handler.LastRequest!.RequestUri!.Query.Should().Contain("disable_link_preview=true"); + } + + // ─── EditMessage ────────────────────────────────────────────────────────── + + [Fact] + public async Task EditMessage_SendsPutWithMessageIdQuery() + { + var (client, handler) = FakeHttpHandler.Create(ApiResponseOk); + var message = new Message("Updated"); + + await client.EditMessage("msgXYZ", message); + + handler.LastRequest!.Method.Should().Be(new HttpMethod("PUT")); + handler.LastRequest.RequestUri!.AbsolutePath.Should().Be("/messages"); + handler.LastRequest.RequestUri.Query.Should().Contain("message_id=msgXYZ"); + } + + // ─── DeleteMessage ──────────────────────────────────────────────────────── + + [Fact] + public async Task DeleteMessage_SendsDeleteWithMessageIdQuery() + { + var (client, handler) = FakeHttpHandler.Create(ApiResponseOk); + + await client.DeleteMessage("delMsg1"); + + handler.LastRequest!.Method.Should().Be(HttpMethod.Delete); + handler.LastRequest.RequestUri!.AbsolutePath.Should().Be("/messages"); + handler.LastRequest.RequestUri.Query.Should().Contain("message_id=delMsg1"); + } + + // ─── AnswerCallback ─────────────────────────────────────────────────────── + + [Fact] + public async Task AnswerCallback_SendsPostWithCallbackIdQuery() + { + var (client, handler) = FakeHttpHandler.Create(ApiResponseOk); + + await client.AnswerCallback("cb42", notification: "Done!"); + + handler.LastRequest!.Method.Should().Be(HttpMethod.Post); + handler.LastRequest.RequestUri!.AbsolutePath.Should().Be("/answers"); + handler.LastRequest.RequestUri.Query.Should().Contain("callback_id=cb42"); + } + + [Fact] + public async Task AnswerCallback_NotificationInBody() + { + var (client, handler) = FakeHttpHandler.Create(ApiResponseOk); + + await client.AnswerCallback("cb1", notification: "ok"); + + var body = JsonDocument.Parse(handler.CapturedBody!); + body.RootElement.GetProperty("notification").GetString().Should().Be("ok"); + } +} \ No newline at end of file diff --git a/tests/Max.BotClient.Tests/Integration/ApiMethods/MiscTests.cs b/tests/Max.BotClient.Tests/Integration/ApiMethods/MiscTests.cs new file mode 100644 index 0000000..e6b9ef6 --- /dev/null +++ b/tests/Max.BotClient.Tests/Integration/ApiMethods/MiscTests.cs @@ -0,0 +1,75 @@ +using System.Net.Http; +using System.Threading.Tasks; +using FluentAssertions; +using Max.BotClient.Tests.Integration.Helpers; +using Max.BotClient.Types; +using Xunit; + +namespace Max.BotClient.Tests.Integration.ApiMethods; + +public class MiscTests +{ + // ─── GetMe ──────────────────────────────────────────────────────────────── + + [Fact] + public async Task GetMe_SendsGetToMePath() + { + var (client, handler) = FakeHttpHandler.Create( + """{"user_id":1,"first_name":"TestBot","is_bot":true}"""); + + await client.GetMe(); + + handler.LastRequest!.Method.Should().Be(HttpMethod.Get); + handler.LastRequest.RequestUri!.AbsolutePath.Should().Be("/me"); + } + + // ─── GetUploadUrl ───────────────────────────────────────────────────────── + + [Fact] + public async Task GetUploadUrl_SendsPostToUploadsPath() + { + var (client, handler) = FakeHttpHandler.Create("""{"url":"https://upload.example.com"}"""); + + await client.GetUploadUrl(UploadType.Image); + + handler.LastRequest!.Method.Should().Be(HttpMethod.Post); + handler.LastRequest.RequestUri!.AbsolutePath.Should().Be("/uploads"); + } + + [Theory] + [InlineData(UploadType.Image, "image")] + [InlineData(UploadType.Video, "video")] + [InlineData(UploadType.Audio, "audio")] + [InlineData(UploadType.File, "file")] + public async Task GetUploadUrl_TypeSnakeCaseInQuery(UploadType type, string expected) + { + var (client, handler) = FakeHttpHandler.Create("""{"url":"https://upload.example.com"}"""); + + await client.GetUploadUrl(type); + + handler.LastRequest!.RequestUri!.Query.Should().Contain($"type={expected}"); + } + + // ─── GetVideo ───────────────────────────────────────────────────────────── + + [Fact] + public async Task GetVideo_SendsGetToVideoPath() + { + var (client, handler) = FakeHttpHandler.Create("{}"); + + await client.GetVideo("token123"); + + handler.LastRequest!.Method.Should().Be(HttpMethod.Get); + handler.LastRequest.RequestUri!.AbsolutePath.Should().Be("/videos/token123"); + } + + [Fact] + public async Task GetVideo_SpecialCharsInToken_UriEncoded() + { + var (client, handler) = FakeHttpHandler.Create("{}"); + + await client.GetVideo("tok/en"); + + handler.LastRequest!.RequestUri!.AbsolutePath.Should().Be("/videos/tok%2Fen"); + } +} \ No newline at end of file diff --git a/tests/Max.BotClient.Tests/Integration/Helpers/FakeHttpHandler.cs b/tests/Max.BotClient.Tests/Integration/Helpers/FakeHttpHandler.cs new file mode 100644 index 0000000..c091c25 --- /dev/null +++ b/tests/Max.BotClient.Tests/Integration/Helpers/FakeHttpHandler.cs @@ -0,0 +1,40 @@ +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Max.BotClient.Tests.Integration.Helpers; + +internal sealed class FakeHttpHandler : HttpMessageHandler +{ + private readonly string _json; + + public HttpRequestMessage? LastRequest { get; private set; } + public string? CapturedBody { get; private set; } + + public FakeHttpHandler(string json) => _json = json; + + protected override async Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + LastRequest = request; + CapturedBody = request.Content != null + ? await request.Content.ReadAsStringAsync() + : null; + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(_json, Encoding.UTF8, "application/json") + }; + } + + public static (BotClient client, FakeHttpHandler handler) Create(string json) + { + var handler = new FakeHttpHandler(json); + var httpClient = new HttpClient(handler); + var options = new BotClientOptions("test-token") { RetryCount = 0 }; + var client = new BotClient(options, httpClient); + return (client, handler); + } +} \ No newline at end of file diff --git a/tests/Max.BotClient.Tests/Max.BotClient.Tests.csproj b/tests/Max.BotClient.Tests/Max.BotClient.Tests.csproj new file mode 100644 index 0000000..3d1659c --- /dev/null +++ b/tests/Max.BotClient.Tests/Max.BotClient.Tests.csproj @@ -0,0 +1,24 @@ + + + + net8.0 + false + enable + latest + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + diff --git a/tests/Max.BotClient.Tests/Unit/ApiRequestBinderTests.cs b/tests/Max.BotClient.Tests/Unit/ApiRequestBinderTests.cs new file mode 100644 index 0000000..9d8821e --- /dev/null +++ b/tests/Max.BotClient.Tests/Unit/ApiRequestBinderTests.cs @@ -0,0 +1,339 @@ +using System; +using System.Collections.Generic; +using FluentAssertions; +using Max.BotClient.DTOs; +using Max.BotClient.Types; +using Xunit; + +namespace Max.BotClient.Tests.Unit; + +public class ApiRequestBinderTests +{ + // ─── QueryParam: null → пропускается ──────────────────────────────────── + + [Fact] + public void AllNullQueryParams_PathUnchanged() + { + ApiRequestBinder.Bind(new GetChatsParams(), "/chats", out var path, out var body); + + path.Should().Be("/chats"); + body.Should().BeNull(); + } + + // ─── QueryParam: числа ─────────────────────────────────────────────────── + + [Fact] + public void SingleIntQueryParam_AppendedToPath() + { + ApiRequestBinder.Bind(new GetChatsParams { Count = 10 }, "/chats", out var path, out _); + + path.Should().Be("/chats?count=10"); + } + + [Fact] + public void MultipleLongQueryParams_JoinedWithAmpersand() + { + ApiRequestBinder.Bind( + new GetChatsParams { Count = 10, Marker = 999L }, + "/chats", out var path, out _); + + path.Should().Be("/chats?count=10&marker=999"); + } + + [Fact] + public void NullableWithValue_IncludedInQuery() + { + ApiRequestBinder.Bind( + new GetMessagesByChatParams { ChatId = 5L, From = 1000L, To = 2000L, Count = 50 }, + "/messages", out var path, out _); + + path.Should().Contain("chat_id=5") + .And.Contain("from=1000") + .And.Contain("to=2000") + .And.Contain("count=50"); + } + + // ─── QueryParam: enum → snake_case ─────────────────────────────────────── + + [Fact] + public void EnumQueryParam_ConvertedToSnakeCase() + { + ApiRequestBinder.Bind( + new GetUploadUrlParams { Type = UploadType.Image }, + "/uploads", out var path, out _); + + path.Should().Be("/uploads?type=image"); + } + + [Theory] + [InlineData(UploadType.Image, "image")] + [InlineData(UploadType.Video, "video")] + [InlineData(UploadType.Audio, "audio")] + [InlineData(UploadType.File, "file")] + public void UploadType_AllValues_CorrectSnakeCase(UploadType type, string expected) + { + ApiRequestBinder.Bind(new GetUploadUrlParams { Type = type }, "/uploads", out var path, out _); + + path.Should().Contain($"type={expected}"); + } + + // ─── QueryParam: bool → lowercase ──────────────────────────────────────── + + [Fact] + public void BoolQueryParam_True_IsLowercaseTrue() + { + ApiRequestBinder.Bind( + new RemoveChatMemberParams { UserId = 1L, Block = true }, + "/members", out var path, out _); + + path.Should().Contain("block=true"); + } + + [Fact] + public void BoolQueryParam_False_IsLowercaseFalse() + { + ApiRequestBinder.Bind( + new RemoveChatMemberParams { UserId = 1L, Block = false }, + "/members", out var path, out _); + + path.Should().Contain("block=false"); + } + + [Fact] + public void NullBoolQueryParam_Skipped() + { + ApiRequestBinder.Bind( + new RemoveChatMemberParams { UserId = 1L, Block = null }, + "/members", out var path, out _); + + path.Should().NotContain("block="); + } + + // ─── QueryParam: string → Uri.EscapeDataString ─────────────────────────── + + [Fact] + public void StringQueryParam_UrlEncoded() + { + ApiRequestBinder.Bind( + new UnsubscribeParams { Url = "https://example.com/hook?x=1" }, + "/subscriptions", out var path, out _); + + path.Should().Contain("url=https%3A%2F%2Fexample.com%2Fhook%3Fx%3D1"); + } + + [Fact] + public void PlainStringQueryParam_NoExtraEncoding() + { + ApiRequestBinder.Bind( + new UnsubscribeParams { Url = "https://example.com/hook" }, + "/subscriptions", out var path, out _); + + path.Should().Contain("url=https%3A%2F%2Fexample.com%2Fhook"); + } + + // ─── QueryParam: long[] → comma-separated ──────────────────────────────── + + [Fact] + public void LongArrayQueryParam_CommaSeparated() + { + ApiRequestBinder.Bind( + new GetChatMembersParams { UserIds = new[] { 1L, 2L, 3L } }, + "/members", out var path, out _); + + path.Should().Contain("user_ids=1,2,3"); + } + + [Fact] + public void SingleElementLongArray_NoComa() + { + ApiRequestBinder.Bind( + new GetChatMembersParams { UserIds = new[] { 42L } }, + "/members", out var path, out _); + + path.Should().Contain("user_ids=42"); + } + + [Fact] + public void EmptyLongArray_Skipped() + { + ApiRequestBinder.Bind( + new GetChatMembersParams { UserIds = Array.Empty() }, + "/members", out var path, out _); + + path.Should().Be("/members"); + } + + // ─── QueryParam: enum[] → snake_case + comma ───────────────────────────── + + [Fact] + public void EnumArrayQueryParam_SnakeCaseCommaSeparated() + { + ApiRequestBinder.Bind( + new GetUpdatesParams { UpdateTypes = new[] { Types.UpdateType.MessageCreated, Types.UpdateType.BotAdded } }, + "/updates", out var path, out _); + + path.Should().Contain("types=message_created,bot_added"); + } + + [Fact] + public void EnumArray_ExplicitAttributeName_UsedInsteadOfPropertyName() + { + // UpdateTypes имеет [QueryParam("types")] — не "update_types" + ApiRequestBinder.Bind( + new GetUpdatesParams { UpdateTypes = new[] { Types.UpdateType.MessageCreated } }, + "/updates", out var path, out _); + + path.Should().Contain("types="); + path.Should().NotContain("update_types="); + } + + // ─── QueryParam: string[] → Uri.EscapeDataString per element ───────────── + + [Fact] + public void StringArrayQueryParam_EachElementEncoded() + { + ApiRequestBinder.Bind( + new GetMessagesByIdsParams { MessageIds = new[] { "mid/1", "mid/2" } }, + "/messages", out var path, out _); + + path.Should().Contain("message_ids=mid%2F1,mid%2F2"); + } + + [Fact] + public void EmptyStringArray_Skipped() + { + ApiRequestBinder.Bind( + new GetMessagesByIdsParams { MessageIds = Array.Empty() }, + "/messages", out var path, out _); + + path.Should().Be("/messages"); + } + + // ─── BodyParam → Dictionary ────────────────────────────── + + [Fact] + public void BodyParam_BuildsDictionary() + { + ApiRequestBinder.Bind( + new SendActionParams { Action = SenderAction.TypingOn }, + "/actions", out _, out var body); + + var dict = body.Should().BeOfType>().Subject; + dict.Should().ContainKey("action"); + dict["action"].Should().Be(SenderAction.TypingOn); + } + + [Fact] + public void MultipleBodyParams_AllInDictionary() + { + ApiRequestBinder.Bind( + new PinMessageParams { MessageId = "msg42", Notify = true }, + "/pin", out _, out var body); + + var dict = body.Should().BeOfType>().Subject; + dict.Should().ContainKey("message_id").And.ContainKey("notify"); + dict["message_id"].Should().Be("msg42"); + dict["notify"].Should().Be(true); + } + + [Fact] + public void NullBodyParam_SkippedFromDictionary() + { + // Notify = null → не попадает в словарь + ApiRequestBinder.Bind( + new PinMessageParams { MessageId = "msg1", Notify = null }, + "/pin", out _, out var body); + + var dict = body.Should().BeOfType>().Subject; + dict.Should().ContainKey("message_id"); + dict.Should().NotContainKey("notify"); + } + + [Fact] + public void AllBodyParamsNull_BodyIsNull() + { + // Message и Notification — null, CallbackId — QueryParam, не BodyParam + ApiRequestBinder.Bind( + new AnswerCallbackParams { CallbackId = "cb1" }, + "/answers", out _, out var body); + + body.Should().BeNull(); + } + + [Fact] + public void LongArrayBodyParam_StoredAsArray() + { + var ids = new[] { 10L, 20L }; + ApiRequestBinder.Bind( + new AddChatMembersParams { UserIds = ids }, + "/members", out _, out var body); + + var dict = body.Should().BeOfType>().Subject; + dict.Should().ContainKey("user_ids"); + dict["user_ids"].Should().BeSameAs(ids); + } + + // ─── Body → прямой объект ──────────────────────────────────────────────── + + [Fact] + public void BodyAttribute_UsedDirectly() + { + var msgBody = new DTOs.NewMessageBody { Text = "hello" }; + ApiRequestBinder.Bind( + new SendMessageParams { ChatId = 42L, Body = msgBody }, + "/messages", out _, out var body); + + body.Should().BeSameAs(msgBody); + } + + [Fact] + public void BodyAttribute_NullValue_BodyIsNull() + { + ApiRequestBinder.Bind( + new EditMessageParams { MessageId = "msg1", Body = null }, + "/messages", out _, out var body); + + body.Should().BeNull(); + } + + // ─── QueryParam + Body вместе ───────────────────────────────────────────── + + [Fact] + public void QueryParam_And_Body_BothHandled() + { + var msgBody = new DTOs.NewMessageBody { Text = "test" }; + ApiRequestBinder.Bind( + new SendMessageParams { ChatId = 7L, DisableLinkPreview = true, Body = msgBody }, + "/messages", out var path, out var body); + + path.Should().Contain("chat_id=7") + .And.Contain("disable_link_preview=true"); + body.Should().BeSameAs(msgBody); + } + + [Fact] + public void QueryParam_And_BodyParam_BothHandled() + { + ApiRequestBinder.Bind( + new AnswerCallbackParams { CallbackId = "cb1", Notification = "ok" }, + "/answers", out var path, out var body); + + path.Should().Contain("callback_id=cb1"); + var dict = body.Should().BeOfType>().Subject; + dict["notification"].Should().Be("ok"); + } + + // ─── Property name → snake_case key ────────────────────────────────────── + + [Fact] + public void PropertyName_ConvertedToSnakeCaseKey() + { + ApiRequestBinder.Bind( + new GetMessagesByChatParams { ChatId = 1L }, + "/messages", out var path, out _); + + // ChatId → chat_id + path.Should().Contain("chat_id=1"); + path.Should().NotContain("ChatId="); + } +} diff --git a/tests/Max.BotClient.Tests/Unit/Mapping/ChatMappingTests.cs b/tests/Max.BotClient.Tests/Unit/Mapping/ChatMappingTests.cs new file mode 100644 index 0000000..5124dbd --- /dev/null +++ b/tests/Max.BotClient.Tests/Unit/Mapping/ChatMappingTests.cs @@ -0,0 +1,167 @@ +using FluentAssertions; +using Max.BotClient.DTOs; +using Max.BotClient.Mapping; +using Xunit; + +namespace Max.BotClient.Tests.Unit.Mapping; + +public class ChatMappingTests +{ + // ─── null → null ───────────────────────────────────────────────────────── + + [Fact] + public void NullChat_ReturnsNull() => + ((DTOs.Chat?)null).ToChat().Should().BeNull(); + + // ─── Scalar fields ─────────────────────────────────────────────────────── + + [Fact] + public void Chat_ScalarFields_Mapped() + { + var dto = new DTOs.Chat + { + ChatId = 10L, + Type = DTOs.ChatType.Chat, + Status = DTOs.ChatStatus.Active, + Title = "Test Chat", + LastEventTime = 1_700_000_000L, + ParticipantsCount = 5, + OwnerId = 42L, + IsPublic = true, + Link = "https://t.me/testchat", + Description = "Test description" + }; + + var result = dto.ToChat()!; + + result.ChatId.Should().Be(10L); + result.Type.Should().Be(Types.ChatType.Chat); + result.Status.Should().Be(Types.ChatStatus.Active); + result.Title.Should().Be("Test Chat"); + result.LastEventTime.Should().Be(1_700_000_000L); + result.ParticipantsCount.Should().Be(5); + result.OwnerId.Should().Be(42L); + result.IsPublic.Should().BeTrue(); + result.Link.Should().Be("https://t.me/testchat"); + result.Description.Should().Be("Test description"); + } + + // ─── Icon ───────────────────────────────────────────────────────────────── + + [Fact] + public void Chat_Icon_Mapped() + { + var dto = new DTOs.Chat + { + ChatId = 1L, + Icon = new DTOs.Image { Url = "https://example.com/icon.png" } + }; + + dto.ToChat()!.Icon!.Url.Should().Be("https://example.com/icon.png"); + } + + [Fact] + public void Chat_NullIcon_IsNull() + { + var dto = new DTOs.Chat { ChatId = 1L, Icon = null }; + + dto.ToChat()!.Icon.Should().BeNull(); + } + + // ─── ChatMember ─────────────────────────────────────────────────────────── + + [Fact] + public void NullChatMember_ReturnsNull() => + ((DTOs.ChatMember?)null).ToChatMember().Should().BeNull(); + + [Fact] + public void ChatMember_AllFields_Mapped() + { + var dto = new DTOs.ChatMember + { + UserId = 7L, + FirstName = "Alice", + LastName = "Smith", + Username = "alice", + IsBot = false, + LastActivityTime = 123L, + LastAccessTime = 456L, + IsOwner = true, + IsAdmin = false, + JoinTime = 789L, + Permissions = new[] { DTOs.ChatAdminPermission.Write, DTOs.ChatAdminPermission.PinMessage }, + Alias = "queen" + }; + + var result = dto.ToChatMember()!; + + result.UserId.Should().Be(7L); + result.FirstName.Should().Be("Alice"); + result.LastName.Should().Be("Smith"); + result.Username.Should().Be("alice"); + result.IsBot.Should().BeFalse(); + result.LastActivityTime.Should().Be(123L); + result.LastAccessTime.Should().Be(456L); + result.IsOwner.Should().BeTrue(); + result.IsAdmin.Should().BeFalse(); + result.JoinTime.Should().Be(789L); + result.Permissions.Should().BeEquivalentTo(new[] + { + Types.ChatAdminPermission.Write, + Types.ChatAdminPermission.PinMessage + }); + result.Alias.Should().Be("queen"); + } + + [Fact] + public void ChatMember_NullPermissions_IsNull() + { + var dto = new DTOs.ChatMember { UserId = 1L, FirstName = "X", Permissions = null }; + + dto.ToChatMember()!.Permissions.Should().BeNull(); + } + + // ─── GetChatsResponse ──────────────────────────────────────────────────── + + [Fact] + public void NullGetChatsResponse_ReturnsNull() => + ((DTOs.GetChatsResponse?)null).ToGetChatsResponse().Should().BeNull(); + + [Fact] + public void GetChatsResponse_MapsChatsAndMarker() + { + var dto = new DTOs.GetChatsResponse + { + Chats = new[] { new DTOs.Chat { ChatId = 5L, Title = "Group" } }, + Marker = 99L + }; + + var result = dto.ToGetChatsResponse()!; + + result.Chats.Should().HaveCount(1); + result.Chats![0].ChatId.Should().Be(5L); + result.Marker.Should().Be(99L); + } + + // ─── GetChatMembersResponse ────────────────────────────────────────────── + + [Fact] + public void NullGetChatMembersResponse_ReturnsNull() => + ((DTOs.GetChatMembersResponse?)null).ToGetChatMembersResponse().Should().BeNull(); + + [Fact] + public void GetChatMembersResponse_MapsMembersAndMarker() + { + var dto = new DTOs.GetChatMembersResponse + { + Members = new[] { new DTOs.ChatMember { UserId = 3L, FirstName = "Bob" } }, + Marker = 77L + }; + + var result = dto.ToGetChatMembersResponse()!; + + result.Members.Should().HaveCount(1); + result.Members![0].UserId.Should().Be(3L); + result.Marker.Should().Be(77L); + } +} \ No newline at end of file diff --git a/tests/Max.BotClient.Tests/Unit/Mapping/MessageMappingTests.cs b/tests/Max.BotClient.Tests/Unit/Mapping/MessageMappingTests.cs new file mode 100644 index 0000000..70ca409 --- /dev/null +++ b/tests/Max.BotClient.Tests/Unit/Mapping/MessageMappingTests.cs @@ -0,0 +1,223 @@ +using FluentAssertions; +using Max.BotClient.DTOs; +using Max.BotClient.Mapping; +using Xunit; + +namespace Max.BotClient.Tests.Unit.Mapping; + +public class MessageMappingTests +{ + // ─── null → null ───────────────────────────────────────────────────────── + + [Fact] + public void NullDto_ReturnsNull() => + ((DTOs.Message?)null).ToMessage().Should().BeNull(); + + // ─── Базовые поля Body ──────────────────────────────────────────────────── + + [Fact] + public void Body_MapsTextMidSeq() + { + var dto = new DTOs.Message + { + Recipient = new Recipient(), + Body = new MessageBody { Mid = "mid123", Seq = 7, Text = "Hello" } + }; + + var result = dto.ToMessage()!; + + result.Text.Should().Be("Hello"); + result.Mid.Should().Be("mid123"); + result.Seq.Should().Be(7L); + } + + [Fact] + public void NullBody_PropertiesAreNull() + { + var dto = new DTOs.Message { Recipient = new Recipient(), Body = null }; + + var result = dto.ToMessage()!; + + result.Text.Should().BeNull(); + result.Mid.Should().BeNull(); + result.Seq.Should().BeNull(); + } + + // ─── Timestamp и Url ────────────────────────────────────────────────────── + + [Fact] + public void Timestamp_And_Url_Mapped() + { + var dto = new DTOs.Message + { + Recipient = new Recipient(), + Body = new MessageBody { Mid = "m1", Seq = 1 }, + Timestamp = 1_700_000_000L, + Url = "https://example.com/post/1" + }; + + var result = dto.ToMessage()!; + + result.Timestamp.Should().Be(1_700_000_000L); + result.Url.Should().Be("https://example.com/post/1"); + } + + // ─── Sender ─────────────────────────────────────────────────────────────── + + [Fact] + public void Sender_MapsToTypesUser() + { + var dto = new DTOs.Message + { + Recipient = new Recipient(), + Body = new MessageBody { Mid = "m1", Seq = 1 }, + Sender = new User { UserId = 42L, FirstName = "John", LastName = "Doe", Username = "jdoe", IsBot = false } + }; + + var result = dto.ToMessage()!; + + result.Sender!.UserId.Should().Be(42L); + result.Sender.FirstName.Should().Be("John"); + result.Sender.LastName.Should().Be("Doe"); + result.Sender.Username.Should().Be("jdoe"); + result.Sender.IsBot.Should().BeFalse(); + } + + [Fact] + public void NullSender_IsNull() + { + var dto = new DTOs.Message + { + Recipient = new Recipient(), + Body = new MessageBody { Mid = "m1", Seq = 1 }, + Sender = null + }; + + dto.ToMessage()!.Sender.Should().BeNull(); + } + + // ─── Recipient ──────────────────────────────────────────────────────────── + + [Fact] + public void Recipient_ChatId_Mapped() + { + var dto = new DTOs.Message + { + Recipient = new Recipient { ChatId = 100L }, + Body = new MessageBody { Mid = "m1", Seq = 1 } + }; + + var result = dto.ToMessage()!; + + result.RecipientChatId.Should().Be(100L); + result.RecipientUserId.Should().BeNull(); + } + + [Fact] + public void Recipient_UserId_Mapped() + { + var dto = new DTOs.Message + { + Recipient = new Recipient { UserId = 55L }, + Body = new MessageBody { Mid = "m1", Seq = 1 } + }; + + dto.ToMessage()!.RecipientUserId.Should().Be(55L); + } + + // ─── LinkedMessage (Link) ───────────────────────────────────────────────── + + [Fact] + public void Link_AllFields_Mapped() + { + var dto = new DTOs.Message + { + Recipient = new Recipient(), + Body = new MessageBody { Mid = "m1", Seq = 1 }, + Link = new LinkedMessage + { + Type = MessageLinkType.Reply, + Sender = new User { UserId = 5L, FirstName = "Alice" }, + ChatId = 200L, + Message = new MessageBody { Mid = "linked123", Seq = 2, Text = "Original" } + } + }; + + var result = dto.ToMessage()!; + + result.LinkType.Should().Be(Types.MessageLinkType.Reply); + result.LinkSender!.UserId.Should().Be(5L); + result.LinkChatId.Should().Be(200L); + result.LinkMid.Should().Be("linked123"); + result.LinkSeq.Should().Be(2L); + result.LinkText.Should().Be("Original"); + } + + [Fact] + public void NullLink_AllLinkPropertiesNull() + { + var dto = new DTOs.Message + { + Recipient = new Recipient(), + Body = new MessageBody { Mid = "m1", Seq = 1 }, + Link = null + }; + + var result = dto.ToMessage()!; + + result.LinkType.Should().BeNull(); + result.LinkSender.Should().BeNull(); + result.LinkChatId.Should().BeNull(); + result.LinkMid.Should().BeNull(); + } + + // ─── Stat.Views ─────────────────────────────────────────────────────────── + + [Fact] + public void Stat_Views_Mapped() + { + var dto = new DTOs.Message + { + Recipient = new Recipient(), + Body = new MessageBody { Mid = "m1", Seq = 1 }, + Stat = new MessageStat { Views = 42 } + }; + + dto.ToMessage()!.Views.Should().Be(42); + } + + [Fact] + public void NullStat_ViewsIsNull() + { + var dto = new DTOs.Message + { + Recipient = new Recipient(), + Body = new MessageBody { Mid = "m1", Seq = 1 }, + Stat = null + }; + + dto.ToMessage()!.Views.Should().BeNull(); + } + + // ─── ToMessages (массив) ────────────────────────────────────────────────── + + [Fact] + public void ToMessages_NullArray_ReturnsNull() => + ((DTOs.Message[]?)null).ToMessages().Should().BeNull(); + + [Fact] + public void ToMessages_MapsAllElements() + { + var dtos = new[] + { + new DTOs.Message { Recipient = new Recipient(), Body = new MessageBody { Mid = "m1", Seq = 1, Text = "A" } }, + new DTOs.Message { Recipient = new Recipient(), Body = new MessageBody { Mid = "m2", Seq = 2, Text = "B" } } + }; + + var result = dtos.ToMessages()!; + + result.Should().HaveCount(2); + result[0].Text.Should().Be("A"); + result[1].Text.Should().Be("B"); + } +} diff --git a/tests/Max.BotClient.Tests/Unit/ToSnakeCaseTests.cs b/tests/Max.BotClient.Tests/Unit/ToSnakeCaseTests.cs new file mode 100644 index 0000000..d6752cb --- /dev/null +++ b/tests/Max.BotClient.Tests/Unit/ToSnakeCaseTests.cs @@ -0,0 +1,22 @@ +using FluentAssertions; +using Max.BotClient; +using Xunit; + +namespace Max.BotClient.Tests.Unit; + +public class ToSnakeCaseTests +{ + [Theory] + [InlineData("TypingOn", "typing_on")] + [InlineData("MessageId", "message_id")] + [InlineData("DisableLinkPreview", "disable_link_preview")] + [InlineData("UpdateTypes", "update_types")] + [InlineData("ChatId", "chat_id")] + [InlineData("url", "url")] + [InlineData("A", "a")] + [InlineData("ABC", "a_b_c")] + [InlineData("UserIds", "user_ids")] + [InlineData("SendingPhoto", "sending_photo")] + public void ToSnakeCase_ConvertsCorrectly(string input, string expected) => + input.ToSnakeCase().Should().Be(expected); +} From 8f88346996bea66303b3bea0651ecd51346d3773 Mon Sep 17 00:00:00 2001 From: Aleksandr Semenenko <112961081+Deskri@users.noreply.github.com> Date: Fri, 27 Feb 2026 17:26:32 +0400 Subject: [PATCH 04/18] feat: update readme --- README.md | 799 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 798 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 7a92777..405053e 100644 --- a/README.md +++ b/README.md @@ -1 +1,798 @@ -# Max.BotClient \ No newline at end of file +# Max.BotClient + +
+ +[![.NET Standard](https://img.shields.io/badge/.NET%20Standard-2.0-blue?style=flat-square)](https://docs.microsoft.com/en-us/dotnet/standard/net-standard) +[![License: MIT](https://img.shields.io/badge/License-MIT-green?style=flat-square)](LICENSE) + +**Неофициальный .NET SDK для [MAX Bot API](https://dev.max.ru/docs-api)** + ·  +**Unofficial .NET SDK for [MAX Bot API](https://dev.max.ru/docs-api)** + +
+ +--- + +> **[Русский](#русский) · [English](#english)** + +--- + +# Русский + +## Содержание + +- [О проекте](#о-проекте) +- [Быстрый старт](#быстрый-старт) +- [Конфигурация](#конфигурация) +- [Получение обновлений](#получение-обновлений) +- [Справочник API методов](#справочник-api-методов) + - [Сообщения](#сообщения) + - [Чаты](#чаты) + - [Участники](#участники) + - [Подписки](#подписки) + - [Прочее](#прочее) +- [Построитель сообщений](#построитель-сообщений) +- [Обработка ошибок](#обработка-ошибок) + +--- + +## О проекте + +`Max.BotClient` — библиотека для разработки ботов на платформе [MAX](https://max.ru). +Написана на **.NET Standard 2.0**, совместима с .NET Framework 4.6.1+ и .NET Core 2.0+. + +**Ключевые возможности:** + +- Все методы MAX Bot API с полной типизацией +- Единый класс `Message` для создания, чтения и редактирования сообщений (builder pattern) +- Long Polling из коробки с автоматической пагинацией через маркеры +- Поддержка Webhook-подписок +- Автоматические повторные запросы при ошибках 429/5xx (exponential backoff) +- Поддержка вложений: фото, видео, аудио, файлы, стикеры, контакты, геолокация +- Inline-клавиатуры с fluent-builder + +--- + +## Быстрый старт + +```csharp +using Max.BotClient; +using Max.BotClient.Types; + +// Создание клиента +var bot = new BotClient("ВАШ_ТОКЕН"); + +// Получение информации о боте +var me = await bot.GetMe(); +Console.WriteLine($"Бот: @{me.Username}"); + +// Отправка текстового сообщения пользователю +await bot.SendMessage(userId, new Message("Привет!")); + +// Отправка сообщения в чат +await bot.SendMessage(chatId, new Message("Привет, чат!").ToChat()); +``` + +--- + +## Конфигурация + +```csharp +var options = new BotClientOptions("ВАШ_ТОКЕН") +{ + RetryCount = 3, // Максимум повторных попыток при 429/5xx (по умолчанию: 3) + RetryDelaySeconds = 1 // Начальная задержка в секундах для exponential backoff (по умолчанию: 1) + // Фактические задержки: 1s, 2s, 4s... +}; + +var bot = new BotClient(options); + +// Можно передать собственный HttpClient +var httpClient = new HttpClient(); +var bot2 = new BotClient(options, httpClient); +``` + +--- + +## Получение обновлений + +### Long Polling (рекомендуется для большинства случаев) + +Метод `StartReceiving` запускает опрос в **фоновом потоке** и не блокирует выполнение: + +```csharp +var cts = new CancellationTokenSource(); + +bot.StartReceiving( + updateHandler: async (bot, update, ct) => + { + Console.WriteLine($"Тип обновления: {update.UpdateType}"); + + if (update.Message != null) + { + var msg = update.Message; + Console.WriteLine($"Сообщение от {msg.Sender?.Name}: {msg.Text}"); + + // Ответить в тот же чат/диалог + await bot.SendMessage( + msg.RecipientId ?? 0, + new Message("Получил твоё сообщение!").ToChat() + ); + } + }, + errorHandler: async (bot, ex, ct) => + { + Console.WriteLine($"Ошибка: {ex.Message}"); + }, + options: new ReceiverOptions + { + Timeout = 30, // Таймаут long polling в секундах (0–90) + Limit = 100, // Обновлений за запрос (1–1000) + DropPendingUpdates = true, // Пропустить накопившиеся обновления при старте + AllowedUpdates = new[] // Фильтр типов обновлений (null = все) + { + UpdateType.MessageCreated, + UpdateType.MessageCallback + } + }, + cancellationToken: cts.Token +); + +Console.WriteLine("Бот запущен. Нажмите Ctrl+C для остановки."); +Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); }; +await Task.Delay(Timeout.Infinite, cts.Token).ContinueWith(_ => { }); +``` + +Метод `ReceiveAsync` **блокирует** текущий поток до отмены: + +```csharp +await bot.ReceiveAsync(updateHandler, errorHandler, options, cts.Token); +``` + +### Webhook + +```csharp +// Подписаться на вебхук +var result = await bot.Subscribe( + url: "https://your-domain.com/webhook", + updateTypes: new[] { UpdateType.MessageCreated, UpdateType.MessageCallback }, + secret: "your-secret-key" // Для заголовка X-Max-Bot-Api-Secret +); + +// Получить список активных подписок +var subscriptions = await bot.GetSubscriptions(); + +// Отписаться +await bot.Unsubscribe("https://your-domain.com/webhook"); +``` + +--- + +## Справочник API методов + +### Сообщения + +| Метод | HTTP | Описание | +|-------|------|----------| +| `GetMessages(chatId, from?, to?, count?)` | GET | Получить сообщения из чата (по времени) | +| `GetMessages(messageIds[])` | GET | Получить сообщения по их ID | +| `GetMessage(messageId)` | GET | Получить одно сообщение по ID | +| `SendMessage(id, message, disableLinkPreview?)` | POST | Отправить сообщение | +| `EditMessage(messageId, message)` | PUT | Редактировать сообщение (до 24 часов) | +| `DeleteMessage(messageId)` | DELETE | Удалить сообщение (до 24 часов) | +| `AnswerCallback(callbackId, message?, notification?)` | POST | Ответить на нажатие кнопки | + +```csharp +// Получить последние 10 сообщений из чата +var messages = await bot.GetMessages(chatId, count: 10); + +// Отправить сообщение с отключённым превью ссылок +await bot.SendMessage(userId, new Message("https://example.com"), disableLinkPreview: true); + +// Редактировать сообщение +await bot.EditMessage(message.Mid, new Message("Исправленный текст")); + +// Удалить сообщение +await bot.DeleteMessage(message.Mid); + +// Ответить на нажатие кнопки уведомлением +await bot.AnswerCallback(callbackId, notification: "Кнопка нажата!"); + +// Ответить на нажатие кнопки с обновлением сообщения +await bot.AnswerCallback(callbackId, message: new Message("Новый текст сообщения")); +``` + +--- + +### Чаты + +| Метод | HTTP | Описание | +|-------|------|----------| +| `GetChats(count?, marker?)` | GET | Список чатов бота (с пагинацией) | +| `GetChat(chatId)` | GET | Информация о чате | +| `UpdateChat(chatId, request)` | PATCH | Изменить название/иконку чата | +| `DeleteChat(chatId)` | DELETE | Удалить чат для всех участников | +| `SendAction(chatId, action)` | POST | Отправить действие (typing, sending_photo...) | +| `GetPinnedMessage(chatId)` | GET | Получить закреплённое сообщение | +| `PinMessage(chatId, messageId, notify?)` | PUT | Закрепить сообщение | +| `UnpinMessage(chatId)` | DELETE | Открепить сообщение | + +```csharp +// Получить все чаты (с пагинацией) +long? marker = null; +do +{ + var response = await bot.GetChats(count: 50, marker: marker); + foreach (var chat in response.Chats) + Console.WriteLine($"{chat.Title} ({chat.ChatId})"); + marker = response.Marker; +} while (marker != null); + +// Показать "бот печатает..." +await bot.SendAction(chatId, SenderAction.TypingOn); + +// Закрепить сообщение без уведомления +await bot.PinMessage(chatId, messageId, notify: false); + +// Изменить название чата +await bot.UpdateChat(chatId, new UpdateChatRequest { Title = "Новое название" }); +``` + +--- + +### Участники + +| Метод | HTTP | Описание | +|-------|------|----------| +| `GetMyMembership(chatId)` | GET | Членство бота в чате | +| `LeaveChat(chatId)` | DELETE | Покинуть чат | +| `GetChatMembers(chatId, userIds?, count?, marker?)` | GET | Список участников | +| `AddChatMembers(chatId, userIds[])` | POST | Добавить участников | +| `RemoveChatMember(chatId, userId, block?)` | DELETE | Удалить участника | +| `GetChatAdmins(chatId)` | GET | Список администраторов | +| `AddChatAdmins(chatId, admins[])` | POST | Назначить администраторов | +| `RemoveChatAdmin(chatId, userId)` | DELETE | Снять права администратора | + +```csharp +// Получить участников чата +var members = await bot.GetChatMembers(chatId, count: 100); +foreach (var member in members.Members) + Console.WriteLine($"{member.User?.Name} — {member.Role}"); + +// Добавить пользователей в чат +await bot.AddChatMembers(chatId, new[] { userId1, userId2 }); + +// Удалить и заблокировать участника +await bot.RemoveChatMember(chatId, userId, block: true); + +// Назначить администратора с правами +await bot.AddChatAdmins(chatId, new[] +{ + new ChatAdmin { UserId = userId, Permissions = AdminPermissions.ReadMessages | AdminPermissions.AddMembers } +}); +``` + +--- + +### Подписки + +| Метод | HTTP | Описание | +|-------|------|----------| +| `GetSubscriptions()` | GET | Список вебхук-подписок | +| `Subscribe(url, updateTypes?, secret?)` | POST | Подписаться на вебхук | +| `Unsubscribe(url)` | DELETE | Отписаться от вебхука | +| `GetUpdates(limit?, timeout?, marker?, types?)` | GET | Получить обновления (low-level) | + +--- + +### Прочее + +| Метод | HTTP | Описание | +|-------|------|----------| +| `GetMe()` | GET | Информация о боте | +| `GetUploadUrl(type)` | POST | Получить URL для загрузки файла | +| `GetVideo(videoToken)` | GET | Информация о видео | + +```csharp +// Получить URL для загрузки фото +var upload = await bot.GetUploadUrl(UploadType.Photo); +// Загрузите файл по upload.Url, затем используйте токен в сообщении + +// Получить информацию о видео +var video = await bot.GetVideo(videoAttachment.Token); +Console.WriteLine($"Длительность: {video.Duration} сек, {video.Width}x{video.Height}"); +``` + +--- + +## Построитель сообщений + +Класс `Message` используется одновременно для создания, получения и редактирования сообщений: + +```csharp +// Текст с форматированием +var msg = new Message("**Жирный** и _курсив_"); + +// Фото по URL +var msg = new Message("Подпись") + .WithPhoto("https://example.com/photo.jpg"); + +// Несколько фото +var msg = new Message() + .WithPhoto("https://example.com/photo1.jpg") + .AddPhoto("https://example.com/photo2.jpg"); + +// Фото по токену (уже загруженное) +var msg = new Message().WithPhoto(PhotoAttachment.FromToken("token123")); + +// Видео +var msg = new Message("Видео").WithVideo("video_token"); + +// Видео + фото в одном сообщении +var msg = new Message("Медиа") + .WithVideo("video_token") + .WithPhoto("https://example.com/photo.jpg"); + +// Inline-клавиатура +var msg = new Message("Выберите действие:") + .WithKeyboard(kb => kb + .AddRow() + .AddCallbackButton("Кнопка 1", "payload_1") + .AddCallbackButton("Кнопка 2", "payload_2") + .AddRow() + .AddLinkButton("Сайт", "https://example.com") + ); + +// Отправить в чат (по умолчанию — пользователю) +var msg = new Message("Привет!").ToChat(); + +// Ссылка на другое сообщение (reply/forward) +var msg = new Message("Ответ") + .WithLink(MessageLinkRequestType.Reply, messageId, chatId); +``` + +### Чтение полученных сообщений + +```csharp +bot.StartReceiving(async (bot, update, ct) => +{ + var msg = update.Message; + if (msg == null) return; + + Console.WriteLine($"Текст: {msg.Text}"); + Console.WriteLine($"От: {msg.Sender?.Name} ({msg.Sender?.UserId})"); + Console.WriteLine($"ID: {msg.Mid}"); + Console.WriteLine($"Время: {DateTimeOffset.FromUnixTimeMilliseconds(msg.Timestamp)}"); + + // Получить вложения + var photos = msg.GetPhotos(); // PhotoAttachment[] + var videos = msg.GetVideos(); // VideoAttachment[] + var files = msg.GetFiles(); // FileAttachment[] + var audio = msg.GetAudios(); // AudioAttachment[] + + // Проверить наличие вложений + if (msg.HasAttachments()) + Console.WriteLine($"Вложений: {msg.GetAllAttachments().Length}"); + + // Inline-клавиатура + if (update.Callback != null) + { + Console.WriteLine($"Payload кнопки: {update.Callback.Payload}"); + await bot.AnswerCallback(update.Callback.CallbackId, notification: "OK"); + } +}); +``` + +--- + +## Обработка ошибок + +```csharp +try +{ + await bot.SendMessage(userId, new Message("Текст")); +} +catch (MaxBotClientApiException ex) +{ + Console.WriteLine($"Код ошибки: {(int)ex.StatusCode}"); + Console.WriteLine($"Тело ответа: {ex.ResponseBody}"); + Console.WriteLine($"Повторяема: {ex.IsRetryable}"); // true для 429/5xx +} +catch (OperationCanceledException) +{ + Console.WriteLine("Запрос отменён"); +} +``` + +--- + +--- + +# English + +## Table of Contents + +- [About](#about) +- [Quick Start](#quick-start) +- [Configuration](#configuration) +- [Receiving Updates](#receiving-updates) +- [API Methods Reference](#api-methods-reference) + - [Messages](#messages) + - [Chats](#chats) + - [Members](#members) + - [Subscriptions](#subscriptions) + - [Miscellaneous](#miscellaneous) +- [Message Builder](#message-builder) +- [Error Handling](#error-handling) + +--- + +## About + +`Max.BotClient` is a library for building bots on the [MAX](https://max.ru) platform. +Targets **.NET Standard 2.0** — compatible with .NET Framework 4.6.1+ and .NET Core 2.0+. + +**Key features:** + +- Full coverage of MAX Bot API with strong typing +- Single unified `Message` class for creating, reading, and editing messages (builder pattern) +- Built-in Long Polling with automatic marker-based pagination +- Webhook subscription support +- Automatic retries on 429/5xx errors (exponential backoff) +- Attachment support: photos, videos, audio, files, stickers, contacts, locations +- Inline keyboards with fluent builder + +--- + +## Quick Start + +```csharp +using Max.BotClient; +using Max.BotClient.Types; + +// Create the client +var bot = new BotClient("YOUR_TOKEN"); + +// Get bot info +var me = await bot.GetMe(); +Console.WriteLine($"Bot: @{me.Username}"); + +// Send a text message to a user +await bot.SendMessage(userId, new Message("Hello!")); + +// Send a message to a chat +await bot.SendMessage(chatId, new Message("Hello, chat!").ToChat()); +``` + +--- + +## Configuration + +```csharp +var options = new BotClientOptions("YOUR_TOKEN") +{ + RetryCount = 3, // Max retries on 429/5xx (default: 3) + RetryDelaySeconds = 1 // Initial delay in seconds for exponential backoff (default: 1) + // Actual delays: 1s, 2s, 4s... +}; + +var bot = new BotClient(options); + +// You can also inject a custom HttpClient +var httpClient = new HttpClient(); +var bot2 = new BotClient(options, httpClient); +``` + +--- + +## Receiving Updates + +### Long Polling (recommended for most use cases) + +`StartReceiving` starts polling in a **background task** and does not block: + +```csharp +var cts = new CancellationTokenSource(); + +bot.StartReceiving( + updateHandler: async (bot, update, ct) => + { + Console.WriteLine($"Update type: {update.UpdateType}"); + + if (update.Message != null) + { + var msg = update.Message; + Console.WriteLine($"Message from {msg.Sender?.Name}: {msg.Text}"); + + // Reply to the same chat/dialog + await bot.SendMessage( + msg.RecipientId ?? 0, + new Message("Got your message!").ToChat() + ); + } + }, + errorHandler: async (bot, ex, ct) => + { + Console.WriteLine($"Error: {ex.Message}"); + }, + options: new ReceiverOptions + { + Timeout = 30, // Long polling timeout in seconds (0–90) + Limit = 100, // Updates per request (1–1000) + DropPendingUpdates = true, // Skip accumulated updates on startup + AllowedUpdates = new[] // Filter update types (null = all) + { + UpdateType.MessageCreated, + UpdateType.MessageCallback + } + }, + cancellationToken: cts.Token +); + +Console.WriteLine("Bot started. Press Ctrl+C to stop."); +Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); }; +await Task.Delay(Timeout.Infinite, cts.Token).ContinueWith(_ => { }); +``` + +`ReceiveAsync` **blocks** the current thread until cancellation: + +```csharp +await bot.ReceiveAsync(updateHandler, errorHandler, options, cts.Token); +``` + +### Webhook + +```csharp +// Subscribe to webhook +var result = await bot.Subscribe( + url: "https://your-domain.com/webhook", + updateTypes: new[] { UpdateType.MessageCreated, UpdateType.MessageCallback }, + secret: "your-secret-key" // Used for X-Max-Bot-Api-Secret header +); + +// List active subscriptions +var subscriptions = await bot.GetSubscriptions(); + +// Unsubscribe +await bot.Unsubscribe("https://your-domain.com/webhook"); +``` + +--- + +## API Methods Reference + +### Messages + +| Method | HTTP | Description | +|--------|------|-------------| +| `GetMessages(chatId, from?, to?, count?)` | GET | Get messages from a chat by time range | +| `GetMessages(messageIds[])` | GET | Get messages by their IDs | +| `GetMessage(messageId)` | GET | Get a single message by ID | +| `SendMessage(id, message, disableLinkPreview?)` | POST | Send a message | +| `EditMessage(messageId, message)` | PUT | Edit a message (within 24 hours) | +| `DeleteMessage(messageId)` | DELETE | Delete a message (within 24 hours) | +| `AnswerCallback(callbackId, message?, notification?)` | POST | Answer a button callback | + +```csharp +// Get last 10 messages from a chat +var messages = await bot.GetMessages(chatId, count: 10); + +// Send a message with link preview disabled +await bot.SendMessage(userId, new Message("https://example.com"), disableLinkPreview: true); + +// Edit a message +await bot.EditMessage(message.Mid, new Message("Updated text")); + +// Delete a message +await bot.DeleteMessage(message.Mid); + +// Answer a button callback with a notification +await bot.AnswerCallback(callbackId, notification: "Button clicked!"); + +// Answer a button callback by updating the message +await bot.AnswerCallback(callbackId, message: new Message("New message text")); +``` + +--- + +### Chats + +| Method | HTTP | Description | +|--------|------|-------------| +| `GetChats(count?, marker?)` | GET | List bot's chats (paginated) | +| `GetChat(chatId)` | GET | Get chat info | +| `UpdateChat(chatId, request)` | PATCH | Update chat title/icon | +| `DeleteChat(chatId)` | DELETE | Delete a chat for all members | +| `SendAction(chatId, action)` | POST | Send a chat action (typing, sending_photo...) | +| `GetPinnedMessage(chatId)` | GET | Get the pinned message | +| `PinMessage(chatId, messageId, notify?)` | PUT | Pin a message | +| `UnpinMessage(chatId)` | DELETE | Unpin the current message | + +```csharp +// Iterate all chats with pagination +long? marker = null; +do +{ + var response = await bot.GetChats(count: 50, marker: marker); + foreach (var chat in response.Chats) + Console.WriteLine($"{chat.Title} ({chat.ChatId})"); + marker = response.Marker; +} while (marker != null); + +// Show "bot is typing..." +await bot.SendAction(chatId, SenderAction.TypingOn); + +// Pin a message silently +await bot.PinMessage(chatId, messageId, notify: false); + +// Rename a chat +await bot.UpdateChat(chatId, new UpdateChatRequest { Title = "New Chat Name" }); +``` + +--- + +### Members + +| Method | HTTP | Description | +|--------|------|-------------| +| `GetMyMembership(chatId)` | GET | Bot's membership in a chat | +| `LeaveChat(chatId)` | DELETE | Leave a chat | +| `GetChatMembers(chatId, userIds?, count?, marker?)` | GET | List chat members | +| `AddChatMembers(chatId, userIds[])` | POST | Add members to a chat | +| `RemoveChatMember(chatId, userId, block?)` | DELETE | Remove a member | +| `GetChatAdmins(chatId)` | GET | List chat admins | +| `AddChatAdmins(chatId, admins[])` | POST | Promote admins | +| `RemoveChatAdmin(chatId, userId)` | DELETE | Demote an admin | + +```csharp +// List chat members +var members = await bot.GetChatMembers(chatId, count: 100); +foreach (var member in members.Members) + Console.WriteLine($"{member.User?.Name} — {member.Role}"); + +// Add users to a chat +await bot.AddChatMembers(chatId, new[] { userId1, userId2 }); + +// Remove and block a member +await bot.RemoveChatMember(chatId, userId, block: true); + +// Promote an admin with specific permissions +await bot.AddChatAdmins(chatId, new[] +{ + new ChatAdmin { UserId = userId, Permissions = AdminPermissions.ReadMessages | AdminPermissions.AddMembers } +}); +``` + +--- + +### Subscriptions + +| Method | HTTP | Description | +|--------|------|-------------| +| `GetSubscriptions()` | GET | List webhook subscriptions | +| `Subscribe(url, updateTypes?, secret?)` | POST | Subscribe to webhook | +| `Unsubscribe(url)` | DELETE | Unsubscribe from webhook | +| `GetUpdates(limit?, timeout?, marker?, types?)` | GET | Get updates (low-level) | + +--- + +### Miscellaneous + +| Method | HTTP | Description | +|--------|------|-------------| +| `GetMe()` | GET | Get bot info | +| `GetUploadUrl(type)` | POST | Get a URL to upload a file | +| `GetVideo(videoToken)` | GET | Get video details | + +```csharp +// Get an upload URL for a photo +var upload = await bot.GetUploadUrl(UploadType.Photo); +// Upload your file to upload.Url, then use the token in a message + +// Get video details +var video = await bot.GetVideo(videoAttachment.Token); +Console.WriteLine($"Duration: {video.Duration}s, {video.Width}x{video.Height}"); +``` + +--- + +## Message Builder + +The `Message` class is used for creating, receiving, and editing messages all in one: + +```csharp +// Plain text +var msg = new Message("Hello World!"); + +// Text with markdown formatting +var msg = new Message("**Bold** and _italic_"); + +// Photo from URL +var msg = new Message("Caption").WithPhoto("https://example.com/photo.jpg"); + +// Multiple photos +var msg = new Message() + .WithPhoto("https://example.com/photo1.jpg") + .AddPhoto("https://example.com/photo2.jpg"); + +// Photo from token (already uploaded) +var msg = new Message().WithPhoto(PhotoAttachment.FromToken("token123")); + +// Video +var msg = new Message("Video").WithVideo("video_token"); + +// Combined video + photo +var msg = new Message("Media") + .WithVideo("video_token") + .WithPhoto("https://example.com/photo.jpg"); + +// Inline keyboard +var msg = new Message("Choose an action:") + .WithKeyboard(kb => kb + .AddRow() + .AddCallbackButton("Button 1", "payload_1") + .AddCallbackButton("Button 2", "payload_2") + .AddRow() + .AddLinkButton("Website", "https://example.com") + ); + +// Send to a chat (default recipient is user) +var msg = new Message("Hello!").ToChat(); + +// Reply / forward +var msg = new Message("Reply text") + .WithLink(MessageLinkRequestType.Reply, messageId, chatId); +``` + +### Reading incoming messages + +```csharp +bot.StartReceiving(async (bot, update, ct) => +{ + var msg = update.Message; + if (msg == null) return; + + Console.WriteLine($"Text: {msg.Text}"); + Console.WriteLine($"From: {msg.Sender?.Name} ({msg.Sender?.UserId})"); + Console.WriteLine($"ID: {msg.Mid}"); + Console.WriteLine($"Time: {DateTimeOffset.FromUnixTimeMilliseconds(msg.Timestamp)}"); + + // Access attachments + var photos = msg.GetPhotos(); // PhotoAttachment[] + var videos = msg.GetVideos(); // VideoAttachment[] + var files = msg.GetFiles(); // FileAttachment[] + var audio = msg.GetAudios(); // AudioAttachment[] + + // Check for attachments + if (msg.HasAttachments()) + Console.WriteLine($"Attachments: {msg.GetAllAttachments().Length}"); + + // Handle button callbacks + if (update.Callback != null) + { + Console.WriteLine($"Button payload: {update.Callback.Payload}"); + await bot.AnswerCallback(update.Callback.CallbackId, notification: "OK"); + } +}); +``` + +--- + +## Error Handling + +```csharp +try +{ + await bot.SendMessage(userId, new Message("Text")); +} +catch (MaxBotClientApiException ex) +{ + Console.WriteLine($"Status code: {(int)ex.StatusCode}"); + Console.WriteLine($"Response body: {ex.ResponseBody}"); + Console.WriteLine($"Is retryable: {ex.IsRetryable}"); // true for 429/5xx +} +catch (OperationCanceledException) +{ + Console.WriteLine("Request was cancelled"); +} +``` From 968efaa72ac95a4395010d0c090b2a244abf2719 Mon Sep 17 00:00:00 2001 From: Aleksandr Semenenko <112961081+Deskri@users.noreply.github.com> Date: Mon, 16 Mar 2026 16:58:28 +0400 Subject: [PATCH 05/18] feat: add version and update System.Text.Json (cherry picked from commit 0b563a3e2d0fb7cc5d6c371a237a424d95825c0a) --- src/Max.BotClient/Max.BotClient.csproj | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Max.BotClient/Max.BotClient.csproj b/src/Max.BotClient/Max.BotClient.csproj index 389f686..8375f0a 100644 --- a/src/Max.BotClient/Max.BotClient.csproj +++ b/src/Max.BotClient/Max.BotClient.csproj @@ -4,10 +4,11 @@ netstandard2.0 latest enable + 1.0.0 - + From cbce98946db778da8905da9a3ad0ec92651b42ab Mon Sep 17 00:00:00 2001 From: Aleksandr Semenenko <112961081+Deskri@users.noreply.github.com> Date: Tue, 17 Mar 2026 14:44:24 +0400 Subject: [PATCH 06/18] fix: deserialize dtos (cherry picked from commit d62a02073f75e8507d571347f39735b795915c37) --- .../Max.BotClient.JsonOptions.cs | 135 +++++++++- .../Unit/JsonConvertersTests.cs | 255 ++++++++++++++++++ 2 files changed, 389 insertions(+), 1 deletion(-) create mode 100644 tests/Max.BotClient.Tests/Unit/JsonConvertersTests.cs diff --git a/src/Max.BotClient/Max.BotClient.JsonOptions.cs b/src/Max.BotClient/Max.BotClient.JsonOptions.cs index 4f304ca..2aa4967 100644 --- a/src/Max.BotClient/Max.BotClient.JsonOptions.cs +++ b/src/Max.BotClient/Max.BotClient.JsonOptions.cs @@ -1,5 +1,7 @@ +using System; using System.Text.Json; using System.Text.Json.Serialization; +using Max.BotClient.DTOs; namespace Max.BotClient { @@ -23,8 +25,139 @@ private static JsonSerializerOptions CreateDefault() }; options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower)); + options.Converters.Add(new UpdateConverter()); + options.Converters.Add(new AttachmentConverter()); + options.Converters.Add(new ButtonConverter()); + options.Converters.Add(new MarkupElementConverter()); + options.Converters.Add(new AttachmentRequestConverter()); return options; } } -} + + internal class UpdateConverter : JsonConverter + { + public override IUpdate? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + using var doc = JsonDocument.ParseValue(ref reader); + var root = doc.RootElement; + + if (!root.TryGetProperty("update_type", out var typeElement)) + return null; + + return typeElement.GetString() switch + { + "message_created" => root.Deserialize(options), + "message_callback" => root.Deserialize(options), + "message_edited" => root.Deserialize(options), + "message_removed" => root.Deserialize(options), + "bot_added" => root.Deserialize(options), + "bot_removed" => root.Deserialize(options), + "dialog_muted" => root.Deserialize(options), + "dialog_unmuted" => root.Deserialize(options), + "dialog_cleared" => root.Deserialize(options), + "dialog_removed" => root.Deserialize(options), + "user_added" => root.Deserialize(options), + "user_removed" => root.Deserialize(options), + "bot_started" => root.Deserialize(options), + "bot_stopped" => root.Deserialize(options), + "chat_title_changed" => root.Deserialize(options), + _ => null + }; + } + + public override void Write(Utf8JsonWriter writer, IUpdate value, JsonSerializerOptions options) + => throw new NotSupportedException("Serialization of IUpdate is not supported."); + } + + internal class AttachmentConverter : JsonConverter + { + public override IAttachment? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + using var doc = JsonDocument.ParseValue(ref reader); + var root = doc.RootElement; + + if (!root.TryGetProperty("type", out var typeElement)) + return null; + + return typeElement.GetString() switch + { + "image" => root.Deserialize(options), + "video" => root.Deserialize(options), + "audio" => root.Deserialize(options), + "file" => root.Deserialize(options), + "sticker" => root.Deserialize(options), + "contact" => root.Deserialize(options), + "share" => root.Deserialize(options), + "location" => root.Deserialize(options), + "inline_keyboard" => root.Deserialize(options), + _ => null + }; + } + + public override void Write(Utf8JsonWriter writer, IAttachment value, JsonSerializerOptions options) + => JsonSerializer.Serialize(writer, value, value.GetType(), options); + } + + internal class ButtonConverter : JsonConverter + { + public override IButton? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + using var doc = JsonDocument.ParseValue(ref reader); + var root = doc.RootElement; + + if (!root.TryGetProperty("type", out var typeElement)) + return null; + + return typeElement.GetString() switch + { + "callback" => root.Deserialize(options), + "link" => root.Deserialize(options), + "request_geo_location" => root.Deserialize(options), + "request_contact" => root.Deserialize(options), + "open_app" => root.Deserialize(options), + "message" => root.Deserialize(options), + _ => null + }; + } + + public override void Write(Utf8JsonWriter writer, IButton value, JsonSerializerOptions options) + => JsonSerializer.Serialize(writer, value, value.GetType(), options); + } + + internal class MarkupElementConverter : JsonConverter + { + public override IMarkupElement? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + using var doc = JsonDocument.ParseValue(ref reader); + var root = doc.RootElement; + + if (!root.TryGetProperty("type", out var typeElement)) + return null; + + return typeElement.GetString() switch + { + "strong" => root.Deserialize(options), + "emphasized" => root.Deserialize(options), + "monospaced" => root.Deserialize(options), + "link" => root.Deserialize(options), + "strikethrough" => root.Deserialize(options), + "underline" => root.Deserialize(options), + "user_mention" => root.Deserialize(options), + _ => null + }; + } + + public override void Write(Utf8JsonWriter writer, IMarkupElement value, JsonSerializerOptions options) + => JsonSerializer.Serialize(writer, value, value.GetType(), options); + } + + internal class AttachmentRequestConverter : JsonConverter + { + public override IAttachmentRequest? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => throw new NotSupportedException("Deserialization of IAttachmentRequest is not supported."); + + public override void Write(Utf8JsonWriter writer, IAttachmentRequest value, JsonSerializerOptions options) + => JsonSerializer.Serialize(writer, value, value.GetType(), options); + } +} \ No newline at end of file diff --git a/tests/Max.BotClient.Tests/Unit/JsonConvertersTests.cs b/tests/Max.BotClient.Tests/Unit/JsonConvertersTests.cs new file mode 100644 index 0000000..aa2550a --- /dev/null +++ b/tests/Max.BotClient.Tests/Unit/JsonConvertersTests.cs @@ -0,0 +1,255 @@ +using System; +using System.Text.Json; +using FluentAssertions; +using Max.BotClient.DTOs; +using Xunit; + +namespace Max.BotClient.Tests.Unit; + +public class JsonConvertersTests +{ + private static readonly JsonSerializerOptions Options = BotClientJsonOptions.Default; + + // ─── UpdateConverter ───────────────────────────────────────────────────────── + + [Theory] + [InlineData("message_created", typeof(MessageCreatedUpdate))] + [InlineData("message_callback", typeof(MessageCallbackUpdate))] + [InlineData("message_edited", typeof(MessageEditedUpdate))] + [InlineData("message_removed", typeof(MessageRemovedUpdate))] + [InlineData("bot_added", typeof(BotAddedUpdate))] + [InlineData("bot_removed", typeof(BotRemovedUpdate))] + [InlineData("dialog_muted", typeof(DialogMutedUpdate))] + [InlineData("dialog_unmuted", typeof(DialogUnmutedUpdate))] + [InlineData("dialog_cleared", typeof(DialogClearedUpdate))] + [InlineData("dialog_removed", typeof(DialogRemovedUpdate))] + [InlineData("user_added", typeof(UserAddedUpdate))] + [InlineData("user_removed", typeof(UserRemovedUpdate))] + [InlineData("bot_started", typeof(BotStartedUpdate))] + [InlineData("bot_stopped", typeof(BotStoppedUpdate))] + [InlineData("chat_title_changed", typeof(ChatTitleChangedUpdate))] + public void UpdateConverter_DeserializesCorrectType(string updateType, Type expectedType) + { + var json = $$"""{"update_type":"{{updateType}}","timestamp":1000}"""; + + var result = JsonSerializer.Deserialize(json, Options); + + result.Should().BeOfType(expectedType); + result!.Timestamp.Should().Be(1000); + } + + [Fact] + public void UpdateConverter_MessageCreated_MapsFields() + { + var json = """ + { + "update_type": "message_created", + "timestamp": 9999, + "message": { + "recipient": { "chat_type": "chat", "chat_id": 42 }, + "timestamp": 9999, + "body": { "mid": "abc123", "seq": 5, "text": "Hello" } + } + } + """; + + var result = JsonSerializer.Deserialize(json, Options) as MessageCreatedUpdate; + + result.Should().NotBeNull(); + result!.Timestamp.Should().Be(9999); + result.Message.Body!.Text.Should().Be("Hello"); + result.Message.Body.Mid.Should().Be("abc123"); + } + + [Fact] + public void UpdateConverter_UnknownType_ReturnsNull() + { + var json = """{"update_type":"unknown_future_event","timestamp":1}"""; + + var result = JsonSerializer.Deserialize(json, Options); + + result.Should().BeNull(); + } + + [Fact] + public void UpdateConverter_Write_ThrowsNotSupported() + { + IUpdate update = new MessageCreatedUpdate { Timestamp = 1 }; + + var act = () => JsonSerializer.Serialize(update, Options); + + act.Should().Throw(); + } + + // ─── AttachmentConverter ───────────────────────────────────────────────────── + + [Theory] + [InlineData("image", typeof(PhotoAttachment))] + [InlineData("video", typeof(VideoAttachment))] + [InlineData("audio", typeof(AudioAttachment))] + [InlineData("file", typeof(FileAttachment))] + [InlineData("sticker", typeof(StickerAttachment))] + [InlineData("contact", typeof(ContactAttachment))] + [InlineData("share", typeof(ShareAttachment))] + [InlineData("location", typeof(LocationAttachment))] + [InlineData("inline_keyboard", typeof(InlineKeyboardAttachment))] + public void AttachmentConverter_DeserializesCorrectType(string type, Type expectedType) + { + var json = $$"""{"type":"{{type}}"}"""; + + var result = JsonSerializer.Deserialize(json, Options); + + result.Should().BeOfType(expectedType); + } + + [Fact] + public void AttachmentConverter_Photo_MapsPayload() + { + var json = """ + { + "type": "image", + "payload": { "photo_id": 7, "token": "tok", "url": "https://example.com/photo.jpg" } + } + """; + + var result = JsonSerializer.Deserialize(json, Options) as PhotoAttachment; + + result.Should().NotBeNull(); + result!.Payload.PhotoId.Should().Be(7); + result.Payload.Token.Should().Be("tok"); + } + + [Fact] + public void AttachmentConverter_UnknownType_ReturnsNull() + { + var result = JsonSerializer.Deserialize("""{"type":"hologram"}""", Options); + + result.Should().BeNull(); + } + + // ─── ButtonConverter ───────────────────────────────────────────────────────── + + [Theory] + [InlineData("callback", typeof(CallbackButton))] + [InlineData("link", typeof(LinkButton))] + [InlineData("request_geo_location", typeof(RequestGeoLocationButton))] + [InlineData("request_contact", typeof(RequestContactButton))] + [InlineData("open_app", typeof(OpenAppButton))] + [InlineData("message", typeof(MessageButton))] + public void ButtonConverter_DeserializesCorrectType(string type, Type expectedType) + { + var json = $$"""{"type":"{{type}}","text":"Click me"}"""; + + var result = JsonSerializer.Deserialize(json, Options); + + result.Should().BeOfType(expectedType); + result!.Text.Should().Be("Click me"); + } + + [Fact] + public void ButtonConverter_Callback_MapsPayload() + { + var json = """{"type":"callback","text":"OK","payload":"action_ok"}"""; + + var result = JsonSerializer.Deserialize(json, Options) as CallbackButton; + + result.Should().NotBeNull(); + result!.Payload.Should().Be("action_ok"); + } + + [Fact] + public void ButtonConverter_Write_IncludesTypeAndFields() + { + IButton button = new CallbackButton { Text = "Yes", Payload = "yes" }; + + var json = JsonSerializer.Serialize(button, Options); + + json.Should().Contain("\"type\":\"callback\""); + json.Should().Contain("\"text\":\"Yes\""); + json.Should().Contain("\"payload\":\"yes\""); + } + + [Fact] + public void ButtonConverter_Write_LinkButton() + { + IButton button = new LinkButton { Text = "Go", Url = "https://example.com" }; + + var json = JsonSerializer.Serialize(button, Options); + + json.Should().Contain("\"type\":\"link\""); + json.Should().Contain("\"url\":\"https://example.com\""); + } + + // ─── MarkupElementConverter ─────────────────────────────────────────────────── + + [Theory] + [InlineData("strong", typeof(StrongMarkupElement))] + [InlineData("emphasized", typeof(EmphasizedMarkupElement))] + [InlineData("monospaced", typeof(MonospacedMarkupElement))] + [InlineData("link", typeof(LinkMarkupElement))] + [InlineData("strikethrough", typeof(StrikethroughMarkupElement))] + [InlineData("underline", typeof(UnderlineMarkupElement))] + [InlineData("user_mention", typeof(UserMentionMarkupElement))] + public void MarkupElementConverter_DeserializesCorrectType(string type, Type expectedType) + { + var json = $$"""{"type":"{{type}}","from":2,"length":5}"""; + + var result = JsonSerializer.Deserialize(json, Options); + + result.Should().BeOfType(expectedType); + result!.From.Should().Be(2); + result.Length.Should().Be(5); + } + + [Fact] + public void MarkupElementConverter_Link_MapsUrl() + { + var json = """{"type":"link","from":0,"length":4,"url":"https://example.com"}"""; + + var result = JsonSerializer.Deserialize(json, Options) as LinkMarkupElement; + + result.Should().NotBeNull(); + result!.Url.Should().Be("https://example.com"); + } + + // ─── AttachmentRequestConverter ─────────────────────────────────────────────── + + [Fact] + public void AttachmentRequestConverter_Write_Photo() + { + IAttachmentRequest request = new PhotoAttachmentRequest + { + Payload = new PhotoAttachmentRequestPayload { Url = "https://example.com/img.jpg" } + }; + + var json = JsonSerializer.Serialize(request, Options); + + json.Should().Contain("\"type\":\"image\""); + json.Should().Contain("\"url\":\"https://example.com/img.jpg\""); + } + + [Fact] + public void AttachmentRequestConverter_Write_InlineKeyboard() + { + IAttachmentRequest request = new InlineKeyboardAttachmentRequest + { + Payload = new InlineKeyboardAttachmentRequestPayload + { + Buttons = [[new CallbackButton { Text = "Hi", Payload = "hi" }]] + } + }; + + var json = JsonSerializer.Serialize(request, Options); + + json.Should().Contain("\"type\":\"inline_keyboard\""); + json.Should().Contain("\"text\":\"Hi\""); + } + + [Fact] + public void AttachmentRequestConverter_Read_ThrowsNotSupported() + { + var act = () => JsonSerializer.Deserialize("""{"type":"image"}""", Options); + + act.Should().Throw(); + } +} \ No newline at end of file From 14754175916b2970c1a997d412fc79588500345a Mon Sep 17 00:00:00 2001 From: Aleksandr Semenenko <112961081+Deskri@users.noreply.github.com> Date: Tue, 17 Mar 2026 15:04:20 +0400 Subject: [PATCH 07/18] fix: chat type (cherry picked from commit e3b9bba727ff2616257766f5dc1bce764b562c00) --- src/Max.BotClient/DTOs/Chat.cs | 4 +++- src/Max.BotClient/Types/Message.cs | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Max.BotClient/DTOs/Chat.cs b/src/Max.BotClient/DTOs/Chat.cs index c7d8f9b..4b20a64 100644 --- a/src/Max.BotClient/DTOs/Chat.cs +++ b/src/Max.BotClient/DTOs/Chat.cs @@ -5,7 +5,9 @@ namespace Max.BotClient.DTOs ///
internal enum ChatType { - Chat + Chat, + Dialog, + Channel } /// diff --git a/src/Max.BotClient/Types/Message.cs b/src/Max.BotClient/Types/Message.cs index 4eb5cfe..090c394 100644 --- a/src/Max.BotClient/Types/Message.cs +++ b/src/Max.BotClient/Types/Message.cs @@ -30,7 +30,9 @@ public enum MessageLinkType /// public enum ChatType { - Chat + Chat, + Dialog, + Channel } /// From d7dc5e7d90d8c6fae665bf1ece3fe4b981c92f86 Mon Sep 17 00:00:00 2001 From: Aleksandr Semenenko <112961081+Deskri@users.noreply.github.com> Date: Thu, 19 Mar 2026 16:39:07 +0400 Subject: [PATCH 08/18] fix: chat type of bot in api methods (cherry picked from commit 97b457ab009ba4dc68154f4135fe0820a7a72a36) --- src/Max.BotClient/Max.BotClient.ApiExtensions.cs | 12 ++++++------ .../Max.BotClient.ApiMethods.Chats.cs | 16 ++++++++-------- .../Max.BotClient.ApiMethods.Members.cs | 16 ++++++++-------- .../Max.BotClient.ApiMethods.Messages.cs | 14 +++++++------- .../Max.BotClient.ApiMethods.Misc.cs | 6 +++--- .../Max.BotClient.ApiMethods.Subscriptions.cs | 8 ++++---- src/Max.BotClient/Max.BotClient.Polling.cs | 12 ++++++------ 7 files changed, 42 insertions(+), 42 deletions(-) diff --git a/src/Max.BotClient/Max.BotClient.ApiExtensions.cs b/src/Max.BotClient/Max.BotClient.ApiExtensions.cs index aab0231..f2f68af 100644 --- a/src/Max.BotClient/Max.BotClient.ApiExtensions.cs +++ b/src/Max.BotClient/Max.BotClient.ApiExtensions.cs @@ -9,7 +9,7 @@ namespace Max.BotClient internal static class BotClientApiMethodsExtensions { public static async Task ProcessApi( - this BotClient botClient, + this IBotClient botClient, HttpMethod method, string path, object? body = null, @@ -21,7 +21,7 @@ public static async Task ProcessApi( } public static async Task ProcessApi( - this BotClient botClient, + this IBotClient botClient, HttpMethod method, string path, object? body = null, @@ -29,7 +29,7 @@ public static async Task ProcessApi( ) => await botClient.SendRequest(method, path, body, cancellationToken); public static async Task ProcessApi( - this BotClient botClient, + this IBotClient botClient, HttpMethod method, string path, object? body = null, @@ -37,7 +37,7 @@ public static async Task ProcessApi( ) => await botClient.SendRequest(method, path, body, cancellationToken); public static async Task ProcessApi( - this BotClient botClient, + this IBotClient botClient, HttpMethod method, string basePath, Func createParams, @@ -50,7 +50,7 @@ public static async Task ProcessApi( } public static async Task ProcessApi( - this BotClient botClient, + this IBotClient botClient, HttpMethod method, string basePath, Func createParams, @@ -62,7 +62,7 @@ public static async Task ProcessApi( } public static async Task ProcessApi( - this BotClient botClient, + this IBotClient botClient, HttpMethod method, string basePath, Func createParams, diff --git a/src/Max.BotClient/Max.BotClient.ApiMethods.Chats.cs b/src/Max.BotClient/Max.BotClient.ApiMethods.Chats.cs index 9c51071..c2c1082 100644 --- a/src/Max.BotClient/Max.BotClient.ApiMethods.Chats.cs +++ b/src/Max.BotClient/Max.BotClient.ApiMethods.Chats.cs @@ -18,7 +18,7 @@ public static partial class BotClientApiMethods /// Токен отмены. /// Список чатов и маркер для следующей страницы. public static async Task GetChats( - this BotClient botClient, + this IBotClient botClient, int? count = null, long? marker = null, CancellationToken cancellationToken = default @@ -38,7 +38,7 @@ public static partial class BotClientApiMethods /// Токен отмены. /// Информация о чате, включая закреплённое сообщение (если есть). public static async Task GetChat( - this BotClient botClient, + this IBotClient botClient, long chatId, CancellationToken cancellationToken = default ) => await botClient.ProcessApi( @@ -57,7 +57,7 @@ public static partial class BotClientApiMethods /// Токен отмены. /// Обновлённая информация о чате. public static async Task UpdateChat( - this BotClient botClient, + this IBotClient botClient, long chatId, Types.UpdateChatRequest request, CancellationToken cancellationToken = default @@ -77,7 +77,7 @@ public static partial class BotClientApiMethods /// Токен отмены. /// true, если запрос был успешным. public static async Task DeleteChat( - this BotClient botClient, + this IBotClient botClient, long chatId, CancellationToken cancellationToken = default ) => (await botClient.ProcessApi( @@ -96,7 +96,7 @@ public static async Task DeleteChat( /// Токен отмены. /// true, если запрос был успешным. public static async Task SendAction( - this BotClient botClient, + this IBotClient botClient, long chatId, Types.SenderAction action, CancellationToken cancellationToken = default @@ -116,7 +116,7 @@ public static async Task SendAction( /// Токен отмены. /// Закреплённое сообщение или null, если в чате нет закреплённого сообщения. public static async Task GetPinnedMessage( - this BotClient botClient, + this IBotClient botClient, long chatId, CancellationToken cancellationToken = default ) => (await botClient.ProcessApi( @@ -136,7 +136,7 @@ public static async Task SendAction( /// Токен отмены. /// true, если запрос был успешным. public static async Task PinMessage( - this BotClient botClient, + this IBotClient botClient, long chatId, string messageId, bool? notify = null, @@ -157,7 +157,7 @@ public static async Task PinMessage( /// Токен отмены. /// true, если запрос был успешным. public static async Task UnpinMessage( - this BotClient botClient, + this IBotClient botClient, long chatId, CancellationToken cancellationToken = default ) => (await botClient.ProcessApi( diff --git a/src/Max.BotClient/Max.BotClient.ApiMethods.Members.cs b/src/Max.BotClient/Max.BotClient.ApiMethods.Members.cs index ed9ddc5..407147a 100644 --- a/src/Max.BotClient/Max.BotClient.ApiMethods.Members.cs +++ b/src/Max.BotClient/Max.BotClient.ApiMethods.Members.cs @@ -16,7 +16,7 @@ public static partial class BotClientApiMethods /// Токен отмены. /// Информация о членстве бота в чате. public static async Task GetMyMembership( - this BotClient botClient, + this IBotClient botClient, long chatId, CancellationToken cancellationToken = default ) => await botClient.ProcessApi( @@ -34,7 +34,7 @@ public static partial class BotClientApiMethods /// Токен отмены. /// true, если запрос был успешным. public static async Task LeaveChat( - this BotClient botClient, + this IBotClient botClient, long chatId, CancellationToken cancellationToken = default ) => (await botClient.ProcessApi( @@ -55,7 +55,7 @@ public static async Task LeaveChat( /// Токен отмены. /// Список участников чата и маркер для следующей страницы. public static async Task GetChatMembers( - this BotClient botClient, + this IBotClient botClient, long chatId, long[]? userIds = null, int? count = null, @@ -79,7 +79,7 @@ public static async Task LeaveChat( /// true, если запрос был успешным. /// Для добавления участников могут потребоваться дополнительные права. public static async Task AddChatMembers( - this BotClient botClient, + this IBotClient botClient, long chatId, long[] userIds, CancellationToken cancellationToken = default @@ -102,7 +102,7 @@ public static async Task AddChatMembers( /// true, если запрос был успешным. /// Для удаления участников могут потребоваться дополнительные права. public static async Task RemoveChatMember( - this BotClient botClient, + this IBotClient botClient, long chatId, long userId, bool? block = null, @@ -124,7 +124,7 @@ public static async Task RemoveChatMember( /// Список администраторов чата с информацией о членстве. /// Бот должен быть администратором в запрашиваемом чате. public static async Task GetChatAdmins( - this BotClient botClient, + this IBotClient botClient, long chatId, CancellationToken cancellationToken = default ) => await botClient.ProcessApi( @@ -143,7 +143,7 @@ public static async Task RemoveChatMember( /// Токен отмены. /// true, если все администраторы успешно добавлены. public static async Task AddChatAdmins( - this BotClient botClient, + this IBotClient botClient, long chatId, Types.ChatAdmin[] admins, CancellationToken cancellationToken = default @@ -164,7 +164,7 @@ public static async Task AddChatAdmins( /// Токен отмены. /// true, если запрос был успешным. public static async Task RemoveChatAdmin( - this BotClient botClient, + this IBotClient botClient, long chatId, long userId, CancellationToken cancellationToken = default diff --git a/src/Max.BotClient/Max.BotClient.ApiMethods.Messages.cs b/src/Max.BotClient/Max.BotClient.ApiMethods.Messages.cs index f31263b..d1372f7 100644 --- a/src/Max.BotClient/Max.BotClient.ApiMethods.Messages.cs +++ b/src/Max.BotClient/Max.BotClient.ApiMethods.Messages.cs @@ -20,7 +20,7 @@ public static partial class BotClientApiMethods /// Токен отмены. /// Массив сообщений (последние сообщения первыми). public static async Task GetMessages( - this BotClient botClient, + this IBotClient botClient, long chatId, long? from = null, long? to = null, @@ -42,7 +42,7 @@ public static partial class BotClientApiMethods /// Токен отмены. /// Массив сообщений. public static async Task GetMessages( - this BotClient botClient, + this IBotClient botClient, string[] messageIds, CancellationToken cancellationToken = default ) => (await botClient.ProcessApi( @@ -61,7 +61,7 @@ public static partial class BotClientApiMethods /// Токен отмены. /// Сообщение с указанным ID. public static async Task GetMessage( - this BotClient botClient, + this IBotClient botClient, string messageId, CancellationToken cancellationToken = default ) => await botClient.ProcessApi( @@ -81,7 +81,7 @@ public static partial class BotClientApiMethods /// Токен отмены. /// Отправленное сообщение. public static async Task SendMessage( - this BotClient botClient, + this IBotClient botClient, long id, Types.Message message, bool? disableLinkPreview = null, @@ -110,7 +110,7 @@ public static partial class BotClientApiMethods /// true, если запрос был успешным. /// С помощью метода можно отредактировать сообщения, которые отправлены менее 24 часов назад. public static async Task EditMessage( - this BotClient botClient, + this IBotClient botClient, string messageId, Types.Message message, CancellationToken cancellationToken = default @@ -131,7 +131,7 @@ public static async Task EditMessage( /// true, если запрос был успешным. /// С помощью метода можно удалять сообщения, которые отправлены менее 24 часов назад. Бот должен иметь разрешение на удаление сообщений. public static async Task DeleteMessage( - this BotClient botClient, + this IBotClient botClient, string messageId, CancellationToken cancellationToken = default ) => (await botClient.ProcessApi( @@ -153,7 +153,7 @@ public static async Task DeleteMessage( /// true, если запрос был успешным. /// Хотя бы один из параметров message или notification должен быть указан. public static async Task AnswerCallback( - this BotClient botClient, + this IBotClient botClient, string callbackId, Types.Message? message = null, string? notification = null, diff --git a/src/Max.BotClient/Max.BotClient.ApiMethods.Misc.cs b/src/Max.BotClient/Max.BotClient.ApiMethods.Misc.cs index f763b06..99b58b1 100644 --- a/src/Max.BotClient/Max.BotClient.ApiMethods.Misc.cs +++ b/src/Max.BotClient/Max.BotClient.ApiMethods.Misc.cs @@ -14,7 +14,7 @@ public static partial class BotClientApiMethods /// Клиент бота. /// Токен отмены. public static async Task GetMe( - this BotClient botClient, + this IBotClient botClient, CancellationToken cancellationToken = default ) => await botClient.ProcessApi( HttpMethod.Get, @@ -31,7 +31,7 @@ public static partial class BotClientApiMethods /// Токен отмены. /// URL для загрузки и токен (для video/audio). public static async Task GetUploadUrl( - this BotClient botClient, + this IBotClient botClient, Types.UploadType type, CancellationToken cancellationToken = default ) => await botClient.ProcessApi( @@ -50,7 +50,7 @@ public static partial class BotClientApiMethods /// Токен отмены. /// Подробная информация о видео, включая URL-адреса воспроизведения и метаданные. public static async Task GetVideo( - this BotClient botClient, + this IBotClient botClient, string videoToken, CancellationToken cancellationToken = default ) => await botClient.ProcessApi( diff --git a/src/Max.BotClient/Max.BotClient.ApiMethods.Subscriptions.cs b/src/Max.BotClient/Max.BotClient.ApiMethods.Subscriptions.cs index 27d2571..31ec905 100644 --- a/src/Max.BotClient/Max.BotClient.ApiMethods.Subscriptions.cs +++ b/src/Max.BotClient/Max.BotClient.ApiMethods.Subscriptions.cs @@ -14,7 +14,7 @@ public static partial class BotClientApiMethods /// Клиент бота. /// Токен отмены. public static async Task GetSubscriptions( - this BotClient botClient, + this IBotClient botClient, CancellationToken cancellationToken = default ) => (await botClient.ProcessApi( HttpMethod.Get, @@ -32,7 +32,7 @@ public static async Task GetSubscriptions( /// Секрет для заголовка X-Max-Bot-Api-Secret (5-256 символов, A-Z, a-z, 0-9, -, _). /// Токен отмены. public static async Task Subscribe( - this BotClient botClient, + this IBotClient botClient, string url, Types.UpdateType[]? updateTypes = null, string? secret = null, @@ -52,7 +52,7 @@ public static async Task Subscribe( /// URL вебхука для удаления из подписок. /// Токен отмены. public static async Task Unsubscribe( - this BotClient botClient, + this IBotClient botClient, string url, CancellationToken cancellationToken = default ) => await botClient.ProcessApi( @@ -73,7 +73,7 @@ public static async Task Unsubscribe( /// Типы обновлений для получения. /// Токен отмены. public static async Task<(Update[], long?)> GetUpdates( - this BotClient botClient, + this IBotClient botClient, int? limit = null, int? timeout = null, long? marker = null, diff --git a/src/Max.BotClient/Max.BotClient.Polling.cs b/src/Max.BotClient/Max.BotClient.Polling.cs index 16d5b64..3552cfb 100644 --- a/src/Max.BotClient/Max.BotClient.Polling.cs +++ b/src/Max.BotClient/Max.BotClient.Polling.cs @@ -43,9 +43,9 @@ public static partial class BotClientApiMethods /// Опции polling. /// Токен отмены. public static void StartReceiving( - this BotClient botClient, - Func updateHandler, - Func? errorHandler = null, + this IBotClient botClient, + Func updateHandler, + Func? errorHandler = null, ReceiverOptions? options = null, CancellationToken cancellationToken = default ) => Task.Run(() => @@ -68,9 +68,9 @@ public static void StartReceiving( /// Опции polling. /// Токен отмены. public static async Task ReceiveAsync( - this BotClient botClient, - Func updateHandler, - Func? errorHandler = null, + this IBotClient botClient, + Func updateHandler, + Func? errorHandler = null, ReceiverOptions? options = null, CancellationToken cancellationToken = default ) From 054fda5759ba47a4d493c8d1f77b9a772864661f Mon Sep 17 00:00:00 2001 From: Aleksandr Semenenko <112961081+Deskri@users.noreply.github.com> Date: Sat, 21 Mar 2026 19:33:19 +0400 Subject: [PATCH 09/18] feat: update .csproj --- src/Max.BotClient/Max.BotClient.csproj | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/Max.BotClient/Max.BotClient.csproj b/src/Max.BotClient/Max.BotClient.csproj index 8375f0a..b505e1b 100644 --- a/src/Max.BotClient/Max.BotClient.csproj +++ b/src/Max.BotClient/Max.BotClient.csproj @@ -5,8 +5,20 @@ latest enable 1.0.0 + + Max.BotClient + Deskri + SDK for MAX Bot API (.NET Standard 2.0) + MIT + https://github.com/Deskri/Max.BotClient + max;bot;api;sdk + README.md + + + + From 130207dcea99ef8fe601390c36525ec7da5f01e8 Mon Sep 17 00:00:00 2001 From: Aleksandr Semenenko <112961081+Deskri@users.noreply.github.com> Date: Wed, 15 Apr 2026 12:12:12 +0400 Subject: [PATCH 10/18] feat: start 1.0.1 version --- src/Max.BotClient/Max.BotClient.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Max.BotClient/Max.BotClient.csproj b/src/Max.BotClient/Max.BotClient.csproj index b505e1b..41493cc 100644 --- a/src/Max.BotClient/Max.BotClient.csproj +++ b/src/Max.BotClient/Max.BotClient.csproj @@ -4,7 +4,7 @@ netstandard2.0 latest enable - 1.0.0 + 1.0.1 Max.BotClient Deskri From dbbdd258963d5e00479d6ca5775af2cee9ffa2ce Mon Sep 17 00:00:00 2001 From: Aleksandr Semenenko <112961081+Deskri@users.noreply.github.com> Date: Wed, 15 Apr 2026 12:13:05 +0400 Subject: [PATCH 11/18] feat: add package project url --- src/Max.BotClient/Max.BotClient.csproj | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Max.BotClient/Max.BotClient.csproj b/src/Max.BotClient/Max.BotClient.csproj index 41493cc..0da9d7e 100644 --- a/src/Max.BotClient/Max.BotClient.csproj +++ b/src/Max.BotClient/Max.BotClient.csproj @@ -10,6 +10,7 @@ Deskri SDK for MAX Bot API (.NET Standard 2.0) MIT + https://github.com/Deskri/Max.BotClient https://github.com/Deskri/Max.BotClient max;bot;api;sdk README.md From a1768561f98128d2539f6c89f0ed4925d63414bc Mon Sep 17 00:00:00 2001 From: Aleksandr Semenenko <112961081+Deskri@users.noreply.github.com> Date: Wed, 15 Apr 2026 14:06:29 +0400 Subject: [PATCH 12/18] feat: add DI support for .NET 10 Register IBotClient via AddMaxBotClient() extension on IServiceCollection. Uses IHttpClientFactory and conditional compilation (#if NET10_0_OR_GREATER). DI packages referenced only for net10.0 target. Co-Authored-By: Claude Sonnet 4.6 --- .../Max.BotClient.DependencyInjection.cs | 44 +++++++++++++++++++ src/Max.BotClient/Max.BotClient.csproj | 7 ++- 2 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 src/Max.BotClient/Max.BotClient.DependencyInjection.cs diff --git a/src/Max.BotClient/Max.BotClient.DependencyInjection.cs b/src/Max.BotClient/Max.BotClient.DependencyInjection.cs new file mode 100644 index 0000000..e0350d9 --- /dev/null +++ b/src/Max.BotClient/Max.BotClient.DependencyInjection.cs @@ -0,0 +1,44 @@ +#if NET10_0_OR_GREATER +using System; +using System.Net.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Max.BotClient +{ + public static class BotClientServiceCollectionExtensions + { + /// + /// Регистрирует и в DI-контейнере. + /// + /// Коллекция сервисов. + /// Токен бота. + /// Дополнительная настройка параметров (RetryCount, RetryDelaySeconds и др.). + public static IServiceCollection AddMaxBotClient( + this IServiceCollection services, + string token, + Action? configure = null) + { + ArgumentNullException.ThrowIfNull(services); + + var options = new BotClientOptions(token); + configure?.Invoke(options); + + services.TryAddSingleton(options); + + services.AddHttpClient("MaxBotClient"); + + services.TryAddSingleton(sp => + { + var factory = sp.GetRequiredService(); + var httpClient = factory.CreateClient("MaxBotClient"); + return new BotClient(options, httpClient); + }); + + services.TryAddSingleton(sp => (BotClient)sp.GetRequiredService()); + + return services; + } + } +} +#endif \ No newline at end of file diff --git a/src/Max.BotClient/Max.BotClient.csproj b/src/Max.BotClient/Max.BotClient.csproj index 0da9d7e..618aae3 100644 --- a/src/Max.BotClient/Max.BotClient.csproj +++ b/src/Max.BotClient/Max.BotClient.csproj @@ -1,7 +1,7 @@  - netstandard2.0 + netstandard2.0;net10.0 latest enable 1.0.1 @@ -24,4 +24,9 @@ + + + + + From 5f47efa911194145c1c6bf935a225286d5e9b2b1 Mon Sep 17 00:00:00 2001 From: Aleksandr Semenenko <112961081+Deskri@users.noreply.github.com> Date: Wed, 15 Apr 2026 14:08:22 +0400 Subject: [PATCH 13/18] ci: add GitHub Actions workflow for NuGet publish Publishes package to NuGet on every push to main. Requires NUGET_API_KEY secret in repository settings. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/publish.yml | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 .github/workflows/publish.yml diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..0ae1bd1 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,29 @@ +name: Publish to NuGet + +on: + push: + branches: + - main + +jobs: + publish: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 10.0.x + + - name: Build + run: dotnet build src/Max.BotClient/Max.BotClient.csproj -c Release + + - name: Pack + run: dotnet pack src/Max.BotClient/Max.BotClient.csproj -c Release --no-build -o ./artifacts + + - name: Push to NuGet + run: dotnet nuget push ./artifacts/*.nupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate \ No newline at end of file From 8558a3ae2547f7b72a42449e29b7162136793e5b Mon Sep 17 00:00:00 2001 From: Aleksandr Semenenko <112961081+Deskri@users.noreply.github.com> Date: Wed, 15 Apr 2026 14:09:25 +0400 Subject: [PATCH 14/18] ci: add test step before publish Run xUnit tests on net8.0 before building and packing. Publish is blocked if tests fail. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/publish.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 0ae1bd1..d82cbb9 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -17,8 +17,12 @@ jobs: uses: actions/setup-dotnet@v4 with: dotnet-version: | + 8.0.x 10.0.x + - name: Test + run: dotnet test tests/Max.BotClient.Tests/Max.BotClient.Tests.csproj -c Release + - name: Build run: dotnet build src/Max.BotClient/Max.BotClient.csproj -c Release From f68193af210b80095be789035a2429c583626e7a Mon Sep 17 00:00:00 2001 From: Aleksandr Semenenko <112961081+Deskri@users.noreply.github.com> Date: Wed, 15 Apr 2026 14:10:40 +0400 Subject: [PATCH 15/18] chore: upgrade test project to net10.0 Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/publish.yml | 1 - tests/Max.BotClient.Tests/Max.BotClient.Tests.csproj | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d82cbb9..b83ea07 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -17,7 +17,6 @@ jobs: uses: actions/setup-dotnet@v4 with: dotnet-version: | - 8.0.x 10.0.x - name: Test diff --git a/tests/Max.BotClient.Tests/Max.BotClient.Tests.csproj b/tests/Max.BotClient.Tests/Max.BotClient.Tests.csproj index 3d1659c..a15db4d 100644 --- a/tests/Max.BotClient.Tests/Max.BotClient.Tests.csproj +++ b/tests/Max.BotClient.Tests/Max.BotClient.Tests.csproj @@ -1,7 +1,7 @@ - net8.0 + net10.0 false enable latest From 624875c4d30b7a920209351797ad1822693521d9 Mon Sep 17 00:00:00 2001 From: Aleksandr Semenenko <112961081+Deskri@users.noreply.github.com> Date: Wed, 15 Apr 2026 14:16:13 +0400 Subject: [PATCH 16/18] ci: split build/test and publish into separate jobs - build-and-test runs on push to main and dev - publish runs only on main, requires build-and-test to pass - publish uses 'main' environment for NUGET_API_KEY secret Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/publish.yml | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b83ea07..cf5375e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,12 +1,13 @@ -name: Publish to NuGet +name: CI on: push: branches: - main + - dev jobs: - publish: + build-and-test: runs-on: ubuntu-latest steps: @@ -19,14 +20,30 @@ jobs: dotnet-version: | 10.0.x - - name: Test - run: dotnet test tests/Max.BotClient.Tests/Max.BotClient.Tests.csproj -c Release - - name: Build run: dotnet build src/Max.BotClient/Max.BotClient.csproj -c Release + - name: Test + run: dotnet test tests/Max.BotClient.Tests/Max.BotClient.Tests.csproj -c Release --no-build + + publish: + runs-on: ubuntu-latest + needs: build-and-test + if: github.ref == 'refs/heads/main' + environment: main + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 10.0.x + - name: Pack - run: dotnet pack src/Max.BotClient/Max.BotClient.csproj -c Release --no-build -o ./artifacts + run: dotnet pack src/Max.BotClient/Max.BotClient.csproj -c Release -o ./artifacts - name: Push to NuGet run: dotnet nuget push ./artifacts/*.nupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate \ No newline at end of file From d316c512d68beee7167301d94835ff5295d53f01 Mon Sep 17 00:00:00 2001 From: Aleksandr Semenenko <112961081+Deskri@users.noreply.github.com> Date: Thu, 16 Apr 2026 16:28:49 +0400 Subject: [PATCH 17/18] feat: add dedicated polling HttpClient with infinite timeout for long polling Separate HttpClient with Timeout.InfiniteTimeSpan prevents TaskCanceledException during long polling when server holds connection up to 90 seconds. - Add internal IBotClientInternal interface with PollingSendRequest (explicit impl) - Add PollingProcessApi/GetUpdatesFromPolling parallel to existing ProcessApi/GetUpdates - Lazy-create polling HttpClient on first use - Implement IDisposable with _ownsHttpClient tracking for safe cleanup Co-Authored-By: Claude Opus 4.6 --- .../Max.BotClient.ApiExtensions.cs | 13 +++++ .../Max.BotClient.ApiMethods.Subscriptions.cs | 22 +++++++- src/Max.BotClient/Max.BotClient.Polling.cs | 43 ++++++++++++--- src/Max.BotClient/Max.BotClient.cs | 55 ++++++++++++++++++- 4 files changed, 122 insertions(+), 11 deletions(-) diff --git a/src/Max.BotClient/Max.BotClient.ApiExtensions.cs b/src/Max.BotClient/Max.BotClient.ApiExtensions.cs index f2f68af..1b929c6 100644 --- a/src/Max.BotClient/Max.BotClient.ApiExtensions.cs +++ b/src/Max.BotClient/Max.BotClient.ApiExtensions.cs @@ -72,5 +72,18 @@ public static async Task ProcessApi( ApiRequestBinder.Bind(createParams(), basePath, out var path, out var body); await botClient.SendRequest(method, path, body, cancellationToken); } + + internal static async Task PollingProcessApi( + this IBotClientInternal botClient, + HttpMethod method, + string basePath, + Func createParams, + CancellationToken cancellationToken = default + ) where TParams : class + { + ApiRequestBinder.Bind(createParams(), basePath, out var path, out var body); + var dto = await botClient.PollingSendRequest(method, path, body, cancellationToken); + return dto.ToResult(); + } } } diff --git a/src/Max.BotClient/Max.BotClient.ApiMethods.Subscriptions.cs b/src/Max.BotClient/Max.BotClient.ApiMethods.Subscriptions.cs index 31ec905..1498e7b 100644 --- a/src/Max.BotClient/Max.BotClient.ApiMethods.Subscriptions.cs +++ b/src/Max.BotClient/Max.BotClient.ApiMethods.Subscriptions.cs @@ -90,5 +90,25 @@ public static async Task Unsubscribe( return (response.Updates, response.Marker); } + + private static async Task<(Update[], long?)> GetUpdatesFromPolling( + this IBotClientInternal botClient, + int? limit = null, + int? timeout = null, + long? marker = null, + Types.UpdateType[]? types = null, + CancellationToken cancellationToken = default + ) + { + var response = await botClient.PollingProcessApi( + HttpMethod.Get, + "/updates", + () => new GetUpdatesParams + { Limit = limit, Timeout = timeout, Marker = marker, UpdateTypes = types }, + cancellationToken + ); + + return (response.Updates, response.Marker); + } } -} +} \ No newline at end of file diff --git a/src/Max.BotClient/Max.BotClient.Polling.cs b/src/Max.BotClient/Max.BotClient.Polling.cs index 3552cfb..be4f43a 100644 --- a/src/Max.BotClient/Max.BotClient.Polling.cs +++ b/src/Max.BotClient/Max.BotClient.Polling.cs @@ -48,13 +48,13 @@ public static void StartReceiving( Func? errorHandler = null, ReceiverOptions? options = null, CancellationToken cancellationToken = default - ) => Task.Run(() => + ) => Task.Run(() => botClient.ReceiveAsync( - updateHandler, - errorHandler, - options, + updateHandler, + errorHandler, + options, cancellationToken - ), + ), cancellationToken ); @@ -83,7 +83,7 @@ public static async Task ReceiveAsync( { try { - var (_, newMarker) = await botClient.GetUpdates( + var (_, newMarker) = await botClient.Update( limit: 1, timeout: 0, marker: null, @@ -109,7 +109,7 @@ public static async Task ReceiveAsync( try { - var result = await botClient.GetUpdates( + var result = await botClient.Update( limit: options.Limit, timeout: options.Timeout, marker: marker, @@ -150,5 +150,32 @@ public static async Task ReceiveAsync( } } } + + private static Task<(Update[], long?)> Update( + this IBotClient botClient, + int? limit = null, + int? timeout = null, + long? marker = null, + Types.UpdateType[]? types = null, + CancellationToken cancellationToken = default + ) + { + if (botClient is IBotClientInternal internalBotClient) + return internalBotClient.GetUpdatesFromPolling( + limit, + timeout, + marker, + types, + cancellationToken + ); + + return botClient.GetUpdates( + limit, + timeout, + marker, + types, + cancellationToken + ); + } } -} +} \ No newline at end of file diff --git a/src/Max.BotClient/Max.BotClient.cs b/src/Max.BotClient/Max.BotClient.cs index 2c48f07..14c9175 100644 --- a/src/Max.BotClient/Max.BotClient.cs +++ b/src/Max.BotClient/Max.BotClient.cs @@ -19,10 +19,23 @@ Task SendRequest( ); } - public partial class BotClient : IBotClient + internal interface IBotClientInternal : IBotClient + { + Task PollingSendRequest( + HttpMethod method, + string path, + object? body = null, + CancellationToken cancellationToken = default + ); + } + + public partial class BotClient : IBotClientInternal, IDisposable { private readonly BotClientOptions _options; private readonly HttpClient _httpClient; + private readonly bool _ownsHttpClient; + private HttpClient? _pollingHttpClient; + private bool _disposed; public string Token => _options.Token; public CancellationToken GlobalCancelToken { get; } @@ -36,6 +49,7 @@ public BotClient( _options = options ?? throw new ArgumentNullException(nameof(options)); GlobalCancelToken = cancellationToken; + _ownsHttpClient = httpClient == null; _httpClient = httpClient ?? new HttpClient(); _httpClient.BaseAddress = new Uri(_options.ApiUrl); _httpClient.DefaultRequestHeaders.TryAddWithoutValidation("Authorization", _options.Token); @@ -54,6 +68,34 @@ public async Task SendRequest( string path, object? body = null, CancellationToken cancellationToken = default + ) => await SendRequestCore(_httpClient, method, path, body, cancellationToken); + + async Task IBotClientInternal.PollingSendRequest( + HttpMethod method, + string path, + object? body, + CancellationToken cancellationToken + ) + { + if (_pollingHttpClient == null) + { + _pollingHttpClient = new HttpClient + { + BaseAddress = new Uri(_options.ApiUrl), + Timeout = Timeout.InfiniteTimeSpan + }; + _pollingHttpClient.DefaultRequestHeaders.TryAddWithoutValidation("Authorization", _options.Token); + } + + return await SendRequestCore(_pollingHttpClient, method, path, body, cancellationToken); + } + + private async Task SendRequestCore( + HttpClient httpClient, + HttpMethod method, + string path, + object? body, + CancellationToken cancellationToken ) { using var cts = CancellationTokenSource.CreateLinkedTokenSource(GlobalCancelToken, cancellationToken); @@ -78,7 +120,7 @@ public async Task SendRequest( request.Content = new StringContent(json, Encoding.UTF8, "application/json"); } - using var response = await _httpClient.SendAsync(request, token); + using var response = await httpClient.SendAsync(request, token); var responseBody = await response.Content.ReadAsStringAsync(); if (response.IsSuccessStatusCode) @@ -98,5 +140,14 @@ public async Task SendRequest( throw lastException!; } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + + _pollingHttpClient?.Dispose(); + if (_ownsHttpClient) _httpClient.Dispose(); + } } } From 469255d02194846318e7ec9e8d31481ab53ca750 Mon Sep 17 00:00:00 2001 From: Sergey Novik Date: Thu, 16 Apr 2026 16:30:41 +0400 Subject: [PATCH 18/18] =?UTF-8?q?=D0=9F=D1=80=D0=B0=D0=B2=D0=BA=D0=B8=20?= =?UTF-8?q?=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D1=8B=20=D1=84=D1=83=D0=BD=D0=BA?= =?UTF-8?q?=D1=86=D0=B8=D0=B8=20polling=20(#3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: различаем отмену пользователя и HTTP-таймаут в polling loop * fix: защита errorHandler от выброса исключений в polling loop * fix: добавлен backoff (5 сек) между ошибками в polling loop * fix: установлен бесконечный таймаут HttpClient для long polling * fix: StartReceiving возвращает Task вместо void * fix: не переопределяем глобальный Timeout --- src/Max.BotClient/Max.BotClient.Polling.cs | 51 +++++++++++++++++----- 1 file changed, 41 insertions(+), 10 deletions(-) diff --git a/src/Max.BotClient/Max.BotClient.Polling.cs b/src/Max.BotClient/Max.BotClient.Polling.cs index be4f43a..bea90c3 100644 --- a/src/Max.BotClient/Max.BotClient.Polling.cs +++ b/src/Max.BotClient/Max.BotClient.Polling.cs @@ -42,7 +42,7 @@ public static partial class BotClientApiMethods /// Обработчик ошибок (необязательно). /// Опции polling. /// Токен отмены. - public static void StartReceiving( + public static Task StartReceiving( this IBotClient botClient, Func updateHandler, Func? errorHandler = null, @@ -92,14 +92,21 @@ public static async Task ReceiveAsync( ); marker = newMarker; } - catch (OperationCanceledException) + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { return; } catch (Exception ex) { - if (errorHandler != null) - await errorHandler(botClient, ex, cancellationToken); + try + { + if (errorHandler != null) + await errorHandler(botClient, ex, cancellationToken); + } + catch + { + // Не позволяем errorHandler убить polling loop + } } } @@ -120,14 +127,31 @@ public static async Task ReceiveAsync( updates = result.Item1; marker = result.Item2 ?? marker; } - catch (OperationCanceledException) + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { break; } catch (Exception ex) { - if (errorHandler != null) - await errorHandler(botClient, ex, cancellationToken); + try + { + if (errorHandler != null) + await errorHandler(botClient, ex, cancellationToken); + } + catch + { + // Не позволяем errorHandler убить polling loop + } + + // Backoff перед повторной попыткой, чтобы не забивать API при длительном сбое + try + { + await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken); + } + catch (OperationCanceledException) + { + break; + } continue; } @@ -138,14 +162,21 @@ public static async Task ReceiveAsync( { await updateHandler(botClient, update, cancellationToken); } - catch (OperationCanceledException) + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { return; } catch (Exception ex) { - if (errorHandler != null) - await errorHandler(botClient, ex, cancellationToken); + try + { + if (errorHandler != null) + await errorHandler(botClient, ex, cancellationToken); + } + catch + { + // Не позволяем errorHandler убить polling loop + } } } }