Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

360Dialog WhatsApp #1645

Merged
merged 60 commits into from
Nov 1, 2023
Merged
Show file tree
Hide file tree
Changes from 36 commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
0ec5c46
Add WhatsAppAdapter::Outbound::Template class
mattwr18 May 6, 2023
9b29a35
Start working on consumption of redirect after onboarding
mattwr18 May 10, 2023
2cd98ef
Update create api key flow
mattwr18 May 11, 2023
64f344c
Clean up env variables, lint
mattwr18 Jun 3, 2023
62abbcd
Merge branch 'main' into 360dialog_spike
mattwr18 Jul 26, 2023
0b4f99c
Update Setting names
mattwr18 Aug 1, 2023
4dfa0d6
Add rake task/jobs for initial config
mattwr18 Aug 1, 2023
74be260
Start receive message feature 360dialog
mattwr18 Aug 1, 2023
d68c395
Send WhatsApp message with configured BPA
mattwr18 Aug 1, 2023
7f9a843
Add 360dialog to other outbound methods
mattwr18 Aug 1, 2023
22a420a
Handle other inbound text actions
mattwr18 Aug 1, 2023
c8e71c1
Fix template creation workflow
mattwr18 Aug 2, 2023
a1701fb
Add sending image files feature to 360dialog WhatsApp
mattwr18 Aug 2, 2023
43c7db0
Conditionally create template, add welcome message
mattwr18 Aug 3, 2023
e0932f3
Add support for receiving files
mattwr18 Aug 3, 2023
14ab59d
Handle unsupported content
mattwr18 Aug 3, 2023
d7d7f1e
Fix unsupported content logic
mattwr18 Aug 3, 2023
25d1294
Extract 360dialog to its own controller
mattwr18 Aug 7, 2023
9468a86
Handle successful client onboarding in webhook
mattwr18 Aug 7, 2023
a8aae46
Set template namespace in create template job
mattwr18 Aug 7, 2023
8e46693
Remove duplicate controller methods
mattwr18 Aug 7, 2023
c0ed395
Decouple WhatsAppAdapter outbound by BSP
mattwr18 Aug 7, 2023
8c2ba7c
Add env to ansible/docker
mattwr18 Aug 7, 2023
4a67572
Add env variables to ansible config for staging
mattwr18 Aug 8, 2023
d7cd83e
Fix syntax errors
mattwr18 Aug 8, 2023
cd77466
Fix naming issues with numerical 360
mattwr18 Aug 8, 2023
215f87a
Improve jobs
mattwr18 Aug 8, 2023
fad7f42
Lint
mattwr18 Aug 8, 2023
42f5ecb
Move connect button to profile, use JS/modal
mattwr18 Aug 9, 2023
018410c
Fix failing tests after refactor
mattwr18 Aug 15, 2023
13bfe5b
Namespace WhatsAppAdapter callbacks to avoid duplicate const
mattwr18 Aug 15, 2023
8a852d2
Add 360dialog check in contributor onboarding
mattwr18 Aug 15, 2023
71ba923
Fix welcome message template payload
mattwr18 Aug 15, 2023
9be875e
Lint
mattwr18 Aug 15, 2023
3d83c29
Add requests specs, fix issues
mattwr18 Aug 15, 2023
3ca3b89
Add tests for files
mattwr18 Aug 16, 2023
5ef625d
Merge branch 'main' into 360dialog_spike
mattwr18 Aug 21, 2023
5ea5e82
Add WhatsApp button when configured with 360dialog
mattwr18 Aug 23, 2023
24c4572
Allow CreateApiKey job to run even with api key
mattwr18 Aug 23, 2023
24c403d
Save captions, allow certain document types
mattwr18 Aug 23, 2023
f5e04b7
Add/refactor specs
mattwr18 Aug 23, 2023
b60db3a
Disable lint for now
mattwr18 Aug 23, 2023
176fbf3
Return class from helper method instead of substring
mattwr18 Aug 23, 2023
a2c9f73
Remove redundant constantize method
mattwr18 Aug 24, 2023
89ae62d
Fix broken specs after adding contact person to org factory
mattwr18 Aug 24, 2023
4915acd
Revert "Fix broken specs after adding contact person to org factory"
mattwr18 Aug 24, 2023
b141494
Fix spec without breaking other specs
mattwr18 Aug 24, 2023
f518217
Lint
mattwr18 Aug 24, 2023
d226e70
Add spec for WhatsAppAdapter::Outbound class
mattwr18 Aug 24, 2023
da959e1
Send out "random" templates
mattwr18 Aug 24, 2023
3f15b55
Remove unused .env variables from template
mattwr18 Aug 24, 2023
e90e1c1
Add spec for 360dialog outbound .send! method
mattwr18 Aug 24, 2023
bbcbfed
Initialize three sixty dialog settings with default empty string
mattwr18 Aug 28, 2023
faf7e42
Finish writing outbound spec for 360dialog
mattwr18 Aug 28, 2023
691a1f4
Remove unrelated code changes
mattwr18 Aug 31, 2023
bd940c8
Remove console log
mattwr18 Aug 31, 2023
1007c38
Namespace concern to be more specific
mattwr18 Aug 31, 2023
02c6d41
Reorganize strong params for readability
mattwr18 Sep 6, 2023
b3b7681
Rename file after renaming concern
mattwr18 Sep 6, 2023
13ec520
Merge branch 'main' into 360dialog_spike
mattwr18 Sep 28, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.template
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,9 @@ THREEMARB_PRIVATE=
# POSTMARK_TRANSACTIONAL_STREAM=outbound
# DOCKER_IMAGE_TAG=
# SENTRY_DSN=
#
# THREE_SIXTY_DIALOG_PARTNER_ID=
# THREE_SIXTY_DIALOG_PARTNER_USERNAME=
# THREE_SIXTY_DIALOG_PARTNER_PASSWORD=
# THREE_SIXTY_DIALOG_WHATS_APP_REST_API_ENDPOINT=
# THREE_SIXTY_DIALOG_PARTNER_REST_API_ENDPOINT=
224 changes: 115 additions & 109 deletions ansible/inventories/staging/host_vars/staging.yml

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions ansible/roles/installation/tasks/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@
TWILIO_AUTH_TOKEN: "{{ rails.twilio.auth_token }}"
TWILIO_API_KEY_SID: "{{ rails.twilio.api_key.sid }}"
TWILIO_API_KEY_SECRET: "{{ rails.twilio.api_key.secret }}"
THREE_SIXTY_DIALOG_PARTNER_ID: "{{ rails.three_sixty_dialog.partner.id }}"
THREE_SIXTY_DIALOG_PARTNER_USERNAME: "{{ rails.three_sixty_dialog.partner.username }}"
THREE_SIXTY_DIALOG_PARTNER_PASSWORD: "{{ rails.three_sixty_dialog.partner.password }}"

community.general.docker_compose:
project_src: /home/ansible
build: no
Expand Down
83 changes: 7 additions & 76 deletions app/adapters/whats_app_adapter/outbound.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,94 +4,25 @@ module WhatsAppAdapter
class Outbound
class << self
def send!(message)
recipient = message&.recipient
return unless contributor_can_receive_messages?(recipient)

if freeform_message_permitted?(recipient)
send_message(recipient, message)
else
send_message_template(recipient, message)
end
"WhatsAppAdapter::#{business_solution_provider}Outbound".constantize.send!(message)
end

def send_welcome_message!(contributor)
return unless contributor_can_receive_messages?(contributor)

welcome_message = I18n.t('adapter.whats_app.welcome_message', project_name: Setting.project_name)
WhatsAppAdapter::Outbound::Text.perform_later(recipient: contributor, text: welcome_message)
end

def send_unsupported_content_message!(contributor)
return unless contributor_can_receive_messages?(contributor)

WhatsAppAdapter::Outbound::Text.perform_later(recipient: contributor,
text: I18n.t('adapter.whats_app.unsupported_content_template',
first_name: contributor.first_name,
contact_person: contributor.organization.contact_person.name))
"WhatsAppAdapter::#{business_solution_provider}Outbound".constantize.send_welcome_message!(contributor)
end

def send_more_info_message!(contributor)
return unless contributor_can_receive_messages?(contributor)

text = [Setting.about, "_#{I18n.t('adapter.whats_app.unsubscribe.instructions')}_"].join("\n\n")
WhatsAppAdapter::Outbound::Text.perform_later(recipient: contributor, text: text)
"WhatsAppAdapter::#{business_solution_provider}Outbound".constantize.send_more_info_message!(contributor)
end

def send_unsubsribed_successfully_message!(contributor)
return unless contributor_can_receive_messages?(contributor)

text = [I18n.t('adapter.whats_app.unsubscribe.successful'), "_#{I18n.t('adapter.whats_app.subscribe.instructions')}_"].join("\n\n")
WhatsAppAdapter::Outbound::Text.perform_later(recipient: contributor, text: text)
end

def contributor_can_receive_messages?(recipient)
recipient&.whats_app_phone_number.present?
end

def time_of_day
current_time = Time.current
morning = current_time.change(hour: 6)
day = current_time.change(hour: 11)
evening = current_time.change(hour: 17)
night = current_time.change(hour: 23)

case current_time
when morning..day
'morning'
when day..evening
'day'
when evening..night
'evening'
else
'night'
end
end

def freeform_message_permitted?(recipient)
responding_to_template_message = recipient.whats_app_message_template_responded_at.present? &&
recipient.whats_app_message_template_responded_at > 24.hours.ago
latest_message_received_within_last_24_hours = recipient.replies.first&.created_at.present? &&
recipient.replies.first.created_at > 24.hours.ago
responding_to_template_message || latest_message_received_within_last_24_hours
end

def send_message_template(recipient, message)
recipient.update!(whats_app_message_template_sent_at: Time.current)
text = I18n.t("adapter.whats_app.request_template.new_request_#{time_of_day}_#{rand(1..3)}", first_name: recipient.first_name,
request_title: message.request.title)
WhatsAppAdapter::Outbound::Text.perform_later(recipient: recipient, text: text)
"WhatsAppAdapter::#{business_solution_provider}Outbound".constantize.send_unsubsribed_successfully_message!(contributor)
end

def send_message(recipient, message)
files = message.files
private

if files.blank?
WhatsAppAdapter::Outbound::Text.perform_later(recipient: recipient, text: message.text)
else
files.each_with_index do |file, index|
WhatsAppAdapter::Outbound::File.perform_later(recipient: recipient, text: index.zero? ? message.text : '', file: file)
end
end
def business_solution_provider
Setting.three_sixty_dialog_client_api_key.present? ? 'ThreeSixtyDialog' : 'Twilio'
mattwr18 marked this conversation as resolved.
Show resolved Hide resolved
end
end
end
Expand Down
49 changes: 49 additions & 0 deletions app/adapters/whats_app_adapter/outbound/three_sixty_dialog_file.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# frozen_string_literal: true

module WhatsAppAdapter
class Outbound
class ThreeSixtyDialogFile < ApplicationJob
queue_as :default

def perform(message_id:, file_id:)
message = Message.find(message_id)
@recipient = message.recipient
@file_id = file_id

url = URI.parse("#{Setting.three_sixty_dialog_whats_app_rest_api_endpoint}/messages")
headers = { 'D360-API-KEY' => Setting.three_sixty_dialog_client_api_key, 'Content-Type' => 'application/json' }
request = Net::HTTP::Post.new(url.to_s, headers)
request.body = payload.to_json
response = Net::HTTP.start(url.host, url.port, use_ssl: true) do |http|
http.request(request)
end
handle_response(response)
end

private

attr_reader :recipient, :file_id

def payload
{
recipient_type: 'individual',
to: recipient.whats_app_phone_number.split('+').last,
type: 'image',
image: {
id: file_id
}
}
end

def handle_response(response)
case response.code.to_i
when 200
Rails.logger.debug 'Great!'
when 400..599
exception = WhatsAppAdapter::ThreeSixtyDialogError.new(error_code: response.code, message: response.body)
ErrorNotifier.report(exception)
end
end
end
end
end
33 changes: 33 additions & 0 deletions app/adapters/whats_app_adapter/outbound/three_sixty_dialog_text.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# frozen_string_literal: true

module WhatsAppAdapter
class Outbound
class ThreeSixtyDialogText < ApplicationJob
queue_as :default

def perform(payload:)
url = URI.parse("#{Setting.three_sixty_dialog_whats_app_rest_api_endpoint}/messages")
headers = { 'D360-API-KEY' => Setting.three_sixty_dialog_client_api_key, 'Content-Type' => 'application/json' }
request = Net::HTTP::Post.new(url.to_s, headers)

request.body = payload.to_json
response = Net::HTTP.start(url.host, url.port, use_ssl: true) do |http|
http.request(request)
end
handle_response(response)
end

private

def handle_response(response)
case response.code.to_i
when 201
Rails.logger.debug 'Great!'
when 400..599
exception = WhatsAppAdapter::ThreeSixtyDialogError.new(error_code: response.code, message: response.body)
ErrorNotifier.report(exception)
end
end
end
end
end
9 changes: 9 additions & 0 deletions app/adapters/whats_app_adapter/three_sixty_dialog_error.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# frozen_string_literal: true

module WhatsAppAdapter
class ThreeSixtyDialogError < StandardError
def initialize(error_code:, message:)
super("Error occurred for WhatsApp with error code: #{error_code} with message: #{message}")
end
end
end
181 changes: 181 additions & 0 deletions app/adapters/whats_app_adapter/three_sixty_dialog_inbound.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
# frozen_string_literal: true

module WhatsAppAdapter
class ThreeSixtyDialogInbound
UNKNOWN_CONTRIBUTOR = :unknown_contributor
UNSUPPORTED_CONTENT = :unsupported_content
REQUEST_FOR_MORE_INFO = :request_for_more_info
REQUEST_TO_RECEIVE_MESSAGE = :request_to_receive_message
UNSUBSCRIBE_CONTRIBUTOR = :unsubscribe_contributor
SUBSCRIBE_CONTRIBUTOR = :subscribe_contributor
UNSUPPORTED_CONTENT_TYPES = %w[location contacts document].freeze

attr_reader :sender, :text, :message

def initialize
@callbacks = {}
end

def on(callback, &block)
@callbacks[callback] = block
end

def consume(whats_app_message)
whats_app_message = whats_app_message.with_indifferent_access

@sender = initialize_sender(whats_app_message)
return unless @sender

@message = initialize_message(whats_app_message)
return unless @message

@unsupported_content = initialize_unsupported_content(whats_app_message)

files = initialize_file(whats_app_message)
@message.files = files

return unless create_message?

yield(@message) if block_given?
end

private

def trigger(event, *args)
return unless @callbacks.key?(event)

@callbacks[event].call(*args)
end

def initialize_sender(whats_app_message)
whats_app_phone_number = whats_app_message[:contacts].first[:wa_id].phony_normalized
sender = Contributor.find_by(whats_app_phone_number: whats_app_phone_number)

unless sender
trigger(UNKNOWN_CONTRIBUTOR, whats_app_phone_number)
return nil
end

sender
end

def initialize_message(whats_app_message)
message = whats_app_message[:messages].first
text = message[:text]&.dig(:body) || message[:button]&.dig(:text)

trigger(REQUEST_FOR_MORE_INFO, sender) if request_for_more_info?(text)
trigger(UNSUBSCRIBE_CONTRIBUTOR, sender) if unsubscribe_text?(text)
trigger(SUBSCRIBE_CONTRIBUTOR, sender) if subscribe_text?(text)
trigger(REQUEST_TO_RECEIVE_MESSAGE, sender) if request_to_receive_message?(sender, text)

message = Message.new(text: text, sender: sender)
message.raw_data.attach(
io: StringIO.new(JSON.generate(whats_app_message)),
filename: 'whats_app_message.json',
content_type: 'application/json'
)
message
end

def initialize_unsupported_content(whats_app_message)
return unless unsupported_content?(whats_app_message)

message.unknown_content = true
trigger(UNSUPPORTED_CONTENT, sender)
end

def initialize_file(whats_app_message)
message = whats_app_message[:messages].first
return [] unless file_type_supported?(message)

file = Message::File.new

message_file = supported_file(message)
content_type = message_file[:mime_type]
file_id = message_file[:id]

file.attachment.attach(
io: StringIO.new(fetch_file(file_id)),
filename: file_id,
content_type: content_type,
identify: false
)

[file]
end

def file_type_supported?(message)
supported_file(message).present?
end

def supported_file(message)
message[:image] || message[:voice] || message[:video] || message[:audio]
end

def unsupported_content?(whats_app_message)
message = whats_app_message[:messages].first
return unless message

unsupported_content = message.keys.any? do |key|
UNSUPPORTED_CONTENT_TYPES.include?(key)
end
errors = message[:errors]
return unsupported_content unless errors

error_indicating_unsupported_content(errors)
end

def error_indicating_unsupported_content(errors)
errors.first[:title].match?(/Unsupported message type/) || errors.first[:title].match?(/Received Wrong Message Type/)
end

def request_for_more_info?(text)
return false if text.blank?

text.strip.eql?(I18n.t('adapter.whats_app.quick_reply_button_text.more_info'))
end

def request_to_receive_message?(contributor, text)
return false if request_for_more_info?(text) || unsubscribe_text?(text) || subscribe_text?(text)

contributor.whats_app_message_template_sent_at.present?
end

def quick_reply_response?(text)
quick_reply_keys = %w[answer more_info]
quick_reply_texts = []
quick_reply_keys.each do |key|
quick_reply_texts << I18n.t("adapter.whats_app.quick_reply_button_text.#{key}")
end
text.strip.in?(quick_reply_texts)
end

def unsubscribe_text?(text)
return false if text.blank?

text.downcase.strip.eql?(I18n.t('adapter.whats_app.unsubscribe.text'))
end

def subscribe_text?(text)
return false if text.blank?

text.downcase.strip.eql?(I18n.t('adapter.whats_app.subscribe.text'))
end

def create_message?
has_non_text_content = message.files.any? || message.unknown_content
text = message.text
has_non_text_content || (message.text.present? && !quick_reply_response?(text) && !unsubscribe_text?(text) && !subscribe_text?(text))
end

def fetch_file(file_id)
url = URI.parse("#{Setting.three_sixty_dialog_whats_app_rest_api_endpoint}/media/#{file_id}")
headers = { 'D360-API-KEY' => Setting.three_sixty_dialog_client_api_key, 'Content-Type' => 'application/json' }
request = Net::HTTP::Get.new(url.to_s, headers)
response = Net::HTTP.start(url.host, url.port, use_ssl: true) do |http|
http.request(request)
end
response.body
end
end
end
Loading