├── docs ├── changelog.md ├── .DS_Store ├── images │ └── logo.png ├── SUMMARY.md ├── gen_doc_stubs.py ├── stylesheets │ └── extra.css ├── index.md ├── usage │ ├── index.md │ └── getting_started.md └── resources │ └── faq.md ├── spec ├── tourmaline_spec.cr └── spec_helper.cr ├── img ├── logo.png └── header.png ├── src ├── tourmaline │ ├── version.cr │ ├── types │ │ └── custom │ │ │ ├── file.cr │ │ │ ├── user.cr │ │ │ ├── inline_keyboard_markup.cr │ │ │ ├── reply_keyboard_markup.cr │ │ │ ├── chat.cr │ │ │ ├── update.cr │ │ │ └── message.cr │ ├── event_handler.cr │ ├── logger.cr │ ├── middleware.cr │ ├── chat_action.cr │ ├── parse_mode.cr │ ├── handlers │ │ ├── hears_handler.cr │ │ ├── inline_query_handler.cr │ │ ├── callback_query_handler.cr │ │ └── command_handler.cr │ ├── poller.cr │ ├── client │ │ ├── reply_keyboard_markup_builder.cr │ │ ├── inline_keyboard_markup_builder.cr │ │ └── inline_query_result_builder.cr │ ├── dispatcher.cr │ ├── server.cr │ ├── helpers.cr │ ├── keyboard_builder.cr │ ├── context.cr │ ├── update_action.cr │ ├── client.cr │ └── error.cr ├── ext │ ├── file.cr │ ├── fiber.cr │ └── slice.cr └── tourmaline.cr ├── examples ├── cat.jpg ├── echo_bot.cr ├── dice_bot.cr ├── webhook_bot.cr ├── button_bot.cr ├── live_location_bot.cr ├── inline_query_bot.cr ├── poll_bot.cr ├── kitty_bot.cr ├── shop_bot.cr └── media_bot.cr ├── .editorconfig ├── main.py ├── .gitignore ├── .readthedocs.yml ├── Pipfile ├── .github ├── FUNDING.yml └── workflows │ ├── crystal.yml │ ├── docs.yml │ └── bot-api-updates.yml ├── shard.yml ├── LICENSE ├── mkdocs.yml ├── requirements.txt ├── README.md ├── CHANGELOG.md ├── scripts └── generate.cr └── Pipfile.lock /docs/changelog.md: -------------------------------------------------------------------------------- 1 | --8<-- "CHANGELOG.md" 2 | -------------------------------------------------------------------------------- /spec/tourmaline_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | -------------------------------------------------------------------------------- /spec/spec_helper.cr: -------------------------------------------------------------------------------- 1 | require "../src/tourmaline" 2 | require "spectator" 3 | -------------------------------------------------------------------------------- /img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/protoncr/tourmaline/master/img/logo.png -------------------------------------------------------------------------------- /src/tourmaline/version.cr: -------------------------------------------------------------------------------- 1 | module Tourmaline 2 | VERSION = "0.30.0" 3 | end 4 | -------------------------------------------------------------------------------- /docs/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/protoncr/tourmaline/master/docs/.DS_Store -------------------------------------------------------------------------------- /img/header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/protoncr/tourmaline/master/img/header.png -------------------------------------------------------------------------------- /examples/cat.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/protoncr/tourmaline/master/examples/cat.jpg -------------------------------------------------------------------------------- /docs/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/protoncr/tourmaline/master/docs/images/logo.png -------------------------------------------------------------------------------- /src/ext/file.cr: -------------------------------------------------------------------------------- 1 | require "json" 2 | 3 | # :nodoc: 4 | class File 5 | def to_json(json : JSON::Builder) 6 | json.string(to_s) 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /src/ext/fiber.cr: -------------------------------------------------------------------------------- 1 | require "http/server" 2 | 3 | # :nodoc: 4 | class Fiber 5 | property telegram_bot_server_http_context : HTTP::Server::Context? 6 | end 7 | -------------------------------------------------------------------------------- /src/ext/slice.cr: -------------------------------------------------------------------------------- 1 | struct Slice(T) 2 | # :nodoc: 3 | def to_bytes 4 | Bytes.new(self.to_unsafe.as(Pointer(UInt8)), self.bytesize) 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.cr] 2 | charset = utf-8 3 | end_of_line = lf 4 | insert_final_newline = true 5 | indent_style = space 6 | indent_size = 2 7 | trim_trailing_whitespace = true 8 | -------------------------------------------------------------------------------- /src/tourmaline/types/custom/file.cr: -------------------------------------------------------------------------------- 1 | module Tourmaline 2 | class File 3 | def link 4 | if file_path = @file_path 5 | File.join("#{API_URL}/file/bot#{@api_key}", file_path) 6 | end 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | # mkdocs macros 2 | 3 | def define_env(env): 4 | "Hook function" 5 | 6 | @env.macro 7 | def iter_crystal_type(kind): 8 | root = env.conf['plugins']['mkdocstrings'].get_handler('crystal').collector.root 9 | return root.lookup(kind).types -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | __pycache__/ 3 | 4 | /lib/ 5 | /bin/ 6 | /site/ 7 | /.shards/ 8 | /examples/build/ 9 | 10 | # Libraries don't need dependency lock 11 | # Dependencies will be locked in application that uses them 12 | /shard.lock 13 | .vscode 14 | .idea 15 | .gvdesign 16 | 17 | tags -------------------------------------------------------------------------------- /examples/echo_bot.cr: -------------------------------------------------------------------------------- 1 | require "../src/tourmaline" 2 | 3 | client = Tourmaline::Client.new(ENV["BOT_TOKEN"]) 4 | 5 | echo_handler = Tourmaline::CommandHandler.new("echo") do |ctx| 6 | text = ctx.text.to_s 7 | ctx.reply(text) unless text.empty? 8 | end 9 | 10 | client.register(echo_handler) 11 | 12 | client.poll 13 | -------------------------------------------------------------------------------- /src/tourmaline/types/custom/user.cr: -------------------------------------------------------------------------------- 1 | module Tourmaline 2 | class User 3 | def full_name 4 | [first_name, last_name].compact.join(" ") 5 | end 6 | 7 | def inline_mention(name) 8 | name ||= full_name 9 | "[#{Helpers.escape_md(name)}](tg://user?id=#{id})" 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /src/tourmaline/types/custom/inline_keyboard_markup.cr: -------------------------------------------------------------------------------- 1 | module Tourmaline 2 | class InlineKeyboardMarkup 3 | def <<(row, btn : InlineKeyboardButton) 4 | inline_keyboard[row] << btn 5 | end 6 | 7 | def <<(btns : Array(InlineKeyboardButton)) 8 | @inline_keyboard << btns 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /src/tourmaline/event_handler.cr: -------------------------------------------------------------------------------- 1 | module Tourmaline 2 | alias EventHandlerProc = Proc(Tourmaline::Context, Nil) 3 | 4 | abstract class EventHandler 5 | abstract def actions : Array(UpdateAction) 6 | abstract def call(ctx : Tourmaline::Context) 7 | end 8 | 9 | alias EventHandlerType = EventHandler | EventHandlerProc 10 | end 11 | -------------------------------------------------------------------------------- /docs/SUMMARY.md: -------------------------------------------------------------------------------- 1 | * [Home](index.md) 2 | * [Usage](usage/index.md) 3 | * [Getting Started](usage/getting_started.md) 4 | * Resources 5 | * [FAQ](resources/faq.md) 6 | * [Asking Questions](http://www.catb.org/~esr/faqs/smart-questions.html) 7 | * API Reference 8 | * [Changelog](changelog.md) 9 | * [Tourmaline](api_reference/Tourmaline/) 10 | -------------------------------------------------------------------------------- /src/tourmaline/logger.cr: -------------------------------------------------------------------------------- 1 | require "log" 2 | require "colorize" 3 | 4 | Log.setup_from_env(default_level: :info) 5 | 6 | module Tourmaline 7 | # :nodoc: 8 | module Logger 9 | macro included 10 | {% begin %} 11 | {% tname = @type.name.stringify.split("::").map(&.underscore).join(".") %} 12 | # :nodoc: 13 | Log = ::Log.for({{ tname }}) 14 | {% end %} 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | # Build documentation with MkDocs 9 | #mkdocs: 10 | # configuration: mkdocs.yml 11 | mkdocs: 12 | configuration: mkdocs.yml 13 | fail_on_warning: false 14 | 15 | # Optionally build your docs in additional formats such as PDF and ePub 16 | formats: all -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | mkdocs-material = "*" 8 | mkdocstrings = "*" 9 | mkdocs-gen-files = "*" 10 | mkdocstrings-crystal = ">= 0.2.2" 11 | mkdocs-section-index = "*" 12 | mkdocs-versioning = "*" 13 | mkdocs-literate-nav = ">= 0.3.0b1" 14 | mike = "*" 15 | mkdocs-macros-plugin = "*" 16 | 17 | [dev-packages] 18 | 19 | [requires] 20 | python_version = "3.8" 21 | 22 | [pipenv] 23 | allow_prereleases = true 24 | -------------------------------------------------------------------------------- /src/tourmaline/middleware.cr: -------------------------------------------------------------------------------- 1 | module Tourmaline 2 | abstract class Middleware 3 | @continue_iteration : Bool = false 4 | 5 | abstract def call(context : Context) 6 | 7 | def next 8 | @continue_iteration = true 9 | end 10 | 11 | def stop 12 | @continue_iteration = false 13 | end 14 | 15 | def call_internal(context : Context) 16 | self.call(context) 17 | raise Stop.new unless @continue_iteration 18 | end 19 | 20 | class Stop < Exception; end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /examples/dice_bot.cr: -------------------------------------------------------------------------------- 1 | require "../src/tourmaline" 2 | 3 | client = Tourmaline::Client.new(ENV["BOT_TOKEN"]) 4 | 5 | roll_command = Tourmaline::CommandHandler.new("roll") do |ctx| 6 | ctx.reply_with_dice 7 | end 8 | 9 | throw_command = Tourmaline::CommandHandler.new("throw") do |ctx| 10 | ctx.reply_with_dart 11 | end 12 | 13 | shoot_command = Tourmaline::CommandHandler.new("shoot") do |ctx| 14 | ctx.reply_with_basketball 15 | end 16 | 17 | client.register(roll_command, throw_command, shoot_command) 18 | 19 | client.poll 20 | -------------------------------------------------------------------------------- /examples/webhook_bot.cr: -------------------------------------------------------------------------------- 1 | require "ngrok" 2 | require "../src/tourmaline" 3 | 4 | client = Tourmaline::Client.new(ENV["BOT_TOKEN"]) 5 | 6 | echo_handler = Tourmaline::CommandHandler.new("echo") do |ctx| 7 | text = ctx.text.to_s 8 | ctx.reply(text) unless text.empty? 9 | end 10 | 11 | client.register(echo_handler) 12 | 13 | Ngrok.start(addr: "127.0.0.1:3000") do |ngrok| 14 | path = "/bot-webhook/#{ENV["BOT_TOKEN"]}" 15 | client.set_webhook(File.join(ngrok.url_https.to_s, path)) 16 | client.serve(host: "127.0.0.1", port: 3000, path: path) 17 | end 18 | -------------------------------------------------------------------------------- /src/tourmaline/types/custom/reply_keyboard_markup.cr: -------------------------------------------------------------------------------- 1 | module Tourmaline 2 | class ReplyKeyboardMarkup 3 | def <<(row : Int32, key : KeyboardButton) 4 | keyboard[row] << key 5 | end 6 | 7 | def <<(keys : Array(KeyboardButton)) 8 | keyboard << keys 9 | end 10 | 11 | def swap_row(row : Int32, keys : Array(KeyboardButton)) 12 | keyboard[row] = keys 13 | end 14 | 15 | def delete_row(row) 16 | keyboard[row].delete 17 | end 18 | 19 | def size 20 | keyboard.size 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /examples/button_bot.cr: -------------------------------------------------------------------------------- 1 | require "../src/tourmaline" 2 | 3 | client = Tourmaline::Client.new(ENV["BOT_TOKEN"]) 4 | 5 | REPLY_MARKUP = Tourmaline::InlineKeyboardMarkup.build(columns: 2) do 6 | url_button "some super long button text which won't fit on most screens", "https://google.com" 7 | url_button "some other button", "https://google.com" 8 | end 9 | 10 | help_handler = Tourmaline::CommandHandler.new("start") do |ctx| 11 | ctx.reply("This is a button demo", reply_markup: REPLY_MARKUP) 12 | end 13 | 14 | client.register(help_handler) 15 | 16 | client.poll 17 | -------------------------------------------------------------------------------- /src/tourmaline/chat_action.cr: -------------------------------------------------------------------------------- 1 | module Tourmaline 2 | # Chat actions are what appear at the top of the screen 3 | # when users are typing, sending files, etc. You can 4 | # mimic these actions by using the 5 | # `Client#send_chat_action` method. 6 | enum ChatAction 7 | Typing 8 | UploadPhoto 9 | RecordVideo 10 | UploadVideo 11 | RecordAudio 12 | RecordVoice 13 | UploadAudio 14 | UploadVoice 15 | UploadDocument 16 | Findlocation 17 | RecordVideoNote 18 | UploadVideoNote 19 | 20 | def to_s 21 | super.to_s.underscore 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: watzon 4 | patreon: watzon 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with a single custom sponsorship URL 13 | -------------------------------------------------------------------------------- /.github/workflows/crystal.yml: -------------------------------------------------------------------------------- 1 | name: Specs 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | crystal-version: [1.10.1] 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | - uses: oprypin/install-crystal@v1 16 | with: 17 | crystal: ${{ matrix.crystal-version }} 18 | 19 | - name: Install dependencies 20 | run: shards install --ignore-crystal-version 21 | 22 | - name: Format code 23 | run: crystal tool format && git diff --exit-code 24 | 25 | - name: Run tests 26 | run: crystal spec 27 | -------------------------------------------------------------------------------- /src/tourmaline.cr: -------------------------------------------------------------------------------- 1 | require "http_proxy" 2 | require "mime/multipart" 3 | 4 | require "./ext/*" 5 | require "./tourmaline/version" 6 | require "./tourmaline/client" 7 | 8 | # Tourmaline is a Telegram Bot API library 9 | # for [Telegram](https://telegram.com). It provides an easy to 10 | # use interface for creating telegram bots, and using the 11 | # various bot APIs that Telegram provides. 12 | # 13 | # For usage examples, see the 14 | # [examples](https://github.com/watzon/tourmaline/tree/master/examples) 15 | # directory. For guides on using Tourmaline, see the 16 | # [usage docs section](https://tourmaline.dev/usage/). 17 | module Tourmaline 18 | end 19 | -------------------------------------------------------------------------------- /src/tourmaline/parse_mode.cr: -------------------------------------------------------------------------------- 1 | module Tourmaline 2 | # Parse mode for messages. 3 | enum ParseMode 4 | None 5 | Markdown 6 | MarkdownV2 7 | HTML 8 | 9 | def self.new(pull : JSON::PullParser) 10 | case pull.read_string_or_null 11 | when "Markdown" 12 | Markdown 13 | when "MarkdownV2" 14 | MarkdownV2 15 | when "HTML" 16 | HTML 17 | else 18 | None 19 | end 20 | end 21 | 22 | def to_json(json : JSON::Builder) 23 | case self 24 | when None 25 | json.null 26 | else 27 | json.string(to_s) 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /docs/gen_doc_stubs.py: -------------------------------------------------------------------------------- 1 | # Generates virtual doc files for the mkdocs site. 2 | # You can also run this script directly to actually write out those files, as a preview. 3 | 4 | import collections 5 | import mkdocs_gen_files 6 | 7 | root = mkdocs_gen_files.config['plugins']['mkdocstrings'].get_handler('crystal').collector.root 8 | 9 | for typ in root.walk_types(): 10 | filename = 'api_reference/' + '/'.join(typ.abs_id.split('::')) + '/index.md' 11 | 12 | with mkdocs_gen_files.open(filename, 'w') as f: 13 | f.write(f'# ::: {typ.abs_id}\n\n') 14 | 15 | if typ.locations: 16 | mkdocs_gen_files.set_edit_path(filename, typ.locations[0].url) 17 | -------------------------------------------------------------------------------- /shard.yml: -------------------------------------------------------------------------------- 1 | name: tourmaline 2 | version: 0.29.0 3 | 4 | authors: 5 | - Chris Watson 6 | 7 | crystal: ">= 1.6.0" 8 | 9 | crystalline: 10 | main: examples/echo_bot.cr 11 | 12 | dependencies: 13 | db: 14 | github: crystal-lang/crystal-db 15 | version: ~> 0.11.0 16 | http_proxy: 17 | github: mamantoha/http_proxy 18 | branch: master 19 | 20 | development_dependencies: 21 | ngrok: 22 | github: watzon/ngrok.cr 23 | version: 0.3.1 24 | spectator: 25 | github: icy-arctic-fox/spectator 26 | version: ~> 0.11.3 27 | code_writer: 28 | # path: ../code_writer 29 | github: watzon/code_writer 30 | branch: main 31 | 32 | license: MIT 33 | -------------------------------------------------------------------------------- /examples/live_location_bot.cr: -------------------------------------------------------------------------------- 1 | require "../src/tourmaline" 2 | 3 | client = Tourmaline::Client.new(ENV["BOT_TOKEN"]) 4 | 5 | send_live_location = Tourmaline::CommandHandler.new("start") do |ctx| 6 | begin 7 | lat = 40.7608 8 | lon = 111.8910 9 | if loc = ctx.reply_with_location(latitude: lat, longitude: lon, live_period: 60) 10 | loop do 11 | lat += rand * 0.001 12 | lon += rand * 0.001 13 | ctx.edit_live_location(latitude: lat, longitude: lon) 14 | sleep(5) 15 | end 16 | end 17 | rescue ex : Tourmaline::Error::MessageCantBeEdited 18 | ctx.reply("Message can't be edited") 19 | end 20 | end 21 | 22 | client.register(send_live_location) 23 | 24 | client.poll 25 | -------------------------------------------------------------------------------- /src/tourmaline/handlers/hears_handler.cr: -------------------------------------------------------------------------------- 1 | module Tourmaline 2 | class HearsHandler < EventHandler 3 | getter actions : Array(UpdateAction) = [UpdateAction::Text] 4 | 5 | getter pattern : Regex 6 | 7 | def initialize(pattern : String | Regex, @proc : EventHandlerProc) 8 | super() 9 | @pattern = pattern.is_a?(Regex) ? pattern : Regex.new(Regex.escape(pattern)) 10 | end 11 | 12 | def self.new(pattern : String | Regex, &block : EventHandlerProc) 13 | new(pattern, block) 14 | end 15 | 16 | def call(ctx : Context) 17 | if text = ctx.text(strip_command: false) 18 | if match = text.match(@pattern) 19 | @proc.call(ctx) 20 | end 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /src/tourmaline/handlers/inline_query_handler.cr: -------------------------------------------------------------------------------- 1 | module Tourmaline 2 | class InlineQueryHandler < EventHandler 3 | getter actions : Array(UpdateAction) = [UpdateAction::InlineQuery] 4 | 5 | getter pattern : Regex 6 | 7 | def initialize(pattern : String | Regex, @proc : EventHandlerProc) 8 | super() 9 | @pattern = pattern.is_a?(Regex) ? pattern : Regex.new("^#{Regex.escape(pattern)}$") 10 | end 11 | 12 | def self.new(pattern : String | Regex, &block : EventHandlerProc) 13 | new(pattern, block) 14 | end 15 | 16 | def call(ctx : Context) 17 | if query = ctx.inline_query 18 | if data = query.query 19 | if match = data.match(@pattern) 20 | @proc.call(ctx) 21 | end 22 | end 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /src/tourmaline/handlers/callback_query_handler.cr: -------------------------------------------------------------------------------- 1 | module Tourmaline 2 | class CallbackQueryHandler < EventHandler 3 | getter actions : Array(UpdateAction) = [UpdateAction::CallbackQuery] 4 | 5 | getter pattern : Regex 6 | 7 | def initialize(pattern : String | Regex, @proc : EventHandlerProc) 8 | super() 9 | @pattern = pattern.is_a?(Regex) ? pattern : Regex.new("^#{Regex.escape(pattern)}$") 10 | end 11 | 12 | def self.new(pattern : String | Regex, &block : EventHandlerProc) 13 | new(pattern, block) 14 | end 15 | 16 | def call(ctx : Context) 17 | if query = ctx.callback_query 18 | data = query.data || query.game_short_name 19 | if data 20 | if match = data.match(@pattern) 21 | @proc.call(ctx) 22 | end 23 | end 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /src/tourmaline/types/custom/chat.cr: -------------------------------------------------------------------------------- 1 | module Tourmaline 2 | class Chat 3 | def name 4 | if first_name || last_name 5 | [first_name, last_name].compact.join(" ") 6 | else 7 | title.to_s 8 | end 9 | end 10 | 11 | def supergroup? 12 | type == Type::Supergroup 13 | end 14 | 15 | def private? 16 | type == Type::Private 17 | end 18 | 19 | def group? 20 | type == Type::Group 21 | end 22 | 23 | def channel? 24 | type == Type::Channel 25 | end 26 | 27 | enum Type 28 | Private 29 | Group 30 | Supergroup 31 | Channel 32 | 33 | def self.new(pull : JSON::PullParser) 34 | parse(pull.read_string) 35 | end 36 | 37 | def to_json(json : JSON::Builder) 38 | json.string(to_s) 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /examples/inline_query_bot.cr: -------------------------------------------------------------------------------- 1 | require "../src/tourmaline" 2 | 3 | client = Tourmaline::Client.new(ENV["BOT_TOKEN"]) 4 | 5 | on_inline_query = Tourmaline::InlineQueryHandler.new(/.*/) do |ctx| 6 | if query = ctx.inline_query 7 | results = client.build_inline_query_result do |qr| 8 | qr.article( 9 | id: "query", 10 | title: "Inline title", 11 | input_message_content: Tourmaline::InputTextMessageContent.new("Click!"), 12 | description: "Your query: #{query.query}" 13 | ) 14 | 15 | qr.photo( 16 | id: "photo", 17 | caption: "Telegram logo", 18 | photo_url: "https://telegram.org/img/t_logo.png", 19 | thumbnail_url: "https://telegram.org/img/t_logo.png" 20 | ) 21 | 22 | qr.gif( 23 | id: "gif", 24 | gif_url: "https://telegram.org/img/tl_card_wecandoit.gif", 25 | thumbnail_url: "https://telegram.org/img/tl_card_wecandoit.gif" 26 | ) 27 | end 28 | 29 | ctx.answer_inline_query(results) 30 | end 31 | end 32 | 33 | client.register(on_inline_query) 34 | 35 | client.poll 36 | -------------------------------------------------------------------------------- /src/tourmaline/poller.cr: -------------------------------------------------------------------------------- 1 | module Tourmaline 2 | # The `Poller` class is responsible for polling Telegram's API for updates, 3 | # and then passing them to the `Dispatcher` for processing. 4 | class Poller 5 | Log = ::Log.for(self) 6 | 7 | getter offset : Int64 8 | 9 | def initialize(client : Tourmaline::Client) 10 | @client = client 11 | @offset = 0_i64 12 | @polling = false 13 | end 14 | 15 | # Starts polling Telegram's API for updates. 16 | def start 17 | @client.delete_webhook 18 | Log.info { "Polling for updates..." } 19 | @polling = true 20 | while @polling 21 | poll_and_dispatch 22 | end 23 | end 24 | 25 | def stop 26 | @polling = false 27 | end 28 | 29 | def poll_and_dispatch 30 | updates = get_updates 31 | updates.each do |update| 32 | @client.dispatcher.process(update) 33 | @offset = Int64.new(update.update_id + 1) 34 | end 35 | end 36 | 37 | def get_updates 38 | @client.get_updates(offset: offset, timeout: 30) 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /docs/stylesheets/extra.css: -------------------------------------------------------------------------------- 1 | [data-md-color-scheme="default"] { 2 | --md-heading-bg-color: #F8F8F8; 3 | --md-including-bg-color: #E2E2E2; 4 | } 5 | 6 | [data-md-color-scheme="slate"] { 7 | --md-heading-bg-color: #3E3E46; 8 | --md-including-bg-color: #C4C4C4; 9 | } 10 | 11 | .doc-method:target::before { 12 | display: block; 13 | margin-top: -4.1rem; 14 | padding-top: 4.1rem; 15 | content: ""; 16 | } 17 | 18 | .doc-type > .doc-heading { 19 | font-size: 1.1em !important; 20 | } 21 | 22 | .doc-type > .doc-heading > code { 23 | font-size: 1.1em !important; 24 | vertical-align: baseline !important; 25 | } 26 | 27 | .doc-object .doc-heading { 28 | padding: 8px !important; 29 | background-color: var(--md-heading-bg-color) !important; 30 | } 31 | 32 | .doc-object .doc-contents { 33 | font-size: 0.9em; 34 | } 35 | 36 | .doc-contents > .doc-children > h2 + code { 37 | font-size: 1.2em; 38 | background-color: var(--md-including-bg-color) !important; 39 | } 40 | 41 | .doc-source-link { 42 | font-size: 80%; 43 | } 44 | 45 | .md-typeset dd { 46 | margin: 0.4em 0 0.8em 1.875em; 47 | } 48 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Chris Watson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/tourmaline/handlers/command_handler.cr: -------------------------------------------------------------------------------- 1 | module Tourmaline 2 | class CommandHandler < EventHandler 3 | getter actions : Array(UpdateAction) = [UpdateAction::Message] 4 | 5 | getter commands : Array(String) 6 | 7 | def initialize(commands : String | Array(String), @proc : EventHandlerProc) 8 | super() 9 | @commands = commands.is_a?(String) ? [commands] : commands 10 | end 11 | 12 | def self.new(commands : String | Array(String), &block : EventHandlerProc) 13 | new(commands, block) 14 | end 15 | 16 | def call(ctx : Context) 17 | if (message = ctx.message) || (message = ctx.channel_post) 18 | if (text = message.text) || (text = message.caption) 19 | command_entities = message.text_entities("bot_command").to_a 20 | return if command_entities.empty? 21 | 22 | _, command = command_entities.first 23 | command = command[1..] 24 | command = command.includes?("@") ? command.split("@").first : command 25 | return unless @commands.includes?(command) 26 | 27 | @proc.call(ctx) 28 | end 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /examples/poll_bot.cr: -------------------------------------------------------------------------------- 1 | require "../src/tourmaline" 2 | 3 | client = Tourmaline::Client.new(ENV["BOT_TOKEN"]) 4 | 5 | start_command = Tourmaline::CommandHandler.new("start") do |ctx| 6 | keyboard = client.build_reply_keyboard_markup do |kb| 7 | kb.poll_request_button "Create poll", :regular 8 | kb.poll_request_button "Create quiz", :quiz 9 | end 10 | ctx.reply("Use the command /poll or /quiz to begin", reply_markup: keyboard) 11 | end 12 | 13 | poll_command = Tourmaline::CommandHandler.new("poll") do |ctx| 14 | ctx.reply_with_poll( 15 | "Your favorite math constant", 16 | ["x", "e", "π", "φ", "γ"], 17 | is_anonymous: false 18 | ) 19 | end 20 | 21 | quiz_command = Tourmaline::CommandHandler.new("quiz") do |ctx| 22 | ctx.reply_with_poll( 23 | "2b|!2b", 24 | ["True", "False"], 25 | correct_option_id: 0, 26 | type: "quiz" 27 | ) 28 | end 29 | 30 | client.register(start_command, poll_command, quiz_command) 31 | 32 | client.on(:poll) do |ctx| 33 | puts "Poll update:" 34 | pp ctx.poll 35 | end 36 | 37 | client.on(:poll_answer) do |ctx| 38 | puts "Poll answer:" 39 | pp ctx.poll_answer 40 | end 41 | 42 | client.poll 43 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Docs 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | python-version: [3.8] 14 | crystal-version: [1.0.0] 15 | 16 | steps: 17 | - uses: actions/checkout@v2 18 | - uses: oprypin/install-crystal@v1 19 | with: 20 | crystal: ${{ matrix.crystal-version }} 21 | - uses: actions/setup-python@v2 22 | with: 23 | python-version: ${{ matrix.python-version }} 24 | - name: Install Python Dependencies 25 | run: | 26 | python -m pip install --upgrade pip 27 | pip install -r requirements.txt 28 | - name: Install Crystal Dependencies 29 | run: shards install --ignore-crystal-version 30 | - name: Build Docs 31 | run: mkdocs build 32 | - name: Deploy to GitHub Pages 33 | if: success() 34 | uses: crazy-max/ghaction-github-pages@v2 35 | with: 36 | keep_history: true 37 | build_dir: site 38 | committer_name: Chris Watson 39 | committer_email: cawatson1993@gmail.com 40 | commit_message: "Update docs" 41 | env: 42 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 43 | -------------------------------------------------------------------------------- /examples/kitty_bot.cr: -------------------------------------------------------------------------------- 1 | require "../src/tourmaline" 2 | 3 | client = Tourmaline::Client.new(ENV["BOT_TOKEN"]) 4 | 5 | API_URL = "https://thecatapi.com/api/images/get" 6 | 7 | help_command = Tourmaline::CommandHandler.new(["help", "start"]) do |ctx| 8 | markup = client.build_reply_keyboard_markup do |kb| 9 | kb.button "/kitty" 10 | kb.button "/kittygif" 11 | end 12 | ctx.reply("😺 Use commands: /kitty, /kittygif and /about", reply_markup: markup) 13 | end 14 | 15 | about_command = Tourmaline::CommandHandler.new("about") do |ctx| 16 | text = "😽 This bot is powered by Tourmaline, a Telegram bot library for Crystal. Visit https://github.com/watzon/tourmaline to check out the source code." 17 | ctx.reply(text) 18 | end 19 | 20 | kitty_command = Tourmaline::CommandHandler.new(["kitty", "kittygif"]) do |ctx| 21 | # The time hack is to get around Telegram's image cache 22 | api = API_URL + "?time=#{Time.utc}&format=src&type=" 23 | case ctx.command! 24 | when "kitty" 25 | ctx.send_chat_action(:upload_photo) 26 | ctx.respond_with_photo(api + "jpg") 27 | when "kittygif" 28 | ctx.send_chat_action(:upload_photo) 29 | ctx.respond_with_animation(api + "gif") 30 | else 31 | end 32 | end 33 | 34 | client.register(help_command, about_command, kitty_command) 35 | 36 | client.poll 37 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | hide: 3 | - navigation 4 | - toc 5 | --- 6 | 7 | 39 | 40 | 41 | 42 |

Tourmaline

43 |

Crystal Telegram Bot Framework

44 | 45 |
46 | 47 |

Quickstart

48 | 49 |

Tourmaline uses Crystal to communicate with the Telegram Bot API. Therefore to use Tourmaline you should be familiar with how Crystal works.

50 |

Once inside of a Crystal project, add Tourmaline to your shard.yml file and run shards install to install it.

51 |

For more information, see the getting started page.

52 | 53 |
54 | 55 | ```crystal 56 | require "tourmaline" 57 | 58 | client = Tourmaline::Client.new(ENV["BOT_TOKEN"]) 59 | 60 | echo_handler = Tourmaline::CommandHandler.new("echo") do |ctx| 61 | text = ctx.text.to_s 62 | ctx.reply(text) unless text.empty? 63 | end 64 | 65 | client.register(echo_handler) 66 | 67 | client.poll 68 | ``` 69 | 70 |
71 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Tourmaline 2 | repo_url: https://github.com/protoncr/tourmaline 3 | edit_uri: https://github.com/protoncr/tourmaline/tree/master/docs/ 4 | site_url: https://tourmaline.dev 5 | 6 | theme: 7 | name: material 8 | palette: 9 | - scheme: default 10 | primary: teal 11 | toggle: 12 | icon: material/toggle-switch-off-outline 13 | name: Switch to dark mode 14 | - scheme: slate 15 | toggle: 16 | icon: material/toggle-switch 17 | name: Switch to light mode 18 | features: 19 | - navigation.instant 20 | - navigation.tabs 21 | - navigation.sections 22 | - navigation.tracking 23 | - content.code.annotate 24 | icon: 25 | logo: fontawesome/brands/telegram 26 | repo: fontawesome/brands/github 27 | favicon: images/logo.png 28 | 29 | extra_css: 30 | - stylesheets/extra.css 31 | 32 | plugins: 33 | - search: 34 | indexing: full 35 | - mkdocstrings: 36 | default_handler: crystal 37 | watch: [src] 38 | - gen-files: 39 | scripts: 40 | - docs/gen_doc_stubs.py 41 | - literate-nav: 42 | nav_file: SUMMARY.md 43 | - section-index 44 | - macros: 45 | modules: 46 | - mkdocstrings.handlers.crystal.macros 47 | 48 | markdown_extensions: 49 | - meta 50 | - pymdownx.highlight 51 | - pymdownx.inlinehilite 52 | - pymdownx.magiclink 53 | - pymdownx.saneheaders 54 | - pymdownx.snippets 55 | - pymdownx.superfences 56 | - pymdownx.critic 57 | - pymdownx.betterem: 58 | smart_enable: all 59 | - pymdownx.caret 60 | - pymdownx.mark 61 | - pymdownx.tilde 62 | - attr_list 63 | - deduplicate-toc 64 | - admonition 65 | - def_list 66 | - toc: 67 | permalink: "#" 68 | 69 | # extra: 70 | # version: 71 | # method: mike 72 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # These requirements were autogenerated by pipenv 3 | # To regenerate from the project's Pipfile, run: 4 | # 5 | # pipenv lock --requirements 6 | # 7 | 8 | -i https://pypi.org/simple 9 | astunparse==1.6.3; python_version < '3.9' 10 | cached-property==1.5.2 11 | click==8.0.3; python_version >= '3.6' 12 | ghp-import==2.0.2 13 | importlib-metadata==4.8.2; python_version < '3.10' 14 | jinja2==3.0.3; python_version >= '3.6' 15 | markdown-callouts==0.2.0; python_version >= '3.6' and python_version < '4.0' 16 | markdown==3.3.6; python_version >= '3.6' 17 | markupsafe==2.0.1; python_version >= '3.6' 18 | mergedeep==1.3.4; python_version >= '3.6' 19 | mike==1.1.2 20 | mkdocs-autorefs==0.3.0; python_version >= '3.6' and python_version < '4.0' 21 | mkdocs-gen-files==0.3.3 22 | mkdocs-literate-nav==0.4.0 23 | mkdocs-macros-plugin==0.6.3 24 | mkdocs-material-extensions==1.0.3; python_version >= '3.6' 25 | mkdocs-material==8.0.5 26 | mkdocs-section-index==0.3.2 27 | mkdocs-versioning==0.4.0 28 | mkdocs==1.2.3; python_version >= '3.6' 29 | mkdocstrings-crystal==0.3.4 30 | mkdocstrings==0.16.2 31 | packaging==21.3; python_version >= '3.6' 32 | pygments==2.10.0; python_version >= '3.5' 33 | pymdown-extensions==9.1; python_version >= '3.6' 34 | pyparsing==3.0.6; python_version >= '3.6' 35 | python-dateutil==2.8.2; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2' 36 | pytkdocs==0.12.0; python_full_version >= '3.6.1' 37 | pyyaml-env-tag==0.1; python_version >= '3.6' 38 | pyyaml==6.0; python_version >= '3.6' 39 | six==1.16.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2' 40 | termcolor==1.1.0 41 | verspec==0.1.0 42 | watchdog==2.1.6; python_version >= '3.6' 43 | wheel==0.38.1; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' 44 | zipp==3.6.0; python_version >= '3.6' 45 | -------------------------------------------------------------------------------- /src/tourmaline/client/reply_keyboard_markup_builder.cr: -------------------------------------------------------------------------------- 1 | module Tourmaline 2 | class Client 3 | def self.build_reply_keyboard_markup(*args, columns = nil, **options, &block : ReplyKeyboardMarkupBuilder ->) 4 | builder = ReplyKeyboardMarkupBuilder.new(*args, **options) 5 | yield builder 6 | builder.keyboard(columns) 7 | end 8 | 9 | def build_reply_keyboard_markup(*args, columns = nil, **options, &block : ReplyKeyboardMarkupBuilder ->) 10 | self.class.build_reply_keyboard_markup(*args, **options, columns: columns, &block) 11 | end 12 | 13 | class ReplyKeyboardMarkupBuilder < KeyboardBuilder(Tourmaline::KeyboardButton, Tourmaline::ReplyKeyboardMarkup) 14 | def keyboard(columns = nil) : G 15 | buttons = KeyboardBuilder(T, G).build_keyboard(@keyboard, columns: columns || 1) 16 | ReplyKeyboardMarkup.new( 17 | keyboard: buttons, 18 | is_persistent: @persistent, 19 | resize_keyboard: @resize, 20 | one_time_keyboard: @one_time, 21 | input_field_placeholder: @input_field_placeholder, 22 | selective: @selective 23 | ) 24 | end 25 | 26 | def text_button(text) 27 | button(text) 28 | end 29 | 30 | def contact_request_button(text) 31 | button(text, request_contact: true) 32 | end 33 | 34 | def location_request_button(text) 35 | button(text, request_location: true) 36 | end 37 | 38 | def poll_request_button(text, type) 39 | type = KeyboardButtonPollType.new(type.to_s) 40 | button(text, request_poll: type) 41 | end 42 | 43 | def web_app_button(app : String | WebAppInfo) 44 | web_app = app.is_a?(String) ? WebAppInfo.new(app) : app 45 | button(url, web_app: web_app) 46 | end 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /src/tourmaline/dispatcher.cr: -------------------------------------------------------------------------------- 1 | module Tourmaline 2 | # The Dispatcher is responsible for dispatching requests to the appropria 3 | class Dispatcher 4 | getter middleware : Array(Middleware) 5 | getter event_handlers : Hash(UpdateAction, Array(EventHandlerType)) 6 | 7 | def initialize(@client : Client) 8 | @middleware = [] of Middleware 9 | @event_handlers = {} of UpdateAction => Array(EventHandlerType) 10 | end 11 | 12 | def process(update : Update) 13 | actions = UpdateAction.from_update(update) 14 | context = Context.new(@client, update) 15 | 16 | @middleware.each do |middleware| 17 | begin 18 | middleware.call_internal(context) 19 | rescue stop : Middleware::Stop 20 | return 21 | end 22 | end 23 | 24 | actions.each do |action| 25 | if handlers = @event_handlers[action]? 26 | handlers.each do |handler| 27 | handler.call(context) 28 | end 29 | end 30 | end 31 | end 32 | 33 | def on(*actions : UpdateAction, &block : Context ->) 34 | actions.each do |action| 35 | @event_handlers[action] ||= [] of EventHandlerType 36 | @event_handlers[action] << block 37 | end 38 | end 39 | 40 | def register(handler : EventHandler) 41 | handler.actions.each do |action| 42 | @event_handlers[action] ||= [] of EventHandlerType 43 | @event_handlers[action] << handler 44 | end 45 | end 46 | 47 | def use(*middlewares : Middleware) 48 | middlewares.each do |middleware| 49 | @middleware << middleware 50 | end 51 | end 52 | 53 | def use(*middlewares : Middleware.class) 54 | middlewares.each do |middleware| 55 | @middleware << middleware.new 56 | end 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /src/tourmaline/client/inline_keyboard_markup_builder.cr: -------------------------------------------------------------------------------- 1 | module Tourmaline 2 | class Client 3 | def self.build_inline_keyboard_markup(*args, columns = nil, **options, &block : InlineKeyboardMarkupBuilder ->) 4 | builder = InlineKeyboardMarkupBuilder.new(*args, **options) 5 | yield builder 6 | builder.keyboard(columns) 7 | end 8 | 9 | def build_inline_keyboard_markup(*args, columns = nil, **options, &block : InlineKeyboardMarkupBuilder ->) 10 | self.class.build_inline_keyboard_markup(*args, **options, columns: columns, &block) 11 | end 12 | 13 | class InlineKeyboardMarkupBuilder < KeyboardBuilder(Tourmaline::InlineKeyboardButton, Tourmaline::InlineKeyboardMarkup) 14 | def keyboard(columns = nil) : G 15 | buttons = KeyboardBuilder(T, G).build_keyboard(@keyboard, columns: columns || 1) 16 | InlineKeyboardMarkup.new(inline_keyboard: buttons) 17 | end 18 | 19 | def url_button(text, url) 20 | button(text, url: url) 21 | end 22 | 23 | def callback_button(text, data) 24 | button(text, callback_data: data) 25 | end 26 | 27 | def switch_to_chat_button(text, value) 28 | button(text, switch_inline_query: value) 29 | end 30 | 31 | def switch_to_current_chat_button(text, value) 32 | button(text, switch_inline_query_current_chat: value) 33 | end 34 | 35 | def game_button(text) 36 | button(text, callback_game: CallbackGame.new) 37 | end 38 | 39 | def pay_button(text) 40 | button(text, pay: true) 41 | end 42 | 43 | def login_button(text, url, *args, **opts) 44 | button(text, login_url: LoginUrl.new(url, *args, **opts)) 45 | end 46 | 47 | def web_app_button(app : String | WebAppInfo) 48 | web_app = app.is_a?(String) ? WebAppInfo.new(app) : app 49 | button(url, web_app: web_app) 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /examples/shop_bot.cr: -------------------------------------------------------------------------------- 1 | require "../src/tourmaline" 2 | 3 | client = Tourmaline::Client.new(ENV["BOT_TOKEN"]) 4 | 5 | INVOICE = { 6 | title: "Working Time Machine", 7 | description: "Want to visit your great-great-great-grandparents? Make a fortune at the races? Shake hands with Hammurabi and take a stroll in the Hanging Gardens? Order our Working Time Machine today!", 8 | payload: {coupon: "BLACK FRIDAY"}.to_s, 9 | provider_token: ENV["PROVIDER_TOKEN"], 10 | start_parameter: "time-machine-sku", 11 | currency: "usd", 12 | prices: [ 13 | Tourmaline::LabeledPrice.new(label: "Working Time Machine", amount: 4200), 14 | Tourmaline::LabeledPrice.new(label: "Gift Wrapping", amount: 1000), 15 | ], 16 | photo_url: "https://img.clipartfest.com/5a7f4b14461d1ab2caaa656bcee42aeb_future-me-fredo-and-pidjin-the-webcomic-time-travel-cartoon_390-240.png", 17 | } 18 | 19 | SHIPPING_OPTIONS = [ 20 | Tourmaline::ShippingOption.new("unicorn", "Unicorn express", [Tourmaline::LabeledPrice.new(label: "Unicorn", amount: 2000)]), 21 | Tourmaline::ShippingOption.new("slowpoke", "Slowpoke Mail", [Tourmaline::LabeledPrice.new(label: "Unicorn", amount: 100)]), 22 | ] 23 | 24 | REPLY_MARKUP = Tourmaline::InlineKeyboardMarkup.build do 25 | pay_button "💸 Buy" 26 | url_button "❤️", "https://github.com/watzon/tourmaline" 27 | end 28 | 29 | start_command = Tourmaline::CommandHandler.new("start") do |ctx| 30 | ctx.reply_with_invoice(**INVOICE) 31 | end 32 | 33 | buy_command = Tourmaline::CommandHandler.new("buy") do |ctx| 34 | ctx.reply_with_invoice(**INVOICE.merge({reply_markup: REPLY_MARKUP})) 35 | end 36 | 37 | client.register(start_command, buy_command) 38 | 39 | client.on(:shipping_query) do |ctx| 40 | ctx.answer_query(true, shipping_options: SHIPPING_OPTIONS) 41 | end 42 | 43 | client.on(:pre_checkout_query) do |ctx| 44 | query.answer(true) 45 | end 46 | 47 | client.on(:successful_payment) do |ctx| 48 | ctx.reply("Thank you for your purchase!") 49 | end 50 | 51 | client.poll 52 | -------------------------------------------------------------------------------- /.github/workflows/bot-api-updates.yml: -------------------------------------------------------------------------------- 1 | # Github workflow to update the code when the bot API is updated. 2 | # Runs every day, and can be manually triggered. 3 | # On trigger, runs the generate script at `scripts/generate.cr`, commits the changes (if any) to the `develop` branch, 4 | # and opens a pull request to merge the changes into `master`. 5 | 6 | name: Update code 7 | on: 8 | workflow_dispatch: {} # Allow manual triggering 9 | schedule: 10 | - cron: "0 0 * * *" 11 | jobs: 12 | update: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | crystal-version: [1.10.1] 17 | steps: 18 | - uses: actions/checkout@v2 19 | with: 20 | ref: develop 21 | - uses: oprypin/install-crystal@v1 22 | with: 23 | crystal: ${{ matrix.crystal-version }} 24 | - name: Install dependencies 25 | run: shards install 26 | - name: Generate code 27 | id: generate 28 | run: crystal scripts/generate.cr 29 | - name: Format code 30 | run: crystal tool format 31 | - name: Commit changes 32 | run: | 33 | git config --local user.email "cawatson1993@gmail.com" 34 | git config --local user.name "Chris Watson" 35 | git add . 36 | git commit -am "bot api update" 37 | - name: Push changes 38 | uses: ad-m/github-push-action@master 39 | with: 40 | github_token: ${{ secrets.GITHUB_TOKEN }} 41 | branch: develop 42 | - name: Create pull request 43 | uses: peter-evans/create-pull-request@v3 44 | with: 45 | token: ${{ secrets.GITHUB_TOKEN }} 46 | commit-message: "bot api update" 47 | title: "bot api update" 48 | body: ${{ steps.generate.outputs.output }} 49 | branch: develop 50 | base: master -------------------------------------------------------------------------------- /src/tourmaline/server.cr: -------------------------------------------------------------------------------- 1 | module Tourmaline 2 | # The `Server` class is a basic webhook server for receiving 3 | # updates from the Telegram API. 4 | class Server 5 | @server : HTTP::Server? 6 | 7 | def initialize(client : Tourmaline::Client) 8 | @client = client 9 | end 10 | 11 | # Start an HTTP server at the specified `host` and `port` that listens for 12 | # updates using Telegram's webhooks. 13 | def serve(host = "127.0.0.1", port = 8081, ssl_certificate_path = nil, ssl_key_path = nil, no_middleware_check = false, &block : HTTP::Server::Context ->) 14 | @server = server = HTTP::Server.new do |context| 15 | Fiber.current.telegram_bot_server_http_context = context 16 | begin 17 | block.call(context) 18 | rescue ex 19 | Log.error(exception: ex) { "Server error" } 20 | ensure 21 | Fiber.current.telegram_bot_server_http_context = nil 22 | end 23 | end 24 | 25 | if ssl_certificate_path && ssl_key_path 26 | fl_use_ssl = true 27 | ssl = OpenSSL::SSL::Context::Server.new 28 | ssl.certificate_chain = ssl_certificate_path.not_nil! 29 | ssl.private_key = ssl_key_path.not_nil! 30 | server.bind_tls(host: host, port: port, context: ssl) 31 | else 32 | server.bind_tcp(host: host, port: port) 33 | end 34 | 35 | Log.info { "Listening for requests at #{host}:#{port}" } 36 | server.listen 37 | end 38 | 39 | # :ditto: 40 | def serve(path = "/", host = "127.0.0.1", port = 8081, ssl_certificate_path = nil, ssl_key_path = nil, no_middleware_check = false) 41 | serve(host, port, ssl_certificate_path, ssl_key_path, no_middleware_check) do |context| 42 | next unless context.request.method == "POST" 43 | next unless context.request.path == path 44 | if body = context.request.body 45 | update = Update.from_json(body) 46 | @client.dispatcher.process(update) 47 | end 48 | end 49 | end 50 | 51 | # Stops the webhook HTTP server 52 | def stop_serving 53 | @server.try &.close 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /examples/media_bot.cr: -------------------------------------------------------------------------------- 1 | require "../src/tourmaline" 2 | 3 | client = Tourmaline::Client.new(ENV["BOT_TOKEN"]) 4 | 5 | ANIMATION_URL_1 = "https://media.giphy.com/media/ya4eevXU490Iw/giphy.gif" 6 | ANIMATION_URL_2 = "https://media.giphy.com/media/LrmU6jXIjwziE/giphy.gif" 7 | LOCAL_FILE = File.expand_path("./cat.jpg", __DIR__) 8 | 9 | local_command = Tourmaline::CommandHandler.new("local") do |ctx| 10 | ctx.reply_with_photo(File.open(LOCAL_FILE, "rb")) 11 | end 12 | 13 | url_command = Tourmaline::CommandHandler.new("url") do |ctx| 14 | ctx.reply_with_photo("https://picsum.photos/200/300/?#{rand}") 15 | end 16 | 17 | animation_command = Tourmaline::CommandHandler.new("animation") do |ctx| 18 | ctx.reply_with_animation(ANIMATION_URL_1) 19 | end 20 | 21 | caption_command = Tourmaline::CommandHandler.new("caption") do |ctx| 22 | ctx.reply_with_photo( 23 | "https://picsum.photos/200/300/?#{rand}", 24 | caption: "Caption **text**" 25 | ) 26 | end 27 | 28 | document_command = Tourmaline::CommandHandler.new("document") do |ctx| 29 | ctx.reply_with_document(File.open(LOCAL_FILE, "rb")) 30 | end 31 | 32 | album_command = Tourmaline::CommandHandler.new("album") do |ctx| 33 | ctx.reply_with_media_group([ 34 | Tourmaline::InputMediaPhoto.new( 35 | media: "https://picsum.photos/200/500/", 36 | caption: "From url" 37 | ), 38 | Tourmaline::InputMediaPhoto.new( 39 | media: File.expand_path("./cat.jpg", __DIR__), 40 | caption: "Local" 41 | ), 42 | ]) 43 | end 44 | 45 | editmedia_command = Tourmaline::CommandHandler.new("editmedia") do |ctx| 46 | ctx.reply_with_animation( 47 | ANIMATION_URL_1, 48 | reply_markup: client.build_inline_keyboard_markup do |kb| 49 | kb.callback_button("Change media", "swap_media") 50 | end 51 | ) 52 | end 53 | 54 | on_swap_media = Tourmaline::CallbackQueryHandler.new("swap_media") do |ctx| 55 | ctx.with_message do |msg| 56 | ctx.client.edit_message_media(media: Tourmaline::InputMediaAnimation.new(ANIMATION_URL_2), message_id: msg.message_id, chat_id: msg.chat.id) 57 | end 58 | end 59 | 60 | client.register(local_command, url_command, animation_command, caption_command, document_command, album_command, editmedia_command, on_swap_media) 61 | 62 | client.poll 63 | -------------------------------------------------------------------------------- /src/tourmaline/types/custom/update.cr: -------------------------------------------------------------------------------- 1 | module Tourmaline 2 | class Update 3 | @[JSON::Field(ignore: true)] 4 | getter update_actions : Array(UpdateAction) { UpdateAction.from_update(self) } 5 | 6 | {% for action in Tourmaline::UpdateAction.constants %} 7 | def {{ action.id.underscore }}? 8 | if self.update_actions.includes?(UpdateAction::{{ action.id }}) 9 | true 10 | else 11 | false 12 | end 13 | end 14 | {% end %} 15 | 16 | # Returns all users included in this update as a Set 17 | def users 18 | users = [] of User? 19 | 20 | [self.channel_post, self.edited_channel_post, self.edited_message, self.message].compact.each do |message| 21 | if message 22 | users.concat(message.users) 23 | end 24 | end 25 | 26 | if query = self.callback_query 27 | users << query.from 28 | if message = query.message 29 | users.concat(message.users) 30 | end 31 | end 32 | 33 | [self.chosen_inline_result, self.shipping_query, self.inline_query, self.pre_checkout_query, self.my_chat_member, self.chat_member].compact.each do |e| 34 | users << e.from if e.from 35 | end 36 | 37 | [self.poll_answer].compact.each do |e| 38 | users << e.user if e.user 39 | end 40 | 41 | users.compact.uniq! 42 | end 43 | 44 | # Yields each unique user in this update to the block. 45 | def users(&block : User ->) 46 | self.users.each { |u| block.call(u) } 47 | end 48 | 49 | # Returns all unique chats included in this update 50 | def chats 51 | chats = [] of Chat 52 | 53 | [self.channel_post, self.edited_channel_post, self.edited_message, self.message].compact.each do |message| 54 | chats.concat(message.chats) if message 55 | end 56 | 57 | [self.callback_query].compact.each do |event| 58 | if message = event.message 59 | chats.concat(message.chats) 60 | end 61 | end 62 | 63 | [self.my_chat_member, self.chat_member, self.chat_join_request].compact.each do |event| 64 | chats << event.chat 65 | end 66 | 67 | chats 68 | end 69 | 70 | # Yields each unique chat in this update to the block. 71 | def chats(&block : Chat ->) 72 | self.chats.each { |c| block.call(c) } 73 | end 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | tourmaline logo 3 |
4 | 5 | # Tourmaline 6 | 7 | [![Chat on Telegram](https://patrolavia.github.io/telegram-badge/chat.png)](https://t.me/protoncr) 8 | 9 | Telegram Bot API library written in Crystal. Meant to be a simple, easy to use, and fast library for writing Telegram bots. 10 | 11 | ## Installation 12 | 13 | Add this to your application's `shard.yml`: 14 | 15 | ```yaml 16 | dependencies: 17 | tourmaline: 18 | github: protoncr/tourmaline 19 | branch: master 20 | ``` 21 | 22 | ## Usage 23 | 24 | API documentation is also available [here](https://tourmaline.dev/api_reference/Tourmaline/). 25 | 26 | Examples are available in the [examples](https://github.com/protoncr/tourmaline/tree/master/examples) folder. 27 | 28 | Just for README purposes though, let's look at the echo bot example: 29 | 30 | ```crystal 31 | require "tourmaline" 32 | 33 | client = Tourmaline::Client.new(ENV["BOT_TOKEN"]) 34 | 35 | echo_handler = Tourmaline::CommandHandler.new("echo") do |ctx| 36 | text = ctx.text.to_s 37 | ctx.reply(text) unless text.empty? 38 | end 39 | 40 | client.register(echo_handler) 41 | 42 | client.poll 43 | ``` 44 | 45 | ## Roadmap 46 | 47 | The following features are/will be implemented: 48 | 49 | - [x] HTTP/HTTP Proxies 50 | - [x] Client API 51 | - [x] Implementation examples 52 | - [x] Handlers for commands, queries, and more 53 | - [x] Robust middleware system 54 | - [x] Standard API queries 55 | - [x] Stickers 56 | - [x] Inline mode 57 | - [x] Long polling 58 | - [x] Webhooks 59 | - [x] Payments 60 | - [x] Games 61 | - [x] Polls 62 | - [x] Telegram Passport 63 | - [ ] Framework Adapters 64 | - [ ] Kemal 65 | - [ ] Amber 66 | - [ ] Lucky 67 | - [ ] Athena 68 | - [ ] Grip 69 | 70 | If you want a new feature feel free to submit an issue or open a pull request. 71 | 72 | ## Contributing 73 | 74 | 1. Fork it ( https://github.com/protoncr/tourmaline/fork ) 75 | 2. Create your feature branch (git checkout -b my-new-feature) 76 | 3. Commit your changes (git commit -am 'Add some feature') 77 | 4. Push to the branch (git push origin my-new-feature) 78 | 5. Create a new Pull Request 79 | 80 | ## Contributors 81 | 82 | Thanks to all the people that have contributed to this project! 83 | 84 | [![Contributors](https://contrib.rocks/image?repo=protoncr/tourmaline)](https://github.com/protoncr/tourmaline/graphs/contributors) 85 | -------------------------------------------------------------------------------- /src/tourmaline/types/custom/message.cr: -------------------------------------------------------------------------------- 1 | module Tourmaline 2 | class Message 3 | def file 4 | if animation 5 | {:animation, animation} 6 | elsif audio 7 | {:audio, audio} 8 | elsif document 9 | {:document, document} 10 | elsif sticker 11 | {:sticker, sticker} 12 | elsif video 13 | {:video, video} 14 | elsif video_note 15 | {:video_note, video_note} 16 | elsif photo.first? 17 | {:photo, photo.first} 18 | else 19 | {nil, nil} 20 | end 21 | end 22 | 23 | def link 24 | if chat.username 25 | "https://t.me/#{chat.username}/#{message_id}" 26 | else 27 | "https://t.me/c/#{chat.id}/#{message_id}" 28 | end 29 | end 30 | 31 | def text_entities 32 | text = self.caption || self.text 33 | [entities, caption_entities].flatten.reduce({} of MessageEntity => String) do |acc, ent| 34 | acc[ent] = text.to_s[ent.offset, ent.length] 35 | acc 36 | end 37 | end 38 | 39 | def text_entities(type : String) 40 | text_entities.select { |ent, text| ent.type == type } 41 | end 42 | 43 | def users 44 | users = [] of User? 45 | users << self.from 46 | users << self.forward_from 47 | users << self.left_chat_member 48 | users.concat(self.new_chat_members) 49 | users.compact.uniq 50 | end 51 | 52 | def users(&block : User ->) 53 | self.users.each { |u| block.call(u) } 54 | end 55 | 56 | def chats 57 | chats = [] of Chat? 58 | chats << self.chat 59 | chats << self.sender_chat 60 | chats << self.forward_from_chat 61 | if reply_message = self.reply_message 62 | chats.concat(reply_message.chats) 63 | end 64 | chats.compact.uniq 65 | end 66 | 67 | def chats(&block : Chat ->) 68 | self.chats.each { |c| block.call(c) } 69 | end 70 | 71 | def sender_type 72 | if is_automatic_forward? 73 | SenderType::ChannelForward 74 | elsif sc = sender_chat 75 | if sc.id == chat.id 76 | SenderType::AnonymousAdmin 77 | else 78 | SenderType::Channel 79 | end 80 | elsif from.try(&.is_bot?) 81 | SenderType::Bot 82 | else 83 | SenderType::User 84 | end 85 | end 86 | 87 | enum SenderType 88 | Bot 89 | User 90 | Channel 91 | ChannelForward 92 | AnonymousAdmin 93 | end 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /src/tourmaline/client/inline_query_result_builder.cr: -------------------------------------------------------------------------------- 1 | module Tourmaline 2 | class Client 3 | def self.build_inline_query_result(&block : InlineQueryResultBuilder ->) 4 | builder = InlineQueryResultBuilder.new 5 | yield builder 6 | builder.results 7 | end 8 | 9 | def build_inline_query_result(&block : InlineQueryResultBuilder ->) 10 | self.class.build_inline_query_result(&block) 11 | end 12 | 13 | class InlineQueryResultBuilder 14 | getter results : Array(Tourmaline::InlineQueryResult) 15 | 16 | def initialize 17 | @results = [] of Tourmaline::InlineQueryResult 18 | end 19 | 20 | def article(*args, **opts) 21 | results << InlineQueryResultArticle.new(*args, **opts) 22 | end 23 | 24 | def audio(*args, **opts) 25 | results << InlineQueryResultAudio.new(*args, **opts) 26 | end 27 | 28 | def cached_audio(*args, **opts) 29 | results << InlineQueryResultCachedAudio.new(*args, **opts) 30 | end 31 | 32 | def cached_document(*args, **opts) 33 | results << InlineQueryResultCachedDocument.new(*args, **opts) 34 | end 35 | 36 | def cached_gif(*args, **opts) 37 | results << InlineQueryResultCachedGif.new(*args, **opts) 38 | end 39 | 40 | def cached_mpeg4_gif(*args, **opts) 41 | results << InlineQueryResultCachedMpeg4Gif.new(*args, **opts) 42 | end 43 | 44 | def cached_photo(*args, **opts) 45 | results << InlineQueryResultCachedPhoto.new(*args, **opts) 46 | end 47 | 48 | def cached_sticker(*args, **opts) 49 | results << InlineQueryResultCachedSticker.new(*args, **opts) 50 | end 51 | 52 | def cached_video(*args, **opts) 53 | results << InlineQueryResultCachedVideo.new(*args, **opts) 54 | end 55 | 56 | def cached_voice(*args, **opts) 57 | results << InlineQueryResultCachedVoice.new(*args, **opts) 58 | end 59 | 60 | def contact(*args, **opts) 61 | results << InlineQueryResultContact.new(*args, **opts) 62 | end 63 | 64 | def document(*args, **opts) 65 | results << InlineQueryResultDocument.new(*args, **opts) 66 | end 67 | 68 | def gif(*args, **opts) 69 | results << InlineQueryResultGif.new(*args, **opts) 70 | end 71 | 72 | def location(*args, **opts) 73 | results << InlineQueryResultLocation.new(*args, **opts) 74 | end 75 | 76 | def mpeg4_gif(*args, **opts) 77 | results << InlineQueryResultMpeg4Gif.new(*args, **opts) 78 | end 79 | 80 | def photo(*args, **opts) 81 | results << InlineQueryResultPhoto.new(*args, **opts) 82 | end 83 | 84 | def venue(*args, **opts) 85 | results << InlineQueryResultVenue.new(*args, **opts) 86 | end 87 | 88 | def video(*args, **opts) 89 | results << InlineQueryResultVideo.new(*args, **opts) 90 | end 91 | 92 | def game(*args, **opts) 93 | results << InlineQueryResultGame.new(*args, **opts) 94 | end 95 | end 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /src/tourmaline/helpers.cr: -------------------------------------------------------------------------------- 1 | module Tourmaline 2 | module Helpers 3 | extend self 4 | 5 | DEFAULT_EXTENSIONS = { 6 | audio: "mp3", 7 | photo: "jpg", 8 | sticker: "webp", 9 | video: "mp4", 10 | animation: "mp4", 11 | video_note: "mp4", 12 | voice: "ogg", 13 | } 14 | 15 | # Return a random string of the given length. If `characters` is not given, 16 | # it will default to 0..9, a..z, A..Z. 17 | def random_string(length, characters = nil) 18 | characters ||= ('0'..'9').to_a + ('a'..'z').to_a + ('A'..'Z').to_a 19 | rands = characters.sample(length) 20 | rands.join 21 | end 22 | 23 | # Escape the given html for use in a Telegram message. 24 | def escape_html(text) 25 | text.to_s 26 | .gsub('&', "&") 27 | .gsub('<', "<") 28 | .gsub('>', ">") 29 | end 30 | 31 | # Escape the given markdown for use in a Telegram message. 32 | def escape_md(text, version = 1) 33 | text = text.to_s 34 | 35 | case version 36 | when 0, 1 37 | chars = ['_', '*', '`', '[', ']', '(', ')'] 38 | when 2 39 | chars = ['_', '*', '[', ']', '(', ')', '~', '`', '>', '#', '+', '-', '=', '|', '{', '}', '.', '!'] 40 | else 41 | raise "Invalid version #{version} for `escape_md`" 42 | end 43 | 44 | chars.each do |char| 45 | text = text.gsub(char, "\\#{char}") 46 | end 47 | 48 | text 49 | end 50 | 51 | # Pad the given text with spaces to make it a multiple of 4 bytes. 52 | def pad_utf16(text) 53 | String.build do |str| 54 | text.each_char do |c| 55 | str << c 56 | if c.ord >= 0x10000 && c.ord <= 0x10FFFF 57 | str << " " 58 | end 59 | end 60 | end 61 | end 62 | 63 | # Unpad the given text by removing spaces that were added to make it a 64 | # multiple of 4 bytes. 65 | def unpad_utf16(text) 66 | String.build do |str| 67 | last_char = nil 68 | text.each_char do |c| 69 | unless last_char && last_char.ord >= 0x10000 && last_char.ord <= 0x10FFFF 70 | str << c 71 | end 72 | last_char = c 73 | end 74 | end 75 | end 76 | 77 | # Convenience method to create and `Array` of `LabledPrice` from an `Array` 78 | # of `NamedTuple(label: String, amount: Int32)`. 79 | # TODO: Replace with a builder of some kind 80 | def labeled_prices(lp : Array(NamedTuple(label: String, amount: Int32))) 81 | lp.reduce([] of Tourmaline::LabeledPrice) { |acc, i| 82 | acc << Tourmaline::LabeledPrice.new(label: i[:label], amount: i[:amount]) 83 | } 84 | end 85 | 86 | # Convenience method to create an `Array` of `ShippingOption` from a 87 | # `NamedTuple(id: String, title: String, prices: Array(LabeledPrice))`. 88 | # TODO: Replace with a builder of some kind 89 | def shipping_options(options : Array(NamedTuple(id: String, title: String, prices: Array(LabeledPrice)))) 90 | lp.reduce([] of Tourmaline::ShippingOption) { |acc, i| 91 | acc << Tourmaline::ShippingOption.new(id: i[:id], title: i[:title], prices: i[:prices]) 92 | } 93 | end 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /src/tourmaline/keyboard_builder.cr: -------------------------------------------------------------------------------- 1 | require "xml" 2 | 3 | module Tourmaline 4 | # Base builder class for `InlineKeyboardMarkup::Builder` and 5 | # `ReplyKeyboardMarkup::Builder`. 6 | abstract class KeyboardBuilder(T, G) 7 | property? force_reply : Bool 8 | 9 | property? remove_keyboard : Bool 10 | 11 | property? selective : Bool 12 | 13 | property? resize : Bool 14 | 15 | property? one_time : Bool 16 | 17 | property? persistent : Bool 18 | 19 | property input_field_placeholder : String? 20 | 21 | def initialize( 22 | @force_reply = false, 23 | @remove_keyboard = false, 24 | @selective = false, 25 | @keyboard = [] of T, 26 | @resize = false, 27 | @one_time = false, 28 | @persistent = false, 29 | @input_field_placeholder = nil 30 | ) 31 | end 32 | 33 | abstract def keyboard(columns = nil) : G 34 | 35 | def force_reply(value) 36 | @force_reply = value 37 | self 38 | end 39 | 40 | def remove_keyboard(value) 41 | @remove_keyboard = value 42 | self 43 | end 44 | 45 | def selective(value) 46 | @selective = value 47 | self 48 | end 49 | 50 | def resize(value) 51 | @resize = value 52 | self 53 | end 54 | 55 | def one_time(value) 56 | @one_time = value 57 | self 58 | end 59 | 60 | def input_field_placeholder(value) 61 | @input_field_placeholder = value 62 | self 63 | end 64 | 65 | def self.remove_keyboard(value : Bool) 66 | self.new.tap { |k| k.remove_keyboard = true } 67 | end 68 | 69 | def self.force_reply(value : Bool) 70 | self.new.tap { |k| k.force_reply = true } 71 | end 72 | 73 | def self.buttons(buttons, **options) 74 | self.new.tap { |k| k.buttons(buttons, **options) } 75 | end 76 | 77 | def self.inline_buttons(buttons, **options) 78 | self.new.tap { |k| k.inline_buttons(buttons, **options) } 79 | end 80 | 81 | def self.resize(value : Bool) 82 | self.new.tap { |k| k.resize = true } 83 | end 84 | 85 | def self.selective(value : Bool) 86 | self.new.tap { |k| k.selective = true } 87 | end 88 | 89 | def self.one_time(value : Bool) 90 | self.new.tap { |k| k.one_time = true } 91 | end 92 | 93 | def button(*args, **options) 94 | @keyboard << T.new(*args, **options) 95 | end 96 | 97 | def self.build_keyboard( 98 | buttons : Array(T), 99 | columns = 1, 100 | wrap = nil 101 | ) 102 | # If `columns` is one or less we don't need to do 103 | # any hard work 104 | if columns < 2 105 | return buttons.map { |b| [b] } 106 | end 107 | 108 | wrap_fn = wrap ? wrap : ->(_btn : T, _index : Int32, current_row : Array(T)) { 109 | current_row.size >= columns 110 | } 111 | 112 | result = [] of Array(T) 113 | current_row = [] of T 114 | 115 | buttons.each_with_index do |btn, index| 116 | if wrap_fn.call(btn, index, current_row) 117 | result << current_row.dup 118 | current_row.clear 119 | end 120 | 121 | current_row << btn 122 | end 123 | 124 | if current_row.size > 0 125 | result << current_row 126 | end 127 | 128 | result 129 | end 130 | end 131 | end 132 | -------------------------------------------------------------------------------- /docs/usage/index.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | Bots are the figurative workhorses of Telegram's platform, allowing for everything from group management to file conversion and more; but how does one make a bot? Well that is the question I aim to answer in this brief introduction. If you already know how to make a bot, and would like to focus more on how to make a bot with Tourmaline, feel free to skip to the [Getting Started](./getting_started.md). 4 | 5 | ## The BotFather 6 | 7 | Every journey has to start somewhere, and for the would-be Telegram bot developer that is [BotFather](https://t.me/BotFather). BotFather itself is a Telegram bot which allows you to create and manage your own bots. As is convention, you can start BotFather by starting a conversation and pressing the "Start" button or sending the `/start` command. 8 | 9 | To create a new bot, just send the `/newbot` command. BotFather will then ask a couple questions which sometimes confuse people. 10 | 11 | > Alright, a new bot. How are we going to call it? Please choose a name for your bot." 12 | 13 | The first question is asking for the screen name of your bot. This is the name that will appear when it sends messages and __not__ its username. 14 | 15 | > Good. Now let's choose a username for your bot. It must end in `bot`. Like this, for example: TetrisBot or tetris_bot. 16 | 17 | Now _this_ is your bot's username. As it states, your bot's username __must__ end with the word "bot". Capitalization doesn't matter, but ending with the word "bot" is pertinant. 18 | 19 | I won't go into more detail as to how to setup a bot here as you can just run `/help` to see all of the available commands, but once you finish the bot creation you will be presented with an API Token. __KEEP THIS SAFE__. Your token is for your eyes and your eyes only, as anyone with half a brain can take your token and use it to control your bot maliciously. We will also need this token later, so keep it handy. 20 | 21 | ## Running Your Bot 22 | 23 | A bot is nothing more than code running on a computer somewhere. In most cases during development and testing that computer will probably be your own, but what happens when you're ready to share your bot with the world? At that point, running your bot on your own local machine isn't the best idea. Personal computers need to sleep, be restarted every now and then, and have more important jobs than hosting a bot 24/7. 24 | 25 | __Enter the VPS.__ 26 | 27 | If you already have a server on which to run your bot, or are at least familiar with how to deploy your bot elsewhere you can probably skip this section. For anyone left remaining, you need a server. Far and away the most affordable method of getting your own server is by using a VPS, or virtual private server. Providers like AWS, DigitalOcean, Vultr, and Hetzner (my personal favorite) provide these for as little as $2-5/mo. 28 | 29 | To use a VPS you will need some knowledge of Linux, including how to use `ssh` to remotely access a server. If Linux is foreign to you I highly recommend getting aquainted, as it's going to be very important going forward. We will go into more detail about how to actually run your bot on your VPS later in this guide. 30 | 31 | ## Avoiding Limits 32 | 33 | Telegram bots are heavily limited in what they can, and cannot do. The good news is that these limits are mostly documented when it comes to bots, so avoiding them is completely in your control. For more information on the current limits I recommend reading ["My bot is hitting limits, how do I avoid this?"](https://core.telegram.org/bots/faq#my-bot-is-hitting-limits-how-do-i-avoid-this) from the Telegram FAQ. -------------------------------------------------------------------------------- /docs/usage/getting_started.md: -------------------------------------------------------------------------------- 1 | # Getting Started with Tourmaline 2 | 3 | Time to create your first bot with Tourmaline! 4 | 5 | ## Installing Tourmaline 6 | 7 | This guide assumes that you have Crystal installed and are at least semi-familiar with the syntax. The first thing we are going to need is a fresh Crystal project, so go ahead and run `crystal init app your_bot_name`, making sure to replace "your_bot_name" with whatever you want to call your bot. I'm going to use the famous echo_bot example. Once it's finished, `cd` into the project directory. 8 | 9 | Now, open `shard.yml` and add the following lines anywhere in the file (probably at the end): 10 | 11 | ```yaml linenums="1" 12 | dependencies: 13 | tourmaline: 14 | github: protoncr/tourmaline 15 | ``` 16 | 17 | Save the file, and run `shards install`. That's it! Tourmaline is now installed. 18 | 19 | ## Creating Your Bot 20 | 21 | Now it's time to write some code. Open `src/echo_bot.cr` or whatever file was generated for you, and paste in the following code. The code is annotated so you can understand what's going on every step of the way. 22 | 23 | ```crystal linenums="1" 24 | require "tourmaline" # (1) 25 | 26 | client = Tourmaline::Client.new(ENV["BOT_TOKEN"]) # (2) 27 | 28 | echo_handler = Tourmaline::CommandHandler.new("echo") do |ctx| # (3) 29 | text = ctx.text.to_s 30 | ctx.reply(text) unless text.empty? # (4) 31 | end 32 | 33 | client.register(echo_handler) # (5) 34 | 35 | client.poll # (6) 36 | ``` 37 | 38 | 1. First we have to import Tourmaline into our code. In Crystal this is done with the `require` statement. 39 | 2. Next we create a new `Client` object. This is the main object that we will be using to interact with the Telegram Bot API. Into the `Client` we pass our bot's API token, which we will be getting from the BotFather. We will be storing this in an environment variable, so we use `ENV["BOT_TOKEN"]` to get the value of the `BOT_TOKEN` environment variable. If you are not familiar with environment variables, you can read more about them [here](https://en.wikipedia.org/wiki/Environment_variable). 40 | 3. Tourmaline uses a system of handlers to handle different types of events. In this case we are creating a `CommandHandler` which will handle the `/echo` command. The first argument to the `CommandHandler` is the name of the command, and the second argument is a block of code that will be executed when the command is received. The block of code is passed a `Context` object, which contains information about the command and the message that triggered it. 41 | 4. The `Context` object has a `text` property which contains the text of the message that triggered the command. We can use this to get the text of the message and reply with it. We use the `reply` method to send a message back to the chat that the command was sent in. We also check to make sure that the message isn't empty, because if the user just sends `/echo` without any text, the message will be empty. 42 | 5. Now that we have created our handler, we need to register it with the `Client` so that it can be used. This is done with the `register` method. 43 | 6. Finally, we call the `poll` method on the `Client` to start the bot. This method will block the current thread, so it is important that you call it at the end of your code. 44 | 45 | And that's really all their is to it. Now we can run our code! 46 | 47 | ```sh 48 | export LOG_LEVEL=info # by default you won't see any logs, so we set the log level to info 49 | export BOT_TOKEN=YOUR_BOT_API_TOKEN 50 | crystal run ./src/echo_bot.cr 51 | ``` 52 | 53 | If all goes well, you should see something like this: 54 | 55 | ```sh 56 | 2023-03-23T00:17:53.778090Z INFO - tourmaline.poller: Polling for updates... 57 | ``` -------------------------------------------------------------------------------- /docs/resources/faq.md: -------------------------------------------------------------------------------- 1 | # Frequently Asked Questions 2 | 3 | This is an FAQ for Tourmaline, but the answers should be generalized enough to use as a general FAQ for Telegram bots. 4 | 5 | ### Why isn't my bot responding? 6 | 7 | Well first of all, have you programmed it? I know it might sound crazy, but 99% of the people that ask this question didn't actually program their bot. Rather they just created a bot with BotFather and expected things to magically work. 8 | 9 | If the answer to that question is "yes", as I hope it is, then there are a few things to try. First of all, make sure you are using the correct API token for the bot you're trying to access. If you have multiple bots it can become pretty easy to accidentally grab the wrong token. 10 | 11 | Assuming you have the right token, be sure to check that your bot is actually running and that you have a working internet connection. Try running your bot with the `LOG_LEVEL` environement variable set to `DEBUG` and check the logs. When running in polling mode you should see a whole bunch of calls to `getUpdates`. 12 | 13 | If that didn't work, you may need to check if group privacy mode is turned on. Go to BotFather, send the command `/mybots`, select your bot, go to `Bot Settings > Group Privacy > Turn off`. This should only be necessary if your bot is running in groups and you're using non-standard command prefixes, or other handlers like the [HearsHandler][Tourmaline::HearsHandler]. 14 | 15 | Lastly be sure to check the commands you're trying to send. The standard [CommandHandler][Tourmaline::CommandHandler] will only respond to commands with the `/` prefix. Also be sure to remember that command names should be unprefixed. For example: 16 | 17 | ```diff 18 | - @[Command("/echo")] 19 | + @[Command("echo")] 20 | ``` 21 | 22 | ### What messages can my bot see? 23 | 24 | Depends partially on whether group privacy mode is turned on or not. As a general rule your bot will not see messages sent by other bots. There is no way around this. If your bot is an admin in the group it will see all messages, except those sent by other bots. 25 | 26 | With group privacy mode turned on (the default) you bot will receive: 27 | 28 | - Commands explicitly meant for them (e.g., `/command@this_bot`). 29 | - General commands from users (e.g. `/start`) if the bot was the last bot to send a message to the group. 30 | - Messages sent via this bot. 31 | - Replies to any messages implicitly or explicitly meant for this bot. 32 | 33 | Additionally all bots, regardless of the group privacy mode will receive: 34 | 35 | - All service messages. 36 | - All messages from private chats with users. 37 | - All messages from channels where they are a member. 38 | 39 | !!! note 40 | Eeach particular message can only be available to one privacy-enabled bot at a time, i.e., a reply to bot A containing an explicit command for bot B or sent via bot C will only be available to bot A. Replies have the highest priority. 41 | 42 | ### Can bots delete messages? 43 | 44 | Yes, under 2 conditions: 45 | 46 | 1. The bot must have the Delete Messages permission 47 | 2. The message must be less than 48 hours old 48 | 49 | ### How can I add my bot to a group? 50 | 51 | Same way you add any other user. On the desktop client this can be done by clicking the ellipses in the top right corner while viewing your group, clicking `Info`, and then clicking the `Add` button. If your bot is meant to be added to groups you can make this a bit easier by giving users a link to do so. The URL for the link should be `http://telegram.me/BOT_NAME?startgroup=botstart` where `BOTNAME` is the username of your bot. 52 | 53 | ### How can I get a user's information? 54 | 55 | Bots are not capable of accessing a user's information soley based off of their user id or username, however there are some ways around this. The simplest is to keep a record of each user your bot comes in contact with by watching incoming messages for the user that sent them. An example of this could be as follows: 56 | 57 | ```crystal 58 | @[On(:message)] 59 | def persist_users(update) 60 | if message = update.message 61 | # Convenience method to get all users from a message 62 | users = message.users 63 | 64 | # ... add them to a database 65 | end 66 | end 67 | ``` 68 | 69 | The one exception to this rule is [chat members][Tourmaline::ChatMember]. If you know the user's id or username and a group that they belong to which your bot also belongs to, you can use [#get_chat_member][Tourmaline::Client#get_chat_member(chat,user)] to get their [ChatMember][Tourmaline::ChatMember] record. 70 | 71 | ### I have a question not listed here, where can I ask? 72 | 73 | Feel free to join the official [Tourmaline/Proton Chat](https://t.me/protoncr) on Telegram and ask away. -------------------------------------------------------------------------------- /src/tourmaline/context.cr: -------------------------------------------------------------------------------- 1 | module Tourmaline 2 | struct Context 3 | getter client : Client 4 | getter update : Update 5 | 6 | def initialize(@client : Client, @update : Update) 7 | end 8 | 9 | # Slightly shorter alias for the client 10 | def api 11 | @client 12 | end 13 | 14 | # Pass all update methods to the update object 15 | delegate :message, :message?, :edited_message, :edited_message?, :channel_post, :channel_post?, 16 | :edited_channel_post, :edited_channel_post?, :inline_query, :inline_query?, :chosen_inline_result, 17 | :chosen_inline_result?, :callback_query, :callback_query?, :shipping_query, :shipping_query?, 18 | :pre_checkout_query, :pre_checkout_query?, :poll, :poll?, :poll_answer, :poll_answer?, to: @update 19 | 20 | # Retuns the message, edited_message, channel_post, edited_channel_post, callback_query.message, or nil 21 | def message 22 | @update.message || @update.edited_message || @update.channel_post || @update.edited_channel_post || @update.callback_query.try &.message 23 | end 24 | 25 | # Returns the message, edited_message, channel_post, edited_channel_post, callback_query.message, or raises an exception 26 | def message! 27 | message.not_nil! 28 | end 29 | 30 | # Get the message text, return nil if there is no message 31 | def text(strip_command = true) 32 | if strip_command && (message = @update.message) 33 | entity, _ = message.text_entities("bot_command").first 34 | message.text.to_s[entity.offset + entity.length..-1].lstrip 35 | else 36 | @update.message.try &.text 37 | end 38 | end 39 | 40 | # Get the message text, raise an exception if there is no message 41 | def text!(strip_command = true) 42 | text(strip_command).not_nil! 43 | end 44 | 45 | # Get the command name, return nil if there is no message 46 | def command 47 | if (message = @update.message) 48 | entity, _ = message.text_entities("bot_command").first 49 | message.text.to_s[1, entity.offset + entity.length - 1].strip 50 | end 51 | end 52 | 53 | # Get the command name, raise an exception if there is no message 54 | def command! 55 | command.not_nil! 56 | end 57 | 58 | # Respond to the incoming message 59 | def respond(text : String, **kwargs) 60 | with_message do |message| 61 | @client.send_message(**kwargs, chat_id: message.chat.id, text: text) 62 | end 63 | end 64 | 65 | # Reply directly to the incoming message 66 | def reply(text : String, **kwargs) 67 | with_message do |message| 68 | kwargs = kwargs.merge(reply_to_message_id: message.message_id) 69 | @client.send_message(**kwargs, chat_id: message.chat.id, text: text) 70 | end 71 | end 72 | 73 | # {% for content_type in %w(audio animation contact document sticker photo media_group venu video video_note voice invoice poll) %} 74 | {% for key, value in {"audio" => "audio", "animation" => "animation", "contact" => "contact", "document" => "document", "sticker" => "sticker", "photo" => "photo", "media_group" => "media", "venue" => "venue", "video" => "video", "video_note" => "video_note", "voice" => "voice", "invoice" => "invoice"} %} 75 | # Respond with a {{key.id}} 76 | def respond_with_{{key.id}}({{ key.id }}, **kwargs) 77 | with_message do |message| 78 | @client.send_{{ key.id }}(**kwargs, {{ value.id }}: {{ key.id }}, chat_id: message.chat.id) 79 | end 80 | end 81 | 82 | # Reply directly to the incoming message with a {{key.id}} 83 | def reply_with_{{key.id}}({{ key.id }}, **kwargs) 84 | with_message do |message| 85 | kwargs = kwargs.merge(reply_to_message_id: message.message_id) 86 | @client.send_{{ key.id }}(**kwargs, {{ value.id }}: {{ key.id }}, chat_id: message.chat.id) 87 | end 88 | end 89 | {% end %} 90 | 91 | {% for name, emoji in {"dice" => "🎲", "dart" => "🎯", "basketball" => "🏀", "football" => "🏈", "slot_machine" => "🎰", "bowling" => "🎳"} %} 92 | # Respond with a {{name.id}} 93 | def respond_with_{{name.id}}(**kwargs) 94 | with_message do |message| 95 | @client.send_dice(**kwargs, chat_id: message.chat.id, emoji: {{emoji.stringify}}) 96 | end 97 | end 98 | 99 | # Reply directly to the incoming message with a {{name.id}} 100 | def reply_with_{{name.id}}(**kwargs) 101 | with_message do |message| 102 | kwargs = kwargs.merge(reply_to_message_id: message.message_id) 103 | @client.send_dice(**kwargs, chat_id: message.chat.id, emoji: {{emoji.stringify}}) 104 | end 105 | end 106 | {% end %} 107 | 108 | # Respond with a location 109 | def respond_with_location(latitude : Float64, longitude : Float64, **kwargs) 110 | with_message do |message| 111 | @client.send_location(**kwargs, latitude: latitude, longitude: longitude, chat_id: message.chat.id) 112 | end 113 | end 114 | 115 | # Reply directly to the incoming message with a location 116 | def reply_with_location(latitude : Float64, longitude : Float64, **kwargs) 117 | with_message do |message| 118 | kwargs = kwargs.merge(reply_to_message_id: message.message_id) 119 | @client.send_location(**kwargs, latitude: latitude, longitude: longitude, chat_id: message.chat.id) 120 | end 121 | end 122 | 123 | # Respond with a poll 124 | def respond_with_poll(question : String, options : Array(String), **kwargs) 125 | with_message do |message| 126 | @client.send_poll(**kwargs, question: question, options: options, chat_id: message.chat.id) 127 | end 128 | end 129 | 130 | # Reply directly to the incoming message with a poll 131 | def reply_with_poll(question : String, options : Array(String), **kwargs) 132 | with_message do |message| 133 | kwargs = kwargs.merge(reply_to_message_id: message.message_id) 134 | @client.send_poll(**kwargs, question: question, options: options, chat_id: message.chat.id) 135 | end 136 | end 137 | 138 | # Context aware message deletion 139 | def delete_message(message_id : Int32) 140 | with_message do |message| 141 | @client.delete_message(chat_id: message.chat.id, message_id: message_id) 142 | end 143 | end 144 | 145 | # Context aware forward 146 | def forward_message(to_chat, **kwargs) 147 | with_message do |message| 148 | @client.forward_message(**kwargs, chat_id: to_chat, from_chat_id: message.chat.id, message_id: message.id) 149 | end 150 | end 151 | 152 | # Context aware pinning 153 | def pin_message(**kwargs) 154 | with_message do |message| 155 | @client.pin_chat_message(**kwargs, chat_id: message.chat.id, message_id: message.id) 156 | end 157 | end 158 | 159 | # Context aware unpinning 160 | def unpin_message(**kwargs) 161 | with_message do |message| 162 | @client.unpin_chat_message(**kwargs, chat_id: message.chat.id) 163 | end 164 | end 165 | 166 | # Context aware editing 167 | def edit_message(text : String, **kwargs) 168 | with_message do |message| 169 | @client.edit_message_text(**kwargs, chat_id: message.chat.id, message_id: message.message_id, text: text) 170 | end 171 | end 172 | 173 | # Context aware live location editing 174 | def edit_live_location(latitude : Float64, longitude : Float64, **kwargs) 175 | with_message do |message| 176 | @client.edit_message_live_location(**kwargs, chat_id: message.chat.id, message_id: message.message_id, latitude: latitude, longitude: longitude) 177 | end 178 | end 179 | 180 | def answer_callback_query(**kwargs) 181 | if query = @update.callback_query 182 | @client.answer_callback_query(**kwargs, callback_query_id: query.id) 183 | end 184 | end 185 | 186 | def answer_inline_query(**kwargs) 187 | if query = @update.inline_query 188 | @client.answer_inline_query(**kwargs, inline_query_id: query.id) 189 | end 190 | end 191 | 192 | def answer_shipping_query(**kwargs) 193 | if query = @update.shipping_query 194 | @client.answer_shipping_query(**kwargs, shipping_query_id: query.id) 195 | end 196 | end 197 | 198 | def answer_pre_checkout_query(**kwargs) 199 | if query = @update.pre_checkout_query 200 | @client.answer_pre_checkout_query(**kwargs, pre_checkout_query_id: query.id) 201 | end 202 | end 203 | 204 | # Context aware chat actions 205 | def send_chat_action(action : String | ChatAction) 206 | with_message do |message| 207 | @client.send_chat_action(chat_id: message.chat.id, action: action.to_s) 208 | end 209 | end 210 | 211 | # If the update contains a message, pass it to the block. Less boilerplate. 212 | def with_message 213 | if message 214 | yield message! 215 | end 216 | end 217 | end 218 | end 219 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Tourmaline Changelog 2 | 3 | I will do my best to keep this updated with changes as they happen. 4 | 5 | ## 0.30.0 6 | 7 | ### Added: 8 | 9 | - Full support for Bot API 6.9 10 | - See [the official Bot API changelog](https://core.telegram.org/bots/api#september-22-2023) for a complete list of changes. 11 | 12 | ## 0.29.0 13 | 14 | **The core functionality of Tourmaline is now auto generated from the official Bot API documentation. This means that Tourmaline will always be up to date with the latest version of the Bot API.** 15 | 16 | ### Added: 17 | 18 | - New method set_my_name to change the bot's name. Returns True on success. 19 | - New method get_my_name to get the current bot name for the user's language. Returns BotName on success. 20 | - New Tourmaline::BotName class to represent the bot's name. 21 | - New Tourmaline::InlineQueryResultsButton class to represent a button shown above inline query results. 22 | - New Tourmaline::SwitchInlineQueryChosenChat class to represent an inline button for switching the user to inline mode in a chosen chat. 23 | 24 | ### Changed: 25 | 26 | - Updated Tourmaline::WriteAccessAllowed class to include an optional web_app_name property for the Web App launched from a link. 27 | - Modified Tourmaline::InlineKeyboardButton class to include an optional switch_inline_query_chosen_chat property. 28 | - Updated Tourmaline::CallbackQuery class to include an optional via_chat_folder_invite_link property. 29 | - Modified answerInlineQuery method to accept a Tourmaline::InlineQueryResultsButton instead of switch_pm_text and switch_pm_parameter parameters. 30 | 31 | ### Fixed: 32 | 33 | No bug fixes reported in this diff. 34 | 35 | ## 0.28.0 36 | 37 | **This release contains major breaking changes. If you currently rely on Tourmaline as a framework you may not want to update.** 38 | 39 | - Added full support for Bot API versions 6.4, 6.5, and 6.6 40 | - **(breaking change)** Removed all annotation based handlers. 41 | - **(breaking change)** Removed the `Handlers` namespace. All handlers now fall directly under `Tourmaline`. 42 | - **(breaking change)** Stripped Tourmaline of all _magic_. Models no longer have a `client` instance passed to them, instead we will now rely on the `Tourmaline::Context` which is passed to all handler callbacks. 43 | 44 | Examples have been updated. 45 | 46 | ## 0.27.0 47 | - Added full support for Bot API 6.3 48 | - **(breaking change)** All `is_` prefixed properties in models have been replaced with `?` getters. For instance, `is_anonymous` is now `anonymous?`. 49 | - **(breaking change)** `Client#default_parse_mode` and `Client#default_command_prefixes` have been made class properties instead of instance properties. 50 | - Fixed issues with missing `priority` and `group` properties on event handlers. 51 | - **(breaking change)** `extra/paginated_keyboard` no longer extends `InlineKeyboardMarkup`. 52 | - Added methods `Client#send_paginated_keyboard`, `Chat#send_paginated_keyboard`, `Message#reply_with_paginated_keyboard`, and `Message#respond_with_paginated_keyboard`. Requires import of `extra/paginated_keyboard`. 53 | - Fixed broken parts of `extra/routed_menu`. 54 | - Fixed broken parts of `extra/stage`. 55 | - Handlers no longer require an instance of `Tourmaline::Client`. 56 | - Added several new `UpdateAction`s including `ThreadMessage`, `ForumTopicCreated`, `ForumTopicClosed`, `ForumTopicReopened`, `VideoChatScheduled`, `VideoChatStarted`, `VideoChatEnded`, `VideoChatParticipantsInvited`, and `WebAppData`. 57 | - Bot examples have all been fixed 58 | - More, see [the official Bot API changelog](https://core.telegram.org/bots/api#november-5-2022) for a complete list of changes. 59 | 60 | ## 0.25.1 61 | - Added `sender_type` method and `SenderType` enum to `Message`, allowing the user to easily figure out what type of user or channel sent the given message. 62 | - Updated docs 63 | 64 | ## 0.25.0 65 | - Removed `Container` class which was being used to maintain a global instance of `Client`. 66 | - Added `finish_init` method to all models, allowing them to contain an instance of the `Client` that created them. 67 | 68 | ## 0.24.0 69 | - Added full support for Bot API 5.4 and 5.5 70 | - More, see [the official Bot API changelog](https://core.telegram.org/bots/api#december-7-2021) for a complete list of changes. 71 | 72 | ## 0.23.0 73 | 74 | - Added full support for Bot API 5.1 - 5.3 75 | - Fixed some dependencies. 76 | - Added additional classes `ChatInviteLink`, `VoiceChatStarted`, `VoiceChatEnded`, `VoiceChatParticipantInvited`, `VoiceChatScheduled`, `MessageAutoDeleteTimerChanged`, `InputInvoiceMessageContent`, and `BotCommandScope`. 77 | - Added `scope` and `language_code` options to `set_my_commands` and `get_my_commands`. 78 | - Added method `delete_my_commands`. 79 | - More, see [the official Bot API changelog](https://core.telegram.org/bots/api#june-25-2021) for a complete list of changes. 80 | 81 | ## 0.22.0 82 | 83 | - Added support for TDLight. 84 | - Added `user_token` argument to `Client.new` to support the TDLight user API. 85 | - **(breaking change)** All arguments to `Client.new` are now keyword arguments. 86 | - **(breaking change)** Removed `async` argument from event handlers. All events are now async by default. Async events can be disabled with the `-Dno_async` flag. 87 | - `UpdateHandler` now accepts an array of `UpdateAction`, or a single one. 88 | - Fixed an issue where `poll` always deletes a set webhook. Now it will only delete the webhook if `delete_webhook` is true. 89 | ## 0.20.0 90 | 91 | - **(breaking change)** Removed the filters, replaced with new handlers 92 | - **(breaking change)** Removed Granite specific DB includes from models (also commented out `db_persistence.cr`; next update should make persistence better) 93 | - **(breaking change)** Renamed `PagedInlineKeyboard` to `PagedKeyboard` 94 | - Added `RoutedMenu` class for easy menu building 95 | 96 | ## 0.19.1 97 | 98 | - Replace broken `Int` in unions with `Int::Primitive` 99 | - Make `Helpers.random_string` actually return a random string, not just a number 100 | - Change the first run logic in `Stage` 101 | 102 | ## 0.19.0 103 | 104 | - Added support for `Passport` 105 | - Added `animated?` to `Sticker` 106 | - Added several new filters including `InlineQueryFilter` and `CallbackQueryFilter` 107 | - Added connection pooling to fix concurrency errors 108 | - Events are now async by default 109 | - Added a new helper class `PagedInlineKeyboard` 110 | - **(breaking change)** Moved KemalAdapter to `tourmaline/extra` 111 | - Added proxy support based on [mamantoha/http_proxy](https://github.com/mamantoha/http_proxy) 112 | - Added support for multiple prefixes with commands 113 | - Allow changing the log level using the `LOG` environment variable 114 | - Added an `InstaBot` example 115 | - **(breaking change)** Disabled (commented out) DBPersistence for now 116 | - Updated for bot API 4.9 117 | - Added support for the 🏀 emoji, including methods `Client#send_basket`, `Message#reply_with_basket`, and `Message#respond_with_basket` 118 | - Added `via_bot` field to `Message` 119 | - Added `Stage` (importable from `tourmaline/extra`) for conversation handling 120 | 121 | ## 0.18.1 122 | 123 | - Added ameba checks 124 | - Replaced Halite with `HTTP::Client`, resulting in a major speed boost 125 | - Rename `persistent_init` and `persistent_cleanup` to `init` and `cleanup` respectively 126 | - Remove `handle_error` in favor of `Error.from_code` 127 | 128 | ## 0.18.0 129 | 130 | - Updated polls for Quiz 2.0 131 | - Added new `send_dart` method 132 | 133 | ## 0.17.0 134 | 135 | + KeyboardMarkup 136 | - **(breaking change)** Replace `Markup` class with `KeyboardBuilder` abstract class and extend it with `ReplyKeyboardMarkup::Builder` and `InlineKeyboardMarkup::Builder`. 137 | - Add `.build` methods to `ReplyKeyboardMarkup` and `InlineKeyboardMarkup`. 138 | - **(breaking change)** Replace `QueryResultBuilder` with `InlineQueryResult::Builder`. 139 | - Update examples with new `Builder` classes being used. 140 | + InlineQueryResult 141 | - **(breaking change)** Replace `QueryResultBuilder` with `InlineQueryResult::Builder`. 142 | - Add `.build` method to `InlineQueryResult`. 143 | - Update examples with new `Builder` classes being used. 144 | + Persistence 145 | - **(breaking change)** Made `Persistence` a class rather than a module and updated `HashPersistence` 146 | and `JsonPersistence` to use it. 147 | - Add `persistence` instance variable to `Client` 148 | - Add `NilPersistence` and make it the default persistence for new `Client`s 149 | - Add `DBPersistence` 150 | 151 | ## 0.16.0 152 | 153 | - Add CHANGELOG 154 | - Add support for Filters. 155 | - Add `users` methods to `Update` and `Message` to return all users included in the same. 156 | - Replaced usage of the `strange` logger with the new Crystal `Log` class. 157 | - Log all updates with `Debug` severity if `VERBOSE` environment variable is set to `true`. 158 | - **(breaking change)** Renamed `File` to `TFile` to avoid conflicting with the builtin `File` class. 159 | - **(breaking change)** removed the `Handler` class and all subclasses. Update handling is now done exclusively with the `EventHandler` class and `Filter`s. 160 | 161 | ## 0.15.1 162 | 163 | - Fix bug with event handler that was causing `On` handlers to run on every update. 164 | - Add CNAME file for tourmaline.dev 165 | - Update the logo. 166 | - Add `DiceBot` example. 167 | 168 | ## 0.15.0 169 | 170 | Updated to bot API 4.7 171 | 172 | - Add `send_dice` method to client. 173 | - Add `BotCommand` model along with `get_my_commands` and `set_my_commands` methods. 174 | - Add new sticker/sticker set methods. 175 | - Add `Dice` update action. 176 | -------------------------------------------------------------------------------- /src/tourmaline/update_action.cr: -------------------------------------------------------------------------------- 1 | module Tourmaline 2 | # The available event types for `EventHandler`. 3 | enum UpdateAction 4 | Update 5 | Message 6 | ThreadMessage 7 | ReplyMessage 8 | EditedMessage 9 | ForwardedMessage 10 | CallbackQuery 11 | InlineQuery 12 | ShippingQuery 13 | PreCheckoutQuery 14 | ChosenInlineResult 15 | ChannelPost 16 | EditedChannelPost 17 | MyChatMember 18 | ChatMember 19 | 20 | ViaBot 21 | Text 22 | Caption 23 | Animation 24 | Audio 25 | Document 26 | Photo 27 | Sticker 28 | Video 29 | Voice 30 | Contact 31 | Location 32 | Venue 33 | MediaGroup 34 | NewChatMembers 35 | LeftChatMember 36 | NewChatTitle 37 | NewChatPhoto 38 | DeleteChatPhoto 39 | GroupChatCreated 40 | MessageAutoDeleteTimerChanged 41 | MigrateToChatId 42 | SupergroupChatCreated 43 | ChannelChatCreated 44 | MigrateFromChatId 45 | PinnedMessage 46 | Game 47 | Poll 48 | VideoNote 49 | Invoice 50 | SuccessfulPayment 51 | UserShared 52 | ChatShared 53 | ConnectedWebsite 54 | PassportData 55 | PollAnswer 56 | ProximityAlertTriggered 57 | ForumTopicCreated 58 | ForumTopicEdited 59 | ForumTopicClosed 60 | ForumTopicReopened 61 | GeneralForumTopicHidden 62 | GeneralForumTopicUnhidden 63 | VideoChatScheduled 64 | VideoChatStarted 65 | VideoChatEnded 66 | VideoChatParticipantsInvited 67 | WebAppData 68 | ReplyMarkup 69 | 70 | Dice # 🎲 71 | Dart # 🎯 72 | Basketball # 🏀 73 | Football # ⚽️ 74 | Soccerball # ⚽️ but American 75 | SlotMachine # 🎰 76 | Bowling # 🎳 77 | 78 | BotMessage 79 | UserMessage 80 | ChannelMessage 81 | ChannelForwardMessage 82 | AnonymousAdminMessage 83 | 84 | MentionEntity 85 | TextMentionEntity 86 | HashtagEntity 87 | CashtagEntity 88 | BotCommandEntity 89 | UrlEntity 90 | EmailEntity 91 | PhoneNumberEntity 92 | BoldEntity 93 | ItalicEntity 94 | CodeEntity 95 | PreEntity 96 | TextLinkEntity 97 | UnderlineEntity 98 | StrikethroughEntity 99 | SpoilerEntity 100 | 101 | def to_s 102 | super.to_s.underscore 103 | end 104 | 105 | def self.to_a 106 | {{ @type.constants.map { |c| c.stringify.id } }} 107 | end 108 | 109 | # Takes an `Update` and returns an array of update actions. 110 | def self.from_update(update : Tourmaline::Update) 111 | actions = [] of UpdateAction 112 | 113 | actions << UpdateAction::Update 114 | 115 | if message = update.message 116 | actions << UpdateAction::Message 117 | actions << UpdateAction::ThreadMessage if message.message_thread_id 118 | actions << UpdateAction::ReplyMessage if message.reply_to_message 119 | actions << UpdateAction::ForwardedMessage if message.forward_date 120 | 121 | if chat = message.chat 122 | actions << UpdateAction::PinnedMessage if chat.pinned_message 123 | end 124 | 125 | actions << UpdateAction::ViaBot if message.via_bot 126 | actions << UpdateAction::Text if message.text 127 | actions << UpdateAction::Caption if message.caption 128 | actions << UpdateAction::Animation if message.animation 129 | actions << UpdateAction::Audio if message.audio 130 | actions << UpdateAction::Document if message.document 131 | actions << UpdateAction::Photo if message.photo.size > 0 132 | actions << UpdateAction::Sticker if message.sticker 133 | actions << UpdateAction::Video if message.video 134 | actions << UpdateAction::Voice if message.voice 135 | actions << UpdateAction::Contact if message.contact 136 | actions << UpdateAction::Location if message.location 137 | actions << UpdateAction::Venue if message.venue 138 | actions << UpdateAction::MediaGroup if message.media_group_id 139 | actions << UpdateAction::NewChatMembers if message.new_chat_members.size > 0 140 | actions << UpdateAction::LeftChatMember if message.left_chat_member 141 | actions << UpdateAction::NewChatTitle if message.new_chat_title 142 | actions << UpdateAction::NewChatPhoto if message.new_chat_photo.size > 0 143 | actions << UpdateAction::DeleteChatPhoto if message.delete_chat_photo? 144 | actions << UpdateAction::GroupChatCreated if message.group_chat_created? 145 | actions << UpdateAction::MessageAutoDeleteTimerChanged if message.message_auto_delete_timer_changed 146 | actions << UpdateAction::MigrateToChatId if message.migrate_from_chat_id 147 | actions << UpdateAction::SupergroupChatCreated if message.supergroup_chat_created? 148 | actions << UpdateAction::ChannelChatCreated if message.channel_chat_created? 149 | actions << UpdateAction::MigrateFromChatId if message.migrate_from_chat_id 150 | actions << UpdateAction::Game if message.game 151 | actions << UpdateAction::Poll if message.poll 152 | actions << UpdateAction::VideoNote if message.video_note 153 | actions << UpdateAction::Invoice if message.invoice 154 | actions << UpdateAction::SuccessfulPayment if message.successful_payment 155 | actions << UpdateAction::UserShared if message.user_shared 156 | actions << UpdateAction::ChatShared if message.chat_shared 157 | actions << UpdateAction::ConnectedWebsite if message.connected_website 158 | actions << UpdateAction::PassportData if message.passport_data 159 | actions << UpdateAction::ProximityAlertTriggered if message.proximity_alert_triggered 160 | actions << UpdateAction::VideoChatScheduled if message.video_chat_scheduled 161 | actions << UpdateAction::ForumTopicCreated if message.forum_topic_created 162 | actions << UpdateAction::ForumTopicEdited if message.forum_topic_edited 163 | actions << UpdateAction::ForumTopicClosed if message.forum_topic_closed 164 | actions << UpdateAction::ForumTopicReopened if message.forum_topic_reopened 165 | actions << UpdateAction::GeneralForumTopicHidden if message.general_forum_topic_hidden 166 | actions << UpdateAction::GeneralForumTopicUnhidden if message.general_forum_topic_unhidden 167 | actions << UpdateAction::VideoChatStarted if message.video_chat_started 168 | actions << UpdateAction::VideoChatEnded if message.video_chat_ended 169 | actions << UpdateAction::VideoChatParticipantsInvited if message.video_chat_participants_invited 170 | actions << UpdateAction::WebAppData if message.web_app_data 171 | actions << UpdateAction::ReplyMarkup if message.reply_markup 172 | 173 | if dice = message.dice 174 | case dice.emoji 175 | when "🎲" 176 | actions << UpdateAction::Dice 177 | when "🎯" 178 | actions << UpdateAction::Dart 179 | when "🏀" 180 | actions << UpdateAction::Basketball 181 | when "⚽️" 182 | actions << UpdateAction::Football 183 | actions << UpdateAction::Soccerball 184 | when "🎰" 185 | actions << UpdateAction::SlotMachine 186 | when "🎳" 187 | actions << UpdateAction::Bowling 188 | end 189 | end 190 | 191 | case message.sender_type 192 | when Tourmaline::Message::SenderType::Bot 193 | actions << UpdateAction::BotMessage 194 | when Tourmaline::Message::SenderType::Channel 195 | actions << UpdateAction::ChannelMessage 196 | when Tourmaline::Message::SenderType::User 197 | actions << UpdateAction::UserMessage 198 | when Tourmaline::Message::SenderType::AnonymousAdmin 199 | actions << UpdateAction::AnonymousAdminMessage 200 | when Tourmaline::Message::SenderType::ChannelForward 201 | actions << UpdateAction::ChannelForwardMessage 202 | end 203 | 204 | entities = (message.entities + message.caption_entities).map(&.type).uniq 205 | entities.each do |ent| 206 | case ent 207 | when "mention" 208 | actions << UpdateAction::MentionEntity 209 | when "text_mention" 210 | actions << UpdateAction::TextMentionEntity 211 | when "hashtag" 212 | actions << UpdateAction::HashtagEntity 213 | when "cashtag" 214 | actions << UpdateAction::CashtagEntity 215 | when "bot_command" 216 | actions << UpdateAction::BotCommandEntity 217 | when "url" 218 | actions << UpdateAction::UrlEntity 219 | when "email" 220 | actions << UpdateAction::EmailEntity 221 | when "phone_number" 222 | actions << UpdateAction::PhoneNumberEntity 223 | when "bold" 224 | actions << UpdateAction::BoldEntity 225 | when "italic" 226 | actions << UpdateAction::ItalicEntity 227 | when "code" 228 | actions << UpdateAction::CodeEntity 229 | when "pre" 230 | actions << UpdateAction::PreEntity 231 | when "text_link" 232 | actions << UpdateAction::TextLinkEntity 233 | when "underline" 234 | actions << UpdateAction::UnderlineEntity 235 | when "strikethrough" 236 | actions << UpdateAction::StrikethroughEntity 237 | when "spoiler" 238 | actions << UpdateAction::SpoilerEntity 239 | end 240 | end 241 | end 242 | 243 | actions << UpdateAction::EditedMessage if update.edited_message 244 | actions << UpdateAction::ChannelPost if update.channel_post 245 | actions << UpdateAction::EditedChannelPost if update.edited_channel_post 246 | actions << UpdateAction::InlineQuery if update.inline_query 247 | actions << UpdateAction::ChosenInlineResult if update.chosen_inline_result 248 | actions << UpdateAction::CallbackQuery if update.callback_query 249 | actions << UpdateAction::ShippingQuery if update.shipping_query 250 | actions << UpdateAction::PreCheckoutQuery if update.pre_checkout_query 251 | actions << UpdateAction::Poll if update.poll 252 | actions << UpdateAction::PollAnswer if update.poll_answer 253 | actions << UpdateAction::MyChatMember if update.my_chat_member 254 | actions << UpdateAction::ChatMember if update.chat_member 255 | 256 | actions 257 | end 258 | end 259 | end 260 | -------------------------------------------------------------------------------- /src/tourmaline/client.cr: -------------------------------------------------------------------------------- 1 | require "./helpers" 2 | require "./error" 3 | require "./logger" 4 | require "./parse_mode" 5 | require "./chat_action" 6 | require "./update_action" 7 | require "./keyboard_builder" 8 | require "./types/**" 9 | require "./context" 10 | require "./middleware" 11 | require "./event_handler" 12 | require "./dispatcher" 13 | require "./poller" 14 | require "./server" 15 | require "./handlers/*" 16 | require "./client/**" 17 | 18 | require "db/pool" 19 | 20 | module Tourmaline 21 | # The `Client` class is the base class for all Tourmaline based bots. 22 | # Extend this class to create your own bots, or create an 23 | # instance of `Client` and add event handlers to it. 24 | class Client 25 | include Api 26 | include Logger 27 | 28 | DEFAULT_API_URL = "https://api.telegram.org/" 29 | 30 | # Gets the name of the Client at the time the Client was 31 | # started. Refreshing can be done by setting 32 | # `@bot` to `get_me`. 33 | getter! bot : User 34 | 35 | getter bot_token : String 36 | 37 | property default_parse_mode : ParseMode 38 | 39 | @dispatcher : Dispatcher? 40 | 41 | private getter pool : DB::Pool(HTTP::Client) 42 | 43 | # Create a new instance of `Tourmaline::Client`. 44 | # 45 | # ## Named Arguments 46 | # 47 | # `bot_token` 48 | # : the bot token you should've received from `@BotFather` 49 | # 50 | # `endpoint` 51 | # : the API endpoint to use for requests; default is `https://api.telegram.org`, but for 52 | # TDLight methods to work you may consider hosting your own instance or using one of 53 | # the official ones such as `https://telegram.rest` 54 | # 55 | # `default_parse_mode` 56 | # : the default parse mode to use for messages; default is `ParseMode::None` (no formatting) 57 | # 58 | # `pool_capacity` 59 | # : the maximum number of concurrent HTTP connections to use 60 | # 61 | # `initial_pool_size` 62 | # : the number of HTTP::Client instances to create on init 63 | # 64 | # `pool_timeout` 65 | # : How long to wait for a new client to be available if the pool is full before throwing a `TimeoutError` 66 | # 67 | # `proxy` 68 | # : an instance of `HTTP::Proxy::Client` to use; if set, overrides the following `proxy_` args 69 | # 70 | # `proxy_uri` 71 | # : a URI to use when connecting to the proxy; can be a `URI` instance or a String 72 | # 73 | # `proxy_host` 74 | # : if no `proxy_uri` is provided, this will be the host for the URI 75 | # 76 | # `proxy_port` 77 | # : if no `proxy_uri` is provided, this will be the port for the URI 78 | # 79 | # `proxy_user` 80 | # : a username to use for a proxy that requires authentication 81 | # 82 | # `proxy_pass` 83 | # : a password to use for a proxy that requires authentication 84 | def initialize(@bot_token : String, 85 | @endpoint = DEFAULT_API_URL, 86 | @default_parse_mode : ParseMode = ParseMode::Markdown, 87 | pool_capacity = 200, 88 | initial_pool_size = 20, 89 | pool_timeout = 0.1, 90 | proxy = nil, 91 | proxy_uri = nil, 92 | proxy_host = nil, 93 | proxy_port = nil, 94 | proxy_user = nil, 95 | proxy_pass = nil) 96 | if !proxy 97 | if proxy_uri 98 | proxy_uri = proxy_uri.is_a?(URI) ? proxy_uri : URI.parse(proxy_uri.starts_with?("http") ? proxy_uri : "http://#{proxy_uri}") 99 | proxy_host = proxy_uri.host 100 | proxy_port = proxy_uri.port 101 | proxy_user = proxy_uri.user if proxy_uri.user 102 | proxy_pass = proxy_uri.password if proxy_uri.password 103 | end 104 | 105 | if proxy_host && proxy_port 106 | proxy = HTTP::Proxy::Client.new(proxy_host, proxy_port, username: proxy_user, password: proxy_pass) 107 | end 108 | end 109 | 110 | @pool = DB::Pool(HTTP::Client).new(max_pool_size: pool_capacity, initial_pool_size: initial_pool_size, checkout_timeout: pool_timeout) do 111 | client = HTTP::Client.new(URI.parse(endpoint)) 112 | client.proxy = proxy.dup if proxy 113 | client 114 | end 115 | 116 | @bot = self.get_me 117 | end 118 | 119 | def dispatcher 120 | @dispatcher ||= Dispatcher.new(self) 121 | end 122 | 123 | def on(action : UpdateAction, &block : Context ->) 124 | dispatcher.on(action, &block) 125 | end 126 | 127 | def on(*actions : Symbol | UpdateAction, &block : Context ->) 128 | actions.each do |action| 129 | action = UpdateAction.parse(action.to_s) if action.is_a?(Symbol) 130 | dispatcher.on(action, &block) 131 | end 132 | end 133 | 134 | def use(middleware : Middleware) 135 | dispatcher.use(middleware) 136 | end 137 | 138 | def register(*handlers : EventHandler) 139 | handlers.each do |handler| 140 | dispatcher.register(handler) 141 | end 142 | end 143 | 144 | def poll 145 | Poller.new(self).start 146 | end 147 | 148 | def serve(path = "/", host = "127.0.0.1", port = 8081, ssl_certificate_path = nil, ssl_key_path = nil, no_middleware_check = false) 149 | Server.new(self).serve(path, host, port, ssl_certificate_path, ssl_key_path, no_middleware_check) 150 | end 151 | 152 | protected def using_connection 153 | @pool.retry do 154 | @pool.checkout do |conn| 155 | yield conn 156 | end 157 | end 158 | end 159 | 160 | # :nodoc: 161 | MULTIPART_METHODS = %w(sendAudio sendDocument sendPhoto sendVideo sendAnimation sendVoice sendVideoNote sendMediaGroup) 162 | 163 | # Sends a request to the Telegram Client API. Returns the raw response. 164 | def request_raw(method : String, params = {} of String => String) 165 | path = "/bot#{bot_token}/#{method}" 166 | request_internal(path, params, multipart: MULTIPART_METHODS.includes?(method)) 167 | end 168 | 169 | # Sends a request to the Telegram Client API. Returns the response, parsed as a `U`. 170 | def request(type : U.class, method, params = {} of String => String) forall U 171 | response = request_raw(method, params) 172 | type.from_json(response) 173 | end 174 | 175 | # :nodoc: 176 | def request_internal(path, params = {} of String => String, multipart = false) 177 | # Wrap this so pool can attempt a retry 178 | using_connection do |client| 179 | Log.debug { "sending ►► #{path.split("/").last}(#{params.to_pretty_json})" } 180 | 181 | begin 182 | if multipart 183 | config = build_form_data_config(params) 184 | response = client.exec(**config.merge({path: path})) 185 | else 186 | config = build_json_config(params) 187 | response = client.exec(**config.merge({path: path})) 188 | end 189 | rescue ex : IO::Error | IO::TimeoutError 190 | Log.error { ex.message } 191 | Log.trace(exception: ex) { ex.message } 192 | 193 | raise Error::ConnectionLost.new(client) 194 | end 195 | 196 | result = JSON.parse(response.body) 197 | 198 | Log.debug { "receiving ◄◄ #{result.to_pretty_json}" } 199 | 200 | if result["ok"].as_bool 201 | result["result"].to_json 202 | else 203 | raise Error.from_message(result["description"].as_s) 204 | end 205 | end 206 | end 207 | 208 | protected def extract_id(object) 209 | return if object.nil? 210 | if object.responds_to?(:id) 211 | return object.id 212 | elsif object.responds_to?(:message_id) 213 | return object.message_id 214 | elsif object.responds_to?(:file_id) 215 | return object.file_id 216 | elsif object.responds_to?(:to_i) 217 | return object.to_i 218 | end 219 | raise ArgumentError.new("Expected object with id or message_id, or integer, got #{object.class}") 220 | end 221 | 222 | protected def build_json_config(payload) 223 | { 224 | method: "POST", 225 | headers: HTTP::Headers{"Content-Type" => "application/json", "Connection" => "keep-alive"}, 226 | body: payload.to_h.compact.to_json, 227 | } 228 | end 229 | 230 | protected def build_form_data_config(payload) 231 | boundary = MIME::Multipart.generate_boundary 232 | formdata = MIME::Multipart.build(boundary) do |form| 233 | payload.each do |key, value| 234 | attach_form_value(form, key.to_s, value) 235 | end 236 | end 237 | 238 | { 239 | method: "POST", 240 | headers: HTTP::Headers{ 241 | "Content-Type" => "multipart/form-data; boundary=#{boundary}", 242 | "Connection" => "keep-alive", 243 | }, 244 | body: formdata, 245 | } 246 | end 247 | 248 | protected def attach_form_value(form : MIME::Multipart::Builder, id : String, value) 249 | return unless value 250 | headers = HTTP::Headers{"Content-Disposition" => "form-data; name=#{id}"} 251 | 252 | case value 253 | when Array 254 | # Likely an Array(InputMedia) 255 | items = value.map do |item| 256 | if item.is_a?(InputMedia) 257 | attach_form_media(form, item) 258 | end 259 | item 260 | end 261 | form.body_part(headers, items.to_json) 262 | when InputMedia 263 | attach_form_media(form, value) 264 | form.body_part(headers, value.to_json) 265 | when ::File 266 | filename = ::File.basename(value.path) 267 | form.body_part( 268 | HTTP::Headers{"Content-Disposition" => "form-data; name=#{id}; filename=#{filename}"}, 269 | value 270 | ) 271 | else 272 | form.body_part(headers, value.to_s) 273 | end 274 | end 275 | 276 | protected def attach_form_media(form : MIME::Multipart::Builder, value : InputMedia) 277 | media = value.media 278 | thumbnail = value.responds_to?(:thumbnail) ? value.thumbnail : nil 279 | 280 | {media: media, thumbnail: thumbnail}.each do |key, item| 281 | item = check_open_local_file(item) 282 | if item.is_a?(::File) 283 | id = Random.new.random_bytes(16).hexstring 284 | filename = ::File.basename(item.path) 285 | 286 | form.body_part( 287 | HTTP::Headers{"Content-Disposition" => "form-data; name=#{id}; filename=#{filename}"}, 288 | item 289 | ) 290 | 291 | if key == :media 292 | value.media = "attach://#{id}" 293 | elsif value.responds_to?(:thumbnail) 294 | value.thumbnail = "attach://#{id}" 295 | end 296 | end 297 | end 298 | end 299 | 300 | protected def check_open_local_file(file) 301 | if file.is_a?(String) 302 | begin 303 | if ::File.file?(file) 304 | return ::File.open(file) 305 | end 306 | rescue ex 307 | end 308 | end 309 | file 310 | end 311 | end 312 | end 313 | -------------------------------------------------------------------------------- /scripts/generate.cr: -------------------------------------------------------------------------------- 1 | require "json" 2 | require "http/client" 3 | require "code_writer" 4 | 5 | METHODS_OUTPUT = File.expand_path(File.join(File.dirname(__FILE__), "../src/tourmaline/client/api.cr")) 6 | TYPES_OUTPUT = File.expand_path(File.join(File.dirname(__FILE__), "../src/tourmaline/types/api.cr")) 7 | 8 | API_JSON_ENDPOINT = "https://raw.githubusercontent.com/PaulSonOfLars/telegram-bot-api-spec/master/api.min.json" 9 | 10 | BANNER = <<-TEXT 11 | # This file is auto-generated by the scripts/generate.cr script. 12 | # Do not edit this file manually. Changes will be overwritten. 13 | TEXT 14 | 15 | DEFAULTS = { 16 | "InlineQueryResultArticle" => { 17 | "type" => "article", 18 | }, 19 | "InlineQueryResultPhoto" => { 20 | "type" => "photo", 21 | }, 22 | "InlineQueryResultGif" => { 23 | "type" => "gif", 24 | }, 25 | "InlineQueryResultMpeg4Gif" => { 26 | "type" => "mpeg4_gif", 27 | }, 28 | "InlineQueryResultVideo" => { 29 | "type" => "video", 30 | }, 31 | "InlineQueryResultAudio" => { 32 | "type" => "audio", 33 | }, 34 | "InlineQueryResultVoice" => { 35 | "type" => "voice", 36 | }, 37 | "InlineQueryResultDocument" => { 38 | "type" => "document", 39 | }, 40 | "InlineQueryResultLocation" => { 41 | "type" => "location", 42 | }, 43 | "InlineQueryResultVenue" => { 44 | "type" => "venue", 45 | }, 46 | "InlineQueryResultContact" => { 47 | "type" => "contact", 48 | }, 49 | "InlineQueryResultGame" => { 50 | "type" => "game", 51 | }, 52 | "InlineQueryResultCachedPhoto" => { 53 | "type" => "photo", 54 | }, 55 | "InlineQueryResultCachedGif" => { 56 | "type" => "gif", 57 | }, 58 | "InlineQueryResultCachedMpeg4Gif" => { 59 | "type" => "mpeg4_gif", 60 | }, 61 | "InlineQueryResultCachedSticker" => { 62 | "type" => "sticker", 63 | }, 64 | "InlineQueryResultCachedDocument" => { 65 | "type" => "document", 66 | }, 67 | "InlineQueryResultCachedVideo" => { 68 | "type" => "video", 69 | }, 70 | "InlineQueryResultCachedVoice" => { 71 | "type" => "voice", 72 | }, 73 | "InlineQueryResultCachedAudio" => { 74 | "type" => "audio", 75 | }, 76 | "InputMediaPhoto" => { 77 | "type" => "photo", 78 | }, 79 | "InputMediaVideo" => { 80 | "type" => "video", 81 | }, 82 | "InputMediaAnimation" => { 83 | "type" => "animation", 84 | }, 85 | "InputMediaAudio" => { 86 | "type" => "audio", 87 | }, 88 | "InputMediaDocument" => { 89 | "type" => "document", 90 | }, 91 | "InputMediaAnimation" => { 92 | "type" => "animation", 93 | }, 94 | } 95 | 96 | class Api 97 | include JSON::Serializable 98 | 99 | property version : String 100 | 101 | property release_date : String 102 | 103 | property changelog : String 104 | 105 | property methods : Hash(String, Api::TypeDef) 106 | 107 | property types : Hash(String, Api::TypeDef) 108 | 109 | class TypeDef 110 | include JSON::Serializable 111 | 112 | property name : String 113 | 114 | property href : String 115 | 116 | property description : Array(String) 117 | 118 | property returns : Array(String) = [] of String 119 | 120 | property fields : Array(Api::Field) = [] of Api::Field 121 | 122 | property subtypes : Array(String) = [] of String 123 | 124 | property subtype_of : Array(String) = [] of String 125 | end 126 | 127 | class Field 128 | include JSON::Serializable 129 | 130 | property name : String 131 | 132 | property types : Array(String) = [] of String 133 | 134 | property required : Bool 135 | 136 | property description : String 137 | 138 | def default_value(type_name : String) 139 | DEFAULTS[type_name]?.try(&.[name]?) 140 | end 141 | end 142 | end 143 | 144 | def get_spec 145 | spec = HTTP::Client.get(API_JSON_ENDPOINT) 146 | Api.from_json(spec.body) 147 | end 148 | 149 | def type_to_cr(type : String | Array(String)) 150 | if type.is_a?(Array) 151 | return type.map(&->type_to_cr(String)).join(" | ") 152 | end 153 | 154 | if type.starts_with?("Array of ") 155 | return "Array(" + type_to_cr(type.sub("Array of ", "")) + ")" 156 | end 157 | 158 | case type 159 | when "Integer" 160 | "Int32 | Int64" 161 | when "Float" 162 | "Float64" 163 | when "Boolean" 164 | "Bool" 165 | when "String" 166 | "String" 167 | when "InputFile" 168 | "::File" 169 | else 170 | "Tourmaline::#{type}" 171 | end 172 | end 173 | 174 | def write_methods(writer : CodeWriter, methods : Array(Api::TypeDef)) 175 | writer.comment("Auto generated methods for the Telegram bot API.") 176 | writer.block("module Tourmaline") do 177 | writer.block("class Client") do 178 | writer.block("module Api") do 179 | methods.each do |method| 180 | write_method(writer, method) 181 | writer.newline 182 | end 183 | end 184 | end 185 | end 186 | end 187 | 188 | # Write a single method to the writer 189 | def write_method(writer : CodeWriter, method : Api::TypeDef) 190 | method.description.each do |line| 191 | writer.comment(line) 192 | end 193 | 194 | # Sort the fields by requiredness. Required fields come first. Maintains order. 195 | fields = method.fields.sort_by { |f| f.required ? -1 : 1 } 196 | 197 | if fields.empty? 198 | writer.puts("def #{method.name.underscore}") 199 | else 200 | writer.puts("def #{method.name.underscore}(").indent do 201 | fields.each_with_index do |field, i| 202 | field_name = field.name.underscore 203 | crystal_type = type_to_cr(field.types) 204 | writer.print(field_name) 205 | 206 | if field_name == "parse_mode" 207 | writer.print(" : ParseMode = default_parse_mode") 208 | else 209 | writer.print(" : #{crystal_type}") 210 | 211 | if !field.required 212 | writer.print(" | ::Nil = nil") 213 | end 214 | end 215 | 216 | if i < fields.size - 1 217 | writer.print(", ").newline 218 | end 219 | end 220 | end 221 | writer.newline.puts(")") 222 | end 223 | 224 | writer.indent do 225 | writer.print("request(#{type_to_cr(method.returns)}, \"#{method.name}\"") 226 | if fields.any? 227 | writer.puts(", {") 228 | writer.indent do 229 | fields.each_with_index do |field, i| 230 | field_name = field.name.underscore 231 | if field.description.includes?("JSON-serialized") 232 | if field.required 233 | writer.print("#{field_name}: #{field_name}.to_json") 234 | else 235 | writer.print("#{field_name}: #{field_name}.try(&.to_json)") 236 | end 237 | else 238 | writer.print("#{field_name}: #{field_name}") 239 | end 240 | if i < fields.size - 1 241 | writer.print(", ").newline 242 | end 243 | end 244 | end 245 | writer.newline.puts("})") 246 | else 247 | writer.puts(")") 248 | end 249 | end 250 | 251 | writer.puts(writer.block_end) 252 | end 253 | 254 | def write_types(writer : CodeWriter, types : Array(Api::TypeDef)) 255 | writer.comment("Auto generated types for the Telegram bot API.") 256 | writer.block("module Tourmaline") do 257 | types.each_with_index do |type, i| 258 | write_type(writer, type) 259 | writer.blank_line if i < types.size - 1 260 | end 261 | end 262 | end 263 | 264 | def write_type(writer : CodeWriter, type : Api::TypeDef) 265 | type.description.each do |line| 266 | writer.comment(line) 267 | end 268 | 269 | # Sort fields so that required fields come first, required fields with default 270 | # values come next, optional arrays come next, and all other optional fields come last. 271 | fields = type.fields.sort_by do |f| 272 | if f.required && !f.default_value(type.name) && !f.types.any? { |t| t.starts_with?("Array of ") } 273 | 0 274 | elsif f.required && (f.default_value(type.name) || f.types.any? { |t| t.starts_with?("Array of ") }) 275 | 1 276 | else 277 | 2 278 | end 279 | end 280 | 281 | if type.subtypes.any? 282 | writer.print "alias #{type.name} = " 283 | writer.print type.subtypes.map { |subtype| "Tourmaline::#{subtype}" }.join(" | ") 284 | writer.newline 285 | return 286 | end 287 | 288 | writer.block("class #{type.name}") do 289 | writer.print("include JSON::Serializable") 290 | writer.blank_line if fields.any? 291 | 292 | fields.each do |field| 293 | field_name = field.name.underscore 294 | 295 | writer.comment(field.description) 296 | 297 | crystal_type = type_to_cr(field.types) 298 | if default = field.default_value(type.name) 299 | writer.print("property #{field_name} : #{crystal_type} = \"#{default}\"") 300 | elsif field_name == "parse_mode" 301 | writer.print("property #{field_name} : ParseMode = ParseMode::Markdown") 302 | elsif field_name =~ /\b(date|time)\b|_date$|_time$/i 303 | writer.puts("@[JSON::Field(converter: Time::EpochConverter)]") 304 | writer.print("property #{field_name} : Time") 305 | writer.print(" | ::Nil") if !field.required 306 | elsif crystal_type == "Bool" 307 | writer.print("property? #{field_name} : #{crystal_type}") 308 | writer.print(" | ::Nil") if !field.required 309 | elsif crystal_type.starts_with?("Array(") 310 | writer.print("property #{field_name} : #{crystal_type}") 311 | # get the inner type 312 | inner_type = crystal_type.sub("Array(", "").sub(")", "") 313 | # if it isn't a primitive type, prepend the module name 314 | writer.print(" = [] of #{inner_type}") 315 | else 316 | writer.print("property #{field_name} : #{crystal_type}") 317 | writer.print(" | ::Nil") if !field.required 318 | end 319 | 320 | writer.newline 321 | writer.newline 322 | end 323 | 324 | if fields.any? 325 | writer.puts("def initialize(").indent do 326 | fields.each_with_index do |field, i| 327 | crystal_type = type_to_cr(field.types) 328 | field_name = field.name.underscore 329 | writer.print("@#{field_name}") 330 | if default = field.default_value(type.name) 331 | writer.print(" = \"#{default}\"") 332 | elsif field_name == "parse_mode" 333 | writer.print(" : ParseMode = ParseMode::Markdown") 334 | elsif crystal_type.starts_with?("Array(") 335 | inner_type = crystal_type.sub("Array(", "").sub(")", "") 336 | writer.print(" : #{crystal_type} = [] of #{inner_type}") 337 | elsif !field.required 338 | writer.print(" : #{crystal_type} | ::Nil = nil") 339 | end 340 | 341 | if i < fields.size - 1 342 | writer.print(", ").newline 343 | end 344 | end 345 | end 346 | writer.newline.puts(")") 347 | writer.puts(writer.block_end) 348 | end 349 | end 350 | end 351 | 352 | def main 353 | types_file = File.open(TYPES_OUTPUT, "w+") 354 | methods_file = File.open(METHODS_OUTPUT, "w+") 355 | 356 | types_writer = CodeWriter.new(buffer: types_file, tab_count: 2) 357 | methods_writer = CodeWriter.new(buffer: methods_file, tab_count: 2) 358 | 359 | spec = get_spec 360 | puts "Generating client for Telegram Bot API #{spec.version} (#{spec.release_date})" 361 | puts "Changelog: #{spec.changelog}" 362 | 363 | types_writer.puts BANNER 364 | types_writer.newline 365 | 366 | methods_writer.puts BANNER 367 | methods_writer.newline 368 | 369 | write_types(types_writer, spec.types.values) 370 | write_methods(methods_writer, spec.methods.values) 371 | ensure 372 | types_file.try &.close 373 | methods_file.try &.close 374 | end 375 | 376 | main() 377 | -------------------------------------------------------------------------------- /src/tourmaline/error.cr: -------------------------------------------------------------------------------- 1 | require "db/pool" 2 | 3 | module Tourmaline 4 | class Error < Exception 5 | # Raised when a connection is unable to be established 6 | # probably due to socket/network or configuration issues. 7 | # It is used by the connection pool retry logic. 8 | class ConnectionLost < ::DB::PoolResourceLost(HTTP::Client); end 9 | 10 | class PoolRetryAttemptsExceeded < ::DB::PoolRetryAttemptsExceeded; end 11 | 12 | ERROR_PREFIXES = ["error: ", "[error]: ", "bad request: ", "conflict: ", "not found: "] 13 | 14 | def initialize(message = "") 15 | super(clean_message(message)) 16 | end 17 | 18 | def self.from_message(text) 19 | error = case text 20 | when /member list is inaccessible/ 21 | MemberListInaccessible 22 | when /chat not found/ 23 | ChatNotFound 24 | when /user not found/ 25 | UserNotFound 26 | when /chat_id is empty/ 27 | ChatIdIsEmpty 28 | when /invalid user_id specified/ 29 | text = "Invalid user id" 30 | InvalidUserId 31 | when /chat description is not modified/ 32 | ChatDescriptionIsNotModified 33 | when /query is too old and response timeout expired or query id is invalid/ 34 | InvalidQueryID 35 | when /PEER_ID_INVALID/ 36 | text = "Invalid peer ID" 37 | InvalidPeerID 38 | when /RESULT_ID_INVALID/ 39 | text = "Invalid result ID" 40 | InvalidResultID 41 | when /Failed to get HTTP URL content/ 42 | InvalidHTTPUrlContent 43 | when /BUTTON_URL_INVALID/ 44 | text = "Button URL invalid" 45 | ButtonURLInvalid 46 | when /URL host is empty/ 47 | URLHostIsEmpty 48 | when /START_PARAM_INVALID/ 49 | text = "Start param invalid" 50 | StartParamInvalid 51 | when /BUTTON_DATA_INVALID/ 52 | text = "Button data invalid" 53 | ButtonDataInvalid 54 | when /wrong file identifier\/HTTP URL specified/ 55 | WrongFileIdentifier 56 | when /group is deactivated/ 57 | GroupDeactivated 58 | when /Photo should be uploaded as an InputFile/ 59 | PhotoAsInputFileRequired 60 | when /STICKERSET_INVALID/ 61 | text = "Sticker set is invalid" 62 | InvalidStickersSet 63 | when /there is no sticker in the request/ 64 | NoStickerInRequest 65 | when /CHAT_ADMIN_REQUIRED/ 66 | text = "Admin permissions are required" 67 | ChatAdminRequired 68 | when /need administrator rights in the channel chat/ 69 | text = "Admin permissions are required" 70 | NeedAdministratorRightsInTheChannel 71 | when /not enough rights to pin a message/ 72 | NotEnoughRightsToPinMessage 73 | when /method is available only for supergroups and channel/ 74 | MethodNotAvailableInPrivateChats 75 | when /can't demote chat creator/ 76 | CantDemoteChatCreator 77 | when /can't remove chat owner/ 78 | CantRemoveChatOwner 79 | when /can't restrict self/ 80 | text = "Admin can't restrict self" 81 | CantRestrictSelf 82 | when /not enough rights to restrict\/unrestrict chat member/ 83 | NotEnoughRightsToRestrict 84 | when /not enough rights/ 85 | NotEnoughRightsOther 86 | when /PHOTO_INVALID_DIMENSIONS/ 87 | text = "Invalid photo dimensions" 88 | PhotoDimensions 89 | when /supergroup members are unavailable/ 90 | UnavailableMembers 91 | when /type of file mismatch/ 92 | TypeOfFileMismatch 93 | when /wrong remote file id specified/ 94 | WrongRemoteFileIdSpecified 95 | when /PAYMENT_PROVIDER_INVALID/ 96 | text = "Payment provider invalid" 97 | PaymentProviderInvalid 98 | when /currency_total_amount_invalid/ 99 | text = "Currency total amount invalid" 100 | CurrencyTotalAmountInvalid 101 | when /HTTPS url must be provided for webhook/ 102 | text = "Bad webhook: HTTPS url must be provided for webhook" 103 | WebhookRequireHTTPS 104 | when /Webhook can be set up only on ports 80, 88, 443 or 8443/ 105 | text = "Bad webhook: Webhook can be set up only on ports 80, 88, 443 or 8443" 106 | BadWebhookPort 107 | when /getaddrinfo: Temporary failure in name resolution/ 108 | text = "Bad webhoook: getaddrinfo: Temporary failure in name resolution" 109 | BadWebhookAddrInfo 110 | when /failed to resolve host: no address associated with hostname/ 111 | BadWebhookNoAddressAssociatedWithHostname 112 | when /can't parse URL/ 113 | CantParseUrl 114 | when /unsupported URL protocol/ 115 | UnsupportedUrlProtocol 116 | when /can't parse entities/ 117 | CantParseEntities 118 | when /result_id_duplicate/ 119 | text = "Result ID duplicate" 120 | ResultIdDuplicate 121 | when /bot_domain_invalid/ 122 | text = "Invalid bot domain" 123 | BotDomainInvalid 124 | when /Method is available only for supergroups/ 125 | MethodIsNotAvailable 126 | when /method not found/ 127 | MethodNotKnown 128 | when /terminated by other getUpdates request/ 129 | text = "Terminated by other getUpdates request; " \ 130 | "Make sure that only one bot instance is running" 131 | TerminatedByOtherGetUpdates 132 | when /can't use getUpdates method while webhook is active/ 133 | CantGetUpdates 134 | when /bot was kicked from a chat/ 135 | BotKicked 136 | when /bot was blocked by the user/ 137 | BotBlocked 138 | when /user is deactivated/ 139 | UserDeactivated 140 | when /bot can't initiate conversation with a user/ 141 | CantInitiateConversation 142 | when /bot can't send messages to bots/ 143 | CantTalkWithBots 144 | when /message is not modified/ 145 | text = "Bad request: message is not modified " \ 146 | "message content and reply markup are exactly the same " \ 147 | "as a current content and reply markup of the message" 148 | MessageNotModified 149 | when /MESSAGE_ID_INVALID/ 150 | MessageIdInvalid 151 | when /message to forward not found/ 152 | MessageToForwardNotFound 153 | when /message to delete not found/ 154 | MessageToDeleteNotFound 155 | when /message text is empty/ 156 | MessageTextIsEmpty 157 | when /message can't be edited/ 158 | MessageCantBeEdited 159 | when /message can't be deleted/ 160 | MessageCantBeDeleted 161 | when /message to edit not found/ 162 | MessageToEditNotFound 163 | when /reply message not found/ 164 | MessageToReplyNotFound 165 | when /message identifier is not specified/ 166 | MessageIdentifierNotSpecified 167 | when /message is too long/ 168 | MessageIsTooLong 169 | when /Too much messages to send as an album/ 170 | TooMuchMessages 171 | when /wrong live location period specified/ 172 | WrongLiveLocationPeriod 173 | when /The group has been migrated to a supergroup with ID (\-?\d+)/ 174 | match = text.match(/The group has been migrated to a supergroup with ID (\-?\d+)/) 175 | id = match.not_nil![1].to_i64 176 | return MigrateToChat.new(id) 177 | when /retry after (\d+)/ 178 | match = text.match(/retry after (\d+)/) 179 | seconds = match.not_nil![1].to_i 180 | return RetryAfter.new(seconds) 181 | else 182 | Error 183 | end 184 | 185 | error.new(text) 186 | end 187 | 188 | protected def clean_message(text) 189 | ERROR_PREFIXES.each do |prefix| 190 | if text.starts_with?(prefix) 191 | text = text[prefix.size..] 192 | end 193 | end 194 | text[0].upcase + text[1..].strip 195 | end 196 | 197 | class ValidationError < Error; end 198 | 199 | class Throttled < Error 200 | # TODO: Optionally handle rate limiting here 201 | end 202 | 203 | class RetryAfter < Error 204 | getter seconds : Int32 205 | 206 | def initialize(seconds) 207 | @seconds = seconds.to_i 208 | super("Flood control exceeded. Retry in #{@seconds} seconds.") 209 | end 210 | end 211 | 212 | class MigrateToChat < Error 213 | getter chat_id : Int64 214 | 215 | def initialize(chat_id) 216 | @chat_id = chat_id.to_i64 217 | super("The group has been migrated to a supergroup. New id: #{@chat_id}.") 218 | end 219 | end 220 | 221 | class BadRequest < Error; end 222 | 223 | class RequestTimeoutError < BadRequest; end 224 | 225 | class MemberListInaccessible < BadRequest; end 226 | 227 | class MessageError < BadRequest; end 228 | 229 | class MessageNotModified < MessageError; end 230 | 231 | class MessageIdInvalid < MessageError; end 232 | 233 | class MessageToForwardNotFound < MessageError; end 234 | 235 | class MessageToDeleteNotFound < MessageError; end 236 | 237 | class MessageIdentifierNotSpecified < MessageError; end 238 | 239 | class MessageTextIsEmpty < MessageError; end 240 | 241 | class MessageCantBeEdited < MessageError; end 242 | 243 | class MessageCantBeDeleted < MessageError; end 244 | 245 | class MessageToEditNotFound < MessageError; end 246 | 247 | class MessageToReplyNotFound < MessageError; end 248 | 249 | class MessageIsTooLong < MessageError; end 250 | 251 | class TooMuchMessages < MessageError; end 252 | 253 | class PollError < BadRequest; end 254 | 255 | class PollCantBeStopped < MessageError; end 256 | 257 | class PollHasAlreadyClosed < MessageError; end 258 | 259 | class PollsCantBeSentToPrivateChats < MessageError; end 260 | 261 | class MessageWithPollNotFound < MessageError; end 262 | 263 | class MessageIsNotAPoll < MessageError; end 264 | 265 | class PollSizeError < PollError; end 266 | 267 | class PollMustHaveMoreOptions < PollError; end 268 | 269 | class PollCantHaveMoreOptions < PollError; end 270 | 271 | class PollsOptionsLengthTooLong < PollError; end 272 | 273 | class PollOptionsMustBeNonEmpty < PollError; end 274 | 275 | class PollQuestionMustBeNonEmpty < PollError; end 276 | 277 | class ObjectExpectedAsReplyMarkup < BadRequest; end 278 | 279 | class InlineKeyboardExpected < BadRequest; end 280 | 281 | class ChatNotFound < BadRequest; end 282 | 283 | class UserNotFound < BadRequest; end 284 | 285 | class ChatDescriptionIsNotModified < BadRequest; end 286 | 287 | class InvalidQueryID < BadRequest; end 288 | 289 | class InvalidPeerID < BadRequest; end 290 | 291 | class InvalidResultID < BadRequest; end 292 | 293 | class InvalidHTTPUrlContent < BadRequest; end 294 | 295 | class ButtonURLInvalid < BadRequest; end 296 | 297 | class URLHostIsEmpty < BadRequest; end 298 | 299 | class StartParamInvalid < BadRequest; end 300 | 301 | class ButtonDataInvalid < BadRequest; end 302 | 303 | class WrongFileIdentifier < BadRequest; end 304 | 305 | class GroupDeactivated < BadRequest; end 306 | 307 | class WrongLiveLocationPeriod < BadRequest; end 308 | 309 | class BadWebhook < BadRequest; end 310 | 311 | class WebhookRequireHTTPS < BadWebhook; end 312 | 313 | class BadWebhookPort < BadWebhook; end 314 | 315 | class BadWebhookAddrInfo < BadWebhook; end 316 | 317 | class BadWebhookNoAddressAssociatedWithHostname < BadWebhook; end 318 | 319 | class NotFound < BadRequest; end 320 | 321 | class MethodNotKnown < NotFound; end 322 | 323 | class PhotoAsInputFileRequired < BadRequest; end 324 | 325 | class InvalidStickersSet < BadRequest; end 326 | 327 | class NoStickerInRequest < BadRequest; end 328 | 329 | class ChatAdminRequired < BadRequest; end 330 | 331 | class NeedAdministratorRightsInTheChannel < BadRequest; end 332 | 333 | class MethodNotAvailableInPrivateChats < BadRequest; end 334 | 335 | class CantDemoteChatCreator < BadRequest; end 336 | 337 | class CantRemoveChatOwner < BadRequest; end 338 | 339 | class CantRestrictSelf < BadRequest; end 340 | 341 | class NotEnoughRightsToRestrict < BadRequest; end 342 | 343 | class NotEnoughRightsToPinMessage < BadRequest; end 344 | 345 | class NotEnoughRightsOther < BadRequest; end 346 | 347 | class PhotoDimensions < BadRequest; end 348 | 349 | class UnavailableMembers < BadRequest; end 350 | 351 | class TypeOfFileMismatch < BadRequest; end 352 | 353 | class WrongRemoteFileIdSpecified < BadRequest; end 354 | 355 | class PaymentProviderInvalid < BadRequest; end 356 | 357 | class CurrencyTotalAmountInvalid < BadRequest; end 358 | 359 | class CantParseUrl < BadRequest; end 360 | 361 | class UnsupportedUrlProtocol < BadRequest; end 362 | 363 | class CantParseEntities < BadRequest; end 364 | 365 | class ResultIdDuplicate < BadRequest; end 366 | 367 | class MethodIsNotAvailable < BadRequest; end 368 | 369 | class ChatIdIsEmpty < BadRequest; end 370 | 371 | class InvalidUserId < BadRequest; end 372 | 373 | class BotDomainInvalid < BadRequest; end 374 | 375 | class ConflictError < Error; end 376 | 377 | class TerminatedByOtherGetUpdates < ConflictError; end 378 | 379 | class CantGetUpdates < ConflictError; end 380 | 381 | class Unauthorized < Error; end 382 | 383 | class BotKicked < Unauthorized; end 384 | 385 | class BotBlocked < Unauthorized; end 386 | 387 | class UserDeactivated < Unauthorized; end 388 | 389 | class CantInitiateConversation < Unauthorized; end 390 | 391 | class CantTalkWithBots < Unauthorized; end 392 | 393 | class NetworkError < Error; end 394 | end 395 | end 396 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "e87cb87fc7004499586b38c09fe9342f09d4520cece611bcc135b4403530c786" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.8" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "astunparse": { 20 | "hashes": [ 21 | "sha256:5ad93a8456f0d084c3456d059fd9a92cce667963232cbf763eac3bc5b7940872", 22 | "sha256:c2652417f2c8b5bb325c885ae329bdf3f86424075c4fd1a128674bc6fba4b8e8" 23 | ], 24 | "markers": "python_version < '3.9'", 25 | "version": "==1.6.3" 26 | }, 27 | "cached-property": { 28 | "hashes": [ 29 | "sha256:9fa5755838eecbb2d234c3aa390bd80fbd3ac6b6869109bfc1b499f7bd89a130", 30 | "sha256:df4f613cf7ad9a588cc381aaf4a512d26265ecebd5eb9e1ba12f1319eb85a6a0" 31 | ], 32 | "version": "==1.5.2" 33 | }, 34 | "click": { 35 | "hashes": [ 36 | "sha256:353f466495adaeb40b6b5f592f9f91cb22372351c84caeb068132442a4518ef3", 37 | "sha256:410e932b050f5eed773c4cda94de75971c89cdb3155a72a0831139a79e5ecb5b" 38 | ], 39 | "markers": "python_version >= '3.6'", 40 | "version": "==8.0.3" 41 | }, 42 | "ghp-import": { 43 | "hashes": [ 44 | "sha256:5f8962b30b20652cdffa9c5a9812f7de6bcb56ec475acac579807719bf242c46", 45 | "sha256:947b3771f11be850c852c64b561c600fdddf794bab363060854c1ee7ad05e071" 46 | ], 47 | "version": "==2.0.2" 48 | }, 49 | "importlib-metadata": { 50 | "hashes": [ 51 | "sha256:53ccfd5c134223e497627b9815d5030edf77d2ed573922f7a0b8f8bb81a1c100", 52 | "sha256:75bdec14c397f528724c1bfd9709d660b33a4d2e77387a3358f20b848bb5e5fb" 53 | ], 54 | "markers": "python_version < '3.10'", 55 | "version": "==4.8.2" 56 | }, 57 | "jinja2": { 58 | "hashes": [ 59 | "sha256:077ce6014f7b40d03b47d1f1ca4b0fc8328a692bd284016f806ed0eaca390ad8", 60 | "sha256:611bb273cd68f3b993fabdc4064fc858c5b47a973cb5aa7999ec1ba405c87cd7" 61 | ], 62 | "markers": "python_version >= '3.6'", 63 | "version": "==3.0.3" 64 | }, 65 | "markdown": { 66 | "hashes": [ 67 | "sha256:76df8ae32294ec39dcf89340382882dfa12975f87f45c3ed1ecdb1e8cefc7006", 68 | "sha256:9923332318f843411e9932237530df53162e29dc7a4e2b91e35764583c46c9a3" 69 | ], 70 | "markers": "python_version >= '3.6'", 71 | "version": "==3.3.6" 72 | }, 73 | "markdown-callouts": { 74 | "hashes": [ 75 | "sha256:d76b2ef1327ef75c2b5c9ee6e8dc1781aeace9f334b22b57716042a4051c61b7", 76 | "sha256:ff9da9f713ff784691e9e57ca10acf08d01fe9fea7546a745c831cfd83797f59" 77 | ], 78 | "markers": "python_version >= '3.6' and python_version < '4.0'", 79 | "version": "==0.2.0" 80 | }, 81 | "markupsafe": { 82 | "hashes": [ 83 | "sha256:01a9b8ea66f1658938f65b93a85ebe8bc016e6769611be228d797c9d998dd298", 84 | "sha256:023cb26ec21ece8dc3907c0e8320058b2e0cb3c55cf9564da612bc325bed5e64", 85 | "sha256:0446679737af14f45767963a1a9ef7620189912317d095f2d9ffa183a4d25d2b", 86 | "sha256:04635854b943835a6ea959e948d19dcd311762c5c0c6e1f0e16ee57022669194", 87 | "sha256:0717a7390a68be14b8c793ba258e075c6f4ca819f15edfc2a3a027c823718567", 88 | "sha256:0955295dd5eec6cb6cc2fe1698f4c6d84af2e92de33fbcac4111913cd100a6ff", 89 | "sha256:0d4b31cc67ab36e3392bbf3862cfbadac3db12bdd8b02a2731f509ed5b829724", 90 | "sha256:10f82115e21dc0dfec9ab5c0223652f7197feb168c940f3ef61563fc2d6beb74", 91 | "sha256:168cd0a3642de83558a5153c8bd34f175a9a6e7f6dc6384b9655d2697312a646", 92 | "sha256:1d609f577dc6e1aa17d746f8bd3c31aa4d258f4070d61b2aa5c4166c1539de35", 93 | "sha256:1f2ade76b9903f39aa442b4aadd2177decb66525062db244b35d71d0ee8599b6", 94 | "sha256:20dca64a3ef2d6e4d5d615a3fd418ad3bde77a47ec8a23d984a12b5b4c74491a", 95 | "sha256:2a7d351cbd8cfeb19ca00de495e224dea7e7d919659c2841bbb7f420ad03e2d6", 96 | "sha256:2d7d807855b419fc2ed3e631034685db6079889a1f01d5d9dac950f764da3dad", 97 | "sha256:2ef54abee730b502252bcdf31b10dacb0a416229b72c18b19e24a4509f273d26", 98 | "sha256:36bc903cbb393720fad60fc28c10de6acf10dc6cc883f3e24ee4012371399a38", 99 | "sha256:37205cac2a79194e3750b0af2a5720d95f786a55ce7df90c3af697bfa100eaac", 100 | "sha256:3c112550557578c26af18a1ccc9e090bfe03832ae994343cfdacd287db6a6ae7", 101 | "sha256:3dd007d54ee88b46be476e293f48c85048603f5f516008bee124ddd891398ed6", 102 | "sha256:4296f2b1ce8c86a6aea78613c34bb1a672ea0e3de9c6ba08a960efe0b0a09047", 103 | "sha256:47ab1e7b91c098ab893b828deafa1203de86d0bc6ab587b160f78fe6c4011f75", 104 | "sha256:49e3ceeabbfb9d66c3aef5af3a60cc43b85c33df25ce03d0031a608b0a8b2e3f", 105 | "sha256:4dc8f9fb58f7364b63fd9f85013b780ef83c11857ae79f2feda41e270468dd9b", 106 | "sha256:4efca8f86c54b22348a5467704e3fec767b2db12fc39c6d963168ab1d3fc9135", 107 | "sha256:53edb4da6925ad13c07b6d26c2a852bd81e364f95301c66e930ab2aef5b5ddd8", 108 | "sha256:5855f8438a7d1d458206a2466bf82b0f104a3724bf96a1c781ab731e4201731a", 109 | "sha256:594c67807fb16238b30c44bdf74f36c02cdf22d1c8cda91ef8a0ed8dabf5620a", 110 | "sha256:5b6d930f030f8ed98e3e6c98ffa0652bdb82601e7a016ec2ab5d7ff23baa78d1", 111 | "sha256:5bb28c636d87e840583ee3adeb78172efc47c8b26127267f54a9c0ec251d41a9", 112 | "sha256:60bf42e36abfaf9aff1f50f52644b336d4f0a3fd6d8a60ca0d054ac9f713a864", 113 | "sha256:611d1ad9a4288cf3e3c16014564df047fe08410e628f89805e475368bd304914", 114 | "sha256:6300b8454aa6930a24b9618fbb54b5a68135092bc666f7b06901f897fa5c2fee", 115 | "sha256:63f3268ba69ace99cab4e3e3b5840b03340efed0948ab8f78d2fd87ee5442a4f", 116 | "sha256:6557b31b5e2c9ddf0de32a691f2312a32f77cd7681d8af66c2692efdbef84c18", 117 | "sha256:693ce3f9e70a6cf7d2fb9e6c9d8b204b6b39897a2c4a1aa65728d5ac97dcc1d8", 118 | "sha256:6a7fae0dd14cf60ad5ff42baa2e95727c3d81ded453457771d02b7d2b3f9c0c2", 119 | "sha256:6c4ca60fa24e85fe25b912b01e62cb969d69a23a5d5867682dd3e80b5b02581d", 120 | "sha256:6fcf051089389abe060c9cd7caa212c707e58153afa2c649f00346ce6d260f1b", 121 | "sha256:7d91275b0245b1da4d4cfa07e0faedd5b0812efc15b702576d103293e252af1b", 122 | "sha256:89c687013cb1cd489a0f0ac24febe8c7a666e6e221b783e53ac50ebf68e45d86", 123 | "sha256:8d206346619592c6200148b01a2142798c989edcb9c896f9ac9722a99d4e77e6", 124 | "sha256:905fec760bd2fa1388bb5b489ee8ee5f7291d692638ea5f67982d968366bef9f", 125 | "sha256:97383d78eb34da7e1fa37dd273c20ad4320929af65d156e35a5e2d89566d9dfb", 126 | "sha256:984d76483eb32f1bcb536dc27e4ad56bba4baa70be32fa87152832cdd9db0833", 127 | "sha256:99df47edb6bda1249d3e80fdabb1dab8c08ef3975f69aed437cb69d0a5de1e28", 128 | "sha256:9f02365d4e99430a12647f09b6cc8bab61a6564363f313126f775eb4f6ef798e", 129 | "sha256:a30e67a65b53ea0a5e62fe23682cfe22712e01f453b95233b25502f7c61cb415", 130 | "sha256:ab3ef638ace319fa26553db0624c4699e31a28bb2a835c5faca8f8acf6a5a902", 131 | "sha256:aca6377c0cb8a8253e493c6b451565ac77e98c2951c45f913e0b52facdcff83f", 132 | "sha256:add36cb2dbb8b736611303cd3bfcee00afd96471b09cda130da3581cbdc56a6d", 133 | "sha256:b2f4bf27480f5e5e8ce285a8c8fd176c0b03e93dcc6646477d4630e83440c6a9", 134 | "sha256:b7f2d075102dc8c794cbde1947378051c4e5180d52d276987b8d28a3bd58c17d", 135 | "sha256:baa1a4e8f868845af802979fcdbf0bb11f94f1cb7ced4c4b8a351bb60d108145", 136 | "sha256:be98f628055368795d818ebf93da628541e10b75b41c559fdf36d104c5787066", 137 | "sha256:bf5d821ffabf0ef3533c39c518f3357b171a1651c1ff6827325e4489b0e46c3c", 138 | "sha256:c47adbc92fc1bb2b3274c4b3a43ae0e4573d9fbff4f54cd484555edbf030baf1", 139 | "sha256:cdfba22ea2f0029c9261a4bd07e830a8da012291fbe44dc794e488b6c9bb353a", 140 | "sha256:d6c7ebd4e944c85e2c3421e612a7057a2f48d478d79e61800d81468a8d842207", 141 | "sha256:d7f9850398e85aba693bb640262d3611788b1f29a79f0c93c565694658f4071f", 142 | "sha256:d8446c54dc28c01e5a2dbac5a25f071f6653e6e40f3a8818e8b45d790fe6ef53", 143 | "sha256:deb993cacb280823246a026e3b2d81c493c53de6acfd5e6bfe31ab3402bb37dd", 144 | "sha256:e0f138900af21926a02425cf736db95be9f4af72ba1bb21453432a07f6082134", 145 | "sha256:e9936f0b261d4df76ad22f8fee3ae83b60d7c3e871292cd42f40b81b70afae85", 146 | "sha256:f0567c4dc99f264f49fe27da5f735f414c4e7e7dd850cfd8e69f0862d7c74ea9", 147 | "sha256:f5653a225f31e113b152e56f154ccbe59eeb1c7487b39b9d9f9cdb58e6c79dc5", 148 | "sha256:f826e31d18b516f653fe296d967d700fddad5901ae07c622bb3705955e1faa94", 149 | "sha256:f8ba0e8349a38d3001fae7eadded3f6606f0da5d748ee53cc1dab1d6527b9509", 150 | "sha256:f9081981fe268bd86831e5c75f7de206ef275defcb82bc70740ae6dc507aee51", 151 | "sha256:fa130dd50c57d53368c9d59395cb5526eda596d3ffe36666cd81a44d56e48872" 152 | ], 153 | "markers": "python_version >= '3.6'", 154 | "version": "==2.0.1" 155 | }, 156 | "mergedeep": { 157 | "hashes": [ 158 | "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", 159 | "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307" 160 | ], 161 | "markers": "python_version >= '3.6'", 162 | "version": "==1.3.4" 163 | }, 164 | "mike": { 165 | "hashes": [ 166 | "sha256:4c307c28769834d78df10f834f57f810f04ca27d248f80a75f49c6fa2d1527ca", 167 | "sha256:56c3f1794c2d0b5fdccfa9b9487beb013ca813de2e3ad0744724e9d34d40b77b" 168 | ], 169 | "index": "pypi", 170 | "version": "==1.1.2" 171 | }, 172 | "mkdocs": { 173 | "hashes": [ 174 | "sha256:89f5a094764381cda656af4298727c9f53dc3e602983087e1fe96ea1df24f4c1", 175 | "sha256:a1fa8c2d0c1305d7fc2b9d9f607c71778572a8b110fb26642aa00296c9e6d072" 176 | ], 177 | "markers": "python_version >= '3.6'", 178 | "version": "==1.2.3" 179 | }, 180 | "mkdocs-autorefs": { 181 | "hashes": [ 182 | "sha256:261875003e49b5d708993fd2792a69d624cbc8cf7de49e96c81d3d9825977ca4", 183 | "sha256:2f89556eb2107d72e3aff41b04dcaaf1125d407a33b8027fbc982137d248d37d" 184 | ], 185 | "markers": "python_version >= '3.6' and python_version < '4.0'", 186 | "version": "==0.3.0" 187 | }, 188 | "mkdocs-gen-files": { 189 | "hashes": [ 190 | "sha256:0bfe82ecb62b3d2064349808c898063ad955a77804436e0a54fa029414c893bb", 191 | "sha256:bcfbaa496c5fc8164a9a243963e444c8750c619e18cd54e217549586c9132461" 192 | ], 193 | "index": "pypi", 194 | "version": "==0.3.3" 195 | }, 196 | "mkdocs-literate-nav": { 197 | "hashes": [ 198 | "sha256:2012ac97bc2316890ac35deabd653fd3c6a7434923d572de965c5b6c80f09537", 199 | "sha256:29bf383170b80200d16f0d8528f4925ae96982677c8a98f84af70343a3b38bcf" 200 | ], 201 | "index": "pypi", 202 | "version": "==0.4.0" 203 | }, 204 | "mkdocs-macros-plugin": { 205 | "hashes": [ 206 | "sha256:2a22297d8fb6339e2a5d711e4473cd9ef3d9cda9b95e7b43a0c42c076b3eeec2", 207 | "sha256:c86e96e205c8c30e9bbdde37178cdd9564eb57248257131d185a846f9a94d8bb" 208 | ], 209 | "index": "pypi", 210 | "version": "==0.6.3" 211 | }, 212 | "mkdocs-material": { 213 | "hashes": [ 214 | "sha256:76aaf358846483f8e81eea6277a577e72be4271321f8fdb5728f8c904576ec9d", 215 | "sha256:7ff116fbd2c494f352d88ebd92730e297b5aefe87148d5c904c32907ed8bfe42" 216 | ], 217 | "index": "pypi", 218 | "version": "==8.0.5" 219 | }, 220 | "mkdocs-material-extensions": { 221 | "hashes": [ 222 | "sha256:a82b70e533ce060b2a5d9eb2bc2e1be201cf61f901f93704b4acf6e3d5983a44", 223 | "sha256:bfd24dfdef7b41c312ede42648f9eb83476ea168ec163b613f9abd12bbfddba2" 224 | ], 225 | "markers": "python_version >= '3.6'", 226 | "version": "==1.0.3" 227 | }, 228 | "mkdocs-section-index": { 229 | "hashes": [ 230 | "sha256:6143b2abbeca8941dcdc3dd999539d12936e6677f578159debcd23fa1534ad03", 231 | "sha256:be4662d7a0cdac160ca94af36c430e1d3541efead8a55bf5ec1042bbe1024391" 232 | ], 233 | "index": "pypi", 234 | "version": "==0.3.2" 235 | }, 236 | "mkdocs-versioning": { 237 | "hashes": [ 238 | "sha256:2eb0053ae96ed8d897499165a66ef630dba4b8c8c0817a534e23967664a3fd63", 239 | "sha256:6da04a2e7483b1800db3ff35bce86ed64b273ce886bcdf857aec46bda1f992d9" 240 | ], 241 | "index": "pypi", 242 | "version": "==0.4.0" 243 | }, 244 | "mkdocstrings": { 245 | "hashes": [ 246 | "sha256:3d8a86c283dfa21818d5b9579aa4e750eea6b5c127b43ad8b00cebbfb7f9634e", 247 | "sha256:671fba8a6c7a8455562aae0a3fa85979fbcef261daec5b2bac4dd1479acc14df" 248 | ], 249 | "index": "pypi", 250 | "version": "==0.16.2" 251 | }, 252 | "mkdocstrings-crystal": { 253 | "hashes": [ 254 | "sha256:7746850e3cf8d9302c1ca277dda93c12609a33b9af96c77428bb2bdd6783f769", 255 | "sha256:ddca5e827a0d88c69cb61a9d3af2785e83af8953e12da2ed0261a77ee9f6b2a5" 256 | ], 257 | "index": "pypi", 258 | "version": "==0.3.4" 259 | }, 260 | "packaging": { 261 | "hashes": [ 262 | "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb", 263 | "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522" 264 | ], 265 | "markers": "python_version >= '3.6'", 266 | "version": "==21.3" 267 | }, 268 | "pygments": { 269 | "hashes": [ 270 | "sha256:b8e67fe6af78f492b3c4b3e2970c0624cbf08beb1e493b2c99b9fa1b67a20380", 271 | "sha256:f398865f7eb6874156579fdf36bc840a03cab64d1cde9e93d68f46a425ec52c6" 272 | ], 273 | "markers": "python_version >= '3.5'", 274 | "version": "==2.10.0" 275 | }, 276 | "pymdown-extensions": { 277 | "hashes": [ 278 | "sha256:74247f2c80f1d9e3c7242abe1c16317da36c6f26c7ad4b8a7f457f0ec20f0365", 279 | "sha256:b03e66f91f33af4a6e7a0e20c740313522995f69a03d86316b1449766c473d0e" 280 | ], 281 | "markers": "python_version >= '3.6'", 282 | "version": "==9.1" 283 | }, 284 | "pyparsing": { 285 | "hashes": [ 286 | "sha256:04ff808a5b90911829c55c4e26f75fa5ca8a2f5f36aa3a51f68e27033341d3e4", 287 | "sha256:d9bdec0013ef1eb5a84ab39a3b3868911598afa494f5faa038647101504e2b81" 288 | ], 289 | "markers": "python_version >= '3.6'", 290 | "version": "==3.0.6" 291 | }, 292 | "python-dateutil": { 293 | "hashes": [ 294 | "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86", 295 | "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9" 296 | ], 297 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", 298 | "version": "==2.8.2" 299 | }, 300 | "pytkdocs": { 301 | "hashes": [ 302 | "sha256:12cb4180d5eafc7819dba91142948aa7b85ad0a3ad0e956db1cdc6d6c5d0ef56", 303 | "sha256:746905493ff79482ebc90816b8c397c096727a1da8214a0ccff662a8412e91b3" 304 | ], 305 | "markers": "python_full_version >= '3.6.1'", 306 | "version": "==0.12.0" 307 | }, 308 | "pyyaml": { 309 | "hashes": [ 310 | "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293", 311 | "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b", 312 | "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57", 313 | "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b", 314 | "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4", 315 | "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07", 316 | "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba", 317 | "sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9", 318 | "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287", 319 | "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513", 320 | "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0", 321 | "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0", 322 | "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92", 323 | "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f", 324 | "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2", 325 | "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc", 326 | "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c", 327 | "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86", 328 | "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4", 329 | "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c", 330 | "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34", 331 | "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b", 332 | "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c", 333 | "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb", 334 | "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737", 335 | "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3", 336 | "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d", 337 | "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53", 338 | "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78", 339 | "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803", 340 | "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a", 341 | "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174", 342 | "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5" 343 | ], 344 | "markers": "python_version >= '3.6'", 345 | "version": "==6.0" 346 | }, 347 | "pyyaml-env-tag": { 348 | "hashes": [ 349 | "sha256:70092675bda14fdec33b31ba77e7543de9ddc88f2e5b99160396572d11525bdb", 350 | "sha256:af31106dec8a4d68c60207c1886031cbf839b68aa7abccdb19868200532c2069" 351 | ], 352 | "markers": "python_version >= '3.6'", 353 | "version": "==0.1" 354 | }, 355 | "six": { 356 | "hashes": [ 357 | "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", 358 | "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" 359 | ], 360 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", 361 | "version": "==1.16.0" 362 | }, 363 | "termcolor": { 364 | "hashes": [ 365 | "sha256:1d6d69ce66211143803fbc56652b41d73b4a400a2891d7bf7a1cdf4c02de613b" 366 | ], 367 | "version": "==1.1.0" 368 | }, 369 | "verspec": { 370 | "hashes": [ 371 | "sha256:741877d5633cc9464c45a469ae2a31e801e6dbbaa85b9675d481cda100f11c31", 372 | "sha256:c4504ca697b2056cdb4bfa7121461f5a0e81809255b41c03dda4ba823637c01e" 373 | ], 374 | "version": "==0.1.0" 375 | }, 376 | "watchdog": { 377 | "hashes": [ 378 | "sha256:25fb5240b195d17de949588628fdf93032ebf163524ef08933db0ea1f99bd685", 379 | "sha256:3386b367e950a11b0568062b70cc026c6f645428a698d33d39e013aaeda4cc04", 380 | "sha256:3becdb380d8916c873ad512f1701f8a92ce79ec6978ffde92919fd18d41da7fb", 381 | "sha256:4ae38bf8ba6f39d5b83f78661273216e7db5b00f08be7592062cb1fc8b8ba542", 382 | "sha256:8047da932432aa32c515ec1447ea79ce578d0559362ca3605f8e9568f844e3c6", 383 | "sha256:8f1c00aa35f504197561060ca4c21d3cc079ba29cf6dd2fe61024c70160c990b", 384 | "sha256:922a69fa533cb0c793b483becaaa0845f655151e7256ec73630a1b2e9ebcb660", 385 | "sha256:9693f35162dc6208d10b10ddf0458cc09ad70c30ba689d9206e02cd836ce28a3", 386 | "sha256:a0f1c7edf116a12f7245be06120b1852275f9506a7d90227648b250755a03923", 387 | "sha256:a36e75df6c767cbf46f61a91c70b3ba71811dfa0aca4a324d9407a06a8b7a2e7", 388 | "sha256:aba5c812f8ee8a3ff3be51887ca2d55fb8e268439ed44110d3846e4229eb0e8b", 389 | "sha256:ad6f1796e37db2223d2a3f302f586f74c72c630b48a9872c1e7ae8e92e0ab669", 390 | "sha256:ae67501c95606072aafa865b6ed47343ac6484472a2f95490ba151f6347acfc2", 391 | "sha256:b2fcf9402fde2672545b139694284dc3b665fd1be660d73eca6805197ef776a3", 392 | "sha256:b52b88021b9541a60531142b0a451baca08d28b74a723d0c99b13c8c8d48d604", 393 | "sha256:b7d336912853d7b77f9b2c24eeed6a5065d0a0cc0d3b6a5a45ad6d1d05fb8cd8", 394 | "sha256:bd9ba4f332cf57b2c1f698be0728c020399ef3040577cde2939f2e045b39c1e5", 395 | "sha256:be9be735f827820a06340dff2ddea1fb7234561fa5e6300a62fe7f54d40546a0", 396 | "sha256:cca7741c0fcc765568350cb139e92b7f9f3c9a08c4f32591d18ab0a6ac9e71b6", 397 | "sha256:d0d19fb2441947b58fbf91336638c2b9f4cc98e05e1045404d7a4cb7cddc7a65", 398 | "sha256:e02794ac791662a5eafc6ffeaf9bcc149035a0e48eb0a9d40a8feb4622605a3d", 399 | "sha256:e0f30db709c939cabf64a6dc5babb276e6d823fd84464ab916f9b9ba5623ca15", 400 | "sha256:e92c2d33858c8f560671b448205a268096e17870dcf60a9bb3ac7bfbafb7f5f9" 401 | ], 402 | "markers": "python_version >= '3.6'", 403 | "version": "==2.1.6" 404 | }, 405 | "wheel": { 406 | "hashes": [ 407 | "sha256:21014b2bd93c6d0034b6ba5d35e4eb284340e09d63c59aef6fc14b0f346146fd", 408 | "sha256:e2ef7239991699e3355d54f8e968a21bb940a1dbf34a4d226741e64462516fad" 409 | ], 410 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", 411 | "version": "==0.37.0" 412 | }, 413 | "zipp": { 414 | "hashes": [ 415 | "sha256:71c644c5369f4a6e07636f0aa966270449561fcea2e3d6747b8d23efaa9d7832", 416 | "sha256:9fe5ea21568a0a70e50f273397638d39b03353731e6cbbb3fd8502a33fec40bc" 417 | ], 418 | "markers": "python_version >= '3.6'", 419 | "version": "==3.6.0" 420 | } 421 | }, 422 | "develop": {} 423 | } 424 | --------------------------------------------------------------------------------