diff --git a/.claude/skills/update-api/SKILL.md b/.claude/skills/update-api/SKILL.md index 105df17..1f76349 100644 --- a/.claude/skills/update-api/SKILL.md +++ b/.claude/skills/update-api/SKILL.md @@ -16,13 +16,21 @@ Update generated types and API endpoints from the latest Telegram Bot API docume ## Step 2: Parse -Run both parse tasks to scrape the latest Telegram Bot API docs: +Download the docs once and parse both types and methods from that snapshot: ``` -bundle exec rake parse:types -bundle exec rake parse:methods +bundle exec rake parse:all ``` +`parse:all` is `parse:fetch` (cache the page under `tmp/api.html`) followed by +`parse:types` and `parse:methods`. Re-run `parse:types` / `parse:methods` alone to +re-parse the cached page without hitting the network. + +`parse:methods` **aborts** if any documented method's return type cannot be read. +That is a parser bug, not a missing endpoint — a new phrasing has appeared in the +docs and `rakelib/parsers/return_type_rules.rb` needs a rule for it. Do not proceed +with a partial `data/methods.json`. + ## Step 3: Rebuild Before rebuilding, read the custom method files listed in Step 4 so you have a snapshot of their current content. @@ -68,3 +76,18 @@ bundle exec rake spec ``` If rubocop or tests fail, fix the issues before finishing. + +## Step 7: Verify the parser did not drift + +When the intent is *only* to pick up a new Bot API version, the parser changes in +`data/` should be exactly the documented additions. If a re-parse of an unchanged +docs page produces any diff at all, the parser has drifted and that must be +understood before shipping: + +``` +bundle exec rake parse:all +git diff data/ +``` + +This is the regression test for `rakelib/docs/` and `rakelib/parsers/`; it needs +network, so it is not part of CI. diff --git a/.rubocop.yml b/.rubocop.yml index 73cb9a3..8bff107 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -15,6 +15,7 @@ Metrics/BlockLength: AllowedMethods: - context - describe + - namespace - task Metrics/ClassLength: @@ -22,24 +23,10 @@ Metrics/ClassLength: - lib/telegram/bot/api/endpoints.rb - lib/telegram/bot/types/message.rb - rakelib/builders/type_builder.rb - - rakelib/parsers/**/* Metrics/MethodLength: Exclude: - spec/**/* - - rakelib/parsers/**/* - -Metrics/AbcSize: - Exclude: - - rakelib/parsers/**/* - -Metrics/CyclomaticComplexity: - Exclude: - - rakelib/parsers/**/* - -Metrics/PerceivedComplexity: - Exclude: - - rakelib/parsers/**/* Layout/LineLength: Max: 120 diff --git a/Gemfile b/Gemfile index e7a0b9c..421bde8 100644 --- a/Gemfile +++ b/Gemfile @@ -16,7 +16,3 @@ gem 'rubocop', '~> 1.66.1' gem 'rubocop-performance', '~> 1.18' gem 'rubocop-rake', '~> 0.6.0' gem 'rubocop-rspec', '~> 3.1.0' - -group :development do - gem 'openapi3_parser', '~> 0.9.2' -end diff --git a/Rakefile b/Rakefile index e56f475..3c79b6b 100644 --- a/Rakefile +++ b/Rakefile @@ -8,7 +8,7 @@ require 'rspec/core/rake_task' RuboCop::RakeTask.new(:rubocop) do |task| task.fail_on_error = false task.options = %w[--force-exclusion] - task.patterns = %w[{lib,spec}/**/*.rb Rakefile] + task.patterns = %w[{lib,rakelib,spec}/**/*.rb Rakefile rakelib/**/*.rake] task.requires << 'rubocop-rspec' end diff --git a/data/methods.json b/data/methods.json index 1b51f3f..7717e25 100644 --- a/data/methods.json +++ b/data/methods.json @@ -63,6 +63,7 @@ "leaveChat": "Boolean", "getChat": "ChatFullInfo", "getChatAdministrators": "Array", + "getChatMemberCount": "Integer", "getChatMember": "ChatMember", "getUserPersonalChatMessages": "Array", "setChatStickerSet": "Boolean", diff --git a/data/types.json b/data/types.json index d8aa276..9bc7f35 100644 --- a/data/types.json +++ b/data/types.json @@ -81,6 +81,9 @@ }, "subscription": { "type": "BotSubscriptionUpdated" + }, + "stopped_message_generation": { + "type": "MessageGenerationStopped" } }, "WebhookInfo": { @@ -683,6 +686,9 @@ "community_chat_added": { "type": "CommunityChatAdded" }, + "community_chat_joined": { + "type": "CommunityChatJoined" + }, "community_chat_removed": { "type": "CommunityChatRemoved" }, @@ -942,7 +948,7 @@ }, "quote": { "type": "string", - "max_size": 1024 + "max_size_parsed": 1024 }, "quote_parse_mode": { "type": "string" @@ -961,6 +967,18 @@ "type": "string" } }, + "EphemeralMessageParameters": { + "receiver_user_id": { + "type": "integer", + "required": true + }, + "callback_query_id": { + "type": "string" + }, + "replace_callback_query_message": { + "type": "boolean" + } + }, "MessageOrigin": { "type": [ "MessageOriginUser", @@ -1684,7 +1702,7 @@ "type": "string", "required": true, "min_size": 1, - "max_size": 100 + "max_size_parsed": 100 }, "parse_mode": { "type": "string" @@ -1699,7 +1717,7 @@ "type": "string", "required": true, "min_size": 1, - "max_size": 255 + "max_size_parsed": 255 }, "parse_mode": { "type": "string" @@ -1828,6 +1846,19 @@ "required": true } }, + "MessageGenerationStopped": { + "chat": { + "type": "Chat", + "required": true + }, + "message_thread_id": { + "type": "integer" + }, + "draft_id": { + "type": "integer", + "required": true + } + }, "PollOptionAdded": { "poll_message": { "type": "MaybeInaccessibleMessage" @@ -2043,6 +2074,12 @@ "required": true } }, + "CommunityChatJoined": { + "community": { + "type": "Community", + "required": true + } + }, "CommunityChatRemoved": {}, "ForumTopicCreated": { "name": { @@ -2351,8 +2388,8 @@ "amount": { "type": "integer", "required": true, - "min_size": 5, - "max_size": 100000 + "min": 5, + "max": 100000 } }, "SuggestedPostInfo": { @@ -2459,6 +2496,9 @@ }, "selective": { "type": "boolean" + }, + "force_reply": { + "type": "boolean" } }, "KeyboardButton": { @@ -2591,6 +2631,9 @@ "items": "InlineKeyboardButton" }, "required": true + }, + "force_reply": { + "type": "boolean" } }, "InlineKeyboardButton": { @@ -2633,6 +2676,9 @@ }, "pay": { "type": "boolean" + }, + "disabled": { + "type": "DisabledButton" } }, "LoginUrl": { @@ -2675,6 +2721,7 @@ "max_size": 256 } }, + "DisabledButton": {}, "CallbackQuery": { "id": { "type": "string", @@ -2846,6 +2893,10 @@ }, "can_manage_tags": { "type": "boolean" + }, + "can_send_welcome_messages": { + "type": "boolean", + "required": true } }, "ChatMemberUpdated": { @@ -2985,6 +3036,10 @@ "can_manage_tags": { "type": "boolean" }, + "can_send_welcome_messages": { + "type": "boolean", + "required": true + }, "custom_title": { "type": "string" } @@ -3794,6 +3849,17 @@ "type": "string", "required": true }, + "text": { + "type": "string" + }, + "entities": { + "type": "array", + "items": "MessageEntity" + }, + "is_private": { + "type": "boolean", + "default": true + }, "last_resale_currency": { "type": "string" }, @@ -4407,7 +4473,7 @@ }, "caption": { "type": "string", - "max_size": 1024 + "max_size_parsed": 1024 }, "parse_mode": { "type": "string" @@ -4448,7 +4514,7 @@ }, "caption": { "type": "string", - "max_size": 1024 + "max_size_parsed": 1024 }, "parse_mode": { "type": "string" @@ -4483,7 +4549,7 @@ }, "caption": { "type": "string", - "max_size": 1024 + "max_size_parsed": 1024 }, "parse_mode": { "type": "string" @@ -4525,7 +4591,7 @@ }, "caption": { "type": "string", - "max_size": 1024 + "max_size_parsed": 1024 }, "parse_mode": { "type": "string" @@ -4573,7 +4639,7 @@ }, "caption": { "type": "string", - "max_size": 1024 + "max_size_parsed": 1024 }, "parse_mode": { "type": "string" @@ -4662,7 +4728,7 @@ }, "caption": { "type": "string", - "max_size": 1024 + "max_size_parsed": 1024 }, "parse_mode": { "type": "string" @@ -4703,7 +4769,7 @@ }, "caption": { "type": "string", - "max_size": 1024 + "max_size_parsed": 1024 }, "parse_mode": { "type": "string" @@ -5019,6 +5085,7 @@ "type": [ "InputMediaAnimation", "InputMediaAudio", + "InputMediaDocument", "InputMediaPhoto", "InputMediaVideo", "InputMediaVoiceNote" @@ -5026,6 +5093,42 @@ "required": true } }, + "RichMessageButton": { + "text": { + "type": "RichText", + "required": true + }, + "style": { + "type": "string" + }, + "url": { + "type": "string" + }, + "callback_data": { + "type": "string" + }, + "web_app": { + "type": "WebAppInfo" + }, + "login_url": { + "type": "LoginUrl" + }, + "switch_inline_query": { + "type": "string" + }, + "switch_inline_query_current_chat": { + "type": "string" + }, + "switch_inline_query_chosen_chat": { + "type": "SwitchInlineQueryChosenChat" + }, + "copy_text": { + "type": "CopyTextButton" + }, + "disabled": { + "type": "DisabledButton" + } + }, "RichText": { "type": [ "string", @@ -5051,6 +5154,7 @@ "RichTextHashtag", "RichTextCashtag", "RichTextBotCommand", + "RichTextButton", "RichTextAnchor", "RichTextAnchorLink", "RichTextReference", @@ -5357,6 +5461,18 @@ "required": true } }, + "RichTextButton": { + "type": { + "type": "string", + "required": true, + "required_value": "button", + "default": "button" + }, + "button": { + "type": "RichMessageButton", + "required": true + } + }, "RichTextAnchor": { "type": { "type": "string", @@ -5485,14 +5601,17 @@ "RichBlockAnchor", "RichBlockList", "RichBlockBlockQuotation", + "RichBlockExpandableBlockQuotation", "RichBlockPullQuotation", "RichBlockCollage", "RichBlockSlideshow", "RichBlockTable", "RichBlockDetails", "RichBlockMap", + "RichBlockButtons", "RichBlockAnimation", "RichBlockAudio", + "RichBlockDocument", "RichBlockPhoto", "RichBlockVideo", "RichBlockVoiceNote", @@ -5615,6 +5734,21 @@ "type": "RichText" } }, + "RichBlockExpandableBlockQuotation": { + "type": { + "type": "string", + "required": true, + "required_value": "expandable_blockquote", + "default": "expandable_blockquote" + }, + "text": { + "type": "RichText", + "required": true + }, + "credit": { + "type": "RichText" + } + }, "RichBlockPullQuotation": { "type": { "type": "string", @@ -5685,6 +5819,10 @@ "type": "boolean", "default": true }, + "is_compact": { + "type": "boolean", + "default": true + }, "caption": { "type": "RichText" } @@ -5737,6 +5875,22 @@ "type": "RichBlockCaption" } }, + "RichBlockButtons": { + "type": { + "type": "string", + "required": true, + "required_value": "buttons", + "default": "buttons" + }, + "buttons": { + "type": "array", + "items": "RichMessageButton", + "required": true + }, + "align": { + "type": "string" + } + }, "RichBlockAnimation": { "type": { "type": "string", @@ -5771,6 +5925,21 @@ "type": "RichBlockCaption" } }, + "RichBlockDocument": { + "type": { + "type": "string", + "required": true, + "required_value": "document", + "default": "document" + }, + "document": { + "type": "Document", + "required": true + }, + "caption": { + "type": "RichBlockCaption" + } + }, "RichBlockPhoto": { "type": { "type": "string", @@ -5869,14 +6038,17 @@ "InputRichBlockAnchor", "InputRichBlockList", "InputRichBlockBlockQuotation", + "InputRichBlockExpandableBlockQuotation", "InputRichBlockPullQuotation", "InputRichBlockCollage", "InputRichBlockSlideshow", "InputRichBlockTable", "InputRichBlockDetails", "InputRichBlockMap", + "InputRichBlockButtons", "InputRichBlockAnimation", "InputRichBlockAudio", + "InputRichBlockDocument", "InputRichBlockPhoto", "InputRichBlockVideo", "InputRichBlockVoiceNote", @@ -5999,6 +6171,21 @@ "type": "RichText" } }, + "InputRichBlockExpandableBlockQuotation": { + "type": { + "type": "string", + "required": true, + "required_value": "expandable_blockquote", + "default": "expandable_blockquote" + }, + "text": { + "type": "RichText", + "required": true + }, + "credit": { + "type": "RichText" + } + }, "InputRichBlockPullQuotation": { "type": { "type": "string", @@ -6069,6 +6256,10 @@ "type": "boolean", "default": true }, + "is_compact": { + "type": "boolean", + "default": true + }, "caption": { "type": "RichText" } @@ -6106,21 +6297,34 @@ "required": true }, "zoom": { - "type": "integer", - "required": true + "type": "integer" }, "width": { - "type": "integer", - "required": true + "type": "integer" }, "height": { - "type": "integer", - "required": true + "type": "integer" }, "caption": { "type": "RichBlockCaption" } }, + "InputRichBlockButtons": { + "type": { + "type": "string", + "required": true, + "required_value": "buttons", + "default": "buttons" + }, + "buttons": { + "type": "array", + "items": "RichMessageButton", + "required": true + }, + "align": { + "type": "string" + } + }, "InputRichBlockAnimation": { "type": { "type": "string", @@ -6151,6 +6355,21 @@ "type": "RichBlockCaption" } }, + "InputRichBlockDocument": { + "type": { + "type": "string", + "required": true, + "required_value": "document", + "default": "document" + }, + "document": { + "type": "InputMediaDocument", + "required": true + }, + "caption": { + "type": "RichBlockCaption" + } + }, "InputRichBlockPhoto": { "type": { "type": "string", @@ -6341,7 +6560,7 @@ }, "caption": { "type": "string", - "max_size": 1024 + "max_size_parsed": 1024 }, "parse_mode": { "type": "string" @@ -6397,7 +6616,7 @@ }, "caption": { "type": "string", - "max_size": 1024 + "max_size_parsed": 1024 }, "parse_mode": { "type": "string" @@ -6453,7 +6672,7 @@ }, "caption": { "type": "string", - "max_size": 1024 + "max_size_parsed": 1024 }, "parse_mode": { "type": "string" @@ -6501,7 +6720,7 @@ }, "caption": { "type": "string", - "max_size": 1024 + "max_size_parsed": 1024 }, "parse_mode": { "type": "string" @@ -6553,7 +6772,7 @@ }, "caption": { "type": "string", - "max_size": 1024 + "max_size_parsed": 1024 }, "parse_mode": { "type": "string" @@ -6596,7 +6815,7 @@ }, "caption": { "type": "string", - "max_size": 1024 + "max_size_parsed": 1024 }, "parse_mode": { "type": "string" @@ -6632,7 +6851,7 @@ }, "caption": { "type": "string", - "max_size": 1024 + "max_size_parsed": 1024 }, "parse_mode": { "type": "string" @@ -6696,8 +6915,8 @@ }, "live_period": { "type": "integer", - "min_size": 60, - "max_size": 86400 + "min": 60, + "max": 86400 }, "heading": { "type": "integer" @@ -6859,7 +7078,7 @@ }, "caption": { "type": "string", - "max_size": 1024 + "max_size_parsed": 1024 }, "parse_mode": { "type": "string" @@ -6898,7 +7117,7 @@ }, "caption": { "type": "string", - "max_size": 1024 + "max_size_parsed": 1024 }, "parse_mode": { "type": "string" @@ -6937,7 +7156,7 @@ }, "caption": { "type": "string", - "max_size": 1024 + "max_size_parsed": 1024 }, "parse_mode": { "type": "string" @@ -7002,7 +7221,7 @@ }, "caption": { "type": "string", - "max_size": 1024 + "max_size_parsed": 1024 }, "parse_mode": { "type": "string" @@ -7042,7 +7261,7 @@ }, "caption": { "type": "string", - "max_size": 1024 + "max_size_parsed": 1024 }, "parse_mode": { "type": "string" @@ -7082,7 +7301,7 @@ }, "caption": { "type": "string", - "max_size": 1024 + "max_size_parsed": 1024 }, "parse_mode": { "type": "string" @@ -7115,7 +7334,7 @@ }, "caption": { "type": "string", - "max_size": 1024 + "max_size_parsed": 1024 }, "parse_mode": { "type": "string" @@ -7179,8 +7398,8 @@ }, "live_period": { "type": "integer", - "min_size": 60, - "max_size": 86400 + "min": 60, + "max": 86400 }, "heading": { "type": "integer" diff --git a/lib/telegram/bot/api/endpoints.rb b/lib/telegram/bot/api/endpoints.rb index 90a17bd..3a7bd98 100644 --- a/lib/telegram/bot/api/endpoints.rb +++ b/lib/telegram/bot/api/endpoints.rb @@ -68,6 +68,7 @@ class Api 'leaveChat' => Types::Bool, 'getChat' => Types::ChatFullInfo, 'getChatAdministrators' => Types::Array.of(Types::ChatMember), + 'getChatMemberCount' => Types::Integer, 'getChatMember' => Types::ChatMember, 'getUserPersonalChatMessages' => Types::Array.of(Types::Message), 'setChatStickerSet' => Types::Bool, diff --git a/lib/telegram/bot/types/chat_administrator_rights.rb b/lib/telegram/bot/types/chat_administrator_rights.rb index 5a15ed6..5b24729 100644 --- a/lib/telegram/bot/types/chat_administrator_rights.rb +++ b/lib/telegram/bot/types/chat_administrator_rights.rb @@ -21,6 +21,7 @@ class ChatAdministratorRights < Base attribute? :can_manage_topics, Types::Bool attribute? :can_manage_direct_messages, Types::Bool attribute? :can_manage_tags, Types::Bool + attribute :can_send_welcome_messages, Types::Bool end end end diff --git a/lib/telegram/bot/types/chat_member_administrator.rb b/lib/telegram/bot/types/chat_member_administrator.rb index b959108..4c9f44a 100644 --- a/lib/telegram/bot/types/chat_member_administrator.rb +++ b/lib/telegram/bot/types/chat_member_administrator.rb @@ -24,6 +24,7 @@ class ChatMemberAdministrator < Base attribute? :can_manage_topics, Types::Bool attribute? :can_manage_direct_messages, Types::Bool attribute? :can_manage_tags, Types::Bool + attribute :can_send_welcome_messages, Types::Bool attribute? :custom_title, Types::String end end diff --git a/lib/telegram/bot/types/community_chat_joined.rb b/lib/telegram/bot/types/community_chat_joined.rb new file mode 100644 index 0000000..aaa4171 --- /dev/null +++ b/lib/telegram/bot/types/community_chat_joined.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Telegram + module Bot + module Types + class CommunityChatJoined < Base + attribute :community, Community + end + end + end +end diff --git a/lib/telegram/bot/types/disabled_button.rb b/lib/telegram/bot/types/disabled_button.rb new file mode 100644 index 0000000..2b358a1 --- /dev/null +++ b/lib/telegram/bot/types/disabled_button.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Telegram + module Bot + module Types + class DisabledButton < Base + end + end + end +end diff --git a/lib/telegram/bot/types/ephemeral_message_parameters.rb b/lib/telegram/bot/types/ephemeral_message_parameters.rb new file mode 100644 index 0000000..de20f67 --- /dev/null +++ b/lib/telegram/bot/types/ephemeral_message_parameters.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Telegram + module Bot + module Types + class EphemeralMessageParameters < Base + attribute :receiver_user_id, Types::Integer + attribute? :callback_query_id, Types::String + attribute? :replace_callback_query_message, Types::Bool + end + end + end +end diff --git a/lib/telegram/bot/types/game.rb b/lib/telegram/bot/types/game.rb index 42cdaba..be18324 100644 --- a/lib/telegram/bot/types/game.rb +++ b/lib/telegram/bot/types/game.rb @@ -7,7 +7,7 @@ class Game < Base attribute :title, Types::String attribute :description, Types::String attribute :photo, Types::Array.of(PhotoSize) - attribute? :text, Types::String + attribute? :text, Types::String.constrained(max_size: 4096) attribute? :text_entities, Types::Array.of(MessageEntity) attribute? :animation, Animation end diff --git a/lib/telegram/bot/types/inline_keyboard_button.rb b/lib/telegram/bot/types/inline_keyboard_button.rb index 0444d3b..b9f1908 100644 --- a/lib/telegram/bot/types/inline_keyboard_button.rb +++ b/lib/telegram/bot/types/inline_keyboard_button.rb @@ -17,6 +17,7 @@ class InlineKeyboardButton < Base attribute? :copy_text, CopyTextButton attribute? :callback_game, CallbackGame attribute? :pay, Types::Bool + attribute? :disabled, DisabledButton end end end diff --git a/lib/telegram/bot/types/inline_keyboard_markup.rb b/lib/telegram/bot/types/inline_keyboard_markup.rb index dbf21e8..1d4750c 100644 --- a/lib/telegram/bot/types/inline_keyboard_markup.rb +++ b/lib/telegram/bot/types/inline_keyboard_markup.rb @@ -5,6 +5,7 @@ module Bot module Types class InlineKeyboardMarkup < Base attribute :inline_keyboard, Types::Array.of(Types::Array.of(InlineKeyboardButton)) + attribute? :force_reply, Types::Bool def to_compact_hash hsh = super diff --git a/lib/telegram/bot/types/inline_query_result_location.rb b/lib/telegram/bot/types/inline_query_result_location.rb index 59677b7..faf29c2 100644 --- a/lib/telegram/bot/types/inline_query_result_location.rb +++ b/lib/telegram/bot/types/inline_query_result_location.rb @@ -10,7 +10,7 @@ class InlineQueryResultLocation < Base attribute :longitude, Types::Float attribute :title, Types::String attribute? :horizontal_accuracy, Types::Float - attribute? :live_period, Types::Integer.constrained(min_size: 60, max_size: 86_400) + attribute? :live_period, Types::Integer.constrained(gteq: 60, lteq: 86_400) attribute? :heading, Types::Integer attribute? :proximity_alert_radius, Types::Integer attribute? :reply_markup, InlineKeyboardMarkup diff --git a/lib/telegram/bot/types/input_checklist.rb b/lib/telegram/bot/types/input_checklist.rb index 7ec2936..fff7ed5 100644 --- a/lib/telegram/bot/types/input_checklist.rb +++ b/lib/telegram/bot/types/input_checklist.rb @@ -4,7 +4,7 @@ module Telegram module Bot module Types class InputChecklist < Base - attribute :title, Types::String.constrained(min_size: 1, max_size: 255) + attribute :title, Types::String.constrained(min_size: 1) attribute? :parse_mode, Types::String attribute? :title_entities, Types::Array.of(MessageEntity) attribute :tasks, Types::Array.of(InputChecklistTask) diff --git a/lib/telegram/bot/types/input_checklist_task.rb b/lib/telegram/bot/types/input_checklist_task.rb index c8c7cc2..f08d579 100644 --- a/lib/telegram/bot/types/input_checklist_task.rb +++ b/lib/telegram/bot/types/input_checklist_task.rb @@ -5,7 +5,7 @@ module Bot module Types class InputChecklistTask < Base attribute :id, Types::Integer - attribute :text, Types::String.constrained(min_size: 1, max_size: 100) + attribute :text, Types::String.constrained(min_size: 1) attribute? :parse_mode, Types::String attribute? :text_entities, Types::Array.of(MessageEntity) end diff --git a/lib/telegram/bot/types/input_location_message_content.rb b/lib/telegram/bot/types/input_location_message_content.rb index 23d21b8..cfa3d66 100644 --- a/lib/telegram/bot/types/input_location_message_content.rb +++ b/lib/telegram/bot/types/input_location_message_content.rb @@ -7,7 +7,7 @@ class InputLocationMessageContent < Base attribute :latitude, Types::Float attribute :longitude, Types::Float attribute? :horizontal_accuracy, Types::Float - attribute? :live_period, Types::Integer.constrained(min_size: 60, max_size: 86_400) + attribute? :live_period, Types::Integer.constrained(gteq: 60, lteq: 86_400) attribute? :heading, Types::Integer attribute? :proximity_alert_radius, Types::Integer end diff --git a/lib/telegram/bot/types/input_rich_block.rb b/lib/telegram/bot/types/input_rich_block.rb index 29e50f0..a50a00b 100644 --- a/lib/telegram/bot/types/input_rich_block.rb +++ b/lib/telegram/bot/types/input_rich_block.rb @@ -15,14 +15,17 @@ module Types InputRichBlockAnchor | InputRichBlockList | InputRichBlockBlockQuotation | + InputRichBlockExpandableBlockQuotation | InputRichBlockPullQuotation | InputRichBlockCollage | InputRichBlockSlideshow | InputRichBlockTable | InputRichBlockDetails | InputRichBlockMap | + InputRichBlockButtons | InputRichBlockAnimation | InputRichBlockAudio | + InputRichBlockDocument | InputRichBlockPhoto | InputRichBlockVideo | InputRichBlockVoiceNote | diff --git a/lib/telegram/bot/types/input_rich_block_buttons.rb b/lib/telegram/bot/types/input_rich_block_buttons.rb new file mode 100644 index 0000000..ef3d78f --- /dev/null +++ b/lib/telegram/bot/types/input_rich_block_buttons.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Telegram + module Bot + module Types + class InputRichBlockButtons < Base + attribute :type, Types::String.constrained(eql: 'buttons').default('buttons') + attribute :buttons, Types::Array.of(RichMessageButton) + attribute? :align, Types::String + end + end + end +end diff --git a/lib/telegram/bot/types/input_rich_block_document.rb b/lib/telegram/bot/types/input_rich_block_document.rb new file mode 100644 index 0000000..76e6116 --- /dev/null +++ b/lib/telegram/bot/types/input_rich_block_document.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Telegram + module Bot + module Types + class InputRichBlockDocument < Base + attribute :type, Types::String.constrained(eql: 'document').default('document') + attribute :document, InputMediaDocument + attribute? :caption, RichBlockCaption + end + end + end +end diff --git a/lib/telegram/bot/types/input_rich_block_expandable_block_quotation.rb b/lib/telegram/bot/types/input_rich_block_expandable_block_quotation.rb new file mode 100644 index 0000000..63f1b3d --- /dev/null +++ b/lib/telegram/bot/types/input_rich_block_expandable_block_quotation.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Telegram + module Bot + module Types + class InputRichBlockExpandableBlockQuotation < Base + attribute :type, Types::String.constrained(eql: 'expandable_blockquote').default('expandable_blockquote') + attribute :text, RichText + attribute? :credit, RichText + end + end + end +end diff --git a/lib/telegram/bot/types/input_rich_block_map.rb b/lib/telegram/bot/types/input_rich_block_map.rb index 06071fb..3ed300e 100644 --- a/lib/telegram/bot/types/input_rich_block_map.rb +++ b/lib/telegram/bot/types/input_rich_block_map.rb @@ -6,9 +6,9 @@ module Types class InputRichBlockMap < Base attribute :type, Types::String.constrained(eql: 'map').default('map') attribute :location, Location - attribute :zoom, Types::Integer - attribute :width, Types::Integer - attribute :height, Types::Integer + attribute? :zoom, Types::Integer + attribute? :width, Types::Integer + attribute? :height, Types::Integer attribute? :caption, RichBlockCaption end end diff --git a/lib/telegram/bot/types/input_rich_block_table.rb b/lib/telegram/bot/types/input_rich_block_table.rb index 0179e33..6ff663a 100644 --- a/lib/telegram/bot/types/input_rich_block_table.rb +++ b/lib/telegram/bot/types/input_rich_block_table.rb @@ -8,6 +8,7 @@ class InputRichBlockTable < Base attribute :cells, Types::Array.of(Types::Array.of(RichBlockTableCell)) attribute? :is_bordered, Types::True attribute? :is_striped, Types::True + attribute? :is_compact, Types::True attribute? :caption, RichText end end diff --git a/lib/telegram/bot/types/input_rich_message_media.rb b/lib/telegram/bot/types/input_rich_message_media.rb index b0058b1..a18efa2 100644 --- a/lib/telegram/bot/types/input_rich_message_media.rb +++ b/lib/telegram/bot/types/input_rich_message_media.rb @@ -6,7 +6,8 @@ module Types class InputRichMessageMedia < Base attribute :id, Types::String.constrained(min_size: 1, max_size: 64) attribute :media, - InputMediaAnimation | InputMediaAudio | InputMediaPhoto | InputMediaVideo | InputMediaVoiceNote + InputMediaAnimation | InputMediaAudio | InputMediaDocument | InputMediaPhoto | InputMediaVideo | + InputMediaVoiceNote end end end diff --git a/lib/telegram/bot/types/message.rb b/lib/telegram/bot/types/message.rb index 09e2c3d..e5c2e24 100644 --- a/lib/telegram/bot/types/message.rb +++ b/lib/telegram/bot/types/message.rb @@ -96,6 +96,7 @@ class Message < Base attribute? :checklist_tasks_done, ChecklistTasksDone attribute? :checklist_tasks_added, ChecklistTasksAdded attribute? :community_chat_added, CommunityChatAdded + attribute? :community_chat_joined, CommunityChatJoined attribute? :community_chat_removed, CommunityChatRemoved attribute? :direct_message_price_changed, DirectMessagePriceChanged attribute? :forum_topic_created, ForumTopicCreated diff --git a/lib/telegram/bot/types/message_generation_stopped.rb b/lib/telegram/bot/types/message_generation_stopped.rb new file mode 100644 index 0000000..be6c784 --- /dev/null +++ b/lib/telegram/bot/types/message_generation_stopped.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Telegram + module Bot + module Types + class MessageGenerationStopped < Base + attribute :chat, Chat + attribute? :message_thread_id, Types::Integer + attribute :draft_id, Types::Integer + end + end + end +end diff --git a/lib/telegram/bot/types/poll.rb b/lib/telegram/bot/types/poll.rb index fb3ba72..92d4942 100644 --- a/lib/telegram/bot/types/poll.rb +++ b/lib/telegram/bot/types/poll.rb @@ -17,7 +17,7 @@ class Poll < Base attribute :members_only, Types::Bool attribute? :country_codes, Types::Array.of(Types::String) attribute? :correct_option_ids, Types::Array.of(Types::Integer) - attribute? :explanation, Types::String + attribute? :explanation, Types::String.constrained(max_size: 200) attribute? :explanation_entities, Types::Array.of(MessageEntity) attribute? :explanation_media, PollMedia attribute? :open_period, Types::Integer diff --git a/lib/telegram/bot/types/reply_keyboard_markup.rb b/lib/telegram/bot/types/reply_keyboard_markup.rb index afddecc..3ab9262 100644 --- a/lib/telegram/bot/types/reply_keyboard_markup.rb +++ b/lib/telegram/bot/types/reply_keyboard_markup.rb @@ -10,6 +10,7 @@ class ReplyKeyboardMarkup < Base attribute? :one_time_keyboard, Types::Bool.default(false) attribute? :input_field_placeholder, Types::String.constrained(min_size: 1, max_size: 64) attribute? :selective, Types::Bool + attribute? :force_reply, Types::Bool def to_compact_hash hsh = super diff --git a/lib/telegram/bot/types/rich_block.rb b/lib/telegram/bot/types/rich_block.rb index dbed01b..0637b4c 100644 --- a/lib/telegram/bot/types/rich_block.rb +++ b/lib/telegram/bot/types/rich_block.rb @@ -15,14 +15,17 @@ module Types RichBlockAnchor | RichBlockList | RichBlockBlockQuotation | + RichBlockExpandableBlockQuotation | RichBlockPullQuotation | RichBlockCollage | RichBlockSlideshow | RichBlockTable | RichBlockDetails | RichBlockMap | + RichBlockButtons | RichBlockAnimation | RichBlockAudio | + RichBlockDocument | RichBlockPhoto | RichBlockVideo | RichBlockVoiceNote | diff --git a/lib/telegram/bot/types/rich_block_buttons.rb b/lib/telegram/bot/types/rich_block_buttons.rb new file mode 100644 index 0000000..2b38fc6 --- /dev/null +++ b/lib/telegram/bot/types/rich_block_buttons.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Telegram + module Bot + module Types + class RichBlockButtons < Base + attribute :type, Types::String.constrained(eql: 'buttons').default('buttons') + attribute :buttons, Types::Array.of(RichMessageButton) + attribute? :align, Types::String + end + end + end +end diff --git a/lib/telegram/bot/types/rich_block_document.rb b/lib/telegram/bot/types/rich_block_document.rb new file mode 100644 index 0000000..64d95c3 --- /dev/null +++ b/lib/telegram/bot/types/rich_block_document.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Telegram + module Bot + module Types + class RichBlockDocument < Base + attribute :type, Types::String.constrained(eql: 'document').default('document') + attribute :document, Document + attribute? :caption, RichBlockCaption + end + end + end +end diff --git a/lib/telegram/bot/types/rich_block_expandable_block_quotation.rb b/lib/telegram/bot/types/rich_block_expandable_block_quotation.rb new file mode 100644 index 0000000..0be66be --- /dev/null +++ b/lib/telegram/bot/types/rich_block_expandable_block_quotation.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Telegram + module Bot + module Types + class RichBlockExpandableBlockQuotation < Base + attribute :type, Types::String.constrained(eql: 'expandable_blockquote').default('expandable_blockquote') + attribute :text, RichText + attribute? :credit, RichText + end + end + end +end diff --git a/lib/telegram/bot/types/rich_block_table.rb b/lib/telegram/bot/types/rich_block_table.rb index 1878c48..31a6739 100644 --- a/lib/telegram/bot/types/rich_block_table.rb +++ b/lib/telegram/bot/types/rich_block_table.rb @@ -8,6 +8,7 @@ class RichBlockTable < Base attribute :cells, Types::Array.of(Types::Array.of(RichBlockTableCell)) attribute? :is_bordered, Types::True attribute? :is_striped, Types::True + attribute? :is_compact, Types::True attribute? :caption, RichText end end diff --git a/lib/telegram/bot/types/rich_message_button.rb b/lib/telegram/bot/types/rich_message_button.rb new file mode 100644 index 0000000..5ede79b --- /dev/null +++ b/lib/telegram/bot/types/rich_message_button.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Telegram + module Bot + module Types + class RichMessageButton < Base + attribute :text, Types.deferred(:RichText) + attribute? :style, Types::String + attribute? :url, Types::String + attribute? :callback_data, Types::String + attribute? :web_app, WebAppInfo + attribute? :login_url, LoginUrl + attribute? :switch_inline_query, Types::String + attribute? :switch_inline_query_current_chat, Types::String + attribute? :switch_inline_query_chosen_chat, SwitchInlineQueryChosenChat + attribute? :copy_text, CopyTextButton + attribute? :disabled, DisabledButton + end + end + end +end diff --git a/lib/telegram/bot/types/rich_text.rb b/lib/telegram/bot/types/rich_text.rb index 2ec5d12..bce175b 100644 --- a/lib/telegram/bot/types/rich_text.rb +++ b/lib/telegram/bot/types/rich_text.rb @@ -29,6 +29,7 @@ module Types RichTextHashtag | RichTextCashtag | RichTextBotCommand | + RichTextButton | RichTextAnchor | RichTextAnchorLink | RichTextReference | diff --git a/lib/telegram/bot/types/rich_text_button.rb b/lib/telegram/bot/types/rich_text_button.rb new file mode 100644 index 0000000..061fb1a --- /dev/null +++ b/lib/telegram/bot/types/rich_text_button.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Telegram + module Bot + module Types + class RichTextButton < Base + attribute :type, Types::String.constrained(eql: 'button').default('button') + attribute :button, RichMessageButton + end + end + end +end diff --git a/lib/telegram/bot/types/suggested_post_price.rb b/lib/telegram/bot/types/suggested_post_price.rb index 7e26de3..8288bbd 100644 --- a/lib/telegram/bot/types/suggested_post_price.rb +++ b/lib/telegram/bot/types/suggested_post_price.rb @@ -5,7 +5,7 @@ module Bot module Types class SuggestedPostPrice < Base attribute :currency, Types::String - attribute :amount, Types::Integer.constrained(min_size: 5, max_size: 100_000) + attribute :amount, Types::Integer.constrained(gteq: 5, lteq: 100_000) end end end diff --git a/lib/telegram/bot/types/unique_gift_info.rb b/lib/telegram/bot/types/unique_gift_info.rb index 6abfc1a..5c939af 100644 --- a/lib/telegram/bot/types/unique_gift_info.rb +++ b/lib/telegram/bot/types/unique_gift_info.rb @@ -6,6 +6,9 @@ module Types class UniqueGiftInfo < Base attribute :gift, UniqueGift attribute :origin, Types::String + attribute? :text, Types::String + attribute? :entities, Types::Array.of(MessageEntity) + attribute? :is_private, Types::True attribute? :last_resale_currency, Types::String attribute? :last_resale_amount, Types::Integer attribute? :owned_gift_id, Types::String diff --git a/lib/telegram/bot/types/update.rb b/lib/telegram/bot/types/update.rb index cb979c6..5be7a78 100644 --- a/lib/telegram/bot/types/update.rb +++ b/lib/telegram/bot/types/update.rb @@ -31,6 +31,7 @@ class Update < Base attribute? :removed_chat_boost, ChatBoostRemoved attribute? :managed_bot, ManagedBotUpdated attribute? :subscription, BotSubscriptionUpdated + attribute? :stopped_message_generation, MessageGenerationStopped def current_message @current_message ||= diff --git a/rakelib/builders/type_builder.rb b/rakelib/builders/type_builder.rb index 407c754..493326d 100644 --- a/rakelib/builders/type_builder.rb +++ b/rakelib/builders/type_builder.rb @@ -100,13 +100,30 @@ def apply_required!(attr_name, properties, original_type) attributes[attr_name][:type] += ".constrained(eql: #{typecast(original_type, properties[:required_value])})" end + # String length limits use min_size/max_size; numeric ranges use + # gteq/lteq, because dry-types measures `size` and Integer#size is the + # byte width, which made every numeric range vacuously true. + CONSTRAINTS = { min_size: :min_size, max_size: :max_size, min: :gteq, max: :lteq }.freeze + def apply_min_max!(attr_name, properties) - return unless properties[:min_size] || properties[:max_size] + constrained = CONSTRAINTS.filter_map do |key, predicate| + "#{predicate}: #{numeral(properties[key])}" if properties[key] + end + return if constrained.empty? + + attributes[attr_name][:type] += ".constrained(#{constrained.join(', ')})" + end + + # Emit 86_400 rather than 86400, so generated files satisfy + # Style/NumericLiterals without a manual rubocop pass. Its MinDigits is 5, + # so shorter numbers are left alone. + NUMERIC_LITERAL_MIN_DIGITS = 5 + + def numeral(value) + digits = value.to_s + return digits if digits.length < NUMERIC_LITERAL_MIN_DIGITS - constrain = '.constrained(minmax)' - constrain = properties[:min_size] ? constrain.gsub('min', "min_size: #{properties[:min_size]}, ") : '' - constrain = constrain.gsub('max', "max_size: #{properties[:max_size]}") if properties[:max_size] - attributes[attr_name][:type] += constrain + digits.reverse.scan(/\d{1,3}/).join('_').reverse end def apply_default!(attr_name, properties, original_type) diff --git a/rakelib/docs.rb b/rakelib/docs.rb new file mode 100644 index 0000000..be53ce9 --- /dev/null +++ b/rakelib/docs.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require_relative 'docs/error' +require_relative 'docs/marks' +require_relative 'docs/text' +require_relative 'docs/table' +require_relative 'docs/section' +require_relative 'docs/source' +require_relative 'docs/page' diff --git a/rakelib/docs/error.rb b/rakelib/docs/error.rb new file mode 100644 index 0000000..c5b7ed2 --- /dev/null +++ b/rakelib/docs/error.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Docs + # Raised when the documentation no longer has the shape the parsers expect. + # Structural surprises are loud on purpose: a silent miss is how + # getChatMemberCount went missing from the generated endpoints. + Error = Class.new(StandardError) +end diff --git a/rakelib/docs/marks.rb b/rakelib/docs/marks.rb new file mode 100644 index 0000000..e254a5b --- /dev/null +++ b/rakelib/docs/marks.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +module Docs + # An index of a node's inline markup: which tag sits at which offset into + # the node's plain text. This is what lets a prose query be anchored to a + # phrase ("the right after 'must be'") without regexing raw HTML. + class Marks + include Enumerable + + Mark = Struct.new(:offset, :tag, :value) + + INLINE = %w[a em strong code].freeze + + def initialize(node) + @marks = [] + index(node, 0) + @marks.freeze + end + + def each(&block) + @marks.each(&block) + end + + def values_for(tag) + select { |mark| mark.tag == tag }.map(&:value) + end + + def last_before(tag, offset) + reverse_each.find { |mark| mark.tag == tag && mark.offset < offset } + end + + def first_from(offset) + find { |mark| mark.offset >= offset } + end + + def first_from_with_tag(tag, offset) + find { |mark| mark.tag == tag && mark.offset >= offset } + end + + private + + def index(node, offset) + node.children.each { |child| offset = index_child(child, offset) } + offset + end + + # Zero-length elements contribute nothing: the page carries 630 empty + # and nodes, and counting them + # would shift every offset that follows. + def index_child(child, offset) + length = child.text.length + return offset + length if child.text? || length.zero? + + if INLINE.include?(child.name) + @marks << Mark.new(offset, child.name, child.text.strip) + else + index(child, offset) + end + + offset + length + end + end +end diff --git a/rakelib/docs/page.rb b/rakelib/docs/page.rb new file mode 100644 index 0000000..641d994 --- /dev/null +++ b/rakelib/docs/page.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'nokogiri' + +require_relative 'error' +require_relative 'section' +require_relative 'source' + +module Docs + # The Bot API documentation page. + # + # Its content is a flat stream of sibling elements -- 601 h4, 676 p, 544 + # table, nothing nested -- so the only structure worth modelling is where + # one heading's run of nodes ends and the next begins. + class Page + CONTENT_SELECTOR = '#dev_page_content' + HEADING = /\Ah([1-6])\z/.freeze + + def self.load(source = Source.new) + new(source.html) + end + + def initialize(html) + @root = Nokogiri::HTML(html).at(CONTENT_SELECTOR) || + raise(Error, "#{CONTENT_SELECTOR} not found; the docs layout changed") + end + + # Every section introduced by a heading at `level`, in document order. + # Slicing before every heading is what makes an

close an open

+ # section without needing a level stack. + def sections(level: 4) + @root.element_children + .slice_before { |node| heading_level(node) } + .select { |(heading, *)| heading_level(heading) == level } + .map { |(heading, *nodes)| Section.new(heading, nodes) } + end + + private + + def heading_level(node) + found = HEADING.match(node.name) + found && found[1].to_i + end + end +end diff --git a/rakelib/docs/section.rb b/rakelib/docs/section.rb new file mode 100644 index 0000000..ca68e40 --- /dev/null +++ b/rakelib/docs/section.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require_relative 'table' +require_relative 'text' + +module Docs + # A heading plus the flat run of sibling nodes that follows it, up to the + # next heading. This is the concept the parsers previously re-derived by + # hand in four separate next_element walks. + class Section + attr_reader :heading, :nodes + + def initialize(heading, nodes = []) + @heading = heading + @nodes = nodes + end + + def title + @title ||= heading.text.strip + end + + def level + @level ||= heading.name[1].to_i + end + + def paragraphs + @paragraphs ||= nodes.select { |node| node.name == 'p' }.map { |node| Text.new(node) } + end + + def intro + paragraphs.first + end + + def table + return @table if defined?(@table) + + node = nodes.find { |candidate| candidate.name == 'table' } + @table = node && Table.new(node) + end + + def items + @items ||= begin + list = nodes.find { |node| node.name == 'ul' } + list ? list.css('li').map { |item| Text.new(item) } : [] + end + end + end +end diff --git a/rakelib/docs/source.rb b/rakelib/docs/source.rb new file mode 100644 index 0000000..ccedee1 --- /dev/null +++ b/rakelib/docs/source.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require 'net/http' +require 'fileutils' + +module Docs + # Fetches the Bot API documentation page and caches it under tmp/, so that + # a parse run makes one request instead of one per parser, and re-running + # during development costs nothing. The cache never expires on its own; + # `rake parse:fetch` is the explicit refresh. + class Source + URL = 'https://core.telegram.org/bots/api' + CACHE_PATH = File.expand_path('../../tmp/api.html', __dir__) + + attr_reader :url, :cache_path + + def initialize(url: URL, cache_path: CACHE_PATH) + @url = url + @cache_path = cache_path + end + + def html + @html ||= cached || download + end + + def download + body = Net::HTTP.get(URI.parse(url)) + FileUtils.mkdir_p(File.dirname(cache_path)) + File.write(cache_path, body) + @html = body + end + + def cached? + File.exist?(cache_path) + end + + private + + def cached + cached? ? File.read(cache_path) : nil + end + end +end diff --git a/rakelib/docs/table.rb b/rakelib/docs/table.rb new file mode 100644 index 0000000..0a49199 --- /dev/null +++ b/rakelib/docs/table.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require_relative 'error' +require_relative 'text' + +module Docs + # A documentation table, addressed by column name rather than cell index. + # + # The page only ever uses two shapes -- "Field | Type | Description" and + # "Parameter | Type | Required | Description" -- so declaring the expected + # columns is cheap, and it turns a docs change from silently-wrong data + # into a failed parse. + class Table + include Enumerable + + attr_reader :columns + + def initialize(node) + @node = node + @columns = node.css('th').map { |cell| cell.text.strip }.freeze + end + + def expect_columns!(expected) + return self if columns == expected + + raise Error, "expected columns #{expected.inspect}, the docs now have #{columns.inspect}" + end + + # Yields each row as a Hash of column name => Docs::Text. + def each + @node.css('tbody tr').each do |row| + cells = row.css('td') + next if cells.empty? + + yield columns.zip(cells.map { |cell| Text.new(cell) }).to_h + end + end + end +end diff --git a/rakelib/docs/text.rb b/rakelib/docs/text.rb new file mode 100644 index 0000000..b81b24f --- /dev/null +++ b/rakelib/docs/text.rb @@ -0,0 +1,144 @@ +# frozen_string_literal: true + +require_relative 'marks' + +module Docs + # The text of a documentation node, queryable together with its inline + # markup. + # + # Markup is load-bearing: "Returns True" and "Returns Message" + # are indistinguishable once the tags are dropped. So is position -- + # paragraphs carry earlier prose links, so "the first link" is not the same + # as "the link after 'Returns'". + class Text + def initialize(node) + @node = node + end + + def raw + @raw ||= @node.text + end + + def to_s + @to_s ||= raw.strip + end + + def start_with?(*prefixes) + to_s.start_with?(*prefixes) + end + + def match(pattern) + to_s.match(pattern) + end + + def match?(pattern) + to_s.match?(pattern) + end + + def inspect + "#<#{self.class} #{to_s.inspect}>" + end + + def marks + @marks ||= Marks.new(@node) + end + + def links + marks.values_for('a') + end + + def emphasised + marks.values_for('em') + end + + # The immediately following `phrase`, with only whitespace between. + # Adjacency is the point: "The bot must be an administrator ... specify + # allowed_updates" must not read as a "must be X" + # constraint merely because an appears later in the sentence. + def after(phrase, tag) + each_occurrence(phrase) do |finish| + mark = marks.first_from(finish) + next unless mark && mark.tag == tag && raw[finish...mark.offset].strip.empty? + + return mark.value + end + nil + end + + # The first anywhere after `phrase`. Looser than `after`, for prose + # that puts arbitrary words in between, as in "Returns basic information + # about the bot in form of a User". + def following(phrase, tag) + index = start_of(phrase) + index && value_of(marks.first_from_with_tag(tag, index)) + end + + # The last that starts before `phrase`. + def before(phrase, tag) + index = start_of(phrase) + index && value_of(marks.last_before(tag, index)) + end + + def emphasised_after(phrase) + after(phrase, 'em') + end + + def link_after(phrase) + after(phrase, 'a') + end + + def link_following(phrase) + following(phrase, 'a') + end + + def link_before(phrase) + before(phrase, 'a') + end + + # always "photo" / always “photo” -> "photo" + def quoted_after(phrase) + found = to_s.match(/#{Regexp.escape(phrase)}\s+["“]([^"”]+)["”]/) + found && found[1] + end + + # Defaults to 100 -> "100" + def number_after(phrase) + found = to_s.match(/#{Regexp.escape(phrase)}\s+(\d+)/) + found && found[1] + end + + private + + def value_of(mark) + mark&.value + end + + # Offset where `phrase` (a String or Regexp) first occurs. + def start_of(phrase) + found = next_occurrence(phrase, 0) + found&.first + end + + # Yields the offset just past each occurrence of `phrase`. + def each_occurrence(phrase) + position = 0 + while (found = next_occurrence(phrase, position)) + yield found.last + position = found.first + 1 + end + end + + # [start, finish] of the next occurrence of `phrase` at or after `position`. + def next_occurrence(phrase, position) + return nil if position > raw.length + + if phrase.is_a?(Regexp) + found = phrase.match(raw, position) + found && [found.begin(0), found.end(0)] + else + index = raw.index(phrase, position) + index && [index, index + phrase.length] + end + end + end +end diff --git a/rakelib/parse.rake b/rakelib/parse.rake index c1c9ab6..8b8afca 100644 --- a/rakelib/parse.rake +++ b/rakelib/parse.rake @@ -1,10 +1,18 @@ # frozen_string_literal: true require 'json' +require_relative 'docs' require_relative 'parsers/types_parser' require_relative 'parsers/methods_parser' namespace :parse do + desc 'Download the Telegram Bot API documentation into tmp/' + task :fetch do + source = Docs::Source.new + source.download + puts "Downloaded #{source.url} to #{source.cache_path}" + end + desc 'Parse types from Telegram Bot API HTML documentation' task :types do puts 'Parsing types from Telegram Bot API...' @@ -21,11 +29,18 @@ namespace :parse do task :methods do puts 'Parsing methods from Telegram Bot API...' - result = Parsers::MethodsParser.new.parse + parser = Parsers::MethodsParser.new + result = parser.parse puts "Found #{result.keys.count} methods" File.write "#{__dir__}/../data/methods.json", JSON.pretty_generate(result) puts 'Written to data/methods.json' + + # A method the parser cannot read is a parser bug, not a missing endpoint. + abort "Unmatched return types: #{parser.unmatched.join(', ')}" if parser.unmatched.any? end + + desc 'Refresh the documentation, then parse types and methods' + task all: %i[fetch types methods] end diff --git a/rakelib/parsers/attribute_parser.rb b/rakelib/parsers/attribute_parser.rb new file mode 100644 index 0000000..587b425 --- /dev/null +++ b/rakelib/parsers/attribute_parser.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +require_relative 'type_value' + +module Parsers + # Turns one row of a "Field | Type | Description" table into the attribute + # hash stored in data/types.json. + # + # Key insertion order is significant: the file is written with + # JSON.pretty_generate, so the order below is part of the output. + class AttributeParser + CHARACTER_RANGE = /(\d+)-(\d+) characters/.freeze + BOUNDED_RANGE = /must be between (\d+) and (\d+)/.freeze + # Telegram measures some limits on the text after markup is parsed, a + # length the client cannot compute: "x" is 8 raw characters and 1 + # parsed. + # + # The two bounds are not symmetric. Markup only ever adds characters, so + # raw >= parsed: a parsed length above the minimum implies a raw length + # above it too, and the minimum stays enforceable. The maximum does not + # carry over -- 102 raw characters can be 95 parsed -- so it is recorded + # under its own name and left for a caller that can measure it. + PARSED_LENGTH = /after entities parsing/.freeze + + def initialize(row) + @type = row['Type'] + @description = row['Description'] + end + + def to_h + attribute = TypeValue.new(@type).to_h + attribute['required'] = true unless optional? + apply_constant(attribute) + apply_size(attribute) + apply_default(attribute) + attribute + end + + private + + def optional? + @description.start_with?('Optional') + end + + # A discriminator: `always "photo"` or `..., must be photo`. + # + # Two independent guards, since either alone has failed before. The type + # check states what a discriminator is. `must be` is then matched + # case-sensitively and only when the follows it immediately: all 57 + # real discriminators are lowercase and mid-sentence, and the known false + # positive is a sentence-initial "Must be False" of conditional + # prose. + def apply_constant(attribute) + value = constant(attribute['type']) + return unless value + + attribute['required_value'] = value + attribute['default'] = value + end + + def constant(type) + # required_value becomes .constrained(eql: 'photo'), which is only + # coherent for a string discriminator; all 176 in the docs are strings. + return nil unless type == 'string' + + quoted = @description.quoted_after('always') + return quoted.delete('\\') if quoted + + @description.emphasised_after('must be') + end + + # A string carries a length limit, a number a value range. They are + # different constraints, so they get different keys rather than leaving + # the builder to infer one from the type. + def apply_size(attribute) + minimum, maximum = size_range + return unless maximum + + low, high = size_keys(attribute['type']) + attribute[low] = minimum if minimum + attribute[high] = maximum + end + + def size_keys(type) + return %w[min max] if type == 'integer' + return %w[min_size max_size_parsed] if @description.match?(PARSED_LENGTH) + + %w[min_size max_size] + end + + def size_range + if (found = @description.match(CHARACTER_RANGE)) + minimum = found[1].to_i + [(minimum if minimum.positive?), found[2].to_i] + elsif (found = @description.match(BOUNDED_RANGE)) + [found[1].to_i, found[2].to_i] + else + [nil, nil] + end + end + + def apply_default(attribute) + return if attribute.key?('default') + # A documented type of "True" rather than "Boolean" means the field is + # only ever present when it is true. + return attribute['default'] = true if @type.to_s == 'True' + + value = default_value(attribute['type']) + attribute['default'] = value unless value.nil? + end + + def default_value(type) + raw = @description.quoted_after('Defaults to') || + @description.emphasised_after('Defaults to') || + @description.number_after('Defaults to') + raw && cast(raw, type) + end + + def cast(value, type) + case type + when 'integer' then value.to_i + when 'boolean' then value.downcase == 'true' + when 'number' then value.to_f + else value + end + end + end +end diff --git a/rakelib/parsers/methods_parser.rb b/rakelib/parsers/methods_parser.rb index ea252ba..c6321ee 100644 --- a/rakelib/parsers/methods_parser.rb +++ b/rakelib/parsers/methods_parser.rb @@ -1,114 +1,46 @@ # frozen_string_literal: true -require 'nokogiri' -require 'net/http' +require_relative '../docs' +require_relative 'return_type_rules' module Parsers + # Builds data/methods.json: the return type of every documented method. class MethodsParser - API_URL = 'https://core.telegram.org/bots/api' + # Methods are camelCase; types on the same heading level are CapitalCase. + NAME = /\A[a-z][a-zA-Z0-9]*\z/.freeze - def parse - doc = fetch_document - result = {} - - method_headers(doc).each do |header| - method_name = extract_method_name(header) - next unless method_name + attr_reader :unmatched - return_type = extract_return_type(header) - result[method_name] = return_type if return_type - end - - result + def initialize(page: nil) + @page = page || Docs::Page.load + @unmatched = [] end - private - - def fetch_document - uri = URI.parse(API_URL) - response = Net::HTTP.get(uri) - Nokogiri::HTML(response) - end - - def method_headers(doc) - doc.css('h4') - end - - def extract_method_name(header) - name = header.text.strip - # Methods start with lowercase letter (camelCase) - # Types start with uppercase (PascalCase) - skip those - return nil unless name.match?(/\A[a-z][a-zA-Z0-9]*\z/) - - name - end - - def extract_return_type(header) - # Find description paragraphs after the header - sibling = header.next_element - - while sibling - break if sibling.name == 'h4' # Next section + def parse + @unmatched = [] - if sibling.name == 'p' - return_type = parse_return_statement(sibling) - return return_type if return_type - end + @page.sections.each_with_object({}) do |section, result| + next unless section.title.match?(NAME) - sibling = sibling.next_element + type = return_type(section) + type ? result[section.title] = type : record_miss(section.title) end - - nil end - def parse_return_statement(paragraph) - html = paragraph.inner_html - - # Pattern: Returns an Array of Type - if (match = html.match(%r{Returns an Array of ]*>([^<]+)}i)) - return "Array<#{match[1]}>" - end - - # Pattern: On success, an array of Type (lowercase) - if (match = html.match(%r{On success,? an array of ]*>([^<]+)}i)) - return "Array<#{match[1]}>" - end - - # Pattern: Returns a Type or Returns the Type - if (match = html.match(%r{Returns (?:a |the )?.*?]*>([^<]+)}i)) - return match[1] - end - - # Pattern: Returns basic information ... in form of a Type - if (match = html.match(%r{in form of a ]*>([^<]+)}i)) - return match[1] - end - - # Pattern: Type is returned, otherwise True is returned (union type) - # e.g., "the edited Message is returned, otherwise True is returned" - if (match = html.match(%r{]*>([^<]+) is returned,? otherwise True is returned}i)) - return "#{match[1]} | Boolean" - end - - # Pattern: Returns True or On success, True is returned - return 'Boolean' if html.match?(%r{True(?:\s+(?:is|on)|\s*\.)}i) + private - # Pattern: On success, a Type object is returned - if (match = html.match(%r{On success,? a ]*>([^<]+) object is returned}i)) - return match[1] - end + # A miss used to be indistinguishable from "absent from the docs", which + # is how getChatMemberCount went missing from ENDPOINTS. + def record_miss(name) + @unmatched << name + warn "WARNING: no return type matched for #{name}" + end - # Pattern: On success, the Type is returned / the edited/sent/stopped X is returned - if (match = html.match(%r{the (?:sent |edited |stopped )?]*>([^<]+) is returned}i)) - return match[1] + def return_type(section) + section.paragraphs.each do |paragraph| + matched = ReturnTypeRules.match(paragraph) + return matched.last if matched end - - # Pattern: Returns Int or Integer - return 'Integer' if html.match?(%r{Returns Int}i) - - # Pattern: Returns ... as String - return 'String' if html.match?(%r{as String}i) - nil end end diff --git a/rakelib/parsers/return_type_rules.rb b/rakelib/parsers/return_type_rules.rb new file mode 100644 index 0000000..d1a0216 --- /dev/null +++ b/rakelib/parsers/return_type_rules.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module Parsers + # A method's return type is stated in prose. Each rule pairs a shape (matched + # against the plain text) with an extractor that pulls the value out of the + # markup, so "Returns True" and "Returns Message" stay + # distinguishable. + # + # Order is significant, and only two pairs are load-bearing: + # * the Array rules must precede :returns_link, or "Array" + # degrades to "Update" + # * :edited_or_true must precede :true_flag and :object_returned, or + # "Message | Boolean" degrades to "Boolean" + # Both are asserted in the specs. + module ReturnTypeRules + Rule = Struct.new(:name, :shape, :extract) do + def call(text) + return nil unless text.match?(shape) + + value = extract.call(text) + value unless value.nil? || value.to_s.empty? + end + end + + def self.rule(name, shape, &extract) + Rule.new(name, shape, extract) + end + + ALL = [ + rule(:returns_array_of, /Returns an Array of/i) { |t| wrap_array(t.link_after(/Returns an Array of/i)) }, + rule(:success_array_of, /On success,? an array of/i) { |t| wrap_array(t.link_after(/an Array of/i)) }, + rule(:returns_link, /Returns (?:a |the )?/i) { |t| t.link_following(/Returns/i) }, + rule(:edited_or_true, /is returned,? otherwise True is returned/i) do |t| + member = t.link_before('is returned') + member && "#{member} | Boolean" + end, + rule(:true_flag, /True(?:\s+(?:is|on)|\s*\.)/) { |t| 'Boolean' if t.emphasised.include?('True') }, + rule(:success_object, /On success,? a .* object is returned/i) { |t| t.link_before('object is returned') }, + rule(:object_returned, /the (?:sent |edited |stopped )?.*? is returned/i) { |t| t.link_before('is returned') }, + rule(:returns_int, /Returns Int(?:eger)?\b/i) do |t| + 'Integer' if %w[Int Integer].include?(t.emphasised_after(/Returns/i)) + end, + rule(:as_string, /as String/i) { |t| 'String' if t.emphasised_after(/\bas/i) == 'String' } + ].freeze + + # Returns [rule_name, value], or nil when no rule matches. + def self.match(text) + ALL.each do |candidate| + value = candidate.call(text) + return [candidate.name, value] if value + end + nil + end + + def self.wrap_array(member) + member && "Array<#{member}>" + end + end +end diff --git a/rakelib/parsers/type_value.rb b/rakelib/parsers/type_value.rb new file mode 100644 index 0000000..ef4f3b7 --- /dev/null +++ b/rakelib/parsers/type_value.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +module Parsers + # Maps a "Type" cell to the JSON shape stored in data/types.json. + class TypeValue + PRIMITIVES = { + 'String' => 'string', + 'Integer' => 'integer', + 'Boolean' => 'boolean', + 'Float' => 'number', + 'True' => 'boolean' + }.freeze + + def initialize(cell) + @cell = cell + end + + def to_h + return { 'type' => 'array', 'items' => array_items } if array? + + { 'type' => scalar } + end + + private + + def text + @cell.to_s + end + + def links + @cell.links + end + + def array? + text.start_with?('Array of') + end + + def array_items + return nested_items if text.include?('Array of Array of') + + normalize(links.first || text.sub('Array of ', '').strip) + end + + def nested_items + inner = links.last || text.split('Array of Array of').last.strip + { 'type' => 'array', 'items' => normalize(inner) } + end + + def scalar + return union if text.include?(' or ') + + normalize(links.first || text) + end + + def union + members = links.empty? ? text.split(' or ').map(&:strip) : links + members.length == 1 ? normalize(members.first) : members.map { |member| normalize(member) } + end + + def normalize(type) + PRIMITIVES[type] || type + end + end +end diff --git a/rakelib/parsers/types_parser.rb b/rakelib/parsers/types_parser.rb index 419d7d6..2e5783a 100644 --- a/rakelib/parsers/types_parser.rb +++ b/rakelib/parsers/types_parser.rb @@ -1,283 +1,60 @@ # frozen_string_literal: true -require 'nokogiri' -require 'net/http' +require_relative '../docs' +require_relative 'attribute_parser' module Parsers + # Builds data/types.json from the "Available types" sections of the docs. class TypesParser - API_URL = 'https://core.telegram.org/bots/api' + # Types are CapitalCase; methods on the same heading level are camelCase. + NAME = /\A[A-Z][a-zA-Z0-9]*\z/.freeze + FIELD_COLUMNS = %w[Field Type Description].freeze + PLAIN_TEXT = /String for plain text/i.freeze - PRIMITIVE_TYPES = { - 'String' => 'string', - 'Integer' => 'integer', - 'Boolean' => 'boolean', - 'Float' => 'number', - 'True' => 'boolean' - }.freeze + def initialize(page: nil) + @page = page || Docs::Page.load + end def parse - doc = fetch_document - result = {} - - type_headers(doc).each do |header| - type_name = extract_type_name(header) - next unless type_name + @page.sections.each_with_object({}) do |section, result| + next unless section.title.match?(NAME) - type_data = parse_type(header, type_name) - result[type_name] = type_data if type_data + result[section.title] = parse_section(section) end - - result end private - def fetch_document - uri = URI.parse(API_URL) - response = Net::HTTP.get(uri) - Nokogiri::HTML(response) - end + def parse_section(section) + return fields(section.table) if section.table - def type_headers(doc) - doc.css('h4') - end + variants = section.items.flat_map(&:links) + return union(section, variants) if variants.any? - def extract_type_name(header) - name = header.text.strip - # Types start with uppercase letter (CapitalCase) - # Methods start with lowercase (camelCase) - skip those - return nil unless name.match?(/\A[A-Z][a-zA-Z0-9]*\z/) - - name + # A handful of types legitimately hold no information. + {} end - def parse_type(header, type_name) - # Check if this is a union type (list of types without a table) - description = first_description_paragraph(header) - next_sibling = find_next_significant_sibling(header) - - if union_type?(next_sibling) - parse_union_type(next_sibling, description, type_name) - else - parse_table_type(header) + def fields(table) + table.expect_columns!(FIELD_COLUMNS) + table.each_with_object({}) do |row, attributes| + attributes[row['Field'].to_s] = AttributeParser.new(row).to_h end end - def first_description_paragraph(header) - sibling = header.next_element - sibling if sibling&.name == 'p' - end - - def find_next_significant_sibling(header) - sibling = header.next_element - # Skip description paragraphs - sibling = sibling.next_element while sibling && sibling.name == 'p' - sibling + # Union types list their members; the prose adds any non-struct members. + def union(section, variants) + { 'type' => (implied(section) + variants).uniq } end - def union_type?(element) - element&.name == 'ul' - end + def implied(section) + description = section.intro + return [] unless description - def parse_union_type(ul_element, description, type_name) types = [] - desc_text = description&.text.to_s - types << 'string' if desc_text.match?(/String for plain text/i) - types << "array:#{type_name}" if desc_text.match?(/an Array of #{type_name}/i) - types.concat(ul_element.css('li a').map { |a| a.text.strip }) - { 'type' => types.uniq } - end - - def parse_table_type(header) - table = find_attribute_table(header) - return {} unless table - - attributes = {} - table.css('tbody tr').each do |row| - cells = row.css('td') - next unless cells.length >= 3 - - field_name = cells[0].text.strip - type_info = cells[1] - description_cell = cells[2] - - attributes[field_name] = parse_attribute(type_info, description_cell) - end - - attributes - end - - def find_attribute_table(header) - sibling = header.next_element - while sibling - return sibling if sibling.name == 'table' - # Stop if we hit another h4 (next type/method) - break if sibling.name == 'h4' - - sibling = sibling.next_element - end - nil - end - - def parse_attribute(type_cell, description_cell) - attribute = {} - raw_type = type_cell.text.strip - description = description_cell.text.strip - description_html = description_cell.inner_html - - # Parse type - type_value = parse_type_value(type_cell) - if type_value.is_a?(Hash) - attribute.merge!(type_value) - else - attribute['type'] = type_value - end - - # Parse required (absence of "Optional" at start of description) - attribute['required'] = true unless description.start_with?('Optional') - - # Parse required_value (always "X" or must be X) - required_value = extract_required_value(description, description_html) - if required_value - attribute['required_value'] = required_value - attribute['default'] = required_value - end - - # Parse size constraints (N-M characters or must be between) - min_size, max_size = extract_size_constraints(description) - attribute['min_size'] = min_size if min_size - attribute['max_size'] = max_size if max_size - - # Parse default value (Defaults to X) - # If HTML type is 'True' (not 'Boolean'), it means the field only exists when true - if raw_type == 'True' && !attribute.key?('default') - attribute['default'] = true - elsif !attribute.key?('default') - default = extract_default_value(description, description_html, attribute['type']) - attribute['default'] = default unless default.nil? - end - - # Clean up: remove required if false - attribute.delete('required') unless attribute['required'] - - attribute - end - - def parse_type_value(type_cell) - text = type_cell.text.strip - links = type_cell.css('a') - - # Check for "Array of X" - if text.start_with?('Array of') - items_type = parse_array_items(type_cell, text) - return { 'type' => 'array', 'items' => items_type } - end - - # Check for union type "X or Y" - if text.include?(' or ') - types = parse_union_types(type_cell, text) - return types.length == 1 ? normalize_type(types.first) : types.map { |t| normalize_type(t) } - end - - # Single type - if links.any? - normalize_type(links.first.text.strip) - else - normalize_type(text) - end - end - - def parse_array_items(type_cell, text) - # Handle "Array of Array of X" - if text.include?('Array of Array of') - inner_type = type_cell.css('a').last&.text&.strip || text.split('Array of Array of').last.strip - return { 'type' => 'array', 'items' => normalize_type(inner_type) } - end - - # Regular "Array of X" - link = type_cell.css('a').first - if link - normalize_type(link.text.strip) - else - # Primitive array like "Array of String" - items_text = text.sub('Array of ', '').strip - normalize_type(items_text) - end - end - - def parse_union_types(type_cell, text) - links = type_cell.css('a') - if links.any? - links.map { |l| l.text.strip } - else - text.split(' or ').map(&:strip) - end - end - - def normalize_type(type) - PRIMITIVE_TYPES[type] || type - end - - def extract_required_value(description, description_html) - # Pattern: always "X" or always "X" (smart quotes) - match = description.match(/always ["\u201c]([^"\u201d]+)["\u201d]/i) - return match[1].delete('\\') if match - - # Pattern: must be X (check inner HTML) - match = description_html.match(%r{must be ([^<]+)}i) - return match[1] if match - - nil - end - - def extract_size_constraints(description) - min_size = nil - max_size = nil - - # Pattern: N-M characters (covers 0-N and 1-N cases) - if (match = description.match(/(\d+)-(\d+) characters/)) - min_val = match[1].to_i - max_size = match[2].to_i - min_size = min_val if min_val.positive? - # Pattern: must be between N and M - elsif (match = description.match(/must be between (\d+) and (\d+)/)) - min_size = match[1].to_i - max_size = match[2].to_i - end - - [min_size, max_size] - end - - def extract_default_value(description, description_html, type) - # Pattern: Defaults to "X" (with smart quotes U+201C/U+201D) - if (match = description.match(/Defaults to \u201c([^\u201d]+)\u201d/i)) - return cast_default_value(match[1], type) - end - - # Pattern: Defaults to X (check inner HTML for em-wrapped values) - if (match = description_html.match(%r{Defaults to ([^<]+)}i)) - return cast_default_value(match[1], type) - end - - # Pattern: Defaults to (plain numeric values) - if (match = description.match(/Defaults to (\d+)/i)) - return cast_default_value(match[1], type) - end - - nil - end - - def cast_default_value(value, type) - case type - when 'integer' - value.to_i - when 'boolean' - value.downcase == 'true' - when 'number' - value.to_f - else - value - end + types << 'string' if description.match?(PLAIN_TEXT) + types << "array:#{section.title}" if description.match?(/an Array of #{Regexp.escape(section.title)}/i) + types end end end diff --git a/spec/lib/telegram/bot/api_spec.rb b/spec/lib/telegram/bot/api_spec.rb index dfe95e6..4fac13f 100644 --- a/spec/lib/telegram/bot/api_spec.rb +++ b/spec/lib/telegram/bot/api_spec.rb @@ -77,6 +77,17 @@ expect(api).to respond_to(endpoint) end end + + # getChatMemberCount was missing from ENDPOINTS: the parser matched only + # "Returns Int" while the docs say "Returns Integer", so + # the endpoint silently never generated and this call raised NoMethodError. + context 'when the endpoint returns a bare Integer' do + let(:endpoint) { 'get_chat_member_count' } + + it 'responds to the endpoint' do + expect(api).to respond_to(endpoint) + end + end end describe '#getMe' do diff --git a/spec/rakelib/builders/type_builder_spec.rb b/spec/rakelib/builders/type_builder_spec.rb index 16a2e45..ea129b8 100644 --- a/spec/rakelib/builders/type_builder_spec.rb +++ b/spec/rakelib/builders/type_builder_spec.rb @@ -57,4 +57,60 @@ end end end + + describe '#build for size and range constraints' do + subject(:output) do + described_class.new( + 'Sized', { field: field }, + templates_dir: templates_dir, dependencies: dependencies + ).build + end + + let(:types) { { Sized: { field: field } } } + + context 'with a string length range' do + let(:field) { { type: 'string', min_size: 1, max_size: 64 } } + + it { is_expected.to include('Types::String.constrained(min_size: 1, max_size: 64)') } + end + + # This was dropped entirely: the old placeholder-substitution reset the + # constraint to '' whenever min_size was absent, so max-only limits never + # reached the generated file. + context 'with only a maximum length' do + let(:field) { { type: 'string', max_size: 200 } } + + it { is_expected.to include('Types::String.constrained(max_size: 200)') } + end + + context 'with only a minimum length' do + let(:field) { { type: 'string', min_size: 3 } } + + it { is_expected.to include('Types::String.constrained(min_size: 3)') } + end + + # min_size on an Integer compares Integer#size, the byte width, which is 8 + # for every value -- so the constraint accepted everything. + context 'with a numeric range' do + let(:field) { { type: 'integer', min: 5, max: 100_000 } } + + it { is_expected.to include('Types::Integer.constrained(gteq: 5, lteq: 100_000)') } + end + + context 'with no constraint at all' do + let(:field) { { type: 'string' } } + + it { is_expected.not_to include('constrained') } + end + + # max_size_parsed is measured on the text after markup is parsed, which + # the client cannot compute, so it is recorded but never emitted. + context 'with a maximum measured after entities parsing' do + let(:field) { { type: 'string', min_size: 1, max_size_parsed: 255 } } + + it { is_expected.to include('Types::String.constrained(min_size: 1)') } + + it { is_expected.not_to include('255') } + end + end end diff --git a/spec/rakelib/docs/page_spec.rb b/spec/rakelib/docs/page_spec.rb new file mode 100644 index 0000000..cdadb85 --- /dev/null +++ b/spec/rakelib/docs/page_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require_relative '../../../rakelib/docs' + +RSpec.describe Docs::Page do + def page(body) + described_class.new("
#{body}
") + end + + describe '.new' do + context 'without the content container' do + subject(:build) { described_class.new('

nope

') } + + it { expect { build }.to raise_error(Docs::Error, /dev_page_content/) } + end + end + + describe '#sections' do + subject(:sections) { page(body).sections } + + context 'with a heading followed by content' do + let(:body) { '

User

An object.

' } + + it 'finds the section' do + expect(sections.map(&:title)).to eq(%w[User]) + end + + it 'gathers the nodes that follow' do + expect(sections.first.nodes.map(&:name)).to eq(%w[p table]) + end + end + + context 'when a same-level heading follows' do + let(:body) { '

User

first

Chat

second

' } + + it 'finds both sections' do + expect(sections.map(&:title)).to eq(%w[User Chat]) + end + + it 'ends the first section at the second' do + expect(sections.first.paragraphs.map(&:to_s)).to eq(%w[first]) + end + end + + context 'when a heading of a higher level follows' do + let(:body) { '

User

mine

Available methods

theirs

' } + + it 'ends the section there' do + expect(sections.first.paragraphs.map(&:to_s)).to eq(%w[mine]) + end + end + + context 'with content before the first heading' do + let(:body) { '

preamble

User

' } + + it 'ignores it' do + expect(sections.map(&:title)).to eq(%w[User]) + end + end + + context 'when another level is requested' do + subject(:sections) { page(body).sections(level: 3) } + + let(:body) { '

Available types

intro

User

' } + + it 'selects headings at that level' do + expect(sections.map(&:title)).to eq(['Available types']) + end + end + end +end diff --git a/spec/rakelib/docs/table_spec.rb b/spec/rakelib/docs/table_spec.rb new file mode 100644 index 0000000..d2d39be --- /dev/null +++ b/spec/rakelib/docs/table_spec.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require 'nokogiri' +require_relative '../../../rakelib/docs' + +RSpec.describe Docs::Table do + def table(head, *rows) + body = rows.map { |cells| "#{cells.map { |cell| "#{cell}" }.join}" }.join + html = "#{head.map { |cell| "" }.join}" \ + "#{body}
#{cell}
" + described_class.new(Nokogiri::HTML.fragment(html).at('table')) + end + + let(:fields) { table(%w[Field Type Description], ['update_id', 'Integer', 'The update id']) } + let(:parameters) { table(%w[Parameter Type Required Description], %w[chat_id Integer Yes Target]) } + + describe '#columns' do + subject { fields.columns } + + it { is_expected.to eq(%w[Field Type Description]) } + end + + describe '#[]' do + subject(:row) { fields.first } + + it 'reads the first column' do + expect(row['Field'].to_s).to eq('update_id') + end + + it 'reads a middle column' do + expect(row['Type'].to_s).to eq('Integer') + end + + it 'reads the last column' do + expect(row['Description'].to_s).to eq('The update id') + end + end + + describe '#expect_columns!' do + subject(:assert) { subject_table.expect_columns!(%w[Field Type Description]) } + + context 'when the columns match' do + let(:subject_table) { fields } + + it { is_expected.to be(fields) } + end + + # Method tables carry a fourth "Required" column. Reading one with the + # three-column layout used to silently yield "Optional" as the description. + context 'when the table has a different shape' do + let(:subject_table) { parameters } + + it { expect { assert }.to raise_error(Docs::Error, /Parameter/) } + end + end +end diff --git a/spec/rakelib/docs/text_spec.rb b/spec/rakelib/docs/text_spec.rb new file mode 100644 index 0000000..3a097e8 --- /dev/null +++ b/spec/rakelib/docs/text_spec.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true + +require 'nokogiri' +require_relative '../../../rakelib/docs' + +RSpec.describe Docs::Text do + def text(html) + described_class.new(Nokogiri::HTML.fragment("#{html}").at('td')) + end + + describe '#to_s' do + subject { text(html).to_s } + + context 'with inline markup' do + let(:html) { 'Type of the media, must be photo' } + + it { is_expected.to eq('Type of the media, must be photo') } + end + + context 'with surrounding whitespace' do + let(:html) { "\n hello \n" } + + it { is_expected.to eq('hello') } + end + end + + describe '#links' do + subject { text(html).links } + + context 'with a single anchor' do + let(:html) { 'Array of PhotoSize' } + + it { is_expected.to eq(%w[PhotoSize]) } + end + + context 'with empty anchor elements' do + let(:html) { 'Array of User' } + + it { is_expected.to eq(%w[User]) } + end + end + + describe '#emphasised_after' do + subject { text(html).emphasised_after('must be') } + + context 'when the emphasis follows the phrase' do + let(:html) { 'Scope type, must be default' } + + it { is_expected.to eq('default') } + end + + context 'when the emphasis only appears later' do + let(:html) do + 'A reaction to a message was changed. The bot must be an administrator ' \ + 'and specify message_reaction in the list of allowed updates.' + end + + it { is_expected.to be_nil } + end + + context 'when the phrase is capitalised' do + # "... Must be False for callback queries" is conditional prose rather + # than a constraint, and reading it as one produced a Boolean field + # constrained to eql: False. + let(:html) { 'Pass True if shown in place. Must be False for callbacks.' } + + it { is_expected.to be_nil } + end + + context 'when an earlier phrase has no emphasis' do + let(:html) { 'The bot must be an administrator. Scope type, must be chat' } + + it { is_expected.to eq('chat') } + end + end + + describe '#quoted_after' do + subject { text(html).quoted_after('always') } + + context 'with typographic quotes' do + let(:html) { 'Type of the result, always “article”' } + + it { is_expected.to eq('article') } + end + + context 'with straight quotes' do + let(:html) { 'Type of the result, always "article"' } + + it { is_expected.to eq('article') } + end + + context 'when the value is emphasised instead' do + let(:html) { 'Always False' } + + it { is_expected.to be_nil } + end + end + + describe '#number_after' do + subject { text(html).number_after('Defaults to') } + + let(:html) { 'Limits the number of updates. Defaults to 100.' } + + it { is_expected.to eq('100') } + end + + describe '#link_before' do + subject { text(html).link_before('is returned') } + + let(:html) { 'On success, the sent Message is returned.' } + + it { is_expected.to eq('Message') } + end + + describe '#link_following' do + subject { text(html).link_following(/Returns/i) } + + context 'with words before the link' do + let(:html) { 'Returns basic information about the bot in form of a User object.' } + + it { is_expected.to eq('User') } + end + + context 'with a link before the phrase' do + let(:html) { 'See Update for details. Returns Message.' } + + it { is_expected.to eq('Message') } + end + end +end diff --git a/spec/rakelib/parsers/attribute_parser_spec.rb b/spec/rakelib/parsers/attribute_parser_spec.rb new file mode 100644 index 0000000..28f6ad3 --- /dev/null +++ b/spec/rakelib/parsers/attribute_parser_spec.rb @@ -0,0 +1,200 @@ +# frozen_string_literal: true + +require 'nokogiri' +require_relative '../../../rakelib/docs' +require_relative '../../../rakelib/parsers/attribute_parser' + +RSpec.describe Parsers::AttributeParser do + describe '#to_h' do + subject(:attribute) { described_class.new(row).to_h } + + # A single "Field | Type | Description" row, the way the docs write one. + let(:row) do + html = '' \ + "
FieldTypeDescription
field#{type}#{description}
" + Docs::Table.new(Nokogiri::HTML.fragment(html).at('table')).first + end + let(:type) { 'String' } + let(:description) { 'Some text' } + + context 'when not marked Optional' do + let(:type) { 'Integer' } + + it { is_expected.to eq('type' => 'integer', 'required' => true) } + end + + context 'when marked Optional' do + let(:type) { 'Integer' } + let(:description) { 'Optional. Unique identifier' } + + it { is_expected.to eq('type' => 'integer') } + end + + context 'with a String type' do + it { is_expected.to include('type' => 'string') } + end + + context 'with a Float type' do + let(:type) { 'Float' } + + it { is_expected.to include('type' => 'number') } + end + + context 'with a linked struct type' do + let(:type) { 'User' } + + it { is_expected.to include('type' => 'User') } + end + + context 'with an array type' do + let(:type) { 'Array of PhotoSize' } + + it { is_expected.to include('type' => 'array') } + + it { is_expected.to include('items' => 'PhotoSize') } + end + + context 'with a nested array type' do + let(:type) { 'Array of Array of PhotoSize' } + + it { is_expected.to include('items' => { 'type' => 'array', 'items' => 'PhotoSize' }) } + end + + context 'with a union of primitives' do + let(:type) { 'Integer or String' } + + it { is_expected.to include('type' => %w[integer string]) } + end + + context 'with a quoted discriminator' do + let(:description) { 'Type of the result, always “article”' } + + it { is_expected.to include('required_value' => 'article') } + + it { is_expected.to include('default' => 'article') } + end + + context 'with an emphasised discriminator' do + let(:description) { 'Scope type, must be default' } + + it { is_expected.to include('required_value' => 'default') } + + it { is_expected.to include('default' => 'default') } + end + + # Regression: a Boolean field reading "... Must be False for callback + # queries ..." became Bool.constrained(eql: False), which both forbade the + # True the field exists to carry and emitted a bare Ruby constant. + context 'with sentence-initial conditional prose' do + let(:type) { 'Boolean' } + let(:description) do + 'Optional. Pass True if the ephemeral message must be shown ' \ + 'in place of the original message. Must be False for callback queries.' + end + + it { is_expected.not_to have_key('required_value') } + end + + context 'with emphasis far from the phrase' do + let(:description) do + 'Optional. The bot must be an administrator and specify ' \ + 'message_reaction in the list of allowed updates.' + end + + it { is_expected.not_to have_key('required_value') } + end + + context 'with a bare numeric default' do + let(:type) { 'Integer' } + let(:description) { 'Optional. Limit. Defaults to 100.' } + + it { is_expected.to include('default' => 100) } + end + + context 'with an emphasised boolean default' do + let(:type) { 'Boolean' } + let(:description) { 'Optional. Defaults to true' } + + it { is_expected.to include('default' => true) } + end + + context 'with a quoted string default' do + let(:description) { 'Optional. Defaults to “mp4”' } + + it { is_expected.to include('default' => 'mp4') } + end + + # A documented type of True rather than Boolean means the field is only + # ever present when it is set. + context 'with a True type' do + let(:type) { 'True' } + let(:description) { 'Optional. Service message' } + + it { is_expected.to include('default' => true) } + end + + context 'with a character range' do + let(:description) { 'Text, 1-64 characters' } + + it { is_expected.to include('min_size' => 1) } + + it { is_expected.to include('max_size' => 64) } + end + + context 'with a zero-based character range' do + let(:description) { 'Quote, 0-1024 characters' } + + it { is_expected.to include('max_size' => 1024) } + + it { is_expected.not_to have_key('min_size') } + end + + # A number carries a value range, not a length, so it gets different keys. + # dry-types measures `size`, and Integer#size is the byte width, which + # made every numeric range vacuously true. + context 'with a numeric range' do + let(:type) { 'Integer' } + let(:description) { 'Period, must be between 60 and 86400' } + + it { is_expected.to include('min' => 60) } + + it { is_expected.to include('max' => 86_400) } + + it { is_expected.not_to have_key('min_size') } + end + + # Measured on the text after markup is parsed. Markup only ever adds + # characters, so raw >= parsed: the minimum still holds on the raw string, + # but the maximum does not and would reject input Telegram accepts. + context 'with a limit measured after entities parsing' do + let(:description) { 'Title of the checklist; 1-255 characters after entities parsing' } + + it 'keeps the minimum, which still holds' do + expect(attribute).to include('min_size' => 1) + end + + it { is_expected.not_to have_key('max_size') } + + it 'records the maximum under its own key' do + expect(attribute).to include('max_size_parsed' => 255) + end + end + + context 'with a non-string discriminator phrase' do + let(:type) { 'Integer' } + let(:description) { 'Identifier, always 42' } + + it { is_expected.not_to have_key('required_value') } + end + + # data/types.json is written with JSON.pretty_generate, so insertion + # order is part of the file. + context 'with every kind of annotation at once' do + let(:description) { 'Type of the result, always “article”, 1-64 characters' } + + it 'keeps a stable key order' do + expect(attribute.keys).to eq(%w[type required required_value default min_size max_size]) + end + end + end +end diff --git a/spec/rakelib/parsers/methods_parser_spec.rb b/spec/rakelib/parsers/methods_parser_spec.rb new file mode 100644 index 0000000..be356a6 --- /dev/null +++ b/spec/rakelib/parsers/methods_parser_spec.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require 'stringio' +require_relative '../../../rakelib/docs' +require_relative '../../../rakelib/parsers/methods_parser' + +RSpec.describe Parsers::MethodsParser do + describe '#parse' do + subject(:parsed) { parser.parse } + + let(:parser) do + page = Docs::Page.new("
#{body}
") + described_class.new(page: page) + end + + # `warn` writes to $stderr; swallow it where the warning is not the subject. + def silently + original = $stderr + $stderr = StringIO.new + yield + ensure + $stderr = original + end + + context 'when the first paragraph names it' do + let(:body) { '

getMe

Returns basic information as a User object.

' } + + it { is_expected.to eq('getMe' => 'User') } + end + + context 'when a later paragraph names it' do + let(:body) do + '

sendMessage

Use this method to send text messages.

' \ + '

On success, the sent Message is returned.

' + end + + it { is_expected.to eq('sendMessage' => 'Message') } + end + + context 'with a CapitalCase heading' do + let(:body) { '

User

Returns a User.

' } + + it 'ignores it, since it names a type' do + expect(parsed).to be_empty + end + end + + context 'when every method is understood' do + let(:body) { '

logOut

Returns True on success.

' } + + it 'reports nothing unmatched' do + parsed + + expect(parser.unmatched).to be_empty + end + end + + # The old parser dropped an unreadable return type silently, which is how + # getChatMemberCount went missing from the generated endpoint list. + context 'when no rule matches' do + let(:body) { '

getMysteryThing

Use this method for something new.

' } + + it 'records the method as unmatched' do + silently { parsed } + + expect(parser.unmatched).to eq(%w[getMysteryThing]) + end + + it 'omits it rather than guessing' do + expect(silently { parsed }).to be_empty + end + + it 'warns on stderr' do + expect { parsed }.to output(/getMysteryThing/).to_stderr + end + end + end +end diff --git a/spec/rakelib/parsers/return_type_rules_spec.rb b/spec/rakelib/parsers/return_type_rules_spec.rb new file mode 100644 index 0000000..28df360 --- /dev/null +++ b/spec/rakelib/parsers/return_type_rules_spec.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require 'nokogiri' +require_relative '../../../rakelib/docs' +require_relative '../../../rakelib/parsers/return_type_rules' + +RSpec.describe Parsers::ReturnTypeRules do + # One example per rule, so a rule that stops working fails under its own + # name rather than somewhere downstream in data/methods.json. + examples = { + returns_array_of: [ + 'Returns an Array of Update objects.', 'Array' + ], + success_array_of: [ + 'On success, an Array of MessageId of the sent messages is returned.', + 'Array' + ], + returns_link: [ + 'Returns basic information about the bot in form of a User object.', 'User' + ], + edited_or_true: [ + 'On success, the edited Message is returned, otherwise True is returned.', + 'Message | Boolean' + ], + true_flag: ['On success, True is returned.', 'Boolean'], + success_object: ['On success, a WebhookInfo object is returned.', 'WebhookInfo'], + object_returned: ['On success, the sent Message is returned.', 'Message'], + returns_int: ['Returns Integer on success.', 'Integer'], + as_string: ['Returns the new invite link as String on success.', 'String'] + } + + describe '.match' do + subject(:matched) { described_class.match(paragraph) } + + let(:paragraph) { Docs::Text.new(Nokogiri::HTML.fragment("

#{html}

").at('p')) } + + examples.each do |name, (example_html, expected)| + context "with prose matching #{name}" do + let(:html) { example_html } + + it { is_expected.to eq([name, expected]) } + end + end + + context 'when nothing matches' do + let(:html) { 'Use this method to do something undocumented.' } + + it { is_expected.to be_nil } + end + end + + describe '::ALL' do + subject(:names) { described_class::ALL.map(&:name) } + + def index_of(name) + names.index(name) + end + + it 'has an example for every rule' do + expect(names).to match_array(examples.keys) + end + + context 'with the array rules' do + it 'tries Returns an Array of first' do + expect(index_of(:returns_array_of)).to be < index_of(:returns_link) + end + + it 'tries an array of first' do + expect(index_of(:success_array_of)).to be < index_of(:returns_link) + end + end + + context 'with the union rule' do + it 'runs before the plain True rule' do + expect(index_of(:edited_or_true)).to be < index_of(:true_flag) + end + + it 'runs before the object rule' do + expect(index_of(:edited_or_true)).to be < index_of(:object_returned) + end + end + end +end diff --git a/spec/rakelib/parsers/types_parser_spec.rb b/spec/rakelib/parsers/types_parser_spec.rb index 15d00dc..2b43c38 100644 --- a/spec/rakelib/parsers/types_parser_spec.rb +++ b/spec/rakelib/parsers/types_parser_spec.rb @@ -1,61 +1,106 @@ # frozen_string_literal: true -require 'nokogiri' +require_relative '../../../rakelib/docs' require_relative '../../../rakelib/parsers/types_parser' RSpec.describe Parsers::TypesParser do - subject(:parser) { described_class.new } + describe '#parse' do + subject(:parsed) { described_class.new(page: page).parse } - describe '#parse_union_type' do - def parse_union(description_html, list_items, type_name = 'RichText') - description = Nokogiri::HTML.fragment(description_html).children.find(&:element?) - ul_html = list_items.map { |name| %(
  • #{name}
  • ) }.join - ul = Nokogiri::HTML.fragment("
      #{ul_html}
    ").at('ul') + let(:page) do + Docs::Page.new("
    #{body}
    ") + end + + def union_of(description, members, name: 'RichText') + items = members.map { |member| %(
  • #{member}
  • ) }.join + "

    #{name}

    #{description}

      #{items}
    " + end + + context 'with a union of linked members' do + let(:body) { union_of('This object represents something.', %w[RichTextBold RichTextItalic]) } + + it { is_expected.to eq('RichText' => { 'type' => %w[RichTextBold RichTextItalic] }) } + end + + context 'when the prose mentions plain text' do + let(:body) do + union_of('This object represents rich text. String for plain text, or one of:', %w[RichTextBold]) + end + + it 'prepends string' do + expect(parsed.dig('RichText', 'type')).to eq(%w[string RichTextBold]) + end + end + + context 'when the prose mentions an Array' do + let(:body) do + union_of('This object represents rich text as an Array of RichText, or one of:', %w[RichTextBold]) + end + + it 'prepends array:TypeName' do + expect(parsed.dig('RichText', 'type')).to eq(%w[array:RichText RichTextBold]) + end + end + + context 'when the prose mentions both' do + let(:body) do + union_of( + 'String for plain text, an Array of RichText for sequences, or one of:', + %w[RichTextBold RichTextItalic] + ) + end + + it 'keeps string before array' do + expect(parsed.dig('RichText', 'type')).to eq(%w[string array:RichText RichTextBold RichTextItalic]) + end + end + + context 'when a member repeats an implied type' do + let(:body) { union_of('String for plain text or one of:', %w[string RichTextBold]) } - parser.send(:parse_union_type, ul, description, type_name) + it 'deduplicates them' do + expect(parsed.dig('RichText', 'type')).to eq(%w[string RichTextBold]) + end end - it 'collects linked struct members from the union list' do - result = parse_union('

    This object represents something.

    ', %w[RichTextBold RichTextItalic]) + context 'with a field table' do + let(:body) do + '

    Update

    This object represents an incoming update.

    ' \ + '' \ + '
    FieldTypeDescription
    update_idIntegerThe identifier.
    ' + end - expect(result).to eq('type' => %w[RichTextBold RichTextItalic]) + it { is_expected.to eq('Update' => { 'update_id' => { 'type' => 'integer', 'required' => true } }) } end - it 'prepends string when prose mentions plain text strings' do - result = parse_union( - '

    This object represents rich text. String for plain text, or one of:

    ', - %w[RichTextBold] - ) + context 'when the field table changes shape' do + let(:body) do + '

    Update

    ' \ + '' \ + '
    FieldKindNotes
    update_idIntegerx
    ' + end - expect(result['type']).to eq(%w[string RichTextBold]) + it { expect { parsed }.to raise_error(Docs::Error, /Kind/) } end - it 'prepends array:TypeName when prose mentions an Array of the type' do - result = parse_union( - '

    This object represents rich text as an Array of RichText, or one of:

    ', - %w[RichTextBold] - ) + context 'with a type that holds no information' do + let(:body) { '

    CallbackGame

    A placeholder, currently holds no information.

    ' } - expect(result['type']).to eq(%w[array:RichText RichTextBold]) + it { is_expected.to eq('CallbackGame' => {}) } end - it 'keeps string before array when both are described' do - result = parse_union( - '

    String for plain text, an Array of RichText for sequences, or one of:

    ', - %w[RichTextBold RichTextItalic] - ) + context 'with a camelCase heading' do + let(:body) { '

    sendMessage

    Use this method.

    ' } - expect(result['type']).to eq(%w[string array:RichText RichTextBold RichTextItalic]) + it 'ignores it, since it names a method' do + expect(parsed).to be_empty + end end - it 'deduplicates members' do - result = parse_union( - '

    String for plain text or one of:

    ', - %w[string RichTextBold] - ) + context 'with a prose heading' do + let(:body) { '

    Formatting options

    Prose.

    ' } - # "string" from prose plus a hypothetical linked "string" collapse via uniq - expect(result['type']).to eq(%w[string RichTextBold]) + it { is_expected.to be_empty } end end end