max_bot 0.1.1 → 0.2.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 +67 -0
- data/LICENSE.txt +21 -0
- data/README.md +242 -0
- data/Rakefile +11 -0
- data/examples/polling_bot.rb +46 -0
- data/lib/max/bot/api/request_builders.rb +98 -0
- data/lib/max/bot/api.rb +150 -0
- data/lib/max/bot/attachments.rb +109 -0
- data/lib/max/bot/client.rb +83 -0
- data/lib/max/bot/configuration.rb +15 -0
- data/lib/max/bot/errors.rb +24 -0
- data/lib/max/bot/http.rb +54 -0
- data/lib/max/bot/json.rb +39 -0
- data/lib/max/bot/multipart_upload.rb +54 -0
- data/lib/max/bot/update_helpers.rb +47 -0
- data/lib/max/bot/version.rb +7 -0
- data/lib/max/bot/webhook.rb +38 -0
- data/lib/max/bot.rb +33 -0
- data/lib/max_bot.rb +2 -2
- data/test/max/bot/api/request_builders_test.rb +56 -0
- data/test/max/bot/api_send_test.rb +128 -0
- data/test/max/bot/api_test.rb +26 -0
- data/test/max/bot/attachments_test.rb +40 -0
- data/test/max/bot/client_test.rb +60 -0
- data/test/max/bot/errors_test.rb +21 -0
- data/test/max/bot/http_test.rb +82 -0
- data/test/max/bot/json_test.rb +44 -0
- data/test/max/bot/update_helpers_test.rb +29 -0
- data/test/max/bot/webhook_test.rb +35 -0
- data/test/test_helper.rb +6 -0
- metadata +96 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0c17ffd6dfbd11379193864cf5bfd412ddfa995de84b9e82fa17e547dc7d7225
|
|
4
|
+
data.tar.gz: d831baa3f7bb717c2ad95acb41536c8925e43a62539ee6d784aa1c29cfc74254
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8b5dbbc7d6f38b01206430efc691ace09c68be6d0ee1a81322816ba5c4dde280ac338ab655cc4fd5d5f114fbe58938331150550df7be83462cd7776efc8bc6da
|
|
7
|
+
data.tar.gz: ed9cc5d5c132d6e4d8f22bc6bf7dbd16486ce4533bb659049053026a73725c54505f6de1bd1e23011b041a14939900d50cb75a126f46ad0d82f1b9575a312a1a
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
Changes in **0.2.0** compared to **0.1.1** are described below in **English** and **Russian**.
|
|
6
|
+
|
|
7
|
+
### English
|
|
8
|
+
|
|
9
|
+
#### API client & HTTP
|
|
10
|
+
|
|
11
|
+
- Modular stack: `Max::Bot::Http` (Faraday) and `Max::Bot::Json` for JSON parsing and `deep_symbolize`.
|
|
12
|
+
- `Max::Bot::Api`: messages (`POST /messages`), long polling (`GET /updates`), subscriptions & webhooks (`GET` / `POST` / `DELETE /subscriptions`), chats (`GET /chats`), uploads (`POST /uploads` + multipart upload to the returned URL).
|
|
13
|
+
- `Authorization` header on requests; JSON body for `POST /messages` without conflicting `url_encoded` middleware.
|
|
14
|
+
- Optional `http:` injection into `Api.new` for tests and stubs.
|
|
15
|
+
- HTTP failures: `Max::Bot::ApiError` (`#status`, `#body`, richer `#to_s` when the body includes `message`).
|
|
16
|
+
|
|
17
|
+
#### Messages & attachments
|
|
18
|
+
|
|
19
|
+
- `send_message` with `chat_id` / `user_id`, `format`, `notify`, `link`, `disable_link_preview`.
|
|
20
|
+
- `attachment:` (single Hash or Array) and `attachments:`; merged into one list in order.
|
|
21
|
+
- `Max::Bot::Attachments`: `image`, `video`, `audio`, `file`, `sticker`, `contact`, `inline_keyboard` / `keyboard`, `location`, `share`, button helpers (`callback_button`, `link_button`, etc.), `raw`.
|
|
22
|
+
- `create_upload`, `upload_file`, `send_media` for media per [upload docs](https://dev.max.ru/docs-api/methods/POST/uploads).
|
|
23
|
+
- `Max::Bot::MultipartUpload`: `multipart/form-data` via stdlib `Net::HTTP`.
|
|
24
|
+
|
|
25
|
+
#### Long polling & webhooks
|
|
26
|
+
|
|
27
|
+
- `Max::Bot::Client`: `GET /updates` loop, marker, `types` / `limit` / `timeout`, retries on errors (`error_backoff`, `on_error`, `logger`), re-raises `Interrupt`.
|
|
28
|
+
- `Max::Bot::Webhook`: `X-Max-Bot-Api-Secret` header, JSON body parsing.
|
|
29
|
+
- `Max::Bot::UpdateHelpers`: `message_created`, message text, recipient (`chat_id` / `user_id`).
|
|
30
|
+
|
|
31
|
+
#### Gem infrastructure
|
|
32
|
+
|
|
33
|
+
- Entrypoint `require "max_bot"` → `lib/max/bot.rb`; `Max::Bot.configure` (Faraday timeouts, adapter).
|
|
34
|
+
- Minitest, default `rake test`, `LICENSE.txt`, `.gitignore`, `examples/polling_bot.rb`.
|
|
35
|
+
- Documentation: `README.md` (Russian), this changelog in English and Russian.
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
### Русский
|
|
40
|
+
|
|
41
|
+
#### Клиент API и HTTP
|
|
42
|
+
|
|
43
|
+
- Модульный клиент: `Max::Bot::Http` (Faraday), разбор ответов в `Max::Bot::Json` (JSON + `deep_symbolize`).
|
|
44
|
+
- `Max::Bot::Api`: сообщения (`POST /messages`), long polling (`GET /updates`), подписки и вебхук (`GET`/`POST`/`DELETE /subscriptions`), чаты (`GET /chats`), загрузки (`POST /uploads` + multipart на выданный URL).
|
|
45
|
+
- Заголовок `Authorization` для запросов; JSON-тело для `POST /messages` без конфликта с `url_encoded`.
|
|
46
|
+
- Опциональная инъекция `http:` в `Api.new` для тестов и стабов.
|
|
47
|
+
- Ошибки HTTP: `Max::Bot::ApiError` (`#status`, `#body`, расширенный `#to_s` при поле `message` в теле).
|
|
48
|
+
|
|
49
|
+
#### Сообщения и вложения
|
|
50
|
+
|
|
51
|
+
- `send_message` с `chat_id` / `user_id`, `format`, `notify`, `link`, `disable_link_preview`.
|
|
52
|
+
- Параметры `attachment:` (один хэш или массив) и `attachments:`; склейка в один массив.
|
|
53
|
+
- Модуль `Max::Bot::Attachments`: `image`, `video`, `audio`, `file`, `sticker`, `contact`, `inline_keyboard` / `keyboard`, `location`, `share`, кнопки (`callback_button`, `link_button`, и др.), `raw`.
|
|
54
|
+
- `create_upload`, `upload_file`, `send_media` для медиа по [документации загрузок](https://dev.max.ru/docs-api/methods/POST/uploads).
|
|
55
|
+
- `Max::Bot::MultipartUpload` — загрузка `multipart/form-data` через stdlib `Net::HTTP`.
|
|
56
|
+
|
|
57
|
+
#### Long polling и вебхуки
|
|
58
|
+
|
|
59
|
+
- `Max::Bot::Client`: цикл `GET /updates`, маркер, `types` / `limit` / `timeout`, повтор при ошибках (`error_backoff`, `on_error`, `logger`), проброс `Interrupt`.
|
|
60
|
+
- `Max::Bot::Webhook`: заголовок `X-Max-Bot-Api-Secret`, разбор JSON тела.
|
|
61
|
+
- `Max::Bot::UpdateHelpers`: разбор `message_created`, текста и адресата из входящих апдейтов.
|
|
62
|
+
|
|
63
|
+
#### Инфраструктура гема
|
|
64
|
+
|
|
65
|
+
- Точка входа `require "max_bot"` → `lib/max/bot.rb`; конфигурация `Max::Bot.configure` (таймауты Faraday, адаптер).
|
|
66
|
+
- Minitest, `rake test` по умолчанию, `LICENSE.txt`, `.gitignore`, пример `examples/polling_bot.rb`.
|
|
67
|
+
- Документация: `README.md` (русский), этот changelog — на английском и русском.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) Sergey Syabrenko
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
# max_bot
|
|
2
|
+
|
|
3
|
+
Ruby-клиент для Bot API мессенджера **[MAX](https://dev.max.ru/docs-api)**: отправка сообщений, получение обновлений через **long polling** или **HTTPS-вебхуки**, список групповых чатов.
|
|
4
|
+
|
|
5
|
+
Официальная документация API: [dev.max.ru/docs-api](https://dev.max.ru/docs-api).
|
|
6
|
+
|
|
7
|
+
История изменений: [CHANGELOG.md](CHANGELOG.md) (английский и русский).
|
|
8
|
+
|
|
9
|
+
## Требования
|
|
10
|
+
|
|
11
|
+
- Ruby **≥ 2.5**
|
|
12
|
+
- Токен бота в кабинете MAX (**Чат-боты → Интеграция → Получить токен**)
|
|
13
|
+
|
|
14
|
+
## Установка
|
|
15
|
+
|
|
16
|
+
В `Gemfile`:
|
|
17
|
+
|
|
18
|
+
```ruby
|
|
19
|
+
gem 'max_bot', '~> 0.2'
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Локально из этого репозитория:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
bundle install
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Настройка
|
|
29
|
+
|
|
30
|
+
По желанию: таймауты и адаптер Faraday.
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
require 'max_bot'
|
|
34
|
+
|
|
35
|
+
Max::Bot.configure do |config|
|
|
36
|
+
config.connection_timeout = 95 # секунды (должно быть > long-poll timeout)
|
|
37
|
+
config.connection_open_timeout = 20
|
|
38
|
+
config.adapter = Faraday.default_adapter
|
|
39
|
+
end
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Для `GET /updates` таймаут чтения HTTP должен быть **больше**, чем параметр long polling `timeout` (в `Max::Bot::Client` по умолчанию **30** секунд).
|
|
43
|
+
|
|
44
|
+
## Запуск бота (long polling)
|
|
45
|
+
|
|
46
|
+
Long polling рассчитан на **разработку и тесты**. Для продакшена MAX рекомендует **HTTPS-вебхуки** ([POST /subscriptions](https://dev.max.ru/docs-api/methods/POST/subscriptions)).
|
|
47
|
+
|
|
48
|
+
1. Задайте токен:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
export MAX_BOT_TOKEN="ваш_токен"
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
2. Из корня репозитория:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
bundle exec ruby examples/polling_bot.rb
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Пример делает long polling `GET /updates` с `types: %w[message_created]`, разбирает апдейты через `Max::Bot::UpdateHelpers` и отвечает эхом (`POST /messages`).
|
|
61
|
+
|
|
62
|
+
### Минимальный цикл polling в своём коде
|
|
63
|
+
|
|
64
|
+
```ruby
|
|
65
|
+
require 'max_bot'
|
|
66
|
+
|
|
67
|
+
api = Max::Bot::Api.new(ENV.fetch('MAX_BOT_TOKEN'))
|
|
68
|
+
|
|
69
|
+
Max::Bot::Client.new(
|
|
70
|
+
api.token,
|
|
71
|
+
timeout: 30,
|
|
72
|
+
limit: 100,
|
|
73
|
+
types: %w[message_created],
|
|
74
|
+
error_backoff: 5,
|
|
75
|
+
logger: Logger.new($stdout) # по желанию
|
|
76
|
+
).run do |update|
|
|
77
|
+
# update — Hash с символичными ключами (объект Update)
|
|
78
|
+
next unless Max::Bot::UpdateHelpers.message_created?(update)
|
|
79
|
+
|
|
80
|
+
message = Max::Bot::UpdateHelpers.message_from(update)
|
|
81
|
+
text = Max::Bot::UpdateHelpers.message_text(message)
|
|
82
|
+
dest = Max::Bot::UpdateHelpers.message_destination(message)
|
|
83
|
+
next if text.to_s.empty? || dest.nil?
|
|
84
|
+
|
|
85
|
+
kind, id = dest # :chat_id или :user_id
|
|
86
|
+
api.send_message("Вы написали: #{text}", kind => id)
|
|
87
|
+
end
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
**Через метод класса:**
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
Max::Bot::Client.run(ENV.fetch('MAX_BOT_TOKEN'), timeout: 30) { |u| ... }
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
При сетевых или API-ошибках клиент **ждёт** `error_backoff` (по умолчанию **3** с) и **повторяет** запрос. `Interrupt` (Ctrl+C) прерывает цикл. Вместо `logger` можно передать **`on_error: ->(e) { ... }`**.
|
|
97
|
+
|
|
98
|
+
## Отправка сообщений
|
|
99
|
+
|
|
100
|
+
```ruby
|
|
101
|
+
api = Max::Bot::Api.new(ENV.fetch('MAX_BOT_TOKEN'))
|
|
102
|
+
|
|
103
|
+
# Группа / канал
|
|
104
|
+
api.send_message('Привет', chat_id: 123)
|
|
105
|
+
|
|
106
|
+
# Личка
|
|
107
|
+
api.send_message('Привет', user_id: 456)
|
|
108
|
+
|
|
109
|
+
# Markdown / HTML (см. раздел «Форматирование» в доке API)
|
|
110
|
+
api.send_message('_cursive_', chat_id: 123, format: 'markdown')
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
`send_message` соответствует **`POST /messages`**: в query — `chat_id` / `user_id`, в теле — JSON. Подробнее: [Отправить сообщение](https://dev.max.ru/docs-api/methods/POST/messages).
|
|
114
|
+
|
|
115
|
+
### Вложения (`Max::Bot::Attachments`)
|
|
116
|
+
|
|
117
|
+
Типизированные хелперы под API: **image**, **video**, **audio**, **file**, **sticker**, **contact**, **inline_keyboard**, **location**, **share**. Примеры клавиатуры — в [документации MAX](https://dev.max.ru/docs-api).
|
|
118
|
+
|
|
119
|
+
```ruby
|
|
120
|
+
api.send_message(
|
|
121
|
+
'Выберите',
|
|
122
|
+
chat_id: chat_id,
|
|
123
|
+
attachment: Max::Bot::Attachments.inline_keyboard([
|
|
124
|
+
[
|
|
125
|
+
Max::Bot::Attachments.callback_button('A', 'choice_a'),
|
|
126
|
+
Max::Bot::Attachments.callback_button('B', 'choice_b')
|
|
127
|
+
],
|
|
128
|
+
[Max::Bot::Attachments.link_button('Документация', 'https://dev.max.ru/docs-api')]
|
|
129
|
+
])
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
api.send_message(
|
|
133
|
+
'Тут',
|
|
134
|
+
user_id: user_id,
|
|
135
|
+
attachments: [
|
|
136
|
+
Max::Bot::Attachments.location(latitude: 55.75, longitude: 37.61),
|
|
137
|
+
Max::Bot::Attachments.share(url: 'https://example.com/article')
|
|
138
|
+
]
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
api.send_message('Стикер', chat_id: cid, attachment: Max::Bot::Attachments.sticker(code: 'код_стикера'))
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Можно передать **`attachment:`** (один Hash или массив) и/или **`attachments:`** — порядок сохраняется.
|
|
145
|
+
|
|
146
|
+
### Загрузка медиа (`POST /uploads`)
|
|
147
|
+
|
|
148
|
+
Для **image**, **video**, **audio**, **file**: слот загрузки → `multipart/form-data` на выданный URL → в сообщении использовать **token** из ответа. См. [Загрузка файлов](https://dev.max.ru/docs-api/methods/POST/uploads).
|
|
149
|
+
|
|
150
|
+
```ruby
|
|
151
|
+
# Низкоуровнево
|
|
152
|
+
slot = api.create_upload(type: :video) # => { url: "https://..." } и др. поля
|
|
153
|
+
meta = api.upload_file(type: :video, path: '/path/to/clip.mp4')
|
|
154
|
+
api.send_message('Видео', chat_id: id, attachment: Max::Bot::Attachments.video(token: meta[:token]))
|
|
155
|
+
|
|
156
|
+
# Одной строкой
|
|
157
|
+
api.send_media(type: :image, path: '/path/to/photo.jpg', text: 'Фото', chat_id: id)
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
После загрузки больших файлов перед `POST /messages` может понадобиться пауза (в доке — ошибка **attachment.not.ready**).
|
|
161
|
+
|
|
162
|
+
## Вебхуки (продакшен)
|
|
163
|
+
|
|
164
|
+
1. Поднимите **HTTPS**-endpoint (порт **443**, валидная цепочка TLS — см. требования MAX).
|
|
165
|
+
|
|
166
|
+
2. Подписка:
|
|
167
|
+
|
|
168
|
+
```ruby
|
|
169
|
+
api.set_webhook(
|
|
170
|
+
url: 'https://your.domain/max/webhook',
|
|
171
|
+
update_types: %w[message_created bot_started],
|
|
172
|
+
secret: 'your_secret' # по желанию, 5–256 символов [a-zA-Z0-9_-]
|
|
173
|
+
)
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
3. В веб-приложении проверьте секретный заголовок и разберите JSON:
|
|
177
|
+
|
|
178
|
+
```ruby
|
|
179
|
+
require 'max_bot'
|
|
180
|
+
|
|
181
|
+
secret = ENV.fetch('WEBHOOK_SECRET')
|
|
182
|
+
header = Max::Bot::Webhook.extract_secret_header(env) # Rack env
|
|
183
|
+
halt 401 unless Max::Bot::Webhook.secret_valid?(header, secret)
|
|
184
|
+
|
|
185
|
+
update = Max::Bot::Webhook.parse_json(request_body)
|
|
186
|
+
# обработка update (тот же формат, что и при long polling)
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Заголовок: **`X-Max-Bot-Api-Secret`**. Подробнее: [Подписка на обновления](https://dev.max.ru/docs-api/methods/POST/subscriptions).
|
|
190
|
+
|
|
191
|
+
4. Отключение вебхука (снова доступен long polling):
|
|
192
|
+
|
|
193
|
+
```ruby
|
|
194
|
+
api.delete_webhook('https://your.domain/max/webhook')
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
## Другие методы API
|
|
198
|
+
|
|
199
|
+
| Метод | HTTP |
|
|
200
|
+
|--------|------|
|
|
201
|
+
| `api.chats` | [GET /chats](https://dev.max.ru/docs-api/methods/GET/chats) |
|
|
202
|
+
| `api.subscriptions` | [GET /subscriptions](https://dev.max.ru/docs-api/methods/GET/subscriptions) |
|
|
203
|
+
|
|
204
|
+
## Ошибки
|
|
205
|
+
|
|
206
|
+
Неуспешные HTTP-ответы вызывают **`Max::Bot::ApiError`**: `#status`, `#body` (Hash с символичными ключами, если пришёл JSON).
|
|
207
|
+
|
|
208
|
+
## Структура библиотеки
|
|
209
|
+
|
|
210
|
+
Типичный layout RubyGem: файл гема совпадает с именем, код — в `lib/max/bot/`.
|
|
211
|
+
|
|
212
|
+
| Путь | Назначение |
|
|
213
|
+
|------|------------|
|
|
214
|
+
| `lib/max_bot.rb` | `require "max_bot"` → подключает `max/bot` |
|
|
215
|
+
| `lib/max/bot.rb` | Загрузка зависимостей, `Max::Bot.configure` |
|
|
216
|
+
| `lib/max/bot/api.rb` | Публичные методы Bot API |
|
|
217
|
+
| `lib/max/bot/api/request_builders.rb` | Сборка query/body для {Api} |
|
|
218
|
+
| `lib/max/bot/http.rb` | Faraday, JSON-тело, заголовок `Authorization` |
|
|
219
|
+
| `lib/max/bot/json.rb` | Разбор ответа, `deep_symbolize` |
|
|
220
|
+
| `lib/max/bot/attachments.rb` | Билдеры вложений и клавиатуры |
|
|
221
|
+
| `lib/max/bot/multipart_upload.rb` | Multipart-загрузка на CDN-URL |
|
|
222
|
+
| `lib/max/bot/client.rb` | Цикл long polling `GET /updates` |
|
|
223
|
+
|
|
224
|
+
Для кастомного транспорта или стабов можно подставить объект как **`Max::Bot::Http`**:
|
|
225
|
+
|
|
226
|
+
```ruby
|
|
227
|
+
api = Max::Bot::Api.new(token, http: my_http_client)
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
В тестах: **`Max::Bot::Client.new(..., api: fake_api, sleep: ->(_) {})`** — без реального `sleep` и с подменённым API.
|
|
231
|
+
|
|
232
|
+
## Разработка
|
|
233
|
+
|
|
234
|
+
```bash
|
|
235
|
+
bundle install
|
|
236
|
+
bundle exec rake test # задача по умолчанию
|
|
237
|
+
gem build max_bot.gemspec
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
## Лицензия
|
|
241
|
+
|
|
242
|
+
MIT — см. [LICENSE.txt](LICENSE.txt).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Run from the repository root:
|
|
5
|
+
# bundle install
|
|
6
|
+
# export MAX_BOT_TOKEN="your_token"
|
|
7
|
+
# bundle exec ruby examples/polling_bot.rb
|
|
8
|
+
|
|
9
|
+
require 'bundler/setup'
|
|
10
|
+
require 'logger'
|
|
11
|
+
require 'max_bot'
|
|
12
|
+
|
|
13
|
+
token = ENV['MAX_BOT_TOKEN']
|
|
14
|
+
abort 'Set MAX_BOT_TOKEN from the MAX platform (Chat bots → Integration → token).' if token.to_s.strip.empty?
|
|
15
|
+
|
|
16
|
+
logger = Logger.new($stdout)
|
|
17
|
+
logger.level = Logger::INFO
|
|
18
|
+
|
|
19
|
+
Max::Bot.configure do |c|
|
|
20
|
+
c.connection_timeout = 95
|
|
21
|
+
c.connection_open_timeout = 20
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
api = Max::Bot::Api.new(token)
|
|
25
|
+
|
|
26
|
+
Max::Bot::Client.new(
|
|
27
|
+
token,
|
|
28
|
+
timeout: 30,
|
|
29
|
+
limit: 100,
|
|
30
|
+
types: %w[message_created],
|
|
31
|
+
logger: logger,
|
|
32
|
+
error_backoff: 5
|
|
33
|
+
).run do |update|
|
|
34
|
+
logger.debug(update.inspect)
|
|
35
|
+
next unless Max::Bot::UpdateHelpers.message_created?(update)
|
|
36
|
+
|
|
37
|
+
message = Max::Bot::UpdateHelpers.message_from(update)
|
|
38
|
+
text = Max::Bot::UpdateHelpers.message_text(message)
|
|
39
|
+
dest = Max::Bot::UpdateHelpers.message_destination(message)
|
|
40
|
+
next if text.to_s.empty? || dest.nil?
|
|
41
|
+
|
|
42
|
+
kind, id = dest
|
|
43
|
+
api.send_message("Echo: #{text}", kind => id)
|
|
44
|
+
rescue Max::Bot::ApiError => e
|
|
45
|
+
logger.error("API error #{e.status}: #{e}")
|
|
46
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Max
|
|
4
|
+
module Bot
|
|
5
|
+
class Api
|
|
6
|
+
# Query/body assembly for {Api} — keeps the public class thin and readable.
|
|
7
|
+
module RequestBuilders
|
|
8
|
+
class << self
|
|
9
|
+
def combine_attachments(attachment, attachments)
|
|
10
|
+
parts = []
|
|
11
|
+
append_attachment_slot!(parts, attachment, :attachment)
|
|
12
|
+
append_attachment_slot!(parts, attachments, :attachments)
|
|
13
|
+
return if parts.empty?
|
|
14
|
+
|
|
15
|
+
parts
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def message_query(chat_id:, user_id:, disable_link_preview:)
|
|
19
|
+
q = {}
|
|
20
|
+
q[:chat_id] = chat_id unless chat_id.nil?
|
|
21
|
+
q[:user_id] = user_id unless user_id.nil?
|
|
22
|
+
q[:disable_link_preview] = disable_link_preview unless disable_link_preview.nil?
|
|
23
|
+
q
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def message_body(text:, attachments:, format:, notify:, link:)
|
|
27
|
+
body = {}
|
|
28
|
+
body[:text] = text unless text.nil?
|
|
29
|
+
body[:attachments] = attachments unless attachments.nil?
|
|
30
|
+
body[:format] = format if format
|
|
31
|
+
body[:notify] = notify unless notify.nil?
|
|
32
|
+
body[:link] = link unless link.nil?
|
|
33
|
+
body
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def updates_query(marker:, limit:, timeout:, types:)
|
|
37
|
+
query = {}
|
|
38
|
+
query[:marker] = marker unless marker.nil?
|
|
39
|
+
query[:limit] = limit unless limit.nil?
|
|
40
|
+
query[:timeout] = timeout unless timeout.nil?
|
|
41
|
+
if types
|
|
42
|
+
list = Array(types).compact
|
|
43
|
+
query[:types] = list.join(',') unless list.empty?
|
|
44
|
+
end
|
|
45
|
+
query
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def subscription_body(url:, update_types:, secret:)
|
|
49
|
+
body = { url: url }
|
|
50
|
+
body[:update_types] = update_types if update_types
|
|
51
|
+
body[:secret] = secret if secret
|
|
52
|
+
body
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def chats_query(count:, marker:)
|
|
56
|
+
query = {}
|
|
57
|
+
query[:count] = count unless count.nil?
|
|
58
|
+
query[:marker] = marker unless marker.nil?
|
|
59
|
+
query
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def media_attachment_from_upload(type, data)
|
|
63
|
+
t = type.to_s
|
|
64
|
+
payload = {}
|
|
65
|
+
payload[:token] = data[:token] if data.key?(:token)
|
|
66
|
+
payload[:url] = data[:url] if data.key?(:url)
|
|
67
|
+
payload[:photos] = data[:photos] if data.key?(:photos)
|
|
68
|
+
|
|
69
|
+
case t
|
|
70
|
+
when 'image' then Attachments.image(**payload)
|
|
71
|
+
when 'video' then Attachments.video(**payload)
|
|
72
|
+
when 'audio' then Attachments.audio(**payload)
|
|
73
|
+
when 'file' then Attachments.file(**payload)
|
|
74
|
+
else
|
|
75
|
+
raise ArgumentError, "unsupported media type: #{type}"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def append_attachment_slot!(parts, value, role)
|
|
82
|
+
case value
|
|
83
|
+
when Array
|
|
84
|
+
parts.concat(value)
|
|
85
|
+
when Hash
|
|
86
|
+
parts << value
|
|
87
|
+
when nil
|
|
88
|
+
# skip
|
|
89
|
+
else
|
|
90
|
+
label = role == :attachment ? 'attachment:' : 'attachments:'
|
|
91
|
+
raise ArgumentError, "#{label} must be a Hash or Array of Hash"
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
data/lib/max/bot/api.rb
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Max
|
|
4
|
+
module Bot
|
|
5
|
+
# HTTP facade for the MAX Bot API (+platform-api.max.ru+).
|
|
6
|
+
#
|
|
7
|
+
# @see https://dev.max.ru/docs-api
|
|
8
|
+
class Api
|
|
9
|
+
DEFAULT_URL = 'https://platform-api.max.ru'
|
|
10
|
+
|
|
11
|
+
UPLOAD_TYPES = %w[image video audio file].freeze
|
|
12
|
+
|
|
13
|
+
attr_reader :token, :base_url, :http
|
|
14
|
+
|
|
15
|
+
def initialize(token, url: DEFAULT_URL, http: nil)
|
|
16
|
+
raise ArgumentError, 'token must be a non-empty String' if token.nil? || token.to_s.strip.empty?
|
|
17
|
+
|
|
18
|
+
@token = token.to_s
|
|
19
|
+
@base_url = url.to_s.chomp('/')
|
|
20
|
+
@http = http || Http.new(@token, @base_url)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# --- Messages -------------------------------------------------
|
|
24
|
+
|
|
25
|
+
# POST /messages — https://dev.max.ru/docs-api/methods/POST/messages
|
|
26
|
+
def send_message(text = nil, chat_id: nil, user_id: nil, attachment: nil, attachments: nil, format: nil,
|
|
27
|
+
notify: nil, disable_link_preview: nil, link: nil)
|
|
28
|
+
assert_recipient!(chat_id, user_id)
|
|
29
|
+
|
|
30
|
+
combined = RequestBuilders.combine_attachments(attachment, attachments)
|
|
31
|
+
assert_message_payload!(text, combined, link)
|
|
32
|
+
|
|
33
|
+
http.post(
|
|
34
|
+
'/messages',
|
|
35
|
+
query: RequestBuilders.message_query(
|
|
36
|
+
chat_id: chat_id,
|
|
37
|
+
user_id: user_id,
|
|
38
|
+
disable_link_preview: disable_link_preview
|
|
39
|
+
),
|
|
40
|
+
body: RequestBuilders.message_body(
|
|
41
|
+
text: text,
|
|
42
|
+
attachments: combined,
|
|
43
|
+
format: format,
|
|
44
|
+
notify: notify,
|
|
45
|
+
link: link
|
|
46
|
+
)
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# POST /uploads — https://dev.max.ru/docs-api/methods/POST/uploads
|
|
51
|
+
def create_upload(type:)
|
|
52
|
+
t = normalize_upload_type!(type)
|
|
53
|
+
http.post('/uploads', query: { type: t })
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def upload_file(type:, path:, filename: nil)
|
|
57
|
+
slot = create_upload(type: type)
|
|
58
|
+
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
|
|
62
|
+
|
|
63
|
+
raw = MultipartUpload.post_file(
|
|
64
|
+
upload_url: upload_url,
|
|
65
|
+
path: path,
|
|
66
|
+
filename: filename,
|
|
67
|
+
authorization: token
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
parse_upload_response(raw).merge(slot)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Uploads local file then POST /messages with the resulting attachment.
|
|
74
|
+
def send_media(type:, path:, text: nil, filename: nil, chat_id: nil, user_id: nil, **message_opts)
|
|
75
|
+
data = upload_file(type: type, path: path, filename: filename)
|
|
76
|
+
att = RequestBuilders.media_attachment_from_upload(type, data)
|
|
77
|
+
|
|
78
|
+
send_message(
|
|
79
|
+
text,
|
|
80
|
+
chat_id: chat_id,
|
|
81
|
+
user_id: user_id,
|
|
82
|
+
attachment: att,
|
|
83
|
+
**message_opts
|
|
84
|
+
)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# --- Updates & subscriptions ------------------------------------
|
|
88
|
+
|
|
89
|
+
# GET /updates — https://dev.max.ru/docs-api/methods/GET/updates
|
|
90
|
+
def get_updates(marker: nil, limit: nil, timeout: nil, types: nil)
|
|
91
|
+
query = RequestBuilders.updates_query(marker: marker, limit: limit, timeout: timeout, types: types)
|
|
92
|
+
http.get('/updates', query: query)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# GET /subscriptions
|
|
96
|
+
def subscriptions
|
|
97
|
+
http.get('/subscriptions')
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# POST /subscriptions — https://dev.max.ru/docs-api/methods/POST/subscriptions
|
|
101
|
+
def set_webhook(url:, update_types: nil, secret: nil)
|
|
102
|
+
body = RequestBuilders.subscription_body(url: url, update_types: update_types, secret: secret)
|
|
103
|
+
http.post('/subscriptions', body: body)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# DELETE /subscriptions — https://dev.max.ru/docs-api/methods/DELETE/subscriptions
|
|
107
|
+
def delete_webhook(webhook_url)
|
|
108
|
+
http.delete('/subscriptions', query: { url: webhook_url })
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# --- Chats ----------------------------------------------------
|
|
112
|
+
|
|
113
|
+
# GET /chats — https://dev.max.ru/docs-api/methods/GET/chats
|
|
114
|
+
def chats(count: nil, marker: nil)
|
|
115
|
+
query = RequestBuilders.chats_query(count: count, marker: marker)
|
|
116
|
+
http.get('/chats', query: query)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
def assert_recipient!(chat_id, user_id)
|
|
122
|
+
return unless chat_id.nil? && user_id.nil?
|
|
123
|
+
|
|
124
|
+
raise ArgumentError, 'send_message requires chat_id: or user_id:'
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def assert_message_payload!(text, combined_attachments, link)
|
|
128
|
+
return unless text.nil? && combined_attachments.nil? && link.nil?
|
|
129
|
+
|
|
130
|
+
raise ArgumentError, 'send_message requires at least one of: text, attachments, attachment, link'
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def normalize_upload_type!(type)
|
|
134
|
+
t = type.to_s
|
|
135
|
+
unless UPLOAD_TYPES.include?(t)
|
|
136
|
+
raise ArgumentError, "upload type must be one of #{UPLOAD_TYPES.join(', ')}"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
t
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def parse_upload_response(raw)
|
|
143
|
+
stripped = raw.to_s.strip
|
|
144
|
+
return {} if stripped.empty?
|
|
145
|
+
|
|
146
|
+
::JSON.parse(stripped, symbolize_names: true)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|