diff --git a/.circleci/config.yml b/.circleci/config.yml index 65ceda04c..804c63857 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -3,6 +3,7 @@ orbs: node: circleci/node@6.1.0 qlty-orb: qltysh/qlty-orb@0.0 +# Shared defaults for setup steps defaults: &defaults working_directory: ~/build machine: @@ -12,10 +13,106 @@ defaults: &defaults RAILS_LOG_TO_STDOUT: false COVERAGE: true LOG_LEVEL: warn - parallelism: 4 jobs: - build: + # Separate job for linting (no parallelism needed) + lint: + <<: *defaults + steps: + - checkout + + # Install minimal system dependencies for linting + - run: + name: Install System Dependencies + command: | + sudo apt-get update + DEBIAN_FRONTEND=noninteractive sudo apt-get install -y \ + libpq-dev \ + build-essential \ + git \ + curl \ + libssl-dev \ + zlib1g-dev \ + libreadline-dev \ + libyaml-dev \ + openjdk-11-jdk \ + jq \ + software-properties-common \ + ca-certificates \ + imagemagick \ + libxml2-dev \ + libxslt1-dev \ + file \ + g++ \ + gcc \ + autoconf \ + gnupg2 \ + patch \ + ruby-dev \ + liblzma-dev \ + libgmp-dev \ + libncurses5-dev \ + libffi-dev \ + libgdbm6 \ + libgdbm-dev \ + libvips + + - run: + name: Install RVM and Ruby 3.4.4 + command: | + sudo apt-get install -y gpg + gpg --keyserver hkp://keyserver.ubuntu.com --recv-keys 409B6B1796C275462A1703113804BB82D39DC0E3 7D2BAF1CF37B13E2069D6956105BD0E739499BDB + \curl -sSL https://get.rvm.io | bash -s stable + echo 'source ~/.rvm/scripts/rvm' >> $BASH_ENV + source ~/.rvm/scripts/rvm + rvm install "3.4.4" + rvm use 3.4.4 --default + gem install bundler -v 2.5.16 + + - run: + name: Install Application Dependencies + command: | + source ~/.rvm/scripts/rvm + bundle install + + - node/install: + node-version: '23.7' + - node/install-pnpm + - node/install-packages: + pkg-manager: pnpm + override-ci-command: pnpm i + + # Swagger verification + - run: + name: Verify swagger API specification + command: | + bundle exec rake swagger:build + if [[ `git status swagger/swagger.json --porcelain` ]] + then + echo "ERROR: The swagger.json file is not in sync with the yaml specification. Run 'rake swagger:build' and commit 'swagger/swagger.json'." + exit 1 + fi + mkdir -p ~/tmp + curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/6.3.0/openapi-generator-cli-6.3.0.jar > ~/tmp/openapi-generator-cli-6.3.0.jar + java -jar ~/tmp/openapi-generator-cli-6.3.0.jar validate -i swagger/swagger.json + + # Bundle audit + - run: + name: Bundle audit + command: bundle exec bundle audit update && bundle exec bundle audit check -v + + # Rubocop linting + - run: + name: Rubocop + command: bundle exec rubocop --parallel + + # ESLint linting + - run: + name: eslint + command: pnpm run eslint + + # Separate job for frontend tests + frontend-tests: <<: *defaults steps: - checkout @@ -25,8 +122,38 @@ jobs: - node/install-packages: pkg-manager: pnpm override-ci-command: pnpm i - - run: node --version - - run: pnpm --version + + - run: + name: Run frontend tests (with coverage) + command: pnpm run test:coverage + + - run: + name: Move coverage files if they exist + command: | + if [ -d "coverage" ]; then + mkdir -p ~/build/coverage + cp -r coverage ~/build/coverage/frontend || true + fi + when: always + + - persist_to_workspace: + root: ~/build + paths: + - coverage + + # Backend tests with parallelization + backend-tests: + <<: *defaults + parallelism: 16 + steps: + - checkout + - node/install: + node-version: '23.7' + - node/install-pnpm + - node/install-packages: + pkg-manager: pnpm + override-ci-command: pnpm i + - run: name: Add PostgreSQL repository and update command: | @@ -91,20 +218,6 @@ jobs: source ~/.rvm/scripts/rvm bundle install - # Swagger verification - - run: - name: Verify swagger API specification - command: | - bundle exec rake swagger:build - if [[ `git status swagger/swagger.json --porcelain` ]] - then - echo "ERROR: The swagger.json file is not in sync with the yaml specification. Run 'rake swagger:build' and commit 'swagger/swagger.json'." - exit 1 - fi - mkdir -p ~/tmp - curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/6.3.0/openapi-generator-cli-6.3.0.jar > ~/tmp/openapi-generator-cli-6.3.0.jar - java -jar ~/tmp/openapi-generator-cli-6.3.0.jar validate -i swagger/swagger.json - # Configure environment and database - run: name: Database Setup and Configure Environment Variables @@ -127,57 +240,91 @@ jobs: name: Run DB migrations command: bundle exec rails db:chatwoot_prepare - # Bundle audit - - run: - name: Bundle audit - command: bundle exec bundle audit update && bundle exec bundle audit check -v - - # Rubocop linting - - run: - name: Rubocop - command: bundle exec rubocop - - # ESLint linting - - run: - name: eslint - command: pnpm run eslint - - - run: - name: Run frontend tests (with coverage) - command: | - mkdir -p ~/build/coverage/frontend - pnpm run test:coverage - - # Run backend tests + # Run backend tests (parallelized) - run: name: Run backend tests command: | mkdir -p ~/tmp/test-results/rspec mkdir -p ~/tmp/test-artifacts mkdir -p ~/build/coverage/backend - TESTFILES=$(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings) + + # Use round-robin distribution (same as GitHub Actions) for better test isolation + # This prevents tests with similar timing from being grouped on the same runner + SPEC_FILES=($(find spec -name '*_spec.rb' | sort)) + TESTS="" + + for i in "${!SPEC_FILES[@]}"; do + if [ $(( i % $CIRCLE_NODE_TOTAL )) -eq $CIRCLE_NODE_INDEX ]; then + TESTS="$TESTS ${SPEC_FILES[$i]}" + fi + done + bundle exec rspec -I ./spec --require coverage_helper --require spec_helper --format progress \ --format RspecJunitFormatter \ --out ~/tmp/test-results/rspec.xml \ - -- ${TESTFILES} + -- $TESTS no_output_timeout: 30m - # Qlty coverage publish - - qlty-orb/coverage_publish: - files: | - coverage/coverage.json - coverage/lcov.info + # Store test results for better splitting in future runs + - store_test_results: + path: ~/tmp/test-results - run: - name: List coverage directory contents + name: Move coverage files if they exist command: | - ls -R ~/build/coverage + if [ -d "coverage" ]; then + mkdir -p ~/build/coverage + cp -r coverage ~/build/coverage/backend || true + fi + when: always - persist_to_workspace: root: ~/build paths: - coverage + # Collect coverage from all jobs + coverage: + <<: *defaults + steps: + - checkout + - attach_workspace: + at: ~/build + + # Qlty coverage publish + - qlty-orb/coverage_publish: + files: | + coverage/frontend/lcov.info + + - run: + name: List coverage directory contents + command: | + ls -R ~/build/coverage || echo "No coverage directory" + - store_artifacts: path: coverage destination: coverage + + build: + <<: *defaults + steps: + - run: + name: Legacy build aggregator + command: | + echo "All main jobs passed; build job kept only for GitHub required check compatibility." + +workflows: + version: 2 + build: + jobs: + - lint + - frontend-tests + - backend-tests + - coverage: + requires: + - frontend-tests + - backend-tests + - build: + requires: + - lint + - coverage diff --git a/.env.example b/.env.example index aa3ece9cf..844aafb6a 100644 --- a/.env.example +++ b/.env.example @@ -220,6 +220,7 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38: ## https://github.com/DataDog/dd-trace-rb/blob/master/docs/GettingStarted.md#environment-variables # DD_TRACE_AGENT_URL= + # MaxMindDB API key to download GeoLite2 City database # IP_LOOKUP_API_KEY= diff --git a/.github/workflows/run_foss_spec.yml b/.github/workflows/run_foss_spec.yml index c18071c76..9061bc6fe 100644 --- a/.github/workflows/run_foss_spec.yml +++ b/.github/workflows/run_foss_spec.yml @@ -1,5 +1,6 @@ name: Run Chatwoot CE spec - +permissions: + contents: read on: push: tags: @@ -7,11 +8,58 @@ on: workflow_dispatch: jobs: - test: - runs-on: ubuntu-22.04 + # Separate linting jobs for faster feedback + lint-backend: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + - name: Run Rubocop + run: bundle exec rubocop --parallel + + lint-frontend: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 23 + cache: 'pnpm' + - name: Install pnpm dependencies + run: pnpm i + - name: Run ESLint + run: pnpm run eslint + + # Frontend tests run in parallel with backend + frontend-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 23 + cache: 'pnpm' + - name: Install pnpm dependencies + run: pnpm i + - name: Run frontend tests + run: pnpm run test:coverage + + # Backend tests with parallelization + backend-tests: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + ci_node_total: [16] + ci_node_index: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15] + services: postgres: - image: pgvector/pgvector:pg15 + image: pgvector/pgvector:pg16 env: POSTGRES_USER: postgres POSTGRES_PASSWORD: '' @@ -19,8 +67,6 @@ jobs: POSTGRES_HOST_AUTH_METHOD: trust ports: - 5432:5432 - # needed because the postgres container does not provide a healthcheck - # tmpfs makes DB faster by using RAM options: >- --mount type=tmpfs,destination=/var/lib/postgresql/data --health-cmd pg_isready @@ -28,7 +74,7 @@ jobs: --health-timeout 5s --health-retries 5 redis: - image: redis + image: redis:alpine ports: - 6379:6379 options: --entrypoint redis-server @@ -42,7 +88,7 @@ jobs: - uses: ruby/setup-ruby@v1 with: - bundler-cache: true # runs 'bundle install' and caches installed gems automatically + bundler-cache: true - uses: actions/setup-node@v4 with: @@ -63,19 +109,36 @@ jobs: - name: Seed database run: bundle exec rake db:schema:load - - name: Run frontend tests - run: pnpm run test:coverage - - # Run rails tests - - name: Run backend tests + - name: Run backend tests (parallelized) run: | - bundle exec rspec --profile=10 --format documentation + # Get all spec files and split them using round-robin distribution + # This ensures slow tests are distributed evenly across all nodes + SPEC_FILES=($(find spec -name '*_spec.rb' | sort)) + TESTS="" + + for i in "${!SPEC_FILES[@]}"; do + # Assign spec to this node if: index % total == node_index + if [ $(( i % ${{ matrix.ci_node_total }} )) -eq ${{ matrix.ci_node_index }} ]; then + TESTS="$TESTS ${SPEC_FILES[$i]}" + fi + done + + if [ -n "$TESTS" ]; then + bundle exec rspec --profile=10 --format progress --format json --out tmp/rspec_results.json $TESTS + fi env: NODE_OPTIONS: --openssl-legacy-provider - - name: Upload rails log folder + - name: Upload test results uses: actions/upload-artifact@v4 if: always() with: - name: rails-log-folder + name: rspec-results-${{ matrix.ci_node_index }} + path: tmp/rspec_results.json + + - name: Upload rails log folder + uses: actions/upload-artifact@v4 + if: failure() + with: + name: rails-log-folder-${{ matrix.ci_node_index }} path: log diff --git a/.gitignore b/.gitignore index 7ca033f87..bcc83c1ef 100644 --- a/.gitignore +++ b/.gitignore @@ -99,3 +99,5 @@ CLAUDE.local.md # Histoire deployment .netlify .histoire +.pnpm-store/* +local/ diff --git a/.qlty/qlty.toml b/.qlty/qlty.toml index 57981a5f7..780b38374 100644 --- a/.qlty/qlty.toml +++ b/.qlty/qlty.toml @@ -39,7 +39,7 @@ exclude_patterns = [ "**/target/**", "**/templates/**", "**/testdata/**", - "**/vendor/**", "spec/", "**/specs/**/**", "**/spec/**/**", "db/*", "bin/**/*", "db/**/*", "config/**/*", "public/**/*", "vendor/**/*", "node_modules/**/*", "lib/tasks/auto_annotate_models.rake", "app/test-matchers.js", "docs/*", "**/*.md", "**/*.yml", "app/javascript/dashboard/i18n/locale", "**/*.stories.js", "stories/", "app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/index.js", "app/javascript/shared/constants/countries.js", "app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/languages.js", "app/javascript/dashboard/routes/dashboard/contacts/contactFilterItems/index.js", "app/javascript/dashboard/routes/dashboard/settings/automation/constants.js", "app/javascript/dashboard/components/widgets/FilterInput/FilterOperatorTypes.js", "app/javascript/dashboard/routes/dashboard/settings/reports/constants.js", "app/javascript/dashboard/store/captain/storeFactory.js", "app/javascript/dashboard/i18n/index.js", "app/javascript/widget/i18n/index.js", "app/javascript/survey/i18n/index.js", "app/javascript/shared/constants/locales.js", "app/javascript/dashboard/helper/specs/macrosFixtures.js", "app/javascript/dashboard/routes/dashboard/settings/macros/constants.js", "**/fixtures/**", "**/*/fixtures.js", + "**/vendor/**", "spec/", "**/specs/**/**", "**/spec/**/**", "db/*", "bin/**/*", "db/**/*", "config/**/*", "public/**/*", "vendor/**/*", "node_modules/**/*", "lib/tasks/auto_annotate_models.rake", "app/test-matchers.js", "docs/*", "**/*.md", "**/*.yml", "app/javascript/dashboard/i18n/locale", "**/*.stories.js", "stories/", "app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/index.js", "app/javascript/shared/constants/countries.js", "app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/languages.js", "app/javascript/dashboard/routes/dashboard/contacts/contactFilterItems/index.js", "app/javascript/dashboard/routes/dashboard/settings/automation/constants.js", "app/javascript/dashboard/components/widgets/FilterInput/FilterOperatorTypes.js", "app/javascript/dashboard/routes/dashboard/settings/reports/constants.js", "app/javascript/dashboard/store/storeFactory.js", "app/javascript/dashboard/i18n/index.js", "app/javascript/widget/i18n/index.js", "app/javascript/survey/i18n/index.js", "app/javascript/shared/constants/locales.js", "app/javascript/dashboard/helper/specs/macrosFixtures.js", "app/javascript/dashboard/routes/dashboard/settings/macros/constants.js", "**/fixtures/**", "**/*/fixtures.js", ] test_patterns = [ diff --git a/.rubocop.yml b/.rubocop.yml index 620dd4c8f..246f503bb 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -7,6 +7,8 @@ plugins: require: - ./rubocop/use_from_email.rb - ./rubocop/custom_cop_location.rb + - ./rubocop/attachment_download.rb + - ./rubocop/one_class_per_file.rb Layout/LineLength: Max: 150 @@ -40,6 +42,12 @@ Style/SymbolArray: Style/OpenStructUse: Enabled: false +Chatwoot/AttachmentDownload: + Enabled: true + Exclude: + - 'spec/**/*' + - 'test/**/*' + Style/OptionalBooleanParameter: Exclude: - 'app/services/email_templates/db_resolver_service.rb' @@ -87,7 +95,7 @@ Metrics/ModuleLength: Rails/HelperInstanceVariable: Exclude: - enterprise/app/helpers/captain/chat_helper.rb - + - enterprise/app/helpers/captain/chat_response_helper.rb Rails/ApplicationController: Exclude: - 'app/controllers/api/v1/widget/messages_controller.rb' @@ -205,6 +213,9 @@ UseFromEmail: CustomCopLocation: Enabled: true +Style/OneClassPerFile: + Enabled: true + AllCops: NewCops: enable SuggestExtensions: false @@ -344,4 +355,6 @@ Rails/SaveBang: AllowedReceivers: - Stripe::Subscription - Stripe::Customer + - Stripe::Invoice + - Stripe::InvoiceItem - FactoryBot diff --git a/AGENTS.md b/AGENTS.md index e3b022a2e..474fe6e7f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,9 @@ - **Test Ruby**: `bundle exec rspec spec/path/to/file_spec.rb` - **Single Test**: `bundle exec rspec spec/path/to/file_spec.rb:LINE_NUMBER` - **Run Project**: `overmind start -f Procfile.dev` +- **Ruby Version**: Manage Ruby via `rbenv` and install the version listed in `.ruby-version` (e.g., `rbenv install $(cat .ruby-version)`) +- **rbenv setup**: Before running any `bundle` or `rspec` commands, init rbenv in your shell (`eval "$(rbenv init -)"`) so the correct Ruby/Bundler versions are used +- Always prefer `bundle exec` for Ruby CLI tasks (rspec, rake, rubocop, etc.) ## Code Style @@ -37,11 +40,20 @@ - MVP focus: Least code change, happy-path only - No unnecessary defensive programming +- Ship the happy path first: limit guards/fallbacks to what production has proven necessary, then iterate +- Prefer minimal, readable code over elaborate abstractions; clarity beats cleverness - Break down complex tasks into small, testable units - Iterate after confirmation - Avoid writing specs unless explicitly asked - Remove dead/unreachable/unused code - Don’t write multiple versions or backups for the same logic — pick the best approach and implement it +- Prefer `with_modified_env` (from spec helpers) over stubbing `ENV` directly in specs +- Specs in parallel/reloading environments: prefer comparing `error.class.name` over constant class equality when asserting raised errors + +## Commit Messages + +- Prefer Conventional Commits: `type(scope): subject` (scope optional) +- Example: `feat(auth): add user authentication` - Don't reference Claude in commit messages ## Project-Specific @@ -73,3 +85,4 @@ Practical checklist for any change impacting core logic or public APIs - Keep request/response contracts stable across OSS and Enterprise; update both sets of routes/controllers when introducing new APIs. - When renaming/moving shared code, mirror the change in `enterprise/` to prevent drift. - Tests: Add Enterprise-specific specs under `spec/enterprise`, mirroring OSS spec layout where applicable. +- When modifying existing OSS features for Enterprise-only behavior, add an Enterprise module (via `prepend_mod_with`/`include_mod_with`) instead of editing OSS files directly—especially for policies, controllers, and services. For Enterprise-exclusive features, place code directly under `enterprise/`. diff --git a/Gemfile b/Gemfile index c047aebc2..f0b6fe6e2 100644 --- a/Gemfile +++ b/Gemfile @@ -162,7 +162,7 @@ gem 'working_hours' gem 'pg_search' # Subscriptions, Billing -gem 'stripe' +gem 'stripe', '~> 18.0' ## - helper gems --## ## to populate db with sample data @@ -191,11 +191,16 @@ gem 'reverse_markdown' gem 'iso-639' gem 'ruby-openai' -gem 'ai-agents', '>= 0.4.3' +gem 'ai-agents', '>= 0.7.0' # TODO: Move this gem as a dependency of ai-agents +gem 'ruby_llm', '>= 1.8.2' gem 'ruby_llm-schema' +# OpenTelemetry for LLM observability +gem 'opentelemetry-sdk' +gem 'opentelemetry-exporter-otlp' + gem 'shopify_api' gem 'resend', '~> 0.19.0' @@ -212,7 +217,7 @@ group :production do end group :development do - gem 'annotate' + gem 'annotaterb' gem 'bullet' gem 'letter_opener' gem 'scss_lint', require: false diff --git a/Gemfile.lock b/Gemfile.lock index 72dd69254..35fade7e6 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -126,11 +126,11 @@ GEM jbuilder (~> 2) rails (>= 4.2, < 7.2) selectize-rails (~> 0.6) - ai-agents (0.4.3) - ruby_llm (~> 1.3) - annotate (3.2.0) - activerecord (>= 3.2, < 8.0) - rake (>= 10.4, < 14.0) + ai-agents (0.7.0) + ruby_llm (~> 1.8.2) + annotaterb (4.20.0) + activerecord (>= 6.0.0) + activesupport (>= 6.0.0) ast (2.4.3) attr_extras (7.1.0) audited (5.4.1) @@ -140,24 +140,27 @@ GEM actionmailbox (>= 7.1.0) aws-sdk-s3 (~> 1, >= 1.123.0) aws-sdk-sns (~> 1, >= 1.61.0) - aws-eventstream (1.2.0) - aws-partitions (1.760.0) - aws-sdk-core (3.188.0) - aws-eventstream (~> 1, >= 1.0.2) - aws-partitions (~> 1, >= 1.651.0) - aws-sigv4 (~> 1.5) + aws-eventstream (1.4.0) + aws-partitions (1.1198.0) + aws-sdk-core (3.240.0) + aws-eventstream (~> 1, >= 1.3.0) + aws-partitions (~> 1, >= 1.992.0) + aws-sigv4 (~> 1.9) + base64 + bigdecimal jmespath (~> 1, >= 1.6.1) - aws-sdk-kms (1.64.0) - aws-sdk-core (~> 3, >= 3.165.0) - aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.126.0) - aws-sdk-core (~> 3, >= 3.174.0) + logger + aws-sdk-kms (1.118.0) + aws-sdk-core (~> 3, >= 3.239.1) + aws-sigv4 (~> 1.5) + aws-sdk-s3 (1.208.0) + aws-sdk-core (~> 3, >= 3.234.0) aws-sdk-kms (~> 1) - aws-sigv4 (~> 1.4) + aws-sigv4 (~> 1.5) aws-sdk-sns (1.70.0) aws-sdk-core (~> 3, >= 3.188.0) aws-sigv4 (~> 1.1) - aws-sigv4 (1.5.2) + aws-sigv4 (1.12.1) aws-eventstream (~> 1, >= 1.0.2) barnes (0.0.9) multi_json (~> 1) @@ -625,6 +628,25 @@ GEM faraday (>= 1.0, < 3) multi_json (>= 1.0) openssl (3.2.0) + opentelemetry-api (1.7.0) + opentelemetry-common (0.23.0) + opentelemetry-api (~> 1.0) + opentelemetry-exporter-otlp (0.31.1) + google-protobuf (>= 3.18) + googleapis-common-protos-types (~> 1.3) + opentelemetry-api (~> 1.1) + opentelemetry-common (~> 0.20) + opentelemetry-sdk (~> 1.10) + opentelemetry-semantic_conventions + opentelemetry-registry (0.4.0) + opentelemetry-api (~> 1.1) + opentelemetry-sdk (1.10.0) + opentelemetry-api (~> 1.1) + opentelemetry-common (~> 0.20) + opentelemetry-registry (~> 0.2) + opentelemetry-semantic_conventions + opentelemetry-semantic_conventions (1.36.0) + opentelemetry-api (~> 1.0) orm_adapter (0.5.0) os (1.1.4) ostruct (0.6.1) @@ -802,7 +824,7 @@ GEM ruby2ruby (2.5.0) ruby_parser (~> 3.1) sexp_processor (~> 4.6) - ruby_llm (1.5.1) + ruby_llm (1.8.2) base64 event_stream_parser (~> 1) faraday (>= 1.10.0) @@ -810,8 +832,9 @@ GEM faraday-net_http (>= 1) faraday-retry (>= 1) marcel (~> 1.0) + ruby_llm-schema (~> 0.2.1) zeitwerk (~> 2) - ruby_llm-schema (0.1.0) + ruby_llm-schema (0.2.5) ruby_parser (3.20.0) sexp_processor (~> 4.16) sass (3.7.4) @@ -911,7 +934,7 @@ GEM squasher (0.7.2) stackprof (0.2.25) statsd-ruby (1.5.0) - stripe (8.5.0) + stripe (18.0.1) telephone_number (1.4.20) test-prof (1.2.1) thor (1.4.0) @@ -1000,8 +1023,8 @@ DEPENDENCIES administrate (>= 0.20.1) administrate-field-active_storage (>= 1.0.3) administrate-field-belongs_to_search (>= 0.9.0) - ai-agents (>= 0.4.3) - annotate + ai-agents (>= 0.7.0) + annotaterb attr_extras audited (~> 5.4, >= 5.4.1) aws-actionmailbox-ses (~> 0) @@ -1075,6 +1098,8 @@ DEPENDENCIES omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2) omniauth-saml opensearch-ruby + opentelemetry-exporter-otlp + opentelemetry-sdk pg pg_search pgvector @@ -1101,6 +1126,7 @@ DEPENDENCIES rubocop-rails rubocop-rspec ruby-openai + ruby_llm (>= 1.8.2) ruby_llm-schema scout_apm scss_lint @@ -1121,7 +1147,7 @@ DEPENDENCIES spring-watcher-listen squasher stackprof - stripe + stripe (~> 18.0) telephone_number test-prof tidewave diff --git a/VERSION_CW b/VERSION_CW index fdc669880..88f181192 100644 --- a/VERSION_CW +++ b/VERSION_CW @@ -1 +1 @@ -4.4.0 +4.8.0 diff --git a/app/builders/contact_inbox_builder.rb b/app/builders/contact_inbox_builder.rb index 788ae39d1..40e571f43 100644 --- a/app/builders/contact_inbox_builder.rb +++ b/app/builders/contact_inbox_builder.rb @@ -103,3 +103,5 @@ class ContactInboxBuilder @inbox.email? || @inbox.sms? || @inbox.twilio? || @inbox.whatsapp? end end + +ContactInboxBuilder.prepend_mod_with('ContactInboxBuilder') diff --git a/app/builders/messages/message_builder.rb b/app/builders/messages/message_builder.rb index 718ab95e8..af73315f8 100644 --- a/app/builders/messages/message_builder.rb +++ b/app/builders/messages/message_builder.rb @@ -161,6 +161,7 @@ class Messages::MessageBuilder # rubocop:disable Metrics/ClassLength private: @private, sender: sender, content_type: @params[:content_type], + content_attributes: content_attributes.presence, items: @items, in_reply_to: @in_reply_to, is_reaction: @is_reaction, @@ -246,3 +247,5 @@ class Messages::MessageBuilder # rubocop:disable Metrics/ClassLength }) end end + +Messages::MessageBuilder.prepend_mod_with('Messages::MessageBuilder') diff --git a/app/builders/messages/messenger/message_builder.rb b/app/builders/messages/messenger/message_builder.rb index 9449ef084..8821da9d5 100644 --- a/app/builders/messages/messenger/message_builder.rb +++ b/app/builders/messages/messenger/message_builder.rb @@ -9,6 +9,8 @@ class Messages::Messenger::MessageBuilder attachment_obj.save! attach_file(attachment_obj, attachment_params(attachment)[:remote_file_url]) if attachment_params(attachment)[:remote_file_url] fetch_story_link(attachment_obj) if attachment_obj.file_type == 'story_mention' + fetch_ig_story_link(attachment_obj) if attachment_obj.file_type == 'ig_story' + fetch_ig_post_link(attachment_obj) if attachment_obj.file_type == 'ig_post' update_attachment_file_type(attachment_obj) end @@ -27,7 +29,7 @@ class Messages::Messenger::MessageBuilder file_type = attachment['type'].to_sym params = { file_type: file_type, account_id: @message.account_id } - if [:image, :file, :audio, :video, :share, :story_mention, :ig_reel].include? file_type + if [:image, :file, :audio, :video, :share, :story_mention, :ig_reel, :ig_post, :ig_story].include? file_type params.merge!(file_type_params(attachment)) elsif file_type == :location params.merge!(location_params(attachment)) @@ -39,9 +41,17 @@ class Messages::Messenger::MessageBuilder end def file_type_params(attachment) + # Handle different URL field names for different attachment types + url = case attachment['type'].to_sym + when :ig_story + attachment['payload']['story_media_url'] + else + attachment['payload']['url'] + end + { - external_url: attachment['payload']['url'], - remote_file_url: attachment['payload']['url'] + external_url: url, + remote_file_url: url } end @@ -68,6 +78,22 @@ class Messages::Messenger::MessageBuilder message.save! end + def fetch_ig_story_link(attachment) + message = attachment.message + # For ig_story, we don't have the same API call as story_mention, so we'll set it up similarly but with generic content + message.content_attributes[:image_type] = 'ig_story' + message.content = I18n.t('conversations.messages.instagram_shared_story_content') + message.save! + end + + def fetch_ig_post_link(attachment) + message = attachment.message + message.content_attributes[:image_type] = 'ig_post' + message.content = I18n.t('conversations.messages.instagram_shared_post_content') + message.save! + end + + # This is a placeholder method to be overridden by child classes def get_story_object_from_source_id(_source_id) {} end @@ -75,6 +101,6 @@ class Messages::Messenger::MessageBuilder private def unsupported_file_type?(attachment_type) - [:template, :unsupported_type].include? attachment_type.to_sym + [:template, :unsupported_type, :ephemeral].include? attachment_type.to_sym end end diff --git a/app/builders/year_in_review_builder.rb b/app/builders/year_in_review_builder.rb new file mode 100644 index 000000000..545fe8029 --- /dev/null +++ b/app/builders/year_in_review_builder.rb @@ -0,0 +1,74 @@ +class YearInReviewBuilder + attr_reader :account, :user_id, :year + + def initialize(account:, user_id:, year:) + @account = account + @user_id = user_id + @year = year + end + + def build + { + year: year, + total_conversations: total_conversations_count, + busiest_day: busiest_day_data, + support_personality: support_personality_data + } + end + + private + + def year_range + @year_range ||= begin + start_time = Time.zone.local(year, 1, 1).beginning_of_day + end_time = Time.zone.local(year, 12, 31).end_of_day + start_time..end_time + end + end + + def total_conversations_count + account.conversations + .where(assignee_id: user_id, created_at: year_range) + .count + end + + def busiest_day_data + daily_counts = account.conversations + .where(assignee_id: user_id, created_at: year_range) + .group_by_day(:created_at, range: year_range, time_zone: Time.zone) + .count + + return nil if daily_counts.empty? + + busiest_date, count = daily_counts.max_by { |_date, cnt| cnt } + + return nil if count.zero? + + { + date: busiest_date.strftime('%b %d'), + count: count + } + end + + def support_personality_data + response_time = average_response_time + + return { avg_response_time_seconds: 0 } if response_time.nil? + + { + avg_response_time_seconds: response_time.to_i + } + end + + def average_response_time + avg_time = account.reporting_events + .where( + name: 'first_response', + user_id: user_id, + created_at: year_range + ) + .average(:value) + + avg_time&.to_f + end +end diff --git a/app/controllers/api/v1/accounts/automation_rules_controller.rb b/app/controllers/api/v1/accounts/automation_rules_controller.rb index 05d3cb1ab..0840d0eea 100644 --- a/app/controllers/api/v1/accounts/automation_rules_controller.rb +++ b/app/controllers/api/v1/accounts/automation_rules_controller.rb @@ -1,4 +1,6 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseController + include AttachmentConcern + before_action :check_authorization before_action :fetch_automation_rule, only: [:show, :update, :destroy, :clone] @@ -9,24 +11,32 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont def show; end def create + blobs, actions, error = validate_and_prepare_attachments(params[:actions]) + return render_could_not_create_error(error) if error + @automation_rule = Current.account.automation_rules.new(automation_rules_permit) - @automation_rule.actions = params[:actions] + @automation_rule.actions = actions @automation_rule.conditions = params[:conditions] - render json: { error: @automation_rule.errors.messages }, status: :unprocessable_entity and return unless @automation_rule.valid? + return render_could_not_create_error(@automation_rule.errors.messages) unless @automation_rule.valid? @automation_rule.save! - process_attachments - @automation_rule + blobs.each { |blob| @automation_rule.files.attach(blob) } end def update + blobs, actions, error = validate_and_prepare_attachments(params[:actions], @automation_rule) + return render_could_not_create_error(error) if error + ActiveRecord::Base.transaction do - automation_rule_update - process_attachments + @automation_rule.assign_attributes(automation_rules_permit) + @automation_rule.actions = actions if params[:actions] + @automation_rule.conditions = params[:conditions] if params[:conditions] + @automation_rule.save! + blobs.each { |blob| @automation_rule.files.attach(blob) } rescue StandardError => e Rails.logger.error e - render json: { error: @automation_rule.errors.messages }.to_json, status: :unprocessable_entity + render_could_not_create_error(@automation_rule.errors.messages) end end @@ -42,29 +52,11 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont @automation_rule = new_rule end - def process_attachments - actions = @automation_rule.actions.filter_map { |k, _v| k if k['action_name'] == 'send_attachment' } - return if actions.blank? - - actions.each do |action| - blob_id = action['action_params'] - blob = ActiveStorage::Blob.find_by(id: blob_id) - @automation_rule.files.attach(blob) - end - end - private - def automation_rule_update - @automation_rule.update!(automation_rules_permit) - @automation_rule.actions = params[:actions] if params[:actions] - @automation_rule.conditions = params[:conditions] if params[:conditions] - @automation_rule.save! - end - def automation_rules_permit params.permit( - :name, :description, :event_name, :account_id, :active, + :name, :description, :event_name, :active, conditions: [:attribute_key, :filter_operator, :query_operator, :custom_attribute_type, { values: [] }], actions: [:action_name, { action_params: [] }] ) diff --git a/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb b/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb index 58ec3bfca..f3b14d49f 100644 --- a/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb +++ b/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb @@ -64,7 +64,7 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts: def permitted_params params.require(:twilio_channel).permit( - :account_id, :messaging_service_sid, :phone_number, :account_sid, :auth_token, :name, :medium, :api_key_sid + :messaging_service_sid, :phone_number, :account_sid, :auth_token, :name, :medium, :api_key_sid ) end end diff --git a/app/controllers/api/v1/accounts/inbox_csat_templates_controller.rb b/app/controllers/api/v1/accounts/inbox_csat_templates_controller.rb new file mode 100644 index 000000000..d17fe35fb --- /dev/null +++ b/app/controllers/api/v1/accounts/inbox_csat_templates_controller.rb @@ -0,0 +1,160 @@ +class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseController + DEFAULT_BUTTON_TEXT = 'Please rate us'.freeze + DEFAULT_LANGUAGE = 'en'.freeze + + before_action :fetch_inbox + before_action :validate_whatsapp_channel + + def show + template = @inbox.csat_config&.dig('template') + return render json: { template_exists: false } unless template + + template_name = template['name'] || Whatsapp::CsatTemplateNameService.csat_template_name(@inbox.id) + status_result = @inbox.channel.provider_service.get_template_status(template_name) + + render_template_status_response(status_result, template_name) + rescue StandardError => e + Rails.logger.error "Error fetching CSAT template status: #{e.message}" + render json: { error: e.message }, status: :internal_server_error + end + + def create + template_params = extract_template_params + return render_missing_message_error if template_params[:message].blank? + + # Delete existing template even though we are using a new one. + # We don't want too many templates in the business portfolio, but the create operation shouldn't fail if deletion fails. + delete_existing_template_if_needed + + result = create_template_via_provider(template_params) + render_template_creation_result(result) + rescue ActionController::ParameterMissing + render json: { error: 'Template parameters are required' }, status: :unprocessable_entity + rescue StandardError => e + Rails.logger.error "Error creating CSAT template: #{e.message}" + render json: { error: 'Template creation failed' }, status: :internal_server_error + end + + private + + def fetch_inbox + @inbox = Current.account.inboxes.find(params[:inbox_id]) + authorize @inbox, :show? + end + + def validate_whatsapp_channel + return if @inbox.whatsapp? + + render json: { error: 'CSAT template operations only available for WhatsApp channels' }, + status: :bad_request + end + + def extract_template_params + params.require(:template).permit(:message, :button_text, :language) + end + + def render_missing_message_error + render json: { error: 'Message is required' }, status: :unprocessable_entity + end + + def create_template_via_provider(template_params) + template_config = { + message: template_params[:message], + button_text: template_params[:button_text] || DEFAULT_BUTTON_TEXT, + base_url: ENV.fetch('FRONTEND_URL', 'http://localhost:3000'), + language: template_params[:language] || DEFAULT_LANGUAGE, + template_name: Whatsapp::CsatTemplateNameService.csat_template_name(@inbox.id) + } + + @inbox.channel.provider_service.create_csat_template(template_config) + end + + def render_template_creation_result(result) + if result[:success] + render_successful_template_creation(result) + else + render_failed_template_creation(result) + end + end + + def render_successful_template_creation(result) + render json: { + template: { + name: result[:template_name], + template_id: result[:template_id], + status: 'PENDING', + language: result[:language] || DEFAULT_LANGUAGE + } + }, status: :created + end + + def render_failed_template_creation(result) + whatsapp_error = parse_whatsapp_error(result[:response_body]) + error_message = whatsapp_error[:user_message] || result[:error] + + render json: { + error: error_message, + details: whatsapp_error[:technical_details] + }, status: :unprocessable_entity + end + + def delete_existing_template_if_needed + template = @inbox.csat_config&.dig('template') + return true if template.blank? + + template_name = template['name'] + return true if template_name.blank? + + template_status = @inbox.channel.provider_service.get_template_status(template_name) + return true unless template_status[:success] + + deletion_result = @inbox.channel.provider_service.delete_csat_template(template_name) + if deletion_result[:success] + Rails.logger.info "Deleted existing CSAT template '#{template_name}' for inbox #{@inbox.id}" + true + else + Rails.logger.warn "Failed to delete existing CSAT template '#{template_name}' for inbox #{@inbox.id}: #{deletion_result[:response_body]}" + false + end + rescue StandardError => e + Rails.logger.error "Error during template deletion for inbox #{@inbox.id}: #{e.message}" + false + end + + def render_template_status_response(status_result, template_name) + if status_result[:success] + render json: { + template_exists: true, + template_name: template_name, + status: status_result[:template][:status], + template_id: status_result[:template][:id] + } + else + render json: { + template_exists: false, + error: 'Template not found' + } + end + end + + def parse_whatsapp_error(response_body) + return { user_message: nil, technical_details: nil } if response_body.blank? + + begin + error_data = JSON.parse(response_body) + whatsapp_error = error_data['error'] || {} + + user_message = whatsapp_error['error_user_msg'] || whatsapp_error['message'] + technical_details = { + code: whatsapp_error['code'], + subcode: whatsapp_error['error_subcode'], + type: whatsapp_error['type'], + title: whatsapp_error['error_user_title'] + }.compact + + { user_message: user_message, technical_details: technical_details } + rescue JSON::ParserError + { user_message: nil, technical_details: response_body } + end + end +end diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb index cfbac1e73..4ca58eeed 100644 --- a/app/controllers/api/v1/accounts/inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/inboxes_controller.rb @@ -190,31 +190,37 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController # end def format_csat_config(config) - { - display_type: config['display_type'] || 'emoji', - message: config['message'] || '', - survey_rules: { - operator: config.dig('survey_rules', 'operator') || 'contains', - values: config.dig('survey_rules', 'values') || [] - } + formatted = { + 'display_type' => config['display_type'] || 'emoji', + 'message' => config['message'] || '', + :survey_rules => { + 'operator' => config.dig('survey_rules', 'operator') || 'contains', + 'values' => config.dig('survey_rules', 'values') || [] + }, + 'button_text' => config['button_text'] || 'Please rate us', + 'language' => config['language'] || 'en' } + format_template_config(config, formatted) + formatted + end + + def format_template_config(config, formatted) + formatted['template'] = config['template'] if config['template'].present? end def inbox_attributes [:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled, :enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved, :lock_to_single_conversation, :portal_id, :sender_name_type, :business_name, - { csat_config: [:display_type, :message, { survey_rules: [:operator, { values: [] }] }] }] + { csat_config: [:display_type, :message, :button_text, :language, + { survey_rules: [:operator, { values: [] }], + template: [:name, :template_id, :created_at, :language] }] }] end def permitted_params(channel_attributes = []) # We will remove this line after fixing https://linear.app/chatwoot/issue/CW-1567/null-value-passed-as-null-string-to-backend params.each { |k, v| params[k] = params[k] == 'null' ? nil : v } - - params.permit( - *inbox_attributes, - channel: [:type, *channel_attributes] - ) + params.permit(*inbox_attributes, channel: [:type, *channel_attributes]) end def channel_type_from_params @@ -230,11 +236,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController # end def get_channel_attributes(channel_type) - if channel_type.constantize.const_defined?(:EDITABLE_ATTRS) - channel_type.constantize::EDITABLE_ATTRS.presence - else - [] - end + channel_type.constantize.const_defined?(:EDITABLE_ATTRS) ? channel_type.constantize::EDITABLE_ATTRS.presence : [] end def whatsapp_channel? diff --git a/app/controllers/api/v1/accounts/macros_controller.rb b/app/controllers/api/v1/accounts/macros_controller.rb index 5dcdd2023..c4e0cd6dd 100644 --- a/app/controllers/api/v1/accounts/macros_controller.rb +++ b/app/controllers/api/v1/accounts/macros_controller.rb @@ -1,4 +1,6 @@ class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController + include AttachmentConcern + before_action :fetch_macro, only: [:show, :update, :destroy, :execute] before_action :check_authorization, only: [:show, :update, :destroy, :execute] @@ -11,26 +13,32 @@ class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController end def create + blobs, actions, error = validate_and_prepare_attachments(params[:actions]) + return render_could_not_create_error(error) if error + @macro = Current.account.macros.new(macros_with_user.merge(created_by_id: current_user.id)) @macro.set_visibility(current_user, permitted_params) - @macro.actions = params[:actions] + @macro.actions = actions - render json: { error: @macro.errors.messages }, status: :unprocessable_entity and return unless @macro.valid? + return render_could_not_create_error(@macro.errors.messages) unless @macro.valid? @macro.save! - process_attachments - @macro + blobs.each { |blob| @macro.files.attach(blob) } end def update + blobs, actions, error = validate_and_prepare_attachments(params[:actions], @macro) + return render_could_not_create_error(error) if error + ActiveRecord::Base.transaction do - @macro.update!(macros_with_user) + @macro.assign_attributes(macros_with_user) @macro.set_visibility(current_user, permitted_params) - process_attachments + @macro.actions = actions if params[:actions] @macro.save! + blobs.each { |blob| @macro.files.attach(blob) } rescue StandardError => e Rails.logger.error e - render json: { error: @macro.errors.messages }.to_json, status: :unprocessable_entity + render_could_not_create_error(@macro.errors.messages) end end @@ -47,20 +55,9 @@ class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController private - def process_attachments - actions = @macro.actions.filter_map { |k, _v| k if k['action_name'] == 'send_attachment' } - return if actions.blank? - - actions.each do |action| - blob_id = action['action_params'] - blob = ActiveStorage::Blob.find_by(id: blob_id) - @macro.files.attach(blob) - end - end - def permitted_params params.permit( - :name, :account_id, :visibility, + :name, :visibility, actions: [:action_name, { action_params: [] }] ) end diff --git a/app/controllers/api/v1/accounts/portals_controller.rb b/app/controllers/api/v1/accounts/portals_controller.rb index 276f2fce5..7f43766d2 100644 --- a/app/controllers/api/v1/accounts/portals_controller.rb +++ b/app/controllers/api/v1/accounts/portals_controller.rb @@ -62,7 +62,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController def process_attached_logo blob_id = params[:blob_id] - blob = ActiveStorage::Blob.find_by(id: blob_id) + blob = ActiveStorage::Blob.find_signed(blob_id) @portal.logo.attach(blob) end @@ -78,7 +78,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController def portal_params params.require(:portal).permit( - :id, :account_id, :color, :custom_domain, :header_text, :homepage_link, + :id, :color, :custom_domain, :header_text, :homepage_link, :name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }] } ) end diff --git a/app/controllers/api/v1/accounts/tiktok/authorizations_controller.rb b/app/controllers/api/v1/accounts/tiktok/authorizations_controller.rb new file mode 100644 index 000000000..7c7320393 --- /dev/null +++ b/app/controllers/api/v1/accounts/tiktok/authorizations_controller.rb @@ -0,0 +1,15 @@ +class Api::V1::Accounts::Tiktok::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController + include Tiktok::IntegrationHelper + + def create + redirect_url = Tiktok::AuthClient.authorize_url( + state: generate_tiktok_token(Current.account.id) + ) + + if redirect_url + render json: { success: true, url: redirect_url } + else + render json: { success: false }, status: :unprocessable_entity + end + end +end diff --git a/app/controllers/api/v1/accounts/upload_controller.rb b/app/controllers/api/v1/accounts/upload_controller.rb index 6530279da..479d8ae1b 100644 --- a/app/controllers/api/v1/accounts/upload_controller.rb +++ b/app/controllers/api/v1/accounts/upload_controller.rb @@ -59,7 +59,7 @@ class Api::V1::Accounts::UploadController < Api::V1::Accounts::BaseController end def render_success(file_blob) - render json: { file_url: url_for(file_blob), blob_key: file_blob.key, blob_id: file_blob.id } + render json: { file_url: url_for(file_blob), blob_id: file_blob.signed_id } end def render_error(message, status) diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index 773126755..57062a5b2 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -92,7 +92,8 @@ class Api::V1::AccountsController < Api::BaseController end def settings_params - params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label) + params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label, + conversation_required_attributes: []) end def check_signup_enabled diff --git a/app/controllers/api/v2/accounts/year_in_reviews_controller.rb b/app/controllers/api/v2/accounts/year_in_reviews_controller.rb new file mode 100644 index 000000000..d17b5dc13 --- /dev/null +++ b/app/controllers/api/v2/accounts/year_in_reviews_controller.rb @@ -0,0 +1,26 @@ +class Api::V2::Accounts::YearInReviewsController < Api::V1::Accounts::BaseController + def show + year = params[:year] || 2025 + cache_key = "year_in_review_#{Current.account.id}_#{year}" + + cached_data = Current.user.ui_settings&.dig(cache_key) + + if cached_data.present? + render json: cached_data + else + builder = YearInReviewBuilder.new( + account: Current.account, + user_id: Current.user.id, + year: year + ) + + data = builder.build + + ui_settings = Current.user.ui_settings || {} + ui_settings[cache_key] = data + Current.user.update!(ui_settings: ui_settings) + + render json: data + end + end +end diff --git a/app/controllers/concerns/attachment_concern.rb b/app/controllers/concerns/attachment_concern.rb new file mode 100644 index 000000000..2652f04be --- /dev/null +++ b/app/controllers/concerns/attachment_concern.rb @@ -0,0 +1,35 @@ +module AttachmentConcern + extend ActiveSupport::Concern + + def validate_and_prepare_attachments(actions, record = nil) + blobs = [] + return [blobs, actions, nil] if actions.blank? + + sanitized = actions.map do |action| + next action unless action[:action_name] == 'send_attachment' + + result = process_attachment_action(action, record, blobs) + return [nil, nil, I18n.t('errors.attachments.invalid')] unless result + + result + end + + [blobs, sanitized, nil] + end + + private + + def process_attachment_action(action, record, blobs) + blob_id = action[:action_params].first + blob = ActiveStorage::Blob.find_signed(blob_id.to_s) + + return action.merge(action_params: [blob.id]).tap { blobs << blob } if blob.present? + return action if blob_already_attached?(record, blob_id) + + nil + end + + def blob_already_attached?(record, blob_id) + record&.files&.any? { |f| f.blob_id == blob_id.to_i } + end +end diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index 5003c4c70..abf42517c 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -73,6 +73,7 @@ class DashboardController < ActionController::Base ENABLE_ACCOUNT_SIGNUP: GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false'), FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''), INSTAGRAM_APP_ID: GlobalConfigService.load('INSTAGRAM_APP_ID', ''), + TIKTOK_APP_ID: GlobalConfigService.load('TIKTOK_APP_ID', ''), FACEBOOK_API_VERSION: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v18.0'), WHATSAPP_APP_ID: GlobalConfigService.load('WHATSAPP_APP_ID', ''), WHATSAPP_CONFIGURATION_ID: GlobalConfigService.load('WHATSAPP_CONFIGURATION_ID', ''), diff --git a/app/controllers/super_admin/app_configs_controller.rb b/app/controllers/super_admin/app_configs_controller.rb index 42a37733c..20ac5f6e6 100644 --- a/app/controllers/super_admin/app_configs_controller.rb +++ b/app/controllers/super_admin/app_configs_controller.rb @@ -46,6 +46,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController 'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET], 'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET], 'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT], + 'tiktok' => %w[TIKTOK_APP_ID TIKTOK_APP_SECRET], 'whatsapp_embedded' => %w[WHATSAPP_APP_ID WHATSAPP_APP_SECRET WHATSAPP_CONFIGURATION_ID WHATSAPP_API_VERSION], 'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET], 'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI ENABLE_GOOGLE_OAUTH_LOGIN] diff --git a/app/controllers/tiktok/callbacks_controller.rb b/app/controllers/tiktok/callbacks_controller.rb new file mode 100644 index 000000000..e484905c3 --- /dev/null +++ b/app/controllers/tiktok/callbacks_controller.rb @@ -0,0 +1,144 @@ +class Tiktok::CallbacksController < ApplicationController + include Tiktok::IntegrationHelper + + def show + return handle_authorization_error if params[:error].present? + return handle_ungranted_scopes_error unless all_scopes_granted? + + process_successful_authorization + rescue StandardError => e + handle_error(e) + end + + private + + def all_scopes_granted? + granted_scopes = short_term_access_token[:scope].to_s.split(',') + (Tiktok::AuthClient::REQUIRED_SCOPES - granted_scopes).blank? + end + + def process_successful_authorization + inbox, already_exists = find_or_create_inbox + + if already_exists + redirect_to app_tiktok_inbox_settings_url(account_id: account_id, inbox_id: inbox.id) + else + redirect_to app_tiktok_inbox_agents_url(account_id: account_id, inbox_id: inbox.id) + end + end + + def handle_error(error) + Rails.logger.error("TikTok Channel creation Error: #{error.message}") + ChatwootExceptionTracker.new(error).capture_exception + + redirect_to_error_page(error_type: error.class.name, code: 500, error_message: error.message) + end + + # Handles the case when a user denies permissions or cancels the authorization flow + def handle_authorization_error + redirect_to_error_page( + error_type: params[:error] || 'access_denied', + code: params[:error_code], + error_message: params[:error_description] || 'User cancelled the Authorization' + ) + end + + # Handles the case when a user partially accepted the required scopes + def handle_ungranted_scopes_error + redirect_to_error_page( + error_type: 'ungranted_scopes', + code: 400, + error_message: 'User did not grant all the required scopes' + ) + end + + # Centralized method to redirect to error page with appropriate parameters + # This ensures consistent error handling across different error scenarios + # Frontend will handle the error page based on the error_type + def redirect_to_error_page(error_type:, code:, error_message:) + redirect_to app_new_tiktok_inbox_url( + account_id: account_id, + error_type: error_type, + code: code, + error_message: error_message + ) + end + + def find_or_create_inbox + business_details = tiktok_client.business_account_details + channel_tiktok = find_channel + channel_exists = channel_tiktok.present? + + if channel_tiktok + update_channel(channel_tiktok, business_details) + else + channel_tiktok = create_channel_with_inbox(business_details) + end + + # reauthorized will also update cache keys for the associated inbox + channel_tiktok.reauthorized! + + set_avatar(channel_tiktok.inbox, business_details[:profile_image]) if business_details[:profile_image].present? + + [channel_tiktok.inbox, channel_exists] + end + + def create_channel_with_inbox(business_details) + ActiveRecord::Base.transaction do + channel_tiktok = Channel::Tiktok.create!( + account: account, + business_id: short_term_access_token[:business_id], + access_token: short_term_access_token[:access_token], + refresh_token: short_term_access_token[:refresh_token], + expires_at: short_term_access_token[:expires_at], + refresh_token_expires_at: short_term_access_token[:refresh_token_expires_at] + ) + + account.inboxes.create!( + account: account, + channel: channel_tiktok, + name: business_details[:display_name].presence || business_details[:username] + ) + + channel_tiktok + end + end + + def find_channel + Channel::Tiktok.find_by(business_id: short_term_access_token[:business_id], account: account) + end + + def update_channel(channel_tiktok, business_details) + channel_tiktok.update!( + access_token: short_term_access_token[:access_token], + refresh_token: short_term_access_token[:refresh_token], + expires_at: short_term_access_token[:expires_at], + refresh_token_expires_at: short_term_access_token[:refresh_token_expires_at] + ) + + channel_tiktok.inbox.update!(name: business_details[:display_name].presence || business_details[:username]) + end + + def set_avatar(inbox, avatar_url) + Avatar::AvatarFromUrlJob.perform_later(inbox, avatar_url) + end + + def account_id + @account_id ||= verify_tiktok_token(params[:state]) + end + + def account + @account ||= Account.find(account_id) + end + + def short_term_access_token + @short_term_access_token ||= Tiktok::AuthClient.obtain_short_term_access_token(params[:code]) + end + + def tiktok_client + @tiktok_client ||= Tiktok::Client.new( + business_id: short_term_access_token[:business_id], + access_token: short_term_access_token[:access_token] + ) + end +end diff --git a/app/controllers/webhooks/tiktok_controller.rb b/app/controllers/webhooks/tiktok_controller.rb new file mode 100644 index 000000000..efaa1830c --- /dev/null +++ b/app/controllers/webhooks/tiktok_controller.rb @@ -0,0 +1,53 @@ +class Webhooks::TiktokController < ActionController::API + before_action :verify_signature! + + def events + event = JSON.parse(request_payload) + if echo_event? + # Add delay to prevent race condition where echo arrives before send message API completes + # This avoids duplicate messages when echo comes early during API processing + ::Webhooks::TiktokEventsJob.set(wait: 2.seconds).perform_later(event) + else + ::Webhooks::TiktokEventsJob.perform_later(event) + end + + head :ok + end + + private + + def request_payload + @request_payload ||= request.body.read + end + + def verify_signature! + signature_header = request.headers['Tiktok-Signature'] + client_secret = GlobalConfigService.load('TIKTOK_APP_SECRET', nil) + received_timestamp, received_signature = extract_signature_parts(signature_header) + + return head :unauthorized unless client_secret && received_timestamp && received_signature + + signature_payload = "#{received_timestamp}.#{request_payload}" + computed_signature = OpenSSL::HMAC.hexdigest('SHA256', client_secret, signature_payload) + + return head :unauthorized unless ActiveSupport::SecurityUtils.secure_compare(computed_signature, received_signature) + + # Check timestamp delay (acceptable delay: 5 seconds) + current_timestamp = Time.current.to_i + delay = current_timestamp - received_timestamp + + return head :unauthorized if delay > 5 + end + + def extract_signature_parts(signature_header) + return [nil, nil] if signature_header.blank? + + keys = signature_header.split(',') + signature_parts = keys.map { |part| part.split('=') }.to_h + [signature_parts['t']&.to_i, signature_parts['s']] + end + + def echo_event? + params[:event] == 'im_send_msg' + end +end diff --git a/app/helpers/super_admin/features.yml b/app/helpers/super_admin/features.yml index b05c603cd..34c7a8138 100644 --- a/app/helpers/super_admin/features.yml +++ b/app/helpers/super_admin/features.yml @@ -78,6 +78,12 @@ instagram: enabled: true icon: 'icon-instagram' config_key: 'instagram' +tiktok: + name: 'TikTok' + description: 'Stay connected with your customers on TikTok' + enabled: true + icon: 'icon-tiktok' + config_key: 'tiktok' whatsapp: name: 'WhatsApp' description: 'Manage your WhatsApp business interactions from Chatwoot.' diff --git a/app/helpers/tiktok/integration_helper.rb b/app/helpers/tiktok/integration_helper.rb new file mode 100644 index 000000000..b2de4a092 --- /dev/null +++ b/app/helpers/tiktok/integration_helper.rb @@ -0,0 +1,47 @@ +module Tiktok::IntegrationHelper + # Generates a signed JWT token for Tiktok integration + # + # @param account_id [Integer] The account ID to encode in the token + # @return [String, nil] The encoded JWT token or nil if client secret is missing + def generate_tiktok_token(account_id) + return if client_secret.blank? + + JWT.encode(token_payload(account_id), client_secret, 'HS256') + rescue StandardError => e + Rails.logger.error("Failed to generate TikTok token: #{e.message}") + nil + end + + # Verifies and decodes a Tiktok JWT token + # + # @param token [String] The JWT token to verify + # @return [Integer, nil] The account ID from the token or nil if invalid + def verify_tiktok_token(token) + return if token.blank? || client_secret.blank? + + decode_token(token, client_secret) + end + + private + + def client_secret + @client_secret ||= GlobalConfigService.load('TIKTOK_APP_SECRET', nil) + end + + def token_payload(account_id) + { + sub: account_id, + iat: Time.current.to_i + } + end + + def decode_token(token, secret) + JWT.decode(token, secret, true, { + algorithm: 'HS256', + verify_expiration: true + }).first['sub'] + rescue StandardError => e + Rails.logger.error("Unexpected error verifying Tiktok token: #{e.message}") + nil + end +end diff --git a/app/javascript/dashboard/api/channel/tiktokClient.js b/app/javascript/dashboard/api/channel/tiktokClient.js new file mode 100644 index 000000000..389eb2699 --- /dev/null +++ b/app/javascript/dashboard/api/channel/tiktokClient.js @@ -0,0 +1,14 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +class TiktokChannel extends ApiClient { + constructor() { + super('tiktok', { accountScoped: true }); + } + + generateAuthorization(payload) { + return axios.post(`${this.url}/authorization`, payload); + } +} + +export default new TiktokChannel(); diff --git a/app/javascript/dashboard/api/channel/voice/twilioVoiceClient.js b/app/javascript/dashboard/api/channel/voice/twilioVoiceClient.js new file mode 100644 index 000000000..67f74a171 --- /dev/null +++ b/app/javascript/dashboard/api/channel/voice/twilioVoiceClient.js @@ -0,0 +1,95 @@ +import { Device } from '@twilio/voice-sdk'; +import VoiceAPI from './voiceAPIClient'; + +const createCallDisconnectedEvent = () => new CustomEvent('call:disconnected'); + +class TwilioVoiceClient extends EventTarget { + constructor() { + super(); + this.device = null; + this.activeConnection = null; + this.initialized = false; + this.inboxId = null; + } + + async initializeDevice(inboxId) { + this.destroyDevice(); + + const response = await VoiceAPI.getToken(inboxId); + const { token, account_id } = response || {}; + if (!token) throw new Error('Invalid token'); + + this.device = new Device(token, { + allowIncomingWhileBusy: true, + disableAudioContextSounds: true, + appParams: { account_id }, + }); + + this.device.removeAllListeners(); + this.device.on('connect', conn => { + this.activeConnection = conn; + conn.on('disconnect', this.onDisconnect); + }); + + this.device.on('disconnect', this.onDisconnect); + + this.device.on('tokenWillExpire', async () => { + const r = await VoiceAPI.getToken(this.inboxId); + if (r?.token) this.device.updateToken(r.token); + }); + + this.initialized = true; + this.inboxId = inboxId; + + return this.device; + } + + get hasActiveConnection() { + return !!this.activeConnection; + } + + endClientCall() { + if (this.activeConnection) { + this.activeConnection.disconnect(); + } + this.activeConnection = null; + if (this.device) { + this.device.disconnectAll(); + } + } + + destroyDevice() { + if (this.device) { + this.device.destroy(); + } + this.activeConnection = null; + this.device = null; + this.initialized = false; + this.inboxId = null; + } + + async joinClientCall({ to, conversationId }) { + if (!this.device || !this.initialized || !to) return null; + if (this.activeConnection) return this.activeConnection; + + const params = { + To: to, + is_agent: 'true', + conversation_id: conversationId, + }; + + const connection = await this.device.connect({ params }); + this.activeConnection = connection; + + connection.on('disconnect', this.onDisconnect); + + return connection; + } + + onDisconnect = () => { + this.activeConnection = null; + this.dispatchEvent(createCallDisconnectedEvent()); + }; +} + +export default new TwilioVoiceClient(); diff --git a/app/javascript/dashboard/api/channel/voice/voiceAPIClient.js b/app/javascript/dashboard/api/channel/voice/voiceAPIClient.js new file mode 100644 index 000000000..6e1e548c8 --- /dev/null +++ b/app/javascript/dashboard/api/channel/voice/voiceAPIClient.js @@ -0,0 +1,40 @@ +/* global axios */ +import ApiClient from '../../ApiClient'; +import ContactsAPI from '../../contacts'; + +class VoiceAPI extends ApiClient { + constructor() { + super('voice', { accountScoped: true }); + } + + // eslint-disable-next-line class-methods-use-this + initiateCall(contactId, inboxId) { + return ContactsAPI.initiateCall(contactId, inboxId).then(r => r.data); + } + + leaveConference(inboxId, conversationId) { + return axios + .delete(`${this.baseUrl()}/inboxes/${inboxId}/conference`, { + params: { conversation_id: conversationId }, + }) + .then(r => r.data); + } + + joinConference({ conversationId, inboxId, callSid }) { + return axios + .post(`${this.baseUrl()}/inboxes/${inboxId}/conference`, { + conversation_id: conversationId, + call_sid: callSid, + }) + .then(r => r.data); + } + + getToken(inboxId) { + if (!inboxId) return Promise.reject(new Error('Inbox ID is required')); + return axios + .get(`${this.baseUrl()}/inboxes/${inboxId}/conference/token`) + .then(r => r.data); + } +} + +export default new VoiceAPI(); diff --git a/app/javascript/dashboard/api/contacts.js b/app/javascript/dashboard/api/contacts.js index 025df2122..1e76ac987 100644 --- a/app/javascript/dashboard/api/contacts.js +++ b/app/javascript/dashboard/api/contacts.js @@ -47,6 +47,12 @@ class ContactAPI extends ApiClient { return axios.get(`${this.url}/${contactId}/labels`); } + initiateCall(contactId, inboxId) { + return axios.post(`${this.url}/${contactId}/call`, { + inbox_id: inboxId, + }); + } + updateContactLabels(contactId, labels) { return axios.post(`${this.url}/${contactId}/labels`, { labels }); } diff --git a/app/javascript/dashboard/api/enterprise/account.js b/app/javascript/dashboard/api/enterprise/account.js index 3f12dc007..9e6d40a62 100644 --- a/app/javascript/dashboard/api/enterprise/account.js +++ b/app/javascript/dashboard/api/enterprise/account.js @@ -23,6 +23,10 @@ class EnterpriseAccountAPI extends ApiClient { action_type: action, }); } + + createTopupCheckout(credits) { + return axios.post(`${this.url}topup_checkout`, { credits }); + } } export default new EnterpriseAccountAPI(); diff --git a/app/javascript/dashboard/api/enterprise/specs/account.spec.js b/app/javascript/dashboard/api/enterprise/specs/account.spec.js index 9c65b0b67..47d2eb26d 100644 --- a/app/javascript/dashboard/api/enterprise/specs/account.spec.js +++ b/app/javascript/dashboard/api/enterprise/specs/account.spec.js @@ -11,6 +11,8 @@ describe('#enterpriseAccountAPI', () => { expect(accountAPI).toHaveProperty('delete'); expect(accountAPI).toHaveProperty('checkout'); expect(accountAPI).toHaveProperty('toggleDeletion'); + expect(accountAPI).toHaveProperty('createTopupCheckout'); + expect(accountAPI).toHaveProperty('getLimits'); }); describe('API calls', () => { @@ -59,5 +61,29 @@ describe('#enterpriseAccountAPI', () => { { action_type: 'undelete' } ); }); + + it('#createTopupCheckout with credits', () => { + accountAPI.createTopupCheckout(1000); + expect(axiosMock.post).toHaveBeenCalledWith( + '/enterprise/api/v1/topup_checkout', + { credits: 1000 } + ); + }); + + it('#createTopupCheckout with different credit amounts', () => { + const creditAmounts = [1000, 2500, 6000, 12000]; + creditAmounts.forEach(credits => { + accountAPI.createTopupCheckout(credits); + expect(axiosMock.post).toHaveBeenCalledWith( + '/enterprise/api/v1/topup_checkout', + { credits } + ); + }); + }); + + it('#getLimits', () => { + accountAPI.getLimits(); + expect(axiosMock.get).toHaveBeenCalledWith('/enterprise/api/v1/limits'); + }); }); }); diff --git a/app/javascript/dashboard/api/integrations/openapi.js b/app/javascript/dashboard/api/integrations/openapi.js index ad203a14c..3fcf241ee 100644 --- a/app/javascript/dashboard/api/integrations/openapi.js +++ b/app/javascript/dashboard/api/integrations/openapi.js @@ -57,6 +57,12 @@ class OpenAIAPI extends ApiClient { content, }; + // Always include conversation_display_id when available for session tracking + if (conversationId) { + data.conversation_display_id = conversationId; + } + + // For conversation-level events, only send conversation_display_id if (this.conversation_events.includes(type)) { data = { conversation_display_id: conversationId, diff --git a/app/javascript/dashboard/api/specs/tiktokClient.spec.js b/app/javascript/dashboard/api/specs/tiktokClient.spec.js new file mode 100644 index 000000000..5250e2c7b --- /dev/null +++ b/app/javascript/dashboard/api/specs/tiktokClient.spec.js @@ -0,0 +1,35 @@ +import ApiClient from '../ApiClient'; +import tiktokClient from '../channel/tiktokClient'; + +describe('#TiktokClient', () => { + it('creates correct instance', () => { + expect(tiktokClient).toBeInstanceOf(ApiClient); + expect(tiktokClient).toHaveProperty('generateAuthorization'); + }); + + describe('#generateAuthorization', () => { + const originalAxios = window.axios; + const originalPathname = window.location.pathname; + const axiosMock = { + post: vi.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + window.history.pushState({}, '', '/app/accounts/1/settings'); + }); + + afterEach(() => { + window.axios = originalAxios; + window.history.pushState({}, '', originalPathname); + }); + + it('posts to the authorization endpoint', () => { + tiktokClient.generateAuthorization({ state: 'test-state' }); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/accounts/1/tiktok/authorization', + { state: 'test-state' } + ); + }); + }); +}); diff --git a/app/javascript/dashboard/api/yearInReview.js b/app/javascript/dashboard/api/yearInReview.js new file mode 100644 index 000000000..fb0661804 --- /dev/null +++ b/app/javascript/dashboard/api/yearInReview.js @@ -0,0 +1,16 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +class YearInReviewAPI extends ApiClient { + constructor() { + super('year_in_review', { accountScoped: true, apiVersion: 'v2' }); + } + + get(year) { + return axios.get(`${this.url}`, { + params: { year }, + }); + } +} + +export default new YearInReviewAPI(); diff --git a/app/javascript/dashboard/components-next/Companies/CompaniesCard/CompaniesCard.vue b/app/javascript/dashboard/components-next/Companies/CompaniesCard/CompaniesCard.vue index d5f191733..4603521eb 100644 --- a/app/javascript/dashboard/components-next/Companies/CompaniesCard/CompaniesCard.vue +++ b/app/javascript/dashboard/components-next/Companies/CompaniesCard/CompaniesCard.vue @@ -79,7 +79,7 @@ const formattedUpdatedAt = computed(() => { class="inline-flex items-center gap-1.5 text-sm text-n-slate-11 truncate" > - {{ t('COMPANIES.CONTACTS_COUNT', { count: contactsCount }) }} + {{ t('COMPANIES.CONTACTS_COUNT', { n: contactsCount }) }} { -