chat_sdk-messenger 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: ddb5167e642f3d5d79effdd65467cbfd7f1c4afae486a95204bf64708ff3019b
4
+ data.tar.gz: aa0f9eae474adae2691643fe4783ddff7c25894f2418036df6651642c6afc882
5
+ SHA512:
6
+ metadata.gz: 8fb24bc5b9428c7d1575bd669a397215a465a78cf5f1ba98b13e1f500a04b89eeeed3471326584d612fa109e1417c227ad90e3f118c28596896f50aa12a83f60
7
+ data.tar.gz: f20ddad5394606ea1685b4ba9a70de96db0094d362dde49674a9a2a8f920802cf5d7631017cb952efeced188e72eb6118c1a6c9f2b01e4b877874530e605707f
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "rack/utils"
5
+
6
+ module ChatSDK
7
+ module Messenger
8
+ class Adapter < ChatSDK::Adapter::Base
9
+ capabilities :typing_indicator, :direct_messages, :file_uploads
10
+
11
+ attr_reader :client
12
+
13
+ def initialize(app_secret: nil, page_access_token: nil, verify_token: nil)
14
+ @app_secret = app_secret || ENV["FACEBOOK_APP_SECRET"]
15
+ @page_access_token = page_access_token || ENV["FACEBOOK_PAGE_ACCESS_TOKEN"]
16
+ @verify_token = verify_token || ENV["FACEBOOK_VERIFY_TOKEN"]
17
+
18
+ raise ChatSDK::ConfigurationError, "Messenger app_secret required" unless @app_secret
19
+ raise ChatSDK::ConfigurationError, "Messenger page_access_token required" unless @page_access_token
20
+
21
+ @client = ApiClient.new(@page_access_token)
22
+ @renderer = TemplateRenderer.new
23
+ end
24
+
25
+ def name
26
+ :messenger
27
+ end
28
+
29
+ # Inbound
30
+ def verify_request!(rack_request)
31
+ signature = rack_request.get_header("HTTP_X_HUB_SIGNATURE_256")
32
+
33
+ unless signature
34
+ raise ChatSDK::SignatureVerificationError, "Missing Facebook signature header"
35
+ end
36
+
37
+ body = rack_request.body.read
38
+ rack_request.body.rewind
39
+
40
+ expected = "sha256=#{OpenSSL::HMAC.hexdigest("SHA256", @app_secret, body)}"
41
+
42
+ unless Rack::Utils.secure_compare(signature, expected)
43
+ raise ChatSDK::SignatureVerificationError, "Invalid Facebook signature"
44
+ end
45
+
46
+ true
47
+ end
48
+
49
+ def ack_response(rack_request)
50
+ return nil unless rack_request.get?
51
+
52
+ params = rack_request.params
53
+ mode = params["hub.mode"]
54
+ token = params["hub.verify_token"]
55
+ challenge = params["hub.challenge"]
56
+
57
+ if mode == "subscribe" && token == @verify_token
58
+ [200, {}, [challenge.to_s]]
59
+ end
60
+ end
61
+
62
+ def parse_events(rack_request)
63
+ body = rack_request.body.read
64
+ rack_request.body.rewind
65
+
66
+ payload = JSON.parse(body)
67
+ EventParser.parse(payload)
68
+ rescue JSON::ParserError
69
+ []
70
+ end
71
+
72
+ # Outbound
73
+ def post_message(channel_id:, message:, thread_id: nil) # rubocop:disable Lint/UnusedMethodArgument
74
+ payload = prepare_message_payload(message)
75
+
76
+ result = @client.send_message(
77
+ recipient_id: channel_id,
78
+ message: payload
79
+ )
80
+
81
+ parse_messenger_message(result, channel_id)
82
+ end
83
+
84
+ def upload_file(channel_id:, io:, filename:, thread_id: nil, comment: nil) # rubocop:disable Lint/UnusedMethodArgument
85
+ raise ChatSDK::PlatformError.new(
86
+ "Messenger file uploads require a publicly accessible URL. Binary upload is not supported. " \
87
+ "Host the file at a public URL and send it as an attachment.",
88
+ adapter_name: :messenger
89
+ )
90
+ end
91
+
92
+ def open_dm(user_id)
93
+ user_id
94
+ end
95
+
96
+ def start_typing(channel_id:, thread_id: nil) # rubocop:disable Lint/UnusedMethodArgument
97
+ @client.send_action(recipient_id: channel_id, action: "typing_on")
98
+ end
99
+
100
+ def mention(user_id)
101
+ user_id
102
+ end
103
+
104
+ def render(postable_message)
105
+ if postable_message.card?
106
+ @renderer.render(postable_message.card)
107
+ else
108
+ postable_message.text
109
+ end
110
+ end
111
+
112
+ private
113
+
114
+ def prepare_message_payload(message)
115
+ msg = ChatSDK::PostableMessage.from(message)
116
+
117
+ if msg.card?
118
+ rendered = @renderer.render(msg.card)
119
+ if rendered.is_a?(Hash) && rendered[:attachment]
120
+ rendered
121
+ else
122
+ {"text" => rendered[:text] || msg.text || msg.card.fallback_text || ""}
123
+ end
124
+ else
125
+ {"text" => msg.text || ""}
126
+ end
127
+ end
128
+
129
+ def parse_messenger_message(data, channel_id)
130
+ ChatSDK::Message.new(
131
+ id: data["message_id"],
132
+ text: "",
133
+ author: ChatSDK::Author.new(
134
+ id: "bot",
135
+ name: "bot",
136
+ platform: :messenger,
137
+ bot: true
138
+ ),
139
+ thread_id: "messenger:#{data.dig("recipient_id") || channel_id}",
140
+ channel_id: channel_id,
141
+ platform: :messenger,
142
+ raw: data
143
+ )
144
+ end
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChatSDK
4
+ module Messenger
5
+ class ApiClient
6
+ BASE_URL = "https://graph.facebook.com/v21.0/"
7
+
8
+ def initialize(page_access_token)
9
+ @page_access_token = page_access_token
10
+ end
11
+
12
+ def send_message(recipient_id:, message:)
13
+ body = {
14
+ "recipient" => {"id" => recipient_id},
15
+ "message" => message
16
+ }
17
+ request(:post, "me/messages", body)
18
+ end
19
+
20
+ def send_action(recipient_id:, action:)
21
+ body = {
22
+ "recipient" => {"id" => recipient_id},
23
+ "sender_action" => action
24
+ }
25
+ request(:post, "me/messages", body)
26
+ end
27
+
28
+ private
29
+
30
+ def connection
31
+ @connection ||= Faraday.new(url: BASE_URL) do |f|
32
+ f.request :json
33
+ f.response :json
34
+ f.adapter :net_http
35
+ f.params["access_token"] = @page_access_token
36
+ end
37
+ end
38
+
39
+ def request(method, path, body = nil)
40
+ response = connection.public_send(method, path) do |req|
41
+ req.body = body if body && method != :get
42
+ end
43
+
44
+ handle_response(response)
45
+ end
46
+
47
+ def handle_response(response)
48
+ body = response.body
49
+
50
+ if response.success?
51
+ return body if body.is_a?(Hash)
52
+
53
+ return {}
54
+ end
55
+
56
+ if response.status == 429
57
+ raise ChatSDK::RateLimitedError.new(
58
+ "Messenger API rate limited",
59
+ retry_after: nil,
60
+ status: response.status,
61
+ body: body,
62
+ adapter_name: :messenger
63
+ )
64
+ end
65
+
66
+ error_message = body.is_a?(Hash) ? body.dig("error", "message") : response.status
67
+ raise ChatSDK::PlatformError.new(
68
+ "Messenger API error: #{error_message}",
69
+ status: response.status,
70
+ body: body,
71
+ adapter_name: :messenger
72
+ )
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChatSDK
4
+ module Messenger
5
+ class EventParser
6
+ class << self
7
+ def parse(payload)
8
+ return [] unless payload.is_a?(Hash)
9
+ return [] unless payload["object"] == "page"
10
+
11
+ events = []
12
+
13
+ (payload["entry"] || []).each do |entry|
14
+ (entry["messaging"] || []).each do |messaging|
15
+ event = parse_messaging(messaging)
16
+ events << event if event
17
+ end
18
+ end
19
+
20
+ events
21
+ end
22
+
23
+ private
24
+
25
+ def parse_messaging(messaging)
26
+ sender_id = messaging.dig("sender", "id")&.to_s
27
+ return nil unless sender_id
28
+
29
+ channel_id = sender_id
30
+ thread_id = "messenger:#{sender_id}"
31
+
32
+ if messaging["postback"]
33
+ parse_postback(messaging, sender_id, channel_id, thread_id)
34
+ elsif messaging["message"]
35
+ parse_message(messaging, sender_id, channel_id, thread_id)
36
+ end
37
+ end
38
+
39
+ def parse_message(messaging, sender_id, channel_id, thread_id)
40
+ message_data = messaging["message"]
41
+ text = message_data["text"] || ""
42
+
43
+ attachments = extract_attachments(message_data["attachments"])
44
+
45
+ if !attachments.empty?
46
+ text = [text, *attachments.map { |a| a[:url] }].reject(&:empty?).join("\n")
47
+ end
48
+
49
+ msg = ChatSDK::Message.new(
50
+ id: message_data["mid"],
51
+ text: text,
52
+ author: ChatSDK::Author.new(id: sender_id, name: sender_id, platform: :messenger, bot: false),
53
+ thread_id: thread_id,
54
+ channel_id: channel_id,
55
+ platform: :messenger,
56
+ raw: messaging
57
+ )
58
+
59
+ ChatSDK::Events::DirectMessage.new(
60
+ message: msg,
61
+ thread_id: thread_id,
62
+ channel_id: channel_id,
63
+ platform: :messenger,
64
+ adapter_name: :messenger,
65
+ raw: messaging
66
+ )
67
+ end
68
+
69
+ def parse_postback(messaging, sender_id, channel_id, thread_id)
70
+ postback = messaging["postback"]
71
+ payload = postback["payload"] || ""
72
+
73
+ user = ChatSDK::Author.new(
74
+ id: sender_id,
75
+ name: sender_id,
76
+ platform: :messenger,
77
+ bot: false
78
+ )
79
+
80
+ ChatSDK::Events::Action.new(
81
+ action_id: payload,
82
+ value: payload,
83
+ user: user,
84
+ thread_id: thread_id,
85
+ channel_id: channel_id,
86
+ platform: :messenger,
87
+ adapter_name: :messenger,
88
+ raw: messaging
89
+ )
90
+ end
91
+
92
+ def extract_attachments(attachments)
93
+ return [] unless attachments.is_a?(Array)
94
+
95
+ attachments.filter_map do |attachment|
96
+ url = attachment.dig("payload", "url")
97
+ next unless url
98
+
99
+ {type: attachment["type"], url: url}
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChatSDK
4
+ module Messenger
5
+ class TemplateRenderer
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_buttons(node)
17
+ title = node.attributes[:title]
18
+ subtitle = node.attributes[:subtitle]
19
+ image_url = find_image_url(node)
20
+ text_parts = collect_text_parts(node)
21
+ body_text = text_parts.join("\n")
22
+
23
+ if title && buttons.any?
24
+ # Generic Template: has title and buttons
25
+ element = {"title" => truncate(title, 80)}
26
+ element["subtitle"] = truncate(subtitle || body_text, 80) if subtitle || body_text.length.positive?
27
+ element["image_url"] = image_url if image_url
28
+ element["buttons"] = buttons.first(3)
29
+
30
+ {
31
+ attachment: {
32
+ "type" => "template",
33
+ "payload" => {
34
+ "template_type" => "generic",
35
+ "elements" => [element]
36
+ }
37
+ }
38
+ }
39
+ elsif buttons.any? && buttons.length <= 3 && !image_url
40
+ # Button Template: text + buttons, no image
41
+ fallback_text = [title, subtitle, body_text].compact.reject(&:empty?).join(" - ")
42
+ fallback_text = "Choose an option" if fallback_text.empty?
43
+
44
+ {
45
+ attachment: {
46
+ "type" => "template",
47
+ "payload" => {
48
+ "template_type" => "button",
49
+ "text" => truncate(fallback_text, 640),
50
+ "buttons" => buttons.first(3)
51
+ }
52
+ }
53
+ }
54
+ else
55
+ # Fallback: plain text
56
+ fallback = [title, subtitle, body_text].compact.reject(&:empty?).join("\n")
57
+ if buttons.any?
58
+ button_text = buttons.map { |b| b["title"] }.join(", ")
59
+ fallback = "#{fallback}\n[#{button_text}]" unless button_text.empty?
60
+ end
61
+ {text: fallback}
62
+ end
63
+ end
64
+
65
+ def collect_buttons(node)
66
+ buttons = []
67
+ node.children.each do |child|
68
+ case child.type
69
+ when :button
70
+ buttons << render_postback_button(child)
71
+ when :link_button
72
+ buttons << render_url_button(child)
73
+ when :actions
74
+ child.children.each do |action_child|
75
+ case action_child.type
76
+ when :button
77
+ buttons << render_postback_button(action_child)
78
+ when :link_button
79
+ buttons << render_url_button(action_child)
80
+ end
81
+ end
82
+ when :section
83
+ buttons.concat(collect_buttons(child))
84
+ end
85
+ end
86
+ buttons
87
+ end
88
+
89
+ def collect_text_parts(node)
90
+ parts = []
91
+ node.children.each do |child|
92
+ case child.type
93
+ when :text
94
+ parts << child.attributes[:content]
95
+ when :divider
96
+ parts << "---"
97
+ when :fields
98
+ child.children.each do |field|
99
+ parts << "#{field.attributes[:label]}: #{field.attributes[:value]}"
100
+ end
101
+ when :section
102
+ parts << child.attributes[:title] if child.attributes[:title]
103
+ parts.concat(collect_text_parts(child))
104
+ end
105
+ end
106
+ parts
107
+ end
108
+
109
+ def find_image_url(node)
110
+ node.children.each do |child|
111
+ return child.attributes[:url] if child.type == :image
112
+ if child.type == :section
113
+ url = find_image_url(child)
114
+ return url if url
115
+ end
116
+ end
117
+ nil
118
+ end
119
+
120
+ def render_postback_button(node)
121
+ {
122
+ "type" => "postback",
123
+ "title" => truncate(node.attributes[:text], 20),
124
+ "payload" => node.attributes[:id] || "button"
125
+ }
126
+ end
127
+
128
+ def render_url_button(node)
129
+ {
130
+ "type" => "web_url",
131
+ "title" => truncate(node.attributes[:text], 20),
132
+ "url" => node.attributes[:url]
133
+ }
134
+ end
135
+
136
+ def render_single(node)
137
+ case node.type
138
+ when :text
139
+ {text: node.attributes[:content]}
140
+ else
141
+ {text: node.fallback_text}
142
+ end
143
+ end
144
+
145
+ def truncate(text, max)
146
+ return "" unless text
147
+
148
+ (text.length > max) ? "#{text[0..max - 4]}..." : text
149
+ end
150
+ end
151
+ end
152
+ 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 Messenger
10
+ class << self
11
+ def loader
12
+ @loader ||= begin
13
+ loader = Zeitwerk::Loader.new
14
+ loader.tag = "chat_sdk-messenger"
15
+ loader.inflector.inflect("chat_sdk" => "ChatSDK")
16
+ loader.push_dir("#{__dir__}/messenger", namespace: ChatSDK::Messenger)
17
+ loader.setup
18
+ loader
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+
25
+ ChatSDK::Messenger.loader
metadata ADDED
@@ -0,0 +1,75 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: chat_sdk-messenger
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: Facebook Messenger adapter for the ChatSDK framework with HMAC-SHA256
41
+ verification and template rendering
42
+ email:
43
+ - quentin@rootly.com
44
+ executables: []
45
+ extensions: []
46
+ extra_rdoc_files: []
47
+ files:
48
+ - lib/chat_sdk/messenger.rb
49
+ - lib/chat_sdk/messenger/adapter.rb
50
+ - lib/chat_sdk/messenger/api_client.rb
51
+ - lib/chat_sdk/messenger/event_parser.rb
52
+ - lib/chat_sdk/messenger/template_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: Facebook Messenger adapter for ChatSDK
75
+ test_files: []