From 62ef2113d5645a278678b28081f54ae8f5f62dcd Mon Sep 17 00:00:00 2001 From: Gabriel Jablonski Date: Sat, 3 May 2025 00:39:39 -0300 Subject: [PATCH] feat: toggle typing status on channel provider (#39) * feat: toggle typing status on channel provider * refactor: general refactorings * fix: check if channel implements method * test: channel listener specs * test: channel spec * test: baileys service spec --- .../api/v1/widget/conversations_controller.rb | 2 + .../v1/inboxes/conversations_controller.rb | 2 + app/dispatchers/async_dispatcher.rb | 3 +- .../components/widgets/WootWriter/Editor.vue | 6 +- .../widgets/conversation/ReplyBox.vue | 7 ++ .../shared/components/ResizableTextArea.vue | 6 +- app/listeners/channel_listener.rb | 25 +++++++ app/models/channel/whatsapp.rb | 6 ++ .../conversations/typing_status_manager.rb | 2 + .../providers/whatsapp_baileys_service.rb | 20 +++++ lib/events/types.rb | 1 + spec/listeners/channel_listener_spec.rb | 66 +++++++++++++++++ spec/models/channel/whatsapp_spec.rb | 29 ++++++++ .../whatsapp_baileys_service_spec.rb | 74 +++++++++++++++++++ .../inboxes/conversations/toggle_typing.yml | 6 +- swagger/swagger.json | 7 +- 16 files changed, 257 insertions(+), 5 deletions(-) create mode 100644 app/listeners/channel_listener.rb create mode 100644 spec/listeners/channel_listener_spec.rb diff --git a/app/controllers/api/v1/widget/conversations_controller.rb b/app/controllers/api/v1/widget/conversations_controller.rb index 64b32db3c..62a7dafd9 100644 --- a/app/controllers/api/v1/widget/conversations_controller.rb +++ b/app/controllers/api/v1/widget/conversations_controller.rb @@ -48,6 +48,8 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController case permitted_params[:typing_status] when 'on' trigger_typing_event(CONVERSATION_TYPING_ON) + when 'recording' + trigger_typing_event(CONVERSATION_RECORDING) when 'off' trigger_typing_event(CONVERSATION_TYPING_OFF) end diff --git a/app/controllers/public/api/v1/inboxes/conversations_controller.rb b/app/controllers/public/api/v1/inboxes/conversations_controller.rb index 4e3b5dca9..b0b67c337 100644 --- a/app/controllers/public/api/v1/inboxes/conversations_controller.rb +++ b/app/controllers/public/api/v1/inboxes/conversations_controller.rb @@ -30,6 +30,8 @@ class Public::Api::V1::Inboxes::ConversationsController < Public::Api::V1::Inbox case params[:typing_status] when 'on' trigger_typing_event(CONVERSATION_TYPING_ON) + when 'recording' + trigger_typing_event(CONVERSATION_RECORDING) when 'off' trigger_typing_event(CONVERSATION_TYPING_OFF) end diff --git a/app/dispatchers/async_dispatcher.rb b/app/dispatchers/async_dispatcher.rb index 7416b7861..f46928d4e 100644 --- a/app/dispatchers/async_dispatcher.rb +++ b/app/dispatchers/async_dispatcher.rb @@ -18,7 +18,8 @@ class AsyncDispatcher < BaseDispatcher NotificationListener.instance, ParticipationListener.instance, ReportingEventListener.instance, - WebhookListener.instance + WebhookListener.instance, + ChannelListener.instance ] end end diff --git a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue index 628c88ad3..ef365d95a 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue +++ b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue @@ -576,7 +576,11 @@ function createEditorView() { handleDOMEvents: { keyup: () => { if (!props.disabled) { - typingIndicator.start(); + if (props.modelValue.length) { + typingIndicator.start(); + } else { + typingIndicator.stop(); + } updateImgToolbarOnDelete(); } }, diff --git a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue index 5323b3e2a..f5daddb89 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue @@ -863,6 +863,9 @@ export default { this.isRecorderAudioStopped = !this.isRecordingAudio; if (!this.isRecordingAudio) { this.resetAudioRecorderInput(); + this.onTypingOff(); + } else { + this.onRecording(); } }, toggleAudioRecorderPlayPause() { @@ -872,6 +875,7 @@ export default { if (!this.isRecorderAudioStopped) { this.isRecorderAudioStopped = true; this.$refs.audioRecorderInput.stopRecording(); + this.onTypingOff(); } else if (this.isRecorderAudioStopped) { this.$refs.audioRecorderInput.playPause(); } @@ -887,6 +891,9 @@ export default { onTypingOn() { this.toggleTyping('on'); }, + onRecording() { + this.toggleTyping('recording'); + }, onTypingOff() { this.toggleTyping('off'); }, diff --git a/app/javascript/shared/components/ResizableTextArea.vue b/app/javascript/shared/components/ResizableTextArea.vue index 088753a23..6c7d4321b 100644 --- a/app/javascript/shared/components/ResizableTextArea.vue +++ b/app/javascript/shared/components/ResizableTextArea.vue @@ -144,7 +144,11 @@ export default { this.resizeTextarea(); }, onKeyup() { - this.typingIndicator.start(); + if (this.modelValue.length) { + this.typingIndicator.start(); + } else { + this.typingIndicator.stop(); + } }, onBlur() { this.typingIndicator.stop(); diff --git a/app/listeners/channel_listener.rb b/app/listeners/channel_listener.rb new file mode 100644 index 000000000..0365454f2 --- /dev/null +++ b/app/listeners/channel_listener.rb @@ -0,0 +1,25 @@ +class ChannelListener < BaseListener + def conversation_typing_on(event) + handle_typing_event(event) + end + + def conversation_recording(event) + handle_typing_event(event) + end + + def conversation_typing_off(event) + handle_typing_event(event) + end + + private + + def handle_typing_event(event) + is_private, conversation = event.data.values_at(:is_private, :conversation) + return if is_private + + channel = conversation.inbox.channel + return unless channel.respond_to?(:toggle_typing_status) + + channel.toggle_typing_status(event.name, conversation: conversation) + end +end diff --git a/app/models/channel/whatsapp.rb b/app/models/channel/whatsapp.rb index 41d1a5960..5fc69c281 100644 --- a/app/models/channel/whatsapp.rb +++ b/app/models/channel/whatsapp.rb @@ -79,6 +79,12 @@ class Channel::Whatsapp < ApplicationRecord data end + def toggle_typing_status(typing_status, conversation:) + return unless provider_service.respond_to?(:toggle_typing_status) + + provider_service.toggle_typing_status(conversation.contact.phone_number, typing_status) + end + delegate :setup_channel_provider, to: :provider_service delegate :disconnect_channel_provider, to: :provider_service delegate :send_message, to: :provider_service diff --git a/app/services/conversations/typing_status_manager.rb b/app/services/conversations/typing_status_manager.rb index e3e9cebc6..2eb38e360 100644 --- a/app/services/conversations/typing_status_manager.rb +++ b/app/services/conversations/typing_status_manager.rb @@ -18,6 +18,8 @@ class Conversations::TypingStatusManager case params[:typing_status] when 'on' trigger_typing_event(CONVERSATION_TYPING_ON, params[:is_private]) + when 'recording' + trigger_typing_event(CONVERSATION_RECORDING, params[:is_private]) when 'off' trigger_typing_event(CONVERSATION_TYPING_OFF, params[:is_private]) end diff --git a/app/services/whatsapp/providers/whatsapp_baileys_service.rb b/app/services/whatsapp/providers/whatsapp_baileys_service.rb index dc0702a90..1189ec291 100644 --- a/app/services/whatsapp/providers/whatsapp_baileys_service.rb +++ b/app/services/whatsapp/providers/whatsapp_baileys_service.rb @@ -66,6 +66,26 @@ class Whatsapp::Providers::WhatsappBaileysService < Whatsapp::Providers::BaseSer process_response(response) end + def toggle_typing_status(phone_number, typing_status) + @phone_number = phone_number + status_map = { + Events::Types::CONVERSATION_TYPING_ON => 'composing', + Events::Types::CONVERSATION_RECORDING => 'recording', + Events::Types::CONVERSATION_TYPING_OFF => 'paused' + } + + response = HTTParty.patch( + "#{provider_url}/connections/#{whatsapp_channel.phone_number}/presence", + headers: api_headers, + body: { + toJid: remote_jid, + type: status_map[typing_status] + }.to_json + ) + + process_response(response) + end + private def provider_url diff --git a/lib/events/types.rb b/lib/events/types.rb index ff7eaf778..e1ebafd2a 100644 --- a/lib/events/types.rb +++ b/lib/events/types.rb @@ -27,6 +27,7 @@ module Events::Types ASSIGNEE_CHANGED = 'assignee.changed' TEAM_CHANGED = 'team.changed' CONVERSATION_TYPING_ON = 'conversation.typing_on' + CONVERSATION_RECORDING = 'conversation.recording' CONVERSATION_TYPING_OFF = 'conversation.typing_off' CONVERSATION_MENTIONED = 'conversation.mentioned' diff --git a/spec/listeners/channel_listener_spec.rb b/spec/listeners/channel_listener_spec.rb new file mode 100644 index 000000000..0df8d486e --- /dev/null +++ b/spec/listeners/channel_listener_spec.rb @@ -0,0 +1,66 @@ +require 'rails_helper' +describe ChannelListener do + let(:listener) { described_class.instance } + + context 'when handling typing events' do + let(:channel) { create(:channel_whatsapp, sync_templates: false, validate_provider_config: false) } + let(:conversation) { create(:conversation, inbox: create(:inbox, channel: channel)) } + + it 'skips the event if is_private is true' do + allow(channel).to receive(:toggle_typing_status) + + listener.conversation_typing_on(build_typing_event(Events::Types::CONVERSATION_TYPING_ON, conversation: conversation, is_private: true)) + + expect(channel).not_to have_received(:toggle_typing_status) + end + + it 'skips the event if channel does not respond to toggle_typing_status' do + channel = create(:channel_api) + conversation = create(:conversation, inbox: create(:inbox, channel: channel)) + + expect do + listener.conversation_typing_on(build_typing_event(Events::Types::CONVERSATION_TYPING_ON, conversation: conversation)) + end.not_to raise_error + end + + describe '#conversation_typing_on' do + let(:event_name) { Events::Types::CONVERSATION_TYPING_ON } + + it 'calls toggle_typing_status on the channel' do + allow(channel).to receive(:toggle_typing_status).with(event_name, conversation: conversation) + + listener.conversation_typing_on(build_typing_event(event_name, conversation: conversation)) + + expect(channel).to have_received(:toggle_typing_status) + end + end + + describe '#conversation_recording' do + let(:event_name) { Events::Types::CONVERSATION_RECORDING } + + it 'calls toggle_typing_status on the channel' do + allow(channel).to receive(:toggle_typing_status).with(event_name, conversation: conversation) + + listener.conversation_recording(build_typing_event(event_name, conversation: conversation)) + + expect(channel).to have_received(:toggle_typing_status) + end + end + + describe '#conversation_typing_off' do + let(:event_name) { Events::Types::CONVERSATION_TYPING_OFF } + + it 'calls toggle_typing_status on the channel' do + allow(channel).to receive(:toggle_typing_status).with(event_name, conversation: conversation) + + listener.conversation_typing_off(build_typing_event(event_name, conversation: conversation)) + + expect(channel).to have_received(:toggle_typing_status) + end + end + end + + def build_typing_event(event_name, conversation:, is_private: false) + Events::Base.new(event_name, Time.zone.now, conversation: conversation, user: create(:user), is_private: is_private) + end +end diff --git a/spec/models/channel/whatsapp_spec.rb b/spec/models/channel/whatsapp_spec.rb index a2767b018..c6608c3c9 100644 --- a/spec/models/channel/whatsapp_spec.rb +++ b/spec/models/channel/whatsapp_spec.rb @@ -62,6 +62,35 @@ RSpec.describe Channel::Whatsapp do end end + describe '#toggle_typing_status' do + let(:channel) { create(:channel_whatsapp, provider: 'baileys', validate_provider_config: false, sync_templates: false) } + let(:conversation) { create(:conversation) } + + it 'calls provider service method' do + provider_double = instance_double(Whatsapp::Providers::WhatsappBaileysService, toggle_typing_status: nil) + allow(Whatsapp::Providers::WhatsappBaileysService).to receive(:new) + .with(whatsapp_channel: channel) + .and_return(provider_double) + + channel.toggle_typing_status(Events::Types::CONVERSATION_TYPING_ON, conversation: conversation) + + expect(provider_double).to have_received(:toggle_typing_status) + .with(conversation.contact.phone_number, Events::Types::CONVERSATION_TYPING_ON) + end + + it 'does not call method if provider service does not implement it' do + channel = create(:channel_whatsapp, provider: 'whatsapp_cloud', validate_provider_config: false, sync_templates: false) + provider_double = instance_double(Whatsapp::Providers::WhatsappCloudService) + allow(Whatsapp::Providers::WhatsappCloudService).to receive(:new) + .with(whatsapp_channel: channel) + .and_return(provider_double) + + expect do + channel.toggle_typing_status(Events::Types::CONVERSATION_TYPING_ON, conversation: conversation) + end.not_to raise_error + end + end + describe 'callbacks' do describe '#disconnect_channel_provider' do context 'when provider is baileys' do diff --git a/spec/services/whatsapp/providers/whatsapp_baileys_service_spec.rb b/spec/services/whatsapp/providers/whatsapp_baileys_service_spec.rb index 365998b61..de92e3a09 100644 --- a/spec/services/whatsapp/providers/whatsapp_baileys_service_spec.rb +++ b/spec/services/whatsapp/providers/whatsapp_baileys_service_spec.rb @@ -363,6 +363,80 @@ describe Whatsapp::Providers::WhatsappBaileysService do end end + describe '#toggle_typing_status' do + let(:request_path) { "#{whatsapp_channel.provider_config['provider_url']}/connections/#{whatsapp_channel.phone_number}/presence" } + + it 'calls presence endpoint for typing on' do + request = stub_request(:patch, request_path) + .with( + headers: stub_headers(whatsapp_channel), + body: { + toJid: test_send_jid, + type: 'composing' + }.to_json + ) + .to_return(status: 200) + + service.toggle_typing_status(test_send_phone_number, Events::Types::CONVERSATION_TYPING_ON) + + expect(request).to have_been_requested + end + + it 'calls presence endpoint for recording' do + request = stub_request(:patch, request_path) + .with( + headers: stub_headers(whatsapp_channel), + body: { + toJid: test_send_jid, + type: 'recording' + }.to_json + ) + .to_return(status: 200) + + service.toggle_typing_status(test_send_phone_number, Events::Types::CONVERSATION_RECORDING) + + expect(request).to have_been_requested + end + + it 'calls presence endpoint for typing off' do + request = stub_request(:patch, request_path) + .with( + headers: stub_headers(whatsapp_channel), + body: { + toJid: test_send_jid, + type: 'paused' + }.to_json + ) + .to_return(status: 200) + + service.toggle_typing_status(test_send_phone_number, Events::Types::CONVERSATION_TYPING_OFF) + + expect(request).to have_been_requested + end + + it 'logs the error and returns false' do + stub_request(:patch, request_path) + .with( + headers: stub_headers(whatsapp_channel), + body: { + toJid: test_send_jid, + type: 'composing' + }.to_json + ) + .to_return( + status: 400, + body: 'error message', + headers: {} + ) + allow(Rails.logger).to receive(:error).with('error message') + + response = service.toggle_typing_status(test_send_phone_number, Events::Types::CONVERSATION_TYPING_ON) + + expect(response).to be(false) + expect(Rails.logger).to have_received(:error) + end + end + context 'when environment variable BAILEYS_PROVIDER_DEFAULT_URL is set' do it 'uses the base url from the environment variable' do stub_const('Whatsapp::Providers::WhatsappBaileysService::DEFAULT_URL', 'http://test.com') diff --git a/swagger/paths/public/inboxes/conversations/toggle_typing.yml b/swagger/paths/public/inboxes/conversations/toggle_typing.yml index af01c77f6..797c75334 100644 --- a/swagger/paths/public/inboxes/conversations/toggle_typing.yml +++ b/swagger/paths/public/inboxes/conversations/toggle_typing.yml @@ -8,7 +8,11 @@ parameters: in: query required: true type: string - description: Typing status, either 'on' or 'off' + enum: + - 'on' + - 'recording' + - 'off' + description: Typing status. responses: 200: description: Typing status toggled successfully diff --git a/swagger/swagger.json b/swagger/swagger.json index d2b699e44..56aef1499 100644 --- a/swagger/swagger.json +++ b/swagger/swagger.json @@ -989,7 +989,12 @@ "in": "query", "required": true, "type": "string", - "description": "Typing status, either 'on' or 'off'" + "enum": [ + "on", + "recording", + "off" + ], + "description": "Typing status." } ], "responses": {