Compare commits

...
Author SHA1 Message Date
Sojan JoseandGitHub 3b1e387509 Merge branch 'develop' into chore/update-rails 2026-02-03 21:14:55 -08:00
Sojan Jose 9eb3ee44a8 Revert "chore: Upgrade Rails to 7.2.2 and update Gemfile dependencies (#11037)"
This reverts commit ef6ba8aabd.
2026-02-03 21:09:42 -08:00
Sojan JoseandGitHub ef6ba8aabd chore: Upgrade Rails to 7.2.2 and update Gemfile dependencies (#11037)
Upgrade rails to 7.2.2 so that we can proceed with the rails 8 upgrade
afterwards
 
 # Changelog
- `.circleci/config.yml` — align CI DB setup with GitHub Actions
(`db:create` + `db:schema:load`) to avoid trigger-dependent prep steps.
- `.rubocop.yml` — add `rubocop-rspec_rails` and disable new cops that
don't match existing spec style.
- `AGENTS.md` — document that specs should run without `.env` (rename
temporarily when present).
- `Gemfile` — upgrade to Rails 7.2, switch Azure storage gem, pin
`commonmarker`, bump `sidekiq-cron`, add `rubocop-rspec_rails`, and
relax some gem pins.
- `Gemfile.lock` — dependency lockfile updates from the Rails 7.2 and
gem changes.
- `app/controllers/api/v1/accounts/integrations/linear_controller.rb` —
stringify params before passing to the Linear service to keep key types
stable.
- `app/controllers/super_admin/instance_statuses_controller.rb` — use
`MigrationContext` API for migration status in Rails 7.2.
- `app/models/installation_config.rb` — add commentary on YAML
serialization and future JSONB migration (no behavior change).
- `app/models/integrations/hook.rb` — ensure hook type is set on create
only and guard against missing app.
- `app/models/user.rb` — update enum syntax for Rails 7.2 deprecation,
serialize OTP backup codes with JSON, and use Ruby `alias`.
- `app/services/crm/leadsquared/setup_service.rb` — stringify hook
settings keys before merge to keep JSON shape consistent.
- `app/services/macros/execution_service.rb` — remove macro-specific
assignee activity workaround; rely on standard assignment handlers.
- `config/application.rb` — load Rails 7.2 defaults.
- `config/storage.yml` — update Azure Active Storage service name to
`AzureBlob`.
- `db/migrate/20230515051424_update_article_image_keys.rb` — use
credentials `secret_key_base` with fallback to legacy secrets.
- `docker/Dockerfile` — add `yaml-dev` and `pkgconf` packages for native
extensions (Ruby 3.4 / psych).
- `lib/seeders/reports/message_creator.rb` — add parentheses for clarity
in range calculation.
- `package.json` — pin Vite version and bump `vite-plugin-ruby`.
- `pnpm-lock.yaml` — lockfile changes from JS dependency updates.
- `spec/builders/v2/report_builder_spec.rb` — disable transactional
fixtures; truncate tables per example via Rails `truncate_tables` so
after_commit callbacks run with clean isolation; keep builder spec
metadata minimal.
- `spec/builders/v2/reports/label_summary_builder_spec.rb` — disable
transactional fixtures + truncate tables via Rails `truncate_tables`;
revert to real `resolved!`/`open!`/`resolved!` flow for multiple
resolution events; align date range to `Time.zone` to avoid offset gaps;
keep builder spec metadata minimal.
- `spec/controllers/api/v1/accounts/macros_controller_spec.rb` — assert
`assignee_id` instead of activity message to avoid transaction-timing
flakes.
- `spec/services/telegram/incoming_message_service_spec.rb` — reference
the contact tied to the created conversation instead of
`Contact.all.first` to avoid order-dependent failures when other specs
leave data behind.
-
`spec/mailers/administrator_notifications/shared/smtp_config_shared.rb`
— use `with_modified_env` instead of stubbing mailer internals.
- `spec/services/account/sign_up_email_validation_service_spec.rb` —
compare error `class.name` for parallel/reload-safe assertions.
2026-02-03 14:29:26 -08:00
Sojan Jose a7c8a7bc26 fix(specs): scope whatsapp cloud contact assertions 2026-02-03 07:47:50 -08:00
Sojan Jose b1e1448b8a fix(specs): avoid raw sql in contact inbox spec 2026-02-02 23:15:49 -08:00
Sojan Jose c837dd5cb7 fix(search): guard message reindex without searchkick 2026-02-02 23:15:37 -08:00
Sojan Jose dd31bfa572 fix(specs): stabilize bulk actions job specs 2026-02-02 23:02:07 -08:00
Sojan JoseandGitHub 97a53f987b Merge branch 'develop' into chore/update-rails 2026-02-02 16:31:59 -08:00
Vishnu NarayananandGitHub c884cdefde feat: add per-account daily rate limit for outbound emails (#13411)
Introduce a daily cap on non-channel outbound emails to prevent abuse.

Fixes https://linear.app/chatwoot/issue/CW-6418/ses-incident-jan-28

## Type of change

- [x] New feature (non-breaking change which adds functionality)
- [x] Breaking change (fix or feature that would cause existing
functionality not to work as expected)

## Summary
- Adds a Redis-based daily counter to rate limit outbound emails per
account, preventing email abuse
- Covers continuity emails (WebWidget/API), conversation transcripts,
and agent notifications
  - Email channel replies are excluded (paid feature, not abusable)
- Adds account suspension check in `ConversationReplyMailer` to block
already-queued emails for suspended accounts

  ## Limit Resolution Hierarchy
1. Per-account override (`account.limits['emails']`) — SuperAdmin
configurable
2. Enterprise plan-based (`ACCOUNT_EMAILS_PLAN_LIMITS`
InstallationConfig)
3. Global default (`ACCOUNT_EMAILS_LIMIT` InstallationConfig, default:
100)
  4. Fallback (`ChatwootApp.max_limit` — effectively unlimited)

  ## Enforcement Points
  | Path | Where | Behavior |
  |------|-------|----------|
| WebWidget/API continuity |
`SendEmailNotificationService#should_send_email_notification?` |
Silently skipped |
| Widget transcript | `Widget::ConversationsController#transcript` |
Returns 429 |
| API transcript | `ConversationsController#transcript` | Returns 429 |
| Agent notifications | `Notification::EmailNotificationService#perform`
| Silently skipped |
  | Email channel replies | Not rate limited | Paid feature |
| Suspended accounts | `ConversationReplyMailer` | Blocked at mailer
level |
2026-02-03 02:06:51 +05:30
c77d935e38 fix: Subscribe app to WABA before overriding webhook callback URL (#13279)
#### Problem
Meta requires the app to be subscribed to the WABA before
`override_callback_uri` can be used. The current implementation tries to
use `override_callback_uri` directly, which fails with:

> Error 100: "Before override the current callback uri, your app must be
subscribed to receive messages for WhatsApp Business Account"

This causes embedded signup to fail silently, the inbox appears
connected but never receives messages.

  #### Solution

  Split `subscribe_waba_webhook` into two sequential API calls:

  ```ruby
  def subscribe_waba_webhook(waba_id, callback_url, verify_token)
    # Step 1: Subscribe app to WABA first (required before override)
    subscribe_app_to_waba(waba_id)

    # Step 2: Override callback URL for this specific WABA
    override_waba_callback(waba_id, callback_url, verify_token)
  end
```

#### References
  - Subscribe app to WABA's webhooks: https://www.postman.com/meta/whatsapp-business-platform/request/ju40fld/subscribe-app-to-waba-s-webhooks
  - Override Callback URL (Embedded Signup): https://www.postman.com/meta/whatsapp-business-platform/request/l6a09ow/override-callback-url

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-02-02 16:50:35 +05:30
Muhsin KelothandGitHub b686d14044 feat: Handle external echo messages from native apps (#13371)
When businesses use WhatsApp Business App (co-existence mode) or
Instagram App or TikTok alongside Chatwoot, messages sent from the
native apps were not synced properly back to Chatwoot. This left agents
with an incomplete conversation history and no visibility into responses
sent outside the dashboard. Additionally, if these echo messages did
arrive, they appeared as "Sent by: Bot" in the UI since they had no
sender, making it confusing for agents.

This PR subscribes to WhatsApp `smb_message_echoes` webhook events and
routes them through the existing service with an `outgoing_echo` flag,
mirroring how Instagram already handles echoes. On the Instagram side,
echo messages now also carry the `external_echo` content attribute and
`delivered` status.

On the frontend, messages with `externalEcho` are distinguished from bot
messages showing a "Native app" avatar and an advisory note encouraging
agents to reply from Chatwoot to maintain the service window.

<img width="1518" height="524" alt="CleanShot 2026-01-29 at 13 37 57@2x"
src="https://github.com/user-attachments/assets/5aa0b552-6382-441f-96aa-9a62ca716e4a"
/>


Fixes
https://linear.app/chatwoot/issue/CW-4204/display-messages-not-sent-from-chatwoot-in-case-of-outgoing-echo
Fixes
https://linear.app/chatwoot/issue/PLA-33/incoming-from-me-messages-from-whatsapp-business-app-are-not-falling
2026-02-02 15:52:53 +05:30
Shivam MishraandGitHub 133fb1bcf6 feat: add mark pending action to automation (#13378) 2026-02-02 11:59:51 +05:30
Sojan JoseandGitHub 5e2fc6e339 Merge branch 'develop' into chore/update-rails 2026-01-30 15:50:33 -08:00
PranavandGitHub e9e6de5690 fix: Increase the parallelism config to fix flaky tests, revert bad commits (#13410)
The specs break only in Circle CI, we have to figure out the root cause
for the same. At the moment, I have increased the parallelism to fix
this.
2026-01-30 12:49:31 -08:00
PranavandGitHub 329b749702 Add API documentation for inbox, agent, and team summary report (#13409)
- Add API documentation for inbox, agent, and team summary report
endpoints
- These endpoints return conversation statistics grouped by
inbox/agent/team for a given date range

Endpoints documented:
GET /api/v2/accounts/{account_id}/summary_reports/inbox │ Conversation
stats grouped by inbox │
GET /api/v2/accounts/{account_id}/summary_reports/agent │ Conversation
stats grouped by agent │
GET /api/v2/accounts/{account_id}/summary_reports/team │ Conversation
stats grouped by team │

Query parameters (all endpoints):
- since - Start timestamp (Unix)
- until - End timestamp (Unix)
- business_hours - Calculate metrics using business hours only

Response fields:
- id - Inbox/Agent/Team ID
- conversations_count - Total conversations in date range
- resolved_conversations_count - Resolved conversations in date range
- avg_resolution_time - Average resolution time (seconds)
- avg_first_response_time - Average first response time (seconds)
- avg_reply_time - Average reply time (seconds)
2026-01-30 22:48:10 +04:00
PranavandGitHub d8c5dda36c chore: Update report documentation (#13408)
New API Documentation

GET
/api/v2/accounts/{account_id}/reports/first_response_time_distribution
  - Returns first response time distribution grouped by channel type
- Shows conversation counts in time buckets: 0-1h, 1-4h, 4-8h, 8-24h,
24h+
  - Parameters: since, until (Unix timestamps)

  GET /api/v2/accounts/{account_id}/reports/inbox_label_matrix
  - Returns a matrix of conversation counts for inbox-label combinations
  - Parameters: since, until, inbox_ids[], label_ids[]

  Fixes

  - Removed unused business_hours boolean parameter from
  /api/v2/accounts/{account_id}/summary_reports/channel
- Updated ReDoc script from unstable @next to stable @2.1.5 version to
fix empty swagger page
2026-01-30 22:33:03 +04:00
5ec77aca64 feat: Add first response time distribution report endpoint (#13400)
The index is already added in production.

Adds a new reporting API that returns conversation counts grouped by
channel type and first response time buckets (0-1h, 1-4h, 4-8h, 8-24h,
24h+).

- GET /api/v2/accounts/:id/reports/first_response_time_distribution
- Uses SQL aggregation to handle large datasets efficiently
- Adds composite index on reporting_events for query performance

Tested on production workload.
Request: GET
`/api/v2/accounts/1/reports/first_response_time_distribution?since=<since>&until=<until>`
Response payload:
```
{
    "Channel::WebWidget": {
      "0-1h": 120,
      "1-4h": 85,
      "4-8h": 32,
      "8-24h": 12,
      "24h+": 3
    },
    "Channel::Email": {
      "0-1h": 12,
      "1-4h": 28,
      "4-8h": 45,
      "8-24h": 35,
      "24h+": 10
    },
    "Channel::FacebookPage": {
      "0-1h": 50,
      "1-4h": 30,
      "4-8h": 15,
      "8-24h": 8,
      "24h+": 2
    }
  }
```

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-01-30 22:22:27 +04:00
Sivin VargheseandGitHub 85324c82fa fix: Formatting issue with reply preview content (#13399) 2026-01-30 16:35:32 +05:30
81307d5aea feat: search documentation tool for reply suggestions (#13340)
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2026-01-30 16:18:33 +05:30
6f45af605c feat: Add inbox-label matrix report endpoint (#13394)
This PR added new API endpoint GET
/api/v2/accounts/:account_id/reports/inbox_label_matrix that returns
conversation counts grouped by inbox and label in a matrix format.
Supports optional filtering by date range, inbox_ids, and label_ids.

---------

Co-authored-by: Pranav <pranav@chatwoot.com>
2026-01-29 13:32:59 -08:00
a32565d72b fix: velma connection limit (#13395)
## Description 
Make the $velma Redis connection pool size configurable via
`REDIS_VELMA_SIZE` environment variable (default: 5, matching current
behavior)
The $velma pool is used exclusively by Rack::Attack for rate limiting
and was the only Redis pool with a hardcoded size

## Fixes

Under high traffic, the hardcoded $velma pool (size: 5) causes
connection contention. Every HTTP request passes through Rack::Attack
middleware, which requires a $velma Redis connection. When
`WEB_CONCURRENCY=2` and `RAILS_MAX_THREADS=10` (20 concurrent threads),
the 4:1 thread-to-connection ratio causes threads to queue for up to 1
second (the pool timeout), resulting in intermittent request latency
spikes during traffic bursts.

The $alfred pool was already configurable via REDIS_ALFRED_SIZE — this
change brings $velma to parity.


## Type of change

- [ ] Bug fix (non-breaking change which fixes an issue)


## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules



<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Low risk: changes only Redis connection pool sizing for Rack::Attack;
misconfiguration could cause rate-limiting Redis contention or extra
connections but no data/auth logic changes.
> 
> **Overview**
> Makes the `velma` Redis connection pool (used by Rack::Attack)
configurable via a new `REDIS_VELMA_SIZE` env var, replacing the
previously hardcoded pool size.
> 
> Documents `REDIS_VELMA_SIZE` in `.env.example` alongside the existing
`REDIS_ALFRED_SIZE` setting.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
dcbc946f2e1d7356dc743178ca46cdf12cb25c78. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com>
2026-01-29 20:53:41 +05:30
Sojan Jose d94392a277 test(telegram): stabilize contact assertions 2026-01-25 23:44:00 -08:00
Sojan Jose f2ff78ee47 test(reports): stabilize builder specs 2026-01-25 16:36:56 -08:00
Sojan Jose a161fdd3e3 test(macros): simplify team setup in macro spec 2026-01-24 02:56:48 -08:00
Sojan Jose a7cd958087 test(macros): assert assignee id in macro execution 2026-01-24 02:51:25 -08:00
Sojan Jose b0784413e9 test(mailers): set smtp env in shared context 2026-01-24 01:52:26 -08:00
Sojan Jose 2e37ecacba fix(integrations): normalize linear issue params 2026-01-24 01:41:02 -08:00
Sojan Jose 195dde9fde docs: note .env handling for specs 2026-01-24 01:23:56 -08:00
Sojan Jose 0a96b31f7f fix(integrations): set hook_type only on create 2026-01-24 01:09:24 -08:00
Sojan Jose eadd831c95 test(reports): stabilize report specs 2026-01-24 00:57:08 -08:00
Sojan Jose 0dadaccb9e Merge remote-tracking branch 'origin/develop' into chore/update-rails 2026-01-23 23:02:16 -08:00
Sojan Jose 7d1f69632e style(integrations): satisfy rubocop hook guard 2026-01-22 22:21:25 -08:00
Sojan Jose 05a5d81ad2 fix(integrations): avoid readonly hook_type updates 2026-01-22 21:56:07 -08:00
Sojan Jose ab010ab71c revert(integrations): keep hook_type assignment behavior 2026-01-22 20:33:14 -08:00
Sojan JoseandGitHub 69fabf0efd Merge branch 'develop' into chore/update-rails 2026-01-22 18:48:54 -08:00
Sojan Jose 9e53743592 test: stabilize mailer and signup validation specs 2026-01-22 18:32:08 -08:00
Sojan Jose 52d403d37c chore(vite): align vite plugin versions 2026-01-22 17:45:02 -08:00
Sojan JoseandGitHub 9ad83d101e Merge branch 'develop' into chore/update-rails 2026-01-22 09:09:25 -08:00
Sojan Jose 6caa572c24 Revert "chore(vite): upgrade vite deps"
This reverts commit 0d0abf6808.
2026-01-22 09:08:00 -08:00
Sojan Jose 0d0abf6808 chore(vite): upgrade vite deps 2026-01-21 17:44:01 -08:00
Sojan Jose b167e24d2b merge: develop 2026-01-21 09:18:06 -08:00
Sojan Jose ffe323261c revert(config): drop config loader guard 2026-01-20 23:51:54 -08:00
Sojan Jose 657afe0ffe test(reports): avoid update_column in specs 2026-01-20 23:38:54 -08:00
Sojan Jose e244d0c56d test(reports): stabilize metrics specs 2026-01-20 23:18:41 -08:00
Sojan Jose 59bf85d993 revert: action cable broadcast guard 2026-01-20 20:47:52 -08:00
Sojan Jose c8bbd6bbd8 test(linear): accept params in spec 2026-01-20 20:46:05 -08:00
Sojan Jose a1a355bc90 chore(linear): revert params to_h 2026-01-20 20:34:11 -08:00
Sojan JoseandGitHub b05311a169 Merge branch 'develop' into chore/update-rails 2026-01-20 16:48:14 -08:00
Sojan Jose 77b286c41f Install libyaml build deps in Docker 2026-01-18 19:23:37 -08:00
Sojan Jose 3cf252a2fa Skip seeds in chatwoot_prepare for test 2026-01-16 15:59:22 -08:00
Sojan Jose 491eaa5bce Run chatwoot_prepare in test env on CircleCI 2026-01-16 14:33:40 -08:00
Sojan Jose 8e4483c722 Revert "Update commonmarker"
This reverts commit dd98bb398e.
2026-01-16 13:31:30 -08:00
Sojan Jose dd98bb398e Update commonmarker 2026-01-16 01:16:46 -08:00
Sojan Jose 7061c3dde0 Reduce rubocop diff 2026-01-16 01:09:07 -08:00
Sojan Jose 9ef476c6d8 Guard config loader during cache clear 2026-01-16 01:01:04 -08:00
Sojan Jose 88fc18617d Fix macro assignment spec 2026-01-16 00:40:14 -08:00
Sojan Jose f5a81964c3 Pass hash params to Linear processor 2026-01-16 00:33:18 -08:00
Sojan Jose 251ef4f8bb Fix RuboCop spec offenses 2026-01-16 00:26:21 -08:00
Sojan Jose 45533f6bf8 Guard ConfigLoader against re-entrance 2026-01-16 00:19:36 -08:00
Sojan Jose 1748c668cf Fix ActionCable broadcast and LeadSquared setup 2026-01-16 00:11:39 -08:00
Sojan Jose be752af4c5 Fix user conversations alias 2026-01-15 23:51:05 -08:00
Sojan Jose 68529278e0 Update vulnerable gems 2026-01-15 23:50:19 -08:00
Sojan Jose d27c78068c Fix serialize coder for otp backup codes 2026-01-15 23:45:16 -08:00
Sojan Jose 3eeab14136 Fix RuboCop config 2026-01-15 23:38:28 -08:00
Sojan Jose 5e06466f18 Merge develop into chore/update-rails 2026-01-15 23:29:19 -08:00
Sojan Jose a0e4ce20f0 chore: remove cosmetic blank line from conversation reply mailer
- Remove extra blank line after include statement
- Another Layout/EmptyLineAfterGuardClause cosmetic change eliminated
- Reduce PR to 13 files by removing non-functional change
2025-08-14 14:30:51 +02:00
Sojan Jose ac1b6d070e chore: remove cosmetic blank line from message.rb
- Remove extra blank line after include statement
- This was a Layout/EmptyLineAfterGuardClause cosmetic change
- Reduce PR to 14 files by eliminating another non-functional change
2025-08-14 14:25:52 +02:00
Sojan Jose 54bd800a82 fix: update enum syntax for Rails 7.2 compatibility
- Change enum availability from keyword args to positional args
- Fix deprecation warning: 'Defining enums with keyword arguments is deprecated'
- Required for Rails 7.2.2.2 enum syntax changes
- Application now starts successfully with overmind
2025-08-14 14:20:21 +02:00
Sojan Jose 3c67d93426 chore: update sidekiq-cron and revert temporary compatibility fix
- Update sidekiq-cron from 1.12.0 to 2.3.1 with Rails 7.2 compatibility
- Update sidekiq from 7.3.1 to 7.3.9
- Restore require 'sidekiq/cron/web' as compatibility is now built-in
- Remove temporary FIXME workaround comments
2025-08-14 14:15:02 +02:00
Sojan Jose 8ed8e809e3 chore: remove remaining blank lines after include statements
- Remove extra blank lines added by Layout/EmptyLineAfterGuardClause rule
- Reduce PR to final 16 files with only functional Rails upgrade changes
2025-08-14 14:11:35 +02:00
Sojan Jose c2e095adba chore: revert memoization patterns and redundant default values
- Disable Style/MemoizeInstanceVariable and Style/RedundantAssignment rules
- Revert all instance_variable_defined? patterns back to ||= memoization
- Restore || 0 and || 1 default values that were removed by RuboCop
- Restore ternary operator formatting
- Reduce PR from 35 to 18 files by removing cosmetic style changes
2025-08-14 14:07:35 +02:00
Sojan Jose d24b513028 chore: revert final cosmetic Performance optimization
- Disable Performance/MapHash rule
- Revert .pluck(:id) back to .map { |tool| tool[:id] }
- Reduce PR to 35 files focused on functional Rails upgrade changes
2025-08-14 14:01:28 +02:00
Sojan Jose 87d703ad3a fix: correct RuboCop rule names in config
- Fix RSpec/RedundantTypeSpec -> RSpec/DescribeClass
- Fix Performance/MapPluck -> Performance/MapCompact
2025-08-14 13:56:58 +02:00
Sojan Jose fa58046c68 chore: revert remaining RuboCop cosmetic changes to minimize PR size
- Disable Performance/Count, RSpec/PredicateMatcher, RSpec/RedundantTypeSpec, Performance/MapPluck rules
- Revert .count.zero?/.none?, .count==1/.one?, .count.positive?/.any? changes
- Revert be_invalid/not_to be_valid predicate matcher changes
- Revert type: :helper/:model removals from RSpec describe blocks
- Revert .pluck()/.map{} performance optimizations
- Revert enum syntax and alias_attribute style changes
- Focus PR on functional Rails upgrade changes only
2025-08-14 13:56:15 +02:00
Sojan Jose 965c316b77 chore: revert more RuboCop cosmetic changes to reduce PR size
Reverted Layout/EmptyLineAfterGuardClause fixes in:
- 29+ controller files
- Multiple service, worker, and mailbox files
- Several spec files
- Enterprise module files

This keeps the PR focused on functional Rails 7.2 upgrade changes only.
2025-08-14 13:33:49 +02:00
Sojan Jose 9d0e2bc258 chore: disable RuboCop rules to keep Rails upgrade PR focused
- Disable Layout/EmptyLineAfterGuardClause
- Disable Performance/Count
- Fix Naming/PredicateMethod -> Naming/PredicateName
- Revert cosmetic RuboCop fixes to reduce PR size
2025-08-14 13:30:39 +02:00
Sojan Jose 00ac2519a2 chore: upgrade Vite compatibility and related dependencies
Fixes vite-plugin-ruby compatibility issue by upgrading:
- vite_rails 3.0.17 -> 3.0.19
- vite_ruby 3.8.0 -> 3.9.2

This resolves the Line service spec test failures that were caused by the Vite compatibility error.
2025-08-14 12:18:05 +02:00
Sojan Jose e59784dccc chore: update gem dependencies after merging develop
Updates Rails from 7.1.5.2 to 7.2.2.2 and resolve dependency conflicts
including devise-secure_password 2.0.1 to 2.1.0
2025-08-14 12:13:49 +02:00
Sojan Jose 807a21daca Merge branch 'develop' into chore/update-rails 2025-08-14 12:05:42 +02:00
Sojan Jose 4e9bdebe16 chore: rubocop fixes 2025-08-13 21:25:52 +02:00
Sojan JoseandGitHub 3ecd14e3ec Merge branch 'develop' into chore/update-rails 2025-08-13 17:32:10 +02:00
Sojan Jose 9638163b6f fix: resolve RuboCop violations
- Auto-fixed 143 code style violations using rubocop -A
- Fixed RuboCop configuration: moved rubocop-rspec_rails to plugins
- Removed redundant || 0 patterns after .to_i calls
- Fixed RSpec describe class format
- Added remaining naming violations to exclude list for future cleanup
- Reduced violations from 100 to 0 offenses
2025-08-13 17:27:34 +02:00
Sojan Jose 1db6c7add3 Merge develop into chore/update-rails 2025-08-12 16:17:41 +02:00
Sojan JoseandGitHub 35760ba464 Merge branch 'develop' into chore/update-rails 2025-07-18 01:11:01 -07:00
Sojan c2d20abce3 chore: gem updates 2025-06-29 01:30:11 -07:00
Sojan JoseandGitHub 06cfdf80d7 Merge branch 'develop' into chore/update-rails 2025-06-29 01:03:18 -07:00
Sojan JoseandGitHub b9fa8fe038 Merge branch 'develop' into chore/update-rails 2025-06-17 19:08:20 -04:00
Sojan JoseandGitHub 1cf5bd50a6 Merge branch 'develop' into chore/update-rails 2025-06-10 15:55:15 -04:00
Sojan JoseandGitHub e21ba3a0b5 Merge branch 'develop' into chore/update-rails 2025-05-28 14:47:11 -06:00
Shivam MishraandGitHub e4e10caf0d Merge branch 'develop' into chore/update-rails 2025-05-28 08:56:23 +05:30
Sojan JoseandGitHub d4e3320fdf Merge branch 'develop' into chore/update-rails 2025-05-27 16:52:33 -07:00
Sojan 0edcca3435 chore: rubocop fixes 2025-05-21 20:36:09 -07:00
Sojan e833b67caa chore: fixes 2025-05-21 20:24:07 -07:00
Sojan JoseandGitHub 01c7252a99 Merge branch 'develop' into chore/update-rails 2025-05-21 20:21:15 -07:00
Sojan 9efd8b761a chore: fixes 2025-05-21 02:02:05 -07:00
Sojan 8764e463f4 chore: fixes 2025-05-21 01:37:33 -07:00
Sojan 8316c98d12 chore: pnpm lock fix 2025-05-21 01:31:28 -07:00
Sojan eecd0a1fc7 Merge branch 'develop' into chore/update-rails
# Conflicts:
#	Gemfile.lock
#	package.json
#	pnpm-lock.yaml
2025-05-21 01:29:23 -07:00
Sojan 23afd1bc3f chore: fixes 2025-04-18 00:31:15 -07:00
Sojan 38f66afed9 chore: update gems 2025-04-18 00:27:23 -07:00
Sojan 1d4047a2e2 Merge branch 'develop' into chore/update-rails
# Conflicts:
#	Gemfile.lock
#	package.json
#	pnpm-lock.yaml
2025-04-18 00:23:26 -07:00
Sojan JoseandGitHub 8cd45e6fdd Merge branch 'develop' into chore/update-rails 2025-03-18 01:29:19 -07:00
Sojan JoseandGitHub 458a374a8d Merge branch 'develop' into chore/update-rails 2025-03-13 03:01:02 -07:00
Sojan ec614e17a2 chore: another fix 2025-03-12 01:59:05 -07:00
Sojan f1f10cea88 chore: spec fixes 2025-03-12 01:02:13 -07:00
Sojan baeb7826cf chore: fixes 2025-03-12 00:48:44 -07:00
Sojan 533ca3e919 chore: fixes 2025-03-12 00:28:52 -07:00
Sojan e23f8ca0c2 chore: fix spec 2025-03-12 00:25:08 -07:00
Sojan 099caa495a chore: fix security issue 2025-03-12 00:13:00 -07:00
Sojan JoseandGitHub 2043148305 Merge branch 'develop' into chore/update-rails 2025-03-12 00:05:01 -07:00
Sojan JoseandGitHub 35ca2d2acd Merge branch 'develop' into chore/update-rails 2025-03-07 15:11:14 -08:00
Sojan 204f28d301 chore: update 2025-03-07 15:05:12 -08:00
Sojan da995000e1 chore: rails 7.2.2 2025-03-07 01:44:50 -08:00
Sojan 478d84b8a1 chore: fix rubocop 2025-03-07 00:15:26 -08:00
Sojan 19d5786cf4 chore: fixes 2025-03-06 20:35:11 -08:00
Sojan JoseandGitHub 10ed3c374e Merge branch 'develop' into chore/update-rails 2025-03-06 20:02:27 -08:00
Sojan a6325b0ce9 chore: swagger build 2025-03-06 19:09:07 -08:00
Sojan 9e3a79303e chore: fixes 2025-03-06 18:53:34 -08:00
Sojan ba1ef0873f chore: Change version 2025-03-06 18:33:11 -08:00
Sojan JoseandGitHub a5addd7549 Merge branch 'develop' into chore/update-rails 2025-03-06 16:12:07 -08:00
Sojan 9ef87ffd19 chore: Upgrade Rails to 7.1.5.1 and update Gemfile dependencies 2025-03-06 16:10:39 -08:00
99 changed files with 4106 additions and 407 deletions
+4 -4
View File
@@ -144,7 +144,7 @@ jobs:
# Backend tests with parallelization
backend-tests:
<<: *defaults
parallelism: 16
parallelism: 20
steps:
- checkout
- node/install:
@@ -279,10 +279,10 @@ jobs:
echo -en "\nINSTALLATION_ENV=circleci" >> ".env"
echo -en "\nOPENSEARCH_URL=http://localhost:9200" >> ".env"
# Database setup
# Database setup (match GitHub Actions flow)
- run:
name: Run DB migrations
command: bundle exec rails db:chatwoot_prepare
name: Create database + load schema
command: bundle exec rake db:create db:schema:load
# Run backend tests (parallelized)
- run:
+1
View File
@@ -276,3 +276,4 @@ AZURE_APP_SECRET=
# REMOVE_STALE_CONTACT_INBOX_JOB_STATUS=false
# REDIS_ALFRED_SIZE=10
# REDIS_VELMA_SIZE=10
+7
View File
@@ -2,6 +2,7 @@ plugins:
- rubocop-performance
- rubocop-rails
- rubocop-rspec
- rubocop-rspec_rails
- rubocop-factory_bot
require:
@@ -203,6 +204,12 @@ RSpec/MultipleExpectations:
RSpec/MultipleMemoizedHelpers:
Max: 14
RSpecRails/InferredSpecType:
Enabled: false
RSpecRails/NegationBeValid:
Enabled: false
# custom rules
UseFromEmail:
Enabled: true
+1
View File
@@ -13,6 +13,7 @@
- **Ruby Version**: Manage Ruby via `rbenv` and install the version listed in `.ruby-version` (e.g., `rbenv install $(cat .ruby-version)`)
- **rbenv setup**: Before running any `bundle` or `rspec` commands, init rbenv in your shell (`eval "$(rbenv init -)"`) so the correct Ruby/Bundler versions are used
- Always prefer `bundle exec` for Ruby CLI tasks (rspec, rake, rubocop, etc.)
- **Test env**: Specs should run without `.env`. If present, temporarily rename it (e.g., `.env` -> `.env.bak`) while running specs and restore afterward.
## Code Style
+13 -10
View File
@@ -3,8 +3,10 @@ source 'https://rubygems.org'
ruby '3.4.4'
##-- base gems for rails --##
gem 'rack-cors', '2.0.0', require: 'rack/cors'
gem 'rails', '~> 7.1'
gem 'rack-cors', require: 'rack/cors'
gem 'rails', '~> 7.2.0'
# Reduces boot times through caching; required in config/boot.rb
gem 'bootsnap', require: false
@@ -31,7 +33,9 @@ gem 'haikunator'
# Template parsing safely
gem 'liquid'
# Parse Markdown to HTML
gem 'commonmarker'
# ref: https://github.com/gjtorikian/commonmarker/issues/358
# can upgrade one this issue is fixed
gem 'commonmarker', '~> 0.23.11'
# Validate Data against JSON Schema
gem 'json_schemer'
# used in swagger build
@@ -49,9 +53,7 @@ gem 'csv-safe'
##-- for active storage --##
gem 'aws-sdk-s3', require: false
# original gem isn't maintained actively
# we wanted updated version of faraday which is a dependency for slack-ruby-client
gem 'azure-storage-blob', git: 'https://github.com/chatwoot/azure-storage-ruby', branch: 'chatwoot', require: false
gem 'azure-blob', require: false
gem 'google-cloud-storage', '>= 1.48.0', require: false
gem 'image_processing'
@@ -89,9 +91,9 @@ gem 'jwt'
gem 'pundit'
# super admin
gem 'administrate', '>= 0.20.1'
gem 'administrate-field-active_storage', '>= 1.0.3'
gem 'administrate-field-belongs_to_search', '>= 0.9.0'
gem 'administrate'
gem 'administrate-field-active_storage'
gem 'administrate-field-belongs_to_search'
##--- gems for pubsub service ---##
# https://karolgalanciak.com/blog/2019/11/30/from-activerecord-callbacks-to-publish-slash-subscribe-pattern-and-event-driven-design/
@@ -131,7 +133,7 @@ gem 'sentry-sidekiq', '>= 5.19.0', require: false
##-- background job processing --##
gem 'sidekiq', '>= 7.3.1'
# We want cron jobs
gem 'sidekiq-cron', '>= 1.12.0'
gem 'sidekiq-cron', '>= 2.3.1'
# for sidekiq healthcheck
gem 'sidekiq_alive'
@@ -262,6 +264,7 @@ group :development, :test do
gem 'rubocop-performance', require: false
gem 'rubocop-rails', require: false
gem 'rubocop-rspec', require: false
gem 'rubocop-rspec_rails', require: false
gem 'rubocop-factory_bot', require: false
gem 'seed_dump'
gem 'shoulda-matchers'
+191 -192
View File
@@ -1,110 +1,89 @@
GIT
remote: https://github.com/chatwoot/azure-storage-ruby
revision: 9957cf899d33a285b5dfe15bdb875292398e392b
branch: chatwoot
specs:
azure-storage-blob (2.0.3)
azure-storage-common (~> 2.0)
nokogiri (~> 1, >= 1.10.8)
azure-storage-common (2.0.4)
faraday (~> 2.0)
faraday-follow_redirects (~> 0.3.0)
faraday-net_http_persistent (~> 2.0)
net-http-persistent (~> 4.0)
nokogiri (~> 1, >= 1.10.8)
GIT
remote: https://github.com/chatwoot/devise-secure_password
revision: adcc85fe1babfe40feae73dbcae64d14fff86e69
revision: 479987594b576dbf23aed8dbd962e78342bc683c
branch: chatwoot
specs:
devise-secure_password (2.0.1)
devise-secure_password (2.1.0)
devise (>= 4.0.0, < 5.0.0)
railties (>= 5.0.0, < 8.0.0)
GEM
remote: https://rubygems.org/
specs:
actioncable (7.1.5.2)
actionpack (= 7.1.5.2)
activesupport (= 7.1.5.2)
actioncable (7.2.2.2)
actionpack (= 7.2.2.2)
activesupport (= 7.2.2.2)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
actionmailbox (7.1.5.2)
actionpack (= 7.1.5.2)
activejob (= 7.1.5.2)
activerecord (= 7.1.5.2)
activestorage (= 7.1.5.2)
activesupport (= 7.1.5.2)
mail (>= 2.7.1)
net-imap
net-pop
net-smtp
actionmailer (7.1.5.2)
actionpack (= 7.1.5.2)
actionview (= 7.1.5.2)
activejob (= 7.1.5.2)
activesupport (= 7.1.5.2)
mail (~> 2.5, >= 2.5.4)
net-imap
net-pop
net-smtp
actionmailbox (7.2.2.2)
actionpack (= 7.2.2.2)
activejob (= 7.2.2.2)
activerecord (= 7.2.2.2)
activestorage (= 7.2.2.2)
activesupport (= 7.2.2.2)
mail (>= 2.8.0)
actionmailer (7.2.2.2)
actionpack (= 7.2.2.2)
actionview (= 7.2.2.2)
activejob (= 7.2.2.2)
activesupport (= 7.2.2.2)
mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
actionpack (7.1.5.2)
actionview (= 7.1.5.2)
activesupport (= 7.1.5.2)
actionpack (7.2.2.2)
actionview (= 7.2.2.2)
activesupport (= 7.2.2.2)
nokogiri (>= 1.8.5)
racc
rack (>= 2.2.4)
rack (>= 2.2.4, < 3.2)
rack-session (>= 1.0.1)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
actiontext (7.1.5.2)
actionpack (= 7.1.5.2)
activerecord (= 7.1.5.2)
activestorage (= 7.1.5.2)
activesupport (= 7.1.5.2)
useragent (~> 0.16)
actiontext (7.2.2.2)
actionpack (= 7.2.2.2)
activerecord (= 7.2.2.2)
activestorage (= 7.2.2.2)
activesupport (= 7.2.2.2)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (7.1.5.2)
activesupport (= 7.1.5.2)
actionview (7.2.2.2)
activesupport (= 7.2.2.2)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
active_record_query_trace (1.8)
activejob (7.1.5.2)
activesupport (= 7.1.5.2)
activejob (7.2.2.2)
activesupport (= 7.2.2.2)
globalid (>= 0.3.6)
activemodel (7.1.5.2)
activesupport (= 7.1.5.2)
activerecord (7.1.5.2)
activemodel (= 7.1.5.2)
activesupport (= 7.1.5.2)
activemodel (7.2.2.2)
activesupport (= 7.2.2.2)
activerecord (7.2.2.2)
activemodel (= 7.2.2.2)
activesupport (= 7.2.2.2)
timeout (>= 0.4.0)
activerecord-import (2.1.0)
activerecord (>= 4.2)
activestorage (7.1.5.2)
actionpack (= 7.1.5.2)
activejob (= 7.1.5.2)
activerecord (= 7.1.5.2)
activesupport (= 7.1.5.2)
activestorage (7.2.2.2)
actionpack (= 7.2.2.2)
activejob (= 7.2.2.2)
activerecord (= 7.2.2.2)
activesupport (= 7.2.2.2)
marcel (~> 1.0)
activesupport (7.1.5.2)
activesupport (7.2.2.2)
base64
benchmark (>= 0.3)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
concurrent-ruby (~> 1.0, >= 1.3.1)
connection_pool (>= 2.2.5)
drb
i18n (>= 1.6, < 2)
logger (>= 1.4.2)
minitest (>= 5.1)
mutex_m
securerandom (>= 0.3)
tzinfo (~> 2.0)
tzinfo (~> 2.0, >= 2.0.5)
acts-as-taggable-on (12.0.0)
activerecord (>= 7.1, < 8.1)
zeitwerk (>= 2.4, < 3.0)
@@ -121,10 +100,10 @@ GEM
administrate-field-active_storage (1.0.3)
administrate (>= 0.2.2)
rails (>= 7.0)
administrate-field-belongs_to_search (0.9.0)
administrate-field-belongs_to_search (0.10.0)
administrate (>= 0.3, < 1.0)
jbuilder (~> 2)
rails (>= 4.2, < 7.2)
rails (>= 4.2, < 8.0)
selectize-rails (~> 0.6)
ai-agents (0.7.0)
ruby_llm (~> 1.8.2)
@@ -141,8 +120,8 @@ GEM
aws-sdk-s3 (~> 1, >= 1.123.0)
aws-sdk-sns (~> 1, >= 1.61.0)
aws-eventstream (1.4.0)
aws-partitions (1.1198.0)
aws-sdk-core (3.240.0)
aws-partitions (1.1205.0)
aws-sdk-core (3.241.3)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
@@ -150,11 +129,11 @@ GEM
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.118.0)
aws-sdk-core (~> 3, >= 3.239.1)
aws-sdk-kms (1.120.0)
aws-sdk-core (~> 3, >= 3.241.3)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.208.0)
aws-sdk-core (~> 3, >= 3.234.0)
aws-sdk-s3 (1.211.0)
aws-sdk-core (~> 3, >= 3.241.3)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sdk-sns (1.70.0)
@@ -162,20 +141,22 @@ GEM
aws-sigv4 (~> 1.1)
aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2)
azure-blob (0.5.9.1)
rexml
barnes (0.0.9)
multi_json (~> 1)
statsd-ruby (~> 1.1)
base64 (0.3.0)
bcrypt (3.1.20)
benchmark (0.4.1)
bigdecimal (3.2.2)
benchmark (0.5.0)
bigdecimal (4.0.1)
bindex (0.8.1)
bootsnap (1.16.0)
msgpack (~> 1.2)
brakeman (5.4.1)
browser (5.3.1)
builder (3.3.0)
bullet (8.0.7)
bullet (7.2.0)
activesupport (>= 3.0.0)
uniform_notifier (~> 1.11)
bundle-audit (0.1.0)
@@ -184,17 +165,21 @@ GEM
bundler (>= 1.2.0, < 3)
thor (~> 1.0)
byebug (11.1.3)
cgi (0.5.1)
childprocess (5.1.0)
logger (~> 1.5)
climate_control (1.2.0)
coderay (1.1.3)
commonmarker (0.23.10)
concurrent-ruby (1.3.5)
connection_pool (2.5.3)
commonmarker (0.23.11)
concurrent-ruby (1.3.6)
connection_pool (3.0.2)
crack (1.0.0)
bigdecimal
rexml
crass (1.0.6)
cronex (0.15.0)
tzinfo
unicode (>= 0.4.4.5)
csv (3.3.0)
csv-safe (3.3.1)
csv (~> 3.0)
@@ -204,14 +189,15 @@ GEM
activerecord (>= 5.a)
database_cleaner-core (~> 2.0.0)
database_cleaner-core (2.0.1)
datadog (2.19.0)
datadog-ruby_core_source (~> 3.4, >= 3.4.1)
libdatadog (~> 18.1.0.1.0)
libddwaf (~> 1.24.1.0.3)
datadog (2.25.0)
cgi
datadog-ruby_core_source (~> 3.5, >= 3.5.0)
libdatadog (~> 24.0.1.1.0)
libddwaf (~> 1.30.0.0.0)
logger
msgpack
datadog-ruby_core_source (3.4.1)
date (3.4.1)
datadog-ruby_core_source (3.5.1)
date (3.5.1)
debug (1.8.0)
irb (>= 1.5.0)
reline (>= 0.3.1)
@@ -222,10 +208,10 @@ GEM
railties (>= 4.1.0)
responders
warden (~> 1.2.3)
devise-two-factor (6.1.0)
activesupport (>= 7.0, < 8.1)
devise (~> 4.0)
railties (>= 7.0, < 8.1)
devise-two-factor (6.3.0)
activesupport (>= 7.0, < 8.2)
devise (>= 4.0, < 5.0)
railties (>= 7.0, < 8.2)
rotp (~> 6.0)
devise_token_auth (1.2.5)
bcrypt (~> 3.0)
@@ -244,31 +230,31 @@ GEM
down (5.4.0)
addressable (~> 2.8)
drb (2.2.3)
dry-cli (1.1.0)
dry-cli (1.4.1)
dry-configurable (1.3.0)
dry-core (~> 1.1)
zeitwerk (~> 2.6)
dry-core (1.1.0)
dry-core (1.2.0)
concurrent-ruby (~> 1.0)
logger
zeitwerk (~> 2.6)
dry-inflector (1.2.0)
dry-inflector (1.3.1)
dry-initializer (3.2.0)
dry-logic (1.6.0)
bigdecimal
concurrent-ruby (~> 1.0)
dry-core (~> 1.1)
zeitwerk (~> 2.6)
dry-schema (1.14.1)
dry-schema (1.15.0)
concurrent-ruby (~> 1.0)
dry-configurable (~> 1.0, >= 1.0.1)
dry-core (~> 1.1)
dry-initializer (~> 3.2)
dry-logic (~> 1.5)
dry-logic (~> 1.6)
dry-types (~> 1.8)
zeitwerk (~> 2.6)
dry-types (1.8.3)
bigdecimal (~> 3.0)
dry-types (1.9.0)
bigdecimal (>= 3.0)
concurrent-ruby (~> 1.0)
dry-core (~> 1.0)
dry-inflector (~> 1.0)
@@ -282,8 +268,9 @@ GEM
ruby2_keywords
email-provider-info (0.0.1)
email_reply_trimmer (0.1.13)
erubi (1.13.0)
et-orbi (1.2.11)
erb (6.0.1)
erubi (1.13.1)
et-orbi (1.3.0)
tzinfo
event_stream_parser (1.0.0)
execjs (2.8.1)
@@ -297,34 +284,27 @@ GEM
railties (>= 5.0.0)
faker (3.2.0)
i18n (>= 1.8.11, < 2)
faraday (2.13.1)
faraday-net_http (>= 2.0, < 3.5)
json
logger
faraday-follow_redirects (0.3.0)
faraday (>= 1, < 3)
faraday-mashify (1.0.0)
faraday (2.9.0)
faraday-net_http (>= 2.0, < 3.2)
faraday-mashify (1.0.2)
faraday (~> 2.0)
hashie
faraday-multipart (1.0.4)
multipart-post (~> 2)
faraday-net_http (3.4.0)
net-http (>= 0.5.0)
faraday-net_http_persistent (2.1.0)
faraday (~> 2.5)
net-http-persistent (~> 4.0)
faraday-net_http (3.1.0)
net-http
faraday-retry (2.2.1)
faraday (~> 2.0)
faraday_middleware-aws-sigv4 (1.0.1)
aws-sigv4 (~> 1.0)
faraday (>= 2.0, < 3)
fast-mcp (1.5.0)
fast-mcp (1.6.0)
addressable (~> 2.8)
base64
dry-schema (~> 1.14)
json (~> 2.0)
mime-types (~> 3.4)
rack (~> 3.1)
rack (>= 2.0, < 4.0)
fcm (1.0.8)
faraday (>= 1.0.0, < 3.0)
googleauth (~> 1)
@@ -441,19 +421,21 @@ GEM
http-cookie (1.0.5)
domain_name (~> 0.5)
http-form_data (2.3.0)
httparty (0.24.0)
httparty (0.24.2)
csv
mini_mime (>= 1.0.0)
multi_xml (>= 0.5.2)
httpclient (2.8.3)
i18n (1.14.7)
i18n (1.14.8)
concurrent-ruby (~> 1.0)
image_processing (1.12.2)
mini_magick (>= 4.9.5, < 5)
ruby-vips (>= 2.0.17, < 3)
io-console (0.6.0)
irb (1.7.2)
reline (>= 0.3.6)
io-console (0.8.2)
irb (1.16.0)
pp (>= 0.6.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
iso-639 (0.3.8)
csv
jbuilder (2.11.5)
@@ -464,7 +446,7 @@ GEM
rails-dom-testing (>= 1, < 3)
railties (>= 4.2.0)
thor (>= 0.14, < 2.0)
json (2.13.2)
json (2.12.0)
json_refs (0.1.8)
hana
json_schemer (0.2.24)
@@ -479,7 +461,7 @@ GEM
judoscale-sidekiq (1.8.2)
judoscale-ruby (= 1.8.2)
sidekiq (>= 5.0)
jwt (2.10.1)
jwt (2.8.1)
base64
kaminari (1.2.2)
activesupport (>= 4.1.0)
@@ -506,15 +488,15 @@ GEM
logger (~> 1.6)
letter_opener (1.10.0)
launchy (>= 2.2, < 4)
libdatadog (18.1.0.1.0)
libdatadog (18.1.0.1.0-x86_64-linux)
libddwaf (1.24.1.0.3)
libdatadog (24.0.1.1.0)
libdatadog (24.0.1.1.0-x86_64-linux)
libddwaf (1.30.0.0.0)
ffi (~> 1.0)
libddwaf (1.24.1.0.3-arm64-darwin)
libddwaf (1.30.0.0.0-arm64-darwin)
ffi (~> 1.0)
libddwaf (1.24.1.0.3-x86_64-darwin)
libddwaf (1.30.0.0.0-x86_64-darwin)
ffi (~> 1.0)
libddwaf (1.24.1.0.3-x86_64-linux)
libddwaf (1.30.0.0.0-x86_64-linux)
ffi (~> 1.0)
line-bot-api (1.28.0)
lint_roller (1.1.0)
@@ -531,7 +513,7 @@ GEM
activesupport (>= 4)
railties (>= 4)
request_store (~> 1.0)
loofah (2.23.1)
loofah (2.25.0)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.8.1)
@@ -551,21 +533,19 @@ GEM
mini_magick (4.12.0)
mini_mime (1.1.5)
mini_portile2 (2.8.9)
minitest (5.25.5)
minitest (5.27.0)
mock_redis (0.36.0)
ruby2_keywords
msgpack (1.8.0)
multi_json (1.15.0)
multi_xml (0.8.0)
multi_xml (0.8.1)
bigdecimal (>= 3.1, < 5)
multipart-post (2.3.0)
mutex_m (0.3.0)
neighbor (0.2.3)
activerecord (>= 5.2)
net-http (0.6.0)
net-http (0.4.1)
uri
net-http-persistent (4.0.2)
connection_pool (~> 2.2)
net-imap (0.4.20)
date
net-protocol
@@ -582,14 +562,14 @@ GEM
newrelic_rpm (9.6.0)
base64
nio4r (2.7.3)
nokogiri (1.18.9)
nokogiri (1.19.0)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nokogiri (1.18.9-arm64-darwin)
nokogiri (1.19.0-arm64-darwin)
racc (~> 1.4)
nokogiri (1.18.9-x86_64-darwin)
nokogiri (1.19.0-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.18.9-x86_64-linux-gnu)
nokogiri (1.19.0-x86_64-linux-gnu)
racc (~> 1.4)
oauth (1.1.0)
oauth-tty (~> 1.0, >= 1.0.1)
@@ -607,9 +587,8 @@ GEM
oj (3.16.10)
bigdecimal (>= 3.0)
ostruct (>= 0.2)
omniauth (2.1.4)
omniauth (2.1.2)
hashie (>= 3.4.6)
logger
rack (>= 2.2.3)
rack-protection
omniauth-google-oauth2 (1.1.3)
@@ -661,6 +640,9 @@ GEM
activerecord (>= 5.2)
activesupport (>= 5.2)
pgvector (0.1.1)
pp (0.6.3)
prettyprint
prettyprint (0.2.0)
prism (1.4.0)
procore-sift (1.0.0)
activerecord (>= 6.1)
@@ -669,6 +651,9 @@ GEM
method_source (~> 1.0)
pry-rails (0.3.9)
pry (>= 0.10.4)
psych (5.3.1)
date
stringio
public_suffix (6.0.2)
puma (6.4.3)
nio4r (~> 2.0)
@@ -676,7 +661,7 @@ GEM
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.8.1)
rack (3.2.3)
rack (3.1.19)
rack-attack (6.7.0)
rack (>= 1.0, < 4)
rack-contrib (2.5.0)
@@ -694,53 +679,57 @@ GEM
rack-session (2.1.1)
base64 (>= 0.1.0)
rack (>= 3.0.0)
rack-test (2.1.0)
rack-test (2.2.0)
rack (>= 1.3)
rack-timeout (0.6.3)
rackup (2.2.1)
rackup (2.3.1)
rack (>= 3)
rails (7.1.5.2)
actioncable (= 7.1.5.2)
actionmailbox (= 7.1.5.2)
actionmailer (= 7.1.5.2)
actionpack (= 7.1.5.2)
actiontext (= 7.1.5.2)
actionview (= 7.1.5.2)
activejob (= 7.1.5.2)
activemodel (= 7.1.5.2)
activerecord (= 7.1.5.2)
activestorage (= 7.1.5.2)
activesupport (= 7.1.5.2)
rails (7.2.2.2)
actioncable (= 7.2.2.2)
actionmailbox (= 7.2.2.2)
actionmailer (= 7.2.2.2)
actionpack (= 7.2.2.2)
actiontext (= 7.2.2.2)
actionview (= 7.2.2.2)
activejob (= 7.2.2.2)
activemodel (= 7.2.2.2)
activerecord (= 7.2.2.2)
activestorage (= 7.2.2.2)
activesupport (= 7.2.2.2)
bundler (>= 1.15.0)
railties (= 7.1.5.2)
rails-dom-testing (2.2.0)
railties (= 7.2.2.2)
rails-dom-testing (2.3.0)
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
rails-html-sanitizer (1.6.1)
rails-html-sanitizer (1.6.2)
loofah (~> 2.21)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
railties (7.1.5.2)
actionpack (= 7.1.5.2)
activesupport (= 7.1.5.2)
irb
railties (7.2.2.2)
actionpack (= 7.2.2.2)
activesupport (= 7.2.2.2)
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
thor (~> 1.0, >= 1.2.2)
zeitwerk (~> 2.6)
rainbow (3.1.1)
rake (13.2.1)
rake (13.3.1)
rb-fsevent (0.11.2)
rb-inotify (0.10.1)
ffi (~> 1.0)
rdoc (7.1.0)
erb
psych (>= 4.0.0)
tsort
redis (5.0.6)
redis-client (>= 0.9.0)
redis-client (0.22.2)
redis-client (0.25.2)
connection_pool
redis-namespace (1.10.0)
redis (>= 4)
regexp_parser (2.10.0)
reline (0.3.6)
reline (0.6.3)
io-console (~> 0.5)
representable (3.2.0)
declarative (< 0.1.0)
@@ -810,6 +799,10 @@ GEM
rubocop-rspec (3.6.0)
lint_roller (~> 1.1)
rubocop (~> 1.72, >= 1.72.1)
rubocop-rspec_rails (2.31.0)
lint_roller (~> 1.1)
rubocop (~> 1.72, >= 1.72.1)
rubocop-rspec (~> 3.5)
ruby-openai (7.3.1)
event_stream_parser (>= 0.3.0, < 2.0.0)
faraday (>= 1)
@@ -832,9 +825,8 @@ GEM
faraday-net_http (>= 1)
faraday-retry (>= 1)
marcel (~> 1.0)
ruby_llm-schema (~> 0.2.1)
zeitwerk (~> 2)
ruby_llm-schema (0.2.5)
ruby_llm-schema (0.1.0)
ruby_parser (3.20.0)
sexp_processor (~> 4.16)
sass (3.7.4)
@@ -854,9 +846,8 @@ GEM
parser
scss_lint (0.60.0)
sass (~> 3.5, >= 3.5.5)
searchkick (5.5.2)
activemodel (>= 7.1)
hashie
searchkick (6.0.3)
activemodel (>= 7.2)
securerandom (0.4.1)
seed_dump (3.3.1)
activerecord (>= 4)
@@ -885,16 +876,17 @@ GEM
zeitwerk (~> 2.5)
shoulda-matchers (5.3.0)
activesupport (>= 5.2.0)
sidekiq (7.3.1)
concurrent-ruby (< 2)
sidekiq (7.3.9)
base64
connection_pool (>= 2.3.0)
logger
rack (>= 2.2.4)
redis-client (>= 0.22.2)
sidekiq-cron (1.12.0)
fugit (~> 1.8)
sidekiq-cron (2.3.1)
cronex (>= 0.13.0)
fugit (~> 1.8, >= 1.11.1)
globalid (>= 1.0.1)
sidekiq (>= 6)
sidekiq (>= 6.5.0)
sidekiq_alive (2.5.0)
gserver (~> 0.0.1)
sidekiq (>= 5, < 9)
@@ -934,12 +926,13 @@ GEM
squasher (0.7.2)
stackprof (0.2.25)
statsd-ruby (1.5.0)
stripe (18.0.1)
stringio (3.2.0)
stripe (18.1.0)
telephone_number (1.4.20)
test-prof (1.2.1)
thor (1.4.0)
tidewave (0.2.0)
fast-mcp (~> 1.5.0)
thor (1.5.0)
tidewave (0.4.1)
fast-mcp (~> 1.6.0)
rack (>= 2.0)
rails (>= 7.1.0)
tilt (2.3.0)
@@ -948,7 +941,8 @@ GEM
i18n
timeout (0.4.3)
trailblazer-option (0.1.2)
twilio-ruby (7.6.0)
tsort (0.2.0)
twilio-ruby (5.77.0)
faraday (>= 0.9, < 3.0)
jwt (>= 1.5, < 3.0)
nokogiri (>= 1.6, < 2.0)
@@ -964,21 +958,25 @@ GEM
unf (0.1.4)
unf_ext
unf_ext (0.0.8.2)
unicode (0.4.4.5)
unicode-display_width (3.1.4)
unicode-emoji (~> 4.0, >= 4.0.4)
unicode-emoji (4.0.4)
uniform_notifier (1.17.0)
uri (1.0.4)
uri (1.1.1)
uri_template (0.7.0)
useragent (0.16.11)
valid_email2 (5.2.6)
activemodel (>= 3.2)
mail (~> 2.5)
version_gem (1.1.4)
vite_rails (3.0.17)
railties (>= 5.1, < 8)
vite_rails (3.0.20)
railties (>= 5.1, < 9)
vite_ruby (~> 3.0, >= 3.2.2)
vite_ruby (3.8.0)
vite_ruby (3.9.2)
dry-cli (>= 0.7, < 2)
logger (~> 1.6)
mutex_m
rack-proxy (~> 0.6, >= 0.6.1)
zeitwerk (~> 2.2)
warden (1.2.9)
@@ -995,7 +993,7 @@ GEM
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
websocket-driver (0.7.7)
websocket-driver (0.8.0)
base64
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
@@ -1003,7 +1001,7 @@ GEM
working_hours (1.4.1)
activesupport (>= 3.2)
tzinfo
zeitwerk (2.6.17)
zeitwerk (2.7.4)
PLATFORMS
arm64-darwin-20
@@ -1020,16 +1018,16 @@ DEPENDENCIES
active_record_query_trace
activerecord-import
acts-as-taggable-on
administrate (>= 0.20.1)
administrate-field-active_storage (>= 1.0.3)
administrate-field-belongs_to_search (>= 0.9.0)
administrate
administrate-field-active_storage
administrate-field-belongs_to_search
ai-agents (>= 0.7.0)
annotaterb
attr_extras
audited (~> 5.4, >= 5.4.1)
aws-actionmailbox-ses (~> 0)
aws-sdk-s3
azure-storage-blob!
azure-blob
barnes
bootsnap
brakeman
@@ -1038,7 +1036,7 @@ DEPENDENCIES
bundle-audit
byebug
climate_control
commonmarker
commonmarker (~> 0.23.11)
csv-safe
database_cleaner
datadog (~> 2.0)
@@ -1108,10 +1106,10 @@ DEPENDENCIES
puma
pundit
rack-attack (>= 6.7.0)
rack-cors (= 2.0.0)
rack-cors
rack-mini-profiler (>= 3.2.0)
rack-timeout
rails (~> 7.1)
rails (~> 7.2.0)
redis
redis-namespace
responders (>= 3.1.1)
@@ -1124,6 +1122,7 @@ DEPENDENCIES
rubocop-performance
rubocop-rails
rubocop-rspec
rubocop-rspec_rails
ruby-openai
ruby_llm (>= 1.8.2)
ruby_llm-schema
@@ -1137,7 +1136,7 @@ DEPENDENCIES
shopify_api
shoulda-matchers
sidekiq (>= 7.3.1)
sidekiq-cron (>= 1.12.0)
sidekiq-cron (>= 2.3.1)
sidekiq_alive
simplecov (>= 0.21)
simplecov_json_formatter
@@ -158,6 +158,7 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
message_type: message_type,
status: @outgoing_echo ? :delivered : :sent,
source_id: message_identifier,
content: message_content,
sender: @outgoing_echo ? nil : contact,
@@ -166,6 +167,7 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil
}
}
params[:content_attributes][:external_echo] = true if @outgoing_echo
params[:content_attributes][:is_unsupported] = true if message_is_unsupported?
params
end
@@ -0,0 +1,68 @@
class V2::Reports::FirstResponseTimeDistributionBuilder
include DateRangeHelper
attr_reader :account, :params
def initialize(account:, params:)
@account = account
@params = params
end
def build
build_distribution
end
private
def build_distribution
results = fetch_aggregated_counts
map_to_channel_types(results)
end
def fetch_aggregated_counts
ReportingEvent
.where(account_id: account.id, name: 'first_response')
.where(range_condition)
.group(:inbox_id)
.select(
:inbox_id,
bucket_case_statements
)
end
def bucket_case_statements
<<~SQL.squish
COUNT(CASE WHEN value < 3600 THEN 1 END) AS bucket_0_1h,
COUNT(CASE WHEN value >= 3600 AND value < 14400 THEN 1 END) AS bucket_1_4h,
COUNT(CASE WHEN value >= 14400 AND value < 28800 THEN 1 END) AS bucket_4_8h,
COUNT(CASE WHEN value >= 28800 AND value < 86400 THEN 1 END) AS bucket_8_24h,
COUNT(CASE WHEN value >= 86400 THEN 1 END) AS bucket_24h_plus
SQL
end
def range_condition
range.present? ? { created_at: range } : {}
end
def inbox_channel_types
@inbox_channel_types ||= account.inboxes.pluck(:id, :channel_type).to_h
end
def map_to_channel_types(results)
results.each_with_object({}) do |row, hash|
channel_type = inbox_channel_types[row.inbox_id]
next unless channel_type
hash[channel_type] ||= empty_buckets
hash[channel_type]['0-1h'] += row.bucket_0_1h
hash[channel_type]['1-4h'] += row.bucket_1_4h
hash[channel_type]['4-8h'] += row.bucket_4_8h
hash[channel_type]['8-24h'] += row.bucket_8_24h
hash[channel_type]['24h+'] += row.bucket_24h_plus
end
end
def empty_buckets
{ '0-1h' => 0, '1-4h' => 0, '4-8h' => 0, '8-24h' => 0, '24h+' => 0 }
end
end
@@ -0,0 +1,65 @@
class V2::Reports::InboxLabelMatrixBuilder
include DateRangeHelper
attr_reader :account, :params
def initialize(account:, params:)
@account = account
@params = params
end
def build
{
inboxes: filtered_inboxes.map { |inbox| { id: inbox.id, name: inbox.name } },
labels: filtered_labels.map { |label| { id: label.id, title: label.title } },
matrix: build_matrix
}
end
private
def filtered_inboxes
@filtered_inboxes ||= begin
inboxes = account.inboxes
inboxes = inboxes.where(id: params[:inbox_ids]) if params[:inbox_ids].present?
inboxes.order(:name).to_a
end
end
def filtered_labels
@filtered_labels ||= begin
labels = account.labels
labels = labels.where(id: params[:label_ids]) if params[:label_ids].present?
labels.order(:title).to_a
end
end
def conversation_filter
filter = { account_id: account.id }
filter[:created_at] = range if range.present?
filter[:inbox_id] = params[:inbox_ids] if params[:inbox_ids].present?
filter
end
def fetch_grouped_counts
label_names = filtered_labels.map(&:title)
return {} if label_names.empty?
ActsAsTaggableOn::Tagging
.joins('INNER JOIN conversations ON taggings.taggable_id = conversations.id')
.joins('INNER JOIN tags ON taggings.tag_id = tags.id')
.where(taggable_type: 'Conversation', context: 'labels', conversations: conversation_filter)
.where(tags: { name: label_names })
.group('conversations.inbox_id', 'tags.name')
.count
end
def build_matrix
counts = fetch_grouped_counts
filtered_inboxes.map do |inbox|
filtered_labels.map do |label|
counts[[inbox.id, label.title]] || 0
end
end
end
end
@@ -70,8 +70,10 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
def transcript
render json: { error: 'email param missing' }, status: :unprocessable_entity and return if params[:email].blank?
return head :too_many_requests unless @conversation.account.within_email_rate_limit?
ConversationReplyMailer.with(account: @conversation.account).conversation_transcript(@conversation, params[:email])&.deliver_later
@conversation.account.increment_email_sent_count
head :ok
end
@@ -28,7 +28,7 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas
end
def create_issue
issue = linear_processor_service.create_issue(permitted_params, Current.user)
issue = linear_processor_service.create_issue(permitted_params.to_h.stringify_keys, Current.user)
if issue[:error]
render json: { error: issue[:error] }, status: :unprocessable_entity
else
@@ -35,12 +35,9 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
end
def transcript
if conversation.present? && conversation.contact.present? && conversation.contact.email.present?
ConversationReplyMailer.with(account: conversation.account).conversation_transcript(
conversation,
conversation.contact.email
)&.deliver_later
end
return head :too_many_requests unless conversation.present? && conversation.account.within_email_rate_limit?
send_transcript_email
head :ok
end
@@ -77,6 +74,16 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
private
def send_transcript_email
return if conversation.contact&.email.blank?
ConversationReplyMailer.with(account: conversation.account).conversation_transcript(
conversation,
conversation.contact.email
)&.deliver_later
conversation.account.increment_email_sent_count
end
def trigger_typing_event(event)
Rails.configuration.dispatcher.dispatch(event, Time.zone.now, conversation: conversation, user: @contact)
end
@@ -62,6 +62,22 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
render json: bot_metrics
end
def inbox_label_matrix
builder = V2::Reports::InboxLabelMatrixBuilder.new(
account: Current.account,
params: inbox_label_matrix_params
)
render json: builder.build
end
def first_response_time_distribution
builder = V2::Reports::FirstResponseTimeDistributionBuilder.new(
account: Current.account,
params: first_response_time_distribution_params
)
render json: builder.build
end
private
def generate_csv(filename, template)
@@ -139,4 +155,20 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
def conversation_metrics
V2::ReportBuilder.new(Current.account, conversation_params).conversation_metrics
end
def inbox_label_matrix_params
{
since: params[:since],
until: params[:until],
inbox_ids: params[:inbox_ids],
label_ids: params[:label_ids]
}
end
def first_response_time_distribution_params
{
since: params[:since],
until: params[:until]
}
end
end
@@ -42,7 +42,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
'facebook' => %w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET IG_VERIFY_TOKEN FACEBOOK_API_VERSION ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT],
'shopify' => %w[SHOPIFY_CLIENT_ID SHOPIFY_CLIENT_SECRET],
'microsoft' => %w[AZURE_APP_ID AZURE_APP_SECRET],
'email' => ['MAILER_INBOUND_EMAIL_DOMAIN'],
'email' => %w[MAILER_INBOUND_EMAIL_DOMAIN ACCOUNT_EMAILS_LIMIT ACCOUNT_EMAILS_PLAN_LIMITS],
'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET],
'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET],
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT],
@@ -20,7 +20,9 @@ class SuperAdmin::InstanceStatusesController < SuperAdmin::ApplicationController
end
def instance_meta
@metrics['Database Migrations'] = ActiveRecord::Base.connection.migration_context.needs_migration? ? 'pending' : 'completed'
migrations_paths = ActiveRecord::Migrator.migrations_paths
migrations_context = ActiveRecord::MigrationContext.new(migrations_paths)
@metrics['Database Migrations'] = migrations_context.needs_migration? ? 'pending' : 'completed'
end
def chatwoot_version
@@ -3,12 +3,14 @@ import { onMounted, computed, ref, toRefs } from 'vue';
import { useTimeoutFn } from '@vueuse/core';
import { provideMessageContext } from './provider.js';
import { useTrack } from 'dashboard/composables';
import { useMapGetter } from 'dashboard/composables/store';
import { emitter } from 'shared/helpers/mitt';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { LocalStorage } from 'shared/helpers/localStorage';
import { ACCOUNT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { getInboxIconByType } from 'dashboard/helper/inbox';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import {
MESSAGE_TYPES,
@@ -139,6 +141,8 @@ const showBackgroundHighlight = ref(false);
const showContextMenu = ref(false);
const { t } = useI18n();
const route = useRoute();
const inboxGetter = useMapGetter('inboxes/getInbox');
const inbox = computed(() => inboxGetter.value(props.inboxId) || {});
/**
* Computes the message variant based on props
@@ -162,6 +166,10 @@ const variant = computed(() => {
if (props.contentAttributes?.isUnsupported)
return MESSAGE_VARIANTS.UNSUPPORTED;
if (props.contentAttributes?.externalEcho) {
return MESSAGE_VARIANTS.AGENT;
}
const isBot = !props.sender || props.sender.type === SENDER_TYPES.AGENT_BOT;
if (isBot && props.messageType === MESSAGE_TYPES.OUTGOING) {
return MESSAGE_VARIANTS.BOT;
@@ -424,6 +432,18 @@ function handleReplyTo() {
}
const avatarInfo = computed(() => {
if (props.contentAttributes?.externalEcho) {
const { name, avatar_url, channel_type, medium } = inbox.value;
const iconName = avatar_url
? null
: getInboxIconByType(channel_type, medium);
return {
name: iconName ? '' : name || t('CONVERSATION.NATIVE_APP'),
src: avatar_url || '',
iconName,
};
}
// If no sender, return bot info
if (!props.sender) {
return {
@@ -451,6 +471,9 @@ const avatarInfo = computed(() => {
});
const avatarTooltip = computed(() => {
if (props.contentAttributes?.externalEcho) {
return t('CONVERSATION.NATIVE_APP_ADVISORY');
}
if (avatarInfo.value.name === '') return '';
return `${t('CONVERSATION.SENT_BY')} ${avatarInfo.value.name}`;
});
@@ -484,7 +507,7 @@ provideMessageContext({
<div
v-if="shouldRenderMessage"
:id="`message${props.id}`"
class="flex mb-2 w-full message-bubble-container"
class="flex w-full mb-2 message-bubble-container"
:data-message-id="props.id"
:class="[
flexOrientationClass,
@@ -7,6 +7,7 @@ import { emitter } from 'shared/helpers/mitt';
import { useMessageContext } from '../provider.js';
import { useI18n } from 'vue-i18n';
import MessageFormatter from 'shared/helpers/MessageFormatter.js';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { MESSAGE_VARIANTS, ORIENTATION } from '../constants';
@@ -80,7 +81,7 @@ const replyToPreview = computed(() => {
const { content, attachments } = inReplyTo.value;
if (content) return content;
if (content) return new MessageFormatter(content).formattedMessage;
if (attachments?.length) {
const firstAttachment = attachments[0];
const fileType = firstAttachment.fileType ?? firstAttachment.file_type;
@@ -107,9 +108,10 @@ const replyToPreview = computed(() => {
class="p-2 -mx-1 mb-2 rounded-lg cursor-pointer bg-n-alpha-black1"
@click="scrollToMessage"
>
<span class="break-all line-clamp-2">
{{ replyToPreview }}
</span>
<div
v-dompurify-html="replyToPreview"
class="prose prose-bubble line-clamp-2"
/>
</div>
<slot />
<MessageMeta
@@ -127,6 +127,7 @@ const validateSingleAction = action => {
'resolve_conversation',
'remove_assigned_team',
'open_conversation',
'pending_conversation',
];
if (
@@ -150,7 +150,8 @@
"ADD_PRIVATE_NOTE": "Add a Private Note",
"CHANGE_PRIORITY": "Change Priority",
"ADD_SLA": "Add SLA",
"OPEN_CONVERSATION": "Open conversation"
"OPEN_CONVERSATION": "Open conversation",
"PENDING_CONVERSATION": "Mark conversation as pending"
},
"MESSAGE_TYPES": {
"INCOMING": "Incoming Message",
@@ -253,6 +253,8 @@
"MESSAGE_ERROR": "Unable to send this message, please try again later",
"SENT_BY": "Sent by:",
"BOT": "Bot",
"NATIVE_APP": "Native app",
"NATIVE_APP_ADVISORY": "This message was sent from the native app. Reply from Chatwoot to maintain the message window.",
"SEND_FAILED": "Couldn't send message! Try again",
"TRY_AGAIN": "retry",
"ASSIGNMENT": {
@@ -116,6 +116,10 @@ export const AUTOMATIONS = {
key: 'open_conversation',
name: 'OPEN_CONVERSATION',
},
{
key: 'pending_conversation',
name: 'PENDING_CONVERSATION',
},
{
key: 'resolve_conversation',
name: 'RESOLVE_CONVERSATION',
@@ -232,6 +236,10 @@ export const AUTOMATIONS = {
key: 'snooze_conversation',
name: 'SNOOZE_CONVERSATION',
},
{
key: 'pending_conversation',
name: 'PENDING_CONVERSATION',
},
{
key: 'resolve_conversation',
name: 'RESOLVE_CONVERSATION',
@@ -360,6 +368,10 @@ export const AUTOMATIONS = {
key: 'snooze_conversation',
name: 'SNOOZE_CONVERSATION',
},
{
key: 'pending_conversation',
name: 'PENDING_CONVERSATION',
},
{
key: 'resolve_conversation',
name: 'RESOLVE_CONVERSATION',
@@ -482,6 +494,10 @@ export const AUTOMATIONS = {
key: 'snooze_conversation',
name: 'SNOOZE_CONVERSATION',
},
{
key: 'pending_conversation',
name: 'PENDING_CONVERSATION',
},
{
key: 'send_webhook_event',
name: 'SEND_WEBHOOK_EVENT',
@@ -668,6 +684,11 @@ export const AUTOMATION_ACTION_TYPES = [
label: 'OPEN_CONVERSATION',
inputType: null,
},
{
key: 'pending_conversation',
label: 'PENDING_CONVERSATION',
inputType: null,
},
{
key: 'send_webhook_event',
label: 'SEND_WEBHOOK_EVENT',
+1
View File
@@ -3,6 +3,7 @@ class ConversationReplyEmailJob < ApplicationJob
def perform(conversation_id, last_queued_id)
conversation = Conversation.find(conversation_id)
return unless conversation.account.active?
if conversation.messages.incoming&.last&.content_type == 'incoming_email'
ConversationReplyMailer.with(account: conversation.account).reply_without_summary(conversation, last_queued_id).deliver_later
+1 -1
View File
@@ -54,7 +54,7 @@ class Webhooks::TiktokEventsJob < MutexApplicationJob
# Receive real-time notifications if you send a message to a user.
def im_send_msg
# This can be either an echo message or a message sent directly via tiktok application
::Tiktok::MessageService.new(channel: channel, content: content).perform
::Tiktok::MessageService.new(channel: channel, content: content, outgoing_echo: true).perform
end
# Receive real-time notifications if a user outside the European Economic Area (EEA), Switzerland, or the UK sends a message to you.
+50
View File
@@ -9,6 +9,56 @@ class Webhooks::WhatsappEventsJob < ApplicationJob
return
end
if message_echo_event?(params)
handle_message_echo(channel, params)
else
handle_message_events(channel, params)
end
end
# Detects if the webhook is an SMB message echo event (message sent from WhatsApp Business app)
# This is part of WhatsApp coexistence feature where businesses can respond from both
# Chatwoot and the WhatsApp Business app, with messages synced to Chatwoot.
#
# Regular message payload (field: "messages"):
# {
# "entry": [{
# "changes": [{
# "field": "messages",
# "value": {
# "contacts": [{ "wa_id": "919745786257", "profile": { "name": "Customer" } }],
# "messages": [{ "from": "919745786257", "id": "wamid...", "text": { "body": "Hello" } }]
# }
# }]
# }]
# }
#
# Echo message payload (field: "smb_message_echoes"):
# {
# "entry": [{
# "changes": [{
# "field": "smb_message_echoes",
# "value": {
# "message_echoes": [{ "from": "971545296927", "to": "919745786257", "id": "wamid...", "text": { "body": "Hi" } }]
# }
# }]
# }]
# }
#
# Key differences:
# - field: "smb_message_echoes" instead of "messages"
# - message_echoes[] instead of messages[]
# - "from" is the business number, "to" is the contact (reversed from regular messages)
# - No "contacts" array in echo payload
def message_echo_event?(params)
params.dig(:entry, 0, :changes, 0, :field) == 'smb_message_echoes'
end
def handle_message_echo(channel, params)
Whatsapp::IncomingMessageWhatsappCloudService.new(inbox: channel.inbox, params: params, outgoing_echo: true).perform
end
def handle_message_events(channel, params)
case channel.provider
when 'whatsapp_cloud'
Whatsapp::IncomingMessageWhatsappCloudService.new(inbox: channel.inbox, params: params).perform
+1
View File
@@ -38,6 +38,7 @@ class ConversationReplyMailer < ApplicationMailer
return unless smtp_config_set_or_development?
init_conversation_attributes(message.conversation)
@message = message
prepare_mail(true)
end
+1
View File
@@ -29,6 +29,7 @@ class Account < ApplicationRecord
include Featurable
include CacheKeys
include CaptainFeaturable
include AccountEmailRateLimitable
SETTINGS_PARAMS_SCHEMA = {
'type': 'object',
+2 -2
View File
@@ -41,8 +41,8 @@ class AutomationRule < ApplicationRecord
def actions_attributes
%w[send_message add_label remove_label send_email_to_team assign_team assign_agent send_webhook_event mute_conversation
send_attachment change_status resolve_conversation open_conversation snooze_conversation change_priority send_email_transcript
add_private_note].freeze
send_attachment change_status resolve_conversation open_conversation pending_conversation snooze_conversation change_priority
send_email_transcript add_private_note].freeze
end
def file_base_data
@@ -0,0 +1,49 @@
module AccountEmailRateLimitable
extend ActiveSupport::Concern
OUTBOUND_EMAIL_TTL = 25.hours.to_i
EMAIL_LIMIT_CONFIG_KEY = 'ACCOUNT_EMAILS_LIMIT'.freeze
def email_rate_limit
account_limit || global_limit || default_limit
end
def emails_sent_today
Redis::Alfred.get(email_count_cache_key).to_i
end
def within_email_rate_limit?
return true if emails_sent_today < email_rate_limit
Rails.logger.warn("Account #{id} reached daily email rate limit of #{email_rate_limit}. Sent: #{emails_sent_today}")
false
end
def increment_email_sent_count
Redis::Alfred.incr(email_count_cache_key).tap do |count|
Redis::Alfred.expire(email_count_cache_key, OUTBOUND_EMAIL_TTL) if count == 1
end
end
private
def email_count_cache_key
@email_count_cache_key ||= format(
Redis::Alfred::ACCOUNT_OUTBOUND_EMAIL_COUNT_KEY,
account_id: id,
date: Time.zone.today.to_s
)
end
def account_limit
self[:limits]&.dig('emails')&.to_i
end
def global_limit
GlobalConfig.get(EMAIL_LIMIT_CONFIG_KEY)[EMAIL_LIMIT_CONFIG_KEY]&.to_i
end
def default_limit
ChatwootApp.max_limit.to_i
end
end
+5
View File
@@ -19,6 +19,11 @@ class InstallationConfig < ApplicationRecord
# https://discuss.rubyonrails.org/t/cve-2022-32224-possible-rce-escalation-bug-with-serialized-columns-in-active-record/81017
# FIX ME : fixes breakage of installation config. we need to migrate.
# Fix configuration in application.rb
#
# Note: This whole thing is because we store the installation config serialized in YAML in Database
# This serialized version stores HashWithIndifferentAccess, We could avoid all this complexity if we store the value as JSONB
# We could also avoid this issue if we migrate the installation config to JSONB
# We should do this migration at some point in time.
serialize :serialized_value, coder: YAML, type: ActiveSupport::HashWithIndifferentAccess
before_validation :set_lock
+4 -2
View File
@@ -18,7 +18,7 @@ class Integrations::Hook < ApplicationRecord
include Reauthorizable
attr_readonly :app_id, :account_id, :inbox_id, :hook_type
before_validation :ensure_hook_type
before_validation :ensure_hook_type, on: :create
after_create :trigger_setup_if_crm
# TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out).
@@ -86,7 +86,9 @@ class Integrations::Hook < ApplicationRecord
end
def ensure_hook_type
self.hook_type = app.params[:hook_type] if app.present?
return if app.blank?
self.hook_type = app.params[:hook_type]
end
def validate_settings_json_schema
+4 -1
View File
@@ -344,10 +344,11 @@ class Message < ApplicationRecord
# if the sender is not a user, it's not a human response
# if automation rule id is present, it's not a human response
# if campaign id is present, it's not a human response
# external echo messages are responses sent from the native app (WhatsApp Business, Instagram)
outgoing? &&
content_attributes['automation_rule_id'].blank? &&
additional_attributes['campaign_id'].blank? &&
sender.is_a?(User)
(sender.is_a?(User) || content_attributes['external_echo'].present?)
end
def bot_response?
@@ -420,6 +421,8 @@ class Message < ApplicationRecord
end
def reindex_for_search
return unless respond_to?(:reindex)
reindex(mode: :async)
end
end
+3 -3
View File
@@ -69,7 +69,7 @@ class User < ApplicationRecord
# TODO: remove in a future version once online status is moved to account users
# remove the column availability from users
enum availability: { online: 0, offline: 1, busy: 2 }
enum :availability, { online: 0, offline: 1, busy: 2 }
# The validation below has been commented out as it does not
# work because :validatable in devise overrides this.
@@ -77,7 +77,7 @@ class User < ApplicationRecord
validates :email, presence: true
serialize :otp_backup_codes, type: Array
serialize :otp_backup_codes, coder: JSON, type: Array
# Encrypt sensitive MFA fields
encrypts :otp_secret, deterministic: true
@@ -88,7 +88,7 @@ class User < ApplicationRecord
accepts_nested_attributes_for :account_users
has_many :assigned_conversations, foreign_key: 'assignee_id', class_name: 'Conversation', dependent: :nullify, inverse_of: :assignee
alias_attribute :conversations, :assigned_conversations
alias conversations assigned_conversations
has_many :csat_survey_responses, foreign_key: 'assigned_agent_id', dependent: :nullify, inverse_of: :assigned_agent
has_many :reviewed_csat_survey_responses, foreign_key: 'review_notes_updated_by_id', class_name: 'CsatSurveyResponse',
dependent: :nullify, inverse_of: :review_notes_updated_by
+4
View File
@@ -22,6 +22,10 @@ class ActionService
@conversation.open!
end
def pending_conversation(_params)
@conversation.pending!
end
def change_status(status)
@conversation.update!(status: status[0])
end
@@ -82,7 +82,7 @@ class Crm::Leadsquared::SetupService
end
def update_hook_settings(params)
@hook.settings = @hook.settings.merge(params)
@hook.settings = @hook.settings.merge(params.stringify_keys)
@hook.save!
end
@@ -13,6 +13,7 @@ class Messages::SendEmailNotificationService
return unless Redis::Alfred.set(conversation_mail_key, message.id, nx: true, ex: 1.hour.to_i)
ConversationReplyEmailJob.set(wait: 2.minutes).perform_later(conversation.id, message.id)
message.account.increment_email_sent_count
end
private
@@ -20,6 +21,7 @@ class Messages::SendEmailNotificationService
def should_send_email_notification?
return false unless message.email_notifiable_message?
return false if message.conversation.contact.email.blank?
return false unless message.account.within_email_rate_limit?
email_reply_enabled?
end
@@ -7,15 +7,22 @@ class Notification::EmailNotificationService
# don't send emails if user is not confirmed
return if notification.user.confirmed_at.nil?
return unless user_subscribed_to_notification?
return unless notification.account.within_email_rate_limit?
# TODO : Clean up whatever happening over here
# Segregate the mailers properly
AgentNotifications::ConversationNotificationsMailer.with(account: notification.account).public_send(notification
.notification_type.to_s, notification.primary_actor, notification.user, notification.secondary_actor).deliver_later
send_notification_email
notification.account.increment_email_sent_count
end
private
# TODO : Clean up whatever happening over here
# Segregate the mailers properly
def send_notification_email
AgentNotifications::ConversationNotificationsMailer.with(account: notification.account).public_send(
notification.notification_type.to_s, notification.primary_actor, notification.user, notification.secondary_actor
).deliver_later
end
def user_subscribed_to_notification?
notification_setting = notification.user.notification_settings.find_by(account_id: notification.account.id)
return true if notification_setting.public_send("email_#{notification.notification_type}?")
+3 -3
View File
@@ -1,11 +1,10 @@
class Tiktok::MessageService
include Tiktok::MessagingHelpers
pattr_initialize [:channel!, :content!]
pattr_initialize [:channel!, :content!, :outgoing_echo]
def perform
if outgoing_message?
# Skip processing echo messages
message = find_message(tt_conversation_id, tt_message_id)
return if message.present?
end
@@ -39,7 +38,7 @@ class Tiktok::MessageService
updated_at: tt_message_time
)
message.sender = contact_inbox.contact if incoming_message?
message.sender = contact_inbox.contact if incoming_message? && !outgoing_echo
message.status = :delivered if outgoing_message?
create_message_attachments(message)
@@ -91,6 +90,7 @@ class Tiktok::MessageService
attributes = {}
attributes[:in_reply_to_external_id] = tt_referenced_message_id if tt_referenced_message_id
attributes[:is_unsupported] = true unless supported_message?
attributes[:external_echo] = true if outgoing_echo
attributes
end
+22 -2
View File
@@ -61,16 +61,36 @@ class Whatsapp::FacebookApiClient
end
def subscribe_waba_webhook(waba_id, callback_url, verify_token)
# Step 1: Subscribe app to WABA first (required before override)
# Meta requires the app to be subscribed before using override_callback_uri
# See: https://github.com/chatwoot/chatwoot/issues/13097
subscribe_app_to_waba(waba_id)
# Step 2: Override callback URL for this specific WABA
override_waba_callback(waba_id, callback_url, verify_token)
end
def subscribe_app_to_waba(waba_id)
response = HTTParty.post(
"#{BASE_URI}/#{@api_version}/#{waba_id}/subscribed_apps",
headers: request_headers
)
handle_response(response, 'App subscription to WABA failed')
end
def override_waba_callback(waba_id, callback_url, verify_token)
response = HTTParty.post(
"#{BASE_URI}/#{@api_version}/#{waba_id}/subscribed_apps",
headers: request_headers,
body: {
override_callback_uri: callback_url,
verify_token: verify_token
verify_token: verify_token,
subscribed_fields: %w[messages smb_message_echoes]
}.to_json
)
handle_response(response, 'Webhook subscription failed')
handle_response(response, 'Webhook callback override failed')
end
def unsubscribe_waba_webhook(waba_id)
@@ -4,18 +4,23 @@
class Whatsapp::IncomingMessageBaseService
include ::Whatsapp::IncomingMessageServiceHelpers
pattr_initialize [:inbox!, :params!]
pattr_initialize [:inbox!, :params!, :outgoing_echo]
def perform
processed_params
if processed_params.try(:[], :statuses).present?
process_statuses
elsif processed_params.try(:[], :messages).present?
elsif messages_data.present?
process_messages
end
end
# Returns messages array for both regular messages and echo events
def messages_data
@processed_params&.dig(:messages) || @processed_params&.dig(:message_echoes)
end
private
def process_messages
@@ -26,7 +31,7 @@ class Whatsapp::IncomingMessageBaseService
# Multiple webhook event can be received against the same message due to misconfigurations in the Meta
# business manager account. While we have not found the core reason yet, the following line ensure that
# there are no duplicate messages created.
return if find_message_by_source_id(@processed_params[:messages].first[:id]) || message_under_process?
return if find_message_by_source_id(messages_data.first[:id]) || message_under_process?
cache_message_source_id_in_redis
set_contact
@@ -57,7 +62,7 @@ class Whatsapp::IncomingMessageBaseService
end
def create_messages
message = @processed_params[:messages].first
message = messages_data.first
log_error(message) && return if error_webhook_event?(message)
process_in_reply_to(message)
@@ -67,20 +72,44 @@ class Whatsapp::IncomingMessageBaseService
def create_contact_messages(message)
message['contacts'].each do |contact|
create_message(contact)
# Pass source_id from parent message since contact objects don't have :id
create_message(contact, source_id: message[:id])
attach_contact(contact)
@message.save!
end
end
def create_regular_message(message)
create_message(message)
create_message(message, source_id: message[:id])
attach_files
attach_location if message_type == 'location'
@message.save!
end
def set_contact
if outgoing_echo
set_contact_from_echo
else
set_contact_from_message
end
end
def set_contact_from_echo
# For echo messages, contact phone is in the 'to' field
phone_number = messages_data.first[:to]
waid = processed_waid(phone_number)
contact_inbox = ::ContactInboxWithContactBuilder.new(
source_id: waid,
inbox: inbox,
contact_attributes: { name: "+#{phone_number}", phone_number: "+#{phone_number}" }
).perform
@contact_inbox = contact_inbox
@contact = contact_inbox.contact
end
def set_contact_from_message
contact_params = @processed_params[:contacts]&.first
return if contact_params.blank?
@@ -89,7 +118,7 @@ class Whatsapp::IncomingMessageBaseService
contact_inbox = ::ContactInboxWithContactBuilder.new(
source_id: waid,
inbox: inbox,
contact_attributes: { name: contact_params.dig(:profile, :name), phone_number: "+#{@processed_params[:messages].first[:from]}" }
contact_attributes: { name: contact_params.dig(:profile, :name), phone_number: "+#{messages_data.first[:from]}" }
).perform
@contact_inbox = contact_inbox
@@ -115,7 +144,7 @@ class Whatsapp::IncomingMessageBaseService
def attach_files
return if %w[text button interactive location contacts].include?(message_type)
attachment_payload = @processed_params[:messages].first[message_type.to_sym]
attachment_payload = messages_data.first[message_type.to_sym]
@message.content ||= attachment_payload[:caption]
attachment_file = download_attachment_file(attachment_payload)
@@ -133,7 +162,7 @@ class Whatsapp::IncomingMessageBaseService
end
def attach_location
location = @processed_params[:messages].first['location']
location = messages_data.first['location']
location_name = location['name'] ? "#{location['name']}, #{location['address']}" : ''
@message.attachments.new(
account_id: @message.account_id,
@@ -145,14 +174,17 @@ class Whatsapp::IncomingMessageBaseService
)
end
def create_message(message)
def create_message(message, source_id: nil)
@message = @conversation.messages.build(
content: message_content(message),
account_id: @inbox.account_id,
inbox_id: @inbox.id,
message_type: :incoming,
sender: @contact,
source_id: message[:id].to_s,
message_type: outgoing_echo ? :outgoing : :incoming,
# Set status to :delivered for echo messages to prevent SendReplyJob from trying to send them
status: outgoing_echo ? :delivered : :sent,
sender: outgoing_echo ? nil : @contact,
source_id: (source_id || message[:id]).to_s,
content_attributes: outgoing_echo ? { external_echo: true } : {},
in_reply_to_external_id: @in_reply_to_external_id
)
end
@@ -189,7 +221,7 @@ class Whatsapp::IncomingMessageBaseService
end
def contact_name_matches_phone_number?
phone_number = "+#{@processed_params[:messages].first[:from]}"
phone_number = "+#{messages_data.first[:from]}"
formatted_phone_number = TelephoneNumber.parse(phone_number).international_number
@contact.name == phone_number || @contact.name == formatted_phone_number
end
@@ -21,7 +21,7 @@ module Whatsapp::IncomingMessageServiceHelpers
end
def message_type
@processed_params[:messages].first[:type]
messages_data.first[:type]
end
def message_content(message)
@@ -70,19 +70,19 @@ module Whatsapp::IncomingMessageServiceHelpers
end
def message_under_process?
key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: @processed_params[:messages].first[:id])
key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: messages_data.first[:id])
Redis::Alfred.get(key)
end
def cache_message_source_id_in_redis
return if @processed_params.try(:[], :messages).blank?
return if messages_data.blank?
key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: @processed_params[:messages].first[:id])
key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: messages_data.first[:id])
::Redis::Alfred.setex(key, true)
end
def clear_message_source_id_from_redis
key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: @processed_params[:messages].first[:id])
key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: messages_data.first[:id])
::Redis::Alfred.delete(key)
end
end
+1 -1
View File
@@ -36,7 +36,7 @@ end
module Chatwoot
class Application < Rails::Application
# Initialize configuration defaults for originally generated Rails version.
config.load_defaults 7.0
config.load_defaults 7.2
config.eager_load_paths << Rails.root.join('lib')
config.eager_load_paths << Rails.root.join('enterprise/lib')
+2 -1
View File
@@ -13,7 +13,8 @@ end
# Velma : Determined protector
# used in rack attack
$velma = ConnectionPool.new(size: 5, timeout: 1) do
velma_size = ENV.fetch('REDIS_VELMA_SIZE', 10)
$velma = ConnectionPool.new(size: velma_size, timeout: 1) do
config = Rails.env.test? ? MockRedis.new : Redis.new(Redis::Config.app)
Redis::Namespace.new('velma', redis: config, warning: true)
end
+10
View File
@@ -107,6 +107,16 @@
value:
description: 'The support email address for your installation'
locked: false
- name: ACCOUNT_EMAILS_LIMIT
display_title: 'Account Email Sending Limit (Daily)'
description: 'Maximum number of non-channel emails an account can send per day'
value: 100
locked: false
- name: ACCOUNT_EMAILS_PLAN_LIMITS
display_title: 'Account Email Plan Limits (Daily)'
description: 'Per-plan daily email sending limits as JSON'
value:
type: code
# ------- End of Email Related Config ------- #
# ------- Facebook Channel Related Config ------- #
+2
View File
@@ -444,6 +444,8 @@ Rails.application.routes.draw do
get :conversations_summary
get :conversation_traffic
get :bot_metrics
get :inbox_label_matrix
get :first_response_time_distribution
end
end
resource :year_in_review, only: [:show]
+1 -1
View File
@@ -23,7 +23,7 @@ google:
# Use rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key)
microsoft:
service: AzureStorage
service: AzureBlob
storage_account_name: <%= ENV.fetch('AZURE_STORAGE_ACCOUNT_NAME', '') %>
storage_access_key: <%= ENV.fetch('AZURE_STORAGE_ACCESS_KEY', '') %>
container: <%= ENV.fetch('AZURE_STORAGE_CONTAINER', '') %>
@@ -12,8 +12,11 @@ class ArticleKeyConverter
def convert_key(id)
verifier_name = 'ActiveStorage'
key_generator = ActiveSupport::KeyGenerator.new(Rails.application.secrets.secret_key_base, iterations: 1000,
hash_digest_class: OpenSSL::Digest::SHA1)
secret_key_base = Rails.application.credentials.secret_key_base ||
Rails.application.secrets.secret_key_base
key_generator = ActiveSupport::KeyGenerator.new(secret_key_base,
iterations: 1000,
hash_digest_class: OpenSSL::Digest::SHA1)
key_generator = ActiveSupport::CachingKeyGenerator.new(key_generator)
secret = key_generator.generate_key(verifier_name.to_s)
verifier = ActiveSupport::MessageVerifier.new(secret)
@@ -0,0 +1,11 @@
class AddIndexToReportingEventsForResponseDistribution < ActiveRecord::Migration[7.1]
disable_ddl_transaction!
def change
add_index :reporting_events,
[:account_id, :name, :inbox_id, :created_at],
name: 'index_reporting_events_for_response_distribution',
algorithm: :concurrently,
if_not_exists: true
end
end
+2 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2026_01_20_121402) do
ActiveRecord::Schema[7.1].define(version: 2026_01_30_061021) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -1115,6 +1115,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_01_20_121402) do
t.datetime "event_start_time", precision: nil
t.datetime "event_end_time", precision: nil
t.index ["account_id", "name", "created_at"], name: "reporting_events__account_id__name__created_at"
t.index ["account_id", "name", "inbox_id", "created_at"], name: "index_reporting_events_for_response_distribution"
t.index ["account_id"], name: "index_reporting_events_on_account_id"
t.index ["conversation_id"], name: "index_reporting_events_on_conversation_id"
t.index ["created_at"], name: "index_reporting_events_on_created_at"
+2
View File
@@ -29,6 +29,8 @@ RUN apk update && apk add --no-cache \
tar \
build-base \
tzdata \
yaml-dev \
pkgconf \
postgresql-dev \
postgresql-client \
git \
@@ -2,6 +2,6 @@ require 'administrate/field/base'
class AccountLimitsField < Administrate::Field::Base
def to_s
data.present? ? data.to_json : { agents: nil, inboxes: nil, captain_responses: nil, captain_documents: nil }.to_json
data.present? ? data.to_json : { agents: nil, inboxes: nil, captain_responses: nil, captain_documents: nil, emails: nil }.to_json
end
end
@@ -1,4 +1,4 @@
module Enterprise::Account::PlanUsageAndLimits
module Enterprise::Account::PlanUsageAndLimits # rubocop:disable Metrics/ModuleLength
CAPTAIN_RESPONSES = 'captain_responses'.freeze
CAPTAIN_DOCUMENTS = 'captain_documents'.freeze
CAPTAIN_RESPONSES_USAGE = 'captain_responses_usage'.freeze
@@ -32,6 +32,10 @@ module Enterprise::Account::PlanUsageAndLimits
save
end
def email_rate_limit
account_limit || plan_email_limit || global_limit || default_limit
end
def subscribed_features
plan_features = InstallationConfig.find_by(name: 'CHATWOOT_CLOUD_PLAN_FEATURES')&.value
return [] if plan_features.blank?
@@ -68,6 +72,16 @@ module Enterprise::Account::PlanUsageAndLimits
}
end
def plan_email_limit
config = InstallationConfig.find_by(name: 'ACCOUNT_EMAILS_PLAN_LIMITS')&.value
return nil if config.blank? || plan_name.blank?
parsed = config.is_a?(String) ? JSON.parse(config) : config
parsed[plan_name.downcase]&.to_i
rescue StandardError
nil
end
def default_captain_limits
max_limits = { documents: ChatwootApp.max_limit, responses: ChatwootApp.max_limit }.with_indifferent_access
zero_limits = { documents: 0, responses: 0 }.with_indifferent_access
@@ -119,7 +133,8 @@ module Enterprise::Account::PlanUsageAndLimits
'inboxes' => { 'type': 'number' },
'agents' => { 'type': 'number' },
'captain_responses' => { 'type': 'number' },
'captain_documents' => { 'type': 'number' }
'captain_documents' => { 'type': 'number' },
'emails' => { 'type': 'number' }
},
'required' => [],
'additionalProperties' => false
@@ -1,4 +1,6 @@
class Captain::Tools::BaseTool < RubyLLM::Tool
prepend Captain::Tools::Instrumentation
attr_accessor :assistant
def initialize(assistant, user: nil)
@@ -0,0 +1,10 @@
module Captain::Tools::Instrumentation
extend ActiveSupport::Concern
include Integrations::LlmInstrumentation
def execute(**args)
instrument_tool_call(name, args) do
super
end
end
end
@@ -0,0 +1,42 @@
class Captain::Tools::SearchReplyDocumentationService < RubyLLM::Tool
prepend Captain::Tools::Instrumentation
description 'Search and retrieve documentation/FAQs from knowledge base'
param :query, desc: 'Search Query', required: true
def initialize(account:, assistant: nil)
@account = account
@assistant = assistant
super()
end
def name
'search_documentation'
end
def execute(query:)
Rails.logger.info { "#{self.class.name}: #{query}" }
responses = search_responses(query)
return 'No FAQs found for the given query' if responses.empty?
responses.map { |response| format_response(response) }.join
end
private
def search_responses(query)
if @assistant.present?
@assistant.responses.approved.search(query, account_id: @account.id)
else
@account.captain_assistant_responses.approved.search(query, account_id: @account.id)
end
end
def format_response(response)
result = "\nQuestion: #{response.question}\nAnswer: #{response.answer}\n"
result += "Source: #{response.documentable.external_link}\n" if response.documentable.present? && response.documentable.try(:external_link)
result
end
end
@@ -0,0 +1,24 @@
module Enterprise::Captain::ReplySuggestionService
def make_api_call(model:, messages:, tools: [])
return super unless use_search_tool?
super(model: model, messages: messages, tools: [build_search_tool])
end
private
def use_search_tool?
ChatwootApp.chatwoot_cloud? || ChatwootApp.self_hosted_enterprise?
end
def prompt_variables
return super unless use_search_tool?
super.merge('has_search_tool' => true)
end
def build_search_tool
assistant = conversation&.inbox&.captain_assistant
Captain::Tools::SearchReplyDocumentationService.new(account: account, assistant: assistant)
end
end
+24 -16
View File
@@ -1,5 +1,6 @@
class Captain::BaseTaskService
include Integrations::LlmInstrumentation
include Captain::ToolInstrumentation
# gpt-4o-mini supports 128,000 tokens
# 1 token is approx 4 characters
@@ -35,44 +36,52 @@ class Captain::BaseTaskService
"#{endpoint}/v1"
end
def make_api_call(model:, messages:)
def make_api_call(model:, messages:, tools: [])
# Community edition prerequisite checks
# Enterprise module handles these with more specific error messages (cloud vs self-hosted)
return { error: I18n.t('captain.disabled'), error_code: 403 } unless captain_tasks_enabled?
return { error: I18n.t('captain.api_key_missing'), error_code: 401 } unless api_key_configured?
instrumentation_params = build_instrumentation_params(model, messages)
instrumentation_method = tools.any? ? :instrument_tool_session : :instrument_llm_call
response = instrument_llm_call(instrumentation_params) do
execute_ruby_llm_request(model: model, messages: messages)
response = send(instrumentation_method, instrumentation_params) do
execute_ruby_llm_request(model: model, messages: messages, tools: tools)
end
# Build follow-up context for client-side refinement, when applicable
if build_follow_up_context? && response[:message].present?
response.merge(follow_up_context: build_follow_up_context(messages, response))
else
response
end
return response unless build_follow_up_context? && response[:message].present?
response.merge(follow_up_context: build_follow_up_context(messages, response))
end
def execute_ruby_llm_request(model:, messages:)
def execute_ruby_llm_request(model:, messages:, tools: [])
Llm::Config.with_api_key(api_key, api_base: api_base) do |context|
chat = context.chat(model: model)
system_msg = messages.find { |m| m[:role] == 'system' }
chat.with_instructions(system_msg[:content]) if system_msg
chat = build_chat(context, model: model, messages: messages, tools: tools)
conversation_messages = messages.reject { |m| m[:role] == 'system' }
return { error: 'No conversation messages provided', error_code: 400, request_messages: messages } if conversation_messages.empty?
add_messages_if_needed(chat, conversation_messages)
response = chat.ask(conversation_messages.last[:content])
build_ruby_llm_response(response, messages)
build_ruby_llm_response(chat.ask(conversation_messages.last[:content]), messages)
end
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: account).capture_exception
{ error: e.message, request_messages: messages }
end
def build_chat(context, model:, messages:, tools: [])
chat = context.chat(model: model)
system_msg = messages.find { |m| m[:role] == 'system' }
chat.with_instructions(system_msg[:content]) if system_msg
if tools.any?
tools.each { |tool| chat = chat.with_tool(tool) }
chat.on_end_message { |message| record_generation(chat, message, model) }
end
chat
end
def add_messages_if_needed(chat, conversation_messages)
return if conversation_messages.length == 1
@@ -177,5 +186,4 @@ class Captain::BaseTaskService
user_msg ? user_msg[:content] : nil
end
end
Captain::BaseTaskService.prepend_mod_with('Captain::BaseTaskService')
+2
View File
@@ -38,3 +38,5 @@ class Captain::ReplySuggestionService < Captain::BaseTaskService
'reply_suggestion'
end
end
Captain::ReplySuggestionService.prepend_mod_with('Captain::ReplySuggestionService')
+48
View File
@@ -0,0 +1,48 @@
module Captain::ToolInstrumentation
extend ActiveSupport::Concern
private
# Custom instrumentation for tool flows - outputs just the message (not full hash)
def instrument_tool_session(params)
return yield unless ChatwootApp.otel_enabled?
response = nil
executed = false
tracer.in_span(params[:span_name]) do |span|
span.set_attribute('langfuse.user.id', params[:account_id].to_s) if params[:account_id]
span.set_attribute('langfuse.tags', [params[:feature_name]].to_json)
span.set_attribute('langfuse.observation.input', params[:messages].to_json)
response = yield
executed = true
# Output just the message for cleaner Langfuse display
span.set_attribute('langfuse.observation.output', response[:message] || response.to_json)
end
response
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: account).capture_exception
executed ? response : yield
end
def record_generation(chat, message, model)
return unless ChatwootApp.otel_enabled?
return unless message.respond_to?(:role) && message.role.to_s == 'assistant'
tracer.in_span("llm.#{event_name}.generation") do |span|
span.set_attribute('gen_ai.system', 'openai')
span.set_attribute('gen_ai.request.model', model)
span.set_attribute('gen_ai.usage.input_tokens', message.input_tokens)
span.set_attribute('gen_ai.usage.output_tokens', message.output_tokens) if message.respond_to?(:output_tokens)
span.set_attribute('langfuse.observation.input', format_chat_messages(chat))
span.set_attribute('langfuse.observation.output', message.content.to_s) if message.respond_to?(:content)
end
rescue StandardError => e
Rails.logger.warn "Failed to record generation: #{e.message}"
end
def format_chat_messages(chat)
chat.messages[0...-1].map { |m| { role: m.role.to_s, content: m.content.to_s } }.to_json
end
end
+4
View File
@@ -21,6 +21,10 @@ module ChatwootApp
enterprise? && GlobalConfig.get_value('DEPLOYMENT_ENV') == 'cloud'
end
def self.self_hosted_enterprise?
enterprise? && !chatwoot_cloud? && GlobalConfig.get_value('INSTALLATION_PRICING_PLAN') == 'enterprise'
end
def self.custom?
@custom ||= root.join('custom').exist?
end
@@ -31,5 +31,10 @@ General guidelines:
- Move the conversation forward
- Do not invent product details, policies, or links that weren't mentioned
- Reply in the customer's language
{% if has_search_tool %}
**Important**: You have access to a `search_documentation` tool that can search the company's knowledge base for product details, policies, FAQs, and other information.
**Use the search_documentation tool first** to find relevant information before composing your reply. This ensures your response is accurate and based on actual company documentation.
{% endif %}
Output only the reply.
+3
View File
@@ -49,4 +49,7 @@ module Redis::RedisKeys
# Track conversation assignments to agents for rate limiting
ASSIGNMENT_KEY = 'ASSIGNMENT::%<inbox_id>d::AGENT::%<agent_id>d::CONVERSATION::%<conversation_id>d'.freeze
ASSIGNMENT_KEY_PATTERN = 'ASSIGNMENT::%<inbox_id>d::AGENT::%<agent_id>d::*'.freeze
## Account Email Rate Limiting
ACCOUNT_OUTBOUND_EMAIL_COUNT_KEY = 'OUTBOUND_EMAIL_COUNT::%<account_id>d::%<date>s'.freeze
end
+1 -1
View File
@@ -15,7 +15,7 @@ class Seeders::Reports::MessageCreator
end
def create_messages
message_count = rand(MESSAGES_PER_CONVERSATION..MESSAGES_PER_CONVERSATION + 5)
message_count = rand(MESSAGES_PER_CONVERSATION..(MESSAGES_PER_CONVERSATION + 5))
first_agent_reply = true
message_count.times do |i|
+2 -2
View File
@@ -144,8 +144,8 @@
"prosemirror-model": "^1.22.3",
"size-limit": "^8.2.4",
"tailwindcss": "^3.4.13",
"vite": "^5.4.21",
"vite-plugin-ruby": "^5.0.0",
"vite": "5.4.21",
"vite-plugin-ruby": "^5.1.1",
"vitest": "3.0.5"
},
"engines": {
+9 -9
View File
@@ -351,8 +351,8 @@ importers:
specifier: 5.4.21
version: 5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
vite-plugin-ruby:
specifier: ^5.0.0
version: 5.0.0(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
specifier: ^5.1.1
version: 5.1.1(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
vitest:
specifier: 3.0.5
version: 3.0.5(@types/node@22.7.0)(jsdom@27.2.0)(sass@1.79.3)(terser@5.33.0)
@@ -4519,8 +4519,8 @@ packages:
engines: {node: ^18.0.0 || >=20.0.0}
hasBin: true
vite-plugin-ruby@5.0.0:
resolution: {integrity: sha512-c8PjTp21Ah/ttgnNUyu0qvCXZI08Jr9I24oUKg3TRIRhF5GcOZ++6wtlTCrNFd9COEQbpXHxlRIXd/MEg0iZJw==}
vite-plugin-ruby@5.1.1:
resolution: {integrity: sha512-I1dXJq2ywdvTD2Cz5LYNcYLujqQ3eUxPoCjruRdfm2QBtHBY15NEeb6x5HuPM3T5S+y8S3p9fwRsieQQCjk0gg==}
peerDependencies:
vite: 5.4.21
@@ -6245,7 +6245,7 @@ snapshots:
agent-base@6.0.2:
dependencies:
debug: 4.4.0
debug: 4.4.3
transitivePeerDependencies:
- supports-color
@@ -7647,7 +7647,7 @@ snapshots:
dependencies:
'@tootallnate/once': 2.0.0
agent-base: 6.0.2
debug: 4.4.0
debug: 4.4.3
transitivePeerDependencies:
- supports-color
@@ -7661,7 +7661,7 @@ snapshots:
https-proxy-agent@5.0.1:
dependencies:
agent-base: 6.0.2
debug: 4.4.0
debug: 4.4.3
transitivePeerDependencies:
- supports-color
@@ -9599,9 +9599,9 @@ snapshots:
- supports-color
- terser
vite-plugin-ruby@5.0.0(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)):
vite-plugin-ruby@5.1.1(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)):
dependencies:
debug: 4.3.5
debug: 4.4.3
fast-glob: 3.3.2
vite: 5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
transitivePeerDependencies:
+16 -9
View File
@@ -1,15 +1,22 @@
require 'rails_helper'
describe V2::ReportBuilder do
RSpec.describe V2::ReportBuilder do
include ActiveJob::TestHelper
let_it_be(:account) { create(:account) }
let_it_be(:label_1) { create(:label, title: 'Label_1', account: account) }
let_it_be(:label_2) { create(:label, title: 'Label_2', account: account) }
self.use_transactional_tests = false
def truncate_test_data
connection = ActiveRecord::Base.connection
connection.truncate_tables(*connection.tables)
end
before { truncate_test_data }
let(:account) { create(:account) }
let!(:label_1) { create(:label, title: 'Label_1', account: account) }
let!(:label_2) { create(:label, title: 'Label_2', account: account) }
describe '#timeseries' do
# Use before_all to share expensive setup across all tests in this describe block
# This runs once instead of 21 times, dramatically speeding up the suite
before_all do
before do
travel_to(Time.zone.today) do
user = create(:user, account: account)
inbox = create(:inbox, account: account)
@@ -21,7 +28,7 @@ describe V2::ReportBuilder do
perform_enqueued_jobs do
10.times do
conversation = create(:conversation, account: account,
inbox: inbox, assignee: user,
inbox: inbox,
created_at: Time.zone.today)
create_list(:message, 5, message_type: 'outgoing',
account: account, inbox: inbox,
@@ -37,7 +44,7 @@ describe V2::ReportBuilder do
5.times do
conversation = create(:conversation, account: account,
inbox: inbox, assignee: user,
inbox: inbox,
created_at: (Time.zone.today - 2.days))
create_list(:message, 3, message_type: 'outgoing',
account: account, inbox: inbox,
@@ -0,0 +1,145 @@
require 'rails_helper'
RSpec.describe V2::Reports::FirstResponseTimeDistributionBuilder do
let!(:account) { create(:account) }
let!(:web_widget_inbox) { create(:inbox, account: account, channel: create(:channel_widget, account: account)) }
let!(:email_inbox) { create(:inbox, account: account, channel: create(:channel_email, account: account)) }
let(:params) do
{
since: 1.week.ago.beginning_of_day.to_i.to_s,
until: Time.current.end_of_day.to_i.to_s
}
end
let(:builder) { described_class.new(account: account, params: params) }
describe '#build' do
subject(:report) { builder.build }
context 'when there are first response events across channels and time buckets' do
before do
# Web Widget: 0-1h bucket (30 minutes = 1800 seconds)
create(:reporting_event, account: account, inbox: web_widget_inbox, name: 'first_response',
value: 1_800, created_at: 2.days.ago)
# Web Widget: 1-4h bucket (2 hours = 7200 seconds)
create(:reporting_event, account: account, inbox: web_widget_inbox, name: 'first_response',
value: 7_200, created_at: 2.days.ago)
# Web Widget: 4-8h bucket (6 hours = 21600 seconds)
create(:reporting_event, account: account, inbox: web_widget_inbox, name: 'first_response',
value: 21_600, created_at: 3.days.ago)
# Email: 8-24h bucket (12 hours = 43200 seconds)
create(:reporting_event, account: account, inbox: email_inbox, name: 'first_response',
value: 43_200, created_at: 2.days.ago)
# Email: 24h+ bucket (48 hours = 172800 seconds)
create(:reporting_event, account: account, inbox: email_inbox, name: 'first_response',
value: 172_800, created_at: 1.day.ago)
end
it 'returns correct distribution for web widget channel' do
expect(report['Channel::WebWidget']).to eq({
'0-1h' => 1,
'1-4h' => 1,
'4-8h' => 1,
'8-24h' => 0,
'24h+' => 0
})
end
it 'returns correct distribution for email channel' do
expect(report['Channel::Email']).to eq({
'0-1h' => 0,
'1-4h' => 0,
'4-8h' => 0,
'8-24h' => 1,
'24h+' => 1
})
end
end
context 'when filtering by date range' do
before do
create(:reporting_event, account: account, inbox: web_widget_inbox, name: 'first_response',
value: 1_800, created_at: 2.days.ago)
create(:reporting_event, account: account, inbox: web_widget_inbox, name: 'first_response',
value: 1_800, created_at: 2.weeks.ago)
end
it 'only counts events within the date range' do
expect(report['Channel::WebWidget']['0-1h']).to eq(1)
end
end
context 'when there are no first response events' do
it 'returns an empty hash' do
expect(report).to eq({})
end
end
context 'when events belong to another account' do
let(:other_account) { create(:account) }
let(:other_inbox) { create(:inbox, account: other_account) }
before do
create(:reporting_event, account: other_account, inbox: other_inbox, name: 'first_response',
value: 1_800, created_at: 2.days.ago)
end
it 'does not include events from other accounts' do
expect(report).to eq({})
end
end
context 'when events have different names' do
before do
create(:reporting_event, account: account, inbox: web_widget_inbox, name: 'first_response',
value: 1_800, created_at: 2.days.ago)
create(:reporting_event, account: account, inbox: web_widget_inbox, name: 'conversation_resolved',
value: 1_800, created_at: 2.days.ago)
create(:reporting_event, account: account, inbox: web_widget_inbox, name: 'reply_time',
value: 1_800, created_at: 2.days.ago)
end
it 'only counts first_response events' do
expect(report['Channel::WebWidget']['0-1h']).to eq(1)
end
end
context 'when no date range params are provided' do
let(:params) { {} }
before do
create(:reporting_event, account: account, inbox: web_widget_inbox, name: 'first_response',
value: 1_800, created_at: 2.days.ago)
create(:reporting_event, account: account, inbox: web_widget_inbox, name: 'first_response',
value: 1_800, created_at: 2.months.ago)
end
it 'returns all events without date filtering' do
expect(report['Channel::WebWidget']['0-1h']).to eq(2)
end
end
context 'with boundary values for time buckets' do
before do
# Exactly at 1 hour boundary (should be in 1-4h bucket)
create(:reporting_event, account: account, inbox: web_widget_inbox, name: 'first_response',
value: 3_600, created_at: 2.days.ago)
# Just under 1 hour (should be in 0-1h bucket)
create(:reporting_event, account: account, inbox: web_widget_inbox, name: 'first_response',
value: 3_599, created_at: 2.days.ago)
# Exactly at 24 hour boundary (should be in 24h+ bucket)
create(:reporting_event, account: account, inbox: web_widget_inbox, name: 'first_response',
value: 86_400, created_at: 2.days.ago)
end
it 'correctly assigns boundary values to buckets' do
expect(report['Channel::WebWidget']).to eq({
'0-1h' => 1,
'1-4h' => 1,
'4-8h' => 0,
'8-24h' => 0,
'24h+' => 1
})
end
end
end
end
@@ -0,0 +1,135 @@
require 'rails_helper'
RSpec.describe V2::Reports::InboxLabelMatrixBuilder do
let!(:account) { create(:account) }
let!(:inbox_one) { create(:inbox, account: account, name: 'Email Support') }
let!(:inbox_two) { create(:inbox, account: account, name: 'Web Chat') }
let!(:label_one) { create(:label, account: account, title: 'bug') }
let!(:label_two) { create(:label, account: account, title: 'feature') }
let(:params) do
{
since: 1.week.ago.beginning_of_day.to_i.to_s,
until: Time.current.end_of_day.to_i.to_s
}
end
let(:builder) { described_class.new(account: account, params: params) }
describe '#build' do
subject(:report) { builder.build }
context 'when there are conversations with labels across inboxes' do
before do
c1 = create(:conversation, account: account, inbox: inbox_one, created_at: 2.days.ago)
c1.update(label_list: [label_one.title])
c2 = create(:conversation, account: account, inbox: inbox_one, created_at: 3.days.ago)
c2.update(label_list: [label_one.title, label_two.title])
c3 = create(:conversation, account: account, inbox: inbox_two, created_at: 1.day.ago)
c3.update(label_list: [label_two.title])
end
it 'returns inboxes ordered by name' do
expect(report[:inboxes]).to eq([
{ id: inbox_one.id, name: 'Email Support' },
{ id: inbox_two.id, name: 'Web Chat' }
])
end
it 'returns labels ordered by title' do
expect(report[:labels]).to eq([
{ id: label_one.id, title: 'bug' },
{ id: label_two.id, title: 'feature' }
])
end
it 'returns correct conversation counts in the matrix' do
# Email Support: bug=2, feature=1
# Web Chat: bug=0, feature=1
expect(report[:matrix]).to eq([[2, 1], [0, 1]])
end
end
context 'when filtering by inbox_ids' do
let(:params) do
{
since: 1.week.ago.beginning_of_day.to_i.to_s,
until: Time.current.end_of_day.to_i.to_s,
inbox_ids: [inbox_one.id]
}
end
before do
c1 = create(:conversation, account: account, inbox: inbox_one, created_at: 2.days.ago)
c1.update(label_list: [label_one.title])
c2 = create(:conversation, account: account, inbox: inbox_two, created_at: 1.day.ago)
c2.update(label_list: [label_one.title])
end
it 'only includes the specified inboxes and their counts' do
expect(report[:inboxes]).to eq([{ id: inbox_one.id, name: 'Email Support' }])
expect(report[:matrix]).to eq([[1, 0]])
end
end
context 'when filtering by label_ids' do
let(:params) do
{
since: 1.week.ago.beginning_of_day.to_i.to_s,
until: Time.current.end_of_day.to_i.to_s,
label_ids: [label_one.id]
}
end
before do
c1 = create(:conversation, account: account, inbox: inbox_one, created_at: 2.days.ago)
c1.update(label_list: [label_one.title, label_two.title])
end
it 'only includes the specified labels and their counts' do
expect(report[:labels]).to eq([{ id: label_one.id, title: 'bug' }])
expect(report[:matrix]).to eq([[1], [0]])
end
end
context 'when conversations are outside the date range' do
before do
c1 = create(:conversation, account: account, inbox: inbox_one, created_at: 2.days.ago)
c1.update(label_list: [label_one.title])
c2 = create(:conversation, account: account, inbox: inbox_one, created_at: 2.weeks.ago)
c2.update(label_list: [label_one.title])
end
it 'only counts conversations within the date range' do
expect(report[:matrix]).to eq([[1, 0], [0, 0]])
end
end
context 'when there are no conversations with labels' do
before do
create(:conversation, account: account, inbox: inbox_one, created_at: 2.days.ago)
end
it 'returns a matrix of zeros' do
expect(report[:matrix]).to eq([[0, 0], [0, 0]])
end
end
context 'when conversations belong to another account' do
let(:other_account) { create(:account) }
let(:other_inbox) { create(:inbox, account: other_account) }
before do
c1 = create(:conversation, account: other_account, inbox: other_inbox, created_at: 2.days.ago)
other_label = create(:label, account: other_account, title: 'bug')
c1.update(label_list: [other_label.title])
end
it 'does not include conversations from other accounts' do
expect(report[:matrix]).to eq([[0, 0], [0, 0]])
end
end
end
end
@@ -2,11 +2,19 @@ require 'rails_helper'
RSpec.describe V2::Reports::LabelSummaryBuilder do
include ActiveJob::TestHelper
self.use_transactional_tests = false
let_it_be(:account) { create(:account) }
let_it_be(:label_1) { create(:label, title: 'label_1', account: account) }
let_it_be(:label_2) { create(:label, title: 'label_2', account: account) }
let_it_be(:label_3) { create(:label, title: 'label_3', account: account) }
def truncate_test_data
connection = ActiveRecord::Base.connection
connection.truncate_tables(*connection.tables)
end
before { truncate_test_data }
let(:account) { create(:account) }
let!(:label_1) { create(:label, title: 'label_1', account: account) }
let!(:label_2) { create(:label, title: 'label_2', account: account) }
let!(:label_3) { create(:label, title: 'label_3', account: account) }
let(:params) do
{
@@ -92,7 +100,7 @@ RSpec.describe V2::Reports::LabelSummaryBuilder do
# Create conversations with label_1
3.times do
conversation = create(:conversation, account: account,
inbox: inbox, assignee: user,
inbox: inbox,
created_at: Time.zone.today)
create_list(:message, 2, message_type: 'outgoing',
account: account, inbox: inbox,
@@ -110,7 +118,7 @@ RSpec.describe V2::Reports::LabelSummaryBuilder do
# Create conversations with label_2
2.times do
conversation = create(:conversation, account: account,
inbox: inbox, assignee: user,
inbox: inbox,
created_at: Time.zone.today)
create_list(:message, 1, message_type: 'outgoing',
account: account, inbox: inbox,
@@ -229,7 +237,7 @@ RSpec.describe V2::Reports::LabelSummaryBuilder do
perform_enqueued_jobs do
# Conversation within range
conversation_in_range = create(:conversation, account: account,
inbox: inbox, assignee: user,
inbox: inbox,
created_at: 2.days.ago)
conversation_in_range.update_labels('label_1')
conversation_in_range.label_list
@@ -244,7 +252,7 @@ RSpec.describe V2::Reports::LabelSummaryBuilder do
# Conversation outside range (too old)
conversation_out_of_range = create(:conversation, account: account,
inbox: inbox, assignee: user,
inbox: inbox,
created_at: 1.week.ago)
conversation_out_of_range.update_labels('label_1')
conversation_out_of_range.label_list
@@ -286,7 +294,7 @@ RSpec.describe V2::Reports::LabelSummaryBuilder do
perform_enqueued_jobs do
conversation = create(:conversation, account: account,
inbox: inbox, assignee: user,
inbox: inbox,
created_at: Time.zone.today)
conversation.update_labels('label_1')
conversation.label_list
@@ -323,8 +331,8 @@ RSpec.describe V2::Reports::LabelSummaryBuilder do
let(:account2_builder) do
described_class.new(account: account2, params: {
business_hours: false,
since: test_date.to_time.to_i.to_s,
until: test_date.end_of_day.to_time.to_i.to_s,
since: test_date.in_time_zone.to_i.to_s,
until: test_date.end_of_day.in_time_zone.to_i.to_s,
timezone_offset: 0
})
end
@@ -343,7 +351,7 @@ RSpec.describe V2::Reports::LabelSummaryBuilder do
perform_enqueued_jobs do
conversation = create(:conversation, account: account2,
inbox: inbox, assignee: user,
inbox: inbox,
created_at: test_date)
conversation.update_labels(unique_label_name)
conversation.label_list
@@ -358,6 +366,7 @@ RSpec.describe V2::Reports::LabelSummaryBuilder do
# Second resolution
conversation.resolved!
end
perform_enqueued_jobs
end
end
@@ -388,7 +388,7 @@ RSpec.describe 'Api::V1::Accounts::MacrosController', type: :request do
headers: administrator.create_new_auth_token
end
expect(conversation.messages.activity.last.content).to eq("Assigned to #{user_1.name} by #{administrator.name}")
expect(conversation.reload.assignee_id).to eq(user_1.id)
end
it 'Assign the agent when he is not inbox member' do
@@ -402,7 +402,7 @@ RSpec.describe 'Api::V1::Accounts::MacrosController', type: :request do
headers: administrator.create_new_auth_token
end
expect(conversation.messages.activity.last.content).not_to eq("Assigned to #{user_1.name} by #{administrator.name}")
expect(conversation.reload.assignee_id).to be_nil
end
it 'Assign the labels' do
@@ -196,4 +196,103 @@ RSpec.describe Api::V2::Accounts::ReportsController, type: :request do
end
end
end
describe 'GET /api/v2/accounts/{account.id}/reports/inbox_label_matrix' do
let!(:inbox_one) { create(:inbox, account: account, name: 'Email Support') }
let!(:label_one) { create(:label, account: account, title: 'bug') }
context 'when unauthenticated' do
it 'returns unauthorized' do
get "/api/v2/accounts/#{account.id}/reports/inbox_label_matrix"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when authenticated as agent' do
it 'returns unauthorized' do
get "/api/v2/accounts/#{account.id}/reports/inbox_label_matrix",
headers: agent.create_new_auth_token, as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when authenticated as admin' do
before do
c1 = create(:conversation, account: account, inbox: inbox_one, created_at: 2.days.ago)
c1.update(label_list: [label_one.title])
end
it 'returns the inbox label matrix' do
get "/api/v2/accounts/#{account.id}/reports/inbox_label_matrix",
params: { since: 1.week.ago.to_i.to_s, until: Time.current.to_i.to_s },
headers: admin.create_new_auth_token, as: :json
expect(response).to have_http_status(:success)
body = response.parsed_body
expect(body['inboxes']).to be_an(Array)
expect(body['labels']).to be_an(Array)
expect(body['matrix']).to be_an(Array)
end
it 'filters by inbox_ids and label_ids' do
get "/api/v2/accounts/#{account.id}/reports/inbox_label_matrix",
params: { inbox_ids: [inbox_one.id], label_ids: [label_one.id] },
headers: admin.create_new_auth_token, as: :json
expect(response).to have_http_status(:success)
body = response.parsed_body
expect(body['inboxes'].length).to eq(1)
expect(body['labels'].length).to eq(1)
end
end
end
describe 'GET /api/v2/accounts/{account.id}/reports/first_response_time_distribution' do
let!(:web_widget_inbox) { create(:inbox, account: account, channel: create(:channel_widget, account: account)) }
context 'when unauthenticated' do
it 'returns unauthorized' do
get "/api/v2/accounts/#{account.id}/reports/first_response_time_distribution"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when authenticated as agent' do
it 'returns unauthorized' do
get "/api/v2/accounts/#{account.id}/reports/first_response_time_distribution",
headers: agent.create_new_auth_token, as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when authenticated as admin' do
before do
create(:reporting_event, account: account, inbox: web_widget_inbox, name: 'first_response',
value: 1_800, created_at: 2.days.ago)
end
it 'returns the first response time distribution' do
get "/api/v2/accounts/#{account.id}/reports/first_response_time_distribution",
params: { since: 1.week.ago.to_i.to_s, until: Time.current.to_i.to_s },
headers: admin.create_new_auth_token, as: :json
expect(response).to have_http_status(:success)
body = response.parsed_body
expect(body).to be_a(Hash)
expect(body['Channel::WebWidget']).to include('0-1h', '1-4h', '4-8h', '8-24h', '24h+')
end
it 'returns correct counts in buckets' do
get "/api/v2/accounts/#{account.id}/reports/first_response_time_distribution",
params: { since: 1.week.ago.to_i.to_s, until: Time.current.to_i.to_s },
headers: admin.create_new_auth_token, as: :json
body = response.parsed_body
expect(body['Channel::WebWidget']['0-1h']).to eq(1)
end
end
end
end
+14 -18
View File
@@ -1,12 +1,6 @@
require 'rails_helper'
RSpec.describe BulkActionsJob do
params = {
type: 'Conversation',
fields: { status: 'snoozed' },
ids: Conversation.first(3).pluck(:display_id)
}
subject(:job) { described_class.perform_later(account: account, params: params, user: agent) }
let(:account) { create(:account) }
@@ -14,6 +8,8 @@ RSpec.describe BulkActionsJob do
let!(:conversation_1) { create(:conversation, account_id: account.id, status: :open) }
let!(:conversation_2) { create(:conversation, account_id: account.id, status: :open) }
let!(:conversation_3) { create(:conversation, account_id: account.id, status: :open) }
let(:conversation_ids) { Conversation.where(id: [conversation_1.id, conversation_2.id, conversation_3.id]).pluck(:display_id) }
let(:params) { { type: 'Conversation', fields: { status: 'snoozed' }, ids: conversation_ids } }
before do
Conversation.all.find_each do |conversation|
@@ -38,10 +34,10 @@ RSpec.describe BulkActionsJob do
params = {
type: 'Conversation',
fields: { status: 'snoozed', assignee_id: agent.id },
ids: Conversation.first(3).pluck(:display_id)
ids: conversation_ids
}
expect(Conversation.first.status).to eq('open')
expect(conversation_1.status).to eq('open')
described_class.perform_now(account: account, params: params, user: agent)
@@ -54,32 +50,32 @@ RSpec.describe BulkActionsJob do
params = {
type: 'Conversation',
fields: { status: 'snoozed', assignee_id: agent.id },
ids: Conversation.first(3).pluck(:display_id)
ids: conversation_ids
}
expect(Conversation.first.assignee_id).to be_nil
expect(conversation_1.assignee_id).to be_nil
described_class.perform_now(account: account, params: params, user: agent)
expect(Conversation.first.assignee_id).to eq(agent.id)
expect(Conversation.second.assignee_id).to eq(agent.id)
expect(Conversation.third.assignee_id).to eq(agent.id)
expect(conversation_1.reload.assignee_id).to eq(agent.id)
expect(conversation_2.reload.assignee_id).to eq(agent.id)
expect(conversation_3.reload.assignee_id).to eq(agent.id)
end
it 'bulk updates the snoozed_until' do
params = {
type: 'Conversation',
fields: { status: 'snoozed', snoozed_until: Time.zone.now },
ids: Conversation.first(3).pluck(:display_id)
ids: conversation_ids
}
expect(Conversation.first.snoozed_until).to be_nil
expect(conversation_1.snoozed_until).to be_nil
described_class.perform_now(account: account, params: params, user: agent)
expect(Conversation.first.snoozed_until).to be_present
expect(Conversation.second.snoozed_until).to be_present
expect(Conversation.third.snoozed_until).to be_present
expect(conversation_1.reload.snoozed_until).to be_present
expect(conversation_2.reload.snoozed_until).to be_present
expect(conversation_3.reload.snoozed_until).to be_present
end
end
end
@@ -161,11 +161,6 @@ RSpec.describe Captain::BaseTaskService do
end
end
it 'calls execute_ruby_llm_request with correct parameters' do
expect(service).to receive(:execute_ruby_llm_request).with(model: model, messages: messages).and_call_original
service.send(:make_api_call, model: model, messages: messages)
end
it 'instruments the LLM call' do
expect(service).to receive(:instrument_llm_call).and_call_original
service.send(:make_api_call, model: model, messages: messages)
@@ -19,6 +19,8 @@ RSpec.describe Captain::ReplySuggestionService do
mock_context = instance_double(RubyLLM::Context, chat: mock_chat)
allow(Llm::Config).to receive(:with_api_key).and_yield(mock_context)
allow(mock_chat).to receive(:with_tool).and_return(mock_chat)
allow(mock_chat).to receive(:on_end_message).and_return(mock_chat)
allow(mock_chat).to receive(:with_instructions) { |msg| captured_messages << { role: 'system', content: msg } }
allow(mock_chat).to receive(:add_message) { |args| captured_messages << args }
allow(mock_chat).to receive(:ask) do |msg|
@@ -1,11 +1,9 @@
# frozen_string_literal: true
RSpec.shared_context 'with smtp config' do
before do
# We need to use allow_any_instance_of here because smtp_config_set_or_development?
# is defined in ApplicationMailer and needs to be stubbed for all mailer instances
# rubocop:disable RSpec/AnyInstance
allow_any_instance_of(ApplicationMailer).to receive(:smtp_config_set_or_development?).and_return(true)
# rubocop:enable RSpec/AnyInstance
around do |example|
# Set SMTP_ADDRESS so mailers build a Mail::Message in test without touching real SMTP.
# Scoped to this shared context to avoid affecting other specs.
with_modified_env('SMTP_ADDRESS' => 'smtp.example.com') { example.run }
end
end
@@ -0,0 +1,63 @@
require 'rails_helper'
RSpec.describe AccountEmailRateLimitable do
let(:account) { create(:account) }
describe '#email_rate_limit' do
it 'returns account-level override when set' do
account.update!(limits: { 'emails' => 50 })
expect(account.email_rate_limit).to eq(50)
end
it 'returns global config when no account override' do
InstallationConfig.where(name: 'ACCOUNT_EMAILS_LIMIT').first_or_create(value: 200)
expect(account.email_rate_limit).to eq(200)
end
it 'returns account override over global config' do
InstallationConfig.where(name: 'ACCOUNT_EMAILS_LIMIT').first_or_create(value: 200)
account.update!(limits: { 'emails' => 50 })
expect(account.email_rate_limit).to eq(50)
end
end
describe '#within_email_rate_limit?' do
before do
account.update!(limits: { 'emails' => 2 })
end
it 'returns true when under limit' do
expect(account).to be_within_email_rate_limit
end
it 'returns false when at limit' do
2.times { account.increment_email_sent_count }
expect(account).not_to be_within_email_rate_limit
end
end
describe '#increment_email_sent_count' do
it 'increments the counter' do
expect { account.increment_email_sent_count }.to change(account, :emails_sent_today).by(1)
end
it 'sets TTL on first increment' do
key = format(Redis::Alfred::ACCOUNT_OUTBOUND_EMAIL_COUNT_KEY, account_id: account.id, date: Time.zone.today.to_s)
allow(Redis::Alfred).to receive(:incr).and_return(1)
allow(Redis::Alfred).to receive(:expire)
account.increment_email_sent_count
expect(Redis::Alfred).to have_received(:expire).with(key, AccountEmailRateLimitable::OUTBOUND_EMAIL_TTL)
end
it 'does not reset TTL on subsequent increments' do
allow(Redis::Alfred).to receive(:incr).and_return(2)
allow(Redis::Alfred).to receive(:expire)
account.increment_email_sent_count
expect(Redis::Alfred).not_to have_received(:expire)
end
end
end
+1 -2
View File
@@ -28,8 +28,7 @@ RSpec.describe ContactInbox do
obj.reload
# ensure the column is nil in database
results = ActiveRecord::Base.connection.execute('Select * from contact_inboxes;')
expect(results.first['pubsub_token']).to be_nil
expect(described_class.where(id: obj.id).pick(:pubsub_token)).to be_nil
new_token = obj.pubsub_token
obj.update(source_id: '234234323')
@@ -20,7 +20,7 @@ RSpec.describe Account::SignUpEmailValidationService, type: :service do
it 'raises InvalidEmail with invalid message' do
allow(ValidEmail2::Address).to receive(:new).with(email).and_return(invalid_email_address)
expect { service.perform }.to raise_error do |error|
expect(error).to be_a(CustomExceptions::Account::InvalidEmail)
expect(error.class.name).to eq('CustomExceptions::Account::InvalidEmail')
expect(error.message).to eq(I18n.t('errors.signup.invalid_email'))
end
end
@@ -32,7 +32,7 @@ RSpec.describe Account::SignUpEmailValidationService, type: :service do
it 'raises InvalidEmail with blocked domain message' do
allow(ValidEmail2::Address).to receive(:new).with(email).and_return(valid_email_address)
expect { service.perform }.to raise_error do |error|
expect(error).to be_a(CustomExceptions::Account::InvalidEmail)
expect(error.class.name).to eq('CustomExceptions::Account::InvalidEmail')
expect(error.message).to eq(I18n.t('errors.signup.blocked_domain'))
end
end
@@ -44,7 +44,7 @@ RSpec.describe Account::SignUpEmailValidationService, type: :service do
it 'raises InvalidEmail with blocked domain message' do
allow(ValidEmail2::Address).to receive(:new).with(email).and_return(valid_email_address)
expect { service.perform }.to raise_error do |error|
expect(error).to be_a(CustomExceptions::Account::InvalidEmail)
expect(error.class.name).to eq('CustomExceptions::Account::InvalidEmail')
expect(error.message).to eq(I18n.t('errors.signup.blocked_domain'))
end
end
@@ -56,7 +56,7 @@ RSpec.describe Account::SignUpEmailValidationService, type: :service do
it 'raises InvalidEmail with disposable message' do
allow(ValidEmail2::Address).to receive(:new).with(email).and_return(disposable_email_address)
expect { service.perform }.to raise_error do |error|
expect(error).to be_a(CustomExceptions::Account::InvalidEmail)
expect(error.class.name).to eq('CustomExceptions::Account::InvalidEmail')
expect(error.message).to eq(I18n.t('errors.signup.disposable_email'))
end
end
@@ -99,6 +99,20 @@ describe Messages::SendEmailNotificationService do
end
end
context 'when account email rate limit is exceeded' do
let(:inbox) { create(:inbox, account: account, channel: create(:channel_widget, account: account, continuity_via_email: true)) }
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
before do
conversation.contact.update!(email: 'test@example.com')
allow_any_instance_of(Account).to receive(:within_email_rate_limit?).and_return(false) # rubocop:disable RSpec/AnyInstance
end
it 'does not enqueue job' do
expect { service.perform }.not_to have_enqueued_job(ConversationReplyEmailJob)
end
end
context 'when channel does not support email notifications' do
let(:inbox) { create(:inbox, account: account, channel: create(:channel_sms, account: account)) }
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
@@ -42,6 +42,11 @@ describe Telegram::IncomingMessageService do
}
end
def contact_for(source_id = nil)
source_id ||= message_params.dig('from', 'id')
ContactInbox.find_by!(inbox: telegram_channel.inbox, source_id: source_id).contact
end
describe '#perform' do
context 'when valid text message params' do
it 'creates appropriate conversations, message and contacts' do
@@ -51,7 +56,7 @@ describe Telegram::IncomingMessageService do
}.with_indifferent_access
described_class.new(inbox: telegram_channel.inbox, params: params).perform
expect(telegram_channel.inbox.conversations.count).not_to eq(0)
expect(Contact.all.first.name).to eq('Sojan Jose')
expect(contact_for.name).to eq('Sojan Jose')
expect(telegram_channel.inbox.messages.first.content).to eq('test')
end
end
@@ -64,9 +69,9 @@ describe Telegram::IncomingMessageService do
}.with_indifferent_access
described_class.new(inbox: telegram_channel.inbox, params: params).perform
expect(telegram_channel.inbox.conversations.count).not_to eq(0)
expect(Contact.all.first.name).to eq('Sojan Jose')
expect(Contact.all.first.additional_attributes['social_telegram_user_id']).to eq(23)
expect(Contact.all.first.additional_attributes['social_telegram_user_name']).to eq('sojan')
expect(contact_for.name).to eq('Sojan Jose')
expect(contact_for.additional_attributes['social_telegram_user_id']).to eq(23)
expect(contact_for.additional_attributes['social_telegram_user_name']).to eq('sojan')
expect(telegram_channel.inbox.messages.first.content).to eq('test')
end
end
@@ -107,7 +112,7 @@ describe Telegram::IncomingMessageService do
expect(telegram_channel.inbox.conversations.count).not_to eq(0)
expect(telegram_channel.inbox.conversations.last.additional_attributes).to include({ 'chat_id' => 23,
'business_connection_id' => 'eooW3KF5WB5HxTD7T826' })
contact = Contact.all.first
contact = contact_for
expect(contact.name).to eq('Sojan Jose')
expect(contact.additional_attributes['language_code']).to eq('en')
message = telegram_channel.inbox.messages.first
@@ -131,7 +136,7 @@ describe Telegram::IncomingMessageService do
expect(telegram_channel.inbox.conversations.count).not_to eq(0)
expect(telegram_channel.inbox.conversations.last.additional_attributes).to include({ 'chat_id' => 23,
'business_connection_id' => 'eooW3KF5WB5HxTD7T826' })
contact = Contact.all.first
contact = contact_for
expect(contact.name).to eq('Sojan Jose')
# TODO: The language code is not present when we send the first message to the client.
# Should we update it when the user replies?
@@ -161,9 +166,9 @@ describe Telegram::IncomingMessageService do
}.with_indifferent_access
described_class.new(inbox: telegram_channel.inbox, params: params).perform
expect(telegram_channel.inbox.conversations.count).not_to eq(0)
expect(Contact.all.first.name).to eq('Sojan Jose')
expect(Contact.all.first.additional_attributes['social_telegram_user_id']).to eq(23)
expect(Contact.all.first.additional_attributes['social_telegram_user_name']).to eq('sojan')
expect(contact_for.name).to eq('Sojan Jose')
expect(contact_for.additional_attributes['social_telegram_user_id']).to eq(23)
expect(contact_for.additional_attributes['social_telegram_user_name']).to eq('sojan')
expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('audio')
end
end
@@ -182,7 +187,7 @@ describe Telegram::IncomingMessageService do
}.with_indifferent_access
described_class.new(inbox: telegram_channel.inbox, params: params).perform
expect(telegram_channel.inbox.conversations.count).not_to eq(0)
expect(Contact.all.first.name).to eq('Sojan Jose')
expect(contact_for.name).to eq('Sojan Jose')
expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('image')
end
end
@@ -207,7 +212,7 @@ describe Telegram::IncomingMessageService do
}.with_indifferent_access
described_class.new(inbox: telegram_channel.inbox, params: params).perform
expect(telegram_channel.inbox.conversations.count).not_to eq(0)
expect(Contact.all.first.name).to eq('Sojan Jose')
expect(contact_for.name).to eq('Sojan Jose')
expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('image')
end
end
@@ -229,7 +234,7 @@ describe Telegram::IncomingMessageService do
}.with_indifferent_access
described_class.new(inbox: telegram_channel.inbox, params: params).perform
expect(telegram_channel.inbox.conversations.count).not_to eq(0)
expect(Contact.all.first.name).to eq('Sojan Jose')
expect(contact_for.name).to eq('Sojan Jose')
expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('video')
end
end
@@ -258,7 +263,7 @@ describe Telegram::IncomingMessageService do
}.with_indifferent_access
described_class.new(inbox: telegram_channel.inbox, params: params).perform
expect(telegram_channel.inbox.conversations.count).not_to eq(0)
expect(Contact.all.first.name).to eq('Sojan Jose')
expect(contact_for.name).to eq('Sojan Jose')
expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('video')
end
end
@@ -277,7 +282,7 @@ describe Telegram::IncomingMessageService do
}.with_indifferent_access
described_class.new(inbox: telegram_channel.inbox, params: params).perform
expect(telegram_channel.inbox.conversations.count).not_to eq(0)
expect(Contact.all.first.name).to eq('Sojan Jose')
expect(contact_for.name).to eq('Sojan Jose')
expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('audio')
end
end
@@ -298,7 +303,7 @@ describe Telegram::IncomingMessageService do
}.with_indifferent_access
described_class.new(inbox: telegram_channel.inbox, params: params).perform
expect(telegram_channel.inbox.conversations.count).not_to eq(0)
expect(Contact.all.first.name).to eq('Sojan Jose')
expect(contact_for.name).to eq('Sojan Jose')
expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('file')
end
end
@@ -336,7 +341,7 @@ describe Telegram::IncomingMessageService do
}.with_indifferent_access
described_class.new(inbox: telegram_channel.inbox, params: params).perform
expect(telegram_channel.inbox.conversations.count).not_to eq(0)
expect(Contact.all.first.name).to eq('Sojan Jose')
expect(contact_for.name).to eq('Sojan Jose')
expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('location')
end
@@ -355,7 +360,7 @@ describe Telegram::IncomingMessageService do
}.with_indifferent_access
described_class.new(inbox: telegram_channel.inbox, params: params).perform
expect(telegram_channel.inbox.conversations.count).not_to eq(0)
expect(Contact.all.first.name).to eq('Sojan Jose')
expect(contact_for.name).to eq('Sojan Jose')
attachment = telegram_channel.inbox.messages.first.attachments.first
expect(attachment.file_type).to eq('location')
@@ -388,8 +393,8 @@ describe Telegram::IncomingMessageService do
described_class.new(inbox: telegram_channel.inbox, params: params).perform
expect(telegram_channel.inbox.conversations.count).not_to eq(0)
expect(Contact.all.first.name).to eq('Sojan Jose')
expect(Contact.all.first.additional_attributes['social_telegram_user_id']).to eq(5_171_248)
expect(contact_for(5_171_248).name).to eq('Sojan Jose')
expect(contact_for(5_171_248).additional_attributes['social_telegram_user_id']).to eq(5_171_248)
expect(telegram_channel.inbox.messages.first.content).to eq('Option 1')
end
end
@@ -406,7 +411,7 @@ describe Telegram::IncomingMessageService do
}.with_indifferent_access
described_class.new(inbox: telegram_channel.inbox, params: params).perform
expect(telegram_channel.inbox.conversations.count).not_to eq(0)
expect(Contact.all.first.name).to eq('Sojan Jose')
expect(contact_for.name).to eq('Sojan Jose')
expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('contact')
end
end
@@ -161,10 +161,23 @@ describe Whatsapp::FacebookApiClient do
context 'when successful' do
before do
# Step 1: Subscribe app to WABA (no body)
stub_request(:post, "https://graph.facebook.com/#{api_version}/#{waba_id}/subscribed_apps")
.with(
headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' }
)
.to_return(
status: 200,
body: { success: true }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
# Step 2: Override callback URL (with body)
stub_request(:post, "https://graph.facebook.com/#{api_version}/#{waba_id}/subscribed_apps")
.with(
headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' },
body: { override_callback_uri: callback_url, verify_token: verify_token }.to_json
body: { override_callback_uri: callback_url, verify_token: verify_token,
subscribed_fields: %w[messages smb_message_echoes] }.to_json
)
.to_return(
status: 200,
@@ -179,18 +192,45 @@ describe Whatsapp::FacebookApiClient do
end
end
context 'when failed' do
context 'when app subscription fails' do
before do
stub_request(:post, "https://graph.facebook.com/#{api_version}/#{waba_id}/subscribed_apps")
.with(
headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' },
body: { override_callback_uri: callback_url, verify_token: verify_token }.to_json
headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' }
)
.to_return(status: 400, body: { error: 'Webhook subscription failed' }.to_json)
.to_return(status: 400, body: { error: 'App subscription to WABA failed' }.to_json)
end
it 'raises an error' do
expect { api_client.subscribe_waba_webhook(waba_id, callback_url, verify_token) }.to raise_error(/Webhook subscription failed/)
expect { api_client.subscribe_waba_webhook(waba_id, callback_url, verify_token) }.to raise_error(/App subscription to WABA failed/)
end
end
context 'when callback override fails' do
before do
# Step 1 succeeds
stub_request(:post, "https://graph.facebook.com/#{api_version}/#{waba_id}/subscribed_apps")
.with(
headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' }
)
.to_return(
status: 200,
body: { success: true }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
# Step 2 fails
stub_request(:post, "https://graph.facebook.com/#{api_version}/#{waba_id}/subscribed_apps")
.with(
headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' },
body: { override_callback_uri: callback_url, verify_token: verify_token,
subscribed_fields: %w[messages smb_message_echoes] }.to_json
)
.to_return(status: 400, body: { error: 'Webhook callback override failed' }.to_json)
end
it 'raises an error' do
expect { api_client.subscribe_waba_webhook(waba_id, callback_url, verify_token) }.to raise_error(/Webhook callback override failed/)
end
end
end
@@ -3,6 +3,7 @@ require 'rails_helper'
describe Whatsapp::IncomingMessageWhatsappCloudService do
describe '#perform' do
let!(:whatsapp_channel) { create(:channel_whatsapp, provider: 'whatsapp_cloud', sync_templates: false, validate_provider_config: false) }
let(:sender_number) { '2423423243' }
let(:params) do
{
phone_number: whatsapp_channel.phone_number,
@@ -10,9 +11,9 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do
entry: [{
changes: [{
value: {
contacts: [{ profile: { name: 'Sojan Jose' }, wa_id: '2423423243' }],
contacts: [{ profile: { name: 'Sojan Jose' }, wa_id: sender_number }],
messages: [{
from: '2423423243',
from: sender_number,
image: {
id: 'b1c68f38-8734-4ad3-b4a1-ef0c10d683',
mime_type: 'image/jpeg',
@@ -48,7 +49,7 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
expect(whatsapp_channel.inbox.conversations.count).not_to eq(0)
expect(Contact.all.first.name).to eq('Sojan Jose')
expect_contact_name
expect(whatsapp_channel.inbox.messages.first.content).to eq('Check out my product!')
expect(whatsapp_channel.inbox.messages.first.attachments.present?).to be false
expect(whatsapp_channel.authorization_error_count).to eq(1)
@@ -63,9 +64,9 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do
entry: [{
changes: [{
value: {
contacts: [{ profile: { name: 'Sojan Jose' }, wa_id: '2423423243' }],
contacts: [{ profile: { name: 'Sojan Jose' }, wa_id: sender_number }],
messages: [{
from: '2423423243',
from: sender_number,
image: {
id: 'b1c68f38-8734-4ad3-b4a1-ef0c10d683',
mime_type: 'image/jpeg',
@@ -88,7 +89,7 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do
it 'with attachment errors' do
described_class.new(inbox: whatsapp_channel.inbox, params: error_params).perform
expect(whatsapp_channel.inbox.conversations.count).not_to eq(0)
expect(Contact.all.first.name).to eq('Sojan Jose')
expect_contact_name
expect(whatsapp_channel.inbox.messages.count).to eq(0)
end
end
@@ -98,7 +99,7 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do
described_class.new(inbox: whatsapp_channel.inbox, params: { phone_number: whatsapp_channel.phone_number,
object: 'whatsapp_business_account', entry: {} }).perform
expect(whatsapp_channel.inbox.conversations.count).to eq(0)
expect(Contact.all.first).to be_nil
expect(Contact.find_by(phone_number: contact_phone_number)).to be_nil
expect(whatsapp_channel.inbox.messages.count).to eq(0)
end
end
@@ -136,7 +137,7 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do
end
def expect_contact_name
expect(Contact.all.first.name).to eq('Sojan Jose')
expect(contact_from_number&.name).to eq('Sojan Jose')
end
def expect_message_content
@@ -146,4 +147,12 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do
def expect_message_has_attachment
expect(whatsapp_channel.inbox.messages.first.attachments.present?).to be true
end
def contact_phone_number
"+#{sender_number}"
end
def contact_from_number
Contact.find_by(phone_number: contact_phone_number)
end
end
+10
View File
@@ -225,6 +225,16 @@ agent_conversation_metrics:
$ref: './resource/reports/conversation/agent.yml'
channel_summary:
$ref: './resource/reports/channel_summary.yml'
first_response_time_distribution:
$ref: './resource/reports/first_response_time_distribution.yml'
inbox_label_matrix:
$ref: './resource/reports/inbox_label_matrix.yml'
inbox_summary:
$ref: './resource/reports/inbox_summary.yml'
agent_summary:
$ref: './resource/reports/agent_summary.yml'
team_summary:
$ref: './resource/reports/team_summary.yml'
contact_detail:
$ref: ./resource/contact_detail.yml
@@ -0,0 +1,39 @@
type: array
description: Agent summary report containing conversation statistics grouped by agent.
items:
type: object
properties:
id:
type: number
description: The agent (user) ID
conversations_count:
type: number
description: Number of conversations assigned to the agent during the date range
resolved_conversations_count:
type: number
description: Number of conversations resolved by the agent during the date range
avg_resolution_time:
type: number
nullable: true
description: Average time (in seconds) to resolve conversations. Null if no data available.
avg_first_response_time:
type: number
nullable: true
description: Average time (in seconds) for the first response. Null if no data available.
avg_reply_time:
type: number
nullable: true
description: Average time (in seconds) between replies. Null if no data available.
example:
- id: 1
conversations_count: 150
resolved_conversations_count: 120
avg_resolution_time: 3600
avg_first_response_time: 300
avg_reply_time: 600
- id: 2
conversations_count: 75
resolved_conversations_count: 60
avg_resolution_time: 1800
avg_first_response_time: 180
avg_reply_time: 420
@@ -0,0 +1,34 @@
type: object
description: First response time distribution report grouped by channel type. Shows the count of conversations with first response times in different time buckets.
additionalProperties:
type: object
description: First response time distribution for a specific channel type (e.g., Channel::WebWidget, Channel::Api)
properties:
0-1h:
type: number
description: Number of conversations with first response time less than 1 hour
1-4h:
type: number
description: Number of conversations with first response time between 1-4 hours
4-8h:
type: number
description: Number of conversations with first response time between 4-8 hours
8-24h:
type: number
description: Number of conversations with first response time between 8-24 hours
24h+:
type: number
description: Number of conversations with first response time greater than 24 hours
example:
Channel::WebWidget:
0-1h: 150
1-4h: 80
4-8h: 45
8-24h: 30
24h+: 15
Channel::Api:
0-1h: 75
1-4h: 40
4-8h: 20
8-24h: 10
24h+: 5
@@ -0,0 +1,50 @@
type: object
description: Inbox-label matrix report showing the count of conversations for each inbox-label combination.
properties:
inboxes:
type: array
description: List of inboxes included in the report
items:
type: object
properties:
id:
type: number
description: The inbox ID
name:
type: string
description: The inbox name
labels:
type: array
description: List of labels included in the report
items:
type: object
properties:
id:
type: number
description: The label ID
title:
type: string
description: The label title
matrix:
type: array
description: 2D array where matrix[i][j] represents the count of conversations in inboxes[i] with labels[j]
items:
type: array
items:
type: number
example:
inboxes:
- id: 1
name: Website Chat
- id: 2
name: Email Support
labels:
- id: 1
title: bug
- id: 2
title: feature-request
- id: 3
title: urgent
matrix:
- [10, 5, 3]
- [8, 12, 2]
@@ -0,0 +1,39 @@
type: array
description: Inbox summary report containing conversation statistics grouped by inbox.
items:
type: object
properties:
id:
type: number
description: The inbox ID
conversations_count:
type: number
description: Number of conversations created in the inbox during the date range
resolved_conversations_count:
type: number
description: Number of conversations resolved in the inbox during the date range
avg_resolution_time:
type: number
nullable: true
description: Average time (in seconds) to resolve conversations. Null if no data available.
avg_first_response_time:
type: number
nullable: true
description: Average time (in seconds) for the first response. Null if no data available.
avg_reply_time:
type: number
nullable: true
description: Average time (in seconds) between replies. Null if no data available.
example:
- id: 1
conversations_count: 150
resolved_conversations_count: 120
avg_resolution_time: 3600
avg_first_response_time: 300
avg_reply_time: 600
- id: 2
conversations_count: 75
resolved_conversations_count: 60
avg_resolution_time: 1800
avg_first_response_time: 180
avg_reply_time: 420
@@ -0,0 +1,39 @@
type: array
description: Team summary report containing conversation statistics grouped by team.
items:
type: object
properties:
id:
type: number
description: The team ID
conversations_count:
type: number
description: Number of conversations assigned to the team during the date range
resolved_conversations_count:
type: number
description: Number of conversations resolved by the team during the date range
avg_resolution_time:
type: number
nullable: true
description: Average time (in seconds) to resolve conversations. Null if no data available.
avg_first_response_time:
type: number
nullable: true
description: Average time (in seconds) for the first response. Null if no data available.
avg_reply_time:
type: number
nullable: true
description: Average time (in seconds) between replies. Null if no data available.
example:
- id: 1
conversations_count: 250
resolved_conversations_count: 200
avg_resolution_time: 2800
avg_first_response_time: 240
avg_reply_time: 500
- id: 2
conversations_count: 180
resolved_conversations_count: 150
avg_resolution_time: 2400
avg_first_response_time: 200
avg_reply_time: 450
+1 -1
View File
@@ -18,6 +18,6 @@
</head>
<body>
<redoc spec-url='/swagger/swagger.json'></redoc>
<script src="https://cdn.jsdelivr.net/npm/redoc@next/bundles/redoc.standalone.js"> </script>
<script src="https://cdn.jsdelivr.net/npm/redoc@2.1.5/bundles/redoc.standalone.js"> </script>
</body>
</html>
@@ -0,0 +1,23 @@
tags:
- Reports
operationId: get-agent-summary-report
summary: Get conversation statistics grouped by agent
security:
- userApiKey: []
description: |
Get conversation statistics grouped by agent for a given date range.
Returns metrics for each agent including conversation counts, resolution counts,
average first response time, average resolution time, and average reply time.
responses:
'200':
description: Success
content:
application/json:
schema:
$ref: '#/components/schemas/agent_summary'
'403':
description: Access denied
content:
application/json:
schema:
$ref: '#/components/schemas/bad_request_error'
@@ -0,0 +1,24 @@
tags:
- Reports
operationId: get-first-response-time-distribution
summary: Get first response time distribution by channel
security:
- userApiKey: []
description: |
Get the distribution of first response times grouped by channel type.
Returns conversation counts in different time buckets (0-1h, 1-4h, 4-8h, 8-24h, 24h+) for each channel type.
**Note:** This API endpoint is available only in Chatwoot version 4.11.0 and above.
responses:
'200':
description: Success
content:
application/json:
schema:
$ref: '#/components/schemas/first_response_time_distribution'
'403':
description: Access denied
content:
application/json:
schema:
$ref: '#/components/schemas/bad_request_error'
@@ -0,0 +1,25 @@
tags:
- Reports
operationId: get-inbox-label-matrix
summary: Get inbox-label matrix report
security:
- userApiKey: []
description: |
Get a matrix showing the count of conversations for each inbox-label combination.
Returns a list of inboxes, labels, and a 2D matrix where each cell contains the count of conversations
in a specific inbox that have a specific label applied.
**Note:** This API endpoint is available only in Chatwoot version 4.11.0 and above.
responses:
'200':
description: Success
content:
application/json:
schema:
$ref: '#/components/schemas/inbox_label_matrix'
'403':
description: Access denied
content:
application/json:
schema:
$ref: '#/components/schemas/bad_request_error'
@@ -0,0 +1,23 @@
tags:
- Reports
operationId: get-inbox-summary-report
summary: Get conversation statistics grouped by inbox
security:
- userApiKey: []
description: |
Get conversation statistics grouped by inbox for a given date range.
Returns metrics for each inbox including conversation counts, resolution counts,
average first response time, average resolution time, and average reply time.
responses:
'200':
description: Success
content:
application/json:
schema:
$ref: '#/components/schemas/inbox_summary'
'403':
description: Access denied
content:
application/json:
schema:
$ref: '#/components/schemas/bad_request_error'
@@ -0,0 +1,23 @@
tags:
- Reports
operationId: get-team-summary-report
summary: Get conversation statistics grouped by team
security:
- userApiKey: []
description: |
Get conversation statistics grouped by team for a given date range.
Returns metrics for each team including conversation counts, resolution counts,
average first response time, average resolution time, and average reply time.
responses:
'200':
description: Success
content:
application/json:
schema:
$ref: '#/components/schemas/team_summary'
'403':
description: Access denied
content:
application/json:
schema:
$ref: '#/components/schemas/bad_request_error'
+111 -2
View File
@@ -641,6 +641,23 @@
# Channel summary report (Available in 4.10.0+)
/api/v2/accounts/{account_id}/summary_reports/channel:
parameters:
- $ref: '#/components/parameters/account_id'
- in: query
name: since
schema:
type: string
description: The timestamp from where report should start (Unix timestamp).
- in: query
name: until
schema:
type: string
description: The timestamp from where report should stop (Unix timestamp).
get:
$ref: './application/reports/channel_summary.yml'
# Inbox summary report
/api/v2/accounts/{account_id}/summary_reports/inbox:
parameters:
- $ref: '#/components/parameters/account_id'
- in: query
@@ -657,9 +674,101 @@
name: business_hours
schema:
type: boolean
description: Whether to filter by business hours.
description: Whether to calculate metrics using business hours only.
get:
$ref: './application/reports/channel_summary.yml'
$ref: './application/reports/inbox_summary.yml'
# Agent summary report
/api/v2/accounts/{account_id}/summary_reports/agent:
parameters:
- $ref: '#/components/parameters/account_id'
- in: query
name: since
schema:
type: string
description: The timestamp from where report should start (Unix timestamp).
- in: query
name: until
schema:
type: string
description: The timestamp from where report should stop (Unix timestamp).
- in: query
name: business_hours
schema:
type: boolean
description: Whether to calculate metrics using business hours only.
get:
$ref: './application/reports/agent_summary.yml'
# Team summary report
/api/v2/accounts/{account_id}/summary_reports/team:
parameters:
- $ref: '#/components/parameters/account_id'
- in: query
name: since
schema:
type: string
description: The timestamp from where report should start (Unix timestamp).
- in: query
name: until
schema:
type: string
description: The timestamp from where report should stop (Unix timestamp).
- in: query
name: business_hours
schema:
type: boolean
description: Whether to calculate metrics using business hours only.
get:
$ref: './application/reports/team_summary.yml'
# First response time distribution report
/api/v2/accounts/{account_id}/reports/first_response_time_distribution:
parameters:
- $ref: '#/components/parameters/account_id'
- in: query
name: since
schema:
type: string
description: The timestamp from where report should start (Unix timestamp).
- in: query
name: until
schema:
type: string
description: The timestamp from where report should stop (Unix timestamp).
get:
$ref: './application/reports/first_response_time_distribution.yml'
# Inbox-label matrix report
/api/v2/accounts/{account_id}/reports/inbox_label_matrix:
parameters:
- $ref: '#/components/parameters/account_id'
- in: query
name: since
schema:
type: string
description: The timestamp from where report should start (Unix timestamp).
- in: query
name: until
schema:
type: string
description: The timestamp from where report should stop (Unix timestamp).
- in: query
name: inbox_ids
schema:
type: array
items:
type: integer
description: Filter by specific inbox IDs.
- in: query
name: label_ids
schema:
type: array
items:
type: integer
description: Filter by specific label IDs.
get:
$ref: './application/reports/inbox_label_matrix.yml'
# Conversations Messages
/accounts/{account_id}/conversations/{conversation_id}/messages:
+632 -8
View File
@@ -7890,14 +7890,6 @@
"type": "string"
},
"description": "The timestamp from where report should stop (Unix timestamp)."
},
{
"in": "query",
"name": "business_hours",
"schema": {
"type": "boolean"
},
"description": "Whether to filter by business hours."
}
],
"get": {
@@ -7946,6 +7938,342 @@
}
}
},
"/api/v2/accounts/{account_id}/summary_reports/inbox": {
"parameters": [
{
"$ref": "#/components/parameters/account_id"
},
{
"in": "query",
"name": "since",
"schema": {
"type": "string"
},
"description": "The timestamp from where report should start (Unix timestamp)."
},
{
"in": "query",
"name": "until",
"schema": {
"type": "string"
},
"description": "The timestamp from where report should stop (Unix timestamp)."
},
{
"in": "query",
"name": "business_hours",
"schema": {
"type": "boolean"
},
"description": "Whether to calculate metrics using business hours only."
}
],
"get": {
"tags": [
"Reports"
],
"operationId": "get-inbox-summary-report",
"summary": "Get conversation statistics grouped by inbox",
"security": [
{
"userApiKey": []
}
],
"description": "Get conversation statistics grouped by inbox for a given date range.\nReturns metrics for each inbox including conversation counts, resolution counts,\naverage first response time, average resolution time, and average reply time.\n",
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/inbox_summary"
}
}
}
},
"403": {
"description": "Access denied",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/bad_request_error"
}
}
}
}
}
}
},
"/api/v2/accounts/{account_id}/summary_reports/agent": {
"parameters": [
{
"$ref": "#/components/parameters/account_id"
},
{
"in": "query",
"name": "since",
"schema": {
"type": "string"
},
"description": "The timestamp from where report should start (Unix timestamp)."
},
{
"in": "query",
"name": "until",
"schema": {
"type": "string"
},
"description": "The timestamp from where report should stop (Unix timestamp)."
},
{
"in": "query",
"name": "business_hours",
"schema": {
"type": "boolean"
},
"description": "Whether to calculate metrics using business hours only."
}
],
"get": {
"tags": [
"Reports"
],
"operationId": "get-agent-summary-report",
"summary": "Get conversation statistics grouped by agent",
"security": [
{
"userApiKey": []
}
],
"description": "Get conversation statistics grouped by agent for a given date range.\nReturns metrics for each agent including conversation counts, resolution counts,\naverage first response time, average resolution time, and average reply time.\n",
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/agent_summary"
}
}
}
},
"403": {
"description": "Access denied",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/bad_request_error"
}
}
}
}
}
}
},
"/api/v2/accounts/{account_id}/summary_reports/team": {
"parameters": [
{
"$ref": "#/components/parameters/account_id"
},
{
"in": "query",
"name": "since",
"schema": {
"type": "string"
},
"description": "The timestamp from where report should start (Unix timestamp)."
},
{
"in": "query",
"name": "until",
"schema": {
"type": "string"
},
"description": "The timestamp from where report should stop (Unix timestamp)."
},
{
"in": "query",
"name": "business_hours",
"schema": {
"type": "boolean"
},
"description": "Whether to calculate metrics using business hours only."
}
],
"get": {
"tags": [
"Reports"
],
"operationId": "get-team-summary-report",
"summary": "Get conversation statistics grouped by team",
"security": [
{
"userApiKey": []
}
],
"description": "Get conversation statistics grouped by team for a given date range.\nReturns metrics for each team including conversation counts, resolution counts,\naverage first response time, average resolution time, and average reply time.\n",
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/team_summary"
}
}
}
},
"403": {
"description": "Access denied",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/bad_request_error"
}
}
}
}
}
}
},
"/api/v2/accounts/{account_id}/reports/first_response_time_distribution": {
"parameters": [
{
"$ref": "#/components/parameters/account_id"
},
{
"in": "query",
"name": "since",
"schema": {
"type": "string"
},
"description": "The timestamp from where report should start (Unix timestamp)."
},
{
"in": "query",
"name": "until",
"schema": {
"type": "string"
},
"description": "The timestamp from where report should stop (Unix timestamp)."
}
],
"get": {
"tags": [
"Reports"
],
"operationId": "get-first-response-time-distribution",
"summary": "Get first response time distribution by channel",
"security": [
{
"userApiKey": []
}
],
"description": "Get the distribution of first response times grouped by channel type.\nReturns conversation counts in different time buckets (0-1h, 1-4h, 4-8h, 8-24h, 24h+) for each channel type.\n\n**Note:** This API endpoint is available only in Chatwoot version 4.11.0 and above.\n",
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/first_response_time_distribution"
}
}
}
},
"403": {
"description": "Access denied",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/bad_request_error"
}
}
}
}
}
}
},
"/api/v2/accounts/{account_id}/reports/inbox_label_matrix": {
"parameters": [
{
"$ref": "#/components/parameters/account_id"
},
{
"in": "query",
"name": "since",
"schema": {
"type": "string"
},
"description": "The timestamp from where report should start (Unix timestamp)."
},
{
"in": "query",
"name": "until",
"schema": {
"type": "string"
},
"description": "The timestamp from where report should stop (Unix timestamp)."
},
{
"in": "query",
"name": "inbox_ids",
"schema": {
"type": "array",
"items": {
"type": "integer"
}
},
"description": "Filter by specific inbox IDs."
},
{
"in": "query",
"name": "label_ids",
"schema": {
"type": "array",
"items": {
"type": "integer"
}
},
"description": "Filter by specific label IDs."
}
],
"get": {
"tags": [
"Reports"
],
"operationId": "get-inbox-label-matrix",
"summary": "Get inbox-label matrix report",
"security": [
{
"userApiKey": []
}
],
"description": "Get a matrix showing the count of conversations for each inbox-label combination.\nReturns a list of inboxes, labels, and a 2D matrix where each cell contains the count of conversations\nin a specific inbox that have a specific label applied.\n\n**Note:** This API endpoint is available only in Chatwoot version 4.11.0 and above.\n",
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/inbox_label_matrix"
}
}
}
},
"403": {
"description": "Access denied",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/bad_request_error"
}
}
}
}
}
}
},
"/accounts/{account_id}/conversations/{conversation_id}/messages": {
"parameters": [
{
@@ -11781,6 +12109,302 @@
}
}
},
"first_response_time_distribution": {
"type": "object",
"description": "First response time distribution report grouped by channel type. Shows the count of conversations with first response times in different time buckets.",
"additionalProperties": {
"type": "object",
"description": "First response time distribution for a specific channel type (e.g., Channel::WebWidget, Channel::Api)",
"properties": {
"0-1h": {
"type": "number",
"description": "Number of conversations with first response time less than 1 hour"
},
"1-4h": {
"type": "number",
"description": "Number of conversations with first response time between 1-4 hours"
},
"4-8h": {
"type": "number",
"description": "Number of conversations with first response time between 4-8 hours"
},
"8-24h": {
"type": "number",
"description": "Number of conversations with first response time between 8-24 hours"
},
"24h+": {
"type": "number",
"description": "Number of conversations with first response time greater than 24 hours"
}
}
},
"example": {
"Channel::WebWidget": {
"0-1h": 150,
"1-4h": 80,
"4-8h": 45,
"8-24h": 30,
"24h+": 15
},
"Channel::Api": {
"0-1h": 75,
"1-4h": 40,
"4-8h": 20,
"8-24h": 10,
"24h+": 5
}
}
},
"inbox_label_matrix": {
"type": "object",
"description": "Inbox-label matrix report showing the count of conversations for each inbox-label combination.",
"properties": {
"inboxes": {
"type": "array",
"description": "List of inboxes included in the report",
"items": {
"type": "object",
"properties": {
"id": {
"type": "number",
"description": "The inbox ID"
},
"name": {
"type": "string",
"description": "The inbox name"
}
}
}
},
"labels": {
"type": "array",
"description": "List of labels included in the report",
"items": {
"type": "object",
"properties": {
"id": {
"type": "number",
"description": "The label ID"
},
"title": {
"type": "string",
"description": "The label title"
}
}
}
},
"matrix": {
"type": "array",
"description": "2D array where matrix[i][j] represents the count of conversations in inboxes[i] with labels[j]",
"items": {
"type": "array",
"items": {
"type": "number"
}
}
}
},
"example": {
"inboxes": [
{
"id": 1,
"name": "Website Chat"
},
{
"id": 2,
"name": "Email Support"
}
],
"labels": [
{
"id": 1,
"title": "bug"
},
{
"id": 2,
"title": "feature-request"
},
{
"id": 3,
"title": "urgent"
}
],
"matrix": [
[
10,
5,
3
],
[
8,
12,
2
]
]
}
},
"inbox_summary": {
"type": "array",
"description": "Inbox summary report containing conversation statistics grouped by inbox.",
"items": {
"type": "object",
"properties": {
"id": {
"type": "number",
"description": "The inbox ID"
},
"conversations_count": {
"type": "number",
"description": "Number of conversations created in the inbox during the date range"
},
"resolved_conversations_count": {
"type": "number",
"description": "Number of conversations resolved in the inbox during the date range"
},
"avg_resolution_time": {
"type": "number",
"nullable": true,
"description": "Average time (in seconds) to resolve conversations. Null if no data available."
},
"avg_first_response_time": {
"type": "number",
"nullable": true,
"description": "Average time (in seconds) for the first response. Null if no data available."
},
"avg_reply_time": {
"type": "number",
"nullable": true,
"description": "Average time (in seconds) between replies. Null if no data available."
}
}
},
"example": [
{
"id": 1,
"conversations_count": 150,
"resolved_conversations_count": 120,
"avg_resolution_time": 3600,
"avg_first_response_time": 300,
"avg_reply_time": 600
},
{
"id": 2,
"conversations_count": 75,
"resolved_conversations_count": 60,
"avg_resolution_time": 1800,
"avg_first_response_time": 180,
"avg_reply_time": 420
}
]
},
"agent_summary": {
"type": "array",
"description": "Agent summary report containing conversation statistics grouped by agent.",
"items": {
"type": "object",
"properties": {
"id": {
"type": "number",
"description": "The agent (user) ID"
},
"conversations_count": {
"type": "number",
"description": "Number of conversations assigned to the agent during the date range"
},
"resolved_conversations_count": {
"type": "number",
"description": "Number of conversations resolved by the agent during the date range"
},
"avg_resolution_time": {
"type": "number",
"nullable": true,
"description": "Average time (in seconds) to resolve conversations. Null if no data available."
},
"avg_first_response_time": {
"type": "number",
"nullable": true,
"description": "Average time (in seconds) for the first response. Null if no data available."
},
"avg_reply_time": {
"type": "number",
"nullable": true,
"description": "Average time (in seconds) between replies. Null if no data available."
}
}
},
"example": [
{
"id": 1,
"conversations_count": 150,
"resolved_conversations_count": 120,
"avg_resolution_time": 3600,
"avg_first_response_time": 300,
"avg_reply_time": 600
},
{
"id": 2,
"conversations_count": 75,
"resolved_conversations_count": 60,
"avg_resolution_time": 1800,
"avg_first_response_time": 180,
"avg_reply_time": 420
}
]
},
"team_summary": {
"type": "array",
"description": "Team summary report containing conversation statistics grouped by team.",
"items": {
"type": "object",
"properties": {
"id": {
"type": "number",
"description": "The team ID"
},
"conversations_count": {
"type": "number",
"description": "Number of conversations assigned to the team during the date range"
},
"resolved_conversations_count": {
"type": "number",
"description": "Number of conversations resolved by the team during the date range"
},
"avg_resolution_time": {
"type": "number",
"nullable": true,
"description": "Average time (in seconds) to resolve conversations. Null if no data available."
},
"avg_first_response_time": {
"type": "number",
"nullable": true,
"description": "Average time (in seconds) for the first response. Null if no data available."
},
"avg_reply_time": {
"type": "number",
"nullable": true,
"description": "Average time (in seconds) between replies. Null if no data available."
}
}
},
"example": [
{
"id": 1,
"conversations_count": 250,
"resolved_conversations_count": 200,
"avg_resolution_time": 2800,
"avg_first_response_time": 240,
"avg_reply_time": 500
},
{
"id": 2,
"conversations_count": 180,
"resolved_conversations_count": 150,
"avg_resolution_time": 2400,
"avg_first_response_time": 200,
"avg_reply_time": 450
}
]
},
"contact_detail": {
"type": "object",
"properties": {
+632 -8
View File
@@ -6433,14 +6433,6 @@
"type": "string"
},
"description": "The timestamp from where report should stop (Unix timestamp)."
},
{
"in": "query",
"name": "business_hours",
"schema": {
"type": "boolean"
},
"description": "Whether to filter by business hours."
}
],
"get": {
@@ -6488,6 +6480,342 @@
}
}
}
},
"/api/v2/accounts/{account_id}/summary_reports/inbox": {
"parameters": [
{
"$ref": "#/components/parameters/account_id"
},
{
"in": "query",
"name": "since",
"schema": {
"type": "string"
},
"description": "The timestamp from where report should start (Unix timestamp)."
},
{
"in": "query",
"name": "until",
"schema": {
"type": "string"
},
"description": "The timestamp from where report should stop (Unix timestamp)."
},
{
"in": "query",
"name": "business_hours",
"schema": {
"type": "boolean"
},
"description": "Whether to calculate metrics using business hours only."
}
],
"get": {
"tags": [
"Reports"
],
"operationId": "get-inbox-summary-report",
"summary": "Get conversation statistics grouped by inbox",
"security": [
{
"userApiKey": []
}
],
"description": "Get conversation statistics grouped by inbox for a given date range.\nReturns metrics for each inbox including conversation counts, resolution counts,\naverage first response time, average resolution time, and average reply time.\n",
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/inbox_summary"
}
}
}
},
"403": {
"description": "Access denied",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/bad_request_error"
}
}
}
}
}
}
},
"/api/v2/accounts/{account_id}/summary_reports/agent": {
"parameters": [
{
"$ref": "#/components/parameters/account_id"
},
{
"in": "query",
"name": "since",
"schema": {
"type": "string"
},
"description": "The timestamp from where report should start (Unix timestamp)."
},
{
"in": "query",
"name": "until",
"schema": {
"type": "string"
},
"description": "The timestamp from where report should stop (Unix timestamp)."
},
{
"in": "query",
"name": "business_hours",
"schema": {
"type": "boolean"
},
"description": "Whether to calculate metrics using business hours only."
}
],
"get": {
"tags": [
"Reports"
],
"operationId": "get-agent-summary-report",
"summary": "Get conversation statistics grouped by agent",
"security": [
{
"userApiKey": []
}
],
"description": "Get conversation statistics grouped by agent for a given date range.\nReturns metrics for each agent including conversation counts, resolution counts,\naverage first response time, average resolution time, and average reply time.\n",
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/agent_summary"
}
}
}
},
"403": {
"description": "Access denied",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/bad_request_error"
}
}
}
}
}
}
},
"/api/v2/accounts/{account_id}/summary_reports/team": {
"parameters": [
{
"$ref": "#/components/parameters/account_id"
},
{
"in": "query",
"name": "since",
"schema": {
"type": "string"
},
"description": "The timestamp from where report should start (Unix timestamp)."
},
{
"in": "query",
"name": "until",
"schema": {
"type": "string"
},
"description": "The timestamp from where report should stop (Unix timestamp)."
},
{
"in": "query",
"name": "business_hours",
"schema": {
"type": "boolean"
},
"description": "Whether to calculate metrics using business hours only."
}
],
"get": {
"tags": [
"Reports"
],
"operationId": "get-team-summary-report",
"summary": "Get conversation statistics grouped by team",
"security": [
{
"userApiKey": []
}
],
"description": "Get conversation statistics grouped by team for a given date range.\nReturns metrics for each team including conversation counts, resolution counts,\naverage first response time, average resolution time, and average reply time.\n",
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/team_summary"
}
}
}
},
"403": {
"description": "Access denied",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/bad_request_error"
}
}
}
}
}
}
},
"/api/v2/accounts/{account_id}/reports/first_response_time_distribution": {
"parameters": [
{
"$ref": "#/components/parameters/account_id"
},
{
"in": "query",
"name": "since",
"schema": {
"type": "string"
},
"description": "The timestamp from where report should start (Unix timestamp)."
},
{
"in": "query",
"name": "until",
"schema": {
"type": "string"
},
"description": "The timestamp from where report should stop (Unix timestamp)."
}
],
"get": {
"tags": [
"Reports"
],
"operationId": "get-first-response-time-distribution",
"summary": "Get first response time distribution by channel",
"security": [
{
"userApiKey": []
}
],
"description": "Get the distribution of first response times grouped by channel type.\nReturns conversation counts in different time buckets (0-1h, 1-4h, 4-8h, 8-24h, 24h+) for each channel type.\n\n**Note:** This API endpoint is available only in Chatwoot version 4.11.0 and above.\n",
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/first_response_time_distribution"
}
}
}
},
"403": {
"description": "Access denied",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/bad_request_error"
}
}
}
}
}
}
},
"/api/v2/accounts/{account_id}/reports/inbox_label_matrix": {
"parameters": [
{
"$ref": "#/components/parameters/account_id"
},
{
"in": "query",
"name": "since",
"schema": {
"type": "string"
},
"description": "The timestamp from where report should start (Unix timestamp)."
},
{
"in": "query",
"name": "until",
"schema": {
"type": "string"
},
"description": "The timestamp from where report should stop (Unix timestamp)."
},
{
"in": "query",
"name": "inbox_ids",
"schema": {
"type": "array",
"items": {
"type": "integer"
}
},
"description": "Filter by specific inbox IDs."
},
{
"in": "query",
"name": "label_ids",
"schema": {
"type": "array",
"items": {
"type": "integer"
}
},
"description": "Filter by specific label IDs."
}
],
"get": {
"tags": [
"Reports"
],
"operationId": "get-inbox-label-matrix",
"summary": "Get inbox-label matrix report",
"security": [
{
"userApiKey": []
}
],
"description": "Get a matrix showing the count of conversations for each inbox-label combination.\nReturns a list of inboxes, labels, and a 2D matrix where each cell contains the count of conversations\nin a specific inbox that have a specific label applied.\n\n**Note:** This API endpoint is available only in Chatwoot version 4.11.0 and above.\n",
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/inbox_label_matrix"
}
}
}
},
"403": {
"description": "Access denied",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/bad_request_error"
}
}
}
}
}
}
}
},
"components": {
@@ -10288,6 +10616,302 @@
}
}
},
"first_response_time_distribution": {
"type": "object",
"description": "First response time distribution report grouped by channel type. Shows the count of conversations with first response times in different time buckets.",
"additionalProperties": {
"type": "object",
"description": "First response time distribution for a specific channel type (e.g., Channel::WebWidget, Channel::Api)",
"properties": {
"0-1h": {
"type": "number",
"description": "Number of conversations with first response time less than 1 hour"
},
"1-4h": {
"type": "number",
"description": "Number of conversations with first response time between 1-4 hours"
},
"4-8h": {
"type": "number",
"description": "Number of conversations with first response time between 4-8 hours"
},
"8-24h": {
"type": "number",
"description": "Number of conversations with first response time between 8-24 hours"
},
"24h+": {
"type": "number",
"description": "Number of conversations with first response time greater than 24 hours"
}
}
},
"example": {
"Channel::WebWidget": {
"0-1h": 150,
"1-4h": 80,
"4-8h": 45,
"8-24h": 30,
"24h+": 15
},
"Channel::Api": {
"0-1h": 75,
"1-4h": 40,
"4-8h": 20,
"8-24h": 10,
"24h+": 5
}
}
},
"inbox_label_matrix": {
"type": "object",
"description": "Inbox-label matrix report showing the count of conversations for each inbox-label combination.",
"properties": {
"inboxes": {
"type": "array",
"description": "List of inboxes included in the report",
"items": {
"type": "object",
"properties": {
"id": {
"type": "number",
"description": "The inbox ID"
},
"name": {
"type": "string",
"description": "The inbox name"
}
}
}
},
"labels": {
"type": "array",
"description": "List of labels included in the report",
"items": {
"type": "object",
"properties": {
"id": {
"type": "number",
"description": "The label ID"
},
"title": {
"type": "string",
"description": "The label title"
}
}
}
},
"matrix": {
"type": "array",
"description": "2D array where matrix[i][j] represents the count of conversations in inboxes[i] with labels[j]",
"items": {
"type": "array",
"items": {
"type": "number"
}
}
}
},
"example": {
"inboxes": [
{
"id": 1,
"name": "Website Chat"
},
{
"id": 2,
"name": "Email Support"
}
],
"labels": [
{
"id": 1,
"title": "bug"
},
{
"id": 2,
"title": "feature-request"
},
{
"id": 3,
"title": "urgent"
}
],
"matrix": [
[
10,
5,
3
],
[
8,
12,
2
]
]
}
},
"inbox_summary": {
"type": "array",
"description": "Inbox summary report containing conversation statistics grouped by inbox.",
"items": {
"type": "object",
"properties": {
"id": {
"type": "number",
"description": "The inbox ID"
},
"conversations_count": {
"type": "number",
"description": "Number of conversations created in the inbox during the date range"
},
"resolved_conversations_count": {
"type": "number",
"description": "Number of conversations resolved in the inbox during the date range"
},
"avg_resolution_time": {
"type": "number",
"nullable": true,
"description": "Average time (in seconds) to resolve conversations. Null if no data available."
},
"avg_first_response_time": {
"type": "number",
"nullable": true,
"description": "Average time (in seconds) for the first response. Null if no data available."
},
"avg_reply_time": {
"type": "number",
"nullable": true,
"description": "Average time (in seconds) between replies. Null if no data available."
}
}
},
"example": [
{
"id": 1,
"conversations_count": 150,
"resolved_conversations_count": 120,
"avg_resolution_time": 3600,
"avg_first_response_time": 300,
"avg_reply_time": 600
},
{
"id": 2,
"conversations_count": 75,
"resolved_conversations_count": 60,
"avg_resolution_time": 1800,
"avg_first_response_time": 180,
"avg_reply_time": 420
}
]
},
"agent_summary": {
"type": "array",
"description": "Agent summary report containing conversation statistics grouped by agent.",
"items": {
"type": "object",
"properties": {
"id": {
"type": "number",
"description": "The agent (user) ID"
},
"conversations_count": {
"type": "number",
"description": "Number of conversations assigned to the agent during the date range"
},
"resolved_conversations_count": {
"type": "number",
"description": "Number of conversations resolved by the agent during the date range"
},
"avg_resolution_time": {
"type": "number",
"nullable": true,
"description": "Average time (in seconds) to resolve conversations. Null if no data available."
},
"avg_first_response_time": {
"type": "number",
"nullable": true,
"description": "Average time (in seconds) for the first response. Null if no data available."
},
"avg_reply_time": {
"type": "number",
"nullable": true,
"description": "Average time (in seconds) between replies. Null if no data available."
}
}
},
"example": [
{
"id": 1,
"conversations_count": 150,
"resolved_conversations_count": 120,
"avg_resolution_time": 3600,
"avg_first_response_time": 300,
"avg_reply_time": 600
},
{
"id": 2,
"conversations_count": 75,
"resolved_conversations_count": 60,
"avg_resolution_time": 1800,
"avg_first_response_time": 180,
"avg_reply_time": 420
}
]
},
"team_summary": {
"type": "array",
"description": "Team summary report containing conversation statistics grouped by team.",
"items": {
"type": "object",
"properties": {
"id": {
"type": "number",
"description": "The team ID"
},
"conversations_count": {
"type": "number",
"description": "Number of conversations assigned to the team during the date range"
},
"resolved_conversations_count": {
"type": "number",
"description": "Number of conversations resolved by the team during the date range"
},
"avg_resolution_time": {
"type": "number",
"nullable": true,
"description": "Average time (in seconds) to resolve conversations. Null if no data available."
},
"avg_first_response_time": {
"type": "number",
"nullable": true,
"description": "Average time (in seconds) for the first response. Null if no data available."
},
"avg_reply_time": {
"type": "number",
"nullable": true,
"description": "Average time (in seconds) between replies. Null if no data available."
}
}
},
"example": [
{
"id": 1,
"conversations_count": 250,
"resolved_conversations_count": 200,
"avg_resolution_time": 2800,
"avg_first_response_time": 240,
"avg_reply_time": 500
},
{
"id": 2,
"conversations_count": 180,
"resolved_conversations_count": 150,
"avg_resolution_time": 2400,
"avg_first_response_time": 200,
"avg_reply_time": 450
}
]
},
"contact_detail": {
"type": "object",
"properties": {
+296
View File
@@ -4424,6 +4424,302 @@
}
}
},
"first_response_time_distribution": {
"type": "object",
"description": "First response time distribution report grouped by channel type. Shows the count of conversations with first response times in different time buckets.",
"additionalProperties": {
"type": "object",
"description": "First response time distribution for a specific channel type (e.g., Channel::WebWidget, Channel::Api)",
"properties": {
"0-1h": {
"type": "number",
"description": "Number of conversations with first response time less than 1 hour"
},
"1-4h": {
"type": "number",
"description": "Number of conversations with first response time between 1-4 hours"
},
"4-8h": {
"type": "number",
"description": "Number of conversations with first response time between 4-8 hours"
},
"8-24h": {
"type": "number",
"description": "Number of conversations with first response time between 8-24 hours"
},
"24h+": {
"type": "number",
"description": "Number of conversations with first response time greater than 24 hours"
}
}
},
"example": {
"Channel::WebWidget": {
"0-1h": 150,
"1-4h": 80,
"4-8h": 45,
"8-24h": 30,
"24h+": 15
},
"Channel::Api": {
"0-1h": 75,
"1-4h": 40,
"4-8h": 20,
"8-24h": 10,
"24h+": 5
}
}
},
"inbox_label_matrix": {
"type": "object",
"description": "Inbox-label matrix report showing the count of conversations for each inbox-label combination.",
"properties": {
"inboxes": {
"type": "array",
"description": "List of inboxes included in the report",
"items": {
"type": "object",
"properties": {
"id": {
"type": "number",
"description": "The inbox ID"
},
"name": {
"type": "string",
"description": "The inbox name"
}
}
}
},
"labels": {
"type": "array",
"description": "List of labels included in the report",
"items": {
"type": "object",
"properties": {
"id": {
"type": "number",
"description": "The label ID"
},
"title": {
"type": "string",
"description": "The label title"
}
}
}
},
"matrix": {
"type": "array",
"description": "2D array where matrix[i][j] represents the count of conversations in inboxes[i] with labels[j]",
"items": {
"type": "array",
"items": {
"type": "number"
}
}
}
},
"example": {
"inboxes": [
{
"id": 1,
"name": "Website Chat"
},
{
"id": 2,
"name": "Email Support"
}
],
"labels": [
{
"id": 1,
"title": "bug"
},
{
"id": 2,
"title": "feature-request"
},
{
"id": 3,
"title": "urgent"
}
],
"matrix": [
[
10,
5,
3
],
[
8,
12,
2
]
]
}
},
"inbox_summary": {
"type": "array",
"description": "Inbox summary report containing conversation statistics grouped by inbox.",
"items": {
"type": "object",
"properties": {
"id": {
"type": "number",
"description": "The inbox ID"
},
"conversations_count": {
"type": "number",
"description": "Number of conversations created in the inbox during the date range"
},
"resolved_conversations_count": {
"type": "number",
"description": "Number of conversations resolved in the inbox during the date range"
},
"avg_resolution_time": {
"type": "number",
"nullable": true,
"description": "Average time (in seconds) to resolve conversations. Null if no data available."
},
"avg_first_response_time": {
"type": "number",
"nullable": true,
"description": "Average time (in seconds) for the first response. Null if no data available."
},
"avg_reply_time": {
"type": "number",
"nullable": true,
"description": "Average time (in seconds) between replies. Null if no data available."
}
}
},
"example": [
{
"id": 1,
"conversations_count": 150,
"resolved_conversations_count": 120,
"avg_resolution_time": 3600,
"avg_first_response_time": 300,
"avg_reply_time": 600
},
{
"id": 2,
"conversations_count": 75,
"resolved_conversations_count": 60,
"avg_resolution_time": 1800,
"avg_first_response_time": 180,
"avg_reply_time": 420
}
]
},
"agent_summary": {
"type": "array",
"description": "Agent summary report containing conversation statistics grouped by agent.",
"items": {
"type": "object",
"properties": {
"id": {
"type": "number",
"description": "The agent (user) ID"
},
"conversations_count": {
"type": "number",
"description": "Number of conversations assigned to the agent during the date range"
},
"resolved_conversations_count": {
"type": "number",
"description": "Number of conversations resolved by the agent during the date range"
},
"avg_resolution_time": {
"type": "number",
"nullable": true,
"description": "Average time (in seconds) to resolve conversations. Null if no data available."
},
"avg_first_response_time": {
"type": "number",
"nullable": true,
"description": "Average time (in seconds) for the first response. Null if no data available."
},
"avg_reply_time": {
"type": "number",
"nullable": true,
"description": "Average time (in seconds) between replies. Null if no data available."
}
}
},
"example": [
{
"id": 1,
"conversations_count": 150,
"resolved_conversations_count": 120,
"avg_resolution_time": 3600,
"avg_first_response_time": 300,
"avg_reply_time": 600
},
{
"id": 2,
"conversations_count": 75,
"resolved_conversations_count": 60,
"avg_resolution_time": 1800,
"avg_first_response_time": 180,
"avg_reply_time": 420
}
]
},
"team_summary": {
"type": "array",
"description": "Team summary report containing conversation statistics grouped by team.",
"items": {
"type": "object",
"properties": {
"id": {
"type": "number",
"description": "The team ID"
},
"conversations_count": {
"type": "number",
"description": "Number of conversations assigned to the team during the date range"
},
"resolved_conversations_count": {
"type": "number",
"description": "Number of conversations resolved by the team during the date range"
},
"avg_resolution_time": {
"type": "number",
"nullable": true,
"description": "Average time (in seconds) to resolve conversations. Null if no data available."
},
"avg_first_response_time": {
"type": "number",
"nullable": true,
"description": "Average time (in seconds) for the first response. Null if no data available."
},
"avg_reply_time": {
"type": "number",
"nullable": true,
"description": "Average time (in seconds) between replies. Null if no data available."
}
}
},
"example": [
{
"id": 1,
"conversations_count": 250,
"resolved_conversations_count": 200,
"avg_resolution_time": 2800,
"avg_first_response_time": 240,
"avg_reply_time": 500
},
{
"id": 2,
"conversations_count": 180,
"resolved_conversations_count": 150,
"avg_resolution_time": 2400,
"avg_first_response_time": 200,
"avg_reply_time": 450
}
]
},
"contact_detail": {
"type": "object",
"properties": {
+296
View File
@@ -3839,6 +3839,302 @@
}
}
},
"first_response_time_distribution": {
"type": "object",
"description": "First response time distribution report grouped by channel type. Shows the count of conversations with first response times in different time buckets.",
"additionalProperties": {
"type": "object",
"description": "First response time distribution for a specific channel type (e.g., Channel::WebWidget, Channel::Api)",
"properties": {
"0-1h": {
"type": "number",
"description": "Number of conversations with first response time less than 1 hour"
},
"1-4h": {
"type": "number",
"description": "Number of conversations with first response time between 1-4 hours"
},
"4-8h": {
"type": "number",
"description": "Number of conversations with first response time between 4-8 hours"
},
"8-24h": {
"type": "number",
"description": "Number of conversations with first response time between 8-24 hours"
},
"24h+": {
"type": "number",
"description": "Number of conversations with first response time greater than 24 hours"
}
}
},
"example": {
"Channel::WebWidget": {
"0-1h": 150,
"1-4h": 80,
"4-8h": 45,
"8-24h": 30,
"24h+": 15
},
"Channel::Api": {
"0-1h": 75,
"1-4h": 40,
"4-8h": 20,
"8-24h": 10,
"24h+": 5
}
}
},
"inbox_label_matrix": {
"type": "object",
"description": "Inbox-label matrix report showing the count of conversations for each inbox-label combination.",
"properties": {
"inboxes": {
"type": "array",
"description": "List of inboxes included in the report",
"items": {
"type": "object",
"properties": {
"id": {
"type": "number",
"description": "The inbox ID"
},
"name": {
"type": "string",
"description": "The inbox name"
}
}
}
},
"labels": {
"type": "array",
"description": "List of labels included in the report",
"items": {
"type": "object",
"properties": {
"id": {
"type": "number",
"description": "The label ID"
},
"title": {
"type": "string",
"description": "The label title"
}
}
}
},
"matrix": {
"type": "array",
"description": "2D array where matrix[i][j] represents the count of conversations in inboxes[i] with labels[j]",
"items": {
"type": "array",
"items": {
"type": "number"
}
}
}
},
"example": {
"inboxes": [
{
"id": 1,
"name": "Website Chat"
},
{
"id": 2,
"name": "Email Support"
}
],
"labels": [
{
"id": 1,
"title": "bug"
},
{
"id": 2,
"title": "feature-request"
},
{
"id": 3,
"title": "urgent"
}
],
"matrix": [
[
10,
5,
3
],
[
8,
12,
2
]
]
}
},
"inbox_summary": {
"type": "array",
"description": "Inbox summary report containing conversation statistics grouped by inbox.",
"items": {
"type": "object",
"properties": {
"id": {
"type": "number",
"description": "The inbox ID"
},
"conversations_count": {
"type": "number",
"description": "Number of conversations created in the inbox during the date range"
},
"resolved_conversations_count": {
"type": "number",
"description": "Number of conversations resolved in the inbox during the date range"
},
"avg_resolution_time": {
"type": "number",
"nullable": true,
"description": "Average time (in seconds) to resolve conversations. Null if no data available."
},
"avg_first_response_time": {
"type": "number",
"nullable": true,
"description": "Average time (in seconds) for the first response. Null if no data available."
},
"avg_reply_time": {
"type": "number",
"nullable": true,
"description": "Average time (in seconds) between replies. Null if no data available."
}
}
},
"example": [
{
"id": 1,
"conversations_count": 150,
"resolved_conversations_count": 120,
"avg_resolution_time": 3600,
"avg_first_response_time": 300,
"avg_reply_time": 600
},
{
"id": 2,
"conversations_count": 75,
"resolved_conversations_count": 60,
"avg_resolution_time": 1800,
"avg_first_response_time": 180,
"avg_reply_time": 420
}
]
},
"agent_summary": {
"type": "array",
"description": "Agent summary report containing conversation statistics grouped by agent.",
"items": {
"type": "object",
"properties": {
"id": {
"type": "number",
"description": "The agent (user) ID"
},
"conversations_count": {
"type": "number",
"description": "Number of conversations assigned to the agent during the date range"
},
"resolved_conversations_count": {
"type": "number",
"description": "Number of conversations resolved by the agent during the date range"
},
"avg_resolution_time": {
"type": "number",
"nullable": true,
"description": "Average time (in seconds) to resolve conversations. Null if no data available."
},
"avg_first_response_time": {
"type": "number",
"nullable": true,
"description": "Average time (in seconds) for the first response. Null if no data available."
},
"avg_reply_time": {
"type": "number",
"nullable": true,
"description": "Average time (in seconds) between replies. Null if no data available."
}
}
},
"example": [
{
"id": 1,
"conversations_count": 150,
"resolved_conversations_count": 120,
"avg_resolution_time": 3600,
"avg_first_response_time": 300,
"avg_reply_time": 600
},
{
"id": 2,
"conversations_count": 75,
"resolved_conversations_count": 60,
"avg_resolution_time": 1800,
"avg_first_response_time": 180,
"avg_reply_time": 420
}
]
},
"team_summary": {
"type": "array",
"description": "Team summary report containing conversation statistics grouped by team.",
"items": {
"type": "object",
"properties": {
"id": {
"type": "number",
"description": "The team ID"
},
"conversations_count": {
"type": "number",
"description": "Number of conversations assigned to the team during the date range"
},
"resolved_conversations_count": {
"type": "number",
"description": "Number of conversations resolved by the team during the date range"
},
"avg_resolution_time": {
"type": "number",
"nullable": true,
"description": "Average time (in seconds) to resolve conversations. Null if no data available."
},
"avg_first_response_time": {
"type": "number",
"nullable": true,
"description": "Average time (in seconds) for the first response. Null if no data available."
},
"avg_reply_time": {
"type": "number",
"nullable": true,
"description": "Average time (in seconds) between replies. Null if no data available."
}
}
},
"example": [
{
"id": 1,
"conversations_count": 250,
"resolved_conversations_count": 200,
"avg_resolution_time": 2800,
"avg_first_response_time": 240,
"avg_reply_time": 500
},
{
"id": 2,
"conversations_count": 180,
"resolved_conversations_count": 150,
"avg_resolution_time": 2400,
"avg_first_response_time": 200,
"avg_reply_time": 450
}
]
},
"contact_detail": {
"type": "object",
"properties": {
+296
View File
@@ -4600,6 +4600,302 @@
}
}
},
"first_response_time_distribution": {
"type": "object",
"description": "First response time distribution report grouped by channel type. Shows the count of conversations with first response times in different time buckets.",
"additionalProperties": {
"type": "object",
"description": "First response time distribution for a specific channel type (e.g., Channel::WebWidget, Channel::Api)",
"properties": {
"0-1h": {
"type": "number",
"description": "Number of conversations with first response time less than 1 hour"
},
"1-4h": {
"type": "number",
"description": "Number of conversations with first response time between 1-4 hours"
},
"4-8h": {
"type": "number",
"description": "Number of conversations with first response time between 4-8 hours"
},
"8-24h": {
"type": "number",
"description": "Number of conversations with first response time between 8-24 hours"
},
"24h+": {
"type": "number",
"description": "Number of conversations with first response time greater than 24 hours"
}
}
},
"example": {
"Channel::WebWidget": {
"0-1h": 150,
"1-4h": 80,
"4-8h": 45,
"8-24h": 30,
"24h+": 15
},
"Channel::Api": {
"0-1h": 75,
"1-4h": 40,
"4-8h": 20,
"8-24h": 10,
"24h+": 5
}
}
},
"inbox_label_matrix": {
"type": "object",
"description": "Inbox-label matrix report showing the count of conversations for each inbox-label combination.",
"properties": {
"inboxes": {
"type": "array",
"description": "List of inboxes included in the report",
"items": {
"type": "object",
"properties": {
"id": {
"type": "number",
"description": "The inbox ID"
},
"name": {
"type": "string",
"description": "The inbox name"
}
}
}
},
"labels": {
"type": "array",
"description": "List of labels included in the report",
"items": {
"type": "object",
"properties": {
"id": {
"type": "number",
"description": "The label ID"
},
"title": {
"type": "string",
"description": "The label title"
}
}
}
},
"matrix": {
"type": "array",
"description": "2D array where matrix[i][j] represents the count of conversations in inboxes[i] with labels[j]",
"items": {
"type": "array",
"items": {
"type": "number"
}
}
}
},
"example": {
"inboxes": [
{
"id": 1,
"name": "Website Chat"
},
{
"id": 2,
"name": "Email Support"
}
],
"labels": [
{
"id": 1,
"title": "bug"
},
{
"id": 2,
"title": "feature-request"
},
{
"id": 3,
"title": "urgent"
}
],
"matrix": [
[
10,
5,
3
],
[
8,
12,
2
]
]
}
},
"inbox_summary": {
"type": "array",
"description": "Inbox summary report containing conversation statistics grouped by inbox.",
"items": {
"type": "object",
"properties": {
"id": {
"type": "number",
"description": "The inbox ID"
},
"conversations_count": {
"type": "number",
"description": "Number of conversations created in the inbox during the date range"
},
"resolved_conversations_count": {
"type": "number",
"description": "Number of conversations resolved in the inbox during the date range"
},
"avg_resolution_time": {
"type": "number",
"nullable": true,
"description": "Average time (in seconds) to resolve conversations. Null if no data available."
},
"avg_first_response_time": {
"type": "number",
"nullable": true,
"description": "Average time (in seconds) for the first response. Null if no data available."
},
"avg_reply_time": {
"type": "number",
"nullable": true,
"description": "Average time (in seconds) between replies. Null if no data available."
}
}
},
"example": [
{
"id": 1,
"conversations_count": 150,
"resolved_conversations_count": 120,
"avg_resolution_time": 3600,
"avg_first_response_time": 300,
"avg_reply_time": 600
},
{
"id": 2,
"conversations_count": 75,
"resolved_conversations_count": 60,
"avg_resolution_time": 1800,
"avg_first_response_time": 180,
"avg_reply_time": 420
}
]
},
"agent_summary": {
"type": "array",
"description": "Agent summary report containing conversation statistics grouped by agent.",
"items": {
"type": "object",
"properties": {
"id": {
"type": "number",
"description": "The agent (user) ID"
},
"conversations_count": {
"type": "number",
"description": "Number of conversations assigned to the agent during the date range"
},
"resolved_conversations_count": {
"type": "number",
"description": "Number of conversations resolved by the agent during the date range"
},
"avg_resolution_time": {
"type": "number",
"nullable": true,
"description": "Average time (in seconds) to resolve conversations. Null if no data available."
},
"avg_first_response_time": {
"type": "number",
"nullable": true,
"description": "Average time (in seconds) for the first response. Null if no data available."
},
"avg_reply_time": {
"type": "number",
"nullable": true,
"description": "Average time (in seconds) between replies. Null if no data available."
}
}
},
"example": [
{
"id": 1,
"conversations_count": 150,
"resolved_conversations_count": 120,
"avg_resolution_time": 3600,
"avg_first_response_time": 300,
"avg_reply_time": 600
},
{
"id": 2,
"conversations_count": 75,
"resolved_conversations_count": 60,
"avg_resolution_time": 1800,
"avg_first_response_time": 180,
"avg_reply_time": 420
}
]
},
"team_summary": {
"type": "array",
"description": "Team summary report containing conversation statistics grouped by team.",
"items": {
"type": "object",
"properties": {
"id": {
"type": "number",
"description": "The team ID"
},
"conversations_count": {
"type": "number",
"description": "Number of conversations assigned to the team during the date range"
},
"resolved_conversations_count": {
"type": "number",
"description": "Number of conversations resolved by the team during the date range"
},
"avg_resolution_time": {
"type": "number",
"nullable": true,
"description": "Average time (in seconds) to resolve conversations. Null if no data available."
},
"avg_first_response_time": {
"type": "number",
"nullable": true,
"description": "Average time (in seconds) for the first response. Null if no data available."
},
"avg_reply_time": {
"type": "number",
"nullable": true,
"description": "Average time (in seconds) between replies. Null if no data available."
}
}
},
"example": [
{
"id": 1,
"conversations_count": 250,
"resolved_conversations_count": 200,
"avg_resolution_time": 2800,
"avg_first_response_time": 240,
"avg_reply_time": 500
},
{
"id": 2,
"conversations_count": 180,
"resolved_conversations_count": 150,
"avg_resolution_time": 2400,
"avg_first_response_time": 200,
"avg_reply_time": 450
}
]
},
"contact_detail": {
"type": "object",
"properties": {