Compare commits

..
Author SHA1 Message Date
Tanmay Deep Sharma 22fd5a872b feat(whatsapp-call): direct browser-Meta WebRTC, no media server 2026-04-29 15:23:32 +07:00
Muhsin KelothandGitHub fa582b31fe Merge branch 'feat/voice-as-twilio-capability' into feat/voice-call-model-wiring 2026-04-28 11:00:45 +04:00
Muhsin KelothandGitHub 8aa71f4a9d Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-28 09:43:04 +04:00
Muhsin KelothandGitHub 223e6402d8 Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-23 11:32:13 +04:00
Muhsin KelothandGitHub e6138739ad Merge branch 'feat/voice-as-twilio-capability' into feat/voice-call-model-wiring 2026-04-21 13:23:15 +04:00
Muhsin KelothandGitHub 6d6133d5c9 Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-21 13:23:06 +04:00
Muhsin KelothandGitHub 6cf5f323fe Merge branch 'feat/voice-as-twilio-capability' into feat/voice-call-model-wiring 2026-04-20 20:28:13 +04:00
Muhsin KelothandGitHub 85f522c693 Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-20 20:28:02 +04:00
Muhsin KelothandGitHub 17a17ad00c Merge branch 'feat/voice-as-twilio-capability' into feat/voice-call-model-wiring 2026-04-20 15:26:25 +04:00
Muhsin KelothandGitHub d365a5fe15 Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-20 15:26:12 +04:00
Muhsin 98ba6e5200 refactor(voice): drop content_attributes.data from voice_call messages
Nothing reads it anymore — frontend uses message.call, CallMessageBuilder
uses call.message (the FK), and the jsonb fallback finder was dead code
(call.message_id is always linked immediately after message creation, in
the same transaction).

New voice_call messages are now created with blank content_attributes.
Existing rows keep their legacy payload; nothing reads it, so it's inert.
Safe to backfill with `Message.voice_calls.update_all(content_attributes: {})`
if you want a tidy table.
2026-04-20 14:38:57 +04:00
Muhsin 02f1fdf3b6 fix(voice): avoid Jbuilder's reserved call keyword in message serializer
Jbuilder defines Jbuilder#call(object, *attributes) as its core API for
extracting attributes. Using `json.call do ... end` collided with that
signature and raised "wrong number of arguments (given 0, expected 1+)".

Use `json.set! :call, message.call.push_event_data` instead — also
deduplicates the field list between the jbuilder and Message#push_event_data
(both now go through Call#push_event_data).
2026-04-20 14:30:42 +04:00
Muhsin c7b2a6f12c refactor(voice): stop duplicating call state in message.content_attributes
Now that the frontend reads from message.call (previous commit), the
message row is just a display anchor — all call state lives on the Call
record and is surfaced via the serializer / push_event_data.

- CallMessageBuilder shrinks to creating the bare voice_call message.
  content_attributes.data keeps only { call_sid } as a breadcrumb and a
  finder fallback for the edge case where message_id isn't linked yet.
- CallStatus::Manager stops re-writing the message on status transitions.
  call.message.touch triggers the standard message.updated dispatcher so
  sockets re-broadcast with the fresh Call embedded.
- Spec assertions switch from content_attributes.data.* to call.* +
  message.call object identity; status update spec asserts message is
  touched.
2026-04-20 13:42:11 +04:00
Muhsin c2a851c756 refactor(voice): read call state from message.call on the frontend
- VoiceCall bubble, voice.js helper, ConversationCard, and the store
  mutation all switch from message.content_attributes.data to message.call.
- Message.vue gains a `call` prop that's provided through
  useMessageContext for the bubble.
- UPDATE_MESSAGE_CALL_STATUS mutation now updates message.call.status,
  matched by provider_call_id.
- content_attributes.data is still emitted by the backend for this
  commit; backend-side trim lands in a follow-up.
2026-04-20 13:36:26 +04:00
Muhsin 5ce77eedbe feat(voice): embed Call in voice_call message payload
Adds a nested `call` object to the voice_call message JSON / push
payload so the frontend can read call state directly from message.call
instead of duplicating it in content_attributes.data.

- Message gains has_one :call (inverse of existing Call#belongs_to :message)
  via enterprise module prepend.
- Message serializer (_message.json.jbuilder) emits a call block for
  voice_call messages when the Call is linked.
- Message#push_event_data mirrors the payload into socket broadcasts.
- MessageFinder eager-loads the call (+ contact + inbox.channel) to
  avoid N+1 when listing conversation messages.
- Call gains helper methods: display_status, from_number, to_number,
  recording_url, push_event_data. These centralize the direction- and
  status-aware derivations so jbuilder and push_event_data stay in sync.

Frontend continues to read content_attributes.data — this commit only
adds the new shape; a follow-up swaps readers to message.call.
2026-04-20 13:33:43 +04:00
Muhsin KelothandGitHub 811d50043c Merge branch 'feat/voice-as-twilio-capability' into feat/voice-call-model-wiring 2026-04-20 12:27:32 +04:00
Muhsin KelothandGitHub 1a2e518341 Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-20 12:25:00 +04:00
Muhsin 117742a779 fix(voice): guarantee conference_sid at TwiML render, consistent Call factory
- conference_twiml now ensures call.conference_sid is present before
  rendering; backfills via Voice::Conference::Name.for(call) if missing.
  Prevents invalid TwiML on Call rows created without the sid set.
- Call factory derives account/inbox/contact from the associated
  conversation so create(:call) is always internally consistent (no more
  mismatched account_id across associations).
2026-04-18 10:37:47 +04:00
Muhsin 081e578a57 fix(voice): validate conversation_id when resolving Call in ConferenceController
Require the resolved Call to belong to the passed conversation_id so a
request with mismatched (conversation_id, call_sid) fails instead of
silently operating on the call from call_sid. Restores the implicit
authorization the previous conversation-based lookup provided.
2026-04-17 23:38:40 +04:00
Muhsin 7635a538af fix(voice): harden agent-label parsing and prevent started_at drift
- Conference::Manager.extract_user_id now requires the account id in the
  participant label to match call.account_id, so a spoofed label can't
  attach a user from another account to the call.
- CallStatus::Manager keeps the earliest started_at across multiple
  in-progress webhooks (Twilio can emit answered + in-progress, plus
  retries) so duration_seconds isn't undercounted.
2026-04-17 23:02:28 +04:00
Muhsin d6d6f87145 fix(voice): O(1) voice_call message lookup and nil-safe content_attributes
- find_message prefers call.message (via the already-linked message_id)
  instead of scanning every voice_call message in the conversation. Falls
  back to a jsonb-indexed query by call_sid when message_id isn't set yet.
- update_message! tolerates a nil content_attributes column (nullable in
  the schema).
2026-04-17 22:52:13 +04:00
Muhsin d62037676c fix(voice): scope all VoiceController Call lookups to the inbox
Every Call lookup in the Twilio webhook controller is now constrained
to the inbox resolved from the URL phone number. Prevents tampered
params from attaching the agent leg or outbound leg to a Call belonging
to another inbox/account.

Factored out the common `inbox_calls` scope used by
find_call_for_agent, sync_outbound_leg, and find_call_for_conference!.
2026-04-17 22:49:46 +04:00
Muhsin 91da28a329 fix(voice): scope inbound Call lookup and handle create races
- Scope the existing-call pre-check to the inbound account + inbox.
- Rescue ActiveRecord::RecordNotUnique so a concurrent Twilio retry
  doesn't 500 when two webhooks race to create the same Call.
2026-04-17 22:45:39 +04:00
Muhsin 4282dec7d6 fix(voice): thread callSid through agent leg + conference API clients
Required by the stricter server-side call_sid requirements from the
previous commit. Without this, agents would fail to join or leave calls
because the client side still sent only conversation_id.

- TwilioVoiceClient.joinClientCall now forwards callSid as a Device.connect
  param, which becomes params[:call_sid] on the TwiML webhook.
- VoiceAPI.leaveConference takes callSid and passes it as a query param
  so the DELETE /conference endpoint can resolve the exact call.
- useCallSession and FloatingCallWidget pass callSid through.
2026-04-17 22:41:20 +04:00
Muhsin 12dc22b693 fix(voice): address codex review feedback on Call-model PR
- Guard against delayed conference-start retries rolling a progressed
  call back to ringing (Conference::Manager#mark_ringing!).
- Scope conference-status webhook Call lookup to the resolved inbox
  (VoiceController#find_call_for_conference!).
- Require explicit call_sid on the agent TwiML leg and on
  ConferenceController#create/destroy so multi-call conversations can't
  attach the wrong call (no more most-recent-active fallback).
- Scope StatusUpdateService Call lookup to the webhook's account for
  defense-in-depth.
- Update UPDATE_MESSAGE_CALL_STATUS mutation to match the voice_call
  message by call_sid; helper/voice.js now passes callSid. Prevents an
  older call's status update from clobbering the newest bubble.
2026-04-17 22:28:10 +04:00
Muhsin 07de9ae1f1 test(voice): update specs to match Call-model-backed voice flow
- Add calls factory.
- voice_controller_spec, conference_controller_spec, and the voice
  service specs now assert against Call records instead of
  conversation.identifier / additional_attributes.
- Scope Call lookup in ConferenceController to the authorized inbox so
  call_sid alone can't cross-inbox escalate.
2026-04-17 20:24:37 +04:00
Muhsin 47788f4d9c Delete voice_call_wiring.md 2026-04-17 19:47:38 +04:00
Muhsin bb4feec53e feat(voice): wire Twilio voice flow through unified Call model
Moves call state off conversation.additional_attributes and
conversation.identifier onto first-class Call records, one per call.

- Call is the source of truth for status, direction, duration, started_at,
  accepted_by_agent, and conference_sid (in meta).
- Conference names key off Call.id (conf_account_{aid}_call_{cid}), so
  multiple calls on one conversation no longer collide. lock_to_single_conversation
  inboxes append each call as a new voice_call bubble in the existing thread.
- Agent identity on conference join webhooks is parsed from the Twilio
  ParticipantLabel (agent-{user_id}-account-{account_id}); stateless and
  independent of the /conference#create API call order.
- ConversationCard.vue derives the call badge from the latest voice_call
  message, not a denormalized cache on the conversation. Removes the stale
  "Call ended" state that lingered after subsequent text messages.
2026-04-17 19:44:11 +04:00
Muhsin KelothandGitHub 3b89787c7f Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-17 12:16:55 +04:00
Muhsin 94766e1200 chore: rescue errors only in delete_twiml_app 2026-04-14 15:29:16 +04:00
Muhsin 5a16a5a0ee test: stub incoming_phone_numbers in voice teardown spec
Teardown now also clears number-level voice_url/status_callback, so
the Twilio client double needs incoming_phone_numbers.list stubbed.
2026-04-14 15:24:44 +04:00
Muhsin b8df28c8a3 chore: restrict TwilioSms attrs to SMS medium 2026-04-14 14:36:44 +04:00
Muhsin a77a1c8ab4 chore: skip TwiML provisioning if no phone number 2026-04-14 14:31:04 +04:00
Muhsin 57821ab6f9 chore: clear Twilio number webhooks during teardown 2026-04-14 14:30:23 +04:00
Muhsin KelothandGitHub b4e27ed4cb Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-14 14:10:12 +04:00
Muhsin KelothandGitHub 726193e3ac Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-13 20:45:18 +04:00
Muhsin KelothandGitHub 55fd3c62cc Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-13 20:16:05 +04:00
Muhsin KelothandGitHub 1a63c4089a Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-11 10:51:31 +04:00
Muhsin KelothandGitHub b98ce4b18c Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-08 20:06:48 +04:00
Muhsin ddbd81b85e Update voice_teardown_service.rb 2026-04-08 13:32:14 +04:00
Muhsin 986f3ebc3a chore: support Twilio API Key auth in ConferenceService 2026-04-08 13:22:16 +04:00
Muhsin a6a612568a chore: support Twilio API Key auth in ConferenceService 2026-04-08 13:21:00 +04:00
Muhsin KelothandGitHub 7ebe9db77c Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-08 12:18:34 +04:00
Muhsin KelothandGitHub fdf22a034f Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-08 12:09:59 +04:00
Muhsin KelothandGitHub 14100580fb Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-06 16:46:02 +04:00
Muhsin KelothandGitHub 453fcecfb4 Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-06 15:45:18 +04:00
Muhsin KelothandGitHub 401ec4cd81 Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-06 14:04:33 +04:00
Muhsin 38867ba75f chore: use API key client for token validation 2026-04-06 13:27:52 +04:00
Muhsin d3b7d7e79a chore: show voice config only if inbox webhook set 2026-04-06 12:29:51 +04:00
Muhsin cb275ce1d9 chore: remove voiceCall gating for tabs and settings 2026-04-06 11:29:48 +04:00
Muhsin e96fa6f561 Merge branch 'feat/voice-as-twilio-capability' of github.com:chatwoot/chatwoot into feat/voice-as-twilio-capability 2026-04-06 11:27:24 +04:00
Muhsin 78296b9ce3 chore: require voice_call content for Twilio incoming 2026-04-06 11:27:16 +04:00
Muhsin KelothandGitHub 9c85a92e86 Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-06 11:14:24 +04:00
Muhsin KelothandGitHub f7d4d2d58c Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-04 17:48:37 +04:00
Muhsin KelothandGitHub fdce5910e0 Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-01 16:10:34 +04:00
Muhsin aa15ddfef8 chore: enable channel_voice in inboxes spec 2026-04-01 15:38:58 +04:00
Muhsin d80a7454ce chore: run teardown_voice callback only on update 2026-04-01 15:28:08 +04:00
Muhsin b684f75631 Update twilio_sms_voice_spec.rb 2026-04-01 15:04:53 +04:00
Muhsin 6e0b10cb42 chore: expose API key secret and require it in UI 2026-04-01 15:01:54 +04:00
Muhsin 020bf6af93 chore: don't provision Twiml app if sid present 2026-04-01 14:58:50 +04:00
Muhsin a51af8ca68 chore: fix review comments 2026-04-01 14:28:11 +04:00
Muhsin dfdfc669ea chore: reserve api_key_secret and update voice UI 2026-04-01 14:22:50 +04:00
Muhsin 987d4d4a01 chore: revert unrelated changes 2026-04-01 13:04:49 +04:00
Muhsin 3471296394 Merge branch 'feat/voice-as-twilio-capability' of github.com:chatwoot/chatwoot into feat/voice-as-twilio-capability 2026-04-01 13:01:57 +04:00
Muhsin 465b70ce27 chore: revert unrelated changes 2026-04-01 13:01:46 +04:00
Muhsin KelothandGitHub c90fdd6594 Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-01 12:41:07 +04:00
Muhsin 6890293e60 chore: extract Twilio voice teardown into service 2026-03-28 12:39:02 +04:00
Muhsin 5c704691d7 chore: validate Twilio number voice capability 2026-03-27 15:01:45 +04:00
Muhsin 1946c373d7 chore: teardown Twilio voice resources when disabled 2026-03-27 14:16:17 +04:00
Muhsin 797d7790db chore: add feature flags 2026-03-27 13:43:30 +04:00
Muhsin 1d555cebfc chore: handle Twilio API Key SID conditionally 2026-03-27 13:37:04 +04:00
Muhsin 2b5d3300f3 chore: add Voice configuration UI and API fields 2026-03-26 18:28:29 +04:00
Muhsin ddfa6cea32 chore: fix the specs 2026-03-26 17:15:52 +04:00
Muhsin 85438cf58a chore: add Twilio voice support to TwilioSms channel 2026-03-26 16:40:33 +04:00
3134 changed files with 22154 additions and 203778 deletions
+4 -18
View File
@@ -2,22 +2,8 @@
ignore:
- 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.
# Chatwoot defaults to Active Storage redirect-style URLs, and its recommended
# storage setup uses local/cloud storage with optional direct uploads to the
# storage provider rather than Rails proxy mode. Revisit if we enable
# rails_storage_proxy or other app-served Active Storage proxy routes.
- 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
+3 -6
View File
@@ -77,8 +77,7 @@ jobs:
- node/install:
node-version: '24.13'
- node/install-pnpm:
version: '10.2.0'
- node/install-pnpm
- node/install-packages:
pkg-manager: pnpm
override-ci-command: pnpm i
@@ -119,8 +118,7 @@ jobs:
- checkout
- node/install:
node-version: '24.13'
- node/install-pnpm:
version: '10.2.0'
- node/install-pnpm
- node/install-packages:
pkg-manager: pnpm
override-ci-command: pnpm i
@@ -151,8 +149,7 @@ jobs:
- checkout
- node/install:
node-version: '24.13'
- node/install-pnpm:
version: '10.2.0'
- node/install-pnpm
- node/install-packages:
pkg-manager: pnpm
override-ci-command: pnpm i
+3 -9
View File
@@ -98,8 +98,6 @@ SMTP_OPENSSL_VERIFY_MODE=peer
# Mail Incoming
# This is the domain set for the reply emails when conversation continuity is enabled
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
# Possible values are :
# relay for Exim, Postfix, Qmail
@@ -234,10 +232,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
# 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
## setting this value to true will disable the frontend dashboard endpoints
# CW_API_ONLY_SERVER=false
@@ -272,9 +266,9 @@ AZURE_APP_SECRET=
# ENABLE_SIDEKIQ_DEQUEUE_LOGGER=false
# AI powered features (Captain)
# The OpenAI API key and endpoint for Captain are not configured via .env.
# Set them at Super Admin > App Configs > Captain (CAPTAIN_OPEN_AI_API_KEY, CAPTAIN_OPEN_AI_ENDPOINT).
# AI powered features
## OpenAI key
# OPENAI_API_KEY=
# Housekeeping/Performance related configurations
# Set to true if you want to remove stale contact inboxes
-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 }}
cancel-in-progress: true
permissions:
contents: read
jobs:
deployment_check:
name: Check Deployment
-3
View File
@@ -8,9 +8,6 @@ on:
branches:
- develop
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-22.04
-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 }}
cancel-in-progress: true
permissions:
contents: read
jobs:
log_lines_check:
runs-on: ubuntu-latest
-3
View File
@@ -14,9 +14,6 @@ on:
- cron: "0 0 * * *"
workflow_dispatch:
permissions:
contents: read
jobs:
nightly:
runs-on: ubuntu-24.04
@@ -3,10 +3,6 @@ name: Publish Codespace Base Image
on:
workflow_dispatch:
permissions:
contents: read
packages: write
jobs:
publish-code-space-image:
runs-on: ubuntu-latest
-3
View File
@@ -18,9 +18,6 @@ on:
env:
DOCKER_REPO: chatwoot/chatwoot
permissions:
contents: read
jobs:
build:
strategy:
@@ -18,9 +18,6 @@ on:
env:
DOCKER_REPO: chatwoot/chatwoot
permissions:
contents: read
jobs:
build:
strategy:
-3
View File
@@ -10,9 +10,6 @@ concurrency:
group: pr-${{ github.workflow }}-${{ github.head_ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-22.04
-3
View File
@@ -7,9 +7,6 @@ on:
- master
workflow_dispatch:
permissions:
contents: read
jobs:
test-build:
strategy:
+5 -10
View File
@@ -43,18 +43,13 @@
## General Guidelines
- Prefer the smallest production-ready change that solves the current problem.
- 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.
- 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.
- MVP focus: Least code change, happy-path only
- No unnecessary defensive programming
- Ship the happy path first: limit guards/fallbacks to what production has proven necessary, then iterate
- Prefer minimal, readable code over elaborate abstractions; clarity beats cleverness
- Break down complex tasks into small, testable units
- Iterate after confirmation
- Avoid writing specs unless explicitly asked
- 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
- 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
@@ -85,8 +80,8 @@
## Project-Specific
- **Translations**:
- For product and source-string changes, only update `en.yml` and `en.json`; other languages are handled through Crowdin and the community
- Crowdin-generated translation sync PRs may update non-English locale files; do not flag those changes solely for modifying translated locale files
- Only update `en.yml` and `en.json`
- Other languages are handled by the community
- Backend i18n → `en.yml`, Frontend i18n → `en.json`
- **Frontend**:
- Use `components-next/` for message bubbles (the rest is being deprecated)
+6 -9
View File
@@ -22,7 +22,6 @@ gem 'time_diff'
gem 'tzinfo-data'
gem 'valid_email2'
gem 'email-provider-info'
gem 'gemoji'
# compress javascript config.assets.js_compressor
gem 'uglifier'
##-- used for single column multiple binary flags in notification settings/feature flagging --##
@@ -76,7 +75,7 @@ gem 'faraday_middleware-aws-sigv4'
##--- gems for server & infra configuration ---##
gem 'dotenv-rails', '>= 3.0.0'
gem 'foreman'
gem 'puma', '~> 7.2', '>= 7.2.1'
gem 'puma'
gem 'vite_rails'
# metrics on heroku
gem 'barnes'
@@ -89,7 +88,7 @@ gem 'rails-i18n', '~> 7.0'
# two-factor authentication
gem 'devise-two-factor', '>= 5.0.0'
# authorization
gem 'jwt', '~> 2.10', '>= 2.10.3'
gem 'jwt'
gem 'pundit'
# super admin
@@ -133,9 +132,9 @@ gem 'sentry-ruby', require: false
gem 'sentry-sidekiq', '>= 5.19.0', require: false
##-- background job processing --##
gem 'sidekiq', '~> 7.3', '>= 7.3.1'
gem 'sidekiq', '>= 7.3.1'
# We want cron jobs
gem 'sidekiq-cron', '>= 2.4.0'
gem 'sidekiq-cron', '>= 1.12.0'
# for sidekiq healthcheck
gem 'sidekiq_alive'
@@ -195,10 +194,10 @@ gem 'reverse_markdown'
gem 'iso-639'
gem 'ruby-openai'
gem 'ai-agents', '>= 0.12.0'
gem 'ai-agents', '>= 0.9.1'
# TODO: Move this gem as a dependency of ai-agents
gem 'ruby_llm', '>= 1.14.1'
gem 'ruby_llm', '>= 1.8.2'
gem 'ruby_llm-schema'
gem 'cld3', '~> 3.7'
@@ -209,8 +208,6 @@ gem 'opentelemetry-exporter-otlp'
gem 'shopify_api'
gem 'firecrawl-sdk', '~> 1.0', require: 'firecrawl'
### Gems required only in specific deployment environments ###
##############################################################
+79 -103
View File
@@ -108,8 +108,8 @@ GEM
acts-as-taggable-on (12.0.0)
activerecord (>= 7.1, < 8.1)
zeitwerk (>= 2.4, < 3.0)
addressable (2.9.0)
public_suffix (>= 2.0.2, < 8.0)
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
administrate (0.20.1)
actionpack (>= 6.0, < 8.0)
actionview (>= 6.0, < 8.0)
@@ -126,8 +126,8 @@ GEM
jbuilder (~> 2)
rails (>= 4.2, < 7.2)
selectize-rails (~> 0.6)
ai-agents (0.12.0)
ruby_llm (~> 1.14)
ai-agents (0.9.1)
ruby_llm (~> 1.9.1)
annotaterb (4.20.0)
activerecord (>= 6.0.0)
activesupport (>= 6.0.0)
@@ -136,8 +136,6 @@ GEM
audited (5.4.1)
activerecord (>= 5.0, < 7.7)
activesupport (>= 5.0, < 7.7)
auth-sanitizer (0.2.1)
version_gem (~> 1.1, >= 1.1.10)
aws-actionmailbox-ses (0.1.0)
actionmailbox (>= 7.1.0)
aws-sdk-s3 (~> 1, >= 1.123.0)
@@ -170,7 +168,7 @@ GEM
base64 (0.3.0)
bcrypt (3.1.22)
benchmark (0.4.1)
bigdecimal (4.1.2)
bigdecimal (3.2.2)
bindex (0.8.1)
bootsnap (1.16.0)
msgpack (~> 1.2)
@@ -186,22 +184,18 @@ GEM
bundler (>= 1.2.0, < 3)
thor (~> 1.0)
byebug (11.1.3)
cgi (0.5.1)
childprocess (5.1.0)
logger (~> 1.5)
cld3 (3.7.0)
climate_control (1.2.0)
coderay (1.1.3)
commonmarker (0.23.10)
concurrent-ruby (1.3.7)
concurrent-ruby (1.3.5)
connection_pool (2.5.5)
crack (1.0.0)
bigdecimal
rexml
crass (1.0.7)
cronex (0.15.0)
tzinfo
unicode (>= 0.4.4.5)
crass (1.0.6)
csv (3.3.0)
csv-safe (3.3.1)
csv (~> 3.0)
@@ -211,15 +205,14 @@ GEM
activerecord (>= 5.a)
database_cleaner-core (~> 2.0.0)
database_cleaner-core (2.0.1)
datadog (2.38.0)
cgi
datadog-ruby_core_source (~> 3.5, >= 3.5.3)
libdatadog (~> 36.0.0.1.0)
libddwaf (~> 1.30.0.0.0)
datadog (2.19.0)
datadog-ruby_core_source (~> 3.4, >= 3.4.1)
libdatadog (~> 18.1.0.1.0)
libddwaf (~> 1.24.1.0.3)
logger
msgpack
datadog-ruby_core_source (3.5.3)
date (3.5.1)
datadog-ruby_core_source (3.4.1)
date (3.4.1)
debug (1.8.0)
irb (>= 1.5.0)
reline (>= 0.3.1)
@@ -275,8 +268,8 @@ GEM
dry-logic (~> 1.5)
dry-types (~> 1.8)
zeitwerk (~> 2.6)
dry-types (1.9.1)
bigdecimal (>= 3.0)
dry-types (1.8.3)
bigdecimal (~> 3.0)
concurrent-ruby (~> 1.0)
dry-core (~> 1.0)
dry-inflector (~> 1.0)
@@ -305,7 +298,7 @@ GEM
railties (>= 5.0.0)
faker (3.2.0)
i18n (>= 1.8.11, < 2)
faraday (2.14.3)
faraday (2.14.1)
faraday-net_http (>= 2.0, < 3.5)
json
logger
@@ -314,9 +307,9 @@ GEM
faraday-mashify (1.0.0)
faraday (~> 2.0)
hashie
faraday-multipart (1.2.0)
multipart-post (~> 2.0)
faraday-net_http (3.4.4)
faraday-multipart (1.0.4)
multipart-post (~> 2)
faraday-net_http (3.4.2)
net-http (~> 0.5)
faraday-net_http_persistent (2.1.0)
faraday (~> 2.5)
@@ -343,7 +336,6 @@ GEM
ffi-compiler (1.0.1)
ffi (>= 1.0.0)
rake
firecrawl-sdk (1.4.1)
flag_shih_tzu (0.3.23)
foreman (0.87.2)
fugit (1.11.1)
@@ -357,7 +349,6 @@ GEM
googleapis-common-protos-types (>= 1.3.1, < 2.a)
googleauth (~> 1.0)
grpc (~> 1.36)
gemoji (4.1.0)
geocoder (1.8.1)
gli (2.22.2)
ostruct
@@ -439,8 +430,7 @@ GEM
hana (1.3.7)
hash_diff (1.1.1)
hashdiff (1.1.0)
hashie (5.1.0)
logger
hashie (5.0.0)
html2text (0.4.0)
nokogiri (>= 1.0, < 2.0)
http (5.1.1)
@@ -475,7 +465,7 @@ GEM
rails-dom-testing (>= 1, < 3)
railties (>= 4.2.0)
thor (>= 0.14, < 2.0)
json (2.19.9)
json (2.19.2)
json_refs (0.1.8)
hana
json_schemer (0.2.24)
@@ -496,7 +486,7 @@ GEM
judoscale-sidekiq (1.8.2)
judoscale-ruby (= 1.8.2)
sidekiq (>= 5.0)
jwt (2.10.3)
jwt (2.10.1)
base64
kaminari (1.2.2)
activesupport (>= 4.1.0)
@@ -523,16 +513,15 @@ GEM
logger (~> 1.6)
letter_opener (1.10.0)
launchy (>= 2.2, < 4)
libdatadog (36.0.0.1.0)
libdatadog (36.0.0.1.0-arm64-darwin)
libdatadog (36.0.0.1.0-x86_64-linux)
libddwaf (1.30.0.0.2)
libdatadog (18.1.0.1.0)
libdatadog (18.1.0.1.0-x86_64-linux)
libddwaf (1.24.1.0.3)
ffi (~> 1.0)
libddwaf (1.30.0.0.2-arm64-darwin)
libddwaf (1.24.1.0.3-arm64-darwin)
ffi (~> 1.0)
libddwaf (1.30.0.0.2-x86_64-darwin)
libddwaf (1.24.1.0.3-x86_64-darwin)
ffi (~> 1.0)
libddwaf (1.30.0.0.2-x86_64-linux)
libddwaf (1.24.1.0.3-x86_64-linux)
ffi (~> 1.0)
line-bot-api (1.28.0)
lint_roller (1.1.0)
@@ -549,7 +538,7 @@ GEM
activesupport (>= 4)
railties (>= 4)
request_store (~> 1.0)
loofah (2.25.2)
loofah (2.23.1)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.8.1)
@@ -572,9 +561,9 @@ GEM
minitest (5.25.5)
mock_redis (0.36.0)
ruby2_keywords
msgpack (1.8.3)
msgpack (1.8.0)
multi_json (1.15.0)
multi_xml (0.9.1)
multi_xml (0.8.0)
bigdecimal (>= 3.1, < 5)
multipart-post (2.4.1)
mutex_m (0.3.0)
@@ -584,7 +573,7 @@ GEM
uri (>= 0.11.1)
net-http-persistent (4.0.2)
connection_pool (~> 2.2)
net-imap (0.6.4.1)
net-imap (0.4.20)
date
net-protocol
net-pop (0.1.2)
@@ -599,37 +588,30 @@ GEM
sidekiq
newrelic_rpm (9.6.0)
base64
nio4r (2.7.5)
nokogiri (1.19.4)
nio4r (2.7.3)
nokogiri (1.19.1)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nokogiri (1.19.4-arm64-darwin)
nokogiri (1.19.1-arm64-darwin)
racc (~> 1.4)
nokogiri (1.19.4-x86_64-darwin)
nokogiri (1.19.1-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.19.4-x86_64-linux-gnu)
nokogiri (1.19.1-x86_64-linux-gnu)
racc (~> 1.4)
oauth (1.1.6)
auth-sanitizer (~> 0.2, >= 0.2.1)
base64 (~> 0.1)
cgi
oauth-tty (~> 1.0, >= 1.0.8)
snaky_hash (~> 2.0, >= 2.0.5)
version_gem (~> 1.1, >= 1.1.11)
oauth-tty (1.0.8)
auth-sanitizer (~> 0.1, >= 0.1.3)
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)
oauth (1.1.0)
oauth-tty (~> 1.0, >= 1.0.1)
snaky_hash (~> 2.0)
version_gem (~> 1.1)
oauth-tty (1.0.5)
version_gem (~> 1.1, >= 1.1.1)
oauth2 (2.0.9)
faraday (>= 0.17.3, < 3.0)
jwt (>= 1.0, < 3.0)
multi_xml (~> 0.5)
rack (>= 1.2, < 4)
snaky_hash (~> 2.0, >= 2.0.5)
version_gem (~> 1.1, >= 1.1.11)
oj (3.17.3)
snaky_hash (~> 2.0)
version_gem (~> 1.1)
oj (3.16.10)
bigdecimal (>= 3.0)
ostruct (>= 0.2)
omniauth (2.1.4)
@@ -676,7 +658,7 @@ GEM
opentelemetry-api (~> 1.0)
orm_adapter (0.5.0)
os (1.1.4)
ostruct (0.6.3)
ostruct (0.6.1)
parallel (1.27.0)
parser (3.3.8.0)
ast (~> 2.4.1)
@@ -694,14 +676,14 @@ GEM
method_source (~> 1.0)
pry-rails (0.3.9)
pry (>= 0.10.4)
public_suffix (7.0.5)
puma (7.2.1)
public_suffix (6.0.2)
puma (6.4.3)
nio4r (~> 2.0)
pundit (2.3.0)
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.8.1)
rack (3.2.6)
rack (3.2.5)
rack-attack (6.7.0)
rack (>= 1.0, < 4)
rack-contrib (2.5.0)
@@ -716,7 +698,7 @@ GEM
rack (>= 3.0.0, < 4)
rack-proxy (0.7.7)
rack
rack-session (2.1.2)
rack-session (2.1.1)
base64 (>= 0.1.0)
rack (>= 3.0.0)
rack-test (2.1.0)
@@ -742,8 +724,8 @@ GEM
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
rails-html-sanitizer (1.7.1)
loofah (~> 2.25, >= 2.25.2)
rails-html-sanitizer (1.6.1)
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)
rails-i18n (7.0.10)
i18n (>= 0.7, < 2)
@@ -852,17 +834,17 @@ GEM
ruby2ruby (2.5.0)
ruby_parser (~> 3.1)
sexp_processor (~> 4.6)
ruby_llm (1.15.0)
ruby_llm (1.9.2)
base64
event_stream_parser (~> 1)
faraday (>= 1.10.0)
faraday-multipart (>= 1)
faraday-net_http (>= 1)
faraday-retry (>= 1)
marcel (~> 1)
ruby_llm-schema (~> 0)
marcel (~> 1.0)
ruby_llm-schema (~> 0.2.1)
zeitwerk (~> 2)
ruby_llm-schema (0.3.0)
ruby_llm-schema (0.2.5)
ruby_parser (3.20.0)
sexp_processor (~> 4.16)
sass (3.7.4)
@@ -919,11 +901,10 @@ GEM
logger
rack (>= 2.2.4)
redis-client (>= 0.22.2)
sidekiq-cron (2.4.0)
cronex (>= 0.13.0)
fugit (~> 1.8, >= 1.11.1)
sidekiq-cron (1.12.0)
fugit (~> 1.8)
globalid (>= 1.0.1)
sidekiq (>= 6.5.0)
sidekiq (>= 6)
sidekiq_alive (2.5.0)
gserver (~> 0.0.1)
sidekiq (>= 5, < 9)
@@ -948,9 +929,9 @@ GEM
gli
hashie
logger
snaky_hash (2.0.5)
hashie (>= 0.1.0, < 6)
version_gem (>= 1.1.8, < 3)
snaky_hash (2.0.1)
hashie
version_gem (~> 1.1, >= 1.1.1)
sorbet-runtime (0.5.11934)
spring (4.1.1)
spring-watcher-listen (2.1.0)
@@ -979,7 +960,7 @@ GEM
time_diff (0.3.0)
activesupport
i18n
timeout (0.6.1)
timeout (0.4.3)
trailblazer-option (0.1.2)
twilio-ruby (7.6.0)
faraday (>= 0.9, < 3.0)
@@ -997,7 +978,6 @@ GEM
unf (0.1.4)
unf_ext
unf_ext (0.0.8.2)
unicode (0.4.4.5)
unicode-display_width (3.1.4)
unicode-emoji (~> 4.0, >= 4.0.4)
unicode-emoji (4.0.4)
@@ -1008,14 +988,12 @@ GEM
valid_email2 (5.2.6)
activemodel (>= 3.2)
mail (~> 2.5)
version_gem (1.1.11)
vite_rails (3.10.0)
railties (>= 5.1, < 9)
version_gem (1.1.4)
vite_rails (3.0.17)
railties (>= 5.1, < 8)
vite_ruby (~> 3.0, >= 3.2.2)
vite_ruby (3.10.2)
vite_ruby (3.8.0)
dry-cli (>= 0.7, < 2)
logger (~> 1.6)
mutex_m
rack-proxy (~> 0.6, >= 0.6.1)
zeitwerk (~> 2.2)
warden (1.2.9)
@@ -1032,7 +1010,7 @@ GEM
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
websocket-driver (0.8.2)
websocket-driver (0.7.7)
base64
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
@@ -1040,7 +1018,7 @@ GEM
working_hours (1.4.1)
activesupport (>= 3.2)
tzinfo
zeitwerk (2.7.5)
zeitwerk (2.7.4)
PLATFORMS
arm64-darwin-20
@@ -1060,7 +1038,7 @@ DEPENDENCIES
administrate (>= 0.20.1)
administrate-field-active_storage (>= 1.0.3)
administrate-field-belongs_to_search (>= 0.9.0)
ai-agents (>= 0.12.0)
ai-agents (>= 0.9.1)
annotaterb
attr_extras
audited (~> 5.4, >= 5.4.1)
@@ -1095,10 +1073,8 @@ DEPENDENCIES
faker
faraday_middleware-aws-sigv4
fcm
firecrawl-sdk (~> 1.0)
flag_shih_tzu
foreman
gemoji
geocoder
gmail_xoauth
google-cloud-dialogflow-v2 (>= 0.24.0)
@@ -1117,7 +1093,7 @@ DEPENDENCIES
json_schemer
judoscale-rails
judoscale-sidekiq
jwt (~> 2.10, >= 2.10.3)
jwt
kaminari
koala
letter_opener
@@ -1145,7 +1121,7 @@ DEPENDENCIES
pgvector
procore-sift
pry-rails
puma (~> 7.2, >= 7.2.1)
puma
pundit
rack-attack (>= 6.7.0)
rack-cors (= 2.0.0)
@@ -1166,7 +1142,7 @@ DEPENDENCIES
rubocop-rails
rubocop-rspec
ruby-openai
ruby_llm (>= 1.14.1)
ruby_llm (>= 1.8.2)
ruby_llm-schema
scout_apm
scss_lint
@@ -1177,8 +1153,8 @@ DEPENDENCIES
sentry-sidekiq (>= 5.19.0)
shopify_api
shoulda-matchers
sidekiq (~> 7.3, >= 7.3.1)
sidekiq-cron (>= 2.4.0)
sidekiq (>= 7.3.1)
sidekiq-cron (>= 1.12.0)
sidekiq_alive
simplecov (>= 0.21)
simplecov_json_formatter
+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:
+1 -1
View File
@@ -1 +1 @@
4.16.1
4.13.0
+3 -19
View File
@@ -2,14 +2,6 @@
# It initializes with necessary attributes and provides a perform method
# to create a user and account user in a transaction.
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.
# @param email [String] the email of the user.
# @param name [String] the name of the user.
@@ -22,23 +14,15 @@ class AgentBuilder
# Creates a user and account user in a transaction.
# @return [User] the created user.
def perform
account.with_lock do
raise LimitExceededError unless can_add_agent?
ActiveRecord::Base.transaction do
@user = find_or_create_user
create_account_user
end
ActiveRecord::Base.transaction do
@user = find_or_create_user
create_account_user
end
@user
end
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.
# @return [User] the found or created user.
def find_or_create_user
@@ -50,7 +50,7 @@ class ContactInboxWithContactBuilder
def create_contact
account.contacts.create!(
name: contact_name,
name: contact_attributes[:name] || ::Haikunator.haikunate(1000),
phone_number: contact_attributes[:phone_number],
email: contact_attributes[:email],
identifier: contact_attributes[:identifier],
@@ -59,11 +59,6 @@ class ContactInboxWithContactBuilder
)
end
def contact_name
name = contact_attributes[:name] || ::Haikunator.haikunate(1000)
name.truncate(ApplicationRecord::MAX_STRING_COLUMN_LENGTH, omission: '')
end
def find_contact
contact = find_contact_by_identifier(contact_attributes[:identifier])
contact ||= find_contact_by_email(contact_attributes[:email])
@@ -91,21 +91,11 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
def fallback_params(attachment)
{
fallback_title: attachment['title'] || attachment.dig('payload', 'title'),
external_url: attachment['url'] || attachment.dig('payload', 'url')
fallback_title: attachment['title'],
external_url: attachment['url']
}
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
{
account_id: @inbox.account_id,
+7 -17
View File
@@ -13,7 +13,6 @@ class Messages::MessageBuilder
@account = conversation.account
@message_type = params[:message_type] || 'outgoing'
@attachments = params[:attachments]
@is_voice_message = ActiveModel::Type::Boolean.new.cast(params[:is_voice_message])
@automation_rule = content_attributes&.dig(:automation_rule_id)
return unless params.instance_of?(ActionController::Parameters)
@@ -57,25 +56,16 @@ class Messages::MessageBuilder
file: uploaded_attachment
)
attachment.file_type = attachment_file_type(uploaded_attachment)
tag_voice_message(attachment)
attachment.file_type = if uploaded_attachment.is_a?(String)
file_type_by_signed_id(
uploaded_attachment
)
else
file_type(uploaded_attachment&.content_type)
end
end
end
def attachment_file_type(uploaded_attachment)
if uploaded_attachment.is_a?(String)
file_type_by_signed_id(uploaded_attachment)
else
file_type(uploaded_attachment&.content_type)
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
def process_emails
return unless @conversation.inbox&.inbox_type == 'Email'
@@ -6,11 +6,6 @@ class Messages::Messenger::MessageBuilder
return if unsupported_file_type?(attachment['type'])
params = attachment_params(attachment)
# 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!
if facebook_reel?(attachment)
@@ -18,14 +13,10 @@ class Messages::Messenger::MessageBuilder
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_ig_story_link(attachment_obj) if attachment_obj.file_type == 'ig_story'
fetch_ig_post_link(attachment_obj) if attachment_obj.file_type == 'ig_post'
update_attachment_file_type(attachment_obj)
end
def attach_file(attachment, file_url)
@@ -37,10 +28,6 @@ class Messages::Messenger::MessageBuilder
filename: attachment_file.original_filename,
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
def attachment_params(attachment)
@@ -120,20 +107,13 @@ class Messages::Messenger::MessageBuilder
# 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
FACEBOOK_FILE_TYPE_MAP = { reel: :ig_reel }.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)
-15
View File
@@ -27,8 +27,6 @@ class NotificationBuilder
return if notification_type == 'conversation_creation' && !user_subscribed_to_notification?
# skip notifications for blocked conversations except for user mentions
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!(
notification_type: notification_type,
@@ -38,17 +36,4 @@ class NotificationBuilder
secondary_actor: secondary_actor || current_user
)
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
+4 -15
View File
@@ -31,24 +31,13 @@ class V2::Reports::BotMetricsBuilder
end
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, created_at: range)
.where.not(conversation_id: bot_handoff_conversation_ids_subquery)
.distinct.count
account.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_bot_resolved,
created_at: range).distinct.count
end
def bot_handoffs_count
account.reporting_events.joins(:conversation).select(:conversation_id)
.where(account_id: account.id, name: :conversation_bot_handoff, created_at: range)
.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)
account.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_bot_handoff,
created_at: range).distinct.count
end
def bot_resolution_rate
@@ -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,4 +1,5 @@
class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
before_action :current_account
before_action :check_authorization
before_action :agent_bot, except: [:index, :create]
@@ -1,6 +1,8 @@
class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
before_action :fetch_agent, except: [:create, :index, :bulk_create]
before_action :check_authorization
before_action :validate_limit, only: [:create]
before_action :validate_limit_for_bulk_create, only: [:bulk_create]
def index
@agents = agents
@@ -18,8 +20,6 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
)
@agent = builder.perform
rescue AgentBuilder::LimitExceededError => e
render_payment_required(e.message)
end
def update
@@ -36,13 +36,25 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
def bulk_create
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
# 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
rescue AgentBuilder::LimitExceededError => e
render_payment_required(e.message)
end
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] })
end
def bulk_create_agents(emails)
Current.account.with_lock do
raise AgentBuilder::LimitExceededError if emails.count > available_agent_count
def validate_limit_for_bulk_create
limit_available = params[:emails].count <= available_agent_count
emails.each { |email| create_agent_from_email(email) }
end
render_payment_required('Account limit exceeded. Please purchase more licenses') unless limit_available
end
def create_agent_from_email(email)
builder = AgentBuilder.new(
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!
def validate_limit
render_payment_required('Account limit exceeded. Please purchase more licenses') unless can_add_agent?
end
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
def delete_user_record(agent)
@@ -1,7 +1,7 @@
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]
before_action :set_articles, only: [:update_status, :delete_articles]
def translate
head :not_implemented
@@ -19,18 +19,6 @@ class Api::V1::Accounts::Articles::BulkActionsController < Api::V1::Accounts::Ba
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?
@@ -51,9 +39,5 @@ class Api::V1::Accounts::Articles::BulkActionsController < Api::V1::Accounts::Ba
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')
@@ -30,8 +30,8 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
end
def update
persist_article_changes if params[:article].present?
render json: { message: @article.errors.full_messages.to_sentence }, status: :unprocessable_entity and return unless @article.valid?
@article.update!(article_params) if params[:article].present?
render json: { error: @article.errors.messages }, status: :unprocessable_entity and return unless @article.valid?
end
def destroy
@@ -40,8 +40,8 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
end
def reorder
positions = Article.update_positions(portal: @portal, positions_hash: params[:positions_hash])
render json: { positions: positions }
Article.update_positions(portal: @portal, positions_hash: params[:positions_hash])
head :ok
end
private
@@ -67,26 +67,12 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
@portal ||= Current.account.portals.find_by!(slug: params[:portal_id])
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
params.require(:article).permit(
:title, :slug, :position, :content, :description, :category_id, :author_id, :associated_article_id, :status,
:locale, :draft_title, :draft_content, meta: [:title,
:description,
{ tags: [] }]
:locale, meta: [:title,
:description,
{ tags: [] }]
)
end
@@ -2,8 +2,6 @@ class Api::V1::Accounts::AssignableAgentsController < Api::V1::Accounts::BaseCon
before_action :fetch_inboxes
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|
authorize inbox, :show?
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(:&)
agents = Current.account.users.where(id: agent_ids)
@assignable_agents = (agents + Current.account.administrators).uniq
@agent_bots = @include_agent_bots ? AgentBot.accessible_to(Current.account) : []
end
private
@@ -30,8 +30,7 @@ class Api::V1::Accounts::AssignmentPoliciesController < Api::V1::Accounts::BaseC
def assignment_policy_params
params.require(:assignment_policy).permit(
:name, :description, :assignment_order, :conversation_priority,
:fair_distribution_limit, :fair_distribution_window, :enabled,
:exclude_older_than_hours
:fair_distribution_limit, :fair_distribution_window, :enabled
)
end
end
@@ -2,14 +2,5 @@ class Api::V1::Accounts::BaseController < Api::BaseController
include SwitchLocale
include EnsureCurrentAccountHelper
before_action :current_account
before_action :validate_token_api_access, if: :authenticate_by_access_token?
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
@@ -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')
@@ -6,7 +6,6 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
page_access_token = params[:page_access_token]
page_id = params[:page_id]
inbox_name = params[:inbox_name]
ActiveRecord::Base.transaction do
facebook_channel = Current.account.facebook_pages.create!(
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_avatar(@facebook_inbox, page_id)
end
rescue CustomExceptions::Inbox::LimitExceeded => e
render_error_response(e)
rescue StandardError => e
ChatwootExceptionTracker.new(e).capture_exception
Rails.logger.error "Error in register_facebook_page: #{e.message}"
@@ -1,4 +1,5 @@
class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::BaseController
before_action :current_account
before_action :authorize_account_update, only: [:update]
def show
@@ -7,8 +8,8 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas
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.captain_models = params_to_update[:captain_models] if params_to_update[:captain_models]
@current_account.captain_features = params_to_update[:captain_features] if params_to_update[:captain_features]
@current_account.save!
render json: preferences_payload
@@ -37,7 +38,7 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas
def merged_captain_models
existing_models = @current_account.captain_models || {}
existing_models.merge(permitted_captain_models).compact_blank.presence
existing_models.merge(permitted_captain_models)
end
def merged_captain_features
@@ -46,38 +47,30 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas
end
def permitted_captain_models
params.require(:captain_models).permit(*captain_feature_keys).to_h.stringify_keys
params.require(:captain_models).permit(
:editor, :assistant, :copilot, :label_suggestion,
:audio_transcription, :help_center_search
).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)
params.require(:captain_features).permit(
:editor, :assistant, :copilot, :label_suggestion,
:audio_transcription, :help_center_search
).to_h.stringify_keys
end
def features_with_account_preferences
preferences = Current.account.captain_preferences
account_features = preferences[:features] || {}
account_models = preferences[:models] || {}
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]
selected: account_models[feature_key] || config[:default]
)
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
@@ -53,7 +53,7 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle
def category_params
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
@@ -6,8 +6,6 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts:
def create
process_create
rescue CustomExceptions::Inbox::LimitExceeded => e
render_error_response(e)
rescue StandardError => e
render_could_not_create_error(e.message)
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
@@ -214,5 +214,3 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
render json: error, status: error_status
end
end
Api::V1::Accounts::ContactsController.prepend_mod_with('Api::V1::Accounts::ContactsController')
@@ -1,17 +1,6 @@
class Api::V1::Accounts::Conversations::DirectUploadsController < ActiveStorage::DirectUploadsController
include DeviseTokenAuth::Concerns::SetUserByToken
include RequestExceptionHandler
include AccessTokenAuthHelper
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 :validate_token_api_access, if: :authenticate_by_access_token?
before_action :conversation
def create
@@ -22,16 +11,6 @@ class Api::V1::Accounts::Conversations::DirectUploadsController < ActiveStorage:
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
@conversation ||= Current.account.conversations.find_by(display_id: params[:conversation_id])
end
@@ -52,9 +52,6 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
end
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
private
@@ -1,40 +1,27 @@
class Api::V1::Accounts::Conversations::ParticipantsController < Api::V1::Accounts::Conversations::BaseController
include Events::Types
def show
@participants = @conversation.conversation_participants
end
def create
participant_ids_to_add = participants_to_be_added_ids
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
notify_unread_count_change if participant_ids_to_add.any?
end
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
participant_ids_to_add.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_added_ids.each { |user_id| @conversation.conversation_participants.find_or_create_by(user_id: user_id) }
participants_to_be_removed_ids.each { |user_id| @conversation.conversation_participants.find_by(user_id: user_id)&.destroy }
end
notify_unread_count_change if changed_participant_ids.any?
@participants = @conversation.conversation_participants
render action: 'show'
end
def destroy
participant_ids_to_remove = current_participant_ids & params[:user_ids]
ActiveRecord::Base.transaction do
params[:user_ids].map { |user_id| @conversation.conversation_participants.find_by(user_id: user_id)&.destroy }
end
notify_unread_count_change if participant_ids_to_remove.any?
head :ok
end
@@ -51,11 +38,4 @@ class Api::V1::Accounts::Conversations::ParticipantsController < Api::V1::Accoun
def current_participant_ids
@current_participant_ids ||= @conversation.conversation_participants.pluck(:user_id)
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
@@ -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
@@ -28,7 +28,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
def attachments
@attachments_count = @conversation.attachments.count
@attachments = @conversation.attachments
.includes({ file_attachment: :blob }, message: [:inbox, { sender: { avatar_attachment: :blob } }])
.includes(:message)
.order(created_at: :desc)
.page(attachment_params[:page])
.per(ATTACHMENT_RESULTS_PER_PAGE)
@@ -140,7 +140,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
def 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
end
@@ -162,9 +162,6 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
# rubocop:disable Rails/SkipsModelValidations
@conversation.update_columns(updates)
# rubocop:enable Rails/SkipsModelValidations
::Conversations::UnreadCounts::Notifier.new(@conversation).perform
::Conversations::UnreadCounts::FilteredCountInvalidator.new(Current.account).conversation_changed!
end
def should_update_last_seen?
@@ -1,7 +1,6 @@
class Api::V1::Accounts::CustomAttributeDefinitionsController < Api::V1::Accounts::BaseController
before_action :fetch_custom_attributes_definitions, except: [:create]
before_action :fetch_custom_attribute_definition, only: [:show, :update, :destroy]
before_action :check_authorization
DEFAULT_ATTRIBUTE_MODEL = 'conversation_attribute'.freeze
def index; end
@@ -1,5 +1,4 @@
class Api::V1::Accounts::DashboardAppsController < Api::V1::Accounts::BaseController
before_action :check_authorization
before_action :fetch_dashboard_apps, except: [:create]
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
@@ -2,15 +2,14 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
include Api::V1::InboxesHelper
before_action :fetch_inbox, except: [:index, :create]
before_action :fetch_agent_bot, only: [:set_agent_bot]
before_action :validate_limit, only: [:create]
# we are already handling the authorization in fetch inbox
before_action :check_authorization, except: [:show]
include Api::V1::Accounts::Concerns::WhatsappHealthManagement
def index
@inboxes = policy_scope(Current.account.inboxes)
.includes(:channel, :portal, :working_hours, { avatar_attachment: :blob })
.order_by_name
@inboxes = policy_scope(Current.account.inboxes.order_by_name.includes(:channel, { avatar_attachment: [:blob] }))
end
def show; end
@@ -45,20 +44,11 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end
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[:csat_config] = format_csat_config(permitted_params[:csat_config]) if permitted_params[:csat_config].present?
@inbox.update!(inbox_params)
update_inbox_working_hours
update_channel if channel_update_required?
end
return unless continue_update
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.update!(inbox_params)
update_inbox_working_hours
update_channel if channel_update_required?
end
def agent_bot
@@ -95,7 +85,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end
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
def create_channel
@@ -133,8 +123,8 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end
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.update!(permitted_params(channel_attributes)[:channel])
end
def update_channel_feature_flags
@@ -164,34 +154,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
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
[: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,
@@ -11,7 +11,7 @@ class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts
enable_fb_login: '0',
force_authentication: '1',
response_type: 'code',
state: generate_instagram_token(Current.account.id, params[:return_to])
state: generate_instagram_token(Current.account.id)
}
)
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,7 +15,7 @@ class Api::V1::Accounts::Integrations::DyteController < Api::V1::Accounts::BaseC
end
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
@@ -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 :check_authorization
@@ -35,6 +35,10 @@ class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Inte
@hook = Current.account.hooks.find(params[:id])
end
def check_authorization
authorize(:hook)
end
def permitted_params
params.require(:hook).permit(:app_id, :inbox_id, :status, settings: {})
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_hook, only: [:destroy]
before_action :check_authorization, only: [:destroy]
def destroy
revoke_linear_token
@@ -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 :check_authorization, only: [:destroy]
def 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
before_action :setup_shopify_context, only: [:orders]
before_action :fetch_hook, except: [:auth]
before_action :check_authorization, only: [:destroy]
before_action :validate_contact, only: [:orders]
def auth
@@ -1,4 +1,5 @@
class Api::V1::Accounts::LabelsController < Api::V1::Accounts::BaseController
before_action :current_account
before_action :fetch_label, except: [:index, :create]
before_action :check_authorization
@@ -17,16 +18,7 @@ class Api::V1::Accounts::LabelsController < Api::V1::Accounts::BaseController
end
def destroy
label_title = @label.title
account_id = Current.account.id
label_deleted_at = Time.current
@label.destroy!
Labels::RemoveAssociationsJob.perform_later(
label_title: label_title,
account_id: account_id,
label_deleted_at: label_deleted_at
)
head :ok
end
@@ -7,9 +7,7 @@ class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts
redirect_uri: "#{base_url}/microsoft/callback",
scope: scope,
state: state,
# Force the Microsoft account picker so an already-signed-in account does not
# silently authorize and re-bind to an existing inbox in the new-inbox flow.
prompt: 'select_account'
prompt: 'consent'
}
)
if redirect_url
@@ -41,9 +41,9 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro
def destroy_all
if params[:type] == 'read'
::Notification::DeleteNotificationJob.perform_later(Current.user, Current.account, type: :read)
::Notification::DeleteNotificationJob.perform_later(Current.user, type: :read)
else
::Notification::DeleteNotificationJob.perform_later(Current.user, Current.account, type: :all)
::Notification::DeleteNotificationJob.perform_later(Current.user, type: :all)
end
head :ok
end
@@ -69,7 +69,7 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro
end
def fetch_notification
@notification = current_user.notifications.where(account_id: Current.account.id).find(params[:id])
@notification = current_user.notifications.find(params[:id])
end
def set_current_page
@@ -8,15 +8,7 @@ class Api::V1::Accounts::OauthAuthorizationController < Api::V1::Accounts::BaseC
end
def state
# The sgid purpose doubles as a return hint: onboarding tags it so the callback
# can route the user back to inbox setup. The purpose is part of the signed
# payload (tamper-proof), and a non-onboarding request keeps the default
# purpose, leaving callers like Notion byte-identical.
Current.account.to_sgid(expires_in: 15.minutes, for: state_purpose).to_s
end
def state_purpose
params[:return_to] == 'onboarding' ? 'onboarding' : 'default'
Current.account.to_sgid(expires_in: 15.minutes).to_s
end
def base_url
@@ -1,89 +0,0 @@
class Api::V1::Accounts::OnboardingsController < Api::V1::Accounts::BaseController
before_action :check_admin_authorization?
ONBOARDING_STEP_KEY = 'onboarding_step'.freeze
STEP_ACCOUNT_DETAILS = 'account_details'.freeze
STEP_INBOX_SETUP = 'inbox_setup'.freeze
ONBOARDING_STEPS = [STEP_ACCOUNT_DETAILS, STEP_INBOX_SETUP].freeze
def update
return render json: { error: 'Invalid onboarding step' }, status: :unprocessable_entity unless ONBOARDING_STEPS.include?(params[:onboarding_step])
@account = Current.account
# The client declares the step it is completing; `account_details` runs
# `complete_account_details`, and so on. The known-step guard above keeps the
# client value from `send`-ing an arbitrary method.
send("complete_#{params[:onboarding_step]}")
render 'api/v1/accounts/update', format: :json
end
def help_center_generation
render json: help_center_generation_status
end
private
def complete_account_details
# Only act while the cursor still points here, so a stale replay after
# onboarding finished can't re-enter it.
return unless current_step == STEP_ACCOUNT_DETAILS
@account.assign_attributes(account_params)
@account.custom_attributes.merge!(custom_attributes_params)
# inbox_setup is a cloud-only step (DEPLOYMENT_ENV config, not a hardcoded
# environment check); self-hosted finishes onboarding here.
if ChatwootApp.chatwoot_cloud?
move_to_step(STEP_INBOX_SETUP)
create_onboarding_inboxes
else
finish_onboarding
end
end
def complete_inbox_setup
# Only finalize while the cursor still points here, so a stale or out-of-order
# request can't end onboarding early. Replays are no-ops.
return unless current_step == STEP_INBOX_SETUP
finish_onboarding
end
def current_step
@account.custom_attributes[ONBOARDING_STEP_KEY]
end
def move_to_step(step)
@account.custom_attributes[ONBOARDING_STEP_KEY] = step
@account.save!
end
def finish_onboarding
@account.custom_attributes.delete(ONBOARDING_STEP_KEY)
@account.save!
end
def create_onboarding_inboxes
Onboarding::WebWidgetCreationService.new(@account, Current.user).perform
end
def account_params
params.permit(:name, :locale)
end
def custom_attributes_params
params.permit(:industry, :company_size, :timezone, :referral_source, :user_role, :website)
end
def help_center_generation_status
{
generation_id: nil,
state: nil,
articles_count: 0,
categories_count: 0
}
end
end
Api::V1::Accounts::OnboardingsController.prepend_mod_with('Api::V1::Accounts::OnboardingsController')
@@ -18,7 +18,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
@portal = Current.account.portals.build(portal_params.merge(live_chat_widget_params))
@portal.custom_domain = parsed_custom_domain
@portal.save!
process_attached_logo if params[:blob_id].present?
process_attached_logo
end
def update
@@ -78,28 +78,16 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
def portal_params
params.require(:portal).permit(
:id, :color, :custom_domain, :header_text, :homepage_link,
:name, :page_title, :slug, :archived,
{ config: [:default_locale, :layout, { allowed_locales: [] }, { draft_locales: [] },
{ social_profiles: %i[facebook x instagram linkedin youtube tiktok github whatsapp] },
{ locale_translations: locale_translation_keys.index_with { %i[name page_title header_text] } },
{ popular_content: popular_content_keys.index_with { { category_ids: [], article_ids: [] } } }] }
:name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }, { draft_locales: [] }] }
)
end
def locale_translation_keys
params.dig(:portal, :config, :locale_translations)&.keys || []
end
def popular_content_keys
params.dig(:portal, :config, :popular_content)&.keys || []
end
def live_chat_widget_params
permitted_params = params.permit(:inbox_id)
return {} unless permitted_params.key?(:inbox_id)
return { channel_web_widget_id: nil } if permitted_params[:inbox_id].blank?
inbox = Current.account.inboxes.find(permitted_params[:inbox_id])
inbox = Inbox.find(permitted_params[:inbox_id])
return {} unless inbox.web_widget?
{ channel_web_widget_id: inbox.channel.id }
@@ -110,8 +98,6 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
end
def parsed_custom_domain
return @portal.custom_domain if @portal.custom_domain.blank?
domain = URI.parse(@portal.custom_domain)
domain.is_a?(URI::HTTP) ? domain.host : @portal.custom_domain
end
@@ -29,6 +29,6 @@ class Api::V1::Accounts::TeamsController < Api::V1::Accounts::BaseController
end
def team_params
params.require(:team).permit(:name, :description, :allow_auto_assign, :icon, :icon_color)
params.require(:team).permit(:name, :description, :allow_auto_assign)
end
end
@@ -3,7 +3,7 @@ class Api::V1::Accounts::Tiktok::AuthorizationsController < Api::V1::Accounts::O
def create
redirect_url = Tiktok::AuthClient.authorize_url(
state: generate_tiktok_token(Current.account.id, params[:return_to])
state: generate_tiktok_token(Current.account.id)
)
if redirect_url
@@ -1,7 +1,4 @@
class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts::BaseController
before_action :ensure_embedded_signup_enabled
# Reconfiguring/reauthorizing a live inbox swaps its credentials, so restrict it to admins.
before_action :check_admin_authorization?, if: -> { params[:inbox_id].present? }
before_action :fetch_and_validate_inbox, if: -> { params[:inbox_id].present? }
# POST /api/v1/accounts/:account_id/whatsapp/authorization
@@ -11,21 +8,12 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
validate_embedded_signup_params!
channel = process_embedded_signup
render_success_response(channel.inbox)
rescue CustomExceptions::Inbox::LimitExceeded => e
render_error_response(e)
rescue StandardError => e
render_embedded_signup_error(e)
render_error_response(e)
end
private
def ensure_embedded_signup_enabled
return unless ChatwootApp.chatwoot_cloud?
return if Current.account.feature_enabled?('whatsapp_embedded_signup_inbox_creation')
raise Pundit::NotAuthorizedError
end
def process_embedded_signup
service = Whatsapp::EmbeddedSignupService.new(
account: Current.account,
@@ -41,7 +29,7 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
end
def validate_reauthorization_required
return if @inbox.channel.reauthorization_required? || can_reconfigure_channel?
return if @inbox.channel.reauthorization_required? || can_upgrade_to_embedded_signup?
render json: {
success: false,
@@ -49,11 +37,9 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
}, status: :unprocessable_entity
end
def can_reconfigure_channel?
def can_upgrade_to_embedded_signup?
channel = @inbox.channel
return false unless channel.provider == 'whatsapp_cloud'
return true if ChatwootApp.chatwoot_cloud?
return Current.account.feature_enabled?('whatsapp_reconfigure') if channel.provider_config['source'] == 'embedded_signup'
true
end
@@ -69,7 +55,7 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
render json: response
end
def render_embedded_signup_error(error)
def render_error_response(error)
Rails.logger.error "[WHATSAPP AUTHORIZATION] Embedded signup error: #{error.message}"
Rails.logger.error error.backtrace.join("\n")
render json: {
@@ -0,0 +1,18 @@
class Api::V1::Accounts::WorkingHoursController < Api::V1::Accounts::BaseController
before_action :check_authorization
before_action :fetch_webhook, only: [:update]
def update
@working_hour.update!(working_hour_params)
end
private
def working_hour_params
params.require(:working_hour).permit(:inbox_id, :open_hour, :open_minutes, :close_hour, :close_minutes, :closed_all_day)
end
def fetch_working_hour
@working_hour = Current.account.working_hours.find(params[:id])
end
end
@@ -8,7 +8,6 @@ class Api::V1::AccountsController < Api::BaseController
before_action :ensure_account_name, only: [:create]
before_action :validate_captcha, only: [:create]
before_action :fetch_account, except: [:create]
before_action :validate_token_api_access, if: :authenticate_by_access_token?, except: [:create]
before_action :check_authorization, except: [:create]
rescue_from CustomExceptions::Account::InvalidEmail,
@@ -59,6 +58,7 @@ class Api::V1::AccountsController < Api::BaseController
@account.assign_attributes(account_params.slice(:name, :locale, :domain, :support_email))
@account.custom_attributes.merge!(custom_attributes_params)
@account.settings.merge!(settings_params)
@account.custom_attributes.delete('onboarding_step') if @account.custom_attributes['onboarding_step'] == 'account_details'
@account.custom_attributes['onboarding_step'] = 'invite_team' if @account.custom_attributes['onboarding_step'] == 'account_update'
@account.save!
end
@@ -106,18 +106,12 @@ class Api::V1::AccountsController < Api::BaseController
@current_account_user = @account.account_users.find_by(user_id: current_user.id)
end
def validate_token_api_access
return if @account.api_and_webhooks_enabled?
render json: { error: 'API access is not enabled for this account' }, status: :forbidden
end
def account_params
params.permit(:account_name, :email, :name, :password, :locale, :domain, :support_email, :user_full_name)
end
def custom_attributes_params
params.permit(:industry, :company_size, :timezone, :referral_source, :user_role, :website)
params.permit(:industry, :company_size, :timezone, :referral_source, :user_role)
end
def settings_params
@@ -8,8 +8,7 @@ class Api::V1::NotificationSubscriptionsController < Api::BaseController
end
def destroy
notification_subscription = current_user.notification_subscriptions
.where(["subscription_attributes->>'push_token' = ?", params[:push_token]]).first
notification_subscription = NotificationSubscription.where(["subscription_attributes->>'push_token' = ?", params[:push_token]]).first
notification_subscription.destroy! if notification_subscription.present?
head :ok
end
@@ -1,36 +0,0 @@
class Api::V1::Profile::SessionsController < Api::BaseController
before_action :set_session, only: [:destroy]
def index
@sessions = current_user.user_sessions.where(client_id: active_token_client_ids).order(last_activity_at: :desc)
@current_client_id = request.headers['client']
end
def destroy
if @session.current?(request.headers['client'])
render json: { error: I18n.t('profile_settings.sessions.cannot_revoke_current') }, status: :unprocessable_entity
return
end
revoke_token!(@session.client_id)
@session.destroy!
head :ok
end
private
def set_session
@session = current_user.user_sessions.find(params[:id])
end
def revoke_token!(client_id)
tokens = current_user.tokens
tokens.delete(client_id)
current_user.update!(tokens: tokens)
end
def active_token_client_ids
now = Time.current.to_i
(current_user.tokens || {}).select { |_, v| v['expiry'].to_i > now }.keys
end
end
@@ -59,10 +59,6 @@ class Api::V1::Widget::BaseController < ApplicationController
permitted_params.dig(:contact, :phone_number)
end
def contact_custom_attributes
permitted_params.dig(:contact, :custom_attributes)&.to_h
end
def browser_params
{
browser_name: browser.name,
@@ -2,7 +2,6 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
include WidgetHelper
before_action :validate_hmac, only: [:set_user]
before_action :validate_hmac_for_identified_update, only: [:update]
def show; end
@@ -47,16 +46,6 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
@contact.identifier.present? && @contact.identifier != permitted_params[:identifier]
end
# The plain update endpoint is also used for anonymous prechat updates
# (name/email/phone/custom_attributes with no identifier), which must keep
# working on hmac_mandatory inboxes. Only the identity-binding path, where an
# identifier is supplied and the contact can be rebound, requires HMAC.
def validate_hmac_for_identified_update
return if params[:identifier].blank?
validate_hmac
end
def validate_hmac
return unless should_verify_hmac?
@@ -73,15 +62,11 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
end
def valid_hmac?
expected_hash = OpenSSL::HMAC.hexdigest(
params[:identifier_hash] == OpenSSL::HMAC.hexdigest(
'sha256',
@web_widget.hmac_token,
params[:identifier].to_s
)
identifier_hash = params[:identifier_hash].to_s
return false unless identifier_hash.bytesize == expected_hash.bytesize
ActiveSupport::SecurityUtils.secure_compare(identifier_hash, expected_hash)
end
def permitted_params
@@ -19,7 +19,7 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
def process_update_contact
@contact = ContactIdentifyAction.new(
contact: @contact,
params: { email: contact_email, phone_number: contact_phone_number, name: contact_name, custom_attributes: contact_custom_attributes },
params: { email: contact_email, phone_number: contact_phone_number, name: contact_name },
retain_original_contact_name: true,
discard_invalid_attrs: true
).perform
@@ -95,7 +95,7 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
end
def permitted_params
params.permit(:id, :typing_status, :website_token, :email, contact: [:name, :email, :phone_number, { custom_attributes: {} }],
params.permit(:id, :typing_status, :website_token, :email, contact: [:name, :email, :phone_number],
message: [:content, :referer_url, :timestamp, :echo_id],
custom_attributes: {})
end
@@ -10,8 +10,7 @@ class Api::V1::Widget::Integrations::DyteController < Api::V1::Widget::BaseContr
response = dyte_processor_service.add_participant_to_meeting(
@message.content_attributes['data']['meeting_id'],
@conversation.contact,
@message
@conversation.contact
)
render_response(response)
end
@@ -83,10 +83,6 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
end
def set_message
# `conversation.messages.find` would be simpler, but `conversation` is `conversations.last`,
# which means a visitor with more than one open thread could not edit a message in any
# but their most recent one. Scoping across all of the visitor's conversations keeps the
# happy path correct for that future multi-conversation widget flow.
@message = Message.where(conversation_id: conversations.select(:id)).find(permitted_params[:id])
@message = @web_widget.inbox.messages.find(permitted_params[:id])
end
end
@@ -51,13 +51,6 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
generate_csv('conversation_traffic_reports', 'api/v2/accounts/reports/conversation_traffic')
end
def drilldown
return head :unauthorized unless Current.account_user.administrator?
return head :unprocessable_entity unless valid_drilldown_params?
render json: V2::Reports::DrilldownBuilder.new(Current.account, drilldown_params).build
end
def conversations
return head :unprocessable_entity if params[:type].blank?
@@ -140,22 +133,6 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
})
end
def drilldown_params
permitted_params = params.permit(
:metric, :id, :since, :until, :group_by, :timezone_offset, :bucket_timestamp, :page, :per_page
).to_h.symbolize_keys
permitted_params.merge(
type: (params[:type].presence || 'account').to_sym,
business_hours: ActiveModel::Type::Boolean.new.cast(params[:business_hours])
)
end
def valid_drilldown_params?
%i[metric bucket_timestamp since until].all? { |param| params[param].present? } &&
Reports::ReportMetricRegistry.supported?(params[:metric]) &&
V2::Reports::DrilldownBuilder.supported_dimension_type?(params[:type]) && Reports::DrilldownTimestampValidator.valid?(params)
end
def conversation_params
{
type: params[:type].to_sym,
@@ -3,7 +3,6 @@ class ApplicationController < ActionController::Base
include RequestExceptionHandler
include Pundit::Authorization
include SwitchLocale
include TrackSessionActivity
skip_before_action :verify_authenticity_token
@@ -1,9 +1,8 @@
module AccessTokenAuthHelper
BOT_ACCESSIBLE_ENDPOINTS = {
'api/v1/accounts/conversations' => %w[show toggle_status toggle_typing_status toggle_priority create update custom_attributes],
'api/v1/accounts/conversations' => %w[toggle_status toggle_typing_status toggle_priority create update custom_attributes],
'api/v1/accounts/conversations/messages' => ['create'],
'api/v1/accounts/conversations/assignments' => ['create'],
'api/v1/accounts/conversations/labels' => %w[index create]
'api/v1/accounts/conversations/assignments' => ['create']
}.freeze
def ensure_access_token
@@ -14,8 +14,6 @@ module EnsureCurrentAccountHelper
account_accessible_for_user?(account)
elsif @resource.is_a?(AgentBot)
account_accessible_for_bot?(account)
else
render_unauthorized(I18n.t('errors.account.not_authorized'))
end
account
end
@@ -23,7 +21,7 @@ module EnsureCurrentAccountHelper
def account_accessible_for_user?(account)
@current_account_user = account.account_users.find_by(user_id: current_user.id)
Current.account_user = @current_account_user
render_unauthorized(I18n.t('errors.account.not_authorized')) unless @current_account_user
render_unauthorized('You are not authorized to access this account') unless @current_account_user
end
def account_accessible_for_bot?(account)
@@ -2,10 +2,6 @@
# This concern handles the token verification step.
module MetaTokenVerifyConcern
CHANNEL_APP_SECRET_KEYS = %w[app_secret app_secret_key client_secret api_secret].freeze
META_SIGNATURE_HEADER = 'X-Hub-Signature-256'.freeze
META_SIGNATURE_PREFIX = 'sha256='.freeze
def verify
service = is_a?(Webhooks::WhatsappController) ? 'whatsapp' : 'instagram'
if valid_token?(params['hub.verify_token'])
@@ -18,53 +14,6 @@ module MetaTokenVerifyConcern
private
def verify_meta_signature!
return unless meta_signature_verification_required?
return if valid_meta_signature?
head :unauthorized
end
def valid_meta_signature?
signature = request.headers[META_SIGNATURE_HEADER]
return false unless signature&.start_with?(META_SIGNATURE_PREFIX)
meta_app_secrets.any? do |secret|
next false if secret.blank?
expected_signature = "#{META_SIGNATURE_PREFIX}#{OpenSSL::HMAC.hexdigest('SHA256', secret, meta_request_body)}"
ActiveSupport::SecurityUtils.secure_compare(expected_signature, signature)
end
end
def meta_request_body
@meta_request_body ||= request.raw_post
end
def meta_app_secrets
raise 'Overwrite this method in your controller'
end
def meta_signature_verification_required?
true
end
def channel_meta_app_secrets(channel)
return [] if channel.blank?
secrets = []
secrets << channel.app_secret if channel.respond_to?(:app_secret)
secrets.concat(provider_config_meta_app_secrets(channel))
secrets.compact_blank.uniq
end
def provider_config_meta_app_secrets(channel)
return [] unless channel.respond_to?(:provider_config)
provider_config = channel.provider_config.to_h.with_indifferent_access
CHANNEL_APP_SECRET_KEYS.filter_map { |key| provider_config[key].presence }
end
def valid_token?(_token)
raise 'Overwrite this method your controller'
end
@@ -1,65 +0,0 @@
module PortalHomeData
extend ActiveSupport::Concern
private
def load_home_data
load_recommended_content
# The classic hero only needs the recommendations above; the rest is
# documentation-layout home data (also used on custom-domain home pages).
return unless @portal.layout == 'documentation'
@visible_categories = @portal.categories
.where(locale: @locale)
.joins(:articles).where(articles: { status: :published })
.order(position: :asc)
.group('categories.id')
@popular_topics = @recommended_categories.presence || @visible_categories.first(3)
@featured = base_articles.order_by_views.limit(6)
@category_contributors = build_category_contributors(@visible_categories)
end
def load_recommended_content
@recommended_categories = recommended_categories
@recommended_articles = recommended_articles
end
def base_articles
@base_articles ||= @portal.articles.published.where(locale: @locale).includes(:author, :category)
end
# Admin-recommended categories for the locale, in the chosen order. Unlike the
# position-based fallback, published articles aren't required: the admin's pick wins.
def recommended_categories
ids = @portal.popular_category_ids(@locale)
ordered_by_ids(@portal.categories.where(locale: @locale, id: ids), ids)
end
# Admin-recommended articles for the locale, in the chosen order, limited to
# published articles that still exist.
def recommended_articles
ids = @portal.popular_article_ids(@locale)
ordered_by_ids(base_articles.where(id: ids), ids)
end
# Loads the scope and returns its records ordered to match `ids`, dropping any
# that no longer exist. Skips the query entirely when `ids` is blank.
def ordered_by_ids(scope, ids)
return [] if ids.blank?
by_id = scope.index_by(&:id)
ids.filter_map { |id| by_id[id] }
end
def build_category_contributors(categories)
category_ids = categories.map(&:id)
return {} if category_ids.empty?
@portal.articles
.published
.where(locale: @locale, category_id: category_ids)
.includes(:author)
.group_by(&:category_id)
.transform_values { |articles| articles.filter_map(&:author).uniq.first(3) }
end
end
@@ -1,15 +1,8 @@
module RequestExceptionHandler
extend ActiveSupport::Concern
QUERY_CANCELED_ERROR_MESSAGE_PATTERNS = [
'ActiveRecord::QueryCanceled',
'PG::QueryCanceled',
'canceling statement due to statement timeout'
].freeze
included do
rescue_from ActiveRecord::RecordInvalid, with: :render_record_invalid
rescue_from CustomExceptions::Inbox::LimitExceeded, with: :render_error_response
end
private
@@ -25,9 +18,6 @@ module RequestExceptionHandler
rescue ActionController::ParameterMissing => e
log_handled_error(e)
render_could_not_create_error(e.message)
rescue ActiveRecord::QueryCanceled => e
log_handled_error(e)
render_could_not_create_error(database_query_canceled_message)
ensure
# to address the thread variable leak issues in Puma/Thin webserver
Current.reset
@@ -41,8 +31,8 @@ module RequestExceptionHandler
render json: { error: message }, status: :not_found
end
def render_could_not_create_error(error)
render json: { error: sanitized_error_message(error) }, status: :unprocessable_entity
def render_could_not_create_error(message)
render json: { error: message }, status: :unprocessable_entity
end
def render_payment_required(message)
@@ -69,19 +59,4 @@ module RequestExceptionHandler
def log_handled_error(exception)
logger.info("Handled error: #{exception.inspect}")
end
def sanitized_error_message(message)
return database_query_canceled_message if database_query_canceled_message?(message)
message
end
def database_query_canceled_message?(message)
error_message = message.to_s
QUERY_CANCELED_ERROR_MESSAGE_PATTERNS.any? { |pattern| error_message.include?(pattern) }
end
def database_query_canceled_message
I18n.t('errors.database.query_canceled')
end
end
@@ -1,22 +0,0 @@
module TrackSessionActivity
extend ActiveSupport::Concern
included do
after_action :update_session_activity
end
private
def update_session_activity
return unless current_user
return if request.headers['client'].blank?
UserSessionTrackingService.new(
user: current_user,
request: request,
client_id: request.headers['client']
).update_activity!
rescue StandardError => e
Rails.logger.warn "Session activity update failed: #{e.message}"
end
end
+1 -11
View File
@@ -1,6 +1,5 @@
class DashboardController < ActionController::Base
include SwitchLocale
include PortalHomeData
GLOBAL_CONFIG_KEYS = %w[
LOGO
@@ -64,8 +63,6 @@ class DashboardController < ActionController::Base
return unless @portal
@locale = @portal.default_locale
request.variant = :documentation if @portal.layout == 'documentation'
load_home_data
render 'public/api/v1/portals/show', layout: 'portal', portal: @portal and return
end
@@ -83,17 +80,10 @@ class DashboardController < ActionController::Base
IS_ENTERPRISE: ChatwootApp.enterprise?,
AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''),
GIT_SHA: GIT_HASH,
ALLOWED_LOGIN_METHODS: allowed_login_methods,
ACTIVE_PLATFORM_BANNERS: active_platform_banners
ALLOWED_LOGIN_METHODS: allowed_login_methods
}
end
def active_platform_banners
return [] unless ChatwootApp.chatwoot_cloud?
PlatformBanner.active.order(created_at: :desc).as_json(only: %i[id banner_message banner_type updated_at])
end
def allowed_login_methods
methods = ['email']
methods << 'google_oauth' if GlobalConfigService.load('ENABLE_GOOGLE_OAUTH_LOGIN', 'true').to_s != 'false'
@@ -1,6 +1,4 @@
class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
MAX_SESSIONS = ENV.fetch('MAX_USER_SESSIONS', 25).to_i
# Prevent session parameter from being passed
# Unpermitted parameter: session
wrap_parameters format: []
@@ -16,27 +14,17 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
user = find_user_for_authentication
return handle_mfa_required(user) if user&.mfa_enabled?
return if user && enforce_session_limit_for_password_login(user)
# Only proceed with standard authentication if no MFA is required
super
end
def render_create_success
track_user_session unless @impersonation
render partial: 'devise/auth', formats: [:json], locals: { resource: @resource }
end
private
def render_create_error_not_confirmed
render_error(
:unauthorized,
I18n.t('devise_token_auth.sessions.not_confirmed', email: @resource.email),
error_code: 'user_not_confirmed'
)
end
def find_user_for_authentication
return nil unless params[:email].present? && params[:password].present?
@@ -57,8 +45,6 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
end
def handle_sso_authentication
return if !@impersonation && enforce_session_limit_for_password_login(@resource)
authenticate_resource_with_sso_token
yield @resource if block_given?
render_create_success
@@ -71,10 +57,7 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
end
def authenticate_resource_with_sso_token
# DTA evicts the earliest-expiring token after save when at max_number_of_devices.
# The short-lived impersonation token would always be that one, so pre-evict to make room.
make_room_for_impersonation_token if @impersonation
@token = @resource.create_token(lifespan: @impersonation ? 2.days.to_i : nil)
@token = @resource.create_token
@resource.save!
sign_in(:user, @resource, store: false, bypass: false)
@@ -82,21 +65,11 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
@resource.invalidate_sso_auth_token(params[:sso_auth_token])
end
def make_room_for_impersonation_token
return if @resource.tokens.size < DeviseTokenAuth.max_number_of_devices
oldest_client_id = @resource.tokens.min_by { |_, v| v['expiry'].to_i }&.first
@resource.tokens.delete(oldest_client_id) if oldest_client_id
end
def process_sso_auth_token
return if params[:email].blank?
user = User.from_email(params[:email])
return unless user&.valid_sso_auth_token?(params[:sso_auth_token])
@resource = user
@impersonation = user.sso_auth_token_impersonation?(params[:sso_auth_token])
@resource = user if user&.valid_sso_auth_token?(params[:sso_auth_token])
end
def handle_mfa_required(user)
@@ -122,7 +95,6 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
end
def sign_in_mfa_user(user)
evict_oldest_session(user) if sessions_limit_reached?(user)
@resource = user
@token = @resource.create_token
@resource.save!
@@ -134,103 +106,6 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
def render_mfa_error(message_key, status = :bad_request)
render json: { error: I18n.t(message_key) }, status: status
end
def sessions_limit_reached?(user)
active_token_count(user) >= MAX_SESSIONS
end
def active_token_count(user)
now = Time.current.to_i
(user.tokens || {}).count { |_, v| v['expiry'].to_i > now }
end
# Returns true when a response has been rendered (e.g., 409 picker). Non-browser clients
# auto-evict instead of getting stuck on a UI they can't render.
def enforce_session_limit_for_password_login(user)
if revoking_sessions?
revoke_sessions_for_login(user)
return false
end
return false unless sessions_limit_reached?(user)
# Picker only when every token has a tracked session; partial tracking would
# show a misleading count, so fall through to silent eviction instead.
if browser_request? && user.user_sessions.count >= user.tokens.size
handle_sessions_limit_for_login(user)
true
else
evict_oldest_session(user)
false
end
end
def browser_request?
request.user_agent.to_s.include?('Mozilla')
end
def revoking_sessions?
params[:revoke_session_id].present? || params[:revoke_all_sessions].present?
end
def revoke_sessions_for_login(user)
if params[:revoke_all_sessions].present?
user.tokens = {}
user.save!
user.user_sessions.destroy_all
elsif params[:revoke_session_id].present?
session = user.user_sessions.find_by(id: params[:revoke_session_id])
return unless session
user.tokens.delete(session.client_id)
user.save!
session.destroy!
end
end
def evict_oldest_session(user)
# Drop pre-rollout untracked tokens first so freshly tracked logins aren't evicted.
return evict_oldest_token(user) if user.user_sessions.count < user.tokens.size
oldest_session = user.user_sessions.order(Arel.sql('COALESCE(last_activity_at, created_at) ASC')).first
return evict_oldest_token(user) unless oldest_session
user.tokens.delete(oldest_session.client_id)
user.save!
oldest_session.destroy!
end
# Fallback if a token exists without a UserSession row (e.g., legacy data before tracking shipped).
def evict_oldest_token(user)
return if user.tokens.blank?
oldest_client_id = user.tokens.min_by { |_, v| v['expiry'].to_i }&.first
return unless oldest_client_id
user.tokens.delete(oldest_client_id)
user.save!
end
PICKER_SESSION_FIELDS = %i[id browser_name browser_version device_name platform_name platform_version
ip_address city country last_activity_at created_at].freeze
def handle_sessions_limit_for_login(user)
sessions = user.user_sessions.order(last_activity_at: :desc).map { |s| s.slice(*PICKER_SESSION_FIELDS) }
render json: { sessions_limit_reached: true, sessions: sessions }, status: :conflict
end
def track_user_session
client_id = @token&.try(:client) || response.headers['client']
return unless client_id.present? && @resource.present?
UserSessionTrackingService.new(
user: @resource,
request: request,
client_id: client_id
).create_or_update!
rescue StandardError => e
Rails.logger.warn "Session tracking failed: #{e.message}"
end
end
DeviseOverrides::SessionsController.prepend_mod_with('DeviseOverrides::SessionsController')
@@ -11,8 +11,6 @@ class Instagram::CallbacksController < ApplicationController
end
process_successful_authorization
rescue CustomExceptions::Inbox::LimitExceeded => e
handle_limit_error(e)
rescue StandardError => e
handle_error(e)
end
@@ -30,8 +28,6 @@ class Instagram::CallbacksController < ApplicationController
@long_lived_token_response = exchange_for_long_lived_token(@response.token)
inbox, already_exists = find_or_create_inbox
return redirect_to app_onboarding_inbox_setup_url(account_id: account_id) if return_to == 'onboarding'
if already_exists
redirect_to app_instagram_inbox_settings_url(account_id: account_id, inbox_id: inbox.id)
else
@@ -49,14 +45,6 @@ class Instagram::CallbacksController < ApplicationController
redirect_to_error_page(error_info)
end
def handle_limit_error(error)
redirect_to_error_page(
'error_type' => error.class.name,
'code' => Rack::Utils.status_code(error.http_status),
'error_message' => error.message
)
end
# Extract error details from the exception
def extract_error_info(error)
if error.is_a?(OAuth2::Error)
@@ -161,10 +149,6 @@ class Instagram::CallbacksController < ApplicationController
verify_instagram_token(params[:state])
end
def return_to
instagram_token_return_to(params[:state])
end
def oauth_code
params[:code]
end
@@ -14,11 +14,4 @@ class Microsoft::CallbacksController < OauthCallbackController
def imap_address
'outlook.office365.com'
end
# Exchange Online's SMTP AUTH (XOAUTH2) rejects proxy addresses in the SASL `user=` field;
# it must match the token's UPN. `preferred_username` is the documented v2.0 claim;
# `upn` is the v1.0 fallback.
def imap_login_identity
users_data['preferred_username'] || users_data['upn'] || super
end
end
+2 -25
View File
@@ -16,8 +16,6 @@ class OauthCallbackController < ApplicationController
def handle_response
inbox, already_exists = find_or_create_inbox
return redirect_to app_onboarding_inbox_setup_url(account_id: account.id) if return_to == 'onboarding'
if already_exists
redirect_to app_email_inbox_settings_url(account_id: account.id, inbox_id: inbox.id)
else
@@ -46,7 +44,7 @@ class OauthCallbackController < ApplicationController
def update_channel(channel_email)
channel_email.update!({
imap_login: imap_login_identity, imap_address: imap_address,
imap_login: users_data['email'], imap_address: imap_address,
imap_port: '993', imap_enabled: true,
provider: provider_name,
provider_config: {
@@ -57,13 +55,6 @@ class OauthCallbackController < ApplicationController
})
end
# Identity used as the IMAP/SMTP login (SASL XOAUTH2 `user=` field). Defaults to the
# id_token's email claim; providers override when their server requires a different
# claim (e.g. Microsoft SMTP requires UPN).
def imap_login_identity
users_data['email']
end
def provider_name
raise NotImplementedError
end
@@ -90,19 +81,10 @@ class OauthCallbackController < ApplicationController
decoded_token[0]
end
# The sgid purpose carries the onboarding return hint (see
# OauthAuthorizationController#state). Try the onboarding purpose first — a match
# both resolves the account and records the return target — then fall back to the
# default purpose used by every other caller.
def account_from_signed_id
raise ActionController::BadRequest, 'Missing state variable' if params[:state].blank?
if (account = GlobalID::Locator.locate_signed(params[:state], for: 'onboarding'))
@return_to = 'onboarding'
else
account = GlobalID::Locator.locate_signed(params[:state])
end
account = GlobalID::Locator.locate_signed(params[:state])
raise 'Invalid or expired state' if account.nil?
account
@@ -112,11 +94,6 @@ class OauthCallbackController < ApplicationController
@account ||= account_from_signed_id
end
def return_to
account # resolving the sgid records which purpose matched
@return_to
end
# Fallback name, for when name field is missing from users_data
def fallback_name
users_data['email'].split('@').first.parameterize.titleize
@@ -18,8 +18,7 @@ class Public::Api::V1::Inboxes::ContactsController < Public::Api::V1::InboxesCon
contact: @contact_inbox.contact,
params: permitted_params.to_h.deep_symbolize_keys.except(:identifier)
)
contact_identify_action.perform
@contact_inbox.reload
render json: contact_identify_action.perform
end
private
@@ -36,15 +35,11 @@ class Public::Api::V1::Inboxes::ContactsController < Public::Api::V1::InboxesCon
end
def valid_hmac?
expected_hash = OpenSSL::HMAC.hexdigest(
params[:identifier_hash] == OpenSSL::HMAC.hexdigest(
'sha256',
@inbox_channel.hmac_token,
params[:identifier].to_s
)
identifier_hash = params[:identifier_hash].to_s
return false unless identifier_hash.bytesize == expected_hash.bytesize
ActiveSupport::SecurityUtils.secure_compare(identifier_hash, expected_hash)
end
def permitted_params
@@ -24,10 +24,6 @@ class Public::Api::V1::InboxesController < PublicController
def set_conversation
return if params[:conversation_id].blank?
@conversation = if @contact_inbox.hmac_verified?
@contact_inbox.contact.conversations.find_by!(display_id: params[:conversation_id])
else
@contact_inbox.conversations.find_by!(display_id: params[:conversation_id])
end
@conversation = @contact_inbox.contact.conversations.find_by!(display_id: params[:conversation_id])
end
end
@@ -1,11 +1,9 @@
class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::BaseController
before_action :ensure_custom_domain_request, only: [:show, :index, :show_markdown]
before_action :ensure_custom_domain_request, only: [:show, :index]
before_action :portal
before_action :set_portal_layout
before_action :set_view_variant
before_action :ensure_portal_feature_enabled
before_action :set_category, except: [:index, :show, :tracking_pixel]
before_action :set_article, only: [:show, :show_markdown]
before_action :set_article, only: [:show]
layout 'portal'
def index
@@ -23,13 +21,6 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
def show
@og_image_url = helpers.set_og_image_url(@portal.name, @article.title)
@parsed_content = render_article_content(@article.content.to_s)
end
def show_markdown
return head :not_found unless @article&.published?
render plain: @article.content.to_s, content_type: 'text/markdown; charset=utf-8'
end
def tracking_pixel
@@ -71,6 +62,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
def set_article
@article = @portal.articles.find_by(slug: permitted_params[:article_slug])
@parsed_content = render_article_content(@article.content.to_s)
end
def set_category
@@ -7,8 +7,6 @@ class Public::Api::V1::Portals::BaseController < PublicController
around_action :set_locale
after_action :allow_iframe_requests
PORTAL_LAYOUTS = %w[classic documentation].freeze
private
def show_plain_layout
@@ -19,18 +17,6 @@ class Public::Api::V1::Portals::BaseController < PublicController
@theme_from_params = params[:theme] if %w[dark light].include?(params[:theme])
end
def set_portal_layout
@portal_layout = PORTAL_LAYOUTS.include?(@portal&.layout) ? @portal.layout : 'classic'
end
def set_view_variant
request.variant = if @is_plain_layout_enabled
:plain
elsif @portal_layout == 'documentation'
:documentation
end
end
def portal
@portal ||= Portal.find_by!(slug: params[:slug], archived: false)
end
@@ -43,11 +29,9 @@ class Public::Api::V1::Portals::BaseController < PublicController
end
def switch_locale_with_portal(&)
# Keep @locale as the portal's own locale code (e.g. th_TH) for content queries,
# while UI translations fall back to an available I18n locale (e.g. th).
@locale = params[:locale]
@locale = validate_and_get_locale(params[:locale])
I18n.with_locale(validate_and_get_locale(@locale), &)
I18n.with_locale(@locale, &)
end
def switch_locale_with_article(&)
@@ -55,12 +39,13 @@ class Public::Api::V1::Portals::BaseController < PublicController
Rails.logger.info "Article: not found for slug: #{params[:article_slug]}"
render_404 && return if article.blank?
@locale = if article.category.present?
article.category.locale
else
article.locale
end
I18n.with_locale(validate_and_get_locale(@locale), &)
article_locale = if article.category.present?
article.category.locale
else
article.portal.default_locale
end
@locale = validate_and_get_locale(article_locale)
I18n.with_locale(@locale, &)
end
def allow_iframe_requests
@@ -69,8 +54,6 @@ class Public::Api::V1::Portals::BaseController < PublicController
def render_404
portal
# set_locale can render_404 before the child's set_view_variant runs; set it here so plain 404s stay chrome-less
set_view_variant
render 'public/api/v1/portals/error/404', status: :not_found
end
@@ -1,18 +1,12 @@
class Public::Api::V1::Portals::CategoriesController < Public::Api::V1::Portals::BaseController
before_action :ensure_custom_domain_request, only: [:show, :index]
before_action :portal
before_action :set_portal_layout
before_action :set_view_variant
before_action :ensure_portal_feature_enabled
before_action :set_category, only: [:show]
before_action :load_category_articles, only: [:show], if: -> { @portal_layout == 'documentation' }
layout 'portal'
def index
respond_to do |format|
format.html { redirect_to public_portal_locale_path(@portal.slug, params[:locale]), status: :moved_permanently }
format.json { @categories = @portal.categories.order(position: :asc) }
end
@categories = @portal.categories.order(position: :asc)
end
def show
@@ -27,9 +21,4 @@ class Public::Api::V1::Portals::CategoriesController < Public::Api::V1::Portals:
Rails.logger.info "Category: not found for slug: #{params[:category_slug]}"
render_404 && return if @category.blank?
end
def load_category_articles
@articles = @category.articles.published.order(:position).includes(:author)
@category_authors = @articles.filter_map(&:author).uniq
end
end
@@ -1,31 +0,0 @@
class Public::Api::V1::Portals::SearchController < Public::Api::V1::Portals::BaseController
before_action :ensure_custom_domain_request, only: [:index]
before_action :portal
before_action :set_portal_layout
before_action :set_view_variant
before_action :ensure_portal_feature_enabled
layout 'portal'
def index
@query = params[:query].to_s.strip
@articles = @portal.articles.published.includes(:category).where(locale: params[:locale])
search_articles
@articles = @articles.page(params[:page]).per(10)
end
private
def search_articles
@articles = @query.present? ? @articles.search(search_params) : @articles.none
end
def search_params
params.permit(:query, :locale, :sort, :status, :page).tap do |permitted|
permitted[:query] = @query
end
end
end
Public::Api::V1::Portals::SearchController.prepend_mod_with('Public::Api::V1::Portals::SearchController')
@@ -1,17 +1,12 @@
class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseController
include PortalHomeData
before_action :ensure_custom_domain_request, only: [:show]
before_action :redirect_to_portal_with_locale, only: [:show]
before_action :portal
before_action :set_portal_layout
before_action :set_view_variant
before_action :ensure_portal_feature_enabled
before_action :load_home_data, only: [:show], unless: -> { @is_plain_layout_enabled }
layout 'portal'
def show
@og_image_url = helpers.set_og_image_url('', @portal.localized_value('header_text', @locale))
@og_image_url = helpers.set_og_image_url('', @portal.header_text)
end
def sitemap
@@ -35,8 +35,7 @@ class SuperAdmin::AccountsController < SuperAdmin::ApplicationController
#
def resource_params
permitted_params = super
permitted_params[:limits] = permitted_params[:limits].to_h.compact if permitted_params.key?(:limits)
permitted_params[:captain_models] = permitted_params[:captain_models].to_h.compact_blank.presence if permitted_params.key?(:captain_models)
permitted_params[:limits] = permitted_params[:limits].to_h.compact
permitted_params[:selected_feature_flags] = params[:enabled_features].keys.map(&:to_sym) if params[:enabled_features].present?
permitted_params
end
@@ -27,7 +27,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
if errors.any?
redirect_to super_admin_app_config_path(config: @config), alert: errors.join(', ')
else
redirect_to super_admin_settings_path, flash: success_flash
redirect_to super_admin_settings_path, notice: "App Configs - #{@config.titleize} updated successfully"
end
end
@@ -58,21 +58,6 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
%w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS WEBHOOK_TIMEOUT MAXIMUM_FILE_UPLOAD_SIZE WIDGET_TOKEN_EXPIRY]
)
end
def success_notice
message = "#{@config.titleize} settings updated successfully"
return message unless restart_required_config_saved?
"#{message.delete_suffix('.')}. Restart Chatwoot web and worker processes to apply this change everywhere."
end
def success_flash
restart_required_config_saved? ? { success: success_notice } : { notice: success_notice }
end
def restart_required_config_saved?
params.fetch('app_config', {}).keys.intersect?(InstallationConfig::RESTART_REQUIRED_CONFIG_KEYS)
end
end
SuperAdmin::AppConfigsController.prepend_mod_with('SuperAdmin::AppConfigsController')
@@ -25,29 +25,6 @@ class SuperAdmin::InstallationConfigsController < SuperAdmin::ApplicationControl
resource_class.editable
end
def create
resource = new_resource(resource_params)
authorize_resource(resource)
if resource.save
redirect_to after_resource_created_path(resource), flash: success_flash(resource)
else
render :new, locals: {
page: Administrate::Page::Form.new(dashboard, resource)
}, status: :unprocessable_entity
end
end
def update
if requested_resource.update(resource_params)
redirect_to after_resource_updated_path(requested_resource), flash: success_flash(requested_resource)
else
render :edit, locals: {
page: Administrate::Page::Form.new(dashboard, requested_resource)
}, status: :unprocessable_entity
end
end
# Override `resource_params` if you want to transform the submitted
# data before it's persisted. For example, the following would turn all
# empty values into nil values. It uses other APIs such as `resource_class`
@@ -65,20 +42,6 @@ class SuperAdmin::InstallationConfigsController < SuperAdmin::ApplicationControl
.transform_values { |value| value == '' ? nil : value }.merge(locked: false)
end
private
def success_flash(resource)
message = translate_with_resource('update.success')
message = translate_with_resource('create.success') if action_name == 'create'
return { notice: message } unless restart_required_config?(resource)
{ success: "#{message.delete_suffix('.')}. Restart Chatwoot web and worker processes to apply this change everywhere." }
end
def restart_required_config?(resource)
resource.name.in?(InstallationConfig::RESTART_REQUIRED_CONFIG_KEYS)
end
# See https://administrate-prototype.herokuapp.com/customizing_controller_actions
# for more information
end
@@ -1,9 +0,0 @@
class SuperAdmin::PlatformBannersController < SuperAdmin::ApplicationController
before_action :ensure_chatwoot_cloud
private
def ensure_chatwoot_cloud
raise ActionController::RoutingError, 'Not Found' unless ChatwootApp.chatwoot_cloud?
end
end

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