diff --git a/.env.example b/.env.example index de671599c..7077d379d 100644 --- a/.env.example +++ b/.env.example @@ -266,3 +266,10 @@ AZURE_APP_SECRET= # Set to true if you want to remove stale contact inboxes # contact_inboxes with no conversation older than 90 days will be removed # REMOVE_STALE_CONTACT_INBOX_JOB_STATUS=false + +# Baileys API Whatsapp provider +BAILEYS_PROVIDER_DEFAULT_CLIENT_NAME=Chatwoot +BAILEYS_PROVIDER_DEFAULT_URL=http://localhost:3025 +BAILEYS_PROVIDER_DEFAULT_API_KEY= + +RESEND_API_KEY= diff --git a/.github/workflows/frontend-fe.yml b/.github/workflows/frontend-fe.yml index 45ff25203..38d70a737 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..3d390333f --- /dev/null +++ b/.github/workflows/publish_ee_github_docker.yml @@ -0,0 +1,137 @@ +name: Publish Chatwoot Enterprise docker images to GitHub + +permissions: + contents: read + packages: write + +on: + push: + tags: + - '*' + workflow_dispatch: + +env: + GITHUB_REPO: ghcr.io/${{ github.repository }} + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + - platform: linux/arm64 + runner: ubuntu-22.04-arm + runs-on: ${{ matrix.runner }} + env: + GIT_REF: ${{ github.head_ref || github.ref_name || github.sha }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Prepare + run: | + platform=${{ matrix.platform }} + echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV + + - name: Set Chatwoot edition + run: | + echo -en '\nENV CW_EDITION="ee"' >> docker/Dockerfile + + - name: Update version in app.yml + run: | + if [[ "$GIT_REF" =~ ^v(.+)$ ]]; then + VERSION="${BASH_REMATCH[1]}" + echo "Updating version to: $VERSION" + sed -i "s/version: '.*'/version: '$VERSION'/" config/app.yml + else + echo "No version tag found, keeping existing version" + fi + + - name: Set Docker Tags + run: | + SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g') + echo "SANITIZED_REF=${SANITIZED_REF}" >> $GITHUB_ENV + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GitHub Container Registry + if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push to GitHub Container Registry + id: build-ghcr + uses: docker/build-push-action@v6 + with: + context: . + file: docker/Dockerfile + platforms: ${{ matrix.platform }} + push: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }} + tags: | + ${{ env.GITHUB_REPO }}:${{ env.SANITIZED_REF }}-ee + ${{ env.GITHUB_REPO }}:latest-ee + + - name: Export digest + run: | + mkdir -p ${{ runner.temp }}/digests + digest="${{ steps.build-ghcr.outputs.digest }}" + touch "${{ runner.temp }}/digests/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@v4 + with: + name: digests-${{ env.PLATFORM_PAIR }} + path: ${{ runner.temp }}/digests/* + if-no-files-found: error + retention-days: 1 + + merge: + runs-on: ubuntu-latest + needs: + - build + steps: + - name: Download digests + uses: actions/download-artifact@v4 + with: + path: ${{ runner.temp }}/digests + pattern: digests-* + merge-multiple: true + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Create manifest list and push + working-directory: ${{ runner.temp }}/digests + env: + GIT_REF: ${{ github.head_ref || github.ref_name || github.sha }} + run: | + SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g') + docker buildx imagetools create \ + -t ghcr.io/${{ github.repository }}:${SANITIZED_REF}-ee \ + -t ghcr.io/${{ github.repository }}:latest-ee \ + $(printf 'ghcr.io/${{ github.repository }}@sha256:%s ' *) + + - name: Inspect image + env: + GIT_REF: ${{ github.head_ref || github.ref_name || github.sha }} + run: | + SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g') + REPO="ghcr.io/${{ github.repository }}" + docker buildx imagetools inspect ${REPO}:${SANITIZED_REF}-ee + docker buildx imagetools inspect ${REPO}:latest-ee diff --git a/.github/workflows/publish_github_docker.yml b/.github/workflows/publish_github_docker.yml new file mode 100644 index 000000000..60ebab774 --- /dev/null +++ b/.github/workflows/publish_github_docker.yml @@ -0,0 +1,138 @@ +name: Publish Chatwoot docker images to GitHub + +permissions: + contents: read + packages: write + +on: + push: + tags: + - '*' + workflow_dispatch: + +env: + GITHUB_REPO: ghcr.io/${{ github.repository }} + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + - platform: linux/arm64 + runner: ubuntu-22.04-arm + runs-on: ${{ matrix.runner }} + env: + GIT_REF: ${{ github.head_ref || github.ref_name }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Prepare + run: | + platform=${{ matrix.platform }} + echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV + + - name: Strip enterprise code + run: | + rm -rf enterprise + rm -rf spec/enterprise + + - name: Update version in app.yml + run: | + if [[ "$GIT_REF" =~ ^v(.+)$ ]]; then + VERSION="${BASH_REMATCH[1]}" + echo "Updating version to: $VERSION" + sed -i "s/version: '.*'/version: '$VERSION'/" config/app.yml + else + echo "No version tag found, keeping existing version" + fi + + - name: Set Docker Tags + run: | + SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g') + echo "SANITIZED_REF=${SANITIZED_REF}" >> $GITHUB_ENV + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GitHub Container Registry + if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push to GitHub Container Registry + id: build-ghcr + uses: docker/build-push-action@v6 + with: + context: . + file: docker/Dockerfile + platforms: ${{ matrix.platform }} + push: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }} + tags: | + ${{ env.GITHUB_REPO }}:${{ env.SANITIZED_REF }} + ${{ env.GITHUB_REPO }}:latest + + - name: Export digest + run: | + mkdir -p ${{ runner.temp }}/digests + digest="${{ steps.build-ghcr.outputs.digest }}" + touch "${{ runner.temp }}/digests/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@v4 + with: + name: digests-${{ env.PLATFORM_PAIR }} + path: ${{ runner.temp }}/digests/* + if-no-files-found: error + retention-days: 1 + + merge: + runs-on: ubuntu-latest + needs: + - build + steps: + - name: Download digests + uses: actions/download-artifact@v4 + with: + path: ${{ runner.temp }}/digests + pattern: digests-* + merge-multiple: true + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Create manifest list and push + working-directory: ${{ runner.temp }}/digests + env: + GIT_REF: ${{ github.head_ref || github.ref_name }} + run: | + SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g') + docker buildx imagetools create \ + -t ghcr.io/${{ github.repository }}:${SANITIZED_REF} \ + -t ghcr.io/${{ github.repository }}:latest \ + $(printf 'ghcr.io/${{ github.repository }}@sha256:%s ' *) + + - name: Inspect image + env: + GIT_REF: ${{ github.head_ref || github.ref_name }} + run: | + SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g') + REPO="ghcr.io/${{ github.repository }}" + docker buildx imagetools inspect ${REPO}:${SANITIZED_REF} + docker buildx imagetools inspect ${REPO}:latest diff --git a/.github/workflows/run_foss_spec.yml b/.github/workflows/run_foss_spec.yml index 385feddfc..c18071c76 100644 --- a/.github/workflows/run_foss_spec.yml +++ b/.github/workflows/run_foss_spec.yml @@ -1,10 +1,9 @@ name: Run Chatwoot CE spec + 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/.nvmrc b/.nvmrc index 6f7af3750..b88575e38 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -20.5.1 \ No newline at end of file +23.7.0 \ No newline at end of file diff --git a/.rubocop.yml b/.rubocop.yml index e30a71ee9..a97d28d32 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -207,6 +207,7 @@ CustomCopLocation: AllCops: NewCops: enable + SuggestExtensions: false Exclude: - 'bin/**/*' - 'db/schema.rb' @@ -336,4 +337,11 @@ FactoryBot/RedundantFactoryOption: Enabled: false FactoryBot/FactoryAssociationWithStrategy: - Enabled: false \ No newline at end of file + Enabled: false + +Rails/SaveBang: + Enabled: true + AllowedReceivers: + - Stripe::Subscription + - Stripe::Customer + - 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 18442e3b0..a4c543fd8 100644 --- a/Gemfile +++ b/Gemfile @@ -42,7 +42,7 @@ gem 'down' # authentication type to fetch and send mail over oauth2.0 gem 'gmail_xoauth' # Lock net-smtp to 0.3.4 to avoid issues with gmail_xoauth2 -gem 'net-smtp', '~> 0.3.4' +gem 'net-smtp', '~> 0.3.4' # Prevent CSV injection gem 'csv-safe' @@ -194,6 +194,8 @@ gem 'ruby_llm-schema' gem 'shopify_api' +gem 'resend', '~> 0.19.0' + ### Gems required only in specific deployment environments ### ############################################################## diff --git a/Gemfile.lock b/Gemfile.lock index 80a78c6b6..e1a08fef8 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -716,6 +716,8 @@ GEM uber (< 0.2.0) request_store (1.5.1) rack (>= 1.4) + resend (0.19.0) + httparty (>= 0.21.0) responders (3.1.1) actionpack (>= 5.2) railties (>= 5.2) @@ -1077,6 +1079,7 @@ DEPENDENCIES rails (~> 7.1) redis redis-namespace + resend (~> 0.19.0) responders (>= 3.1.1) rest-client reverse_markdown 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/messages/instagram/base_message_builder.rb b/app/builders/messages/instagram/base_message_builder.rb index 8b40ba3c9..878c31540 100644 --- a/app/builders/messages/instagram/base_message_builder.rb +++ b/app/builders/messages/instagram/base_message_builder.rb @@ -162,7 +162,7 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil end def all_unsupported_files? - return if attachments.empty? + return false if attachments.empty? attachments_type = attachments.pluck(:type).uniq.first unsupported_file_type?(attachments_type) 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 12a74ed9c..297bfb419 100644 --- a/app/builders/messages/message_builder.rb +++ b/app/builders/messages/message_builder.rb @@ -1,8 +1,8 @@ -class Messages::MessageBuilder +class Messages::MessageBuilder # rubocop:disable Metrics/ClassLength include ::FileTypeHelper attr_reader :message - def initialize(user, conversation, params) + def initialize(user, conversation, params) # rubocop:disable Metrics/CyclomaticComplexity @params = params @private = params[:private] || false @conversation = conversation @@ -10,10 +10,12 @@ class Messages::MessageBuilder @account = conversation.account @message_type = params[:message_type] || 'outgoing' @attachments = params[:attachments] + @is_recorded_audio = params[:is_recorded_audio] @automation_rule = content_attributes&.dig(:automation_rule_id) return unless params.instance_of?(ActionController::Parameters) @in_reply_to = content_attributes&.dig(:in_reply_to) + @is_reaction = content_attributes&.dig(:is_reaction) @items = content_attributes&.dig(:items) end @@ -70,7 +72,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 @@ -81,6 +83,22 @@ class Messages::MessageBuilder end end + def process_metadata(attachment) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity + # NOTE: `is_recorded_audio` can be either a boolean or an array of file names. + return unless @is_recorded_audio + return { is_recorded_audio: true } if @is_recorded_audio == true + + return { is_recorded_audio: true } if @is_recorded_audio.is_a?(Array) && attachment.original_filename.in?(@is_recorded_audio) + + # FIXME: Remove backwards compatibility with old format. + if @is_recorded_audio.is_a?(String) + parsed = JSON.parse(@is_recorded_audio) + { is_recorded_audio: true } if parsed.is_a?(Array) && attachment.original_filename.in?(parsed) + end + rescue JSON::ParserError + nil + end + def process_emails return unless @conversation.inbox&.inbox_type == 'Email' @@ -161,6 +179,7 @@ class Messages::MessageBuilder content_type: @params[:content_type], items: @items, in_reply_to: @in_reply_to, + is_reaction: @is_reaction, echo_id: @params[:echo_id], source_id: @params[:source_id] }.merge(external_created_at).merge(automation_rule_id).merge(campaign_id).merge(template_params) diff --git a/app/builders/messages/messenger/message_builder.rb b/app/builders/messages/messenger/message_builder.rb index 4e7f2849d..9449ef084 100644 --- a/app/builders/messages/messenger/message_builder.rb +++ b/app/builders/messages/messenger/message_builder.rb @@ -68,7 +68,6 @@ class Messages::Messenger::MessageBuilder message.save! end - # This is a placeholder method to be overridden by child classes def get_story_object_from_source_id(_source_id) {} end diff --git a/app/controllers/api/v1/accounts/automation_rules_controller.rb b/app/controllers/api/v1/accounts/automation_rules_controller.rb index 3d894808d..05d3cb1ab 100644 --- a/app/controllers/api/v1/accounts/automation_rules_controller.rb +++ b/app/controllers/api/v1/accounts/automation_rules_controller.rb @@ -24,7 +24,6 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont ActiveRecord::Base.transaction do automation_rule_update process_attachments - rescue StandardError => e Rails.logger.error e render json: { error: @automation_rule.errors.messages }.to_json, status: :unprocessable_entity 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/conversations_controller.rb b/app/controllers/api/v1/accounts/conversations_controller.rb index e27869d82..146ecdede 100644 --- a/app/controllers/api/v1/accounts/conversations_controller.rb +++ b/app/controllers/api/v1/accounts/conversations_controller.rb @@ -110,10 +110,14 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro end def update_last_seen + dispatch_messages_read_event if assignee? + update_last_seen_on_conversation(DateTime.now.utc, assignee?) end def unread + Rails.configuration.dispatcher.dispatch(Events::Types::CONVERSATION_UNREAD, Time.zone.now, conversation: @conversation) + last_incoming_message = @conversation.messages.incoming.last last_seen_at = last_incoming_message.created_at - 1.second if last_incoming_message.present? update_last_seen_on_conversation(last_seen_at, true) @@ -206,6 +210,12 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro def assignee? @conversation.assignee_id? && Current.user == @conversation.assignee end + + def dispatch_messages_read_event + # NOTE: Use old `agent_last_seen_at`, so we reference messages received after that + Rails.configuration.dispatcher.dispatch(Events::Types::MESSAGES_READ, Time.zone.now, conversation: @conversation, + last_seen_at: @conversation.agent_last_seen_at) + end end Api::V1::Accounts::ConversationsController.prepend_mod_with('Api::V1::Accounts::ConversationsController') diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb index ae1d4369a..cfbac1e73 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 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 57344cc1e..276f2fce5 100644 --- a/app/controllers/api/v1/accounts/portals_controller.rb +++ b/app/controllers/api/v1/accounts/portals_controller.rb @@ -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 diff --git a/app/controllers/api/v1/accounts/webhooks_controller.rb b/app/controllers/api/v1/accounts/webhooks_controller.rb index 7ea257ed2..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,8 +22,12 @@ class Api::V1::Accounts::WebhooksController < Api::V1::Accounts::BaseController private - def webhook_params - params.require(:webhook).permit(:inbox_id, :url, subscriptions: []) + def webhook_create_params + params.require(:webhook).permit(:inbox_id, :name, :url, subscriptions: []) + end + + def webhook_update_params + params.require(:webhook).permit(:name, subscriptions: []) end def fetch_webhook 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 fe5facc1a..62a7dafd9 100644 --- a/app/controllers/api/v1/widget/conversations_controller.rb +++ b/app/controllers/api/v1/widget/conversations_controller.rb @@ -48,6 +48,8 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController case permitted_params[:typing_status] when 'on' trigger_typing_event(CONVERSATION_TYPING_ON) + when 'recording' + trigger_typing_event(CONVERSATION_RECORDING) when 'off' trigger_typing_event(CONVERSATION_TYPING_OFF) end @@ -82,7 +84,7 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController end def render_not_found_if_empty - return head :not_found if conversation.nil? + head :not_found if conversation.nil? end def permitted_params 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/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 5cf158b98..b90de8779 100644 --- a/app/controllers/super_admin/app_configs_controller.rb +++ b/app/controllers/super_admin/app_configs_controller.rb @@ -18,7 +18,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController params['app_config'].each do |key, value| next unless @allowed_configs.include?(key) - i = InstallationConfig.where(name: key).first_or_create(value: value, locked: false) + i = InstallationConfig.where(name: key).first_or_create!(value: value, locked: false) i.value = value i.save! end 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/helpers/baileys_helper.rb b/app/helpers/baileys_helper.rb new file mode 100644 index 000000000..009b048d8 --- /dev/null +++ b/app/helpers/baileys_helper.rb @@ -0,0 +1,45 @@ +module BaileysHelper + CHANNEL_LOCK_ON_OUTGOING_MESSAGE_KEY = 'BAILEYS::CHANNEL_LOCK_ON_OUTGOING_MESSAGE::%s'.freeze + CHANNEL_LOCK_ON_OUTGOING_MESSAGE_TIMEOUT = 15.seconds + + def baileys_extract_message_timestamp(timestamp) + # NOTE: Timestamp might be in this format {"low"=>1748003165, "high"=>0, "unsigned"=>true} + if timestamp.is_a?(Hash) && timestamp.key?('low') + low = timestamp['low'].to_i + high = timestamp.fetch('high', 0).to_i + return (high << 32) | low + end + + # NOTE: Timestamp might be a string or a number + timestamp.to_i + end + + def with_baileys_channel_lock_on_outgoing_message(channel_id, timeout: CHANNEL_LOCK_ON_OUTGOING_MESSAGE_TIMEOUT) + raise ArgumentError, 'A block is required for with_baileys_channel_lock_on_outgoing_message' unless block_given? + + start_time = Time.now.to_i + + # NOTE: On timeout, we ignore the lock and proceed with the block execution + while (Time.now.to_i - start_time) < timeout + break if baileys_lock_channel_on_outgoing_message(channel_id, timeout) + + sleep(0.1) + end + + yield + ensure + baileys_clear_channel_lock_on_outgoing_message(channel_id) + end + + private + + def baileys_lock_channel_on_outgoing_message(channel_id, timeout) + key = format(CHANNEL_LOCK_ON_OUTGOING_MESSAGE_KEY, channel_id: channel_id) + Redis::Alfred.set(key, 1, nx: true, ex: timeout) + end + + def baileys_clear_channel_lock_on_outgoing_message(channel_id) + key = format(CHANNEL_LOCK_ON_OUTGOING_MESSAGE_KEY, channel_id: channel_id) + Redis::Alfred.delete(key) + end +end 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/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/inbox/message.js b/app/javascript/dashboard/api/inbox/message.js index 8f294a0ee..903e5849c 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,9 @@ export const buildCreatePayload = ({ files.forEach(file => { payload.append('attachments[]', file); }); + isRecordedAudio?.forEach(filename => { + payload.append('is_recorded_audio[]', filename); + }); payload.append('private', isPrivate); payload.append('echo_id', echoId); payload.append('cc_emails', ccEmails); @@ -60,6 +64,7 @@ class MessageApi extends ApiClient { contentAttributes, echo_id: echoId, files, + isRecordedAudio, ccEmails = '', bccEmails = '', toEmails = '', @@ -74,6 +79,7 @@ class MessageApi extends ApiClient { contentAttributes, echoId, files, + isRecordedAudio, ccEmails, bccEmails, toEmails, diff --git a/app/javascript/dashboard/api/inboxes.js b/app/javascript/dashboard/api/inboxes.js index 361b9472f..5323ba60e 100644 --- a/app/javascript/dashboard/api/inboxes.js +++ b/app/javascript/dashboard/api/inboxes.js @@ -32,6 +32,14 @@ class Inboxes extends CacheEnabledApiClient { syncTemplates(inboxId) { return axios.post(`${this.url}/${inboxId}/sync_templates`); } + + setupChannelProvider(inboxId) { + return axios.post(`${this.url}/${inboxId}/setup_channel_provider`); + } + + disconnectChannelProvider(inboxId) { + return axios.post(`${this.url}/${inboxId}/disconnect_channel_provider`); + } } export default new Inboxes(); diff --git a/app/javascript/dashboard/components-next/NewConversation/components/ActionButtons.vue b/app/javascript/dashboard/components-next/NewConversation/components/ActionButtons.vue index 773ebe315..396fe1ada 100644 --- a/app/javascript/dashboard/components-next/NewConversation/components/ActionButtons.vue +++ b/app/javascript/dashboard/components-next/NewConversation/components/ActionButtons.vue @@ -16,6 +16,8 @@ import ContentTemplateSelector from './ContentTemplateSelector.vue'; const props = defineProps({ attachedFiles: { type: Array, default: () => [] }, isWhatsappInbox: { type: Boolean, default: false }, + isWhatsappBaileysInbox: { type: Boolean, default: false }, + isWhatsAppZapiInbox: { type: Boolean, default: false }, isEmailOrWebWidgetInbox: { type: Boolean, default: false }, isTwilioSmsInbox: { type: Boolean, default: false }, isTwilioWhatsAppInbox: { type: Boolean, default: false }, @@ -77,7 +79,11 @@ const shouldShowEmojiButton = computed(() => { }); const isRegularMessageMode = computed(() => { - return !props.isWhatsappInbox && !props.isTwilioWhatsAppInbox; + return ( + (!props.isWhatsappInbox && !props.isTwilioWhatsAppInbox) || + props.isWhatsappBaileysInbox || + props.isWhatsAppZapiInbox + ); }); const setSignature = () => { diff --git a/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue b/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue index 4d6d41dac..f26e75e94 100644 --- a/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue +++ b/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue @@ -66,6 +66,12 @@ const inboxTypes = computed(() => ({ isEmail: props.targetInbox?.channelType === INBOX_TYPES.EMAIL, isTwilio: props.targetInbox?.channelType === INBOX_TYPES.TWILIO, isWhatsapp: props.targetInbox?.channelType === INBOX_TYPES.WHATSAPP, + isWhatsappBaileys: + props.targetInbox?.channelType === INBOX_TYPES.WHATSAPP && + props.targetInbox?.provider === 'baileys', + isWhatsappZapi: + props.targetInbox?.channelType === INBOX_TYPES.WHATSAPP && + props.targetInbox?.provider === 'zapi', isWebWidget: props.targetInbox?.channelType === INBOX_TYPES.WEB, isApi: props.targetInbox?.channelType === INBOX_TYPES.API, isEmailOrWebWidget: @@ -292,7 +298,9 @@ const handleSendTwilioMessage = async ({ message, templateParams }) => { const shouldShowMessageEditor = computed(() => { return ( - !inboxTypes.value.isWhatsapp && + (!inboxTypes.value.isWhatsapp || + inboxTypes.value.isWhatsappBaileys || + inboxTypes.value.isWhatsappZapi) && !showNoInboxAlert.value && !inboxTypes.value.isTwilioWhatsapp ); @@ -365,6 +373,8 @@ const shouldShowMessageEditor = computed(() => { { - messageTimestamp(createdAt.value, 'LLL d, h:mm a') + messageTimestamp( + contentAttributes?.value?.externalCreatedAt ?? createdAt.value, + 'LLL d, h:mm a' + ) ); const showStatusIndicator = computed(() => { diff --git a/app/javascript/dashboard/components-next/sidebar/SidebarAccountSwitcher.vue b/app/javascript/dashboard/components-next/sidebar/SidebarAccountSwitcher.vue index 6ef69b92c..6108f5384 100644 --- a/app/javascript/dashboard/components-next/sidebar/SidebarAccountSwitcher.vue +++ b/app/javascript/dashboard/components-next/sidebar/SidebarAccountSwitcher.vue @@ -73,7 +73,10 @@ const emitNewAccount = () => { /> - + -import { computed } from 'vue'; +import { computed, ref } from 'vue'; import 'highlight.js/styles/default.css'; import 'highlight.js/lib/common'; import NextButton from 'dashboard/components-next/button/Button.vue'; @@ -24,10 +24,20 @@ const props = defineProps({ type: String, default: 'Chatwoot Codepen', }, + secure: { + type: Boolean, + default: false, + }, }); const { t } = useI18n(); +const isVisible = ref(false); + +const toggleVisibility = () => { + isVisible.value = !isVisible.value; +}; + const scrubbedScript = computed(() => { // remove trailing and leading extra lines and not spaces const scrubbed = props.script.replace(/^\s*[\r\n]/gm, ''); @@ -52,6 +62,10 @@ const codepenScriptValue = computed(() => { }); }); +const shouldShowScript = computed(() => { + return !props.secure || isVisible.value; +}); + const onCopy = async e => { e.preventDefault(); await copyTextToClipboard(scrubbedScript.value); @@ -80,6 +94,14 @@ const onCopy = async e => { :label="t('COMPONENTS.CODE.CODEPEN')" /> + { /> + diff --git a/app/javascript/dashboard/components/app/UpdateBanner.vue b/app/javascript/dashboard/components/app/UpdateBanner.vue index fcfcfe11f..d84b14366 100644 --- a/app/javascript/dashboard/components/app/UpdateBanner.vue +++ b/app/javascript/dashboard/components/app/UpdateBanner.vue @@ -73,7 +73,7 @@ export default { v-if="shouldShowBanner" color-scheme="primary" :banner-message="bannerMessage" - href-link="https://github.com/chatwoot/chatwoot/releases" + href-link="https://github.com/fazer-ai/chatwoot/releases" :href-link-text="$t('GENERAL_SETTINGS.LEARN_MORE')" has-close-button @close="dismissUpdateBanner" diff --git a/app/javascript/dashboard/components/widgets/InboxName.vue b/app/javascript/dashboard/components/widgets/InboxName.vue index ecdaa8a6f..de10faa81 100644 --- a/app/javascript/dashboard/components/widgets/InboxName.vue +++ b/app/javascript/dashboard/components/widgets/InboxName.vue @@ -1,11 +1,24 @@ @@ -18,5 +31,17 @@ defineProps({ {{ inbox.name }} + {{ + inbox.phone_number + }} + + + diff --git a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue index b76416c2d..e137cf068 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue +++ b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue @@ -23,6 +23,7 @@ import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents'; import { useTrack } from 'dashboard/composables'; import { useUISettings } from 'dashboard/composables/useUISettings'; import { useAlert } from 'dashboard/composables'; +import { useMapGetter } from 'dashboard/composables/store'; import { BUS_EVENTS } from 'shared/constants/busEvents'; import { CONVERSATION_EVENTS } from 'dashboard/helper/AnalyticsHelper/events'; @@ -46,11 +47,10 @@ import { } from '@chatwoot/prosemirror-schema/src/mentions/plugin'; import { - appendSignature, findNodeToInsertImage, getContentNode, + cleanSignature, insertAtCursor, - removeSignature as removeSignatureHelper, scrollCursorIntoView, setURLWithQueryAndSize, } from 'dashboard/helper/editorHelper'; @@ -126,6 +126,8 @@ const createState = ( const { isEditorHotKeyEnabled, fetchSignatureFlagFromUISettings } = useUISettings(); +const currentUser = useMapGetter('getCurrentUser'); + const typingIndicator = createTypingIndicator( () => emit('typingOn'), () => emit('typingOff'), @@ -280,8 +282,27 @@ watch(showToolsMenu, updatedValue => { function focusEditorInputField(pos = 'end') { const { tr } = editorView.state; - const selection = - pos === 'end' ? Selection.atEnd(tr.doc) : Selection.atStart(tr.doc); + // Check if signature is at start and adjust cursor position accordingly + const signaturePosition = + currentUser.value?.ui_settings?.signature_position || 'top'; + const hasSignature = sendWithSignature.value && props.signature; + + let selection; + if (pos === 'end' || !hasSignature || signaturePosition !== 'top') { + selection = + pos === 'end' ? Selection.atEnd(tr.doc) : Selection.atStart(tr.doc); + } else { + // Position cursor after signature when signature is at start + const signatureLength = props.signature + ? cleanSignature(props.signature).length + : 0; + const separatorLength = + currentUser.value?.ui_settings?.signature_separator === '--' ? 6 : 2; // "\n--\n" vs "\n\n" + const cursorPos = signatureLength + separatorLength; + selection = Selection.near( + tr.doc.resolve(Math.min(cursorPos, tr.doc.content.size)) + ); + } editorView.dispatch(tr.setSelection(selection)); editorView.focus(); @@ -291,14 +312,8 @@ function isBodyEmpty(content) { // if content is undefined, we assume that the body is empty if (!content) return true; - // if the signature is present, we need to remove it before checking - // note that we don't update the editorView, so this is safe - const bodyWithoutSignature = props.signature - ? removeSignatureHelper(content, props.signature) - : content; - // trimming should remove all the whitespaces, so we can check the length - return bodyWithoutSignature.trim().length === 0; + return content.trim().length === 0; } function handleEmptyBodyWithSignature() { @@ -348,39 +363,6 @@ function reloadState(content = props.modelValue) { focusEditor(unrefContent); } -function addSignature() { - let content = props.modelValue; - // see if the content is empty, if it is before appending the signature - // we need to add a paragraph node and move the cursor at the start of the editor - const contentWasEmpty = isBodyEmpty(content); - content = appendSignature(content, props.signature); - // need to reload first, ensuring that the editorView is updated - reloadState(content); - - if (contentWasEmpty) { - handleEmptyBodyWithSignature(); - } -} - -function removeSignature() { - if (!props.signature) return; - let content = props.modelValue; - content = removeSignatureHelper(content, props.signature); - // reload the state, ensuring that the editorView is updated - reloadState(content); -} - -function toggleSignatureInEditor(signatureEnabled) { - // The toggleSignatureInEditor gets the new value from the - // watcher, this means that if the value is true, the signature - // is supposed to be added, else we remove it. - if (signatureEnabled) { - addSignature(); - } else { - removeSignature(); - } -} - function setToolbarPosition() { const editorRect = editorRoot.value.getBoundingClientRect(); const rect = selectedImageNode.value.getBoundingClientRect(); @@ -591,7 +573,11 @@ function createEditorView() { handleDOMEvents: { keyup: () => { if (!props.disabled) { - typingIndicator.start(); + if (props.modelValue.length) { + typingIndicator.start(); + } else { + typingIndicator.stop(); + } updateImgToolbarOnDelete(); } }, @@ -661,13 +647,6 @@ watch( } ); -watch(sendWithSignature, newValue => { - // see if the allowSignature flag is true - if (props.allowSignature) { - toggleSignatureInEditor(newValue); - } -}); - onMounted(() => { // [VITE] state assignment was done in created before state = createState( diff --git a/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue b/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue index bc43f4869..cb167adbc 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue +++ b/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue @@ -348,7 +348,7 @@ export default { v-if="showMessageSignatureButton" v-tooltip.top-end="signatureToggleTooltip" icon="i-ph-signature" - slate + :color="sendWithSignature ? 'blue' : 'slate'" faded sm @click="toggleMessageSignature" diff --git a/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue b/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue index 3cb46c05f..10805a04d 100644 --- a/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue +++ b/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue @@ -5,6 +5,9 @@ import { useConfig } from 'dashboard/composables/useConfig'; import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents'; import { useAI } from 'dashboard/composables/useAI'; import { useSnakeCase } from 'dashboard/composables/useTransformKeys'; +import { useAdmin } from 'dashboard/composables/useAdmin'; +import { useAlert } from 'dashboard/composables'; +import { useStore } from 'vuex'; // components import ReplyBox from './ReplyBox.vue'; @@ -36,6 +39,7 @@ import { REPLY_POLICY } from 'shared/constants/links'; import wootConstants from 'dashboard/constants/globals'; import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage'; import { INBOX_TYPES } from 'dashboard/helper/inbox'; +import WhatsappLinkDeviceModal from '../../../routes/dashboard/settings/inbox/components/WhatsappLinkDeviceModal.vue'; export default { components: { @@ -44,12 +48,15 @@ export default { Banner, ConversationLabelSuggestion, Spinner, + WhatsappLinkDeviceModal, }, mixins: [inboxMixin], setup() { + const { isAdmin } = useAdmin(); const isPopOutReplyBox = ref(false); const conversationPanelRef = ref(null); const { isEnterprise } = useConfig(); + const store = useStore(); const keyboardEvents = { Escape: { @@ -78,6 +85,8 @@ export default { fetchIntegrationsIfRequired, fetchLabelSuggestions, conversationPanelRef, + isAdmin, + store, }; }, data() { @@ -89,6 +98,7 @@ export default { isProgrammaticScroll: false, messageSentSinceOpened: false, labelSuggestions: [], + showLinkDeviceModal: false, }; }, @@ -99,6 +109,9 @@ export default { listLoadingStatus: 'getAllMessagesLoaded', currentAccountId: 'getCurrentAccountId', }), + currentInbox() { + return this.$store.getters['inboxes/getInbox'](this.currentChat.inbox_id); + }, isOpen() { return this.currentChat?.status === wootConstants.STATUS_TYPE.OPEN; }, @@ -243,6 +256,9 @@ export default { return { incoming, outgoing }; }, + inboxProviderConnection() { + return this.currentInbox.provider_connection?.connection; + }, }, watch: { @@ -443,12 +459,75 @@ export default { const payload = useSnakeCase(message); await this.$store.dispatch('sendMessageWithData', payload); }, + getInReplyToMessage(parentMessage) { + if (!parentMessage) return {}; + const inReplyToMessageId = parentMessage.content_attributes?.in_reply_to; + if (!inReplyToMessageId) return {}; + + return this.currentChat?.messages.find(message => { + if (message.id === inReplyToMessageId) { + return true; + } + return false; + }); + }, + onOpenLinkDeviceModal() { + this.showLinkDeviceModal = true; + }, + onCloseLinkDeviceModal() { + this.showLinkDeviceModal = false; + }, + onSetupProviderConnection() { + this.store + .dispatch('inboxes/setupChannelProvider', this.inbox.id) + .catch(e => { + // eslint-disable-next-line no-console + console.error('Error setting up provider connection:', e); + useAlert( + this.$t( + 'CONVERSATION.INBOX.WHATSAPP_PROVIDER_CONNECTION.RECONNECT_FAILED' + ) + ); + }); + }, }, }; diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/Index.vue index b8b7ff1a0..0a1360cd8 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/Index.vue @@ -125,6 +125,7 @@ const openDelete = inbox => { diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue index 4849628e6..573ddbe79 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue @@ -110,6 +110,12 @@ export default { if (this.isATwilioWhatsAppChannel) { return this.$t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.TWILIO'); } + if (this.isAWhatsAppBaileysChannel) { + return this.$t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.BAILEYS'); + } + if (this.isAWhatsAppZapiChannel) { + return this.$t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.ZAPI'); + } return ''; }, tabs() { @@ -159,7 +165,9 @@ export default { this.isAVoiceChannel || (this.isAnEmailChannel && !this.inbox.provider) || this.shouldShowWhatsAppConfiguration || - this.isAWebWidgetInbox + this.isAWebWidgetInbox || + this.isAWhatsAppBaileysChannel || + this.isAWhatsAppZapiChannel ) { visibleToAllChannelTabs = [ ...visibleToAllChannelTabs, diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/BaileysWhatsapp.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/BaileysWhatsapp.vue new file mode 100644 index 000000000..55593f94e --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/BaileysWhatsapp.vue @@ -0,0 +1,184 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Whatsapp.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Whatsapp.vue index c6c021a45..359dd253a 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Whatsapp.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Whatsapp.vue @@ -7,10 +7,15 @@ import ThreeSixtyDialogWhatsapp from './360DialogWhatsapp.vue'; import CloudWhatsapp from './CloudWhatsapp.vue'; import WhatsappEmbeddedSignup from './WhatsappEmbeddedSignup.vue'; import ChannelSelector from 'dashboard/components/ChannelSelector.vue'; +import BaileysWhatsapp from './BaileysWhatsapp.vue'; +import ZapiWhatsapp from './ZapiWhatsapp.vue'; +import { usePolicy } from 'dashboard/composables/usePolicy'; +import { FEATURE_FLAGS } from 'dashboard/featureFlags'; const route = useRoute(); const router = useRouter(); const { t } = useI18n(); +const { isFeatureFlagEnabled } = usePolicy(); const PROVIDER_TYPES = { WHATSAPP: 'whatsapp', @@ -19,6 +24,8 @@ const PROVIDER_TYPES = { WHATSAPP_EMBEDDED: 'whatsapp_embedded', WHATSAPP_MANUAL: 'whatsapp_manual', THREE_SIXTY_DIALOG: '360dialog', + BAILEYS: 'baileys', + ZAPI: 'zapi', }; const hasWhatsappAppId = computed(() => { @@ -34,20 +41,39 @@ const showProviderSelection = computed(() => !selectedProvider.value); const showConfiguration = computed(() => Boolean(selectedProvider.value)); -const availableProviders = computed(() => [ - { - key: PROVIDER_TYPES.WHATSAPP, - title: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_CLOUD'), - description: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_CLOUD_DESC'), - icon: 'i-woot-whatsapp', - }, - { - key: PROVIDER_TYPES.TWILIO, - title: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.TWILIO'), - description: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.TWILIO_DESC'), - icon: 'i-woot-twilio', - }, -]); +const availableProviders = computed(() => { + const providers = [ + { + key: PROVIDER_TYPES.WHATSAPP, + title: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_CLOUD'), + description: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_CLOUD_DESC'), + icon: 'i-woot-whatsapp', + }, + { + key: PROVIDER_TYPES.TWILIO, + title: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.TWILIO'), + description: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.TWILIO_DESC'), + icon: 'i-woot-twilio', + }, + { + key: PROVIDER_TYPES.BAILEYS, + title: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.BAILEYS'), + description: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.BAILEYS_DESC'), + icon: 'i-woot-baileys', + }, + ]; + + if (isFeatureFlagEnabled(FEATURE_FLAGS.CHANNEL_ZAPI)) { + providers.push({ + key: PROVIDER_TYPES.ZAPI, + title: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.ZAPI'), + description: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.ZAPI_DESC'), + icon: 'i-woot-zapi', + }); + } + + return providers; +}); const selectProvider = providerValue => { router.push({ @@ -138,7 +164,13 @@ const handleManualLinkClick = () => { - + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/ZapiWhatsapp.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/ZapiWhatsapp.vue new file mode 100644 index 000000000..e5ddb8e6a --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/ZapiWhatsapp.vue @@ -0,0 +1,162 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/components/ChannelName.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/components/ChannelName.vue index 4ff8b1ab1..31e1dc9da 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/components/ChannelName.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/components/ChannelName.vue @@ -12,6 +12,10 @@ const props = defineProps({ type: String, default: '', }, + provider: { + type: String, + default: '', + }, }); const getters = useStoreGetters(); const { t } = useI18n(); @@ -39,6 +43,16 @@ const twilioChannelName = () => { return t(`INBOX_MGMT.CHANNELS.TWILIO_SMS`); }; +const whatsappChannelName = () => { + if (props.provider === 'baileys') { + return t(`INBOX_MGMT.CHANNELS.WHATSAPP_BAILEYS`); + } + if (props.provider === 'zapi') { + return t(`INBOX_MGMT.CHANNELS.WHATSAPP_ZAPI`); + } + return t(`INBOX_MGMT.CHANNELS.WHATSAPP`); +}; + const readableChannelName = computed(() => { if (props.channelType === 'Channel::Api') { return globalConfig.value.apiChannelName || t('INBOX_MGMT.CHANNELS.API'); @@ -46,6 +60,9 @@ const readableChannelName = computed(() => { if (props.channelType === 'Channel::TwilioSms') { return twilioChannelName(); } + if (props.channelType === 'Channel::Whatsapp') { + return whatsappChannelName(); + } return t(`INBOX_MGMT.CHANNELS.${i18nMap[props.channelType]}`); }); diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/components/WhatsappLinkDeviceModal.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/components/WhatsappLinkDeviceModal.vue new file mode 100644 index 000000000..461bc4b68 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/components/WhatsappLinkDeviceModal.vue @@ -0,0 +1,168 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/ConfigurationPage.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/ConfigurationPage.vue index 186fcc48e..7f14b3475 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/ConfigurationPage.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/ConfigurationPage.vue @@ -5,11 +5,14 @@ import SettingsSection from '../../../../../components/SettingsSection.vue'; import ImapSettings from '../ImapSettings.vue'; import SmtpSettings from '../SmtpSettings.vue'; import { useVuelidate } from '@vuelidate/core'; -import { required } from '@vuelidate/validators'; import NextButton from 'dashboard/components-next/button/Button.vue'; import TextArea from 'next/textarea/TextArea.vue'; import WhatsappReauthorize from '../channels/whatsapp/Reauthorize.vue'; -import { sanitizeAllowedDomains } from 'dashboard/helper/URLHelper'; +import { sanitizeAllowedDomains, isValidURL } from 'dashboard/helper/URLHelper'; +import { requiredIf } from '@vuelidate/validators'; +import WhatsappLinkDeviceModal from '../components/WhatsappLinkDeviceModal.vue'; +import InboxName from 'dashboard/components/widgets/InboxName.vue'; +import Switch from 'dashboard/components-next/switch/Switch.vue'; export default { components: { @@ -19,6 +22,10 @@ export default { NextButton, TextArea, WhatsappReauthorize, + WhatsappLinkDeviceModal, + InboxName, + // eslint-disable-next-line vue/no-reserved-component-names + Switch, }, mixins: [inboxMixin], props: { @@ -38,10 +45,27 @@ export default { isSyncingTemplates: false, allowedDomains: '', isUpdatingAllowedDomains: false, + baileysProviderUrl: '', + showLinkDeviceModal: false, + markAsRead: true, + zapiInstanceId: '', + zapiToken: '', + zapiClientToken: '', + zapiTokenUpdate: '', + zapiClientTokenUpdate: '', }; }, - validations: { - whatsAppInboxAPIKey: { required }, + validations() { + return { + whatsAppInboxAPIKey: { + requiredIf: requiredIf( + !this.isAWhatsAppBaileysChannel && !this.isAWhatsAppZapiChannel + ), + }, + baileysProviderUrl: { isValidURL: value => !value || isValidURL(value) }, + zapiTokenUpdate: {}, + zapiClientTokenUpdate: {}, + }; }, computed: { isEmbeddedSignupWhatsApp() { @@ -63,6 +87,11 @@ export default { setDefaults() { this.hmacMandatory = this.inbox.hmac_mandatory || false; this.allowedDomains = this.inbox.allowed_domains || ''; + this.baileysProviderUrl = this.inbox.provider_config?.provider_url ?? ''; + this.markAsRead = this.inbox.provider_config?.mark_as_read ?? true; + this.zapiInstanceId = this.inbox.provider_config?.instance_id ?? ''; + this.zapiToken = this.inbox.provider_config?.token ?? ''; + this.zapiClientToken = this.inbox.provider_config?.client_token ?? ''; }, handleHmacFlag() { this.updateInbox(); @@ -141,6 +170,85 @@ export default { this.isSyncingTemplates = false; } }, + async updateBaileysProviderUrl() { + try { + const payload = { + id: this.inbox.id, + formData: false, + channel: { + provider_config: { + ...this.inbox.provider_config, + provider_url: this.baileysProviderUrl, + }, + }, + }; + + await this.$store.dispatch('inboxes/updateInbox', payload); + useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE')); + } catch (error) { + useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE')); + } + }, + async updateWhatsAppMarkAsRead() { + try { + const payload = { + id: this.inbox.id, + formData: false, + channel: { + provider_config: { + ...this.inbox.provider_config, + mark_as_read: this.markAsRead, + }, + }, + }; + await this.$store.dispatch('inboxes/updateInbox', payload); + useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE')); + } catch (error) { + useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE')); + } + }, + onOpenLinkDeviceModal() { + this.showLinkDeviceModal = true; + }, + onCloseLinkDeviceModal() { + this.showLinkDeviceModal = false; + }, + async updateZapiToken() { + try { + const payload = { + id: this.inbox.id, + formData: false, + channel: { + provider_config: { + ...this.inbox.provider_config, + token: this.zapiTokenUpdate, + }, + }, + }; + await this.$store.dispatch('inboxes/updateInbox', payload); + useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE')); + } catch (error) { + useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE')); + } + }, + async updateZapiClientToken() { + try { + const payload = { + id: this.inbox.id, + formData: false, + channel: { + provider_config: { + ...this.inbox.provider_config, + client_token: this.zapiClientTokenUpdate, + }, + }, + }; + await this.$store.dispatch('inboxes/updateInbox', payload); + useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE')); + } catch (error) { + useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE')); + } + }, }, }; @@ -308,7 +416,7 @@ export default { -
+