From 5b9706ed47994eb823b71b5de5a75c1bbee04f7b Mon Sep 17 00:00:00 2001 From: Max Melentiev Date: Wed, 8 Sep 2021 20:05:31 +0100 Subject: [PATCH] Fix sending File objects in nested objects in sendMediaGroup --- CHANGELOG.md | 1 + lib/telegram/bot/client.rb | 15 ++--- .../bot/client/request_body_formatter.rb | 48 ++++++++++++++ .../bot/client/request_body_formatter_spec.rb | 62 +++++++++++++++++++ spec/telegram/bot/client_spec.rb | 19 ------ 5 files changed, 115 insertions(+), 30 deletions(-) create mode 100644 lib/telegram/bot/client/request_body_formatter.rb create mode 100644 spec/telegram/bot/client/request_body_formatter_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index ba7d441..a6c0c4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Unreleased - Rails 7.0 support. +- Fix sending File objects in nested objects in sendMediaGroup. # 0.15.4 diff --git a/lib/telegram/bot/client.rb b/lib/telegram/bot/client.rb index fcff19c..3edb706 100644 --- a/lib/telegram/bot/client.rb +++ b/lib/telegram/bot/client.rb @@ -1,5 +1,4 @@ require 'active_support/core_ext/hash/keys' -require 'json' require 'httpclient' module Telegram @@ -8,7 +7,9 @@ module Telegram SERVER = 'https://api.telegram.org'.freeze URL_TEMPLATE = '%s/bot%s/'.freeze + autoload :RequestBodyFormatter, 'telegram/bot/client/request_body_formatter' autoload :TypedResponse, 'telegram/bot/client/typed_response' + prepend Async include DebugClient @@ -35,16 +36,8 @@ module Telegram prepend TypedResponse end - # Encodes nested hashes as json. - def prepare_body(body) - body = body.dup - body.each do |k, val| - body[k] = val.to_json if val.is_a?(Hash) || val.is_a?(Array) - end - end - def prepare_async_args(action, body = {}) - [action.to_s, Async.prepare_hash(prepare_body(body))] + [action.to_s, Async.prepare_hash(RequestBodyFormatter.format(body))] end def error_for_response(response) @@ -70,7 +63,7 @@ module Telegram end def request(action, body = {}) - response = http_request("#{base_uri}#{action}", self.class.prepare_body(body)) + response = http_request("#{base_uri}#{action}", RequestBodyFormatter.format(body)) raise self.class.error_for_response(response) if response.status >= 300 JSON.parse(response.body) end diff --git a/lib/telegram/bot/client/request_body_formatter.rb b/lib/telegram/bot/client/request_body_formatter.rb new file mode 100644 index 0000000..6c55c35 --- /dev/null +++ b/lib/telegram/bot/client/request_body_formatter.rb @@ -0,0 +1,48 @@ +require 'json' + +module Telegram + module Bot + class Client + # Encodes nested hashes and arrays as json and extract File objects from them + # to the top level. Top-level File objects are handled by httpclient. + # More details: https://core.telegram.org/bots/api#sending-files + module RequestBodyFormatter + extend self + + def format(body, action) + body = body.dup + if action.to_s == 'sendMediaGroup' + body = extract_files_from_array!(body, :media) + end + body.each do |key, val| + body[key] = val.to_json if val.is_a?(Hash) || val.is_a?(Array) + end + end + + private + + def extract_files_from_array!(body, field_name) + field_name = [field_name.to_sym, field_name.to_s].find { |x| body.key?(x) } + return body unless field_name && body[field_name].is_a?(Array) + files = {} + body[field_name] = body[field_name].map { |x| extract_files_from_hash(x, files) } + body.merge!(files) + end + + # Replace File objects with `attach` URIs. File objects are added into `files` hash. + def extract_files_from_hash(hash, files) + return hash unless hash.is_a?(Hash) + hash.transform_values do |value| + if value.is_a?(File) + arg_name = "_file#{files.size}" + files[arg_name] = value + "attach://#{arg_name}" + else + value + end + end + end + end + end + end +end diff --git a/spec/telegram/bot/client/request_body_formatter_spec.rb b/spec/telegram/bot/client/request_body_formatter_spec.rb new file mode 100644 index 0000000..3a89811 --- /dev/null +++ b/spec/telegram/bot/client/request_body_formatter_spec.rb @@ -0,0 +1,62 @@ +RSpec.describe Telegram::Bot::Client::RequestBodyFormatter do + describe '.format' do + subject { described_class.format(input, action) } + let(:action) { :sendMessage } + + context 'when plain hash is given' do + let(:input) { {a: 1, b: '2', c: nil} } + it { should eq input } + end + + context 'when nested hash is given' do + let(:input) { {a: 1, b: '2', c: [1, 2], d: {a: 1}, e: {b: []}} } + + it 'encodes nested hashes to json' do + expected = input.dup + %i[c d e].each { |x| expected[x] = expected[x].to_json } + should eq expected + end + end + + context 'with sendMediaGroup action' do + let(:action) { :sendMediaGroup } + let(:input) { {media: [{a: file_1}, {'b' => file_2, c: 123}, {d: 456}], x: 789} } + let(:file_1) { File.new(__FILE__) } + let(:file_2) { File.new(__FILE__) } + + it 'extracts files to the top-level' do + should eq( + media: [ + {a: 'attach://_file0'}, + {b: 'attach://_file1', c: 123}, + {d: 456}, + ].to_json, + x: 789, + '_file0' => file_1, + '_file1' => file_2, + ) + end + + context 'and input has string keys' do + let(:input) { super().stringify_keys } + it 'extracts files to the top-level' do + should eq( + 'media' => [ + {a: 'attach://_file0'}, + {b: 'attach://_file1', c: 123}, + {d: 456}, + ].to_json, + 'x' => 789, + '_file0' => file_1, + '_file1' => file_2, + ) + end + end + + context 'without media' do + let(:input) { {a: 1, b: '2', c: nil} } + it { should eq input } + end + end + end +end diff --git a/spec/telegram/bot/client_spec.rb b/spec/telegram/bot/client_spec.rb index ab9983c..cca549d 100644 --- a/spec/telegram/bot/client_spec.rb +++ b/spec/telegram/bot/client_spec.rb @@ -71,25 +71,6 @@ RSpec.describe Telegram::Bot::Client do end end - describe '.prepare_body' do - subject { described_class.prepare_body(input) } - - context 'when plain hash is given' do - let(:input) { {a: 1, b: '2', c: nil} } - it { should eq input } - end - - context 'when nested hash is given' do - let(:input) { {a: 1, b: '2', c: [1, 2], d: {a: 1}, e: {b: []}} } - - it 'encodes nested hashes to json' do - expected = input.dup - %i[c d e].each { |x| expected[x] = expected[x].to_json } - should eq expected - end - end - end - describe '.prepare_async_args' do subject { described_class.prepare_async_args(*input) } let(:input) { [:action, a: 1, b: :sym, c: [:other], 'd' => 'str'] }