chore: sync fazer-ai chatwoot 4.13.0

This commit is contained in:
Rodribm10 2026-05-06 06:24:46 -03:00
commit cf7338a6e2
2934 changed files with 199268 additions and 57520 deletions

View File

@ -1,45 +1,65 @@
additional_file_patterns: []
routes: false
models: true
position_in_routes: before
position_in_class: before
position_in_test: before
position_in_fixture: before
position_in_factory: before
position_in_serializer: before
show_foreign_keys: true
show_complete_foreign_keys: false
show_indexes: true
simple_indexes: false
model_dir:
- app/models
- enterprise/app/models
root_dir: ''
include_version: false
require: ''
exclude_tests: true
exclude_fixtures: true
exclude_factories: true
exclude_serializers: true
exclude_scaffolds: true
exclude_controllers: true
exclude_helpers: true
exclude_sti_subclasses: false
ignore_model_sub_dir: false
ignore_columns: null
ignore_routes: null
ignore_unknown_models: false
hide_limit_column_types: integer,bigint,boolean
hide_default_column_types: json,jsonb,hstore
skip_on_db_migrate: false
format_bare: true
format_rdoc: false
format_markdown: false
sort: false
force: false
frozen: false
classified_sort: true
trace: false
wrapper_open: null
wrapper_close: null
with_comment: true
---
:position: before
:position_in_additional_file_patterns: before
:position_in_class: before
:position_in_factory: before
:position_in_fixture: before
:position_in_routes: before
:position_in_serializer: before
:position_in_test: before
:classified_sort: true
:exclude_controllers: true
:exclude_factories: true
:exclude_fixtures: true
:exclude_helpers: true
:exclude_scaffolds: true
:exclude_serializers: true
:exclude_sti_subclasses: false
:exclude_tests: true
:force: false
:format_markdown: false
:format_rdoc: false
:format_yard: false
:frozen: false
:grouped_polymorphic: false
:ignore_model_sub_dir: false
:ignore_unknown_models: false
:include_version: false
:show_check_constraints: false
:show_complete_foreign_keys: false
:show_foreign_keys: true
:show_indexes: true
:show_indexes_include: false
:simple_indexes: false
:sort: false
:timestamp: false
:trace: false
:with_comment: true
:with_column_comments: true
:with_table_comments: true
:position_of_column_comment: :with_name
:active_admin: false
:command:
:debug: false
:hide_default_column_types: json,jsonb,hstore
:hide_limit_column_types: integer,bigint,boolean
:timestamp_columns:
- created_at
- updated_at
:ignore_columns:
:ignore_routes:
:models: true
:routes: false
:skip_on_db_migrate: false
:target_action: :do_annotations
:wrapper:
:wrapper_close:
:wrapper_open:
:classes_default_to_s: []
:additional_file_patterns: []
:model_dir:
- app/models
- enterprise/app/models
:require: []
:root_dir:
- ''

View File

@ -1,3 +1,9 @@
---
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+)
# 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

View File

@ -93,8 +93,8 @@ jobs:
exit 1
fi
mkdir -p ~/tmp
curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/6.3.0/openapi-generator-cli-6.3.0.jar > ~/tmp/openapi-generator-cli-6.3.0.jar
java -jar ~/tmp/openapi-generator-cli-6.3.0.jar validate -i swagger/swagger.json
curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/7.19.0/openapi-generator-cli-7.19.0.jar > ~/tmp/openapi-generator-cli-7.19.0.jar
java -jar ~/tmp/openapi-generator-cli-7.19.0.jar validate -i swagger/swagger.json
# Bundle audit
- run:

View File

@ -0,0 +1,188 @@
---
name: merge-upstream
description: Use this skill when pulling chatwoot upstream (chatwoot/chatwoot) into the fazer-ai fork, resolving merge conflicts, and validating the result. Covers direction choice, per-file decision framework (KC/AI/CO/delete), recurring patterns (SaveBang, signature architecture, schema.rb regen, WhatsApp service, installation_config), validation flow, and pre-commit/CI pitfalls specific to this repo. Trigger when the user asks to merge develop/main from chatwoot upstream, resolve merge conflicts on a merge branch, or bump the fork to a new chatwoot version.
allowed-tools: Bash, Read, Edit, Write, Grep, Glob
---
# Merge upstream (chatwoot → fazer-ai fork)
The fazer-ai fork diverges from chatwoot upstream on real features (Baileys, Zapi, per-inbox signatures, scheduled messages, group conversations, internal chat). Every few releases we pull upstream in to stay current. This skill captures the recurring patterns and footguns so the next merge doesn't rediscover them from scratch.
## Direction of merge
Prefer **branch from our fork's `main`, merge `upstream/develop` into it**, not the other way around.
- Same number of conflicts either way — git is symmetric.
- What differs: the `--first-parent` chain. Merging upstream into a fork-based branch keeps our main's first-parent history "our work", with upstream as a side merge. Easier to answer "what's ours" later with `git log --first-parent`.
- If the current in-progress merge already went the other direction, finish it as-is. Standardize on next merge.
## Pre-flight
After `git merge upstream/develop` (or whatever ref), before touching anything:
```bash
# list conflicted files
git diff --name-only --diff-filter=U
# confirm direction — who is HEAD (ours) vs MERGE_HEAD (theirs)
cat .git/MERGE_HEAD
head -5 .git/MERGE_MSG
git log --oneline HEAD -3
git log --oneline MERGE_HEAD -3
```
Terminology used in this skill:
- **HEAD / current / ours** = the branch you're sitting on (the one receiving the merge).
- **MERGE_HEAD / incoming / theirs** = the branch being merged in.
If you're on a fork-based branch pulling upstream in: `HEAD` = fork, `MERGE_HEAD` = upstream.
If you're on an upstream-based branch pulling fork in (the less-preferred direction): `HEAD` = upstream, `MERGE_HEAD` = fork.
Read carefully which side is which before labeling decisions.
## Per-file decision framework
For each conflicted file, pick one of:
| Code | Meaning |
|------|---------|
| **KC** | Keep current (HEAD) — drop the incoming side |
| **AI** | Accept incoming (MERGE_HEAD) — drop the HEAD side |
| **CO** | Combination — merge both sides manually |
| **DEL** | Accept deletion — `git rm` (modify/delete conflict where one side deleted) |
Process:
1. Read the conflict markers to see what each side does.
2. `git log --oneline HEAD -5 -- <path>` and `git log --oneline MERGE_HEAD -5 -- <path>` — understand WHY each side changed it.
3. For modify/delete: `git ls-files -u <path>` shows which stages are present (1=base, 2=ours, 3=theirs).
4. For complex hunks: `git show HEAD:<path>` and `git show MERGE_HEAD:<path>` to see each full file.
5. Decide KC/AI/CO/DEL based on intent, not just diff.
## Recurring patterns in this repo
### Style/SaveBang noise
Our fork has `Rails/SaveBang: Enabled: true` in `.rubocop.yml`. Upstream doesn't enforce it as strictly. Consequence: when upstream touches any line near a persistence call, we see a conflict where our side says `save!`/`update!`/`destroy!`/`create!` and theirs says the non-bang version.
The cop flags more than just `save`. Full list it tries to add `!` to: `save`, `update`, `update_attributes`, `destroy`, `create`, `create_or_find_by`, `find_or_create_by`, `find_or_initialize_by`, `first_or_create`, `first_or_initialize`. Any of these can appear in a conflict.
- Most are **trivial** style churn from our fork's rubocop autofix, no semantic change.
- **Never blindly accept the bang rewrite (or run `rubocop -A`) without evaluating each offense individually.** The cop doesn't check the receiver's class — it matches by method name alone. Non-ActiveRecord receivers (POROs, service objects with their own `save`/`update`/`destroy` method, third-party libraries like Stripe, Kredis, OpenStruct wrappers, CSV/IO objects with `update`, filesystem objects with `destroy`) will raise `NoMethodError` at runtime. Caught by CI if there's a spec, silently broken in prod if not.
- For each SaveBang offense, read the surrounding code: what class is the receiver? If it's an ActiveRecord model, the autocorrect is safe. If it's anything else, either add the receiver to `.rubocop.yml`'s `Rails/SaveBang.AllowedReceivers` list (currently Stripe::Subscription, Stripe::Customer, Stripe::Invoice) or add a targeted `rubocop:disable Rails/SaveBang` comment.
- Safe workflow: run `bundle exec rubocop <files>` (without `-A`) first to see the offenses listed, evaluate each individually, then apply `-A` only once you've confirmed every receiver is an ActiveRecord object. Always review the diff before committing.
### Signature architecture (PR #79)
We deliberately removed upstream's editor-side signature manipulation (`addSignature`, `removeSignature`, `toggleSignatureInEditor`, signature-in-draft logic) and moved signature application to **send-time** (`getMessagePayload`). This prevents signature duplication, persistence in drafts, and position-toggle bugs.
When upstream adds or tweaks any signature-related code in:
- `app/javascript/dashboard/components/widgets/WootWriter/Editor.vue`
- `app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue`
- `app/javascript/dashboard/routes/dashboard/settings/profile/MessageSignature.vue`
→ Usually **AI (accept incoming = our fork)**, preserving the send-time architecture. Upstream's "fixes" may be rebuilding exactly what we tore out.
One exception worth porting as follow-up (NOT during merge): upstream's inline-image sanitization (`stripInlineBase64Images` + `INLINE_IMAGE_WARNING` i18n key) is orthogonal to architecture and would be a nice safety net in our send-time code.
### WhatsApp incoming message service
`app/services/whatsapp/incoming_message_base_service.rb` is the other frequent conflict zone. Our fork has two-layer locking (source_id lock + contact phone lock) plus a contact-level re-check for slow networks. Upstream evolves its simpler dedup logic.
Decision: **CO (combination)**. Keep the fork's `acquire_message_processing_lock` + `with_contact_lock` + explicit `clear_message_source_id_from_redis` in `ensure`. Layer upstream's improvements in (e.g., the `@contact.blocked? && !outgoing_echo` check) at the equivalent point inside the contact lock.
Adjacent file that may need follow-up: `app/services/whatsapp/incoming_message_service_helpers.rb` typically auto-merges to our version. That's correct. If upstream's `Whatsapp::MessageDedupLock` class becomes orphaned after a merge, `git rm` it (and its spec).
**Known regression hiding here:** `acquire_message_processing_lock` in our fork checks `@processed_params.try(:[], :messages).blank?`, which skips `:message_echoes` payloads. Echoes from WhatsApp Cloud native-app sends were being silently dropped. Fixed in the 4.13.0 merge by changing to `messages_data.blank?` and picking `:to` vs `:from` for the contact phone based on `outgoing_echo`. Keep that fix on future merges.
### db/schema.rb
Always conflicts because both sides have different migration versions. Resolution is mechanical but has traps:
1. Resolve the version-number conflict first so Ruby can parse the file (`ActiveRecord::Schema[7.1].define(version: ...)`). Pick the later timestamp.
2. Resolve every other Ruby conflict file (`installation_config.rb`, any model conflicts) so Rails can boot.
3. `bundle exec rails db:migrate` to apply pending migrations.
4. `bundle exec rails db:schema:dump` to regenerate.
**Traps to remember:**
- **Local dev DB may have tables from other branches** (kanban, features in progress). After `db:schema:dump`, diff against `git show HEAD:db/schema.rb` and `git show MERGE_HEAD:db/schema.rb` to find extras. Manually delete stray `create_table` blocks + any foreign-key references + column references in shared tables (`conversations.kanban_task_id`, etc.).
- **Custom SQL functions aren't dumpable.** `db:schema:dump` strips our `execute <<~SQL CREATE OR REPLACE FUNCTION f_unaccent(text)` block. Automated re-injection is wired via the `Rakefile` + `lib/tasks/internal_chat_search.rake` (`db:internal_chat:inject_schema_functions` runs as an `enhance` hook after `db:schema:dump`). If you see the block missing after a dump, the hook didn't run — check the Rakefile wiring and the task for a warning line like `Could not find insertion point ...`. The function itself is created by migration `20260410170003_add_unaccent_search_to_internal_chat.rb`.
- **Schema version may be stamped with a migration from another branch.** `db:schema:dump` uses `MAX(schema_migrations.version)`. If the dev DB has a kanban/other-branch migration with a higher timestamp, that version ends up in `schema.rb`. Manually set the version to the highest timestamp among migrations *present in this merge's `db/migrate/`*.
- **Quick integrity diff** (in Python — sed-free): parse HEAD's schema + MERGE_HEAD's schema + merged schema, compare column/index sets per table. Any table with columns outside HEADMERGE_HEAD is a stray from another branch.
### annotate_rb vs auto_annotate_models
Upstream migrated `.annotaterb.yml` + `lib/tasks/annotate_rb.rake` and deleted the old custom `lib/tasks/auto_annotate_models.rake`. Our fork did a similar migration earlier with different config style.
- `.annotaterb.yml`: **KC** (upstream's format is more complete, symbol-key style).
- `lib/tasks/auto_annotate_models.rake`: **DEL** (`git rm`). Replacement is `lib/tasks/annotate_rb.rake` from upstream.
### InstallationConfig serialize
Upstream simplified to `serialize :serialized_value, coder: YAML, type: ActiveSupport::HashWithIndifferentAccess, default: {}.with_indifferent_access`. Our fork had a custom `SerializedValueCoder` handling both YAML strings and native jsonb hashes.
Test before choosing: create a legacy `InstallationConfig` where `serialized_value` is a YAML string inside the jsonb column, then confirm upstream's simpler version can still load it. If it works (it did in 4.13.0 merge with all 3 legacy formats: tagged YAML, symbol-key YAML, native hash), go **KC**. Otherwise keep the custom coder.
### i18n files
`config/locales/en.yml` / `pt_BR.yml` and `app/javascript/dashboard/i18n/locale/en/settings.json` / `pt_BR/settings.json` conflict because both sides add keys. Almost always **CO**: merge both key sets under the right parent.
When upstream only adds `en.yml` keys and not `pt_BR.yml`, match upstream's scope — do not invent pt_BR translations as part of the merge. Those come in as community PRs or a separate translation pass.
### New features from both sides
Controllers (`inboxes_controller`, `conversations_controller`), policies, routes, store modules, automation_rule action whitelist, spec describe-blocks — when both sides added net-new methods/endpoints/actions, the resolution is always **CO**. Keep both additions ordered sensibly.
## Validation flow
After staging all resolved files and before commit:
```bash
# parse sanity
ruby -c app/models/installation_config.rb
ruby -c db/schema.rb
# rails boots
bundle exec rails runner 'puts "ok"'
# migrations all apply
bundle exec rails db:migrate
# specs for each changed area at minimum
bundle exec rspec spec/models spec/policies
bundle exec rspec spec/services/whatsapp
bundle exec rspec spec/controllers/api/v1/accounts/inboxes_controller_spec.rb \
spec/controllers/api/v1/accounts/conversations_controller_spec.rb \
spec/controllers/api/v1/accounts/conversations/messages_controller_spec.rb
# targeted specs we touched
bundle exec rspec spec/services/action_service_spec.rb \
spec/services/automation_rules/action_service_spec.rb
# smoke: load real installation configs / other records touched by the merge
bundle exec rails runner 'InstallationConfig.find_each { |c| c.value }'
```
## Pre-commit pitfalls
1. **Husky rubocop check only inspects files with staged diff.** Upstream files merged as-is don't appear in the diff, so their offenses slip past the hook and blow up in CI. Before commit:
```bash
bundle exec rubocop --parallel
```
Run the full thing. Fix anything that comes up (most are `Rails/SaveBang` in upstream migrations/specs — safe to `rubocop -A` after receiver check).
2. **Frontend lint error vs warning.** `pnpm-lint-staged` eslint runs with `--max-warnings=0` in some configs; a warning appears as an error in the hook. Check the actual error line in the hook output, not the warning count.
3. **Missing imports after removing conflict hunks.** When resolving AI (accept incoming) conflicts in JS/Vue files, you can accidentally delete imports you still need. Example from 4.13.0: `replaceVariablesInMessage` in `ReplyBox.vue` — the `replaceText` method came in from main but its import was above the conflict. After keeping `replaceText`, add the import.
4. **Duplicate `defineExpose` / `setup()` returns.** Same category: when combining both sides of a Vue component, watch for duplicate `defineExpose({ ... })` calls or duplicate keys in the `setup()` return object. Consolidate.
## What this skill deliberately does NOT cover
- CI flakiness from shard redistribution (pre-existing test pollution involving `perform_enqueued_jobs` in `before_all`, test-prof `let_it_be`, and rspec-mocks interaction). Track separately.
- Frontend build pipeline issues unrelated to the merge.
- Upstream feature rollouts that need product decisions (e.g., adopting a new captain model in our UI).

View File

@ -0,0 +1,170 @@
---
name: release-notes
description: Use this skill whenever you are about to cut, edit, or backfill a GitHub release for fazer-ai/chatwoot. Generates the bilingual user-notes blocks (pt-BR + en) embedded in the release body for non-technical end users. Trigger before calling `gh release create`, `gh release edit`, or any flow that touches a release body on this repo (including the `release` skill from fazer-ai-tools and any retroactive backfill of historical releases).
allowed-tools: Bash, Read, Edit, Write, Grep, Glob
---
# Release Notes (user-facing)
Every release cut from `fazer-ai/chatwoot` must embed bilingual user-notes blocks in the release body, written for non-technical end users (operators, admins, superadmins). Do not put implementation detail in these blocks.
## Required blocks (bilingual, both mandatory)
The release body must contain both an English block and a Portuguese block, in this order. Use H2 headings with country flags **outside** the blocks to separate the two sections visually on GitHub. The fazer.ai page only renders the content **inside** the `<!-- user-notes:xx:start -->` / `<!-- user-notes:xx:end -->` markers, so the H2 headings, the flags, and any commit list above are invisible there.
```markdown
## 🇺🇸 English
<!-- user-notes:en:start -->
... markdown in english ...
<!-- user-notes:en:end -->
## 🇧🇷 Português
<!-- user-notes:pt-BR:start -->
... markdown em português ...
<!-- user-notes:pt-BR:end -->
```
The two versions must be **equivalent in content**, written naturally in each language. They are **not** literal translations:
- en: "Drag conversations between columns faster."
- pt-BR: "Agora você pode arrastar conversas entre colunas mais rápido."
## Mirroring upstream releases
Downstream forks (e.g. `fazer-ai/chatwoot-pro`) that mirror a CE release must declare it with a blockquote at the top of each user-notes block, inside the markers. List all mirrored CE versions when there's more than one. CE releases never carry this marker.
```markdown
<!-- user-notes:en:start -->
> Includes changes from Chatwoot fazer.ai v4.12.0-fazer-ai.47.
...
<!-- user-notes:en:end -->
<!-- user-notes:pt-BR:start -->
> Inclui mudanças do Chatwoot fazer.ai v4.12.0-fazer-ai.47.
...
<!-- user-notes:pt-BR:end -->
```
## Audience and tone
Write for an **end user, not a developer**. Readers do not read code, do not know what a PR is, and do not care about refactors.
- **Present tense, active voice.** "Agora você pode reordenar etiquetas" / "You can now reorder labels". Not "Adicionada a possibilidade de…" / "Added the ability to…".
- **Lead with benefit, not implementation.** "Carregamento mais rápido em conexões lentas" / "Faster loading on slow connections" beats "Preload de componentes de rota no módulo internal-chat".
- **Plain language.** No jargon, no internal codenames, no function/file/library/module names.
- **No PR numbers, commit hashes, `#1234` references, or links to internal issues.**
- **Group by theme**, not by PR. Use these headers (omit empty ones, but keep the same set in both locales):
| pt-BR | en | When to use |
| ----------------- | --------------- | ---------------------------------------------------- |
| `### ✨ Novidades` | `### ✨ What's new` | New user-visible features |
| `### ⚡ Melhorias` | `### ⚡ Improvements` | Refinements to existing features (perf, UX, polish) |
| `### 🐛 Correções` | `### 🐛 Fixes` | Bugs the user might have noticed |
## Full release body example
The release body should preserve the auto-generated `## Changes` commit list at the top and append both locale sections after it:
```markdown
## Changes
- feat(internal-chat): implement internal chat system for agents (#247)
- fix(signatures): allow admins to manage inbox signatures without explicit membership (#260)
## 🇺🇸 English
<!-- user-notes:en:start -->
### ✨ What's new
- **Internal agent chat.** Your team can now message each other right inside Chatwoot, no extra tool needed.
### ⚡ Improvements
- **Faster navigation on slow connections.** Switching between conversations feels more responsive.
### 🐛 Fixes
- **Inbox signatures.** Admins can manage signatures without having to be a member of the inbox.
<!-- user-notes:en:end -->
## 🇧🇷 Português
<!-- user-notes:pt-BR:start -->
### ✨ Novidades
- **Chat interno entre agentes.** Sua equipe agora troca mensagens diretamente dentro do Chatwoot, sem precisar de outra ferramenta.
### ⚡ Melhorias
- **Navegação mais rápida em conexões lentas.** A troca entre conversas ficou mais responsiva.
### 🐛 Correções
- **Assinaturas de caixas de entrada.** Administradores conseguem gerenciar assinaturas mesmo sem participar da caixa.
<!-- user-notes:pt-BR:end -->
```
Bold the change name, then a single short sentence describing the user benefit. Keep each item to 1 or 2 lines.
If a release has nothing user-visible, write a single generic line in both locales rather than dumping a PR list:
```markdown
## 🇺🇸 English
<!-- user-notes:en:start -->
Bug fixes and internal improvements.
<!-- user-notes:en:end -->
## 🇧🇷 Português
<!-- user-notes:pt-BR:start -->
Correções de bugs e melhorias internas.
<!-- user-notes:pt-BR:end -->
```
## Quality checklist (run before publishing)
Run this checklist on **both** locale blocks:
- [ ] Both `en` and `pt-BR` blocks are present, with the exact tag spelling shown above, and the `en` block comes first.
- [ ] Both sections are wrapped by `## 🇺🇸 English` / `## 🇧🇷 Português` H2 headings outside the markers.
- [ ] Both blocks contain equivalent content (same items, same order, same themes), written naturally in each language. Not a literal translation.
- [ ] Headers use the localized header table above. Omit empty themes consistently across locales.
- [ ] Every item leads with a user benefit, not an implementation detail.
- [ ] No PR numbers, commit hashes, file paths, function names, library names, or internal module names.
- [ ] No mention of internal initiatives, customers, deals, roadmap, or anything that would not make sense to an external operator.
- [ ] Each item is understandable by someone who has never opened the codebase.
- [ ] Items are present-tense, benefit-led, 1 to 2 lines.
- [ ] Empty release: one generic line in both locales, never an empty block, never one block missing.
## Look at examples first
Before drafting, read the user-notes blocks from recent releases in this repo to match tone:
```bash
gh release list --limit 5
gh release view <tag> --json body -q .body
```
The references behind this style are **Linear**, **Stripe**, **Notion**, and **Vercel** changelogs: short, benefit-led, grouped by theme, with the user as the protagonist.
## Drafting workflow
When invoked for a release (new or backfill):
1. Read the current release body via `gh release view <tag> --json body -q .body` (or the source commits via `git log <prev-tag>..<tag> --oneline`) to understand what shipped.
2. Filter the changes through "would a non-technical operator notice or care about this?". Drop everything that fails the filter.
3. Group what survived into Novidades / Melhorias / Correções.
4. Draft the **pt-BR** block first as the source language. Write naturally, lead with benefit.
5. Draft the **en** block. Equivalent content, natural English, not a word-for-word translation.
6. Assemble the full release body: keep the `## Changes` commit list at the top, then `## 🇺🇸 English` + the `en` block, then `## 🇧🇷 Português` + the `pt-BR` block. The `en` section always comes first in the rendered release body.
7. Run the quality checklist on both blocks.
8. Show the full proposed body to the user for approval **before** editing the release.
9. Only after approval, write the body to a temp file and apply it:
- **For new releases**, pass the file via `gh release create <tag> --notes-file <file>`.
- **For backfills / edits**, this version of `gh` does not have a `release edit` subcommand. Use the API directly:
```bash
RELEASE_ID=$(gh api repos/<owner>/<repo>/releases/tags/<tag> --jq '.id')
gh api -X PATCH "repos/<owner>/<repo>/releases/$RELEASE_ID" -F body=@<file>
```

View File

@ -10,21 +10,21 @@ on:
jobs:
# Separate linting jobs for faster feedback
lint-backend:
runs-on: ubuntu-latest
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
- uses: useblacksmith/setup-ruby@v2
with:
bundler-cache: true
- name: Run Rubocop
run: bundle exec rubocop --parallel
lint-frontend:
runs-on: ubuntu-latest
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
- uses: useblacksmith/setup-node@v5
with:
node-version: 24
cache: 'pnpm'
@ -35,11 +35,11 @@ jobs:
# Frontend tests run in parallel with backend
frontend-tests:
runs-on: ubuntu-latest
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
- uses: useblacksmith/setup-node@v5
with:
node-version: 24
cache: 'pnpm'
@ -50,7 +50,7 @@ jobs:
# Backend tests with parallelization
backend-tests:
runs-on: ubuntu-latest
runs-on: blacksmith-4vcpu-ubuntu-2404
strategy:
fail-fast: false
matrix:
@ -86,11 +86,11 @@ jobs:
ref: ${{ github.event.pull_request.head.ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
- uses: ruby/setup-ruby@v1
- uses: useblacksmith/setup-ruby@v2
with:
bundler-cache: true
- uses: actions/setup-node@v4
- uses: useblacksmith/setup-node@v5
with:
node-version: 24
cache: 'pnpm'

2
.gitignore vendored
View File

@ -92,7 +92,7 @@ yarn-debug.log*
# TextEditors & AI Agents config files
.vscode
.claude/settings.local.json
.claude/**/*.local.*
.cursor
.codex/
CLAUDE.local.md

View File

@ -40,6 +40,8 @@ gem 'json_refs'
gem 'rack-attack', '>= 6.7.0'
# a utility tool for streaming, flexible and safe downloading of remote files
gem 'down'
# SSRF-safe URL fetching
gem 'ssrf_filter', '~> 1.5'
# authentication type to fetch and send mail over oauth2.0
gem 'gmail_xoauth'
# Lock net-smtp to 0.3.4 to avoid issues with gmail_xoauth2
@ -192,7 +194,7 @@ gem 'reverse_markdown'
gem 'iso-639'
gem 'ruby-openai'
gem 'ai-agents'
gem 'ai-agents', '>= 0.9.1'
# TODO: Move this gem as a dependency of ai-agents
gem 'ruby_llm', '>= 1.8.2'
@ -271,6 +273,7 @@ group :development, :test do
gem 'seed_dump'
gem 'shoulda-matchers'
gem 'simplecov', '>= 0.21', require: false
gem 'skooma'
gem 'spring'
gem 'spring-watcher-listen'
end

View File

@ -126,7 +126,7 @@ GEM
jbuilder (~> 2)
rails (>= 4.2, < 7.2)
selectize-rails (~> 0.6)
ai-agents (0.9.0)
ai-agents (0.9.1)
ruby_llm (~> 1.9.1)
annotaterb (4.20.0)
activerecord (>= 6.0.0)
@ -166,7 +166,7 @@ GEM
multi_json (~> 1)
statsd-ruby (~> 1.1)
base64 (0.3.0)
bcrypt (3.1.20)
bcrypt (3.1.22)
benchmark (0.4.1)
bigdecimal (3.2.2)
bindex (0.8.1)
@ -191,7 +191,7 @@ GEM
coderay (1.1.3)
commonmarker (0.23.10)
concurrent-ruby (1.3.5)
connection_pool (2.5.3)
connection_pool (2.5.5)
crack (1.0.0)
bigdecimal
rexml
@ -465,7 +465,7 @@ GEM
rails-dom-testing (>= 1, < 3)
railties (>= 4.2.0)
thor (>= 0.14, < 2.0)
json (2.18.1)
json (2.19.2)
json_refs (0.1.8)
hana
json_schemer (0.2.24)
@ -473,6 +473,12 @@ GEM
hana (~> 1.3)
regexp_parser (~> 2.0)
uri_template (~> 0.7)
json_skooma (0.2.5)
bigdecimal
hana (~> 1.3)
regexp_parser (~> 2.0)
uri-idna (~> 0.2)
zeitwerk (~> 2.6)
judoscale-rails (1.8.2)
judoscale-ruby (= 1.8.2)
railties
@ -583,14 +589,14 @@ GEM
newrelic_rpm (9.6.0)
base64
nio4r (2.7.3)
nokogiri (1.18.9)
nokogiri (1.19.1)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nokogiri (1.18.9-arm64-darwin)
nokogiri (1.19.1-arm64-darwin)
racc (~> 1.4)
nokogiri (1.18.9-x86_64-darwin)
nokogiri (1.19.1-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.18.9-x86_64-linux-gnu)
nokogiri (1.19.1-x86_64-linux-gnu)
racc (~> 1.4)
oauth (1.1.0)
oauth-tty (~> 1.0, >= 1.0.1)
@ -736,7 +742,7 @@ GEM
ffi (~> 1.0)
redis (5.0.6)
redis-client (>= 0.9.0)
redis-client (0.22.2)
redis-client (0.26.4)
connection_pool
redis-namespace (1.10.0)
redis (>= 4)
@ -912,6 +918,9 @@ GEM
simplecov_json_formatter (~> 0.1)
simplecov-html (0.13.2)
simplecov_json_formatter (0.1.4)
skooma (0.3.7)
json_skooma (~> 0.2.5)
zeitwerk (~> 2.6)
slack-ruby-client (2.7.0)
faraday (>= 2.0.1)
faraday-mashify
@ -935,6 +944,7 @@ GEM
activesupport (>= 5.2)
sprockets (>= 3.0.0)
squasher (0.7.2)
ssrf_filter (1.5.0)
stackprof (0.2.25)
statsd-ruby (1.5.0)
streamio-ffmpeg (3.0.2)
@ -974,6 +984,7 @@ GEM
unicode-emoji (4.0.4)
uniform_notifier (1.17.0)
uri (1.1.1)
uri-idna (0.3.1)
uri_template (0.7.0)
valid_email2 (5.2.6)
activemodel (>= 3.2)
@ -1028,7 +1039,7 @@ DEPENDENCIES
administrate (>= 0.20.1)
administrate-field-active_storage (>= 1.0.3)
administrate-field-belongs_to_search (>= 0.9.0)
ai-agents
ai-agents (>= 0.9.1)
annotaterb
attr_extras
audited (~> 5.4, >= 5.4.1)
@ -1148,10 +1159,12 @@ DEPENDENCIES
sidekiq_alive
simplecov (>= 0.21)
simplecov_json_formatter
skooma
slack-ruby-client (~> 2.7.0)
spring
spring-watcher-listen
squasher
ssrf_filter (~> 1.5)
stackprof
streamio-ffmpeg (~> 3.0)
stripe (~> 18.0)

View File

@ -1,229 +0,0 @@
# Meta Webhook Proxy
## Problem
Some VPS providers silently drop inbound TCP connections from Meta's webhook servers (AS32934) due to overzealous DDoS protection. This causes 1520% WhatsApp message loss. A reverse proxy on a clean provider (e.g., DigitalOcean) eliminates the drops completely.
```
Meta (WhatsApp) → proxy.example.com (clean provider) → your Chatwoot instance
```
## Architecture
The proxy is a single nginx server that routes requests based on the first path segment:
```
https://proxy.example.com/<upstream_host>/webhooks/whatsapp/%2B<phone>
```
This is the URL you configure in Meta's App Dashboard as the webhook callback URL. The proxy extracts `<upstream_host>`, checks it against an allowlist, and forwards the request to `https://<upstream_host>/webhooks/whatsapp/%2B<phone>`.
### Multi-tenant
One proxy serves multiple Chatwoot instances. Each upstream is identified by its domain in the URL path — no separate config per tenant beyond adding the host to the allowlist.
## Setup
### Prerequisites
- A server (Ubuntu 22.04/24.04) on a provider with clean Meta connectivity (DigitalOcean, AWS, etc.)
- A DNS A record pointing your proxy domain to the server IP (e.g., `proxy.example.com → 1.2.3.4`)
- SSH root access to the server
### 1. Install nginx and certbot
```bash
ssh root@proxy.example.com
apt-get update
apt-get install -y nginx certbot python3-certbot-nginx
```
### 2. Create a temporary HTTP-only config
Certbot needs nginx running to perform the ACME challenge, but the full config references SSL certs that don't exist yet. Start with an HTTP-only config:
```bash
cat > /etc/nginx/sites-available/cw-proxy << 'EOF'
server {
listen 80;
server_name proxy.example.com;
location /.well-known/acme-challenge/ {
root /var/www/html;
}
location / {
return 404;
}
}
EOF
```
Enable the site and reload:
```bash
rm -f /etc/nginx/sites-enabled/default
ln -sf /etc/nginx/sites-available/cw-proxy /etc/nginx/sites-enabled/cw-proxy
nginx -t && systemctl reload nginx
```
### 3. Obtain the SSL certificate
```bash
certbot certonly --webroot -w /var/www/html -d proxy.example.com \
--non-interactive --agree-tos -m your-email@example.com
```
Certbot installs a systemd timer that auto-renews the certificate before it expires.
### 4. Deploy the full proxy config
Replace the temporary config with the full proxy configuration:
```bash
cat > /etc/nginx/sites-available/cw-proxy << 'EOF'
# Allowlist of upstream Chatwoot hosts
# Add new hosts here to enable proxying
map $upstream_host $upstream_allowed {
default 0;
chatwoot.example.com 1;
# chatwoot.other.com 1;
}
server {
listen 80;
server_name proxy.example.com;
location /.well-known/acme-challenge/ {
root /var/www/html;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
server_name proxy.example.com;
resolver 1.1.1.1 8.8.8.8 valid=300s;
resolver_timeout 5s;
ssl_certificate /etc/letsencrypt/live/proxy.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/proxy.example.com/privkey.pem;
# Extract upstream host from first path segment, proxy the rest
location ~ ^/([^/]+)(/.*)$ {
set $upstream_host $1;
set $upstream_path $2;
# Reject hosts not in the allowlist
if ($upstream_allowed = 0) {
return 403;
}
proxy_pass https://$upstream_host$upstream_path$is_args$args;
proxy_set_header Host $upstream_host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
proxy_ssl_server_name on;
}
location / {
return 404;
}
}
EOF
```
Test and reload:
```bash
nginx -t && systemctl reload nginx
```
### 5. Verify
```bash
# Root path → 404
curl -s -o /dev/null -w "%{http_code}" https://proxy.example.com/
# Expected: 404
# Unknown host → 403
curl -s -o /dev/null -w "%{http_code}" https://proxy.example.com/unknown.host/webhooks/whatsapp/test
# Expected: 403
# Allowed host → proxied (502 if upstream is unreachable, 200 if live)
curl -s -o /dev/null -w "%{http_code}" https://proxy.example.com/chatwoot.example.com/webhooks/whatsapp/test
# Expected: 502 or 200
```
### 6. Configure Meta webhook URL
In the Meta App Dashboard, set the webhook callback URL to:
```
https://proxy.example.com/<your-chatwoot-domain>/webhooks/whatsapp/%2B<phone>
```
For example, if your Chatwoot is at `chatwoot.example.com` and the phone number is `+5511999999999`:
```
https://proxy.example.com/chatwoot.example.com/webhooks/whatsapp/%2B5511999999999
```
Meta will send both verification (GET) and delivery (POST) requests to this URL. The proxy passes them through transparently.
## Adding a new upstream
1. SSH into the proxy server
2. Edit `/etc/nginx/sites-available/cw-proxy`
3. Add the new host to the `map` block:
```nginx
map $upstream_host $upstream_allowed {
default 0;
chatwoot.example.com 1;
chatwoot.newclient.com 1; # ← add this line
}
```
4. Test and reload:
```bash
nginx -t && systemctl reload nginx
```
5. Set the Meta webhook callback URL for the new instance to:
```
https://proxy.example.com/chatwoot.newclient.com/webhooks/whatsapp/%2B<phone>
```
## Removing an upstream
1. Remove or comment out the host from the `map` block
2. `nginx -t && systemctl reload nginx`
3. Update the Meta webhook callback URL to point directly at the Chatwoot instance (or to a different proxy)
## Key nginx directives
| Directive | Purpose |
|-----------|---------|
| `map $upstream_host $upstream_allowed` | Allowlist of permitted upstream hosts. Only hosts set to `1` are proxied; all others get 403. |
| `proxy_ssl_server_name on` | Enables SNI so the TLS handshake uses the correct hostname for the upstream's certificate. |
| `resolver 1.1.1.1 8.8.8.8 valid=300s` | Required because `proxy_pass` uses a variable (`$upstream_host`), so nginx cannot resolve DNS at config load time. Uses Cloudflare and Google DNS. |
| `proxy_set_header Host $upstream_host` | Sets the Host header to the upstream domain so reverse proxies (Traefik, etc.) route correctly. |
## Failure modes
All recoverable — Meta retries with exponential backoff for up to 36 hours:
| Failure | What happens | Recovery |
|---------|-------------|----------|
| Proxy down | Connection refused | Meta retries |
| Upstream down | 502 Bad Gateway | Meta retries |
| SSL expired | TLS handshake error | Meta retries |
## Important notes
- **Do not rate-limit.** Meta sends webhook deliveries from many IPs in AS32934. Bursts of 10+ requests per second are normal.
- **SSL auto-renewal** is handled by the certbot systemd timer. Verify with `systemctl status certbot.timer`.
- The `%2B` in the URL is the URL-encoded `+` sign for the phone number's country code.

View File

@ -40,8 +40,12 @@ run:
fi
force_run:
rm -f ./.overmind.sock
rm -f tmp/pids/*.pid
@echo "Cleaning up Overmind processes..."
@lsof -ti:3036 2>/dev/null | xargs kill -9 2>/dev/null || true
@lsof -ti:3000 2>/dev/null | xargs kill -9 2>/dev/null || true
@rm -f ./.overmind.sock
@rm -f tmp/pids/*.pid
@echo "Cleanup complete"
overmind start -f Procfile.dev
force_run_tunnel:

View File

@ -7,3 +7,23 @@ enterprise_tasks_path = Rails.root.join('enterprise/tasks_railtie.rb').to_s
require enterprise_tasks_path if File.exist?(enterprise_tasks_path)
Rails.application.load_tasks
# Ensure the f_unaccent function used by internal chat search indexes is created
# before db:schema:load runs. This must happen after Rails.application.load_tasks
# so that both `db:schema:load` and `db:internal_chat:ensure_search_functions`
# are guaranteed to be defined.
if Rake::Task.task_defined?('db:schema:load') &&
Rake::Task.task_defined?('db:internal_chat:ensure_search_functions')
Rake::Task['db:schema:load'].enhance(['db:internal_chat:ensure_search_functions'])
end
# Re-inject the f_unaccent `execute <<~SQL ...` block into db/schema.rb after
# db:schema:dump rewrites the file. The schema dumper can't capture CREATE
# FUNCTION statements, so without this hook every dump would silently drop the
# block and break db:schema:load downstream.
if Rake::Task.task_defined?('db:schema:dump') &&
Rake::Task.task_defined?('db:internal_chat:inject_schema_functions')
Rake::Task['db:schema:dump'].enhance do
Rake::Task['db:internal_chat:inject_schema_functions'].invoke
end
end

View File

@ -1 +1 @@
4.11.0
4.13.0

View File

@ -104,7 +104,7 @@ class ContactIdentifyAction
# blank identifier or email will throw unique index error
# TODO: replace reject { |_k, v| v.blank? } with compact_blank when rails is upgraded
@contact.discard_invalid_attrs if discard_invalid_attrs
@contact.save!
@contact.save! if @contact.changed?
enqueue_avatar_job
end

View File

@ -31,19 +31,27 @@ class ContactMergeAction
end
def merge_conversations
Conversation.where(contact_id: @mergee_contact.id).update(contact_id: @base_contact.id)
Conversation.where(contact_id: @mergee_contact.id).find_each do |conversation|
conversation.update!(contact_id: @base_contact.id)
end
end
def merge_contact_notes
Note.where(contact_id: @mergee_contact.id, account_id: @mergee_contact.account_id).update(contact_id: @base_contact.id)
Note.where(contact_id: @mergee_contact.id, account_id: @mergee_contact.account_id).find_each do |note|
note.update!(contact_id: @base_contact.id)
end
end
def merge_messages
Message.where(sender: @mergee_contact).update(sender: @base_contact)
Message.where(sender: @mergee_contact).find_each do |message|
message.update!(sender: @base_contact)
end
end
def merge_contact_inboxes
ContactInbox.where(contact_id: @mergee_contact.id).update(contact_id: @base_contact.id)
ContactInbox.where(contact_id: @mergee_contact.id).find_each do |contact_inbox|
contact_inbox.update!(contact_id: @base_contact.id)
end
end
def merge_and_remove_mergee_contact

View File

@ -55,7 +55,8 @@ class ContactInboxWithContactBuilder
email: contact_attributes[:email],
identifier: contact_attributes[:identifier],
additional_attributes: contact_attributes[:additional_attributes],
custom_attributes: contact_attributes[:custom_attributes]
custom_attributes: contact_attributes[:custom_attributes],
group_type: contact_attributes[:group_type] || :individual
)
end

View File

@ -1,4 +1,6 @@
class Email::BaseBuilder
include EmailAddressParseable
pattr_initialize [:inbox!]
private
@ -47,8 +49,4 @@ class Email::BaseBuilder
# can save it in the format "Name <email@domain.com>"
parse_email(account.support_email)
end
def parse_email(email_string)
Mail::Address.new(email_string).address
end
end

View File

@ -105,15 +105,19 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
end
def message_params
content_attributes = {
in_reply_to_external_id: response.in_reply_to_external_id
}
content_attributes[:external_echo] = true if @outgoing_echo
{
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
message_type: @message_type,
status: @outgoing_echo ? :delivered : :sent,
content: response.content,
source_id: response.identifier,
content_attributes: {
in_reply_to_external_id: response.in_reply_to_external_id
},
content_attributes: content_attributes,
sender: @outgoing_echo ? nil : @contact_inbox.contact
}
end

View File

@ -2,12 +2,17 @@ class Messages::Messenger::MessageBuilder
include ::FileTypeHelper
def process_attachment(attachment)
# This check handles very rare case if there are multiple files to attach with only one usupported file
# This check handles very rare case if there are multiple files to attach with only one unsupported file
return if unsupported_file_type?(attachment['type'])
attachment_obj = @message.attachments.new(attachment_params(attachment).except(:remote_file_url))
params = attachment_params(attachment)
attachment_obj = @message.attachments.new(params.except(:remote_file_url))
attachment_obj.save!
attach_file(attachment_obj, attachment_params(attachment)[:remote_file_url]) if attachment_params(attachment)[:remote_file_url]
if facebook_reel?(attachment)
update_facebook_reel_content(attachment)
elsif params[:remote_file_url]
attach_file(attachment_obj, params[:remote_file_url])
end
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'
@ -26,7 +31,7 @@ class Messages::Messenger::MessageBuilder
end
def attachment_params(attachment)
file_type = attachment['type'].to_sym
file_type = normalize_file_type(attachment['type'])
params = { file_type: file_type, account_id: @message.account_id }
if [:image, :file, :audio, :video, :share, :story_mention, :ig_reel, :ig_post, :ig_story].include? file_type
@ -100,6 +105,28 @@ class Messages::Messenger::MessageBuilder
private
# Facebook may send attachment types that don't directly match our file_type enum.
# Map known aliases to their canonical enum values.
FACEBOOK_FILE_TYPE_MAP = { reel: :ig_reel }.freeze
def normalize_file_type(type)
sym = type.to_sym
FACEBOOK_FILE_TYPE_MAP.fetch(sym, sym)
end
# Facebook sends reel URLs as webpage links (facebook.com/reel/...) rather than
# direct video URLs. Downloading these yields HTML, not video content.
def facebook_reel?(attachment)
attachment['type'].to_sym == :reel
end
def update_facebook_reel_content(attachment)
url = attachment.dig('payload', 'url')
return if url.blank?
@message.update!(content: url) if @message.content.blank?
end
def unsupported_file_type?(attachment_type)
[:template, :unsupported_type, :ephemeral].include? attachment_type.to_sym
end

View File

@ -34,6 +34,10 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
@agent_bot.reload
end
def reset_secret
@agent_bot.reset_secret!
end
private
def agent_bot

View File

@ -40,7 +40,7 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
end
def reorder
Article.update_positions(params[:positions_hash])
Article.update_positions(portal: @portal, positions_hash: params[:positions_hash])
head :ok
end

View File

@ -57,7 +57,7 @@ class Api::V1::Accounts::Captain::TasksController < Api::V1::Accounts::BaseContr
if result.nil?
render json: { message: nil }
elsif result[:error]
render json: { error: result[:error] }, status: :unprocessable_entity
render json: { error: result[:error] }, status: :unprocessable_content
else
response_data = { message: result[:message] }
response_data[:follow_up_context] = result[:follow_up_context] if result[:follow_up_context]
@ -69,3 +69,5 @@ class Api::V1::Accounts::Captain::TasksController < Api::V1::Accounts::BaseContr
authorize(:'captain/tasks')
end
end
Api::V1::Accounts::Captain::TasksController.prepend_mod_with('Api::V1::Accounts::Captain::TasksController')

View File

@ -1,7 +1,7 @@
class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseController
before_action :portal
before_action :check_authorization
before_action :fetch_category, except: [:index, :create]
before_action :fetch_category, except: [:index, :create, :reorder]
before_action :set_current_page, only: [:index]
def index
@ -32,6 +32,11 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle
head :ok
end
def reorder
Category.update_positions(portal: @portal, positions_hash: params[:positions_hash])
head :ok
end
private
def fetch_category
@ -39,7 +44,7 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle
end
def portal
@portal ||= Current.account.portals.find_by(slug: params[:portal_id])
@portal ||= Current.account.portals.find_by!(slug: params[:portal_id])
end
def related_categories_records

View File

@ -0,0 +1,55 @@
module Api::V1::Accounts::Concerns::WhatsappHealthManagement
extend ActiveSupport::Concern
included do
skip_before_action :check_authorization, only: [:health, :register_webhook]
before_action :check_admin_authorization?, only: [:register_webhook]
before_action :validate_whatsapp_cloud_channel, only: [:health, :register_webhook]
end
def sync_templates
return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' } unless whatsapp_channel?
trigger_template_sync
render status: :ok, json: { message: 'Template sync initiated successfully' }
rescue StandardError => e
render status: :internal_server_error, json: { error: e.message }
end
def health
health_data = Whatsapp::HealthService.new(@inbox.channel).fetch_health_status
render json: health_data
rescue StandardError => e
Rails.logger.error "[INBOX HEALTH] Error fetching health data: #{e.message}"
render json: { error: e.message }, status: :unprocessable_entity
end
def register_webhook
Whatsapp::WebhookSetupService.new(@inbox.channel).register_callback
render json: { message: 'Webhook registered successfully' }, status: :ok
rescue StandardError => e
Rails.logger.error "[INBOX WEBHOOK] Webhook registration failed: #{e.message}"
render json: { error: e.message }, status: :unprocessable_entity
end
private
def validate_whatsapp_cloud_channel
return if @inbox.channel.is_a?(Channel::Whatsapp) && @inbox.channel.provider == 'whatsapp_cloud'
render json: { error: 'Health data only available for WhatsApp Cloud API channels' }, status: :bad_request
end
def whatsapp_channel?
@inbox.whatsapp? || (@inbox.twilio? && @inbox.channel.whatsapp?)
end
def trigger_template_sync
if @inbox.whatsapp?
Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel)
elsif @inbox.twilio? && @inbox.channel.whatsapp?
Channels::Twilio::TemplatesSyncJob.perform_later(@inbox.channel)
end
end
end

View File

@ -0,0 +1,56 @@
class Api::V1::Accounts::Contacts::GroupAdminController < Api::V1::Accounts::Contacts::BaseController
VALID_PROPERTIES = %w[announce restrict join_approval_mode member_add_mode].freeze
def leave
authorize @contact, :update?
channel.group_leave(@contact.identifier)
head :ok
rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e
render json: { error: e.message }, status: :unprocessable_entity
end
def update
authorize @contact, :update?
property = property_params[:property]
enabled = ActiveModel::Type::Boolean.new.cast(property_params[:enabled])
return render json: { error: 'invalid_property' }, status: :unprocessable_entity unless property.in?(VALID_PROPERTIES)
apply_property_change(property, enabled)
update_contact_attribute(property, enabled)
head :ok
rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e
render json: { error: e.message }, status: :unprocessable_entity
end
private
def apply_property_change(property, enabled)
case property
when 'announce', 'restrict'
channel.group_setting_update(@contact.identifier, property, enabled)
when 'join_approval_mode'
channel.group_join_approval_mode(@contact.identifier, enabled ? 'on' : 'off')
when 'member_add_mode'
channel.group_member_add_mode(@contact.identifier, enabled ? 'all_member_add' : 'admin_add')
end
end
def property_params
params.permit(:property, :enabled)
end
def channel
@channel ||= @contact.group_channel
end
def resolve_group_conversations
Current.account.conversations
.where(contact_id: @contact.id, group_type: :group, status: %i[open pending])
.find_each { |c| c.update!(status: :resolved) }
end
def update_contact_attribute(key, value)
new_attrs = (@contact.additional_attributes || {}).merge(key => value)
@contact.update!(additional_attributes: new_attrs)
end
end

View File

@ -0,0 +1,27 @@
class Api::V1::Accounts::Contacts::GroupInvitesController < Api::V1::Accounts::Contacts::BaseController
def show
authorize @contact, :show?
code = channel.group_invite_code(@contact.identifier)
render json: invite_response(code)
rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e
render json: { error: e.message }, status: :unprocessable_entity
end
def revoke
authorize @contact, :update?
code = channel.revoke_group_invite(@contact.identifier)
render json: invite_response(code)
rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e
render json: { error: e.message }, status: :unprocessable_entity
end
private
def channel
@channel ||= @contact.group_channel
end
def invite_response(code)
{ invite_code: code, invite_url: "https://chat.whatsapp.com/#{code}" }
end
end

View File

@ -0,0 +1,37 @@
class Api::V1::Accounts::Contacts::GroupJoinRequestsController < Api::V1::Accounts::Contacts::BaseController
def index
authorize @contact, :show?
requests = channel.group_join_requests(@contact.identifier)
render json: { payload: requests }
rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e
render json: { error: e.message }, status: :unprocessable_entity
end
def handle
authorize @contact, :update?
channel.handle_group_join_requests(@contact.identifier, handle_params[:participants], handle_params[:request_action])
remove_handled_requests(handle_params[:participants])
head :ok
rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e
render json: { error: e.message }, status: :unprocessable_entity
end
private
def handle_params
params.permit(:request_action, participants: [])
end
def channel
@channel ||= @contact.group_channel
end
def remove_handled_requests(participants)
return if participants.blank?
current_requests = @contact.additional_attributes&.dig('pending_join_requests') || []
updated_requests = current_requests.reject { |r| participants.include?(r['jid']) }
new_attrs = (@contact.additional_attributes || {}).merge('pending_join_requests' => updated_requests)
@contact.update!(additional_attributes: new_attrs)
end
end

View File

@ -0,0 +1,155 @@
class Api::V1::Accounts::Contacts::GroupMembersController < Api::V1::Accounts::Contacts::BaseController
DEFAULT_PER_PAGE = 10
before_action :ensure_group_contact, only: %i[create update destroy]
def index
authorize @contact, :show?
base_query = GroupMember.active
.where(group_contact: @contact)
.includes(:contact)
@total_count = base_query.count
@page = [(params[:page] || 1).to_i, 1].max
@per_page = (params[:per_page] || DEFAULT_PER_PAGE).to_i.clamp(1, 100)
@inbox_phone_number = inbox_phone_number
@is_inbox_admin = inbox_admin?
paginated = base_query.order(role: :desc, id: :asc)
.offset((@page - 1) * @per_page)
.limit(@per_page)
@group_members = pin_own_member_on_first_page(paginated)
end
def create
authorize @contact, :update?
participants = create_params[:participants]
return render json: { error: 'participants_required' }, status: :unprocessable_entity if participants.blank?
channel.update_group_participants(@contact.identifier, format_participants(participants), 'add')
add_group_members(participants)
head :ok
rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e
render json: { error: e.message }, status: :unprocessable_entity
end
def update
authorize @contact, :update?
role = update_params[:role]
return render json: { error: 'invalid_role' }, status: :unprocessable_entity unless %w[admin member].include?(role)
member = group_members.find(params[:member_id])
action = role == 'admin' ? 'promote' : 'demote'
channel.update_group_participants(@contact.identifier, [jid_for_member(member)], action)
member.update!(role: role)
head :ok
rescue Whatsapp::Providers::WhatsappBaileysService::GroupParticipantNotAllowedError
render json: { error: 'group_creator_not_modifiable' }, status: :unprocessable_entity
rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e
render json: { error: e.message }, status: :unprocessable_entity
end
def destroy
authorize @contact, :update?
member = group_members.find(params[:id])
channel.update_group_participants(@contact.identifier, [jid_for_member(member)], 'remove')
member.update!(is_active: false)
head :ok
rescue Whatsapp::Providers::WhatsappBaileysService::GroupParticipantNotAllowedError
render json: { error: 'group_creator_not_modifiable' }, status: :unprocessable_entity
rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e
render json: { error: e.message }, status: :unprocessable_entity
end
private
def ensure_group_contact
return if @contact.group_type_group? && @contact.identifier.present?
render json: { error: 'Contact is not a valid group' }, status: :unprocessable_entity
end
def group_members
GroupMember.where(group_contact: @contact)
end
def create_params
params.permit(participants: [])
end
def update_params
params.permit(:role)
end
def channel
@channel ||= @contact.group_channel
end
def inbox_phone_number
channel&.phone_number
end
def inbox_admin?
return false if @inbox_phone_number.blank?
find_own_member&.role == 'admin'
end
def pin_own_member_on_first_page(paginated)
return paginated unless @page == 1 && @inbox_phone_number.present?
ids = paginated.pluck(:id)
own = find_own_member
return paginated if own.blank? || ids.include?(own.id)
# Prepend own member; drop the last one so total per-page stays consistent
[own] + paginated.where.not(id: own.id).limit(@per_page - 1).to_a
end
def find_own_member
clean = @inbox_phone_number.delete('+')
GroupMember.active
.where(group_contact: @contact)
.joins(:contact)
.where('REPLACE(contacts.phone_number, \'+\', \'\') = ? OR RIGHT(REPLACE(contacts.phone_number, \'+\', \'\'), 8) = RIGHT(?, 8)',
clean, clean)
.includes(:contact)
.first
end
def format_participants(phone_numbers)
Array(phone_numbers).map { |phone| "#{phone.to_s.delete('+')}@s.whatsapp.net" }
end
def jid_for_member(member)
"#{member.contact.phone_number.to_s.delete('+')}@s.whatsapp.net"
end
def add_group_members(phone_numbers)
inbox = @contact.contact_inboxes.first&.inbox
Array(phone_numbers).each do |phone|
normalized = normalize_phone(phone)
next if normalized.blank?
contact_inbox = ::ContactInboxWithContactBuilder.new(
source_id: normalized.delete('+'),
inbox: inbox,
contact_attributes: { name: normalized, phone_number: normalized }
).perform
next if contact_inbox.blank?
member = GroupMember.find_or_initialize_by(group_contact: @contact, contact: contact_inbox.contact)
member.update!(role: :member, is_active: true) unless member.persisted? && member.is_active?
end
end
def normalize_phone(phone)
cleaned = phone.to_s.strip
return nil if cleaned.blank?
cleaned.start_with?('+') ? cleaned : "+#{cleaned}"
end
end

View File

@ -0,0 +1,39 @@
class Api::V1::Accounts::Contacts::GroupMetadataController < Api::V1::Accounts::Contacts::BaseController
def update
authorize @contact, :update?
update_subject if metadata_params[:subject].present?
update_description if metadata_params[:description].present?
update_picture if metadata_params[:avatar].present?
render json: { id: @contact.id, name: @contact.name, additional_attributes: @contact.additional_attributes }
rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e
render json: { error: e.message }, status: :unprocessable_entity
end
private
def metadata_params
params.permit(:subject, :description, :avatar)
end
def update_subject
channel.update_group_subject(@contact.identifier, metadata_params[:subject])
@contact.update!(name: metadata_params[:subject])
end
def update_description
channel.update_group_description(@contact.identifier, metadata_params[:description])
attrs = @contact.additional_attributes.merge('description' => metadata_params[:description])
@contact.update!(additional_attributes: attrs)
end
def update_picture
avatar = metadata_params[:avatar]
image_base64 = Base64.strict_encode64(avatar.read)
channel.update_group_picture(@contact.identifier, image_base64)
@contact.avatar.attach(avatar)
end
def channel
@channel ||= @contact.group_channel
end
end

View File

@ -13,7 +13,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
before_action :check_authorization
before_action :set_current_page, only: [:index, :active, :search, :filter]
before_action :fetch_contact, only: [:show, :update, :destroy, :avatar, :contactable_inboxes, :destroy_custom_attributes]
before_action :fetch_contact, only: [:show, :update, :destroy, :avatar, :contactable_inboxes, :destroy_custom_attributes, :sync_group]
before_action :set_include_contact_inboxes, only: [:index, :active, :search, :filter, :show, :update]
def index
@ -82,6 +82,15 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
@contact.save!
end
def sync_group
authorize @contact, :sync_group?
raise ActionController::BadRequest, I18n.t('contacts.sync_group.not_a_group') if @contact.group_type_individual?
raise ActionController::BadRequest, I18n.t('contacts.sync_group.no_identifier') if @contact.identifier.blank?
Contacts::SyncGroupJob.perform_later(@contact)
head :accepted
end
def create
ActiveRecord::Base.transaction do
@contact = Current.account.contacts.new(permitted_params.except(:avatar_url))
@ -201,7 +210,9 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
end
def fetch_contact
@contact = Current.account.contacts.includes(contact_inboxes: [:inbox]).find(params[:id])
contact_scope = Current.account.contacts
contact_scope = contact_scope.includes(contact_inboxes: [:inbox]) if @include_contact_inboxes
@contact = contact_scope.find(params[:id])
end
def process_avatar_from_url

View File

@ -0,0 +1,213 @@
class Api::V1::Accounts::Conversations::Messages::ReactionsController < Api::V1::Accounts::Conversations::BaseController
before_action :ensure_channel_supports_reactions
before_action :fetch_target_message
before_action :ensure_target_is_reactable
MAX_EMOJI_BYTES = 32 # an emoji with skin tone + ZWJ sequences fits in <=32 bytes
# The `messages.content_attributes` column is `json` but the model writes it
# as a double-encoded JSON string (legacy `store coder: JSON`), so the `->>`
# operator can't traverse it directly. `#>>'{}'` unwraps the outer encoding
# back to a real JSON object that we can then cast to `jsonb` and query.
CONTENT_ATTRIBUTES_JSONB = "(content_attributes#>>'{}')::jsonb".freeze
def create
# An omitted `emoji` key, or an explicit JSON `null`, would otherwise
# coerce to '' and silently wipe an active reaction. Require a String
# (explicit '' is still the intended remove signal).
return render(json: { error: 'emoji is required' }, status: :unprocessable_entity) unless params[:emoji].is_a?(String)
emoji = reaction_params[:emoji]
return render(json: { error: 'Invalid emoji' }, status: :unprocessable_entity) unless emoji_payload_valid?(emoji)
result = apply_toggle!(emoji)
return render(json: { error: 'Emoji cannot be empty without an active reaction' }, status: :unprocessable_entity) if result == :invalid
# Dispatched after the lock commits so the worker reads the post-update row
# (source_id cleared); inside the transaction it would still see the stale
# source_id and SendOnChannelService would skip the send. CREATE goes through
# Message#after_create_commit -> send_reply, which already runs post-commit,
# so we only re-dispatch for UPDATEs.
::SendReplyJob.perform_later(result) if result.is_a?(Integer)
# Cable broadcast so the chat list refreshes `last_non_activity_message`.
# Message#after_update_commit only sends MESSAGE_UPDATED (touches
# chat.messages on the frontend); without this, the conversation card
# snapshot stays pointed at the pre-toggle reaction state. Touch
# `updated_at` first so the frontend's out-of-order guard in
# UPDATE_CONVERSATION can drop stale cables when the user toggles fast.
@conversation.update_columns(updated_at: Time.current) # rubocop:disable Rails/SkipsModelValidations
@conversation.dispatch_conversation_updated_event
head :ok
end
private
# Serialize concurrent operations from the same user against the same target
# message. Without the lock, two near-simultaneous clicks would both observe
# the same state and either create duplicates or step on each other's update.
def apply_toggle!(emoji)
outcome = nil
@target_message.with_lock do
existing = current_user_reaction
if emoji.blank? && !reaction_active?(existing)
outcome = :invalid
next
end
outcome = mutate_reaction!(emoji, existing)
end
outcome
end
def mutate_reaction!(emoji, existing)
if existing.present?
update_existing_reaction!(existing, emoji)
existing.id
elsif emoji.present?
build_reaction_message!(emoji)
:created
end
end
# WhatsApp allows one reaction per (message, user). We mirror that in storage:
# a single Message row holds the user's current reaction. Replacing the emoji
# updates the row in-place, removing it sets content='' + deleted=true, and a
# subsequent re-add resurrects the same row. This keeps the conversation
# history clean instead of accumulating one Message per toggle.
def update_existing_reaction!(existing, emoji)
is_removing = reaction_active?(existing) && (emoji.blank? || existing.content == emoji)
new_attrs = existing.content_attributes.dup
if is_removing
new_content = ''
new_attrs['deleted'] = true
else
new_content = emoji
new_attrs.delete('deleted')
end
# Reset source_id so SendOnChannelService doesn't treat this as a message
# echoed back from the provider and skip the resend. The provider assigns a
# fresh source_id on success via send_session_message.
existing.update!(content: new_content, content_attributes: new_attrs, source_id: nil)
end
def reaction_active?(message)
return false if message.nil?
message.content.present? && !message.content_attributes['deleted']
end
# An emoji payload is either empty (removal) or a single grapheme cluster
# that actually renders as an emoji. `\p{Emoji}` alone is too broad (it
# matches keycap bases like `1`, `#`, `*`), while `\p{Extended_Pictographic}`
# alone is too narrow — it only hits single codepoints, so flag sequences
# (🇧🇷 = 2 regional indicators) and keycaps (1⃣ = digit + VS16 + U+20E3)
# would be rejected. Accept a grapheme cluster that contains at least one
# pictographic codepoint, a regional indicator, or the combining keycap.
EMOJI_PROPERTY_RE = /[\p{Extended_Pictographic}\p{Regional_Indicator}\u{20E3}]/
def emoji_payload_valid?(emoji)
return true if emoji.empty?
return false if emoji.bytesize > MAX_EMOJI_BYTES
return false if emoji.each_grapheme_cluster.to_a.length != 1
emoji.match?(EMOJI_PROPERTY_RE)
end
def ensure_channel_supports_reactions
channel = @conversation.inbox.channel
return if channel.respond_to?(:supports_reactions?) && channel.supports_reactions?
render json: { error: 'Reactions are not supported on this channel' }, status: :unprocessable_entity
end
def fetch_target_message
@target_message = @conversation.messages.find(params[:message_id])
end
def ensure_target_is_reactable
error = target_unreactable_error
return if error.nil?
render(json: { error: error }, status: :unprocessable_entity)
end
# Mirrors the client-side guard in
# app/javascript/dashboard/components-next/message/Message.vue#canShowReactionToolbar
# so a crafted POST cannot persist a reaction (and enqueue a provider send)
# against a target the UI would never let the user pick.
def target_unreactable_error
return 'Cannot react to private messages' if @target_message.private?
return 'Cannot react to a reaction' if @target_message.reaction?
return 'Cannot react to deleted messages' if @target_message.content_attributes['deleted']
return 'Cannot react to activity messages' if @target_message.activity?
return 'Cannot react to template messages' if @target_message.template?
return 'Cannot react to failed messages' if @target_message.failed?
return 'Cannot react to unsupported messages' if @target_message.content_attributes['is_unsupported']
return 'Target message is not deliverable to WhatsApp' if @target_message.source_id.blank?
nil
end
# Returns the most recent reaction Message we should mutate for the current
# user. Two sources qualify:
# - Reactions the agent created via Chatwoot UI (sender = Current.user).
# - Multi-device echoes: the agent reacted from the WhatsApp mobile app on the
# same number as the inbox, so the message comes back outgoing without an
# agent. Without this fallback, a click on such a chip would create a brand
# new Chatwoot-side reaction and the original would never be removed from
# WhatsApp.
def current_user_reaction
# Match by both the internal in_reply_to (set by Chatwoot-originated
# reactions via MessageBuilder) and the in_reply_to_external_id (set by
# WhatsApp incoming/echoed reactions via IncomingMessageBaseService). A
# multi-device echo persists with only the external id, so without this OR
# the next toggle would miss the echoed row and stack a duplicate self
# reaction.
matches = @conversation.messages
.where("#{CONTENT_ATTRIBUTES_JSONB}->>'is_reaction' = 'true'")
.where(
"(#{CONTENT_ATTRIBUTES_JSONB}->>'in_reply_to')::bigint = :message_id OR " \
"#{CONTENT_ATTRIBUTES_JSONB}->>'in_reply_to_external_id' = :source_id",
message_id: @target_message.id,
source_id: @target_message.source_id
)
.where(
'(sender_type = ? AND sender_id = ?) OR ' \
'(message_type = ? AND sender_type IS NULL AND sender_id IS NULL)',
'User', Current.user.id, Message.message_types[:outgoing]
)
# Prefer the newest active row so a stale deleted echo can't hijack the
# toggle target and either resurrect a removed reaction or leave the
# active one untouched (creating a duplicate active state for the user).
active = matches.where.not(content: '')
.where("COALESCE(#{CONTENT_ATTRIBUTES_JSONB}->>'deleted', 'false') != 'true'")
.reorder(created_at: :desc)
.first
active || matches.reorder(created_at: :desc).first
end
def build_reaction_message!(emoji)
Messages::MessageBuilder.new(
Current.user,
@conversation,
ActionController::Parameters.new(
message_type: 'outgoing',
content: emoji,
echo_id: reaction_params[:echo_id],
content_attributes: {
is_reaction: true,
in_reply_to: @target_message.id
}
)
).perform
end
def reaction_params
params.permit(:emoji, :echo_id)
end
end

View File

@ -0,0 +1,176 @@
class Api::V1::Accounts::Conversations::RecurringScheduledMessagesController < Api::V1::Accounts::Conversations::BaseController
include Events::Types
before_action :set_recurring_scheduled_message, only: [:update, :destroy]
MAX_LIMIT = 50
def index
authorize build_recurring_scheduled_message
@recurring_scheduled_messages = @conversation.recurring_scheduled_messages
.includes(:scheduled_messages, :author)
.order(Arel.sql('CASE status WHEN 1 THEN 0 WHEN 0 THEN 1 ELSE 2 END, created_at DESC'))
.limit(MAX_LIMIT)
end
def create
@recurring_scheduled_message = build_recurring_scheduled_message
authorize @recurring_scheduled_message
@recurring_scheduled_message.assign_attributes(recurring_scheduled_message_params)
ActiveRecord::Base.transaction do
@recurring_scheduled_message.save!
create_first_occurrence if @recurring_scheduled_message.active?
end
dispatch_event(RECURRING_SCHEDULED_MESSAGE_CREATED)
end
def update
@recurring_scheduled_message.assign_attributes(recurring_scheduled_message_params)
ActiveRecord::Base.transaction do
@recurring_scheduled_message.save!
@recurring_scheduled_message.attachment.purge if params[:remove_attachment].present? && @recurring_scheduled_message.attachment.attached?
if @recurring_scheduled_message.active?
reschedule_pending_occurrence
else
@recurring_scheduled_message.scheduled_messages.pending.destroy_all
end
end
dispatch_event(RECURRING_SCHEDULED_MESSAGE_UPDATED)
end
def destroy
cancel_recurring_message
dispatch_event(RECURRING_SCHEDULED_MESSAGE_UPDATED)
end
private
def set_recurring_scheduled_message
@recurring_scheduled_message = @conversation.recurring_scheduled_messages.find(params[:id])
authorize @recurring_scheduled_message
end
def build_recurring_scheduled_message
@conversation.recurring_scheduled_messages.new(account: Current.account, inbox: @conversation.inbox, author: Current.user)
end
def recurring_scheduled_message_params
permitted = params.permit(
:content,
:status,
:attachment,
template_params: {},
recurrence_rule: [:frequency, :interval, :end_type, :end_date, :end_count,
:monthly_type, :monthly_week, :monthly_weekday, :month_day,
:year_day, :year_month, { week_days: [] }]
)
permitted[:recurrence_rule] = cast_recurrence_rule(permitted[:recurrence_rule].to_h) if permitted[:recurrence_rule].present?
permitted
end
def cast_recurrence_rule(rule)
integer_keys = %w[interval end_count monthly_week monthly_weekday month_day year_day year_month]
rule.each_with_object({}) do |(key, value), hash|
hash[key] = if key == 'week_days' && value.is_a?(Array)
value.map(&:to_i)
elsif integer_keys.include?(key)
value.to_i
else
value
end
end
end
def create_first_occurrence
scheduled_at = params[:scheduled_at]
return if scheduled_at.blank?
sm = @recurring_scheduled_message.scheduled_messages.create!(
content: @recurring_scheduled_message.content,
template_params: @recurring_scheduled_message.template_params,
scheduled_at: scheduled_at,
status: :pending,
account: @recurring_scheduled_message.account,
conversation: @recurring_scheduled_message.conversation,
inbox: @recurring_scheduled_message.inbox,
author: @recurring_scheduled_message.author
)
copy_attachment(sm) if @recurring_scheduled_message.attachment.attached?
end
def reschedule_pending_occurrence
@recurring_scheduled_message.scheduled_messages.pending.destroy_all
next_scheduled_at = compute_next_valid_date
return if next_scheduled_at.blank?
sm = @recurring_scheduled_message.scheduled_messages.create!(
content: @recurring_scheduled_message.content,
template_params: @recurring_scheduled_message.template_params,
scheduled_at: next_scheduled_at,
status: :pending,
account: @recurring_scheduled_message.account,
conversation: @recurring_scheduled_message.conversation,
inbox: @recurring_scheduled_message.inbox,
author: @recurring_scheduled_message.author
)
copy_attachment(sm) if @recurring_scheduled_message.attachment.attached?
end
def compute_next_valid_date
user_date = params[:scheduled_at].present? ? Time.zone.parse(params[:scheduled_at].to_s) : nil
rule = @recurring_scheduled_message.recurrence_rule
return user_date if user_date.present? && date_matches_rule?(user_date, rule)
base = [user_date, Time.current].compact.max
RecurringScheduledMessages::RecurrenceCalculatorService
.new(recurrence_rule: rule, last_date: base)
.next_date
end
def date_matches_rule?(date, rule)
return true unless rule.is_a?(Hash)
rule = rule.with_indifferent_access
return true unless rule[:frequency] == 'weekly' && rule[:week_days].present?
rule[:week_days].map(&:to_i).include?(date.wday)
end
def cancel_recurring_message
@recurring_scheduled_message.scheduled_messages.pending.destroy_all
@recurring_scheduled_message.update!(status: :cancelled)
I18n.with_locale(@recurring_scheduled_message.account.locale) do
@recurring_scheduled_message.conversation.messages.create!(
account: @recurring_scheduled_message.account,
inbox: @recurring_scheduled_message.inbox,
message_type: :activity,
content: I18n.t(
'conversations.activity.recurring_message_cancelled',
agent: @recurring_scheduled_message.author&.name || I18n.t('conversations.activity.unknown_agent')
)
)
end
end
def copy_attachment(scheduled_message)
scheduled_message.attachment.attach(@recurring_scheduled_message.attachment.blob)
end
def dispatch_event(event_name)
Rails.configuration.dispatcher.dispatch(event_name, Time.zone.now, recurring_scheduled_message: @recurring_scheduled_message)
end
end
Api::V1::Accounts::Conversations::RecurringScheduledMessagesController.prepend_mod_with(
'Api::V1::Accounts::Conversations::RecurringScheduledMessagesController'
)

View File

@ -8,6 +8,7 @@ class Api::V1::Accounts::Conversations::ScheduledMessagesController < Api::V1::A
def index
authorize build_scheduled_message
@scheduled_messages = @conversation.scheduled_messages
.includes(:recurring_scheduled_message)
.order(scheduled_at: :desc)
.limit(MAX_LIMIT)
end
@ -22,6 +23,7 @@ class Api::V1::Accounts::Conversations::ScheduledMessagesController < Api::V1::A
def update
@scheduled_message.assign_attributes(scheduled_message_params)
@scheduled_message.attachment.purge if params[:remove_attachment].present? && @scheduled_message.attachment.attached?
@scheduled_message.save!
dispatch_event(SCHEDULED_MESSAGE_UPDATED, scheduled_message: @scheduled_message)
end

View File

@ -3,7 +3,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
include DateRangeHelper
include HmacConcern
before_action :conversation, except: [:index, :meta, :search, :create, :filter]
before_action :conversation, except: [:index, :meta, :search, :create, :filter, :presence_subscribe_bulk]
before_action :inbox, :contact, :contact_inbox, only: [:create]
ATTACHMENT_RESULTS_PER_PAGE = 100
@ -15,7 +15,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end
def meta
result = conversation_finder.perform
result = conversation_finder.perform_meta_only
@conversations_count = result[:count]
end
@ -34,6 +34,11 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
.per(ATTACHMENT_RESULTS_PER_PAGE)
end
def presence_subscribe_bulk
Conversations::PresenceSubscribeService.new(Current.account, presence_subscribe_params[:conversation_ids]).perform
head :ok
end
def show; end
def create
@ -107,20 +112,26 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end
def toggle_typing_status
typing_status_manager = ::Conversations::TypingStatusManager.new(@conversation, current_user, params)
typing_status_manager = ::Conversations::TypingStatusManager.new(@conversation, Current.user, params)
typing_status_manager.toggle_typing_status
head :ok
end
def presence_subscribe
Conversations::PresenceSubscribeService.new(Current.account, [@conversation.display_id]).perform
head :ok
end
def update_last_seen
# High-traffic accounts generate excessive DB writes when agents frequently switch between conversations.
# Throttle last_seen updates to once per hour when there are no unread messages to reduce DB load.
# Always update immediately if there are unread messages to maintain accurate read/unread state.
return update_last_seen_on_conversation(DateTime.now.utc, true) if assignee? && @conversation.assignee_unread_messages.any?
return update_last_seen_on_conversation(DateTime.now.utc, false) if !assignee? && @conversation.unread_messages.any?
# Visiting a conversation should clear any unread inbox notifications for this conversation.
Notification::MarkConversationReadService.new(user: Current.user, account: Current.account, conversation: @conversation).perform
has_unread = assignee? ? @conversation.assignee_unread_messages.any? : @conversation.unread_messages.any?
# No unread messages - apply throttling to limit DB writes
return unless should_update_last_seen?
return if !has_unread && !should_update_last_seen?
dispatch_messages_read_event if assignee?
@ -157,6 +168,10 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
params.permit(:page)
end
def presence_subscribe_params
params.permit(conversation_ids: [])
end
def update_last_seen_on_conversation(last_seen_at, update_assignee)
updates = { agent_last_seen_at: last_seen_at }
updates[:assignee_last_seen_at] = last_seen_at if update_assignee.present?
@ -166,7 +181,15 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
# rubocop:enable Rails/SkipsModelValidations
end
def unseen_activity?
@conversation.last_activity_at.present? &&
(@conversation.agent_last_seen_at.blank? || @conversation.last_activity_at > @conversation.agent_last_seen_at)
end
def should_update_last_seen?
# Always update when there's unseen activity (e.g. soft-disabled group conversations that don't create messages)
return true if unseen_activity?
# Update if at least one relevant timestamp is older than 1 hour or not set
# This prevents redundant DB writes when agents repeatedly view the same conversation
agent_needs_update = @conversation.agent_last_seen_at.blank? || @conversation.agent_last_seen_at < 1.hour.ago

View File

@ -0,0 +1,26 @@
class Api::V1::Accounts::GroupsController < Api::V1::Accounts::BaseController
def create
inbox = Current.account.inboxes.find_by(id: group_params[:inbox_id])
return render json: { error: 'Access Denied' }, status: :forbidden unless inbox_accessible?(inbox)
result = Groups::CreateService.new(
inbox: inbox,
subject: group_params[:subject],
participants: Array(group_params[:participants])
).perform
render json: result
rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e
render json: { error: e.message }, status: :unprocessable_entity
end
private
def group_params
params.permit(:inbox_id, :subject, participants: [])
end
def inbox_accessible?(inbox)
inbox.present? && Current.user.assigned_inboxes.exists?(id: inbox.id) && inbox.channel.try(:allow_group_creation?)
end
end

View File

@ -1,6 +1,7 @@
class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseController
before_action :fetch_inbox
before_action :validate_whatsapp_channel
before_action :validate_captain_enabled, only: [:analyze]
def show
service = CsatTemplateManagementService.new(@inbox)
@ -24,6 +25,23 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC
render json: { error: 'Template parameters are required' }, status: :unprocessable_entity
end
def analyze
template_params = extract_template_params
return render_missing_message_error if template_params[:message].blank?
result = CsatTemplateUtilityAnalysisService.new(
account: Current.account,
inbox: @inbox,
message: template_params[:message],
button_text: template_params[:button_text],
language: template_params[:language]
).perform
render json: result
rescue ActionController::ParameterMissing
render json: { error: 'Template parameters are required' }, status: :unprocessable_entity
end
def link
link_params = params.require(:template).permit(:name, :language, body_variables: {})
return render json: { error: 'Template name is required' }, status: :unprocessable_entity if link_params[:name].blank?
@ -66,6 +84,12 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC
render json: { error: 'Message is required' }, status: :unprocessable_entity
end
def validate_captain_enabled
return if Current.account.feature_enabled?('captain_integration')
render json: { error: 'Captain is required for template analysis' }, status: :forbidden
end
def render_link_result(result)
if result[:success]
render json: {

View File

@ -126,7 +126,7 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas
return unless @hook&.access_token
begin
linear_client = Linear.new(@hook.access_token)
linear_client = Linear.new(@hook.access_token, refresh_token: @hook.settings&.[]('refresh_token'))
linear_client.revoke_token
rescue StandardError => e
Rails.logger.error "Failed to revoke Linear token: #{e.message}"

View File

@ -0,0 +1,19 @@
class Api::V1::Accounts::InternalChat::BaseController < Api::V1::Accounts::BaseController
private
def current_channel
@current_channel ||= Current.account.internal_chat_channels.find(params[:channel_id] || params[:id])
end
def current_membership
@current_membership ||= current_channel.channel_members.find_by(user_id: Current.user.id)
end
def channel_member?
current_channel.channel_type_public_channel? || current_membership.present?
end
def render_pro_required(feature)
render json: { error: 'pro_feature_required', feature: feature }, status: :payment_required
end
end

View File

@ -0,0 +1,49 @@
class Api::V1::Accounts::InternalChat::CategoriesController < Api::V1::Accounts::InternalChat::BaseController
before_action :fetch_category, only: [:update, :destroy]
def index
authorize InternalChat::Category, :index?
@categories = Current.account.internal_chat_categories.ordered.includes(:channels)
render json: @categories.map { |category| category_response(category) }
end
def create
authorize InternalChat::Category, :create?
@category = Current.account.internal_chat_categories.create!(category_params)
render json: category_response(@category), status: :created
end
def update
authorize @category, :update?
@category.update!(category_params)
render json: category_response(@category)
end
def destroy
authorize @category, :destroy?
@category.destroy!
head :ok
end
private
def fetch_category
@category = Current.account.internal_chat_categories.find(params[:id])
end
def category_params
params.require(:category).permit(:name, :position)
end
def category_response(category)
{
id: category.id,
name: category.name,
position: category.position,
account_id: category.account_id,
channels_count: category.channels.size,
created_at: category.created_at,
updated_at: category.updated_at
}
end
end

View File

@ -0,0 +1,107 @@
class Api::V1::Accounts::InternalChat::ChannelMembersController < Api::V1::Accounts::InternalChat::BaseController
include Events::Types
before_action :current_channel
before_action :fetch_member, only: [:update, :destroy]
def index
authorize current_channel, :show?, policy_class: InternalChat::ChannelPolicy
@members = current_channel.channel_members.includes(user: :account_users)
render json: @members.map { |member| member_response(member) }
end
def create
authorize current_channel, :update?, policy_class: InternalChat::ChannelPolicy
members = create_channel_members(validated_user_ids, requested_role)
dispatch_member_update
render json: members.map { |member| member_response(member) }, status: :created
end
def update
authorize_member_update!
@member.update!(member_update_params)
render json: member_response(@member)
end
def destroy
authorize_member_destroy!
removed_user = @member.user
@member.destroy!
dispatch_member_update(removed_user: removed_user)
head :ok
end
private
def validated_user_ids
user_ids = Array(params[:user_ids] || [params[:user_id]]).compact.map(&:to_i)
valid_user_ids = Current.account.users.where(id: user_ids).pluck(:id)
raise ActionController::BadRequest, 'No valid user IDs provided' if valid_user_ids.empty?
valid_user_ids
end
def create_channel_members(user_ids, role)
ActiveRecord::Base.transaction do
user_ids.map do |user_id|
current_channel.channel_members.find_or_create_by!(user_id: user_id) do |m|
m.role = role
end
end
end
end
# Only account administrators can promote a new member to channel admin via params.
# Channel admins (without account-admin) always create plain members.
def requested_role
return :member unless Current.account_user&.administrator?
return :member if params[:role].blank?
InternalChat::ChannelMember.roles.key?(params[:role].to_s) ? params[:role] : :member
end
def fetch_member
@member = current_channel.channel_members.find(params[:id])
end
def authorize_member_update!
raise Pundit::NotAuthorizedError unless @member.user_id == Current.user.id || Current.account_user&.administrator?
end
def authorize_member_destroy!
raise Pundit::NotAuthorizedError unless @member.user_id == Current.user.id || Current.account_user&.administrator?
end
def dispatch_member_update(removed_user: nil)
# Capture tokens before the broadcast so the removed user also receives the event
tokens = current_channel.members.pluck(:pubsub_token)
tokens << removed_user.pubsub_token if removed_user.present?
Rails.configuration.dispatcher.dispatch(
INTERNAL_CHAT_CHANNEL_UPDATED,
Time.zone.now,
channel: current_channel,
member_tokens: tokens.uniq
)
end
def member_update_params
params.permit(:muted, :favorited, :hidden)
end
def member_response(member)
{
id: member.id,
user_id: member.user_id,
role: member.role,
muted: member.muted,
favorited: member.favorited,
last_read_at: member.last_read_at,
name: member.user.name,
avatar_url: member.user.avatar_url,
availability_status: member.user.availability_status,
created_at: member.created_at,
updated_at: member.updated_at
}
end
end

View File

@ -0,0 +1,495 @@
class Api::V1::Accounts::InternalChat::ChannelsController < Api::V1::Accounts::InternalChat::BaseController # rubocop:disable Metrics/ClassLength
include Events::Types
before_action :current_channel, only: [:show, :update, :destroy, :archive, :unarchive, :toggle_typing_status, :mark_read, :mark_unread]
RECENT_MESSAGES_LIMIT = 20
# Arbitrary 32-bit namespace for the private-channel limit advisory lock; paired with account id.
PRIVATE_CHANNEL_LOCK_KEY = 0x49434C4D # 'ICLM'
def index
authorize InternalChat::Channel, :index?
@channels = filtered_channels
@unread_counts = compute_unread_counts(@channels)
@mention_channel_ids = compute_mention_channel_ids(@channels)
render json: @channels.map { |channel| channel_index_response(channel) }
end
def show
authorize @current_channel, :show?
render json: channel_show_response(@current_channel)
end
def create
@channel = build_channel
authorize @channel, :create?
created = @channel.new_record?
if dm_params? && created
create_dm_with_lock
else
with_private_channel_limit_lock(@channel) do
return if enforce_private_channel_limit(@channel)
ActiveRecord::Base.transaction do
@channel.save!
add_creator_as_admin
add_initial_members
add_channel_type_members
end
end
end
dispatch_channel_event(@channel) if created
render json: channel_show_response(@channel), status: :created
end
def update
authorize @current_channel, :update?
attrs = update_channel_params
validate_category!(attrs[:category_id])
@current_channel.update!(attrs)
dispatch_channel_event(@current_channel)
render json: channel_show_response(@current_channel)
end
def destroy
authorize @current_channel, :destroy?
# Capture member tokens before destroying so the listener can broadcast to them
cached_tokens = channel_member_tokens(@current_channel)
@current_channel.destroy!
Rails.configuration.dispatcher.dispatch(INTERNAL_CHAT_CHANNEL_UPDATED, Time.zone.now, channel: @current_channel,
member_tokens: cached_tokens)
head :ok
end
def archive
authorize @current_channel, :archive?
head(:unprocessable_entity) and return if @current_channel.channel_type_dm?
@current_channel.archived!
dispatch_channel_event(@current_channel)
render json: channel_show_response(@current_channel)
end
def unarchive
authorize @current_channel, :unarchive?
with_private_channel_limit_lock(@current_channel) do
return if enforce_private_channel_limit(@current_channel)
@current_channel.active!
end
dispatch_channel_event(@current_channel)
render json: channel_show_response(@current_channel)
end
def toggle_typing_status
authorize @current_channel, :toggle_typing_status?
InternalChat::TypingStatusManager.new(
channel: @current_channel, user: Current.user, params: { typing_status: typing_status_param }
).perform
head :ok
end
def mark_read
authorize @current_channel, :mark_read?
membership = @current_channel.channel_members.find_by(user_id: Current.user.id)
membership&.update!(last_read_at: Time.current)
head :ok
end
def mark_unread
authorize @current_channel, :mark_unread?
msg_id = mark_unread_params[:message_id]
return head(:ok) if msg_id.blank?
membership = @current_channel.channel_members.find_by!(user_id: Current.user.id)
message = @current_channel.messages.find(msg_id)
membership.update!(last_read_at: message.created_at - 1.second)
head :ok
end
private
def enforce_private_channel_limit(channel)
return unless channel.channel_type_private_channel?
max = InternalChat::Limits.max_private_channels
return if max.blank?
count = Current.account.internal_chat_channels.where(channel_type: :private_channel).active.count
render_pro_required('private_channels') if count >= max
end
# Postgres advisory transaction lock keyed by account so concurrent create/unarchive
# cannot bypass the private-channel limit by racing between count and save.
def with_private_channel_limit_lock(channel)
return yield unless channel.channel_type_private_channel? && InternalChat::Limits.max_private_channels.present?
ActiveRecord::Base.transaction do
ActiveRecord::Base.connection.execute(
ActiveRecord::Base.sanitize_sql_array(['SELECT pg_advisory_xact_lock(?, ?)', PRIVATE_CHANNEL_LOCK_KEY, Current.account.id])
)
yield
end
end
def filtered_channels
channels = Current.account.internal_chat_channels.includes(channel_members: { user: :account_users }, category: [])
channels = apply_type_filter(channels)
channels = apply_category_filter(channels)
channels = apply_status_filter(channels)
channels = apply_visibility_filter(channels)
channels.order(last_activity_at: :desc)
end
def apply_type_filter(channels)
case params[:type]
when 'text_channels'
channels.text_channels
when 'direct_messages'
channels.direct_messages
else
channels
end
end
def apply_category_filter(channels)
return channels if params[:category_id].blank?
channels.where(category_id: params[:category_id])
end
def apply_status_filter(channels)
case params[:status]
when 'archived'
channels.archived
else
channels.active
end
end
def apply_visibility_filter(channels)
user_channels = channels.where(id: Current.user.internal_chat_channels.select(:id))
return channels.where(channel_type: %i[public_channel private_channel]).or(user_channels) if Current.account_user&.administrator?
channels.where(channel_type: :public_channel).or(user_channels)
end
def build_channel
if dm_params?
find_or_build_dm
else
attrs = create_channel_params.except(:member_ids, :team_ids)
validate_category!(attrs[:category_id])
Current.account.internal_chat_channels.build(attrs.merge(created_by: Current.user))
end
end
def dm_params?
params[:channel_type] == 'dm' || params.dig(:channel, :channel_type) == 'dm'
end
def find_or_build_dm
user_ids = dm_member_ids
existing_dm = find_existing_dm(user_ids)
return existing_dm if existing_dm.present?
Current.account.internal_chat_channels.build(
channel_type: :dm,
name: nil,
created_by: Current.user
)
end
def find_existing_dm(user_ids)
sorted_ids = user_ids.sort
member_count = sorted_ids.size
Current.account.internal_chat_channels
.where(channel_type: :dm)
.joins(:channel_members)
.group('internal_chat_channels.id')
.having('COUNT(internal_chat_channel_members.id) = ?', member_count)
.having(
'ARRAY_AGG(internal_chat_channel_members.user_id ORDER BY internal_chat_channel_members.user_id) = ARRAY[?]::bigint[]',
sorted_ids
)
.first
end
def dm_member_ids
ids = Array(permitted_member_ids).map(&:to_i)
ids = Current.account.users.where(id: ids).pluck(:id)
ids << Current.user.id unless ids.include?(Current.user.id)
ids
end
def add_creator_as_admin
return if @channel.channel_type_dm?
return if @channel.channel_members.exists?(user_id: Current.user.id)
@channel.channel_members.create!(user_id: Current.user.id, role: :admin)
end
def add_initial_members
member_ids = Array(permitted_member_ids).map(&:to_i)
member_ids = Current.account.users.where(id: member_ids).pluck(:id)
member_ids << Current.user.id if @channel.channel_type_dm? && member_ids.exclude?(Current.user.id)
member_ids.uniq.each do |user_id|
next if @channel.channel_members.exists?(user_id: user_id)
@channel.channel_members.create!(user_id: user_id, role: :member)
end
end
def add_channel_type_members
return if @channel.channel_type_dm?
if @channel.channel_type_public_channel?
add_all_agents_as_members
else
add_team_members
end
end
def add_all_agents_as_members
agent_ids = Current.account.agents.where.not(id: Current.user.id).pluck(:id)
agent_ids.each do |uid|
@channel.channel_members.find_or_create_by!(user_id: uid) { |m| m.role = :member }
end
end
def add_team_members
team_ids = permitted_team_ids
return if team_ids.blank?
team_ids.each do |team_id|
team = Current.account.teams.find_by(id: team_id)
next unless team
@channel.channel_teams.find_or_create_by!(team: team)
team.members.each do |user|
@channel.channel_members.find_or_create_by!(user_id: user.id) { |m| m.role = :member }
end
end
end
def create_channel_params
@create_channel_params ||= params.require(:channel).permit(:name, :description, :channel_type, :category_id, member_ids: [], team_ids: [])
end
def update_channel_params
params.require(:channel).permit(:name, :description, :category_id)
end
def permitted_member_ids
params.permit(member_ids: [])[:member_ids] || create_channel_params[:member_ids]
end
def permitted_team_ids
ids = params.permit(team_ids: [])[:team_ids] || create_channel_params[:team_ids]
Array(ids).map(&:to_i).compact_blank
end
def mark_unread_params
params.permit(:message_id)
end
def typing_status_param
params.permit(:typing_status)[:typing_status]
end
def create_dm_with_lock
lock_key = "internal_chat_dm_#{Current.account.id}_#{dm_member_ids.sort.join('_')}"
ActiveRecord::Base.transaction do
ActiveRecord::Base.connection.execute(
ActiveRecord::Base.sanitize_sql_array(['SELECT pg_advisory_xact_lock(?)', Zlib.crc32(lock_key)])
)
existing = find_existing_dm(dm_member_ids)
if existing
@channel = existing
else
@channel.save!
add_initial_members
end
end
end
def compute_mention_channel_ids(channels)
user_id = Current.user.id
InternalChat::ChannelMember
.joins(
'INNER JOIN internal_chat_messages ' \
'ON internal_chat_messages.internal_chat_channel_id = internal_chat_channel_members.internal_chat_channel_id ' \
'AND internal_chat_messages.created_at > internal_chat_channel_members.last_read_at'
)
.where(internal_chat_channel_id: channels.select(:id), user_id: user_id)
.where.not(last_read_at: nil)
.where.not('internal_chat_messages.sender_id' => user_id)
.where("internal_chat_messages.content_attributes->'mentioned_user_ids' @> ?", [user_id].to_json)
.pluck(Arel.sql('DISTINCT internal_chat_channel_members.internal_chat_channel_id'))
end
def compute_unread_counts(channels)
InternalChat::ChannelMember
.joins(
'INNER JOIN internal_chat_messages ' \
'ON internal_chat_messages.internal_chat_channel_id = internal_chat_channel_members.internal_chat_channel_id ' \
'AND internal_chat_messages.created_at > internal_chat_channel_members.last_read_at'
)
.where(internal_chat_channel_id: channels.select(:id), user_id: Current.user.id)
.where.not(last_read_at: nil)
.where.not('internal_chat_messages.sender_id' => Current.user.id)
.group('internal_chat_channel_members.internal_chat_channel_id')
.count('internal_chat_messages.id')
end
def channel_base_response(channel)
{
id: channel.id,
name: channel.name,
description: channel.description,
channel_type: channel.channel_type,
status: channel.status,
category_id: channel.category_id,
last_activity_at: channel.last_activity_at,
created_at: channel.created_at,
updated_at: channel.updated_at
}
end
def channel_index_response(channel) # rubocop:disable Metrics/AbcSize
membership = channel.channel_members.detect { |member| member.user_id == Current.user.id }
response = channel_base_response(channel).merge(
is_dm: channel.channel_type_dm?,
muted: membership&.muted || false,
favorited: membership&.favorited || false,
hidden: membership&.hidden || false,
members_count: channel.channel_members.size,
unread_count: @unread_counts&.dig(channel.id) || 0,
has_unread_mention: @mention_channel_ids&.include?(channel.id) || false
)
if channel.channel_type_dm?
response[:members] = channel.channel_members.map do |m|
{ user_id: m.user_id, name: m.user.name, avatar_url: m.user.avatar_url, availability_status: m.user.availability_status }
end
end
response
end
def channel_show_response(channel) # rubocop:disable Metrics/AbcSize
members = channel.channel_members.includes(:user).load
membership = members.detect { |member| member.user_id == Current.user.id }
recent_messages = channel.messages
.includes(:sender, :reactions, :replies, { poll: { options: { votes: :user } } },
attachments: { file_attachment: :blob })
.recent.limit(RECENT_MESSAGES_LIMIT).reverse
channel_base_response(channel).merge(
is_dm: channel.channel_type_dm?,
muted: membership&.muted || false,
favorited: membership&.favorited || false,
account_id: channel.account_id,
created_by_id: channel.created_by_id,
members_count: members.size,
unread_count: membership&.unread_messages_count || 0,
members: members.map { |m| member_response(m) },
messages: recent_messages.map { |msg| message_response(msg) }
)
end
def member_response(member)
{
id: member.id,
user_id: member.user_id,
role: member.role,
muted: member.muted,
favorited: member.favorited,
name: member.user.name,
avatar_url: member.user.avatar_url
}
end
def message_response(message)
deleted = message.content_attributes&.dig('deleted')
attrs = message.content_attributes || {}
attrs = attrs.merge(poll: poll_response_for(message.poll)) if message.poll.present?
{
id: message.id,
content: message.content,
content_type: message.content_type,
content_attributes: attrs,
sender: message.sender&.push_event_data,
parent_id: message.parent_id,
echo_id: message.echo_id,
replies_count: message.replies_count,
created_at: message.created_at,
updated_at: message.updated_at,
reactions: reaction_responses(message),
attachments: deleted ? [] : message.attachments.map { |a| attachment_response(a) }
}
end
def poll_response_for(poll)
{
id: poll.id,
question: poll.question,
multiple_choice: poll.multiple_choice,
public_results: poll.public_results,
allow_revote: poll.allow_revote,
expires_at: poll.expires_at,
internal_chat_message_id: poll.internal_chat_message_id,
options: poll.options.ordered.includes(votes: :user).map { |opt| poll_option_response(opt, poll) },
total_votes: poll.total_votes_count,
created_at: poll.created_at,
updated_at: poll.updated_at
}
end
def poll_option_response(option, poll)
response = {
id: option.id,
text: option.text,
votes_count: option.votes_count,
voted: option.votes.any? { |v| v.user_id == Current.user.id }
}
response[:voters] = option.votes.map { |v| { id: v.user_id, name: v.user.name } } if poll.public_results
response
end
def reaction_responses(message)
message.reactions.includes(:user).map do |r|
{ id: r.id, emoji: r.emoji, user_id: r.user_id, user: { name: r.user&.name } }
end
end
def attachment_response(attachment)
{
id: attachment.id,
file_type: attachment.file_type,
external_url: attachment.external_url,
extension: attachment.extension,
file_url: attachment.file.attached? ? url_for(attachment.file) : nil
}
end
def channel_member_tokens(channel)
users = channel.channel_type_public_channel? ? channel.account.users : channel.members
users.pluck(:pubsub_token)
end
def validate_category!(category_id)
return if category_id.blank?
Current.account.internal_chat_categories.find(category_id)
end
def dispatch_channel_event(channel)
Rails.configuration.dispatcher.dispatch(INTERNAL_CHAT_CHANNEL_UPDATED, Time.zone.now, channel: channel)
end
end

View File

@ -0,0 +1,55 @@
class Api::V1::Accounts::InternalChat::DraftsController < Api::V1::Accounts::InternalChat::BaseController
before_action :current_channel, only: [:update, :destroy]
def index
accessible_channel_ids = Current.account.internal_chat_channels
.where(channel_type: :public_channel)
.or(Current.account.internal_chat_channels.where(id: Current.user.internal_chat_channels.select(:id)))
.select(:id)
@drafts = InternalChat::Draft.where(user: Current.user, account: Current.account,
internal_chat_channel_id: accessible_channel_ids).recent
render json: @drafts.map { |draft| draft_response(draft) }
end
def update
authorize current_channel, :show?, policy_class: InternalChat::ChannelPolicy
@draft = InternalChat::Draft.find_or_initialize_by(
user: Current.user,
internal_chat_channel_id: current_channel.id,
parent_id: draft_params[:parent_id]
)
@draft.assign_attributes(
account: Current.account,
content: draft_params[:content]
)
@draft.save!
render json: draft_response(@draft), status: :ok
end
def destroy
authorize current_channel, :show?, policy_class: InternalChat::ChannelPolicy
@draft = InternalChat::Draft.find_by!(user: Current.user, internal_chat_channel_id: current_channel.id, parent_id: params[:parent_id])
@draft.destroy!
head :ok
end
private
def draft_params
params.permit(:content, :parent_id)
end
def draft_response(draft)
{
id: draft.id,
content: draft.content,
internal_chat_channel_id: draft.internal_chat_channel_id,
parent_id: draft.parent_id,
created_at: draft.created_at,
updated_at: draft.updated_at
}
end
end

View File

@ -0,0 +1,191 @@
class Api::V1::Accounts::InternalChat::MessagesController < Api::V1::Accounts::InternalChat::BaseController
include Events::Types
before_action :current_channel
before_action :fetch_message, only: [:update, :destroy, :pin, :unpin, :thread]
MESSAGES_PER_PAGE = 50
def index
authorize current_channel, :show?, policy_class: InternalChat::ChannelPolicy
@messages = paginated_messages
render json: {
messages: @messages.map { |msg| message_response(msg) },
meta: pagination_meta
}
end
def create
authorize current_channel, :show?, policy_class: InternalChat::ChannelPolicy
@message = InternalChat::MessageCreateService.new(
channel: current_channel,
sender: Current.user,
params: message_params
).perform
render json: message_response(@message), status: :created
end
def update
authorize @message, :update?, policy_class: InternalChat::MessagePolicy
previous_content = @message.content
@message.update!(
content: update_params[:content],
content_attributes: (@message.content_attributes || {}).merge('edited_at' => Time.current.iso8601, 'previous_content' => previous_content)
)
dispatch_message_event(INTERNAL_CHAT_MESSAGE_UPDATED, message: @message)
render json: message_response(@message)
end
def destroy
authorize @message, :destroy?, policy_class: InternalChat::MessagePolicy
message_data = {
id: @message.id,
internal_chat_channel_id: @message.internal_chat_channel_id,
account_id: @message.account_id
}
@message.update!(content: I18n.t('internal_chat.messages.deleted'), content_attributes: { deleted: true })
dispatch_message_event(INTERNAL_CHAT_MESSAGE_DELETED, message_data: message_data)
head :ok
end
def pin
authorize @message, :pin?, policy_class: InternalChat::MessagePolicy
@message.skip_content_validation = true
@message.update!(content_attributes: (@message.content_attributes || {}).merge('pinned' => true, 'pinned_by' => Current.user.id,
'pinned_at' => Time.current.iso8601))
dispatch_message_event(INTERNAL_CHAT_MESSAGE_UPDATED, message: @message)
render json: message_response(@message)
end
def unpin
authorize @message, :unpin?, policy_class: InternalChat::MessagePolicy
@message.skip_content_validation = true
attrs = (@message.content_attributes || {}).except('pinned', 'pinned_by', 'pinned_at')
@message.update!(content_attributes: attrs)
dispatch_message_event(INTERNAL_CHAT_MESSAGE_UPDATED, message: @message)
render json: message_response(@message)
end
def thread
authorize @message, :thread?, policy_class: InternalChat::MessagePolicy
replies = @message.replies.includes(:sender, :reactions, :replies, :attachments, :poll).ordered
render json: {
parent: message_response(@message),
replies: replies.map { |msg| message_response(msg) }
}
end
private
def fetch_message
@message = current_channel.messages.find(params[:id])
end
def paginated_messages
return fetch_around_messages if params[:around].present?
messages = apply_time_filters(base_messages_scope)
if params[:after].present?
messages.ordered.limit(MESSAGES_PER_PAGE)
else
messages.ordered.last(MESSAGES_PER_PAGE)
end
rescue ArgumentError
base_messages_scope.ordered.last(MESSAGES_PER_PAGE)
end
def fetch_around_messages
target = current_channel.messages.find_by(id: params[:around])
return base_messages_scope.ordered.last(MESSAGES_PER_PAGE) unless target
half = MESSAGES_PER_PAGE / 2
before_msgs = base_messages_scope.where('internal_chat_messages.created_at <= ?', target.created_at)
.ordered.last(half)
after_msgs = base_messages_scope.where('internal_chat_messages.created_at > ?', target.created_at)
.ordered.limit(half)
(before_msgs + after_msgs).uniq(&:id).sort_by(&:created_at)
end
def base_messages_scope
current_channel.messages
.includes(:sender, :reactions, :replies, :attachments, :poll)
.where("parent_id IS NULL OR (content_attributes->>'also_send_in_channel')::boolean = true")
end
def apply_time_filters(messages)
messages = messages.where('internal_chat_messages.created_at < ?', Time.zone.parse(params[:before])) if params[:before].present?
messages = messages.where('internal_chat_messages.created_at > ?', Time.zone.parse(params[:after])) if params[:after].present?
messages
end
def pagination_meta
{
has_more: @messages.size >= MESSAGES_PER_PAGE
}
end
def message_params
params.permit(:content, :content_type, :parent_id, :echo_id, :also_send_in_channel, attachments: [:file, :file_type])
end
def update_params
params.permit(:content)
end
def message_response(message) # rubocop:disable Metrics/AbcSize
deleted = message.content_attributes&.dig('deleted')
response = {
id: message.id,
content: message.content,
content_type: message.content_type,
content_attributes: message.content_attributes,
internal_chat_channel_id: message.internal_chat_channel_id,
sender: message.sender&.push_event_data,
parent_id: message.parent_id,
echo_id: message.echo_id,
replies_count: message.replies_count,
created_at: message.created_at,
updated_at: message.updated_at,
reactions: message.reactions.includes(:user).map { |r| { id: r.id, emoji: r.emoji, user_id: r.user_id, user: { name: r.user&.name } } },
attachments: deleted ? [] : message.attachments.map { |a| attachment_response(a) }
}
response[:poll] = poll_data(message.poll) if !deleted && message.poll?
response
end
def poll_data(poll)
return nil unless poll
{
id: poll.id,
question: poll.question,
multiple_choice: poll.multiple_choice,
public_results: poll.public_results,
allow_revote: poll.allow_revote,
expires_at: poll.expires_at,
options: poll.options.ordered.includes(votes: :user).map { |o| poll_option_data(o, poll) },
total_votes: poll.total_votes_count
}
end
def poll_option_data(option, poll)
data = { id: option.id, text: option.text, emoji: option.emoji, votes_count: option.votes_count,
voted: option.votes.any? { |v| v.user_id == Current.user.id } }
data[:voters] = option.votes.map { |v| v.user.push_event_data } if poll.public_results
data
end
def attachment_response(attachment)
{
id: attachment.id,
file_type: attachment.file_type,
external_url: attachment.external_url,
extension: attachment.extension,
file_url: attachment.file.attached? ? url_for(attachment.file) : nil
}
end
def dispatch_message_event(event, data)
Rails.configuration.dispatcher.dispatch(event, Time.zone.now, **data)
end
end

View File

@ -0,0 +1,177 @@
class Api::V1::Accounts::InternalChat::PollsController < Api::V1::Accounts::InternalChat::BaseController
include Events::Types
before_action :set_poll, only: [:vote]
before_action :set_poll_for_unvote, only: [:unvote]
def create
return render_pro_required('polls') unless InternalChat::Limits.polls_enabled?
@channel = Current.account.internal_chat_channels.find(params[:channel_id])
authorize @channel, :show?, policy_class: InternalChat::ChannelPolicy
raise ActionController::BadRequest, 'Options are required' if poll_params[:options].blank?
ActiveRecord::Base.transaction do
@message = create_poll_message
@poll = build_poll
create_poll_options
end
dispatch_message_created_event
render json: message_with_poll_response(@message, @poll), status: :created
end
def vote
ActiveRecord::Base.transaction do
validate_vote!
@vote = @option.votes.create!(user: Current.user)
end
dispatch_poll_event
render json: message_with_poll_response(@poll.message, @poll.reload), status: :ok
end
def unvote
raise ActionController::BadRequest, 'Poll has expired' if @poll.expired?
@vote = if params[:option_id].present?
option = @poll.options.find(params[:option_id])
option.votes.find_by!(user_id: Current.user.id)
else
InternalChat::PollVote.joins(:option)
.where(internal_chat_poll_options: { internal_chat_poll_id: @poll.id }, user_id: Current.user.id)
.first!
end
@vote.destroy!
dispatch_poll_event
render json: message_with_poll_response(@poll.message, @poll.reload), status: :ok
end
private
def set_poll
@poll = InternalChat::Poll.joins(:message).where(internal_chat_messages: { account_id: Current.account.id }).find(params[:id])
@option = @poll.options.find(params[:option_id])
channel = @poll.message.channel
authorize channel, :show?, policy_class: InternalChat::ChannelPolicy
end
def set_poll_for_unvote
@poll = InternalChat::Poll.joins(:message).where(internal_chat_messages: { account_id: Current.account.id }).find(params[:id])
channel = @poll.message.channel
authorize channel, :show?, policy_class: InternalChat::ChannelPolicy
end
def create_poll_message
@channel.messages.create!(
account: Current.account,
sender: Current.user,
content: poll_params[:question],
content_type: :poll
)
end
def build_poll
@message.create_poll!(
question: poll_params[:question],
multiple_choice: poll_params[:multiple_choice] || false,
public_results: poll_params.fetch(:public_results, true),
allow_revote: poll_params.fetch(:allow_revote, true),
expires_at: poll_params[:expires_at]
)
end
def validate_vote!
raise ActionController::BadRequest, 'Poll has expired' if @poll.expired?
existing_votes = existing_user_votes
return unless existing_votes.exists?
raise ActionController::BadRequest, 'Revoting is not allowed' unless @poll.allow_revote
if @poll.multiple_choice
raise ActionController::BadRequest, 'Already voted for this option' if @option.votes.exists?(user_id: Current.user.id)
else
existing_votes.destroy_all
end
end
def existing_user_votes
InternalChat::PollVote.joins(:option).where(
internal_chat_poll_options: { internal_chat_poll_id: @poll.id },
user_id: Current.user.id
)
end
def create_poll_options
poll_params[:options].each_with_index do |option_attrs, index|
@poll.options.create!(
text: option_attrs[:text],
emoji: option_attrs[:emoji],
image_url: option_attrs[:image_url],
position: index
)
end
end
def poll_params
params.permit(:question, :multiple_choice, :public_results, :allow_revote, :expires_at, :channel_id,
options: [:text, :emoji, :image_url])
end
def message_with_poll_response(message, poll)
{
id: message.id,
content: message.content,
content_type: message.content_type,
content_attributes: (message.content_attributes || {}).merge(poll: poll_response(poll)),
internal_chat_channel_id: message.internal_chat_channel_id,
sender: message.sender.push_event_data,
parent_id: message.parent_id,
created_at: message.created_at,
updated_at: message.updated_at,
attachments: [],
reactions: []
}
end
def poll_response(poll)
{
id: poll.id,
question: poll.question,
multiple_choice: poll.multiple_choice,
public_results: poll.public_results,
allow_revote: poll.allow_revote,
expires_at: poll.expires_at,
internal_chat_message_id: poll.internal_chat_message_id,
options: poll.options.ordered.includes(votes: :user).map { |option| option_response(option, poll) },
total_votes: poll.total_votes_count,
created_at: poll.created_at,
updated_at: poll.updated_at
}
end
def option_response(option, poll)
response = {
id: option.id,
text: option.text,
emoji: option.emoji,
image_url: option.image_url,
position: option.position,
votes_count: option.votes_count,
voted: option.votes.any? { |v| v.user_id == Current.user.id }
}
response[:voters] = option.votes.map { |v| v.user.push_event_data } if poll.public_results
response
end
def dispatch_message_created_event
Rails.configuration.dispatcher.dispatch(INTERNAL_CHAT_MESSAGE_CREATED, Time.zone.now, message: @message)
end
def dispatch_poll_event
Rails.configuration.dispatcher.dispatch(INTERNAL_CHAT_POLL_VOTED, Time.zone.now, poll: @poll, message: @poll.message)
end
end

View File

@ -0,0 +1,54 @@
class Api::V1::Accounts::InternalChat::ReactionsController < Api::V1::Accounts::InternalChat::BaseController
include Events::Types
before_action :fetch_message
def create
@reaction = @message.reactions.build(user: Current.user, emoji: reaction_params[:emoji])
authorize @reaction, :create?, policy_class: InternalChat::ReactionPolicy
@reaction.save!
dispatch_reaction_event(INTERNAL_CHAT_REACTION_CREATED, reaction: @reaction)
render json: reaction_response(@reaction), status: :created
end
def destroy
@reaction = @message.reactions.find(params[:id])
authorize @reaction, :destroy?, policy_class: InternalChat::ReactionPolicy
reaction_data = {
id: @reaction.id,
message_id: @reaction.internal_chat_message_id,
internal_chat_channel_id: @message.internal_chat_channel_id,
account_id: @message.account_id,
user_id: @reaction.user_id,
emoji: @reaction.emoji
}
@reaction.destroy!
dispatch_reaction_event(INTERNAL_CHAT_REACTION_DELETED, reaction_data: reaction_data)
head :ok
end
private
def fetch_message
@message = InternalChat::Message.joins(:channel).where(internal_chat_channels: { account_id: Current.account.id }).find(params[:message_id])
end
def reaction_response(reaction)
{
id: reaction.id,
emoji: reaction.emoji,
user_id: reaction.user_id,
user: { name: reaction.user&.name },
internal_chat_message_id: reaction.internal_chat_message_id,
created_at: reaction.created_at
}
end
def reaction_params
params.permit(:emoji)
end
def dispatch_reaction_event(event, **data)
Rails.configuration.dispatcher.dispatch(event, Time.zone.now, **data)
end
end

View File

@ -0,0 +1,19 @@
class Api::V1::Accounts::InternalChat::SearchController < Api::V1::Accounts::BaseController
def show
authorize InternalChat::Channel, :index?
result = InternalChat::SearchService.new(
current_user: Current.user,
current_account: Current.account,
params: search_params
).perform
render json: result
end
private
def search_params
params.permit(:q, :page)
end
end

View File

@ -23,7 +23,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
def update
ActiveRecord::Base.transaction do
@portal.update!(portal_params.merge(live_chat_widget_params)) if params[:portal].present?
@portal.update!(merged_portal_params.merge(live_chat_widget_params)) if params[:portal].present?
# @portal.custom_domain = parsed_custom_domain
process_attached_logo if params[:blob_id].present?
rescue ActiveRecord::RecordInvalid => e
@ -79,10 +79,21 @@ 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, { allowed_locales: [] }] }
:name, :page_title, :slug, :archived, :custom_head_html, :custom_body_html,
{ config: [:default_locale, :show_author, { allowed_locales: [] }, { draft_locales: [] }] }
)
end
def merged_portal_params
update_params = portal_params.to_h
if update_params.key?('config')
base_config = @portal.config.is_a?(Hash) ? @portal.config : {}
incoming_config = update_params['config']
update_params['config'] = incoming_config.is_a?(Hash) ? base_config.merge(incoming_config) : base_config
end
update_params
end
def live_chat_widget_params
permitted_params = params.permit(:inbox_id)
return {} unless permitted_params.key?(:inbox_id)

View File

@ -5,7 +5,7 @@ class Api::V1::Accounts::UploadController < Api::V1::Accounts::BaseController
elsif params[:external_url].present?
create_from_url
else
render_error('No file or URL provided', :unprocessable_entity)
render_error(I18n.t('errors.upload.missing_input'), :unprocessable_entity)
end
render_success(result) if result.is_a?(ActiveStorage::Blob)
@ -19,35 +19,21 @@ class Api::V1::Accounts::UploadController < Api::V1::Accounts::BaseController
end
def create_from_url
uri = parse_uri(params[:external_url])
return if performed?
fetch_and_process_file_from_uri(uri)
end
def parse_uri(url)
uri = URI.parse(url)
validate_uri(uri)
uri
rescue URI::InvalidURIError, SocketError
render_error('Invalid URL provided', :unprocessable_entity)
nil
end
def validate_uri(uri)
raise URI::InvalidURIError unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
end
def fetch_and_process_file_from_uri(uri)
uri.open do |file|
create_and_save_blob(file, File.basename(uri.path), file.content_type)
SafeFetch.fetch(params[:external_url].to_s) do |result|
create_and_save_blob(result.tempfile, result.filename, result.content_type)
end
rescue OpenURI::HTTPError => e
render_error("Failed to fetch file from URL: #{e.message}", :unprocessable_entity)
rescue SocketError
render_error('Invalid URL provided', :unprocessable_entity)
rescue SafeFetch::HttpError => e
render_error(I18n.t('errors.upload.fetch_failed_with_message', message: e.message), :unprocessable_entity)
rescue SafeFetch::FetchError
render_error(I18n.t('errors.upload.fetch_failed'), :unprocessable_entity)
rescue SafeFetch::FileTooLargeError
render_error(I18n.t('errors.upload.file_too_large'), :unprocessable_entity)
rescue SafeFetch::UnsupportedContentTypeError
render_error(I18n.t('errors.upload.unsupported_content_type'), :unprocessable_entity)
rescue SafeFetch::Error
render_error(I18n.t('errors.upload.invalid_url'), :unprocessable_entity)
rescue StandardError
render_error('An unexpected error occurred', :internal_server_error)
render_error(I18n.t('errors.upload.unexpected'), :internal_server_error)
end
def create_and_save_blob(io, filename, content_type)

View File

@ -30,9 +30,20 @@ class Api::V1::AccountsController < Api::BaseController
locale: account_params[:locale],
user: current_user
).perform
enqueue_branding_enrichment
if @user
send_auth_headers(@user)
render 'api/v1/accounts/create', format: :json, locals: { resource: @user }
# Authenticated users (dashboard "add account") and api_only signups
# need the full response with account_id. API-only deployments have no
# frontend to handle the email confirmation flow, so they need auth
# tokens to proceed.
# Unauthenticated web signup returns only the email — no session is
# created until the user confirms via the email link.
if current_user || api_only_signup?
send_auth_headers(@user)
render 'api/v1/accounts/create', format: :json, locals: { resource: @user }
else
render json: { email: @user.email }
end
else
render_error_response(CustomExceptions::Account::SignupFailed.new({}))
end
@ -59,6 +70,16 @@ class Api::V1::AccountsController < Api::BaseController
private
def enqueue_branding_enrichment
return if account_params[:email].blank?
Account::BrandingEnrichmentJob.perform_later(@account.id, account_params[:email])
Redis::Alfred.set(format(Redis::Alfred::ACCOUNT_ONBOARDING_ENRICHMENT, account_id: @account.id), '1', ex: 30)
rescue StandardError => e
# Enrichment is optional — never let queue/Redis failures abort signup
ChatwootExceptionTracker.new(e).capture_exception
end
def ensure_account_name
# ensure that account_name and user_full_name is present
# this is becuase the account builder and the models validations are not triggered
@ -101,7 +122,16 @@ class Api::V1::AccountsController < Api::BaseController
end
def check_signup_enabled
raise ActionController::RoutingError, 'Not Found' if GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') == 'false'
raise ActionController::RoutingError, 'Not Found' unless GlobalConfigService.account_signup_enabled?
end
def api_only_signup?
# CW_API_ONLY_SERVER is the canonical flag for API-only deployments.
# ENABLE_ACCOUNT_SIGNUP='api_only' is a legacy sentinel for the same purpose.
# Read ENABLE_ACCOUNT_SIGNUP raw from InstallationConfig because GlobalConfig.get
# typecasts it to boolean, coercing 'api_only' to true.
ActiveModel::Type::Boolean.new.cast(ENV.fetch('CW_API_ONLY_SERVER', false)) ||
InstallationConfig.find_by(name: 'ENABLE_ACCOUNT_SIGNUP')&.value.to_s == 'api_only'
end
def validate_captcha

View File

@ -0,0 +1,69 @@
class Api::V1::Profile::InboxSignaturesController < Api::BaseController
before_action :set_user
before_action :set_inbox_signature, only: %i[show update destroy]
before_action :validate_inbox_access, only: %i[show update destroy]
def index
if params[:account_id].present?
validate_account_access!
return if performed?
@inbox_signatures = @user.inbox_signatures.joins(:inbox).where(inboxes: { account_id: params[:account_id] })
else
@inbox_signatures = @user.inbox_signatures
end
end
def show
head :not_found and return unless @inbox_signature
end
def update
if @inbox_signature
@inbox_signature.update!(inbox_signature_params)
else
@inbox_signature = @user.inbox_signatures.create!(
inbox_signature_params.merge(inbox_id: params[:inbox_id])
)
end
end
def destroy
@inbox_signature&.destroy!
head :no_content
end
private
def set_user
@user = current_user
end
def set_inbox_signature
@inbox_signature = @user.inbox_signatures.find_by(inbox_id: params[:inbox_id])
end
def inbox_signature_params
params.require(:inbox_signature).permit(:message_signature, :signature_position, :signature_separator)
end
def validate_inbox_access
inbox = Inbox.find_by(id: params[:inbox_id])
return head :not_found unless inbox
account_user = @user.account_users.find_by(account_id: inbox.account_id)
return head :unauthorized unless account_user
return if account_user.administrator?
return if InboxMember.exists?(user_id: @user.id, inbox_id: inbox.id)
head :unauthorized
end
def validate_account_access!
account_id = params[:account_id]
return if @user.account_ids.include?(account_id.to_i)
head :unauthorized
end
end

View File

@ -19,7 +19,7 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
contact = @contact
end
@contact_inbox.update!(hmac_verified: true) if should_verify_hmac? && valid_hmac?
@contact_inbox.update!(hmac_verified: true) if should_verify_hmac?
identify_contact(contact)
end

View File

@ -43,7 +43,15 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
end
def set_conversation
@conversation = create_conversation if conversation.nil?
return unless conversation.nil?
@conversation = create_conversation
apply_labels if permitted_params[:labels].present?
end
def apply_labels
valid_labels = inbox.account.labels.where(title: permitted_params[:labels]).pluck(:title)
@conversation.update_labels(valid_labels) if valid_labels.present?
end
def message_finder_params
@ -64,7 +72,14 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
def permitted_params
# timestamp parameter is used in create conversation method
params.permit(:id, :before, :after, :website_token, contact: [:name, :email], message: [:content, :referer_url, :timestamp, :echo_id, :reply_to])
# custom_attributes and labels are applied when a new conversation is created alongside the first message
params.permit(
:id, :before, :after, :website_token,
contact: [:name, :email],
message: [:content, :referer_url, :timestamp, :echo_id, :reply_to],
custom_attributes: {},
labels: []
)
end
def set_message

View File

@ -58,7 +58,7 @@ class Api::V2::AccountsController < Api::BaseController
end
def check_signup_enabled
raise ActionController::RoutingError, 'Not Found' if GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') == 'false'
raise ActionController::RoutingError, 'Not Found' unless GlobalConfigService.account_signup_enabled?
end
def validate_captcha

View File

@ -0,0 +1,18 @@
# Unauthenticated endpoint for resending confirmation emails during signup.
# This is a standalone controller (not on DeviseOverrides::ConfirmationsController)
# because OmniAuth middleware intercepts all POST /auth/* routes as provider
# callbacks, and Devise controller filters cause 307 redirects for custom actions.
# Inherits from ActionController::API to avoid both issues entirely.
# Rate-limited by Rack::Attack (IP + email) and gated by hCaptcha.
class Auth::ResendConfirmationsController < ActionController::API
def create
return head(:ok) unless ChatwootCaptcha.new(params[:h_captcha_client_response]).valid?
email = params[:email]
return head(:ok) unless email.is_a?(String)
user = User.from_email(email.strip.downcase)
user&.send_confirmation_instructions unless user&.confirmed?
head :ok
end
end

View File

@ -1,6 +1,6 @@
module AccessTokenAuthHelper
BOT_ACCESSIBLE_ENDPOINTS = {
'api/v1/accounts/conversations' => %w[toggle_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']
}.freeze
@ -28,7 +28,7 @@ module AccessTokenAuthHelper
def validate_bot_access_token!
return if Current.user.is_a?(User)
return if agent_bot_accessible?
return if @resource.is_a?(AgentBot) && agent_bot_accessible?
render_unauthorized('Access to this endpoint is not authorized for bots')
end

View File

@ -10,6 +10,7 @@ class DashboardController < ActionController::Base
TERMS_URL
BRAND_URL
BRAND_NAME
BRAND_COLOR
PRIVACY_URL
DISPLAY_MANIFEST
CREATE_NEW_ACCOUNT_FROM_DASHBOARD
@ -78,6 +79,7 @@ class DashboardController < ActionController::Base
WHATSAPP_APP_ID: GlobalConfigService.load('WHATSAPP_APP_ID', ''),
WHATSAPP_CONFIGURATION_ID: GlobalConfigService.load('WHATSAPP_CONFIGURATION_ID', ''),
IS_ENTERPRISE: ChatwootApp.enterprise?,
BAILEYS_WHATSAPP_GROUPS_ENABLED: Whatsapp::Providers::WhatsappBaileysService.groups_enabled?,
AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''),
GIT_SHA: GIT_HASH,
ALLOWED_LOGIN_METHODS: allowed_login_methods

View File

@ -10,7 +10,12 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
private
def sign_in_user
# Capture before skip_confirmation! sets confirmed_at, which would
# make oauth_user_needs_password_reset? return false and skip the
# password reset for persisted unconfirmed users.
needs_password_reset = oauth_user_needs_password_reset?
@resource.skip_confirmation! if confirmable_enabled?
set_random_password_if_oauth_user if needs_password_reset
# once the resource is found and verified
# we can just send them to the login page again with the SSO params
@ -20,7 +25,10 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
end
def sign_in_user_on_mobile
# See comment in sign_in_user for why this is captured before skip_confirmation!
needs_password_reset = oauth_user_needs_password_reset?
@resource.skip_confirmation! if confirmable_enabled?
set_random_password_if_oauth_user if needs_password_reset
# once the resource is found and verified
# we can just send them to the login page again with the SSO params
@ -37,6 +45,7 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
return redirect_to login_page_url(error: 'business-account-only') unless validate_signup_email_is_business_domain?
create_account_for_user
set_random_password_if_oauth_user
token = @resource.send(:set_reset_password_token)
frontend_url = ENV.fetch('FRONTEND_URL', nil)
redirect_to "#{frontend_url}/app/auth/password/edit?config=default&reset_password_token=#{token}"
@ -51,8 +60,7 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
end
def account_signup_allowed?
# set it to true by default, this is the behaviour across the app
GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') != 'false'
GlobalConfigService.account_signup_enabled?
end
def resource_class(_mapping = nil)
@ -82,6 +90,15 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
Avatar::AvatarFromUrlJob.perform_later(@resource, auth_hash['info']['image'])
end
def oauth_user_needs_password_reset?
@resource.present? && (@resource.new_record? || !@resource.confirmed?)
end
def set_random_password_if_oauth_user
# Password must satisfy secure_password requirements (uppercase, lowercase, number, special char)
@resource.update!(password: "#{SecureRandom.hex(16)}aA1!") if @resource.persisted?
end
def default_devise_mapping
'user'
end

View File

@ -2,6 +2,8 @@ class Linear::CallbacksController < ApplicationController
include Linear::IntegrationHelper
def show
return redirect_to(safe_linear_redirect_uri) if params[:code].blank? || account_id.blank?
@response = oauth_client.auth_code.get_token(
params[:code],
redirect_uri: "#{base_url}/linear/callback"
@ -10,7 +12,7 @@ class Linear::CallbacksController < ApplicationController
handle_response
rescue StandardError => e
Rails.logger.error("Linear callback error: #{e.message}")
redirect_to linear_redirect_uri
redirect_to safe_linear_redirect_uri
end
private
@ -31,22 +33,19 @@ class Linear::CallbacksController < ApplicationController
end
def handle_response
hook = account.hooks.new(
raise ArgumentError, 'Missing access token in Linear OAuth response' if parsed_body['access_token'].blank?
hook = account.hooks.find_or_initialize_by(app_id: 'linear')
hook.assign_attributes(
access_token: parsed_body['access_token'],
status: 'enabled',
app_id: 'linear',
settings: {
token_type: parsed_body['token_type'],
expires_in: parsed_body['expires_in'],
scope: parsed_body['scope']
}
settings: merged_integration_settings(hook.settings)
)
# You may wonder why we're not handling the refresh token update, since the token will expire only after 10 years, https://github.com/linear/linear/issues/251
hook.save!
redirect_to linear_redirect_uri
rescue StandardError => e
Rails.logger.error("Linear callback error: #{e.message}")
redirect_to linear_redirect_uri
redirect_to safe_linear_redirect_uri
end
def account
@ -54,19 +53,47 @@ class Linear::CallbacksController < ApplicationController
end
def account_id
return unless params[:state]
return @account_id if instance_variable_defined?(:@account_id)
verify_linear_token(params[:state])
@account_id = params[:state].present? ? verify_linear_token(params[:state]) : nil
end
def linear_redirect_uri
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/linear"
end
def safe_linear_redirect_uri
return base_url if account_id.blank?
linear_redirect_uri
rescue StandardError
base_url
end
def parsed_body
@parsed_body ||= @response.response.parsed
end
def integration_settings
{
token_type: parsed_body['token_type'],
expires_in: parsed_body['expires_in'],
expires_on: expires_on,
scope: parsed_body['scope'],
refresh_token: parsed_body['refresh_token']
}.compact
end
def merged_integration_settings(existing_settings)
existing_settings.to_h.with_indifferent_access.merge(integration_settings)
end
def expires_on
return if parsed_body['expires_in'].blank?
(Time.current.utc + parsed_body['expires_in'].to_i.seconds).to_s
end
def base_url
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
end

View File

@ -0,0 +1,35 @@
class ManifestController < ApplicationController
PNG_MIME = 'image/png'.freeze
SVG_MIME = 'image/svg+xml'.freeze
def show
config = GlobalConfig.get('INSTALLATION_NAME', 'LOGO_THUMBNAIL', 'BRAND_COLOR')
installation_name = config['INSTALLATION_NAME'].presence || 'Chatwoot'
logo = config['LOGO_THUMBNAIL'].presence || '/brand-assets/logo_thumbnail.svg'
brand_color = config['BRAND_COLOR'].presence || '#1f93ff'
icon_type = svg?(logo) ? SVG_MIME : PNG_MIME
expires_in 1.hour, public: true
render json: {
name: installation_name,
short_name: installation_name,
id: '/',
start_url: '/',
display: 'standalone',
background_color: brand_color,
theme_color: brand_color,
icons: [
{ src: logo, sizes: '192x192', type: icon_type, purpose: 'any maskable' },
{ src: logo, sizes: '512x512', type: icon_type, purpose: 'any maskable' }
]
}, content_type: 'application/manifest+json'
end
private
def svg?(url)
File.extname(URI.parse(url).path).casecmp('.svg').zero?
rescue URI::InvalidURIError
false
end
end

View File

@ -0,0 +1,101 @@
class Platform::Api::V1::EmailChannelMigrationsController < PlatformController
before_action :set_account
before_action :validate_account_permissible
before_action :validate_feature_flag
before_action :validate_params
def create
results = migrate_email_channels
render json: { results: results }, status: :ok
end
private
def set_account
@account = Account.find(params[:account_id])
end
def validate_account_permissible
return if @platform_app.platform_app_permissibles.find_by(permissible: @account)
render json: { error: 'Non permissible resource' }, status: :unauthorized
end
def validate_feature_flag
return if ActiveModel::Type::Boolean.new.cast(ENV.fetch('EMAIL_CHANNEL_MIGRATION', false))
render json: { error: 'Email channel migration is not enabled' }, status: :forbidden
end
def validate_params
return render json: { error: 'Missing migrations parameter' }, status: :unprocessable_entity if migration_params.blank?
return unless migration_params.size > MAX_MIGRATIONS
return render json: { error: "Too many migrations (max #{MAX_MIGRATIONS})" },
status: :unprocessable_entity
end
def migrate_email_channels
migration_params.map { |entry| migrate_single(entry) }
end
MAX_MIGRATIONS = 25
SUPPORTED_PROVIDERS = %w[google microsoft].freeze
def migrate_single(entry)
validate_provider!(entry[:provider])
ActiveRecord::Base.transaction do
channel = create_channel(entry)
inbox = create_inbox(channel, entry)
{ email: entry[:email], inbox_id: inbox.id, channel_id: channel.id, status: 'success' }
end
rescue StandardError => e
{ email: entry[:email], status: 'error', message: e.message }
end
def create_channel(entry)
Channel::Email.create!(
account_id: @account.id,
email: entry[:email],
provider: entry[:provider],
provider_config: entry[:provider_config]&.to_h,
imap_enabled: entry.fetch(:imap_enabled, true),
imap_address: entry[:imap_address] || default_imap_address(entry[:provider]),
imap_port: entry[:imap_port] || 993,
imap_login: entry[:imap_login] || entry[:email],
imap_enable_ssl: entry.fetch(:imap_enable_ssl, true)
)
end
def create_inbox(channel, entry)
@account.inboxes.create!(
name: entry[:inbox_name] || "Migrated #{entry[:provider]&.capitalize}: #{entry[:email]}",
channel: channel
)
end
def validate_provider!(provider)
return if SUPPORTED_PROVIDERS.include?(provider)
raise ArgumentError, "Unsupported provider '#{provider}'. Must be one of: #{SUPPORTED_PROVIDERS.join(', ')}"
end
def default_imap_address(provider)
case provider
when 'google' then 'imap.gmail.com'
when 'microsoft' then 'outlook.office365.com'
else ''
end
end
def migration_params
params.permit(migrations: [
:email, :provider, :inbox_name,
:imap_enabled, :imap_address, :imap_port, :imap_login, :imap_enable_ssl,
{ provider_config: {} }
])[:migrations]
end
end

View File

@ -1,11 +1,13 @@
class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::BaseController
before_action :ensure_custom_domain_request, only: [:show, :index]
before_action :portal
before_action :ensure_portal_feature_enabled
before_action :set_category, except: [:index, :show, :tracking_pixel]
before_action :set_article, only: [:show]
layout 'portal'
def index
@search_query = list_params[:query]
@articles = @portal.articles.published.includes(:category, :author)
@articles = @articles.where(locale: permitted_params[:locale]) if permitted_params[:locale].present?
@ -60,7 +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)
@parsed_content = render_article_content(@article.content.to_s)
end
def set_category
@ -73,7 +75,9 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
end
def list_params
params.permit(:query, :locale, :sort, :status, :page, :per_page)
@list_params ||= params.permit(:query, :locale, :sort, :status, :page, :per_page).tap do |permitted|
permitted[:query] = permitted[:query].to_s.strip.presence
end
end
def permitted_params

View File

@ -1,6 +1,7 @@
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 :ensure_portal_feature_enabled
before_action :set_category, only: [:show]
layout 'portal'

View File

@ -1,7 +1,8 @@
class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseController
before_action :ensure_custom_domain_request, only: [:show]
before_action :portal
before_action :redirect_to_portal_with_locale, only: [:show]
before_action :portal
before_action :ensure_portal_feature_enabled
layout 'portal'
def show
@ -24,6 +25,7 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl
def redirect_to_portal_with_locale
return if params[:locale].present?
portal
redirect_to "/hc/#{@portal.slug}/#{@portal.default_locale}"
end
end

View File

@ -18,4 +18,11 @@ class PublicController < ActionController::Base
Please send us an email at support@chatwoot.com with the custom domain name and account API key"
}, status: :unauthorized and return
end
def ensure_portal_feature_enabled
return unless ChatwootApp.chatwoot_cloud?
return if @portal.account.feature_enabled?('help_center')
render 'public/api/v1/portals/not_active', status: :payment_required
end
end

View File

@ -77,13 +77,23 @@ class WidgetsController < ActionController::Base
end
def allow_iframe_requests
if @web_widget.allowed_domains.blank?
if @web_widget.allowed_domains.blank? || embedded_from_non_web_origin?
response.headers.delete('X-Frame-Options')
else
domains = @web_widget.allowed_domains.split(',').map(&:strip).join(' ')
response.headers['Content-Security-Policy'] = "frame-ancestors #{domains}"
end
end
# Mobile WebViews (iOS/Android) load content from file:// or null origins,
# which cannot match any domain in frame-ancestors. When the per-inbox flag
# is enabled, skip frame-ancestors for these requests.
def embedded_from_non_web_origin?
return false unless @web_widget.allow_mobile_webview?
origin = request.headers['Origin']
origin.blank? || origin == 'null' || origin&.start_with?('file://')
end
end
WidgetsController.prepend_mod_with('WidgetsController')

View File

@ -34,7 +34,9 @@ class AccountDashboard < Administrate::BaseDashboard
locale: Field::Select.with_options(collection: LANGUAGES_CONFIG.map { |_x, y| y[:iso_639_1_code] }),
status: Field::Select.with_options(collection: [%w[Active active], %w[Suspended suspended]]),
account_users: Field::HasMany,
custom_attributes: Field::String
custom_attributes: Field::String,
hide_agent_unassigned_tab: Field::Boolean,
hide_agent_all_tab: HideAgentAllTabField
}.merge(enterprise_attribute_types).freeze
# COLLECTION_ATTRIBUTES
@ -70,6 +72,8 @@ class AccountDashboard < Administrate::BaseDashboard
status
conversations
account_users
hide_agent_unassigned_tab
hide_agent_all_tab
] + enterprise_show_page_attributes).freeze
# FORM_ATTRIBUTES
@ -87,6 +91,8 @@ class AccountDashboard < Administrate::BaseDashboard
name
locale
status
hide_agent_unassigned_tab
hide_agent_all_tab
] + enterprise_form_attributes).freeze
# COLLECTION_FILTERS

View File

@ -25,7 +25,7 @@ class UserDashboard < Administrate::BaseDashboard
current_sign_in_ip: Field::String,
last_sign_in_ip: Field::String,
confirmation_token: Field::String,
confirmed_at: Field::DateTime,
confirmed_at: ConfirmedAtField,
confirmation_sent_at: Field::DateTime,
unconfirmed_email: Field::String,
name: Field::String.with_options(searchable: true),

View File

@ -15,6 +15,7 @@ class AsyncDispatcher < BaseDispatcher
CsatSurveyListener.instance,
HookListener.instance,
InstallationWebhookListener.instance,
InternalChatListener.instance,
NotificationListener.instance,
ParticipationListener.instance,
ReportingEventListener.instance,

View File

@ -0,0 +1,4 @@
require 'administrate/field/base'
class ConfirmedAtField < Administrate::Field::DateTime
end

View File

@ -0,0 +1,4 @@
require 'administrate/field/base'
class HideAgentAllTabField < Administrate::Field::Boolean
end

View File

@ -11,6 +11,7 @@ class ConversationFinder
'priority_desc' => %w[sort_on_priority desc],
'waiting_since_asc' => %w[sort_on_waiting_since asc],
'waiting_since_desc' => %w[sort_on_waiting_since desc],
'priority_desc_created_at_asc' => %w[sort_on_priority_created_at desc],
# To be removed in v3.5.0
'latest' => %w[sort_on_last_activity_at desc],
@ -55,6 +56,22 @@ class ConversationFinder
}
end
def perform_meta_only
set_up
mine_count, unassigned_count, all_count, = set_count_for_all_conversations
assigned_count = all_count - unassigned_count
{
count: {
mine_count: mine_count,
assigned_count: assigned_count,
unassigned_count: unassigned_count,
all_count: all_count
}
}
end
private
def set_up
@ -64,6 +81,7 @@ class ConversationFinder
find_all_conversations
filter_by_status unless params[:q]
filter_by_group_type
filter_by_team
filter_by_labels
filter_by_query
@ -118,6 +136,12 @@ class ConversationFinder
@conversations
end
def filter_by_group_type
return unless params[:group_type].present? && params[:group_type] != 'all'
@conversations = @conversations.where(group_type: params[:group_type])
end
def filter_by_conversation_type
case @params[:conversation_type]
when 'mention'

View File

@ -1,4 +1,11 @@
class MessageFinder
PAGE_LIMIT = 20
# `messages.content_attributes` is `json` but the model stores it as a
# double-encoded string (legacy `store coder: JSON`), so `->>` can't traverse
# it directly — `#>>'{}'` unwraps the outer encoding into proper jsonb.
NON_REACTION_CLAUSE = "((content_attributes#>>'{}')::jsonb->>'is_reaction') IS DISTINCT FROM 'true'".freeze
def initialize(conversation, params)
@conversation = conversation
@params = params
@ -37,7 +44,7 @@ class MessageFinder
end
def messages_before(before_id)
messages.reorder('created_at desc').where('id < ?', before_id).limit(20).reverse
page_window(messages.where('id < ?', before_id))
end
def messages_between(after_id, before_id)
@ -45,6 +52,32 @@ class MessageFinder
end
def messages_latest
messages.reorder('created_at desc').limit(20).reverse
page_window(messages)
end
# Reactions don't count toward the page limit — otherwise a heavily-reacted
# message can flood the latest page and hide regular messages from the UI on
# initial load. Pick the most recent non-reactions, then add only the
# reactions whose target is inside that window so chips render alongside
# their parents and orphan reactions on older messages don't bloat the page.
def page_window(scope)
# Drop `includes(:sender, ...)` for the id-only probe to avoid Rails trying
# to eager-load the polymorphic sender association (which would error).
# `minimum(:id)` would silently aggregate over the FULL relation (Rails
# drops the limit), pulling in old messages and blowing up the page. Pluck
# the limited window first and take the min in Ruby.
bare = scope.except(:includes)
window_ids = bare.where(NON_REACTION_CLAUSE).reorder('created_at desc').limit(PAGE_LIMIT).pluck(:id)
return scope.none if window_ids.empty?
json_path = "(content_attributes#>>'{}')::jsonb"
# `Message#ensure_in_reply_to` always populates content_attributes['in_reply_to']
# when either the internal id or external source_id resolves to a parent in the
# same conversation, so a single jsonb path scopes reactions to the windowed
# parents reliably.
reaction_in_window = "((#{json_path}->>'is_reaction') = 'true' AND " \
"(#{json_path}->>'in_reply_to')::bigint IN (:ids))"
scope.where("id IN (:ids) OR #{reaction_in_window}", ids: window_ids)
.reorder('created_at asc')
end
end

View File

@ -57,39 +57,35 @@ module Api::V1::InboxesHelper
end
def check_smtp_connection(channel_data, smtp)
smtp.open_timeout = 10
smtp.start(channel_data[:smtp_domain], channel_data[:smtp_login], channel_data[:smtp_password],
channel_data[:smtp_authentication]&.to_sym || :login)
smtp.finish
rescue Net::SMTPAuthenticationError
raise StandardError, I18n.t('errors.inboxes.smtp.authentication_error')
rescue SocketError, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ENETUNREACH, Net::OpenTimeout
raise StandardError, I18n.t('errors.inboxes.smtp.connection_error')
rescue OpenSSL::SSL::SSLError
raise StandardError, I18n.t('errors.inboxes.smtp.ssl_error')
rescue Net::SMTPServerBusy, Net::SMTPSyntaxError, Net::SMTPFatalError
raise StandardError, I18n.t('errors.inboxes.smtp.smtp_error')
rescue StandardError => e
raise StandardError, e.message
end
def set_smtp_encryption(channel_data, smtp)
if channel_data[:smtp_enable_ssl_tls]
set_enable_tls(channel_data, smtp)
set_smtp_ssl_method(smtp, :enable_tls, channel_data[:smtp_openssl_verify_mode])
elsif channel_data[:smtp_enable_starttls_auto]
set_enable_starttls_auto(channel_data, smtp)
set_smtp_ssl_method(smtp, :enable_starttls_auto, channel_data[:smtp_openssl_verify_mode])
end
end
def set_enable_starttls_auto(channel_data, smtp)
return unless smtp.respond_to?(:enable_starttls_auto)
def set_smtp_ssl_method(smtp, method, openssl_verify_mode)
return unless smtp.respond_to?(method)
if channel_data[:smtp_openssl_verify_mode]
context = enable_openssl_mode(channel_data[:smtp_openssl_verify_mode])
smtp.enable_starttls_auto(context)
else
smtp.enable_starttls_auto
end
end
def set_enable_tls(channel_data, smtp)
return unless smtp.respond_to?(:enable_tls)
if channel_data[:smtp_openssl_verify_mode]
context = enable_openssl_mode(channel_data[:smtp_openssl_verify_mode])
smtp.enable_tls(context)
else
smtp.enable_tls
end
context = enable_openssl_mode(openssl_verify_mode) if openssl_verify_mode
context ? smtp.send(method, context) : smtp.send(method)
end
def enable_openssl_mode(smtp_openssl_verify_mode)

View File

@ -1,6 +1,6 @@
module BaileysHelper
CHANNEL_LOCK_ON_OUTGOING_MESSAGE_KEY = 'BAILEYS::CHANNEL_LOCK_ON_OUTGOING_MESSAGE::%<channel_id>s'.freeze
CHANNEL_LOCK_ON_OUTGOING_MESSAGE_TIMEOUT = 60.seconds
CHANNEL_LOCK_ON_OUTGOING_MESSAGE_TIMEOUT = 130.seconds
def baileys_extract_message_timestamp(timestamp)
# NOTE: Timestamp might be in this format {"low"=>1748003165, "high"=>0, "unsigned"=>true}
@ -35,7 +35,7 @@ module BaileysHelper
yield
ensure
baileys_clear_channel_lock_on_outgoing_message(channel_id)
baileys_clear_channel_lock_on_outgoing_message(channel_id) if lock_acquired
end
private

View File

@ -47,11 +47,15 @@ module Filters::FilterHelper
def handle_additional_attributes(query_hash, filter_operator_value, data_type)
if data_type == 'text_case_insensitive'
"LOWER(#{filter_config[:table_name]}.additional_attributes ->> '#{query_hash[:attribute_key]}') " \
"#{filter_operator_value} #{query_hash[:query_operator]}"
ActiveRecord::Base.sanitize_sql_array(
["LOWER(#{filter_config[:table_name]}.additional_attributes ->> ?) #{filter_operator_value} #{query_hash[:query_operator]}",
query_hash[:attribute_key]]
)
else
"#{filter_config[:table_name]}.additional_attributes ->> '#{query_hash[:attribute_key]}' " \
"#{filter_operator_value} #{query_hash[:query_operator]} "
ActiveRecord::Base.sanitize_sql_array(
["#{filter_config[:table_name]}.additional_attributes ->> ? #{filter_operator_value} #{query_hash[:query_operator]} ",
query_hash[:attribute_key]]
)
end
end
@ -70,7 +74,7 @@ module Filters::FilterHelper
def date_filter(current_filter, query_hash, filter_operator_value)
"(#{filter_config[:table_name]}.#{query_hash[:attribute_key]})::#{current_filter['data_type']} " \
"#{filter_operator_value}#{current_filter['data_type']} #{query_hash[:query_operator]}"
"#{filter_operator_value} #{query_hash[:query_operator]}"
end
def text_case_insensitive_filter(query_hash, filter_operator_value)
@ -100,6 +104,10 @@ module Filters::FilterHelper
values.map { |x| Conversation.priorities[x.to_sym] }
end
def conversation_group_type_values(values)
values.map { |x| Conversation.group_types[x.to_sym] }
end
def message_type_values(values)
values.map { |x| Message.message_types[x.to_sym] }
end

View File

@ -1,4 +1,10 @@
module TimezoneHelper
def timezone_name_from_params(timezone, offset)
return timezone if timezone.present? && ActiveSupport::TimeZone[timezone].present?
timezone_name_from_offset(offset)
end
# ActiveSupport TimeZone is not aware of the current time, so ActiveSupport::Timezone[offset]
# would return the timezone without considering day light savings. To get the correct timezone,
# this method uses zone.now.utc_offset for comparison as referenced in the issues below

View File

@ -100,7 +100,9 @@ export default {
mql.onchange = e => setColorTheme(e.matches);
},
setLocale(locale) {
this.$root.$i18n.locale = locale;
if (locale) {
this.$root.$i18n.locale = locale;
}
},
async initializeAccount() {
await this.$store.dispatch('accounts/get');
@ -167,10 +169,4 @@ export default {
.v-popper--theme-tooltip .v-popper__arrow-container {
display: none;
}
.multiselect__input {
margin-bottom: 0px !important;
}
</style>
<style src="vue-multiselect/dist/vue-multiselect.css"></style>

View File

@ -25,6 +25,10 @@ class AgentBotsAPI extends ApiClient {
resetAccessToken(botId) {
return axios.post(`${this.url}/${botId}/reset_access_token`);
}
resetSecret(botId) {
return axios.post(`${this.url}/${botId}/reset_secret`);
}
}
export default new AgentBotsAPI();

View File

@ -31,6 +31,12 @@ class CaptainCustomTools extends ApiClient {
delete(id) {
return axios.delete(`${this.url}/${id}`);
}
test(data = {}) {
return axios.post(`${this.url}/test`, {
custom_tool: data,
});
}
}
export default new CaptainCustomTools();

View File

@ -57,14 +57,14 @@ class ContactAPI extends ApiClient {
return axios.post(`${this.url}/${contactId}/labels`, { labels });
}
search(search = '', page = 1, sortAttr = 'name', label = '') {
search(search = '', page = 1, sortAttr = 'name', label = '', options = {}) {
let requestURL = `${this.url}/search?${buildContactParams(
page,
sortAttr,
label,
search
)}`;
return axios.get(requestURL);
return axios.get(requestURL, { signal: options.signal });
}
active(page = 1, sortAttr = 'name') {

View File

@ -0,0 +1,71 @@
/* global axios */
import ApiClient from './ApiClient';
class GroupMembersAPI extends ApiClient {
constructor() {
super('contacts', { accountScoped: true });
}
getGroupMembers(contactId, page = 1) {
return axios.get(`${this.url}/${contactId}/group_members`, {
params: { page },
});
}
syncGroup(contactId) {
return axios.post(`${this.url}/${contactId}/sync_group`);
}
createGroup(params) {
return axios.post(`${this.baseUrl()}/groups`, params);
}
updateGroupMetadata(contactId, params) {
return axios.patch(`${this.url}/${contactId}/group_metadata`, params);
}
addMembers(contactId, participants) {
return axios.post(`${this.url}/${contactId}/group_members`, {
participants,
});
}
removeMembers(contactId, memberId) {
return axios.delete(`${this.url}/${contactId}/group_members/${memberId}`);
}
updateMemberRole(contactId, memberId, role) {
return axios.patch(`${this.url}/${contactId}/group_members/${memberId}`, {
role,
});
}
getInviteLink(contactId) {
return axios.get(`${this.url}/${contactId}/group_invite`);
}
revokeInviteLink(contactId) {
return axios.post(`${this.url}/${contactId}/group_invite/revoke`);
}
getPendingRequests(contactId) {
return axios.get(`${this.url}/${contactId}/group_join_requests`);
}
handleJoinRequest(contactId, params) {
return axios.post(
`${this.url}/${contactId}/group_join_requests/handle`,
params
);
}
leaveGroup(contactId) {
return axios.post(`${this.url}/${contactId}/group_admin/leave`);
}
updateGroupProperty(contactId, params) {
return axios.patch(`${this.url}/${contactId}/group_admin`, params);
}
}
export default new GroupMembersAPI();

View File

@ -25,6 +25,12 @@ class CategoriesAPI extends PortalsAPI {
delete({ portalSlug, categoryId }) {
return axios.delete(`${this.url}/${portalSlug}/categories/${categoryId}`);
}
reorder({ portalSlug, reorderedGroup }) {
return axios.post(`${this.url}/${portalSlug}/categories/reorder`, {
positions_hash: reorderedGroup,
});
}
}
export default new CategoriesAPI();

View File

@ -16,6 +16,7 @@ class ConversationApi extends ApiClient {
conversationType,
sortBy,
updatedWithin,
groupType,
}) {
return axios.get(this.url, {
params: {
@ -28,6 +29,7 @@ class ConversationApi extends ApiClient {
conversation_type: conversationType,
sort_by: sortBy,
updated_within: updatedWithin,
group_type: groupType,
},
});
}
@ -88,6 +90,16 @@ class ConversationApi extends ApiClient {
});
}
presenceSubscribe(conversationId) {
return axios.post(`${this.url}/${conversationId}/presence_subscribe`);
}
presenceSubscribeBulk(conversationIds) {
return axios.post(`${this.url}/presence_subscribe_bulk`, {
conversation_ids: conversationIds,
});
}
mute(conversationId) {
return axios.post(`${this.url}/${conversationId}/mute`);
}

View File

@ -125,6 +125,13 @@ class MessageApi extends ApiClient {
}
);
}
toggleReaction(conversationId, messageId, emoji, echoId) {
return axios.post(
`${this.url}/${conversationId}/messages/${messageId}/reactions`,
{ emoji, echo_id: echoId }
);
}
}
export default new MessageApi();

View File

@ -9,6 +9,10 @@ class InboxHealthAPI extends ApiClient {
getHealthStatus(inboxId) {
return axios.get(`${this.url}/${inboxId}/health`);
}
registerWebhook(inboxId) {
return axios.post(`${this.url}/${inboxId}/register_webhook`);
}
}
export default new InboxHealthAPI();

View File

@ -0,0 +1,25 @@
/* global axios */
const API_BASE = '/api/v1/profile/inbox_signatures';
export default {
getAll(accountId) {
return axios.get(API_BASE, {
params: { account_id: accountId },
});
},
get(inboxId) {
return axios.get(`${API_BASE}/${inboxId}`);
},
upsert(inboxId, params) {
return axios.put(`${API_BASE}/${inboxId}`, {
inbox_signature: params,
});
},
delete(inboxId) {
return axios.delete(`${API_BASE}/${inboxId}`);
},
};

View File

@ -43,6 +43,16 @@ class Inboxes extends CacheEnabledApiClient {
return axios.get(`${this.url}/${inboxId}/csat_template`);
}
analyzeCSATTemplateUtility(inboxId, template) {
return axios.post(`${this.url}/${inboxId}/csat_template/analyze`, {
template,
});
}
resetSecret(inboxId) {
return axios.post(`${this.url}/${inboxId}/reset_secret`);
}
linkCSATTemplate(inboxId, template) {
return axios.post(`${this.url}/${inboxId}/csat_template/link`, {
template,
@ -62,6 +72,13 @@ class Inboxes extends CacheEnabledApiClient {
disconnectChannelProvider(inboxId) {
return axios.post(`${this.url}/${inboxId}/disconnect_channel_provider`);
}
convertProvider(inboxId, { provider, providerConfig }) {
return axios.post(`${this.url}/${inboxId}/convert_provider`, {
provider,
provider_config: providerConfig,
});
}
}
export default new Inboxes();

View File

@ -0,0 +1,72 @@
/* global axios */
import ApiClient from './ApiClient';
class InternalChatChannelsAPI extends ApiClient {
constructor() {
super('internal_chat/channels', { accountScoped: true });
}
getWithParams(params) {
return axios.get(this.url, { params });
}
getCategories() {
return axios.get(`${this.url.replace('/channels', '/categories')}`);
}
createCategory(data) {
return axios.post(`${this.url.replace('/channels', '/categories')}`, data);
}
deleteCategory(categoryId) {
return axios.delete(
`${this.url.replace('/channels', '/categories')}/${categoryId}`
);
}
archive(channelId) {
return axios.post(`${this.url}/${channelId}/archive`);
}
unarchive(channelId) {
return axios.post(`${this.url}/${channelId}/unarchive`);
}
getMembers(channelId) {
return axios.get(`${this.url}/${channelId}/members`);
}
addMember(channelId, userId) {
return axios.post(`${this.url}/${channelId}/members`, { user_id: userId });
}
removeMember(channelId, memberId) {
return axios.delete(`${this.url}/${channelId}/members/${memberId}`);
}
updateMember(channelId, memberId, data) {
return axios.patch(`${this.url}/${channelId}/members/${memberId}`, data);
}
toggleTypingStatus(channelId, typingStatus) {
return axios.post(`${this.url}/${channelId}/toggle_typing_status`, {
typing_status: typingStatus,
});
}
markRead(channelId) {
return axios.post(`${this.url}/${channelId}/mark_read`);
}
markUnread(channelId, messageId) {
return axios.post(`${this.url}/${channelId}/mark_unread`, {
message_id: messageId,
});
}
search(params) {
return axios.get(`${this.url.replace('/channels', '/search')}`, { params });
}
}
export default new InternalChatChannelsAPI();

View File

@ -0,0 +1,24 @@
/* global axios */
import ApiClient from './ApiClient';
class InternalChatDraftsAPI extends ApiClient {
constructor() {
super('internal_chat', { accountScoped: true });
}
getDrafts() {
return axios.get(`${this.url}/drafts`);
}
saveDraft(channelId, data) {
return axios.patch(`${this.url}/channels/${channelId}/draft`, data);
}
deleteDraft(channelId, { parentId } = {}) {
return axios.delete(`${this.url}/channels/${channelId}/draft`, {
params: { parent_id: parentId },
});
}
}
export default new InternalChatDraftsAPI();

View File

@ -0,0 +1,62 @@
/* global axios */
import ApiClient from './ApiClient';
class InternalChatMessagesAPI extends ApiClient {
constructor() {
super('internal_chat/channels', { accountScoped: true });
}
getMessages(channelId, params = {}) {
return axios.get(`${this.url}/${channelId}/messages`, { params });
}
createMessage(channelId, data, files = []) {
if (files.length === 0) {
return axios.post(`${this.url}/${channelId}/messages`, data);
}
const formData = new FormData();
if (data.content) formData.append('content', data.content);
if (data.parent_id) formData.append('parent_id', data.parent_id);
if (data.echo_id) formData.append('echo_id', data.echo_id);
files.forEach(file => {
formData.append('attachments[][file]', file);
});
return axios.post(`${this.url}/${channelId}/messages`, formData);
}
updateMessage(channelId, messageId, data) {
return axios.patch(`${this.url}/${channelId}/messages/${messageId}`, data);
}
deleteMessage(channelId, messageId) {
return axios.delete(`${this.url}/${channelId}/messages/${messageId}`);
}
getThread(channelId, messageId) {
return axios.get(`${this.url}/${channelId}/messages/${messageId}/thread`);
}
pinMessage(channelId, messageId) {
return axios.post(`${this.url}/${channelId}/messages/${messageId}/pin`);
}
unpinMessage(channelId, messageId) {
return axios.delete(`${this.url}/${channelId}/messages/${messageId}/unpin`);
}
addReaction(messageId, emoji) {
const baseUrl = this.url.replace('/channels', '');
return axios.post(`${baseUrl}/messages/${messageId}/reactions`, {
emoji,
});
}
removeReaction(messageId, reactionId) {
const baseUrl = this.url.replace('/channels', '');
return axios.delete(
`${baseUrl}/messages/${messageId}/reactions/${reactionId}`
);
}
}
export default new InternalChatMessagesAPI();

View File

@ -0,0 +1,24 @@
/* global axios */
import ApiClient from './ApiClient';
class InternalChatPollsAPI extends ApiClient {
constructor() {
super('internal_chat/polls', { accountScoped: true });
}
createPoll(data) {
return axios.post(this.url, data);
}
vote(pollId, optionId) {
return axios.post(`${this.url}/${pollId}/vote`, { option_id: optionId });
}
unvote(pollId, optionId) {
return axios.delete(`${this.url}/${pollId}/vote`, {
params: { option_id: optionId },
});
}
}
export default new InternalChatPollsAPI();

View File

@ -0,0 +1,81 @@
/* global axios */
import ApiClient from './ApiClient';
export const buildRecurringScheduledMessagePayload = ({
content,
status,
scheduledAt,
templateParams,
attachment,
removeAttachment,
recurrenceRule,
} = {}) => {
if (!attachment) {
return {
content,
status,
scheduled_at: scheduledAt,
template_params: templateParams,
remove_attachment: removeAttachment || undefined,
recurrence_rule: recurrenceRule,
};
}
const payload = new FormData();
if (content) payload.append('content', content);
if (scheduledAt) payload.append('scheduled_at', scheduledAt);
if (status) payload.append('status', status);
payload.append('attachment', attachment);
if (templateParams) {
payload.append('template_params', JSON.stringify(templateParams));
}
if (recurrenceRule) {
Object.entries(recurrenceRule).forEach(([key, value]) => {
if (Array.isArray(value)) {
value.forEach(v =>
payload.append(`recurrence_rule[${key}][]`, String(v))
);
} else {
payload.append(`recurrence_rule[${key}]`, String(value));
}
});
}
return payload;
};
class RecurringScheduledMessagesAPI extends ApiClient {
constructor() {
super('conversations', { accountScoped: true });
}
get(conversationId) {
return axios.get(
`${this.baseUrl()}/conversations/${conversationId}/recurring_scheduled_messages`
);
}
create(conversationId, payload) {
return axios({
method: 'post',
url: `${this.baseUrl()}/conversations/${conversationId}/recurring_scheduled_messages`,
data: buildRecurringScheduledMessagePayload(payload),
});
}
update(conversationId, recurringScheduledMessageId, payload) {
return axios({
method: 'patch',
url: `${this.baseUrl()}/conversations/${conversationId}/recurring_scheduled_messages/${recurringScheduledMessageId}`,
data: buildRecurringScheduledMessagePayload(payload),
});
}
delete(conversationId, recurringScheduledMessageId) {
return axios.delete(
`${this.baseUrl()}/conversations/${conversationId}/recurring_scheduled_messages/${recurringScheduledMessageId}`
);
}
}
export default new RecurringScheduledMessagesAPI();

View File

@ -7,6 +7,7 @@ export const buildScheduledMessagePayload = ({
scheduledAt,
templateParams,
attachment,
removeAttachment,
} = {}) => {
if (!attachment) {
return {
@ -14,6 +15,7 @@ export const buildScheduledMessagePayload = ({
status,
scheduled_at: scheduledAt,
template_params: templateParams,
remove_attachment: removeAttachment || undefined,
};
}

View File

@ -68,7 +68,19 @@ describe('#ContactsAPI', () => {
it('#search', () => {
contactAPI.search('leads', 1, 'date', 'customer-support');
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/contacts/search?include_contact_inboxes=false&page=1&sort=date&q=leads&labels[]=customer-support'
'/api/v1/contacts/search?include_contact_inboxes=false&page=1&sort=date&q=leads&labels[]=customer-support',
{ signal: undefined }
);
});
it('#search with signal', () => {
const controller = new AbortController();
contactAPI.search('leads', 1, 'date', 'customer-support', {
signal: controller.signal,
});
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/contacts/search?include_contact_inboxes=false&page=1&sort=date&q=leads&labels[]=customer-support',
{ signal: controller.signal }
);
});

View File

@ -8,5 +8,6 @@ describe('#BulkActionsAPI', () => {
expect(categoriesAPI).toHaveProperty('create');
expect(categoriesAPI).toHaveProperty('update');
expect(categoriesAPI).toHaveProperty('delete');
expect(categoriesAPI).toHaveProperty('reorder');
});
});

View File

@ -66,7 +66,7 @@ textarea {
// Field base styles (Input, TextArea, Select)
@layer components {
.field-base {
@apply block box-border w-full transition-colors duration-[0.25s] ease-[ease-in-out] focus:outline-n-brand dark:focus:outline-n-brand appearance-none mx-0 mt-0 mb-4 py-2 px-3 rounded-lg text-base font-normal bg-n-alpha-black2 placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 text-n-slate-12 border-none outline outline-1 outline-n-weak dark:outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6;
@apply block box-border w-full transition-colors duration-[0.25s] ease-[ease-in-out] focus:outline-n-brand dark:focus:outline-n-brand appearance-none mx-0 mt-0 mb-4 py-2 px-3 rounded-lg text-sm font-normal bg-n-alpha-black2 placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 text-n-slate-12 border-none outline outline-1 outline-n-weak dark:outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6;
}
.field-disabled {
@ -78,7 +78,7 @@ textarea {
}
}
$form-input-selector: "input[type]:not([type='file']):not([type='checkbox']):not([type='radio']):not([type='range']):not([type='button']):not([type='submit']):not([type='reset']):not([type='color']):not([type='image']):not([type='hidden']):not(.reset-base):not(.multiselect__input):not(.no-margin)";
$form-input-selector: "input[type]:not([type='file']):not([type='checkbox']):not([type='radio']):not([type='range']):not([type='button']):not([type='submit']):not([type='reset']):not([type='color']):not([type='image']):not([type='hidden']):not(.reset-base):not(.no-margin)";
#{$form-input-selector} {
@apply field-base h-10;
@ -92,7 +92,7 @@ $form-input-selector: "input[type]:not([type='file']):not([type='checkbox']):not
}
}
input[type='file']:not(.multiselect__input) {
input[type='file'] {
@apply leading-[1.15] mb-4 border-0 bg-transparent text-sm;
}
@ -106,6 +106,10 @@ select {
&[disabled] {
@apply field-disabled;
}
option:not(:disabled) {
@apply bg-n-solid-2 text-n-slate-12;
}
}
// Textarea
@ -126,13 +130,6 @@ label:has(.help-text) {
}
}
// Error handling
.has-multi-select-error {
div.multiselect {
@apply mb-1;
}
}
// FormKit support
.formkit-outer[data-invalid='true'] {
#{$form-input-selector},
@ -150,9 +147,7 @@ label:has(.help-text) {
#{$form-input-selector},
input:not([type]),
textarea,
select,
.multiselect > .multiselect__tags,
.multiselect:not(.no-margin) {
select {
@apply field-error;
}

View File

@ -13,7 +13,6 @@
@import 'base';
// Plugins
@import 'plugins/multiselect';
@import 'plugins/date-picker';
html,
@ -66,4 +65,84 @@ body {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
/**
* ============================================================================
* TYPOGRAPHY UTILITIES
* ============================================================================
*
* | Class | Use Case |
* |--------------------|----------------------------------------------------|
* | .text-body-main | <p>, <span>, general body text |
* | .text-body-para | <p> for paragraphs, larger text blocks |
* | .text-heading-1 | <h1>, page titles, panel headers |
* | .text-heading-2 | <h2>, section headings, card titles |
* | .text-heading-3 | <h3>, card headings, breadcrumbs, subsections |
* | .text-label | <label>, form labels, field names |
* | .text-label-small | <small>, footnotes, tags, badges, captions |
* | .text-button | <button>, standard button text |
* | .text-button-small | <button>, small/compact button text |
*/
/* body-text-main: Main text style for general body text */
.text-body-main {
@apply font-inter text-sm font-420;
line-height: 21px; /* 150% */
letter-spacing: -0.28px;
}
/* body-text-paragraph: For paragraphs or larger blocks of text */
.text-body-para {
@apply font-inter text-sm font-420;
line-height: 21px; /* 150% */
letter-spacing: -0.21px;
}
/* heading-1: Large heading for pages and panels */
.text-heading-1 {
@apply font-inter text-lg font-520;
line-height: 24px; /* 133.333% */
letter-spacing: -0.27px;
}
/* heading-2: Secondary heading for sections */
.text-heading-2 {
@apply font-inter text-base font-medium;
line-height: 24px; /* 133.333% */
letter-spacing: -0.27px;
}
/* heading-3: For card headings, breadcrumbs, subsections */
.text-heading-3 {
@apply font-inter text-sm font-medium;
line-height: 21px; /* 150% */
letter-spacing: -0.27px;
}
/* label: Standard label text for form fields */
.text-label {
@apply font-inter text-sm font-medium;
line-height: 21px; /* 150% */
}
/* label-small: Smallest font for labels, footnotes, tags */
.text-label-small {
@apply font-inter text-xs font-440;
line-height: 16px; /* 133.333% */
letter-spacing: -0.24px;
}
/* button-text: Text for standard size buttons */
.text-button {
@apply font-inter text-sm font-460;
line-height: 21px; /* 150% */
letter-spacing: -0.28px;
}
/* button-text-small: Text for smaller buttons */
.text-button-small {
@apply font-inter text-xs font-440;
line-height: 18px; /* 150% */
letter-spacing: -0.24px;
}
}

View File

@ -1,273 +0,0 @@
@mixin label-multiselect-hover {
&::after {
@apply text-n-brand;
}
&:hover {
@apply bg-n-slate-3;
&::after {
@apply text-n-blue-11;
}
}
}
.multiselect {
&:not(.no-margin) {
@apply mb-4;
}
&.invalid .multiselect__tags {
@apply border-0 outline outline-1 outline-n-ruby-8 dark:outline-n-ruby-8 hover:outline-n-ruby-9 dark:hover:outline-n-ruby-9 disabled:outline-n-ruby-8 dark:disabled:outline-n-ruby-8;
}
&.multiselect--disabled {
@apply opacity-50 rounded-lg cursor-not-allowed pointer-events-auto;
.multiselect__select {
@apply cursor-not-allowed bg-transparent rounded-lg;
}
}
.multiselect--active {
> .multiselect__tags {
@apply outline-n-blue-border;
}
}
.multiselect__select {
@apply min-h-[2.875rem] p-0 right-0 top-0;
&::before {
@apply right-0;
}
}
.multiselect__content-wrapper {
@apply bg-n-alpha-black2 text-n-slate-12 backdrop-blur-[100px] border-0 border-none outline outline-1 outline-n-weak rounded-b-lg;
}
.multiselect__content {
@apply max-w-full;
.multiselect__option {
@apply text-sm font-normal flex justify-between items-center;
span {
@apply inline-block overflow-hidden text-ellipsis whitespace-nowrap w-fit;
}
p {
@apply mb-0;
}
&::after {
@apply bottom-0 flex items-center justify-center text-center relative px-1 leading-tight;
}
&.multiselect__option--highlight {
@apply bg-n-alpha-black2 text-n-slate-12;
}
&.multiselect__option--highlight:hover {
@apply bg-n-brand/10 text-n-slate-12;
&::after {
@apply bg-transparent text-center text-n-slate-12;
}
}
&.multiselect__option--highlight::after {
@apply bg-transparent text-n-slate-12;
}
&.multiselect__option--selected {
@apply bg-n-brand/20 text-n-slate-12;
&::after {
@apply bg-transparent;
}
&.multiselect__option--highlight:hover {
@apply bg-transparent;
&::after:hover {
@apply text-n-slate-12 bg-transparent;
}
}
}
}
}
.multiselect__tags {
@apply bg-n-alpha-black2 border-0 grid items-center w-full border-none outline-1 outline outline-n-weak hover:outline-n-slate-6 m-0 min-h-[2.875rem] rounded-lg pt-0;
input {
@apply border-0 border-none bg-transparent dark:bg-transparent text-n-slate-12 placeholder:text-n-slate-10;
}
}
.multiselect__spinner {
background-color: transparent;
}
.multiselect__tags-wrap {
@apply inline-block leading-none mt-1;
}
.multiselect__placeholder {
@apply text-n-slate-10 font-normal pt-3;
}
.multiselect__tag {
@apply bg-n-alpha-white mt-1 text-n-slate-12 pr-6 pl-2.5 py-1.5;
}
.multiselect__tag-icon {
@include label-multiselect-hover;
}
.multiselect__input {
@apply text-sm h-[2.875rem] mb-0 p-0 shadow-none border-transparent hover:border-transparent hover:shadow-none focus:border-transparent focus:shadow-none active:border-transparent active:shadow-none;
}
.multiselect__single {
@apply bg-transparent text-n-slate-12 inline-block mb-0 py-3 px-2.5 overflow-hidden whitespace-nowrap text-ellipsis;
}
}
.sidebar-labels-wrap {
&.has-edited,
&:hover {
.multiselect {
@apply cursor-pointer;
}
}
.multiselect {
> .multiselect__select {
@apply invisible;
}
> .multiselect__tags {
@apply outline-transparent;
}
&.multiselect--active > .multiselect__tags {
@apply outline-n-blue-border;
}
}
}
.multiselect-wrap--small {
// To be removed one SLA reports date picker is created
&.tiny {
.multiselect.no-margin {
@apply min-h-[32px];
}
.multiselect__select {
@apply min-h-[32px] h-8;
&::before {
@apply top-[60%];
}
}
.multiselect__tags {
@apply min-h-[32px] max-h-[32px];
.multiselect__single {
@apply pt-1 pb-1;
}
}
}
.multiselect__tags,
.multiselect__input,
.multiselect {
@apply text-n-slate-12 rounded-lg text-sm min-h-[2.5rem];
}
.multiselect__input {
@apply h-[2.375rem] min-h-[2.375rem];
}
.multiselect__single {
@apply items-center flex m-0 text-sm max-h-[2.375rem] bg-transparent text-n-slate-12 py-3 px-0.5;
}
.multiselect__placeholder {
@apply m-0 py-2 px-0.5;
}
.multiselect__tag {
@apply py-[6px] my-[1px];
}
.multiselect__select {
@apply min-h-[2.5rem];
}
.multiselect--disabled .multiselect__current,
.multiselect--disabled .multiselect__select {
@apply bg-transparent;
}
}
.multiselect--disabled {
background-color: rgba(var(--black-alpha-2)) !important;
.multiselect__tags {
@apply hover:outline-n-weak;
}
}
.multiselect--active {
.multiselect__select::before {
@apply top-[62%];
}
}
.multiselect__select::before {
top: 60% !important;
}
.multiselect-wrap--medium {
.multiselect__tags,
.multiselect__input {
@apply items-center flex;
}
.multiselect__tags,
.multiselect__input,
.multiselect {
@apply bg-n-alpha-black2 text-n-slate-12 text-sm h-12 min-h-[3rem];
}
.multiselect__input {
@apply h-[2.875rem] min-h-[2.875rem];
margin-bottom: 0 !important;
}
.multiselect__single {
@apply items-center flex m-0 text-sm py-1 px-0.5 bg-transparent text-n-slate-12;
}
.multiselect__placeholder {
@apply m-0 py-1 px-0.5;
}
.multiselect__select {
@apply min-h-[3rem];
}
.multiselect--disabled .multiselect__current,
.multiselect--disabled .multiselect__select {
@apply bg-transparent;
}
.multiselect__tags-wrap {
@apply flex-shrink-0;
}
}

View File

@ -49,7 +49,7 @@ const handleFetchUsers = () => {
<div class="flex flex-col gap-2 relative justify-between w-full">
<div class="flex items-center gap-3 justify-between w-full">
<div class="flex items-center gap-3">
<h3 class="text-base font-medium text-n-slate-12 line-clamp-1">
<h3 class="text-heading-2 text-n-slate-12 line-clamp-1">
{{ name }}
</h3>
<CardPopover
@ -78,7 +78,7 @@ const handleFetchUsers = () => {
<Button icon="i-lucide-trash" sm slate ghost @click="handleDelete" />
</div>
</div>
<p class="text-n-slate-11 text-sm line-clamp-1 mb-0 py-1">
<p class="text-n-slate-11 text-body-para line-clamp-1 mb-0 py-1">
{{ description }}
</p>
</div>

View File

@ -20,7 +20,7 @@ const handleClick = () => {
<CardLayout class="[&>div]:px-5 cursor-pointer" @click="handleClick">
<div class="flex flex-col items-start gap-2">
<div class="flex justify-between w-full items-center">
<h3 class="text-n-slate-12 text-base font-medium">{{ title }}</h3>
<h3 class="text-n-slate-12 text-heading-2">{{ title }}</h3>
<Button
xs
slate
@ -29,14 +29,14 @@ const handleClick = () => {
@click.stop="handleClick"
/>
</div>
<p class="text-n-slate-11 text-sm mb-0">{{ description }}</p>
<p class="text-n-slate-11 text-body-para mb-0">{{ description }}</p>
</div>
<ul class="flex flex-col items-start gap-3 mt-3">
<li
v-for="feature in features"
:key="feature.id"
class="flex items-center gap-3 text-sm"
class="flex items-center gap-3 text-body-para"
>
<Icon
:icon="feature.icon"

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