iachat/spec/services/conversations/presence_subscribe_service_spec.rb
Gabriel Jablonski 11e9932e9b
feat(whatsapp): show contact typing and recording indicators via baileys presence (#264)
* feat(whatsapp): show contact typing and recording indicators via baileys presence

Subscribe to WhatsApp presence updates via the baileys-api provider to
display real-time typing and recording indicators in the dashboard.

- Handle presence.update webhook events (composing, recording, paused,
  available) and broadcast via ActionCable
- Add conversation.recording event to ActionCable, webhook, and channel
  listeners for parity with typing_on/typing_off
- Show "typing..." / "recording..." in green text on the chat list,
  replacing the message preview
- Show "X is typing" / "X is recording audio" in the conversation view
- Add presence_subscribe provider config option (default off) to gate
  all subscription calls to the baileys-api
- Subscribe to presence on conversation open and periodically (1 min)
  for the top 10 chat list conversations
- Consolidate contact LID from presence.update jidAlt payload
- Prevent echo-back of contact typing events to the channel

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address review feedback

- Filter chat list typing indicator to contact-only events
- Add dedupe to presence subscribe bulk calls
- Use strong parameters for conversation_ids
- Remove redundant YAML quotes in swagger webhook enum

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address review feedback

- Extract phone from data[:id] when JID is @s.whatsapp.net (fallback
  when jidAlt is absent)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address review feedback

- Filter recording users in getTypingUsersText to show correct names
- Add 10s timeout to presence_subscribe HTTP request

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: scope typing timer per user instead of per conversation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: make presence subscribe best-effort with rescue per channel

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address review feedback

- Add messagePreviewClass to typing preview for consistent padding
- Fix specs to use WebMock assertions instead of instance spying

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 11:38:11 -03:00

97 lines
3.7 KiB
Ruby

require 'rails_helper'
describe Conversations::PresenceSubscribeService do
let!(:whatsapp_channel) do
create(:channel_whatsapp,
provider: 'baileys',
provider_config: { webhook_verify_token: 'token', presence_subscribe: presence_enabled },
validate_provider_config: false,
received_messages: false)
end
let(:inbox) { whatsapp_channel.inbox }
let(:account) { inbox.account }
let(:contact) { create(:contact, account: account, phone_number: '+5521999999999', identifier: '12345@lid') }
let(:contact_inbox) { create(:contact_inbox, inbox: inbox, contact: contact, source_id: '12345') }
let!(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact, contact_inbox: contact_inbox) }
let(:presence_subscribe_url) { "https://baileys.api/connections/#{whatsapp_channel.phone_number}/presence-subscribe" }
let(:setup_url) { "https://baileys.api/connections/#{whatsapp_channel.phone_number}" }
let(:json_headers) { { 'Content-Type' => 'application/json' } }
before do
stub_request(:post, setup_url).to_return(status: 200, body: {}.to_json)
stub_request(:post, presence_subscribe_url)
.to_return(status: 200, body: { data: { subscribed: [], skipped: [] } }.to_json, headers: json_headers)
end
describe '#perform' do
context 'when presence_subscribe is enabled' do
let(:presence_enabled) { true }
it 'calls presence_subscribe with the contact JID' do
described_class.new(account, [conversation.display_id]).perform
expect(WebMock).to have_requested(:post, presence_subscribe_url)
.with(body: { jids: ['12345@lid'] }.to_json)
end
it 'falls back to phone JID when identifier is blank' do
contact.update!(identifier: nil)
described_class.new(account, [conversation.display_id]).perform
expect(WebMock).to have_requested(:post, presence_subscribe_url)
.with(body: { jids: ['5521999999999@s.whatsapp.net'] }.to_json)
end
it 'skips contacts with no identifier or phone' do
contact.update!(identifier: nil, phone_number: nil)
described_class.new(account, [conversation.display_id]).perform
expect(WebMock).not_to have_requested(:post, presence_subscribe_url)
end
it 'limits to 10 conversation IDs' do
ids = (1..15).to_a
service = described_class.new(account, ids)
expect(service.instance_variable_get(:@conversation_ids).length).to eq(10)
end
end
context 'when presence_subscribe is disabled' do
let(:presence_enabled) { false }
it 'does not call presence_subscribe' do
described_class.new(account, [conversation.display_id]).perform
expect(WebMock).not_to have_requested(:post, presence_subscribe_url)
end
end
context 'with non-WhatsApp conversations' do
let(:presence_enabled) { true }
let(:web_inbox) { create(:inbox, account: account) }
let(:web_contact) { create(:contact, account: account) }
let(:web_contact_inbox) { create(:contact_inbox, inbox: web_inbox, contact: web_contact) }
let!(:web_conversation) do
create(:conversation, account: account, inbox: web_inbox, contact: web_contact, contact_inbox: web_contact_inbox)
end
it 'skips non-WhatsApp conversations' do
described_class.new(account, [web_conversation.display_id]).perform
expect(WebMock).not_to have_requested(:post, presence_subscribe_url)
end
end
context 'with blank conversation_ids' do
let(:presence_enabled) { true }
it 'returns early without any HTTP calls' do
described_class.new(account, []).perform
expect(WebMock).not_to have_requested(:post, presence_subscribe_url)
end
end
end
end