chat_sdk-whatsapp 1.0.1 → 1.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.
- checksums.yaml +4 -4
- data/lib/chat_sdk/whatsapp/adapter.rb +75 -19
- data/lib/chat_sdk/whatsapp/api_client.rb +46 -9
- data/lib/chat_sdk/whatsapp/event_parser.rb +21 -4
- metadata +3 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d3aae7e3166ec158db8bb11d3a5fbd88e6df312846caf3208ba154ffbd65c200
|
|
4
|
+
data.tar.gz: 99c40c41b5cb2677c56add71a5717c18b8d91fcc3725b579e81e58f537d55186
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9848398b73448697e4432c5a669607a151823c479021e825558d73b1be6e48cae051be2572c9501d3f9060b95e9b38223082cc942e76cd9464261dd4432b7bb6
|
|
7
|
+
data.tar.gz: 0b5d5837929611cd82558baffdcace85decc3ed2dac403f5a32c1bc10c0c465d3002644a29be7e8a5b105c8e7c3a422010c74ddbf10208a5636d9c595e79cd9a
|
|
@@ -6,7 +6,7 @@ module ChatSDK
|
|
|
6
6
|
include ChatSDK::Adapter::MetaVerification
|
|
7
7
|
include ChatSDK::Adapter::MediaTypes
|
|
8
8
|
|
|
9
|
-
capabilities :direct_messages, :file_uploads, :reactions
|
|
9
|
+
capabilities :direct_messages, :file_uploads, :reactions, :replies, :read_receipts
|
|
10
10
|
|
|
11
11
|
attr_reader :client
|
|
12
12
|
|
|
@@ -44,19 +44,20 @@ module ChatSDK
|
|
|
44
44
|
end
|
|
45
45
|
|
|
46
46
|
# Outbound
|
|
47
|
-
def post_message(channel_id:, message:, thread_id: nil)
|
|
47
|
+
def post_message(channel_id:, message:, thread_id: nil)
|
|
48
|
+
phone_number_id = outbound_phone_number_id(channel_id, thread_id)
|
|
48
49
|
payload = prepare_message_payload(message)
|
|
49
50
|
|
|
50
51
|
result = if payload[:type] == "text" && payload.dig(:text, :body)
|
|
51
52
|
chunks = split_message(payload[:text][:body])
|
|
52
53
|
chunks.reduce(nil) do |_, chunk|
|
|
53
|
-
@client.send_message(to: channel_id, type: "text", text: {body: chunk})
|
|
54
|
+
@client.send_message(to: channel_id, type: "text", phone_number_id: phone_number_id, text: {body: chunk})
|
|
54
55
|
end
|
|
55
56
|
else
|
|
56
|
-
@client.send_message(to: channel_id, **payload)
|
|
57
|
+
@client.send_message(to: channel_id, phone_number_id: phone_number_id, **payload)
|
|
57
58
|
end
|
|
58
59
|
|
|
59
|
-
parse_whatsapp_message(result, channel_id)
|
|
60
|
+
parse_whatsapp_message(result, channel_id, phone_number_id: phone_number_id)
|
|
60
61
|
end
|
|
61
62
|
|
|
62
63
|
def self.template_components(header: nil, body_params: [], button_payloads: [])
|
|
@@ -88,12 +89,30 @@ module ChatSDK
|
|
|
88
89
|
components
|
|
89
90
|
end
|
|
90
91
|
|
|
91
|
-
def post_template(channel_id:, template_name:, language_code: "en", components: nil)
|
|
92
|
-
|
|
92
|
+
def post_template(channel_id:, template_name:, language_code: "en", components: nil, thread_id: nil)
|
|
93
|
+
phone_number_id = outbound_phone_number_id(channel_id, thread_id)
|
|
94
|
+
@client.send_template(
|
|
95
|
+
to: channel_id,
|
|
96
|
+
template_name: template_name,
|
|
97
|
+
language_code: language_code,
|
|
98
|
+
components: components,
|
|
99
|
+
phone_number_id: phone_number_id
|
|
100
|
+
)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def mark_as_read(message_id:, channel_id: nil, thread_id: nil, message: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
104
|
+
@client.mark_as_read(
|
|
105
|
+
message_id: message_id,
|
|
106
|
+
phone_number_id: outbound_phone_number_id(channel_id, thread_id)
|
|
107
|
+
)
|
|
93
108
|
end
|
|
94
109
|
|
|
95
|
-
def
|
|
96
|
-
|
|
110
|
+
def reply_message(channel_id:, message_id:, message:, thread_id: nil)
|
|
111
|
+
phone_number_id = outbound_phone_number_id(channel_id, thread_id)
|
|
112
|
+
payload = prepare_message_payload(message)
|
|
113
|
+
payload[:context] = {message_id: message_id}
|
|
114
|
+
result = @client.send_message(to: channel_id, phone_number_id: phone_number_id, **payload)
|
|
115
|
+
parse_whatsapp_message(result, channel_id, phone_number_id: phone_number_id)
|
|
97
116
|
end
|
|
98
117
|
|
|
99
118
|
def download_media(media_id:)
|
|
@@ -105,27 +124,48 @@ module ChatSDK
|
|
|
105
124
|
response.body
|
|
106
125
|
end
|
|
107
126
|
|
|
108
|
-
def upload_file(channel_id:, io:, filename:, thread_id: nil, comment: nil)
|
|
127
|
+
def upload_file(channel_id:, io:, filename:, thread_id: nil, comment: nil)
|
|
128
|
+
phone_number_id = outbound_phone_number_id(channel_id, thread_id)
|
|
109
129
|
content_type = detect_content_type(filename)
|
|
110
130
|
media_type = media_type_for(content_type)
|
|
111
131
|
|
|
112
132
|
# Upload media first
|
|
113
|
-
media_result = @client.upload_media(
|
|
133
|
+
media_result = @client.upload_media(
|
|
134
|
+
io: io,
|
|
135
|
+
filename: filename,
|
|
136
|
+
content_type: content_type,
|
|
137
|
+
phone_number_id: phone_number_id
|
|
138
|
+
)
|
|
114
139
|
media_id = media_result["id"]
|
|
115
140
|
|
|
116
141
|
# Send media message
|
|
117
142
|
media_payload = {caption: comment}.compact
|
|
118
|
-
result = @client.send_message(
|
|
143
|
+
result = @client.send_message(
|
|
144
|
+
to: channel_id,
|
|
145
|
+
type: media_type,
|
|
146
|
+
phone_number_id: phone_number_id,
|
|
147
|
+
**{media_type => media_payload.merge(id: media_id)}
|
|
148
|
+
)
|
|
119
149
|
|
|
120
|
-
parse_whatsapp_message(result, channel_id)
|
|
150
|
+
parse_whatsapp_message(result, channel_id, phone_number_id: phone_number_id)
|
|
121
151
|
end
|
|
122
152
|
|
|
123
|
-
def add_reaction(channel_id:, message_id:, emoji:)
|
|
124
|
-
@client.send_reaction(
|
|
153
|
+
def add_reaction(channel_id:, message_id:, emoji:, thread_id: nil)
|
|
154
|
+
@client.send_reaction(
|
|
155
|
+
to: channel_id,
|
|
156
|
+
message_id: message_id,
|
|
157
|
+
emoji: emoji,
|
|
158
|
+
phone_number_id: outbound_phone_number_id(channel_id, thread_id)
|
|
159
|
+
)
|
|
125
160
|
end
|
|
126
161
|
|
|
127
|
-
def remove_reaction(channel_id:, message_id:, emoji: "") # rubocop:disable Lint/UnusedMethodArgument
|
|
128
|
-
@client.send_reaction(
|
|
162
|
+
def remove_reaction(channel_id:, message_id:, emoji: "", thread_id: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
163
|
+
@client.send_reaction(
|
|
164
|
+
to: channel_id,
|
|
165
|
+
message_id: message_id,
|
|
166
|
+
emoji: "",
|
|
167
|
+
phone_number_id: outbound_phone_number_id(channel_id, thread_id)
|
|
168
|
+
)
|
|
129
169
|
end
|
|
130
170
|
|
|
131
171
|
def open_dm(user_id)
|
|
@@ -179,7 +219,7 @@ module ChatSDK
|
|
|
179
219
|
end
|
|
180
220
|
end
|
|
181
221
|
|
|
182
|
-
def parse_whatsapp_message(data, channel_id)
|
|
222
|
+
def parse_whatsapp_message(data, channel_id, phone_number_id: @phone_number_id)
|
|
183
223
|
message_id = data.dig("messages", 0, "id")
|
|
184
224
|
|
|
185
225
|
ChatSDK::Message.new(
|
|
@@ -191,12 +231,28 @@ module ChatSDK
|
|
|
191
231
|
platform: :whatsapp,
|
|
192
232
|
bot: true
|
|
193
233
|
),
|
|
194
|
-
thread_id: "whatsapp:#{
|
|
234
|
+
thread_id: "whatsapp:#{phone_number_id}:#{channel_id}",
|
|
195
235
|
channel_id: channel_id,
|
|
196
236
|
platform: :whatsapp,
|
|
197
237
|
raw: data
|
|
198
238
|
)
|
|
199
239
|
end
|
|
240
|
+
|
|
241
|
+
def outbound_phone_number_id(channel_id, thread_id)
|
|
242
|
+
return @phone_number_id unless thread_id
|
|
243
|
+
|
|
244
|
+
match = thread_id.match(/\Awhatsapp:([^:]+):(.+)\z/) if thread_id.is_a?(String)
|
|
245
|
+
phone_number_id = match && match[1]
|
|
246
|
+
unless phone_number_id&.match?(/\A\d+\z/)
|
|
247
|
+
raise ChatSDK::Error, "Invalid WhatsApp phone number ID in thread ID: #{thread_id.inspect}"
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
unless channel_id.nil? || match[2] == channel_id.to_s
|
|
251
|
+
raise ChatSDK::Error, "WhatsApp thread recipient does not match channel ID: #{thread_id.inspect}"
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
phone_number_id
|
|
255
|
+
end
|
|
200
256
|
end
|
|
201
257
|
end
|
|
202
258
|
end
|
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
3
5
|
module ChatSDK
|
|
4
6
|
module WhatsApp
|
|
5
7
|
class ApiClient < ChatSDK::ApiClient::Base
|
|
6
8
|
BASE_URL = "https://graph.facebook.com/v25.0"
|
|
9
|
+
MEDIA_HOSTS = %w[fbcdn.net fbsbx.com].freeze
|
|
10
|
+
MEDIA_DOWNLOAD_LIMIT = 25 * 1024 * 1024
|
|
11
|
+
MEDIA_DOWNLOAD_TIMEOUT = 30
|
|
7
12
|
|
|
8
13
|
def initialize(access_token, phone_number_id)
|
|
9
14
|
@access_token = access_token
|
|
10
15
|
@phone_number_id = phone_number_id
|
|
11
16
|
end
|
|
12
17
|
|
|
13
|
-
def send_message(to:, type:, **payload)
|
|
18
|
+
def send_message(to:, type:, phone_number_id: @phone_number_id, **payload)
|
|
14
19
|
body = {
|
|
15
20
|
"messaging_product" => "whatsapp",
|
|
16
21
|
"recipient_type" => "individual",
|
|
@@ -18,18 +23,19 @@ module ChatSDK
|
|
|
18
23
|
"type" => type
|
|
19
24
|
}.merge(payload)
|
|
20
25
|
|
|
21
|
-
request(:post, "#{
|
|
26
|
+
request(:post, "#{phone_number_id}/messages", body)
|
|
22
27
|
end
|
|
23
28
|
|
|
24
|
-
def send_reaction(to:, message_id:, emoji:)
|
|
29
|
+
def send_reaction(to:, message_id:, emoji:, phone_number_id: @phone_number_id)
|
|
25
30
|
send_message(
|
|
26
31
|
to: to,
|
|
27
32
|
type: "reaction",
|
|
33
|
+
phone_number_id: phone_number_id,
|
|
28
34
|
reaction: {message_id: message_id, emoji: emoji}
|
|
29
35
|
)
|
|
30
36
|
end
|
|
31
37
|
|
|
32
|
-
def send_template(to:, template_name:, language_code: "en", components: nil)
|
|
38
|
+
def send_template(to:, template_name:, language_code: "en", components: nil, phone_number_id: @phone_number_id)
|
|
33
39
|
body = {
|
|
34
40
|
"messaging_product" => "whatsapp",
|
|
35
41
|
"to" => to,
|
|
@@ -40,11 +46,11 @@ module ChatSDK
|
|
|
40
46
|
}
|
|
41
47
|
}
|
|
42
48
|
body["template"]["components"] = components if components
|
|
43
|
-
request(:post, "#{
|
|
49
|
+
request(:post, "#{phone_number_id}/messages", body)
|
|
44
50
|
end
|
|
45
51
|
|
|
46
|
-
def mark_as_read(message_id:)
|
|
47
|
-
request(:post, "#{
|
|
52
|
+
def mark_as_read(message_id:, phone_number_id: @phone_number_id)
|
|
53
|
+
request(:post, "#{phone_number_id}/messages", {
|
|
48
54
|
"messaging_product" => "whatsapp",
|
|
49
55
|
"status" => "read",
|
|
50
56
|
"message_id" => message_id
|
|
@@ -56,13 +62,16 @@ module ChatSDK
|
|
|
56
62
|
end
|
|
57
63
|
|
|
58
64
|
def download_media(url:)
|
|
65
|
+
validate_media_url!(url)
|
|
59
66
|
Faraday.get(url) do |req|
|
|
60
67
|
req.headers["Authorization"] = "Bearer #{@access_token}"
|
|
68
|
+
req.options.timeout = MEDIA_DOWNLOAD_TIMEOUT
|
|
61
69
|
end
|
|
70
|
+
.tap { |response| validate_media_response!(response) }
|
|
62
71
|
end
|
|
63
72
|
|
|
64
|
-
def upload_media(io:, filename:, content_type:)
|
|
65
|
-
response = upload_connection.post("#{
|
|
73
|
+
def upload_media(io:, filename:, content_type:, phone_number_id: @phone_number_id)
|
|
74
|
+
response = upload_connection.post("#{phone_number_id}/media") do |req|
|
|
66
75
|
req.body = {
|
|
67
76
|
"messaging_product" => "whatsapp",
|
|
68
77
|
"file" => Faraday::Multipart::FilePart.new(io, content_type, filename),
|
|
@@ -87,6 +96,34 @@ module ChatSDK
|
|
|
87
96
|
faraday.headers["Authorization"] = "Bearer #{@access_token}"
|
|
88
97
|
end
|
|
89
98
|
|
|
99
|
+
def validate_media_url!(url)
|
|
100
|
+
uri = URI.parse(url)
|
|
101
|
+
host = uri.host&.downcase
|
|
102
|
+
graph_origin = URI.parse(BASE_URL)
|
|
103
|
+
trusted_host = host == graph_origin.host || MEDIA_HOSTS.any? { |allowed| host == allowed || host&.end_with?(".#{allowed}") }
|
|
104
|
+
trusted = uri.is_a?(URI::HTTPS) && uri.port == 443 && trusted_host
|
|
105
|
+
return if trusted
|
|
106
|
+
|
|
107
|
+
raise ChatSDK::PlatformError.new(
|
|
108
|
+
"Refusing to send the WhatsApp access token to an untrusted media URL",
|
|
109
|
+
adapter_name: :whatsapp
|
|
110
|
+
)
|
|
111
|
+
rescue URI::InvalidURIError
|
|
112
|
+
raise ChatSDK::PlatformError.new("Invalid WhatsApp media URL", adapter_name: :whatsapp)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def validate_media_response!(response)
|
|
116
|
+
declared_size = response.headers["content-length"]&.to_i
|
|
117
|
+
actual_size = response.body.respond_to?(:bytesize) ? response.body.bytesize : 0
|
|
118
|
+
return if [declared_size, actual_size].compact.max <= MEDIA_DOWNLOAD_LIMIT
|
|
119
|
+
|
|
120
|
+
raise ChatSDK::PlatformError.new(
|
|
121
|
+
"WhatsApp media exceeds the 25 MB download limit",
|
|
122
|
+
status: response.status,
|
|
123
|
+
adapter_name: :whatsapp
|
|
124
|
+
)
|
|
125
|
+
end
|
|
126
|
+
|
|
90
127
|
def extract_error_message(response)
|
|
91
128
|
body = response.body
|
|
92
129
|
body.is_a?(Hash) ? body.dig("error", "message") : response.status.to_s
|
|
@@ -11,14 +11,21 @@ module ChatSDK
|
|
|
11
11
|
(payload["entry"] || [])
|
|
12
12
|
.flat_map { |entry| entry["changes"] || [] }
|
|
13
13
|
.select { |change| change["field"] == "messages" }
|
|
14
|
-
.flat_map
|
|
15
|
-
|
|
14
|
+
.flat_map do |change|
|
|
15
|
+
value = change["value"] || {}
|
|
16
|
+
contacts = value["contacts"] || []
|
|
17
|
+
(value["messages"] || []).filter_map do |msg|
|
|
18
|
+
contact = contact_for(msg, contacts)
|
|
19
|
+
parse_message(msg, phone_number_id, contact)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
16
22
|
end
|
|
17
23
|
|
|
18
24
|
private
|
|
19
25
|
|
|
20
|
-
def parse_message(msg, phone_number_id)
|
|
21
|
-
from = msg["from"]&.
|
|
26
|
+
def parse_message(msg, phone_number_id, contact)
|
|
27
|
+
from = first_present(msg["from"], msg["wa_id"], contact&.dig("wa_id"),
|
|
28
|
+
msg["from_user_id"], contact&.dig("user_id"))
|
|
22
29
|
return nil unless from
|
|
23
30
|
|
|
24
31
|
author = ChatSDK::Author.new(id: from, name: from, platform: :whatsapp, bot: false)
|
|
@@ -36,6 +43,16 @@ module ChatSDK
|
|
|
36
43
|
end
|
|
37
44
|
end
|
|
38
45
|
|
|
46
|
+
def contact_for(message, contacts)
|
|
47
|
+
from = message["from"].to_s
|
|
48
|
+
contacts.find { |contact| contact["wa_id"].to_s == from } ||
|
|
49
|
+
(contacts.first if contacts.length == 1)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def first_present(*values)
|
|
53
|
+
values.map { |value| value&.to_s }.find { |value| value && !value.empty? }
|
|
54
|
+
end
|
|
55
|
+
|
|
39
56
|
def parse_reaction_message(msg, from, thread_id)
|
|
40
57
|
reaction = msg["reaction"] || {}
|
|
41
58
|
emoji = reaction["emoji"] || ""
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: chat_sdk-whatsapp
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.0
|
|
4
|
+
version: 1.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Quentin Rousseau
|
|
@@ -15,14 +15,14 @@ dependencies:
|
|
|
15
15
|
requirements:
|
|
16
16
|
- - ">="
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
|
-
version: '
|
|
18
|
+
version: '1.2'
|
|
19
19
|
type: :runtime
|
|
20
20
|
prerelease: false
|
|
21
21
|
version_requirements: !ruby/object:Gem::Requirement
|
|
22
22
|
requirements:
|
|
23
23
|
- - ">="
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
|
-
version: '
|
|
25
|
+
version: '1.2'
|
|
26
26
|
- !ruby/object:Gem::Dependency
|
|
27
27
|
name: faraday
|
|
28
28
|
requirement: !ruby/object:Gem::Requirement
|