diff --git a/app/adapters/whats_app_adapter/three_sixty_dialog_outbound.rb b/app/adapters/whats_app_adapter/three_sixty_dialog_outbound.rb index e9fe33760..2e425f3cd 100644 --- a/app/adapters/whats_app_adapter/three_sixty_dialog_outbound.rb +++ b/app/adapters/whats_app_adapter/three_sixty_dialog_outbound.rb @@ -112,18 +112,16 @@ def send_message_template(recipient, message) end def send_message(recipient, message) - files = message.files + if message.files.present? + WhatsAppAdapter::ThreeSixtyDialogOutbound::File.perform_later(message_id: message.id) + + else - if files.blank? WhatsAppAdapter::ThreeSixtyDialogOutbound::Text.perform_later(organization_id: message.organization.id, payload: text_payload( recipient, message.text ), message_id: message.id) - else - files.each do |_file| - WhatsAppAdapter::ThreeSixtyDialog::UploadFileJob.perform_later(message_id: message.id) - end end end diff --git a/app/adapters/whats_app_adapter/three_sixty_dialog_outbound/file.rb b/app/adapters/whats_app_adapter/three_sixty_dialog_outbound/file.rb index d85885066..8d16e0724 100644 --- a/app/adapters/whats_app_adapter/three_sixty_dialog_outbound/file.rb +++ b/app/adapters/whats_app_adapter/three_sixty_dialog_outbound/file.rb @@ -5,30 +5,52 @@ class ThreeSixtyDialogOutbound class File < ApplicationJob queue_as :default - def perform(message_id:, file_id:) - @message = Message.find(message_id) - organization = Organization.find(message.organization.id) + retry_on Net::HTTPServerError, wait: ->(executions) { executions * 3 } do |job, exception| + if job.executions == 5 + exception = WhatsAppAdapter::ThreeSixtyDialogError.new(error_code: exception.code, message: exception.message) + context = { message_id: job.arguments.first[:message_id] } + ErrorNotifier.report(exception, context: context) + end + end + def perform(message_id:) + @message = Message.find(message_id) @recipient = message.recipient - @file_id = file_id - url = URI.parse("#{ENV.fetch('THREE_SIXTY_DIALOG_WHATS_APP_REST_API_ENDPOINT', 'https://stoplight.io/mocks/360dialog/360dialog-partner-api/24588693')}/messages") - headers = { 'D360-API-KEY' => organization.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) - rescue ActiveRecord::RecordNotFound => e - ErrorNotifier.report(e) + send_files + send_text_separately unless caption_it? end private - attr_reader :recipient, :file_id, :message + attr_reader :recipient, :message + + def send_files + url = URI.parse("#{ENV.fetch('THREE_SIXTY_DIALOG_WHATS_APP_REST_API_ENDPOINT', 'https://stoplight.io/mocks/360dialog/360dialog-partner-api/24588693')}/messages") + headers = { 'D360-API-KEY' => message.organization.three_sixty_dialog_client_api_key, 'Content-Type' => 'application/json' } + request = Net::HTTP::Post.new(url, headers) + + message.request.whats_app_external_file_ids.each do |file_id| + body = payload(file_id) + body[:image][:caption] = message.text if caption_it? + + request.body = body.to_json + response = Net::HTTP.start(url.host, url.port, use_ssl: true) do |http| + http.request(request) + end + handle_response(response) + end + end + + def send_text_separately + WhatsAppAdapter::ThreeSixtyDialogOutbound::Text.perform_later( + organization_id: message.organization_id, + payload: text_payload, + message_id: message.id + ) + end - def payload + def payload(file_id) { messaging_product: 'whatsapp', recipient_type: 'individual', @@ -40,16 +62,35 @@ def payload } end + def text_payload + { + messaging_product: 'whatsapp', + recipient_type: 'individual', + to: recipient.whats_app_phone_number.split('+').last, + type: 'text', + text: { + body: message.text + } + } + end + def handle_response(response) case response when Net::HTTPSuccess - external_id = JSON.parse(response.body)['messages'].first['id'] - message.update!(external_id: external_id) - when Net::HTTPClientError, Net::HTTPServerError + if caption_it? + external_id = JSON.parse(response.body)['messages'].first['id'] + message.update!(external_id: external_id) + end + when Net::HTTPClientError exception = WhatsAppAdapter::ThreeSixtyDialogError.new(error_code: response.code, message: response.body) - ErrorNotifier.report(exception) + context = { message_id: message.id } + ErrorNotifier.report(exception, context: context) end end + + def caption_it? + message.request.whats_app_external_file_ids.length.eql?(1) && message.text.length < 1024 + end end end end diff --git a/app/adapters/whats_app_adapter/three_sixty_dialog_outbound/text.rb b/app/adapters/whats_app_adapter/three_sixty_dialog_outbound/text.rb index 58e6f641e..47efa919a 100644 --- a/app/adapters/whats_app_adapter/three_sixty_dialog_outbound/text.rb +++ b/app/adapters/whats_app_adapter/three_sixty_dialog_outbound/text.rb @@ -5,24 +5,31 @@ class ThreeSixtyDialogOutbound class Text < ApplicationJob queue_as :default + retry_on Net::HTTPServerError, wait: ->(executions) { executions * 3 } do |job, exception| + if job.executions == 5 + exception = WhatsAppAdapter::ThreeSixtyDialogError.new(error_code: exception.code, message: exception.message) + context = { message_id: job.arguments.first[:message_id], recipient: job.payload[:to] } + ErrorNotifier.report(exception, context: context) + end + end + def perform(organization_id:, payload:, message_id: nil) - organization = Organization.find(organization_id) @message = Message.find(message_id) if message_id + @payload = payload + organization = Organization.find(organization_id) url = URI.parse("#{ENV.fetch('THREE_SIXTY_DIALOG_WHATS_APP_REST_API_ENDPOINT', 'https://stoplight.io/mocks/360dialog/360dialog-partner-api/24588693')}/messages") headers = { 'D360-API-KEY' => organization.three_sixty_dialog_client_api_key, 'Content-Type' => 'application/json' } - request = Net::HTTP::Post.new(url.to_s, headers) + request = Net::HTTP::Post.new(url, 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) - rescue ActiveRecord::RecordNotFound => e - ErrorNotifier.report(e) end - attr_reader :message + attr_reader :message, :payload private @@ -31,9 +38,10 @@ def handle_response(response) when Net::HTTPSuccess external_id = JSON.parse(response.body)['messages'].first['id'] message&.update!(external_id: external_id) - when Net::HTTPClientError, Net::HTTPServerError + when Net::HTTPClientError exception = WhatsAppAdapter::ThreeSixtyDialogError.new(error_code: response.code, message: response.body) - ErrorNotifier.report(exception) + context = { message_id: message&.id, recipient: payload[:to] } + ErrorNotifier.report(exception, context: context) end end end diff --git a/app/controllers/whats_app/three_sixty_dialog_webhook_controller.rb b/app/controllers/whats_app/three_sixty_dialog_webhook_controller.rb index 1452d4b2d..1689a03f1 100644 --- a/app/controllers/whats_app/three_sixty_dialog_webhook_controller.rb +++ b/app/controllers/whats_app/three_sixty_dialog_webhook_controller.rb @@ -7,9 +7,8 @@ class ThreeSixtyDialogWebhookController < ApplicationController def message head :ok - return if @components[:statuses].present? # TODO: Handle statuses - - handle_error(@components[:errors].first) and return if @components[:errors].present? + handle_statuses and return if @components[:statuses].present? # TODO: Handle statuses + handle_errors(@components[:errors]) and return if @components[:errors].present? WhatsAppAdapter::ThreeSixtyDialog::ProcessWebhookJob.perform_later(organization_id: @organization.id, components: @components) end @@ -25,8 +24,8 @@ def message_params entry: [:id, { changes: [:field, { value: [:messaging_product, - { metadata: %i[display_phone_number phone_number_id] }, - { contacts: [:wa_id, { profile: [:name] }], + { metadata: %i[display_phone_number phone_number_id], + contacts: [:wa_id, { profile: [:name] }], messages: [:from, :id, :type, :timestamp, { text: [:body] }, { button: %i[payload text] }, { image: %i[id mime_type sha256 caption] }, { voice: %i[id mime_type sha256] }, @@ -39,15 +38,26 @@ def message_params { context: %i[from id] }], statuses: [:id, :status, :timestamp, :expiration_timestamp, :recipient_id, { conversation: [:id, { origin: [:type] }] }, - { pricing: %i[billable pricing_model category] }], + { pricing: %i[billable pricing_model category] }, + { errors: [:code, :title, :message, :href, { error_data: [:details] }] }], errors: [:code, :title, :message, :href, { error_data: [:details] }] }] + }] }]) end - def handle_error(error) - exception = WhatsAppAdapter::ThreeSixtyDialogError.new(error_code: error[:code], message: error[:title]) - ErrorNotifier.report(exception, context: { details: error[:error_data][:details] }) + def handle_statuses + statuses = @components[:statuses] + statuses.each do |status| + handle_errors(status[:errors]) if status[:errors] + end + end + + def handle_errors(errors) + errors.each do |error| + exception = WhatsAppAdapter::ThreeSixtyDialogError.new(error_code: error[:code], message: error[:title]) + ErrorNotifier.report(exception, context: { details: error[:error_data][:details] }) + end end end end diff --git a/app/jobs/broadcast_request_job.rb b/app/jobs/broadcast_request_job.rb index 3575981b7..9bdc30913 100644 --- a/app/jobs/broadcast_request_job.rb +++ b/app/jobs/broadcast_request_job.rb @@ -3,13 +3,28 @@ class BroadcastRequestJob < ApplicationJob queue_as :broadcast_request + attr_reader :request, :recipients + def perform(request_id) - request = Request.where(id: request_id).first - return unless request + @request = Request.find(request_id) return if request.broadcasted_at.present? return if request.planned? # rescheduled for future - request.organization.contributors.active.with_tags(request.tag_list).each do |contributor| + all_recipients = request.organization.contributors.active.with_tags(request.tag_list) + whats_app_recipients = all_recipients.with_whats_app + @recipients = all_recipients - whats_app_recipients + + create_and_send_messages + + WhatsAppAdapter::BroadcastMessagesJob.perform_later(request_id: request.id) + + request.update(broadcasted_at: Time.current) + end + + private + + def create_and_send_messages + recipients.each do |contributor| message = Message.new( sender: request.user, recipient: contributor, @@ -17,11 +32,11 @@ def perform(request_id) request: request, broadcasted: true ) - message.files = Request.attach_files(request.files) if request.files.attached? + + message.files = Message::File.attach_files(request.files) if request.files.attached? message.save! message.send! end - request.update(broadcasted_at: Time.current) end end diff --git a/app/jobs/whats_app_adapter/broadcast_messages_job.rb b/app/jobs/whats_app_adapter/broadcast_messages_job.rb new file mode 100644 index 000000000..e0f0cd862 --- /dev/null +++ b/app/jobs/whats_app_adapter/broadcast_messages_job.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module WhatsAppAdapter + class BroadcastMessagesJob < ApplicationJob + queue_as :broadcast_whats_app_messages + + attr_reader :request, :recipients + + def perform(request_id: request.id) + @request = Request.find(request_id) + @recipients = request.organization.contributors.active.with_tags(request.tag_list).with_whats_app + + upload_files_to_meta if request.files.attached? + create_and_send_messages + end + + private + + def upload_files_to_meta + WhatsAppAdapter::ThreeSixtyDialog::UploadFileService.call(request_id: request.id) + end + + def create_and_send_messages + recipients.each do |contributor| + message = Message.new( + sender: request.user, + recipient: contributor, + text: request.personalized_text(contributor), + request: request, + broadcasted: true + ) + + message.files = Message::File.attach_files(request.files) if request.files.attached? + + message.save! + message.send! + end + end + end +end diff --git a/app/models/message/file.rb b/app/models/message/file.rb index eb3375a1a..acc112f6a 100644 --- a/app/models/message/file.rb +++ b/app/models/message/file.rb @@ -27,4 +27,12 @@ def thumbnail def image_attachment? attachment.blob.content_type.match?(/image/) end + + def self.attach_files(files) + files.map do |file| + message_file = Message::File.new + message_file.attachment.attach(file.blob) + message_file + end + end end diff --git a/app/jobs/whats_app_adapter/three_sixty_dialog/upload_file_job.rb b/app/services/whats_app_adapter/three_sixty_dialog/upload_file_service.rb similarity index 58% rename from app/jobs/whats_app_adapter/three_sixty_dialog/upload_file_job.rb rename to app/services/whats_app_adapter/three_sixty_dialog/upload_file_service.rb index 3287f6a5c..fe2bc5987 100644 --- a/app/jobs/whats_app_adapter/three_sixty_dialog/upload_file_job.rb +++ b/app/services/whats_app_adapter/three_sixty_dialog/upload_file_service.rb @@ -4,21 +4,19 @@ module WhatsAppAdapter module ThreeSixtyDialog - class UploadFileJob < ApplicationJob - def perform(message_id:) - @message_id = message_id - message = Message.find_by(id: message_id) - - request = message.request - organization = request.organization + class UploadFileService < ApplicationService + def initialize(request_id:) + @broadcasted_request = Request.find(request_id) + @base_uri = ENV.fetch('THREE_SIXTY_DIALOG_WHATS_APP_REST_API_ENDPOINT', 'https://stoplight.io/mocks/360dialog/360dialog-partner-api/24588693') + end - base_uri = ENV.fetch('THREE_SIXTY_DIALOG_WHATS_APP_REST_API_ENDPOINT', 'https://stoplight.io/mocks/360dialog/360dialog-partner-api/24588693') + def call url = URI.parse("#{base_uri}/media") headers = { - 'D360-API-KEY' => organization.three_sixty_dialog_client_api_key + 'D360-API-KEY' => broadcasted_request.organization.three_sixty_dialog_client_api_key } - request.files.each do |file| + broadcasted_request.files.each do |file| params = { 'messaging_product' => 'whatsapp', 'file' => UploadIO.new(ActiveStorage::Blob.service.path_for(file.blob.key), file.blob.content_type) @@ -27,20 +25,20 @@ def perform(message_id:) response = Net::HTTP.start(url.host, url.port, use_ssl: true) do |http| http.request(request) end - handle_response(response) end + broadcasted_request.save! end private - attr_reader :message_id + attr_reader :broadcasted_request, :base_uri def handle_response(response) case response when Net::HTTPSuccess - file_id = JSON.parse(response.body)['id'] - WhatsAppAdapter::ThreeSixtyDialogOutbound::File.perform_later(message_id: message_id, file_id: file_id) + external_file_id = JSON.parse(response.body)['id'] + broadcasted_request.whats_app_external_file_ids << external_file_id when Net::HTTPClientError, Net::HTTPServerError exception = WhatsAppAdapter::ThreeSixtyDialogError.new(error_code: response.code, message: response.body) ErrorNotifier.report(exception) diff --git a/db/migrate/20241108101857_add_external_file_ids_to_requests.rb b/db/migrate/20241108101857_add_external_file_ids_to_requests.rb new file mode 100644 index 000000000..23799a0cb --- /dev/null +++ b/db/migrate/20241108101857_add_external_file_ids_to_requests.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class AddExternalFileIdsToRequests < ActiveRecord::Migration[6.1] + def change + add_column :requests, :external_file_ids, :string, array: true, default: [] + add_index :requests, :external_file_ids, using: 'gin' + end +end diff --git a/db/migrate/20241119084424_change_external_file_ids_to_whats_app_external_file_ids_on_requests.rb b/db/migrate/20241119084424_change_external_file_ids_to_whats_app_external_file_ids_on_requests.rb new file mode 100644 index 000000000..d89c46ef0 --- /dev/null +++ b/db/migrate/20241119084424_change_external_file_ids_to_whats_app_external_file_ids_on_requests.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class ChangeExternalFileIdsToWhatsAppExternalFileIdsOnRequests < ActiveRecord::Migration[6.1] + def change + rename_column :requests, :external_file_ids, :whats_app_external_file_ids + end +end diff --git a/db/schema.rb b/db/schema.rb index 386eb99dc..41a6f75f7 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: 2024_11_13_120655) do +ActiveRecord::Schema.define(version: 2024_11_19_084424) do # These are extensions that must be enabled in order to support this database enable_extension "pg_trgm" @@ -267,8 +267,10 @@ t.datetime "schedule_send_for" t.datetime "broadcasted_at" t.bigint "organization_id" + t.string "whats_app_external_file_ids", default: [], array: true t.index ["organization_id"], name: "index_requests_on_organization_id" t.index ["user_id"], name: "index_requests_on_user_id" + t.index ["whats_app_external_file_ids"], name: "index_requests_on_whats_app_external_file_ids", using: :gin end create_table "taggings", id: :serial, force: :cascade do |t| diff --git a/spec/adapters/whats_app_adapter/three_sixty_dialog_outbound/file_spec.rb b/spec/adapters/whats_app_adapter/three_sixty_dialog_outbound/file_spec.rb new file mode 100644 index 000000000..8a02fcc23 --- /dev/null +++ b/spec/adapters/whats_app_adapter/three_sixty_dialog_outbound/file_spec.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe WhatsAppAdapter::ThreeSixtyDialogOutbound::File do + subject { -> { described_class.new.perform(message_id: message.id) } } + + describe '#perform_later(message_id:)' do + let(:organization) { create(:organization, three_sixty_dialog_client_api_key: 'valid_client_api_key') } + let(:message) do + create(:message, request: create(:request, whats_app_external_file_ids: ['883247393974022']), organization: organization, + recipient: create(:contributor, whats_app_phone_number: '+4915123456789', email: nil)) + end + let(:text_payload) do + { + messaging_product: 'whatsapp', + recipient_type: 'individual', + to: message.recipient.whats_app_phone_number.split('+').last, + type: 'text', + text: { + body: message.text + } + } + end + + before do + allow(ENV).to receive(:fetch).with('THREE_SIXTY_DIALOG_WHATS_APP_REST_API_ENDPOINT', + 'https://stoplight.io/mocks/360dialog/360dialog-partner-api/24588693').and_return('https://waba-v2.360dialog.io') + allow(ENV).to receive(:fetch).with('ATTR_ENCRYPTED_KEY', + nil).and_return(Base64.encode64(OpenSSL::Cipher.new('aes-256-gcm').random_key)) + end + + it "updates the message's external_id", vcr: { cassette_name: 'three_sixty_dialog_send_file' } do + expect { subject.call }.to (change do + message.reload.external_id + end).from(nil).to('wamid.HBgNNDkxNTE0MzQxNjI2NRUCABEYEjJGRDRDQzJDOUYxMjVEQzExRQA=') + end + + context 'with one file and text longer than 1023', vcr: { cassette_name: 'three_sixty_dialog_send_file_long_text' } do + before { message.update!(text: Faker::Lorem.characters(number: 1024)) } + + it 'schedules a text message to be sent separately' do + expect { subject.call }.to have_enqueued_job(WhatsAppAdapter::ThreeSixtyDialogOutbound::Text).with( + organization_id: organization.id, + payload: text_payload, + message_id: message.id + ) + end + end + + context 'with multiple files' do + before do + message.request.whats_app_external_file_ids << '371901912601458' + message.request.save! + end + + it 'schedules a text message to be sent separately', vcr: { cassette_name: 'three_sixty_dialog_send_files' } do + expect { subject.call }.to have_enqueued_job(WhatsAppAdapter::ThreeSixtyDialogOutbound::Text).with( + organization_id: organization.id, + payload: text_payload, + message_id: message.id + ) + end + end + end +end diff --git a/spec/adapters/whats_app_adapter/three_sixty_dialog_outbound_spec.rb b/spec/adapters/whats_app_adapter/three_sixty_dialog_outbound_spec.rb index 4d5aa17cc..87c20572b 100644 --- a/spec/adapters/whats_app_adapter/three_sixty_dialog_outbound_spec.rb +++ b/spec/adapters/whats_app_adapter/three_sixty_dialog_outbound_spec.rb @@ -123,12 +123,14 @@ end context 'contributor has sent a reply within 24 hours' do - before { create(:message, sender: contributor) } + before do + create(:message, sender: contributor) + end it 'enqueues a File job with file, contributor, text' do expect do subject.call - end.to(have_enqueued_job(WhatsAppAdapter::ThreeSixtyDialog::UploadFileJob).on_queue('default').with do |params| + end.to(have_enqueued_job(WhatsAppAdapter::ThreeSixtyDialogOutbound::File).on_queue('default').with do |params| expect(params[:message_id]).to eq(message.id) end) end diff --git a/spec/factories/users.rb b/spec/factories/users.rb index 10319007c..d88655f8a 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -2,15 +2,9 @@ FactoryBot.define do factory :user do - sequence :email do |n| - "user#{n}@example.org" - end - sequence :first_name do |n| - "FirstName#{n}" - end - sequence :last_name do |n| - "LastName#{n}" - end + email { Faker::Internet.email } + first_name { Faker::Name.first_name } + last_name { Faker::Name.last_name } password { Faker::Internet.password(min_length: 20, max_length: 128) } otp_enabled { true } end diff --git a/spec/jobs/broadcast_request_job_spec.rb b/spec/jobs/broadcast_request_job_spec.rb index 8ae543cab..a5b8512cb 100644 --- a/spec/jobs/broadcast_request_job_spec.rb +++ b/spec/jobs/broadcast_request_job_spec.rb @@ -7,18 +7,8 @@ subject { -> { described_class.new.perform(request.id) } } let!(:contributor) { create(:contributor) } - let(:request) { create(:request, broadcasted_at: nil) } - - context 'given the request has been deleted' do - before { request.destroy } - - it 'does not raise an error' do - expect { subject.call }.not_to raise_error - end - - it 'does not create a Message instance' do - expect { subject.call }.not_to change(Message, :count) - end + let(:request) do + create(:request, broadcasted_at: nil, organization: create(:organization, three_sixty_dialog_client_api_key: 'valid_client_api_key')) end context 'given a request has been broadcast' do @@ -46,7 +36,7 @@ end context 'given a request that is to be sent out now' do - describe 'given contributors from multipile organizations' do + describe 'given contributors from multiple organizations' do before(:each) do create(:contributor, id: 1, email: 'somebody@example.org', organization: request.organization) create(:contributor, id: 2, email: nil, telegram_id: 22, organization: request.organization) @@ -136,6 +126,14 @@ .and (change { Message.pluck(:recipient_id) }).from([]).to([8]) end end + + describe 'given a WhatsApp contributor' do + before { create(:contributor, :whats_app_contributor, organization: request.organization) } + + it 'schedules a job to send out the message' do + expect { subject.call }.to have_enqueued_job(WhatsAppAdapter::BroadcastMessagesJob).with(request_id: request.id) + end + end end end end diff --git a/spec/jobs/whats_app_adapter/broadcast_messages_job_spec.rb b/spec/jobs/whats_app_adapter/broadcast_messages_job_spec.rb new file mode 100644 index 000000000..af1b37b71 --- /dev/null +++ b/spec/jobs/whats_app_adapter/broadcast_messages_job_spec.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe WhatsAppAdapter::BroadcastMessagesJob do + describe '#perform_later(request_id:)' do + subject { -> { described_class.new.perform(request_id: request.id) } } + + let(:request) do + create(:request, broadcasted_at: nil, organization: create(:organization, three_sixty_dialog_client_api_key: 'valid_client_api_key')) + end + let(:text_payload) do + { + messaging_product: 'whatsapp', + recipient_type: 'individual', + type: 'text', + text: { + body: message.text + } + } + end + + describe 'given contributors from multiple organizations' do + before do + create(:contributor, :whats_app_contributor, id: 1, organization: request.organization) + create(:contributor, :whats_app_contributor, id: 2, organization: request.organization) + create(:contributor, :whats_app_contributor, id: 3) + end + + it "schedules jobs to send out message to an organization's contributors" do + subject.call + expect(WhatsAppAdapter::ThreeSixtyDialogOutbound::Text).to have_been_enqueued.exactly(2).times + request.organization.contributors.each do |contributor| + expect(WhatsAppAdapter::ThreeSixtyDialogOutbound::Text).to have_been_enqueued.with( + organization_id: request.organization, + payload: text_payload.merge({ to: contributor.whats_app_phone_number.split('+').last }) + ) + end + end + + it 'only creates a message for contributors of the organization' do + expect { subject.call }.to change(Message, :count).from(0).to(2) + .and (change { Message.pluck(:recipient_id).sort }).from([]).to([1, 2]) + end + + it 'assigns the user of the request as the sender of the message' do + expect { subject.call }.to (change { Message.pluck(:sender_id) }).from([]).to([request.user.id, request.user.id]) + end + + describe 'given a request with files attached', vcr: { cassette_name: :three_sixty_dialog_upload_file_service } do + before do + request.update!(files: [fixture_file_upload('profile_picture.jpg')]) + allow(ENV).to receive(:fetch).with('THREE_SIXTY_DIALOG_WHATS_APP_REST_API_ENDPOINT', + 'https://stoplight.io/mocks/360dialog/360dialog-partner-api/24588693').and_return('https://waba-v2.360dialog.io') + end + + it 'attaches the files to the messages' do + expect { subject.call }.to (change { Message::File.count }).from(0).to(2) + Message.find_each do |message| + message.files.each do |file| + expect(file.attachment).to be_attached + end + end + end + + it "updates the request's whats_app_external_file_ids" do + expect { subject.call }.to (change { request.reload.whats_app_external_file_ids }).from([]).to(['545466424653131']) + end + end + + describe 'given a request with a tag_list' do + before do + request.update!(tag_list: ['programmer']) + request.organization.contributors.find(1).update!(tag_list: ['programmer']) + end + + it 'only sends to contributors tagged with the tag' do + expect { subject.call }.to change(Message, :count).from(0).to(1) + .and (change { Message.pluck(:recipient_id) }).from([]).to([1]) + end + end + + describe 'given non-active contributors' do + before do + request.organization.contributors.find(1).update!(deactivated_at: 1.hour.ago) + create(:contributor, :whats_app_contributor, unsubscribed_at: 1.minute.ago) + end + + it 'only sends to active contributors' do + expect { subject.call }.to change(Message, :count).from(0).to(1) + .and (change { Message.pluck(:recipient_id) }).from([]).to([2]) + end + end + + describe 'given contributors of other messengers' do + before do + create(:contributor, :threema_contributor, :skip_validations, organization: request.organization) + create(:contributor, :telegram_contributor, organization: request.organization) + create(:contributor, :signal_contributor, organization: request.organization) + create(:contributor, organization: request.organization) + end + + it 'only creates a message for contributors of the organization' do + expect { subject.call }.to change(Message, :count).from(0).to(2) + .and (change { Message.pluck(:recipient_id).sort }).from([]).to([1, 2]) + end + end + end + end +end diff --git a/spec/jobs/whats_app_adapter/three_sixty_dialog/process_webhook_job_spec.rb b/spec/jobs/whats_app_adapter/three_sixty_dialog/process_webhook_job_spec.rb index 085979007..d4ec33340 100644 --- a/spec/jobs/whats_app_adapter/three_sixty_dialog/process_webhook_job_spec.rb +++ b/spec/jobs/whats_app_adapter/three_sixty_dialog/process_webhook_job_spec.rb @@ -78,6 +78,17 @@ end describe 'with no external id' do + let(:base_uri) { 'https://waba-v2.360dialog.io' } + let(:external_file_id) { '545466424653131' } + + before do + latest_message.request.update!(whats_app_external_file_ids: [external_file_id]) + allow(ENV).to receive(:fetch).with( + 'THREE_SIXTY_DIALOG_WHATS_APP_REST_API_ENDPOINT', 'https://stoplight.io/mocks/360dialog/360dialog-partner-api/24588693' + ).and_return(base_uri) + stub_request(:post, "#{base_uri}/messages").to_return(status: 200, body: { messages: [id: 'some_external_id'] }.to_json) + end + it 'enqueues a job to send the latest received message' do expect do subject.call @@ -85,6 +96,63 @@ text_payload.merge({ message_id: latest_message.id }) ) end + + it 'updates the message with the external id' do + perform_enqueued_jobs(only: WhatsAppAdapter::ThreeSixtyDialogOutbound::Text) do + expect { subject.call }.to (change { latest_message.reload.external_id }).from(nil).to('some_external_id') + end + end + + context 'message with file, no text' do + let(:message_file) do + [create(:file, message: latest_message, + attachment: Rack::Test::UploadedFile.new(Rails.root.join('spec/fixtures/files/matt.jpeg'), 'image/jpeg'))] + end + + before do + latest_message.update!(text: '', files: message_file) + end + + it 'enqueues a job to send the file' do + expect { subject.call }.to have_enqueued_job( + WhatsAppAdapter::ThreeSixtyDialogOutbound::File + ).with({ message_id: latest_message.id }) + end + + it 'updates the message with the external id' do + perform_enqueued_jobs(only: WhatsAppAdapter::ThreeSixtyDialogOutbound::File) do + expect { subject.call }.to (change { latest_message.reload.external_id }).from(nil).to('some_external_id') + end + end + + context 'message with file and text' do + before do + latest_message.update!(text: 'Some text') + end + + it 'enqueues a job to upload the file' do + expect do + subject.call + end.to have_enqueued_job(WhatsAppAdapter::ThreeSixtyDialogOutbound::File).on_queue('default').with( + message_id: latest_message.id + ) + end + + context 'given the text is greater than 1024' do + before { latest_message.update!(text: Faker::Lorem.characters(number: 1025)) } + + it 'enqueues a job to send out the text' do + perform_enqueued_jobs(only: WhatsAppAdapter::ThreeSixtyDialogOutbound::File) do + expect do + subject.call + end.to have_enqueued_job(WhatsAppAdapter::ThreeSixtyDialogOutbound::Text).on_queue('default').with( + text_payload.merge({ message_id: latest_message.id }) + ) + end + end + end + end + end end describe 'with an external id' do diff --git a/spec/services/whats_app_adapter/three_sixty_dialog/file_fetcher_service_spec.rb b/spec/services/whats_app_adapter/three_sixty_dialog/file_fetcher_service_spec.rb index abe0ef8bb..0d2ceb61c 100644 --- a/spec/services/whats_app_adapter/three_sixty_dialog/file_fetcher_service_spec.rb +++ b/spec/services/whats_app_adapter/three_sixty_dialog/file_fetcher_service_spec.rb @@ -3,7 +3,7 @@ require 'rails_helper' require 'webmock/rspec' -RSpec.describe WhatsAppAdapter::ThreeSixtyDialog::FileFetcherService, type: :model do +RSpec.describe WhatsAppAdapter::ThreeSixtyDialog::FileFetcherService do describe '#call' do subject { -> { described_class.new(organization_id: organization.id, file_id: file_id).call } } diff --git a/spec/jobs/whats_app_adapter/three_sixty_dialog/upload_file_job_spec.rb b/spec/services/whats_app_adapter/three_sixty_dialog/upload_file_service_spec.rb similarity index 51% rename from spec/jobs/whats_app_adapter/three_sixty_dialog/upload_file_job_spec.rb rename to spec/services/whats_app_adapter/three_sixty_dialog/upload_file_service_spec.rb index c2894c0c4..143ffddfc 100644 --- a/spec/jobs/whats_app_adapter/three_sixty_dialog/upload_file_job_spec.rb +++ b/spec/services/whats_app_adapter/three_sixty_dialog/upload_file_service_spec.rb @@ -2,9 +2,9 @@ require 'rails_helper' -RSpec.describe WhatsAppAdapter::ThreeSixtyDialog::UploadFileJob do - describe '#perform_later(message_id:)' do - subject { -> { described_class.new.perform(message_id: message.id) } } +RSpec.describe WhatsAppAdapter::ThreeSixtyDialog::UploadFileService do + describe '#call(request_id:)' do + subject { -> { described_class.call(request_id: message.request.id) } } let(:organization) { create(:organization, three_sixty_dialog_client_api_key: 'valid_api_key') } let(:message) { create(:message, request: create(:request, :with_file, organization: organization)) } @@ -16,13 +16,8 @@ ).and_return('https://waba-v2.360dialog.io') end - it 'schedules a job to send out the message with the file', vcr: { cassette_name: :three_sixty_dialog_upload_file_job } do - expect { subject.call }.to have_enqueued_job( - WhatsAppAdapter::ThreeSixtyDialogOutbound::File - ).with({ - message_id: message.id, - file_id: external_file_id - }) + it "updates the request's whats_app_external_file_ids", vcr: { cassette_name: :three_sixty_dialog_upload_file_service } do + expect { subject.call }.to (change { message.reload.request.whats_app_external_file_ids }).from([]).to([external_file_id]) end end end diff --git a/vcr_cassettes/three_sixty_dialog_send_file.yml b/vcr_cassettes/three_sixty_dialog_send_file.yml new file mode 100644 index 000000000..e323a2079 --- /dev/null +++ b/vcr_cassettes/three_sixty_dialog_send_file.yml @@ -0,0 +1,69 @@ +--- +http_interactions: +- request: + method: post + uri: https://waba-v2.360dialog.io/messages + body: + encoding: UTF-8 + string: '{"messaging_product":"whatsapp","recipient_type":"individual","to":"4915123456789","type":"image","image":{"id":"883247393974022","caption":"Sit + quia laudantium voluptatem."}}' + headers: + D360-Api-Key: + - D360-API-KEY + Content-Type: + - application/json + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + response: + status: + code: 200 + message: OK + headers: + Date: + - Mon, 18 Nov 2024 09:51:58 GMT + Content-Type: + - application/json; charset=UTF-8 + Content-Length: + - '180' + Access-Control-Allow-Origin: + - "*" + Alt-Svc: + - h3=":443"; ma=86400 + Cache-Control: + - private, no-cache, no-store, must-revalidate + Expires: + - Sat, 01 Jan 2000 00:00:00 GMT + Facebook-Api-Version: + - v19.0 + Pragma: + - no-cache + Strict-Transport-Security: + - max-age=15724800; includeSubDomains + Vary: + - Origin,Accept-Encoding + X-Business-Use-Case-Usage: + - '{"118406021245988":[{"type":"whatsapp","call_count":0,"total_cputime":0,"total_time":0,"estimated_time_to_regain_access":0}]}' + X-Fb-Connection-Quality: + - EXCELLENT; q=0.9, rtt=6, rtx=0, c=10, mss=1380, tbw=3388, tp=-1, tpl=-1, uplat=1117, + ullat=0 + X-Fb-Debug: + - j9awPyydfUOE4R2Bu2MKuYqCBic84wtP1u6/lHzpLSMd7QhEIB3drSYzssFnVT7d1mFSALEUQ9iKxuTRmDxsPA== + X-Fb-Request-Id: + - AY32k_JL4iVHd4Zg04EFnUB + X-Fb-Rev: + - '1018276187' + X-Fb-Trace-Id: + - Ek3GUKSSQ6R + X-Envoy-Upstream-Service-Time: + - '1177' + Server: + - istio-envoy + body: + encoding: UTF-8 + string: '{"messaging_product":"whatsapp","contacts":[{"input":"4915123456789","wa_id":"4915123456789"}],"messages":[{"id":"wamid.HBgNNDkxNTE0MzQxNjI2NRUCABEYEjJGRDRDQzJDOUYxMjVEQzExRQA="}]}' + recorded_at: Mon, 18 Nov 2024 09:51:58 GMT +recorded_with: VCR 6.1.0 diff --git a/vcr_cassettes/three_sixty_dialog_send_file_long_text.yml b/vcr_cassettes/three_sixty_dialog_send_file_long_text.yml new file mode 100644 index 000000000..33ed18b54 --- /dev/null +++ b/vcr_cassettes/three_sixty_dialog_send_file_long_text.yml @@ -0,0 +1,68 @@ +--- +http_interactions: +- request: + method: post + uri: https://waba-v2.360dialog.io/messages + body: + encoding: UTF-8 + string: '{"messaging_product":"whatsapp","recipient_type":"individual","to":"4915123456789","type":"image","image":{"id":"883247393974022"}}' + headers: + D360-Api-Key: + - D360-API-KEY + Content-Type: + - application/json + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + response: + status: + code: 200 + message: OK + headers: + Date: + - Mon, 18 Nov 2024 10:12:40 GMT + Content-Type: + - application/json; charset=UTF-8 + Content-Length: + - '180' + Access-Control-Allow-Origin: + - "*" + Alt-Svc: + - h3=":443"; ma=86400 + Cache-Control: + - private, no-cache, no-store, must-revalidate + Expires: + - Sat, 01 Jan 2000 00:00:00 GMT + Facebook-Api-Version: + - v19.0 + Pragma: + - no-cache + Strict-Transport-Security: + - max-age=15724800; includeSubDomains + Vary: + - Origin,Accept-Encoding + X-Business-Use-Case-Usage: + - '{"118406021245988":[{"type":"whatsapp","call_count":1,"total_cputime":1,"total_time":1,"estimated_time_to_regain_access":0}]}' + X-Fb-Connection-Quality: + - EXCELLENT; q=0.9, rtt=1, rtx=0, c=10, mss=1380, tbw=3387, tp=-1, tpl=-1, uplat=639, + ullat=0 + X-Fb-Debug: + - "/Bvw+7zJIG+tHtLPTJdM1wS4S+mAQ39u3AFI71R4jEyZwupU4YzAKKnPoNpeo0HynXFvU1wg+ZljCNQEm0kZyA==" + X-Fb-Request-Id: + - A2ViDW1v_Cem7BZwEPouvOE + X-Fb-Rev: + - '1018276187' + X-Fb-Trace-Id: + - BdjsfPbttAN + X-Envoy-Upstream-Service-Time: + - '670' + Server: + - istio-envoy + body: + encoding: UTF-8 + string: '{"messaging_product":"whatsapp","contacts":[{"input":"4915123456789","wa_id":"4915123456789"}],"messages":[{"id":"wamid.HBgNNDkxNTE0MzQxNjI2NRUCABEYEjIzQUFFNzI0QjZEMUYzQTQ0NAA="}]}' + recorded_at: Mon, 18 Nov 2024 10:12:40 GMT +recorded_with: VCR 6.1.0 diff --git a/vcr_cassettes/three_sixty_dialog_send_files.yml b/vcr_cassettes/three_sixty_dialog_send_files.yml new file mode 100644 index 000000000..66cd6d652 --- /dev/null +++ b/vcr_cassettes/three_sixty_dialog_send_files.yml @@ -0,0 +1,133 @@ +--- +http_interactions: +- request: + method: post + uri: https://waba-v2.360dialog.io/messages + body: + encoding: UTF-8 + string: '{"messaging_product":"whatsapp","recipient_type":"individual","to":"4915123456789","type":"image","image":{"id":"883247393974022"}}' + headers: + D360-Api-Key: + - D360-API-KEY + Content-Type: + - application/json + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + response: + status: + code: 200 + message: OK + headers: + Date: + - Mon, 18 Nov 2024 10:02:23 GMT + Content-Type: + - application/json; charset=UTF-8 + Content-Length: + - '180' + Access-Control-Allow-Origin: + - "*" + Alt-Svc: + - h3=":443"; ma=86400 + Cache-Control: + - private, no-cache, no-store, must-revalidate + Expires: + - Sat, 01 Jan 2000 00:00:00 GMT + Facebook-Api-Version: + - v19.0 + Pragma: + - no-cache + Strict-Transport-Security: + - max-age=15724800; includeSubDomains + Vary: + - Origin,Accept-Encoding + X-Business-Use-Case-Usage: + - '{"118406021245988":[{"type":"whatsapp","call_count":1,"total_cputime":1,"total_time":0,"estimated_time_to_regain_access":0}]}' + X-Fb-Connection-Quality: + - EXCELLENT; q=0.9, rtt=5, rtx=0, c=10, mss=1380, tbw=3364, tp=-1, tpl=-1, uplat=694, + ullat=0 + X-Fb-Debug: + - nFZfNqGefRwrfQ8oOQv5n/gMdsVtSdqHSqS4o/f3bRd4r8ztO7HMr9tM9AHzaIHTJ3IIrm5ejNxBOeQnI37iog== + X-Fb-Request-Id: + - A5EGrLikK6mkUjZu6yN1kT2 + X-Fb-Rev: + - '1018276187' + X-Fb-Trace-Id: + - ANEDLkY2PZn + X-Envoy-Upstream-Service-Time: + - '725' + Server: + - istio-envoy + body: + encoding: UTF-8 + string: '{"messaging_product":"whatsapp","contacts":[{"input":"4915123456789","wa_id":"4915123456789"}],"messages":[{"id":"wamid.HBgNNDkxNTE0MzQxNjI2NRUCABEYEjg2RTAxQzkxNDAyRDJGNjdGQgA="}]}' + recorded_at: Mon, 18 Nov 2024 10:02:23 GMT +- request: + method: post + uri: https://waba-v2.360dialog.io/messages + body: + encoding: UTF-8 + string: '{"messaging_product":"whatsapp","recipient_type":"individual","to":"4915123456789","type":"image","image":{"id":"371901912601458"}}' + headers: + D360-Api-Key: + - D360-API-KEY + Content-Type: + - application/json + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + response: + status: + code: 200 + message: OK + headers: + Date: + - Mon, 18 Nov 2024 10:02:24 GMT + Content-Type: + - application/json; charset=UTF-8 + Content-Length: + - '180' + Access-Control-Allow-Origin: + - "*" + Alt-Svc: + - h3=":443"; ma=86400 + Cache-Control: + - private, no-cache, no-store, must-revalidate + Expires: + - Sat, 01 Jan 2000 00:00:00 GMT + Facebook-Api-Version: + - v19.0 + Pragma: + - no-cache + Strict-Transport-Security: + - max-age=15724800; includeSubDomains + Vary: + - Origin,Accept-Encoding + X-Business-Use-Case-Usage: + - '{"118406021245988":[{"type":"whatsapp","call_count":1,"total_cputime":1,"total_time":1,"estimated_time_to_regain_access":0}]}' + X-Fb-Connection-Quality: + - EXCELLENT; q=0.9, rtt=4, rtx=0, c=10, mss=1380, tbw=4200, tp=-1, tpl=-1, uplat=681, + ullat=0 + X-Fb-Debug: + - DY5YiaftdXpsPY5zo56xzd004EBfzzk/W99hc5DIACZyREH5TBWOW2afd9CqD/9JoHNz/QpugnSaC+QP07elkg== + X-Fb-Request-Id: + - A4eRa5IMmuhGfR2WNNww9DK + X-Fb-Rev: + - '1018276187' + X-Fb-Trace-Id: + - CnlgEmlFXs1 + X-Envoy-Upstream-Service-Time: + - '689' + Server: + - istio-envoy + body: + encoding: UTF-8 + string: '{"messaging_product":"whatsapp","contacts":[{"input":"4915123456789","wa_id":"4915123456789"}],"messages":[{"id":"wamid.HBgNNDkxNTE0MzQxNjI2NRUCABEYEkM4NDZFRUFFNjlCRDEwMjY4NwA="}]}' + recorded_at: Mon, 18 Nov 2024 10:02:24 GMT +recorded_with: VCR 6.1.0 diff --git a/vcr_cassettes/three_sixty_dialog_upload_file_job.yml b/vcr_cassettes/three_sixty_dialog_upload_file_service.yml similarity index 100% rename from vcr_cassettes/three_sixty_dialog_upload_file_job.yml rename to vcr_cassettes/three_sixty_dialog_upload_file_service.yml