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
This commit is contained in:
Gabriel Jablonski 2025-05-03 00:39:39 -03:00 committed by GitHub
parent 81d62d94f1
commit 62ef2113d5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 257 additions and 5 deletions

View File

@ -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

View File

@ -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

View File

@ -18,7 +18,8 @@ class AsyncDispatcher < BaseDispatcher
NotificationListener.instance,
ParticipationListener.instance,
ReportingEventListener.instance,
WebhookListener.instance
WebhookListener.instance,
ChannelListener.instance
]
end
end

View File

@ -576,7 +576,11 @@ function createEditorView() {
handleDOMEvents: {
keyup: () => {
if (!props.disabled) {
typingIndicator.start();
if (props.modelValue.length) {
typingIndicator.start();
} else {
typingIndicator.stop();
}
updateImgToolbarOnDelete();
}
},

View File

@ -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');
},

View File

@ -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();

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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'

View File

@ -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

View File

@ -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

View File

@ -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')

View File

@ -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

View File

@ -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": {