diff --git a/.annotaterb.yml b/.annotaterb.yml new file mode 100644 index 000000000..5b79a7fba --- /dev/null +++ b/.annotaterb.yml @@ -0,0 +1,45 @@ +additional_file_patterns: [] +routes: false +models: true +position_in_routes: before +position_in_class: before +position_in_test: before +position_in_fixture: before +position_in_factory: before +position_in_serializer: before +show_foreign_keys: true +show_complete_foreign_keys: false +show_indexes: true +simple_indexes: false +model_dir: + - app/models + - enterprise/app/models +root_dir: '' +include_version: false +require: '' +exclude_tests: true +exclude_fixtures: true +exclude_factories: true +exclude_serializers: true +exclude_scaffolds: true +exclude_controllers: true +exclude_helpers: true +exclude_sti_subclasses: false +ignore_model_sub_dir: false +ignore_columns: null +ignore_routes: null +ignore_unknown_models: false +hide_limit_column_types: integer,bigint,boolean +hide_default_column_types: json,jsonb,hstore +skip_on_db_migrate: false +format_bare: true +format_rdoc: false +format_markdown: false +sort: false +force: false +frozen: false +classified_sort: true +trace: false +wrapper_open: null +wrapper_close: null +with_comment: true diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 000000000..a31d28c2d --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,3 @@ +{ + "additionalDirectories": ["../baileys-api"] +} diff --git a/.env.example b/.env.example index bc7380a29..ca4faf88b 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 000000000..5da1bca2e --- /dev/null +++ b/.github/copilot-instructions.md @@ -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 -- `. Just do `pnpm test ` directly diff --git a/.github/workflows/frontend-fe.yml b/.github/workflows/frontend-fe.yml index 1d1116d0c..a7b19cd7a 100644 --- a/.github/workflows/frontend-fe.yml +++ b/.github/workflows/frontend-fe.yml @@ -2,11 +2,9 @@ name: Frontend Lint & Test on: push: - branches: - - develop - pull_request: - branches: - - develop + tags: + - '*' + workflow_dispatch: jobs: test: diff --git a/.github/workflows/publish_ee_github_docker.yml b/.github/workflows/publish_ee_github_docker.yml new file mode 100644 index 000000000..4bf5d9fce --- /dev/null +++ b/.github/workflows/publish_ee_github_docker.yml @@ -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 diff --git a/.github/workflows/publish_github_docker.yml b/.github/workflows/publish_github_docker.yml new file mode 100644 index 000000000..2aeaf6366 --- /dev/null +++ b/.github/workflows/publish_github_docker.yml @@ -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 diff --git a/.github/workflows/publish_github_docker_beta.yml b/.github/workflows/publish_github_docker_beta.yml new file mode 100644 index 000000000..7bc537ed2 --- /dev/null +++ b/.github/workflows/publish_github_docker_beta.yml @@ -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 diff --git a/.github/workflows/run_foss_spec.yml b/.github/workflows/run_foss_spec.yml index c2a626388..1487d96a5 100644 --- a/.github/workflows/run_foss_spec.yml +++ b/.github/workflows/run_foss_spec.yml @@ -3,10 +3,8 @@ permissions: contents: read on: push: - branches: - - develop - - master - pull_request: + tags: + - '*' workflow_dispatch: jobs: diff --git a/.husky/pre-commit b/.husky/pre-commit index b3aceacd6..9c350db88 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -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 diff --git a/.rubocop.yml b/.rubocop.yml index d87f08bfd..246f503bb 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -218,6 +218,7 @@ Style/OneClassPerFile: AllCops: NewCops: enable + SuggestExtensions: false Exclude: - 'bin/**/*' - 'db/schema.rb' @@ -348,3 +349,12 @@ FactoryBot/RedundantFactoryOption: FactoryBot/FactoryAssociationWithStrategy: Enabled: false + +Rails/SaveBang: + Enabled: true + AllowedReceivers: + - Stripe::Subscription + - Stripe::Customer + - Stripe::Invoice + - Stripe::InvoiceItem + - FactoryBot diff --git a/.vscode/settings.json b/.vscode/settings.json index b3cfaee50..b663561bb 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,5 +2,6 @@ "cSpell.words": [ "chatwoot", "dompurify" - ] + ], + "css.customData": [".vscode/tailwind.json"] } diff --git a/.vscode/tailwind.json b/.vscode/tailwind.json new file mode 100644 index 000000000..e47372fec --- /dev/null +++ b/.vscode/tailwind.json @@ -0,0 +1,55 @@ +{ + "version": 1.1, + "atDirectives": [ + { + "name": "@tailwind", + "description": "Use the `@tailwind` directive to insert Tailwind's `base`, `components`, `utilities` and `screens` styles into your CSS.", + "references": [ + { + "name": "Tailwind Documentation", + "url": "https://tailwindcss.com/docs/functions-and-directives#tailwind" + } + ] + }, + { + "name": "@apply", + "description": "Use the `@apply` directive to inline any existing utility classes into your own custom CSS. This is useful when you find a common utility pattern in your HTML that you’d like to extract to a new component.", + "references": [ + { + "name": "Tailwind Documentation", + "url": "https://tailwindcss.com/docs/functions-and-directives#apply" + } + ] + }, + { + "name": "@responsive", + "description": "You can generate responsive variants of your own classes by wrapping their definitions in the `@responsive` directive:\n```css\n@responsive {\n .alert {\n background-color: #E53E3E;\n }\n}\n```\n", + "references": [ + { + "name": "Tailwind Documentation", + "url": "https://tailwindcss.com/docs/functions-and-directives#responsive" + } + ] + }, + { + "name": "@screen", + "description": "The `@screen` directive allows you to create media queries that reference your breakpoints by **name** instead of duplicating their values in your own CSS:\n```css\n@screen sm {\n /* ... */\n}\n```\n…gets transformed into this:\n```css\n@media (min-width: 640px) {\n /* ... */\n}\n```\n", + "references": [ + { + "name": "Tailwind Documentation", + "url": "https://tailwindcss.com/docs/functions-and-directives#screen" + } + ] + }, + { + "name": "@variants", + "description": "Generate `hover`, `focus`, `active` and other **variants** of your own utilities by wrapping their definitions in the `@variants` directive:\n```css\n@variants hover, focus {\n .btn-brand {\n background-color: #3182CE;\n }\n}\n```\n", + "references": [ + { + "name": "Tailwind Documentation", + "url": "https://tailwindcss.com/docs/functions-and-directives#variants" + } + ] + } + ] +} diff --git a/CUSTOM_BRANDING.md b/CUSTOM_BRANDING.md new file mode 100644 index 000000000..2359bf342 --- /dev/null +++ b/CUSTOM_BRANDING.md @@ -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). diff --git a/Gemfile b/Gemfile index 0636b7b2b..8366e19e4 100644 --- a/Gemfile +++ b/Gemfile @@ -43,7 +43,7 @@ gem 'down' # authentication type to fetch and send mail over oauth2.0 gem 'gmail_xoauth' # Lock net-smtp to 0.3.4 to avoid issues with gmail_xoauth2 -gem 'net-smtp', '~> 0.3.4' +gem 'net-smtp', '~> 0.3.4' # Prevent CSV injection gem 'csv-safe' @@ -54,6 +54,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' @@ -205,6 +206,8 @@ gem 'opentelemetry-exporter-otlp' gem 'shopify_api' +gem 'resend', '~> 0.19.0' + ### Gems required only in specific deployment environments ### ############################################################## diff --git a/Gemfile.lock b/Gemfile.lock index 7a7316e3c..6115b51d5 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -749,6 +749,8 @@ GEM uber (< 0.2.0) request_store (1.5.1) rack (>= 1.4) + resend (0.19.0) + httparty (>= 0.21.0) responders (3.1.1) actionpack (>= 5.2) railties (>= 5.2) @@ -935,6 +937,8 @@ GEM squasher (0.7.2) 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) @@ -1116,6 +1120,7 @@ DEPENDENCIES rails (~> 7.1) redis redis-namespace + resend (~> 0.19.0) responders (>= 3.1.1) rest-client reverse_markdown @@ -1148,6 +1153,7 @@ DEPENDENCIES spring-watcher-listen squasher stackprof + streamio-ffmpeg (~> 3.0) stripe (~> 18.0) telephone_number test-prof diff --git a/app/actions/contact_identify_action.rb b/app/actions/contact_identify_action.rb index bcf5a93c3..d18999bc1 100644 --- a/app/actions/contact_identify_action.rb +++ b/app/actions/contact_identify_action.rb @@ -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? diff --git a/app/builders/contact_inbox_with_contact_builder.rb b/app/builders/contact_inbox_with_contact_builder.rb index 2c0e6087e..994b52078 100644 --- a/app/builders/contact_inbox_with_contact_builder.rb +++ b/app/builders/contact_inbox_with_contact_builder.rb @@ -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 diff --git a/app/builders/conversation_builder.rb b/app/builders/conversation_builder.rb index 6a995b188..b9d9b8148 100644 --- a/app/builders/conversation_builder.rb +++ b/app/builders/conversation_builder.rb @@ -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 diff --git a/app/builders/messages/instagram/base_message_builder.rb b/app/builders/messages/instagram/base_message_builder.rb index 8045e84c9..09176f499 100644 --- a/app/builders/messages/instagram/base_message_builder.rb +++ b/app/builders/messages/instagram/base_message_builder.rb @@ -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) diff --git a/app/builders/messages/instagram/message_builder.rb b/app/builders/messages/instagram/message_builder.rb index 4e7150894..e306c7b70 100644 --- a/app/builders/messages/instagram/message_builder.rb +++ b/app/builders/messages/instagram/message_builder.rb @@ -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}") diff --git a/app/builders/messages/instagram/messenger/message_builder.rb b/app/builders/messages/instagram/messenger/message_builder.rb index 1263dee90..41ba59f2c 100644 --- a/app/builders/messages/instagram/messenger/message_builder.rb +++ b/app/builders/messages/instagram/messenger/message_builder.rb @@ -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 diff --git a/app/builders/messages/message_builder.rb b/app/builders/messages/message_builder.rb index 7df72e14a..91d4df118 100644 --- a/app/builders/messages/message_builder.rb +++ b/app/builders/messages/message_builder.rb @@ -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? diff --git a/app/controllers/api/v1/accounts/callbacks_controller.rb b/app/controllers/api/v1/accounts/callbacks_controller.rb index 90cdf2418..69a7f31da 100644 --- a/app/controllers/api/v1/accounts/callbacks_controller.rb +++ b/app/controllers/api/v1/accounts/callbacks_controller.rb @@ -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 diff --git a/app/controllers/api/v1/accounts/contacts/group_admin_controller.rb b/app/controllers/api/v1/accounts/contacts/group_admin_controller.rb new file mode 100644 index 000000000..79a5837c9 --- /dev/null +++ b/app/controllers/api/v1/accounts/contacts/group_admin_controller.rb @@ -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 diff --git a/app/controllers/api/v1/accounts/contacts/group_invites_controller.rb b/app/controllers/api/v1/accounts/contacts/group_invites_controller.rb new file mode 100644 index 000000000..9d9c18cca --- /dev/null +++ b/app/controllers/api/v1/accounts/contacts/group_invites_controller.rb @@ -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 diff --git a/app/controllers/api/v1/accounts/contacts/group_join_requests_controller.rb b/app/controllers/api/v1/accounts/contacts/group_join_requests_controller.rb new file mode 100644 index 000000000..db1caabe6 --- /dev/null +++ b/app/controllers/api/v1/accounts/contacts/group_join_requests_controller.rb @@ -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 diff --git a/app/controllers/api/v1/accounts/contacts/group_members_controller.rb b/app/controllers/api/v1/accounts/contacts/group_members_controller.rb new file mode 100644 index 000000000..8ce6ef3d2 --- /dev/null +++ b/app/controllers/api/v1/accounts/contacts/group_members_controller.rb @@ -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 diff --git a/app/controllers/api/v1/accounts/contacts/group_metadata_controller.rb b/app/controllers/api/v1/accounts/contacts/group_metadata_controller.rb new file mode 100644 index 000000000..889236ace --- /dev/null +++ b/app/controllers/api/v1/accounts/contacts/group_metadata_controller.rb @@ -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 diff --git a/app/controllers/api/v1/accounts/contacts_controller.rb b/app/controllers/api/v1/accounts/contacts_controller.rb index 14d4f2c89..2670bc0ba 100644 --- a/app/controllers/api/v1/accounts/contacts_controller.rb +++ b/app/controllers/api/v1/accounts/contacts_controller.rb @@ -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)) diff --git a/app/controllers/api/v1/accounts/conversations/attachments_controller.rb b/app/controllers/api/v1/accounts/conversations/attachments_controller.rb new file mode 100644 index 000000000..0c2dc9977 --- /dev/null +++ b/app/controllers/api/v1/accounts/conversations/attachments_controller.rb @@ -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 diff --git a/app/controllers/api/v1/accounts/conversations/messages_controller.rb b/app/controllers/api/v1/accounts/conversations/messages_controller.rb index 67381a715..1675e4b43 100644 --- a/app/controllers/api/v1/accounts/conversations/messages_controller.rb +++ b/app/controllers/api/v1/accounts/conversations/messages_controller.rb @@ -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 diff --git a/app/controllers/api/v1/accounts/conversations/recurring_scheduled_messages_controller.rb b/app/controllers/api/v1/accounts/conversations/recurring_scheduled_messages_controller.rb new file mode 100644 index 000000000..1d325361c --- /dev/null +++ b/app/controllers/api/v1/accounts/conversations/recurring_scheduled_messages_controller.rb @@ -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' +) diff --git a/app/controllers/api/v1/accounts/conversations/scheduled_messages_controller.rb b/app/controllers/api/v1/accounts/conversations/scheduled_messages_controller.rb new file mode 100644 index 000000000..9ae9ac6bc --- /dev/null +++ b/app/controllers/api/v1/accounts/conversations/scheduled_messages_controller.rb @@ -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' +) diff --git a/app/controllers/api/v1/accounts/conversations_controller.rb b/app/controllers/api/v1/accounts/conversations_controller.rb index ab1cae17d..62c1541e4 100644 --- a/app/controllers/api/v1/accounts/conversations_controller.rb +++ b/app/controllers/api/v1/accounts/conversations_controller.rb @@ -1,4 +1,4 @@ -class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseController +class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseController # rubocop:disable Metrics/ClassLength include Events::Types include DateRangeHelper include HmacConcern @@ -122,10 +122,14 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro # No unread messages - apply throttling to limit DB writes return unless should_update_last_seen? + dispatch_messages_read_event if assignee? + update_last_seen_on_conversation(DateTime.now.utc, assignee?) end def unread + Rails.configuration.dispatcher.dispatch(Events::Types::CONVERSATION_UNREAD, Time.zone.now, conversation: @conversation) + last_incoming_message = @conversation.messages.incoming.last last_seen_at = last_incoming_message.created_at - 1.second if last_incoming_message.present? update_last_seen_on_conversation(last_seen_at, true) @@ -162,7 +166,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 @@ -231,6 +243,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') diff --git a/app/controllers/api/v1/accounts/dashboard_apps_controller.rb b/app/controllers/api/v1/accounts/dashboard_apps_controller.rb index a8d7ebcb9..fce80036b 100644 --- a/app/controllers/api/v1/accounts/dashboard_apps_controller.rb +++ b/app/controllers/api/v1/accounts/dashboard_apps_controller.rb @@ -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 diff --git a/app/controllers/api/v1/accounts/groups_controller.rb b/app/controllers/api/v1/accounts/groups_controller.rb new file mode 100644 index 000000000..edad93c31 --- /dev/null +++ b/app/controllers/api/v1/accounts/groups_controller.rb @@ -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 diff --git a/app/controllers/api/v1/accounts/inbox_csat_templates_controller.rb b/app/controllers/api/v1/accounts/inbox_csat_templates_controller.rb index d7244616d..051817cf3 100644 --- a/app/controllers/api/v1/accounts/inbox_csat_templates_controller.rb +++ b/app/controllers/api/v1/accounts/inbox_csat_templates_controller.rb @@ -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) diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb index 322c7c7fe..265debd2f 100644 --- a/app/controllers/api/v1/accounts/inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/inboxes_controller.rb @@ -1,10 +1,10 @@ -class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController +class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController # rubocop:disable Metrics/ClassLength include Api::V1::InboxesHelper before_action :fetch_inbox, except: [:index, :create] before_action :fetch_agent_bot, only: [:set_agent_bot] before_action :validate_limit, only: [:create] # we are already handling the authorization in fetch inbox - before_action :check_authorization, except: [:show, :health] + before_action :check_authorization, except: [:show, :health, :setup_channel_provider] before_action :validate_whatsapp_cloud_channel, only: [:health] def index @@ -65,6 +65,30 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController head :ok end + def setup_channel_provider + channel = @inbox.channel + + unless channel.respond_to?(:setup_channel_provider) + render json: { error: 'Channel does not support setup' }, status: :unprocessable_entity and return + end + + channel.setup_channel_provider + head :ok + end + + def disconnect_channel_provider + channel = @inbox.channel + + unless channel.respond_to?(:disconnect_channel_provider) + render json: { error: 'Channel does not support disconnect' }, status: :unprocessable_entity and return + end + + channel.disconnect_channel_provider + head :ok + ensure + channel.update_provider_connection!(connection: 'close') if channel.respond_to?(:update_provider_connection!) + end + def destroy ::DeleteObjectJob.perform_later(@inbox, Current.user, request.ip) if @inbox.present? render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') } @@ -87,6 +111,20 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController render json: { error: e.message }, status: :unprocessable_entity end + def on_whatsapp + params.require(:phone_number) + phone_number = params[:phone_number] + channel = @inbox.channel + + unless channel.respond_to?(:on_whatsapp) + render json: { error: 'Channel does not support whatsapp check' }, status: :unprocessable_entity and return + end + + response = channel.on_whatsapp(phone_number) + + render json: response, status: :ok + end + private def fetch_inbox @@ -176,7 +214,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 = []) diff --git a/app/controllers/api/v1/accounts/integrations/slack_controller.rb b/app/controllers/api/v1/accounts/integrations/slack_controller.rb index eaa18c2de..eadd46e4a 100644 --- a/app/controllers/api/v1/accounts/integrations/slack_controller.rb +++ b/app/controllers/api/v1/accounts/integrations/slack_controller.rb @@ -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 diff --git a/app/controllers/api/v1/accounts/notifications_controller.rb b/app/controllers/api/v1/accounts/notifications_controller.rb index 52035ce64..874fb67a4 100644 --- a/app/controllers/api/v1/accounts/notifications_controller.rb +++ b/app/controllers/api/v1/accounts/notifications_controller.rb @@ -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 diff --git a/app/controllers/api/v1/accounts/portals_controller.rb b/app/controllers/api/v1/accounts/portals_controller.rb index 8eb24b757..8800d09ff 100644 --- a/app/controllers/api/v1/accounts/portals_controller.rb +++ b/app/controllers/api/v1/accounts/portals_controller.rb @@ -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: [] }] } + :name, :page_title, :slug, :archived, :custom_head_html, :custom_body_html, + { config: [:default_locale, :show_author, { allowed_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) diff --git a/app/controllers/api/v1/accounts/webhooks_controller.rb b/app/controllers/api/v1/accounts/webhooks_controller.rb index 9f8e94821..a32f1aa3c 100644 --- a/app/controllers/api/v1/accounts/webhooks_controller.rb +++ b/app/controllers/api/v1/accounts/webhooks_controller.rb @@ -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 diff --git a/app/controllers/api/v1/profile/inbox_signatures_controller.rb b/app/controllers/api/v1/profile/inbox_signatures_controller.rb new file mode 100644 index 000000000..e375184f6 --- /dev/null +++ b/app/controllers/api/v1/profile/inbox_signatures_controller.rb @@ -0,0 +1,63 @@ +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_id = params[:inbox_id] + 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 diff --git a/app/controllers/api/v1/profiles_controller.rb b/app/controllers/api/v1/profiles_controller.rb index 141253d0d..a99f6ced6 100644 --- a/app/controllers/api/v1/profiles_controller.rb +++ b/app/controllers/api/v1/profiles_controller.rb @@ -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 diff --git a/app/controllers/api/v1/widget/contacts_controller.rb b/app/controllers/api/v1/widget/contacts_controller.rb index 5138fe675..32d89f450 100644 --- a/app/controllers/api/v1/widget/contacts_controller.rb +++ b/app/controllers/api/v1/widget/contacts_controller.rb @@ -19,7 +19,7 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController contact = @contact end - @contact_inbox.update(hmac_verified: true) if should_verify_hmac? && valid_hmac? + @contact_inbox.update!(hmac_verified: true) if should_verify_hmac? && valid_hmac? identify_contact(contact) end diff --git a/app/controllers/api/v1/widget/conversations_controller.rb b/app/controllers/api/v1/widget/conversations_controller.rb index 00e718614..276d92c74 100644 --- a/app/controllers/api/v1/widget/conversations_controller.rb +++ b/app/controllers/api/v1/widget/conversations_controller.rb @@ -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 diff --git a/app/controllers/api/v2/accounts/year_in_reviews_controller.rb b/app/controllers/api/v2/accounts/year_in_reviews_controller.rb index 7946614bb..d17b5dc13 100644 --- a/app/controllers/api/v2/accounts/year_in_reviews_controller.rb +++ b/app/controllers/api/v2/accounts/year_in_reviews_controller.rb @@ -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 diff --git a/app/controllers/api_controller.rb b/app/controllers/api_controller.rb index eb6f776f1..769bb1b18 100644 --- a/app/controllers/api_controller.rb +++ b/app/controllers/api_controller.rb @@ -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 diff --git a/app/controllers/concerns/attachment_concern.rb b/app/controllers/concerns/attachment_concern.rb index 2652f04be..6eae2d6f5 100644 --- a/app/controllers/concerns/attachment_concern.rb +++ b/app/controllers/concerns/attachment_concern.rb @@ -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 diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index d57ad0e53..5b18d3030 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -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 diff --git a/app/controllers/health_controller.rb b/app/controllers/health_controller.rb index fdf969a39..6f948cb57 100644 --- a/app/controllers/health_controller.rb +++ b/app/controllers/health_controller.rb @@ -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 diff --git a/app/controllers/platform/api/v1/accounts_controller.rb b/app/controllers/platform/api/v1/accounts_controller.rb index 4521930a6..903e09832 100644 --- a/app/controllers/platform/api/v1/accounts_controller.rb +++ b/app/controllers/platform/api/v1/accounts_controller.rb @@ -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 diff --git a/app/controllers/platform/api/v1/agent_bots_controller.rb b/app/controllers/platform/api/v1/agent_bots_controller.rb index dd70a1ba5..a3ed639fd 100644 --- a/app/controllers/platform/api/v1/agent_bots_controller.rb +++ b/app/controllers/platform/api/v1/agent_bots_controller.rb @@ -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 diff --git a/app/controllers/public/api/v1/inboxes/contacts_controller.rb b/app/controllers/public/api/v1/inboxes/contacts_controller.rb index 835c2596b..246c95d25 100644 --- a/app/controllers/public/api/v1/inboxes/contacts_controller.rb +++ b/app/controllers/public/api/v1/inboxes/contacts_controller.rb @@ -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? diff --git a/app/controllers/public/api/v1/inboxes/conversations_controller.rb b/app/controllers/public/api/v1/inboxes/conversations_controller.rb index 242dcde77..ec8475fc5 100644 --- a/app/controllers/public/api/v1/inboxes/conversations_controller.rb +++ b/app/controllers/public/api/v1/inboxes/conversations_controller.rb @@ -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 diff --git a/app/controllers/super_admin/account_users_controller.rb b/app/controllers/super_admin/account_users_controller.rb index d665b5684..04092b2d9 100644 --- a/app/controllers/super_admin/account_users_controller.rb +++ b/app/controllers/super_admin/account_users_controller.rb @@ -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 diff --git a/app/controllers/super_admin/app_configs_controller.rb b/app/controllers/super_admin/app_configs_controller.rb index 67d58aef1..b48cf5cd9 100644 --- a/app/controllers/super_admin/app_configs_controller.rb +++ b/app/controllers/super_admin/app_configs_controller.rb @@ -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 diff --git a/app/controllers/super_admin/instance_statuses_controller.rb b/app/controllers/super_admin/instance_statuses_controller.rb index b0e97b95d..15a930c3e 100644 --- a/app/controllers/super_admin/instance_statuses_controller.rb +++ b/app/controllers/super_admin/instance_statuses_controller.rb @@ -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 diff --git a/app/controllers/webhooks/whatsapp_controller.rb b/app/controllers/webhooks/whatsapp_controller.rb index c4c376e5c..088677556 100644 --- a/app/controllers/webhooks/whatsapp_controller.rb +++ b/app/controllers/webhooks/whatsapp_controller.rb @@ -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]) diff --git a/app/dispatchers/async_dispatcher.rb b/app/dispatchers/async_dispatcher.rb index 7416b7861..f46928d4e 100644 --- a/app/dispatchers/async_dispatcher.rb +++ b/app/dispatchers/async_dispatcher.rb @@ -18,7 +18,8 @@ class AsyncDispatcher < BaseDispatcher NotificationListener.instance, ParticipationListener.instance, ReportingEventListener.instance, - WebhookListener.instance + WebhookListener.instance, + ChannelListener.instance ] end end diff --git a/app/drops/conversation_drop.rb b/app/drops/conversation_drop.rb index d62642885..b8f8a9e06 100644 --- a/app/drops/conversation_drop.rb +++ b/app/drops/conversation_drop.rb @@ -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) diff --git a/app/finders/conversation_finder.rb b/app/finders/conversation_finder.rb index 7821bee49..f5e8849c8 100644 --- a/app/finders/conversation_finder.rb +++ b/app/finders/conversation_finder.rb @@ -80,6 +80,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 @@ -134,6 +135,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' diff --git a/app/helpers/baileys_helper.rb b/app/helpers/baileys_helper.rb new file mode 100644 index 000000000..61fcf08b1 --- /dev/null +++ b/app/helpers/baileys_helper.rb @@ -0,0 +1,52 @@ +module BaileysHelper + CHANNEL_LOCK_ON_OUTGOING_MESSAGE_KEY = 'BAILEYS::CHANNEL_LOCK_ON_OUTGOING_MESSAGE::%s'.freeze + CHANNEL_LOCK_ON_OUTGOING_MESSAGE_TIMEOUT = 60.seconds + + def baileys_extract_message_timestamp(timestamp) + # NOTE: Timestamp might be in this format {"low"=>1748003165, "high"=>0, "unsigned"=>true} + if timestamp.is_a?(Hash) && timestamp.key?('low') + low = timestamp['low'].to_i + high = timestamp.fetch('high', 0).to_i + return (high << 32) | low + end + + # NOTE: Timestamp might be a string or a number + timestamp.to_i + end + + def with_baileys_channel_lock_on_outgoing_message(channel_id, timeout: CHANNEL_LOCK_ON_OUTGOING_MESSAGE_TIMEOUT) + raise ArgumentError, 'A block is required for with_baileys_channel_lock_on_outgoing_message' unless block_given? + + start_time = Time.now.to_i + lock_acquired = false + + # NOTE: On timeout, we log a warning and proceed with the block execution. + # The re-check inside the contact lock handles potential duplicates. + while (Time.now.to_i - start_time) < timeout + if baileys_lock_channel_on_outgoing_message(channel_id, timeout) + lock_acquired = true + break + end + + sleep(0.1) + end + + Rails.logger.warn "Baileys channel lock timeout for channel #{channel_id} after #{timeout}s - proceeding anyway" unless lock_acquired + + yield + ensure + baileys_clear_channel_lock_on_outgoing_message(channel_id) + end + + private + + def baileys_lock_channel_on_outgoing_message(channel_id, timeout) + key = format(CHANNEL_LOCK_ON_OUTGOING_MESSAGE_KEY, channel_id: channel_id) + Redis::Alfred.set(key, 1, nx: true, ex: timeout) + end + + def baileys_clear_channel_lock_on_outgoing_message(channel_id) + key = format(CHANNEL_LOCK_ON_OUTGOING_MESSAGE_KEY, channel_id: channel_id) + Redis::Alfred.delete(key) + end +end diff --git a/app/helpers/cache_keys_helper.rb b/app/helpers/cache_keys_helper.rb index aab33e44c..366c87383 100644 --- a/app/helpers/cache_keys_helper.rb +++ b/app/helpers/cache_keys_helper.rb @@ -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 diff --git a/app/helpers/filters/filter_helper.rb b/app/helpers/filters/filter_helper.rb index fe03dae28..2bf492915 100644 --- a/app/helpers/filters/filter_helper.rb +++ b/app/helpers/filters/filter_helper.rb @@ -100,6 +100,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 diff --git a/app/helpers/frontend_urls_helper.rb b/app/helpers/frontend_urls_helper.rb index 1867c77ea..967483c7b 100644 --- a/app/helpers/frontend_urls_helper.rb +++ b/app/helpers/frontend_urls_helper.rb @@ -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 diff --git a/app/helpers/reporting_event_helper.rb b/app/helpers/reporting_event_helper.rb index f0a419cc8..7da3ed5b3 100644 --- a/app/helpers/reporting_event_helper.rb +++ b/app/helpers/reporting_event_helper.rb @@ -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 diff --git a/app/helpers/timezone_helper.rb b/app/helpers/timezone_helper.rb index b016cc9d9..7b88ceae6 100644 --- a/app/helpers/timezone_helper.rb +++ b/app/helpers/timezone_helper.rb @@ -14,6 +14,6 @@ module TimezoneHelper zone.now.utc_offset == offset_in_seconds end - return matching_zone.name if matching_zone + matching_zone&.name end end diff --git a/app/javascript/dashboard/api/groupMembers.js b/app/javascript/dashboard/api/groupMembers.js new file mode 100644 index 000000000..224e95eb1 --- /dev/null +++ b/app/javascript/dashboard/api/groupMembers.js @@ -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(); diff --git a/app/javascript/dashboard/api/inbox/conversation.js b/app/javascript/dashboard/api/inbox/conversation.js index f94fca452..ea2802cae 100644 --- a/app/javascript/dashboard/api/inbox/conversation.js +++ b/app/javascript/dashboard/api/inbox/conversation.js @@ -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, }, }); } diff --git a/app/javascript/dashboard/api/inbox/message.js b/app/javascript/dashboard/api/inbox/message.js index 8f294a0ee..99adc0302 100644 --- a/app/javascript/dashboard/api/inbox/message.js +++ b/app/javascript/dashboard/api/inbox/message.js @@ -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` diff --git a/app/javascript/dashboard/api/inboxSignatures.js b/app/javascript/dashboard/api/inboxSignatures.js new file mode 100644 index 000000000..6f915b1e7 --- /dev/null +++ b/app/javascript/dashboard/api/inboxSignatures.js @@ -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}`); + }, +}; diff --git a/app/javascript/dashboard/api/inboxes.js b/app/javascript/dashboard/api/inboxes.js index 079f21815..aeaea41ab 100644 --- a/app/javascript/dashboard/api/inboxes.js +++ b/app/javascript/dashboard/api/inboxes.js @@ -48,6 +48,26 @@ class Inboxes extends CacheEnabledApiClient { template, }); } + + 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(); diff --git a/app/javascript/dashboard/api/recurringScheduledMessages.js b/app/javascript/dashboard/api/recurringScheduledMessages.js new file mode 100644 index 000000000..c0af4b82d --- /dev/null +++ b/app/javascript/dashboard/api/recurringScheduledMessages.js @@ -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(); diff --git a/app/javascript/dashboard/api/scheduledMessages.js b/app/javascript/dashboard/api/scheduledMessages.js new file mode 100644 index 000000000..71465b51c --- /dev/null +++ b/app/javascript/dashboard/api/scheduledMessages.js @@ -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(); diff --git a/app/javascript/dashboard/api/specs/scheduledMessages.spec.js b/app/javascript/dashboard/api/specs/scheduledMessages.spec.js new file mode 100644 index 000000000..6358b478f --- /dev/null +++ b/app/javascript/dashboard/api/specs/scheduledMessages.spec.js @@ -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' + ); + }); + }); +}); diff --git a/app/javascript/dashboard/assets/images/curved-arrow.svg b/app/javascript/dashboard/assets/images/curved-arrow.svg new file mode 100644 index 000000000..f021b57ba --- /dev/null +++ b/app/javascript/dashboard/assets/images/curved-arrow.svg @@ -0,0 +1,9 @@ + + + + diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/components/ScheduledMessageItem.vue b/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/components/ScheduledMessageItem.vue new file mode 100644 index 000000000..f9ce8680e --- /dev/null +++ b/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/components/ScheduledMessageItem.vue @@ -0,0 +1,526 @@ + + + diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalBaseSettings.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalBaseSettings.vue index b99f08a29..f396baaed 100644 --- a/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalBaseSettings.vue +++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalBaseSettings.vue @@ -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 = () => { +
+ +
+ + + {{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.SHOW_AUTHOR.HELP_TEXT') }} + +
+
+
+ +
+