diff --git a/.env.example b/.env.example index 2ab2933dc..fb9ee8398 100644 --- a/.env.example +++ b/.env.example @@ -259,3 +259,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 b986e9612..38d70a737 100644 --- a/.github/workflows/frontend-fe.yml +++ b/.github/workflows/frontend-fe.yml @@ -1,6 +1,9 @@ name: Frontend Lint & Test on: + push: + tags: + - '*' workflow_dispatch: jobs: diff --git a/.github/workflows/publish_github_docker.yml b/.github/workflows/publish_github_docker.yml new file mode 100644 index 000000000..c9efc6760 --- /dev/null +++ b/.github/workflows/publish_github_docker.yml @@ -0,0 +1,126 @@ +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 + 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: 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 942cb351d..c18071c76 100644 --- a/.github/workflows/run_foss_spec.yml +++ b/.github/workflows/run_foss_spec.yml @@ -1,5 +1,9 @@ name: Run Chatwoot CE spec + on: + push: + 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 12e756af6..b50665899 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/Gemfile b/Gemfile index b4752c745..7c06481c2 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' @@ -180,6 +180,8 @@ gem 'ruby-openai' gem 'shopify_api' +gem 'resend', '~> 0.19.0' + ### Gems required only in specific deployment environments ### ############################################################## diff --git a/Gemfile.lock b/Gemfile.lock index d9908f5e1..3787cde07 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -641,6 +641,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) @@ -973,6 +975,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 a88d3535b..8e7e1bf2d 100644 --- a/app/actions/contact_identify_action.rb +++ b/app/actions/contact_identify_action.rb @@ -69,7 +69,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 e1087b19f..cff65e8f0 100644 --- a/app/builders/messages/message_builder.rb +++ b/app/builders/messages/message_builder.rb @@ -2,17 +2,19 @@ class Messages::MessageBuilder 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 @user = user @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 @@ -66,7 +68,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 @@ -77,6 +79,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' @@ -149,6 +167,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 6e119ca3d..960fc5725 100644 --- a/app/controllers/api/v1/accounts/callbacks_controller.rb +++ b/app/controllers/api/v1/accounts/callbacks_controller.rb @@ -39,7 +39,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 8753918fc..de19128cf 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) @@ -200,6 +204,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 61d16b2ca..18976f295 100644 --- a/app/controllers/api/v1/accounts/inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/inboxes_controller.rb @@ -64,6 +64,28 @@ 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 + 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') } 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 6cfed161f..73bba60eb 100644 --- a/app/controllers/api/v1/accounts/portals_controller.rb +++ b/app/controllers/api/v1/accounts/portals_controller.rb @@ -38,7 +38,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 e11cf9d4a..4b3c7cfee 100644 --- a/app/controllers/platform/api/v1/accounts_controller.rb +++ b/app/controllers/platform/api/v1/accounts_controller.rb @@ -5,7 +5,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 4e3b5dca9..b0b67c337 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 b210dea19..057128c98 100644 --- a/app/controllers/super_admin/account_users_controller.rb +++ b/app/controllers/super_admin/account_users_controller.rb @@ -6,7 +6,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 204bfc95b..52b82deb3 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/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 e08b5691c..94c7f8cde 100644 --- a/app/helpers/reporting_event_helper.rb +++ b/app/helpers/reporting_event_helper.rb @@ -52,8 +52,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 8c09791c8..7af82a720 100644 --- a/app/javascript/dashboard/api/inboxes.js +++ b/app/javascript/dashboard/api/inboxes.js @@ -28,6 +28,14 @@ class Inboxes extends CacheEnabledApiClient { agent_bot: botId, }); } + + 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 90cb67c4f..1623d4c04 100644 --- a/app/javascript/dashboard/components-next/NewConversation/components/ActionButtons.vue +++ b/app/javascript/dashboard/components-next/NewConversation/components/ActionButtons.vue @@ -15,6 +15,7 @@ import WhatsAppOptions from './WhatsAppOptions.vue'; const props = defineProps({ attachedFiles: { type: Array, default: () => [] }, isWhatsappInbox: { type: Boolean, default: false }, + isWhatsappBaileysInbox: { type: Boolean, default: false }, isEmailOrWebWidgetInbox: { type: Boolean, default: false }, isTwilioSmsInbox: { type: Boolean, default: false }, messageTemplates: { type: Array, default: () => [] }, @@ -216,7 +217,7 @@ useKeyboardEvents(keyboardEvents); @click="emit('discard')" />