Merge branch 'main' into chore/merge-upstream-4.11.0
This commit is contained in:
commit
9a4c5058f3
45
.annotaterb.yml
Normal file
45
.annotaterb.yml
Normal file
@ -0,0 +1,45 @@
|
||||
additional_file_patterns: []
|
||||
routes: false
|
||||
models: true
|
||||
position_in_routes: before
|
||||
position_in_class: before
|
||||
position_in_test: before
|
||||
position_in_fixture: before
|
||||
position_in_factory: before
|
||||
position_in_serializer: before
|
||||
show_foreign_keys: true
|
||||
show_complete_foreign_keys: false
|
||||
show_indexes: true
|
||||
simple_indexes: false
|
||||
model_dir:
|
||||
- app/models
|
||||
- enterprise/app/models
|
||||
root_dir: ''
|
||||
include_version: false
|
||||
require: ''
|
||||
exclude_tests: true
|
||||
exclude_fixtures: true
|
||||
exclude_factories: true
|
||||
exclude_serializers: true
|
||||
exclude_scaffolds: true
|
||||
exclude_controllers: true
|
||||
exclude_helpers: true
|
||||
exclude_sti_subclasses: false
|
||||
ignore_model_sub_dir: false
|
||||
ignore_columns: null
|
||||
ignore_routes: null
|
||||
ignore_unknown_models: false
|
||||
hide_limit_column_types: integer,bigint,boolean
|
||||
hide_default_column_types: json,jsonb,hstore
|
||||
skip_on_db_migrate: false
|
||||
format_bare: true
|
||||
format_rdoc: false
|
||||
format_markdown: false
|
||||
sort: false
|
||||
force: false
|
||||
frozen: false
|
||||
classified_sort: true
|
||||
trace: false
|
||||
wrapper_open: null
|
||||
wrapper_close: null
|
||||
with_comment: true
|
||||
@ -277,3 +277,10 @@ AZURE_APP_SECRET=
|
||||
|
||||
# REDIS_ALFRED_SIZE=10
|
||||
# REDIS_VELMA_SIZE=10
|
||||
|
||||
# Baileys API Whatsapp provider
|
||||
BAILEYS_PROVIDER_DEFAULT_CLIENT_NAME=Chatwoot
|
||||
BAILEYS_PROVIDER_DEFAULT_URL=http://localhost:3025
|
||||
BAILEYS_PROVIDER_DEFAULT_API_KEY=
|
||||
|
||||
RESEND_API_KEY=
|
||||
|
||||
8
.github/copilot-instructions.md
vendored
Normal file
8
.github/copilot-instructions.md
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
# GitHub Copilot Instructions
|
||||
|
||||
- Always include pt-BR translations for any new text added to the project.
|
||||
- fazer.ai is always styled as-is, with a dot and lowercase letters. Never use Fazer.ai
|
||||
- Always check if adding specs is necessary when modifying code.
|
||||
- Evaluate if specs added are actually needed and not redundant. Specs should not be for documentation purposes only, they should cover expected behavior.
|
||||
- Always evaluate if frontend changes are needed when modifying backend code, and vice versa.
|
||||
- NEVER use `--` in `pnpm test -- <file>`. Just do `pnpm test <file>` directly
|
||||
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:
|
||||
|
||||
138
.github/workflows/publish_ee_github_docker.yml
vendored
Normal file
138
.github/workflows/publish_ee_github_docker.yml
vendored
Normal file
@ -0,0 +1,138 @@
|
||||
name: Publish Chatwoot Enterprise docker images to GitHub
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [released]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
GITHUB_REPO: ghcr.io/${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-22.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
env:
|
||||
GIT_REF: ${{ github.event.release.tag_name || github.ref_name }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.release.tag_name || github.ref }}
|
||||
|
||||
- name: Prepare
|
||||
run: |
|
||||
platform=${{ matrix.platform }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
|
||||
- name: Set Chatwoot edition
|
||||
run: |
|
||||
echo -en '\nENV CW_EDITION="ee"' >> docker/Dockerfile
|
||||
|
||||
- name: Update version in app.yml
|
||||
run: |
|
||||
if [[ "$GIT_REF" =~ ^v(.+)$ ]]; then
|
||||
VERSION="${BASH_REMATCH[1]}"
|
||||
echo "Updating version to: $VERSION"
|
||||
sed -i "s/version: '.*'/version: '$VERSION'/" config/app.yml
|
||||
else
|
||||
echo "No version tag found, keeping existing version"
|
||||
fi
|
||||
|
||||
- name: Set Docker Tags
|
||||
run: |
|
||||
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
|
||||
echo "SANITIZED_REF=${SANITIZED_REF}" >> $GITHUB_ENV
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push to GitHub Container Registry
|
||||
id: build-ghcr
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile
|
||||
platforms: ${{ matrix.platform }}
|
||||
push: ${{ github.event_name == 'release' || github.event_name == 'workflow_dispatch' }}
|
||||
tags: |
|
||||
${{ env.GITHUB_REPO }}:${{ env.SANITIZED_REF }}-ee
|
||||
${{ env.GITHUB_REPO }}:latest-ee
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p ${{ runner.temp }}/digests
|
||||
digest="${{ steps.build-ghcr.outputs.digest }}"
|
||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-${{ env.PLATFORM_PAIR }}
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- build
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: ${{ runner.temp }}/digests
|
||||
pattern: digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Create manifest list and push
|
||||
working-directory: ${{ runner.temp }}/digests
|
||||
env:
|
||||
GIT_REF: ${{ github.event.release.tag_name || github.ref_name }}
|
||||
run: |
|
||||
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
|
||||
docker buildx imagetools create \
|
||||
-t ghcr.io/${{ github.repository }}:${SANITIZED_REF}-ee \
|
||||
-t ghcr.io/${{ github.repository }}:latest-ee \
|
||||
$(printf 'ghcr.io/${{ github.repository }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect image
|
||||
env:
|
||||
GIT_REF: ${{ github.event.release.tag_name || github.ref_name }}
|
||||
run: |
|
||||
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
|
||||
REPO="ghcr.io/${{ github.repository }}"
|
||||
docker buildx imagetools inspect ${REPO}:${SANITIZED_REF}-ee
|
||||
docker buildx imagetools inspect ${REPO}:latest-ee
|
||||
139
.github/workflows/publish_github_docker.yml
vendored
Normal file
139
.github/workflows/publish_github_docker.yml
vendored
Normal file
@ -0,0 +1,139 @@
|
||||
name: Publish Chatwoot docker images to GitHub
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [released]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
GITHUB_REPO: ghcr.io/${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-22.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
env:
|
||||
GIT_REF: ${{ github.event.release.tag_name || github.ref_name }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.release.tag_name || github.ref }}
|
||||
|
||||
- name: Prepare
|
||||
run: |
|
||||
platform=${{ matrix.platform }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
|
||||
- name: Strip enterprise code
|
||||
run: |
|
||||
rm -rf enterprise
|
||||
rm -rf spec/enterprise
|
||||
|
||||
- name: Update version in app.yml
|
||||
run: |
|
||||
if [[ "$GIT_REF" =~ ^v(.+)$ ]]; then
|
||||
VERSION="${BASH_REMATCH[1]}"
|
||||
echo "Updating version to: $VERSION"
|
||||
sed -i "s/version: '.*'/version: '$VERSION'/" config/app.yml
|
||||
else
|
||||
echo "No version tag found, keeping existing version"
|
||||
fi
|
||||
|
||||
- name: Set Docker Tags
|
||||
run: |
|
||||
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
|
||||
echo "SANITIZED_REF=${SANITIZED_REF}" >> $GITHUB_ENV
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push to GitHub Container Registry
|
||||
id: build-ghcr
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile
|
||||
platforms: ${{ matrix.platform }}
|
||||
push: ${{ github.event_name == 'release' || github.event_name == 'workflow_dispatch' }}
|
||||
tags: |
|
||||
${{ env.GITHUB_REPO }}:${{ env.SANITIZED_REF }}
|
||||
${{ env.GITHUB_REPO }}:latest
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p ${{ runner.temp }}/digests
|
||||
digest="${{ steps.build-ghcr.outputs.digest }}"
|
||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-${{ env.PLATFORM_PAIR }}
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- build
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: ${{ runner.temp }}/digests
|
||||
pattern: digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Create manifest list and push
|
||||
working-directory: ${{ runner.temp }}/digests
|
||||
env:
|
||||
GIT_REF: ${{ github.event.release.tag_name || github.ref_name }}
|
||||
run: |
|
||||
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
|
||||
docker buildx imagetools create \
|
||||
-t ghcr.io/${{ github.repository }}:${SANITIZED_REF} \
|
||||
-t ghcr.io/${{ github.repository }}:latest \
|
||||
$(printf 'ghcr.io/${{ github.repository }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect image
|
||||
env:
|
||||
GIT_REF: ${{ github.event.release.tag_name || github.ref_name }}
|
||||
run: |
|
||||
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
|
||||
REPO="ghcr.io/${{ github.repository }}"
|
||||
docker buildx imagetools inspect ${REPO}:${SANITIZED_REF}
|
||||
docker buildx imagetools inspect ${REPO}:latest
|
||||
139
.github/workflows/publish_github_docker_beta.yml
vendored
Normal file
139
.github/workflows/publish_github_docker_beta.yml
vendored
Normal file
@ -0,0 +1,139 @@
|
||||
name: Publish Chatwoot beta docker images to GitHub
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [prereleased]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
GITHUB_REPO: ghcr.io/${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-22.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
env:
|
||||
GIT_REF: ${{ github.event.release.tag_name || github.ref_name }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.release.tag_name || github.ref }}
|
||||
|
||||
- name: Prepare
|
||||
run: |
|
||||
platform=${{ matrix.platform }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
|
||||
- name: Strip enterprise code
|
||||
run: |
|
||||
rm -rf enterprise
|
||||
rm -rf spec/enterprise
|
||||
|
||||
- name: Update version in app.yml
|
||||
run: |
|
||||
if [[ "$GIT_REF" =~ ^v(.+)$ ]]; then
|
||||
VERSION="${BASH_REMATCH[1]}"
|
||||
echo "Updating version to: $VERSION"
|
||||
sed -i "s/version: '.*'/version: '$VERSION'/" config/app.yml
|
||||
else
|
||||
echo "No version tag found, keeping existing version"
|
||||
fi
|
||||
|
||||
- name: Set Docker Tags
|
||||
run: |
|
||||
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
|
||||
echo "SANITIZED_REF=${SANITIZED_REF}" >> $GITHUB_ENV
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push to GitHub Container Registry
|
||||
id: build-ghcr
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile
|
||||
platforms: ${{ matrix.platform }}
|
||||
push: ${{ github.event_name == 'release' || github.event_name == 'workflow_dispatch' }}
|
||||
tags: |
|
||||
${{ env.GITHUB_REPO }}:${{ env.SANITIZED_REF }}
|
||||
${{ env.GITHUB_REPO }}:beta
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p ${{ runner.temp }}/digests
|
||||
digest="${{ steps.build-ghcr.outputs.digest }}"
|
||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-${{ env.PLATFORM_PAIR }}
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- build
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: ${{ runner.temp }}/digests
|
||||
pattern: digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Create manifest list and push
|
||||
working-directory: ${{ runner.temp }}/digests
|
||||
env:
|
||||
GIT_REF: ${{ github.event.release.tag_name || github.ref_name }}
|
||||
run: |
|
||||
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
|
||||
docker buildx imagetools create \
|
||||
-t ghcr.io/${{ github.repository }}:${SANITIZED_REF} \
|
||||
-t ghcr.io/${{ github.repository }}:beta \
|
||||
$(printf 'ghcr.io/${{ github.repository }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect image
|
||||
env:
|
||||
GIT_REF: ${{ github.event.release.tag_name || github.ref_name }}
|
||||
run: |
|
||||
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
|
||||
REPO="ghcr.io/${{ github.repository }}"
|
||||
docker buildx imagetools inspect ${REPO}:${SANITIZED_REF}
|
||||
docker buildx imagetools inspect ${REPO}:beta
|
||||
6
.github/workflows/run_foss_spec.yml
vendored
6
.github/workflows/run_foss_spec.yml
vendored
@ -3,10 +3,8 @@ permissions:
|
||||
contents: read
|
||||
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
@ -218,6 +218,7 @@ Style/OneClassPerFile:
|
||||
|
||||
AllCops:
|
||||
NewCops: enable
|
||||
SuggestExtensions: false
|
||||
Exclude:
|
||||
- 'bin/**/*'
|
||||
- 'db/schema.rb'
|
||||
@ -348,3 +349,12 @@ FactoryBot/RedundantFactoryOption:
|
||||
|
||||
FactoryBot/FactoryAssociationWithStrategy:
|
||||
Enabled: false
|
||||
|
||||
Rails/SaveBang:
|
||||
Enabled: true
|
||||
AllowedReceivers:
|
||||
- Stripe::Subscription
|
||||
- Stripe::Customer
|
||||
- Stripe::Invoice
|
||||
- Stripe::InvoiceItem
|
||||
- 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).
|
||||
2
Gemfile
2
Gemfile
@ -205,6 +205,8 @@ gem 'opentelemetry-exporter-otlp'
|
||||
|
||||
gem 'shopify_api'
|
||||
|
||||
gem 'resend', '~> 0.19.0'
|
||||
|
||||
### Gems required only in specific deployment environments ###
|
||||
##############################################################
|
||||
|
||||
|
||||
@ -749,6 +749,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)
|
||||
@ -1116,6 +1118,7 @@ DEPENDENCIES
|
||||
rails (~> 7.1)
|
||||
redis
|
||||
redis-namespace
|
||||
resend (~> 0.19.0)
|
||||
responders (>= 3.1.1)
|
||||
rest-client
|
||||
reverse_markdown
|
||||
|
||||
229
META-WEBHOOK-PROXY.md
Normal file
229
META-WEBHOOK-PROXY.md
Normal file
@ -0,0 +1,229 @@
|
||||
# Meta Webhook Proxy
|
||||
|
||||
## Problem
|
||||
|
||||
Some VPS providers silently drop inbound TCP connections from Meta's webhook servers (AS32934) due to overzealous DDoS protection. This causes 15–20% WhatsApp message loss. A reverse proxy on a clean provider (e.g., DigitalOcean) eliminates the drops completely.
|
||||
|
||||
```
|
||||
Meta (WhatsApp) → proxy.example.com (clean provider) → your Chatwoot instance
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
The proxy is a single nginx server that routes requests based on the first path segment:
|
||||
|
||||
```
|
||||
https://proxy.example.com/<upstream_host>/webhooks/whatsapp/%2B<phone>
|
||||
```
|
||||
|
||||
This is the URL you configure in Meta's App Dashboard as the webhook callback URL. The proxy extracts `<upstream_host>`, checks it against an allowlist, and forwards the request to `https://<upstream_host>/webhooks/whatsapp/%2B<phone>`.
|
||||
|
||||
### Multi-tenant
|
||||
|
||||
One proxy serves multiple Chatwoot instances. Each upstream is identified by its domain in the URL path — no separate config per tenant beyond adding the host to the allowlist.
|
||||
|
||||
## Setup
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- A server (Ubuntu 22.04/24.04) on a provider with clean Meta connectivity (DigitalOcean, AWS, etc.)
|
||||
- A DNS A record pointing your proxy domain to the server IP (e.g., `proxy.example.com → 1.2.3.4`)
|
||||
- SSH root access to the server
|
||||
|
||||
### 1. Install nginx and certbot
|
||||
|
||||
```bash
|
||||
ssh root@proxy.example.com
|
||||
|
||||
apt-get update
|
||||
apt-get install -y nginx certbot python3-certbot-nginx
|
||||
```
|
||||
|
||||
### 2. Create a temporary HTTP-only config
|
||||
|
||||
Certbot needs nginx running to perform the ACME challenge, but the full config references SSL certs that don't exist yet. Start with an HTTP-only config:
|
||||
|
||||
```bash
|
||||
cat > /etc/nginx/sites-available/cw-proxy << 'EOF'
|
||||
server {
|
||||
listen 80;
|
||||
server_name proxy.example.com;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/html;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 404;
|
||||
}
|
||||
}
|
||||
EOF
|
||||
```
|
||||
|
||||
Enable the site and reload:
|
||||
|
||||
```bash
|
||||
rm -f /etc/nginx/sites-enabled/default
|
||||
ln -sf /etc/nginx/sites-available/cw-proxy /etc/nginx/sites-enabled/cw-proxy
|
||||
nginx -t && systemctl reload nginx
|
||||
```
|
||||
|
||||
### 3. Obtain the SSL certificate
|
||||
|
||||
```bash
|
||||
certbot certonly --webroot -w /var/www/html -d proxy.example.com \
|
||||
--non-interactive --agree-tos -m your-email@example.com
|
||||
```
|
||||
|
||||
Certbot installs a systemd timer that auto-renews the certificate before it expires.
|
||||
|
||||
### 4. Deploy the full proxy config
|
||||
|
||||
Replace the temporary config with the full proxy configuration:
|
||||
|
||||
```bash
|
||||
cat > /etc/nginx/sites-available/cw-proxy << 'EOF'
|
||||
# Allowlist of upstream Chatwoot hosts
|
||||
# Add new hosts here to enable proxying
|
||||
map $upstream_host $upstream_allowed {
|
||||
default 0;
|
||||
chatwoot.example.com 1;
|
||||
# chatwoot.other.com 1;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name proxy.example.com;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/html;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name proxy.example.com;
|
||||
|
||||
resolver 1.1.1.1 8.8.8.8 valid=300s;
|
||||
resolver_timeout 5s;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/proxy.example.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/proxy.example.com/privkey.pem;
|
||||
|
||||
# Extract upstream host from first path segment, proxy the rest
|
||||
location ~ ^/([^/]+)(/.*)$ {
|
||||
set $upstream_host $1;
|
||||
set $upstream_path $2;
|
||||
|
||||
# Reject hosts not in the allowlist
|
||||
if ($upstream_allowed = 0) {
|
||||
return 403;
|
||||
}
|
||||
|
||||
proxy_pass https://$upstream_host$upstream_path$is_args$args;
|
||||
proxy_set_header Host $upstream_host;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_ssl_server_name on;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 404;
|
||||
}
|
||||
}
|
||||
EOF
|
||||
```
|
||||
|
||||
Test and reload:
|
||||
|
||||
```bash
|
||||
nginx -t && systemctl reload nginx
|
||||
```
|
||||
|
||||
### 5. Verify
|
||||
|
||||
```bash
|
||||
# Root path → 404
|
||||
curl -s -o /dev/null -w "%{http_code}" https://proxy.example.com/
|
||||
# Expected: 404
|
||||
|
||||
# Unknown host → 403
|
||||
curl -s -o /dev/null -w "%{http_code}" https://proxy.example.com/unknown.host/webhooks/whatsapp/test
|
||||
# Expected: 403
|
||||
|
||||
# Allowed host → proxied (502 if upstream is unreachable, 200 if live)
|
||||
curl -s -o /dev/null -w "%{http_code}" https://proxy.example.com/chatwoot.example.com/webhooks/whatsapp/test
|
||||
# Expected: 502 or 200
|
||||
```
|
||||
|
||||
### 6. Configure Meta webhook URL
|
||||
|
||||
In the Meta App Dashboard, set the webhook callback URL to:
|
||||
|
||||
```
|
||||
https://proxy.example.com/<your-chatwoot-domain>/webhooks/whatsapp/%2B<phone>
|
||||
```
|
||||
|
||||
For example, if your Chatwoot is at `chatwoot.example.com` and the phone number is `+5511999999999`:
|
||||
|
||||
```
|
||||
https://proxy.example.com/chatwoot.example.com/webhooks/whatsapp/%2B5511999999999
|
||||
```
|
||||
|
||||
Meta will send both verification (GET) and delivery (POST) requests to this URL. The proxy passes them through transparently.
|
||||
|
||||
## Adding a new upstream
|
||||
|
||||
1. SSH into the proxy server
|
||||
2. Edit `/etc/nginx/sites-available/cw-proxy`
|
||||
3. Add the new host to the `map` block:
|
||||
```nginx
|
||||
map $upstream_host $upstream_allowed {
|
||||
default 0;
|
||||
chatwoot.example.com 1;
|
||||
chatwoot.newclient.com 1; # ← add this line
|
||||
}
|
||||
```
|
||||
4. Test and reload:
|
||||
```bash
|
||||
nginx -t && systemctl reload nginx
|
||||
```
|
||||
5. Set the Meta webhook callback URL for the new instance to:
|
||||
```
|
||||
https://proxy.example.com/chatwoot.newclient.com/webhooks/whatsapp/%2B<phone>
|
||||
```
|
||||
|
||||
## Removing an upstream
|
||||
|
||||
1. Remove or comment out the host from the `map` block
|
||||
2. `nginx -t && systemctl reload nginx`
|
||||
3. Update the Meta webhook callback URL to point directly at the Chatwoot instance (or to a different proxy)
|
||||
|
||||
## Key nginx directives
|
||||
|
||||
| Directive | Purpose |
|
||||
|-----------|---------|
|
||||
| `map $upstream_host $upstream_allowed` | Allowlist of permitted upstream hosts. Only hosts set to `1` are proxied; all others get 403. |
|
||||
| `proxy_ssl_server_name on` | Enables SNI so the TLS handshake uses the correct hostname for the upstream's certificate. |
|
||||
| `resolver 1.1.1.1 8.8.8.8 valid=300s` | Required because `proxy_pass` uses a variable (`$upstream_host`), so nginx cannot resolve DNS at config load time. Uses Cloudflare and Google DNS. |
|
||||
| `proxy_set_header Host $upstream_host` | Sets the Host header to the upstream domain so reverse proxies (Traefik, etc.) route correctly. |
|
||||
|
||||
## Failure modes
|
||||
|
||||
All recoverable — Meta retries with exponential backoff for up to 36 hours:
|
||||
|
||||
| Failure | What happens | Recovery |
|
||||
|---------|-------------|----------|
|
||||
| Proxy down | Connection refused | Meta retries |
|
||||
| Upstream down | 502 Bad Gateway | Meta retries |
|
||||
| SSL expired | TLS handshake error | Meta retries |
|
||||
|
||||
## Important notes
|
||||
|
||||
- **Do not rate-limit.** Meta sends webhook deliveries from many IPs in AS32934. Bursts of 10+ requests per second are normal.
|
||||
- **SSL auto-renewal** is handled by the certbot systemd timer. Verify with `systemctl status certbot.timer`.
|
||||
- The `%2B` in the URL is the URL-encoded `+` sign for the phone number's country code.
|
||||
@ -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?
|
||||
|
||||
|
||||
@ -10,7 +10,7 @@ class ConversationBuilder
|
||||
def look_up_exising_conversation
|
||||
return unless @contact_inbox.inbox.lock_to_single_conversation?
|
||||
|
||||
@contact_inbox.conversations.last
|
||||
@contact_inbox.inbox.conversations.where(contact_id: @contact_inbox.contact_id).last
|
||||
end
|
||||
|
||||
def create_new_conversation
|
||||
|
||||
@ -183,7 +183,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
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
class Messages::MessageBuilder
|
||||
class Messages::MessageBuilder # rubocop:disable Metrics/ClassLength
|
||||
include ::FileTypeHelper
|
||||
include ::EmailHelper
|
||||
include ::DataHelper
|
||||
|
||||
attr_reader :message
|
||||
|
||||
def initialize(user, conversation, params)
|
||||
def initialize(user, conversation, params) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
|
||||
@params = params
|
||||
@private = params[:private] || false
|
||||
@conversation = conversation
|
||||
@ -13,11 +13,15 @@ class Messages::MessageBuilder
|
||||
@account = conversation.account
|
||||
@message_type = params[:message_type] || 'outgoing'
|
||||
@attachments = params[:attachments]
|
||||
@is_recorded_audio = params[:is_recorded_audio]
|
||||
@attachments_metadata = normalize_attachments_metadata(params[:attachments_metadata])
|
||||
@automation_rule = content_attributes&.dig(:automation_rule_id)
|
||||
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)
|
||||
@zapi_args = content_attributes&.dig(:zapi_args)
|
||||
end
|
||||
|
||||
def perform
|
||||
@ -55,7 +59,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
|
||||
@ -66,6 +70,46 @@ class Messages::MessageBuilder
|
||||
end
|
||||
end
|
||||
|
||||
def process_metadata(attachment)
|
||||
meta = {}
|
||||
meta.merge!(recorded_audio_metadata(attachment) || {})
|
||||
meta.merge!(custom_attachment_metadata(attachment) || {})
|
||||
meta.presence
|
||||
end
|
||||
|
||||
def recorded_audio_metadata(attachment) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
|
||||
# NOTE: `is_recorded_audio` can be either a boolean or an array of file names.
|
||||
return unless @is_recorded_audio
|
||||
return { is_recorded_audio: true } if @is_recorded_audio == true
|
||||
|
||||
return { is_recorded_audio: true } if @is_recorded_audio.is_a?(Array) && attachment.original_filename.in?(@is_recorded_audio)
|
||||
|
||||
# FIXME: Remove backwards compatibility with old format.
|
||||
if @is_recorded_audio.is_a?(String)
|
||||
parsed = JSON.parse(@is_recorded_audio)
|
||||
{ is_recorded_audio: true } if parsed.is_a?(Array) && attachment.original_filename.in?(parsed)
|
||||
end
|
||||
rescue JSON::ParserError
|
||||
nil
|
||||
end
|
||||
|
||||
def custom_attachment_metadata(attachment)
|
||||
return unless @attachments_metadata.is_a?(Hash)
|
||||
|
||||
filename = attachment.respond_to?(:original_filename) ? attachment.original_filename : nil
|
||||
return unless filename
|
||||
|
||||
metadata = @attachments_metadata[filename]
|
||||
metadata.to_h if metadata.present?
|
||||
end
|
||||
|
||||
def normalize_attachments_metadata(metadata)
|
||||
return if metadata.blank?
|
||||
|
||||
metadata = metadata.to_unsafe_h if metadata.respond_to?(:to_unsafe_h)
|
||||
metadata.deep_stringify_keys
|
||||
end
|
||||
|
||||
def process_emails
|
||||
return unless @conversation.inbox&.inbox_type == 'Email'
|
||||
|
||||
@ -123,12 +167,32 @@ class Messages::MessageBuilder
|
||||
@params[:template_params].present? ? { additional_attributes: { template_params: JSON.parse(@params[:template_params].to_json) } } : {}
|
||||
end
|
||||
|
||||
def scheduled_message_metadata
|
||||
return {} if @params[:scheduled_message].blank?
|
||||
|
||||
sm = @params[:scheduled_message]
|
||||
scheduled_by = { 'id' => sm.author_id, 'type' => sm.author_type }
|
||||
scheduled_by['name'] = sm.author.name if sm.author.respond_to?(:name)
|
||||
|
||||
{
|
||||
additional_attributes: {
|
||||
scheduled_message_id: sm.id,
|
||||
scheduled_by: scheduled_by,
|
||||
scheduled_at: sm.updated_at.to_i
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def message_sender
|
||||
return if @params[:sender_type] != 'AgentBot'
|
||||
|
||||
AgentBot.where(account_id: [nil, @conversation.account.id]).find_by(id: @params[:sender_id])
|
||||
end
|
||||
|
||||
def zapi_args
|
||||
@zapi_args.present? ? { zapi_args: @zapi_args } : {}
|
||||
end
|
||||
|
||||
def message_params
|
||||
{
|
||||
account_id: @conversation.account_id,
|
||||
@ -141,9 +205,11 @@ class Messages::MessageBuilder
|
||||
content_attributes: content_attributes.presence,
|
||||
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)
|
||||
}.merge(external_created_at).merge(automation_rule_id).merge(campaign_id)
|
||||
.deep_merge(template_params).merge(zapi_args).deep_merge(scheduled_message_metadata)
|
||||
end
|
||||
|
||||
def email_inbox?
|
||||
|
||||
@ -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
|
||||
|
||||
@ -0,0 +1,34 @@
|
||||
class Api::V1::Accounts::Conversations::AttachmentsController < Api::V1::Accounts::Conversations::BaseController
|
||||
before_action :set_message
|
||||
before_action :set_attachment
|
||||
before_action :validate_meta_size, only: [:update]
|
||||
|
||||
MAX_META_SIZE = 16.kilobytes
|
||||
|
||||
def update
|
||||
@attachment.update!(permitted_params)
|
||||
@attachment.message.send_update_event
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_message
|
||||
@message = @conversation.messages.find(params[:message_id])
|
||||
end
|
||||
|
||||
def set_attachment
|
||||
@attachment = @message.attachments.find(params[:id])
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(meta: {})
|
||||
end
|
||||
|
||||
def validate_meta_size
|
||||
return if params[:meta].blank?
|
||||
|
||||
return unless params[:meta].to_json.bytesize > MAX_META_SIZE
|
||||
|
||||
render json: { error: "Metadata size exceeds maximum allowed (#{MAX_META_SIZE / 1024}KB)" }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
@ -1,4 +1,6 @@
|
||||
class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::Conversations::BaseController
|
||||
include Events::Types
|
||||
|
||||
before_action :ensure_api_inbox, only: :update
|
||||
|
||||
def index
|
||||
@ -9,6 +11,8 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
|
||||
user = Current.user || @resource
|
||||
mb = Messages::MessageBuilder.new(user, @conversation, params)
|
||||
@message = mb.perform
|
||||
|
||||
trigger_typing_event(CONVERSATION_TYPING_OFF)
|
||||
rescue StandardError => e
|
||||
render_could_not_create_error(e.message)
|
||||
end
|
||||
@ -23,6 +27,7 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
|
||||
message.update!(content: I18n.t('conversations.messages.deleted'), content_type: :text, content_attributes: { deleted: true })
|
||||
message.attachments.destroy_all
|
||||
end
|
||||
delete_message_on_channel
|
||||
end
|
||||
|
||||
def retry
|
||||
@ -54,6 +59,22 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
|
||||
render json: { content: translated_content }
|
||||
end
|
||||
|
||||
def edit_content
|
||||
new_content = params[:content]
|
||||
return render json: { error: 'Content is required' }, status: :unprocessable_entity if new_content.blank?
|
||||
return render json: { error: 'Content exceeds maximum length' }, status: :unprocessable_entity if new_content.length > 150_000
|
||||
return render json: { error: 'Only outgoing messages can be edited' }, status: :forbidden unless message.outgoing?
|
||||
|
||||
original_content = message.content
|
||||
# Only save previous_content on first edit to preserve the original message
|
||||
previous_content_to_save = message.is_edited ? message.previous_content : original_content
|
||||
message.update!(content: new_content, is_edited: true, previous_content: previous_content_to_save)
|
||||
|
||||
edit_message_on_channel(new_content, original_content)
|
||||
|
||||
@message = message.reload
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def message
|
||||
@ -65,16 +86,48 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:id, :target_language, :status, :external_error)
|
||||
params.permit(:id, :target_language, :status, :external_error, :content)
|
||||
end
|
||||
|
||||
def already_translated_content_available?
|
||||
message.translations.present? && message.translations[permitted_params[:target_language]].present?
|
||||
end
|
||||
|
||||
def delete_message_on_channel
|
||||
return unless @conversation.inbox.channel.respond_to?(:delete_message)
|
||||
return if message.source_id.blank?
|
||||
|
||||
@conversation.inbox.channel.delete_message(message, conversation: @conversation)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Failed to delete message on channel: #{e.message}"
|
||||
end
|
||||
|
||||
def edit_message_on_channel(new_content, original_content)
|
||||
return unless @conversation.inbox.channel.respond_to?(:edit_message)
|
||||
return if message.source_id.blank?
|
||||
|
||||
@conversation.inbox.channel.edit_message(message, new_content, conversation: @conversation)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Failed to edit message on channel: #{e.message}"
|
||||
was_already_edited = message.previous_content != original_content
|
||||
if was_already_edited
|
||||
message.update!(content: original_content)
|
||||
else
|
||||
message.update!(content: original_content, is_edited: false, previous_content: nil)
|
||||
end
|
||||
raise e
|
||||
end
|
||||
|
||||
# API inbox check
|
||||
def ensure_api_inbox
|
||||
# Only API inboxes can update messages
|
||||
render json: { error: 'Message status update is only allowed for API inboxes' }, status: :forbidden unless @conversation.inbox.api?
|
||||
end
|
||||
|
||||
def trigger_typing_event(event)
|
||||
user = Current.user || @resource
|
||||
return unless user.is_a?(User)
|
||||
|
||||
Rails.configuration.dispatcher.dispatch(event, Time.zone.now, conversation: @conversation, user: user, is_private: params[:private])
|
||||
end
|
||||
end
|
||||
|
||||
@ -0,0 +1,67 @@
|
||||
class Api::V1::Accounts::Conversations::ScheduledMessagesController < Api::V1::Accounts::Conversations::BaseController
|
||||
include Events::Types
|
||||
|
||||
before_action :scheduled_message, only: [:update, :destroy]
|
||||
|
||||
MAX_LIMIT = 100
|
||||
|
||||
def index
|
||||
authorize build_scheduled_message
|
||||
@scheduled_messages = @conversation.scheduled_messages
|
||||
.order(scheduled_at: :desc)
|
||||
.limit(MAX_LIMIT)
|
||||
end
|
||||
|
||||
def create
|
||||
@scheduled_message = build_scheduled_message
|
||||
authorize @scheduled_message
|
||||
@scheduled_message.assign_attributes(scheduled_message_params)
|
||||
@scheduled_message.save!
|
||||
dispatch_event(SCHEDULED_MESSAGE_CREATED, scheduled_message: @scheduled_message)
|
||||
end
|
||||
|
||||
def update
|
||||
@scheduled_message.assign_attributes(scheduled_message_params)
|
||||
@scheduled_message.save!
|
||||
dispatch_event(SCHEDULED_MESSAGE_UPDATED, scheduled_message: @scheduled_message)
|
||||
end
|
||||
|
||||
def destroy
|
||||
if @scheduled_message.sent? || @scheduled_message.failed?
|
||||
return render json: { error: I18n.t('errors.scheduled_messages.cannot_delete_processed') }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
scheduled_message = @scheduled_message
|
||||
scheduled_message.destroy!
|
||||
dispatch_event(SCHEDULED_MESSAGE_DELETED, scheduled_message: scheduled_message)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def scheduled_message
|
||||
@scheduled_message ||= @conversation.scheduled_messages.find(params[:id])
|
||||
authorize @scheduled_message
|
||||
end
|
||||
|
||||
def build_scheduled_message
|
||||
@conversation.scheduled_messages.new(account: Current.account, inbox: @conversation.inbox, author: Current.user)
|
||||
end
|
||||
|
||||
def scheduled_message_params
|
||||
params.permit(
|
||||
:content,
|
||||
:scheduled_at,
|
||||
:status,
|
||||
:attachment,
|
||||
template_params: {}
|
||||
)
|
||||
end
|
||||
|
||||
def dispatch_event(event_name, data)
|
||||
Rails.configuration.dispatcher.dispatch(event_name, Time.zone.now, data)
|
||||
end
|
||||
end
|
||||
|
||||
Api::V1::Accounts::Conversations::ScheduledMessagesController.prepend_mod_with(
|
||||
'Api::V1::Accounts::Conversations::ScheduledMessagesController'
|
||||
)
|
||||
@ -1,4 +1,4 @@
|
||||
class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseController
|
||||
class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseController # rubocop:disable Metrics/ClassLength
|
||||
include Events::Types
|
||||
include DateRangeHelper
|
||||
include HmacConcern
|
||||
@ -122,10 +122,14 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||
# No unread messages - apply throttling to limit DB writes
|
||||
return unless should_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)
|
||||
@ -231,6 +235,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')
|
||||
|
||||
@ -34,6 +34,7 @@ class Api::V1::Accounts::DashboardAppsController < Api::V1::Accounts::BaseContro
|
||||
def permitted_payload
|
||||
params.require(:dashboard_app).permit(
|
||||
:title,
|
||||
:show_on_sidebar,
|
||||
content: [:url, :type]
|
||||
)
|
||||
end
|
||||
|
||||
@ -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, :health]
|
||||
before_action :check_authorization, except: [:show, :health, :setup_channel_provider]
|
||||
before_action :validate_whatsapp_cloud_channel, only: [:health]
|
||||
|
||||
def index
|
||||
@ -65,6 +65,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') }
|
||||
@ -87,6 +111,20 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
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,10 +22,14 @@ class Api::V1::Accounts::WebhooksController < Api::V1::Accounts::BaseController
|
||||
|
||||
private
|
||||
|
||||
def webhook_params
|
||||
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
|
||||
@webhook = Current.account.webhooks.find(params[:id])
|
||||
end
|
||||
|
||||
@ -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
|
||||
|
||||
@ -47,6 +47,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
|
||||
@ -91,7 +93,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
|
||||
|
||||
@ -18,7 +18,7 @@ class Api::V2::Accounts::YearInReviewsController < Api::V1::Accounts::BaseContro
|
||||
|
||||
ui_settings = Current.user.ui_settings || {}
|
||||
ui_settings[cache_key] = data
|
||||
Current.user.update(ui_settings: ui_settings)
|
||||
Current.user.update!(ui_settings: ui_settings)
|
||||
|
||||
render json: data
|
||||
end
|
||||
|
||||
@ -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
|
||||
|
||||
@ -6,7 +6,7 @@ module AttachmentConcern
|
||||
return [blobs, actions, nil] if actions.blank?
|
||||
|
||||
sanitized = actions.map do |action|
|
||||
next action unless action[:action_name] == 'send_attachment'
|
||||
next action unless attachment_action?(action)
|
||||
|
||||
result = process_attachment_action(action, record, blobs)
|
||||
return [nil, nil, I18n.t('errors.attachments.invalid')] unless result
|
||||
@ -20,15 +20,39 @@ module AttachmentConcern
|
||||
private
|
||||
|
||||
def process_attachment_action(action, record, blobs)
|
||||
blob_id = action[:action_params].first
|
||||
blob_id = attachment_blob_id(action)
|
||||
return action if action[:action_name] == 'create_scheduled_message' && blob_id.blank?
|
||||
|
||||
blob = ActiveStorage::Blob.find_signed(blob_id.to_s)
|
||||
|
||||
return action.merge(action_params: [blob.id]).tap { blobs << blob } if blob.present?
|
||||
return action.merge(action_params: attachment_action_params(action, blob.id)).tap { blobs << blob } if blob.present?
|
||||
return action if blob_already_attached?(record, blob_id)
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
def attachment_action?(action)
|
||||
%w[send_attachment create_scheduled_message].include?(action[:action_name])
|
||||
end
|
||||
|
||||
def attachment_blob_id(action)
|
||||
return action[:action_params].first unless action[:action_name] == 'create_scheduled_message'
|
||||
|
||||
params = action[:action_params].first
|
||||
params = params.to_unsafe_h if params.respond_to?(:to_unsafe_h)
|
||||
params&.with_indifferent_access&.dig(:blob_id)
|
||||
end
|
||||
|
||||
def attachment_action_params(action, blob_id)
|
||||
return [blob_id] unless action[:action_name] == 'create_scheduled_message'
|
||||
|
||||
params = action[:action_params].first
|
||||
params = params.to_unsafe_h if params.respond_to?(:to_unsafe_h)
|
||||
params = params.with_indifferent_access
|
||||
params[:blob_id] = blob_id
|
||||
[params]
|
||||
end
|
||||
|
||||
def blob_already_attached?(record, blob_id)
|
||||
record&.files&.any? { |f| f.blob_id == blob_id.to_i }
|
||||
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
|
||||
|
||||
@ -19,7 +19,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
|
||||
errors.concat(i.errors.full_messages) unless 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
|
||||
|
||||
52
app/helpers/baileys_helper.rb
Normal file
52
app/helpers/baileys_helper.rb
Normal file
@ -0,0 +1,52 @@
|
||||
module BaileysHelper
|
||||
CHANNEL_LOCK_ON_OUTGOING_MESSAGE_KEY = 'BAILEYS::CHANNEL_LOCK_ON_OUTGOING_MESSAGE::%<channel_id>s'.freeze
|
||||
CHANNEL_LOCK_ON_OUTGOING_MESSAGE_TIMEOUT = 60.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
|
||||
lock_acquired = false
|
||||
|
||||
# NOTE: On timeout, we log a warning and proceed with the block execution.
|
||||
# The re-check inside the contact lock handles potential duplicates.
|
||||
while (Time.now.to_i - start_time) < timeout
|
||||
if baileys_lock_channel_on_outgoing_message(channel_id, timeout)
|
||||
lock_acquired = true
|
||||
break
|
||||
end
|
||||
|
||||
sleep(0.1)
|
||||
end
|
||||
|
||||
Rails.logger.warn "Baileys channel lock timeout for channel #{channel_id} after #{timeout}s - proceeding anyway" unless lock_acquired
|
||||
|
||||
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,
|
||||
@ -86,6 +92,13 @@ class MessageApi extends ApiClient {
|
||||
return axios.delete(`${this.url}/${conversationID}/messages/${messageId}`);
|
||||
}
|
||||
|
||||
editContent(conversationID, messageId, content) {
|
||||
return axios.patch(
|
||||
`${this.url}/${conversationID}/messages/${messageId}/edit_content`,
|
||||
{ content }
|
||||
);
|
||||
}
|
||||
|
||||
retry(conversationID, messageId) {
|
||||
return axios.post(
|
||||
`${this.url}/${conversationID}/messages/${messageId}/retry`
|
||||
|
||||
@ -42,6 +42,14 @@ class Inboxes extends CacheEnabledApiClient {
|
||||
getCSATTemplateStatus(inboxId) {
|
||||
return axios.get(`${this.url}/${inboxId}/csat_template`);
|
||||
}
|
||||
|
||||
setupChannelProvider(inboxId) {
|
||||
return axios.post(`${this.url}/${inboxId}/setup_channel_provider`);
|
||||
}
|
||||
|
||||
disconnectChannelProvider(inboxId) {
|
||||
return axios.post(`${this.url}/${inboxId}/disconnect_channel_provider`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new Inboxes();
|
||||
|
||||
66
app/javascript/dashboard/api/scheduledMessages.js
Normal file
66
app/javascript/dashboard/api/scheduledMessages.js
Normal file
@ -0,0 +1,66 @@
|
||||
/* global axios */
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
export const buildScheduledMessagePayload = ({
|
||||
content,
|
||||
status,
|
||||
scheduledAt,
|
||||
templateParams,
|
||||
attachment,
|
||||
} = {}) => {
|
||||
if (!attachment) {
|
||||
return {
|
||||
content,
|
||||
status,
|
||||
scheduled_at: scheduledAt,
|
||||
template_params: templateParams,
|
||||
};
|
||||
}
|
||||
|
||||
const payload = new FormData();
|
||||
if (content) payload.append('content', content);
|
||||
if (scheduledAt) payload.append('scheduled_at', scheduledAt);
|
||||
if (status) payload.append('status', status);
|
||||
payload.append('attachment', attachment);
|
||||
if (templateParams) {
|
||||
payload.append('template_params', JSON.stringify(templateParams));
|
||||
}
|
||||
|
||||
return payload;
|
||||
};
|
||||
|
||||
class ScheduledMessagesAPI extends ApiClient {
|
||||
constructor() {
|
||||
super('conversations', { accountScoped: true });
|
||||
}
|
||||
|
||||
get(conversationId) {
|
||||
return axios.get(
|
||||
`${this.baseUrl()}/conversations/${conversationId}/scheduled_messages`
|
||||
);
|
||||
}
|
||||
|
||||
create(conversationId, payload) {
|
||||
return axios({
|
||||
method: 'post',
|
||||
url: `${this.baseUrl()}/conversations/${conversationId}/scheduled_messages`,
|
||||
data: buildScheduledMessagePayload(payload),
|
||||
});
|
||||
}
|
||||
|
||||
update(conversationId, scheduledMessageId, payload) {
|
||||
return axios({
|
||||
method: 'patch',
|
||||
url: `${this.baseUrl()}/conversations/${conversationId}/scheduled_messages/${scheduledMessageId}`,
|
||||
data: buildScheduledMessagePayload(payload),
|
||||
});
|
||||
}
|
||||
|
||||
delete(conversationId, scheduledMessageId) {
|
||||
return axios.delete(
|
||||
`${this.baseUrl()}/conversations/${conversationId}/scheduled_messages/${scheduledMessageId}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default new ScheduledMessagesAPI();
|
||||
77
app/javascript/dashboard/api/specs/scheduledMessages.spec.js
Normal file
77
app/javascript/dashboard/api/specs/scheduledMessages.spec.js
Normal file
@ -0,0 +1,77 @@
|
||||
import ScheduledMessagesAPI, {
|
||||
buildScheduledMessagePayload,
|
||||
} from '../scheduledMessages';
|
||||
|
||||
describe('#ScheduledMessagesAPI', () => {
|
||||
describe('#buildScheduledMessagePayload', () => {
|
||||
it('builds object payload without attachment or FormData with attachment', () => {
|
||||
const objectPayload = buildScheduledMessagePayload({
|
||||
content: 'Hello',
|
||||
scheduledAt: '2025-01-01T10:00:00Z',
|
||||
status: 'pending',
|
||||
});
|
||||
|
||||
expect(objectPayload).toEqual({
|
||||
content: 'Hello',
|
||||
scheduled_at: '2025-01-01T10:00:00Z',
|
||||
status: 'pending',
|
||||
private: undefined,
|
||||
template_params: undefined,
|
||||
content_attributes: undefined,
|
||||
additional_attributes: undefined,
|
||||
});
|
||||
|
||||
const formPayload = buildScheduledMessagePayload({
|
||||
content: 'Hello',
|
||||
attachment: new Blob(['test'], { type: 'text/plain' }),
|
||||
});
|
||||
|
||||
expect(formPayload).toBeInstanceOf(FormData);
|
||||
expect(formPayload.get('content')).toEqual('Hello');
|
||||
});
|
||||
});
|
||||
|
||||
describe('API calls', () => {
|
||||
const originalAxios = window.axios;
|
||||
const originalPathname = window.location.pathname;
|
||||
const axiosMock = Object.assign(
|
||||
vi.fn(() => Promise.resolve()),
|
||||
{ delete: vi.fn(() => Promise.resolve()) }
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
axiosMock.mockClear();
|
||||
axiosMock.delete.mockClear();
|
||||
window.axios = axiosMock;
|
||||
window.history.pushState({}, '', '/app/accounts/1/inbox');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.axios = originalAxios;
|
||||
window.history.pushState({}, '', originalPathname);
|
||||
});
|
||||
|
||||
it('calls correct endpoints for create, update, and delete', () => {
|
||||
ScheduledMessagesAPI.create(12, { content: 'Hello' });
|
||||
expect(axiosMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: 'post',
|
||||
url: '/api/v1/accounts/1/conversations/12/scheduled_messages',
|
||||
})
|
||||
);
|
||||
|
||||
ScheduledMessagesAPI.update(12, 7, { status: 'pending' });
|
||||
expect(axiosMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: 'patch',
|
||||
url: '/api/v1/accounts/1/conversations/12/scheduled_messages/7',
|
||||
})
|
||||
);
|
||||
|
||||
ScheduledMessagesAPI.delete(12, 7);
|
||||
expect(axiosMock.delete).toHaveBeenCalledWith(
|
||||
'/api/v1/accounts/1/conversations/12/scheduled_messages/7'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
9
app/javascript/dashboard/assets/images/curved-arrow.svg
Normal file
9
app/javascript/dashboard/assets/images/curved-arrow.svg
Normal file
@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg fill="#2781F6"
|
||||
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 302.816 302.816">
|
||||
<path d="M298.423,152.996c-5.857-5.858-15.354-5.858-21.213,0l-35.137,35.136
|
||||
c-5.871-59.78-50.15-111.403-112.001-123.706c-45.526-9.055-92.479,5.005-125.596,37.612c-5.903,5.813-5.977,15.31-0.165,21.213
|
||||
c5.813,5.903,15.31,5.977,21.212,0.164c26.029-25.628,62.923-36.679,98.695-29.565c48.865,9.72,83.772,50.677,88.07,97.978
|
||||
l-38.835-38.835c-5.857-5.857-15.355-5.858-21.213,0.001c-5.858,5.858-5.858,15.355,0,21.213l62.485,62.485
|
||||
c2.929,2.929,6.768,4.393,10.606,4.393s7.678-1.464,10.607-4.393l62.483-62.482C304.281,168.352,304.281,158.854,298.423,152.996z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 691 B |
@ -0,0 +1,293 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onMounted, ref, useTemplateRef, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { fromUnixTime } from 'date-fns';
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
scheduledMessage: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
writtenBy: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
allowEdit: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
allowDelete: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
collapsible: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['edit', 'delete']);
|
||||
const noteContentRef = useTemplateRef('noteContentRef');
|
||||
const [isExpanded, toggleExpanded] = useToggle();
|
||||
const showToggle = ref(false);
|
||||
const { t, locale } = useI18n();
|
||||
const { formatMessage } = useMessageFormatter();
|
||||
|
||||
const statusConfig = {
|
||||
draft: {
|
||||
labelKey: 'SCHEDULED_MESSAGES.STATUS.DRAFT',
|
||||
class: 'bg-n-slate-9/10 text-n-slate-12',
|
||||
},
|
||||
pending: {
|
||||
labelKey: 'SCHEDULED_MESSAGES.STATUS.PENDING',
|
||||
class: 'bg-n-brand/10 text-n-blue-text',
|
||||
},
|
||||
sent: {
|
||||
labelKey: 'SCHEDULED_MESSAGES.STATUS.SENT',
|
||||
class: 'bg-n-teal-9/10 text-n-teal-11',
|
||||
},
|
||||
failed: {
|
||||
labelKey: 'SCHEDULED_MESSAGES.STATUS.FAILED',
|
||||
class: 'bg-n-ruby-9/10 text-n-ruby-11',
|
||||
},
|
||||
};
|
||||
|
||||
const author = computed(() => props.scheduledMessage?.author || null);
|
||||
const authorType = computed(() => props.scheduledMessage?.author_type);
|
||||
const isUserAuthor = computed(
|
||||
() => authorType.value === 'User' && Boolean(author.value?.id)
|
||||
);
|
||||
const avatarSrc = computed(() => {
|
||||
if (isUserAuthor.value) {
|
||||
return author.value?.thumbnail || '';
|
||||
}
|
||||
return '/assets/images/chatwoot_bot.png';
|
||||
});
|
||||
const avatarName = computed(() => {
|
||||
if (isUserAuthor.value) {
|
||||
return author.value?.name || t('CONVERSATION.BOT');
|
||||
}
|
||||
return t('CONVERSATION.BOT');
|
||||
});
|
||||
const status = computed(() => props.scheduledMessage?.status || 'draft');
|
||||
const statusBadge = computed(() => {
|
||||
const config = statusConfig[status.value] || statusConfig.draft;
|
||||
return {
|
||||
class: config.class,
|
||||
// eslint-disable-next-line @intlify/vue-i18n/no-dynamic-keys
|
||||
label: t(config.labelKey),
|
||||
};
|
||||
});
|
||||
const scheduledAt = computed(() => props.scheduledMessage?.scheduled_at);
|
||||
const formattedScheduledTime = computed(() => {
|
||||
if (!scheduledAt.value) return '';
|
||||
const date = fromUnixTime(scheduledAt.value);
|
||||
const now = new Date();
|
||||
|
||||
const options = {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
};
|
||||
|
||||
if (date.getFullYear() !== now.getFullYear()) {
|
||||
options.year = 'numeric';
|
||||
}
|
||||
|
||||
return date.toLocaleString(locale.value.replace('_', '-'), options);
|
||||
});
|
||||
|
||||
const templateName = computed(() => {
|
||||
const templateParams = props.scheduledMessage?.template_params || {};
|
||||
return templateParams.name || templateParams.id;
|
||||
});
|
||||
|
||||
const hasTemplate = computed(() => Boolean(templateName.value));
|
||||
|
||||
const attachment = computed(() => props.scheduledMessage?.attachment);
|
||||
const attachmentName = computed(() => attachment.value?.filename);
|
||||
const attachmentUrl = computed(() => attachment.value?.file_url);
|
||||
const shouldShowAttachmentLine = computed(() => Boolean(attachmentName.value));
|
||||
|
||||
const previewContent = computed(() => {
|
||||
if (props.scheduledMessage?.content) {
|
||||
return props.scheduledMessage.content;
|
||||
}
|
||||
if (templateName.value) {
|
||||
return t('SCHEDULED_MESSAGES.ITEM.TEMPLATE_PREVIEW', {
|
||||
name: templateName.value,
|
||||
});
|
||||
}
|
||||
if (attachmentName.value) {
|
||||
return '';
|
||||
}
|
||||
return t('SCHEDULED_MESSAGES.ITEM.EMPTY_PREVIEW');
|
||||
});
|
||||
|
||||
const hasPreviewContent = computed(() => Boolean(previewContent.value));
|
||||
|
||||
const formattedContent = computed(() => formatMessage(previewContent.value));
|
||||
|
||||
const checkOverflow = () => {
|
||||
if (!props.collapsible) {
|
||||
showToggle.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const el = noteContentRef.value;
|
||||
if (el && !isExpanded.value) {
|
||||
showToggle.value = el.scrollHeight > el.clientHeight;
|
||||
}
|
||||
};
|
||||
|
||||
const onEdit = () => emit('edit', props.scheduledMessage);
|
||||
const onDelete = () => emit('delete', props.scheduledMessage);
|
||||
|
||||
onMounted(() => {
|
||||
checkOverflow();
|
||||
});
|
||||
|
||||
watch(previewContent, () => {
|
||||
nextTick(checkOverflow);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col gap-3 border-b border-n-strong py-3 group/scheduled"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<Avatar
|
||||
:name="avatarName"
|
||||
:src="avatarSrc"
|
||||
:size="30"
|
||||
rounded-full
|
||||
class="shrink-0"
|
||||
/>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<p
|
||||
class="text-sm font-medium text-n-slate-12 mb-0.5 line-clamp-1"
|
||||
:title="writtenBy"
|
||||
>
|
||||
{{ writtenBy }}
|
||||
</p>
|
||||
<p
|
||||
v-if="formattedScheduledTime"
|
||||
class="flex items-center gap-1 text-xs text-n-slate-11 mb-0"
|
||||
>
|
||||
<Icon icon="i-lucide-alarm-clock" class="size-3 shrink-0" />
|
||||
{{ formattedScheduledTime }}
|
||||
</p>
|
||||
<p v-else class="text-xs text-n-slate-11 mb-0">
|
||||
{{ t('SCHEDULED_MESSAGES.ITEM.NO_SCHEDULE') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-center gap-2 shrink-0">
|
||||
<span
|
||||
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium"
|
||||
:class="statusBadge.class"
|
||||
>
|
||||
{{ statusBadge.label }}
|
||||
</span>
|
||||
<div
|
||||
v-if="allowEdit || allowDelete"
|
||||
class="flex items-center gap-1 opacity-0 group-hover/scheduled:opacity-100"
|
||||
>
|
||||
<Button
|
||||
v-if="allowEdit"
|
||||
variant="faded"
|
||||
color="slate"
|
||||
size="xs"
|
||||
icon="i-lucide-pencil"
|
||||
@click="onEdit"
|
||||
/>
|
||||
<Button
|
||||
v-if="allowDelete"
|
||||
variant="faded"
|
||||
color="ruby"
|
||||
size="xs"
|
||||
icon="i-lucide-trash"
|
||||
@click="onDelete"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="hasPreviewContent"
|
||||
ref="noteContentRef"
|
||||
v-dompurify-html="formattedContent"
|
||||
class="mb-0 prose-sm prose-p:text-sm prose-p:leading-relaxed prose-p:mb-1 prose-p:mt-0 prose-ul:mb-1 prose-ul:mt-0 text-n-slate-12"
|
||||
:class="{
|
||||
'line-clamp-4': collapsible && !isExpanded,
|
||||
}"
|
||||
/>
|
||||
|
||||
<div v-if="hasPreviewContent && collapsible && showToggle">
|
||||
<Button
|
||||
variant="faded"
|
||||
color="blue"
|
||||
size="xs"
|
||||
:icon="isExpanded ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
||||
@click="() => toggleExpanded()"
|
||||
>
|
||||
<template v-if="isExpanded">
|
||||
{{ t('SCHEDULED_MESSAGES.ITEM.COLLAPSE') }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ t('SCHEDULED_MESSAGES.ITEM.EXPAND') }}
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="hasTemplate"
|
||||
class="flex items-center gap-1.5 text-xs text-n-slate-11"
|
||||
>
|
||||
<Icon icon="i-lucide-zap" class="size-3 shrink-0" />
|
||||
<span class="truncate">
|
||||
{{
|
||||
t('SCHEDULED_MESSAGES.ITEM.TEMPLATE_LABEL', {
|
||||
name: templateName,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="shouldShowAttachmentLine"
|
||||
class="flex items-center gap-1.5 text-xs text-n-slate-11"
|
||||
>
|
||||
<Icon icon="i-lucide-paperclip" class="size-3 shrink-0" />
|
||||
<a
|
||||
v-if="attachmentUrl"
|
||||
:href="attachmentUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="truncate hover:underline"
|
||||
>
|
||||
{{
|
||||
t('SCHEDULED_MESSAGES.ITEM.ATTACHMENT_LABEL', {
|
||||
filename: attachmentName,
|
||||
})
|
||||
}}
|
||||
</a>
|
||||
<span v-else class="truncate">
|
||||
{{
|
||||
t('SCHEDULED_MESSAGES.ITEM.ATTACHMENT_LABEL', {
|
||||
filename: attachmentName,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -17,6 +17,8 @@ import ContentTemplateSelector from './ContentTemplateSelector.vue';
|
||||
const props = defineProps({
|
||||
attachedFiles: { type: Array, default: () => [] },
|
||||
isWhatsappInbox: { type: Boolean, default: false },
|
||||
isWhatsappBaileysInbox: { type: Boolean, default: false },
|
||||
isWhatsappZapiInbox: { type: Boolean, default: false },
|
||||
isEmailOrWebWidgetInbox: { type: Boolean, default: false },
|
||||
isTwilioSmsInbox: { type: Boolean, default: false },
|
||||
isTwilioWhatsAppInbox: { type: Boolean, default: false },
|
||||
@ -78,7 +80,11 @@ const shouldShowEmojiButton = computed(() => {
|
||||
});
|
||||
|
||||
const isRegularMessageMode = computed(() => {
|
||||
return !props.isWhatsappInbox && !props.isTwilioWhatsAppInbox;
|
||||
return (
|
||||
(!props.isWhatsappInbox && !props.isTwilioWhatsAppInbox) ||
|
||||
props.isWhatsappBaileysInbox ||
|
||||
props.isWhatsappZapiInbox
|
||||
);
|
||||
});
|
||||
|
||||
const isVoiceInbox = computed(() => props.channelType === INBOX_TYPES.VOICE);
|
||||
|
||||
@ -4,8 +4,6 @@ import { useVuelidate } from '@vuelidate/core';
|
||||
import { required, requiredIf } from '@vuelidate/validators';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
import {
|
||||
appendSignature,
|
||||
removeSignature,
|
||||
getEffectiveChannelType,
|
||||
stripUnsupportedMarkdown,
|
||||
} from 'dashboard/helper/editorHelper';
|
||||
@ -70,6 +68,12 @@ 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',
|
||||
isWhatsappZapi:
|
||||
props.targetInbox?.channelType === INBOX_TYPES.WHATSAPP &&
|
||||
props.targetInbox?.provider === 'zapi',
|
||||
isWebWidget: props.targetInbox?.channelType === INBOX_TYPES.WEB,
|
||||
isApi: props.targetInbox?.channelType === INBOX_TYPES.API,
|
||||
isEmailOrWebWidget:
|
||||
@ -91,12 +95,6 @@ const whatsappMessageTemplates = computed(() =>
|
||||
|
||||
const inboxChannelType = computed(() => props.targetInbox?.channelType || '');
|
||||
|
||||
const inboxMedium = computed(() => props.targetInbox?.medium || '');
|
||||
|
||||
const effectiveChannelType = computed(() =>
|
||||
getEffectiveChannelType(inboxChannelType.value, inboxMedium.value)
|
||||
);
|
||||
|
||||
const validationRules = computed(() => ({
|
||||
selectedContact: { required },
|
||||
targetInbox: { required },
|
||||
@ -131,6 +129,8 @@ const newMessagePayload = () => {
|
||||
currentUser: props.currentUser,
|
||||
attachedFiles,
|
||||
directUploadsEnabled: props.isDirectUploadsEnabled,
|
||||
sendWithSignature: props.sendWithSignature,
|
||||
messageSignature: props.messageSignature,
|
||||
});
|
||||
};
|
||||
|
||||
@ -222,21 +222,8 @@ const handleInboxAction = ({ value, action, channelType, medium, ...rest }) => {
|
||||
state.attachedFiles = [];
|
||||
};
|
||||
|
||||
const removeSignatureFromMessage = () => {
|
||||
// Always remove the signature from message content when inbox/contact is removed
|
||||
// to ensure no leftover signature content remains
|
||||
if (props.messageSignature) {
|
||||
state.message = removeSignature(
|
||||
state.message,
|
||||
props.messageSignature,
|
||||
effectiveChannelType.value
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const removeTargetInbox = value => {
|
||||
v$.value.$reset();
|
||||
removeSignatureFromMessage();
|
||||
|
||||
stripMessageFormatting(DEFAULT_FORMATTING);
|
||||
|
||||
@ -245,7 +232,6 @@ const removeTargetInbox = value => {
|
||||
};
|
||||
|
||||
const clearSelectedContact = () => {
|
||||
removeSignatureFromMessage();
|
||||
emit('clearSelectedContact');
|
||||
state.message = '';
|
||||
state.attachedFiles = [];
|
||||
@ -255,22 +241,6 @@ const onClickInsertEmoji = emoji => {
|
||||
state.message += emoji;
|
||||
};
|
||||
|
||||
const handleAddSignature = signature => {
|
||||
state.message = appendSignature(
|
||||
state.message,
|
||||
signature,
|
||||
effectiveChannelType.value
|
||||
);
|
||||
};
|
||||
|
||||
const handleRemoveSignature = signature => {
|
||||
state.message = removeSignature(
|
||||
state.message,
|
||||
signature,
|
||||
effectiveChannelType.value
|
||||
);
|
||||
};
|
||||
|
||||
const handleAttachFile = files => {
|
||||
state.attachedFiles = files;
|
||||
};
|
||||
@ -333,7 +303,9 @@ const handleSendTwilioMessage = async ({ message, templateParams }) => {
|
||||
|
||||
const shouldShowMessageEditor = computed(() => {
|
||||
return (
|
||||
!inboxTypes.value.isWhatsapp &&
|
||||
(!inboxTypes.value.isWhatsapp ||
|
||||
inboxTypes.value.isWhatsappBaileys ||
|
||||
inboxTypes.value.isWhatsappZapi) &&
|
||||
!showNoInboxAlert.value &&
|
||||
!inboxTypes.value.isTwilioWhatsapp
|
||||
);
|
||||
@ -408,6 +380,8 @@ const shouldShowMessageEditor = computed(() => {
|
||||
<ActionButtons
|
||||
:attached-files="state.attachedFiles"
|
||||
:is-whatsapp-inbox="inboxTypes.isWhatsapp"
|
||||
:is-whatsapp-baileys-inbox="inboxTypes.isWhatsappBaileys"
|
||||
:is-whatsapp-zapi-inbox="inboxTypes.isWhatsappZapi"
|
||||
:is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget"
|
||||
:is-twilio-sms-inbox="inboxTypes.isTwilioSMS"
|
||||
:is-twilio-whats-app-inbox="inboxTypes.isTwilioWhatsapp"
|
||||
@ -421,8 +395,6 @@ const shouldShowMessageEditor = computed(() => {
|
||||
:is-dropdown-active="isAnyDropdownActive"
|
||||
:message-signature="messageSignature"
|
||||
@insert-emoji="onClickInsertEmoji"
|
||||
@add-signature="handleAddSignature"
|
||||
@remove-signature="handleRemoveSignature"
|
||||
@attach-file="handleAttachFile"
|
||||
@discard="$emit('discard')"
|
||||
@send-message="handleSendMessage"
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
import { getInboxIconByType } from 'dashboard/helper/inbox';
|
||||
import { appendSignature } from 'dashboard/helper/editorHelper';
|
||||
import camelcaseKeys from 'camelcase-keys';
|
||||
import ContactAPI from 'dashboard/api/contacts';
|
||||
|
||||
@ -129,12 +130,29 @@ export const prepareNewMessagePayload = ({
|
||||
currentUser,
|
||||
attachedFiles = [],
|
||||
directUploadsEnabled = false,
|
||||
sendWithSignature = false,
|
||||
messageSignature = '',
|
||||
}) => {
|
||||
let finalMessage = message;
|
||||
if (sendWithSignature && messageSignature) {
|
||||
const { signature_position, signature_separator } =
|
||||
currentUser?.ui_settings || {};
|
||||
const signatureSettings = {
|
||||
position: signature_position || 'top',
|
||||
separator: signature_separator || 'blank',
|
||||
};
|
||||
finalMessage = appendSignature(
|
||||
message,
|
||||
messageSignature,
|
||||
signatureSettings
|
||||
);
|
||||
}
|
||||
|
||||
const payload = {
|
||||
inboxId: targetInbox.id,
|
||||
sourceId: targetInbox.sourceId,
|
||||
contactId: Number(selectedContact.id),
|
||||
message: { content: message },
|
||||
message: { content: finalMessage },
|
||||
assigneeId: currentUser.id,
|
||||
};
|
||||
|
||||
|
||||
127
app/javascript/dashboard/components-next/banner/PromoBanner.vue
Normal file
127
app/javascript/dashboard/components-next/banner/PromoBanner.vue
Normal file
@ -0,0 +1,127 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'info',
|
||||
validator: value => ['info', 'success', 'warning'].includes(value),
|
||||
},
|
||||
ctaText: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
ctaLink: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
ctaExternal: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showIcon: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
logoSrc: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
logoAlt: {
|
||||
type: String,
|
||||
default: 'Logo',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['ctaClick']);
|
||||
|
||||
const variantClasses = computed(() => {
|
||||
const variants = {
|
||||
info: {
|
||||
container: 'bg-woot-50 border-woot-200',
|
||||
icon: 'i-lucide-info text-woot-600',
|
||||
text: 'text-woot-700',
|
||||
description: 'text-woot-600',
|
||||
},
|
||||
success: {
|
||||
container: 'bg-green-50 border-green-200',
|
||||
icon: 'i-lucide-sparkles text-green-600',
|
||||
text: 'text-green-700',
|
||||
description: 'text-green-600',
|
||||
},
|
||||
warning: {
|
||||
container: 'bg-yellow-50 border-yellow-200',
|
||||
icon: 'i-lucide-alert-circle text-yellow-600',
|
||||
text: 'text-yellow-700',
|
||||
description: 'text-yellow-600',
|
||||
},
|
||||
};
|
||||
return variants[props.variant];
|
||||
});
|
||||
|
||||
const handleCtaClick = () => {
|
||||
emit('ctaClick');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative flex items-start gap-3 p-4 rounded-lg border"
|
||||
:class="variantClasses.container"
|
||||
>
|
||||
<div v-if="logoSrc || showIcon" class="flex-shrink-0 mt-0.5">
|
||||
<img
|
||||
v-if="logoSrc"
|
||||
:src="logoSrc"
|
||||
:alt="logoAlt"
|
||||
class="w-8 h-8 object-contain"
|
||||
/>
|
||||
<i v-else class="w-5 h-5" :class="variantClasses.icon" />
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-sm font-semibold mb-1" :class="variantClasses.text">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<p class="text-sm leading-relaxed" :class="variantClasses.description">
|
||||
{{ description }}
|
||||
</p>
|
||||
|
||||
<div v-if="ctaText" class="mt-3">
|
||||
<a
|
||||
v-if="ctaLink"
|
||||
:href="ctaLink"
|
||||
:target="ctaExternal ? '_blank' : '_self'"
|
||||
:rel="ctaExternal ? 'noopener noreferrer' : undefined"
|
||||
class="inline-block"
|
||||
>
|
||||
<NextButton
|
||||
sm
|
||||
:color-scheme="variant === 'success' ? 'primary' : 'secondary'"
|
||||
type="button"
|
||||
>
|
||||
{{ ctaText }}
|
||||
</NextButton>
|
||||
</a>
|
||||
<NextButton
|
||||
v-else
|
||||
sm
|
||||
:color-scheme="variant === 'success' ? 'primary' : 'secondary'"
|
||||
type="button"
|
||||
@click="handleCtaClick"
|
||||
>
|
||||
{{ ctaText }}
|
||||
</NextButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -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,
|
||||
}"
|
||||
|
||||
@ -97,6 +97,7 @@ import { useBranding } from 'shared/composables/useBranding';
|
||||
* @property {boolean} [isEmailInbox=false] - Whether the message is from an email inbox
|
||||
* @property {number} conversationId - The ID of the conversation to which the message belongs
|
||||
* @property {number} inboxId - The ID of the inbox to which the message belongs
|
||||
* @property {Object} [additionalAttributes={}] - Additional attributes of the message
|
||||
*/
|
||||
|
||||
// eslint-disable-next-line vue/define-macros-order
|
||||
@ -120,12 +121,15 @@ const props = defineProps({
|
||||
default: 'text',
|
||||
validator: value => Object.values(CONTENT_TYPES).includes(value),
|
||||
},
|
||||
// eslint-disable-next-line vue/no-unused-properties
|
||||
additionalAttributes: { type: Object, default: () => ({}) },
|
||||
conversationId: { type: Number, required: true },
|
||||
createdAt: { type: Number, required: true }, // eslint-disable-line vue/no-unused-properties
|
||||
currentUserId: { type: Number, required: true }, // eslint-disable-line vue/no-unused-properties
|
||||
groupWithNext: { type: Boolean, default: false },
|
||||
inboxId: { type: Number, default: null }, // eslint-disable-line vue/no-unused-properties
|
||||
inboxSupportsReplyTo: { type: Object, default: () => ({}) },
|
||||
inboxSupportsEdit: { type: Boolean, default: false },
|
||||
inReplyTo: { type: Object, default: null }, // eslint-disable-line vue/no-unused-properties
|
||||
isEmailInbox: { type: Boolean, default: false },
|
||||
private: { type: Boolean, default: false },
|
||||
@ -382,6 +386,12 @@ const contextMenuEnabledOptions = computed(() => {
|
||||
!props.private &&
|
||||
props.inboxSupportsReplyTo.outgoing &&
|
||||
!isFailedOrProcessing,
|
||||
edit:
|
||||
isOutgoing &&
|
||||
hasText &&
|
||||
!isFailedOrProcessing &&
|
||||
!isMessageDeleted.value &&
|
||||
props.inboxSupportsEdit,
|
||||
};
|
||||
});
|
||||
|
||||
@ -450,8 +460,16 @@ const avatarInfo = computed(() => {
|
||||
};
|
||||
}
|
||||
|
||||
// If no sender, return bot info
|
||||
// If no sender, check for external sender name
|
||||
if (!props.sender) {
|
||||
const externalSenderName = props.contentAttributes?.externalSenderName;
|
||||
if (externalSenderName === 'WhatsApp') {
|
||||
return {
|
||||
name: t('CONVERSATION.WHATSAPP'),
|
||||
src: '',
|
||||
iconName: 'i-woot-whatsapp',
|
||||
};
|
||||
}
|
||||
return {
|
||||
name: t('CONVERSATION.BOT'),
|
||||
src: '',
|
||||
|
||||
@ -14,6 +14,7 @@ import MessageApi from 'dashboard/api/inbox/message.js';
|
||||
* @property {Number} currentUserId - ID of the current user
|
||||
* @property {Boolean} isAnEmailChannel - Whether this is an email channel
|
||||
* @property {Object} inboxSupportsReplyTo - Inbox reply support configuration
|
||||
* @property {Boolean} inboxSupportsEdit - Whether the inbox supports message editing
|
||||
* @property {Array} messages - Array of all messages [These are not in camelcase]
|
||||
*/
|
||||
const props = defineProps({
|
||||
@ -33,6 +34,10 @@ const props = defineProps({
|
||||
type: Object,
|
||||
default: () => ({ incoming: false, outgoing: false }),
|
||||
},
|
||||
inboxSupportsEdit: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
messages: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
@ -176,6 +181,7 @@ const getInReplyToMessage = parentMessage => {
|
||||
:in-reply-to="getInReplyToMessage(message)"
|
||||
:group-with-next="shouldGroupWithNext(index, allMessages)"
|
||||
:inbox-supports-reply-to="inboxSupportsReplyTo"
|
||||
:inbox-supports-edit="inboxSupportsEdit"
|
||||
:current-user-id="currentUserId"
|
||||
data-clarity-mask="True"
|
||||
@retry="emit('retry', message)"
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { messageTimestamp } from 'shared/helpers/timeHelper';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useFunctionGetter } from 'dashboard/composables/store';
|
||||
|
||||
import MessageStatus from './MessageStatus.vue';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
@ -23,6 +25,8 @@ const {
|
||||
isATiktokChannel,
|
||||
} = useInbox();
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
|
||||
const {
|
||||
status,
|
||||
isPrivate,
|
||||
@ -30,12 +34,96 @@ const {
|
||||
sourceId,
|
||||
messageType,
|
||||
contentAttributes,
|
||||
additionalAttributes,
|
||||
sender,
|
||||
currentUserId,
|
||||
} = useMessageContext();
|
||||
|
||||
const readableTime = computed(() =>
|
||||
messageTimestamp(createdAt.value, 'LLL d, h:mm a')
|
||||
messageTimestamp(
|
||||
contentAttributes?.value?.externalCreatedAt ?? createdAt.value,
|
||||
'LLL d, h:mm a'
|
||||
)
|
||||
);
|
||||
|
||||
const isScheduledMessage = computed(
|
||||
() => !!additionalAttributes.value?.scheduledMessageId
|
||||
);
|
||||
const scheduledBy = computed(() => additionalAttributes.value?.scheduledBy);
|
||||
const scheduledById = computed(() => scheduledBy.value?.id);
|
||||
const scheduledByType = computed(() =>
|
||||
scheduledBy.value?.type ? String(scheduledBy.value.type) : ''
|
||||
);
|
||||
const scheduledByTypeNormalized = computed(() =>
|
||||
scheduledByType.value.toLowerCase()
|
||||
);
|
||||
const scheduledByAgent = useFunctionGetter(
|
||||
'agents/getAgentById',
|
||||
scheduledById
|
||||
);
|
||||
|
||||
const isScheduledByCurrentUser = computed(() => {
|
||||
if (!scheduledById.value || !currentUserId.value) return false;
|
||||
return Number(scheduledById.value) === Number(currentUserId.value);
|
||||
});
|
||||
|
||||
const scheduledAt = computed(() => additionalAttributes.value?.scheduledAt);
|
||||
const scheduledAtTimestamp = computed(() => {
|
||||
if (!scheduledAt.value) return null;
|
||||
return Math.floor(scheduledAt.value);
|
||||
});
|
||||
|
||||
const scheduledAtLabel = computed(() => {
|
||||
if (!scheduledAtTimestamp.value) {
|
||||
return t('SCHEDULED_MESSAGES.ITEM.NO_SCHEDULE');
|
||||
}
|
||||
const date = new Date(scheduledAtTimestamp.value * 1000);
|
||||
const now = new Date();
|
||||
|
||||
const options = {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
};
|
||||
|
||||
if (date.getFullYear() !== now.getFullYear()) {
|
||||
options.year = 'numeric';
|
||||
}
|
||||
|
||||
return date.toLocaleString(locale.value.replace('_', '-'), options);
|
||||
});
|
||||
|
||||
const scheduledByLabel = computed(() => {
|
||||
if (!isScheduledMessage.value) return '';
|
||||
if (isScheduledByCurrentUser.value) {
|
||||
const userName = scheduledByAgent.value?.name;
|
||||
return t('SCHEDULED_MESSAGES.META.AUTHOR_YOU', { name: userName });
|
||||
}
|
||||
if (scheduledByTypeNormalized.value.includes('automation')) {
|
||||
const automationLabel = t('SCHEDULED_MESSAGES.META.AUTOMATION');
|
||||
if (scheduledBy.value?.name) {
|
||||
return `${scheduledBy.value.name} (${automationLabel})`;
|
||||
}
|
||||
return automationLabel;
|
||||
}
|
||||
if (scheduledByAgent.value?.name) {
|
||||
return scheduledByAgent.value.name;
|
||||
}
|
||||
if (sender.value?.name) {
|
||||
return sender.value.name;
|
||||
}
|
||||
return t('SCHEDULED_MESSAGES.META.UNKNOWN_AUTHOR');
|
||||
});
|
||||
|
||||
const scheduledTooltip = computed(() => {
|
||||
if (!isScheduledMessage.value) return '';
|
||||
return t('SCHEDULED_MESSAGES.META.TOOLTIP', {
|
||||
time: scheduledAtLabel.value,
|
||||
author: scheduledByLabel.value,
|
||||
});
|
||||
});
|
||||
|
||||
const showStatusIndicator = computed(() => {
|
||||
if (isPrivate.value) return false;
|
||||
// Don't show status for failed messages, we already show error message
|
||||
@ -123,6 +211,14 @@ const statusToShow = computed(() => {
|
||||
|
||||
return MESSAGE_STATUS.PROGRESS;
|
||||
});
|
||||
|
||||
const isEdited = computed(() => {
|
||||
return contentAttributes.value?.isEdited === true;
|
||||
});
|
||||
|
||||
const previousContent = computed(() => {
|
||||
return contentAttributes.value?.previousContent || '';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -130,8 +226,27 @@ const statusToShow = computed(() => {
|
||||
<div class="inline">
|
||||
<time class="inline">{{ readableTime }}</time>
|
||||
</div>
|
||||
<span
|
||||
v-if="isScheduledMessage"
|
||||
v-tooltip.top-start="{
|
||||
content: scheduledTooltip,
|
||||
delay: { show: 300, hide: 0 },
|
||||
}"
|
||||
class="inline-flex items-center gap-0.5"
|
||||
>
|
||||
<Icon icon="i-lucide-alarm-clock" class="size-3" />
|
||||
</span>
|
||||
<span
|
||||
v-if="isEdited"
|
||||
v-tooltip.top="{
|
||||
content: previousContent,
|
||||
delay: { show: 300, hide: 0 },
|
||||
}"
|
||||
class="inline-flex items-center gap-0.5"
|
||||
>
|
||||
<Icon icon="i-lucide-pencil" class="size-3" />
|
||||
</span>
|
||||
<Icon v-if="isPrivate" icon="i-lucide-lock-keyhole" class="size-3" />
|
||||
<MessageStatus v-if="showStatusIndicator" :status="statusToShow" />
|
||||
</div>
|
||||
</template>
|
||||
`
|
||||
|
||||
@ -15,8 +15,13 @@ const props = defineProps({
|
||||
hideMeta: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const { variant, orientation, inReplyTo, shouldGroupWithNext } =
|
||||
useMessageContext();
|
||||
const {
|
||||
variant,
|
||||
orientation,
|
||||
inReplyTo,
|
||||
shouldGroupWithNext,
|
||||
additionalAttributes,
|
||||
} = useMessageContext();
|
||||
const { t } = useI18n();
|
||||
|
||||
const varaintBaseMap = {
|
||||
@ -51,6 +56,16 @@ const flexOrientationClass = computed(() => {
|
||||
return map[orientation.value];
|
||||
});
|
||||
|
||||
const isScheduledMessage = computed(
|
||||
() => !!additionalAttributes.value?.scheduledMessageId
|
||||
);
|
||||
|
||||
const scheduledMessageClass = computed(() => {
|
||||
if (!isScheduledMessage.value) return '';
|
||||
if (variant.value === MESSAGE_VARIANTS.AGENT) return 'bg-n-solid-iris';
|
||||
return '';
|
||||
});
|
||||
|
||||
const messageClass = computed(() => {
|
||||
const classToApply = [varaintBaseMap[variant.value]];
|
||||
|
||||
@ -60,6 +75,10 @@ const messageClass = computed(() => {
|
||||
classToApply.push('rounded-lg');
|
||||
}
|
||||
|
||||
if (scheduledMessageClass.value) {
|
||||
classToApply.push(scheduledMessageClass.value);
|
||||
}
|
||||
|
||||
return classToApply;
|
||||
});
|
||||
|
||||
|
||||
@ -20,7 +20,9 @@ const attachment = computed(() => {
|
||||
return attachments.value[0];
|
||||
});
|
||||
|
||||
const { isLoaded, hasError, loadWithRetry } = useLoadWithRetry();
|
||||
const { isLoaded, hasError, loadWithRetry } = useLoadWithRetry({
|
||||
type: 'image',
|
||||
});
|
||||
|
||||
const showGallery = ref(false);
|
||||
const isDownloading = ref(false);
|
||||
|
||||
@ -1,9 +1,12 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMessageContext } from '../../provider.js';
|
||||
import { useFunctionGetter } from 'dashboard/composables/store';
|
||||
|
||||
import MessageFormatter from 'shared/helpers/MessageFormatter.js';
|
||||
import { MESSAGE_VARIANTS } from '../../constants';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
content: {
|
||||
@ -12,7 +15,16 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const { variant } = useMessageContext();
|
||||
const { t, locale } = useI18n();
|
||||
|
||||
const {
|
||||
variant,
|
||||
contentAttributes,
|
||||
shouldGroupWithNext,
|
||||
additionalAttributes,
|
||||
sender,
|
||||
currentUserId,
|
||||
} = useMessageContext();
|
||||
|
||||
const formattedContent = computed(() => {
|
||||
if (variant.value === MESSAGE_VARIANTS.ACTIVITY) {
|
||||
@ -21,8 +33,134 @@ const formattedContent = computed(() => {
|
||||
|
||||
return new MessageFormatter(props.content).formattedMessage;
|
||||
});
|
||||
|
||||
// Show edited indicator inline when meta is hidden (grouped messages)
|
||||
const isEdited = computed(() => {
|
||||
return contentAttributes.value?.isEdited === true;
|
||||
});
|
||||
|
||||
const previousContent = computed(() => {
|
||||
return contentAttributes.value?.previousContent || '';
|
||||
});
|
||||
|
||||
const shouldShowEditedIndicator = computed(() => {
|
||||
return isEdited.value && shouldGroupWithNext.value;
|
||||
});
|
||||
|
||||
// Scheduled message indicator
|
||||
const isScheduledMessage = computed(
|
||||
() => !!additionalAttributes.value?.scheduledMessageId
|
||||
);
|
||||
const scheduledBy = computed(() => additionalAttributes.value?.scheduledBy);
|
||||
const scheduledById = computed(() => scheduledBy.value?.id);
|
||||
const scheduledByType = computed(() =>
|
||||
scheduledBy.value?.type ? String(scheduledBy.value.type) : ''
|
||||
);
|
||||
const scheduledByTypeNormalized = computed(() =>
|
||||
scheduledByType.value.toLowerCase()
|
||||
);
|
||||
const scheduledByAgent = useFunctionGetter(
|
||||
'agents/getAgentById',
|
||||
scheduledById
|
||||
);
|
||||
|
||||
const isScheduledByCurrentUser = computed(() => {
|
||||
if (!scheduledById.value || !currentUserId.value) return false;
|
||||
return Number(scheduledById.value) === Number(currentUserId.value);
|
||||
});
|
||||
|
||||
const scheduledAt = computed(() => additionalAttributes.value?.scheduledAt);
|
||||
const scheduledAtTimestamp = computed(() => {
|
||||
if (!scheduledAt.value) return null;
|
||||
return Math.floor(scheduledAt.value);
|
||||
});
|
||||
|
||||
const scheduledAtLabel = computed(() => {
|
||||
if (!scheduledAtTimestamp.value) {
|
||||
return t('SCHEDULED_MESSAGES.ITEM.NO_SCHEDULE');
|
||||
}
|
||||
const date = new Date(scheduledAtTimestamp.value * 1000);
|
||||
const now = new Date();
|
||||
|
||||
const options = {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
};
|
||||
|
||||
if (date.getFullYear() !== now.getFullYear()) {
|
||||
options.year = 'numeric';
|
||||
}
|
||||
|
||||
return date.toLocaleString(locale.value.replace('_', '-'), options);
|
||||
});
|
||||
|
||||
const scheduledByLabel = computed(() => {
|
||||
if (!isScheduledMessage.value) return '';
|
||||
if (isScheduledByCurrentUser.value) {
|
||||
const userName = scheduledByAgent.value?.name;
|
||||
return t('SCHEDULED_MESSAGES.META.AUTHOR_YOU', { name: userName });
|
||||
}
|
||||
if (scheduledByTypeNormalized.value.includes('automation')) {
|
||||
const automationLabel = t('SCHEDULED_MESSAGES.META.AUTOMATION');
|
||||
if (scheduledBy.value?.name) {
|
||||
return `${scheduledBy.value.name} (${automationLabel})`;
|
||||
}
|
||||
return automationLabel;
|
||||
}
|
||||
if (scheduledByAgent.value?.name) {
|
||||
return scheduledByAgent.value.name;
|
||||
}
|
||||
if (sender.value?.name) {
|
||||
return sender.value.name;
|
||||
}
|
||||
return t('SCHEDULED_MESSAGES.META.UNKNOWN_AUTHOR');
|
||||
});
|
||||
|
||||
const scheduledTooltip = computed(() => {
|
||||
if (!isScheduledMessage.value) return '';
|
||||
return t('SCHEDULED_MESSAGES.META.TOOLTIP', {
|
||||
time: scheduledAtLabel.value,
|
||||
author: scheduledByLabel.value,
|
||||
});
|
||||
});
|
||||
|
||||
const shouldShowScheduledIndicator = computed(() => {
|
||||
return isScheduledMessage.value && shouldGroupWithNext.value;
|
||||
});
|
||||
|
||||
const iconColorClass = computed(() => {
|
||||
return variant.value === MESSAGE_VARIANTS.PRIVATE
|
||||
? 'text-n-amber-12/50'
|
||||
: 'text-n-slate-11';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="inline">
|
||||
<span v-dompurify-html="formattedContent" class="prose prose-bubble" />
|
||||
<span
|
||||
v-if="shouldShowScheduledIndicator"
|
||||
v-tooltip.top="{
|
||||
content: scheduledTooltip,
|
||||
delay: { show: 300, hide: 0 },
|
||||
}"
|
||||
:class="iconColorClass"
|
||||
class="inline-flex items-center ml-1 align-middle"
|
||||
>
|
||||
<Icon icon="i-lucide-alarm-clock" class="size-3" />
|
||||
</span>
|
||||
<span
|
||||
v-if="shouldShowEditedIndicator"
|
||||
v-tooltip.top="{
|
||||
content: previousContent,
|
||||
delay: { show: 300, hide: 0 },
|
||||
}"
|
||||
:class="iconColorClass"
|
||||
class="inline-flex items-center ml-1 align-middle"
|
||||
>
|
||||
<Icon icon="i-lucide-pencil" class="size-3" />
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
@ -6,6 +6,8 @@ import {
|
||||
ref,
|
||||
getCurrentInstance,
|
||||
} from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useLoadWithRetry } from 'dashboard/composables/loadWithRetry';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import { timeStampAppendedURL } from 'dashboard/helper/URLHelper';
|
||||
import { downloadFile } from '@chatwoot/utils';
|
||||
@ -27,6 +29,11 @@ defineOptions({
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const { isLoaded, hasError, loadWithRetry } = useLoadWithRetry({
|
||||
type: 'audio',
|
||||
});
|
||||
|
||||
const timeStampURL = computed(() => {
|
||||
return timeStampAppendedURL(attachment.dataUrl);
|
||||
});
|
||||
@ -42,19 +49,20 @@ const playbackSpeed = ref(1);
|
||||
const { uid } = getCurrentInstance();
|
||||
|
||||
const onLoadedMetadata = () => {
|
||||
duration.value = audioPlayer.value?.duration;
|
||||
if (audioPlayer.value) {
|
||||
duration.value = audioPlayer.value.duration;
|
||||
audioPlayer.value.playbackRate = playbackSpeed.value;
|
||||
}
|
||||
};
|
||||
|
||||
const playbackSpeedLabel = computed(() => {
|
||||
return `${playbackSpeed.value}x`;
|
||||
});
|
||||
|
||||
// There maybe a chance that the audioPlayer ref is not available
|
||||
// When the onLoadMetadata is called, so we need to set the duration
|
||||
// value when the component is mounted
|
||||
onMounted(() => {
|
||||
duration.value = audioPlayer.value?.duration;
|
||||
audioPlayer.value.playbackRate = playbackSpeed.value;
|
||||
if (attachment.dataUrl) {
|
||||
loadWithRetry(attachment.dataUrl);
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for global audio play events and pause if it's not this audio
|
||||
@ -125,6 +133,17 @@ const downloadAudio = async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="hasError"
|
||||
v-bind="$attrs"
|
||||
class="flex items-center gap-1 text-center rounded-lg p-2 bg-n-alpha-white border border-n-container"
|
||||
>
|
||||
<Icon icon="i-lucide-circle-off" class="text-n-slate-11" />
|
||||
<p class="mb-0 text-n-slate-11 text-sm">
|
||||
{{ t('COMPONENTS.MEDIA.AUDIO_UNAVAILABLE') }}
|
||||
</p>
|
||||
</div>
|
||||
<template v-else-if="isLoaded">
|
||||
<audio
|
||||
ref="audioPlayer"
|
||||
controls
|
||||
@ -192,4 +211,5 @@ const downloadAudio = async () => {
|
||||
{{ attachment.transcribedText }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@ -96,6 +96,7 @@ const MessageControl = Symbol('MessageControl');
|
||||
* @property {import('vue').Ref<Object|null>} [inReplyTo=null] - The message to which this message is a reply
|
||||
* @property {import('vue').Ref<SenderType>} [senderType=null] - The type of the sender
|
||||
* @property {import('vue').Ref<Sender|null>} [sender=null] - The sender information
|
||||
* @property {import('vue').Ref<Object>} [additionalAttributes={}] - Additional attributes of the message
|
||||
* @property {import('vue').ComputedRef<MessageOrientation>} orientation - The visual variant of the message
|
||||
* @property {import('vue').ComputedRef<MessageVariant>} variant - The visual variant of the message
|
||||
* @property {import('vue').ComputedRef<boolean>} isBotOrAgentMessage - Does the message belong to the current user
|
||||
|
||||
@ -159,6 +159,7 @@ useEventListener(document, 'touchend', onResizeEnd);
|
||||
|
||||
const inboxes = useMapGetter('inboxes/getInboxes');
|
||||
const labels = useMapGetter('labels/getLabelsOnSidebar');
|
||||
const dashboardApps = useMapGetter('dashboardApps/getAppsOnSidebar');
|
||||
const teams = useMapGetter('teams/getMyTeams');
|
||||
const contactCustomViews = useMapGetter('customViews/getContactCustomViews');
|
||||
const conversationCustomViews = useMapGetter(
|
||||
@ -173,6 +174,7 @@ onMounted(() => {
|
||||
store.dispatch('attributes/get');
|
||||
store.dispatch('customViews/get', 'conversation');
|
||||
store.dispatch('customViews/get', 'contact');
|
||||
store.dispatch('dashboardApps/get');
|
||||
});
|
||||
|
||||
const sortedInboxes = computed(() =>
|
||||
@ -222,7 +224,7 @@ const newReportRoutes = () => [
|
||||
const reportRoutes = computed(() => newReportRoutes());
|
||||
|
||||
const menuItems = computed(() => {
|
||||
return [
|
||||
const items = [
|
||||
{
|
||||
name: 'Inbox',
|
||||
label: t('SIDEBAR.INBOX'),
|
||||
@ -694,6 +696,23 @@ const menuItems = computed(() => {
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
if (dashboardApps.value.length > 0) {
|
||||
const settingsIndex = items.findIndex(item => item.name === 'Settings');
|
||||
items.splice(settingsIndex, 0, {
|
||||
name: 'Apps',
|
||||
label: t('SIDEBAR.APPS'),
|
||||
icon: 'i-lucide-layout-grid',
|
||||
children: dashboardApps.value.map(app => ({
|
||||
name: `app-${app.id}`,
|
||||
label: app.title,
|
||||
to: accountScopedRoute('dashboard_app_view', { appId: app.id }),
|
||||
activeOn: ['dashboard_app_view'],
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@ -158,9 +158,11 @@ const activeChild = computed(() => {
|
||||
return rankedPage ?? activeOnPages[0];
|
||||
}
|
||||
|
||||
return navigableChildren.value.find(
|
||||
child => child.to && route.path.startsWith(resolvePath(child.to))
|
||||
);
|
||||
return navigableChildren.value.find(child => {
|
||||
if (!child.to) return false;
|
||||
const childPath = resolvePath(child.to);
|
||||
return route.path === childPath || route.path.startsWith(childPath + '/');
|
||||
});
|
||||
});
|
||||
|
||||
const hasActiveChild = computed(() => {
|
||||
|
||||
@ -1,6 +1,13 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['change']);
|
||||
|
||||
const { t } = useI18n();
|
||||
@ -18,6 +25,7 @@ const updateValue = () => {
|
||||
|
||||
<template>
|
||||
<button
|
||||
:id="props.id"
|
||||
type="button"
|
||||
class="relative h-4 transition-colors duration-200 ease-in-out rounded-full w-7 focus:outline-none focus:ring-1 focus:ring-n-brand focus:ring-offset-n-slate-2 focus:ring-offset-2 flex-shrink-0"
|
||||
:class="modelValue ? 'bg-n-brand' : 'bg-n-slate-6 disabled:bg-n-slate-6/60'"
|
||||
|
||||
@ -35,6 +35,10 @@ const props = defineProps({
|
||||
return true;
|
||||
},
|
||||
},
|
||||
sendButtonLabel: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['sendMessage', 'resetTemplate', 'back']);
|
||||
@ -43,6 +47,12 @@ const { t } = useI18n();
|
||||
|
||||
const processedParams = ref({});
|
||||
|
||||
const sendButtonText = computed(() => {
|
||||
return (
|
||||
props.sendButtonLabel || t('WHATSAPP_TEMPLATES.PARSER.SEND_MESSAGE_LABEL')
|
||||
);
|
||||
});
|
||||
|
||||
const languageLabel = computed(() => {
|
||||
return `${t('WHATSAPP_TEMPLATES.PARSER.LANGUAGE')}: ${props.template.language || DEFAULT_LANGUAGE}`;
|
||||
});
|
||||
@ -305,6 +315,7 @@ defineExpose({
|
||||
:go-back="goBack"
|
||||
:is-valid="!v$.$invalid"
|
||||
:disabled="isFormInvalid"
|
||||
:send-button-text="sendButtonText"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import 'highlight.js/styles/default.css';
|
||||
import 'highlight.js/lib/common';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
@ -24,10 +24,20 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: 'Chatwoot Codepen',
|
||||
},
|
||||
secure: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const isVisible = ref(false);
|
||||
|
||||
const toggleVisibility = () => {
|
||||
isVisible.value = !isVisible.value;
|
||||
};
|
||||
|
||||
const scrubbedScript = computed(() => {
|
||||
// remove trailing and leading extra lines and not spaces
|
||||
const scrubbed = props.script.replace(/^\s*[\r\n]/gm, '');
|
||||
@ -52,6 +62,10 @@ const codepenScriptValue = computed(() => {
|
||||
});
|
||||
});
|
||||
|
||||
const shouldShowScript = computed(() => {
|
||||
return !props.secure || isVisible.value;
|
||||
});
|
||||
|
||||
const onCopy = async e => {
|
||||
e.preventDefault();
|
||||
await copyTextToClipboard(scrubbedScript.value);
|
||||
@ -80,6 +94,14 @@ const onCopy = async e => {
|
||||
:label="t('COMPONENTS.CODE.CODEPEN')"
|
||||
/>
|
||||
</form>
|
||||
<NextButton
|
||||
v-if="secure"
|
||||
slate
|
||||
xs
|
||||
faded
|
||||
:icon="isVisible ? 'i-lucide-eye-off' : 'i-lucide-eye'"
|
||||
@click="toggleVisibility"
|
||||
/>
|
||||
<NextButton
|
||||
slate
|
||||
xs
|
||||
@ -89,10 +111,16 @@ const onCopy = async e => {
|
||||
/>
|
||||
</div>
|
||||
<highlightjs
|
||||
v-if="script"
|
||||
v-if="script && shouldShowScript"
|
||||
:language="lang"
|
||||
:code="scrubbedScript"
|
||||
class="[&_code]:text-start"
|
||||
/>
|
||||
<highlightjs
|
||||
v-else-if="script && secure && !isVisible"
|
||||
:language="lang"
|
||||
code="••••••••••••••••••••••••••••••••"
|
||||
class="[&_code]:text-start"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -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,6 +1,7 @@
|
||||
<script>
|
||||
import AutomationActionTeamMessageInput from './AutomationActionTeamMessageInput.vue';
|
||||
import AutomationActionFileInput from './AutomationFileInput.vue';
|
||||
import AutomationActionScheduledMessageInput from './AutomationActionScheduledMessageInput.vue';
|
||||
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import SingleSelect from 'dashboard/components-next/filter/inputs/SingleSelect.vue';
|
||||
@ -11,6 +12,7 @@ export default {
|
||||
components: {
|
||||
AutomationActionTeamMessageInput,
|
||||
AutomationActionFileInput,
|
||||
AutomationActionScheduledMessageInput,
|
||||
WootMessageEditor,
|
||||
NextButton,
|
||||
SingleSelect,
|
||||
@ -50,6 +52,10 @@ export default {
|
||||
type: String,
|
||||
default: 'max-h-80',
|
||||
},
|
||||
conditions: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
emits: ['update:modelValue', 'input', 'removeAction', 'resetAction'],
|
||||
computed: {
|
||||
@ -98,9 +104,10 @@ export default {
|
||||
castMessageVmodel: {
|
||||
get() {
|
||||
if (Array.isArray(this.action_params)) {
|
||||
return this.action_params[0];
|
||||
const value = this.action_params[0];
|
||||
return typeof value === 'string' ? value : '';
|
||||
}
|
||||
return this.action_params;
|
||||
return typeof this.action_params === 'string' ? this.action_params : '';
|
||||
},
|
||||
set(value) {
|
||||
this.action_params = value;
|
||||
@ -194,6 +201,12 @@ export default {
|
||||
:placeholder="$t('AUTOMATION.ACTION.TEAM_MESSAGE_INPUT_PLACEHOLDER')"
|
||||
class="[&_.ProseMirror-menubar]:hidden px-3 py-1 bg-n-alpha-1 rounded-lg outline outline-1 outline-n-weak dark:outline-n-strong"
|
||||
/>
|
||||
<AutomationActionScheduledMessageInput
|
||||
v-if="inputType === 'scheduled_message'"
|
||||
v-model="action_params"
|
||||
:initial-file-name="initialFileName"
|
||||
:conditions="conditions"
|
||||
/>
|
||||
</div>
|
||||
<span v-if="errorMessage" class="text-sm text-n-ruby-11">
|
||||
{{ errorMessage }}
|
||||
|
||||
@ -0,0 +1,314 @@
|
||||
<script setup>
|
||||
import { computed, ref, onBeforeMount } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import FileUpload from 'vue-upload-component';
|
||||
|
||||
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
|
||||
import DurationInput from 'dashboard/components-next/input/DurationInput.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import WhatsappTemplates from 'dashboard/components/widgets/conversation/WhatsappTemplates/Modal.vue';
|
||||
import { DURATION_UNITS } from 'dashboard/components-next/input/constants';
|
||||
import { DEFAULT_SCHEDULED_MESSAGE_DELAY_MINUTES } from 'dashboard/routes/dashboard/settings/automation/constants.js';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: [Object, Array],
|
||||
default: () => ({}),
|
||||
},
|
||||
initialFileName: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
conditions: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
const MAX_DELAY_MINUTES = 999 * 24 * 60; // 999 days
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
|
||||
const showWhatsAppTemplatesModal = ref(false);
|
||||
|
||||
const normalizedParams = computed(() => {
|
||||
const value = props.modelValue;
|
||||
if (Array.isArray(value)) {
|
||||
const first = value[0];
|
||||
return typeof first === 'object' && first !== null ? first : {};
|
||||
}
|
||||
return typeof value === 'object' && value !== null ? value : {};
|
||||
});
|
||||
|
||||
const updateParams = updates => {
|
||||
const newParams = { ...normalizedParams.value, ...updates };
|
||||
emit('update:modelValue', [newParams]);
|
||||
};
|
||||
|
||||
const content = computed({
|
||||
get: () => {
|
||||
const value = normalizedParams.value.content;
|
||||
return typeof value === 'string' ? value : '';
|
||||
},
|
||||
set: value => updateParams({ content: value }),
|
||||
});
|
||||
|
||||
const delayMinutes = computed({
|
||||
get: () =>
|
||||
normalizedParams.value.delay_minutes ??
|
||||
DEFAULT_SCHEDULED_MESSAGE_DELAY_MINUTES,
|
||||
set: value => {
|
||||
const numValue = Math.min(
|
||||
Math.max(1, Number(value) || 1),
|
||||
MAX_DELAY_MINUTES
|
||||
);
|
||||
updateParams({ delay_minutes: numValue });
|
||||
},
|
||||
});
|
||||
|
||||
const delayUnit = ref(DURATION_UNITS.MINUTES);
|
||||
|
||||
const detectUnit = minutes => {
|
||||
const m = Number(minutes) || 0;
|
||||
if (m === 0) return DURATION_UNITS.DAYS;
|
||||
if (m % (24 * 60) === 0) return DURATION_UNITS.DAYS;
|
||||
if (m % 60 === 0) return DURATION_UNITS.HOURS;
|
||||
return DURATION_UNITS.MINUTES;
|
||||
};
|
||||
|
||||
onBeforeMount(() => {
|
||||
// Normalize delay_minutes for existing automations with invalid/out-of-range values
|
||||
// For new actions, resetAction in useAutomation.js sets the default
|
||||
const rawDelay =
|
||||
normalizedParams.value.delay_minutes ??
|
||||
DEFAULT_SCHEDULED_MESSAGE_DELAY_MINUTES;
|
||||
const clampedDelay = Math.min(
|
||||
Math.max(1, Number(rawDelay) || 1),
|
||||
MAX_DELAY_MINUTES
|
||||
);
|
||||
|
||||
// Only emit if the value needs normalization to avoid unnecessary updates
|
||||
if (clampedDelay !== normalizedParams.value.delay_minutes) {
|
||||
updateParams({ delay_minutes: clampedDelay });
|
||||
}
|
||||
|
||||
delayUnit.value = detectUnit(clampedDelay);
|
||||
});
|
||||
|
||||
// Attachment handling
|
||||
const attachmentState = ref('idle'); // 'idle' | 'uploading' | 'uploaded' | 'failed'
|
||||
const attachmentFileName = ref(props.initialFileName || '');
|
||||
|
||||
const hasAttachment = computed(() => {
|
||||
const blobId = normalizedParams.value.blob_id;
|
||||
return !!blobId;
|
||||
});
|
||||
|
||||
const attachmentLabel = computed(() => {
|
||||
if (attachmentState.value === 'uploading') {
|
||||
return t('AUTOMATION.ATTACHMENT.LABEL_UPLOADING');
|
||||
}
|
||||
if (attachmentFileName.value) {
|
||||
return attachmentFileName.value;
|
||||
}
|
||||
return t('AUTOMATION.ATTACHMENT.LABEL_IDLE');
|
||||
});
|
||||
|
||||
const onFileUpload = async file => {
|
||||
if (!file?.file) return;
|
||||
|
||||
attachmentState.value = 'uploading';
|
||||
try {
|
||||
const id = await store.dispatch('automations/uploadAttachment', file.file);
|
||||
updateParams({ blob_id: id });
|
||||
attachmentState.value = 'uploaded';
|
||||
attachmentFileName.value = file.file.name;
|
||||
} catch {
|
||||
attachmentState.value = 'failed';
|
||||
useAlert(t('AUTOMATION.ATTACHMENT.UPLOAD_ERROR'));
|
||||
}
|
||||
};
|
||||
|
||||
const clearAttachment = () => {
|
||||
updateParams({ blob_id: null });
|
||||
attachmentState.value = 'idle';
|
||||
attachmentFileName.value = '';
|
||||
};
|
||||
|
||||
// Template params handling
|
||||
const templateParams = computed(
|
||||
() => normalizedParams.value.template_params || null
|
||||
);
|
||||
|
||||
const hasTemplate = computed(
|
||||
() => templateParams.value && Object.keys(templateParams.value).length > 0
|
||||
);
|
||||
|
||||
const templateName = computed(() => {
|
||||
return templateParams.value?.name || templateParams.value?.id || null;
|
||||
});
|
||||
|
||||
// Extract inbox IDs from conditions
|
||||
const inboxIdsFromConditions = computed(() => {
|
||||
const inboxConditions = props.conditions.filter(
|
||||
condition => condition.attribute_key === 'inbox_id'
|
||||
);
|
||||
const ids = [];
|
||||
inboxConditions.forEach(condition => {
|
||||
const values = condition.values;
|
||||
if (Array.isArray(values)) {
|
||||
values.forEach(v => {
|
||||
const id = typeof v === 'object' ? v.id : v;
|
||||
if (id) ids.push(Number(id));
|
||||
});
|
||||
} else if (values) {
|
||||
const id = typeof values === 'object' ? values.id : values;
|
||||
if (id) ids.push(Number(id));
|
||||
}
|
||||
});
|
||||
return ids;
|
||||
});
|
||||
|
||||
// Get the first inbox ID that has templates (for the modal)
|
||||
const inboxIdForTemplates = computed(() => {
|
||||
const inboxWithTemplates = inboxIdsFromConditions.value.find(inboxId => {
|
||||
const templates =
|
||||
store.getters['inboxes/getWhatsAppTemplates'](inboxId) || [];
|
||||
return templates.length > 0;
|
||||
});
|
||||
return inboxWithTemplates ?? null;
|
||||
});
|
||||
|
||||
// Check if any inbox has WhatsApp templates
|
||||
const showWhatsappTemplates = computed(() => {
|
||||
return inboxIdForTemplates.value !== null;
|
||||
});
|
||||
|
||||
// Show action buttons only when no attachment, no template, and not uploading
|
||||
const isUploading = computed(() => attachmentState.value === 'uploading');
|
||||
const showActionButtons = computed(
|
||||
() => !hasAttachment.value && !hasTemplate.value && !isUploading.value
|
||||
);
|
||||
|
||||
const openWhatsAppTemplatesModal = () => {
|
||||
showWhatsAppTemplatesModal.value = true;
|
||||
};
|
||||
|
||||
const hideWhatsAppTemplatesModal = () => {
|
||||
showWhatsAppTemplatesModal.value = false;
|
||||
};
|
||||
|
||||
const onTemplateSelect = messagePayload => {
|
||||
updateParams({
|
||||
template_params: messagePayload.templateParams,
|
||||
content: messagePayload.message,
|
||||
});
|
||||
hideWhatsAppTemplatesModal();
|
||||
};
|
||||
|
||||
const clearTemplate = () => {
|
||||
updateParams({
|
||||
template_params: null,
|
||||
content: '',
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mt-2 flex flex-col gap-1">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs text-n-slate-11">
|
||||
{{ $t('AUTOMATION.ACTION.SCHEDULED_MESSAGE_DELAY_LABEL') }}
|
||||
</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- allow 1 min to 999 days -->
|
||||
<DurationInput
|
||||
v-model:model-value="delayMinutes"
|
||||
v-model:unit="delayUnit"
|
||||
:min="1"
|
||||
:max="MAX_DELAY_MINUTES"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<WootMessageEditor
|
||||
v-model="content"
|
||||
rows="4"
|
||||
enable-variables
|
||||
:placeholder="$t('AUTOMATION.ACTION.TEAM_MESSAGE_INPUT_PLACEHOLDER')"
|
||||
class="action-message"
|
||||
:class="hasTemplate ? 'opacity-60 cursor-not-allowed' : ''"
|
||||
:disabled="hasTemplate"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="isUploading"
|
||||
class="flex items-center gap-2 text-xs text-n-slate-11"
|
||||
>
|
||||
<NextButton
|
||||
ghost
|
||||
xs
|
||||
icon="i-lucide-paperclip"
|
||||
:label="t('AUTOMATION.ATTACHMENT.LABEL_UPLOADING')"
|
||||
is-loading
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="showActionButtons" class="flex items-center gap-2">
|
||||
<FileUpload
|
||||
:multiple="false"
|
||||
:maximum="1"
|
||||
class="cursor-pointer [&:hover_button]:bg-n-alpha-2 [&:hover_button]:text-n-slate-12"
|
||||
@input-file="onFileUpload"
|
||||
>
|
||||
<NextButton
|
||||
ghost
|
||||
xs
|
||||
icon="i-lucide-paperclip"
|
||||
:label="t('AUTOMATION.ACTION.ATTACHMENT_ADD')"
|
||||
class="pointer-events-none"
|
||||
/>
|
||||
</FileUpload>
|
||||
<NextButton
|
||||
v-if="showWhatsappTemplates"
|
||||
ghost
|
||||
xs
|
||||
icon="i-lucide-zap"
|
||||
:label="t('AUTOMATION.ACTION.TEMPLATE_SELECT')"
|
||||
@click="openWhatsAppTemplatesModal"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="hasAttachment"
|
||||
class="flex items-center gap-2 text-xs text-n-slate-11"
|
||||
>
|
||||
<span>{{ attachmentLabel }}</span>
|
||||
<NextButton ghost xs slate icon="i-lucide-x" @click="clearAttachment" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="hasTemplate"
|
||||
class="flex items-center gap-2 text-xs text-n-slate-11"
|
||||
>
|
||||
<span>
|
||||
{{ t('AUTOMATION.ACTION.TEMPLATE_SELECTED', { name: templateName }) }}
|
||||
</span>
|
||||
<NextButton ghost xs slate icon="i-lucide-x" @click="clearTemplate" />
|
||||
</div>
|
||||
|
||||
<WhatsappTemplates
|
||||
v-model:show="showWhatsAppTemplatesModal"
|
||||
:inbox-id="inboxIdForTemplates"
|
||||
:send-button-label="t('AUTOMATION.ACTION.TEMPLATE_USE')"
|
||||
@on-send="onTemplateSelect"
|
||||
@cancel="hideWhatsAppTemplatesModal"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@ -36,7 +36,8 @@ export default {
|
||||
);
|
||||
this.$emit('update:modelValue', [id]);
|
||||
this.uploadState = 'uploaded';
|
||||
this.label = this.$t('AUTOMATION.ATTACHMENT.LABEL_UPLOADED');
|
||||
this.label =
|
||||
file?.name || this.$t('AUTOMATION.ATTACHMENT.LABEL_UPLOADED');
|
||||
} catch (error) {
|
||||
this.uploadState = 'failed';
|
||||
this.label = this.$t('AUTOMATION.ATTACHMENT.LABEL_UPLOAD_FAILED');
|
||||
|
||||
@ -25,7 +25,7 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
hasOpenedAtleastOnce: false,
|
||||
hasOpenedAtleastOnce: this.isVisible,
|
||||
iframeLoading: true,
|
||||
};
|
||||
},
|
||||
@ -46,8 +46,8 @@ export default {
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
isVisible() {
|
||||
if (this.isVisible) {
|
||||
isVisible(value) {
|
||||
if (value) {
|
||||
this.hasOpenedAtleastOnce = true;
|
||||
}
|
||||
},
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -25,7 +25,8 @@ import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import { useTrack } from 'dashboard/composables';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import {
|
||||
@ -50,11 +51,9 @@ import {
|
||||
} from '@chatwoot/prosemirror-schema/src/mentions/plugin';
|
||||
|
||||
import {
|
||||
appendSignature,
|
||||
findNodeToInsertImage,
|
||||
getContentNode,
|
||||
insertAtCursor,
|
||||
removeSignature as removeSignatureHelper,
|
||||
scrollCursorIntoView,
|
||||
setURLWithQueryAndSize,
|
||||
getFormattingForEditor,
|
||||
@ -166,6 +165,10 @@ const createState = (content, placeholder, plugins = [], methods = {}) => {
|
||||
const { isEditorHotKeyEnabled, fetchSignatureFlagFromUISettings } =
|
||||
useUISettings();
|
||||
|
||||
const { formatMessage } = useMessageFormatter();
|
||||
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
|
||||
const typingIndicator = createTypingIndicator(
|
||||
() => emit('typingOn'),
|
||||
() => emit('typingOff'),
|
||||
@ -306,8 +309,7 @@ const plugins = computed(() => {
|
||||
});
|
||||
|
||||
const sendWithSignature = computed(() => {
|
||||
// this is considered the source of truth, we watch this property
|
||||
// on change, we toggle the signature in the editor
|
||||
// this is considered the source of truth for signature display
|
||||
if (props.allowSignature && !props.isPrivate && props.channelType) {
|
||||
return fetchSignatureFlagFromUISettings(props.channelType);
|
||||
}
|
||||
@ -315,6 +317,23 @@ const sendWithSignature = computed(() => {
|
||||
return false;
|
||||
});
|
||||
|
||||
const signaturePosition = computed(() => {
|
||||
return currentUser.value?.ui_settings?.signature_position || 'top';
|
||||
});
|
||||
|
||||
const signatureSeparator = computed(() => {
|
||||
return currentUser.value?.ui_settings?.signature_separator || 'blank';
|
||||
});
|
||||
|
||||
const shouldShowSignaturePreview = computed(() => {
|
||||
return sendWithSignature.value && props.signature;
|
||||
});
|
||||
|
||||
const formattedSignature = computed(() => {
|
||||
if (!props.signature) return '';
|
||||
return formatMessage(props.signature, false, false);
|
||||
});
|
||||
|
||||
watch(showUserMentions, updatedValue => {
|
||||
emit('toggleUserMention', props.isPrivate && updatedValue);
|
||||
});
|
||||
@ -331,6 +350,8 @@ watch(showToolsMenu, updatedValue => {
|
||||
function focusEditorInputField(pos = 'end') {
|
||||
const { tr } = editorView.state;
|
||||
|
||||
// Signature is now displayed as read-only preview outside the editor,
|
||||
// so cursor positioning is straightforward
|
||||
const selection =
|
||||
pos === 'end' ? Selection.atEnd(tr.doc) : Selection.atStart(tr.doc);
|
||||
|
||||
@ -342,19 +363,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
|
||||
// Use effective channel type to match how signature was appended
|
||||
const bodyWithoutSignature = props.signature
|
||||
? removeSignatureHelper(
|
||||
content,
|
||||
props.signature,
|
||||
effectiveChannelType.value
|
||||
)
|
||||
: content;
|
||||
|
||||
// trimming should remove all the whitespaces, so we can check the length
|
||||
return bodyWithoutSignature.trim().length === 0;
|
||||
return content.trim().length === 0;
|
||||
}
|
||||
|
||||
function handleEmptyBodyWithSignature() {
|
||||
@ -430,47 +440,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,
|
||||
effectiveChannelType.value
|
||||
);
|
||||
// need to reload first, ensuring that the editorView is updated
|
||||
reloadState(content);
|
||||
|
||||
if (contentWasEmpty) {
|
||||
handleEmptyBodyWithSignature();
|
||||
}
|
||||
}
|
||||
|
||||
function removeSignature() {
|
||||
if (!props.signature) return;
|
||||
let content = props.modelValue;
|
||||
content = removeSignatureHelper(
|
||||
content,
|
||||
props.signature,
|
||||
effectiveChannelType.value
|
||||
);
|
||||
// reload the state, ensuring that the editorView is updated
|
||||
reloadState(content);
|
||||
}
|
||||
|
||||
function toggleSignatureInEditor(signatureEnabled) {
|
||||
// The toggleSignatureInEditor gets the new value from the
|
||||
// watcher, this means that if the value is true, the signature
|
||||
// is supposed to be added, else we remove it.
|
||||
if (signatureEnabled) {
|
||||
addSignature();
|
||||
} else {
|
||||
removeSignature();
|
||||
}
|
||||
}
|
||||
|
||||
function setToolbarPosition() {
|
||||
const editorRect = editorRoot.value.getBoundingClientRect();
|
||||
const rect = selectedImageNode.value.getBoundingClientRect();
|
||||
@ -721,7 +690,11 @@ function createEditorView() {
|
||||
handleDOMEvents: {
|
||||
keyup: () => {
|
||||
if (!props.disabled) {
|
||||
if (props.modelValue.length) {
|
||||
typingIndicator.start();
|
||||
} else {
|
||||
typingIndicator.stop();
|
||||
}
|
||||
updateImgToolbarOnDelete();
|
||||
}
|
||||
},
|
||||
@ -798,13 +771,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(
|
||||
@ -877,7 +843,33 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
||||
hidden
|
||||
@change="onFileChange"
|
||||
/>
|
||||
<!-- Signature preview at top -->
|
||||
<div
|
||||
v-if="shouldShowSignaturePreview && signaturePosition === 'top'"
|
||||
class="signature-preview signature-preview--top"
|
||||
>
|
||||
<div class="signature-label">
|
||||
{{ t('CONVERSATION.FOOTER.SIGNATURE_LABEL_TOP') }}
|
||||
</div>
|
||||
<div v-dompurify-html="formattedSignature" class="signature-content" />
|
||||
<div v-if="signatureSeparator === '--'" class="signature-separator">
|
||||
{{ signatureSeparator }}
|
||||
</div>
|
||||
</div>
|
||||
<div ref="editor" />
|
||||
<!-- Signature preview at bottom -->
|
||||
<div
|
||||
v-if="shouldShowSignaturePreview && signaturePosition === 'bottom'"
|
||||
class="signature-preview signature-preview--bottom"
|
||||
>
|
||||
<div class="signature-label">
|
||||
{{ t('CONVERSATION.FOOTER.SIGNATURE_LABEL_BOTTOM') }}
|
||||
</div>
|
||||
<div v-if="signatureSeparator === '--'" class="signature-separator">
|
||||
{{ signatureSeparator }}
|
||||
</div>
|
||||
<div v-dompurify-html="formattedSignature" class="signature-content" />
|
||||
</div>
|
||||
<div
|
||||
v-show="isImageNodeSelected && showImageResizeToolbar"
|
||||
class="absolute shadow-md rounded-[6px] flex gap-1 py-1 px-1 bg-n-solid-3 outline outline-1 outline-n-weak text-n-slate-12"
|
||||
@ -902,6 +894,42 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
||||
<style lang="scss">
|
||||
@import '@chatwoot/prosemirror-schema/src/styles/base.scss';
|
||||
|
||||
.signature-preview {
|
||||
@apply px-1 py-1 text-n-slate-10 text-sm pointer-events-none select-none opacity-70;
|
||||
|
||||
&--top {
|
||||
@apply border-b border-n-weak pb-1;
|
||||
|
||||
.signature-separator {
|
||||
@apply text-n-slate-9 mt-1 mb-0;
|
||||
}
|
||||
}
|
||||
|
||||
&--bottom {
|
||||
@apply border-t border-n-weak pt-1 mt-2;
|
||||
|
||||
.signature-separator {
|
||||
@apply text-n-slate-9 mb-1 mt-0;
|
||||
}
|
||||
}
|
||||
|
||||
.signature-label {
|
||||
@apply text-xs text-n-slate-9 mb-1;
|
||||
}
|
||||
|
||||
.signature-content {
|
||||
@apply break-words;
|
||||
|
||||
:deep(p) {
|
||||
@apply m-0 text-n-slate-10;
|
||||
}
|
||||
|
||||
:deep(a) {
|
||||
@apply text-n-slate-10 no-underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ProseMirror-menubar-wrapper {
|
||||
@apply flex flex-col gap-3;
|
||||
|
||||
|
||||
@ -12,10 +12,22 @@ import VideoCallButton from '../VideoCallButton.vue';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
import { mapGetters } from 'vuex';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import DropdownContainer from 'next/dropdown-menu/base/DropdownContainer.vue';
|
||||
import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
|
||||
import DropdownSection from 'next/dropdown-menu/base/DropdownSection.vue';
|
||||
import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue';
|
||||
|
||||
export default {
|
||||
name: 'ReplyBottomPanel',
|
||||
components: { NextButton, FileUpload, VideoCallButton },
|
||||
components: {
|
||||
NextButton,
|
||||
FileUpload,
|
||||
VideoCallButton,
|
||||
DropdownContainer,
|
||||
DropdownBody,
|
||||
DropdownSection,
|
||||
DropdownItem,
|
||||
},
|
||||
mixins: [inboxMixin],
|
||||
props: {
|
||||
isNote: {
|
||||
@ -126,6 +138,10 @@ export default {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showScheduleOptions: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: [
|
||||
'replaceText',
|
||||
@ -133,6 +149,7 @@ export default {
|
||||
'selectWhatsappTemplate',
|
||||
'selectContentTemplate',
|
||||
'toggleQuotedReply',
|
||||
'scheduleMessage',
|
||||
],
|
||||
setup(props) {
|
||||
const { setSignatureFlagForInbox, fetchSignatureFlagFromUISettings } =
|
||||
@ -283,6 +300,9 @@ export default {
|
||||
toggleInsertArticle() {
|
||||
this.$emit('toggleInsertArticle');
|
||||
},
|
||||
openScheduleModal() {
|
||||
this.$emit('scheduleMessage');
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@ -346,7 +366,7 @@ export default {
|
||||
v-if="showMessageSignatureButton"
|
||||
v-tooltip.top-end="signatureToggleTooltip"
|
||||
icon="i-ph-signature"
|
||||
slate
|
||||
:color="sendWithSignature ? 'blue' : 'slate'"
|
||||
faded
|
||||
sm
|
||||
@click="toggleMessageSignature"
|
||||
@ -405,7 +425,42 @@ export default {
|
||||
/>
|
||||
</div>
|
||||
<div class="right-wrap">
|
||||
<div v-if="showScheduleOptions && !isNote" class="flex">
|
||||
<NextButton
|
||||
:label="sendButtonText"
|
||||
type="submit"
|
||||
sm
|
||||
blue
|
||||
:disabled="isSendDisabled"
|
||||
class="flex-shrink-0 !rounded-r-none"
|
||||
@click="onSend"
|
||||
/>
|
||||
<DropdownContainer>
|
||||
<template #trigger="{ toggle, isOpen }">
|
||||
<NextButton
|
||||
type="button"
|
||||
sm
|
||||
blue
|
||||
icon="i-lucide-chevron-down"
|
||||
:disabled="isSendDisabled"
|
||||
class="flex-shrink-0 !rounded-l-none !border-l border-l-white/20 !px-1.5"
|
||||
:class="{ 'bg-n-blue-11': isOpen }"
|
||||
@click="toggle"
|
||||
/>
|
||||
</template>
|
||||
<DropdownBody class="bottom-11 -right-8 min-w-48 z-50" strong>
|
||||
<DropdownSection>
|
||||
<DropdownItem
|
||||
icon="i-lucide-clock"
|
||||
:label="$t('CONVERSATION.REPLYBOX.SCHEDULE_SEND')"
|
||||
:click="openScheduleModal"
|
||||
/>
|
||||
</DropdownSection>
|
||||
</DropdownBody>
|
||||
</DropdownContainer>
|
||||
</div>
|
||||
<NextButton
|
||||
v-else
|
||||
:label="sendButtonText"
|
||||
type="submit"
|
||||
sm
|
||||
|
||||
@ -39,6 +39,9 @@ export default {
|
||||
currentChat: 'getSelectedChat',
|
||||
dashboardApps: 'dashboardApps/getRecords',
|
||||
}),
|
||||
conversationDashboardApps() {
|
||||
return this.dashboardApps.filter(app => !app.show_on_sidebar);
|
||||
},
|
||||
dashboardAppTabs() {
|
||||
return [
|
||||
{
|
||||
@ -46,7 +49,7 @@ export default {
|
||||
index: 0,
|
||||
name: this.$t('CONVERSATION.DASHBOARD_APP_TAB_MESSAGES'),
|
||||
},
|
||||
...this.dashboardApps.map((dashboardApp, index) => ({
|
||||
...this.conversationDashboardApps.map((dashboardApp, index) => ({
|
||||
key: `dashboard-${dashboardApp.id}`,
|
||||
index: index + 1,
|
||||
name: dashboardApp.title,
|
||||
@ -102,7 +105,7 @@ export default {
|
||||
:show-back-button="isOnExpandedLayout && !isInboxView"
|
||||
/>
|
||||
<woot-tabs
|
||||
v-if="dashboardApps.length && currentChat.id"
|
||||
v-if="conversationDashboardApps.length && currentChat.id"
|
||||
:index="activeIndex"
|
||||
class="h-10"
|
||||
@change="onDashboardAppTabChange"
|
||||
@ -129,11 +132,11 @@ export default {
|
||||
<slot />
|
||||
</div>
|
||||
<DashboardAppFrame
|
||||
v-for="(dashboardApp, index) in dashboardApps"
|
||||
v-for="(dashboardApp, index) in conversationDashboardApps"
|
||||
v-show="activeIndex - 1 === index"
|
||||
:key="currentChat.id + '-' + dashboardApp.id"
|
||||
:is-visible="activeIndex - 1 === index"
|
||||
:config="dashboardApps[index].content"
|
||||
:config="conversationDashboardApps[index].content"
|
||||
:position="index"
|
||||
:current-chat="currentChat"
|
||||
/>
|
||||
|
||||
@ -4,6 +4,8 @@ import { ref, provide } from 'vue';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import { useLabelSuggestions } from 'dashboard/composables/useLabelSuggestions';
|
||||
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
|
||||
import { useAdmin } from 'dashboard/composables/useAdmin';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
|
||||
// components
|
||||
import ReplyBox from './ReplyBox.vue';
|
||||
@ -35,6 +37,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 WhatsappLinkDeviceModal from '../../../routes/dashboard/settings/inbox/components/WhatsappLinkDeviceModal.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@ -43,9 +46,11 @@ export default {
|
||||
Banner,
|
||||
ConversationLabelSuggestion,
|
||||
Spinner,
|
||||
WhatsappLinkDeviceModal,
|
||||
},
|
||||
mixins: [inboxMixin],
|
||||
setup() {
|
||||
const { isAdmin } = useAdmin();
|
||||
const isPopOutReplyBox = ref(false);
|
||||
const conversationPanelRef = ref(null);
|
||||
|
||||
@ -73,6 +78,7 @@ export default {
|
||||
getLabelSuggestions,
|
||||
isLabelSuggestionFeatureEnabled,
|
||||
conversationPanelRef,
|
||||
isAdmin,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
@ -84,6 +90,7 @@ export default {
|
||||
isProgrammaticScroll: false,
|
||||
messageSentSinceOpened: false,
|
||||
labelSuggestions: [],
|
||||
showLinkDeviceModal: false,
|
||||
};
|
||||
},
|
||||
|
||||
@ -94,6 +101,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;
|
||||
},
|
||||
@ -244,6 +254,13 @@ export default {
|
||||
|
||||
return { incoming, outgoing };
|
||||
},
|
||||
inboxSupportsEdit() {
|
||||
// Currently only Baileys WhatsApp channel supports message editing
|
||||
return this.isAWhatsAppBaileysChannel;
|
||||
},
|
||||
inboxProviderConnection() {
|
||||
return this.currentInbox.provider_connection?.connection;
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
@ -437,12 +454,75 @@ 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;
|
||||
});
|
||||
},
|
||||
onOpenLinkDeviceModal() {
|
||||
this.showLinkDeviceModal = true;
|
||||
},
|
||||
onCloseLinkDeviceModal() {
|
||||
this.showLinkDeviceModal = false;
|
||||
},
|
||||
onSetupProviderConnection() {
|
||||
this.$store
|
||||
.dispatch('inboxes/setupChannelProvider', this.inbox.id)
|
||||
.catch(e => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Error setting up provider connection:', e);
|
||||
useAlert(
|
||||
this.$t(
|
||||
'CONVERSATION.INBOX.WHATSAPP_PROVIDER_CONNECTION.RECONNECT_FAILED'
|
||||
)
|
||||
);
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col justify-between flex-grow h-full min-w-0 m-0">
|
||||
<template v-if="isAWhatsAppBaileysChannel || isAWhatsAppZapiChannel">
|
||||
<WhatsappLinkDeviceModal
|
||||
v-if="showLinkDeviceModal"
|
||||
:show="showLinkDeviceModal"
|
||||
:on-close="onCloseLinkDeviceModal"
|
||||
:inbox="currentInbox"
|
||||
/>
|
||||
<Banner
|
||||
v-if="inboxProviderConnection !== 'open'"
|
||||
color-scheme="alert"
|
||||
class="mt-2 mx-2 rounded-lg overflow-hidden"
|
||||
:banner-message="
|
||||
isAdmin
|
||||
? $t(
|
||||
'CONVERSATION.INBOX.WHATSAPP_PROVIDER_CONNECTION.NOT_CONNECTED'
|
||||
)
|
||||
: $t(
|
||||
'CONVERSATION.INBOX.WHATSAPP_PROVIDER_CONNECTION.NOT_CONNECTED_CONTACT_ADMIN'
|
||||
)
|
||||
"
|
||||
has-action-button
|
||||
:action-button-label="
|
||||
isAdmin
|
||||
? $t('CONVERSATION.INBOX.WHATSAPP_PROVIDER_CONNECTION.LINK_DEVICE')
|
||||
: ''
|
||||
"
|
||||
:action-button-icon="isAdmin ? '' : 'i-lucide-refresh-cw'"
|
||||
@primary-action="
|
||||
isAdmin ? onOpenLinkDeviceModal() : onSetupProviderConnection()
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
<Banner
|
||||
v-if="!currentChat.can_reply"
|
||||
color-scheme="alert"
|
||||
@ -464,6 +544,7 @@ export default {
|
||||
:first-unread-id="unReadMessages[0]?.id"
|
||||
:is-an-email-channel="isAnEmailChannel"
|
||||
:inbox-supports-reply-to="inboxSupportsReplyTo"
|
||||
:inbox-supports-edit="inboxSupportsEdit"
|
||||
:messages="getMessages"
|
||||
@retry="handleMessageRetry"
|
||||
>
|
||||
|
||||
@ -4,6 +4,7 @@ import { mapGetters } from 'vuex';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import { useTrack } from 'dashboard/composables';
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
|
||||
@ -31,6 +32,7 @@ import {
|
||||
} from '@chatwoot/utils';
|
||||
import WhatsappTemplates from './WhatsappTemplates/Modal.vue';
|
||||
import ContentTemplates from './ContentTemplates/ContentTemplatesModal.vue';
|
||||
import ScheduledMessageModal from 'dashboard/routes/dashboard/conversation/scheduledMessages/ScheduledMessageModal.vue';
|
||||
import { MESSAGE_MAX_LENGTH } from 'shared/helpers/MessageTypeHelper';
|
||||
import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin';
|
||||
import { trimContent, debounce, getRecipients } from '@chatwoot/utils';
|
||||
@ -46,11 +48,7 @@ import {
|
||||
CAPTAIN_EVENTS,
|
||||
} from '../../../helper/AnalyticsHelper/events';
|
||||
import fileUploadMixin from 'dashboard/mixins/fileUploadMixin';
|
||||
import {
|
||||
appendSignature,
|
||||
removeSignature,
|
||||
getEffectiveChannelType,
|
||||
} from 'dashboard/helper/editorHelper';
|
||||
import { appendSignature } from 'dashboard/helper/editorHelper';
|
||||
import { useCopilotReply } from 'dashboard/composables/useCopilotReply';
|
||||
import { useKbd } from 'dashboard/composables/utils/useKbd';
|
||||
import { isFileTypeAllowedForChannel } from 'shared/helpers/FileHelper';
|
||||
@ -80,6 +78,7 @@ export default {
|
||||
QuotedEmailPreview,
|
||||
CopilotEditorSection,
|
||||
CopilotReplyBottomPanel,
|
||||
ScheduledMessageModal,
|
||||
},
|
||||
mixins: [inboxMixin, fileUploadMixin, keyboardEventListenerMixins],
|
||||
props: {
|
||||
@ -98,6 +97,8 @@ export default {
|
||||
fetchQuotedReplyFlagFromUISettings,
|
||||
} = useUISettings();
|
||||
|
||||
const { formatMessage } = useMessageFormatter();
|
||||
|
||||
const replyEditor = useTemplateRef('replyEditor');
|
||||
const copilot = useCopilotReply();
|
||||
const shortcutKey = useKbd(['$mod', '+', 'enter']);
|
||||
@ -111,6 +112,7 @@ export default {
|
||||
replyEditor,
|
||||
copilot,
|
||||
shortcutKey,
|
||||
formatMessage,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
@ -138,7 +140,7 @@ export default {
|
||||
showVariablesMenu: false,
|
||||
newConversationModalActive: false,
|
||||
showArticleSearchPopover: false,
|
||||
hasRecordedAudio: false,
|
||||
showScheduledMessageModal: false,
|
||||
copilotAcceptedMessages: {},
|
||||
};
|
||||
},
|
||||
@ -301,6 +303,9 @@ export default {
|
||||
hasAttachments() {
|
||||
return this.attachedFiles.length;
|
||||
},
|
||||
hasRecordedAudio() {
|
||||
return this.attachedFiles.some(file => file.isRecordedAudio);
|
||||
},
|
||||
showAudioRecorder() {
|
||||
return !this.isOnPrivateNote && this.showFileUpload;
|
||||
},
|
||||
@ -431,6 +436,25 @@ export default {
|
||||
!this.currentChat.can_reply
|
||||
);
|
||||
},
|
||||
// Signature preview for non-rich editor (WhatsApp, etc.)
|
||||
shouldShowSignaturePreview() {
|
||||
return (
|
||||
this.sendWithSignature &&
|
||||
this.messageSignature &&
|
||||
!this.isPrivate &&
|
||||
!this.showRichContentEditor
|
||||
);
|
||||
},
|
||||
signaturePosition() {
|
||||
return this.currentUser?.ui_settings?.signature_position || 'top';
|
||||
},
|
||||
signatureSeparator() {
|
||||
return this.currentUser?.ui_settings?.signature_separator || 'blank';
|
||||
},
|
||||
formattedSignature() {
|
||||
if (!this.messageSignature) return '';
|
||||
return this.formatMessage(this.messageSignature, false, false);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
currentChat(conversation, oldConversation) {
|
||||
@ -612,24 +636,9 @@ export default {
|
||||
const key = this.getDraftKey();
|
||||
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;
|
||||
}
|
||||
|
||||
const effectiveChannelType = getEffectiveChannelType(
|
||||
this.channelType,
|
||||
this.inbox?.medium || ''
|
||||
);
|
||||
return this.sendWithSignature
|
||||
? appendSignature(message, this.messageSignature, effectiveChannelType)
|
||||
: removeSignature(message, this.messageSignature, effectiveChannelType);
|
||||
},
|
||||
removeFromDraft() {
|
||||
if (this.conversationIdByRoute) {
|
||||
const key = this.getDraftKey();
|
||||
@ -682,10 +691,23 @@ export default {
|
||||
!this.showMentions &&
|
||||
!this.showCannedMenu &&
|
||||
!this.showVariablesMenu &&
|
||||
!this.showScheduledMessageModal &&
|
||||
this.isFocused &&
|
||||
this.isEditorHotKeyEnabled(selectedKey)
|
||||
);
|
||||
},
|
||||
applySignatureToMessage(message) {
|
||||
if (!this.sendWithSignature || !this.messageSignature) {
|
||||
return message;
|
||||
}
|
||||
const { signature_position, signature_separator } =
|
||||
this.currentUser?.ui_settings || {};
|
||||
const signatureSettings = {
|
||||
position: signature_position || 'top',
|
||||
separator: signature_separator || 'blank',
|
||||
};
|
||||
return appendSignature(message, this.messageSignature, signatureSettings);
|
||||
},
|
||||
onPaste(e) {
|
||||
// Don't handle paste if compose new conversation modal is open
|
||||
if (this.newConversationModalActive) return;
|
||||
@ -693,6 +715,9 @@ export default {
|
||||
// Don't handle paste if editor is disabled
|
||||
if (this.isEditorDisabled) return;
|
||||
|
||||
// NOTE: Don't handle paste if scheduled message modal is open
|
||||
if (this.showScheduledMessageModal) return;
|
||||
|
||||
// Filter valid files (non-zero size)
|
||||
Array.from(e.clipboardData.files)
|
||||
.filter(file => file.size > 0)
|
||||
@ -741,6 +766,21 @@ export default {
|
||||
hideContentTemplatesModal() {
|
||||
this.showContentTemplatesModal = false;
|
||||
},
|
||||
openScheduledMessageModal() {
|
||||
this.showScheduledMessageModal = true;
|
||||
},
|
||||
closeScheduledMessageModal() {
|
||||
this.showScheduledMessageModal = false;
|
||||
},
|
||||
async onScheduledMessageCreated() {
|
||||
this.closeScheduledMessageModal();
|
||||
this.clearMessage();
|
||||
// NOTE: Open sidebar and expand scheduled messages card
|
||||
this.$store.dispatch('updateUISettings', {
|
||||
is_contact_sidebar_open: true,
|
||||
is_scheduled_messages_open: true,
|
||||
});
|
||||
},
|
||||
confirmOnSendReply() {
|
||||
if (this.isReplyButtonDisabled) {
|
||||
return;
|
||||
@ -792,23 +832,7 @@ export default {
|
||||
isPrivate,
|
||||
{ editorMessage = '', copilotAcceptedMessage = '' } = {}
|
||||
) {
|
||||
const normalizeForComparison = message => {
|
||||
let normalizedMessage = message || '';
|
||||
|
||||
if (this.sendWithSignature && this.messageSignature && !isPrivate) {
|
||||
const effectiveChannelType = getEffectiveChannelType(
|
||||
this.channelType,
|
||||
this.inbox?.medium || ''
|
||||
);
|
||||
normalizedMessage = removeSignature(
|
||||
normalizedMessage,
|
||||
this.messageSignature,
|
||||
effectiveChannelType
|
||||
);
|
||||
}
|
||||
|
||||
return trimContent(normalizedMessage);
|
||||
};
|
||||
const normalizeForComparison = message => trimContent(message || '');
|
||||
|
||||
const normalizedAcceptedMessage = normalizeForComparison(
|
||||
copilotAcceptedMessage
|
||||
@ -896,21 +920,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
|
||||
const effectiveChannelType = getEffectiveChannelType(
|
||||
this.channelType,
|
||||
this.inbox?.medium || ''
|
||||
);
|
||||
message = appendSignature(
|
||||
message,
|
||||
this.messageSignature,
|
||||
effectiveChannelType
|
||||
);
|
||||
}
|
||||
|
||||
const updatedMessage = replaceVariablesInMessage({
|
||||
message,
|
||||
variables: this.messageVariables,
|
||||
@ -949,18 +958,6 @@ export default {
|
||||
clearMessage() {
|
||||
this.message = '';
|
||||
this.clearCopilotAcceptedMessage();
|
||||
if (this.sendWithSignature && !this.isPrivate) {
|
||||
// if signature is enabled, append it to the message
|
||||
const effectiveChannelType = getEffectiveChannelType(
|
||||
this.channelType,
|
||||
this.inbox?.medium || ''
|
||||
);
|
||||
this.message = appendSignature(
|
||||
this.message,
|
||||
this.messageSignature,
|
||||
effectiveChannelType
|
||||
);
|
||||
}
|
||||
this.attachedFiles = [];
|
||||
this.isRecordingAudio = false;
|
||||
this.resetReplyToMessage();
|
||||
@ -979,6 +976,9 @@ export default {
|
||||
this.isRecordingAudio = !this.isRecordingAudio;
|
||||
if (!this.isRecordingAudio) {
|
||||
this.resetAudioRecorderInput();
|
||||
this.onTypingOff();
|
||||
} else {
|
||||
this.onRecording();
|
||||
}
|
||||
},
|
||||
toggleAudioRecorderPlayPause() {
|
||||
@ -986,6 +986,7 @@ export default {
|
||||
if (!this.recordingAudioState) {
|
||||
this.$refs.audioRecorderInput.stopRecording();
|
||||
} else {
|
||||
this.onTypingOff();
|
||||
this.$refs.audioRecorderInput.playPause();
|
||||
}
|
||||
},
|
||||
@ -997,6 +998,9 @@ export default {
|
||||
onTypingOn() {
|
||||
this.toggleTyping('on');
|
||||
},
|
||||
onRecording() {
|
||||
this.toggleTyping('recording');
|
||||
},
|
||||
onTypingOff() {
|
||||
this.toggleTyping('off');
|
||||
},
|
||||
@ -1012,7 +1016,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 = {
|
||||
@ -1036,6 +1042,10 @@ export default {
|
||||
});
|
||||
},
|
||||
attachFile({ blob, file }) {
|
||||
if (file?.isRecordedAudio) {
|
||||
this.removeRecordedAudio();
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file.file);
|
||||
reader.onloadend = () => {
|
||||
@ -1068,8 +1078,10 @@ export default {
|
||||
getMultipleMessagesPayload(message) {
|
||||
const multipleMessagePayload = [];
|
||||
|
||||
if (this.attachedFiles && this.attachedFiles.length) {
|
||||
let caption = this.isAnInstagramChannel ? '' : message;
|
||||
const messageWithSignature = this.applySignatureToMessage(message);
|
||||
|
||||
if (this.attachedFiles?.length) {
|
||||
let caption = this.isAnInstagramChannel ? '' : messageWithSignature;
|
||||
this.attachedFiles.forEach(attachment => {
|
||||
const attachedFile = this.globalConfig.directUploadsEnabled
|
||||
? attachment.blobSignedId
|
||||
@ -1089,8 +1101,7 @@ export default {
|
||||
});
|
||||
}
|
||||
|
||||
const hasNoAttachments =
|
||||
!this.attachedFiles || !this.attachedFiles.length;
|
||||
const hasNoAttachments = !this.attachedFiles?.length;
|
||||
// For Instagram, we need a separate text message
|
||||
// For WhatsApp, we only need a text message if there are no attachments
|
||||
if (
|
||||
@ -1099,7 +1110,7 @@ export default {
|
||||
) {
|
||||
let messagePayload = {
|
||||
conversationId: this.currentChat.id,
|
||||
message,
|
||||
message: messageWithSignature,
|
||||
private: false,
|
||||
sender: this.sender,
|
||||
};
|
||||
@ -1112,23 +1123,32 @@ export default {
|
||||
return multipleMessagePayload;
|
||||
},
|
||||
getMessagePayload(message) {
|
||||
const messageWithQuote = this.getMessageWithQuotedEmailText(message);
|
||||
let finalMessage = this.getMessageWithQuotedEmailText(message);
|
||||
if (!this.isPrivate) {
|
||||
finalMessage = this.applySignatureToMessage(finalMessage);
|
||||
}
|
||||
|
||||
let messagePayload = {
|
||||
conversationId: this.currentChat.id,
|
||||
message: messageWithQuote,
|
||||
message: finalMessage,
|
||||
private: this.isPrivate,
|
||||
sender: this.sender,
|
||||
};
|
||||
messagePayload = this.setReplyToInPayload(messagePayload);
|
||||
|
||||
if (this.attachedFiles && this.attachedFiles.length) {
|
||||
if (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
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -1203,8 +1223,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
|
||||
);
|
||||
@ -1403,11 +1425,13 @@ export default {
|
||||
:message="message"
|
||||
:portal-slug="connectedPortalSlug"
|
||||
:new-conversation-modal-active="newConversationModalActive"
|
||||
:show-schedule-options="!isPrivate"
|
||||
@select-whatsapp-template="openWhatsappTemplateModal"
|
||||
@select-content-template="openContentTemplateModal"
|
||||
@replace-text="replaceText"
|
||||
@toggle-insert-article="toggleInsertArticle"
|
||||
@toggle-quoted-reply="toggleQuotedReply"
|
||||
@schedule-message="openScheduledMessageModal"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
@ -1427,6 +1451,15 @@ export default {
|
||||
@cancel="hideContentTemplatesModal"
|
||||
/>
|
||||
|
||||
<ScheduledMessageModal
|
||||
v-model:show="showScheduledMessageModal"
|
||||
:conversation-id="conversationId"
|
||||
:inbox-id="inbox.id"
|
||||
:initial-content="message"
|
||||
:initial-attachment="attachedFiles[0] || null"
|
||||
@scheduled-message-created="onScheduledMessageCreated"
|
||||
/>
|
||||
|
||||
<woot-confirm-modal
|
||||
ref="confirmDialog"
|
||||
:title="$t('CONVERSATION.REPLYBOX.UNDEFINED_VARIABLES.TITLE')"
|
||||
|
||||
@ -12,9 +12,13 @@ export default {
|
||||
default: false,
|
||||
},
|
||||
inboxId: {
|
||||
type: Number,
|
||||
type: [Number, String],
|
||||
default: undefined,
|
||||
},
|
||||
sendButtonLabel: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
emits: ['onSend', 'cancel', 'update:show'],
|
||||
data() {
|
||||
@ -39,6 +43,13 @@ export default {
|
||||
: this.$t('WHATSAPP_TEMPLATES.MODAL.SUBTITLE');
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
show(newVal) {
|
||||
if (newVal) {
|
||||
this.selectedWaTemplate = null;
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
pickTemplate(template) {
|
||||
this.selectedWaTemplate = template;
|
||||
@ -71,6 +82,7 @@ export default {
|
||||
<WhatsAppTemplateReply
|
||||
v-else
|
||||
:template="selectedWaTemplate"
|
||||
:send-button-label="sendButtonLabel"
|
||||
@reset-template="onResetTemplate"
|
||||
@send-message="onSendMessage"
|
||||
/>
|
||||
|
||||
@ -2,11 +2,15 @@
|
||||
import WhatsAppTemplateParser from 'dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
template: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
sendButtonLabel: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['sendMessage', 'resetTemplate']);
|
||||
@ -23,11 +27,14 @@ const handleResetTemplate = () => {
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<WhatsAppTemplateParser
|
||||
:template="template"
|
||||
:template="props.template"
|
||||
:send-button-label="props.sendButtonLabel"
|
||||
@send-message="handleSendMessage"
|
||||
@reset-template="handleResetTemplate"
|
||||
>
|
||||
<template #actions="{ sendMessage, resetTemplate, disabled }">
|
||||
<template
|
||||
#actions="{ sendMessage, resetTemplate, disabled, sendButtonText }"
|
||||
>
|
||||
<footer class="flex gap-2 justify-end">
|
||||
<NextButton
|
||||
faded
|
||||
@ -38,7 +45,7 @@ const handleResetTemplate = () => {
|
||||
/>
|
||||
<NextButton
|
||||
type="button"
|
||||
:label="$t('WHATSAPP_TEMPLATES.PARSER.SEND_MESSAGE_LABEL')"
|
||||
:label="sendButtonText"
|
||||
:disabled="disabled"
|
||||
@click="sendMessage"
|
||||
/>
|
||||
|
||||
@ -1,28 +1,35 @@
|
||||
import { ref } from 'vue';
|
||||
|
||||
export const useLoadWithRetry = (config = {}) => {
|
||||
const maxRetry = config.max_retry || 3;
|
||||
const maxRetry = config.maxRetry || 3;
|
||||
const backoff = config.backoff || 1000;
|
||||
const type = config.type || '';
|
||||
|
||||
const isLoaded = ref(false);
|
||||
const hasError = ref(false);
|
||||
|
||||
const loadWithRetry = async url => {
|
||||
const attemptLoad = () => {
|
||||
const attemptLoad = async () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
|
||||
img.onload = () => {
|
||||
isLoaded.value = true;
|
||||
hasError.value = false;
|
||||
resolve();
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
reject(new Error('Failed to load image'));
|
||||
};
|
||||
|
||||
img.src = url;
|
||||
let media;
|
||||
if (type === 'image') {
|
||||
media = new Image();
|
||||
media.onload = () => resolve();
|
||||
media.onerror = () => reject(new Error('Failed to load image'));
|
||||
} else if (type === 'audio') {
|
||||
media = new Audio();
|
||||
media.onloadedmetadata = () => resolve();
|
||||
media.onerror = () => reject(new Error('Failed to load audio'));
|
||||
} else {
|
||||
fetch(url)
|
||||
.then(res => {
|
||||
if (res.ok) resolve();
|
||||
else reject(new Error('Failed to load resource'));
|
||||
})
|
||||
.catch(err => reject(err));
|
||||
return;
|
||||
}
|
||||
media.src = url;
|
||||
});
|
||||
};
|
||||
|
||||
@ -35,6 +42,8 @@ export const useLoadWithRetry = (config = {}) => {
|
||||
const retry = async (attempt = 0) => {
|
||||
try {
|
||||
await attemptLoad();
|
||||
hasError.value = false;
|
||||
isLoaded.value = true;
|
||||
} catch (error) {
|
||||
if (attempt + 1 >= maxRetry) {
|
||||
hasError.value = true;
|
||||
|
||||
@ -190,6 +190,27 @@ describe('useAutomation', () => {
|
||||
expect(automation.value.actions[0].action_params).toEqual([]);
|
||||
});
|
||||
|
||||
it('resets scheduled message action with default delay_minutes', () => {
|
||||
const { resetAction, automation } = useAutomation();
|
||||
automation.value = {
|
||||
event_name: 'message_created',
|
||||
conditions: [],
|
||||
actions: [
|
||||
{
|
||||
action_name: 'create_scheduled_message',
|
||||
action_params: [{ content: 'test', delay_minutes: 60 }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
resetAction(0);
|
||||
|
||||
// Should reset with default delay of 24 hours (1440 minutes)
|
||||
expect(automation.value.actions[0].action_params).toEqual([
|
||||
{ delay_minutes: 1440 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('manifests custom attributes correctly', () => {
|
||||
const { manifestCustomAttributes, automationTypes } = useAutomation();
|
||||
automationTypes.message_created = { conditions: [] };
|
||||
|
||||
@ -144,8 +144,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', () => {
|
||||
|
||||
@ -15,6 +15,7 @@ import {
|
||||
// AUTOMATION_RULE_EVENTS,
|
||||
// AUTOMATION_ACTION_TYPES,
|
||||
AUTOMATIONS,
|
||||
DEFAULT_SCHEDULED_MESSAGE_DELAY_MINUTES,
|
||||
} from 'dashboard/routes/dashboard/settings/automation/constants.js';
|
||||
|
||||
/**
|
||||
@ -123,10 +124,18 @@ export function useAutomation(startValue = null) {
|
||||
* @param {number} index - The index of the action to reset.
|
||||
*/
|
||||
const resetAction = index => {
|
||||
const action = automation.value.actions[index];
|
||||
const newActions = [...automation.value.actions];
|
||||
|
||||
// For scheduled messages, initialize with default delay
|
||||
const actionParams =
|
||||
action.action_name === 'create_scheduled_message'
|
||||
? [{ delay_minutes: DEFAULT_SCHEDULED_MESSAGE_DELAY_MINUTES }]
|
||||
: [];
|
||||
|
||||
newActions[index] = {
|
||||
...newActions[index],
|
||||
action_params: [],
|
||||
action_params: actionParams,
|
||||
};
|
||||
|
||||
automation.value.actions = newActions;
|
||||
|
||||
@ -72,6 +72,10 @@ export function useEditableAutomation() {
|
||||
[...params].includes(item.id)
|
||||
);
|
||||
}
|
||||
if (inputType === 'scheduled_message') {
|
||||
// Keep as array to maintain consistent format with how the component emits updates
|
||||
return params[0] ? [params[0]] : [];
|
||||
}
|
||||
if (inputType === 'team_message') {
|
||||
return {
|
||||
team_ids: [...getActionDropdownValues(action.action_name)].filter(
|
||||
|
||||
@ -119,6 +119,20 @@ export const useInbox = (inboxId = null) => {
|
||||
);
|
||||
});
|
||||
|
||||
const isAWhatsAppBaileysChannel = computed(() => {
|
||||
return (
|
||||
channelType.value === INBOX_TYPES.WHATSAPP &&
|
||||
whatsAppAPIProvider.value === 'baileys'
|
||||
);
|
||||
});
|
||||
|
||||
const isAWhatsAppZapiChannel = computed(() => {
|
||||
return (
|
||||
channelType.value === INBOX_TYPES.WHATSAPP &&
|
||||
whatsAppAPIProvider.value === 'zapi'
|
||||
);
|
||||
});
|
||||
|
||||
const isAWhatsAppChannel = computed(() => {
|
||||
return (
|
||||
channelType.value === INBOX_TYPES.WHATSAPP ||
|
||||
@ -153,6 +167,8 @@ export const useInbox = (inboxId = null) => {
|
||||
isATwilioWhatsAppChannel,
|
||||
isAWhatsAppCloudChannel,
|
||||
is360DialogWhatsAppChannel,
|
||||
isAWhatsAppBaileysChannel,
|
||||
isAWhatsAppZapiChannel,
|
||||
isAnEmailChannel,
|
||||
isAnInstagramChannel,
|
||||
isATiktokChannel,
|
||||
|
||||
@ -2,6 +2,7 @@ import { computed } from 'vue';
|
||||
import { useStore, useStoreGetters } from 'dashboard/composables/store';
|
||||
|
||||
export const DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER = Object.freeze([
|
||||
{ name: 'scheduled_messages' },
|
||||
{ name: 'conversation_actions' },
|
||||
{ name: 'macros' },
|
||||
{ name: 'conversation_info' },
|
||||
@ -45,8 +46,12 @@ const useConversationSidebarItemsOrder = uiSettings => {
|
||||
// If the sidebar order doesn't have the new elements, then add them to the list.
|
||||
DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER.forEach(item => {
|
||||
if (!itemsOrderCopy.find(i => i.name === item.name)) {
|
||||
if (item.name === 'scheduled_messages') {
|
||||
itemsOrderCopy.unshift(item);
|
||||
} else {
|
||||
itemsOrderCopy.push(item);
|
||||
}
|
||||
}
|
||||
});
|
||||
return itemsOrderCopy;
|
||||
});
|
||||
@ -126,7 +131,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;
|
||||
};
|
||||
|
||||
@ -34,6 +34,9 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
'conversation.updated': this.onConversationUpdated,
|
||||
'account.cache_invalidated': this.onCacheInvalidate,
|
||||
'copilot.message.created': this.onCopilotMessageCreated,
|
||||
'scheduled_message.created': this.onScheduledMessageCreated,
|
||||
'scheduled_message.updated': this.onScheduledMessageUpdated,
|
||||
'scheduled_message.deleted': this.onScheduledMessageDeleted,
|
||||
};
|
||||
}
|
||||
|
||||
@ -119,6 +122,18 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
this.fetchConversationStats();
|
||||
};
|
||||
|
||||
onScheduledMessageCreated = data => {
|
||||
this.app.$store.dispatch('handleScheduledMessageCreated', data);
|
||||
};
|
||||
|
||||
onScheduledMessageUpdated = data => {
|
||||
this.app.$store.dispatch('handleScheduledMessageUpdated', data);
|
||||
};
|
||||
|
||||
onScheduledMessageDeleted = data => {
|
||||
this.app.$store.dispatch('handleScheduledMessageDeleted', data);
|
||||
};
|
||||
|
||||
onTypingOn = ({ conversation, user }) => {
|
||||
const conversationId = conversation.id;
|
||||
|
||||
|
||||
@ -6,11 +6,19 @@ const allElementsNumbers = arr => {
|
||||
return arr.every(elem => typeof elem === 'number');
|
||||
};
|
||||
|
||||
const allElementsPlainObjects = arr => {
|
||||
return arr.every(
|
||||
elem => typeof elem === 'object' && elem !== null && !elem.id
|
||||
);
|
||||
};
|
||||
|
||||
const formatArray = params => {
|
||||
if (params.length <= 0) {
|
||||
params = [];
|
||||
} else if (allElementsString(params) || allElementsNumbers(params)) {
|
||||
params = [...params];
|
||||
} else if (allElementsPlainObjects(params)) {
|
||||
params = [...params];
|
||||
} else {
|
||||
params = params.map(val => val.id);
|
||||
}
|
||||
|
||||
@ -158,10 +158,21 @@ export const getConditionOptions = ({
|
||||
};
|
||||
|
||||
export const getFileName = (action, files = []) => {
|
||||
const blobId = action.action_params[0];
|
||||
const scheduledParams = Array.isArray(action.action_params)
|
||||
? action.action_params[0]
|
||||
: action.action_params;
|
||||
const blobId =
|
||||
action.action_name === 'create_scheduled_message'
|
||||
? scheduledParams?.blob_id
|
||||
: action.action_params?.[0];
|
||||
if (!blobId) return '';
|
||||
if (action.action_name === 'send_attachment') {
|
||||
const file = files.find(item => item.blob_id === blobId);
|
||||
if (
|
||||
action.action_name === 'send_attachment' ||
|
||||
action.action_name === 'create_scheduled_message'
|
||||
) {
|
||||
const file = files.find(
|
||||
item => item.blob_id?.toString() === blobId.toString()
|
||||
);
|
||||
if (file) return file.filename.toString();
|
||||
}
|
||||
return '';
|
||||
@ -285,7 +296,7 @@ export const getInputType = (
|
||||
return getCustomAttributeInputType(customAttribute.attribute_display_type);
|
||||
}
|
||||
const type = getAutomationType(automationTypes, automation, key);
|
||||
return type.inputType;
|
||||
return type?.inputType ?? '';
|
||||
};
|
||||
|
||||
/**
|
||||
@ -311,7 +322,7 @@ export const getOperators = (
|
||||
}
|
||||
}
|
||||
const type = getAutomationType(automationTypes, automation, key);
|
||||
return type.filterOperators;
|
||||
return type?.filterOperators ?? [];
|
||||
};
|
||||
|
||||
/**
|
||||
@ -322,9 +333,10 @@ export const getOperators = (
|
||||
* @returns {string} The custom attribute type.
|
||||
*/
|
||||
export const getCustomAttributeType = (automationTypes, automation, key) => {
|
||||
return automationTypes[automation.event_name].conditions.find(
|
||||
i => i.key === key
|
||||
).customAttributeType;
|
||||
return (
|
||||
automationTypes[automation.event_name].conditions.find(i => i.key === key)
|
||||
?.customAttributeType ?? ''
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
@ -334,8 +346,12 @@ export const getCustomAttributeType = (automationTypes, automation, key) => {
|
||||
* @returns {boolean} True if the action input should be shown, false otherwise.
|
||||
*/
|
||||
export const showActionInput = (automationActionTypes, action) => {
|
||||
if (action === 'send_email_to_team' || action === 'send_message')
|
||||
if (
|
||||
action === 'send_email_to_team' ||
|
||||
action === 'send_message' ||
|
||||
action === 'create_scheduled_message'
|
||||
)
|
||||
return false;
|
||||
const type = automationActionTypes.find(i => i.key === action).inputType;
|
||||
const type = automationActionTypes.find(i => i.key === action)?.inputType;
|
||||
return !!type;
|
||||
};
|
||||
|
||||
@ -103,16 +103,6 @@ export function cleanSignature(signature) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the signature delimiter to the beginning of the signature.
|
||||
*
|
||||
* @param {string} signature - The signature to add the delimiter to.
|
||||
* @returns {string} - The signature with the delimiter added.
|
||||
*/
|
||||
function appendDelimiter(signature) {
|
||||
return `${SIGNATURE_DELIMITER}\n\n${cleanSignature(signature)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there's an unedited signature at the end of the body
|
||||
* If there is, return the index of the signature, If there isn't, return -1
|
||||
@ -156,22 +146,28 @@ export function getEffectiveChannelType(channelType, medium) {
|
||||
*
|
||||
* @param {string} body - The body to append the signature to.
|
||||
* @param {string} signature - The signature to append.
|
||||
* @param {string} channelType - Optional. The effective channel type to determine supported formatting.
|
||||
* For Twilio channels, pass the result of getEffectiveChannelType().
|
||||
* @param {Object} settings - The signature settings (position, separator).
|
||||
* @returns {string} - The body with the signature appended.
|
||||
*/
|
||||
export function appendSignature(body, signature, channelType) {
|
||||
// Strip only unsupported formatting based on channel capabilities
|
||||
const preparedSignature = channelType
|
||||
? stripUnsupportedMarkdown(signature, channelType)
|
||||
: signature;
|
||||
const cleanedSignature = cleanSignature(preparedSignature);
|
||||
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}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user