diff --git a/.gitignore b/.gitignore index 9f74d68..aced27e 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,7 @@ mkmf.log *.iml *.ipr *.iws +.ruby-version +.ruby-gemset +.rvmrc +.versions.conf \ No newline at end of file diff --git a/README.md b/README.md index 26f7dec..e5e2d9e 100644 --- a/README.md +++ b/README.md @@ -28,18 +28,18 @@ require 'telegram_bot' bot = TelegramBot.new(token: '[YOUR TELEGRAM BOT TOKEN GOES HERE]') bot.get_updates(fail_silently: true) do |message| - puts "@#{message.from.username}: #{message.text}" - command = message.get_command_for(bot) + msg_text = message.text + puts "@#{message.from.username}: #{msg_text}" message.reply do |reply| - case command + case msg_text when /greet/i - reply.text = "Hello, #{message.from.first_name}!" + reply[:text] = "Hello, #{message.from.first_name}!" else - reply.text = "#{message.from.first_name}, have no idea what #{command.inspect} means." + reply[:text] = "#{message.from.first_name}, have no idea what #{msg_text.inspect} means." end - puts "sending #{reply.text.inspect} to @#{message.from.username}" - reply.send_with(bot) + puts "sending #{reply[:text].inspect} to @#{message.from.username}" + bot.send_message(**reply) end end ``` @@ -96,37 +96,33 @@ message.from.first_name # "Homer" message.from.last_name # "Simpson" message.from.username # "mr_x" -# channel -message.channel.id # 123123123 (telegram's id) +# chat +message.chat.id # 123123123 (telegram's id) # reply message.reply do |reply| - reply.text = "homer please clean the garage" - reply.send_with(bot) + reply[:text] = "homer please clean the garage" + bot.send_message(**reply) end # or reply = message.reply -reply.text = "i'll do it after going to moe's" -bot.send_message(reply) +reply[:text] = "i'll do it after going to moe's" +bot.send_message(**reply) ``` -To send message to specific channel you could do following: +To send message to specific chat you could do following: ```ruby bot = TelegramBot.new(token: '[YOUR TELEGRAM BOT TOKEN GOES HERE]') -channel = TelegramBot::Channel.new(id: channel_id) -message = TelegramBot::OutMessage.new -message.chat = channel -message.text = 'Some message' - -message.send_with(bot) +message = {chat_id: chat_id, text: 'Some message'} +bot.send_message(**message) ``` Also you may pass additional options described in [API Docs](https://core.telegram.org/bots/api#sendmessage) ```ruby -message.parse_mode = 'Markdown' +message[:parse_mode] = 'Markdown' ``` ## Contributing diff --git a/example/bot.rb b/example/bot.rb index a3eed17..a2e611d 100644 --- a/example/bot.rb +++ b/example/bot.rb @@ -8,15 +8,15 @@ logger.debug "starting telegram bot" bot.get_updates(fail_silently: true) do |message| - logger.info "@#{message.from.username}: #{message.text}" - command = message.get_command_for(bot) + msg_text = message.text + logger.info "@#{message.from.username}: #{msg_text}" message.reply do |reply| - case command + case msg_text when /greet/i reply.text = "Hello, #{message.from.first_name}!" else - reply.text = "#{message.from.first_name}, have no idea what #{command.inspect} means." + reply.text = "#{message.from.first_name}, have no idea what #{msg_text.inspect} means." end logger.info "sending #{reply.text.inspect} to @#{message.from.username}" reply.send_with(bot) diff --git a/fixtures/vcr_cassettes/get_me.yml b/fixtures/vcr_cassettes/get_me.yml index 59b25a8..1cea15f 100644 --- a/fixtures/vcr_cassettes/get_me.yml +++ b/fixtures/vcr_cassettes/get_me.yml @@ -1,7 +1,7 @@ --- http_interactions: - request: - method: post + method: get uri: https://api.telegram.org/botTEST_BOT_API_TOKEN/getMe body: encoding: US-ASCII diff --git a/fixtures/vcr_cassettes/integration_test.yml b/fixtures/vcr_cassettes/integration_test.yml index 83237fa..56200a0 100644 --- a/fixtures/vcr_cassettes/integration_test.yml +++ b/fixtures/vcr_cassettes/integration_test.yml @@ -1,7 +1,7 @@ --- http_interactions: - request: - method: post + method: get uri: https://api.telegram.org/botTEST_BOT_API_TOKEN/getUpdates?offset=0 body: encoding: US-ASCII @@ -44,10 +44,10 @@ http_interactions: recorded_at: Mon, 06 Aug 2018 22:25:44 GMT - request: method: post - uri: https://api.telegram.org/botTEST_BOT_API_TOKEN/sendMessage?chat_id=5678&text=Hello%2C+Jos%C3%A9%21 + uri: https://api.telegram.org/botTEST_BOT_API_TOKEN/sendMessage body: encoding: US-ASCII - string: '' + string: 'chat_id=5678&text=Hello%2C+Jos%C3%A9%21' headers: User-Agent: - excon/0.61.0 @@ -81,7 +81,7 @@ http_interactions: http_version: recorded_at: Mon, 06 Aug 2018 22:25:44 GMT - request: - method: post + method: get uri: https://api.telegram.org/botTEST_BOT_API_TOKEN/getMe body: encoding: US-ASCII diff --git a/lib/telegram_bot.rb b/lib/telegram_bot.rb index 6e9b937..b4daac2 100644 --- a/lib/telegram_bot.rb +++ b/lib/telegram_bot.rb @@ -2,34 +2,28 @@ require 'virtus' require 'json' -require "telegram_bot/version" -require "telegram_bot/result" -require "telegram_bot/null_logger" -require "telegram_bot/user" -require "telegram_bot/group_chat" -require "telegram_bot/channel" -require "telegram_bot/message" -require "telegram_bot/keyboard" -require "telegram_bot/reply_keyboard_hide" -require "telegram_bot/reply_keyboard_markup" -require "telegram_bot/force_replay" -require "telegram_bot/out_message" -require "telegram_bot/update" -require "telegram_bot/api_response" -require "telegram_bot/bot" -require "telegram_bot/client" - - module TelegramBot - def self.new(opts) - # compatibility with just passing a token - if opts.is_a?(String) - opts = { token: opts } - end - - opts[:logger] ||= NullLogger.new - opts[:client] ||= Client.new(token: opts.fetch(:token), logger: opts[:logger], proxy: opts[:proxy]) + { + Version: "version", + Result: "result", + NullLogger: "null_logger", + User: "user", + Chat: "chat", + MessageEntity: "message_entity", + Message: "message", + Keyboard: "keyboard", + ReplyKeyboardHide: "reply_keyboard_hide", + ReplyKeyboardMarkup: "reply_keyboard_markup", + ForceReplay: "force_replay", + Update: "update", + ApiResponse: "api_response", + Bot: "bot", + Connection: "connection", + }.each do |key, val| + autoload(key, "telegram_bot/#{val}") + end + def self.new(opts) Bot.new(opts) end end diff --git a/lib/telegram_bot/bot.rb b/lib/telegram_bot/bot.rb index 875d589..473eff7 100644 --- a/lib/telegram_bot/bot.rb +++ b/lib/telegram_bot/bot.rb @@ -17,18 +17,24 @@ def to_h end class Bot + ENDPOINT = 'https://api.telegram.org/' + attr_reader :connection + def initialize(opts = {}) + # compatibility with just passing a token + opts = {token: opts} if opts.is_a?(String) + + @token = opts.fetch(:token) + @base_path = "/bot#{@token}" @offset = opts[:offset] || 0 - @logger = opts.fetch(:logger) - @client = opts.fetch(:client) + @logger = opts[:logger] || NullLogger.new + @connection = Connection.new(ENDPOINT, persistent: true, proxy: opts[:proxy]) end def get_me - @me ||= @client - .request(:getMe) - .and_then do |result| - User.new(result) - end + @me ||= @connection + .get(path: "#{@base_path}/getMe") + .and_then { |result| User.new(result) } .value! end alias_method :me, :get_me @@ -41,6 +47,7 @@ def get_updates(opts = {}, &block) logger.info "starting get_updates loop" loop do messages = get_last_messages(opts) + opts[:offset] = @offset messages.compact.each do |message| next unless message logger.info "message from @#{message.chat.friendly_name}: #{message.text.inspect}" @@ -49,20 +56,27 @@ def get_updates(opts = {}, &block) end end - def send_message(out_message) - logger.info "sending message: #{out_message.text.inspect} to #{out_message.chat_friendly_name}" - @client - .request(:sendMessage, out_message) - .and_then do |result| - Message.new(result) - end - .value! + # send_message(chat_id:, text:, parse_mode: nil, disable_web_page_preview: nil, **kwargs) + def send_message(*args, **kwargs) + chat_id = kwargs.fetch(:chat_id) { args.fetch(0) } + text = kwargs.fetch(:text) { args.fetch(1) } + parse_mode = kwargs.fetch(:parse_mode) { args[2] } + disable_web_page_preview = kwargs.fetch(:disable_web_page_preview) { args[3] } + + logger.info "sending message: #{text.inspect}" + data = {text: text, chat_id: chat_id} + data[:parse_mode] = parse_mode unless parse_mode.nil? + data[:disable_web_page_preview] = disable_web_page_preview unless disable_web_page_preview.nil? + + args.shift(4) + args.unshift("#{@base_path}/sendMessage", data) + Message.new(post_message(*args, **kwargs)) end def set_webhook(url, allowed_updates: %i(message)) logger.info "setting webhook url to #{url}, allowed_updates: #{allowed_updates}" - request = WebhookRequest.new(url: url, allowed_updates: allowed_updates) - @client.request(:setWebhook, request) + webhook_request = WebhookRequest.new(url: url, allowed_updates: allowed_updates) + post_message(path: "#{@base_path}/setWebhook", data: webhook_request.to_h) end def remove_webhook @@ -70,23 +84,49 @@ def remove_webhook end private - attr_reader :logger - - def get_last_updates(opts = {}) - opts[:offset] ||= @offset - request = UpdatesRequest.new(opts) - response = @client.request(:getUpdates, request) - if opts[:fail_silently] && !response.ok? - logger.warn "error when getting updates. ignoring due to fail_silently." - return [] + attr_reader :logger + + def get_last_updates(opts = {}) + opts[:offset] ||= @offset + updates_request = UpdatesRequest.new(opts) + path = "#{@base_path}/getUpdates" + response = @connection.get(path: path, query: updates_request.to_h) + if opts[:fail_silently] && !response.ok? + logger.warn "error when getting updates. ignoring due to fail_silently." + return [] + end + updates = response.value!.compact.map { |raw_update| Update.new(raw_update) } + @offset = updates.last.id + 1 if updates.any? + updates end - updates = response.value!.compact.map{|raw_update| Update.new(raw_update) } - @offset = updates.last.id + 1 if updates.any? - updates - end - def get_last_messages(opts = {}) - get_last_updates(opts).map(&:message) - end + def get_last_messages(opts = {}) + get_last_updates(opts).map(&:get_message) + end + + # post_message(path:, data: {}, disable_notification: nil, reply_to_message_id: nil, content_type: nil) + def post_message(*args, **kwargs) + path = kwargs.fetch(:path) { args.fetch(0) } + data = kwargs.fetch(:data) { args.fetch(1, {}) } + disable_notification = kwargs.fetch(:disable_notification) { args[2] } + reply_to_message_id = kwargs.fetch(:reply_to_message_id) { args[3] } + content_type = kwargs.fetch(:content_type) { args[4] } + + data[:disable_notification] = disable_notification unless disable_notification.nil? + data[:reply_to_message_id] = reply_to_message_id unless reply_to_message_id.nil? + + if content_type.nil? + content_type = "application/x-www-form-urlencoded" + data = URI.encode_www_form(data) + else + content_type = content_type.downcase + end + + if content_type == "application/json" + data = JSON.dump(data) + end + + @connection.post(path: path, body: data, headers: {"Content-Type" => content_type}).value! + end end end diff --git a/lib/telegram_bot/channel.rb b/lib/telegram_bot/channel.rb deleted file mode 100644 index d4baf89..0000000 --- a/lib/telegram_bot/channel.rb +++ /dev/null @@ -1,12 +0,0 @@ -module TelegramBot - class Channel - include Virtus.model - attribute :id, Integer - attribute :username, String - attribute :title, String - - def friendly_name - username ? "@#{username}" : "channel #{title.inspect}" - end - end -end diff --git a/lib/telegram_bot/chat.rb b/lib/telegram_bot/chat.rb new file mode 100644 index 0000000..29b38e9 --- /dev/null +++ b/lib/telegram_bot/chat.rb @@ -0,0 +1,48 @@ +module TelegramBot + class Chat + include Virtus.model + + PRIVATE = "private" + GROUP = "group" + SUPERGROUP = "supergroup" + CHANNEL = "channel" + CHAT_TYPES = [PRIVATE, GROUP, SUPERGROUP, CHANNEL] + + attribute :id, Integer + attribute :type, String + attribute :title, String + attribute :username, String + attribute :first_name, String + attribute :last_name, String + attribute :all_members_are_administrators, Boolean + attribute :description, String + attribute :invite_link, String + attribute :pinned_message, Message + attribute :sticker_set_name, String + attribute :can_set_sticker_set, Boolean + + def friendly_name + username ? "@#{username}" : "chat #{title.inspect}" + end + + def is_type?(chat_type) + type == chat_type + end + + CHAT_TYPES.each do |chat_type| + class_eval <<-DEF, __FILE__, __LINE__ + 1 + def is_#{chat_type}? + is_type?("#{chat_type}") + end + DEF + end + + # send_message(bot:, text:, parse_mode: nil, disable_web_page_preview: nil, **kwargs) + def send_message(*args, **kwargs) + bot = kwargs.fetch(:bot) { args.fetch(0) } + args[0] = id + kwargs[:chat_id] = id + bot.send_message(*args, **kwargs) + end + end +end diff --git a/lib/telegram_bot/client.rb b/lib/telegram_bot/client.rb deleted file mode 100644 index 821164a..0000000 --- a/lib/telegram_bot/client.rb +++ /dev/null @@ -1,22 +0,0 @@ -module TelegramBot - class Client - ENDPOINT = 'https://api.telegram.org/' - - def initialize(token:, logger:, proxy: nil) - @token = token - @logger = logger - @proxy = proxy - @connection = Excon.new(ENDPOINT, persistent: true, proxy: @proxy) - end - - def request(action, query = {}) - path = "/bot#{@token}/#{action}" - res = @connection.post(path: path, query: query.to_h) - ApiResponse.from_excon(res) - end - - private - attr_reader :token - attr_reader :logger - end -end diff --git a/lib/telegram_bot/connection.rb b/lib/telegram_bot/connection.rb new file mode 100644 index 0000000..9f824b5 --- /dev/null +++ b/lib/telegram_bot/connection.rb @@ -0,0 +1,20 @@ +module TelegramBot + class Connection + attr_reader :connection + + def initialize(url, params = {}) + @connection = Excon.new(url, params) + end + + def request(params = {}, &block) + response = @connection.request(params, &block) + ApiResponse.from_excon(response) + end + + Excon::HTTP_VERBS.each do |method_name| + define_method(method_name) do |params = {}, &block| + request(params.merge(method: method_name), &block) + end + end + end +end diff --git a/lib/telegram_bot/group_chat.rb b/lib/telegram_bot/group_chat.rb deleted file mode 100644 index 9f742fa..0000000 --- a/lib/telegram_bot/group_chat.rb +++ /dev/null @@ -1,8 +0,0 @@ -module TelegramBot - class GroupChat - include Virtus.model - attribute :id, Integer - alias_method :to_i, :id - attribute :title, String - end -end diff --git a/lib/telegram_bot/message.rb b/lib/telegram_bot/message.rb index 078edd6..296d7fd 100644 --- a/lib/telegram_bot/message.rb +++ b/lib/telegram_bot/message.rb @@ -6,19 +6,61 @@ class Message alias_method :to_i, :id attribute :from, User alias_method :user, :from - attribute :text, String attribute :date, DateTime - attribute :chat, Channel + attribute :chat, Chat + attribute :forward_from, User + attribute :forward_from_chat, Chat + attribute :forward_from_message_id, Integer + attribute :forward_signature, String + attribute :forward_date, DateTime attribute :reply_to_message, Message + attribute :edit_date, DateTime + attribute :media_group_id, String + attribute :author_signature, String + attribute :text, String + attribute :entities, Array[MessageEntity] + attribute :caption_entities, Array[MessageEntity] + attribute :caption, String + attribute :new_chat_members, Array[User] + attribute :left_chat_member, User + attribute :new_chat_title, String + attribute :delete_chat_photo, Boolean, default: false + attribute :group_chat_created, Boolean, default: false + attribute :supergroup_chat_created, Boolean, default: false + attribute :channel_chat_created, Boolean, default: false + attribute :migrate_to_chat_id, Integer + attribute :migrate_from_chat_id, Integer + attribute :pinned_message, Message + attribute :connected_website, String - def reply(&block) - reply = OutMessage.new(chat: chat) + def reply + reply = {chat_id: chat.id, reply_to_message_id: message_id} yield reply if block_given? reply end - def get_command_for(bot) - text && text.sub(Regexp.new("@#{bot.identity.username}($|\s|\.|,)", Regexp::IGNORECASE), '').strip + def all_entities + (entities || []) + (caption_entities || []) + end + + MessageEntity::ENTITY_TYPES.each do |method_name| + class_eval <<-DEF, __FILE__, __LINE__ + 1 + def get_#{method_name}s(return_entities: false, only_#{method_name}: false) + return [] unless all_entities.any? + list_entities = all_entities.select(&:is_#{method_name}?) + return list_entities if return_entities + list_entities.map { |entity| entity.get_#{method_name}(self, only_#{method_name}: only_#{method_name}) } + end + DEF + + class_eval <<-DEF, __FILE__, __LINE__ + 1 + def get_#{method_name}(return_entity: false, only_#{method_name}: false) + return nil unless all_entities.any? + entity = all_entities.find(&:is_#{method_name}?) + return entity if return_entity || entity.nil? + entity.get_#{method_name}(self, only_#{method_name}: only_#{method_name}) + end + DEF end end end diff --git a/lib/telegram_bot/message_entity.rb b/lib/telegram_bot/message_entity.rb new file mode 100644 index 0000000..41647e5 --- /dev/null +++ b/lib/telegram_bot/message_entity.rb @@ -0,0 +1,50 @@ +module TelegramBot + class MessageEntity + include Virtus.model + + MENTION = "mention" + HASHTAG = "hashtag" + CASHTAG = "cashtag" + BOT_COMMAND = "bot_command" + URL = "url" + EMAIL = "email" + PHONE_NUMBER = "phone_number" + BOLD = "bold" + ITALIC = "italic" + CODE = "code" + PRE = "pre" + TEXT_LINK = "text_link" + TEXT_MENTION = "text_mention" + ENTITY_TYPES = [ + MENTION, HASHTAG, CASHTAG, BOT_COMMAND, URL, EMAIL, + PHONE_NUMBER, BOLD, ITALIC, CODE, PRE, TEXT_LINK, TEXT_MENTION + ] + + attribute :type, String + attribute :offset, Integer + attribute :length, Integer + attribute :url, String + attribute :user, User + + def is_type?(type_entity) + type == type_entity + end + + ENTITY_TYPES.each do |method_name| + class_eval <<-DEF, __FILE__, __LINE__ + 1 + def is_#{method_name}? + is_type?("#{method_name}") + end + DEF + + class_eval <<-DEF, __FILE__, __LINE__ + 1 + def get_#{method_name}(message, only_#{method_name}: false) + return nil unless is_#{method_name}? + limit = -1 + limit += offset + length if only_#{method_name} + message.text[offset..limit] + end + DEF + end + end +end \ No newline at end of file diff --git a/lib/telegram_bot/out_message.rb b/lib/telegram_bot/out_message.rb deleted file mode 100644 index e2e0591..0000000 --- a/lib/telegram_bot/out_message.rb +++ /dev/null @@ -1,33 +0,0 @@ -module TelegramBot - class OutMessage - include Virtus.model - attribute :chat, Channel - attribute :text, String - attribute :reply_to, Message - attribute :parse_mode, String - attribute :disable_web_page_preview, Boolean - attribute :reply_markup, Keyboard - - def send_with(bot) - bot.send_message(self) - end - - def chat_friendly_name - chat.friendly_name - end - - def to_h - message = { - text: text, - chat_id: chat.id - } - - message[:reply_to_message_id] = reply_to.id unless reply_to.nil? - message[:parse_mode] = parse_mode unless parse_mode.nil? - message[:disable_web_page_preview] = disable_web_page_preview unless disable_web_page_preview.nil? - message[:reply_markup] = reply_markup.to_h.to_json unless reply_markup.nil? - - message - end - end -end diff --git a/lib/telegram_bot/update.rb b/lib/telegram_bot/update.rb index 2d05b36..27eac5f 100644 --- a/lib/telegram_bot/update.rb +++ b/lib/telegram_bot/update.rb @@ -5,5 +5,12 @@ class Update alias_method :id, :update_id alias_method :to_i, :id attribute :message, Message + attribute :edited_message, Message + attribute :channel_post, Message + attribute :edited_channel_post, Message + + def get_message + message || edited_message || channel_post || edited_channel_post + end end end diff --git a/lib/telegram_bot/user.rb b/lib/telegram_bot/user.rb index 77ba935..d34dace 100644 --- a/lib/telegram_bot/user.rb +++ b/lib/telegram_bot/user.rb @@ -6,11 +6,17 @@ class User attribute :first_name, String attribute :last_name, String attribute :username, String + attribute :is_bot, Boolean + attribute :language_code, String def ==(other) other.is_a?(self.class) && other.hash == hash end + def full_name + @last_name ? "#{@first_name} #{@last_name}" : @first_name + end + def hash to_h.hash end diff --git a/spec/minitest_helper.rb b/spec/minitest_helper.rb index a67ce6f..3519efc 100644 --- a/spec/minitest_helper.rb +++ b/spec/minitest_helper.rb @@ -20,7 +20,8 @@ def fixture_bot_user TelegramBot::User.new( id: ENV['TEST_BOT_ID'], first_name: "telegram-bot-gem-test", - username: "gem_test_bot" + username: "gem_test_bot", + is_bot: true ) end end diff --git a/spec/telegram_bot_spec.rb b/spec/telegram_bot_spec.rb index e561684..cad7cc4 100644 --- a/spec/telegram_bot_spec.rb +++ b/spec/telegram_bot_spec.rb @@ -20,19 +20,29 @@ def test_integration bot = new_test_bot messages = bot.get_updates message = messages.first + entity = message.entities.first assert_equal "/start", message.text + assert_equal "/start", message.get_bot_command + assert_equal ["/start"], message.get_bot_commands + assert_nil message.get_mention + assert_equal true, message.chat.is_private? + assert_equal false, message.chat.is_group? answer = message.reply do |reply| - reply.text = "Hello, #{message.from.first_name}!" - result = reply.send_with(bot) + reply[:text] = "Hello, #{message.from.first_name}!" + result = message.chat.send_message(bot, **reply) assert_equal bot.get_me.id, result.from.id - assert_equal result.text, reply.text + assert_equal result.text, reply[:text] end - assert_equal message.from.id, answer.chat.id - assert_equal "Hello, José!", answer.text + assert !message.from.is_bot? + assert_includes ["enCA", nil], message.from.language_code + assert_equal message.from.id, answer[:chat_id] + assert_equal "Hello, José!", answer[:text] + + assert entity.is_bot_command? end end end