diff --git a/src/Max.Bot/Types/CallbackQuery.cs b/src/Max.Bot/Types/CallbackQuery.cs
index b11a9d2..3eea1c1 100644
--- a/src/Max.Bot/Types/CallbackQuery.cs
+++ b/src/Max.Bot/Types/CallbackQuery.cs
@@ -32,6 +32,7 @@ public class CallbackQuery
/// Gets or sets the message with the inline button that was pressed.
///
/// The message with the inline button, or null if not available.
+ [Obsolete("CallbackQuery.Message is not populated by current MAX API webhook contract. Use CallbackQueryUpdate.Message from Update.CallbackQueryUpdate instead.")]
[JsonIgnore]
public Message? Message { get; set; }
diff --git a/src/Max.Bot/Types/CallbackQueryUpdate.cs b/src/Max.Bot/Types/CallbackQueryUpdate.cs
index 855f6b8..acafdc9 100644
--- a/src/Max.Bot/Types/CallbackQueryUpdate.cs
+++ b/src/Max.Bot/Types/CallbackQueryUpdate.cs
@@ -32,6 +32,15 @@ public class CallbackQueryUpdate
///
/// The callback query in this update.
public CallbackQuery CallbackQuery { get; set; } = null!;
+
+ ///
+ /// Gets or sets the message that contains the callback keyboard.
+ ///
+ ///
+ /// MAX webhook delivers message payload at update level for message_callback,
+ /// so consumers should read message from this property.
+ ///
+ public Message? Message { get; set; }
}
diff --git a/src/Max.Bot/Types/Converters/AttachmentJsonConverter.cs b/src/Max.Bot/Types/Converters/AttachmentJsonConverter.cs
index a4aa064..c582997 100644
--- a/src/Max.Bot/Types/Converters/AttachmentJsonConverter.cs
+++ b/src/Max.Bot/Types/Converters/AttachmentJsonConverter.cs
@@ -38,7 +38,7 @@ public class AttachmentJsonConverter : JsonConverter
// Route by type name — primary and most reliable method
if (IsType(typeString, AttachmentTypeNames.Image))
{
- return JsonSerializer.Deserialize(root.GetRawText(), options);
+ return DeserializePhotoAttachment(root, options);
}
if (IsType(typeString, AttachmentTypeNames.InlineKeyboard))
@@ -53,22 +53,22 @@ public class AttachmentJsonConverter : JsonConverter
if (IsType(typeString, AttachmentTypeNames.Contact))
{
- return JsonSerializer.Deserialize(root.GetRawText(), options);
+ return DeserializeContactAttachment(root, options);
}
if (IsType(typeString, AttachmentTypeNames.Video))
{
- return DeserializeAttachment(root, "video", options);
+ return DeserializeMediaAttachment(root, "video", options);
}
if (IsType(typeString, AttachmentTypeNames.Audio))
{
- return DeserializeAttachment(root, "audio", options);
+ return DeserializeMediaAttachment(root, "audio", options);
}
if (IsType(typeString, AttachmentTypeNames.File))
{
- return DeserializeAttachment(root, "document", options);
+ return DeserializeMediaAttachment(root, "document", options);
}
// Fallback for unknown types — use DocumentAttachment as it has the most generic fields
@@ -123,6 +123,119 @@ public override void Write(Utf8JsonWriter writer, Attachment value, JsonSerializ
return JsonSerializer.Deserialize(root.GetRawText(), options);
}
+ private static PhotoAttachment? DeserializePhotoAttachment(JsonElement root, JsonSerializerOptions options)
+ {
+ if (root.TryGetProperty("payload", out var payload) && payload.ValueKind == JsonValueKind.Object)
+ {
+ var attachment = new PhotoAttachment();
+
+ if (payload.TryGetProperty("id", out var idElement) && idElement.ValueKind == JsonValueKind.Number && idElement.TryGetInt64(out var id))
+ {
+ attachment.Id = id;
+ }
+ else if (payload.TryGetProperty("photo_id", out var photoIdElement) && photoIdElement.ValueKind == JsonValueKind.Number && photoIdElement.TryGetInt64(out var photoId))
+ {
+ attachment.Id = photoId;
+ }
+
+ if (payload.TryGetProperty("file_id", out var fileIdElement) && fileIdElement.ValueKind == JsonValueKind.String)
+ {
+ attachment.FileId = fileIdElement.GetString() ?? string.Empty;
+ }
+ else if (payload.TryGetProperty("token", out var tokenElement) && tokenElement.ValueKind == JsonValueKind.String)
+ {
+ attachment.FileId = tokenElement.GetString() ?? string.Empty;
+ }
+
+ if (payload.TryGetProperty("width", out var widthElement) && widthElement.ValueKind == JsonValueKind.Number && widthElement.TryGetInt32(out var width))
+ {
+ attachment.Width = width;
+ }
+
+ if (payload.TryGetProperty("height", out var heightElement) && heightElement.ValueKind == JsonValueKind.Number && heightElement.TryGetInt32(out var height))
+ {
+ attachment.Height = height;
+ }
+
+ if (payload.TryGetProperty("file_size", out var fileSizeElement) && fileSizeElement.ValueKind == JsonValueKind.Number && fileSizeElement.TryGetInt64(out var fileSize))
+ {
+ attachment.FileSize = fileSize;
+ }
+
+ if (payload.TryGetProperty("url", out var urlElement) && urlElement.ValueKind == JsonValueKind.String)
+ {
+ attachment.Url = urlElement.GetString();
+ }
+
+ return attachment;
+ }
+
+ if (root.TryGetProperty("photo", out var photo) && photo.ValueKind == JsonValueKind.Object)
+ {
+ var attachment = JsonSerializer.Deserialize(photo.GetRawText(), options);
+ if (attachment != null)
+ {
+ attachment.Type = AttachmentTypeNames.Image;
+ }
+
+ return attachment;
+ }
+
+ return JsonSerializer.Deserialize(root.GetRawText(), options);
+ }
+
+ private static ContactAttachment? DeserializeContactAttachment(JsonElement root, JsonSerializerOptions options)
+ {
+ if (root.TryGetProperty("payload", out var payload) && payload.ValueKind == JsonValueKind.Object)
+ {
+ var attachment = JsonSerializer.Deserialize(payload.GetRawText(), options);
+ if (attachment != null)
+ {
+ // Ensure type is always set even when payload does not include it.
+ attachment.Type = AttachmentTypeNames.Contact;
+ }
+ return attachment;
+ }
+
+ return JsonSerializer.Deserialize(root.GetRawText(), options);
+ }
+
+ private static T? DeserializeMediaAttachment(JsonElement root, string payloadPropertyName, JsonSerializerOptions options)
+ where T : Attachment
+ {
+ if (root.TryGetProperty("payload", out var payload) && payload.ValueKind == JsonValueKind.Object)
+ {
+ var attachment = JsonSerializer.Deserialize(payload.GetRawText(), options);
+ if (attachment == null)
+ {
+ return null;
+ }
+
+ switch (attachment)
+ {
+ case AudioAttachment audio when string.IsNullOrWhiteSpace(audio.FileId)
+ && payload.TryGetProperty("token", out var audioToken)
+ && audioToken.ValueKind == JsonValueKind.String:
+ audio.FileId = audioToken.GetString() ?? string.Empty;
+ break;
+ case VideoAttachment video when string.IsNullOrWhiteSpace(video.FileId)
+ && payload.TryGetProperty("token", out var videoToken)
+ && videoToken.ValueKind == JsonValueKind.String:
+ video.FileId = videoToken.GetString() ?? string.Empty;
+ break;
+ case DocumentAttachment document when string.IsNullOrWhiteSpace(document.FileId)
+ && payload.TryGetProperty("token", out var documentToken)
+ && documentToken.ValueKind == JsonValueKind.String:
+ document.FileId = documentToken.GetString() ?? string.Empty;
+ break;
+ }
+
+ return attachment;
+ }
+
+ return DeserializeAttachment(root, payloadPropertyName, options);
+ }
+
private static bool IsType(string? actualType, string expectedType)
{
return !string.IsNullOrWhiteSpace(actualType) &&
diff --git a/src/Max.Bot/Types/Converters/CallbackQueryJsonConverter.cs b/src/Max.Bot/Types/Converters/CallbackQueryJsonConverter.cs
index fad844a..e1be155 100644
--- a/src/Max.Bot/Types/Converters/CallbackQueryJsonConverter.cs
+++ b/src/Max.Bot/Types/Converters/CallbackQueryJsonConverter.cs
@@ -53,12 +53,6 @@ public override CallbackQuery Read(ref Utf8JsonReader reader, Type typeToConvert
callbackQuery.Timestamp = timestampElement.GetInt64();
}
- // Read message
- if (root.TryGetProperty("message", out var messageElement))
- {
- callbackQuery.Message = JsonSerializer.Deserialize(messageElement.GetRawText(), options);
- }
-
return callbackQuery;
}
@@ -92,13 +86,6 @@ public override void Write(Utf8JsonWriter writer, CallbackQuery value, JsonSeria
writer.WriteNumber("timestamp", value.Timestamp.Value);
}
- // Write message if present
- if (value.Message != null)
- {
- writer.WritePropertyName("message");
- JsonSerializer.Serialize(writer, value.Message, options);
- }
-
writer.WriteEndObject();
}
}
diff --git a/src/Max.Bot/Types/Enums/ChatAdminPermission.cs b/src/Max.Bot/Types/Enums/ChatAdminPermission.cs
index dd907c6..dcdc57c 100644
--- a/src/Max.Bot/Types/Enums/ChatAdminPermission.cs
+++ b/src/Max.Bot/Types/Enums/ChatAdminPermission.cs
@@ -7,6 +7,12 @@ namespace Max.Bot.Types.Enums;
///
public enum ChatAdminPermission
{
+ ///
+ /// Permission to view stats.
+ /// Serializes as "view_stats".
+ ///
+ ViewStats,
+
///
/// Permission to read all messages in the chat.
/// Serializes as "read_all_messages".
@@ -55,6 +61,12 @@ public enum ChatAdminPermission
///
EditLink,
+ ///
+ /// Permission to edit messages (short form in MAX API).
+ /// Serializes as "edit".
+ ///
+ Edit,
+
///
/// Permission to edit or delete posted messages.
/// Serializes as "post_edit_delete_message".
@@ -72,4 +84,10 @@ public enum ChatAdminPermission
/// Serializes as "delete_message".
///
DeleteMessage,
+
+ ///
+ /// Permission to delete messages (short form in MAX API).
+ /// Serializes as "delete".
+ ///
+ Delete,
}
diff --git a/src/Max.Bot/Types/Update.cs b/src/Max.Bot/Types/Update.cs
index 6c03ed3..b5a2fae 100644
--- a/src/Max.Bot/Types/Update.cs
+++ b/src/Max.Bot/Types/Update.cs
@@ -147,7 +147,8 @@ public CallbackQueryUpdate? CallbackQueryUpdate
UpdateId = UpdateId,
Timestamp = Timestamp,
UserLocale = UserLocale,
- CallbackQuery = Callback
+ CallbackQuery = Callback,
+ Message = Message
};
}
}
diff --git a/src/Max.Bot/Types/Video.cs b/src/Max.Bot/Types/Video.cs
index aed74fc..8fc66e4 100644
--- a/src/Max.Bot/Types/Video.cs
+++ b/src/Max.Bot/Types/Video.cs
@@ -1,3 +1,4 @@
+using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
@@ -8,6 +9,12 @@ namespace Max.Bot.Types;
///
public class Video
{
+ ///
+ /// Gets or sets the media token returned by /videos/{token}.
+ ///
+ [JsonPropertyName("token")]
+ public string? Token { get; set; }
+
///
/// Gets or sets the unique identifier of the video.
///
@@ -73,5 +80,29 @@ public class Video
[StringLength(2048, ErrorMessage = "URL must not exceed 2048 characters.")]
[JsonPropertyName("url")]
public string? Url { get; set; }
+
+ ///
+ /// Gets or sets quality-specific video URLs keyed by rendition name (for example, mp4_720).
+ ///
+ [JsonPropertyName("urls")]
+ public Dictionary? Urls { get; set; }
+
+ ///
+ /// Gets or sets the thumbnail for this video.
+ ///
+ [JsonPropertyName("thumbnail")]
+ public VideoThumbnail? Thumbnail { get; set; }
+}
+
+///
+/// Represents a preview image for a video.
+///
+public class VideoThumbnail
+{
+ ///
+ /// Gets or sets the thumbnail URL.
+ ///
+ [JsonPropertyName("url")]
+ public string? Url { get; set; }
}
diff --git a/tests/Max.Bot.Tests/Unit/Api/ChatsApiTests.cs b/tests/Max.Bot.Tests/Unit/Api/ChatsApiTests.cs
index d7678c9..f1ddae1 100644
--- a/tests/Max.Bot.Tests/Unit/Api/ChatsApiTests.cs
+++ b/tests/Max.Bot.Tests/Unit/Api/ChatsApiTests.cs
@@ -580,6 +580,53 @@ public async Task GetChatAdminsAsync_ShouldReturnAdmins_WhenRequestSucceeds()
result[1].Id.Should().Be(200L);
}
+ [Fact]
+ public async Task GetChatAdminsAsync_ShouldDeserialize_AllPermissionStrings_WhenApiReturnsShortNames()
+ {
+ // Arrange - API returns permissions as short strings: "view_stats", "edit", "delete", ...
+ var chatId = 123456L;
+
+ var responseJson = """
+ {
+ "members": [
+ {
+ "user_id": 100,
+ "is_admin": true,
+ "permissions": ["view_stats","read_all_messages","edit_link","write","edit","add_remove_members","change_chat_info","delete","pin_message"]
+ }
+ ],
+ "marker": null
+ }
+ """;
+
+ _mockHttpClient
+ .Setup(x => x.SendAsyncRaw(
+ It.Is(req =>
+ req.Method == HttpMethod.Get &&
+ req.Endpoint == $"/chats/{chatId}/members/admins"),
+ It.IsAny()))
+ .ReturnsAsync(responseJson);
+
+ var chatsApi = new ChatsApi(_mockHttpClient.Object, _options);
+
+ // Act
+ var result = await chatsApi.GetChatAdminsAsync(chatId);
+
+ // Assert
+ result.Should().NotBeNull();
+ result.Should().HaveCount(1);
+ result[0].Permissions.Should().NotBeNull();
+ result[0].Permissions!.Should().Contain(new[]
+ {
+ ChatAdminPermission.ViewStats,
+ ChatAdminPermission.EditLink,
+ ChatAdminPermission.Write,
+ ChatAdminPermission.Edit,
+ ChatAdminPermission.Delete,
+ ChatAdminPermission.PinMessage
+ });
+ }
+
#endregion
#region AddChatAdminAsync Tests
diff --git a/tests/Max.Bot.Tests/Unit/Api/MessagesApiTests.cs b/tests/Max.Bot.Tests/Unit/Api/MessagesApiTests.cs
index 3b27372..25e8523 100644
--- a/tests/Max.Bot.Tests/Unit/Api/MessagesApiTests.cs
+++ b/tests/Max.Bot.Tests/Unit/Api/MessagesApiTests.cs
@@ -495,6 +495,54 @@ public async Task GetVideoAsync_ShouldReturnVideo_WhenRequestSucceeds()
result.Duration.Should().Be(expectedVideo.Duration);
}
+ [Fact]
+ public async Task GetVideoAsync_ShouldMapTokenUrlsAndThumbnail_WhenResponseUsesNewShape()
+ {
+ // Arrange
+ var videoToken = "video-token-123";
+ var responseJson = """
+ {
+ "ok": true,
+ "result": {
+ "token": "f9LHodD0",
+ "width": 720,
+ "height": 1280,
+ "duration": 6633,
+ "urls": {
+ "mp4_720": "http://vd555.okcdn.ru/video.mp4"
+ },
+ "thumbnail": {
+ "url": "https://pimg.mycdn.me/thumb.jpg"
+ }
+ }
+ }
+ """;
+
+ _mockHttpClient
+ .Setup(x => x.SendAsyncRaw(
+ It.Is(req =>
+ req.Method == HttpMethod.Get &&
+ req.Endpoint == $"/videos/{videoToken}"),
+ It.IsAny()))
+ .ReturnsAsync(responseJson);
+
+ var messagesApi = new MessagesApi(_mockHttpClient.Object, _options);
+
+ // Act
+ var result = await messagesApi.GetVideoAsync(videoToken);
+
+ // Assert
+ result.Should().NotBeNull();
+ result.Token.Should().Be("f9LHodD0");
+ result.Width.Should().Be(720);
+ result.Height.Should().Be(1280);
+ result.Duration.Should().Be(6633);
+ result.Urls.Should().NotBeNull();
+ result.Urls!["mp4_720"].Should().Be("http://vd555.okcdn.ru/video.mp4");
+ result.Thumbnail.Should().NotBeNull();
+ result.Thumbnail!.Url.Should().Be("https://pimg.mycdn.me/thumb.jpg");
+ }
+
[Theory]
[InlineData(null)]
[InlineData("")]
diff --git a/tests/Max.Bot.Tests/Unit/Types/AttachmentFormatTests.cs b/tests/Max.Bot.Tests/Unit/Types/AttachmentFormatTests.cs
index e074e78..c1f1da0 100644
--- a/tests/Max.Bot.Tests/Unit/Types/AttachmentFormatTests.cs
+++ b/tests/Max.Bot.Tests/Unit/Types/AttachmentFormatTests.cs
@@ -57,6 +57,20 @@ public void Photo_MinimalFormat_ShouldDeserialize()
photo.FileId.Should().Be("abc123");
}
+ [Fact]
+ public void Photo_PayloadFormat_ShouldDeserialize_TokenAndUrl()
+ {
+ var json = """{"type":"image","payload":{"photo_id":14463448907,"token":"token123","url":"https://i.oneme.ru/i?r=abc"}}""";
+
+ var attachment = MaxJsonSerializer.Deserialize(json);
+
+ attachment.Should().BeOfType();
+ var photo = (PhotoAttachment)attachment;
+ photo.Id.Should().Be(14463448907);
+ photo.FileId.Should().Be("token123");
+ photo.Url.Should().Be("https://i.oneme.ru/i?r=abc");
+ }
+
// ==================== VIDEO ====================
[Fact]
@@ -112,6 +126,20 @@ public void Audio_FlatFormat_ShouldDeserialize_AudioAttachment()
audio.Id.Should().Be(789);
}
+ [Fact]
+ public void Audio_PayloadFormat_ShouldDeserialize_TokenAndUrl()
+ {
+ var json = """{"type":"audio","payload":{"token":"voice-token","url":"https://i.oneme.ru/a?r=abc","duration":13}}""";
+
+ var attachment = MaxJsonSerializer.Deserialize(json);
+
+ attachment.Should().BeOfType();
+ var audio = (AudioAttachment)attachment;
+ audio.FileId.Should().Be("voice-token");
+ audio.Url.Should().Be("https://i.oneme.ru/a?r=abc");
+ audio.Duration.Should().Be(13);
+ }
+
// ==================== DOCUMENT ====================
[Fact]
diff --git a/tests/Max.Bot.Tests/Unit/Types/CallbackQueryTests.cs b/tests/Max.Bot.Tests/Unit/Types/CallbackQueryTests.cs
index 221843b..78c9ecc 100644
--- a/tests/Max.Bot.Tests/Unit/Types/CallbackQueryTests.cs
+++ b/tests/Max.Bot.Tests/Unit/Types/CallbackQueryTests.cs
@@ -22,9 +22,7 @@ public void CallbackQuery_ShouldDeserialize_FromJson()
callbackQuery.User.Should().NotBeNull();
callbackQuery.User!.Id.Should().Be(123);
callbackQuery.User.Username.Should().Be("user123");
- callbackQuery.Message.Should().NotBeNull();
- callbackQuery.Message!.Body?.Mid.Should().Be("msg456");
- callbackQuery.Message.Text.Should().Be("Test message");
+ callbackQuery.Message.Should().BeNull();
callbackQuery.Payload.Should().Be("payload123");
callbackQuery.Timestamp.Should().Be(1609459200000);
}
diff --git a/tests/Max.Bot.Tests/Unit/Types/UpdateTests.cs b/tests/Max.Bot.Tests/Unit/Types/UpdateTests.cs
index 36b5659..14d4e18 100644
--- a/tests/Max.Bot.Tests/Unit/Types/UpdateTests.cs
+++ b/tests/Max.Bot.Tests/Unit/Types/UpdateTests.cs
@@ -77,7 +77,7 @@ public void Deserialize_WebhookUpdate_ShouldDeserializeCorrectly()
public void Deserialize_MessageCallback_ShouldDeserializeCorrectly()
{
// Arrange - API format uses "callback" field
- var json = """{"update_id":2,"update_type":"message_callback","timestamp":1609459200000,"callback":{"callback_id":"cb123","user":{"user_id":123,"username":"user123","is_bot":false},"payload":"button_clicked","timestamp":1609459200000}}""";
+ var json = """{"update_id":2,"update_type":"message_callback","timestamp":1609459200000,"message":{"body":{"mid":"mid.cb.1","text":"Button host"},"timestamp":1609459200000},"callback":{"callback_id":"cb123","user":{"user_id":123,"username":"user123","is_bot":false},"payload":"button_clicked","timestamp":1609459200000}}""";
// Act
var result = MaxJsonSerializer.Deserialize(json);
@@ -99,6 +99,8 @@ public void Deserialize_MessageCallback_ShouldDeserializeCorrectly()
result.CallbackQueryUpdate!.UpdateId.Should().Be(2);
result.CallbackQueryUpdate.CallbackQuery.Should().NotBeNull();
result.CallbackQueryUpdate.CallbackQuery.CallbackId.Should().Be("cb123");
+ result.CallbackQueryUpdate.Message.Should().NotBeNull();
+ result.CallbackQueryUpdate.Message!.Body!.Mid.Should().Be("mid.cb.1");
}
#endregion