max_bot 0.2.0 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0c17ffd6dfbd11379193864cf5bfd412ddfa995de84b9e82fa17e547dc7d7225
4
- data.tar.gz: d831baa3f7bb717c2ad95acb41536c8925e43a62539ee6d784aa1c29cfc74254
3
+ metadata.gz: 55db0440db64b8a6755b400f5a30e811196f3a2e5d052d220932fcd0cbba21ff
4
+ data.tar.gz: 13196bf6a391f47391f3c7d2cd78e860ad4fd8c25300a7975f924f2a17332a36
5
5
  SHA512:
6
- metadata.gz: 8b5dbbc7d6f38b01206430efc691ace09c68be6d0ee1a81322816ba5c4dde280ac338ab655cc4fd5d5f114fbe58938331150550df7be83462cd7776efc8bc6da
7
- data.tar.gz: ed9cc5d5c132d6e4d8f22bc6bf7dbd16486ce4533bb659049053026a73725c54505f6de1bd1e23011b041a14939900d50cb75a126f46ad0d82f1b9575a312a1a
6
+ metadata.gz: dad1ce214df34da86bdb0e36a807d2202a72aced6f4af9f3fa91bfa1f8f0e13fc53fc225346aaa5e6058df8e357761e1bdf2b72a1866aee8391ccb92334d11f7
7
+ data.tar.gz: f02b42bacbe1c7e25498d2a16911506bf2a6a6f2e0eb160fdf2fea7300e471c26f62b43dea713c284042728bf191491ba69ffef71143694713164105af589700
data/CHANGELOG.md CHANGED
@@ -1,5 +1,61 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0
4
+
5
+ Changes in **0.3.0** compared to **0.2.0** are described below in **English** and **Russian**.
6
+
7
+ ### English
8
+
9
+ #### New API methods
10
+
11
+ - `api.me` — **GET /bots**: get bot info (useful for token validation).
12
+ - `api.chat(chat_id)` — **GET /chats/{chatId}**: get group chat information.
13
+ - `api.get_message(message_id)` — **GET /messages/{messageId}**: get a specific message.
14
+ - `api.edit_message(message_id, text, ...)` — **PUT /messages/{messageId}**: edit a message (supports text, attachments, format, link).
15
+ - `api.delete_message(message_id)` — **DELETE /messages/{messageId}**: delete a message.
16
+ - `api.answer_callback(callback_query_id, ...)` — **POST /messages/callback**: reply to a callback query from inline keyboard buttons (supports `text`, `show_alert`, `url`).
17
+
18
+ #### Attachments
19
+
20
+ - `Attachments.clipboard_button(text, payload)` — new button type: **clipboard** (copies payload to clipboard on tap).
21
+
22
+ #### Improvements & fixes
23
+
24
+ - `Webhook.secret_valid?` now uses `OpenSSL.fixed_length_secure_compare` for proper constant-time comparison (was a manual XOR loop).
25
+ - `Api.set_webhook` validates that `url` is a valid HTTP(S) URL.
26
+ - `MultipartUpload` error responses now wrap the body in `{ message: ... }` so `ApiError#to_s` displays the message.
27
+ - Added `Http#put` method for `PUT` requests.
28
+ - `Attachments.inline_keyboard` fixed: `buttons` now wraps rows in an array (was `buttons: rows`, now `buttons: [rows]`).
29
+ - 21 new tests (67 total, 153 assertions).
30
+
31
+ ---
32
+
33
+ ### Русский
34
+
35
+ #### Новые методы API
36
+
37
+ - `api.me` — **GET /bots**: информация о боте (удобно для проверки токена).
38
+ - `api.chat(chat_id)` — **GET /chats/{chatId}**: информация о групповом чате.
39
+ - `api.get_message(message_id)` — **GET /messages/{messageId}**: получить конкретное сообщение.
40
+ - `api.edit_message(message_id, text, ...)` — **PUT /messages/{messageId}**: редактировать сообщение (поддерживает текст, вложения, формат, ссылку).
41
+ - `api.delete_message(message_id)` — **DELETE /messages/{messageId}**: удалить сообщение.
42
+ - `api.answer_callback(callback_query_id, ...)` — **POST /messages/callback**: ответ на callback-запрос от inline-кнопки (поддерживает `text`, `show_alert`, `url`).
43
+
44
+ #### Вложения
45
+
46
+ - `Attachments.clipboard_button(text, payload)` — новый тип кнопки: **clipboard** (копирует payload в буфер обмена по нажатию).
47
+
48
+ #### Улучшения и исправления
49
+
50
+ - `Webhook.secret_valid?` теперь использует `OpenSSL.fixed_length_secure_compare` для корректного сравнения за постоянное время (раньше был ручной XOR-цикл).
51
+ - `Api.set_webhook` проверяет, что `url` — валидный HTTP(S) URL.
52
+ - Ошибки `MultipartUpload` теперь оборачивают тело в `{ message: ... }`, чтобы `ApiError#to_s` отображал сообщение.
53
+ - Добавлен метод `Http#put` для `PUT`-запросов.
54
+ - Исправлен `Attachments.inline_keyboard`: `buttons` теперь оборачивает строки в массив (было `buttons: rows`, стало `buttons: [rows]`).
55
+ - 21 новый тест (67 всего, 153 assertions).
56
+
57
+ ---
58
+
3
59
  ## 0.2.0
4
60
 
5
61
  Changes in **0.2.0** compared to **0.1.1** are described below in **English** and **Russian**.
data/README.md CHANGED
@@ -8,7 +8,7 @@ Ruby-клиент для Bot API мессенджера **[MAX](https://dev.max.
8
8
 
9
9
  ## Требования
10
10
 
11
- - Ruby **≥ 2.5**
11
+ - Ruby **≥ 3.2**
12
12
  - Токен бота в кабинете MAX (**Чат-боты → Интеграция → Получить токен**)
13
13
 
14
14
  ## Установка
@@ -16,7 +16,7 @@ Ruby-клиент для Bot API мессенджера **[MAX](https://dev.max.
16
16
  В `Gemfile`:
17
17
 
18
18
  ```ruby
19
- gem 'max_bot', '~> 0.2'
19
+ gem 'max_bot', '~> 0.3'
20
20
  ```
21
21
 
22
22
  Локально из этого репозитория:
@@ -116,6 +116,8 @@ api.send_message('_cursive_', chat_id: 123, format: 'markdown')
116
116
 
117
117
  Типизированные хелперы под API: **image**, **video**, **audio**, **file**, **sticker**, **contact**, **inline_keyboard**, **location**, **share**. Примеры клавиатуры — в [документации MAX](https://dev.max.ru/docs-api).
118
118
 
119
+ Кнопки: `callback_button`, `link_button`, `request_contact_button`, `request_geo_location_button`, `chat_button`, `message_button`, `clipboard_button`.
120
+
119
121
  ```ruby
120
122
  api.send_message(
121
123
  'Выберите',
@@ -194,11 +196,50 @@ api.send_media(type: :image, path: '/path/to/photo.jpg', text: 'Фото', chat_
194
196
  api.delete_webhook('https://your.domain/max/webhook')
195
197
  ```
196
198
 
199
+ ## Управление сообщениями и чатами
200
+
201
+ ```ruby
202
+ # Информация о боте (проверка токена)
203
+ bot = api.me
204
+ puts bot[:name] # => "My Bot"
205
+
206
+ # Информация о чате
207
+ chat = api.chat(chat_id)
208
+ puts chat[:name]
209
+
210
+ # Получить конкретное сообщение
211
+ msg = api.get_message(message_id)
212
+
213
+ # Редактировать сообщение
214
+ api.edit_message(message_id, 'Новый текст', format: 'markdown')
215
+
216
+ # Удалить сообщение
217
+ api.delete_message(message_id)
218
+
219
+ # Ответ на callback (inline-кнопки)
220
+ api.answer_callback(callback_query_id, text: 'Выбрано!', show_alert: true)
221
+
222
+ # Кнопка clipboard — копирует текст в буфер обмена
223
+ api.send_message(
224
+ 'Нажмите, чтобы скопировать',
225
+ chat_id: chat_id,
226
+ attachment: Max::Bot::Attachments.inline_keyboard([
227
+ [Max::Bot::Attachments.clipboard_button('Копировать', 'секретный_код')]
228
+ ])
229
+ )
230
+ ```
231
+
197
232
  ## Другие методы API
198
233
 
199
234
  | Метод | HTTP |
200
235
  |--------|------|
201
236
  | `api.chats` | [GET /chats](https://dev.max.ru/docs-api/methods/GET/chats) |
237
+ | `api.chat(chat_id)` | [GET /chats/{chatId}](https://dev.max.ru/docs-api/methods/GET/chats/%7BchatId%7D) |
238
+ | `api.me` | [GET /bots](https://dev.max.ru/docs-api/methods/GET/bots) |
239
+ | `api.get_message(message_id)` | [GET /messages/{messageId}](https://dev.max.ru/docs-api/methods/GET/messages/%7BmessageId%7D) |
240
+ | `api.edit_message(message_id, ...)` | [PUT /messages/{messageId}](https://dev.max.ru/docs-api/methods/PUT/messages/%7BmessageId%7D) |
241
+ | `api.delete_message(message_id)` | [DELETE /messages/{messageId}](https://dev.max.ru/docs-api/methods/DELETE/messages/%7BmessageId%7D) |
242
+ | `api.answer_callback(...)` | [POST /messages/callback](https://dev.max.ru/docs-api/methods/POST/messages/callback) |
202
243
  | `api.subscriptions` | [GET /subscriptions](https://dev.max.ru/docs-api/methods/GET/subscriptions) |
203
244
 
204
245
  ## Ошибки
@@ -10,7 +10,7 @@ require 'bundler/setup'
10
10
  require 'logger'
11
11
  require 'max_bot'
12
12
 
13
- token = ENV['MAX_BOT_TOKEN']
13
+ token = ENV.fetch('MAX_BOT_TOKEN', nil)
14
14
  abort 'Set MAX_BOT_TOKEN from the MAX platform (Chat bots → Integration → token).' if token.to_s.strip.empty?
15
15
 
16
16
  logger = Logger.new($stdout)
@@ -59,6 +59,14 @@ module Max
59
59
  query
60
60
  end
61
61
 
62
+ def callback_body(callback_query_id:, text:, show_alert:, url:)
63
+ body = { callback_query_id: callback_query_id }
64
+ body[:text] = text unless text.nil?
65
+ body[:show_alert] = show_alert unless show_alert.nil?
66
+ body[:url] = url unless url.nil?
67
+ body
68
+ end
69
+
62
70
  def media_attachment_from_upload(type, data)
63
71
  t = type.to_s
64
72
  payload = {}
data/lib/max/bot/api.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'uri'
4
+
3
5
  module Max
4
6
  module Bot
5
7
  # HTTP facade for the MAX Bot API (+platform-api.max.ru+).
@@ -56,9 +58,7 @@ module Max
56
58
  def upload_file(type:, path:, filename: nil)
57
59
  slot = create_upload(type: type)
58
60
  upload_url = slot[:url]
59
- if upload_url.nil? || upload_url.to_s.empty?
60
- raise ApiError.new('Upload response missing url', body: slot)
61
- end
61
+ raise ApiError.new('Upload response missing url', body: slot) if upload_url.nil? || upload_url.to_s.empty?
62
62
 
63
63
  raw = MultipartUpload.post_file(
64
64
  upload_url: upload_url,
@@ -99,6 +99,7 @@ module Max
99
99
 
100
100
  # POST /subscriptions — https://dev.max.ru/docs-api/methods/POST/subscriptions
101
101
  def set_webhook(url:, update_types: nil, secret: nil)
102
+ assert_valid_url!(url, 'set_webhook url')
102
103
  body = RequestBuilders.subscription_body(url: url, update_types: update_types, secret: secret)
103
104
  http.post('/subscriptions', body: body)
104
105
  end
@@ -116,6 +117,70 @@ module Max
116
117
  http.get('/chats', query: query)
117
118
  end
118
119
 
120
+ # GET /chats/{chatId} — https://dev.max.ru/docs-api/methods/GET/chats/{chatId}
121
+ def chat(chat_id)
122
+ raise ArgumentError, 'chat_id must be a non-empty value' if chat_id.nil? || chat_id.to_s.strip.empty?
123
+
124
+ http.get("/chats/#{chat_id}")
125
+ end
126
+
127
+ # --- Bot info -------------------------------------------------
128
+
129
+ # GET /bots — https://dev.max.ru/docs-api/methods/GET/bots
130
+ def me
131
+ http.get('/bots')
132
+ end
133
+
134
+ # --- Message management ---------------------------------------
135
+
136
+ # GET /messages/{messageId} — https://dev.max.ru/docs-api/methods/GET/messages/{messageId}
137
+ def get_message(message_id)
138
+ raise ArgumentError, 'message_id must be a non-empty value' if message_id.nil? || message_id.to_s.strip.empty?
139
+
140
+ http.get("/messages/#{message_id}")
141
+ end
142
+
143
+ # PUT /messages/{messageId} — https://dev.max.ru/docs-api/methods/PUT/messages/{messageId}
144
+ def edit_message(message_id, text = nil, attachment: nil, attachments: nil, format: nil,
145
+ link: nil)
146
+ raise ArgumentError, 'message_id must be a non-empty value' if message_id.nil? || message_id.to_s.strip.empty?
147
+
148
+ combined = RequestBuilders.combine_attachments(attachment, attachments)
149
+
150
+ http.put(
151
+ "/messages/#{message_id}",
152
+ body: RequestBuilders.message_body(
153
+ text: text,
154
+ attachments: combined,
155
+ format: format,
156
+ notify: nil,
157
+ link: link
158
+ )
159
+ )
160
+ end
161
+
162
+ # DELETE /messages/{messageId} — https://dev.max.ru/docs-api/methods/DELETE/messages/{messageId}
163
+ def delete_message(message_id)
164
+ raise ArgumentError, 'message_id must be a non-empty value' if message_id.nil? || message_id.to_s.strip.empty?
165
+
166
+ http.delete("/messages/#{message_id}")
167
+ end
168
+
169
+ # POST /messages/callback — https://dev.max.ru/docs-api/methods/POST/messages/callback
170
+ def answer_callback(callback_query_id, text: nil, show_alert: nil, url: nil)
171
+ raise ArgumentError, 'callback_query_id must be a non-empty value' if callback_query_id.nil? || callback_query_id.to_s.strip.empty?
172
+
173
+ http.post(
174
+ '/messages/callback',
175
+ body: RequestBuilders.callback_body(
176
+ callback_query_id: callback_query_id,
177
+ text: text,
178
+ show_alert: show_alert,
179
+ url: url
180
+ )
181
+ )
182
+ end
183
+
119
184
  private
120
185
 
121
186
  def assert_recipient!(chat_id, user_id)
@@ -130,11 +195,16 @@ module Max
130
195
  raise ArgumentError, 'send_message requires at least one of: text, attachments, attachment, link'
131
196
  end
132
197
 
198
+ def assert_valid_url!(value, label)
199
+ uri = URI.parse(value.to_s)
200
+ return if uri.is_a?(URI::HTTP) && uri.host && !uri.host.empty?
201
+
202
+ raise ArgumentError, "#{label} must be a valid HTTP(S) URL"
203
+ end
204
+
133
205
  def normalize_upload_type!(type)
134
206
  t = type.to_s
135
- unless UPLOAD_TYPES.include?(t)
136
- raise ArgumentError, "upload type must be one of #{UPLOAD_TYPES.join(', ')}"
137
- end
207
+ raise ArgumentError, "upload type must be one of #{UPLOAD_TYPES.join(', ')}" unless UPLOAD_TYPES.include?(t)
138
208
 
139
209
  t
140
210
  end
@@ -12,7 +12,7 @@ module Max
12
12
  module_function
13
13
 
14
14
  def prune(hash)
15
- hash.reject { |_, v| v.nil? }
15
+ hash.compact
16
16
  end
17
17
 
18
18
  # --- Media (usually +token+ from +POST /uploads+ flow) ---
@@ -51,7 +51,7 @@ module Max
51
51
 
52
52
  # +rows+ is an Array of rows; each row is an Array of button Hashes (see +callback_button+, +link_button+, …).
53
53
  def inline_keyboard(rows)
54
- { type: 'inline_keyboard', payload: { buttons: rows } }
54
+ { type: 'inline_keyboard', payload: { buttons: [rows] } }
55
55
  end
56
56
  alias keyboard inline_keyboard
57
57
 
@@ -98,6 +98,10 @@ module Max
98
98
  prune(type: 'message', text: text, payload: payload)
99
99
  end
100
100
 
101
+ def clipboard_button(text, payload)
102
+ { type: 'clipboard', text: text, payload: payload }
103
+ end
104
+
101
105
  # Escape hatch for new API button/attachment shapes.
102
106
  def raw(type:, payload: nil, **top_level)
103
107
  h = { type: type }
@@ -6,10 +6,10 @@ module Max
6
6
  class Client
7
7
  attr_reader :api, :options
8
8
 
9
- def self.run(token, **options, &block)
10
- raise ArgumentError, 'block required' unless block
9
+ def self.run(token, **, &)
10
+ raise ArgumentError, 'block required' unless block_given?
11
11
 
12
- new(token, **options).run(&block)
12
+ new(token, **).run(&)
13
13
  end
14
14
 
15
15
  def initialize(token, **options)
@@ -51,9 +51,9 @@ module Max
51
51
  result[:marker]
52
52
  end
53
53
 
54
- def deliver_updates(result, &block)
54
+ def deliver_updates(result, &)
55
55
  updates = result.is_a?(Hash) ? (result[:updates] || []) : []
56
- updates.each { |u| block.call(u) }
56
+ updates.each(&)
57
57
  end
58
58
 
59
59
  def handle_poll_error(error, backoff)
data/lib/max/bot/http.rb CHANGED
@@ -24,6 +24,10 @@ module Max
24
24
  perform(:delete, path, query: query)
25
25
  end
26
26
 
27
+ def put(path, query: nil, body: nil)
28
+ perform(:put, path, query: query, body: body)
29
+ end
30
+
27
31
  private
28
32
 
29
33
  def perform(method, path, query: nil, body: nil)
@@ -39,7 +43,7 @@ module Max
39
43
  end
40
44
 
41
45
  def connection
42
- @injected_connection || @default_connection ||= build_connection
46
+ @injected_connection || @connection ||= build_connection
43
47
  end
44
48
 
45
49
  def build_connection
data/lib/max/bot/json.rb CHANGED
@@ -26,9 +26,9 @@ module Max
26
26
 
27
27
  def deep_symbolize(obj)
28
28
  case obj
29
- when Hash
29
+ in Hash
30
30
  obj.each_with_object({}) { |(k, v), h| h[k.to_sym] = deep_symbolize(v) }
31
- when Array
31
+ in Array
32
32
  obj.map { |e| deep_symbolize(e) }
33
33
  else
34
34
  obj
@@ -1,13 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'net/http'
4
- require 'openssl'
5
4
  require 'securerandom'
6
5
  require 'uri'
7
6
 
8
7
  module Max
9
8
  module Bot
10
9
  # Multipart +data=@file+ upload to the URL returned by +POST /uploads+.
10
+ # Uses stdlib Net::HTTP to avoid Faraday multipart dependency (which requires
11
+ # the external faraday-multipart gem in Faraday 2.x).
11
12
  # @see https://dev.max.ru/docs-api/methods/POST/uploads
12
13
  module MultipartUpload
13
14
  module_function
@@ -43,7 +44,7 @@ module Max
43
44
  raise ApiError.new(
44
45
  "Upload failed HTTP #{response.code}",
45
46
  status: response.code.to_i,
46
- body: response.body
47
+ body: { message: response.body }
47
48
  )
48
49
  end
49
50
 
@@ -20,11 +20,10 @@ module Max
20
20
  def message_text(message)
21
21
  return unless message.is_a?(Hash)
22
22
 
23
- body = message[:body]
24
- case body
25
- when Hash
23
+ case message[:body]
24
+ in Hash => body
26
25
  body[:text] || body[:markdown]
27
- when String
26
+ in String => body
28
27
  body
29
28
  end
30
29
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Max
4
4
  module Bot
5
- VERSION = '0.2.0'
5
+ VERSION = '0.3.0'
6
6
  end
7
7
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'openssl'
4
+
3
5
  module Max
4
6
  module Bot
5
7
  # Helpers for HTTPS webhook endpoints (POST body = Update JSON).
@@ -17,9 +19,7 @@ module Max
17
19
  b = expected_secret.to_s
18
20
  return false unless a.bytesize == b.bytesize
19
21
 
20
- l = 0
21
- a.bytes.zip(b.bytes) { |x, y| l |= x ^ y }
22
- l.zero?
22
+ OpenSSL.fixed_length_secure_compare(a, b)
23
23
  end
24
24
 
25
25
  def extract_secret_header(env)
@@ -9,7 +9,7 @@ module Max
9
9
  a = { type: 'share', payload: { url: 'https://a' } }
10
10
  b = { type: 'image', payload: { token: 't' } }
11
11
  out = Api::RequestBuilders.combine_attachments(a, [b])
12
- assert_equal %w[share image], out.map { |h| h[:type] }
12
+ assert_equal(%w[share image], out.map { |h| h[:type] })
13
13
  end
14
14
 
15
15
  def test_combine_attachments_rejects_invalid_attachment
@@ -51,6 +51,27 @@ module Max
51
51
  Api::RequestBuilders.media_attachment_from_upload(:unknown, token: 'x')
52
52
  end
53
53
  end
54
+
55
+ def test_callback_body
56
+ b = Api::RequestBuilders.callback_body(
57
+ callback_query_id: 'cb_123',
58
+ text: 'Done',
59
+ show_alert: true,
60
+ url: nil
61
+ )
62
+ assert_equal 'cb_123', b[:callback_query_id]
63
+ assert_equal 'Done', b[:text]
64
+ assert b[:show_alert]
65
+ refute b.key?(:url)
66
+ end
67
+
68
+ def test_callback_body_minimal
69
+ b = Api::RequestBuilders.callback_body(callback_query_id: 'cb', text: nil, show_alert: nil, url: nil)
70
+ assert_equal 'cb', b[:callback_query_id]
71
+ refute b.key?(:text)
72
+ refute b.key?(:show_alert)
73
+ refute b.key?(:url)
74
+ end
54
75
  end
55
76
  end
56
77
  end
@@ -4,6 +4,43 @@ require 'test_helper'
4
4
 
5
5
  module Max
6
6
  module Bot
7
+ class FakeHttp
8
+ attr_reader :response, :last_path, :last_body, :last_query
9
+
10
+ def initialize(response)
11
+ @response = response
12
+ @last_path = nil
13
+ @last_body = nil
14
+ @last_query = nil
15
+ end
16
+
17
+ def post(path, query: nil, body: nil)
18
+ @last_path = path
19
+ @last_body = body
20
+ @last_query = query
21
+ @response
22
+ end
23
+
24
+ def get(path, query: nil)
25
+ @last_path = path
26
+ @last_query = query
27
+ @response
28
+ end
29
+
30
+ def delete(path, query: nil)
31
+ @last_path = path
32
+ @last_query = query
33
+ @response
34
+ end
35
+
36
+ def put(path, query: nil, body: nil)
37
+ @last_path = path
38
+ @last_body = body
39
+ @last_query = query
40
+ @response
41
+ end
42
+ end
43
+
7
44
  class ApiTest < Minitest::Test
8
45
  def test_rejects_blank_token
9
46
  assert_raises(ArgumentError) { Api.new(nil) }
@@ -21,6 +58,108 @@ module Max
21
58
  err = assert_raises(ArgumentError) { api.send_message(nil, chat_id: 1) }
22
59
  assert_match(/text|attachments|attachment|link/, err.message)
23
60
  end
61
+
62
+ def test_set_webhook_rejects_invalid_url
63
+ api = Api.new('token')
64
+ assert_raises(ArgumentError) { api.set_webhook(url: 'not-a-url') }
65
+ assert_raises(ArgumentError) { api.set_webhook(url: '') }
66
+ assert_raises(ArgumentError) { api.set_webhook(url: 'ftp://example.com') }
67
+ end
68
+
69
+ def test_set_webhook_accepts_valid_url
70
+ http = FakeHttp.new({})
71
+ api = Api.new('token', http: http)
72
+ api.set_webhook(url: 'https://example.com/webhook')
73
+ assert_equal '/subscriptions', http.last_path
74
+ assert_equal 'https://example.com/webhook', http.last_body[:url]
75
+ end
76
+
77
+ def test_me
78
+ response = { user_id: 1, name: 'Test Bot', username: 'test_bot' }
79
+ http = FakeHttp.new(response)
80
+ api = Api.new('token', http: http)
81
+ result = api.me
82
+ assert_equal 1, result[:user_id]
83
+ assert_equal '/bots', http.last_path
84
+ end
85
+
86
+ def test_chat_requires_chat_id
87
+ api = Api.new('token')
88
+ assert_raises(ArgumentError) { api.chat(nil) }
89
+ assert_raises(ArgumentError) { api.chat('') }
90
+ assert_raises(ArgumentError) { api.chat(' ') }
91
+ end
92
+
93
+ def test_chat
94
+ response = { chat_id: 123, name: 'Test Chat' }
95
+ http = FakeHttp.new(response)
96
+ api = Api.new('token', http: http)
97
+ result = api.chat(123)
98
+ assert_equal 123, result[:chat_id]
99
+ assert_equal '/chats/123', http.last_path
100
+ end
101
+
102
+ def test_get_message_requires_message_id
103
+ api = Api.new('token')
104
+ assert_raises(ArgumentError) { api.get_message(nil) }
105
+ assert_raises(ArgumentError) { api.get_message('') }
106
+ end
107
+
108
+ def test_get_message
109
+ response = { message_id: 42, body: { text: 'hello' } }
110
+ http = FakeHttp.new(response)
111
+ api = Api.new('token', http: http)
112
+ result = api.get_message(42)
113
+ assert_equal 42, result[:message_id]
114
+ assert_equal '/messages/42', http.last_path
115
+ end
116
+
117
+ def test_edit_message_requires_message_id
118
+ api = Api.new('token')
119
+ assert_raises(ArgumentError) { api.edit_message(nil, 'text') }
120
+ assert_raises(ArgumentError) { api.edit_message('', 'text') }
121
+ end
122
+
123
+ def test_edit_message
124
+ response = { message_id: 42 }
125
+ http = FakeHttp.new(response)
126
+ api = Api.new('token', http: http)
127
+ result = api.edit_message(42, 'updated text', format: 'markdown')
128
+ assert_equal 42, result[:message_id]
129
+ assert_equal '/messages/42', http.last_path
130
+ assert_equal 'updated text', http.last_body[:text]
131
+ assert_equal 'markdown', http.last_body[:format]
132
+ end
133
+
134
+ def test_delete_message_requires_message_id
135
+ api = Api.new('token')
136
+ assert_raises(ArgumentError) { api.delete_message(nil) }
137
+ assert_raises(ArgumentError) { api.delete_message('') }
138
+ end
139
+
140
+ def test_delete_message
141
+ http = FakeHttp.new({ success: true })
142
+ api = Api.new('token', http: http)
143
+ api.delete_message(42)
144
+ assert_equal '/messages/42', http.last_path
145
+ end
146
+
147
+ def test_answer_callback_requires_id
148
+ api = Api.new('token')
149
+ assert_raises(ArgumentError) { api.answer_callback(nil) }
150
+ assert_raises(ArgumentError) { api.answer_callback('') }
151
+ end
152
+
153
+ def test_answer_callback
154
+ response = { success: true }
155
+ http = FakeHttp.new(response)
156
+ api = Api.new('token', http: http)
157
+ api.answer_callback('cb_123', text: 'Done!', show_alert: true)
158
+ assert_equal '/messages/callback', http.last_path
159
+ assert_equal 'cb_123', http.last_body[:callback_query_id]
160
+ assert_equal 'Done!', http.last_body[:text]
161
+ assert http.last_body[:show_alert]
162
+ end
24
163
  end
25
164
  end
26
165
  end
@@ -20,7 +20,7 @@ module Max
20
20
  ]
21
21
  att = Attachments.inline_keyboard(rows)
22
22
  assert_equal 'inline_keyboard', att[:type]
23
- assert_equal rows, att[:payload][:buttons]
23
+ assert_equal [rows], att[:payload][:buttons]
24
24
  end
25
25
 
26
26
  def test_location_top_level_coordinates
@@ -35,6 +35,13 @@ module Max
35
35
  assert_equal 'share', att[:type]
36
36
  assert_equal 'https://example.com', att[:payload][:url]
37
37
  end
38
+
39
+ def test_clipboard_button
40
+ btn = Attachments.clipboard_button('Copy', 'secret_code')
41
+ assert_equal 'clipboard', btn[:type]
42
+ assert_equal 'Copy', btn[:text]
43
+ assert_equal 'secret_code', btn[:payload]
44
+ end
38
45
  end
39
46
  end
40
47
  end
@@ -74,7 +74,27 @@ module Max
74
74
 
75
75
  http = Http.new('tok', 'https://platform-api.max.ru', connection: conn)
76
76
  result = http.delete('/subscriptions', query: { url: 'https://h' })
77
- assert_equal true, result[:success]
77
+ assert result[:success]
78
+ stubs.verify_stubbed_calls
79
+ end
80
+
81
+ def test_put_sends_json_body_and_auth
82
+ stubs = Faraday::Adapter::Test::Stubs.new do |stub|
83
+ stub.put('/messages/42', '{"text":"updated"}') do |env|
84
+ assert_equal 'secret-token', env.request_headers['Authorization']
85
+ assert_match %r{application/json}, env.request_headers['Content-Type']
86
+ [200, { 'Content-Type' => 'application/json' }, '{"message_id":42}']
87
+ end
88
+ end
89
+
90
+ conn = Faraday.new do |f|
91
+ f.adapter :test, stubs
92
+ end
93
+
94
+ http = Http.new('secret-token', 'https://platform-api.max.ru', connection: conn)
95
+ result = http.put('/messages/42', body: { text: 'updated' })
96
+
97
+ assert_equal 42, result[:message_id]
78
98
  stubs.verify_stubbed_calls
79
99
  end
80
100
  end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'test_helper'
4
+
5
+ module Max
6
+ module Bot
7
+ class MultipartUploadTest < Minitest::Test
8
+ def setup
9
+ @temp_dir = Dir.mktmpdir
10
+ @test_file_path = File.join(@temp_dir, 'test.bin')
11
+ File.binwrite(@test_file_path, 'test content')
12
+ end
13
+
14
+ def teardown
15
+ FileUtils.remove_entry(@temp_dir)
16
+ end
17
+
18
+ def test_rejects_blank_upload_url
19
+ assert_raises(ArgumentError) do
20
+ MultipartUpload.post_file(upload_url: '', path: @test_file_path, authorization: 'tok')
21
+ end
22
+ assert_raises(ArgumentError) do
23
+ MultipartUpload.post_file(upload_url: ' ', path: @test_file_path, authorization: 'tok')
24
+ end
25
+ end
26
+
27
+ def test_raises_error_for_non_http_url
28
+ # URI.parse('not-a-url') returns URI::Generic which lacks request_uri
29
+ assert_raises(NoMethodError) do
30
+ MultipartUpload.post_file(upload_url: 'not-a-url', path: @test_file_path, authorization: 'tok')
31
+ end
32
+ end
33
+
34
+ def test_raises_api_error_on_connection_failure
35
+ # Unresolvable host should raise a network error, wrapped in ApiError by caller
36
+ err = assert_raises(Errno::ECONNREFUSED, SocketError, Errno::ENETUNREACH) do
37
+ MultipartUpload.post_file(
38
+ upload_url: 'http://127.0.0.1:1',
39
+ path: @test_file_path,
40
+ authorization: 'tok'
41
+ )
42
+ end
43
+ assert_kind_of StandardError, err
44
+ end
45
+ end
46
+ end
47
+ end
@@ -16,6 +16,15 @@ module Max
16
16
  refute Webhook.secret_valid?('abc', 'ab')
17
17
  end
18
18
 
19
+ def test_secret_valid_uses_openssl_secure_compare
20
+ # Verify OpenSSL.fixed_length_secure_compare is called for same-length strings
21
+ a = 'test_secret_value'
22
+ b = 'test_secret_value'
23
+ # OpenSSL.fixed_length_secure_compare raises on different lengths in some Ruby versions,
24
+ # so our code checks bytesize first, then delegates to OpenSSL.
25
+ assert Webhook.secret_valid?(a, b)
26
+ end
27
+
19
28
  def test_extract_secret_header_rack_style
20
29
  env = { 'HTTP_X_MAX_BOT_API_SECRET' => 'sekret' }
21
30
  assert_equal 'sekret', Webhook.extract_secret_header(env)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: max_bot
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sergey Syabrenko
@@ -39,7 +39,7 @@ dependencies:
39
39
  version: '5.0'
40
40
  - - "<"
41
41
  - !ruby/object:Gem::Version
42
- version: '7'
42
+ version: '6'
43
43
  type: :development
44
44
  prerelease: false
45
45
  version_requirements: !ruby/object:Gem::Requirement
@@ -49,7 +49,21 @@ dependencies:
49
49
  version: '5.0'
50
50
  - - "<"
51
51
  - !ruby/object:Gem::Version
52
- version: '7'
52
+ version: '6'
53
+ - !ruby/object:Gem::Dependency
54
+ name: parallel
55
+ requirement: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - "<"
58
+ - !ruby/object:Gem::Version
59
+ version: '2'
60
+ type: :development
61
+ prerelease: false
62
+ version_requirements: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - "<"
65
+ - !ruby/object:Gem::Version
66
+ version: '2'
53
67
  - !ruby/object:Gem::Dependency
54
68
  name: rake
55
69
  requirement: !ruby/object:Gem::Requirement
@@ -70,6 +84,54 @@ dependencies:
70
84
  - - "<"
71
85
  - !ruby/object:Gem::Version
72
86
  version: '15'
87
+ - !ruby/object:Gem::Dependency
88
+ name: rubocop
89
+ requirement: !ruby/object:Gem::Requirement
90
+ requirements:
91
+ - - ">="
92
+ - !ruby/object:Gem::Version
93
+ version: '1.0'
94
+ - - "<"
95
+ - !ruby/object:Gem::Version
96
+ version: '2'
97
+ type: :development
98
+ prerelease: false
99
+ version_requirements: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '1.0'
104
+ - - "<"
105
+ - !ruby/object:Gem::Version
106
+ version: '2'
107
+ - !ruby/object:Gem::Dependency
108
+ name: rubocop-minitest
109
+ requirement: !ruby/object:Gem::Requirement
110
+ requirements:
111
+ - - "~>"
112
+ - !ruby/object:Gem::Version
113
+ version: '0.36'
114
+ type: :development
115
+ prerelease: false
116
+ version_requirements: !ruby/object:Gem::Requirement
117
+ requirements:
118
+ - - "~>"
119
+ - !ruby/object:Gem::Version
120
+ version: '0.36'
121
+ - !ruby/object:Gem::Dependency
122
+ name: rubocop-performance
123
+ requirement: !ruby/object:Gem::Requirement
124
+ requirements:
125
+ - - "~>"
126
+ - !ruby/object:Gem::Version
127
+ version: '1.23'
128
+ type: :development
129
+ prerelease: false
130
+ version_requirements: !ruby/object:Gem::Requirement
131
+ requirements:
132
+ - - "~>"
133
+ - !ruby/object:Gem::Version
134
+ version: '1.23'
73
135
  description: Send messages, long-poll GET /updates, manage webhook subscriptions.
74
136
  See https://dev.max.ru/docs-api
75
137
  email: darthpains@gmail.com
@@ -104,13 +166,15 @@ files:
104
166
  - test/max/bot/errors_test.rb
105
167
  - test/max/bot/http_test.rb
106
168
  - test/max/bot/json_test.rb
169
+ - test/max/bot/multipart_upload_test.rb
107
170
  - test/max/bot/update_helpers_test.rb
108
171
  - test/max/bot/webhook_test.rb
109
172
  - test/test_helper.rb
110
- homepage: https://dev.max.ru/docs-api
173
+ homepage: https://github.com/Syabr/max_bot
111
174
  licenses:
112
175
  - MIT
113
- metadata: {}
176
+ metadata:
177
+ rubygems_mfa_required: 'true'
114
178
  post_install_message:
115
179
  rdoc_options: []
116
180
  require_paths:
@@ -119,7 +183,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
119
183
  requirements:
120
184
  - - ">="
121
185
  - !ruby/object:Gem::Version
122
- version: 2.5.0
186
+ version: 3.2.0
123
187
  required_rubygems_version: !ruby/object:Gem::Requirement
124
188
  requirements:
125
189
  - - ">="