max_api_client 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.rubocop.yml +377 -0
- data/CHANGELOG.md +8 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +272 -0
- data/Rakefile +12 -0
- data/lib/max_api_client/api.rb +194 -0
- data/lib/max_api_client/attachments.rb +111 -0
- data/lib/max_api_client/base_api.rb +37 -0
- data/lib/max_api_client/client.rb +178 -0
- data/lib/max_api_client/error.rb +25 -0
- data/lib/max_api_client/polling.rb +95 -0
- data/lib/max_api_client/raw_api.rb +169 -0
- data/lib/max_api_client/upload.rb +147 -0
- data/lib/max_api_client/version.rb +6 -0
- data/lib/max_api_client.rb +23 -0
- data/sig/max_api_client.rbs +162 -0
- metadata +61 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MaxApiClient
|
|
4
|
+
# HTTP transport client responsible for authenticated API requests.
|
|
5
|
+
class Client
|
|
6
|
+
DEFAULT_BASE_URL = "https://platform-api.max.ru"
|
|
7
|
+
REQUEST_CLASSES = {
|
|
8
|
+
get: Net::HTTP::Get,
|
|
9
|
+
post: Net::HTTP::Post,
|
|
10
|
+
put: Net::HTTP::Put,
|
|
11
|
+
patch: Net::HTTP::Patch,
|
|
12
|
+
delete: Net::HTTP::Delete
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
attr_reader :token, :base_url
|
|
16
|
+
|
|
17
|
+
# rubocop:disable Metrics/ParameterLists
|
|
18
|
+
def initialize(token:, base_url: DEFAULT_BASE_URL, adapter: nil, open_timeout: nil, read_timeout: nil, logger: nil)
|
|
19
|
+
@token = token
|
|
20
|
+
@base_url = base_url
|
|
21
|
+
@adapter = adapter
|
|
22
|
+
@open_timeout = open_timeout
|
|
23
|
+
@read_timeout = read_timeout
|
|
24
|
+
@logger = logger || MaxApiClient.logger
|
|
25
|
+
end
|
|
26
|
+
# rubocop:enable Metrics/ParameterLists
|
|
27
|
+
|
|
28
|
+
# rubocop:disable Metrics/ParameterLists
|
|
29
|
+
def call(method:, path: nil, query: nil, body: nil, path_params: nil, headers: nil, url: nil, raw_body: nil,
|
|
30
|
+
parse_json: true, open_timeout: nil, read_timeout: nil)
|
|
31
|
+
request = build_request(
|
|
32
|
+
method:,
|
|
33
|
+
path:,
|
|
34
|
+
query:,
|
|
35
|
+
body:,
|
|
36
|
+
path_params:,
|
|
37
|
+
headers:,
|
|
38
|
+
url:,
|
|
39
|
+
raw_body:,
|
|
40
|
+
parse_json:,
|
|
41
|
+
open_timeout:,
|
|
42
|
+
read_timeout:
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
log_debug("max_api_client.request", loggable_request(request))
|
|
46
|
+
|
|
47
|
+
response = @adapter ? @adapter.call(request) : perform_request(request)
|
|
48
|
+
|
|
49
|
+
log_debug("max_api_client.response", loggable_response(response))
|
|
50
|
+
response
|
|
51
|
+
end
|
|
52
|
+
# rubocop:enable Metrics/ParameterLists
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
# rubocop:disable Metrics/ParameterLists
|
|
57
|
+
def build_request(method:, path:, query:, body:, path_params:, headers:, url:, raw_body:, parse_json:,
|
|
58
|
+
open_timeout:, read_timeout:)
|
|
59
|
+
{
|
|
60
|
+
method: method.to_sym,
|
|
61
|
+
url: build_url(path:, path_params:, query:, url:),
|
|
62
|
+
path: path,
|
|
63
|
+
path_params: path_params,
|
|
64
|
+
query: query,
|
|
65
|
+
body: body,
|
|
66
|
+
raw_body: raw_body,
|
|
67
|
+
headers: default_headers(headers, body:, raw_body:),
|
|
68
|
+
parse_json: parse_json,
|
|
69
|
+
open_timeout: open_timeout,
|
|
70
|
+
read_timeout: read_timeout
|
|
71
|
+
}
|
|
72
|
+
end
|
|
73
|
+
# rubocop:enable Metrics/ParameterLists
|
|
74
|
+
|
|
75
|
+
# rubocop:disable Metrics/AbcSize
|
|
76
|
+
def build_url(path:, path_params:, query:, url:)
|
|
77
|
+
uri = if url
|
|
78
|
+
URI(url)
|
|
79
|
+
else
|
|
80
|
+
URI.join(base_url.end_with?("/") ? base_url : "#{base_url}/", expand_path(path.to_s, path_params))
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
params = URI.decode_www_form(String(uri.query))
|
|
84
|
+
query.to_h.each do |key, value|
|
|
85
|
+
next if value.nil? || value == false
|
|
86
|
+
|
|
87
|
+
params << [key.to_s, value.to_s]
|
|
88
|
+
end
|
|
89
|
+
uri.query = params.empty? ? nil : URI.encode_www_form(params)
|
|
90
|
+
uri
|
|
91
|
+
end
|
|
92
|
+
# rubocop:enable Metrics/AbcSize
|
|
93
|
+
|
|
94
|
+
def expand_path(path, path_params)
|
|
95
|
+
path_params.to_h.each_with_object(path.dup) do |(key, value), expanded|
|
|
96
|
+
expanded.gsub!("{#{key}}", URI.encode_www_form_component(value.to_s))
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def default_headers(headers, body:, raw_body:)
|
|
101
|
+
{
|
|
102
|
+
"Authorization" => token.to_s
|
|
103
|
+
}.tap do |result|
|
|
104
|
+
result["Content-Type"] = "application/json" if body && raw_body.nil?
|
|
105
|
+
result.merge!(headers.to_h)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def perform_request(request)
|
|
110
|
+
uri = request.fetch(:url)
|
|
111
|
+
response = configured_http(
|
|
112
|
+
uri,
|
|
113
|
+
open_timeout: request[:open_timeout],
|
|
114
|
+
read_timeout: request[:read_timeout]
|
|
115
|
+
).request(build_http_request(request, uri))
|
|
116
|
+
|
|
117
|
+
{
|
|
118
|
+
status: response.code.to_i,
|
|
119
|
+
data: parse_response_body(response.body.to_s, parse_json: request[:parse_json]),
|
|
120
|
+
headers: response.to_hash
|
|
121
|
+
}
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def configured_http(uri, open_timeout:, read_timeout:)
|
|
125
|
+
Net::HTTP.new(uri.host, uri.port).tap do |http|
|
|
126
|
+
http.use_ssl = uri.scheme == "https"
|
|
127
|
+
http.open_timeout = open_timeout || @open_timeout if open_timeout || @open_timeout
|
|
128
|
+
http.read_timeout = read_timeout || @read_timeout if read_timeout || @read_timeout
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def build_http_request(request, uri)
|
|
133
|
+
REQUEST_CLASSES.fetch(request.fetch(:method)).new(uri).tap do |http_request|
|
|
134
|
+
request.fetch(:headers).each do |key, value|
|
|
135
|
+
http_request[key] = value
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
http_request.body = request_body(request)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def request_body(request)
|
|
143
|
+
return request[:raw_body] if request[:raw_body]
|
|
144
|
+
return JSON.generate(request[:body]) if request[:body]
|
|
145
|
+
|
|
146
|
+
nil
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def parse_response_body(body, parse_json:)
|
|
150
|
+
return body unless parse_json
|
|
151
|
+
return {} if body.empty?
|
|
152
|
+
|
|
153
|
+
JSON.parse(body)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def log_debug(message, payload)
|
|
157
|
+
@logger&.debug("#{message} #{payload.inspect}")
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def loggable_request(request)
|
|
161
|
+
request.merge(
|
|
162
|
+
headers: mask_headers(request[:headers]),
|
|
163
|
+
url: request[:url].to_s
|
|
164
|
+
)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def loggable_response(response)
|
|
168
|
+
response.merge(headers: mask_headers(response[:headers]))
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def mask_headers(headers)
|
|
172
|
+
headers.to_h.dup.tap do |result|
|
|
173
|
+
result["Authorization"] = "[FILTERED]" if result.key?("Authorization")
|
|
174
|
+
result["authorization"] = "[FILTERED]" if result.key?("authorization")
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MaxApiClient
|
|
4
|
+
# Base error type for gem-specific failures.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Error raised for non-successful API responses.
|
|
8
|
+
class ApiError < Error
|
|
9
|
+
attr_reader :status, :response
|
|
10
|
+
|
|
11
|
+
def initialize(status, response = {})
|
|
12
|
+
@status = status
|
|
13
|
+
@response = response || {}
|
|
14
|
+
super("#{status}: #{description}")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def code
|
|
18
|
+
response["code"] || response[:code]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def description
|
|
22
|
+
response["message"] || response[:message] || "Unknown API error"
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MaxApiClient
|
|
4
|
+
# Long-polling helper over GET /updates with marker tracking and retries.
|
|
5
|
+
class Polling
|
|
6
|
+
DEFAULT_TIMEOUT = 20
|
|
7
|
+
DEFAULT_RETRY_INTERVAL = 5
|
|
8
|
+
READ_TIMEOUT_PADDING = 5
|
|
9
|
+
RETRYABLE_ERRORS = [
|
|
10
|
+
Net::OpenTimeout,
|
|
11
|
+
Net::ReadTimeout,
|
|
12
|
+
EOFError,
|
|
13
|
+
IOError,
|
|
14
|
+
SocketError,
|
|
15
|
+
Errno::ECONNRESET
|
|
16
|
+
].freeze
|
|
17
|
+
|
|
18
|
+
# rubocop:disable Metrics/ParameterLists
|
|
19
|
+
def initialize(api, types: [], marker: nil, timeout: DEFAULT_TIMEOUT, retry_interval: DEFAULT_RETRY_INTERVAL,
|
|
20
|
+
read_timeout: nil)
|
|
21
|
+
@api = api
|
|
22
|
+
@types = types
|
|
23
|
+
@marker = marker
|
|
24
|
+
@timeout = timeout
|
|
25
|
+
@retry_interval = retry_interval
|
|
26
|
+
@read_timeout = read_timeout || (timeout.to_i + READ_TIMEOUT_PADDING)
|
|
27
|
+
@stopped = false
|
|
28
|
+
end
|
|
29
|
+
# rubocop:enable Metrics/ParameterLists
|
|
30
|
+
|
|
31
|
+
def each
|
|
32
|
+
return enum_for(:each) unless block_given?
|
|
33
|
+
|
|
34
|
+
until stopped?
|
|
35
|
+
begin
|
|
36
|
+
response = fetch_updates
|
|
37
|
+
@marker = fetch_value(response, :marker)
|
|
38
|
+
|
|
39
|
+
Array(fetch_value(response, :updates)).each do |update|
|
|
40
|
+
break if stopped?
|
|
41
|
+
|
|
42
|
+
yield update
|
|
43
|
+
end
|
|
44
|
+
rescue *RETRYABLE_ERRORS
|
|
45
|
+
retry_later
|
|
46
|
+
rescue ApiError => e
|
|
47
|
+
raise unless retryable_status?(e.status)
|
|
48
|
+
|
|
49
|
+
retry_later
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
self
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def stop
|
|
57
|
+
@stopped = true
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def stopped?
|
|
61
|
+
@stopped
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
attr_reader :api, :types, :marker, :timeout, :retry_interval, :read_timeout
|
|
67
|
+
|
|
68
|
+
def fetch_updates
|
|
69
|
+
api.raw.subscriptions.get_updates(
|
|
70
|
+
types: normalize_types(types),
|
|
71
|
+
marker:,
|
|
72
|
+
timeout:,
|
|
73
|
+
read_timeout:
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def retry_later
|
|
78
|
+
sleep(retry_interval)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def retryable_status?(status)
|
|
82
|
+
status == 429 || status >= 500
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def fetch_value(hash, key)
|
|
86
|
+
hash[key] || hash[key.to_s]
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def normalize_types(value)
|
|
90
|
+
return value.join(",") if value.is_a?(Array)
|
|
91
|
+
|
|
92
|
+
value
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MaxApiClient
|
|
4
|
+
# Low-level grouped access to Max Bot API endpoint families.
|
|
5
|
+
class RawApi < BaseApi
|
|
6
|
+
attr_reader :client
|
|
7
|
+
|
|
8
|
+
def bots
|
|
9
|
+
@bots ||= build_api(BotsApi)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def chats
|
|
13
|
+
@chats ||= build_api(ChatsApi)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def messages
|
|
17
|
+
@messages ||= build_api(MessagesApi)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def subscriptions
|
|
21
|
+
@subscriptions ||= build_api(SubscriptionsApi)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def uploads
|
|
25
|
+
@uploads ||= build_api(UploadsApi)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def build_api(klass)
|
|
31
|
+
klass.new(client)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Raw bot profile endpoints.
|
|
36
|
+
class BotsApi < BaseApi
|
|
37
|
+
# rubocop:disable Naming/AccessorMethodName
|
|
38
|
+
def get_my_info
|
|
39
|
+
get("me")
|
|
40
|
+
end
|
|
41
|
+
# rubocop:enable Naming/AccessorMethodName
|
|
42
|
+
|
|
43
|
+
def edit_my_info(**extra)
|
|
44
|
+
patch("me", body: extra)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Raw chat management endpoints.
|
|
49
|
+
class ChatsApi < BaseApi
|
|
50
|
+
def get_all(**extra)
|
|
51
|
+
get("chats", query: extra)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def get_by_id(chat_id:)
|
|
55
|
+
get("chats/{chat_id}", path_params: { chat_id: })
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def get_by_link(chat_link:)
|
|
59
|
+
get("chats/{chat_link}", path_params: { chat_link: })
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def edit(chat_id:, **extra)
|
|
63
|
+
patch("chats/{chat_id}", path_params: { chat_id: }, body: extra)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def get_chat_membership(chat_id:)
|
|
67
|
+
get("chats/{chat_id}/members/me", path_params: { chat_id: })
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def get_chat_admins(chat_id:)
|
|
71
|
+
get("chats/{chat_id}/members/admins", path_params: { chat_id: })
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def add_chat_members(chat_id:, user_ids:)
|
|
75
|
+
post("chats/{chat_id}/members", path_params: { chat_id: }, body: { user_ids: })
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def get_chat_members(chat_id:, **query)
|
|
79
|
+
get("chats/{chat_id}/members", path_params: { chat_id: }, query:)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def remove_chat_member(chat_id:, user_id:, block: nil)
|
|
83
|
+
delete("chats/{chat_id}/members", path_params: { chat_id: }, body: compact_nil(user_id:, block:))
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def get_pinned_message(chat_id:)
|
|
87
|
+
get("chats/{chat_id}/pin", path_params: { chat_id: })
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def pin_message(chat_id:, message_id:, notify: nil)
|
|
91
|
+
put("chats/{chat_id}/pin", path_params: { chat_id: }, body: compact_nil(message_id:, notify:))
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def unpin_message(chat_id:)
|
|
95
|
+
delete("chats/{chat_id}/pin", path_params: { chat_id: })
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def send_action(chat_id:, action:)
|
|
99
|
+
post("chats/{chat_id}/actions", path_params: { chat_id: }, body: { action: })
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def leave_chat(chat_id:)
|
|
103
|
+
delete("chats/{chat_id}/members/me", path_params: { chat_id: })
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Raw message delivery and mutation endpoints.
|
|
108
|
+
class MessagesApi < BaseApi
|
|
109
|
+
ATTACHMENT_NOT_READY_CODE = "attachment.not.ready"
|
|
110
|
+
ATTACHMENT_NOT_READY_DELAY = 1
|
|
111
|
+
|
|
112
|
+
def get(**query)
|
|
113
|
+
super("messages", query:)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def get_by_id(message_id:)
|
|
117
|
+
get("messages/{message_id}", path_params: { message_id: })
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def send(chat_id: nil, user_id: nil, disable_link_preview: nil, **body)
|
|
121
|
+
post("messages", query: compact_nil(chat_id:, user_id:, disable_link_preview:), body:)
|
|
122
|
+
rescue ApiError => e
|
|
123
|
+
raise unless e.code == ATTACHMENT_NOT_READY_CODE
|
|
124
|
+
|
|
125
|
+
sleep(ATTACHMENT_NOT_READY_DELAY)
|
|
126
|
+
send(chat_id:, user_id:, disable_link_preview:, **body)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def edit(message_id:, **body)
|
|
130
|
+
put("messages", query: { message_id: }, body:)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def delete(message_id:)
|
|
134
|
+
super("messages", query: { message_id: })
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def answer_on_callback(callback_id:, **body)
|
|
138
|
+
post("answers", query: { callback_id: }, body:)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Raw update subscription endpoints.
|
|
143
|
+
class SubscriptionsApi < BaseApi
|
|
144
|
+
# rubocop:disable Naming/AccessorMethodName
|
|
145
|
+
def get_subscriptions
|
|
146
|
+
get("subscriptions")
|
|
147
|
+
end
|
|
148
|
+
# rubocop:enable Naming/AccessorMethodName
|
|
149
|
+
|
|
150
|
+
def subscribe(url:, update_types: nil, secret: nil)
|
|
151
|
+
post("subscriptions", body: compact_nil(url:, update_types:, secret:))
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def unsubscribe(url:)
|
|
155
|
+
delete("subscriptions", query: { url: })
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def get_updates(read_timeout: nil, **query)
|
|
159
|
+
get("updates", query:, read_timeout:)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Raw upload URL acquisition endpoints.
|
|
164
|
+
class UploadsApi < BaseApi
|
|
165
|
+
def get_upload_url(type:)
|
|
166
|
+
post("uploads", query: { type: })
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MaxApiClient
|
|
4
|
+
# Upload helper that prepares files and sends them to upload endpoints.
|
|
5
|
+
class Upload
|
|
6
|
+
DEFAULT_TIMEOUT = 20
|
|
7
|
+
BINARY_HEADERS = {
|
|
8
|
+
"Content-Type" => "application/x-binary; charset=x-user-defined",
|
|
9
|
+
"X-Uploading-Mode" => "parallel",
|
|
10
|
+
"Connection" => "keep-alive"
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
def initialize(api)
|
|
14
|
+
@api = api
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def image(source: nil, url: nil, timeout: DEFAULT_TIMEOUT, filename: nil)
|
|
18
|
+
return { url: } if url
|
|
19
|
+
|
|
20
|
+
upload_from_source("image", source, timeout:, filename:)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def video(source:, timeout: DEFAULT_TIMEOUT, filename: nil)
|
|
24
|
+
upload_from_source("video", source, timeout:, filename:)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def audio(source:, timeout: DEFAULT_TIMEOUT, filename: nil)
|
|
28
|
+
upload_from_source("audio", source, timeout:, filename:)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def file(source:, timeout: DEFAULT_TIMEOUT, filename: nil)
|
|
32
|
+
upload_from_source("file", source, timeout:, filename:)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
attr_reader :api
|
|
38
|
+
|
|
39
|
+
def upload_from_source(type, source, timeout:, filename:)
|
|
40
|
+
upload(type, file_from_source(source, filename:), timeout:)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def upload(type, file, timeout:)
|
|
44
|
+
response = api.raw.uploads.get_upload_url(type:)
|
|
45
|
+
upload_url = fetch_value(response, :url)
|
|
46
|
+
token = fetch_value(response, :token)
|
|
47
|
+
|
|
48
|
+
return { token: }.tap { upload_binary(upload_url, file, timeout:) } if token
|
|
49
|
+
|
|
50
|
+
upload_multipart(upload_url, file, timeout:)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# rubocop:disable Metrics/AbcSize
|
|
54
|
+
def upload_binary(upload_url, file, timeout:)
|
|
55
|
+
headers = BINARY_HEADERS.merge(
|
|
56
|
+
"Content-Disposition" => %(attachment; filename="#{file[:filename]}"),
|
|
57
|
+
"Content-Range" => "bytes 0-#{file[:content].bytesize - 1}/#{file[:content].bytesize}",
|
|
58
|
+
"X-File-Name" => file[:filename]
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
result = api.client.call(
|
|
62
|
+
method: :post,
|
|
63
|
+
url: upload_url,
|
|
64
|
+
raw_body: file[:content],
|
|
65
|
+
headers: headers,
|
|
66
|
+
parse_json: false,
|
|
67
|
+
open_timeout: timeout,
|
|
68
|
+
read_timeout: timeout
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
raise ApiError.new(result[:status], { message: result[:data] }) if result[:status] >= 400
|
|
72
|
+
end
|
|
73
|
+
# rubocop:enable Metrics/AbcSize
|
|
74
|
+
|
|
75
|
+
def upload_multipart(upload_url, file, timeout:)
|
|
76
|
+
boundary = "----max-api-client-#{SecureRandom.hex(12)}"
|
|
77
|
+
body = build_multipart_body(boundary, file)
|
|
78
|
+
headers = {
|
|
79
|
+
"Content-Type" => "multipart/form-data; boundary=#{boundary}"
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
result = api.client.call(
|
|
83
|
+
method: :post,
|
|
84
|
+
url: upload_url,
|
|
85
|
+
raw_body: body,
|
|
86
|
+
headers: headers,
|
|
87
|
+
parse_json: true,
|
|
88
|
+
open_timeout: timeout,
|
|
89
|
+
read_timeout: timeout
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
raise ApiError.new(result[:status], result[:data]) if result[:status] >= 400
|
|
93
|
+
|
|
94
|
+
result[:data]
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def build_multipart_body(boundary, file)
|
|
98
|
+
[
|
|
99
|
+
"--#{boundary}\r\n",
|
|
100
|
+
%(Content-Disposition: form-data; name="data"; filename="#{file[:filename]}"\r\n),
|
|
101
|
+
"Content-Type: application/octet-stream\r\n\r\n",
|
|
102
|
+
file[:content],
|
|
103
|
+
"\r\n--#{boundary}--\r\n"
|
|
104
|
+
].join
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def file_from_source(source, filename: nil)
|
|
108
|
+
raise ArgumentError, "source is required" if source.nil?
|
|
109
|
+
|
|
110
|
+
return file_from_path(source, filename:) if source.is_a?(String) && File.file?(source)
|
|
111
|
+
return file_from_io(source, filename:) if source.respond_to?(:read)
|
|
112
|
+
|
|
113
|
+
raise ArgumentError, "source must be a file path or readable IO"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def file_from_path(source, filename:)
|
|
117
|
+
{
|
|
118
|
+
filename: filename || File.basename(source),
|
|
119
|
+
content: File.binread(source)
|
|
120
|
+
}
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def file_from_io(source, filename:)
|
|
124
|
+
current_pos = source.pos if source.respond_to?(:pos)
|
|
125
|
+
source.rewind if source.respond_to?(:rewind)
|
|
126
|
+
content = source.read
|
|
127
|
+
source.pos = current_pos if current_pos && source.respond_to?(:pos=)
|
|
128
|
+
|
|
129
|
+
{
|
|
130
|
+
filename: filename || io_filename(source),
|
|
131
|
+
content:
|
|
132
|
+
}
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def io_filename(source)
|
|
136
|
+
if source.respond_to?(:path) && source.path.is_a?(String)
|
|
137
|
+
File.basename(source.path)
|
|
138
|
+
else
|
|
139
|
+
SecureRandom.uuid
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def fetch_value(hash, key)
|
|
144
|
+
hash[key] || hash[key.to_s]
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
require "securerandom"
|
|
7
|
+
|
|
8
|
+
require_relative "max_api_client/version"
|
|
9
|
+
require_relative "max_api_client/error"
|
|
10
|
+
require_relative "max_api_client/client"
|
|
11
|
+
require_relative "max_api_client/base_api"
|
|
12
|
+
require_relative "max_api_client/raw_api"
|
|
13
|
+
require_relative "max_api_client/polling"
|
|
14
|
+
require_relative "max_api_client/attachments"
|
|
15
|
+
require_relative "max_api_client/upload"
|
|
16
|
+
require_relative "max_api_client/api"
|
|
17
|
+
|
|
18
|
+
# Root namespace for the Max Bot API Ruby client.
|
|
19
|
+
module MaxApiClient
|
|
20
|
+
class << self
|
|
21
|
+
attr_accessor :logger
|
|
22
|
+
end
|
|
23
|
+
end
|