chat_sdk-whatsapp 0.2.1

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 5a9c659c8408890113cd3a76002744ec05805a84b7f931bc3f5af5d3c8f12b92
4
+ data.tar.gz: f75e6904f0de6b2f29b3a7b069abc773044a31cfb189aa4f92ba9b25a9a1ef62
5
+ SHA512:
6
+ metadata.gz: 2a38e860754f4f1950c9e1353e46a1ab26bd16ab2e8ef4c18861ec79fc2a4a8d17c9a1e79b8e3a2bd8e6b2d305f74f6c08ebbb1a83c6dabc5274c5ab2c841113
7
+ data.tar.gz: 7b1be833a9084bd3e65b7a9ba27c795b61323115ae7c0457a3ab24bec9a26e95375f6de6d638f3c56def3895362ebee343df067aa555d0f3c87c21dbdf4569e1
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "rack/utils"
5
+
6
+ module ChatSDK
7
+ module WhatsApp
8
+ class Adapter < ChatSDK::Adapter::Base
9
+ capabilities :direct_messages, :file_uploads, :reactions
10
+
11
+ attr_reader :client
12
+
13
+ def initialize(access_token: nil, app_secret: nil, phone_number_id: nil, verify_token: nil)
14
+ @access_token = access_token || ENV["WHATSAPP_ACCESS_TOKEN"]
15
+ @app_secret = app_secret || ENV["WHATSAPP_APP_SECRET"]
16
+ @phone_number_id = phone_number_id || ENV["WHATSAPP_PHONE_NUMBER_ID"]
17
+ @verify_token = verify_token || ENV["WHATSAPP_VERIFY_TOKEN"]
18
+
19
+ raise ChatSDK::ConfigurationError, "WhatsApp access_token required" unless @access_token
20
+ raise ChatSDK::ConfigurationError, "WhatsApp phone_number_id required" unless @phone_number_id
21
+
22
+ @client = ApiClient.new(@access_token, @phone_number_id)
23
+ @renderer = InteractiveRenderer.new
24
+ end
25
+
26
+ def name
27
+ :whatsapp
28
+ end
29
+
30
+ # Inbound
31
+ def verify_request!(rack_request)
32
+ signature = rack_request.get_header("HTTP_X_HUB_SIGNATURE_256")
33
+
34
+ unless signature
35
+ raise ChatSDK::SignatureVerificationError, "Missing WhatsApp signature header"
36
+ end
37
+
38
+ body = rack_request.body.read
39
+ rack_request.body.rewind
40
+
41
+ expected = "sha256=#{OpenSSL::HMAC.hexdigest("SHA256", @app_secret, body)}"
42
+
43
+ unless Rack::Utils.secure_compare(signature, expected)
44
+ raise ChatSDK::SignatureVerificationError, "Invalid WhatsApp signature"
45
+ end
46
+
47
+ true
48
+ end
49
+
50
+ def ack_response(rack_request)
51
+ return nil unless rack_request.get?
52
+
53
+ params = rack_request.params
54
+ mode = params["hub.mode"]
55
+ token = params["hub.verify_token"]
56
+ challenge = params["hub.challenge"]
57
+
58
+ if mode == "subscribe" && token == @verify_token
59
+ [200, {}, [challenge.to_s]]
60
+ end
61
+ end
62
+
63
+ def parse_events(rack_request)
64
+ body = rack_request.body.read
65
+ rack_request.body.rewind
66
+
67
+ payload = JSON.parse(body)
68
+ EventParser.parse(payload, @phone_number_id)
69
+ rescue JSON::ParserError
70
+ []
71
+ end
72
+
73
+ # Outbound
74
+ def post_message(channel_id:, message:, thread_id: nil) # rubocop:disable Lint/UnusedMethodArgument
75
+ payload = prepare_message_payload(message)
76
+
77
+ result = @client.send_message(to: channel_id, **payload)
78
+
79
+ parse_whatsapp_message(result, channel_id)
80
+ end
81
+
82
+ def upload_file(channel_id:, io:, filename:, thread_id: nil, comment: nil) # rubocop:disable Lint/UnusedMethodArgument
83
+ content_type = detect_content_type(filename)
84
+ media_type = media_type_for(content_type)
85
+
86
+ # Upload media first
87
+ media_result = @client.upload_media(io: io, filename: filename, content_type: content_type)
88
+ media_id = media_result["id"]
89
+
90
+ # Send media message
91
+ media_payload = {caption: comment}.compact
92
+ result = @client.send_message(to: channel_id, type: media_type, **{media_type => media_payload.merge(id: media_id)})
93
+
94
+ parse_whatsapp_message(result, channel_id)
95
+ end
96
+
97
+ def add_reaction(channel_id:, message_id:, emoji:)
98
+ @client.send_reaction(to: channel_id, message_id: message_id, emoji: emoji)
99
+ end
100
+
101
+ def remove_reaction(channel_id:, message_id:, emoji: "") # rubocop:disable Lint/UnusedMethodArgument
102
+ @client.send_reaction(to: channel_id, message_id: message_id, emoji: "")
103
+ end
104
+
105
+ def open_dm(user_id)
106
+ user_id
107
+ end
108
+
109
+ def mention(user_id)
110
+ user_id
111
+ end
112
+
113
+ def render(postable_message)
114
+ if postable_message.card?
115
+ @renderer.render(postable_message.card)
116
+ else
117
+ postable_message.text
118
+ end
119
+ end
120
+
121
+ private
122
+
123
+ def prepare_message_payload(message)
124
+ msg = ChatSDK::PostableMessage.from(message)
125
+
126
+ if msg.card?
127
+ rendered = @renderer.render(msg.card)
128
+ if rendered.is_a?(Hash) && rendered[:type] == "interactive"
129
+ {type: "interactive", interactive: rendered[:interactive]}
130
+ else
131
+ {type: "text", text: {body: rendered[:text] || msg.text || msg.card.fallback_text || ""}}
132
+ end
133
+ else
134
+ {type: "text", text: {body: msg.text || ""}}
135
+ end
136
+ end
137
+
138
+ def parse_whatsapp_message(data, channel_id)
139
+ message_id = data.dig("messages", 0, "id")
140
+
141
+ ChatSDK::Message.new(
142
+ id: message_id,
143
+ text: "",
144
+ author: ChatSDK::Author.new(
145
+ id: "bot",
146
+ name: "bot",
147
+ platform: :whatsapp,
148
+ bot: true
149
+ ),
150
+ thread_id: "whatsapp:#{@phone_number_id}:#{channel_id}",
151
+ channel_id: channel_id,
152
+ platform: :whatsapp,
153
+ raw: data
154
+ )
155
+ end
156
+
157
+ CONTENT_TYPES = {
158
+ ".jpg" => "image/jpeg", ".jpeg" => "image/jpeg", ".png" => "image/png",
159
+ ".gif" => "image/gif", ".webp" => "image/webp",
160
+ ".mp4" => "video/mp4", ".3gp" => "video/3gpp",
161
+ ".mp3" => "audio/mpeg", ".ogg" => "audio/ogg", ".amr" => "audio/amr",
162
+ ".pdf" => "application/pdf", ".doc" => "application/msword",
163
+ ".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
164
+ ".xls" => "application/vnd.ms-excel",
165
+ ".xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
166
+ }.freeze
167
+
168
+ def detect_content_type(filename)
169
+ CONTENT_TYPES.fetch(File.extname(filename).downcase, "application/octet-stream")
170
+ end
171
+
172
+ def media_type_for(content_type)
173
+ case content_type
174
+ when %r{^image/} then "image"
175
+ when %r{^video/} then "video"
176
+ when %r{^audio/} then "audio"
177
+ else "document"
178
+ end
179
+ end
180
+ end
181
+ end
182
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChatSDK
4
+ module WhatsApp
5
+ class ApiClient
6
+ BASE_URL = "https://graph.facebook.com/v21.0"
7
+
8
+ def initialize(access_token, phone_number_id)
9
+ @access_token = access_token
10
+ @phone_number_id = phone_number_id
11
+ end
12
+
13
+ def send_message(to:, type:, **payload)
14
+ body = {
15
+ "messaging_product" => "whatsapp",
16
+ "recipient_type" => "individual",
17
+ "to" => to,
18
+ "type" => type
19
+ }.merge(payload)
20
+
21
+ request(:post, "#{@phone_number_id}/messages", body)
22
+ end
23
+
24
+ def send_reaction(to:, message_id:, emoji:)
25
+ send_message(
26
+ to: to,
27
+ type: "reaction",
28
+ reaction: {message_id: message_id, emoji: emoji}
29
+ )
30
+ end
31
+
32
+ def upload_media(io:, filename:, content_type:)
33
+ response = media_connection.post("#{@phone_number_id}/media") do |req|
34
+ req.body = {
35
+ "messaging_product" => "whatsapp",
36
+ "file" => Faraday::Multipart::FilePart.new(io, content_type, filename),
37
+ "type" => content_type
38
+ }
39
+ end
40
+
41
+ handle_response(response)
42
+ end
43
+
44
+ private
45
+
46
+ def connection
47
+ @connection ||= build_connection { |f| f.request :json }
48
+ end
49
+
50
+ def media_connection
51
+ @media_connection ||= build_connection { |f| f.request :multipart }
52
+ end
53
+
54
+ def build_connection
55
+ Faraday.new(url: BASE_URL) do |f|
56
+ yield f
57
+ f.response :json
58
+ f.adapter :net_http
59
+ f.headers["Authorization"] = "Bearer #{@access_token}"
60
+ end
61
+ end
62
+
63
+ def request(method, path, body = nil)
64
+ response = connection.public_send(method, path) do |req|
65
+ req.body = body if body && method != :get
66
+ end
67
+
68
+ handle_response(response)
69
+ end
70
+
71
+ def handle_response(response)
72
+ body = response.body
73
+ return body.is_a?(Hash) ? body : {} if response.success?
74
+
75
+ if response.status == 429
76
+ raise ChatSDK::RateLimitedError.new(
77
+ "WhatsApp API rate limited",
78
+ retry_after: nil,
79
+ status: response.status,
80
+ body: body,
81
+ adapter_name: :whatsapp
82
+ )
83
+ end
84
+
85
+ error_message = body.is_a?(Hash) ? body.dig("error", "message") : response.status
86
+ raise ChatSDK::PlatformError.new(
87
+ "WhatsApp API error: #{error_message}",
88
+ status: response.status,
89
+ body: body,
90
+ adapter_name: :whatsapp
91
+ )
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChatSDK
4
+ module WhatsApp
5
+ class EventParser
6
+ class << self
7
+ def parse(payload, phone_number_id)
8
+ return [] unless payload.is_a?(Hash)
9
+ return [] unless payload["object"] == "whatsapp_business_account"
10
+
11
+ (payload["entry"] || [])
12
+ .flat_map { |entry| entry["changes"] || [] }
13
+ .select { |change| change["field"] == "messages" }
14
+ .flat_map { |change| change.dig("value", "messages") || [] }
15
+ .filter_map { |msg| parse_message(msg, phone_number_id) }
16
+ end
17
+
18
+ private
19
+
20
+ def parse_message(msg, phone_number_id)
21
+ from = msg["from"]&.to_s
22
+ return nil unless from
23
+
24
+ author = ChatSDK::Author.new(id: from, name: from, platform: :whatsapp, bot: false)
25
+ thread_id = "whatsapp:#{phone_number_id}:#{from}"
26
+
27
+ case msg["type"]
28
+ when "text"
29
+ build_direct_message(msg, msg.dig("text", "body") || "", author, from, thread_id)
30
+ when "interactive"
31
+ parse_interactive_message(msg, author, from, thread_id)
32
+ when "image", "document", "audio", "video"
33
+ parse_media_message(msg, author, from, thread_id)
34
+ end
35
+ end
36
+
37
+ def parse_interactive_message(msg, author, channel_id, thread_id)
38
+ interactive = msg["interactive"] || {}
39
+ action_id = interactive.dig("button_reply", "id") || interactive.dig("list_reply", "id") || ""
40
+
41
+ ChatSDK::Events::Action.new(
42
+ action_id: action_id,
43
+ value: action_id,
44
+ user: author,
45
+ thread_id: thread_id,
46
+ channel_id: channel_id,
47
+ platform: :whatsapp,
48
+ adapter_name: :whatsapp,
49
+ raw: msg
50
+ )
51
+ end
52
+
53
+ def parse_media_message(msg, author, channel_id, thread_id)
54
+ media_type = msg["type"]
55
+ media_data = msg[media_type] || {}
56
+ caption = media_data["caption"] || ""
57
+ mime_type = media_data["mime_type"] || ""
58
+ media_id = media_data["id"] || ""
59
+
60
+ text = [caption, "[#{media_type}: #{mime_type} #{media_id}]"].reject(&:empty?).join("\n")
61
+ build_direct_message(msg, text, author, channel_id, thread_id)
62
+ end
63
+
64
+ def build_direct_message(msg, text, author, channel_id, thread_id)
65
+ message = ChatSDK::Message.new(
66
+ id: msg["id"],
67
+ text: text,
68
+ author: author,
69
+ thread_id: thread_id,
70
+ channel_id: channel_id,
71
+ platform: :whatsapp,
72
+ raw: msg
73
+ )
74
+
75
+ ChatSDK::Events::DirectMessage.new(
76
+ message: message,
77
+ thread_id: thread_id,
78
+ channel_id: channel_id,
79
+ platform: :whatsapp,
80
+ adapter_name: :whatsapp,
81
+ raw: msg
82
+ )
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChatSDK
4
+ module WhatsApp
5
+ class InteractiveRenderer
6
+ def render(node)
7
+ case node.type
8
+ when :card then render_card(node)
9
+ else render_single(node)
10
+ end
11
+ end
12
+
13
+ private
14
+
15
+ def render_card(node)
16
+ buttons = collect_reply_buttons(node)
17
+ title = node.attributes[:title]
18
+ subtitle = node.attributes[:subtitle]
19
+ text_parts = collect_text_parts(node)
20
+ body_text = [subtitle, *text_parts].compact.reject(&:empty?).join("\n")
21
+ body_text = title if body_text.empty?
22
+
23
+ if title && buttons.any? && buttons.length <= 3
24
+ interactive = {
25
+ "type" => "button",
26
+ "body" => {"text" => body_text}
27
+ }
28
+ interactive["header"] = {"type" => "text", "text" => truncate(title, 60)} if title
29
+ interactive["action"] = {
30
+ "buttons" => buttons.first(3).map do |btn|
31
+ {
32
+ "type" => "reply",
33
+ "reply" => {
34
+ "id" => btn[:id],
35
+ "title" => truncate(btn[:text], 20)
36
+ }
37
+ }
38
+ end
39
+ }
40
+
41
+ {type: "interactive", interactive: interactive}
42
+ else
43
+ # Fallback to plain text
44
+ fallback = [title, subtitle, *text_parts].compact.reject(&:empty?).join("\n")
45
+ if buttons.any?
46
+ button_text = buttons.map { |b| b[:text] }.join(", ")
47
+ fallback = "#{fallback}\n[#{button_text}]" unless button_text.empty?
48
+ end
49
+ {text: fallback}
50
+ end
51
+ end
52
+
53
+ def collect_reply_buttons(node)
54
+ buttons = []
55
+ node.children.each do |child|
56
+ case child.type
57
+ when :button
58
+ buttons << {id: child.attributes[:id] || "button", text: child.attributes[:text] || ""}
59
+ when :link_button
60
+ # WhatsApp reply buttons can't have URLs; skip link buttons
61
+ next
62
+ when :actions
63
+ child.children.each do |action_child|
64
+ case action_child.type
65
+ when :button
66
+ buttons << {id: action_child.attributes[:id] || "button", text: action_child.attributes[:text] || ""}
67
+ when :link_button
68
+ next
69
+ end
70
+ end
71
+ when :section
72
+ buttons.concat(collect_reply_buttons(child))
73
+ end
74
+ end
75
+ buttons
76
+ end
77
+
78
+ def collect_text_parts(node)
79
+ parts = []
80
+ node.children.each do |child|
81
+ case child.type
82
+ when :text
83
+ parts << child.attributes[:content]
84
+ when :divider
85
+ parts << "---"
86
+ when :fields
87
+ child.children.each do |field|
88
+ parts << "#{field.attributes[:label]}: #{field.attributes[:value]}"
89
+ end
90
+ when :section
91
+ parts << child.attributes[:title] if child.attributes[:title]
92
+ parts.concat(collect_text_parts(child))
93
+ end
94
+ end
95
+ parts
96
+ end
97
+
98
+ def render_single(node)
99
+ case node.type
100
+ when :text
101
+ {text: node.attributes[:content]}
102
+ else
103
+ {text: node.fallback_text}
104
+ end
105
+ end
106
+
107
+ def truncate(text, max)
108
+ return "" unless text
109
+
110
+ (text.length > max) ? "#{text[0..max - 4]}..." : text
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "chat_sdk"
4
+ require "faraday"
5
+ require "faraday/net_http"
6
+ require "zeitwerk"
7
+
8
+ module ChatSDK
9
+ module WhatsApp
10
+ class << self
11
+ def loader
12
+ @loader ||= begin
13
+ loader = Zeitwerk::Loader.new
14
+ loader.tag = "chat_sdk-whatsapp"
15
+ loader.inflector.inflect("chat_sdk" => "ChatSDK", "whatsapp" => "WhatsApp")
16
+ loader.push_dir("#{__dir__}/whatsapp", namespace: ChatSDK::WhatsApp)
17
+ loader.setup
18
+ loader
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+
25
+ ChatSDK::WhatsApp.loader
metadata ADDED
@@ -0,0 +1,75 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: chat_sdk-whatsapp
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.1
5
+ platform: ruby
6
+ authors:
7
+ - Quentin Rousseau
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: chat_sdk
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: faraday
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '2.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '2.0'
40
+ description: WhatsApp Business Cloud API adapter for the ChatSDK framework with HMAC-SHA256
41
+ verification and interactive message rendering
42
+ email:
43
+ - quentin@rootly.com
44
+ executables: []
45
+ extensions: []
46
+ extra_rdoc_files: []
47
+ files:
48
+ - lib/chat_sdk/whatsapp.rb
49
+ - lib/chat_sdk/whatsapp/adapter.rb
50
+ - lib/chat_sdk/whatsapp/api_client.rb
51
+ - lib/chat_sdk/whatsapp/event_parser.rb
52
+ - lib/chat_sdk/whatsapp/interactive_renderer.rb
53
+ homepage: https://github.com/rootlyhq/chat-sdk
54
+ licenses:
55
+ - MIT
56
+ metadata:
57
+ rubygems_mfa_required: 'true'
58
+ rdoc_options: []
59
+ require_paths:
60
+ - lib
61
+ required_ruby_version: !ruby/object:Gem::Requirement
62
+ requirements:
63
+ - - ">="
64
+ - !ruby/object:Gem::Version
65
+ version: 3.2.0
66
+ required_rubygems_version: !ruby/object:Gem::Requirement
67
+ requirements:
68
+ - - ">="
69
+ - !ruby/object:Gem::Version
70
+ version: '0'
71
+ requirements: []
72
+ rubygems_version: 4.0.16
73
+ specification_version: 4
74
+ summary: WhatsApp Business Cloud API adapter for ChatSDK
75
+ test_files: []