diff --git a/app/builders/contact_inbox_builder.rb b/app/builders/contact_inbox_builder.rb index 73380190d..8ee1b3fec 100644 --- a/app/builders/contact_inbox_builder.rb +++ b/app/builders/contact_inbox_builder.rb @@ -4,7 +4,7 @@ class ContactInboxBuilder def perform @contact = Contact.find(contact_id) @inbox = @contact.account.inboxes.find(inbox_id) - return unless ['Channel::TwilioSms', 'Channel::Email', 'Channel::Api'].include? @inbox.channel_type + return unless ['Channel::TwilioSms', 'Channel::Email', 'Channel::Api', 'Channel::Whatsapp'].include? @inbox.channel_type source_id = @source_id || generate_source_id create_contact_inbox(source_id) if source_id.present? @@ -14,12 +14,20 @@ class ContactInboxBuilder def generate_source_id return twilio_source_id if @inbox.channel_type == 'Channel::TwilioSms' + return wa_source_id if @inbox.channel_type == 'Channel::Whatsapp' return @contact.email if @inbox.channel_type == 'Channel::Email' return SecureRandom.uuid if @inbox.channel_type == 'Channel::Api' nil end + def wa_source_id + return unless @contact.phone_number + + # whatsapp doesn't want the + in e164 format + "#{@contact.phone_number}.delete('+')" + end + def twilio_source_id return unless @contact.phone_number diff --git a/app/models/channel/whatsapp.rb b/app/models/channel/whatsapp.rb index ab76e3ce1..3642fac16 100644 --- a/app/models/channel/whatsapp.rb +++ b/app/models/channel/whatsapp.rb @@ -2,13 +2,15 @@ # # Table name: channel_whatsapp # -# id :bigint not null, primary key -# phone_number :string not null -# provider :string default("default") -# provider_config :jsonb -# created_at :datetime not null -# updated_at :datetime not null -# account_id :integer not null +# id :bigint not null, primary key +# message_templates :jsonb +# message_templates_last_updated :datetime +# phone_number :string not null +# provider :string default("default") +# provider_config :jsonb +# created_at :datetime not null +# updated_at :datetime not null +# account_id :integer not null # # Indexes # @@ -43,6 +45,10 @@ class Channel::Whatsapp < ApplicationRecord end end + def send_template(phone_number, template_info) + send_template_message(phone_number, template_info) + end + def media_url(media_id) "#{api_base_path}/media/#{media_id}" end @@ -55,27 +61,34 @@ class Channel::Whatsapp < ApplicationRecord true end + def message_templates + sync_templates + super + end + private def send_text_message(phone_number, message) - HTTParty.post( + response = HTTParty.post( "#{api_base_path}/messages", - headers: { 'D360-API-KEY': provider_config['api_key'], 'Content-Type': 'application/json' }, + headers: api_headers, body: { to: phone_number, text: { body: message.content }, type: 'text' }.to_json ) + + response.success? ? response['messages'].first['id'] : nil end def send_attachment_message(phone_number, message) attachment = message.attachments.first type = %w[image audio video].include?(attachment.file_type) ? attachment.file_type : 'document' attachment_url = attachment.file_url - HTTParty.post( + response = HTTParty.post( "#{api_base_path}/messages", - headers: { 'D360-API-KEY': provider_config['api_key'], 'Content-Type': 'application/json' }, + headers: api_headers, body: { 'to' => phone_number, 'type' => type, @@ -85,6 +98,46 @@ class Channel::Whatsapp < ApplicationRecord } }.to_json ) + + response.success? ? response['messages'].first['id'] : nil + end + + def send_template_message(phone_number, template_info) + response = HTTParty.post( + "#{api_base_path}/messages", + headers: api_headers, + body: { + to: phone_number, + template: template_body_parameters(template_info), + type: 'template' + }.to_json + ) + + response.success? ? response['messages'].first['id'] : nil + end + + def template_body_parameters(template_info) + { + name: template_info[:name], + namespace: template_info[:namespace], + language: { + policy: 'deterministic', + code: template_info[:lang_code] + }, + components: [{ + type: 'body', + parameters: template_info[:parameters] + }] + } + end + + def sync_templates + # to prevent too many api calls + last_updated = message_templates_last_updated || 1.day.ago + return if Time.current < (last_updated + 12.hours) + + response = HTTParty.get("#{api_base_path}/configs/templates", headers: api_headers) + update(message_templates: response['waba_templates'], message_templates_last_updated: Time.now.utc) if response.success? end # Extract later into provider Service diff --git a/app/services/whatsapp/send_on_whatsapp_service.rb b/app/services/whatsapp/send_on_whatsapp_service.rb index 7b511cf83..4fbaa1312 100644 --- a/app/services/whatsapp/send_on_whatsapp_service.rb +++ b/app/services/whatsapp/send_on_whatsapp_service.rb @@ -6,6 +6,71 @@ class Whatsapp::SendOnWhatsappService < Base::SendOnChannelService end def perform_reply - channel.send_message(message.conversation.contact_inbox.source_id, message) + # can reply checks if 24 hour limit has passed. + if message.conversation.can_reply? + send_on_whatsapp + else + send_template_message + end + end + + def send_template_message + name, namespace, lang_code, processed_parameters = processable_channel_message_template + return if name.blank? + + message_id = channel.send_template(message.conversation.contact_inbox.source_id, { + name: name, + namespace: namespace, + lang_code: lang_code, + parameters: processed_parameters + }) + message.update!(source_id: message_id) if message_id.present? + end + + def processable_channel_message_template + # see if we can match the message content to a template + # An example template may look like "Your package has been shipped. It will be delivered in {{1}} business days. + # We want to iterate over these templates with our message body and see if we can fit it to any of the templates + # Then we use regex to parse the template varibles and convert them into the proper payload + channel.message_templates.each do |template| + match_obj = template_match_object(template) + next if match_obj.blank? + + # we have a match, now we need to parse the template variables and convert them into the wa recommended format + processed_parameters = match_obj.captures.map { |x| { type: 'text', text: x } } + + # no need to look up further end the search + return [template['name'], template['namespace'], template['language'], processed_parameters] + end + [nil, nil, nil, nil] + end + + def template_match_object(template) + body_object = validated_body_object(template) + return if body_object.blank? + + template_match_regex = build_template_match_regex(body_object['text']) + message.content.match(template_match_regex) + end + + def build_template_match_regex(template_text) + # Converts the whatsapp template to a comparable regex string to check against the message content + # the variables are of the format {{num}} ex:{{1}} + template_match_string = "^#{template_text.gsub(/{{\d}}/, '(.*)')}$" + Regexp.new template_match_string + end + + def validated_body_object(template) + # we don't care if its not approved template + return if template['status'] != 'approved' + + # we only care about text body object in template. if not present we discard the template + # we don't support other forms of templates + template['components'].find { |obj| obj['type'] == 'BODY' && obj.key?('text') } + end + + def send_on_whatsapp + message_id = channel.send_message(message.conversation.contact_inbox.source_id, message) + message.update!(source_id: message_id) if message_id.present? end end diff --git a/db/migrate/20211129120040_add_templates_to_whatsapp_channel.rb b/db/migrate/20211129120040_add_templates_to_whatsapp_channel.rb new file mode 100644 index 000000000..247fd9095 --- /dev/null +++ b/db/migrate/20211129120040_add_templates_to_whatsapp_channel.rb @@ -0,0 +1,15 @@ +class AddTemplatesToWhatsappChannel < ActiveRecord::Migration[6.1] + def up + change_table :channel_whatsapp, bulk: true do |t| + t.column :message_templates, :jsonb, default: {} + t.column :message_templates_last_updated, :datetime + end + end + + def down + change_table :channel_whatsapp, bulk: true do |t| + t.remove :message_templates + t.remove :message_templates_last_updated + end + end +end diff --git a/db/schema.rb b/db/schema.rb index b8ea425a8..14526629d 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2021_11_22_112607) do +ActiveRecord::Schema.define(version: 2021_11_29_120040) do # These are extensions that must be enabled in order to support this database enable_extension "pg_stat_statements" @@ -270,6 +270,8 @@ ActiveRecord::Schema.define(version: 2021_11_22_112607) do t.jsonb "provider_config", default: {} t.datetime "created_at", precision: 6, null: false t.datetime "updated_at", precision: 6, null: false + t.jsonb "message_templates", default: {} + t.datetime "message_templates_last_updated" t.index ["phone_number"], name: "index_channel_whatsapp_on_phone_number", unique: true end diff --git a/spec/factories/channel/channel_whatsapp.rb b/spec/factories/channel/channel_whatsapp.rb index ea8b563dc..87ded15ab 100644 --- a/spec/factories/channel/channel_whatsapp.rb +++ b/spec/factories/channel/channel_whatsapp.rb @@ -3,6 +3,27 @@ FactoryBot.define do sequence(:phone_number) { |n| "+123456789#{n}1" } account provider_config { { 'api_key' => 'test_key' } } + message_templates do + [{ 'name' => 'sample_shipping_confirmation', + 'status' => 'approved', + 'category' => 'SHIPPING_UPDATE', + 'language' => 'id', + 'namespace' => '2342384942_32423423_23423fdsdaf', + 'components' => + [{ 'text' => 'Paket Anda sudah dikirim. Paket akan sampai dalam {{1}} hari kerja.', 'type' => 'BODY' }, + { 'text' => 'Pesan ini berasal dari bisnis yang tidak terverifikasi.', 'type' => 'FOOTER' }], + 'rejected_reason' => 'NONE' }, + { 'name' => 'sample_shipping_confirmation', + 'status' => 'approved', + 'category' => 'SHIPPING_UPDATE', + 'language' => 'en_US', + 'namespace' => '23423423_2342423_324234234_2343224', + 'components' => + [{ 'text' => 'Your package has been shipped. It will be delivered in {{1}} business days.', 'type' => 'BODY' }, + { 'text' => 'This message is from an unverified business.', 'type' => 'FOOTER' }], + 'rejected_reason' => 'NONE' }] + end + message_templates_last_updated { Time.now.utc } after(:create) do |channel_whatsapp| create(:inbox, channel: channel_whatsapp, account: channel_whatsapp.account) diff --git a/spec/services/whatsapp/send_on_whatsapp_service_spec.rb b/spec/services/whatsapp/send_on_whatsapp_service_spec.rb index ef2bf8e45..0dc8f6809 100644 --- a/spec/services/whatsapp/send_on_whatsapp_service_spec.rb +++ b/spec/services/whatsapp/send_on_whatsapp_service_spec.rb @@ -7,20 +7,54 @@ describe Whatsapp::SendOnWhatsappService do end context 'when a valid message' do - it 'calls channel.send_message' do + it 'calls channel.send_message when with in 24 hour limit' do whatsapp_request = double whatsapp_channel = create(:channel_whatsapp) contact_inbox = create(:contact_inbox, inbox: whatsapp_channel.inbox, source_id: '123456789') conversation = create(:conversation, contact_inbox: contact_inbox, inbox: whatsapp_channel.inbox) + # to handle the case of 24 hour window limit. + create(:message, message_type: :incoming, content: 'test', + conversation: conversation) message = create(:message, message_type: :outgoing, content: 'test', conversation: conversation) allow(HTTParty).to receive(:post).and_return(whatsapp_request) + allow(whatsapp_request).to receive(:success?).and_return(true) + allow(whatsapp_request).to receive(:[]).with('messages').and_return([{ 'id' => '123456789' }]) expect(HTTParty).to receive(:post).with( 'https://waba.360dialog.io/v1/messages', - headers: { 'D360-API-KEY': 'test_key', 'Content-Type': 'application/json' }, - body: { to: '123456789', text: { body: 'test' }, type: 'text' }.to_json + headers: { 'D360-API-KEY' => 'test_key', 'Content-Type' => 'application/json' }, + body: { 'to' => '123456789', 'text' => { 'body' => 'test' }, 'type' => 'text' }.to_json ) described_class.new(message: message).perform + expect(message.reload.source_id).to eq('123456789') + end + + it 'calls channel.send_template when after 24 hour limit' do + whatsapp_request = double + whatsapp_channel = create(:channel_whatsapp) + contact_inbox = create(:contact_inbox, inbox: whatsapp_channel.inbox, source_id: '123456789') + conversation = create(:conversation, contact_inbox: contact_inbox, inbox: whatsapp_channel.inbox) + message = create(:message, message_type: :outgoing, content: 'Your package has been shipped. It will be delivered in 3 business days.', + conversation: conversation) + allow(HTTParty).to receive(:post).and_return(whatsapp_request) + allow(whatsapp_request).to receive(:success?).and_return(true) + allow(whatsapp_request).to receive(:[]).with('messages').and_return([{ 'id' => '123456789' }]) + expect(HTTParty).to receive(:post).with( + 'https://waba.360dialog.io/v1/messages', + headers: { 'D360-API-KEY' => 'test_key', 'Content-Type' => 'application/json' }, + body: { + to: '123456789', + template: { + name: 'sample_shipping_confirmation', + namespace: '23423423_2342423_324234234_2343224', + language: { 'policy': 'deterministic', 'code': 'en_US' }, + components: [{ 'type': 'body', 'parameters': [{ 'type': 'text', 'text': '3' }] }] + }, + type: 'template' + }.to_json + ) + described_class.new(message: message).perform + expect(message.reload.source_id).to eq('123456789') end end end