Merge branch 'fazer-ai/main' into chore/merge-upstream

This commit is contained in:
Gabriel Jablonski 2025-05-18 11:43:18 -03:00 committed by GitHub
commit ba14d51d23
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
251 changed files with 4197 additions and 449 deletions

View File

@ -259,3 +259,10 @@ AZURE_APP_SECRET=
# Set to true if you want to remove stale contact inboxes # Set to true if you want to remove stale contact inboxes
# contact_inboxes with no conversation older than 90 days will be removed # contact_inboxes with no conversation older than 90 days will be removed
# REMOVE_STALE_CONTACT_INBOX_JOB_STATUS=false # REMOVE_STALE_CONTACT_INBOX_JOB_STATUS=false
# Baileys API Whatsapp provider
BAILEYS_PROVIDER_DEFAULT_CLIENT_NAME=Chatwoot
BAILEYS_PROVIDER_DEFAULT_URL=http://localhost:3025
BAILEYS_PROVIDER_DEFAULT_API_KEY=
RESEND_API_KEY=

View File

@ -1,6 +1,9 @@
name: Frontend Lint & Test name: Frontend Lint & Test
on: on:
push:
tags:
- '*'
workflow_dispatch: workflow_dispatch:
jobs: jobs:

View File

@ -0,0 +1,126 @@
name: Publish Chatwoot docker images to GitHub
permissions:
contents: read
packages: write
on:
push:
tags:
- '*'
workflow_dispatch:
env:
GITHUB_REPO: ghcr.io/${{ github.repository }}
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
runs-on: ${{ matrix.runner }}
env:
GIT_REF: ${{ github.head_ref || github.ref_name }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Prepare
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Strip enterprise code
run: |
rm -rf enterprise
rm -rf spec/enterprise
- name: Set Docker Tags
run: |
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
echo "SANITIZED_REF=${SANITIZED_REF}" >> $GITHUB_ENV
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push to GitHub Container Registry
id: build-ghcr
uses: docker/build-push-action@v6
with:
context: .
file: docker/Dockerfile
platforms: ${{ matrix.platform }}
push: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }}
tags: |
${{ env.GITHUB_REPO }}:${{ env.SANITIZED_REF }}
${{ env.GITHUB_REPO }}:latest
- name: Export digest
run: |
mkdir -p ${{ runner.temp }}/digests
digest="${{ steps.build-ghcr.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
merge:
runs-on: ubuntu-latest
needs:
- build
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: digests-*
merge-multiple: true
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Create manifest list and push
working-directory: ${{ runner.temp }}/digests
env:
GIT_REF: ${{ github.head_ref || github.ref_name }}
run: |
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
docker buildx imagetools create \
-t ghcr.io/${{ github.repository }}:${SANITIZED_REF} \
-t ghcr.io/${{ github.repository }}:latest \
$(printf 'ghcr.io/${{ github.repository }}@sha256:%s ' *)
- name: Inspect image
env:
GIT_REF: ${{ github.head_ref || github.ref_name }}
run: |
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
REPO="ghcr.io/${{ github.repository }}"
docker buildx imagetools inspect ${REPO}:${SANITIZED_REF}
docker buildx imagetools inspect ${REPO}:latest

View File

@ -1,5 +1,9 @@
name: Run Chatwoot CE spec name: Run Chatwoot CE spec
on: on:
push:
tags:
- '*'
workflow_dispatch: workflow_dispatch:
jobs: jobs:

View File

@ -4,8 +4,8 @@
# lint js and vue files # lint js and vue files
npx --no-install lint-staged npx --no-install lint-staged
# lint only staged ruby files that still exist (not deleted) # lint only staged ruby files
git diff --name-only --cached | xargs -I {} sh -c 'test -f "{}" && echo "{}"' | grep '\.rb$' | xargs -I {} bundle exec rubocop --force-exclusion -a "{}" || true git diff --name-only --cached | xargs ls -1 2>/dev/null | grep '\.rb$' | xargs bundle exec rubocop --force-exclusion
# stage rubocop changes to files # stage rubocop changes to files
git diff --name-only --cached | xargs -I {} sh -c 'test -f "{}" && git add "{}"' || true # git diff --name-only --cached | xargs git add

2
.nvmrc
View File

@ -1 +1 @@
20.5.1 23.7.0

View File

@ -154,6 +154,7 @@ CustomCopLocation:
AllCops: AllCops:
NewCops: enable NewCops: enable
SuggestExtensions: false
Exclude: Exclude:
- 'bin/**/*' - 'bin/**/*'
- 'db/schema.rb' - 'db/schema.rb'
@ -166,3 +167,13 @@ AllCops:
- 'tmp/**/*' - 'tmp/**/*'
- 'storage/**/*' - 'storage/**/*'
- 'db/migrate/20230426130150_init_schema.rb' - 'db/migrate/20230426130150_init_schema.rb'
Layout/LeadingCommentSpace:
Enabled: false
Rails/SaveBang:
Enabled: true
AllowedReceivers:
- Stripe::Subscription
- Stripe::Customer
- FactoryBot

View File

@ -2,5 +2,6 @@
"cSpell.words": [ "cSpell.words": [
"chatwoot", "chatwoot",
"dompurify" "dompurify"
] ],
"css.customData": [".vscode/tailwind.json"]
} }

55
.vscode/tailwind.json vendored Normal file
View File

@ -0,0 +1,55 @@
{
"version": 1.1,
"atDirectives": [
{
"name": "@tailwind",
"description": "Use the `@tailwind` directive to insert Tailwind's `base`, `components`, `utilities` and `screens` styles into your CSS.",
"references": [
{
"name": "Tailwind Documentation",
"url": "https://tailwindcss.com/docs/functions-and-directives#tailwind"
}
]
},
{
"name": "@apply",
"description": "Use the `@apply` directive to inline any existing utility classes into your own custom CSS. This is useful when you find a common utility pattern in your HTML that youd like to extract to a new component.",
"references": [
{
"name": "Tailwind Documentation",
"url": "https://tailwindcss.com/docs/functions-and-directives#apply"
}
]
},
{
"name": "@responsive",
"description": "You can generate responsive variants of your own classes by wrapping their definitions in the `@responsive` directive:\n```css\n@responsive {\n .alert {\n background-color: #E53E3E;\n }\n}\n```\n",
"references": [
{
"name": "Tailwind Documentation",
"url": "https://tailwindcss.com/docs/functions-and-directives#responsive"
}
]
},
{
"name": "@screen",
"description": "The `@screen` directive allows you to create media queries that reference your breakpoints by **name** instead of duplicating their values in your own CSS:\n```css\n@screen sm {\n /* ... */\n}\n```\n…gets transformed into this:\n```css\n@media (min-width: 640px) {\n /* ... */\n}\n```\n",
"references": [
{
"name": "Tailwind Documentation",
"url": "https://tailwindcss.com/docs/functions-and-directives#screen"
}
]
},
{
"name": "@variants",
"description": "Generate `hover`, `focus`, `active` and other **variants** of your own utilities by wrapping their definitions in the `@variants` directive:\n```css\n@variants hover, focus {\n .btn-brand {\n background-color: #3182CE;\n }\n}\n```\n",
"references": [
{
"name": "Tailwind Documentation",
"url": "https://tailwindcss.com/docs/functions-and-directives#variants"
}
]
}
]
}

View File

@ -40,7 +40,7 @@ gem 'down'
# authentication type to fetch and send mail over oauth2.0 # authentication type to fetch and send mail over oauth2.0
gem 'gmail_xoauth' gem 'gmail_xoauth'
# Lock net-smtp to 0.3.4 to avoid issues with gmail_xoauth2 # Lock net-smtp to 0.3.4 to avoid issues with gmail_xoauth2
gem 'net-smtp', '~> 0.3.4' gem 'net-smtp', '~> 0.3.4'
# Prevent CSV injection # Prevent CSV injection
gem 'csv-safe' gem 'csv-safe'
@ -178,6 +178,8 @@ gem 'ruby-openai'
gem 'shopify_api' gem 'shopify_api'
gem 'resend', '~> 0.19.0'
### Gems required only in specific deployment environments ### ### Gems required only in specific deployment environments ###
############################################################## ##############################################################

View File

@ -423,6 +423,7 @@ GEM
faraday-multipart faraday-multipart
json (>= 1.8) json (>= 1.8)
rexml rexml
language_server-protocol (3.17.0.4)
launchy (2.5.2) launchy (2.5.2)
addressable (~> 2.8) addressable (~> 2.8)
letter_opener (1.8.1) letter_opener (1.8.1)
@ -545,9 +546,10 @@ GEM
orm_adapter (0.5.0) orm_adapter (0.5.0)
os (1.1.4) os (1.1.4)
ostruct (0.6.1) ostruct (0.6.1)
parallel (1.23.0) parallel (1.26.3)
parser (3.2.2.1) parser (3.3.7.1)
ast (~> 2.4.1) ast (~> 2.4.1)
racc
pg (1.5.3) pg (1.5.3)
pg_search (2.3.6) pg_search (2.3.6)
activerecord (>= 5.2) activerecord (>= 5.2)
@ -632,6 +634,8 @@ GEM
uber (< 0.2.0) uber (< 0.2.0)
request_store (1.5.1) request_store (1.5.1)
rack (>= 1.4) rack (>= 1.4)
resend (0.19.0)
httparty (>= 0.21.0)
responders (3.1.1) responders (3.1.1)
actionpack (>= 5.2) actionpack (>= 5.2)
railties (>= 5.2) railties (>= 5.2)
@ -663,14 +667,15 @@ GEM
rspec-support (3.13.1) rspec-support (3.13.1)
rspec_junit_formatter (0.6.0) rspec_junit_formatter (0.6.0)
rspec-core (>= 2, < 4, != 2.12.0) rspec-core (>= 2, < 4, != 2.12.0)
rubocop (1.50.2) rubocop (1.57.2)
json (~> 2.3) json (~> 2.3)
language_server-protocol (>= 3.17.0)
parallel (~> 1.10) parallel (~> 1.10)
parser (>= 3.2.0.0) parser (>= 3.2.2.4)
rainbow (>= 2.2.2, < 4.0) rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.8, < 3.0) regexp_parser (>= 1.8, < 3.0)
rexml (>= 3.2.5, < 4.0) rexml (>= 3.2.5, < 4.0)
rubocop-ast (>= 1.28.0, < 2.0) rubocop-ast (>= 1.28.1, < 2.0)
ruby-progressbar (~> 1.7) ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 3.0) unicode-display_width (>= 2.4.0, < 3.0)
rubocop-ast (1.28.1) rubocop-ast (1.28.1)
@ -816,7 +821,7 @@ GEM
unf (0.1.4) unf (0.1.4)
unf_ext unf_ext
unf_ext (0.0.8.2) unf_ext (0.0.8.2)
unicode-display_width (2.4.2) unicode-display_width (2.6.0)
uniform_notifier (1.16.0) uniform_notifier (1.16.0)
uri (1.0.3) uri (1.0.3)
uri_template (0.7.0) uri_template (0.7.0)
@ -954,6 +959,7 @@ DEPENDENCIES
rails (~> 7.0.8.4) rails (~> 7.0.8.4)
redis redis
redis-namespace redis-namespace
resend (~> 0.19.0)
responders (>= 3.1.1) responders (>= 3.1.1)
rest-client rest-client
reverse_markdown reverse_markdown

View File

@ -69,7 +69,7 @@ class ContactIdentifyAction
end end
def merge_contacts?(existing_contact, key) def merge_contacts?(existing_contact, key)
return if existing_contact.blank? return false if existing_contact.blank?
return true if params[:identifier].blank? return true if params[:identifier].blank?

View File

@ -160,7 +160,7 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil
end end
def all_unsupported_files? def all_unsupported_files?
return if attachments.empty? return false if attachments.empty?
attachments_type = attachments.pluck(:type).uniq.first attachments_type = attachments.pluck(:type).uniq.first
unsupported_file_type?(attachments_type) unsupported_file_type?(attachments_type)

View File

@ -30,7 +30,7 @@ class Messages::Instagram::MessageBuilder < Messages::Instagram::BaseMessageBuil
# https://developers.facebook.com/docs/graph-api/guides/error-handling/ search for error code 1609005 # https://developers.facebook.com/docs/graph-api/guides/error-handling/ search for error code 1609005
if error_code == 1_609_005 if error_code == 1_609_005
@message.attachments.destroy_all @message.attachments.destroy_all
@message.update(content: I18n.t('conversations.messages.instagram_deleted_story_content')) @message.update!(content: I18n.t('conversations.messages.instagram_deleted_story_content'))
end end
Rails.logger.error("[InstagramStoryFetchError]: #{parsed_response.dig('error', 'message')} #{error_code}") Rails.logger.error("[InstagramStoryFetchError]: #{parsed_response.dig('error', 'message')} #{error_code}")

View File

@ -14,7 +14,7 @@ class Messages::Instagram::Messenger::MessageBuilder < Messages::Instagram::Base
rescue Koala::Facebook::ClientError => e rescue Koala::Facebook::ClientError => e
# The exception occurs when we are trying fetch the deleted story or blocked story. # The exception occurs when we are trying fetch the deleted story or blocked story.
@message.attachments.destroy_all @message.attachments.destroy_all
@message.update(content: I18n.t('conversations.messages.instagram_deleted_story_content')) @message.update!(content: I18n.t('conversations.messages.instagram_deleted_story_content'))
Rails.logger.error e Rails.logger.error e
{} {}
rescue StandardError => e rescue StandardError => e

View File

@ -2,17 +2,19 @@ class Messages::MessageBuilder
include ::FileTypeHelper include ::FileTypeHelper
attr_reader :message attr_reader :message
def initialize(user, conversation, params) def initialize(user, conversation, params) # rubocop:disable Metrics/CyclomaticComplexity
@params = params @params = params
@private = params[:private] || false @private = params[:private] || false
@conversation = conversation @conversation = conversation
@user = user @user = user
@message_type = params[:message_type] || 'outgoing' @message_type = params[:message_type] || 'outgoing'
@attachments = params[:attachments] @attachments = params[:attachments]
@is_recorded_audio = params[:is_recorded_audio]
@automation_rule = content_attributes&.dig(:automation_rule_id) @automation_rule = content_attributes&.dig(:automation_rule_id)
return unless params.instance_of?(ActionController::Parameters) return unless params.instance_of?(ActionController::Parameters)
@in_reply_to = content_attributes&.dig(:in_reply_to) @in_reply_to = content_attributes&.dig(:in_reply_to)
@is_reaction = content_attributes&.dig(:is_reaction)
@items = content_attributes&.dig(:items) @items = content_attributes&.dig(:items)
end end
@ -66,7 +68,7 @@ class Messages::MessageBuilder
account_id: @message.account_id, account_id: @message.account_id,
file: uploaded_attachment file: uploaded_attachment
) )
attachment.meta = process_metadata(uploaded_attachment)
attachment.file_type = if uploaded_attachment.is_a?(String) attachment.file_type = if uploaded_attachment.is_a?(String)
file_type_by_signed_id( file_type_by_signed_id(
uploaded_attachment uploaded_attachment
@ -77,6 +79,14 @@ class Messages::MessageBuilder
end end
end end
def process_metadata(attachment)
# NOTE: `is_recorded_audio` can be either a boolean or an array of file names.
return unless @is_recorded_audio
return { is_recorded_audio: true } if @is_recorded_audio == true
{ is_recorded_audio: true } if @is_recorded_audio.is_a?(Array) && attachment.original_filename.in?(@is_recorded_audio)
end
def process_emails def process_emails
return unless @conversation.inbox&.inbox_type == 'Email' return unless @conversation.inbox&.inbox_type == 'Email'
@ -149,6 +159,7 @@ class Messages::MessageBuilder
content_type: @params[:content_type], content_type: @params[:content_type],
items: @items, items: @items,
in_reply_to: @in_reply_to, in_reply_to: @in_reply_to,
is_reaction: @is_reaction,
echo_id: @params[:echo_id], echo_id: @params[:echo_id],
source_id: @params[:source_id] source_id: @params[:source_id]
}.merge(external_created_at).merge(automation_rule_id).merge(campaign_id).merge(template_params) }.merge(external_created_at).merge(automation_rule_id).merge(campaign_id).merge(template_params)

View File

@ -68,7 +68,6 @@ class Messages::Messenger::MessageBuilder
message.save! message.save!
end end
# This is a placeholder method to be overridden by child classes
def get_story_object_from_source_id(_source_id) def get_story_object_from_source_id(_source_id)
{} {}
end end

View File

@ -24,7 +24,6 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
automation_rule_update automation_rule_update
process_attachments process_attachments
rescue StandardError => e rescue StandardError => e
Rails.logger.error e Rails.logger.error e
render json: { error: @automation_rule.errors.messages }.to_json, status: :unprocessable_entity render json: { error: @automation_rule.errors.messages }.to_json, status: :unprocessable_entity

View File

@ -39,7 +39,7 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
return if response['instagram_business_account'].blank? return if response['instagram_business_account'].blank?
instagram_id = response['instagram_business_account']['id'] instagram_id = response['instagram_business_account']['id']
facebook_channel.update(instagram_id: instagram_id) facebook_channel.update!(instagram_id: instagram_id)
rescue StandardError => e rescue StandardError => e
Rails.logger.error "Error in set_instagram_id: #{e.message}" Rails.logger.error "Error in set_instagram_id: #{e.message}"
end end

View File

@ -110,6 +110,10 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end end
def update_last_seen def update_last_seen
# NOTE: Use old `agent_last_seen_at`, so we reference messages received after that
Rails.configuration.dispatcher.dispatch(Events::Types::MESSAGES_READ, Time.zone.now, conversation: @conversation,
last_seen_at: @conversation.agent_last_seen_at)
update_last_seen_on_conversation(DateTime.now.utc, assignee?) update_last_seen_on_conversation(DateTime.now.utc, assignee?)
end end

View File

@ -64,6 +64,28 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
head :ok head :ok
end end
def setup_channel_provider
channel = @inbox.channel
unless channel.respond_to?(:setup_channel_provider)
render json: { error: 'Channel does not support setup' }, status: :unprocessable_entity and return
end
channel.setup_channel_provider
head :ok
end
def disconnect_channel_provider
channel = @inbox.channel
unless channel.respond_to?(:disconnect_channel_provider)
render json: { error: 'Channel does not support disconnect' }, status: :unprocessable_entity and return
end
channel.disconnect_channel_provider
head :ok
end
def destroy def destroy
::DeleteObjectJob.perform_later(@inbox, Current.user, request.ip) if @inbox.present? ::DeleteObjectJob.perform_later(@inbox, Current.user, request.ip) if @inbox.present?
render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') } render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') }

View File

@ -16,7 +16,7 @@ class Api::V1::Accounts::Integrations::SlackController < Api::V1::Accounts::Base
end end
def update def update
@hook = channel_builder.update(permitted_params[:reference_id]) @hook = channel_builder.update_reference_id(permitted_params[:reference_id])
render json: { error: I18n.t('errors.slack.invalid_channel_id') }, status: :unprocessable_entity if @hook.blank? render json: { error: I18n.t('errors.slack.invalid_channel_id') }, status: :unprocessable_entity if @hook.blank?
end end

View File

@ -25,17 +25,17 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro
end end
def update def update
@notification.update(read_at: DateTime.now.utc) @notification.update!(read_at: DateTime.now.utc)
render json: @notification render json: @notification
end end
def unread def unread
@notification.update(read_at: nil) @notification.update!(read_at: nil)
render json: @notification render json: @notification
end end
def destroy def destroy
@notification.destroy @notification.destroy!
head :ok head :ok
end end
@ -55,7 +55,7 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro
def snooze def snooze
updated_meta = (@notification.meta || {}).merge('last_snoozed_at' => nil) updated_meta = (@notification.meta || {}).merge('last_snoozed_at' => nil)
@notification.update(snoozed_until: parse_date_time(params[:snoozed_until].to_s), meta: updated_meta) if params[:snoozed_until] @notification.update!(snoozed_until: parse_date_time(params[:snoozed_until].to_s), meta: updated_meta) if params[:snoozed_until]
render json: @notification render json: @notification
end end

View File

@ -38,7 +38,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
end end
def archive def archive
@portal.update(archive: true) @portal.update!(archive: true)
head :ok head :ok
end end

View File

@ -7,12 +7,12 @@ class Api::V1::Accounts::WebhooksController < Api::V1::Accounts::BaseController
end end
def create def create
@webhook = Current.account.webhooks.new(webhook_params) @webhook = Current.account.webhooks.new(webhook_create_params)
@webhook.save! @webhook.save!
end end
def update def update
@webhook.update!(webhook_params) @webhook.update!(webhook_update_params)
end end
def destroy def destroy
@ -22,8 +22,12 @@ class Api::V1::Accounts::WebhooksController < Api::V1::Accounts::BaseController
private private
def webhook_params def webhook_create_params
params.require(:webhook).permit(:inbox_id, :url, subscriptions: []) params.require(:webhook).permit(:inbox_id, :name, :url, subscriptions: [])
end
def webhook_update_params
params.require(:webhook).permit(:name, subscriptions: [])
end end
def fetch_webhook def fetch_webhook

View File

@ -26,10 +26,14 @@ class Api::V1::ProfilesController < Api::BaseController
def availability def availability
@user.account_users.find_by!(account_id: availability_params[:account_id]).update!(availability: availability_params[:availability]) @user.account_users.find_by!(account_id: availability_params[:account_id]).update!(availability: availability_params[:availability])
Rails.configuration.dispatcher.dispatch(Events::Types::ACCOUNT_PRESENCE_UPDATED, Time.zone.now, account_id: availability_params[:account_id],
user_id: @current_user.id,
status: availability_params[:availability])
end end
def set_active_account def set_active_account
@user.account_users.find_by(account_id: profile_params[:account_id]).update(active_at: Time.now.utc) @user.account_users.find_by(account_id: profile_params[:account_id]).update!(active_at: Time.now.utc)
head :ok head :ok
end end

View File

@ -19,7 +19,7 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
contact = @contact contact = @contact
end end
@contact_inbox.update(hmac_verified: true) if should_verify_hmac? && valid_hmac? @contact_inbox.update!(hmac_verified: true) if should_verify_hmac? && valid_hmac?
identify_contact(contact) identify_contact(contact)
end end

View File

@ -48,6 +48,8 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
case permitted_params[:typing_status] case permitted_params[:typing_status]
when 'on' when 'on'
trigger_typing_event(CONVERSATION_TYPING_ON) trigger_typing_event(CONVERSATION_TYPING_ON)
when 'recording'
trigger_typing_event(CONVERSATION_RECORDING)
when 'off' when 'off'
trigger_typing_event(CONVERSATION_TYPING_OFF) trigger_typing_event(CONVERSATION_TYPING_OFF)
end end
@ -82,7 +84,7 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
end end
def render_not_found_if_empty def render_not_found_if_empty
return head :not_found if conversation.nil? head :not_found if conversation.nil?
end end
def permitted_params def permitted_params

View File

@ -12,7 +12,7 @@ class ApiController < ApplicationController
def redis_status def redis_status
r = Redis.new(Redis::Config.app) r = Redis.new(Redis::Config.app)
return 'ok' if r.ping 'ok' if r.ping
rescue Redis::CannotConnectError rescue Redis::CannotConnectError
'failing' 'failing'
end end

View File

@ -5,7 +5,7 @@ class Platform::Api::V1::AccountsController < PlatformController
@resource = Account.create!(account_params) @resource = Account.create!(account_params)
update_resource_features update_resource_features
@resource.save! @resource.save!
@platform_app.platform_app_permissibles.find_or_create_by(permissible: @resource) @platform_app.platform_app_permissibles.find_or_create_by!(permissible: @resource)
end end
def update def update

View File

@ -12,7 +12,7 @@ class Platform::Api::V1::AgentBotsController < PlatformController
@resource = AgentBot.new(agent_bot_params.except(:avatar_url)) @resource = AgentBot.new(agent_bot_params.except(:avatar_url))
@resource.save! @resource.save!
process_avatar_from_url process_avatar_from_url
@platform_app.platform_app_permissibles.find_or_create_by(permissible: @resource) @platform_app.platform_app_permissibles.find_or_create_by!(permissible: @resource)
end end
def update def update

View File

@ -31,7 +31,7 @@ class Public::Api::V1::Inboxes::ContactsController < Public::Api::V1::InboxesCon
return if params[:identifier_hash].blank? && !@inbox_channel.hmac_mandatory return if params[:identifier_hash].blank? && !@inbox_channel.hmac_mandatory
raise StandardError, 'HMAC failed: Invalid Identifier Hash Provided' unless valid_hmac? raise StandardError, 'HMAC failed: Invalid Identifier Hash Provided' unless valid_hmac?
@contact_inbox.update(hmac_verified: true) if @contact_inbox.present? @contact_inbox.update!(hmac_verified: true) if @contact_inbox.present?
end end
def valid_hmac? def valid_hmac?

View File

@ -30,6 +30,8 @@ class Public::Api::V1::Inboxes::ConversationsController < Public::Api::V1::Inbox
case params[:typing_status] case params[:typing_status]
when 'on' when 'on'
trigger_typing_event(CONVERSATION_TYPING_ON) trigger_typing_event(CONVERSATION_TYPING_ON)
when 'recording'
trigger_typing_event(CONVERSATION_RECORDING)
when 'off' when 'off'
trigger_typing_event(CONVERSATION_TYPING_OFF) trigger_typing_event(CONVERSATION_TYPING_OFF)
end end

View File

@ -6,7 +6,7 @@ class SuperAdmin::AccountUsersController < SuperAdmin::ApplicationController
resource = resource_class.new(resource_params) resource = resource_class.new(resource_params)
authorize_resource(resource) authorize_resource(resource)
notice = resource.save ? translate_with_resource('create.success') : resource.errors.full_messages.first notice = resource.save ? translate_with_resource('create.success') : resource.errors.full_messages.first
redirect_back(fallback_location: [namespace, resource.account], notice: notice) redirect_back(fallback_location: [namespace, resource.account], notice: notice)
end end

View File

@ -18,7 +18,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
params['app_config'].each do |key, value| params['app_config'].each do |key, value|
next unless @allowed_configs.include?(key) next unless @allowed_configs.include?(key)
i = InstallationConfig.where(name: key).first_or_create(value: value, locked: false) i = InstallationConfig.where(name: key).first_or_create!(value: value, locked: false)
i.value = value i.value = value
i.save! i.save!
end end

View File

@ -8,11 +8,28 @@ class Webhooks::WhatsappController < ActionController::API
return return
end end
perform_whatsapp_events_job
end
private
def perform_whatsapp_events_job
perform_sync if params[:awaitResponse].present?
return if performed?
Webhooks::WhatsappEventsJob.perform_later(params.to_unsafe_hash) Webhooks::WhatsappEventsJob.perform_later(params.to_unsafe_hash)
head :ok head :ok
end end
private def perform_sync
Webhooks::WhatsappEventsJob.perform_now(params.to_unsafe_hash)
rescue Whatsapp::IncomingMessageBaileysService::InvalidWebhookVerifyToken
head :unauthorized
rescue Whatsapp::IncomingMessageBaileysService::MessageNotFoundError
head :not_found
rescue Whatsapp::IncomingMessageBaileysService::AttachmentNotFoundError
head :unprocessable_entity
end
def valid_token?(token) def valid_token?(token)
channel = Channel::Whatsapp.find_by(phone_number: params[:phone_number]) channel = Channel::Whatsapp.find_by(phone_number: params[:phone_number])

View File

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

View File

@ -10,6 +10,6 @@ module CacheKeysHelper
return value_from_cache if value_from_cache.present? return value_from_cache if value_from_cache.present?
# zero epoch time: 1970-01-01 00:00:00 UTC # zero epoch time: 1970-01-01 00:00:00 UTC
'0000000000' '0000000000000'
end end
end end

View File

@ -1,6 +1,8 @@
module FrontendUrlsHelper module FrontendUrlsHelper
def frontend_url(path, **query_params) def frontend_url(path, **query_params)
url_params = query_params.blank? ? '' : "?#{query_params.to_query}" url_params = query_params.blank? ? '' : "?#{query_params.to_query}"
"#{root_url}app/#{path}#{url_params}" host = ENV.fetch('FRONTEND_URL', root_url)
host = "#{host}/" unless host.end_with?('/')
"#{host}app/#{path}#{url_params}"
end end
end end

View File

@ -52,8 +52,8 @@ module ReportingEventHelper
end end
def format_time(hour, minute) def format_time(hour, minute)
hour = hour < 10 ? "0#{hour}" : hour hour = "0#{hour}" if hour < 10
minute = minute < 10 ? "0#{minute}" : minute minute = "0#{minute}" if minute < 10
"#{hour}:#{minute}" "#{hour}:#{minute}"
end end
end end

View File

@ -14,6 +14,6 @@ module TimezoneHelper
zone.now.utc_offset == offset_in_seconds zone.now.utc_offset == offset_in_seconds
end end
return matching_zone.name if matching_zone matching_zone&.name
end end
end end

View File

@ -8,6 +8,7 @@ export const buildCreatePayload = ({
contentAttributes, contentAttributes,
echoId, echoId,
files, files,
isRecordedAudio,
ccEmails = '', ccEmails = '',
bccEmails = '', bccEmails = '',
toEmails = '', toEmails = '',
@ -22,6 +23,7 @@ export const buildCreatePayload = ({
files.forEach(file => { files.forEach(file => {
payload.append('attachments[]', file); payload.append('attachments[]', file);
}); });
payload.append('is_recorded_audio', JSON.stringify(isRecordedAudio));
payload.append('private', isPrivate); payload.append('private', isPrivate);
payload.append('echo_id', echoId); payload.append('echo_id', echoId);
payload.append('cc_emails', ccEmails); payload.append('cc_emails', ccEmails);
@ -60,6 +62,7 @@ class MessageApi extends ApiClient {
contentAttributes, contentAttributes,
echo_id: echoId, echo_id: echoId,
files, files,
isRecordedAudio,
ccEmails = '', ccEmails = '',
bccEmails = '', bccEmails = '',
toEmails = '', toEmails = '',
@ -74,6 +77,7 @@ class MessageApi extends ApiClient {
contentAttributes, contentAttributes,
echoId, echoId,
files, files,
isRecordedAudio,
ccEmails, ccEmails,
bccEmails, bccEmails,
toEmails, toEmails,

View File

@ -28,6 +28,14 @@ class Inboxes extends CacheEnabledApiClient {
agent_bot: botId, agent_bot: botId,
}); });
} }
setupChannelProvider(inboxId) {
return axios.post(`${this.url}/${inboxId}/setup_channel_provider`);
}
disconnectChannelProvider(inboxId) {
return axios.post(`${this.url}/${inboxId}/disconnect_channel_provider`);
}
} }
export default new Inboxes(); export default new Inboxes();

View File

@ -15,6 +15,7 @@ import WhatsAppOptions from './WhatsAppOptions.vue';
const props = defineProps({ const props = defineProps({
attachedFiles: { type: Array, default: () => [] }, attachedFiles: { type: Array, default: () => [] },
isWhatsappInbox: { type: Boolean, default: false }, isWhatsappInbox: { type: Boolean, default: false },
isWhatsappBaileysInbox: { type: Boolean, default: false },
isEmailOrWebWidgetInbox: { type: Boolean, default: false }, isEmailOrWebWidgetInbox: { type: Boolean, default: false },
isTwilioSmsInbox: { type: Boolean, default: false }, isTwilioSmsInbox: { type: Boolean, default: false },
messageTemplates: { type: Array, default: () => [] }, messageTemplates: { type: Array, default: () => [] },
@ -216,7 +217,7 @@ useKeyboardEvents(keyboardEvents);
@click="emit('discard')" @click="emit('discard')"
/> />
<Button <Button
v-if="!isWhatsappInbox" v-if="!isWhatsappInbox || isWhatsappBaileysInbox"
:label="sendButtonLabel" :label="sendButtonLabel"
size="sm" size="sm"
class="!text-xs font-medium" class="!text-xs font-medium"

View File

@ -66,6 +66,9 @@ const inboxTypes = computed(() => ({
isEmail: props.targetInbox?.channelType === INBOX_TYPES.EMAIL, isEmail: props.targetInbox?.channelType === INBOX_TYPES.EMAIL,
isTwilio: props.targetInbox?.channelType === INBOX_TYPES.TWILIO, isTwilio: props.targetInbox?.channelType === INBOX_TYPES.TWILIO,
isWhatsapp: props.targetInbox?.channelType === INBOX_TYPES.WHATSAPP, isWhatsapp: props.targetInbox?.channelType === INBOX_TYPES.WHATSAPP,
isWhatsappBaileys:
props.targetInbox?.channelType === INBOX_TYPES.WHATSAPP &&
props.targetInbox?.provider === 'baileys',
isWebWidget: props.targetInbox?.channelType === INBOX_TYPES.WEB, isWebWidget: props.targetInbox?.channelType === INBOX_TYPES.WEB,
isApi: props.targetInbox?.channelType === INBOX_TYPES.API, isApi: props.targetInbox?.channelType === INBOX_TYPES.API,
isEmailOrWebWidget: isEmailOrWebWidget:
@ -311,7 +314,10 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
/> />
<MessageEditor <MessageEditor
v-if="!inboxTypes.isWhatsapp && !showNoInboxAlert" v-if="
(!inboxTypes.isWhatsapp || inboxTypes.isWhatsappBaileys) &&
!showNoInboxAlert
"
v-model="state.message" v-model="state.message"
:message-signature="messageSignature" :message-signature="messageSignature"
:send-with-signature="sendWithSignature" :send-with-signature="sendWithSignature"
@ -329,6 +335,7 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
<ActionButtons <ActionButtons
:attached-files="state.attachedFiles" :attached-files="state.attachedFiles"
:is-whatsapp-inbox="inboxTypes.isWhatsapp" :is-whatsapp-inbox="inboxTypes.isWhatsapp"
:is-whatsapp-baileys-inbox="inboxTypes.isWhatsappBaileys"
:is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget" :is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget"
:is-twilio-sms-inbox="inboxTypes.isTwilioSMS" :is-twilio-sms-inbox="inboxTypes.isTwilioSMS"
:message-templates="whatsappMessageTemplates" :message-templates="whatsappMessageTemplates"

View File

@ -44,7 +44,7 @@ const triggerClick = () => {
<component <component
:is="componentIs" :is="componentIs"
v-bind="$attrs" v-bind="$attrs"
class="flex text-left rtl:text-right items-center p-2 reset-base text-sm text-n-slate-12 w-full border-0" class="flex text-left rtl:text-right items-center p-2 reset-base text-sm text-n-slate-12 w-full border-0 cursor-pointer"
:class="{ :class="{
'hover:bg-n-alpha-2 rounded-lg w-full gap-3': !$slots.default, 'hover:bg-n-alpha-2 rounded-lg w-full gap-3': !$slots.default,
}" }"

View File

@ -32,7 +32,10 @@ const {
} = useMessageContext(); } = useMessageContext();
const readableTime = computed(() => const readableTime = computed(() =>
messageTimestamp(createdAt.value, 'LLL d, h:mm a') messageTimestamp(
contentAttributes?.value?.externalCreatedAt ?? createdAt.value,
'LLL d, h:mm a'
)
); );
const showStatusIndicator = computed(() => { const showStatusIndicator = computed(() => {

View File

@ -7,6 +7,14 @@ export default {
type: Object, type: Object,
default: () => {}, default: () => {},
}, },
withPhoneNumber: {
type: Boolean,
default: false,
},
withProviderConnectionStatus: {
type: Boolean,
default: false,
},
}, },
computed: { computed: {
computedInboxClass() { computedInboxClass() {
@ -14,6 +22,9 @@ export default {
const classByType = getInboxClassByType(type, phoneNumber); const classByType = getInboxClassByType(type, phoneNumber);
return classByType; return classByType;
}, },
providerConnection() {
return this.inbox.provider_connection?.connection;
},
}, },
}; };
</script> </script>
@ -28,5 +39,17 @@ export default {
size="12" size="12"
/> />
{{ inbox.name }} {{ inbox.name }}
<span v-if="withPhoneNumber" class="ml-2 text-n-slate-12">{{
inbox.phone_number
}}</span>
<span v-if="withProviderConnectionStatus" class="ml-2">
<fluent-icon
icon="circle"
type="filled"
:class="
providerConnection === 'open' ? 'text-green-500' : 'text-n-slate-8'
"
/>
</span>
</div> </div>
</template> </template>

View File

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

View File

@ -485,6 +485,9 @@ export default {
<template v-else-if="isAFacebookInbox"> <template v-else-if="isAFacebookInbox">
{{ $t('CONVERSATION.UNSUPPORTED_MESSAGE_FACEBOOK') }} {{ $t('CONVERSATION.UNSUPPORTED_MESSAGE_FACEBOOK') }}
</template> </template>
<template v-else-if="isAWhatsAppChannel">
{{ $t('CONVERSATION.UNSUPPORTED_MESSAGE_WHATSAPP') }}
</template>
<template v-else> <template v-else>
{{ $t('CONVERSATION.UNSUPPORTED_MESSAGE') }} {{ $t('CONVERSATION.UNSUPPORTED_MESSAGE') }}
</template> </template>

View File

@ -5,6 +5,7 @@ import { useConfig } from 'dashboard/composables/useConfig';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents'; import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useAI } from 'dashboard/composables/useAI'; import { useAI } from 'dashboard/composables/useAI';
import { useMapGetter } from 'dashboard/composables/store'; import { useMapGetter } from 'dashboard/composables/store';
import { useAdmin } from 'dashboard/composables/useAdmin';
// components // components
import ReplyBox from './ReplyBox.vue'; import ReplyBox from './ReplyBox.vue';
@ -37,6 +38,7 @@ import wootConstants from 'dashboard/constants/globals';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage'; import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { FEATURE_FLAGS } from '../../../featureFlags'; import { FEATURE_FLAGS } from '../../../featureFlags';
import { INBOX_TYPES } from 'dashboard/helper/inbox'; import { INBOX_TYPES } from 'dashboard/helper/inbox';
import WhatsappBaileysLinkDeviceModal from '../../../routes/dashboard/settings/inbox/components/WhatsappBaileysLinkDeviceModal.vue';
import NextButton from 'dashboard/components-next/button/Button.vue'; import NextButton from 'dashboard/components-next/button/Button.vue';
@ -48,6 +50,7 @@ export default {
Banner, Banner,
ConversationLabelSuggestion, ConversationLabelSuggestion,
NextButton, NextButton,
WhatsappBaileysLinkDeviceModal,
}, },
mixins: [inboxMixin], mixins: [inboxMixin],
props: { props: {
@ -62,6 +65,7 @@ export default {
}, },
emits: ['contactPanelToggle'], emits: ['contactPanelToggle'],
setup() { setup() {
const { isAdmin } = useAdmin();
const isPopOutReplyBox = ref(false); const isPopOutReplyBox = ref(false);
const { isEnterprise } = useConfig(); const { isEnterprise } = useConfig();
@ -108,6 +112,7 @@ export default {
fetchIntegrationsIfRequired, fetchIntegrationsIfRequired,
fetchLabelSuggestions, fetchLabelSuggestions,
showNextBubbles, showNextBubbles,
isAdmin,
}; };
}, },
data() { data() {
@ -119,6 +124,7 @@ export default {
isProgrammaticScroll: false, isProgrammaticScroll: false,
messageSentSinceOpened: false, messageSentSinceOpened: false,
labelSuggestions: [], labelSuggestions: [],
showBaileysLinkDeviceModal: false,
}; };
}, },
@ -129,6 +135,9 @@ export default {
listLoadingStatus: 'getAllMessagesLoaded', listLoadingStatus: 'getAllMessagesLoaded',
currentAccountId: 'getCurrentAccountId', currentAccountId: 'getCurrentAccountId',
}), }),
currentInbox() {
return this.$store.getters['inboxes/getInbox'](this.currentChat.inbox_id);
},
isOpen() { isOpen() {
return this.currentChat?.status === wootConstants.STATUS_TYPE.OPEN; return this.currentChat?.status === wootConstants.STATUS_TYPE.OPEN;
}, },
@ -296,6 +305,9 @@ export default {
return { incoming, outgoing }; return { incoming, outgoing };
}, },
inboxProviderConnection() {
return this.currentInbox.provider_connection?.connection;
},
}, },
watch: { watch: {
@ -506,12 +518,48 @@ export default {
return false; return false;
}); });
}, },
onOpenBaileysLinkDeviceModal() {
this.showBaileysLinkDeviceModal = true;
},
onCloseBaileysLinkDeviceModal() {
this.showBaileysLinkDeviceModal = false;
},
}, },
}; };
</script> </script>
<template> <template>
<div class="flex flex-col justify-between flex-grow h-full min-w-0 m-0"> <div class="flex flex-col justify-between flex-grow h-full min-w-0 m-0">
<template v-if="isAWhatsAppBaileysChannel">
<WhatsappBaileysLinkDeviceModal
v-if="showBaileysLinkDeviceModal"
:show="showBaileysLinkDeviceModal"
:on-close="onCloseBaileysLinkDeviceModal"
:inbox="currentInbox"
/>
<Banner
v-if="inboxProviderConnection !== 'open'"
color-scheme="alert"
class="mt-2 mx-2 rounded-lg overflow-hidden"
:banner-message="
isAdmin
? $t(
'CONVERSATION.INBOX.WHATSAPP_BAILEYS_PROVIDER_CONNECTION.NOT_CONNECTED'
)
: $t(
'CONVERSATION.INBOX.WHATSAPP_BAILEYS_PROVIDER_CONNECTION.NOT_CONNECTED_CONTACT_ADMIN'
)
"
:has-action-button="isAdmin"
:action-button-label="
$t(
'CONVERSATION.INBOX.WHATSAPP_BAILEYS_PROVIDER_CONNECTION.LINK_DEVICE'
)
"
action-button-icon=""
@primary-action="onOpenBaileysLinkDeviceModal"
/>
</template>
<Banner <Banner
v-if="!currentChat.can_reply" v-if="!currentChat.can_reply"
color-scheme="alert" color-scheme="alert"

View File

@ -863,6 +863,9 @@ export default {
this.isRecorderAudioStopped = !this.isRecordingAudio; this.isRecorderAudioStopped = !this.isRecordingAudio;
if (!this.isRecordingAudio) { if (!this.isRecordingAudio) {
this.resetAudioRecorderInput(); this.resetAudioRecorderInput();
this.onTypingOff();
} else {
this.onRecording();
} }
}, },
toggleAudioRecorderPlayPause() { toggleAudioRecorderPlayPause() {
@ -872,6 +875,7 @@ export default {
if (!this.isRecorderAudioStopped) { if (!this.isRecorderAudioStopped) {
this.isRecorderAudioStopped = true; this.isRecorderAudioStopped = true;
this.$refs.audioRecorderInput.stopRecording(); this.$refs.audioRecorderInput.stopRecording();
this.onTypingOff();
} else if (this.isRecorderAudioStopped) { } else if (this.isRecorderAudioStopped) {
this.$refs.audioRecorderInput.playPause(); this.$refs.audioRecorderInput.playPause();
} }
@ -887,6 +891,9 @@ export default {
onTypingOn() { onTypingOn() {
this.toggleTyping('on'); this.toggleTyping('on');
}, },
onRecording() {
this.toggleTyping('recording');
},
onTypingOff() { onTypingOff() {
this.toggleTyping('off'); this.toggleTyping('off');
}, },
@ -1012,11 +1019,17 @@ export default {
if (this.attachedFiles && this.attachedFiles.length) { if (this.attachedFiles && this.attachedFiles.length) {
messagePayload.files = []; messagePayload.files = [];
messagePayload.isRecordedAudio = [];
this.attachedFiles.forEach(attachment => { this.attachedFiles.forEach(attachment => {
if (this.globalConfig.directUploadsEnabled) { if (this.globalConfig.directUploadsEnabled) {
messagePayload.files.push(attachment.blobSignedId); messagePayload.files.push(attachment.blobSignedId);
} else { } else {
messagePayload.files.push(attachment.resource.file); messagePayload.files.push(attachment.resource.file);
if (attachment.isRecordedAudio) {
messagePayload.isRecordedAudio.push(
attachment.resource.file.name
);
}
} }
}); });
} }

View File

@ -119,8 +119,8 @@ describe('useUISettings', () => {
it('returns correct value for isEditorHotKeyEnabled when editor_message_key is not configured', () => { it('returns correct value for isEditorHotKeyEnabled when editor_message_key is not configured', () => {
getUISettingsMock.value.editor_message_key = undefined; getUISettingsMock.value.editor_message_key = undefined;
const { isEditorHotKeyEnabled } = useUISettings(); const { isEditorHotKeyEnabled } = useUISettings();
expect(isEditorHotKeyEnabled('enter')).toBe(false); expect(isEditorHotKeyEnabled('enter')).toBe(true);
expect(isEditorHotKeyEnabled('cmd_enter')).toBe(true); expect(isEditorHotKeyEnabled('cmd_enter')).toBe(false);
}); });
it('handles non-existent keys', () => { it('handles non-existent keys', () => {

View File

@ -114,6 +114,13 @@ export const useInbox = () => {
); );
}); });
const isAWhatsAppBaileysChannel = computed(() => {
return (
channelType.value === INBOX_TYPES.WHATSAPP &&
whatsAppAPIProvider.value === 'baileys'
);
});
const isAWhatsAppChannel = computed(() => { const isAWhatsAppChannel = computed(() => {
return ( return (
channelType.value === INBOX_TYPES.WHATSAPP || channelType.value === INBOX_TYPES.WHATSAPP ||
@ -140,6 +147,7 @@ export const useInbox = () => {
isATwilioWhatsAppChannel, isATwilioWhatsAppChannel,
isAWhatsAppCloudChannel, isAWhatsAppCloudChannel,
is360DialogWhatsAppChannel, is360DialogWhatsAppChannel,
isAWhatsAppBaileysChannel,
isAnEmailChannel, isAnEmailChannel,
isAnInstagramChannel, isAnInstagramChannel,
}; };

View File

@ -111,7 +111,7 @@ const isEditorHotKeyEnabled = (key, uiSettings) => {
enter_to_send_enabled: enterToSendEnabled, enter_to_send_enabled: enterToSendEnabled,
} = uiSettings.value || {}; } = uiSettings.value || {};
if (!editorMessageKey) { if (!editorMessageKey) {
return key === (enterToSendEnabled ? 'enter' : 'cmd_enter'); return key === (enterToSendEnabled ? 'cmd_enter' : 'enter');
} }
return editorMessageKey === key; return editorMessageKey === key;
}; };

View File

@ -52,7 +52,8 @@
}, },
"UPLOADING_ATTACHMENTS": "Uploading attachments...", "UPLOADING_ATTACHMENTS": "Uploading attachments...",
"REPLIED_TO_STORY": "Replied to your story", "REPLIED_TO_STORY": "Replied to your story",
"UNSUPPORTED_MESSAGE": "This message is unsupported. You can view this message on the Facebook / Instagram app.", "UNSUPPORTED_MESSAGE": "This message is unsupported. You can view this message on the app.",
"UNSUPPORTED_MESSAGE_WHATSAPP": "This message is unsupported. You can view this message on the WhatsApp app.",
"UNSUPPORTED_MESSAGE_FACEBOOK": "This message is unsupported. You can view this message on the Facebook Messenger app.", "UNSUPPORTED_MESSAGE_FACEBOOK": "This message is unsupported. You can view this message on the Facebook Messenger app.",
"UNSUPPORTED_MESSAGE_INSTAGRAM": "This message is unsupported. You can view this message on the Instagram app.", "UNSUPPORTED_MESSAGE_INSTAGRAM": "This message is unsupported. You can view this message on the Instagram app.",
"SUCCESS_DELETE_MESSAGE": "Message deleted successfully", "SUCCESS_DELETE_MESSAGE": "Message deleted successfully",
@ -236,6 +237,13 @@
"SIDEBAR": { "SIDEBAR": {
"CONTACT": "Contact", "CONTACT": "Contact",
"COPILOT": "Copilot" "COPILOT": "Copilot"
},
"INBOX": {
"WHATSAPP_BAILEYS_PROVIDER_CONNECTION": {
"NOT_CONNECTED": "WhatsApp is not connected. Please link your device again.",
"NOT_CONNECTED_CONTACT_ADMIN": "WhatsApp is not connected. Please contact your administrator to link your device again.",
"LINK_DEVICE": "Link device"
}
} }
}, },
"EMAIL_TRANSCRIPT": { "EMAIL_TRANSCRIPT": {

View File

@ -224,7 +224,8 @@
"LABEL": "API Provider", "LABEL": "API Provider",
"TWILIO": "Twilio", "TWILIO": "Twilio",
"WHATSAPP_CLOUD": "WhatsApp Cloud", "WHATSAPP_CLOUD": "WhatsApp Cloud",
"360_DIALOG": "360Dialog" "360_DIALOG": "360Dialog",
"BAILEYS": "Baileys"
}, },
"INBOX_NAME": { "INBOX_NAME": {
"LABEL": "Inbox Name", "LABEL": "Inbox Name",
@ -263,6 +264,25 @@
"WEBHOOK_URL": "Webhook URL", "WEBHOOK_URL": "Webhook URL",
"WEBHOOK_VERIFICATION_TOKEN": "Webhook Verification Token" "WEBHOOK_VERIFICATION_TOKEN": "Webhook Verification Token"
}, },
"PROVIDER_URL": {
"LABEL": "Provider URL",
"PLACEHOLDER": "If provider is not running locally, please provide the URL",
"ERROR":"Please enter a valid URL"
},
"ADVANCED_OPTIONS": "Advanced options",
"BAILEYS": {
"SUBTITLE": "Click below to setup the WhatsApp channel using Baileys.",
"LINK_BUTTON": "Link device",
"LINK_DEVICE_MODAL": {
"TITLE": "Link your device",
"SUBTITLE": "Scan the QR code to link your device. Make sure the phone number is correct before scanning.",
"LOADING_QRCODE": "Loading QR code...",
"RECONNECTING": "Connecting...",
"LINK_DEVICE": "Link device",
"DISCONNECT": "Disconnect",
"CONNECTED": "Your device has been connected successfully. You can now start sending and receiving messages."
}
},
"SUBMIT_BUTTON": "Create WhatsApp Channel", "SUBMIT_BUTTON": "Create WhatsApp Channel",
"API": { "API": {
"ERROR_MESSAGE": "We were not able to save the WhatsApp channel" "ERROR_MESSAGE": "We were not able to save the WhatsApp channel"
@ -531,6 +551,13 @@
"WHATSAPP_SECTION_UPDATE_BUTTON": "Update", "WHATSAPP_SECTION_UPDATE_BUTTON": "Update",
"WHATSAPP_WEBHOOK_TITLE": "Webhook Verification Token", "WHATSAPP_WEBHOOK_TITLE": "Webhook Verification Token",
"WHATSAPP_WEBHOOK_SUBHEADER": "This token is used to verify the authenticity of the webhook endpoint.", "WHATSAPP_WEBHOOK_SUBHEADER": "This token is used to verify the authenticity of the webhook endpoint.",
"WHATSAPP_MANAGE_PROVIDER_CONNECTION_TITLE": "Manage Provider Connection",
"WHATSAPP_MANAGE_PROVIDER_CONNECTION_SUBHEADER": "Link your device and manage the provider connection.",
"WHATSAPP_MANAGE_PROVIDER_CONNECTION_BUTTON": "Manage connection",
"WHATSAPP_PROVIDER_URL_TITLE": "Provider URL",
"WHATSAPP_PROVIDER_URL_SUBHEADER": "If the provider is not running locally, please provide the URL.",
"WHATSAPP_PROVIDER_URL_PLACEHOLDER": "Enter the provider URL",
"WHATSAPP_PROVIDER_URL_ERROR": "Please enter a valid URL",
"UPDATE_PRE_CHAT_FORM_SETTINGS": "Update Pre Chat Form Settings" "UPDATE_PRE_CHAT_FORM_SETTINGS": "Update Pre Chat Form Settings"
}, },
"HELP_CENTER": { "HELP_CENTER": {

View File

@ -46,6 +46,17 @@
"CONVERSATION_TYPING_OFF": "Conversation Typing Off" "CONVERSATION_TYPING_OFF": "Conversation Typing Off"
} }
}, },
"NAME": {
"LABEL": "Webhook Name",
"PLACEHOLDER": "Enter the name of the webhook"
},
"INBOX": {
"LABEL": "Inbox",
"TITLE": "Select the inbox",
"PLACEHOLDER": "All Inboxes",
"NO_RESULTS": "No inboxes found",
"INPUT_PLACEHOLDER": "Search inbox"
},
"END_POINT": { "END_POINT": {
"LABEL": "Webhook URL", "LABEL": "Webhook URL",
"PLACEHOLDER": "Example: {webhookExampleURL}", "PLACEHOLDER": "Example: {webhookExampleURL}",

View File

@ -52,7 +52,8 @@
}, },
"UPLOADING_ATTACHMENTS": "Enviando anexos...", "UPLOADING_ATTACHMENTS": "Enviando anexos...",
"REPLIED_TO_STORY": "Respondido ao seu story", "REPLIED_TO_STORY": "Respondido ao seu story",
"UNSUPPORTED_MESSAGE": "Esta mensagem não é suportada. Você pode ver esta mensagem no aplicativo Facebook Messenger.", "UNSUPPORTED_MESSAGE": "Esta mensagem não é suportada. Você pode ver esta mensagem no aplicativo.",
"UNSUPPORTED_MESSAGE_WHATSAPP": "Esta mensagem não é suportada. Você pode ver esta mensagem no aplicativo do WhatsApp.",
"UNSUPPORTED_MESSAGE_FACEBOOK": "Esta mensagem não é suportada. Você pode ver esta mensagem no aplicativo Facebook Messenger.", "UNSUPPORTED_MESSAGE_FACEBOOK": "Esta mensagem não é suportada. Você pode ver esta mensagem no aplicativo Facebook Messenger.",
"UNSUPPORTED_MESSAGE_INSTAGRAM": "Esta mensagem não é suportada. Você pode ver esta mensagem no aplicativo do Instagram.", "UNSUPPORTED_MESSAGE_INSTAGRAM": "Esta mensagem não é suportada. Você pode ver esta mensagem no aplicativo do Instagram.",
"SUCCESS_DELETE_MESSAGE": "Mensagem excluída com sucesso", "SUCCESS_DELETE_MESSAGE": "Mensagem excluída com sucesso",
@ -236,6 +237,13 @@
"SIDEBAR": { "SIDEBAR": {
"CONTACT": "Contatos", "CONTACT": "Contatos",
"COPILOT": "Copiloto" "COPILOT": "Copiloto"
},
"INBOX": {
"WHATSAPP_BAILEYS_PROVIDER_CONNECTION": {
"NOT_CONNECTED": "O WhatsApp não está conectado. Por favor conecte o seu dispositivo novamente.",
"NOT_CONNECTED_CONTACT_ADMIN": "O WhatsApp não está conectado. Por favor contate o seu administrador para conectar o dispositivo novamente.",
"LINK_DEVICE": "Conectar dispositivo"
}
} }
}, },
"EMAIL_TRANSCRIPT": { "EMAIL_TRANSCRIPT": {

View File

@ -224,7 +224,8 @@
"LABEL": "Provedor de API", "LABEL": "Provedor de API",
"TWILIO": "Twilio", "TWILIO": "Twilio",
"WHATSAPP_CLOUD": "Cloud do WhatsApp", "WHATSAPP_CLOUD": "Cloud do WhatsApp",
"360_DIALOG": "360Dialog" "360_DIALOG": "360Dialog",
"BAILEYS": "Baileys"
}, },
"INBOX_NAME": { "INBOX_NAME": {
"LABEL": "Nome da Caixa de Entrada", "LABEL": "Nome da Caixa de Entrada",
@ -263,6 +264,25 @@
"WEBHOOK_URL": "URL do Webhook", "WEBHOOK_URL": "URL do Webhook",
"WEBHOOK_VERIFICATION_TOKEN": "Token de verificação Webhook" "WEBHOOK_VERIFICATION_TOKEN": "Token de verificação Webhook"
}, },
"PROVIDER_URL": {
"LABEL": "URL do provedor",
"PLACEHOLDER": "Se o provedor não está rodando localmente, por favor, insira a URL do provedor",
"ERROR":"Por favor, insira uma URL válida"
},
"ADVANCED_OPTIONS": "Opções avançadas",
"BAILEYS": {
"SUBTITLE": "Clique abaixo para configurar o canal do WhatsApp usando o Baileys.",
"LINK_BUTTON": "Conectar dispositivo",
"LINK_DEVICE_MODAL": {
"TITLE": "Conecte o seu dispositivo",
"SUBTITLE": "Escaneie o QR code para conectar seu dispositivo. Certifique-se de que o número de telefone esteja correto antes de escanear.",
"LOADING_QRCODE": "Carregando QR code...",
"RECONNECTING": "Conectando...",
"LINK_DEVICE": "Conectar dispositivo",
"DISCONNECT": "Desconectar",
"CONNECTED": "Seu dispositivo foi conectado com sucesso. Agora você pode começar a enviar e receber mensagens."
}
},
"SUBMIT_BUTTON": "Criar canal do WhatsApp", "SUBMIT_BUTTON": "Criar canal do WhatsApp",
"API": { "API": {
"ERROR_MESSAGE": "Não foi possível salvar o canal do WhatsApp" "ERROR_MESSAGE": "Não foi possível salvar o canal do WhatsApp"
@ -531,6 +551,13 @@
"WHATSAPP_SECTION_UPDATE_BUTTON": "Atualizar", "WHATSAPP_SECTION_UPDATE_BUTTON": "Atualizar",
"WHATSAPP_WEBHOOK_TITLE": "Token de verificação Webhook", "WHATSAPP_WEBHOOK_TITLE": "Token de verificação Webhook",
"WHATSAPP_WEBHOOK_SUBHEADER": "Este token é usado para verificar a autenticidade do webhook endpoint.", "WHATSAPP_WEBHOOK_SUBHEADER": "Este token é usado para verificar a autenticidade do webhook endpoint.",
"WHATSAPP_MANAGE_PROVIDER_CONNECTION_TITLE": "Gerenciar Conexão do Provedor",
"WHATSAPP_MANAGE_PROVIDER_CONNECTION_SUBHEADER": "Conecte o seu dispositivo e gerencie a conexão do provedor.",
"WHATSAPP_MANAGE_PROVIDER_CONNECTION_BUTTON": "Gerenciar conexão",
"WHATSAPP_PROVIDER_URL_TITLE": "URL do provedor",
"WHATSAPP_PROVIDER_URL_SUBHEADER": "Se o provedor não estiver rodando localmente, por favor, forneça a URL.",
"WHATSAPP_PROVIDER_URL_PLACEHOLDER": "Digite a URL do provedor",
"WHATSAPP_PROVIDER_URL_ERROR": "Por favor, insira uma URL válida",
"UPDATE_PRE_CHAT_FORM_SETTINGS": "Atualizar configurações do Formulário Pre Chat" "UPDATE_PRE_CHAT_FORM_SETTINGS": "Atualizar configurações do Formulário Pre Chat"
}, },
"HELP_CENTER": { "HELP_CENTER": {

View File

@ -46,6 +46,17 @@
"CONVERSATION_TYPING_OFF": "Status de Digitação desativado" "CONVERSATION_TYPING_OFF": "Status de Digitação desativado"
} }
}, },
"NAME": {
"LABEL": "Nome do Webhook",
"PLACEHOLDER": "Insira o nome do webhook"
},
"INBOX": {
"LABEL": "Caixa de Entrada",
"TITLE": "Selecione a caixa de entrada",
"PLACEHOLDER": "Todas as caixas de entrada",
"NO_RESULTS": "Nenhuma caixa de entrada encontrada",
"INPUT_PLACEHOLDER": "Buscar caixa de entrada"
},
"END_POINT": { "END_POINT": {
"LABEL": "URL do Webhook", "LABEL": "URL do Webhook",
"PLACEHOLDER": "Exemplo: {webhookExampleURL}", "PLACEHOLDER": "Exemplo: {webhookExampleURL}",

View File

@ -3,11 +3,19 @@ import EmptyState from '../../../../components/widgets/EmptyState.vue';
import NextButton from 'dashboard/components-next/button/Button.vue'; import NextButton from 'dashboard/components-next/button/Button.vue';
import DuplicateInboxBanner from './channels/instagram/DuplicateInboxBanner.vue'; import DuplicateInboxBanner from './channels/instagram/DuplicateInboxBanner.vue';
import { INBOX_TYPES } from 'dashboard/helper/inbox'; import { INBOX_TYPES } from 'dashboard/helper/inbox';
import WhatsappBaileysLinkDeviceModal from './components/WhatsappBaileysLinkDeviceModal.vue';
export default { export default {
components: { components: {
EmptyState, EmptyState,
NextButton, NextButton,
DuplicateInboxBanner, DuplicateInboxBanner,
WhatsappBaileysLinkDeviceModal,
},
data() {
return {
showBaileysLinkDeviceModal: false,
};
}, },
computed: { computed: {
currentInbox() { currentInbox() {
@ -47,6 +55,12 @@ export default {
this.currentInbox.provider === 'whatsapp_cloud' this.currentInbox.provider === 'whatsapp_cloud'
); );
}, },
isWhatsAppBaileysInbox() {
return (
this.currentInbox.channel_type === 'Channel::Whatsapp' &&
this.currentInbox.provider === 'baileys'
);
},
message() { message() {
if (this.isATwilioInbox) { if (this.isATwilioInbox) {
return `${this.$t('INBOX_MGMT.FINISH.MESSAGE')}. ${this.$t( return `${this.$t('INBOX_MGMT.FINISH.MESSAGE')}. ${this.$t(
@ -72,6 +86,12 @@ export default {
)}`; )}`;
} }
if (this.isWhatsAppBaileysInbox) {
return `${this.$t('INBOX_MGMT.FINISH.MESSAGE')}. ${this.$t(
'INBOX_MGMT.ADD.WHATSAPP.BAILEYS.SUBTITLE'
)}`;
}
if (this.isAEmailInbox && !this.currentInbox.provider) { if (this.isAEmailInbox && !this.currentInbox.provider) {
return this.$t('INBOX_MGMT.ADD.EMAIL_CHANNEL.FINISH_MESSAGE'); return this.$t('INBOX_MGMT.ADD.EMAIL_CHANNEL.FINISH_MESSAGE');
} }
@ -83,6 +103,14 @@ export default {
return this.$t('INBOX_MGMT.FINISH.MESSAGE'); return this.$t('INBOX_MGMT.FINISH.MESSAGE');
}, },
}, },
methods: {
onOpenBaileysLinkDeviceModal() {
this.showBaileysLinkDeviceModal = true;
},
onCloseBaileysLinkDeviceModal() {
this.showBaileysLinkDeviceModal = false;
},
},
}; };
</script> </script>
@ -130,6 +158,11 @@ export default {
:script="currentInbox.provider_config.webhook_verify_token" :script="currentInbox.provider_config.webhook_verify_token"
/> />
</div> </div>
<div v-if="isWhatsAppBaileysInbox" class="w-[50%] max-w-[50%] ml-[25%]">
<NextButton @click="onOpenBaileysLinkDeviceModal">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.BAILEYS.LINK_BUTTON') }}
</NextButton>
</div>
<div class="w-[50%] max-w-[50%] ml-[25%]"> <div class="w-[50%] max-w-[50%] ml-[25%]">
<woot-code <woot-code
v-if="isALineInbox" v-if="isALineInbox"
@ -178,5 +211,12 @@ export default {
</div> </div>
</div> </div>
</EmptyState> </EmptyState>
<WhatsappBaileysLinkDeviceModal
v-if="showBaileysLinkDeviceModal"
:show="showBaileysLinkDeviceModal"
:on-close="onCloseBaileysLinkDeviceModal"
:inbox="currentInbox"
is-setup
/>
</div> </div>
</template> </template>

View File

@ -92,6 +92,9 @@ export default {
if (this.isATwilioWhatsAppChannel) { if (this.isATwilioWhatsAppChannel) {
return this.$t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.TWILIO'); return this.$t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.TWILIO');
} }
if (this.isAWhatsAppBaileysChannel) {
return this.$t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.BAILEYS');
}
return ''; return '';
}, },
tabs() { tabs() {

View File

@ -0,0 +1,174 @@
<script>
import { mapGetters } from 'vuex';
import { useVuelidate } from '@vuelidate/core';
import { useAlert } from 'dashboard/composables';
import { required, requiredIf } from '@vuelidate/validators';
import router from '../../../../index';
import { isPhoneE164OrEmpty } from 'shared/helpers/Validators';
import { isValidURL } from '../../../../../helper/URLHelper';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
NextButton,
},
setup() {
return { v$: useVuelidate() };
},
data() {
return {
inboxName: '',
phoneNumber: '',
apiKey: '',
providerUrl: '',
showAdvancedOptions: false,
};
},
computed: {
...mapGetters({ uiFlags: 'inboxes/getUIFlags' }),
},
validations() {
return {
inboxName: { required },
phoneNumber: { required, isPhoneE164OrEmpty },
providerUrl: {
isValidURL: value => !value || isValidURL(value),
requiredIf: requiredIf(this.apiKey),
},
apiKey: { requiredIf: requiredIf(this.providerUrl) },
};
},
methods: {
async createChannel() {
this.v$.$touch();
if (this.v$.$invalid) {
return;
}
try {
const whatsappChannel = await this.$store.dispatch(
'inboxes/createChannel',
{
name: this.inboxName,
channel: {
type: 'whatsapp',
phone_number: this.phoneNumber,
provider: 'baileys',
provider_config:
this.apiKey || this.providerUrl
? {
api_key: this.apiKey,
url: this.providerUrl,
}
: {},
},
}
);
router.replace({
name: 'settings_inboxes_add_agents',
params: {
page: 'new',
inbox_id: whatsappChannel.id,
},
});
} catch (error) {
useAlert(
error.message || this.$t('INBOX_MGMT.ADD.WHATSAPP.API.ERROR_MESSAGE')
);
}
},
setShowAdvancedOptions() {
this.showAdvancedOptions = true;
},
},
};
</script>
<template>
<form class="flex flex-wrap mx-0" @submit.prevent="createChannel()">
<div class="w-[65%] flex-shrink-0 flex-grow-0 max-w-[65%]">
<label :class="{ error: v$.inboxName.$error }">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.INBOX_NAME.LABEL') }}
<input
v-model="inboxName"
type="text"
:placeholder="$t('INBOX_MGMT.ADD.WHATSAPP.INBOX_NAME.PLACEHOLDER')"
@blur="v$.inboxName.$touch"
/>
<span v-if="v$.inboxName.$error" class="message">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.INBOX_NAME.ERROR') }}
</span>
</label>
</div>
<div class="w-[65%] flex-shrink-0 flex-grow-0 max-w-[65%]">
<label :class="{ error: v$.phoneNumber.$error }">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PHONE_NUMBER.LABEL') }}
<input
v-model="phoneNumber"
type="text"
:placeholder="$t('INBOX_MGMT.ADD.WHATSAPP.PHONE_NUMBER.PLACEHOLDER')"
@blur="v$.phoneNumber.$touch"
/>
<span v-if="v$.phoneNumber.$error" class="message">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PHONE_NUMBER.ERROR') }}
</span>
</label>
</div>
<div
v-if="!showAdvancedOptions"
class="w-[65%] flex-shrink-0 flex-grow-0 max-w-[65%] mb-4"
>
<NextButton icon="i-lucide-plus" sm link @click="setShowAdvancedOptions">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.ADVANCED_OPTIONS') }}
</NextButton>
</div>
<template v-else>
<div class="w-[65%] flex-shrink-0 flex-grow-0 max-w-[65%]">
<span class="text-sm text-gray-600">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.ADVANCED_OPTIONS') }}
</span>
<label :class="{ error: v$.providerUrl.$error }">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PROVIDER_URL.LABEL') }}
<input
v-model="providerUrl"
type="text"
:placeholder="
$t('INBOX_MGMT.ADD.WHATSAPP.PROVIDER_URL.PLACEHOLDER')
"
/>
<span v-if="v$.providerUrl.$error" class="message">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PROVIDER_URL.ERROR') }}
</span>
</label>
</div>
<div class="w-[65%] flex-shrink-0 flex-grow-0 max-w-[65%]">
<label :class="{ error: v$.apiKey.$error }">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.API_KEY.LABEL') }}
<input
v-model="apiKey"
type="text"
:placeholder="$t('INBOX_MGMT.ADD.WHATSAPP.API_KEY.PLACEHOLDER')"
/>
<span v-if="v$.apiKey.$error" class="message">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.API_KEY.ERROR') }}
</span>
</label>
</div>
</template>
<div class="w-full">
<NextButton
:is-loading="uiFlags.isCreating"
type="submit"
solid
blue
:label="$t('INBOX_MGMT.ADD.WHATSAPP.SUBMIT_BUTTON')"
/>
</div>
</form>
</template>

View File

@ -3,6 +3,7 @@ import PageHeader from '../../SettingsSubPageHeader.vue';
import Twilio from './Twilio.vue'; import Twilio from './Twilio.vue';
import ThreeSixtyDialogWhatsapp from './360DialogWhatsapp.vue'; import ThreeSixtyDialogWhatsapp from './360DialogWhatsapp.vue';
import CloudWhatsapp from './CloudWhatsapp.vue'; import CloudWhatsapp from './CloudWhatsapp.vue';
import BaileysWhatsapp from './BaileysWhatsapp.vue';
export default { export default {
components: { components: {
@ -10,6 +11,7 @@ export default {
Twilio, Twilio,
ThreeSixtyDialogWhatsapp, ThreeSixtyDialogWhatsapp,
CloudWhatsapp, CloudWhatsapp,
BaileysWhatsapp,
}, },
data() { data() {
return { return {
@ -37,12 +39,16 @@ export default {
<option value="twilio"> <option value="twilio">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.TWILIO') }} {{ $t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.TWILIO') }}
</option> </option>
<option value="baileys">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.BAILEYS') }}
</option>
</select> </select>
</label> </label>
</div> </div>
<Twilio v-if="provider === 'twilio'" type="whatsapp" /> <Twilio v-if="provider === 'twilio'" type="whatsapp" />
<ThreeSixtyDialogWhatsapp v-else-if="provider === '360dialog'" /> <ThreeSixtyDialogWhatsapp v-else-if="provider === '360dialog'" />
<CloudWhatsapp v-else /> <CloudWhatsapp v-if="provider === 'whatsapp_cloud'" />
<BaileysWhatsapp v-if="provider === 'baileys'" />
</div> </div>
</template> </template>

View File

@ -0,0 +1,161 @@
<script setup>
import { onMounted, computed, onUnmounted, ref, watchEffect } from 'vue';
import { useStore } from 'vuex';
import { useAlert } from 'dashboard/composables';
import InboxName from 'dashboard/components/widgets/InboxName.vue';
import Spinner from 'shared/components/Spinner.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
show: { type: Boolean, required: true },
onClose: { type: Function, required: true },
isSetup: { type: Boolean, required: false },
inbox: {
type: Object,
required: true,
},
});
const store = useStore();
const providerConnection = computed(() => props.inbox.provider_connection);
const connection = computed(() => providerConnection.value?.connection);
const qrDataUrl = computed(() => providerConnection.value?.qr_data_url);
const error = computed(() => providerConnection.value?.error);
const loading = ref(false);
const handleError = e => {
useAlert(e.message);
loading.value = false;
};
const setup = () => {
loading.value = true;
store
.dispatch('inboxes/setupChannelProvider', props.inbox.id)
.catch(handleError);
};
const disconnect = () => {
loading.value = true;
store
.dispatch('inboxes/disconnectChannelProvider', props.inbox.id)
.catch(handleError);
};
onMounted(() => {
if (!connection.value || connection.value === 'close') {
setup();
}
});
onUnmounted(() => {
if (connection.value === 'connecting') {
disconnect();
}
});
watchEffect(() => {
if (connection.value) {
loading.value = false;
}
});
</script>
<template>
<woot-modal :show="show" size="small" @close="onClose">
<div class="flex flex-col h-auto overflow-auto">
<woot-modal-header
:header-title="
$t('INBOX_MGMT.ADD.WHATSAPP.BAILEYS.LINK_DEVICE_MODAL.TITLE')
"
:header-content="
$t('INBOX_MGMT.ADD.WHATSAPP.BAILEYS.LINK_DEVICE_MODAL.SUBTITLE')
"
/>
<div class="flex flex-col gap-4 p-8 pt-4">
<div class="flex flex-col gap-4 items-center">
<InboxName
:inbox="inbox"
class="!text-lg"
with-phone-number
with-provider-connection-status
/>
<template v-if="!connection || connection === 'close' || error">
<p v-if="error" class="text-red-500 text-center">
{{ error }}
</p>
<Button :is-loading="loading" @click="setup">
{{
$t(
'INBOX_MGMT.ADD.WHATSAPP.BAILEYS.LINK_DEVICE_MODAL.LINK_DEVICE'
)
}}
</Button>
</template>
<template v-else-if="connection === 'connecting'">
<div v-if="!qrDataUrl" class="flex flex-col gap-4 items-center">
<p>
{{
$t(
'INBOX_MGMT.ADD.WHATSAPP.BAILEYS.LINK_DEVICE_MODAL.LOADING_QRCODE'
)
}}
</p>
<Spinner />
</div>
<img
v-else
:src="qrDataUrl"
alt="QR Code"
class="w-[276px] h-[276px]"
/>
</template>
<template v-else-if="connection === 'reconnecting'">
<p>
{{
$t(
'INBOX_MGMT.ADD.WHATSAPP.BAILEYS.LINK_DEVICE_MODAL.RECONNECTING'
)
}}
</p>
<Spinner />
</template>
<template v-else-if="connection === 'open'">
<p v-if="isSetup" class="text-center">
{{
$t(
'INBOX_MGMT.ADD.WHATSAPP.BAILEYS.LINK_DEVICE_MODAL.CONNECTED'
)
}}
</p>
<div class="flex gap-2">
<Button ghost :is-loading="loading" @click="disconnect">
{{
$t(
'INBOX_MGMT.ADD.WHATSAPP.BAILEYS.LINK_DEVICE_MODAL.DISCONNECT'
)
}}
</Button>
<router-link
v-if="isSetup"
:to="{
name: 'inbox_dashboard',
params: { inboxId: inbox.id },
}"
>
<Button
solid
teal
:label="$t('INBOX_MGMT.FINISH.BUTTON_TEXT')"
/>
</router-link>
</div>
</template>
</div>
</div>
</div>
</woot-modal>
</template>

View File

@ -5,8 +5,11 @@ import SettingsSection from '../../../../../components/SettingsSection.vue';
import ImapSettings from '../ImapSettings.vue'; import ImapSettings from '../ImapSettings.vue';
import SmtpSettings from '../SmtpSettings.vue'; import SmtpSettings from '../SmtpSettings.vue';
import { useVuelidate } from '@vuelidate/core'; import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import NextButton from 'dashboard/components-next/button/Button.vue'; import NextButton from 'dashboard/components-next/button/Button.vue';
import { requiredIf } from '@vuelidate/validators';
import { isValidURL } from '../../../../../helper/URLHelper';
import WhatsappBaileysLinkDeviceModal from '../components/WhatsappBaileysLinkDeviceModal.vue';
import InboxName from '../../../../../components/widgets/InboxName.vue';
export default { export default {
components: { components: {
@ -14,6 +17,8 @@ export default {
ImapSettings, ImapSettings,
SmtpSettings, SmtpSettings,
NextButton, NextButton,
WhatsappBaileysLinkDeviceModal,
InboxName,
}, },
mixins: [inboxMixin], mixins: [inboxMixin],
props: { props: {
@ -29,10 +34,17 @@ export default {
return { return {
hmacMandatory: false, hmacMandatory: false,
whatsAppInboxAPIKey: '', whatsAppInboxAPIKey: '',
whatsAppProviderUrl: '',
showBaileysLinkDeviceModal: false,
}; };
}, },
validations: { validations() {
whatsAppInboxAPIKey: { required }, return {
whatsAppInboxAPIKey: {
requiredIf: requiredIf(!this.isAWhatsAppBaileysChannel),
},
whatsAppProviderUrl: { isValidURL: value => !value || isValidURL(value) },
};
}, },
watch: { watch: {
inbox() { inbox() {
@ -83,6 +95,31 @@ export default {
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE')); useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
} }
}, },
async updateWhatsAppProviderUrl() {
try {
const payload = {
id: this.inbox.id,
formData: false,
channel: {
provider_config: {
...this.inbox.provider_config,
provider_url: this.whatsAppProviderUrl,
},
},
};
await this.$store.dispatch('inboxes/updateInbox', payload);
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
}
},
onOpenBaileysLinkDeviceModal() {
this.showBaileysLinkDeviceModal = true;
},
onCloseBaileysLinkDeviceModal() {
this.showBaileysLinkDeviceModal = false;
},
}, },
}; };
</script> </script>
@ -194,8 +231,8 @@ export default {
<ImapSettings :inbox="inbox" /> <ImapSettings :inbox="inbox" />
<SmtpSettings v-if="inbox.imap_enabled" :inbox="inbox" /> <SmtpSettings v-if="inbox.imap_enabled" :inbox="inbox" />
</div> </div>
<div v-else-if="isAWhatsAppChannel && !isATwilioChannel"> <div v-else-if="isAWhatsAppCloudChannel && inbox.provider_config">
<div v-if="inbox.provider_config" class="mx-8"> <div class="mx-8">
<SettingsSection <SettingsSection
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_WEBHOOK_TITLE')" :title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_WEBHOOK_TITLE')"
:sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_WEBHOOK_SUBHEADER')" :sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_WEBHOOK_SUBHEADER')"
@ -237,6 +274,117 @@ export default {
</SettingsSection> </SettingsSection>
</div> </div>
</div> </div>
<div v-else-if="isAWhatsAppBaileysChannel">
<WhatsappBaileysLinkDeviceModal
v-if="showBaileysLinkDeviceModal"
:show="showBaileysLinkDeviceModal"
:on-close="onCloseBaileysLinkDeviceModal"
:inbox="inbox"
/>
<div class="mx-8">
<SettingsSection
:title="
$t(
'INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANAGE_PROVIDER_CONNECTION_TITLE'
)
"
:sub-title="
$t(
'INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANAGE_PROVIDER_CONNECTION_SUBHEADER'
)
"
>
<div class="flex flex-col gap-2">
<InboxName
:inbox="inbox"
class="!text-lg !m-0"
with-phone-number
with-provider-connection-status
/>
<NextButton class="w-fit" @click="onOpenBaileysLinkDeviceModal">
{{
$t(
'INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANAGE_PROVIDER_CONNECTION_BUTTON'
)
}}
</NextButton>
</div>
</SettingsSection>
<SettingsSection
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_PROVIDER_URL_TITLE')"
:sub-title="
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_PROVIDER_URL_SUBHEADER')
"
>
<div
class="flex items-center justify-between flex-1 mt-2 whatsapp-settings--content"
>
<woot-input
v-model="whatsAppProviderUrl"
type="text"
class="flex-1 mr-2 items-center"
:placeholder="
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_PROVIDER_URL_PLACEHOLDER')
"
@keydown="v$.whatsAppProviderUrl.$touch"
/>
<NextButton
:disabled="
v$.whatsAppProviderUrl.$invalid ||
whatsAppProviderUrl === inbox.provider_config.provider_url
"
@click="updateWhatsAppProviderUrl"
>
{{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_UPDATE_BUTTON') }}
</NextButton>
</div>
<span v-if="v$.whatsAppProviderUrl.$error" class="text-red-400">
{{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_PROVIDER_URL_ERROR') }}
</span>
</SettingsSection>
<template v-if="inbox.provider_config.api_key">
<SettingsSection
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_TITLE')"
:sub-title="
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_SUBHEADER')
"
>
<woot-code :script="inbox.provider_config.api_key" />
</SettingsSection>
</template>
<SettingsSection
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_UPDATE_TITLE')"
:sub-title="
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_UPDATE_SUBHEADER')
"
>
<div
class="flex items-center justify-between flex-1 mt-2 whatsapp-settings--content"
>
<woot-input
v-model="whatsAppInboxAPIKey"
type="text"
class="flex-1 mr-2"
:placeholder="
$t(
'INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_UPDATE_PLACEHOLDER'
)
"
/>
<NextButton
:disabled="
v$.whatsAppInboxAPIKey.$invalid ||
(!inbox.provider_config.api_key && !whatsAppInboxAPIKey) ||
whatsAppInboxAPIKey === inbox.provider_config.api_key
"
@click="updateWhatsAppInboxAPIKey"
>
{{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_UPDATE_BUTTON') }}
</NextButton>
</div>
</SettingsSection>
</div>
</div>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@ -53,6 +53,7 @@ export default {
:value="value" :value="value"
:is-submitting="uiFlags.updatingItem" :is-submitting="uiFlags.updatingItem"
:submit-label="$t('INTEGRATION_SETTINGS.WEBHOOK.FORM.EDIT_SUBMIT')" :submit-label="$t('INTEGRATION_SETTINGS.WEBHOOK.FORM.EDIT_SUBMIT')"
is-editing
@submit="onSubmit" @submit="onSubmit"
@cancel="onClose" @cancel="onClose"
/> />

View File

@ -4,6 +4,8 @@ import { required, url, minLength } from '@vuelidate/validators';
import wootConstants from 'dashboard/constants/globals'; import wootConstants from 'dashboard/constants/globals';
import { getI18nKey } from 'dashboard/routes/dashboard/settings/helper/settingsHelper'; import { getI18nKey } from 'dashboard/routes/dashboard/settings/helper/settingsHelper';
import NextButton from 'dashboard/components-next/button/Button.vue'; import NextButton from 'dashboard/components-next/button/Button.vue';
import MultiselectDropdown from 'shared/components/ui/MultiselectDropdown.vue';
import { useMapGetter } from 'dashboard/composables/store';
const { EXAMPLE_WEBHOOK_URL } = wootConstants; const { EXAMPLE_WEBHOOK_URL } = wootConstants;
@ -23,6 +25,7 @@ const SUPPORTED_WEBHOOK_EVENTS = [
export default { export default {
components: { components: {
NextButton, NextButton,
MultiselectDropdown,
}, },
props: { props: {
value: { value: {
@ -37,10 +40,17 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
isEditing: {
type: Boolean,
default: false,
},
}, },
emits: ['submit', 'cancel'], emits: ['submit', 'cancel'],
setup() { setup() {
return { v$: useVuelidate() }; return {
v$: useVuelidate(),
inboxes: useMapGetter('inboxes/getInboxes'),
};
}, },
validations: { validations: {
url: { url: {
@ -55,11 +65,27 @@ export default {
data() { data() {
return { return {
url: this.value.url || '', url: this.value.url || '',
assignedInbox: this.value.inbox || null,
name: this.value.name || '',
subscriptions: this.value.subscriptions || [], subscriptions: this.value.subscriptions || [],
supportedWebhookEvents: SUPPORTED_WEBHOOK_EVENTS, supportedWebhookEvents: SUPPORTED_WEBHOOK_EVENTS,
}; };
}, },
computed: { computed: {
inboxesList() {
if (this.assignedInbox?.id) {
return [
{
id: 0,
name: this.$t(
'INTEGRATION_SETTINGS.WEBHOOK.FORM.INBOX.PLACEHOLDER'
),
},
...this.inboxes,
];
}
return this.inboxes;
},
webhookURLInputPlaceholder() { webhookURLInputPlaceholder() {
return this.$t( return this.$t(
'INTEGRATION_SETTINGS.WEBHOOK.FORM.END_POINT.PLACEHOLDER', 'INTEGRATION_SETTINGS.WEBHOOK.FORM.END_POINT.PLACEHOLDER',
@ -68,14 +94,22 @@ export default {
} }
); );
}, },
webhookNameInputPlaceholder() {
return this.$t('INTEGRATION_SETTINGS.WEBHOOK.FORM.NAME.PLACEHOLDER');
},
}, },
methods: { methods: {
onSubmit() { onSubmit() {
this.$emit('submit', { this.$emit('submit', {
url: this.url, url: this.url,
inbox_id: this.assignedInbox?.id || null,
name: this.name,
subscriptions: this.subscriptions, subscriptions: this.subscriptions,
}); });
}, },
onClickAssignInbox(inbox) {
this.assignedInbox = inbox;
},
getI18nKey, getI18nKey,
}, },
}; };
@ -90,6 +124,7 @@ export default {
v-model="url" v-model="url"
type="text" type="text"
name="url" name="url"
:disabled="isEditing"
:placeholder="webhookURLInputPlaceholder" :placeholder="webhookURLInputPlaceholder"
@input="v$.url.$touch" @input="v$.url.$touch"
/> />
@ -97,6 +132,38 @@ export default {
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.FORM.END_POINT.ERROR') }} {{ $t('INTEGRATION_SETTINGS.WEBHOOK.FORM.END_POINT.ERROR') }}
</span> </span>
</label> </label>
<label>
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.FORM.INBOX.LABEL') }}
<div class="multiselect-wrap--small">
<MultiselectDropdown
:options="inboxesList"
:selected-item="assignedInbox"
:multiselector-title="
$t('INTEGRATION_SETTINGS.WEBHOOK.FORM.INBOX.TITLE')
"
:multiselector-placeholder="
$t('INTEGRATION_SETTINGS.WEBHOOK.FORM.INBOX.PLACEHOLDER')
"
:no-search-result="
$t('INTEGRATION_SETTINGS.WEBHOOK.FORM.INBOX.NO_RESULTS')
"
:input-placeholder="
$t('INTEGRATION_SETTINGS.WEBHOOK.FORM.INBOX.INPUT_PLACEHOLDER')
"
:disabled="isEditing"
@select="onClickAssignInbox"
/>
</div>
</label>
<label>
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.FORM.NAME.LABEL') }}
<input
v-model="name"
type="text"
name="name"
:placeholder="webhookNameInputPlaceholder"
/>
</label>
<label :class="{ error: v$.url.$error }" class="mb-2"> <label :class="{ error: v$.url.$error }" class="mb-2">
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.FORM.SUBSCRIPTIONS.LABEL') }} {{ $t('INTEGRATION_SETTINGS.WEBHOOK.FORM.SUBSCRIPTIONS.LABEL') }}
</label> </label>
@ -127,7 +194,6 @@ export default {
</div> </div>
</div> </div>
</div> </div>
<div class="flex flex-row justify-end w-full gap-2 px-0 py-2"> <div class="flex flex-row justify-end w-full gap-2 px-0 py-2">
<NextButton <NextButton
faded faded

View File

@ -3,6 +3,7 @@ import { computed } from 'vue';
import { getI18nKey } from 'dashboard/routes/dashboard/settings/helper/settingsHelper'; import { getI18nKey } from 'dashboard/routes/dashboard/settings/helper/settingsHelper';
import ShowMore from 'dashboard/components/widgets/ShowMore.vue'; import ShowMore from 'dashboard/components/widgets/ShowMore.vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import InboxName from 'components/widgets/InboxName.vue';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
@ -37,8 +38,19 @@ const subscribedEvents = computed(() => {
<template> <template>
<tr> <tr>
<td class="py-4 ltr:pr-4 rtl:pl-4"> <td class="py-4 ltr:pr-4 rtl:pl-4">
<div class="font-medium break-words text-slate-700 dark:text-slate-100"> <InboxName v-if="webhook.inbox" class="!mx-0" :inbox="webhook.inbox" />
{{ webhook.url }} <div
class="flex gap-2 font-medium break-words text-slate-700 dark:text-slate-100"
>
<template v-if="webhook.name">
{{ webhook.name }}
<span class="text-slate-500 dark:text-slate-400">
{{ webhook.url }}
</span>
</template>
<template v-else>
{{ webhook.url }}
</template>
</div> </div>
<div class="block mt-1 text-sm text-slate-500 dark:text-slate-400"> <div class="block mt-1 text-sm text-slate-500 dark:text-slate-400">
<span class="font-medium"> <span class="font-medium">

View File

@ -282,6 +282,20 @@ export const actions = {
throw new Error(error); throw new Error(error);
} }
}, },
setupChannelProvider: async (_, inboxId) => {
try {
await InboxesAPI.setupChannelProvider(inboxId);
} catch (error) {
throwErrorMessage(error);
}
},
disconnectChannelProvider: async (_, inboxId) => {
try {
await InboxesAPI.disconnectChannelProvider(inboxId);
} catch (error) {
throwErrorMessage(error);
}
},
}; };
export const mutations = { export const mutations = {

View File

@ -285,5 +285,7 @@
"M9.60364 9.20645C9.60364 8.67008 10.0385 8.23523 10.5749 8.23523C11.1113 8.23523 11.5461 8.67008 11.5461 9.20645V11.4511C11.5461 11.9875 11.1113 12.4223 10.5749 12.4223C10.0385 12.4223 9.60364 11.9875 9.60364 11.4511V9.20645Z", "M9.60364 9.20645C9.60364 8.67008 10.0385 8.23523 10.5749 8.23523C11.1113 8.23523 11.5461 8.67008 11.5461 9.20645V11.4511C11.5461 11.9875 11.1113 12.4223 10.5749 12.4223C10.0385 12.4223 9.60364 11.9875 9.60364 11.4511V9.20645Z",
"M17.1442 5.57049C13.5275 5.06019 10.5793 5.04007 6.88135 5.56825C5.9466 5.70176 5.32812 5.79197 4.85654 5.92976C4.41928 6.05757 4.17061 6.20994 3.96492 6.43984C3.539 6.91583 3.48286 7.45419 3.4248 9.33184C3.36775 11.1772 3.48076 12.831 3.69481 14.6918C3.80887 15.6834 3.88736 16.3526 4.01268 16.8613C4.13155 17.3439 4.27532 17.6034 4.47513 17.802C4.67654 18.0023 4.93467 18.1435 5.40841 18.2581C5.90952 18.3793 6.56702 18.4526 7.5442 18.5592C10.7045 18.904 13.0702 18.9022 16.2423 18.561C17.2313 18.4546 17.8995 18.3813 18.4081 18.2609C18.8913 18.1465 19.1511 18.0063 19.3497 17.8118C19.5442 17.6213 19.6928 17.3587 19.8217 16.852C19.9561 16.3234 20.0476 15.624 20.18 14.5966C20.4162 12.7633 20.5863 11.1533 20.5929 9.3896C20.5999 7.50391 20.5613 6.96737 20.1306 6.46971C19.9226 6.22932 19.6696 6.0713 19.2224 5.93968C18.7395 5.79754 18.1042 5.70594 17.1442 5.57049ZM6.65555 3.98715C10.5078 3.43695 13.6072 3.45849 17.3674 3.98902L17.4224 3.99678C18.3127 4.12235 19.0648 4.22844 19.6733 4.40753C20.33 4.60078 20.8792 4.89417 21.3382 5.4245C22.2041 6.42482 22.1984 7.6117 22.1909 9.18858C22.1905 9.25686 22.1902 9.32584 22.19 9.3956C22.183 11.2604 22.0026 12.949 21.764 14.8006L21.7577 14.8496C21.6332 15.8159 21.5307 16.6121 21.3695 17.2458C21.2 17.9121 20.9467 18.4833 20.4672 18.9529C19.9919 19.4183 19.4302 19.6602 18.776 19.8151C18.1582 19.9613 17.3895 20.044 16.4629 20.1436L16.4131 20.149C13.1283 20.5023 10.6472 20.5043 7.37097 20.1469L7.32043 20.1414C6.40679 20.0417 5.64604 19.9587 5.03292 19.8104C4.38112 19.6527 3.82317 19.406 3.34911 18.9347C2.87346 18.4618 2.62363 17.8999 2.46191 17.2433C2.30938 16.6241 2.22071 15.8531 2.11393 14.9246L2.10815 14.8743C1.88863 12.9659 1.76823 11.23 1.82845 9.28246C1.83063 9.2118 1.83272 9.14191 1.83479 9.07281C1.8816 7.50776 1.91671 6.33374 2.7747 5.37486C3.22992 4.86612 3.76798 4.58399 4.40853 4.39678C5.00257 4.22316 5.73505 4.11858 6.60207 3.99479C6.61981 3.99225 6.63764 3.9897 6.65555 3.98715Z" "M17.1442 5.57049C13.5275 5.06019 10.5793 5.04007 6.88135 5.56825C5.9466 5.70176 5.32812 5.79197 4.85654 5.92976C4.41928 6.05757 4.17061 6.20994 3.96492 6.43984C3.539 6.91583 3.48286 7.45419 3.4248 9.33184C3.36775 11.1772 3.48076 12.831 3.69481 14.6918C3.80887 15.6834 3.88736 16.3526 4.01268 16.8613C4.13155 17.3439 4.27532 17.6034 4.47513 17.802C4.67654 18.0023 4.93467 18.1435 5.40841 18.2581C5.90952 18.3793 6.56702 18.4526 7.5442 18.5592C10.7045 18.904 13.0702 18.9022 16.2423 18.561C17.2313 18.4546 17.8995 18.3813 18.4081 18.2609C18.8913 18.1465 19.1511 18.0063 19.3497 17.8118C19.5442 17.6213 19.6928 17.3587 19.8217 16.852C19.9561 16.3234 20.0476 15.624 20.18 14.5966C20.4162 12.7633 20.5863 11.1533 20.5929 9.3896C20.5999 7.50391 20.5613 6.96737 20.1306 6.46971C19.9226 6.22932 19.6696 6.0713 19.2224 5.93968C18.7395 5.79754 18.1042 5.70594 17.1442 5.57049ZM6.65555 3.98715C10.5078 3.43695 13.6072 3.45849 17.3674 3.98902L17.4224 3.99678C18.3127 4.12235 19.0648 4.22844 19.6733 4.40753C20.33 4.60078 20.8792 4.89417 21.3382 5.4245C22.2041 6.42482 22.1984 7.6117 22.1909 9.18858C22.1905 9.25686 22.1902 9.32584 22.19 9.3956C22.183 11.2604 22.0026 12.949 21.764 14.8006L21.7577 14.8496C21.6332 15.8159 21.5307 16.6121 21.3695 17.2458C21.2 17.9121 20.9467 18.4833 20.4672 18.9529C19.9919 19.4183 19.4302 19.6602 18.776 19.8151C18.1582 19.9613 17.3895 20.044 16.4629 20.1436L16.4131 20.149C13.1283 20.5023 10.6472 20.5043 7.37097 20.1469L7.32043 20.1414C6.40679 20.0417 5.64604 19.9587 5.03292 19.8104C4.38112 19.6527 3.82317 19.406 3.34911 18.9347C2.87346 18.4618 2.62363 17.8999 2.46191 17.2433C2.30938 16.6241 2.22071 15.8531 2.11393 14.9246L2.10815 14.8743C1.88863 12.9659 1.76823 11.23 1.82845 9.28246C1.83063 9.2118 1.83272 9.14191 1.83479 9.07281C1.8816 7.50776 1.91671 6.33374 2.7747 5.37486C3.22992 4.86612 3.76798 4.58399 4.40853 4.39678C5.00257 4.22316 5.73505 4.11858 6.60207 3.99479C6.61981 3.99225 6.63764 3.9897 6.65555 3.98715Z"
], ],
"scan-person-outline": "M5.25 3.5A1.75 1.75 0 0 0 3.5 5.25v3a.75.75 0 0 1-1.5 0v-3A3.25 3.25 0 0 1 5.25 2h3a.75.75 0 0 1 0 1.5zm0 17a1.75 1.75 0 0 1-1.75-1.75v-3a.75.75 0 0 0-1.5 0v3A3.25 3.25 0 0 0 5.25 22h3a.75.75 0 0 0 .707-1l-.005-.015a.75.75 0 0 0-.702-.485zM20.5 5.25a1.75 1.75 0 0 0-1.75-1.75h-3a.75.75 0 0 1 0-1.5h3A3.25 3.25 0 0 1 22 5.25v3a.75.75 0 0 1-1.5 0zM18.75 20.5a1.75 1.75 0 0 0 1.75-1.75v-3a.75.75 0 0 1 1.5 0v3A3.25 3.25 0 0 1 18.75 22h-3a.75.75 0 0 1 0-1.5zM6.5 18.616q0 .465.258.884H5.25a1 1 0 0 1-.129-.011A3.1 3.1 0 0 1 5 18.616v-.366A2.25 2.25 0 0 1 7.25 16h9.5A2.25 2.25 0 0 1 19 18.25v.366c0 .31-.047.601-.132.875a1 1 0 0 1-.118.009h-1.543a1.56 1.56 0 0 0 .293-.884v-.366a.75.75 0 0 0-.75-.75h-9.5a.75.75 0 0 0-.75.75zm8.25-8.866a2.75 2.75 0 1 0-5.5 0a2.75 2.75 0 0 0 5.5 0m1.5 0a4.25 4.25 0 1 1-8.5 0a4.25 4.25 0 0 1 8.5 0" "scan-person-outline": "M5.25 3.5A1.75 1.75 0 0 0 3.5 5.25v3a.75.75 0 0 1-1.5 0v-3A3.25 3.25 0 0 1 5.25 2h3a.75.75 0 0 1 0 1.5zm0 17a1.75 1.75 0 0 1-1.75-1.75v-3a.75.75 0 0 0-1.5 0v3A3.25 3.25 0 0 0 5.25 22h3a.75.75 0 0 0 .707-1l-.005-.015a.75.75 0 0 0-.702-.485zM20.5 5.25a1.75 1.75 0 0 0-1.75-1.75h-3a.75.75 0 0 1 0-1.5h3A3.25 3.25 0 0 1 22 5.25v3a.75.75 0 0 1-1.5 0zM18.75 20.5a1.75 1.75 0 0 0 1.75-1.75v-3a.75.75 0 0 1 1.5 0v3A3.25 3.25 0 0 1 18.75 22h-3a.75.75 0 0 1 0-1.5zM6.5 18.616q0 .465.258.884H5.25a1 1 0 0 1-.129-.011A3.1 3.1 0 0 1 5 18.616v-.366A2.25 2.25 0 0 1 7.25 16h9.5A2.25 2.25 0 0 1 19 18.25v.366c0 .31-.047.601-.132.875a1 1 0 0 1-.118.009h-1.543a1.56 1.56 0 0 0 .293-.884v-.366a.75.75 0 0 0-.75-.75h-9.5a.75.75 0 0 0-.75.75zm8.25-8.866a2.75 2.75 0 1 0-5.5 0a2.75 2.75 0 0 0 5.5 0m1.5 0a4.25 4.25 0 1 1-8.5 0a4.25 4.25 0 0 1 8.5 0",
"circle-outline": "M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20Z",
"circle-filled": "M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z"
} }

View File

@ -144,7 +144,11 @@ export default {
this.resizeTextarea(); this.resizeTextarea();
}, },
onKeyup() { onKeyup() {
this.typingIndicator.start(); if (this.modelValue.length) {
this.typingIndicator.start();
} else {
this.typingIndicator.stop();
}
}, },
onBlur() { onBlur() {
this.typingIndicator.stop(); this.typingIndicator.stop();

View File

@ -36,6 +36,10 @@ const props = defineProps({
type: String, type: String,
default: 'Search', default: 'Search',
}, },
disabled: {
type: Boolean,
default: false,
},
}); });
const emit = defineEmits(['select']); const emit = defineEmits(['select']);
@ -66,6 +70,8 @@ const hasValue = computed(() => {
showSearchDropdown ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down' showSearchDropdown ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'
" "
class="w-full !px-2" class="w-full !px-2"
type="button"
:disabled="disabled"
@click=" @click="
() => toggleDropdown() // ensure that the event is not passed to the button () => toggleDropdown() // ensure that the event is not passed to the button
" "
@ -90,9 +96,11 @@ const hasValue = computed(() => {
:username="selectedItem.name" :username="selectedItem.name"
/> />
</Button> </Button>
<!-- NOTE: Without @click.prevent, the dropdown does not behave as expected when used inside a <label> tag. -->
<div <div
:class="{ 'dropdown-pane--open': showSearchDropdown }" :class="{ 'dropdown-pane--open': showSearchDropdown }"
class="dropdown-pane" class="dropdown-pane"
@click.prevent
> >
<div class="flex items-center justify-between mb-1"> <div class="flex items-center justify-between mb-1">
<h4 <h4

View File

@ -86,6 +86,12 @@ export default {
this.whatsAppAPIProvider === 'default' this.whatsAppAPIProvider === 'default'
); );
}, },
isAWhatsAppBaileysChannel() {
return (
this.channelType === INBOX_TYPES.WHATSAPP &&
this.whatsAppAPIProvider === 'baileys'
);
},
chatAdditionalAttributes() { chatAdditionalAttributes() {
const { additional_attributes: additionalAttributes } = this.chat || {}; const { additional_attributes: additionalAttributes } = this.chat || {};
return additionalAttributes || {}; return additionalAttributes || {};

View File

@ -269,6 +269,14 @@ describe('inboxMixin', () => {
expect(wrapper.vm.is360DialogWhatsAppChannel).toBe(true); expect(wrapper.vm.is360DialogWhatsAppChannel).toBe(true);
}); });
it('isAWhatsAppBaileysChannel returns true if channel type is WhatsApp and provider is baileys', () => {
const Component = getComponentConfigForInbox('Channel::Whatsapp', {
provider: 'baileys',
});
const wrapper = shallowMount(Component);
expect(wrapper.vm.isAWhatsAppBaileysChannel).toBe(true);
});
it('isAWhatsAppChannel returns true if channel type is WhatsApp', () => { it('isAWhatsAppChannel returns true if channel type is WhatsApp', () => {
const Component = getComponentConfigForInbox('Channel::Whatsapp'); const Component = getComponentConfigForInbox('Channel::Whatsapp');
const wrapper = shallowMount(Component); const wrapper = shallowMount(Component);

View File

@ -26,7 +26,7 @@ class BulkActionsJob < ApplicationJob
records.each do |conversation| records.each do |conversation|
bulk_add_labels(conversation) bulk_add_labels(conversation)
bulk_snoozed_until(conversation) bulk_snoozed_until(conversation)
conversation.update(params) if params conversation.update!(params) if params
end end
end end
@ -54,7 +54,7 @@ class BulkActionsJob < ApplicationJob
return unless @params[:labels] && @params[:labels][:remove] return unless @params[:labels] && @params[:labels][:remove]
labels = conversation.label_list - @params[:labels][:remove] labels = conversation.label_list - @params[:labels][:remove]
conversation.update(label_list: labels) conversation.update!(label_list: labels)
end end
def records_to_updated(ids) def records_to_updated(ids)

View File

@ -17,7 +17,7 @@ class Conversations::UserMentionJob < ApplicationJob
account_id: account_id account_id: account_id
) )
else else
mention.update(mentioned_at: Time.zone.now) mention.update!(mentioned_at: Time.zone.now)
end end
end end
end end

View File

@ -2,8 +2,7 @@ class Webhooks::WhatsappEventsJob < ApplicationJob
queue_as :low queue_as :low
def perform(params = {}) def perform(params = {})
channel = find_channel_from_whatsapp_business_payload(params) channel = find_channel(params)
if channel_is_inactive?(channel) if channel_is_inactive?(channel)
Rails.logger.warn("Inactive WhatsApp channel: #{channel&.phone_number || "unknown - #{params[:phone_number]}"}") Rails.logger.warn("Inactive WhatsApp channel: #{channel&.phone_number || "unknown - #{params[:phone_number]}"}")
return return
@ -12,6 +11,8 @@ class Webhooks::WhatsappEventsJob < ApplicationJob
case channel.provider case channel.provider
when 'whatsapp_cloud' when 'whatsapp_cloud'
Whatsapp::IncomingMessageWhatsappCloudService.new(inbox: channel.inbox, params: params).perform Whatsapp::IncomingMessageWhatsappCloudService.new(inbox: channel.inbox, params: params).perform
when 'baileys'
Whatsapp::IncomingMessageBaileysService.new(inbox: channel.inbox, params: params).perform
else else
Whatsapp::IncomingMessageService.new(inbox: channel.inbox, params: params).perform Whatsapp::IncomingMessageService.new(inbox: channel.inbox, params: params).perform
end end
@ -19,6 +20,14 @@ class Webhooks::WhatsappEventsJob < ApplicationJob
private private
def find_channel(params)
return find_channel_from_whatsapp_business_payload(params) if params[:object] == 'whatsapp_business_account'
return unless params[:phone_number]
Channel::Whatsapp.find_by(phone_number: params[:phone_number])
end
def channel_is_inactive?(channel) def channel_is_inactive?(channel)
return true if channel.blank? return true if channel.blank?
return true if channel.reauthorization_required? return true if channel.reauthorization_required?
@ -27,26 +36,14 @@ class Webhooks::WhatsappEventsJob < ApplicationJob
false false
end end
def find_channel_by_url_param(params)
return unless params[:phone_number]
Channel::Whatsapp.find_by(phone_number: params[:phone_number])
end
def find_channel_from_whatsapp_business_payload(params) def find_channel_from_whatsapp_business_payload(params)
# for the case where facebook cloud api support multiple numbers for a single app # for the case where facebook cloud api support multiple numbers for a single app
# https://github.com/chatwoot/chatwoot/issues/4712#issuecomment-1173838350 # https://github.com/chatwoot/chatwoot/issues/4712#issuecomment-1173838350
# we will give priority to the phone_number in the payload # we will give priority to the phone_number in the payload
return get_channel_from_wb_payload(params) if params[:object] == 'whatsapp_business_account' phone_number = "+#{params[:entry].first[:changes].first.dig(:value, :metadata, :display_phone_number)}"
phone_number_id = params[:entry].first[:changes].first.dig(:value, :metadata, :phone_number_id)
find_channel_by_url_param(params)
end
def get_channel_from_wb_payload(wb_params)
phone_number = "+#{wb_params[:entry].first[:changes].first.dig(:value, :metadata, :display_phone_number)}"
phone_number_id = wb_params[:entry].first[:changes].first.dig(:value, :metadata, :phone_number_id)
channel = Channel::Whatsapp.find_by(phone_number: phone_number) channel = Channel::Whatsapp.find_by(phone_number: phone_number)
# validate to ensure the phone number id matches the whatsapp channel # validate to ensure the phone number id matches the whatsapp channel
return channel if channel && channel.provider_config['phone_number_id'] == phone_number_id channel if channel && channel.provider_config['phone_number_id'] == phone_number_id
end end
end end

View File

@ -53,8 +53,8 @@ class AgentBotListener < BaseListener
private private
def connected_agent_bot_exist?(inbox) def connected_agent_bot_exist?(inbox)
return if inbox.agent_bot_inbox.blank? return false if inbox.agent_bot_inbox.blank?
return unless inbox.agent_bot_inbox.active? return false unless inbox.agent_bot_inbox.active?
true true
end end

View File

@ -70,7 +70,7 @@ class AutomationRuleListener < BaseListener
end end
def rule_present?(event_name, account) def rule_present?(event_name, account)
return if account.blank? return false if account.blank?
current_account_rules(event_name, account).any? current_account_rules(event_name, account).any?
end end

View File

@ -0,0 +1,49 @@
class ChannelListener < BaseListener
def conversation_typing_on(event)
handle_typing_event(event)
end
def conversation_recording(event)
handle_typing_event(event)
end
def conversation_typing_off(event)
handle_typing_event(event)
end
def account_presence_updated(event)
account_id, user_id, status = event.data.values_at(:account_id, :user_id, :status)
account = Account.find(account_id)
account.inboxes.joins(:inbox_members).where(inbox_members: { user_id: user_id }).find_each do |inbox|
next unless inbox.channel.respond_to?(:update_presence)
inbox.channel.update_presence(status)
end
end
def messages_read(event)
conversation, last_seen_at = event.data.values_at(:conversation, :last_seen_at)
channel = conversation.inbox.channel
return unless channel.respond_to?(:send_read_messages)
messages = conversation.messages.where(message_type: :incoming).where.not(status: :read)
messages = messages.where('updated_at > ?', last_seen_at) if last_seen_at.present?
channel.send_read_messages(messages, conversation: conversation) if messages.any?
end
private
def handle_typing_event(event)
is_private, conversation = event.data.values_at(:is_private, :conversation)
return if is_private
channel = conversation.inbox.channel
return unless channel.respond_to?(:toggle_typing_status)
channel.toggle_typing_status(event.name, conversation: conversation)
end
end

View File

@ -110,6 +110,7 @@ class WebhookListener < BaseListener
def deliver_account_webhooks(payload, account) def deliver_account_webhooks(payload, account)
account.webhooks.account_type.each do |webhook| account.webhooks.account_type.each do |webhook|
next unless webhook.subscriptions.include?(payload[:event]) next unless webhook.subscriptions.include?(payload[:event])
next if payload[:inbox].present? && webhook.inbox_id.present? && webhook.inbox_id != payload[:inbox][:id]
WebhookJob.perform_later(webhook.url, payload) WebhookJob.perform_later(webhook.url, payload)
end end

View File

@ -39,7 +39,7 @@ class ConversationReplyMailer < ApplicationMailer
init_conversation_attributes(message.conversation) init_conversation_attributes(message.conversation)
@message = message @message = message
reply_mail_object = prepare_mail(true) reply_mail_object = prepare_mail(true)
message.update(source_id: reply_mail_object.message_id) message.update!(source_id: reply_mail_object.message_id)
end end
def conversation_transcript(conversation, to_email) def conversation_transcript(conversation, to_email)
@ -76,8 +76,8 @@ class ConversationReplyMailer < ApplicationMailer
def conversation_already_viewed? def conversation_already_viewed?
# whether contact already saw the message on widget # whether contact already saw the message on widget
return unless @conversation.contact_last_seen_at return false unless @conversation.contact_last_seen_at
return unless last_outgoing_message&.created_at return false unless last_outgoing_message&.created_at
@conversation.contact_last_seen_at > last_outgoing_message&.created_at @conversation.contact_last_seen_at > last_outgoing_message&.created_at
end end

View File

@ -108,7 +108,7 @@ class Article < ApplicationRecord
root_article_id = self.class.find_root_article_id(article) root_article_id = self.class.find_root_article_id(article)
update(associated_article_id: root_article_id) if root_article_id.present? update!(associated_article_id: root_article_id) if root_article_id.present?
end end
# Make sure we always associate the parent's associated id to avoid the deeper associations od articles. # Make sure we always associate the parent's associated id to avoid the deeper associations od articles.

View File

@ -111,7 +111,8 @@ class Attachment < ApplicationRecord
id: id, id: id,
message_id: message_id, message_id: message_id,
file_type: file_type, file_type: file_type,
account_id: account_id account_id: account_id,
meta: meta || {}
} }
end end
@ -123,9 +124,9 @@ class Attachment < ApplicationRecord
end end
def should_validate_file? def should_validate_file?
return unless file.attached? return false unless file.attached?
# we are only limiting attachment types in case of website widget # we are only limiting attachment types in case of website widget
return unless message.inbox.channel_type == 'Channel::WebWidget' return false unless message.inbox.channel_type == 'Channel::WebWidget'
true true
end end

View File

@ -50,7 +50,7 @@ class Channel::TwilioSms < ApplicationRecord
params = send_message_from.merge(to: to, body: body) params = send_message_from.merge(to: to, body: body)
params[:media_url] = media_url if media_url.present? params[:media_url] = media_url if media_url.present?
params[:status_callback] = twilio_delivery_status_index_url params[:status_callback] = twilio_delivery_status_index_url
client.messages.create(**params) client.messages.create!(**params)
end end
private private

View File

@ -8,6 +8,7 @@
# phone_number :string not null # phone_number :string not null
# provider :string default("default") # provider :string default("default")
# provider_config :jsonb # provider_config :jsonb
# provider_connection :jsonb
# created_at :datetime not null # created_at :datetime not null
# updated_at :datetime not null # updated_at :datetime not null
# account_id :integer not null # account_id :integer not null
@ -25,33 +26,79 @@ class Channel::Whatsapp < ApplicationRecord
EDITABLE_ATTRS = [:phone_number, :provider, { provider_config: {} }].freeze EDITABLE_ATTRS = [:phone_number, :provider, { provider_config: {} }].freeze
# default at the moment is 360dialog lets change later. # default at the moment is 360dialog lets change later.
PROVIDERS = %w[default whatsapp_cloud].freeze PROVIDERS = %w[default whatsapp_cloud baileys].freeze
before_validation :ensure_webhook_verify_token before_validation :ensure_webhook_verify_token
validates :provider, inclusion: { in: PROVIDERS } validates :provider, inclusion: { in: PROVIDERS }
validates :phone_number, presence: true, uniqueness: true validates :phone_number, presence: true, uniqueness: true
validate :validate_provider_config validate :validate_provider_config
has_one :inbox, as: :channel, dependent: :destroy
after_create :sync_templates after_create :sync_templates
before_destroy :disconnect_channel_provider, if: -> { provider == 'baileys' }
def name def name
'Whatsapp' 'Whatsapp'
end end
def provider_service def provider_service
if provider == 'whatsapp_cloud' case provider
when 'whatsapp_cloud'
Whatsapp::Providers::WhatsappCloudService.new(whatsapp_channel: self) Whatsapp::Providers::WhatsappCloudService.new(whatsapp_channel: self)
when 'baileys'
Whatsapp::Providers::WhatsappBaileysService.new(whatsapp_channel: self)
else else
Whatsapp::Providers::Whatsapp360DialogService.new(whatsapp_channel: self) Whatsapp::Providers::Whatsapp360DialogService.new(whatsapp_channel: self)
end end
end end
def use_internal_host?
provider == 'baileys' && ENV.fetch('BAILEYS_PROVIDER_USE_INTERNAL_HOST_URL', false)
end
def mark_message_templates_updated def mark_message_templates_updated
# rubocop:disable Rails/SkipsModelValidations # rubocop:disable Rails/SkipsModelValidations
update_column(:message_templates_last_updated, Time.zone.now) update_column(:message_templates_last_updated, Time.zone.now)
# rubocop:enable Rails/SkipsModelValidations # rubocop:enable Rails/SkipsModelValidations
end end
def update_provider_connection!(provider_connection)
assign_attributes(provider_connection: provider_connection)
# NOTE: Skip `validate_provider_config?` check
save!(validate: false)
end
def provider_connection_data
data = { connection: provider_connection['connection'] }
if Current.account_user&.administrator?
data[:qr_data_url] = provider_connection['qr_data_url']
data[:error] = provider_connection['error']
end
data
end
def toggle_typing_status(typing_status, conversation:)
return unless provider_service.respond_to?(:toggle_typing_status)
provider_service.toggle_typing_status(conversation.contact.phone_number, typing_status)
end
def update_presence(status)
return unless provider_service.respond_to?(:update_presence)
provider_service.update_presence(status)
end
def send_read_messages(messages, conversation:)
return unless provider_service.respond_to?(:send_read_messages)
provider_service.send_read_messages(conversation.contact.phone_number, messages)
end
delegate :setup_channel_provider, to: :provider_service
delegate :disconnect_channel_provider, to: :provider_service
delegate :send_message, to: :provider_service delegate :send_message, to: :provider_service
delegate :send_template, to: :provider_service delegate :send_template, to: :provider_service
delegate :sync_templates, to: :provider_service delegate :sync_templates, to: :provider_service
@ -61,7 +108,7 @@ class Channel::Whatsapp < ApplicationRecord
private private
def ensure_webhook_verify_token def ensure_webhook_verify_token
provider_config['webhook_verify_token'] ||= SecureRandom.hex(16) if provider == 'whatsapp_cloud' provider_config['webhook_verify_token'] ||= SecureRandom.hex(16) if provider.in?(%w[whatsapp_cloud baileys])
end end
def validate_provider_config def validate_provider_config

View File

@ -37,7 +37,8 @@ module CacheKeys
def update_cache_key_for_account(account_id, key) def update_cache_key_for_account(account_id, key)
prefixed_cache_key = get_prefixed_cache_key(account_id, key) prefixed_cache_key = get_prefixed_cache_key(account_id, key)
Redis::Alfred.setex(prefixed_cache_key, Time.now.utc.to_i, CACHE_KEYS_EXPIRY) timestamp = (Time.now.utc.to_f * 1000).to_i
Redis::Alfred.setex(prefixed_cache_key, timestamp, CACHE_KEYS_EXPIRY)
end end
def dispatch_cache_update_event def dispatch_cache_update_event

View File

@ -37,7 +37,7 @@ class ContentAttributeValidator < ActiveModel::Validator
end end
def validate_item_actions!(record) def validate_item_actions!(record)
if record.items.select { |item| item[:actions].blank? }.present? if record.items.select { |item| item[:actions].blank? }.present? # rubocop:disable Style/RedundantFilterChain
record.errors.add(:content_attributes, 'contains items missing actions') && return record.errors.add(:content_attributes, 'contains items missing actions') && return
end end

View File

@ -3,12 +3,12 @@ module ConversationMuteHelpers
def mute! def mute!
resolved! resolved!
contact.update(blocked: true) contact.update!(blocked: true)
create_muted_message create_muted_message
end end
def unmute! def unmute!
contact.update(blocked: false) contact.update!(blocked: false)
create_unmuted_message create_unmuted_message
end end

View File

@ -25,9 +25,9 @@ module Featurable
end end
end end
def enable_features!(*names) def enable_features!(*)
enable_features(*names) enable_features(*)
save save!
end end
def disable_features(*names) def disable_features(*names)
@ -36,9 +36,9 @@ module Featurable
end end
end end
def disable_features!(*names) def disable_features!(*)
disable_features(*names) disable_features(*)
save save!
end end
def feature_enabled?(name) def feature_enabled?(name)

View File

@ -140,12 +140,12 @@ class Conversation < ApplicationRecord
# FIXME: implement state machine with aasm # FIXME: implement state machine with aasm
self.status = open? ? :resolved : :open self.status = open? ? :resolved : :open
self.status = :open if pending? || snoozed? self.status = :open if pending? || snoozed?
save save!
end end
def toggle_priority(priority = nil) def toggle_priority(priority = nil)
self.priority = priority.presence self.priority = priority.presence
save save!
end end
def bot_handoff! def bot_handoff!
@ -271,8 +271,9 @@ class Conversation < ApplicationRecord
def conversation_status_changed_to_open? def conversation_status_changed_to_open?
return false unless open? return false unless open?
# saved_change_to_status? method only works in case of update # saved_change_to_status? method only works in case of update
return true if previous_changes.key?(:id) || saved_change_to_status? true if previous_changes.key?(:id) || saved_change_to_status?
end end
def create_label_change(user_name) def create_label_change(user_name)

View File

@ -160,15 +160,17 @@ class Inbox < ApplicationRecord
end end
def callback_webhook_url def callback_webhook_url
host = ENV.fetch('FRONTEND_URL', nil)
case channel_type case channel_type
when 'Channel::TwilioSms' when 'Channel::TwilioSms'
"#{ENV.fetch('FRONTEND_URL', nil)}/twilio/callback" "#{host}/twilio/callback"
when 'Channel::Sms' when 'Channel::Sms'
"#{ENV.fetch('FRONTEND_URL', nil)}/webhooks/sms/#{channel.phone_number.delete_prefix('+')}" "#{host}/webhooks/sms/#{channel.phone_number.delete_prefix('+')}"
when 'Channel::Line' when 'Channel::Line'
"#{ENV.fetch('FRONTEND_URL', nil)}/webhooks/line/#{channel.line_channel_id}" "#{host}/webhooks/line/#{channel.line_channel_id}"
when 'Channel::Whatsapp' when 'Channel::Whatsapp'
"#{ENV.fetch('FRONTEND_URL', nil)}/webhooks/whatsapp/#{channel.phone_number}" host = ENV.fetch('INTERNAL_HOST_URL', nil) if channel.use_internal_host?
"#{host}/webhooks/whatsapp/#{channel.phone_number}"
end end
end end

View File

@ -101,9 +101,13 @@ class Message < ApplicationRecord
# [:deleted] : Used to denote whether the message was deleted by the agent # [:deleted] : Used to denote whether the message was deleted by the agent
# [:external_created_at] : Can specify if the message was created at a different timestamp externally # [:external_created_at] : Can specify if the message was created at a different timestamp externally
# [:external_error : Can specify if the message creation failed due to an error at external API # [:external_error : Can specify if the message creation failed due to an error at external API
# [:is_reaction] : Used to denote if the message is a reaction and differentiate it from a simple reply message
# [:is_edited, :previous_content] : Used to indicated edited message and previous content (before edit)
store :content_attributes, accessors: [:submitted_email, :items, :submitted_values, :email, :in_reply_to, :deleted, store :content_attributes, accessors: [:submitted_email, :items, :submitted_values, :email, :in_reply_to, :deleted,
:external_created_at, :story_sender, :story_id, :external_error, :external_created_at, :story_sender, :story_id, :external_error,
:translations, :in_reply_to_external_id, :is_unsupported], coder: JSON :translations, :in_reply_to_external_id, :is_unsupported,
:is_reaction, :is_edited, :previous_content], coder: JSON
store :external_source_ids, accessors: [:slack], coder: JSON, prefix: :external_source_id store :external_source_ids, accessors: [:slack], coder: JSON, prefix: :external_source_id
@ -268,7 +272,7 @@ class Message < ApplicationRecord
end end
def update_contact_activity def update_contact_activity
sender.update(last_activity_at: DateTime.now) if sender.is_a?(Contact) sender.update!(last_activity_at: DateTime.now) if sender.is_a?(Contact)
end end
def update_waiting_since def update_waiting_since
@ -276,9 +280,9 @@ class Message < ApplicationRecord
Rails.configuration.dispatcher.dispatch( Rails.configuration.dispatcher.dispatch(
REPLY_CREATED, Time.zone.now, waiting_since: conversation.waiting_since, message: self REPLY_CREATED, Time.zone.now, waiting_since: conversation.waiting_since, message: self
) )
conversation.update(waiting_since: nil) conversation.update!(waiting_since: nil)
end end
conversation.update(waiting_since: created_at) if incoming? && conversation.waiting_since.blank? conversation.update!(waiting_since: created_at) if incoming? && conversation.waiting_since.blank?
end end
def human_response? def human_response?
@ -296,7 +300,7 @@ class Message < ApplicationRecord
if valid_first_reply? if valid_first_reply?
Rails.configuration.dispatcher.dispatch(FIRST_REPLY_CREATED, Time.zone.now, message: self, performed_by: Current.executed_by) Rails.configuration.dispatcher.dispatch(FIRST_REPLY_CREATED, Time.zone.now, message: self, performed_by: Current.executed_by)
conversation.update(first_reply_created_at: created_at, waiting_since: nil) conversation.update!(first_reply_created_at: created_at, waiting_since: nil)
else else
update_waiting_since update_waiting_since
end end
@ -359,9 +363,9 @@ class Message < ApplicationRecord
end end
def can_notify_via_mail? def can_notify_via_mail?
return unless email_notifiable_message? return false unless email_notifiable_message?
return unless email_notifiable_channel? return false unless email_notifiable_channel?
return if conversation.contact.email.blank? return false if conversation.contact.email.blank?
true true
end end

View File

@ -66,6 +66,6 @@ class Portal < ApplicationRecord
def config_json_format def config_json_format
config['default_locale'] = default_locale config['default_locale'] = default_locale
denied_keys = config.keys - CONFIG_JSON_KEYS denied_keys = config.keys - CONFIG_JSON_KEYS
errors.add(:cofig, "in portal on #{denied_keys.join(',')} is not supported.") if denied_keys.any? errors.add(:config, "in portal on #{denied_keys.join(',')} is not supported.") if denied_keys.any?
end end
end end

View File

@ -36,7 +36,7 @@ class Team < ApplicationRecord
# @return [Array<User>] Array of newly added members # @return [Array<User>] Array of newly added members
def add_members(user_ids) def add_members(user_ids)
team_members_to_create = user_ids.map { |user_id| { user_id: user_id } } team_members_to_create = user_ids.map { |user_id| { user_id: user_id } }
created_members = team_members.create(team_members_to_create) created_members = team_members.create!(team_members_to_create)
added_users = created_members.filter_map(&:user) added_users = created_members.filter_map(&:user)
update_account_cache update_account_cache

View File

@ -109,8 +109,8 @@ class User < ApplicationRecord
self.email = email.try(:downcase) self.email = email.try(:downcase)
end end
def send_devise_notification(notification, *args) def send_devise_notification(notification, *)
devise_mailer.with(account: Current.account).send(notification, self, *args).deliver_later devise_mailer.with(account: Current.account).send(notification, self, *).deliver_later
end end
def set_password_and_uid def set_password_and_uid

View File

@ -3,6 +3,7 @@
# Table name: webhooks # Table name: webhooks
# #
# id :bigint not null, primary key # id :bigint not null, primary key
# name :string
# subscriptions :jsonb # subscriptions :jsonb
# url :string # url :string
# webhook_type :integer default("account_type") # webhook_type :integer default("account_type")

View File

@ -57,4 +57,12 @@ class InboxPolicy < ApplicationPolicy
def avatar? def avatar?
@account_user.administrator? @account_user.administrator?
end end
def setup_channel_provider?
@account_user.administrator?
end
def disconnect_channel_provider?
@account_user.administrator?
end
end end

Some files were not shown because too many files have changed in this diff Show More