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.
- checksums.yaml +7 -0
- data/.github/pull_request_template.md +13 -0
- data/.github/workflows/ci.yml +32 -0
- data/.github/workflows/release.yml +457 -0
- data/.reek.yml +38 -0
- data/.rubocop.yml +73 -0
- data/.tool-versions +1 -0
- data/CHANGELOG.md +34 -0
- data/CONTRIBUTING.md +53 -0
- data/LICENSE.txt +21 -0
- data/README.md +63 -0
- data/Rakefile +18 -0
- data/lib/generators/waha/install/install_generator.rb +15 -0
- data/lib/generators/waha/install/templates/waha.rb +14 -0
- data/lib/waha/client.rb +37 -0
- data/lib/waha/error.rb +36 -0
- data/lib/waha/gows.rb +103 -0
- data/lib/waha/rails/controller_concern.rb +30 -0
- data/lib/waha/rails/railtie.rb +11 -0
- data/lib/waha/rails.rb +8 -0
- data/lib/waha/resource.rb +25 -0
- data/lib/waha/resources/chats.rb +29 -0
- data/lib/waha/resources/contacts.rb +37 -0
- data/lib/waha/resources/media.rb +63 -0
- data/lib/waha/resources/messages.rb +186 -0
- data/lib/waha/resources/presence.rb +37 -0
- data/lib/waha/resources/sessions.rb +100 -0
- data/lib/waha/resources/webhooks.rb +85 -0
- data/lib/waha/support.rb +39 -0
- data/lib/waha/transport/faraday.rb +117 -0
- data/lib/waha/version.rb +5 -0
- data/lib/waha/webhook.rb +49 -0
- data/lib/waha.rb +17 -0
- metadata +274 -0
|
@@ -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
|
data/lib/waha/support.rb
ADDED
|
@@ -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
|
data/lib/waha/version.rb
ADDED
data/lib/waha/webhook.rb
ADDED
|
@@ -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"
|