From d9101e797e62aa0a96a487a2747ce49e8346fcd4 Mon Sep 17 00:00:00 2001 From: Sergey Sysalov Date: Wed, 29 Apr 2026 13:11:25 +0700 Subject: [PATCH 1/5] fix: align callback message contract with update wrapper Move callback message access to CallbackQueryUpdate.Message and mark CallbackQuery.Message obsolete so consumers can reliably read source message mid after deserialization. --- src/Max.Bot/Types/CallbackQuery.cs | 1 + src/Max.Bot/Types/CallbackQueryUpdate.cs | 9 +++++++++ .../Types/Converters/CallbackQueryJsonConverter.cs | 13 ------------- src/Max.Bot/Types/Update.cs | 3 ++- .../Max.Bot.Tests/Unit/Types/CallbackQueryTests.cs | 4 +--- tests/Max.Bot.Tests/Unit/Types/UpdateTests.cs | 4 +++- 6 files changed, 16 insertions(+), 18 deletions(-) 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/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/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/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 From 931902b5f2cadc0d762e317900390e04858ee861 Mon Sep 17 00:00:00 2001 From: Sergey Sysalov Date: Wed, 29 Apr 2026 13:29:05 +0700 Subject: [PATCH 2/5] fix: parse payload tokens for media attachments Handle MAX payload-style media attachments so image/audio/video/file tokens are mapped to FileId and preserved by consumers that persist attachment tokens. --- .../Converters/AttachmentJsonConverter.cs | 105 +++++++++++++++++- .../Unit/Types/AttachmentFormatTests.cs | 28 +++++ 2 files changed, 129 insertions(+), 4 deletions(-) diff --git a/src/Max.Bot/Types/Converters/AttachmentJsonConverter.cs b/src/Max.Bot/Types/Converters/AttachmentJsonConverter.cs index a4aa064..51b8df5 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)) @@ -58,17 +58,17 @@ public class AttachmentJsonConverter : JsonConverter 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,103 @@ 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 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/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] From b5fb923b3d018278881bdb400335b385fa733b92 Mon Sep 17 00:00:00 2001 From: Sergey Sysalov Date: Wed, 29 Apr 2026 14:59:09 +0700 Subject: [PATCH 3/5] fix: align MAX callback/message and media/video contracts --- src/Max.Bot/Types/Video.cs | 31 ++++++++++++ .../Unit/Api/MessagesApiTests.cs | 48 +++++++++++++++++++ 2 files changed, 79 insertions(+) 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/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("")] From bd5f2309796238527124c7bb4f52fcd72ff8efc9 Mon Sep 17 00:00:00 2001 From: Sergey Sysalov Date: Wed, 29 Apr 2026 15:30:53 +0700 Subject: [PATCH 4/5] fix: add missing chat admin permission enum values Prevent deserialization failures of Chats API responses when MAX returns short permission strings (e.g. view_stats, edit, delete). --- .../Types/Enums/ChatAdminPermission.cs | 18 +++++++ tests/Max.Bot.Tests/Unit/Api/ChatsApiTests.cs | 47 +++++++++++++++++++ 2 files changed, 65 insertions(+) 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/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 From 910804d9fe2cb4e915f3600b01b140b96def83af Mon Sep 17 00:00:00 2001 From: Sergey Sysalov Date: Wed, 29 Apr 2026 17:49:06 +0700 Subject: [PATCH 5/5] =?UTF-8?q?payload-=D1=84=D0=BE=D1=80=D0=BC=D0=B0?= =?UTF-8?q?=D1=82=20contact=20=D0=B8=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D0=B8=D0=BB=20=D0=BA=D0=BE=D0=BD=D0=B2=D0=B5=D1=80=D1=82=D0=B5?= =?UTF-8?q?=D1=80:=20=D1=82=D0=B5=D0=BF=D0=B5=D1=80=D1=8C=20ContactAttachm?= =?UTF-8?q?ent=20=D1=80=D0=B0=D0=B7=D0=B1=D0=B8=D1=80=D0=B0=D0=B5=D1=82?= =?UTF-8?q?=D1=81=D1=8F=20=D0=B8=D0=B7=20attachments[].payload.vcf=5Finfo/?= =?UTF-8?q?max=5Finfo/hash?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Converters/AttachmentJsonConverter.cs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/Max.Bot/Types/Converters/AttachmentJsonConverter.cs b/src/Max.Bot/Types/Converters/AttachmentJsonConverter.cs index 51b8df5..c582997 100644 --- a/src/Max.Bot/Types/Converters/AttachmentJsonConverter.cs +++ b/src/Max.Bot/Types/Converters/AttachmentJsonConverter.cs @@ -53,7 +53,7 @@ public class AttachmentJsonConverter : JsonConverter if (IsType(typeString, AttachmentTypeNames.Contact)) { - return JsonSerializer.Deserialize(root.GetRawText(), options); + return DeserializeContactAttachment(root, options); } if (IsType(typeString, AttachmentTypeNames.Video)) @@ -184,6 +184,22 @@ public override void Write(Utf8JsonWriter writer, Attachment value, JsonSerializ 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 {