Merge branch 'main' into chore/merge-upstream-4.10

This commit is contained in:
gabrieljablonski 2026-01-16 14:01:53 -03:00
commit 6ab1898992
390 changed files with 13846 additions and 1160 deletions

45
.annotaterb.yml Normal file
View File

@ -0,0 +1,45 @@
additional_file_patterns: []
routes: false
models: true
position_in_routes: before
position_in_class: before
position_in_test: before
position_in_fixture: before
position_in_factory: before
position_in_serializer: before
show_foreign_keys: true
show_complete_foreign_keys: false
show_indexes: true
simple_indexes: false
model_dir:
- app/models
- enterprise/app/models
root_dir: ''
include_version: false
require: ''
exclude_tests: true
exclude_fixtures: true
exclude_factories: true
exclude_serializers: true
exclude_scaffolds: true
exclude_controllers: true
exclude_helpers: true
exclude_sti_subclasses: false
ignore_model_sub_dir: false
ignore_columns: null
ignore_routes: null
ignore_unknown_models: false
hide_limit_column_types: integer,bigint,boolean
hide_default_column_types: json,jsonb,hstore
skip_on_db_migrate: false
format_bare: true
format_rdoc: false
format_markdown: false
sort: false
force: false
frozen: false
classified_sort: true
trace: false
wrapper_open: null
wrapper_close: null
with_comment: true

View File

@ -276,3 +276,10 @@ AZURE_APP_SECRET=
# REMOVE_STALE_CONTACT_INBOX_JOB_STATUS=false # REMOVE_STALE_CONTACT_INBOX_JOB_STATUS=false
# REDIS_ALFRED_SIZE=10 # REDIS_ALFRED_SIZE=10
# 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

@ -2,11 +2,9 @@ name: Frontend Lint & Test
on: on:
push: push:
branches: tags:
- develop - '*'
pull_request: workflow_dispatch:
branches:
- develop
jobs: jobs:
test: test:

View File

@ -0,0 +1,138 @@
name: Publish Chatwoot Enterprise docker images to GitHub
permissions:
contents: read
packages: write
on:
release:
types: [released]
workflow_dispatch:
env:
GITHUB_REPO: ghcr.io/${{ github.repository }}
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-22.04-arm
runs-on: ${{ matrix.runner }}
env:
GIT_REF: ${{ github.event.release.tag_name || github.ref_name }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.release.tag_name || github.ref }}
- name: Prepare
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Set Chatwoot edition
run: |
echo -en '\nENV CW_EDITION="ee"' >> docker/Dockerfile
- name: Update version in app.yml
run: |
if [[ "$GIT_REF" =~ ^v(.+)$ ]]; then
VERSION="${BASH_REMATCH[1]}"
echo "Updating version to: $VERSION"
sed -i "s/version: '.*'/version: '$VERSION'/" config/app.yml
else
echo "No version tag found, keeping existing version"
fi
- 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 == 'release' || 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 == 'release' || github.event_name == 'workflow_dispatch' }}
tags: |
${{ env.GITHUB_REPO }}:${{ env.SANITIZED_REF }}-ee
${{ env.GITHUB_REPO }}:latest-ee
- 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.event.release.tag_name || github.ref_name }}
run: |
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
docker buildx imagetools create \
-t ghcr.io/${{ github.repository }}:${SANITIZED_REF}-ee \
-t ghcr.io/${{ github.repository }}:latest-ee \
$(printf 'ghcr.io/${{ github.repository }}@sha256:%s ' *)
- name: Inspect image
env:
GIT_REF: ${{ github.event.release.tag_name || github.ref_name }}
run: |
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
REPO="ghcr.io/${{ github.repository }}"
docker buildx imagetools inspect ${REPO}:${SANITIZED_REF}-ee
docker buildx imagetools inspect ${REPO}:latest-ee

View File

@ -0,0 +1,139 @@
name: Publish Chatwoot docker images to GitHub
permissions:
contents: read
packages: write
on:
release:
types: [released]
workflow_dispatch:
env:
GITHUB_REPO: ghcr.io/${{ github.repository }}
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-22.04-arm
runs-on: ${{ matrix.runner }}
env:
GIT_REF: ${{ github.event.release.tag_name || github.ref_name }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.release.tag_name || github.ref }}
- 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: Update version in app.yml
run: |
if [[ "$GIT_REF" =~ ^v(.+)$ ]]; then
VERSION="${BASH_REMATCH[1]}"
echo "Updating version to: $VERSION"
sed -i "s/version: '.*'/version: '$VERSION'/" config/app.yml
else
echo "No version tag found, keeping existing version"
fi
- 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 == 'release' || 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 == 'release' || 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.event.release.tag_name || 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.event.release.tag_name || 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

@ -0,0 +1,139 @@
name: Publish Chatwoot beta docker images to GitHub
permissions:
contents: read
packages: write
on:
release:
types: [prereleased]
workflow_dispatch:
env:
GITHUB_REPO: ghcr.io/${{ github.repository }}
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-22.04-arm
runs-on: ${{ matrix.runner }}
env:
GIT_REF: ${{ github.event.release.tag_name || github.ref_name }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.release.tag_name || github.ref }}
- 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: Update version in app.yml
run: |
if [[ "$GIT_REF" =~ ^v(.+)$ ]]; then
VERSION="${BASH_REMATCH[1]}"
echo "Updating version to: $VERSION"
sed -i "s/version: '.*'/version: '$VERSION'/" config/app.yml
else
echo "No version tag found, keeping existing version"
fi
- 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 == 'release' || 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 == 'release' || github.event_name == 'workflow_dispatch' }}
tags: |
${{ env.GITHUB_REPO }}:${{ env.SANITIZED_REF }}
${{ env.GITHUB_REPO }}:beta
- 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.event.release.tag_name || 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 }}:beta \
$(printf 'ghcr.io/${{ github.repository }}@sha256:%s ' *)
- name: Inspect image
env:
GIT_REF: ${{ github.event.release.tag_name || 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}:beta

View File

@ -3,10 +3,8 @@ permissions:
contents: read contents: read
on: on:
push: push:
branches: tags:
- develop - '*'
- master
pull_request:
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

View File

@ -218,6 +218,7 @@ Style/OneClassPerFile:
AllCops: AllCops:
NewCops: enable NewCops: enable
SuggestExtensions: false
Exclude: Exclude:
- 'bin/**/*' - 'bin/**/*'
- 'db/schema.rb' - 'db/schema.rb'
@ -348,3 +349,12 @@ FactoryBot/RedundantFactoryOption:
FactoryBot/FactoryAssociationWithStrategy: FactoryBot/FactoryAssociationWithStrategy:
Enabled: false Enabled: false
Rails/SaveBang:
Enabled: true
AllowedReceivers:
- Stripe::Subscription
- Stripe::Customer
- Stripe::Invoice
- Stripe::InvoiceItem
- 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"
}
]
}
]
}

73
CUSTOM_BRANDING.md Normal file
View File

@ -0,0 +1,73 @@
# Custom branding
## Brand configuration
Export environment variables and run rake task with `bundle exec rails branding:update`.
> [!IMPORTANT]
> Unset environment variables are reset to default values.
```bash
INSTALLATION_NAME="Chatwoot fazer.ai" \
BRAND_NAME="My Company" \
LOGO_THUMBNAIL="https://fazer.ai/logo-thumbnail.svg" \
LOGO="https://fazer.ai/logo.svg" \
bundle exec rails branding:update
```
| Environment variable | Default Value | Description |
| :--------------------| :------------------------------------------ | :-------------------------------------------------------------------- |
| `INSTALLATION_NAME` | `Chatwoot` | The installation-wide name used in the dashboard, title, etc. |
| `LOGO_THUMBNAIL` | `/brand-assets/logo_thumbnail.svg` | The thumbnail used for favicon (512px X 512px). |
| `LOGO` | `/brand-assets/logo.svg` | The logo used on the dashboard, login page, etc. |
| `LOGO_DARK` | `/brand-assets/logo_dark.svg` | The logo used on the dashboard, login page, etc. for dark mode. |
| `BRAND_URL` | `https://www.chatwoot.com` | The URL used in emails under the section “Powered By”. |
| `WIDGET_BRAND_URL` | `https://www.chatwoot.com` | The URL used in the widget under the section “Powered By”. |
| `BRAND_NAME` | `Chatwoot` | The name used in emails and the widget. |
| `TERMS_URL` | `https://www.chatwoot.com/terms-of-service` | The terms of service URL displayed on the Signup Page. |
| `PRIVACY_URL` | `https://www.chatwoot.com/privacy-policy` | The privacy policy URL displayed in the app. |
| `DISPLAY_MANIFEST` | `true` | Display default Chatwoot metadata like favicons and upgrade warnings. |
## Favicon and other assets
Update the favicon files in the [`public/`](public/) folder.
Can also be done by creating a zip file with relevant files, and running [`deployment/extract_brand_assets.sh`](deployment/extract_brand_assets.sh) to override the existing favicons with your own.
In this case, the zip file should be a flat archive containing the following files:
```
android-icon-36x36.png
android-icon-48x48.png
android-icon-72x72.png
android-icon-96x96.png
android-icon-144x144.png
android-icon-192x192.png
apple-icon-57x57.png
apple-icon-60x60.png
apple-icon-72x72.png
apple-icon-76x76.png
apple-icon-114x114.png
apple-icon-120x120.png
apple-icon-144x144.png
apple-icon-152x152.png
apple-icon-180x180.png
apple-icon.png
apple-icon-precomposed.png
apple-touch-icon.png
apple-touch-icon-precomposed.png
favicon-16x16.png
favicon-32x32.png
favicon-96x96.png
favicon-512x512.png
favicon-badge-16x16.png
favicon-badge-32x32.png
favicon-badge-96x96.png
ms-icon-70x70.png
ms-icon-144x144.png
ms-icon-150x150.png
ms-icon-310x310.png
```
> [!NOTE]
> You can include other assets in the zip file, and use them when running the rake task for `LOGO_THUMBNAIL`, `LOGO`, and `LOGO_DARK`.
> See [Brand configuration](#brand-configuration).

View File

@ -43,7 +43,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'
@ -203,6 +203,8 @@ gem 'opentelemetry-exporter-otlp'
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

@ -748,6 +748,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)
@ -1114,6 +1116,7 @@ DEPENDENCIES
rails (~> 7.1) rails (~> 7.1)
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

@ -70,7 +70,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

@ -162,7 +162,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

@ -1,11 +1,11 @@
class Messages::MessageBuilder class Messages::MessageBuilder # rubocop:disable Metrics/ClassLength
include ::FileTypeHelper include ::FileTypeHelper
include ::EmailHelper include ::EmailHelper
include ::DataHelper include ::DataHelper
attr_reader :message attr_reader :message
def initialize(user, conversation, params) def initialize(user, conversation, params) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
@params = params @params = params
@private = params[:private] || false @private = params[:private] || false
@conversation = conversation @conversation = conversation
@ -13,11 +13,15 @@ class Messages::MessageBuilder
@account = conversation.account @account = conversation.account
@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]
@attachments_metadata = normalize_attachments_metadata(params[:attachments_metadata])
@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)
@zapi_args = content_attributes&.dig(:zapi_args)
end end
def perform def perform
@ -55,7 +59,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
@ -66,6 +70,46 @@ class Messages::MessageBuilder
end end
end end
def process_metadata(attachment)
meta = {}
meta.merge!(recorded_audio_metadata(attachment) || {})
meta.merge!(custom_attachment_metadata(attachment) || {})
meta.presence
end
def recorded_audio_metadata(attachment) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
# 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
return { is_recorded_audio: true } if @is_recorded_audio.is_a?(Array) && attachment.original_filename.in?(@is_recorded_audio)
# FIXME: Remove backwards compatibility with old format.
if @is_recorded_audio.is_a?(String)
parsed = JSON.parse(@is_recorded_audio)
{ is_recorded_audio: true } if parsed.is_a?(Array) && attachment.original_filename.in?(parsed)
end
rescue JSON::ParserError
nil
end
def custom_attachment_metadata(attachment)
return unless @attachments_metadata.is_a?(Hash)
filename = attachment.respond_to?(:original_filename) ? attachment.original_filename : nil
return unless filename
metadata = @attachments_metadata[filename]
metadata.to_h if metadata.present?
end
def normalize_attachments_metadata(metadata)
return if metadata.blank?
metadata = metadata.to_unsafe_h if metadata.respond_to?(:to_unsafe_h)
metadata.deep_stringify_keys
end
def process_emails def process_emails
return unless @conversation.inbox&.inbox_type == 'Email' return unless @conversation.inbox&.inbox_type == 'Email'
@ -129,6 +173,10 @@ class Messages::MessageBuilder
AgentBot.where(account_id: [nil, @conversation.account.id]).find_by(id: @params[:sender_id]) AgentBot.where(account_id: [nil, @conversation.account.id]).find_by(id: @params[:sender_id])
end end
def zapi_args
@zapi_args.present? ? { zapi_args: @zapi_args } : {}
end
def message_params def message_params
{ {
account_id: @conversation.account_id, account_id: @conversation.account_id,
@ -141,9 +189,10 @@ class Messages::MessageBuilder
content_attributes: content_attributes.presence, content_attributes: content_attributes.presence,
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).merge(zapi_args)
end end
def email_inbox? def email_inbox?

View File

@ -46,7 +46,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

@ -0,0 +1,34 @@
class Api::V1::Accounts::Conversations::AttachmentsController < Api::V1::Accounts::Conversations::BaseController
before_action :set_message
before_action :set_attachment
before_action :validate_meta_size, only: [:update]
MAX_META_SIZE = 16.kilobytes
def update
@attachment.update!(permitted_params)
@attachment.message.send_update_event
end
private
def set_message
@message = @conversation.messages.find(params[:message_id])
end
def set_attachment
@attachment = @message.attachments.find(params[:id])
end
def permitted_params
params.permit(meta: {})
end
def validate_meta_size
return if params[:meta].blank?
return unless params[:meta].to_json.bytesize > MAX_META_SIZE
render json: { error: "Metadata size exceeds maximum allowed (#{MAX_META_SIZE / 1024}KB)" }, status: :unprocessable_entity
end
end

View File

@ -1,4 +1,6 @@
class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::Conversations::BaseController class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::Conversations::BaseController
include Events::Types
before_action :ensure_api_inbox, only: :update before_action :ensure_api_inbox, only: :update
def index def index
@ -9,6 +11,8 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
user = Current.user || @resource user = Current.user || @resource
mb = Messages::MessageBuilder.new(user, @conversation, params) mb = Messages::MessageBuilder.new(user, @conversation, params)
@message = mb.perform @message = mb.perform
trigger_typing_event(CONVERSATION_TYPING_OFF)
rescue StandardError => e rescue StandardError => e
render_could_not_create_error(e.message) render_could_not_create_error(e.message)
end end
@ -77,4 +81,11 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
# Only API inboxes can update messages # Only API inboxes can update messages
render json: { error: 'Message status update is only allowed for API inboxes' }, status: :forbidden unless @conversation.inbox.api? render json: { error: 'Message status update is only allowed for API inboxes' }, status: :forbidden unless @conversation.inbox.api?
end end
def trigger_typing_event(event)
user = Current.user || @resource
return unless user.is_a?(User)
Rails.configuration.dispatcher.dispatch(event, Time.zone.now, conversation: @conversation, user: user, is_private: params[:private])
end
end end

View File

@ -110,10 +110,14 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end end
def update_last_seen def update_last_seen
dispatch_messages_read_event if assignee?
update_last_seen_on_conversation(DateTime.now.utc, assignee?) update_last_seen_on_conversation(DateTime.now.utc, assignee?)
end end
def unread def unread
Rails.configuration.dispatcher.dispatch(Events::Types::CONVERSATION_UNREAD, Time.zone.now, conversation: @conversation)
last_incoming_message = @conversation.messages.incoming.last last_incoming_message = @conversation.messages.incoming.last
last_seen_at = last_incoming_message.created_at - 1.second if last_incoming_message.present? last_seen_at = last_incoming_message.created_at - 1.second if last_incoming_message.present?
update_last_seen_on_conversation(last_seen_at, true) update_last_seen_on_conversation(last_seen_at, true)
@ -206,6 +210,12 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
def assignee? def assignee?
@conversation.assignee_id? && Current.user == @conversation.assignee @conversation.assignee_id? && Current.user == @conversation.assignee
end end
def dispatch_messages_read_event
# 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)
end
end end
Api::V1::Accounts::ConversationsController.prepend_mod_with('Api::V1::Accounts::ConversationsController') Api::V1::Accounts::ConversationsController.prepend_mod_with('Api::V1::Accounts::ConversationsController')

View File

@ -34,6 +34,7 @@ class Api::V1::Accounts::DashboardAppsController < Api::V1::Accounts::BaseContro
def permitted_payload def permitted_payload
params.require(:dashboard_app).permit( params.require(:dashboard_app).permit(
:title, :title,
:show_on_sidebar,
content: [:url, :type] content: [:url, :type]
) )
end end

View File

@ -1,10 +1,10 @@
class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController # rubocop:disable Metrics/ClassLength
include Api::V1::InboxesHelper include Api::V1::InboxesHelper
before_action :fetch_inbox, except: [:index, :create] before_action :fetch_inbox, except: [:index, :create]
before_action :fetch_agent_bot, only: [:set_agent_bot] before_action :fetch_agent_bot, only: [:set_agent_bot]
before_action :validate_limit, only: [:create] before_action :validate_limit, only: [:create]
# we are already handling the authorization in fetch inbox # we are already handling the authorization in fetch inbox
before_action :check_authorization, except: [:show, :health] before_action :check_authorization, except: [:show, :health, :setup_channel_provider]
before_action :validate_whatsapp_cloud_channel, only: [:health] before_action :validate_whatsapp_cloud_channel, only: [:health]
def index def index
@ -65,6 +65,30 @@ 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
ensure
channel.update_provider_connection!(connection: 'close') if channel.respond_to?(:update_provider_connection!)
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') }
@ -87,6 +111,20 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
render json: { error: e.message }, status: :unprocessable_entity render json: { error: e.message }, status: :unprocessable_entity
end end
def on_whatsapp
params.require(:phone_number)
phone_number = params[:phone_number]
channel = @inbox.channel
unless channel.respond_to?(:on_whatsapp)
render json: { error: 'Channel does not support whatsapp check' }, status: :unprocessable_entity and return
end
response = channel.on_whatsapp(phone_number)
render json: response, status: :ok
end
private private
def fetch_inbox def fetch_inbox

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

@ -37,7 +37,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,10 +22,14 @@ class Api::V1::Accounts::WebhooksController < Api::V1::Accounts::BaseController
private private
def webhook_params def webhook_create_params
params.require(:webhook).permit(:inbox_id, :name, :url, subscriptions: []) params.require(:webhook).permit(:inbox_id, :name, :url, subscriptions: [])
end end
def webhook_update_params
params.require(:webhook).permit(:name, subscriptions: [])
end
def fetch_webhook def fetch_webhook
@webhook = Current.account.webhooks.find(params[:id]) @webhook = Current.account.webhooks.find(params[:id])
end end

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

@ -18,7 +18,7 @@ class Api::V2::Accounts::YearInReviewsController < Api::V1::Accounts::BaseContro
ui_settings = Current.user.ui_settings || {} ui_settings = Current.user.ui_settings || {}
ui_settings[cache_key] = data ui_settings[cache_key] = data
Current.user.update(ui_settings: ui_settings) Current.user.update!(ui_settings: ui_settings)
render json: data render json: data
end end

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

@ -12,7 +12,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

@ -13,7 +13,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

@ -19,7 +19,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
errors.concat(i.errors.full_messages) unless i.save errors.concat(i.errors.full_messages) unless i.save
end end

View File

@ -7,6 +7,7 @@ class SuperAdmin::InstanceStatusesController < SuperAdmin::ApplicationController
redis_metrics redis_metrics
chatwoot_edition chatwoot_edition
instance_meta instance_meta
baileys_api_version
end end
def chatwoot_edition def chatwoot_edition
@ -56,4 +57,10 @@ class SuperAdmin::InstanceStatusesController < SuperAdmin::ApplicationController
rescue Redis::CannotConnectError rescue Redis::CannotConnectError
@metrics['Redis alive'] = false @metrics['Redis alive'] = false
end end
def baileys_api_version
@metrics['Baileys API version'] = Whatsapp::Providers::WhatsappBaileysService.status[:packageInfo][:version]
rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e
@metrics['Baileys API version'] = e.message
end
end end

View File

@ -8,11 +8,26 @@ 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
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

@ -0,0 +1,45 @@
module BaileysHelper
CHANNEL_LOCK_ON_OUTGOING_MESSAGE_KEY = 'BAILEYS::CHANNEL_LOCK_ON_OUTGOING_MESSAGE::%<channel_id>s'.freeze
CHANNEL_LOCK_ON_OUTGOING_MESSAGE_TIMEOUT = 15.seconds
def baileys_extract_message_timestamp(timestamp)
# NOTE: Timestamp might be in this format {"low"=>1748003165, "high"=>0, "unsigned"=>true}
if timestamp.is_a?(Hash) && timestamp.key?('low')
low = timestamp['low'].to_i
high = timestamp.fetch('high', 0).to_i
return (high << 32) | low
end
# NOTE: Timestamp might be a string or a number
timestamp.to_i
end
def with_baileys_channel_lock_on_outgoing_message(channel_id, timeout: CHANNEL_LOCK_ON_OUTGOING_MESSAGE_TIMEOUT)
raise ArgumentError, 'A block is required for with_baileys_channel_lock_on_outgoing_message' unless block_given?
start_time = Time.now.to_i
# NOTE: On timeout, we ignore the lock and proceed with the block execution
while (Time.now.to_i - start_time) < timeout
break if baileys_lock_channel_on_outgoing_message(channel_id, timeout)
sleep(0.1)
end
yield
ensure
baileys_clear_channel_lock_on_outgoing_message(channel_id)
end
private
def baileys_lock_channel_on_outgoing_message(channel_id, timeout)
key = format(CHANNEL_LOCK_ON_OUTGOING_MESSAGE_KEY, channel_id: channel_id)
Redis::Alfred.set(key, 1, nx: true, ex: timeout)
end
def baileys_clear_channel_lock_on_outgoing_message(channel_id)
key = format(CHANNEL_LOCK_ON_OUTGOING_MESSAGE_KEY, channel_id: channel_id)
Redis::Alfred.delete(key)
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

@ -65,8 +65,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,9 @@ export const buildCreatePayload = ({
files.forEach(file => { files.forEach(file => {
payload.append('attachments[]', file); payload.append('attachments[]', file);
}); });
isRecordedAudio?.forEach(filename => {
payload.append('is_recorded_audio[]', filename);
});
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 +64,7 @@ class MessageApi extends ApiClient {
contentAttributes, contentAttributes,
echo_id: echoId, echo_id: echoId,
files, files,
isRecordedAudio,
ccEmails = '', ccEmails = '',
bccEmails = '', bccEmails = '',
toEmails = '', toEmails = '',
@ -74,6 +79,7 @@ class MessageApi extends ApiClient {
contentAttributes, contentAttributes,
echoId, echoId,
files, files,
isRecordedAudio,
ccEmails, ccEmails,
bccEmails, bccEmails,
toEmails, toEmails,

View File

@ -42,6 +42,14 @@ class Inboxes extends CacheEnabledApiClient {
getCSATTemplateStatus(inboxId) { getCSATTemplateStatus(inboxId) {
return axios.get(`${this.url}/${inboxId}/csat_template`); return axios.get(`${this.url}/${inboxId}/csat_template`);
} }
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

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<svg fill="#2781F6"
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 302.816 302.816">
<path d="M298.423,152.996c-5.857-5.858-15.354-5.858-21.213,0l-35.137,35.136
c-5.871-59.78-50.15-111.403-112.001-123.706c-45.526-9.055-92.479,5.005-125.596,37.612c-5.903,5.813-5.977,15.31-0.165,21.213
c5.813,5.903,15.31,5.977,21.212,0.164c26.029-25.628,62.923-36.679,98.695-29.565c48.865,9.72,83.772,50.677,88.07,97.978
l-38.835-38.835c-5.857-5.857-15.355-5.858-21.213,0.001c-5.858,5.858-5.858,15.355,0,21.213l62.485,62.485
c2.929,2.929,6.768,4.393,10.606,4.393s7.678-1.464,10.607-4.393l62.483-62.482C304.281,168.352,304.281,158.854,298.423,152.996z" />
</svg>

After

Width:  |  Height:  |  Size: 691 B

View File

@ -17,6 +17,8 @@ import ContentTemplateSelector from './ContentTemplateSelector.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 },
isWhatsappZapiInbox: { type: Boolean, default: false },
isEmailOrWebWidgetInbox: { type: Boolean, default: false }, isEmailOrWebWidgetInbox: { type: Boolean, default: false },
isTwilioSmsInbox: { type: Boolean, default: false }, isTwilioSmsInbox: { type: Boolean, default: false },
isTwilioWhatsAppInbox: { type: Boolean, default: false }, isTwilioWhatsAppInbox: { type: Boolean, default: false },
@ -78,7 +80,11 @@ const shouldShowEmojiButton = computed(() => {
}); });
const isRegularMessageMode = computed(() => { const isRegularMessageMode = computed(() => {
return !props.isWhatsappInbox && !props.isTwilioWhatsAppInbox; return (
(!props.isWhatsappInbox && !props.isTwilioWhatsAppInbox) ||
props.isWhatsappBaileysInbox ||
props.isWhatsappZapiInbox
);
}); });
const isVoiceInbox = computed(() => props.channelType === INBOX_TYPES.VOICE); const isVoiceInbox = computed(() => props.channelType === INBOX_TYPES.VOICE);

View File

@ -4,8 +4,6 @@ import { useVuelidate } from '@vuelidate/core';
import { required, requiredIf } from '@vuelidate/validators'; import { required, requiredIf } from '@vuelidate/validators';
import { INBOX_TYPES } from 'dashboard/helper/inbox'; import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { import {
appendSignature,
removeSignature,
getEffectiveChannelType, getEffectiveChannelType,
stripUnsupportedMarkdown, stripUnsupportedMarkdown,
} from 'dashboard/helper/editorHelper'; } from 'dashboard/helper/editorHelper';
@ -69,6 +67,12 @@ 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',
isWhatsappZapi:
props.targetInbox?.channelType === INBOX_TYPES.WHATSAPP &&
props.targetInbox?.provider === 'zapi',
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:
@ -90,12 +94,6 @@ const whatsappMessageTemplates = computed(() =>
const inboxChannelType = computed(() => props.targetInbox?.channelType || ''); const inboxChannelType = computed(() => props.targetInbox?.channelType || '');
const inboxMedium = computed(() => props.targetInbox?.medium || '');
const effectiveChannelType = computed(() =>
getEffectiveChannelType(inboxChannelType.value, inboxMedium.value)
);
const validationRules = computed(() => ({ const validationRules = computed(() => ({
selectedContact: { required }, selectedContact: { required },
targetInbox: { required }, targetInbox: { required },
@ -221,21 +219,8 @@ const handleInboxAction = ({ value, action, channelType, medium, ...rest }) => {
state.attachedFiles = []; state.attachedFiles = [];
}; };
const removeSignatureFromMessage = () => {
// Always remove the signature from message content when inbox/contact is removed
// to ensure no leftover signature content remains
if (props.messageSignature) {
state.message = removeSignature(
state.message,
props.messageSignature,
effectiveChannelType.value
);
}
};
const removeTargetInbox = value => { const removeTargetInbox = value => {
v$.value.$reset(); v$.value.$reset();
removeSignatureFromMessage();
stripMessageFormatting(DEFAULT_FORMATTING); stripMessageFormatting(DEFAULT_FORMATTING);
@ -244,7 +229,6 @@ const removeTargetInbox = value => {
}; };
const clearSelectedContact = () => { const clearSelectedContact = () => {
removeSignatureFromMessage();
emit('clearSelectedContact'); emit('clearSelectedContact');
state.message = ''; state.message = '';
state.attachedFiles = []; state.attachedFiles = [];
@ -254,22 +238,6 @@ const onClickInsertEmoji = emoji => {
state.message += emoji; state.message += emoji;
}; };
const handleAddSignature = signature => {
state.message = appendSignature(
state.message,
signature,
effectiveChannelType.value
);
};
const handleRemoveSignature = signature => {
state.message = removeSignature(
state.message,
signature,
effectiveChannelType.value
);
};
const handleAttachFile = files => { const handleAttachFile = files => {
state.attachedFiles = files; state.attachedFiles = files;
}; };
@ -332,7 +300,9 @@ const handleSendTwilioMessage = async ({ message, templateParams }) => {
const shouldShowMessageEditor = computed(() => { const shouldShowMessageEditor = computed(() => {
return ( return (
!inboxTypes.value.isWhatsapp && (!inboxTypes.value.isWhatsapp ||
inboxTypes.value.isWhatsappBaileys ||
inboxTypes.value.isWhatsappZapi) &&
!showNoInboxAlert.value && !showNoInboxAlert.value &&
!inboxTypes.value.isTwilioWhatsapp !inboxTypes.value.isTwilioWhatsapp
); );
@ -407,6 +377,8 @@ const shouldShowMessageEditor = computed(() => {
<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-whatsapp-zapi-inbox="inboxTypes.isWhatsappZapi"
: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"
:is-twilio-whats-app-inbox="inboxTypes.isTwilioWhatsapp" :is-twilio-whats-app-inbox="inboxTypes.isTwilioWhatsapp"
@ -420,8 +392,6 @@ const shouldShowMessageEditor = computed(() => {
:is-dropdown-active="isAnyDropdownActive" :is-dropdown-active="isAnyDropdownActive"
:message-signature="messageSignature" :message-signature="messageSignature"
@insert-emoji="onClickInsertEmoji" @insert-emoji="onClickInsertEmoji"
@add-signature="handleAddSignature"
@remove-signature="handleRemoveSignature"
@attach-file="handleAttachFile" @attach-file="handleAttachFile"
@discard="$emit('discard')" @discard="$emit('discard')"
@send-message="handleSendMessage" @send-message="handleSendMessage"

View File

@ -0,0 +1,127 @@
<script setup>
import { computed } from 'vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
title: {
type: String,
required: true,
},
description: {
type: String,
required: true,
},
variant: {
type: String,
default: 'info',
validator: value => ['info', 'success', 'warning'].includes(value),
},
ctaText: {
type: String,
default: '',
},
ctaLink: {
type: String,
default: '',
},
ctaExternal: {
type: Boolean,
default: false,
},
showIcon: {
type: Boolean,
default: true,
},
logoSrc: {
type: String,
default: '',
},
logoAlt: {
type: String,
default: 'Logo',
},
});
const emit = defineEmits(['ctaClick']);
const variantClasses = computed(() => {
const variants = {
info: {
container: 'bg-woot-50 border-woot-200',
icon: 'i-lucide-info text-woot-600',
text: 'text-woot-700',
description: 'text-woot-600',
},
success: {
container: 'bg-green-50 border-green-200',
icon: 'i-lucide-sparkles text-green-600',
text: 'text-green-700',
description: 'text-green-600',
},
warning: {
container: 'bg-yellow-50 border-yellow-200',
icon: 'i-lucide-alert-circle text-yellow-600',
text: 'text-yellow-700',
description: 'text-yellow-600',
},
};
return variants[props.variant];
});
const handleCtaClick = () => {
emit('ctaClick');
};
</script>
<template>
<div
class="relative flex items-start gap-3 p-4 rounded-lg border"
:class="variantClasses.container"
>
<div v-if="logoSrc || showIcon" class="flex-shrink-0 mt-0.5">
<img
v-if="logoSrc"
:src="logoSrc"
:alt="logoAlt"
class="w-8 h-8 object-contain"
/>
<i v-else class="w-5 h-5" :class="variantClasses.icon" />
</div>
<div class="flex-1 min-w-0">
<h3 class="text-sm font-semibold mb-1" :class="variantClasses.text">
{{ title }}
</h3>
<p class="text-sm leading-relaxed" :class="variantClasses.description">
{{ description }}
</p>
<div v-if="ctaText" class="mt-3">
<a
v-if="ctaLink"
:href="ctaLink"
:target="ctaExternal ? '_blank' : '_self'"
:rel="ctaExternal ? 'noopener noreferrer' : undefined"
class="inline-block"
>
<NextButton
sm
:color-scheme="variant === 'success' ? 'primary' : 'secondary'"
type="button"
>
{{ ctaText }}
</NextButton>
</a>
<NextButton
v-else
sm
:color-scheme="variant === 'success' ? 'primary' : 'secondary'"
type="button"
@click="handleCtaClick"
>
{{ ctaText }}
</NextButton>
</div>
</div>
</div>
</template>

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

@ -33,7 +33,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

@ -20,7 +20,9 @@ const attachment = computed(() => {
return attachments.value[0]; return attachments.value[0];
}); });
const { isLoaded, hasError, loadWithRetry } = useLoadWithRetry(); const { isLoaded, hasError, loadWithRetry } = useLoadWithRetry({
type: 'image',
});
const showGallery = ref(false); const showGallery = ref(false);
const isDownloading = ref(false); const isDownloading = ref(false);

View File

@ -6,6 +6,8 @@ import {
ref, ref,
getCurrentInstance, getCurrentInstance,
} from 'vue'; } from 'vue';
import { useI18n } from 'vue-i18n';
import { useLoadWithRetry } from 'dashboard/composables/loadWithRetry';
import Icon from 'next/icon/Icon.vue'; import Icon from 'next/icon/Icon.vue';
import { timeStampAppendedURL } from 'dashboard/helper/URLHelper'; import { timeStampAppendedURL } from 'dashboard/helper/URLHelper';
import { downloadFile } from '@chatwoot/utils'; import { downloadFile } from '@chatwoot/utils';
@ -27,6 +29,11 @@ defineOptions({
inheritAttrs: false, inheritAttrs: false,
}); });
const { t } = useI18n();
const { isLoaded, hasError, loadWithRetry } = useLoadWithRetry({
type: 'audio',
});
const timeStampURL = computed(() => { const timeStampURL = computed(() => {
return timeStampAppendedURL(attachment.dataUrl); return timeStampAppendedURL(attachment.dataUrl);
}); });
@ -42,19 +49,20 @@ const playbackSpeed = ref(1);
const { uid } = getCurrentInstance(); const { uid } = getCurrentInstance();
const onLoadedMetadata = () => { const onLoadedMetadata = () => {
duration.value = audioPlayer.value?.duration; if (audioPlayer.value) {
duration.value = audioPlayer.value.duration;
audioPlayer.value.playbackRate = playbackSpeed.value;
}
}; };
const playbackSpeedLabel = computed(() => { const playbackSpeedLabel = computed(() => {
return `${playbackSpeed.value}x`; return `${playbackSpeed.value}x`;
}); });
// There maybe a chance that the audioPlayer ref is not available
// When the onLoadMetadata is called, so we need to set the duration
// value when the component is mounted
onMounted(() => { onMounted(() => {
duration.value = audioPlayer.value?.duration; if (attachment.dataUrl) {
audioPlayer.value.playbackRate = playbackSpeed.value; loadWithRetry(attachment.dataUrl);
}
}); });
// Listen for global audio play events and pause if it's not this audio // Listen for global audio play events and pause if it's not this audio
@ -125,71 +133,83 @@ const downloadAudio = async () => {
</script> </script>
<template> <template>
<audio
ref="audioPlayer"
controls
class="hidden"
playsinline
@loadedmetadata="onLoadedMetadata"
@timeupdate="onTimeUpdate"
@ended="onEnd"
>
<source :src="timeStampURL" />
</audio>
<div <div
v-if="hasError"
v-bind="$attrs" v-bind="$attrs"
class="rounded-xl w-full gap-2 p-1.5 bg-n-alpha-white flex flex-col items-center border border-n-container shadow-[0px_2px_8px_0px_rgba(94,94,94,0.06)]" class="flex items-center gap-1 text-center rounded-lg p-2 bg-n-alpha-white border border-n-container"
> >
<div class="flex gap-1 w-full flex-1 items-center justify-start"> <Icon icon="i-lucide-circle-off" class="text-n-slate-11" />
<button class="p-0 border-0 size-8" @click="playOrPause"> <p class="mb-0 text-n-slate-11 text-sm">
<Icon {{ t('COMPONENTS.MEDIA.AUDIO_UNAVAILABLE') }}
v-if="isPlaying" </p>
class="size-8"
icon="i-teenyicons-pause-small-solid"
/>
<Icon v-else class="size-8" icon="i-teenyicons-play-small-solid" />
</button>
<div class="tabular-nums text-xs">
{{ formatTime(currentTime) }} / {{ formatTime(duration) }}
</div>
<div class="flex-1 items-center flex px-2">
<input
type="range"
min="0"
:max="duration"
:value="currentTime"
class="w-full h-1 bg-n-slate-12/40 rounded-lg appearance-none cursor-pointer accent-current"
@input="seek"
/>
</div>
<button
class="border-0 w-10 h-6 grid place-content-center bg-n-alpha-2 hover:bg-alpha-3 rounded-2xl"
@click="changePlaybackSpeed"
>
<span class="text-xs text-n-slate-11 font-medium">
{{ playbackSpeedLabel }}
</span>
</button>
<button
class="p-0 border-0 size-8 grid place-content-center"
@click="toggleMute"
>
<Icon v-if="isMuted" class="size-4" icon="i-lucide-volume-off" />
<Icon v-else class="size-4" icon="i-lucide-volume-2" />
</button>
<button
class="p-0 border-0 size-8 grid place-content-center"
@click="downloadAudio"
>
<Icon class="size-4" icon="i-lucide-download" />
</button>
</div>
<div
v-if="attachment.transcribedText && showTranscribedText"
class="text-n-slate-12 p-3 text-sm bg-n-alpha-1 rounded-lg w-full break-words"
>
{{ attachment.transcribedText }}
</div>
</div> </div>
<template v-else-if="isLoaded">
<audio
ref="audioPlayer"
controls
class="hidden"
playsinline
@loadedmetadata="onLoadedMetadata"
@timeupdate="onTimeUpdate"
@ended="onEnd"
>
<source :src="timeStampURL" />
</audio>
<div
v-bind="$attrs"
class="rounded-xl w-full gap-2 p-1.5 bg-n-alpha-white flex flex-col items-center border border-n-container shadow-[0px_2px_8px_0px_rgba(94,94,94,0.06)]"
>
<div class="flex gap-1 w-full flex-1 items-center justify-start">
<button class="p-0 border-0 size-8" @click="playOrPause">
<Icon
v-if="isPlaying"
class="size-8"
icon="i-teenyicons-pause-small-solid"
/>
<Icon v-else class="size-8" icon="i-teenyicons-play-small-solid" />
</button>
<div class="tabular-nums text-xs">
{{ formatTime(currentTime) }} / {{ formatTime(duration) }}
</div>
<div class="flex-1 items-center flex px-2">
<input
type="range"
min="0"
:max="duration"
:value="currentTime"
class="w-full h-1 bg-n-slate-12/40 rounded-lg appearance-none cursor-pointer accent-current"
@input="seek"
/>
</div>
<button
class="border-0 w-10 h-6 grid place-content-center bg-n-alpha-2 hover:bg-alpha-3 rounded-2xl"
@click="changePlaybackSpeed"
>
<span class="text-xs text-n-slate-11 font-medium">
{{ playbackSpeedLabel }}
</span>
</button>
<button
class="p-0 border-0 size-8 grid place-content-center"
@click="toggleMute"
>
<Icon v-if="isMuted" class="size-4" icon="i-lucide-volume-off" />
<Icon v-else class="size-4" icon="i-lucide-volume-2" />
</button>
<button
class="p-0 border-0 size-8 grid place-content-center"
@click="downloadAudio"
>
<Icon class="size-4" icon="i-lucide-download" />
</button>
</div>
<div
v-if="attachment.transcribedText && showTranscribedText"
class="text-n-slate-12 p-3 text-sm bg-n-alpha-1 rounded-lg w-full break-words"
>
{{ attachment.transcribedText }}
</div>
</div>
</template>
</template> </template>

View File

@ -65,6 +65,7 @@ provideSidebarContext({
const inboxes = useMapGetter('inboxes/getInboxes'); const inboxes = useMapGetter('inboxes/getInboxes');
const labels = useMapGetter('labels/getLabelsOnSidebar'); const labels = useMapGetter('labels/getLabelsOnSidebar');
const dashboardApps = useMapGetter('dashboardApps/getAppsOnSidebar');
const teams = useMapGetter('teams/getMyTeams'); const teams = useMapGetter('teams/getMyTeams');
const contactCustomViews = useMapGetter('customViews/getContactCustomViews'); const contactCustomViews = useMapGetter('customViews/getContactCustomViews');
const conversationCustomViews = useMapGetter( const conversationCustomViews = useMapGetter(
@ -79,6 +80,7 @@ onMounted(() => {
store.dispatch('attributes/get'); store.dispatch('attributes/get');
store.dispatch('customViews/get', 'conversation'); store.dispatch('customViews/get', 'conversation');
store.dispatch('customViews/get', 'contact'); store.dispatch('customViews/get', 'contact');
store.dispatch('dashboardApps/get');
}); });
const sortedInboxes = computed(() => const sortedInboxes = computed(() =>
@ -128,7 +130,7 @@ const newReportRoutes = () => [
const reportRoutes = computed(() => newReportRoutes()); const reportRoutes = computed(() => newReportRoutes());
const menuItems = computed(() => { const menuItems = computed(() => {
return [ const items = [
{ {
name: 'Inbox', name: 'Inbox',
label: t('SIDEBAR.INBOX'), label: t('SIDEBAR.INBOX'),
@ -589,6 +591,23 @@ const menuItems = computed(() => {
], ],
}, },
]; ];
if (dashboardApps.value.length > 0) {
const settingsIndex = items.findIndex(item => item.name === 'Settings');
items.splice(settingsIndex, 0, {
name: 'Apps',
label: t('SIDEBAR.APPS'),
icon: 'i-lucide-layout-grid',
children: dashboardApps.value.map(app => ({
name: `app-${app.id}`,
label: app.title,
to: accountScopedRoute('dashboard_app_view', { appId: app.id }),
activeOn: ['dashboard_app_view'],
})),
});
}
return items;
}); });
</script> </script>

View File

@ -73,7 +73,10 @@ const emitNewAccount = () => {
/> />
</button> </button>
</template> </template>
<DropdownBody v-if="showAccountSwitcher" class="min-w-80 z-50"> <DropdownBody
v-if="showAccountSwitcher"
class="min-w-80 z-50 max-h-[80vh] overflow-y-auto"
>
<DropdownSection :title="t('SIDEBAR_ITEMS.SWITCH_ACCOUNT')"> <DropdownSection :title="t('SIDEBAR_ITEMS.SWITCH_ACCOUNT')">
<DropdownItem <DropdownItem
v-for="account in sortedCurrentUserAccounts" v-for="account in sortedCurrentUserAccounts"

View File

@ -98,9 +98,11 @@ const activeChild = computed(() => {
return rankedPage ?? activeOnPages[0]; return rankedPage ?? activeOnPages[0];
} }
return navigableChildren.value.find( return navigableChildren.value.find(child => {
child => child.to && route.path.startsWith(resolvePath(child.to)) if (!child.to) return false;
); const childPath = resolvePath(child.to);
return route.path === childPath || route.path.startsWith(childPath + '/');
});
}); });
const hasActiveChild = computed(() => { const hasActiveChild = computed(() => {

View File

@ -1,6 +1,13 @@
<script setup> <script setup>
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
const props = defineProps({
id: {
type: String,
default: undefined,
},
});
const emit = defineEmits(['change']); const emit = defineEmits(['change']);
const { t } = useI18n(); const { t } = useI18n();
@ -18,6 +25,7 @@ const updateValue = () => {
<template> <template>
<button <button
:id="props.id"
type="button" type="button"
class="relative h-4 transition-colors duration-200 ease-in-out rounded-full w-7 focus:outline-none focus:ring-1 focus:ring-n-brand focus:ring-offset-n-slate-2 focus:ring-offset-2 flex-shrink-0" class="relative h-4 transition-colors duration-200 ease-in-out rounded-full w-7 focus:outline-none focus:ring-1 focus:ring-n-brand focus:ring-offset-n-slate-2 focus:ring-offset-2 flex-shrink-0"
:class="modelValue ? 'bg-n-brand' : 'bg-n-slate-6 disabled:bg-n-slate-6/60'" :class="modelValue ? 'bg-n-brand' : 'bg-n-slate-6 disabled:bg-n-slate-6/60'"

View File

@ -1,5 +1,5 @@
<script setup> <script setup>
import { computed } from 'vue'; import { computed, ref } from 'vue';
import 'highlight.js/styles/default.css'; import 'highlight.js/styles/default.css';
import 'highlight.js/lib/common'; import 'highlight.js/lib/common';
import NextButton from 'dashboard/components-next/button/Button.vue'; import NextButton from 'dashboard/components-next/button/Button.vue';
@ -24,10 +24,20 @@ const props = defineProps({
type: String, type: String,
default: 'Chatwoot Codepen', default: 'Chatwoot Codepen',
}, },
secure: {
type: Boolean,
default: false,
},
}); });
const { t } = useI18n(); const { t } = useI18n();
const isVisible = ref(false);
const toggleVisibility = () => {
isVisible.value = !isVisible.value;
};
const scrubbedScript = computed(() => { const scrubbedScript = computed(() => {
// remove trailing and leading extra lines and not spaces // remove trailing and leading extra lines and not spaces
const scrubbed = props.script.replace(/^\s*[\r\n]/gm, ''); const scrubbed = props.script.replace(/^\s*[\r\n]/gm, '');
@ -52,6 +62,10 @@ const codepenScriptValue = computed(() => {
}); });
}); });
const shouldShowScript = computed(() => {
return !props.secure || isVisible.value;
});
const onCopy = async e => { const onCopy = async e => {
e.preventDefault(); e.preventDefault();
await copyTextToClipboard(scrubbedScript.value); await copyTextToClipboard(scrubbedScript.value);
@ -80,6 +94,14 @@ const onCopy = async e => {
:label="t('COMPONENTS.CODE.CODEPEN')" :label="t('COMPONENTS.CODE.CODEPEN')"
/> />
</form> </form>
<NextButton
v-if="secure"
slate
xs
faded
:icon="isVisible ? 'i-lucide-eye-off' : 'i-lucide-eye'"
@click="toggleVisibility"
/>
<NextButton <NextButton
slate slate
xs xs
@ -89,10 +111,16 @@ const onCopy = async e => {
/> />
</div> </div>
<highlightjs <highlightjs
v-if="script" v-if="script && shouldShowScript"
:language="lang" :language="lang"
:code="scrubbedScript" :code="scrubbedScript"
class="[&_code]:text-start" class="[&_code]:text-start"
/> />
<highlightjs
v-else-if="script && secure && !isVisible"
:language="lang"
code="••••••••••••••••••••••••••••••••"
class="[&_code]:text-start"
/>
</div> </div>
</template> </template>

View File

@ -73,7 +73,7 @@ export default {
v-if="shouldShowBanner" v-if="shouldShowBanner"
color-scheme="primary" color-scheme="primary"
:banner-message="bannerMessage" :banner-message="bannerMessage"
href-link="https://github.com/chatwoot/chatwoot/releases" href-link="https://github.com/fazer-ai/chatwoot/releases"
:href-link-text="$t('GENERAL_SETTINGS.LEARN_MORE')" :href-link-text="$t('GENERAL_SETTINGS.LEARN_MORE')"
has-close-button has-close-button
@close="dismissUpdateBanner" @close="dismissUpdateBanner"

View File

@ -25,7 +25,7 @@ export default {
}, },
data() { data() {
return { return {
hasOpenedAtleastOnce: false, hasOpenedAtleastOnce: this.isVisible,
iframeLoading: true, iframeLoading: true,
}; };
}, },
@ -46,8 +46,8 @@ export default {
}, },
}, },
watch: { watch: {
isVisible() { isVisible(value) {
if (this.isVisible) { if (value) {
this.hasOpenedAtleastOnce = true; this.hasOpenedAtleastOnce = true;
} }
}, },

View File

@ -1,11 +1,24 @@
<script setup> <script setup>
import ChannelIcon from 'dashboard/components-next/icon/ChannelIcon.vue'; import ChannelIcon from 'dashboard/components-next/icon/ChannelIcon.vue';
import { computed } from 'vue';
defineProps({ const props = defineProps({
inbox: { inbox: {
type: Object, type: Object,
default: () => {}, default: () => {},
}, },
withPhoneNumber: {
type: Boolean,
default: false,
},
withProviderConnectionStatus: {
type: Boolean,
default: false,
},
});
const providerConnection = computed(() => {
return props.inbox.provider_connection?.connection;
}); });
</script> </script>
@ -18,5 +31,17 @@ defineProps({
<span class="truncate"> <span class="truncate">
{{ inbox.name }} {{ inbox.name }}
</span> </span>
<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

@ -23,6 +23,8 @@ import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useTrack } from 'dashboard/composables'; import { useTrack } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings'; import { useUISettings } from 'dashboard/composables/useUISettings';
import { useAlert } from 'dashboard/composables'; import { useAlert } from 'dashboard/composables';
import { useMapGetter } from 'dashboard/composables/store';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import { BUS_EVENTS } from 'shared/constants/busEvents'; import { BUS_EVENTS } from 'shared/constants/busEvents';
import { CONVERSATION_EVENTS } from 'dashboard/helper/AnalyticsHelper/events'; import { CONVERSATION_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
@ -44,11 +46,9 @@ import {
} from '@chatwoot/prosemirror-schema/src/mentions/plugin'; } from '@chatwoot/prosemirror-schema/src/mentions/plugin';
import { import {
appendSignature,
findNodeToInsertImage, findNodeToInsertImage,
getContentNode, getContentNode,
insertAtCursor, insertAtCursor,
removeSignature as removeSignatureHelper,
scrollCursorIntoView, scrollCursorIntoView,
setURLWithQueryAndSize, setURLWithQueryAndSize,
getFormattingForEditor, getFormattingForEditor,
@ -149,6 +149,10 @@ const createState = (content, placeholder, plugins = [], methods = {}) => {
const { isEditorHotKeyEnabled, fetchSignatureFlagFromUISettings } = const { isEditorHotKeyEnabled, fetchSignatureFlagFromUISettings } =
useUISettings(); useUISettings();
const { formatMessage } = useMessageFormatter();
const currentUser = useMapGetter('getCurrentUser');
const typingIndicator = createTypingIndicator( const typingIndicator = createTypingIndicator(
() => emit('typingOn'), () => emit('typingOn'),
() => emit('typingOff'), () => emit('typingOff'),
@ -274,8 +278,7 @@ const plugins = computed(() => {
}); });
const sendWithSignature = computed(() => { const sendWithSignature = computed(() => {
// this is considered the source of truth, we watch this property // this is considered the source of truth for signature display
// on change, we toggle the signature in the editor
if (props.allowSignature && !props.isPrivate && props.channelType) { if (props.allowSignature && !props.isPrivate && props.channelType) {
return fetchSignatureFlagFromUISettings(props.channelType); return fetchSignatureFlagFromUISettings(props.channelType);
} }
@ -283,6 +286,23 @@ const sendWithSignature = computed(() => {
return false; return false;
}); });
const signaturePosition = computed(() => {
return currentUser.value?.ui_settings?.signature_position || 'top';
});
const signatureSeparator = computed(() => {
return currentUser.value?.ui_settings?.signature_separator || 'blank';
});
const shouldShowSignaturePreview = computed(() => {
return sendWithSignature.value && props.signature;
});
const formattedSignature = computed(() => {
if (!props.signature) return '';
return formatMessage(props.signature, false, false);
});
watch(showUserMentions, updatedValue => { watch(showUserMentions, updatedValue => {
emit('toggleUserMention', props.isPrivate && updatedValue); emit('toggleUserMention', props.isPrivate && updatedValue);
}); });
@ -299,6 +319,8 @@ watch(showToolsMenu, updatedValue => {
function focusEditorInputField(pos = 'end') { function focusEditorInputField(pos = 'end') {
const { tr } = editorView.state; const { tr } = editorView.state;
// Signature is now displayed as read-only preview outside the editor,
// so cursor positioning is straightforward
const selection = const selection =
pos === 'end' ? Selection.atEnd(tr.doc) : Selection.atStart(tr.doc); pos === 'end' ? Selection.atEnd(tr.doc) : Selection.atStart(tr.doc);
@ -310,19 +332,8 @@ function isBodyEmpty(content) {
// if content is undefined, we assume that the body is empty // if content is undefined, we assume that the body is empty
if (!content) return true; if (!content) return true;
// if the signature is present, we need to remove it before checking
// note that we don't update the editorView, so this is safe
// Use effective channel type to match how signature was appended
const bodyWithoutSignature = props.signature
? removeSignatureHelper(
content,
props.signature,
effectiveChannelType.value
)
: content;
// trimming should remove all the whitespaces, so we can check the length // trimming should remove all the whitespaces, so we can check the length
return bodyWithoutSignature.trim().length === 0; return content.trim().length === 0;
} }
function handleEmptyBodyWithSignature() { function handleEmptyBodyWithSignature() {
@ -381,47 +392,6 @@ function reloadState(content = props.modelValue) {
focusEditor(unrefContent); focusEditor(unrefContent);
} }
function addSignature() {
let content = props.modelValue;
// see if the content is empty, if it is before appending the signature
// we need to add a paragraph node and move the cursor at the start of the editor
const contentWasEmpty = isBodyEmpty(content);
content = appendSignature(
content,
props.signature,
effectiveChannelType.value
);
// need to reload first, ensuring that the editorView is updated
reloadState(content);
if (contentWasEmpty) {
handleEmptyBodyWithSignature();
}
}
function removeSignature() {
if (!props.signature) return;
let content = props.modelValue;
content = removeSignatureHelper(
content,
props.signature,
effectiveChannelType.value
);
// reload the state, ensuring that the editorView is updated
reloadState(content);
}
function toggleSignatureInEditor(signatureEnabled) {
// The toggleSignatureInEditor gets the new value from the
// watcher, this means that if the value is true, the signature
// is supposed to be added, else we remove it.
if (signatureEnabled) {
addSignature();
} else {
removeSignature();
}
}
function setToolbarPosition() { function setToolbarPosition() {
const editorRect = editorRoot.value.getBoundingClientRect(); const editorRect = editorRoot.value.getBoundingClientRect();
const rect = selectedImageNode.value.getBoundingClientRect(); const rect = selectedImageNode.value.getBoundingClientRect();
@ -667,7 +637,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();
} }
}, },
@ -744,13 +718,6 @@ watch(
} }
); );
watch(sendWithSignature, newValue => {
// see if the allowSignature flag is true
if (props.allowSignature) {
toggleSignatureInEditor(newValue);
}
});
onMounted(() => { onMounted(() => {
// [VITE] state assignment was done in created before // [VITE] state assignment was done in created before
state = createState( state = createState(
@ -809,7 +776,33 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
hidden hidden
@change="onFileChange" @change="onFileChange"
/> />
<!-- Signature preview at top -->
<div
v-if="shouldShowSignaturePreview && signaturePosition === 'top'"
class="signature-preview signature-preview--top"
>
<div class="signature-label">
{{ t('CONVERSATION.FOOTER.SIGNATURE_LABEL_TOP') }}
</div>
<div v-dompurify-html="formattedSignature" class="signature-content" />
<div v-if="signatureSeparator === '--'" class="signature-separator">
{{ signatureSeparator }}
</div>
</div>
<div ref="editor" /> <div ref="editor" />
<!-- Signature preview at bottom -->
<div
v-if="shouldShowSignaturePreview && signaturePosition === 'bottom'"
class="signature-preview signature-preview--bottom"
>
<div class="signature-label">
{{ t('CONVERSATION.FOOTER.SIGNATURE_LABEL_BOTTOM') }}
</div>
<div v-if="signatureSeparator === '--'" class="signature-separator">
{{ signatureSeparator }}
</div>
<div v-dompurify-html="formattedSignature" class="signature-content" />
</div>
<div <div
v-show="isImageNodeSelected && showImageResizeToolbar" v-show="isImageNodeSelected && showImageResizeToolbar"
class="absolute shadow-md rounded-[6px] flex gap-1 py-1 px-1 bg-n-solid-3 outline outline-1 outline-n-weak text-n-slate-12" class="absolute shadow-md rounded-[6px] flex gap-1 py-1 px-1 bg-n-solid-3 outline outline-1 outline-n-weak text-n-slate-12"
@ -834,6 +827,42 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
<style lang="scss"> <style lang="scss">
@import '@chatwoot/prosemirror-schema/src/styles/base.scss'; @import '@chatwoot/prosemirror-schema/src/styles/base.scss';
.signature-preview {
@apply px-1 py-1 text-n-slate-10 text-sm pointer-events-none select-none opacity-70;
&--top {
@apply border-b border-n-weak pb-1;
.signature-separator {
@apply text-n-slate-9 mt-1 mb-0;
}
}
&--bottom {
@apply border-t border-n-weak pt-1 mt-2;
.signature-separator {
@apply text-n-slate-9 mb-1 mt-0;
}
}
.signature-label {
@apply text-xs text-n-slate-9 mb-1;
}
.signature-content {
@apply break-words;
:deep(p) {
@apply m-0 text-n-slate-10;
}
:deep(a) {
@apply text-n-slate-10 no-underline;
}
}
}
.ProseMirror-menubar-wrapper { .ProseMirror-menubar-wrapper {
@apply flex flex-col gap-3; @apply flex flex-col gap-3;

View File

@ -333,7 +333,7 @@ export default {
v-if="showMessageSignatureButton" v-if="showMessageSignatureButton"
v-tooltip.top-end="signatureToggleTooltip" v-tooltip.top-end="signatureToggleTooltip"
icon="i-ph-signature" icon="i-ph-signature"
slate :color="sendWithSignature ? 'blue' : 'slate'"
faded faded
sm sm
@click="toggleMessageSignature" @click="toggleMessageSignature"

View File

@ -39,6 +39,9 @@ export default {
currentChat: 'getSelectedChat', currentChat: 'getSelectedChat',
dashboardApps: 'dashboardApps/getRecords', dashboardApps: 'dashboardApps/getRecords',
}), }),
conversationDashboardApps() {
return this.dashboardApps.filter(app => !app.show_on_sidebar);
},
dashboardAppTabs() { dashboardAppTabs() {
return [ return [
{ {
@ -46,7 +49,7 @@ export default {
index: 0, index: 0,
name: this.$t('CONVERSATION.DASHBOARD_APP_TAB_MESSAGES'), name: this.$t('CONVERSATION.DASHBOARD_APP_TAB_MESSAGES'),
}, },
...this.dashboardApps.map((dashboardApp, index) => ({ ...this.conversationDashboardApps.map((dashboardApp, index) => ({
key: `dashboard-${dashboardApp.id}`, key: `dashboard-${dashboardApp.id}`,
index: index + 1, index: index + 1,
name: dashboardApp.title, name: dashboardApp.title,
@ -102,7 +105,7 @@ export default {
:show-back-button="isOnExpandedLayout && !isInboxView" :show-back-button="isOnExpandedLayout && !isInboxView"
/> />
<woot-tabs <woot-tabs
v-if="dashboardApps.length && currentChat.id" v-if="conversationDashboardApps.length && currentChat.id"
:index="activeIndex" :index="activeIndex"
class="-mt-px border-t border-t-n-background" class="-mt-px border-t border-t-n-background"
@change="onDashboardAppTabChange" @change="onDashboardAppTabChange"
@ -130,11 +133,11 @@ export default {
<slot /> <slot />
</div> </div>
<DashboardAppFrame <DashboardAppFrame
v-for="(dashboardApp, index) in dashboardApps" v-for="(dashboardApp, index) in conversationDashboardApps"
v-show="activeIndex - 1 === index" v-show="activeIndex - 1 === index"
:key="currentChat.id + '-' + dashboardApp.id" :key="currentChat.id + '-' + dashboardApp.id"
:is-visible="activeIndex - 1 === index" :is-visible="activeIndex - 1 === index"
:config="dashboardApps[index].content" :config="conversationDashboardApps[index].content"
:position="index" :position="index"
:current-chat="currentChat" :current-chat="currentChat"
/> />

View File

@ -5,6 +5,9 @@ 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 { useSnakeCase } from 'dashboard/composables/useTransformKeys'; import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
import { useAdmin } from 'dashboard/composables/useAdmin';
import { useAlert } from 'dashboard/composables';
import { useStore } from 'vuex';
// components // components
import ReplyBox from './ReplyBox.vue'; import ReplyBox from './ReplyBox.vue';
@ -36,6 +39,7 @@ import { REPLY_POLICY } from 'shared/constants/links';
import wootConstants from 'dashboard/constants/globals'; import wootConstants from 'dashboard/constants/globals';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage'; import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { INBOX_TYPES } from 'dashboard/helper/inbox'; import { INBOX_TYPES } from 'dashboard/helper/inbox';
import WhatsappLinkDeviceModal from '../../../routes/dashboard/settings/inbox/components/WhatsappLinkDeviceModal.vue';
export default { export default {
components: { components: {
@ -44,12 +48,15 @@ export default {
Banner, Banner,
ConversationLabelSuggestion, ConversationLabelSuggestion,
Spinner, Spinner,
WhatsappLinkDeviceModal,
}, },
mixins: [inboxMixin], mixins: [inboxMixin],
setup() { setup() {
const { isAdmin } = useAdmin();
const isPopOutReplyBox = ref(false); const isPopOutReplyBox = ref(false);
const conversationPanelRef = ref(null); const conversationPanelRef = ref(null);
const { isEnterprise } = useConfig(); const { isEnterprise } = useConfig();
const store = useStore();
const keyboardEvents = { const keyboardEvents = {
Escape: { Escape: {
@ -78,6 +85,8 @@ export default {
fetchIntegrationsIfRequired, fetchIntegrationsIfRequired,
fetchLabelSuggestions, fetchLabelSuggestions,
conversationPanelRef, conversationPanelRef,
isAdmin,
store,
}; };
}, },
data() { data() {
@ -89,6 +98,7 @@ export default {
isProgrammaticScroll: false, isProgrammaticScroll: false,
messageSentSinceOpened: false, messageSentSinceOpened: false,
labelSuggestions: [], labelSuggestions: [],
showLinkDeviceModal: false,
}; };
}, },
@ -99,6 +109,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;
}, },
@ -249,6 +262,9 @@ export default {
return { incoming, outgoing }; return { incoming, outgoing };
}, },
inboxProviderConnection() {
return this.currentInbox.provider_connection?.connection;
},
}, },
watch: { watch: {
@ -451,12 +467,75 @@ export default {
const payload = useSnakeCase(message); const payload = useSnakeCase(message);
await this.$store.dispatch('sendMessageWithData', payload); await this.$store.dispatch('sendMessageWithData', payload);
}, },
getInReplyToMessage(parentMessage) {
if (!parentMessage) return {};
const inReplyToMessageId = parentMessage.content_attributes?.in_reply_to;
if (!inReplyToMessageId) return {};
return this.currentChat?.messages.find(message => {
if (message.id === inReplyToMessageId) {
return true;
}
return false;
});
},
onOpenLinkDeviceModal() {
this.showLinkDeviceModal = true;
},
onCloseLinkDeviceModal() {
this.showLinkDeviceModal = false;
},
onSetupProviderConnection() {
this.store
.dispatch('inboxes/setupChannelProvider', this.inbox.id)
.catch(e => {
// eslint-disable-next-line no-console
console.error('Error setting up provider connection:', e);
useAlert(
this.$t(
'CONVERSATION.INBOX.WHATSAPP_PROVIDER_CONNECTION.RECONNECT_FAILED'
)
);
});
},
}, },
}; };
</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 || isAWhatsAppZapiChannel">
<WhatsappLinkDeviceModal
v-if="showLinkDeviceModal"
:show="showLinkDeviceModal"
:on-close="onCloseLinkDeviceModal"
: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_PROVIDER_CONNECTION.NOT_CONNECTED'
)
: $t(
'CONVERSATION.INBOX.WHATSAPP_PROVIDER_CONNECTION.NOT_CONNECTED_CONTACT_ADMIN'
)
"
has-action-button
:action-button-label="
isAdmin
? $t('CONVERSATION.INBOX.WHATSAPP_PROVIDER_CONNECTION.LINK_DEVICE')
: ''
"
:action-button-icon="isAdmin ? '' : 'i-lucide-refresh-cw'"
@primary-action="
isAdmin ? onOpenLinkDeviceModal() : onSetupProviderConnection()
"
/>
</template>
<Banner <Banner
v-if="!currentChat.can_reply" v-if="!currentChat.can_reply"
color-scheme="alert" color-scheme="alert"

View File

@ -4,6 +4,7 @@ import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables'; import { useAlert } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings'; import { useUISettings } from 'dashboard/composables/useUISettings';
import { useTrack } from 'dashboard/composables'; import { useTrack } from 'dashboard/composables';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins'; import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import { FEATURE_FLAGS } from 'dashboard/featureFlags'; import { FEATURE_FLAGS } from 'dashboard/featureFlags';
@ -40,11 +41,7 @@ import {
} from 'dashboard/helper/quotedEmailHelper'; } from 'dashboard/helper/quotedEmailHelper';
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events'; import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
import fileUploadMixin from 'dashboard/mixins/fileUploadMixin'; import fileUploadMixin from 'dashboard/mixins/fileUploadMixin';
import { import { appendSignature } from 'dashboard/helper/editorHelper';
appendSignature,
removeSignature,
getEffectiveChannelType,
} from 'dashboard/helper/editorHelper';
import { isFileTypeAllowedForChannel } from 'shared/helpers/FileHelper'; import { isFileTypeAllowedForChannel } from 'shared/helpers/FileHelper';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage'; import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
@ -88,6 +85,8 @@ export default {
fetchQuotedReplyFlagFromUISettings, fetchQuotedReplyFlagFromUISettings,
} = useUISettings(); } = useUISettings();
const { formatMessage } = useMessageFormatter();
const replyEditor = useTemplateRef('replyEditor'); const replyEditor = useTemplateRef('replyEditor');
return { return {
@ -97,6 +96,7 @@ export default {
setQuotedReplyFlagForInbox, setQuotedReplyFlagForInbox,
fetchQuotedReplyFlagFromUISettings, fetchQuotedReplyFlagFromUISettings,
replyEditor, replyEditor,
formatMessage,
}; };
}, },
data() { data() {
@ -124,7 +124,6 @@ export default {
showVariablesMenu: false, showVariablesMenu: false,
newConversationModalActive: false, newConversationModalActive: false,
showArticleSearchPopover: false, showArticleSearchPopover: false,
hasRecordedAudio: false,
}; };
}, },
computed: { computed: {
@ -280,6 +279,9 @@ export default {
hasAttachments() { hasAttachments() {
return this.attachedFiles.length; return this.attachedFiles.length;
}, },
hasRecordedAudio() {
return this.attachedFiles.some(file => file.isRecordedAudio);
},
showAudioRecorder() { showAudioRecorder() {
return !this.isOnPrivateNote && this.showFileUpload; return !this.isOnPrivateNote && this.showFileUpload;
}, },
@ -400,6 +402,25 @@ export default {
!!this.quotedEmailText !!this.quotedEmailText
); );
}, },
// Signature preview for non-rich editor (WhatsApp, etc.)
shouldShowSignaturePreview() {
return (
this.sendWithSignature &&
this.messageSignature &&
!this.isPrivate &&
!this.showRichContentEditor
);
},
signaturePosition() {
return this.currentUser?.ui_settings?.signature_position || 'top';
},
signatureSeparator() {
return this.currentUser?.ui_settings?.signature_separator || 'blank';
},
formattedSignature() {
if (!this.messageSignature) return '';
return this.formatMessage(this.messageSignature, false, false);
},
}, },
watch: { watch: {
currentChat(conversation, oldConversation) { currentChat(conversation, oldConversation) {
@ -559,24 +580,9 @@ export default {
const key = `draft-${this.conversationIdByRoute}-${this.replyType}`; const key = `draft-${this.conversationIdByRoute}-${this.replyType}`;
const messageFromStore = const messageFromStore =
this.$store.getters['draftMessages/get'](key) || ''; this.$store.getters['draftMessages/get'](key) || '';
this.message = messageFromStore;
// ensure that the message has signature set based on the ui setting
this.message = this.toggleSignatureForDraft(messageFromStore);
} }
}, },
toggleSignatureForDraft(message) {
if (this.isPrivate) {
return message;
}
const effectiveChannelType = getEffectiveChannelType(
this.channelType,
this.inbox?.medium || ''
);
return this.sendWithSignature
? appendSignature(message, this.messageSignature, effectiveChannelType)
: removeSignature(message, this.messageSignature, effectiveChannelType);
},
removeFromDraft() { removeFromDraft() {
if (this.conversationIdByRoute) { if (this.conversationIdByRoute) {
const key = `draft-${this.conversationIdByRoute}-${this.replyType}`; const key = `draft-${this.conversationIdByRoute}-${this.replyType}`;
@ -631,6 +637,18 @@ export default {
this.isEditorHotKeyEnabled(selectedKey) this.isEditorHotKeyEnabled(selectedKey)
); );
}, },
applySignatureToMessage(message) {
if (!this.sendWithSignature || !this.messageSignature) {
return message;
}
const { signature_position, signature_separator } =
this.currentUser?.ui_settings || {};
const signatureSettings = {
position: signature_position || 'top',
separator: signature_separator || 'blank',
};
return appendSignature(message, this.messageSignature, signatureSettings);
},
onPaste(e) { onPaste(e) {
// Don't handle paste if compose new conversation modal is open // Don't handle paste if compose new conversation modal is open
if (this.newConversationModalActive) return; if (this.newConversationModalActive) return;
@ -783,21 +801,6 @@ export default {
this.hideContentTemplatesModal(); this.hideContentTemplatesModal();
}, },
replaceText(message) { replaceText(message) {
if (this.sendWithSignature && !this.private) {
// if signature is enabled, append it to the message
// appendSignature ensures that the signature is not duplicated
// so we don't need to check if the signature is already present
const effectiveChannelType = getEffectiveChannelType(
this.channelType,
this.inbox?.medium || ''
);
message = appendSignature(
message,
this.messageSignature,
effectiveChannelType
);
}
const updatedMessage = replaceVariablesInMessage({ const updatedMessage = replaceVariablesInMessage({
message, message,
variables: this.messageVariables, variables: this.messageVariables,
@ -832,18 +835,6 @@ export default {
}, },
clearMessage() { clearMessage() {
this.message = ''; this.message = '';
if (this.sendWithSignature && !this.isPrivate) {
// if signature is enabled, append it to the message
const effectiveChannelType = getEffectiveChannelType(
this.channelType,
this.inbox?.medium || ''
);
this.message = appendSignature(
this.message,
this.messageSignature,
effectiveChannelType
);
}
this.attachedFiles = []; this.attachedFiles = [];
this.isRecordingAudio = false; this.isRecordingAudio = false;
this.resetReplyToMessage(); this.resetReplyToMessage();
@ -862,6 +853,9 @@ export default {
this.isRecordingAudio = !this.isRecordingAudio; this.isRecordingAudio = !this.isRecordingAudio;
if (!this.isRecordingAudio) { if (!this.isRecordingAudio) {
this.resetAudioRecorderInput(); this.resetAudioRecorderInput();
this.onTypingOff();
} else {
this.onRecording();
} }
}, },
toggleAudioRecorderPlayPause() { toggleAudioRecorderPlayPause() {
@ -869,6 +863,7 @@ export default {
if (!this.recordingAudioState) { if (!this.recordingAudioState) {
this.$refs.audioRecorderInput.stopRecording(); this.$refs.audioRecorderInput.stopRecording();
} else { } else {
this.onTypingOff();
this.$refs.audioRecorderInput.playPause(); this.$refs.audioRecorderInput.playPause();
} }
}, },
@ -880,6 +875,9 @@ export default {
onTypingOn() { onTypingOn() {
this.toggleTyping('on'); this.toggleTyping('on');
}, },
onRecording() {
this.toggleTyping('recording');
},
onTypingOff() { onTypingOff() {
this.toggleTyping('off'); this.toggleTyping('off');
}, },
@ -895,7 +893,9 @@ export default {
}, },
onFinishRecorder(file) { onFinishRecorder(file) {
this.recordingAudioState = 'stopped'; this.recordingAudioState = 'stopped';
this.hasRecordedAudio = true;
this.removeRecordedAudio();
// Added a new key isRecordedAudio to the file to find it's and recorded audio // Added a new key isRecordedAudio to the file to find it's and recorded audio
// Because to filter and show only non recorded audio and other attachments // Because to filter and show only non recorded audio and other attachments
const autoRecordedFile = { const autoRecordedFile = {
@ -919,6 +919,10 @@ export default {
}); });
}, },
attachFile({ blob, file }) { attachFile({ blob, file }) {
if (file?.isRecordedAudio) {
this.removeRecordedAudio();
}
const reader = new FileReader(); const reader = new FileReader();
reader.readAsDataURL(file.file); reader.readAsDataURL(file.file);
reader.onloadend = () => { reader.onloadend = () => {
@ -951,8 +955,10 @@ export default {
getMultipleMessagesPayload(message) { getMultipleMessagesPayload(message) {
const multipleMessagePayload = []; const multipleMessagePayload = [];
if (this.attachedFiles && this.attachedFiles.length) { const messageWithSignature = this.applySignatureToMessage(message);
let caption = this.isAnInstagramChannel ? '' : message;
if (this.attachedFiles?.length) {
let caption = this.isAnInstagramChannel ? '' : messageWithSignature;
this.attachedFiles.forEach(attachment => { this.attachedFiles.forEach(attachment => {
const attachedFile = this.globalConfig.directUploadsEnabled const attachedFile = this.globalConfig.directUploadsEnabled
? attachment.blobSignedId ? attachment.blobSignedId
@ -972,8 +978,7 @@ export default {
}); });
} }
const hasNoAttachments = const hasNoAttachments = !this.attachedFiles?.length;
!this.attachedFiles || !this.attachedFiles.length;
// For Instagram, we need a separate text message // For Instagram, we need a separate text message
// For WhatsApp, we only need a text message if there are no attachments // For WhatsApp, we only need a text message if there are no attachments
if ( if (
@ -982,7 +987,7 @@ export default {
) { ) {
let messagePayload = { let messagePayload = {
conversationId: this.currentChat.id, conversationId: this.currentChat.id,
message, message: messageWithSignature,
private: false, private: false,
sender: this.sender, sender: this.sender,
}; };
@ -995,23 +1000,32 @@ export default {
return multipleMessagePayload; return multipleMessagePayload;
}, },
getMessagePayload(message) { getMessagePayload(message) {
const messageWithQuote = this.getMessageWithQuotedEmailText(message); let finalMessage = this.getMessageWithQuotedEmailText(message);
if (!this.isPrivate) {
finalMessage = this.applySignatureToMessage(finalMessage);
}
let messagePayload = { let messagePayload = {
conversationId: this.currentChat.id, conversationId: this.currentChat.id,
message: messageWithQuote, message: finalMessage,
private: this.isPrivate, private: this.isPrivate,
sender: this.sender, sender: this.sender,
}; };
messagePayload = this.setReplyToInPayload(messagePayload); messagePayload = this.setReplyToInPayload(messagePayload);
if (this.attachedFiles && this.attachedFiles.length) { if (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
);
}
} }
}); });
} }
@ -1086,8 +1100,10 @@ export default {
this.recordingAudioDurationText = '00:00'; this.recordingAudioDurationText = '00:00';
this.isRecordingAudio = false; this.isRecordingAudio = false;
this.recordingAudioState = ''; this.recordingAudioState = '';
this.hasRecordedAudio = false;
// Only clear the recorded audio when we click toggle button. // Only clear the recorded audio when we click toggle button.
this.removeRecordedAudio();
},
removeRecordedAudio() {
this.attachedFiles = this.attachedFiles.filter( this.attachedFiles = this.attachedFiles.filter(
file => !file?.isRecordedAudio file => !file?.isRecordedAudio
); );
@ -1146,6 +1162,21 @@ export default {
@play="recordingAudioState = 'playing'" @play="recordingAudioState = 'playing'"
@pause="recordingAudioState = 'paused'" @pause="recordingAudioState = 'paused'"
/> />
<div
v-if="shouldShowSignaturePreview && signaturePosition === 'top'"
class="signature-preview px-2 py-1 text-slate-500 dark:text-slate-400 text-sm opacity-70 select-none border-b border-slate-100 dark:border-slate-700"
>
<div class="text-xs text-slate-400 dark:text-slate-500 mb-1">
{{ $t('CONVERSATION.FOOTER.SIGNATURE_LABEL_TOP') }}
</div>
<div v-dompurify-html="formattedSignature" />
<div
v-if="signatureSeparator === '--'"
class="text-slate-400 dark:text-slate-500 mt-1"
>
{{ signatureSeparator }}
</div>
</div>
<WootMessageEditor <WootMessageEditor
v-model="message" v-model="message"
:editor-id="editorStateId" :editor-id="editorStateId"
@ -1169,6 +1200,21 @@ export default {
@toggle-variables-menu="toggleVariablesMenu" @toggle-variables-menu="toggleVariablesMenu"
@clear-selection="clearEditorSelection" @clear-selection="clearEditorSelection"
/> />
<div
v-if="shouldShowSignaturePreview && signaturePosition === 'bottom'"
class="signature-preview px-2 py-1 mt-2 text-slate-500 dark:text-slate-400 text-sm opacity-70 select-none border-t border-slate-100 dark:border-slate-700"
>
<div class="text-xs text-slate-400 dark:text-slate-500 mb-1">
{{ $t('CONVERSATION.FOOTER.SIGNATURE_LABEL_BOTTOM') }}
</div>
<div
v-if="signatureSeparator === '--'"
class="text-slate-400 dark:text-slate-500 mb-1"
>
{{ signatureSeparator }}
</div>
<div v-dompurify-html="formattedSignature" />
</div>
<QuotedEmailPreview <QuotedEmailPreview
v-if="shouldShowQuotedPreview" v-if="shouldShowQuotedPreview"
:quoted-email-text="quotedEmailText" :quoted-email-text="quotedEmailText"

View File

@ -1,28 +1,35 @@
import { ref } from 'vue'; import { ref } from 'vue';
export const useLoadWithRetry = (config = {}) => { export const useLoadWithRetry = (config = {}) => {
const maxRetry = config.max_retry || 3; const maxRetry = config.maxRetry || 3;
const backoff = config.backoff || 1000; const backoff = config.backoff || 1000;
const type = config.type || '';
const isLoaded = ref(false); const isLoaded = ref(false);
const hasError = ref(false); const hasError = ref(false);
const loadWithRetry = async url => { const loadWithRetry = async url => {
const attemptLoad = () => { const attemptLoad = async () => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const img = new Image(); let media;
if (type === 'image') {
img.onload = () => { media = new Image();
isLoaded.value = true; media.onload = () => resolve();
hasError.value = false; media.onerror = () => reject(new Error('Failed to load image'));
resolve(); } else if (type === 'audio') {
}; media = new Audio();
media.onloadedmetadata = () => resolve();
img.onerror = () => { media.onerror = () => reject(new Error('Failed to load audio'));
reject(new Error('Failed to load image')); } else {
}; fetch(url)
.then(res => {
img.src = url; if (res.ok) resolve();
else reject(new Error('Failed to load resource'));
})
.catch(err => reject(err));
return;
}
media.src = url;
}); });
}; };
@ -35,6 +42,8 @@ export const useLoadWithRetry = (config = {}) => {
const retry = async (attempt = 0) => { const retry = async (attempt = 0) => {
try { try {
await attemptLoad(); await attemptLoad();
hasError.value = false;
isLoaded.value = true;
} catch (error) { } catch (error) {
if (attempt + 1 >= maxRetry) { if (attempt + 1 >= maxRetry) {
hasError.value = true; hasError.value = true;

View File

@ -144,8 +144,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

@ -119,6 +119,20 @@ export const useInbox = (inboxId = null) => {
); );
}); });
const isAWhatsAppBaileysChannel = computed(() => {
return (
channelType.value === INBOX_TYPES.WHATSAPP &&
whatsAppAPIProvider.value === 'baileys'
);
});
const isAWhatsAppZapiChannel = computed(() => {
return (
channelType.value === INBOX_TYPES.WHATSAPP &&
whatsAppAPIProvider.value === 'zapi'
);
});
const isAWhatsAppChannel = computed(() => { const isAWhatsAppChannel = computed(() => {
return ( return (
channelType.value === INBOX_TYPES.WHATSAPP || channelType.value === INBOX_TYPES.WHATSAPP ||
@ -153,6 +167,8 @@ export const useInbox = (inboxId = null) => {
isATwilioWhatsAppChannel, isATwilioWhatsAppChannel,
isAWhatsAppCloudChannel, isAWhatsAppCloudChannel,
is360DialogWhatsAppChannel, is360DialogWhatsAppChannel,
isAWhatsAppBaileysChannel,
isAWhatsAppZapiChannel,
isAnEmailChannel, isAnEmailChannel,
isAnInstagramChannel, isAnInstagramChannel,
isATiktokChannel, isATiktokChannel,

View File

@ -126,7 +126,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

@ -285,7 +285,7 @@ export const getInputType = (
return getCustomAttributeInputType(customAttribute.attribute_display_type); return getCustomAttributeInputType(customAttribute.attribute_display_type);
} }
const type = getAutomationType(automationTypes, automation, key); const type = getAutomationType(automationTypes, automation, key);
return type.inputType; return type?.inputType ?? '';
}; };
/** /**
@ -311,7 +311,7 @@ export const getOperators = (
} }
} }
const type = getAutomationType(automationTypes, automation, key); const type = getAutomationType(automationTypes, automation, key);
return type.filterOperators; return type?.filterOperators ?? [];
}; };
/** /**
@ -322,9 +322,10 @@ export const getOperators = (
* @returns {string} The custom attribute type. * @returns {string} The custom attribute type.
*/ */
export const getCustomAttributeType = (automationTypes, automation, key) => { export const getCustomAttributeType = (automationTypes, automation, key) => {
return automationTypes[automation.event_name].conditions.find( return (
i => i.key === key automationTypes[automation.event_name].conditions.find(i => i.key === key)
).customAttributeType; ?.customAttributeType ?? ''
);
}; };
/** /**
@ -336,6 +337,6 @@ export const getCustomAttributeType = (automationTypes, automation, key) => {
export const showActionInput = (automationActionTypes, action) => { export const showActionInput = (automationActionTypes, action) => {
if (action === 'send_email_to_team' || action === 'send_message') if (action === 'send_email_to_team' || action === 'send_message')
return false; return false;
const type = automationActionTypes.find(i => i.key === action).inputType; const type = automationActionTypes.find(i => i.key === action)?.inputType;
return !!type; return !!type;
}; };

View File

@ -103,16 +103,6 @@ export function cleanSignature(signature) {
} }
} }
/**
* Adds the signature delimiter to the beginning of the signature.
*
* @param {string} signature - The signature to add the delimiter to.
* @returns {string} - The signature with the delimiter added.
*/
function appendDelimiter(signature) {
return `${SIGNATURE_DELIMITER}\n\n${cleanSignature(signature)}`;
}
/** /**
* Check if there's an unedited signature at the end of the body * Check if there's an unedited signature at the end of the body
* If there is, return the index of the signature, If there isn't, return -1 * If there is, return the index of the signature, If there isn't, return -1
@ -156,22 +146,28 @@ export function getEffectiveChannelType(channelType, medium) {
* *
* @param {string} body - The body to append the signature to. * @param {string} body - The body to append the signature to.
* @param {string} signature - The signature to append. * @param {string} signature - The signature to append.
* @param {string} channelType - Optional. The effective channel type to determine supported formatting. * @param {Object} settings - The signature settings (position, separator).
* For Twilio channels, pass the result of getEffectiveChannelType().
* @returns {string} - The body with the signature appended. * @returns {string} - The body with the signature appended.
*/ */
export function appendSignature(body, signature, channelType) { export function appendSignature(body, signature, settings = {}) {
// Strip only unsupported formatting based on channel capabilities const position = settings.position || 'top';
const preparedSignature = channelType const separator = settings.separator || 'blank';
? stripUnsupportedMarkdown(signature, channelType) const cleanedSignature = cleanSignature(signature);
: signature;
const cleanedSignature = cleanSignature(preparedSignature);
// if signature is already present, return body // if signature is already present, return body
if (findSignatureInBody(body, cleanedSignature) > -1) { if (findSignatureInBody(body, cleanedSignature).index > -1) {
return body; return body;
} }
return `${body.trimEnd()}\n\n${appendDelimiter(cleanedSignature)}`; const delimiter =
{
blank: '\n\n',
'--': '\n\n--\n\n',
}[separator] || separator;
if (position === 'top') {
return `${cleanedSignature}${delimiter}${body.trimStart()}`;
}
return `${body.trimEnd()}${delimiter}${cleanedSignature}`;
} }
/** /**

View File

@ -391,6 +391,17 @@ describe('getInputType', () => {
); );
expect(result).toEqual('search_select'); expect(result).toEqual('search_select');
}); });
it('returns empty string when attribute key is not found', () => {
const mockAutomation = { event_name: 'message_created' };
const result = helpers.getInputType(
customAttributes,
AUTOMATIONS,
mockAutomation,
'non_existent_key'
);
expect(result).toEqual('');
});
}); });
describe('getOperators', () => { describe('getOperators', () => {
@ -420,6 +431,18 @@ describe('getOperators', () => {
.filterOperators .filterOperators
); );
}); });
it('returns empty array when attribute key is not found', () => {
const mockAutomation = { event_name: 'message_created' };
const result = helpers.getOperators(
customAttributes,
AUTOMATIONS,
mockAutomation,
'create',
'non_existent_key'
);
expect(result).toEqual([]);
});
}); });
describe('getCustomAttributeType', () => { describe('getCustomAttributeType', () => {
@ -430,10 +453,18 @@ describe('getCustomAttributeType', () => {
mockAutomation, mockAutomation,
'message_type' 'message_type'
); );
expect(result).toEqual( // message_type condition doesn't have customAttributeType defined, so it returns empty string
AUTOMATIONS.message_created.conditions.find(c => c.key === 'message_type') expect(result).toEqual('');
.customAttributeType });
it('returns empty string when attribute key is not found', () => {
const mockAutomation = { event_name: 'message_created' };
const result = helpers.getCustomAttributeType(
AUTOMATIONS,
mockAutomation,
'non_existent_key'
); );
expect(result).toEqual('');
}); });
}); });
@ -452,4 +483,11 @@ describe('showActionInput', () => {
const mockActionTypes = [{ key: 'some_action', inputType: null }]; const mockActionTypes = [{ key: 'some_action', inputType: null }];
expect(helpers.showActionInput(mockActionTypes, 'some_action')).toBe(false); expect(helpers.showActionInput(mockActionTypes, 'some_action')).toBe(false);
}); });
it('returns false when action key is not found in action types', () => {
const mockActionTypes = [{ key: 'add_label', inputType: 'select' }];
expect(
helpers.showActionInput(mockActionTypes, 'non_existent_action')
).toBe(false);
});
}); });

View File

@ -112,17 +112,17 @@ const HAS_SIGNATURE = {
}, },
}; };
describe('findSignatureInBody', () => { describe.skip('findSignatureInBody - SKIP(#78): Due to changes on append signature logic', () => {
it('returns -1 if there is no signature', () => { it('returns -1 if there is no signature', () => {
Object.keys(DOES_NOT_HAVE_SIGNATURE).forEach(key => { Object.keys(DOES_NOT_HAVE_SIGNATURE).forEach(key => {
const { body, signature } = DOES_NOT_HAVE_SIGNATURE[key]; const { body, signature } = DOES_NOT_HAVE_SIGNATURE[key];
expect(findSignatureInBody(body, signature)).toBe(-1); expect(findSignatureInBody(body, signature).index).toBe(-1);
}); });
}); });
it('returns the index of the signature if there is one', () => { it('returns the index of the signature if there is one', () => {
Object.keys(HAS_SIGNATURE).forEach(key => { Object.keys(HAS_SIGNATURE).forEach(key => {
const { body, signature } = HAS_SIGNATURE[key]; const { body, signature } = HAS_SIGNATURE[key];
expect(findSignatureInBody(body, signature)).toBeGreaterThan(0); expect(findSignatureInBody(body, signature).index).toBeGreaterThan(0);
}); });
}); });
}); });
@ -133,11 +133,48 @@ describe('appendSignature', () => {
const { body, signature } = DOES_NOT_HAVE_SIGNATURE[key]; const { body, signature } = DOES_NOT_HAVE_SIGNATURE[key];
const cleanedSignature = cleanSignature(signature); const cleanedSignature = cleanSignature(signature);
expect( expect(
appendSignature(body, signature).includes(cleanedSignature) appendSignature(body, signature, {
position: 'bottom',
separator: '--',
}).includes(cleanedSignature)
).toBeTruthy(); ).toBeTruthy();
}); });
}); });
it('does not append signature if already present', () => {
it('appends the signature at the top with -- separator', () => {
const { body, signature } = DOES_NOT_HAVE_SIGNATURE['no signature'];
const cleanedSignature = cleanSignature(signature);
expect(
appendSignature(body, signature, {
position: 'top',
separator: '--',
})
).toBe(`${cleanedSignature}\n\n--\n\n${body}`);
});
it('appends the signature at the bottom with blank separator', () => {
const { body, signature } = DOES_NOT_HAVE_SIGNATURE['no signature'];
const cleanedSignature = cleanSignature(signature);
expect(
appendSignature(body, signature, {
position: 'bottom',
separator: 'blank',
})
).toBe(`${body}\n\n${cleanedSignature}`);
});
it('appends the signature at the top with blank separator', () => {
const { body, signature } = DOES_NOT_HAVE_SIGNATURE['no signature'];
const cleanedSignature = cleanSignature(signature);
expect(
appendSignature(body, signature, {
position: 'top',
separator: 'blank',
})
).toBe(`${cleanedSignature}\n\n${body}`);
});
it.skip('does not append signature if already present - SKIP(#78): Due to changes on append signature logic', () => {
Object.keys(HAS_SIGNATURE).forEach(key => { Object.keys(HAS_SIGNATURE).forEach(key => {
const { body, signature } = HAS_SIGNATURE[key]; const { body, signature } = HAS_SIGNATURE[key];
expect(appendSignature(body, signature)).toBe(body); expect(appendSignature(body, signature)).toBe(body);
@ -231,7 +268,7 @@ describe('stripUnsupportedMarkdown', () => {
}); });
}); });
describe('appendSignature with channelType', () => { describe.skip('appendSignature with channelType - SKIP(#78): Due to changes on append signature logic', () => {
const signatureWithImage = const signatureWithImage =
'Thanks\n![](http://localhost:3000/image.png?cw_image_height=24px)'; 'Thanks\n![](http://localhost:3000/image.png?cw_image_height=24px)';
@ -309,7 +346,7 @@ describe('cleanSignature', () => {
}); });
}); });
describe('removeSignature', () => { describe.skip('removeSignature - SKIP(#78): Due to changes on append signature logic', () => {
it('does not remove signature if not present', () => { it('does not remove signature if not present', () => {
Object.keys(DOES_NOT_HAVE_SIGNATURE).forEach(key => { Object.keys(DOES_NOT_HAVE_SIGNATURE).forEach(key => {
const { body, signature } = DOES_NOT_HAVE_SIGNATURE[key]; const { body, signature } = DOES_NOT_HAVE_SIGNATURE[key];
@ -318,12 +355,12 @@ describe('removeSignature', () => {
}); });
it('removes signature if present at the end', () => { it('removes signature if present at the end', () => {
const { body, signature } = HAS_SIGNATURE['signature at end']; const { body, signature } = HAS_SIGNATURE['signature at end'];
expect(removeSignature(body, signature)).toBe('This is a test\n\n'); expect(removeSignature(body, signature, '--')).toBe('This is a test');
}); });
it('removes signature if present with spaces and new lines', () => { it('removes signature if present with spaces and new lines', () => {
const { body, signature } = const { body, signature } =
HAS_SIGNATURE['signature at end with spaces and new lines']; HAS_SIGNATURE['signature at end with spaces and new lines'];
expect(removeSignature(body, signature)).toBe('This is a test\n\n'); expect(removeSignature(body, signature, '--')).toBe('This is a test');
}); });
it('removes signature if present without text before it', () => { it('removes signature if present without text before it', () => {
const { body, signature } = HAS_SIGNATURE['no text before signature']; const { body, signature } = HAS_SIGNATURE['no text before signature'];
@ -336,38 +373,7 @@ describe('removeSignature', () => {
}); });
}); });
describe('removeSignature with stripped signature', () => { describe.skip('replaceSignature - SKIP(#78): Due to changes on append signature logic', () => {
const signatureWithImage =
'Thanks\n![](http://localhost:3000/image.png?cw_image_height=24px)';
it('removes stripped signature from body', () => {
// Simulate a body where signature was added with images stripped
const bodyWithStrippedSignature = 'Hello\n\n--\n\nThanks';
const result = removeSignature(
bodyWithStrippedSignature,
signatureWithImage
);
expect(result).toBe('Hello\n\n');
});
it('removes original signature from body', () => {
// Simulate a body where signature was added with images (using cleanSignature format)
const cleanedSig = cleanSignature(signatureWithImage);
const bodyWithOriginalSignature = `Hello\n\n--\n\n${cleanedSig}`;
const result = removeSignature(
bodyWithOriginalSignature,
signatureWithImage
);
expect(result).toBe('Hello\n\n');
});
it('handles signature without images', () => {
const simpleSignature = 'Best regards';
const body = 'Hello\n\n--\n\nBest regards';
const result = removeSignature(body, simpleSignature);
expect(result).toBe('Hello\n\n');
});
});
describe('replaceSignature', () => {
it('appends the new signature if not present', () => { it('appends the new signature if not present', () => {
Object.keys(DOES_NOT_HAVE_SIGNATURE).forEach(key => { Object.keys(DOES_NOT_HAVE_SIGNATURE).forEach(key => {
const { body, signature } = DOES_NOT_HAVE_SIGNATURE[key]; const { body, signature } = DOES_NOT_HAVE_SIGNATURE[key];

View File

@ -58,7 +58,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. To view it, please open it on the original platform.", "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",
@ -183,6 +184,8 @@
"MESSAGE_SIGN_TOOLTIP": "Message signature", "MESSAGE_SIGN_TOOLTIP": "Message signature",
"ENABLE_SIGN_TOOLTIP": "Enable signature", "ENABLE_SIGN_TOOLTIP": "Enable signature",
"DISABLE_SIGN_TOOLTIP": "Disable signature", "DISABLE_SIGN_TOOLTIP": "Disable signature",
"SIGNATURE_LABEL_TOP": "↓ Signature",
"SIGNATURE_LABEL_BOTTOM": "↑ Signature",
"MSG_INPUT": "Shift + enter for new line. Start with '/' to select a Canned Response.", "MSG_INPUT": "Shift + enter for new line. Start with '/' to select a Canned Response.",
"PRIVATE_MSG_INPUT": "Shift + enter for new line. This will be visible only to Agents", "PRIVATE_MSG_INPUT": "Shift + enter for new line. This will be visible only to Agents",
"MESSAGE_SIGNATURE_NOT_CONFIGURED": "Message signature is not configured, please configure it in profile settings.", "MESSAGE_SIGNATURE_NOT_CONFIGURED": "Message signature is not configured, please configure it in profile settings.",
@ -286,6 +289,14 @@
"REJECT_CALL": "Reject", "REJECT_CALL": "Reject",
"JOIN_CALL": "Join call", "JOIN_CALL": "Join call",
"END_CALL": "End call" "END_CALL": "End call"
},
"INBOX": {
"WHATSAPP_PROVIDER_CONNECTION": {
"NOT_CONNECTED": "WhatsApp is not connected. Please link your device again.",
"NOT_CONNECTED_CONTACT_ADMIN": "WhatsApp is not connected. Click this button to try to reconnect, or please contact your administrator to link your device again.",
"LINK_DEVICE": "Link device",
"RECONNECT_FAILED": "Failed to reconnect. Please contact your administrator to link your device again."
}
} }
}, },
"EMAIL_TRANSCRIPT": { "EMAIL_TRANSCRIPT": {

View File

@ -236,11 +236,20 @@
"WHATSAPP_CLOUD": "WhatsApp Cloud", "WHATSAPP_CLOUD": "WhatsApp Cloud",
"WHATSAPP_CLOUD_DESC": "Quick setup through Meta", "WHATSAPP_CLOUD_DESC": "Quick setup through Meta",
"TWILIO_DESC": "Connect via Twilio credentials", "TWILIO_DESC": "Connect via Twilio credentials",
"360_DIALOG": "360Dialog" "360_DIALOG": "360Dialog",
"BAILEYS": "Baileys",
"BAILEYS_DESC": "Connect via non-official API Baileys",
"ZAPI": "Z-API",
"ZAPI_DESC": "Connect via non-official API Z-API"
}, },
"SELECT_PROVIDER": { "SELECT_PROVIDER": {
"TITLE": "Select your API provider", "TITLE": "Select your API provider",
"DESCRIPTION": "Choose your WhatsApp provider. You can connect directly through Meta which requires no setup, or connect through Twilio using your account credentials." "DESCRIPTION": "Choose your WhatsApp provider. You can connect directly through Meta which requires no setup, or connect through Twilio using your account credentials.",
"ZAPI_PROMO": {
"TITLE": "Looking for a reliable WhatsApp solution?",
"DESCRIPTION": "Z-API offers superior stability compared to Baileys and is much simpler to set up than Cloud or Twilio - no complex configuration required. Perfect for businesses that want to get started quickly.",
"CTA": "Use Z-API"
}
}, },
"INBOX_NAME": { "INBOX_NAME": {
"LABEL": "Inbox Name", "LABEL": "Inbox Name",
@ -279,6 +288,43 @@
"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"
},
"MARK_AS_READ": {
"LABEL": "Send read receipts"
},
"INSTANCE_ID": {
"LABEL": "Instance ID",
"PLACEHOLDER": "Please enter your instance ID",
"ERROR": "This field is required"
},
"TOKEN": {
"LABEL": "Token",
"PLACEHOLDER": "Please enter your instance Token",
"ERROR": "This field is required"
},
"CLIENT_TOKEN": {
"LABEL": "Security Token",
"PLACEHOLDER": "Please enter your Security Token (see Security tab on Z-API dashboard)",
"ERROR": "This field is required"
},
"ADVANCED_OPTIONS": "Advanced options",
"EXTERNAL_PROVIDER": {
"SUBTITLE": "Click below to setup the WhatsApp channel.",
"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",
"EMBEDDED_SIGNUP": { "EMBEDDED_SIGNUP": {
"TITLE": "Quick setup with Meta", "TITLE": "Quick setup with Meta",
@ -308,6 +354,18 @@
"MANUAL_FALLBACK": "If your number is already connected to the WhatsApp Business Platform (API), or if youre a tech provider onboarding your own number, please use the {link} flow", "MANUAL_FALLBACK": "If your number is already connected to the WhatsApp Business Platform (API), or if youre a tech provider onboarding your own number, please use the {link} flow",
"MANUAL_LINK_TEXT": "manual setup flow" "MANUAL_LINK_TEXT": "manual setup flow"
}, },
"ZAPI_PROMO": {
"SWITCH_BANNER": {
"TITLE": "Consider switching to Z-API for easier setup",
"DESCRIPTION": "Z-API provides a more stable connection than Baileys and requires less configuration than Cloud/Twilio. Switch to a hassle-free WhatsApp integration.",
"CTA": "Switch to Z-API"
},
"SETUP_BANNER": {
"TITLE": "Get 10% off your Z-API subscription",
"DESCRIPTION": "Create your Z-API account using our affiliate link and receive 10% off. Simple setup, reliable connections, and great support.",
"CTA": "Create Z-API Account"
}
},
"API": { "API": {
"ERROR_MESSAGE": "We were not able to save the WhatsApp channel" "ERROR_MESSAGE": "We were not able to save the WhatsApp channel"
} }
@ -752,7 +810,29 @@
"WHATSAPP_TEMPLATES_SYNC_SUBHEADER": "Manually sync message templates from WhatsApp to update your available templates.", "WHATSAPP_TEMPLATES_SYNC_SUBHEADER": "Manually sync message templates from WhatsApp to update your available templates.",
"WHATSAPP_TEMPLATES_SYNC_BUTTON": "Sync Templates", "WHATSAPP_TEMPLATES_SYNC_BUTTON": "Sync Templates",
"WHATSAPP_TEMPLATES_SYNC_SUCCESS": "Templates sync initiated successfully. It may take a couple of minutes to update.", "WHATSAPP_TEMPLATES_SYNC_SUCCESS": "Templates sync initiated successfully. It may take a couple of minutes to update.",
"UPDATE_PRE_CHAT_FORM_SETTINGS": "Update Pre Chat Form Settings" "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",
"WHATSAPP_MARK_AS_READ_TITLE": "Read receipts",
"WHATSAPP_MARK_AS_READ_SUBHEADER": "If turned off, when a message is viewed in Chatwoot, a read receipt will not be sent to the sender. Your messages will still be able to receive read receipts from the sender.",
"WHATSAPP_MARK_AS_READ_LABEL": "Send read receipts",
"WHATSAPP_INSTANCE_ID_TITLE": "Instance ID",
"WHATSAPP_INSTANCE_ID_SUBHEADER": "Your Z-API Instance ID.",
"WHATSAPP_INSTANCE_ID_UPDATE_TITLE": "Update Instance ID",
"WHATSAPP_INSTANCE_ID_UPDATE_SUBHEADER": "Enter the new Instance ID here",
"WHATSAPP_TOKEN_TITLE": "Token",
"WHATSAPP_TOKEN_SUBHEADER": "Your Z-API instance Token.",
"WHATSAPP_TOKEN_UPDATE_TITLE": "Update Token",
"WHATSAPP_TOKEN_UPDATE_SUBHEADER": "Enter the new instance Token here",
"WHATSAPP_CLIENT_TOKEN_TITLE": "Security Token",
"WHATSAPP_CLIENT_TOKEN_SUBHEADER": "Your Z-API Client Token (see Security tab on Z-API dashboard).",
"WHATSAPP_CLIENT_TOKEN_UPDATE_TITLE": "Update Security Token",
"WHATSAPP_CLIENT_TOKEN_UPDATE_SUBHEADER": "Enter the new Security Token here"
}, },
"HELP_CENTER": { "HELP_CENTER": {
"LABEL": "Help Center", "LABEL": "Help Center",
@ -1044,6 +1124,8 @@
"TWITTER_PROFILE": "Twitter", "TWITTER_PROFILE": "Twitter",
"TWILIO_SMS": "Twilio SMS", "TWILIO_SMS": "Twilio SMS",
"WHATSAPP": "WhatsApp", "WHATSAPP": "WhatsApp",
"WHATSAPP_BAILEYS": "WhatsApp - Baileys",
"WHATSAPP_ZAPI": "WhatsApp - Z-API",
"SMS": "SMS", "SMS": "SMS",
"EMAIL": "Email", "EMAIL": "Email",
"TELEGRAM": "Telegram", "TELEGRAM": "Telegram",

View File

@ -38,18 +38,28 @@
"CONVERSATION_STATUS_CHANGED": "Conversation Status Changed", "CONVERSATION_STATUS_CHANGED": "Conversation Status Changed",
"CONVERSATION_UPDATED": "Conversation Updated", "CONVERSATION_UPDATED": "Conversation Updated",
"MESSAGE_CREATED": "Message created", "MESSAGE_CREATED": "Message created",
"MESSAGE_INCOMING": "Incoming message",
"MESSAGE_OUTGOING": "Outgoing message",
"MESSAGE_UPDATED": "Message updated", "MESSAGE_UPDATED": "Message updated",
"WEBWIDGET_TRIGGERED": "Live chat widget opened by the user", "WEBWIDGET_TRIGGERED": "Live chat widget opened by the user",
"CONTACT_CREATED": "Contact created", "CONTACT_CREATED": "Contact created",
"CONTACT_UPDATED": "Contact updated", "CONTACT_UPDATED": "Contact updated",
"CONVERSATION_TYPING_ON": "Conversation Typing On", "CONVERSATION_TYPING_ON": "Conversation Typing On",
"CONVERSATION_TYPING_OFF": "Conversation Typing Off" "CONVERSATION_TYPING_OFF": "Conversation Typing Off",
"PROVIDER_EVENT_RECEIVED": "Provider Event Received"
} }
}, },
"NAME": { "NAME": {
"LABEL": "Webhook Name", "LABEL": "Webhook Name",
"PLACEHOLDER": "Enter the name of the webhook" "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}",
@ -208,13 +218,17 @@
"EDIT_TOOLTIP": "Edit app", "EDIT_TOOLTIP": "Edit app",
"DELETE_TOOLTIP": "Delete app" "DELETE_TOOLTIP": "Delete app"
}, },
"VIEW": {
"NOT_FOUND": "We couldn't find that dashboard app."
},
"FORM": { "FORM": {
"TITLE_LABEL": "Name", "TITLE_LABEL": "Name",
"TITLE_PLACEHOLDER": "Enter a name for your dashboard app", "TITLE_PLACEHOLDER": "Enter a name for your dashboard app",
"TITLE_ERROR": "A name for the dashboard app is required", "TITLE_ERROR": "A name for the dashboard app is required",
"URL_LABEL": "Endpoint", "URL_LABEL": "Endpoint",
"URL_PLACEHOLDER": "Enter the endpoint URL where your app is hosted", "URL_PLACEHOLDER": "Enter the endpoint URL where your app is hosted",
"URL_ERROR": "A valid URL is required" "URL_ERROR": "A valid URL is required",
"SHOW_ON_SIDEBAR_LABEL": "Show on sidebar"
}, },
"CREATE": { "CREATE": {
"HEADER": "Add a new dashboard app", "HEADER": "Add a new dashboard app",

View File

@ -68,7 +68,27 @@
"API_SUCCESS": "Signature saved successfully", "API_SUCCESS": "Signature saved successfully",
"IMAGE_UPLOAD_ERROR": "Couldn't upload image! Try again", "IMAGE_UPLOAD_ERROR": "Couldn't upload image! Try again",
"IMAGE_UPLOAD_SUCCESS": "Image added successfully. Please click on save to save the signature", "IMAGE_UPLOAD_SUCCESS": "Image added successfully. Please click on save to save the signature",
"IMAGE_UPLOAD_SIZE_ERROR": "Image size should be less than {size}MB" "IMAGE_UPLOAD_SIZE_ERROR": "Image size should be less than {size}MB",
"SIGNATURE_POSITION": {
"LABEL": "Signature Position",
"OPTIONS": {
"TOP": "Top of the message",
"BOTTOM": "Bottom of the message"
}
},
"SIGNATURE_SEPARATOR": {
"LABEL": "Signature Separator",
"OPTIONS": {
"BLANK": "Blank line",
"HORIZONTAL_LINE": "Horizontal line (--)"
}
},
"PREVIEW": {
"TITLE": "Signature Preview",
"NOTE": "This is how your signature will appear in messages",
"EMPTY": "Enter a signature above to see the preview",
"SAMPLE_MESSAGE": "Hello! Thank you for contacting us. How can I help you today?"
}
}, },
"MESSAGE_SIGNATURE": { "MESSAGE_SIGNATURE": {
"LABEL": "Message Signature", "LABEL": "Message Signature",
@ -283,6 +303,7 @@
}, },
"MEDIA": { "MEDIA": {
"IMAGE_UNAVAILABLE": "This image is no longer available.", "IMAGE_UNAVAILABLE": "This image is no longer available.",
"AUDIO_UNAVAILABLE": "This audio is no longer available.",
"LOADING_FAILED": "Loading failed" "LOADING_FAILED": "Loading failed"
} }
}, },
@ -321,6 +342,7 @@
"HOME": "Home", "HOME": "Home",
"AGENTS": "Agents", "AGENTS": "Agents",
"AGENT_BOTS": "Bots", "AGENT_BOTS": "Bots",
"APPS": "Apps",
"AUDIT_LOGS": "Audit Logs", "AUDIT_LOGS": "Audit Logs",
"INBOXES": "Inboxes", "INBOXES": "Inboxes",
"NOTIFICATIONS": "Notifications", "NOTIFICATIONS": "Notifications",

View File

@ -58,7 +58,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",
@ -183,6 +184,8 @@
"MESSAGE_SIGN_TOOLTIP": "Assinatura de mensagem", "MESSAGE_SIGN_TOOLTIP": "Assinatura de mensagem",
"ENABLE_SIGN_TOOLTIP": "Ativar assinatura", "ENABLE_SIGN_TOOLTIP": "Ativar assinatura",
"DISABLE_SIGN_TOOLTIP": "Desativar assinatura", "DISABLE_SIGN_TOOLTIP": "Desativar assinatura",
"SIGNATURE_LABEL_TOP": "↓ Assinatura",
"SIGNATURE_LABEL_BOTTOM": "↑ Assinatura",
"MSG_INPUT": "Shift + enter para nova linha. Digite '/' para selecionar uma Resposta Pronta.", "MSG_INPUT": "Shift + enter para nova linha. Digite '/' para selecionar uma Resposta Pronta.",
"PRIVATE_MSG_INPUT": "A mensagem será visível apenas para agentes", "PRIVATE_MSG_INPUT": "A mensagem será visível apenas para agentes",
"MESSAGE_SIGNATURE_NOT_CONFIGURED": "A assinatura da mensagem não está configurada. Por favor, configure-a nas configurações do perfil.", "MESSAGE_SIGNATURE_NOT_CONFIGURED": "A assinatura da mensagem não está configurada. Por favor, configure-a nas configurações do perfil.",
@ -285,6 +288,14 @@
"REJECT_CALL": "Recusar", "REJECT_CALL": "Recusar",
"JOIN_CALL": "Entrar na chamada", "JOIN_CALL": "Entrar na chamada",
"END_CALL": "Encerrar chamada" "END_CALL": "Encerrar chamada"
},
"INBOX": {
"WHATSAPP_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. Clique no botão ao lado para tentar reconectar, ou contate o seu administrador para conectar o dispositivo novamente.",
"LINK_DEVICE": "Conectar dispositivo",
"RECONNECT_FAILED": "Falha ao reconectar. Por favor, contate o seu administrador para conectar o dispositivo novamente."
}
} }
}, },
"EMAIL_TRANSCRIPT": { "EMAIL_TRANSCRIPT": {

View File

@ -236,11 +236,20 @@
"WHATSAPP_CLOUD": "Cloud do WhatsApp", "WHATSAPP_CLOUD": "Cloud do WhatsApp",
"WHATSAPP_CLOUD_DESC": "Configuração rápida via Meta", "WHATSAPP_CLOUD_DESC": "Configuração rápida via Meta",
"TWILIO_DESC": "Conectar através de credenciais Twilio", "TWILIO_DESC": "Conectar através de credenciais Twilio",
"360_DIALOG": "360Dialog" "360_DIALOG": "360Dialog",
"BAILEYS": "Baileys",
"BAILEYS_DESC": "Conectar via API não-oficial Baileys",
"ZAPI": "Z-API",
"ZAPI_DESC": "Conectar via API não-oficial Z-API"
}, },
"SELECT_PROVIDER": { "SELECT_PROVIDER": {
"TITLE": "Selecione seu provedor de API", "TITLE": "Selecione seu provedor de API",
"DESCRIPTION": "Escolha seu provedor do WhatsApp. Você pode se conectar diretamente através de metade, que não requer nenhuma configuração ou se conectar pelo Twilio usando as credenciais da sua conta." "DESCRIPTION": "Escolha seu provedor do WhatsApp. Você pode se conectar diretamente através de metade, que não requer nenhuma configuração ou se conectar pelo Twilio usando as credenciais da sua conta.",
"ZAPI_PROMO": {
"TITLE": "Procurando uma solução WhatsApp confiável?",
"DESCRIPTION": "Z-API oferece estabilidade superior comparado ao Baileys e é muito mais simples de configurar que Cloud ou Twilio - sem necessidade de configuração complexa. Perfeito para empresas que querem começar rapidamente.",
"CTA": "Usar Z-API"
}
}, },
"INBOX_NAME": { "INBOX_NAME": {
"LABEL": "Nome da Caixa de Entrada", "LABEL": "Nome da Caixa de Entrada",
@ -279,6 +288,43 @@
"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"
},
"MARK_AS_READ": {
"LABEL": "Enviar confirmações de leitura"
},
"INSTANCE_ID": {
"LABEL": "ID da instância",
"PLACEHOLDER": "Por favor, insira o ID da sua instância",
"ERROR": "Este campo é obrigatório"
},
"TOKEN": {
"LABEL": "Token",
"PLACEHOLDER": "Por favor, insira o Token da sua instância",
"ERROR": "Este campo é obrigatório"
},
"CLIENT_TOKEN": {
"LABEL": "Token de Segurança",
"PLACEHOLDER": "Por favor, insira o Token de Segurança (veja a aba Segurança no painel do Z-API)",
"ERROR": "Este campo é obrigatório"
},
"ADVANCED_OPTIONS": "Opções avançadas",
"EXTERNAL_PROVIDER": {
"SUBTITLE": "Clique abaixo para configurar o canal do WhatsApp.",
"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",
"EMBEDDED_SIGNUP": { "EMBEDDED_SIGNUP": {
"TITLE": "Configuração rápida com Meta", "TITLE": "Configuração rápida com Meta",
@ -308,6 +354,18 @@
"MANUAL_FALLBACK": "Se o seu número já estiver conectado à Plataforma WhatsApp Business (API) ou se você for um provedor de tecnologia integrando o seu próprio número, use o fluxo de {link}", "MANUAL_FALLBACK": "Se o seu número já estiver conectado à Plataforma WhatsApp Business (API) ou se você for um provedor de tecnologia integrando o seu próprio número, use o fluxo de {link}",
"MANUAL_LINK_TEXT": "fluxo de configuração manual" "MANUAL_LINK_TEXT": "fluxo de configuração manual"
}, },
"ZAPI_PROMO": {
"SWITCH_BANNER": {
"TITLE": "Considere mudar para Z-API para configuração mais fácil",
"DESCRIPTION": "Z-API fornece uma conexão mais estável que Baileys e requer menos configuração que Cloud/Twilio. Mude para uma integração WhatsApp sem complicações.",
"CTA": "Mudar para Z-API"
},
"SETUP_BANNER": {
"TITLE": "Ganhe 10% de desconto na sua assinatura Z-API",
"DESCRIPTION": "Crie sua conta Z-API usando nosso link de afiliado e receba 10% de desconto. Configuração simples, conexões confiáveis e ótimo suporte.",
"CTA": "Criar Conta Z-API"
}
},
"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"
} }
@ -752,7 +810,29 @@
"WHATSAPP_TEMPLATES_SYNC_SUBHEADER": "Sincronize manualmente os modelos de mensagens do WhatsApp para atualizar seus modelos disponíveis.", "WHATSAPP_TEMPLATES_SYNC_SUBHEADER": "Sincronize manualmente os modelos de mensagens do WhatsApp para atualizar seus modelos disponíveis.",
"WHATSAPP_TEMPLATES_SYNC_BUTTON": "Sincronizar Modelos", "WHATSAPP_TEMPLATES_SYNC_BUTTON": "Sincronizar Modelos",
"WHATSAPP_TEMPLATES_SYNC_SUCCESS": "Sincronização de modelos iniciada com sucesso. Pode demorar alguns minutos para atualizar.", "WHATSAPP_TEMPLATES_SYNC_SUCCESS": "Sincronização de modelos iniciada com sucesso. Pode demorar alguns minutos para atualizar.",
"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",
"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",
"WHATSAPP_MARK_AS_READ_TITLE": "Confirmações de leitura",
"WHATSAPP_MARK_AS_READ_SUBHEADER": "Se essa opção estiver desativada, ao visualizar uma mensagem pelo Chatwoot, não será enviada uma confirmação de leitura para o remetente. As suas mensagens ainda poderão receber confirmações de leitura.",
"WHATSAPP_MARK_AS_READ_LABEL": "Enviar confirmações de leitura",
"WHATSAPP_INSTANCE_ID_TITLE": "ID da Instância",
"WHATSAPP_INSTANCE_ID_SUBHEADER": "Seu ID da Instância Z-API.",
"WHATSAPP_INSTANCE_ID_UPDATE_TITLE": "Atualizar ID da Instância",
"WHATSAPP_INSTANCE_ID_UPDATE_SUBHEADER": "Digite o novo ID da Instância aqui",
"WHATSAPP_TOKEN_TITLE": "Token",
"WHATSAPP_TOKEN_SUBHEADER": "Seu Token da Instância Z-API.",
"WHATSAPP_TOKEN_UPDATE_TITLE": "Atualizar Token",
"WHATSAPP_TOKEN_UPDATE_SUBHEADER": "Digite o novo Token aqui",
"WHATSAPP_CLIENT_TOKEN_TITLE": "Token de Segurança",
"WHATSAPP_CLIENT_TOKEN_SUBHEADER": "Seu Token de Segurança Z-API (veja a aba Segurança no painel do Z-API).",
"WHATSAPP_CLIENT_TOKEN_UPDATE_TITLE": "Atualizar Token de Segurança",
"WHATSAPP_CLIENT_TOKEN_UPDATE_SUBHEADER": "Digite o novo Token de Segurança aqui"
}, },
"HELP_CENTER": { "HELP_CENTER": {
"LABEL": "Centro de Ajuda", "LABEL": "Centro de Ajuda",
@ -1044,6 +1124,8 @@
"TWITTER_PROFILE": "Twitter", "TWITTER_PROFILE": "Twitter",
"TWILIO_SMS": "SMS Twilio", "TWILIO_SMS": "SMS Twilio",
"WHATSAPP": "WhatsApp", "WHATSAPP": "WhatsApp",
"WHATSAPP_BAILEYS": "WhatsApp - Baileys",
"WHATSAPP_ZAPI": "WhatsApp - Z-API",
"SMS": "SMS", "SMS": "SMS",
"EMAIL": "e-mail", "EMAIL": "e-mail",
"TELEGRAM": "Telegram", "TELEGRAM": "Telegram",

View File

@ -38,17 +38,27 @@
"CONVERSATION_STATUS_CHANGED": "Status de conversa alterado", "CONVERSATION_STATUS_CHANGED": "Status de conversa alterado",
"CONVERSATION_UPDATED": "Conversa Atualizada", "CONVERSATION_UPDATED": "Conversa Atualizada",
"MESSAGE_CREATED": "Mensagem criada", "MESSAGE_CREATED": "Mensagem criada",
"MESSAGE_INCOMING": "Mensagem recebida",
"MESSAGE_OUTGOING": "Mensagem enviada",
"MESSAGE_UPDATED": "Mensagem atualizada", "MESSAGE_UPDATED": "Mensagem atualizada",
"WEBWIDGET_TRIGGERED": "Widget de chat aberto pelo usuário", "WEBWIDGET_TRIGGERED": "Widget de chat aberto pelo usuário",
"CONTACT_CREATED": "Contato criado", "CONTACT_CREATED": "Contato criado",
"CONTACT_UPDATED": "Contato atualizado", "CONTACT_UPDATED": "Contato atualizado",
"CONVERSATION_TYPING_ON": "Status de Digitação ativado", "CONVERSATION_TYPING_ON": "Status de Digitação ativado",
"CONVERSATION_TYPING_OFF": "Status de Digitação desativado" "CONVERSATION_TYPING_OFF": "Status de Digitação desativado",
"PROVIDER_EVENT_RECEIVED": "Evento do Provedor Recebido"
} }
}, },
"NAME": { "NAME": {
"LABEL": "Webhook Name", "LABEL": "Nome do Webhook",
"PLACEHOLDER": "Enter the name of the 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",
@ -211,13 +221,17 @@
"EDIT_TOOLTIP": "Alterar aplicativo", "EDIT_TOOLTIP": "Alterar aplicativo",
"DELETE_TOOLTIP": "Excluir aplicativo" "DELETE_TOOLTIP": "Excluir aplicativo"
}, },
"VIEW": {
"NOT_FOUND": "Não encontramos este aplicativo do painel."
},
"FORM": { "FORM": {
"TITLE_LABEL": "Nome", "TITLE_LABEL": "Nome",
"TITLE_PLACEHOLDER": "Digite um nome para o aplicativo", "TITLE_PLACEHOLDER": "Digite um nome para o aplicativo",
"TITLE_ERROR": "É necessário um nome para o aplicativo", "TITLE_ERROR": "É necessário um nome para o aplicativo",
"URL_LABEL": "Endpoint", "URL_LABEL": "Endpoint",
"URL_PLACEHOLDER": "Digite a URL do endpoint onde seu aplicativo está hospedado", "URL_PLACEHOLDER": "Digite a URL do endpoint onde seu aplicativo está hospedado",
"URL_ERROR": "É necessário uma URL válida" "URL_ERROR": "É necessário uma URL válida",
"SHOW_ON_SIDEBAR_LABEL": "Mostrar na barra lateral"
}, },
"CREATE": { "CREATE": {
"HEADER": "Adicionar um novo aplicativo", "HEADER": "Adicionar um novo aplicativo",

View File

@ -68,7 +68,27 @@
"API_SUCCESS": "Assinatura salva com sucesso", "API_SUCCESS": "Assinatura salva com sucesso",
"IMAGE_UPLOAD_ERROR": "Não foi possível fazer o upload da imagem! Tente novamente", "IMAGE_UPLOAD_ERROR": "Não foi possível fazer o upload da imagem! Tente novamente",
"IMAGE_UPLOAD_SUCCESS": "Imagem adicionada com sucesso. Por favor clique em salvar para salvar a assinatura", "IMAGE_UPLOAD_SUCCESS": "Imagem adicionada com sucesso. Por favor clique em salvar para salvar a assinatura",
"IMAGE_UPLOAD_SIZE_ERROR": "O tamanho da imagem deve ser menor que {size}MB" "IMAGE_UPLOAD_SIZE_ERROR": "O tamanho da imagem deve ser menor que {size}MB",
"SIGNATURE_POSITION": {
"LABEL": "Posição da assinatura",
"OPTIONS": {
"TOP": "Início da mensagem",
"BOTTOM": "Final da mensagem"
}
},
"SIGNATURE_SEPARATOR": {
"LABEL": "Separador da assinatura",
"OPTIONS": {
"BLANK": "Linha em branco",
"HORIZONTAL_LINE": "Linha horizontal (--)"
}
},
"PREVIEW": {
"TITLE": "Pré-visualização da Assinatura",
"NOTE": "Esta é a aparência da sua assinatura nas mensagens",
"EMPTY": "Digite uma assinatura acima para ver a pré-visualização",
"SAMPLE_MESSAGE": "Olá! Obrigado por entrar em contato. Como posso ajudá-lo hoje?"
}
}, },
"MESSAGE_SIGNATURE": { "MESSAGE_SIGNATURE": {
"LABEL": "Assinatura da mensagem", "LABEL": "Assinatura da mensagem",
@ -283,6 +303,7 @@
}, },
"MEDIA": { "MEDIA": {
"IMAGE_UNAVAILABLE": "Esta imagem não está mais disponível.", "IMAGE_UNAVAILABLE": "Esta imagem não está mais disponível.",
"AUDIO_UNAVAILABLE": "Este áudio não está mais disponível.",
"LOADING_FAILED": "Falha no carregamento" "LOADING_FAILED": "Falha no carregamento"
} }
}, },
@ -321,6 +342,7 @@
"HOME": "Principal", "HOME": "Principal",
"AGENTS": "Agentes", "AGENTS": "Agentes",
"AGENT_BOTS": "Robôs", "AGENT_BOTS": "Robôs",
"APPS": "Apps",
"AUDIT_LOGS": "Auditoria", "AUDIT_LOGS": "Auditoria",
"INBOXES": "Caixas de Entrada", "INBOXES": "Caixas de Entrada",
"NOTIFICATIONS": "Notificações", "NOTIFICATIONS": "Notificações",

View File

@ -9,6 +9,7 @@ import { frontendURL } from '../../helper/URLHelper';
import helpcenterRoutes from './helpcenter/helpcenter.routes'; import helpcenterRoutes from './helpcenter/helpcenter.routes';
import campaignsRoutes from './campaigns/campaigns.routes'; import campaignsRoutes from './campaigns/campaigns.routes';
import { routes as captainRoutes } from './captain/captain.routes'; import { routes as captainRoutes } from './captain/captain.routes';
import dashboardAppsRoutes from './dashboardApps/dashboardApps.routes';
import AppContainer from './Dashboard.vue'; import AppContainer from './Dashboard.vue';
import Suspended from './suspended/Index.vue'; import Suspended from './suspended/Index.vue';
import NoAccounts from './noAccounts/Index.vue'; import NoAccounts from './noAccounts/Index.vue';
@ -29,6 +30,7 @@ export default {
...notificationRoutes, ...notificationRoutes,
...helpcenterRoutes.routes, ...helpcenterRoutes.routes,
...campaignsRoutes.routes, ...campaignsRoutes.routes,
...dashboardAppsRoutes.routes,
], ],
}, },
{ {

View File

@ -0,0 +1,72 @@
<script setup>
import { computed, onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import DashboardAppFrame from 'dashboard/components/widgets/DashboardApp/Frame.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const route = useRoute();
const store = useStore();
const { t } = useI18n();
const dashboardApps = useMapGetter('dashboardApps/getRecords');
const isLoadingApps = ref(true);
const appId = computed(() => Number(route.params.appId));
const dashboardApp = computed(() => {
return dashboardApps.value.find(app => app.id === appId.value);
});
const notFound = computed(() => !isLoadingApps.value && !dashboardApp.value);
onMounted(async () => {
try {
if (!dashboardApps.value.length) {
await store.dispatch('dashboardApps/get');
}
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to fetch dashboard apps', error);
} finally {
isLoadingApps.value = false;
}
});
</script>
<template>
<div class="flex flex-col w-full h-full bg-n-background">
<div
v-if="isLoadingApps"
class="flex items-center justify-center w-full h-full"
>
<Spinner />
</div>
<div
v-else-if="notFound"
class="flex items-center justify-center w-full h-full px-4 text-center"
>
<p class="text-sm text-n-slate-11">
{{ t('INTEGRATION_SETTINGS.DASHBOARD_APPS.VIEW.NOT_FOUND') }}
</p>
</div>
<div v-else class="flex flex-col w-full h-full">
<div
class="flex items-center gap-3 px-4 py-3 border-b border-n-weak bg-n-background"
>
<h1 class="text-lg font-semibold text-n-slate-12">
{{ dashboardApp.title }}
</h1>
</div>
<div class="flex-1 min-h-0">
<DashboardAppFrame
v-if="dashboardApp"
is-visible
:config="dashboardApp.content"
:position="0"
:current-chat="null"
/>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,16 @@
import { frontendURL } from '../../../helper/URLHelper';
import DashboardAppView from './DashboardAppView.vue';
export default {
routes: [
{
path: frontendURL('accounts/:accountId/dashboard-apps/:appId'),
name: 'dashboard_app_view',
meta: {
permissions: ['administrator', 'agent'],
},
component: DashboardAppView,
props: route => ({ appId: route.params.appId }),
},
],
};

View File

@ -8,6 +8,7 @@ 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 EmailInboxFinish from './channels/emailChannels/EmailInboxFinish.vue'; import EmailInboxFinish from './channels/emailChannels/EmailInboxFinish.vue';
import WhatsappLinkDeviceModal from './components/WhatsappLinkDeviceModal.vue';
import { useInbox } from 'dashboard/composables/useInbox'; import { useInbox } from 'dashboard/composables/useInbox';
import { INBOX_TYPES } from 'dashboard/helper/inbox'; import { INBOX_TYPES } from 'dashboard/helper/inbox';
@ -25,9 +26,15 @@ const currentInbox = computed(() =>
store.getters['inboxes/getInbox'](route.params.inbox_id) store.getters['inboxes/getInbox'](route.params.inbox_id)
); );
const showLinkDeviceModal = reactive({
value: false,
});
// Use useInbox composable with the inbox ID // Use useInbox composable with the inbox ID
const { const {
isAWhatsAppCloudChannel, isAWhatsAppCloudChannel,
isAWhatsAppBaileysChannel,
isAWhatsAppZapiChannel,
isATwilioChannel, isATwilioChannel,
isASmsInbox, isASmsInbox,
isALineChannel, isALineChannel,
@ -87,6 +94,16 @@ const message = computed(() => {
)}`; )}`;
} }
if (isAWhatsAppBaileysChannel.value || isAWhatsAppZapiChannel.value) {
return `${t('INBOX_MGMT.FINISH.MESSAGE')}. ${t(
'INBOX_MGMT.ADD.WHATSAPP.EXTERNAL_PROVIDER.SUBTITLE'
)}`;
}
if (isAnEmailChannel.value && !currentInbox.value.provider) {
return t('INBOX_MGMT.ADD.EMAIL_CHANNEL.FINISH_MESSAGE');
}
if (currentInbox.value.web_widget_script) { if (currentInbox.value.web_widget_script) {
return t('INBOX_MGMT.FINISH.WEBSITE_SUCCESS'); return t('INBOX_MGMT.FINISH.WEBSITE_SUCCESS');
} }
@ -149,6 +166,14 @@ async function generateQRCodes() {
} }
} }
const onOpenLinkDeviceModal = () => {
showLinkDeviceModal.value = true;
};
const onCloseLinkDeviceModal = () => {
showLinkDeviceModal.value = false;
};
// Watch for currentInbox changes and regenerate QR codes when available // Watch for currentInbox changes and regenerate QR codes when available
watch( watch(
currentInbox, currentInbox,
@ -210,6 +235,14 @@ onMounted(() => {
:script="currentInbox.provider_config.webhook_verify_token" :script="currentInbox.provider_config.webhook_verify_token"
/> />
</div> </div>
<div
v-if="isAWhatsAppBaileysChannel || isAWhatsAppZapiChannel"
class="w-[50%] max-w-[50%] ml-[25%]"
>
<NextButton @click="onOpenLinkDeviceModal">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EXTERNAL_PROVIDER.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="isALineChannel" v-if="isALineChannel"
@ -230,7 +263,12 @@ onMounted(() => {
:inbox-id="$route.params.inbox_id" :inbox-id="$route.params.inbox_id"
/> />
<div <div
v-if="isAWhatsAppChannel && qrCodes.whatsapp" v-if="
isAWhatsAppChannel &&
!isAWhatsAppBaileysChannel &&
!isAWhatsAppZapiChannel &&
qrCodes.whatsapp
"
class="flex flex-col gap-3 items-center mt-8" class="flex flex-col gap-3 items-center mt-8"
> >
<p class="mt-2 text-sm text-n-slate-9"> <p class="mt-2 text-sm text-n-slate-9">
@ -303,5 +341,12 @@ onMounted(() => {
</div> </div>
</div> </div>
</EmptyState> </EmptyState>
<WhatsappLinkDeviceModal
v-if="showLinkDeviceModal.value"
:show="showLinkDeviceModal.value"
:on-close="onCloseLinkDeviceModal"
:inbox="currentInbox"
is-setup
/>
</div> </div>
</template> </template>

View File

@ -125,6 +125,7 @@ const openDelete = inbox => {
<ChannelName <ChannelName
:channel-type="inbox.channel_type" :channel-type="inbox.channel_type"
:medium="inbox.medium" :medium="inbox.medium"
:provider="inbox.provider"
/> />
</div> </div>
</div> </div>

View File

@ -110,6 +110,12 @@ 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');
}
if (this.isAWhatsAppZapiChannel) {
return this.$t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.ZAPI');
}
return ''; return '';
}, },
tabs() { tabs() {
@ -159,7 +165,9 @@ export default {
this.isAVoiceChannel || this.isAVoiceChannel ||
(this.isAnEmailChannel && !this.inbox.provider) || (this.isAnEmailChannel && !this.inbox.provider) ||
this.shouldShowWhatsAppConfiguration || this.shouldShowWhatsAppConfiguration ||
this.isAWebWidgetInbox this.isAWebWidgetInbox ||
this.isAWhatsAppBaileysChannel ||
this.isAWhatsAppZapiChannel
) { ) {
visibleToAllChannelTabs = [ visibleToAllChannelTabs = [
...visibleToAllChannelTabs, ...visibleToAllChannelTabs,

View File

@ -0,0 +1,207 @@
<script setup>
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { useAlert } from 'dashboard/composables';
import { required, requiredIf } from '@vuelidate/validators';
import { isPhoneE164OrEmpty } from 'shared/helpers/Validators';
import { isValidURL } from '../../../../../helper/URLHelper';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Switch from 'dashboard/components-next/switch/Switch.vue';
import PromoBanner from 'dashboard/components-next/banner/PromoBanner.vue';
const router = useRouter();
const store = useStore();
const { t } = useI18n();
const inboxName = ref('');
const phoneNumber = ref('');
const apiKey = ref('');
const providerUrl = ref('');
const showAdvancedOptions = ref(false);
const markAsRead = ref(true);
const uiFlags = computed(() => store.getters['inboxes/getUIFlags']);
const rules = computed(() => ({
inboxName: { required },
phoneNumber: { required, isPhoneE164OrEmpty },
providerUrl: {
isValidURL: value => !value || isValidURL(value),
requiredIf: requiredIf(apiKey),
},
apiKey: { requiredIf: requiredIf(providerUrl) },
}));
const v$ = useVuelidate(rules, {
inboxName,
phoneNumber,
providerUrl,
apiKey,
});
const createChannel = async () => {
v$.value.$touch();
if (v$.value.$invalid) {
return;
}
try {
const providerConfig = {
mark_as_read: markAsRead.value,
};
if (apiKey.value || providerUrl.value) {
providerConfig.api_key = apiKey.value;
providerConfig.url = providerUrl.value;
}
const whatsappChannel = await store.dispatch('inboxes/createChannel', {
name: inboxName.value,
channel: {
type: 'whatsapp',
phone_number: phoneNumber.value,
provider: 'baileys',
provider_config: providerConfig,
},
});
router.replace({
name: 'settings_inboxes_add_agents',
params: {
page: 'new',
inbox_id: whatsappChannel.id,
},
});
} catch (error) {
useAlert(error.message || t('INBOX_MGMT.ADD.WHATSAPP.API.ERROR_MESSAGE'));
}
};
const setShowAdvancedOptions = () => {
showAdvancedOptions.value = true;
};
const switchToZapi = () => {
router.push({
name: router.currentRoute.value.name,
params: router.currentRoute.value.params,
query: { provider: 'zapi' },
});
};
</script>
<template>
<form class="flex flex-wrap mx-0" @submit.prevent="createChannel()">
<div class="w-full mb-6">
<PromoBanner
:title="$t('INBOX_MGMT.ADD.WHATSAPP.ZAPI_PROMO.SWITCH_BANNER.TITLE')"
:description="
$t('INBOX_MGMT.ADD.WHATSAPP.ZAPI_PROMO.SWITCH_BANNER.DESCRIPTION')
"
variant="info"
logo-src="/assets/images/dashboard/channels/z-api/z-api-dark-blue.png"
logo-alt="Z-API"
:cta-text="$t('INBOX_MGMT.ADD.WHATSAPP.ZAPI_PROMO.SWITCH_BANNER.CTA')"
@cta-click="switchToZapi"
/>
</div>
<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>
<div class="w-[65%] flex-shrink-0 flex-grow-0 max-w-[65%]">
<label>
<div class="flex mb-2 items-center">
<span class="mr-2 text-sm">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.MARK_AS_READ.LABEL') }}
</span>
<Switch id="markAsRead" v-model="markAsRead" />
</div>
</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

@ -7,10 +7,12 @@ import router from '../../../../index';
import { isPhoneE164OrEmpty, isNumber } from 'shared/helpers/Validators'; import { isPhoneE164OrEmpty, isNumber } from 'shared/helpers/Validators';
import NextButton from 'dashboard/components-next/button/Button.vue'; import NextButton from 'dashboard/components-next/button/Button.vue';
import PromoBanner from 'dashboard/components-next/banner/PromoBanner.vue';
export default { export default {
components: { components: {
NextButton, NextButton,
PromoBanner,
}, },
setup() { setup() {
return { v$: useVuelidate() }; return { v$: useVuelidate() };
@ -25,7 +27,9 @@ export default {
}; };
}, },
computed: { computed: {
...mapGetters({ uiFlags: 'inboxes/getUIFlags' }), ...mapGetters({
uiFlags: 'inboxes/getUIFlags',
}),
}, },
validations: { validations: {
inboxName: { required }, inboxName: { required },
@ -72,12 +76,33 @@ export default {
); );
} }
}, },
switchToZapi() {
router.push({
name: this.$route.name,
params: this.$route.params,
query: { provider: 'zapi' },
});
},
}, },
}; };
</script> </script>
<template> <template>
<form class="flex flex-wrap flex-col mx-0" @submit.prevent="createChannel()"> <form class="flex flex-wrap flex-col mx-0" @submit.prevent="createChannel()">
<div class="mb-6">
<PromoBanner
:title="$t('INBOX_MGMT.ADD.WHATSAPP.ZAPI_PROMO.SWITCH_BANNER.TITLE')"
:description="
$t('INBOX_MGMT.ADD.WHATSAPP.ZAPI_PROMO.SWITCH_BANNER.DESCRIPTION')
"
variant="info"
logo-src="/assets/images/dashboard/channels/z-api/z-api-dark-blue.png"
logo-alt="Z-API"
:cta-text="$t('INBOX_MGMT.ADD.WHATSAPP.ZAPI_PROMO.SWITCH_BANNER.CTA')"
@cta-click="switchToZapi"
/>
</div>
<div class="flex-shrink-0 flex-grow-0"> <div class="flex-shrink-0 flex-grow-0">
<label :class="{ error: v$.inboxName.$error }"> <label :class="{ error: v$.inboxName.$error }">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.INBOX_NAME.LABEL') }} {{ $t('INBOX_MGMT.ADD.WHATSAPP.INBOX_NAME.LABEL') }}

View File

@ -6,12 +6,14 @@ import { useAlert } from 'dashboard/composables';
import { required } from '@vuelidate/validators'; import { required } from '@vuelidate/validators';
import router from '../../../../index'; import router from '../../../../index';
import NextButton from 'dashboard/components-next/button/Button.vue'; import NextButton from 'dashboard/components-next/button/Button.vue';
import PromoBanner from 'dashboard/components-next/banner/PromoBanner.vue';
import { isPhoneE164OrEmpty } from 'shared/helpers/Validators'; import { isPhoneE164OrEmpty } from 'shared/helpers/Validators';
import { parseAPIErrorResponse } from 'dashboard/store/utils/api'; import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
export default { export default {
components: { components: {
NextButton, NextButton,
PromoBanner,
}, },
props: { props: {
type: { type: {
@ -42,6 +44,9 @@ export default {
authTokeni18nKey() { authTokeni18nKey() {
return this.useAPIKey ? 'API_KEY_SECRET' : 'AUTH_TOKEN'; return this.useAPIKey ? 'API_KEY_SECRET' : 'AUTH_TOKEN';
}, },
isWhatsApp() {
return this.type === 'whatsapp';
},
}, },
validations() { validations() {
let validations = { let validations = {
@ -112,12 +117,33 @@ export default {
useAlert(errorMessage); useAlert(errorMessage);
} }
}, },
switchToZapi() {
router.push({
name: this.$route.name,
params: this.$route.params,
query: { provider: 'zapi' },
});
},
}, },
}; };
</script> </script>
<template> <template>
<form class="flex flex-wrap flex-col mx-0" @submit.prevent="createChannel()"> <form class="flex flex-wrap flex-col mx-0" @submit.prevent="createChannel()">
<div v-if="isWhatsApp" class="mb-6">
<PromoBanner
:title="$t('INBOX_MGMT.ADD.WHATSAPP.ZAPI_PROMO.SWITCH_BANNER.TITLE')"
:description="
$t('INBOX_MGMT.ADD.WHATSAPP.ZAPI_PROMO.SWITCH_BANNER.DESCRIPTION')
"
variant="info"
logo-src="/assets/images/dashboard/channels/z-api/z-api-dark-blue.png"
logo-alt="Z-API"
:cta-text="$t('INBOX_MGMT.ADD.WHATSAPP.ZAPI_PROMO.SWITCH_BANNER.CTA')"
@cta-click="switchToZapi"
/>
</div>
<div class="flex-shrink-0 flex-grow-0"> <div class="flex-shrink-0 flex-grow-0">
<label :class="{ error: v$.channelName.$error }"> <label :class="{ error: v$.channelName.$error }">
{{ $t('INBOX_MGMT.ADD.TWILIO.CHANNEL_NAME.LABEL') }} {{ $t('INBOX_MGMT.ADD.TWILIO.CHANNEL_NAME.LABEL') }}

View File

@ -7,6 +7,9 @@ import ThreeSixtyDialogWhatsapp from './360DialogWhatsapp.vue';
import CloudWhatsapp from './CloudWhatsapp.vue'; import CloudWhatsapp from './CloudWhatsapp.vue';
import WhatsappEmbeddedSignup from './WhatsappEmbeddedSignup.vue'; import WhatsappEmbeddedSignup from './WhatsappEmbeddedSignup.vue';
import ChannelSelector from 'dashboard/components/ChannelSelector.vue'; import ChannelSelector from 'dashboard/components/ChannelSelector.vue';
import BaileysWhatsapp from './BaileysWhatsapp.vue';
import ZapiWhatsapp from './ZapiWhatsapp.vue';
import PromoBanner from 'dashboard/components-next/banner/PromoBanner.vue';
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@ -19,6 +22,8 @@ const PROVIDER_TYPES = {
WHATSAPP_EMBEDDED: 'whatsapp_embedded', WHATSAPP_EMBEDDED: 'whatsapp_embedded',
WHATSAPP_MANUAL: 'whatsapp_manual', WHATSAPP_MANUAL: 'whatsapp_manual',
THREE_SIXTY_DIALOG: '360dialog', THREE_SIXTY_DIALOG: '360dialog',
BAILEYS: 'baileys',
ZAPI: 'zapi',
}; };
const hasWhatsappAppId = computed(() => { const hasWhatsappAppId = computed(() => {
@ -34,20 +39,36 @@ const showProviderSelection = computed(() => !selectedProvider.value);
const showConfiguration = computed(() => Boolean(selectedProvider.value)); const showConfiguration = computed(() => Boolean(selectedProvider.value));
const availableProviders = computed(() => [ const availableProviders = computed(() => {
{ const providers = [
key: PROVIDER_TYPES.WHATSAPP, {
title: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_CLOUD'), key: PROVIDER_TYPES.WHATSAPP,
description: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_CLOUD_DESC'), title: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_CLOUD'),
icon: 'i-woot-whatsapp', description: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_CLOUD_DESC'),
}, icon: 'i-woot-whatsapp',
{ },
key: PROVIDER_TYPES.TWILIO, {
title: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.TWILIO'), key: PROVIDER_TYPES.TWILIO,
description: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.TWILIO_DESC'), title: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.TWILIO'),
icon: 'i-woot-twilio', description: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.TWILIO_DESC'),
}, icon: 'i-woot-twilio',
]); },
{
key: PROVIDER_TYPES.BAILEYS,
title: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.BAILEYS'),
description: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.BAILEYS_DESC'),
icon: 'i-woot-baileys',
},
{
key: PROVIDER_TYPES.ZAPI,
title: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.ZAPI'),
description: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.ZAPI_DESC'),
icon: 'i-woot-zapi',
},
];
return providers;
});
const selectProvider = providerValue => { const selectProvider = providerValue => {
router.push({ router.push({
@ -91,6 +112,29 @@ const handleManualLinkClick = () => {
@click="selectProvider(provider.key)" @click="selectProvider(provider.key)"
/> />
</div> </div>
<div class="mt-6 relative overflow-visible">
<img
src="~dashboard/assets/images/curved-arrow.svg"
alt=""
class="absolute -top-12 right-0 w-20 h-20 pointer-events-none z-10 scale-y-[-1] -rotate-45"
/>
<PromoBanner
:title="
$t('INBOX_MGMT.ADD.WHATSAPP.SELECT_PROVIDER.ZAPI_PROMO.TITLE')
"
:description="
$t('INBOX_MGMT.ADD.WHATSAPP.SELECT_PROVIDER.ZAPI_PROMO.DESCRIPTION')
"
variant="success"
logo-src="/assets/images/dashboard/channels/z-api/z-api-dark-green.png"
logo-alt="Z-API"
:cta-text="
$t('INBOX_MGMT.ADD.WHATSAPP.SELECT_PROVIDER.ZAPI_PROMO.CTA')
"
@cta-click="selectProvider(PROVIDER_TYPES.ZAPI)"
/>
</div>
</div> </div>
<div v-else-if="showConfiguration"> <div v-else-if="showConfiguration">
@ -138,7 +182,13 @@ const handleManualLinkClick = () => {
<ThreeSixtyDialogWhatsapp <ThreeSixtyDialogWhatsapp
v-else-if="selectedProvider === PROVIDER_TYPES.THREE_SIXTY_DIALOG" v-else-if="selectedProvider === PROVIDER_TYPES.THREE_SIXTY_DIALOG"
/> />
<CloudWhatsapp v-else /> <CloudWhatsapp
v-else-if="selectedProvider === PROVIDER_TYPES.WHATSAPP"
/>
<BaileysWhatsapp
v-else-if="selectedProvider === PROVIDER_TYPES.BAILEYS"
/>
<ZapiWhatsapp v-else-if="selectedProvider === PROVIDER_TYPES.ZAPI" />
</div> </div>
</div> </div>
</div> </div>

View File

@ -0,0 +1,182 @@
<script setup>
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { useAlert } from 'dashboard/composables';
import { required } from '@vuelidate/validators';
import { isPhoneE164OrEmpty } from 'shared/helpers/Validators';
import NextButton from 'dashboard/components-next/button/Button.vue';
import PromoBanner from 'dashboard/components-next/banner/PromoBanner.vue';
const router = useRouter();
const store = useStore();
const { t } = useI18n();
const inboxName = ref('');
const phoneNumber = ref('');
const instanceId = ref('');
const token = ref('');
const clientToken = ref('');
const uiFlags = computed(() => store.getters['inboxes/getUIFlags']);
// NOTE: Affiliate link is left intentionally hardcoded.
const zapiAffiliateUrl =
'https://app.z-api.io/app/auth/new-account?afilliate=3E0B31343E6CB0297B567AC1D8277FBB';
const rules = computed(() => ({
inboxName: { required },
phoneNumber: { required, isPhoneE164OrEmpty },
instanceId: { required },
token: { required },
clientToken: { required },
}));
const v$ = useVuelidate(rules, {
inboxName,
phoneNumber,
instanceId,
token,
clientToken,
});
const createChannel = async () => {
v$.value.$touch();
if (v$.value.$invalid) {
return;
}
try {
const whatsappChannel = await store.dispatch('inboxes/createChannel', {
name: inboxName.value,
channel: {
type: 'whatsapp',
phone_number: phoneNumber.value,
provider: 'zapi',
provider_config: {
instance_id: instanceId.value,
token: token.value,
client_token: clientToken.value,
},
},
});
router.replace({
name: 'settings_inboxes_add_agents',
params: {
page: 'new',
inbox_id: whatsappChannel.id,
},
});
} catch (error) {
useAlert(error.message || t('INBOX_MGMT.ADD.WHATSAPP.API.ERROR_MESSAGE'));
}
};
</script>
<template>
<form class="flex flex-wrap mx-0" @submit.prevent="createChannel()">
<div class="w-full mb-6">
<PromoBanner
:title="$t('INBOX_MGMT.ADD.WHATSAPP.ZAPI_PROMO.SETUP_BANNER.TITLE')"
:description="
$t('INBOX_MGMT.ADD.WHATSAPP.ZAPI_PROMO.SETUP_BANNER.DESCRIPTION')
"
variant="success"
logo-src="/assets/images/dashboard/channels/z-api/z-api-dark-green.png"
logo-alt="Z-API"
:cta-text="$t('INBOX_MGMT.ADD.WHATSAPP.ZAPI_PROMO.SETUP_BANNER.CTA')"
cta-external
:cta-link="zapiAffiliateUrl"
/>
</div>
<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 class="w-[65%] flex-shrink-0 flex-grow-0 max-w-[65%]">
<label :class="{ error: v$.instanceId.$error }">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.INSTANCE_ID.LABEL') }}
<input
v-model="instanceId"
type="password"
:placeholder="$t('INBOX_MGMT.ADD.WHATSAPP.INSTANCE_ID.PLACEHOLDER')"
@blur="v$.instanceId.$touch"
/>
<span v-if="v$.instanceId.$error" class="message">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.INSTANCE_ID.ERROR') }}
</span>
</label>
</div>
<div class="w-[65%] flex-shrink-0 flex-grow-0 max-w-[65%]">
<label :class="{ error: v$.token.$error }">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.TOKEN.LABEL') }}
<input
v-model="token"
type="password"
:placeholder="$t('INBOX_MGMT.ADD.WHATSAPP.TOKEN.PLACEHOLDER')"
@blur="v$.token.$touch"
/>
<span v-if="v$.token.$error" class="message">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.TOKEN.ERROR') }}
</span>
</label>
</div>
<div class="w-[65%] flex-shrink-0 flex-grow-0 max-w-[65%]">
<label :class="{ error: v$.clientToken.$error }">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.CLIENT_TOKEN.LABEL') }}
<input
v-model="clientToken"
type="password"
:placeholder="$t('INBOX_MGMT.ADD.WHATSAPP.CLIENT_TOKEN.PLACEHOLDER')"
@blur="v$.clientToken.$touch"
/>
<span v-if="v$.clientToken.$error" class="message">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.CLIENT_TOKEN.ERROR') }}
</span>
</label>
</div>
<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

@ -12,6 +12,10 @@ const props = defineProps({
type: String, type: String,
default: '', default: '',
}, },
provider: {
type: String,
default: '',
},
}); });
const getters = useStoreGetters(); const getters = useStoreGetters();
const { t } = useI18n(); const { t } = useI18n();
@ -40,6 +44,16 @@ const twilioChannelName = () => {
return t(`INBOX_MGMT.CHANNELS.TWILIO_SMS`); return t(`INBOX_MGMT.CHANNELS.TWILIO_SMS`);
}; };
const whatsappChannelName = () => {
if (props.provider === 'baileys') {
return t(`INBOX_MGMT.CHANNELS.WHATSAPP_BAILEYS`);
}
if (props.provider === 'zapi') {
return t(`INBOX_MGMT.CHANNELS.WHATSAPP_ZAPI`);
}
return t(`INBOX_MGMT.CHANNELS.WHATSAPP`);
};
const readableChannelName = computed(() => { const readableChannelName = computed(() => {
if (props.channelType === 'Channel::Api') { if (props.channelType === 'Channel::Api') {
return globalConfig.value.apiChannelName || t('INBOX_MGMT.CHANNELS.API'); return globalConfig.value.apiChannelName || t('INBOX_MGMT.CHANNELS.API');
@ -47,6 +61,9 @@ const readableChannelName = computed(() => {
if (props.channelType === 'Channel::TwilioSms') { if (props.channelType === 'Channel::TwilioSms') {
return twilioChannelName(); return twilioChannelName();
} }
if (props.channelType === 'Channel::Whatsapp') {
return whatsappChannelName();
}
return t(`INBOX_MGMT.CHANNELS.${i18nMap[props.channelType]}`); return t(`INBOX_MGMT.CHANNELS.${i18nMap[props.channelType]}`);
}); });
</script> </script>

View File

@ -0,0 +1,168 @@
<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' ||
connection.value === 'reconnecting'
) {
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.EXTERNAL_PROVIDER.LINK_DEVICE_MODAL.TITLE'
)
"
:header-content="
$t(
'INBOX_MGMT.ADD.WHATSAPP.EXTERNAL_PROVIDER.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.EXTERNAL_PROVIDER.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.EXTERNAL_PROVIDER.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.EXTERNAL_PROVIDER.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.EXTERNAL_PROVIDER.LINK_DEVICE_MODAL.CONNECTED'
)
}}
</p>
<div class="flex gap-2">
<Button ghost :is-loading="loading" @click="disconnect">
{{
$t(
'INBOX_MGMT.ADD.WHATSAPP.EXTERNAL_PROVIDER.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>

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