waha-ruby 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,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Waha
6
+ module Resources
7
+ class Messages < Resource
8
+ DATA_MEDIA_CHARS = "a-zA-Z0-9!/:[:space:]\u0023$&'()*+,;=?~_-"
9
+ DATA_URL_PATTERN = Regexp.new("\\Adata:[#{DATA_MEDIA_CHARS}]*;base64,", Regexp::IGNORECASE)
10
+
11
+ def send_text(chat_id:, text:, id: nil, reply_to: nil, session: nil)
12
+ body = { session: session_name(session, "send_text"), chatId: chat_id, text: }
13
+ body[:id] = id unless id.nil?
14
+ body[:reply_to] = reply_to unless reply_to.nil?
15
+ transport.request(
16
+ method: :post,
17
+ path: "/api/sendText",
18
+ operation: "send_text",
19
+ expected_status: 201,
20
+ body:
21
+ )
22
+ end
23
+
24
+ def edit(chat_id:, message_id:, text:, session: nil)
25
+ name = session_name(session, "edit_message")
26
+ transport.request(
27
+ method: :put,
28
+ path: message_path(name, chat_id, message_id),
29
+ operation: "edit_message",
30
+ expected_status: 200,
31
+ body: { text: }
32
+ )
33
+ end
34
+
35
+ def list(chat_id:, limit: 100, offset: nil, session: nil, **query)
36
+ name = session_name(session, "list_messages")
37
+ query = Support.query_hash(compact_hash(query.merge(limit:, offset:)))
38
+ transport.request(
39
+ method: :get,
40
+ path: "/api/#{segment(name)}/chats/#{segment(chat_id)}/messages",
41
+ operation: "list_messages",
42
+ expected_status: 200,
43
+ query:
44
+ )
45
+ end
46
+
47
+ def find(chat_id:, message_id:, download_media: nil, session: nil)
48
+ name = session_name(session, "find_message")
49
+ transport.request(
50
+ method: :get,
51
+ path: message_path(name, chat_id, message_id),
52
+ operation: "find_message",
53
+ expected_status: 200,
54
+ query: Support.query_hash(compact_hash(download_media:))
55
+ )
56
+ end
57
+
58
+ def send_seen(chat_id:, message_ids: nil, session: nil)
59
+ body = { session: session_name(session, "send_seen"), chatId: chat_id }
60
+ body[:messageIds] = Array(message_ids) unless message_ids.nil?
61
+ transport.request(
62
+ method: :post,
63
+ path: "/api/sendSeen",
64
+ operation: "send_seen",
65
+ expected_status: 201,
66
+ body:
67
+ )
68
+ end
69
+
70
+ def start_typing(chat_id:, session: nil)
71
+ typing("start_typing", "startTyping", chat_id:, session:)
72
+ end
73
+
74
+ def stop_typing(chat_id:, session: nil)
75
+ typing("stop_typing", "stopTyping", chat_id:, session:)
76
+ end
77
+
78
+ def new_message_id(session: nil)
79
+ name = session_name(session, "new_message_id")
80
+ transport.request(
81
+ method: :get,
82
+ path: "/api/#{segment(name)}/new-message-id",
83
+ operation: "new_message_id",
84
+ expected_status: 200
85
+ )
86
+ end
87
+
88
+ def send_image(chat_id:, file:, mimetype:, filename: nil, caption: nil, reply_to: nil, session: nil)
89
+ body = file_body("send_image", { chat_id:, file:, mimetype:, filename:, caption:, reply_to:, session: })
90
+ transport.request(
91
+ method: :post,
92
+ path: "/api/sendImage",
93
+ operation: "send_image",
94
+ expected_status: 201,
95
+ body:
96
+ )
97
+ end
98
+
99
+ def send_file(chat_id:, file:, mimetype:, filename: nil, caption: nil, reply_to: nil, session: nil)
100
+ body = file_body("send_file", { chat_id:, file:, mimetype:, filename:, caption:, reply_to:, session: })
101
+ transport.request(
102
+ method: :post,
103
+ path: "/api/sendFile",
104
+ operation: "send_file",
105
+ expected_status: 201,
106
+ body:
107
+ )
108
+ end
109
+
110
+ def send_voice(chat_id:, file:, mimetype:, filename: nil, reply_to: nil, convert: nil, session: nil)
111
+ body = file_body("send_voice", { chat_id:, file:, mimetype:, filename:, reply_to:, session: })
112
+ body[:convert] = convert unless convert.nil?
113
+ transport.request(
114
+ method: :post,
115
+ path: "/api/sendVoice",
116
+ operation: "send_voice",
117
+ expected_status: 201,
118
+ body:
119
+ )
120
+ end
121
+
122
+ private
123
+
124
+ def file_body(operation, attributes)
125
+ file_payload = file_payload(
126
+ operation,
127
+ file: attributes[:file],
128
+ mimetype: attributes[:mimetype],
129
+ filename: attributes[:filename]
130
+ )
131
+ Support.compact_hash(
132
+ session: session_name(attributes[:session], operation),
133
+ chatId: attributes[:chat_id],
134
+ file: file_payload,
135
+ caption: attributes[:caption],
136
+ reply_to: attributes[:reply_to]
137
+ )
138
+ end
139
+
140
+ def file_payload(operation, file:, mimetype:, filename:)
141
+ raise ValidationError.new(operation:, details: "file must be a non-empty string") if file.to_s.empty?
142
+ raise ValidationError.new(operation:, details: "mimetype is required") if mimetype.to_s.empty?
143
+
144
+ payload = { mimetype: }
145
+ payload[:filename] = filename unless filename.nil?
146
+
147
+ if http_url?(file)
148
+ payload.merge(url: file)
149
+ elsif data_url?(file)
150
+ payload.merge(data: file.split(",", 2).last)
151
+ else
152
+ raise ValidationError.new(operation:, details: "file must be an http(s) URL or data URL")
153
+ end
154
+ end
155
+
156
+ def http_url?(value)
157
+ uri = URI.parse(value.to_s)
158
+ uri.is_a?(URI::HTTP) && !uri.host.to_s.empty?
159
+ rescue URI::InvalidURIError
160
+ false
161
+ end
162
+
163
+ def data_url?(value)
164
+ DATA_URL_PATTERN.match?(value.to_s)
165
+ end
166
+
167
+ def typing(operation, endpoint, chat_id:, session:)
168
+ transport.request(
169
+ method: :post,
170
+ path: "/api/#{endpoint}",
171
+ operation:,
172
+ expected_status: 201,
173
+ body: { session: session_name(session, operation), chatId: chat_id }
174
+ )
175
+ end
176
+
177
+ def message_path(session_name, chat_id, message_id)
178
+ "/api/#{segment(session_name)}/chats/#{segment(chat_id)}/messages/#{segment(message_id)}"
179
+ end
180
+
181
+ def compact_hash(hash)
182
+ Support.compact_hash(hash)
183
+ end
184
+ end
185
+ end
186
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Waha
4
+ module Resources
5
+ class Presence < Resource
6
+ def subscribe(chat_id:, session: nil)
7
+ name = session_name(session, "subscribe_presence")
8
+ transport.request(
9
+ method: :post,
10
+ path: "/api/#{segment(name)}/presence/#{segment(chat_id)}/subscribe",
11
+ operation: "subscribe_presence",
12
+ expected_status: [200, 201]
13
+ )
14
+ end
15
+
16
+ def list(session: nil)
17
+ name = session_name(session, "list_presence")
18
+ transport.request(
19
+ method: :get,
20
+ path: "/api/#{segment(name)}/presence",
21
+ operation: "list_presence",
22
+ expected_status: 200
23
+ )
24
+ end
25
+
26
+ def get(chat_id:, session: nil)
27
+ name = session_name(session, "get_presence")
28
+ transport.request(
29
+ method: :get,
30
+ path: "/api/#{segment(name)}/presence/#{segment(chat_id)}",
31
+ operation: "get_presence",
32
+ expected_status: 200
33
+ )
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Waha
4
+ module Resources
5
+ class Sessions < Resource
6
+ def list(all: nil)
7
+ transport.request(
8
+ method: :get,
9
+ path: "/api/sessions/",
10
+ operation: "list_sessions",
11
+ expected_status: 200,
12
+ query: Support.compact_hash(all: all)
13
+ )
14
+ end
15
+
16
+ def get(session: nil)
17
+ name = session_name(session, "get_session")
18
+ transport.request(
19
+ method: :get,
20
+ path: "/api/sessions/#{segment(name)}",
21
+ operation: "get_session",
22
+ expected_status: 200
23
+ )
24
+ end
25
+
26
+ def create(name:, start: nil, config: nil)
27
+ transport.request(
28
+ method: :post,
29
+ path: "/api/sessions",
30
+ operation: "create_session",
31
+ expected_status: [200, 201],
32
+ body: Support.compact_hash(name:, start:, config:)
33
+ )
34
+ end
35
+
36
+ def start(session: nil)
37
+ action("start_session", "start", session:)
38
+ end
39
+
40
+ def stop(session: nil)
41
+ action("stop_session", "stop", session:)
42
+ end
43
+
44
+ def restart(session: nil)
45
+ action("restart_session", "restart", session:)
46
+ end
47
+
48
+ def logout(session: nil)
49
+ action("logout_session", "logout", session:, body: {})
50
+ end
51
+
52
+ def destroy(session: nil)
53
+ name = session_name(session, "delete_session")
54
+ transport.request(
55
+ method: :delete,
56
+ path: "/api/sessions/#{segment(name)}",
57
+ operation: "delete_session",
58
+ expected_status: [200, 204]
59
+ )
60
+ end
61
+
62
+ def qr(format: nil, session: nil)
63
+ name = session_name(session, "session_qr")
64
+ response_format = format.nil? ? :binary : :json
65
+ transport.request(
66
+ method: :get,
67
+ path: "/api/#{segment(name)}/auth/qr",
68
+ operation: "session_qr",
69
+ expected_status: 200,
70
+ query: Support.compact_hash(format: format),
71
+ response: response_format
72
+ )
73
+ end
74
+
75
+ def request_code(phone_number:, method: nil, session: nil)
76
+ name = session_name(session, "request_code")
77
+ transport.request(
78
+ method: :post,
79
+ path: "/api/#{segment(name)}/auth/request-code",
80
+ operation: "request_code",
81
+ expected_status: [200, 201],
82
+ body: Support.compact_hash(phoneNumber: phone_number, method: method)
83
+ )
84
+ end
85
+
86
+ private
87
+
88
+ def action(operation, action, session:, body: nil)
89
+ name = session_name(session, operation)
90
+ transport.request(
91
+ method: :post,
92
+ path: "/api/sessions/#{segment(name)}/#{action}",
93
+ operation:,
94
+ expected_status: [200, 201],
95
+ body:
96
+ )
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Waha
4
+ module Resources
5
+ class Webhooks < Resource
6
+ def initialize(transport:, session:, sessions:)
7
+ super(transport:, session:)
8
+ @sessions = sessions
9
+ end
10
+
11
+ def configure(url:, events:, hmac_secret: nil, retries: nil, session: nil)
12
+ name = session_name(session, "configure_webhook")
13
+ session_data = @sessions.get(session: name)
14
+ config = session_config(session_data)
15
+ desired_config = config_with_webhook(config, url:, events:, hmac_secret:, retries:)
16
+ return :unchanged if desired_config == config
17
+
18
+ put_session(name, session_name_value(session_data, name), desired_config)
19
+ :updated
20
+ end
21
+
22
+ private
23
+
24
+ def session_config(session_data)
25
+ config = Support.value(session_data, "config")
26
+ unless config.is_a?(Hash)
27
+ raise ApiError.new(operation: "configure_webhook", details: "session response is missing config")
28
+ end
29
+
30
+ config
31
+ end
32
+
33
+ def config_with_webhook(config, url:, events:, hmac_secret:, retries:)
34
+ webhooks = Array(Support.value(config, "webhooks"))
35
+ index = webhooks.index { |webhook| matching_webhook?(webhook, url) }
36
+ kept = webhooks.reject { |webhook| matching_webhook?(webhook, url) }
37
+ kept.insert(index || kept.length, desired_webhook(url:, events:, hmac_secret:, retries:))
38
+ deep_dup(config).merge("webhooks" => kept)
39
+ end
40
+
41
+ def matching_webhook?(webhook, url)
42
+ webhook.is_a?(Hash) && webhook["url"] == url
43
+ end
44
+
45
+ def desired_webhook(url:, events:, hmac_secret:, retries:)
46
+ webhook = { "url" => url, "events" => Array(events).map(&:to_s) }
47
+ webhook["hmac"] = { "key" => hmac_secret } unless hmac_secret.nil?
48
+ webhook["retries"] = retries unless retries.nil?
49
+ webhook
50
+ end
51
+
52
+ def put_session(session_name, name, config)
53
+ transport.request(
54
+ method: :put,
55
+ path: "/api/sessions/#{segment(session_name)}",
56
+ operation: "configure_webhook",
57
+ expected_status: 200,
58
+ body: { name:, config: }
59
+ )
60
+ end
61
+
62
+ def session_name_value(session_data, fallback)
63
+ Support.value(session_data, "name") || fallback
64
+ end
65
+
66
+ def deep_dup(value, seen = {})
67
+ return value unless value.is_a?(Hash) || value.is_a?(Array)
68
+ return seen[value] if seen.key?(value)
69
+
70
+ copy = value.is_a?(Hash) ? {} : []
71
+ seen[value] = copy
72
+ copy_dup_children(value, copy, seen)
73
+ end
74
+
75
+ def copy_dup_children(value, copy, seen)
76
+ if value.is_a?(Hash)
77
+ value.each { |key, nested| copy[key] = deep_dup(nested, seen) }
78
+ else
79
+ value.each { |nested| copy << deep_dup(nested, seen) }
80
+ end
81
+ copy
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Waha
6
+ module Support
7
+ module_function
8
+
9
+ def segment(value)
10
+ URI.encode_www_form_component(value.to_s).gsub("+", "%20")
11
+ end
12
+
13
+ def value(hash, key)
14
+ return unless hash.is_a?(Hash)
15
+
16
+ hash[key.to_s] || hash[key.to_sym]
17
+ end
18
+
19
+ QUERY_KEY_MAP = {
20
+ download_media: "downloadMedia",
21
+ sort_by: "sortBy",
22
+ sort_order: "sortOrder",
23
+ last_message_timestamp: "lastMessageTimestamp",
24
+ last_message_lid: "lastMessageId",
25
+ contact_id: "contactId",
26
+ phone_number: "phoneNumber"
27
+ }.freeze
28
+
29
+ def compact_hash(hash)
30
+ hash.compact
31
+ end
32
+
33
+ def query_hash(hash)
34
+ hash.each_with_object({}) do |(key, value), query|
35
+ query[QUERY_KEY_MAP.fetch(key, key)] = value
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "uri"
5
+ require "faraday"
6
+
7
+ module Waha
8
+ module Transport
9
+ class Faraday
10
+ JSON_HEADERS = { "Content-Type" => "application/json" }.freeze
11
+
12
+ def initialize(base_url:, api_key:, timeout:)
13
+ @base_url = base_url.to_s.delete_suffix("/")
14
+ @api_key = api_key
15
+ @timeout = timeout
16
+ @connection = ::Faraday.new do |conn|
17
+ conn.adapter ::Faraday.default_adapter
18
+ end
19
+ end
20
+
21
+ def request(**call_attributes)
22
+ operation = call_attributes.fetch(:operation)
23
+ response = validated_response_mode(call_attributes[:response] || :json)
24
+ http_response = dispatch_request(call_attributes)
25
+ verify_status!(http_response, call_attributes.fetch(:expected_status), operation)
26
+ parse_response(http_response, response, operation)
27
+ rescue Waha::Error
28
+ raise
29
+ rescue JSON::GeneratorError, JSON::ParserError
30
+ raise TransportError.new(operation:, details: "invalid JSON response")
31
+ rescue StandardError => e
32
+ raise TransportError.new(operation:, details: "transport failure (#{e.class})")
33
+ end
34
+
35
+ def escape_path_segment(value)
36
+ URI.encode_www_form_component(value.to_s).gsub("+", "%20")
37
+ end
38
+
39
+ private
40
+
41
+ def dispatch_request(call_attributes)
42
+ method = call_attributes.fetch(:method)
43
+ url = request_url(call_attributes.fetch(:path))
44
+
45
+ @connection.public_send(method) do |req|
46
+ configure_request(req, url, call_attributes)
47
+ end
48
+ end
49
+
50
+ def configure_request(req, url, call_attributes)
51
+ req.url(url)
52
+ req.headers = request_headers(call_attributes[:headers])
53
+ req.body = request_body(call_attributes[:body])
54
+ req.params = call_attributes[:query] if call_attributes[:query]&.any?
55
+ configure_timeouts(req.options, call_attributes[:timeout])
56
+ end
57
+
58
+ def configure_timeouts(options, custom_timeout)
59
+ timeout = custom_timeout || @timeout
60
+ options.timeout = timeout
61
+ options.open_timeout = timeout
62
+ end
63
+
64
+ def request_headers(custom_headers)
65
+ default_headers.merge(custom_headers || {}).compact
66
+ end
67
+
68
+ def request_body(body)
69
+ return if body.nil?
70
+
71
+ JSON.generate(body)
72
+ end
73
+
74
+ def validated_response_mode(response)
75
+ return response if %i[json binary].include?(response)
76
+
77
+ raise ValidationError.new(operation: "request", details: "unsupported response mode")
78
+ end
79
+
80
+ def default_headers
81
+ JSON_HEADERS.merge(
82
+ "X-Api-Key" => @api_key,
83
+ "User-Agent" => "waha-ruby/#{Waha::VERSION}"
84
+ )
85
+ end
86
+
87
+ def request_url(path)
88
+ text = path.to_s
89
+ uri = URI.parse(text)
90
+ return uri.to_s if uri.absolute?
91
+
92
+ raise URI::InvalidURIError unless text.start_with?("/")
93
+ raise URI::InvalidURIError if text.start_with?("//")
94
+
95
+ "#{@base_url}#{text}"
96
+ rescue URI::InvalidURIError
97
+ raise ValidationError.new(operation: "request", details: "invalid request URL")
98
+ end
99
+
100
+ def verify_status!(response, expected_status, operation)
101
+ return if Array(expected_status).include?(response.status)
102
+
103
+ details = response.body.to_s.empty? ? "empty provider error response" : "[REDACTED]"
104
+ raise ApiError.new(operation:, status: response.status, details:)
105
+ end
106
+
107
+ def parse_response(response, format, operation)
108
+ return response.body if format == :binary
109
+ return nil if response.body.to_s.strip.empty?
110
+
111
+ JSON.parse(response.body)
112
+ rescue JSON::ParserError
113
+ raise TransportError.new(operation:, status: response.status, details: "invalid JSON response")
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Waha
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ module Waha
6
+ module Webhook
7
+ DIGEST = "sha512"
8
+
9
+ module_function
10
+
11
+ def verify!(body:, signature:, algorithm:, secret:)
12
+ verify_algorithm!(algorithm)
13
+ signature_text = verified_signature_text(signature)
14
+ expected = OpenSSL::HMAC.hexdigest(DIGEST, secret.to_s, body.to_s)
15
+ return true if secure_compare?(signature_text, expected)
16
+
17
+ raise VerificationError.new(operation: "verify_webhook", details: "signature mismatch")
18
+ end
19
+
20
+ def verify_algorithm!(algorithm)
21
+ return if algorithm.to_s.strip.downcase == DIGEST
22
+
23
+ raise VerificationError.new(operation: "verify_webhook", details: "unsupported algorithm")
24
+ end
25
+ private_class_method :verify_algorithm!
26
+
27
+ def verified_signature_text(signature)
28
+ text = signature.to_s
29
+ expected_length = OpenSSL::Digest.new(DIGEST).digest_length * 2
30
+ return text if text.length == expected_length && text.match?(/\A[0-9a-fA-F]+\z/)
31
+
32
+ raise VerificationError.new(operation: "verify_webhook", details: "malformed signature")
33
+ end
34
+ private_class_method :verified_signature_text
35
+
36
+ def secure_compare?(signature, expected)
37
+ signature.bytesize == expected.bytesize && secure_bytes_equal?(signature, expected)
38
+ end
39
+ private_class_method :secure_compare?
40
+
41
+ def secure_bytes_equal?(left, right)
42
+ left_bytes = left.unpack("C*")
43
+ right_bytes = right.unpack("C*")
44
+ comparison = left_bytes.zip(right_bytes).reduce(0) { |result, pair| result | (pair[0] ^ pair[1]) }
45
+ comparison.zero?
46
+ end
47
+ private_class_method :secure_bytes_equal?
48
+ end
49
+ end
data/lib/waha.rb ADDED
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "waha/version"
4
+ require "waha/error"
5
+ require "waha/support"
6
+ require "waha/transport/faraday"
7
+ require "waha/resource"
8
+ require "waha/resources/sessions"
9
+ require "waha/resources/messages"
10
+ require "waha/resources/chats"
11
+ require "waha/resources/contacts"
12
+ require "waha/resources/presence"
13
+ require "waha/resources/webhooks"
14
+ require "waha/resources/media"
15
+ require "waha/gows"
16
+ require "waha/webhook"
17
+ require "waha/client"