max_bot_api 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,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MaxBotApi
4
+ module Builders
5
+ # Builder for message payloads.
6
+ class MessageBuilder
7
+ attr_reader :user_id, :chat_id, :reset
8
+
9
+ # Create a builder from an existing hash payload.
10
+ # @param hash [Hash]
11
+ # @return [MessageBuilder]
12
+ def self.from_hash(hash)
13
+ builder = new
14
+ return builder if hash.nil?
15
+
16
+ builder.set_user(hash[:user_id] || hash['user_id']) if hash.key?(:user_id) || hash.key?('user_id')
17
+ builder.set_chat(hash[:chat_id] || hash['chat_id']) if hash.key?(:chat_id) || hash.key?('chat_id')
18
+ builder.set_reset(hash[:reset] || hash['reset']) if hash.key?(:reset) || hash.key?('reset')
19
+
20
+ payload = hash[:message] || hash['message'] || hash
21
+ builder.apply_payload(payload)
22
+ builder
23
+ end
24
+
25
+ # Initialize a new builder with empty payload.
26
+ def initialize
27
+ @user_id = nil
28
+ @chat_id = nil
29
+ @reset = false
30
+ @message = {
31
+ attachments: []
32
+ }
33
+ end
34
+
35
+ # Set recipient user ID.
36
+ def set_user(user_id)
37
+ @user_id = user_id
38
+ self
39
+ end
40
+
41
+ # Set recipient chat ID.
42
+ def set_chat(chat_id)
43
+ @chat_id = chat_id
44
+ self
45
+ end
46
+
47
+ # Toggle reset mode (skip Authorization header).
48
+ def set_reset(reset)
49
+ @reset = !!reset
50
+ self
51
+ end
52
+
53
+ # Set message text.
54
+ def set_text(text)
55
+ @message[:text] = text
56
+ self
57
+ end
58
+
59
+ # Set message format (markdown/html).
60
+ def set_format(format)
61
+ @message[:format] = format
62
+ self
63
+ end
64
+
65
+ # Toggle notification flag.
66
+ def set_notify(notify)
67
+ @message[:notify] = notify
68
+ self
69
+ end
70
+
71
+ # Set reply with explicit message ID.
72
+ def set_reply(text, message_id)
73
+ @message[:text] = text
74
+ @message[:link] = { type: 'reply', mid: message_id }
75
+ self
76
+ end
77
+
78
+ # Reply to a message hash with inferred recipient.
79
+ def reply(text, reply_message)
80
+ recipient = reply_message[:recipient] || reply_message['recipient'] || {}
81
+ set_user(recipient[:user_id] || recipient['user_id']) if recipient[:user_id] || recipient['user_id']
82
+ set_chat(recipient[:chat_id] || recipient['chat_id']) if recipient[:chat_id] || recipient['chat_id']
83
+
84
+ body = reply_message[:body] || reply_message['body'] || {}
85
+ @message[:text] = text
86
+ @message[:link] = { type: 'reply', mid: body[:mid] || body['mid'] }
87
+ self
88
+ end
89
+
90
+ # Add user mention markup.
91
+ def add_markup(user_id, from, length)
92
+ @message[:markup] ||= []
93
+ @message[:markup] << { user_id: user_id, from: from, length: length, type: 'user_mention' }
94
+ self
95
+ end
96
+
97
+ # Attach a keyboard.
98
+ def add_keyboard(keyboard)
99
+ payload = keyboard.is_a?(Builders::KeyboardBuilder) ? keyboard.build : keyboard
100
+ add_attachment(type: 'inline_keyboard', payload: payload)
101
+ end
102
+
103
+ # Attach a photo payload.
104
+ def add_photo(photo_tokens)
105
+ payload = photo_tokens.is_a?(Hash) ? photo_tokens : { photos: photo_tokens }
106
+ add_attachment(type: 'image', payload: { photos: payload[:photos] || payload['photos'] })
107
+ end
108
+
109
+ # Attach audio payload.
110
+ def add_audio(uploaded_info)
111
+ add_attachment(type: 'audio', payload: uploaded_info)
112
+ end
113
+
114
+ # Attach video payload.
115
+ def add_video(uploaded_info)
116
+ add_attachment(type: 'video', payload: uploaded_info)
117
+ end
118
+
119
+ # Attach file payload.
120
+ def add_file(uploaded_info)
121
+ add_attachment(type: 'file', payload: uploaded_info)
122
+ end
123
+
124
+ # Attach a location.
125
+ def add_location(lat, lon)
126
+ add_attachment(type: 'location', latitude: lat, longitude: lon)
127
+ end
128
+
129
+ # Attach a contact card.
130
+ def add_contact(name:, contact_id:, vcf_info: nil, vcf_phone: nil)
131
+ payload = {
132
+ name: name,
133
+ contact_id: contact_id,
134
+ vcf_info: vcf_info,
135
+ vcf_phone: vcf_phone
136
+ }.compact
137
+ add_attachment(type: 'contact', payload: payload)
138
+ end
139
+
140
+ # Attach a sticker.
141
+ def add_sticker(code)
142
+ add_attachment(type: 'sticker', payload: { code: code })
143
+ end
144
+
145
+ # Set bot token for reset mode.
146
+ def set_bot_token(token)
147
+ @message[:bot_token] = token
148
+ self
149
+ end
150
+
151
+ # Set phone numbers for notify/exists.
152
+ def set_phone_numbers(numbers)
153
+ @message[:phone_numbers] = Array(numbers)
154
+ self
155
+ end
156
+
157
+ # Return bot token used for reset mode.
158
+ def bot_token
159
+ @message[:bot_token]
160
+ end
161
+
162
+ # Return phone numbers used for notify/exists.
163
+ def phone_numbers
164
+ @message[:phone_numbers]
165
+ end
166
+
167
+ # Whether reset mode is enabled.
168
+ def reset?
169
+ @reset
170
+ end
171
+
172
+ # Return the message payload hash.
173
+ def to_h
174
+ @message
175
+ end
176
+
177
+ protected
178
+
179
+ def apply_payload(payload)
180
+ return if payload.nil?
181
+
182
+ payload.each do |key, value|
183
+ @message[key.to_sym] = value
184
+ end
185
+ end
186
+
187
+ private
188
+
189
+ def add_attachment(attachment)
190
+ @message[:attachments] ||= []
191
+ @message[:attachments] << attachment
192
+ self
193
+ end
194
+ end
195
+ end
196
+ end
@@ -0,0 +1,225 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+
5
+ module MaxBotApi
6
+ # Main API client. Holds auth config and provides resource accessors.
7
+ class Client
8
+ # Default API base URL.
9
+ DEFAULT_BASE_URL = 'https://botapi.max.ru/'
10
+ # Default API version appended as query param.
11
+ DEFAULT_VERSION = '1.2.5'
12
+ # Default pause between update polling loops.
13
+ DEFAULT_PAUSE = 1
14
+ # Default limit for updates requests.
15
+ DEFAULT_UPDATES_LIMIT = 50
16
+ # Max retry attempts for update polling.
17
+ MAX_RETRIES = 3
18
+
19
+ # @return [String] bot token
20
+ # @return [String] base URL
21
+ # @return [String] API version
22
+ attr_reader :token, :base_url, :version
23
+
24
+ # @param token [String] bot token
25
+ # @param base_url [String] API base URL
26
+ # @param version [String] API version
27
+ # @param faraday [Faraday::Connection, nil] custom Faraday connection
28
+ # @param adapter [Symbol] Faraday adapter
29
+ def initialize(token:, base_url: DEFAULT_BASE_URL, version: DEFAULT_VERSION, faraday: nil,
30
+ adapter: Faraday.default_adapter)
31
+ raise EmptyTokenError, 'bot token is empty' if token.to_s.empty?
32
+
33
+ @token = token
34
+ @base_url = normalize_base_url(base_url)
35
+ @version = version.to_s.empty? ? DEFAULT_VERSION : version.to_s
36
+
37
+ @conn = faraday || Faraday.new(url: @base_url) do |f|
38
+ f.request :multipart
39
+ f.request :url_encoded
40
+ f.adapter adapter
41
+ end
42
+ end
43
+
44
+ def bots
45
+ Resources::Bots.new(self)
46
+ end
47
+
48
+ def chats
49
+ Resources::Chats.new(self)
50
+ end
51
+
52
+ def messages
53
+ Resources::Messages.new(self)
54
+ end
55
+
56
+ def subscriptions
57
+ Resources::Subscriptions.new(self)
58
+ end
59
+
60
+ def uploads
61
+ Resources::Uploads.new(self)
62
+ end
63
+
64
+ # Build a debug sender bound to a chat.
65
+ # @param chat_id [Integer]
66
+ def debugs(chat_id: 0)
67
+ Resources::Debugs.new(self, chat_id: chat_id)
68
+ end
69
+
70
+ # Fetch updates from the API.
71
+ # @param limit [Integer, nil]
72
+ # @param timeout [Integer, nil]
73
+ # @param marker [Integer, nil]
74
+ # @param types [Array<String>, nil]
75
+ # @param debug [Boolean]
76
+ def get_updates(limit: nil, timeout: nil, marker: nil, types: nil, debug: false)
77
+ query = {}
78
+ query['limit'] = limit if limit && limit.to_i > 0
79
+ query['timeout'] = timeout.to_i if timeout && timeout.to_i > 0
80
+ query['marker'] = marker if marker && marker.to_i > 0
81
+ Array(types).each { |t| (query['types'] ||= []) << t }
82
+
83
+ result = request(:get, 'updates', query: query)
84
+ Updates::Parser.parse_update_list(result, debug: debug)
85
+ rescue TimeoutError
86
+ { updates: [], marker: nil }
87
+ end
88
+
89
+ # Fetch updates with retry/backoff.
90
+ def get_updates_with_retry(limit: nil, timeout: nil, marker: nil, types: nil, debug: false)
91
+ last_error = nil
92
+
93
+ MAX_RETRIES.times do |attempt|
94
+ return get_updates(limit: limit, timeout: timeout, marker: marker, types: types, debug: debug)
95
+ rescue Error => e
96
+ last_error = e
97
+ raise e if attempt == MAX_RETRIES - 1
98
+
99
+ sleep(2**attempt)
100
+ end
101
+
102
+ raise last_error
103
+ end
104
+
105
+ # Returns an enumerator that yields updates indefinitely.
106
+ def updates_enum(pause: DEFAULT_PAUSE, limit: DEFAULT_UPDATES_LIMIT, timeout: nil, types: nil, debug: false)
107
+ Enumerator.new do |yielder|
108
+ marker = nil
109
+ loop do
110
+ updates_list = get_updates_with_retry(limit: limit, timeout: timeout, marker: marker, types: types,
111
+ debug: debug)
112
+ updates = Array(updates_list[:updates])
113
+
114
+ updates.each { |update| yielder << update }
115
+ marker = updates_list[:marker] if updates_list[:marker]
116
+
117
+ sleep(pause)
118
+ end
119
+ end
120
+ end
121
+
122
+ # Iterates over updates, yielding each update hash.
123
+ def each_update(pause: DEFAULT_PAUSE, limit: DEFAULT_UPDATES_LIMIT, timeout: nil, types: nil, debug: false, &block)
124
+ return updates_enum(pause: pause, limit: limit, timeout: timeout, types: types, debug: debug) unless block
125
+
126
+ updates_enum(pause: pause, limit: limit, timeout: timeout, types: types, debug: debug).each(&block)
127
+ end
128
+
129
+ # Parses a single webhook payload into an update hash.
130
+ def parse_webhook(body, debug: false)
131
+ Updates::Parser.parse_update(body.to_s, debug: debug)
132
+ end
133
+
134
+ # Perform an HTTP request.
135
+ # @param method [Symbol]
136
+ # @param path [String]
137
+ # @param query [Hash]
138
+ # @param body [Hash, Array, String, nil]
139
+ # @param headers [Hash]
140
+ # @param reset [Boolean]
141
+ def request(method, path, query: nil, body: nil, headers: {}, reset: false)
142
+ query = (query || {}).dup
143
+ query['v'] = version
144
+
145
+ response = @conn.public_send(method) do |req|
146
+ req.url(path.to_s.sub(%r{\A/}, ''))
147
+ req.params.update(query) unless query.empty?
148
+ req.headers['User-Agent'] = "max-bot-api-client-ruby/#{VERSION}"
149
+ req.headers['Authorization'] = token unless reset
150
+ headers.each { |k, v| req.headers[k] = v }
151
+
152
+ if body
153
+ if body.is_a?(Hash) || body.is_a?(Array)
154
+ if multipart_body?(body)
155
+ req.body = body
156
+ else
157
+ req.headers['Content-Type'] ||= 'application/json'
158
+ req.body = JSON.generate(body)
159
+ end
160
+ else
161
+ req.body = body
162
+ end
163
+ end
164
+ end
165
+
166
+ handle_response(response)
167
+ rescue Faraday::TimeoutError => e
168
+ raise TimeoutError.new(op: "#{method.to_s.upcase} #{path}", reason: e.message)
169
+ rescue Faraday::ConnectionFailed, Faraday::SSLError, Faraday::Error => e
170
+ raise NetworkError.new(op: "#{method.to_s.upcase} #{path}", original_error: e)
171
+ rescue JSON::GeneratorError => e
172
+ raise SerializationError.new(op: 'marshal', type: 'request body', original_error: e)
173
+ end
174
+
175
+ def http_client
176
+ @conn
177
+ end
178
+
179
+ private
180
+
181
+ def normalize_base_url(url)
182
+ uri = URI.parse(url.to_s)
183
+ raise InvalidUrlError, 'invalid API URL' if uri.scheme.nil? || uri.host.nil?
184
+
185
+ normalized = uri.to_s
186
+ normalized.end_with?('/') ? normalized : "#{normalized}/"
187
+ rescue URI::InvalidURIError => e
188
+ raise InvalidUrlError, "invalid API URL: #{e.message}"
189
+ end
190
+
191
+ def multipart_body?(body)
192
+ body.values.any? { |value| value.is_a?(Faraday::Multipart::FilePart) }
193
+ end
194
+
195
+ def handle_response(response)
196
+ return parse_body(response) if response.status == 200
197
+
198
+ api_message = parse_error_message(response)
199
+ raise ApiError.new(code: response.status, message: api_message)
200
+ end
201
+
202
+ def parse_body(response)
203
+ content_type = response.headers['content-type'].to_s
204
+ return response.body unless content_type.include?('application/json')
205
+
206
+ body = response.body.to_s
207
+ return nil if body.empty?
208
+
209
+ JSON.parse(body, symbolize_names: true)
210
+ end
211
+
212
+ def parse_error_message(response)
213
+ body = response.body.to_s
214
+ return response.reason_phrase.to_s if body.empty?
215
+
216
+ json = JSON.parse(body)
217
+ return json['error'] if json.is_a?(Hash) && json['error']
218
+ return json['message'] if json.is_a?(Hash) && json['message']
219
+
220
+ response.reason_phrase.to_s
221
+ rescue JSON::ParserError
222
+ response.reason_phrase.to_s
223
+ end
224
+ end
225
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MaxBotApi
4
+ # Base error for all library exceptions.
5
+ class Error < StandardError; end
6
+
7
+ # Raised when the bot token is missing.
8
+ class EmptyTokenError < Error; end
9
+ # Raised when the base URL is invalid.
10
+ class InvalidUrlError < Error; end
11
+
12
+ # Raised when the API responds with a non-200 status code.
13
+ class ApiError < Error
14
+ attr_reader :code, :message, :details
15
+
16
+ # @param code [Integer] HTTP status code
17
+ # @param message [String] error message
18
+ # @param details [String, nil] optional details
19
+ def initialize(code:, message:, details: nil)
20
+ @code = code
21
+ @message = message
22
+ @details = details
23
+ super(build_message)
24
+ end
25
+
26
+ def ==(other)
27
+ other.is_a?(ApiError) && other.code == code
28
+ end
29
+
30
+ private
31
+
32
+ def build_message
33
+ return "API error #{code}: #{message} (#{details})" if details && !details.empty?
34
+
35
+ "API error #{code}: #{message}"
36
+ end
37
+ end
38
+
39
+ # Raised when the HTTP request fails before reaching the API.
40
+ class NetworkError < Error
41
+ attr_reader :op, :original_error
42
+
43
+ # @param op [String] operation name
44
+ # @param original_error [Exception] original error
45
+ def initialize(op:, original_error:)
46
+ @op = op
47
+ @original_error = original_error
48
+ super("network error during #{op}: #{original_error}")
49
+ end
50
+ end
51
+
52
+ # Raised when the request times out.
53
+ class TimeoutError < Error
54
+ attr_reader :op, :reason
55
+
56
+ # @param op [String] operation name
57
+ # @param reason [String, nil] timeout reason
58
+ def initialize(op:, reason: nil)
59
+ @op = op
60
+ @reason = reason
61
+ super(build_message)
62
+ end
63
+
64
+ def timeout?
65
+ true
66
+ end
67
+
68
+ private
69
+
70
+ def build_message
71
+ return "timeout error during #{op}: #{reason}" if reason && !reason.empty?
72
+
73
+ "timeout error during #{op}"
74
+ end
75
+ end
76
+
77
+ # Raised when serialization or parsing fails.
78
+ class SerializationError < Error
79
+ attr_reader :op, :type, :original_error
80
+
81
+ # @param op [String] operation name
82
+ # @param type [String] serialized object type
83
+ # @param original_error [Exception] original error
84
+ def initialize(op:, type:, original_error:)
85
+ @op = op
86
+ @type = type
87
+ @original_error = original_error
88
+ super("serialization error during #{op} of #{type}: #{original_error}")
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MaxBotApi
4
+ module Resources
5
+ # Bots API methods.
6
+ class Bots
7
+ def initialize(client)
8
+ @client = client
9
+ end
10
+
11
+ # Get current bot info.
12
+ def get_bot
13
+ @client.request(:get, 'me')
14
+ end
15
+
16
+ # Patch current bot info.
17
+ # @param patch [Hash]
18
+ def patch_bot(patch)
19
+ @client.request(:patch, 'me', body: patch)
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MaxBotApi
4
+ module Resources
5
+ # Chats API methods.
6
+ class Chats
7
+ def initialize(client)
8
+ @client = client
9
+ end
10
+
11
+ # List chats with pagination.
12
+ # @param count [Integer, nil]
13
+ # @param marker [Integer, nil]
14
+ def get_chats(count: nil, marker: nil)
15
+ query = {}
16
+ query['count'] = count if count && count.to_i > 0
17
+ query['marker'] = marker if marker && marker.to_i > 0
18
+ @client.request(:get, 'chats', query: query)
19
+ end
20
+
21
+ # Fetch a single chat.
22
+ # @param chat_id [Integer]
23
+ def get_chat(chat_id:)
24
+ @client.request(:get, "chats/#{chat_id}")
25
+ end
26
+
27
+ # Fetch current bot membership info.
28
+ # @param chat_id [Integer]
29
+ def get_chat_membership(chat_id:)
30
+ @client.request(:get, "chats/#{chat_id}/members/me")
31
+ end
32
+
33
+ # List chat members.
34
+ # @param chat_id [Integer]
35
+ def get_chat_members(chat_id:, count: nil, marker: nil)
36
+ query = {}
37
+ query['count'] = count if count && count.to_i > 0
38
+ query['marker'] = marker unless marker.nil?
39
+ @client.request(:get, "chats/#{chat_id}/members", query: query)
40
+ end
41
+
42
+ # Fetch specific members by user IDs.
43
+ # @param chat_id [Integer]
44
+ # @param user_ids [Array<Integer>]
45
+ def get_specific_chat_members(chat_id:, user_ids:)
46
+ ids = Array(user_ids).map(&:to_s).join(',')
47
+ @client.request(:get, "chats/#{chat_id}/members", query: { 'user_ids' => ids })
48
+ end
49
+
50
+ # List chat admins.
51
+ # @param chat_id [Integer]
52
+ def get_chat_admins(chat_id:)
53
+ @client.request(:get, "chats/#{chat_id}/members/admins")
54
+ end
55
+
56
+ # Leave a chat.
57
+ # @param chat_id [Integer]
58
+ def leave_chat(chat_id:)
59
+ @client.request(:delete, "chats/#{chat_id}/members/me")
60
+ end
61
+
62
+ # Patch chat info.
63
+ # @param chat_id [Integer]
64
+ # @param update [Hash]
65
+ def edit_chat(chat_id:, update:)
66
+ @client.request(:patch, "chats/#{chat_id}", body: update)
67
+ end
68
+
69
+ # Add members to chat.
70
+ # @param chat_id [Integer]
71
+ # @param users [Hash]
72
+ def add_member(chat_id:, users:)
73
+ @client.request(:post, "chats/#{chat_id}/members", body: users)
74
+ end
75
+
76
+ # Remove a member from chat.
77
+ # @param chat_id [Integer]
78
+ # @param user_id [Integer]
79
+ def remove_member(chat_id:, user_id:)
80
+ @client.request(:delete, "chats/#{chat_id}/members", query: { 'user_id' => user_id })
81
+ end
82
+
83
+ # Send a chat action.
84
+ # @param chat_id [Integer]
85
+ # @param action [String]
86
+ def send_action(chat_id:, action:)
87
+ @client.request(:post, "chats/#{chat_id}/actions", body: { action: action })
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MaxBotApi
4
+ module Resources
5
+ # Debug helper for sending raw updates/errors to a chat.
6
+ class Debugs
7
+ def initialize(client, chat_id: 0)
8
+ @client = client
9
+ @chat_id = chat_id
10
+ end
11
+
12
+ # Send raw update debug text to the configured chat.
13
+ # @param update [Hash]
14
+ def send(update)
15
+ message = Builders::MessageBuilder.new
16
+ .set_chat(@chat_id)
17
+ .set_text(update[:debug_raw].to_s)
18
+
19
+ @client.request(:post, 'messages', query: { 'chat_id' => @chat_id }, body: message.to_h)
20
+ true
21
+ end
22
+
23
+ # Send an error message to the configured chat.
24
+ # @param error [Exception, String]
25
+ def send_err(error)
26
+ message = Builders::MessageBuilder.new
27
+ .set_chat(@chat_id)
28
+ .set_text(error.to_s)
29
+
30
+ @client.request(:post, 'messages', query: { 'chat_id' => @chat_id }, body: message.to_h)
31
+ true
32
+ end
33
+ end
34
+ end
35
+ end