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.
@@ -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,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Root namespace for gem version information.
4
+ module MaxApiClient
5
+ VERSION = "0.1.0"
6
+ 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