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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 597bcaeccc068737954a05d9a71894f5b5dcdc5398ef51634658c90461f9aa75
4
- data.tar.gz: 23942bc33cb539a45e9e3519dba9a88eb788c17e886ecb8cdf406d712c887c24
3
+ metadata.gz: d3aae7e3166ec158db8bb11d3a5fbd88e6df312846caf3208ba154ffbd65c200
4
+ data.tar.gz: 99c40c41b5cb2677c56add71a5717c18b8d91fcc3725b579e81e58f537d55186
5
5
  SHA512:
6
- metadata.gz: 8f0c5680bbb226b46b3921e75d581ad613cf170085abd8edc5d0819cf03ca6fd67d1ef613eb9fec96e8620b61e90ee1c9b3dfb6bffc4c1df4e50c5c1ced67d42
7
- data.tar.gz: 2254a8b5bcfc1ce79545e489a6289c82a50ae1b475e6cb5e765b5a24f2535ed3914edf4a55c9dbcd036fa5d9e857ba925ab0c03cc1670114fef9fcd6e449c03f
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) # rubocop:disable Lint/UnusedMethodArgument
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
- @client.send_template(to: channel_id, template_name: template_name, language_code: language_code, components: components)
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 mark_as_read(message_id:)
96
- @client.mark_as_read(message_id: message_id)
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) # rubocop:disable Lint/UnusedMethodArgument
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(io: io, filename: filename, content_type: content_type)
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(to: channel_id, type: media_type, **{media_type => media_payload.merge(id: media_id)})
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(to: channel_id, message_id: message_id, emoji: emoji)
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(to: channel_id, message_id: message_id, emoji: "")
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:#{@phone_number_id}:#{channel_id}",
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, "#{@phone_number_id}/messages", body)
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, "#{@phone_number_id}/messages", body)
49
+ request(:post, "#{phone_number_id}/messages", body)
44
50
  end
45
51
 
46
- def mark_as_read(message_id:)
47
- request(:post, "#{@phone_number_id}/messages", {
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("#{@phone_number_id}/media") do |req|
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 { |change| change.dig("value", "messages") || [] }
15
- .filter_map { |msg| parse_message(msg, phone_number_id) }
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"]&.to_s
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.1
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: '0.1'
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: '0.1'
25
+ version: '1.2'
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: faraday
28
28
  requirement: !ruby/object:Gem::Requirement