From 4733159753a902ac2d04c4329fa8ba6b7c70e3a2 Mon Sep 17 00:00:00 2001 From: Ayrat Hudaygulov Date: Wed, 15 Jul 2026 17:27:33 +0100 Subject: [PATCH 1/2] Fix DU case selection for tag-discriminated unions (ChatMember et al.) DiscriminatedUnionConverter picked union cases by field-shape overlap, ignoring the discriminator value Telegram actually uses. Shape matching cannot be correct for ChatMember: ChatMemberLeft and ChatMemberMember are shape-identical on the wire, and the first shape-superset won, so {"status":"member"} parsed as Owner, {"status":"left"} as Owner, and {"status":"kicked"} as Member. Re-serializing a misdiscriminated value also invented fields that were never on the wire (is_anonymous). Fix: - New Funogram.Types.TelegramTagAttribute(field, value) marks a generated record as a tag-discriminated union payload (the Bot API documents the fixed value as `always "X"` on the field). - The converter captures top-level string property values while shape- reading and selects the case whose tag matches the JSON value; unions without tags (and unknown future tag values) fall back to the previous shape-based resolution, so behavior is strictly corrected, never narrowed. - The generator parses `always "X"` from required string field descriptions and emits the attribute; the checked-in generated Types.fs is updated accordingly (91 subtypes across ChatMember, MessageOrigin, ReactionType, PaidMedia, ChatBoostSource, BackgroundType/Fill, StoryAreaType, OwnedGift, RichText, PageBlock, ...). Tests: all six ChatMember statuses now parse to the right case (three failed before), round-trip no longer invents fields, unknown future statuses still parse (forward compat), and MessageOrigin/ReactionType keep working. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RrGX1qGPj7jLo1jgotgLKU --- .../Types/TypesGenerator.fs | 22 ++- src/Funogram.Telegram/Types.fs | 182 +++++++++--------- src/Funogram.Tests/Funogram.Tests.fsproj | 1 + src/Funogram.Tests/UnionDiscriminators.fs | 96 +++++++++ src/Funogram/Converters.fs | 84 ++++++-- src/Funogram/Types.fs | 12 ++ 6 files changed, 284 insertions(+), 113 deletions(-) create mode 100644 src/Funogram.Tests/UnionDiscriminators.fs diff --git a/src/Funogram.Generator/Types/TypesGenerator.fs b/src/Funogram.Generator/Types/TypesGenerator.fs index e496c54..dd539e9 100644 --- a/src/Funogram.Generator/Types/TypesGenerator.fs +++ b/src/Funogram.Generator/Types/TypesGenerator.fs @@ -163,9 +163,29 @@ let private generateFieldsCreateMember tp (fields: ApiTypeField[]) code = |> Code.setIndent 2 |> Code.printNewLine "}" +// Subtypes of tag-discriminated unions (ChatMember, MessageOrigin, ReactionType, ...) +// document their fixed discriminator as `always "X"` on a required string field. +// Emit it as a TelegramTag attribute so the union converter can select the case by +// the discriminator value (shape-identical subtypes like ChatMemberLeft vs +// ChatMemberMember cannot be discriminated any other way). +let private discriminatorRegex = + System.Text.RegularExpressions.Regex("""always [“"]([A-Za-z0-9_]+)[”"]""", + System.Text.RegularExpressions.RegexOptions.Compiled) + +let private discriminatorTag (fields: ApiTypeField[]) = + fields + |> Array.tryPick (fun f -> + if f.IsOptional || f.ConvertedFieldType <> "string" then None + else + let m = discriminatorRegex.Match f.Description + if m.Success then Some (f.OriginalName, m.Groups[1].Value) else None) + let private attributesForType tp = match tp.Kind with - | ApiTypeKind.Fields _ -> " []" + | ApiTypeKind.Fields fields -> + match discriminatorTag fields with + | Some (field, value) -> sprintf " []" field value + | None -> " []" | _ -> "" let generate config = diff --git a/src/Funogram.Telegram/Types.fs b/src/Funogram.Telegram/Types.fs index 74d0f29..a37c55e 100644 --- a/src/Funogram.Telegram/Types.fs +++ b/src/Funogram.Telegram/Types.fs @@ -1305,7 +1305,7 @@ and MessageOrigin = | Channel of MessageOriginChannel /// The message was originally sent by a known user. -and [] MessageOriginUser = +and [] MessageOriginUser = { /// Type of the message origin, always “user” [] @@ -1325,7 +1325,7 @@ and [] MessageOriginUser = } /// The message was originally sent by an unknown user. -and [] MessageOriginHiddenUser = +and [] MessageOriginHiddenUser = { /// Type of the message origin, always “hidden_user” [] @@ -1345,7 +1345,7 @@ and [] MessageOriginHiddenUser = } /// The message was originally sent on behalf of a chat to a group chat. -and [] MessageOriginChat = +and [] MessageOriginChat = { /// Type of the message origin, always “chat” [] @@ -1369,7 +1369,7 @@ and [] MessageOriginChat = } /// The message was originally sent to a channel chat. -and [] MessageOriginChannel = +and [] MessageOriginChannel = { /// Type of the message origin, always “channel” [] @@ -1772,7 +1772,7 @@ and PaidMedia = | Video of PaidMediaVideo /// The paid media is a live photo. -and [] PaidMediaLivePhoto = +and [] PaidMediaLivePhoto = { /// Type of the paid media, always “live_photo” [] @@ -1788,7 +1788,7 @@ and [] PaidMediaLivePhoto = } /// The paid media is a photo. -and [] PaidMediaPhoto = +and [] PaidMediaPhoto = { /// Type of the paid media, always “photo” [] @@ -1804,7 +1804,7 @@ and [] PaidMediaPhoto = } /// The paid media isn't available before the payment. -and [] PaidMediaPreview = +and [] PaidMediaPreview = { /// Type of the paid media, always “preview” [] @@ -1828,7 +1828,7 @@ and [] PaidMediaPreview = } /// The paid media is a video. -and [] PaidMediaVideo = +and [] PaidMediaVideo = { /// Type of the paid media, always “video” [] @@ -2516,7 +2516,7 @@ and BackgroundFill = | FreeformGradient of BackgroundFillFreeformGradient /// The background is filled using the selected color. -and [] BackgroundFillSolid = +and [] BackgroundFillSolid = { /// Type of the background fill, always “solid” [] @@ -2532,7 +2532,7 @@ and [] BackgroundFillSolid = } /// The background is a gradient fill. -and [] BackgroundFillGradient = +and [] BackgroundFillGradient = { /// Type of the background fill, always “gradient” [] @@ -2556,7 +2556,7 @@ and [] BackgroundFillGradient = } /// The background is a freeform gradient that rotates after every message in the chat. -and [] BackgroundFillFreeformGradient = +and [] BackgroundFillFreeformGradient = { /// Type of the background fill, always “freeform_gradient” [] @@ -2579,7 +2579,7 @@ and BackgroundType = | ChatTheme of BackgroundTypeChatTheme /// The background is automatically filled based on the selected colors. -and [] BackgroundTypeFill = +and [] BackgroundTypeFill = { /// Type of the background, always “fill” [] @@ -2599,7 +2599,7 @@ and [] BackgroundTypeFill = } /// The background is a wallpaper in the JPEG format. -and [] BackgroundTypeWallpaper = +and [] BackgroundTypeWallpaper = { /// Type of the background, always “wallpaper” [] @@ -2627,7 +2627,7 @@ and [] BackgroundTypeWallpaper = } /// The background is a .PNG or .TGV (gzipped subset of SVG with MIME type “application/x-tgwallpattern”) pattern to be combined with the background fill chosen by the user. -and [] BackgroundTypePattern = +and [] BackgroundTypePattern = { /// Type of the background, always “pattern” [] @@ -2659,7 +2659,7 @@ and [] BackgroundTypePattern = } /// The background is taken directly from a built-in chat theme. -and [] BackgroundTypeChatTheme = +and [] BackgroundTypeChatTheme = { /// Type of the background, always “chat_theme” [] @@ -3933,7 +3933,7 @@ and ChatMember = | Banned of ChatMemberBanned /// Represents a chat member that owns the chat and has all administrator privileges. -and [] ChatMemberOwner = +and [] ChatMemberOwner = { /// The member's status in the chat, always “creator” [] @@ -3957,7 +3957,7 @@ and [] ChatMemberOwner = } /// Represents a chat member that has some additional privileges. -and [] ChatMemberAdministrator = +and [] ChatMemberAdministrator = { /// The member's status in the chat, always “administrator” [] @@ -4053,7 +4053,7 @@ and [] ChatMemberAdministrator = } /// Represents a chat member that has no additional privileges or restrictions. -and [] ChatMemberMember = +and [] ChatMemberMember = { /// The member's status in the chat, always “member” [] @@ -4077,7 +4077,7 @@ and [] ChatMemberMember = } /// Represents a chat member that is under certain restrictions in the chat. Supergroups only. -and [] ChatMemberRestricted = +and [] ChatMemberRestricted = { /// The member's status in the chat, always “restricted” [] @@ -4169,7 +4169,7 @@ and [] ChatMemberRestricted = } /// Represents a chat member that isn't currently a member of the chat, but may join it themselves. -and [] ChatMemberLeft = +and [] ChatMemberLeft = { /// The member's status in the chat, always “left” [] @@ -4185,7 +4185,7 @@ and [] ChatMemberLeft = } /// Represents a chat member that was banned in the chat and can't return to the chat or view chat messages. -and [] ChatMemberBanned = +and [] ChatMemberBanned = { /// The member's status in the chat, always “kicked” [] @@ -4489,7 +4489,7 @@ and StoryAreaType = | UniqueGift of StoryAreaTypeUniqueGift /// Describes a story area pointing to a location. Currently, a story can have up to 10 location areas. -and [] StoryAreaTypeLocation = +and [] StoryAreaTypeLocation = { /// Type of the area, always “location” [] @@ -4513,7 +4513,7 @@ and [] StoryAreaTypeLocation = } /// Describes a story area pointing to a suggested reaction. Currently, a story can have up to 5 suggested reaction areas. -and [] StoryAreaTypeSuggestedReaction = +and [] StoryAreaTypeSuggestedReaction = { /// Type of the area, always “suggested_reaction” [] @@ -4537,7 +4537,7 @@ and [] StoryAreaTypeSuggestedReaction = } /// Describes a story area pointing to an HTTP or tg:// link. Currently, a story can have up to 3 link areas. -and [] StoryAreaTypeLink = +and [] StoryAreaTypeLink = { /// Type of the area, always “link” [] @@ -4553,7 +4553,7 @@ and [] StoryAreaTypeLink = } /// Describes a story area containing weather information. Currently, a story can have up to 3 weather areas. -and [] StoryAreaTypeWeather = +and [] StoryAreaTypeWeather = { /// Type of the area, always “weather” [] @@ -4577,7 +4577,7 @@ and [] StoryAreaTypeWeather = } /// Describes a story area pointing to a unique gift. Currently, a story can have at most 1 unique gift area. -and [] StoryAreaTypeUniqueGift = +and [] StoryAreaTypeUniqueGift = { /// Type of the area, always “unique_gift” [] @@ -4631,7 +4631,7 @@ and ReactionType = | Paid of ReactionTypePaid /// The reaction is based on an emoji. -and [] ReactionTypeEmoji = +and [] ReactionTypeEmoji = { /// Type of the reaction, always “emoji” [] @@ -4647,7 +4647,7 @@ and [] ReactionTypeEmoji = } /// The reaction is based on a custom emoji. -and [] ReactionTypeCustomEmoji = +and [] ReactionTypeCustomEmoji = { /// Type of the reaction, always “custom_emoji” [] @@ -4663,7 +4663,7 @@ and [] ReactionTypeCustomEmoji = } /// The reaction is paid. -and [] ReactionTypePaid = +and [] ReactionTypePaid = { /// Type of the reaction, always “paid” [] @@ -5136,7 +5136,7 @@ and OwnedGift = | Unique of OwnedGiftUnique /// Describes a regular gift owned by a user or a chat. -and [] OwnedGiftRegular = +and [] OwnedGiftRegular = { /// Type of the gift, always “regular” [] @@ -5204,7 +5204,7 @@ and [] OwnedGiftRegular = } /// Describes a unique gift received and owned by a user or a chat. -and [] OwnedGiftUnique = +and [] OwnedGiftUnique = { /// Type of the gift, always “unique” [] @@ -5547,7 +5547,7 @@ and ChatBoostSource = | Giveaway of ChatBoostSourceGiveaway /// The boost was obtained by subscribing to Telegram Premium or by gifting a Telegram Premium subscription to another user. -and [] ChatBoostSourcePremium = +and [] ChatBoostSourcePremium = { /// Source of the boost, always “premium” [] @@ -5563,7 +5563,7 @@ and [] ChatBoostSourcePremium = } /// The boost was obtained by the creation of Telegram Premium gift codes to boost a chat. Each such code boosts the chat 4 times for the duration of the corresponding Telegram Premium subscription. -and [] ChatBoostSourceGiftCode = +and [] ChatBoostSourceGiftCode = { /// Source of the boost, always “gift_code” [] @@ -5579,7 +5579,7 @@ and [] ChatBoostSourceGiftCode = } /// The boost was obtained by the creation of a Telegram Premium or a Telegram Star giveaway. This boosts the chat 4 times for the duration of the corresponding Telegram Premium subscription for Telegram Premium giveaways and prize_star_count / 500 times for one year for Telegram Star giveaways. -and [] ChatBoostSourceGiveaway = +and [] ChatBoostSourceGiveaway = { /// Source of the boost, always “giveaway” [] @@ -6670,7 +6670,7 @@ and RichText = | ArrayOf of RichText array /// A bold text. -and [] RichTextBold = +and [] RichTextBold = { /// Type of the rich text, always “bold” [] @@ -6686,7 +6686,7 @@ and [] RichTextBold = } /// An italicized text. -and [] RichTextItalic = +and [] RichTextItalic = { /// Type of the rich text, always “italic” [] @@ -6702,7 +6702,7 @@ and [] RichTextItalic = } /// An underlined text. -and [] RichTextUnderline = +and [] RichTextUnderline = { /// Type of the rich text, always “underline” [] @@ -6718,7 +6718,7 @@ and [] RichTextUnderline = } /// A strikethrough text. -and [] RichTextStrikethrough = +and [] RichTextStrikethrough = { /// Type of the rich text, always “strikethrough” [] @@ -6734,7 +6734,7 @@ and [] RichTextStrikethrough = } /// A text covered by a spoiler. -and [] RichTextSpoiler = +and [] RichTextSpoiler = { /// Type of the rich text, always “spoiler” [] @@ -6750,7 +6750,7 @@ and [] RichTextSpoiler = } /// Formatted date and time. -and [] RichTextDateTime = +and [] RichTextDateTime = { /// Type of the rich text, always “date_time” [] @@ -6774,7 +6774,7 @@ and [] RichTextDateTime = } /// A mention of a Telegram user by their identifier. -and [] RichTextTextMention = +and [] RichTextTextMention = { /// Type of the rich text, always “text_mention” [] @@ -6794,7 +6794,7 @@ and [] RichTextTextMention = } /// A subscript text. -and [] RichTextSubscript = +and [] RichTextSubscript = { /// Type of the rich text, always “subscript” [] @@ -6810,7 +6810,7 @@ and [] RichTextSubscript = } /// A superscript text. -and [] RichTextSuperscript = +and [] RichTextSuperscript = { /// Type of the rich text, always “superscript” [] @@ -6826,7 +6826,7 @@ and [] RichTextSuperscript = } /// A marked text. -and [] RichTextMarked = +and [] RichTextMarked = { /// Type of the rich text, always “marked” [] @@ -6842,7 +6842,7 @@ and [] RichTextMarked = } /// A monowidth text. -and [] RichTextCode = +and [] RichTextCode = { /// Type of the rich text, always “code” [] @@ -6858,7 +6858,7 @@ and [] RichTextCode = } /// A custom emoji. -and [] RichTextCustomEmoji = +and [] RichTextCustomEmoji = { /// Type of the rich text, always “custom_emoji” [] @@ -6878,7 +6878,7 @@ and [] RichTextCustomEmoji = } /// A mathematical expression. -and [] RichTextMathematicalExpression = +and [] RichTextMathematicalExpression = { /// Type of the rich text, always “mathematical_expression” [] @@ -6894,7 +6894,7 @@ and [] RichTextMathematicalExpression = } /// A text with a link. -and [] RichTextUrl = +and [] RichTextUrl = { /// Type of the rich text, always “url” [] @@ -6914,7 +6914,7 @@ and [] RichTextUrl = } /// A text with an email address. -and [] RichTextEmailAddress = +and [] RichTextEmailAddress = { /// Type of the rich text, always “email_address” [] @@ -6934,7 +6934,7 @@ and [] RichTextEmailAddress = } /// A text with a phone number. -and [] RichTextPhoneNumber = +and [] RichTextPhoneNumber = { /// Type of the rich text, always “phone_number” [] @@ -6954,7 +6954,7 @@ and [] RichTextPhoneNumber = } /// A text with a bank card number. -and [] RichTextBankCardNumber = +and [] RichTextBankCardNumber = { /// Type of the rich text, always “bank_card_number” [] @@ -6974,7 +6974,7 @@ and [] RichTextBankCardNumber = } /// A mention by a username. -and [] RichTextMention = +and [] RichTextMention = { /// Type of the rich text, always “mention” [] @@ -6994,7 +6994,7 @@ and [] RichTextMention = } /// A hashtag. -and [] RichTextHashtag = +and [] RichTextHashtag = { /// Type of the rich text, always “hashtag” [] @@ -7014,7 +7014,7 @@ and [] RichTextHashtag = } /// A cashtag. -and [] RichTextCashtag = +and [] RichTextCashtag = { /// Type of the rich text, always “cashtag” [] @@ -7034,7 +7034,7 @@ and [] RichTextCashtag = } /// A bot command. -and [] RichTextBotCommand = +and [] RichTextBotCommand = { /// Type of the rich text, always “bot_command” [] @@ -7054,7 +7054,7 @@ and [] RichTextBotCommand = } /// An anchor. -and [] RichTextAnchor = +and [] RichTextAnchor = { /// Type of the rich text, always “anchor” [] @@ -7070,7 +7070,7 @@ and [] RichTextAnchor = } /// A link to an anchor. -and [] RichTextAnchorLink = +and [] RichTextAnchorLink = { /// Type of the rich text, always “anchor_link” [] @@ -7090,7 +7090,7 @@ and [] RichTextAnchorLink = } /// A reference. -and [] RichTextReference = +and [] RichTextReference = { /// Type of the rich text, always “reference” [] @@ -7110,7 +7110,7 @@ and [] RichTextReference = } /// A link to a reference. -and [] RichTextReferenceLink = +and [] RichTextReferenceLink = { /// Type of the rich text, always “reference_link” [] @@ -7234,7 +7234,7 @@ and RichBlock = | Thinking of RichBlockThinking /// A text paragraph, corresponding to the HTML tag

. -and [] RichBlockParagraph = +and [] RichBlockParagraph = { /// Type of the block, always “paragraph” [] @@ -7250,7 +7250,7 @@ and [] RichBlockParagraph = } /// A section heading, corresponding to the HTML tags

,

,

,

,

, or
. -and [] RichBlockSectionHeading = +and [] RichBlockSectionHeading = { /// Type of the block, always “heading” [] @@ -7270,7 +7270,7 @@ and [] RichBlockSectionHeading = } /// A preformatted text block, corresponding to the nested HTML tags
 and .
-and [] RichBlockPreformatted =
+and [] RichBlockPreformatted =
   {
     /// Type of the block, always “pre”
     []
@@ -7290,7 +7290,7 @@ and [] RichBlockPreformatted =
     }
 
 /// A footer, corresponding to the HTML tag