Skip to content

Commit b4a38cd

Browse files
authored
Merge pull request #32 from sereginsk/issue/ChatPermission
Fix callback message, attachment payload tokens, and video response mapping, Issue/chat permission
2 parents e4c0c12 + 910804d commit b4a38cd

12 files changed

Lines changed: 306 additions & 23 deletions

src/Max.Bot/Types/CallbackQuery.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ public class CallbackQuery
3232
/// Gets or sets the message with the inline button that was pressed.
3333
/// </summary>
3434
/// <value>The message with the inline button, or null if not available.</value>
35+
[Obsolete("CallbackQuery.Message is not populated by current MAX API webhook contract. Use CallbackQueryUpdate.Message from Update.CallbackQueryUpdate instead.")]
3536
[JsonIgnore]
3637
public Message? Message { get; set; }
3738

src/Max.Bot/Types/CallbackQueryUpdate.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,15 @@ public class CallbackQueryUpdate
3232
/// </summary>
3333
/// <value>The callback query in this update.</value>
3434
public CallbackQuery CallbackQuery { get; set; } = null!;
35+
36+
/// <summary>
37+
/// Gets or sets the message that contains the callback keyboard.
38+
/// </summary>
39+
/// <remarks>
40+
/// MAX webhook delivers message payload at update level for <c>message_callback</c>,
41+
/// so consumers should read message from this property.
42+
/// </remarks>
43+
public Message? Message { get; set; }
3544
}
3645

3746

src/Max.Bot/Types/Converters/AttachmentJsonConverter.cs

Lines changed: 118 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ public class AttachmentJsonConverter : JsonConverter<Attachment>
3838
// Route by type name — primary and most reliable method
3939
if (IsType(typeString, AttachmentTypeNames.Image))
4040
{
41-
return JsonSerializer.Deserialize<PhotoAttachment>(root.GetRawText(), options);
41+
return DeserializePhotoAttachment(root, options);
4242
}
4343

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

5454
if (IsType(typeString, AttachmentTypeNames.Contact))
5555
{
56-
return JsonSerializer.Deserialize<ContactAttachment>(root.GetRawText(), options);
56+
return DeserializeContactAttachment(root, options);
5757
}
5858

5959
if (IsType(typeString, AttachmentTypeNames.Video))
6060
{
61-
return DeserializeAttachment<VideoAttachment>(root, "video", options);
61+
return DeserializeMediaAttachment<VideoAttachment>(root, "video", options);
6262
}
6363

6464
if (IsType(typeString, AttachmentTypeNames.Audio))
6565
{
66-
return DeserializeAttachment<AudioAttachment>(root, "audio", options);
66+
return DeserializeMediaAttachment<AudioAttachment>(root, "audio", options);
6767
}
6868

6969
if (IsType(typeString, AttachmentTypeNames.File))
7070
{
71-
return DeserializeAttachment<DocumentAttachment>(root, "document", options);
71+
return DeserializeMediaAttachment<DocumentAttachment>(root, "document", options);
7272
}
7373

7474
// 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
123123
return JsonSerializer.Deserialize<T>(root.GetRawText(), options);
124124
}
125125

126+
private static PhotoAttachment? DeserializePhotoAttachment(JsonElement root, JsonSerializerOptions options)
127+
{
128+
if (root.TryGetProperty("payload", out var payload) && payload.ValueKind == JsonValueKind.Object)
129+
{
130+
var attachment = new PhotoAttachment();
131+
132+
if (payload.TryGetProperty("id", out var idElement) && idElement.ValueKind == JsonValueKind.Number && idElement.TryGetInt64(out var id))
133+
{
134+
attachment.Id = id;
135+
}
136+
else if (payload.TryGetProperty("photo_id", out var photoIdElement) && photoIdElement.ValueKind == JsonValueKind.Number && photoIdElement.TryGetInt64(out var photoId))
137+
{
138+
attachment.Id = photoId;
139+
}
140+
141+
if (payload.TryGetProperty("file_id", out var fileIdElement) && fileIdElement.ValueKind == JsonValueKind.String)
142+
{
143+
attachment.FileId = fileIdElement.GetString() ?? string.Empty;
144+
}
145+
else if (payload.TryGetProperty("token", out var tokenElement) && tokenElement.ValueKind == JsonValueKind.String)
146+
{
147+
attachment.FileId = tokenElement.GetString() ?? string.Empty;
148+
}
149+
150+
if (payload.TryGetProperty("width", out var widthElement) && widthElement.ValueKind == JsonValueKind.Number && widthElement.TryGetInt32(out var width))
151+
{
152+
attachment.Width = width;
153+
}
154+
155+
if (payload.TryGetProperty("height", out var heightElement) && heightElement.ValueKind == JsonValueKind.Number && heightElement.TryGetInt32(out var height))
156+
{
157+
attachment.Height = height;
158+
}
159+
160+
if (payload.TryGetProperty("file_size", out var fileSizeElement) && fileSizeElement.ValueKind == JsonValueKind.Number && fileSizeElement.TryGetInt64(out var fileSize))
161+
{
162+
attachment.FileSize = fileSize;
163+
}
164+
165+
if (payload.TryGetProperty("url", out var urlElement) && urlElement.ValueKind == JsonValueKind.String)
166+
{
167+
attachment.Url = urlElement.GetString();
168+
}
169+
170+
return attachment;
171+
}
172+
173+
if (root.TryGetProperty("photo", out var photo) && photo.ValueKind == JsonValueKind.Object)
174+
{
175+
var attachment = JsonSerializer.Deserialize<PhotoAttachment>(photo.GetRawText(), options);
176+
if (attachment != null)
177+
{
178+
attachment.Type = AttachmentTypeNames.Image;
179+
}
180+
181+
return attachment;
182+
}
183+
184+
return JsonSerializer.Deserialize<PhotoAttachment>(root.GetRawText(), options);
185+
}
186+
187+
private static ContactAttachment? DeserializeContactAttachment(JsonElement root, JsonSerializerOptions options)
188+
{
189+
if (root.TryGetProperty("payload", out var payload) && payload.ValueKind == JsonValueKind.Object)
190+
{
191+
var attachment = JsonSerializer.Deserialize<ContactAttachment>(payload.GetRawText(), options);
192+
if (attachment != null)
193+
{
194+
// Ensure type is always set even when payload does not include it.
195+
attachment.Type = AttachmentTypeNames.Contact;
196+
}
197+
return attachment;
198+
}
199+
200+
return JsonSerializer.Deserialize<ContactAttachment>(root.GetRawText(), options);
201+
}
202+
203+
private static T? DeserializeMediaAttachment<T>(JsonElement root, string payloadPropertyName, JsonSerializerOptions options)
204+
where T : Attachment
205+
{
206+
if (root.TryGetProperty("payload", out var payload) && payload.ValueKind == JsonValueKind.Object)
207+
{
208+
var attachment = JsonSerializer.Deserialize<T>(payload.GetRawText(), options);
209+
if (attachment == null)
210+
{
211+
return null;
212+
}
213+
214+
switch (attachment)
215+
{
216+
case AudioAttachment audio when string.IsNullOrWhiteSpace(audio.FileId)
217+
&& payload.TryGetProperty("token", out var audioToken)
218+
&& audioToken.ValueKind == JsonValueKind.String:
219+
audio.FileId = audioToken.GetString() ?? string.Empty;
220+
break;
221+
case VideoAttachment video when string.IsNullOrWhiteSpace(video.FileId)
222+
&& payload.TryGetProperty("token", out var videoToken)
223+
&& videoToken.ValueKind == JsonValueKind.String:
224+
video.FileId = videoToken.GetString() ?? string.Empty;
225+
break;
226+
case DocumentAttachment document when string.IsNullOrWhiteSpace(document.FileId)
227+
&& payload.TryGetProperty("token", out var documentToken)
228+
&& documentToken.ValueKind == JsonValueKind.String:
229+
document.FileId = documentToken.GetString() ?? string.Empty;
230+
break;
231+
}
232+
233+
return attachment;
234+
}
235+
236+
return DeserializeAttachment<T>(root, payloadPropertyName, options);
237+
}
238+
126239
private static bool IsType(string? actualType, string expectedType)
127240
{
128241
return !string.IsNullOrWhiteSpace(actualType) &&

src/Max.Bot/Types/Converters/CallbackQueryJsonConverter.cs

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -53,12 +53,6 @@ public override CallbackQuery Read(ref Utf8JsonReader reader, Type typeToConvert
5353
callbackQuery.Timestamp = timestampElement.GetInt64();
5454
}
5555

56-
// Read message
57-
if (root.TryGetProperty("message", out var messageElement))
58-
{
59-
callbackQuery.Message = JsonSerializer.Deserialize<Message>(messageElement.GetRawText(), options);
60-
}
61-
6256
return callbackQuery;
6357
}
6458

@@ -92,13 +86,6 @@ public override void Write(Utf8JsonWriter writer, CallbackQuery value, JsonSeria
9286
writer.WriteNumber("timestamp", value.Timestamp.Value);
9387
}
9488

95-
// Write message if present
96-
if (value.Message != null)
97-
{
98-
writer.WritePropertyName("message");
99-
JsonSerializer.Serialize(writer, value.Message, options);
100-
}
101-
10289
writer.WriteEndObject();
10390
}
10491
}

src/Max.Bot/Types/Enums/ChatAdminPermission.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ namespace Max.Bot.Types.Enums;
77
/// </summary>
88
public enum ChatAdminPermission
99
{
10+
/// <summary>
11+
/// Permission to view stats.
12+
/// Serializes as "view_stats".
13+
/// </summary>
14+
ViewStats,
15+
1016
/// <summary>
1117
/// Permission to read all messages in the chat.
1218
/// Serializes as "read_all_messages".
@@ -55,6 +61,12 @@ public enum ChatAdminPermission
5561
/// </summary>
5662
EditLink,
5763

64+
/// <summary>
65+
/// Permission to edit messages (short form in MAX API).
66+
/// Serializes as "edit".
67+
/// </summary>
68+
Edit,
69+
5870
/// <summary>
5971
/// Permission to edit or delete posted messages.
6072
/// Serializes as "post_edit_delete_message".
@@ -72,4 +84,10 @@ public enum ChatAdminPermission
7284
/// Serializes as "delete_message".
7385
/// </summary>
7486
DeleteMessage,
87+
88+
/// <summary>
89+
/// Permission to delete messages (short form in MAX API).
90+
/// Serializes as "delete".
91+
/// </summary>
92+
Delete,
7593
}

src/Max.Bot/Types/Update.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,8 @@ public CallbackQueryUpdate? CallbackQueryUpdate
147147
UpdateId = UpdateId,
148148
Timestamp = Timestamp,
149149
UserLocale = UserLocale,
150-
CallbackQuery = Callback
150+
CallbackQuery = Callback,
151+
Message = Message
151152
};
152153
}
153154
}

src/Max.Bot/Types/Video.cs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System.Collections.Generic;
12
using System.ComponentModel.DataAnnotations;
23
using System.Text.Json.Serialization;
34

@@ -8,6 +9,12 @@ namespace Max.Bot.Types;
89
/// </summary>
910
public class Video
1011
{
12+
/// <summary>
13+
/// Gets or sets the media token returned by /videos/{token}.
14+
/// </summary>
15+
[JsonPropertyName("token")]
16+
public string? Token { get; set; }
17+
1118
/// <summary>
1219
/// Gets or sets the unique identifier of the video.
1320
/// </summary>
@@ -73,5 +80,29 @@ public class Video
7380
[StringLength(2048, ErrorMessage = "URL must not exceed 2048 characters.")]
7481
[JsonPropertyName("url")]
7582
public string? Url { get; set; }
83+
84+
/// <summary>
85+
/// Gets or sets quality-specific video URLs keyed by rendition name (for example, mp4_720).
86+
/// </summary>
87+
[JsonPropertyName("urls")]
88+
public Dictionary<string, string>? Urls { get; set; }
89+
90+
/// <summary>
91+
/// Gets or sets the thumbnail for this video.
92+
/// </summary>
93+
[JsonPropertyName("thumbnail")]
94+
public VideoThumbnail? Thumbnail { get; set; }
95+
}
96+
97+
/// <summary>
98+
/// Represents a preview image for a video.
99+
/// </summary>
100+
public class VideoThumbnail
101+
{
102+
/// <summary>
103+
/// Gets or sets the thumbnail URL.
104+
/// </summary>
105+
[JsonPropertyName("url")]
106+
public string? Url { get; set; }
76107
}
77108

tests/Max.Bot.Tests/Unit/Api/ChatsApiTests.cs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -580,6 +580,53 @@ public async Task GetChatAdminsAsync_ShouldReturnAdmins_WhenRequestSucceeds()
580580
result[1].Id.Should().Be(200L);
581581
}
582582

583+
[Fact]
584+
public async Task GetChatAdminsAsync_ShouldDeserialize_AllPermissionStrings_WhenApiReturnsShortNames()
585+
{
586+
// Arrange - API returns permissions as short strings: "view_stats", "edit", "delete", ...
587+
var chatId = 123456L;
588+
589+
var responseJson = """
590+
{
591+
"members": [
592+
{
593+
"user_id": 100,
594+
"is_admin": true,
595+
"permissions": ["view_stats","read_all_messages","edit_link","write","edit","add_remove_members","change_chat_info","delete","pin_message"]
596+
}
597+
],
598+
"marker": null
599+
}
600+
""";
601+
602+
_mockHttpClient
603+
.Setup(x => x.SendAsyncRaw(
604+
It.Is<MaxApiRequest>(req =>
605+
req.Method == HttpMethod.Get &&
606+
req.Endpoint == $"/chats/{chatId}/members/admins"),
607+
It.IsAny<CancellationToken>()))
608+
.ReturnsAsync(responseJson);
609+
610+
var chatsApi = new ChatsApi(_mockHttpClient.Object, _options);
611+
612+
// Act
613+
var result = await chatsApi.GetChatAdminsAsync(chatId);
614+
615+
// Assert
616+
result.Should().NotBeNull();
617+
result.Should().HaveCount(1);
618+
result[0].Permissions.Should().NotBeNull();
619+
result[0].Permissions!.Should().Contain(new[]
620+
{
621+
ChatAdminPermission.ViewStats,
622+
ChatAdminPermission.EditLink,
623+
ChatAdminPermission.Write,
624+
ChatAdminPermission.Edit,
625+
ChatAdminPermission.Delete,
626+
ChatAdminPermission.PinMessage
627+
});
628+
}
629+
583630
#endregion
584631

585632
#region AddChatAdminAsync Tests

0 commit comments

Comments
 (0)