├── spec ├── spec_helper.cr └── proton_spec.cr ├── src ├── proton │ ├── version.cr │ ├── annotations.cr │ ├── errors.cr │ ├── tl │ │ └── patched │ │ │ ├── formatted_text.cr │ │ │ ├── user.cr │ │ │ ├── chat_member.cr │ │ │ ├── chat.cr │ │ │ └── message.cr │ ├── logger.cr │ ├── parse_mode.cr │ ├── event_handler.cr │ ├── auth_flow.cr │ ├── utils │ │ ├── entity_parser.cr │ │ └── markdown_builder.cr │ ├── tdlib.cr │ ├── auth_flows │ │ └── terminal_auth_flow.cr │ ├── event_handlers │ │ ├── raw_handler.cr │ │ ├── message_handler.cr │ │ └── command_handler.cr │ ├── event.cr │ ├── client │ │ ├── chat_methods.cr │ │ ├── upload_methods.cr │ │ ├── message_iterator.cr │ │ └── message_methods.cr │ ├── client.cr │ └── utils.cr ├── proton.cr └── generator │ ├── source_builder.cr │ ├── tlobjects.cr │ └── data │ └── errors.csv ├── img ├── proton.png └── proton.svg ├── generate_types.sh ├── .editorconfig ├── .gitignore ├── shard.yml ├── LICENSE └── README.md /spec/spec_helper.cr: -------------------------------------------------------------------------------- 1 | require "spec" 2 | require "../src/proton" 3 | -------------------------------------------------------------------------------- /src/proton/version.cr: -------------------------------------------------------------------------------- 1 | module Proton 2 | VERSION = "0.1.0" 3 | end 4 | -------------------------------------------------------------------------------- /img/proton.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/protoncr/proton/HEAD/img/proton.png -------------------------------------------------------------------------------- /generate_types.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | crystal run ./src/generator/tlobjects.cr -------------------------------------------------------------------------------- /src/proton/annotations.cr: -------------------------------------------------------------------------------- 1 | module Proton 2 | annotation On; end 3 | annotation Command; end 4 | annotation OnMessage; end 5 | end 6 | -------------------------------------------------------------------------------- /spec/proton_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | describe Proton do 4 | # TODO: Write tests 5 | 6 | it "works" do 7 | false.should eq(true) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /src/proton/errors.cr: -------------------------------------------------------------------------------- 1 | module Proton 2 | module Errors 3 | class Error < Exception; end 4 | 5 | class DeadClient < Error; end 6 | class TimeoutError < Error; end 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*.cr] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | indent_style = space 8 | indent_size = 2 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /src/proton/tl/patched/formatted_text.cr: -------------------------------------------------------------------------------- 1 | module Proton 2 | module TL 3 | class FormattedText < TLObject 4 | def raw_text(parse_mode : ParseMode = :markdown) 5 | Utils.unparse_text(text.to_s, entities!) 6 | end 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /src/proton/logger.cr: -------------------------------------------------------------------------------- 1 | require "log" 2 | 3 | module Proton 4 | module Logger 5 | macro included 6 | {% begin %} 7 | {% tname = @type.name.stringify.split("::").map(&.underscore).join(".") %} 8 | Log = ::Log.for({{ tname }}) 9 | {% end %} 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /docs/ 2 | /lib/ 3 | /bin/ 4 | /.shards/ 5 | *.dwarf 6 | *.log 7 | 8 | # Libraries don't need dependency lock 9 | # Dependencies will be locked in applications that use them 10 | /shard.lock 11 | 12 | # Ignore generated files 13 | /src/proton/tl/functions.cr 14 | /src/proton/tl/tlobject.cr 15 | /src/proton/tl/types.cr 16 | -------------------------------------------------------------------------------- /src/proton/parse_mode.cr: -------------------------------------------------------------------------------- 1 | module Proton 2 | enum ParseMode 3 | Markdown 4 | MarkdownV2 5 | HTML 6 | 7 | def to_tl 8 | case self 9 | when Markdown 10 | TL::TextParseModeMarkdown.new(1) 11 | when MarkdownV2 12 | TL::TextParseModeMarkdown.new(2) 13 | when HTML 14 | TL::TextParseModeHTML.new 15 | else 16 | raise "Unreachable" 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /shard.yml: -------------------------------------------------------------------------------- 1 | name: proton 2 | version: 0.1.0 3 | 4 | authors: 5 | - Chris Watson 6 | 7 | dependencies: 8 | tl_parser: 9 | github: protoncr/tl_parser 10 | branch: master 11 | magic: 12 | github: dscottboggs/magic.cr 13 | branch: master 14 | markd: 15 | github: icyleaf/markd 16 | 17 | scripts: 18 | postinstall: ./generate_types.sh 19 | 20 | libraries: 21 | tdlib: master 22 | 23 | crystal: 0.31.1 24 | 25 | license: MIT 26 | -------------------------------------------------------------------------------- /src/proton/event_handler.cr: -------------------------------------------------------------------------------- 1 | module Proton 2 | abstract class EventHandler 3 | abstract def call(update : TL::Update) 4 | 5 | # :nodoc: 6 | module Annotator 7 | private def register_event_handler_annotations 8 | {% begin %} 9 | {% for subclass in Proton::EventHandler.subclasses %} 10 | {{ subclass.id }}.annotate(self) 11 | {% end %} 12 | {% end %} 13 | end 14 | end 15 | end 16 | end 17 | 18 | require "./event_handlers/*" 19 | -------------------------------------------------------------------------------- /src/proton/tl/patched/user.cr: -------------------------------------------------------------------------------- 1 | module Proton 2 | module TL 3 | class User 4 | def deleted? 5 | type.is_a?(TL::UserTypeDeleted) 6 | end 7 | 8 | def bot? 9 | type.is_a?(TL::UserTypeBot) 10 | end 11 | 12 | def display_name 13 | "#{first_name} #{last_name}".strip 14 | end 15 | 16 | def inline_mention(markdown_version = 1) 17 | name = display_name 18 | name = name.strip.empty? ? id.to_s : name.gsub(/\[|\]/, '|') 19 | "[#{name}](tg://user?id=#{id})" 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /src/proton/auth_flow.cr: -------------------------------------------------------------------------------- 1 | module Proton 2 | abstract class AuthFlow 3 | # :nodoc: 4 | property! client : Proton::Client 5 | 6 | @encryption_key : String? 7 | 8 | def initialize(@encryption_key = nil, @allow_flash_call = false, @current_phone_number = true, @force_sms = false) 9 | end 10 | 11 | abstract def request_encryption_key 12 | 13 | abstract def request_phone_number 14 | 15 | abstract def request_code 16 | 17 | abstract def request_password 18 | 19 | abstract def request_registration 20 | end 21 | end 22 | 23 | require "./auth_flows/*" 24 | -------------------------------------------------------------------------------- /src/proton/tl/patched/chat_member.cr: -------------------------------------------------------------------------------- 1 | module Proton 2 | module TL 3 | class ChatMember 4 | def administrator? 5 | status.is_a?(TL::ChatMemberStatusAdministrator) 6 | end 7 | 8 | def creator? 9 | status.is_a?(TL::ChatMemberStatusCreator) 10 | end 11 | 12 | def member? 13 | status.is_a?(TL::ChatMemberStatusMember) 14 | end 15 | 16 | def restricted? 17 | status.is_a?(TL::ChatMemberStatusRestricted) 18 | end 19 | 20 | def left? 21 | status.is_a?(TL::ChatMemberStatusLeft) 22 | end 23 | 24 | def banned? 25 | status.is_a?(TL::ChatMemberStatusBanned) 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /src/proton/utils/entity_parser.cr: -------------------------------------------------------------------------------- 1 | module Proton 2 | module Utils 3 | class EntityParser 4 | def initialize(@parse_mode : ParseMode) 5 | end 6 | 7 | def parse(text) 8 | case @parse_mode 9 | in ParseMode::Markdown 10 | Markdown.new(1).parse(text) 11 | in ParseMode::MarkdownV2 12 | Markdown.new(2).parse(text) 13 | in ParseMode::HTML 14 | raise "HTML not yet supported" 15 | end 16 | end 17 | 18 | class Markdown 19 | def initialize(@version : Int32) 20 | end 21 | 22 | def parse(text) 23 | utf16 = text.to_utf16 24 | 25 | end 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /src/proton/tdlib.cr: -------------------------------------------------------------------------------- 1 | module Proton 2 | {% if flag?(:static) %} 3 | @[Link(ldflags: "-ltdjson_static -ltdjson_private -ltdclient -ltdcore -ltdactor -ltddb -ltdsqlite -ltdnet -ltdutils -lstdc++ -lssl -lcrypto -ldl -lz -lm")] 4 | {% else %} 5 | @[Link(ldflags: "-ltdjson -lstdc++ -lssl -lcrypto -ldl -lz -lm")] 6 | {% end %} 7 | lib TDLib 8 | alias Client = Void* 9 | fun client_create = td_json_client_create() : Client 10 | fun client_send = td_json_client_send(client : Client, request : LibC::Char*) 11 | fun client_receive = td_json_client_receive(client : Client, timeout : LibC::Double) : LibC::Char* 12 | fun client_execute = td_json_client_execute(client : Client, request : LibC::Char*) : LibC::Char* 13 | fun client_destroy = td_json_client_destroy(client : Client) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /src/proton/tl/patched/chat.cr: -------------------------------------------------------------------------------- 1 | module Proton 2 | module TL 3 | class Chat < TLObject 4 | def display_name 5 | title.to_s 6 | end 7 | 8 | def private? 9 | type.is_a?(TL::ChatTypePrivate) 10 | end 11 | 12 | def group? 13 | type.is_a?(TL::ChatTypeBasicGroup) 14 | end 15 | 16 | def supergroup? 17 | type.is_a?(TL::ChatTypeSupergroup) 18 | end 19 | 20 | def secret? 21 | type.is_a?(TL::ChatTypeSecret) 22 | end 23 | 24 | def supergroup_id 25 | if supergroup? 26 | type!.as(TL::ChatTypeSupergroup).supergroup_id! 27 | end 28 | end 29 | 30 | def supergroup 31 | if sgid = supergroup_id 32 | TL.get_supergroup(sgid) 33 | end 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /src/proton.cr: -------------------------------------------------------------------------------- 1 | require "json" 2 | 3 | require "./proton/version" 4 | require "./proton/tdlib" 5 | require "./proton/logger" 6 | require "./proton/errors" 7 | require "./proton/event" 8 | require "./proton/utils" 9 | require "./proton/annotations" 10 | 11 | require "./proton/tl/tlobject" 12 | require "./proton/tl/types" 13 | require "./proton/tl/functions" 14 | require "./proton/tl/patched/*" 15 | 16 | require "./proton/event_handler" 17 | require "./proton/parse_mode" 18 | require "./proton/client" 19 | require "./proton/auth_flow" 20 | 21 | # Proton is a client library for Telegram. It uses [tdlib]() as a backbone, and 22 | # builds on top of it by adding several convenience classes and methods. The 23 | # overarching goal is to have something as friendly as Telethon, with 24 | # the tdlib bindings eventually being replaced by a pure Crystal 25 | # alternative. 26 | module Proton 27 | include Logger 28 | end 29 | 30 | # pp Proton::Utils.parse_entities("Hello *world*") 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 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/proton/auth_flows/terminal_auth_flow.cr: -------------------------------------------------------------------------------- 1 | module Proton 2 | class TerminalAuthFlow < AuthFlow 3 | def request_encryption_key 4 | encryption_key = if @encryption_key 5 | @encryption_key.to_s 6 | else 7 | print "Database encryption key: " 8 | gets.to_s.strip 9 | end 10 | spawn TL.check_database_encryption_key(encryption_key) 11 | end 12 | 13 | def request_phone_number 14 | print "Phone number: " 15 | phone_number = gets.to_s.strip 16 | spawn TL.set_authentication_phone_number(phone_number, { 17 | allow_flash_call: @allow_flash_call, 18 | is_current_phone_number: @current_phone_number, 19 | allow_sms_retriever_api: @force_sms 20 | }) 21 | end 22 | 23 | def request_code 24 | print "Enter code: " 25 | code = gets.to_s.strip 26 | spawn TL.check_authentication_code(code) 27 | end 28 | 29 | def request_password 30 | print "Enter password: " 31 | password = gets.to_s.strip 32 | spawn TL.check_authentication_password(password) 33 | end 34 | 35 | def request_registration 36 | puts "Registration not implemented yet" 37 | exit(1) 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /src/generator/source_builder.cr: -------------------------------------------------------------------------------- 1 | module Proton 2 | module Generator 3 | class SourceBuilder 4 | INDENT_PATTERN = /(^(abstract class|class|module|def|when))|({|\(|do|do \|[\w\d\s,_]+\|)$/ 5 | DEDENT_PATTERN = /(end)$/ 6 | 7 | def initialize(@io : IO, @indent_size = 2) 8 | @current_indent = 0 9 | @last_line_newline = false 10 | end 11 | 12 | def indent(n = 1) 13 | @current_indent += n 14 | end 15 | 16 | def dedent(n = 1) 17 | @current_indent -= n 18 | end 19 | 20 | # Writes a string into the source code, applying indentation if required 21 | def write(string = "") 22 | @io.print(get_indent + string) 23 | @last_line_newline = string.ends_with?("\n") ? true : false 24 | end 25 | 26 | def <<(string) 27 | write(string) 28 | end 29 | 30 | def writeln(string = "") 31 | if string.strip.match(DEDENT_PATTERN) 32 | dedent 33 | end 34 | 35 | write(string + "\n") 36 | 37 | if string.strip.match(INDENT_PATTERN) 38 | indent 39 | end 40 | 41 | @last_line_newline = true 42 | end 43 | 44 | private def get_indent 45 | @last_line_newline ? 46 | " " * (@current_indent * @indent_size).abs : 47 | "" 48 | end 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /src/proton/event_handlers/raw_handler.cr: -------------------------------------------------------------------------------- 1 | module Proton 2 | class RawHandler < EventHandler 3 | def initialize(@event : Event, &block : TL::Update ->) 4 | @proc = block 5 | end 6 | 7 | def call(update : TL::Update) 8 | update_events = Event.from_tl_update(update) 9 | if update_events.includes?(@event) 10 | spawn @proc.call(update) 11 | end 12 | end 13 | 14 | # :nodoc: 15 | def self.annotate(client) 16 | {% for command_class in Proton::Client.subclasses %} 17 | {% for method in command_class.methods %} 18 | 19 | # Handle `On` annotation 20 | {% for ann in method.annotations(On) %} 21 | %events = {{ ann[:event] || ann[:events] || ann[0] }} 22 | 23 | %events = if %events.is_a?(Array) 24 | %events.map do |ev| 25 | ev.is_a?(Event) ? ev : Event.parse(ev.to_s) 26 | end 27 | elsif %events.is_a?(Event) 28 | [%events] 29 | else 30 | [Event.parse(%events.to_s)] 31 | end 32 | 33 | %events.each do |ev| 34 | %handler = RawHandler.new(ev, &->(update : TL::Update) { client.{{ method.name.id }}(update) }) 35 | client.add_event_handler(%handler) 36 | end 37 | {% end %} 38 | {% end %} 39 | {% end %} 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Proton 2 | 3 | [![Chat on Telegram](https://patrolavia.github.io/telegram-badge/chat.png)](https://t.me/protoncr) 4 | 5 | Proton is a client library for Telegram. It uses [tdlib]() as a backbone, and builds on top of it by adding several convenience classes and methods. The overarching goal is to have something as friendly as Telethon, with the tdlib bindings eventually being replaced by a pure Crystal alternative. 6 | 7 | ## Installation 8 | 9 | 1. Clone tdlib from [here](https://github.com/tdlib/td), build it, and make sure it's installed and available in your library path. Typically this should be either `/usr/lib` or `/usr/local/lib`. 10 | 11 | 2. Add the dependency to your `shard.yml`: 12 | 13 | ```yaml 14 | dependencies: 15 | proton: 16 | github: protoncr/proton 17 | branch: master 18 | ``` 19 | 20 | 3. Run `shards install` 21 | 22 | 4. Profit 23 | 24 | ## Usage 25 | 26 | Check the [example](./example) directory for a simple userbot example. 27 | 28 | ### Authenticating 29 | 30 | ```crystal 31 | require "proton" 32 | 33 | class Userbot < Proton::Client 34 | # Stuff 35 | end 36 | 37 | auth_flow = Proton::TerminalAuthFlow.new(encryption_key: "SOME_DB_ENCRYPTION_KEY") 38 | 39 | userbot = Userbot.new( 40 | api_id: 12345, 41 | api_hash: "0123456789abcdef0123456789abcdef", 42 | auth_flow: auth_flow, 43 | verbosity_level: 0 # This is the tdlib verbosity 44 | ) 45 | 46 | Userbot.start 47 | ``` 48 | 49 | ## Contributing 50 | 51 | 1. Fork it () 52 | 2. Create your feature branch (`git checkout -b my-new-feature`) 53 | 3. Commit your changes (`git commit -am 'Add some feature'`) 54 | 4. Push to the branch (`git push origin my-new-feature`) 55 | 5. Create a new Pull Request 56 | 57 | ## Contributors 58 | 59 | - [Chris Watson](https://github.com/watzon) - creator and maintainer 60 | -------------------------------------------------------------------------------- /src/proton/event.cr: -------------------------------------------------------------------------------- 1 | module Proton 2 | enum Event 3 | NewMessage 4 | NewChatMember 5 | MessageEdited 6 | MessageDeleted 7 | MessageRead 8 | MessageSent 9 | MessageSendingFailed 10 | MessageOpened 11 | MessageNewViews 12 | ChatAction 13 | UserUpdate 14 | UserStatusChanged 15 | CallbackQuery 16 | InlineQuery 17 | ChosenInlineResult 18 | Album 19 | Raw 20 | 21 | def self.from_tl_update(update) 22 | actions = [] of Event 23 | 24 | case update 25 | when TL::UpdateNewMessage 26 | actions << NewMessage 27 | case update.message!.content! 28 | when TL::MessageChatAddMembers, TL::MessageChatJoinByLink 29 | actions << NewChatMember 30 | end 31 | when TL::UpdateMessageEdited 32 | actions << MessageEdited 33 | when TL::UpdateMessageSendAcknowledged 34 | actions << MessageRead 35 | when TL::UpdateMessageSendSucceeded 36 | actions << MessageSent 37 | when TL::UpdateMessageSendFailed 38 | actions << MessageSendingFailed 39 | when TL::UpdateMessageViews 40 | actions << MessageNewViews 41 | when TL::UpdateMessageContentOpened 42 | actions << MessageOpened 43 | when TL::UpdateMessageMentionRead 44 | actions << MessageRead 45 | when TL::UpdateUserChatAction 46 | actions << ChatAction 47 | when TL::UpdateUser 48 | actions << UserUpdate 49 | when TL::UpdateUserStatus 50 | actions << UserStatusChanged 51 | when TL::UpdateNewInlineQuery 52 | actions << InlineQuery 53 | when TL::UpdateNewCallbackQuery 54 | actions << CallbackQuery 55 | when TL::UpdateNewChosenInlineResult 56 | actions << ChosenInlineResult 57 | end 58 | 59 | actions << Raw 60 | actions 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /src/proton/tl/patched/message.cr: -------------------------------------------------------------------------------- 1 | module Proton 2 | module TL 3 | class Message < TLObject 4 | def server_id 5 | divisor = self.id! / 1048576 6 | if (divisor * (2^20)) % 1 == 0 7 | divisor.to_i64 8 | else 9 | self.id! 10 | end 11 | end 12 | 13 | def link 14 | chat = TL.get_chat(chat_id!) 15 | if (sg = chat.supergroup) 16 | if (sg.username!.empty?) 17 | "https://t.me/c/#{sg.id!}/#{server_id}" 18 | else 19 | "https://t.me/#{sg.username!}/#{server_id}" 20 | end 21 | else 22 | "https://t.me/c/#{chat.id!}/#{server_id}" 23 | end 24 | end 25 | 26 | def reply? 27 | reply_to_message_id! > 0 28 | end 29 | 30 | def forwarded? 31 | !!self.forward_info 32 | end 33 | 34 | def text(caption = true) 35 | content = self.content! 36 | case content 37 | when TL::MessageText 38 | content.text!.text! 39 | when .responds_to?(:caption) 40 | if caption 41 | content.caption!.text! 42 | end 43 | else 44 | end 45 | end 46 | 47 | def raw_text(caption = true) 48 | if text = self.text(caption) 49 | Utils.unparse_text(text, entities) 50 | end 51 | end 52 | 53 | def entities(caption = true) 54 | content = self.content! 55 | case content 56 | when TL::MessageText 57 | content.text!.entities! 58 | when .responds_to?(:caption) 59 | if caption 60 | content.caption!.entities! 61 | else 62 | [] of TL::TextEntity 63 | end 64 | else 65 | [] of TL::TextEntity 66 | end 67 | end 68 | 69 | def text_entities(caption = true) 70 | if text = self.text(caption) 71 | entities(caption).flatten.reduce({} of TL::TextEntity => String) do |acc, ent| 72 | acc[ent] = text[ent.offset!, ent.length!] 73 | acc 74 | end 75 | else 76 | {} of TL::TextEntity => String 77 | end 78 | end 79 | 80 | def reply_message 81 | if reply_to_message_id! > 0 82 | TL.get_message(chat_id!, reply_to_message_id!) 83 | end 84 | end 85 | 86 | def from_user 87 | if sender_user_id! > 0 88 | TL.get_user(sender_user_id!) 89 | end 90 | end 91 | end 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /img/proton.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 30 | 31 | -------------------------------------------------------------------------------- /src/proton/event_handlers/message_handler.cr: -------------------------------------------------------------------------------- 1 | module Proton 2 | class MessageHandler < EventHandler 3 | property edited : Bool 4 | 5 | property pattern : Regex? 6 | 7 | property outgoing : Bool 8 | 9 | property incoming : Bool 10 | 11 | property no_caption : Bool 12 | 13 | def initialize(pattern : (Regex | String)? = nil, @outgoing = true, @incoming = true, @edited = false, @no_caption = false, &block : Context ->) 14 | @proc = block 15 | if pat = pattern 16 | pat = Regex.escape(pat) unless pat.is_a?(Regex) 17 | @pattern = /#{pat}/ 18 | end 19 | end 20 | 21 | def call(update : TL::Update) 22 | update_events = Event.from_tl_update(update) 23 | if update_events.includes?(Event::NewMessage) 24 | was_edited = false 25 | message = update.as(TL::UpdateNewMessage).message! 26 | elsif update_events.includes?(Event::MessageEdited) && @edited 27 | was_edited = true 28 | edited_message = update.as(TL::UpdateMessageEdited) 29 | message = TL.get_message_locally(edited_message.chat_id!, edited_message.message_id!) 30 | else 31 | return 32 | end 33 | 34 | return if message.is_outgoing && !@outgoing 35 | return if !message.is_outgoing && !@incoming 36 | 37 | if pattern = @pattern 38 | if text = message.text(!@no_caption) 39 | match = text.match(pattern) 40 | end 41 | return unless match 42 | end 43 | 44 | context = Context.new(message, message.text(!@no_caption), message.raw_text(!@no_caption), match, message.entities, was_edited) 45 | @proc.call(context) 46 | end 47 | 48 | # :nodoc: 49 | def self.annotate(client) 50 | {% for command_class in Proton::Client.subclasses %} 51 | {% for method in command_class.methods %} 52 | 53 | # Handle `On` annotation 54 | {% for ann in method.annotations(OnMessage) %} 55 | %pattern = {{ ann[:pattern] || ann[0] }} 56 | %outgoing = {{ ann[:outgoing] }}.nil? ? true : !!{{ ann[:outgoing] }} 57 | %incoming = {{ ann[:incoming] }}.nil? ? true : !!{{ ann[:incoming] }} 58 | %edited = {{ !!ann[:edited] }} 59 | %no_caption = {{ !!ann[:no_caption] }} 60 | 61 | %handler = MessageHandler.new(%pattern, %outgoing, %incoming, %edited, %no_caption) { |ctx| client.{{ method.name.id }}(ctx); nil } 62 | client.add_event_handler(%handler) 63 | {% end %} 64 | {% end %} 65 | {% end %} 66 | end 67 | 68 | record Context, message : TL::Message, text : String?, raw_text : String?, match : Regex::MatchData?, entities : Array(TL::TextEntity), edited : Bool 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /src/proton/client/chat_methods.cr: -------------------------------------------------------------------------------- 1 | module Proton 2 | module ChatMethods 3 | Log = ::Log.for("proton.chat_methods") 4 | 5 | def get_chat_administrators(chat : TL::Chat | Int64) 6 | chat_id = chat.is_a?(TL::Chat) ? chat.id! : chat 7 | admins = TL.get_chat_administrators(chat_id).administrators! 8 | admins.map do |admin| 9 | TL.get_user(admin.user_id!) 10 | end 11 | end 12 | 13 | # Ban a chat member with an optional `until_date`. If the date is less than 14 | # 30 seconds or more than 365 days from the current time they will be 15 | # considered permabanned. 16 | def ban_chat_member(chat : TL::Chat | Int64, user : TL::User | Int32, until_date = 0) 17 | chat_id = chat.is_a?(TL::Chat) ? chat.id! : chat 18 | user_id = user.is_a?(TL::User) ? user.id! : user 19 | 20 | until_date = 21 | case until_date 22 | when Time 23 | until_date.to_unix 24 | when Time::Span 25 | Time.utc + until_date.total_seconds 26 | when Int 27 | until_date 28 | end 29 | 30 | banned_status = TL::ChatMemberStatusBanned.new(until_date) 31 | TL.set_chat_member_status(chat_id, user_id, banned_status) 32 | end 33 | 34 | # Kick a member from the chat by quickly banning and unbanning them. 35 | def kick_chat_member(chat : TL::Chat | Int64, user : TL::User | Int32) 36 | chat_id = chat.is_a?(TL::Chat) ? chat.id! : chat 37 | user_id = user.is_a?(TL::User) ? user.id! : user 38 | left_status = TL::ChatMemberStatusLeft.new 39 | TL.set_chat_member_status(chat_id, user_id, left_status) 40 | end 41 | 42 | def kick_chat_member(chat_member : TL::ChatMember) 43 | left_status = TL::ChatMemberStatusLeft.new 44 | TL.set_chat_member_status(chat_member.chat_id!, chat_member.user_id!, left_status) 45 | end 46 | 47 | # Unban a banned chat member by setting their status to "left". 48 | # 49 | # If check is false this method will assume that you already 50 | # know for sure that the chat member is banned. If they're 51 | # not banned this method will kick them, so it's advisable 52 | # to leave it as true. 53 | def unban_chat_member(chat : TL::Chat | Int64, user : TL::User | Int32, check = true) 54 | chat_id = chat.is_a?(TL::Chat) ? chat.id! : chat 55 | user_id = user.is_a?(TL::User) ? user.id! : user 56 | 57 | if check 58 | chat_member = TL.get_chat_member(chat_id, user_id) 59 | unban_chat_member(chat_member) 60 | else 61 | kick_chat_member(chat_id, user_id) 62 | end 63 | end 64 | 65 | # :ditto: 66 | def unban_chat_member(chat_member : TL::ChatMember) 67 | return unless chat_member.banned? 68 | kick_chat_member(chat_member.chat_id!, chat_member.user_id!) 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /src/proton/client/upload_methods.cr: -------------------------------------------------------------------------------- 1 | module Proton 2 | module UploadMethods 3 | def upload_file(file, 4 | type = nil, 5 | priority = 1, 6 | video_note = false, 7 | voice_note = false, 8 | force_document = false) 9 | if file.is_a?(TL::File) 10 | return file 11 | end 12 | 13 | # First we need to check the input file type 14 | input_file = 15 | case file 16 | when Int 17 | TL::InputFileId.new(file.to_i) 18 | when String 19 | if file.match(/https?:\/\//) 20 | # Looks like a URL 21 | ext = File.extname(file) 22 | localfile = File.tempfile(suffix: ext) do |f| 23 | response = HTTP::Client.get(file) 24 | f << response.body 25 | end 26 | TL::InputFileLocal.new(File.real_path(localfile.path)) 27 | elsif file.includes?("/") || file.match(/\.[a-z0-9_\-]$/) 28 | # Looks like a local path 29 | TL::InputFileLocal.new(file) 30 | else 31 | # Probably a remote file ID 32 | TL::InputFileRemote.new(file) 33 | end 34 | when File 35 | raise "A file with an empty path cannot be sent" if file.path.empty? 36 | TL::InputFileLocal.new(file.real_path) 37 | when IO 38 | newfile = File.tempfile do |tmp| 39 | tmp << file 40 | end 41 | TL::InputFileLocal.new(File.real_path(newfile.path)) 42 | when TL::InputFile 43 | file 44 | else 45 | raise "Unknown input file type #{typeof(file)}" 46 | end 47 | 48 | if !type 49 | type = Utils.guess_mime_type(file) 50 | end 51 | 52 | # Then we need to check the type of the file 53 | file_type = 54 | if force_document 55 | TL::FileTypeDocument.new 56 | elsif video_note 57 | TL::FileTypeVideoNote.new 58 | elsif voice_note 59 | TL::FileTypeVoiceNote.new 60 | else 61 | case type 62 | when TL::FileType 63 | type 64 | else 65 | case type.to_s 66 | when /image|photo/ 67 | TL::FileTypePhoto.new 68 | when /animation|gif/ 69 | TL::FileTypeAnimation.new 70 | when /audio/ 71 | TL::FileTypeAudio.new 72 | when /sticker|webp/ 73 | TL::FileTypeSticker.new 74 | when /video/ 75 | TL::FileTypeVideo.new 76 | else 77 | TL::FileTypeDocument.new 78 | end 79 | end 80 | end 81 | 82 | # Priority has to be between 1 and 32 83 | priority = priority.clamp(1, 32) 84 | 85 | # Now to upload it 86 | uploaded = TL.upload_file(input_file, file_type, priority) 87 | 88 | # Return a tuple of {file, input_file, file_type} 89 | {uploaded, input_file, file_type} 90 | end 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /src/proton/event_handlers/command_handler.cr: -------------------------------------------------------------------------------- 1 | module Proton 2 | class CommandHandler < EventHandler 3 | property command : Regex 4 | property outgoing : Bool 5 | property incoming : Bool 6 | property edited : Bool 7 | property no_caption : Bool 8 | property pattern : Regex? 9 | 10 | def initialize(command : String | Regex, 11 | @outgoing = true, 12 | @incoming = false, 13 | @edited = false, 14 | @no_caption = false, 15 | @pattern = nil, 16 | &block : Context ->) 17 | command = Regex.escape(command) unless command.is_a?(Regex) 18 | @command = /^#{command}(?:\s|$)/ 19 | @proc = block 20 | end 21 | 22 | def call(update : TL::Update) 23 | update_events = Event.from_tl_update(update) 24 | if update_events.includes?(Event::NewMessage) 25 | was_edited = false 26 | message = update.as(TL::UpdateNewMessage).message! 27 | elsif update_events.includes?(Event::MessageEdited) && @edited 28 | was_edited = true 29 | edited_message = update.as(TL::UpdateMessageEdited) 30 | message = TL.get_message(edited_message.chat_id!, edited_message.message_id!) 31 | else 32 | return 33 | end 34 | 35 | return if message.is_outgoing && !@outgoing 36 | return if !message.is_outgoing && !@incoming 37 | 38 | text = message.text 39 | raw_text = message.raw_text 40 | 41 | return unless text && raw_text 42 | return unless raw_text.match(command) 43 | 44 | text = text.sub(/#{@command}\s*/, "") 45 | 46 | if pattern = @pattern 47 | match = text.match(pattern) 48 | return unless match 49 | end 50 | 51 | context = Context.new(message, text, raw_text, match, message.entities, was_edited) 52 | @proc.call(context) 53 | end 54 | 55 | # :nodoc: 56 | def self.annotate(client) 57 | {% begin %} 58 | {% for command_class in Proton::Client.subclasses %} 59 | {% for method in command_class.methods %} 60 | 61 | # Handle `Command` annotation 62 | {% for ann in method.annotations(Command) %} 63 | %commands = {{ ann[:commands] || ann[:commands] || ann[0] }} 64 | %commands = %commands.is_a?(Array) ? %commands : [%commands] 65 | 66 | %outgoing = {{ ann[:outgoing] }}.nil? ? true : !!{{ ann[:outgoing] }} 67 | %incoming = {{ !!ann[:incoming] }} 68 | %edited = {{ !!ann[:edited] }} 69 | %no_caption = {{ !!ann[:no_caption] }} 70 | %pattern = {{ ann[:pattern] }} 71 | 72 | %commands.each do |cmd| 73 | %handler = CommandHandler.new(cmd, %outgoing, %incoming, %edited, %no_caption, %pattern) { |ctx| client.{{ method.name.id }}(ctx); nil } 74 | client.add_event_handler(%handler) 75 | end 76 | {% end %} 77 | {% end %} 78 | {% end %} 79 | {% end %} 80 | end 81 | 82 | record Context, message : TL::Message, text : String, raw_text : String, match : Regex::MatchData?, entities : Array(TL::TextEntity), edited : Bool 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /src/proton/client/message_iterator.cr: -------------------------------------------------------------------------------- 1 | # module Proto 2 | # class MessageIterator 3 | # include Iterable(TL::Message) 4 | # include Enumearable(TL::Message) 5 | 6 | # Log = ::Log.for("proton.message_iterator") 7 | 8 | # @entity : TL::Chat? 9 | # @from_user : TL::User? 10 | # @query : String? 11 | # @offset_id : Int64? 12 | # @limit : Int::Primitive? 13 | # @filter : Filter? 14 | 15 | # @buffer : Array(TL::Message) 16 | # @total : Int32 17 | 18 | # def initialize(entity = nil, 19 | # from_user = nil, 20 | # query = nil, 21 | # from_message = nil, 22 | # offset = nil, 23 | # limit = nil, 24 | # filter : Filter? = nil) 25 | # # `entity` being nil will perform a global search 26 | # @entity = Helpers.parse_chat(entity) if entity 27 | # @from_user = Helpers.parse_user(from_user) if from_user 28 | # @offset_id = from_message.is_a?(TL::Message) ? from_message.id : (from_message || 0).to_i64 29 | # @query = query 30 | # @filter = filter 31 | 32 | # @buffer = [] of TL::Message 33 | # @total = 0 34 | 35 | # @offset = offset || 0 36 | # if @offset > 0 37 | # raise Errors::Error.new("offset must be negative or zero") 38 | # end 39 | 40 | # @limit = limit 41 | # if @offset < 0 && @limit <= @offset 42 | # raise Errors::Error.new("limit must be greater than offset when offset is negative") 43 | # end 44 | # end 45 | 46 | # def next 47 | # # Stop once the limit is hit 48 | # if (limit = @limit) && (@total >= limit) 49 | # return 50 | # end 51 | 52 | # # Figure out how many messages to fetch 53 | # if limit = @limit 54 | # remaining = @total - limit 55 | # if remaining > 100 56 | # count = 100 57 | # else 58 | # count = remaining 59 | # end 60 | # else 61 | # count = 100 62 | # end 63 | 64 | # if entity = @entity 65 | # # Localized search 66 | # messages = TL.search_chat_messages(entity.id, ) 67 | # else 68 | # # Global search 69 | # messages = TL.search_messages() 70 | # end 71 | # end 72 | 73 | # def each(&block : TL::Message ->) 74 | 75 | # end 76 | 77 | # enum Filter 78 | # Empty 79 | # Animation 80 | # Audio 81 | # Document 82 | # Photo 83 | # Video 84 | # VoiceNote 85 | # PhotoOrVideo 86 | # Url 87 | # ChatPhoto 88 | # Call 89 | # MissedCall 90 | # VideoNote 91 | # VideoOrVoiceNote 92 | # Mention 93 | # UnreadMention 94 | 95 | # def self.to_tl 96 | # case self 97 | # when Empty 98 | # TL::SearchMessagesFilterEmpty 99 | # when Animation 100 | # TL::SearchMessagesFilterAnimation 101 | # when Audio 102 | # TL::SearchMessagesFilterAudio 103 | # when Document 104 | # TL::SearchMessagesFilterDocument 105 | # when Photo 106 | # TL::SearchMessagesFilterPhoto 107 | # when Video 108 | # TL::SearchMessagesFilterVideo 109 | # when VoiceNote 110 | # TL::SearchMessagesFilterVoiceNote 111 | # when PhotoOrVideo 112 | # TL::SearchMessagesFilterPhotoAndVideo 113 | # when Url 114 | # TL::SearchMessagesFilterUrl 115 | # when ChatPhoto 116 | # TL::SearchMessagesFilterChatPhoto 117 | # when Call 118 | # TL::SearchMessagesFilterCall 119 | # when MissedCall 120 | # TL::SearchMessagesFilterMissedCall 121 | # when VideoNote 122 | # TL::SearchMessagesFilterVideoNote 123 | # when VideoOrVoiceNote 124 | # TL::SearchMessagesFilterVideoAndVoiceNote 125 | # when Mention 126 | # TL::SearchMessagesFilterMention 127 | # when UnreadMention 128 | # TL::SearchMessagesFilterUnreadMention 129 | # else 130 | # raise "unreachable" 131 | # end 132 | # end 133 | # end 134 | # end 135 | # end 136 | -------------------------------------------------------------------------------- /src/proton/utils/markdown_builder.cr: -------------------------------------------------------------------------------- 1 | module Proton 2 | module Utils 3 | class MarkdownBuilder 4 | @nested : Bool 5 | 6 | getter items : Array(String | Component | Section) 7 | 8 | def initialize(@items = [] of String | Component | Section) 9 | @nested = false 10 | end 11 | 12 | def self.new(*args) 13 | new(*args.to_a) 14 | end 15 | 16 | def self.build(**options, &block) 17 | builder = new(**options) 18 | with builder yield builder 19 | builder 20 | end 21 | 22 | def nested(&block : self ->) 23 | @nested = true 24 | with self yield self 25 | @nested = false 26 | end 27 | 28 | def add_item(item) 29 | @items << item unless @nested 30 | item 31 | end 32 | 33 | def text(txt) 34 | add_item txt.to_s 35 | end 36 | 37 | def bold(txt) 38 | add_item Bold.new(txt) 39 | end 40 | 41 | def italic(txt) 42 | add_item Italic.new(txt) 43 | end 44 | 45 | def code(txt) 46 | add_item Code.new(txt) 47 | end 48 | 49 | def pre(txt, language = nil) 50 | add_item Pre.new(txt, language) 51 | end 52 | 53 | def link(txt, url) 54 | add_item Link.new(txt, url) 55 | end 56 | 57 | def mention(txt, user) 58 | add_item Mention.new(txt, user) 59 | end 60 | 61 | def key_value_item(key, value) 62 | @items.delete key 63 | @items.delete value 64 | add_item KeyValueItem.new(key, value) 65 | end 66 | 67 | def section(items, indent = 4) 68 | add_item Section.new(items, indent) 69 | end 70 | 71 | def section(*items, indent = 4) 72 | section(items.to_a, indent) 73 | end 74 | 75 | def section(indent = 4, &block) 76 | builder = MarkdownBuilder.new 77 | nested do 78 | with builder yield builder 79 | end 80 | section(builder.items, indent) 81 | end 82 | 83 | def sub_section(items, indent = 8) 84 | add_item SubSection.new(items, indent) 85 | end 86 | 87 | def sub_section(*items, indent = 8) 88 | sub_section(items.to_a, indent) 89 | end 90 | 91 | def sub_section(indent = 8, &block) 92 | builder = MarkdownBuilder.new 93 | nested do 94 | with builder yield builder 95 | end 96 | sub_section(builder.items, indent) 97 | end 98 | 99 | def sub_sub_section(items, indent = 12) 100 | add_item SubSubSection.new(items, indent) 101 | end 102 | 103 | def sub_sub_section(*items, indent = 12) 104 | sub_sub_section(items.to_a, indent) 105 | end 106 | 107 | def sub_sub_section(indent = 12, &block) 108 | builder = MarkdownBuilder.new 109 | nested do 110 | with builder yield builder 111 | end 112 | sub_sub_section(builder.items, indent) 113 | end 114 | 115 | def to_s(io) 116 | @items.each do |item| 117 | io << item.to_s 118 | end 119 | end 120 | 121 | abstract class Component 122 | getter text : String 123 | 124 | def initialize(@text) 125 | end 126 | 127 | def validate_text(text) 128 | text = text.to_s 129 | text.empty? ? " " : text 130 | end 131 | 132 | def to_s(io) 133 | io << @text 134 | end 135 | end 136 | 137 | class Bold < Component 138 | def initialize(text) 139 | super("*#{validate_text(text)}*") 140 | end 141 | end 142 | 143 | class Italic < Component 144 | def initialize(text) 145 | super("_#{validate_text(text)}_") 146 | end 147 | end 148 | 149 | class Code < Component 150 | def initialize(text) 151 | super("`#{validate_text(text)}`") 152 | end 153 | end 154 | 155 | class Pre < Component 156 | def initialize(text, language = nil) 157 | super("```#{language}\n#{validate_text(text)}\n```") 158 | end 159 | end 160 | 161 | class Link < Component 162 | def initialize(text, url) 163 | super("[#{validate_text(text)}](#{url})") 164 | end 165 | end 166 | 167 | class Mention < Link 168 | def initialize(text, user) 169 | user_id = user.is_a?(TL::User) ? user.id : user 170 | super(validate_text(text), "tg://user?id=#{user_id}") 171 | end 172 | end 173 | 174 | class KeyValueItem < Component 175 | def initialize(key, value) 176 | super("#{key.to_s}: #{value.to_s}") 177 | end 178 | end 179 | 180 | class Text; end 181 | 182 | class Section 183 | property indent : Int32 184 | 185 | getter header : String 186 | 187 | getter items : Array(String) 188 | 189 | def initialize(args, @indent = 4) 190 | raise "Empty section" if args.empty? 191 | @header = args[0].to_s 192 | @items = args.size > 1 ? args[1..].compact.map(&.to_s) : [] of String 193 | end 194 | 195 | def item(other) 196 | items << other.to_s 197 | end 198 | 199 | def to_s(io) 200 | io.puts @header 201 | items.each do |item| 202 | io.puts (" " * @indent) + item 203 | end 204 | end 205 | end 206 | 207 | class SubSection < Section 208 | def initialize(args, indent = 8) 209 | super(args, indent) 210 | end 211 | end 212 | 213 | class SubSubSection < SubSection 214 | def initialize(args, indent = 12) 215 | super(args, indent) 216 | end 217 | end 218 | end 219 | end 220 | end 221 | -------------------------------------------------------------------------------- /src/proton/client.cr: -------------------------------------------------------------------------------- 1 | require "./client/*" 2 | 3 | module Proton 4 | class Client 5 | include Proton 6 | 7 | include Logger 8 | include ChatMethods 9 | include UploadMethods 10 | include MessageMethods 11 | include EventHandler::Annotator 12 | 13 | DEFAULT_TD_LIB_PARAMETERS = { 14 | use_test_dc: false, 15 | database_directory: Path.home.join(".proton/{{ @type.class.name.underscore }}").to_s, 16 | files_directory: "", 17 | use_file_database: true, 18 | use_chat_info_database: true, 19 | use_message_database: true, 20 | use_secret_chats: false, 21 | api_id: 10000, 22 | api_hash: "", 23 | system_language_code: "en", 24 | device_model: "Desktop", 25 | system_version: "Linux", 26 | application_version: Proton::VERSION, 27 | enable_storage_optimizer: true, 28 | ignore_file_names: false 29 | } 30 | 31 | @client : TDLib::Client 32 | @td_lib_parameters : TL::TdlibParameters? 33 | @auth_flow : AuthFlow? 34 | @counter : Atomic(Int64) 35 | @result_hash : Hash(Int32, JSON::Any) 36 | 37 | getter event_handlers : Array(EventHandler) 38 | 39 | # True if this client is currently running 40 | getter? alive : Bool 41 | 42 | # Receive timeout 43 | property timeout : Time::Span 44 | 45 | def initialize(auth_flow = nil, 46 | timeout = 5.seconds, 47 | **params) 48 | @client = TDLib.client_create 49 | @alive = true 50 | @counter = Atomic.new(0_i64) 51 | @passing_channel = Channel(JSON::Any).new 52 | @result_hash = {} of Int32 => JSON::Any 53 | @event_handlers = [] of EventHandler 54 | @auth_flow = auth_flow 55 | @timeout = timeout 56 | 57 | if params 58 | @td_lib_parameters = TL::TdlibParameters.new(**DEFAULT_TD_LIB_PARAMETERS.merge(params)) 59 | end 60 | 61 | TL.client = self 62 | register_event_handler_annotations 63 | end 64 | 65 | def set_tdlib_verbosity(level) 66 | send({ 67 | "@type" => "setLogVerbosityLevel", 68 | "new_verbosity_level" => level 69 | }, false) 70 | end 71 | 72 | def add_event_handler(handler : EventHandler) 73 | @event_handlers << handler 74 | end 75 | 76 | def start(timeout = nil, &block) 77 | receive_loop(timeout) do |update| 78 | if update.is_a?(TL::Update) 79 | type = update.responds_to?(:_type) ? update._type : "Unknown" 80 | yield update.as(TL::Update) 81 | @event_handlers.each do |handler| 82 | spawn do 83 | handler = handler.not_nil! 84 | begin 85 | handler.call(update.as(TL::Update)) 86 | rescue ex 87 | Log.error exception: ex, &.emit( 88 | "Unhandled exception", 89 | handler: handler.class.name, 90 | update: type.to_s 91 | ) 92 | end 93 | end 94 | end 95 | end 96 | end 97 | end 98 | 99 | def start(timeout = nil) 100 | start(timeout) { } 101 | end 102 | 103 | def receive_loop(timeout = nil, &block : TL::TLObject ->) 104 | loop do 105 | if update = receive(timeout || @timeout) 106 | if event = TL::TLObject.from_json(update.to_json) 107 | case event 108 | when TL::UpdateAuthorizationState 109 | if auth_flow = @auth_flow 110 | auth_flow.client = self 111 | case event.authorization_state 112 | when TL::AuthorizationStateClosed 113 | @alive = false 114 | break 115 | when TL::AuthorizationStateWaitTdlibParameters 116 | if params = @td_lib_parameters 117 | spawn TL.set_tdlib_parameters(params) 118 | else 119 | yield event 120 | end 121 | when TL::AuthorizationStateWaitEncryptionKey 122 | auth_flow.request_encryption_key 123 | when TL::AuthorizationStateWaitPhoneNumber 124 | auth_flow.request_phone_number 125 | when TL::AuthorizationStateWaitCode 126 | auth_flow.request_code 127 | when TL::AuthorizationStateWaitRegistration 128 | auth_flow.request_registration 129 | when TL::AuthorizationStateWaitPassword 130 | auth_flow.request_password 131 | else 132 | end 133 | else 134 | yield event 135 | end 136 | else 137 | yield event 138 | end 139 | end 140 | end 141 | 142 | sleep 0.001 143 | end 144 | end 145 | 146 | def execute(query) 147 | raise Errors::DeadClient.new unless alive? 148 | json_query = query.to_json 149 | res = TDLib.client_execute(self, json_query) 150 | JSON.parse(String.new(res)) unless res.null? 151 | end 152 | 153 | def send_async(query, counter = true) 154 | Channel(JSON::Any?).new(1).tap do |ch| 155 | query = query.to_h 156 | 157 | if counter 158 | index = @counter.get 159 | query["@extra"] = index.to_s 160 | @counter.add(1) 161 | end 162 | 163 | Log.debug { "Sending: #{query.to_pretty_json}" } 164 | 165 | TDLib.client_send(self, query.to_json) 166 | 167 | if index 168 | spawn do 169 | loop do 170 | if val = @result_hash[index]? 171 | @result_hash.delete(index) 172 | break ch.send(val) 173 | else 174 | sleep 0.001 175 | end 176 | end 177 | end 178 | else 179 | ch.send(nil) 180 | end 181 | end 182 | end 183 | 184 | def send!(query, counter = true, wait_max = @timeout) 185 | select 186 | when res = send_async(query, counter).receive 187 | return unless res 188 | case res["@type"] 189 | when "error" 190 | # TODO: Use generated errors 191 | raise Errors::Error.new(res["message"].as_s) 192 | else 193 | res 194 | end 195 | when timeout wait_max 196 | raise Errors::TimeoutError.new 197 | end 198 | end 199 | 200 | def send(query, counter = true, wait_max = @timeout) 201 | send!(query, counter, wait_max) 202 | rescue Errors::TimeoutError 203 | Log.warn { "Query timed out: #{query.to_pretty_json}" } 204 | nil 205 | end 206 | 207 | def receive(timeout = @timeout) 208 | res = TDLib.client_receive(self, timeout.total_seconds) 209 | unless res.null? 210 | res = JSON.parse(String.new(res)) 211 | 212 | if extra = res["@extra"]? 213 | if index = extra.as_s.to_i? 214 | @result_hash[index] = res 215 | end 216 | end 217 | 218 | Log.debug { "Receiving: #{res.to_pretty_json}" } 219 | res 220 | end 221 | end 222 | 223 | def close 224 | if alive? 225 | TDLib.client_destroy(self) 226 | @alive = false 227 | end 228 | end 229 | 230 | def to_unsafe 231 | @client 232 | end 233 | 234 | def finalize 235 | close 236 | end 237 | end 238 | end 239 | -------------------------------------------------------------------------------- /src/proton/utils.cr: -------------------------------------------------------------------------------- 1 | require "mime" 2 | require "magic" 3 | 4 | require "./utils/*" 5 | 6 | # Register some of the most common mime-types to avoid any issues. 7 | MIME.register(".png", "image/png") 8 | MIME.register(".jpeg", "image/jpeg") 9 | MIME.register(".webp", "image/webp") 10 | MIME.register(".gif", "image/gif") 11 | MIME.register(".bmp", "image/bmp") 12 | MIME.register(".tga", "image/x-tga") 13 | MIME.register(".tiff", "image/tiff") 14 | MIME.register(".psd", "image/vnd.adobe.photoshop") 15 | 16 | MIME.register(".mp4", "video/mp4") 17 | MIME.register(".mov", "video/quicktime") 18 | MIME.register(".avi", "video/avi") 19 | 20 | MIME.register(".mp3", "audio/mpeg") 21 | MIME.register(".m4a", "audio/m4a") 22 | MIME.register(".aac", "audio/aac") 23 | MIME.register(".ogg", "audio/ogg") 24 | MIME.register(".flac", "audio/flac") 25 | 26 | MIME.register(".tgs", "application/x-tgsticker") 27 | 28 | module Proton 29 | module Utils 30 | extend self 31 | 32 | USERNAME_RE = /@|(?:https?:\/\/)?(?:www\.)?(?:telegram\.(?:me|dog)|t\.me)\/(@|joinchat\/)?/ 33 | TG_JOIN_RE = /tg:\/\/(join)\?invite=/ 34 | 35 | # The only shorter-than-five-characters usernames are those used for some 36 | # special, very well known bots. This list may be incomplete though: 37 | # "[...] @gif, @vid, @pic, @bing, @wiki, @imdb and @bold [...]" 38 | # 39 | # See https://telegram.org/blog/inline-bots#how-does-it-work 40 | VALID_USERNAME_RE = Regex.new( 41 | "^([a-z](?:(?!__)\\w){3,30}[a-z\\d]" \ 42 | "|gif|vid|pic|bing|wiki|imdb|bold|vote|like|coub)$", 43 | Regex::Options::IGNORE_CASE 44 | ) 45 | 46 | MD_ENTITY_MAP = { 47 | TL::TextEntityTypeBold => {"*", "*"}, 48 | TL::TextEntityTypeItalic => {"_", "_"}, 49 | TL::TextEntityTypeUnderline => {"", ""}, 50 | TL::TextEntityTypeCode => {"`", "`"}, 51 | TL::TextEntityTypePre => {"```\n", "\n```"}, 52 | TL::TextEntityTypePreCode => {"```{language}\n", "\n```"}, 53 | TL::TextEntityTypeStrikethrough => {"", ""}, 54 | TL::TextEntityTypeMentionName => {"[", "](tg://user?id={id})"}, 55 | TL::TextEntityTypeTextUrl => {"[", "]({url})"} 56 | } 57 | 58 | MDV2_ENTITY_MAP = { 59 | TL::TextEntityTypeBold => {"*", "*"}, 60 | TL::TextEntityTypeItalic => {"_", "_"}, 61 | TL::TextEntityTypeUnderline => {"__", "__"}, 62 | TL::TextEntityTypeCode => {"`", "`"}, 63 | TL::TextEntityTypePre => {"```\n", "\n```"}, 64 | TL::TextEntityTypePreCode => {"```{language}\n", "\n```"}, 65 | TL::TextEntityTypeStrikethrough => {"~", "~"}, 66 | TL::TextEntityTypeMentionName => {"[", "](tg://user?id={id})"}, 67 | TL::TextEntityTypeTextUrl => {"[", "]({url})"} 68 | } 69 | 70 | HTML_ENTITY_MAP = { 71 | TL::TextEntityTypeBold => {"", ""}, 72 | TL::TextEntityTypeItalic => {"", ""}, 73 | TL::TextEntityTypeUnderline => {"", ""}, 74 | TL::TextEntityTypeCode => {"", ""}, 75 | TL::TextEntityTypePre => {"
\n", "\n
"}, 76 | TL::TextEntityTypePreCode => {"
\n", "\n
"}, 77 | TL::TextEntityTypeStrikethrough => {"", ""}, 78 | TL::TextEntityTypeMentionName => {"", ""}, 79 | TL::TextEntityTypeTextUrl => {"", ""} 80 | } 81 | 82 | class_getter my_id : Int32 { TL.get_me.id! } 83 | 84 | def unparse_text(text : String, entities ents : Array(TL::TextEntity), parse_mode : ParseMode = :markdown) 85 | start_entities = ents.reduce({} of Int32 => TL::TextEntity) { |acc, e| acc[e.offset!] = e; acc } 86 | end_entities = ents.reduce({} of Int32 => TL::TextEntity) { |acc, e| acc[e.offset! + e.length!] = e; acc } 87 | 88 | chars = text.chars 89 | chars << ' ' # The last entity doesn't complete without this 90 | 91 | entity_map = case parse_mode 92 | when ParseMode::Markdown 93 | MD_ENTITY_MAP 94 | when ParseMode::MarkdownV2 95 | MDV2_ENTITY_MAP 96 | when ParseMode::HTML 97 | HTML_ENTITY_MAP 98 | else 99 | raise "Unreachable" 100 | end 101 | 102 | String.build do |str| 103 | idx = 0 104 | chars.each do |char| 105 | if (entity = start_entities[idx]?.try &.type!) && (pieces = entity_map[entity.class]?) 106 | str << format_entity_chunk(pieces[0], entity) 107 | elsif (entity = end_entities[idx]?.try &.type!) && (pieces = entity_map[entity.class]?) 108 | str << format_entity_chunk(pieces[1], entity) 109 | end 110 | str << char 111 | idx += char.bytesize >= 4 ? 2 : 1 112 | end 113 | end 114 | end 115 | 116 | def guess_mime_type(file) 117 | case file 118 | when String 119 | MIME.from_extension(Path[file].extension) 120 | when Path 121 | MIME.from_extension(file.extension) 122 | when Bytes 123 | Magic.mime_type.of(IO::Memory.new(file)) 124 | when IO 125 | Magic.mime_type.of(file) 126 | when TL::Photo 127 | "image/jpeg" 128 | when .responds_to?(:mime_type) 129 | file.mime_type 130 | else 131 | raise "Unsupported file type for #{file}. Unable to guess mime type." 132 | end 133 | end 134 | 135 | def image?(file) 136 | mime = guess_mime_type(file) 137 | mime.starts_with?("image") 138 | end 139 | 140 | def gif?(file) 141 | mime = guess_mime_type(file) 142 | mime.ends_with?("gif") 143 | end 144 | 145 | def audio?(file) 146 | mime = guess_mime_type(file) 147 | mime.starts_with?("audio") 148 | end 149 | 150 | def video?(file) 151 | mime = guess_mime_type(file) 152 | mime.starts_with?("video") 153 | end 154 | 155 | def parse_phone(phone) 156 | if phone.is_a?(Int) 157 | phone.to_s 158 | else 159 | phone = phone.replace(/[+()\s-]/, "") 160 | if phone.to_i? 161 | phone 162 | end 163 | end 164 | end 165 | 166 | # Parses the given username or channel access hash, given 167 | # a string, username or URL. Returns a tuple consisting of 168 | # both the stripped, lowercase username and whether it is 169 | # a joinchat/ hash (in which case is not lowercase'd). 170 | def parse_username(username) 171 | username = username.strip 172 | if match = USERNAME_RE.match(username) || TG_JOIN_RE.match(username) 173 | username = username 174 | end 175 | end 176 | 177 | def escape_md(text, version = 1) 178 | text = text.to_s 179 | 180 | case version 181 | when 0, 1 182 | chars = ['_', '*', '[', '`'] 183 | when 2 184 | chars = ['_', '*', '[', ']', '(', ')', '~', '`', '>', '#', '+', '-', '=', '|', '{', '}', '.', '!'] 185 | else 186 | raise "Invalid version #{version} for `escape_md`" 187 | end 188 | 189 | chars.each do |char| 190 | text = text.gsub(char, "\\#{char}") 191 | end 192 | 193 | text 194 | end 195 | 196 | def parse_entities(text, parse_mode : ParseMode = :markdown) 197 | parser = EntityParser.new(parse_mode) 198 | parser.parse(text) 199 | end 200 | 201 | def parse_chat(chat) 202 | case chat 203 | when "me", "self", :me, :self 204 | # Make it easy to send messages to yourself using "me" and "self" 205 | TL.get_chat(my_id.to_i64) 206 | when TL::Chat 207 | chat 208 | when TL::User 209 | # Allow passing a chat or user in directly 210 | TL.get_chat(chat.id.to_i64) 211 | when Int 212 | # Also allow passing an id in directly 213 | TL.get_chat(chat) 214 | when String 215 | uname = chat.lstrip("@") 216 | TL.search_public_chat(uname) 217 | else 218 | raise ArgumentError.new("invalid type #{typeof(chat)} for property `chat`") 219 | end 220 | end 221 | 222 | def parse_user(user) 223 | case user 224 | when "me", "self", :me, :self 225 | TL.get_user(my_id.to_i32) 226 | when TL::User 227 | user 228 | when TL::Chat 229 | TL.get_user(user.id!.to_i32) 230 | when Int 231 | TL.get_user(user.to_i32) 232 | when String 233 | uname = chat.lstrip("@") 234 | chat = TL.search_public_chat(uname) 235 | TL.get_user(chat.id!.to_i32) 236 | else 237 | raise ArgumentError.new("invalid type #{typeof(user)} for property `user`") 238 | end 239 | end 240 | 241 | def parse_send_at(send_at, send_when_online) 242 | # Warn if send_at and send_when_online are provided 243 | if send_at && send_when_online 244 | Log.warn do 245 | "both send_at and send_when_online supplied to send_message call. " \ 246 | "using send_at value." 247 | end 248 | end 249 | 250 | if send_at.is_a?(Int) 251 | # If send_at is an Int value it should represent an epoch time 252 | TL::MessageSchedulingStateSendAtDate.new(send_at) 253 | elsif send_at.is_a?(Time) 254 | # If send_at is a Time we convert it to a unix time stamp 255 | TL::MessageSchedulingStateSendAtDate.new(send_at.to_unix.to_i32) 256 | elsif send_at.is_a?(Time::Span) 257 | # If send_at is a Time::Span we need to get the current time, and add the time span to it 258 | TL::MessageSchedulingStateSendAtDate.new(Time.utc.to_unix.to_i32 + send_at.total_seconds) 259 | elsif send_when_online 260 | # Otherwise check for send_when_online and use that 261 | TL::MessageSchedulingStateSendWhenOnline.new 262 | end 263 | end 264 | 265 | private def format_entity_chunk(chunk, entity) 266 | case entity 267 | when TL::TextEntityTypePreCode 268 | chunk = chunk.sub("{language}", entity.language!) 269 | when TL::TextEntityTypeMentionName 270 | chunk = chunk.sub("{id}", entity.user_id!) 271 | when TL::TextEntityTypeTextUrl 272 | chunk = chunk.sub("{url}", entity.url!) 273 | end 274 | chunk 275 | end 276 | end 277 | end 278 | -------------------------------------------------------------------------------- /src/proton/client/message_methods.cr: -------------------------------------------------------------------------------- 1 | module Proton 2 | module MessageMethods 3 | Log = ::Log.for("proton.message_methods") 4 | 5 | # Send a message to the given `to` user or chat. 6 | def send_message(to entity, 7 | message = nil, 8 | parse_mode : ParseMode? = :markdown, 9 | reply_message = nil, 10 | silent = false, 11 | background = false, 12 | send_at = nil, 13 | send_when_online = false, 14 | link_preview = false, 15 | file = nil, # TODO 16 | force_document = false, 17 | clear_draft = true, 18 | reply_markup = nil) 19 | # Reply to message can be either a message id or a TL::Message 20 | reply_message = reply_message.is_a?(TL::Message) ? reply_message.id! : (reply_message || 0) 21 | chat = Utils.parse_chat(entity) 22 | schedule = Utils.parse_send_at(send_at, send_when_online) 23 | 24 | # Create our options and message content 25 | # TODO: Do some client side validation of the message content 26 | options = TL::SendMessageOptions.new(silent, background, schedule) 27 | 28 | 29 | if message.is_a?(TL::Message) 30 | message_content = message.content! 31 | elsif file 32 | _, input_file, file_type = upload_file(file) 33 | caption = parse_text_entities(message.to_s, parse_mode) if message 34 | message_content = 35 | case file_type 36 | when TL::FileTypePhoto 37 | TL::InputMessagePhoto.new(input_file, caption: caption) 38 | when TL::FileTypeAnimation 39 | TL::InputMessageAnimation.new(input_file, caption: caption) 40 | when TL::FileTypeAudio 41 | TL::InputMessageAudio.new(input_file, caption: caption) 42 | when TL::FileTypeSticker 43 | TL::InputMessageSticker.new(input_file) 44 | when TL::FileTypeVideo 45 | TL::InputMessageVideo.new(input_file, caption: caption, ttl: 0) 46 | when TL::FileTypeDocument 47 | TL::InputMessageDocument.new(input_file, caption: caption) 48 | when TL::FileTypeVideoNote 49 | TL::InputMessageVideoNote.new(input_file) 50 | when TL::FileTypeVoiceNote 51 | TL::InputMessageVoiceNote.new(input_file, caption: caption) 52 | else 53 | raise "Unsupported input file type #{input_file.class}" 54 | end 55 | elsif !message.nil? 56 | formatted_text = parse_text_entities(message.to_s, parse_mode) 57 | message_content = TL::InputMessageText.new(formatted_text, !link_preview, clear_draft) 58 | else 59 | raise "A message is required unless sending a file" 60 | end 61 | 62 | TL.send_message(chat.id!, reply_message.to_i64, options, message_content, reply_markup) 63 | end 64 | 65 | def delete_messages(entity, 66 | messages, 67 | revoke = true) 68 | chat = Utils.parse_chat(entity) if entity 69 | message_tuples = [] of Tuple(Int64, Int64) 70 | messages = messages.is_a?(Array) ? messages : [messages] 71 | 72 | # Create a collection of {chat_id, message_id} pairs 73 | messages.each do |message| 74 | case message 75 | when Int 76 | # If just an int is provided, this will be assumed to be a message id. 77 | if chat 78 | message_tuples << {chat.id!, message} 79 | else 80 | # If a chat was not provided, we need to throw an error. 81 | raise ArgumentError.new("delete_messages requires a chat to be provided if message ids are supplied") 82 | end 83 | when Tuple(Int::Primitive, Int::Primitive) 84 | cid, mid = message 85 | message_tuples << {cid.to_i64, mid.to_i64} 86 | when TL::Message 87 | message_tuples << {message.chat_id!, message.id!} 88 | else 89 | raise ArgumentError.new("invalid type #{typeof(message)} in argument `messages`") 90 | end 91 | end 92 | 93 | # Group the {chat_id, message_id} pairs into a Hash of chat_id => Array(message_id) 94 | groups = message_tuples.reduce({} of Int64 => Array(Int64)) do |acc, (cid, mid)| 95 | acc[cid] ||= [] of Int64 96 | acc[cid] << mid 97 | acc 98 | end 99 | 100 | # Take each grouping and apply the delete operation separately 101 | groups.each do |cid, mids| 102 | TL.delete_messages(cid, mids, revoke) 103 | end 104 | end 105 | 106 | def delete_message(message, revoke = true) 107 | delete_messages(nil, [message], revoke) 108 | end 109 | 110 | def edit_message(message : TL::Message, 111 | text, 112 | parse_mode : ParseMode? = :markdown, 113 | link_preview = false, 114 | file = nil, # TODO 115 | force_document = false, 116 | send_at = nil, 117 | send_when_online = false, 118 | clear_draft = true, 119 | reply_markup = nil) 120 | formatted_text = parse_text_entities(text, parse_mode) 121 | message_content = TL::InputMessageText.new(formatted_text, !link_preview, clear_draft) 122 | 123 | TL.edit_message_text(message.chat_id!, message.id!, message_content, reply_markup) 124 | end 125 | 126 | def edit_message(entity, 127 | message, 128 | text, 129 | parse_mode : ParseMode? = :markdown, 130 | link_preview = false, 131 | file = nil, # TODO 132 | force_document = false, 133 | send_at = nil, 134 | send_when_online = false, 135 | clear_draft = true, 136 | reply_markup = nil) 137 | chat = Utils.parse_chat(entity) 138 | message_id = message.is_a?(TL::Message) ? message.id! : message.to_i64 139 | 140 | formatted_text = parse_text_entities(text, parse_mode) 141 | message_content = TL::InputMessageText.new(formatted_text, !link_preview, clear_draft) 142 | 143 | TL.edit_message_text(chat.id!, message_id, message_content, reply_markup) 144 | end 145 | 146 | 147 | # Forward previously sent messages and returns the forwarded messages in the same order as the 148 | # messages were sent. 149 | def forward_messages(to to_entity, 150 | messages, 151 | from from_entity = nil, 152 | silent = false, 153 | background = false, 154 | as_album = false, 155 | send_copy = false, 156 | remove_caption = false, 157 | send_at = nil, 158 | send_when_online = false) 159 | to_chat = Utils.parse_chat(to_entity) 160 | from_chat = Utils.parse_chat(from_entity) if from_entity 161 | messages = messages.is_a?(Array) ? messages : [messages] 162 | 163 | # Create a collection of {to_chat, from_chat, message_id} pairs 164 | message_tuples = messages.reduce([] of Tuple(Int64, Int64)) do |acc, message| 165 | case message 166 | when Int 167 | # If just an int is provided, this will be assumed to be a message id. 168 | if from_chat 169 | acc << {from_chat.id!, message} 170 | else 171 | # If to and from weren't provided, we need to throw an error. 172 | raise ArgumentError.new("a `from` param must be provided if message ids are supplied") 173 | end 174 | when Tuple(Int::Primitive, Int::Primitive) 175 | fid, msg = message 176 | case msg 177 | when Int 178 | acc << {fid.to_i64, msg.to_i64} 179 | when TL::Message 180 | acc << {fid.to_i64, msg.id} 181 | else 182 | raise ArgumentError.new("invalid type #{typeof(msg)} for index 1 in argument `messages`") 183 | end 184 | when TL::Message 185 | acc << {message.chat_id!, message.id!} 186 | else 187 | raise ArgumentError.new("invalid type #{typeof(message)} in argument `messages`") 188 | end 189 | 190 | acc 191 | end 192 | 193 | schedule = Utils.parse_send_at(send_at, send_when_online) 194 | options = TL::SendMessageOptions.new(silent, background, schedule) 195 | 196 | # Group the {from_chat, message_id} pairs into a Hash of from_chat => Array(message_id) 197 | groups = message_tuples.reduce({} of Int64 => Array(Int64)) do |acc, (fid, mid)| 198 | acc[fid] ||= [] of Int64 199 | acc[fid] << mid 200 | acc 201 | end 202 | 203 | # Take each grouping and apply the delete operation separately 204 | groups.reduce([] of TL::Message) do |acc, (fid, mids)| 205 | results = TL.forward_messages(to_chat.id!, fid, mids, options, as_album, send_copy, remove_caption) 206 | acc.concat(results.messages!) 207 | end 208 | end 209 | 210 | # def each_message(entity, **options) 211 | # chat = Utils.parse_chat(entity) 212 | # MessageIterator.new(entity, **options) 213 | # end 214 | 215 | # def each_message(entity, **options, &block : TL::Message ->) 216 | # iter = each_message(entity, **options) 217 | # iter.each(&block) 218 | # end 219 | 220 | def pin_message(entity, message, silent = true) 221 | chat = Utils.parse_chat(entity) 222 | message_id = message.is_a?(TL::Message) ? message.id! : message.to_i64 223 | TL.pin_chat_message(chat.id!, message_id, !silent) 224 | end 225 | 226 | def send_read_acknowledge(entity, message = nil, last_message = nil) 227 | chat = Utils.parse_chat(entity) 228 | last_message = last_message.is_a?(TL::Message) ? last_message.id! : last_message 229 | 230 | # Open the chat so we can act on it 231 | TL.open_chat(chat.id!) 232 | 233 | # Get the chat item 234 | chat = TL.get_chat(chat.id!) 235 | 236 | # Get the id of the last read message, and the last message 237 | from_message_id = chat.last_read_inbox_message_id! 238 | 239 | to_message_id = chat.last_message.try &.id! || 0 240 | to_message_id = last_message ? Math.min(to_message_id, last_message) : to_message_id 241 | 242 | # No last message means nothing to do 243 | return if to_message_id = 0 244 | 245 | messages_to_read = (from_message_id..to_message_id).to_a 246 | TL.view_messages(chat.id!, messages_to_read, false) 247 | end 248 | 249 | def parse_text_entities(text, parse_mode : ParseMode? = nil) 250 | if parse_mode 251 | parse_mode = parse_mode.to_tl 252 | TL.parse_text_entities(text, parse_mode) 253 | else 254 | TL::FormattedText.new(text, [] of TL::TextEntity) 255 | end 256 | end 257 | end 258 | end 259 | -------------------------------------------------------------------------------- /src/generator/tlobjects.cr: -------------------------------------------------------------------------------- 1 | require "tl_parser" 2 | require "./source_builder" 3 | 4 | module Proton 5 | module Generator 6 | class TLObjects 7 | HEADER = <<-CRYSTAL 8 | # Copyright 2020 - Chris Watson 9 | # 10 | # You should have received with this program a copy of the MIT license. This code is 11 | # subject to the terms and conditions outlined in said license. For more information, 12 | # please see https://en.wikipedia.org/wiki/MIT_License. 13 | # 14 | # This file was auto generated. Please do not modify directly. 15 | CRYSTAL 16 | 17 | PATCHED_TYPES = { 18 | message: "Message", 19 | message_empty: "Message", 20 | message_service: "Message", 21 | } 22 | 23 | PRIMITIVE_SUPER_CLASSES = ["Bool", "True", "Null"] 24 | PRIMITIVE_TYPES = ["int", "int53", "int128", "int256", "long", "double", "bytes", "string", "true", "date", "vector", "Vector"] 25 | OPTIONAL_KEYS = ["; may be null", "; may be empty", "for bots only", "pass null", "if known", "if available"] 26 | 27 | # [Types|Functions] => Namespace => []TLParser::Definition 28 | @definitions : Hash(String, Hash(String, Array(TLParser::Definition))) 29 | 30 | def initialize(sources : Array(String)) 31 | @definitions = build_definitions(sources) 32 | end 33 | 34 | def generate_types(outdir : String, namespace : Array(String) = [] of String) 35 | definitions = @definitions["Types"] 36 | tlobjects = definitions[""] 37 | 38 | 39 | # Find all primitives (types with no subclass) 40 | primitives = tlobjects.select { |o| PRIMITIVE_SUPER_CLASSES.includes?(o.type.name) } 41 | tlobjects -= primitives 42 | 43 | # Get names of all classes 44 | class_names = tlobjects.map { |o| make_class_name(o.name) } 45 | 46 | # Find all super classes 47 | super_classes = tlobjects.reduce(Hash(String, Array(TLParser::Definition)).new) do |acc, o| 48 | unless class_names.includes?(o.type.name) 49 | acc[o.type.name] ||= [] of TLParser::Definition 50 | acc[o.type.name] << o 51 | end 52 | acc 53 | end.to_a 54 | 55 | # Figure out file name 56 | filename = File.join(outdir, "types.cr") 57 | 58 | # Create directory 59 | Dir.mkdir_p(outdir) 60 | 61 | # Write the file 62 | write_file(filename, namespace) do |builder| 63 | unless primitives.empty? 64 | primitives.each { |pr| write_primitive(builder, pr) } 65 | builder.writeln 66 | end 67 | 68 | super_classes.each_with_index do |(sc, tlos), i| 69 | write_superclass(builder, sc, tlos) 70 | builder.writeln 71 | end 72 | 73 | tlobjects.each_with_index do |tlo, i| 74 | write_tlobject(builder, tlo) 75 | builder.writeln if i < tlobjects.size - 1 76 | end 77 | end 78 | 79 | write_file(File.join(outdir, "tlobject.cr"), namespace) do |builder| 80 | builder.writeln "abstract class TLObject" 81 | builder.writeln "include JSON::Serializable" 82 | builder.writeln 83 | builder.writeln "use_json_discriminator \"@type\", {" 84 | tlobjects.each do |tlo| 85 | cls = make_class_name(tlo.name) 86 | builder.writeln("\"#{tlo.name}\" => #{cls},") 87 | end 88 | builder.writeln "}" 89 | builder.writeln 90 | builder.writeln "end" 91 | end 92 | end 93 | 94 | def generate_functions(outdir : String, namespace : Array(String) = [] of String) 95 | definitions = @definitions["Functions"] 96 | tlobjects = definitions[""] 97 | 98 | # Figure out file name 99 | filename = File.join(outdir, "functions.cr") 100 | 101 | # Create directory 102 | Dir.mkdir_p(outdir) 103 | 104 | # Write the file 105 | write_file(filename, namespace) do |builder| 106 | builder.writeln("extend self") 107 | builder.writeln 108 | builder.writeln("class_property! client : Proton::Client?") 109 | builder.writeln 110 | 111 | tlobjects.each_with_index do |tlo, i| 112 | method_name = tlo.name.underscore 113 | return_type = to_crystal_type(tlo.type) 114 | comments = description_to_comments(tlo.description) 115 | params = sort_params(tlo.params, comments) 116 | 117 | unless comments.empty? 118 | if desc = comments.delete("description") 119 | desc.each_line do |line| 120 | builder.writeln("# #{line}") 121 | end 122 | end 123 | 124 | if comments.size > 1 125 | builder.writeln("#") 126 | builder.writeln("# **Params:**") 127 | padding = comments.keys.max_by(&.size).size 128 | comments.each do |(param, desc)| 129 | desc.lines.each_with_index do |line, k| 130 | if k == 0 131 | builder.writeln("# `#{param.ljust(padding)}` - #{line}") 132 | else 133 | builder.writeln("# #{" " * (padding + 4)} #{line}") 134 | end 135 | end 136 | end 137 | end 138 | end 139 | 140 | builder.write("def #{method_name}") 141 | unless params.empty? 142 | builder.write("(") 143 | params.each_with_index do |param, j| 144 | if param.type.is_a?(TLParser::NormalParam) 145 | name = param.name 146 | tltype = param.type.as(TLParser::NormalParam).type 147 | type = to_crystal_type(tltype) 148 | builder.write("#{name} : ") 149 | if PRIMITIVE_TYPES.includes?(tltype.name) 150 | builder.write(type) 151 | else 152 | builder.write("(#{type} | NamedTuple)") 153 | end 154 | builder.write("? = nil") if param_optional?(param, comments) 155 | builder.write(", ") if j < (params.size - 1) 156 | end 157 | end 158 | builder.write(")") 159 | end 160 | 161 | builder.writeln(" : #{return_type == "Ok" ? "Ok?" : return_type}") 162 | builder.indent 163 | 164 | params.each do |param| 165 | tltype = param.type.as(TLParser::NormalParam).type 166 | unless PRIMITIVE_TYPES.includes?(tltype.name) 167 | type = to_crystal_type(tltype) 168 | builder.writeln("#{param.name} = #{param.name}.is_a?(NamedTuple) ? #{type}.new(**#{param.name}) : #{param.name}") 169 | end 170 | end 171 | 172 | builder.writeln 173 | builder.writeln("res = client.send({") 174 | builder.writeln("\"@type\" => \"#{tlo.name}\",") 175 | params.each do |param| 176 | builder.writeln("\"#{param.name}\" => #{param.name},") 177 | end 178 | builder.dedent 179 | builder.writeln("}, true)") 180 | builder.writeln 181 | 182 | if return_type == "Ok" 183 | builder.writeln("res.nil? ? nil : #{return_type}.from_json(res.to_json)") 184 | else 185 | builder.writeln("#{return_type}.from_json(res.to_json)") 186 | end 187 | 188 | builder.writeln("end") 189 | builder.writeln if i < (tlobjects.size - 1) 190 | end 191 | end 192 | end 193 | 194 | private def write_file(filename, namespace, &block) 195 | output = File.open(filename, mode: "w+") 196 | output.rewind 197 | 198 | builder = SourceBuilder.new(output) 199 | 200 | path = Path.new(filename) 201 | builder.writeln HEADER 202 | 203 | namespace.each do |ns| 204 | builder.writeln "module #{ns}" 205 | end 206 | 207 | yield builder 208 | 209 | namespace.each do |ns| 210 | builder.writeln "end" 211 | end 212 | 213 | output.close 214 | end 215 | 216 | private def write_primitive(builder, type) 217 | clsname = make_class_name(type.name) 218 | supername = superlcass_name(type) 219 | 220 | builder.writeln "class #{clsname} < #{supername}" 221 | builder.writeln "end" 222 | end 223 | 224 | private def write_superclass(builder, type, tlobjects) 225 | clsname = make_class_name(type) 226 | 227 | builder.writeln "class #{clsname} < TLObject" 228 | builder.writeln "include JSON::Serializable" 229 | builder.writeln 230 | builder.writeln "use_json_discriminator \"@type\", {" 231 | tlobjects.each do |tlo| 232 | cls = make_class_name(tlo.name) 233 | builder.writeln("\"#{tlo.name}\" => #{cls},") 234 | end 235 | builder.dedent 236 | builder.writeln "}" 237 | builder.writeln "end" 238 | end 239 | 240 | private def write_tlobject(builder, type) 241 | clsname = make_class_name(type.name) 242 | supername = superlcass_name(type) 243 | comments = description_to_comments(type.description) 244 | 245 | comments["description"]?.try &.lines.each do |line| 246 | builder.writeln "# #{line}" 247 | end 248 | 249 | builder.writeln "class #{clsname} < #{supername}" 250 | builder.writeln 251 | builder.writeln "@[JSON::Field(key: \"@type\")]" 252 | builder.writeln "getter _type = \"#{type.name}\"" 253 | 254 | unless type.params.empty? 255 | builder.writeln 256 | write_param_props(builder, type.params, comments) 257 | end 258 | 259 | write_initializer(builder, type.params, comments) 260 | 261 | builder.writeln("end") 262 | end 263 | 264 | private def description_to_comments(description) 265 | last_key = nil 266 | description.split('\n').reduce({} of String => String) do |acc, str| 267 | if str.starts_with?("@") 268 | key, value = str[1..].split(/\s+/, 2) 269 | acc[key] = value 270 | last_key = key 271 | elsif str.starts_with?("-") && (lk = last_key) 272 | acc[lk] += "\n" + str[1..] 273 | else 274 | puts "Hit this with: #{str}" 275 | end 276 | acc 277 | end 278 | end 279 | 280 | private def build_definitions(sources) 281 | sources.reduce({} of String => Hash(String, Array(TLParser::Definition))) do |acc, d| 282 | acc["Types"] ||= {} of String => Array(TLParser::Definition) 283 | acc["Functions"] ||= {} of String => Array(TLParser::Definition) 284 | 285 | defs = TLParser.parse(d) 286 | defs.each do |x| 287 | cat = x[0].to_s 288 | de = x[1] 289 | ns = de.namespace.join(".") 290 | acc[cat][ns] ||= [] of TLParser::Definition 291 | acc[cat][ns] << de 292 | end 293 | 294 | acc 295 | end 296 | end 297 | 298 | private def make_class_name(name) 299 | name.split('_').join(&.camelcase) 300 | end 301 | 302 | private def superlcass_name(obj) 303 | subclass = make_class_name(obj.type.name) 304 | if ["Bool", "True", "Error", "Null"].includes?(subclass) || 305 | subclass.downcase == obj.name.downcase 306 | subclass = "TLObject" 307 | end 308 | subclass 309 | end 310 | 311 | private def to_crystal_type(param) 312 | case param.name 313 | when "int" 314 | "Int32" 315 | when "long", "int53" 316 | "Int64" 317 | when "double" 318 | "Float64" 319 | when "string", "bytes", "int64", "int128", "int256" 320 | "String" 321 | when "true" 322 | "Bool" 323 | when "date" 324 | "Time" 325 | when "Vector", "vector" 326 | "Array(#{to_crystal_type(param.generic_arg.not_nil!)})" 327 | else 328 | make_class_name(param.name) 329 | end 330 | end 331 | 332 | private def write_param_props(builder, params, comments) 333 | params.each do |p| 334 | type = p.type 335 | comment = comments[p.name]? 336 | if type.is_a?(TLParser::NormalParam) 337 | cr_type = to_crystal_type(p.type.as(TLParser::NormalParam).type) 338 | 339 | comment.lines.each { |ln| builder.writeln("# #{ln}") } if comment 340 | builder.writeln "property #{p.name} : #{cr_type}?" 341 | builder.writeln 342 | builder.writeln "# :ditto:" if comment 343 | builder.writeln "def #{p.name}!" 344 | builder.writeln "@#{p.name}.not_nil!" 345 | builder.writeln "end" 346 | builder.writeln 347 | end 348 | end 349 | end 350 | 351 | private def write_initializer(builder, params, comments) 352 | if params.empty? 353 | builder.writeln "def initialize" 354 | builder.writeln "end" 355 | else 356 | builder.write "def initialize(" 357 | params.each_with_index do |p, i| 358 | write_param(builder, p, comments) 359 | builder.write(i < (params.size - 1) ? ", " : "") 360 | end 361 | builder.writeln ")" 362 | builder.indent 363 | builder.writeln("end") 364 | end 365 | end 366 | 367 | private def write_param(builder, param, comments) 368 | type = param.type.as(TLParser::NormalParam) 369 | cr_type = to_crystal_type(type.type) 370 | builder.write("@#{param.name} : #{cr_type}? = nil") 371 | end 372 | 373 | private def sort_params(params, comments) 374 | params 375 | .select(&.type.is_a?(TLParser::NormalParam)) 376 | .sort do |a, b| 377 | a_index = param_optional?(a, comments) ? 1 : 0 378 | b_index = param_optional?(b, comments) ? 1 : 0 379 | 380 | a_index <=> b_index 381 | end 382 | end 383 | 384 | private def param_optional?(param, comments) 385 | comment = comments[param.name]? 386 | optional = comment ? OPTIONAL_KEYS.any? { |key| comment.includes?("#{key}") } : false 387 | end 388 | end 389 | end 390 | end 391 | 392 | # api_data = File.read(File.expand_path("./data/api.tl", __DIR__)) 393 | # mtproto_data = File.read(File.expand_path("./data/mtproto.tl", __DIR__)) 394 | td_api = File.read(File.expand_path("./data/td_api.tl", __DIR__)) 395 | 396 | # generator = Proton::Generator::TLObjects.new([api_data, mtproto_data]) 397 | generator = Proton::Generator::TLObjects.new([td_api]) 398 | generator.generate_types(File.expand_path("../proton/tl", __DIR__), ["Proton", "TL"]) 399 | generator.generate_functions(File.expand_path("../proton/tl", __DIR__), ["Proton", "TL"]) 400 | -------------------------------------------------------------------------------- /src/generator/data/errors.csv: -------------------------------------------------------------------------------- 1 | name,codes,description 2 | ABOUT_TOO_LONG,400,The provided bio is too long 3 | ACCESS_TOKEN_EXPIRED,400,Bot token expired 4 | ACCESS_TOKEN_INVALID,400,The provided token is not valid 5 | ACTIVE_USER_REQUIRED,401,The method is only available to already activated users 6 | ADMINS_TOO_MUCH,400,Too many admins 7 | ADMIN_RANK_EMOJI_NOT_ALLOWED,400,Emoji are not allowed in admin titles or ranks 8 | ADMIN_RANK_INVALID,400,The given admin title or rank was invalid (possibly larger than 16 characters) 9 | API_ID_INVALID,400,The api_id/api_hash combination is invalid 10 | API_ID_PUBLISHED_FLOOD,400,"This API id was published somewhere, you can't use it now" 11 | ARTICLE_TITLE_EMPTY,400,The title of the article is empty 12 | AUTH_BYTES_INVALID,400,The provided authorization is invalid 13 | AUTH_KEY_DUPLICATED,406,"The authorization key (session file) was used under two different IP addresses simultaneously, and can no longer be used. Use the same session exclusively, or use different sessions" 14 | AUTH_KEY_INVALID,401,The key is invalid 15 | AUTH_KEY_PERM_EMPTY,401,"The method is unavailable for temporary authorization key, not bound to permanent" 16 | AUTH_KEY_UNREGISTERED,401,The key is not registered in the system 17 | AUTH_RESTART,500,Restart the authorization process 18 | AUTH_TOKEN_ALREADY_ACCEPTED,400,The authorization token was already used 19 | AUTH_TOKEN_EXPIRED,400,The provided authorization token has expired and the updated QR-code must be re-scanned 20 | AUTH_TOKEN_INVALID,400,An invalid authorization token was provided 21 | BANNED_RIGHTS_INVALID,400,"You cannot use that set of permissions in this request, i.e. restricting view_messages as a default" 22 | BOTS_TOO_MUCH,400,There are too many bots in this chat/channel 23 | BOT_CHANNELS_NA,400,Bots can't edit admin privileges 24 | BOT_COMMAND_DESCRIPTION_INVALID,400,"The command description was empty, too long or had invalid characters used" 25 | BOT_GROUPS_BLOCKED,400,This bot can't be added to groups 26 | BOT_INLINE_DISABLED,400,This bot can't be used in inline mode 27 | BOT_INVALID,400,This is not a valid bot 28 | BOT_METHOD_INVALID,400,The API access for bot users is restricted. The method you tried to invoke cannot be executed as a bot 29 | BOT_MISSING,400,This method can only be run by a bot 30 | BOT_PAYMENTS_DISABLED,400,This method can only be run by a bot 31 | BOT_POLLS_DISABLED,400,You cannot create polls under a bot account 32 | BROADCAST_ID_INVALID,400,The channel is invalid 33 | BROADCAST_PUBLIC_VOTERS_FORBIDDEN,400,You cannot broadcast polls where the voters are public 34 | BUTTON_DATA_INVALID,400,The provided button data is invalid 35 | BUTTON_TYPE_INVALID,400,The type of one of the buttons you provided is invalid 36 | BUTTON_URL_INVALID,400,Button URL invalid 37 | CALL_ALREADY_ACCEPTED,400,The call was already accepted 38 | CALL_ALREADY_DECLINED,400,The call was already declined 39 | CALL_OCCUPY_FAILED,500,The call failed because the user is already making another call 40 | CALL_PEER_INVALID,400,The provided call peer object is invalid 41 | CALL_PROTOCOL_FLAGS_INVALID,400,Call protocol flags invalid 42 | CDN_METHOD_INVALID,400,This method cannot be invoked on a CDN server. Refer to https://core.telegram.org/cdn#schema for available methods 43 | CHANNELS_ADMIN_PUBLIC_TOO_MUCH,400,"You're admin of too many public channels, make some channels private to change the username of this channel" 44 | CHANNELS_TOO_MUCH,400,You have joined too many channels/supergroups 45 | CHANNEL_INVALID,400,"Invalid channel object. Make sure to pass the right types, for instance making sure that the request is designed for channels or otherwise look for a different one more suited" 46 | CHANNEL_PRIVATE,400,The channel specified is private and you lack permission to access it. Another reason may be that you were banned from it 47 | CHANNEL_PUBLIC_GROUP_NA,403,channel/supergroup not available 48 | CHAT_ABOUT_NOT_MODIFIED,400,About text has not changed 49 | CHAT_ABOUT_TOO_LONG,400,Chat about too long 50 | CHAT_ADMIN_INVITE_REQUIRED,403,You do not have the rights to do this 51 | CHAT_ADMIN_REQUIRED,400 403,"Chat admin privileges are required to do that in the specified chat (for example, to send a message in a channel which is not yours), or invalid permissions used for the channel or group" 52 | CHAT_FORBIDDEN,,You cannot write in this chat 53 | CHAT_ID_EMPTY,400,The provided chat ID is empty 54 | CHAT_ID_INVALID,400,"Invalid object ID for a chat. Make sure to pass the right types, for instance making sure that the request is designed for chats (not channels/megagroups) or otherwise look for a different one more suited\nAn example working with a megagroup and AddChatUserRequest, it will fail because megagroups are channels. Use InviteToChannelRequest instead" 55 | CHAT_INVALID,400,The chat is invalid for this request 56 | CHAT_LINK_EXISTS,400,The chat is linked to a channel and cannot be used in that request 57 | CHAT_NOT_MODIFIED,400,"The chat or channel wasn't modified (title, invites, username, admins, etc. are the same)" 58 | CHAT_RESTRICTED,400,The chat is restricted and cannot be used in that request 59 | CHAT_SEND_GIFS_FORBIDDEN,403,You can't send gifs in this chat 60 | CHAT_SEND_INLINE_FORBIDDEN,400,You cannot send inline results in this chat 61 | CHAT_SEND_MEDIA_FORBIDDEN,403,You can't send media in this chat 62 | CHAT_SEND_STICKERS_FORBIDDEN,403,You can't send stickers in this chat 63 | CHAT_TITLE_EMPTY,400,No chat title provided 64 | CHAT_WRITE_FORBIDDEN,403,You can't write in this chat 65 | CODE_EMPTY,400,The provided code is empty 66 | CODE_HASH_INVALID,400,Code hash invalid 67 | CODE_INVALID,400,Code invalid (i.e. from email) 68 | CONNECTION_API_ID_INVALID,400,The provided API id is invalid 69 | CONNECTION_DEVICE_MODEL_EMPTY,400,Device model empty 70 | CONNECTION_LANG_PACK_INVALID,400,"The specified language pack is not valid. This is meant to be used by official applications only so far, leave it empty" 71 | CONNECTION_LAYER_INVALID,400,The very first request must always be InvokeWithLayerRequest 72 | CONNECTION_NOT_INITED,400,Connection not initialized 73 | CONNECTION_SYSTEM_EMPTY,400,Connection system empty 74 | CONNECTION_SYSTEM_LANG_CODE_EMPTY,400,The system language string was empty during connection 75 | CONTACT_ID_INVALID,400,The provided contact ID is invalid 76 | CONTACT_NAME_EMPTY,400,The provided contact name cannot be empty 77 | DATA_INVALID,400,Encrypted data invalid 78 | DATA_JSON_INVALID,400,The provided JSON data is invalid 79 | DATE_EMPTY,400,Date empty 80 | DC_ID_INVALID,400,This occurs when an authorization is tried to be exported for the same data center one is currently connected to 81 | DH_G_A_INVALID,400,g_a invalid 82 | EMAIL_HASH_EXPIRED,400,The email hash expired and cannot be used to verify it 83 | EMAIL_INVALID,400,The given email is invalid 84 | EMAIL_UNCONFIRMED_X,400,"Email unconfirmed, the length of the code must be {code_length}" 85 | EMOTICON_EMPTY,400,The emoticon field cannot be empty 86 | EMOTICON_INVALID,400,The specified emoticon cannot be used or was not a emoticon 87 | ENCRYPTED_MESSAGE_INVALID,400,Encrypted message invalid 88 | ENCRYPTION_ALREADY_ACCEPTED,400,Secret chat already accepted 89 | ENCRYPTION_ALREADY_DECLINED,400,The secret chat was already declined 90 | ENCRYPTION_DECLINED,400,The secret chat was declined 91 | ENCRYPTION_ID_INVALID,400,The provided secret chat ID is invalid 92 | ENCRYPTION_OCCUPY_FAILED,500,TDLib developer claimed it is not an error while accepting secret chats and 500 is used instead of 420 93 | ENTITIES_TOO_LONG,400,It is no longer possible to send such long data inside entity tags (for example inline text URLs) 94 | ENTITY_MENTION_USER_INVALID,400,You can't use this entity 95 | ERROR_TEXT_EMPTY,400,The provided error message is empty 96 | EXPORT_CARD_INVALID,400,Provided card is invalid 97 | EXTERNAL_URL_INVALID,400,External URL invalid 98 | FIELD_NAME_EMPTY,,The field with the name FIELD_NAME is missing 99 | FIELD_NAME_INVALID,,The field with the name FIELD_NAME is invalid 100 | FILE_ID_INVALID,400,The provided file id is invalid 101 | FILE_MIGRATE_X,303,The file to be accessed is currently stored in DC {new_dc} 102 | FILE_PARTS_INVALID,400,The number of file parts is invalid 103 | FILE_PART_0_MISSING,,File part 0 missing 104 | FILE_PART_EMPTY,400,The provided file part is empty 105 | FILE_PART_INVALID,400,The file part number is invalid 106 | FILE_PART_LENGTH_INVALID,400,The length of a file part is invalid 107 | FILE_PART_SIZE_CHANGED,400,The file part size (chunk size) cannot change during upload 108 | FILE_PART_SIZE_INVALID,400,The provided file part size is invalid 109 | FILE_PART_X_MISSING,400,Part {which} of the file is missing from storage 110 | FILE_REFERENCE_EMPTY,400,The file reference must exist to access the media and it cannot be empty 111 | FILEREF_UPGRADE_NEEDED,406,The file reference needs to be refreshed before being used again 112 | FIRSTNAME_INVALID,400,The first name is invalid 113 | FLOOD_TEST_PHONE_WAIT_X,420,A wait of {seconds} seconds is required in the test servers 114 | FLOOD_WAIT_X,420,A wait of {seconds} seconds is required 115 | FOLDER_ID_EMPTY,400,The folder you tried to delete was already empty 116 | FOLDER_ID_INVALID,400,The folder you tried to use was not valid 117 | FRESH_CHANGE_ADMINS_FORBIDDEN,400,Recently logged-in users cannot add or change admins 118 | FRESH_CHANGE_PHONE_FORBIDDEN,406,Recently logged-in users cannot use this request 119 | FRESH_RESET_AUTHORISATION_FORBIDDEN,406,The current session is too new and cannot be used to reset other authorisations yet 120 | GAME_BOT_INVALID,400,You cannot send that game with the current bot 121 | GIF_ID_INVALID,400,The provided GIF ID is invalid 122 | GROUPED_MEDIA_INVALID,400,Invalid grouped media 123 | HASH_INVALID,400,The provided hash is invalid 124 | HISTORY_GET_FAILED,500,Fetching of history failed 125 | IMAGE_PROCESS_FAILED,400,Failure while processing image 126 | INLINE_BOT_REQUIRED,403,The action must be performed through an inline bot callback 127 | INLINE_RESULT_EXPIRED,400,The inline query expired 128 | INPUT_CONSTRUCTOR_INVALID,400,The provided constructor is invalid 129 | INPUT_FETCH_ERROR,,An error occurred while deserializing TL parameters 130 | INPUT_FETCH_FAIL,400,Failed deserializing TL payload 131 | INPUT_LAYER_INVALID,400,The provided layer is invalid 132 | INPUT_METHOD_INVALID,,The invoked method does not exist anymore or has never existed 133 | INPUT_REQUEST_TOO_LONG,400,The input request was too long. This may be a bug in the library as it can occur when serializing more bytes than it should (like appending the vector constructor code at the end of a message) 134 | INPUT_USER_DEACTIVATED,400,The specified user was deleted 135 | INTERDC_X_CALL_ERROR,,An error occurred while communicating with DC {dc} 136 | INTERDC_X_CALL_RICH_ERROR,,A rich error occurred while communicating with DC {dc} 137 | INVITE_HASH_EMPTY,400,The invite hash is empty 138 | INVITE_HASH_EXPIRED,400,The chat the user tried to join has expired and is not valid anymore 139 | INVITE_HASH_INVALID,400,The invite hash is invalid 140 | LANG_PACK_INVALID,400,The provided language pack is invalid 141 | LASTNAME_INVALID,,The last name is invalid 142 | LIMIT_INVALID,400,An invalid limit was provided. See https://core.telegram.org/api/files#downloading-files 143 | LINK_NOT_MODIFIED,400,The channel is already linked to this group 144 | LOCATION_INVALID,400,The location given for a file was invalid. See https://core.telegram.org/api/files#downloading-files 145 | MAX_ID_INVALID,400,The provided max ID is invalid 146 | MAX_QTS_INVALID,400,The provided QTS were invalid 147 | MD5_CHECKSUM_INVALID,,The MD5 check-sums do not match 148 | MEDIA_CAPTION_TOO_LONG,400,The caption is too long 149 | MEDIA_EMPTY,400,The provided media object is invalid or the current account may not be able to send it (such as games as users) 150 | MEDIA_INVALID,400,Media invalid 151 | MEDIA_NEW_INVALID,400,The new media to edit the message with is invalid (such as stickers or voice notes) 152 | MEDIA_PREV_INVALID,400,The old media cannot be edited with anything else (such as stickers or voice notes) 153 | MEGAGROUP_ID_INVALID,400,The group is invalid 154 | MEGAGROUP_PREHISTORY_HIDDEN,400,You can't set this discussion group because it's history is hidden 155 | MEMBER_NO_LOCATION,500,An internal failure occurred while fetching user info (couldn't find location) 156 | MEMBER_OCCUPY_PRIMARY_LOC_FAILED,500,Occupation of primary member location failed 157 | MESSAGE_AUTHOR_REQUIRED,403,Message author required 158 | MESSAGE_DELETE_FORBIDDEN,403,"You can't delete one of the messages you tried to delete, most likely because it is a service message." 159 | MESSAGE_EDIT_TIME_EXPIRED,400,"You can't edit this message anymore, too much time has passed since its creation." 160 | MESSAGE_EMPTY,400,Empty or invalid UTF-8 message was sent 161 | MESSAGE_IDS_EMPTY,400,No message ids were provided 162 | MESSAGE_ID_INVALID,400,"The specified message ID is invalid or you can't do that operation on such message" 163 | MESSAGE_NOT_MODIFIED,400,Content of the message was not modified 164 | MESSAGE_POLL_CLOSED,400,The poll was closed and can no longer be voted on 165 | MESSAGE_TOO_LONG,400,Message was too long. Current maximum length is 4096 UTF-8 characters 166 | MSGID_DECREASE_RETRY,500,The request should be retried with a lower message ID 167 | MSG_ID_INVALID,400,The message ID used in the peer was invalid 168 | MSG_WAIT_FAILED,400,A waiting call returned an error 169 | MT_SEND_QUEUE_TOO_LONG,500, 170 | NEED_CHAT_INVALID,500,The provided chat is invalid 171 | NEED_MEMBER_INVALID,500,The provided member is invalid or does not exist (for example a thumb size) 172 | NETWORK_MIGRATE_X,303,The source IP address is associated with DC {new_dc} 173 | NEW_SALT_INVALID,400,The new salt is invalid 174 | NEW_SETTINGS_INVALID,400,The new settings are invalid 175 | OFFSET_INVALID,400,"The given offset was invalid, it must be divisible by 1KB. See https://core.telegram.org/api/files#downloading-files" 176 | OFFSET_PEER_ID_INVALID,400,The provided offset peer is invalid 177 | OPTION_INVALID,400,The option specified is invalid and does not exist in the target poll 178 | OPTIONS_TOO_MUCH,400,You defined too many options for the poll 179 | PACK_SHORT_NAME_INVALID,400,"Invalid sticker pack name. It must begin with a letter, can't contain consecutive underscores and must end in ""_by_""." 180 | PACK_SHORT_NAME_OCCUPIED,400,A stickerpack with this name already exists 181 | PARTICIPANTS_TOO_FEW,400,Not enough participants 182 | PARTICIPANT_CALL_FAILED,500,Failure while making call 183 | PARTICIPANT_VERSION_OUTDATED,400,The other participant does not use an up to date telegram client with support for calls 184 | PASSWORD_EMPTY,400,The provided password is empty 185 | PASSWORD_HASH_INVALID,400,The password (and thus its hash value) you entered is invalid 186 | PASSWORD_REQUIRED,400,The account must have 2-factor authentication enabled (a password) before this method can be used 187 | PAYMENT_PROVIDER_INVALID,400,The payment provider was not recognised or its token was invalid 188 | PEER_FLOOD,,Too many requests 189 | PEER_ID_INVALID,400,An invalid Peer was used. Make sure to pass the right peer type 190 | PEER_ID_NOT_SUPPORTED,400,The provided peer ID is not supported 191 | PERSISTENT_TIMESTAMP_EMPTY,400,Persistent timestamp empty 192 | PERSISTENT_TIMESTAMP_INVALID,400,Persistent timestamp invalid 193 | PERSISTENT_TIMESTAMP_OUTDATED,500,Persistent timestamp outdated 194 | PHONE_CODE_EMPTY,400,The phone code is missing 195 | PHONE_CODE_EXPIRED,400,The confirmation code has expired 196 | PHONE_CODE_HASH_EMPTY,,The phone code hash is missing 197 | PHONE_CODE_INVALID,400,The phone code entered was invalid 198 | PHONE_MIGRATE_X,303,The phone number a user is trying to use for authorization is associated with DC {new_dc} 199 | PHONE_NUMBER_APP_SIGNUP_FORBIDDEN,400, 200 | PHONE_NUMBER_BANNED,400,The used phone number has been banned from Telegram and cannot be used anymore. Maybe check https://www.telegram.org/faq_spam 201 | PHONE_NUMBER_FLOOD,400,You asked for the code too many times. 202 | PHONE_NUMBER_INVALID,400 406,The phone number is invalid 203 | PHONE_NUMBER_OCCUPIED,400,The phone number is already in use 204 | PHONE_NUMBER_UNOCCUPIED,400,The phone number is not yet being used 205 | PHONE_PASSWORD_FLOOD,406,You have tried logging in too many times 206 | PHONE_PASSWORD_PROTECTED,400,This phone is password protected 207 | PHOTO_CONTENT_URL_EMPTY,400,The content from the URL used as a photo appears to be empty or has caused another HTTP error 208 | PHOTO_CROP_SIZE_SMALL,400,Photo is too small 209 | PHOTO_EXT_INVALID,400,The extension of the photo is invalid 210 | PHOTO_INVALID,400,Photo invalid 211 | PHOTO_INVALID_DIMENSIONS,400,The photo dimensions are invalid (hint: `pip install pillow` for `send_file` to resize images) 212 | PHOTO_SAVE_FILE_INVALID,400,The photo you tried to send cannot be saved by Telegram. A reason may be that it exceeds 10MB. Try resizing it locally 213 | PHOTO_THUMB_URL_EMPTY,400,The URL used as a thumbnail appears to be empty or has caused another HTTP error 214 | PIN_RESTRICTED,400,You can't pin messages in private chats with other people 215 | POLL_ANSWERS_INVALID,400,The poll did not have enough answers or had too many 216 | POLL_OPTION_DUPLICATE,400,A duplicate option was sent in the same poll 217 | POLL_OPTION_INVALID,400,A poll option used invalid data (the data may be too long) 218 | POLL_QUESTION_INVALID,400,The poll question was either empty or too long 219 | POLL_UNSUPPORTED,400,This layer does not support polls in the issued method 220 | PRIVACY_KEY_INVALID,400,The privacy key is invalid 221 | PTS_CHANGE_EMPTY,500,No PTS change 222 | QUERY_ID_EMPTY,400,The query ID is empty 223 | QUERY_ID_INVALID,400,The query ID is invalid 224 | QUERY_TOO_SHORT,400,The query string is too short 225 | QUIZ_CORRECT_ANSWERS_EMPTY,400,A quiz must specify one correct answer 226 | QUIZ_CORRECT_ANSWERS_TOO_MUCH,400,There can only be one correct answer 227 | QUIZ_CORRECT_ANSWER_INVALID,400,The correct answer is not an existing answer 228 | QUIZ_MULTIPLE_INVALID,400,A poll cannot be both multiple choice and quiz 229 | RANDOM_ID_DUPLICATE,500,You provided a random ID that was already used 230 | RANDOM_ID_INVALID,400,A provided random ID is invalid 231 | RANDOM_LENGTH_INVALID,400,Random length invalid 232 | RANGES_INVALID,400,Invalid range provided 233 | REACTION_EMPTY,400,No reaction provided 234 | REACTION_INVALID,400,Invalid reaction provided (only emoji are allowed) 235 | REG_ID_GENERATE_FAILED,500,Failure while generating registration ID 236 | REPLY_MARKUP_INVALID,400,The provided reply markup is invalid 237 | REPLY_MARKUP_TOO_LONG,400,The data embedded in the reply markup buttons was too much 238 | RESULT_ID_DUPLICATE,400,Duplicated IDs on the sent results. Make sure to use unique IDs. 239 | RESULT_TYPE_INVALID,400,Result type invalid 240 | RESULTS_TOO_MUCH,400,You sent too many results. See https://core.telegram.org/bots/api#answerinlinequery for the current limit. 241 | RIGHT_FORBIDDEN,403,Either your admin rights do not allow you to do this or you passed the wrong rights combination (some rights only apply to channels and vice versa) 242 | RPC_CALL_FAIL,,"Telegram is having internal issues, please try again later." 243 | RPC_MCGET_FAIL,,"Telegram is having internal issues, please try again later." 244 | RSA_DECRYPT_FAILED,400,Internal RSA decryption failed 245 | SCHEDULE_BOT_NOT_ALLOWED,400,Bots are not allowed to schedule messages 246 | SCHEDULE_DATE_TOO_LATE,400,The date you tried to schedule is too far in the future (last known limit of 1 year and a few hours) 247 | SCHEDULE_STATUS_PRIVATE,400,You cannot schedule a message until the person comes online if their privacy does not show this information 248 | SCHEDULE_TOO_MUCH,400,You cannot schedule more messages in this chat (last known limit of 100 per chat) 249 | SEARCH_QUERY_EMPTY,400,The search query is empty 250 | SECONDS_INVALID,400,"Slow mode only supports certain values (e.g. 0, 10s, 30s, 1m, 5m, 15m and 1h)" 251 | SEND_MESSAGE_MEDIA_INVALID,400,The message media was invalid or not specified 252 | SEND_MESSAGE_TYPE_INVALID,400,The message type is invalid 253 | SESSION_EXPIRED,401,The authorization has expired 254 | SESSION_PASSWORD_NEEDED,401,Two-steps verification is enabled and a password is required 255 | SESSION_REVOKED,401,"The authorization has been invalidated, because of the user terminating all sessions" 256 | SHA256_HASH_INVALID,400,The provided SHA256 hash is invalid 257 | SHORTNAME_OCCUPY_FAILED,400,An error occurred when trying to register the short-name used for the sticker pack. Try a different name 258 | SLOWMODE_WAIT_X,420,A wait of {seconds} seconds is required before sending another message in this chat 259 | START_PARAM_EMPTY,400,The start parameter is empty 260 | START_PARAM_INVALID,400,Start parameter invalid 261 | STATS_MIGRATE_X,303,The channel statistics must be fetched from DC {dc} 262 | STICKERSET_INVALID,400,The provided sticker set is invalid 263 | STICKERS_EMPTY,400,No sticker provided 264 | STICKER_EMOJI_INVALID,400,Sticker emoji invalid 265 | STICKER_FILE_INVALID,400,Sticker file invalid 266 | STICKER_ID_INVALID,400,The provided sticker ID is invalid 267 | STICKER_INVALID,400,The provided sticker is invalid 268 | STICKER_PNG_DIMENSIONS,400,Sticker png dimensions invalid 269 | STICKER_PNG_NOPNG,400,Stickers must be a png file but the used image was not a png 270 | STORAGE_CHECK_FAILED,500,Server storage check failed 271 | STORE_INVALID_SCALAR_TYPE,500, 272 | TAKEOUT_INIT_DELAY_X,420,A wait of {seconds} seconds is required before being able to initiate the takeout 273 | TAKEOUT_INVALID,400,The takeout session has been invalidated by another data export session 274 | TAKEOUT_REQUIRED,400,You must initialize a takeout request first 275 | TEMP_AUTH_KEY_EMPTY,400,No temporary auth key provided 276 | Timeout,-503,A timeout occurred while fetching data from the worker 277 | TMP_PASSWORD_DISABLED,400,The temporary password is disabled 278 | TOKEN_INVALID,400,The provided token is invalid 279 | TTL_DAYS_INVALID,400,The provided TTL is invalid 280 | TYPES_EMPTY,400,The types field is empty 281 | TYPE_CONSTRUCTOR_INVALID,,The type constructor is invalid 282 | UNKNOWN_METHOD,500,The method you tried to call cannot be called on non-CDN DCs 283 | UNTIL_DATE_INVALID,400,That date cannot be specified in this request (try using None) 284 | URL_INVALID,400,The URL used was invalid (e.g. when answering a callback with an URL that's not t.me/yourbot or your game's URL) 285 | USERNAME_INVALID,400,"Nobody is using this username, or the username is unacceptable. If the latter, it must match r""[a-zA-Z][\w\d]{3,30}[a-zA-Z\d]""" 286 | USERNAME_NOT_MODIFIED,400,The username is not different from the current username 287 | USERNAME_NOT_OCCUPIED,400,The username is not in use by anyone else yet 288 | USERNAME_OCCUPIED,400,The username is already taken 289 | USERS_TOO_FEW,400,"Not enough users (to create a chat, for example)" 290 | USERS_TOO_MUCH,400,"The maximum number of users has been exceeded (to create a chat, for example)" 291 | USER_ADMIN_INVALID,400,Either you're not an admin or you tried to ban an admin that you didn't promote 292 | USER_ALREADY_PARTICIPANT,400,The authenticated user is already a participant of the chat 293 | USER_BANNED_IN_CHANNEL,400,You're banned from sending messages in supergroups/channels 294 | USER_BLOCKED,400,User blocked 295 | USER_BOT,400,Bots can only be admins in channels. 296 | USER_BOT_INVALID,400 403,This method can only be called by a bot 297 | USER_BOT_REQUIRED,400,This method can only be called by a bot 298 | USER_CHANNELS_TOO_MUCH,403,One of the users you tried to add is already in too many channels/supergroups 299 | USER_CREATOR,400,"You can't leave this channel, because you're its creator" 300 | USER_DEACTIVATED,401,The user has been deleted/deactivated 301 | USER_DEACTIVATED_BAN,401,The user has been deleted/deactivated 302 | USER_ID_INVALID,400,"Invalid object ID for a user. Make sure to pass the right types, for instance making sure that the request is designed for users or otherwise look for a different one more suited" 303 | USER_INVALID,400,The given user was invalid 304 | USER_IS_BLOCKED,400 403,User is blocked 305 | USER_IS_BOT,400,Bots can't send messages to other bots 306 | USER_KICKED,400,This user was kicked from this supergroup/channel 307 | USER_MIGRATE_X,303,The user whose identity is being used to execute queries is associated with DC {new_dc} 308 | USER_NOT_MUTUAL_CONTACT,400 403,The provided user is not a mutual contact 309 | USER_NOT_PARTICIPANT,400,The target user is not a member of the specified megagroup or channel 310 | USER_PRIVACY_RESTRICTED,403,The user's privacy settings do not allow you to do this 311 | USER_RESTRICTED,403,"You're spamreported, you can't create channels or chats." 312 | VIDEO_CONTENT_TYPE_INVALID,400,The video content type is not supported with the given parameters (i.e. supports_streaming) 313 | WALLPAPER_FILE_INVALID,400,The given file cannot be used as a wallpaper 314 | WALLPAPER_INVALID,400,The input wallpaper was not valid 315 | WC_CONVERT_URL_INVALID,400,WC convert URL invalid 316 | WEBDOCUMENT_URL_INVALID,400,The given URL cannot be used 317 | WEBPAGE_CURL_FAILED,400,Failure while fetching the webpage with cURL 318 | WEBPAGE_MEDIA_EMPTY,400,Webpage media empty 319 | WORKER_BUSY_TOO_LONG_RETRY,500,Telegram workers are too busy to respond immediately 320 | YOU_BLOCKED_USER,400,You blocked this user 321 | --------------------------------------------------------------------------------