Merge branch 'main' into chore/merge-upstream-4.6.0
This commit is contained in:
commit
18c672c204
@ -266,3 +266,10 @@ AZURE_APP_SECRET=
|
||||
# Set to true if you want to remove stale contact inboxes
|
||||
# contact_inboxes with no conversation older than 90 days will be removed
|
||||
# REMOVE_STALE_CONTACT_INBOX_JOB_STATUS=false
|
||||
|
||||
# Baileys API Whatsapp provider
|
||||
BAILEYS_PROVIDER_DEFAULT_CLIENT_NAME=Chatwoot
|
||||
BAILEYS_PROVIDER_DEFAULT_URL=http://localhost:3025
|
||||
BAILEYS_PROVIDER_DEFAULT_API_KEY=
|
||||
|
||||
RESEND_API_KEY=
|
||||
|
||||
8
.github/workflows/frontend-fe.yml
vendored
8
.github/workflows/frontend-fe.yml
vendored
@ -2,11 +2,9 @@ name: Frontend Lint & Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
pull_request:
|
||||
branches:
|
||||
- develop
|
||||
tags:
|
||||
- '*'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
|
||||
137
.github/workflows/publish_ee_github_docker.yml
vendored
Normal file
137
.github/workflows/publish_ee_github_docker.yml
vendored
Normal file
@ -0,0 +1,137 @@
|
||||
name: Publish Chatwoot Enterprise docker images to GitHub
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
GITHUB_REPO: ghcr.io/${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-22.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
env:
|
||||
GIT_REF: ${{ github.head_ref || github.ref_name || github.sha }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- 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 == 'push' || github.event_name == 'workflow_dispatch'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push to GitHub Container Registry
|
||||
id: build-ghcr
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile
|
||||
platforms: ${{ matrix.platform }}
|
||||
push: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }}
|
||||
tags: |
|
||||
${{ env.GITHUB_REPO }}:${{ env.SANITIZED_REF }}-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.head_ref || github.ref_name || github.sha }}
|
||||
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.head_ref || github.ref_name || github.sha }}
|
||||
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
|
||||
138
.github/workflows/publish_github_docker.yml
vendored
Normal file
138
.github/workflows/publish_github_docker.yml
vendored
Normal file
@ -0,0 +1,138 @@
|
||||
name: Publish Chatwoot docker images to GitHub
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
GITHUB_REPO: ghcr.io/${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-22.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
env:
|
||||
GIT_REF: ${{ github.head_ref || github.ref_name }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Prepare
|
||||
run: |
|
||||
platform=${{ matrix.platform }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
|
||||
- name: Strip enterprise code
|
||||
run: |
|
||||
rm -rf enterprise
|
||||
rm -rf spec/enterprise
|
||||
|
||||
- name: 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 == 'push' || github.event_name == 'workflow_dispatch'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push to GitHub Container Registry
|
||||
id: build-ghcr
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile
|
||||
platforms: ${{ matrix.platform }}
|
||||
push: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }}
|
||||
tags: |
|
||||
${{ env.GITHUB_REPO }}:${{ env.SANITIZED_REF }}
|
||||
${{ env.GITHUB_REPO }}:latest
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p ${{ runner.temp }}/digests
|
||||
digest="${{ steps.build-ghcr.outputs.digest }}"
|
||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-${{ env.PLATFORM_PAIR }}
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- build
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: ${{ runner.temp }}/digests
|
||||
pattern: digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Create manifest list and push
|
||||
working-directory: ${{ runner.temp }}/digests
|
||||
env:
|
||||
GIT_REF: ${{ github.head_ref || github.ref_name }}
|
||||
run: |
|
||||
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
|
||||
docker buildx imagetools create \
|
||||
-t ghcr.io/${{ github.repository }}:${SANITIZED_REF} \
|
||||
-t ghcr.io/${{ github.repository }}:latest \
|
||||
$(printf 'ghcr.io/${{ github.repository }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect image
|
||||
env:
|
||||
GIT_REF: ${{ github.head_ref || github.ref_name }}
|
||||
run: |
|
||||
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
|
||||
REPO="ghcr.io/${{ github.repository }}"
|
||||
docker buildx imagetools inspect ${REPO}:${SANITIZED_REF}
|
||||
docker buildx imagetools inspect ${REPO}:latest
|
||||
7
.github/workflows/run_foss_spec.yml
vendored
7
.github/workflows/run_foss_spec.yml
vendored
@ -1,10 +1,9 @@
|
||||
name: Run Chatwoot CE spec
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
- master
|
||||
pull_request:
|
||||
tags:
|
||||
- '*'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
|
||||
@ -4,8 +4,8 @@
|
||||
# lint js and vue files
|
||||
npx --no-install lint-staged
|
||||
|
||||
# lint only staged ruby files that still exist (not deleted)
|
||||
git diff --name-only --cached | xargs -I {} sh -c 'test -f "{}" && echo "{}"' | grep '\.rb$' | xargs -I {} bundle exec rubocop --force-exclusion -a "{}" || true
|
||||
# lint only staged ruby files
|
||||
git diff --name-only --cached | xargs ls -1 2>/dev/null | grep '\.rb$' | xargs bundle exec rubocop --force-exclusion
|
||||
|
||||
# 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
|
||||
|
||||
10
.rubocop.yml
10
.rubocop.yml
@ -207,6 +207,7 @@ CustomCopLocation:
|
||||
|
||||
AllCops:
|
||||
NewCops: enable
|
||||
SuggestExtensions: false
|
||||
Exclude:
|
||||
- 'bin/**/*'
|
||||
- 'db/schema.rb'
|
||||
@ -336,4 +337,11 @@ FactoryBot/RedundantFactoryOption:
|
||||
Enabled: false
|
||||
|
||||
FactoryBot/FactoryAssociationWithStrategy:
|
||||
Enabled: false
|
||||
Enabled: false
|
||||
|
||||
Rails/SaveBang:
|
||||
Enabled: true
|
||||
AllowedReceivers:
|
||||
- Stripe::Subscription
|
||||
- Stripe::Customer
|
||||
- FactoryBot
|
||||
|
||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@ -2,5 +2,6 @@
|
||||
"cSpell.words": [
|
||||
"chatwoot",
|
||||
"dompurify"
|
||||
]
|
||||
],
|
||||
"css.customData": [".vscode/tailwind.json"]
|
||||
}
|
||||
|
||||
55
.vscode/tailwind.json
vendored
Normal file
55
.vscode/tailwind.json
vendored
Normal 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 you’d 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
73
CUSTOM_BRANDING.md
Normal 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).
|
||||
4
Gemfile
4
Gemfile
@ -42,7 +42,7 @@ gem 'down'
|
||||
# authentication type to fetch and send mail over oauth2.0
|
||||
gem 'gmail_xoauth'
|
||||
# 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
|
||||
gem 'csv-safe'
|
||||
|
||||
@ -194,6 +194,8 @@ gem 'ruby_llm-schema'
|
||||
|
||||
gem 'shopify_api'
|
||||
|
||||
gem 'resend', '~> 0.19.0'
|
||||
|
||||
### Gems required only in specific deployment environments ###
|
||||
##############################################################
|
||||
|
||||
|
||||
@ -716,6 +716,8 @@ GEM
|
||||
uber (< 0.2.0)
|
||||
request_store (1.5.1)
|
||||
rack (>= 1.4)
|
||||
resend (0.19.0)
|
||||
httparty (>= 0.21.0)
|
||||
responders (3.1.1)
|
||||
actionpack (>= 5.2)
|
||||
railties (>= 5.2)
|
||||
@ -1077,6 +1079,7 @@ DEPENDENCIES
|
||||
rails (~> 7.1)
|
||||
redis
|
||||
redis-namespace
|
||||
resend (~> 0.19.0)
|
||||
responders (>= 3.1.1)
|
||||
rest-client
|
||||
reverse_markdown
|
||||
|
||||
@ -70,7 +70,7 @@ class ContactIdentifyAction
|
||||
end
|
||||
|
||||
def merge_contacts?(existing_contact, key)
|
||||
return if existing_contact.blank?
|
||||
return false if existing_contact.blank?
|
||||
|
||||
return true if params[:identifier].blank?
|
||||
|
||||
|
||||
@ -162,7 +162,7 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil
|
||||
end
|
||||
|
||||
def all_unsupported_files?
|
||||
return if attachments.empty?
|
||||
return false if attachments.empty?
|
||||
|
||||
attachments_type = attachments.pluck(:type).uniq.first
|
||||
unsupported_file_type?(attachments_type)
|
||||
|
||||
@ -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
|
||||
if error_code == 1_609_005
|
||||
@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
|
||||
|
||||
Rails.logger.error("[InstagramStoryFetchError]: #{parsed_response.dig('error', 'message')} #{error_code}")
|
||||
|
||||
@ -14,7 +14,7 @@ class Messages::Instagram::Messenger::MessageBuilder < Messages::Instagram::Base
|
||||
rescue Koala::Facebook::ClientError => e
|
||||
# The exception occurs when we are trying fetch the deleted story or blocked story.
|
||||
@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
|
||||
{}
|
||||
rescue StandardError => e
|
||||
|
||||
@ -2,17 +2,19 @@ class Messages::MessageBuilder
|
||||
include ::FileTypeHelper
|
||||
attr_reader :message
|
||||
|
||||
def initialize(user, conversation, params)
|
||||
def initialize(user, conversation, params) # rubocop:disable Metrics/CyclomaticComplexity
|
||||
@params = params
|
||||
@private = params[:private] || false
|
||||
@conversation = conversation
|
||||
@user = user
|
||||
@message_type = params[:message_type] || 'outgoing'
|
||||
@attachments = params[:attachments]
|
||||
@is_recorded_audio = params[:is_recorded_audio]
|
||||
@automation_rule = content_attributes&.dig(:automation_rule_id)
|
||||
return unless params.instance_of?(ActionController::Parameters)
|
||||
|
||||
@in_reply_to = content_attributes&.dig(:in_reply_to)
|
||||
@is_reaction = content_attributes&.dig(:is_reaction)
|
||||
@items = content_attributes&.dig(:items)
|
||||
end
|
||||
|
||||
@ -66,7 +68,7 @@ class Messages::MessageBuilder
|
||||
account_id: @message.account_id,
|
||||
file: uploaded_attachment
|
||||
)
|
||||
|
||||
attachment.meta = process_metadata(uploaded_attachment)
|
||||
attachment.file_type = if uploaded_attachment.is_a?(String)
|
||||
file_type_by_signed_id(
|
||||
uploaded_attachment
|
||||
@ -77,6 +79,22 @@ class Messages::MessageBuilder
|
||||
end
|
||||
end
|
||||
|
||||
def process_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 process_emails
|
||||
return unless @conversation.inbox&.inbox_type == 'Email'
|
||||
|
||||
@ -149,6 +167,7 @@ class Messages::MessageBuilder
|
||||
content_type: @params[:content_type],
|
||||
items: @items,
|
||||
in_reply_to: @in_reply_to,
|
||||
is_reaction: @is_reaction,
|
||||
echo_id: @params[:echo_id],
|
||||
source_id: @params[:source_id]
|
||||
}.merge(external_created_at).merge(automation_rule_id).merge(campaign_id).merge(template_params)
|
||||
|
||||
@ -68,7 +68,6 @@ class Messages::Messenger::MessageBuilder
|
||||
message.save!
|
||||
end
|
||||
|
||||
# This is a placeholder method to be overridden by child classes
|
||||
def get_story_object_from_source_id(_source_id)
|
||||
{}
|
||||
end
|
||||
|
||||
@ -24,7 +24,6 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
|
||||
ActiveRecord::Base.transaction do
|
||||
automation_rule_update
|
||||
process_attachments
|
||||
|
||||
rescue StandardError => e
|
||||
Rails.logger.error e
|
||||
render json: { error: @automation_rule.errors.messages }.to_json, status: :unprocessable_entity
|
||||
|
||||
@ -46,7 +46,7 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
|
||||
return if response['instagram_business_account'].blank?
|
||||
|
||||
instagram_id = response['instagram_business_account']['id']
|
||||
facebook_channel.update(instagram_id: instagram_id)
|
||||
facebook_channel.update!(instagram_id: instagram_id)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Error in set_instagram_id: #{e.message}"
|
||||
end
|
||||
|
||||
@ -110,10 +110,14 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||
end
|
||||
|
||||
def update_last_seen
|
||||
dispatch_messages_read_event if assignee?
|
||||
|
||||
update_last_seen_on_conversation(DateTime.now.utc, assignee?)
|
||||
end
|
||||
|
||||
def unread
|
||||
Rails.configuration.dispatcher.dispatch(Events::Types::CONVERSATION_UNREAD, Time.zone.now, conversation: @conversation)
|
||||
|
||||
last_incoming_message = @conversation.messages.incoming.last
|
||||
last_seen_at = last_incoming_message.created_at - 1.second if last_incoming_message.present?
|
||||
update_last_seen_on_conversation(last_seen_at, true)
|
||||
@ -206,6 +210,12 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||
def assignee?
|
||||
@conversation.assignee_id? && Current.user == @conversation.assignee
|
||||
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
|
||||
|
||||
Api::V1::Accounts::ConversationsController.prepend_mod_with('Api::V1::Accounts::ConversationsController')
|
||||
|
||||
@ -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
|
||||
before_action :fetch_inbox, except: [:index, :create]
|
||||
before_action :fetch_agent_bot, only: [:set_agent_bot]
|
||||
before_action :validate_limit, only: [:create]
|
||||
# we are already handling the authorization in fetch inbox
|
||||
before_action :check_authorization, except: [:show]
|
||||
before_action :check_authorization, except: [:show, :setup_channel_provider]
|
||||
|
||||
def index
|
||||
@inboxes = policy_scope(Current.account.inboxes.order_by_name.includes(:channel, { avatar_attachment: [:blob] }))
|
||||
@ -64,6 +64,30 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
head :ok
|
||||
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
|
||||
::DeleteObjectJob.perform_later(@inbox, Current.user, request.ip) if @inbox.present?
|
||||
render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') }
|
||||
@ -78,6 +102,20 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
render status: :internal_server_error, json: { error: e.message }
|
||||
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
|
||||
|
||||
def fetch_inbox
|
||||
|
||||
@ -16,7 +16,7 @@ class Api::V1::Accounts::Integrations::SlackController < Api::V1::Accounts::Base
|
||||
end
|
||||
|
||||
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?
|
||||
end
|
||||
|
||||
|
||||
@ -25,17 +25,17 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro
|
||||
end
|
||||
|
||||
def update
|
||||
@notification.update(read_at: DateTime.now.utc)
|
||||
@notification.update!(read_at: DateTime.now.utc)
|
||||
render json: @notification
|
||||
end
|
||||
|
||||
def unread
|
||||
@notification.update(read_at: nil)
|
||||
@notification.update!(read_at: nil)
|
||||
render json: @notification
|
||||
end
|
||||
|
||||
def destroy
|
||||
@notification.destroy
|
||||
@notification.destroy!
|
||||
head :ok
|
||||
end
|
||||
|
||||
@ -55,7 +55,7 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro
|
||||
|
||||
def snooze
|
||||
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
|
||||
end
|
||||
|
||||
|
||||
@ -37,7 +37,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def archive
|
||||
@portal.update(archive: true)
|
||||
@portal.update!(archive: true)
|
||||
head :ok
|
||||
end
|
||||
|
||||
|
||||
@ -7,12 +7,12 @@ class Api::V1::Accounts::WebhooksController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def create
|
||||
@webhook = Current.account.webhooks.new(webhook_params)
|
||||
@webhook = Current.account.webhooks.new(webhook_create_params)
|
||||
@webhook.save!
|
||||
end
|
||||
|
||||
def update
|
||||
@webhook.update!(webhook_params)
|
||||
@webhook.update!(webhook_update_params)
|
||||
end
|
||||
|
||||
def destroy
|
||||
@ -22,8 +22,12 @@ class Api::V1::Accounts::WebhooksController < Api::V1::Accounts::BaseController
|
||||
|
||||
private
|
||||
|
||||
def webhook_params
|
||||
params.require(:webhook).permit(:inbox_id, :url, subscriptions: [])
|
||||
def webhook_create_params
|
||||
params.require(:webhook).permit(:inbox_id, :name, :url, subscriptions: [])
|
||||
end
|
||||
|
||||
def webhook_update_params
|
||||
params.require(:webhook).permit(:name, subscriptions: [])
|
||||
end
|
||||
|
||||
def fetch_webhook
|
||||
|
||||
@ -26,10 +26,14 @@ class Api::V1::ProfilesController < Api::BaseController
|
||||
|
||||
def 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
|
||||
|
||||
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
|
||||
end
|
||||
|
||||
|
||||
@ -19,7 +19,7 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
|
||||
contact = @contact
|
||||
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)
|
||||
end
|
||||
|
||||
@ -48,6 +48,8 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
|
||||
case permitted_params[:typing_status]
|
||||
when 'on'
|
||||
trigger_typing_event(CONVERSATION_TYPING_ON)
|
||||
when 'recording'
|
||||
trigger_typing_event(CONVERSATION_RECORDING)
|
||||
when 'off'
|
||||
trigger_typing_event(CONVERSATION_TYPING_OFF)
|
||||
end
|
||||
@ -82,7 +84,7 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
|
||||
end
|
||||
|
||||
def render_not_found_if_empty
|
||||
return head :not_found if conversation.nil?
|
||||
head :not_found if conversation.nil?
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
|
||||
@ -12,7 +12,7 @@ class ApiController < ApplicationController
|
||||
|
||||
def redis_status
|
||||
r = Redis.new(Redis::Config.app)
|
||||
return 'ok' if r.ping
|
||||
'ok' if r.ping
|
||||
rescue Redis::CannotConnectError
|
||||
'failing'
|
||||
end
|
||||
|
||||
@ -12,7 +12,7 @@ class Platform::Api::V1::AccountsController < PlatformController
|
||||
@resource = Account.create!(account_params)
|
||||
update_resource_features
|
||||
@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
|
||||
|
||||
def update
|
||||
|
||||
@ -12,7 +12,7 @@ class Platform::Api::V1::AgentBotsController < PlatformController
|
||||
@resource = AgentBot.new(agent_bot_params.except(:avatar_url))
|
||||
@resource.save!
|
||||
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
|
||||
|
||||
def update
|
||||
|
||||
@ -31,7 +31,7 @@ class Public::Api::V1::Inboxes::ContactsController < Public::Api::V1::InboxesCon
|
||||
return if params[:identifier_hash].blank? && !@inbox_channel.hmac_mandatory
|
||||
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
|
||||
|
||||
def valid_hmac?
|
||||
|
||||
@ -30,6 +30,8 @@ class Public::Api::V1::Inboxes::ConversationsController < Public::Api::V1::Inbox
|
||||
case params[:typing_status]
|
||||
when 'on'
|
||||
trigger_typing_event(CONVERSATION_TYPING_ON)
|
||||
when 'recording'
|
||||
trigger_typing_event(CONVERSATION_RECORDING)
|
||||
when 'off'
|
||||
trigger_typing_event(CONVERSATION_TYPING_OFF)
|
||||
end
|
||||
|
||||
@ -13,7 +13,7 @@ class SuperAdmin::AccountUsersController < SuperAdmin::ApplicationController
|
||||
resource = resource_class.new(resource_params)
|
||||
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)
|
||||
end
|
||||
|
||||
|
||||
@ -18,7 +18,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
|
||||
params['app_config'].each do |key, value|
|
||||
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.save!
|
||||
end
|
||||
|
||||
@ -7,6 +7,7 @@ class SuperAdmin::InstanceStatusesController < SuperAdmin::ApplicationController
|
||||
redis_metrics
|
||||
chatwoot_edition
|
||||
instance_meta
|
||||
baileys_api_version
|
||||
end
|
||||
|
||||
def chatwoot_edition
|
||||
@ -56,4 +57,10 @@ class SuperAdmin::InstanceStatusesController < SuperAdmin::ApplicationController
|
||||
rescue Redis::CannotConnectError
|
||||
@metrics['Redis alive'] = false
|
||||
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
|
||||
|
||||
@ -8,11 +8,26 @@ class Webhooks::WhatsappController < ActionController::API
|
||||
return
|
||||
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)
|
||||
head :ok
|
||||
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)
|
||||
channel = Channel::Whatsapp.find_by(phone_number: params[:phone_number])
|
||||
|
||||
@ -18,7 +18,8 @@ class AsyncDispatcher < BaseDispatcher
|
||||
NotificationListener.instance,
|
||||
ParticipationListener.instance,
|
||||
ReportingEventListener.instance,
|
||||
WebhookListener.instance
|
||||
WebhookListener.instance,
|
||||
ChannelListener.instance
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
45
app/helpers/baileys_helper.rb
Normal file
45
app/helpers/baileys_helper.rb
Normal 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
|
||||
@ -10,6 +10,6 @@ module CacheKeysHelper
|
||||
return value_from_cache if value_from_cache.present?
|
||||
|
||||
# zero epoch time: 1970-01-01 00:00:00 UTC
|
||||
'0000000000'
|
||||
'0000000000000'
|
||||
end
|
||||
end
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
module FrontendUrlsHelper
|
||||
def frontend_url(path, **query_params)
|
||||
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
|
||||
|
||||
@ -65,8 +65,8 @@ module ReportingEventHelper
|
||||
end
|
||||
|
||||
def format_time(hour, minute)
|
||||
hour = hour < 10 ? "0#{hour}" : hour
|
||||
minute = minute < 10 ? "0#{minute}" : minute
|
||||
hour = "0#{hour}" if hour < 10
|
||||
minute = "0#{minute}" if minute < 10
|
||||
"#{hour}:#{minute}"
|
||||
end
|
||||
end
|
||||
|
||||
@ -14,6 +14,6 @@ module TimezoneHelper
|
||||
zone.now.utc_offset == offset_in_seconds
|
||||
end
|
||||
|
||||
return matching_zone.name if matching_zone
|
||||
matching_zone&.name
|
||||
end
|
||||
end
|
||||
|
||||
@ -8,6 +8,7 @@ export const buildCreatePayload = ({
|
||||
contentAttributes,
|
||||
echoId,
|
||||
files,
|
||||
isRecordedAudio,
|
||||
ccEmails = '',
|
||||
bccEmails = '',
|
||||
toEmails = '',
|
||||
@ -22,6 +23,9 @@ export const buildCreatePayload = ({
|
||||
files.forEach(file => {
|
||||
payload.append('attachments[]', file);
|
||||
});
|
||||
isRecordedAudio?.forEach(filename => {
|
||||
payload.append('is_recorded_audio[]', filename);
|
||||
});
|
||||
payload.append('private', isPrivate);
|
||||
payload.append('echo_id', echoId);
|
||||
payload.append('cc_emails', ccEmails);
|
||||
@ -60,6 +64,7 @@ class MessageApi extends ApiClient {
|
||||
contentAttributes,
|
||||
echo_id: echoId,
|
||||
files,
|
||||
isRecordedAudio,
|
||||
ccEmails = '',
|
||||
bccEmails = '',
|
||||
toEmails = '',
|
||||
@ -74,6 +79,7 @@ class MessageApi extends ApiClient {
|
||||
contentAttributes,
|
||||
echoId,
|
||||
files,
|
||||
isRecordedAudio,
|
||||
ccEmails,
|
||||
bccEmails,
|
||||
toEmails,
|
||||
|
||||
@ -32,6 +32,14 @@ class Inboxes extends CacheEnabledApiClient {
|
||||
syncTemplates(inboxId) {
|
||||
return axios.post(`${this.url}/${inboxId}/sync_templates`);
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
@ -16,6 +16,7 @@ import ContentTemplateSelector from './ContentTemplateSelector.vue';
|
||||
const props = defineProps({
|
||||
attachedFiles: { type: Array, default: () => [] },
|
||||
isWhatsappInbox: { type: Boolean, default: false },
|
||||
isWhatsappBaileysInbox: { type: Boolean, default: false },
|
||||
isEmailOrWebWidgetInbox: { type: Boolean, default: false },
|
||||
isTwilioSmsInbox: { type: Boolean, default: false },
|
||||
isTwilioWhatsAppInbox: { type: Boolean, default: false },
|
||||
@ -77,7 +78,10 @@ const shouldShowEmojiButton = computed(() => {
|
||||
});
|
||||
|
||||
const isRegularMessageMode = computed(() => {
|
||||
return !props.isWhatsappInbox && !props.isTwilioWhatsAppInbox;
|
||||
return (
|
||||
(!props.isWhatsappInbox && !props.isTwilioWhatsAppInbox) ||
|
||||
props.isWhatsappBaileysInbox
|
||||
);
|
||||
});
|
||||
|
||||
const setSignature = () => {
|
||||
|
||||
@ -66,6 +66,9 @@ const inboxTypes = computed(() => ({
|
||||
isEmail: props.targetInbox?.channelType === INBOX_TYPES.EMAIL,
|
||||
isTwilio: props.targetInbox?.channelType === INBOX_TYPES.TWILIO,
|
||||
isWhatsapp: props.targetInbox?.channelType === INBOX_TYPES.WHATSAPP,
|
||||
isWhatsappBaileys:
|
||||
props.targetInbox?.channelType === INBOX_TYPES.WHATSAPP &&
|
||||
props.targetInbox?.provider === 'baileys',
|
||||
isWebWidget: props.targetInbox?.channelType === INBOX_TYPES.WEB,
|
||||
isApi: props.targetInbox?.channelType === INBOX_TYPES.API,
|
||||
isEmailOrWebWidget:
|
||||
@ -281,7 +284,7 @@ const handleSendTwilioMessage = async ({ message, templateParams }) => {
|
||||
|
||||
const shouldShowMessageEditor = computed(() => {
|
||||
return (
|
||||
!inboxTypes.value.isWhatsapp &&
|
||||
(!inboxTypes.value.isWhatsapp || inboxTypes.value.isWhatsappBaileys) &&
|
||||
!showNoInboxAlert.value &&
|
||||
!inboxTypes.value.isTwilioWhatsapp
|
||||
);
|
||||
@ -354,6 +357,7 @@ const shouldShowMessageEditor = computed(() => {
|
||||
<ActionButtons
|
||||
:attached-files="state.attachedFiles"
|
||||
:is-whatsapp-inbox="inboxTypes.isWhatsapp"
|
||||
:is-whatsapp-baileys-inbox="inboxTypes.isWhatsappBaileys"
|
||||
:is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget"
|
||||
:is-twilio-sms-inbox="inboxTypes.isTwilioSMS"
|
||||
:is-twilio-whats-app-inbox="inboxTypes.isTwilioWhatsapp"
|
||||
|
||||
@ -44,7 +44,7 @@ const triggerClick = () => {
|
||||
<component
|
||||
:is="componentIs"
|
||||
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="{
|
||||
'hover:bg-n-alpha-2 rounded-lg w-full gap-3': !$slots.default,
|
||||
}"
|
||||
|
||||
@ -32,7 +32,10 @@ const {
|
||||
} = useMessageContext();
|
||||
|
||||
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(() => {
|
||||
|
||||
@ -73,7 +73,10 @@ const emitNewAccount = () => {
|
||||
/>
|
||||
</button>
|
||||
</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')">
|
||||
<DropdownItem
|
||||
v-for="account in sortedCurrentUserAccounts"
|
||||
|
||||
@ -73,7 +73,7 @@ export default {
|
||||
v-if="shouldShowBanner"
|
||||
color-scheme="primary"
|
||||
: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')"
|
||||
has-close-button
|
||||
@close="dismissUpdateBanner"
|
||||
|
||||
@ -1,11 +1,24 @@
|
||||
<script setup>
|
||||
import ChannelIcon from 'dashboard/components-next/icon/ChannelIcon.vue';
|
||||
import { computed } from 'vue';
|
||||
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
inbox: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
withPhoneNumber: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
withProviderConnectionStatus: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const providerConnection = computed(() => {
|
||||
return props.inbox.provider_connection?.connection;
|
||||
});
|
||||
</script>
|
||||
|
||||
@ -18,5 +31,17 @@ defineProps({
|
||||
<span class="truncate">
|
||||
{{ inbox.name }}
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@ -23,6 +23,7 @@ import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import { useTrack } from 'dashboard/composables';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import { CONVERSATION_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
@ -46,11 +47,10 @@ import {
|
||||
} from '@chatwoot/prosemirror-schema/src/mentions/plugin';
|
||||
|
||||
import {
|
||||
appendSignature,
|
||||
findNodeToInsertImage,
|
||||
getContentNode,
|
||||
cleanSignature,
|
||||
insertAtCursor,
|
||||
removeSignature as removeSignatureHelper,
|
||||
scrollCursorIntoView,
|
||||
setURLWithQueryAndSize,
|
||||
} from 'dashboard/helper/editorHelper';
|
||||
@ -126,6 +126,8 @@ const createState = (
|
||||
const { isEditorHotKeyEnabled, fetchSignatureFlagFromUISettings } =
|
||||
useUISettings();
|
||||
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
|
||||
const typingIndicator = createTypingIndicator(
|
||||
() => emit('typingOn'),
|
||||
() => emit('typingOff'),
|
||||
@ -280,8 +282,27 @@ watch(showToolsMenu, updatedValue => {
|
||||
function focusEditorInputField(pos = 'end') {
|
||||
const { tr } = editorView.state;
|
||||
|
||||
const selection =
|
||||
pos === 'end' ? Selection.atEnd(tr.doc) : Selection.atStart(tr.doc);
|
||||
// Check if signature is at start and adjust cursor position accordingly
|
||||
const signaturePosition =
|
||||
currentUser.value?.ui_settings?.signature_position || 'top';
|
||||
const hasSignature = sendWithSignature.value && props.signature;
|
||||
|
||||
let selection;
|
||||
if (pos === 'end' || !hasSignature || signaturePosition !== 'top') {
|
||||
selection =
|
||||
pos === 'end' ? Selection.atEnd(tr.doc) : Selection.atStart(tr.doc);
|
||||
} else {
|
||||
// Position cursor after signature when signature is at start
|
||||
const signatureLength = props.signature
|
||||
? cleanSignature(props.signature).length
|
||||
: 0;
|
||||
const separatorLength =
|
||||
currentUser.value?.ui_settings?.signature_separator === '--' ? 6 : 2; // "\n--\n" vs "\n\n"
|
||||
const cursorPos = signatureLength + separatorLength;
|
||||
selection = Selection.near(
|
||||
tr.doc.resolve(Math.min(cursorPos, tr.doc.content.size))
|
||||
);
|
||||
}
|
||||
|
||||
editorView.dispatch(tr.setSelection(selection));
|
||||
editorView.focus();
|
||||
@ -291,14 +312,8 @@ function isBodyEmpty(content) {
|
||||
// if content is undefined, we assume that the body is empty
|
||||
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
|
||||
const bodyWithoutSignature = props.signature
|
||||
? removeSignatureHelper(content, props.signature)
|
||||
: content;
|
||||
|
||||
// trimming should remove all the whitespaces, so we can check the length
|
||||
return bodyWithoutSignature.trim().length === 0;
|
||||
return content.trim().length === 0;
|
||||
}
|
||||
|
||||
function handleEmptyBodyWithSignature() {
|
||||
@ -348,39 +363,6 @@ function reloadState(content = props.modelValue) {
|
||||
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);
|
||||
// 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);
|
||||
// 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() {
|
||||
const editorRect = editorRoot.value.getBoundingClientRect();
|
||||
const rect = selectedImageNode.value.getBoundingClientRect();
|
||||
@ -591,7 +573,11 @@ function createEditorView() {
|
||||
handleDOMEvents: {
|
||||
keyup: () => {
|
||||
if (!props.disabled) {
|
||||
typingIndicator.start();
|
||||
if (props.modelValue.length) {
|
||||
typingIndicator.start();
|
||||
} else {
|
||||
typingIndicator.stop();
|
||||
}
|
||||
updateImgToolbarOnDelete();
|
||||
}
|
||||
},
|
||||
@ -661,13 +647,6 @@ watch(
|
||||
}
|
||||
);
|
||||
|
||||
watch(sendWithSignature, newValue => {
|
||||
// see if the allowSignature flag is true
|
||||
if (props.allowSignature) {
|
||||
toggleSignatureInEditor(newValue);
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
// [VITE] state assignment was done in created before
|
||||
state = createState(
|
||||
|
||||
@ -334,7 +334,7 @@ export default {
|
||||
v-if="showMessageSignatureButton"
|
||||
v-tooltip.top-end="signatureToggleTooltip"
|
||||
icon="i-ph-signature"
|
||||
slate
|
||||
:color="sendWithSignature ? 'blue' : 'slate'"
|
||||
faded
|
||||
sm
|
||||
@click="toggleMessageSignature"
|
||||
|
||||
@ -5,6 +5,9 @@ import { useConfig } from 'dashboard/composables/useConfig';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import { useAI } from 'dashboard/composables/useAI';
|
||||
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
|
||||
import { useAdmin } from 'dashboard/composables/useAdmin';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useStore } from 'vuex';
|
||||
|
||||
// components
|
||||
import ReplyBox from './ReplyBox.vue';
|
||||
@ -36,6 +39,7 @@ import { REPLY_POLICY } from 'shared/constants/links';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
import WhatsappBaileysLinkDeviceModal from '../../../routes/dashboard/settings/inbox/components/WhatsappBaileysLinkDeviceModal.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@ -44,12 +48,15 @@ export default {
|
||||
Banner,
|
||||
ConversationLabelSuggestion,
|
||||
Spinner,
|
||||
WhatsappBaileysLinkDeviceModal,
|
||||
},
|
||||
mixins: [inboxMixin],
|
||||
setup() {
|
||||
const { isAdmin } = useAdmin();
|
||||
const isPopOutReplyBox = ref(false);
|
||||
const conversationPanelRef = ref(null);
|
||||
const { isEnterprise } = useConfig();
|
||||
const store = useStore();
|
||||
|
||||
const keyboardEvents = {
|
||||
Escape: {
|
||||
@ -78,6 +85,8 @@ export default {
|
||||
fetchIntegrationsIfRequired,
|
||||
fetchLabelSuggestions,
|
||||
conversationPanelRef,
|
||||
isAdmin,
|
||||
store,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
@ -89,6 +98,7 @@ export default {
|
||||
isProgrammaticScroll: false,
|
||||
messageSentSinceOpened: false,
|
||||
labelSuggestions: [],
|
||||
showBaileysLinkDeviceModal: false,
|
||||
};
|
||||
},
|
||||
|
||||
@ -99,6 +109,9 @@ export default {
|
||||
listLoadingStatus: 'getAllMessagesLoaded',
|
||||
currentAccountId: 'getCurrentAccountId',
|
||||
}),
|
||||
currentInbox() {
|
||||
return this.$store.getters['inboxes/getInbox'](this.currentChat.inbox_id);
|
||||
},
|
||||
isOpen() {
|
||||
return this.currentChat?.status === wootConstants.STATUS_TYPE.OPEN;
|
||||
},
|
||||
@ -243,6 +256,9 @@ export default {
|
||||
|
||||
return { incoming, outgoing };
|
||||
},
|
||||
inboxProviderConnection() {
|
||||
return this.currentInbox.provider_connection?.connection;
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
@ -443,12 +459,77 @@ export default {
|
||||
const payload = useSnakeCase(message);
|
||||
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;
|
||||
});
|
||||
},
|
||||
onOpenBaileysLinkDeviceModal() {
|
||||
this.showBaileysLinkDeviceModal = true;
|
||||
},
|
||||
onCloseBaileysLinkDeviceModal() {
|
||||
this.showBaileysLinkDeviceModal = 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_BAILEYS_PROVIDER_CONNECTION.RECONNECT_FAILED'
|
||||
)
|
||||
);
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col justify-between flex-grow h-full min-w-0 m-0">
|
||||
<template v-if="isAWhatsAppBaileysChannel">
|
||||
<WhatsappBaileysLinkDeviceModal
|
||||
v-if="showBaileysLinkDeviceModal"
|
||||
:show="showBaileysLinkDeviceModal"
|
||||
:on-close="onCloseBaileysLinkDeviceModal"
|
||||
:inbox="currentInbox"
|
||||
/>
|
||||
<Banner
|
||||
v-if="inboxProviderConnection !== 'open'"
|
||||
color-scheme="alert"
|
||||
class="mt-2 mx-2 rounded-lg overflow-hidden"
|
||||
:banner-message="
|
||||
isAdmin
|
||||
? $t(
|
||||
'CONVERSATION.INBOX.WHATSAPP_BAILEYS_PROVIDER_CONNECTION.NOT_CONNECTED'
|
||||
)
|
||||
: $t(
|
||||
'CONVERSATION.INBOX.WHATSAPP_BAILEYS_PROVIDER_CONNECTION.NOT_CONNECTED_CONTACT_ADMIN'
|
||||
)
|
||||
"
|
||||
has-action-button
|
||||
:action-button-label="
|
||||
isAdmin
|
||||
? $t(
|
||||
'CONVERSATION.INBOX.WHATSAPP_BAILEYS_PROVIDER_CONNECTION.LINK_DEVICE'
|
||||
)
|
||||
: ''
|
||||
"
|
||||
:action-button-icon="isAdmin ? '' : 'i-lucide-refresh-cw'"
|
||||
@primary-action="
|
||||
isAdmin ? onOpenBaileysLinkDeviceModal() : onSetupProviderConnection()
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
<Banner
|
||||
v-if="!currentChat.can_reply"
|
||||
color-scheme="alert"
|
||||
|
||||
@ -36,8 +36,6 @@ import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
|
||||
import fileUploadMixin from 'dashboard/mixins/fileUploadMixin';
|
||||
import {
|
||||
appendSignature,
|
||||
removeSignature,
|
||||
replaceSignature,
|
||||
extractTextFromMarkdown,
|
||||
} from 'dashboard/helper/editorHelper';
|
||||
|
||||
@ -120,7 +118,6 @@ export default {
|
||||
showVariablesMenu: false,
|
||||
newConversationModalActive: false,
|
||||
showArticleSearchPopover: false,
|
||||
hasRecordedAudio: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@ -273,6 +270,9 @@ export default {
|
||||
hasAttachments() {
|
||||
return this.attachedFiles.length;
|
||||
},
|
||||
hasRecordedAudio() {
|
||||
return this.attachedFiles.some(file => file.isRecordedAudio);
|
||||
},
|
||||
isRichEditorEnabled() {
|
||||
return this.isAWebWidgetInbox || this.isAnEmailChannel;
|
||||
},
|
||||
@ -411,11 +411,7 @@ export default {
|
||||
},
|
||||
message(updatedMessage) {
|
||||
// Check if the message starts with a slash.
|
||||
const bodyWithoutSignature = removeSignature(
|
||||
updatedMessage,
|
||||
this.signatureToApply
|
||||
);
|
||||
const startsWithSlash = bodyWithoutSignature.startsWith('/');
|
||||
const startsWithSlash = updatedMessage.startsWith('/');
|
||||
|
||||
// Determine if the user is potentially typing a slash command.
|
||||
// This is true if the message starts with a slash and the rich content editor is not active.
|
||||
@ -425,7 +421,7 @@ export default {
|
||||
// If a slash command is active, extract the command text after the slash.
|
||||
// If not, reset the mentionSearchKey.
|
||||
this.mentionSearchKey = this.hasSlashCommand
|
||||
? bodyWithoutSignature.substring(1)
|
||||
? updatedMessage.substring(1)
|
||||
: '';
|
||||
|
||||
// Autosave the current message draft.
|
||||
@ -499,21 +495,10 @@ export default {
|
||||
display_rich_content_editor: !this.showRichContentEditor,
|
||||
});
|
||||
|
||||
const plainTextSignature = extractTextFromMarkdown(this.messageSignature);
|
||||
|
||||
if (!this.showRichContentEditor && this.messageSignature) {
|
||||
// remove the old signature -> extract text from markdown -> attach new signature
|
||||
let message = removeSignature(this.message, this.messageSignature);
|
||||
message = extractTextFromMarkdown(message);
|
||||
message = appendSignature(message, plainTextSignature);
|
||||
|
||||
// extract text from markdown for plain text editor
|
||||
let message = extractTextFromMarkdown(this.message);
|
||||
this.message = message;
|
||||
} else {
|
||||
this.message = replaceSignature(
|
||||
this.message,
|
||||
plainTextSignature,
|
||||
this.messageSignature
|
||||
);
|
||||
}
|
||||
},
|
||||
resetRecorderAndClearAttachments() {
|
||||
@ -542,20 +527,9 @@ export default {
|
||||
const key = `draft-${this.conversationIdByRoute}-${this.replyType}`;
|
||||
const messageFromStore =
|
||||
this.$store.getters['draftMessages/get'](key) || '';
|
||||
|
||||
// ensure that the message has signature set based on the ui setting
|
||||
this.message = this.toggleSignatureForDraft(messageFromStore);
|
||||
this.message = messageFromStore;
|
||||
}
|
||||
},
|
||||
toggleSignatureForDraft(message) {
|
||||
if (this.isPrivate) {
|
||||
return message;
|
||||
}
|
||||
|
||||
return this.sendWithSignature
|
||||
? appendSignature(message, this.signatureToApply)
|
||||
: removeSignature(message, this.signatureToApply);
|
||||
},
|
||||
removeFromDraft() {
|
||||
if (this.conversationIdByRoute) {
|
||||
const key = `draft-${this.conversationIdByRoute}-${this.replyType}`;
|
||||
@ -745,13 +719,6 @@ export default {
|
||||
this.hideContentTemplatesModal();
|
||||
},
|
||||
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
|
||||
message = appendSignature(message, this.signatureToApply);
|
||||
}
|
||||
|
||||
const updatedMessage = replaceVariablesInMessage({
|
||||
message,
|
||||
variables: this.messageVariables,
|
||||
@ -803,10 +770,6 @@ export default {
|
||||
},
|
||||
clearMessage() {
|
||||
this.message = '';
|
||||
if (this.sendWithSignature && !this.isPrivate) {
|
||||
// if signature is enabled, append it to the message
|
||||
this.message = appendSignature(this.message, this.signatureToApply);
|
||||
}
|
||||
this.attachedFiles = [];
|
||||
this.isRecordingAudio = false;
|
||||
this.resetReplyToMessage();
|
||||
@ -826,6 +789,9 @@ export default {
|
||||
this.isRecorderAudioStopped = !this.isRecordingAudio;
|
||||
if (!this.isRecordingAudio) {
|
||||
this.resetAudioRecorderInput();
|
||||
this.onTypingOff();
|
||||
} else {
|
||||
this.onRecording();
|
||||
}
|
||||
},
|
||||
toggleAudioRecorderPlayPause() {
|
||||
@ -835,6 +801,7 @@ export default {
|
||||
if (!this.isRecorderAudioStopped) {
|
||||
this.isRecorderAudioStopped = true;
|
||||
this.$refs.audioRecorderInput.stopRecording();
|
||||
this.onTypingOff();
|
||||
} else if (this.isRecorderAudioStopped) {
|
||||
this.$refs.audioRecorderInput.playPause();
|
||||
}
|
||||
@ -850,6 +817,9 @@ export default {
|
||||
onTypingOn() {
|
||||
this.toggleTyping('on');
|
||||
},
|
||||
onRecording() {
|
||||
this.toggleTyping('recording');
|
||||
},
|
||||
onTypingOff() {
|
||||
this.toggleTyping('off');
|
||||
},
|
||||
@ -865,7 +835,9 @@ export default {
|
||||
},
|
||||
onFinishRecorder(file) {
|
||||
this.recordingAudioState = 'stopped';
|
||||
this.hasRecordedAudio = true;
|
||||
|
||||
this.removeRecordedAudio();
|
||||
|
||||
// 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
|
||||
const autoRecordedFile = {
|
||||
@ -889,6 +861,10 @@ export default {
|
||||
});
|
||||
},
|
||||
attachFile({ blob, file }) {
|
||||
if (file?.isRecordedAudio) {
|
||||
this.removeRecordedAudio();
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file.file);
|
||||
reader.onloadend = () => {
|
||||
@ -965,9 +941,24 @@ export default {
|
||||
return multipleMessagePayload;
|
||||
},
|
||||
getMessagePayload(message) {
|
||||
let finalMessage = message;
|
||||
if (this.sendWithSignature && !this.isPrivate && this.messageSignature) {
|
||||
const { signature_position, signature_separator } =
|
||||
this.currentUser?.ui_settings || {};
|
||||
const signatureSettings = {
|
||||
position: signature_position || 'top',
|
||||
separator: signature_separator || 'blank',
|
||||
};
|
||||
finalMessage = appendSignature(
|
||||
message,
|
||||
this.messageSignature,
|
||||
signatureSettings
|
||||
);
|
||||
}
|
||||
|
||||
let messagePayload = {
|
||||
conversationId: this.currentChat.id,
|
||||
message,
|
||||
message: finalMessage,
|
||||
private: this.isPrivate,
|
||||
sender: this.sender,
|
||||
};
|
||||
@ -975,11 +966,17 @@ export default {
|
||||
|
||||
if (this.attachedFiles && this.attachedFiles.length) {
|
||||
messagePayload.files = [];
|
||||
messagePayload.isRecordedAudio = [];
|
||||
this.attachedFiles.forEach(attachment => {
|
||||
if (this.globalConfig.directUploadsEnabled) {
|
||||
messagePayload.files.push(attachment.blobSignedId);
|
||||
} else {
|
||||
messagePayload.files.push(attachment.resource.file);
|
||||
if (attachment.isRecordedAudio) {
|
||||
messagePayload.isRecordedAudio.push(
|
||||
attachment.resource.file.name
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -1055,8 +1052,10 @@ export default {
|
||||
this.recordingAudioDurationText = '00:00';
|
||||
this.isRecordingAudio = false;
|
||||
this.recordingAudioState = '';
|
||||
this.hasRecordedAudio = false;
|
||||
// Only clear the recorded audio when we click toggle button.
|
||||
this.removeRecordedAudio();
|
||||
},
|
||||
removeRecordedAudio() {
|
||||
this.attachedFiles = this.attachedFiles.filter(
|
||||
file => !file?.isRecordedAudio
|
||||
);
|
||||
@ -1129,9 +1128,6 @@ export default {
|
||||
class="rounded-none input"
|
||||
:placeholder="messagePlaceHolder"
|
||||
:min-height="4"
|
||||
:signature="signatureToApply"
|
||||
allow-signature
|
||||
:send-with-signature="sendWithSignature"
|
||||
@typing-off="onTypingOff"
|
||||
@typing-on="onTypingOn"
|
||||
@focus="onFocus"
|
||||
@ -1148,8 +1144,6 @@ export default {
|
||||
:min-height="4"
|
||||
enable-variables
|
||||
:variables="messageVariables"
|
||||
:signature="signatureToApply"
|
||||
allow-signature
|
||||
:channel-type="channelType"
|
||||
@typing-off="onTypingOff"
|
||||
@typing-on="onTypingOn"
|
||||
|
||||
@ -119,8 +119,8 @@ describe('useUISettings', () => {
|
||||
it('returns correct value for isEditorHotKeyEnabled when editor_message_key is not configured', () => {
|
||||
getUISettingsMock.value.editor_message_key = undefined;
|
||||
const { isEditorHotKeyEnabled } = useUISettings();
|
||||
expect(isEditorHotKeyEnabled('enter')).toBe(false);
|
||||
expect(isEditorHotKeyEnabled('cmd_enter')).toBe(true);
|
||||
expect(isEditorHotKeyEnabled('enter')).toBe(true);
|
||||
expect(isEditorHotKeyEnabled('cmd_enter')).toBe(false);
|
||||
});
|
||||
|
||||
it('handles non-existent keys', () => {
|
||||
|
||||
@ -117,6 +117,13 @@ export const useInbox = (inboxId = null) => {
|
||||
);
|
||||
});
|
||||
|
||||
const isAWhatsAppBaileysChannel = computed(() => {
|
||||
return (
|
||||
channelType.value === INBOX_TYPES.WHATSAPP &&
|
||||
whatsAppAPIProvider.value === 'baileys'
|
||||
);
|
||||
});
|
||||
|
||||
const isAWhatsAppChannel = computed(() => {
|
||||
return (
|
||||
channelType.value === INBOX_TYPES.WHATSAPP ||
|
||||
@ -147,6 +154,7 @@ export const useInbox = (inboxId = null) => {
|
||||
isATwilioWhatsAppChannel,
|
||||
isAWhatsAppCloudChannel,
|
||||
is360DialogWhatsAppChannel,
|
||||
isAWhatsAppBaileysChannel,
|
||||
isAnEmailChannel,
|
||||
isAnInstagramChannel,
|
||||
isAVoiceChannel,
|
||||
|
||||
@ -112,7 +112,7 @@ const isEditorHotKeyEnabled = (key, uiSettings) => {
|
||||
enter_to_send_enabled: enterToSendEnabled,
|
||||
} = uiSettings.value || {};
|
||||
if (!editorMessageKey) {
|
||||
return key === (enterToSendEnabled ? 'enter' : 'cmd_enter');
|
||||
return key === (enterToSendEnabled ? 'cmd_enter' : 'enter');
|
||||
}
|
||||
return editorMessageKey === key;
|
||||
};
|
||||
|
||||
@ -37,16 +37,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
|
||||
* If there is, return the index of the signature, If there isn't, return -1
|
||||
@ -72,16 +62,28 @@ export function findSignatureInBody(body, signature) {
|
||||
*
|
||||
* @param {string} body - The body to append the signature to.
|
||||
* @param {string} signature - The signature to append.
|
||||
* @param {Object} settings - The signature settings (position, separator).
|
||||
* @returns {string} - The body with the signature appended.
|
||||
*/
|
||||
export function appendSignature(body, signature) {
|
||||
export function appendSignature(body, signature, settings = {}) {
|
||||
const position = settings.position || 'top';
|
||||
const separator = settings.separator || 'blank';
|
||||
const cleanedSignature = cleanSignature(signature);
|
||||
// if signature is already present, return body
|
||||
if (findSignatureInBody(body, cleanedSignature) > -1) {
|
||||
if (findSignatureInBody(body, cleanedSignature).index > -1) {
|
||||
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}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -105,17 +105,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', () => {
|
||||
Object.keys(DOES_NOT_HAVE_SIGNATURE).forEach(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', () => {
|
||||
Object.keys(HAS_SIGNATURE).forEach(key => {
|
||||
const { body, signature } = HAS_SIGNATURE[key];
|
||||
expect(findSignatureInBody(body, signature)).toBeGreaterThan(0);
|
||||
expect(findSignatureInBody(body, signature).index).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -126,11 +126,48 @@ describe('appendSignature', () => {
|
||||
const { body, signature } = DOES_NOT_HAVE_SIGNATURE[key];
|
||||
const cleanedSignature = cleanSignature(signature);
|
||||
expect(
|
||||
appendSignature(body, signature).includes(cleanedSignature)
|
||||
appendSignature(body, signature, {
|
||||
position: 'bottom',
|
||||
separator: '--',
|
||||
}).includes(cleanedSignature)
|
||||
).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 => {
|
||||
const { body, signature } = HAS_SIGNATURE[key];
|
||||
expect(appendSignature(body, signature)).toBe(body);
|
||||
@ -169,7 +206,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', () => {
|
||||
Object.keys(DOES_NOT_HAVE_SIGNATURE).forEach(key => {
|
||||
const { body, signature } = DOES_NOT_HAVE_SIGNATURE[key];
|
||||
@ -178,12 +215,12 @@ describe('removeSignature', () => {
|
||||
});
|
||||
it('removes signature if present at the 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', () => {
|
||||
const { body, signature } =
|
||||
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', () => {
|
||||
const { body, signature } = HAS_SIGNATURE['no text before signature'];
|
||||
@ -196,7 +233,7 @@ describe('removeSignature', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('replaceSignature', () => {
|
||||
describe.skip('replaceSignature - SKIP(#78): Due to changes on append signature logic', () => {
|
||||
it('appends the new signature if not present', () => {
|
||||
Object.keys(DOES_NOT_HAVE_SIGNATURE).forEach(key => {
|
||||
const { body, signature } = DOES_NOT_HAVE_SIGNATURE[key];
|
||||
|
||||
@ -57,7 +57,8 @@
|
||||
},
|
||||
"UPLOADING_ATTACHMENTS": "Uploading attachments...",
|
||||
"REPLIED_TO_STORY": "Replied to your story",
|
||||
"UNSUPPORTED_MESSAGE": "This message is unsupported. You can view this message on the Facebook / Instagram app.",
|
||||
"UNSUPPORTED_MESSAGE": "This message is unsupported. You can view this message on the app.",
|
||||
"UNSUPPORTED_MESSAGE_WHATSAPP": "This message is unsupported. You can view this message on the WhatsApp app.",
|
||||
"UNSUPPORTED_MESSAGE_FACEBOOK": "This message is unsupported. You can view this message on the Facebook Messenger app.",
|
||||
"UNSUPPORTED_MESSAGE_INSTAGRAM": "This message is unsupported. You can view this message on the Instagram app.",
|
||||
"SUCCESS_DELETE_MESSAGE": "Message deleted successfully",
|
||||
@ -268,6 +269,14 @@
|
||||
"SIDEBAR": {
|
||||
"CONTACT": "Contact",
|
||||
"COPILOT": "Copilot"
|
||||
},
|
||||
"INBOX": {
|
||||
"WHATSAPP_BAILEYS_PROVIDER_CONNECTION": {
|
||||
"NOT_CONNECTED": "WhatsApp is not connected. Please link your device again.",
|
||||
"NOT_CONNECTED_CONTACT_ADMIN": "WhatsApp is not connected. 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": {
|
||||
|
||||
@ -227,7 +227,9 @@
|
||||
"WHATSAPP_CLOUD": "WhatsApp Cloud",
|
||||
"WHATSAPP_CLOUD_DESC": "Quick setup through Meta",
|
||||
"TWILIO_DESC": "Connect via Twilio credentials",
|
||||
"360_DIALOG": "360Dialog"
|
||||
"360_DIALOG": "360Dialog",
|
||||
"BAILEYS": "Baileys",
|
||||
"BAILEYS_DESC": "Connect via non-official API Baileys"
|
||||
},
|
||||
"SELECT_PROVIDER": {
|
||||
"TITLE": "Select your API provider",
|
||||
@ -270,6 +272,28 @@
|
||||
"WEBHOOK_URL": "Webhook URL",
|
||||
"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"
|
||||
},
|
||||
"ADVANCED_OPTIONS": "Advanced options",
|
||||
"BAILEYS": {
|
||||
"SUBTITLE": "Click below to setup the WhatsApp channel using Baileys.",
|
||||
"LINK_BUTTON": "Link device",
|
||||
"LINK_DEVICE_MODAL": {
|
||||
"TITLE": "Link your device",
|
||||
"SUBTITLE": "Scan the QR code to link your device. Make sure the phone number is correct before scanning.",
|
||||
"LOADING_QRCODE": "Loading QR code...",
|
||||
"RECONNECTING": "Connecting...",
|
||||
"LINK_DEVICE": "Link device",
|
||||
"DISCONNECT": "Disconnect",
|
||||
"CONNECTED": "Your device has been connected successfully. You can now start sending and receiving messages."
|
||||
}
|
||||
},
|
||||
"SUBMIT_BUTTON": "Create WhatsApp Channel",
|
||||
"EMBEDDED_SIGNUP": {
|
||||
"TITLE": "Quick setup with Meta",
|
||||
@ -673,7 +697,17 @@
|
||||
"WHATSAPP_TEMPLATES_SYNC_SUBHEADER": "Manually sync message templates from WhatsApp to update your available templates.",
|
||||
"WHATSAPP_TEMPLATES_SYNC_BUTTON": "Sync Templates",
|
||||
"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"
|
||||
},
|
||||
"HELP_CENTER": {
|
||||
"LABEL": "Help Center",
|
||||
|
||||
@ -46,6 +46,17 @@
|
||||
"CONVERSATION_TYPING_OFF": "Conversation Typing Off"
|
||||
}
|
||||
},
|
||||
"NAME": {
|
||||
"LABEL": "Webhook Name",
|
||||
"PLACEHOLDER": "Enter the name of the webhook"
|
||||
},
|
||||
"INBOX": {
|
||||
"LABEL": "Inbox",
|
||||
"TITLE": "Select the inbox",
|
||||
"PLACEHOLDER": "All Inboxes",
|
||||
"NO_RESULTS": "No inboxes found",
|
||||
"INPUT_PLACEHOLDER": "Search inbox"
|
||||
},
|
||||
"END_POINT": {
|
||||
"LABEL": "Webhook URL",
|
||||
"PLACEHOLDER": "Example: {webhookExampleURL}",
|
||||
|
||||
@ -68,7 +68,27 @@
|
||||
"API_SUCCESS": "Signature saved successfully",
|
||||
"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_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": {
|
||||
"LABEL": "Message Signature",
|
||||
|
||||
@ -57,7 +57,8 @@
|
||||
},
|
||||
"UPLOADING_ATTACHMENTS": "Enviando anexos...",
|
||||
"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_INSTAGRAM": "Esta mensagem não é suportada. Você pode ver esta mensagem no aplicativo do Instagram.",
|
||||
"SUCCESS_DELETE_MESSAGE": "Mensagem excluída com sucesso",
|
||||
@ -268,6 +269,14 @@
|
||||
"SIDEBAR": {
|
||||
"CONTACT": "Contatos",
|
||||
"COPILOT": "Copiloto"
|
||||
},
|
||||
"INBOX": {
|
||||
"WHATSAPP_BAILEYS_PROVIDER_CONNECTION": {
|
||||
"NOT_CONNECTED": "O WhatsApp não está conectado. Por favor conecte o seu dispositivo novamente.",
|
||||
"NOT_CONNECTED_CONTACT_ADMIN": "O WhatsApp não está conectado. 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": {
|
||||
|
||||
@ -227,7 +227,9 @@
|
||||
"WHATSAPP_CLOUD": "Cloud do WhatsApp",
|
||||
"WHATSAPP_CLOUD_DESC": "Configuração rápida via Meta",
|
||||
"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"
|
||||
},
|
||||
"SELECT_PROVIDER": {
|
||||
"TITLE": "Selecione seu provedor de API",
|
||||
@ -270,6 +272,28 @@
|
||||
"WEBHOOK_URL": "URL do 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"
|
||||
},
|
||||
"ADVANCED_OPTIONS": "Opções avançadas",
|
||||
"BAILEYS": {
|
||||
"SUBTITLE": "Clique abaixo para configurar o canal do WhatsApp usando o Baileys.",
|
||||
"LINK_BUTTON": "Conectar dispositivo",
|
||||
"LINK_DEVICE_MODAL": {
|
||||
"TITLE": "Conecte o seu dispositivo",
|
||||
"SUBTITLE": "Escaneie o QR code para conectar seu dispositivo. Certifique-se de que o número de telefone esteja correto antes de escanear.",
|
||||
"LOADING_QRCODE": "Carregando QR code...",
|
||||
"RECONNECTING": "Conectando...",
|
||||
"LINK_DEVICE": "Conectar dispositivo",
|
||||
"DISCONNECT": "Desconectar",
|
||||
"CONNECTED": "Seu dispositivo foi conectado com sucesso. Agora você pode começar a enviar e receber mensagens."
|
||||
}
|
||||
},
|
||||
"SUBMIT_BUTTON": "Criar canal do WhatsApp",
|
||||
"EMBEDDED_SIGNUP": {
|
||||
"TITLE": "Configuração rápida com Meta",
|
||||
@ -672,7 +696,17 @@
|
||||
"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_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"
|
||||
},
|
||||
"HELP_CENTER": {
|
||||
"LABEL": "Centro de Ajuda",
|
||||
|
||||
@ -46,6 +46,17 @@
|
||||
"CONVERSATION_TYPING_OFF": "Status de Digitação desativado"
|
||||
}
|
||||
},
|
||||
"NAME": {
|
||||
"LABEL": "Nome do Webhook",
|
||||
"PLACEHOLDER": "Insira o nome do webhook"
|
||||
},
|
||||
"INBOX": {
|
||||
"LABEL": "Caixa de Entrada",
|
||||
"TITLE": "Selecione a caixa de entrada",
|
||||
"PLACEHOLDER": "Todas as caixas de entrada",
|
||||
"NO_RESULTS": "Nenhuma caixa de entrada encontrada",
|
||||
"INPUT_PLACEHOLDER": "Buscar caixa de entrada"
|
||||
},
|
||||
"END_POINT": {
|
||||
"LABEL": "URL do Webhook",
|
||||
"PLACEHOLDER": "Exemplo: {webhookExampleURL}",
|
||||
|
||||
@ -68,7 +68,27 @@
|
||||
"API_SUCCESS": "Assinatura salva com sucesso",
|
||||
"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_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": {
|
||||
"LABEL": "Assinatura da mensagem",
|
||||
|
||||
@ -7,6 +7,7 @@ import QRCode from 'qrcode';
|
||||
import EmptyState from '../../../../components/widgets/EmptyState.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import DuplicateInboxBanner from './channels/instagram/DuplicateInboxBanner.vue';
|
||||
import WhatsappBaileysLinkDeviceModal from './components/WhatsappBaileysLinkDeviceModal.vue';
|
||||
import { useInbox } from 'dashboard/composables/useInbox';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
|
||||
@ -24,9 +25,14 @@ const currentInbox = computed(() =>
|
||||
store.getters['inboxes/getInbox'](route.params.inbox_id)
|
||||
);
|
||||
|
||||
const showBaileysLinkDeviceModal = reactive({
|
||||
value: false,
|
||||
});
|
||||
|
||||
// Use useInbox composable with the inbox ID
|
||||
const {
|
||||
isAWhatsAppCloudChannel,
|
||||
isAWhatsAppBaileysChannel,
|
||||
isATwilioChannel,
|
||||
isASmsInbox,
|
||||
isALineChannel,
|
||||
@ -86,6 +92,12 @@ const message = computed(() => {
|
||||
)}`;
|
||||
}
|
||||
|
||||
if (isAWhatsAppBaileysChannel.value) {
|
||||
return `${t('INBOX_MGMT.FINISH.MESSAGE')}. ${t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.BAILEYS.SUBTITLE'
|
||||
)}`;
|
||||
}
|
||||
|
||||
if (isAnEmailChannel.value && !currentInbox.value.provider) {
|
||||
return t('INBOX_MGMT.ADD.EMAIL_CHANNEL.FINISH_MESSAGE');
|
||||
}
|
||||
@ -152,6 +164,14 @@ async function generateQRCodes() {
|
||||
}
|
||||
}
|
||||
|
||||
const onOpenBaileysLinkDeviceModal = () => {
|
||||
showBaileysLinkDeviceModal.value = true;
|
||||
};
|
||||
|
||||
const onCloseBaileysLinkDeviceModal = () => {
|
||||
showBaileysLinkDeviceModal.value = false;
|
||||
};
|
||||
|
||||
// Watch for currentInbox changes and regenerate QR codes when available
|
||||
watch(
|
||||
currentInbox,
|
||||
@ -213,6 +233,14 @@ onMounted(() => {
|
||||
:script="currentInbox.provider_config.webhook_verify_token"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="isAWhatsAppBaileysChannel"
|
||||
class="w-[50%] max-w-[50%] ml-[25%]"
|
||||
>
|
||||
<NextButton @click="onOpenBaileysLinkDeviceModal">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.BAILEYS.LINK_BUTTON') }}
|
||||
</NextButton>
|
||||
</div>
|
||||
<div class="w-[50%] max-w-[50%] ml-[25%]">
|
||||
<woot-code
|
||||
v-if="isALineChannel"
|
||||
@ -234,7 +262,9 @@ onMounted(() => {
|
||||
<woot-code lang="html" :script="currentInbox.forward_to_email" />
|
||||
</div>
|
||||
<div
|
||||
v-if="isAWhatsAppChannel && qrCodes.whatsapp"
|
||||
v-if="
|
||||
isAWhatsAppChannel && !isAWhatsAppBaileysChannel && qrCodes.whatsapp
|
||||
"
|
||||
class="flex flex-col gap-3 items-center mt-8"
|
||||
>
|
||||
<p class="mt-2 text-sm text-n-slate-9">
|
||||
@ -307,5 +337,12 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
</EmptyState>
|
||||
<WhatsappBaileysLinkDeviceModal
|
||||
v-if="showBaileysLinkDeviceModal.value"
|
||||
:show="showBaileysLinkDeviceModal.value"
|
||||
:on-close="onCloseBaileysLinkDeviceModal"
|
||||
:inbox="currentInbox"
|
||||
is-setup
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -104,6 +104,9 @@ export default {
|
||||
if (this.isATwilioWhatsAppChannel) {
|
||||
return this.$t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.TWILIO');
|
||||
}
|
||||
if (this.isAWhatsAppBaileysChannel) {
|
||||
return this.$t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.BAILEYS');
|
||||
}
|
||||
return '';
|
||||
},
|
||||
tabs() {
|
||||
@ -153,7 +156,8 @@ export default {
|
||||
this.isAVoiceChannel ||
|
||||
(this.isAnEmailChannel && !this.inbox.provider) ||
|
||||
this.shouldShowWhatsAppConfiguration ||
|
||||
this.isAWebWidgetInbox
|
||||
this.isAWebWidgetInbox ||
|
||||
this.isAWhatsAppBaileysChannel
|
||||
) {
|
||||
visibleToAllChannelTabs = [
|
||||
...visibleToAllChannelTabs,
|
||||
|
||||
@ -0,0 +1,184 @@
|
||||
<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';
|
||||
|
||||
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;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form class="flex flex-wrap mx-0" @submit.prevent="createChannel()">
|
||||
<div class="w-[65%] flex-shrink-0 flex-grow-0 max-w-[65%]">
|
||||
<label :class="{ error: v$.inboxName.$error }">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.INBOX_NAME.LABEL') }}
|
||||
<input
|
||||
v-model="inboxName"
|
||||
type="text"
|
||||
:placeholder="$t('INBOX_MGMT.ADD.WHATSAPP.INBOX_NAME.PLACEHOLDER')"
|
||||
@blur="v$.inboxName.$touch"
|
||||
/>
|
||||
<span v-if="v$.inboxName.$error" class="message">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.INBOX_NAME.ERROR') }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="w-[65%] flex-shrink-0 flex-grow-0 max-w-[65%]">
|
||||
<label :class="{ error: v$.phoneNumber.$error }">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PHONE_NUMBER.LABEL') }}
|
||||
<input
|
||||
v-model="phoneNumber"
|
||||
type="text"
|
||||
:placeholder="$t('INBOX_MGMT.ADD.WHATSAPP.PHONE_NUMBER.PLACEHOLDER')"
|
||||
@blur="v$.phoneNumber.$touch"
|
||||
/>
|
||||
<span v-if="v$.phoneNumber.$error" class="message">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PHONE_NUMBER.ERROR') }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!showAdvancedOptions"
|
||||
class="w-[65%] flex-shrink-0 flex-grow-0 max-w-[65%] mb-4"
|
||||
>
|
||||
<NextButton icon="i-lucide-plus" sm link @click="setShowAdvancedOptions">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.ADVANCED_OPTIONS') }}
|
||||
</NextButton>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="w-[65%] flex-shrink-0 flex-grow-0 max-w-[65%]">
|
||||
<span class="text-sm text-gray-600">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.ADVANCED_OPTIONS') }}
|
||||
</span>
|
||||
<label :class="{ error: v$.providerUrl.$error }">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PROVIDER_URL.LABEL') }}
|
||||
<input
|
||||
v-model="providerUrl"
|
||||
type="text"
|
||||
:placeholder="
|
||||
$t('INBOX_MGMT.ADD.WHATSAPP.PROVIDER_URL.PLACEHOLDER')
|
||||
"
|
||||
/>
|
||||
<span v-if="v$.providerUrl.$error" class="message">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PROVIDER_URL.ERROR') }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="w-[65%] flex-shrink-0 flex-grow-0 max-w-[65%]">
|
||||
<label :class="{ error: v$.apiKey.$error }">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.API_KEY.LABEL') }}
|
||||
<input
|
||||
v-model="apiKey"
|
||||
type="text"
|
||||
:placeholder="$t('INBOX_MGMT.ADD.WHATSAPP.API_KEY.PLACEHOLDER')"
|
||||
/>
|
||||
<span v-if="v$.apiKey.$error" class="message">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.API_KEY.ERROR') }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
@ -7,6 +7,7 @@ import ThreeSixtyDialogWhatsapp from './360DialogWhatsapp.vue';
|
||||
import CloudWhatsapp from './CloudWhatsapp.vue';
|
||||
import WhatsappEmbeddedSignup from './WhatsappEmbeddedSignup.vue';
|
||||
import ChannelSelector from 'dashboard/components/ChannelSelector.vue';
|
||||
import BaileysWhatsapp from './BaileysWhatsapp.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@ -19,6 +20,7 @@ const PROVIDER_TYPES = {
|
||||
WHATSAPP_EMBEDDED: 'whatsapp_embedded',
|
||||
WHATSAPP_MANUAL: 'whatsapp_manual',
|
||||
THREE_SIXTY_DIALOG: '360dialog',
|
||||
BAILEYS: 'baileys',
|
||||
};
|
||||
|
||||
const hasWhatsappAppId = computed(() => {
|
||||
@ -47,6 +49,12 @@ const availableProviders = computed(() => [
|
||||
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',
|
||||
},
|
||||
]);
|
||||
|
||||
const selectProvider = providerValue => {
|
||||
@ -138,7 +146,12 @@ const handleManualLinkClick = () => {
|
||||
<ThreeSixtyDialogWhatsapp
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -0,0 +1,164 @@
|
||||
<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.BAILEYS.LINK_DEVICE_MODAL.TITLE')
|
||||
"
|
||||
:header-content="
|
||||
$t('INBOX_MGMT.ADD.WHATSAPP.BAILEYS.LINK_DEVICE_MODAL.SUBTITLE')
|
||||
"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-4 p-8 pt-4">
|
||||
<div class="flex flex-col gap-4 items-center">
|
||||
<InboxName
|
||||
:inbox="inbox"
|
||||
class="!text-lg"
|
||||
with-phone-number
|
||||
with-provider-connection-status
|
||||
/>
|
||||
|
||||
<template v-if="!connection || connection === 'close' || error">
|
||||
<p v-if="error" class="text-red-500 text-center">
|
||||
{{ error }}
|
||||
</p>
|
||||
<Button :is-loading="loading" @click="setup">
|
||||
{{
|
||||
$t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.BAILEYS.LINK_DEVICE_MODAL.LINK_DEVICE'
|
||||
)
|
||||
}}
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<template v-else-if="connection === 'connecting'">
|
||||
<div v-if="!qrDataUrl" class="flex flex-col gap-4 items-center">
|
||||
<p>
|
||||
{{
|
||||
$t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.BAILEYS.LINK_DEVICE_MODAL.LOADING_QRCODE'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<Spinner />
|
||||
</div>
|
||||
<img
|
||||
v-else
|
||||
:src="qrDataUrl"
|
||||
alt="QR Code"
|
||||
class="w-[276px] h-[276px]"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-else-if="connection === 'reconnecting'">
|
||||
<p>
|
||||
{{
|
||||
$t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.BAILEYS.LINK_DEVICE_MODAL.RECONNECTING'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<Spinner />
|
||||
</template>
|
||||
|
||||
<template v-else-if="connection === 'open'">
|
||||
<p v-if="isSetup" class="text-center">
|
||||
{{
|
||||
$t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.BAILEYS.LINK_DEVICE_MODAL.CONNECTED'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<Button ghost :is-loading="loading" @click="disconnect">
|
||||
{{
|
||||
$t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.BAILEYS.LINK_DEVICE_MODAL.DISCONNECT'
|
||||
)
|
||||
}}
|
||||
</Button>
|
||||
<router-link
|
||||
v-if="isSetup"
|
||||
:to="{
|
||||
name: 'inbox_dashboard',
|
||||
params: { inboxId: inbox.id },
|
||||
}"
|
||||
>
|
||||
<Button
|
||||
solid
|
||||
teal
|
||||
:label="$t('INBOX_MGMT.FINISH.BUTTON_TEXT')"
|
||||
/>
|
||||
</router-link>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</woot-modal>
|
||||
</template>
|
||||
@ -5,9 +5,13 @@ import SettingsSection from '../../../../../components/SettingsSection.vue';
|
||||
import ImapSettings from '../ImapSettings.vue';
|
||||
import SmtpSettings from '../SmtpSettings.vue';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required } from '@vuelidate/validators';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import WhatsappReauthorize from '../channels/whatsapp/Reauthorize.vue';
|
||||
import { requiredIf } from '@vuelidate/validators';
|
||||
import { isValidURL } from '../../../../../helper/URLHelper';
|
||||
import WhatsappBaileysLinkDeviceModal from '../components/WhatsappBaileysLinkDeviceModal.vue';
|
||||
import InboxName from '../../../../../components/widgets/InboxName.vue';
|
||||
import Switch from 'dashboard/components-next/switch/Switch.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@ -16,6 +20,10 @@ export default {
|
||||
SmtpSettings,
|
||||
NextButton,
|
||||
WhatsappReauthorize,
|
||||
WhatsappBaileysLinkDeviceModal,
|
||||
InboxName,
|
||||
// eslint-disable-next-line vue/no-reserved-component-names
|
||||
Switch,
|
||||
},
|
||||
mixins: [inboxMixin],
|
||||
props: {
|
||||
@ -33,10 +41,18 @@ export default {
|
||||
whatsAppInboxAPIKey: '',
|
||||
isRequestingReauthorization: false,
|
||||
isSyncingTemplates: false,
|
||||
whatsAppProviderUrl: '',
|
||||
showBaileysLinkDeviceModal: false,
|
||||
markAsRead: true,
|
||||
};
|
||||
},
|
||||
validations: {
|
||||
whatsAppInboxAPIKey: { required },
|
||||
validations() {
|
||||
return {
|
||||
whatsAppInboxAPIKey: {
|
||||
requiredIf: requiredIf(!this.isAWhatsAppBaileysChannel),
|
||||
},
|
||||
whatsAppProviderUrl: { isValidURL: value => !value || isValidURL(value) },
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
isEmbeddedSignupWhatsApp() {
|
||||
@ -57,6 +73,8 @@ export default {
|
||||
methods: {
|
||||
setDefaults() {
|
||||
this.hmacMandatory = this.inbox.hmac_mandatory || false;
|
||||
this.whatsAppProviderUrl = this.inbox.provider_config?.provider_url ?? '';
|
||||
this.markAsRead = this.inbox.provider_config?.mark_as_read ?? true;
|
||||
},
|
||||
handleHmacFlag() {
|
||||
this.updateInbox();
|
||||
@ -113,6 +131,49 @@ export default {
|
||||
this.isSyncingTemplates = false;
|
||||
}
|
||||
},
|
||||
async updateWhatsAppProviderUrl() {
|
||||
try {
|
||||
const payload = {
|
||||
id: this.inbox.id,
|
||||
formData: false,
|
||||
channel: {
|
||||
provider_config: {
|
||||
...this.inbox.provider_config,
|
||||
provider_url: this.whatsAppProviderUrl,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await this.$store.dispatch('inboxes/updateInbox', payload);
|
||||
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
|
||||
}
|
||||
},
|
||||
async updateWhatsAppMarkAsRead() {
|
||||
try {
|
||||
const payload = {
|
||||
id: this.inbox.id,
|
||||
formData: false,
|
||||
channel: {
|
||||
provider_config: {
|
||||
...this.inbox.provider_config,
|
||||
mark_as_read: this.markAsRead,
|
||||
},
|
||||
},
|
||||
};
|
||||
await this.$store.dispatch('inboxes/updateInbox', payload);
|
||||
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
|
||||
}
|
||||
},
|
||||
onOpenBaileysLinkDeviceModal() {
|
||||
this.showBaileysLinkDeviceModal = true;
|
||||
},
|
||||
onCloseBaileysLinkDeviceModal() {
|
||||
this.showBaileysLinkDeviceModal = false;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@ -256,7 +317,7 @@ export default {
|
||||
<ImapSettings :inbox="inbox" />
|
||||
<SmtpSettings v-if="inbox.imap_enabled" :inbox="inbox" />
|
||||
</div>
|
||||
<div v-else-if="isAWhatsAppChannel && !isATwilioChannel">
|
||||
<div v-else-if="isAWhatsAppCloudChannel">
|
||||
<div v-if="inbox.provider_config" class="mx-8">
|
||||
<!-- Embedded Signup Section -->
|
||||
<template v-if="isEmbeddedSignupWhatsApp">
|
||||
@ -352,6 +413,134 @@ export default {
|
||||
class="hidden"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="isAWhatsAppBaileysChannel">
|
||||
<WhatsappBaileysLinkDeviceModal
|
||||
v-if="showBaileysLinkDeviceModal"
|
||||
:show="showBaileysLinkDeviceModal"
|
||||
:on-close="onCloseBaileysLinkDeviceModal"
|
||||
:inbox="inbox"
|
||||
/>
|
||||
<div class="mx-8">
|
||||
<SettingsSection
|
||||
:title="
|
||||
$t(
|
||||
'INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANAGE_PROVIDER_CONNECTION_TITLE'
|
||||
)
|
||||
"
|
||||
:sub-title="
|
||||
$t(
|
||||
'INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANAGE_PROVIDER_CONNECTION_SUBHEADER'
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<InboxName
|
||||
:inbox="inbox"
|
||||
class="!text-lg !m-0"
|
||||
with-phone-number
|
||||
with-provider-connection-status
|
||||
/>
|
||||
<NextButton class="w-fit" @click="onOpenBaileysLinkDeviceModal">
|
||||
{{
|
||||
$t(
|
||||
'INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANAGE_PROVIDER_CONNECTION_BUTTON'
|
||||
)
|
||||
}}
|
||||
</NextButton>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
<SettingsSection
|
||||
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_PROVIDER_URL_TITLE')"
|
||||
:sub-title="
|
||||
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_PROVIDER_URL_SUBHEADER')
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between flex-1 mt-2 whatsapp-settings--content"
|
||||
>
|
||||
<woot-input
|
||||
v-model="whatsAppProviderUrl"
|
||||
type="text"
|
||||
class="flex-1 mr-2 items-center"
|
||||
:placeholder="
|
||||
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_PROVIDER_URL_PLACEHOLDER')
|
||||
"
|
||||
@keydown="v$.whatsAppProviderUrl.$touch"
|
||||
/>
|
||||
<NextButton
|
||||
:disabled="
|
||||
v$.whatsAppProviderUrl.$invalid ||
|
||||
whatsAppProviderUrl === inbox.provider_config.provider_url
|
||||
"
|
||||
@click="updateWhatsAppProviderUrl"
|
||||
>
|
||||
{{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_UPDATE_BUTTON') }}
|
||||
</NextButton>
|
||||
</div>
|
||||
<span v-if="v$.whatsAppProviderUrl.$error" class="text-red-400">
|
||||
{{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_PROVIDER_URL_ERROR') }}
|
||||
</span>
|
||||
</SettingsSection>
|
||||
<template v-if="inbox.provider_config.api_key">
|
||||
<SettingsSection
|
||||
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_TITLE')"
|
||||
:sub-title="
|
||||
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_SUBHEADER')
|
||||
"
|
||||
>
|
||||
<woot-code :script="inbox.provider_config.api_key" />
|
||||
</SettingsSection>
|
||||
</template>
|
||||
<SettingsSection
|
||||
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_UPDATE_TITLE')"
|
||||
:sub-title="
|
||||
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_UPDATE_SUBHEADER')
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between flex-1 mt-2 whatsapp-settings--content"
|
||||
>
|
||||
<woot-input
|
||||
v-model="whatsAppInboxAPIKey"
|
||||
type="text"
|
||||
class="flex-1 mr-2"
|
||||
:placeholder="
|
||||
$t(
|
||||
'INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_UPDATE_PLACEHOLDER'
|
||||
)
|
||||
"
|
||||
/>
|
||||
<NextButton
|
||||
:disabled="
|
||||
v$.whatsAppInboxAPIKey.$invalid ||
|
||||
(!inbox.provider_config.api_key && !whatsAppInboxAPIKey) ||
|
||||
whatsAppInboxAPIKey === inbox.provider_config.api_key
|
||||
"
|
||||
@click="updateWhatsAppInboxAPIKey"
|
||||
>
|
||||
{{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_UPDATE_BUTTON') }}
|
||||
</NextButton>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
<SettingsSection
|
||||
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MARK_AS_READ_TITLE')"
|
||||
:sub-title="
|
||||
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MARK_AS_READ_SUBHEADER')
|
||||
"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Switch
|
||||
id="markAsRead"
|
||||
v-model="markAsRead"
|
||||
@change="updateWhatsAppMarkAsRead"
|
||||
/>
|
||||
<label for="markAsRead">
|
||||
{{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MARK_AS_READ_LABEL') }}
|
||||
</label>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@ -53,6 +53,7 @@ export default {
|
||||
:value="value"
|
||||
:is-submitting="uiFlags.updatingItem"
|
||||
:submit-label="$t('INTEGRATION_SETTINGS.WEBHOOK.FORM.EDIT_SUBMIT')"
|
||||
is-editing
|
||||
@submit="onSubmit"
|
||||
@cancel="onClose"
|
||||
/>
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
<script>
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required, url, minLength } from '@vuelidate/validators';
|
||||
import { required, url, minLength, or } from '@vuelidate/validators';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import { getI18nKey } from 'dashboard/routes/dashboard/settings/helper/settingsHelper';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import MultiselectDropdown from 'shared/components/ui/MultiselectDropdown.vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
const { EXAMPLE_WEBHOOK_URL } = wootConstants;
|
||||
|
||||
@ -20,9 +22,22 @@ const SUPPORTED_WEBHOOK_EVENTS = [
|
||||
'conversation_typing_off',
|
||||
];
|
||||
|
||||
const localhostUrl = value => {
|
||||
if (!value) {
|
||||
return true;
|
||||
}
|
||||
const isRunningOnLocalhost = ['localhost', '127.0.0.1'].includes(
|
||||
window.location.hostname
|
||||
);
|
||||
const localUrlPattern =
|
||||
/^(?:https?:\/\/)?(?:localhost|127\.0\.0\.1)(?::\d+)?(?:\/.*)?$/i;
|
||||
return isRunningOnLocalhost && localUrlPattern.test(value);
|
||||
};
|
||||
|
||||
export default {
|
||||
components: {
|
||||
NextButton,
|
||||
MultiselectDropdown,
|
||||
},
|
||||
props: {
|
||||
value: {
|
||||
@ -37,16 +52,23 @@ export default {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
isEditing: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ['submit', 'cancel'],
|
||||
setup() {
|
||||
return { v$: useVuelidate() };
|
||||
return {
|
||||
v$: useVuelidate(),
|
||||
inboxes: useMapGetter('inboxes/getInboxes'),
|
||||
};
|
||||
},
|
||||
validations: {
|
||||
url: {
|
||||
required,
|
||||
minLength: minLength(7),
|
||||
url,
|
||||
url: or(localhostUrl, url),
|
||||
},
|
||||
subscriptions: {
|
||||
required,
|
||||
@ -55,11 +77,27 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
url: this.value.url || '',
|
||||
assignedInbox: this.value.inbox || null,
|
||||
name: this.value.name || '',
|
||||
subscriptions: this.value.subscriptions || [],
|
||||
supportedWebhookEvents: SUPPORTED_WEBHOOK_EVENTS,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
inboxesList() {
|
||||
if (this.assignedInbox?.id) {
|
||||
return [
|
||||
{
|
||||
id: 0,
|
||||
name: this.$t(
|
||||
'INTEGRATION_SETTINGS.WEBHOOK.FORM.INBOX.PLACEHOLDER'
|
||||
),
|
||||
},
|
||||
...this.inboxes,
|
||||
];
|
||||
}
|
||||
return this.inboxes;
|
||||
},
|
||||
webhookURLInputPlaceholder() {
|
||||
return this.$t(
|
||||
'INTEGRATION_SETTINGS.WEBHOOK.FORM.END_POINT.PLACEHOLDER',
|
||||
@ -68,14 +106,22 @@ export default {
|
||||
}
|
||||
);
|
||||
},
|
||||
webhookNameInputPlaceholder() {
|
||||
return this.$t('INTEGRATION_SETTINGS.WEBHOOK.FORM.NAME.PLACEHOLDER');
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onSubmit() {
|
||||
this.$emit('submit', {
|
||||
url: this.url,
|
||||
inbox_id: this.assignedInbox?.id || null,
|
||||
name: this.name,
|
||||
subscriptions: this.subscriptions,
|
||||
});
|
||||
},
|
||||
onClickAssignInbox(inbox) {
|
||||
this.assignedInbox = inbox;
|
||||
},
|
||||
getI18nKey,
|
||||
},
|
||||
};
|
||||
@ -90,6 +136,7 @@ export default {
|
||||
v-model="url"
|
||||
type="text"
|
||||
name="url"
|
||||
:disabled="isEditing"
|
||||
:placeholder="webhookURLInputPlaceholder"
|
||||
@input="v$.url.$touch"
|
||||
/>
|
||||
@ -97,6 +144,38 @@ export default {
|
||||
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.FORM.END_POINT.ERROR') }}
|
||||
</span>
|
||||
</label>
|
||||
<label>
|
||||
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.FORM.INBOX.LABEL') }}
|
||||
<div class="multiselect-wrap--small">
|
||||
<MultiselectDropdown
|
||||
:options="inboxesList"
|
||||
:selected-item="assignedInbox"
|
||||
:multiselector-title="
|
||||
$t('INTEGRATION_SETTINGS.WEBHOOK.FORM.INBOX.TITLE')
|
||||
"
|
||||
:multiselector-placeholder="
|
||||
$t('INTEGRATION_SETTINGS.WEBHOOK.FORM.INBOX.PLACEHOLDER')
|
||||
"
|
||||
:no-search-result="
|
||||
$t('INTEGRATION_SETTINGS.WEBHOOK.FORM.INBOX.NO_RESULTS')
|
||||
"
|
||||
:input-placeholder="
|
||||
$t('INTEGRATION_SETTINGS.WEBHOOK.FORM.INBOX.INPUT_PLACEHOLDER')
|
||||
"
|
||||
:disabled="isEditing"
|
||||
@select="onClickAssignInbox"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<label>
|
||||
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.FORM.NAME.LABEL') }}
|
||||
<input
|
||||
v-model="name"
|
||||
type="text"
|
||||
name="name"
|
||||
:placeholder="webhookNameInputPlaceholder"
|
||||
/>
|
||||
</label>
|
||||
<label :class="{ error: v$.url.$error }" class="mb-2">
|
||||
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.FORM.SUBSCRIPTIONS.LABEL') }}
|
||||
</label>
|
||||
@ -127,7 +206,6 @@ export default {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row justify-end w-full gap-2 px-0 py-2">
|
||||
<NextButton
|
||||
faded
|
||||
|
||||
@ -3,6 +3,7 @@ import { computed } from 'vue';
|
||||
import { getI18nKey } from 'dashboard/routes/dashboard/settings/helper/settingsHelper';
|
||||
import ShowMore from 'dashboard/components/widgets/ShowMore.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import InboxName from 'components/widgets/InboxName.vue';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
@ -37,8 +38,17 @@ const subscribedEvents = computed(() => {
|
||||
<template>
|
||||
<tr>
|
||||
<td class="py-4 ltr:pr-4 rtl:pl-4">
|
||||
<InboxName v-if="webhook.inbox" class="!mx-0" :inbox="webhook.inbox" />
|
||||
<div class="font-medium break-words text-n-slate-12">
|
||||
{{ webhook.url }}
|
||||
<template v-if="webhook.name">
|
||||
{{ webhook.name }}
|
||||
<span class="text-slate-500 dark:text-slate-400">
|
||||
{{ webhook.url }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ webhook.url }}
|
||||
</template>
|
||||
</div>
|
||||
<div class="block mt-1 text-sm text-n-slate-11">
|
||||
<span class="font-medium">
|
||||
|
||||
@ -63,6 +63,8 @@ export default {
|
||||
displayName: '',
|
||||
email: '',
|
||||
messageSignature: '',
|
||||
signaturePosition: '',
|
||||
signatureSeparator: '',
|
||||
hotKeys: [
|
||||
{
|
||||
key: 'enter',
|
||||
@ -114,6 +116,11 @@ export default {
|
||||
this.avatarUrl = this.currentUser.avatar_url;
|
||||
this.displayName = this.currentUser.display_name;
|
||||
this.messageSignature = this.currentUser.message_signature;
|
||||
|
||||
const { signature_position, signature_separator } =
|
||||
this.currentUser.ui_settings || {};
|
||||
this.signaturePosition = signature_position || 'top';
|
||||
this.signatureSeparator = signature_separator || 'blank';
|
||||
},
|
||||
async dispatchUpdate(payload, successMessage, errorMessage) {
|
||||
let alertMessage = '';
|
||||
@ -154,16 +161,29 @@ export default {
|
||||
|
||||
if (hasEmailChanged && success) clearCookiesOnLogout();
|
||||
},
|
||||
async updateSignature(signature) {
|
||||
const payload = { message_signature: signature };
|
||||
let successMessage = this.$t(
|
||||
'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.API_SUCCESS'
|
||||
);
|
||||
let errorMessage = this.$t(
|
||||
'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.API_ERROR'
|
||||
);
|
||||
async updateSignature(signature, signaturePosition, signatureSeparator) {
|
||||
try {
|
||||
const signaturePayload = { message_signature: signature };
|
||||
await this.dispatchUpdate(
|
||||
signaturePayload,
|
||||
this.$t(
|
||||
'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.API_SUCCESS'
|
||||
),
|
||||
this.$t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.API_ERROR')
|
||||
);
|
||||
|
||||
await this.dispatchUpdate(payload, successMessage, errorMessage);
|
||||
await this.updateUISettings({
|
||||
signature_position: signaturePosition,
|
||||
signature_separator: signatureSeparator,
|
||||
});
|
||||
|
||||
this.signaturePosition = signaturePosition;
|
||||
this.signatureSeparator = signatureSeparator;
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
this.$t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.API_ERROR')
|
||||
);
|
||||
}
|
||||
},
|
||||
updateProfilePicture({ file, url }) {
|
||||
this.avatarFile = file;
|
||||
@ -251,6 +271,8 @@ export default {
|
||||
>
|
||||
<MessageSignature
|
||||
:message-signature="messageSignature"
|
||||
:signature-position="signaturePosition"
|
||||
:signature-separator="signatureSeparator"
|
||||
@update-signature="updateSignature"
|
||||
/>
|
||||
</FormSection>
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import { ref, watch, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
|
||||
import { MESSAGE_SIGNATURE_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
@ -9,25 +11,184 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
signaturePosition: {
|
||||
type: String,
|
||||
// NOTE: 'top' or 'bottom'
|
||||
default: 'top',
|
||||
},
|
||||
signatureSeparator: {
|
||||
type: String,
|
||||
// NOTE: 'blank' or '--'
|
||||
default: 'blank',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['updateSignature']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const { formatMessage } = useMessageFormatter();
|
||||
|
||||
const customEditorMenuList = MESSAGE_SIGNATURE_EDITOR_MENU_OPTIONS;
|
||||
const signature = ref(props.messageSignature);
|
||||
const signaturePosition = ref(props.signaturePosition);
|
||||
const signatureSeparator = ref(props.signatureSeparator);
|
||||
|
||||
const positionOptions = computed(() => [
|
||||
{
|
||||
value: 'top',
|
||||
label: t(
|
||||
'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.SIGNATURE_POSITION.OPTIONS.TOP'
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 'bottom',
|
||||
label: t(
|
||||
'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.SIGNATURE_POSITION.OPTIONS.BOTTOM'
|
||||
),
|
||||
},
|
||||
]);
|
||||
|
||||
const separatorOptions = computed(() => [
|
||||
{
|
||||
value: 'blank',
|
||||
label: t(
|
||||
'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.SIGNATURE_SEPARATOR.OPTIONS.BLANK'
|
||||
),
|
||||
},
|
||||
{
|
||||
value: '--',
|
||||
label: t(
|
||||
'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.SIGNATURE_SEPARATOR.OPTIONS.HORIZONTAL_LINE'
|
||||
),
|
||||
},
|
||||
]);
|
||||
|
||||
const sampleMessage = computed(
|
||||
() =>
|
||||
`<p>${t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.PREVIEW.SAMPLE_MESSAGE')}</p>`
|
||||
);
|
||||
|
||||
const formattedSignature = computed(() => {
|
||||
if (!signature.value) return '';
|
||||
return formatMessage(signature.value, false, false);
|
||||
});
|
||||
|
||||
const messagePreview = computed(() => {
|
||||
if (!signature.value) return sampleMessage.value;
|
||||
|
||||
const separator =
|
||||
signatureSeparator.value === 'blank' ? '<p></p>' : '<p>--</p>';
|
||||
|
||||
if (signaturePosition.value === 'top') {
|
||||
return `${formattedSignature.value}${separator}${sampleMessage.value}`;
|
||||
}
|
||||
return `${sampleMessage.value}${separator}${formattedSignature.value}`;
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.signaturePosition,
|
||||
newValue => {
|
||||
signaturePosition.value = newValue;
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.signatureSeparator,
|
||||
newValue => {
|
||||
signatureSeparator.value = newValue;
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.messageSignature ?? '',
|
||||
newValue => {
|
||||
signature.value = newValue;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const updateSignature = () => {
|
||||
emit('updateSignature', signature.value);
|
||||
emit(
|
||||
'updateSignature',
|
||||
signature.value,
|
||||
signaturePosition.value,
|
||||
signatureSeparator.value
|
||||
);
|
||||
};
|
||||
|
||||
const handlePositionChange = value => {
|
||||
signaturePosition.value = value;
|
||||
emit('updateSignature', signature.value, value, signatureSeparator.value);
|
||||
};
|
||||
|
||||
const handleSeparatorChange = value => {
|
||||
signatureSeparator.value = value;
|
||||
emit('updateSignature', signature.value, signaturePosition.value, value);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form class="flex flex-col gap-6" @submit.prevent="updateSignature()">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label
|
||||
for="signaturePosition"
|
||||
class="text-sm font-medium text-n-slate-12"
|
||||
>
|
||||
{{
|
||||
t(
|
||||
'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.SIGNATURE_POSITION.LABEL'
|
||||
)
|
||||
}}
|
||||
</label>
|
||||
<select
|
||||
id="signaturePosition"
|
||||
v-model="signaturePosition"
|
||||
name="signaturePosition"
|
||||
class="block w-full px-3 py-2 pr-6 mb-0 shadow-sm appearance-none rounded-xl select-caret leading-6 bg-white dark:bg-n-slate-3 border border-n-slate-3 dark:border-n-slate-7"
|
||||
@change="handlePositionChange($event.target.value)"
|
||||
>
|
||||
<option
|
||||
v-for="option in positionOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
:selected="option.value === signaturePosition"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label
|
||||
for="signatureSeparator"
|
||||
class="text-sm font-medium text-n-slate-12"
|
||||
>
|
||||
{{
|
||||
t(
|
||||
'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.SIGNATURE_SEPARATOR.LABEL'
|
||||
)
|
||||
}}
|
||||
</label>
|
||||
<select
|
||||
id="signatureSeparator"
|
||||
v-model="signatureSeparator"
|
||||
name="signatureSeparator"
|
||||
class="block w-full px-3 py-2 pr-6 mb-0 shadow-sm appearance-none rounded-xl select-caret leading-6 bg-white dark:bg-n-slate-3 border border-n-slate-3 dark:border-n-slate-7"
|
||||
@change="handleSeparatorChange($event.target.value)"
|
||||
>
|
||||
<option
|
||||
v-for="option in separatorOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
:selected="option.value === signatureSeparator"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<WootMessageEditor
|
||||
id="message-signature-input"
|
||||
v-model="signature"
|
||||
@ -38,6 +199,35 @@ const updateSignature = () => {
|
||||
:enable-suggestions="false"
|
||||
show-image-resize-toolbar
|
||||
/>
|
||||
<div
|
||||
class="flex flex-col gap-3 p-4 bg-n-slate-1 dark:bg-n-slate-2 rounded-lg border border-n-slate-4 dark:border-n-slate-8"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<fluent-icon icon="info" size="16" class="text-n-slate-11" />
|
||||
<h3 class="text-sm font-medium text-n-slate-12 m-0">
|
||||
{{
|
||||
$t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.PREVIEW.TITLE')
|
||||
}}
|
||||
</h3>
|
||||
</div>
|
||||
<div
|
||||
class="bg-white dark:bg-n-slate-3 rounded-md p-3 border border-n-slate-3 dark:border-n-slate-7"
|
||||
>
|
||||
<div
|
||||
v-if="messagePreview"
|
||||
v-dompurify-html="messagePreview"
|
||||
class="message-preview text-sm text-n-slate-12 [&>p]:mb-2 [&>p:last-child]:mb-0"
|
||||
/>
|
||||
<div v-else class="text-sm text-n-slate-10 italic">
|
||||
{{
|
||||
$t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.PREVIEW.EMPTY')
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-n-slate-11 m-0">
|
||||
{{ $t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.PREVIEW.NOTE') }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<NextButton
|
||||
type="submit"
|
||||
|
||||
@ -332,6 +332,20 @@ export const actions = {
|
||||
throw new Error(error);
|
||||
}
|
||||
},
|
||||
setupChannelProvider: async (_, inboxId) => {
|
||||
try {
|
||||
await InboxesAPI.setupChannelProvider(inboxId);
|
||||
} catch (error) {
|
||||
throwErrorMessage(error);
|
||||
}
|
||||
},
|
||||
disconnectChannelProvider: async (_, inboxId) => {
|
||||
try {
|
||||
await InboxesAPI.disconnectChannelProvider(inboxId);
|
||||
} catch (error) {
|
||||
throwErrorMessage(error);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const mutations = {
|
||||
|
||||
@ -285,5 +285,7 @@
|
||||
"M9.60364 9.20645C9.60364 8.67008 10.0385 8.23523 10.5749 8.23523C11.1113 8.23523 11.5461 8.67008 11.5461 9.20645V11.4511C11.5461 11.9875 11.1113 12.4223 10.5749 12.4223C10.0385 12.4223 9.60364 11.9875 9.60364 11.4511V9.20645Z",
|
||||
"M17.1442 5.57049C13.5275 5.06019 10.5793 5.04007 6.88135 5.56825C5.9466 5.70176 5.32812 5.79197 4.85654 5.92976C4.41928 6.05757 4.17061 6.20994 3.96492 6.43984C3.539 6.91583 3.48286 7.45419 3.4248 9.33184C3.36775 11.1772 3.48076 12.831 3.69481 14.6918C3.80887 15.6834 3.88736 16.3526 4.01268 16.8613C4.13155 17.3439 4.27532 17.6034 4.47513 17.802C4.67654 18.0023 4.93467 18.1435 5.40841 18.2581C5.90952 18.3793 6.56702 18.4526 7.5442 18.5592C10.7045 18.904 13.0702 18.9022 16.2423 18.561C17.2313 18.4546 17.8995 18.3813 18.4081 18.2609C18.8913 18.1465 19.1511 18.0063 19.3497 17.8118C19.5442 17.6213 19.6928 17.3587 19.8217 16.852C19.9561 16.3234 20.0476 15.624 20.18 14.5966C20.4162 12.7633 20.5863 11.1533 20.5929 9.3896C20.5999 7.50391 20.5613 6.96737 20.1306 6.46971C19.9226 6.22932 19.6696 6.0713 19.2224 5.93968C18.7395 5.79754 18.1042 5.70594 17.1442 5.57049ZM6.65555 3.98715C10.5078 3.43695 13.6072 3.45849 17.3674 3.98902L17.4224 3.99678C18.3127 4.12235 19.0648 4.22844 19.6733 4.40753C20.33 4.60078 20.8792 4.89417 21.3382 5.4245C22.2041 6.42482 22.1984 7.6117 22.1909 9.18858C22.1905 9.25686 22.1902 9.32584 22.19 9.3956C22.183 11.2604 22.0026 12.949 21.764 14.8006L21.7577 14.8496C21.6332 15.8159 21.5307 16.6121 21.3695 17.2458C21.2 17.9121 20.9467 18.4833 20.4672 18.9529C19.9919 19.4183 19.4302 19.6602 18.776 19.8151C18.1582 19.9613 17.3895 20.044 16.4629 20.1436L16.4131 20.149C13.1283 20.5023 10.6472 20.5043 7.37097 20.1469L7.32043 20.1414C6.40679 20.0417 5.64604 19.9587 5.03292 19.8104C4.38112 19.6527 3.82317 19.406 3.34911 18.9347C2.87346 18.4618 2.62363 17.8999 2.46191 17.2433C2.30938 16.6241 2.22071 15.8531 2.11393 14.9246L2.10815 14.8743C1.88863 12.9659 1.76823 11.23 1.82845 9.28246C1.83063 9.2118 1.83272 9.14191 1.83479 9.07281C1.8816 7.50776 1.91671 6.33374 2.7747 5.37486C3.22992 4.86612 3.76798 4.58399 4.40853 4.39678C5.00257 4.22316 5.73505 4.11858 6.60207 3.99479C6.61981 3.99225 6.63764 3.9897 6.65555 3.98715Z"
|
||||
],
|
||||
"scan-person-outline": "M5.25 3.5A1.75 1.75 0 0 0 3.5 5.25v3a.75.75 0 0 1-1.5 0v-3A3.25 3.25 0 0 1 5.25 2h3a.75.75 0 0 1 0 1.5zm0 17a1.75 1.75 0 0 1-1.75-1.75v-3a.75.75 0 0 0-1.5 0v3A3.25 3.25 0 0 0 5.25 22h3a.75.75 0 0 0 .707-1l-.005-.015a.75.75 0 0 0-.702-.485zM20.5 5.25a1.75 1.75 0 0 0-1.75-1.75h-3a.75.75 0 0 1 0-1.5h3A3.25 3.25 0 0 1 22 5.25v3a.75.75 0 0 1-1.5 0zM18.75 20.5a1.75 1.75 0 0 0 1.75-1.75v-3a.75.75 0 0 1 1.5 0v3A3.25 3.25 0 0 1 18.75 22h-3a.75.75 0 0 1 0-1.5zM6.5 18.616q0 .465.258.884H5.25a1 1 0 0 1-.129-.011A3.1 3.1 0 0 1 5 18.616v-.366A2.25 2.25 0 0 1 7.25 16h9.5A2.25 2.25 0 0 1 19 18.25v.366c0 .31-.047.601-.132.875a1 1 0 0 1-.118.009h-1.543a1.56 1.56 0 0 0 .293-.884v-.366a.75.75 0 0 0-.75-.75h-9.5a.75.75 0 0 0-.75.75zm8.25-8.866a2.75 2.75 0 1 0-5.5 0a2.75 2.75 0 0 0 5.5 0m1.5 0a4.25 4.25 0 1 1-8.5 0a4.25 4.25 0 0 1 8.5 0"
|
||||
"scan-person-outline": "M5.25 3.5A1.75 1.75 0 0 0 3.5 5.25v3a.75.75 0 0 1-1.5 0v-3A3.25 3.25 0 0 1 5.25 2h3a.75.75 0 0 1 0 1.5zm0 17a1.75 1.75 0 0 1-1.75-1.75v-3a.75.75 0 0 0-1.5 0v3A3.25 3.25 0 0 0 5.25 22h3a.75.75 0 0 0 .707-1l-.005-.015a.75.75 0 0 0-.702-.485zM20.5 5.25a1.75 1.75 0 0 0-1.75-1.75h-3a.75.75 0 0 1 0-1.5h3A3.25 3.25 0 0 1 22 5.25v3a.75.75 0 0 1-1.5 0zM18.75 20.5a1.75 1.75 0 0 0 1.75-1.75v-3a.75.75 0 0 1 1.5 0v3A3.25 3.25 0 0 1 18.75 22h-3a.75.75 0 0 1 0-1.5zM6.5 18.616q0 .465.258.884H5.25a1 1 0 0 1-.129-.011A3.1 3.1 0 0 1 5 18.616v-.366A2.25 2.25 0 0 1 7.25 16h9.5A2.25 2.25 0 0 1 19 18.25v.366c0 .31-.047.601-.132.875a1 1 0 0 1-.118.009h-1.543a1.56 1.56 0 0 0 .293-.884v-.366a.75.75 0 0 0-.75-.75h-9.5a.75.75 0 0 0-.75.75zm8.25-8.866a2.75 2.75 0 1 0-5.5 0a2.75 2.75 0 0 0 5.5 0m1.5 0a4.25 4.25 0 1 1-8.5 0a4.25 4.25 0 0 1 8.5 0",
|
||||
"circle-outline": "M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20Z",
|
||||
"circle-filled": "M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z"
|
||||
}
|
||||
|
||||
@ -144,7 +144,11 @@ export default {
|
||||
this.resizeTextarea();
|
||||
},
|
||||
onKeyup() {
|
||||
this.typingIndicator.start();
|
||||
if (this.modelValue.length) {
|
||||
this.typingIndicator.start();
|
||||
} else {
|
||||
this.typingIndicator.stop();
|
||||
}
|
||||
},
|
||||
onBlur() {
|
||||
this.typingIndicator.stop();
|
||||
|
||||
@ -36,6 +36,10 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: 'Search',
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['select']);
|
||||
@ -66,6 +70,8 @@ const hasValue = computed(() => {
|
||||
showSearchDropdown ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'
|
||||
"
|
||||
class="w-full !px-2"
|
||||
type="button"
|
||||
:disabled="disabled"
|
||||
@click="
|
||||
() => toggleDropdown() // ensure that the event is not passed to the button
|
||||
"
|
||||
@ -92,12 +98,14 @@ const hasValue = computed(() => {
|
||||
rounded-full
|
||||
/>
|
||||
</Button>
|
||||
<!-- NOTE: Without @click.prevent, the dropdown does not behave as expected when used inside a <label> tag. -->
|
||||
<div
|
||||
:class="{
|
||||
'block visible': showSearchDropdown,
|
||||
'hidden invisible': !showSearchDropdown,
|
||||
}"
|
||||
class="box-border top-[2.625rem] w-full border rounded-lg bg-n-alpha-3 backdrop-blur-[100px] absolute shadow-lg border-n-strong dark:border-n-strong p-2 z-[9999]"
|
||||
@click.prevent
|
||||
>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<h4
|
||||
|
||||
@ -89,6 +89,12 @@ export default {
|
||||
this.whatsAppAPIProvider === 'default'
|
||||
);
|
||||
},
|
||||
isAWhatsAppBaileysChannel() {
|
||||
return (
|
||||
this.channelType === INBOX_TYPES.WHATSAPP &&
|
||||
this.whatsAppAPIProvider === 'baileys'
|
||||
);
|
||||
},
|
||||
chatAdditionalAttributes() {
|
||||
const { additional_attributes: additionalAttributes } = this.chat || {};
|
||||
return additionalAttributes || {};
|
||||
|
||||
@ -269,6 +269,14 @@ describe('inboxMixin', () => {
|
||||
expect(wrapper.vm.is360DialogWhatsAppChannel).toBe(true);
|
||||
});
|
||||
|
||||
it('isAWhatsAppBaileysChannel returns true if channel type is WhatsApp and provider is baileys', () => {
|
||||
const Component = getComponentConfigForInbox('Channel::Whatsapp', {
|
||||
provider: 'baileys',
|
||||
});
|
||||
const wrapper = shallowMount(Component);
|
||||
expect(wrapper.vm.isAWhatsAppBaileysChannel).toBe(true);
|
||||
});
|
||||
|
||||
it('isAWhatsAppChannel returns true if channel type is WhatsApp', () => {
|
||||
const Component = getComponentConfigForInbox('Channel::Whatsapp');
|
||||
const wrapper = shallowMount(Component);
|
||||
|
||||
@ -26,7 +26,7 @@ class BulkActionsJob < ApplicationJob
|
||||
records.each do |conversation|
|
||||
bulk_add_labels(conversation)
|
||||
bulk_snoozed_until(conversation)
|
||||
conversation.update(params) if params
|
||||
conversation.update!(params) if params
|
||||
end
|
||||
end
|
||||
|
||||
@ -54,7 +54,7 @@ class BulkActionsJob < ApplicationJob
|
||||
return unless @params[:labels] && @params[:labels][:remove]
|
||||
|
||||
labels = conversation.label_list - @params[:labels][:remove]
|
||||
conversation.update(label_list: labels)
|
||||
conversation.update!(label_list: labels)
|
||||
end
|
||||
|
||||
def records_to_updated(ids)
|
||||
|
||||
@ -0,0 +1,7 @@
|
||||
class Channels::Whatsapp::BaileysConnectionCheckJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
def perform(whatsapp_channel)
|
||||
whatsapp_channel.setup_channel_provider
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,11 @@
|
||||
class Channels::Whatsapp::BaileysConnectionCheckSchedulerJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
def perform
|
||||
Channel::Whatsapp.where(provider: 'baileys')
|
||||
.where("provider_connection->>'connection' = ?", 'open')
|
||||
.find_each do |channel|
|
||||
Channels::Whatsapp::BaileysConnectionCheckJob.perform_later(channel)
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -17,7 +17,7 @@ class Conversations::UserMentionJob < ApplicationJob
|
||||
account_id: account_id
|
||||
)
|
||||
else
|
||||
mention.update(mentioned_at: Time.zone.now)
|
||||
mention.update!(mentioned_at: Time.zone.now)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@ -4,16 +4,23 @@ class Internal::CheckNewVersionsJob < ApplicationJob
|
||||
def perform
|
||||
return unless Rails.env.production?
|
||||
|
||||
@instance_info = ChatwootHub.sync_with_hub
|
||||
update_version_info
|
||||
latest_version = fetch_latest_github_release
|
||||
::Redis::Alfred.set(::Redis::Alfred::LATEST_CHATWOOT_VERSION, latest_version) if latest_version.present?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def update_version_info
|
||||
return if @instance_info['version'].blank?
|
||||
def fetch_latest_github_release
|
||||
response = HTTParty.get('https://api.github.com/repos/fazer-ai/chatwoot/releases/latest', timeout: 5)
|
||||
unless response.success?
|
||||
Rails.logger.error "Failed to fetch latest GitHub release: HTTP #{response.code} - #{response.body}"
|
||||
return nil
|
||||
end
|
||||
|
||||
::Redis::Alfred.set(::Redis::Alfred::LATEST_CHATWOOT_VERSION, @instance_info['version'])
|
||||
response['tag_name']&.sub(/^v/, '')
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Failed to fetch latest GitHub release: #{e.message}"
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@ -20,6 +20,9 @@ class TriggerScheduledItemsJob < ApplicationJob
|
||||
# Job to sync whatsapp templates
|
||||
Channels::Whatsapp::TemplatesSyncSchedulerJob.perform_later
|
||||
|
||||
# Job to check WhatsApp connection status
|
||||
Channels::Whatsapp::BaileysConnectionCheckSchedulerJob.perform_later
|
||||
|
||||
# Job to clear notifications which are older than 1 month
|
||||
Notification::RemoveOldNotificationJob.perform_later
|
||||
end
|
||||
|
||||
@ -2,8 +2,7 @@ class Webhooks::WhatsappEventsJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
def perform(params = {})
|
||||
channel = find_channel_from_whatsapp_business_payload(params)
|
||||
|
||||
channel = find_channel(params)
|
||||
if channel_is_inactive?(channel)
|
||||
Rails.logger.warn("Inactive WhatsApp channel: #{channel&.phone_number || "unknown - #{params[:phone_number]}"}")
|
||||
return
|
||||
@ -12,6 +11,8 @@ class Webhooks::WhatsappEventsJob < ApplicationJob
|
||||
case channel.provider
|
||||
when 'whatsapp_cloud'
|
||||
Whatsapp::IncomingMessageWhatsappCloudService.new(inbox: channel.inbox, params: params).perform
|
||||
when 'baileys'
|
||||
Whatsapp::IncomingMessageBaileysService.new(inbox: channel.inbox, params: params).perform
|
||||
else
|
||||
Whatsapp::IncomingMessageService.new(inbox: channel.inbox, params: params).perform
|
||||
end
|
||||
@ -19,6 +20,14 @@ class Webhooks::WhatsappEventsJob < ApplicationJob
|
||||
|
||||
private
|
||||
|
||||
def find_channel(params)
|
||||
return find_channel_from_whatsapp_business_payload(params) if params[:object] == 'whatsapp_business_account'
|
||||
|
||||
return unless params[:phone_number]
|
||||
|
||||
Channel::Whatsapp.find_by(phone_number: params[:phone_number])
|
||||
end
|
||||
|
||||
def channel_is_inactive?(channel)
|
||||
return true if channel.blank?
|
||||
return true if channel.reauthorization_required?
|
||||
@ -27,26 +36,14 @@ class Webhooks::WhatsappEventsJob < ApplicationJob
|
||||
false
|
||||
end
|
||||
|
||||
def find_channel_by_url_param(params)
|
||||
return unless params[:phone_number]
|
||||
|
||||
Channel::Whatsapp.find_by(phone_number: params[:phone_number])
|
||||
end
|
||||
|
||||
def find_channel_from_whatsapp_business_payload(params)
|
||||
# for the case where facebook cloud api support multiple numbers for a single app
|
||||
# https://github.com/chatwoot/chatwoot/issues/4712#issuecomment-1173838350
|
||||
# we will give priority to the phone_number in the payload
|
||||
return get_channel_from_wb_payload(params) if params[:object] == 'whatsapp_business_account'
|
||||
|
||||
find_channel_by_url_param(params)
|
||||
end
|
||||
|
||||
def get_channel_from_wb_payload(wb_params)
|
||||
phone_number = "+#{wb_params[:entry].first[:changes].first.dig(:value, :metadata, :display_phone_number)}"
|
||||
phone_number_id = wb_params[:entry].first[:changes].first.dig(:value, :metadata, :phone_number_id)
|
||||
phone_number = "+#{params[:entry].first[:changes].first.dig(:value, :metadata, :display_phone_number)}"
|
||||
phone_number_id = params[:entry].first[:changes].first.dig(:value, :metadata, :phone_number_id)
|
||||
channel = Channel::Whatsapp.find_by(phone_number: phone_number)
|
||||
# validate to ensure the phone number id matches the whatsapp channel
|
||||
return channel if channel && channel.provider_config['phone_number_id'] == phone_number_id
|
||||
channel if channel && channel.provider_config['phone_number_id'] == phone_number_id
|
||||
end
|
||||
end
|
||||
|
||||
@ -53,8 +53,8 @@ class AgentBotListener < BaseListener
|
||||
private
|
||||
|
||||
def connected_agent_bot_exist?(inbox)
|
||||
return if inbox.agent_bot_inbox.blank?
|
||||
return unless inbox.agent_bot_inbox.active?
|
||||
return false if inbox.agent_bot_inbox.blank?
|
||||
return false unless inbox.agent_bot_inbox.active?
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
@ -57,7 +57,7 @@ class AutomationRuleListener < BaseListener
|
||||
end
|
||||
|
||||
def rule_present?(event_name, account)
|
||||
return if account.blank?
|
||||
return false if account.blank?
|
||||
|
||||
current_account_rules(event_name, account).any?
|
||||
end
|
||||
|
||||
57
app/listeners/channel_listener.rb
Normal file
57
app/listeners/channel_listener.rb
Normal file
@ -0,0 +1,57 @@
|
||||
class ChannelListener < BaseListener
|
||||
def conversation_typing_on(event)
|
||||
handle_typing_event(event)
|
||||
end
|
||||
|
||||
def conversation_recording(event)
|
||||
handle_typing_event(event)
|
||||
end
|
||||
|
||||
def conversation_typing_off(event)
|
||||
handle_typing_event(event)
|
||||
end
|
||||
|
||||
def conversation_unread(event)
|
||||
conversation = event.data[:conversation]
|
||||
channel = conversation.inbox.channel
|
||||
return unless channel.respond_to?(:unread_conversation)
|
||||
|
||||
channel.unread_conversation(conversation)
|
||||
end
|
||||
|
||||
def account_presence_updated(event)
|
||||
account_id, user_id, status = event.data.values_at(:account_id, :user_id, :status)
|
||||
account = Account.find(account_id)
|
||||
|
||||
account.inboxes.joins(:inbox_members).where(inbox_members: { user_id: user_id }).find_each do |inbox|
|
||||
next unless inbox.channel.respond_to?(:update_presence)
|
||||
|
||||
inbox.channel.update_presence(status)
|
||||
end
|
||||
end
|
||||
|
||||
def messages_read(event)
|
||||
conversation, last_seen_at = event.data.values_at(:conversation, :last_seen_at)
|
||||
|
||||
channel = conversation.inbox.channel
|
||||
return unless channel.respond_to?(:read_messages)
|
||||
|
||||
messages = conversation.messages.where(message_type: :incoming).where.not(status: :read)
|
||||
|
||||
messages = messages.where('updated_at > ?', last_seen_at) if last_seen_at.present?
|
||||
|
||||
channel.read_messages(messages, conversation: conversation) if messages.any?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def handle_typing_event(event)
|
||||
is_private, conversation = event.data.values_at(:is_private, :conversation)
|
||||
return if is_private
|
||||
|
||||
channel = conversation.inbox.channel
|
||||
return unless channel.respond_to?(:toggle_typing_status)
|
||||
|
||||
channel.toggle_typing_status(event.name, conversation: conversation)
|
||||
end
|
||||
end
|
||||
@ -110,6 +110,7 @@ class WebhookListener < BaseListener
|
||||
def deliver_account_webhooks(payload, account)
|
||||
account.webhooks.account_type.each do |webhook|
|
||||
next unless webhook.subscriptions.include?(payload[:event])
|
||||
next if payload[:inbox].present? && webhook.inbox_id.present? && webhook.inbox_id != payload[:inbox][:id]
|
||||
|
||||
WebhookJob.perform_later(webhook.url, payload)
|
||||
end
|
||||
|
||||
@ -19,7 +19,7 @@ class ApplicationMailer < ActionMailer::Base
|
||||
rescue_from(*ExceptionList::SMTP_EXCEPTIONS, with: :handle_smtp_exceptions)
|
||||
|
||||
def smtp_config_set_or_development?
|
||||
ENV.fetch('SMTP_ADDRESS', nil).present? || Rails.env.development?
|
||||
ENV.fetch('SMTP_ADDRESS', nil).present? || ENV.fetch('RESEND_API_KEY', nil).present? || Rails.env.development?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user