diff --git a/.claude/settings.json b/.claude/settings.json
new file mode 100644
index 000000000..a31d28c2d
--- /dev/null
+++ b/.claude/settings.json
@@ -0,0 +1,3 @@
+{
+ "additionalDirectories": ["../baileys-api"]
+}
diff --git a/.env.example b/.env.example
index 0158c1f0f..ca4faf88b 100644
--- a/.env.example
+++ b/.env.example
@@ -293,5 +293,7 @@ AZURE_APP_SECRET=
BAILEYS_PROVIDER_DEFAULT_CLIENT_NAME=Chatwoot
BAILEYS_PROVIDER_DEFAULT_URL=http://localhost:3025
BAILEYS_PROVIDER_DEFAULT_API_KEY=
+# Enable WhatsApp group conversations for Baileys provider (default: false)
+BAILEYS_WHATSAPP_GROUPS_ENABLED=false
RESEND_API_KEY=
diff --git a/app/builders/contact_inbox_with_contact_builder.rb b/app/builders/contact_inbox_with_contact_builder.rb
index 2c0e6087e..994b52078 100644
--- a/app/builders/contact_inbox_with_contact_builder.rb
+++ b/app/builders/contact_inbox_with_contact_builder.rb
@@ -55,7 +55,8 @@ class ContactInboxWithContactBuilder
email: contact_attributes[:email],
identifier: contact_attributes[:identifier],
additional_attributes: contact_attributes[:additional_attributes],
- custom_attributes: contact_attributes[:custom_attributes]
+ custom_attributes: contact_attributes[:custom_attributes],
+ group_type: contact_attributes[:group_type] || :individual
)
end
diff --git a/app/controllers/api/v1/accounts/contacts/group_admin_controller.rb b/app/controllers/api/v1/accounts/contacts/group_admin_controller.rb
new file mode 100644
index 000000000..79a5837c9
--- /dev/null
+++ b/app/controllers/api/v1/accounts/contacts/group_admin_controller.rb
@@ -0,0 +1,56 @@
+class Api::V1::Accounts::Contacts::GroupAdminController < Api::V1::Accounts::Contacts::BaseController
+ VALID_PROPERTIES = %w[announce restrict join_approval_mode member_add_mode].freeze
+
+ def leave
+ authorize @contact, :update?
+ channel.group_leave(@contact.identifier)
+ head :ok
+ rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e
+ render json: { error: e.message }, status: :unprocessable_entity
+ end
+
+ def update
+ authorize @contact, :update?
+ property = property_params[:property]
+ enabled = ActiveModel::Type::Boolean.new.cast(property_params[:enabled])
+ return render json: { error: 'invalid_property' }, status: :unprocessable_entity unless property.in?(VALID_PROPERTIES)
+
+ apply_property_change(property, enabled)
+ update_contact_attribute(property, enabled)
+ head :ok
+ rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e
+ render json: { error: e.message }, status: :unprocessable_entity
+ end
+
+ private
+
+ def apply_property_change(property, enabled)
+ case property
+ when 'announce', 'restrict'
+ channel.group_setting_update(@contact.identifier, property, enabled)
+ when 'join_approval_mode'
+ channel.group_join_approval_mode(@contact.identifier, enabled ? 'on' : 'off')
+ when 'member_add_mode'
+ channel.group_member_add_mode(@contact.identifier, enabled ? 'all_member_add' : 'admin_add')
+ end
+ end
+
+ def property_params
+ params.permit(:property, :enabled)
+ end
+
+ def channel
+ @channel ||= @contact.group_channel
+ end
+
+ def resolve_group_conversations
+ Current.account.conversations
+ .where(contact_id: @contact.id, group_type: :group, status: %i[open pending])
+ .find_each { |c| c.update!(status: :resolved) }
+ end
+
+ def update_contact_attribute(key, value)
+ new_attrs = (@contact.additional_attributes || {}).merge(key => value)
+ @contact.update!(additional_attributes: new_attrs)
+ end
+end
diff --git a/app/controllers/api/v1/accounts/contacts/group_invites_controller.rb b/app/controllers/api/v1/accounts/contacts/group_invites_controller.rb
new file mode 100644
index 000000000..9d9c18cca
--- /dev/null
+++ b/app/controllers/api/v1/accounts/contacts/group_invites_controller.rb
@@ -0,0 +1,27 @@
+class Api::V1::Accounts::Contacts::GroupInvitesController < Api::V1::Accounts::Contacts::BaseController
+ def show
+ authorize @contact, :show?
+ code = channel.group_invite_code(@contact.identifier)
+ render json: invite_response(code)
+ rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e
+ render json: { error: e.message }, status: :unprocessable_entity
+ end
+
+ def revoke
+ authorize @contact, :update?
+ code = channel.revoke_group_invite(@contact.identifier)
+ render json: invite_response(code)
+ rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e
+ render json: { error: e.message }, status: :unprocessable_entity
+ end
+
+ private
+
+ def channel
+ @channel ||= @contact.group_channel
+ end
+
+ def invite_response(code)
+ { invite_code: code, invite_url: "https://chat.whatsapp.com/#{code}" }
+ end
+end
diff --git a/app/controllers/api/v1/accounts/contacts/group_join_requests_controller.rb b/app/controllers/api/v1/accounts/contacts/group_join_requests_controller.rb
new file mode 100644
index 000000000..db1caabe6
--- /dev/null
+++ b/app/controllers/api/v1/accounts/contacts/group_join_requests_controller.rb
@@ -0,0 +1,37 @@
+class Api::V1::Accounts::Contacts::GroupJoinRequestsController < Api::V1::Accounts::Contacts::BaseController
+ def index
+ authorize @contact, :show?
+ requests = channel.group_join_requests(@contact.identifier)
+ render json: { payload: requests }
+ rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e
+ render json: { error: e.message }, status: :unprocessable_entity
+ end
+
+ def handle
+ authorize @contact, :update?
+ channel.handle_group_join_requests(@contact.identifier, handle_params[:participants], handle_params[:request_action])
+ remove_handled_requests(handle_params[:participants])
+ head :ok
+ rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e
+ render json: { error: e.message }, status: :unprocessable_entity
+ end
+
+ private
+
+ def handle_params
+ params.permit(:request_action, participants: [])
+ end
+
+ def channel
+ @channel ||= @contact.group_channel
+ end
+
+ def remove_handled_requests(participants)
+ return if participants.blank?
+
+ current_requests = @contact.additional_attributes&.dig('pending_join_requests') || []
+ updated_requests = current_requests.reject { |r| participants.include?(r['jid']) }
+ new_attrs = (@contact.additional_attributes || {}).merge('pending_join_requests' => updated_requests)
+ @contact.update!(additional_attributes: new_attrs)
+ end
+end
diff --git a/app/controllers/api/v1/accounts/contacts/group_members_controller.rb b/app/controllers/api/v1/accounts/contacts/group_members_controller.rb
new file mode 100644
index 000000000..8ce6ef3d2
--- /dev/null
+++ b/app/controllers/api/v1/accounts/contacts/group_members_controller.rb
@@ -0,0 +1,155 @@
+class Api::V1::Accounts::Contacts::GroupMembersController < Api::V1::Accounts::Contacts::BaseController
+ DEFAULT_PER_PAGE = 10
+
+ before_action :ensure_group_contact, only: %i[create update destroy]
+
+ def index
+ authorize @contact, :show?
+
+ base_query = GroupMember.active
+ .where(group_contact: @contact)
+ .includes(:contact)
+
+ @total_count = base_query.count
+ @page = [(params[:page] || 1).to_i, 1].max
+ @per_page = (params[:per_page] || DEFAULT_PER_PAGE).to_i.clamp(1, 100)
+ @inbox_phone_number = inbox_phone_number
+ @is_inbox_admin = inbox_admin?
+
+ paginated = base_query.order(role: :desc, id: :asc)
+ .offset((@page - 1) * @per_page)
+ .limit(@per_page)
+
+ @group_members = pin_own_member_on_first_page(paginated)
+ end
+
+ def create
+ authorize @contact, :update?
+ participants = create_params[:participants]
+ return render json: { error: 'participants_required' }, status: :unprocessable_entity if participants.blank?
+
+ channel.update_group_participants(@contact.identifier, format_participants(participants), 'add')
+ add_group_members(participants)
+ head :ok
+ rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e
+ render json: { error: e.message }, status: :unprocessable_entity
+ end
+
+ def update
+ authorize @contact, :update?
+ role = update_params[:role]
+ return render json: { error: 'invalid_role' }, status: :unprocessable_entity unless %w[admin member].include?(role)
+
+ member = group_members.find(params[:member_id])
+ action = role == 'admin' ? 'promote' : 'demote'
+ channel.update_group_participants(@contact.identifier, [jid_for_member(member)], action)
+ member.update!(role: role)
+ head :ok
+ rescue Whatsapp::Providers::WhatsappBaileysService::GroupParticipantNotAllowedError
+ render json: { error: 'group_creator_not_modifiable' }, status: :unprocessable_entity
+ rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e
+ render json: { error: e.message }, status: :unprocessable_entity
+ end
+
+ def destroy
+ authorize @contact, :update?
+
+ member = group_members.find(params[:id])
+ channel.update_group_participants(@contact.identifier, [jid_for_member(member)], 'remove')
+ member.update!(is_active: false)
+ head :ok
+ rescue Whatsapp::Providers::WhatsappBaileysService::GroupParticipantNotAllowedError
+ render json: { error: 'group_creator_not_modifiable' }, status: :unprocessable_entity
+ rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e
+ render json: { error: e.message }, status: :unprocessable_entity
+ end
+
+ private
+
+ def ensure_group_contact
+ return if @contact.group_type_group? && @contact.identifier.present?
+
+ render json: { error: 'Contact is not a valid group' }, status: :unprocessable_entity
+ end
+
+ def group_members
+ GroupMember.where(group_contact: @contact)
+ end
+
+ def create_params
+ params.permit(participants: [])
+ end
+
+ def update_params
+ params.permit(:role)
+ end
+
+ def channel
+ @channel ||= @contact.group_channel
+ end
+
+ def inbox_phone_number
+ channel&.phone_number
+ end
+
+ def inbox_admin?
+ return false if @inbox_phone_number.blank?
+
+ find_own_member&.role == 'admin'
+ end
+
+ def pin_own_member_on_first_page(paginated)
+ return paginated unless @page == 1 && @inbox_phone_number.present?
+
+ ids = paginated.pluck(:id)
+ own = find_own_member
+ return paginated if own.blank? || ids.include?(own.id)
+
+ # Prepend own member; drop the last one so total per-page stays consistent
+ [own] + paginated.where.not(id: own.id).limit(@per_page - 1).to_a
+ end
+
+ def find_own_member
+ clean = @inbox_phone_number.delete('+')
+ GroupMember.active
+ .where(group_contact: @contact)
+ .joins(:contact)
+ .where('REPLACE(contacts.phone_number, \'+\', \'\') = ? OR RIGHT(REPLACE(contacts.phone_number, \'+\', \'\'), 8) = RIGHT(?, 8)',
+ clean, clean)
+ .includes(:contact)
+ .first
+ end
+
+ def format_participants(phone_numbers)
+ Array(phone_numbers).map { |phone| "#{phone.to_s.delete('+')}@s.whatsapp.net" }
+ end
+
+ def jid_for_member(member)
+ "#{member.contact.phone_number.to_s.delete('+')}@s.whatsapp.net"
+ end
+
+ def add_group_members(phone_numbers)
+ inbox = @contact.contact_inboxes.first&.inbox
+ Array(phone_numbers).each do |phone|
+ normalized = normalize_phone(phone)
+ next if normalized.blank?
+
+ contact_inbox = ::ContactInboxWithContactBuilder.new(
+ source_id: normalized.delete('+'),
+ inbox: inbox,
+ contact_attributes: { name: normalized, phone_number: normalized }
+ ).perform
+ next if contact_inbox.blank?
+
+ member = GroupMember.find_or_initialize_by(group_contact: @contact, contact: contact_inbox.contact)
+ member.update!(role: :member, is_active: true) unless member.persisted? && member.is_active?
+ end
+ end
+
+ def normalize_phone(phone)
+ cleaned = phone.to_s.strip
+ return nil if cleaned.blank?
+
+ cleaned.start_with?('+') ? cleaned : "+#{cleaned}"
+ end
+end
diff --git a/app/controllers/api/v1/accounts/contacts/group_metadata_controller.rb b/app/controllers/api/v1/accounts/contacts/group_metadata_controller.rb
new file mode 100644
index 000000000..889236ace
--- /dev/null
+++ b/app/controllers/api/v1/accounts/contacts/group_metadata_controller.rb
@@ -0,0 +1,39 @@
+class Api::V1::Accounts::Contacts::GroupMetadataController < Api::V1::Accounts::Contacts::BaseController
+ def update
+ authorize @contact, :update?
+ update_subject if metadata_params[:subject].present?
+ update_description if metadata_params[:description].present?
+ update_picture if metadata_params[:avatar].present?
+ render json: { id: @contact.id, name: @contact.name, additional_attributes: @contact.additional_attributes }
+ rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e
+ render json: { error: e.message }, status: :unprocessable_entity
+ end
+
+ private
+
+ def metadata_params
+ params.permit(:subject, :description, :avatar)
+ end
+
+ def update_subject
+ channel.update_group_subject(@contact.identifier, metadata_params[:subject])
+ @contact.update!(name: metadata_params[:subject])
+ end
+
+ def update_description
+ channel.update_group_description(@contact.identifier, metadata_params[:description])
+ attrs = @contact.additional_attributes.merge('description' => metadata_params[:description])
+ @contact.update!(additional_attributes: attrs)
+ end
+
+ def update_picture
+ avatar = metadata_params[:avatar]
+ image_base64 = Base64.strict_encode64(avatar.read)
+ channel.update_group_picture(@contact.identifier, image_base64)
+ @contact.avatar.attach(avatar)
+ end
+
+ def channel
+ @channel ||= @contact.group_channel
+ end
+end
diff --git a/app/controllers/api/v1/accounts/contacts_controller.rb b/app/controllers/api/v1/accounts/contacts_controller.rb
index 14d4f2c89..2670bc0ba 100644
--- a/app/controllers/api/v1/accounts/contacts_controller.rb
+++ b/app/controllers/api/v1/accounts/contacts_controller.rb
@@ -13,7 +13,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
before_action :check_authorization
before_action :set_current_page, only: [:index, :active, :search, :filter]
- before_action :fetch_contact, only: [:show, :update, :destroy, :avatar, :contactable_inboxes, :destroy_custom_attributes]
+ before_action :fetch_contact, only: [:show, :update, :destroy, :avatar, :contactable_inboxes, :destroy_custom_attributes, :sync_group]
before_action :set_include_contact_inboxes, only: [:index, :active, :search, :filter, :show, :update]
def index
@@ -82,6 +82,15 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
@contact.save!
end
+ def sync_group
+ authorize @contact, :sync_group?
+ raise ActionController::BadRequest, I18n.t('contacts.sync_group.not_a_group') if @contact.group_type_individual?
+ raise ActionController::BadRequest, I18n.t('contacts.sync_group.no_identifier') if @contact.identifier.blank?
+
+ Contacts::SyncGroupJob.perform_later(@contact)
+ head :accepted
+ end
+
def create
ActiveRecord::Base.transaction do
@contact = Current.account.contacts.new(permitted_params.except(:avatar_url))
diff --git a/app/controllers/api/v1/accounts/conversations_controller.rb b/app/controllers/api/v1/accounts/conversations_controller.rb
index bd2186b0a..83873149e 100644
--- a/app/controllers/api/v1/accounts/conversations_controller.rb
+++ b/app/controllers/api/v1/accounts/conversations_controller.rb
@@ -166,7 +166,15 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
# rubocop:enable Rails/SkipsModelValidations
end
+ def unseen_activity?
+ @conversation.last_activity_at.present? &&
+ (@conversation.agent_last_seen_at.blank? || @conversation.last_activity_at > @conversation.agent_last_seen_at)
+ end
+
def should_update_last_seen?
+ # Always update when there's unseen activity (e.g. soft-disabled group conversations that don't create messages)
+ return true if unseen_activity?
+
# Update if at least one relevant timestamp is older than 1 hour or not set
# This prevents redundant DB writes when agents repeatedly view the same conversation
agent_needs_update = @conversation.agent_last_seen_at.blank? || @conversation.agent_last_seen_at < 1.hour.ago
diff --git a/app/controllers/api/v1/accounts/groups_controller.rb b/app/controllers/api/v1/accounts/groups_controller.rb
new file mode 100644
index 000000000..edad93c31
--- /dev/null
+++ b/app/controllers/api/v1/accounts/groups_controller.rb
@@ -0,0 +1,26 @@
+class Api::V1::Accounts::GroupsController < Api::V1::Accounts::BaseController
+ def create
+ inbox = Current.account.inboxes.find_by(id: group_params[:inbox_id])
+ return render json: { error: 'Access Denied' }, status: :forbidden unless inbox_accessible?(inbox)
+
+ result = Groups::CreateService.new(
+ inbox: inbox,
+ subject: group_params[:subject],
+ participants: Array(group_params[:participants])
+ ).perform
+
+ render json: result
+ rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e
+ render json: { error: e.message }, status: :unprocessable_entity
+ end
+
+ private
+
+ def group_params
+ params.permit(:inbox_id, :subject, participants: [])
+ end
+
+ def inbox_accessible?(inbox)
+ inbox.present? && Current.user.assigned_inboxes.exists?(id: inbox.id) && inbox.channel.try(:allow_group_creation?)
+ end
+end
diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb
index d57ad0e53..5b18d3030 100644
--- a/app/controllers/dashboard_controller.rb
+++ b/app/controllers/dashboard_controller.rb
@@ -78,6 +78,7 @@ class DashboardController < ActionController::Base
WHATSAPP_APP_ID: GlobalConfigService.load('WHATSAPP_APP_ID', ''),
WHATSAPP_CONFIGURATION_ID: GlobalConfigService.load('WHATSAPP_CONFIGURATION_ID', ''),
IS_ENTERPRISE: ChatwootApp.enterprise?,
+ BAILEYS_WHATSAPP_GROUPS_ENABLED: Whatsapp::Providers::WhatsappBaileysService.groups_enabled?,
AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''),
GIT_SHA: GIT_HASH,
ALLOWED_LOGIN_METHODS: allowed_login_methods
diff --git a/app/finders/conversation_finder.rb b/app/finders/conversation_finder.rb
index d43ed31e7..cd4f91206 100644
--- a/app/finders/conversation_finder.rb
+++ b/app/finders/conversation_finder.rb
@@ -64,6 +64,7 @@ class ConversationFinder
find_all_conversations
filter_by_status unless params[:q]
+ filter_by_group_type
filter_by_team
filter_by_labels
filter_by_query
@@ -118,6 +119,12 @@ class ConversationFinder
@conversations
end
+ def filter_by_group_type
+ return unless params[:group_type].present? && params[:group_type] != 'all'
+
+ @conversations = @conversations.where(group_type: params[:group_type])
+ end
+
def filter_by_conversation_type
case @params[:conversation_type]
when 'mention'
diff --git a/app/helpers/filters/filter_helper.rb b/app/helpers/filters/filter_helper.rb
index fe03dae28..2bf492915 100644
--- a/app/helpers/filters/filter_helper.rb
+++ b/app/helpers/filters/filter_helper.rb
@@ -100,6 +100,10 @@ module Filters::FilterHelper
values.map { |x| Conversation.priorities[x.to_sym] }
end
+ def conversation_group_type_values(values)
+ values.map { |x| Conversation.group_types[x.to_sym] }
+ end
+
def message_type_values(values)
values.map { |x| Message.message_types[x.to_sym] }
end
diff --git a/app/javascript/dashboard/api/groupMembers.js b/app/javascript/dashboard/api/groupMembers.js
new file mode 100644
index 000000000..224e95eb1
--- /dev/null
+++ b/app/javascript/dashboard/api/groupMembers.js
@@ -0,0 +1,71 @@
+/* global axios */
+import ApiClient from './ApiClient';
+
+class GroupMembersAPI extends ApiClient {
+ constructor() {
+ super('contacts', { accountScoped: true });
+ }
+
+ getGroupMembers(contactId, page = 1) {
+ return axios.get(`${this.url}/${contactId}/group_members`, {
+ params: { page },
+ });
+ }
+
+ syncGroup(contactId) {
+ return axios.post(`${this.url}/${contactId}/sync_group`);
+ }
+
+ createGroup(params) {
+ return axios.post(`${this.baseUrl()}/groups`, params);
+ }
+
+ updateGroupMetadata(contactId, params) {
+ return axios.patch(`${this.url}/${contactId}/group_metadata`, params);
+ }
+
+ addMembers(contactId, participants) {
+ return axios.post(`${this.url}/${contactId}/group_members`, {
+ participants,
+ });
+ }
+
+ removeMembers(contactId, memberId) {
+ return axios.delete(`${this.url}/${contactId}/group_members/${memberId}`);
+ }
+
+ updateMemberRole(contactId, memberId, role) {
+ return axios.patch(`${this.url}/${contactId}/group_members/${memberId}`, {
+ role,
+ });
+ }
+
+ getInviteLink(contactId) {
+ return axios.get(`${this.url}/${contactId}/group_invite`);
+ }
+
+ revokeInviteLink(contactId) {
+ return axios.post(`${this.url}/${contactId}/group_invite/revoke`);
+ }
+
+ getPendingRequests(contactId) {
+ return axios.get(`${this.url}/${contactId}/group_join_requests`);
+ }
+
+ handleJoinRequest(contactId, params) {
+ return axios.post(
+ `${this.url}/${contactId}/group_join_requests/handle`,
+ params
+ );
+ }
+
+ leaveGroup(contactId) {
+ return axios.post(`${this.url}/${contactId}/group_admin/leave`);
+ }
+
+ updateGroupProperty(contactId, params) {
+ return axios.patch(`${this.url}/${contactId}/group_admin`, params);
+ }
+}
+
+export default new GroupMembersAPI();
diff --git a/app/javascript/dashboard/api/inbox/conversation.js b/app/javascript/dashboard/api/inbox/conversation.js
index f94fca452..ea2802cae 100644
--- a/app/javascript/dashboard/api/inbox/conversation.js
+++ b/app/javascript/dashboard/api/inbox/conversation.js
@@ -16,6 +16,7 @@ class ConversationApi extends ApiClient {
conversationType,
sortBy,
updatedWithin,
+ groupType,
}) {
return axios.get(this.url, {
params: {
@@ -28,6 +29,7 @@ class ConversationApi extends ApiClient {
conversation_type: conversationType,
sort_by: sortBy,
updated_within: updatedWithin,
+ group_type: groupType,
},
});
}
diff --git a/app/javascript/dashboard/components-next/NewConversation/ComposeConversation.vue b/app/javascript/dashboard/components-next/NewConversation/ComposeConversation.vue
index 9aa365f9b..b61c1b80f 100644
--- a/app/javascript/dashboard/components-next/NewConversation/ComposeConversation.vue
+++ b/app/javascript/dashboard/components-next/NewConversation/ComposeConversation.vue
@@ -1,7 +1,8 @@
+
+
+
+
+
+
+
+ {{ t('GROUP.CREATE.GROUPS_DISABLED') }}
+
+ {{ t('GROUP.CREATE.GROUPS_DISABLED_CTA') }}
+
+
+
+
+
+
+
+
+ {{ selectedInbox.name }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('GROUP.CREATE.NAME_REQUIRED') }}
+
+
+
+
+
+
+
+
+ {{ t('GROUP.CREATE.PARTICIPANTS_REQUIRED') }}
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/filter/helper/filterHelper.js b/app/javascript/dashboard/components-next/filter/helper/filterHelper.js
index 274eecb49..c0317975e 100644
--- a/app/javascript/dashboard/components-next/filter/helper/filterHelper.js
+++ b/app/javascript/dashboard/components-next/filter/helper/filterHelper.js
@@ -14,6 +14,7 @@ export const CONVERSATION_ATTRIBUTES = {
REFERER: 'referer',
CREATED_AT: 'created_at',
LAST_ACTIVITY_AT: 'last_activity_at',
+ GROUP_TYPE: 'group_type',
};
export const CONTACT_ATTRIBUTES = {
diff --git a/app/javascript/dashboard/components-next/filter/provider.js b/app/javascript/dashboard/components-next/filter/provider.js
index fc418b132..82c7eafa7 100644
--- a/app/javascript/dashboard/components-next/filter/provider.js
+++ b/app/javascript/dashboard/components-next/filter/provider.js
@@ -247,6 +247,20 @@ export function useConversationFilterContext() {
filterOperators: dateOperators.value,
attributeModel: 'standard',
},
+ {
+ attributeKey: CONVERSATION_ATTRIBUTES.GROUP_TYPE,
+ value: CONVERSATION_ATTRIBUTES.GROUP_TYPE,
+ attributeName: t('FILTER.ATTRIBUTES.GROUP_TYPE'),
+ label: t('FILTER.ATTRIBUTES.GROUP_TYPE'),
+ inputType: 'multiSelect',
+ options: ['individual', 'group'].map(id => ({
+ id,
+ name: t(`GROUP.FILTER.${id.toUpperCase()}`),
+ })),
+ dataType: 'text',
+ filterOperators: equalityOperators.value,
+ attributeModel: 'standard',
+ },
...customFilterTypes.value,
]);
diff --git a/app/javascript/dashboard/components-next/message/Message.vue b/app/javascript/dashboard/components-next/message/Message.vue
index cf02d5319..fe0346451 100644
--- a/app/javascript/dashboard/components-next/message/Message.vue
+++ b/app/javascript/dashboard/components-next/message/Message.vue
@@ -6,7 +6,7 @@ import { useTrack } from 'dashboard/composables';
import { useMapGetter } from 'dashboard/composables/store';
import { emitter } from 'shared/helpers/mitt';
import { useI18n } from 'vue-i18n';
-import { useRoute } from 'vue-router';
+import { useRoute, useRouter } from 'vue-router';
import { LocalStorage } from 'shared/helpers/localStorage';
import { ACCOUNT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
@@ -102,7 +102,7 @@ import { useBranding } from 'shared/composables/useBranding';
// eslint-disable-next-line vue/define-macros-order
const props = defineProps({
- id: { type: Number, required: true },
+ id: { type: [Number, String], required: true },
messageType: {
type: Number,
required: true,
@@ -127,11 +127,13 @@ const props = defineProps({
createdAt: { type: Number, required: true }, // eslint-disable-line vue/no-unused-properties
currentUserId: { type: Number, required: true }, // eslint-disable-line vue/no-unused-properties
groupWithNext: { type: Boolean, default: false },
+ groupWithPrevious: { type: Boolean, default: false },
inboxId: { type: Number, default: null }, // eslint-disable-line vue/no-unused-properties
inboxSupportsReplyTo: { type: Object, default: () => ({}) },
inboxSupportsEdit: { type: Boolean, default: false },
inReplyTo: { type: Object, default: null }, // eslint-disable-line vue/no-unused-properties
isEmailInbox: { type: Boolean, default: false },
+ isGroupConversation: { type: Boolean, default: false },
private: { type: Boolean, default: false },
sender: { type: Object, default: null },
senderId: { type: Number, default: null },
@@ -148,6 +150,7 @@ const { t } = useI18n();
const route = useRoute();
const inboxGetter = useMapGetter('inboxes/getInbox');
const inbox = computed(() => inboxGetter.value(props.inboxId) || {});
+const router = useRouter();
const { replaceInstallationName } = useBranding();
/**
@@ -245,7 +248,21 @@ const flexOrientationClass = computed(() => {
return map[orientation.value];
});
+const isGroupIncoming = computed(() => {
+ return (
+ props.isGroupConversation && props.messageType === MESSAGE_TYPES.INCOMING
+ );
+});
+
+const showGroupSenderAvatar = computed(() => {
+ return isGroupIncoming.value && !props.groupWithPrevious;
+});
+
const gridClass = computed(() => {
+ if (orientation.value === ORIENTATION.LEFT && isGroupIncoming.value) {
+ return 'grid grid-cols-[24px_1fr]';
+ }
+
const map = {
[ORIENTATION.LEFT]: 'grid grid-cols-1fr',
[ORIENTATION.RIGHT]: 'grid grid-cols-[1fr_24px]',
@@ -255,6 +272,13 @@ const gridClass = computed(() => {
});
const gridTemplate = computed(() => {
+ if (orientation.value === ORIENTATION.LEFT && isGroupIncoming.value) {
+ return `
+ "avatar bubble"
+ "spacer meta"
+ `;
+ }
+
const map = {
[ORIENTATION.LEFT]: `
"bubble"
@@ -502,6 +526,47 @@ const avatarTooltip = computed(() => {
return `${t('CONVERSATION.SENT_BY')} ${avatarInfo.value.name}`;
});
+// Colors for group sender names, matching AVATAR_COLORS from Avatar component
+const SENDER_NAME_COLORS = {
+ light: ['#C2298A', '#99543A', '#60646C', '#008573', '#4747C2', '#3A5BC7'],
+ dark: ['#FF8DCC', '#FFA366', '#ADB1B8', '#0BD8B6', '#A19EFF', '#9EB1FF'],
+};
+
+const showGroupSenderName = computed(() => {
+ return (
+ props.isGroupConversation &&
+ props.messageType === MESSAGE_TYPES.INCOMING &&
+ !props.groupWithPrevious &&
+ props.sender?.name
+ );
+});
+
+const senderNameStyle = computed(() => {
+ if (!showGroupSenderName.value) return {};
+ const name = props.sender?.name || '';
+ const index = name.length % SENDER_NAME_COLORS.light.length;
+ return {
+ color: SENDER_NAME_COLORS.light[index],
+ '--dark-sender-color': SENDER_NAME_COLORS.dark[index],
+ };
+});
+
+const navigateToGroupSender = event => {
+ if (
+ !isGroupIncoming.value ||
+ !props.sender?.id ||
+ props.sender.type?.toLowerCase() !== 'contact'
+ )
+ return;
+ const accountId = route.params.accountId;
+ const url = `/app/accounts/${accountId}/contacts/${props.sender.id}`;
+ if (event?.ctrlKey || event?.metaKey) {
+ window.open(url, '_blank');
+ } else {
+ router.push(url);
+ }
+};
+
const setupHighlightTimer = () => {
if (Number(route.query.messageId) !== Number(props.id)) {
return;
@@ -558,6 +623,18 @@ provideMessageContext({
gridTemplateAreas: gridTemplate,
}"
>
+
-
-
+
+
+ {{ sender?.name }}
+
+
+
+
{
const currentChat = useMapGetter('getSelectedChat');
+const isGroupConversation = computed(
+ () => currentChat.value?.group_type === 'group'
+);
+
// Cache for fetched reply messages to avoid duplicate API calls
const fetchedReplyMessages = reactive(new Map());
@@ -180,6 +184,10 @@ const getInReplyToMessage = parentMessage => {
:is-email-inbox="isAnEmailChannel"
:in-reply-to="getInReplyToMessage(message)"
:group-with-next="shouldGroupWithNext(index, allMessages)"
+ :group-with-previous="
+ index > 0 && shouldGroupWithNext(index - 1, allMessages)
+ "
+ :is-group-conversation="isGroupConversation"
:inbox-supports-reply-to="inboxSupportsReplyTo"
:inbox-supports-edit="inboxSupportsEdit"
:current-user-id="currentUserId"
diff --git a/app/javascript/dashboard/components-next/message/bubbles/Text/FormattedContent.vue b/app/javascript/dashboard/components-next/message/bubbles/Text/FormattedContent.vue
index afb29f6ed..095c12422 100644
--- a/app/javascript/dashboard/components-next/message/bubbles/Text/FormattedContent.vue
+++ b/app/javascript/dashboard/components-next/message/bubbles/Text/FormattedContent.vue
@@ -139,7 +139,10 @@ const iconColorClass = computed(() => {
-
+
{
labels: props.label ? [props.label] : undefined,
teamId: props.teamId || undefined,
conversationType: props.conversationType || undefined,
+ groupType: activeGroupType.value || undefined,
};
});
@@ -373,13 +375,14 @@ const uniqueInboxes = computed(() => {
// ---------------------- Methods -----------------------
function setFiltersFromUISettings() {
const { conversations_filter_by: filterBy = {} } = uiSettings.value;
- const { status, order_by: orderBy } = filterBy;
+ const { status, order_by: orderBy, group_type: groupType } = filterBy;
activeStatus.value = status || wootConstants.STATUS_TYPE.OPEN;
activeSortBy.value = Object.values(wootConstants.SORT_BY_TYPE).includes(
orderBy
)
? orderBy
: wootConstants.SORT_BY_TYPE.LAST_ACTIVITY_AT_DESC;
+ activeGroupType.value = groupType || '';
}
function emitConversationLoaded() {
@@ -488,6 +491,10 @@ function setParamsForEditFolderModal() {
{ id: 'high', name: t('CONVERSATION.PRIORITY.OPTIONS.HIGH') },
{ id: 'urgent', name: t('CONVERSATION.PRIORITY.OPTIONS.URGENT') },
],
+ group_type: [
+ { id: 'individual', name: t('GROUP.FILTER.INDIVIDUAL') },
+ { id: 'group', name: t('GROUP.FILTER.GROUP') },
+ ],
filterTypes: advancedFilterTypes.value,
allCustomAttributes: conversationCustomAttributes.value,
};
@@ -632,6 +639,8 @@ function updateAssigneeTab(selectedTab) {
function onBasicFilterChange(value, type) {
if (type === 'status') {
activeStatus.value = value;
+ } else if (type === 'group_type') {
+ activeGroupType.value = value;
} else {
activeSortBy.value = value;
}
@@ -829,6 +838,7 @@ onMounted(() => {
setFiltersFromUISettings();
store.dispatch('setChatStatusFilter', activeStatus.value);
store.dispatch('setChatSortFilter', activeSortBy.value);
+ store.dispatch('setChatGroupTypeFilter', activeGroupType.value);
resetAndFetchData();
if (hasActiveFolders.value) {
store.dispatch('campaigns/get');
diff --git a/app/javascript/dashboard/components/SnackbarContainer.vue b/app/javascript/dashboard/components/SnackbarContainer.vue
index 8c0daebaf..ace99a2fd 100644
--- a/app/javascript/dashboard/components/SnackbarContainer.vue
+++ b/app/javascript/dashboard/components/SnackbarContainer.vue
@@ -31,26 +31,35 @@ const showPopover = () => {
const onNewToastMessage = ({ message: originalMessage, action }) => {
const message = action?.usei18n ? t(originalMessage) : originalMessage;
const duration = action?.duration || props.duration;
+ const key = action?.key || Date.now();
snackMessages.value.push({
- key: Date.now(),
+ key,
message,
action,
});
nextTick(showPopover);
- setTimeout(() => {
- snackMessages.value.shift();
- }, duration);
+ if (!action?.persistent) {
+ setTimeout(() => {
+ snackMessages.value = snackMessages.value.filter(m => m.key !== key);
+ }, duration);
+ }
+};
+
+const onDismissToastMessage = ({ key }) => {
+ snackMessages.value = snackMessages.value.filter(m => m.key !== key);
};
onMounted(() => {
emitter.on('newToastMessage', onNewToastMessage);
+ emitter.on('dismissToastMessage', onDismissToastMessage);
});
onUnmounted(() => {
emitter.off('newToastMessage', onNewToastMessage);
+ emitter.off('dismissToastMessage', onDismissToastMessage);
});
diff --git a/app/javascript/dashboard/components/ui/Banner.vue b/app/javascript/dashboard/components/ui/Banner.vue
index 6c71588f7..2aa907999 100644
--- a/app/javascript/dashboard/components/ui/Banner.vue
+++ b/app/javascript/dashboard/components/ui/Banner.vue
@@ -79,7 +79,7 @@ export default {
@@ -152,7 +152,7 @@ export default {
}
.banner-message {
- @apply flex items-center;
+ @apply inline;
}
.actions {
diff --git a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue
index 8971dff18..0eaf13b72 100644
--- a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue
+++ b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue
@@ -14,6 +14,7 @@ import {
import CannedResponse from '../conversation/CannedResponse.vue';
import KeyboardEmojiSelector from './keyboardEmojiSelector.vue';
import TagAgents from '../conversation/TagAgents.vue';
+import TagGroupMembers from '../conversation/TagGroupMembers.vue';
import VariableList from '../conversation/VariableList.vue';
import TagTools from '../conversation/TagTools.vue';
import CopilotMenuBar from './CopilotMenuBar.vue';
@@ -27,6 +28,7 @@ import { useUISettings } from 'dashboard/composables/useUISettings';
import { useAlert } from 'dashboard/composables';
import { useMapGetter } from 'dashboard/composables/store';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
+import { vOnClickOutside } from '@vueuse/components';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import {
@@ -96,6 +98,9 @@ const props = defineProps({
showImageResizeToolbar: { type: Boolean, default: false }, // A kill switch to show or hide the image toolbar
focusOnMount: { type: Boolean, default: true },
enableCopilot: { type: Boolean, default: true },
+ isGroupConversation: { type: Boolean, default: false },
+ groupContactId: { type: [Number, String], default: null },
+ inboxPhoneNumber: { type: String, default: null },
});
const emit = defineEmits([
@@ -292,7 +297,10 @@ const plugins = computed(() => {
trigger: '@',
showMenu: showUserMentions,
searchTerm: mentionSearchKey,
- isAllowed: () => props.isPrivate || !props.enableCaptainTools,
+ isAllowed: () =>
+ props.isPrivate ||
+ props.isGroupConversation ||
+ !props.enableCaptainTools,
}),
createSuggestionPlugin({
trigger: '/',
@@ -350,7 +358,10 @@ const formattedSignature = computed(() => {
});
watch(showUserMentions, updatedValue => {
- emit('toggleUserMention', props.isPrivate && updatedValue);
+ emit(
+ 'toggleUserMention',
+ (props.isPrivate || props.isGroupConversation) && updatedValue
+ );
});
watch(showCannedMenu, updatedValue => {
emit('toggleCannedMenu', !props.isPrivate && updatedValue);
@@ -818,6 +829,13 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
'opacity-50 cursor-not-allowed pointer-events-none': disabled,
}"
>
+ insertSpecialContent('mention', content)"
+ />
{
);
});
+const currentGroupType = computed(() => chatGroupTypeFilter.value || '');
+
const chatStatusOptions = computed(() => [
{
label: t('CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.open.TEXT'),
@@ -96,6 +99,12 @@ const chatSortOptions = computed(() => [
},
]);
+const chatGroupTypeOptions = computed(() => [
+ { label: t('GROUP.FILTER.ALL'), value: '' },
+ { label: t('GROUP.FILTER.INDIVIDUAL'), value: 'individual' },
+ { label: t('GROUP.FILTER.GROUP'), value: 'group' },
+]);
+
const activeChatStatusLabel = computed(
() =>
chatStatusOptions.value.find(m => m.value === chatStatusFilter.value)
@@ -108,11 +117,18 @@ const activeChatSortLabel = computed(
''
);
+const activeGroupTypeLabel = computed(
+ () =>
+ chatGroupTypeOptions.value.find(m => m.value === chatGroupTypeFilter.value)
+ ?.label || t('GROUP.FILTER.ALL')
+);
+
const saveSelectedFilter = (type, value) => {
updateUISettings({
conversations_filter_by: {
status: type === 'status' ? value : currentStatusFilter.value,
order_by: type === 'sort' ? value : currentSortBy.value,
+ group_type: type === 'group_type' ? value : currentGroupType.value,
},
});
};
@@ -128,6 +144,12 @@ const handleSortChange = value => {
store.dispatch('setChatSortFilter', value);
saveSelectedFilter('sort', value);
};
+
+const handleGroupTypeChange = value => {
+ emit('changeFilter', value, 'group_type');
+ store.dispatch('setChatGroupTypeFilter', value);
+ saveSelectedFilter('group_type', value);
+};
@@ -143,13 +165,13 @@ const handleSortChange = value => {
-
+
{{ $t('CHAT_LIST.CHAT_SORT.STATUS') }}
@@ -161,7 +183,7 @@ const handleSortChange = value => {
@update:model-value="handleStatusChange"
/>
-
+
{{ $t('CHAT_LIST.CHAT_SORT.ORDER_BY') }}
@@ -173,6 +195,18 @@ const handleSortChange = value => {
@update:model-value="handleSortChange"
/>
+
+
+ {{ $t('GROUP.FILTER.TYPE_LABEL') }}
+
+
+
diff --git a/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue b/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue
index 9f7805b4e..a74299b74 100644
--- a/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue
+++ b/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue
@@ -59,6 +59,7 @@ const currentChat = useMapGetter('getSelectedChat');
const inboxesList = useMapGetter('inboxes/getInboxes');
const activeInbox = useMapGetter('getSelectedInbox');
const accountId = useMapGetter('getCurrentAccountId');
+const globalConfig = useMapGetter('globalConfig/get');
const chatMetadata = computed(() => props.chat.meta || {});
@@ -78,7 +79,23 @@ const isActiveChat = computed(() => {
const unreadCount = computed(() => props.chat.unread_count);
-const hasUnread = computed(() => unreadCount.value > 0);
+const isGroupsDisabled = computed(() => {
+ return (
+ props.chat.group_type === 'group' &&
+ !globalConfig.value.baileysWhatsappGroupsEnabled
+ );
+});
+
+const hasGroupActivity = computed(() => {
+ if (!isGroupsDisabled.value) return false;
+ const lastActivity = props.chat.last_activity_at;
+ const agentSeen = props.chat.agent_last_seen_at;
+ return lastActivity > 0 && (!agentSeen || lastActivity > agentSeen);
+});
+
+const hasUnread = computed(
+ () => unreadCount.value > 0 || hasGroupActivity.value
+);
const isInboxNameVisible = computed(() => !activeInbox.value);
@@ -355,11 +372,15 @@ const deleteConversation = () => {
/>
{{ unreadCount > 9 ? '9+' : unreadCount }}
+
0;
+ },
+ isAnnouncementModeRestricted() {
+ return (
+ this.isAWhatsAppBaileysChannel &&
+ this.isGroupConversation &&
+ this.currentContact?.additional_attributes?.announce === true &&
+ this.isGroupMembersLoaded &&
+ !this.isInboxAdminInCurrentGroup
+ );
+ },
+ isGroupLeft() {
+ return (
+ this.isAWhatsAppBaileysChannel &&
+ this.isGroupConversation &&
+ this.currentContact?.additional_attributes?.group_left === true
+ );
+ },
+ isGroupsDisabled() {
+ return (
+ this.isAWhatsAppBaileysChannel &&
+ this.isGroupConversation &&
+ !this.globalConfig.baileysWhatsappGroupsEnabled
+ );
+ },
inboxProviderConnection() {
return this.currentInbox.provider_connection?.connection;
},
@@ -272,6 +334,21 @@ export default {
this.fetchSuggestions();
this.messageSentSinceOpened = false;
},
+ groupContactId: {
+ immediate: true,
+ handler(contactId) {
+ if (
+ contactId &&
+ this.isAWhatsAppBaileysChannel &&
+ this.isGroupConversation &&
+ !this.isGroupMembersLoaded
+ ) {
+ this.$store.dispatch('groupMembers/fetch', {
+ contactId,
+ });
+ }
+ },
+ },
},
created() {
@@ -466,6 +543,9 @@ export default {
return false;
});
},
+ onOpenGroupsEnabledLink() {
+ window.open(wootConstants.FAZER_AI_GUIDES_URL, '_blank');
+ },
onOpenLinkDeviceModal() {
this.showLinkDeviceModal = true;
},
@@ -537,6 +617,27 @@ export default {
class="mx-2 mt-2 overflow-hidden rounded-lg"
:banner-message="$t('CONVERSATION.OLD_INSTAGRAM_INBOX_REPLY_BANNER')"
/>
+
+
+
0;
+ },
+ isAnnouncementModeRestricted() {
+ return (
+ this.isAWhatsAppBaileysChannel &&
+ this.isGroupConversation &&
+ this.currentContact?.additional_attributes?.announce === true &&
+ this.isGroupMembersLoaded &&
+ !this.isInboxAdminInCurrentGroup
+ );
+ },
+ isGroupLeft() {
+ return (
+ this.isAWhatsAppBaileysChannel &&
+ this.isGroupConversation &&
+ this.currentContact?.additional_attributes?.group_left === true
+ );
+ },
+ isGroupsDisabled() {
+ return (
+ this.isAWhatsAppBaileysChannel &&
+ this.isGroupConversation &&
+ !this.globalConfig.baileysWhatsappGroupsEnabled
+ );
+ },
shouldShowReplyToMessage() {
return (
this.inReplyTo?.id &&
@@ -211,6 +270,15 @@ export default {
return this.$store.getters['inboxes/getInbox'](this.inboxId);
},
messagePlaceHolder() {
+ if (this.isGroupsDisabled && !this.isOnPrivateNote) {
+ return this.$t('CONVERSATION.FOOTER.GROUPS_DISABLED_RESTRICTED');
+ }
+ if (this.isGroupLeft && !this.isOnPrivateNote) {
+ return this.$t('CONVERSATION.FOOTER.GROUP_LEFT_RESTRICTED');
+ }
+ if (this.isAnnouncementModeRestricted && !this.isOnPrivateNote) {
+ return this.$t('CONVERSATION.FOOTER.ANNOUNCEMENT_MODE_RESTRICTED');
+ }
if (this.isEditorDisabled) {
return this.isAWhatsAppChannel
? this.$t('CONVERSATION.FOOTER.MESSAGING_RESTRICTED_WHATSAPP')
@@ -446,6 +514,15 @@ export default {
return !this.showAudioRecorderEditor && !this.copilot.isActive.value;
},
isEditorDisabled() {
+ if (this.isGroupsDisabled && !this.isOnPrivateNote) {
+ return true;
+ }
+ if (this.isGroupLeft && !this.isOnPrivateNote) {
+ return true;
+ }
+ if (this.isAnnouncementModeRestricted && !this.isOnPrivateNote) {
+ return true;
+ }
return (
this.isAWhatsAppChannel &&
!this.isOnPrivateNote &&
@@ -519,6 +596,21 @@ export default {
// Autosave the current message draft.
this.doAutoSaveDraft();
},
+ groupContactId: {
+ immediate: true,
+ handler(contactId) {
+ if (
+ contactId &&
+ this.isAWhatsAppBaileysChannel &&
+ this.isGroupConversation &&
+ !this.isGroupMembersLoaded
+ ) {
+ this.$store.dispatch('groupMembers/fetch', {
+ contactId,
+ });
+ }
+ },
+ },
replyType(updatedReplyType, oldReplyType) {
this.setToDraft(this.conversationIdByRoute, oldReplyType);
this.getFromDraft();
@@ -1369,6 +1461,9 @@ export default {
:signature-separator-override="signatureSeparator"
:channel-type="channelType"
:medium="inbox.medium"
+ :is-group-conversation="isGroupConversation"
+ :group-contact-id="groupContactId"
+ :inbox-phone-number="inboxPhoneNumber"
@typing-off="onTypingOff"
@typing-on="onTypingOn"
@focus="onFocus"
diff --git a/app/javascript/dashboard/components/widgets/conversation/TagGroupMembers.vue b/app/javascript/dashboard/components/widgets/conversation/TagGroupMembers.vue
new file mode 100644
index 000000000..a4cf857c7
--- /dev/null
+++ b/app/javascript/dashboard/components/widgets/conversation/TagGroupMembers.vue
@@ -0,0 +1,190 @@
+
+
+
+
+
+ -
+
+
+ {{ item.title }}
+
+
+
+
+
+
+ {{ item.displayName }}
+
+
+ {{ item.displayInfo }}
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/index.js b/app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/index.js
index 2c3c2fd13..b3d26e0f0 100644
--- a/app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/index.js
+++ b/app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/index.js
@@ -110,6 +110,14 @@ const filterTypes = [
filterOperators: OPERATOR_TYPES_5,
attributeModel: 'standard',
},
+ {
+ attributeKey: 'group_type',
+ attributeI18nKey: 'GROUP_TYPE',
+ inputType: 'multi_select',
+ dataType: 'text',
+ filterOperators: OPERATOR_TYPES_1,
+ attributeModel: 'standard',
+ },
];
export const filterAttributeGroups = [
@@ -153,6 +161,10 @@ export const filterAttributeGroups = [
key: 'last_activity_at',
i18nKey: 'LAST_ACTIVITY',
},
+ {
+ key: 'group_type',
+ i18nKey: 'GROUP_TYPE',
+ },
],
},
{
diff --git a/app/javascript/dashboard/components/widgets/conversation/specs/TagGroupMembers.spec.js b/app/javascript/dashboard/components/widgets/conversation/specs/TagGroupMembers.spec.js
new file mode 100644
index 000000000..46690e183
--- /dev/null
+++ b/app/javascript/dashboard/components/widgets/conversation/specs/TagGroupMembers.spec.js
@@ -0,0 +1,139 @@
+import { describe, it, expect, vi } from 'vitest';
+import { mount } from '@vue/test-utils';
+import TagGroupMembers from '../TagGroupMembers.vue';
+import { useStoreGetters } from 'dashboard/composables/store';
+
+vi.mock('dashboard/composables/store');
+vi.mock('dashboard/composables/useKeyboardNavigableList', () => ({
+ useKeyboardNavigableList: vi.fn(),
+}));
+
+const MEMBERS = [
+ {
+ id: 1,
+ is_active: true,
+ contact: {
+ id: 10,
+ name: 'Alice Silva',
+ phone_number: '+5511999990001',
+ thumbnail: 'alice.jpg',
+ },
+ },
+ {
+ id: 2,
+ is_active: true,
+ contact: {
+ id: 20,
+ name: 'Bob Santos',
+ phone_number: '+5511999990002',
+ thumbnail: 'bob.jpg',
+ },
+ },
+ {
+ id: 3,
+ is_active: false,
+ contact: {
+ id: 30,
+ name: 'Charlie Inactive',
+ phone_number: '+5511999990003',
+ thumbnail: '',
+ },
+ },
+];
+
+const mountComponent = (props = {}) => {
+ const getGroupMembersFn = vi.fn(contactId => {
+ if (contactId === 100) return MEMBERS;
+ return [];
+ });
+
+ useStoreGetters.mockReturnValue({
+ 'groupMembers/getGroupMembers': { value: getGroupMembersFn },
+ });
+
+ return mount(TagGroupMembers, {
+ props: {
+ groupContactId: 100,
+ searchKey: '',
+ ...props,
+ },
+ global: {
+ stubs: { Avatar: true },
+ },
+ });
+};
+
+describe('TagGroupMembers', () => {
+ it('does not include an @all/everyone item in the list', () => {
+ const wrapper = mountComponent();
+
+ const listItems = wrapper.findAll('[role="option"]');
+ const names = listItems.map(el => el.text());
+ const hasEveryone = names.some(
+ n => n.includes('all') && !n.includes('Alice')
+ );
+
+ expect(hasEveryone).toBe(false);
+ });
+
+ it('renders only active members excluding the inbox phone number', () => {
+ const wrapper = mountComponent({
+ excludePhoneNumber: '+5511999990001',
+ });
+
+ const listItems = wrapper.findAll('[role="option"]');
+
+ expect(listItems).toHaveLength(1);
+ expect(listItems[0].text()).toContain('Bob Santos');
+ });
+
+ it('filters members by search key matching name', () => {
+ const wrapper = mountComponent({ searchKey: 'alice' });
+
+ const listItems = wrapper.findAll('[role="option"]');
+
+ expect(listItems).toHaveLength(1);
+ expect(listItems[0].text()).toContain('Alice Silva');
+ });
+
+ it('filters members by search key matching phone number', () => {
+ const wrapper = mountComponent({ searchKey: '0002' });
+
+ const listItems = wrapper.findAll('[role="option"]');
+
+ expect(listItems).toHaveLength(1);
+ expect(listItems[0].text()).toContain('Bob Santos');
+ });
+
+ it('shows no dropdown when no members match the search', () => {
+ const wrapper = mountComponent({ searchKey: 'nonexistent' });
+
+ const list = wrapper.find('ul');
+
+ expect(list.exists()).toBe(false);
+ });
+
+ it('emits selectAgent with the correct member on click', async () => {
+ const wrapper = mountComponent();
+
+ const firstOption = wrapper.findAll('[role="option"]')[0];
+ await firstOption.trigger('click');
+
+ expect(wrapper.emitted('selectAgent')).toBeTruthy();
+ expect(wrapper.emitted('selectAgent')[0][0]).toMatchObject({
+ id: 10,
+ type: 'contact',
+ name: 'Alice Silva',
+ });
+ });
+
+ it('renders a section header', () => {
+ const wrapper = mountComponent();
+
+ const header = wrapper.find(
+ '.text-xs.font-medium.tracking-wide.capitalize'
+ );
+
+ expect(header.exists()).toBe(true);
+ });
+});
diff --git a/app/javascript/dashboard/composables/index.js b/app/javascript/dashboard/composables/index.js
index cf4cdf834..25cdc40b2 100644
--- a/app/javascript/dashboard/composables/index.js
+++ b/app/javascript/dashboard/composables/index.js
@@ -22,3 +22,21 @@ export const useTrack = (...args) => {
export const useAlert = (message, action = null) => {
emitter.emit('newToastMessage', { message, action });
};
+
+let pendingAlertCounter = 0;
+
+/**
+ * Shows a persistent toast that stays visible until explicitly dismissed.
+ * Useful for long-running operations (e.g. "Adding member...").
+ * @param {string} message - The message to display while the operation is in progress.
+ * @returns {Function} dismiss - Call this function to remove the persistent toast.
+ */
+export const usePendingAlert = message => {
+ pendingAlertCounter += 1;
+ const key = `pending-${Date.now()}-${pendingAlertCounter}`;
+ emitter.emit('newToastMessage', {
+ message,
+ action: { persistent: true, key },
+ });
+ return () => emitter.emit('dismissToastMessage', { key });
+};
diff --git a/app/javascript/dashboard/composables/spec/index.spec.js b/app/javascript/dashboard/composables/spec/index.spec.js
index dc1016af8..1987ee47c 100644
--- a/app/javascript/dashboard/composables/spec/index.spec.js
+++ b/app/javascript/dashboard/composables/spec/index.spec.js
@@ -1,6 +1,6 @@
import { emitter } from 'shared/helpers/mitt';
import analyticsHelper from 'dashboard/helper/AnalyticsHelper';
-import { useTrack, useAlert } from '../index';
+import { useTrack, useAlert, usePendingAlert } from '../index';
vi.mock('shared/helpers/mitt', () => ({
emitter: {
@@ -48,3 +48,63 @@ describe('useAlert', () => {
});
});
});
+
+describe('usePendingAlert', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('should emit a persistent newToastMessage and return a dismiss function', () => {
+ const message = 'Adding member...';
+ const dismiss = usePendingAlert(message);
+
+ expect(emitter.emit).toHaveBeenCalledWith(
+ 'newToastMessage',
+ expect.objectContaining({
+ message,
+ action: expect.objectContaining({
+ persistent: true,
+ key: expect.any(String),
+ }),
+ })
+ );
+
+ expect(typeof dismiss).toBe('function');
+ });
+
+ it('should emit dismissToastMessage with the matching key when dismiss is called', () => {
+ const dismiss = usePendingAlert('Processing...');
+ const emittedCall = emitter.emit.mock.calls.find(
+ c => c[0] === 'newToastMessage'
+ );
+ const { key } = emittedCall[1].action;
+
+ dismiss();
+
+ expect(emitter.emit).toHaveBeenCalledWith('dismissToastMessage', { key });
+ });
+
+ it('should generate unique keys for each call', () => {
+ const dismiss1 = usePendingAlert('First');
+ const dismiss2 = usePendingAlert('Second');
+
+ const calls = emitter.emit.mock.calls.filter(
+ c => c[0] === 'newToastMessage'
+ );
+ const key1 = calls[0][1].action.key;
+ const key2 = calls[1][1].action.key;
+
+ expect(key1).not.toBe(key2);
+
+ // Each dismiss should only dismiss its own toast
+ dismiss1();
+ expect(emitter.emit).toHaveBeenCalledWith('dismissToastMessage', {
+ key: key1,
+ });
+
+ dismiss2();
+ expect(emitter.emit).toHaveBeenCalledWith('dismissToastMessage', {
+ key: key2,
+ });
+ });
+});
diff --git a/app/javascript/dashboard/constants/globals.js b/app/javascript/dashboard/constants/globals.js
index eb42a270f..45cdf8318 100644
--- a/app/javascript/dashboard/constants/globals.js
+++ b/app/javascript/dashboard/constants/globals.js
@@ -38,6 +38,7 @@ export default {
'https://testimonials.cdn.chatwoot.com/testimonial-content.json',
WHATSAPP_EMBEDDED_SIGNUP_DOCS_URL:
'https://developers.facebook.com/docs/whatsapp/embedded-signup/custom-flows/onboarding-business-app-users#limitations',
+ FAZER_AI_GUIDES_URL: 'https://app.fazer.ai/#/dashboard#guides',
SMALL_SCREEN_BREAKPOINT: 768,
AVAILABILITY_STATUS_KEYS: ['online', 'busy', 'offline'],
SNOOZE_OPTIONS: {
diff --git a/app/javascript/dashboard/helper/actionCable.js b/app/javascript/dashboard/helper/actionCable.js
index 78177d563..3c10853d8 100644
--- a/app/javascript/dashboard/helper/actionCable.js
+++ b/app/javascript/dashboard/helper/actionCable.js
@@ -4,6 +4,7 @@ import DashboardAudioNotificationHelper from './AudioAlerts/DashboardAudioNotifi
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { emitter } from 'shared/helpers/mitt';
import { useImpersonation } from 'dashboard/composables/useImpersonation';
+import { pendingGroupNavigation } from 'dashboard/helper/pendingGroupNavigation';
const { isImpersonating } = useImpersonation();
@@ -26,6 +27,7 @@ class ActionCableConnector extends BaseActionCableConnector {
'presence.update': this.onPresenceUpdate,
'contact.deleted': this.onContactDelete,
'contact.updated': this.onContactUpdate,
+ 'contact.group_synced': this.onContactGroupSynced,
'conversation.mentioned': this.onConversationMentioned,
'notification.created': this.onNotificationCreated,
'notification.deleted': this.onNotificationDeleted,
@@ -87,6 +89,13 @@ class ActionCableConnector extends BaseActionCableConnector {
onConversationCreated = data => {
this.app.$store.dispatch('addConversation', data);
this.fetchConversationStats();
+
+ const pendingJid = pendingGroupNavigation.consume();
+ if (pendingJid && data.meta?.sender?.identifier === pendingJid) {
+ emitter.emit(BUS_EVENTS.NAVIGATE_TO_GROUP, { conversationId: data.id });
+ } else if (pendingJid) {
+ pendingGroupNavigation.set(pendingJid);
+ }
};
onConversationRead = data => {
@@ -193,6 +202,16 @@ class ActionCableConnector extends BaseActionCableConnector {
this.app.$store.dispatch('contacts/updateContact', data);
};
+ onContactGroupSynced = data => {
+ this.app.$store.dispatch('groupMembers/setGroupMembers', {
+ contactId: data.id,
+ members: data.group_members,
+ inboxPhoneNumber: data.inbox_phone_number,
+ isInboxAdmin: data.is_inbox_admin,
+ });
+ this.app.$store.dispatch('contacts/updateContact', data);
+ };
+
onNotificationCreated = data => {
this.app.$store.dispatch('notifications/addNotification', data);
};
diff --git a/app/javascript/dashboard/helper/automationHelper.js b/app/javascript/dashboard/helper/automationHelper.js
index 206501f19..7de9b3ba3 100644
--- a/app/javascript/dashboard/helper/automationHelper.js
+++ b/app/javascript/dashboard/helper/automationHelper.js
@@ -151,6 +151,10 @@ export const getConditionOptions = ({
country_code: countries,
message_type: messageTypeOptions,
priority: priorityOptions,
+ group_type: [
+ { id: 'individual', name: 'Individual' },
+ { id: 'group', name: 'Group' },
+ ],
labels: generateConditionOptions(labels, 'title'),
};
diff --git a/app/javascript/dashboard/helper/customViewsHelper.js b/app/javascript/dashboard/helper/customViewsHelper.js
index c837ce613..8585828f5 100644
--- a/app/javascript/dashboard/helper/customViewsHelper.js
+++ b/app/javascript/dashboard/helper/customViewsHelper.js
@@ -73,6 +73,10 @@ const getValuesForPriority = (values, priority) => {
return priority.filter(option => values.includes(option.id));
};
+const getValuesForGroupType = (values, groupType) => {
+ return groupType.filter(option => values.includes(option.id));
+};
+
export const getValuesForFilter = (filter, params) => {
const { attribute_key, values } = filter;
const {
@@ -84,6 +88,7 @@ export const getValuesForFilter = (filter, params) => {
campaigns,
labels,
priority,
+ group_type: groupType = [],
} = params;
switch (attribute_key) {
case 'status':
@@ -104,6 +109,8 @@ export const getValuesForFilter = (filter, params) => {
return getValuesForLanguages(values, languages);
case 'country_code':
return getValuesForCountries(values, countries);
+ case 'group_type':
+ return getValuesForGroupType(values, groupType);
default:
return { id: values[0], name: values[0] };
}
diff --git a/app/javascript/dashboard/helper/pendingGroupNavigation.js b/app/javascript/dashboard/helper/pendingGroupNavigation.js
new file mode 100644
index 000000000..aaa706f51
--- /dev/null
+++ b/app/javascript/dashboard/helper/pendingGroupNavigation.js
@@ -0,0 +1,26 @@
+// Simple module-level store for pending group creation navigation.
+// When a group is created, the group JID is stored here. When the
+// conversation.created websocket event arrives matching that JID,
+// the ActionCable handler emits a NAVIGATE_TO_GROUP bus event.
+
+let pendingJid = null;
+let timeout = null;
+
+const TIMEOUT_MS = 30_000; // 30 seconds to receive the GROUP_CREATE event
+
+export const pendingGroupNavigation = {
+ set(groupJid) {
+ pendingJid = groupJid;
+ clearTimeout(timeout);
+ timeout = setTimeout(() => {
+ pendingJid = null;
+ }, TIMEOUT_MS);
+ },
+
+ consume() {
+ const jid = pendingJid;
+ pendingJid = null;
+ clearTimeout(timeout);
+ return jid;
+ },
+};
diff --git a/app/javascript/dashboard/helper/phoneHelper.js b/app/javascript/dashboard/helper/phoneHelper.js
new file mode 100644
index 000000000..d5f860b06
--- /dev/null
+++ b/app/javascript/dashboard/helper/phoneHelper.js
@@ -0,0 +1,24 @@
+/**
+ * Compare phone numbers flexibly to handle format differences
+ * (e.g. Brazilian 9th digit: +5587988465072 vs +558788465072)
+ */
+export const phonesMatch = (phoneA, phoneB) => {
+ const a = phoneA?.replace(/\D/g, '');
+ const b = phoneB?.replace(/\D/g, '');
+ if (!a || !b) return false;
+ if (a === b) return true;
+ return a.length >= 8 && b.length >= 8 && a.slice(-8) === b.slice(-8);
+};
+
+/**
+ * Check if a given inbox phone number is admin in a group.
+ * @param {string} inboxPhone - The inbox phone number
+ * @param {Array} members - Array of group members with contact.phone_number and role
+ * @returns {boolean}
+ */
+export const isInboxAdminInGroup = (inboxPhone, members) => {
+ if (!inboxPhone) return false;
+ return members.some(
+ m => phonesMatch(inboxPhone, m.contact?.phone_number) && m.role === 'admin'
+ );
+};
diff --git a/app/javascript/dashboard/helper/specs/actionCable.spec.js b/app/javascript/dashboard/helper/specs/actionCable.spec.js
index 4ad8a52c6..647ea74f2 100644
--- a/app/javascript/dashboard/helper/specs/actionCable.spec.js
+++ b/app/javascript/dashboard/helper/specs/actionCable.spec.js
@@ -36,6 +36,60 @@ describe('ActionCableConnector - Copilot Tests', () => {
actionCable = ActionCableConnector.init(store.$store, 'test-token');
});
+ describe('contact.group_synced event handler', () => {
+ it('should register the contact.group_synced event handler', () => {
+ expect(Object.keys(actionCable.events)).toContain('contact.group_synced');
+ expect(actionCable.events['contact.group_synced']).toBe(
+ actionCable.onContactGroupSynced
+ );
+ });
+
+ it('should dispatch groupMembers/setGroupMembers with contact id and members from payload', () => {
+ const groupSyncedData = {
+ id: 42,
+ name: 'Test Group',
+ account_id: 1,
+ group_members: [
+ {
+ id: 1,
+ role: 'admin',
+ is_active: true,
+ contact: {
+ id: 10,
+ name: 'Alice',
+ phone_number: '+1234567890',
+ thumbnail: null,
+ },
+ },
+ {
+ id: 2,
+ role: 'member',
+ is_active: true,
+ contact: {
+ id: 11,
+ name: 'Bob',
+ phone_number: '+0987654321',
+ thumbnail: null,
+ },
+ },
+ ],
+ };
+
+ actionCable.onReceived({
+ event: 'contact.group_synced',
+ data: groupSyncedData,
+ });
+
+ expect(mockDispatch).toHaveBeenCalledWith(
+ 'groupMembers/setGroupMembers',
+ {
+ contactId: 42,
+ members: groupSyncedData.group_members,
+ }
+ );
+ });
+ });
+
describe('copilot event handlers', () => {
it('should register the copilot.message.created event handler', () => {
expect(Object.keys(actionCable.events)).toContain(
diff --git a/app/javascript/dashboard/i18n/locale/en/advancedFilters.json b/app/javascript/dashboard/i18n/locale/en/advancedFilters.json
index a991cb25b..ab022c8c9 100644
--- a/app/javascript/dashboard/i18n/locale/en/advancedFilters.json
+++ b/app/javascript/dashboard/i18n/locale/en/advancedFilters.json
@@ -62,7 +62,8 @@
"CUSTOM_ATTRIBUTE_LINK": "Link",
"CUSTOM_ATTRIBUTE_CHECKBOX": "Checkbox",
"CREATED_AT": "Created at",
- "LAST_ACTIVITY": "Last activity"
+ "LAST_ACTIVITY": "Last activity",
+ "GROUP_TYPE": "Group type"
},
"ERRORS": {
"VALUE_REQUIRED": "Value is required",
diff --git a/app/javascript/dashboard/i18n/locale/en/contact.json b/app/javascript/dashboard/i18n/locale/en/contact.json
index 86162d2c7..019cd6cf3 100644
--- a/app/javascript/dashboard/i18n/locale/en/contact.json
+++ b/app/javascript/dashboard/i18n/locale/en/contact.json
@@ -606,6 +606,8 @@
},
"COMPOSE_NEW_CONVERSATION": {
+ "TAB_CONVERSATION": "Conversation",
+ "TAB_GROUP": "Group",
"CONTACT_SEARCH": {
"ERROR_MESSAGE": "We couldn’t complete the search. Please try again."
},
diff --git a/app/javascript/dashboard/i18n/locale/en/conversation.json b/app/javascript/dashboard/i18n/locale/en/conversation.json
index 91db5242a..9183a096a 100644
--- a/app/javascript/dashboard/i18n/locale/en/conversation.json
+++ b/app/javascript/dashboard/i18n/locale/en/conversation.json
@@ -43,6 +43,10 @@
"BOT_HANDOFF_ERROR": "Failed to take over the conversation. Please try again.",
"TWILIO_WHATSAPP_CAN_REPLY": "You can only reply to this conversation using a template message due to",
"TWILIO_WHATSAPP_24_HOURS_WINDOW": "24 hour message window restriction",
+ "ANNOUNCEMENT_MODE_BANNER": "Only administrators are allowed to send messages in this group",
+ "GROUP_LEFT_BANNER": "You are no longer part of this group and cannot send messages in it",
+ "GROUPS_DISABLED_BANNER": "Group messages are disabled. Enable full group support (free) to view and send messages.",
+ "GROUPS_DISABLED_CTA": "Learn how to enable (free)",
"OLD_INSTAGRAM_INBOX_REPLY_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. All new messages will show up there. You won’t be able to send messages from this conversation anymore.",
"REPLYING_TO": "You are replying to:",
"REMOVE_SELECTION": "Remove Selection",
@@ -197,6 +201,9 @@
"PRIVATE_MSG_INPUT": "Shift + enter for new line. This will be visible only to Agents",
"MESSAGING_RESTRICTED": "You cannot reply to this conversation",
"MESSAGING_RESTRICTED_WHATSAPP": "You can only reply using a template message due to 24-hour message window restriction",
+ "ANNOUNCEMENT_MODE_RESTRICTED": "Only administrators are allowed to send messages in this group",
+ "GROUP_LEFT_RESTRICTED": "You are no longer part of this group and cannot send messages in it",
+ "GROUPS_DISABLED_RESTRICTED": "Group messages are disabled — enable for free",
"MESSAGE_SIGNATURE_NOT_CONFIGURED": "Message signature is not configured, please configure it in profile settings.",
"COPILOT_MSG_INPUT": "Give copilot additional prompts, or ask anything else... Press enter to send follow-up",
"CLICK_HERE": "Click here to update",
diff --git a/app/javascript/dashboard/i18n/locale/en/groups.json b/app/javascript/dashboard/i18n/locale/en/groups.json
new file mode 100644
index 000000000..24c37f9c6
--- /dev/null
+++ b/app/javascript/dashboard/i18n/locale/en/groups.json
@@ -0,0 +1,126 @@
+{
+ "GROUP": {
+ "SIDEBAR_TITLE": "Group",
+ "INFO": {
+ "HEADER": "Group Info",
+ "MEMBER_COUNT": "{count} members",
+ "MEMBER_LIST_TITLE": "Members",
+ "ADMIN_BADGE": "Admin",
+ "YOU_BADGE": "You",
+ "SYNC_BUTTON": "Sync",
+ "SYNC_SUCCESS": "Group members synced successfully.",
+ "SYNC_ERROR": "Failed to sync group members. Please try again.",
+ "EMPTY_STATE": "No members found."
+ },
+ "FILTER": {
+ "TYPE_LABEL": "Type",
+ "ALL": "All",
+ "INDIVIDUAL": "Individual",
+ "GROUP": "Group"
+ },
+ "CREATE": {
+ "TITLE": "Create New Group",
+ "INBOX_LABEL": "Inbox:",
+ "INBOX_PLACEHOLDER": "Select a inbox",
+ "NAME_LABEL": "Group Name:",
+ "NAME_PLACEHOLDER": "Enter group name",
+ "PARTICIPANTS_LABEL": "Participants:",
+ "PARTICIPANTS_PLACEHOLDER": "Search contacts to add",
+ "NAME_REQUIRED": "Group name is required",
+ "PARTICIPANTS_REQUIRED": "Add at least one participant",
+ "SUBMIT_BUTTON": "Create Group",
+ "SUCCESS_MESSAGE": "Group created successfully.",
+ "ERROR_MESSAGE": "Failed to create group. Please try again.",
+ "GROUPS_DISABLED": "Group creation is disabled. Enable WhatsApp group support (free) to create groups.",
+ "GROUPS_DISABLED_CTA": "Learn how to enable (free)"
+ },
+ "METADATA": {
+ "EDIT_NAME_LABEL": "Group Name",
+ "EDIT_NAME_PLACEHOLDER": "Enter group name",
+ "EDIT_DESCRIPTION_LABEL": "Description",
+ "EDIT_DESCRIPTION_PLACEHOLDER": "Enter group description",
+ "EDIT_AVATAR_LABEL": "Group Photo",
+ "SAVE_SUCCESS": "Group info updated successfully.",
+ "SAVE_ERROR": "Failed to update group info. Please try again."
+ },
+ "INVITE": {
+ "SECTION_TITLE": "Invite Link",
+ "COPY_BUTTON": "Copy Link",
+ "COPY_INVITE_LINK": "Copy Invite Link",
+ "COPY_SUCCESS": "Invite link copied to clipboard.",
+ "FETCH_ERROR": "Failed to load invite link."
+ },
+ "MEMBERS": {
+ "ADD_BUTTON": "Add Member",
+ "ADDING": "Adding member...",
+ "ADD_SUCCESS": "Member added successfully.",
+ "ADD_ERROR": "Failed to add member. Please try again.",
+ "REMOVE_BUTTON": "Remove",
+ "REMOVING": "Removing member...",
+ "REMOVE_SUCCESS": "Member removed successfully.",
+ "REMOVE_ERROR": "Failed to remove member. Please try again.",
+ "PROMOTE_BUTTON": "Promote to Admin",
+ "PROMOTING": "Promoting member...",
+ "PROMOTE_SUCCESS": "Member promoted to admin.",
+ "PROMOTE_ERROR": "Failed to promote member. Please try again.",
+ "DEMOTE_BUTTON": "Demote to Member",
+ "DEMOTING": "Demoting member...",
+ "DEMOTE_SUCCESS": "Member demoted to member.",
+ "DEMOTE_ERROR": "Failed to demote member. Please try again.",
+ "GROUP_CREATOR_NOT_MODIFIABLE": "This member is the group creator and cannot be removed or demoted."
+ },
+ "JOIN_REQUESTS": {
+ "SECTION_TITLE": "Pending Requests",
+ "PENDING_COUNT": "{count} pending",
+ "APPROVE_BUTTON": "Approve",
+ "REJECT_BUTTON": "Reject",
+ "PROCESSING": "Processing request...",
+ "APPROVE_SUCCESS": "Join request approved.",
+ "REJECT_SUCCESS": "Join request rejected.",
+ "ACTION_ERROR": "Failed to process join request. Please try again."
+ },
+ "MENTION": {
+ "DROPDOWN_HEADER": "Group Members",
+ "EVERYONE": "Everyone",
+ "EVERYONE_DESCRIPTION": "Notify all members"
+ },
+ "BAILEYS_OPTIONS": {
+ "MEMBERS_CAN": "Group members can:",
+ "ADMINS_CAN": "Group admins can:",
+ "EDIT_GROUP_SETTINGS": "Edit group settings",
+ "EDIT_GROUP_SETTINGS_DESCRIPTION": "Includes group name, image, description, disappearing messages duration, advanced conversation privacy, and allows pinning and saving messages in the conversation or reverting this action.",
+ "SEND_MESSAGES": "Send new messages",
+ "ADD_MEMBERS": "Add members",
+ "RESET_INVITE_LINK": "Reset invite link",
+ "RESET_INVITE_LINK_SUCCESS": "Invite link has been reset successfully.",
+ "RESET_INVITE_LINK_ERROR": "Failed to reset invite link. Please try again.",
+ "RESETTING_INVITE_LINK": "Resetting...",
+ "DISABLE_ADD_MEMBERS_CONFIRM_TITLE": "Restrict member additions?",
+ "DISABLE_ADD_MEMBERS_CONFIRM_DESCRIPTION": "By disabling this option, only admins will be able to add members and the current invite link will be reset. Do you want to continue?",
+ "DISABLE_ADD_MEMBERS_CONFIRM_YES": "Confirm",
+ "DISABLE_ADD_MEMBERS_CONFIRM_NO": "Cancel",
+ "APPROVE_MEMBERS": "Approve new members",
+ "APPROVE_MEMBERS_DESCRIPTION": "While this option is enabled, admins must approve new members joining the group."
+ },
+ "SETTINGS": {
+ "SECTION_TITLE": "Group Settings",
+ "ANNOUNCEMENT_MODE": "Announcement Mode",
+ "ANNOUNCEMENT_MODE_DESCRIPTION": "Only admins can send messages",
+ "LOCKED_MODE": "Locked Mode",
+ "LOCKED_MODE_DESCRIPTION": "Only admins can edit group info",
+ "JOIN_APPROVAL": "Admin Approval to Join",
+ "JOIN_APPROVAL_DESCRIPTION": "Admins must approve new members",
+ "ADVANCED_OPTIONS": "Advanced Options",
+ "GROUP_LEFT_BANNER": "You are no longer a member of this group",
+ "LEAVE_GROUP": "Leave Group",
+ "LEAVING": "Leaving group...",
+ "LEAVE_CONFIRM": "Are you sure you want to leave this group?",
+ "LEAVE_CONFIRM_YES": "Leave",
+ "LEAVE_CONFIRM_NO": "Cancel",
+ "LEAVE_SUCCESS": "You have left the group.",
+ "LEAVE_ERROR": "Failed to leave the group. Please try again.",
+ "UPDATE_SUCCESS": "Group setting updated successfully.",
+ "UPDATE_ERROR": "Failed to update group setting. Please try again."
+ }
+ }
+}
diff --git a/app/javascript/dashboard/i18n/locale/en/index.js b/app/javascript/dashboard/i18n/locale/en/index.js
index 98982e6bb..63aed743e 100644
--- a/app/javascript/dashboard/i18n/locale/en/index.js
+++ b/app/javascript/dashboard/i18n/locale/en/index.js
@@ -1,4 +1,5 @@
import advancedFilters from './advancedFilters.json';
+import groups from './groups.json';
import agentBots from './agentBots.json';
import agentMgmt from './agentMgmt.json';
import attributesMgmt from './attributesMgmt.json';
@@ -43,6 +44,7 @@ import yearInReview from './yearInReview.json';
export default {
...advancedFilters,
+ ...groups,
...agentBots,
...agentMgmt,
...attributesMgmt,
diff --git a/app/javascript/dashboard/i18n/locale/pt_BR/advancedFilters.json b/app/javascript/dashboard/i18n/locale/pt_BR/advancedFilters.json
index 7958f4c14..e8fd5d3b0 100644
--- a/app/javascript/dashboard/i18n/locale/pt_BR/advancedFilters.json
+++ b/app/javascript/dashboard/i18n/locale/pt_BR/advancedFilters.json
@@ -62,7 +62,8 @@
"CUSTOM_ATTRIBUTE_LINK": "Link",
"CUSTOM_ATTRIBUTE_CHECKBOX": "Checkbox",
"CREATED_AT": "Criado em",
- "LAST_ACTIVITY": "Última atividade"
+ "LAST_ACTIVITY": "Última atividade",
+ "GROUP_TYPE": "Tipo de conversa"
},
"ERRORS": {
"VALUE_REQUIRED": "Valor obrigatório",
diff --git a/app/javascript/dashboard/i18n/locale/pt_BR/contact.json b/app/javascript/dashboard/i18n/locale/pt_BR/contact.json
index 0ef030782..821a79fe7 100644
--- a/app/javascript/dashboard/i18n/locale/pt_BR/contact.json
+++ b/app/javascript/dashboard/i18n/locale/pt_BR/contact.json
@@ -603,6 +603,8 @@
}
},
"COMPOSE_NEW_CONVERSATION": {
+ "TAB_CONVERSATION": "Conversa",
+ "TAB_GROUP": "Grupo",
"CONTACT_SEARCH": {
"ERROR_MESSAGE": "Não foi possível completar a pesquisa. Por favor, tente novamente."
},
diff --git a/app/javascript/dashboard/i18n/locale/pt_BR/conversation.json b/app/javascript/dashboard/i18n/locale/pt_BR/conversation.json
index a11d85947..f1fcce9f1 100644
--- a/app/javascript/dashboard/i18n/locale/pt_BR/conversation.json
+++ b/app/javascript/dashboard/i18n/locale/pt_BR/conversation.json
@@ -43,6 +43,10 @@
"BOT_HANDOFF_ERROR": "Falha ao resolver conversas. Por favor, tente novamente.",
"TWILIO_WHATSAPP_CAN_REPLY": "Você só pode responder a esta conversa usando um modelo de mensagem devido a",
"TWILIO_WHATSAPP_24_HOURS_WINDOW": "Restrições de janela de mensagem de 24 horas",
+ "ANNOUNCEMENT_MODE_BANNER": "Apenas administradores têm permissão para enviar mensagens neste grupo",
+ "GROUP_LEFT_BANNER": "Você não faz mais parte deste grupo e não pode enviar mensagens nele",
+ "GROUPS_DISABLED_BANNER": "As mensagens de grupo estão desativadas. Ative o suporte completo a grupos (gratuito) para ver e enviar mensagens.",
+ "GROUPS_DISABLED_CTA": "Saiba como ativar (gratuito)",
"OLD_INSTAGRAM_INBOX_REPLY_BANNER": "Esta conta do Instagram foi migrada para a nova caixa de entrada do canal do Instagram. Todas as novas mensagens serão mostradas lá. Você não poderá mais enviar mensagens desta conversa.",
"REPLYING_TO": "Você está respondendo a:",
"REMOVE_SELECTION": "Remover seleção",
@@ -192,14 +196,17 @@
"PRIVATE_MSG_INPUT": "A mensagem será visível apenas para agentes",
"MESSAGE_SIGNATURE_NOT_CONFIGURED": "A assinatura da mensagem não está configurada. Por favor, configure-a nas configurações do perfil.",
"CLICK_HERE": "Clique aqui para atualizar",
- "WHATSAPP_TEMPLATES": "Templates do Whatsapp"
+ "WHATSAPP_TEMPLATES": "Templates do Whatsapp",
+ "ANNOUNCEMENT_MODE_RESTRICTED": "Apenas administradores têm permissão para enviar mensagens neste grupo",
+ "GROUP_LEFT_RESTRICTED": "Você não faz mais parte deste grupo e não pode enviar mensagens nele",
+ "GROUPS_DISABLED_RESTRICTED": "As mensagens de grupo estão desativadas — ative gratuitamente"
},
"REPLYBOX": {
"REPLY": "Responder",
"PRIVATE_NOTE": "Mensagem Privada",
"SEND": "Enviar",
"CREATE": "Enviar",
- "INSERT_READ_MORE": "Saiba mais",
+ "INSERT_READ_MORE": "Ler mais",
"DISMISS_REPLY": "Dispensar resposta",
"REPLYING_TO": "Respondendo a:",
"TIP_EMOJI_ICON": "Mostrar seletor de emoji",
diff --git a/app/javascript/dashboard/i18n/locale/pt_BR/groups.json b/app/javascript/dashboard/i18n/locale/pt_BR/groups.json
new file mode 100644
index 000000000..816202c9c
--- /dev/null
+++ b/app/javascript/dashboard/i18n/locale/pt_BR/groups.json
@@ -0,0 +1,126 @@
+{
+ "GROUP": {
+ "SIDEBAR_TITLE": "Grupo",
+ "INFO": {
+ "HEADER": "Informações do Grupo",
+ "MEMBER_COUNT": "{count} membros",
+ "MEMBER_LIST_TITLE": "Membros",
+ "ADMIN_BADGE": "Admin",
+ "YOU_BADGE": "Você",
+ "SYNC_BUTTON": "Sincronizar",
+ "SYNC_SUCCESS": "Membros do grupo sincronizados com sucesso.",
+ "SYNC_ERROR": "Falha ao sincronizar membros do grupo. Por favor, tente novamente.",
+ "EMPTY_STATE": "Nenhum membro encontrado."
+ },
+ "FILTER": {
+ "TYPE_LABEL": "Tipo",
+ "ALL": "Todos",
+ "INDIVIDUAL": "Individual",
+ "GROUP": "Grupo"
+ },
+ "CREATE": {
+ "TITLE": "Criar Novo Grupo",
+ "INBOX_LABEL": "Caixa de entrada:",
+ "INBOX_PLACEHOLDER": "Selecione uma caixa de entrada",
+ "NAME_LABEL": "Nome do Grupo:",
+ "NAME_PLACEHOLDER": "Digite o nome do grupo",
+ "PARTICIPANTS_LABEL": "Participantes:",
+ "PARTICIPANTS_PLACEHOLDER": "Pesquisar contatos para adicionar",
+ "NAME_REQUIRED": "O nome do grupo é obrigatório",
+ "PARTICIPANTS_REQUIRED": "Adicione pelo menos um participante",
+ "SUBMIT_BUTTON": "Criar Grupo",
+ "SUCCESS_MESSAGE": "Grupo criado com sucesso.",
+ "ERROR_MESSAGE": "Falha ao criar grupo. Por favor, tente novamente.",
+ "GROUPS_DISABLED": "A criação de grupos está desativada. Ative o suporte a grupos do WhatsApp (gratuito) para criar grupos.",
+ "GROUPS_DISABLED_CTA": "Saiba como ativar (gratuito)"
+ },
+ "METADATA": {
+ "EDIT_NAME_LABEL": "Nome do Grupo",
+ "EDIT_NAME_PLACEHOLDER": "Digite o nome do grupo",
+ "EDIT_DESCRIPTION_LABEL": "Descrição",
+ "EDIT_DESCRIPTION_PLACEHOLDER": "Digite a descrição do grupo",
+ "EDIT_AVATAR_LABEL": "Foto do Grupo",
+ "SAVE_SUCCESS": "Informações do grupo atualizadas com sucesso.",
+ "SAVE_ERROR": "Falha ao atualizar informações do grupo. Por favor, tente novamente."
+ },
+ "INVITE": {
+ "SECTION_TITLE": "Link de Convite",
+ "COPY_BUTTON": "Copiar Link",
+ "COPY_INVITE_LINK": "Copiar Link de Convite",
+ "COPY_SUCCESS": "Link de convite copiado para a área de transferência.",
+ "FETCH_ERROR": "Falha ao carregar o link de convite."
+ },
+ "MEMBERS": {
+ "ADD_BUTTON": "Adicionar Membro",
+ "ADDING": "Adicionando membro...",
+ "ADD_SUCCESS": "Membro adicionado com sucesso.",
+ "ADD_ERROR": "Falha ao adicionar membro. Por favor, tente novamente.",
+ "REMOVE_BUTTON": "Remover",
+ "REMOVING": "Removendo membro...",
+ "REMOVE_SUCCESS": "Membro removido com sucesso.",
+ "REMOVE_ERROR": "Falha ao remover membro. Por favor, tente novamente.",
+ "PROMOTE_BUTTON": "Promover a Admin",
+ "PROMOTING": "Promovendo membro...",
+ "PROMOTE_SUCCESS": "Membro promovido a admin.",
+ "PROMOTE_ERROR": "Falha ao promover membro. Por favor, tente novamente.",
+ "DEMOTE_BUTTON": "Rebaixar para Membro",
+ "DEMOTING": "Rebaixando membro...",
+ "DEMOTE_SUCCESS": "Membro rebaixado para membro.",
+ "DEMOTE_ERROR": "Falha ao rebaixar membro. Por favor, tente novamente.",
+ "GROUP_CREATOR_NOT_MODIFIABLE": "Este membro é o criador do grupo e não pode ser removido ou rebaixado."
+ },
+ "JOIN_REQUESTS": {
+ "SECTION_TITLE": "Solicitações Pendentes",
+ "PENDING_COUNT": "{count} pendentes",
+ "APPROVE_BUTTON": "Aprovar",
+ "REJECT_BUTTON": "Rejeitar",
+ "PROCESSING": "Processando solicitação...",
+ "APPROVE_SUCCESS": "Solicitação de entrada aprovada.",
+ "REJECT_SUCCESS": "Solicitação de entrada rejeitada.",
+ "ACTION_ERROR": "Falha ao processar solicitação de entrada. Por favor, tente novamente."
+ },
+ "MENTION": {
+ "DROPDOWN_HEADER": "Membros do Grupo",
+ "EVERYONE": "Todos",
+ "EVERYONE_DESCRIPTION": "Notificar todos os membros"
+ },
+ "BAILEYS_OPTIONS": {
+ "MEMBERS_CAN": "Os membros do grupo podem:",
+ "ADMINS_CAN": "Os admins do grupo podem:",
+ "EDIT_GROUP_SETTINGS": "Editar configurações do grupo",
+ "EDIT_GROUP_SETTINGS_DESCRIPTION": "Inclui o nome do grupo, a imagem, a descrição, a duração das mensagens temporárias, a privacidade avançada da conversa e permite fixar e salvar mensagens na conversa ou reverter essa ação.",
+ "SEND_MESSAGES": "Enviar novas mensagens",
+ "ADD_MEMBERS": "Adicionar membros",
+ "RESET_INVITE_LINK": "Redefinir link de convite",
+ "RESET_INVITE_LINK_SUCCESS": "O link de convite foi redefinido com sucesso.",
+ "RESET_INVITE_LINK_ERROR": "Falha ao redefinir o link de convite. Por favor, tente novamente.",
+ "RESETTING_INVITE_LINK": "Redefinindo...",
+ "DISABLE_ADD_MEMBERS_CONFIRM_TITLE": "Restringir adição de membros?",
+ "DISABLE_ADD_MEMBERS_CONFIRM_DESCRIPTION": "Ao desabilitar esta opção, apenas admins poderão adicionar membros e o link de convite atual será redefinido. Deseja continuar?",
+ "DISABLE_ADD_MEMBERS_CONFIRM_YES": "Confirmar",
+ "DISABLE_ADD_MEMBERS_CONFIRM_NO": "Cancelar",
+ "APPROVE_MEMBERS": "Aprovar novos membros",
+ "APPROVE_MEMBERS_DESCRIPTION": "Enquanto essa opção estiver ativada, os admins deverão aprovar a entrada de membros no grupo."
+ },
+ "SETTINGS": {
+ "SECTION_TITLE": "Configurações do Grupo",
+ "ANNOUNCEMENT_MODE": "Modo Anúncio",
+ "ANNOUNCEMENT_MODE_DESCRIPTION": "Apenas admins podem enviar mensagens",
+ "LOCKED_MODE": "Modo Trancado",
+ "LOCKED_MODE_DESCRIPTION": "Apenas admins podem editar informações do grupo",
+ "JOIN_APPROVAL": "Aprovação do Admin para Entrar",
+ "JOIN_APPROVAL_DESCRIPTION": "Admins devem aprovar novos membros",
+ "ADVANCED_OPTIONS": "Opções Avançadas",
+ "GROUP_LEFT_BANNER": "Você não é mais membro deste grupo",
+ "LEAVE_GROUP": "Sair do Grupo",
+ "LEAVING": "Saindo do grupo...",
+ "LEAVE_CONFIRM": "Tem certeza de que deseja sair deste grupo?",
+ "LEAVE_CONFIRM_YES": "Sair",
+ "LEAVE_CONFIRM_NO": "Cancelar",
+ "LEAVE_SUCCESS": "Você saiu do grupo.",
+ "LEAVE_ERROR": "Falha ao sair do grupo. Por favor, tente novamente.",
+ "UPDATE_SUCCESS": "Configuração do grupo atualizada com sucesso.",
+ "UPDATE_ERROR": "Falha ao atualizar configuração do grupo. Por favor, tente novamente."
+ }
+ }
+}
diff --git a/app/javascript/dashboard/i18n/locale/pt_BR/index.js b/app/javascript/dashboard/i18n/locale/pt_BR/index.js
index 7a1f910f4..1ca6d217c 100644
--- a/app/javascript/dashboard/i18n/locale/pt_BR/index.js
+++ b/app/javascript/dashboard/i18n/locale/pt_BR/index.js
@@ -1,4 +1,5 @@
import advancedFilters from './advancedFilters.json';
+import groups from './groups.json';
import agentBots from './agentBots.json';
import agentMgmt from './agentMgmt.json';
import attributesMgmt from './attributesMgmt.json';
@@ -39,6 +40,7 @@ import whatsappTemplates from './whatsappTemplates.json';
export default {
...advancedFilters,
+ ...groups,
...agentBots,
...agentMgmt,
...attributesMgmt,
diff --git a/app/javascript/dashboard/i18n/locale/pt_BR/integrations.json b/app/javascript/dashboard/i18n/locale/pt_BR/integrations.json
index e18b15148..a36f156e7 100644
--- a/app/javascript/dashboard/i18n/locale/pt_BR/integrations.json
+++ b/app/javascript/dashboard/i18n/locale/pt_BR/integrations.json
@@ -155,7 +155,11 @@
"EXPAND": "Expandir",
"MAKE_FRIENDLY": "Alterar o tom de mensagem para amigável",
"MAKE_FORMAL": "Usar tom formal",
- "SIMPLIFY": "Simplificar"
+ "SIMPLIFY": "Simplificar",
+ "CONFIDENT": "Usar tom confiante",
+ "PROFESSIONAL": "Usar tom profissional",
+ "CASUAL": "Usar tom casual",
+ "STRAIGHTFORWARD": "Usar tom direto"
},
"ASSISTANCE_MODAL": {
"DRAFT_TITLE": "Conteúdo do rascunho",
@@ -186,7 +190,10 @@
"TITLE": "Tom",
"OPTIONS": {
"PROFESSIONAL": "Profissional",
- "FRIENDLY": "Amigável"
+ "FRIENDLY": "Amigável",
+ "CASUAL": "Casual",
+ "STRAIGHTFORWARD": "Direto",
+ "CONFIDENT": "Confiante"
}
},
"BUTTONS": {
diff --git a/app/javascript/dashboard/i18n/locale/pt_BR/search.json b/app/javascript/dashboard/i18n/locale/pt_BR/search.json
index 2cb8f25c2..3160501f4 100644
--- a/app/javascript/dashboard/i18n/locale/pt_BR/search.json
+++ b/app/javascript/dashboard/i18n/locale/pt_BR/search.json
@@ -26,7 +26,7 @@
"MOST_RECENT": "Mais recentes",
"EMPTY_STATE_DEFAULT": "Procurar por ID de conversa, e-mail, número de telefone, mensagens para melhores resultados de busca.",
"BOT_LABEL": "Robôs",
- "READ_MORE": "Saiba mais",
+ "READ_MORE": "Ler mais",
"READ_LESS": "Ler menos",
"WROTE": "escreveu:",
"FROM": "De",
diff --git a/app/javascript/dashboard/i18n/locale/pt_BR/settings.json b/app/javascript/dashboard/i18n/locale/pt_BR/settings.json
index e6861c257..a015ccf93 100644
--- a/app/javascript/dashboard/i18n/locale/pt_BR/settings.json
+++ b/app/javascript/dashboard/i18n/locale/pt_BR/settings.json
@@ -159,7 +159,7 @@
"CONDITION_TWO": "Enviar alertas a cada 30 segundos até que todas as conversas atribuídas sejam lidas"
},
"SOUND_PERMISSION_ERROR": "A reprodução automática está desativada no seu navegador. Para ouvir alertas automaticamente, habilite a permissão de som nas configurações do seu navegador ou interaja com a página.",
- "READ_MORE": "Saiba mais"
+ "READ_MORE": "Ler mais"
},
"EMAIL_NOTIFICATIONS_SECTION": {
"TITLE": "Notificações por e-mail",
@@ -408,7 +408,9 @@
"INFO_SHORT": "Marcar off-line automaticamente quando não estiver usando o aplicativo."
},
"DOCS": "Ler documentos",
- "SECURITY": "Segurança"
+ "SECURITY": "Segurança",
+ "CAPTAIN_AI": "Captain",
+ "CONVERSATION_WORKFLOW": "Fluxo de Conversa"
},
"BILLING_SETTINGS": {
"TITLE": "Cobrança",
@@ -822,5 +824,57 @@
"CONFIRM_BUTTON_LABEL": "Excluir",
"CANCEL_BUTTON_LABEL": "Cancelar"
}
+ },
+ "CONVERSATION_WORKFLOW": {
+ "INDEX": {
+ "HEADER": {
+ "TITLE": "Fluxos de Conversa",
+ "DESCRIPTION": "Configure regras e campos obrigatórios para resolução de conversas."
+ }
+ },
+ "REQUIRED_ATTRIBUTES": {
+ "TITLE": "Atributos obrigatórios na resolução",
+ "DESCRIPTION": "Ao resolver uma conversa, os agentes serão solicitados a preencher esses atributos se ainda não o fizeram.",
+ "NO_ATTRIBUTES": "Nenhum atributo adicionado ainda",
+ "ADD": {
+ "TITLE": "Adicionar Atributos",
+ "SEARCH_PLACEHOLDER": "Buscar atributos"
+ },
+ "SAVE": {
+ "SUCCESS": "Atributos obrigatórios atualizados",
+ "ERROR": "Não foi possível atualizar os atributos obrigatórios, tente novamente"
+ },
+ "MODAL": {
+ "TITLE": "Resolver conversa",
+ "DESCRIPTION": "Por favor, preencha os seguintes atributos personalizados antes de resolver esta conversa",
+ "ACTIONS": {
+ "RESOLVE": "Resolver conversa",
+ "CANCEL": "Cancelar"
+ },
+ "PLACEHOLDERS": {
+ "TEXT": "Escreva uma nota...",
+ "NUMBER": "Insira um número",
+ "LINK": "Adicione um link",
+ "DATE": "Escolha uma data",
+ "LIST": "Selecione uma opção"
+ },
+ "CHECKBOX": {
+ "YES": "Sim",
+ "NO": "Não"
+ }
+ },
+ "PAYWALL": {
+ "TITLE": "Faça upgrade para usar atributos obrigatórios",
+ "AVAILABLE_ON": "O recurso de atributos obrigatórios de conversa está disponível nos planos Business e Enterprise.",
+ "UPGRADE_PROMPT": "Faça upgrade do seu plano para solicitar que os agentes preencham atributos obrigatórios antes da resolução da conversa.",
+ "UPGRADE_NOW": "Fazer upgrade agora",
+ "CANCEL_ANYTIME": "Você pode alterar ou cancelar seu plano a qualquer momento"
+ },
+ "ENTERPRISE_PAYWALL": {
+ "AVAILABLE_ON": "O recurso de atributos obrigatórios de conversa está disponível nos planos pagos.",
+ "UPGRADE_PROMPT": "Faça upgrade para um plano pago para exigir atributos obrigatórios antes da resolução da conversa.",
+ "ASK_ADMIN": "Entre em contato com seu administrador para o upgrade."
+ }
+ }
}
}
diff --git a/app/javascript/dashboard/routes/dashboard/conversation/ContactPanel.vue b/app/javascript/dashboard/routes/dashboard/conversation/ContactPanel.vue
index c148625fa..ef9e6f1d3 100644
--- a/app/javascript/dashboard/routes/dashboard/conversation/ContactPanel.vue
+++ b/app/javascript/dashboard/routes/dashboard/conversation/ContactPanel.vue
@@ -14,6 +14,7 @@ import ContactConversations from './ContactConversations.vue';
import ConversationAction from './ConversationAction.vue';
import ConversationParticipant from './ConversationParticipant.vue';
import ContactInfo from './contact/ContactInfo.vue';
+import GroupContactInfo from './contact/GroupContactInfo.vue';
import ContactNotes from './contact/ContactNotes.vue';
import ScheduledMessages from './scheduledMessages/ScheduledMessages.vue';
import ConversationInfo from './ConversationInfo.vue';
@@ -88,6 +89,14 @@ const conversationAdditionalAttributes = computed(
);
const channelType = computed(() => currentChat.value.meta?.channel);
+const isGroupConversation = computed(
+ () => currentChat.value.group_type === 'group'
+);
+const sidebarTitle = computed(() =>
+ isGroupConversation.value
+ ? 'GROUP.SIDEBAR_TITLE'
+ : 'CONVERSATION.SIDEBAR.CONTACT'
+);
const contactGetter = useMapGetter('contacts/getContact');
const contactId = computed(() => currentChat.value.meta?.sender?.id);
@@ -102,9 +111,16 @@ const getContactDetails = () => {
}
};
+const triggerGroupSync = () => {
+ if (isGroupConversation.value && contactId.value) {
+ store.dispatch('groupMembers/sync', { contactId: contactId.value });
+ }
+};
+
watch(contactId, (newContactId, prevContactId) => {
if (newContactId && newContactId !== prevContactId) {
getContactDetails();
+ triggerGroupSync();
}
});
@@ -125,6 +141,7 @@ const closeContactPanel = () => {
onMounted(() => {
conversationSidebarItems.value = conversationSidebarItemsOrder.value;
getContactDetails();
+ triggerGroupSync();
store.dispatch('attributes/get', 0);
// Load integrations to ensure linear integration state is available
store.dispatch('integrations/get', 'linear');
@@ -134,10 +151,11 @@ onMounted(() => {
-
+
+
+import { computed, ref } from 'vue';
+import { useStore } from 'dashboard/composables/store';
+import { useAlert } from 'dashboard/composables';
+import { useI18n } from 'vue-i18n';
+import GroupMembersAPI from 'dashboard/api/groupMembers';
+import Switch from 'dashboard/components-next/switch/Switch.vue';
+import NextButton from 'dashboard/components-next/button/Button.vue';
+import ConfirmationModal from 'dashboard/components/widgets/modal/ConfirmationModal.vue';
+
+const props = defineProps({
+ contact: {
+ type: Object,
+ default: () => ({}),
+ },
+ isAdmin: {
+ type: Boolean,
+ default: false,
+ },
+});
+
+const store = useStore();
+const { t } = useI18n();
+
+// NOTE: Computed: read from additional_attributes (stored as restriction-active booleans)
+// NOTE: WhatsApp shows "members CAN do X" so we invert restrict/announce for display
+const canEditGroupSettings = computed(
+ () => props.contact.additional_attributes?.restrict !== true
+);
+const canSendMessages = computed(
+ () => props.contact.additional_attributes?.announce !== true
+);
+const canAddMembers = computed(
+ () => props.contact.additional_attributes?.member_add_mode !== false
+);
+const isJoinApprovalEnabled = computed(
+ () => props.contact.additional_attributes?.join_approval_mode === true
+);
+
+const isTogglingRestrict = ref(false);
+const isTogglingAnnounce = ref(false);
+const isTogglingMemberAdd = ref(false);
+const isTogglingJoinApproval = ref(false);
+const isResettingInviteLink = ref(false);
+const confirmDialog = ref(null);
+
+const updateContactAttribute = async (key, value) => {
+ await store.dispatch('contacts/update', {
+ id: props.contact.id,
+ additional_attributes: {
+ ...props.contact.additional_attributes,
+ [key]: value,
+ },
+ });
+};
+
+const toggleEditGroupSettings = async () => {
+ isTogglingRestrict.value = true;
+ try {
+ // NOTE: restrict=true means members CANNOT edit; flip to the opposite of current
+ const currentValue = props.contact.additional_attributes?.restrict === true;
+ const newValue = !currentValue;
+ await GroupMembersAPI.updateGroupProperty(props.contact.id, {
+ property: 'restrict',
+ enabled: newValue,
+ });
+ await updateContactAttribute('restrict', newValue);
+ useAlert(t('GROUP.SETTINGS.UPDATE_SUCCESS'));
+ } catch {
+ useAlert(t('GROUP.SETTINGS.UPDATE_ERROR'));
+ } finally {
+ isTogglingRestrict.value = false;
+ }
+};
+
+const toggleSendMessages = async () => {
+ isTogglingAnnounce.value = true;
+ try {
+ // NOTE: announce=true means only admins can send; flip to the opposite of current
+ const currentValue = props.contact.additional_attributes?.announce === true;
+ const newValue = !currentValue;
+ await GroupMembersAPI.updateGroupProperty(props.contact.id, {
+ property: 'announce',
+ enabled: newValue,
+ });
+ await updateContactAttribute('announce', newValue);
+ useAlert(t('GROUP.SETTINGS.UPDATE_SUCCESS'));
+ } catch {
+ useAlert(t('GROUP.SETTINGS.UPDATE_ERROR'));
+ } finally {
+ isTogglingAnnounce.value = false;
+ }
+};
+
+const resetInviteLink = async () => {
+ isResettingInviteLink.value = true;
+ try {
+ const { data } = await GroupMembersAPI.revokeInviteLink(props.contact.id);
+ if (data.invite_code) {
+ store.dispatch('contacts/updateContact', {
+ ...props.contact,
+ additional_attributes: {
+ ...props.contact.additional_attributes,
+ invite_code: data.invite_code,
+ },
+ });
+ }
+ useAlert(t('GROUP.BAILEYS_OPTIONS.RESET_INVITE_LINK_SUCCESS'));
+ } catch {
+ useAlert(t('GROUP.BAILEYS_OPTIONS.RESET_INVITE_LINK_ERROR'));
+ } finally {
+ isResettingInviteLink.value = false;
+ }
+};
+
+const toggleAddMembers = async () => {
+ // NOTE: member_add_mode: true = all members can add, false = only admins
+ const currentValue =
+ props.contact.additional_attributes?.member_add_mode !== false;
+ const newValue = !currentValue;
+
+ // When disabling (restricting to admins only), confirm and also reset invite link
+ if (!newValue) {
+ const confirmed = await confirmDialog.value.showConfirmation();
+ if (!confirmed) return;
+ }
+
+ isTogglingMemberAdd.value = true;
+ try {
+ await GroupMembersAPI.updateGroupProperty(props.contact.id, {
+ property: 'member_add_mode',
+ enabled: newValue,
+ });
+
+ // Also revoke invite link when restricting member additions
+ if (!newValue) {
+ await GroupMembersAPI.revokeInviteLink(props.contact.id);
+ }
+
+ await updateContactAttribute('member_add_mode', newValue);
+ useAlert(t('GROUP.SETTINGS.UPDATE_SUCCESS'));
+ } catch {
+ useAlert(t('GROUP.SETTINGS.UPDATE_ERROR'));
+ } finally {
+ isTogglingMemberAdd.value = false;
+ }
+};
+
+const toggleJoinApproval = async () => {
+ isTogglingJoinApproval.value = true;
+ try {
+ // NOTE: join_approval_mode=true means admins must approve; flip to opposite
+ const currentValue =
+ props.contact.additional_attributes?.join_approval_mode === true;
+ const newValue = !currentValue;
+ await GroupMembersAPI.updateGroupProperty(props.contact.id, {
+ property: 'join_approval_mode',
+ enabled: newValue,
+ });
+ await updateContactAttribute('join_approval_mode', newValue);
+ useAlert(t('GROUP.SETTINGS.UPDATE_SUCCESS'));
+ } catch {
+ useAlert(t('GROUP.SETTINGS.UPDATE_ERROR'));
+ } finally {
+ isTogglingJoinApproval.value = false;
+ }
+};
+
+
+
+
+
+
+ {{ t('GROUP.BAILEYS_OPTIONS.MEMBERS_CAN') }}
+
+
+
+
+
+ {{ t('GROUP.BAILEYS_OPTIONS.EDIT_GROUP_SETTINGS') }}
+
+
+ {{ t('GROUP.BAILEYS_OPTIONS.EDIT_GROUP_SETTINGS_DESCRIPTION') }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('GROUP.BAILEYS_OPTIONS.SEND_MESSAGES') }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('GROUP.BAILEYS_OPTIONS.ADD_MEMBERS') }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('GROUP.BAILEYS_OPTIONS.RESET_INVITE_LINK') }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('GROUP.BAILEYS_OPTIONS.ADMINS_CAN') }}
+
+
+
+
+
+ {{ t('GROUP.BAILEYS_OPTIONS.APPROVE_MEMBERS') }}
+
+
+ {{ t('GROUP.BAILEYS_OPTIONS.APPROVE_MEMBERS_DESCRIPTION') }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/conversation/contact/GroupContactInfo.vue b/app/javascript/dashboard/routes/dashboard/conversation/contact/GroupContactInfo.vue
new file mode 100644
index 000000000..b162cacc3
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/conversation/contact/GroupContactInfo.vue
@@ -0,0 +1,1127 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ contact.name }}
+
+
+
+
+ {{ t('GROUP.INFO.MEMBER_COUNT', { count: memberCount }) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{
+ contactDescription ||
+ t('GROUP.METADATA.EDIT_DESCRIPTION_PLACEHOLDER')
+ }}
+
+
+
+
+
+
+
+
+
+ {{ t('GROUP.SETTINGS.GROUP_LEFT_BANNER') }}
+
+
+
+
+
+
+ {{ t('GROUP.INFO.MEMBER_LIST_TITLE') }}
+
+
+
+
+
+
+
+
+
0"
+ />
+
+
+
+ -
+
+ {{ result.name }}
+
+ {{ result.phone_number }}
+
+
+
+
+
+
+
+
+
+
+ {{ t('GROUP.INFO.EMPTY_STATE') }}
+
+
+
+
+
+
+
+
+
+ {{ t('GROUP.JOIN_REQUESTS.SECTION_TITLE') }}
+
+ {{
+ t('GROUP.JOIN_REQUESTS.PENDING_COUNT', {
+ count: pendingRequests.length,
+ })
+ }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('GROUP.SETTINGS.LEAVE_CONFIRM') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/store/index.js b/app/javascript/dashboard/store/index.js
index 1b150c916..af17fca8d 100755
--- a/app/javascript/dashboard/store/index.js
+++ b/app/javascript/dashboard/store/index.js
@@ -32,6 +32,7 @@ import customViews from './modules/customViews';
import dashboardApps from './modules/dashboardApps';
import draftMessages from './modules/draftMessages';
import globalConfig from 'shared/store/globalConfig';
+import groupMembers from './modules/groupMembers';
import inboxAssignableAgents from './modules/inboxAssignableAgents';
import inboxes from './modules/inboxes';
import inboxMembers from './modules/inboxMembers';
@@ -96,6 +97,7 @@ export default createStore({
dashboardApps,
draftMessages,
globalConfig,
+ groupMembers,
inboxAssignableAgents,
inboxes,
inboxMembers,
diff --git a/app/javascript/dashboard/store/modules/contacts/actions.js b/app/javascript/dashboard/store/modules/contacts/actions.js
index d7f87b776..858432d7d 100644
--- a/app/javascript/dashboard/store/modules/contacts/actions.js
+++ b/app/javascript/dashboard/store/modules/contacts/actions.js
@@ -17,7 +17,7 @@ const buildContactFormData = contactParams => {
formData.append(key, contactProperties[key]);
}
});
- const { social_profiles, ...additionalAttributesProperties } =
+ const { social_profiles = {}, ...additionalAttributesProperties } =
additional_attributes;
Object.keys(additionalAttributesProperties).forEach(key => {
formData.append(
diff --git a/app/javascript/dashboard/store/modules/conversations/actions.js b/app/javascript/dashboard/store/modules/conversations/actions.js
index 1bc6e8aae..0aed3c6c3 100644
--- a/app/javascript/dashboard/store/modules/conversations/actions.js
+++ b/app/javascript/dashboard/store/modules/conversations/actions.js
@@ -440,6 +440,10 @@ const actions = {
commit(types.CHANGE_CHAT_SORT_FILTER, data);
},
+ setChatGroupTypeFilter({ commit }, data) {
+ commit(types.CHANGE_CHAT_GROUP_TYPE_FILTER, data);
+ },
+
updateAssignee({ commit }, data) {
commit(types.UPDATE_ASSIGNEE, data);
},
diff --git a/app/javascript/dashboard/store/modules/conversations/getters.js b/app/javascript/dashboard/store/modules/conversations/getters.js
index 9f5744fbb..bad4a5814 100644
--- a/app/javascript/dashboard/store/modules/conversations/getters.js
+++ b/app/javascript/dashboard/store/modules/conversations/getters.js
@@ -141,6 +141,7 @@ const getters = {
},
getChatStatusFilter: ({ chatStatusFilter }) => chatStatusFilter,
getChatSortFilter: ({ chatSortFilter }) => chatSortFilter,
+ getChatGroupTypeFilter: ({ chatGroupTypeFilter }) => chatGroupTypeFilter,
getSelectedInbox: ({ currentInbox }) => currentInbox,
getConversationById: _state => conversationId => {
return _state.allConversations.find(
diff --git a/app/javascript/dashboard/store/modules/conversations/helpers/filterHelpers.js b/app/javascript/dashboard/store/modules/conversations/helpers/filterHelpers.js
index c7060bee3..7f3bc35d4 100644
--- a/app/javascript/dashboard/store/modules/conversations/helpers/filterHelpers.js
+++ b/app/javascript/dashboard/store/modules/conversations/helpers/filterHelpers.js
@@ -64,6 +64,7 @@ const getValueFromConversation = (conversation, attributeKey) => {
switch (attributeKey) {
case 'status':
case 'priority':
+ case 'group_type':
case 'labels':
case 'created_at':
case 'last_activity_at':
diff --git a/app/javascript/dashboard/store/modules/conversations/index.js b/app/javascript/dashboard/store/modules/conversations/index.js
index 84be116fe..838d8c22f 100644
--- a/app/javascript/dashboard/store/modules/conversations/index.js
+++ b/app/javascript/dashboard/store/modules/conversations/index.js
@@ -14,6 +14,7 @@ const state = {
listLoadingStatus: true,
chatStatusFilter: wootConstants.STATUS_TYPE.OPEN,
chatSortFilter: wootConstants.SORT_BY_TYPE.LATEST,
+ chatGroupTypeFilter: '',
currentInbox: null,
selectedChatId: null,
appliedFilters: [],
@@ -285,6 +286,10 @@ export const mutations = {
_state.chatSortFilter = data;
},
+ [types.CHANGE_CHAT_GROUP_TYPE_FILTER](_state, data) {
+ _state.chatGroupTypeFilter = data;
+ },
+
// Update assignee on action cable message
[types.UPDATE_ASSIGNEE](_state, payload) {
const chat = getConversationById(_state)(payload.id);
diff --git a/app/javascript/dashboard/store/modules/groupMembers.js b/app/javascript/dashboard/store/modules/groupMembers.js
new file mode 100644
index 000000000..a7b9296a6
--- /dev/null
+++ b/app/javascript/dashboard/store/modules/groupMembers.js
@@ -0,0 +1,169 @@
+import types from '../mutation-types';
+import GroupMembersAPI from '../../api/groupMembers';
+
+export const state = {
+ records: {},
+ meta: {},
+ uiFlags: {
+ isFetching: false,
+ isFetchingMore: false,
+ isSyncing: false,
+ isUpdating: false,
+ isCreating: false,
+ },
+};
+
+export const getters = {
+ getGroupMembers: _state => contactId => {
+ return _state.records[contactId] || [];
+ },
+ getGroupMembersMeta: _state => contactId => {
+ return _state.meta[contactId] || {};
+ },
+ getUIFlags(_state) {
+ return _state.uiFlags;
+ },
+};
+
+export const actions = {
+ setGroupMembers(
+ { commit },
+ { contactId, members, inboxPhoneNumber, isInboxAdmin }
+ ) {
+ commit(types.SET_GROUP_MEMBERS, { contactId, members });
+ commit(types.SET_GROUP_MEMBERS_META, {
+ contactId,
+ meta: {
+ total_count: members.length,
+ page: 1,
+ per_page: members.length,
+ inbox_phone_number: inboxPhoneNumber || null,
+ is_inbox_admin: isInboxAdmin ?? null,
+ },
+ });
+ },
+
+ async createGroup({ commit }, params) {
+ commit(types.SET_GROUP_MEMBERS_UI_FLAG, { isCreating: true });
+ try {
+ const { data } = await GroupMembersAPI.createGroup(params);
+ return data;
+ } finally {
+ commit(types.SET_GROUP_MEMBERS_UI_FLAG, { isCreating: false });
+ }
+ },
+
+ async fetch({ commit }, { contactId, page = 1 }) {
+ const isFirstPage = page === 1;
+ commit(
+ types.SET_GROUP_MEMBERS_UI_FLAG,
+ isFirstPage ? { isFetching: true } : { isFetchingMore: true }
+ );
+ try {
+ const { data } = await GroupMembersAPI.getGroupMembers(contactId, page);
+ if (isFirstPage) {
+ commit(types.SET_GROUP_MEMBERS, { contactId, members: data.payload });
+ } else {
+ commit(types.APPEND_GROUP_MEMBERS, {
+ contactId,
+ members: data.payload,
+ });
+ }
+ commit(types.SET_GROUP_MEMBERS_META, { contactId, meta: data.meta });
+ } finally {
+ commit(
+ types.SET_GROUP_MEMBERS_UI_FLAG,
+ isFirstPage ? { isFetching: false } : { isFetchingMore: false }
+ );
+ }
+ },
+
+ async sync({ commit }, { contactId }) {
+ commit(types.SET_GROUP_MEMBERS_UI_FLAG, { isSyncing: true });
+ try {
+ await GroupMembersAPI.syncGroup(contactId);
+ } catch (error) {
+ // fire-and-forget: sync runs in background, results arrive via ActionCable
+ } finally {
+ commit(types.SET_GROUP_MEMBERS_UI_FLAG, { isSyncing: false });
+ }
+ },
+
+ async addMembers({ commit, dispatch }, { contactId, participants }) {
+ commit(types.SET_GROUP_MEMBERS_UI_FLAG, { isUpdating: true });
+ try {
+ await GroupMembersAPI.addMembers(contactId, participants);
+ await dispatch('fetch', { contactId });
+ } finally {
+ commit(types.SET_GROUP_MEMBERS_UI_FLAG, { isUpdating: false });
+ }
+ },
+
+ async removeMembers({ commit, dispatch }, { contactId, memberId }) {
+ commit(types.SET_GROUP_MEMBERS_UI_FLAG, { isUpdating: true });
+ try {
+ await GroupMembersAPI.removeMembers(contactId, memberId);
+ await dispatch('fetch', { contactId });
+ } finally {
+ commit(types.SET_GROUP_MEMBERS_UI_FLAG, { isUpdating: false });
+ }
+ },
+
+ async updateGroupMetadata({ commit }, { contactId, params }) {
+ commit(types.SET_GROUP_MEMBERS_UI_FLAG, { isUpdating: true });
+ try {
+ await GroupMembersAPI.updateGroupMetadata(contactId, params);
+ } finally {
+ commit(types.SET_GROUP_MEMBERS_UI_FLAG, { isUpdating: false });
+ }
+ },
+
+ async updateMemberRole({ commit, dispatch }, { contactId, memberId, role }) {
+ commit(types.SET_GROUP_MEMBERS_UI_FLAG, { isUpdating: true });
+ try {
+ await GroupMembersAPI.updateMemberRole(contactId, memberId, role);
+ await dispatch('fetch', { contactId });
+ } finally {
+ commit(types.SET_GROUP_MEMBERS_UI_FLAG, { isUpdating: false });
+ }
+ },
+};
+
+export const mutations = {
+ [types.SET_GROUP_MEMBERS_UI_FLAG](_state, data) {
+ _state.uiFlags = {
+ ..._state.uiFlags,
+ ...data,
+ };
+ },
+
+ [types.SET_GROUP_MEMBERS](_state, { contactId, members }) {
+ _state.records = {
+ ..._state.records,
+ [contactId]: members,
+ };
+ },
+
+ [types.APPEND_GROUP_MEMBERS](_state, { contactId, members }) {
+ const existing = _state.records[contactId] || [];
+ _state.records = {
+ ..._state.records,
+ [contactId]: [...existing, ...members],
+ };
+ },
+
+ [types.SET_GROUP_MEMBERS_META](_state, { contactId, meta }) {
+ _state.meta = {
+ ..._state.meta,
+ [contactId]: meta,
+ };
+ },
+};
+
+export default {
+ namespaced: true,
+ state,
+ getters,
+ actions,
+ mutations,
+};
diff --git a/app/javascript/dashboard/store/modules/groupMembers.spec.js b/app/javascript/dashboard/store/modules/groupMembers.spec.js
new file mode 100644
index 000000000..a860ec81b
--- /dev/null
+++ b/app/javascript/dashboard/store/modules/groupMembers.spec.js
@@ -0,0 +1,155 @@
+import axios from 'axios';
+import { actions, getters, mutations, state } from './groupMembers';
+import * as types from '../mutation-types';
+
+const commit = vi.fn();
+const dispatch = vi.fn();
+global.axios = axios;
+vi.mock('axios');
+vi.mock('../../api/groupMembers', () => ({
+ default: {
+ getGroupMembers: vi.fn(),
+ syncGroup: vi.fn(),
+ addMembers: vi.fn(),
+ removeMembers: vi.fn(),
+ updateMemberRole: vi.fn(),
+ },
+}));
+
+import GroupMembersAPI from '../../api/groupMembers';
+
+const sampleMembers = [
+ { id: 1, role: 'admin', is_active: true, contact: { id: 10, name: 'Alice' } },
+ { id: 2, role: 'member', is_active: true, contact: { id: 11, name: 'Bob' } },
+];
+
+describe('groupMembers store', () => {
+ beforeEach(() => {
+ commit.mockClear();
+ dispatch.mockClear();
+ });
+
+ describe('getters', () => {
+ it('getGroupMembers returns members for a contactId', () => {
+ const localState = { records: { 42: sampleMembers } };
+ expect(getters.getGroupMembers(localState)(42)).toEqual(sampleMembers);
+ });
+
+ it('getGroupMembers returns empty array for unknown contactId', () => {
+ const localState = { records: {} };
+ expect(getters.getGroupMembers(localState)(99)).toEqual([]);
+ });
+
+ it('getUIFlags returns uiFlags', () => {
+ const localState = {
+ uiFlags: { isFetching: true, isSyncing: false, isUpdating: false },
+ };
+ expect(getters.getUIFlags(localState)).toEqual(localState.uiFlags);
+ });
+ });
+
+ describe('mutations', () => {
+ it('SET_GROUP_MEMBERS_UI_FLAG merges flags', () => {
+ const localState = { ...state };
+ mutations[types.default.SET_GROUP_MEMBERS_UI_FLAG](localState, {
+ isFetching: true,
+ });
+ expect(localState.uiFlags.isFetching).toBe(true);
+ });
+
+ it('SET_GROUP_MEMBERS stores members keyed by contactId', () => {
+ const localState = { records: {} };
+ mutations[types.default.SET_GROUP_MEMBERS](localState, {
+ contactId: 42,
+ members: sampleMembers,
+ });
+ expect(localState.records[42]).toEqual(sampleMembers);
+ });
+ });
+
+ describe('actions', () => {
+ describe('setGroupMembers', () => {
+ it('commits SET_GROUP_MEMBERS directly', () => {
+ actions.setGroupMembers(
+ { commit },
+ { contactId: 42, members: sampleMembers }
+ );
+ expect(commit).toHaveBeenCalledWith(types.default.SET_GROUP_MEMBERS, {
+ contactId: 42,
+ members: sampleMembers,
+ });
+ });
+ });
+
+ describe('fetch', () => {
+ it('commits SET_GROUP_MEMBERS on success', async () => {
+ const meta = { total_count: 2, page: 1, per_page: 15 };
+ GroupMembersAPI.getGroupMembers.mockResolvedValue({
+ data: { payload: sampleMembers, meta },
+ });
+ await actions.fetch({ commit }, { contactId: 42 });
+ expect(commit.mock.calls).toEqual([
+ [types.default.SET_GROUP_MEMBERS_UI_FLAG, { isFetching: true }],
+ [
+ types.default.SET_GROUP_MEMBERS,
+ { contactId: 42, members: sampleMembers },
+ ],
+ [types.default.SET_GROUP_MEMBERS_META, { contactId: 42, meta }],
+ [types.default.SET_GROUP_MEMBERS_UI_FLAG, { isFetching: false }],
+ ]);
+ });
+
+ it('throws on API error', async () => {
+ GroupMembersAPI.getGroupMembers.mockRejectedValue(new Error('fail'));
+ await expect(
+ actions.fetch({ commit }, { contactId: 42 })
+ ).rejects.toThrow(Error);
+ });
+ });
+
+ describe('sync', () => {
+ it('calls syncGroup without re-fetching (fire-and-forget)', async () => {
+ GroupMembersAPI.syncGroup.mockResolvedValue({});
+ await actions.sync({ commit }, { contactId: 42 });
+ expect(GroupMembersAPI.syncGroup).toHaveBeenCalledWith(42);
+ expect(dispatch).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('addMembers', () => {
+ it('calls addMembers and re-fetches on success', async () => {
+ GroupMembersAPI.addMembers.mockResolvedValue({});
+ dispatch.mockResolvedValue();
+ await actions.addMembers(
+ { commit, dispatch },
+ { contactId: 42, participants: ['+5511999'] }
+ );
+ expect(dispatch).toHaveBeenCalledWith('fetch', { contactId: 42 });
+ });
+ });
+
+ describe('removeMembers', () => {
+ it('calls removeMembers and re-fetches on success', async () => {
+ GroupMembersAPI.removeMembers.mockResolvedValue({});
+ dispatch.mockResolvedValue();
+ await actions.removeMembers(
+ { commit, dispatch },
+ { contactId: 42, memberId: 1 }
+ );
+ expect(dispatch).toHaveBeenCalledWith('fetch', { contactId: 42 });
+ });
+ });
+
+ describe('updateMemberRole', () => {
+ it('calls updateMemberRole and re-fetches on success', async () => {
+ GroupMembersAPI.updateMemberRole.mockResolvedValue({});
+ dispatch.mockResolvedValue();
+ await actions.updateMemberRole(
+ { commit, dispatch },
+ { contactId: 42, memberId: 1, role: 'admin' }
+ );
+ expect(dispatch).toHaveBeenCalledWith('fetch', { contactId: 42 });
+ });
+ });
+ });
+});
diff --git a/app/javascript/dashboard/store/mutation-types.js b/app/javascript/dashboard/store/mutation-types.js
index 26e33cdd2..605c1fdab 100644
--- a/app/javascript/dashboard/store/mutation-types.js
+++ b/app/javascript/dashboard/store/mutation-types.js
@@ -18,6 +18,7 @@ export default {
CLEAR_ALL_MESSAGES_LOADED: 'CLEAR_ALL_MESSAGES_LOADED',
CHANGE_CHAT_STATUS_FILTER: 'CHANGE_CHAT_STATUS_FILTER',
CHANGE_CHAT_SORT_FILTER: 'CHANGE_CHAT_SORT_FILTER',
+ CHANGE_CHAT_GROUP_TYPE_FILTER: 'CHANGE_CHAT_GROUP_TYPE_FILTER',
UPDATE_ASSIGNEE: 'UPDATE_ASSIGNEE',
UPDATE_CONVERSATION_CONTACT: 'UPDATE_CONVERSATION_CONTACT',
CLEAR_CONTACT_CONVERSATIONS: 'CLEAR_CONTACT_CONVERSATIONS',
@@ -237,6 +238,12 @@ export default {
EDIT_CAMPAIGN: 'EDIT_CAMPAIGN',
DELETE_CAMPAIGN: 'DELETE_CAMPAIGN',
+ // Group members
+ SET_GROUP_MEMBERS_UI_FLAG: 'SET_GROUP_MEMBERS_UI_FLAG',
+ SET_GROUP_MEMBERS: 'SET_GROUP_MEMBERS',
+ APPEND_GROUP_MEMBERS: 'APPEND_GROUP_MEMBERS',
+ SET_GROUP_MEMBERS_META: 'SET_GROUP_MEMBERS_META',
+
// Contact notes
SET_CONTACT_NOTES_UI_FLAG: 'SET_CONTACT_NOTES_UI_FLAG',
SET_CONTACT_NOTES: 'SET_CONTACT_NOTES',
diff --git a/app/javascript/shared/constants/busEvents.js b/app/javascript/shared/constants/busEvents.js
index ef8f24155..21ead3817 100644
--- a/app/javascript/shared/constants/busEvents.js
+++ b/app/javascript/shared/constants/busEvents.js
@@ -13,4 +13,5 @@ export const BUS_EVENTS = {
NEW_CONVERSATION_MODAL: 'newConversationModal',
INSERT_INTO_RICH_EDITOR: 'insertIntoRichEditor',
INSERT_INTO_NORMAL_EDITOR: 'insertIntoNormalEditor',
+ NAVIGATE_TO_GROUP: 'navigateToGroup',
};
diff --git a/app/javascript/shared/helpers/markdownIt/link.js b/app/javascript/shared/helpers/markdownIt/link.js
index c5300391b..bbd87cc7c 100644
--- a/app/javascript/shared/helpers/markdownIt/link.js
+++ b/app/javascript/shared/helpers/markdownIt/link.js
@@ -1,5 +1,5 @@
-// Process [@mention](mention://user/1/Pranav)
-const USER_MENTIONS_REGEX = /mention:\/\/(user|team)\/(\d+)\/(.+)/gm;
+// Process [@mention](mention://user/1/Pranav) and [@mention](mention://contact/1/Name)
+const USER_MENTIONS_REGEX = /mention:\/\/(user|team|contact)\/(\d+)\/(.+)/gm;
const buildMentionTokens = () => (state, silent) => {
var label;
@@ -51,6 +51,8 @@ const buildMentionTokens = () => (state, silent) => {
token = state.push('mention', '');
token.href = href;
token.content = label;
+ const mentionMatch = href.match(/mention:\/\/(user|team|contact)\//);
+ token.mentionType = mentionMatch ? mentionMatch[1] : 'user';
}
state.pos = pos;
@@ -60,7 +62,11 @@ const buildMentionTokens = () => (state, silent) => {
};
const renderMentions = () => (tokens, idx) => {
- return `${tokens[idx].content}`;
+ const token = tokens[idx];
+ if (token.mentionType === 'contact') {
+ return `${token.content}`;
+ }
+ return `${token.content}`;
};
export default function mentionPlugin(md) {
diff --git a/app/javascript/shared/store/globalConfig.js b/app/javascript/shared/store/globalConfig.js
index 6b4c9a5ee..cd0129e27 100644
--- a/app/javascript/shared/store/globalConfig.js
+++ b/app/javascript/shared/store/globalConfig.js
@@ -24,6 +24,7 @@ const {
WIDGET_BRAND_URL: widgetBrandURL,
DISABLE_USER_PROFILE_UPDATE: disableUserProfileUpdate,
DEPLOYMENT_ENV: deploymentEnv,
+ BAILEYS_WHATSAPP_GROUPS_ENABLED: baileysWhatsappGroupsEnabled,
} = window.globalConfig || {};
const state = {
@@ -49,6 +50,7 @@ const state = {
termsURL,
widgetBrandURL,
isEnterprise: parseBoolean(isEnterprise),
+ baileysWhatsappGroupsEnabled: parseBoolean(baileysWhatsappGroupsEnabled),
};
export const getters = {
diff --git a/app/jobs/avatar/avatar_from_url_job.rb b/app/jobs/avatar/avatar_from_url_job.rb
index 929e76597..88f8cbaa1 100644
--- a/app/jobs/avatar/avatar_from_url_job.rb
+++ b/app/jobs/avatar/avatar_from_url_job.rb
@@ -27,6 +27,8 @@ class Avatar::AvatarFromUrlJob < ApplicationJob
content_type: avatar_file.content_type
)
+ dispatch_contact_update(avatarable)
+
rescue Down::NotFound
Rails.logger.info "AvatarFromUrlJob: avatar not found at #{avatar_url}"
rescue Down::Error => e
@@ -83,4 +85,14 @@ class Avatar::AvatarFromUrlJob < ApplicationJob
true
end
+
+ def dispatch_contact_update(avatarable)
+ return unless avatarable.is_a?(Contact)
+
+ Rails.configuration.dispatcher.dispatch(
+ Events::Types::CONTACT_UPDATED,
+ Time.zone.now,
+ contact: avatarable
+ )
+ end
end
diff --git a/app/jobs/contacts/sync_group_job.rb b/app/jobs/contacts/sync_group_job.rb
new file mode 100644
index 000000000..401a0dbf3
--- /dev/null
+++ b/app/jobs/contacts/sync_group_job.rb
@@ -0,0 +1,22 @@
+class Contacts::SyncGroupJob < ApplicationJob
+ queue_as :default
+
+ SYNC_COOLDOWN = 15.minutes
+
+ def perform(contact, force: false, soft: false)
+ return if !force && recently_synced?(contact)
+
+ Contacts::SyncGroupService.new(contact: contact, soft: soft).perform
+ rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e
+ Rails.logger.error "SyncGroupJob failed for contact #{contact.id}: #{e.message}"
+ end
+
+ private
+
+ def recently_synced?(contact)
+ last_synced = contact.additional_attributes&.dig('group_last_synced_at')
+ return false if last_synced.blank?
+
+ Time.zone.at(last_synced) > SYNC_COOLDOWN.ago
+ end
+end
diff --git a/app/listeners/action_cable_listener.rb b/app/listeners/action_cable_listener.rb
index 9c888c3de..153768660 100644
--- a/app/listeners/action_cable_listener.rb
+++ b/app/listeners/action_cable_listener.rb
@@ -190,6 +190,18 @@ class ActionCableListener < BaseListener # rubocop:disable Metrics/ClassLength
broadcast(account, [account_token(account)], CONTACT_DELETED, contact_data)
end
+ def contact_group_synced(event)
+ contact, account = extract_contact_and_account(event)
+ inbox_phone = contact.group_channel&.phone_number
+ payload = contact.push_event_data.merge(
+ group_members: group_members_data(contact, account),
+ inbox_phone_number: inbox_phone,
+ is_inbox_admin: inbox_admin_in_group?(contact, inbox_phone)
+ )
+
+ broadcast(account, [account_token(account)], CONTACT_GROUP_SYNCED, payload)
+ end
+
def conversation_mentioned(event)
conversation, account = extract_conversation_and_account(event)
user = event.data[:user]
@@ -228,6 +240,27 @@ class ActionCableListener < BaseListener # rubocop:disable Metrics/ClassLength
contact_inbox.hmac_verified? ? contact.contact_inboxes.where(hmac_verified: true).filter_map(&:pubsub_token) : [contact_inbox.pubsub_token]
end
+ def group_members_data(contact, _account)
+ GroupMember.active.where(group_contact: contact).includes(:contact).map do |member|
+ {
+ id: member.id, role: member.role, is_active: member.is_active, group_contact_id: member.group_contact_id,
+ contact: { id: member.contact.id, name: member.contact.name, phone_number: member.contact.phone_number,
+ identifier: member.contact.identifier, thumbnail: member.contact.avatar_url }
+ }
+ end
+ end
+
+ def inbox_admin_in_group?(contact, inbox_phone)
+ return false if inbox_phone.blank?
+
+ clean = inbox_phone.delete('+')
+ GroupMember.active
+ .where(group_contact: contact, role: :admin)
+ .joins(:contact)
+ .exists?(['REPLACE(contacts.phone_number, \'+\', \'\') = ? OR RIGHT(REPLACE(contacts.phone_number, \'+\', \'\'), 8) = RIGHT(?, 8)',
+ clean, clean])
+ end
+
def broadcast(account, tokens, event_name, data)
return if tokens.blank?
diff --git a/app/models/channel/whatsapp.rb b/app/models/channel/whatsapp.rb
index d55ef3251..a03044ca5 100644
--- a/app/models/channel/whatsapp.rb
+++ b/app/models/channel/whatsapp.rb
@@ -1,3 +1,4 @@
+# rubocop:disable Layout/LineLength
# == Schema Information
#
# Table name: channel_whatsapp
@@ -16,8 +17,9 @@
# Indexes
#
# index_channel_whatsapp_on_phone_number (phone_number) UNIQUE
-# index_channel_whatsapp_provider_connection (provider_connection) WHERE ((provider)::text = ANY ((ARRAY['baileys'::character varying, 'zapi'::character varying])::text[])) USING gin # rubocop:disable Layout/LineLength
+# index_channel_whatsapp_provider_connection (provider_connection) WHERE ((provider)::text = ANY ((ARRAY['baileys'::character varying, 'zapi'::character varying])::text[])) USING gin
#
+# rubocop:enable Layout/LineLength
class Channel::Whatsapp < ApplicationRecord
include Channelable
@@ -159,12 +161,35 @@ class Channel::Whatsapp < ApplicationRecord
provider_service.edit_message(recipient_id, message, new_content)
end
+ def sync_group(conversation, soft: false)
+ return unless provider_service.respond_to?(:sync_group)
+
+ provider_service.sync_group(conversation, soft: soft)
+ end
+
+ def allow_group_creation?
+ provider_service.respond_to?(:allow_group_creation?) && provider_service.allow_group_creation?
+ end
+
delegate :setup_channel_provider, to: :provider_service
delegate :send_message, to: :provider_service
delegate :send_template, to: :provider_service
delegate :sync_templates, to: :provider_service
delegate :media_url, to: :provider_service
delegate :api_headers, to: :provider_service
+ delegate :create_group, to: :provider_service
+ delegate :update_group_subject, to: :provider_service
+ delegate :update_group_description, to: :provider_service
+ delegate :update_group_picture, to: :provider_service
+ delegate :update_group_participants, to: :provider_service
+ delegate :group_invite_code, to: :provider_service
+ delegate :revoke_group_invite, to: :provider_service
+ delegate :group_join_requests, to: :provider_service
+ delegate :handle_group_join_requests, to: :provider_service
+ delegate :group_leave, to: :provider_service
+ delegate :group_setting_update, to: :provider_service
+ delegate :group_join_approval_mode, to: :provider_service
+ delegate :group_member_add_mode, to: :provider_service
def setup_webhooks
perform_webhook_setup
diff --git a/app/models/concerns/group_conversation_handler.rb b/app/models/concerns/group_conversation_handler.rb
new file mode 100644
index 000000000..552e23595
--- /dev/null
+++ b/app/models/concerns/group_conversation_handler.rb
@@ -0,0 +1,172 @@
+module GroupConversationHandler # rubocop:disable Metrics/ModuleLength
+ extend ActiveSupport::Concern
+
+ # This concern provides the base logic for handling group conversations across all channels.
+ # Channel-specific handlers should include this concern and implement the required abstract methods.
+ #
+ # Abstract methods that must be implemented by including modules:
+ # - extract_group_identifier: Returns a unique identifier for the group
+ # - extract_group_source_id: Returns the source_id for the group contact_inbox
+ # - extract_group_name: Returns the display name of the group
+ # - extract_sender_identifier: Returns a unique identifier for the message sender
+ # - extract_sender_source_id: Returns the source_id for the sender contact_inbox
+ # - extract_sender_name: Returns the display name of the sender
+ # - extract_sender_phone: Returns the phone number of the sender
+ # - build_sender_contact_attributes: Returns a hash of attributes for the sender contact
+
+ private
+
+ def find_or_create_group_contact
+ group_contact_inbox = ::ContactInboxWithContactBuilder.new(
+ source_id: extract_group_source_id,
+ inbox: inbox,
+ contact_attributes: {
+ name: extract_group_name || extract_group_source_id,
+ identifier: extract_group_identifier,
+ group_type: :group
+ }
+ ).perform
+
+ contact = group_contact_inbox.contact
+ update_group_contact_info(contact)
+
+ [group_contact_inbox, contact]
+ end
+
+ def update_group_contact_info(contact)
+ update_params = {}
+ group_name = extract_group_name
+ update_params[:name] = group_name if group_name.present? && contact.name != group_name
+ update_params[:group_type] = :group unless contact.group_type_group?
+ contact.update!(update_params) if update_params.present?
+ end
+
+ def find_or_create_sender_contact
+ source_id = extract_sender_source_id
+ return nil if source_id.blank?
+
+ sender_contact_inbox = ::ContactInboxWithContactBuilder.new(
+ source_id: source_id,
+ inbox: inbox,
+ contact_attributes: build_sender_contact_attributes
+ ).perform
+
+ sender_contact_inbox.contact
+ end
+
+ def find_or_create_group_conversation(group_contact_inbox)
+ @conversation = group_contact_inbox.conversations.where(status: %i[open pending]).last
+ if @conversation.present?
+ @conversation.update!(group_type: :group) unless @conversation.group_type_group?
+ return @conversation
+ end
+
+ @conversation = ::Conversation.create!(
+ account_id: inbox.account_id,
+ inbox_id: inbox.id,
+ contact_id: group_contact_inbox.contact_id,
+ contact_inbox_id: group_contact_inbox.id,
+ group_type: :group
+ )
+ end
+
+ def add_group_member(group_contact, contact, role: :member)
+ return if group_contact.blank?
+ return if contact.blank?
+
+ member = GroupMember.find_or_initialize_by(
+ group_contact: group_contact,
+ contact: contact
+ )
+
+ member.update!(role: role, is_active: true) if member.new_record? || !member.is_active? || member.role != role.to_s
+ member
+ end
+
+ def remove_group_member(group_contact, contact)
+ return if group_contact.blank?
+ return if contact.blank?
+
+ member = GroupMember.find_by(group_contact: group_contact, contact: contact)
+ member&.update!(is_active: false)
+ member
+ end
+
+ def update_group_member_role(group_contact, contact, role)
+ return if group_contact.blank?
+ return if contact.blank?
+ return if role.blank?
+
+ member = GroupMember.find_by(group_contact: group_contact, contact: contact)
+ member&.update!(role: role)
+ member
+ end
+
+ def sync_group_members(group_contact, contacts, admins: [])
+ contacts.each do |contact|
+ role = admins.include?(contact) ? :admin : :member
+ add_group_member(group_contact, contact, role: role)
+ end
+
+ current_member_ids = group_contact.group_memberships.active.pluck(:contact_id)
+ new_contact_ids = contacts.map(&:id)
+ removed_ids = current_member_ids - new_contact_ids
+
+ group_contact.group_memberships.where(contact_id: removed_ids).find_each do |member|
+ member.update!(is_active: false)
+ end
+ end
+
+ def create_group_message(conversation:, sender_contact:, content:, message_type: :incoming, **options)
+ return if conversation.blank?
+
+ message_params = {
+ account_id: conversation.account_id,
+ inbox_id: conversation.inbox_id,
+ conversation_id: conversation.id,
+ message_type: message_type,
+ content: content,
+ sender: sender_contact
+ }.merge(options)
+
+ Message.create!(message_params)
+ end
+
+ def extract_group_identifier
+ raise NotImplementedError, "#{self.class} must implement #extract_group_identifier"
+ end
+
+ def extract_group_source_id
+ raise NotImplementedError, "#{self.class} must implement #extract_group_source_id"
+ end
+
+ def extract_group_name
+ raise NotImplementedError, "#{self.class} must implement #extract_group_name"
+ end
+
+ def extract_sender_identifier
+ raise NotImplementedError, "#{self.class} must implement #extract_sender_identifier"
+ end
+
+ def extract_sender_source_id
+ raise NotImplementedError, "#{self.class} must implement #extract_sender_source_id"
+ end
+
+ def extract_sender_name
+ raise NotImplementedError, "#{self.class} must implement #extract_sender_name"
+ end
+
+ def extract_sender_phone
+ raise NotImplementedError, "#{self.class} must implement #extract_sender_phone"
+ end
+
+ def build_sender_contact_attributes
+ phone = extract_sender_phone
+ identifier = extract_sender_identifier
+
+ attrs = { name: extract_sender_name }
+ attrs[:phone_number] = phone if phone.present?
+ attrs[:identifier] = identifier if identifier.present?
+ attrs
+ end
+end
diff --git a/app/models/contact.rb b/app/models/contact.rb
index 3badf478d..ef65d8bc9 100644
--- a/app/models/contact.rb
+++ b/app/models/contact.rb
@@ -11,6 +11,7 @@
# country_code :string default("")
# custom_attributes :jsonb
# email :string
+# group_type :integer default("individual"), not null
# identifier :string
# last_activity_at :datetime
# last_name :string default("")
@@ -27,6 +28,7 @@
#
# index_contacts_on_account_id (account_id)
# index_contacts_on_account_id_and_contact_type (account_id,contact_type)
+# index_contacts_on_account_id_and_group_type (account_id,group_type)
# index_contacts_on_account_id_and_last_activity_at (account_id,last_activity_at DESC NULLS LAST)
# index_contacts_on_blocked (blocked)
# index_contacts_on_company_id (company_id)
@@ -41,7 +43,7 @@
# rubocop:enable Layout/LineLength
-class Contact < ApplicationRecord
+class Contact < ApplicationRecord # rubocop:disable Metrics/ClassLength
include Avatarable
include AvailabilityStatusable
include Labelable
@@ -62,6 +64,10 @@ class Contact < ApplicationRecord
has_many :inboxes, through: :contact_inboxes
has_many :messages, as: :sender, dependent: :destroy_async
has_many :notes, dependent: :destroy_async
+ has_many :group_memberships, class_name: 'GroupMember', foreign_key: :group_contact_id, dependent: :destroy,
+ inverse_of: :group_contact
+ has_many :group_member_contacts, through: :group_memberships, source: :contact
+ has_many :group_participations, class_name: 'GroupMember', dependent: :destroy, inverse_of: :contact
before_validation :prepare_contact_attributes
after_create_commit :dispatch_create_event, :ip_lookup
after_update_commit :dispatch_update_event
@@ -69,6 +75,7 @@ class Contact < ApplicationRecord
before_save :sync_contact_attributes
enum contact_type: { visitor: 0, lead: 1, customer: 2 }
+ enum group_type: { individual: 0, group: 1 }, _prefix: true
scope :order_on_last_activity_at, lambda { |direction|
order(
@@ -147,11 +154,16 @@ class Contact < ApplicationRecord
contact_inboxes.find_by!(inbox_id: inbox_id).source_id
end
+ def group_channel
+ contact_inboxes.first&.inbox&.channel
+ end
+
def push_event_data
{
additional_attributes: additional_attributes,
custom_attributes: custom_attributes,
email: email,
+ group_type: group_type,
id: id,
identifier: identifier,
name: name,
diff --git a/app/models/conversation.rb b/app/models/conversation.rb
index 50534d3bd..ddcd9d459 100644
--- a/app/models/conversation.rb
+++ b/app/models/conversation.rb
@@ -10,6 +10,7 @@
# contact_last_seen_at :datetime
# custom_attributes :jsonb
# first_reply_created_at :datetime
+# group_type :integer default("individual"), not null
# identifier :string
# last_activity_at :datetime not null
# priority :integer
@@ -27,6 +28,7 @@
# contact_inbox_id :bigint
# display_id :integer not null
# inbox_id :integer not null
+# kanban_task_id :bigint
# sla_policy_id :bigint
# team_id :bigint
#
@@ -35,6 +37,7 @@
# conv_acid_inbid_stat_asgnid_idx (account_id,inbox_id,status,assignee_id)
# index_conversations_on_account_id (account_id)
# index_conversations_on_account_id_and_display_id (account_id,display_id) UNIQUE
+# index_conversations_on_account_id_and_group_type (account_id,group_type)
# index_conversations_on_assignee_id_and_account_id (assignee_id,account_id)
# index_conversations_on_campaign_id (campaign_id)
# index_conversations_on_contact_id (contact_id)
@@ -43,6 +46,8 @@
# index_conversations_on_id_and_account_id (account_id,id)
# index_conversations_on_identifier_and_account_id (identifier,account_id)
# index_conversations_on_inbox_id (inbox_id)
+# index_conversations_on_inbox_id_and_group_type (inbox_id,group_type)
+# index_conversations_on_kanban_task_id (kanban_task_id)
# index_conversations_on_priority (priority)
# index_conversations_on_status_and_account_id (status,account_id)
# index_conversations_on_status_and_priority (status,priority)
@@ -50,6 +55,10 @@
# index_conversations_on_uuid (uuid) UNIQUE
# index_conversations_on_waiting_since (waiting_since)
#
+# Foreign Keys
+#
+# fk_rails_... (kanban_task_id => kanban_tasks.id)
+#
class Conversation < ApplicationRecord
include Labelable
@@ -74,6 +83,7 @@ class Conversation < ApplicationRecord
enum status: { open: 0, resolved: 1, pending: 2, snoozed: 3 }
enum priority: { low: 0, medium: 1, high: 2, urgent: 3 }
+ enum group_type: { individual: 0, group: 1 }, _prefix: true
scope :unassigned, -> { where(assignee_id: nil) }
scope :assigned, -> { where.not(assignee_id: nil) }
diff --git a/app/models/csat_survey_response.rb b/app/models/csat_survey_response.rb
index 804dfd4b7..212530493 100644
--- a/app/models/csat_survey_response.rb
+++ b/app/models/csat_survey_response.rb
@@ -2,24 +2,28 @@
#
# Table name: csat_survey_responses
#
-# id :bigint not null, primary key
-# feedback_message :text
-# rating :integer not null
-# created_at :datetime not null
-# updated_at :datetime not null
-# account_id :bigint not null
-# assigned_agent_id :bigint
-# contact_id :bigint not null
-# conversation_id :bigint not null
-# message_id :bigint not null
+# id :bigint not null, primary key
+# csat_review_notes :text
+# feedback_message :text
+# rating :integer not null
+# review_notes_updated_at :datetime
+# created_at :datetime not null
+# updated_at :datetime not null
+# account_id :bigint not null
+# assigned_agent_id :bigint
+# contact_id :bigint not null
+# conversation_id :bigint not null
+# message_id :bigint not null
+# review_notes_updated_by_id :bigint
#
# Indexes
#
-# index_csat_survey_responses_on_account_id (account_id)
-# index_csat_survey_responses_on_assigned_agent_id (assigned_agent_id)
-# index_csat_survey_responses_on_contact_id (contact_id)
-# index_csat_survey_responses_on_conversation_id (conversation_id)
-# index_csat_survey_responses_on_message_id (message_id) UNIQUE
+# index_csat_survey_responses_on_account_id (account_id)
+# index_csat_survey_responses_on_assigned_agent_id (assigned_agent_id)
+# index_csat_survey_responses_on_contact_id (contact_id)
+# index_csat_survey_responses_on_conversation_id (conversation_id)
+# index_csat_survey_responses_on_message_id (message_id) UNIQUE
+# index_csat_survey_responses_on_review_notes_updated_by_id (review_notes_updated_by_id)
#
class CsatSurveyResponse < ApplicationRecord
belongs_to :account
diff --git a/app/models/group_member.rb b/app/models/group_member.rb
new file mode 100644
index 000000000..a2cedbb13
--- /dev/null
+++ b/app/models/group_member.rb
@@ -0,0 +1,35 @@
+# == Schema Information
+#
+# Table name: group_members
+#
+# id :bigint not null, primary key
+# is_active :boolean default(TRUE), not null
+# role :integer default("member"), not null
+# created_at :datetime not null
+# updated_at :datetime not null
+# contact_id :bigint not null
+# group_contact_id :bigint not null
+#
+# Indexes
+#
+# index_group_members_on_contact_id (contact_id)
+# index_group_members_on_group_contact_id (group_contact_id)
+# index_group_members_on_group_contact_id_and_contact_id (group_contact_id,contact_id) UNIQUE
+# index_group_members_on_group_contact_id_and_is_active (group_contact_id,is_active)
+#
+# Foreign Keys
+#
+# fk_rails_... (contact_id => contacts.id)
+# fk_rails_... (group_contact_id => contacts.id)
+#
+class GroupMember < ApplicationRecord
+ belongs_to :group_contact, class_name: 'Contact'
+ belongs_to :contact
+
+ enum role: { member: 0, admin: 1 }
+
+ validates :group_contact_id, uniqueness: { scope: :contact_id }
+
+ scope :active, -> { where(is_active: true) }
+ scope :inactive, -> { where(is_active: false) }
+end
diff --git a/app/models/reporting_event.rb b/app/models/reporting_event.rb
index f083e32a1..6c2c10b01 100644
--- a/app/models/reporting_event.rb
+++ b/app/models/reporting_event.rb
@@ -17,13 +17,14 @@
#
# Indexes
#
-# index_reporting_events_on_account_id (account_id)
-# index_reporting_events_on_conversation_id (conversation_id)
-# index_reporting_events_on_created_at (created_at)
-# index_reporting_events_on_inbox_id (inbox_id)
-# index_reporting_events_on_name (name)
-# index_reporting_events_on_user_id (user_id)
-# reporting_events__account_id__name__created_at (account_id,name,created_at)
+# index_reporting_events_for_response_distribution (account_id,name,inbox_id,created_at)
+# index_reporting_events_on_account_id (account_id)
+# index_reporting_events_on_conversation_id (conversation_id)
+# index_reporting_events_on_created_at (created_at)
+# index_reporting_events_on_inbox_id (inbox_id)
+# index_reporting_events_on_name (name)
+# index_reporting_events_on_user_id (user_id)
+# reporting_events__account_id__name__created_at (account_id,name,created_at)
#
class ReportingEvent < ApplicationRecord
diff --git a/app/models/super_admin.rb b/app/models/super_admin.rb
index 9bcee9b8a..316d60c7b 100644
--- a/app/models/super_admin.rb
+++ b/app/models/super_admin.rb
@@ -19,7 +19,7 @@
# message_signature :text
# name :string not null
# otp_backup_codes :text
-# otp_required_for_login :boolean default(FALSE)
+# otp_required_for_login :boolean default(FALSE), not null
# otp_secret :string
# provider :string default("email"), not null
# pubsub_token :string
diff --git a/app/models/user.rb b/app/models/user.rb
index b2808fc37..afea4b4c5 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -19,7 +19,7 @@
# message_signature :text
# name :string not null
# otp_backup_codes :text
-# otp_required_for_login :boolean default(FALSE)
+# otp_required_for_login :boolean default(FALSE), not null
# otp_secret :string
# provider :string default("email"), not null
# pubsub_token :string
diff --git a/app/policies/contact_policy.rb b/app/policies/contact_policy.rb
index cd199012f..d05b82478 100644
--- a/app/policies/contact_policy.rb
+++ b/app/policies/contact_policy.rb
@@ -47,6 +47,10 @@ class ContactPolicy < ApplicationPolicy
true
end
+ def sync_group?
+ true
+ end
+
def destroy?
@account_user.administrator?
end
diff --git a/app/presenters/conversations/event_data_presenter.rb b/app/presenters/conversations/event_data_presenter.rb
index 4a9216b05..551fe581c 100644
--- a/app/presenters/conversations/event_data_presenter.rb
+++ b/app/presenters/conversations/event_data_presenter.rb
@@ -1,10 +1,11 @@
class Conversations::EventDataPresenter < SimpleDelegator
- def push_data
+ def push_data # rubocop:disable Metrics/MethodLength
{
additional_attributes: additional_attributes,
can_reply: can_reply?,
channel: inbox.try(:channel_type),
contact_inbox: contact_inbox,
+ group_type: group_type,
id: display_id,
inbox_id: inbox_id,
messages: push_messages,
diff --git a/app/services/contacts/sync_group_service.rb b/app/services/contacts/sync_group_service.rb
new file mode 100644
index 000000000..69b1d97ff
--- /dev/null
+++ b/app/services/contacts/sync_group_service.rb
@@ -0,0 +1,53 @@
+class Contacts::SyncGroupService
+ pattr_initialize [:contact!, { soft: false }]
+
+ def perform
+ validate_group_contact!
+
+ channel = contact.group_channel
+ raise ActionController::BadRequest, I18n.t('contacts.sync_group.no_supported_inbox') if channel.blank? || !channel.respond_to?(:sync_group)
+
+ conversation = find_or_create_sync_conversation
+ raise ActionController::BadRequest, I18n.t('contacts.sync_group.no_supported_inbox') if conversation.blank?
+
+ channel.sync_group(conversation, soft: soft)
+
+ contact.reload
+ dispatch_group_synced_event
+ contact
+ end
+
+ private
+
+ def find_or_create_sync_conversation
+ contact_inbox = contact.contact_inboxes.first
+ return nil if contact_inbox.blank?
+
+ contact_inbox.conversations.where(status: %i[open pending]).last ||
+ contact_inbox.conversations.order(created_at: :desc).first ||
+ create_group_conversation(contact_inbox)
+ end
+
+ def create_group_conversation(contact_inbox)
+ Conversation.create!(
+ account_id: contact_inbox.inbox.account_id,
+ inbox_id: contact_inbox.inbox_id,
+ contact_id: contact.id,
+ contact_inbox_id: contact_inbox.id,
+ group_type: :group
+ )
+ end
+
+ def validate_group_contact!
+ raise ActionController::BadRequest, I18n.t('contacts.sync_group.not_a_group') if contact.group_type_individual?
+ raise ActionController::BadRequest, I18n.t('contacts.sync_group.no_identifier') if contact.identifier.blank?
+ end
+
+ def dispatch_group_synced_event
+ Rails.configuration.dispatcher.dispatch(
+ Events::Types::CONTACT_GROUP_SYNCED,
+ Time.zone.now,
+ contact: contact
+ )
+ end
+end
diff --git a/app/services/filter_service.rb b/app/services/filter_service.rb
index e4cef7941..2bf86e6bd 100644
--- a/app/services/filter_service.rb
+++ b/app/services/filter_service.rb
@@ -48,6 +48,7 @@ class FilterService
return conversation_status_values(values) if attribute_key == 'status'
return conversation_priority_values(values) if attribute_key == 'priority'
+ return conversation_group_type_values(values) if attribute_key == 'group_type'
return message_type_values(values) if attribute_key == 'message_type'
return downcase_array_values(values) if attribute_key == 'content'
diff --git a/app/services/groups/create_service.rb b/app/services/groups/create_service.rb
new file mode 100644
index 000000000..eecc1c77d
--- /dev/null
+++ b/app/services/groups/create_service.rb
@@ -0,0 +1,21 @@
+class Groups::CreateService
+ pattr_initialize [:inbox!, :subject!, :participants!]
+
+ def perform
+ group_data = channel.create_group(subject, format_participants)
+ group_jid = group_data&.dig(:id)
+ raise Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError, 'Group JID missing from response' if group_jid.blank?
+
+ { group_jid: group_jid }
+ end
+
+ private
+
+ def channel
+ inbox.channel
+ end
+
+ def format_participants
+ participants.map { |phone| "#{phone.delete('+')}@s.whatsapp.net" }
+ end
+end
diff --git a/app/services/messages/markdown_renderers/whats_app_renderer.rb b/app/services/messages/markdown_renderers/whats_app_renderer.rb
index 3b4517b4b..2da42b54b 100644
--- a/app/services/messages/markdown_renderers/whats_app_renderer.rb
+++ b/app/services/messages/markdown_renderers/whats_app_renderer.rb
@@ -17,7 +17,11 @@ class Messages::MarkdownRenderers::WhatsAppRenderer < Messages::MarkdownRenderer
end
def link(node)
- out(node.url)
+ if node.url.start_with?('mention://')
+ out(:children)
+ else
+ out(node.url)
+ end
end
def list(node)
diff --git a/app/services/messages/mention_service.rb b/app/services/messages/mention_service.rb
index 43bc17fb8..1c4c72f67 100644
--- a/app/services/messages/mention_service.rb
+++ b/app/services/messages/mention_service.rb
@@ -2,7 +2,14 @@ class Messages::MentionService
pattr_initialize [:message!]
def perform
- return unless valid_mention_message?(message)
+ process_user_team_mentions
+ process_contact_mentions
+ end
+
+ private
+
+ def process_user_team_mentions
+ return unless valid_user_mention_message?
validated_mentioned_ids = filter_mentioned_ids_by_inbox
return if validated_mentioned_ids.blank?
@@ -12,12 +19,23 @@ class Messages::MentionService
add_mentioned_users_as_participants(validated_mentioned_ids)
end
- private
+ def process_contact_mentions
+ contact_ids = contact_mentioned_ids
+ return if contact_ids.blank?
- def valid_mention_message?(message)
+ message.update!(content_attributes: message.content_attributes.merge('mentioned_contacts' => contact_ids))
+ end
+
+ def valid_user_mention_message?
message.private? && message.content.present? && mentioned_ids.present?
end
+ def contact_mentioned_ids
+ return [] if message.content.blank?
+
+ message.content.scan(%r{\(mention://contact/(\d+)/(.+?)\)}).map(&:first).uniq
+ end
+
def mentioned_ids
user_mentions = message.content.scan(%r{\(mention://user/(\d+)/(.+?)\)}).map(&:first)
team_mentions = message.content.scan(%r{\(mention://team/(\d+)/(.+?)\)}).map(&:first)
diff --git a/app/services/whatsapp/baileys_handlers/concerns/group_contact_message_handler.rb b/app/services/whatsapp/baileys_handlers/concerns/group_contact_message_handler.rb
new file mode 100644
index 000000000..7bc9e988e
--- /dev/null
+++ b/app/services/whatsapp/baileys_handlers/concerns/group_contact_message_handler.rb
@@ -0,0 +1,189 @@
+module Whatsapp::BaileysHandlers::Concerns::GroupContactMessageHandler # rubocop:disable Metrics/ModuleLength
+ extend ActiveSupport::Concern
+ include GroupConversationHandler
+ include Whatsapp::BaileysHandlers::Concerns::MessageCreationHandler
+
+ private
+
+ def handle_group_contact_message
+ @lock_acquired = acquire_message_processing_lock
+ return unless @lock_acquired
+
+ # Lock by group jid to prevent race conditions when multiple messages
+ # from the same group arrive simultaneously (e.g., Multiple contacts sending messages at the same time).
+ with_contact_lock(extract_group_jid) do
+ # Re-check after acquiring lock to handle race conditions where:
+ # 1. An agent sends a message from Chatwoot (slow API call)
+ # 2. WhatsApp sends webhook before source_id is saved
+ # 3. Webhook handler times out waiting for channel lock and proceeds
+ # 4. By now, source_id should be set, so we can find the message
+ return if find_message_by_source_id(raw_message_id)
+
+ process_group_message
+ end
+ ensure
+ clear_message_source_id_from_redis if @lock_acquired
+ end
+
+ def process_group_message
+ @group_contact_inbox, @group_contact = find_or_create_group_contact
+
+ consolidate_contact(baileys_sender_phone, baileys_sender_lid, baileys_sender_identifier)
+ @sender_contact = find_or_create_sender_contact
+ if @sender_contact
+ update_contact_whatsapp_info(@sender_contact, baileys_sender_phone, baileys_sender_identifier, name: extract_sender_name)
+ try_update_contact_avatar(@sender_contact)
+ end
+
+ @conversation = find_or_create_group_conversation(@group_contact_inbox)
+ add_group_member(@group_contact, @sender_contact) if @sender_contact
+
+ build_and_save_message(
+ conversation: @conversation,
+ sender: @sender_contact,
+ attach_media: should_attach_media?
+ )
+ end
+
+ def find_or_create_participant_contact(participant)
+ lid = extract_lid_from_participant(participant)
+ phone = extract_phone_from_participant(participant)
+ identifier = lid ? "#{lid}@lid" : nil
+ source_id = lid || phone
+
+ return nil if source_id.blank?
+
+ consolidate_contact(phone, lid, identifier)
+
+ contact_inbox = ::ContactInboxWithContactBuilder.new(
+ source_id: source_id,
+ inbox: inbox,
+ contact_attributes: {
+ name: phone,
+ phone_number: ("+#{phone}" if phone),
+ identifier: identifier
+ }
+ ).perform
+
+ update_contact_whatsapp_info(contact_inbox.contact, phone, identifier)
+ end
+
+ def consolidate_contact(phone, lid, identifier)
+ return unless phone || lid
+
+ Whatsapp::ContactInboxConsolidationService.new(
+ inbox: inbox, phone: phone, lid: lid, identifier: identifier
+ ).perform
+ end
+
+ def update_contact_whatsapp_info(contact, phone, identifier, name: nil)
+ update_params = {
+ phone_number: ("+#{phone}" if should_update_contact_phone?(contact, phone)),
+ identifier: (identifier if should_update_contact_identifier?(contact, identifier)),
+ name: (name if should_update_contact_name?(contact, name))
+ }.compact
+
+ contact.update!(update_params) if update_params.present?
+ contact
+ end
+
+ def should_update_contact_phone?(contact, phone)
+ phone && contact.phone_number.blank?
+ end
+
+ def should_update_contact_identifier?(contact, identifier)
+ identifier && contact.identifier.blank?
+ end
+
+ def should_update_contact_name?(contact, name)
+ name && (contact.name.blank? || contact.name.match?(/^\d+/))
+ end
+
+ def extract_lid_from_participant(participant)
+ return nil if participant[:id].blank?
+
+ jid_part, jid_suffix = participant[:id].split('@')
+ jid_part if jid_suffix == 'lid' && jid_part.match?(/^\d+$/)
+ end
+
+ def extract_phone_from_participant(participant)
+ return nil if participant[:phoneNumber].blank?
+
+ phone = participant[:phoneNumber].split('@').first
+ phone if phone.match?(/^\d+$/)
+ end
+
+ def extract_group_identifier
+ extract_group_jid
+ end
+
+ def extract_group_source_id
+ extract_group_jid.split('@').first
+ end
+
+ def extract_group_name
+ nil
+ end
+
+ def extract_sender_identifier
+ baileys_sender_identifier
+ end
+
+ def extract_sender_source_id
+ baileys_sender_lid || baileys_sender_phone
+ end
+
+ def extract_sender_name
+ @raw_message[:pushName] || baileys_sender_phone || baileys_sender_lid
+ end
+
+ def extract_sender_phone
+ phone = baileys_sender_phone
+ "+#{phone}" if phone.present?
+ end
+
+ def extract_group_jid
+ @raw_message[:key][:remoteJid]
+ end
+
+ def extract_sender_jid
+ return if @raw_message[:key][:participant].blank?
+
+ @raw_message[:key][:participant]
+ end
+
+ def extract_sender_jid_alt
+ @raw_message[:key][:participantAlt]
+ end
+
+ def baileys_sender_phone
+ alt_jid = extract_sender_jid_alt
+ if alt_jid.present?
+ phone = alt_jid.split('@').first
+ return phone if phone.match?(/^\d+$/)
+ end
+
+ sender_jid = extract_sender_jid
+ return if sender_jid.blank?
+
+ jid_part = sender_jid.split('@').first
+ parts = jid_part.split(':')
+ parts.first if parts.first.match?(/^\d+$/)
+ end
+
+ def baileys_sender_lid
+ sender_jid = extract_sender_jid
+ return if sender_jid.blank?
+
+ jid_part, jid_suffix = sender_jid.split('@')
+ return jid_part if jid_suffix == 'lid' && jid_part.match?(/^\d+$/)
+
+ parts = jid_part.split(':')
+ parts.last if parts.length > 1 && parts.last.match?(/^\d+$/)
+ end
+
+ def baileys_sender_identifier
+ lid = baileys_sender_lid
+ lid ? "#{lid}@lid" : nil
+ end
+end
diff --git a/app/services/whatsapp/baileys_handlers/concerns/group_event_helper.rb b/app/services/whatsapp/baileys_handlers/concerns/group_event_helper.rb
new file mode 100644
index 000000000..d52de2692
--- /dev/null
+++ b/app/services/whatsapp/baileys_handlers/concerns/group_event_helper.rb
@@ -0,0 +1,49 @@
+module Whatsapp::BaileysHandlers::Concerns::GroupEventHelper
+ private
+
+ def find_or_create_group_contact_inbox_by_jid(group_jid)
+ source_id = group_jid.split('@').first
+
+ ::ContactInboxWithContactBuilder.new(
+ source_id: source_id,
+ inbox: inbox,
+ contact_attributes: {
+ name: source_id,
+ identifier: group_jid,
+ group_type: :group
+ }
+ ).perform
+ end
+
+ def create_group_activity(conversation, action, **params)
+ locale = inbox.account.locale || I18n.default_locale
+
+ content = I18n.with_locale(locale) { I18n.t("conversations.activity.groups_update.#{action}", **params) }
+
+ conversation.messages.create!(
+ account_id: conversation.account_id,
+ inbox_id: conversation.inbox_id,
+ message_type: :activity,
+ content: content
+ )
+ end
+
+ def resolve_author_name(author_jid)
+ return author_jid if author_jid.blank?
+
+ lid = author_jid.split('@').first
+ contact_inbox = inbox.contact_inboxes.find_by(source_id: lid)
+ resolved_contact = contact_inbox&.contact
+
+ resolved_contact&.name.presence || resolved_contact&.phone_number || lid
+ end
+
+ def dispatch_group_synced_event(group_contact)
+ group_contact.reload
+ Rails.configuration.dispatcher.dispatch(
+ Events::Types::CONTACT_GROUP_SYNCED,
+ Time.zone.now,
+ contact: group_contact
+ )
+ end
+end
diff --git a/app/services/whatsapp/baileys_handlers/concerns/group_stub_message_handler.rb b/app/services/whatsapp/baileys_handlers/concerns/group_stub_message_handler.rb
new file mode 100644
index 000000000..04e80cd38
--- /dev/null
+++ b/app/services/whatsapp/baileys_handlers/concerns/group_stub_message_handler.rb
@@ -0,0 +1,170 @@
+module Whatsapp::BaileysHandlers::Concerns::GroupStubMessageHandler # rubocop:disable Metrics/ModuleLength
+ MEMBERSHIP_REQUEST_STUB = 'GROUP_MEMBERSHIP_JOIN_APPROVAL_REQUEST_NON_ADMIN_ADD'.freeze
+ ICON_CHANGE_STUB = 'GROUP_CHANGE_ICON'.freeze
+ GROUP_CREATE_STUB = 'GROUP_CREATE'.freeze
+
+ private
+
+ def handle_membership_request_stub
+ stub_params = @raw_message[:messageStubParameters]
+ return if stub_params.blank?
+
+ action = parse_membership_request_action(stub_params)
+ return unless action
+
+ group_jid = @raw_message[:key][:remoteJid]
+ contact_name = resolve_membership_request_contact_name(stub_params)
+
+ with_contact_lock(group_jid) do
+ group_contact_inbox = find_or_create_group_contact_inbox_by_jid(group_jid)
+ conversation = find_or_create_group_conversation(group_contact_inbox)
+ create_group_activity(conversation, action, contact_name: contact_name)
+ update_pending_join_requests(group_contact_inbox.contact, stub_params, action)
+ end
+ end
+
+ def handle_icon_change_stub
+ group_jid = @raw_message[:key][:remoteJid]
+ participant_jid = @raw_message[:key][:participant]
+
+ with_contact_lock(group_jid) do
+ group_contact_inbox = find_or_create_group_contact_inbox_by_jid(group_jid)
+ conversation = find_or_create_group_conversation(group_contact_inbox)
+ author_name = resolve_author_name(participant_jid)
+ create_group_activity(conversation, 'icon_changed', author_name: author_name)
+ update_group_avatar(group_contact_inbox.contact)
+ end
+ end
+
+ def handle_group_create_stub
+ group_jid = @raw_message[:key][:remoteJid]
+ group_name = @raw_message[:messageStubParameters]&.first
+
+ with_contact_lock(group_jid) do
+ group_contact_inbox = ::ContactInboxWithContactBuilder.new(
+ source_id: group_jid.split('@').first,
+ inbox: inbox,
+ contact_attributes: {
+ name: group_name || group_jid,
+ identifier: group_jid,
+ group_type: :group
+ }
+ ).perform
+
+ group_contact = group_contact_inbox.contact
+ was_group_left = group_contact.additional_attributes&.dig('group_left').present?
+ reset_group_left_flag(group_contact)
+ find_or_create_group_conversation(group_contact_inbox)
+ handle_group_rejoin(group_contact) if was_group_left
+ enqueue_group_sync(group_contact, force: was_group_left)
+ end
+ end
+
+ def handle_group_rejoin(group_contact)
+ add_inbox_contact_as_member(group_contact)
+ dispatch_group_synced_event(group_contact)
+ end
+
+ def enqueue_group_sync(group_contact, force: false)
+ Contacts::SyncGroupJob.set(wait: 5.seconds).perform_later(group_contact, force: force)
+ end
+
+ def add_inbox_contact_as_member(group_contact)
+ inbox_phone = inbox.channel.phone_number&.delete('+')
+ return if inbox_phone.blank?
+
+ contact = Contact.where(account_id: inbox.account_id)
+ .where("REPLACE(phone_number, '+', '') = ?", inbox_phone)
+ .first
+ return if contact.blank?
+
+ add_group_member(group_contact, contact)
+ end
+
+ def reset_group_left_flag(group_contact)
+ return unless group_contact.additional_attributes&.dig('group_left')
+
+ new_attrs = (group_contact.additional_attributes || {}).merge('group_left' => false)
+ group_contact.update!(additional_attributes: new_attrs)
+ end
+
+ def update_group_avatar(group_contact)
+ provider = group_contact.group_channel&.provider_service
+ return if provider.blank?
+
+ provider.try_update_group_avatar(group_contact, force: true)
+ rescue StandardError => e
+ Rails.logger.error "[GROUP_ICON] Failed to update avatar for #{group_contact.identifier}: #{e.message}"
+ end
+
+ def parse_membership_request_action(stub_params)
+ if stub_params.include?('created')
+ 'membership_request_created'
+ elsif stub_params.include?('revoked')
+ 'membership_request_revoked'
+ end
+ end
+
+ def resolve_membership_request_contact_name(stub_params)
+ participant_data = JSON.parse(stub_params.first)
+ lid = extract_jid_user(participant_data['lid'])
+ phone = extract_jid_user(participant_data['pn'])
+
+ find_contact_display_name(lid, phone) || format_fallback_name(lid, phone)
+ rescue JSON::ParserError, TypeError
+ extract_jid_user(@raw_message[:key][:participant])
+ end
+
+ def extract_jid_user(jid)
+ jid&.split('@')&.first
+ end
+
+ def find_contact_display_name(lid, phone)
+ source_id = lid || phone
+ return unless source_id
+
+ contact = inbox.contact_inboxes.find_by(source_id: source_id)&.contact
+ return unless contact
+
+ contact.name.presence || contact.phone_number
+ end
+
+ def format_fallback_name(lid, phone)
+ phone ? "+#{phone}" : lid
+ end
+
+ def update_pending_join_requests(group_contact, stub_params, action)
+ participant_data = JSON.parse(stub_params.first)
+ lid = participant_data['lid']
+ current_requests = group_contact.additional_attributes&.dig('pending_join_requests') || []
+ updated = current_requests.reject { |r| r['jid'] == lid }
+ updated << build_join_request_entry(participant_data) if action == 'membership_request_created'
+
+ new_attrs = (group_contact.additional_attributes || {}).merge('pending_join_requests' => updated)
+ group_contact.update!(additional_attributes: new_attrs)
+ rescue JSON::ParserError, TypeError => e
+ Rails.logger.error "[GROUP_STUB] Failed to update pending join requests: #{e.message}"
+ end
+
+ def build_join_request_entry(participant_data)
+ contact = find_or_create_requester_contact(participant_data['lid'], participant_data['pn'])
+ { 'jid' => participant_data['lid'], 'contact_id' => contact&.id, 'request_time' => Time.current.to_i.to_s }
+ end
+
+ def find_or_create_requester_contact(lid_jid, phone_jid)
+ lid = extract_jid_user(lid_jid)
+ phone = extract_jid_user(phone_jid)
+ source_id = lid || phone
+ return if source_id.blank?
+
+ contact_inbox = ::ContactInboxWithContactBuilder.new(
+ source_id: source_id, inbox: inbox,
+ contact_attributes: requester_contact_attributes(lid, phone)
+ ).perform
+ contact_inbox&.contact
+ end
+
+ def requester_contact_attributes(lid, phone)
+ { name: phone ? "+#{phone}" : lid, phone_number: ("+#{phone}" if phone), identifier: ("#{lid}@lid" if lid) }.compact
+ end
+end
diff --git a/app/services/whatsapp/baileys_handlers/concerns/individual_contact_message_handler.rb b/app/services/whatsapp/baileys_handlers/concerns/individual_contact_message_handler.rb
new file mode 100644
index 000000000..113adf193
--- /dev/null
+++ b/app/services/whatsapp/baileys_handlers/concerns/individual_contact_message_handler.rb
@@ -0,0 +1,80 @@
+module Whatsapp::BaileysHandlers::Concerns::IndividualContactMessageHandler
+ extend ActiveSupport::Concern
+ include Whatsapp::BaileysHandlers::Concerns::MessageCreationHandler
+
+ private
+
+ def handle_individual_contact_message
+ return unless extract_from_jid(type: 'lid')
+
+ @lock_acquired = acquire_message_processing_lock
+ return unless @lock_acquired
+
+ # Lock by contact phone to prevent race conditions when multiple messages
+ # from the same contact arrive simultaneously (e.g., WhatsApp albums).
+ contact_phone = extract_from_jid(type: 'pn') || extract_from_jid(type: 'lid')
+ with_contact_lock(contact_phone) do
+ # Re-check after acquiring lock to handle race conditions where:
+ # 1. An agent sends a message from Chatwoot (slow API call)
+ # 2. WhatsApp sends webhook before source_id is saved
+ # 3. Webhook handler times out waiting for channel lock and proceeds
+ # 4. By now, source_id should be set, so we can find the message
+ return if find_message_by_source_id(raw_message_id)
+
+ set_contact
+
+ unless @contact
+ Rails.logger.warn "Contact not found for message: #{raw_message_id}"
+ return
+ end
+
+ set_conversation
+ handle_create_message
+ end
+ ensure
+ clear_message_source_id_from_redis if @lock_acquired
+ end
+
+ def set_contact
+ phone = extract_from_jid(type: 'pn')
+ source_id = extract_from_jid(type: 'lid')
+ identifier = "#{source_id}@lid"
+
+ Whatsapp::ContactInboxConsolidationService.new(
+ inbox: inbox,
+ phone: phone,
+ lid: source_id,
+ identifier: identifier
+ ).perform
+
+ contact_inbox = ::ContactInboxWithContactBuilder.new(
+ source_id: source_id,
+ inbox: inbox,
+ contact_attributes: { name: contact_name, phone_number: ("+#{phone}" if phone), identifier: identifier }
+ ).perform
+
+ @contact_inbox = contact_inbox
+ @contact = contact_inbox.contact
+
+ update_contact_info(phone, source_id, identifier)
+ end
+
+ def update_contact_info(phone, source_id, identifier)
+ update_params = {
+ phone_number: ("+#{phone}" if phone),
+ identifier: (identifier if @contact.identifier != identifier),
+ name: (contact_name if @contact.name.in?([phone, source_id, identifier]))
+ }.compact
+
+ @contact.update!(update_params) if update_params.present?
+ try_update_contact_avatar
+ end
+
+ def handle_create_message
+ build_and_save_message(
+ conversation: @conversation,
+ sender: @contact,
+ attach_media: should_attach_media?
+ )
+ end
+end
diff --git a/app/services/whatsapp/baileys_handlers/concerns/message_creation_handler.rb b/app/services/whatsapp/baileys_handlers/concerns/message_creation_handler.rb
new file mode 100644
index 000000000..b099b3198
--- /dev/null
+++ b/app/services/whatsapp/baileys_handlers/concerns/message_creation_handler.rb
@@ -0,0 +1,78 @@
+module Whatsapp::BaileysHandlers::Concerns::MessageCreationHandler
+ extend ActiveSupport::Concern
+
+ private
+
+ def build_and_save_message(conversation:, sender:, attach_media: false)
+ @message = conversation.messages.build(
+ content: message_content,
+ account_id: inbox.account_id,
+ inbox_id: inbox.id,
+ source_id: raw_message_id,
+ sender: incoming? ? sender : nil,
+ message_type: incoming? ? :incoming : :outgoing,
+ content_attributes: build_message_content_attributes
+ )
+
+ attach_media_to_message if attach_media
+
+ @message.save!
+
+ inbox.channel.received_messages([@message], conversation) if incoming?
+
+ @message
+ end
+
+ def build_message_content_attributes
+ type = message_type
+ msg = unwrap_ephemeral_message(@raw_message[:message])
+ content_attributes = { external_created_at: baileys_extract_message_timestamp(@raw_message[:messageTimestamp]) }
+ content_attributes[:external_sender_name] = 'WhatsApp' unless incoming?
+
+ if type == 'reaction'
+ content_attributes[:in_reply_to_external_id] = msg.dig(:reactionMessage, :key, :id)
+ content_attributes[:is_reaction] = true
+ elsif reply_to_message_id
+ content_attributes[:in_reply_to_external_id] = reply_to_message_id
+ elsif type == 'unsupported'
+ content_attributes[:is_unsupported] = true
+ end
+
+ content_attributes
+ end
+
+ def attach_media_to_message
+ attachment_file = download_attachment_file
+ msg = unwrap_ephemeral_message(@raw_message[:message])
+
+ attachment = @message.attachments.build(
+ account_id: @message.account_id,
+ file_type: file_content_type.to_s,
+ file: { io: attachment_file, filename: build_attachment_filename, content_type: message_mimetype }
+ )
+ attachment.meta = { is_recorded_audio: true } if msg.dig(:audioMessage, :ptt)
+ rescue Down::Error => e
+ @message.is_unsupported = true
+ Rails.logger.error "Failed to download attachment for message #{raw_message_id}: #{e.message}"
+ end
+
+ def download_attachment_file
+ Down.download(
+ inbox.channel.media_url(@raw_message.dig(:key, :id)),
+ headers: inbox.channel.api_headers
+ )
+ end
+
+ def build_attachment_filename
+ msg = unwrap_ephemeral_message(@raw_message[:message])
+ filename = msg.dig(:documentMessage, :fileName) || msg.dig(:documentWithCaptionMessage, :message, :documentMessage, :fileName)
+ return filename if filename.present?
+
+ ext = ".#{message_mimetype.split(';').first.split('/').last}" if message_mimetype.present?
+ "#{file_content_type}_#{raw_message_id}_#{Time.current.strftime('%Y%m%d')}#{ext}"
+ end
+
+ def should_attach_media?
+ %w[image file video audio sticker].include?(message_type)
+ end
+end
diff --git a/app/services/whatsapp/baileys_handlers/group_participants_update.rb b/app/services/whatsapp/baileys_handlers/group_participants_update.rb
new file mode 100644
index 000000000..52589ce7f
--- /dev/null
+++ b/app/services/whatsapp/baileys_handlers/group_participants_update.rb
@@ -0,0 +1,118 @@
+module Whatsapp::BaileysHandlers::GroupParticipantsUpdate
+ include Whatsapp::BaileysHandlers::Helpers
+ include Whatsapp::BaileysHandlers::Concerns::GroupContactMessageHandler
+ include Whatsapp::BaileysHandlers::Concerns::GroupEventHelper
+
+ private
+
+ def process_group_participants_update
+ data = processed_params[:data]
+ return if data.blank?
+
+ group_jid, author, action, participants = data.values_at(:id, :author, :action, :participants)
+ return unless valid_participant_update?(group_jid, action, participants)
+
+ with_contact_lock(group_jid) do
+ group_contact_inbox = find_or_create_group_contact_inbox_by_jid(group_jid)
+ conversation = find_or_create_group_conversation(group_contact_inbox)
+ group_contact = group_contact_inbox.contact
+
+ contacts = participants.filter_map { |participant| find_or_create_participant_contact(participant) }
+ return if contacts.empty?
+
+ contacts.each { |contact| apply_participant_action(action, group_contact, contact) }
+ create_participant_activity(conversation, action, contacts, author)
+ dispatch_group_synced_event(group_contact)
+
+ resolve_conversations_if_inbox_left(action, author, contacts, group_contact_inbox)
+ end
+ end
+
+ def valid_participant_update?(group_jid, action, participants)
+ group_jid.present? && action.present? && participants.present? && action.in?(%w[add remove promote demote])
+ end
+
+ def apply_participant_action(action, group_contact, contact)
+ case action
+ when 'add'
+ add_group_member(group_contact, contact, role: :member)
+ when 'remove'
+ remove_group_member(group_contact, contact)
+ when 'promote'
+ update_group_member_role(group_contact, contact, :admin)
+ when 'demote'
+ update_group_member_role(group_contact, contact, :member)
+ end
+ end
+
+ def create_participant_activity(conversation, action, contacts, author_jid)
+ locale = inbox.account.locale || I18n.default_locale
+ action = resolve_effective_action(action, author_jid, contacts)
+
+ content = I18n.with_locale(locale) { build_activity_content(action, contacts, resolve_author_name(author_jid)) }
+
+ conversation.messages.create!(
+ account_id: conversation.account_id,
+ inbox_id: conversation.inbox_id,
+ message_type: :activity,
+ content: content
+ )
+ end
+
+ def resolve_effective_action(action, author_jid, contacts)
+ return 'join' if action == 'add' && author_jid.blank?
+ return 'leave' if action == 'remove' && author_is_participant?(author_jid, contacts)
+
+ action
+ end
+
+ def author_is_participant?(author_jid, contacts)
+ return false if author_jid.blank?
+
+ author_lid = author_jid.split('@').first
+ contacts.any? { |c| c.identifier&.start_with?(author_lid) || c.phone_number&.delete('+') == author_lid }
+ end
+
+ def build_activity_content(action, contacts, author_name)
+ names = contacts.map { |c| c.name.presence || c.phone_number || c.identifier }
+
+ return I18n.t("conversations.activity.group_participants.#{action}", contact_name: names.first) if action.in?(%w[join leave])
+
+ params = { author_name: author_name }
+
+ if names.one?
+ params[:contact_name] = names.first
+ I18n.t("conversations.activity.group_participants.#{action}.single", **params)
+ else
+ params[:contact_names] = names[..-2].join(', ')
+ params[:last_contact_name] = names.last
+ I18n.t("conversations.activity.group_participants.#{action}.multiple", **params)
+ end
+ end
+
+ def resolve_conversations_if_inbox_left(action, author_jid, contacts, group_contact_inbox)
+ return unless action == 'remove'
+ return unless inbox_phone_in_participants?(contacts)
+
+ effective_action = resolve_effective_action(action, author_jid, contacts)
+ return unless effective_action.in?(%w[leave remove])
+
+ mark_group_as_left(group_contact_inbox.contact)
+
+ group_contact_inbox.conversations.where(status: %i[open pending]).find_each do |conversation|
+ conversation.update!(status: :resolved)
+ end
+ end
+
+ def mark_group_as_left(group_contact)
+ new_attrs = (group_contact.additional_attributes || {}).merge('group_left' => true)
+ group_contact.update!(additional_attributes: new_attrs) if new_attrs != group_contact.additional_attributes
+ end
+
+ def inbox_phone_in_participants?(contacts)
+ inbox_phone = inbox.channel.phone_number&.delete('+')
+ return false if inbox_phone.blank?
+
+ contacts.any? { |c| c.phone_number&.delete('+') == inbox_phone }
+ end
+end
diff --git a/app/services/whatsapp/baileys_handlers/groups_activity.rb b/app/services/whatsapp/baileys_handlers/groups_activity.rb
new file mode 100644
index 000000000..333a49145
--- /dev/null
+++ b/app/services/whatsapp/baileys_handlers/groups_activity.rb
@@ -0,0 +1,26 @@
+module Whatsapp::BaileysHandlers::GroupsActivity
+ include Whatsapp::BaileysHandlers::Concerns::GroupEventHelper
+ include GroupConversationHandler
+
+ private
+
+ def process_groups_activity
+ activities = processed_params[:data]
+ return if activities.blank?
+
+ activities.each do |activity|
+ jid = activity[:jid]
+ next if jid.blank?
+
+ with_contact_lock(jid) do
+ group_contact_inbox = find_or_create_group_contact_inbox_by_jid(jid)
+ conversation = find_or_create_group_conversation(group_contact_inbox)
+
+ Contacts::SyncGroupJob.perform_later(group_contact_inbox.contact, soft: true)
+
+ conversation.update_columns(last_activity_at: Time.current) # rubocop:disable Rails/SkipsModelValidations
+ conversation.dispatch_conversation_updated_event
+ end
+ end
+ end
+end
diff --git a/app/services/whatsapp/baileys_handlers/groups_update.rb b/app/services/whatsapp/baileys_handlers/groups_update.rb
new file mode 100644
index 000000000..31d25e3c7
--- /dev/null
+++ b/app/services/whatsapp/baileys_handlers/groups_update.rb
@@ -0,0 +1,95 @@
+module Whatsapp::BaileysHandlers::GroupsUpdate
+ include Whatsapp::BaileysHandlers::Helpers
+ include Whatsapp::BaileysHandlers::Concerns::GroupContactMessageHandler
+ include Whatsapp::BaileysHandlers::Concerns::GroupEventHelper
+
+ TRACKED_SETTINGS = %w[restrict announce memberAddMode joinApprovalMode].freeze
+
+ private
+
+ def process_groups_update
+ updates = processed_params[:data]
+ return if updates.blank?
+
+ updates.each { |update| process_single_group_update(update) }
+ end
+
+ def process_single_group_update(update)
+ group_jid = update[:id]
+ return if group_jid.blank?
+
+ with_contact_lock(group_jid) do
+ group_contact_inbox = find_or_create_group_contact_inbox_by_jid(group_jid)
+ conversation = find_or_create_group_conversation(group_contact_inbox)
+ author_name = resolve_author_name(update[:author])
+
+ update_group_subject(group_contact_inbox, update[:subject], conversation, author_name) if update.key?(:subject)
+ update_group_description(conversation, update, author_name) if update.key?(:desc)
+ persist_invite_code_update(conversation, update) if update.key?(:inviteCode)
+ create_group_activity(conversation, 'invite_link_reset', author_name: author_name) if update.key?(:inviteCode)
+ persist_settings_changes(conversation, update)
+ process_group_settings_changes(conversation, update, author_name)
+
+ dispatch_group_synced_event(group_contact_inbox.contact)
+ end
+ end
+
+ def update_group_subject(group_contact_inbox, subject, conversation, author_name)
+ return if subject.blank?
+
+ contact = group_contact_inbox.contact
+ contact.update!(name: subject)
+
+ create_group_activity(conversation, 'subject_changed', author_name: author_name, value: subject)
+ end
+
+ def update_group_description(conversation, update, author_name)
+ desc = update[:desc]
+ contact = conversation.contact
+
+ current_attrs = contact.additional_attributes || {}
+ new_attrs = current_attrs.merge('description' => desc.presence)
+ contact.update!(additional_attributes: new_attrs) if current_attrs != new_attrs
+
+ if desc.present?
+ create_group_activity(conversation, 'description_changed', author_name: author_name)
+ else
+ create_group_activity(conversation, 'description_removed', author_name: author_name)
+ end
+ end
+
+ def process_group_settings_changes(conversation, update, author_name)
+ TRACKED_SETTINGS.each do |setting|
+ next unless update.key?(setting.to_sym)
+
+ value = update[setting.to_sym]
+ setting_key = setting.underscore
+ i18n_key = value ? "#{setting_key}_enabled" : "#{setting_key}_disabled"
+
+ create_group_activity(conversation, i18n_key, author_name: author_name)
+ end
+ end
+
+ def persist_settings_changes(conversation, update)
+ contact = conversation.contact
+ settings = {}
+ TRACKED_SETTINGS.each do |setting|
+ next unless update.key?(setting.to_sym)
+
+ settings[setting.underscore] = update[setting.to_sym]
+ end
+ return if settings.blank?
+
+ new_attrs = (contact.additional_attributes || {}).merge(settings)
+ contact.update!(additional_attributes: new_attrs) if new_attrs != contact.additional_attributes
+ end
+
+ def persist_invite_code_update(conversation, update)
+ contact = conversation.contact
+ invite_code = update[:inviteCode]
+ return if invite_code.blank?
+
+ new_attrs = (contact.additional_attributes || {}).merge('invite_code' => invite_code)
+ contact.update!(additional_attributes: new_attrs) if new_attrs != contact.additional_attributes
+ end
+end
diff --git a/app/services/whatsapp/baileys_handlers/helpers.rb b/app/services/whatsapp/baileys_handlers/helpers.rb
index 93f38f66d..cc2ee432c 100644
--- a/app/services/whatsapp/baileys_handlers/helpers.rb
+++ b/app/services/whatsapp/baileys_handlers/helpers.rb
@@ -73,7 +73,9 @@ module Whatsapp::BaileysHandlers::Helpers # rubocop:disable Metrics/ModuleLength
msg = unwrap_ephemeral_message(@raw_message[:message])
case message_type
when 'text'
- msg[:conversation] || msg.dig(:extendedTextMessage, :text)
+ text = msg[:conversation] || msg.dig(:extendedTextMessage, :text)
+ context_info = msg.dig(:extendedTextMessage, :contextInfo)
+ convert_incoming_mentions(text, context_info)
when 'image'
msg.dig(:imageMessage, :caption)
when 'video'
@@ -183,13 +185,14 @@ module Whatsapp::BaileysHandlers::Helpers # rubocop:disable Metrics/ModuleLength
nil
end
- def try_update_contact_avatar
+ def try_update_contact_avatar(contact = nil)
# TODO: Current logic will never update the contact avatar if their profile picture changes on WhatsApp.
- return if @contact.avatar.attached?
+ target_contact = contact || @contact
+ return if target_contact.avatar.attached?
- phone = extract_from_jid(type: 'pn')
+ phone = contact ? target_contact.phone_number&.delete('+') : extract_from_jid(type: 'pn')
profile_pic_url = fetch_profile_picture_url(phone) if phone
- ::Avatar::AvatarFromUrlJob.perform_later(@contact, profile_pic_url) if profile_pic_url
+ ::Avatar::AvatarFromUrlJob.perform_later(target_contact, profile_pic_url) if profile_pic_url
end
def message_under_process?
@@ -206,4 +209,10 @@ module Whatsapp::BaileysHandlers::Helpers # rubocop:disable Metrics/ModuleLength
key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: "#{inbox.id}_#{raw_message_id}")
::Redis::Alfred.delete(key)
end
+
+ def convert_incoming_mentions(text, context_info)
+ return text if text.blank? || context_info.blank?
+
+ Whatsapp::MentionConverterService.convert_incoming_mentions(text, context_info, inbox.account, inbox)
+ end
end
diff --git a/app/services/whatsapp/baileys_handlers/message_receipt_update.rb b/app/services/whatsapp/baileys_handlers/message_receipt_update.rb
new file mode 100644
index 000000000..5fa23efe0
--- /dev/null
+++ b/app/services/whatsapp/baileys_handlers/message_receipt_update.rb
@@ -0,0 +1,40 @@
+module Whatsapp::BaileysHandlers::MessageReceiptUpdate
+ include Whatsapp::BaileysHandlers::Helpers
+
+ private
+
+ def process_message_receipt_update
+ receipts = processed_params[:data]
+ receipts.each do |receipt|
+ @message = nil
+ @raw_message = receipt
+
+ next handle_receipt_update if incoming?
+
+ # NOTE: Shared lock with Whatsapp::SendOnWhatsappService
+ # Avoids race conditions when sending messages.
+ with_baileys_channel_lock_on_outgoing_message(inbox.channel.id) { handle_receipt_update }
+ end
+ end
+
+ def handle_receipt_update
+ return unless find_message_by_source_id(raw_message_id)
+
+ new_status = receipt_status
+ return if new_status.nil?
+ return unless receipt_status_transition_allowed?(new_status)
+
+ @message.update!(status: new_status)
+ end
+
+ def receipt_status
+ 'delivered' if @raw_message.dig(:receipt, :receiptTimestamp).present?
+ end
+
+ def receipt_status_transition_allowed?(new_status)
+ return false if @message.status == 'read'
+ return false if @message.status == 'delivered' && new_status == 'delivered'
+
+ true
+ end
+end
diff --git a/app/services/whatsapp/baileys_handlers/messages_upsert.rb b/app/services/whatsapp/baileys_handlers/messages_upsert.rb
index a7e6ccf99..8b0dc2711 100644
--- a/app/services/whatsapp/baileys_handlers/messages_upsert.rb
+++ b/app/services/whatsapp/baileys_handlers/messages_upsert.rb
@@ -1,5 +1,9 @@
-module Whatsapp::BaileysHandlers::MessagesUpsert # rubocop:disable Metrics/ModuleLength
+module Whatsapp::BaileysHandlers::MessagesUpsert
include Whatsapp::BaileysHandlers::Helpers
+ include Whatsapp::BaileysHandlers::Concerns::GroupContactMessageHandler
+ include Whatsapp::BaileysHandlers::Concerns::IndividualContactMessageHandler
+ include Whatsapp::BaileysHandlers::Concerns::GroupEventHelper
+ include Whatsapp::BaileysHandlers::Concerns::GroupStubMessageHandler
include BaileysHelper
private
@@ -20,141 +24,37 @@ module Whatsapp::BaileysHandlers::MessagesUpsert # rubocop:disable Metrics/Modul
end
end
- def handle_message # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity,Metrics/MethodLength
+ def handle_message
@lock_acquired = false
- return unless %w[lid user].include?(jid_type)
- return unless extract_from_jid(type: 'lid')
+ return handle_message_stub if message_stub?
+
return if ignore_message?
return if find_message_by_source_id(raw_message_id)
+ return handle_individual_contact_message if %w[lid user].include?(jid_type)
+ return handle_group_contact_message if jid_type == 'group' && Whatsapp::Providers::WhatsappBaileysService.groups_enabled?
+ end
+
+ def message_stub?
+ @raw_message[:messageStubType].present?
+ end
+
+ def handle_message_stub
+ return unless jid_type == 'group'
+
@lock_acquired = acquire_message_processing_lock
return unless @lock_acquired
- # Lock by contact phone to prevent race conditions when multiple messages
- # from the same contact arrive simultaneously (e.g., WhatsApp albums).
- contact_phone = extract_from_jid(type: 'pn') || extract_from_jid(type: 'lid')
- with_contact_lock(contact_phone) do
- # Re-check after acquiring lock to handle race conditions where:
- # 1. An agent sends a message from Chatwoot (slow API call)
- # 2. WhatsApp sends webhook before source_id is saved
- # 3. Webhook handler times out waiting for channel lock and proceeds
- # 4. By now, source_id should be set, so we can find the message
- return if find_message_by_source_id(raw_message_id)
-
- set_contact
-
- unless @contact
- Rails.logger.warn "Contact not found for message: #{raw_message_id}"
- return
- end
-
- set_conversation
- handle_create_message
+ case @raw_message[:messageStubType]
+ when MEMBERSHIP_REQUEST_STUB
+ handle_membership_request_stub
+ when ICON_CHANGE_STUB
+ handle_icon_change_stub
+ when GROUP_CREATE_STUB
+ handle_group_create_stub
end
ensure
clear_message_source_id_from_redis if @lock_acquired
end
-
- def set_contact
- phone = extract_from_jid(type: 'pn')
- source_id = extract_from_jid(type: 'lid')
- identifier = "#{source_id}@lid"
-
- Whatsapp::ContactInboxConsolidationService.new(
- inbox: inbox,
- phone: phone,
- lid: source_id,
- identifier: identifier
- ).perform
-
- contact_inbox = ::ContactInboxWithContactBuilder.new(
- source_id: source_id,
- inbox: inbox,
- contact_attributes: { name: contact_name, phone_number: ("+#{phone}" if phone), identifier: identifier }
- ).perform
-
- @contact_inbox = contact_inbox
- @contact = contact_inbox.contact
-
- update_contact_info(phone, source_id, identifier)
- end
-
- def update_contact_info(phone, source_id, identifier)
- update_params = {}
- update_params[:phone_number] = "+#{phone}" if phone
- update_params[:identifier] = identifier
- update_params[:name] = contact_name if @contact.name.in?([phone, source_id, identifier])
-
- @contact.update!(update_params) if update_params.present?
- try_update_contact_avatar
- end
-
- def handle_create_message
- create_message(attach_media: %w[image file video audio sticker].include?(message_type))
- end
-
- def create_message(attach_media: false)
- @message = @conversation.messages.build(
- content: message_content,
- account_id: @inbox.account_id,
- inbox_id: @inbox.id,
- source_id: raw_message_id,
- sender: incoming? ? @contact : nil,
- message_type: incoming? ? :incoming : :outgoing,
- content_attributes: message_content_attributes
- )
-
- handle_attach_media if attach_media
-
- @message.save!
-
- inbox.channel.received_messages([@message], @conversation) if incoming?
- end
-
- def message_content_attributes
- type = message_type
- msg = unwrap_ephemeral_message(@raw_message[:message])
- content_attributes = { external_created_at: baileys_extract_message_timestamp(@raw_message[:messageTimestamp]) }
- content_attributes[:external_sender_name] = 'WhatsApp' unless incoming?
- if type == 'reaction'
- content_attributes[:in_reply_to_external_id] = msg.dig(:reactionMessage, :key, :id)
- content_attributes[:is_reaction] = true
- elsif reply_to_message_id
- content_attributes[:in_reply_to_external_id] = reply_to_message_id
- elsif type == 'unsupported'
- content_attributes[:is_unsupported] = true
- end
-
- content_attributes
- end
-
- def handle_attach_media
- attachment_file = download_attachment_file
- msg = unwrap_ephemeral_message(@raw_message[:message])
-
- attachment = @message.attachments.build(
- account_id: @message.account_id,
- file_type: file_content_type.to_s,
- file: { io: attachment_file, filename: filename, content_type: message_mimetype }
- )
- attachment.meta = { is_recorded_audio: true } if msg.dig(:audioMessage, :ptt)
- rescue Down::Error => e
- @message.update!(is_unsupported: true)
-
- Rails.logger.error "Failed to download attachment for message #{raw_message_id}: #{e.message}"
- end
-
- def download_attachment_file
- Down.download(@conversation.inbox.channel.media_url(@raw_message.dig(:key, :id)), headers: @conversation.inbox.channel.api_headers)
- end
-
- def filename
- msg = unwrap_ephemeral_message(@raw_message[:message])
- filename = msg.dig(:documentMessage, :fileName) || msg.dig(:documentWithCaptionMessage, :message, :documentMessage, :fileName)
- return filename if filename.present?
-
- ext = ".#{message_mimetype.split(';').first.split('/').last}" if message_mimetype.present?
- "#{file_content_type}_#{raw_message_id}_#{Time.current.strftime('%Y%m%d')}#{ext}"
- end
end
diff --git a/app/services/whatsapp/incoming_message_baileys_service.rb b/app/services/whatsapp/incoming_message_baileys_service.rb
index e9132270a..6b6df8eea 100644
--- a/app/services/whatsapp/incoming_message_baileys_service.rb
+++ b/app/services/whatsapp/incoming_message_baileys_service.rb
@@ -3,6 +3,10 @@ class Whatsapp::IncomingMessageBaileysService < Whatsapp::IncomingMessageBaseSer
include Whatsapp::BaileysHandlers::ConnectionUpdate
include Whatsapp::BaileysHandlers::MessagesUpsert
include Whatsapp::BaileysHandlers::MessagesUpdate
+ include Whatsapp::BaileysHandlers::MessageReceiptUpdate
+ include Whatsapp::BaileysHandlers::GroupParticipantsUpdate
+ include Whatsapp::BaileysHandlers::GroupsUpdate
+ include Whatsapp::BaileysHandlers::GroupsActivity
class InvalidWebhookVerifyToken < StandardError; end
diff --git a/app/services/whatsapp/mention_converter_service.rb b/app/services/whatsapp/mention_converter_service.rb
new file mode 100644
index 000000000..f9f3712ad
--- /dev/null
+++ b/app/services/whatsapp/mention_converter_service.rb
@@ -0,0 +1,162 @@
+class Whatsapp::MentionConverterService
+ MENTION_REGEX = %r{\[@([^\]]+)\]\(mention://contact/(\d+)/([^)]+)\)}
+ ALL_MENTION_REGEX = %r{\[@[^\]]*\]\(mention://contact/0/all\)}
+ INCOMING_ALL_PATTERNS = /@(all|todos|everyone)\b/i
+
+ class << self
+ def extract_mentions_for_whatsapp(content, account)
+ return {} if content.blank?
+
+ mentions = collect_mention_jids(content, account)
+ result = {}
+ result[:mentions] = mentions if mentions.present?
+ result[:groupMentions] = [{ groupSubject: 'everyone' }] if ALL_MENTION_REGEX.match?(content)
+ result
+ end
+
+ # Replaces @DisplayName with @ in outgoing rendered text so Baileys can match mentions
+ def replace_mentions_in_outgoing_text(raw_content, rendered_text, account)
+ return rendered_text if raw_content.blank? || rendered_text.blank?
+
+ result = rendered_text.dup
+ raw_content.scan(MENTION_REGEX).each do |display_name, id, _encoded_name|
+ next if id == '0'
+
+ jid_user = resolve_contact_jid_user(id, account)
+ next if jid_user.blank? || jid_user == display_name
+
+ result.sub!(/@#{Regexp.escape(display_name)}(?![\w(])/, "@#{jid_user}")
+ end
+ result
+ end
+
+ def convert_incoming_mentions(text, context_info, account, inbox)
+ return text if text.blank? || context_info.blank?
+
+ result = convert_jid_mentions(text.dup, context_info, account, inbox)
+ convert_group_mentions(result, context_info)
+ end
+
+ private
+
+ def collect_mention_jids(content, account)
+ jids = content.scan(MENTION_REGEX).filter_map do |_name, id, _encoded_name|
+ next if id == '0'
+
+ contact = account.contacts.find_by(id: id)
+ next if contact.blank?
+
+ # Prefer LID identifier if available (e.g., "123456@lid")
+ if contact.identifier.present? && contact.identifier.end_with?('@lid')
+ contact.identifier
+ elsif contact.phone_number.present?
+ "#{contact.phone_number.delete('+')}@s.whatsapp.net"
+ end
+ end
+
+ jids.compact.uniq
+ end
+
+ def resolve_contact_jid_user(contact_id, account)
+ contact = account.contacts.find_by(id: contact_id)
+ return nil if contact.blank?
+
+ if contact.identifier.present? && contact.identifier.end_with?('@lid')
+ contact.identifier.sub(/@lid$/, '')
+ elsif contact.phone_number.present?
+ contact.phone_number.delete('+')
+ end
+ end
+
+ def convert_jid_mentions(text, context_info, account, inbox)
+ mentioned_jids = context_info[:mentionedJid] || context_info['mentionedJid']
+ return text if mentioned_jids.blank?
+
+ mentioned_jids.reduce(text) do |result, jid|
+ jid_user, jid_server = jid.split('@')
+
+ if jid_server == 'lid'
+ apply_lid_mention(result, jid_user, account, inbox)
+ else
+ apply_jid_mention(result, jid_user, account)
+ end
+ end
+ end
+
+ def apply_jid_mention(text, phone, account)
+ contact = find_contact_by_phone(phone, account)
+ return text unless contact
+
+ display_name = contact.name.presence || phone
+ encoded_name = ERB::Util.url_encode(display_name)
+ mention_uri = "[@#{display_name}](mention://contact/#{contact.id}/#{encoded_name})"
+
+ replace_mention_in_text(text, phone, display_name, mention_uri)
+ end
+
+ def apply_lid_mention(text, lid, account, inbox) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
+ contact = find_contact_by_lid(lid, account, inbox)
+ return text unless contact
+
+ contact_phone = contact.phone_number&.delete('+')
+ display_name = contact.name.presence || contact_phone || lid
+ encoded_name = ERB::Util.url_encode(display_name)
+ mention_uri = "[@#{display_name}](mention://contact/#{contact.id}/#{encoded_name})"
+
+ # Try @lid first, then @phone, then @displayName
+ patterns = [/@#{Regexp.escape(lid)}/]
+ patterns << /@#{Regexp.escape(contact_phone)}/ if contact_phone.present?
+ patterns << /@#{Regexp.escape(display_name)}/i if display_name != lid && display_name != contact_phone
+
+ patterns.each do |pattern|
+ return text.sub(pattern, mention_uri) if text.match?(pattern)
+ end
+
+ text
+ end
+
+ def convert_group_mentions(text, context_info)
+ group_mentions = context_info[:groupMentions] || context_info['groupMentions']
+ return text if group_mentions.blank?
+
+ text.gsub(INCOMING_ALL_PATTERNS, '[@all](mention://contact/0/all)')
+ end
+
+ def find_contact_by_phone(phone, account)
+ # Try exact match first
+ contact = account.contacts.find_by(phone_number: phone)
+ contact ||= account.contacts.find_by(phone_number: "+#{phone}")
+
+ # Brazilian number fallback: try last 8 digits
+ if contact.nil? && phone.length >= 8
+ last_digits = phone[-8..]
+ contact = account.contacts.where('phone_number LIKE ?', "%#{last_digits}").first
+ end
+
+ contact
+ end
+
+ def find_contact_by_lid(lid, account, inbox)
+ # Try by identifier (stored as "lid@lid")
+ contact = account.contacts.find_by(identifier: "#{lid}@lid")
+ return contact if contact
+
+ # Fallback: try by contact_inbox source_id
+ inbox.contact_inboxes.find_by(source_id: lid)&.contact
+ end
+
+ def replace_mention_in_text(text, phone, display_name, mention_uri)
+ # Try @phone first, then @DisplayName
+ patterns = [
+ /@#{Regexp.escape(phone)}/,
+ /@#{Regexp.escape(display_name)}/i
+ ]
+
+ patterns.each do |pattern|
+ return text.sub(pattern, mention_uri) if text.match?(pattern)
+ end
+
+ text
+ end
+ end
+end
diff --git a/app/services/whatsapp/providers/base_service.rb b/app/services/whatsapp/providers/base_service.rb
index 42a71367d..177384e11 100644
--- a/app/services/whatsapp/providers/base_service.rb
+++ b/app/services/whatsapp/providers/base_service.rb
@@ -27,6 +27,42 @@ class Whatsapp::Providers::BaseService
raise 'Overwrite this method in child class'
end
+ def create_group(_subject, _participants)
+ raise 'Overwrite this method in child class'
+ end
+
+ def update_group_subject(_group_jid, _subject)
+ raise 'Overwrite this method in child class'
+ end
+
+ def update_group_description(_group_jid, _description)
+ raise 'Overwrite this method in child class'
+ end
+
+ def update_group_participants(_group_jid, _participants, _action)
+ raise 'Overwrite this method in child class'
+ end
+
+ def update_group_picture(_group_jid, _image_base64)
+ raise 'Overwrite this method in child class'
+ end
+
+ def group_invite_code(_group_jid)
+ raise 'Overwrite this method in child class'
+ end
+
+ def revoke_group_invite(_group_jid)
+ raise 'Overwrite this method in child class'
+ end
+
+ def group_join_requests(_group_jid)
+ raise 'Overwrite this method in child class'
+ end
+
+ def handle_group_join_requests(_group_jid, _participants, _action)
+ raise 'Overwrite this method in child class'
+ end
+
def error_message
raise 'Overwrite this method in child class'
end
diff --git a/app/services/whatsapp/providers/whatsapp_baileys_service.rb b/app/services/whatsapp/providers/whatsapp_baileys_service.rb
index 2817b8ade..014b15680 100644
--- a/app/services/whatsapp/providers/whatsapp_baileys_service.rb
+++ b/app/services/whatsapp/providers/whatsapp_baileys_service.rb
@@ -3,10 +3,16 @@ class Whatsapp::Providers::WhatsappBaileysService < Whatsapp::Providers::BaseSer
class MessageContentTypeNotSupported < StandardError; end
class ProviderUnavailableError < StandardError; end
+ class GroupParticipantNotAllowedError < StandardError; end
DEFAULT_CLIENT_NAME = ENV.fetch('BAILEYS_PROVIDER_DEFAULT_CLIENT_NAME', nil)
DEFAULT_URL = ENV.fetch('BAILEYS_PROVIDER_DEFAULT_URL', nil)
DEFAULT_API_KEY = ENV.fetch('BAILEYS_PROVIDER_DEFAULT_API_KEY', nil)
+ GROUPS_ENABLED = ENV.fetch('BAILEYS_WHATSAPP_GROUPS_ENABLED', 'false') == 'true'
+
+ def self.groups_enabled?
+ GROUPS_ENABLED
+ end
def self.status
if DEFAULT_URL.blank? || DEFAULT_API_KEY.blank?
@@ -40,7 +46,8 @@ class Whatsapp::Providers::WhatsappBaileysService < Whatsapp::Providers::BaseSer
webhookUrl: whatsapp_channel.inbox.callback_webhook_url,
webhookVerifyToken: whatsapp_channel.provider_config['webhook_verify_token'],
# TODO: Remove on Baileys v2, default will be false
- includeMedia: false
+ includeMedia: false,
+ groupsEnabled: GROUPS_ENABLED
}.compact.to_json
)
@@ -70,6 +77,7 @@ class Whatsapp::Providers::WhatsappBaileysService < Whatsapp::Providers::BaseSer
@message_content = attachment_message_content.merge(reply_context)
elsif @message.outgoing_content.present?
@message_content = { text: @message.outgoing_content }.merge(reply_context)
+ merge_mention_data
else
@message.update!(is_unsupported: true)
return
@@ -82,6 +90,188 @@ class Whatsapp::Providers::WhatsappBaileysService < Whatsapp::Providers::BaseSer
def sync_templates; end
+ def allow_group_creation?
+ true
+ end
+
+ def create_group(subject, participants)
+ response = HTTParty.post(
+ "#{provider_url}/connections/#{whatsapp_channel.phone_number}/group-create",
+ headers: api_headers,
+ body: { subject: subject, participants: participants }.to_json
+ )
+
+ raise ProviderUnavailableError unless process_response(response)
+
+ response.parsed_response&.deep_symbolize_keys
+ end
+
+ def update_group_subject(group_jid, subject)
+ response = HTTParty.post(
+ "#{provider_url}/connections/#{whatsapp_channel.phone_number}/group-subject",
+ headers: api_headers,
+ body: { jid: group_jid, subject: subject }.to_json
+ )
+
+ raise ProviderUnavailableError unless process_response(response)
+ end
+
+ def update_group_description(group_jid, description)
+ response = HTTParty.post(
+ "#{provider_url}/connections/#{whatsapp_channel.phone_number}/group-description",
+ headers: api_headers,
+ body: { jid: group_jid, description: description }.to_json
+ )
+
+ raise ProviderUnavailableError unless process_response(response)
+ end
+
+ def update_group_picture(group_jid, image_base64)
+ response = HTTParty.post(
+ "#{provider_url}/connections/#{whatsapp_channel.phone_number}/update-profile-picture",
+ headers: api_headers,
+ body: { jid: group_jid, image: image_base64 }.to_json
+ )
+
+ raise ProviderUnavailableError unless process_response(response)
+ end
+
+ def update_group_participants(group_jid, participants, action)
+ Array(participants).each do |participant|
+ response = HTTParty.post(
+ "#{provider_url}/connections/#{whatsapp_channel.phone_number}/group-participants",
+ headers: api_headers,
+ body: { jid: group_jid, participant: participant, action: action }.to_json
+ )
+
+ raise ProviderUnavailableError unless process_response(response)
+
+ check_participant_errors(response, action)
+ end
+ end
+
+ def group_invite_code(group_jid)
+ response = HTTParty.get(
+ "#{provider_url}/connections/#{whatsapp_channel.phone_number}/group-invite-code",
+ headers: api_headers,
+ query: { jid: group_jid },
+ format: :json
+ )
+
+ raise ProviderUnavailableError unless process_response(response)
+
+ response.parsed_response&.dig('data', 'inviteCode')
+ end
+
+ def revoke_group_invite(group_jid)
+ response = HTTParty.post(
+ "#{provider_url}/connections/#{whatsapp_channel.phone_number}/group-revoke-invite",
+ headers: api_headers,
+ body: { jid: group_jid }.to_json
+ )
+
+ raise ProviderUnavailableError unless process_response(response)
+
+ response.parsed_response&.dig('data', 'inviteCode')
+ end
+
+ def group_join_requests(group_jid)
+ response = HTTParty.get(
+ "#{provider_url}/connections/#{whatsapp_channel.phone_number}/group-request-participants-list",
+ headers: api_headers,
+ query: { jid: group_jid },
+ format: :json
+ )
+
+ return [] if response.code == 403
+
+ raise ProviderUnavailableError unless process_response(response)
+
+ parsed = response.parsed_response
+ parsed.is_a?(Array) ? parsed : (parsed&.dig('data') || [])
+ end
+
+ def handle_group_join_requests(group_jid, participants, action)
+ response = HTTParty.post(
+ "#{provider_url}/connections/#{whatsapp_channel.phone_number}/group-request-participants-update",
+ headers: api_headers,
+ body: { jid: group_jid, participants: participants, action: action }.to_json
+ )
+
+ raise ProviderUnavailableError unless process_response(response)
+ end
+
+ def group_leave(group_jid)
+ response = HTTParty.post(
+ "#{provider_url}/connections/#{whatsapp_channel.phone_number}/group-leave",
+ headers: api_headers,
+ body: { jid: group_jid }.to_json
+ )
+
+ raise ProviderUnavailableError unless process_response(response)
+ end
+
+ PROPERTY_TO_SETTING = {
+ ['announce', true] => 'announcement',
+ ['announce', false] => 'not_announcement',
+ ['restrict', true] => 'locked',
+ ['restrict', false] => 'unlocked'
+ }.freeze
+
+ def group_setting_update(group_jid, property, enabled)
+ setting = PROPERTY_TO_SETTING[[property, enabled]]
+ response = HTTParty.post(
+ "#{provider_url}/connections/#{whatsapp_channel.phone_number}/group-setting-update",
+ headers: api_headers,
+ body: { jid: group_jid, setting: setting }.to_json
+ )
+
+ raise ProviderUnavailableError unless process_response(response)
+ end
+
+ def group_join_approval_mode(group_jid, mode)
+ response = HTTParty.post(
+ "#{provider_url}/connections/#{whatsapp_channel.phone_number}/group-join-approval-mode",
+ headers: api_headers,
+ body: { jid: group_jid, mode: mode }.to_json
+ )
+
+ raise ProviderUnavailableError unless process_response(response)
+ end
+
+ def group_member_add_mode(group_jid, mode)
+ response = HTTParty.post(
+ "#{provider_url}/connections/#{whatsapp_channel.phone_number}/group-member-add-mode",
+ headers: api_headers,
+ body: { jid: group_jid, mode: mode }.to_json
+ )
+
+ raise ProviderUnavailableError unless process_response(response)
+ end
+
+ def sync_group(conversation, soft: false)
+ group_contact = conversation.contact
+
+ return true if group_contact.additional_attributes&.dig('group_left')
+
+ inbox = conversation.inbox
+
+ metadata = group_metadata(group_contact.identifier)
+ raise ProviderUnavailableError, 'Could not fetch group metadata' if metadata.blank?
+
+ update_group_contact_info(group_contact, metadata)
+ persist_group_settings(group_contact, metadata)
+ persist_invite_code(group_contact) unless soft
+ persist_pending_join_requests(group_contact, inbox) unless soft
+ try_update_group_avatar(group_contact) unless soft
+
+ participant_contacts = build_participant_contacts(metadata[:participants], inbox, skip_avatars: soft)
+ sync_group_members(group_contact, participant_contacts)
+ persist_sync_status(group_contact)
+
+ true
+ end
+
def media_url(media_id)
"#{provider_url}/media/#{media_id}"
end
@@ -148,13 +338,7 @@ class Whatsapp::Providers::WhatsappBaileysService < Whatsapp::Providers::BaseSer
"#{provider_url}/connections/#{whatsapp_channel.phone_number}/read-messages",
headers: api_headers,
body: {
- keys: messages.map do |message|
- {
- id: message.source_id,
- remoteJid: remote_jid,
- fromMe: message.message_type == 'outgoing'
- }
- end
+ keys: messages.map { |message| message_key_for(message) }
}.to_json
)
@@ -163,7 +347,7 @@ class Whatsapp::Providers::WhatsappBaileysService < Whatsapp::Providers::BaseSer
true
end
- def unread_message(recipient_id, message) # rubocop:disable Metrics/MethodLength
+ def unread_message(recipient_id, message)
@recipient_id = recipient_id
response = HTTParty.post(
@@ -174,11 +358,7 @@ class Whatsapp::Providers::WhatsappBaileysService < Whatsapp::Providers::BaseSer
mod: {
markRead: false,
lastMessages: [{
- key: {
- id: message.source_id,
- remoteJid: remote_jid,
- fromMe: message.message_type == 'outgoing'
- },
+ key: message_key_for(message),
messageTimestamp: message.content_attributes[:external_created_at]
}]
}
@@ -197,13 +377,7 @@ class Whatsapp::Providers::WhatsappBaileysService < Whatsapp::Providers::BaseSer
"#{provider_url}/connections/#{whatsapp_channel.phone_number}/send-receipts",
headers: api_headers,
body: {
- keys: messages.map do |message|
- {
- id: message.source_id,
- remoteJid: remote_jid,
- fromMe: message.message_type == 'outgoing'
- }
- end
+ keys: messages.map { |message| message_key_for(message) }
}.to_json
)
@@ -225,6 +399,19 @@ class Whatsapp::Providers::WhatsappBaileysService < Whatsapp::Providers::BaseSer
response.parsed_response
end
+ def group_metadata(group_jid)
+ response = HTTParty.get(
+ "#{provider_url}/connections/#{whatsapp_channel.phone_number}/group-metadata",
+ headers: api_headers,
+ query: { jid: group_jid },
+ format: :json
+ )
+
+ raise ProviderUnavailableError unless process_response(response)
+
+ response.parsed_response&.deep_symbolize_keys
+ end
+
def on_whatsapp(recipient_id)
@recipient_id = recipient_id
@@ -238,7 +425,7 @@ class Whatsapp::Providers::WhatsappBaileysService < Whatsapp::Providers::BaseSer
raise ProviderUnavailableError unless process_response(response)
- response.parsed_response&.first || { 'jid' => remote_jid, 'exists' => false }
+ response.parsed_response&.dig('data')&.first || { 'jid' => remote_jid, 'exists' => false }
end
def delete_message(recipient_id, message)
@@ -249,11 +436,7 @@ class Whatsapp::Providers::WhatsappBaileysService < Whatsapp::Providers::BaseSer
headers: api_headers,
body: {
jid: remote_jid,
- key: {
- id: message.source_id,
- remoteJid: remote_jid,
- fromMe: message.message_type == 'outgoing'
- }
+ key: message_key_for(message)
}.to_json
)
@@ -270,11 +453,7 @@ class Whatsapp::Providers::WhatsappBaileysService < Whatsapp::Providers::BaseSer
headers: api_headers,
body: {
jid: remote_jid,
- key: {
- id: message.source_id,
- remoteJid: remote_jid,
- fromMe: message.message_type == 'outgoing'
- },
+ key: message_key_for(message),
messageContent: { text: new_content }
}.to_json
)
@@ -297,10 +476,10 @@ class Whatsapp::Providers::WhatsappBaileysService < Whatsapp::Providers::BaseSer
def reaction_message_content
reply_to = Message.find(@message.in_reply_to)
{
- react: { key: { id: reply_to.source_id,
- remoteJid: remote_jid,
- fromMe: reply_to.message_type == 'outgoing' },
- text: @message.outgoing_content }
+ react: {
+ key: message_key_for(reply_to),
+ text: @message.outgoing_content
+ }
}
end
@@ -313,16 +492,28 @@ class Whatsapp::Providers::WhatsappBaileysService < Whatsapp::Providers::BaseSer
{
quotedMessage: {
- key: {
- id: reply_to_external_id,
- remoteJid: remote_jid,
- fromMe: reply_to_message.message_type == 'outgoing'
- },
+ key: message_key_for(reply_to_message),
message: quoted_message_content(reply_to_message)
}
}
end
+ def message_key_for(message)
+ {
+ id: message.source_id,
+ remoteJid: remote_jid,
+ fromMe: message.message_type == 'outgoing',
+ participant: group_participant_jid(message)
+ }.compact
+ end
+
+ def group_participant_jid(message)
+ return unless remote_jid.ends_with?('@g.us')
+ return if message.message_type == 'outgoing'
+
+ message.sender&.identifier
+ end
+
def quoted_message_content(message)
if message.attachments.present?
attachment = message.attachments.first
@@ -390,8 +581,33 @@ class Whatsapp::Providers::WhatsappBaileysService < Whatsapp::Providers::BaseSer
response.success?
end
+ def check_participant_errors(response, action)
+ return unless action.in?(%w[demote remove])
+
+ results = response.parsed_response
+ return unless results.is_a?(Array)
+
+ failed = results.find { |r| r['status'].to_s == '406' }
+ return if failed.blank?
+
+ raise GroupParticipantNotAllowedError, 'group_creator_not_modifiable'
+ end
+
+ def merge_mention_data
+ return if @message.content.blank?
+
+ mention_data = Whatsapp::MentionConverterService.extract_mentions_for_whatsapp(@message.content, whatsapp_channel.account)
+ @message_content.merge!(mention_data) if mention_data.present?
+
+ # Replace @DisplayName with @lid/@phone in text so Baileys can match mentions
+ @message_content[:text] = Whatsapp::MentionConverterService.replace_mentions_in_outgoing_text(
+ @message.content, @message_content[:text], whatsapp_channel.account
+ )
+ end
+
def remote_jid
return @recipient_id if @recipient_id.ends_with?('@lid')
+ return @recipient_id if @recipient_id.ends_with?('@g.us')
"#{@recipient_id.delete('+')}@s.whatsapp.net"
end
@@ -404,6 +620,191 @@ class Whatsapp::Providers::WhatsappBaileysService < Whatsapp::Providers::BaseSer
@message.update!(external_created_at: external_created_at)
end
+ def build_participant_contacts(participants, inbox, skip_avatars: false)
+ return [] if participants.blank?
+
+ participants.filter_map do |participant|
+ contact = find_or_create_participant_contact(participant, inbox)
+ next if contact.blank?
+
+ try_update_participant_avatar(contact) unless skip_avatars
+ { contact: contact, admin: participant[:admin] }
+ end
+ end
+
+ def update_group_contact_info(group_contact, metadata)
+ update_params = {}
+ update_params[:name] = metadata[:subject] if metadata[:subject].present? && group_contact.name != metadata[:subject]
+
+ new_attrs = (group_contact.additional_attributes || {}).merge(
+ 'description' => metadata[:desc].presence,
+ 'owner' => metadata[:owner],
+ 'owner_pn' => metadata[:ownerPn].presence
+ )
+ update_params[:additional_attributes] = new_attrs if new_attrs != group_contact.additional_attributes
+
+ group_contact.update!(update_params) if update_params.present?
+ end
+
+ def sync_group_members(group_contact, participant_contacts)
+ return if participant_contacts.blank?
+
+ new_contact_ids = participant_contacts.filter_map do |entry|
+ role = entry[:admin].in?(%w[admin superadmin]) ? :admin : :member
+ member = GroupMember.find_or_initialize_by(group_contact: group_contact, contact: entry[:contact])
+ member.assign_attributes(role: role, is_active: true)
+ member.save! if member.changed?
+ entry[:contact].id
+ end
+
+ group_contact.group_memberships.active.where.not(contact_id: new_contact_ids).find_each do |member|
+ member.update!(is_active: false)
+ end
+ end
+
+ TRACKED_GROUP_SETTINGS = {
+ announce: 'announce',
+ restrict: 'restrict',
+ joinApprovalMode: 'join_approval_mode',
+ memberAddMode: 'member_add_mode'
+ }.freeze
+
+ def persist_group_settings(group_contact, metadata)
+ settings = TRACKED_GROUP_SETTINGS.each_with_object({}) do |(api_key, attr_key), hash|
+ hash[attr_key] = metadata[api_key] if metadata.key?(api_key)
+ end
+ return if settings.blank?
+
+ new_attrs = (group_contact.additional_attributes || {}).merge(settings)
+ group_contact.update!(additional_attributes: new_attrs) if new_attrs != group_contact.additional_attributes
+ end
+
+ def persist_sync_status(group_contact)
+ new_attrs = (group_contact.additional_attributes || {}).merge(
+ 'group_last_synced_at' => Time.current.to_i,
+ 'group_left' => false
+ )
+ group_contact.update!(additional_attributes: new_attrs) if new_attrs != group_contact.additional_attributes
+ end
+
+ def persist_invite_code(group_contact)
+ code = group_invite_code(group_contact.identifier)
+ return if code.blank?
+
+ new_attrs = (group_contact.additional_attributes || {}).merge('invite_code' => code)
+ group_contact.update!(additional_attributes: new_attrs) if new_attrs != group_contact.additional_attributes
+ rescue StandardError => e
+ Rails.logger.error "Failed to fetch invite code for group #{group_contact.identifier}: #{e.message}"
+ end
+
+ def persist_pending_join_requests(group_contact, inbox)
+ raw_requests = group_join_requests(group_contact.identifier)
+ requests = raw_requests.filter_map do |req|
+ contact = find_or_create_participant_contact({ id: req['jid'], phoneNumber: req['phone_number'] }, inbox)
+ next if contact.blank?
+
+ { 'jid' => req['jid'], 'contact_id' => contact.id, 'request_time' => req['request_time'] }
+ end
+
+ new_attrs = (group_contact.additional_attributes || {}).merge('pending_join_requests' => requests)
+ group_contact.update!(additional_attributes: new_attrs) if new_attrs != group_contact.additional_attributes
+ rescue StandardError => e
+ Rails.logger.error "Failed to fetch pending join requests for group #{group_contact.identifier}: #{e.message}"
+ end
+
+ public
+
+ def try_update_group_avatar(group_contact, force: false)
+ if force
+ reset_avatar_state(group_contact)
+ elsif group_contact.avatar.attached?
+ return
+ end
+
+ response = get_profile_pic(group_contact.identifier)
+ profile_pic_url = response&.dig('data', 'profilePictureUrl')
+ ::Avatar::AvatarFromUrlJob.perform_later(group_contact, profile_pic_url) if profile_pic_url
+ rescue StandardError => e
+ Rails.logger.error "Failed to update avatar for group #{group_contact.identifier}: #{e.message}"
+ end
+
+ private
+
+ def reset_avatar_state(group_contact)
+ group_contact.avatar.purge if group_contact.avatar.attached?
+ attrs = (group_contact.additional_attributes || {}).except('last_avatar_sync_at', 'avatar_url_hash')
+ group_contact.update_columns(additional_attributes: attrs) # rubocop:disable Rails/SkipsModelValidations
+ end
+
+ def try_update_participant_avatar(contact)
+ return if contact.avatar.attached?
+
+ phone = contact.phone_number&.delete('+')
+ return if phone.blank?
+
+ profile_pic_url = fetch_profile_picture_url(phone)
+ ::Avatar::AvatarFromUrlJob.perform_later(contact, profile_pic_url) if profile_pic_url
+ rescue StandardError => e
+ Rails.logger.error "Failed to update avatar for contact #{contact.id}: #{e.message}"
+ end
+
+ def fetch_profile_picture_url(phone_number)
+ jid = "#{phone_number}@s.whatsapp.net"
+ response = get_profile_pic(jid)
+ response&.dig('data', 'profilePictureUrl')
+ end
+
+ def find_or_create_participant_contact(participant, inbox)
+ lid = extract_lid_from_participant(participant)
+ phone = extract_phone_from_participant(participant)
+ identifier = lid ? "#{lid}@lid" : nil
+ source_id = lid || phone
+
+ return nil if source_id.blank?
+
+ Whatsapp::ContactInboxConsolidationService.new(
+ inbox: inbox, phone: phone, lid: lid, identifier: identifier
+ ).perform
+
+ contact_inbox = ::ContactInboxWithContactBuilder.new(
+ source_id: source_id,
+ inbox: inbox,
+ contact_attributes: {
+ name: phone,
+ phone_number: ("+#{phone}" if phone),
+ identifier: identifier
+ }
+ ).perform
+
+ return nil if contact_inbox.blank?
+
+ update_participant_contact_info(contact_inbox.contact, phone, identifier)
+ end
+
+ def update_participant_contact_info(contact, phone, identifier)
+ update_params = {
+ phone_number: ("+#{phone}" if phone && contact.phone_number.blank?),
+ identifier: (identifier if identifier && contact.identifier.blank?)
+ }.compact
+
+ contact.update!(update_params) if update_params.present?
+ contact
+ end
+
+ def extract_lid_from_participant(participant)
+ return nil if participant[:id].blank?
+
+ jid_part, jid_suffix = participant[:id].split('@')
+ jid_part if jid_suffix == 'lid' && jid_part.match?(/^\d+$/)
+ end
+
+ def extract_phone_from_participant(participant)
+ return nil if participant[:phoneNumber].blank?
+
+ phone = participant[:phoneNumber].split('@').first
+ phone if phone.match?(/^\d+$/)
+ end
+
private_class_method def self.with_error_handling(*method_names)
method_names.each do |method_name|
original_method = instance_method(method_name)
@@ -444,7 +845,13 @@ class Whatsapp::Providers::WhatsappBaileysService < Whatsapp::Providers::BaseSer
:read_messages,
:unread_message,
:received_messages,
+ :group_metadata,
+ :sync_group,
:on_whatsapp,
:delete_message,
- :edit_message
+ :edit_message,
+ :group_leave,
+ :group_setting_update,
+ :group_join_approval_mode,
+ :group_member_add_mode
end
diff --git a/app/services/whatsapp/send_on_whatsapp_service.rb b/app/services/whatsapp/send_on_whatsapp_service.rb
index 10db7cb2c..72cefcaef 100644
--- a/app/services/whatsapp/send_on_whatsapp_service.rb
+++ b/app/services/whatsapp/send_on_whatsapp_service.rb
@@ -42,9 +42,35 @@ class Whatsapp::SendOnWhatsappService < Base::SendOnChannelService
end
def send_baileys_session_message
+ validate_announcement_mode!
with_baileys_channel_lock_on_outgoing_message(channel.id) { send_session_message }
end
+ def validate_announcement_mode!
+ return unless conversation.contact.group_type_group?
+ return unless conversation.contact.additional_attributes&.dig('announce') == true
+ return if inbox_admin_in_group?
+
+ message.update!(status: :failed, external_error: 'Only administrators are allowed to send messages in this group')
+ raise StandardError, 'Only admins can send messages in this group'
+ end
+
+ def inbox_admin_in_group?
+ inbox_phone = channel.phone_number&.gsub(/[^\d]/, '')
+ return false if inbox_phone.blank?
+
+ admin_phones = conversation.contact.group_memberships.active.where(role: :admin)
+ .includes(:contact).filter_map { |m| m.contact.phone_number&.gsub(/[^\d]/, '') }
+
+ admin_phones.any? { |phone| phones_match?(inbox_phone, phone) }
+ end
+
+ def phones_match?(phone_a, phone_b)
+ return false if phone_a.blank? || phone_b.blank?
+
+ phone_a == phone_b || (phone_a.length >= 8 && phone_b.length >= 8 && phone_a[-8..] == phone_b[-8..])
+ end
+
def send_session_message
message_id = channel.send_message(recipient_id, message)
message.update!(source_id: message_id) if message_id.present?
diff --git a/app/views/api/v1/accounts/contacts/group_members/index.json.jbuilder b/app/views/api/v1/accounts/contacts/group_members/index.json.jbuilder
new file mode 100644
index 000000000..ccd0e2605
--- /dev/null
+++ b/app/views/api/v1/accounts/contacts/group_members/index.json.jbuilder
@@ -0,0 +1,23 @@
+json.payload do
+ json.array! @group_members do |member|
+ json.id member.id
+ json.role member.role
+ json.is_active member.is_active
+ json.group_contact_id member.group_contact_id
+ json.contact do
+ json.id member.contact.id
+ json.name member.contact.name
+ json.phone_number member.contact.phone_number
+ json.identifier member.contact.identifier
+ json.thumbnail member.contact.avatar_url
+ end
+ end
+end
+
+json.meta do
+ json.total_count @total_count
+ json.page @page
+ json.per_page @per_page
+ json.inbox_phone_number @inbox_phone_number
+ json.is_inbox_admin @is_inbox_admin
+end
diff --git a/app/views/api/v1/conversations/partials/_conversation.json.jbuilder b/app/views/api/v1/conversations/partials/_conversation.json.jbuilder
index 4cb13f543..9ff9cd06f 100644
--- a/app/views/api/v1/conversations/partials/_conversation.json.jbuilder
+++ b/app/views/api/v1/conversations/partials/_conversation.json.jbuilder
@@ -56,6 +56,7 @@ json.first_reply_created_at conversation.first_reply_created_at.to_i
json.unread_count conversation.unread_incoming_messages.count
json.last_non_activity_message conversation.messages.where(account_id: conversation.account_id).non_activity_messages.first.try(:push_event_data)
json.last_activity_at conversation.last_activity_at.to_i
+json.group_type conversation.group_type
json.priority conversation.priority
json.waiting_since conversation.waiting_since.to_i.to_i
json.sla_policy_id conversation.sla_policy_id
diff --git a/app/views/api/v1/models/_contact.json.jbuilder b/app/views/api/v1/models/_contact.json.jbuilder
index 809452a45..a47fb9b4c 100644
--- a/app/views/api/v1/models/_contact.json.jbuilder
+++ b/app/views/api/v1/models/_contact.json.jbuilder
@@ -10,6 +10,7 @@ json.thumbnail resource.avatar_url
json.custom_attributes resource.custom_attributes
json.last_activity_at resource.last_activity_at.to_i if resource[:last_activity_at].present?
json.created_at resource.created_at.to_i if resource[:created_at].present?
+json.group_type resource.group_type
json.account_id resource.account_id
# we only want to output contact inbox when its /contacts endpoints
if defined?(with_contact_inboxes) && with_contact_inboxes.present?
diff --git a/app/views/api/v1/models/_inbox.json.jbuilder b/app/views/api/v1/models/_inbox.json.jbuilder
index ca5e0a01e..aff1fa647 100644
--- a/app/views/api/v1/models/_inbox.json.jbuilder
+++ b/app/views/api/v1/models/_inbox.json.jbuilder
@@ -120,6 +120,7 @@ if resource.api?
end
json.provider resource.channel.try(:provider)
+json.allow_group_creation resource.channel.try(:allow_group_creation?) || false
## Telegram Attributes
json.bot_name resource.channel.try(:bot_name) if resource.telegram?
diff --git a/config/initializers/baileys.rb b/config/initializers/baileys.rb
new file mode 100644
index 000000000..ef920692c
--- /dev/null
+++ b/config/initializers/baileys.rb
@@ -0,0 +1,5 @@
+Rails.application.config.after_initialize do
+ next unless defined?(Sidekiq::CLI)
+
+ Channels::Whatsapp::BaileysConnectionCheckSchedulerJob.perform_later
+end
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 63385f3ae..b9be6329d 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -273,6 +273,37 @@ en:
issue_created: 'Linear issue %{issue_id} was created by %{user_name}'
issue_linked: 'Linear issue %{issue_id} was linked by %{user_name}'
issue_unlinked: 'Linear issue %{issue_id} was unlinked by %{user_name}'
+ group_participants:
+ add:
+ single: '%{author_name} added %{contact_name} to the group'
+ multiple: '%{author_name} added %{contact_names} and %{last_contact_name} to the group'
+ join: '%{contact_name} joined using the group invite link'
+ leave: '%{contact_name} left the group'
+ remove:
+ single: '%{author_name} removed %{contact_name} from the group'
+ multiple: '%{author_name} removed %{contact_names} and %{last_contact_name} from the group'
+ promote:
+ single: '%{author_name} promoted %{contact_name} to admin'
+ multiple: '%{author_name} promoted %{contact_names} and %{last_contact_name} to admin'
+ demote:
+ single: '%{author_name} demoted %{contact_name} to member'
+ multiple: '%{author_name} demoted %{contact_names} and %{last_contact_name} to member'
+ groups_update:
+ subject_changed: '%{author_name} changed the group name to "%{value}"'
+ description_changed: '%{author_name} changed the group description'
+ description_removed: '%{author_name} removed the group description'
+ restrict_enabled: '%{author_name} changed the settings so only admins can edit the group settings'
+ restrict_disabled: '%{author_name} changed the settings so all members can edit the group settings'
+ announce_enabled: "%{author_name} changed this group's settings to allow only admins to send messages to this group"
+ announce_disabled: "%{author_name} changed this group's settings to allow all members to send messages to this group"
+ member_add_mode_enabled: "%{author_name} changed this group's setting to allow all members to add others to this group"
+ member_add_mode_disabled: "%{author_name} changed this group's setting to allow only admins to add others to this group"
+ join_approval_mode_enabled: '%{author_name} turned on admin approval to join this group'
+ join_approval_mode_disabled: '%{author_name} turned off admin approval to join this group'
+ invite_link_reset: "%{author_name} reset this group's invite link"
+ membership_request_created: '%{contact_name} wants to join the group'
+ membership_request_revoked: '%{contact_name} no longer wants to join the group'
+ icon_changed: '%{author_name} changed the group image'
csat:
not_sent_due_to_messaging_window: 'CSAT survey not sent due to outgoing message restrictions'
auto_resolve:
@@ -304,6 +335,11 @@ en:
contacts:
online:
delete: '%{contact_name} is Online, please try again later'
+ sync_group:
+ not_a_group: 'Contact is not a group'
+ no_identifier: 'Contact does not have a group identifier'
+ no_supported_inbox: 'No inbox with group sync support found for this contact'
+ metadata_unavailable: 'Could not retrieve group metadata from the provider'
integration_apps:
# Note: webhooks and dashboard_apps don't need short_description as they use different modal components
dashboard_apps:
diff --git a/config/locales/pt_BR.yml b/config/locales/pt_BR.yml
index f945e0fed..329efd25d 100644
--- a/config/locales/pt_BR.yml
+++ b/config/locales/pt_BR.yml
@@ -247,6 +247,37 @@ pt_BR:
issue_created: 'Problema linear %{issue_id} foi criado por %{user_name}'
issue_linked: 'Problema linear %{issue_id} foi vinculado por %{user_name}'
issue_unlinked: 'Problema linear %{issue_id} foi desvinculado por %{user_name}'
+ group_participants:
+ add:
+ single: '%{author_name} adicionou %{contact_name} ao grupo'
+ multiple: '%{author_name} adicionou %{contact_names} e %{last_contact_name} ao grupo'
+ join: '%{contact_name} entrou usando o link do grupo'
+ leave: '%{contact_name} saiu do grupo'
+ remove:
+ single: '%{author_name} removeu %{contact_name} do grupo'
+ multiple: '%{author_name} removeu %{contact_names} e %{last_contact_name} do grupo'
+ promote:
+ single: '%{author_name} promoveu %{contact_name} a admin'
+ multiple: '%{author_name} promoveu %{contact_names} e %{last_contact_name} a admin'
+ demote:
+ single: '%{author_name} rebaixou %{contact_name} a membro'
+ multiple: '%{author_name} rebaixou %{contact_names} e %{last_contact_name} a membro'
+ groups_update:
+ subject_changed: '%{author_name} alterou o nome do grupo para "%{value}"'
+ description_changed: '%{author_name} alterou a descrição do grupo'
+ description_removed: '%{author_name} removeu a descrição do grupo'
+ restrict_enabled: '%{author_name} mudou as configurações para permitir que somente admins editem as configurações do grupo'
+ restrict_disabled: '%{author_name} mudou as configurações para permitir que todos os membros editem as configurações do grupo'
+ announce_enabled: '%{author_name} mudou as configurações desse grupo para permitir que somente admins enviem mensagens ao grupo'
+ announce_disabled: '%{author_name} mudou as configurações do grupo para permitir que todos os membros enviem mensagens'
+ member_add_mode_enabled: '%{author_name} mudou as configurações do grupo para permitir que todos os membros adicionem outras pessoas'
+ member_add_mode_disabled: '%{author_name} mudou as configurações deste grupo para permitir que somente admins adicionem outras pessoas ao grupo'
+ join_approval_mode_enabled: '%{author_name} ativou a autorização de admins para entrar neste grupo'
+ join_approval_mode_disabled: '%{author_name} desativou a autorização de admins para entrar neste grupo'
+ invite_link_reset: '%{author_name} redefiniu o link do grupo para convidar outras pessoas'
+ membership_request_created: '%{contact_name} deseja entrar no grupo'
+ membership_request_revoked: '%{contact_name} não deseja mais fazer parte do grupo'
+ icon_changed: '%{author_name} alterou a imagem do grupo'
csat:
not_sent_due_to_messaging_window: 'Pesquisa CSAT não foi enviada devido a restrições de envio de mensagens'
auto_resolve:
@@ -278,6 +309,11 @@ pt_BR:
contacts:
online:
delete: '%{contact_name} está Online, por favor, tente novamente mais tarde'
+ sync_group:
+ not_a_group: 'O contato não é um grupo'
+ no_identifier: 'O contato não possui um identificador de grupo'
+ no_supported_inbox: 'Nenhuma caixa de entrada com suporte a sincronização de grupo encontrada para este contato'
+ metadata_unavailable: 'Não foi possível obter os metadados do grupo do provedor'
integration_apps:
#Note: webhooks and dashboard_apps don't need short_description as they use different modal components
dashboard_apps:
diff --git a/config/routes.rb b/config/routes.rb
index eb0f58aa2..5c525dd43 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -174,6 +174,7 @@ Rails.application.routes.draw do
get :search
end
end
+ resources :groups, only: [:create]
resources :contacts, only: [:index, :show, :update, :create, :destroy] do
collection do
get :active
@@ -185,11 +186,25 @@ Rails.application.routes.draw do
member do
get :contactable_inboxes
post :destroy_custom_attributes
+ post :sync_group
delete :avatar
end
scope module: :contacts do
resources :conversations, only: [:index]
resources :contact_inboxes, only: [:create]
+ resources :group_members, only: [:index, :create, :destroy] do
+ patch ':member_id', to: 'group_members#update', on: :collection
+ end
+ resource :group_metadata, only: [:update]
+ resource :group_invite, only: [:show] do
+ post :revoke, on: :member
+ end
+ resources :group_join_requests, only: [:index] do
+ post :handle, on: :collection
+ end
+ resource :group_admin, only: [:update], controller: 'group_admin' do
+ post :leave, on: :member
+ end
resources :labels, only: [:create, :index]
resources :notes
post :call, on: :member, to: 'calls#create' if ChatwootApp.enterprise?
diff --git a/db/migrate/20260205010032_create_conversation_group_members.rb b/db/migrate/20260205010032_create_conversation_group_members.rb
new file mode 100644
index 000000000..ca27380cf
--- /dev/null
+++ b/db/migrate/20260205010032_create_conversation_group_members.rb
@@ -0,0 +1,14 @@
+class CreateConversationGroupMembers < ActiveRecord::Migration[7.1]
+ def change
+ create_table :conversation_group_members do |t|
+ t.references :conversation, null: false, foreign_key: true
+ t.references :contact, null: false, foreign_key: true
+ t.integer :role, default: 0, null: false
+ t.boolean :is_active, default: true, null: false
+
+ t.timestamps
+ end
+
+ add_index :conversation_group_members, [:conversation_id, :contact_id], unique: true
+ end
+end
diff --git a/db/migrate/20260205040548_add_conversation_type_to_conversations.rb b/db/migrate/20260205040548_add_conversation_type_to_conversations.rb
new file mode 100644
index 000000000..dcbe9deaa
--- /dev/null
+++ b/db/migrate/20260205040548_add_conversation_type_to_conversations.rb
@@ -0,0 +1,9 @@
+class AddConversationTypeToConversations < ActiveRecord::Migration[7.1]
+ disable_ddl_transaction!
+
+ def change
+ add_column :conversations, :conversation_type, :integer, default: 0, null: false
+ add_index :conversations, [:account_id, :conversation_type], algorithm: :concurrently
+ add_index :conversations, [:inbox_id, :conversation_type], algorithm: :concurrently
+ end
+end
diff --git a/db/migrate/20260205040643_add_group_type_to_contacts.rb b/db/migrate/20260205040643_add_group_type_to_contacts.rb
new file mode 100644
index 000000000..d0784beec
--- /dev/null
+++ b/db/migrate/20260205040643_add_group_type_to_contacts.rb
@@ -0,0 +1,6 @@
+class AddGroupTypeToContacts < ActiveRecord::Migration[7.1]
+ def change
+ add_column :contacts, :group_type, :integer, default: 0, null: false
+ add_index :contacts, [:account_id, :group_type]
+ end
+end
diff --git a/db/migrate/20260211200624_add_is_active_index_to_conversation_group_members.rb b/db/migrate/20260211200624_add_is_active_index_to_conversation_group_members.rb
new file mode 100644
index 000000000..492325e36
--- /dev/null
+++ b/db/migrate/20260211200624_add_is_active_index_to_conversation_group_members.rb
@@ -0,0 +1,8 @@
+class AddIsActiveIndexToConversationGroupMembers < ActiveRecord::Migration[7.1]
+ disable_ddl_transaction!
+
+ def change
+ add_index :conversation_group_members, [:conversation_id, :is_active],
+ algorithm: :concurrently
+ end
+end
diff --git a/db/migrate/20260227135739_rename_conversation_type_to_group_type_on_conversations.rb b/db/migrate/20260227135739_rename_conversation_type_to_group_type_on_conversations.rb
new file mode 100644
index 000000000..ae6109506
--- /dev/null
+++ b/db/migrate/20260227135739_rename_conversation_type_to_group_type_on_conversations.rb
@@ -0,0 +1,29 @@
+class RenameConversationTypeToGroupTypeOnConversations < ActiveRecord::Migration[7.1]
+ def up
+ rename_column :conversations, :conversation_type, :group_type
+
+ execute <<-SQL.squish
+ ALTER INDEX IF EXISTS index_conversations_on_account_id_and_conversation_type
+ RENAME TO index_conversations_on_account_id_and_group_type;
+ SQL
+
+ execute <<-SQL.squish
+ ALTER INDEX IF EXISTS index_conversations_on_inbox_id_and_conversation_type
+ RENAME TO index_conversations_on_inbox_id_and_group_type;
+ SQL
+ end
+
+ def down
+ rename_column :conversations, :group_type, :conversation_type
+
+ execute <<-SQL.squish
+ ALTER INDEX IF EXISTS index_conversations_on_account_id_and_group_type
+ RENAME TO index_conversations_on_account_id_and_conversation_type;
+ SQL
+
+ execute <<-SQL.squish
+ ALTER INDEX IF EXISTS index_conversations_on_inbox_id_and_group_type
+ RENAME TO index_conversations_on_inbox_id_and_conversation_type;
+ SQL
+ end
+end
diff --git a/db/migrate/20260303114847_create_group_members.rb b/db/migrate/20260303114847_create_group_members.rb
new file mode 100644
index 000000000..a2ebd5cce
--- /dev/null
+++ b/db/migrate/20260303114847_create_group_members.rb
@@ -0,0 +1,18 @@
+class CreateGroupMembers < ActiveRecord::Migration[7.1]
+ def change
+ create_table :group_members do |t|
+ t.bigint :group_contact_id, null: false
+ t.bigint :contact_id, null: false
+ t.integer :role, default: 0, null: false
+ t.boolean :is_active, default: true, null: false
+ t.timestamps
+ end
+
+ add_index :group_members, %i[group_contact_id contact_id], unique: true
+ add_index :group_members, %i[group_contact_id is_active]
+ add_index :group_members, :contact_id
+ add_index :group_members, :group_contact_id
+ add_foreign_key :group_members, :contacts, column: :group_contact_id
+ add_foreign_key :group_members, :contacts, column: :contact_id
+ end
+end
diff --git a/db/migrate/20260303120000_drop_conversation_group_members.rb b/db/migrate/20260303120000_drop_conversation_group_members.rb
new file mode 100644
index 000000000..1bfe79404
--- /dev/null
+++ b/db/migrate/20260303120000_drop_conversation_group_members.rb
@@ -0,0 +1,18 @@
+class DropConversationGroupMembers < ActiveRecord::Migration[7.1]
+ def up
+ drop_table :conversation_group_members, if_exists: true
+ end
+
+ def down
+ create_table :conversation_group_members do |t|
+ t.references :conversation, null: false, foreign_key: true
+ t.references :contact, null: false, foreign_key: true
+ t.integer :role, default: 0, null: false
+ t.boolean :is_active, default: true, null: false
+ t.timestamps
+ end
+ add_index :conversation_group_members, %i[conversation_id contact_id], unique: true
+ add_index :conversation_group_members, %i[conversation_id is_active],
+ name: 'index_conv_group_members_on_conversation_id_and_is_active'
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 08fb0b5e9..afcfad2ef 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[7.1].define(version: 2026_03_09_131532) do
+ActiveRecord::Schema[7.1].define(version: 2026_03_18_180001) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -591,7 +591,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_03_09_131532) do
t.bigint "account_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
- t.integer "contacts_count"
+ t.integer "contacts_count", default: 0, null: false
t.index ["account_id", "domain"], name: "index_companies_on_account_and_domain", unique: true, where: "(domain IS NOT NULL)"
t.index ["account_id"], name: "index_companies_on_account_id"
t.index ["name", "account_id"], name: "index_companies_on_name_and_account_id"
@@ -630,9 +630,11 @@ ActiveRecord::Schema[7.1].define(version: 2026_03_09_131532) do
t.string "country_code", default: ""
t.boolean "blocked", default: false, null: false
t.bigint "company_id"
+ t.integer "group_type", default: 0, null: false
t.index "lower((email)::text), account_id", name: "index_contacts_on_lower_email_account_id"
t.index ["account_id", "contact_type"], name: "index_contacts_on_account_id_and_contact_type"
t.index ["account_id", "email", "phone_number", "identifier"], name: "index_contacts_on_nonempty_fields", where: "(((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))"
+ t.index ["account_id", "group_type"], name: "index_contacts_on_account_id_and_group_type"
t.index ["account_id", "last_activity_at"], name: "index_contacts_on_account_id_and_last_activity_at", order: { last_activity_at: "DESC NULLS LAST" }
t.index ["account_id"], name: "index_contacts_on_account_id"
t.index ["account_id"], name: "index_resolved_contact_account_id", where: "(((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))"
@@ -683,7 +685,9 @@ ActiveRecord::Schema[7.1].define(version: 2026_03_09_131532) do
t.datetime "waiting_since"
t.text "cached_label_list"
t.bigint "assignee_agent_bot_id"
+ t.integer "group_type", default: 0, null: false
t.index ["account_id", "display_id"], name: "index_conversations_on_account_id_and_display_id", unique: true
+ t.index ["account_id", "group_type"], name: "index_conversations_on_account_id_and_group_type"
t.index ["account_id", "id"], name: "index_conversations_on_id_and_account_id"
t.index ["account_id", "inbox_id", "status", "assignee_id"], name: "conv_acid_inbid_stat_asgnid_idx"
t.index ["account_id"], name: "index_conversations_on_account_id"
@@ -693,6 +697,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_03_09_131532) do
t.index ["contact_inbox_id"], name: "index_conversations_on_contact_inbox_id"
t.index ["first_reply_created_at"], name: "index_conversations_on_first_reply_created_at"
t.index ["identifier", "account_id"], name: "index_conversations_on_identifier_and_account_id"
+ t.index ["inbox_id", "group_type"], name: "index_conversations_on_inbox_id_and_group_type"
t.index ["inbox_id"], name: "index_conversations_on_inbox_id"
t.index ["priority"], name: "index_conversations_on_priority"
t.index ["status", "account_id"], name: "index_conversations_on_status_and_account_id"
@@ -828,6 +833,19 @@ ActiveRecord::Schema[7.1].define(version: 2026_03_09_131532) do
t.datetime "updated_at", null: false
end
+ create_table "group_members", force: :cascade do |t|
+ t.bigint "group_contact_id", null: false
+ t.bigint "contact_id", null: false
+ t.integer "role", default: 0, null: false
+ t.boolean "is_active", default: true, null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["contact_id"], name: "index_group_members_on_contact_id"
+ t.index ["group_contact_id", "contact_id"], name: "index_group_members_on_group_contact_id_and_contact_id", unique: true
+ t.index ["group_contact_id", "is_active"], name: "index_group_members_on_group_contact_id_and_is_active"
+ t.index ["group_contact_id"], name: "index_group_members_on_group_contact_id"
+ end
+
create_table "inbox_assignment_policies", force: :cascade do |t|
t.bigint "inbox_id", null: false
t.bigint "assignment_policy_id", null: false
@@ -1274,7 +1292,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_03_09_131532) do
t.text "message_signature"
t.string "otp_secret"
t.integer "consumed_timestep"
- t.boolean "otp_required_for_login", default: false
+ t.boolean "otp_required_for_login", default: false, null: false
t.text "otp_backup_codes"
t.index ["email"], name: "index_users_on_email"
t.index ["otp_required_for_login"], name: "index_users_on_otp_required_for_login"
@@ -1314,6 +1332,8 @@ ActiveRecord::Schema[7.1].define(version: 2026_03_09_131532) do
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
+ add_foreign_key "group_members", "contacts"
+ add_foreign_key "group_members", "contacts", column: "group_contact_id"
add_foreign_key "inboxes", "portals"
add_foreign_key "scheduled_messages", "accounts"
add_foreign_key "scheduled_messages", "conversations"
diff --git a/enterprise/app/models/company.rb b/enterprise/app/models/company.rb
index d96344f9b..4c228bc97 100644
--- a/enterprise/app/models/company.rb
+++ b/enterprise/app/models/company.rb
@@ -3,7 +3,7 @@
# Table name: companies
#
# id :bigint not null, primary key
-# contacts_count :integer
+# contacts_count :integer default(0), not null
# description :text
# domain :string
# name :string not null
diff --git a/lib/events/types.rb b/lib/events/types.rb
index cb76babbe..c4ff4748d 100644
--- a/lib/events/types.rb
+++ b/lib/events/types.rb
@@ -51,6 +51,7 @@ module Events::Types
CONTACT_UPDATED = 'contact.updated'
CONTACT_MERGED = 'contact.merged'
CONTACT_DELETED = 'contact.deleted'
+ CONTACT_GROUP_SYNCED = 'contact.group_synced'
# contact events
INBOX_CREATED = 'inbox.created'
diff --git a/lib/filters/filter_keys.yml b/lib/filters/filter_keys.yml
index bfaf39325..3334e36bb 100644
--- a/lib/filters/filter_keys.yml
+++ b/lib/filters/filter_keys.yml
@@ -94,6 +94,12 @@ conversations:
- "not_equal_to"
- "contains"
- "does_not_contain"
+ group_type:
+ attribute_type: "standard"
+ data_type: "text"
+ filter_operators:
+ - "equal_to"
+ - "not_equal_to"
created_at:
attribute_type: "standard"
data_type: "date"
diff --git a/lib/regex_helper.rb b/lib/regex_helper.rb
index 2eeb895ea..a68d70491 100644
--- a/lib/regex_helper.rb
+++ b/lib/regex_helper.rb
@@ -15,5 +15,5 @@ module RegexHelper
TWILIO_CHANNEL_SMS_REGEX = Regexp.new('^\+\d{1,15}\z')
TWILIO_CHANNEL_WHATSAPP_REGEX = Regexp.new('^whatsapp:\+\d{1,15}\z')
- WHATSAPP_CHANNEL_REGEX = Regexp.new('^\d{1,15}\z')
+ WHATSAPP_CHANNEL_REGEX = Regexp.new('^\d{1,20}(-\d{1,20})?\z')
end
diff --git a/spec/builders/contact_inbox_with_contact_builder_spec.rb b/spec/builders/contact_inbox_with_contact_builder_spec.rb
index 60f0834af..cab7b909c 100644
--- a/spec/builders/contact_inbox_with_contact_builder_spec.rb
+++ b/spec/builders/contact_inbox_with_contact_builder_spec.rb
@@ -36,6 +36,7 @@ describe ContactInboxWithContactBuilder do
expect(contact_inbox.contact.id).not_to eq(contact.id)
expect(contact_inbox.contact.name).to eq('Contact')
expect(contact_inbox.contact.custom_attributes).to eq({ 'test' => 'test' })
+ expect(contact_inbox.contact.group_type).to eq('individual')
expect(contact_inbox.inbox_id).to eq(inbox.id)
end
@@ -96,6 +97,19 @@ describe ContactInboxWithContactBuilder do
expect(contact_inbox.contact.id).to be(contact.id)
end
+ it 'creates contact for group' do
+ contact_inbox = described_class.new(
+ source_id: '123456',
+ inbox: inbox,
+ contact_attributes: {
+ name: 'Group Contact',
+ group_type: :group
+ }
+ ).perform
+
+ expect(contact_inbox.contact.group_type).to eq('group')
+ end
+
it 'reuses contact if it exists with the same source_id in a Facebook inbox when creating for Instagram inbox' do
instagram_source_id = '123456789'
diff --git a/spec/controllers/api/v1/accounts/contacts/group_invite_controller_spec.rb b/spec/controllers/api/v1/accounts/contacts/group_invite_controller_spec.rb
new file mode 100644
index 000000000..a6cefe830
--- /dev/null
+++ b/spec/controllers/api/v1/accounts/contacts/group_invite_controller_spec.rb
@@ -0,0 +1,74 @@
+require 'rails_helper'
+
+RSpec.describe '/api/v1/accounts/{account.id}/contacts/:id/group_invite', type: :request do
+ let(:account) { create(:account) }
+ let(:admin) { create(:user, account: account, role: :administrator) }
+ let(:whatsapp_channel) do
+ create(:channel_whatsapp, provider: 'baileys', validate_provider_config: false, sync_templates: false, account: account)
+ end
+ let(:inbox) { whatsapp_channel.inbox }
+ let(:group_contact) { create(:contact, account: account, group_type: :group, identifier: 'group@g.us') }
+ let(:conversation) { create(:conversation, account: account, contact: group_contact, inbox: inbox, group_type: :group) }
+ let(:baileys_service) { instance_double(Whatsapp::Providers::WhatsappBaileysService) }
+
+ before do
+ conversation
+ allow(Whatsapp::Providers::WhatsappBaileysService).to receive(:new).and_return(baileys_service)
+ allow(baileys_service).to receive(:group_invite_code).and_return('ABCXYZ')
+ allow(baileys_service).to receive(:revoke_group_invite).and_return('NEWCODE')
+ end
+
+ describe 'GET /api/v1/accounts/{account.id}/contacts/:id/group_invite' do
+ context 'when unauthenticated' do
+ it 'returns unauthorized' do
+ get "/api/v1/accounts/#{account.id}/contacts/#{group_contact.id}/group_invite"
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when user is logged in' do
+ it 'returns invite code and url' do
+ get "/api/v1/accounts/#{account.id}/contacts/#{group_contact.id}/group_invite",
+ headers: admin.create_new_auth_token
+
+ expect(response).to have_http_status(:ok)
+ expect(response.parsed_body['invite_code']).to eq('ABCXYZ')
+ expect(response.parsed_body['invite_url']).to eq('https://chat.whatsapp.com/ABCXYZ')
+ end
+
+ it 'returns 422 when provider is unavailable' do
+ allow(baileys_service).to receive(:group_invite_code)
+ .and_raise(Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError, 'Offline')
+
+ get "/api/v1/accounts/#{account.id}/contacts/#{group_contact.id}/group_invite",
+ headers: admin.create_new_auth_token
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ expect(response.parsed_body['error']).to eq('Offline')
+ end
+ end
+ end
+
+ describe 'POST /api/v1/accounts/{account.id}/contacts/:id/group_invite/revoke' do
+ context 'when user is logged in' do
+ it 'revokes and returns new invite code and url' do
+ post "/api/v1/accounts/#{account.id}/contacts/#{group_contact.id}/group_invite/revoke",
+ headers: admin.create_new_auth_token
+
+ expect(response).to have_http_status(:ok)
+ expect(response.parsed_body['invite_code']).to eq('NEWCODE')
+ expect(response.parsed_body['invite_url']).to eq('https://chat.whatsapp.com/NEWCODE')
+ end
+
+ it 'returns 422 when provider is unavailable' do
+ allow(baileys_service).to receive(:revoke_group_invite)
+ .and_raise(Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError, 'Offline')
+
+ post "/api/v1/accounts/#{account.id}/contacts/#{group_contact.id}/group_invite/revoke",
+ headers: admin.create_new_auth_token
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ end
+ end
+ end
+end
diff --git a/spec/controllers/api/v1/accounts/contacts/group_join_requests_controller_spec.rb b/spec/controllers/api/v1/accounts/contacts/group_join_requests_controller_spec.rb
new file mode 100644
index 000000000..8eac98368
--- /dev/null
+++ b/spec/controllers/api/v1/accounts/contacts/group_join_requests_controller_spec.rb
@@ -0,0 +1,76 @@
+require 'rails_helper'
+
+RSpec.describe '/api/v1/accounts/{account.id}/contacts/:id/group_join_requests', type: :request do
+ let(:account) { create(:account) }
+ let(:admin) { create(:user, account: account, role: :administrator) }
+ let(:whatsapp_channel) do
+ create(:channel_whatsapp, provider: 'baileys', validate_provider_config: false, sync_templates: false, account: account)
+ end
+ let(:inbox) { whatsapp_channel.inbox }
+ let(:group_contact) { create(:contact, account: account, group_type: :group, identifier: 'group@g.us') }
+ let(:conversation) { create(:conversation, account: account, contact: group_contact, inbox: inbox, group_type: :group) }
+ let(:baileys_service) { instance_double(Whatsapp::Providers::WhatsappBaileysService) }
+ let(:join_requests) { [{ 'jid' => '551199999@s.whatsapp.net', 'name' => 'Alice' }] }
+
+ before do
+ conversation
+ allow(Whatsapp::Providers::WhatsappBaileysService).to receive(:new).and_return(baileys_service)
+ allow(baileys_service).to receive(:group_join_requests).and_return(join_requests)
+ allow(baileys_service).to receive(:handle_group_join_requests).and_return(true)
+ end
+
+ describe 'GET /api/v1/accounts/{account.id}/contacts/:id/group_join_requests' do
+ context 'when unauthenticated' do
+ it 'returns unauthorized' do
+ get "/api/v1/accounts/#{account.id}/contacts/#{group_contact.id}/group_join_requests"
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when user is logged in' do
+ it 'returns list of join requests' do
+ get "/api/v1/accounts/#{account.id}/contacts/#{group_contact.id}/group_join_requests",
+ headers: admin.create_new_auth_token
+
+ expect(response).to have_http_status(:ok)
+ expect(response.parsed_body['payload']).to eq(join_requests)
+ end
+
+ it 'returns 422 when provider is unavailable' do
+ allow(baileys_service).to receive(:group_join_requests)
+ .and_raise(Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError, 'Offline')
+
+ get "/api/v1/accounts/#{account.id}/contacts/#{group_contact.id}/group_join_requests",
+ headers: admin.create_new_auth_token
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ expect(response.parsed_body['error']).to eq('Offline')
+ end
+ end
+ end
+
+ describe 'POST /api/v1/accounts/{account.id}/contacts/:id/group_join_requests/handle' do
+ context 'when user is logged in' do
+ it 'approves join requests and returns ok' do
+ post "/api/v1/accounts/#{account.id}/contacts/#{group_contact.id}/group_join_requests/handle",
+ params: { participants: ['551199999@s.whatsapp.net'], request_action: 'approve' },
+ headers: admin.create_new_auth_token
+
+ expect(response).to have_http_status(:ok)
+ expect(baileys_service).to have_received(:handle_group_join_requests)
+ .with('group@g.us', ['551199999@s.whatsapp.net'], 'approve')
+ end
+
+ it 'returns 422 when provider is unavailable' do
+ allow(baileys_service).to receive(:handle_group_join_requests)
+ .and_raise(Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError, 'Offline')
+
+ post "/api/v1/accounts/#{account.id}/contacts/#{group_contact.id}/group_join_requests/handle",
+ params: { participants: ['551199999@s.whatsapp.net'], request_action: 'approve' },
+ headers: admin.create_new_auth_token
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ end
+ end
+ end
+end
diff --git a/spec/controllers/api/v1/accounts/contacts/group_members_controller_spec.rb b/spec/controllers/api/v1/accounts/contacts/group_members_controller_spec.rb
new file mode 100644
index 000000000..ff1d6e1c2
--- /dev/null
+++ b/spec/controllers/api/v1/accounts/contacts/group_members_controller_spec.rb
@@ -0,0 +1,218 @@
+require 'rails_helper'
+
+RSpec.describe '/api/v1/accounts/{account.id}/contacts/:id/group_members', type: :request do
+ let(:account) { create(:account) }
+ let(:admin) { create(:user, account: account, role: :administrator) }
+
+ describe 'GET /api/v1/accounts/{account.id}/contacts/:id/group_members' do
+ context 'when unauthenticated user' do
+ it 'returns unauthorized' do
+ contact = create(:contact, account: account, group_type: :group, identifier: 'group@g.us')
+
+ get "/api/v1/accounts/#{account.id}/contacts/#{contact.id}/group_members"
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when user is logged in' do
+ it 'returns active group members' do
+ contact = create(:contact, account: account, group_type: :group, identifier: 'group@g.us')
+ create(:group_member, group_contact: contact, contact: contact)
+ create(:group_member, group_contact: contact, contact: create(:contact, account: account))
+
+ get "/api/v1/accounts/#{account.id}/contacts/#{contact.id}/group_members",
+ headers: admin.create_new_auth_token
+
+ expect(response).to have_http_status(:success)
+ expect(response.parsed_body['payload'].length).to eq 2
+ end
+
+ it 'does not return inactive group members' do
+ contact = create(:contact, account: account, group_type: :group, identifier: 'group@g.us')
+ create(:group_member, group_contact: contact, contact: contact)
+ create(:group_member, :inactive, group_contact: contact, contact: create(:contact, account: account))
+
+ get "/api/v1/accounts/#{account.id}/contacts/#{contact.id}/group_members",
+ headers: admin.create_new_auth_token
+
+ expect(response).to have_http_status(:success)
+ expect(response.parsed_body['payload'].length).to eq 1
+ end
+
+ it 'does not return group members from another account' do
+ contact = create(:contact, account: account, group_type: :group, identifier: 'group@g.us')
+ create(:group_member, group_contact: contact, contact: contact)
+ other_account = create(:account)
+ other_group_contact = create(:contact, account: other_account, group_type: :group, identifier: 'other@g.us')
+ create(:group_member, group_contact: other_group_contact, contact: create(:contact, account: other_account))
+
+ get "/api/v1/accounts/#{account.id}/contacts/#{contact.id}/group_members",
+ headers: admin.create_new_auth_token
+
+ expect(response).to have_http_status(:success)
+ expect(response.parsed_body['payload'].length).to eq 1
+ end
+
+ it 'returns expected attributes in the response' do
+ contact = create(:contact, account: account, group_type: :group, identifier: 'group@g.us')
+ create(:group_member, group_contact: contact, contact: contact)
+
+ get "/api/v1/accounts/#{account.id}/contacts/#{contact.id}/group_members",
+ headers: admin.create_new_auth_token
+
+ expect(response).to have_http_status(:success)
+ member = response.parsed_body['payload'].first
+ source_member = GroupMember.find(member['id'])
+ expect(member['id']).to eq(source_member.id)
+ expect(member['role']).to eq(source_member.role)
+ expect(member['is_active']).to eq(source_member.is_active)
+ expect(member['group_contact_id']).to eq(contact.id)
+ expect(member['contact']['id']).to eq(source_member.contact.id)
+ end
+
+ it 'returns empty payload when contact is not a group' do
+ contact = create(:contact, account: account, group_type: :individual)
+
+ get "/api/v1/accounts/#{account.id}/contacts/#{contact.id}/group_members",
+ headers: admin.create_new_auth_token
+
+ expect(response).to have_http_status(:success)
+ expect(response.parsed_body['payload']).to be_empty
+ end
+ end
+ end
+
+ describe 'POST /api/v1/accounts/{account.id}/contacts/:id/group_members' do
+ let(:whatsapp_channel) do
+ create(:channel_whatsapp, provider: 'baileys', validate_provider_config: false, sync_templates: false, account: account)
+ end
+ let(:inbox) { whatsapp_channel.inbox }
+ let(:group_contact) { create(:contact, account: account, group_type: :group, identifier: 'group@g.us') }
+ let(:baileys_service) { instance_double(Whatsapp::Providers::WhatsappBaileysService) }
+
+ before do
+ create(:contact_inbox, inbox: inbox, contact: group_contact)
+ allow(Whatsapp::Providers::WhatsappBaileysService).to receive(:new).and_return(baileys_service)
+ allow(baileys_service).to receive(:update_group_participants).and_return(true)
+ end
+
+ context 'when unauthenticated' do
+ it 'returns unauthorized' do
+ post "/api/v1/accounts/#{account.id}/contacts/#{group_contact.id}/group_members"
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when user is logged in' do
+ it 'adds members and returns ok' do
+ allow(baileys_service).to receive(:validate_provider_config?).and_return(true)
+ allow(ContactInboxWithContactBuilder).to receive(:new).and_call_original
+
+ post "/api/v1/accounts/#{account.id}/contacts/#{group_contact.id}/group_members",
+ params: { participants: ['+5511999990001'] },
+ headers: admin.create_new_auth_token
+
+ expect(response).to have_http_status(:ok)
+ end
+
+ it 'returns 422 when provider is unavailable' do
+ allow(baileys_service).to receive(:update_group_participants)
+ .and_raise(Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError, 'Offline')
+
+ post "/api/v1/accounts/#{account.id}/contacts/#{group_contact.id}/group_members",
+ params: { participants: ['+5511999990001'] },
+ headers: admin.create_new_auth_token
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ expect(response.parsed_body['error']).to eq('Offline')
+ end
+ end
+ end
+
+ describe 'DELETE /api/v1/accounts/{account.id}/contacts/:id/group_members/:id' do
+ let(:whatsapp_channel) do
+ create(:channel_whatsapp, provider: 'baileys', validate_provider_config: false, sync_templates: false, account: account)
+ end
+ let(:inbox) { whatsapp_channel.inbox }
+ let(:group_contact) { create(:contact, account: account, group_type: :group, identifier: 'group@g.us') }
+ let(:member_contact) { create(:contact, account: account, phone_number: '+5511999990002') }
+ let!(:member) { create(:group_member, group_contact: group_contact, contact: member_contact) }
+ let(:baileys_service) { instance_double(Whatsapp::Providers::WhatsappBaileysService) }
+
+ before do
+ create(:contact_inbox, inbox: inbox, contact: group_contact)
+ allow(Whatsapp::Providers::WhatsappBaileysService).to receive(:new).and_return(baileys_service)
+ allow(baileys_service).to receive(:update_group_participants).and_return(true)
+ end
+
+ context 'when user is logged in' do
+ it 'deactivates the member and returns ok' do
+ delete "/api/v1/accounts/#{account.id}/contacts/#{group_contact.id}/group_members/#{member.id}",
+ headers: admin.create_new_auth_token
+
+ expect(response).to have_http_status(:ok)
+ expect(member.reload.is_active).to be false
+ end
+
+ it 'returns 422 when provider is unavailable' do
+ allow(baileys_service).to receive(:update_group_participants)
+ .and_raise(Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError, 'Offline')
+
+ delete "/api/v1/accounts/#{account.id}/contacts/#{group_contact.id}/group_members/#{member.id}",
+ headers: admin.create_new_auth_token
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ end
+ end
+ end
+
+ describe 'PATCH /api/v1/accounts/{account.id}/contacts/:id/group_members/:member_id' do
+ let(:whatsapp_channel) do
+ create(:channel_whatsapp, provider: 'baileys', validate_provider_config: false, sync_templates: false, account: account)
+ end
+ let(:inbox) { whatsapp_channel.inbox }
+ let(:group_contact) { create(:contact, account: account, group_type: :group, identifier: 'group@g.us') }
+ let(:member_contact) { create(:contact, account: account, phone_number: '+5511999990003') }
+ let!(:member) { create(:group_member, group_contact: group_contact, contact: member_contact, role: :member) }
+ let(:baileys_service) { instance_double(Whatsapp::Providers::WhatsappBaileysService) }
+
+ before do
+ create(:contact_inbox, inbox: inbox, contact: group_contact)
+ allow(Whatsapp::Providers::WhatsappBaileysService).to receive(:new).and_return(baileys_service)
+ allow(baileys_service).to receive(:update_group_participants).and_return(true)
+ end
+
+ context 'when user is logged in' do
+ it 'promotes member to admin' do
+ patch "/api/v1/accounts/#{account.id}/contacts/#{group_contact.id}/group_members/#{member.id}",
+ params: { role: 'admin' },
+ headers: admin.create_new_auth_token
+
+ expect(response).to have_http_status(:ok)
+ expect(member.reload.role).to eq('admin')
+ end
+
+ it 'demotes admin to member' do
+ member.update!(role: :admin)
+ patch "/api/v1/accounts/#{account.id}/contacts/#{group_contact.id}/group_members/#{member.id}",
+ params: { role: 'member' },
+ headers: admin.create_new_auth_token
+
+ expect(response).to have_http_status(:ok)
+ expect(member.reload.role).to eq('member')
+ end
+
+ it 'returns 422 when provider is unavailable' do
+ allow(baileys_service).to receive(:update_group_participants)
+ .and_raise(Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError, 'Offline')
+
+ patch "/api/v1/accounts/#{account.id}/contacts/#{group_contact.id}/group_members/#{member.id}",
+ params: { role: 'admin' },
+ headers: admin.create_new_auth_token
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ end
+ end
+ end
+end
diff --git a/spec/controllers/api/v1/accounts/contacts/group_metadata_controller_spec.rb b/spec/controllers/api/v1/accounts/contacts/group_metadata_controller_spec.rb
new file mode 100644
index 000000000..a2186b331
--- /dev/null
+++ b/spec/controllers/api/v1/accounts/contacts/group_metadata_controller_spec.rb
@@ -0,0 +1,73 @@
+require 'rails_helper'
+
+RSpec.describe '/api/v1/accounts/{account.id}/contacts/:id/group_metadata', type: :request do
+ let(:account) { create(:account) }
+ let(:admin) { create(:user, account: account, role: :administrator) }
+ let(:whatsapp_channel) do
+ create(:channel_whatsapp, provider: 'baileys', validate_provider_config: false, sync_templates: false, account: account)
+ end
+ let(:inbox) { whatsapp_channel.inbox }
+ let(:group_contact) { create(:contact, account: account, group_type: :group, identifier: 'group@g.us', name: 'Old Name') }
+ let(:conversation) { create(:conversation, account: account, contact: group_contact, inbox: inbox, group_type: :group) }
+ let(:baileys_service) { instance_double(Whatsapp::Providers::WhatsappBaileysService) }
+
+ before do
+ conversation
+ allow(Whatsapp::Providers::WhatsappBaileysService).to receive(:new).and_return(baileys_service)
+ allow(baileys_service).to receive(:update_group_subject).and_return(true)
+ allow(baileys_service).to receive(:update_group_description).and_return(true)
+ end
+
+ describe 'PATCH /api/v1/accounts/{account.id}/contacts/:id/group_metadata' do
+ context 'when unauthenticated' do
+ it 'returns unauthorized' do
+ patch "/api/v1/accounts/#{account.id}/contacts/#{group_contact.id}/group_metadata"
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when user is logged in' do
+ it 'updates the group subject and contact name' do
+ patch "/api/v1/accounts/#{account.id}/contacts/#{group_contact.id}/group_metadata",
+ params: { subject: 'New Group Name' },
+ headers: admin.create_new_auth_token
+
+ expect(response).to have_http_status(:ok)
+ expect(group_contact.reload.name).to eq('New Group Name')
+ expect(baileys_service).to have_received(:update_group_subject).with('group@g.us', 'New Group Name')
+ end
+
+ it 'updates the group description' do
+ patch "/api/v1/accounts/#{account.id}/contacts/#{group_contact.id}/group_metadata",
+ params: { description: 'A new description' },
+ headers: admin.create_new_auth_token
+
+ expect(response).to have_http_status(:ok)
+ expect(group_contact.reload.additional_attributes['description']).to eq('A new description')
+ expect(baileys_service).to have_received(:update_group_description).with('group@g.us', 'A new description')
+ end
+
+ it 'updates both subject and description' do
+ patch "/api/v1/accounts/#{account.id}/contacts/#{group_contact.id}/group_metadata",
+ params: { subject: 'Updated Name', description: 'Updated Desc' },
+ headers: admin.create_new_auth_token
+
+ expect(response).to have_http_status(:ok)
+ expect(group_contact.reload.name).to eq('Updated Name')
+ expect(group_contact.additional_attributes['description']).to eq('Updated Desc')
+ end
+
+ it 'returns 422 when provider is unavailable' do
+ allow(baileys_service).to receive(:update_group_subject)
+ .and_raise(Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError, 'Offline')
+
+ patch "/api/v1/accounts/#{account.id}/contacts/#{group_contact.id}/group_metadata",
+ params: { subject: 'New Name' },
+ headers: admin.create_new_auth_token
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ expect(response.parsed_body['error']).to eq('Offline')
+ end
+ end
+ end
+end
diff --git a/spec/controllers/api/v1/accounts/contacts_controller_spec.rb b/spec/controllers/api/v1/accounts/contacts_controller_spec.rb
index dcf5e2f4f..2282876a7 100644
--- a/spec/controllers/api/v1/accounts/contacts_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/contacts_controller_spec.rb
@@ -784,6 +784,50 @@ RSpec.describe 'Contacts API', type: :request do
end
end
+ describe 'POST /api/v1/accounts/{account.id}/contacts/:id/sync_group' do
+ let(:agent) { create(:user, account: account, role: :agent) }
+ let(:contact) { create(:contact, account: account, group_type: :group, identifier: '12345678901234567890@g.us') }
+
+ context 'when it is an unauthenticated user' do
+ it 'returns unauthorized' do
+ post "/api/v1/accounts/#{account.id}/contacts/#{contact.id}/sync_group"
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when it is an authenticated user' do
+ it 'enqueues SyncGroupJob and returns accepted' do
+ post "/api/v1/accounts/#{account.id}/contacts/#{contact.id}/sync_group",
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:accepted)
+ expect(Contacts::SyncGroupJob).to have_been_enqueued.with(contact)
+ end
+
+ it 'returns bad request when contact is not a group' do
+ individual_contact = create(:contact, account: account, group_type: :individual)
+
+ post "/api/v1/accounts/#{account.id}/contacts/#{individual_contact.id}/sync_group",
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:bad_request)
+ end
+
+ it 'returns bad request when contact has no identifier' do
+ group_without_id = create(:contact, account: account, group_type: :group, identifier: nil)
+
+ post "/api/v1/accounts/#{account.id}/contacts/#{group_without_id.id}/sync_group",
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:bad_request)
+ end
+ end
+ end
+
describe 'DELETE /api/v1/accounts/{account.id}/contacts/:id/avatar' do
let(:contact) { create(:contact, account: account) }
let(:agent) { create(:user, account: account, role: :agent) }
diff --git a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb
index 86f342fd2..a5f1478a4 100644
--- a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb
@@ -692,7 +692,7 @@ RSpec.describe 'Conversations API', type: :request do
end
it 'throttles updates within an hour when there are no unread messages' do
- conversation.update!(agent_last_seen_at: 30.minutes.ago)
+ conversation.update!(agent_last_seen_at: 30.minutes.ago, last_activity_at: 31.minutes.ago)
# Ensure all messages are older than agent_last_seen_at (no unread messages)
# rubocop:disable Rails/SkipsModelValidations
conversation.messages.update_all(created_at: 1.hour.ago)
@@ -742,7 +742,8 @@ RSpec.describe 'Conversations API', type: :request do
end
it 'throttles only when both timestamps are recent and no unread messages' do
- conversation.update!(assignee_id: agent.id, agent_last_seen_at: 30.minutes.ago, assignee_last_seen_at: 30.minutes.ago)
+ conversation.update!(assignee_id: agent.id, agent_last_seen_at: 30.minutes.ago, assignee_last_seen_at: 30.minutes.ago,
+ last_activity_at: 31.minutes.ago)
# Ensure all messages are older (no unread messages)
# rubocop:disable Rails/SkipsModelValidations
conversation.messages.update_all(created_at: 1.hour.ago)
diff --git a/spec/controllers/api/v1/accounts/groups_controller_spec.rb b/spec/controllers/api/v1/accounts/groups_controller_spec.rb
new file mode 100644
index 000000000..7b8165604
--- /dev/null
+++ b/spec/controllers/api/v1/accounts/groups_controller_spec.rb
@@ -0,0 +1,67 @@
+require 'rails_helper'
+
+RSpec.describe '/api/v1/accounts/{account.id}/groups', type: :request do
+ let(:account) { create(:account) }
+ let(:admin) { create(:user, account: account, role: :administrator) }
+ let(:agent) { create(:user, account: account, role: :agent) }
+ let(:inbox) { create(:inbox, account: account) }
+ let(:create_service) { instance_double(Groups::CreateService) }
+
+ before do
+ create(:inbox_member, inbox: inbox, user: admin)
+ create(:inbox_member, inbox: inbox, user: agent)
+ allow(Groups::CreateService).to receive(:new).and_return(create_service)
+ allow(create_service).to receive(:perform)
+ Channel::WebWidget.define_method(:allow_group_creation?) { true }
+ end
+
+ after do
+ Channel::WebWidget.remove_method(:allow_group_creation?) if Channel::WebWidget.method_defined?(:allow_group_creation?)
+ end
+
+ describe 'POST /api/v1/accounts/{account.id}/groups' do
+ context 'when unauthenticated user' do
+ it 'returns unauthorized' do
+ post "/api/v1/accounts/#{account.id}/groups"
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when user is logged in' do
+ it 'creates a group conversation and returns it' do
+ conversation = create(:conversation, account: account, group_type: :group)
+ allow(create_service).to receive(:perform).and_return(conversation)
+
+ post "/api/v1/accounts/#{account.id}/groups",
+ params: { inbox_id: inbox.id, subject: 'Test Group', participants: ['+5511999999999'] },
+ headers: admin.create_new_auth_token
+
+ expect(response).to have_http_status(:success)
+ expect(response.parsed_body['group_type']).to eq('group')
+ end
+
+ it 'returns 422 when provider is unavailable' do
+ allow(create_service).to receive(:perform)
+ .and_raise(Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError, 'Unavailable')
+
+ post "/api/v1/accounts/#{account.id}/groups",
+ params: { inbox_id: inbox.id, subject: 'Test Group', participants: [] },
+ headers: admin.create_new_auth_token
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ expect(response.parsed_body['error']).to eq('Unavailable')
+ end
+
+ it 'returns 403 when agent does not have inbox access' do
+ other_inbox = create(:inbox, account: account)
+
+ post "/api/v1/accounts/#{account.id}/groups",
+ params: { inbox_id: other_inbox.id, subject: 'Test Group', participants: [] },
+ headers: agent.create_new_auth_token
+
+ expect(response).to have_http_status(:forbidden)
+ expect(response.parsed_body['error']).to be_present
+ end
+ end
+ end
+end
diff --git a/spec/factories/group_members.rb b/spec/factories/group_members.rb
new file mode 100644
index 000000000..911d10ae6
--- /dev/null
+++ b/spec/factories/group_members.rb
@@ -0,0 +1,14 @@
+FactoryBot.define do
+ factory :group_member do
+ group_contact { association :contact, group_type: :group }
+ contact { association :contact, account: group_contact.account }
+
+ trait :admin do
+ role { :admin }
+ end
+
+ trait :inactive do
+ is_active { false }
+ end
+ end
+end
diff --git a/spec/jobs/contacts/sync_group_job_spec.rb b/spec/jobs/contacts/sync_group_job_spec.rb
new file mode 100644
index 000000000..747422df2
--- /dev/null
+++ b/spec/jobs/contacts/sync_group_job_spec.rb
@@ -0,0 +1,60 @@
+require 'rails_helper'
+
+RSpec.describe Contacts::SyncGroupJob do
+ let(:account) { create(:account) }
+
+ let(:contact) do
+ create(:contact, account: account, group_type: :group, identifier: '12345@g.us', additional_attributes: {})
+ end
+
+ describe '#perform' do
+ it 'calls SyncGroupService when group_last_synced_at is nil' do
+ service = instance_double(Contacts::SyncGroupService, perform: contact)
+ allow(Contacts::SyncGroupService).to receive(:new).with(contact: contact, soft: false).and_return(service)
+
+ described_class.perform_now(contact)
+
+ expect(Contacts::SyncGroupService).to have_received(:new).with(contact: contact, soft: false)
+ end
+
+ it 'calls SyncGroupService when group_last_synced_at is older than 15 minutes' do
+ contact.update!(additional_attributes: { 'group_last_synced_at' => 20.minutes.ago.to_i })
+
+ service = instance_double(Contacts::SyncGroupService, perform: contact)
+ allow(Contacts::SyncGroupService).to receive(:new).with(contact: contact, soft: false).and_return(service)
+
+ described_class.perform_now(contact)
+
+ expect(Contacts::SyncGroupService).to have_received(:new).with(contact: contact, soft: false)
+ end
+
+ it 'skips SyncGroupService when group_last_synced_at is within the last 15 minutes' do
+ contact.update!(additional_attributes: { 'group_last_synced_at' => 5.minutes.ago.to_i })
+
+ allow(Contacts::SyncGroupService).to receive(:new)
+
+ described_class.perform_now(contact)
+
+ expect(Contacts::SyncGroupService).not_to have_received(:new)
+ end
+
+ it 'calls SyncGroupService when recently synced but force is true' do
+ contact.update!(additional_attributes: { 'group_last_synced_at' => 5.minutes.ago.to_i })
+
+ service = instance_double(Contacts::SyncGroupService, perform: contact)
+ allow(Contacts::SyncGroupService).to receive(:new).with(contact: contact, soft: false).and_return(service)
+
+ described_class.perform_now(contact, force: true)
+
+ expect(Contacts::SyncGroupService).to have_received(:new).with(contact: contact, soft: false)
+ end
+
+ it 'rescues ProviderUnavailableError without re-raising' do
+ allow(Contacts::SyncGroupService).to receive(:new).and_raise(
+ Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError, 'Provider offline'
+ )
+
+ expect { described_class.perform_now(contact) }.not_to raise_error
+ end
+ end
+end
diff --git a/spec/models/channel/whatsapp_spec.rb b/spec/models/channel/whatsapp_spec.rb
index 6fd12f39e..633f87eb5 100644
--- a/spec/models/channel/whatsapp_spec.rb
+++ b/spec/models/channel/whatsapp_spec.rb
@@ -551,4 +551,30 @@ RSpec.describe Channel::Whatsapp do
end
end
end
+
+ describe '#sync_group' do
+ it 'delegates to provider_service when it supports sync_group' do
+ channel = create(:channel_whatsapp, provider: 'baileys', validate_provider_config: false, sync_templates: false)
+ conversation = create(:conversation, inbox: channel.inbox, account: channel.account)
+ provider_double = instance_double(Whatsapp::Providers::WhatsappBaileysService, sync_group: nil)
+ allow(Whatsapp::Providers::WhatsappBaileysService).to receive(:new)
+ .with(whatsapp_channel: channel)
+ .and_return(provider_double)
+
+ channel.sync_group(conversation)
+
+ expect(provider_double).to have_received(:sync_group).with(conversation, soft: false)
+ end
+
+ it 'does nothing when provider_service does not support sync_group' do
+ channel = create(:channel_whatsapp, provider: 'whatsapp_cloud', validate_provider_config: false, sync_templates: false)
+ conversation = create(:conversation, inbox: channel.inbox, account: channel.account)
+ provider_double = instance_double(Whatsapp::Providers::WhatsappCloudService)
+ allow(Whatsapp::Providers::WhatsappCloudService).to receive(:new)
+ .with(whatsapp_channel: channel)
+ .and_return(provider_double)
+
+ expect(channel.sync_group(conversation)).to be_nil
+ end
+ end
end
diff --git a/spec/models/contact_inbox_spec.rb b/spec/models/contact_inbox_spec.rb
index 08e7d79eb..ef86eacf4 100644
--- a/spec/models/contact_inbox_spec.rb
+++ b/spec/models/contact_inbox_spec.rb
@@ -61,11 +61,11 @@ RSpec.describe ContactInbox do
expect(valid_source_id.valid?).to be(true)
expect(ci_character_in_source_id.valid?).to be(false)
expect(ci_character_in_source_id.errors.full_messages).to eq(
- ['Source invalid source id for whatsapp inbox. valid Regex (?-mix:^\\d{1,15}\\z)']
+ ['Source invalid source id for whatsapp inbox. valid Regex (?-mix:^\\d{1,20}(-\\d{1,20})?\\z)']
)
expect(ci_plus_in_source_id.valid?).to be(false)
expect(ci_plus_in_source_id.errors.full_messages).to eq(
- ['Source invalid source id for whatsapp inbox. valid Regex (?-mix:^\\d{1,15}\\z)']
+ ['Source invalid source id for whatsapp inbox. valid Regex (?-mix:^\\d{1,20}(-\\d{1,20})?\\z)']
)
end
diff --git a/spec/models/contact_spec.rb b/spec/models/contact_spec.rb
index f21a81978..6e97b913e 100644
--- a/spec/models/contact_spec.rb
+++ b/spec/models/contact_spec.rb
@@ -196,4 +196,14 @@ RSpec.describe Contact do
end
end
end
+
+ describe 'group_type' do
+ it 'provides type check methods' do
+ individual_contact = create(:contact, group_type: :individual)
+ group_contact = create(:contact, group_type: :group)
+
+ expect(individual_contact).to be_group_type_individual
+ expect(group_contact).to be_group_type_group
+ end
+ end
end
diff --git a/spec/models/conversation_spec.rb b/spec/models/conversation_spec.rb
index 159df50a1..b33435080 100644
--- a/spec/models/conversation_spec.rb
+++ b/spec/models/conversation_spec.rb
@@ -589,7 +589,8 @@ RSpec.describe Conversation do
updated_at: conversation.updated_at.to_f,
waiting_since: conversation.waiting_since.to_i,
priority: nil,
- unread_count: 0
+ unread_count: 0,
+ group_type: 'individual'
}
end
@@ -1084,4 +1085,14 @@ RSpec.describe Conversation do
end
end
end
+
+ describe 'group_type' do
+ it 'provides type check methods' do
+ individual_conversation = create(:conversation, group_type: :individual)
+ group_conversation = create(:conversation, group_type: :group)
+
+ expect(individual_conversation).to be_group_type_individual
+ expect(group_conversation).to be_group_type_group
+ end
+ end
end
diff --git a/spec/services/contacts/sync_group_service_spec.rb b/spec/services/contacts/sync_group_service_spec.rb
new file mode 100644
index 000000000..81e526653
--- /dev/null
+++ b/spec/services/contacts/sync_group_service_spec.rb
@@ -0,0 +1,52 @@
+require 'rails_helper'
+
+RSpec.describe Contacts::SyncGroupService do
+ describe '#perform' do
+ it 'raises BadRequest when contact is not a group' do
+ contact = create(:contact, group_type: :individual, identifier: 'group@g.us')
+
+ expect { described_class.new(contact: contact).perform }.to raise_error(ActionController::BadRequest)
+ end
+
+ it 'raises BadRequest when contact has no identifier' do
+ contact = create(:contact, group_type: :group, identifier: nil)
+
+ expect { described_class.new(contact: contact).perform }.to raise_error(ActionController::BadRequest)
+ end
+
+ it 'raises BadRequest when no channel supports sync_group' do
+ contact = create(:contact, group_type: :group, identifier: 'group@g.us')
+
+ expect { described_class.new(contact: contact).perform }.to raise_error(ActionController::BadRequest)
+ end
+
+ it 'calls channel.sync_group with a conversation' do
+ channel = create(:channel_whatsapp, provider: 'baileys', validate_provider_config: false)
+ contact = create(:contact, account: channel.account, group_type: :group, identifier: 'group@g.us')
+ contact_inbox = create(:contact_inbox, contact: contact, inbox: channel.inbox)
+ conversation = create(:conversation, account: channel.account, inbox: channel.inbox, contact: contact, contact_inbox: contact_inbox)
+
+ allow(channel).to receive(:sync_group).and_return(true)
+ allow(contact).to receive(:group_channel).and_return(channel)
+
+ described_class.new(contact: contact).perform
+
+ expect(channel).to have_received(:sync_group).with(conversation, soft: false)
+ end
+
+ it 'dispatches contact_group_synced event' do
+ channel = create(:channel_whatsapp, provider: 'baileys', validate_provider_config: false)
+ contact = create(:contact, account: channel.account, group_type: :group, identifier: 'group@g.us')
+ contact_inbox = create(:contact_inbox, contact: contact, inbox: channel.inbox)
+ create(:conversation, account: channel.account, inbox: channel.inbox, contact: contact, contact_inbox: contact_inbox)
+
+ allow(channel).to receive(:sync_group).and_return(true)
+ allow(contact).to receive(:group_channel).and_return(channel)
+
+ expect(Rails.configuration.dispatcher).to receive(:dispatch)
+ .with(Events::Types::CONTACT_GROUP_SYNCED, anything, contact: contact)
+
+ described_class.new(contact: contact).perform
+ end
+ end
+end
diff --git a/spec/services/messages/mention_service_spec.rb b/spec/services/messages/mention_service_spec.rb
index 7cdb8ffe7..1a6410a10 100644
--- a/spec/services/messages/mention_service_spec.rb
+++ b/spec/services/messages/mention_service_spec.rb
@@ -504,4 +504,70 @@ describe Messages::MentionService do
expect(Conversations::UserMentionJob).not_to have_received(:perform_later)
end
end
+
+ describe 'contact mentions' do
+ let(:group_contact) { create(:contact, account: account, group_type: :group) }
+
+ context 'when message contains contact mentions' do
+ it 'stores mentioned contact IDs in content_attributes' do
+ message = create(
+ :message,
+ conversation: conversation,
+ account: account,
+ content: "hey (mention://contact/#{group_contact.id}/Alice) check this out",
+ private: false
+ )
+
+ described_class.new(message: message).perform
+
+ expect(message.reload.content_attributes['mentioned_contacts']).to eq([group_contact.id.to_s])
+ end
+
+ it 'does not trigger user mention notifications for contact mentions' do
+ message = create(
+ :message,
+ conversation: conversation,
+ account: account,
+ content: "hey (mention://contact/#{group_contact.id}/Alice)",
+ private: false
+ )
+
+ described_class.new(message: message).perform
+
+ expect(NotificationBuilder).not_to have_received(:new)
+ expect(Conversations::UserMentionJob).not_to have_received(:perform_later)
+ end
+
+ it 'deduplicates repeated contact mentions' do
+ message = create(
+ :message,
+ conversation: conversation,
+ account: account,
+ content: "(mention://contact/#{group_contact.id}/Alice) and again (mention://contact/#{group_contact.id}/Alice)",
+ private: false
+ )
+
+ described_class.new(message: message).perform
+
+ expect(message.reload.content_attributes['mentioned_contacts']).to eq([group_contact.id.to_s])
+ end
+ end
+
+ context 'when message has no contact mentions' do
+ it 'does not update content_attributes' do
+ message = create(
+ :message,
+ conversation: conversation,
+ account: account,
+ content: 'just a regular group message',
+ private: false
+ )
+ original_attrs = message.content_attributes.dup
+
+ described_class.new(message: message).perform
+
+ expect(message.reload.content_attributes).to eq(original_attrs)
+ end
+ end
+ end
end
diff --git a/spec/services/whatsapp/baileys_handlers/group_participants_update_spec.rb b/spec/services/whatsapp/baileys_handlers/group_participants_update_spec.rb
new file mode 100644
index 000000000..c96e1f117
--- /dev/null
+++ b/spec/services/whatsapp/baileys_handlers/group_participants_update_spec.rb
@@ -0,0 +1,122 @@
+require 'rails_helper'
+
+describe Whatsapp::BaileysHandlers::GroupParticipantsUpdate do
+ let(:webhook_verify_token) { 'valid_token' }
+ let!(:whatsapp_channel) do
+ create(:channel_whatsapp,
+ provider: 'baileys',
+ provider_config: { webhook_verify_token: webhook_verify_token },
+ validate_provider_config: false,
+ received_messages: false)
+ end
+ let(:inbox) { whatsapp_channel.inbox }
+ let(:group_jid) { '123456789@g.us' }
+ let(:group_source_id) { '123456789' }
+
+ def perform(data)
+ params = { webhookVerifyToken: webhook_verify_token, event: 'group-participants.update', data: data }
+ Whatsapp::IncomingMessageBaileysService.new(inbox: inbox, params: params).perform
+ end
+
+ def create_group_conversation
+ contact = create(:contact, account: inbox.account, identifier: group_jid, group_type: :group)
+ contact_inbox = create(:contact_inbox, inbox: inbox, contact: contact, source_id: group_source_id)
+ conversation = create(:conversation, account: inbox.account, inbox: inbox, contact: contact,
+ contact_inbox: contact_inbox, group_type: :group, status: :open)
+ [conversation, contact]
+ end
+
+ def participant(id, phone, admin: nil)
+ { id: id, phoneNumber: phone, admin: admin }
+ end
+
+ describe 'add action' do
+ it 'adds participants as group members and creates activity message' do
+ conversation, group_contact = create_group_conversation
+ author_lid = '99999999'
+ author_contact = create(:contact, account: inbox.account, name: 'Admin User')
+ create(:contact_inbox, inbox: inbox, contact: author_contact, source_id: author_lid)
+
+ perform(id: group_jid, action: 'add', author: "#{author_lid}@lid",
+ participants: [participant('11111111@lid', '5511911111111@s.whatsapp.net')])
+
+ member = GroupMember.find_by(group_contact: group_contact, contact: Contact.find_by(phone_number: '+5511911111111'))
+
+ expect(member).to be_is_active
+ expect(member.role).to eq('member')
+ expect(conversation.messages.activity.last.content).to include('Admin User', 'added')
+ end
+ end
+
+ describe 'join action (add without author)' do
+ it 'creates join activity when participant adds themselves' do
+ conversation, = create_group_conversation
+
+ perform(id: group_jid, action: 'add', author: nil,
+ participants: [participant('11111111@lid', '5511911111111@s.whatsapp.net')])
+
+ activity = conversation.messages.activity.last
+
+ expect(activity.content).to include('joined')
+ end
+ end
+
+ describe 'remove action' do
+ it 'deactivates the member and creates activity message' do
+ conversation, group_contact = create_group_conversation
+ removed_contact = create(:contact, account: inbox.account, phone_number: '+5511911111111')
+ create(:contact_inbox, inbox: inbox, contact: removed_contact, source_id: '11111111')
+ GroupMember.create!(group_contact: group_contact, contact: removed_contact)
+
+ perform(id: group_jid, action: 'remove', author: '99999999@lid',
+ participants: [participant('11111111@lid', '5511911111111@s.whatsapp.net')])
+
+ expect(GroupMember.find_by(group_contact: group_contact, contact: removed_contact)).not_to be_is_active
+ expect(conversation.messages.activity.last.content).to include('removed')
+ end
+ end
+
+ describe 'leave action (remove by self)' do
+ it 'creates leave activity when participant removes themselves' do
+ conversation, group_contact = create_group_conversation
+ leaving_contact = create(:contact, account: inbox.account, phone_number: '+5511911111111')
+ create(:contact_inbox, inbox: inbox, contact: leaving_contact, source_id: '11111111')
+ GroupMember.create!(group_contact: group_contact, contact: leaving_contact)
+
+ perform(id: group_jid, action: 'remove', author: '5511911111111@s.whatsapp.net',
+ participants: [participant('11111111@lid', '5511911111111@s.whatsapp.net')])
+
+ expect(conversation.messages.activity.last.content).to include('left')
+ end
+ end
+
+ describe 'promote action' do
+ it 'updates member role to admin and creates activity message' do
+ conversation, group_contact = create_group_conversation
+ contact = create(:contact, account: inbox.account)
+ create(:contact_inbox, inbox: inbox, contact: contact, source_id: '11111111')
+ GroupMember.create!(group_contact: group_contact, contact: contact, role: :member)
+
+ perform(id: group_jid, action: 'promote', author: '99999999@lid',
+ participants: [participant('11111111@lid', '5511911111111@s.whatsapp.net')])
+
+ expect(GroupMember.find_by(group_contact: group_contact, contact: contact).role).to eq('admin')
+ expect(conversation.messages.activity.last.content).to include('promoted')
+ end
+ end
+
+ describe 'demote action' do
+ it 'updates member role to member and creates activity message' do
+ conversation, group_contact = create_group_conversation
+ contact = create(:contact, account: inbox.account)
+ create(:contact_inbox, inbox: inbox, contact: contact, source_id: '11111111')
+ GroupMember.create!(group_contact: group_contact, contact: contact, role: :admin)
+
+ perform(id: group_jid, action: 'demote', author: '99999999@lid',
+ participants: [participant('11111111@lid', '5511911111111@s.whatsapp.net')])
+
+ expect(GroupMember.find_by(group_contact: group_contact, contact: contact).role).to eq('member')
+ expect(conversation.messages.activity.last.content).to include('demoted')
+ end
+ end
+end
diff --git a/spec/services/whatsapp/baileys_handlers/groups_activity_spec.rb b/spec/services/whatsapp/baileys_handlers/groups_activity_spec.rb
new file mode 100644
index 000000000..b9eb10d58
--- /dev/null
+++ b/spec/services/whatsapp/baileys_handlers/groups_activity_spec.rb
@@ -0,0 +1,79 @@
+require 'rails_helper'
+
+describe Whatsapp::BaileysHandlers::GroupsActivity do
+ let(:webhook_verify_token) { 'valid_token' }
+ let!(:whatsapp_channel) do
+ create(:channel_whatsapp,
+ provider: 'baileys',
+ provider_config: { webhook_verify_token: webhook_verify_token },
+ validate_provider_config: false,
+ received_messages: false)
+ end
+ let(:inbox) { whatsapp_channel.inbox }
+ let(:group_jid) { '123456789@g.us' }
+ let(:group_source_id) { '123456789' }
+
+ def perform(data)
+ params = { webhookVerifyToken: webhook_verify_token, event: 'groups.activity', data: data }
+ Whatsapp::IncomingMessageBaileysService.new(inbox: inbox, params: params).perform
+ end
+
+ def create_group_conversation
+ contact = create(:contact, account: inbox.account, identifier: group_jid, group_type: :group, name: group_source_id)
+ contact_inbox = create(:contact_inbox, inbox: inbox, contact: contact, source_id: group_source_id)
+ conversation = create(:conversation, account: inbox.account, inbox: inbox, contact: contact,
+ contact_inbox: contact_inbox, group_type: :group, status: :open)
+ [conversation, contact, contact_inbox]
+ end
+
+ describe 'existing group conversation' do
+ it 'updates last_activity_at on the conversation' do
+ conversation, = create_group_conversation
+ original_activity = conversation.last_activity_at
+
+ travel_to 1.minute.from_now do
+ perform([{ jid: group_jid }])
+ expect(conversation.reload.last_activity_at).to be > original_activity
+ end
+ end
+
+ it 'enqueues SyncGroupJob in soft mode for the contact' do
+ _conversation, contact, = create_group_conversation
+ perform([{ jid: group_jid }])
+ expect(Contacts::SyncGroupJob).to have_been_enqueued.with(contact, soft: true)
+ end
+ end
+
+ describe 'group that does not exist yet' do
+ it 'creates the group contact, contact_inbox, and conversation' do
+ expect { perform([{ jid: group_jid }]) }
+ .to change(Contact, :count).by(1)
+ .and change(ContactInbox, :count).by(1)
+ .and change(Conversation, :count).by(1)
+
+ contact = Contact.find_by(identifier: group_jid, account: inbox.account)
+ expect(contact).to be_present
+ expect(contact.group_type).to eq('group')
+
+ conversation = contact.conversations.last
+ expect(conversation.group_type).to eq('group')
+ expect(conversation.inbox).to eq(inbox)
+ end
+
+ it 'enqueues SyncGroupJob in soft mode for the new contact' do
+ perform([{ jid: group_jid }])
+ contact = Contact.find_by(identifier: group_jid, account: inbox.account)
+ expect(Contacts::SyncGroupJob).to have_been_enqueued.with(contact, soft: true)
+ end
+ end
+
+ describe 'skips invalid data' do
+ it 'skips activities with blank jid' do
+ expect { perform([{ jid: '' }]) }.not_to change(Conversation, :count)
+ end
+
+ it 'handles empty data gracefully' do
+ expect { perform(nil) }.not_to raise_error
+ end
+ end
+end
diff --git a/spec/services/whatsapp/baileys_handlers/groups_update_spec.rb b/spec/services/whatsapp/baileys_handlers/groups_update_spec.rb
new file mode 100644
index 000000000..812887a8b
--- /dev/null
+++ b/spec/services/whatsapp/baileys_handlers/groups_update_spec.rb
@@ -0,0 +1,93 @@
+require 'rails_helper'
+
+describe Whatsapp::BaileysHandlers::GroupsUpdate do
+ let(:webhook_verify_token) { 'valid_token' }
+ let!(:whatsapp_channel) do
+ create(:channel_whatsapp,
+ provider: 'baileys',
+ provider_config: { webhook_verify_token: webhook_verify_token },
+ validate_provider_config: false,
+ received_messages: false)
+ end
+ let(:inbox) { whatsapp_channel.inbox }
+ let(:group_jid) { '123456789@g.us' }
+ let(:group_source_id) { '123456789' }
+
+ def perform(data)
+ params = { webhookVerifyToken: webhook_verify_token, event: 'groups.update', data: data }
+ Whatsapp::IncomingMessageBaileysService.new(inbox: inbox, params: params).perform
+ end
+
+ def create_group_conversation(name: group_source_id)
+ contact = create(:contact, account: inbox.account, identifier: group_jid, group_type: :group, name: name)
+ contact_inbox = create(:contact_inbox, inbox: inbox, contact: contact, source_id: group_source_id)
+ conversation = create(:conversation, account: inbox.account, inbox: inbox, contact: contact,
+ contact_inbox: contact_inbox, group_type: :group, status: :open)
+ [conversation, contact]
+ end
+
+ describe 'subject change' do
+ it 'updates the group contact name and creates activity message' do
+ conversation, contact = create_group_conversation(name: 'Old Name')
+
+ perform([{ id: group_jid, subject: 'New Group Name', author: '99999999@lid' }])
+
+ expect(contact.reload.name).to eq('New Group Name')
+ expect(conversation.messages.activity.last.content).to include('New Group Name')
+ end
+ end
+
+ describe 'description change' do
+ it 'creates activity when description is set' do
+ conversation, = create_group_conversation
+
+ perform([{ id: group_jid, desc: 'A new description', author: '99999999@lid' }])
+
+ expect(conversation.messages.activity.last.content).to include('changed the group description')
+ end
+
+ it 'creates activity when description is removed' do
+ conversation, = create_group_conversation
+
+ perform([{ id: group_jid, desc: '', author: '99999999@lid' }])
+
+ expect(conversation.messages.activity.last.content).to include('removed the group description')
+ end
+ end
+
+ describe 'invite link reset' do
+ it 'creates activity message' do
+ conversation, = create_group_conversation
+
+ perform([{ id: group_jid, inviteCode: 'abc123', author: '99999999@lid' }])
+
+ expect(conversation.messages.activity.last.content).to include('invite link')
+ end
+ end
+
+ describe 'settings changes' do
+ it 'creates activity for restrict enabled' do
+ conversation, = create_group_conversation
+
+ perform([{ id: group_jid, restrict: true, author: '99999999@lid' }])
+
+ expect(conversation.messages.activity.last.content).to include('only admins can edit')
+ end
+
+ it 'creates activity for announce enabled' do
+ conversation, = create_group_conversation
+
+ perform([{ id: group_jid, announce: true, author: '99999999@lid' }])
+
+ expect(conversation.messages.activity.last.content).to include('only admins to send messages')
+ end
+
+ it 'creates activity for joinApprovalMode toggled' do
+ conversation, = create_group_conversation
+
+ perform([{ id: group_jid, joinApprovalMode: true, author: '99999999@lid' }])
+
+ expect(conversation.messages.activity.last.content).to include('admin approval')
+ end
+ end
+end
diff --git a/spec/services/whatsapp/baileys_handlers/messages_upsert_spec.rb b/spec/services/whatsapp/baileys_handlers/messages_upsert_spec.rb
index a457ae543..0f7ba8221 100644
--- a/spec/services/whatsapp/baileys_handlers/messages_upsert_spec.rb
+++ b/spec/services/whatsapp/baileys_handlers/messages_upsert_spec.rb
@@ -498,6 +498,115 @@ describe Whatsapp::BaileysHandlers::MessagesUpsert do
end
end
+ describe 'group message handling' do
+ before do
+ allow(Whatsapp::Providers::WhatsappBaileysService).to receive(:groups_enabled?).and_return(true)
+ end
+
+ let(:group_jid) { '123456789123456789@g.us' }
+ let(:group_source_id) { '123456789123456789' }
+ let(:sender_phone) { '5511912345678' }
+ let(:sender_lid) { '12345678' }
+
+ def build_group_raw_message(id:, text:, sender_participant: "#{sender_lid}@lid", sender_alt: "#{sender_phone}@s.whatsapp.net")
+ {
+ key: { id: id, remoteJid: group_jid, participant: sender_participant, participantAlt: sender_alt, fromMe: false },
+ pushName: 'Sender User',
+ messageTimestamp: timestamp,
+ message: { conversation: text }
+ }
+ end
+
+ def build_params(raw_message)
+ { webhookVerifyToken: webhook_verify_token, event: 'messages.upsert', data: { type: 'notify', messages: [raw_message] } }
+ end
+
+ it 'creates a group conversation where the message sender is a group member' do
+ params = build_params(build_group_raw_message(id: 'grp_msg_001', text: 'Hello group'))
+
+ expect do
+ Whatsapp::IncomingMessageBaileysService.new(inbox: inbox, params: params).perform
+ end.to change(inbox.messages, :count).by(1)
+ .and change(Conversation, :count).by(1)
+
+ message = inbox.messages.last
+ conversation = message.conversation
+ group_contact = conversation.contact
+
+ expect(group_contact.group_type).to eq('group')
+ expect(group_contact.identifier).to eq(group_jid)
+ expect(conversation.group_type).to eq('group')
+ expect(message.content).to eq('Hello group')
+ expect(message.sender).not_to eq(group_contact)
+ expect(GroupMember.where(group_contact: group_contact).active.pluck(:contact_id)).to include(message.sender_id)
+ end
+
+ it 'adds only the message sender as a group member' do
+ params = build_params(build_group_raw_message(id: 'grp_msg_002', text: 'Hi'))
+
+ Whatsapp::IncomingMessageBaileysService.new(inbox: inbox, params: params).perform
+
+ conversation = inbox.conversations.last
+ group_contact = conversation.contact
+ members = GroupMember.where(group_contact: group_contact).active
+
+ expect(members.count).to eq(1)
+ sender_member = members.first
+ expect(sender_member.contact.phone_number).to eq("+#{sender_phone}")
+ expect(sender_member.role).to eq('member')
+ end
+
+ it 'creates message with correct sender when different members send messages' do
+ other_phone = '5511911111111'
+ other_lid = '11111111'
+
+ Whatsapp::IncomingMessageBaileysService.new(
+ inbox: inbox,
+ params: build_params(build_group_raw_message(id: 'grp_msg_005', text: 'From sender'))
+ ).perform
+
+ Whatsapp::IncomingMessageBaileysService.new(
+ inbox: inbox,
+ params: build_params(build_group_raw_message(
+ id: 'grp_msg_006', text: 'From other',
+ sender_participant: "#{other_lid}@lid", sender_alt: "#{other_phone}@s.whatsapp.net"
+ ))
+ ).perform
+
+ conversation = inbox.conversations.last
+ messages = conversation.messages.order(:created_at)
+
+ expect(messages.first.sender.phone_number).to eq("+#{sender_phone}")
+ expect(messages.last.sender.phone_number).to eq("+#{other_phone}")
+ expect(messages.first.sender).not_to eq(messages.last.sender)
+ expect(conversation.contact.group_type).to eq('group')
+ end
+
+ it 'processes a group image message with attachment' do
+ stub_request(:get, whatsapp_channel.media_url('grp_img_001'))
+ .to_return(status: 200, body: 'fake image data')
+
+ raw_message = {
+ key: { id: 'grp_img_001', remoteJid: group_jid, participant: "#{sender_lid}@lid",
+ participantAlt: "#{sender_phone}@s.whatsapp.net", fromMe: false },
+ pushName: 'Sender User',
+ messageTimestamp: timestamp,
+ message: { imageMessage: { caption: 'Group photo', mimetype: 'image/jpeg', url: 'https://example.com/img.jpg' } }
+ }
+ params = build_params(raw_message)
+
+ expect do
+ Whatsapp::IncomingMessageBaileysService.new(inbox: inbox, params: params).perform
+ end.to change(inbox.messages, :count).by(1)
+
+ message = inbox.messages.last
+
+ expect(message.content).to eq('Group photo')
+ expect(message.attachments.count).to eq(1)
+ expect(message.sender).not_to eq(message.conversation.contact)
+ end
+ end
+
describe 'conversation duplication after deletion or resolution' do
let(:phone) { '5511912345678' }
let(:lid) { '12345678' }
@@ -568,4 +677,111 @@ describe Whatsapp::BaileysHandlers::MessagesUpsert do
it_behaves_like 'routes messages to the new conversation', first_msg_id: 'msg_003', second_msg_id: 'msg_004'
end
end
+
+ describe 'membership request stub handling' do
+ let(:group_jid) { '123456789123456789@g.us' }
+ let(:requester_lid) { '12345678' }
+ let(:requester_phone) { '5511912345678' }
+ let(:participant_json) { { lid: "#{requester_lid}@lid", pn: "#{requester_phone}@s.whatsapp.net" }.to_json }
+
+ def build_stub_message(action_params)
+ {
+ key: { remoteJid: group_jid, fromMe: false, id: '11111111', participant: "#{requester_lid}@lid" },
+ messageTimestamp: { low: timestamp, high: 0, unsigned: true },
+ participant: "#{requester_lid}@lid",
+ messageStubType: 'GROUP_MEMBERSHIP_JOIN_APPROVAL_REQUEST_NON_ADMIN_ADD',
+ messageStubParameters: [participant_json, *action_params]
+ }
+ end
+
+ def build_params(raw_message)
+ { webhookVerifyToken: webhook_verify_token, event: 'messages.upsert', data: { type: 'append', messages: [raw_message] } }
+ end
+
+ def perform_service(raw_message)
+ Whatsapp::IncomingMessageBaileysService.new(inbox: inbox, params: build_params(raw_message)).perform
+ end
+
+ context 'when a user requests to join the group' do
+ let(:raw_message) { build_stub_message(%w[created invite_link]) }
+
+ it 'creates an activity message indicating the user wants to join' do
+ expect { perform_service(raw_message) }
+ .to change(inbox.messages, :count).by(1)
+ .and change(Conversation, :count).by(1)
+
+ message = inbox.messages.last
+
+ expect(message.message_type).to eq('activity')
+ expect(message.content).to include('wants to join the group')
+ end
+ end
+
+ context 'when a user revokes their join request' do
+ let(:raw_message) { build_stub_message(%w[revoked]) }
+
+ it 'creates an activity message indicating the user no longer wants to join' do
+ expect { perform_service(raw_message) }
+ .to change(inbox.messages, :count).by(1)
+
+ message = inbox.messages.last
+
+ expect(message.message_type).to eq('activity')
+ expect(message.content).to include('no longer wants to join the group')
+ end
+ end
+ end
+
+ describe 'group icon change stub handling' do
+ let(:group_jid) { '123456789123456789@g.us' }
+ let(:raw_message) do
+ {
+ key: { remoteJid: group_jid, fromMe: false, id: '111111111', participant: "#{author_lid}@lid" },
+ messageTimestamp: timestamp.to_s,
+ participant: "#{author_lid}@lid",
+ messageStubType: 'GROUP_CHANGE_ICON',
+ messageStubParameters: [timestamp.to_s]
+ }
+ end
+ let(:author_lid) { '12345678' }
+
+ def build_params(raw_message)
+ { webhookVerifyToken: webhook_verify_token, event: 'messages.upsert', data: { type: 'append', messages: [raw_message] } }
+ end
+
+ def perform_service(raw_message)
+ Whatsapp::IncomingMessageBaileysService.new(inbox: inbox, params: build_params(raw_message)).perform
+ end
+
+ it 'creates an activity message about the icon change' do
+ expect { perform_service(raw_message) }
+ .to change(inbox.messages, :count).by(1)
+ .and change(Conversation, :count).by(1)
+
+ message = inbox.messages.last
+
+ expect(message.message_type).to eq('activity')
+ expect(message.content).to include('changed the group image')
+ end
+ end
+
+ describe 'unhandled stub messages' do
+ let(:group_jid) { '123456789123456789@g.us' }
+
+ def build_params(raw_message)
+ { webhookVerifyToken: webhook_verify_token, event: 'messages.upsert', data: { type: 'append', messages: [raw_message] } }
+ end
+
+ it 'does not crash and silently ignores unhandled group stub types' do
+ raw_message = {
+ key: { remoteJid: group_jid, fromMe: false, id: '11111111', participant: '12345678@lid' },
+ messageTimestamp: Time.current.to_i.to_s,
+ participant: '12345678@lid',
+ messageStubType: 'SOME_STUB_TYPE_WE_DONT_CARE_ABOUT'
+ }
+
+ expect { Whatsapp::IncomingMessageBaileysService.new(inbox: inbox, params: build_params(raw_message)).perform }
+ .not_to raise_error
+ end
+ end
end
diff --git a/spec/services/whatsapp/incoming_message_baileys_service_spec.rb b/spec/services/whatsapp/incoming_message_baileys_service_spec.rb
index fff1f7b52..a44a2f30f 100644
--- a/spec/services/whatsapp/incoming_message_baileys_service_spec.rb
+++ b/spec/services/whatsapp/incoming_message_baileys_service_spec.rb
@@ -1046,6 +1046,67 @@ describe Whatsapp::IncomingMessageBaileysService do
end
end
end
+
+ context 'when processing message-receipt.update event' do
+ let(:conversation) do
+ agent = create(:user, account: inbox.account, role: :agent)
+ contact = create(:contact, account: inbox.account)
+ contact_inbox = create(:contact_inbox, inbox: inbox, contact: contact)
+ create(:conversation, inbox: inbox, contact_inbox: contact_inbox, assignee_id: agent.id)
+ end
+ let!(:message) { create(:message, inbox: inbox, conversation: conversation, source_id: '123ABCDE1234567', status: 'sent') }
+ let(:receipt_payload) do
+ {
+ key: { remoteJid: '123456789123456789@g.us', id: '123ABCDE1234567', fromMe: true,
+ participant: '12345678@lid' },
+ receipt: receipt_data
+ }
+ end
+ let(:receipt_data) { { userJid: '12345678@lid', receiptTimestamp: 1_772_056_268 } }
+ let(:params) do
+ {
+ webhookVerifyToken: webhook_verify_token,
+ event: 'message-receipt.update',
+ data: [receipt_payload]
+ }
+ end
+
+ it 'updates message from sent to delivered on receiptTimestamp' do
+ described_class.new(inbox: inbox, params: params).perform
+
+ expect(message.reload.status).to eq('delivered')
+ end
+
+ it 'ignores readTimestamp and does not update status' do
+ receipt_data.replace(userJid: '12345678@lid', readTimestamp: 1_772_056_497)
+
+ described_class.new(inbox: inbox, params: params).perform
+
+ expect(message.reload.status).to eq('sent')
+ end
+
+ it 'does not downgrade a delivered message on receiptTimestamp' do
+ message.update!(status: 'delivered')
+
+ described_class.new(inbox: inbox, params: params).perform
+
+ expect(message.reload.status).to eq('delivered')
+ end
+
+ it 'does not downgrade a read message on receiptTimestamp' do
+ message.update!(status: 'read')
+
+ described_class.new(inbox: inbox, params: params).perform
+
+ expect(message.reload.status).to eq('read')
+ end
+
+ it 'does not raise error when message is not found' do
+ receipt_payload[:key][:id] = 'NONEXISTENT_MSG_ID'
+
+ expect { described_class.new(inbox: inbox, params: params).perform }.not_to raise_error
+ end
+ end
end
def format_message_source_key(message_id)
diff --git a/spec/services/whatsapp/providers/whatsapp_baileys_service_spec.rb b/spec/services/whatsapp/providers/whatsapp_baileys_service_spec.rb
index 1e3cf4758..a4b43ab89 100644
--- a/spec/services/whatsapp/providers/whatsapp_baileys_service_spec.rb
+++ b/spec/services/whatsapp/providers/whatsapp_baileys_service_spec.rb
@@ -100,7 +100,8 @@ describe Whatsapp::Providers::WhatsappBaileysService do
clientName: 'chatwoot-test',
webhookUrl: whatsapp_channel.inbox.callback_webhook_url,
webhookVerifyToken: whatsapp_channel.provider_config['webhook_verify_token'],
- includeMedia: false
+ includeMedia: false,
+ groupsEnabled: Whatsapp::Providers::WhatsappBaileysService::GROUPS_ENABLED
}.to_json
)
.to_return(status: 200)
@@ -120,7 +121,8 @@ describe Whatsapp::Providers::WhatsappBaileysService do
clientName: 'chatwoot-test',
webhookUrl: whatsapp_channel.inbox.callback_webhook_url,
webhookVerifyToken: whatsapp_channel.provider_config['webhook_verify_token'],
- includeMedia: false
+ includeMedia: false,
+ groupsEnabled: Whatsapp::Providers::WhatsappBaileysService::GROUPS_ENABLED
}.to_json
)
.to_return(
@@ -577,6 +579,30 @@ describe Whatsapp::Providers::WhatsappBaileysService do
end
end
+ context 'when recipient is a group' do
+ let(:group_jid) { '123456789123456789@g.us' }
+
+ it 'uses the group JID as-is without transformation' do
+ stub_request(:post, request_path)
+ .with(
+ headers: stub_headers(whatsapp_channel),
+ body: {
+ jid: group_jid,
+ messageContent: { text: message.content }
+ }.to_json
+ )
+ .to_return(
+ status: 200,
+ headers: { 'Content-Type' => 'application/json' },
+ body: result_body.to_json
+ )
+
+ result = service.send_message(group_jid, message)
+
+ expect(result).to eq('msg_123')
+ end
+ end
+
context 'when request is unsuccessful' do
it 'raises ProviderUnavailableError' do
stub_request(:post, request_path)
@@ -897,7 +923,7 @@ describe Whatsapp::Providers::WhatsappBaileysService do
.to_return(
status: 200,
headers: { 'Content-Type' => 'application/json' },
- body: [{ jid: "#{phone_number.delete('+')}@s.whatsapp.net", exists: true }].to_json
+ body: { data: [{ jid: "#{phone_number.delete('+')}@s.whatsapp.net", exists: true }] }.to_json
)
response = service.on_whatsapp(phone_number)
@@ -911,7 +937,7 @@ describe Whatsapp::Providers::WhatsappBaileysService do
.to_return(
status: 200,
headers: { 'Content-Type' => 'application/json' },
- body: [].to_json
+ body: { data: [] }.to_json
)
response = service.on_whatsapp(phone_number)
@@ -1013,6 +1039,267 @@ describe Whatsapp::Providers::WhatsappBaileysService do
end
end
+ context 'when managing group messages with participant' do # rubocop:disable RSpec/MultipleMemoizedHelpers
+ let(:inbox) { whatsapp_channel.inbox }
+ let(:account_user) { create(:account_user, account: inbox.account) }
+ let(:group_jid) { '123456789123456789@g.us' }
+ let(:participant_lid) { '1111111@lid' }
+ let(:group_contact) { create(:contact, account: inbox.account, name: 'Test Group', identifier: group_jid, group_type: :group) }
+ let(:sender_contact) do
+ create(:contact, account: inbox.account, name: 'Participant', identifier: participant_lid, phone_number: '+5511999999999')
+ end
+ let(:conversation) do
+ contact_inbox = create(:contact_inbox, inbox: inbox, contact: group_contact, source_id: group_jid.split('@').first)
+ create(:conversation, inbox: inbox, contact_inbox: contact_inbox, contact: group_contact, group_type: :group)
+ end
+ let(:incoming_group_message) do
+ create(:message, inbox: inbox, conversation: conversation, sender: sender_contact,
+ message_type: 'incoming', source_id: 'group_msg_123', content: 'Hello',
+ content_attributes: { external_created_at: 123 })
+ end
+ let(:outgoing_group_message) do
+ create(:message, inbox: inbox, conversation: conversation, sender: account_user,
+ message_type: 'outgoing', source_id: 'group_msg_456', content: 'Reply')
+ end
+ let(:send_message_path) { "#{whatsapp_channel.provider_config['provider_url']}/connections/#{whatsapp_channel.phone_number}/send-message" }
+ let(:result_body) { { 'data' => { 'key' => { 'id' => 'msg_123' } } } }
+
+ describe '#send_message' do # rubocop:disable RSpec/MultipleMemoizedHelpers
+ it 'includes participant in quotedMessage key when replying to incoming message' do
+ original_message = create(:message, inbox: inbox, conversation: conversation, sender: sender_contact,
+ message_type: 'incoming', source_id: 'incoming_group_msg', content: 'Hello')
+ reply_message = create(:message, inbox: inbox, conversation: conversation, sender: account_user,
+ content: 'World!',
+ content_attributes: { in_reply_to_external_id: original_message.source_id })
+ stub_request(:post, send_message_path)
+ .with(
+ headers: stub_headers(whatsapp_channel),
+ body: {
+ jid: group_jid,
+ messageContent: {
+ text: 'World!',
+ quotedMessage: {
+ key: { id: 'incoming_group_msg', remoteJid: group_jid, fromMe: false, participant: participant_lid },
+ message: { conversation: 'Hello' }
+ }
+ }
+ }.to_json
+ )
+ .to_return(status: 200, headers: { 'Content-Type' => 'application/json' }, body: result_body.to_json)
+
+ result = service.send_message(group_jid, reply_message)
+
+ expect(result).to eq('msg_123')
+ end
+
+ it 'does not include participant when replying to outgoing message' do
+ original_message = create(:message, inbox: inbox, conversation: conversation, sender: account_user,
+ message_type: 'outgoing', source_id: 'outgoing_group_msg', content: 'Hello')
+ reply_message = create(:message, inbox: inbox, conversation: conversation, sender: account_user,
+ content: 'World!',
+ content_attributes: { in_reply_to_external_id: original_message.source_id })
+ stub_request(:post, send_message_path)
+ .with(
+ headers: stub_headers(whatsapp_channel),
+ body: {
+ jid: group_jid,
+ messageContent: {
+ text: 'World!',
+ quotedMessage: {
+ key: { id: 'outgoing_group_msg', remoteJid: group_jid, fromMe: true },
+ message: { conversation: 'Hello' }
+ }
+ }
+ }.to_json
+ )
+ .to_return(status: 200, headers: { 'Content-Type' => 'application/json' }, body: result_body.to_json)
+
+ result = service.send_message(group_jid, reply_message)
+
+ expect(result).to eq('msg_123')
+ end
+
+ it 'includes participant in reaction key when reacting to incoming message' do
+ original_message = create(:message, inbox: inbox, conversation: conversation, sender: sender_contact,
+ message_type: 'incoming', source_id: 'react_group_msg', content: 'Nice')
+ reaction = create(:message, inbox: inbox, conversation: conversation, sender: account_user, content: '👍',
+ content_attributes: { is_reaction: true, in_reply_to: original_message.id })
+ stub_request(:post, send_message_path)
+ .with(
+ headers: stub_headers(whatsapp_channel),
+ body: {
+ jid: group_jid,
+ messageContent: {
+ react: {
+ key: { id: original_message.source_id, remoteJid: group_jid, fromMe: false, participant: participant_lid },
+ text: '👍'
+ }
+ }
+ }.to_json
+ )
+ .to_return(status: 200, headers: { 'Content-Type' => 'application/json' }, body: result_body.to_json)
+
+ result = service.send_message(group_jid, reaction)
+
+ expect(result).to eq('msg_123')
+ end
+
+ it 'does not include participant in reaction key when reacting to outgoing message' do
+ original_message = create(:message, inbox: inbox, conversation: conversation, sender: account_user,
+ message_type: 'outgoing', source_id: 'react_out_msg', content: 'Sent')
+ reaction = create(:message, inbox: inbox, conversation: conversation, sender: account_user, content: '❤️',
+ content_attributes: { is_reaction: true, in_reply_to: original_message.id })
+ stub_request(:post, send_message_path)
+ .with(
+ headers: stub_headers(whatsapp_channel),
+ body: {
+ jid: group_jid,
+ messageContent: {
+ react: {
+ key: { id: original_message.source_id, remoteJid: group_jid, fromMe: true },
+ text: '❤️'
+ }
+ }
+ }.to_json
+ )
+ .to_return(status: 200, headers: { 'Content-Type' => 'application/json' }, body: result_body.to_json)
+
+ result = service.send_message(group_jid, reaction)
+
+ expect(result).to eq('msg_123')
+ end
+ end
+
+ describe '#read_messages' do # rubocop:disable RSpec/MultipleMemoizedHelpers
+ it 'includes participant for incoming group messages' do
+ stub_request(:post, "#{whatsapp_channel.provider_config['provider_url']}/connections/#{whatsapp_channel.phone_number}/read-messages")
+ .with(
+ headers: stub_headers(whatsapp_channel),
+ body: { keys: [{ id: incoming_group_message.source_id, remoteJid: group_jid, fromMe: false, participant: participant_lid }] }.to_json
+ ).to_return(status: 200)
+
+ result = service.read_messages([incoming_group_message], recipient_id: group_jid)
+
+ expect(result).to be(true)
+ end
+
+ it 'does not include participant for outgoing group messages' do
+ stub_request(:post, "#{whatsapp_channel.provider_config['provider_url']}/connections/#{whatsapp_channel.phone_number}/read-messages")
+ .with(
+ headers: stub_headers(whatsapp_channel),
+ body: { keys: [{ id: outgoing_group_message.source_id, remoteJid: group_jid, fromMe: true }] }.to_json
+ ).to_return(status: 200)
+
+ result = service.read_messages([outgoing_group_message], recipient_id: group_jid)
+
+ expect(result).to be(true)
+ end
+ end
+
+ describe '#unread_message' do # rubocop:disable RSpec/MultipleMemoizedHelpers
+ it 'includes participant for incoming group message' do
+ stub_request(:post, "#{whatsapp_channel.provider_config['provider_url']}/connections/#{whatsapp_channel.phone_number}/chat-modify")
+ .with(
+ headers: stub_headers(whatsapp_channel),
+ body: {
+ jid: group_jid,
+ mod: {
+ markRead: false,
+ lastMessages: [{
+ key: { id: incoming_group_message.source_id, remoteJid: group_jid, fromMe: false, participant: participant_lid },
+ messageTimestamp: 123
+ }]
+ }
+ }.to_json
+ ).to_return(status: 200)
+
+ result = service.unread_message(group_jid, incoming_group_message)
+
+ expect(result).to be(true)
+ end
+ end
+
+ describe '#received_messages' do # rubocop:disable RSpec/MultipleMemoizedHelpers
+ it 'includes participant for incoming group messages' do
+ stub_request(:post, "#{whatsapp_channel.provider_config['provider_url']}/connections/#{whatsapp_channel.phone_number}/send-receipts")
+ .with(
+ headers: stub_headers(whatsapp_channel),
+ body: { keys: [{ id: incoming_group_message.source_id, remoteJid: group_jid, fromMe: false, participant: participant_lid }] }.to_json
+ ).to_return(status: 200)
+
+ result = service.received_messages(group_jid, [incoming_group_message])
+
+ expect(result).to be(true)
+ end
+ end
+
+ describe '#delete_message' do # rubocop:disable RSpec/MultipleMemoizedHelpers
+ it 'includes participant for incoming group message' do
+ stub_request(:delete, "#{whatsapp_channel.provider_config['provider_url']}/connections/#{whatsapp_channel.phone_number}/messages")
+ .with(
+ headers: stub_headers(whatsapp_channel),
+ body: {
+ jid: group_jid,
+ key: { id: incoming_group_message.source_id, remoteJid: group_jid, fromMe: false, participant: participant_lid }
+ }.to_json
+ ).to_return(status: 200, body: '{}')
+
+ result = service.delete_message(group_jid, incoming_group_message)
+
+ expect(result).to be(true)
+ end
+
+ it 'does not include participant for outgoing group message' do
+ stub_request(:delete, "#{whatsapp_channel.provider_config['provider_url']}/connections/#{whatsapp_channel.phone_number}/messages")
+ .with(
+ headers: stub_headers(whatsapp_channel),
+ body: {
+ jid: group_jid,
+ key: { id: outgoing_group_message.source_id, remoteJid: group_jid, fromMe: true }
+ }.to_json
+ ).to_return(status: 200, body: '{}')
+
+ result = service.delete_message(group_jid, outgoing_group_message)
+
+ expect(result).to be(true)
+ end
+ end
+
+ describe '#edit_message' do # rubocop:disable RSpec/MultipleMemoizedHelpers
+ it 'includes participant for incoming group message' do
+ stub_request(:patch, "#{whatsapp_channel.provider_config['provider_url']}/connections/#{whatsapp_channel.phone_number}/messages")
+ .with(
+ headers: stub_headers(whatsapp_channel),
+ body: {
+ jid: group_jid,
+ key: { id: incoming_group_message.source_id, remoteJid: group_jid, fromMe: false, participant: participant_lid },
+ messageContent: { text: 'Edited text' }
+ }.to_json
+ ).to_return(status: 200, body: '{}')
+
+ result = service.edit_message(group_jid, incoming_group_message, 'Edited text')
+
+ expect(result).to be(true)
+ end
+
+ it 'does not include participant for outgoing group message' do
+ stub_request(:patch, "#{whatsapp_channel.provider_config['provider_url']}/connections/#{whatsapp_channel.phone_number}/messages")
+ .with(
+ headers: stub_headers(whatsapp_channel),
+ body: {
+ jid: group_jid,
+ key: { id: outgoing_group_message.source_id, remoteJid: group_jid, fromMe: true },
+ messageContent: { text: 'Edited text' }
+ }.to_json
+ ).to_return(status: 200, body: '{}')
+
+ result = service.edit_message(group_jid, outgoing_group_message, 'Edited text')
+
+ expect(result).to be(true)
+ end
+ 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')
@@ -1043,7 +1330,8 @@ describe Whatsapp::Providers::WhatsappBaileysService do
clientName: 'chatwoot-test',
webhookUrl: whatsapp_channel.inbox.callback_webhook_url,
webhookVerifyToken: whatsapp_channel.provider_config['webhook_verify_token'],
- includeMedia: false
+ includeMedia: false,
+ groupsEnabled: Whatsapp::Providers::WhatsappBaileysService::GROUPS_ENABLED
}.to_json
)
.to_return(status: 200)
@@ -1061,7 +1349,8 @@ describe Whatsapp::Providers::WhatsappBaileysService do
clientName: 'chatwoot-test',
webhookUrl: whatsapp_channel.inbox.callback_webhook_url,
webhookVerifyToken: whatsapp_channel.provider_config['webhook_verify_token'],
- includeMedia: false
+ includeMedia: false,
+ groupsEnabled: Whatsapp::Providers::WhatsappBaileysService::GROUPS_ENABLED
}.to_json
)
.to_return(status: 200)
@@ -1079,7 +1368,8 @@ describe Whatsapp::Providers::WhatsappBaileysService do
clientName: 'chatwoot-test',
webhookUrl: whatsapp_channel.inbox.callback_webhook_url,
webhookVerifyToken: whatsapp_channel.provider_config['webhook_verify_token'],
- includeMedia: false
+ includeMedia: false,
+ groupsEnabled: Whatsapp::Providers::WhatsappBaileysService::GROUPS_ENABLED
}.to_json
)
.to_return(status: 400, body: 'reconnection failed')
@@ -1208,6 +1498,273 @@ describe Whatsapp::Providers::WhatsappBaileysService do
end
end
+ describe '#group_metadata' do
+ let(:group_jid) { '123456789123456789@g.us' }
+
+ it 'returns symbolized group metadata on success' do
+ stub_request(:get, "#{whatsapp_channel.provider_config['provider_url']}/connections/#{whatsapp_channel.phone_number}/group-metadata")
+ .with(headers: stub_headers(whatsapp_channel), query: { jid: group_jid })
+ .to_return(
+ status: 200,
+ body: {
+ subject: 'Test Group',
+ participants: [
+ { id: '111@lid', phoneNumber: '5511911111111@s.whatsapp.net', admin: 'admin' },
+ { id: '222@lid', phoneNumber: '5511922222222@s.whatsapp.net', admin: nil }
+ ]
+ }.to_json
+ )
+
+ result = service.group_metadata(group_jid)
+
+ expect(result[:subject]).to eq('Test Group')
+ expect(result[:participants].length).to eq(2)
+ expect(result[:participants].first[:admin]).to eq('admin')
+ end
+
+ it 'raises ProviderUnavailableError when the API returns an error' do
+ stub_request(:get, "#{whatsapp_channel.provider_config['provider_url']}/connections/#{whatsapp_channel.phone_number}/group-metadata")
+ .with(headers: stub_headers(whatsapp_channel), query: { jid: group_jid })
+ .to_return(status: 404, body: { error: 'Group not found' }.to_json)
+
+ stub_request(:post, "#{whatsapp_channel.provider_config['provider_url']}/connections/#{whatsapp_channel.phone_number}")
+ .to_return(status: 200)
+
+ expect do
+ service.group_metadata(group_jid)
+ end.to raise_error(Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError)
+ end
+ end
+
+ describe '#update_group_participants' do
+ let(:group_jid) { '123456789@g.us' }
+ let(:participant_jid) { '5511999999999@s.whatsapp.net' }
+ let(:request_path) { "#{whatsapp_channel.provider_config['provider_url']}/connections/#{whatsapp_channel.phone_number}/group-participants" }
+
+ it 'sends a POST request per participant with singular participant key' do
+ stub_request(:post, request_path)
+ .with(headers: stub_headers(whatsapp_channel), body: { jid: group_jid, participant: participant_jid, action: 'add' }.to_json)
+ .to_return(status: 200)
+
+ service.update_group_participants(group_jid, [participant_jid], 'add')
+
+ expect(WebMock).to have_requested(:post, request_path)
+ .with(body: { jid: group_jid, participant: participant_jid, action: 'add' }.to_json).once
+ end
+
+ it 'makes one call per participant when given multiple' do
+ jid_a = '111@s.whatsapp.net'
+ jid_b = '222@s.whatsapp.net'
+
+ stub_request(:post, request_path).to_return(status: 200)
+
+ service.update_group_participants(group_jid, [jid_a, jid_b], 'remove')
+
+ expect(WebMock).to have_requested(:post, request_path)
+ .with(body: { jid: group_jid, participant: jid_a, action: 'remove' }.to_json).once
+ expect(WebMock).to have_requested(:post, request_path)
+ .with(body: { jid: group_jid, participant: jid_b, action: 'remove' }.to_json).once
+ end
+
+ it 'raises ProviderUnavailableError on failure' do
+ stub_request(:post, request_path).to_return(status: 400, body: 'error')
+ stub_request(:post, "#{whatsapp_channel.provider_config['provider_url']}/connections/#{whatsapp_channel.phone_number}")
+ .to_return(status: 200)
+ allow(Rails.logger).to receive(:error)
+
+ expect do
+ service.update_group_participants(group_jid, [participant_jid], 'add')
+ end.to raise_error(Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError)
+ end
+ end
+
+ describe '#group_invite_code' do
+ let(:group_jid) { '123456789@g.us' }
+ let(:request_path) { "#{whatsapp_channel.provider_config['provider_url']}/connections/#{whatsapp_channel.phone_number}/group-invite-code" }
+
+ it 'returns the inviteCode from response data' do
+ stub_request(:get, request_path)
+ .with(headers: stub_headers(whatsapp_channel), query: { jid: group_jid })
+ .to_return(status: 200, headers: { 'Content-Type' => 'application/json' },
+ body: { data: { jid: group_jid, inviteCode: 'ABC123' } }.to_json)
+
+ result = service.group_invite_code(group_jid)
+
+ expect(result).to eq('ABC123')
+ end
+
+ it 'raises ProviderUnavailableError on failure' do
+ stub_request(:get, request_path)
+ .with(query: { jid: group_jid })
+ .to_return(status: 400, body: 'error')
+ stub_request(:post, "#{whatsapp_channel.provider_config['provider_url']}/connections/#{whatsapp_channel.phone_number}")
+ .to_return(status: 200)
+ allow(Rails.logger).to receive(:error)
+
+ expect do
+ service.group_invite_code(group_jid)
+ end.to raise_error(Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError)
+ end
+ end
+
+ describe '#revoke_group_invite' do
+ let(:group_jid) { '123456789@g.us' }
+ let(:request_path) { "#{whatsapp_channel.provider_config['provider_url']}/connections/#{whatsapp_channel.phone_number}/group-revoke-invite" }
+
+ it 'returns the inviteCode from response data' do
+ stub_request(:post, request_path)
+ .with(headers: stub_headers(whatsapp_channel), body: { jid: group_jid }.to_json)
+ .to_return(status: 200, headers: { 'Content-Type' => 'application/json' },
+ body: { data: { jid: group_jid, inviteCode: 'NEW456' } }.to_json)
+
+ result = service.revoke_group_invite(group_jid)
+
+ expect(result).to eq('NEW456')
+ end
+ end
+
+ describe '#group_join_requests' do
+ let(:group_jid) { '123456789@g.us' }
+ let(:request_path) do
+ "#{whatsapp_channel.provider_config['provider_url']}/connections/#{whatsapp_channel.phone_number}/group-request-participants-list"
+ end
+
+ it 'sends GET to group-request-participants-list and returns data' do
+ stub_request(:get, request_path)
+ .with(headers: stub_headers(whatsapp_channel), query: { jid: group_jid })
+ .to_return(status: 200, headers: { 'Content-Type' => 'application/json' },
+ body: { data: [{ jid: '999@s.whatsapp.net' }] }.to_json)
+
+ result = service.group_join_requests(group_jid)
+
+ expect(result).to eq([{ 'jid' => '999@s.whatsapp.net' }])
+ end
+
+ it 'returns empty array when data is nil' do
+ stub_request(:get, request_path)
+ .with(query: { jid: group_jid })
+ .to_return(status: 200, headers: { 'Content-Type' => 'application/json' }, body: {}.to_json)
+
+ result = service.group_join_requests(group_jid)
+
+ expect(result).to eq([])
+ end
+ end
+
+ describe '#handle_group_join_requests' do
+ let(:group_jid) { '123456789@g.us' }
+ let(:participants) { ['999@s.whatsapp.net'] }
+ let(:request_path) do
+ "#{whatsapp_channel.provider_config['provider_url']}/connections/#{whatsapp_channel.phone_number}/group-request-participants-update"
+ end
+
+ it 'sends POST to group-request-participants-update with participants array' do
+ stub_request(:post, request_path)
+ .with(headers: stub_headers(whatsapp_channel),
+ body: { jid: group_jid, participants: participants, action: 'approve' }.to_json)
+ .to_return(status: 200)
+
+ service.handle_group_join_requests(group_jid, participants, 'approve')
+
+ expect(WebMock).to have_requested(:post, request_path)
+ .with(body: { jid: group_jid, participants: participants, action: 'approve' }.to_json).once
+ end
+ end
+
+ describe '#sync_group' do
+ let(:group_contact) { create(:contact, account: whatsapp_channel.account, identifier: '123456789@g.us', name: 'Old Group Name') }
+ let(:conversation) { create(:conversation, inbox: whatsapp_channel.inbox, contact: group_contact) }
+ let(:base_url) { "#{whatsapp_channel.provider_config['provider_url']}/connections/#{whatsapp_channel.phone_number}" }
+ let(:metadata) do
+ {
+ subject: 'Updated Group Name',
+ desc: 'Group description',
+ owner: '111@lid',
+ ownerPn: '5511911111111',
+ participants: [
+ { id: '111@lid', phoneNumber: '5511911111111@s.whatsapp.net', admin: 'admin' },
+ { id: '222@lid', phoneNumber: '5511922222222@s.whatsapp.net', admin: nil }
+ ]
+ }
+ end
+
+ before do
+ stub_request(:get, "#{base_url}/group-invite-code")
+ .with(headers: stub_headers(whatsapp_channel), query: { jid: group_contact.identifier })
+ .to_return(status: 200, body: { code: 'ABC123' }.to_json)
+ stub_request(:get, "#{base_url}/profile-picture-url")
+ .with(headers: stub_headers(whatsapp_channel), query: { jid: group_contact.identifier })
+ .to_return(status: 200, body: { data: { profilePictureUrl: nil } }.to_json)
+ stub_request(:get, "#{base_url}/group-request-participants-list")
+ .with(headers: stub_headers(whatsapp_channel), query: { jid: group_contact.identifier })
+ .to_return(status: 200, body: [].to_json)
+ end
+
+ def stub_group_metadata(body)
+ stub_request(:get, "#{base_url}/group-metadata")
+ .with(headers: stub_headers(whatsapp_channel), query: { jid: group_contact.identifier })
+ .to_return(status: 200, body: body.to_json)
+ end
+
+ def stub_participant_services(*contacts)
+ allow(Whatsapp::ContactInboxConsolidationService).to receive(:new)
+ .and_return(instance_double(Whatsapp::ContactInboxConsolidationService, perform: nil))
+
+ contact_inboxes = contacts.map do |contact|
+ create(:contact_inbox, inbox: whatsapp_channel.inbox, contact: contact)
+ end
+
+ allow(ContactInboxWithContactBuilder).to receive(:new)
+ .and_return(*contact_inboxes.map { |ci| instance_double(ContactInboxWithContactBuilder, perform: ci) })
+
+ stub_request(:get, %r{/profile-picture-url}).to_return(status: 200, body: {}.to_json)
+ end
+
+ it 'raises ProviderUnavailableError when metadata is blank' do
+ stub_group_metadata({})
+ stub_request(:post, base_url).to_return(status: 200)
+
+ expect { service.sync_group(conversation) }.to raise_error(
+ Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError
+ )
+ end
+
+ it 'updates group contact name and attributes from metadata' do
+ stub_group_metadata(metadata.merge(participants: []))
+
+ service.sync_group(conversation)
+
+ group_contact.reload
+ expect(group_contact.name).to eq('Updated Group Name')
+ expect(group_contact.additional_attributes).to include('description' => 'Group description', 'owner' => '111@lid')
+ end
+
+ it 'creates group members with correct roles from participants' do
+ admin_contact = create(:contact, account: whatsapp_channel.account)
+ member_contact = create(:contact, account: whatsapp_channel.account)
+ stub_group_metadata(metadata)
+ stub_participant_services(admin_contact, member_contact)
+
+ service.sync_group(conversation)
+
+ expect(GroupMember.find_by(group_contact: group_contact, contact: admin_contact)).to have_attributes(role: 'admin', is_active: true)
+ expect(GroupMember.find_by(group_contact: group_contact, contact: member_contact)).to have_attributes(role: 'member', is_active: true)
+ end
+
+ it 'deactivates members not present in the participant list' do
+ absent_contact = create(:contact, account: whatsapp_channel.account)
+ GroupMember.create!(group_contact: group_contact, contact: absent_contact, is_active: true)
+ remaining_contact = create(:contact, account: whatsapp_channel.account)
+ stub_group_metadata(metadata.merge(participants: [metadata[:participants].last]))
+ stub_participant_services(remaining_contact)
+
+ service.sync_group(conversation)
+
+ expect(GroupMember.find_by(group_contact: group_contact, contact: absent_contact).is_active).to be false
+ expect(GroupMember.find_by(group_contact: group_contact, contact: remaining_contact).is_active).to be true
+ end
+ end
+
def stub_headers(channel)
{
'Content-Type' => 'application/json',
diff --git a/spec/services/whatsapp/send_on_whatsapp_service_spec.rb b/spec/services/whatsapp/send_on_whatsapp_service_spec.rb
index 3692f25cf..8a41a97a3 100644
--- a/spec/services/whatsapp/send_on_whatsapp_service_spec.rb
+++ b/spec/services/whatsapp/send_on_whatsapp_service_spec.rb
@@ -400,15 +400,38 @@ describe Whatsapp::SendOnWhatsappService do
.to_return(status: 200, body: '', headers: {})
end
- it 'calls channel.send_message if channel is not locked on outgoing message' do
+ it 'uses phone number as recipient_id for individual contacts' do
conversation.contact.update!(phone_number: '+123456789')
message = create(:message, message_type: :outgoing, content: 'test', conversation: conversation)
+
allow(whatsapp_channel).to receive(:send_message).with('123456789', message).and_return('123456789')
described_class.new(message: message).perform
expect(message.reload.source_id).to eq('123456789')
end
+
+ it 'falls back to identifier when contact has no phone_number' do
+ conversation.contact.update!(phone_number: nil, identifier: '99999999@lid')
+ message = create(:message, message_type: :outgoing, content: 'test', conversation: conversation)
+
+ allow(whatsapp_channel).to receive(:send_message).with('99999999@lid', message).and_return('msg_lid')
+
+ described_class.new(message: message).perform
+
+ expect(message.reload.source_id).to eq('msg_lid')
+ end
+
+ it 'uses identifier as recipient_id for group contacts' do
+ conversation.contact.update!(identifier: '123456789123456789@g.us', group_type: :group)
+ message = create(:message, message_type: :outgoing, content: 'test', conversation: conversation)
+
+ allow(whatsapp_channel).to receive(:send_message).with('123456789123456789@g.us', message).and_return('msg_group')
+
+ described_class.new(message: message).perform
+
+ expect(message.reload.source_id).to eq('msg_group')
+ end
end
context 'when provider is zapi' do
@@ -442,6 +465,16 @@ describe Whatsapp::SendOnWhatsappService do
described_class.new(message: message).perform
end
+
+ it 'uses identifier as recipient_id for group contacts' do
+ conversation.contact.update!(identifier: '120363123456789@g.us', group_type: :group)
+ create(:message, message_type: :incoming, content: 'test', conversation: conversation)
+ message = create(:message, message_type: :outgoing, content: 'test', conversation: conversation)
+
+ expect(whatsapp_channel).to receive(:send_message).with('120363123456789@g.us', message).and_return('msg_group')
+
+ described_class.new(message: message).perform
+ end
end
end
end
diff --git a/swagger/definitions/index.yml b/swagger/definitions/index.yml
index 77d6f14e4..74c0e0af1 100644
--- a/swagger/definitions/index.yml
+++ b/swagger/definitions/index.yml
@@ -36,6 +36,8 @@ article:
$ref: ./resource/article.yml
contact:
$ref: ./resource/contact.yml
+group_member:
+ $ref: ./resource/group_member.yml
conversation:
$ref: ./resource/conversation.yml
message:
diff --git a/swagger/definitions/resource/group_member.yml b/swagger/definitions/resource/group_member.yml
new file mode 100644
index 000000000..5b1462d35
--- /dev/null
+++ b/swagger/definitions/resource/group_member.yml
@@ -0,0 +1,33 @@
+type: object
+properties:
+ id:
+ type: number
+ description: ID of the group member record
+ role:
+ type: string
+ enum: ['member', 'admin']
+ description: Role of the member in the group
+ is_active:
+ type: boolean
+ description: Whether the member is currently active in the group
+ group_contact_id:
+ type: number
+ description: ID of the group contact this membership belongs to
+ contact:
+ type: object
+ properties:
+ id:
+ type: number
+ description: ID of the member contact
+ name:
+ type: string
+ description: Name of the member contact
+ phone_number:
+ type: string
+ description: Phone number of the member contact
+ identifier:
+ type: string
+ description: Identifier of the member contact
+ thumbnail:
+ type: string
+ description: Thumbnail URL of the member contact
diff --git a/swagger/paths/application/contacts/group_admin.yml b/swagger/paths/application/contacts/group_admin.yml
new file mode 100644
index 000000000..8ddd0a27d
--- /dev/null
+++ b/swagger/paths/application/contacts/group_admin.yml
@@ -0,0 +1,45 @@
+parameters:
+ - $ref: '#/components/parameters/account_id'
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: number
+ description: ID of the group contact
+
+patch:
+ tags:
+ - Groups
+ operationId: contact-group-admin-update
+ summary: Update Group Property
+ description: Updates a WhatsApp group property. "announce" controls whether only admins can send messages. "restrict" controls whether only admins can edit group info. "join_approval_mode" controls whether new members must be approved by an admin. "member_add_mode" controls whether all members or only admins can add new members.
+ security:
+ - userApiKey: []
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required:
+ - property
+ - enabled
+ properties:
+ property:
+ type: string
+ enum: ['announce', 'restrict', 'join_approval_mode', 'member_add_mode']
+ description: The group property to update
+ enabled:
+ type: boolean
+ description: Whether to enable or disable the property
+ responses:
+ '200':
+ description: Property updated successfully
+ '401':
+ description: Unauthorized
+ '422':
+ description: Provider unavailable
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/bad_request_error'
diff --git a/swagger/paths/application/contacts/group_admin_leave.yml b/swagger/paths/application/contacts/group_admin_leave.yml
new file mode 100644
index 000000000..973e73e60
--- /dev/null
+++ b/swagger/paths/application/contacts/group_admin_leave.yml
@@ -0,0 +1,28 @@
+parameters:
+ - $ref: '#/components/parameters/account_id'
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: number
+ description: ID of the group contact
+
+post:
+ tags:
+ - Groups
+ operationId: contact-group-admin-leave
+ summary: Leave Group
+ description: Leaves the WhatsApp group and resolves all open/pending conversations associated with this group contact.
+ security:
+ - userApiKey: []
+ responses:
+ '200':
+ description: Left group successfully
+ '401':
+ description: Unauthorized
+ '422':
+ description: Provider unavailable
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/bad_request_error'
diff --git a/swagger/paths/application/contacts/group_invite.yml b/swagger/paths/application/contacts/group_invite.yml
new file mode 100644
index 000000000..6f189533d
--- /dev/null
+++ b/swagger/paths/application/contacts/group_invite.yml
@@ -0,0 +1,39 @@
+parameters:
+ - $ref: '#/components/parameters/account_id'
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: number
+ description: ID of the group contact
+
+get:
+ tags:
+ - Groups
+ operationId: contact-group-invite-show
+ summary: Get Group Invite Link
+ description: Retrieves the current invite code and full invite URL for the WhatsApp group.
+ security:
+ - userApiKey: []
+ responses:
+ '200':
+ description: Success
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ invite_code:
+ type: string
+ description: The group invite code
+ invite_url:
+ type: string
+ description: Full WhatsApp invite URL (https://chat.whatsapp.com/{code})
+ '401':
+ description: Unauthorized
+ '422':
+ description: Provider unavailable
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/bad_request_error'
diff --git a/swagger/paths/application/contacts/group_invite_revoke.yml b/swagger/paths/application/contacts/group_invite_revoke.yml
new file mode 100644
index 000000000..a176ac462
--- /dev/null
+++ b/swagger/paths/application/contacts/group_invite_revoke.yml
@@ -0,0 +1,39 @@
+parameters:
+ - $ref: '#/components/parameters/account_id'
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: number
+ description: ID of the group contact
+
+post:
+ tags:
+ - Groups
+ operationId: contact-group-invite-revoke
+ summary: Revoke Group Invite Link
+ description: Revokes the current group invite link and returns the newly generated invite code and URL.
+ security:
+ - userApiKey: []
+ responses:
+ '200':
+ description: Success
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ invite_code:
+ type: string
+ description: The new group invite code
+ invite_url:
+ type: string
+ description: New full WhatsApp invite URL (https://chat.whatsapp.com/{code})
+ '401':
+ description: Unauthorized
+ '422':
+ description: Provider unavailable
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/bad_request_error'
diff --git a/swagger/paths/application/contacts/group_join_requests.yml b/swagger/paths/application/contacts/group_join_requests.yml
new file mode 100644
index 000000000..0d0f6819c
--- /dev/null
+++ b/swagger/paths/application/contacts/group_join_requests.yml
@@ -0,0 +1,38 @@
+parameters:
+ - $ref: '#/components/parameters/account_id'
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: number
+ description: ID of the group contact
+
+get:
+ tags:
+ - Groups
+ operationId: contact-group-join-requests-list
+ summary: List Group Join Requests
+ description: Retrieves the list of pending join requests for the WhatsApp group.
+ security:
+ - userApiKey: []
+ responses:
+ '200':
+ description: Success
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ payload:
+ type: array
+ items:
+ type: object
+ description: A pending join request from the provider
+ '401':
+ description: Unauthorized
+ '422':
+ description: Provider unavailable
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/bad_request_error'
diff --git a/swagger/paths/application/contacts/group_join_requests_handle.yml b/swagger/paths/application/contacts/group_join_requests_handle.yml
new file mode 100644
index 000000000..eedcb6a1f
--- /dev/null
+++ b/swagger/paths/application/contacts/group_join_requests_handle.yml
@@ -0,0 +1,47 @@
+parameters:
+ - $ref: '#/components/parameters/account_id'
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: number
+ description: ID of the group contact
+
+post:
+ tags:
+ - Groups
+ operationId: contact-group-join-requests-handle
+ summary: Handle Group Join Requests
+ description: Approves or rejects pending join requests for the WhatsApp group.
+ security:
+ - userApiKey: []
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required:
+ - participants
+ - request_action
+ properties:
+ participants:
+ type: array
+ description: List of participant JIDs or phone numbers to approve/reject
+ items:
+ type: string
+ request_action:
+ type: string
+ enum: ['approve', 'reject']
+ description: Action to take on the join requests
+ responses:
+ '200':
+ description: Join requests handled successfully
+ '401':
+ description: Unauthorized
+ '422':
+ description: Provider unavailable
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/bad_request_error'
diff --git a/swagger/paths/application/contacts/group_members.yml b/swagger/paths/application/contacts/group_members.yml
new file mode 100644
index 000000000..fffffd82f
--- /dev/null
+++ b/swagger/paths/application/contacts/group_members.yml
@@ -0,0 +1,93 @@
+parameters:
+ - $ref: '#/components/parameters/account_id'
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: number
+ description: ID of the contact
+
+get:
+ tags:
+ - Groups
+ operationId: contact-group-members-list
+ summary: List Group Members
+ description: Lists all active group members for a group contact, with pagination. The inbox's own member is pinned to the top of the first page.
+ security:
+ - userApiKey: []
+ parameters:
+ - name: page
+ in: query
+ schema:
+ type: integer
+ default: 1
+ description: Page number
+ - name: per_page
+ in: query
+ schema:
+ type: integer
+ default: 10
+ description: Number of members per page
+ responses:
+ '200':
+ description: Success
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ payload:
+ type: array
+ items:
+ $ref: '#/components/schemas/group_member'
+ meta:
+ type: object
+ properties:
+ total_count:
+ type: integer
+ description: Total number of active members
+ page:
+ type: integer
+ per_page:
+ type: integer
+ inbox_phone_number:
+ type: string
+ description: Phone number of the inbox (channel)
+ '401':
+ description: Unauthorized
+ '404':
+ description: Contact not found
+
+post:
+ tags:
+ - Groups
+ operationId: contact-group-members-add
+ summary: Add Group Members
+ description: Adds new participants to the WhatsApp group. Expects an array of phone numbers (E.164 format without the + prefix).
+ security:
+ - userApiKey: []
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required:
+ - participants
+ properties:
+ participants:
+ type: array
+ description: Phone numbers to add (e.g. ["5511999999999"])
+ items:
+ type: string
+ responses:
+ '200':
+ description: Members added successfully
+ '401':
+ description: Unauthorized
+ '422':
+ description: Provider unavailable
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/bad_request_error'
diff --git a/swagger/paths/application/contacts/group_members_member.yml b/swagger/paths/application/contacts/group_members_member.yml
new file mode 100644
index 000000000..d0c1e4b51
--- /dev/null
+++ b/swagger/paths/application/contacts/group_members_member.yml
@@ -0,0 +1,67 @@
+parameters:
+ - $ref: '#/components/parameters/account_id'
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: number
+ description: ID of the group contact
+ - name: member_id
+ in: path
+ required: true
+ schema:
+ type: number
+ description: ID of the group member record
+
+patch:
+ tags:
+ - Groups
+ operationId: contact-group-member-update-role
+ summary: Update Member Role
+ description: Promotes or demotes a group member. Set role to "admin" to promote, or "member" to demote. The group creator cannot be modified.
+ security:
+ - userApiKey: []
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required:
+ - role
+ properties:
+ role:
+ type: string
+ enum: ['admin', 'member']
+ description: New role for the member
+ responses:
+ '200':
+ description: Role updated successfully
+ '401':
+ description: Unauthorized
+ '422':
+ description: Provider unavailable or group creator not modifiable
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/bad_request_error'
+
+delete:
+ tags:
+ - Groups
+ operationId: contact-group-member-remove
+ summary: Remove Group Member
+ description: Removes a participant from the WhatsApp group. The group creator cannot be removed.
+ security:
+ - userApiKey: []
+ responses:
+ '200':
+ description: Member removed successfully
+ '401':
+ description: Unauthorized
+ '422':
+ description: Provider unavailable or group creator not modifiable
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/bad_request_error'
diff --git a/swagger/paths/application/contacts/group_metadata.yml b/swagger/paths/application/contacts/group_metadata.yml
new file mode 100644
index 000000000..68bb689b1
--- /dev/null
+++ b/swagger/paths/application/contacts/group_metadata.yml
@@ -0,0 +1,55 @@
+parameters:
+ - $ref: '#/components/parameters/account_id'
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: number
+ description: ID of the group contact
+
+patch:
+ tags:
+ - Groups
+ operationId: contact-group-metadata-update
+ summary: Update Group Metadata
+ description: Updates the group subject and/or description via the WhatsApp provider. At least one of subject or description must be provided.
+ security:
+ - userApiKey: []
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ subject:
+ type: string
+ description: New group subject (name)
+ description:
+ type: string
+ description: New group description
+ responses:
+ '200':
+ description: Success
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ id:
+ type: number
+ description: ID of the contact
+ name:
+ type: string
+ description: Updated name of the contact
+ additional_attributes:
+ type: object
+ description: Additional attributes of the contact
+ '401':
+ description: Unauthorized
+ '422':
+ description: Provider unavailable
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/bad_request_error'
diff --git a/swagger/paths/application/contacts/sync_group.yml b/swagger/paths/application/contacts/sync_group.yml
new file mode 100644
index 000000000..17fbaac17
--- /dev/null
+++ b/swagger/paths/application/contacts/sync_group.yml
@@ -0,0 +1,108 @@
+parameters:
+ - $ref: '#/components/parameters/account_id'
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: number
+ description: ID of the contact
+
+post:
+ tags:
+ - Contacts
+ operationId: contact-sync-group
+ summary: Sync Group
+ description: Syncs group information for a group contact. Triggers a sync with the channel provider (e.g., WhatsApp) to update group metadata and members across all open/pending conversations. The contact must be a group contact with a valid identifier.
+ security:
+ - userApiKey: []
+ responses:
+ '200':
+ description: Success
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ payload:
+ type: object
+ properties:
+ id:
+ type: number
+ description: ID of the contact
+ name:
+ type: string
+ description: Name of the contact
+ email:
+ type: string
+ description: Email of the contact
+ phone_number:
+ type: string
+ description: Phone number of the contact
+ identifier:
+ type: string
+ description: Identifier of the contact
+ thumbnail:
+ type: string
+ description: Thumbnail URL of the contact
+ custom_attributes:
+ type: object
+ description: Custom attributes of the contact
+ contact_inboxes:
+ type: array
+ items:
+ $ref: '#/components/schemas/contact_inboxes'
+ group_members:
+ type: array
+ description: List of group members from the most recent open/pending group conversation
+ items:
+ type: object
+ properties:
+ id:
+ type: number
+ description: ID of the group member record
+ role:
+ type: string
+ enum: ['member', 'admin']
+ description: Role of the member in the group
+ is_active:
+ type: boolean
+ description: Whether the member is currently active in the group
+ contact:
+ type: object
+ properties:
+ id:
+ type: number
+ description: ID of the member contact
+ name:
+ type: string
+ description: Name of the member contact
+ phone_number:
+ type: string
+ description: Phone number of the member contact
+ identifier:
+ type: string
+ description: Identifier of the member contact
+ '400':
+ description: Bad request — contact is not a group, has no identifier, or no supported inbox found
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/bad_request_error'
+ '401':
+ description: Unauthorized
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/bad_request_error'
+ '404':
+ description: Contact not found
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/bad_request_error'
+ '500':
+ description: Internal server error — channel provider unavailable
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/bad_request_error'
diff --git a/swagger/paths/application/groups/create.yml b/swagger/paths/application/groups/create.yml
new file mode 100644
index 000000000..b6b8eecba
--- /dev/null
+++ b/swagger/paths/application/groups/create.yml
@@ -0,0 +1,55 @@
+tags:
+ - Groups
+operationId: create-group
+summary: Create Group
+description: Creates a new WhatsApp group via the channel provider. The authenticated user must have access to the specified inbox.
+security:
+ - userApiKey: []
+requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required:
+ - inbox_id
+ - subject
+ - participants
+ properties:
+ inbox_id:
+ type: number
+ description: ID of the inbox (WhatsApp channel) to create the group in
+ subject:
+ type: string
+ description: Subject (name) for the new group
+ participants:
+ type: array
+ description: Phone numbers of initial group participants (E.164 format without +)
+ items:
+ type: string
+responses:
+ '200':
+ description: Group created successfully
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ group_jid:
+ type: string
+ description: The JID of the newly created group
+ subject:
+ type: string
+ description: Subject of the created group
+ '403':
+ description: Forbidden — user does not have access to the inbox
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/bad_request_error'
+ '422':
+ description: Provider unavailable
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/bad_request_error'
diff --git a/swagger/paths/index.yml b/swagger/paths/index.yml
index 5ef6921f6..bff73ce53 100644
--- a/swagger/paths/index.yml
+++ b/swagger/paths/index.yml
@@ -274,6 +274,33 @@
$ref: ./application/contact_inboxes/create.yml
/api/v1/accounts/{account_id}/contacts/{id}/contactable_inboxes:
$ref: ./application/contactable_inboxes/get.yml
+/api/v1/accounts/{account_id}/contacts/{id}/sync_group:
+ $ref: ./application/contacts/sync_group.yml
+/api/v1/accounts/{account_id}/contacts/{id}/group_members:
+ $ref: ./application/contacts/group_members.yml
+/api/v1/accounts/{account_id}/contacts/{id}/group_members/{member_id}:
+ $ref: ./application/contacts/group_members_member.yml
+/api/v1/accounts/{account_id}/contacts/{id}/group_metadata:
+ $ref: ./application/contacts/group_metadata.yml
+/api/v1/accounts/{account_id}/contacts/{id}/group_invite:
+ $ref: ./application/contacts/group_invite.yml
+/api/v1/accounts/{account_id}/contacts/{id}/group_invite/revoke:
+ $ref: ./application/contacts/group_invite_revoke.yml
+/api/v1/accounts/{account_id}/contacts/{id}/group_join_requests:
+ $ref: ./application/contacts/group_join_requests.yml
+/api/v1/accounts/{account_id}/contacts/{id}/group_join_requests/handle:
+ $ref: ./application/contacts/group_join_requests_handle.yml
+/api/v1/accounts/{account_id}/contacts/{id}/group_admin:
+ $ref: ./application/contacts/group_admin.yml
+/api/v1/accounts/{account_id}/contacts/{id}/group_admin/leave:
+ $ref: ./application/contacts/group_admin_leave.yml
+
+# Groups
+/api/v1/accounts/{account_id}/groups:
+ parameters:
+ - $ref: '#/components/parameters/account_id'
+ post:
+ $ref: ./application/groups/create.yml
# Contact Merge
/api/v1/accounts/{account_id}/actions/contact_merge:
diff --git a/swagger/swagger.json b/swagger/swagger.json
index 0547b96be..4d947a644 100644
--- a/swagger/swagger.json
+++ b/swagger/swagger.json
@@ -3428,6 +3428,1006 @@
}
}
},
+ "/api/v1/accounts/{account_id}/contacts/{id}/sync_group": {
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/account_id"
+ },
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "number"
+ },
+ "description": "ID of the contact"
+ }
+ ],
+ "post": {
+ "tags": [
+ "Contacts"
+ ],
+ "operationId": "contact-sync-group",
+ "summary": "Sync Group",
+ "description": "Syncs group information for a group contact. Triggers a sync with the channel provider (e.g., WhatsApp) to update group metadata and members across all open/pending conversations. The contact must be a group contact with a valid identifier.",
+ "security": [
+ {
+ "userApiKey": []
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Success",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "payload": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "number",
+ "description": "ID of the contact"
+ },
+ "name": {
+ "type": "string",
+ "description": "Name of the contact"
+ },
+ "email": {
+ "type": "string",
+ "description": "Email of the contact"
+ },
+ "phone_number": {
+ "type": "string",
+ "description": "Phone number of the contact"
+ },
+ "identifier": {
+ "type": "string",
+ "description": "Identifier of the contact"
+ },
+ "thumbnail": {
+ "type": "string",
+ "description": "Thumbnail URL of the contact"
+ },
+ "custom_attributes": {
+ "type": "object",
+ "description": "Custom attributes of the contact"
+ },
+ "contact_inboxes": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/contact_inboxes"
+ }
+ },
+ "group_members": {
+ "type": "array",
+ "description": "List of group members from the most recent open/pending group conversation",
+ "items": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "number",
+ "description": "ID of the group member record"
+ },
+ "role": {
+ "type": "string",
+ "enum": [
+ "member",
+ "admin"
+ ],
+ "description": "Role of the member in the group"
+ },
+ "is_active": {
+ "type": "boolean",
+ "description": "Whether the member is currently active in the group"
+ },
+ "contact": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "number",
+ "description": "ID of the member contact"
+ },
+ "name": {
+ "type": "string",
+ "description": "Name of the member contact"
+ },
+ "phone_number": {
+ "type": "string",
+ "description": "Phone number of the member contact"
+ },
+ "identifier": {
+ "type": "string",
+ "description": "Identifier of the member contact"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad request — contact is not a group, has no identifier, or no supported inbox found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/bad_request_error"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/bad_request_error"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Contact not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/bad_request_error"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal server error — channel provider unavailable",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/bad_request_error"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/accounts/{account_id}/contacts/{id}/group_members": {
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/account_id"
+ },
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "number"
+ },
+ "description": "ID of the contact"
+ }
+ ],
+ "get": {
+ "tags": [
+ "Groups"
+ ],
+ "operationId": "contact-group-members-list",
+ "summary": "List Group Members",
+ "description": "Lists all active group members for a group contact, with pagination. The inbox's own member is pinned to the top of the first page.",
+ "security": [
+ {
+ "userApiKey": []
+ }
+ ],
+ "parameters": [
+ {
+ "name": "page",
+ "in": "query",
+ "schema": {
+ "type": "integer",
+ "default": 1
+ },
+ "description": "Page number"
+ },
+ {
+ "name": "per_page",
+ "in": "query",
+ "schema": {
+ "type": "integer",
+ "default": 10
+ },
+ "description": "Number of members per page"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Success",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "payload": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/group_member"
+ }
+ },
+ "meta": {
+ "type": "object",
+ "properties": {
+ "total_count": {
+ "type": "integer",
+ "description": "Total number of active members"
+ },
+ "page": {
+ "type": "integer"
+ },
+ "per_page": {
+ "type": "integer"
+ },
+ "inbox_phone_number": {
+ "type": "string",
+ "description": "Phone number of the inbox (channel)"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized"
+ },
+ "404": {
+ "description": "Contact not found"
+ }
+ }
+ },
+ "post": {
+ "tags": [
+ "Groups"
+ ],
+ "operationId": "contact-group-members-add",
+ "summary": "Add Group Members",
+ "description": "Adds new participants to the WhatsApp group. Expects an array of phone numbers (E.164 format without the + prefix).",
+ "security": [
+ {
+ "userApiKey": []
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "participants"
+ ],
+ "properties": {
+ "participants": {
+ "type": "array",
+ "description": "Phone numbers to add (e.g. [\"5511999999999\"])",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Members added successfully"
+ },
+ "401": {
+ "description": "Unauthorized"
+ },
+ "422": {
+ "description": "Provider unavailable",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/bad_request_error"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/accounts/{account_id}/contacts/{id}/group_members/{member_id}": {
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/account_id"
+ },
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "number"
+ },
+ "description": "ID of the group contact"
+ },
+ {
+ "name": "member_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "number"
+ },
+ "description": "ID of the group member record"
+ }
+ ],
+ "patch": {
+ "tags": [
+ "Groups"
+ ],
+ "operationId": "contact-group-member-update-role",
+ "summary": "Update Member Role",
+ "description": "Promotes or demotes a group member. Set role to \"admin\" to promote, or \"member\" to demote. The group creator cannot be modified.",
+ "security": [
+ {
+ "userApiKey": []
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "role"
+ ],
+ "properties": {
+ "role": {
+ "type": "string",
+ "enum": [
+ "admin",
+ "member"
+ ],
+ "description": "New role for the member"
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Role updated successfully"
+ },
+ "401": {
+ "description": "Unauthorized"
+ },
+ "422": {
+ "description": "Provider unavailable or group creator not modifiable",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/bad_request_error"
+ }
+ }
+ }
+ }
+ }
+ },
+ "delete": {
+ "tags": [
+ "Groups"
+ ],
+ "operationId": "contact-group-member-remove",
+ "summary": "Remove Group Member",
+ "description": "Removes a participant from the WhatsApp group. The group creator cannot be removed.",
+ "security": [
+ {
+ "userApiKey": []
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Member removed successfully"
+ },
+ "401": {
+ "description": "Unauthorized"
+ },
+ "422": {
+ "description": "Provider unavailable or group creator not modifiable",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/bad_request_error"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/accounts/{account_id}/contacts/{id}/group_metadata": {
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/account_id"
+ },
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "number"
+ },
+ "description": "ID of the group contact"
+ }
+ ],
+ "patch": {
+ "tags": [
+ "Groups"
+ ],
+ "operationId": "contact-group-metadata-update",
+ "summary": "Update Group Metadata",
+ "description": "Updates the group subject and/or description via the WhatsApp provider. At least one of subject or description must be provided.",
+ "security": [
+ {
+ "userApiKey": []
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "subject": {
+ "type": "string",
+ "description": "New group subject (name)"
+ },
+ "description": {
+ "type": "string",
+ "description": "New group description"
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Success",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "number",
+ "description": "ID of the contact"
+ },
+ "name": {
+ "type": "string",
+ "description": "Updated name of the contact"
+ },
+ "additional_attributes": {
+ "type": "object",
+ "description": "Additional attributes of the contact"
+ }
+ }
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized"
+ },
+ "422": {
+ "description": "Provider unavailable",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/bad_request_error"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/accounts/{account_id}/contacts/{id}/group_invite": {
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/account_id"
+ },
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "number"
+ },
+ "description": "ID of the group contact"
+ }
+ ],
+ "get": {
+ "tags": [
+ "Groups"
+ ],
+ "operationId": "contact-group-invite-show",
+ "summary": "Get Group Invite Link",
+ "description": "Retrieves the current invite code and full invite URL for the WhatsApp group.",
+ "security": [
+ {
+ "userApiKey": []
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Success",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "invite_code": {
+ "type": "string",
+ "description": "The group invite code"
+ },
+ "invite_url": {
+ "type": "string",
+ "description": "Full WhatsApp invite URL (https://chat.whatsapp.com/{code})"
+ }
+ }
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized"
+ },
+ "422": {
+ "description": "Provider unavailable",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/bad_request_error"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/accounts/{account_id}/contacts/{id}/group_invite/revoke": {
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/account_id"
+ },
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "number"
+ },
+ "description": "ID of the group contact"
+ }
+ ],
+ "post": {
+ "tags": [
+ "Groups"
+ ],
+ "operationId": "contact-group-invite-revoke",
+ "summary": "Revoke Group Invite Link",
+ "description": "Revokes the current group invite link and returns the newly generated invite code and URL.",
+ "security": [
+ {
+ "userApiKey": []
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Success",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "invite_code": {
+ "type": "string",
+ "description": "The new group invite code"
+ },
+ "invite_url": {
+ "type": "string",
+ "description": "New full WhatsApp invite URL (https://chat.whatsapp.com/{code})"
+ }
+ }
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized"
+ },
+ "422": {
+ "description": "Provider unavailable",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/bad_request_error"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/accounts/{account_id}/contacts/{id}/group_join_requests": {
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/account_id"
+ },
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "number"
+ },
+ "description": "ID of the group contact"
+ }
+ ],
+ "get": {
+ "tags": [
+ "Groups"
+ ],
+ "operationId": "contact-group-join-requests-list",
+ "summary": "List Group Join Requests",
+ "description": "Retrieves the list of pending join requests for the WhatsApp group.",
+ "security": [
+ {
+ "userApiKey": []
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Success",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "payload": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "description": "A pending join request from the provider"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized"
+ },
+ "422": {
+ "description": "Provider unavailable",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/bad_request_error"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/accounts/{account_id}/contacts/{id}/group_join_requests/handle": {
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/account_id"
+ },
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "number"
+ },
+ "description": "ID of the group contact"
+ }
+ ],
+ "post": {
+ "tags": [
+ "Groups"
+ ],
+ "operationId": "contact-group-join-requests-handle",
+ "summary": "Handle Group Join Requests",
+ "description": "Approves or rejects pending join requests for the WhatsApp group.",
+ "security": [
+ {
+ "userApiKey": []
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "participants",
+ "request_action"
+ ],
+ "properties": {
+ "participants": {
+ "type": "array",
+ "description": "List of participant JIDs or phone numbers to approve/reject",
+ "items": {
+ "type": "string"
+ }
+ },
+ "request_action": {
+ "type": "string",
+ "enum": [
+ "approve",
+ "reject"
+ ],
+ "description": "Action to take on the join requests"
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Join requests handled successfully"
+ },
+ "401": {
+ "description": "Unauthorized"
+ },
+ "422": {
+ "description": "Provider unavailable",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/bad_request_error"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/accounts/{account_id}/contacts/{id}/group_admin": {
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/account_id"
+ },
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "number"
+ },
+ "description": "ID of the group contact"
+ }
+ ],
+ "patch": {
+ "tags": [
+ "Groups"
+ ],
+ "operationId": "contact-group-admin-update",
+ "summary": "Update Group Property",
+ "description": "Updates a WhatsApp group property. \"announce\" controls whether only admins can send messages. \"restrict\" controls whether only admins can edit group info. \"join_approval_mode\" controls whether new members must be approved by an admin. \"member_add_mode\" controls whether all members or only admins can add new members.",
+ "security": [
+ {
+ "userApiKey": []
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "property",
+ "enabled"
+ ],
+ "properties": {
+ "property": {
+ "type": "string",
+ "enum": [
+ "announce",
+ "restrict",
+ "join_approval_mode",
+ "member_add_mode"
+ ],
+ "description": "The group property to update"
+ },
+ "enabled": {
+ "type": "boolean",
+ "description": "Whether to enable or disable the property"
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Property updated successfully"
+ },
+ "401": {
+ "description": "Unauthorized"
+ },
+ "422": {
+ "description": "Provider unavailable",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/bad_request_error"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/accounts/{account_id}/contacts/{id}/group_admin/leave": {
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/account_id"
+ },
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "number"
+ },
+ "description": "ID of the group contact"
+ }
+ ],
+ "post": {
+ "tags": [
+ "Groups"
+ ],
+ "operationId": "contact-group-admin-leave",
+ "summary": "Leave Group",
+ "description": "Leaves the WhatsApp group and resolves all open/pending conversations associated with this group contact.",
+ "security": [
+ {
+ "userApiKey": []
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Left group successfully"
+ },
+ "401": {
+ "description": "Unauthorized"
+ },
+ "422": {
+ "description": "Provider unavailable",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/bad_request_error"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/accounts/{account_id}/groups": {
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/account_id"
+ }
+ ],
+ "post": {
+ "tags": [
+ "Groups"
+ ],
+ "operationId": "create-group",
+ "summary": "Create Group",
+ "description": "Creates a new WhatsApp group via the channel provider. The authenticated user must have access to the specified inbox.",
+ "security": [
+ {
+ "userApiKey": []
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "inbox_id",
+ "subject",
+ "participants"
+ ],
+ "properties": {
+ "inbox_id": {
+ "type": "number",
+ "description": "ID of the inbox (WhatsApp channel) to create the group in"
+ },
+ "subject": {
+ "type": "string",
+ "description": "Subject (name) for the new group"
+ },
+ "participants": {
+ "type": "array",
+ "description": "Phone numbers of initial group participants (E.164 format without +)",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Group created successfully",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "group_jid": {
+ "type": "string",
+ "description": "The JID of the newly created group"
+ },
+ "subject": {
+ "type": "string",
+ "description": "Subject of the created group"
+ }
+ }
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden — user does not have access to the inbox",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/bad_request_error"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Provider unavailable",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/bad_request_error"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
"/api/v1/accounts/{account_id}/actions/contact_merge": {
"parameters": [
{
@@ -9268,6 +10268,56 @@
}
}
},
+ "group_member": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "number",
+ "description": "ID of the group member record"
+ },
+ "role": {
+ "type": "string",
+ "enum": [
+ "member",
+ "admin"
+ ],
+ "description": "Role of the member in the group"
+ },
+ "is_active": {
+ "type": "boolean",
+ "description": "Whether the member is currently active in the group"
+ },
+ "group_contact_id": {
+ "type": "number",
+ "description": "ID of the group contact this membership belongs to"
+ },
+ "contact": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "number",
+ "description": "ID of the member contact"
+ },
+ "name": {
+ "type": "string",
+ "description": "Name of the member contact"
+ },
+ "phone_number": {
+ "type": "string",
+ "description": "Phone number of the member contact"
+ },
+ "identifier": {
+ "type": "string",
+ "description": "Identifier of the member contact"
+ },
+ "thumbnail": {
+ "type": "string",
+ "description": "Thumbnail URL of the member contact"
+ }
+ }
+ }
+ }
+ },
"conversation": {
"type": "object",
"properties": {
diff --git a/swagger/tag_groups/application.yml b/swagger/tag_groups/application.yml
index 85d96c5b7..14c7ee51e 100644
--- a/swagger/tag_groups/application.yml
+++ b/swagger/tag_groups/application.yml
@@ -20,6 +20,8 @@ tags:
description: Manage canned responses
- name: Contacts
description: Manage contacts
+ - name: Groups
+ description: Manage WhatsApp group settings, members, invites, and join requests
- name: Contact Labels
description: Manage contact labels
- name: Conversation Assignments
@@ -62,4 +64,4 @@ components:
type: apiKey
in: header
name: api_access_token
- description: This token can be obtained by visiting the profile page or via rails console. Provides access to endpoints based on the user permissions levels.
\ No newline at end of file
+ description: This token can be obtained by visiting the profile page or via rails console. Provides access to endpoints based on the user permissions levels.
diff --git a/swagger/tag_groups/application_swagger.json b/swagger/tag_groups/application_swagger.json
index f0cfa1378..dddf91a5a 100644
--- a/swagger/tag_groups/application_swagger.json
+++ b/swagger/tag_groups/application_swagger.json
@@ -1967,6 +1967,174 @@
}
}
},
+ "/api/v1/accounts/{account_id}/contacts/{id}/sync_group": {
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/account_id"
+ },
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "number"
+ },
+ "description": "ID of the contact"
+ }
+ ],
+ "post": {
+ "tags": [
+ "Contacts"
+ ],
+ "operationId": "contact-sync-group",
+ "summary": "Sync Group",
+ "description": "Syncs group information for a group contact. Triggers a sync with the channel provider (e.g., WhatsApp) to update group metadata and members across all open/pending conversations. The contact must be a group contact with a valid identifier.",
+ "security": [
+ {
+ "userApiKey": []
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Success",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "payload": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "number",
+ "description": "ID of the contact"
+ },
+ "name": {
+ "type": "string",
+ "description": "Name of the contact"
+ },
+ "email": {
+ "type": "string",
+ "description": "Email of the contact"
+ },
+ "phone_number": {
+ "type": "string",
+ "description": "Phone number of the contact"
+ },
+ "identifier": {
+ "type": "string",
+ "description": "Identifier of the contact"
+ },
+ "thumbnail": {
+ "type": "string",
+ "description": "Thumbnail URL of the contact"
+ },
+ "custom_attributes": {
+ "type": "object",
+ "description": "Custom attributes of the contact"
+ },
+ "contact_inboxes": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/contact_inboxes"
+ }
+ },
+ "group_members": {
+ "type": "array",
+ "description": "List of group members from the most recent open/pending group conversation",
+ "items": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "number",
+ "description": "ID of the group member record"
+ },
+ "role": {
+ "type": "string",
+ "enum": [
+ "member",
+ "admin"
+ ],
+ "description": "Role of the member in the group"
+ },
+ "is_active": {
+ "type": "boolean",
+ "description": "Whether the member is currently active in the group"
+ },
+ "contact": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "number",
+ "description": "ID of the member contact"
+ },
+ "name": {
+ "type": "string",
+ "description": "Name of the member contact"
+ },
+ "phone_number": {
+ "type": "string",
+ "description": "Phone number of the member contact"
+ },
+ "identifier": {
+ "type": "string",
+ "description": "Identifier of the member contact"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad request — contact is not a group, has no identifier, or no supported inbox found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/bad_request_error"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/bad_request_error"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Contact not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/bad_request_error"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal server error — channel provider unavailable",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/bad_request_error"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
"/api/v1/accounts/{account_id}/actions/contact_merge": {
"parameters": [
{
@@ -7771,6 +7939,56 @@
}
}
},
+ "group_member": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "number",
+ "description": "ID of the group member record"
+ },
+ "role": {
+ "type": "string",
+ "enum": [
+ "member",
+ "admin"
+ ],
+ "description": "Role of the member in the group"
+ },
+ "is_active": {
+ "type": "boolean",
+ "description": "Whether the member is currently active in the group"
+ },
+ "group_contact_id": {
+ "type": "number",
+ "description": "ID of the group contact this membership belongs to"
+ },
+ "contact": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "number",
+ "description": "ID of the member contact"
+ },
+ "name": {
+ "type": "string",
+ "description": "Name of the member contact"
+ },
+ "phone_number": {
+ "type": "string",
+ "description": "Phone number of the member contact"
+ },
+ "identifier": {
+ "type": "string",
+ "description": "Identifier of the member contact"
+ },
+ "thumbnail": {
+ "type": "string",
+ "description": "Thumbnail URL of the member contact"
+ }
+ }
+ }
+ }
+ },
"conversation": {
"type": "object",
"properties": {
diff --git a/swagger/tag_groups/client_swagger.json b/swagger/tag_groups/client_swagger.json
index abac8b24e..20805a708 100644
--- a/swagger/tag_groups/client_swagger.json
+++ b/swagger/tag_groups/client_swagger.json
@@ -1206,6 +1206,56 @@
}
}
},
+ "group_member": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "number",
+ "description": "ID of the group member record"
+ },
+ "role": {
+ "type": "string",
+ "enum": [
+ "member",
+ "admin"
+ ],
+ "description": "Role of the member in the group"
+ },
+ "is_active": {
+ "type": "boolean",
+ "description": "Whether the member is currently active in the group"
+ },
+ "group_contact_id": {
+ "type": "number",
+ "description": "ID of the group contact this membership belongs to"
+ },
+ "contact": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "number",
+ "description": "ID of the member contact"
+ },
+ "name": {
+ "type": "string",
+ "description": "Name of the member contact"
+ },
+ "phone_number": {
+ "type": "string",
+ "description": "Phone number of the member contact"
+ },
+ "identifier": {
+ "type": "string",
+ "description": "Identifier of the member contact"
+ },
+ "thumbnail": {
+ "type": "string",
+ "description": "Thumbnail URL of the member contact"
+ }
+ }
+ }
+ }
+ },
"conversation": {
"type": "object",
"properties": {
diff --git a/swagger/tag_groups/other_swagger.json b/swagger/tag_groups/other_swagger.json
index d4a9a551d..a9a17887a 100644
--- a/swagger/tag_groups/other_swagger.json
+++ b/swagger/tag_groups/other_swagger.json
@@ -617,6 +617,56 @@
}
}
},
+ "group_member": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "number",
+ "description": "ID of the group member record"
+ },
+ "role": {
+ "type": "string",
+ "enum": [
+ "member",
+ "admin"
+ ],
+ "description": "Role of the member in the group"
+ },
+ "is_active": {
+ "type": "boolean",
+ "description": "Whether the member is currently active in the group"
+ },
+ "group_contact_id": {
+ "type": "number",
+ "description": "ID of the group contact this membership belongs to"
+ },
+ "contact": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "number",
+ "description": "ID of the member contact"
+ },
+ "name": {
+ "type": "string",
+ "description": "Name of the member contact"
+ },
+ "phone_number": {
+ "type": "string",
+ "description": "Phone number of the member contact"
+ },
+ "identifier": {
+ "type": "string",
+ "description": "Identifier of the member contact"
+ },
+ "thumbnail": {
+ "type": "string",
+ "description": "Thumbnail URL of the member contact"
+ }
+ }
+ }
+ }
+ },
"conversation": {
"type": "object",
"properties": {
diff --git a/swagger/tag_groups/platform_swagger.json b/swagger/tag_groups/platform_swagger.json
index 796100d8a..8af1627a0 100644
--- a/swagger/tag_groups/platform_swagger.json
+++ b/swagger/tag_groups/platform_swagger.json
@@ -1378,6 +1378,56 @@
}
}
},
+ "group_member": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "number",
+ "description": "ID of the group member record"
+ },
+ "role": {
+ "type": "string",
+ "enum": [
+ "member",
+ "admin"
+ ],
+ "description": "Role of the member in the group"
+ },
+ "is_active": {
+ "type": "boolean",
+ "description": "Whether the member is currently active in the group"
+ },
+ "group_contact_id": {
+ "type": "number",
+ "description": "ID of the group contact this membership belongs to"
+ },
+ "contact": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "number",
+ "description": "ID of the member contact"
+ },
+ "name": {
+ "type": "string",
+ "description": "Name of the member contact"
+ },
+ "phone_number": {
+ "type": "string",
+ "description": "Phone number of the member contact"
+ },
+ "identifier": {
+ "type": "string",
+ "description": "Identifier of the member contact"
+ },
+ "thumbnail": {
+ "type": "string",
+ "description": "Thumbnail URL of the member contact"
+ }
+ }
+ }
+ }
+ },
"conversation": {
"type": "object",
"properties": {