max_api_client 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/README.md ADDED
@@ -0,0 +1,272 @@
1
+ # max_api_client
2
+
3
+ Ruby gem для работы с Max Bot API.
4
+
5
+ ## Состояние
6
+
7
+ Текущая реализация на Ruby включает API-клиент со следующими возможностями:
8
+
9
+ - высокоуровневый `MaxApiClient::Api`
10
+ - низкоуровневый `MaxApiClient::RawApi`
11
+ - сгруппированные методы для bot/chat/message/subscription/upload
12
+ - вспомогательные объекты вложений для результатов загрузки
13
+
14
+ ## Установка
15
+
16
+ Добавьте gem в проект:
17
+
18
+ ```ruby
19
+ # Gemfile
20
+ gem "max_api_client", git: "git@github.com:Ziaw/max_api_client.git"
21
+ ```
22
+
23
+ ```bash
24
+ bundle install
25
+ ```
26
+
27
+ ## Справочник API
28
+
29
+ ### Методы бота
30
+
31
+ Методы Ruby, доступные через `MaxApiClient::Api`:
32
+
33
+ - `get_my_info`
34
+ - `edit_my_info(**extra)`
35
+ - `set_my_commands(commands)`
36
+ - `delete_my_commands`
37
+
38
+ `set_my_commands(commands)` это короткий хелпер над `edit_my_info(commands: ...)`.
39
+ Он принимает массив команд и отправляет его в поле `commands` профиля бота.
40
+
41
+ Ожидается массив хэшей с данными команды, например:
42
+
43
+ ```ruby
44
+ api.set_my_commands([
45
+ { name: "start", description: "Запустить бота" },
46
+ { name: "help", description: "Показать справку" }
47
+ ])
48
+ ```
49
+
50
+ `delete_my_commands` делает то же самое, но передаёт пустой массив и тем самым очищает список команд.
51
+
52
+ Соответствующие HTTP-маршруты:
53
+
54
+ - `GET /me`
55
+ - `PATCH /me`
56
+
57
+ Типовые сценарии:
58
+
59
+ - получить текущий профиль бота;
60
+ - обновить имя, описание, аватар и команды бота;
61
+ - опубликовать или очистить подсказки команд для пользователей.
62
+
63
+ ### Методы чатов
64
+
65
+ Методы Ruby, доступные через `MaxApiClient::Api`:
66
+
67
+ - `get_all_chats(**extra)`
68
+ - `get_chat(chat_id)`
69
+ - `get_chat_by_link(chat_link)`
70
+ - `edit_chat_info(chat_id, **extra)`
71
+ - `get_chat_membership(chat_id)`
72
+ - `get_chat_admins(chat_id)`
73
+ - `add_chat_members(chat_id, user_ids)`
74
+ - `get_chat_members(chat_id, **extra)`
75
+ - `remove_chat_member(chat_id, user_id)`
76
+ - `get_pinned_message(chat_id)`
77
+ - `pin_message(chat_id, message_id, **extra)`
78
+ - `unpin_message(chat_id)`
79
+ - `send_action(chat_id, action)`
80
+ - `leave_chat(chat_id)`
81
+
82
+ Соответствующие HTTP-маршруты:
83
+
84
+ - `GET /chats`
85
+ - `GET /chats/{chat_id}`
86
+ - `GET /chats/{chat_link}`
87
+ - `PATCH /chats/{chat_id}`
88
+ - `GET /chats/{chat_id}/members/me`
89
+ - `GET /chats/{chat_id}/members/admins`
90
+ - `POST /chats/{chat_id}/members`
91
+ - `GET /chats/{chat_id}/members`
92
+ - `DELETE /chats/{chat_id}/members`
93
+ - `GET /chats/{chat_id}/pin`
94
+ - `PUT /chats/{chat_id}/pin`
95
+ - `DELETE /chats/{chat_id}/pin`
96
+ - `POST /chats/{chat_id}/actions`
97
+ - `DELETE /chats/{chat_id}/members/me`
98
+
99
+ Типовые сценарии:
100
+
101
+ - получить список чатов, доступных боту;
102
+ - найти чат по идентификатору или публичной ссылке;
103
+ - изменить заголовок, иконку и метаданные чата;
104
+ - управлять участниками и администраторами;
105
+ - читать, устанавливать и снимать закреплённые сообщения;
106
+ - отправлять статус набора текста и другие действия отправителя;
107
+ - выходить из чата.
108
+
109
+ ### Методы сообщений
110
+
111
+ Методы Ruby, доступные через `MaxApiClient::Api`:
112
+
113
+ - `send_message_to_chat(chat_id, text, **extra)`
114
+ - `send_message_to_user(user_id, text, **extra)`
115
+ - `get_messages(chat_id, **extra)`
116
+ - `get_message(message_id)`
117
+ - `edit_message(message_id, **extra)`
118
+ - `delete_message(message_id, **extra)`
119
+ - `answer_on_callback(callback_id, **extra)`
120
+
121
+ Соответствующие HTTP-маршруты:
122
+
123
+ - `POST /messages`
124
+ - `GET /messages`
125
+ - `GET /messages/{message_id}`
126
+ - `PUT /messages`
127
+ - `DELETE /messages`
128
+ - `POST /answers`
129
+
130
+ Поддерживаемые возможности:
131
+
132
+ - отправка обычного текста в чат или напрямую пользователю;
133
+ - дополнительный payload для форматирования, reply-ссылок и вложений;
134
+ - редактирование и удаление сообщений;
135
+ - ответы на callback-кнопки;
136
+ - автоматический повтор запроса, если вложение после загрузки ещё не готово.
137
+
138
+ ### Методы подписок
139
+
140
+ Методы Ruby, доступные через `MaxApiClient::Api`:
141
+
142
+ - `get_subscriptions`
143
+ - `subscribe(url, update_types: nil, secret: nil)`
144
+ - `unsubscribe(url)`
145
+ - `poll_updates(types = [], marker: nil, timeout: 20, retry_interval: 5, read_timeout: nil, &block)`
146
+
147
+ Соответствующие HTTP-маршруты:
148
+
149
+ - `GET /subscriptions`
150
+ - `POST /subscriptions`
151
+ - `DELETE /subscriptions`
152
+ - `GET /updates`
153
+
154
+ Типовые сценарии:
155
+
156
+ - получить список активных webhook-подписок бота;
157
+ - создать webhook-подписку на нужные типы обновлений;
158
+ - удалить подписку по URL webhook;
159
+ - использовать polling через `poll_updates`, если webhook не нужен.
160
+
161
+ Пример long polling:
162
+
163
+ ```ruby
164
+ poller = api.poll_updates(%w[message_created], timeout: 20)
165
+
166
+ poller.each do |update|
167
+ puts update["update_type"]
168
+ # poller.stop if нужно остановить цикл
169
+ end
170
+ ```
171
+
172
+ `poll_updates` автоматически:
173
+
174
+ - передаёт `marker` между запросами;
175
+ - поднимает HTTP `read_timeout` выше API `timeout`;
176
+ - повторяет запрос после временных сетевых ошибок, `429` и `5xx`.
177
+
178
+ ### Методы загрузки
179
+
180
+ Методы Ruby, доступные через `MaxApiClient::Api`:
181
+
182
+ - `upload_image(options)`
183
+ - `upload_video(options)`
184
+ - `upload_audio(options)`
185
+ - `upload_file(options)`
186
+
187
+ Связанный HTTP-маршрут:
188
+
189
+ - `POST /uploads`
190
+
191
+ Вспомогательные классы вложений:
192
+
193
+ - `ImageAttachment`
194
+ - `VideoAttachment`
195
+ - `AudioAttachment`
196
+ - `FileAttachment`
197
+ - `StickerAttachment`
198
+ - `LocationAttachment`
199
+ - `ShareAttachment`
200
+
201
+ ### Доступ к Raw API
202
+
203
+ Низкоуровневый доступ через `api.raw` поддерживает:
204
+
205
+ - `get`
206
+ - `post`
207
+ - `put`
208
+ - `patch`
209
+ - `delete`
210
+
211
+ ## Логирование
212
+
213
+ Если нужен отладочный лог HTTP-обмена, можно задать глобальный логгер:
214
+
215
+ ```ruby
216
+ MaxApiClient.logger = Logger.new($stdout)
217
+ ```
218
+
219
+ Либо передать логгер в конкретный клиент:
220
+
221
+ ```ruby
222
+ api = MaxApiClient::Api.new(token: ENV.fetch("MAX_BOT_TOKEN"), logger: Logger.new($stdout))
223
+ ```
224
+
225
+ Если логгер задан, клиент пишет в `debug` данные запроса и ответа.
226
+
227
+ ## Разработка
228
+
229
+ Склонируйте репозиторий и установите зависимости:
230
+
231
+ ```bash
232
+ git clone git@github.com:Ziaw/max_api_client.git
233
+ cd max_api_client
234
+ bundle install
235
+ ```
236
+
237
+ Полезные команды:
238
+
239
+ ```bash
240
+ bundle exec rake test
241
+ bin/console
242
+ ```
243
+
244
+
245
+ ## Релиз
246
+
247
+ Релиз публикуется через GitHub Releases и GitHub Actions.
248
+
249
+ Перед релизом:
250
+
251
+ 1. Обновите версию в `lib/max_api_client/version.rb`.
252
+ 2. Перенесите изменения из `Unreleased` в `CHANGELOG.md`.
253
+ 3. Закоммитьте изменения в `master`.
254
+
255
+ ## Приоритеты реализации
256
+
257
+ Рекомендуемый порядок развития Ruby-клиента:
258
+
259
+ 1. HTTP-клиент и слой ошибок.
260
+ 2. Интерфейс raw-запросов.
261
+ 3. Высокоуровневая обёртка `Api` для bot, chat, message и update endpoints.
262
+ 4. Механизм загрузки файлов и объекты вложений. (вы находитесь здесь)
263
+ 5. Опциональный bot framework с polling, context и middleware.
264
+
265
+ ## Источники
266
+
267
+ - Официальная документация Max Bot API: <https://dev.max.ru/>
268
+ - TypeScript reference client: <https://github.com/max-messenger/max-bot-api-client-ts>
269
+
270
+ ## Лицензия
271
+
272
+ Проект распространяется по лицензии MIT. См. [`LICENSE.txt`](./LICENSE.txt).
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "minitest/test_task"
5
+
6
+ Minitest::TestTask.create
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[test rubocop]
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MaxApiClient
4
+ # High-level convenience wrapper over grouped Max Bot API endpoints.
5
+ class Api
6
+ attr_reader :raw, :upload, :client
7
+
8
+ # rubocop:disable Metrics/ParameterLists
9
+ def initialize(token:, base_url: Client::DEFAULT_BASE_URL, adapter: nil, open_timeout: nil, read_timeout: nil,
10
+ logger: nil)
11
+ @client = Client.new(
12
+ token:,
13
+ base_url:,
14
+ adapter:,
15
+ open_timeout:,
16
+ read_timeout:,
17
+ logger:
18
+ )
19
+ @raw = RawApi.new(client)
20
+ @upload = Upload.new(self)
21
+ end
22
+ # rubocop:enable Metrics/ParameterLists
23
+
24
+ # rubocop:disable Naming/AccessorMethodName
25
+ def get_my_info
26
+ raw.bots.get_my_info
27
+ end
28
+ # rubocop:enable Naming/AccessorMethodName
29
+
30
+ def edit_my_info(**extra)
31
+ raw.bots.edit_my_info(**extra)
32
+ end
33
+
34
+ # rubocop:disable Naming/AccessorMethodName
35
+ def set_my_commands(commands)
36
+ edit_my_info(commands:)
37
+ end
38
+ # rubocop:enable Naming/AccessorMethodName
39
+
40
+ def delete_my_commands
41
+ edit_my_info(commands: [])
42
+ end
43
+
44
+ def get_all_chats(**extra)
45
+ raw.chats.get_all(**extra)
46
+ end
47
+
48
+ def get_chat(chat_id)
49
+ raw.chats.get_by_id(chat_id:)
50
+ end
51
+
52
+ def get_chat_by_link(chat_link)
53
+ raw.chats.get_by_link(chat_link:)
54
+ end
55
+
56
+ def edit_chat_info(chat_id, **extra)
57
+ raw.chats.edit(chat_id:, **extra)
58
+ end
59
+
60
+ def get_chat_membership(chat_id)
61
+ raw.chats.get_chat_membership(chat_id:)
62
+ end
63
+
64
+ def get_chat_admins(chat_id)
65
+ raw.chats.get_chat_admins(chat_id:)
66
+ end
67
+
68
+ def add_chat_members(chat_id, user_ids)
69
+ raw.chats.add_chat_members(chat_id:, user_ids:)
70
+ end
71
+
72
+ def get_chat_members(chat_id, **extra)
73
+ raw.chats.get_chat_members(chat_id:, **csv_query(extra, :user_ids))
74
+ end
75
+
76
+ def remove_chat_member(chat_id, user_id, block: nil)
77
+ raw.chats.remove_chat_member(chat_id:, user_id:, block:)
78
+ end
79
+
80
+ def get_pinned_message(chat_id)
81
+ raw.chats.get_pinned_message(chat_id:)
82
+ end
83
+
84
+ def pin_message(chat_id, message_id, **extra)
85
+ raw.chats.pin_message(chat_id:, message_id:, notify: extra[:notify])
86
+ end
87
+
88
+ def unpin_message(chat_id)
89
+ raw.chats.unpin_message(chat_id:)
90
+ end
91
+
92
+ def send_action(chat_id, action)
93
+ raw.chats.send_action(chat_id:, action:)
94
+ end
95
+
96
+ def leave_chat(chat_id)
97
+ raw.chats.leave_chat(chat_id:)
98
+ end
99
+
100
+ def send_message_to_chat(chat_id, text, **extra)
101
+ message_from(raw.messages.send(chat_id:, text:, **extra))
102
+ end
103
+
104
+ def send_message_to_user(user_id, text, **extra)
105
+ message_from(raw.messages.send(user_id:, text:, **extra))
106
+ end
107
+
108
+ def get_messages(chat_id, **extra)
109
+ raw.messages.get(chat_id:, **csv_query(extra, :message_ids))
110
+ end
111
+
112
+ def get_message(message_id)
113
+ raw.messages.get_by_id(message_id:)
114
+ end
115
+
116
+ def edit_message(message_id, **extra)
117
+ raw.messages.edit(message_id:, **extra)
118
+ end
119
+
120
+ def delete_message(message_id, **extra)
121
+ raw.messages.delete(message_id:, **extra)
122
+ end
123
+
124
+ def answer_on_callback(callback_id, **extra)
125
+ raw.messages.answer_on_callback(callback_id:, **extra)
126
+ end
127
+
128
+ # rubocop:disable Naming/AccessorMethodName
129
+ def get_subscriptions
130
+ raw.subscriptions.get_subscriptions
131
+ end
132
+ # rubocop:enable Naming/AccessorMethodName
133
+
134
+ def subscribe(url, update_types: nil, secret: nil)
135
+ raw.subscriptions.subscribe(url:, update_types:, secret:)
136
+ end
137
+
138
+ def unsubscribe(url)
139
+ raw.subscriptions.unsubscribe(url:)
140
+ end
141
+
142
+ def poll_updates(types = [], marker: nil, timeout: Polling::DEFAULT_TIMEOUT,
143
+ retry_interval: Polling::DEFAULT_RETRY_INTERVAL, read_timeout: nil, &block)
144
+ poller = Polling.new(
145
+ self,
146
+ types:,
147
+ marker:,
148
+ timeout:,
149
+ retry_interval:,
150
+ read_timeout:
151
+ )
152
+
153
+ return poller.each unless block
154
+
155
+ poller.each(&block)
156
+ end
157
+
158
+ def upload_image(options)
159
+ data = upload.image(**options)
160
+ ImageAttachment.new(token: data[:token], photos: data[:photos], url: data[:url] || data["url"])
161
+ end
162
+
163
+ def upload_video(options)
164
+ data = upload.video(**options)
165
+ VideoAttachment.new(token: data[:token] || data["token"])
166
+ end
167
+
168
+ def upload_audio(options)
169
+ data = upload.audio(**options)
170
+ AudioAttachment.new(token: data[:token] || data["token"])
171
+ end
172
+
173
+ def upload_file(options)
174
+ data = upload.file(**options)
175
+ FileAttachment.new(token: data[:token] || data["token"])
176
+ end
177
+
178
+ private
179
+
180
+ def normalize_types(types)
181
+ return types unless types.is_a?(Array)
182
+
183
+ types.join(",")
184
+ end
185
+
186
+ def csv_query(query, key)
187
+ query.merge(key => normalize_types(query[key]))
188
+ end
189
+
190
+ def message_from(response)
191
+ response.fetch("message") { response.fetch(:message) }
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MaxApiClient
4
+ # Base type for all outgoing attachment payload wrappers.
5
+ class Attachment
6
+ def to_h
7
+ raise NotImplementedError, "#{self.class} must implement #to_h"
8
+ end
9
+ end
10
+
11
+ # Shared attachment implementation for upload-backed media objects.
12
+ class MediaAttachment < Attachment
13
+ attr_reader :token
14
+
15
+ def initialize(token: nil)
16
+ super()
17
+ @token = token
18
+ end
19
+
20
+ def payload
21
+ { token: token }
22
+ end
23
+ end
24
+
25
+ # Attachment wrapper for uploaded or remote images.
26
+ class ImageAttachment < MediaAttachment
27
+ attr_reader :photos, :url
28
+
29
+ def initialize(token: nil, photos: nil, url: nil)
30
+ super(token:)
31
+ @photos = photos
32
+ @url = url
33
+ end
34
+
35
+ def payload
36
+ return { token: token } if token
37
+ return { url: url } if url
38
+
39
+ { photos: photos }
40
+ end
41
+
42
+ def to_h
43
+ { type: "image", payload: }
44
+ end
45
+ end
46
+
47
+ # Attachment wrapper for uploaded videos.
48
+ class VideoAttachment < MediaAttachment
49
+ def to_h
50
+ { type: "video", payload: }
51
+ end
52
+ end
53
+
54
+ # Attachment wrapper for uploaded audio files.
55
+ class AudioAttachment < MediaAttachment
56
+ def to_h
57
+ { type: "audio", payload: }
58
+ end
59
+ end
60
+
61
+ # Attachment wrapper for generic uploaded files.
62
+ class FileAttachment < MediaAttachment
63
+ def to_h
64
+ { type: "file", payload: }
65
+ end
66
+ end
67
+
68
+ # Attachment wrapper for sticker references.
69
+ class StickerAttachment < Attachment
70
+ attr_reader :code
71
+
72
+ def initialize(code:)
73
+ super()
74
+ @code = code
75
+ end
76
+
77
+ def to_h
78
+ { type: "sticker", payload: { code: } }
79
+ end
80
+ end
81
+
82
+ # Attachment wrapper for geo coordinates.
83
+ class LocationAttachment < Attachment
84
+ attr_reader :longitude, :latitude
85
+
86
+ def initialize(lon:, lat:)
87
+ super()
88
+ @longitude = lon
89
+ @latitude = lat
90
+ end
91
+
92
+ def to_h
93
+ { type: "location", latitude:, longitude: }
94
+ end
95
+ end
96
+
97
+ # Attachment wrapper for shared links or tokens.
98
+ class ShareAttachment < Attachment
99
+ attr_reader :url, :token
100
+
101
+ def initialize(url: nil, token: nil)
102
+ super()
103
+ @url = url
104
+ @token = token
105
+ end
106
+
107
+ def to_h
108
+ { type: "share", payload: { url:, token: } }
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MaxApiClient
4
+ # Shared HTTP helper for raw API groups.
5
+ class BaseApi
6
+ HTTP_METHODS = %i[get post put patch delete].freeze
7
+
8
+ def initialize(client)
9
+ @client = client
10
+ end
11
+
12
+ HTTP_METHODS.each do |name|
13
+ define_method(name) do |path, **options|
14
+ call_api(name, path, **options)
15
+ end
16
+ end
17
+
18
+ private
19
+
20
+ attr_reader :client
21
+
22
+ def call_api(http_method, path, **options)
23
+ result = client.call(
24
+ method: http_method,
25
+ path:,
26
+ **options
27
+ )
28
+ raise ApiError.new(result[:status], result[:data]) unless result[:status] == 200
29
+
30
+ result[:data]
31
+ end
32
+
33
+ def compact_nil(hash)
34
+ hash.compact
35
+ end
36
+ end
37
+ end