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.
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Max
4
+ module Bot
5
+ # Builders for +attachments+ in +POST /messages+ (NewMessageBody).
6
+ # Types follow the MAX Bot API: image, video, audio, file, sticker, contact,
7
+ # inline_keyboard, location, share.
8
+ #
9
+ # @see https://dev.max.ru/docs-api/methods/POST/messages
10
+ # @see https://dev.max.ru/docs-api/objects/NewMessageBody
11
+ module Attachments
12
+ module_function
13
+
14
+ def prune(hash)
15
+ hash.reject { |_, v| v.nil? }
16
+ end
17
+
18
+ # --- Media (usually +token+ from +POST /uploads+ flow) ---
19
+
20
+ def image(token: nil, url: nil, photos: nil)
21
+ { type: 'image', payload: prune(token: token, url: url, photos: photos) }
22
+ end
23
+
24
+ def video(token: nil)
25
+ { type: 'video', payload: prune(token: token) }
26
+ end
27
+
28
+ def audio(token: nil)
29
+ { type: 'audio', payload: prune(token: token) }
30
+ end
31
+
32
+ def file(token: nil)
33
+ { type: 'file', payload: prune(token: token) }
34
+ end
35
+
36
+ def sticker(code:)
37
+ { type: 'sticker', payload: prune(code: code) }
38
+ end
39
+
40
+ def contact(name:, contact_id: nil, vcf_info: nil, vcf_phone: nil)
41
+ {
42
+ type: 'contact',
43
+ payload: prune(
44
+ name: name,
45
+ contact_id: contact_id,
46
+ vcf_info: vcf_info,
47
+ vcf_phone: vcf_phone
48
+ )
49
+ }
50
+ end
51
+
52
+ # +rows+ is an Array of rows; each row is an Array of button Hashes (see +callback_button+, +link_button+, …).
53
+ def inline_keyboard(rows)
54
+ { type: 'inline_keyboard', payload: { buttons: rows } }
55
+ end
56
+ alias keyboard inline_keyboard
57
+
58
+ # Per client typings, latitude/longitude sit on the attachment object (not under +payload+).
59
+ def location(latitude:, longitude:)
60
+ { type: 'location', latitude: latitude, longitude: longitude }
61
+ end
62
+
63
+ def share(url: nil, **payload_rest)
64
+ { type: 'share', payload: prune({ url: url }.merge(payload_rest)) }
65
+ end
66
+
67
+ # --- Inline keyboard buttons (https://dev.max.ru/docs-api keyboard section) ---
68
+
69
+ def callback_button(text, payload, intent: nil)
70
+ prune(type: 'callback', text: text, payload: payload, intent: intent)
71
+ end
72
+
73
+ def link_button(text, url)
74
+ { type: 'link', text: text, url: url }
75
+ end
76
+
77
+ def request_contact_button(text)
78
+ { type: 'request_contact', text: text }
79
+ end
80
+
81
+ def request_geo_location_button(text, quick: nil)
82
+ prune(type: 'request_geo_location', text: text, quick: quick)
83
+ end
84
+
85
+ # Some integrations use +chat+ for deep-link style buttons; pass API fields you need.
86
+ def chat_button(text, chat_title:, chat_description: nil, start_payload: nil, uuid: nil)
87
+ prune(
88
+ type: 'chat',
89
+ text: text,
90
+ chat_title: chat_title,
91
+ chat_description: chat_description,
92
+ start_payload: start_payload,
93
+ uuid: uuid
94
+ )
95
+ end
96
+
97
+ def message_button(text, payload: nil)
98
+ prune(type: 'message', text: text, payload: payload)
99
+ end
100
+
101
+ # Escape hatch for new API button/attachment shapes.
102
+ def raw(type:, payload: nil, **top_level)
103
+ h = { type: type }
104
+ h[:payload] = payload unless payload.nil?
105
+ prune(h.merge(top_level))
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Max
4
+ module Bot
5
+ # Polls the MAX Bot API with GET /updates and yields each update to your block.
6
+ class Client
7
+ attr_reader :api, :options
8
+
9
+ def self.run(token, **options, &block)
10
+ raise ArgumentError, 'block required' unless block
11
+
12
+ new(token, **options).run(&block)
13
+ end
14
+
15
+ def initialize(token, **options)
16
+ @options = default_options.merge(options)
17
+ url = @options.delete(:url) || Api::DEFAULT_URL
18
+ @api = @options.delete(:api) || Api.new(token, url: url)
19
+ end
20
+
21
+ def run(&block)
22
+ raise ArgumentError, 'block required' unless block
23
+
24
+ marker = options[:marker]
25
+ limit = options.fetch(:limit, 100)
26
+ timeout = options.fetch(:timeout, 30)
27
+ types = options[:types]
28
+ backoff = options.fetch(:error_backoff, 3).to_f
29
+ backoff = 0.5 if backoff < 0.5
30
+
31
+ loop do
32
+ result = fetch_updates(marker: marker, limit: limit, timeout: timeout, types: types)
33
+ marker = advance_marker(result, marker)
34
+ deliver_updates(result, &block)
35
+ rescue Interrupt
36
+ raise
37
+ rescue ApiError, Faraday::Error => e
38
+ handle_poll_error(e, backoff)
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def fetch_updates(marker:, limit:, timeout:, types:)
45
+ api.get_updates(marker: marker, limit: limit, timeout: timeout, types: types)
46
+ end
47
+
48
+ def advance_marker(result, marker)
49
+ return marker unless result.is_a?(Hash) && result.key?(:marker)
50
+
51
+ result[:marker]
52
+ end
53
+
54
+ def deliver_updates(result, &block)
55
+ updates = result.is_a?(Hash) ? (result[:updates] || []) : []
56
+ updates.each { |u| block.call(u) }
57
+ end
58
+
59
+ def handle_poll_error(error, backoff)
60
+ if (cb = options[:on_error])
61
+ cb.call(error)
62
+ elsif (log = options[:logger]) && log.respond_to?(:warn)
63
+ log.warn("#{error.class}: #{error.message} — retrying in #{backoff}s")
64
+ end
65
+ sleeper = options[:sleep] || ->(duration) { Kernel.sleep(duration) }
66
+ sleeper.call(backoff)
67
+ end
68
+
69
+ def default_options
70
+ {
71
+ limit: 100,
72
+ timeout: 30,
73
+ types: nil,
74
+ marker: nil,
75
+ error_backoff: 3,
76
+ on_error: nil,
77
+ logger: nil,
78
+ sleep: nil
79
+ }
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Max
4
+ module Bot
5
+ class Configuration
6
+ attr_accessor :adapter, :connection_open_timeout, :connection_timeout
7
+
8
+ def initialize
9
+ @adapter = Faraday.default_adapter
10
+ @connection_open_timeout = 20
11
+ @connection_timeout = 90
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Max
4
+ module Bot
5
+ class Error < StandardError; end
6
+
7
+ class ApiError < Error
8
+ attr_reader :status, :body
9
+
10
+ def initialize(message, status: nil, body: nil)
11
+ super(message)
12
+ @status = status
13
+ @body = body
14
+ end
15
+
16
+ def to_s
17
+ return super if body.nil? || !body.is_a?(Hash)
18
+
19
+ detail = body[:message] || body['message']
20
+ detail ? "#{super}: #{detail}" : super
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Max
4
+ module Bot
5
+ # Low-level HTTP client (Faraday). Use {Api} for public endpoints.
6
+ class Http
7
+ attr_reader :token, :base_url
8
+
9
+ def initialize(token, base_url, connection: nil)
10
+ @token = token.to_s
11
+ @base_url = base_url.to_s.chomp('/')
12
+ @injected_connection = connection
13
+ end
14
+
15
+ def get(path, query: {})
16
+ perform(:get, path, query: query)
17
+ end
18
+
19
+ def post(path, query: nil, body: nil)
20
+ perform(:post, path, query: query, body: body)
21
+ end
22
+
23
+ def delete(path, query: nil)
24
+ perform(:delete, path, query: query)
25
+ end
26
+
27
+ private
28
+
29
+ def perform(method, path, query: nil, body: nil)
30
+ resp = connection.run_request(method, path, nil, nil) do |req|
31
+ req.headers['Authorization'] = token
32
+ if body
33
+ req.headers['Content-Type'] = 'application/json; charset=utf-8'
34
+ req.body = ::JSON.generate(body)
35
+ end
36
+ req.params.update(query) if query&.any?
37
+ end
38
+ Json.decode_response(resp)
39
+ end
40
+
41
+ def connection
42
+ @injected_connection || @default_connection ||= build_connection
43
+ end
44
+
45
+ def build_connection
46
+ Faraday.new(url: base_url) do |f|
47
+ f.adapter Bot.configuration.adapter
48
+ f.options.timeout = Bot.configuration.connection_timeout
49
+ f.options.open_timeout = Bot.configuration.connection_open_timeout
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Max
4
+ module Bot
5
+ module Json
6
+ module_function
7
+
8
+ def decode_response(response)
9
+ raw = response.body.to_s
10
+ data =
11
+ if raw.empty?
12
+ {}
13
+ else
14
+ ::JSON.parse(raw)
15
+ end
16
+
17
+ unless response.success?
18
+ body = data.is_a?(Hash) ? deep_symbolize(data) : data
19
+ raise ApiError.new("HTTP #{response.status}", status: response.status, body: body)
20
+ end
21
+
22
+ deep_symbolize(data)
23
+ rescue ::JSON::ParserError => e
24
+ raise ApiError.new("Invalid JSON: #{e.message}", status: response.status, body: raw)
25
+ end
26
+
27
+ def deep_symbolize(obj)
28
+ case obj
29
+ when Hash
30
+ obj.each_with_object({}) { |(k, v), h| h[k.to_sym] = deep_symbolize(v) }
31
+ when Array
32
+ obj.map { |e| deep_symbolize(e) }
33
+ else
34
+ obj
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'openssl'
5
+ require 'securerandom'
6
+ require 'uri'
7
+
8
+ module Max
9
+ module Bot
10
+ # Multipart +data=@file+ upload to the URL returned by +POST /uploads+.
11
+ # @see https://dev.max.ru/docs-api/methods/POST/uploads
12
+ module MultipartUpload
13
+ module_function
14
+
15
+ def post_file(upload_url:, path:, authorization:, filename: nil)
16
+ raise ArgumentError, 'upload_url must be a non-empty String' if upload_url.to_s.strip.empty?
17
+
18
+ filename ||= File.basename(path)
19
+ filename = filename.to_s.gsub(/["\r\n]/, '_')
20
+
21
+ uri = URI(upload_url)
22
+ boundary = "----maxbot#{SecureRandom.hex(16)}"
23
+ file_content = File.binread(path)
24
+ body = +''
25
+ body << "--#{boundary}\r\n"
26
+ body << %(Content-Disposition: form-data; name="data"; filename="#{filename}"\r\n)
27
+ body << "Content-Type: application/octet-stream\r\n\r\n"
28
+ body << file_content
29
+ body << "\r\n--#{boundary}--\r\n"
30
+
31
+ http = Net::HTTP.new(uri.host, uri.port)
32
+ http.use_ssl = (uri.scheme == 'https')
33
+ http.open_timeout = Bot.configuration.connection_open_timeout
34
+ http.read_timeout = Bot.configuration.connection_timeout
35
+
36
+ request = Net::HTTP::Post.new(uri.request_uri)
37
+ request['Content-Type'] = "multipart/form-data; boundary=#{boundary}"
38
+ request['Authorization'] = authorization.to_s if authorization && !authorization.to_s.empty?
39
+ request.body = body
40
+
41
+ response = http.request(request)
42
+ unless response.is_a?(Net::HTTPSuccess)
43
+ raise ApiError.new(
44
+ "Upload failed HTTP #{response.code}",
45
+ status: response.code.to_i,
46
+ body: response.body
47
+ )
48
+ end
49
+
50
+ response.body.to_s
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Max
4
+ module Bot
5
+ # Convenience parsers aligned with the public MAX Bot API object shapes
6
+ # (Update → message → body / recipient). Field names may evolve — treat as best-effort.
7
+ module UpdateHelpers
8
+ module_function
9
+
10
+ def message_created?(update)
11
+ update.is_a?(Hash) && update[:update_type].to_s == 'message_created'
12
+ end
13
+
14
+ def message_from(update)
15
+ return unless update.is_a?(Hash)
16
+
17
+ update[:message]
18
+ end
19
+
20
+ def message_text(message)
21
+ return unless message.is_a?(Hash)
22
+
23
+ body = message[:body]
24
+ case body
25
+ when Hash
26
+ body[:text] || body[:markdown]
27
+ when String
28
+ body
29
+ end
30
+ end
31
+
32
+ # Returns either [:chat_id, id] or [:user_id, id] for send_message kwargs.
33
+ def message_destination(message)
34
+ return unless message.is_a?(Hash)
35
+
36
+ recipient = message[:recipient]
37
+ return unless recipient.is_a?(Hash)
38
+
39
+ if recipient.key?(:chat_id) && !recipient[:chat_id].nil?
40
+ [:chat_id, recipient[:chat_id]]
41
+ elsif recipient.key?(:user_id) && !recipient[:user_id].nil?
42
+ [:user_id, recipient[:user_id]]
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Max
4
+ module Bot
5
+ VERSION = '0.2.0'
6
+ end
7
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Max
4
+ module Bot
5
+ # Helpers for HTTPS webhook endpoints (POST body = Update JSON).
6
+ # Optional header X-Max-Bot-Api-Secret when a subscription secret is set.
7
+ # https://dev.max.ru/docs-api/methods/POST/subscriptions
8
+ module Webhook
9
+ SECRET_HEADER = 'X-Max-Bot-Api-Secret'
10
+
11
+ module_function
12
+
13
+ def secret_valid?(header_value, expected_secret)
14
+ return true if expected_secret.nil? || expected_secret.to_s.empty?
15
+
16
+ a = header_value.to_s
17
+ b = expected_secret.to_s
18
+ return false unless a.bytesize == b.bytesize
19
+
20
+ l = 0
21
+ a.bytes.zip(b.bytes) { |x, y| l |= x ^ y }
22
+ l.zero?
23
+ end
24
+
25
+ def extract_secret_header(env)
26
+ return unless env.is_a?(Hash)
27
+
28
+ env['HTTP_X_MAX_BOT_API_SECRET'] || env[SECRET_HEADER]
29
+ end
30
+
31
+ def parse_json(body)
32
+ return {} if body.nil? || body.to_s.empty?
33
+
34
+ JSON.parse(body.to_s, symbolize_names: true)
35
+ end
36
+ end
37
+ end
38
+ end
data/lib/max/bot.rb ADDED
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'faraday'
5
+
6
+ require_relative 'bot/version'
7
+ require_relative 'bot/configuration'
8
+ require_relative 'bot/errors'
9
+ require_relative 'bot/json'
10
+ require_relative 'bot/http'
11
+ require_relative 'bot/attachments'
12
+ require_relative 'bot/multipart_upload'
13
+ require_relative 'bot/api/request_builders'
14
+ require_relative 'bot/api'
15
+ require_relative 'bot/client'
16
+ require_relative 'bot/webhook'
17
+ require_relative 'bot/update_helpers'
18
+
19
+ module Max
20
+ module Bot
21
+ class << self
22
+ attr_writer :configuration
23
+
24
+ def configuration
25
+ @configuration ||= Configuration.new
26
+ end
27
+
28
+ def configure
29
+ yield configuration
30
+ end
31
+ end
32
+ end
33
+ end
data/lib/max_bot.rb CHANGED
@@ -1,3 +1,3 @@
1
- module MaxBot
1
+ # frozen_string_literal: true
2
2
 
3
- end
3
+ require_relative 'max/bot'
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'test_helper'
4
+
5
+ module Max
6
+ module Bot
7
+ class ApiRequestBuildersTest < Minitest::Test
8
+ def test_combine_attachments_order
9
+ a = { type: 'share', payload: { url: 'https://a' } }
10
+ b = { type: 'image', payload: { token: 't' } }
11
+ out = Api::RequestBuilders.combine_attachments(a, [b])
12
+ assert_equal %w[share image], out.map { |h| h[:type] }
13
+ end
14
+
15
+ def test_combine_attachments_rejects_invalid_attachment
16
+ err = assert_raises(ArgumentError) do
17
+ Api::RequestBuilders.combine_attachments('bad', nil)
18
+ end
19
+ assert_match(/attachment:/, err.message)
20
+ end
21
+
22
+ def test_updates_query_types_csv
23
+ q = Api::RequestBuilders.updates_query(marker: 5, limit: 10, timeout: 20, types: %w[a b])
24
+ assert_equal 5, q[:marker]
25
+ assert_equal 10, q[:limit]
26
+ assert_equal 20, q[:timeout]
27
+ assert_equal 'a,b', q[:types]
28
+ end
29
+
30
+ def test_updates_query_omits_empty_types
31
+ q = Api::RequestBuilders.updates_query(marker: nil, limit: nil, timeout: nil, types: [])
32
+ refute q.key?(:types)
33
+ end
34
+
35
+ def test_subscription_body_optional_fields
36
+ b = Api::RequestBuilders.subscription_body(url: 'https://x', update_types: %w[a], secret: 's')
37
+ assert_equal 'https://x', b[:url]
38
+ assert_equal %w[a], b[:update_types]
39
+ assert_equal 's', b[:secret]
40
+ end
41
+
42
+ def test_media_attachment_from_upload_image
43
+ att = Api::RequestBuilders.media_attachment_from_upload(:image, token: 'tok', url: 'https://u')
44
+ assert_equal 'image', att[:type]
45
+ assert_equal 'tok', att[:payload][:token]
46
+ assert_equal 'https://u', att[:payload][:url]
47
+ end
48
+
49
+ def test_media_attachment_unknown_type
50
+ assert_raises(ArgumentError) do
51
+ Api::RequestBuilders.media_attachment_from_upload(:unknown, token: 'x')
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end