Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/Max.Bot/Types/CallbackQuery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ public class CallbackQuery
/// Gets or sets the message with the inline button that was pressed.
/// </summary>
/// <value>The message with the inline button, or null if not available.</value>
[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; }

Expand Down
9 changes: 9 additions & 0 deletions src/Max.Bot/Types/CallbackQueryUpdate.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,15 @@ public class CallbackQueryUpdate
/// </summary>
/// <value>The callback query in this update.</value>
public CallbackQuery CallbackQuery { get; set; } = null!;

/// <summary>
/// Gets or sets the message that contains the callback keyboard.
/// </summary>
/// <remarks>
/// MAX webhook delivers message payload at update level for <c>message_callback</c>,
/// so consumers should read message from this property.
/// </remarks>
public Message? Message { get; set; }
}


Expand Down
123 changes: 118 additions & 5 deletions src/Max.Bot/Types/Converters/AttachmentJsonConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ public class AttachmentJsonConverter : JsonConverter<Attachment>
// Route by type name — primary and most reliable method
if (IsType(typeString, AttachmentTypeNames.Image))
{
return JsonSerializer.Deserialize<PhotoAttachment>(root.GetRawText(), options);
return DeserializePhotoAttachment(root, options);
}

if (IsType(typeString, AttachmentTypeNames.InlineKeyboard))
Expand All @@ -53,22 +53,22 @@ public class AttachmentJsonConverter : JsonConverter<Attachment>

if (IsType(typeString, AttachmentTypeNames.Contact))
{
return JsonSerializer.Deserialize<ContactAttachment>(root.GetRawText(), options);
return DeserializeContactAttachment(root, options);
}

if (IsType(typeString, AttachmentTypeNames.Video))
{
return DeserializeAttachment<VideoAttachment>(root, "video", options);
return DeserializeMediaAttachment<VideoAttachment>(root, "video", options);
}

if (IsType(typeString, AttachmentTypeNames.Audio))
{
return DeserializeAttachment<AudioAttachment>(root, "audio", options);
return DeserializeMediaAttachment<AudioAttachment>(root, "audio", options);
}

if (IsType(typeString, AttachmentTypeNames.File))
{
return DeserializeAttachment<DocumentAttachment>(root, "document", options);
return DeserializeMediaAttachment<DocumentAttachment>(root, "document", options);
}

// Fallback for unknown types — use DocumentAttachment as it has the most generic fields
Expand Down Expand Up @@ -123,6 +123,119 @@ public override void Write(Utf8JsonWriter writer, Attachment value, JsonSerializ
return JsonSerializer.Deserialize<T>(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<PhotoAttachment>(photo.GetRawText(), options);
if (attachment != null)
{
attachment.Type = AttachmentTypeNames.Image;
}

return attachment;
}

return JsonSerializer.Deserialize<PhotoAttachment>(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<ContactAttachment>(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<ContactAttachment>(root.GetRawText(), options);
}

private static T? DeserializeMediaAttachment<T>(JsonElement root, string payloadPropertyName, JsonSerializerOptions options)
where T : Attachment
{
if (root.TryGetProperty("payload", out var payload) && payload.ValueKind == JsonValueKind.Object)
{
var attachment = JsonSerializer.Deserialize<T>(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<T>(root, payloadPropertyName, options);
}

private static bool IsType(string? actualType, string expectedType)
{
return !string.IsNullOrWhiteSpace(actualType) &&
Expand Down
13 changes: 0 additions & 13 deletions src/Max.Bot/Types/Converters/CallbackQueryJsonConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Message>(messageElement.GetRawText(), options);
}

return callbackQuery;
}

Expand Down Expand Up @@ -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();
}
}
Expand Down
18 changes: 18 additions & 0 deletions src/Max.Bot/Types/Enums/ChatAdminPermission.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ namespace Max.Bot.Types.Enums;
/// </summary>
public enum ChatAdminPermission
{
/// <summary>
/// Permission to view stats.
/// Serializes as "view_stats".
/// </summary>
ViewStats,

/// <summary>
/// Permission to read all messages in the chat.
/// Serializes as "read_all_messages".
Expand Down Expand Up @@ -55,6 +61,12 @@ public enum ChatAdminPermission
/// </summary>
EditLink,

/// <summary>
/// Permission to edit messages (short form in MAX API).
/// Serializes as "edit".
/// </summary>
Edit,

/// <summary>
/// Permission to edit or delete posted messages.
/// Serializes as "post_edit_delete_message".
Expand All @@ -72,4 +84,10 @@ public enum ChatAdminPermission
/// Serializes as "delete_message".
/// </summary>
DeleteMessage,

/// <summary>
/// Permission to delete messages (short form in MAX API).
/// Serializes as "delete".
/// </summary>
Delete,
}
3 changes: 2 additions & 1 deletion src/Max.Bot/Types/Update.cs
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,8 @@ public CallbackQueryUpdate? CallbackQueryUpdate
UpdateId = UpdateId,
Timestamp = Timestamp,
UserLocale = UserLocale,
CallbackQuery = Callback
CallbackQuery = Callback,
Message = Message
};
}
}
Expand Down
31 changes: 31 additions & 0 deletions src/Max.Bot/Types/Video.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;

Expand All @@ -8,6 +9,12 @@ namespace Max.Bot.Types;
/// </summary>
public class Video
{
/// <summary>
/// Gets or sets the media token returned by /videos/{token}.
/// </summary>
[JsonPropertyName("token")]
public string? Token { get; set; }

/// <summary>
/// Gets or sets the unique identifier of the video.
/// </summary>
Expand Down Expand Up @@ -73,5 +80,29 @@ public class Video
[StringLength(2048, ErrorMessage = "URL must not exceed 2048 characters.")]
[JsonPropertyName("url")]
public string? Url { get; set; }

/// <summary>
/// Gets or sets quality-specific video URLs keyed by rendition name (for example, mp4_720).
/// </summary>
[JsonPropertyName("urls")]
public Dictionary<string, string>? Urls { get; set; }

/// <summary>
/// Gets or sets the thumbnail for this video.
/// </summary>
[JsonPropertyName("thumbnail")]
public VideoThumbnail? Thumbnail { get; set; }
}

/// <summary>
/// Represents a preview image for a video.
/// </summary>
public class VideoThumbnail
{
/// <summary>
/// Gets or sets the thumbnail URL.
/// </summary>
[JsonPropertyName("url")]
public string? Url { get; set; }
}

47 changes: 47 additions & 0 deletions tests/Max.Bot.Tests/Unit/Api/ChatsApiTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<MaxApiRequest>(req =>
req.Method == HttpMethod.Get &&
req.Endpoint == $"/chats/{chatId}/members/admins"),
It.IsAny<CancellationToken>()))
.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
Expand Down
Loading