Merge branch 'main' into chore/merge-4.13.0

Resolves 26 conflicts via manual review. Key decisions:

- signature: kept fork's send-time architecture (PR #79), discarded upstream's
  editor-manipulation functions
- WhatsApp incoming: combined fork's two-layer locking (source_id + contact
  phone) with upstream's blocked-contact drop. Fixed pre-existing regression
  where echoes were silently dropped
- InstallationConfig: upstream's simplified coder (validated against legacy
  YAML-in-jsonb data)
- schema.rb: regenerated, stripped kanban tables from other branches,
  restored f_unaccent SQL function
This commit is contained in:
gabrieljablonski 2026-04-17 16:23:47 -03:00
commit 112385fd9e
938 changed files with 75366 additions and 1761 deletions

View File

@ -0,0 +1,170 @@
---
name: release-notes
description: Use this skill whenever you are about to cut, edit, or backfill a GitHub release for fazer-ai/chatwoot. Generates the bilingual user-notes blocks (pt-BR + en) embedded in the release body for non-technical end users. Trigger before calling `gh release create`, `gh release edit`, or any flow that touches a release body on this repo (including the `release` skill from fazer-ai-tools and any retroactive backfill of historical releases).
allowed-tools: Bash, Read, Edit, Write, Grep, Glob
---
# Release Notes (user-facing)
Every release cut from `fazer-ai/chatwoot` must embed bilingual user-notes blocks in the release body, written for non-technical end users (operators, admins, superadmins). Do not put implementation detail in these blocks.
## Required blocks (bilingual, both mandatory)
The release body must contain both an English block and a Portuguese block, in this order. Use H2 headings with country flags **outside** the blocks to separate the two sections visually on GitHub. The fazer.ai page only renders the content **inside** the `<!-- user-notes:xx:start -->` / `<!-- user-notes:xx:end -->` markers, so the H2 headings, the flags, and any commit list above are invisible there.
```markdown
## 🇺🇸 English
<!-- user-notes:en:start -->
... markdown in english ...
<!-- user-notes:en:end -->
## 🇧🇷 Português
<!-- user-notes:pt-BR:start -->
... markdown em português ...
<!-- user-notes:pt-BR:end -->
```
The two versions must be **equivalent in content**, written naturally in each language. They are **not** literal translations:
- en: "Drag conversations between columns faster."
- pt-BR: "Agora você pode arrastar conversas entre colunas mais rápido."
## Mirroring upstream releases
Downstream forks (e.g. `fazer-ai/chatwoot-pro`) that mirror a CE release must declare it with a blockquote at the top of each user-notes block, inside the markers. List all mirrored CE versions when there's more than one. CE releases never carry this marker.
```markdown
<!-- user-notes:en:start -->
> Includes changes from Chatwoot fazer.ai v4.12.0-fazer-ai.47.
...
<!-- user-notes:en:end -->
<!-- user-notes:pt-BR:start -->
> Inclui mudanças do Chatwoot fazer.ai v4.12.0-fazer-ai.47.
...
<!-- user-notes:pt-BR:end -->
```
## Audience and tone
Write for an **end user, not a developer**. Readers do not read code, do not know what a PR is, and do not care about refactors.
- **Present tense, active voice.** "Agora você pode reordenar etiquetas" / "You can now reorder labels". Not "Adicionada a possibilidade de…" / "Added the ability to…".
- **Lead with benefit, not implementation.** "Carregamento mais rápido em conexões lentas" / "Faster loading on slow connections" beats "Preload de componentes de rota no módulo internal-chat".
- **Plain language.** No jargon, no internal codenames, no function/file/library/module names.
- **No PR numbers, commit hashes, `#1234` references, or links to internal issues.**
- **Group by theme**, not by PR. Use these headers (omit empty ones, but keep the same set in both locales):
| pt-BR | en | When to use |
| ----------------- | --------------- | ---------------------------------------------------- |
| `### ✨ Novidades` | `### ✨ What's new` | New user-visible features |
| `### ⚡ Melhorias` | `### ⚡ Improvements` | Refinements to existing features (perf, UX, polish) |
| `### 🐛 Correções` | `### 🐛 Fixes` | Bugs the user might have noticed |
## Full release body example
The release body should preserve the auto-generated `## Changes` commit list at the top and append both locale sections after it:
```markdown
## Changes
- feat(internal-chat): implement internal chat system for agents (#247)
- fix(signatures): allow admins to manage inbox signatures without explicit membership (#260)
## 🇺🇸 English
<!-- user-notes:en:start -->
### ✨ What's new
- **Internal agent chat.** Your team can now message each other right inside Chatwoot, no extra tool needed.
### ⚡ Improvements
- **Faster navigation on slow connections.** Switching between conversations feels more responsive.
### 🐛 Fixes
- **Inbox signatures.** Admins can manage signatures without having to be a member of the inbox.
<!-- user-notes:en:end -->
## 🇧🇷 Português
<!-- user-notes:pt-BR:start -->
### ✨ Novidades
- **Chat interno entre agentes.** Sua equipe agora troca mensagens diretamente dentro do Chatwoot, sem precisar de outra ferramenta.
### ⚡ Melhorias
- **Navegação mais rápida em conexões lentas.** A troca entre conversas ficou mais responsiva.
### 🐛 Correções
- **Assinaturas de caixas de entrada.** Administradores conseguem gerenciar assinaturas mesmo sem participar da caixa.
<!-- user-notes:pt-BR:end -->
```
Bold the change name, then a single short sentence describing the user benefit. Keep each item to 1 or 2 lines.
If a release has nothing user-visible, write a single generic line in both locales rather than dumping a PR list:
```markdown
## 🇺🇸 English
<!-- user-notes:en:start -->
Bug fixes and internal improvements.
<!-- user-notes:en:end -->
## 🇧🇷 Português
<!-- user-notes:pt-BR:start -->
Correções de bugs e melhorias internas.
<!-- user-notes:pt-BR:end -->
```
## Quality checklist (run before publishing)
Run this checklist on **both** locale blocks:
- [ ] Both `en` and `pt-BR` blocks are present, with the exact tag spelling shown above, and the `en` block comes first.
- [ ] Both sections are wrapped by `## 🇺🇸 English` / `## 🇧🇷 Português` H2 headings outside the markers.
- [ ] Both blocks contain equivalent content (same items, same order, same themes), written naturally in each language. Not a literal translation.
- [ ] Headers use the localized header table above. Omit empty themes consistently across locales.
- [ ] Every item leads with a user benefit, not an implementation detail.
- [ ] No PR numbers, commit hashes, file paths, function names, library names, or internal module names.
- [ ] No mention of internal initiatives, customers, deals, roadmap, or anything that would not make sense to an external operator.
- [ ] Each item is understandable by someone who has never opened the codebase.
- [ ] Items are present-tense, benefit-led, 1 to 2 lines.
- [ ] Empty release: one generic line in both locales, never an empty block, never one block missing.
## Look at examples first
Before drafting, read the user-notes blocks from recent releases in this repo to match tone:
```bash
gh release list --limit 5
gh release view <tag> --json body -q .body
```
The references behind this style are **Linear**, **Stripe**, **Notion**, and **Vercel** changelogs: short, benefit-led, grouped by theme, with the user as the protagonist.
## Drafting workflow
When invoked for a release (new or backfill):
1. Read the current release body via `gh release view <tag> --json body -q .body` (or the source commits via `git log <prev-tag>..<tag> --oneline`) to understand what shipped.
2. Filter the changes through "would a non-technical operator notice or care about this?". Drop everything that fails the filter.
3. Group what survived into Novidades / Melhorias / Correções.
4. Draft the **pt-BR** block first as the source language. Write naturally, lead with benefit.
5. Draft the **en** block. Equivalent content, natural English, not a word-for-word translation.
6. Assemble the full release body: keep the `## Changes` commit list at the top, then `## 🇺🇸 English` + the `en` block, then `## 🇧🇷 Português` + the `pt-BR` block. The `en` section always comes first in the rendered release body.
7. Run the quality checklist on both blocks.
8. Show the full proposed body to the user for approval **before** editing the release.
9. Only after approval, write the body to a temp file and apply it:
- **For new releases**, pass the file via `gh release create <tag> --notes-file <file>`.
- **For backfills / edits**, this version of `gh` does not have a `release edit` subcommand. Use the API directly:
```bash
RELEASE_ID=$(gh api repos/<owner>/<repo>/releases/tags/<tag> --jq '.id')
gh api -X PATCH "repos/<owner>/<repo>/releases/$RELEASE_ID" -F body=@<file>
```

View File

@ -137,6 +137,17 @@ AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_REGION=
# S3-compatible storage (e.g., Cloudflare R2, MinIO, DigitalOcean Spaces)
# Set ACTIVE_STORAGE_SERVICE=s3_compatible to use this
# STORAGE_ACCESS_KEY_ID=
# STORAGE_SECRET_ACCESS_KEY=
# STORAGE_REGION=
# STORAGE_BUCKET_NAME=
# STORAGE_ENDPOINT=
# STORAGE_FORCE_PATH_STYLE=true
# STORAGE_REQUEST_CHECKSUM_CALCULATION=when_required
# STORAGE_RESPONSE_CHECKSUM_VALIDATION=when_required
# Log settings
# Disable if you want to write logs to a file
RAILS_LOG_TO_STDOUT=true
@ -277,3 +288,12 @@ AZURE_APP_SECRET=
# REDIS_ALFRED_SIZE=10
# REDIS_VELMA_SIZE=10
# Baileys API Whatsapp provider
BAILEYS_PROVIDER_DEFAULT_CLIENT_NAME=Chatwoot
BAILEYS_PROVIDER_DEFAULT_URL=http://localhost:3025
BAILEYS_PROVIDER_DEFAULT_API_KEY=
# Enable WhatsApp group conversations for Baileys provider (default: false)
BAILEYS_WHATSAPP_GROUPS_ENABLED=false
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:
push:
branches:
- develop
pull_request:
branches:
- develop
tags:
- '*'
workflow_dispatch:
jobs:
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,30 +3,28 @@ permissions:
contents: read
on:
push:
branches:
- develop
- master
pull_request:
tags:
- '*'
workflow_dispatch:
jobs:
# Separate linting jobs for faster feedback
lint-backend:
runs-on: ubuntu-latest
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
- uses: useblacksmith/setup-ruby@v2
with:
bundler-cache: true
- name: Run Rubocop
run: bundle exec rubocop --parallel
lint-frontend:
runs-on: ubuntu-latest
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
- uses: useblacksmith/setup-node@v5
with:
node-version: 24
cache: 'pnpm'
@ -37,11 +35,11 @@ jobs:
# Frontend tests run in parallel with backend
frontend-tests:
runs-on: ubuntu-latest
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
- uses: useblacksmith/setup-node@v5
with:
node-version: 24
cache: 'pnpm'
@ -52,7 +50,7 @@ jobs:
# Backend tests with parallelization
backend-tests:
runs-on: ubuntu-latest
runs-on: blacksmith-4vcpu-ubuntu-2404
strategy:
fail-fast: false
matrix:
@ -88,11 +86,11 @@ jobs:
ref: ${{ github.event.pull_request.head.ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
- uses: ruby/setup-ruby@v1
- uses: useblacksmith/setup-ruby@v2
with:
bundler-cache: true
- uses: actions/setup-node@v4
- uses: useblacksmith/setup-node@v5
with:
node-version: 24
cache: 'pnpm'

3
.gitignore vendored
View File

@ -92,10 +92,9 @@ yarn-debug.log*
# TextEditors & AI Agents config files
.vscode
.claude/settings.local.json
.claude/**/*.local.*
.cursor
.codex/
.claude/
CLAUDE.local.md
# Histoire deployment

View File

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

View File

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

View File

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

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

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

View File

@ -15,8 +15,7 @@
- **Test Ruby**: `bundle exec rspec spec/path/to/file_spec.rb`
- **Single Test**: `bundle exec rspec spec/path/to/file_spec.rb:LINE_NUMBER`
- **Run Project**: `overmind start -f Procfile.dev`
- **Ruby Version**: Manage Ruby via `rbenv` and install the version listed in `.ruby-version` (e.g., `rbenv install $(cat .ruby-version)`)
- **rbenv setup**: Before running any `bundle` or `rspec` commands, init rbenv in your shell (`eval "$(rbenv init -)"`) so the correct Ruby/Bundler versions are used
- **Ruby Version**: Manage Ruby via `rvm`
- Always prefer `bundle exec` for Ruby CLI tasks (rspec, rake, rubocop, etc.)
## Code Style
@ -62,6 +61,11 @@
- The setup workflow in `.codex/environments/environment.toml` should dynamically generate per-worktree DB/port values (Rails, Vite, Redis DB index) to avoid collisions.
- Start each worktree with its own Overmind socket/title so multiple instances can run at the same time.
## Release Notes
- Every GitHub release cut from this repo must include the bilingual `user-notes` blocks (pt-BR + en) in the release body, written for non-technical end users.
- Before running `gh release create`, `gh release edit`, the `release` skill from `fazer-ai-tools`, or any flow that touches a release body (including retroactive backfills), invoke the `release-notes` skill at `.claude/skills/release-notes/SKILL.md` to draft and validate the blocks.
## Commit Messages
- Prefer Conventional Commits: `type(scope): subject` (scope optional)
@ -80,9 +84,9 @@
## Project-Specific
- **Translations**:
- Only update `en.yml` and `en.json`
- Update `en.yml`/`en.json` and `pt_BR.yml`/`pt_BR.json`
- Other languages are handled by the community
- Backend i18n → `en.yml`, Frontend i18n → `en.json`
- Backend i18n → `.yml`, Frontend i18n → `.json`
- **Frontend**:
- Use `components-next/` for message bubbles (the rest is being deprecated)

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

@ -45,7 +45,7 @@ gem 'ssrf_filter', '~> 1.5'
# authentication type to fetch and send mail over oauth2.0
gem 'gmail_xoauth'
# Lock net-smtp to 0.3.4 to avoid issues with gmail_xoauth2
gem 'net-smtp', '~> 0.3.4'
gem 'net-smtp', '~> 0.3.4'
# Prevent CSV injection
gem 'csv-safe'
@ -56,6 +56,7 @@ gem 'aws-sdk-s3', require: false
gem 'azure-storage-blob', git: 'https://github.com/chatwoot/azure-storage-ruby', branch: 'chatwoot', require: false
gem 'google-cloud-storage', '>= 1.48.0', require: false
gem 'image_processing'
gem 'streamio-ffmpeg', '~> 3.0'
##-- for actionmailbox --##
gem 'aws-actionmailbox-ses', '~> 0'
@ -207,6 +208,8 @@ gem 'opentelemetry-exporter-otlp'
gem 'shopify_api'
gem 'resend', '~> 0.19.0'
### Gems required only in specific deployment environments ###
##############################################################

View File

@ -755,6 +755,8 @@ GEM
uber (< 0.2.0)
request_store (1.5.1)
rack (>= 1.4)
resend (0.19.0)
httparty (>= 0.21.0)
responders (3.1.1)
actionpack (>= 5.2)
railties (>= 5.2)
@ -945,6 +947,8 @@ GEM
ssrf_filter (1.5.0)
stackprof (0.2.25)
statsd-ruby (1.5.0)
streamio-ffmpeg (3.0.2)
multi_json (~> 1.8)
stripe (18.0.1)
telephone_number (1.4.20)
test-prof (1.2.1)
@ -1127,6 +1131,7 @@ DEPENDENCIES
rails (~> 7.1)
redis
redis-namespace
resend (~> 0.19.0)
responders (>= 3.1.1)
rest-client
reverse_markdown
@ -1161,6 +1166,7 @@ DEPENDENCIES
squasher
ssrf_filter (~> 1.5)
stackprof
streamio-ffmpeg (~> 3.0)
stripe (~> 18.0)
telephone_number
test-prof

View File

@ -7,3 +7,12 @@ enterprise_tasks_path = Rails.root.join('enterprise/tasks_railtie.rb').to_s
require enterprise_tasks_path if File.exist?(enterprise_tasks_path)
Rails.application.load_tasks
# Ensure the f_unaccent function used by internal chat search indexes is created
# before db:schema:load runs. This must happen after Rails.application.load_tasks
# so that both `db:schema:load` and `db:internal_chat:ensure_search_functions`
# are guaranteed to be defined.
if Rake::Task.task_defined?('db:schema:load') &&
Rake::Task.task_defined?('db:internal_chat:ensure_search_functions')
Rake::Task['db:schema:load'].enhance(['db:internal_chat:ensure_search_functions'])
end

View File

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

View File

@ -55,7 +55,8 @@ class ContactInboxWithContactBuilder
email: contact_attributes[:email],
identifier: contact_attributes[:identifier],
additional_attributes: contact_attributes[:additional_attributes],
custom_attributes: contact_attributes[:custom_attributes]
custom_attributes: contact_attributes[:custom_attributes],
group_type: contact_attributes[:group_type] || :individual
)
end

View File

@ -10,7 +10,7 @@ class ConversationBuilder
def look_up_exising_conversation
return unless @contact_inbox.inbox.lock_to_single_conversation?
@contact_inbox.conversations.last
@contact_inbox.inbox.conversations.where(contact_id: @contact_inbox.contact_id).last
end
def create_new_conversation

View File

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

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
if error_code == 1_609_005
@message.attachments.destroy_all
@message.update(content: I18n.t('conversations.messages.instagram_deleted_story_content'))
@message.update!(content: I18n.t('conversations.messages.instagram_deleted_story_content'))
end
Rails.logger.error("[InstagramStoryFetchError]: #{parsed_response.dig('error', 'message')} #{error_code}")

View File

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

View File

@ -1,11 +1,11 @@
class Messages::MessageBuilder
class Messages::MessageBuilder # rubocop:disable Metrics/ClassLength
include ::FileTypeHelper
include ::EmailHelper
include ::DataHelper
attr_reader :message
def initialize(user, conversation, params)
def initialize(user, conversation, params) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
@params = params
@private = params[:private] || false
@conversation = conversation
@ -13,11 +13,16 @@ class Messages::MessageBuilder
@account = conversation.account
@message_type = params[:message_type] || 'outgoing'
@attachments = params[:attachments]
@is_recorded_audio = params[:is_recorded_audio]
@transcode_audio = params[:transcode_audio]
@attachments_metadata = normalize_attachments_metadata(params[:attachments_metadata])
@automation_rule = content_attributes&.dig(:automation_rule_id)
return unless params.instance_of?(ActionController::Parameters)
@in_reply_to = content_attributes&.dig(:in_reply_to)
@is_reaction = content_attributes&.dig(:is_reaction)
@items = content_attributes&.dig(:items)
@zapi_args = content_attributes&.dig(:zapi_args)
end
def perform
@ -55,7 +60,7 @@ class Messages::MessageBuilder
account_id: @message.account_id,
file: uploaded_attachment
)
attachment.meta = process_metadata(uploaded_attachment)
attachment.file_type = if uploaded_attachment.is_a?(String)
file_type_by_signed_id(
uploaded_attachment
@ -63,9 +68,71 @@ class Messages::MessageBuilder
else
file_type(uploaded_attachment&.content_type)
end
transcode_attachment(attachment, file_like_source(uploaded_attachment)) if should_transcode?(attachment)
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, the string "true", or an array of file names.
return unless @is_recorded_audio
return { is_recorded_audio: true } if @is_recorded_audio == true || @is_recorded_audio == 'true'
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 should_transcode?(attachment)
@transcode_audio.present? && attachment.file_type == 'audio'
end
# Returns the uploaded file only when it's a real file-like object (ActionDispatch::Http::UploadedFile,
# Tempfile, etc.). Direct-upload signed-ID Strings are not usable as source files for transcoding;
# TranscodeService falls back to downloading from the blob in that case.
def file_like_source(uploaded_attachment)
return uploaded_attachment if uploaded_attachment.respond_to?(:path) || uploaded_attachment.respond_to?(:tempfile)
end
def transcode_attachment(attachment, uploaded_file = nil)
Audio::TranscodeService.new(attachment, @transcode_audio, source_file: uploaded_file).perform
attachment.meta ||= {}
attachment.meta['is_recorded_audio'] = true
rescue CustomExceptions::Audio::UnsupportedFormatError, CustomExceptions::Audio::TranscodingError => e
Rails.logger.error("Audio transcoding failed, keeping original attachment: #{e.message}")
attachment.meta ||= {}
attachment.meta['audio_transcoding_failed'] = true
end
def process_emails
return unless @conversation.inbox&.inbox_type == 'Email'
@ -123,12 +190,32 @@ class Messages::MessageBuilder
@params[:template_params].present? ? { additional_attributes: { template_params: JSON.parse(@params[:template_params].to_json) } } : {}
end
def scheduled_message_metadata
return {} if @params[:scheduled_message].blank?
sm = @params[:scheduled_message]
scheduled_by = { 'id' => sm.author_id, 'type' => sm.author_type }
scheduled_by['name'] = sm.author.name if sm.author.respond_to?(:name)
{
additional_attributes: {
scheduled_message_id: sm.id,
scheduled_by: scheduled_by,
scheduled_at: sm.updated_at.to_i
}
}
end
def message_sender
return if @params[:sender_type] != 'AgentBot'
AgentBot.where(account_id: [nil, @conversation.account.id]).find_by(id: @params[:sender_id])
end
def zapi_args
@zapi_args.present? ? { zapi_args: @zapi_args } : {}
end
def message_params
{
account_id: @conversation.account_id,
@ -141,9 +228,11 @@ class Messages::MessageBuilder
content_attributes: content_attributes.presence,
items: @items,
in_reply_to: @in_reply_to,
is_reaction: @is_reaction,
echo_id: @params[:echo_id],
source_id: @params[:source_id]
}.merge(external_created_at).merge(automation_rule_id).merge(campaign_id).merge(template_params)
}.merge(external_created_at).merge(automation_rule_id).merge(campaign_id)
.deep_merge(template_params).merge(zapi_args).deep_merge(scheduled_message_metadata)
end
def email_inbox?

View File

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

View File

@ -0,0 +1,56 @@
class Api::V1::Accounts::Contacts::GroupAdminController < Api::V1::Accounts::Contacts::BaseController
VALID_PROPERTIES = %w[announce restrict join_approval_mode member_add_mode].freeze
def leave
authorize @contact, :update?
channel.group_leave(@contact.identifier)
head :ok
rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e
render json: { error: e.message }, status: :unprocessable_entity
end
def update
authorize @contact, :update?
property = property_params[:property]
enabled = ActiveModel::Type::Boolean.new.cast(property_params[:enabled])
return render json: { error: 'invalid_property' }, status: :unprocessable_entity unless property.in?(VALID_PROPERTIES)
apply_property_change(property, enabled)
update_contact_attribute(property, enabled)
head :ok
rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e
render json: { error: e.message }, status: :unprocessable_entity
end
private
def apply_property_change(property, enabled)
case property
when 'announce', 'restrict'
channel.group_setting_update(@contact.identifier, property, enabled)
when 'join_approval_mode'
channel.group_join_approval_mode(@contact.identifier, enabled ? 'on' : 'off')
when 'member_add_mode'
channel.group_member_add_mode(@contact.identifier, enabled ? 'all_member_add' : 'admin_add')
end
end
def property_params
params.permit(:property, :enabled)
end
def channel
@channel ||= @contact.group_channel
end
def resolve_group_conversations
Current.account.conversations
.where(contact_id: @contact.id, group_type: :group, status: %i[open pending])
.find_each { |c| c.update!(status: :resolved) }
end
def update_contact_attribute(key, value)
new_attrs = (@contact.additional_attributes || {}).merge(key => value)
@contact.update!(additional_attributes: new_attrs)
end
end

View File

@ -0,0 +1,27 @@
class Api::V1::Accounts::Contacts::GroupInvitesController < Api::V1::Accounts::Contacts::BaseController
def show
authorize @contact, :show?
code = channel.group_invite_code(@contact.identifier)
render json: invite_response(code)
rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e
render json: { error: e.message }, status: :unprocessable_entity
end
def revoke
authorize @contact, :update?
code = channel.revoke_group_invite(@contact.identifier)
render json: invite_response(code)
rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e
render json: { error: e.message }, status: :unprocessable_entity
end
private
def channel
@channel ||= @contact.group_channel
end
def invite_response(code)
{ invite_code: code, invite_url: "https://chat.whatsapp.com/#{code}" }
end
end

View File

@ -0,0 +1,37 @@
class Api::V1::Accounts::Contacts::GroupJoinRequestsController < Api::V1::Accounts::Contacts::BaseController
def index
authorize @contact, :show?
requests = channel.group_join_requests(@contact.identifier)
render json: { payload: requests }
rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e
render json: { error: e.message }, status: :unprocessable_entity
end
def handle
authorize @contact, :update?
channel.handle_group_join_requests(@contact.identifier, handle_params[:participants], handle_params[:request_action])
remove_handled_requests(handle_params[:participants])
head :ok
rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e
render json: { error: e.message }, status: :unprocessable_entity
end
private
def handle_params
params.permit(:request_action, participants: [])
end
def channel
@channel ||= @contact.group_channel
end
def remove_handled_requests(participants)
return if participants.blank?
current_requests = @contact.additional_attributes&.dig('pending_join_requests') || []
updated_requests = current_requests.reject { |r| participants.include?(r['jid']) }
new_attrs = (@contact.additional_attributes || {}).merge('pending_join_requests' => updated_requests)
@contact.update!(additional_attributes: new_attrs)
end
end

View File

@ -0,0 +1,155 @@
class Api::V1::Accounts::Contacts::GroupMembersController < Api::V1::Accounts::Contacts::BaseController
DEFAULT_PER_PAGE = 10
before_action :ensure_group_contact, only: %i[create update destroy]
def index
authorize @contact, :show?
base_query = GroupMember.active
.where(group_contact: @contact)
.includes(:contact)
@total_count = base_query.count
@page = [(params[:page] || 1).to_i, 1].max
@per_page = (params[:per_page] || DEFAULT_PER_PAGE).to_i.clamp(1, 100)
@inbox_phone_number = inbox_phone_number
@is_inbox_admin = inbox_admin?
paginated = base_query.order(role: :desc, id: :asc)
.offset((@page - 1) * @per_page)
.limit(@per_page)
@group_members = pin_own_member_on_first_page(paginated)
end
def create
authorize @contact, :update?
participants = create_params[:participants]
return render json: { error: 'participants_required' }, status: :unprocessable_entity if participants.blank?
channel.update_group_participants(@contact.identifier, format_participants(participants), 'add')
add_group_members(participants)
head :ok
rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e
render json: { error: e.message }, status: :unprocessable_entity
end
def update
authorize @contact, :update?
role = update_params[:role]
return render json: { error: 'invalid_role' }, status: :unprocessable_entity unless %w[admin member].include?(role)
member = group_members.find(params[:member_id])
action = role == 'admin' ? 'promote' : 'demote'
channel.update_group_participants(@contact.identifier, [jid_for_member(member)], action)
member.update!(role: role)
head :ok
rescue Whatsapp::Providers::WhatsappBaileysService::GroupParticipantNotAllowedError
render json: { error: 'group_creator_not_modifiable' }, status: :unprocessable_entity
rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e
render json: { error: e.message }, status: :unprocessable_entity
end
def destroy
authorize @contact, :update?
member = group_members.find(params[:id])
channel.update_group_participants(@contact.identifier, [jid_for_member(member)], 'remove')
member.update!(is_active: false)
head :ok
rescue Whatsapp::Providers::WhatsappBaileysService::GroupParticipantNotAllowedError
render json: { error: 'group_creator_not_modifiable' }, status: :unprocessable_entity
rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e
render json: { error: e.message }, status: :unprocessable_entity
end
private
def ensure_group_contact
return if @contact.group_type_group? && @contact.identifier.present?
render json: { error: 'Contact is not a valid group' }, status: :unprocessable_entity
end
def group_members
GroupMember.where(group_contact: @contact)
end
def create_params
params.permit(participants: [])
end
def update_params
params.permit(:role)
end
def channel
@channel ||= @contact.group_channel
end
def inbox_phone_number
channel&.phone_number
end
def inbox_admin?
return false if @inbox_phone_number.blank?
find_own_member&.role == 'admin'
end
def pin_own_member_on_first_page(paginated)
return paginated unless @page == 1 && @inbox_phone_number.present?
ids = paginated.pluck(:id)
own = find_own_member
return paginated if own.blank? || ids.include?(own.id)
# Prepend own member; drop the last one so total per-page stays consistent
[own] + paginated.where.not(id: own.id).limit(@per_page - 1).to_a
end
def find_own_member
clean = @inbox_phone_number.delete('+')
GroupMember.active
.where(group_contact: @contact)
.joins(:contact)
.where('REPLACE(contacts.phone_number, \'+\', \'\') = ? OR RIGHT(REPLACE(contacts.phone_number, \'+\', \'\'), 8) = RIGHT(?, 8)',
clean, clean)
.includes(:contact)
.first
end
def format_participants(phone_numbers)
Array(phone_numbers).map { |phone| "#{phone.to_s.delete('+')}@s.whatsapp.net" }
end
def jid_for_member(member)
"#{member.contact.phone_number.to_s.delete('+')}@s.whatsapp.net"
end
def add_group_members(phone_numbers)
inbox = @contact.contact_inboxes.first&.inbox
Array(phone_numbers).each do |phone|
normalized = normalize_phone(phone)
next if normalized.blank?
contact_inbox = ::ContactInboxWithContactBuilder.new(
source_id: normalized.delete('+'),
inbox: inbox,
contact_attributes: { name: normalized, phone_number: normalized }
).perform
next if contact_inbox.blank?
member = GroupMember.find_or_initialize_by(group_contact: @contact, contact: contact_inbox.contact)
member.update!(role: :member, is_active: true) unless member.persisted? && member.is_active?
end
end
def normalize_phone(phone)
cleaned = phone.to_s.strip
return nil if cleaned.blank?
cleaned.start_with?('+') ? cleaned : "+#{cleaned}"
end
end

View File

@ -0,0 +1,39 @@
class Api::V1::Accounts::Contacts::GroupMetadataController < Api::V1::Accounts::Contacts::BaseController
def update
authorize @contact, :update?
update_subject if metadata_params[:subject].present?
update_description if metadata_params[:description].present?
update_picture if metadata_params[:avatar].present?
render json: { id: @contact.id, name: @contact.name, additional_attributes: @contact.additional_attributes }
rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e
render json: { error: e.message }, status: :unprocessable_entity
end
private
def metadata_params
params.permit(:subject, :description, :avatar)
end
def update_subject
channel.update_group_subject(@contact.identifier, metadata_params[:subject])
@contact.update!(name: metadata_params[:subject])
end
def update_description
channel.update_group_description(@contact.identifier, metadata_params[:description])
attrs = @contact.additional_attributes.merge('description' => metadata_params[:description])
@contact.update!(additional_attributes: attrs)
end
def update_picture
avatar = metadata_params[:avatar]
image_base64 = Base64.strict_encode64(avatar.read)
channel.update_group_picture(@contact.identifier, image_base64)
@contact.avatar.attach(avatar)
end
def channel
@channel ||= @contact.group_channel
end
end

View File

@ -13,7 +13,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
before_action :check_authorization
before_action :set_current_page, only: [:index, :active, :search, :filter]
before_action :fetch_contact, only: [:show, :update, :destroy, :avatar, :contactable_inboxes, :destroy_custom_attributes]
before_action :fetch_contact, only: [:show, :update, :destroy, :avatar, :contactable_inboxes, :destroy_custom_attributes, :sync_group]
before_action :set_include_contact_inboxes, only: [:index, :active, :search, :filter, :show, :update]
def index
@ -82,6 +82,15 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
@contact.save!
end
def sync_group
authorize @contact, :sync_group?
raise ActionController::BadRequest, I18n.t('contacts.sync_group.not_a_group') if @contact.group_type_individual?
raise ActionController::BadRequest, I18n.t('contacts.sync_group.no_identifier') if @contact.identifier.blank?
Contacts::SyncGroupJob.perform_later(@contact)
head :accepted
end
def create
ActiveRecord::Base.transaction do
@contact = Current.account.contacts.new(permitted_params.except(:avatar_url))

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
include Events::Types
before_action :ensure_api_inbox, only: :update
def index
@ -9,6 +11,8 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
user = Current.user || @resource
mb = Messages::MessageBuilder.new(user, @conversation, params)
@message = mb.perform
trigger_typing_event(CONVERSATION_TYPING_OFF)
rescue StandardError => e
render_could_not_create_error(e.message)
end
@ -23,14 +27,16 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
message.update!(content: I18n.t('conversations.messages.deleted'), content_type: :text, content_attributes: { deleted: true })
message.attachments.destroy_all
end
delete_message_on_channel
end
def retry
return if message.blank?
return head :unprocessable_entity unless message.failed? && (message.outgoing? || message.template?)
service = Messages::StatusUpdateService.new(message, 'sent')
service.perform
message.update!(content_attributes: {})
message.update!(content_attributes: {}, source_id: nil)
::SendReplyJob.perform_later(message.id)
rescue StandardError => e
render_could_not_create_error(e.message)
@ -54,6 +60,22 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
render json: { content: translated_content }
end
def edit_content
new_content = params[:content]
return render json: { error: 'Content is required' }, status: :unprocessable_entity if new_content.blank?
return render json: { error: 'Content exceeds maximum length' }, status: :unprocessable_entity if new_content.length > 150_000
return render json: { error: 'Only outgoing messages can be edited' }, status: :forbidden unless message.outgoing?
original_content = message.content
# Only save previous_content on first edit to preserve the original message
previous_content_to_save = message.is_edited ? message.previous_content : original_content
message.update!(content: new_content, is_edited: true, previous_content: previous_content_to_save)
edit_message_on_channel(new_content, original_content)
@message = message.reload
end
private
def message
@ -65,16 +87,48 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
end
def permitted_params
params.permit(:id, :target_language, :status, :external_error)
params.permit(:id, :target_language, :status, :external_error, :content)
end
def already_translated_content_available?
message.translations.present? && message.translations[permitted_params[:target_language]].present?
end
def delete_message_on_channel
return unless @conversation.inbox.channel.respond_to?(:delete_message)
return if message.source_id.blank?
@conversation.inbox.channel.delete_message(message, conversation: @conversation)
rescue StandardError => e
Rails.logger.error "Failed to delete message on channel: #{e.message}"
end
def edit_message_on_channel(new_content, original_content)
return unless @conversation.inbox.channel.respond_to?(:edit_message)
return if message.source_id.blank?
@conversation.inbox.channel.edit_message(message, new_content, conversation: @conversation)
rescue StandardError => e
Rails.logger.error "Failed to edit message on channel: #{e.message}"
was_already_edited = message.previous_content != original_content
if was_already_edited
message.update!(content: original_content)
else
message.update!(content: original_content, is_edited: false, previous_content: nil)
end
raise e
end
# API inbox check
def ensure_api_inbox
# Only API inboxes can update messages
render json: { error: 'Message status update is only allowed for API inboxes' }, status: :forbidden unless @conversation.inbox.api?
end
def trigger_typing_event(event)
user = Current.user || @resource
return unless user.is_a?(User)
Rails.configuration.dispatcher.dispatch(event, Time.zone.now, conversation: @conversation, user: user, is_private: params[:private])
end
end

View File

@ -0,0 +1,176 @@
class Api::V1::Accounts::Conversations::RecurringScheduledMessagesController < Api::V1::Accounts::Conversations::BaseController
include Events::Types
before_action :set_recurring_scheduled_message, only: [:update, :destroy]
MAX_LIMIT = 50
def index
authorize build_recurring_scheduled_message
@recurring_scheduled_messages = @conversation.recurring_scheduled_messages
.includes(:scheduled_messages, :author)
.order(Arel.sql('CASE status WHEN 1 THEN 0 WHEN 0 THEN 1 ELSE 2 END, created_at DESC'))
.limit(MAX_LIMIT)
end
def create
@recurring_scheduled_message = build_recurring_scheduled_message
authorize @recurring_scheduled_message
@recurring_scheduled_message.assign_attributes(recurring_scheduled_message_params)
ActiveRecord::Base.transaction do
@recurring_scheduled_message.save!
create_first_occurrence if @recurring_scheduled_message.active?
end
dispatch_event(RECURRING_SCHEDULED_MESSAGE_CREATED)
end
def update
@recurring_scheduled_message.assign_attributes(recurring_scheduled_message_params)
ActiveRecord::Base.transaction do
@recurring_scheduled_message.save!
@recurring_scheduled_message.attachment.purge if params[:remove_attachment].present? && @recurring_scheduled_message.attachment.attached?
if @recurring_scheduled_message.active?
reschedule_pending_occurrence
else
@recurring_scheduled_message.scheduled_messages.pending.destroy_all
end
end
dispatch_event(RECURRING_SCHEDULED_MESSAGE_UPDATED)
end
def destroy
cancel_recurring_message
dispatch_event(RECURRING_SCHEDULED_MESSAGE_UPDATED)
end
private
def set_recurring_scheduled_message
@recurring_scheduled_message = @conversation.recurring_scheduled_messages.find(params[:id])
authorize @recurring_scheduled_message
end
def build_recurring_scheduled_message
@conversation.recurring_scheduled_messages.new(account: Current.account, inbox: @conversation.inbox, author: Current.user)
end
def recurring_scheduled_message_params
permitted = params.permit(
:content,
:status,
:attachment,
template_params: {},
recurrence_rule: [:frequency, :interval, :end_type, :end_date, :end_count,
:monthly_type, :monthly_week, :monthly_weekday, :month_day,
:year_day, :year_month, { week_days: [] }]
)
permitted[:recurrence_rule] = cast_recurrence_rule(permitted[:recurrence_rule].to_h) if permitted[:recurrence_rule].present?
permitted
end
def cast_recurrence_rule(rule)
integer_keys = %w[interval end_count monthly_week monthly_weekday month_day year_day year_month]
rule.each_with_object({}) do |(key, value), hash|
hash[key] = if key == 'week_days' && value.is_a?(Array)
value.map(&:to_i)
elsif integer_keys.include?(key)
value.to_i
else
value
end
end
end
def create_first_occurrence
scheduled_at = params[:scheduled_at]
return if scheduled_at.blank?
sm = @recurring_scheduled_message.scheduled_messages.create!(
content: @recurring_scheduled_message.content,
template_params: @recurring_scheduled_message.template_params,
scheduled_at: scheduled_at,
status: :pending,
account: @recurring_scheduled_message.account,
conversation: @recurring_scheduled_message.conversation,
inbox: @recurring_scheduled_message.inbox,
author: @recurring_scheduled_message.author
)
copy_attachment(sm) if @recurring_scheduled_message.attachment.attached?
end
def reschedule_pending_occurrence
@recurring_scheduled_message.scheduled_messages.pending.destroy_all
next_scheduled_at = compute_next_valid_date
return if next_scheduled_at.blank?
sm = @recurring_scheduled_message.scheduled_messages.create!(
content: @recurring_scheduled_message.content,
template_params: @recurring_scheduled_message.template_params,
scheduled_at: next_scheduled_at,
status: :pending,
account: @recurring_scheduled_message.account,
conversation: @recurring_scheduled_message.conversation,
inbox: @recurring_scheduled_message.inbox,
author: @recurring_scheduled_message.author
)
copy_attachment(sm) if @recurring_scheduled_message.attachment.attached?
end
def compute_next_valid_date
user_date = params[:scheduled_at].present? ? Time.zone.parse(params[:scheduled_at].to_s) : nil
rule = @recurring_scheduled_message.recurrence_rule
return user_date if user_date.present? && date_matches_rule?(user_date, rule)
base = [user_date, Time.current].compact.max
RecurringScheduledMessages::RecurrenceCalculatorService
.new(recurrence_rule: rule, last_date: base)
.next_date
end
def date_matches_rule?(date, rule)
return true unless rule.is_a?(Hash)
rule = rule.with_indifferent_access
return true unless rule[:frequency] == 'weekly' && rule[:week_days].present?
rule[:week_days].map(&:to_i).include?(date.wday)
end
def cancel_recurring_message
@recurring_scheduled_message.scheduled_messages.pending.destroy_all
@recurring_scheduled_message.update!(status: :cancelled)
I18n.with_locale(@recurring_scheduled_message.account.locale) do
@recurring_scheduled_message.conversation.messages.create!(
account: @recurring_scheduled_message.account,
inbox: @recurring_scheduled_message.inbox,
message_type: :activity,
content: I18n.t(
'conversations.activity.recurring_message_cancelled',
agent: @recurring_scheduled_message.author&.name || I18n.t('conversations.activity.unknown_agent')
)
)
end
end
def copy_attachment(scheduled_message)
scheduled_message.attachment.attach(@recurring_scheduled_message.attachment.blob)
end
def dispatch_event(event_name)
Rails.configuration.dispatcher.dispatch(event_name, Time.zone.now, recurring_scheduled_message: @recurring_scheduled_message)
end
end
Api::V1::Accounts::Conversations::RecurringScheduledMessagesController.prepend_mod_with(
'Api::V1::Accounts::Conversations::RecurringScheduledMessagesController'
)

View File

@ -0,0 +1,69 @@
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
.includes(:recurring_scheduled_message)
.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.attachment.purge if params[:remove_attachment].present? && @scheduled_message.attachment.attached?
@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,9 +1,9 @@
class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseController
class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseController # rubocop:disable Metrics/ClassLength
include Events::Types
include DateRangeHelper
include HmacConcern
before_action :conversation, except: [:index, :meta, :search, :create, :filter]
before_action :conversation, except: [:index, :meta, :search, :create, :filter, :presence_subscribe_bulk]
before_action :inbox, :contact, :contact_inbox, only: [:create]
ATTACHMENT_RESULTS_PER_PAGE = 100
@ -34,6 +34,11 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
.per(ATTACHMENT_RESULTS_PER_PAGE)
end
def presence_subscribe_bulk
Conversations::PresenceSubscribeService.new(Current.account, presence_subscribe_params[:conversation_ids]).perform
head :ok
end
def show; end
def create
@ -112,22 +117,30 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
head :ok
end
def presence_subscribe
Conversations::PresenceSubscribeService.new(Current.account, [@conversation.display_id]).perform
head :ok
end
def update_last_seen
# High-traffic accounts generate excessive DB writes when agents frequently switch between conversations.
# Throttle last_seen updates to once per hour when there are no unread messages to reduce DB load.
# Always update immediately if there are unread messages to maintain accurate read/unread state.
# Visiting a conversation should clear any unread inbox notifications for this conversation.
Notification::MarkConversationReadService.new(user: Current.user, account: Current.account, conversation: @conversation).perform
return update_last_seen_on_conversation(DateTime.now.utc, true) if assignee? && @conversation.assignee_unread_messages.any?
return update_last_seen_on_conversation(DateTime.now.utc, false) if !assignee? && @conversation.unread_messages.any?
has_unread = assignee? ? @conversation.assignee_unread_messages.any? : @conversation.unread_messages.any?
# No unread messages - apply throttling to limit DB writes
return unless should_update_last_seen?
return if !has_unread && !should_update_last_seen?
dispatch_messages_read_event if assignee?
update_last_seen_on_conversation(DateTime.now.utc, assignee?)
end
def unread
Rails.configuration.dispatcher.dispatch(Events::Types::CONVERSATION_UNREAD, Time.zone.now, conversation: @conversation)
last_incoming_message = @conversation.messages.incoming.last
last_seen_at = last_incoming_message.created_at - 1.second if last_incoming_message.present?
update_last_seen_on_conversation(last_seen_at, true)
@ -155,6 +168,10 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
params.permit(:page)
end
def presence_subscribe_params
params.permit(conversation_ids: [])
end
def update_last_seen_on_conversation(last_seen_at, update_assignee)
updates = { agent_last_seen_at: last_seen_at }
updates[:assignee_last_seen_at] = last_seen_at if update_assignee.present?
@ -164,7 +181,15 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
# rubocop:enable Rails/SkipsModelValidations
end
def unseen_activity?
@conversation.last_activity_at.present? &&
(@conversation.agent_last_seen_at.blank? || @conversation.last_activity_at > @conversation.agent_last_seen_at)
end
def should_update_last_seen?
# Always update when there's unseen activity (e.g. soft-disabled group conversations that don't create messages)
return true if unseen_activity?
# Update if at least one relevant timestamp is older than 1 hour or not set
# This prevents redundant DB writes when agents repeatedly view the same conversation
agent_needs_update = @conversation.agent_last_seen_at.blank? || @conversation.agent_last_seen_at < 1.hour.ago
@ -233,6 +258,12 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
def assignee?
@conversation.assignee_id? && Current.user == @conversation.assignee
end
def dispatch_messages_read_event
# NOTE: Use old `agent_last_seen_at`, so we reference messages received after that
Rails.configuration.dispatcher.dispatch(Events::Types::MESSAGES_READ, Time.zone.now, conversation: @conversation,
last_seen_at: @conversation.agent_last_seen_at)
end
end
Api::V1::Accounts::ConversationsController.prepend_mod_with('Api::V1::Accounts::ConversationsController')

View File

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

View File

@ -0,0 +1,26 @@
class Api::V1::Accounts::GroupsController < Api::V1::Accounts::BaseController
def create
inbox = Current.account.inboxes.find_by(id: group_params[:inbox_id])
return render json: { error: 'Access Denied' }, status: :forbidden unless inbox_accessible?(inbox)
result = Groups::CreateService.new(
inbox: inbox,
subject: group_params[:subject],
participants: Array(group_params[:participants])
).perform
render json: result
rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e
render json: { error: e.message }, status: :unprocessable_entity
end
private
def group_params
params.permit(:inbox_id, :subject, participants: [])
end
def inbox_accessible?(inbox)
inbox.present? && Current.user.assigned_inboxes.exists?(id: inbox.id) && inbox.channel.try(:allow_group_creation?)
end
end

View File

@ -42,6 +42,26 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC
render json: { error: 'Template parameters are required' }, status: :unprocessable_entity
end
def link
link_params = params.require(:template).permit(:name, :language, body_variables: {})
return render json: { error: 'Template name is required' }, status: :unprocessable_entity if link_params[:name].blank?
service = CsatTemplateManagementService.new(@inbox)
result = service.link_existing_template(
link_params[:name], link_params[:language], body_variables: link_params[:body_variables].to_h
)
render_link_result(result)
rescue ActionController::ParameterMissing
render json: { error: 'Template parameters are required' }, status: :unprocessable_entity
end
def available_templates
service = CsatTemplateManagementService.new(@inbox)
templates = service.available_templates
render json: { templates: templates }
end
private
def fetch_inbox
@ -70,6 +90,22 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC
render json: { error: 'Captain is required for template analysis' }, status: :forbidden
end
def render_link_result(result)
if result[:success]
render json: {
template: {
name: result[:template_name], template_id: result[:template_id],
status: result[:status], language: result[:language], source: result[:source],
linked_at: result[:linked_at]
}
}, status: :ok
elsif result[:error]
render json: { error: result[:error] }, status: :unprocessable_entity
else
render json: { error: result[:service_error] || 'An unexpected error occurred' }, status: :internal_server_error
end
end
def render_template_creation_result(result)
if result[:success]
render_successful_template_creation(result)

View File

@ -1,11 +1,13 @@
class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController # rubocop:disable Metrics/ClassLength
include Api::V1::InboxesHelper
before_action :fetch_inbox, except: [:index, :create]
before_action :fetch_agent_bot, only: [:set_agent_bot]
before_action :validate_limit, only: [:create]
# we are already handling the authorization in fetch inbox
before_action :check_authorization, except: [:show]
# rubocop:disable Rails/LexicallyScopedActionFilter -- health is defined in WhatsappHealthManagement concern
before_action :check_authorization, except: [:show, :health, :setup_channel_provider]
before_action :validate_whatsapp_cloud_channel, only: [:health]
# rubocop:enable Rails/LexicallyScopedActionFilter
include Api::V1::Accounts::Concerns::WhatsappHealthManagement
def index
@ -72,11 +74,49 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
@inbox.channel.reset_secret!
end
def setup_channel_provider
channel = @inbox.channel
unless channel.respond_to?(:setup_channel_provider)
render json: { error: 'Channel does not support setup' }, status: :unprocessable_entity and return
end
channel.setup_channel_provider
head :ok
end
def disconnect_channel_provider
channel = @inbox.channel
unless channel.respond_to?(:disconnect_channel_provider)
render json: { error: 'Channel does not support disconnect' }, status: :unprocessable_entity and return
end
channel.disconnect_channel_provider
head :ok
ensure
channel.update_provider_connection!(connection: 'close') if channel.respond_to?(:update_provider_connection!)
end
def destroy
::DeleteObjectJob.perform_later(@inbox, Current.user, request.ip) if @inbox.present?
render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') }
end
def on_whatsapp
params.require(:phone_number)
phone_number = params[:phone_number]
channel = @inbox.channel
unless channel.respond_to?(:on_whatsapp)
render json: { error: 'Channel does not support whatsapp check' }, status: :unprocessable_entity and return
end
response = channel.on_whatsapp(phone_number)
render json: response, status: :ok
end
private
def fetch_inbox
@ -160,7 +200,8 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
:lock_to_single_conversation, :portal_id, :sender_name_type, :business_name,
{ csat_config: [:display_type, :message, :button_text, :language,
{ survey_rules: [:operator, { values: [] }],
template: [:name, :template_id, :friendly_name, :content_sid, :approval_sid, :created_at, :language, :status] }] }]
template: [:name, :template_id, :friendly_name, :content_sid, :approval_sid,
:created_at, :linked_at, :language, :source, :status, { body_variables: {} }] }] }]
end
def permitted_params(channel_attributes = [])

View File

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

View File

@ -0,0 +1,19 @@
class Api::V1::Accounts::InternalChat::BaseController < Api::V1::Accounts::BaseController
private
def current_channel
@current_channel ||= Current.account.internal_chat_channels.find(params[:channel_id] || params[:id])
end
def current_membership
@current_membership ||= current_channel.channel_members.find_by(user_id: Current.user.id)
end
def channel_member?
current_channel.channel_type_public_channel? || current_membership.present?
end
def render_pro_required(feature)
render json: { error: 'pro_feature_required', feature: feature }, status: :payment_required
end
end

View File

@ -0,0 +1,49 @@
class Api::V1::Accounts::InternalChat::CategoriesController < Api::V1::Accounts::InternalChat::BaseController
before_action :fetch_category, only: [:update, :destroy]
def index
authorize InternalChat::Category, :index?
@categories = Current.account.internal_chat_categories.ordered.includes(:channels)
render json: @categories.map { |category| category_response(category) }
end
def create
authorize InternalChat::Category, :create?
@category = Current.account.internal_chat_categories.create!(category_params)
render json: category_response(@category), status: :created
end
def update
authorize @category, :update?
@category.update!(category_params)
render json: category_response(@category)
end
def destroy
authorize @category, :destroy?
@category.destroy!
head :ok
end
private
def fetch_category
@category = Current.account.internal_chat_categories.find(params[:id])
end
def category_params
params.require(:category).permit(:name, :position)
end
def category_response(category)
{
id: category.id,
name: category.name,
position: category.position,
account_id: category.account_id,
channels_count: category.channels.size,
created_at: category.created_at,
updated_at: category.updated_at
}
end
end

View File

@ -0,0 +1,107 @@
class Api::V1::Accounts::InternalChat::ChannelMembersController < Api::V1::Accounts::InternalChat::BaseController
include Events::Types
before_action :current_channel
before_action :fetch_member, only: [:update, :destroy]
def index
authorize current_channel, :show?, policy_class: InternalChat::ChannelPolicy
@members = current_channel.channel_members.includes(user: :account_users)
render json: @members.map { |member| member_response(member) }
end
def create
authorize current_channel, :update?, policy_class: InternalChat::ChannelPolicy
members = create_channel_members(validated_user_ids, requested_role)
dispatch_member_update
render json: members.map { |member| member_response(member) }, status: :created
end
def update
authorize_member_update!
@member.update!(member_update_params)
render json: member_response(@member)
end
def destroy
authorize_member_destroy!
removed_user = @member.user
@member.destroy!
dispatch_member_update(removed_user: removed_user)
head :ok
end
private
def validated_user_ids
user_ids = Array(params[:user_ids] || [params[:user_id]]).compact.map(&:to_i)
valid_user_ids = Current.account.users.where(id: user_ids).pluck(:id)
raise ActionController::BadRequest, 'No valid user IDs provided' if valid_user_ids.empty?
valid_user_ids
end
def create_channel_members(user_ids, role)
ActiveRecord::Base.transaction do
user_ids.map do |user_id|
current_channel.channel_members.find_or_create_by!(user_id: user_id) do |m|
m.role = role
end
end
end
end
# Only account administrators can promote a new member to channel admin via params.
# Channel admins (without account-admin) always create plain members.
def requested_role
return :member unless Current.account_user&.administrator?
return :member if params[:role].blank?
InternalChat::ChannelMember.roles.key?(params[:role].to_s) ? params[:role] : :member
end
def fetch_member
@member = current_channel.channel_members.find(params[:id])
end
def authorize_member_update!
raise Pundit::NotAuthorizedError unless @member.user_id == Current.user.id || Current.account_user&.administrator?
end
def authorize_member_destroy!
raise Pundit::NotAuthorizedError unless @member.user_id == Current.user.id || Current.account_user&.administrator?
end
def dispatch_member_update(removed_user: nil)
# Capture tokens before the broadcast so the removed user also receives the event
tokens = current_channel.members.pluck(:pubsub_token)
tokens << removed_user.pubsub_token if removed_user.present?
Rails.configuration.dispatcher.dispatch(
INTERNAL_CHAT_CHANNEL_UPDATED,
Time.zone.now,
channel: current_channel,
member_tokens: tokens.uniq
)
end
def member_update_params
params.permit(:muted, :favorited, :hidden)
end
def member_response(member)
{
id: member.id,
user_id: member.user_id,
role: member.role,
muted: member.muted,
favorited: member.favorited,
last_read_at: member.last_read_at,
name: member.user.name,
avatar_url: member.user.avatar_url,
availability_status: member.user.availability_status,
created_at: member.created_at,
updated_at: member.updated_at
}
end
end

View File

@ -0,0 +1,495 @@
class Api::V1::Accounts::InternalChat::ChannelsController < Api::V1::Accounts::InternalChat::BaseController # rubocop:disable Metrics/ClassLength
include Events::Types
before_action :current_channel, only: [:show, :update, :destroy, :archive, :unarchive, :toggle_typing_status, :mark_read, :mark_unread]
RECENT_MESSAGES_LIMIT = 20
# Arbitrary 32-bit namespace for the private-channel limit advisory lock; paired with account id.
PRIVATE_CHANNEL_LOCK_KEY = 0x49434C4D # 'ICLM'
def index
authorize InternalChat::Channel, :index?
@channels = filtered_channels
@unread_counts = compute_unread_counts(@channels)
@mention_channel_ids = compute_mention_channel_ids(@channels)
render json: @channels.map { |channel| channel_index_response(channel) }
end
def show
authorize @current_channel, :show?
render json: channel_show_response(@current_channel)
end
def create
@channel = build_channel
authorize @channel, :create?
created = @channel.new_record?
if dm_params? && created
create_dm_with_lock
else
with_private_channel_limit_lock(@channel) do
return if enforce_private_channel_limit(@channel)
ActiveRecord::Base.transaction do
@channel.save!
add_creator_as_admin
add_initial_members
add_channel_type_members
end
end
end
dispatch_channel_event(@channel) if created
render json: channel_show_response(@channel), status: :created
end
def update
authorize @current_channel, :update?
attrs = update_channel_params
validate_category!(attrs[:category_id])
@current_channel.update!(attrs)
dispatch_channel_event(@current_channel)
render json: channel_show_response(@current_channel)
end
def destroy
authorize @current_channel, :destroy?
# Capture member tokens before destroying so the listener can broadcast to them
cached_tokens = channel_member_tokens(@current_channel)
@current_channel.destroy!
Rails.configuration.dispatcher.dispatch(INTERNAL_CHAT_CHANNEL_UPDATED, Time.zone.now, channel: @current_channel,
member_tokens: cached_tokens)
head :ok
end
def archive
authorize @current_channel, :archive?
head(:unprocessable_entity) and return if @current_channel.channel_type_dm?
@current_channel.archived!
dispatch_channel_event(@current_channel)
render json: channel_show_response(@current_channel)
end
def unarchive
authorize @current_channel, :unarchive?
with_private_channel_limit_lock(@current_channel) do
return if enforce_private_channel_limit(@current_channel)
@current_channel.active!
end
dispatch_channel_event(@current_channel)
render json: channel_show_response(@current_channel)
end
def toggle_typing_status
authorize @current_channel, :toggle_typing_status?
InternalChat::TypingStatusManager.new(
channel: @current_channel, user: Current.user, params: { typing_status: typing_status_param }
).perform
head :ok
end
def mark_read
authorize @current_channel, :mark_read?
membership = @current_channel.channel_members.find_by(user_id: Current.user.id)
membership&.update!(last_read_at: Time.current)
head :ok
end
def mark_unread
authorize @current_channel, :mark_unread?
msg_id = mark_unread_params[:message_id]
return head(:ok) if msg_id.blank?
membership = @current_channel.channel_members.find_by!(user_id: Current.user.id)
message = @current_channel.messages.find(msg_id)
membership.update!(last_read_at: message.created_at - 1.second)
head :ok
end
private
def enforce_private_channel_limit(channel)
return unless channel.channel_type_private_channel?
max = InternalChat::Limits.max_private_channels
return if max.blank?
count = Current.account.internal_chat_channels.where(channel_type: :private_channel).active.count
render_pro_required('private_channels') if count >= max
end
# Postgres advisory transaction lock keyed by account so concurrent create/unarchive
# cannot bypass the private-channel limit by racing between count and save.
def with_private_channel_limit_lock(channel)
return yield unless channel.channel_type_private_channel? && InternalChat::Limits.max_private_channels.present?
ActiveRecord::Base.transaction do
ActiveRecord::Base.connection.execute(
ActiveRecord::Base.sanitize_sql_array(['SELECT pg_advisory_xact_lock(?, ?)', PRIVATE_CHANNEL_LOCK_KEY, Current.account.id])
)
yield
end
end
def filtered_channels
channels = Current.account.internal_chat_channels.includes(channel_members: { user: :account_users }, category: [])
channels = apply_type_filter(channels)
channels = apply_category_filter(channels)
channels = apply_status_filter(channels)
channels = apply_visibility_filter(channels)
channels.order(last_activity_at: :desc)
end
def apply_type_filter(channels)
case params[:type]
when 'text_channels'
channels.text_channels
when 'direct_messages'
channels.direct_messages
else
channels
end
end
def apply_category_filter(channels)
return channels if params[:category_id].blank?
channels.where(category_id: params[:category_id])
end
def apply_status_filter(channels)
case params[:status]
when 'archived'
channels.archived
else
channels.active
end
end
def apply_visibility_filter(channels)
user_channels = channels.where(id: Current.user.internal_chat_channels.select(:id))
return channels.where(channel_type: %i[public_channel private_channel]).or(user_channels) if Current.account_user&.administrator?
channels.where(channel_type: :public_channel).or(user_channels)
end
def build_channel
if dm_params?
find_or_build_dm
else
attrs = create_channel_params.except(:member_ids, :team_ids)
validate_category!(attrs[:category_id])
Current.account.internal_chat_channels.build(attrs.merge(created_by: Current.user))
end
end
def dm_params?
params[:channel_type] == 'dm' || params.dig(:channel, :channel_type) == 'dm'
end
def find_or_build_dm
user_ids = dm_member_ids
existing_dm = find_existing_dm(user_ids)
return existing_dm if existing_dm.present?
Current.account.internal_chat_channels.build(
channel_type: :dm,
name: nil,
created_by: Current.user
)
end
def find_existing_dm(user_ids)
sorted_ids = user_ids.sort
member_count = sorted_ids.size
Current.account.internal_chat_channels
.where(channel_type: :dm)
.joins(:channel_members)
.group('internal_chat_channels.id')
.having('COUNT(internal_chat_channel_members.id) = ?', member_count)
.having(
'ARRAY_AGG(internal_chat_channel_members.user_id ORDER BY internal_chat_channel_members.user_id) = ARRAY[?]::bigint[]',
sorted_ids
)
.first
end
def dm_member_ids
ids = Array(permitted_member_ids).map(&:to_i)
ids = Current.account.users.where(id: ids).pluck(:id)
ids << Current.user.id unless ids.include?(Current.user.id)
ids
end
def add_creator_as_admin
return if @channel.channel_type_dm?
return if @channel.channel_members.exists?(user_id: Current.user.id)
@channel.channel_members.create!(user_id: Current.user.id, role: :admin)
end
def add_initial_members
member_ids = Array(permitted_member_ids).map(&:to_i)
member_ids = Current.account.users.where(id: member_ids).pluck(:id)
member_ids << Current.user.id if @channel.channel_type_dm? && member_ids.exclude?(Current.user.id)
member_ids.uniq.each do |user_id|
next if @channel.channel_members.exists?(user_id: user_id)
@channel.channel_members.create!(user_id: user_id, role: :member)
end
end
def add_channel_type_members
return if @channel.channel_type_dm?
if @channel.channel_type_public_channel?
add_all_agents_as_members
else
add_team_members
end
end
def add_all_agents_as_members
agent_ids = Current.account.agents.where.not(id: Current.user.id).pluck(:id)
agent_ids.each do |uid|
@channel.channel_members.find_or_create_by!(user_id: uid) { |m| m.role = :member }
end
end
def add_team_members
team_ids = permitted_team_ids
return if team_ids.blank?
team_ids.each do |team_id|
team = Current.account.teams.find_by(id: team_id)
next unless team
@channel.channel_teams.find_or_create_by!(team: team)
team.members.each do |user|
@channel.channel_members.find_or_create_by!(user_id: user.id) { |m| m.role = :member }
end
end
end
def create_channel_params
@create_channel_params ||= params.require(:channel).permit(:name, :description, :channel_type, :category_id, member_ids: [], team_ids: [])
end
def update_channel_params
params.require(:channel).permit(:name, :description, :category_id)
end
def permitted_member_ids
params.permit(member_ids: [])[:member_ids] || create_channel_params[:member_ids]
end
def permitted_team_ids
ids = params.permit(team_ids: [])[:team_ids] || create_channel_params[:team_ids]
Array(ids).map(&:to_i).compact_blank
end
def mark_unread_params
params.permit(:message_id)
end
def typing_status_param
params.permit(:typing_status)[:typing_status]
end
def create_dm_with_lock
lock_key = "internal_chat_dm_#{Current.account.id}_#{dm_member_ids.sort.join('_')}"
ActiveRecord::Base.transaction do
ActiveRecord::Base.connection.execute(
ActiveRecord::Base.sanitize_sql_array(['SELECT pg_advisory_xact_lock(?)', Zlib.crc32(lock_key)])
)
existing = find_existing_dm(dm_member_ids)
if existing
@channel = existing
else
@channel.save!
add_initial_members
end
end
end
def compute_mention_channel_ids(channels)
user_id = Current.user.id
InternalChat::ChannelMember
.joins(
'INNER JOIN internal_chat_messages ' \
'ON internal_chat_messages.internal_chat_channel_id = internal_chat_channel_members.internal_chat_channel_id ' \
'AND internal_chat_messages.created_at > internal_chat_channel_members.last_read_at'
)
.where(internal_chat_channel_id: channels.select(:id), user_id: user_id)
.where.not(last_read_at: nil)
.where.not('internal_chat_messages.sender_id' => user_id)
.where("internal_chat_messages.content_attributes->'mentioned_user_ids' @> ?", [user_id].to_json)
.pluck(Arel.sql('DISTINCT internal_chat_channel_members.internal_chat_channel_id'))
end
def compute_unread_counts(channels)
InternalChat::ChannelMember
.joins(
'INNER JOIN internal_chat_messages ' \
'ON internal_chat_messages.internal_chat_channel_id = internal_chat_channel_members.internal_chat_channel_id ' \
'AND internal_chat_messages.created_at > internal_chat_channel_members.last_read_at'
)
.where(internal_chat_channel_id: channels.select(:id), user_id: Current.user.id)
.where.not(last_read_at: nil)
.where.not('internal_chat_messages.sender_id' => Current.user.id)
.group('internal_chat_channel_members.internal_chat_channel_id')
.count('internal_chat_messages.id')
end
def channel_base_response(channel)
{
id: channel.id,
name: channel.name,
description: channel.description,
channel_type: channel.channel_type,
status: channel.status,
category_id: channel.category_id,
last_activity_at: channel.last_activity_at,
created_at: channel.created_at,
updated_at: channel.updated_at
}
end
def channel_index_response(channel) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
membership = channel.channel_members.detect { |member| member.user_id == Current.user.id }
response = channel_base_response(channel).merge(
is_dm: channel.channel_type_dm?,
muted: membership&.muted || false,
favorited: membership&.favorited || false,
hidden: membership&.hidden || false,
members_count: channel.channel_members.size,
unread_count: @unread_counts&.dig(channel.id) || 0,
has_unread_mention: @mention_channel_ids&.include?(channel.id) || false
)
if channel.channel_type_dm?
response[:members] = channel.channel_members.map do |m|
{ user_id: m.user_id, name: m.user.name, avatar_url: m.user.avatar_url, availability_status: m.user.availability_status }
end
end
response
end
def channel_show_response(channel) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity
members = channel.channel_members.includes(:user).load
membership = members.detect { |member| member.user_id == Current.user.id }
recent_messages = channel.messages
.includes(:sender, :reactions, :replies, { poll: { options: { votes: :user } } },
attachments: { file_attachment: :blob })
.recent.limit(RECENT_MESSAGES_LIMIT).reverse
channel_base_response(channel).merge(
is_dm: channel.channel_type_dm?,
muted: membership&.muted || false,
favorited: membership&.favorited || false,
account_id: channel.account_id,
created_by_id: channel.created_by_id,
members_count: members.size,
unread_count: membership&.unread_messages_count || 0,
members: members.map { |m| member_response(m) },
messages: recent_messages.map { |msg| message_response(msg) }
)
end
def member_response(member)
{
id: member.id,
user_id: member.user_id,
role: member.role,
muted: member.muted,
favorited: member.favorited,
name: member.user.name,
avatar_url: member.user.avatar_url
}
end
def message_response(message)
deleted = message.content_attributes&.dig('deleted')
attrs = message.content_attributes || {}
attrs = attrs.merge(poll: poll_response_for(message.poll)) if message.poll.present?
{
id: message.id,
content: message.content,
content_type: message.content_type,
content_attributes: attrs,
sender: message.sender&.push_event_data,
parent_id: message.parent_id,
echo_id: message.echo_id,
replies_count: message.replies_count,
created_at: message.created_at,
updated_at: message.updated_at,
reactions: reaction_responses(message),
attachments: deleted ? [] : message.attachments.map { |a| attachment_response(a) }
}
end
def poll_response_for(poll)
{
id: poll.id,
question: poll.question,
multiple_choice: poll.multiple_choice,
public_results: poll.public_results,
allow_revote: poll.allow_revote,
expires_at: poll.expires_at,
internal_chat_message_id: poll.internal_chat_message_id,
options: poll.options.ordered.includes(votes: :user).map { |opt| poll_option_response(opt, poll) },
total_votes: poll.total_votes_count,
created_at: poll.created_at,
updated_at: poll.updated_at
}
end
def poll_option_response(option, poll)
response = {
id: option.id,
text: option.text,
votes_count: option.votes_count,
voted: option.votes.any? { |v| v.user_id == Current.user.id }
}
response[:voters] = option.votes.map { |v| { id: v.user_id, name: v.user.name } } if poll.public_results
response
end
def reaction_responses(message)
message.reactions.includes(:user).map do |r|
{ id: r.id, emoji: r.emoji, user_id: r.user_id, user: { name: r.user&.name } }
end
end
def attachment_response(attachment)
{
id: attachment.id,
file_type: attachment.file_type,
external_url: attachment.external_url,
extension: attachment.extension,
file_url: attachment.file.attached? ? url_for(attachment.file) : nil
}
end
def channel_member_tokens(channel)
users = channel.channel_type_public_channel? ? channel.account.users : channel.members
users.pluck(:pubsub_token)
end
def validate_category!(category_id)
return if category_id.blank?
Current.account.internal_chat_categories.find(category_id)
end
def dispatch_channel_event(channel)
Rails.configuration.dispatcher.dispatch(INTERNAL_CHAT_CHANNEL_UPDATED, Time.zone.now, channel: channel)
end
end

View File

@ -0,0 +1,55 @@
class Api::V1::Accounts::InternalChat::DraftsController < Api::V1::Accounts::InternalChat::BaseController
before_action :current_channel, only: [:update, :destroy]
def index
accessible_channel_ids = Current.account.internal_chat_channels
.where(channel_type: :public_channel)
.or(Current.account.internal_chat_channels.where(id: Current.user.internal_chat_channels.select(:id)))
.select(:id)
@drafts = InternalChat::Draft.where(user: Current.user, account: Current.account,
internal_chat_channel_id: accessible_channel_ids).recent
render json: @drafts.map { |draft| draft_response(draft) }
end
def update
authorize current_channel, :show?, policy_class: InternalChat::ChannelPolicy
@draft = InternalChat::Draft.find_or_initialize_by(
user: Current.user,
internal_chat_channel_id: current_channel.id,
parent_id: draft_params[:parent_id]
)
@draft.assign_attributes(
account: Current.account,
content: draft_params[:content]
)
@draft.save!
render json: draft_response(@draft), status: :ok
end
def destroy
authorize current_channel, :show?, policy_class: InternalChat::ChannelPolicy
@draft = InternalChat::Draft.find_by!(user: Current.user, internal_chat_channel_id: current_channel.id, parent_id: params[:parent_id])
@draft.destroy!
head :ok
end
private
def draft_params
params.permit(:content, :parent_id)
end
def draft_response(draft)
{
id: draft.id,
content: draft.content,
internal_chat_channel_id: draft.internal_chat_channel_id,
parent_id: draft.parent_id,
created_at: draft.created_at,
updated_at: draft.updated_at
}
end
end

View File

@ -0,0 +1,191 @@
class Api::V1::Accounts::InternalChat::MessagesController < Api::V1::Accounts::InternalChat::BaseController
include Events::Types
before_action :current_channel
before_action :fetch_message, only: [:update, :destroy, :pin, :unpin, :thread]
MESSAGES_PER_PAGE = 50
def index
authorize current_channel, :show?, policy_class: InternalChat::ChannelPolicy
@messages = paginated_messages
render json: {
messages: @messages.map { |msg| message_response(msg) },
meta: pagination_meta
}
end
def create
authorize current_channel, :show?, policy_class: InternalChat::ChannelPolicy
@message = InternalChat::MessageCreateService.new(
channel: current_channel,
sender: Current.user,
params: message_params
).perform
render json: message_response(@message), status: :created
end
def update
authorize @message, :update?, policy_class: InternalChat::MessagePolicy
previous_content = @message.content
@message.update!(
content: update_params[:content],
content_attributes: (@message.content_attributes || {}).merge('edited_at' => Time.current.iso8601, 'previous_content' => previous_content)
)
dispatch_message_event(INTERNAL_CHAT_MESSAGE_UPDATED, message: @message)
render json: message_response(@message)
end
def destroy
authorize @message, :destroy?, policy_class: InternalChat::MessagePolicy
message_data = {
id: @message.id,
channel_id: @message.internal_chat_channel_id,
account_id: @message.account_id
}
@message.update!(content: I18n.t('internal_chat.messages.deleted'), content_attributes: { deleted: true })
dispatch_message_event(INTERNAL_CHAT_MESSAGE_DELETED, message_data: message_data)
head :ok
end
def pin
authorize @message, :pin?, policy_class: InternalChat::MessagePolicy
@message.skip_content_validation = true
@message.update!(content_attributes: (@message.content_attributes || {}).merge('pinned' => true, 'pinned_by' => Current.user.id,
'pinned_at' => Time.current.iso8601))
dispatch_message_event(INTERNAL_CHAT_MESSAGE_UPDATED, message: @message)
render json: message_response(@message)
end
def unpin
authorize @message, :unpin?, policy_class: InternalChat::MessagePolicy
@message.skip_content_validation = true
attrs = (@message.content_attributes || {}).except('pinned', 'pinned_by', 'pinned_at')
@message.update!(content_attributes: attrs)
dispatch_message_event(INTERNAL_CHAT_MESSAGE_UPDATED, message: @message)
render json: message_response(@message)
end
def thread
authorize @message, :thread?, policy_class: InternalChat::MessagePolicy
replies = @message.replies.includes(:sender, :reactions, :replies, :attachments, :poll).ordered
render json: {
parent: message_response(@message),
replies: replies.map { |msg| message_response(msg) }
}
end
private
def fetch_message
@message = current_channel.messages.find(params[:id])
end
def paginated_messages
return fetch_around_messages if params[:around].present?
messages = apply_time_filters(base_messages_scope)
if params[:after].present?
messages.ordered.limit(MESSAGES_PER_PAGE)
else
messages.ordered.last(MESSAGES_PER_PAGE)
end
rescue ArgumentError
base_messages_scope.ordered.last(MESSAGES_PER_PAGE)
end
def fetch_around_messages
target = current_channel.messages.find_by(id: params[:around])
return base_messages_scope.ordered.last(MESSAGES_PER_PAGE) unless target
half = MESSAGES_PER_PAGE / 2
before_msgs = base_messages_scope.where('internal_chat_messages.created_at <= ?', target.created_at)
.ordered.last(half)
after_msgs = base_messages_scope.where('internal_chat_messages.created_at > ?', target.created_at)
.ordered.limit(half)
(before_msgs + after_msgs).uniq(&:id).sort_by(&:created_at)
end
def base_messages_scope
current_channel.messages
.includes(:sender, :reactions, :replies, :attachments, :poll)
.where("parent_id IS NULL OR (content_attributes->>'also_send_in_channel')::boolean = true")
end
def apply_time_filters(messages)
messages = messages.where('internal_chat_messages.created_at < ?', Time.zone.parse(params[:before])) if params[:before].present?
messages = messages.where('internal_chat_messages.created_at > ?', Time.zone.parse(params[:after])) if params[:after].present?
messages
end
def pagination_meta
{
has_more: @messages.size >= MESSAGES_PER_PAGE
}
end
def message_params
params.permit(:content, :content_type, :parent_id, :echo_id, :also_send_in_channel, attachments: [:file, :file_type])
end
def update_params
params.permit(:content)
end
def message_response(message) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
deleted = message.content_attributes&.dig('deleted')
response = {
id: message.id,
content: message.content,
content_type: message.content_type,
content_attributes: message.content_attributes,
internal_chat_channel_id: message.internal_chat_channel_id,
sender: message.sender&.push_event_data,
parent_id: message.parent_id,
echo_id: message.echo_id,
replies_count: message.replies_count,
created_at: message.created_at,
updated_at: message.updated_at,
reactions: message.reactions.includes(:user).map { |r| { id: r.id, emoji: r.emoji, user_id: r.user_id, user: { name: r.user&.name } } },
attachments: deleted ? [] : message.attachments.map { |a| attachment_response(a) }
}
response[:poll] = poll_data(message.poll) if !deleted && message.poll?
response
end
def poll_data(poll)
return nil unless poll
{
id: poll.id,
question: poll.question,
multiple_choice: poll.multiple_choice,
public_results: poll.public_results,
allow_revote: poll.allow_revote,
expires_at: poll.expires_at,
options: poll.options.ordered.includes(votes: :user).map { |o| poll_option_data(o, poll) },
total_votes: poll.total_votes_count
}
end
def poll_option_data(option, poll)
data = { id: option.id, text: option.text, emoji: option.emoji, votes_count: option.votes_count,
voted: option.votes.any? { |v| v.user_id == Current.user.id } }
data[:voters] = option.votes.map { |v| v.user.push_event_data } if poll.public_results
data
end
def attachment_response(attachment)
{
id: attachment.id,
file_type: attachment.file_type,
external_url: attachment.external_url,
extension: attachment.extension,
file_url: attachment.file.attached? ? url_for(attachment.file) : nil
}
end
def dispatch_message_event(event, data)
Rails.configuration.dispatcher.dispatch(event, Time.zone.now, **data)
end
end

View File

@ -0,0 +1,177 @@
class Api::V1::Accounts::InternalChat::PollsController < Api::V1::Accounts::InternalChat::BaseController
include Events::Types
before_action :set_poll, only: [:vote]
before_action :set_poll_for_unvote, only: [:unvote]
def create
return render_pro_required('polls') unless InternalChat::Limits.polls_enabled?
@channel = Current.account.internal_chat_channels.find(params[:channel_id])
authorize @channel, :show?, policy_class: InternalChat::ChannelPolicy
raise ActionController::BadRequest, 'Options are required' if poll_params[:options].blank?
ActiveRecord::Base.transaction do
@message = create_poll_message
@poll = build_poll
create_poll_options
end
dispatch_message_created_event
render json: message_with_poll_response(@message, @poll), status: :created
end
def vote
ActiveRecord::Base.transaction do
validate_vote!
@vote = @option.votes.create!(user: Current.user)
end
dispatch_poll_event
render json: message_with_poll_response(@poll.message, @poll.reload), status: :ok
end
def unvote
raise ActionController::BadRequest, 'Poll has expired' if @poll.expired?
@vote = if params[:option_id].present?
option = @poll.options.find(params[:option_id])
option.votes.find_by!(user_id: Current.user.id)
else
InternalChat::PollVote.joins(:option)
.where(internal_chat_poll_options: { internal_chat_poll_id: @poll.id }, user_id: Current.user.id)
.first!
end
@vote.destroy!
dispatch_poll_event
render json: message_with_poll_response(@poll.message, @poll.reload), status: :ok
end
private
def set_poll
@poll = InternalChat::Poll.joins(:message).where(internal_chat_messages: { account_id: Current.account.id }).find(params[:id])
@option = @poll.options.find(params[:option_id])
channel = @poll.message.channel
authorize channel, :show?, policy_class: InternalChat::ChannelPolicy
end
def set_poll_for_unvote
@poll = InternalChat::Poll.joins(:message).where(internal_chat_messages: { account_id: Current.account.id }).find(params[:id])
channel = @poll.message.channel
authorize channel, :show?, policy_class: InternalChat::ChannelPolicy
end
def create_poll_message
@channel.messages.create!(
account: Current.account,
sender: Current.user,
content: poll_params[:question],
content_type: :poll
)
end
def build_poll
@message.create_poll!(
question: poll_params[:question],
multiple_choice: poll_params[:multiple_choice] || false,
public_results: poll_params.fetch(:public_results, true),
allow_revote: poll_params.fetch(:allow_revote, true),
expires_at: poll_params[:expires_at]
)
end
def validate_vote!
raise ActionController::BadRequest, 'Poll has expired' if @poll.expired?
existing_votes = existing_user_votes
return unless existing_votes.exists?
raise ActionController::BadRequest, 'Revoting is not allowed' unless @poll.allow_revote
if @poll.multiple_choice
raise ActionController::BadRequest, 'Already voted for this option' if @option.votes.exists?(user_id: Current.user.id)
else
existing_votes.destroy_all
end
end
def existing_user_votes
InternalChat::PollVote.joins(:option).where(
internal_chat_poll_options: { internal_chat_poll_id: @poll.id },
user_id: Current.user.id
)
end
def create_poll_options
poll_params[:options].each_with_index do |option_attrs, index|
@poll.options.create!(
text: option_attrs[:text],
emoji: option_attrs[:emoji],
image_url: option_attrs[:image_url],
position: index
)
end
end
def poll_params
params.permit(:question, :multiple_choice, :public_results, :allow_revote, :expires_at, :channel_id,
options: [:text, :emoji, :image_url])
end
def message_with_poll_response(message, poll)
{
id: message.id,
content: message.content,
content_type: message.content_type,
content_attributes: (message.content_attributes || {}).merge(poll: poll_response(poll)),
internal_chat_channel_id: message.internal_chat_channel_id,
sender: message.sender.push_event_data,
parent_id: message.parent_id,
created_at: message.created_at,
updated_at: message.updated_at,
attachments: [],
reactions: []
}
end
def poll_response(poll)
{
id: poll.id,
question: poll.question,
multiple_choice: poll.multiple_choice,
public_results: poll.public_results,
allow_revote: poll.allow_revote,
expires_at: poll.expires_at,
internal_chat_message_id: poll.internal_chat_message_id,
options: poll.options.ordered.includes(votes: :user).map { |option| option_response(option, poll) },
total_votes: poll.total_votes_count,
created_at: poll.created_at,
updated_at: poll.updated_at
}
end
def option_response(option, poll)
response = {
id: option.id,
text: option.text,
emoji: option.emoji,
image_url: option.image_url,
position: option.position,
votes_count: option.votes_count,
voted: option.votes.any? { |v| v.user_id == Current.user.id }
}
response[:voters] = option.votes.map { |v| v.user.push_event_data } if poll.public_results
response
end
def dispatch_message_created_event
Rails.configuration.dispatcher.dispatch(INTERNAL_CHAT_MESSAGE_CREATED, Time.zone.now, message: @message)
end
def dispatch_poll_event
Rails.configuration.dispatcher.dispatch(INTERNAL_CHAT_POLL_VOTED, Time.zone.now, poll: @poll, message: @poll.message)
end
end

View File

@ -0,0 +1,54 @@
class Api::V1::Accounts::InternalChat::ReactionsController < Api::V1::Accounts::InternalChat::BaseController
include Events::Types
before_action :fetch_message
def create
@reaction = @message.reactions.build(user: Current.user, emoji: reaction_params[:emoji])
authorize @reaction, :create?, policy_class: InternalChat::ReactionPolicy
@reaction.save!
dispatch_reaction_event(INTERNAL_CHAT_REACTION_CREATED, reaction: @reaction)
render json: reaction_response(@reaction), status: :created
end
def destroy
@reaction = @message.reactions.find(params[:id])
authorize @reaction, :destroy?, policy_class: InternalChat::ReactionPolicy
reaction_data = {
id: @reaction.id,
message_id: @reaction.internal_chat_message_id,
channel_id: @message.internal_chat_channel_id,
account_id: @message.account_id,
user_id: @reaction.user_id,
emoji: @reaction.emoji
}
@reaction.destroy!
dispatch_reaction_event(INTERNAL_CHAT_REACTION_DELETED, reaction_data: reaction_data)
head :ok
end
private
def fetch_message
@message = InternalChat::Message.joins(:channel).where(internal_chat_channels: { account_id: Current.account.id }).find(params[:message_id])
end
def reaction_response(reaction)
{
id: reaction.id,
emoji: reaction.emoji,
user_id: reaction.user_id,
user: { name: reaction.user&.name },
internal_chat_message_id: reaction.internal_chat_message_id,
created_at: reaction.created_at
}
end
def reaction_params
params.permit(:emoji)
end
def dispatch_reaction_event(event, **data)
Rails.configuration.dispatcher.dispatch(event, Time.zone.now, **data)
end
end

View File

@ -0,0 +1,19 @@
class Api::V1::Accounts::InternalChat::SearchController < Api::V1::Accounts::BaseController
def show
authorize InternalChat::Channel, :index?
result = InternalChat::SearchService.new(
current_user: Current.user,
current_account: Current.account,
params: search_params
).perform
render json: result
end
private
def search_params
params.permit(:q, :page)
end
end

View File

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

View File

@ -23,7 +23,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
def update
ActiveRecord::Base.transaction do
@portal.update!(portal_params.merge(live_chat_widget_params)) if params[:portal].present?
@portal.update!(merged_portal_params.merge(live_chat_widget_params)) if params[:portal].present?
# @portal.custom_domain = parsed_custom_domain
process_attached_logo if params[:blob_id].present?
rescue ActiveRecord::RecordInvalid => e
@ -37,7 +37,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
end
def archive
@portal.update(archive: true)
@portal.update!(archive: true)
head :ok
end
@ -79,10 +79,21 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
def portal_params
params.require(:portal).permit(
:id, :color, :custom_domain, :header_text, :homepage_link,
:name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }, { draft_locales: [] }] }
:name, :page_title, :slug, :archived, :custom_head_html, :custom_body_html,
{ config: [:default_locale, :show_author, { allowed_locales: [] }, { draft_locales: [] }] }
)
end
def merged_portal_params
update_params = portal_params.to_h
if update_params.key?('config')
base_config = @portal.config.is_a?(Hash) ? @portal.config : {}
incoming_config = update_params['config']
update_params['config'] = incoming_config.is_a?(Hash) ? base_config.merge(incoming_config) : base_config
end
update_params
end
def live_chat_widget_params
permitted_params = params.permit(:inbox_id)
return {} unless permitted_params.key?(:inbox_id)

View File

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

View File

@ -0,0 +1,69 @@
class Api::V1::Profile::InboxSignaturesController < Api::BaseController
before_action :set_user
before_action :set_inbox_signature, only: %i[show update destroy]
before_action :validate_inbox_access, only: %i[show update destroy]
def index
if params[:account_id].present?
validate_account_access!
return if performed?
@inbox_signatures = @user.inbox_signatures.joins(:inbox).where(inboxes: { account_id: params[:account_id] })
else
@inbox_signatures = @user.inbox_signatures
end
end
def show
head :not_found and return unless @inbox_signature
end
def update
if @inbox_signature
@inbox_signature.update!(inbox_signature_params)
else
@inbox_signature = @user.inbox_signatures.create!(
inbox_signature_params.merge(inbox_id: params[:inbox_id])
)
end
end
def destroy
@inbox_signature&.destroy!
head :no_content
end
private
def set_user
@user = current_user
end
def set_inbox_signature
@inbox_signature = @user.inbox_signatures.find_by(inbox_id: params[:inbox_id])
end
def inbox_signature_params
params.require(:inbox_signature).permit(:message_signature, :signature_position, :signature_separator)
end
def validate_inbox_access
inbox = Inbox.find_by(id: params[:inbox_id])
return head :not_found unless inbox
account_user = @user.account_users.find_by(account_id: inbox.account_id)
return head :unauthorized unless account_user
return if account_user.administrator?
return if InboxMember.exists?(user_id: @user.id, inbox_id: inbox.id)
head :unauthorized
end
def validate_account_access!
account_id = params[:account_id]
return if @user.account_ids.include?(account_id.to_i)
head :unauthorized
end
end

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -6,7 +6,7 @@ module AttachmentConcern
return [blobs, actions, nil] if actions.blank?
sanitized = actions.map do |action|
next action unless action[:action_name] == 'send_attachment'
next action unless attachment_action?(action)
result = process_attachment_action(action, record, blobs)
return [nil, nil, I18n.t('errors.attachments.invalid')] unless result
@ -20,15 +20,39 @@ module AttachmentConcern
private
def process_attachment_action(action, record, blobs)
blob_id = action[:action_params].first
blob_id = attachment_blob_id(action)
return action if action[:action_name] == 'create_scheduled_message' && blob_id.blank?
blob = ActiveStorage::Blob.find_signed(blob_id.to_s)
return action.merge(action_params: [blob.id]).tap { blobs << blob } if blob.present?
return action.merge(action_params: attachment_action_params(action, blob.id)).tap { blobs << blob } if blob.present?
return action if blob_already_attached?(record, blob_id)
nil
end
def attachment_action?(action)
%w[send_attachment create_scheduled_message].include?(action[:action_name])
end
def attachment_blob_id(action)
return action[:action_params].first unless action[:action_name] == 'create_scheduled_message'
params = action[:action_params].first
params = params.to_unsafe_h if params.respond_to?(:to_unsafe_h)
params&.with_indifferent_access&.dig(:blob_id)
end
def attachment_action_params(action, blob_id)
return [blob_id] unless action[:action_name] == 'create_scheduled_message'
params = action[:action_params].first
params = params.to_unsafe_h if params.respond_to?(:to_unsafe_h)
params = params.with_indifferent_access
params[:blob_id] = blob_id
[params]
end
def blob_already_attached?(record, blob_id)
record&.files&.any? { |f| f.blob_id == blob_id.to_i }
end

View File

@ -78,6 +78,7 @@ class DashboardController < ActionController::Base
WHATSAPP_APP_ID: GlobalConfigService.load('WHATSAPP_APP_ID', ''),
WHATSAPP_CONFIGURATION_ID: GlobalConfigService.load('WHATSAPP_CONFIGURATION_ID', ''),
IS_ENTERPRISE: ChatwootApp.enterprise?,
BAILEYS_WHATSAPP_GROUPS_ENABLED: Whatsapp::Providers::WhatsappBaileysService.groups_enabled?,
AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''),
GIT_SHA: GIT_HASH,
ALLOWED_LOGIN_METHODS: allowed_login_methods

View File

@ -2,6 +2,6 @@
# authentication, and callbacks. Used for health checks
class HealthController < ActionController::Base # rubocop:disable Rails/ApplicationController
def show
render json: { status: 'woot' }
render json: { status: 'woot', platform: 'fazer.ai', version: Chatwoot.config[:version] }
end
end

View File

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

View File

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

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
raise StandardError, 'HMAC failed: Invalid Identifier Hash Provided' unless valid_hmac?
@contact_inbox.update(hmac_verified: true) if @contact_inbox.present?
@contact_inbox.update!(hmac_verified: true) if @contact_inbox.present?
end
def valid_hmac?

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -15,10 +15,12 @@ class AsyncDispatcher < BaseDispatcher
CsatSurveyListener.instance,
HookListener.instance,
InstallationWebhookListener.instance,
InternalChatListener.instance,
NotificationListener.instance,
ParticipationListener.instance,
ReportingEventListener.instance,
WebhookListener.instance
WebhookListener.instance,
ChannelListener.instance
]
end
end

View File

@ -24,8 +24,33 @@ class ConversationDrop < BaseDrop
custom_attributes.transform_keys(&:to_s)
end
def first_reply_created_at
format_datetime(@obj.try(:first_reply_created_at))
end
def first_reply_created_at_time
format_datetime(@obj.try(:first_reply_created_at), include_time: true)
end
def last_activity_at
format_datetime(@obj.try(:last_activity_at))
end
def last_activity_at_time
format_datetime(@obj.try(:last_activity_at), include_time: true)
end
private
def format_datetime(datetime, include_time: false)
return '' if datetime.blank?
locale = @obj.try(:account)&.locale || 'en'
date_format = locale == 'pt_BR' ? '%d/%m/%Y' : '%b %d, %Y'
date_format += ' %H:%M' if include_time
datetime.strftime(date_format)
end
def message_sender_name(sender)
return 'Bot' if sender.blank?
return contact_name if sender.instance_of?(Contact)

View File

@ -81,6 +81,7 @@ class ConversationFinder
find_all_conversations
filter_by_status unless params[:q]
filter_by_group_type
filter_by_team
filter_by_labels
filter_by_query
@ -135,6 +136,12 @@ class ConversationFinder
@conversations
end
def filter_by_group_type
return unless params[:group_type].present? && params[:group_type] != 'all'
@conversations = @conversations.where(group_type: params[:group_type])
end
def filter_by_conversation_type
case @params[:conversation_type]
when 'mention'

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 = 130.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) if lock_acquired
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?
# zero epoch time: 1970-01-01 00:00:00 UTC
'0000000000'
'0000000000000'
end
end

View File

@ -104,6 +104,10 @@ module Filters::FilterHelper
values.map { |x| Conversation.priorities[x.to_sym] }
end
def conversation_group_type_values(values)
values.map { |x| Conversation.group_types[x.to_sym] }
end
def message_type_values(values)
values.map { |x| Message.message_types[x.to_sym] }
end

View File

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

View File

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

View File

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

View File

@ -0,0 +1,71 @@
/* global axios */
import ApiClient from './ApiClient';
class GroupMembersAPI extends ApiClient {
constructor() {
super('contacts', { accountScoped: true });
}
getGroupMembers(contactId, page = 1) {
return axios.get(`${this.url}/${contactId}/group_members`, {
params: { page },
});
}
syncGroup(contactId) {
return axios.post(`${this.url}/${contactId}/sync_group`);
}
createGroup(params) {
return axios.post(`${this.baseUrl()}/groups`, params);
}
updateGroupMetadata(contactId, params) {
return axios.patch(`${this.url}/${contactId}/group_metadata`, params);
}
addMembers(contactId, participants) {
return axios.post(`${this.url}/${contactId}/group_members`, {
participants,
});
}
removeMembers(contactId, memberId) {
return axios.delete(`${this.url}/${contactId}/group_members/${memberId}`);
}
updateMemberRole(contactId, memberId, role) {
return axios.patch(`${this.url}/${contactId}/group_members/${memberId}`, {
role,
});
}
getInviteLink(contactId) {
return axios.get(`${this.url}/${contactId}/group_invite`);
}
revokeInviteLink(contactId) {
return axios.post(`${this.url}/${contactId}/group_invite/revoke`);
}
getPendingRequests(contactId) {
return axios.get(`${this.url}/${contactId}/group_join_requests`);
}
handleJoinRequest(contactId, params) {
return axios.post(
`${this.url}/${contactId}/group_join_requests/handle`,
params
);
}
leaveGroup(contactId) {
return axios.post(`${this.url}/${contactId}/group_admin/leave`);
}
updateGroupProperty(contactId, params) {
return axios.patch(`${this.url}/${contactId}/group_admin`, params);
}
}
export default new GroupMembersAPI();

View File

@ -16,6 +16,7 @@ class ConversationApi extends ApiClient {
conversationType,
sortBy,
updatedWithin,
groupType,
}) {
return axios.get(this.url, {
params: {
@ -28,6 +29,7 @@ class ConversationApi extends ApiClient {
conversation_type: conversationType,
sort_by: sortBy,
updated_within: updatedWithin,
group_type: groupType,
},
});
}
@ -88,6 +90,16 @@ class ConversationApi extends ApiClient {
});
}
presenceSubscribe(conversationId) {
return axios.post(`${this.url}/${conversationId}/presence_subscribe`);
}
presenceSubscribeBulk(conversationIds) {
return axios.post(`${this.url}/presence_subscribe_bulk`, {
conversation_ids: conversationIds,
});
}
mute(conversationId) {
return axios.post(`${this.url}/${conversationId}/mute`);
}

View File

@ -8,6 +8,7 @@ export const buildCreatePayload = ({
contentAttributes,
echoId,
files,
isRecordedAudio,
ccEmails = '',
bccEmails = '',
toEmails = '',
@ -22,6 +23,13 @@ export const buildCreatePayload = ({
files.forEach(file => {
payload.append('attachments[]', file);
});
if (isRecordedAudio === true) {
payload.append('is_recorded_audio', true);
} else if (Array.isArray(isRecordedAudio)) {
isRecordedAudio.forEach(filename => {
payload.append('is_recorded_audio[]', filename);
});
}
payload.append('private', isPrivate);
payload.append('echo_id', echoId);
payload.append('cc_emails', ccEmails);
@ -60,6 +68,7 @@ class MessageApi extends ApiClient {
contentAttributes,
echo_id: echoId,
files,
isRecordedAudio,
ccEmails = '',
bccEmails = '',
toEmails = '',
@ -74,6 +83,7 @@ class MessageApi extends ApiClient {
contentAttributes,
echoId,
files,
isRecordedAudio,
ccEmails,
bccEmails,
toEmails,
@ -86,6 +96,13 @@ class MessageApi extends ApiClient {
return axios.delete(`${this.url}/${conversationID}/messages/${messageId}`);
}
editContent(conversationID, messageId, content) {
return axios.patch(
`${this.url}/${conversationID}/messages/${messageId}/edit_content`,
{ content }
);
}
retry(conversationID, messageId) {
return axios.post(
`${this.url}/${conversationID}/messages/${messageId}/retry`

View File

@ -0,0 +1,25 @@
/* global axios */
const API_BASE = '/api/v1/profile/inbox_signatures';
export default {
getAll(accountId) {
return axios.get(API_BASE, {
params: { account_id: accountId },
});
},
get(inboxId) {
return axios.get(`${API_BASE}/${inboxId}`);
},
upsert(inboxId, params) {
return axios.put(`${API_BASE}/${inboxId}`, {
inbox_signature: params,
});
},
delete(inboxId) {
return axios.delete(`${API_BASE}/${inboxId}`);
},
};

View File

@ -52,6 +52,26 @@ class Inboxes extends CacheEnabledApiClient {
resetSecret(inboxId) {
return axios.post(`${this.url}/${inboxId}/reset_secret`);
}
linkCSATTemplate(inboxId, template) {
return axios.post(`${this.url}/${inboxId}/csat_template/link`, {
template,
});
}
getAvailableCSATTemplates(inboxId) {
return axios.get(
`${this.url}/${inboxId}/csat_template/available_templates`
);
}
setupChannelProvider(inboxId) {
return axios.post(`${this.url}/${inboxId}/setup_channel_provider`);
}
disconnectChannelProvider(inboxId) {
return axios.post(`${this.url}/${inboxId}/disconnect_channel_provider`);
}
}
export default new Inboxes();

View File

@ -0,0 +1,72 @@
/* global axios */
import ApiClient from './ApiClient';
class InternalChatChannelsAPI extends ApiClient {
constructor() {
super('internal_chat/channels', { accountScoped: true });
}
getWithParams(params) {
return axios.get(this.url, { params });
}
getCategories() {
return axios.get(`${this.url.replace('/channels', '/categories')}`);
}
createCategory(data) {
return axios.post(`${this.url.replace('/channels', '/categories')}`, data);
}
deleteCategory(categoryId) {
return axios.delete(
`${this.url.replace('/channels', '/categories')}/${categoryId}`
);
}
archive(channelId) {
return axios.post(`${this.url}/${channelId}/archive`);
}
unarchive(channelId) {
return axios.post(`${this.url}/${channelId}/unarchive`);
}
getMembers(channelId) {
return axios.get(`${this.url}/${channelId}/members`);
}
addMember(channelId, userId) {
return axios.post(`${this.url}/${channelId}/members`, { user_id: userId });
}
removeMember(channelId, memberId) {
return axios.delete(`${this.url}/${channelId}/members/${memberId}`);
}
updateMember(channelId, memberId, data) {
return axios.patch(`${this.url}/${channelId}/members/${memberId}`, data);
}
toggleTypingStatus(channelId, typingStatus) {
return axios.post(`${this.url}/${channelId}/toggle_typing_status`, {
typing_status: typingStatus,
});
}
markRead(channelId) {
return axios.post(`${this.url}/${channelId}/mark_read`);
}
markUnread(channelId, messageId) {
return axios.post(`${this.url}/${channelId}/mark_unread`, {
message_id: messageId,
});
}
search(params) {
return axios.get(`${this.url.replace('/channels', '/search')}`, { params });
}
}
export default new InternalChatChannelsAPI();

View File

@ -0,0 +1,24 @@
/* global axios */
import ApiClient from './ApiClient';
class InternalChatDraftsAPI extends ApiClient {
constructor() {
super('internal_chat', { accountScoped: true });
}
getDrafts() {
return axios.get(`${this.url}/drafts`);
}
saveDraft(channelId, data) {
return axios.patch(`${this.url}/channels/${channelId}/draft`, data);
}
deleteDraft(channelId, { parentId } = {}) {
return axios.delete(`${this.url}/channels/${channelId}/draft`, {
params: { parent_id: parentId },
});
}
}
export default new InternalChatDraftsAPI();

View File

@ -0,0 +1,62 @@
/* global axios */
import ApiClient from './ApiClient';
class InternalChatMessagesAPI extends ApiClient {
constructor() {
super('internal_chat/channels', { accountScoped: true });
}
getMessages(channelId, params = {}) {
return axios.get(`${this.url}/${channelId}/messages`, { params });
}
createMessage(channelId, data, files = []) {
if (files.length === 0) {
return axios.post(`${this.url}/${channelId}/messages`, data);
}
const formData = new FormData();
if (data.content) formData.append('content', data.content);
if (data.parent_id) formData.append('parent_id', data.parent_id);
if (data.echo_id) formData.append('echo_id', data.echo_id);
files.forEach(file => {
formData.append('attachments[][file]', file);
});
return axios.post(`${this.url}/${channelId}/messages`, formData);
}
updateMessage(channelId, messageId, data) {
return axios.patch(`${this.url}/${channelId}/messages/${messageId}`, data);
}
deleteMessage(channelId, messageId) {
return axios.delete(`${this.url}/${channelId}/messages/${messageId}`);
}
getThread(channelId, messageId) {
return axios.get(`${this.url}/${channelId}/messages/${messageId}/thread`);
}
pinMessage(channelId, messageId) {
return axios.post(`${this.url}/${channelId}/messages/${messageId}/pin`);
}
unpinMessage(channelId, messageId) {
return axios.delete(`${this.url}/${channelId}/messages/${messageId}/unpin`);
}
addReaction(messageId, emoji) {
const baseUrl = this.url.replace('/channels', '');
return axios.post(`${baseUrl}/messages/${messageId}/reactions`, {
emoji,
});
}
removeReaction(messageId, reactionId) {
const baseUrl = this.url.replace('/channels', '');
return axios.delete(
`${baseUrl}/messages/${messageId}/reactions/${reactionId}`
);
}
}
export default new InternalChatMessagesAPI();

View File

@ -0,0 +1,24 @@
/* global axios */
import ApiClient from './ApiClient';
class InternalChatPollsAPI extends ApiClient {
constructor() {
super('internal_chat/polls', { accountScoped: true });
}
createPoll(data) {
return axios.post(this.url, data);
}
vote(pollId, optionId) {
return axios.post(`${this.url}/${pollId}/vote`, { option_id: optionId });
}
unvote(pollId, optionId) {
return axios.delete(`${this.url}/${pollId}/vote`, {
params: { option_id: optionId },
});
}
}
export default new InternalChatPollsAPI();

View File

@ -0,0 +1,81 @@
/* global axios */
import ApiClient from './ApiClient';
export const buildRecurringScheduledMessagePayload = ({
content,
status,
scheduledAt,
templateParams,
attachment,
removeAttachment,
recurrenceRule,
} = {}) => {
if (!attachment) {
return {
content,
status,
scheduled_at: scheduledAt,
template_params: templateParams,
remove_attachment: removeAttachment || undefined,
recurrence_rule: recurrenceRule,
};
}
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));
}
if (recurrenceRule) {
Object.entries(recurrenceRule).forEach(([key, value]) => {
if (Array.isArray(value)) {
value.forEach(v =>
payload.append(`recurrence_rule[${key}][]`, String(v))
);
} else {
payload.append(`recurrence_rule[${key}]`, String(value));
}
});
}
return payload;
};
class RecurringScheduledMessagesAPI extends ApiClient {
constructor() {
super('conversations', { accountScoped: true });
}
get(conversationId) {
return axios.get(
`${this.baseUrl()}/conversations/${conversationId}/recurring_scheduled_messages`
);
}
create(conversationId, payload) {
return axios({
method: 'post',
url: `${this.baseUrl()}/conversations/${conversationId}/recurring_scheduled_messages`,
data: buildRecurringScheduledMessagePayload(payload),
});
}
update(conversationId, recurringScheduledMessageId, payload) {
return axios({
method: 'patch',
url: `${this.baseUrl()}/conversations/${conversationId}/recurring_scheduled_messages/${recurringScheduledMessageId}`,
data: buildRecurringScheduledMessagePayload(payload),
});
}
delete(conversationId, recurringScheduledMessageId) {
return axios.delete(
`${this.baseUrl()}/conversations/${conversationId}/recurring_scheduled_messages/${recurringScheduledMessageId}`
);
}
}
export default new RecurringScheduledMessagesAPI();

View File

@ -0,0 +1,68 @@
/* global axios */
import ApiClient from './ApiClient';
export const buildScheduledMessagePayload = ({
content,
status,
scheduledAt,
templateParams,
attachment,
removeAttachment,
} = {}) => {
if (!attachment) {
return {
content,
status,
scheduled_at: scheduledAt,
template_params: templateParams,
remove_attachment: removeAttachment || undefined,
};
}
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,526 @@
<script setup>
import { computed, nextTick, onMounted, ref, useTemplateRef, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
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';
import { buildRecurrenceDescription } from 'dashboard/helper/recurrenceHelpers';
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', 'stop']);
const noteContentRef = useTemplateRef('noteContentRef');
const [isExpanded, toggleExpanded] = useToggle();
const showToggle = ref(false);
const showHistory = ref(false);
const showStopConfirm = ref(false);
const { t, locale } = useI18n();
const { formatMessage } = useMessageFormatter();
const route = useRoute();
const router = useRouter();
const normalizedLocale = computed(() => locale.value.replace('_', '-'));
const isRecurring = computed(() =>
Boolean(props.scheduledMessage?.recurrence_rule)
);
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',
},
active: {
labelKey: 'SCHEDULED_MESSAGES.RECURRENCE.STATUS_ACTIVE',
class: 'bg-n-brand/10 text-n-blue-text',
},
completed: {
labelKey: 'SCHEDULED_MESSAGES.RECURRENCE.STATUS_COMPLETED',
class: 'bg-n-slate-3 text-n-slate-11',
},
cancelled: {
labelKey: 'SCHEDULED_MESSAGES.RECURRENCE.STATUS_CANCELLED',
class: 'bg-n-ruby-3 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 recurrenceDescription = computed(() => {
if (!isRecurring.value) return '';
return buildRecurrenceDescription(
props.scheduledMessage.recurrence_rule,
t,
normalizedLocale.value
);
});
const scheduledAt = computed(() => {
if (isRecurring.value) {
const pending =
props.scheduledMessage.pending_scheduled_message ||
props.scheduledMessage.scheduled_messages?.find(
sm => sm.status === 'pending'
);
return pending?.scheduled_at || null;
}
return 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(normalizedLocale.value, 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));
// Recurring: completed children history
const completedChildren = computed(() => {
if (!isRecurring.value) return [];
const children = props.scheduledMessage.scheduled_messages || [];
return children
.filter(m => ['sent', 'failed'].includes(m.status))
.sort((a, b) => (b.scheduled_at || 0) - (a.scheduled_at || 0));
});
const hasCompletedChildren = computed(() => completedChildren.value.length > 0);
const formatChildTime = childScheduledAt => {
if (!childScheduledAt) return '';
const date = new Date(childScheduledAt * 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(normalizedLocale.value, options);
};
const canNavigateToChild = child =>
child.status === 'sent' && Boolean(child.message_id);
const scrollToChildMessage = child => {
if (!canNavigateToChild(child)) return;
router.replace({
...route,
query: { ...route.query, messageId: child.message_id },
});
};
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 = () => {
if (isRecurring.value) {
showStopConfirm.value = true;
} else {
emit('delete', props.scheduledMessage);
}
};
const confirmStop = () => {
emit('stop', props.scheduledMessage);
showStopConfirm.value = false;
};
const canScrollToMessage = computed(
() =>
!isRecurring.value &&
props.scheduledMessage?.status === 'sent' &&
Boolean(props.scheduledMessage?.message_id)
);
const scrollToMessage = () => {
if (!canScrollToMessage.value) return;
const messageId = props.scheduledMessage.message_id;
router.replace({
...route,
query: { ...route.query, messageId },
});
};
onMounted(() => {
checkOverflow();
});
watch(previewContent, () => {
nextTick(checkOverflow);
});
</script>
<template>
<div
class="flex flex-col gap-3 border-b border-n-strong py-3 group/scheduled rounded-md transition-colors"
:class="{
'cursor-pointer hover:bg-n-alpha-1': canScrollToMessage,
}"
:title="
canScrollToMessage
? t('SCHEDULED_MESSAGES.ITEM.GO_TO_MESSAGE')
: undefined
"
@click="scrollToMessage"
>
<!-- Recurrence description header -->
<div
v-if="isRecurring"
class="flex items-center gap-1.5 text-xs text-n-slate-11"
>
<Icon icon="i-lucide-repeat" class="size-3 shrink-0" />
<span class="truncate">{{ recurrenceDescription }}</span>
</div>
<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" />
{{
isRecurring
? t('SCHEDULED_MESSAGES.RECURRENCE.NEXT_SEND', {
time: formattedScheduledTime,
})
: 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.stop="onEdit"
/>
<Button
v-if="allowDelete"
variant="faded"
color="ruby"
size="xs"
:icon="isRecurring ? 'i-lucide-square' : 'i-lucide-trash'"
@click.stop="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>
<!-- Recurring: sent/failed history toggle -->
<div v-if="isRecurring && hasCompletedChildren" class="text-xs">
<button
class="flex items-center gap-1 text-n-slate-10 hover:text-n-slate-12 cursor-pointer transition-colors"
@click.stop="showHistory = !showHistory"
>
<Icon
:icon="showHistory ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
class="size-3"
/>
<span>
{{
t('SCHEDULED_MESSAGES.RECURRENCE.OCCURRENCES_SENT', {
count: completedChildren.length,
})
}}
</span>
</button>
</div>
<!-- Recurring: expanded history list -->
<div
v-if="isRecurring && showHistory && hasCompletedChildren"
class="flex flex-col gap-1 border-t border-n-weak pt-2"
>
<div
v-for="child in completedChildren"
:key="child.id"
class="flex items-center justify-between gap-2 rounded-lg px-2 py-1.5 text-xs transition-colors"
:class="{
'cursor-pointer hover:bg-n-alpha-2': canNavigateToChild(child),
}"
@click.stop="scrollToChildMessage(child)"
>
<div class="flex items-center gap-2 min-w-0">
<Icon
:icon="
child.status === 'sent'
? 'i-lucide-check-circle'
: 'i-lucide-x-circle'
"
class="size-3 shrink-0"
:class="
child.status === 'sent' ? 'text-n-teal-11' : 'text-n-ruby-11'
"
/>
<span class="text-n-slate-11">
{{ formatChildTime(child.scheduled_at) }}
</span>
</div>
<span
class="inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium shrink-0"
:class="
child.status === 'sent'
? 'bg-n-teal-9/10 text-n-teal-11'
: 'bg-n-ruby-9/10 text-n-ruby-11'
"
>
{{
t(
child.status === 'sent'
? 'SCHEDULED_MESSAGES.STATUS.SENT'
: 'SCHEDULED_MESSAGES.STATUS.FAILED'
)
}}
</span>
</div>
</div>
<!-- Stop recurrence confirmation modal -->
<woot-modal
v-if="isRecurring"
v-model:show="showStopConfirm"
size="small"
@close="() => (showStopConfirm = false)"
>
<div class="flex w-full flex-col gap-4 px-6 py-6">
<h3 class="text-lg font-semibold text-n-slate-12">
{{ t('SCHEDULED_MESSAGES.RECURRENCE.STOP_CONFIRM.TITLE') }}
</h3>
<p class="text-sm text-n-slate-11">
{{ t('SCHEDULED_MESSAGES.RECURRENCE.STOP_CONFIRM.MESSAGE') }}
</p>
<div class="flex items-center justify-end gap-3">
<Button
variant="faded"
color="slate"
:label="t('SCHEDULED_MESSAGES.RECURRENCE.STOP_CONFIRM.CANCEL')"
@click="showStopConfirm = false"
/>
<Button
variant="solid"
color="ruby"
:label="t('SCHEDULED_MESSAGES.RECURRENCE.STOP_CONFIRM.CONFIRM')"
@click="confirmStop"
/>
</div>
</div>
</woot-modal>
</div>
</template>

View File

@ -7,7 +7,13 @@ import { useStore, useStoreGetters } from 'dashboard/composables/store';
import { uploadFile } from 'dashboard/helper/uploadHelper';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { useVuelidate } from '@vuelidate/core';
import { required, minLength, helpers, url } from '@vuelidate/validators';
import {
required,
minLength,
maxLength,
helpers,
url,
} from '@vuelidate/validators';
import { isValidSlug } from 'shared/helpers/Validators';
import Button from 'dashboard/components-next/button/Button.vue';
@ -15,6 +21,7 @@ import Input from 'dashboard/components-next/input/Input.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import ColorPicker from 'dashboard/components-next/colorpicker/ColorPicker.vue';
import Switch from 'dashboard/components-next/switch/Switch.vue';
const props = defineProps({
activePortal: {
@ -34,6 +41,7 @@ const store = useStore();
const getters = useStoreGetters();
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
const CUSTOM_HTML_MAX_LENGTH = 15_000;
const state = reactive({
name: '',
@ -45,6 +53,9 @@ const state = reactive({
liveChatWidgetInboxId: '',
logoUrl: '',
avatarBlobId: '',
showAuthor: true,
customHeadHtml: '',
customBodyHtml: '',
});
const originalState = reactive({ ...state });
@ -80,6 +91,8 @@ const rules = {
),
},
homePageLink: { url },
customHeadHtml: { maxLength: maxLength(CUSTOM_HTML_MAX_LENGTH) },
customBodyHtml: { maxLength: maxLength(CUSTOM_HTML_MAX_LENGTH) },
};
const v$ = useVuelidate(rules, state);
@ -98,6 +111,18 @@ const homePageLinkError = computed(() =>
: ''
);
const customHeadHtmlError = computed(() =>
v$.value.customHeadHtml.$error
? t('HELP_CENTER.PORTAL_SETTINGS.FORM.CUSTOM_HEAD_HTML.ERROR')
: ''
);
const customBodyHtmlError = computed(() =>
v$.value.customBodyHtml.$error
? t('HELP_CENTER.PORTAL_SETTINGS.FORM.CUSTOM_BODY_HTML.ERROR')
: ''
);
const isUpdatingPortal = computed(() => {
const slug = props.activePortal?.slug;
if (slug) return getters['portals/uiFlagsIn'].value(slug)?.isUpdating;
@ -117,6 +142,9 @@ watch(
homePageLink: newVal.homepage_link,
slug: newVal.slug,
liveChatWidgetInboxId: newVal.inbox?.id || '',
showAuthor: newVal.config?.show_author !== false,
customHeadHtml: newVal.custom_head_html || '',
customBodyHtml: newVal.custom_body_html || '',
});
if (newVal.logo) {
const {
@ -149,6 +177,9 @@ const handleUpdatePortal = () => {
homepage_link: state.homePageLink,
blob_id: state.avatarBlobId,
inbox_id: state.liveChatWidgetInboxId,
config: { show_author: state.showAuthor },
custom_head_html: state.customHeadHtml,
custom_body_html: state.customBodyHtml,
};
emit('updatePortal', portal);
};
@ -335,6 +366,89 @@ const handleAvatarDelete = () => {
<ColorPicker v-model="state.widgetColor" />
</div>
</div>
<div
class="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]"
>
<label
class="text-sm font-medium whitespace-nowrap py-2.5 text-n-slate-12"
>
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.SHOW_AUTHOR.LABEL') }}
</label>
<div class="flex flex-col gap-1 py-2.5">
<Switch v-model="state.showAuthor" />
<span class="text-xs text-n-slate-11">
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.SHOW_AUTHOR.HELP_TEXT') }}
</span>
</div>
</div>
<div
class="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]"
>
<label
class="text-sm font-medium whitespace-nowrap py-2.5 text-n-slate-12"
>
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.CUSTOM_HEAD_HTML.LABEL') }}
</label>
<div class="flex flex-col gap-1">
<textarea
v-model="state.customHeadHtml"
:placeholder="
t('HELP_CENTER.PORTAL_SETTINGS.FORM.CUSTOM_HEAD_HTML.PLACEHOLDER')
"
rows="4"
class="w-full px-3 py-2 text-sm border rounded-lg resize-y bg-transparent dark:bg-transparent text-n-slate-12 placeholder:text-n-slate-9 focus:outline-none focus:ring-1 font-mono"
:class="
customHeadHtmlError
? 'border-n-ruby-9 focus:ring-n-ruby-9'
: 'border-n-weak focus:ring-n-brand'
"
@input="v$.customHeadHtml.$touch()"
/>
<span
class="text-xs"
:class="customHeadHtmlError ? 'text-n-ruby-9' : 'text-n-slate-11'"
>
{{
customHeadHtmlError ||
t('HELP_CENTER.PORTAL_SETTINGS.FORM.CUSTOM_HEAD_HTML.HELP_TEXT')
}}
</span>
</div>
</div>
<div
class="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]"
>
<label
class="text-sm font-medium whitespace-nowrap py-2.5 text-n-slate-12"
>
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.CUSTOM_BODY_HTML.LABEL') }}
</label>
<div class="flex flex-col gap-1">
<textarea
v-model="state.customBodyHtml"
:placeholder="
t('HELP_CENTER.PORTAL_SETTINGS.FORM.CUSTOM_BODY_HTML.PLACEHOLDER')
"
rows="4"
class="w-full px-3 py-2 text-sm border rounded-lg resize-y bg-transparent dark:bg-transparent text-n-slate-12 placeholder:text-n-slate-9 focus:outline-none focus:ring-1 font-mono"
:class="
customBodyHtmlError
? 'border-n-ruby-9 focus:ring-n-ruby-9'
: 'border-n-weak focus:ring-n-brand'
"
@input="v$.customBodyHtml.$touch()"
/>
<span
class="text-xs"
:class="customBodyHtmlError ? 'text-n-ruby-9' : 'text-n-slate-11'"
>
{{
customBodyHtmlError ||
t('HELP_CENTER.PORTAL_SETTINGS.FORM.CUSTOM_BODY_HTML.HELP_TEXT')
}}
</span>
</div>
</div>
<div class="flex justify-end w-full gap-2">
<Button
:label="t('HELP_CENTER.PORTAL_SETTINGS.FORM.SAVE_CHANGES')"

View File

@ -1,9 +1,11 @@
<script setup>
import { reactive, ref, computed, onMounted, watch } from 'vue';
import { reactive, ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { useRouter, useRoute } from 'vue-router';
import { useWindowSize } from '@vueuse/core';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useInboxSignatures } from 'dashboard/composables/useInboxSignatures';
import { vOnClickOutside } from '@vueuse/components';
import { useAlert } from 'dashboard/composables';
import { ExceptionWithMessage } from 'shared/helpers/CustomErrors';
@ -18,9 +20,12 @@ import {
processContactableInboxes,
mergeInboxDetails,
} from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import { pendingGroupNavigation } from 'dashboard/helper/pendingGroupNavigation';
import wootConstants from 'dashboard/constants/globals';
import ComposeNewConversationForm from 'dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue';
import ComposeNewGroupForm from 'dashboard/components-next/NewConversation/components/ComposeNewGroupForm.vue';
const props = defineProps({
alignPosition: {
@ -42,6 +47,8 @@ const emit = defineEmits(['close']);
const searchContacts = createContactSearcher();
const store = useStore();
const { t } = useI18n();
const router = useRouter();
const route = useRoute();
const { width: windowWidth } = useWindowSize();
const { fetchSignatureFlagFromUISettings } = useUISettings();
@ -59,6 +66,8 @@ const isCreatingContact = ref(false);
const isFetchingInboxes = ref(false);
const isSearching = ref(false);
const showComposeNewConversation = ref(false);
const composeMode = ref('conversation');
const groupFormRef = ref(null);
const formState = reactive({
message: '',
@ -84,6 +93,85 @@ const globalConfig = useMapGetter('globalConfig/get');
const uiFlags = useMapGetter('contactConversations/getUIFlags');
const messageSignature = useMapGetter('getMessageSignature');
const inboxesList = useMapGetter('inboxes/getInboxes');
const groupUiFlags = useMapGetter('groupMembers/getUIFlags');
const groupCreationInboxes = computed(() =>
inboxesList.value.filter(inbox => inbox.allow_group_creation)
);
const isGroupMode = computed(() => composeMode.value === 'group');
const hasGroupInboxes = computed(() => groupCreationInboxes.value.length > 0);
const isGroupsDisabled = computed(
() => !globalConfig.value.baileysWhatsappGroupsEnabled
);
const isSuperAdmin = computed(() => currentUser.value.type === 'SuperAdmin');
const resetContacts = () => {
contacts.value = [];
};
const closeCompose = () => {
showComposeNewConversation.value = false;
composeMode.value = 'conversation';
if (!props.contactId) {
selectedContact.value = null;
}
targetInbox.value = null;
resetContacts();
groupFormRef.value?.resetForm();
emit('close');
};
const discardCompose = () => {
clearFormState();
formState.message = '';
closeCompose();
};
const switchMode = mode => {
if (composeMode.value === mode) return;
composeMode.value = mode;
selectedContact.value = null;
targetInbox.value = null;
clearFormState();
formState.message = '';
resetContacts();
groupFormRef.value?.resetForm();
};
const createGroup = async ({ inboxId, subject, participants }) => {
try {
const data = await store.dispatch('groupMembers/createGroup', {
inbox_id: inboxId,
subject,
participants,
});
pendingGroupNavigation.set(data.group_jid);
groupFormRef.value?.resetForm();
discardCompose();
useAlert(t('GROUP.CREATE.SUCCESS_MESSAGE'));
} catch {
useAlert(t('GROUP.CREATE.ERROR_MESSAGE'));
}
};
const {
fetchInboxSignatures,
getSignatureForInbox,
getSignatureSettingsForInbox,
} = useInboxSignatures();
fetchInboxSignatures();
const resolvedMessageSignature = computed(() => {
if (!targetInbox.value?.id) return messageSignature.value;
return getSignatureForInbox(targetInbox.value.id);
});
const resolvedSignatureSettings = computed(() => {
if (!targetInbox.value?.id) return null;
return getSignatureSettingsForInbox(targetInbox.value.id);
});
const sendWithSignature = computed(() =>
fetchSignatureFlagFromUISettings(targetInbox.value?.channelType)
@ -122,10 +210,6 @@ const onContactSearch = debounce(
false
);
const resetContacts = () => {
contacts.value = [];
};
const handleSelectedContact = async ({ value, action, ...rest }) => {
let contact;
if (action === 'create') {
@ -171,24 +255,6 @@ const clearSelectedContact = () => {
clearFormState();
};
const closeCompose = () => {
showComposeNewConversation.value = false;
if (!props.contactId) {
// If contactId is passed as prop
// Then don't allow to remove the selected contact
selectedContact.value = null;
}
targetInbox.value = null;
resetContacts();
emit('close');
};
const discardCompose = () => {
clearFormState();
formState.message = '';
closeCompose();
};
const createConversation = async ({ payload, isFromWhatsApp }) => {
try {
const data = await store.dispatch('contactConversations/create', {
@ -254,7 +320,24 @@ const onModalBackdropClick = () => {
handleClickOutside();
};
onMounted(() => resetContacts());
const navigateToGroup = ({ conversationId }) => {
const url = frontendURL(
conversationUrl({
accountId: route.params.accountId,
id: conversationId,
})
);
router.push({ path: url });
};
onMounted(() => {
resetContacts();
emitter.on(BUS_EVENTS.NAVIGATE_TO_GROUP, navigateToGroup);
});
onUnmounted(() => {
emitter.off(BUS_EVENTS.NAVIGATE_TO_GROUP, navigateToGroup);
});
const keyboardEvents = {
Escape: {
@ -297,30 +380,107 @@ useKeyboardEvents(keyboardEvents);
}"
@click.self="onModalBackdropClick"
>
<ComposeNewConversationForm
:form-state="formState"
<div
v-if="!isGroupMode"
:class="[{ 'mt-2': !viewInModal }, composePopoverClass]"
:contacts="contacts"
:contact-id="contactId"
:is-loading="isSearching"
:current-user="currentUser"
:selected-contact="selectedContact"
:target-inbox="targetInbox"
:is-creating-contact="isCreatingContact"
:is-fetching-inboxes="isFetchingInboxes"
:is-direct-uploads-enabled="directUploadsEnabled"
:contact-conversations-ui-flags="uiFlags"
:contacts-ui-flags="contactsUiFlags"
:message-signature="messageSignature"
:send-with-signature="sendWithSignature"
@search-contacts="onContactSearch"
@reset-contact-search="resetContacts"
@update-selected-contact="handleSelectedContact"
@update-target-inbox="handleTargetInbox"
@clear-selected-contact="clearSelectedContact"
@create-conversation="createConversation"
@discard="discardCompose"
/>
class="w-[42rem] flex flex-col"
>
<div
v-if="hasGroupInboxes"
class="flex gap-1 px-4 pt-3 pb-0 bg-n-alpha-3 border border-b-0 border-n-strong backdrop-blur-[100px] rounded-t-xl"
>
<button
class="px-3 py-1.5 text-sm font-medium rounded-t-lg border-b-2 transition-colors"
:class="
!isGroupMode
? 'text-n-brand border-n-brand bg-n-alpha-2'
: 'text-n-slate-11 border-transparent hover:text-n-slate-12'
"
@click="switchMode('conversation')"
>
{{ t('COMPOSE_NEW_CONVERSATION.TAB_CONVERSATION') }}
</button>
<button
class="px-3 py-1.5 text-sm font-medium rounded-t-lg border-b-2 transition-colors"
:class="
isGroupMode
? 'text-n-brand border-n-brand bg-n-alpha-2'
: 'text-n-slate-11 border-transparent hover:text-n-slate-12'
"
@click="switchMode('group')"
>
{{ t('COMPOSE_NEW_CONVERSATION.TAB_GROUP') }}
</button>
</div>
<ComposeNewConversationForm
:form-state="formState"
:class="{ '!rounded-t-none !border-t-0': hasGroupInboxes }"
:contacts="contacts"
:contact-id="contactId"
:is-loading="isSearching"
:current-user="currentUser"
:selected-contact="selectedContact"
:target-inbox="targetInbox"
:is-creating-contact="isCreatingContact"
:is-fetching-inboxes="isFetchingInboxes"
:is-direct-uploads-enabled="directUploadsEnabled"
:contact-conversations-ui-flags="uiFlags"
:contacts-ui-flags="contactsUiFlags"
:message-signature="resolvedMessageSignature"
:send-with-signature="sendWithSignature"
:signature-settings="resolvedSignatureSettings"
@search-contacts="onContactSearch"
@reset-contact-search="resetContacts"
@update-selected-contact="handleSelectedContact"
@update-target-inbox="handleTargetInbox"
@clear-selected-contact="clearSelectedContact"
@create-conversation="createConversation"
@discard="discardCompose"
/>
</div>
<div
v-else
:class="[{ 'mt-2': !viewInModal }, composePopoverClass]"
class="w-[42rem] flex flex-col"
>
<div
class="flex gap-1 px-4 pt-3 pb-0 bg-n-alpha-3 border border-b-0 border-n-strong backdrop-blur-[100px] rounded-t-xl"
>
<button
class="px-3 py-1.5 text-sm font-medium rounded-t-lg border-b-2 transition-colors"
:class="
!isGroupMode
? 'text-n-brand border-n-brand bg-n-alpha-2'
: 'text-n-slate-11 border-transparent hover:text-n-slate-12'
"
@click="switchMode('conversation')"
>
{{ t('COMPOSE_NEW_CONVERSATION.TAB_CONVERSATION') }}
</button>
<button
class="px-3 py-1.5 text-sm font-medium rounded-t-lg border-b-2 transition-colors"
:class="
isGroupMode
? 'text-n-brand border-n-brand bg-n-alpha-2'
: 'text-n-slate-11 border-transparent hover:text-n-slate-12'
"
@click="switchMode('group')"
>
{{ t('COMPOSE_NEW_CONVERSATION.TAB_GROUP') }}
</button>
</div>
<ComposeNewGroupForm
ref="groupFormRef"
class="!rounded-t-none !border-t-0"
:inboxes="groupCreationInboxes"
:is-creating="groupUiFlags.isCreating"
:is-groups-disabled="isGroupsDisabled"
:is-super-admin="isSuperAdmin"
@create-group="createGroup"
@discard="discardCompose"
/>
</div>
</div>
</div>
</template>

View File

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

View File

@ -4,8 +4,6 @@ import { useVuelidate } from '@vuelidate/core';
import { required, requiredIf } from '@vuelidate/validators';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import {
appendSignature,
removeSignature,
getEffectiveChannelType,
stripUnsupportedMarkdown,
} from 'dashboard/helper/editorHelper';
@ -41,6 +39,7 @@ const props = defineProps({
contactsUiFlags: { type: Object, default: null },
messageSignature: { type: String, default: '' },
sendWithSignature: { type: Boolean, default: false },
signatureSettings: { type: Object, default: null },
formState: { type: Object, required: true },
});
@ -77,6 +76,12 @@ const inboxTypes = computed(() => ({
isEmail: props.targetInbox?.channelType === INBOX_TYPES.EMAIL,
isTwilio: props.targetInbox?.channelType === INBOX_TYPES.TWILIO,
isWhatsapp: props.targetInbox?.channelType === INBOX_TYPES.WHATSAPP,
isWhatsappBaileys:
props.targetInbox?.channelType === INBOX_TYPES.WHATSAPP &&
props.targetInbox?.provider === 'baileys',
isWhatsappZapi:
props.targetInbox?.channelType === INBOX_TYPES.WHATSAPP &&
props.targetInbox?.provider === 'zapi',
isWebWidget: props.targetInbox?.channelType === INBOX_TYPES.WEB,
isApi: props.targetInbox?.channelType === INBOX_TYPES.API,
isEmailOrWebWidget:
@ -98,12 +103,6 @@ const whatsappMessageTemplates = computed(() =>
const inboxChannelType = computed(() => props.targetInbox?.channelType || '');
const inboxMedium = computed(() => props.targetInbox?.medium || '');
const effectiveChannelType = computed(() =>
getEffectiveChannelType(inboxChannelType.value, inboxMedium.value)
);
const validationRules = computed(() => ({
selectedContact: { required },
targetInbox: { required },
@ -138,6 +137,9 @@ const newMessagePayload = () => {
currentUser: props.currentUser,
attachedFiles,
directUploadsEnabled: props.isDirectUploadsEnabled,
sendWithSignature: props.sendWithSignature,
messageSignature: props.messageSignature,
signatureSettings: props.signatureSettings,
});
};
@ -220,22 +222,9 @@ const handleInboxAction = ({ value, action, channelType, medium, ...rest }) => {
state.attachedFiles = [];
};
const removeSignatureFromMessage = () => {
// Always remove the signature from message content when inbox/contact is removed
// to ensure no leftover signature content remains
if (props.messageSignature) {
state.message = removeSignature(
state.message,
props.messageSignature,
effectiveChannelType.value
);
}
};
const removeTargetInbox = value => {
v$.value.$reset();
copilot.reset(false);
removeSignatureFromMessage();
stripMessageFormatting(DEFAULT_FORMATTING);
@ -245,7 +234,6 @@ const removeTargetInbox = value => {
const clearSelectedContact = () => {
copilot.reset(false);
removeSignatureFromMessage();
emit('clearSelectedContact');
state.message = '';
state.attachedFiles = [];
@ -255,22 +243,6 @@ const onClickInsertEmoji = emoji => {
state.message += emoji;
};
const handleAddSignature = signature => {
state.message = appendSignature(
state.message,
signature,
effectiveChannelType.value
);
};
const handleRemoveSignature = signature => {
state.message = removeSignature(
state.message,
signature,
effectiveChannelType.value
);
};
const handleAttachFile = files => {
state.attachedFiles = files;
};
@ -334,7 +306,9 @@ const handleSendTwilioMessage = async ({ message, templateParams }) => {
const shouldShowMessageEditor = computed(() => {
return (
!inboxTypes.value.isWhatsapp &&
(!inboxTypes.value.isWhatsapp ||
inboxTypes.value.isWhatsappBaileys ||
inboxTypes.value.isWhatsappZapi) &&
!showNoInboxAlert.value &&
!inboxTypes.value.isTwilioWhatsapp
);
@ -437,6 +411,8 @@ useKeyboardEvents({
v-else
:attached-files="state.attachedFiles"
:is-whatsapp-inbox="inboxTypes.isWhatsapp"
:is-whatsapp-baileys-inbox="inboxTypes.isWhatsappBaileys"
:is-whatsapp-zapi-inbox="inboxTypes.isWhatsappZapi"
:is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget"
:is-twilio-sms-inbox="inboxTypes.isTwilioSMS"
:is-twilio-whats-app-inbox="inboxTypes.isTwilioWhatsapp"
@ -450,8 +426,6 @@ useKeyboardEvents({
:is-dropdown-active="isAnyDropdownActive"
:message-signature="messageSignature"
@insert-emoji="onClickInsertEmoji"
@add-signature="handleAddSignature"
@remove-signature="handleRemoveSignature"
@attach-file="handleAttachFile"
@discard="$emit('discard')"
@send-message="handleSendMessage"

View File

@ -0,0 +1,309 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { debounce } from '@chatwoot/utils';
import ContactsAPI from 'dashboard/api/contacts';
import wootConstants from 'dashboard/constants/globals';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import TagInput from 'dashboard/components-next/taginput/TagInput.vue';
const props = defineProps({
inboxes: { type: Array, default: () => [] },
isCreating: { type: Boolean, default: false },
isGroupsDisabled: { type: Boolean, default: false },
isSuperAdmin: { type: Boolean, default: false },
});
const emit = defineEmits(['createGroup', 'discard']);
const { t } = useI18n();
const groupName = ref('');
const selectedInbox = ref(null);
const showInboxDropdown = ref(false);
const participants = ref([]);
const contactResults = ref([]);
const showContactsDropdown = ref(false);
const isSearching = ref(false);
const nameTouched = ref(false);
const participantsTouched = ref(false);
const participantsFocused = ref(false);
const inboxMenuItems = computed(() =>
props.inboxes.map(inbox => ({
label: inbox.name,
value: inbox.id,
action: 'select',
}))
);
const contactMenuItems = computed(() =>
contactResults.value.map(contact => ({
id: contact.id,
label: contact.phone_number
? `${contact.name} (${contact.phone_number})`
: contact.name,
value: contact.id,
action: 'contact',
thumbnail: { name: contact.name, src: contact.thumbnail },
phoneNumber: contact.phone_number,
name: contact.name,
}))
);
const participantTags = computed(() =>
participants.value.map(p => p.name || p.phone_number)
);
const showNameError = computed(
() => nameTouched.value && !groupName.value.trim()
);
const showParticipantsError = computed(
() => participantsTouched.value && participants.value.length === 0
);
const isFormValid = computed(
() =>
selectedInbox.value &&
groupName.value.trim() &&
participants.value.length > 0
);
const searchContacts = debounce(
async query => {
if (!query || query.length < 2) {
contactResults.value = [];
showContactsDropdown.value = false;
return;
}
isSearching.value = true;
try {
const { data } = await ContactsAPI.search(query);
const selectedIds = participants.value.map(p => p.id);
contactResults.value = (data.payload || []).filter(
contact => contact.phone_number && !selectedIds.includes(contact.id)
);
showContactsDropdown.value = contactResults.value.length > 0;
} catch {
contactResults.value = [];
} finally {
isSearching.value = false;
}
},
300,
false
);
const handleInboxAction = item => {
const inbox = props.inboxes.find(i => i.id === item.value);
selectedInbox.value = inbox;
showInboxDropdown.value = false;
};
const clearInbox = () => {
selectedInbox.value = null;
};
const handleAddParticipant = item => {
const contact = contactResults.value.find(c => c.id === item.value);
if (contact) {
participants.value = [...participants.value, contact];
participantsTouched.value = true;
contactResults.value = [];
showContactsDropdown.value = false;
}
};
const handleRemoveParticipant = index => {
participants.value = participants.value.filter((_, i) => i !== index);
participantsTouched.value = true;
};
const handleNameBlur = () => {
nameTouched.value = true;
};
const handleParticipantsFocus = () => {
participantsFocused.value = true;
};
const handleParticipantsBlur = () => {
showContactsDropdown.value = false;
if (participantsFocused.value && participants.value.length === 0) {
participantsTouched.value = true;
}
};
const resetForm = () => {
groupName.value = '';
selectedInbox.value = null;
participants.value = [];
contactResults.value = [];
showContactsDropdown.value = false;
showInboxDropdown.value = false;
nameTouched.value = false;
participantsTouched.value = false;
participantsFocused.value = false;
};
const handleSubmit = () => {
if (!isFormValid.value) return;
emit('createGroup', {
inboxId: selectedInbox.value.id,
subject: groupName.value.trim(),
participants: participants.value.map(p => p.phone_number),
});
};
defineExpose({ resetForm });
</script>
<template>
<div
class="w-[42rem] divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl min-w-0 max-h-[calc(100vh-8rem)]"
>
<div class="flex-1 divide-y divide-n-strong overflow-visible">
<div
v-if="isGroupsDisabled"
class="flex items-center gap-2 mx-4 mt-3 px-3 py-2 rounded-lg text-sm text-n-amber-11 bg-n-amber-2"
>
<span class="i-lucide-triangle-alert text-base flex-shrink-0" />
<span v-if="isSuperAdmin">
{{ t('GROUP.CREATE.GROUPS_DISABLED') }}
<a
:href="wootConstants.FAZER_AI_GUIDES_URL"
target="_blank"
rel="noopener noreferrer"
class="underline font-medium"
>
{{ t('GROUP.CREATE.GROUPS_DISABLED_CTA') }}
</a>
</span>
<span v-else>
{{ t('GROUP.CREATE.GROUPS_DISABLED_NON_ADMIN') }}
</span>
</div>
<div
class="flex items-center flex-1 w-full gap-3 px-4 py-3 overflow-y-visible"
>
<label
class="mb-0.5 text-sm font-medium text-n-slate-11 whitespace-nowrap"
>
{{ t('GROUP.CREATE.INBOX_LABEL') }}
</label>
<div class="relative flex-1 min-w-0">
<div
v-if="selectedInbox"
class="flex items-center gap-1.5 rounded-md bg-n-alpha-2 truncate ltr:pl-3 rtl:pr-3 ltr:pr-1 rtl:pl-1 h-7 min-w-0"
>
<span class="text-sm truncate text-n-slate-12">
{{ selectedInbox.name }}
</span>
<Button
variant="ghost"
icon="i-lucide-x"
color="slate"
size="xs"
class="flex-shrink-0"
@click="clearInbox"
/>
</div>
<div v-else class="relative">
<Button
:label="t('GROUP.CREATE.INBOX_PLACEHOLDER')"
variant="link"
size="sm"
color="slate"
class="hover:!no-underline"
@click="showInboxDropdown = !showInboxDropdown"
/>
<DropdownMenu
v-if="showInboxDropdown"
:menu-items="inboxMenuItems"
class="z-[100] top-9 w-full max-h-48 overflow-y-auto dark:!outline-n-slate-5"
@action="handleInboxAction"
/>
</div>
</div>
</div>
<div
class="flex items-start flex-1 w-full gap-3 px-4 py-3 overflow-y-visible"
>
<label
class="mb-0.5 text-sm font-medium whitespace-nowrap mt-1"
:class="showNameError ? 'text-n-ruby-9' : 'text-n-slate-11'"
>
{{ t('GROUP.CREATE.NAME_LABEL') }}
</label>
<div class="flex flex-col flex-1 min-w-0">
<input
v-model="groupName"
type="text"
class="w-full px-2 py-1 text-sm rounded-md bg-transparent text-n-slate-12 placeholder:text-n-slate-10 focus:outline-none border"
:class="showNameError ? 'border-n-ruby-9' : 'border-transparent'"
:placeholder="t('GROUP.CREATE.NAME_PLACEHOLDER')"
@blur="handleNameBlur"
/>
<span v-if="showNameError" class="text-xs text-n-ruby-9 mt-0.5 px-2">
{{ t('GROUP.CREATE.NAME_REQUIRED') }}
</span>
</div>
</div>
<div class="relative flex flex-col gap-1 px-4 py-3">
<label
class="mb-0.5 text-sm font-medium whitespace-nowrap"
:class="showParticipantsError ? 'text-n-ruby-9' : 'text-n-slate-11'"
>
{{ t('GROUP.CREATE.PARTICIPANTS_LABEL') }}
</label>
<TagInput
:model-value="participantTags"
:placeholder="t('GROUP.CREATE.PARTICIPANTS_PLACEHOLDER')"
mode="multiple"
:menu-items="contactMenuItems"
:show-dropdown="showContactsDropdown"
:is-loading="isSearching"
skip-label-dedup
:auto-open-dropdown="false"
:class="showParticipantsError ? '!border-n-ruby-9' : ''"
@input="searchContacts"
@focus="handleParticipantsFocus"
@on-click-outside="handleParticipantsBlur"
@add="handleAddParticipant"
@remove="handleRemoveParticipant"
/>
<span v-if="showParticipantsError" class="text-xs text-n-ruby-9">
{{ t('GROUP.CREATE.PARTICIPANTS_REQUIRED') }}
</span>
</div>
</div>
<div class="flex items-center justify-between gap-2 px-4 py-3">
<div />
<div class="flex items-center gap-2">
<Button
:label="t('COMPOSE_NEW_CONVERSATION.FORM.ACTION_BUTTONS.DISCARD')"
variant="faded"
color="slate"
size="sm"
@click="
resetForm();
emit('discard');
"
/>
<Button
:label="t('GROUP.CREATE.SUBMIT_BUTTON')"
color="blue"
size="sm"
:disabled="!isFormValid || isCreating || isGroupsDisabled"
:is-loading="isCreating"
@click="handleSubmit"
/>
</div>
</div>
</div>
</template>

View File

@ -1,5 +1,6 @@
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { getInboxIconByType } from 'dashboard/helper/inbox';
import { appendSignature } from 'dashboard/helper/editorHelper';
import camelcaseKeys from 'camelcase-keys';
import ContactAPI from 'dashboard/api/contacts';
@ -129,12 +130,24 @@ export const prepareNewMessagePayload = ({
currentUser,
attachedFiles = [],
directUploadsEnabled = false,
sendWithSignature = false,
messageSignature = '',
signatureSettings = null,
}) => {
let finalMessage = message;
if (sendWithSignature && messageSignature) {
const settings = signatureSettings || {
position: currentUser?.ui_settings?.signature_position || 'top',
separator: currentUser?.ui_settings?.signature_separator || 'blank',
};
finalMessage = appendSignature(message, messageSignature, settings);
}
const payload = {
inboxId: targetInbox.id,
sourceId: targetInbox.sourceId,
contactId: Number(selectedContact.id),
message: { content: message },
message: { content: finalMessage },
assigneeId: currentUser.id,
};

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