max_bot 0.1.1 → 0.2.1
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
|
@@ -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
|
data/lib/max/bot/http.rb
ADDED
|
@@ -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
|
data/lib/max/bot/json.rb
ADDED
|
@@ -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,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
|
-
|
|
1
|
+
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
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
|