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

This commit is contained in:
gabrieljablonski 2026-02-17 23:05:26 -03:00
commit 9a4c5058f3
484 changed files with 21902 additions and 1307 deletions

45
.annotaterb.yml Normal file
View File

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

View File

@ -277,3 +277,10 @@ AZURE_APP_SECRET=
# REDIS_ALFRED_SIZE=10 # REDIS_ALFRED_SIZE=10
# REDIS_VELMA_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
View 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

View File

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

View File

@ -0,0 +1,138 @@
name: Publish Chatwoot Enterprise docker images to GitHub
permissions:
contents: read
packages: write
on:
release:
types: [released]
workflow_dispatch:
env:
GITHUB_REPO: ghcr.io/${{ github.repository }}
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-22.04-arm
runs-on: ${{ matrix.runner }}
env:
GIT_REF: ${{ github.event.release.tag_name || github.ref_name }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.release.tag_name || github.ref }}
- name: Prepare
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Set Chatwoot edition
run: |
echo -en '\nENV CW_EDITION="ee"' >> docker/Dockerfile
- name: Update version in app.yml
run: |
if [[ "$GIT_REF" =~ ^v(.+)$ ]]; then
VERSION="${BASH_REMATCH[1]}"
echo "Updating version to: $VERSION"
sed -i "s/version: '.*'/version: '$VERSION'/" config/app.yml
else
echo "No version tag found, keeping existing version"
fi
- name: Set Docker Tags
run: |
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
echo "SANITIZED_REF=${SANITIZED_REF}" >> $GITHUB_ENV
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push to GitHub Container Registry
id: build-ghcr
uses: docker/build-push-action@v6
with:
context: .
file: docker/Dockerfile
platforms: ${{ matrix.platform }}
push: ${{ github.event_name == 'release' || github.event_name == 'workflow_dispatch' }}
tags: |
${{ env.GITHUB_REPO }}:${{ env.SANITIZED_REF }}-ee
${{ env.GITHUB_REPO }}:latest-ee
- name: Export digest
run: |
mkdir -p ${{ runner.temp }}/digests
digest="${{ steps.build-ghcr.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
merge:
runs-on: ubuntu-latest
needs:
- build
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: digests-*
merge-multiple: true
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Create manifest list and push
working-directory: ${{ runner.temp }}/digests
env:
GIT_REF: ${{ github.event.release.tag_name || github.ref_name }}
run: |
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
docker buildx imagetools create \
-t ghcr.io/${{ github.repository }}:${SANITIZED_REF}-ee \
-t ghcr.io/${{ github.repository }}:latest-ee \
$(printf 'ghcr.io/${{ github.repository }}@sha256:%s ' *)
- name: Inspect image
env:
GIT_REF: ${{ github.event.release.tag_name || github.ref_name }}
run: |
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
REPO="ghcr.io/${{ github.repository }}"
docker buildx imagetools inspect ${REPO}:${SANITIZED_REF}-ee
docker buildx imagetools inspect ${REPO}:latest-ee

View File

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

View File

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

View File

@ -3,10 +3,8 @@ permissions:
contents: read contents: read
on: on:
push: push:
branches: tags:
- develop - '*'
- master
pull_request:
workflow_dispatch: workflow_dispatch:
jobs: jobs:

View File

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

View File

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

View File

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

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

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

73
CUSTOM_BRANDING.md Normal file
View File

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

View File

@ -205,6 +205,8 @@ gem 'opentelemetry-exporter-otlp'
gem 'shopify_api' gem 'shopify_api'
gem 'resend', '~> 0.19.0'
### Gems required only in specific deployment environments ### ### Gems required only in specific deployment environments ###
############################################################## ##############################################################

View File

@ -749,6 +749,8 @@ GEM
uber (< 0.2.0) uber (< 0.2.0)
request_store (1.5.1) request_store (1.5.1)
rack (>= 1.4) rack (>= 1.4)
resend (0.19.0)
httparty (>= 0.21.0)
responders (3.1.1) responders (3.1.1)
actionpack (>= 5.2) actionpack (>= 5.2)
railties (>= 5.2) railties (>= 5.2)
@ -1116,6 +1118,7 @@ DEPENDENCIES
rails (~> 7.1) rails (~> 7.1)
redis redis
redis-namespace redis-namespace
resend (~> 0.19.0)
responders (>= 3.1.1) responders (>= 3.1.1)
rest-client rest-client
reverse_markdown reverse_markdown

229
META-WEBHOOK-PROXY.md Normal file
View 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 1520% 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.

View File

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

View File

@ -10,7 +10,7 @@ class ConversationBuilder
def look_up_exising_conversation def look_up_exising_conversation
return unless @contact_inbox.inbox.lock_to_single_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 end
def create_new_conversation def create_new_conversation

View File

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

View File

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

View File

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

View File

@ -1,11 +1,11 @@
class Messages::MessageBuilder class Messages::MessageBuilder # rubocop:disable Metrics/ClassLength
include ::FileTypeHelper include ::FileTypeHelper
include ::EmailHelper include ::EmailHelper
include ::DataHelper include ::DataHelper
attr_reader :message attr_reader :message
def initialize(user, conversation, params) def initialize(user, conversation, params) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
@params = params @params = params
@private = params[:private] || false @private = params[:private] || false
@conversation = conversation @conversation = conversation
@ -13,11 +13,15 @@ class Messages::MessageBuilder
@account = conversation.account @account = conversation.account
@message_type = params[:message_type] || 'outgoing' @message_type = params[:message_type] || 'outgoing'
@attachments = params[:attachments] @attachments = params[:attachments]
@is_recorded_audio = params[:is_recorded_audio]
@attachments_metadata = normalize_attachments_metadata(params[:attachments_metadata])
@automation_rule = content_attributes&.dig(:automation_rule_id) @automation_rule = content_attributes&.dig(:automation_rule_id)
return unless params.instance_of?(ActionController::Parameters) return unless params.instance_of?(ActionController::Parameters)
@in_reply_to = content_attributes&.dig(:in_reply_to) @in_reply_to = content_attributes&.dig(:in_reply_to)
@is_reaction = content_attributes&.dig(:is_reaction)
@items = content_attributes&.dig(:items) @items = content_attributes&.dig(:items)
@zapi_args = content_attributes&.dig(:zapi_args)
end end
def perform def perform
@ -55,7 +59,7 @@ class Messages::MessageBuilder
account_id: @message.account_id, account_id: @message.account_id,
file: uploaded_attachment file: uploaded_attachment
) )
attachment.meta = process_metadata(uploaded_attachment)
attachment.file_type = if uploaded_attachment.is_a?(String) attachment.file_type = if uploaded_attachment.is_a?(String)
file_type_by_signed_id( file_type_by_signed_id(
uploaded_attachment uploaded_attachment
@ -66,6 +70,46 @@ class Messages::MessageBuilder
end end
end end
def process_metadata(attachment)
meta = {}
meta.merge!(recorded_audio_metadata(attachment) || {})
meta.merge!(custom_attachment_metadata(attachment) || {})
meta.presence
end
def recorded_audio_metadata(attachment) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
# NOTE: `is_recorded_audio` can be either a boolean or an array of file names.
return unless @is_recorded_audio
return { is_recorded_audio: true } if @is_recorded_audio == true
return { is_recorded_audio: true } if @is_recorded_audio.is_a?(Array) && attachment.original_filename.in?(@is_recorded_audio)
# FIXME: Remove backwards compatibility with old format.
if @is_recorded_audio.is_a?(String)
parsed = JSON.parse(@is_recorded_audio)
{ is_recorded_audio: true } if parsed.is_a?(Array) && attachment.original_filename.in?(parsed)
end
rescue JSON::ParserError
nil
end
def custom_attachment_metadata(attachment)
return unless @attachments_metadata.is_a?(Hash)
filename = attachment.respond_to?(:original_filename) ? attachment.original_filename : nil
return unless filename
metadata = @attachments_metadata[filename]
metadata.to_h if metadata.present?
end
def normalize_attachments_metadata(metadata)
return if metadata.blank?
metadata = metadata.to_unsafe_h if metadata.respond_to?(:to_unsafe_h)
metadata.deep_stringify_keys
end
def process_emails def process_emails
return unless @conversation.inbox&.inbox_type == 'Email' return unless @conversation.inbox&.inbox_type == 'Email'
@ -123,12 +167,32 @@ class Messages::MessageBuilder
@params[:template_params].present? ? { additional_attributes: { template_params: JSON.parse(@params[:template_params].to_json) } } : {} @params[:template_params].present? ? { additional_attributes: { template_params: JSON.parse(@params[:template_params].to_json) } } : {}
end 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 def message_sender
return if @params[:sender_type] != 'AgentBot' return if @params[:sender_type] != 'AgentBot'
AgentBot.where(account_id: [nil, @conversation.account.id]).find_by(id: @params[:sender_id]) AgentBot.where(account_id: [nil, @conversation.account.id]).find_by(id: @params[:sender_id])
end end
def zapi_args
@zapi_args.present? ? { zapi_args: @zapi_args } : {}
end
def message_params def message_params
{ {
account_id: @conversation.account_id, account_id: @conversation.account_id,
@ -141,9 +205,11 @@ class Messages::MessageBuilder
content_attributes: content_attributes.presence, content_attributes: content_attributes.presence,
items: @items, items: @items,
in_reply_to: @in_reply_to, in_reply_to: @in_reply_to,
is_reaction: @is_reaction,
echo_id: @params[:echo_id], echo_id: @params[:echo_id],
source_id: @params[:source_id] source_id: @params[:source_id]
}.merge(external_created_at).merge(automation_rule_id).merge(campaign_id).merge(template_params) }.merge(external_created_at).merge(automation_rule_id).merge(campaign_id)
.deep_merge(template_params).merge(zapi_args).deep_merge(scheduled_message_metadata)
end end
def email_inbox? def email_inbox?

View File

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

View File

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

View File

@ -1,4 +1,6 @@
class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::Conversations::BaseController class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::Conversations::BaseController
include Events::Types
before_action :ensure_api_inbox, only: :update before_action :ensure_api_inbox, only: :update
def index def index
@ -9,6 +11,8 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
user = Current.user || @resource user = Current.user || @resource
mb = Messages::MessageBuilder.new(user, @conversation, params) mb = Messages::MessageBuilder.new(user, @conversation, params)
@message = mb.perform @message = mb.perform
trigger_typing_event(CONVERSATION_TYPING_OFF)
rescue StandardError => e rescue StandardError => e
render_could_not_create_error(e.message) render_could_not_create_error(e.message)
end end
@ -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.update!(content: I18n.t('conversations.messages.deleted'), content_type: :text, content_attributes: { deleted: true })
message.attachments.destroy_all message.attachments.destroy_all
end end
delete_message_on_channel
end end
def retry def retry
@ -54,6 +59,22 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
render json: { content: translated_content } render json: { content: translated_content }
end 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 private
def message def message
@ -65,16 +86,48 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
end end
def permitted_params def permitted_params
params.permit(:id, :target_language, :status, :external_error) params.permit(:id, :target_language, :status, :external_error, :content)
end end
def already_translated_content_available? def already_translated_content_available?
message.translations.present? && message.translations[permitted_params[:target_language]].present? message.translations.present? && message.translations[permitted_params[:target_language]].present?
end 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 # API inbox check
def ensure_api_inbox def ensure_api_inbox
# Only API inboxes can update messages # Only API inboxes can update messages
render json: { error: 'Message status update is only allowed for API inboxes' }, status: :forbidden unless @conversation.inbox.api? render json: { error: 'Message status update is only allowed for API inboxes' }, status: :forbidden unless @conversation.inbox.api?
end end
def trigger_typing_event(event)
user = Current.user || @resource
return unless user.is_a?(User)
Rails.configuration.dispatcher.dispatch(event, Time.zone.now, conversation: @conversation, user: user, is_private: params[:private])
end
end end

View File

@ -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'
)

View File

@ -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 Events::Types
include DateRangeHelper include DateRangeHelper
include HmacConcern include HmacConcern
@ -122,10 +122,14 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
# No unread messages - apply throttling to limit DB writes # No unread messages - apply throttling to limit DB writes
return unless should_update_last_seen? return unless should_update_last_seen?
dispatch_messages_read_event if assignee?
update_last_seen_on_conversation(DateTime.now.utc, assignee?) update_last_seen_on_conversation(DateTime.now.utc, assignee?)
end end
def unread def unread
Rails.configuration.dispatcher.dispatch(Events::Types::CONVERSATION_UNREAD, Time.zone.now, conversation: @conversation)
last_incoming_message = @conversation.messages.incoming.last last_incoming_message = @conversation.messages.incoming.last
last_seen_at = last_incoming_message.created_at - 1.second if last_incoming_message.present? last_seen_at = last_incoming_message.created_at - 1.second if last_incoming_message.present?
update_last_seen_on_conversation(last_seen_at, true) update_last_seen_on_conversation(last_seen_at, true)
@ -231,6 +235,12 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
def assignee? def assignee?
@conversation.assignee_id? && Current.user == @conversation.assignee @conversation.assignee_id? && Current.user == @conversation.assignee
end end
def dispatch_messages_read_event
# NOTE: Use old `agent_last_seen_at`, so we reference messages received after that
Rails.configuration.dispatcher.dispatch(Events::Types::MESSAGES_READ, Time.zone.now, conversation: @conversation,
last_seen_at: @conversation.agent_last_seen_at)
end
end end
Api::V1::Accounts::ConversationsController.prepend_mod_with('Api::V1::Accounts::ConversationsController') Api::V1::Accounts::ConversationsController.prepend_mod_with('Api::V1::Accounts::ConversationsController')

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -6,7 +6,7 @@ module AttachmentConcern
return [blobs, actions, nil] if actions.blank? return [blobs, actions, nil] if actions.blank?
sanitized = actions.map do |action| 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) result = process_attachment_action(action, record, blobs)
return [nil, nil, I18n.t('errors.attachments.invalid')] unless result return [nil, nil, I18n.t('errors.attachments.invalid')] unless result
@ -20,15 +20,39 @@ module AttachmentConcern
private private
def process_attachment_action(action, record, blobs) 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) 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) return action if blob_already_attached?(record, blob_id)
nil nil
end 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) def blob_already_attached?(record, blob_id)
record&.files&.any? { |f| f.blob_id == blob_id.to_i } record&.files&.any? { |f| f.blob_id == blob_id.to_i }
end end

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,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

View File

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

View File

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

View File

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

View File

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

View File

@ -8,6 +8,7 @@ export const buildCreatePayload = ({
contentAttributes, contentAttributes,
echoId, echoId,
files, files,
isRecordedAudio,
ccEmails = '', ccEmails = '',
bccEmails = '', bccEmails = '',
toEmails = '', toEmails = '',
@ -22,6 +23,9 @@ export const buildCreatePayload = ({
files.forEach(file => { files.forEach(file => {
payload.append('attachments[]', file); payload.append('attachments[]', file);
}); });
isRecordedAudio?.forEach(filename => {
payload.append('is_recorded_audio[]', filename);
});
payload.append('private', isPrivate); payload.append('private', isPrivate);
payload.append('echo_id', echoId); payload.append('echo_id', echoId);
payload.append('cc_emails', ccEmails); payload.append('cc_emails', ccEmails);
@ -60,6 +64,7 @@ class MessageApi extends ApiClient {
contentAttributes, contentAttributes,
echo_id: echoId, echo_id: echoId,
files, files,
isRecordedAudio,
ccEmails = '', ccEmails = '',
bccEmails = '', bccEmails = '',
toEmails = '', toEmails = '',
@ -74,6 +79,7 @@ class MessageApi extends ApiClient {
contentAttributes, contentAttributes,
echoId, echoId,
files, files,
isRecordedAudio,
ccEmails, ccEmails,
bccEmails, bccEmails,
toEmails, toEmails,
@ -86,6 +92,13 @@ class MessageApi extends ApiClient {
return axios.delete(`${this.url}/${conversationID}/messages/${messageId}`); 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) { retry(conversationID, messageId) {
return axios.post( return axios.post(
`${this.url}/${conversationID}/messages/${messageId}/retry` `${this.url}/${conversationID}/messages/${messageId}/retry`

View File

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

View File

@ -0,0 +1,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();

View 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'
);
});
});
});

View File

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

After

Width:  |  Height:  |  Size: 691 B

View File

@ -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>

View File

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

View File

@ -4,8 +4,6 @@ import { useVuelidate } from '@vuelidate/core';
import { required, requiredIf } from '@vuelidate/validators'; import { required, requiredIf } from '@vuelidate/validators';
import { INBOX_TYPES } from 'dashboard/helper/inbox'; import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { import {
appendSignature,
removeSignature,
getEffectiveChannelType, getEffectiveChannelType,
stripUnsupportedMarkdown, stripUnsupportedMarkdown,
} from 'dashboard/helper/editorHelper'; } from 'dashboard/helper/editorHelper';
@ -70,6 +68,12 @@ const inboxTypes = computed(() => ({
isEmail: props.targetInbox?.channelType === INBOX_TYPES.EMAIL, isEmail: props.targetInbox?.channelType === INBOX_TYPES.EMAIL,
isTwilio: props.targetInbox?.channelType === INBOX_TYPES.TWILIO, isTwilio: props.targetInbox?.channelType === INBOX_TYPES.TWILIO,
isWhatsapp: props.targetInbox?.channelType === INBOX_TYPES.WHATSAPP, isWhatsapp: props.targetInbox?.channelType === INBOX_TYPES.WHATSAPP,
isWhatsappBaileys:
props.targetInbox?.channelType === INBOX_TYPES.WHATSAPP &&
props.targetInbox?.provider === 'baileys',
isWhatsappZapi:
props.targetInbox?.channelType === INBOX_TYPES.WHATSAPP &&
props.targetInbox?.provider === 'zapi',
isWebWidget: props.targetInbox?.channelType === INBOX_TYPES.WEB, isWebWidget: props.targetInbox?.channelType === INBOX_TYPES.WEB,
isApi: props.targetInbox?.channelType === INBOX_TYPES.API, isApi: props.targetInbox?.channelType === INBOX_TYPES.API,
isEmailOrWebWidget: isEmailOrWebWidget:
@ -91,12 +95,6 @@ const whatsappMessageTemplates = computed(() =>
const inboxChannelType = computed(() => props.targetInbox?.channelType || ''); const inboxChannelType = computed(() => props.targetInbox?.channelType || '');
const inboxMedium = computed(() => props.targetInbox?.medium || '');
const effectiveChannelType = computed(() =>
getEffectiveChannelType(inboxChannelType.value, inboxMedium.value)
);
const validationRules = computed(() => ({ const validationRules = computed(() => ({
selectedContact: { required }, selectedContact: { required },
targetInbox: { required }, targetInbox: { required },
@ -131,6 +129,8 @@ const newMessagePayload = () => {
currentUser: props.currentUser, currentUser: props.currentUser,
attachedFiles, attachedFiles,
directUploadsEnabled: props.isDirectUploadsEnabled, directUploadsEnabled: props.isDirectUploadsEnabled,
sendWithSignature: props.sendWithSignature,
messageSignature: props.messageSignature,
}); });
}; };
@ -222,21 +222,8 @@ const handleInboxAction = ({ value, action, channelType, medium, ...rest }) => {
state.attachedFiles = []; state.attachedFiles = [];
}; };
const removeSignatureFromMessage = () => {
// Always remove the signature from message content when inbox/contact is removed
// to ensure no leftover signature content remains
if (props.messageSignature) {
state.message = removeSignature(
state.message,
props.messageSignature,
effectiveChannelType.value
);
}
};
const removeTargetInbox = value => { const removeTargetInbox = value => {
v$.value.$reset(); v$.value.$reset();
removeSignatureFromMessage();
stripMessageFormatting(DEFAULT_FORMATTING); stripMessageFormatting(DEFAULT_FORMATTING);
@ -245,7 +232,6 @@ const removeTargetInbox = value => {
}; };
const clearSelectedContact = () => { const clearSelectedContact = () => {
removeSignatureFromMessage();
emit('clearSelectedContact'); emit('clearSelectedContact');
state.message = ''; state.message = '';
state.attachedFiles = []; state.attachedFiles = [];
@ -255,22 +241,6 @@ const onClickInsertEmoji = emoji => {
state.message += emoji; state.message += emoji;
}; };
const handleAddSignature = signature => {
state.message = appendSignature(
state.message,
signature,
effectiveChannelType.value
);
};
const handleRemoveSignature = signature => {
state.message = removeSignature(
state.message,
signature,
effectiveChannelType.value
);
};
const handleAttachFile = files => { const handleAttachFile = files => {
state.attachedFiles = files; state.attachedFiles = files;
}; };
@ -333,7 +303,9 @@ const handleSendTwilioMessage = async ({ message, templateParams }) => {
const shouldShowMessageEditor = computed(() => { const shouldShowMessageEditor = computed(() => {
return ( return (
!inboxTypes.value.isWhatsapp && (!inboxTypes.value.isWhatsapp ||
inboxTypes.value.isWhatsappBaileys ||
inboxTypes.value.isWhatsappZapi) &&
!showNoInboxAlert.value && !showNoInboxAlert.value &&
!inboxTypes.value.isTwilioWhatsapp !inboxTypes.value.isTwilioWhatsapp
); );
@ -408,6 +380,8 @@ const shouldShowMessageEditor = computed(() => {
<ActionButtons <ActionButtons
:attached-files="state.attachedFiles" :attached-files="state.attachedFiles"
:is-whatsapp-inbox="inboxTypes.isWhatsapp" :is-whatsapp-inbox="inboxTypes.isWhatsapp"
:is-whatsapp-baileys-inbox="inboxTypes.isWhatsappBaileys"
:is-whatsapp-zapi-inbox="inboxTypes.isWhatsappZapi"
:is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget" :is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget"
:is-twilio-sms-inbox="inboxTypes.isTwilioSMS" :is-twilio-sms-inbox="inboxTypes.isTwilioSMS"
:is-twilio-whats-app-inbox="inboxTypes.isTwilioWhatsapp" :is-twilio-whats-app-inbox="inboxTypes.isTwilioWhatsapp"
@ -421,8 +395,6 @@ const shouldShowMessageEditor = computed(() => {
:is-dropdown-active="isAnyDropdownActive" :is-dropdown-active="isAnyDropdownActive"
:message-signature="messageSignature" :message-signature="messageSignature"
@insert-emoji="onClickInsertEmoji" @insert-emoji="onClickInsertEmoji"
@add-signature="handleAddSignature"
@remove-signature="handleRemoveSignature"
@attach-file="handleAttachFile" @attach-file="handleAttachFile"
@discard="$emit('discard')" @discard="$emit('discard')"
@send-message="handleSendMessage" @send-message="handleSendMessage"

View File

@ -1,5 +1,6 @@
import { INBOX_TYPES } from 'dashboard/helper/inbox'; import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { getInboxIconByType } from 'dashboard/helper/inbox'; import { getInboxIconByType } from 'dashboard/helper/inbox';
import { appendSignature } from 'dashboard/helper/editorHelper';
import camelcaseKeys from 'camelcase-keys'; import camelcaseKeys from 'camelcase-keys';
import ContactAPI from 'dashboard/api/contacts'; import ContactAPI from 'dashboard/api/contacts';
@ -129,12 +130,29 @@ export const prepareNewMessagePayload = ({
currentUser, currentUser,
attachedFiles = [], attachedFiles = [],
directUploadsEnabled = false, 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 = { const payload = {
inboxId: targetInbox.id, inboxId: targetInbox.id,
sourceId: targetInbox.sourceId, sourceId: targetInbox.sourceId,
contactId: Number(selectedContact.id), contactId: Number(selectedContact.id),
message: { content: message }, message: { content: finalMessage },
assigneeId: currentUser.id, assigneeId: currentUser.id,
}; };

View File

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

View File

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

View File

@ -97,6 +97,7 @@ import { useBranding } from 'shared/composables/useBranding';
* @property {boolean} [isEmailInbox=false] - Whether the message is from an email inbox * @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} 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 {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 // eslint-disable-next-line vue/define-macros-order
@ -120,12 +121,15 @@ const props = defineProps({
default: 'text', default: 'text',
validator: value => Object.values(CONTENT_TYPES).includes(value), 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 }, conversationId: { type: Number, required: true },
createdAt: { type: Number, required: true }, // eslint-disable-line vue/no-unused-properties createdAt: { type: Number, required: true }, // eslint-disable-line vue/no-unused-properties
currentUserId: { 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 }, groupWithNext: { type: Boolean, default: false },
inboxId: { type: Number, default: null }, // eslint-disable-line vue/no-unused-properties inboxId: { type: Number, default: null }, // eslint-disable-line vue/no-unused-properties
inboxSupportsReplyTo: { type: Object, default: () => ({}) }, inboxSupportsReplyTo: { type: Object, default: () => ({}) },
inboxSupportsEdit: { type: Boolean, default: false },
inReplyTo: { type: Object, default: null }, // eslint-disable-line vue/no-unused-properties inReplyTo: { type: Object, default: null }, // eslint-disable-line vue/no-unused-properties
isEmailInbox: { type: Boolean, default: false }, isEmailInbox: { type: Boolean, default: false },
private: { type: Boolean, default: false }, private: { type: Boolean, default: false },
@ -382,6 +386,12 @@ const contextMenuEnabledOptions = computed(() => {
!props.private && !props.private &&
props.inboxSupportsReplyTo.outgoing && props.inboxSupportsReplyTo.outgoing &&
!isFailedOrProcessing, !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) { if (!props.sender) {
const externalSenderName = props.contentAttributes?.externalSenderName;
if (externalSenderName === 'WhatsApp') {
return {
name: t('CONVERSATION.WHATSAPP'),
src: '',
iconName: 'i-woot-whatsapp',
};
}
return { return {
name: t('CONVERSATION.BOT'), name: t('CONVERSATION.BOT'),
src: '', src: '',

View File

@ -14,6 +14,7 @@ import MessageApi from 'dashboard/api/inbox/message.js';
* @property {Number} currentUserId - ID of the current user * @property {Number} currentUserId - ID of the current user
* @property {Boolean} isAnEmailChannel - Whether this is an email channel * @property {Boolean} isAnEmailChannel - Whether this is an email channel
* @property {Object} inboxSupportsReplyTo - Inbox reply support configuration * @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] * @property {Array} messages - Array of all messages [These are not in camelcase]
*/ */
const props = defineProps({ const props = defineProps({
@ -33,6 +34,10 @@ const props = defineProps({
type: Object, type: Object,
default: () => ({ incoming: false, outgoing: false }), default: () => ({ incoming: false, outgoing: false }),
}, },
inboxSupportsEdit: {
type: Boolean,
default: false,
},
messages: { messages: {
type: Array, type: Array,
default: () => [], default: () => [],
@ -176,6 +181,7 @@ const getInReplyToMessage = parentMessage => {
:in-reply-to="getInReplyToMessage(message)" :in-reply-to="getInReplyToMessage(message)"
:group-with-next="shouldGroupWithNext(index, allMessages)" :group-with-next="shouldGroupWithNext(index, allMessages)"
:inbox-supports-reply-to="inboxSupportsReplyTo" :inbox-supports-reply-to="inboxSupportsReplyTo"
:inbox-supports-edit="inboxSupportsEdit"
:current-user-id="currentUserId" :current-user-id="currentUserId"
data-clarity-mask="True" data-clarity-mask="True"
@retry="emit('retry', message)" @retry="emit('retry', message)"

View File

@ -1,6 +1,8 @@
<script setup> <script setup>
import { computed } from 'vue'; import { computed } from 'vue';
import { messageTimestamp } from 'shared/helpers/timeHelper'; import { messageTimestamp } from 'shared/helpers/timeHelper';
import { useI18n } from 'vue-i18n';
import { useFunctionGetter } from 'dashboard/composables/store';
import MessageStatus from './MessageStatus.vue'; import MessageStatus from './MessageStatus.vue';
import Icon from 'next/icon/Icon.vue'; import Icon from 'next/icon/Icon.vue';
@ -23,6 +25,8 @@ const {
isATiktokChannel, isATiktokChannel,
} = useInbox(); } = useInbox();
const { t, locale } = useI18n();
const { const {
status, status,
isPrivate, isPrivate,
@ -30,12 +34,96 @@ const {
sourceId, sourceId,
messageType, messageType,
contentAttributes, contentAttributes,
additionalAttributes,
sender,
currentUserId,
} = useMessageContext(); } = useMessageContext();
const readableTime = computed(() => const readableTime = computed(() =>
messageTimestamp(createdAt.value, 'LLL d, h:mm a') messageTimestamp(
contentAttributes?.value?.externalCreatedAt ?? createdAt.value,
'LLL d, h:mm a'
)
); );
const 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(() => { const showStatusIndicator = computed(() => {
if (isPrivate.value) return false; if (isPrivate.value) return false;
// Don't show status for failed messages, we already show error message // Don't show status for failed messages, we already show error message
@ -123,6 +211,14 @@ const statusToShow = computed(() => {
return MESSAGE_STATUS.PROGRESS; return MESSAGE_STATUS.PROGRESS;
}); });
const isEdited = computed(() => {
return contentAttributes.value?.isEdited === true;
});
const previousContent = computed(() => {
return contentAttributes.value?.previousContent || '';
});
</script> </script>
<template> <template>
@ -130,8 +226,27 @@ const statusToShow = computed(() => {
<div class="inline"> <div class="inline">
<time class="inline">{{ readableTime }}</time> <time class="inline">{{ readableTime }}</time>
</div> </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" /> <Icon v-if="isPrivate" icon="i-lucide-lock-keyhole" class="size-3" />
<MessageStatus v-if="showStatusIndicator" :status="statusToShow" /> <MessageStatus v-if="showStatusIndicator" :status="statusToShow" />
</div> </div>
</template> </template>
`

View File

@ -15,8 +15,13 @@ const props = defineProps({
hideMeta: { type: Boolean, default: false }, hideMeta: { type: Boolean, default: false },
}); });
const { variant, orientation, inReplyTo, shouldGroupWithNext } = const {
useMessageContext(); variant,
orientation,
inReplyTo,
shouldGroupWithNext,
additionalAttributes,
} = useMessageContext();
const { t } = useI18n(); const { t } = useI18n();
const varaintBaseMap = { const varaintBaseMap = {
@ -51,6 +56,16 @@ const flexOrientationClass = computed(() => {
return map[orientation.value]; 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 messageClass = computed(() => {
const classToApply = [varaintBaseMap[variant.value]]; const classToApply = [varaintBaseMap[variant.value]];
@ -60,6 +75,10 @@ const messageClass = computed(() => {
classToApply.push('rounded-lg'); classToApply.push('rounded-lg');
} }
if (scheduledMessageClass.value) {
classToApply.push(scheduledMessageClass.value);
}
return classToApply; return classToApply;
}); });

View File

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

View File

@ -1,9 +1,12 @@
<script setup> <script setup>
import { computed } from 'vue'; import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMessageContext } from '../../provider.js'; import { useMessageContext } from '../../provider.js';
import { useFunctionGetter } from 'dashboard/composables/store';
import MessageFormatter from 'shared/helpers/MessageFormatter.js'; import MessageFormatter from 'shared/helpers/MessageFormatter.js';
import { MESSAGE_VARIANTS } from '../../constants'; import { MESSAGE_VARIANTS } from '../../constants';
import Icon from 'next/icon/Icon.vue';
const props = defineProps({ const props = defineProps({
content: { 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(() => { const formattedContent = computed(() => {
if (variant.value === MESSAGE_VARIANTS.ACTIVITY) { if (variant.value === MESSAGE_VARIANTS.ACTIVITY) {
@ -21,8 +33,134 @@ const formattedContent = computed(() => {
return new MessageFormatter(props.content).formattedMessage; 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> </script>
<template> <template>
<span class="inline">
<span v-dompurify-html="formattedContent" class="prose prose-bubble" /> <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> </template>

View File

@ -6,6 +6,8 @@ import {
ref, ref,
getCurrentInstance, getCurrentInstance,
} from 'vue'; } from 'vue';
import { useI18n } from 'vue-i18n';
import { useLoadWithRetry } from 'dashboard/composables/loadWithRetry';
import Icon from 'next/icon/Icon.vue'; import Icon from 'next/icon/Icon.vue';
import { timeStampAppendedURL } from 'dashboard/helper/URLHelper'; import { timeStampAppendedURL } from 'dashboard/helper/URLHelper';
import { downloadFile } from '@chatwoot/utils'; import { downloadFile } from '@chatwoot/utils';
@ -27,6 +29,11 @@ defineOptions({
inheritAttrs: false, inheritAttrs: false,
}); });
const { t } = useI18n();
const { isLoaded, hasError, loadWithRetry } = useLoadWithRetry({
type: 'audio',
});
const timeStampURL = computed(() => { const timeStampURL = computed(() => {
return timeStampAppendedURL(attachment.dataUrl); return timeStampAppendedURL(attachment.dataUrl);
}); });
@ -42,19 +49,20 @@ const playbackSpeed = ref(1);
const { uid } = getCurrentInstance(); const { uid } = getCurrentInstance();
const onLoadedMetadata = () => { const onLoadedMetadata = () => {
duration.value = audioPlayer.value?.duration; if (audioPlayer.value) {
duration.value = audioPlayer.value.duration;
audioPlayer.value.playbackRate = playbackSpeed.value;
}
}; };
const playbackSpeedLabel = computed(() => { const playbackSpeedLabel = computed(() => {
return `${playbackSpeed.value}x`; return `${playbackSpeed.value}x`;
}); });
// There maybe a chance that the audioPlayer ref is not available
// When the onLoadMetadata is called, so we need to set the duration
// value when the component is mounted
onMounted(() => { onMounted(() => {
duration.value = audioPlayer.value?.duration; if (attachment.dataUrl) {
audioPlayer.value.playbackRate = playbackSpeed.value; loadWithRetry(attachment.dataUrl);
}
}); });
// Listen for global audio play events and pause if it's not this audio // Listen for global audio play events and pause if it's not this audio
@ -125,6 +133,17 @@ const downloadAudio = async () => {
</script> </script>
<template> <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 <audio
ref="audioPlayer" ref="audioPlayer"
controls controls
@ -193,3 +212,4 @@ const downloadAudio = async () => {
</div> </div>
</div> </div>
</template> </template>
</template>

View File

@ -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<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<SenderType>} [senderType=null] - The type of the sender
* @property {import('vue').Ref<Sender|null>} [sender=null] - The sender information * @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<MessageOrientation>} orientation - The visual variant of the message
* @property {import('vue').ComputedRef<MessageVariant>} variant - 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 * @property {import('vue').ComputedRef<boolean>} isBotOrAgentMessage - Does the message belong to the current user

View File

@ -159,6 +159,7 @@ useEventListener(document, 'touchend', onResizeEnd);
const inboxes = useMapGetter('inboxes/getInboxes'); const inboxes = useMapGetter('inboxes/getInboxes');
const labels = useMapGetter('labels/getLabelsOnSidebar'); const labels = useMapGetter('labels/getLabelsOnSidebar');
const dashboardApps = useMapGetter('dashboardApps/getAppsOnSidebar');
const teams = useMapGetter('teams/getMyTeams'); const teams = useMapGetter('teams/getMyTeams');
const contactCustomViews = useMapGetter('customViews/getContactCustomViews'); const contactCustomViews = useMapGetter('customViews/getContactCustomViews');
const conversationCustomViews = useMapGetter( const conversationCustomViews = useMapGetter(
@ -173,6 +174,7 @@ onMounted(() => {
store.dispatch('attributes/get'); store.dispatch('attributes/get');
store.dispatch('customViews/get', 'conversation'); store.dispatch('customViews/get', 'conversation');
store.dispatch('customViews/get', 'contact'); store.dispatch('customViews/get', 'contact');
store.dispatch('dashboardApps/get');
}); });
const sortedInboxes = computed(() => const sortedInboxes = computed(() =>
@ -222,7 +224,7 @@ const newReportRoutes = () => [
const reportRoutes = computed(() => newReportRoutes()); const reportRoutes = computed(() => newReportRoutes());
const menuItems = computed(() => { const menuItems = computed(() => {
return [ const items = [
{ {
name: 'Inbox', name: 'Inbox',
label: t('SIDEBAR.INBOX'), label: t('SIDEBAR.INBOX'),
@ -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> </script>

View File

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

View File

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

View File

@ -35,6 +35,10 @@ const props = defineProps({
return true; return true;
}, },
}, },
sendButtonLabel: {
type: String,
default: '',
},
}); });
const emit = defineEmits(['sendMessage', 'resetTemplate', 'back']); const emit = defineEmits(['sendMessage', 'resetTemplate', 'back']);
@ -43,6 +47,12 @@ const { t } = useI18n();
const processedParams = ref({}); const processedParams = ref({});
const sendButtonText = computed(() => {
return (
props.sendButtonLabel || t('WHATSAPP_TEMPLATES.PARSER.SEND_MESSAGE_LABEL')
);
});
const languageLabel = computed(() => { const languageLabel = computed(() => {
return `${t('WHATSAPP_TEMPLATES.PARSER.LANGUAGE')}: ${props.template.language || DEFAULT_LANGUAGE}`; return `${t('WHATSAPP_TEMPLATES.PARSER.LANGUAGE')}: ${props.template.language || DEFAULT_LANGUAGE}`;
}); });
@ -305,6 +315,7 @@ defineExpose({
:go-back="goBack" :go-back="goBack"
:is-valid="!v$.$invalid" :is-valid="!v$.$invalid"
:disabled="isFormInvalid" :disabled="isFormInvalid"
:send-button-text="sendButtonText"
/> />
</div> </div>
</template> </template>

View File

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

View File

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

View File

@ -1,6 +1,7 @@
<script> <script>
import AutomationActionTeamMessageInput from './AutomationActionTeamMessageInput.vue'; import AutomationActionTeamMessageInput from './AutomationActionTeamMessageInput.vue';
import AutomationActionFileInput from './AutomationFileInput.vue'; import AutomationActionFileInput from './AutomationFileInput.vue';
import AutomationActionScheduledMessageInput from './AutomationActionScheduledMessageInput.vue';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue'; import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
import NextButton from 'dashboard/components-next/button/Button.vue'; import NextButton from 'dashboard/components-next/button/Button.vue';
import SingleSelect from 'dashboard/components-next/filter/inputs/SingleSelect.vue'; import SingleSelect from 'dashboard/components-next/filter/inputs/SingleSelect.vue';
@ -11,6 +12,7 @@ export default {
components: { components: {
AutomationActionTeamMessageInput, AutomationActionTeamMessageInput,
AutomationActionFileInput, AutomationActionFileInput,
AutomationActionScheduledMessageInput,
WootMessageEditor, WootMessageEditor,
NextButton, NextButton,
SingleSelect, SingleSelect,
@ -50,6 +52,10 @@ export default {
type: String, type: String,
default: 'max-h-80', default: 'max-h-80',
}, },
conditions: {
type: Array,
default: () => [],
},
}, },
emits: ['update:modelValue', 'input', 'removeAction', 'resetAction'], emits: ['update:modelValue', 'input', 'removeAction', 'resetAction'],
computed: { computed: {
@ -98,9 +104,10 @@ export default {
castMessageVmodel: { castMessageVmodel: {
get() { get() {
if (Array.isArray(this.action_params)) { 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) { set(value) {
this.action_params = value; this.action_params = value;
@ -194,6 +201,12 @@ export default {
:placeholder="$t('AUTOMATION.ACTION.TEAM_MESSAGE_INPUT_PLACEHOLDER')" :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" 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> </div>
<span v-if="errorMessage" class="text-sm text-n-ruby-11"> <span v-if="errorMessage" class="text-sm text-n-ruby-11">
{{ errorMessage }} {{ errorMessage }}

View File

@ -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>

View File

@ -36,7 +36,8 @@ export default {
); );
this.$emit('update:modelValue', [id]); this.$emit('update:modelValue', [id]);
this.uploadState = 'uploaded'; this.uploadState = 'uploaded';
this.label = this.$t('AUTOMATION.ATTACHMENT.LABEL_UPLOADED'); this.label =
file?.name || this.$t('AUTOMATION.ATTACHMENT.LABEL_UPLOADED');
} catch (error) { } catch (error) {
this.uploadState = 'failed'; this.uploadState = 'failed';
this.label = this.$t('AUTOMATION.ATTACHMENT.LABEL_UPLOAD_FAILED'); this.label = this.$t('AUTOMATION.ATTACHMENT.LABEL_UPLOAD_FAILED');

View File

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

View File

@ -1,11 +1,24 @@
<script setup> <script setup>
import ChannelIcon from 'dashboard/components-next/icon/ChannelIcon.vue'; import ChannelIcon from 'dashboard/components-next/icon/ChannelIcon.vue';
import { computed } from 'vue';
defineProps({ const props = defineProps({
inbox: { inbox: {
type: Object, type: Object,
default: () => {}, default: () => {},
}, },
withPhoneNumber: {
type: Boolean,
default: false,
},
withProviderConnectionStatus: {
type: Boolean,
default: false,
},
});
const providerConnection = computed(() => {
return props.inbox.provider_connection?.connection;
}); });
</script> </script>
@ -18,5 +31,17 @@ defineProps({
<span class="truncate"> <span class="truncate">
{{ inbox.name }} {{ inbox.name }}
</span> </span>
<span v-if="withPhoneNumber" class="ml-2 text-n-slate-12">{{
inbox.phone_number
}}</span>
<span v-if="withProviderConnectionStatus" class="ml-2">
<fluent-icon
icon="circle"
type="filled"
:class="
providerConnection === 'open' ? 'text-green-500' : 'text-n-slate-8'
"
/>
</span>
</div> </div>
</template> </template>

View File

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

View File

@ -12,10 +12,22 @@ import VideoCallButton from '../VideoCallButton.vue';
import { INBOX_TYPES } from 'dashboard/helper/inbox'; import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import NextButton from 'dashboard/components-next/button/Button.vue'; 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 { export default {
name: 'ReplyBottomPanel', name: 'ReplyBottomPanel',
components: { NextButton, FileUpload, VideoCallButton }, components: {
NextButton,
FileUpload,
VideoCallButton,
DropdownContainer,
DropdownBody,
DropdownSection,
DropdownItem,
},
mixins: [inboxMixin], mixins: [inboxMixin],
props: { props: {
isNote: { isNote: {
@ -126,6 +138,10 @@ export default {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
showScheduleOptions: {
type: Boolean,
default: false,
},
}, },
emits: [ emits: [
'replaceText', 'replaceText',
@ -133,6 +149,7 @@ export default {
'selectWhatsappTemplate', 'selectWhatsappTemplate',
'selectContentTemplate', 'selectContentTemplate',
'toggleQuotedReply', 'toggleQuotedReply',
'scheduleMessage',
], ],
setup(props) { setup(props) {
const { setSignatureFlagForInbox, fetchSignatureFlagFromUISettings } = const { setSignatureFlagForInbox, fetchSignatureFlagFromUISettings } =
@ -283,6 +300,9 @@ export default {
toggleInsertArticle() { toggleInsertArticle() {
this.$emit('toggleInsertArticle'); this.$emit('toggleInsertArticle');
}, },
openScheduleModal() {
this.$emit('scheduleMessage');
},
}, },
}; };
</script> </script>
@ -346,7 +366,7 @@ export default {
v-if="showMessageSignatureButton" v-if="showMessageSignatureButton"
v-tooltip.top-end="signatureToggleTooltip" v-tooltip.top-end="signatureToggleTooltip"
icon="i-ph-signature" icon="i-ph-signature"
slate :color="sendWithSignature ? 'blue' : 'slate'"
faded faded
sm sm
@click="toggleMessageSignature" @click="toggleMessageSignature"
@ -405,7 +425,42 @@ export default {
/> />
</div> </div>
<div class="right-wrap"> <div class="right-wrap">
<div v-if="showScheduleOptions && !isNote" class="flex">
<NextButton <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" :label="sendButtonText"
type="submit" type="submit"
sm sm

View File

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

View File

@ -4,6 +4,8 @@ import { ref, provide } from 'vue';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents'; import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useLabelSuggestions } from 'dashboard/composables/useLabelSuggestions'; import { useLabelSuggestions } from 'dashboard/composables/useLabelSuggestions';
import { useSnakeCase } from 'dashboard/composables/useTransformKeys'; import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
import { useAdmin } from 'dashboard/composables/useAdmin';
import { useAlert } from 'dashboard/composables';
// components // components
import ReplyBox from './ReplyBox.vue'; import ReplyBox from './ReplyBox.vue';
@ -35,6 +37,7 @@ import { REPLY_POLICY } from 'shared/constants/links';
import wootConstants from 'dashboard/constants/globals'; import wootConstants from 'dashboard/constants/globals';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage'; import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { INBOX_TYPES } from 'dashboard/helper/inbox'; import { INBOX_TYPES } from 'dashboard/helper/inbox';
import WhatsappLinkDeviceModal from '../../../routes/dashboard/settings/inbox/components/WhatsappLinkDeviceModal.vue';
export default { export default {
components: { components: {
@ -43,9 +46,11 @@ export default {
Banner, Banner,
ConversationLabelSuggestion, ConversationLabelSuggestion,
Spinner, Spinner,
WhatsappLinkDeviceModal,
}, },
mixins: [inboxMixin], mixins: [inboxMixin],
setup() { setup() {
const { isAdmin } = useAdmin();
const isPopOutReplyBox = ref(false); const isPopOutReplyBox = ref(false);
const conversationPanelRef = ref(null); const conversationPanelRef = ref(null);
@ -73,6 +78,7 @@ export default {
getLabelSuggestions, getLabelSuggestions,
isLabelSuggestionFeatureEnabled, isLabelSuggestionFeatureEnabled,
conversationPanelRef, conversationPanelRef,
isAdmin,
}; };
}, },
data() { data() {
@ -84,6 +90,7 @@ export default {
isProgrammaticScroll: false, isProgrammaticScroll: false,
messageSentSinceOpened: false, messageSentSinceOpened: false,
labelSuggestions: [], labelSuggestions: [],
showLinkDeviceModal: false,
}; };
}, },
@ -94,6 +101,9 @@ export default {
listLoadingStatus: 'getAllMessagesLoaded', listLoadingStatus: 'getAllMessagesLoaded',
currentAccountId: 'getCurrentAccountId', currentAccountId: 'getCurrentAccountId',
}), }),
currentInbox() {
return this.$store.getters['inboxes/getInbox'](this.currentChat.inbox_id);
},
isOpen() { isOpen() {
return this.currentChat?.status === wootConstants.STATUS_TYPE.OPEN; return this.currentChat?.status === wootConstants.STATUS_TYPE.OPEN;
}, },
@ -244,6 +254,13 @@ export default {
return { incoming, outgoing }; return { incoming, outgoing };
}, },
inboxSupportsEdit() {
// Currently only Baileys WhatsApp channel supports message editing
return this.isAWhatsAppBaileysChannel;
},
inboxProviderConnection() {
return this.currentInbox.provider_connection?.connection;
},
}, },
watch: { watch: {
@ -437,12 +454,75 @@ export default {
const payload = useSnakeCase(message); const payload = useSnakeCase(message);
await this.$store.dispatch('sendMessageWithData', payload); await this.$store.dispatch('sendMessageWithData', payload);
}, },
getInReplyToMessage(parentMessage) {
if (!parentMessage) return {};
const inReplyToMessageId = parentMessage.content_attributes?.in_reply_to;
if (!inReplyToMessageId) return {};
return this.currentChat?.messages.find(message => {
if (message.id === inReplyToMessageId) {
return true;
}
return false;
});
},
onOpenLinkDeviceModal() {
this.showLinkDeviceModal = true;
},
onCloseLinkDeviceModal() {
this.showLinkDeviceModal = false;
},
onSetupProviderConnection() {
this.$store
.dispatch('inboxes/setupChannelProvider', this.inbox.id)
.catch(e => {
// eslint-disable-next-line no-console
console.error('Error setting up provider connection:', e);
useAlert(
this.$t(
'CONVERSATION.INBOX.WHATSAPP_PROVIDER_CONNECTION.RECONNECT_FAILED'
)
);
});
},
}, },
}; };
</script> </script>
<template> <template>
<div class="flex flex-col justify-between flex-grow h-full min-w-0 m-0"> <div class="flex flex-col justify-between flex-grow h-full min-w-0 m-0">
<template v-if="isAWhatsAppBaileysChannel || isAWhatsAppZapiChannel">
<WhatsappLinkDeviceModal
v-if="showLinkDeviceModal"
:show="showLinkDeviceModal"
:on-close="onCloseLinkDeviceModal"
:inbox="currentInbox"
/>
<Banner
v-if="inboxProviderConnection !== 'open'"
color-scheme="alert"
class="mt-2 mx-2 rounded-lg overflow-hidden"
:banner-message="
isAdmin
? $t(
'CONVERSATION.INBOX.WHATSAPP_PROVIDER_CONNECTION.NOT_CONNECTED'
)
: $t(
'CONVERSATION.INBOX.WHATSAPP_PROVIDER_CONNECTION.NOT_CONNECTED_CONTACT_ADMIN'
)
"
has-action-button
:action-button-label="
isAdmin
? $t('CONVERSATION.INBOX.WHATSAPP_PROVIDER_CONNECTION.LINK_DEVICE')
: ''
"
:action-button-icon="isAdmin ? '' : 'i-lucide-refresh-cw'"
@primary-action="
isAdmin ? onOpenLinkDeviceModal() : onSetupProviderConnection()
"
/>
</template>
<Banner <Banner
v-if="!currentChat.can_reply" v-if="!currentChat.can_reply"
color-scheme="alert" color-scheme="alert"
@ -464,6 +544,7 @@ export default {
:first-unread-id="unReadMessages[0]?.id" :first-unread-id="unReadMessages[0]?.id"
:is-an-email-channel="isAnEmailChannel" :is-an-email-channel="isAnEmailChannel"
:inbox-supports-reply-to="inboxSupportsReplyTo" :inbox-supports-reply-to="inboxSupportsReplyTo"
:inbox-supports-edit="inboxSupportsEdit"
:messages="getMessages" :messages="getMessages"
@retry="handleMessageRetry" @retry="handleMessageRetry"
> >

View File

@ -4,6 +4,7 @@ import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables'; import { useAlert } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings'; import { useUISettings } from 'dashboard/composables/useUISettings';
import { useTrack } from 'dashboard/composables'; import { useTrack } from 'dashboard/composables';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins'; import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import { FEATURE_FLAGS } from 'dashboard/featureFlags'; import { FEATURE_FLAGS } from 'dashboard/featureFlags';
@ -31,6 +32,7 @@ import {
} from '@chatwoot/utils'; } from '@chatwoot/utils';
import WhatsappTemplates from './WhatsappTemplates/Modal.vue'; import WhatsappTemplates from './WhatsappTemplates/Modal.vue';
import ContentTemplates from './ContentTemplates/ContentTemplatesModal.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 { MESSAGE_MAX_LENGTH } from 'shared/helpers/MessageTypeHelper';
import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin'; import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin';
import { trimContent, debounce, getRecipients } from '@chatwoot/utils'; import { trimContent, debounce, getRecipients } from '@chatwoot/utils';
@ -46,11 +48,7 @@ import {
CAPTAIN_EVENTS, CAPTAIN_EVENTS,
} from '../../../helper/AnalyticsHelper/events'; } from '../../../helper/AnalyticsHelper/events';
import fileUploadMixin from 'dashboard/mixins/fileUploadMixin'; import fileUploadMixin from 'dashboard/mixins/fileUploadMixin';
import { import { appendSignature } from 'dashboard/helper/editorHelper';
appendSignature,
removeSignature,
getEffectiveChannelType,
} from 'dashboard/helper/editorHelper';
import { useCopilotReply } from 'dashboard/composables/useCopilotReply'; import { useCopilotReply } from 'dashboard/composables/useCopilotReply';
import { useKbd } from 'dashboard/composables/utils/useKbd'; import { useKbd } from 'dashboard/composables/utils/useKbd';
import { isFileTypeAllowedForChannel } from 'shared/helpers/FileHelper'; import { isFileTypeAllowedForChannel } from 'shared/helpers/FileHelper';
@ -80,6 +78,7 @@ export default {
QuotedEmailPreview, QuotedEmailPreview,
CopilotEditorSection, CopilotEditorSection,
CopilotReplyBottomPanel, CopilotReplyBottomPanel,
ScheduledMessageModal,
}, },
mixins: [inboxMixin, fileUploadMixin, keyboardEventListenerMixins], mixins: [inboxMixin, fileUploadMixin, keyboardEventListenerMixins],
props: { props: {
@ -98,6 +97,8 @@ export default {
fetchQuotedReplyFlagFromUISettings, fetchQuotedReplyFlagFromUISettings,
} = useUISettings(); } = useUISettings();
const { formatMessage } = useMessageFormatter();
const replyEditor = useTemplateRef('replyEditor'); const replyEditor = useTemplateRef('replyEditor');
const copilot = useCopilotReply(); const copilot = useCopilotReply();
const shortcutKey = useKbd(['$mod', '+', 'enter']); const shortcutKey = useKbd(['$mod', '+', 'enter']);
@ -111,6 +112,7 @@ export default {
replyEditor, replyEditor,
copilot, copilot,
shortcutKey, shortcutKey,
formatMessage,
}; };
}, },
data() { data() {
@ -138,7 +140,7 @@ export default {
showVariablesMenu: false, showVariablesMenu: false,
newConversationModalActive: false, newConversationModalActive: false,
showArticleSearchPopover: false, showArticleSearchPopover: false,
hasRecordedAudio: false, showScheduledMessageModal: false,
copilotAcceptedMessages: {}, copilotAcceptedMessages: {},
}; };
}, },
@ -301,6 +303,9 @@ export default {
hasAttachments() { hasAttachments() {
return this.attachedFiles.length; return this.attachedFiles.length;
}, },
hasRecordedAudio() {
return this.attachedFiles.some(file => file.isRecordedAudio);
},
showAudioRecorder() { showAudioRecorder() {
return !this.isOnPrivateNote && this.showFileUpload; return !this.isOnPrivateNote && this.showFileUpload;
}, },
@ -431,6 +436,25 @@ export default {
!this.currentChat.can_reply !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: { watch: {
currentChat(conversation, oldConversation) { currentChat(conversation, oldConversation) {
@ -612,24 +636,9 @@ export default {
const key = this.getDraftKey(); const key = this.getDraftKey();
const messageFromStore = const messageFromStore =
this.$store.getters['draftMessages/get'](key) || ''; this.$store.getters['draftMessages/get'](key) || '';
this.message = messageFromStore;
// ensure that the message has signature set based on the ui setting
this.message = this.toggleSignatureForDraft(messageFromStore);
} }
}, },
toggleSignatureForDraft(message) {
if (this.isPrivate) {
return message;
}
const effectiveChannelType = getEffectiveChannelType(
this.channelType,
this.inbox?.medium || ''
);
return this.sendWithSignature
? appendSignature(message, this.messageSignature, effectiveChannelType)
: removeSignature(message, this.messageSignature, effectiveChannelType);
},
removeFromDraft() { removeFromDraft() {
if (this.conversationIdByRoute) { if (this.conversationIdByRoute) {
const key = this.getDraftKey(); const key = this.getDraftKey();
@ -682,10 +691,23 @@ export default {
!this.showMentions && !this.showMentions &&
!this.showCannedMenu && !this.showCannedMenu &&
!this.showVariablesMenu && !this.showVariablesMenu &&
!this.showScheduledMessageModal &&
this.isFocused && this.isFocused &&
this.isEditorHotKeyEnabled(selectedKey) this.isEditorHotKeyEnabled(selectedKey)
); );
}, },
applySignatureToMessage(message) {
if (!this.sendWithSignature || !this.messageSignature) {
return message;
}
const { signature_position, signature_separator } =
this.currentUser?.ui_settings || {};
const signatureSettings = {
position: signature_position || 'top',
separator: signature_separator || 'blank',
};
return appendSignature(message, this.messageSignature, signatureSettings);
},
onPaste(e) { onPaste(e) {
// Don't handle paste if compose new conversation modal is open // Don't handle paste if compose new conversation modal is open
if (this.newConversationModalActive) return; if (this.newConversationModalActive) return;
@ -693,6 +715,9 @@ export default {
// Don't handle paste if editor is disabled // Don't handle paste if editor is disabled
if (this.isEditorDisabled) return; 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) // Filter valid files (non-zero size)
Array.from(e.clipboardData.files) Array.from(e.clipboardData.files)
.filter(file => file.size > 0) .filter(file => file.size > 0)
@ -741,6 +766,21 @@ export default {
hideContentTemplatesModal() { hideContentTemplatesModal() {
this.showContentTemplatesModal = false; 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() { confirmOnSendReply() {
if (this.isReplyButtonDisabled) { if (this.isReplyButtonDisabled) {
return; return;
@ -792,23 +832,7 @@ export default {
isPrivate, isPrivate,
{ editorMessage = '', copilotAcceptedMessage = '' } = {} { editorMessage = '', copilotAcceptedMessage = '' } = {}
) { ) {
const normalizeForComparison = message => { const normalizeForComparison = message => trimContent(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 normalizedAcceptedMessage = normalizeForComparison( const normalizedAcceptedMessage = normalizeForComparison(
copilotAcceptedMessage copilotAcceptedMessage
@ -896,21 +920,6 @@ export default {
this.hideContentTemplatesModal(); this.hideContentTemplatesModal();
}, },
replaceText(message) { replaceText(message) {
if (this.sendWithSignature && !this.private) {
// if signature is enabled, append it to the message
// appendSignature ensures that the signature is not duplicated
// so we don't need to check if the signature is already present
const effectiveChannelType = getEffectiveChannelType(
this.channelType,
this.inbox?.medium || ''
);
message = appendSignature(
message,
this.messageSignature,
effectiveChannelType
);
}
const updatedMessage = replaceVariablesInMessage({ const updatedMessage = replaceVariablesInMessage({
message, message,
variables: this.messageVariables, variables: this.messageVariables,
@ -949,18 +958,6 @@ export default {
clearMessage() { clearMessage() {
this.message = ''; this.message = '';
this.clearCopilotAcceptedMessage(); 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.attachedFiles = [];
this.isRecordingAudio = false; this.isRecordingAudio = false;
this.resetReplyToMessage(); this.resetReplyToMessage();
@ -979,6 +976,9 @@ export default {
this.isRecordingAudio = !this.isRecordingAudio; this.isRecordingAudio = !this.isRecordingAudio;
if (!this.isRecordingAudio) { if (!this.isRecordingAudio) {
this.resetAudioRecorderInput(); this.resetAudioRecorderInput();
this.onTypingOff();
} else {
this.onRecording();
} }
}, },
toggleAudioRecorderPlayPause() { toggleAudioRecorderPlayPause() {
@ -986,6 +986,7 @@ export default {
if (!this.recordingAudioState) { if (!this.recordingAudioState) {
this.$refs.audioRecorderInput.stopRecording(); this.$refs.audioRecorderInput.stopRecording();
} else { } else {
this.onTypingOff();
this.$refs.audioRecorderInput.playPause(); this.$refs.audioRecorderInput.playPause();
} }
}, },
@ -997,6 +998,9 @@ export default {
onTypingOn() { onTypingOn() {
this.toggleTyping('on'); this.toggleTyping('on');
}, },
onRecording() {
this.toggleTyping('recording');
},
onTypingOff() { onTypingOff() {
this.toggleTyping('off'); this.toggleTyping('off');
}, },
@ -1012,7 +1016,9 @@ export default {
}, },
onFinishRecorder(file) { onFinishRecorder(file) {
this.recordingAudioState = 'stopped'; this.recordingAudioState = 'stopped';
this.hasRecordedAudio = true;
this.removeRecordedAudio();
// Added a new key isRecordedAudio to the file to find it's and recorded audio // Added a new key isRecordedAudio to the file to find it's and recorded audio
// Because to filter and show only non recorded audio and other attachments // Because to filter and show only non recorded audio and other attachments
const autoRecordedFile = { const autoRecordedFile = {
@ -1036,6 +1042,10 @@ export default {
}); });
}, },
attachFile({ blob, file }) { attachFile({ blob, file }) {
if (file?.isRecordedAudio) {
this.removeRecordedAudio();
}
const reader = new FileReader(); const reader = new FileReader();
reader.readAsDataURL(file.file); reader.readAsDataURL(file.file);
reader.onloadend = () => { reader.onloadend = () => {
@ -1068,8 +1078,10 @@ export default {
getMultipleMessagesPayload(message) { getMultipleMessagesPayload(message) {
const multipleMessagePayload = []; const multipleMessagePayload = [];
if (this.attachedFiles && this.attachedFiles.length) { const messageWithSignature = this.applySignatureToMessage(message);
let caption = this.isAnInstagramChannel ? '' : message;
if (this.attachedFiles?.length) {
let caption = this.isAnInstagramChannel ? '' : messageWithSignature;
this.attachedFiles.forEach(attachment => { this.attachedFiles.forEach(attachment => {
const attachedFile = this.globalConfig.directUploadsEnabled const attachedFile = this.globalConfig.directUploadsEnabled
? attachment.blobSignedId ? attachment.blobSignedId
@ -1089,8 +1101,7 @@ export default {
}); });
} }
const hasNoAttachments = const hasNoAttachments = !this.attachedFiles?.length;
!this.attachedFiles || !this.attachedFiles.length;
// For Instagram, we need a separate text message // For Instagram, we need a separate text message
// For WhatsApp, we only need a text message if there are no attachments // For WhatsApp, we only need a text message if there are no attachments
if ( if (
@ -1099,7 +1110,7 @@ export default {
) { ) {
let messagePayload = { let messagePayload = {
conversationId: this.currentChat.id, conversationId: this.currentChat.id,
message, message: messageWithSignature,
private: false, private: false,
sender: this.sender, sender: this.sender,
}; };
@ -1112,23 +1123,32 @@ export default {
return multipleMessagePayload; return multipleMessagePayload;
}, },
getMessagePayload(message) { getMessagePayload(message) {
const messageWithQuote = this.getMessageWithQuotedEmailText(message); let finalMessage = this.getMessageWithQuotedEmailText(message);
if (!this.isPrivate) {
finalMessage = this.applySignatureToMessage(finalMessage);
}
let messagePayload = { let messagePayload = {
conversationId: this.currentChat.id, conversationId: this.currentChat.id,
message: messageWithQuote, message: finalMessage,
private: this.isPrivate, private: this.isPrivate,
sender: this.sender, sender: this.sender,
}; };
messagePayload = this.setReplyToInPayload(messagePayload); messagePayload = this.setReplyToInPayload(messagePayload);
if (this.attachedFiles && this.attachedFiles.length) { if (this.attachedFiles?.length) {
messagePayload.files = []; messagePayload.files = [];
messagePayload.isRecordedAudio = [];
this.attachedFiles.forEach(attachment => { this.attachedFiles.forEach(attachment => {
if (this.globalConfig.directUploadsEnabled) { if (this.globalConfig.directUploadsEnabled) {
messagePayload.files.push(attachment.blobSignedId); messagePayload.files.push(attachment.blobSignedId);
} else { } else {
messagePayload.files.push(attachment.resource.file); messagePayload.files.push(attachment.resource.file);
if (attachment.isRecordedAudio) {
messagePayload.isRecordedAudio.push(
attachment.resource.file.name
);
}
} }
}); });
} }
@ -1203,8 +1223,10 @@ export default {
this.recordingAudioDurationText = '00:00'; this.recordingAudioDurationText = '00:00';
this.isRecordingAudio = false; this.isRecordingAudio = false;
this.recordingAudioState = ''; this.recordingAudioState = '';
this.hasRecordedAudio = false;
// Only clear the recorded audio when we click toggle button. // Only clear the recorded audio when we click toggle button.
this.removeRecordedAudio();
},
removeRecordedAudio() {
this.attachedFiles = this.attachedFiles.filter( this.attachedFiles = this.attachedFiles.filter(
file => !file?.isRecordedAudio file => !file?.isRecordedAudio
); );
@ -1403,11 +1425,13 @@ export default {
:message="message" :message="message"
:portal-slug="connectedPortalSlug" :portal-slug="connectedPortalSlug"
:new-conversation-modal-active="newConversationModalActive" :new-conversation-modal-active="newConversationModalActive"
:show-schedule-options="!isPrivate"
@select-whatsapp-template="openWhatsappTemplateModal" @select-whatsapp-template="openWhatsappTemplateModal"
@select-content-template="openContentTemplateModal" @select-content-template="openContentTemplateModal"
@replace-text="replaceText" @replace-text="replaceText"
@toggle-insert-article="toggleInsertArticle" @toggle-insert-article="toggleInsertArticle"
@toggle-quoted-reply="toggleQuotedReply" @toggle-quoted-reply="toggleQuotedReply"
@schedule-message="openScheduledMessageModal"
/> />
</Transition> </Transition>
@ -1427,6 +1451,15 @@ export default {
@cancel="hideContentTemplatesModal" @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 <woot-confirm-modal
ref="confirmDialog" ref="confirmDialog"
:title="$t('CONVERSATION.REPLYBOX.UNDEFINED_VARIABLES.TITLE')" :title="$t('CONVERSATION.REPLYBOX.UNDEFINED_VARIABLES.TITLE')"

View File

@ -12,9 +12,13 @@ export default {
default: false, default: false,
}, },
inboxId: { inboxId: {
type: Number, type: [Number, String],
default: undefined, default: undefined,
}, },
sendButtonLabel: {
type: String,
default: '',
},
}, },
emits: ['onSend', 'cancel', 'update:show'], emits: ['onSend', 'cancel', 'update:show'],
data() { data() {
@ -39,6 +43,13 @@ export default {
: this.$t('WHATSAPP_TEMPLATES.MODAL.SUBTITLE'); : this.$t('WHATSAPP_TEMPLATES.MODAL.SUBTITLE');
}, },
}, },
watch: {
show(newVal) {
if (newVal) {
this.selectedWaTemplate = null;
}
},
},
methods: { methods: {
pickTemplate(template) { pickTemplate(template) {
this.selectedWaTemplate = template; this.selectedWaTemplate = template;
@ -71,6 +82,7 @@ export default {
<WhatsAppTemplateReply <WhatsAppTemplateReply
v-else v-else
:template="selectedWaTemplate" :template="selectedWaTemplate"
:send-button-label="sendButtonLabel"
@reset-template="onResetTemplate" @reset-template="onResetTemplate"
@send-message="onSendMessage" @send-message="onSendMessage"
/> />

View File

@ -2,11 +2,15 @@
import WhatsAppTemplateParser from 'dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue'; import WhatsAppTemplateParser from 'dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue';
import NextButton from 'dashboard/components-next/button/Button.vue'; import NextButton from 'dashboard/components-next/button/Button.vue';
defineProps({ const props = defineProps({
template: { template: {
type: Object, type: Object,
default: () => ({}), default: () => ({}),
}, },
sendButtonLabel: {
type: String,
default: '',
},
}); });
const emit = defineEmits(['sendMessage', 'resetTemplate']); const emit = defineEmits(['sendMessage', 'resetTemplate']);
@ -23,11 +27,14 @@ const handleResetTemplate = () => {
<template> <template>
<div class="w-full"> <div class="w-full">
<WhatsAppTemplateParser <WhatsAppTemplateParser
:template="template" :template="props.template"
:send-button-label="props.sendButtonLabel"
@send-message="handleSendMessage" @send-message="handleSendMessage"
@reset-template="handleResetTemplate" @reset-template="handleResetTemplate"
> >
<template #actions="{ sendMessage, resetTemplate, disabled }"> <template
#actions="{ sendMessage, resetTemplate, disabled, sendButtonText }"
>
<footer class="flex gap-2 justify-end"> <footer class="flex gap-2 justify-end">
<NextButton <NextButton
faded faded
@ -38,7 +45,7 @@ const handleResetTemplate = () => {
/> />
<NextButton <NextButton
type="button" type="button"
:label="$t('WHATSAPP_TEMPLATES.PARSER.SEND_MESSAGE_LABEL')" :label="sendButtonText"
:disabled="disabled" :disabled="disabled"
@click="sendMessage" @click="sendMessage"
/> />

View File

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

View File

@ -190,6 +190,27 @@ describe('useAutomation', () => {
expect(automation.value.actions[0].action_params).toEqual([]); 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', () => { it('manifests custom attributes correctly', () => {
const { manifestCustomAttributes, automationTypes } = useAutomation(); const { manifestCustomAttributes, automationTypes } = useAutomation();
automationTypes.message_created = { conditions: [] }; automationTypes.message_created = { conditions: [] };

View File

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

View File

@ -15,6 +15,7 @@ import {
// AUTOMATION_RULE_EVENTS, // AUTOMATION_RULE_EVENTS,
// AUTOMATION_ACTION_TYPES, // AUTOMATION_ACTION_TYPES,
AUTOMATIONS, AUTOMATIONS,
DEFAULT_SCHEDULED_MESSAGE_DELAY_MINUTES,
} from 'dashboard/routes/dashboard/settings/automation/constants.js'; } 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. * @param {number} index - The index of the action to reset.
*/ */
const resetAction = index => { const resetAction = index => {
const action = automation.value.actions[index];
const newActions = [...automation.value.actions]; 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] = {
...newActions[index], ...newActions[index],
action_params: [], action_params: actionParams,
}; };
automation.value.actions = newActions; automation.value.actions = newActions;

View File

@ -72,6 +72,10 @@ export function useEditableAutomation() {
[...params].includes(item.id) [...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') { if (inputType === 'team_message') {
return { return {
team_ids: [...getActionDropdownValues(action.action_name)].filter( team_ids: [...getActionDropdownValues(action.action_name)].filter(

View File

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

View File

@ -2,6 +2,7 @@ import { computed } from 'vue';
import { useStore, useStoreGetters } from 'dashboard/composables/store'; import { useStore, useStoreGetters } from 'dashboard/composables/store';
export const DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER = Object.freeze([ export const DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER = Object.freeze([
{ name: 'scheduled_messages' },
{ name: 'conversation_actions' }, { name: 'conversation_actions' },
{ name: 'macros' }, { name: 'macros' },
{ name: 'conversation_info' }, { 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. // If the sidebar order doesn't have the new elements, then add them to the list.
DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER.forEach(item => { DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER.forEach(item => {
if (!itemsOrderCopy.find(i => i.name === item.name)) { if (!itemsOrderCopy.find(i => i.name === item.name)) {
if (item.name === 'scheduled_messages') {
itemsOrderCopy.unshift(item);
} else {
itemsOrderCopy.push(item); itemsOrderCopy.push(item);
} }
}
}); });
return itemsOrderCopy; return itemsOrderCopy;
}); });
@ -126,7 +131,7 @@ const isEditorHotKeyEnabled = (key, uiSettings) => {
enter_to_send_enabled: enterToSendEnabled, enter_to_send_enabled: enterToSendEnabled,
} = uiSettings.value || {}; } = uiSettings.value || {};
if (!editorMessageKey) { if (!editorMessageKey) {
return key === (enterToSendEnabled ? 'enter' : 'cmd_enter'); return key === (enterToSendEnabled ? 'cmd_enter' : 'enter');
} }
return editorMessageKey === key; return editorMessageKey === key;
}; };

View File

@ -34,6 +34,9 @@ class ActionCableConnector extends BaseActionCableConnector {
'conversation.updated': this.onConversationUpdated, 'conversation.updated': this.onConversationUpdated,
'account.cache_invalidated': this.onCacheInvalidate, 'account.cache_invalidated': this.onCacheInvalidate,
'copilot.message.created': this.onCopilotMessageCreated, '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(); 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 }) => { onTypingOn = ({ conversation, user }) => {
const conversationId = conversation.id; const conversationId = conversation.id;

View File

@ -6,11 +6,19 @@ const allElementsNumbers = arr => {
return arr.every(elem => typeof elem === 'number'); return arr.every(elem => typeof elem === 'number');
}; };
const allElementsPlainObjects = arr => {
return arr.every(
elem => typeof elem === 'object' && elem !== null && !elem.id
);
};
const formatArray = params => { const formatArray = params => {
if (params.length <= 0) { if (params.length <= 0) {
params = []; params = [];
} else if (allElementsString(params) || allElementsNumbers(params)) { } else if (allElementsString(params) || allElementsNumbers(params)) {
params = [...params]; params = [...params];
} else if (allElementsPlainObjects(params)) {
params = [...params];
} else { } else {
params = params.map(val => val.id); params = params.map(val => val.id);
} }

View File

@ -158,10 +158,21 @@ export const getConditionOptions = ({
}; };
export const getFileName = (action, files = []) => { 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 (!blobId) return '';
if (action.action_name === 'send_attachment') { if (
const file = files.find(item => item.blob_id === blobId); 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(); if (file) return file.filename.toString();
} }
return ''; return '';
@ -285,7 +296,7 @@ export const getInputType = (
return getCustomAttributeInputType(customAttribute.attribute_display_type); return getCustomAttributeInputType(customAttribute.attribute_display_type);
} }
const type = getAutomationType(automationTypes, automation, key); const type = getAutomationType(automationTypes, automation, key);
return type.inputType; return type?.inputType ?? '';
}; };
/** /**
@ -311,7 +322,7 @@ export const getOperators = (
} }
} }
const type = getAutomationType(automationTypes, automation, key); 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. * @returns {string} The custom attribute type.
*/ */
export const getCustomAttributeType = (automationTypes, automation, key) => { export const getCustomAttributeType = (automationTypes, automation, key) => {
return automationTypes[automation.event_name].conditions.find( return (
i => i.key === key automationTypes[automation.event_name].conditions.find(i => i.key === key)
).customAttributeType; ?.customAttributeType ?? ''
);
}; };
/** /**
@ -334,8 +346,12 @@ export const getCustomAttributeType = (automationTypes, automation, key) => {
* @returns {boolean} True if the action input should be shown, false otherwise. * @returns {boolean} True if the action input should be shown, false otherwise.
*/ */
export const showActionInput = (automationActionTypes, action) => { export const showActionInput = (automationActionTypes, action) => {
if (action === 'send_email_to_team' || action === 'send_message') if (
action === 'send_email_to_team' ||
action === 'send_message' ||
action === 'create_scheduled_message'
)
return false; return false;
const type = automationActionTypes.find(i => i.key === action).inputType; const type = automationActionTypes.find(i => i.key === action)?.inputType;
return !!type; return !!type;
}; };

View File

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

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