max_bot 0.2.1 → 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 +4 -4
- data/CHANGELOG.md +56 -0
- data/README.md +43 -2
- data/examples/polling_bot.rb +1 -1
- data/lib/max/bot/api/request_builders.rb +8 -0
- data/lib/max/bot/api.rb +76 -6
- data/lib/max/bot/attachments.rb +6 -2
- data/lib/max/bot/client.rb +5 -5
- data/lib/max/bot/http.rb +5 -1
- data/lib/max/bot/json.rb +2 -2
- data/lib/max/bot/multipart_upload.rb +3 -2
- data/lib/max/bot/update_helpers.rb +3 -4
- data/lib/max/bot/version.rb +1 -1
- data/lib/max/bot/webhook.rb +3 -3
- data/test/max/bot/api/request_builders_test.rb +22 -1
- data/test/max/bot/api_test.rb +139 -0
- data/test/max/bot/attachments_test.rb +8 -1
- data/test/max/bot/http_test.rb +21 -1
- data/test/max/bot/multipart_upload_test.rb +47 -0
- data/test/max/bot/webhook_test.rb +9 -0
- metadata +69 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 55db0440db64b8a6755b400f5a30e811196f3a2e5d052d220932fcd0cbba21ff
|
|
4
|
+
data.tar.gz: 13196bf6a391f47391f3c7d2cd78e860ad4fd8c25300a7975f924f2a17332a36
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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.
|
|
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
|
## Ошибки
|
data/examples/polling_bot.rb
CHANGED
|
@@ -10,7 +10,7 @@ require 'bundler/setup'
|
|
|
10
10
|
require 'logger'
|
|
11
11
|
require 'max_bot'
|
|
12
12
|
|
|
13
|
-
token = ENV
|
|
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
|
data/lib/max/bot/attachments.rb
CHANGED
|
@@ -12,7 +12,7 @@ module Max
|
|
|
12
12
|
module_function
|
|
13
13
|
|
|
14
14
|
def prune(hash)
|
|
15
|
-
hash.
|
|
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 }
|
data/lib/max/bot/client.rb
CHANGED
|
@@ -6,10 +6,10 @@ module Max
|
|
|
6
6
|
class Client
|
|
7
7
|
attr_reader :api, :options
|
|
8
8
|
|
|
9
|
-
def self.run(token,
|
|
10
|
-
raise ArgumentError, 'block required' unless
|
|
9
|
+
def self.run(token, **, &)
|
|
10
|
+
raise ArgumentError, 'block required' unless block_given?
|
|
11
11
|
|
|
12
|
-
new(token, **
|
|
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, &
|
|
54
|
+
def deliver_updates(result, &)
|
|
55
55
|
updates = result.is_a?(Hash) ? (result[:updates] || []) : []
|
|
56
|
-
updates.each
|
|
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 || @
|
|
46
|
+
@injected_connection || @connection ||= build_connection
|
|
43
47
|
end
|
|
44
48
|
|
|
45
49
|
def build_connection
|
data/lib/max/bot/json.rb
CHANGED
|
@@ -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
|
-
|
|
24
|
-
|
|
25
|
-
when Hash
|
|
23
|
+
case message[:body]
|
|
24
|
+
in Hash => body
|
|
26
25
|
body[:text] || body[:markdown]
|
|
27
|
-
|
|
26
|
+
in String => body
|
|
28
27
|
body
|
|
29
28
|
end
|
|
30
29
|
end
|
data/lib/max/bot/version.rb
CHANGED
data/lib/max/bot/webhook.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
data/test/max/bot/api_test.rb
CHANGED
|
@@ -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
|
data/test/max/bot/http_test.rb
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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: '
|
|
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: '
|
|
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
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.
|
|
186
|
+
version: 3.2.0
|
|
123
187
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
124
188
|
requirements:
|
|
125
189
|
- - ">="
|