max_bot 0.1.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 278b07ebdb70beab10d6d77a01003539629b8d7d41a3656cbb7b347fad78b68f
4
- data.tar.gz: 7e5a6fa0f682fa0a6e28dba7412adae971a3a6081b57ce7bf14902095c9a2b36
3
+ metadata.gz: 0c17ffd6dfbd11379193864cf5bfd412ddfa995de84b9e82fa17e547dc7d7225
4
+ data.tar.gz: d831baa3f7bb717c2ad95acb41536c8925e43a62539ee6d784aa1c29cfc74254
5
5
  SHA512:
6
- metadata.gz: 1bc127e78606d5cd7626e9aecce5b0f67ccda694cb00ec47b335a60e2da36f9c9fc572adf976cd7b2c4e4bd73648796977090c47c2daf68895558afcd36d9562
7
- data.tar.gz: 433adbceb32792dac73993b93084950b1a0c13b568af731b1ddc603271b6a13578bf0dbcb871ddb174f1a251ce9d5813b81f9ffb07bb8ce547232c7f31df3652
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,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rake/testtask'
4
+
5
+ Rake::TestTask.new(:test) do |t|
6
+ t.libs << 'test'
7
+ t.pattern = 'test/**/*_test.rb'
8
+ t.warning = true
9
+ end
10
+
11
+ task default: :test
@@ -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
@@ -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