max_bot_api 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.
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cgi'
4
+
5
+ module MaxBotApi
6
+ module Resources
7
+ # Messages API methods.
8
+ class Messages
9
+ def initialize(client)
10
+ @client = client
11
+ end
12
+
13
+ # List messages with filters.
14
+ def get_messages(chat_id: nil, message_ids: nil, from: nil, to: nil, count: nil)
15
+ query = {}
16
+ query['chat_id'] = chat_id if chat_id && chat_id.to_i != 0
17
+ Array(message_ids).each do |mid|
18
+ query['message_ids'] ||= []
19
+ query['message_ids'] << mid
20
+ end
21
+ query['from'] = from if from && from.to_i != 0
22
+ query['to'] = to if to && to.to_i != 0
23
+ query['count'] = count if count && count.to_i > 0
24
+
25
+ @client.request(:get, 'messages', query: normalize_array_query(query))
26
+ end
27
+
28
+ # Fetch a single message by ID.
29
+ # @param message_id [String]
30
+ def get_message(message_id:)
31
+ path = "messages/#{CGI.escape(message_id.to_s)}"
32
+ @client.request(:get, path)
33
+ end
34
+
35
+ # Edit a message by ID.
36
+ # @param message_id [String]
37
+ # @param message [MaxBotApi::Builders::MessageBuilder, Hash]
38
+ def edit_message(message_id:, message:)
39
+ body = message_payload(message)
40
+ result = @client.request(:put, 'messages', query: { 'message_id' => message_id }, body: body)
41
+ return true if result.is_a?(Hash) && result[:success]
42
+
43
+ raise Error, (result[:message] || 'message update failed')
44
+ end
45
+
46
+ # Delete a message by ID.
47
+ # @param message_id [String]
48
+ def delete_message(message_id:)
49
+ @client.request(:delete, 'messages', query: { 'message_id' => message_id })
50
+ end
51
+
52
+ # Answer a callback button press.
53
+ # @param callback_id [String]
54
+ # @param answer [Hash]
55
+ def answer_on_callback(callback_id:, answer:)
56
+ @client.request(:post, 'answers', query: { 'callback_id' => callback_id }, body: answer)
57
+ end
58
+
59
+ # Build a keyboard using the helper.
60
+ def new_keyboard_builder
61
+ Builders::KeyboardBuilder.new
62
+ end
63
+
64
+ # Send a message builder without returning the created message.
65
+ # @param message [MaxBotApi::Builders::MessageBuilder, Hash]
66
+ def send(message)
67
+ send_message(message, with_result: false)
68
+ end
69
+
70
+ # Send a message builder and return the created message hash.
71
+ # @param message [MaxBotApi::Builders::MessageBuilder, Hash]
72
+ def send_with_result(message)
73
+ send_message(message, with_result: true)
74
+ end
75
+
76
+ # Check if a message can be sent to the provided phone numbers.
77
+ # @param message [MaxBotApi::Builders::MessageBuilder, Hash]
78
+ def check(message)
79
+ message = ensure_builder(message)
80
+ query = {}
81
+ query['access_token'] = message.bot_token if message.reset?
82
+ query['phone_numbers'] = Array(message.phone_numbers).join(',') if message.phone_numbers
83
+
84
+ result = @client.request(:get, 'notify/exists', query: query, reset: message.reset?)
85
+ numbers = Array(result[:existing_phone_numbers])
86
+ [!numbers.empty?, result]
87
+ end
88
+
89
+ # List phone numbers that exist in MAX.
90
+ # @param message [MaxBotApi::Builders::MessageBuilder, Hash]
91
+ def list_exist(message)
92
+ message = ensure_builder(message)
93
+ query = {}
94
+ query['access_token'] = message.bot_token if message.reset?
95
+ query['phone_numbers'] = Array(message.phone_numbers).join(',') if message.phone_numbers
96
+
97
+ result = @client.request(:get, 'notify/exists', query: query, reset: message.reset?)
98
+ numbers = Array(result[:existing_phone_numbers])
99
+ [numbers.empty? ? nil : numbers, result]
100
+ end
101
+
102
+ private
103
+
104
+ def send_message(message, with_result:)
105
+ message = ensure_builder(message)
106
+ query = {}
107
+ query['chat_id'] = message.chat_id if message.chat_id && message.chat_id.to_i != 0
108
+ query['user_id'] = message.user_id if message.user_id && message.user_id.to_i != 0
109
+
110
+ response = @client.request(:post, 'messages', query: query, body: message_payload(message),
111
+ reset: message.reset?)
112
+ return response[:message] if with_result && response.is_a?(Hash)
113
+
114
+ nil
115
+ end
116
+
117
+ def message_payload(message)
118
+ message.is_a?(Builders::MessageBuilder) ? message.to_h : message
119
+ end
120
+
121
+ def ensure_builder(message)
122
+ return message if message.is_a?(Builders::MessageBuilder)
123
+
124
+ Builders::MessageBuilder.from_hash(message)
125
+ end
126
+
127
+ def normalize_array_query(query)
128
+ query.each_with_object({}) do |(key, value), acc|
129
+ acc[key] = if value.is_a?(Array)
130
+ value
131
+ else
132
+ value
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MaxBotApi
4
+ module Resources
5
+ # Webhook subscription methods.
6
+ class Subscriptions
7
+ def initialize(client)
8
+ @client = client
9
+ end
10
+
11
+ # List active subscriptions.
12
+ def get_subscriptions
13
+ @client.request(:get, 'subscriptions')
14
+ end
15
+
16
+ # Create a webhook subscription.
17
+ # @param url [String]
18
+ # @param update_types [Array<String>]
19
+ # @param secret [String, nil]
20
+ def subscribe(url:, update_types: [], secret: nil)
21
+ body = {
22
+ url: url,
23
+ update_types: update_types,
24
+ secret: secret,
25
+ version: @client.version
26
+ }.compact
27
+
28
+ @client.request(:post, 'subscriptions', body: body)
29
+ end
30
+
31
+ # Remove a webhook subscription.
32
+ # @param url [String]
33
+ def unsubscribe(url:)
34
+ @client.request(:delete, 'subscriptions', query: { 'url' => url })
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+ require 'stringio'
5
+
6
+ module MaxBotApi
7
+ module Resources
8
+ # Upload helpers for media and photos.
9
+ class Uploads
10
+ UPLOAD_TYPES = {
11
+ photo: 'image',
12
+ video: 'video',
13
+ audio: 'audio',
14
+ file: 'file'
15
+ }.freeze
16
+
17
+ def initialize(client)
18
+ @client = client
19
+ end
20
+
21
+ # Upload media from a local file.
22
+ # @param type [Symbol, String]
23
+ # @param filename [String]
24
+ def upload_media_from_file(type:, filename:)
25
+ File.open(filename, 'rb') do |fh|
26
+ upload_media_from_reader_with_name(type: type, io: fh, name: File.basename(filename))
27
+ end
28
+ end
29
+
30
+ # Upload media from a remote URL.
31
+ # @param type [Symbol, String]
32
+ # @param url [String]
33
+ def upload_media_from_url(type:, url:)
34
+ response = Faraday.get(url.to_s)
35
+ name = attachment_name(response.headers)
36
+ upload_media_from_reader_with_name(type: type, io: StringIO.new(response.body), name: name)
37
+ end
38
+
39
+ # Upload media from an IO object.
40
+ # @param type [Symbol, String]
41
+ # @param io [#read]
42
+ def upload_media_from_reader(type:, io:)
43
+ upload_media_from_reader_with_name(type: type, io: io, name: nil)
44
+ end
45
+
46
+ # Upload media from IO with explicit name.
47
+ # @param type [Symbol, String]
48
+ # @param io [#read]
49
+ # @param name [String, nil]
50
+ def upload_media_from_reader_with_name(type:, io:, name: nil)
51
+ upload_media_from_reader_internal(type: type, io: io, name: name)
52
+ end
53
+
54
+ # Upload a photo from a local file.
55
+ # @param path [String]
56
+ def upload_photo_from_file(path:)
57
+ upload_media_from_file(type: :photo, filename: path)
58
+ end
59
+
60
+ # Upload a photo from base64 content.
61
+ # @param code [String]
62
+ def upload_photo_from_base64_string(code:)
63
+ decoded = Base64.decode64(code)
64
+ upload_media_from_reader_with_name(type: :photo, io: StringIO.new(decoded), name: nil)
65
+ end
66
+
67
+ # Upload a photo from a remote URL.
68
+ # @param url [String]
69
+ def upload_photo_from_url(url:)
70
+ upload_media_from_url(type: :photo, url: url)
71
+ end
72
+
73
+ # Upload a photo from an IO object.
74
+ # @param io [#read]
75
+ def upload_photo_from_reader(io:)
76
+ upload_media_from_reader(type: :photo, io: io)
77
+ end
78
+
79
+ # Upload a photo from IO with explicit name.
80
+ # @param io [#read]
81
+ # @param name [String]
82
+ def upload_photo_from_reader_with_name(io:, name:)
83
+ upload_media_from_reader_with_name(type: :photo, io: io, name: name)
84
+ end
85
+
86
+ private
87
+
88
+ def upload_media_from_reader_internal(type:, io:, name: nil)
89
+ upload_type = UPLOAD_TYPES.fetch(type.to_sym) { type.to_s }
90
+ endpoint = @client.request(:post, 'uploads', query: { 'type' => upload_type })
91
+
92
+ file_name = name.to_s.empty? ? 'file' : name.to_s
93
+ file_part = Faraday::Multipart::FilePart.new(io, nil, file_name)
94
+
95
+ response = Faraday.post(endpoint[:url]) do |req|
96
+ req.body = { 'data' => file_part }
97
+ end
98
+
99
+ raise Error, "upload failed: #{response.status}" unless response.status == 200
100
+
101
+ JSON.parse(response.body, symbolize_names: true)
102
+ rescue Faraday::Error => e
103
+ raise NetworkError.new(op: 'POST uploads', original_error: e)
104
+ end
105
+
106
+ def attachment_name(headers)
107
+ disposition = headers['content-disposition'].to_s
108
+ return '' if disposition.empty?
109
+
110
+ match = disposition.match(/filename="?([^";]+)"?/)
111
+ match ? match[1] : ''
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MaxBotApi
4
+ module Updates
5
+ # Update parsing helpers.
6
+ class Parser
7
+ # Parse a single update JSON string or hash.
8
+ # @param data [String, Hash]
9
+ # @param debug [Boolean]
10
+ # @return [Hash]
11
+ def self.parse_update(data, debug: false)
12
+ raw = data.is_a?(String) ? data : JSON.generate(data)
13
+ update = data.is_a?(Hash) ? deep_symbolize_keys(data) : JSON.parse(raw, symbolize_names: true)
14
+
15
+ update[:debug_raw] = raw if debug
16
+ normalize_attachments!(update)
17
+ update
18
+ end
19
+
20
+ # Parse a list of updates.
21
+ # @param data [String, Hash]
22
+ # @param debug [Boolean]
23
+ # @return [Hash]
24
+ def self.parse_update_list(data, debug: false)
25
+ list = data.is_a?(Hash) ? data : JSON.parse(data.to_s, symbolize_names: true)
26
+ updates = Array(list[:updates])
27
+ list[:updates] = updates.map { |u| parse_update(u, debug: debug) }
28
+ list
29
+ end
30
+
31
+ # Normalize attachment payloads to symbolized hashes.
32
+ def self.normalize_attachments!(update)
33
+ message = update.dig(:message)
34
+ body = message && message[:body]
35
+ if body && body[:attachments].is_a?(Array)
36
+ body[:attachments] = body[:attachments].map { |att| deep_symbolize_keys(att) }
37
+ end
38
+
39
+ linked_body = update.dig(:message, :link, :message, :attachments)
40
+ return unless linked_body.is_a?(Array)
41
+
42
+ update[:message][:link][:message][:attachments] = linked_body.map { |att| deep_symbolize_keys(att) }
43
+ end
44
+
45
+ # Deep symbolize hash keys.
46
+ def self.deep_symbolize_keys(value)
47
+ case value
48
+ when Hash
49
+ value.each_with_object({}) do |(k, v), acc|
50
+ acc[k.to_sym] = deep_symbolize_keys(v)
51
+ end
52
+ when Array
53
+ value.map { |item| deep_symbolize_keys(item) }
54
+ else
55
+ value
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MaxBotApi
4
+ # Gem version.
5
+ VERSION = '0.1.0'
6
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'faraday'
4
+ require 'faraday/multipart'
5
+ require 'json'
6
+
7
+ require_relative 'max_bot_api/version'
8
+ require_relative 'max_bot_api/errors'
9
+ require_relative 'max_bot_api/client'
10
+
11
+ require_relative 'max_bot_api/builders/message_builder'
12
+ require_relative 'max_bot_api/builders/keyboard_builder'
13
+
14
+ require_relative 'max_bot_api/updates/parser'
15
+
16
+ require_relative 'max_bot_api/resources/bots'
17
+ require_relative 'max_bot_api/resources/chats'
18
+ require_relative 'max_bot_api/resources/messages'
19
+ require_relative 'max_bot_api/resources/subscriptions'
20
+ require_relative 'max_bot_api/resources/uploads'
21
+ require_relative 'max_bot_api/resources/debugs'
metadata ADDED
@@ -0,0 +1,140 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: max_bot_api
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - ChatGPT Codex
8
+ - Dmitry Merkushin
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 1980-01-02 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: base64
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '0.1'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '0.1'
27
+ - !ruby/object:Gem::Dependency
28
+ name: faraday
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '2.7'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '2.7'
41
+ - !ruby/object:Gem::Dependency
42
+ name: faraday-multipart
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1.0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '1.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: minitest
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '6.0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '6.0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: webmock
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '3.18'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '3.18'
83
+ description: Ruby gem that mirrors the MAX Bot API Go client.
84
+ email:
85
+ - merkushin@hey.com
86
+ executables: []
87
+ extensions: []
88
+ extra_rdoc_files: []
89
+ files:
90
+ - README.md
91
+ - docs/01-your-first-bot.md
92
+ - docs/02-listen-and-respond.md
93
+ - docs/03-attachments.md
94
+ - docs/04-keyboard.md
95
+ - docs/05-uploads.md
96
+ - docs/06-subscriptions.md
97
+ - examples/attachments.rb
98
+ - examples/basic_send.rb
99
+ - examples/keyboard.rb
100
+ - examples/long_poll.rb
101
+ - examples/subscriptions.rb
102
+ - examples/uploads.rb
103
+ - examples/webhook_rack.rb
104
+ - lib/max_bot_api.rb
105
+ - lib/max_bot_api/builders/keyboard_builder.rb
106
+ - lib/max_bot_api/builders/message_builder.rb
107
+ - lib/max_bot_api/client.rb
108
+ - lib/max_bot_api/errors.rb
109
+ - lib/max_bot_api/resources/bots.rb
110
+ - lib/max_bot_api/resources/chats.rb
111
+ - lib/max_bot_api/resources/debugs.rb
112
+ - lib/max_bot_api/resources/messages.rb
113
+ - lib/max_bot_api/resources/subscriptions.rb
114
+ - lib/max_bot_api/resources/uploads.rb
115
+ - lib/max_bot_api/updates/parser.rb
116
+ - lib/max_bot_api/version.rb
117
+ homepage: https://github.com/creogen/max_bot_api
118
+ licenses:
119
+ - MIT
120
+ metadata:
121
+ homepage_uri: https://github.com/creogen/max_bot_api
122
+ source_code_uri: https://github.com/creogen/max_bot_api/tree/main
123
+ rdoc_options: []
124
+ require_paths:
125
+ - lib
126
+ required_ruby_version: !ruby/object:Gem::Requirement
127
+ requirements:
128
+ - - ">="
129
+ - !ruby/object:Gem::Version
130
+ version: '3.1'
131
+ required_rubygems_version: !ruby/object:Gem::Requirement
132
+ requirements:
133
+ - - ">="
134
+ - !ruby/object:Gem::Version
135
+ version: '0'
136
+ requirements: []
137
+ rubygems_version: 3.7.2
138
+ specification_version: 4
139
+ summary: Ruby client for MAX Bot API
140
+ test_files: []