Compare commits

..
Author SHA1 Message Date
Muhsin KelothandGitHub 9b32748c8b Merge branch 'develop' into fix/error_reporting 2025-09-25 13:46:34 +05:30
Muhsin KelothandGitHub 0403361a36 Merge branch 'develop' into fix/error_reporting 2025-03-26 09:30:29 +05:30
Vishnu Narayanan 9bd93f970c fix: specs 2025-03-25 17:19:00 +05:30
Vishnu Narayanan 2e6142c4c3 fix: refactor 2025-03-25 01:13:17 +05:30
Vishnu Narayanan f32daa96ab fix: improve apm error reporting
Previously, errors were only being logged with logger.info and rendered
as JSON responses, but weren't being properly reported to APM services.
This meant that critical errors like 500s were only visible in application
logs but not in our monitoring systems, making it harder to track and debug
issues in production.

This commit ensures that all errors are properly reported to configured
APM services by:
1. Adding explicit APM error reporting alongside logging
2. Using proper APM reporting methods for each service
3. Maintaining consistent error handling across all APM integrations

These changes ensure that errors are properly captured in our monitoring
systems,improving our ability to track and debug production issues.
2025-03-25 00:36:40 +05:30
5354 changed files with 87432 additions and 462423 deletions
-65
View File
@@ -1,65 +0,0 @@
---
:position: before
:position_in_additional_file_patterns: before
:position_in_class: before
:position_in_factory: before
:position_in_fixture: before
:position_in_routes: before
:position_in_serializer: before
:position_in_test: before
:classified_sort: true
:exclude_controllers: true
:exclude_factories: true
:exclude_fixtures: true
:exclude_helpers: true
:exclude_scaffolds: true
:exclude_serializers: true
:exclude_sti_subclasses: false
:exclude_tests: true
:force: false
:format_markdown: false
:format_rdoc: false
:format_yard: false
:frozen: false
:grouped_polymorphic: false
:ignore_model_sub_dir: false
:ignore_unknown_models: false
:include_version: false
:show_check_constraints: false
:show_complete_foreign_keys: false
:show_foreign_keys: true
:show_indexes: true
:show_indexes_include: false
:simple_indexes: false
:sort: false
:timestamp: false
:trace: false
:with_comment: true
:with_column_comments: true
:with_table_comments: true
:position_of_column_comment: :with_name
:active_admin: false
:command:
:debug: false
:hide_default_column_types: json,jsonb,hstore
:hide_limit_column_types: integer,bigint,boolean
:timestamp_columns:
- created_at
- updated_at
:ignore_columns:
:ignore_routes:
:models: true
:routes: false
:skip_on_db_migrate: false
:target_action: :do_annotations
:wrapper:
:wrapper_close:
:wrapper_open:
:classes_default_to_s: []
:additional_file_patterns: []
:model_dir:
- app/models
- enterprise/app/models
:require: []
:root_dir:
- ''
-20
View File
@@ -1,23 +1,3 @@
--- ---
ignore: ignore:
- CVE-2021-41098 # https://github.com/chatwoot/chatwoot/issues/3097 (update once azure blob storage is updated) - CVE-2021-41098 # https://github.com/chatwoot/chatwoot/issues/3097 (update once azure blob storage is updated)
- GHSA-57hq-95w6-v4fc # Devise confirmable race condition — patched locally in User model (remove once on Devise 5+)
# Devise 5 is currently blocked by devise-secure_password/devise_token_auth/devise-two-factor.
# Chatwoot does not enable Timeoutable, so the timeout redirect path is not reachable.
- GHSA-jp94-3292-c3xv
# Rails 7.1 has no patched release for the Active Storage proxy range
# advisories. Chatwoot limits proxy range requests locally.
- CVE-2026-33658
# Rails 7.1 has no patched release for this Active Storage direct-upload
# advisory. Chatwoot filters internal metadata keys locally.
- CVE-2026-33173
- CVE-2026-33174
# Rails 7.1 has no patched release for these Rails advisories. These are not
# reachable through Chatwoot's current usage patterns and should be removed
# once we upgrade to Rails 7.2.3.1+.
- CVE-2026-33168
- CVE-2026-33169
- CVE-2026-33170
- CVE-2026-33176
- CVE-2026-33195
- CVE-2026-33202
+47 -241
View File
@@ -3,7 +3,6 @@ orbs:
node: circleci/node@6.1.0 node: circleci/node@6.1.0
qlty-orb: qltysh/qlty-orb@0.0 qlty-orb: qltysh/qlty-orb@0.0
# Shared defaults for setup steps
defaults: &defaults defaults: &defaults
working_directory: ~/build working_directory: ~/build
machine: machine:
@@ -13,150 +12,21 @@ defaults: &defaults
RAILS_LOG_TO_STDOUT: false RAILS_LOG_TO_STDOUT: false
COVERAGE: true COVERAGE: true
LOG_LEVEL: warn LOG_LEVEL: warn
parallelism: 4
jobs: jobs:
# Separate job for linting (no parallelism needed) build:
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: '24.13'
- node/install-pnpm:
version: '10.2.0'
- 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/7.19.0/openapi-generator-cli-7.19.0.jar > ~/tmp/openapi-generator-cli-7.19.0.jar
java -jar ~/tmp/openapi-generator-cli-7.19.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 <<: *defaults
steps: steps:
- checkout - checkout
- node/install: - node/install:
node-version: '24.13' node-version: '23.7'
- node/install-pnpm: - node/install-pnpm
version: '10.2.0'
- node/install-packages: - node/install-packages:
pkg-manager: pnpm pkg-manager: pnpm
override-ci-command: pnpm i override-ci-command: pnpm i
- run: node --version
- run: - run: pnpm --version
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: 18
steps:
- checkout
- node/install:
node-version: '24.13'
- node/install-pnpm:
version: '10.2.0'
- node/install-packages:
pkg-manager: pnpm
override-ci-command: pnpm i
- run: - run:
name: Add PostgreSQL repository and update name: Add PostgreSQL repository and update
command: | command: |
@@ -221,48 +91,19 @@ jobs:
source ~/.rvm/scripts/rvm source ~/.rvm/scripts/rvm
bundle install bundle install
# Install and configure OpenSearch # Swagger verification
- run: - run:
name: Install OpenSearch name: Verify swagger API specification
command: | command: |
# Download and install OpenSearch 2.11.0 (compatible with Elasticsearch 7.x clients) bundle exec rake swagger:build
wget https://artifacts.opensearch.org/releases/bundle/opensearch/2.11.0/opensearch-2.11.0-linux-x64.tar.gz if [[ `git status swagger/swagger.json --porcelain` ]]
tar -xzf opensearch-2.11.0-linux-x64.tar.gz then
sudo mv opensearch-2.11.0 /opt/opensearch echo "ERROR: The swagger.json file is not in sync with the yaml specification. Run 'rake swagger:build' and commit 'swagger/swagger.json'."
- run:
name: Configure and Start OpenSearch
command: |
# Configure OpenSearch for single-node testing
cat > /opt/opensearch/config/opensearch.yml \<< EOF
cluster.name: chatwoot-test
node.name: node-1
network.host: 0.0.0.0
http.port: 9200
discovery.type: single-node
plugins.security.disabled: true
EOF
# Set ownership and permissions
sudo chown -R $USER:$USER /opt/opensearch
# Start OpenSearch in background
/opt/opensearch/bin/opensearch -d -p /tmp/opensearch.pid
- run:
name: Wait for OpenSearch to be ready
command: |
echo "Waiting for OpenSearch to start..."
for i in {1..30}; do
if curl -s http://localhost:9200/_cluster/health | grep -q '"status"'; then
echo "OpenSearch is ready!"
exit 0
fi
echo "Waiting... ($i/30)"
sleep 2
done
echo "OpenSearch failed to start"
exit 1 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 # Configure environment and database
- run: - run:
@@ -280,98 +121,63 @@ jobs:
sed -i -e '/POSTGRES_USERNAME/ s/=.*/=chatwoot/' .env sed -i -e '/POSTGRES_USERNAME/ s/=.*/=chatwoot/' .env
sed -i -e "/POSTGRES_PASSWORD/ s/=.*/=$pg_pass/" .env sed -i -e "/POSTGRES_PASSWORD/ s/=.*/=$pg_pass/" .env
echo -en "\nINSTALLATION_ENV=circleci" >> ".env" echo -en "\nINSTALLATION_ENV=circleci" >> ".env"
echo -en "\nOPENSEARCH_URL=http://localhost:9200" >> ".env"
# Database setup # Database setup
- run: - run:
name: Run DB migrations name: Run DB migrations
command: bundle exec rails db:chatwoot_prepare command: bundle exec rails db:chatwoot_prepare
# Run backend tests (parallelized) # 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: - run:
name: Run backend tests name: Run backend tests
command: | command: |
mkdir -p ~/tmp/test-results/rspec mkdir -p ~/tmp/test-results/rspec
mkdir -p ~/tmp/test-artifacts mkdir -p ~/tmp/test-artifacts
mkdir -p ~/build/coverage/backend 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 \ bundle exec rspec -I ./spec --require coverage_helper --require spec_helper --format progress \
--format RspecJunitFormatter \ --format RspecJunitFormatter \
--out ~/tmp/test-results/rspec.xml \ --out ~/tmp/test-results/rspec.xml \
-- $TESTS -- ${TESTFILES}
no_output_timeout: 30m no_output_timeout: 30m
# Store test results for better splitting in future runs # Qlty coverage publish
- store_test_results: - qlty-orb/coverage_publish:
path: ~/tmp/test-results files: |
coverage/coverage.json
coverage/lcov.info
- run: - run:
name: Move coverage files if they exist name: List coverage directory contents
command: | command: |
if [ -d "coverage" ]; then ls -R ~/build/coverage
mkdir -p ~/build/coverage
cp -r coverage ~/build/coverage/backend || true
fi
when: always
- persist_to_workspace: - persist_to_workspace:
root: ~/build root: ~/build
paths: paths:
- coverage - 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: - store_artifacts:
path: coverage path: coverage
destination: 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
+1 -1
View File
@@ -10,7 +10,7 @@ services:
dockerfile: .devcontainer/Dockerfile.base dockerfile: .devcontainer/Dockerfile.base
args: args:
VARIANT: 'ubuntu-22.04' VARIANT: 'ubuntu-22.04'
NODE_VERSION: '24.13.0' NODE_VERSION: '23.7.0'
RUBY_VERSION: '3.4.4' RUBY_VERSION: '3.4.4'
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000. # On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
USER_UID: '1000' USER_UID: '1000'
+1 -1
View File
@@ -11,7 +11,7 @@ services:
dockerfile: .devcontainer/Dockerfile dockerfile: .devcontainer/Dockerfile
args: args:
VARIANT: 'ubuntu-22.04' VARIANT: 'ubuntu-22.04'
NODE_VERSION: '24.13.0' NODE_VERSION: '23.7.0'
RUBY_VERSION: '3.4.4' RUBY_VERSION: '3.4.4'
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000. # On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
USER_UID: '1000' USER_UID: '1000'
+3 -20
View File
@@ -98,8 +98,6 @@ SMTP_OPENSSL_VERIFY_MODE=peer
# Mail Incoming # Mail Incoming
# This is the domain set for the reply emails when conversation continuity is enabled # This is the domain set for the reply emails when conversation continuity is enabled
MAILER_INBOUND_EMAIL_DOMAIN= MAILER_INBOUND_EMAIL_DOMAIN=
# Maximum time in seconds to process a single IMAP email
# EMAIL_PROCESSING_TIMEOUT_SECONDS=60
# Set this to the appropriate ingress channel with regards to incoming emails # Set this to the appropriate ingress channel with regards to incoming emails
# Possible values are : # Possible values are :
# relay for Exim, Postfix, Qmail # relay for Exim, Postfix, Qmail
@@ -107,7 +105,6 @@ MAILER_INBOUND_EMAIL_DOMAIN=
# mandrill for Mandrill # mandrill for Mandrill
# postmark for Postmark # postmark for Postmark
# sendgrid for Sendgrid # sendgrid for Sendgrid
# ses for Amazon SES
RAILS_INBOUND_EMAIL_SERVICE= RAILS_INBOUND_EMAIL_SERVICE=
# Use one of the following based on the email ingress service # Use one of the following based on the email ingress service
# Ref: https://edgeguides.rubyonrails.org/action_mailbox_basics.html # Ref: https://edgeguides.rubyonrails.org/action_mailbox_basics.html
@@ -117,10 +114,6 @@ RAILS_INBOUND_EMAIL_PASSWORD=
MAILGUN_INGRESS_SIGNING_KEY= MAILGUN_INGRESS_SIGNING_KEY=
MANDRILL_INGRESS_API_KEY= MANDRILL_INGRESS_API_KEY=
# SNS topic ARN for ActionMailbox (format: arn:aws:sns:region:account-id:topic-name)
# Configure only if the rails_inbound_email_service = ses
ACTION_MAILBOX_SES_SNS_TOPIC=
# Creating Your Inbound Webhook Instructions for Postmark and Sendgrid: # Creating Your Inbound Webhook Instructions for Postmark and Sendgrid:
# Inbound webhook URL format: # Inbound webhook URL format:
# https://actionmailbox:[YOUR_RAILS_INBOUND_EMAIL_PASSWORD]@[YOUR_CHATWOOT_DOMAIN.COM]/rails/action_mailbox/[RAILS_INBOUND_EMAIL_SERVICE]/inbound_emails # https://actionmailbox:[YOUR_RAILS_INBOUND_EMAIL_PASSWORD]@[YOUR_CHATWOOT_DOMAIN.COM]/rails/action_mailbox/[RAILS_INBOUND_EMAIL_SERVICE]/inbound_emails
@@ -222,7 +215,6 @@ 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 ## https://github.com/DataDog/dd-trace-rb/blob/master/docs/GettingStarted.md#environment-variables
# DD_TRACE_AGENT_URL= # DD_TRACE_AGENT_URL=
# MaxMindDB API key to download GeoLite2 City database # MaxMindDB API key to download GeoLite2 City database
# IP_LOOKUP_API_KEY= # IP_LOOKUP_API_KEY=
@@ -234,10 +226,6 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38:
# Comma-separated list of trusted IPs that bypass Rack Attack throttling rules # Comma-separated list of trusted IPs that bypass Rack Attack throttling rules
# RACK_ATTACK_ALLOWED_IPS=127.0.0.1,::1,192.168.0.10 # RACK_ATTACK_ALLOWED_IPS=127.0.0.1,::1,192.168.0.10
## SafeFetch private network access
## Keep disabled by default. Self-hosted installations can enable this to allow SafeFetch requests to private network URLs.
# SAFE_FETCH_ALLOW_PRIVATE_NETWORK=false
## Running chatwoot as an API only server ## Running chatwoot as an API only server
## setting this value to true will disable the frontend dashboard endpoints ## setting this value to true will disable the frontend dashboard endpoints
# CW_API_ONLY_SERVER=false # CW_API_ONLY_SERVER=false
@@ -268,18 +256,13 @@ AZURE_APP_SECRET=
## Change these values to fine tune performance ## Change these values to fine tune performance
# control the concurrency setting of sidekiq # control the concurrency setting of sidekiq
# SIDEKIQ_CONCURRENCY=10 # SIDEKIQ_CONCURRENCY=10
# Enable verbose logging each time a job is dequeued in Sidekiq
# ENABLE_SIDEKIQ_DEQUEUE_LOGGER=false
# AI powered features (Captain) # AI powered features
# The OpenAI API key and endpoint for Captain are not configured via .env. ## OpenAI key
# Set them at Super Admin > App Configs > Captain (CAPTAIN_OPEN_AI_API_KEY, CAPTAIN_OPEN_AI_ENDPOINT). # OPENAI_API_KEY=
# Housekeeping/Performance related configurations # Housekeeping/Performance related configurations
# Set to true if you want to remove stale contact inboxes # Set to true if you want to remove stale contact inboxes
# contact_inboxes with no conversation older than 90 days will be removed # contact_inboxes with no conversation older than 90 days will be removed
# REMOVE_STALE_CONTACT_INBOX_JOB_STATUS=false # REMOVE_STALE_CONTACT_INBOX_JOB_STATUS=false
# REDIS_ALFRED_SIZE=10
# REDIS_VELMA_SIZE=10
-195
View File
@@ -1,195 +0,0 @@
#!/usr/bin/env python3
"""Sync triage GitHub security advisories to Linear issues."""
from __future__ import annotations
import os
import sys
from typing import Any
import requests
GITHUB_API = "https://api.github.com"
LINEAR_API = "https://api.linear.app/graphql"
SEVERITY_PRIORITY = {"critical": 1, "high": 2, "medium": 3, "low": 4}
SEVERITY_COLOR = {
"critical": 15548997,
"high": 15105570,
"medium": 15844367,
"low": 3066993,
}
DEFAULT_COLOR = 9807270
def required_env(name: str) -> str:
value = os.environ.get(name)
if not value:
sys.exit(f"Missing required env var: {name}")
return value
def fetch_triage_advisories(repo: str, token: str) -> list[dict[str, Any]]:
url: str | None = f"{GITHUB_API}/repos/{repo}/security-advisories"
params: dict[str, Any] | None = {"state": "triage", "per_page": 100}
headers = {
"Accept": "application/vnd.github+json",
"Authorization": f"Bearer {token}",
"X-GitHub-Api-Version": "2022-11-28",
}
advisories: list[dict[str, Any]] = []
while url:
r = requests.get(url, headers=headers, params=params, timeout=30)
r.raise_for_status()
advisories.extend(r.json())
next_link = r.links.get("next")
url = next_link["url"] if next_link else None
params = None
return advisories
def linear_call(query: str, variables: dict[str, Any], api_key: str) -> dict[str, Any]:
r = requests.post(
LINEAR_API,
headers={"Authorization": api_key},
json={"query": query, "variables": variables},
timeout=30,
)
r.raise_for_status()
return r.json()
def linear_issue_exists(ghsa_id: str, api_key: str) -> bool:
query = (
"query($q: String!) { issues(filter: {title: {contains: $q}}, first: 1) "
"{ nodes { id } } }"
)
resp = linear_call(query, {"q": ghsa_id}, api_key)
return len(resp.get("data", {}).get("issues", {}).get("nodes", [])) > 0
def linear_create_issue(input_data: dict[str, Any], api_key: str) -> dict[str, str] | None:
query = (
"mutation($input: IssueCreateInput!) { issueCreate(input: $input) "
"{ success issue { identifier url } } }"
)
resp = linear_call(query, {"input": input_data}, api_key)
create = resp.get("data", {}).get("issueCreate") or {}
if not create.get("success"):
return None
return create.get("issue")
def reporter_login(advisory: dict[str, Any]) -> str:
for credit in advisory.get("credits") or []:
user = (credit or {}).get("user") or {}
if user.get("login"):
return user["login"]
return "unknown"
def cvss_score(advisory: dict[str, Any]) -> str:
score = (advisory.get("cvss") or {}).get("score")
return str(score) if score is not None else "n/a"
def build_description(adv: dict[str, Any]) -> str:
return (
f"**GHSA:** {adv['ghsa_id']}\n"
f"**CVE:** {adv.get('cve_id') or 'n/a'}\n"
f"**Severity:** {adv.get('severity') or 'unknown'} (CVSS {cvss_score(adv)})\n"
f"**Reporter:** {reporter_login(adv)}\n"
f"**Reported:** {(adv.get('created_at') or '').split('T')[0]}\n"
f"**Advisory:** {adv['html_url']}\n\n"
f"---\n\n"
f"{adv.get('description') or 'No description provided.'}"
)
def post_discord(adv: dict[str, Any], issue: dict[str, str], webhook_url: str) -> None:
severity = adv.get("severity") or "unknown"
title = f"[{adv['ghsa_id']}] {adv['summary']}"[:250]
payload = {
"username": "GHSA Sync",
"embeds": [
{
"title": title,
"url": issue["url"],
"color": SEVERITY_COLOR.get(severity, DEFAULT_COLOR),
"fields": [
{"name": "Linear", "value": issue["identifier"], "inline": True},
{
"name": "Severity",
"value": f"{severity} (CVSS {cvss_score(adv)})",
"inline": True,
},
{
"name": "Advisory",
"value": f"[GitHub]({adv['html_url']})",
"inline": True,
},
],
}
],
}
try:
requests.post(webhook_url, json=payload, timeout=10)
except requests.RequestException:
pass
def main() -> int:
repo = required_env("GITHUB_REPOSITORY")
gh_token = required_env("GHSA_READ_TOKEN")
linear_api_key = required_env("LINEAR_API_KEY")
team_id = required_env("LINEAR_TEAM_ID")
project_id = required_env("LINEAR_PROJECT_ID")
label_id = required_env("LINEAR_LABEL_ID")
discord_webhook = os.environ.get("DISCORD_WEBHOOK_URL") or None
advisories = fetch_triage_advisories(repo, gh_token)
print(f"Fetched {len(advisories)} triage advisories")
created = skipped = failed = 0
for adv in advisories:
ghsa_id = adv.get("ghsa_id")
if not ghsa_id:
failed += 1
continue
try:
if linear_issue_exists(ghsa_id, linear_api_key):
skipped += 1
continue
severity = adv.get("severity") or "unknown"
issue = linear_create_issue(
{
"title": f"[{ghsa_id}] {adv.get('summary', '')}",
"description": build_description(adv),
"teamId": team_id,
"projectId": project_id,
"labelIds": [label_id],
"priority": SEVERITY_PRIORITY.get(severity, 3),
},
linear_api_key,
)
except requests.RequestException:
failed += 1
continue
if not issue:
failed += 1
continue
created += 1
if discord_webhook:
post_discord(adv, issue, discord_webhook)
print(f"Created {created}, skipped {skipped}, failed {failed}")
return 1 if failed > 0 else 0
if __name__ == "__main__":
sys.exit(main())
-3
View File
@@ -11,9 +11,6 @@ concurrency:
group: pr-${{ github.workflow }}-${{ github.head_ref }} group: pr-${{ github.workflow }}-${{ github.head_ref }}
cancel-in-progress: true cancel-in-progress: true
permissions:
contents: read
jobs: jobs:
deployment_check: deployment_check:
name: Check Deployment name: Check Deployment
+1 -4
View File
@@ -8,9 +8,6 @@ on:
branches: branches:
- develop - develop
permissions:
contents: read
jobs: jobs:
test: test:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
@@ -29,7 +26,7 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 24 node-version: 23
cache: 'pnpm' cache: 'pnpm'
- name: Install pnpm dependencies - name: Install pnpm dependencies
-29
View File
@@ -1,29 +0,0 @@
name: Sync GHSA advisories to Linear
on:
schedule:
- cron: '0 4 * * *' # daily at 09:30 IST
workflow_dispatch: {}
permissions:
contents: read
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: pip install requests==2.32.3
- name: Sync advisories
env:
GHSA_READ_TOKEN: ${{ secrets.GHSA_READ_TOKEN }}
LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}
LINEAR_TEAM_ID: ${{ secrets.LINEAR_TEAM_ID }}
LINEAR_PROJECT_ID: ${{ secrets.LINEAR_PROJECT_ID }}
LINEAR_LABEL_ID: ${{ secrets.LINEAR_LABEL_ID }}
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
run: python3 .github/scripts/ghsa_linear_sync.py
@@ -10,9 +10,6 @@ concurrency:
group: pr-${{ github.workflow }}-${{ github.head_ref }} group: pr-${{ github.workflow }}-${{ github.head_ref }}
cancel-in-progress: true cancel-in-progress: true
permissions:
contents: read
jobs: jobs:
log_lines_check: log_lines_check:
runs-on: ubuntu-latest runs-on: ubuntu-latest
-3
View File
@@ -14,9 +14,6 @@ on:
- cron: "0 0 * * *" - cron: "0 0 * * *"
workflow_dispatch: workflow_dispatch:
permissions:
contents: read
jobs: jobs:
nightly: nightly:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
@@ -3,10 +3,6 @@ name: Publish Codespace Base Image
on: on:
workflow_dispatch: workflow_dispatch:
permissions:
contents: read
packages: write
jobs: jobs:
publish-code-space-image: publish-code-space-image:
runs-on: ubuntu-latest runs-on: ubuntu-latest
-3
View File
@@ -18,9 +18,6 @@ on:
env: env:
DOCKER_REPO: chatwoot/chatwoot DOCKER_REPO: chatwoot/chatwoot
permissions:
contents: read
jobs: jobs:
build: build:
strategy: strategy:
@@ -18,9 +18,6 @@ on:
env: env:
DOCKER_REPO: chatwoot/chatwoot DOCKER_REPO: chatwoot/chatwoot
permissions:
contents: read
jobs: jobs:
build: build:
strategy: strategy:
+16 -80
View File
@@ -1,6 +1,4 @@
name: Run Chatwoot CE spec name: Run Chatwoot CE spec
permissions:
contents: read
on: on:
push: push:
branches: branches:
@@ -10,58 +8,11 @@ on:
workflow_dispatch: workflow_dispatch:
jobs: jobs:
# Separate linting jobs for faster feedback test:
lint-backend: runs-on: ubuntu-22.04
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: 24
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: 24
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: services:
postgres: postgres:
image: pgvector/pgvector:pg16 image: pgvector/pgvector:pg15
env: env:
POSTGRES_USER: postgres POSTGRES_USER: postgres
POSTGRES_PASSWORD: '' POSTGRES_PASSWORD: ''
@@ -69,6 +20,8 @@ jobs:
POSTGRES_HOST_AUTH_METHOD: trust POSTGRES_HOST_AUTH_METHOD: trust
ports: ports:
- 5432:5432 - 5432:5432
# needed because the postgres container does not provide a healthcheck
# tmpfs makes DB faster by using RAM
options: >- options: >-
--mount type=tmpfs,destination=/var/lib/postgresql/data --mount type=tmpfs,destination=/var/lib/postgresql/data
--health-cmd pg_isready --health-cmd pg_isready
@@ -76,7 +29,7 @@ jobs:
--health-timeout 5s --health-timeout 5s
--health-retries 5 --health-retries 5
redis: redis:
image: redis:alpine image: redis
ports: ports:
- 6379:6379 - 6379:6379
options: --entrypoint redis-server options: --entrypoint redis-server
@@ -90,11 +43,11 @@ jobs:
- uses: ruby/setup-ruby@v1 - uses: ruby/setup-ruby@v1
with: with:
bundler-cache: true bundler-cache: true # runs 'bundle install' and caches installed gems automatically
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 24 node-version: 23
cache: 'pnpm' cache: 'pnpm'
- name: Install pnpm dependencies - name: Install pnpm dependencies
@@ -111,36 +64,19 @@ jobs:
- name: Seed database - name: Seed database
run: bundle exec rake db:schema:load run: bundle exec rake db:schema:load
- name: Run backend tests (parallelized) - name: Run frontend tests
run: pnpm run test:coverage
# Run rails tests
- name: Run backend tests
run: | run: |
# Get all spec files and split them using round-robin distribution bundle exec rspec --profile=10 --format documentation
# 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: env:
NODE_OPTIONS: --openssl-legacy-provider NODE_OPTIONS: --openssl-legacy-provider
- name: Upload test results - name: Upload rails log folder
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
if: always() if: always()
with: with:
name: rspec-results-${{ matrix.ci_node_index }} name: rails-log-folder
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 path: log
-1
View File
@@ -70,7 +70,6 @@ jobs:
spec/services/mfa/authentication_service_spec.rb \ spec/services/mfa/authentication_service_spec.rb \
spec/requests/api/v1/profile/mfa_controller_spec.rb \ spec/requests/api/v1/profile/mfa_controller_spec.rb \
spec/controllers/devise_overrides/sessions_controller_spec.rb \ spec/controllers/devise_overrides/sessions_controller_spec.rb \
spec/models/application_record_external_credentials_encryption_spec.rb \
--profile=10 \ --profile=10 \
--format documentation --format documentation
env: env:
+1 -4
View File
@@ -10,9 +10,6 @@ concurrency:
group: pr-${{ github.workflow }}-${{ github.head_ref }} group: pr-${{ github.workflow }}-${{ github.head_ref }}
cancel-in-progress: true cancel-in-progress: true
permissions:
contents: read
jobs: jobs:
test: test:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
@@ -31,7 +28,7 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 24 node-version: 23
cache: 'pnpm' cache: 'pnpm'
- name: pnpm - name: pnpm
+2 -5
View File
@@ -7,9 +7,6 @@ on:
- master - master
workflow_dispatch: workflow_dispatch:
permissions:
contents: read
jobs: jobs:
test-build: test-build:
strategy: strategy:
@@ -39,5 +36,5 @@ jobs:
platforms: ${{ matrix.platform }} platforms: ${{ matrix.platform }}
push: false push: false
load: false load: false
cache-from: type=gha,scope=${{ matrix.platform }} cache-from: type=gha
cache-to: type=gha,mode=max,scope=${{ matrix.platform }} cache-to: type=gha,mode=max
-5
View File
@@ -94,13 +94,8 @@ yarn-debug.log*
.vscode .vscode
.claude/settings.local.json .claude/settings.local.json
.cursor .cursor
.codex/
.claude/
CLAUDE.local.md CLAUDE.local.md
# Histoire deployment # Histoire deployment
.netlify .netlify
.histoire .histoire
.pnpm-store/*
local/
Procfile.worktree
+1 -1
View File
@@ -1 +1 @@
24.13.0 20.5.1
+1 -1
View File
@@ -39,7 +39,7 @@ exclude_patterns = [
"**/target/**", "**/target/**",
"**/templates/**", "**/templates/**",
"**/testdata/**", "**/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/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/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",
] ]
test_patterns = [ test_patterns = [
+2 -13
View File
@@ -7,8 +7,6 @@ plugins:
require: require:
- ./rubocop/use_from_email.rb - ./rubocop/use_from_email.rb
- ./rubocop/custom_cop_location.rb - ./rubocop/custom_cop_location.rb
- ./rubocop/attachment_download.rb
- ./rubocop/one_class_per_file.rb
Layout/LineLength: Layout/LineLength:
Max: 150 Max: 150
@@ -25,7 +23,7 @@ Metrics/MethodLength:
- 'enterprise/lib/captain/agent.rb' - 'enterprise/lib/captain/agent.rb'
RSpec/ExampleLength: RSpec/ExampleLength:
Max: 50 Max: 25
Style/Documentation: Style/Documentation:
Enabled: false Enabled: false
@@ -42,12 +40,6 @@ Style/SymbolArray:
Style/OpenStructUse: Style/OpenStructUse:
Enabled: false Enabled: false
Chatwoot/AttachmentDownload:
Enabled: true
Exclude:
- 'spec/**/*'
- 'test/**/*'
Style/OptionalBooleanParameter: Style/OptionalBooleanParameter:
Exclude: Exclude:
- 'app/services/email_templates/db_resolver_service.rb' - 'app/services/email_templates/db_resolver_service.rb'
@@ -95,7 +87,7 @@ Metrics/ModuleLength:
Rails/HelperInstanceVariable: Rails/HelperInstanceVariable:
Exclude: Exclude:
- enterprise/app/helpers/captain/chat_helper.rb - enterprise/app/helpers/captain/chat_helper.rb
- enterprise/app/helpers/captain/chat_response_helper.rb
Rails/ApplicationController: Rails/ApplicationController:
Exclude: Exclude:
- 'app/controllers/api/v1/widget/messages_controller.rb' - 'app/controllers/api/v1/widget/messages_controller.rb'
@@ -213,9 +205,6 @@ UseFromEmail:
CustomCopLocation: CustomCopLocation:
Enabled: true Enabled: true
Style/OneClassPerFile:
Enabled: true
AllCops: AllCops:
NewCops: enable NewCops: enable
Exclude: Exclude:
+4 -47
View File
@@ -4,20 +4,12 @@
- **Setup**: `bundle install && pnpm install` - **Setup**: `bundle install && pnpm install`
- **Run Dev**: `pnpm dev` or `overmind start -f ./Procfile.dev` - **Run Dev**: `pnpm dev` or `overmind start -f ./Procfile.dev`
- **Seed Local Test Data**: `bundle exec rails db:seed` (quickly populates minimal data for standard feature verification)
- **Seed Search Test Data**: `bundle exec rails search:setup_test_data` (bulk fixture generation for search/performance/manual load scenarios)
- **Seed Account Sample Data (richer test data)**: `Seeders::AccountSeeder` is available as an internal utility and is exposed through Super Admin `Accounts#seed`, but can be used directly in dev workflows too:
- UI path: Super Admin → Accounts → Seed (enqueues `Internal::SeedAccountJob`).
- CLI path: `bundle exec rails runner "Internal::SeedAccountJob.perform_now(Account.find(<id>))"` (or call `Seeders::AccountSeeder.new(account: Account.find(<id>)).perform!` directly).
- **Lint JS/Vue**: `pnpm eslint` / `pnpm eslint:fix` - **Lint JS/Vue**: `pnpm eslint` / `pnpm eslint:fix`
- **Lint Ruby**: `bundle exec rubocop -a` - **Lint Ruby**: `bundle exec rubocop -a`
- **Test JS**: `pnpm test` or `pnpm test:watch` - **Test JS**: `pnpm test` or `pnpm test:watch`
- **Test Ruby**: `bundle exec rspec spec/path/to/file_spec.rb` - **Test Ruby**: `bundle exec rspec spec/path/to/file_spec.rb`
- **Single Test**: `bundle exec rspec spec/path/to/file_spec.rb:LINE_NUMBER` - **Single Test**: `bundle exec rspec spec/path/to/file_spec.rb:LINE_NUMBER`
- **Run Project**: `overmind start -f Procfile.dev` - **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 ## Code Style
@@ -43,50 +35,20 @@
## General Guidelines ## General Guidelines
- Prefer the smallest production-ready change that solves the current problem. - MVP focus: Least code change, happy-path only
- Build for the expected production path first. Do not add speculative guards, fallbacks, retries, or edge-case handling unless the caller can actually hit that case or production has proven it necessary. - No unnecessary defensive programming
- When an impossible or misconfigured state would indicate a setup/deployment bug, let it fail loudly instead of silently skipping behavior.
- For locked/internal configs that must exist in production, prefer direct reads (`find`, `find_by!`, required hash keys) over silent fallbacks.
- Do not add validation or response checks unless the code uses the result or the check changes behavior meaningfully.
- Prefer existing repo dependencies/client libraries over hand-rolled protocol code for auth, signing, parsing, or API plumbing.
- Avoid one-use private helpers unless they hide real complexity or make the main flow meaningfully easier to read.
- Prefer minimal, readable code over elaborate abstractions; clarity beats cleverness
- Break down complex tasks into small, testable units - Break down complex tasks into small, testable units
- Iterate after confirmation - Iterate after confirmation
- Avoid writing specs unless explicitly asked - Avoid writing specs unless explicitly asked
- In specs, avoid custom helper methods for setup/data. Prefer `let` values and direct per-example setup; only add a helper when it removes meaningful repeated complexity.
- Remove dead/unreachable/unused code - Remove dead/unreachable/unused code
- Dont write multiple versions or backups for the same logic — pick the best approach and implement it - Dont 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
## Codex Worktree Workflow
- Use a separate git worktree + branch per task to keep changes isolated.
- Keep Codex-specific local setup under `.codex/` and use `Procfile.worktree` for worktree process orchestration.
- The setup workflow in `.codex/environments/environment.toml` should dynamically generate per-worktree DB/port values (Rails, Vite, Redis DB index) to avoid collisions.
- Start each worktree with its own Overmind socket/title so multiple instances can run at the same time.
## Commit Messages
- Prefer Conventional Commits: `type(scope): subject` (scope optional)
- Example: `feat(auth): add user authentication`
- Don't reference Claude in commit messages - Don't reference Claude in commit messages
## PR Description Format
- Start with a short, user-facing paragraph describing the product change.
- Add a `Closes` section with relevant issue links (GitHub, Linear, etc.).
- For feature PRs, add `How to test` from a product/UX standpoint.
- For bugfix PRs, use `How to reproduce` when helpful.
- Optionally add a `What changed` section for implementation highlights.
- Do not add a `How this was tested` section listing specs/commands.
## Project-Specific ## Project-Specific
- **Translations**: - **Translations**:
- For product and source-string changes, only update `en.yml` and `en.json`; other languages are handled through Crowdin and the community - Only update `en.yml` and `en.json`
- Crowdin-generated translation sync PRs may update non-English locale files; do not flag those changes solely for modifying translated locale files - Other languages are handled by the community
- Backend i18n → `en.yml`, Frontend i18n → `en.json` - Backend i18n → `en.yml`, Frontend i18n → `en.json`
- **Frontend**: - **Frontend**:
- Use `components-next/` for message bubbles (the rest is being deprecated) - Use `components-next/` for message bubbles (the rest is being deprecated)
@@ -111,8 +73,3 @@ 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. - 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. - 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. - 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/`.
## Branding / White-labeling note
- For user-facing strings that currently contain "Chatwoot" but should adapt to branded/self-hosted installs, prefer applying `replaceInstallationName` from `shared/composables/useBranding` in the UI layer (for example tooltip and suggestion labels) instead of adding hardcoded brand-specific copy.
+7 -25
View File
@@ -21,8 +21,6 @@ gem 'telephone_number'
gem 'time_diff' gem 'time_diff'
gem 'tzinfo-data' gem 'tzinfo-data'
gem 'valid_email2' gem 'valid_email2'
gem 'email-provider-info'
gem 'gemoji'
# compress javascript config.assets.js_compressor # compress javascript config.assets.js_compressor
gem 'uglifier' gem 'uglifier'
##-- used for single column multiple binary flags in notification settings/feature flagging --## ##-- used for single column multiple binary flags in notification settings/feature flagging --##
@@ -41,8 +39,6 @@ gem 'json_refs'
gem 'rack-attack', '>= 6.7.0' gem 'rack-attack', '>= 6.7.0'
# a utility tool for streaming, flexible and safe downloading of remote files # a utility tool for streaming, flexible and safe downloading of remote files
gem 'down' gem 'down'
# SSRF-safe URL fetching
gem 'ssrf_filter', '~> 1.5'
# authentication type to fetch and send mail over oauth2.0 # authentication type to fetch and send mail over oauth2.0
gem 'gmail_xoauth' gem 'gmail_xoauth'
# Lock net-smtp to 0.3.4 to avoid issues with gmail_xoauth2 # Lock net-smtp to 0.3.4 to avoid issues with gmail_xoauth2
@@ -58,9 +54,6 @@ gem 'azure-storage-blob', git: 'https://github.com/chatwoot/azure-storage-ruby',
gem 'google-cloud-storage', '>= 1.48.0', require: false gem 'google-cloud-storage', '>= 1.48.0', require: false
gem 'image_processing' gem 'image_processing'
##-- for actionmailbox --##
gem 'aws-actionmailbox-ses', '~> 0'
##-- gems for database --# ##-- gems for database --#
gem 'groupdate' gem 'groupdate'
gem 'pg' gem 'pg'
@@ -76,7 +69,7 @@ gem 'faraday_middleware-aws-sigv4'
##--- gems for server & infra configuration ---## ##--- gems for server & infra configuration ---##
gem 'dotenv-rails', '>= 3.0.0' gem 'dotenv-rails', '>= 3.0.0'
gem 'foreman' gem 'foreman'
gem 'puma', '~> 7.2', '>= 7.2.1' gem 'puma'
gem 'vite_rails' gem 'vite_rails'
# metrics on heroku # metrics on heroku
gem 'barnes' gem 'barnes'
@@ -85,11 +78,10 @@ gem 'barnes'
gem 'devise', '>= 4.9.4' gem 'devise', '>= 4.9.4'
gem 'devise-secure_password', git: 'https://github.com/chatwoot/devise-secure_password', branch: 'chatwoot' gem 'devise-secure_password', git: 'https://github.com/chatwoot/devise-secure_password', branch: 'chatwoot'
gem 'devise_token_auth', '>= 1.2.3' gem 'devise_token_auth', '>= 1.2.3'
gem 'rails-i18n', '~> 7.0'
# two-factor authentication # two-factor authentication
gem 'devise-two-factor', '>= 5.0.0' gem 'devise-two-factor', '>= 5.0.0'
# authorization # authorization
gem 'jwt', '~> 2.10', '>= 2.10.3' gem 'jwt'
gem 'pundit' gem 'pundit'
# super admin # super admin
@@ -133,9 +125,9 @@ gem 'sentry-ruby', require: false
gem 'sentry-sidekiq', '>= 5.19.0', require: false gem 'sentry-sidekiq', '>= 5.19.0', require: false
##-- background job processing --## ##-- background job processing --##
gem 'sidekiq', '~> 7.3', '>= 7.3.1' gem 'sidekiq', '>= 7.3.1'
# We want cron jobs # We want cron jobs
gem 'sidekiq-cron', '>= 2.4.0' gem 'sidekiq-cron', '>= 1.12.0'
# for sidekiq healthcheck # for sidekiq healthcheck
gem 'sidekiq_alive' gem 'sidekiq_alive'
@@ -166,7 +158,7 @@ gem 'working_hours'
gem 'pg_search' gem 'pg_search'
# Subscriptions, Billing # Subscriptions, Billing
gem 'stripe', '~> 18.0' gem 'stripe'
## - helper gems --## ## - helper gems --##
## to populate db with sample data ## to populate db with sample data
@@ -195,22 +187,13 @@ gem 'reverse_markdown'
gem 'iso-639' gem 'iso-639'
gem 'ruby-openai' gem 'ruby-openai'
gem 'ai-agents', '>= 0.12.0' gem 'ai-agents', '>= 0.4.3'
# TODO: Move this gem as a dependency of ai-agents # TODO: Move this gem as a dependency of ai-agents
gem 'ruby_llm', '>= 1.14.1'
gem 'ruby_llm-schema' gem 'ruby_llm-schema'
gem 'cld3', '~> 3.7'
# OpenTelemetry for LLM observability
gem 'opentelemetry-sdk'
gem 'opentelemetry-exporter-otlp'
gem 'shopify_api' gem 'shopify_api'
gem 'firecrawl-sdk', '~> 1.0', require: 'firecrawl'
### Gems required only in specific deployment environments ### ### Gems required only in specific deployment environments ###
############################################################## ##############################################################
@@ -223,7 +206,7 @@ group :production do
end end
group :development do group :development do
gem 'annotaterb' gem 'annotate'
gem 'bullet' gem 'bullet'
gem 'letter_opener' gem 'letter_opener'
gem 'scss_lint', require: false gem 'scss_lint', require: false
@@ -274,7 +257,6 @@ group :development, :test do
gem 'seed_dump' gem 'seed_dump'
gem 'shoulda-matchers' gem 'shoulda-matchers'
gem 'simplecov', '>= 0.21', require: false gem 'simplecov', '>= 0.21', require: false
gem 'skooma'
gem 'spring' gem 'spring'
gem 'spring-watcher-listen' gem 'spring-watcher-listen'
end end
+111 -192
View File
@@ -108,8 +108,8 @@ GEM
acts-as-taggable-on (12.0.0) acts-as-taggable-on (12.0.0)
activerecord (>= 7.1, < 8.1) activerecord (>= 7.1, < 8.1)
zeitwerk (>= 2.4, < 3.0) zeitwerk (>= 2.4, < 3.0)
addressable (2.9.0) addressable (2.8.7)
public_suffix (>= 2.0.2, < 8.0) public_suffix (>= 2.0.2, < 7.0)
administrate (0.20.1) administrate (0.20.1)
actionpack (>= 6.0, < 8.0) actionpack (>= 6.0, < 8.0)
actionview (>= 6.0, < 8.0) actionview (>= 6.0, < 8.0)
@@ -126,51 +126,39 @@ GEM
jbuilder (~> 2) jbuilder (~> 2)
rails (>= 4.2, < 7.2) rails (>= 4.2, < 7.2)
selectize-rails (~> 0.6) selectize-rails (~> 0.6)
ai-agents (0.12.0) ai-agents (0.4.3)
ruby_llm (~> 1.14) ruby_llm (~> 1.3)
annotaterb (4.20.0) annotate (3.2.0)
activerecord (>= 6.0.0) activerecord (>= 3.2, < 8.0)
activesupport (>= 6.0.0) rake (>= 10.4, < 14.0)
ast (2.4.3) ast (2.4.3)
attr_extras (7.1.0) attr_extras (7.1.0)
audited (5.4.1) audited (5.4.1)
activerecord (>= 5.0, < 7.7) activerecord (>= 5.0, < 7.7)
activesupport (>= 5.0, < 7.7) activesupport (>= 5.0, < 7.7)
auth-sanitizer (0.2.1) aws-eventstream (1.2.0)
version_gem (~> 1.1, >= 1.1.10) aws-partitions (1.760.0)
aws-actionmailbox-ses (0.1.0) aws-sdk-core (3.171.1)
actionmailbox (>= 7.1.0) aws-eventstream (~> 1, >= 1.0.2)
aws-sdk-s3 (~> 1, >= 1.123.0) aws-partitions (~> 1, >= 1.651.0)
aws-sdk-sns (~> 1, >= 1.61.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) jmespath (~> 1, >= 1.6.1)
logger aws-sdk-kms (1.64.0)
aws-sdk-kms (1.118.0) aws-sdk-core (~> 3, >= 3.165.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.5)
aws-sdk-sns (1.70.0)
aws-sdk-core (~> 3, >= 3.188.0)
aws-sigv4 (~> 1.1) aws-sigv4 (~> 1.1)
aws-sigv4 (1.12.1) aws-sdk-s3 (1.122.0)
aws-sdk-core (~> 3, >= 3.165.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.4)
aws-sigv4 (1.5.2)
aws-eventstream (~> 1, >= 1.0.2) aws-eventstream (~> 1, >= 1.0.2)
barnes (0.0.9) barnes (0.0.9)
multi_json (~> 1) multi_json (~> 1)
statsd-ruby (~> 1.1) statsd-ruby (~> 1.1)
base64 (0.3.0) base64 (0.3.0)
bcrypt (3.1.22) bcrypt (3.1.20)
benchmark (0.4.1) benchmark (0.4.1)
bigdecimal (4.1.2) bigdecimal (3.2.2)
bindex (0.8.1) bindex (0.8.1)
bootsnap (1.16.0) bootsnap (1.16.0)
msgpack (~> 1.2) msgpack (~> 1.2)
@@ -186,22 +174,17 @@ GEM
bundler (>= 1.2.0, < 3) bundler (>= 1.2.0, < 3)
thor (~> 1.0) thor (~> 1.0)
byebug (11.1.3) byebug (11.1.3)
cgi (0.5.1)
childprocess (5.1.0) childprocess (5.1.0)
logger (~> 1.5) logger (~> 1.5)
cld3 (3.7.0)
climate_control (1.2.0) climate_control (1.2.0)
coderay (1.1.3) coderay (1.1.3)
commonmarker (0.23.10) commonmarker (0.23.10)
concurrent-ruby (1.3.7) concurrent-ruby (1.3.5)
connection_pool (2.5.5) connection_pool (2.5.3)
crack (1.0.0) crack (1.0.0)
bigdecimal bigdecimal
rexml rexml
crass (1.0.7) crass (1.0.6)
cronex (0.15.0)
tzinfo
unicode (>= 0.4.4.5)
csv (3.3.0) csv (3.3.0)
csv-safe (3.3.1) csv-safe (3.3.1)
csv (~> 3.0) csv (~> 3.0)
@@ -211,15 +194,14 @@ GEM
activerecord (>= 5.a) activerecord (>= 5.a)
database_cleaner-core (~> 2.0.0) database_cleaner-core (~> 2.0.0)
database_cleaner-core (2.0.1) database_cleaner-core (2.0.1)
datadog (2.38.0) datadog (2.19.0)
cgi datadog-ruby_core_source (~> 3.4, >= 3.4.1)
datadog-ruby_core_source (~> 3.5, >= 3.5.3) libdatadog (~> 18.1.0.1.0)
libdatadog (~> 36.0.0.1.0) libddwaf (~> 1.24.1.0.3)
libddwaf (~> 1.30.0.0.0)
logger logger
msgpack msgpack
datadog-ruby_core_source (3.5.3) datadog-ruby_core_source (3.4.1)
date (3.5.1) date (3.4.1)
debug (1.8.0) debug (1.8.0)
irb (>= 1.5.0) irb (>= 1.5.0)
reline (>= 0.3.1) reline (>= 0.3.1)
@@ -275,8 +257,8 @@ GEM
dry-logic (~> 1.5) dry-logic (~> 1.5)
dry-types (~> 1.8) dry-types (~> 1.8)
zeitwerk (~> 2.6) zeitwerk (~> 2.6)
dry-types (1.9.1) dry-types (1.8.3)
bigdecimal (>= 3.0) bigdecimal (~> 3.0)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
dry-core (~> 1.0) dry-core (~> 1.0)
dry-inflector (~> 1.0) dry-inflector (~> 1.0)
@@ -288,7 +270,6 @@ GEM
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
http (>= 3.0) http (>= 3.0)
ruby2_keywords ruby2_keywords
email-provider-info (0.0.1)
email_reply_trimmer (0.1.13) email_reply_trimmer (0.1.13)
erubi (1.13.0) erubi (1.13.0)
et-orbi (1.2.11) et-orbi (1.2.11)
@@ -305,7 +286,7 @@ GEM
railties (>= 5.0.0) railties (>= 5.0.0)
faker (3.2.0) faker (3.2.0)
i18n (>= 1.8.11, < 2) i18n (>= 1.8.11, < 2)
faraday (2.14.3) faraday (2.13.1)
faraday-net_http (>= 2.0, < 3.5) faraday-net_http (>= 2.0, < 3.5)
json json
logger logger
@@ -314,14 +295,14 @@ GEM
faraday-mashify (1.0.0) faraday-mashify (1.0.0)
faraday (~> 2.0) faraday (~> 2.0)
hashie hashie
faraday-multipart (1.2.0) faraday-multipart (1.0.4)
multipart-post (~> 2.0) multipart-post (~> 2)
faraday-net_http (3.4.4) faraday-net_http (3.4.0)
net-http (~> 0.5) net-http (>= 0.5.0)
faraday-net_http_persistent (2.1.0) faraday-net_http_persistent (2.1.0)
faraday (~> 2.5) faraday (~> 2.5)
net-http-persistent (~> 4.0) net-http-persistent (~> 4.0)
faraday-retry (2.4.0) faraday-retry (2.2.1)
faraday (~> 2.0) faraday (~> 2.0)
faraday_middleware-aws-sigv4 (1.0.1) faraday_middleware-aws-sigv4 (1.0.1)
aws-sigv4 (~> 1.0) aws-sigv4 (~> 1.0)
@@ -343,7 +324,6 @@ GEM
ffi-compiler (1.0.1) ffi-compiler (1.0.1)
ffi (>= 1.0.0) ffi (>= 1.0.0)
rake rake
firecrawl-sdk (1.4.1)
flag_shih_tzu (0.3.23) flag_shih_tzu (0.3.23)
foreman (0.87.2) foreman (0.87.2)
fugit (1.11.1) fugit (1.11.1)
@@ -357,7 +337,6 @@ GEM
googleapis-common-protos-types (>= 1.3.1, < 2.a) googleapis-common-protos-types (>= 1.3.1, < 2.a)
googleauth (~> 1.0) googleauth (~> 1.0)
grpc (~> 1.36) grpc (~> 1.36)
gemoji (4.1.0)
geocoder (1.8.1) geocoder (1.8.1)
gli (2.22.2) gli (2.22.2)
ostruct ostruct
@@ -439,8 +418,7 @@ GEM
hana (1.3.7) hana (1.3.7)
hash_diff (1.1.1) hash_diff (1.1.1)
hashdiff (1.1.0) hashdiff (1.1.0)
hashie (5.1.0) hashie (5.0.0)
logger
html2text (0.4.0) html2text (0.4.0)
nokogiri (>= 1.0, < 2.0) nokogiri (>= 1.0, < 2.0)
http (5.1.1) http (5.1.1)
@@ -452,8 +430,7 @@ GEM
http-cookie (1.0.5) http-cookie (1.0.5)
domain_name (~> 0.5) domain_name (~> 0.5)
http-form_data (2.3.0) http-form_data (2.3.0)
httparty (0.24.0) httparty (0.21.0)
csv
mini_mime (>= 1.0.0) mini_mime (>= 1.0.0)
multi_xml (>= 0.5.2) multi_xml (>= 0.5.2)
httpclient (2.8.3) httpclient (2.8.3)
@@ -475,7 +452,7 @@ GEM
rails-dom-testing (>= 1, < 3) rails-dom-testing (>= 1, < 3)
railties (>= 4.2.0) railties (>= 4.2.0)
thor (>= 0.14, < 2.0) thor (>= 0.14, < 2.0)
json (2.19.9) json (2.13.2)
json_refs (0.1.8) json_refs (0.1.8)
hana hana
json_schemer (0.2.24) json_schemer (0.2.24)
@@ -483,12 +460,6 @@ GEM
hana (~> 1.3) hana (~> 1.3)
regexp_parser (~> 2.0) regexp_parser (~> 2.0)
uri_template (~> 0.7) uri_template (~> 0.7)
json_skooma (0.2.5)
bigdecimal
hana (~> 1.3)
regexp_parser (~> 2.0)
uri-idna (~> 0.2)
zeitwerk (~> 2.6)
judoscale-rails (1.8.2) judoscale-rails (1.8.2)
judoscale-ruby (= 1.8.2) judoscale-ruby (= 1.8.2)
railties railties
@@ -496,7 +467,7 @@ GEM
judoscale-sidekiq (1.8.2) judoscale-sidekiq (1.8.2)
judoscale-ruby (= 1.8.2) judoscale-ruby (= 1.8.2)
sidekiq (>= 5.0) sidekiq (>= 5.0)
jwt (2.10.3) jwt (2.10.1)
base64 base64
kaminari (1.2.2) kaminari (1.2.2)
activesupport (>= 4.1.0) activesupport (>= 4.1.0)
@@ -523,16 +494,15 @@ GEM
logger (~> 1.6) logger (~> 1.6)
letter_opener (1.10.0) letter_opener (1.10.0)
launchy (>= 2.2, < 4) launchy (>= 2.2, < 4)
libdatadog (36.0.0.1.0) libdatadog (18.1.0.1.0)
libdatadog (36.0.0.1.0-arm64-darwin) libdatadog (18.1.0.1.0-x86_64-linux)
libdatadog (36.0.0.1.0-x86_64-linux) libddwaf (1.24.1.0.3)
libddwaf (1.30.0.0.2)
ffi (~> 1.0) ffi (~> 1.0)
libddwaf (1.30.0.0.2-arm64-darwin) libddwaf (1.24.1.0.3-arm64-darwin)
ffi (~> 1.0) ffi (~> 1.0)
libddwaf (1.30.0.0.2-x86_64-darwin) libddwaf (1.24.1.0.3-x86_64-darwin)
ffi (~> 1.0) ffi (~> 1.0)
libddwaf (1.30.0.0.2-x86_64-linux) libddwaf (1.24.1.0.3-x86_64-linux)
ffi (~> 1.0) ffi (~> 1.0)
line-bot-api (1.28.0) line-bot-api (1.28.0)
lint_roller (1.1.0) lint_roller (1.1.0)
@@ -549,7 +519,7 @@ GEM
activesupport (>= 4) activesupport (>= 4)
railties (>= 4) railties (>= 4)
request_store (~> 1.0) request_store (~> 1.0)
loofah (2.25.2) loofah (2.23.1)
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.12.0) nokogiri (>= 1.12.0)
mail (2.8.1) mail (2.8.1)
@@ -557,11 +527,11 @@ GEM
net-imap net-imap
net-pop net-pop
net-smtp net-smtp
marcel (1.1.0) marcel (1.0.4)
maxminddb (0.1.22) maxminddb (0.1.22)
meta_request (0.8.5) meta_request (0.8.3)
rack-contrib (>= 1.1, < 3) rack-contrib (>= 1.1, < 3)
railties (>= 3.0.0, < 9) railties (>= 3.0.0, < 8)
method_source (1.1.0) method_source (1.1.0)
mime-types (3.4.1) mime-types (3.4.1)
mime-types-data (~> 3.2015) mime-types-data (~> 3.2015)
@@ -572,19 +542,18 @@ GEM
minitest (5.25.5) minitest (5.25.5)
mock_redis (0.36.0) mock_redis (0.36.0)
ruby2_keywords ruby2_keywords
msgpack (1.8.3) msgpack (1.8.0)
multi_json (1.15.0) multi_json (1.15.0)
multi_xml (0.9.1) multi_xml (0.6.0)
bigdecimal (>= 3.1, < 5) multipart-post (2.3.0)
multipart-post (2.4.1)
mutex_m (0.3.0) mutex_m (0.3.0)
neighbor (0.2.3) neighbor (0.2.3)
activerecord (>= 5.2) activerecord (>= 5.2)
net-http (0.9.1) net-http (0.6.0)
uri (>= 0.11.1) uri
net-http-persistent (4.0.2) net-http-persistent (4.0.2)
connection_pool (~> 2.2) connection_pool (~> 2.2)
net-imap (0.6.4.1) net-imap (0.4.20)
date date
net-protocol net-protocol
net-pop (0.1.2) net-pop (0.1.2)
@@ -599,40 +568,33 @@ GEM
sidekiq sidekiq
newrelic_rpm (9.6.0) newrelic_rpm (9.6.0)
base64 base64
nio4r (2.7.5) nio4r (2.7.3)
nokogiri (1.19.4) nokogiri (1.18.9)
mini_portile2 (~> 2.8.2) mini_portile2 (~> 2.8.2)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.19.4-arm64-darwin) nokogiri (1.18.9-arm64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.19.4-x86_64-darwin) nokogiri (1.18.9-x86_64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.19.4-x86_64-linux-gnu) nokogiri (1.18.9-x86_64-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
oauth (1.1.6) oauth (1.1.0)
auth-sanitizer (~> 0.2, >= 0.2.1) oauth-tty (~> 1.0, >= 1.0.1)
base64 (~> 0.1) snaky_hash (~> 2.0)
cgi version_gem (~> 1.1)
oauth-tty (~> 1.0, >= 1.0.8) oauth-tty (1.0.5)
snaky_hash (~> 2.0, >= 2.0.5) version_gem (~> 1.1, >= 1.1.1)
version_gem (~> 1.1, >= 1.1.11) oauth2 (2.0.9)
oauth-tty (1.0.8) faraday (>= 0.17.3, < 3.0)
auth-sanitizer (~> 0.1, >= 0.1.3) jwt (>= 1.0, < 3.0)
cgi
version_gem (~> 1.1, >= 1.1.9)
oauth2 (2.0.22)
auth-sanitizer (~> 0.2, >= 0.2.1)
faraday (>= 0.17.3, < 4.0)
jwt (>= 1.0, < 4.0)
logger (~> 1.2)
multi_xml (~> 0.5) multi_xml (~> 0.5)
rack (>= 1.2, < 4) rack (>= 1.2, < 4)
snaky_hash (~> 2.0, >= 2.0.5) snaky_hash (~> 2.0)
version_gem (~> 1.1, >= 1.1.11) version_gem (~> 1.1)
oj (3.17.3) oj (3.16.10)
bigdecimal (>= 3.0) bigdecimal (>= 3.0)
ostruct (>= 0.2) ostruct (>= 0.2)
omniauth (2.1.4) omniauth (2.1.3)
hashie (>= 3.4.6) hashie (>= 3.4.6)
logger logger
rack (>= 2.2.3) rack (>= 2.2.3)
@@ -655,28 +617,9 @@ GEM
faraday (>= 1.0, < 3) faraday (>= 1.0, < 3)
multi_json (>= 1.0) multi_json (>= 1.0)
openssl (3.2.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) orm_adapter (0.5.0)
os (1.1.4) os (1.1.4)
ostruct (0.6.3) ostruct (0.6.1)
parallel (1.27.0) parallel (1.27.0)
parser (3.3.8.0) parser (3.3.8.0)
ast (~> 2.4.1) ast (~> 2.4.1)
@@ -694,14 +637,14 @@ GEM
method_source (~> 1.0) method_source (~> 1.0)
pry-rails (0.3.9) pry-rails (0.3.9)
pry (>= 0.10.4) pry (>= 0.10.4)
public_suffix (7.0.5) public_suffix (6.0.2)
puma (7.2.1) puma (6.4.3)
nio4r (~> 2.0) nio4r (~> 2.0)
pundit (2.3.0) pundit (2.3.0)
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
raabro (1.4.0) raabro (1.4.0)
racc (1.8.1) racc (1.8.1)
rack (3.2.6) rack (3.2.0)
rack-attack (6.7.0) rack-attack (6.7.0)
rack (>= 1.0, < 4) rack (>= 1.0, < 4)
rack-contrib (2.5.0) rack-contrib (2.5.0)
@@ -710,13 +653,13 @@ GEM
rack (>= 2.0.0) rack (>= 2.0.0)
rack-mini-profiler (3.2.0) rack-mini-profiler (3.2.0)
rack (>= 1.2.0) rack (>= 1.2.0)
rack-protection (4.2.1) rack-protection (4.1.1)
base64 (>= 0.1.0) base64 (>= 0.1.0)
logger (>= 1.6.0) logger (>= 1.6.0)
rack (>= 3.0.0, < 4) rack (>= 3.0.0, < 4)
rack-proxy (0.7.7) rack-proxy (0.7.7)
rack rack
rack-session (2.1.2) rack-session (2.1.1)
base64 (>= 0.1.0) base64 (>= 0.1.0)
rack (>= 3.0.0) rack (>= 3.0.0)
rack-test (2.1.0) rack-test (2.1.0)
@@ -742,12 +685,9 @@ GEM
activesupport (>= 5.0.0) activesupport (>= 5.0.0)
minitest minitest
nokogiri (>= 1.6) nokogiri (>= 1.6)
rails-html-sanitizer (1.7.1) rails-html-sanitizer (1.6.1)
loofah (~> 2.25, >= 2.25.2) loofah (~> 2.21)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
rails-i18n (7.0.10)
i18n (>= 0.7, < 2)
railties (>= 6.0.0, < 8)
railties (7.1.5.2) railties (7.1.5.2)
actionpack (= 7.1.5.2) actionpack (= 7.1.5.2)
activesupport (= 7.1.5.2) activesupport (= 7.1.5.2)
@@ -763,7 +703,7 @@ GEM
ffi (~> 1.0) ffi (~> 1.0)
redis (5.0.6) redis (5.0.6)
redis-client (>= 0.9.0) redis-client (>= 0.9.0)
redis-client (0.26.4) redis-client (0.22.2)
connection_pool connection_pool
redis-namespace (1.10.0) redis-namespace (1.10.0)
redis (>= 4) redis (>= 4)
@@ -852,17 +792,16 @@ GEM
ruby2ruby (2.5.0) ruby2ruby (2.5.0)
ruby_parser (~> 3.1) ruby_parser (~> 3.1)
sexp_processor (~> 4.6) sexp_processor (~> 4.6)
ruby_llm (1.15.0) ruby_llm (1.5.1)
base64 base64
event_stream_parser (~> 1) event_stream_parser (~> 1)
faraday (>= 1.10.0) faraday (>= 1.10.0)
faraday-multipart (>= 1) faraday-multipart (>= 1)
faraday-net_http (>= 1) faraday-net_http (>= 1)
faraday-retry (>= 1) faraday-retry (>= 1)
marcel (~> 1) marcel (~> 1.0)
ruby_llm-schema (~> 0)
zeitwerk (~> 2) zeitwerk (~> 2)
ruby_llm-schema (0.3.0) ruby_llm-schema (0.1.0)
ruby_parser (3.20.0) ruby_parser (3.20.0)
sexp_processor (~> 4.16) sexp_processor (~> 4.16)
sass (3.7.4) sass (3.7.4)
@@ -919,11 +858,10 @@ GEM
logger logger
rack (>= 2.2.4) rack (>= 2.2.4)
redis-client (>= 0.22.2) redis-client (>= 0.22.2)
sidekiq-cron (2.4.0) sidekiq-cron (1.12.0)
cronex (>= 0.13.0) fugit (~> 1.8)
fugit (~> 1.8, >= 1.11.1)
globalid (>= 1.0.1) globalid (>= 1.0.1)
sidekiq (>= 6.5.0) sidekiq (>= 6)
sidekiq_alive (2.5.0) sidekiq_alive (2.5.0)
gserver (~> 0.0.1) gserver (~> 0.0.1)
sidekiq (>= 5, < 9) sidekiq (>= 5, < 9)
@@ -938,9 +876,6 @@ GEM
simplecov_json_formatter (~> 0.1) simplecov_json_formatter (~> 0.1)
simplecov-html (0.13.2) simplecov-html (0.13.2)
simplecov_json_formatter (0.1.4) simplecov_json_formatter (0.1.4)
skooma (0.3.7)
json_skooma (~> 0.2.5)
zeitwerk (~> 2.6)
slack-ruby-client (2.7.0) slack-ruby-client (2.7.0)
faraday (>= 2.0.1) faraday (>= 2.0.1)
faraday-mashify faraday-mashify
@@ -948,9 +883,9 @@ GEM
gli gli
hashie hashie
logger logger
snaky_hash (2.0.5) snaky_hash (2.0.1)
hashie (>= 0.1.0, < 6) hashie
version_gem (>= 1.1.8, < 3) version_gem (~> 1.1, >= 1.1.1)
sorbet-runtime (0.5.11934) sorbet-runtime (0.5.11934)
spring (4.1.1) spring (4.1.1)
spring-watcher-listen (2.1.0) spring-watcher-listen (2.1.0)
@@ -964,10 +899,9 @@ GEM
activesupport (>= 5.2) activesupport (>= 5.2)
sprockets (>= 3.0.0) sprockets (>= 3.0.0)
squasher (0.7.2) squasher (0.7.2)
ssrf_filter (1.5.0)
stackprof (0.2.25) stackprof (0.2.25)
statsd-ruby (1.5.0) statsd-ruby (1.5.0)
stripe (18.0.1) stripe (8.5.0)
telephone_number (1.4.20) telephone_number (1.4.20)
test-prof (1.2.1) test-prof (1.2.1)
thor (1.4.0) thor (1.4.0)
@@ -979,7 +913,7 @@ GEM
time_diff (0.3.0) time_diff (0.3.0)
activesupport activesupport
i18n i18n
timeout (0.6.1) timeout (0.4.3)
trailblazer-option (0.1.2) trailblazer-option (0.1.2)
twilio-ruby (7.6.0) twilio-ruby (7.6.0)
faraday (>= 0.9, < 3.0) faraday (>= 0.9, < 3.0)
@@ -997,25 +931,21 @@ GEM
unf (0.1.4) unf (0.1.4)
unf_ext unf_ext
unf_ext (0.0.8.2) unf_ext (0.0.8.2)
unicode (0.4.4.5)
unicode-display_width (3.1.4) unicode-display_width (3.1.4)
unicode-emoji (~> 4.0, >= 4.0.4) unicode-emoji (~> 4.0, >= 4.0.4)
unicode-emoji (4.0.4) unicode-emoji (4.0.4)
uniform_notifier (1.17.0) uniform_notifier (1.17.0)
uri (1.1.1) uri (1.0.3)
uri-idna (0.3.1)
uri_template (0.7.0) uri_template (0.7.0)
valid_email2 (5.2.6) valid_email2 (5.2.6)
activemodel (>= 3.2) activemodel (>= 3.2)
mail (~> 2.5) mail (~> 2.5)
version_gem (1.1.11) version_gem (1.1.4)
vite_rails (3.10.0) vite_rails (3.0.17)
railties (>= 5.1, < 9) railties (>= 5.1, < 8)
vite_ruby (~> 3.0, >= 3.2.2) vite_ruby (~> 3.0, >= 3.2.2)
vite_ruby (3.10.2) vite_ruby (3.8.0)
dry-cli (>= 0.7, < 2) dry-cli (>= 0.7, < 2)
logger (~> 1.6)
mutex_m
rack-proxy (~> 0.6, >= 0.6.1) rack-proxy (~> 0.6, >= 0.6.1)
zeitwerk (~> 2.2) zeitwerk (~> 2.2)
warden (1.2.9) warden (1.2.9)
@@ -1032,7 +962,7 @@ GEM
addressable (>= 2.8.0) addressable (>= 2.8.0)
crack (>= 0.3.2) crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0) hashdiff (>= 0.4.0, < 2.0.0)
websocket-driver (0.8.2) websocket-driver (0.7.7)
base64 base64
websocket-extensions (>= 0.1.0) websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5) websocket-extensions (0.1.5)
@@ -1040,7 +970,7 @@ GEM
working_hours (1.4.1) working_hours (1.4.1)
activesupport (>= 3.2) activesupport (>= 3.2)
tzinfo tzinfo
zeitwerk (2.7.5) zeitwerk (2.6.17)
PLATFORMS PLATFORMS
arm64-darwin-20 arm64-darwin-20
@@ -1060,11 +990,10 @@ DEPENDENCIES
administrate (>= 0.20.1) administrate (>= 0.20.1)
administrate-field-active_storage (>= 1.0.3) administrate-field-active_storage (>= 1.0.3)
administrate-field-belongs_to_search (>= 0.9.0) administrate-field-belongs_to_search (>= 0.9.0)
ai-agents (>= 0.12.0) ai-agents (>= 0.4.3)
annotaterb annotate
attr_extras attr_extras
audited (~> 5.4, >= 5.4.1) audited (~> 5.4, >= 5.4.1)
aws-actionmailbox-ses (~> 0)
aws-sdk-s3 aws-sdk-s3
azure-storage-blob! azure-storage-blob!
barnes barnes
@@ -1074,7 +1003,6 @@ DEPENDENCIES
bullet bullet
bundle-audit bundle-audit
byebug byebug
cld3 (~> 3.7)
climate_control climate_control
commonmarker commonmarker
csv-safe csv-safe
@@ -1088,17 +1016,14 @@ DEPENDENCIES
dotenv-rails (>= 3.0.0) dotenv-rails (>= 3.0.0)
down down
elastic-apm elastic-apm
email-provider-info
email_reply_trimmer email_reply_trimmer
facebook-messenger facebook-messenger
factory_bot_rails (>= 6.4.3) factory_bot_rails (>= 6.4.3)
faker faker
faraday_middleware-aws-sigv4 faraday_middleware-aws-sigv4
fcm fcm
firecrawl-sdk (~> 1.0)
flag_shih_tzu flag_shih_tzu
foreman foreman
gemoji
geocoder geocoder
gmail_xoauth gmail_xoauth
google-cloud-dialogflow-v2 (>= 0.24.0) google-cloud-dialogflow-v2 (>= 0.24.0)
@@ -1117,7 +1042,7 @@ DEPENDENCIES
json_schemer json_schemer
judoscale-rails judoscale-rails
judoscale-sidekiq judoscale-sidekiq
jwt (~> 2.10, >= 2.10.3) jwt
kaminari kaminari
koala koala
letter_opener letter_opener
@@ -1138,21 +1063,18 @@ DEPENDENCIES
omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2) omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2)
omniauth-saml omniauth-saml
opensearch-ruby opensearch-ruby
opentelemetry-exporter-otlp
opentelemetry-sdk
pg pg
pg_search pg_search
pgvector pgvector
procore-sift procore-sift
pry-rails pry-rails
puma (~> 7.2, >= 7.2.1) puma
pundit pundit
rack-attack (>= 6.7.0) rack-attack (>= 6.7.0)
rack-cors (= 2.0.0) rack-cors (= 2.0.0)
rack-mini-profiler (>= 3.2.0) rack-mini-profiler (>= 3.2.0)
rack-timeout rack-timeout
rails (~> 7.1) rails (~> 7.1)
rails-i18n (~> 7.0)
redis redis
redis-namespace redis-namespace
responders (>= 3.1.1) responders (>= 3.1.1)
@@ -1166,7 +1088,6 @@ DEPENDENCIES
rubocop-rails rubocop-rails
rubocop-rspec rubocop-rspec
ruby-openai ruby-openai
ruby_llm (>= 1.14.1)
ruby_llm-schema ruby_llm-schema
scout_apm scout_apm
scss_lint scss_lint
@@ -1177,19 +1098,17 @@ DEPENDENCIES
sentry-sidekiq (>= 5.19.0) sentry-sidekiq (>= 5.19.0)
shopify_api shopify_api
shoulda-matchers shoulda-matchers
sidekiq (~> 7.3, >= 7.3.1) sidekiq (>= 7.3.1)
sidekiq-cron (>= 2.4.0) sidekiq-cron (>= 1.12.0)
sidekiq_alive sidekiq_alive
simplecov (>= 0.21) simplecov (>= 0.21)
simplecov_json_formatter simplecov_json_formatter
skooma
slack-ruby-client (~> 2.7.0) slack-ruby-client (~> 2.7.0)
spring spring
spring-watcher-listen spring-watcher-listen
squasher squasher
ssrf_filter (~> 1.5)
stackprof stackprof
stripe (~> 18.0) stripe
telephone_number telephone_number
test-prof test-prof
tidewave tidewave
+1 -1
View File
@@ -1,4 +1,4 @@
Copyright (c) 2017-2026 Chatwoot Inc. Copyright (c) 2017-2024 Chatwoot Inc.
Portions of this software are licensed as follows: Portions of this software are licensed as follows:
+2 -6
View File
@@ -40,12 +40,8 @@ run:
fi fi
force_run: force_run:
@echo "Cleaning up Overmind processes..." rm -f ./.overmind.sock
@lsof -ti:3036 2>/dev/null | xargs kill -9 2>/dev/null || true rm -f tmp/pids/*.pid
@lsof -ti:3000 2>/dev/null | xargs kill -9 2>/dev/null || true
@rm -f ./.overmind.sock
@rm -f tmp/pids/*.pid
@echo "Cleanup complete"
overmind start -f Procfile.dev overmind start -f Procfile.dev
force_run_tunnel: force_run_tunnel:
+2 -1
View File
@@ -8,6 +8,7 @@ ___
The modern customer support platform, an open-source alternative to Intercom, Zendesk, Salesforce Service Cloud etc. The modern customer support platform, an open-source alternative to Intercom, Zendesk, Salesforce Service Cloud etc.
<p> <p>
<a href="https://codeclimate.com/github/chatwoot/chatwoot/maintainability"><img src="https://api.codeclimate.com/v1/badges/e6e3f66332c91e5a4c0c/maintainability" alt="Maintainability"></a>
<img src="https://img.shields.io/circleci/build/github/chatwoot/chatwoot" alt="CircleCI Badge"> <img src="https://img.shields.io/circleci/build/github/chatwoot/chatwoot" alt="CircleCI Badge">
<a href="https://hub.docker.com/r/chatwoot/chatwoot/"><img src="https://img.shields.io/docker/pulls/chatwoot/chatwoot" alt="Docker Pull Badge"></a> <a href="https://hub.docker.com/r/chatwoot/chatwoot/"><img src="https://img.shields.io/docker/pulls/chatwoot/chatwoot" alt="Docker Pull Badge"></a>
<a href="https://hub.docker.com/r/chatwoot/chatwoot/"><img src="https://img.shields.io/docker/cloud/build/chatwoot/chatwoot" alt="Docker Build Badge"></a> <a href="https://hub.docker.com/r/chatwoot/chatwoot/"><img src="https://img.shields.io/docker/cloud/build/chatwoot/chatwoot" alt="Docker Build Badge"></a>
@@ -136,4 +137,4 @@ Thanks goes to all these [wonderful people](https://www.chatwoot.com/docs/contri
<a href="https://github.com/chatwoot/chatwoot/graphs/contributors"><img src="https://opencollective.com/chatwoot/contributors.svg?width=890&button=false" /></a> <a href="https://github.com/chatwoot/chatwoot/graphs/contributors"><img src="https://opencollective.com/chatwoot/contributors.svg?width=890&button=false" /></a>
*Chatwoot* &copy; 2017-2026, Chatwoot Inc - Released under the MIT License. *Chatwoot* &copy; 2017-2025, Chatwoot Inc - Released under the MIT License.
+1 -1
View File
@@ -1 +1 @@
4.16.1 4.4.0
+1 -1
View File
@@ -1 +1 @@
3.5.0 3.4.3
-4
View File
@@ -36,10 +36,6 @@
"REDIS_OPENSSL_VERIFY_MODE":{ "REDIS_OPENSSL_VERIFY_MODE":{
"description": "OpenSSL verification mode for Redis connections. ref https://help.heroku.com/HC0F8CUS/redis-connection-issues", "description": "OpenSSL verification mode for Redis connections. ref https://help.heroku.com/HC0F8CUS/redis-connection-issues",
"value": "none" "value": "none"
},
"NODE_OPTIONS": {
"description": "Increase V8 heap for Vite build to avoid OOM",
"value": "--max-old-space-size=4096"
} }
}, },
"formation": { "formation": {
+1 -1
View File
@@ -104,7 +104,7 @@ class ContactIdentifyAction
# blank identifier or email will throw unique index error # blank identifier or email will throw unique index error
# TODO: replace reject { |_k, v| v.blank? } with compact_blank when rails is upgraded # TODO: replace reject { |_k, v| v.blank? } with compact_blank when rails is upgraded
@contact.discard_invalid_attrs if discard_invalid_attrs @contact.discard_invalid_attrs if discard_invalid_attrs
@contact.save! if @contact.changed? @contact.save!
enqueue_avatar_job enqueue_avatar_job
end end
+1 -5
View File
@@ -44,11 +44,7 @@ class AccountBuilder
end end
def create_account def create_account
@account = Account.create!( @account = Account.create!(name: account_name, locale: I18n.locale)
name: account_name,
locale: I18n.locale,
custom_attributes: { 'onboarding_step' => 'account_details' }
)
Current.account = @account Current.account = @account
end end
+1 -18
View File
@@ -2,14 +2,6 @@
# It initializes with necessary attributes and provides a perform method # It initializes with necessary attributes and provides a perform method
# to create a user and account user in a transaction. # to create a user and account user in a transaction.
class AgentBuilder class AgentBuilder
LIMIT_EXCEEDED_MESSAGE = 'Account limit exceeded. Please purchase more licenses'.freeze
class LimitExceededError < StandardError
def initialize
super(AgentBuilder::LIMIT_EXCEEDED_MESSAGE)
end
end
# Initializes an AgentBuilder with necessary attributes. # Initializes an AgentBuilder with necessary attributes.
# @param email [String] the email of the user. # @param email [String] the email of the user.
# @param name [String] the name of the user. # @param name [String] the name of the user.
@@ -22,32 +14,23 @@ class AgentBuilder
# Creates a user and account user in a transaction. # Creates a user and account user in a transaction.
# @return [User] the created user. # @return [User] the created user.
def perform def perform
account.with_lock do
raise LimitExceededError unless can_add_agent?
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
@user = find_or_create_user @user = find_or_create_user
create_account_user create_account_user
end end
end
@user @user
end end
private private
def can_add_agent?
account.usage_limits[:agents] > account.account_users.count
end
# Finds a user by email or creates a new one with a temporary password. # Finds a user by email or creates a new one with a temporary password.
# @return [User] the found or created user. # @return [User] the found or created user.
def find_or_create_user def find_or_create_user
user = User.from_email(email) user = User.from_email(email)
return user if user return user if user
@name = email.split('@').first if @name.blank?
temp_password = "1!aA#{SecureRandom.alphanumeric(12)}" temp_password = "1!aA#{SecureRandom.alphanumeric(12)}"
User.create!(email: email, name: @name, password: temp_password, password_confirmation: temp_password) User.create!(email: email, name: name, password: temp_password, password_confirmation: temp_password)
end end
# Checks if the user needs confirmation. # Checks if the user needs confirmation.
-2
View File
@@ -103,5 +103,3 @@ class ContactInboxBuilder
@inbox.email? || @inbox.sms? || @inbox.twilio? || @inbox.whatsapp? @inbox.email? || @inbox.sms? || @inbox.twilio? || @inbox.whatsapp?
end end
end end
ContactInboxBuilder.prepend_mod_with('ContactInboxBuilder')
@@ -50,7 +50,7 @@ class ContactInboxWithContactBuilder
def create_contact def create_contact
account.contacts.create!( account.contacts.create!(
name: contact_name, name: contact_attributes[:name] || ::Haikunator.haikunate(1000),
phone_number: contact_attributes[:phone_number], phone_number: contact_attributes[:phone_number],
email: contact_attributes[:email], email: contact_attributes[:email],
identifier: contact_attributes[:identifier], identifier: contact_attributes[:identifier],
@@ -59,11 +59,6 @@ class ContactInboxWithContactBuilder
) )
end end
def contact_name
name = contact_attributes[:name] || ::Haikunator.haikunate(1000)
name.truncate(ApplicationRecord::MAX_STRING_COLUMN_LENGTH, omission: '')
end
def find_contact def find_contact
contact = find_contact_by_identifier(contact_attributes[:identifier]) contact = find_contact_by_identifier(contact_attributes[:identifier])
contact ||= find_contact_by_email(contact_attributes[:email]) contact ||= find_contact_by_email(contact_attributes[:email])
+5 -3
View File
@@ -1,6 +1,4 @@
class Email::BaseBuilder class Email::BaseBuilder
include EmailAddressParseable
pattr_initialize [:inbox!] pattr_initialize [:inbox!]
private private
@@ -41,7 +39,7 @@ class Email::BaseBuilder
end end
def business_name def business_name
inbox.sanitized_business_name inbox.business_name || inbox.sanitized_name
end end
def account_support_email def account_support_email
@@ -49,4 +47,8 @@ class Email::BaseBuilder
# can save it in the format "Name <email@domain.com>" # can save it in the format "Name <email@domain.com>"
parse_email(account.support_email) parse_email(account.support_email)
end end
def parse_email(email_string)
Mail::Address.new(email_string).address
end
end end
@@ -91,21 +91,11 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
def fallback_params(attachment) def fallback_params(attachment)
{ {
fallback_title: attachment['title'] || attachment.dig('payload', 'title'), fallback_title: attachment['title'],
external_url: attachment['url'] || attachment.dig('payload', 'url') external_url: attachment['url']
} }
end end
# Facebook shared posts point to page URLs, not downloadable media URLs.
# Both `share` and `post` attachment types carry a page URL rather than a media file,
# so map them to `fallback` (which keeps the title/link without attempting a download).
# Keep this Facebook-only so Messenger/Instagram share attachments still use the parent media handling.
def normalize_file_type(type)
return :fallback if [:share, :post].include?(type.to_sym)
super
end
def conversation_params def conversation_params
{ {
account_id: @inbox.account_id, account_id: @inbox.account_id,
@@ -115,19 +105,15 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
end end
def message_params def message_params
content_attributes = {
in_reply_to_external_id: response.in_reply_to_external_id
}
content_attributes[:external_echo] = true if @outgoing_echo
{ {
account_id: conversation.account_id, account_id: conversation.account_id,
inbox_id: conversation.inbox_id, inbox_id: conversation.inbox_id,
message_type: @message_type, message_type: @message_type,
status: @outgoing_echo ? :delivered : :sent,
content: response.content, content: response.content,
source_id: response.identifier, source_id: response.identifier,
content_attributes: content_attributes, content_attributes: {
in_reply_to_external_id: response.in_reply_to_external_id
},
sender: @outgoing_echo ? nil : @contact_inbox.contact sender: @outgoing_echo ? nil : @contact_inbox.contact
} }
end end
@@ -112,25 +112,6 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil
return if story_reply_attributes.blank? return if story_reply_attributes.blank?
@message.save_story_info(story_reply_attributes) @message.save_story_info(story_reply_attributes)
create_story_reply_attachment(story_reply_attributes['url'])
end
def create_story_reply_attachment(story_url)
return if story_url.blank?
attachment = @message.attachments.new(
file_type: :ig_story,
account_id: @message.account_id,
external_url: story_url
)
attachment.save!
begin
attach_file(attachment, story_url)
rescue Down::Error, StandardError => e
Rails.logger.warn "Failed to download Instagram story attachment: #{e.message}"
end
@message.content_attributes[:image_type] = 'ig_story_reply'
@message.save!
end end
def build_conversation def build_conversation
@@ -158,7 +139,6 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil
account_id: conversation.account_id, account_id: conversation.account_id,
inbox_id: conversation.inbox_id, inbox_id: conversation.inbox_id,
message_type: message_type, message_type: message_type,
status: @outgoing_echo ? :delivered : :sent,
source_id: message_identifier, source_id: message_identifier,
content: message_content, content: message_content,
sender: @outgoing_echo ? nil : contact, sender: @outgoing_echo ? nil : contact,
@@ -167,7 +147,6 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil
} }
} }
params[:content_attributes][:external_echo] = true if @outgoing_echo
params[:content_attributes][:is_unsupported] = true if message_is_unsupported? params[:content_attributes][:is_unsupported] = true if message_is_unsupported?
params params
end end
+29 -110
View File
@@ -1,8 +1,5 @@
class Messages::MessageBuilder class Messages::MessageBuilder
include ::FileTypeHelper include ::FileTypeHelper
include ::EmailHelper
include ::DataHelper
attr_reader :message attr_reader :message
def initialize(user, conversation, params) def initialize(user, conversation, params)
@@ -10,10 +7,8 @@ class Messages::MessageBuilder
@private = params[:private] || false @private = params[:private] || false
@conversation = conversation @conversation = conversation
@user = user @user = user
@account = conversation.account
@message_type = params[:message_type] || 'outgoing' @message_type = params[:message_type] || 'outgoing'
@attachments = params[:attachments] @attachments = params[:attachments]
@is_voice_message = ActiveModel::Type::Boolean.new.cast(params[:is_voice_message])
@automation_rule = content_attributes&.dig(:automation_rule_id) @automation_rule = content_attributes&.dig(:automation_rule_id)
return unless params.instance_of?(ActionController::Parameters) return unless params.instance_of?(ActionController::Parameters)
@@ -25,9 +20,6 @@ class Messages::MessageBuilder
@message = @conversation.messages.build(message_params) @message = @conversation.messages.build(message_params)
process_attachments process_attachments
process_emails process_emails
# When the message has no quoted content, it will just be rendered as a regular message
# The frontend is equipped to handle this case
process_email_content
@message.save! @message.save!
@message @message
end end
@@ -42,12 +34,30 @@ class Messages::MessageBuilder
params = convert_to_hash(@params) params = convert_to_hash(@params)
content_attributes = params.fetch(:content_attributes, {}) content_attributes = params.fetch(:content_attributes, {})
return safe_parse_json(content_attributes) if content_attributes.is_a?(String) return parse_json(content_attributes) if content_attributes.is_a?(String)
return content_attributes if content_attributes.is_a?(Hash) return content_attributes if content_attributes.is_a?(Hash)
{} {}
end end
# Converts the given object to a hash.
# If it's an instance of ActionController::Parameters, converts it to an unsafe hash.
# Otherwise, returns the object as-is.
def convert_to_hash(obj)
return obj.to_unsafe_h if obj.instance_of?(ActionController::Parameters)
obj
end
# Attempts to parse a string as JSON.
# If successful, returns the parsed hash with symbolized names.
# If unsuccessful, returns nil.
def parse_json(content)
JSON.parse(content, symbolize_names: true)
rescue JSON::ParserError
{}
end
def process_attachments def process_attachments
return if @attachments.blank? return if @attachments.blank?
@@ -57,23 +67,14 @@ class Messages::MessageBuilder
file: uploaded_attachment file: uploaded_attachment
) )
attachment.file_type = attachment_file_type(uploaded_attachment) attachment.file_type = if uploaded_attachment.is_a?(String)
tag_voice_message(attachment) file_type_by_signed_id(
end uploaded_attachment
end )
def attachment_file_type(uploaded_attachment)
if uploaded_attachment.is_a?(String)
file_type_by_signed_id(uploaded_attachment)
else else
file_type(uploaded_attachment&.content_type) file_type(uploaded_attachment&.content_type)
end end
end end
def tag_voice_message(attachment)
return unless @is_voice_message && attachment.file_type == 'audio'
attachment.meta = (attachment.meta || {}).merge('is_voice_message' => true)
end end
def process_emails def process_emails
@@ -91,20 +92,18 @@ class Messages::MessageBuilder
@message.content_attributes[:to_emails] = to_emails @message.content_attributes[:to_emails] = to_emails
end end
def process_email_content
return unless should_process_email_content?
@message.content_attributes ||= {}
email_attributes = build_email_attributes
@message.content_attributes[:email] = email_attributes
end
def process_email_string(email_string) def process_email_string(email_string)
return [] if email_string.blank? return [] if email_string.blank?
email_string.gsub(/\s+/, '').split(',') email_string.gsub(/\s+/, '').split(',')
end end
def validate_email_addresses(all_emails)
all_emails&.each do |email|
raise StandardError, 'Invalid email address' unless email.match?(URI::MailTo::EMAIL_REGEXP)
end
end
def message_type def message_type
if @conversation.inbox.channel_type != 'Channel::Api' && @message_type == 'incoming' if @conversation.inbox.channel_type != 'Channel::Api' && @message_type == 'incoming'
raise StandardError, 'Incoming messages are only allowed in Api inboxes' raise StandardError, 'Incoming messages are only allowed in Api inboxes'
@@ -148,90 +147,10 @@ class Messages::MessageBuilder
private: @private, private: @private,
sender: sender, sender: sender,
content_type: @params[:content_type], content_type: @params[:content_type],
content_attributes: content_attributes.presence,
items: @items, items: @items,
in_reply_to: @in_reply_to, in_reply_to: @in_reply_to,
echo_id: @params[:echo_id], echo_id: @params[:echo_id],
source_id: @params[:source_id] source_id: @params[:source_id]
}.merge(external_created_at).merge(automation_rule_id).merge(campaign_id).merge(template_params) }.merge(external_created_at).merge(automation_rule_id).merge(campaign_id).merge(template_params)
end end
def email_inbox?
@conversation.inbox&.inbox_type == 'Email'
end end
def should_process_email_content?
email_inbox? && !@private && @message.content.present?
end
def build_email_attributes
email_attributes = ensure_indifferent_access(@message.content_attributes[:email] || {})
normalized_content = normalize_email_body(@message.content)
# Process liquid templates in normalized content with code block protection
processed_content = process_liquid_in_email_body(normalized_content)
# Use custom HTML content if provided, otherwise generate from message content
email_attributes[:html_content] = if custom_email_content_provided?
build_custom_html_content
else
build_html_content(processed_content)
end
email_attributes[:text_content] = build_text_content(processed_content)
email_attributes
end
def build_html_content(normalized_content)
html_content = ensure_indifferent_access(@message.content_attributes.dig(:email, :html_content) || {})
rendered_html = render_email_html(normalized_content)
html_content[:full] = rendered_html
html_content[:reply] = rendered_html
html_content
end
def build_text_content(normalized_content)
text_content = ensure_indifferent_access(@message.content_attributes.dig(:email, :text_content) || {})
text_content[:full] = normalized_content
text_content[:reply] = normalized_content
text_content
end
def custom_email_content_provided?
@params[:email_html_content].present?
end
def build_custom_html_content
html_content = ensure_indifferent_access(@message.content_attributes.dig(:email, :html_content) || {})
html_content[:full] = @params[:email_html_content]
html_content[:reply] = @params[:email_html_content]
html_content
end
# Liquid processing methods for email content
def process_liquid_in_email_body(content)
return content if content.blank?
return content unless should_process_liquid?
# Protect code blocks from liquid processing
modified_content = modified_liquid_content(content)
template = Liquid::Template.parse(modified_content)
template.render(drops_with_sender)
rescue Liquid::Error
content
end
def should_process_liquid?
@message_type == 'outgoing' || @message_type == 'template'
end
def drops_with_sender
message_drops(@conversation).merge({
'agent' => UserDrop.new(sender)
})
end
end
Messages::MessageBuilder.prepend_mod_with('Messages::MessageBuilder')
@@ -2,30 +2,14 @@ class Messages::Messenger::MessageBuilder
include ::FileTypeHelper include ::FileTypeHelper
def process_attachment(attachment) def process_attachment(attachment)
# This check handles very rare case if there are multiple files to attach with only one unsupported file # This check handles very rare case if there are multiple files to attach with only one usupported file
return if unsupported_file_type?(attachment['type']) return if unsupported_file_type?(attachment['type'])
params = attachment_params(attachment) attachment_obj = @message.attachments.new(attachment_params(attachment).except(:remote_file_url))
# During Meta's sticker webhook transition, a sticker message carries both an `image`
# and a `sticker` attachment pointing to the same URL. Skip the redundant sticker so it
# isn't attached twice, while still storing legitimate duplicate attachments of other types.
return if duplicate_sticker?(attachment, params[:external_url])
attachment_obj = @message.attachments.new(params.except(:remote_file_url))
attachment_obj.save! attachment_obj.save!
if facebook_reel?(attachment) attach_file(attachment_obj, attachment_params(attachment)[:remote_file_url]) if attachment_params(attachment)[:remote_file_url]
update_facebook_reel_content(attachment)
elsif params[:remote_file_url]
attach_file(attachment_obj, params[:remote_file_url])
end
fetch_attachment_links(attachment_obj)
update_attachment_file_type(attachment_obj)
end
def fetch_attachment_links(attachment_obj)
fetch_story_link(attachment_obj) if attachment_obj.file_type == 'story_mention' 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' update_attachment_file_type(attachment_obj)
fetch_ig_post_link(attachment_obj) if attachment_obj.file_type == 'ig_post'
end end
def attach_file(attachment, file_url) def attach_file(attachment, file_url)
@@ -37,17 +21,13 @@ class Messages::Messenger::MessageBuilder
filename: attachment_file.original_filename, filename: attachment_file.original_filename,
content_type: attachment_file.content_type content_type: attachment_file.content_type
) )
# The Attachment row is saved before the blob is attached, so the
# after_create_commit broadcast bails on `file.attached?`. Re-fire here
# for audio so the bubble updates without waiting on transcription.
attachment.message&.reload&.send_update_event if attachment.file_type.to_sym == :audio
end end
def attachment_params(attachment) def attachment_params(attachment)
file_type = normalize_file_type(attachment['type']) file_type = attachment['type'].to_sym
params = { file_type: file_type, account_id: @message.account_id } params = { file_type: file_type, account_id: @message.account_id }
if [:image, :file, :audio, :video, :share, :story_mention, :ig_reel, :ig_post, :ig_story].include? file_type if [:image, :file, :audio, :video, :share, :story_mention, :ig_reel].include? file_type
params.merge!(file_type_params(attachment)) params.merge!(file_type_params(attachment))
elsif file_type == :location elsif file_type == :location
params.merge!(location_params(attachment)) params.merge!(location_params(attachment))
@@ -59,17 +39,9 @@ class Messages::Messenger::MessageBuilder
end end
def file_type_params(attachment) 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: url, external_url: attachment['payload']['url'],
remote_file_url: url remote_file_url: attachment['payload']['url']
} }
end end
@@ -96,21 +68,6 @@ class Messages::Messenger::MessageBuilder
message.save! message.save!
end 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 # This is a placeholder method to be overridden by child classes
def get_story_object_from_source_id(_source_id) def get_story_object_from_source_id(_source_id)
{} {}
@@ -118,36 +75,7 @@ class Messages::Messenger::MessageBuilder
private private
# Facebook may send attachment types that don't directly match our file_type enum.
# Map known aliases to their canonical enum values.
FACEBOOK_FILE_TYPE_MAP = { reel: :ig_reel, sticker: :image }.freeze
def normalize_file_type(type)
sym = type.to_sym
FACEBOOK_FILE_TYPE_MAP.fetch(sym, sym)
end
def duplicate_sticker?(attachment, url)
return false unless attachment['type'].to_sym == :sticker
return false if url.blank?
@message.attachments.any? { |existing| existing.external_url == url }
end
# Facebook sends reel URLs as webpage links (facebook.com/reel/...) rather than
# direct video URLs. Downloading these yields HTML, not video content.
def facebook_reel?(attachment)
attachment['type'].to_sym == :reel
end
def update_facebook_reel_content(attachment)
url = attachment.dig('payload', 'url')
return if url.blank?
@message.update!(content: url) if @message.content.blank?
end
def unsupported_file_type?(attachment_type) def unsupported_file_type?(attachment_type)
[:template, :unsupported_type, :ephemeral].include? attachment_type.to_sym [:template, :unsupported_type].include? attachment_type.to_sym
end end
end end
-15
View File
@@ -27,8 +27,6 @@ class NotificationBuilder
return if notification_type == 'conversation_creation' && !user_subscribed_to_notification? return if notification_type == 'conversation_creation' && !user_subscribed_to_notification?
# skip notifications for blocked conversations except for user mentions # skip notifications for blocked conversations except for user mentions
return if primary_actor.contact.blocked? && notification_type != 'conversation_mention' return if primary_actor.contact.blocked? && notification_type != 'conversation_mention'
# respect conversation access (inbox/team membership and custom-role permissions)
return unless user_can_access_conversation?
user.notifications.create!( user.notifications.create!(
notification_type: notification_type, notification_type: notification_type,
@@ -38,17 +36,4 @@ class NotificationBuilder
secondary_actor: secondary_actor || current_user secondary_actor: secondary_actor || current_user
) )
end end
def user_can_access_conversation?
conversation = primary_actor.is_a?(Conversation) ? primary_actor : primary_actor.try(:conversation)
return true if conversation.blank?
account_user = AccountUser.find_by(account_id: account.id, user_id: user.id)
return false if account_user.blank?
ConversationPolicy.new(
{ user: user, account: account, account_user: account_user },
conversation
).show?
end
end end
-1
View File
@@ -1,7 +1,6 @@
class V2::ReportBuilder class V2::ReportBuilder
include DateRangeHelper include DateRangeHelper
include ReportHelper include ReportHelper
attr_reader :account, :params attr_reader :account, :params
DEFAULT_GROUP_BY = 'day'.freeze DEFAULT_GROUP_BY = 'day'.freeze
@@ -11,6 +11,10 @@ class V2::Reports::AgentSummaryBuilder < V2::Reports::BaseSummaryBuilder
attr_reader :conversations_count, :resolved_count, attr_reader :conversations_count, :resolved_count,
:avg_resolution_time, :avg_first_response_time, :avg_reply_time :avg_resolution_time, :avg_first_response_time, :avg_reply_time
def fetch_conversations_count
account.conversations.where(created_at: range).group('assignee_id').count
end
def prepare_report def prepare_report
account.account_users.map do |account_user| account.account_users.map do |account_user|
build_agent_stats(account_user) build_agent_stats(account_user)
+24 -25
View File
@@ -9,13 +9,27 @@ class V2::Reports::BaseSummaryBuilder
private private
def load_data def load_data
results = data_source.summary @conversations_count = fetch_conversations_count
@resolved_count = fetch_resolved_count
@avg_resolution_time = fetch_average_time('conversation_resolved')
@avg_first_response_time = fetch_average_time('first_response')
@avg_reply_time = fetch_average_time('reply_time')
end
@conversations_count = results.transform_values { |data| data[:conversations_count] } def reporting_events
@resolved_count = results.transform_values { |data| data[:resolved_conversations_count] } @reporting_events ||= account.reporting_events.where(created_at: range)
@avg_resolution_time = results.transform_values { |data| data[:avg_resolution_time] } end
@avg_first_response_time = results.transform_values { |data| data[:avg_first_response_time] }
@avg_reply_time = results.transform_values { |data| data[:avg_reply_time] } def fetch_conversations_count
# Override this method
end
def fetch_average_time(event_name)
get_grouped_average(reporting_events.where(name: event_name))
end
def fetch_resolved_count
reporting_events.where(name: 'conversation_resolved').group(group_by_key).count
end end
def group_by_key def group_by_key
@@ -26,26 +40,11 @@ class V2::Reports::BaseSummaryBuilder
# Override this method # Override this method
end end
def data_source def get_grouped_average(events)
@data_source ||= Reports::DataSource.for( events.group(group_by_key).average(average_value_key)
account: account,
metric: nil,
dimension_type: summary_dimension_type,
dimension_id: nil,
scope: nil,
range: range,
group_by: 'day',
timezone_offset: params[:timezone_offset],
business_hours: params[:business_hours]
)
end end
def summary_dimension_type def average_value_key
{ ActiveModel::Type::Boolean.new.cast(params[:business_hours]).present? ? :value_in_business_hours : :value
'account_id' => 'account',
'user_id' => 'agent',
'inbox_id' => 'inbox',
'conversations.team_id' => 'team'
}.fetch(group_by_key.to_s)
end end
end end
+4 -15
View File
@@ -31,24 +31,13 @@ class V2::Reports::BotMetricsBuilder
end end
def bot_resolutions_count def bot_resolutions_count
# Exclude conversations that also had a handoff in the same range — handoff wins account.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_bot_resolved,
account.reporting_events.joins(:conversation).select(:conversation_id) created_at: range).distinct.count
.where(account_id: account.id, name: :conversation_bot_resolved, created_at: range)
.where.not(conversation_id: bot_handoff_conversation_ids_subquery)
.distinct.count
end end
def bot_handoffs_count def bot_handoffs_count
account.reporting_events.joins(:conversation).select(:conversation_id) account.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_bot_handoff,
.where(account_id: account.id, name: :conversation_bot_handoff, created_at: range) created_at: range).distinct.count
.distinct.count
end
def bot_handoff_conversation_ids_subquery
account.reporting_events
.where(name: :conversation_bot_handoff, created_at: range)
.where.not(conversation_id: nil)
.select(:conversation_id)
end end
def bot_resolution_rate def bot_resolution_rate
@@ -1,38 +0,0 @@
class V2::Reports::ChannelSummaryBuilder
include DateRangeHelper
pattr_initialize [:account!, :params!]
def build
conversations_by_channel_and_status.transform_values { |status_counts| build_channel_stats(status_counts) }
end
private
def conversations_by_channel_and_status
account.conversations
.joins(:inbox)
.where(created_at: range)
.group('inboxes.channel_type', 'conversations.status')
.count
.each_with_object({}) do |((channel_type, status), count), grouped|
grouped[channel_type] ||= {}
grouped[channel_type][status] = count
end
end
def build_channel_stats(status_counts)
open_count = status_counts['open'] || 0
resolved_count = status_counts['resolved'] || 0
pending_count = status_counts['pending'] || 0
snoozed_count = status_counts['snoozed'] || 0
{
open: open_count,
resolved: resolved_count,
pending: pending_count,
snoozed: snoozed_count,
total: open_count + resolved_count + pending_count + snoozed_count
}
end
end
@@ -3,10 +3,23 @@ class V2::Reports::Conversations::BaseReportBuilder
private private
def builder_class(metric) AVG_METRICS = %w[avg_first_response_time avg_resolution_time reply_time].freeze
return unless Reports::ReportMetricRegistry.supported?(metric) COUNT_METRICS = %w[
conversations_count
incoming_messages_count
outgoing_messages_count
resolutions_count
bot_resolutions_count
bot_handoffs_count
].freeze
V2::Reports::Timeseries::ReportBuilder def builder_class(metric)
case metric
when *AVG_METRICS
V2::Reports::Timeseries::AverageReportBuilder
when *COUNT_METRICS
V2::Reports::Timeseries::CountReportBuilder
end
end end
def log_invalid_metric def log_invalid_metric
@@ -1,213 +0,0 @@
class V2::Reports::DrilldownBuilder
include DateRangeHelper
include TimezoneHelper
DEFAULT_GROUP_BY = 'day'.freeze
DEFAULT_PAGE = 1
DEFAULT_PER_PAGE = 25
MAX_PER_PAGE = 100
SUPPORTED_GROUP_BY = %w[hour day week month year].freeze
SUPPORTED_DIMENSION_TYPES = %w[account inbox agent label team].freeze
MESSAGE_METRICS = {
'incoming_messages_count' => :incoming,
'outgoing_messages_count' => :outgoing
}.freeze
MESSAGE_EVENT_METRICS = %w[avg_first_response_time reply_time].freeze
pattr_initialize :account, :params
def self.supported_dimension_type?(type) = SUPPORTED_DIMENSION_TYPES.include?((type.presence || 'account').to_s)
def build
records = paginated_records.to_a
{ meta: meta, payload: records.map { |record| record_serializer(records).serialize(record) } }
end
private
def meta
{
metric: metric,
record_type: record_type,
bucket: {
since: bucket_range.begin.to_i,
until: bucket_range.end.to_i
},
current_page: current_page,
per_page: per_page,
total_count: paginated_records.total_count,
conversation_count: conversation_count
}
end
def conversation_count
return paginated_records.total_count if conversation_metric?
drilldown_scope.except(:includes).reorder(nil).distinct.count(:conversation_id)
end
def paginated_records
@paginated_records ||= drilldown_scope.page(current_page).per(per_page)
end
def drilldown_scope
if message_metric?
message_scope
elsif conversation_metric?
conversation_scope
else
reporting_event_scope
end
end
def message_scope
scope.messages
.where(account_id: account.id, created_at: bucket_range)
.public_send(MESSAGE_METRICS.fetch(metric))
.includes(:sender, conversation: [:assignee, :contact, :inbox])
.reorder(created_at: :desc)
end
def conversation_scope
scope.conversations
.where(account_id: account.id, created_at: bucket_range)
.includes(:assignee, :contact, :inbox)
.order(created_at: :desc)
end
def reporting_event_scope
events = scope.reporting_events
.where(account_id: account.id, name: raw_event_name, created_at: bucket_range)
.includes(:user, :inbox, conversation: [:assignee, :contact, :inbox])
.order(created_at: :desc)
if raw_count_strategy == :exclude_bot_handoffs
events = events.where.not(conversation_id: bot_handoff_conversation_ids_subquery)
elsif raw_count_strategy == :distinct_conversation
events = events.where(id: distinct_conversation_event_ids(events))
end
events
end
def bot_handoff_conversation_ids_subquery
scope.reporting_events
.where(account_id: account.id, name: :conversation_bot_handoff, created_at: range)
.where.not(conversation_id: nil)
.select(:conversation_id)
end
def distinct_conversation_event_ids(events)
events.reorder(nil)
.where.not(conversation_id: nil)
.select('MAX(reporting_events.id)')
.group(:conversation_id)
end
def record_serializer(records)
@record_serializer ||= V2::Reports::DrilldownRecordSerializer.new(
account,
metric,
use_business_hours?,
records
)
end
def bucket_range
@bucket_range ||= begin
bucket_start = Time.zone.at(params[:bucket_timestamp].to_i).in_time_zone(timezone)
bucket_end = bucket_end_for(bucket_start)
requested_start = Time.zone.at(params[:since].to_i)
requested_end = Time.zone.at(params[:until].to_i)
[bucket_start, requested_start].max...[bucket_end, requested_end].min
end
end
def bucket_end_for(bucket_start)
{
'hour' => bucket_start + 1.hour,
'day' => bucket_start + 1.day,
'week' => bucket_start + 1.week,
'month' => bucket_start + 1.month,
'year' => bucket_start + 1.year
}.fetch(group_by)
end
def scope
case dimension_type
when 'account' then account
when 'inbox' then inbox
when 'agent' then user
when 'label' then label
when 'team' then team
else
raise ArgumentError, "Unsupported drilldown dimension type: #{dimension_type}"
end
end
def inbox = @inbox ||= account.inboxes.find(params[:id])
def user = @user ||= account.users.find(params[:id])
def label = @label ||= account.labels.find(params[:id])
def team = @team ||= account.teams.find(params[:id])
def metric
params[:metric].to_s
end
def report_metric
@report_metric ||= Reports::ReportMetricRegistry.fetch(metric)
end
def raw_event_name
report_metric&.raw_event_name
end
def raw_count_strategy
report_metric&.raw_count_strategy
end
def record_type
return 'message' if message_metric? || MESSAGE_EVENT_METRICS.include?(metric)
'conversation'
end
def message_metric?
MESSAGE_METRICS.key?(metric)
end
def conversation_metric?
metric == 'conversations_count'
end
def dimension_type
(params[:type].presence || 'account').to_s
end
def group_by
@group_by ||= SUPPORTED_GROUP_BY.include?(params[:group_by].to_s) ? params[:group_by].to_s : DEFAULT_GROUP_BY
end
def timezone
@timezone ||= timezone_name_from_offset(params[:timezone_offset])
end
def current_page
[params[:page].to_i, DEFAULT_PAGE].max
end
def per_page
requested_per_page = params[:per_page].to_i
requested_per_page = DEFAULT_PER_PAGE if requested_per_page <= 0
[requested_per_page, MAX_PER_PAGE].min
end
def use_business_hours?
ActiveModel::Type::Boolean.new.cast(params[:business_hours])
end
end
@@ -1,199 +0,0 @@
class V2::Reports::DrilldownRecordSerializer
MESSAGE_EVENT_METRICS = %w[avg_first_response_time reply_time].freeze
attr_reader :account, :metric, :use_business_hours, :records
def initialize(account, metric, use_business_hours, records = [])
@account = account
@metric = metric
@use_business_hours = use_business_hours
@records = records
end
def serialize(record)
return serialize_message(record) if record.is_a?(Message)
return serialize_conversation_event(record) if record.is_a?(ReportingEvent)
serialize_conversation(record)
end
private
def serialize_message(message, metric_value: nil, occurred_at: nil)
{
record_type: 'message',
conversation: conversation_attributes(message.conversation),
message: message_attributes(message),
metric_value: metric_value,
occurred_at: (occurred_at || message.created_at).to_i
}
end
def serialize_conversation_event(event)
inferred_message = inferred_message_for(event)
if inferred_message.present?
return serialize_message(
inferred_message,
metric_value: event_metric_value(event),
occurred_at: event_timestamp(event)
)
end
serialize_conversation(
event.conversation,
metric_value: event_metric_value(event),
occurred_at: event_timestamp(event),
event_name: event.name
)
end
def serialize_conversation(conversation, metric_value: nil, occurred_at: nil, event_name: nil)
serialized_record = {
record_type: 'conversation',
conversation: conversation_attributes(conversation),
message: nil,
metric_value: metric_value,
occurred_at: (occurred_at || conversation&.created_at)&.to_i
}
serialized_record[:event_name] = event_name if event_name.present?
serialized_record
end
def conversation_attributes(conversation)
return {} if conversation.blank?
{
id: conversation.id,
display_id: conversation.display_id,
contact_id: conversation.contact_id,
contact_name: conversation.contact&.name,
inbox_id: conversation.inbox_id,
inbox_name: conversation.inbox&.name,
assignee_id: conversation.assignee_id,
assignee_name: conversation.assignee&.name,
status: conversation.status,
created_at: conversation.created_at.to_i,
last_activity_at: conversation.last_activity_at.to_i,
last_message: last_message_attributes(conversation)
}
end
def message_attributes(message)
{
id: message.id,
content: message.content,
message_type: message.message_type,
sender_name: message.sender&.try(:name),
created_at: message.created_at.to_i
}
end
def last_message_attributes(conversation)
message = latest_messages_by_conversation_id[conversation.id]
return if message.blank?
message_attributes(message)
end
def inferred_message_for(event)
return unless MESSAGE_EVENT_METRICS.include?(metric)
return if event.conversation.blank? || event.event_end_time.blank?
inferred_messages_by_event_id[event.id]
end
def first_response_event_with_user?(event)
metric == 'avg_first_response_time' && event.user_id.present?
end
def message_inference_range(event)
(event.event_end_time - 1.second)..(event.event_end_time + 1.second)
end
def event_metric_value(event)
use_business_hours ? event.value_in_business_hours : event.value
end
def event_timestamp(event)
event.event_end_time || event.created_at
end
def latest_messages_by_conversation_id
@latest_messages_by_conversation_id ||= if conversation_ids.blank?
{}
else
latest_messages.index_by(&:conversation_id)
end
end
def latest_messages
Message
.where(account_id: account.id, conversation_id: conversation_ids)
.where.not(message_type: :activity)
.select('DISTINCT ON (messages.conversation_id) messages.*')
.reorder(Arel.sql('messages.conversation_id, messages.created_at DESC, messages.id DESC'))
.includes(:sender)
end
def inferred_messages_by_event_id
@inferred_messages_by_event_id ||= inference_events.each_with_object({}) do |event, messages_by_event_id|
messages_by_event_id[event.id] = inferred_message_candidates.find do |message|
message_matches_event?(message, event)
end
end
end
def inferred_message_candidates
@inferred_message_candidates ||= if inference_events.blank?
[]
else
inferred_messages.to_a
end
end
def inferred_messages
Message
.where(account_id: account.id, conversation_id: inference_events.map(&:conversation_id).uniq)
.where(created_at: inference_time_range)
.where(message_type: %i[outgoing template])
.includes(:sender)
.reorder(created_at: :desc, id: :desc)
end
def message_matches_event?(message, event)
message.conversation_id == event.conversation_id &&
message.created_at.between?(
message_inference_range(event).begin,
message_inference_range(event).end
) &&
message_sender_matches_event?(message, event)
end
def message_sender_matches_event?(message, event)
return true unless first_response_event_with_user?(event)
message.sender_id == event.user_id && message.sender_type == 'User'
end
def inference_time_range
event_end_times = inference_events.map(&:event_end_time)
(event_end_times.min - 1.second)..(event_end_times.max + 1.second)
end
def inference_events
@inference_events ||= records.select do |record|
record.is_a?(ReportingEvent) && record.conversation_id.present? && record.event_end_time.present?
end
end
def conversation_ids
@conversation_ids ||= records.filter_map { |record| conversation_id_for(record) }.uniq
end
def conversation_id_for(record)
return record.conversation_id if record.is_a?(Message) || record.is_a?(ReportingEvent)
record.id
end
end
@@ -1,68 +0,0 @@
class V2::Reports::FirstResponseTimeDistributionBuilder
include DateRangeHelper
attr_reader :account, :params
def initialize(account:, params:)
@account = account
@params = params
end
def build
build_distribution
end
private
def build_distribution
results = fetch_aggregated_counts
map_to_channel_types(results)
end
def fetch_aggregated_counts
ReportingEvent
.where(account_id: account.id, name: 'first_response')
.where(range_condition)
.group(:inbox_id)
.select(
:inbox_id,
bucket_case_statements
)
end
def bucket_case_statements
<<~SQL.squish
COUNT(CASE WHEN value < 3600 THEN 1 END) AS bucket_0_1h,
COUNT(CASE WHEN value >= 3600 AND value < 14400 THEN 1 END) AS bucket_1_4h,
COUNT(CASE WHEN value >= 14400 AND value < 28800 THEN 1 END) AS bucket_4_8h,
COUNT(CASE WHEN value >= 28800 AND value < 86400 THEN 1 END) AS bucket_8_24h,
COUNT(CASE WHEN value >= 86400 THEN 1 END) AS bucket_24h_plus
SQL
end
def range_condition
range.present? ? { created_at: range } : {}
end
def inbox_channel_types
@inbox_channel_types ||= account.inboxes.pluck(:id, :channel_type).to_h
end
def map_to_channel_types(results)
results.each_with_object({}) do |row, hash|
channel_type = inbox_channel_types[row.inbox_id]
next unless channel_type
hash[channel_type] ||= empty_buckets
hash[channel_type]['0-1h'] += row.bucket_0_1h
hash[channel_type]['1-4h'] += row.bucket_1_4h
hash[channel_type]['4-8h'] += row.bucket_4_8h
hash[channel_type]['8-24h'] += row.bucket_8_24h
hash[channel_type]['24h+'] += row.bucket_24h_plus
end
end
def empty_buckets
{ '0-1h' => 0, '1-4h' => 0, '4-8h' => 0, '8-24h' => 0, '24h+' => 0 }
end
end
@@ -1,65 +0,0 @@
class V2::Reports::InboxLabelMatrixBuilder
include DateRangeHelper
attr_reader :account, :params
def initialize(account:, params:)
@account = account
@params = params
end
def build
{
inboxes: filtered_inboxes.map { |inbox| { id: inbox.id, name: inbox.name } },
labels: filtered_labels.map { |label| { id: label.id, title: label.title } },
matrix: build_matrix
}
end
private
def filtered_inboxes
@filtered_inboxes ||= begin
inboxes = account.inboxes
inboxes = inboxes.where(id: params[:inbox_ids]) if params[:inbox_ids].present?
inboxes.order(:name).to_a
end
end
def filtered_labels
@filtered_labels ||= begin
labels = account.labels
labels = labels.where(id: params[:label_ids]) if params[:label_ids].present?
labels.order(:title).to_a
end
end
def conversation_filter
filter = { account_id: account.id }
filter[:created_at] = range if range.present?
filter[:inbox_id] = params[:inbox_ids] if params[:inbox_ids].present?
filter
end
def fetch_grouped_counts
label_names = filtered_labels.map(&:title)
return {} if label_names.empty?
ActsAsTaggableOn::Tagging
.joins('INNER JOIN conversations ON taggings.taggable_id = conversations.id')
.joins('INNER JOIN tags ON taggings.tag_id = tags.id')
.where(taggable_type: 'Conversation', context: 'labels', conversations: conversation_filter)
.where(tags: { name: label_names })
.group('conversations.inbox_id', 'tags.name')
.count
end
def build_matrix
counts = fetch_grouped_counts
filtered_inboxes.map do |inbox|
filtered_labels.map do |label|
counts[[inbox.id, label.title]] || 0
end
end
end
end
@@ -11,6 +11,18 @@ class V2::Reports::InboxSummaryBuilder < V2::Reports::BaseSummaryBuilder
attr_reader :conversations_count, :resolved_count, attr_reader :conversations_count, :resolved_count,
:avg_resolution_time, :avg_first_response_time, :avg_reply_time :avg_resolution_time, :avg_first_response_time, :avg_reply_time
def load_data
@conversations_count = fetch_conversations_count
@resolved_count = fetch_resolved_count
@avg_resolution_time = fetch_average_time('conversation_resolved')
@avg_first_response_time = fetch_average_time('first_response')
@avg_reply_time = fetch_average_time('reply_time')
end
def fetch_conversations_count
account.conversations.where(created_at: range).group(group_by_key).count
end
def prepare_report def prepare_report
account.inboxes.map do |inbox| account.inboxes.map do |inbox|
build_inbox_stats(inbox) build_inbox_stats(inbox)
@@ -31,4 +43,8 @@ class V2::Reports::InboxSummaryBuilder < V2::Reports::BaseSummaryBuilder
def group_by_key def group_by_key
:inbox_id :inbox_id
end end
def average_value_key
ActiveModel::Type::Boolean.new.cast(params[:business_hours]) ? :value_in_business_hours : :value
end
end end
@@ -1,79 +0,0 @@
class V2::Reports::OutgoingMessagesCountBuilder
include DateRangeHelper
attr_reader :account, :params
def initialize(account, params)
@account = account
@params = params
end
def build
send("build_by_#{params[:group_by]}")
end
private
def base_messages
account.messages.outgoing.unscope(:order).where(created_at: range)
end
def build_by_agent
counts = base_messages
.where(sender_type: 'User')
.where.not(sender_id: nil)
.group(:sender_id)
.count
user_names = account.users.where(id: counts.keys).index_by(&:id)
counts.map do |user_id, count|
user = user_names[user_id]
{ id: user_id, name: user&.name, outgoing_messages_count: count }
end
end
def build_by_team
counts = base_messages
.joins('INNER JOIN conversations ON messages.conversation_id = conversations.id')
.where.not(conversations: { team_id: nil })
.group('conversations.team_id')
.count
team_names = account.teams.where(id: counts.keys).index_by(&:id)
counts.map do |team_id, count|
team = team_names[team_id]
{ id: team_id, name: team&.name, outgoing_messages_count: count }
end
end
def build_by_inbox
counts = base_messages
.group(:inbox_id)
.count
inbox_names = account.inboxes.where(id: counts.keys).index_by(&:id)
counts.map do |inbox_id, count|
inbox = inbox_names[inbox_id]
{ id: inbox_id, name: inbox&.name, outgoing_messages_count: count }
end
end
def build_by_label
counts = base_messages
.joins('INNER JOIN conversations ON messages.conversation_id = conversations.id')
.joins("INNER JOIN taggings ON taggings.taggable_id = conversations.id
AND taggings.taggable_type = 'Conversation' AND taggings.context = 'labels'")
.joins('INNER JOIN tags ON tags.id = taggings.tag_id')
.group('tags.name')
.count
label_ids = account.labels.where(title: counts.keys).index_by(&:title)
counts.map do |label_name, count|
label = label_ids[label_name]
{ id: label&.id, name: label_name, outgoing_messages_count: count }
end
end
end
@@ -6,6 +6,14 @@ class V2::Reports::TeamSummaryBuilder < V2::Reports::BaseSummaryBuilder
attr_reader :conversations_count, :resolved_count, attr_reader :conversations_count, :resolved_count,
:avg_resolution_time, :avg_first_response_time, :avg_reply_time :avg_resolution_time, :avg_first_response_time, :avg_reply_time
def fetch_conversations_count
account.conversations.where(created_at: range).group(:team_id).count
end
def reporting_events
@reporting_events ||= account.reporting_events.where(created_at: range).joins(:conversation)
end
def prepare_report def prepare_report
account.teams.map do |team| account.teams.map do |team|
build_team_stats(team) build_team_stats(team)
@@ -0,0 +1,48 @@
class V2::Reports::Timeseries::AverageReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder
def timeseries
grouped_average_time = reporting_events.average(average_value_key)
grouped_event_count = reporting_events.count
grouped_average_time.each_with_object([]) do |element, arr|
event_date, average_time = element
arr << {
value: average_time,
timestamp: event_date.in_time_zone(timezone).to_i,
count: grouped_event_count[event_date]
}
end
end
def aggregate_value
object_scope.average(average_value_key)
end
private
def event_name
metric_to_event_name = {
avg_first_response_time: :first_response,
avg_resolution_time: :conversation_resolved,
reply_time: :reply_time
}
metric_to_event_name[params[:metric].to_sym]
end
def object_scope
scope.reporting_events.where(name: event_name, created_at: range, account_id: account.id)
end
def reporting_events
@grouped_values = object_scope.group_by_period(
group_by,
:created_at,
default_value: 0,
range: range,
permit: %w[day week month year hour],
time_zone: timezone
)
end
def average_value_key
@average_value_key ||= params[:business_hours].present? ? :value_in_business_hours : :value
end
end
@@ -1,13 +1,12 @@
class V2::Reports::Timeseries::BaseTimeseriesBuilder class V2::Reports::Timeseries::BaseTimeseriesBuilder
include TimezoneHelper include TimezoneHelper
include DateRangeHelper include DateRangeHelper
DEFAULT_GROUP_BY = 'day'.freeze DEFAULT_GROUP_BY = 'day'.freeze
pattr_initialize :account, :params pattr_initialize :account, :params
def scope def scope
case dimension_type.to_sym case params[:type].to_sym
when :account when :account
account account
when :inbox when :inbox
@@ -21,20 +20,6 @@ class V2::Reports::Timeseries::BaseTimeseriesBuilder
end end
end end
def data_source
@data_source ||= Reports::DataSource.for(
account: account,
metric: params[:metric],
dimension_type: dimension_type,
dimension_id: params[:id],
scope: scope,
range: range,
group_by: group_by,
timezone_offset: params[:timezone_offset],
business_hours: params[:business_hours]
)
end
def inbox def inbox
@inbox ||= account.inboxes.find(params[:id]) @inbox ||= account.inboxes.find(params[:id])
end end
@@ -58,10 +43,4 @@ class V2::Reports::Timeseries::BaseTimeseriesBuilder
def timezone def timezone
@timezone ||= timezone_name_from_offset(params[:timezone_offset]) @timezone ||= timezone_name_from_offset(params[:timezone_offset])
end end
private
def dimension_type
(params[:type].presence || 'account').to_s
end
end end
@@ -0,0 +1,78 @@
class V2::Reports::Timeseries::CountReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder
def timeseries
grouped_count.each_with_object([]) do |element, arr|
event_date, event_count = element
# The `event_date` is in Date format (without time), such as "Wed, 15 May 2024".
# We need a timestamp for the start of the day. However, we can't use `event_date.to_time.to_i`
# because it converts the date to 12:00 AM server timezone.
# The desired output should be 12:00 AM in the specified timezone.
arr << { value: event_count, timestamp: event_date.in_time_zone(timezone).to_i }
end
end
def aggregate_value
object_scope.count
end
private
def metric
@metric ||= params[:metric]
end
def object_scope
send("scope_for_#{metric}")
end
def scope_for_conversations_count
scope.conversations.where(account_id: account.id, created_at: range)
end
def scope_for_incoming_messages_count
scope.messages.where(account_id: account.id, created_at: range).incoming.unscope(:order)
end
def scope_for_outgoing_messages_count
scope.messages.where(account_id: account.id, created_at: range).outgoing.unscope(:order)
end
def scope_for_resolutions_count
scope.reporting_events.where(
name: :conversation_resolved,
account_id: account.id,
created_at: range
)
end
def scope_for_bot_resolutions_count
scope.reporting_events.where(
name: :conversation_bot_resolved,
account_id: account.id,
created_at: range
)
end
def scope_for_bot_handoffs_count
scope.reporting_events.joins(:conversation).select(:conversation_id).where(
name: :conversation_bot_handoff,
account_id: account.id,
created_at: range
).distinct
end
def grouped_count
# IMPORTANT: time_zone parameter affects both data grouping AND output timestamps
# It converts timestamps to the target timezone before grouping, which means
# the same event can fall into different day buckets depending on timezone
# Example: 2024-01-15 00:00 UTC becomes 2024-01-14 16:00 PST (falls on different day)
@grouped_values = object_scope.group_by_period(
group_by,
:created_at,
default_value: 0,
range: range,
permit: %w[day week month year hour],
time_zone: timezone
).count
end
end
@@ -1,9 +0,0 @@
class V2::Reports::Timeseries::ReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder
def timeseries
data_source.timeseries
end
def aggregate_value
data_source.aggregate
end
end
-74
View File
@@ -1,74 +0,0 @@
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
@@ -1,9 +1,10 @@
class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
before_action :current_account
before_action :check_authorization before_action :check_authorization
before_action :agent_bot, except: [:index, :create] before_action :agent_bot, except: [:index, :create]
def index def index
@agent_bots = AgentBot.accessible_to(Current.account) @agent_bots = AgentBot.where(account_id: [nil, Current.account.id])
end end
def show; end def show; end
@@ -33,14 +34,10 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
@agent_bot.reload @agent_bot.reload
end end
def reset_secret
@agent_bot.reset_secret!
end
private private
def agent_bot def agent_bot
@agent_bot = AgentBot.accessible_to(Current.account).find(params[:id]) if params[:action] == 'show' @agent_bot = AgentBot.where(account_id: [nil, Current.account.id]).find(params[:id]) if params[:action] == 'show'
@agent_bot ||= Current.account.agent_bots.find(params[:id]) @agent_bot ||= Current.account.agent_bots.find(params[:id])
end end
@@ -1,6 +1,8 @@
class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
before_action :fetch_agent, except: [:create, :index, :bulk_create] before_action :fetch_agent, except: [:create, :index, :bulk_create]
before_action :check_authorization before_action :check_authorization
before_action :validate_limit, only: [:create]
before_action :validate_limit_for_bulk_create, only: [:bulk_create]
def index def index
@agents = agents @agents = agents
@@ -18,8 +20,6 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
) )
@agent = builder.perform @agent = builder.perform
rescue AgentBuilder::LimitExceededError => e
render_payment_required(e.message)
end end
def update def update
@@ -36,13 +36,25 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
def bulk_create def bulk_create
emails = params[:emails] emails = params[:emails]
bulk_create_agents(emails) emails.each do |email|
builder = AgentBuilder.new(
email: email,
name: email.split('@').first,
inviter: current_user,
account: Current.account
)
begin
builder.perform
rescue ActiveRecord::RecordInvalid => e
Rails.logger.info "[Agent#bulk_create] ignoring email #{email}, errors: #{e.record.errors}"
end
end
# This endpoint is used to bulk create agents during onboarding # This endpoint is used to bulk create agents during onboarding
# onboarding_step key in present in Current account custom attributes, since this is a one time operation # onboarding_step key in present in Current account custom attributes, since this is a one time operation
clear_onboarding_step Current.account.custom_attributes.delete('onboarding_step')
Current.account.save!
head :ok head :ok
rescue AgentBuilder::LimitExceededError => e
render_payment_required(e.message)
end end
private private
@@ -75,33 +87,22 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
@agents ||= Current.account.users.order_by_full_name.includes(:account_users, { avatar_attachment: [:blob] }) @agents ||= Current.account.users.order_by_full_name.includes(:account_users, { avatar_attachment: [:blob] })
end end
def bulk_create_agents(emails) def validate_limit_for_bulk_create
Current.account.with_lock do limit_available = params[:emails].count <= available_agent_count
raise AgentBuilder::LimitExceededError if emails.count > available_agent_count
emails.each { |email| create_agent_from_email(email) } render_payment_required('Account limit exceeded. Please purchase more licenses') unless limit_available
end
end end
def create_agent_from_email(email) def validate_limit
builder = AgentBuilder.new( render_payment_required('Account limit exceeded. Please purchase more licenses') unless can_add_agent?
email: email,
name: email.split('@').first,
inviter: current_user,
account: Current.account
)
builder.perform
rescue ActiveRecord::RecordInvalid => e
Rails.logger.info "[Agent#bulk_create] ignoring email #{email}, errors: #{e.record.errors}"
end
def clear_onboarding_step
Current.account.custom_attributes.delete('onboarding_step')
Current.account.save!
end end
def available_agent_count def available_agent_count
Current.account.usage_limits[:agents] - Current.account.account_users.count Current.account.usage_limits[:agents] - agents.count
end
def can_add_agent?
available_agent_count.positive?
end end
def delete_user_record(agent) def delete_user_record(agent)
@@ -1,59 +0,0 @@
class Api::V1::Accounts::Articles::BulkActionsController < Api::V1::Accounts::BaseController
before_action :portal
before_action :check_authorization
before_action :set_articles, only: [:update_status, :update_category, :delete_articles]
def translate
head :not_implemented
end
def update_status
return render_could_not_create_error(I18n.t('portals.articles.no_articles_found')) if @articles.none?
return render_could_not_create_error(I18n.t('portals.articles.invalid_status')) unless Article.statuses.key?(params[:status])
ActiveRecord::Base.transaction do
@articles.find_each { |article| article.update!(status: params[:status]) }
end
head :ok
rescue ActiveRecord::RecordInvalid => e
render_could_not_create_error(e.message)
end
def update_category
return render_could_not_create_error(I18n.t('portals.articles.no_articles_found')) if @articles.none?
return render_could_not_create_error(I18n.t('portals.articles.category_not_found')) unless category_valid?
ActiveRecord::Base.transaction do
@articles.find_each { |article| article.update!(category_id: params[:category_id]) }
end
head :ok
rescue ActiveRecord::RecordInvalid => e
render_could_not_create_error(e.message)
end
def delete_articles
return render_could_not_create_error(I18n.t('portals.articles.no_articles_found')) if @articles.none?
@articles.destroy_all
head :ok
end
private
def portal
@portal ||= Current.account.portals.find_by!(slug: params[:portal_id])
end
def check_authorization
authorize(Article, :create?)
end
def set_articles
@articles = @portal.articles.where(id: params[:ids])
end
def category_valid?
@portal.categories.exists?(id: params[:category_id])
end
end
Api::V1::Accounts::Articles::BulkActionsController.prepend_mod_with('Api::V1::Accounts::Articles::BulkActionsController')
@@ -22,16 +22,15 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
def edit; end def edit; end
def create def create
params_with_defaults = article_params @article = @portal.articles.create!(article_params)
params_with_defaults[:status] ||= :draft
@article = @portal.articles.create!(params_with_defaults)
@article.associate_root_article(article_params[:associated_article_id]) @article.associate_root_article(article_params[:associated_article_id])
@article.draft!
render json: { error: @article.errors.messages }, status: :unprocessable_entity and return unless @article.valid? render json: { error: @article.errors.messages }, status: :unprocessable_entity and return unless @article.valid?
end end
def update def update
persist_article_changes if params[:article].present? @article.update!(article_params) if params[:article].present?
render json: { message: @article.errors.full_messages.to_sentence }, status: :unprocessable_entity and return unless @article.valid? render json: { error: @article.errors.messages }, status: :unprocessable_entity and return unless @article.valid?
end end
def destroy def destroy
@@ -40,8 +39,8 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
end end
def reorder def reorder
positions = Article.update_positions(portal: @portal, positions_hash: params[:positions_hash]) Article.update_positions(params[:positions_hash])
render json: { positions: positions } head :ok
end end
private private
@@ -67,24 +66,10 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
@portal ||= Current.account.portals.find_by!(slug: params[:portal_id]) @portal ||= Current.account.portals.find_by!(slug: params[:portal_id])
end end
# Draft-only autosaves must not bump the public-facing updated_at, so write
# them with update_columns (which skips the timestamp). update_columns also
# skips validations, so assign and validate first to avoid persisting content
# that exceeds the column length limit.
def persist_article_changes
keys = article_params.to_h.keys
if keys.any? && (keys - %w[draft_title draft_content]).empty?
@article.assign_attributes(article_params)
@article.update_columns(article_params.to_h) if @article.valid? # rubocop:disable Rails/SkipsModelValidations
else
@article.update!(article_params)
end
end
def article_params def article_params
params.require(:article).permit( params.require(:article).permit(
:title, :slug, :position, :content, :description, :category_id, :author_id, :associated_article_id, :status, :title, :slug, :position, :content, :description, :category_id, :author_id, :associated_article_id, :status,
:locale, :draft_title, :draft_content, meta: [:title, :locale, meta: [:title,
:description, :description,
{ tags: [] }] { tags: [] }]
) )
@@ -2,8 +2,6 @@ class Api::V1::Accounts::AssignableAgentsController < Api::V1::Accounts::BaseCon
before_action :fetch_inboxes before_action :fetch_inboxes
def index def index
# TODO: Remove this opt-in once mobile clients support AgentBot assignees in this payload.
@include_agent_bots = params[:include_agent_bots].present?
agent_ids = @inboxes.map do |inbox| agent_ids = @inboxes.map do |inbox|
authorize inbox, :show? authorize inbox, :show?
member_ids = inbox.members.pluck(:user_id) member_ids = inbox.members.pluck(:user_id)
@@ -12,7 +10,6 @@ class Api::V1::Accounts::AssignableAgentsController < Api::V1::Accounts::BaseCon
agent_ids = agent_ids.inject(:&) agent_ids = agent_ids.inject(:&)
agents = Current.account.users.where(id: agent_ids) agents = Current.account.users.where(id: agent_ids)
@assignable_agents = (agents + Current.account.administrators).uniq @assignable_agents = (agents + Current.account.administrators).uniq
@agent_bots = @include_agent_bots ? AgentBot.accessible_to(Current.account) : []
end end
private private
@@ -30,8 +30,7 @@ class Api::V1::Accounts::AssignmentPoliciesController < Api::V1::Accounts::BaseC
def assignment_policy_params def assignment_policy_params
params.require(:assignment_policy).permit( params.require(:assignment_policy).permit(
:name, :description, :assignment_order, :conversation_priority, :name, :description, :assignment_order, :conversation_priority,
:fair_distribution_limit, :fair_distribution_window, :enabled, :fair_distribution_limit, :fair_distribution_window, :enabled
:exclude_older_than_hours
) )
end end
end end
@@ -1,6 +1,4 @@
class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseController class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseController
include AttachmentConcern
before_action :check_authorization before_action :check_authorization
before_action :fetch_automation_rule, only: [:show, :update, :destroy, :clone] before_action :fetch_automation_rule, only: [:show, :update, :destroy, :clone]
@@ -11,32 +9,25 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
def show; end def show; end
def create 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 = Current.account.automation_rules.new(automation_rules_permit)
@automation_rule.actions = actions @automation_rule.actions = params[:actions]
@automation_rule.conditions = params[:conditions] @automation_rule.conditions = params[:conditions]
return render_could_not_create_error(@automation_rule.errors.messages) unless @automation_rule.valid? render json: { error: @automation_rule.errors.messages }, status: :unprocessable_entity and return unless @automation_rule.valid?
@automation_rule.save! @automation_rule.save!
blobs.each { |blob| @automation_rule.files.attach(blob) } process_attachments
@automation_rule
end end
def update 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 ActiveRecord::Base.transaction do
@automation_rule.assign_attributes(automation_rules_permit) automation_rule_update
@automation_rule.actions = actions if params[:actions] process_attachments
@automation_rule.conditions = params[:conditions] if params[:conditions]
@automation_rule.save!
blobs.each { |blob| @automation_rule.files.attach(blob) }
rescue StandardError => e rescue StandardError => e
Rails.logger.error e Rails.logger.error e
render_could_not_create_error(@automation_rule.errors.messages) render json: { error: @automation_rule.errors.messages }.to_json, status: :unprocessable_entity
end end
end end
@@ -52,11 +43,29 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
@automation_rule = new_rule @automation_rule = new_rule
end 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 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 def automation_rules_permit
params.permit( params.permit(
:name, :description, :event_name, :active, :name, :description, :event_name, :account_id, :active,
conditions: [:attribute_key, :filter_operator, :query_operator, :custom_attribute_type, { values: [] }], conditions: [:attribute_key, :filter_operator, :query_operator, :custom_attribute_type, { values: [] }],
actions: [:action_name, { action_params: [] }] actions: [:action_name, { action_params: [] }]
) )
@@ -2,14 +2,5 @@ class Api::V1::Accounts::BaseController < Api::BaseController
include SwitchLocale include SwitchLocale
include EnsureCurrentAccountHelper include EnsureCurrentAccountHelper
before_action :current_account before_action :current_account
before_action :validate_token_api_access, if: :authenticate_by_access_token?
around_action :switch_locale_using_account_locale around_action :switch_locale_using_account_locale
private
def validate_token_api_access
return if Current.account.api_and_webhooks_enabled?
render json: { error: 'API access is not enabled for this account' }, status: :forbidden
end
end end
@@ -1,28 +0,0 @@
class Api::V1::Accounts::BrandedEmailLayoutsController < Api::V1::Accounts::BaseController
before_action :check_admin_authorization?
def show
set_branded_email_layout
end
def update
unless Current.account.feature_enabled?(:branded_email_templates)
render_could_not_create_error('Branded email templates feature is not enabled')
return
end
branded_email_layout = params[:branded_email_layout] == 'null' ? nil : params[:branded_email_layout]
EmailTemplate.update_account_branded_layout!(account: Current.account, body: branded_email_layout) if params.key?(:branded_email_layout)
set_branded_email_layout
rescue ActiveRecord::RecordInvalid => e
render_could_not_create_error(e.record.errors.full_messages.join(', '))
end
private
def set_branded_email_layout
@branded_email_layout = EmailTemplate.account_branded_layout_template_for(Current.account)&.body
end
end
Api::V1::Accounts::BrandedEmailLayoutsController.prepend_mod_with('Api::V1::Accounts::BrandedEmailLayoutsController')
@@ -1,12 +1,13 @@
class Api::V1::Accounts::BulkActionsController < Api::V1::Accounts::BaseController class Api::V1::Accounts::BulkActionsController < Api::V1::Accounts::BaseController
before_action :type_matches?
def create def create
case normalized_type if type_matches?
when 'Conversation' ::BulkActionsJob.perform_later(
enqueue_conversation_job account: @current_account,
head :ok user: current_user,
when 'Contact' params: permitted_params
check_authorization_for_contact_action )
enqueue_contact_job
head :ok head :ok
else else
render json: { success: false }, status: :unprocessable_entity render json: { success: false }, status: :unprocessable_entity
@@ -15,54 +16,11 @@ class Api::V1::Accounts::BulkActionsController < Api::V1::Accounts::BaseControll
private private
def normalized_type def type_matches?
params[:type].to_s.camelize ['Conversation'].include?(params[:type])
end end
def enqueue_conversation_job def permitted_params
::BulkActionsJob.perform_later( params.permit(:type, :snoozed_until, ids: [], fields: [:status, :assignee_id, :team_id], labels: [add: [], remove: []])
account: @current_account,
user: current_user,
params: conversation_params
)
end
def enqueue_contact_job
Contacts::BulkActionJob.perform_later(
@current_account.id,
current_user.id,
contact_params
)
end
def delete_contact_action?
params[:action_name] == 'delete'
end
def check_authorization_for_contact_action
authorize(Contact, :destroy?) if delete_contact_action?
end
def conversation_params
# TODO: Align conversation payloads with the `{ action_name, action_attributes }`
# and then remove this method in favor of a common params method.
base = params.permit(
:snoozed_until,
fields: [:status, :assignee_id, :team_id]
)
append_common_bulk_attributes(base)
end
def contact_params
# TODO: remove this method in favor of a common params method.
# once legacy conversation payloads are migrated.
append_common_bulk_attributes({})
end
def append_common_bulk_attributes(base_params)
# NOTE: Conversation payloads historically diverged per action. Going forward we
# want all objects to share a common contract: `{ action_name, action_attributes }`
common = params.permit(:type, :action_name, ids: [], labels: [add: [], remove: []])
base_params.merge(common)
end end
end end
@@ -6,7 +6,6 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
page_access_token = params[:page_access_token] page_access_token = params[:page_access_token]
page_id = params[:page_id] page_id = params[:page_id]
inbox_name = params[:inbox_name] inbox_name = params[:inbox_name]
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
facebook_channel = Current.account.facebook_pages.create!( facebook_channel = Current.account.facebook_pages.create!(
page_id: page_id, user_access_token: user_access_token, page_id: page_id, user_access_token: user_access_token,
@@ -16,8 +15,6 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
set_instagram_id(page_access_token, facebook_channel) set_instagram_id(page_access_token, facebook_channel)
set_avatar(@facebook_inbox, page_id) set_avatar(@facebook_inbox, page_id)
end end
rescue CustomExceptions::Inbox::LimitExceeded => e
render_error_response(e)
rescue StandardError => e rescue StandardError => e
ChatwootExceptionTracker.new(e).capture_exception ChatwootExceptionTracker.new(e).capture_exception
Rails.logger.error "Error in register_facebook_page: #{e.message}" Rails.logger.error "Error in register_facebook_page: #{e.message}"
@@ -1,83 +0,0 @@
class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::BaseController
before_action :authorize_account_update, only: [:update]
def show
render json: preferences_payload
end
def update
params_to_update = captain_params
@current_account.captain_models = params_to_update[:captain_models] if params_to_update.key?(:captain_models)
@current_account.captain_features = params_to_update[:captain_features] if params_to_update.key?(:captain_features)
@current_account.save!
render json: preferences_payload
end
private
def preferences_payload
{
providers: Llm::Models.providers,
models: Llm::Models.models,
features: features_with_account_preferences
}
end
def authorize_account_update
authorize @current_account, :update?
end
def captain_params
permitted = {}
permitted[:captain_models] = merged_captain_models if params[:captain_models].present?
permitted[:captain_features] = merged_captain_features if params[:captain_features].present?
permitted
end
def merged_captain_models
existing_models = @current_account.captain_models || {}
existing_models.merge(permitted_captain_models).compact_blank.presence
end
def merged_captain_features
existing_features = @current_account.captain_features || {}
existing_features.merge(permitted_captain_features)
end
def permitted_captain_models
params.require(:captain_models).permit(*captain_feature_keys).to_h.stringify_keys
end
def permitted_captain_features
params.require(:captain_features).permit(*captain_feature_keys).to_h.stringify_keys
end
def captain_feature_keys
Llm::Models.feature_keys.map(&:to_sym)
end
def features_with_account_preferences
preferences = Current.account.captain_preferences
account_features = preferences[:features] || {}
Llm::Models.feature_keys.index_with do |feature_key|
config = Llm::Models.feature_config(feature_key)
route = Llm::FeatureRouter.resolve(feature: feature_key, account: Current.account)
config.merge(
default: default_model_for(feature_key),
enabled: account_features[feature_key] == true,
model: route[:model],
selected: route[:model],
provider: route[:provider],
source: route[:source]
)
end
end
def default_model_for(feature_key)
return Llm::FeatureRouter::CAPTAIN_V2_ASSISTANT_MODEL if feature_key == 'assistant' && Current.account.feature_enabled?('captain_integration_v2')
Llm::Models.default_model_for(feature_key)
end
end
@@ -1,73 +0,0 @@
class Api::V1::Accounts::Captain::TasksController < Api::V1::Accounts::BaseController
before_action :check_authorization
def rewrite
result = Captain::RewriteService.new(
account: Current.account,
content: params[:content],
operation: params[:operation],
conversation_display_id: params[:conversation_display_id]
).perform
render_result(result)
end
def summarize
result = Captain::SummaryService.new(
account: Current.account,
conversation_display_id: params[:conversation_display_id]
).perform
render_result(result)
end
def reply_suggestion
result = Captain::ReplySuggestionService.new(
account: Current.account,
conversation_display_id: params[:conversation_display_id],
user: Current.user
).perform
render_result(result)
end
def label_suggestion
result = Captain::LabelSuggestionService.new(
account: Current.account,
conversation_display_id: params[:conversation_display_id]
).perform
render_result(result)
end
def follow_up
result = Captain::FollowUpService.new(
account: Current.account,
follow_up_context: params[:follow_up_context]&.to_unsafe_h,
user_message: params[:message],
conversation_display_id: params[:conversation_display_id]
).perform
render_result(result)
end
private
def render_result(result)
if result.nil?
render json: { message: nil }
elsif result[:error]
render json: { error: result[:error] }, status: :unprocessable_content
else
response_data = { message: result[:message] }
response_data[:follow_up_context] = result[:follow_up_context] if result[:follow_up_context]
render json: response_data
end
end
def check_authorization
authorize(:'captain/tasks')
end
end
Api::V1::Accounts::Captain::TasksController.prepend_mod_with('Api::V1::Accounts::Captain::TasksController')
@@ -1,7 +1,7 @@
class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseController class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseController
before_action :portal before_action :portal
before_action :check_authorization before_action :check_authorization
before_action :fetch_category, except: [:index, :create, :reorder] before_action :fetch_category, except: [:index, :create]
before_action :set_current_page, only: [:index] before_action :set_current_page, only: [:index]
def index def index
@@ -32,11 +32,6 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle
head :ok head :ok
end end
def reorder
Category.update_positions(portal: @portal, positions_hash: params[:positions_hash])
head :ok
end
private private
def fetch_category def fetch_category
@@ -44,7 +39,7 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle
end end
def portal def portal
@portal ||= Current.account.portals.find_by!(slug: params[:portal_id]) @portal ||= Current.account.portals.find_by(slug: params[:portal_id])
end end
def related_categories_records def related_categories_records
@@ -53,7 +48,7 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle
def category_params def category_params
params.require(:category).permit( params.require(:category).permit(
:name, :description, :position, :slug, :locale, :icon, :icon_color, :parent_category_id, :associated_category_id :name, :description, :position, :slug, :locale, :icon, :parent_category_id, :associated_category_id
) )
end end
@@ -6,8 +6,6 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts:
def create def create
process_create process_create
rescue CustomExceptions::Inbox::LimitExceeded => e
render_error_response(e)
rescue StandardError => e rescue StandardError => e
render_could_not_create_error(e.message) render_could_not_create_error(e.message)
end end
@@ -66,7 +64,7 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts:
def permitted_params def permitted_params
params.require(:twilio_channel).permit( params.require(:twilio_channel).permit(
:messaging_service_sid, :phone_number, :account_sid, :auth_token, :name, :medium, :api_key_sid :account_id, :messaging_service_sid, :phone_number, :account_sid, :auth_token, :name, :medium, :api_key_sid
) )
end end
end end
@@ -1,55 +0,0 @@
module Api::V1::Accounts::Concerns::WhatsappHealthManagement
extend ActiveSupport::Concern
included do
skip_before_action :check_authorization, only: [:health, :register_webhook]
before_action :check_admin_authorization?, only: [:register_webhook]
before_action :validate_whatsapp_cloud_channel, only: [:health, :register_webhook]
end
def sync_templates
return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' } unless whatsapp_channel?
trigger_template_sync
render status: :ok, json: { message: 'Template sync initiated successfully' }
rescue StandardError => e
render status: :internal_server_error, json: { error: e.message }
end
def health
health_data = Whatsapp::HealthService.new(@inbox.channel).fetch_health_status
render json: health_data
rescue StandardError => e
Rails.logger.error "[INBOX HEALTH] Error fetching health data: #{e.message}"
render json: { error: e.message }, status: :unprocessable_entity
end
def register_webhook
Whatsapp::WebhookSetupService.new(@inbox.channel).register_callback
render json: { message: 'Webhook registered successfully' }, status: :ok
rescue StandardError => e
Rails.logger.error "[INBOX WEBHOOK] Webhook registration failed: #{e.message}"
render json: { error: e.message }, status: :unprocessable_entity
end
private
def validate_whatsapp_cloud_channel
return if @inbox.channel.is_a?(Channel::Whatsapp) && @inbox.channel.provider == 'whatsapp_cloud'
render json: { error: 'Health data only available for WhatsApp Cloud API channels' }, status: :bad_request
end
def whatsapp_channel?
@inbox.whatsapp? || (@inbox.twilio? && @inbox.channel.whatsapp?)
end
def trigger_template_sync
if @inbox.whatsapp?
Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel)
elsif @inbox.twilio? && @inbox.channel.whatsapp?
Channels::Twilio::TemplatesSyncJob.perform_later(@inbox.channel)
end
end
end
@@ -1,18 +0,0 @@
class Api::V1::Accounts::Contacts::AttachmentsController < Api::V1::Accounts::Contacts::BaseController
RESULTS_PER_PAGE = 100
def index
conversations = Conversations::PermissionFilterService.new(
Current.account.conversations.where(contact_id: @contact.id),
Current.user,
Current.account
).perform
@attachments = Attachment.where(message_id: Message.where(conversation_id: conversations).select(:id))
.includes({ file_attachment: :blob }, message: [:conversation, :inbox, { sender: { avatar_attachment: :blob } }])
.order(created_at: :desc)
.page(params[:page])
.per(RESULTS_PER_PAGE)
@attachments_count = @attachments.total_count
end
end
@@ -5,7 +5,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
sort_on :phone_number, type: :string sort_on :phone_number, type: :string
sort_on :last_activity_at, internal_name: :order_on_last_activity_at, type: :scope, scope_params: [:direction] sort_on :last_activity_at, internal_name: :order_on_last_activity_at, type: :scope, scope_params: [:direction]
sort_on :created_at, internal_name: :order_on_created_at, type: :scope, scope_params: [:direction] sort_on :created_at, internal_name: :order_on_created_at, type: :scope, scope_params: [:direction]
sort_on :company_name, internal_name: :order_on_company_name, type: :scope, scope_params: [:direction] sort_on :company, internal_name: :order_on_company_name, type: :scope, scope_params: [:direction]
sort_on :city, internal_name: :order_on_city, type: :scope, scope_params: [:direction] sort_on :city, internal_name: :order_on_city, type: :scope, scope_params: [:direction]
sort_on :country, internal_name: :order_on_country_name, type: :scope, scope_params: [:direction] sort_on :country, internal_name: :order_on_country_name, type: :scope, scope_params: [:direction]
@@ -17,18 +17,20 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
before_action :set_include_contact_inboxes, only: [:index, :active, :search, :filter, :show, :update] before_action :set_include_contact_inboxes, only: [:index, :active, :search, :filter, :show, :update]
def index def index
@contacts_count = resolved_contacts.count
@contacts = fetch_contacts(resolved_contacts) @contacts = fetch_contacts(resolved_contacts)
@contacts_count = @contacts.total_count
end end
def search def search
render json: { error: 'Specify search string with parameter q' }, status: :unprocessable_entity if params[:q].blank? && return render json: { error: 'Specify search string with parameter q' }, status: :unprocessable_entity if params[:q].blank? && return
contacts = Current.account.contacts.where( contacts = resolved_contacts.where(
'name ILIKE :search OR email ILIKE :search OR phone_number ILIKE :search OR contacts.identifier LIKE :search', 'name ILIKE :search OR email ILIKE :search OR phone_number ILIKE :search OR contacts.identifier LIKE :search
OR contacts.additional_attributes->>\'company_name\' ILIKE :search',
search: "%#{params[:q].strip}%" search: "%#{params[:q].strip}%"
) )
@contacts = fetch_contacts_with_has_more(contacts) @contacts_count = contacts.count
@contacts = fetch_contacts(contacts)
end end
def import def import
@@ -53,8 +55,8 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
def active def active
contacts = Current.account.contacts.where(id: ::OnlineStatusTracker contacts = Current.account.contacts.where(id: ::OnlineStatusTracker
.get_available_contact_ids(Current.account.id)) .get_available_contact_ids(Current.account.id))
@contacts_count = contacts.count
@contacts = fetch_contacts(contacts) @contacts = fetch_contacts(contacts)
@contacts_count = @contacts.total_count
end end
def show; end def show; end
@@ -131,32 +133,13 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
end end
def fetch_contacts(contacts) def fetch_contacts(contacts)
# Build includes hash to avoid separate query when contact_inboxes are needed contacts_with_avatar = filtrate(contacts)
includes_hash = { avatar_attachment: [:blob] } .includes([{ avatar_attachment: [:blob] }])
includes_hash[:contact_inboxes] = { inbox: :channel } if @include_contact_inboxes .page(@current_page).per(RESULTS_PER_PAGE)
filtrate(contacts) return contacts_with_avatar.includes([{ contact_inboxes: [:inbox] }]) if @include_contact_inboxes
.includes(includes_hash)
.page(@current_page)
.per(RESULTS_PER_PAGE)
end
def fetch_contacts_with_has_more(contacts) contacts_with_avatar
includes_hash = { avatar_attachment: [:blob] }
includes_hash[:contact_inboxes] = { inbox: :channel } if @include_contact_inboxes
# Calculate offset manually to fetch one extra record for has_more check
offset = (@current_page.to_i - 1) * RESULTS_PER_PAGE
results = filtrate(contacts)
.includes(includes_hash)
.offset(offset)
.limit(RESULTS_PER_PAGE + 1)
.to_a
@has_more = results.size > RESULTS_PER_PAGE
results = results.first(RESULTS_PER_PAGE) if @has_more
@contacts_count = results.size
results
end end
def build_contact_inbox def build_contact_inbox
@@ -201,9 +184,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
end end
def fetch_contact def fetch_contact
contact_scope = Current.account.contacts @contact = Current.account.contacts.includes(contact_inboxes: [:inbox]).find(params[:id])
contact_scope = contact_scope.includes(contact_inboxes: [:inbox]) if @include_contact_inboxes
@contact = contact_scope.find(params[:id])
end end
def process_avatar_from_url def process_avatar_from_url
@@ -214,5 +195,3 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
render json: error, status: error_status render json: error, status: error_status
end end
end end
Api::V1::Accounts::ContactsController.prepend_mod_with('Api::V1::Accounts::ContactsController')
@@ -1,7 +1,7 @@
class Api::V1::Accounts::Conversations::AssignmentsController < Api::V1::Accounts::Conversations::BaseController class Api::V1::Accounts::Conversations::AssignmentsController < Api::V1::Accounts::Conversations::BaseController
# assigns agent/team to a conversation # assigns agent/team to a conversation
def create def create
if params.key?(:assignee_id) || agent_bot_assignment? if params.key?(:assignee_id)
set_agent set_agent
elsif params.key?(:team_id) elsif params.key?(:team_id)
set_team set_team
@@ -13,23 +13,17 @@ class Api::V1::Accounts::Conversations::AssignmentsController < Api::V1::Account
private private
def set_agent def set_agent
resource = Conversations::AssignmentService.new( @agent = Current.account.users.find_by(id: params[:assignee_id])
conversation: @conversation, @conversation.assignee = @agent
assignee_id: params[:assignee_id], @conversation.save!
assignee_type: params[:assignee_type] render_agent
).perform
render_agent(resource)
end end
def render_agent(resource) def render_agent
case resource if @agent.nil?
when User
render partial: 'api/v1/models/agent', formats: [:json], locals: { resource: resource }
when AgentBot
render partial: 'api/v1/models/agent_bot_slim', formats: [:json], locals: { resource: resource }
else
render json: nil render json: nil
else
render partial: 'api/v1/models/agent', formats: [:json], locals: { resource: @agent }
end end
end end
@@ -38,8 +32,4 @@ class Api::V1::Accounts::Conversations::AssignmentsController < Api::V1::Account
@conversation.update!(team: @team) @conversation.update!(team: @team)
render json: @team render json: @team
end end
def agent_bot_assignment?
params[:assignee_type].to_s == 'AgentBot'
end
end end
@@ -5,6 +5,6 @@ class Api::V1::Accounts::Conversations::BaseController < Api::V1::Accounts::Base
def conversation def conversation
@conversation ||= Current.account.conversations.find_by!(display_id: params[:conversation_id]) @conversation ||= Current.account.conversations.find_by!(display_id: params[:conversation_id])
authorize @conversation, :show? authorize @conversation.inbox, :show?
end end
end end
@@ -1,17 +1,6 @@
class Api::V1::Accounts::Conversations::DirectUploadsController < ActiveStorage::DirectUploadsController class Api::V1::Accounts::Conversations::DirectUploadsController < ActiveStorage::DirectUploadsController
include DeviseTokenAuth::Concerns::SetUserByToken
include RequestExceptionHandler
include AccessTokenAuthHelper
include EnsureCurrentAccountHelper include EnsureCurrentAccountHelper
skip_before_action :verify_authenticity_token, if: :authenticate_by_access_token?
around_action :handle_with_exception
before_action :authenticate_access_token!, if: :authenticate_by_access_token?
before_action :validate_bot_access_token!, if: :authenticate_by_access_token?
before_action :authenticate_user!, unless: :authenticate_by_access_token?
before_action :current_account before_action :current_account
before_action :validate_token_api_access, if: :authenticate_by_access_token?
before_action :conversation before_action :conversation
def create def create
@@ -22,16 +11,6 @@ class Api::V1::Accounts::Conversations::DirectUploadsController < ActiveStorage:
private private
def authenticate_by_access_token?
request.headers[:api_access_token].present? || request.headers[:HTTP_API_ACCESS_TOKEN].present?
end
def validate_token_api_access
return if Current.account.api_and_webhooks_enabled?
render json: { error: 'API access is not enabled for this account' }, status: :forbidden
end
def conversation def conversation
@conversation ||= Current.account.conversations.find_by(display_id: params[:conversation_id]) @conversation ||= Current.account.conversations.find_by(display_id: params[:conversation_id])
end end
@@ -52,9 +52,6 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
end end
render json: { content: translated_content } render json: { content: translated_content }
rescue Google::Cloud::Error => e
# `details` carries the clean human message; `message` includes gRPC debug noise
render_could_not_create_error(e.details.presence || e.message)
end end
private private
@@ -1,40 +1,27 @@
class Api::V1::Accounts::Conversations::ParticipantsController < Api::V1::Accounts::Conversations::BaseController class Api::V1::Accounts::Conversations::ParticipantsController < Api::V1::Accounts::Conversations::BaseController
include Events::Types
def show def show
@participants = @conversation.conversation_participants @participants = @conversation.conversation_participants
end end
def create def create
participant_ids_to_add = participants_to_be_added_ids
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
@participants = participant_ids_to_add.map { |user_id| @conversation.conversation_participants.find_or_create_by(user_id: user_id) } @participants = participants_to_be_added_ids.map { |user_id| @conversation.conversation_participants.find_or_create_by(user_id: user_id) }
end end
notify_unread_count_change if participant_ids_to_add.any?
end end
def update def update
participant_ids_to_add = participants_to_be_added_ids
participant_ids_to_remove = participants_to_be_removed_ids
changed_participant_ids = participant_ids_to_add + participant_ids_to_remove
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
participant_ids_to_add.each { |user_id| @conversation.conversation_participants.find_or_create_by(user_id: user_id) } participants_to_be_added_ids.each { |user_id| @conversation.conversation_participants.find_or_create_by(user_id: user_id) }
participant_ids_to_remove.each { |user_id| @conversation.conversation_participants.find_by(user_id: user_id)&.destroy } participants_to_be_removed_ids.each { |user_id| @conversation.conversation_participants.find_by(user_id: user_id)&.destroy }
end end
notify_unread_count_change if changed_participant_ids.any?
@participants = @conversation.conversation_participants @participants = @conversation.conversation_participants
render action: 'show' render action: 'show'
end end
def destroy def destroy
participant_ids_to_remove = current_participant_ids & params[:user_ids]
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
params[:user_ids].map { |user_id| @conversation.conversation_participants.find_by(user_id: user_id)&.destroy } params[:user_ids].map { |user_id| @conversation.conversation_participants.find_by(user_id: user_id)&.destroy }
end end
notify_unread_count_change if participant_ids_to_remove.any?
head :ok head :ok
end end
@@ -51,11 +38,4 @@ class Api::V1::Accounts::Conversations::ParticipantsController < Api::V1::Accoun
def current_participant_ids def current_participant_ids
@current_participant_ids ||= @conversation.conversation_participants.pluck(:user_id) @current_participant_ids ||= @conversation.conversation_participants.pluck(:user_id)
end end
def notify_unread_count_change
return unless Current.account.feature_enabled?('conversation_unread_counts')
return unless Current.account.feature_enabled?('unread_count_for_filters')
Rails.configuration.dispatcher.dispatch(CONVERSATION_UNREAD_COUNT_CHANGED, Time.zone.now, conversation: @conversation)
end
end end
@@ -1,32 +0,0 @@
class Api::V1::Accounts::Conversations::UnreadCountsController < Api::V1::Accounts::BaseController
before_action :ensure_unread_counts_enabled
def index
counts = if filtered_unread_counts_enabled?
instrumentation.summarize_request(account_id: Current.account.id) { unread_counts }
else
unread_counts
end
render json: { payload: counts }
end
private
def unread_counts
::Conversations::UnreadCounts::Counter.new(account: Current.account, user: Current.user).perform
end
def filtered_unread_counts_enabled?
Current.account.feature_enabled?(::Conversations::UnreadCounts::FilteredCounter::FEATURE_FLAG)
end
def instrumentation
::Conversations::UnreadCounts::FilteredCountInstrumentation
end
def ensure_unread_counts_enabled
return if Current.account.feature_enabled?('conversation_unread_counts')
render json: { error: I18n.t('errors.conversations.unread_counts.feature_not_enabled') }, status: :forbidden
end
end
@@ -15,7 +15,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end end
def meta def meta
result = conversation_finder.perform_meta_only result = conversation_finder.perform
@conversations_count = result[:count] @conversations_count = result[:count]
end end
@@ -28,7 +28,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
def attachments def attachments
@attachments_count = @conversation.attachments.count @attachments_count = @conversation.attachments.count
@attachments = @conversation.attachments @attachments = @conversation.attachments
.includes({ file_attachment: :blob }, message: [:inbox, { sender: { avatar_attachment: :blob } }]) .includes(:message)
.order(created_at: :desc) .order(created_at: :desc)
.page(attachment_params[:page]) .page(attachment_params[:page])
.per(ATTACHMENT_RESULTS_PER_PAGE) .per(ATTACHMENT_RESULTS_PER_PAGE)
@@ -70,11 +70,8 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
def transcript def transcript
render json: { error: 'email param missing' }, status: :unprocessable_entity and return if params[:email].blank? render json: { error: 'email param missing' }, status: :unprocessable_entity and return if params[:email].blank?
return render_payment_required('Email transcript is not available on your plan') unless @conversation.account.email_transcript_enabled?
return head :too_many_requests unless @conversation.account.within_email_rate_limit?
ConversationReplyMailer.with(account: @conversation.account).conversation_transcript(@conversation, params[:email])&.deliver_later ConversationReplyMailer.with(account: @conversation.account).conversation_transcript(@conversation, params[:email])&.deliver_later
@conversation.account.increment_email_sent_count
head :ok head :ok
end end
@@ -107,23 +104,12 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end end
def toggle_typing_status def toggle_typing_status
typing_status_manager = ::Conversations::TypingStatusManager.new(@conversation, Current.user, params) typing_status_manager = ::Conversations::TypingStatusManager.new(@conversation, current_user, params)
typing_status_manager.toggle_typing_status typing_status_manager.toggle_typing_status
head :ok head :ok
end end
def update_last_seen def update_last_seen
# High-traffic accounts generate excessive DB writes when agents frequently switch between conversations.
# Throttle last_seen updates to once per hour when there are no unread messages to reduce DB load.
# Always update immediately if there are unread messages to maintain accurate read/unread state.
# Visiting a conversation should clear any unread inbox notifications for this conversation.
Notification::MarkConversationReadService.new(user: Current.user, account: Current.account, conversation: @conversation).perform
return update_last_seen_on_conversation(DateTime.now.utc, true) if assignee? && @conversation.assignee_unread_messages.any?
return update_last_seen_on_conversation(DateTime.now.utc, false) if !assignee? && @conversation.unread_messages.any?
# No unread messages - apply throttling to limit DB writes
return unless should_update_last_seen?
update_last_seen_on_conversation(DateTime.now.utc, assignee?) update_last_seen_on_conversation(DateTime.now.utc, assignee?)
end end
@@ -140,7 +126,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
def destroy def destroy
authorize @conversation, :destroy? authorize @conversation, :destroy?
::Conversations::DeleteService.new(conversation: @conversation, user: Current.user, ip: request.ip).perform ::DeleteObjectJob.perform_later(@conversation, Current.user, request.ip)
head :ok head :ok
end end
@@ -156,26 +142,10 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end end
def update_last_seen_on_conversation(last_seen_at, update_assignee) def update_last_seen_on_conversation(last_seen_at, update_assignee)
updates = { agent_last_seen_at: last_seen_at }
updates[:assignee_last_seen_at] = last_seen_at if update_assignee.present?
# rubocop:disable Rails/SkipsModelValidations # rubocop:disable Rails/SkipsModelValidations
@conversation.update_columns(updates) @conversation.update_column(:agent_last_seen_at, last_seen_at)
@conversation.update_column(:assignee_last_seen_at, last_seen_at) if update_assignee.present?
# rubocop:enable Rails/SkipsModelValidations # rubocop:enable Rails/SkipsModelValidations
::Conversations::UnreadCounts::Notifier.new(@conversation).perform
::Conversations::UnreadCounts::FilteredCountInvalidator.new(Current.account).conversation_changed!
end
def should_update_last_seen?
# Update if at least one relevant timestamp is older than 1 hour or not set
# This prevents redundant DB writes when agents repeatedly view the same conversation
agent_needs_update = @conversation.agent_last_seen_at.blank? || @conversation.agent_last_seen_at < 1.hour.ago
return agent_needs_update unless assignee?
# For assignees, check both timestamps - update if either is old
assignee_needs_update = @conversation.assignee_last_seen_at.blank? || @conversation.assignee_last_seen_at < 1.hour.ago
agent_needs_update || assignee_needs_update
end end
def set_conversation_status def set_conversation_status
@@ -190,7 +160,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
def conversation def conversation
@conversation ||= Current.account.conversations.find_by!(display_id: params[:id]) @conversation ||= Current.account.conversations.find_by!(display_id: params[:id])
authorize @conversation, :show? authorize @conversation.inbox, :show?
end end
def inbox def inbox
@@ -50,5 +50,3 @@ class Api::V1::Accounts::CsatSurveyResponsesController < Api::V1::Accounts::Base
@current_page = params[:page] || 1 @current_page = params[:page] || 1
end end
end end
Api::V1::Accounts::CsatSurveyResponsesController.prepend_mod_with('Api::V1::Accounts::CsatSurveyResponsesController')
@@ -1,7 +1,6 @@
class Api::V1::Accounts::CustomAttributeDefinitionsController < Api::V1::Accounts::BaseController class Api::V1::Accounts::CustomAttributeDefinitionsController < Api::V1::Accounts::BaseController
before_action :fetch_custom_attributes_definitions, except: [:create] before_action :fetch_custom_attributes_definitions, except: [:create]
before_action :fetch_custom_attribute_definition, only: [:show, :update, :destroy] before_action :fetch_custom_attribute_definition, only: [:show, :update, :destroy]
before_action :check_authorization
DEFAULT_ATTRIBUTE_MODEL = 'conversation_attribute'.freeze DEFAULT_ATTRIBUTE_MODEL = 'conversation_attribute'.freeze
def index; end def index; end
@@ -1,5 +1,4 @@
class Api::V1::Accounts::DashboardAppsController < Api::V1::Accounts::BaseController class Api::V1::Accounts::DashboardAppsController < Api::V1::Accounts::BaseController
before_action :check_authorization
before_action :fetch_dashboard_apps, except: [:create] before_action :fetch_dashboard_apps, except: [:create]
before_action :fetch_dashboard_app, only: [:show, :update, :destroy] before_action :fetch_dashboard_app, only: [:show, :update, :destroy]
@@ -1,159 +0,0 @@
require 'csv'
class Api::V1::Accounts::DataImportsController < Api::V1::Accounts::BaseController
DATA_IMPORT_FEATURE = 'data_import'.freeze
before_action :ensure_data_import_feature_enabled
before_action :set_data_import, only: [:show, :start, :abandon, :error_logs, :skip_logs]
before_action :check_authorization
def index
@data_imports = policy_scope(Current.account.data_imports).includes(:initiated_by).order(created_at: :desc)
data_import_ids = @data_imports.map(&:id)
@import_errors_counts = DataImportError.non_skip_logs.where(data_import_id: data_import_ids).group(:data_import_id).count
@skip_logs_counts = DataImportError.skip_logs.where(data_import_id: data_import_ids).group(:data_import_id).count
end
def show
render_show
end
def validate_source
totals = validate_intercom_source
render json: { valid: true, totals: totals }
rescue DataImports::Intercom::Client::AuthenticationError
render_source_validation_error('We could not validate this Intercom access key. Check the key and its permissions.')
rescue DataImports::Intercom::Client::Error
render_source_validation_error('Intercom could not be reached. Please try again.')
rescue ArgumentError => e
render_source_validation_error(e.message)
end
def create
@data_import = creation_service.perform
unless @data_import
render json: { message: 'Another data import is already in progress.' }, status: :unprocessable_entity
return
end
DataImports::Intercom::ImportJob.perform_later(@data_import, @data_import.active_intercom_import_run_id)
render_show
rescue DataImports::Intercom::Client::AuthenticationError
render_source_validation_error('We could not validate this Intercom access key. Check the key and its permissions.')
rescue DataImports::Intercom::Client::Error
render_source_validation_error('Intercom could not be reached. Please try again.')
rescue ArgumentError => e
render_source_validation_error(e.message)
end
def start
restart_service = DataImports::Intercom::RestartService.new(account: Current.account, data_import: @data_import)
restart_result = restart_service.perform
@data_import = restart_service.data_import
if restart_result == :access_token_missing
render json: { message: 'The Intercom access key for this import is unavailable.' }, status: :unprocessable_entity
return
end
DataImports::Intercom::ImportJob.perform_later(@data_import, @data_import.active_intercom_import_run_id) if restart_result == :enqueue
render_show
end
def abandon
@data_import.abandon!
render_show
end
def skip_logs
send_data(
skip_logs_csv,
filename: "data-import-#{@data_import.id}-skip-logs.csv",
type: 'text/csv'
)
end
def error_logs
send_data(
error_logs_csv,
filename: "data-import-#{@data_import.id}-error-logs.csv",
type: 'text/csv'
)
end
private
def ensure_data_import_feature_enabled
raise Pundit::NotAuthorizedError unless Current.account.feature_enabled?(DATA_IMPORT_FEATURE)
end
def set_data_import
@data_import = Current.account.data_imports.find(params[:id])
end
def check_authorization
authorize(@data_import || DataImport)
end
def permitted_params
params.permit(:name, :source_provider, :access_token, import_types: [])
end
def creation_service
DataImports::Intercom::CreationService.new(
account: Current.account,
initiated_by: Current.user,
source_params: permitted_params.to_h
)
end
def import_types
return DataImports::Intercom::Importer::DEFAULT_IMPORT_TYPES unless permitted_params.key?(:import_types)
Array(permitted_params[:import_types]).compact_blank
end
def validate_intercom_source
raise ArgumentError, 'Unsupported import source.' unless permitted_params[:source_provider] == 'intercom'
DataImports::Intercom::CredentialsValidator.new(
access_token: permitted_params[:access_token],
import_types: import_types
).perform
end
def render_source_validation_error(message)
render json: { valid: false, message: message }, status: :unprocessable_entity
end
def render_show
@import_errors_finder = DataImportErrorFinder.new(@data_import)
@skip_logs_finder = DataImportSkipLogFinder.new(@data_import, params)
render :show
end
def skip_logs_csv
logs_csv(@data_import.import_errors.skip_logs)
end
def error_logs_csv
logs_csv(@data_import.import_errors.non_skip_logs)
end
def logs_csv(logs)
CSV.generate(headers: true) do |csv|
csv << %w[created_at kind source_object_type source_object_id error_code message details]
logs.order(:created_at).find_each do |log|
csv << [
log.created_at.iso8601,
log.details['kind'],
log.source_object_type,
log.source_object_id,
log.error_code,
log.message,
log.details.to_json
]
end
end
end
end
@@ -1,135 +0,0 @@
class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseController
before_action :fetch_inbox
before_action :validate_whatsapp_channel
before_action :validate_captain_enabled, only: [:analyze]
def show
service = CsatTemplateManagementService.new(@inbox)
result = service.template_status
if result[:service_error]
render json: { error: result[:service_error] }, status: :internal_server_error
else
render json: result
end
end
def create
template_params = extract_template_params
return render_missing_message_error if template_params[:message].blank?
service = CsatTemplateManagementService.new(@inbox)
result = service.create_template(template_params)
render_template_creation_result(result)
rescue ActionController::ParameterMissing
render json: { error: 'Template parameters are required' }, status: :unprocessable_entity
end
def analyze
template_params = extract_template_params
return render_missing_message_error if template_params[:message].blank?
result = CsatTemplateUtilityAnalysisService.new(
account: Current.account,
inbox: @inbox,
message: template_params[:message],
button_text: template_params[:button_text],
language: template_params[:language]
).perform
render json: result
rescue ActionController::ParameterMissing
render json: { error: 'Template parameters are required' }, status: :unprocessable_entity
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? || @inbox.twilio_whatsapp?
render json: { error: 'CSAT template operations only available for WhatsApp and Twilio 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 validate_captain_enabled
return if Current.account.feature_enabled?('captain_integration')
render json: { error: 'Captain is required for template analysis' }, status: :forbidden
end
def render_template_creation_result(result)
if result[:success]
render_successful_template_creation(result)
elsif result[:service_error]
render json: { error: result[:service_error] }, status: :internal_server_error
else
render_failed_template_creation(result)
end
end
def render_successful_template_creation(result)
if @inbox.twilio_whatsapp?
render json: {
template: {
friendly_name: result[:friendly_name],
content_sid: result[:content_sid],
status: result[:status] || 'pending',
language: result[:language] || 'en'
}
}, status: :created
else
render json: {
template: {
name: result[:template_name],
template_id: result[:template_id],
status: 'PENDING',
language: result[:language] || 'en'
}
}, status: :created
end
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 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
@@ -2,15 +2,12 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
include Api::V1::InboxesHelper include Api::V1::InboxesHelper
before_action :fetch_inbox, except: [:index, :create] before_action :fetch_inbox, except: [:index, :create]
before_action :fetch_agent_bot, only: [:set_agent_bot] before_action :fetch_agent_bot, only: [:set_agent_bot]
before_action :validate_limit, only: [:create]
# we are already handling the authorization in fetch inbox # we are already handling the authorization in fetch inbox
before_action :check_authorization, except: [:show] before_action :check_authorization, except: [:show]
include Api::V1::Accounts::Concerns::WhatsappHealthManagement
def index def index
@inboxes = policy_scope(Current.account.inboxes) @inboxes = policy_scope(Current.account.inboxes.order_by_name.includes(:channel, { avatar_attachment: [:blob] }))
.includes(:channel, :portal, :working_hours, { avatar_attachment: :blob })
.order_by_name
end end
def show; end def show; end
@@ -45,12 +42,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end end
def update def update
continue_update = false
ActiveRecord::Base.transaction do
continue_update = update_branded_email_layout
raise ActiveRecord::Rollback unless continue_update
inbox_params = permitted_params.except(:channel, :csat_config) inbox_params = permitted_params.except(:channel, :csat_config)
inbox_params[:csat_config] = format_csat_config(permitted_params[:csat_config]) if permitted_params[:csat_config].present? inbox_params[:csat_config] = format_csat_config(permitted_params[:csat_config]) if permitted_params[:csat_config].present?
@inbox.update!(inbox_params) @inbox.update!(inbox_params)
@@ -58,9 +49,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
update_channel if channel_update_required? update_channel if channel_update_required?
end end
return unless continue_update
end
def agent_bot def agent_bot
@agent_bot = @inbox.agent_bot @agent_bot = @inbox.agent_bot
end end
@@ -76,17 +64,20 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
head :ok head :ok
end end
def reset_secret
return head :not_found unless @inbox.api?
@inbox.channel.reset_secret!
end
def destroy def destroy
::DeleteObjectJob.perform_later(@inbox, Current.user, request.ip) if @inbox.present? ::DeleteObjectJob.perform_later(@inbox, Current.user, request.ip) if @inbox.present?
render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') } render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') }
end end
def sync_templates
return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' } unless whatsapp_channel?
trigger_template_sync
render status: :ok, json: { message: 'Template sync initiated successfully' }
rescue StandardError => e
render status: :internal_server_error, json: { error: e.message }
end
private private
def fetch_inbox def fetch_inbox
@@ -95,7 +86,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end end
def fetch_agent_bot def fetch_agent_bot
@agent_bot = AgentBot.accessible_to(Current.account).find(params[:agent_bot]) if params[:agent_bot] @agent_bot = AgentBot.find(params[:agent_bot]) if params[:agent_bot]
end end
def create_channel def create_channel
@@ -133,8 +124,8 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end end
def reauthorize_and_update_channel(channel_attributes) def reauthorize_and_update_channel(channel_attributes)
@inbox.channel.update!(permitted_params(channel_attributes)[:channel])
@inbox.channel.reauthorized! if @inbox.channel.respond_to?(:reauthorized!) @inbox.channel.reauthorized! if @inbox.channel.respond_to?(:reauthorized!)
@inbox.channel.update!(permitted_params(channel_attributes)[:channel])
end end
def update_channel_feature_flags def update_channel_feature_flags
@@ -146,65 +137,31 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end end
def format_csat_config(config) def format_csat_config(config)
formatted = { {
'display_type' => config['display_type'] || 'emoji', display_type: config['display_type'] || 'emoji',
'message' => config['message'] || '', message: config['message'] || '',
:survey_rules => { survey_rules: {
'operator' => config.dig('survey_rules', 'operator') || 'contains', operator: config.dig('survey_rules', 'operator') || 'contains',
'values' => config.dig('survey_rules', 'values') || [] 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 end
def format_template_config(config, formatted)
formatted['template'] = config['template'] if config['template'].present?
end
def update_branded_email_layout
return true unless params.key?(:branded_email_layout)
branded_email_layout = normalized_branded_email_layout
unless Current.account.feature_enabled?(:branded_email_templates)
return true if branded_email_layout.blank?
render_could_not_create_error('Branded email templates feature is not enabled')
return false
end
unless @inbox.email?
return true if branded_email_layout.blank?
render_could_not_create_error('Branded email layout is only supported for email inboxes')
return false
end
@inbox.update_branded_email_layout!(branded_email_layout)
true
rescue ActiveRecord::RecordInvalid => e
render_could_not_create_error(e.record.errors.full_messages.join(', '))
false
end
def normalized_branded_email_layout = params[:branded_email_layout] == 'null' ? nil : params[:branded_email_layout]
def inbox_attributes def inbox_attributes
[:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled, [: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, :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, :lock_to_single_conversation, :portal_id, :sender_name_type, :business_name,
{ csat_config: [:display_type, :message, :button_text, :language, { csat_config: [:display_type, :message, { survey_rules: [:operator, { values: [] }] }] }]
{ survey_rules: [:operator, { values: [] }],
template: [:name, :template_id, :friendly_name, :content_sid, :approval_sid, :created_at, :language, :status] }] }]
end end
def permitted_params(channel_attributes = []) 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 # 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.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 end
def channel_type_from_params def channel_type_from_params
@@ -220,7 +177,23 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end end
def get_channel_attributes(channel_type) def get_channel_attributes(channel_type)
channel_type.constantize.const_defined?(:EDITABLE_ATTRS) ? channel_type.constantize::EDITABLE_ATTRS.presence : [] if channel_type.constantize.const_defined?(:EDITABLE_ATTRS)
channel_type.constantize::EDITABLE_ATTRS.presence
else
[]
end
end
def whatsapp_channel?
@inbox.whatsapp? || (@inbox.twilio? && @inbox.channel.whatsapp?)
end
def trigger_template_sync
if @inbox.whatsapp?
Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel)
elsif @inbox.twilio? && @inbox.channel.whatsapp?
Channels::Twilio::TemplatesSyncJob.perform_later(@inbox.channel)
end
end end
end end
@@ -11,7 +11,7 @@ class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts
enable_fb_login: '0', enable_fb_login: '0',
force_authentication: '1', force_authentication: '1',
response_type: 'code', response_type: 'code',
state: generate_instagram_token(Current.account.id, params[:return_to]) state: generate_instagram_token(Current.account.id)
} }
) )
if redirect_url if redirect_url
@@ -1,9 +0,0 @@
class Api::V1::Accounts::Integrations::BaseController < Api::V1::Accounts::BaseController
private
# Managing an integration hook (create/update/destroy) is admin-only, enforced via HookPolicy.
# Subclasses opt in per action with `before_action :check_authorization, only: [...]`.
def check_authorization
authorize(:hook)
end
end
@@ -15,14 +15,14 @@ class Api::V1::Accounts::Integrations::DyteController < Api::V1::Accounts::BaseC
end end
render_response( render_response(
dyte_processor_service.add_participant_to_meeting(@message.content_attributes['data']['meeting_id'], Current.user, @message) dyte_processor_service.add_participant_to_meeting(@message.content_attributes['data']['meeting_id'], Current.user)
) )
end end
private private
def authorize_request def authorize_request
authorize @conversation, :show? authorize @conversation.inbox, :show?
end end
def render_response(response) def render_response(response)
@@ -1,4 +1,4 @@
class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Integrations::BaseController class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::BaseController
before_action :fetch_hook, except: [:create] before_action :fetch_hook, except: [:create]
before_action :check_authorization before_action :check_authorization
@@ -35,6 +35,10 @@ class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Inte
@hook = Current.account.hooks.find(params[:id]) @hook = Current.account.hooks.find(params[:id])
end end
def check_authorization
authorize(:hook)
end
def permitted_params def permitted_params
params.require(:hook).permit(:app_id, :inbox_id, :status, settings: {}) params.require(:hook).permit(:app_id, :inbox_id, :status, settings: {})
end end
@@ -1,7 +1,6 @@
class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Integrations::BaseController class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::BaseController
before_action :fetch_conversation, only: [:create_issue, :link_issue, :unlink_issue, :linked_issues] before_action :fetch_conversation, only: [:create_issue, :link_issue, :unlink_issue, :linked_issues]
before_action :fetch_hook, only: [:destroy] before_action :fetch_hook, only: [:destroy]
before_action :check_authorization, only: [:destroy]
def destroy def destroy
revoke_linear_token revoke_linear_token
@@ -127,7 +126,7 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Int
return unless @hook&.access_token return unless @hook&.access_token
begin begin
linear_client = Linear.new(@hook.access_token, refresh_token: @hook.settings&.[]('refresh_token')) linear_client = Linear.new(@hook.access_token)
linear_client.revoke_token linear_client.revoke_token
rescue StandardError => e rescue StandardError => e
Rails.logger.error "Failed to revoke Linear token: #{e.message}" Rails.logger.error "Failed to revoke Linear token: #{e.message}"
@@ -1,6 +1,5 @@
class Api::V1::Accounts::Integrations::NotionController < Api::V1::Accounts::Integrations::BaseController class Api::V1::Accounts::Integrations::NotionController < Api::V1::Accounts::BaseController
before_action :fetch_hook, only: [:destroy] before_action :fetch_hook, only: [:destroy]
before_action :check_authorization, only: [:destroy]
def destroy def destroy
@hook.destroy! @hook.destroy!
@@ -1,8 +1,7 @@
class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::Integrations::BaseController class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::BaseController
include Shopify::IntegrationHelper include Shopify::IntegrationHelper
before_action :setup_shopify_context, only: [:orders] before_action :setup_shopify_context, only: [:orders]
before_action :fetch_hook, except: [:auth] before_action :fetch_hook, except: [:auth]
before_action :check_authorization, only: [:destroy]
before_action :validate_contact, only: [:orders] before_action :validate_contact, only: [:orders]
def auth def auth
@@ -1,4 +1,5 @@
class Api::V1::Accounts::LabelsController < Api::V1::Accounts::BaseController class Api::V1::Accounts::LabelsController < Api::V1::Accounts::BaseController
before_action :current_account
before_action :fetch_label, except: [:index, :create] before_action :fetch_label, except: [:index, :create]
before_action :check_authorization before_action :check_authorization
@@ -17,16 +18,7 @@ class Api::V1::Accounts::LabelsController < Api::V1::Accounts::BaseController
end end
def destroy def destroy
label_title = @label.title
account_id = Current.account.id
label_deleted_at = Time.current
@label.destroy! @label.destroy!
Labels::RemoveAssociationsJob.perform_later(
label_title: label_title,
account_id: account_id,
label_deleted_at: label_deleted_at
)
head :ok head :ok
end end

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