Compare commits

...
Author SHA1 Message Date
Sony MathewandGitHub a4eff3d200 Merge branch 'feature/cw-7513' into feature/cw-7513-specs 2026-07-22 16:54:30 +05:30
Sony MathewandGitHub 565a26be21 Merge branch 'develop' into feature/cw-7513 2026-07-22 16:54:12 +05:30
Sivin VargheseandGitHub 1e52d23d7a fix: guard agent sort against null names in assignment dropdown (#15125) 2026-07-22 15:27:04 +05:30
Sivin VargheseandGitHub fbb3479263 fix: guard agent sort against null names in assignment dropdown (#15125)
# Pull Request Template

## Description

This PR fixes a crash where opening a conversation threw `TypeError:
Cannot read properties of null (reading 'localeCompare')` and prevented
the agent assignment dropdown from rendering.

Since #14866, agent bots are included in the assignable agents list.
`AgentBot#name` is not presence-validated, so system bots (account-less,
global) can have a `null` name. Those nameless bots flowed into
name-based operations that assumed a string, causing crashes and
warnings across multiple surfaces:

* **Assignment dropdown sort:** `getAgentsByAvailability` called
`a.name.localeCompare(b.name)`, causing a `localeCompare` `TypeError`.
* **Dropdown search:** `MultiselectDropdownItems` called
`option.name.toLowerCase()`, causing a `toLowerCase` `TypeError`.
* **Agent Bots settings:** `Avatar` received `name=null` for a `String`
prop, triggering a Vue prop validation warning.

### What changed

* Keep nameless agent bots in the assignment dropdown and render a `-`
fallback label in `useAgentsList`. These are still valid,
assignable-by-ID records: the assignable agents API includes accessible
bots, and `Conversations::AssignmentService` assigns them by ID.
Preserving them avoids hiding valid assignment targets. Bots are still
included only when `includeAgentBots` is enabled.
* Make the sort in `getAgentsByAvailability` null-safe by coercing
missing names to an empty string (defense in depth).
* Make the search filter in `MultiselectDropdownItems` null-safe
(defense in depth).
* Pass a null-safe `name` prop to `Avatar` in the Agent Bots settings
list to eliminate the Vue prop validation warning.

Fixes
https://linear.app/chatwoot/issue/CW-7670/agent-assignment-dropdown-crashes-with-cannot-read-properties-of-null

## Type of change

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

## How Has This Been Tested?


1. Have a system agent bot (`name: null`) that is assignable to an
inbox.
2. Open any conversation in that inbox.
   * The agent assignment dropdown renders without console errors.
   * The nameless bot is listed with a `-` label and can be assigned.
3. Go to **Settings → Agent Bots**.
   * The page renders without the `Avatar` prop validation warning.


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-07-22 15:23:19 +05:30
166a41c31c fix(whatsapp): prevent invalid automation sends outside reply window (#15113)
WhatsApp automations now fail locally when they attempt to send a
free-form message after the 24-hour customer service window has closed.
This avoids sending an invalid template request to Meta and gives users
a clear, actionable error instead of “Template not found or invalid
template name.”

Template messages continue to be sent whenever template parameters are
present. Free-form messages continue to be sent normally while the
conversation is replyable.

Fixes
https://linear.app/chatwoot/issue/PLA-183/prevent-whatsapp-automations-outside-the-24-hour-window-from-producing

### How to reproduce

1. Create a WhatsApp automation that sends a message without template
parameters.
2. Trigger it on a conversation whose 24-hour customer service window is
closed.
3. Observe that the message previously reached the template send path
and failed with a misleading provider error.

### How to test

1. Trigger an automation with template parameters and confirm it sends
as a template message.
2. Trigger an automation without template parameters inside the 24-hour
window and confirm it sends as a free-form message.
3. Trigger an automation without template parameters outside the 24-hour
window and confirm it fails locally with a clear error and makes no
request to Meta.

### Things to know

This changes only the invalid closed-window, no-template path. Existing
template and in-window message behavior remains unchanged.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-07-22 12:40:28 +04:00
e65e18e9c5 fix: normalize phone numbers during whatsapp channel lookup (#13709)
Fixes
https://linear.app/chatwoot/issue/PLA-99/whatsapp-messages-dropped-for-brazilargentina-numbers-due-to-phone
Fixes https://github.com/chatwoot/chatwoot/issues/14492

Meta's WhatsApp Cloud API includes `display_phone_number` in webhook
payloads, but its format can differ from the number stored in Chatwoot's
channel record.
In Brazil, Meta omits the mobile 9 prefix. For example, it sends
55419XXXXXXX (12 digits) instead of 554199XXXXXXX (13 digits). In
Argentina, Meta adds an extra 9 after the country code. For example, it
sends 549XXXXXXXXXX instead of 54XXXXXXXXXX.
The whatsapp event job uses `display_phone_number` for an exact-match
channel lookup. When the formats do not match, the lookup returns nil
and the incoming message is silently dropped, logging:

`Inactive WhatsApp channel: unknown - <phone_number>.`

The fix extends `get_channel_from_wb_payload` to fall back to normalized
phone number matching using the existing PhoneNumberNormalizationService
normalizers (Brazil, Argentina), which were previously only used for
contact-level lookups.

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-07-22 10:33:40 +04:00
Shivam MishraandGitHub 89b83c65c8 fix: close message generation popover when its trigger scrolls away (#15114) 2026-07-21 19:34:57 +05:30
ed30ff9c22 fix(whatsapp): allow calling a contact with no existing conversation (#15014)
## Description

Agents can now place a WhatsApp call to a contact straight from the
contacts screen, even if that contact has never messaged in. Previously
the call only worked once a conversation already existed, so a freshly
added contact would fail with "Unable to start the call. Please try
again." — the only workaround was to get the contact to message the
channel first.


## Type of change

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

## How Has This Been Tested?

- Manually via UI

## 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

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-07-21 18:08:32 +05:30
7d2f01e402 feat(whatsapp): unify embedded signup feature gating (#15106)
WhatsApp embedded signup now uses
`whatsapp_embedded_signup_inbox_creation` as the single Chatwoot Cloud
rollout gate for inbox creation, proactive reconfiguration, and
disconnected inbox reauthorization. The authorization endpoint enforces
the same gate, so the UI and backend remain consistent.

Self-hosted installations keep their existing behavior.

## Things to know

- This reuses the existing feature flag; there is no migration or schema
change.
- The feature is shown as “WhatsApp Embedded Signup Flow” in feature
management.
- `whatsapp_reconfigure` remains visible and honored for self-hosted
proactive reconfiguration to preserve existing accounts. It can be
deprecated after the self-hosted dependency is removed or migrated.

## How to test

1. On Chatwoot Cloud, enable `whatsapp_embedded_signup_inbox_creation`
for an account.
2. Confirm that new WhatsApp inbox creation, proactive reconfiguration,
and disconnected inbox reauthorization are available.
3. Disable the flag and confirm those entry points are hidden and
authorization requests are rejected.
4. On self-hosted, confirm proactive reconfiguration remains controlled
by the existing `whatsapp_reconfigure` account setting.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-07-21 15:05:11 +04:00
Shivam MishraandGitHub 8c013415b8 fix: localize the captain overview summary greeting (#15108) 2026-07-21 16:29:08 +05:30
Tanmay Deep SharmaandGitHub 2144de92f2 fix(whatsapp): reopen conversation across a contact's coexistence identities (#15098)
WhatsApp contacts using coexistence are identified by more than one
source ID (a phone `wa_id` and a `BR.`/BSUID identity), so a single
contact ends up owning multiple `contact_inbox` records. The "reopen the
same conversation" feature scoped conversation reuse to a single
`contact_inbox`, so messages arriving under a different identity of the
same contact started a brand-new conversation — even with reopen enabled
— producing duplicate conversations.

This scopes reuse to the contact across all of its `contact_inbox`
records in the inbox instead of a single `contact_inbox`.

## Closes
- [CW-7651
](https://linear.app/chatwoot/issue/CW-7651/duplicate-conversations)

## How to reproduce
1. On a WhatsApp Cloud inbox with "reopen the same conversation" (lock
to single conversation) enabled.
2. Have a coexistence contact whose webhooks alternate between carrying
the phone `wa_id` and only the BSUID identity.
3. Before: each identity opens its own conversation → duplicates. After:
incoming messages reopen the contact's existing conversation regardless
of which identity the webhook carried.

## What changed
- `Whatsapp::IncomingMessageBaseService#set_conversation` now looks up
reusable conversations via `@contact.conversations.where(inbox_id:
@inbox.id)` instead of `@contact_inbox.conversations`.
- Updated existing specs to wire the conversation's `contact` to the
contact_inbox's contact, mirroring production data.
2026-07-21 16:21:40 +05:30
0e376f4fe2 feat(whatsapp-call): support BSUID callers for inbound voice calls (#14743)
## Linear Ticket
-
https://linear.app/chatwoot/issue/CW-7276/bsuid-support-to-whatsapp-voice-calling

## Description

Keeps WhatsApp voice calls in the same thread as the chat when a caller
has adopted a **WhatsApp username** and hidden their phone number.
This makes the inbound-call path BSUID-aware, reusing the same
identifier the messaging pipeline keys on so calls land on the existing
`ContactInbox`/conversation.

## Type of change

- [ ] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?

-  Locally via UI

## 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

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-07-21 16:19:53 +05:30
Shivam MishraandGitHub 67cab7171d feat: show Captain generation path on conversation messages [CW-7484] (#15078) 2026-07-21 15:15:13 +05:30
Shivam MishraandGitHub 7a5385cc32 feat: improve captain overview loading and reuse stats for summary [CW-7610] (#15105) 2026-07-21 15:14:18 +05:30
Sivin VargheseandGitHub 920a98ccf4 fix: calls dashboard load race (#15094) 2026-07-21 15:00:08 +05:30
Aakash BakhleandGitHub ae49af354d fix: serialize multimodal Captain session content (#15096)
Captain now saves agent session records when a user message includes an
image. The saved record keeps the image URL and excludes downloaded
image bytes, so image replies no longer report a JSON serialization
error after delivery.

Fixes:
https://chatwoot-p3.sentry.io/issues/7618423184/?alert_rule_id=13673680&alert_type=issue&notification_uuid=d22a7ab9-95d6-4bba-85e0-733a28466775&project=6382945

## Root cause

RubyLLM downloads image attachments and caches the binary bytes inside
`RubyLLM::Content`. `SessionCaptureService` passed the live object to
the `run_context` JSON column. Rails then tried to encode the cached
JPEG bytes as UTF-8 and raised `JSON::GeneratorError`.

The error did not block replies, handoffs, or credit updates because
session capture rescues its own failures. The failed write meant that
Chatwoot lost the agent session record for the response.

## How to reproduce

1. Send an image to a Captain V2 assistant.
2. Let RubyLLM load the image during the model request.
3. Save the resulting conversation history in an agent session.
4. Observe the JSON encoding error when Rails reaches the cached image
bytes.

## What changed

`SessionCaptureService` now converts `RubyLLM::Content` to its JSON safe
hash before saving the current turn. The hash contains the message text
and attachment URL without the cached bytes. Other message content is
unchanged.

The focused service spec covers a cached JPEG byte payload and passes
with 12 examples. RuboCop reports no offenses in the changed service and
spec.
2026-07-21 13:04:12 +05:30
d1fa8d8c2f refactor: share whatsapp/twilio template logic via @chatwoot/utils (#15001)
# Pull Request Template

## Description
Moves the WhatsApp & Twilio content-template logic to the shared
[`@chatwoot/utils`](https://github.com/chatwoot/utils)
([PR](https://github.com/chatwoot/utils/pull/62)) package so web and
mobile share one implementation. The neutral core takes the raw template
and returns `processed_params`, the same shape the web parsers already
use, so it's a drop-in with no behavior change.

- `templateHelper.js` / `URLHelper.js` → source `MEDIA_FORMATS`,
`findComponentByType`, `processVariable`, `buildTemplateParameters`,
`extractFilenameFromUrl` from the package
- `inboxes.js` → filters with shared `isSendableTemplate`
- `WhatsAppTemplateParser.vue` / `ContentTemplateParser.vue` →
`isFormInvalid` and Twilio media helpers now use the shared
`isWhatsAppComplete` / `isTwilioComplete` / `applyTwilioMediaFilename`

> Depends on the `@chatwoot/utils` release adding the shared template
API, bump `package.json` from `^0.0.55` to the published version before
merge.

Fixes
[CW-7540](https://linear.app/chatwoot/issue/CW-7540/web-templates-integration-with-utils)

## Type of change

- [x] Breaking change (Refactor)

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-07-21 10:08:23 +04:00
71fffdd2b9 fix: rate limit widget conversation transcript API (#15085)
## Description

The widget conversation transcript endpoint (`POST
/api/v1/widget/conversations/transcript`) has no rate limit. Every other
comparable endpoint does: the agent-facing transcript API and the widget
conversation-create and contact-update endpoints are all throttled. This
gap lets a single client trigger a large burst of transcript emails from
one conversation.

This adds an IP-based throttle (5 requests/hour) for the endpoint,
placed inside the existing widget-API throttle block so it inherits the
`ENABLE_RACK_ATTACK_WIDGET_API` opt-out used by embedded/iframe clients.
The limit is generous for legitimate use (a visitor emailing themselves
a transcript) while stopping abusive loops. Throttled requests get the
standard 429 the widget already handles.

## Type of change

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

## How Has This Been Tested?

`config/initializers/rack_attack.rb` throttles have no existing specs in
this file, so this follows the established convention (no new spec).
Verified `ruby -c` and `rubocop` pass on the file. The new throttle
mirrors the sibling widget throttles directly above it (same IP key,
path guard, and structure).

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] My changes generate no new warnings

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-07-20 16:09:29 -07:00
Sojan JoseandGitHub eae9841eb4 fix: restore token access to account APIs (#15088)
Token-authenticated requests to Agent Bots, Labels, and affected Captain
endpoints return normal responses again. The regression was caused by
duplicate `current_account` callbacks in subclasses moving account
resolution behind the API entitlement check, leaving `Current.account`
unset.

## Closes

- https://linear.app/chatwoot/issue/CW-7641/5xx-errors-in-agent-bot-apis

## How to reproduce

1. Send `GET /api/v1/accounts/:account_id/agent_bots` with a valid
administrator API access token.
2. Observe a `500` from `validate_token_api_access` because
`Current.account` is `nil`.
3. With this change, account resolution runs in the base-controller
order and the request succeeds.

## What changed

- Removed redundant `current_account` callbacks from account-scoped
controllers that already inherit the callback from
`Api::V1::Accounts::BaseController`.
- Kept the standalone direct-upload controller callback unchanged.
- Added regression coverage for administrator API-token access to Agent
Bots.
2026-07-20 15:28:33 -07:00
160732c07d fix: rate limit agent management APIs (#15081)
# Pull Request Template


Bring  agent create and delete requests under rack attack throttling

Related to https://linear.app/chatwoot/issue/CW-7637


Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com>
2026-07-20 20:41:39 +05:30
Sivin VargheseandGitHub 7a299307b8 fix: apply installation name to sender name preview (#15076) 2026-07-20 19:28:58 +05:30
Sivin VargheseandGitHub 08f49f5896 fix: prevent channel list crash on hard reload (#15074) 2026-07-20 19:28:48 +05:30
Tanmay Deep Sharma b324a147e6 Merge feature/cw-7513 into feature/cw-7513-specs 2026-07-20 18:32:46 +05:30
Tanmay Deep Sharma 428b07157c chore(automations): run delayed sweep on the 5-minute scheduled items job 2026-07-20 18:32:19 +05:30
Tanmay Deep Sharma 9e281bd655 Merge feature/cw-7513 into feature/cw-7513-specs 2026-07-20 18:27:05 +05:30
Tanmay Deep Sharma 63837ebef3 feat(automations): trigger-based delayed builder with standard dropdowns 2026-07-20 18:26:56 +05:30
Tanmay Deep Sharma bdee98d965 Merge feature/cw-7513 into feature/cw-7513-specs 2026-07-20 17:48:34 +05:30
Tanmay Deep Sharma 174630bec1 refactor(automations): keep delayed-rule curation in the frontend, leave backend validation as-is 2026-07-20 17:48:27 +05:30
Tanmay Deep Sharma 927e6573c9 Merge feature/cw-7513 into feature/cw-7513-specs 2026-07-20 17:42:41 +05:30
Tanmay Deep Sharma a363f22d82 feat(automations): scope delayed rules to meaningful events/conditions and group the list 2026-07-20 17:42:33 +05:30
Sivin VargheseandGitHub bf0a10c780 feat: introduce voice call dashboard (#14954) 2026-07-20 14:54:05 +05:30
Tanmay Deep Sharma a1c32ca96c test(automations): re-arm tracks newest incoming message on episode collision 2026-07-20 14:06:41 +05:30
Tanmay Deep Sharma 7deda0f82a Merge feature/cw-7513 into feature/cw-7513-specs 2026-07-20 14:06:01 +05:30
Tanmay Deep Sharma a450d6437d fix(automations): track newest incoming message on episode re-arm 2026-07-20 14:05:59 +05:30
Tanmay Deep Sharma a9149ed211 test(automations): re-anchor stuck processing reply-chase rows on a newer reply 2026-07-20 13:02:07 +05:30
Tanmay Deep Sharma f52f988b6f Merge feature/cw-7513 into feature/cw-7513-specs 2026-07-20 13:01:05 +05:30
Tanmay Deep Sharma a33ee3a1d0 fix(automations): re-anchor stuck processing reply-chase rows on a newer reply 2026-07-20 13:01:03 +05:30
Tanmay Deep Sharma 3c813a6d15 test(automations): re-arm message waits after a condition-only skip 2026-07-20 12:47:52 +05:30
Tanmay Deep Sharma 77d37fb889 Merge feature/cw-7513 into feature/cw-7513-specs 2026-07-20 12:47:03 +05:30
Tanmay Deep Sharma 1a73682c9a fix(automations): re-arm message waits after a condition-only skip 2026-07-20 12:47:00 +05:30
Tanmay Deep Sharma 10ae8218d2 Merge feature/cw-7513 into feature/cw-7513-specs 2026-07-20 12:26:18 +05:30
Tanmay Deep Sharma 5f92426e85 fix(automations): sweep delayed rules on a dedicated 1-minute cron 2026-07-20 12:26:16 +05:30
Tanmay Deep Sharma cfb6643ed5 test(automations): reply-chase clock does not move backwards on late older reply 2026-07-20 12:06:06 +05:30
Tanmay Deep Sharma 7929fbd8d9 Merge feature/cw-7513 into feature/cw-7513-specs 2026-07-20 12:05:26 +05:30
Tanmay Deep Sharma 68feaf99f6 fix(automations): guard reply-chase reschedule and re-validate delayed conditions on event change 2026-07-20 12:05:12 +05:30
Tanmay Deep Sharma 906263ee20 test(automations): anchor delayed due_at to the triggering event time 2026-07-20 11:49:03 +05:30
Tanmay Deep Sharma f298b00477 Merge feature/cw-7513 into feature/cw-7513-specs 2026-07-20 11:46:56 +05:30
Tanmay Deep Sharma 7ef91bee53 fix(automations): anchor delayed due_at to the triggering event time 2026-07-20 11:46:06 +05:30
Tanmay Deep Sharma 0d2c0a5656 Merge branch 'develop' into feature/cw-7513 2026-07-20 11:44:18 +05:30
Tanmay Deep Sharma 89df93e1dd Merge feature/cw-7513 into feature/cw-7513-specs 2026-07-20 11:42:48 +05:30
Tanmay Deep Sharma 0e54bd17e5 fix(automations): reset delayed condition to a supported attribute per event 2026-07-20 11:42:40 +05:30
Tanmay Deep Sharma ee3600be46 Merge branch 'feature/cw-7513' into feature/cw-7513-specs 2026-07-20 11:28:50 +05:30
Tanmay Deep Sharma 6830c3273f feat(automations): show what ends the wait in the builder
Make the wait's interrupt explicit (as Intercom does) so users can predict when the
rule runs. An "Ends the wait if …" line adapts to the rule's episode: status change
for conversation rules, customer reply for outgoing message rules, agent reply for
incoming ones — plus "or the conditions no longer match" universally. Behaviour is
unchanged; this only surfaces it.
2026-07-20 11:28:41 +05:30
Tanmay Deep Sharma 9530cca197 Merge branch 'feature/cw-7513' into feature/cw-7513-specs 2026-07-20 11:22:22 +05:30
Tanmay Deep Sharma 2c728341e0 refactor(automations): rename "Delayed execution" to "Wait" in the builder
Adopt the simpler Wait concept (as Intercom et al. use): the toggle is labelled
"Wait", the duration reads "Wait for [4] [Hours]", and the block moves to just
before Actions (it applies to the whole rule; if we add ordered steps later it can
become a proper Wait action). The "Runs after 4h" list badge is unchanged, and the
engineering layer (execution_delay column, delayed_automations flag) keeps its name.
2026-07-20 11:22:12 +05:30
Sojan Jose a752e56765 Merge branch 'release/4.16.0' into develop 2026-07-18 03:59:29 -07:00
Tanmay Deep Sharma 1f713d6dae Merge branch 'feature/cw-7513' into feature/cw-7513-specs 2026-07-15 23:26:53 +05:30
Tanmay Deep Sharma 2b8d1a67b3 fix(automations): preserve actions when a delay resets invalid conditions
Enabling a delay on a rule with delay-unsafe conditions called onEventChange, which
resets conditions AND actions to defaults, silently replacing the user's configured
actions with assign_agent. Reset only the conditions (getDefaultConditions) and leave
the actions intact.
2026-07-15 23:26:44 +05:30
Tanmay Deep Sharma f62b5e51d9 test(automations): claim due_at guard, paused-row resume, factory episode key
- process-job specs make rows due before running (the sweep only enqueues due rows).
- claim! guard: a row pushed into the future isn't claimed.
- reschedule_paused resets overdue rows; re-enabling the account flag enqueues the
  resume job.
- factory derives a current episode key so rows are episode_current.
2026-07-15 23:14:32 +05:30
Tanmay Deep Sharma 9488b7d005 Merge branch 'feature/cw-7513' into feature/cw-7513-specs 2026-07-15 23:11:09 +05:30
Tanmay Deep Sharma f4e0481b00 fix(automations): dont fire rescheduled rows early; resume paused rows past expiry
- claimable? now requires the row to still be due, so a reply-chase reschedule that
  pushes due_at forward after the sweep enqueued the row no longer fires early.
- Re-enabling delayed_automations on an account reschedules its overdue pending rows
  (past DUE_WINDOW) via a job enqueued ahead of the next sweep, so a pause longer than
  the expiry window resumes those rows instead of expiring them.
2026-07-15 23:10:55 +05:30
Tanmay Deep Sharma b355443642 test(automations): delayed conversation_created rule is valid 2026-07-15 20:15:02 +05:30
Tanmay Deep Sharma 12a53d5a1e Merge branch 'feature/cw-7513' into feature/cw-7513-specs 2026-07-15 20:14:34 +05:30
Tanmay Deep Sharma 67dbcca638 fix(automations): keep all events available for delayed rules
Event-narrowing wrongly hid valid delayed triggers like conversation_created
(new conversation idle 10m -> assign), forcing the event to conversation_updated.
The real guardrail is the Status/Inbox condition narrowing, which stays; the event
list no longer needs restricting since every event supports a delayed rule with
the right conditions.
2026-07-15 20:14:26 +05:30
Tanmay Deep Sharma 8097e329d0 test(automations): delayed conversation rule allows status+inbox, rejects mutable attrs 2026-07-15 18:56:59 +05:30
Tanmay Deep Sharma 8df903b008 Merge branch 'feature/cw-7513' into feature/cw-7513-specs 2026-07-15 18:56:26 +05:30
Tanmay Deep Sharma 1957cd15dc feat(automations): allow inbox filter on delayed conversation rules
inbox_id never changes after a conversation is created, so filtering a delayed
conversation-level rule by inbox is safe: the status episode key still tracks the
only mutable dimension, and inbox rides along as a static fire-time re-check. Allow
status + inbox_id in the validation and the narrowed condition dropdown.
2026-07-15 18:56:15 +05:30
Tanmay Deep Sharma abaf4d9ccb Merge branch 'feature/cw-7513' into feature/cw-7513-specs 2026-07-15 18:42:13 +05:30
Tanmay Deep Sharma 88d3876c2b chore(automations): remove unused IMMEDIATELY i18n string 2026-07-15 18:42:10 +05:30
Tanmay Deep Sharma 55e7cafbac Merge branch 'feature/cw-7513' into feature/cw-7513-specs 2026-07-15 18:41:30 +05:30
Tanmay Deep Sharma 29eecfafcb chore(automations): label the delay toggle 'Delayed execution' 2026-07-15 18:41:21 +05:30
Tanmay Deep Sharma 1fafa7d13d Merge branch 'feature/cw-7513' into feature/cw-7513-specs 2026-07-15 18:36:09 +05:30
Tanmay Deep Sharma dd2f3d80cc feat(automations): narrow condition options for delayed rules instead of warning
When a delay is on, the condition attribute dropdown for conversation-level events
offers only Status, and attribute_changed is dropped from the operator list for any
delayed rule. Toggling the delay on resets conditions the delayed rule can't use.
This makes unsupported delayed rules unconstructable, so the inline restriction
warning (and its i18n strings) are removed.
2026-07-15 18:35:55 +05:30
Tanmay Deep Sharma 852a219e0b feat(automations): narrow condition options for delayed rules instead of warning
When a delay is on, the condition attribute dropdown for conversation-level events
offers only Status, and attribute_changed is dropped from the operator list for any
delayed rule. Toggling the delay on resets conditions the delayed rule can't use.
This makes unsupported delayed rules unconstructable, so the inline restriction
warning (and its i18n strings) are removed.
2026-07-15 18:35:11 +05:30
Tanmay Deep Sharma be381b940d Merge branch 'feature/cw-7513' into feature/cw-7513-specs 2026-07-15 18:12:30 +05:30
Tanmay Deep Sharma 3728d71176 feat(automations): make delayed execution a toggle that narrows the event list
Delayed execution is now a toggle chosen first (above Event). Turning it on limits
the Event dropdown to the events where a delayed rule is meaningful (Conversation
Updated for status waits, Message Created for awaiting-agent/reply-chase) and snaps
an unsupported event to a supported one, so users can't build a delayed rule on an
event the engine can't handle.
2026-07-15 18:12:21 +05:30
Tanmay Deep Sharma 9660b48c27 test(automations): sweep excludes rows for accounts with delayed automations disabled 2026-07-15 16:45:12 +05:30
Tanmay Deep Sharma a44645a7de Merge branch 'feature/cw-7513' into feature/cw-7513-specs 2026-07-15 16:44:10 +05:30
Tanmay Deep Sharma 1e57cff70a fix(automations): exclude paused accounts from the sweep so they can't starve others
Now that a flag-off account's due rows stay pending (paused), order(:due_at).limit
would keep re-selecting that backlog every sweep, starving enabled accounts with
later due_at. Filter the sweep to accounts with delayed_automations enabled via a
for_enabled_accounts scope, so paused rows sit out of the limit until re-enabled.
2026-07-15 16:44:01 +05:30
Tanmay Deep Sharma 4c398b3c85 test(automations): account flag off pauses the row and resumes on re-enable 2026-07-15 16:19:31 +05:30
Tanmay Deep Sharma a521175422 Merge branch 'feature/cw-7513' into feature/cw-7513-specs 2026-07-15 16:19:03 +05:30
Tanmay Deep Sharma a921a38db9 fix(automations): pause (not skip) armed rows when the account flag is off
Marking a due row skipped/flag_disabled was terminal, so re-enabling the account
flag could never resume it (sweepable only sees pending/processing) — the delayed
action was lost, contradicting the banner that says rules resume when re-enabled.
Check the flag before claiming and return, leaving the row pending like the
instance kill switch does, so it fires once the feature is turned back on.
2026-07-15 16:18:55 +05:30
Tanmay Deep Sharma 7c5d6f8d72 Merge branch 'feature/cw-7513' into feature/cw-7513-specs 2026-07-15 15:59:23 +05:30
Tanmay Deep Sharma 7bb90d84cd fix(automations): hydrate delay controls from the edited rule on first open
The edit dialog's delay radios/value were synced once in open() from the automation
model, whose prop only settles a tick later — so the first edit click showed the
previous rule's delay (or immediate). Thread the clicked rule's execution_delay
directly into open() so the delay controls hydrate correctly on the first click.
2026-07-15 15:59:11 +05:30
Tanmay Deep Sharma 466763054c Merge branch 'feature/cw-7513' into feature/cw-7513-specs 2026-07-15 15:31:04 +05:30
Tanmay Deep Sharma 21ea2ae08d revert(search): drop unnecessary reindex_for_search guard; trim automation_rule comments
The guard was dead code: should_index? is only true when searchkick is loaded
(both gated on advanced_search_allowed?), so #reindex is always defined when the
callback fires. It only masked a flaky develop-owned message_spec and contradicts
the fail-loudly-on-impossible-state guideline. Also trim verbose comments in
automation_rule.rb.
2026-07-15 15:30:46 +05:30
Tanmay Deep Sharma fd91ab84af test(automations): status episode keys use microsecond stamp and survive DB reload 2026-07-15 14:59:23 +05:30
Tanmay Deep Sharma e6ae59b8b2 Merge branch 'feature/cw-7513' into feature/cw-7513-specs 2026-07-15 14:58:49 +05:30
Tanmay Deep Sharma 35f14c7e02 fix(automations): use microsecond-integer stamp for status episode keys too
Same float64 imprecision as the awaiting-agent key: a status episode armed from an
in-memory status_changed_at (Time.current, nanosecond) could recompute to a slightly
different float once the worker reloads the DB-rounded value, skipping the row as
episode_moved. Use the shared microsecond_stamp helper so both sides agree.
2026-07-15 14:58:39 +05:30
Tanmay Deep Sharma e2c449a0c2 Merge branch 'feature/cw-7513' into feature/cw-7513-specs 2026-07-15 14:50:52 +05:30
Tanmay Deep Sharma 40b77ca001 fix(search): guard reindex_for_search when searchkick is not loaded
searchkick (which defines #reindex) is only mixed into Message at class-load time
when advanced_search_allowed? (enterprise + OPENSEARCH_URL) is true at boot. A spec
that stubs advanced_search_allowed? true without a loaded index makes should_index?
true and fires the reindex callback, raising NoMethodError. Guard on respond_to?
so it no-ops when no index is available (real deployments without search index
already have should_index? false, so behavior is unchanged).
2026-07-15 14:50:39 +05:30
Tanmay Deep Sharma 40e529fe4d test(automations): fix awaiting-agent key format and tighten job assertions
- Match the microsecond-integer (strftime %s%6N) awaiting-agent key format,
  fixing the CI failure from float epoch imprecision.
- Assert exactly one ProcessPendingExecutionJob is enqueued and the future row
  is not, so an early-fire regression is caught.
- Count the reply-chase follow-up as exactly one so a double-send regression fails.
- Stub the ActionService instance's perform (not new) to raise, exercising the
  job's error/retry path rather than only constructor failures.
2026-07-15 14:42:59 +05:30
Tanmay Deep Sharma d7b25d7db0 Merge branch 'feature/cw-7513' into feature/cw-7513-specs 2026-07-15 14:40:12 +05:30
Tanmay Deep Sharma e2cd372d2d fix(automations): use microsecond-integer stamp for awaiting-agent keys
Float epoch seconds carry ~16 significant digits, exceeding float64 precision,
and the arm path compares an in-memory created_at against the DB-stored
waiting_since. A float rounds differently on each side, so the armed key never
matched at fire time and the awaiting-agent automation was always skipped.
Use strftime('%s%6N') (integer microseconds) on both paths.
2026-07-15 14:40:05 +05:30
Tanmay Deep Sharma 17da6ee3ec test(automations): clone strips execution_delay when feature is disabled 2026-07-15 14:16:54 +05:30
Tanmay Deep Sharma b81f916002 Merge branch 'feature/cw-7513' into feature/cw-7513-specs 2026-07-15 14:16:20 +05:30
Tanmay Deep Sharma a1fdba2783 fix(automations): strip execution_delay when cloning a rule with the feature off
clone duplicates the rule via dup (including execution_delay) with no param for
ensure_execution_delay_allowed to inspect, so it could create new delayed rules
while the feature is disabled. Drop the delay on the clone unless the account has
delayed_automations enabled, matching create/update.
2026-07-15 14:16:10 +05:30
Tanmay Deep Sharma dea4d47602 test(automations): sub-second awaiting-agent keys and stale processing discard 2026-07-15 13:27:43 +05:30
Tanmay Deep Sharma 2728a90a2f Merge branch 'feature/cw-7513' into feature/cw-7513-specs 2026-07-15 13:26:50 +05:30
Tanmay Deep Sharma d55c5660a3 fix(automations): sub-second awaiting-agent keys and discard stale processing rows on edit
- awaiting_agent episode keys use sub-second (.to_f) precision like status keys,
  so a reply then re-wait within the same second is a distinct episode and the
  original armed row no longer matches the later waiting period.
- discard_stale_pending_executions now deletes armed rows (pending and stale
  processing), since the sweep reclaims stale processing rows and would otherwise
  fire them against the edited rule definition.
2026-07-15 13:26:37 +05:30
Tanmay Deep Sharma 92bcd2f214 test(automations): cover event restriction, stale-edit discard, and awaiting-agent arm race 2026-07-15 13:08:29 +05:30
Tanmay Deep Sharma cc23867774 Merge branch 'feature/cw-7513' into feature/cw-7513-specs 2026-07-15 13:07:44 +05:30
Tanmay Deep Sharma 7a8903051d fix(automations): make awaiting-agent race fix asymmetric to preserve reply cancellation
The earlier waiting_since fallback in episode_key_for also matched at fire time,
so an agent reply (which clears waiting_since) no longer ended the episode and the
rule could fire anyway. Apply the created_at fallback only at arm time
(arm_episode_key_for); keep episode_key_for strict so a nil waiting_since at fire
time still means the agent replied and the episode is over.
2026-07-15 13:07:37 +05:30
Tanmay Deep Sharma d1d464dfb0 Merge branch 'feature/cw-7513' into feature/cw-7513-specs 2026-07-15 13:00:22 +05:30
Tanmay Deep Sharma 1e6291616d fix(automations): harden delayed rules against races, stale edits, and unsupported configs
- episode_key_for falls back to the incoming message's created_at when
  waiting_since is still nil (it is written after MESSAGE_CREATED dispatches),
  so awaiting-agent episodes no longer arm as awaiting_agent:0 and get skipped
  as episode_moved on the first customer message after an agent reply.
- Editing a rule's trigger, conditions, or actions (not just the delay) now
  discards its armed pending rows so they can't fire against a definition they
  were never armed under; deleting rather than skipping frees the episode slot
  so the new definition re-arms on the next matching event.
- The rule form only offers a delay when the config supports it: the delayed
  option is disabled with an explanation for attribute_changed conditions and
  for non-status conditions on conversation events, and the backend's specific
  error is surfaced on save instead of a generic message.
2026-07-15 13:00:03 +05:30
Tanmay Deep Sharma 2e2fcdec47 test(automations): update feature flag bit-mapping spec for delayed_automations 2026-07-15 12:43:43 +05:30
Tanmay Deep Sharma 3436632a3b Merge branch 'feature/cw-7513' into feature/cw-7513-specs 2026-07-15 12:42:54 +05:30
Tanmay Deep Sharma 7a73a68753 fix(automations): cancel stale pending runs on delay edit; restrict conversation-level delayed rules to status conditions
- Editing a rule's execution_delay (removing or changing it) now cancels any
  pending executions armed under the old configuration instead of leaving
  them to fire on a stale schedule.
- conversation_created/updated/opened/resolved delayed rules key their episode
  on status_changed_at alone, so a delayed condition on any other attribute
  (assignee, team, priority, ...) could collapse distinct qualifying periods
  into one episode. Restricted to status conditions until episodes track
  per-attribute change times.
2026-07-15 12:36:58 +05:30
Tanmay Deep Sharma 55d68d930d Merge remote-tracking branch 'origin/develop' into feature/cw-7513
# Conflicts:
#	app/javascript/dashboard/featureFlags.js
#	config/features.yml
2026-07-15 12:31:43 +05:30
Tanmay Deep Sharma e328492463 refactor(automations): fold expiry check into skip_reason_for guard chain 2026-07-15 12:30:46 +05:30
Tanmay Deep Sharma ac9b055ada test(automations): specs for delayed automation rules 2026-07-15 10:57:41 +05:30
Tanmay Deep Sharma 82f28aa7fb chore(automations): move specs to a follow-up branch 2026-07-15 10:57:23 +05:30
Tanmay Deep Sharma 5c0cbcbaaa chore(automations): remove design docs from branch 2026-07-15 10:56:07 +05:30
Tanmay Deep Sharma 5d5fa0c21a refactor(automations): job-side claim for delayed executions, remove rubocop disables 2026-07-15 10:50:54 +05:30
Tanmay Deep Sharma e12e686244 Merge branch 'develop' into feature/cw-7513 2026-07-13 14:36:26 +05:30
Tanmay Deep Sharma cdccbb39aa Merge branch 'develop' into feature/cw-7513 2026-07-13 14:35:03 +05:30
Tanmay Deep Sharma 30ee4a61fc feat(automations): add delayed execution for automation rules 2026-07-10 10:00:07 +05:30
139 changed files with 4772 additions and 678 deletions
@@ -1,5 +1,4 @@
class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
before_action :current_account
before_action :check_authorization
before_action :agent_bot, except: [:index, :create]
@@ -3,6 +3,7 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
before_action :check_authorization
before_action :fetch_automation_rule, only: [:show, :update, :destroy, :clone]
before_action :ensure_execution_delay_allowed, only: [:create, :update]
def index
@automation_rules = Current.account.automation_rules
@@ -48,6 +49,9 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
def clone
automation_rule = Current.account.automation_rules.find_by(id: params[:automation_rule_id])
new_rule = automation_rule.dup
# dup copies execution_delay; drop it when the feature is off so clone can't create new
# delayed rules that create/update would reject.
new_rule.execution_delay = nil unless delayed_automations_enabled?
new_rule.save!
@automation_rule = new_rule
end
@@ -55,13 +59,27 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
private
def automation_rules_permit
permitted_attributes = [:name, :description, :event_name, :active]
permitted_attributes << :execution_delay if delayed_automations_enabled?
params.permit(
:name, :description, :event_name, :active,
*permitted_attributes,
conditions: [:attribute_key, :filter_operator, :query_operator, :custom_attribute_type, { values: [] }],
actions: [:action_name, { action_params: [] }]
)
end
def ensure_execution_delay_allowed
return if delayed_automations_enabled?
return if params[:execution_delay].blank?
render json: { error: 'Delayed automations are not enabled for this account.' }, status: :unprocessable_entity
end
def delayed_automations_enabled?
Current.account.feature_enabled?('delayed_automations')
end
def fetch_automation_rule
@automation_rule = Current.account.automation_rules.find_by(id: params[:id])
end
@@ -1,5 +1,4 @@
class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::BaseController
before_action :current_account
before_action :authorize_account_update, only: [:update]
def show
@@ -1,5 +1,4 @@
class Api::V1::Accounts::LabelsController < Api::V1::Accounts::BaseController
before_action :current_account
before_action :fetch_label, except: [:index, :create]
before_action :check_authorization
@@ -1,4 +1,5 @@
class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts::BaseController
before_action :ensure_embedded_signup_enabled
# Reconfiguring/reauthorizing a live inbox swaps its credentials, so restrict it to admins.
before_action :check_admin_authorization?, if: -> { params[:inbox_id].present? }
before_action :fetch_and_validate_inbox, if: -> { params[:inbox_id].present? }
@@ -18,6 +19,13 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
private
def ensure_embedded_signup_enabled
return unless ChatwootApp.chatwoot_cloud?
return if Current.account.feature_enabled?('whatsapp_embedded_signup_inbox_creation')
raise Pundit::NotAuthorizedError
end
def process_embedded_signup
service = Whatsapp::EmbeddedSignupService.new(
account: Current.account,
@@ -44,8 +52,7 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
def can_reconfigure_channel?
channel = @inbox.channel
return false unless channel.provider == 'whatsapp_cloud'
# Reconfiguring a live embedded-signup channel requires the feature flag.
return true if ChatwootApp.chatwoot_cloud?
return Current.account.feature_enabled?('whatsapp_reconfigure') if channel.provider_config['source'] == 'embedded_signup'
true
@@ -47,18 +47,10 @@ class Webhooks::WhatsappController < ActionController::API
metadata = params.dig(:entry, 0, :changes, 0, :value, :metadata)
return if metadata.blank?
phone_number = normalized_phone_number(metadata[:display_phone_number])
phone_number_id = metadata[:phone_number_id]
channel = Channel::Whatsapp.find_by(phone_number: phone_number)
return channel if channel && channel.provider_config['phone_number_id'] == phone_number_id
end
def normalized_phone_number(phone_number)
return if phone_number.blank?
phone_number = phone_number.to_s
phone_number.start_with?('+') ? phone_number : "+#{phone_number}"
Whatsapp::WebhookChannelFinderService.new(
display_phone_number: metadata[:display_phone_number],
phone_number_id: metadata[:phone_number_id]
).perform
end
def inactive_whatsapp_number?
+14
View File
@@ -0,0 +1,14 @@
/* global axios */
import ApiClient from './ApiClient';
class CallsAPI extends ApiClient {
constructor() {
super('calls', { accountScoped: true });
}
get(params = {}) {
return axios.get(this.url, { params });
}
}
export default new CallsAPI();
@@ -0,0 +1,9 @@
import ApiClient from '../ApiClient';
class CaptainAgentSessions extends ApiClient {
constructor() {
super('captain/agent_sessions', { accountScoped: true });
}
}
export default new CaptainAgentSessions();
@@ -26,15 +26,18 @@ class CaptainAssistant extends ApiClient {
});
}
getStats({ assistantId, range }) {
return axios.get(`${this.url}/${assistantId}/stats`, {
getStats({ assistantId, range, signal }) {
const requestConfig = {
params: { range, timezone_offset: getTimezoneOffset() },
});
};
if (signal) requestConfig.signal = signal;
return axios.get(`${this.url}/${assistantId}/stats`, requestConfig);
}
getSummary({ assistantId, range }) {
getSummary({ assistantId, range, stats }) {
return axios.get(`${this.url}/${assistantId}/summary`, {
params: { range, timezone_offset: getTimezoneOffset() },
params: { range, timezone_offset: getTimezoneOffset(), stats },
});
}
@@ -10,10 +10,13 @@ class WhatsappCallsAPI extends ApiClient {
return axios.get(`${this.url}/${callId}`).then(r => r.data);
}
initiate(conversationId, sdpOffer) {
// Either conversationId, or contactId + inboxId to let the BE resolve the conversation.
initiate({ conversationId, contactId, inboxId }, sdpOffer) {
return axios
.post(`${this.url}/initiate`, {
conversation_id: conversationId,
contact_id: contactId,
inbox_id: inboxId,
sdp_offer: sdpOffer,
})
.then(r => r.data);
@@ -0,0 +1,240 @@
<script setup>
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { relativeDayTimestamp } from 'shared/helpers/timeHelper';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import AudioPlayer from 'dashboard/components-next/audio/AudioPlayer.vue';
import {
VOICE_CALL_DIRECTION,
VOICE_CALL_STATUS,
} from 'dashboard/components-next/message/constants';
import CallStatusBadge from './CallStatusBadge.vue';
import { CALL_KIND, getCallKind } from './constants';
const props = defineProps({
call: {
type: Object,
required: true,
},
});
const { t } = useI18n();
const route = useRoute();
const kind = computed(() => getCallKind(props.call));
const contactName = computed(() =>
(props.call.contact.name || props.call.contact.phoneNumber || '').replace(
/^\+/,
''
)
);
const agentActionLabel = computed(() => {
if (!props.call.agent) return '';
if (kind.value === CALL_KIND.OUTGOING) return t('CALLS_PAGE.ROW.DIALED_BY');
if (kind.value === CALL_KIND.INCOMING) return t('CALLS_PAGE.ROW.PICKED_BY');
// Ongoing collapses direction, so resolve dialed-vs-picked from the raw value.
if (kind.value === CALL_KIND.ONGOING) {
return props.call.direction === VOICE_CALL_DIRECTION.OUTBOUND
? t('CALLS_PAGE.ROW.DIALED_BY')
: t('CALLS_PAGE.ROW.PICKED_BY');
}
return '';
});
const resultLabel = computed(() => {
if (kind.value === CALL_KIND.MISSED) return t('CALLS_PAGE.ROW.NO_AGENT');
if (kind.value === CALL_KIND.NO_REPLY) {
return t('CALLS_PAGE.ROW.NO_CONTACT_ANSWER');
}
if (kind.value === CALL_KIND.FAILED) return t('CALLS_PAGE.ROW.FAILED');
if (kind.value === CALL_KIND.ONGOING) {
return props.call.status === VOICE_CALL_STATUS.RINGING
? t('CALLS_PAGE.ROW.RINGING')
: t('CALLS_PAGE.ROW.IN_PROGRESS');
}
return t('CALLS_PAGE.ROW.ANSWERED');
});
const providerIcon = computed(() =>
props.call.provider === 'whatsapp' ? 'i-woot-whatsapp' : 'i-lucide-phone'
);
const createdAtLabel = computed(() =>
relativeDayTimestamp(props.call.createdAt, t('CALLS_PAGE.ROW.YESTERDAY'))
);
const conversationRoute = computed(() => ({
name: 'inbox_conversation',
params: {
accountId: route.params.accountId,
conversation_id: props.call.conversation.displayId,
},
query: { messageId: props.call.messageId },
}));
</script>
<template>
<div class="flex flex-col gap-2 py-3.5 border-b border-n-weak lg:hidden">
<div class="flex items-center gap-2 min-w-0">
<Avatar
:src="call.contact.avatar"
:name="contactName"
:size="24"
rounded-full
/>
<span
v-tooltip.top="{ content: contactName, delay: { show: 500, hide: 0 } }"
class="text-heading-3 font-medium truncate text-n-slate-12 min-w-0"
>
{{ contactName }}
</span>
<CallStatusBadge :kind="kind" class="ms-auto shrink-0" />
<RouterLink
:to="conversationRoute"
class="inline-flex items-center h-6 gap-1 px-2 text-label-small outline outline-1 -outline-offset-1 rounded-md outline-n-weak text-n-slate-11 hover:bg-n-alpha-1 shrink-0"
>
<Icon icon="i-lucide-message-circle" class="size-3.5 text-n-slate-11" />
{{ call.conversation.displayId }}
<Icon icon="i-lucide-arrow-up-right" class="size-3.5 text-n-slate-11" />
</RouterLink>
</div>
<div class="flex items-center gap-1.5 min-w-0">
<template v-if="agentActionLabel">
<span class="text-label-small text-n-slate-10 shrink-0">
{{ agentActionLabel }}
</span>
<Avatar
:src="call.agent.avatar"
:name="call.agent.name"
:size="20"
rounded-full
/>
<span class="text-body-main truncate text-n-slate-12 min-w-0">
{{ call.agent.name }}
</span>
</template>
<span v-else class="text-body-main truncate text-n-slate-10 min-w-0">
{{ resultLabel }}
</span>
<span class="w-px h-3 bg-n-strong shrink-0" />
<Icon :icon="providerIcon" class="size-4 text-n-slate-11 shrink-0" />
<span class="text-body-main truncate text-n-slate-11 min-w-0">
{{ call.inbox.name }}
</span>
<span
v-if="!call.recordingUrl"
class="ms-auto shrink-0 text-label-small text-n-slate-11 tabular-nums"
>
{{ createdAtLabel }}
</span>
</div>
<div
v-if="call.recordingUrl"
class="flex items-center gap-2 min-w-0 justify-between"
>
<AudioPlayer
:src="call.recordingUrl"
:fallback-duration="call.durationSeconds || 0"
class="flex-1 sm:flex-[0.7] min-w-0"
/>
<span class="shrink-0 text-label-small text-n-slate-11 tabular-nums">
{{ createdAtLabel }}
</span>
</div>
</div>
<div
class="hidden items-center gap-x-1.5 gap-y-2.5 border-b border-n-weak lg:flex lg:items-center lg:gap-1.5"
>
<div class="flex items-center gap-2.5 min-w-0 w-40 shrink-0 py-3.5">
<Avatar
:src="call.contact.avatar"
:name="contactName"
:size="24"
rounded-full
/>
<span
v-tooltip.top="{ content: contactName, delay: { show: 500, hide: 0 } }"
class="text-heading-3 font-medium truncate text-n-slate-12"
>
{{ contactName }}
</span>
</div>
<div
class="flex flex-nowrap items-center gap-x-2 gap-y-2 min-w-0 grow shrink"
>
<div class="flex items-center gap-x-2 min-w-0 lg:contents py-3.5">
<CallStatusBadge :kind="kind" class="shrink-0" />
<template v-if="agentActionLabel">
<span
class="text-label-small text-n-slate-10 truncate min-w-0 shrink min-w-8"
>
{{ agentActionLabel }}
</span>
<span class="flex items-center gap-1.5 min-w-16 shrink-[20]">
<Avatar
:src="call.agent.avatar"
:name="call.agent.name"
:size="20"
rounded-full
/>
<span
v-tooltip.top="{
content: call.agent.name,
delay: { show: 500, hide: 0 },
}"
class="text-body-main truncate text-n-slate-12 min-w-0"
>
{{ call.agent.name }}
</span>
</span>
</template>
<span
v-else-if="resultLabel"
class="text-body-main truncate text-n-slate-10 min-w-0 shrink-[20]"
>
{{ resultLabel }}
</span>
</div>
<AudioPlayer
v-if="call.recordingUrl"
:src="call.recordingUrl"
:fallback-duration="call.durationSeconds || 0"
class="w-auto min-w-44 shrink mx-auto"
/>
</div>
<div
v-tooltip.top="{
content: call.inbox.name,
delay: { show: 500, hide: 0 },
}"
class="flex items-center gap-1.5 justify-start min-w-14 shrink-[100] py-3.5"
>
<Icon :icon="providerIcon" class="size-4 text-n-slate-11 shrink-0" />
<span class="text-body-main truncate text-n-slate-11">
{{ call.inbox.name }}
</span>
</div>
<RouterLink
:to="conversationRoute"
class="inline-flex items-center h-6 gap-1 px-2 text-label-small py-3.5 outline outline-1 -outline-offset-1 rounded-md outline-n-weak text-n-slate-11 hover:bg-n-alpha-1 shrink-0 justify-self-start"
>
<Icon icon="i-lucide-message-circle" class="size-3.5 text-n-slate-11" />
{{ call.conversation.displayId }}
<Icon icon="i-lucide-arrow-up-right" class="size-3.5 text-n-slate-11" />
</RouterLink>
<span
v-tooltip.top="{
content: createdAtLabel,
delay: { show: 500, hide: 0 },
}"
class="text-label-small text-end text-n-slate-11 truncate py-3.5 tabular-nums justify-self-end w-16 shrink-0"
>
{{ createdAtLabel }}
</span>
</div>
</template>
@@ -0,0 +1,158 @@
<script setup>
import { computed, getCurrentInstance, ref, useTemplateRef } from 'vue';
import { downloadFile } from '@chatwoot/utils';
import { useEmitter } from 'dashboard/composables/emitter';
import { emitter } from 'shared/helpers/mitt';
import Button from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
src: {
type: String,
required: true,
},
fallbackDuration: {
type: Number,
default: 0,
},
});
const PLAYBACK_SPEEDS = [1, 1.5, 2];
const audioPlayer = useTemplateRef('audioPlayer');
const { uid } = getCurrentInstance();
const isPlaying = ref(false);
const currentTime = ref(0);
const duration = ref(props.fallbackDuration);
const playbackSpeed = ref(1);
const onLoadedMetadata = () => {
const loadedDuration = audioPlayer.value?.duration;
if (Number.isFinite(loadedDuration)) duration.value = loadedDuration;
};
const formatTime = time => {
if (!time || Number.isNaN(time)) return '00:00';
const minutes = Math.floor(time / 60);
const seconds = Math.floor(time % 60);
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
};
const playbackSpeedLabel = computed(() => `${playbackSpeed.value}x`);
const displayedTime = computed(() =>
formatTime(
isPlaying.value || currentTime.value ? currentTime.value : duration.value
)
);
// Only one recording should play at a time across the list.
useEmitter('pause_playing_audio', currentPlayingId => {
if (currentPlayingId !== uid && isPlaying.value) {
audioPlayer.value?.pause();
isPlaying.value = false;
}
});
const playOrPause = () => {
if (isPlaying.value) {
audioPlayer.value.pause();
isPlaying.value = false;
} else {
emitter.emit('pause_playing_audio', uid);
audioPlayer.value.play();
isPlaying.value = true;
}
};
const onTimeUpdate = () => {
currentTime.value = audioPlayer.value?.currentTime;
};
const seek = event => {
const time = Number(event.target.value);
audioPlayer.value.currentTime = time;
currentTime.value = time;
};
const onEnd = () => {
isPlaying.value = false;
currentTime.value = 0;
};
const changePlaybackSpeed = () => {
const currentIndex = PLAYBACK_SPEEDS.indexOf(playbackSpeed.value);
playbackSpeed.value =
PLAYBACK_SPEEDS[(currentIndex + 1) % PLAYBACK_SPEEDS.length];
audioPlayer.value.playbackRate = playbackSpeed.value;
};
const downloadRecording = () => {
downloadFile({ url: props.src, type: 'audio' });
};
</script>
<template>
<div
class="flex items-center justify-center h-9 gap-2 px-2 rounded-full bg-n-alpha-1 dark:bg-n-alpha-2 overflow-hidden"
@click.stop
>
<audio
ref="audioPlayer"
class="hidden"
playsinline
@loadedmetadata="onLoadedMetadata"
@timeupdate="onTimeUpdate"
@ended="onEnd"
>
<source :src="src" />
</audio>
<Button
variant="ghost"
color="slate"
size="xs"
class="!w-6 !p-0 text-n-slate-12"
@click="playOrPause"
>
<template #icon>
<Icon
:icon="isPlaying ? 'i-lucide-pause' : 'i-lucide-play'"
class="size-4 flex-shrink-0"
/>
</template>
</Button>
<input
type="range"
min="0"
:max="duration || 0"
:value="currentTime"
class="flex-1 min-w-0 lg:grow-0 lg:basis-24 h-1 rounded-lg appearance-none cursor-pointer bg-n-slate-12/30 accent-n-slate-11"
@input="seek"
/>
<span class="text-sm tabular-nums text-n-slate-11 shrink-0">
{{ displayedTime }}
</span>
<div class="w-px h-3.5 bg-n-slate-6 shrink-0" />
<Button
variant="ghost"
color="slate"
size="xs"
:label="playbackSpeedLabel"
class="!px-1 min-w-6 !text-n-slate-11"
@click="changePlaybackSpeed"
/>
<div class="w-px h-3.5 bg-n-slate-6 shrink-0" />
<Button
variant="ghost"
color="slate"
size="xs"
class="!w-6 !p-0 text-n-slate-11"
@click="downloadRecording"
>
<template #icon>
<Icon icon="i-lucide-download" class="size-4 flex-shrink-0" />
</template>
</Button>
</div>
</template>
@@ -0,0 +1,56 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import { CALL_KIND } from './constants';
const props = defineProps({
kind: {
type: String,
required: true,
},
});
const { t } = useI18n();
const KIND_CONFIG = {
[CALL_KIND.ONGOING]: {
icon: 'i-lucide-phone-call',
class: 'bg-n-teal-3 text-n-teal-11',
},
[CALL_KIND.INCOMING]: {
icon: 'i-lucide-phone-incoming',
class: 'bg-n-slate-3 text-n-slate-11',
},
[CALL_KIND.OUTGOING]: {
icon: 'i-lucide-phone-outgoing',
class: 'bg-n-slate-3 text-n-slate-11',
},
[CALL_KIND.MISSED]: {
icon: 'i-lucide-phone-missed',
class: 'bg-n-ruby-3 text-n-ruby-11',
},
[CALL_KIND.NO_REPLY]: {
icon: 'i-lucide-phone-outgoing',
class: 'bg-n-amber-3 text-n-amber-11',
},
[CALL_KIND.FAILED]: {
icon: 'i-lucide-phone-off',
class: 'bg-n-ruby-3 text-n-ruby-11',
},
};
const config = computed(() => KIND_CONFIG[props.kind]);
</script>
<template>
<span
class="inline-flex items-center justify-center w-20 gap-1.5 h-6 px-1 rounded-md text-label-small shrink-0"
:class="config.class"
>
<Icon :icon="config.icon" class="size-3 flex-shrink-0" />
<span class="truncate">{{
t(`CALLS_PAGE.STATUS.${kind.toUpperCase()}`)
}}</span>
</span>
</template>
@@ -0,0 +1,34 @@
<script setup>
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const setupVoiceChannel = () => {
router.push({
name: 'settings_inbox_new',
params: { accountId: route.params.accountId },
});
};
</script>
<template>
<EmptyStateLayout
:title="t('CALLS_PAGE.SETUP.TITLE')"
:subtitle="t('CALLS_PAGE.SETUP.SUBTITLE')"
:show-backdrop="false"
:action-perms="['administrator']"
>
<template #actions>
<Button
:label="t('CALLS_PAGE.SETUP.ACTION')"
icon="i-lucide-plus"
@click="setupVoiceChannel"
/>
</template>
</EmptyStateLayout>
</template>
@@ -0,0 +1,250 @@
<script setup>
import { computed, ref } from 'vue';
import { OnClickOutside } from '@vueuse/components';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
// Null while a fetch is in flight so stale counts are never shown.
totalCount: {
type: Number,
default: null,
},
agents: {
type: Array,
default: () => [],
},
inboxes: {
type: Array,
default: () => [],
},
// Self-scoped viewers only ever see their own calls, so the assignee filter
// is meaningless for them — only admins get it.
showAssignee: {
type: Boolean,
default: false,
},
});
const activity = defineModel('activity', { type: String, default: null });
const assigneeId = defineModel('assigneeId', { type: Number, default: null });
const inboxId = defineModel('inboxId', { type: Number, default: null });
const { t } = useI18n();
const ACTIVITY_ICONS = {
missed: 'i-lucide-phone-missed',
no_reply: 'i-lucide-phone-outgoing',
incoming: 'i-lucide-phone-incoming',
outgoing: 'i-lucide-phone-outgoing',
in_progress: 'i-lucide-phone-call',
};
const BASE_ACTIVITIES = ['missed', 'no_reply'];
const OTHER_ACTIVITIES = ['incoming', 'outgoing', 'in_progress'];
// A single open-menu identifier keeps the three dropdowns mutually exclusive:
// opening one closes the others without any cross-wiring.
const openMenu = ref(null); // 'activity' | 'assignee' | 'more' | null
const toggleMenu = name => {
openMenu.value = openMenu.value === name ? null : name;
};
// Each dropdown wrapper closes itself on outside clicks. The guard keeps the
// other two wrappers (which the click is also outside of) from closing a
// menu the user is interacting with.
const closeOnOutside = name => {
if (openMenu.value === name) openMenu.value = null;
};
const activityLabel = value => t(`CALLS_PAGE.FILTERS.${value.toUpperCase()}`);
const activeChipLabel = computed(() => {
const label = activityLabel(activity.value);
return props.totalCount === null ? label : `${label} (${props.totalCount})`;
});
const inactiveChips = computed(() =>
BASE_ACTIVITIES.filter(value => value !== activity.value)
);
const otherActivityItems = computed(() =>
OTHER_ACTIVITIES.map(value => ({
label: activityLabel(value),
value,
action: 'filter',
icon: ACTIVITY_ICONS[value],
isSelected: activity.value === value,
}))
);
const assigneeItems = computed(() => [
{
label: t('CALLS_PAGE.FILTERS.ALL_ASSIGNEES'),
value: null,
action: 'filter',
isSelected: !assigneeId.value,
},
...props.agents.map(agent => ({
label: agent.name,
value: agent.id,
action: 'filter',
thumbnail: { name: agent.name, src: agent.thumbnail },
isSelected: assigneeId.value === agent.id,
})),
]);
const moreFiltersSections = computed(() => [
{
title: t('CALLS_PAGE.FILTERS.INBOX'),
items: [
{
label: t('CALLS_PAGE.FILTERS.ALL_INBOXES'),
value: null,
action: 'inbox',
isSelected: !inboxId.value,
},
...props.inboxes.map(inbox => ({
label: inbox.name,
value: inbox.id,
action: 'inbox',
isSelected: inboxId.value === inbox.id,
})),
],
},
]);
const selectedAssigneeLabel = computed(
() =>
props.agents.find(agent => agent.id === assigneeId.value)?.name ||
t('CALLS_PAGE.FILTERS.ASSIGNEE')
);
const hasMoreFilters = computed(() => Boolean(inboxId.value));
const setActivity = value => {
openMenu.value = null;
activity.value = value;
};
const setAssignee = ({ value }) => {
openMenu.value = null;
assigneeId.value = value;
};
const applyMoreFilter = ({ action, value }) => {
openMenu.value = null;
if (action === 'inbox') inboxId.value = value;
};
</script>
<template>
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex flex-wrap items-center gap-3">
<span v-if="!activity" class="text-heading-3 text-n-slate-11 shrink-0">
{{
totalCount === null
? t('CALLS_PAGE.ALL_CALLS')
: t('CALLS_PAGE.ALL_CALLS_COUNT', { count: totalCount })
}}
</span>
<Button
v-else
variant="outline"
color="blue"
size="sm"
:icon="ACTIVITY_ICONS[activity]"
class="shrink-0"
@click="setActivity(null)"
>
{{ activeChipLabel }}
<Icon icon="i-lucide-x" />
</Button>
<div class="w-px h-4 bg-n-strong shrink-0" />
<Button
v-for="chip in inactiveChips"
:key="chip"
variant="outline"
color="slate"
size="sm"
:icon="ACTIVITY_ICONS[chip]"
:label="activityLabel(chip)"
class="shrink-0 text-n-slate-12"
@click="setActivity(chip)"
/>
<OnClickOutside
class="relative shrink-0"
@trigger="closeOnOutside('activity')"
>
<Button
variant="outline"
color="slate"
size="sm"
icon="i-lucide-phone"
class="text-n-slate-12"
@click="toggleMenu('activity')"
>
{{ t('CALLS_PAGE.FILTERS.OTHER_ACTIVITY') }}
<Icon icon="i-lucide-chevron-down" class="text-n-slate-11" />
</Button>
<DropdownMenu
v-if="openMenu === 'activity'"
:menu-items="otherActivityItems"
class="mt-1 start-0 top-full w-44"
@action="setActivity($event.value)"
/>
</OnClickOutside>
</div>
<div class="flex items-center gap-2 shrink-0">
<OnClickOutside
v-if="showAssignee"
class="relative"
@trigger="closeOnOutside('assignee')"
>
<Button
variant="outline"
color="slate"
size="sm"
icon="i-lucide-user-round-cog"
class="max-w-52 text-n-slate-12"
@click="toggleMenu('assignee')"
>
<span class="truncate">{{ selectedAssigneeLabel }}</span>
<Icon icon="i-lucide-chevron-down" class="text-n-slate-11 shrink-0" />
</Button>
<DropdownMenu
v-if="openMenu === 'assignee'"
:menu-items="assigneeItems"
show-search
class="mt-1 end-0 top-full w-56 max-h-72"
@action="setAssignee"
/>
</OnClickOutside>
<OnClickOutside class="relative" @trigger="closeOnOutside('more')">
<Button
variant="outline"
size="sm"
icon="i-lucide-list-filter"
:color="hasMoreFilters ? 'blue' : 'slate'"
:class="hasMoreFilters ? '' : 'text-n-slate-12'"
@click="toggleMenu('more')"
>
{{ t('CALLS_PAGE.FILTERS.MORE_FILTERS') }}
<Icon
icon="i-lucide-chevron-down"
:class="hasMoreFilters ? '' : 'text-n-slate-11'"
/>
</Button>
<DropdownMenu
v-if="openMenu === 'more'"
:menu-sections="moreFiltersSections"
class="mt-1 end-0 top-full w-56 max-h-80"
@action="applyMoreFilter"
/>
</OnClickOutside>
</div>
</div>
</template>
@@ -0,0 +1,51 @@
import {
VOICE_CALL_STATUS,
VOICE_CALL_DIRECTION,
} from 'dashboard/components-next/message/constants';
export const CALL_KIND = {
ONGOING: 'ongoing',
INCOMING: 'incoming',
OUTGOING: 'outgoing',
MISSED: 'missed',
NO_REPLY: 'no_reply',
FAILED: 'failed',
};
// The API returns display values: status (ringing/in-progress/completed/
// no-answer/failed) and direction (inbound/outbound). The list UI presents
// them as a single "kind" per row.
export const getCallKind = call => {
if (
[VOICE_CALL_STATUS.RINGING, VOICE_CALL_STATUS.IN_PROGRESS].includes(
call.status
)
) {
return CALL_KIND.ONGOING;
}
if (
[VOICE_CALL_STATUS.FAILED, VOICE_CALL_STATUS.REJECTED].includes(call.status)
) {
return CALL_KIND.FAILED;
}
const isInbound = call.direction === VOICE_CALL_DIRECTION.INBOUND;
if (call.status === VOICE_CALL_STATUS.NO_ANSWER) {
return isInbound ? CALL_KIND.MISSED : CALL_KIND.NO_REPLY;
}
return isInbound ? CALL_KIND.INCOMING : CALL_KIND.OUTGOING;
};
// Filter chips map to the status/direction params supported by CallFinder.
export const CALL_ACTIVITY_PARAMS = {
missed: {
status: VOICE_CALL_STATUS.NO_ANSWER,
direction: VOICE_CALL_DIRECTION.INBOUND,
},
no_reply: {
status: VOICE_CALL_STATUS.NO_ANSWER,
direction: VOICE_CALL_DIRECTION.OUTBOUND,
},
incoming: { direction: VOICE_CALL_DIRECTION.INBOUND },
outgoing: { direction: VOICE_CALL_DIRECTION.OUTBOUND },
in_progress: { status: VOICE_CALL_STATUS.IN_PROGRESS },
};
@@ -16,7 +16,6 @@ import { useAlert } from 'dashboard/composables';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import { useCallsStore } from 'dashboard/stores/calls';
import { useWhatsappCallSession } from 'dashboard/composables/useWhatsappCallSession';
import ContactAPI from 'dashboard/api/contacts';
import Button from 'dashboard/components-next/button/Button.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
@@ -83,39 +82,18 @@ const navigateToConversation = conversationId => {
const whatsappCallSession = useWhatsappCallSession();
// Find the most recent open conversation for this contact in the picked inbox.
// WhatsApp /initiate is conversation-scoped (unlike Twilio's contact-scoped path).
// Pass inboxId so the BE applies the filter before the 20-row cap — without it,
// contacts whose latest WhatsApp conversation falls outside the 20 most recent
// across all inboxes would be treated as having no conversation.
const findWhatsappConversationId = async inboxId => {
const { data } = await ContactAPI.getConversations(props.contactId, {
inboxId,
});
const conversations = data?.payload || [];
const match = [...conversations].sort(
(a, b) => (b.last_activity_at || 0) - (a.last_activity_at || 0)
)[0];
return match?.id || null;
};
const startWhatsappCall = async (inboxId, conversationIdHint) => {
// WhatsApp /initiate is conversation-scoped, so we must hand it a
// conversation. Use the caller's hint when given (in-conversation flow);
// otherwise pick the most recent one in the inbox.
const conversationId =
conversationIdHint || (await findWhatsappConversationId(inboxId));
if (!conversationId) {
useAlert(t('CONTACT_PANEL.CALL_FAILED'));
return;
}
const response =
await whatsappCallSession.initiateOutboundCall(conversationId);
const response = await whatsappCallSession.initiateOutboundCall(
conversationIdHint
? { conversationId: conversationIdHint }
: { contactId: props.contactId, inboxId }
);
// The composable returns { status: 'locked' } when an init is already in
// flight or a call is already active; treat that as a soft no-op rather than
// claiming success.
if (response?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.LOCKED) return;
const conversationId = response?.conversation_id || conversationIdHint;
if (!response?.id) {
// Permission template path returns no call id. Mirror the header button and
// surface whether the request was just sent or is already pending instead of
@@ -234,6 +234,7 @@ onMounted(() => resetContacts());
ref="popoverRef"
:align="align"
:show-content-border="false"
:close-on-scroll="false"
@show="onPopoverShow"
@hide="onPopoverHide"
>
@@ -0,0 +1,158 @@
<script setup>
import { computed, getCurrentInstance, ref, useTemplateRef } from 'vue';
import { downloadFile } from '@chatwoot/utils';
import { useEmitter } from 'dashboard/composables/emitter';
import { emitter } from 'shared/helpers/mitt';
import Button from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
src: {
type: String,
required: true,
},
fallbackDuration: {
type: Number,
default: 0,
},
});
const PLAYBACK_SPEEDS = [1, 1.5, 2];
const audioPlayer = useTemplateRef('audioPlayer');
const { uid } = getCurrentInstance();
const isPlaying = ref(false);
const currentTime = ref(0);
const duration = ref(props.fallbackDuration);
const playbackSpeed = ref(1);
const onLoadedMetadata = () => {
const loadedDuration = audioPlayer.value?.duration;
if (Number.isFinite(loadedDuration)) duration.value = loadedDuration;
};
const formatTime = time => {
if (!time || Number.isNaN(time)) return '00:00';
const minutes = Math.floor(time / 60);
const seconds = Math.floor(time % 60);
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
};
const playbackSpeedLabel = computed(() => `${playbackSpeed.value}x`);
const displayedTime = computed(() =>
formatTime(
isPlaying.value || currentTime.value ? currentTime.value : duration.value
)
);
// Only one recording should play at a time across the list.
useEmitter('pause_playing_audio', currentPlayingId => {
if (currentPlayingId !== uid && isPlaying.value) {
audioPlayer.value?.pause();
isPlaying.value = false;
}
});
const playOrPause = () => {
if (isPlaying.value) {
audioPlayer.value.pause();
isPlaying.value = false;
} else {
emitter.emit('pause_playing_audio', uid);
audioPlayer.value.play();
isPlaying.value = true;
}
};
const onTimeUpdate = () => {
currentTime.value = audioPlayer.value?.currentTime;
};
const seek = event => {
const time = Number(event.target.value);
audioPlayer.value.currentTime = time;
currentTime.value = time;
};
const onEnd = () => {
isPlaying.value = false;
currentTime.value = 0;
};
const changePlaybackSpeed = () => {
const currentIndex = PLAYBACK_SPEEDS.indexOf(playbackSpeed.value);
playbackSpeed.value =
PLAYBACK_SPEEDS[(currentIndex + 1) % PLAYBACK_SPEEDS.length];
audioPlayer.value.playbackRate = playbackSpeed.value;
};
const downloadRecording = () => {
downloadFile({ url: props.src, type: 'audio' });
};
</script>
<template>
<div
class="flex items-center justify-center h-8 gap-2 px-2 rounded-full bg-n-alpha-1 dark:bg-n-alpha-2 overflow-hidden"
@click.stop
>
<audio
ref="audioPlayer"
class="hidden"
playsinline
@loadedmetadata="onLoadedMetadata"
@timeupdate="onTimeUpdate"
@ended="onEnd"
>
<source :src="src" />
</audio>
<Button
variant="ghost"
color="slate"
size="xs"
class="!w-6 !p-0 text-n-slate-12"
@click="playOrPause"
>
<template #icon>
<Icon
:icon="isPlaying ? 'i-lucide-pause' : 'i-lucide-play'"
class="size-4 flex-shrink-0"
/>
</template>
</Button>
<input
type="range"
min="0"
:max="duration || 0"
:value="currentTime"
class="flex-1 min-w-0 lg:grow-0 lg:basis-24 h-1 rounded-lg appearance-none cursor-pointer bg-n-slate-12/30 accent-n-slate-11"
@input="seek"
/>
<span class="text-sm tabular-nums text-n-slate-11 shrink-0">
{{ displayedTime }}
</span>
<div class="w-px h-3.5 bg-n-slate-6 shrink-0" />
<Button
variant="ghost"
color="slate"
size="xs"
:label="playbackSpeedLabel"
class="!px-1 min-w-6 !text-n-slate-11"
@click="changePlaybackSpeed"
/>
<div class="w-px h-3.5 bg-n-slate-6 shrink-0" />
<Button
variant="ghost"
color="slate"
size="xs"
class="!w-6 !p-0 text-n-slate-11"
@click="downloadRecording"
>
<template #icon>
<Icon icon="i-lucide-download" class="size-4 flex-shrink-0" />
</template>
</Button>
</div>
</template>
@@ -9,6 +9,7 @@ const props = defineProps({
// null = neutral, true = good direction, false = bad direction
trendGood: { type: Boolean, default: null },
clickable: { type: Boolean, default: false },
loading: { type: Boolean, default: false },
});
const emit = defineEmits(['click']);
@@ -45,7 +46,11 @@ const onActivate = () => {
class="transition-opacity opacity-0 cursor-help i-lucide-info size-3.5 text-n-slate-10 group-hover:opacity-100"
/>
</div>
<div class="flex items-end justify-between gap-2">
<div v-if="loading" class="flex items-end justify-between gap-2">
<div class="w-20 rounded h-9 bg-n-slate-3 animate-pulse" />
<div class="w-10 h-5 rounded bg-n-slate-3 animate-pulse" />
</div>
<div v-else class="flex items-end justify-between gap-2">
<span
class="text-3xl font-semibold tracking-tight tabular-nums text-n-slate-12"
>
@@ -9,6 +9,10 @@ const props = defineProps({
type: String,
default: '30',
},
stats: {
type: Object,
default: null,
},
});
const route = useRoute();
@@ -20,22 +24,41 @@ const assistantId = computed(() => route.params.assistantId);
const welcomeMarkdown = ref('');
const isLoading = ref(false);
// Increments on every fetch so a slow response for a superseded
// range/stats/assistant can't overwrite the latest request's state.
let fetchToken = 0;
const fetchSummary = async () => {
fetchToken += 1;
const token = fetchToken;
if (!props.stats) {
welcomeMarkdown.value = '';
isLoading.value = false;
return;
}
isLoading.value = true;
let message = '';
try {
const { data } = await CaptainAssistant.getSummary({
assistantId: assistantId.value,
range: props.range,
stats: props.stats,
});
welcomeMarkdown.value = data.message ?? '';
message = data.message ?? '';
} catch {
welcomeMarkdown.value = '';
} finally {
isLoading.value = false;
message = '';
}
if (token !== fetchToken) return;
welcomeMarkdown.value = message;
isLoading.value = false;
};
watch([() => props.range, assistantId], fetchSummary, { immediate: true });
watch([() => props.range, () => props.stats, assistantId], fetchSummary, {
immediate: true,
});
// Render through the shared markdown formatter (html disabled, so it is safe)
// used everywhere else for Captain output, instead of a bespoke parser. It
@@ -3,8 +3,13 @@ import { ref, computed, onMounted, watch } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { requiredIf } from '@vuelidate/validators';
import { useI18n } from 'vue-i18n';
import { extractFilenameFromUrl } from 'dashboard/helper/URLHelper';
import { TWILIO_CONTENT_TEMPLATE_TYPES } from 'shared/constants/messages';
import {
isTwilioComplete,
isTwilioMediaTemplate,
getTwilioMediaVariableKey,
getTwilioMediaUrl,
applyTwilioMediaFilename,
} from '@chatwoot/utils';
import Input from 'dashboard/components-next/input/Input.vue';
@@ -40,30 +45,23 @@ const templateBody = computed(() => {
return props.template.body || '';
});
const hasMediaTemplate = computed(() => {
return props.template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.MEDIA;
});
// Media-template detection and variable extraction are shared with the mobile
// app via @chatwoot/utils.
const hasMediaTemplate = computed(() => isTwilioMediaTemplate(props.template));
const hasVariables = computed(() => {
return templateBody.value?.match(VARIABLE_PATTERN) !== null;
});
const mediaVariableKey = computed(() => {
if (!hasMediaTemplate.value) return null;
const mediaUrl = props.template?.types?.['twilio/media']?.media?.[0];
if (!mediaUrl) return null;
return mediaUrl.match(/{{(\d+)}}/)?.[1] ?? null;
});
const mediaVariableKey = computed(() =>
getTwilioMediaVariableKey(props.template)
);
const hasMediaVariable = computed(() => {
return hasMediaTemplate.value && mediaVariableKey.value !== null;
});
const hasMediaVariable = computed(() => mediaVariableKey.value !== null);
const templateMediaUrl = computed(() => {
if (!hasMediaTemplate.value) return '';
return props.template?.types?.['twilio/media']?.media?.[0] || '';
});
const templateMediaUrl = computed(() =>
hasMediaTemplate.value ? getTwilioMediaUrl(props.template) : ''
);
const variablePattern = computed(() => {
if (!hasVariables.value) return [];
@@ -83,26 +81,10 @@ const renderedTemplate = computed(() => {
return rendered;
});
const isFormInvalid = computed(() => {
if (!hasVariables.value && !hasMediaVariable.value) return false;
if (hasVariables.value) {
const hasEmptyVariable = variablePattern.value.some(
variable => !processedParams.value[variable]
);
if (hasEmptyVariable) return true;
}
if (
hasMediaVariable.value &&
mediaVariableKey.value &&
!processedParams.value[mediaVariableKey.value]
) {
return true;
}
return false;
});
// Completeness validation is shared with the mobile app via @chatwoot/utils.
const isFormInvalid = computed(
() => !isTwilioComplete(props.template, processedParams.value)
);
const v$ = useVuelidate(
{
@@ -135,19 +117,11 @@ const sendMessage = () => {
const { friendly_name, language } = props.template;
// Process parameters and extract filename from media URL if needed
const processedParameters = { ...processedParams.value };
// For media templates, extract filename from full URL
if (
hasMediaVariable.value &&
mediaVariableKey.value &&
processedParameters[mediaVariableKey.value]
) {
processedParameters[mediaVariableKey.value] = extractFilenameFromUrl(
processedParameters[mediaVariableKey.value]
);
}
// For media templates, reduce the media URL to a filename before sending.
const processedParameters = applyTwilioMediaFilename(
props.template,
processedParams.value
);
const payload = {
message: renderedTemplate.value,
@@ -0,0 +1,342 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n, I18nT } from 'vue-i18n';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Popover from 'dashboard/components-next/popover/Popover.vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useAccount } from 'dashboard/composables/useAccount';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useMessageContext } from './provider.js';
import { MESSAGE_VARIANTS, ORIENTATION } from './constants';
const props = defineProps({
messageId: { type: Number, required: true },
});
const { t } = useI18n();
const { orientation, variant, createdAt } = useMessageContext();
const store = useStore();
const { isCloudFeatureEnabled } = useAccount();
const isOpen = ref(false);
const showSparkle = computed(() =>
isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN_V2)
);
const session = computed(() =>
store.getters['captainAgentSessions/getSessionByMessageId'](props.messageId)
);
const hasFetched = computed(() =>
store.getters['captainAgentSessions/hasFetched'](props.messageId)
);
const isLoading = computed(
() =>
!hasFetched.value ||
store.getters['captainAgentSessions/isFetching'](props.messageId)
);
const citations = computed(() => session.value?.citations || []);
const scenarioTitles = computed(() =>
(session.value?.scenarios || []).reduce((map, scenario) => {
map[scenario.id] = scenario.title;
return map;
}, {})
);
// Fallback for agents without a matching scenario title:
// "chatwoot_assistant" "Chatwoot assistant",
// "scenario_5_chatwoot_uptime_agent" "Chatwoot uptime".
const humanizeAgentName = agentName => {
const label = agentName
.replace(/^scenario_\d+_/, '')
.replace(/_agent$/, '')
.replaceAll('_', ' ')
.trim();
return label.charAt(0).toUpperCase() + label.slice(1);
};
const handoffLabel = agentName => {
const scenarioId = agentName.match(/^scenario_(\d+)/)?.[1];
return scenarioTitles.value[scenarioId] || humanizeAgentName(agentName);
};
const ACRONYMS = ['faq', 'api', 'url', 'id', 'sla', 'csat'];
// Tool names arrive as RubyLLM identifiers like
// "captain--tools--faq_lookup" or "custom_get_status_page_overview";
// show "FAQ Lookup" / "Get Status Page Overview" instead.
const humanizeToolName = name => {
return (name || '')
.split('--')
.pop()
.replace(/^custom_/, '')
.split('_')
.filter(Boolean)
.map(word =>
ACRONYMS.includes(word)
? word.toUpperCase()
: word.charAt(0).toUpperCase() + word.slice(1)
)
.join(' ');
};
// Argument keys are camelCased by the store ("labelName"); show "Label Name".
const humanizeArgumentKey = key =>
key
.replace(/([a-z])([A-Z])/g, '$1 $2')
.split(' ')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
const formatArguments = args => {
if (!args || typeof args !== 'object') return '';
return Object.entries(args)
.map(([key, value]) => `${humanizeArgumentKey(key)}: ${value}`)
.join(', ');
};
// Timeline of what Captain did during the run: tool calls (with their
// arguments) and scenario/agent handoffs. Message bodies and raw tool
// results are intentionally not echoed here.
const steps = computed(() => {
const runContext = session.value?.runContext;
const result = [];
let currentAgent = null;
(Array.isArray(runContext) ? runContext : []).forEach(entry => {
if (entry?.role !== 'assistant') return;
const agentName = entry.agentName;
if (agentName && agentName !== currentAgent) {
if (currentAgent !== null) {
result.push({ type: 'handoff', name: handoffLabel(agentName) });
}
currentAgent = agentName;
}
(entry.toolCalls || []).forEach(call => {
// Agent-to-agent transfers surface as "handoff_to_<agent>" tool calls;
// the agent_name change above already yields a handoff step for them.
if (call.name?.startsWith('handoff_to_')) return;
result.push({
type: 'tool',
name: humanizeToolName(call.name),
detail: formatArguments(call.arguments),
});
});
});
return result;
});
// The final assistant entry stores structured content ({response, reasoning});
// surface the model's reasoning for the reply it produced.
const reasoning = computed(() => {
const runContext = session.value?.runContext;
if (!Array.isArray(runContext)) return '';
const entry = [...runContext]
.reverse()
.find(item => item?.role === 'assistant' && item.content?.reasoning);
return entry?.content?.reasoning || '';
});
const STEP_ICONS = {
tool: 'i-ph-wrench',
handoff: 'i-ph-user-switch',
};
const STEP_KEYPATHS = {
tool: 'CONVERSATION.CAPTAIN_GENERATION.STEP_TOOL',
handoff: 'CONVERSATION.CAPTAIN_GENERATION.STEP_HANDOFF',
};
const currentUser = useMapGetter('getCurrentUser');
const isSuperAdmin = computed(() => currentUser.value.type === 'SuperAdmin');
// Model and credits are only surfaced to super admins and in development.
const devDetails = computed(() => {
if (!session.value) return null;
if (!import.meta.env.DEV && !isSuperAdmin.value) return null;
const model = t('CONVERSATION.CAPTAIN_GENERATION.MODEL', {
model: session.value.llmModel,
});
const credits = t('CONVERSATION.CAPTAIN_GENERATION.CREDITS', {
credits: session.value.creditsConsumed,
});
return `${model} · ${credits}`;
});
// With the sparkle at the row start, the meta gets pushed to the opposite end;
// without it, fall back to the message orientation.
const rowLayoutClass = computed(() => {
if (showSparkle.value) return 'justify-between';
return orientation.value === ORIENTATION.LEFT
? 'justify-start'
: 'justify-end';
});
// Blend the sparkle with the bubble background: amber on private notes,
// slate everywhere else. Tokens adapt to dark mode on their own.
const sparkleColorClass = computed(() => {
if (variant.value === MESSAGE_VARIANTS.PRIVATE) {
return isOpen.value
? 'text-n-amber-12/80'
: 'text-n-amber-12/40 hover:text-n-amber-12/70';
}
return isOpen.value
? 'text-n-slate-12'
: 'text-n-slate-11/60 hover:text-n-slate-12';
});
const popoverAlign = computed(() =>
orientation.value === ORIENTATION.LEFT ? 'start' : 'end'
);
const prefetch = () => {
store.dispatch('captainAgentSessions/fetch', {
messageId: props.messageId,
createdAt: createdAt.value,
});
};
const onPopoverShow = () => {
isOpen.value = true;
prefetch();
};
const onPopoverHide = () => {
isOpen.value = false;
};
</script>
<template>
<div class="flex items-center gap-1.5" :class="rowLayoutClass">
<Popover
v-if="showSparkle"
:align="popoverAlign"
@show="onPopoverShow"
@hide="onPopoverHide"
>
<button
v-tooltip="t('CONVERSATION.CAPTAIN_GENERATION.TITLE')"
type="button"
class="inline-flex items-center gap-1 p-0 bg-transparent border-0 cursor-pointer"
:class="sparkleColorClass"
@mouseenter="prefetch"
@focus="prefetch"
>
<Icon icon="i-ph-sparkle-fill" class="size-3.5" />
<span class="text-xs">
{{ t('CONVERSATION.CAPTAIN_GENERATION.GENERATED_BY') }}
</span>
</button>
<template #content>
<div class="flex flex-col gap-4 p-4 w-80">
<span v-if="isLoading" class="text-xs text-n-slate-11">
{{ t('CONVERSATION.CAPTAIN_GENERATION.LOADING') }}
</span>
<span v-else-if="!session" class="text-xs text-n-slate-11">
{{ t('CONVERSATION.CAPTAIN_GENERATION.EMPTY') }}
</span>
<template v-else>
<div v-if="steps.length" class="flex flex-col gap-2">
<span class="text-xs font-medium text-n-slate-11">
{{ t('CONVERSATION.CAPTAIN_GENERATION.TIMELINE') }}
</span>
<div class="flex flex-col">
<div
v-for="(step, index) in steps"
:key="index"
class="flex gap-2.5"
>
<div class="flex flex-col items-center">
<span
class="flex items-center justify-center rounded-full size-5 bg-n-alpha-2 text-n-slate-11"
>
<Icon :icon="STEP_ICONS[step.type]" class="size-3" />
</span>
<span
v-if="index < steps.length - 1"
class="flex-1 w-px min-h-2 bg-n-weak"
/>
</div>
<div
class="flex flex-col min-w-0 gap-0.5"
:class="index < steps.length - 1 ? 'pb-3' : ''"
>
<I18nT
:keypath="STEP_KEYPATHS[step.type]"
tag="span"
class="text-xs leading-5 text-n-slate-11"
>
<template #name>
<span class="font-medium text-n-slate-12">
{{ step.name }}
</span>
</template>
</I18nT>
<span
v-if="step.detail"
class="text-xs text-n-slate-11 break-words"
>
{{ step.detail }}
</span>
</div>
</div>
</div>
</div>
<div v-if="citations.length" class="flex flex-col gap-2">
<div class="flex items-baseline gap-1.5">
<span class="text-xs font-medium text-n-slate-11">
{{ t('CONVERSATION.CAPTAIN_GENERATION.SOURCES') }}
</span>
<span class="text-xs text-n-slate-10">
{{
t(
'CONVERSATION.CAPTAIN_GENERATION.SOURCES_SUMMARY',
citations.length
)
}}
</span>
</div>
<ul class="flex flex-col gap-1 m-0 list-disc ps-4">
<li
v-for="citation in citations"
:key="citation.id"
class="text-xs text-n-slate-12"
>
<a
v-if="citation.link"
:href="citation.link"
target="_blank"
rel="noopener noreferrer"
class="text-xs text-n-blue-11 hover:underline"
>
{{ citation.title || citation.link }}
</a>
<span v-else>{{ citation.title }}</span>
</li>
</ul>
</div>
<div v-if="reasoning" class="flex flex-col gap-2">
<span class="text-xs font-medium text-n-slate-11">
{{ t('CONVERSATION.CAPTAIN_GENERATION.REASONING') }}
</span>
<p class="m-0 text-xs leading-normal text-n-slate-12 break-words">
{{ reasoning }}
</p>
</div>
<span v-if="devDetails" class="text-xs text-n-slate-11">
{{ devDetails }}
</span>
</template>
</div>
</template>
</Popover>
<slot name="meta" />
</div>
</template>
@@ -2,6 +2,7 @@
import { computed } from 'vue';
import MessageMeta from '../MessageMeta.vue';
import CaptainGenerationDetails from '../CaptainGenerationDetails.vue';
import { emitter } from 'shared/helpers/mitt';
import { useMessageContext } from '../provider.js';
@@ -9,16 +10,38 @@ 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';
import { MESSAGE_VARIANTS, ORIENTATION, SENDER_TYPES } from '../constants';
const props = defineProps({
hideMeta: { type: Boolean, default: false },
});
const { variant, orientation, inReplyTo, shouldGroupWithNext } =
useMessageContext();
const {
variant,
orientation,
inReplyTo,
shouldGroupWithNext,
id,
sender,
senderType,
} = useMessageContext();
const { t } = useI18n();
const isCaptainMessage = computed(
() =>
(sender.value?.type ?? senderType.value) === SENDER_TYPES.CAPTAIN_ASSISTANT
);
const metaColorClass = computed(() =>
variant.value === MESSAGE_VARIANTS.PRIVATE
? 'text-n-amber-12/50'
: 'text-n-slate-11'
);
const emailMetaClass = computed(() =>
variant.value === MESSAGE_VARIANTS.EMAIL ? 'px-3 pb-3' : ''
);
const varaintBaseMap = {
[MESSAGE_VARIANTS.AGENT]: 'bg-n-solid-blue text-n-slate-12',
[MESSAGE_VARIANTS.PRIVATE]:
@@ -114,16 +137,21 @@ const replyToPreview = computed(() => {
/>
</div>
<slot />
<MessageMeta
v-if="shouldShowMeta"
:class="[
flexOrientationClass,
variant === MESSAGE_VARIANTS.EMAIL ? 'px-3 pb-3' : '',
variant === MESSAGE_VARIANTS.PRIVATE
? 'text-n-amber-12/50'
: 'text-n-slate-11',
]"
class="mt-2"
/>
<template v-if="shouldShowMeta">
<CaptainGenerationDetails
v-if="isCaptainMessage"
:message-id="id"
class="mt-2"
>
<template #meta>
<MessageMeta :class="[emailMetaClass, metaColorClass]" />
</template>
</CaptainGenerationDetails>
<MessageMeta
v-else
:class="[flexOrientationClass, emailMetaClass, metaColorClass]"
class="mt-2"
/>
</template>
</div>
</template>
@@ -263,9 +263,9 @@ const handleCallBack = async () => {
if (!canCallBack.value || isInitiatingCall.value) return;
try {
if (isWhatsapp.value) {
const response = await whatsappCallSession.initiateOutboundCall(
conversationId.value
);
const response = await whatsappCallSession.initiateOutboundCall({
conversationId: conversationId.value,
});
if (response?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.LOCKED) return;
// Permission template path returns no call id show banner, no widget yet.
if (!response?.id) {
@@ -1,7 +1,11 @@
<script setup>
import { ref, computed, watch, nextTick } from 'vue';
import { vOnClickOutside } from '@vueuse/components';
import { useBreakpoints, breakpointsTailwind } from '@vueuse/core';
import {
useBreakpoints,
breakpointsTailwind,
useEventListener,
} from '@vueuse/core';
import { useDropdownPosition } from 'dashboard/composables/useDropdownPosition';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
@@ -16,6 +20,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
closeOnScroll: {
type: Boolean,
default: true,
},
showContentBorder: {
type: Boolean,
default: true,
@@ -41,8 +49,12 @@ const { fixedPosition, updatePosition } = useDropdownPosition(
{ align: props.align }
);
const SCROLL_CLOSE_THRESHOLD = 24;
const triggerTopAtOpen = ref(0);
const show = async () => {
isActive.value = true;
triggerTopAtOpen.value = triggerRef.value?.getBoundingClientRect().top ?? 0;
if (!isMobile.value) {
await nextTick();
updatePosition();
@@ -56,6 +68,22 @@ const hide = () => {
emit('hide');
};
// The teleported popover tracks its trigger while ancestors scroll; allow
// small drift (trackpad inertia), but close once the trigger moves further.
useEventListener(
window,
'scroll',
event => {
if (!props.closeOnScroll || !showPopover.value) return;
if (popoverRef.value?.contains(event.target)) return;
const top = triggerRef.value?.getBoundingClientRect().top ?? 0;
if (Math.abs(top - triggerTopAtOpen.value) > SCROLL_CLOSE_THRESHOLD) {
hide();
}
},
{ capture: true, passive: true }
);
const toggle = async () => {
if (isActive.value) hide();
else await show();
@@ -2,6 +2,7 @@
import { h, ref, computed, onMounted, watch } from 'vue';
import { provideSidebarContext, useSidebarResize } from './provider';
import { useAccount } from 'dashboard/composables/useAccount';
import { useConfig } from 'dashboard/composables/useConfig';
import { useKbd } from 'dashboard/composables/utils/useKbd';
import { useMapGetter } from 'dashboard/composables/store';
import { useStore } from 'vuex';
@@ -43,7 +44,14 @@ const emit = defineEmits([
]);
const { accountScopedRoute, isOnChatwootCloud } = useAccount();
const { isEnterprise } = useConfig();
const store = useStore();
// Calls run on the enterprise-only API (cloud runs enterprise); hide the entry
// on community so it doesn't lead to a dashboard/CTA the backend can't serve.
const isCallsAvailable = computed(
() => isOnChatwootCloud.value || isEnterprise
);
const searchShortcut = useKbd([`$mod`, 'k']);
const { t } = useI18n();
@@ -563,6 +571,17 @@ const menuItems = computed(() => {
},
],
},
...(isCallsAvailable.value
? [
{
name: 'Calls',
label: t('SIDEBAR.CALLS'),
icon: 'i-lucide-phone',
to: accountScopedRoute('calls_dashboard_index'),
activeOn: ['calls_dashboard_index'],
},
]
: []),
{
name: 'Contacts',
label: t('SIDEBAR.CONTACTS'),
@@ -13,6 +13,7 @@ import { useVuelidate } from '@vuelidate/core';
import { requiredIf } from '@vuelidate/validators';
import { useI18n } from 'vue-i18n';
import { isWhatsAppComplete } from '@chatwoot/utils';
import Input from 'dashboard/components-next/input/Input.vue';
import {
buildTemplateParameters,
@@ -84,29 +85,10 @@ const renderedTemplate = computed(() => {
return replaceTemplateVariables(bodyText.value, processedParams.value);
});
const isFormInvalid = computed(() => {
if (!hasVariables.value && !hasMediaHeader.value) return false;
if (hasMediaHeader.value && !processedParams.value.header?.media_url) {
return true;
}
if (hasVariables.value && processedParams.value.body) {
const hasEmptyBodyVariable = Object.values(processedParams.value.body).some(
value => !value
);
if (hasEmptyBodyVariable) return true;
}
if (processedParams.value.buttons) {
const hasEmptyButtonParameter = processedParams.value.buttons.some(
button => !button.parameter
);
if (hasEmptyButtonParameter) return true;
}
return false;
});
// Completeness validation is shared with the mobile app via @chatwoot/utils.
const isFormInvalid = computed(
() => !isWhatsAppComplete(props.template, processedParams.value)
);
const v$ = useVuelidate(
{
@@ -69,9 +69,9 @@ const callButtonTooltip = computed(() =>
const startWhatsappCall = async () => {
if (whatsappCallSession.isInitiating.value) return;
try {
const response = await whatsappCallSession.initiateOutboundCall(
props.chat.id
);
const response = await whatsappCallSession.initiateOutboundCall({
conversationId: props.chat.id,
});
// Composable returns LOCKED when init is already in flight or a call is
// active; soft no-op so a parallel click doesn't trigger a banner.
@@ -1,9 +1,9 @@
import { ref } from 'vue';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useAgentsList } from '../useAgentsList';
import { useMapGetter } from 'dashboard/composables/store';
import { allAgentsData, formattedAgentsData } from './fixtures/agentFixtures';
import * as agentHelper from 'dashboard/helper/agentHelper';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ref } from 'vue';
import { useAgentsList } from '../useAgentsList';
import { allAgentsData, formattedAgentsData } from './fixtures/agentFixtures';
// Mock vue-i18n
vi.mock('vue-i18n', () => ({
@@ -94,6 +94,32 @@ describe('useAgentsList', () => {
expect(agentsList.value.length).toBe(formattedAgentsData.slice(1).length);
});
it('keeps nameless agent bots and applies a fallback label', () => {
const namelessBot = {
id: 91,
name: null,
assignee_type: 'AgentBot',
availability_status: 'offline',
};
mockUseMapGetter({
'inboxAssignableAgents/getAssignableAgents': ref(() => [
...allAgentsData,
namelessBot,
]),
});
const { agentsList } = useAgentsList();
// access the computed to trigger evaluation
expect(agentsList.value).toBeDefined();
const passedAgents =
agentHelper.getAgentsByUpdatedPresence.mock.calls[0][0];
expect(passedAgents).toContainEqual({
...namelessBot,
name: '-',
});
});
it('handles empty assignable agents', () => {
mockUseMapGetter({
'inboxAssignableAgents/getAssignableAgents': ref(() => []),
@@ -1,10 +1,10 @@
import { computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import {
getAgentsByUpdatedPresence,
getSortedAgentsByAvailability,
} from 'dashboard/helper/agentHelper';
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
/**
* A composable function that provides a list of agents for assignment.
@@ -53,7 +53,11 @@ export function useAgentsList(
* @type {import('vue').ComputedRef<Array>}
*/
const agentsList = computed(() => {
const agents = assignableAgents.value || [];
const agents = (assignableAgents.value || []).map(agent =>
!agent.name && agent.assignee_type === 'AgentBot'
? { ...agent, name: '-' }
: agent
);
const agentsByUpdatedPresence = getAgentsByUpdatedPresence(
agents,
currentUser.value,
@@ -308,7 +308,8 @@ export function useWhatsappCallSession() {
}
};
const initiateOutboundCall = async conversationId => {
// target: { conversationId } or { contactId, inboxId }
const initiateOutboundCall = async target => {
// Module-scoped lock + active-session guard so a second click — from the
// same composable instance OR a different one (header vs contact panel)
// OR while a call is already live — can't tear down the in-flight setup
@@ -320,10 +321,7 @@ export function useWhatsappCallSession() {
isInitiatingOutbound.value = true;
try {
const sdpOffer = await prepareOutboundOffer();
const response = await WhatsappCallsAPI.initiate(
conversationId,
sdpOffer
);
const response = await WhatsappCallsAPI.initiate(target, sdpOffer);
if (response?.id) {
activeCallId = response.id;
// A connect webhook that raced ahead of this response was buffered;
@@ -354,7 +352,7 @@ export function useWhatsappCallSession() {
data?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.PERMISSION_REQUESTED ||
data?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.PERMISSION_PENDING
) {
return { status: data.status };
return { status: data.status, conversation_id: data.conversation_id };
}
throw e;
} finally {
+2 -2
View File
@@ -7,14 +7,14 @@ export const FEATURE_FLAGS = {
AUTOMATIONS: 'automations',
CAMPAIGNS: 'campaigns',
WHATSAPP_CAMPAIGNS: 'whatsapp_campaign',
WHATSAPP_EMBEDDED_SIGNUP_INBOX_CREATION:
'whatsapp_embedded_signup_inbox_creation',
WHATSAPP_EMBEDDED_SIGNUP_FLOW: 'whatsapp_embedded_signup_inbox_creation',
WHATSAPP_MANUAL_TRANSFER: 'whatsapp_manual_transfer',
WHATSAPP_RECONFIGURE: 'whatsapp_reconfigure',
CANNED_RESPONSES: 'canned_responses',
CRM: 'crm',
CUSTOM_ATTRIBUTES: 'custom_attributes',
DATA_IMPORT: 'data_import',
DELAYED_AUTOMATIONS: 'delayed_automations',
API_AND_WEBHOOKS: 'api_and_webhooks',
INBOX_MANAGEMENT: 'inbox_management',
INTEGRATIONS: 'integrations',
+2 -19
View File
@@ -127,25 +127,8 @@ export const getHostNameFromURL = url => {
}
};
/**
* Extracts filename from a URL
* @param {string} url - The URL to extract filename from
* @returns {string} - The extracted filename or original URL if extraction fails
*/
export const extractFilenameFromUrl = url => {
if (!url || typeof url !== 'string') return url;
try {
const urlObj = new URL(url);
const pathname = urlObj.pathname;
const filename = pathname.split('/').pop();
return filename || url;
} catch (error) {
// If URL parsing fails, try to extract filename using regex
const match = url.match(/\/([^/?#]+)(?:[?#]|$)/);
return match ? match[1] : url;
}
};
// Shared with the mobile app via @chatwoot/utils.
export { extractFilenameFromUrl } from '@chatwoot/utils';
/**
* Normalizes a comma/newline separated list of domains
@@ -7,7 +7,7 @@
export const getAgentsByAvailability = (agents, availability) => {
return agents
.filter(agent => agent.availability_status === availability)
.sort((a, b) => a.name.localeCompare(b.name));
.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
};
/**
@@ -208,6 +208,12 @@ export const generateAutomationPayload = payload => {
return automation;
};
export const formatDelay = minutes => {
if (minutes % 1440 === 0) return `${minutes / 1440}d`;
if (minutes % 60 === 0) return `${minutes / 60}h`;
return `${minutes}m`;
};
export const isCustomAttribute = (attrs, key) => {
return attrs.find(attr => attr.key === key);
};
@@ -26,6 +26,18 @@ describe('agentHelper', () => {
offlineAgentsData
);
});
it('does not throw when an agent has a null name', () => {
const agents = [
{ id: 1, name: null, availability_status: 'offline' },
{ id: 2, name: 'Zoe', availability_status: 'offline' },
];
expect(() => getAgentsByAvailability(agents, 'offline')).not.toThrow();
expect(
getAgentsByAvailability(agents, 'offline').map(agent => agent.id)
).toEqual([1, 2]);
});
});
describe('getSortedAgentsByAvailability', () => {
@@ -156,12 +156,18 @@ describe('templateHelper', () => {
]);
});
it('should handle templates with no variables', () => {
it('should handle templates with no variables but a media header', () => {
const emptyTemplate = templates.find(
t => t.name === 'no_variable_template'
);
const result = buildTemplateParameters(emptyTemplate, false);
expect(result).toEqual({});
const result = buildTemplateParameters(emptyTemplate);
// hasMediaHeader is derived from the template, so the document header is kept.
expect(result.body).toBeUndefined();
expect(result.header).toEqual({
media_url: '',
media_type: 'document',
media_name: '',
});
});
it('should build parameters for templates with multiple component types', () => {
@@ -1,19 +1,16 @@
// Constants
import { processVariable, buildWhatsAppProcessedParams } from '@chatwoot/utils';
// Constants and pure template helpers are shared with the mobile app via
// @chatwoot/utils so the logic lives in one place.
export {
MEDIA_FORMATS,
COMPONENT_TYPES,
findComponentByType,
processVariable,
} from '@chatwoot/utils';
export const DEFAULT_LANGUAGE = 'en';
export const DEFAULT_CATEGORY = 'UTILITY';
export const COMPONENT_TYPES = {
HEADER: 'HEADER',
BODY: 'BODY',
BUTTONS: 'BUTTONS',
};
export const MEDIA_FORMATS = ['IMAGE', 'VIDEO', 'DOCUMENT'];
export const findComponentByType = (template, type) =>
template.components?.find(component => component.type === type);
export const processVariable = str => {
return str.replace(/{{|}}/g, '');
};
export const allKeysRequired = value => {
const keys = Object.keys(value);
@@ -27,70 +24,7 @@ export const replaceTemplateVariables = (templateText, processedParams) => {
});
};
export const buildTemplateParameters = (template, hasMediaHeaderValue) => {
const allVariables = {};
const bodyComponent = findComponentByType(template, COMPONENT_TYPES.BODY);
const headerComponent = findComponentByType(template, COMPONENT_TYPES.HEADER);
if (!bodyComponent) return allVariables;
const templateString = bodyComponent.text;
// Process body variables
const matchedVariables = templateString.match(/{{([^}]+)}}/g);
if (matchedVariables) {
allVariables.body = {};
matchedVariables.forEach(variable => {
const key = processVariable(variable);
allVariables.body[key] = '';
});
}
if (hasMediaHeaderValue) {
if (!allVariables.header) allVariables.header = {};
allVariables.header.media_url = '';
allVariables.header.media_type = headerComponent.format.toLowerCase();
// For document templates, include media_name field for filename support
if (headerComponent.format.toLowerCase() === 'document') {
allVariables.header.media_name = '';
}
}
// Process button variables
const buttonComponents = template.components.filter(
component => component.type === COMPONENT_TYPES.BUTTONS
);
buttonComponents.forEach(buttonComponent => {
if (buttonComponent.buttons) {
buttonComponent.buttons.forEach((button, index) => {
// Handle URL buttons with variables
if (button.type === 'URL' && button.url && button.url.includes('{{')) {
const buttonVars = button.url.match(/{{([^}]+)}}/g) || [];
if (buttonVars.length > 0) {
if (!allVariables.buttons) allVariables.buttons = [];
allVariables.buttons[index] = {
type: 'url',
parameter: '',
url: button.url,
variables: buttonVars.map(v => processVariable(v)),
};
}
}
// Handle copy code buttons
if (button.type === 'COPY_CODE') {
if (!allVariables.buttons) allVariables.buttons = [];
allVariables.buttons[index] = {
type: 'copy_code',
parameter: '',
};
}
});
}
});
return allVariables;
};
// The media-header flag is derived from the template inside the shared helper;
// the second argument is kept for backwards-compatible call sites.
export const buildTemplateParameters = template =>
buildWhatsAppProcessedParams(template);
@@ -28,6 +28,35 @@
"PLACEHOLDER": "Please select one",
"ERROR": "Event is required"
},
"EXECUTE": {
"LABEL": "Delayed execution",
"AFTER_DELAY": "Run after",
"UNITS": {
"MINUTES": "Minutes",
"HOURS": "Hours",
"DAYS": "Days"
},
"ERROR": "Delay must be between 10 minutes and 30 days",
"ENDS_IF_LABEL": "Won't run if",
"ENDS_IF": {
"STATUS": "the conversation's status changes, or its conditions no longer match.",
"CUSTOMER_REPLY": "the customer replies, or the conditions no longer match.",
"AGENT_REPLY": "an agent replies, or the conditions no longer match.",
"GENERIC": "there is a new reply on the conversation, or the conditions no longer match."
},
"HELP_TEXT": "Only applies to conversations with activity after the rule is created."
},
"TRIGGER": {
"LABEL": "Trigger",
"WHEN_LABEL": "When",
"STATUS_LABEL": "Status is",
"INBOX_LABEL": "Inbox",
"OPTIONS": {
"CUSTOMER_UNRESPONSIVE": "Customer unresponsive",
"AGENT_UNRESPONSIVE": "Teammate unresponsive",
"CONVERSATION_STATUS": "Conversation in a status"
}
},
"CONDITIONS": {
"LABEL": "Conditions"
},
@@ -49,7 +78,13 @@
"CREATED_ON": "Created on",
"ACTIONS": "Actions"
},
"404": "No automation rules found"
"404": "No automation rules found",
"SECTIONS": {
"INSTANT": "Automations",
"DELAYED": "Delayed execution"
},
"DELAY_BADGE": "Runs after {delay}",
"DELAY_DISABLED_BANNER": "Delayed execution is disabled for this account. Delayed rules won't run until it is enabled again."
},
"DELETE": {
"TITLE": "Delete Automation Rule",
@@ -0,0 +1,45 @@
{
"CALLS_PAGE": {
"HEADER": "Calls",
"ALL_CALLS": "All Calls",
"ALL_CALLS_COUNT": "All Calls ({count})",
"EMPTY_STATE": "No calls found",
"SETUP": {
"TITLE": "Make and receive calls in one place",
"SUBTITLE": "Set up a voice channel to start handling calls with your team. Every call, along with its recording, will appear here.",
"ACTION": "Set up voice channel"
},
"FILTERS": {
"MISSED": "Missed",
"NO_REPLY": "No reply",
"OTHER_ACTIVITY": "Other activity",
"INCOMING": "Incoming",
"OUTGOING": "Outgoing",
"IN_PROGRESS": "In progress",
"ASSIGNEE": "Assignee",
"ALL_ASSIGNEES": "All assignees",
"MORE_FILTERS": "More filters",
"INBOX": "Inbox",
"ALL_INBOXES": "All inboxes"
},
"STATUS": {
"ONGOING": "Ongoing",
"INCOMING": "Incoming",
"OUTGOING": "Outgoing",
"MISSED": "Missed",
"NO_REPLY": "No reply",
"FAILED": "Failed"
},
"ROW": {
"PICKED_BY": "Picked by",
"DIALED_BY": "Dialed by",
"ANSWERED": "Answered",
"RINGING": "Ringing",
"IN_PROGRESS": "In progress",
"NO_AGENT": "No agent answered this call",
"NO_CONTACT_ANSWER": "Contact did not answer",
"FAILED": "This call could not be connected",
"YESTERDAY": "Yesterday"
}
}
}
@@ -72,6 +72,20 @@
"RATING_TITLE": "Rating",
"FEEDBACK_TITLE": "Feedback",
"REPLY_MESSAGE_NOT_FOUND": "Message not available",
"CAPTAIN_GENERATION": {
"TITLE": "How was this reply generated?",
"GENERATED_BY": "Generated by Captain",
"LOADING": "Loading details…",
"EMPTY": "No generation details available for this message.",
"TIMELINE": "Generation steps",
"STEP_TOOL": "Called {name}",
"STEP_HANDOFF": "Handed off to {name}",
"REASONING": "Reasoning",
"SOURCES": "Knowledge base",
"SOURCES_SUMMARY": "{count} result | {count} results",
"MODEL": "Generated with {model}",
"CREDITS": "Credits: {credits}"
},
"CARD": {
"SHOW_LABELS": "Show labels",
"HIDE_LABELS": "Hide labels",
@@ -5,6 +5,7 @@ import attributesMgmt from './attributesMgmt.json';
import auditLogs from './auditLogs.json';
import automation from './automation.json';
import bulkActions from './bulkActions.json';
import calls from './calls.json';
import campaign from './campaign.json';
import cannedMgmt from './cannedMgmt.json';
import chatlist from './chatlist.json';
@@ -51,6 +52,7 @@ export default {
...auditLogs,
...automation,
...bulkActions,
...calls,
...campaign,
...cannedMgmt,
...chatlist,
@@ -324,6 +324,7 @@
"COMPANIES": "Companies",
"ALL_COMPANIES": "All Companies",
"CAPTAIN": "Captain",
"CALLS": "Calls",
"CAPTAIN_ASSISTANTS": "Assistants",
"CAPTAIN_OVERVIEW": "Overview",
"CAPTAIN_DOCUMENTS": "Documents",
@@ -0,0 +1,176 @@
<script setup>
import { computed, ref, watch, onMounted } from 'vue';
import { until } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useAdmin } from 'dashboard/composables/useAdmin';
import { isVoiceCallEnabled } from 'dashboard/helper/inbox';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useCallHistoryStore } from 'dashboard/stores/callHistory';
import CallListItem from 'dashboard/components-next/Calls/CallListItem.vue';
import CallsEmptyState from 'dashboard/components-next/Calls/CallsEmptyState.vue';
import CallsFilterBar from 'dashboard/components-next/Calls/CallsFilterBar.vue';
import { CALL_ACTIVITY_PARAMS } from 'dashboard/components-next/Calls/constants';
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const RESULTS_PER_PAGE = 25;
const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const store = useStore();
const callHistoryStore = useCallHistoryStore();
const inboxes = useMapGetter('inboxes/getInboxes');
const accountId = useMapGetter('getCurrentAccountId');
const currentUserId = useMapGetter('getCurrentUserID');
const agents = useMapGetter('agents/getVerifiedAgents');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
// CallFinder scopes non-admins to their own accepted calls, so the assignee
// filter is only meaningful for admins; everyone else defaults to themselves.
const { isAdmin } = useAdmin();
const voiceInboxes = computed(() => inboxes.value.filter(isVoiceCallEnabled));
const isVoiceEnabled = computed(
() =>
isFeatureEnabledonAccount.value(
accountId.value,
FEATURE_FLAGS.CHANNEL_VOICE
) && voiceInboxes.value.length > 0
);
const calls = computed(() => callHistoryStore.records);
const meta = computed(() => callHistoryStore.meta);
const isFetching = computed(() => callHistoryStore.uiFlags.isFetching);
const accountUiFlags = useMapGetter('accounts/getUIFlags');
const isInitializing = ref(true);
// Filters are seeded from the URL so a shared link restores the same view.
const activity = ref(
CALL_ACTIVITY_PARAMS[route.query.activity] ? route.query.activity : null
);
const assigneeId = ref(
isAdmin.value ? Number(route.query.assignee_id) || null : currentUserId.value
);
const inboxId = ref(Number(route.query.inbox_id) || null);
const currentPage = ref(Number(route.query.page) || 1);
const syncFiltersToUrl = () => {
router.replace({
query: {
...(activity.value && { activity: activity.value }),
...(isAdmin.value &&
assigneeId.value && { assignee_id: assigneeId.value }),
...(inboxId.value && { inbox_id: inboxId.value }),
...(currentPage.value > 1 && { page: currentPage.value }),
},
});
};
const fetchCalls = async () => {
syncFiltersToUrl();
try {
await callHistoryStore.fetchCalls({
page: currentPage.value,
...(CALL_ACTIVITY_PARAMS[activity.value] || {}),
...(assigneeId.value ? { agent_id: assigneeId.value } : {}),
...(inboxId.value ? { inbox_id: inboxId.value } : {}),
});
} catch (error) {
useAlert(error.message);
}
};
watch([activity, assigneeId, inboxId], () => {
currentPage.value = 1;
fetchCalls();
});
const onPageChange = page => {
currentPage.value = page;
fetchCalls();
};
onMounted(async () => {
try {
await Promise.all([
store.dispatch('inboxes/get'),
until(() => accountUiFlags.value.isFetchingItem).toBe(false),
]);
if (!isVoiceEnabled.value) return;
// Only admins see the assignee filter, so only they need the agent list.
if (isAdmin.value) store.dispatch('agents/get');
await fetchCalls();
} finally {
isInitializing.value = false;
}
});
</script>
<template>
<div
v-if="isInitializing"
class="flex items-center justify-center w-full h-full bg-n-surface-1"
>
<Spinner :size="24" />
</div>
<CallsEmptyState v-else-if="!isVoiceEnabled" />
<section
v-else
class="flex flex-col w-full h-full overflow-hidden bg-n-surface-1"
>
<header class="px-6 pt-6 pb-4 shrink-0">
<div class="w-full">
<h1 class="text-xl font-medium text-n-slate-12">
{{ t('CALLS_PAGE.HEADER') }}
</h1>
<CallsFilterBar
v-model:activity="activity"
v-model:assignee-id="assigneeId"
v-model:inbox-id="inboxId"
class="mt-5"
:total-count="isFetching ? null : meta.count"
:agents="agents"
:inboxes="voiceInboxes"
:show-assignee="isAdmin"
/>
</div>
</header>
<main class="flex-1 px-6 overflow-y-auto">
<div class="w-full">
<div v-if="isFetching" class="flex items-center justify-center py-16">
<Spinner :size="24" />
</div>
<div
v-else-if="!calls.length"
class="flex items-center justify-center py-16"
>
<span class="text-base text-n-slate-11">
{{ t('CALLS_PAGE.EMPTY_STATE') }}
</span>
</div>
<template v-else>
<CallListItem v-for="call in calls" :key="call.id" :call="call" />
</template>
</div>
</main>
<footer v-if="calls.length" class="sticky bottom-0 shrink-0">
<PaginationFooter
:current-page="currentPage"
:total-items="meta.count"
:items-per-page="RESULTS_PER_PAGE"
@update:current-page="onPageChange"
/>
</footer>
</section>
</template>
@@ -0,0 +1,22 @@
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
import {
CONVERSATION_PERMISSIONS,
ROLES,
} from 'dashboard/constants/permissions';
import { frontendURL } from '../../../helper/URLHelper';
import CallsIndex from './pages/CallsIndex.vue';
export const routes = [
{
path: frontendURL('accounts/:accountId/calls'),
name: 'calls_dashboard_index',
component: CallsIndex,
meta: {
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
installationTypes: [
INSTALLATION_TYPES.CLOUD,
INSTALLATION_TYPES.ENTERPRISE,
],
},
},
];
@@ -1,5 +1,5 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { computed, onUnmounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
@@ -27,19 +27,49 @@ const selectedRange = ref('this_month');
const assistantId = computed(() => route.params.assistantId);
const stats = ref(null);
const isFetching = ref(false);
// Increments on every fetch so a response (or retry) from a superseded
// range/assistant can't clobber the latest request's state.
let fetchToken = 0;
let abortController = null;
const fetchStats = async () => {
try {
const { data } = await CaptainAssistant.getStats({
fetchToken += 1;
const token = fetchToken;
abortController?.abort();
abortController = new AbortController();
const { signal } = abortController;
stats.value = null;
isFetching.value = true;
const requestStats = () =>
CaptainAssistant.getStats({
assistantId: assistantId.value,
range: selectedRange.value,
signal,
});
stats.value = data;
let data = null;
try {
({ data } = await requestStats());
} catch {
stats.value = null;
// One silent retry before giving up, unless the request was aborted.
try {
if (token === fetchToken && !signal.aborted)
({ data } = await requestStats());
} catch {
data = null;
}
}
if (token !== fetchToken || signal.aborted) return;
stats.value = data;
isFetching.value = false;
};
onUnmounted(() => abortController?.abort());
watch([selectedRange, assistantId], fetchStats, { immediate: true });
// `direction` says whether a rising trend is good ('up'), bad ('down'), or
@@ -156,7 +186,7 @@ const closeDrilldown = () => {
<CoverageBanner :knowledge="stats?.knowledge" />
<WelcomeCard :range="selectedRange" />
<WelcomeCard :range="selectedRange" :stats="stats" />
<div
class="grid grid-cols-1 gap-px overflow-hidden border rounded-xl sm:grid-cols-2 lg:grid-cols-3 bg-n-weak border-n-weak"
@@ -169,7 +199,8 @@ const closeDrilldown = () => {
:trend="metric.trend"
:hint="metric.hint"
:trend-good="metric.trendGood"
:clickable="canDrilldown && Boolean(metric.metric)"
:loading="isFetching"
:clickable="canDrilldown && Boolean(metric.metric) && !isFetching"
@click="openDrilldown(metric)"
/>
</div>
@@ -1,6 +1,7 @@
import settings from './settings/settings.routes';
import conversation from './conversation/conversation.routes';
import { routes as searchRoutes } from '../../modules/search/search.routes';
import { routes as callRoutes } from './calls/routes';
import { routes as contactRoutes } from './contacts/routes';
import { routes as companyRoutes } from './companies/routes';
import { routes as notificationRoutes } from './notifications/routes';
@@ -25,6 +26,7 @@ export default {
...inboxRoutes,
...conversation.routes,
...settings.routes,
...callRoutes,
...contactRoutes,
...companyRoutes,
...searchRoutes,
@@ -18,9 +18,7 @@ export function useChannelConfig() {
// app id (not the 'none' sentinel) and the signup configuration id.
whatsapp: () =>
(!isOnChatwootCloud.value ||
isCloudFeatureEnabled(
FEATURE_FLAGS.WHATSAPP_EMBEDDED_SIGNUP_INBOX_CREATION
)) &&
isCloudFeatureEnabled(FEATURE_FLAGS.WHATSAPP_EMBEDDED_SIGNUP_FLOW)) &&
Boolean(installationConfig.whatsappAppId) &&
installationConfig.whatsappAppId !== 'none' &&
Boolean(installationConfig.whatsappConfigurationId),
@@ -135,7 +135,7 @@ onMounted(() => {
<BaseTableCell class="max-w-0">
<div class="flex items-center gap-4 min-w-0">
<Avatar
:name="bot.name"
:name="bot.name || ''"
:src="bot.thumbnail"
:size="40"
class="flex-shrink-0"
@@ -10,6 +10,7 @@ const START_VALUE = {
name: null,
description: null,
event_name: 'conversation_created',
execution_delay: null,
conditions: [
{
attribute_key: 'status',
@@ -6,6 +6,11 @@ import { useOperators } from 'dashboard/components-next/filter/operators';
import ConditionRow from 'dashboard/components-next/filter/ConditionRow.vue';
import AutomationActionInput from 'dashboard/components/widgets/AutomationActionInput.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import ToggleSwitch from 'dashboard/components-next/switch/Switch.vue';
import FilterSelect from 'dashboard/components-next/filter/inputs/FilterSelect.vue';
import MultiSelect from 'dashboard/components-next/filter/inputs/MultiSelect.vue';
import DurationInput from 'dashboard/components-next/input/DurationInput.vue';
import { DURATION_UNITS } from 'dashboard/components-next/input/constants';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import {
generateAutomationPayload,
@@ -14,6 +19,7 @@ import {
showActionInput,
} from 'dashboard/helper/automationHelper';
import { validateAutomation } from 'dashboard/helper/validations';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { AUTOMATION_RULE_EVENTS, AUTOMATION_ACTION_TYPES } from './constants';
const props = defineProps({
@@ -71,6 +77,28 @@ const INPUT_TYPE_MAP = {
date: 'date',
};
const DEFAULT_DELAY_MINUTES = 240; // 4 hours
const MIN_DELAY_MINUTES = 10;
const MAX_DELAY_MINUTES = 43200; // 30 days
// A delayed rule is expressed as one meaningful trigger instead of a raw event + conditions. Each
// trigger maps to the automation's event_name plus a preset condition: message_type for the two
// unresponsive cases (reply-chase / awaiting-agent), or a chosen status for conversation_updated.
const DELAYED_TRIGGERS = [
{ key: 'conversation_status', eventName: 'conversation_updated' },
{
key: 'customer_unresponsive',
eventName: 'message_created',
messageType: 'outgoing',
},
{
key: 'agent_unresponsive',
eventName: 'message_created',
messageType: 'incoming',
},
];
const DEFAULT_TRIGGER = DELAYED_TRIGGERS[0].key;
const DEFAULT_TRIGGER_STATUS = 'pending';
const { t } = useI18n();
const { isCloudFeatureEnabled } = useAccount();
const { operators } = useOperators();
@@ -78,6 +106,7 @@ const { operators } = useOperators();
const dialogRef = ref(null);
const conditionsRef = useTemplateRef('conditionsRef');
const errors = ref({});
const isDelayed = ref(false);
const isEditMode = computed(() => props.mode === 'edit');
@@ -184,6 +213,167 @@ const hasActionErrors = computed(() =>
Object.keys(errors.value).some(key => key.startsWith('action_'))
);
const allowsDelayedExecution = computed(() =>
isCloudFeatureEnabled(FEATURE_FLAGS.DELAYED_AUTOMATIONS)
);
// Trigger controls for the delayed flow. They own event_name + conditions while the wait is on.
// selectedTrigger / triggerStatus are plain values (FilterSelect); triggerInboxes is an array of
// { id, name } (MultiSelect) empty means the rule applies to every inbox.
const selectedTrigger = ref(DEFAULT_TRIGGER);
const triggerStatus = ref(DEFAULT_TRIGGER_STATUS);
const triggerInboxes = ref([]);
const isStatusTrigger = computed(
() => selectedTrigger.value === 'conversation_status'
);
// FilterSelect expects { label, value }.
const statusSelectOptions = computed(() =>
(props.getConditionDropdownValues('status') || [])
.filter(option => option.id !== 'all')
.map(option => ({ value: option.id, label: option.name }))
);
const triggerSelectOptions = computed(() =>
DELAYED_TRIGGERS.map(trigger => ({
value: trigger.key,
label: t(
`AUTOMATION.ADD.FORM.TRIGGER.OPTIONS.${trigger.key.toUpperCase()}`
),
}))
);
// MultiSelect expects (and returns) { id, name } options.
const inboxOptions = computed(
() => props.getConditionDropdownValues('inbox_id') || []
);
// What ends the wait, mirroring the backend episode that arms the rule. Shown to the user so
// they can predict when the rule runs. Conversation rules key on status; message rules key on
// the reply that ends the wait (customer reply for outgoing, agent reply for incoming).
const waitEndsKey = computed(() => {
if (eventName.value !== 'message_created') return 'STATUS';
const messageType = (automation.value?.conditions || []).find(
condition => condition.attribute_key === 'message_type'
);
const raw = Array.isArray(messageType?.values)
? messageType.values[0]
: messageType?.values;
// Raw create-mode values are strings ('outgoing'); edit-mode values are option objects.
const value = raw && typeof raw === 'object' ? raw.id : raw;
if (value === 'outgoing') return 'CUSTOMER_REPLY';
if (value === 'incoming') return 'AGENT_REPLY';
return 'GENERIC';
});
// DurationInput holds the wait in minutes and clamps to [MIN, MAX]; the unit is display-only.
const delayMinutes = ref(DEFAULT_DELAY_MINUTES);
const delayUnit = ref(DURATION_UNITS.HOURS);
const executionDelayInvalid = computed(
() => isDelayed.value && !Number.isFinite(delayMinutes.value)
);
// Show the wait in the largest whole unit (240 min 4 hours). Passed in by open() rather than
// read from `automation`, whose model prop only settles a tick later.
const syncDelayFromDelay = delay => {
isDelayed.value = Boolean(delay);
const minutes = delay || DEFAULT_DELAY_MINUTES;
if (minutes % 1440 === 0) delayUnit.value = DURATION_UNITS.DAYS;
else if (minutes % 60 === 0) delayUnit.value = DURATION_UNITS.HOURS;
else delayUnit.value = DURATION_UNITS.MINUTES;
delayMinutes.value = minutes;
};
watch([isDelayed, delayMinutes], () => {
if (!automation.value || !allowsDelayedExecution.value) return;
automation.value.execution_delay = isDelayed.value
? delayMinutes.value
: null;
});
const buildTriggerCondition = (attributeKey, values) => ({
attribute_key: attributeKey,
filter_operator: 'equal_to',
values,
query_operator: 'and',
custom_attribute_type: '',
});
// Write the selected trigger (plus optional inbox scope) onto the rule's event_name + conditions.
const applyDelayedTrigger = () => {
const trigger = DELAYED_TRIGGERS.find(
item => item.key === selectedTrigger.value
);
if (!automation.value || !trigger) return;
automation.value.event_name = trigger.eventName;
const conditions = [
trigger.messageType
? buildTriggerCondition('message_type', trigger.messageType)
: buildTriggerCondition('status', triggerStatus.value),
];
if (triggerInboxes.value.length) {
conditions.push(
buildTriggerCondition(
'inbox_id',
triggerInboxes.value.map(inbox => inbox.id)
)
);
}
automation.value.conditions = conditions;
};
// A single value is a raw string in create mode and an option object ({ id }) after edit-mode
// formatting; return its plain value either way.
const rawConditionValue = condition => {
const raw = Array.isArray(condition?.values)
? condition.values[0]
: condition?.values;
return raw && typeof raw === 'object' ? raw.id : raw;
};
// Populate the trigger controls from an existing delayed rule when editing.
const hydrateTriggerFromAutomation = () => {
const conditions = automation.value?.conditions || [];
const byKey = key => conditions.find(c => c.attribute_key === key);
const messageType = rawConditionValue(byKey('message_type'));
if (messageType === 'incoming') selectedTrigger.value = 'agent_unresponsive';
else if (messageType === 'outgoing')
selectedTrigger.value = 'customer_unresponsive';
else {
selectedTrigger.value = 'conversation_status';
triggerStatus.value =
rawConditionValue(byKey('status')) || DEFAULT_TRIGGER_STATUS;
}
const inboxValues = byKey('inbox_id')?.values || [];
const inboxIds = inboxValues.map(value =>
value && typeof value === 'object' ? value.id : value
);
triggerInboxes.value = inboxOptions.value.filter(inbox =>
inboxIds.includes(inbox.id)
);
};
// Turning the wait on (create) sets the default trigger's event + conditions.
watch(isDelayed, delayed => {
if (delayed && automation.value && !isEditMode.value) applyDelayedTrigger();
});
// Any trigger-control change re-derives event_name + conditions. After hydration this simply
// re-writes the same values, so it stays idempotent (no reference change no loop).
watch([selectedTrigger, triggerStatus, triggerInboxes], () => {
if (isDelayed.value) applyDelayedTrigger();
});
// Opening an existing delayed rule mirrors its event/conditions into the trigger controls.
watch(
() => automation.value,
() => {
if (isDelayed.value && automation.value) hydrateTriggerFromAutomation();
}
);
watch(
() => automation.value,
() => {
@@ -216,8 +406,9 @@ const syncCustomAttributeTypes = () => {
});
};
const open = () => {
const open = (executionDelay = null) => {
resetValidation();
syncDelayFromDelay(executionDelay);
dialogRef.value?.open();
};
@@ -230,8 +421,13 @@ const emitSaveAutomation = () => {
syncCustomAttributeTypes();
const conditionsValid = isConditionsValid();
errors.value = validateAutomation(automation.value);
if (allowsDelayedExecution.value && executionDelayInvalid.value) {
errors.value.execution_delay = true;
}
if (Object.keys(errors.value).length === 0 && conditionsValid) {
const payload = generateAutomationPayload(automation.value);
// The API rejects the param when the feature is off; existing values are kept server-side.
if (!allowsDelayedExecution.value) delete payload.execution_delay;
emit('save', payload, props.mode);
}
};
@@ -266,84 +462,163 @@ defineExpose({ open, close });
:error="errors.description ? $t('AUTOMATION.ADD.FORM.DESC.ERROR') : ''"
:placeholder="$t('AUTOMATION.ADD.FORM.DESC.PLACEHOLDER')"
/>
<div class="mb-6">
<label :class="{ error: errors.event_name }">
{{ $t('AUTOMATION.ADD.FORM.EVENT.LABEL') }}
<select
v-model="automation.event_name"
class="m-0"
@change="onEventChange()"
>
<option
v-for="event in automationRuleEvents"
:key="event.key"
:value="event.key"
>
{{ event.value }}
</option>
</select>
<span v-if="errors.event_name" class="message">
{{ $t('AUTOMATION.ADD.FORM.EVENT.ERROR') }}
<!-- Wait Start (choose the delay first, then the trigger) -->
<div v-if="allowsDelayedExecution" class="mb-6">
<div class="flex items-center justify-between gap-4">
<label class="mb-0" :class="{ error: errors.execution_delay }">
{{ $t('AUTOMATION.ADD.FORM.EXECUTE.LABEL') }}
</label>
<ToggleSwitch v-model="isDelayed" />
</div>
<div v-if="isDelayed" class="flex flex-wrap items-center gap-2 mt-2">
<span class="text-sm text-n-slate-11">
{{ $t('AUTOMATION.ADD.FORM.EXECUTE.AFTER_DELAY') }}
</span>
</label>
<p
v-if="!isEditMode && hasAutomationMutated"
class="text-xs text-right text-n-teal-10 pt-1"
>
{{ $t('AUTOMATION.FORM.RESET_MESSAGE') }}
</p>
</div>
<!-- Conditions Start -->
<section class="mb-5">
<label>
{{ $t('AUTOMATION.ADD.FORM.CONDITIONS.LABEL') }}
</label>
<ul
class="grid gap-4 list-none p-3 mb-4 outline outline-1 rounded-xl -outline-offset-1"
:class="
hasConditionErrors
? 'outline-n-ruby-5 bg-n-ruby-2/50'
: 'outline-n-weak dark:outline-n-strong'
"
>
<template v-for="(condition, i) in automation.conditions" :key="i">
<ConditionRow
v-if="i === 0"
ref="conditionsRef"
v-model:attribute-key="automation.conditions[i].attribute_key"
v-model:filter-operator="automation.conditions[i].filter_operator"
v-model:values="automation.conditions[i].values"
:filter-types="filterTypes"
:show-query-operator="false"
@remove="removeFilter(i)"
/>
<ConditionRow
v-else
ref="conditionsRef"
v-model:attribute-key="automation.conditions[i].attribute_key"
v-model:filter-operator="automation.conditions[i].filter_operator"
v-model:query-operator="
automation.conditions[i - 1].query_operator
"
v-model:values="automation.conditions[i].values"
:filter-types="filterTypes"
show-query-operator
@remove="removeFilter(i)"
/>
</template>
<div>
<NextButton
icon="i-lucide-plus"
blue
faded
sm
:label="$t('AUTOMATION.ADD.CONDITION_BUTTON_LABEL')"
@click="appendNewCondition"
<div class="flex items-center gap-2 w-64">
<DurationInput
v-model="delayMinutes"
v-model:unit="delayUnit"
:min="MIN_DELAY_MINUTES"
:max="MAX_DELAY_MINUTES"
/>
</div>
</ul>
</section>
<!-- Conditions End -->
</div>
<span
v-if="isDelayed && executionDelayInvalid"
class="text-xs text-n-ruby-9"
>
{{ $t('AUTOMATION.ADD.FORM.EXECUTE.ERROR') }}
</span>
</div>
<!-- Wait End -->
<!-- Delayed trigger: a curated event + condition, in place of raw Event/Conditions -->
<div v-if="isDelayed" class="mb-6">
<label class="mb-1">
{{ $t('AUTOMATION.ADD.FORM.TRIGGER.LABEL') }}
</label>
<div
class="flex flex-col gap-3 p-4 outline outline-1 -outline-offset-1 rounded-xl outline-n-weak dark:outline-n-strong"
>
<div class="flex items-center gap-3 min-h-8">
<span class="w-20 shrink-0 text-sm text-n-slate-11">
{{ $t('AUTOMATION.ADD.FORM.TRIGGER.WHEN_LABEL') }}
</span>
<FilterSelect
v-model="selectedTrigger"
:options="triggerSelectOptions"
/>
</div>
<div v-if="isStatusTrigger" class="flex items-center gap-3 min-h-8">
<span class="w-20 shrink-0 text-sm text-n-slate-11">
{{ $t('AUTOMATION.ADD.FORM.TRIGGER.STATUS_LABEL') }}
</span>
<FilterSelect
v-model="triggerStatus"
:options="statusSelectOptions"
/>
</div>
<div class="flex items-center gap-3 min-h-8">
<span class="w-20 shrink-0 text-sm text-n-slate-11">
{{ $t('AUTOMATION.ADD.FORM.TRIGGER.INBOX_LABEL') }}
</span>
<MultiSelect v-model="triggerInboxes" :options="inboxOptions" />
</div>
</div>
<p class="text-xs text-n-slate-11 pt-2 mb-0">
<span class="text-n-slate-12 font-medium">
{{ $t('AUTOMATION.ADD.FORM.EXECUTE.ENDS_IF_LABEL') }}
</span>
{{ $t(`AUTOMATION.ADD.FORM.EXECUTE.ENDS_IF.${waitEndsKey}`) }}
</p>
<p class="text-xs text-n-slate-11 pt-1 mb-0">
{{ $t('AUTOMATION.ADD.FORM.EXECUTE.HELP_TEXT') }}
</p>
</div>
<!-- Instant flow: raw Event + Conditions -->
<template v-else>
<div class="mb-6">
<label :class="{ error: errors.event_name }">
{{ $t('AUTOMATION.ADD.FORM.EVENT.LABEL') }}
<select
v-model="automation.event_name"
class="m-0"
@change="onEventChange()"
>
<option
v-for="event in automationRuleEvents"
:key="event.key"
:value="event.key"
>
{{ event.value }}
</option>
</select>
<span v-if="errors.event_name" class="message">
{{ $t('AUTOMATION.ADD.FORM.EVENT.ERROR') }}
</span>
</label>
<p
v-if="!isEditMode && hasAutomationMutated"
class="text-xs text-right text-n-teal-10 pt-1"
>
{{ $t('AUTOMATION.FORM.RESET_MESSAGE') }}
</p>
</div>
<!-- Conditions Start -->
<section class="mb-5">
<label>
{{ $t('AUTOMATION.ADD.FORM.CONDITIONS.LABEL') }}
</label>
<ul
class="grid gap-4 list-none p-3 mb-4 outline outline-1 rounded-xl -outline-offset-1"
:class="
hasConditionErrors
? 'outline-n-ruby-5 bg-n-ruby-2/50'
: 'outline-n-weak dark:outline-n-strong'
"
>
<template v-for="(condition, i) in automation.conditions" :key="i">
<ConditionRow
v-if="i === 0"
ref="conditionsRef"
v-model:attribute-key="automation.conditions[i].attribute_key"
v-model:filter-operator="
automation.conditions[i].filter_operator
"
v-model:values="automation.conditions[i].values"
:filter-types="filterTypes"
:show-query-operator="false"
@remove="removeFilter(i)"
/>
<ConditionRow
v-else
ref="conditionsRef"
v-model:attribute-key="automation.conditions[i].attribute_key"
v-model:filter-operator="
automation.conditions[i].filter_operator
"
v-model:query-operator="
automation.conditions[i - 1].query_operator
"
v-model:values="automation.conditions[i].values"
:filter-types="filterTypes"
show-query-operator
@remove="removeFilter(i)"
/>
</template>
<div>
<NextButton
icon="i-lucide-plus"
blue
faded
sm
:label="$t('AUTOMATION.ADD.CONDITION_BUTTON_LABEL')"
@click="appendNewCondition"
/>
</div>
</ul>
</section>
<!-- Conditions End -->
</template>
<!-- Actions Start -->
<section>
<label>
@@ -1,6 +1,7 @@
<script setup>
import { computed } from 'vue';
import { messageStamp } from 'shared/helpers/timeHelper';
import { formatDelay } from 'dashboard/helper/automationHelper';
import Button from 'dashboard/components-next/button/Button.vue';
import ToggleSwitch from 'dashboard/components-next/switch/Switch.vue';
import { BaseTableRow, BaseTableCell } from 'dashboard/components-next/table';
@@ -43,6 +44,16 @@ const automationActive = computed({
<span class="text-body-main text-n-slate-12 truncate">
{{ automation.name }}
</span>
<span
v-if="automation.execution_delay"
class="text-xs px-1.5 py-0.5 rounded-md bg-n-alpha-2 text-n-slate-11 whitespace-nowrap flex-shrink-0"
>
{{
$t('AUTOMATION.LIST.DELAY_BADGE', {
delay: formatDelay(automation.execution_delay),
})
}}
</span>
<div class="w-px h-3 rounded-lg bg-n-weak flex-shrink-0" />
<span class="text-body-main text-n-slate-11 truncate">
{{ automation.description }}
@@ -34,29 +34,33 @@ const {
const { formatAutomation } = useEditableAutomation();
const open = () => formRef.value?.open();
const syncAutomationFromSelected = (source = props.selectedResponse) => {
if (!source?.conditions) return;
manifestCustomAttributes();
automation.value = formatAutomation(
source,
allCustomAttributes.value,
automationTypes,
AUTOMATION_ACTION_TYPES
);
};
// Format from the rule passed to open(): the prop updates a tick later, so at open() time
// automation still holds the previously selected rule (its execution_delay hydrates the form).
const open = rule => {
syncAutomationFromSelected(rule);
formRef.value?.open(rule?.execution_delay);
};
const close = () => formRef.value?.close();
const onSave = (payload, mode) => {
emit('saveAutomation', payload, mode);
};
watch(
() => props.selectedResponse,
value => {
if (!value?.conditions) return;
manifestCustomAttributes();
automation.value = formatAutomation(
value,
allCustomAttributes.value,
automationTypes,
AUTOMATION_ACTION_TYPES
);
},
{ immediate: true }
);
watch(() => props.selectedResponse, syncAutomationFromSelected, {
immediate: true,
});
defineExpose({ open, close });
</script>
@@ -35,6 +35,31 @@ const filteredRecords = computed(() => {
if (!query) return records.value;
return picoSearch(records.value, query, ['name', 'description']);
});
// Delayed (wait) rules run on a different lifecycle, so list them in their own section.
const hasDelayedRecords = computed(() =>
records.value.some(automation => automation.execution_delay)
);
const sections = computed(() => {
const instant = [];
const delayed = [];
filteredRecords.value.forEach(automation =>
(automation.execution_delay ? delayed : instant).push(automation)
);
return [
{
key: 'instant',
label: t('AUTOMATION.LIST.SECTIONS.INSTANT'),
items: instant,
},
{
key: 'delayed',
label: t('AUTOMATION.LIST.SECTIONS.DELAYED'),
items: delayed,
},
].filter(section => section.items.length);
});
const uiFlags = computed(() => getters['automations/getUIFlags'].value);
const accountId = computed(() => getters.getCurrentAccountId.value);
@@ -52,6 +77,14 @@ const isSLAEnabled = computed(() =>
getters['accounts/isFeatureEnabledonAccount'].value(accountId.value, 'sla')
);
const showDelayDisabledBanner = computed(
() =>
!getters['accounts/isFeatureEnabledonAccount'].value(
accountId.value,
'delayed_automations'
) && records.value.some(automation => automation.execution_delay)
);
onMounted(() => {
store.dispatch('inboxes/get');
store.dispatch('agents/get');
@@ -74,7 +107,7 @@ const hideAddPopup = () => {
const openEditPopup = response => {
selectedAutomation.value = { ...response };
editDialogRef.value?.open();
editDialogRef.value?.open(response);
};
const hideEditPopup = () => {
editDialogRef.value?.close();
@@ -128,11 +161,11 @@ const submitAutomation = async (payload, mode) => {
hideAddPopup();
hideEditPopup();
} catch (error) {
const errorMessage =
const fallbackMessage =
mode === 'edit'
? t('AUTOMATION.EDIT.API.ERROR_MESSAGE')
: t('AUTOMATION.ADD.API.ERROR_MESSAGE');
useAlert(errorMessage);
useAlert(error?.response?.data?.error || fallbackMessage);
}
};
const toggleAutomation = async ({ id, name, status }) => {
@@ -212,25 +245,49 @@ const tableHeaders = computed(() => {
</BaseSettingsHeader>
</template>
<template #body>
<div
v-if="showDelayDisabledBanner"
class="px-4 py-3 mb-4 text-sm rounded-lg bg-n-amber-3 text-n-amber-12"
>
{{ $t('AUTOMATION.LIST.DELAY_DISABLED_BANNER') }}
</div>
<template v-if="filteredRecords.length">
<div
v-for="section in sections"
:key="section.key"
class="mb-6 last:mb-0"
>
<h4
v-if="hasDelayedRecords"
class="mb-2 text-sm font-medium text-n-slate-11"
>
{{ section.label }}
</h4>
<BaseTable :headers="tableHeaders" :items="section.items">
<template #row="{ items }">
<AutomationRuleRow
v-for="automation in items"
:key="automation.id"
:automation="automation"
:loading="loading[automation.id]"
@clone="cloneAutomation"
@toggle="toggleAutomation"
@edit="openEditPopup"
@delete="openDeletePopup"
/>
</template>
</BaseTable>
</div>
</template>
<BaseTable
v-else
:headers="tableHeaders"
:items="filteredRecords"
:items="[]"
:no-data-message="
searchQuery ? $t('AUTOMATION.NO_RESULTS') : $t('AUTOMATION.LIST.404')
"
>
<template #row="{ items }">
<AutomationRuleRow
v-for="automation in items"
:key="automation.id"
:automation="automation"
:loading="loading[automation.id]"
@clone="cloneAutomation"
@toggle="toggleAutomation"
@edit="openEditPopup"
@delete="openDeletePopup"
/>
</template>
<template #row />
</BaseTable>
</template>
@@ -1,5 +1,5 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store';
@@ -14,7 +14,7 @@ const { accountId, currentAccount } = useAccount();
const globalConfig = useMapGetter('globalConfig/get');
const enabledFeatures = ref({});
const enabledFeatures = computed(() => currentAccount.value?.features || {});
const hasTiktokConfigured = computed(() => {
return window.chatwootConfig?.tiktokAppId;
@@ -105,10 +105,6 @@ const channelList = computed(() => {
return channels;
});
const initializeEnabledFeatures = async () => {
enabledFeatures.value = currentAccount.value.features;
};
const initChannelAuth = channel => {
const params = {
sub_page: channel,
@@ -116,10 +112,6 @@ const initChannelAuth = channel => {
};
router.push({ name: 'settings_inboxes_page_channel', params });
};
onMounted(() => {
initializeEnabledFeatures();
});
</script>
<template>
@@ -390,6 +390,11 @@ export default {
return (
this.isAWhatsAppCloudChannel &&
this.isEmbeddedSignupWhatsApp &&
(!this.isOnChatwootCloud ||
this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.WHATSAPP_EMBEDDED_SIGNUP_FLOW
)) &&
this.inbox.reauthorization_required
);
},
@@ -42,9 +42,7 @@ const shouldShowWhatsappEmbeddedSignup = computed(() => {
selectedProvider.value === PROVIDER_TYPES.WHATSAPP &&
hasWhatsappAppId.value &&
(!isOnChatwootCloud.value ||
isCloudFeatureEnabled(
FEATURE_FLAGS.WHATSAPP_EMBEDDED_SIGNUP_INBOX_CREATION
))
isCloudFeatureEnabled(FEATURE_FLAGS.WHATSAPP_EMBEDDED_SIGNUP_FLOW))
);
});
@@ -3,6 +3,7 @@ import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Avatar from 'next/avatar/Avatar.vue';
import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue';
import { useBranding } from 'shared/composables/useBranding';
const props = defineProps({
senderNameType: {
@@ -22,6 +23,7 @@ const props = defineProps({
const emit = defineEmits(['update']);
const { t } = useI18n();
const { replaceInstallationName } = useBranding();
const senderNameKeyOptions = computed(() => [
{
@@ -30,7 +32,7 @@ const senderNameKeyOptions = computed(() => [
content: t('INBOX_MGMT.EDIT.SENDER_NAME_SECTION.FRIENDLY.SUBTITLE'),
preview: {
senderName: 'Smith',
businessName: 'Chatwoot',
businessName: replaceInstallationName('Chatwoot'),
email: '<support@yourbusiness.com>',
},
},
@@ -40,7 +42,7 @@ const senderNameKeyOptions = computed(() => [
content: t('INBOX_MGMT.EDIT.SENDER_NAME_SECTION.PROFESSIONAL.SUBTITLE'),
preview: {
senderName: '',
businessName: 'Chatwoot',
businessName: replaceInstallationName('Chatwoot'),
email: '<support@yourbusiness.com>',
},
},
@@ -51,7 +53,7 @@ const isKeyOptionFriendly = key => key === 'friendly';
const userName = keyOption =>
isKeyOptionFriendly(keyOption.key)
? keyOption.preview.senderName
: keyOption.preview.businessName;
: props.businessName || keyOption.preview.businessName;
const toggleSenderNameType = key => {
emit('update', key);
@@ -56,6 +56,7 @@ export default {
...mapGetters({
accountId: 'getCurrentAccountId',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
}),
isEmbeddedSignupWhatsApp() {
return this.inbox.provider_config?.source === 'embedded_signup';
@@ -65,7 +66,9 @@ export default {
this.isEmbeddedSignupWhatsApp &&
this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.WHATSAPP_RECONFIGURE
this.isOnChatwootCloud
? FEATURE_FLAGS.WHATSAPP_EMBEDDED_SIGNUP_FLOW
: FEATURE_FLAGS.WHATSAPP_RECONFIGURE
)
);
},
@@ -0,0 +1,62 @@
import CaptainAgentSessionsAPI from 'dashboard/api/captain/agentSessions';
import camelcaseKeys from 'camelcase-keys';
const SET_SESSION = 'SET_SESSION';
const SET_FETCHING = 'SET_FETCHING';
// Session capture runs right after the message is broadcast (and well after,
// for handoff notes created mid-run), so a 404 on a fresh message may just
// mean the session isn't written yet. Skip caching those so a later
// hover/click retries; older misses are permanent (V1 messages, failed runs).
const RECENT_MESSAGE_WINDOW_SECONDS = 60;
// Caches Captain agent-session metadata per message id. A missing session
// (404) is cached as null so the UI shows an empty state without refetching.
export default {
namespaced: true,
state: {
sessions: {},
fetchingIds: [],
},
getters: {
getSessionByMessageId: state => messageId => state.sessions[messageId],
isFetching: state => messageId => state.fetchingIds.includes(messageId),
hasFetched: state => messageId => messageId in state.sessions,
},
actions: {
fetch: async ({ state, commit }, { messageId, createdAt }) => {
if (messageId in state.sessions) return;
if (state.fetchingIds.includes(messageId)) return;
commit(SET_FETCHING, { messageId, isFetching: true });
try {
const { data } = await CaptainAgentSessionsAPI.show(messageId);
commit(SET_SESSION, {
messageId,
session: camelcaseKeys(data, { deep: true }),
});
} catch (error) {
const isRecentMessage =
createdAt &&
Date.now() / 1000 - createdAt < RECENT_MESSAGE_WINDOW_SECONDS;
// Only a 404 means "no session exists"; transient failures (5xx,
// network errors) stay uncached so a later hover retries.
if (error.response?.status === 404 && !isRecentMessage) {
commit(SET_SESSION, { messageId, session: null });
}
} finally {
commit(SET_FETCHING, { messageId, isFetching: false });
}
},
},
mutations: {
[SET_SESSION](state, { messageId, session }) {
state.sessions = { ...state.sessions, [messageId]: session };
},
[SET_FETCHING](state, { messageId, isFetching }) {
state.fetchingIds = isFetching
? [...state.fetchingIds, messageId]
: state.fetchingIds.filter(id => id !== messageId);
},
},
};
+2
View File
@@ -50,6 +50,7 @@ import teamMembers from './modules/teamMembers';
import teams from './modules/teams';
import userNotificationSettings from './modules/userNotificationSettings';
import webhooks from './modules/webhooks';
import captainAgentSessions from './captain/agentSessions';
import captainAssistants from './captain/assistant';
import captainDocuments from './captain/document';
import captainResponses from './captain/response';
@@ -115,6 +116,7 @@ export default createStore({
teams,
userNotificationSettings,
webhooks,
captainAgentSessions,
captainAssistants,
captainDocuments,
captainResponses,
@@ -7,6 +7,7 @@ import FBChannel from '../../api/channel/fbChannel';
import TwilioChannel from '../../api/channel/twilioChannel';
import WhatsappChannel from '../../api/channel/whatsappChannel';
import { throwErrorMessage } from '../utils/api';
import { isSendableTemplate } from '@chatwoot/utils';
import AnalyticsHelper from '../../helper/AnalyticsHelper';
import camelcaseKeys from 'camelcase-keys';
import { ACCOUNT_EVENTS } from '../../helper/AnalyticsHelper/events';
@@ -67,45 +68,8 @@ export const getters = {
return [];
}
return templates.filter(template => {
// Ensure template has required properties
if (!template || !template.status || !template.components) {
return false;
}
// Only show approved templates
if (template.status.toLowerCase() !== 'approved') {
return false;
}
// Filter out authentication templates
if (template.category === 'AUTHENTICATION') {
return false;
}
// Filter out CSAT templates (customer_satisfaction_survey and its versions)
if (
template.name &&
template.name.startsWith('customer_satisfaction_survey')
) {
return false;
}
// Filter out interactive templates (LIST, PRODUCT, CATALOG), location templates, and call permission templates
const hasUnsupportedComponents = template.components.some(
component =>
['LIST', 'PRODUCT', 'CATALOG', 'CALL_PERMISSION_REQUEST'].includes(
component.type
) ||
(component.type === 'HEADER' && component.format === 'LOCATION')
);
if (hasUnsupportedComponents) {
return false;
}
return true;
});
// Sendable-template filtering is shared with the mobile app via @chatwoot/utils.
return templates.filter(isSendableTemplate);
},
getNewConversationInboxes($state) {
return $state.records.filter(inbox => {
@@ -0,0 +1,40 @@
import camelcaseKeys from 'camelcase-keys';
import CallsAPI from 'dashboard/api/calls';
import { throwErrorMessage } from 'dashboard/store/utils/api';
import { defineStore } from 'pinia';
export const useCallHistoryStore = defineStore('callHistory', {
state: () => ({
records: [],
meta: { count: 0, currentPage: 1, totalPages: 0 },
uiFlags: { isFetching: false },
fetchRequestToken: 0,
}),
actions: {
async fetchCalls(params = {}) {
this.uiFlags.isFetching = true;
this.fetchRequestToken += 1;
const requestToken = this.fetchRequestToken;
try {
const { data } = await CallsAPI.get(params);
// A newer fetch (filter/page change) superseded this one; drop the result.
if (this.fetchRequestToken !== requestToken) return this.records;
this.records = camelcaseKeys(data.payload, { deep: true });
this.meta = camelcaseKeys(data.meta);
return this.records;
} catch (error) {
// Don't surface errors from a fetch that a newer request already replaced.
if (this.fetchRequestToken !== requestToken) return this.records;
// Drop the previous results so stale rows aren't shown under the new view.
this.records = [];
this.meta = { count: 0, currentPage: 1, totalPages: 0 };
return throwErrorMessage(error);
} finally {
if (this.fetchRequestToken === requestToken) {
this.uiFlags.isFetching = false;
}
}
},
},
});
@@ -0,0 +1,117 @@
import { setActivePinia, createPinia } from 'pinia';
import CallsAPI from 'dashboard/api/calls';
import { throwErrorMessage } from 'dashboard/store/utils/api';
import { useCallHistoryStore } from '../callHistory';
vi.mock('dashboard/api/calls', () => ({
default: {
get: vi.fn(),
},
}));
vi.mock('dashboard/store/utils/api', () => ({
throwErrorMessage: vi.fn(error => error),
}));
const createDeferred = () => {
let resolve;
const promise = new Promise(res => {
resolve = res;
});
return { promise, resolve };
};
const buildResponse = (payload, meta) => ({ data: { payload, meta } });
describe('callHistory store', () => {
beforeEach(() => {
setActivePinia(createPinia());
vi.clearAllMocks();
});
it('fetches calls and stores camelized records and meta', async () => {
CallsAPI.get.mockResolvedValue(
buildResponse(
[{ id: 1, recording_url: 'rec.mp3', contact: { phone_number: '+1' } }],
{ count: 44, current_page: 1, total_pages: 2 }
)
);
const store = useCallHistoryStore();
await store.fetchCalls({ page: 1, status: 'no-answer' });
expect(CallsAPI.get).toHaveBeenCalledWith({ page: 1, status: 'no-answer' });
expect(store.records).toEqual([
{ id: 1, recordingUrl: 'rec.mp3', contact: { phoneNumber: '+1' } },
]);
expect(store.meta).toEqual({ count: 44, currentPage: 1, totalPages: 2 });
expect(store.uiFlags.isFetching).toBe(false);
});
it('drops a superseded response that resolves after the latest one', async () => {
const firstRequest = createDeferred();
const secondRequest = createDeferred();
CallsAPI.get
.mockImplementationOnce(() => firstRequest.promise)
.mockImplementationOnce(() => secondRequest.promise);
const store = useCallHistoryStore();
const staleFetch = store.fetchCalls({ page: 1 });
const currentFetch = store.fetchCalls({ page: 2 });
secondRequest.resolve(
buildResponse([{ id: 2 }], { count: 1, current_page: 2, total_pages: 2 })
);
await currentFetch;
firstRequest.resolve(
buildResponse([{ id: 1 }], { count: 99, current_page: 1, total_pages: 9 })
);
await staleFetch;
expect(store.records).toEqual([{ id: 2 }]);
expect(store.meta.count).toBe(1);
expect(store.uiFlags.isFetching).toBe(false);
});
it('keeps fetching state when a superseded response resolves first', async () => {
const firstRequest = createDeferred();
const secondRequest = createDeferred();
CallsAPI.get
.mockImplementationOnce(() => firstRequest.promise)
.mockImplementationOnce(() => secondRequest.promise);
const store = useCallHistoryStore();
const staleFetch = store.fetchCalls({ page: 1 });
const currentFetch = store.fetchCalls({ page: 2 });
firstRequest.resolve(
buildResponse([{ id: 1 }], { count: 99, current_page: 1, total_pages: 9 })
);
await staleFetch;
expect(store.records).toEqual([]);
expect(store.uiFlags.isFetching).toBe(true);
secondRequest.resolve(
buildResponse([{ id: 2 }], { count: 1, current_page: 2, total_pages: 2 })
);
await currentFetch;
expect(store.records).toEqual([{ id: 2 }]);
expect(store.uiFlags.isFetching).toBe(false);
});
it('surfaces the error and resets fetching state on failure', async () => {
const error = new Error('Request failed');
CallsAPI.get.mockRejectedValue(error);
const store = useCallHistoryStore();
await store.fetchCalls();
expect(throwErrorMessage).toHaveBeenCalledWith(error);
expect(store.records).toEqual([]);
expect(store.uiFlags.isFetching).toBe(false);
});
});
@@ -1,6 +1,6 @@
import { setActivePinia, createPinia } from 'pinia';
import CompanyAPI from 'dashboard/api/companies';
import { useCompaniesStore } from './companies';
import { useCompaniesStore } from '../companies';
vi.mock('dashboard/api/companies', () => ({
default: {
@@ -68,6 +68,10 @@ const isAgentBot = computed(
() => props.selectedItem?.assignee_type === 'AgentBot'
);
const selectedItemName = computed(() =>
!props.selectedItem?.name && isAgentBot.value ? '-' : props.selectedItem?.name
);
const selectedThumbnail = computed(
() => props.selectedItem?.thumbnail || props.selectedItem?.avatar_url
);
@@ -95,16 +99,16 @@ const selectedThumbnail = computed(
<h4
v-else
class="items-center overflow-hidden text-sm leading-tight whitespace-nowrap text-ellipsis text-n-slate-12"
:title="selectedItem.name"
:title="selectedItemName"
>
{{ selectedItem.name }}
{{ selectedItemName }}
</h4>
</div>
<Avatar
v-if="hasValue && hasThumbnail && (isAgentBot || !hasIcon)"
:src="selectedThumbnail"
:status="selectedItem.availability_status"
:name="selectedItem.name"
:name="selectedItemName"
:icon-name="isAgentBot ? 'i-lucide-bot' : undefined"
:size="24"
hide-offline-status
@@ -53,7 +53,9 @@ export default {
computed: {
filteredOptions() {
return this.options.filter(option => {
return option.name.toLowerCase().includes(this.search.toLowerCase());
return (option.name || '')
.toLowerCase()
.includes(this.search.toLowerCase());
});
},
noResult() {
@@ -73,13 +73,13 @@ describe('useBranding', () => {
expect(result).toBe('Welcome to our platform');
});
it('should be case-sensitive for "Chatwoot"', () => {
it('should replace "Chatwoot" regardless of casing', () => {
const { replaceInstallationName } = useBranding();
const result = replaceInstallationName(
'Welcome to chatwoot and CHATWOOT'
'Welcome to chatwoot, Chatwoot and CHATWOOT'
);
expect(result).toBe('Welcome to chatwoot and CHATWOOT');
expect(result).toBe('Welcome to MyCompany, MyCompany and MyCompany');
});
it('should handle special characters in installation name', () => {
@@ -7,7 +7,8 @@ import { useMapGetter } from 'dashboard/composables/store.js';
export function useBranding() {
const globalConfig = useMapGetter('globalConfig/get');
/**
* Replaces "Chatwoot" in text with the installation name from global config
* Replaces "Chatwoot" (any casing) in text with the installation name from
* global config
* @param {string} text - The text to process
* @returns {string} - Text with "Chatwoot" replaced by installation name
*/
@@ -17,7 +18,7 @@ export function useBranding() {
const installationName = globalConfig.value?.installationName;
if (!installationName) return text;
return text.replace(/Chatwoot/g, installationName);
return text.replace(/chatwoot/gi, installationName);
};
return {
@@ -1,11 +1,12 @@
import {
messageStamp,
messageTimestamp,
dynamicTime,
dateFormat,
shortTimestamp,
dynamicTime,
getDayDifferenceFromNow,
hasOneDayPassed,
messageStamp,
messageTimestamp,
relativeDayTimestamp,
shortTimestamp,
} from 'shared/helpers/timeHelper';
beforeEach(() => {
@@ -37,6 +38,33 @@ describe('#messageTimestamp', () => {
});
});
describe('#relativeDayTimestamp', () => {
// System time is mocked to May 5, 2023 00:00 UTC.
const toUnix = date => Math.floor(date / 1000);
it('returns the time for timestamps from today', () => {
const today = toUnix(Date.UTC(2023, 4, 5, 15, 35, 0));
expect(relativeDayTimestamp(today, 'Yesterday')).toEqual('3:35 PM');
});
it('returns the supplied label for timestamps from yesterday', () => {
const yesterday = toUnix(Date.UTC(2023, 4, 4, 9, 0, 0));
expect(relativeDayTimestamp(yesterday, 'Yesterday')).toEqual('Yesterday');
});
it('returns a day and month for older timestamps in the current year', () => {
const earlierThisYear = toUnix(Date.UTC(2023, 1, 10, 12, 0, 0));
expect(relativeDayTimestamp(earlierThisYear, 'Yesterday')).toEqual(
'Feb 10'
);
});
it('returns a full date for timestamps from a previous year', () => {
const lastYear = toUnix(Date.UTC(2021, 1, 10, 12, 0, 0));
expect(relativeDayTimestamp(lastYear, 'Yesterday')).toEqual('Feb 10, 2021');
});
});
describe('#dynamicTime', () => {
it('returns correct value', () => {
Date.now = vi.fn(() => new Date(Date.UTC(2023, 1, 14)).valueOf());
@@ -1,6 +1,9 @@
import {
format,
isSameYear,
isThisYear,
isToday,
isYesterday,
fromUnixTime,
formatDistanceToNow,
differenceInDays,
@@ -33,6 +36,22 @@ export const messageTimestamp = (time, dateFormat = 'MMM d, yyyy') => {
return messageDate;
};
/**
* Formats a Unix timestamp relative to today: the time for today, a caller-
* supplied label for yesterday, and a date otherwise. The yesterday label is
* passed in so the caller keeps ownership of translation.
* @param {number} time - Unix timestamp.
* @param {string} yesterdayLabel - Localized label shown for yesterday.
* @returns {string} Formatted timestamp string.
*/
export const relativeDayTimestamp = (time, yesterdayLabel) => {
const date = fromUnixTime(time);
if (isToday(date)) return format(date, 'h:mm a');
if (isYesterday(date)) return yesterdayLabel;
if (isThisYear(date)) return format(date, 'MMM d');
return format(date, 'MMM d, yyyy');
};
/**
* Converts a Unix timestamp to a relative time string (e.g., 3 hours ago).
* @param {number} time - Unix timestamp.
@@ -0,0 +1,65 @@
class AutomationRules::ProcessPendingExecutionJob < ApplicationJob
queue_as :medium
discard_on ActiveJob::DeserializationError
def perform(pending_execution)
return if delayed_automations_disabled?
# Account flag off pauses (not skips): leave the row pending so re-enabling resumes it.
return unless pending_execution.account.feature_enabled?('delayed_automations')
# Atomic claim: a duplicate enqueue (overlapping sweep or stale reclaim) loses here and returns.
return unless pending_execution.claim!
skip_reason = skip_reason_for(pending_execution)
return pending_execution.update!(status: :skipped, skip_reason: skip_reason) if skip_reason
execute(pending_execution)
rescue StandardError => e
# Row stays `processing`; the next sweep reclaims and retries it once the lock goes stale.
ChatwootExceptionTracker.new(e, account: pending_execution.account).capture_exception
end
private
def skip_reason_for(pending_execution)
return 'expired' if pending_execution.due_at < AutomationRulePendingExecution::DUE_WINDOW.ago
structural_skip_reason(pending_execution) || behavioral_skip_reason(pending_execution)
end
def structural_skip_reason(pending_execution)
rule = pending_execution.automation_rule
return 'rule_inactive' if rule.nil? || !rule.active?
return 'conversation_gone' if pending_execution.conversation.nil?
nil
end
def behavioral_skip_reason(pending_execution)
return 'episode_moved' unless pending_execution.episode_current?
return AutomationRulePendingExecution::CONDITIONS_CHANGED_SKIP unless conditions_still_match?(pending_execution)
nil
end
def conditions_still_match?(pending_execution)
AutomationRules::ConditionsFilterService.new(
pending_execution.automation_rule,
pending_execution.conversation,
{ message: pending_execution.message }
).perform.present?
end
def execute(pending_execution)
AutomationRules::ActionService.new(
pending_execution.automation_rule,
pending_execution.account,
pending_execution.conversation
).perform
pending_execution.update!(status: :executed)
end
def delayed_automations_disabled?
GlobalConfig.get('DISABLE_DELAYED_AUTOMATIONS')['DISABLE_DELAYED_AUTOMATIONS']
end
end
@@ -0,0 +1,11 @@
class AutomationRules::ResumePausedExecutionsJob < ApplicationJob
# Enqueued the moment the account flag flips back on, ahead of the next sweep, so overdue rows
# are rescheduled before that sweep's per-row jobs could mark them expired.
queue_as :medium
discard_on ActiveJob::DeserializationError
def perform(account)
AutomationRulePendingExecution.reschedule_paused(account)
end
end
@@ -0,0 +1,32 @@
class AutomationRules::TriggerPendingExecutionsJob < ApplicationJob
queue_as :scheduled_jobs
DEFAULT_SWEEP_LIMIT = 1000
def perform
return if delayed_automations_disabled?
started_at = Time.current
purged = AutomationRulePendingExecution.purge_terminal!
rows = AutomationRulePendingExecution.sweepable.for_enabled_accounts.order(:due_at).limit(sweep_limit).to_a
rows.each { |row| AutomationRules::ProcessPendingExecutionJob.perform_later(row) }
log_summary(enqueued: rows.size, capped: rows.size >= sweep_limit, purged: purged, started_at: started_at)
end
private
def delayed_automations_disabled?
GlobalConfig.get('DISABLE_DELAYED_AUTOMATIONS')['DISABLE_DELAYED_AUTOMATIONS']
end
def sweep_limit
(InstallationConfig.find_by(name: 'AUTOMATION_PENDING_EXECUTIONS_SWEEP_LIMIT')&.value || DEFAULT_SWEEP_LIMIT).to_i
end
def log_summary(enqueued:, capped:, purged:, started_at:)
summary = { event: 'completed', enqueued: enqueued, capped: capped, purged: purged, duration_ms: ((Time.current - started_at) * 1000).round }
Rails.logger.info("[AutomationRules::TriggerPendingExecutionsJob] #{summary.to_json}")
end
end
+3
View File
@@ -19,6 +19,9 @@ class TriggerScheduledItemsJob < ApplicationJob
# Job to sync whatsapp templates
Channels::Whatsapp::TemplatesSyncSchedulerJob.perform_later
# Job to trigger pending executions
AutomationRules::TriggerPendingExecutionsJob.perform_later
end
end
+5 -5
View File
@@ -153,11 +153,11 @@ class Webhooks::WhatsappEventsJob < MutexApplicationJob
end
def get_channel_from_wb_payload(wb_params)
phone_number = "+#{wb_params[:entry].first[:changes].first.dig(:value, :metadata, :display_phone_number)}"
phone_number_id = wb_params[:entry].first[:changes].first.dig(:value, :metadata, :phone_number_id)
channel = Channel::Whatsapp.find_by(phone_number: phone_number)
# validate to ensure the phone number id matches the whatsapp channel
return channel if channel && channel.provider_config['phone_number_id'] == phone_number_id
metadata = wb_params[:entry].first[:changes].first.dig(:value, :metadata) || {}
Whatsapp::WebhookChannelFinderService.new(
display_phone_number: metadata[:display_phone_number],
phone_number_id: metadata[:phone_number_id]
).perform
end
end
+15 -2
View File
@@ -30,7 +30,7 @@ class AutomationRuleListener < BaseListener
rules.each do |rule|
conditions_match = ::AutomationRules::ConditionsFilterService.new(rule, message.conversation,
{ message: message, changed_attributes: changed_attributes }).perform
::AutomationRules::ActionService.new(rule, account, message.conversation).perform if conditions_match.present?
execute_rule(rule, account, message.conversation, message: message) if conditions_match.present?
end
end
@@ -52,7 +52,20 @@ class AutomationRuleListener < BaseListener
rules.each do |rule|
conditions_match = ::AutomationRules::ConditionsFilterService.new(rule, conversation, { changed_attributes: changed_attributes }).perform
AutomationRules::ActionService.new(rule, account, conversation).perform if conditions_match.present?
execute_rule(rule, account, conversation) if conditions_match.present?
end
end
# Delayed rules record a pending execution instead of acting; the sweep re-checks and
# runs them at due time. Flag off means no arming and no immediate fallback — a delayed
# message silently becoming instant is worse than skipping.
def execute_rule(rule, account, conversation, message: nil)
if rule.execution_delay.present?
return unless account.feature_enabled?('delayed_automations')
AutomationRulePendingExecution.schedule(rule: rule, conversation: conversation, message: message)
else
::AutomationRules::ActionService.new(rule, account, conversation).perform
end
end
+6
View File
@@ -65,6 +65,7 @@ class Account < ApplicationRecord
has_many :articles, dependent: :destroy_async, class_name: '::Article'
has_many :assignment_policies, dependent: :destroy_async
has_many :automation_rules, dependent: :destroy_async
has_many :automation_rule_pending_executions, dependent: :delete_all
has_many :macros, dependent: :destroy_async
has_many :campaigns, dependent: :destroy_async
has_many :canned_responses, dependent: :destroy_async
@@ -111,6 +112,7 @@ class Account < ApplicationRecord
before_validation :validate_limit_keys
after_create_commit :notify_creation
after_update_commit :clear_unread_conversation_counts_cache, if: :saved_change_to_feature_conversation_unread_counts?
after_update_commit :resume_delayed_automations, if: -> { saved_change_to_feature_delayed_automations? && feature_delayed_automations? }
after_destroy :remove_account_sequences
def agents
@@ -189,6 +191,10 @@ class Account < ApplicationRecord
::Conversations::UnreadCounts::Store.clear_account!(id)
end
def resume_delayed_automations
AutomationRules::ResumePausedExecutionsJob.perform_later(self)
end
trigger.after(:insert).for_each(:row) do
"execute format('create sequence IF NOT EXISTS conv_dpid_seq_%s', NEW.id);"
end
+50 -10
View File
@@ -2,16 +2,17 @@
#
# Table name: automation_rules
#
# id :bigint not null, primary key
# actions :jsonb not null
# active :boolean default(TRUE), not null
# conditions :jsonb not null
# description :text
# event_name :string not null
# name :string not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# id :bigint not null, primary key
# actions :jsonb not null
# active :boolean default(TRUE), not null
# conditions :jsonb not null
# description :text
# event_name :string not null
# execution_delay :integer
# name :string not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
#
# Indexes
#
@@ -21,7 +22,13 @@ class AutomationRule < ApplicationRecord
include Rails.application.routes.url_helpers
include Reauthorizable
EXECUTION_DELAY_RANGE = (10..43_200) # minutes: 10 min to 30 days
# Conversation-level delayed rules key their episode on status; only status and attributes
# that never change after the delay (inbox) are safe to also filter on.
DELAYED_CONVERSATION_ATTRIBUTES = %w[status inbox_id].freeze
belongs_to :account
has_many :pending_executions, class_name: 'AutomationRulePendingExecution', dependent: :delete_all
has_many_attached :files
validate :json_conditions_format
@@ -29,8 +36,13 @@ class AutomationRule < ApplicationRecord
validate :query_operator_presence
validate :query_operator_value
validates :account_id, presence: true
validates :execution_delay, numericality: { only_integer: true, in: EXECUTION_DELAY_RANGE }, allow_nil: true
validate :execution_delay_supported_conditions
validate :execution_delay_supported_event
after_update_commit :reauthorized!, if: -> { saved_change_to_conditions? }
# Discard rows armed under the old definition; they re-arm on the next matching event.
after_update :discard_stale_pending_executions, if: :execution_config_changed?
scope :active, -> { where(active: true) }
@@ -95,6 +107,34 @@ class AutomationRule < ApplicationRecord
end
end
# The fire-time re-check cannot reconstruct changed_attributes, so delayed rules
# cannot use attribute_changed conditions.
def execution_delay_supported_conditions
return if execution_delay.blank? || conditions.blank?
return if conditions.none? { |obj| obj['filter_operator'] == 'attribute_changed' }
errors.add(:execution_delay, 'cannot be used with attribute_changed conditions.')
end
# Conversation-level episodes key on status_changed_at alone. Mutable attributes would collapse
# distinct periods into one episode, so only status and immutable filters (inbox) are allowed.
def execution_delay_supported_event
return if execution_delay.blank? || conditions.blank? || event_name == 'message_created'
return if conditions.all? { |obj| DELAYED_CONVERSATION_ATTRIBUTES.include?(obj['attribute_key']) }
errors.add(:execution_delay, 'only supports status and inbox conditions for conversation-level events.')
end
def execution_config_changed?
saved_change_to_execution_delay? || saved_change_to_event_name? ||
saved_change_to_conditions? || saved_change_to_actions?
end
def discard_stale_pending_executions
# armed = pending + stale processing, which the sweep would otherwise reclaim.
pending_executions.armed.delete_all
end
def validate_single_condition(condition)
query_operator = condition['query_operator']
@@ -0,0 +1,180 @@
# == Schema Information
#
# Table name: automation_rule_pending_executions
#
# id :bigint not null, primary key
# due_at :datetime not null
# episode_key :string not null
# skip_reason :string
# status :integer default("pending"), not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# automation_rule_id :bigint not null
# conversation_id :bigint not null
# message_id :bigint
#
# Indexes
#
# index_automation_rule_pending_executions_on_account_id (account_id)
# index_automation_rule_pending_executions_on_automation_rule_id (automation_rule_id)
# index_automation_rule_pending_executions_on_conversation_id (conversation_id)
# index_automation_rule_pending_executions_on_status_and_due_at (status,due_at)
# uniq_automation_pending_execution_episode (automation_rule_id,conversation_id,episode_key) UNIQUE
#
class AutomationRulePendingExecution < ApplicationRecord
# Rows older than this never fire (bounds backlog replay after downtime).
DUE_WINDOW = 3.days
# A processing row whose lock is older than this is treated as abandoned and reclaimed.
STALE_PROCESSING_TIMEOUT = 15.minutes
# Terminal rows are purged after this to keep the table bounded.
RETENTION_WINDOW = 30.days
# Skip reason for a row cancelled only because conditions no longer matched at fire time; unlike
# other terminal reasons, a later qualifying message can re-arm it (see .schedule).
CONDITIONS_CHANGED_SKIP = 'conditions_changed'.freeze
belongs_to :automation_rule
belongs_to :conversation
belongs_to :account
belongs_to :message, optional: true
enum status: { pending: 0, processing: 1, executed: 2, skipped: 3 }
# Rows a sweep should hand to a worker: due pending rows, plus processing rows whose lock went stale.
scope :sweepable, lambda {
pending.where(due_at: ..Time.current).or(processing.where(updated_at: ...STALE_PROCESSING_TIMEOUT.ago))
}
# Non-terminal rows still bound to fire (a stale processing row is reclaimed by the sweep).
scope :armed, -> { where(status: [statuses[:pending], statuses[:processing]]) }
# Excludes rows whose account paused delayed automations, so one disabled account's backlog
# can't fill the sweep limit and starve enabled accounts (paused rows resume on re-enable).
scope :for_enabled_accounts, -> { joins(:account).merge(Account.feature_delayed_automations) }
def self.schedule(rule:, conversation:, message: nil)
key = arm_episode_key_for(conversation, message)
anchor = arm_anchor_for(conversation, message)
create!(
automation_rule: rule, conversation: conversation, account_id: conversation.account_id,
message_id: message&.id, episode_key: key, due_at: rule.execution_delay.minutes.since(anchor)
)
rescue ActiveRecord::RecordNotUnique
rearm_or_advance_episode(rule, conversation, key, message, anchor)
end
# The episode is already armed. Status episodes keep their first clock (a status change would
# give a new key), so only message episodes advance or re-arm here.
def self.rearm_or_advance_episode(rule, conversation, key, message, anchor)
return unless message
row = find_by!(automation_rule_id: rule.id, conversation_id: conversation.id, episode_key: key)
# Jobs can arrive out of order; only a strictly newer message advances or re-arms, so a late
# older message can't pull due_at backwards and fire before the delay elapses.
return unless message.id > row.message_id
due_at = rule.execution_delay.minutes.since(anchor)
if row.condition_skipped?
# A message episode key can recur (no new incoming reply) while conditions swing back into
# match, so a later qualifying message re-arms the condition-only skip instead of dropping.
row.update!(status: :pending, skip_reason: nil, due_at: due_at, message_id: message.id)
elsif !row.terminal?
# Track the newest qualifying message. Reply-chase advances due_at with each agent reply;
# awaiting-agent keeps its first clock (its anchor is the stable waiting_since, so due_at is
# unchanged). Re-anchoring a row still processing (its worker died mid-run) back to pending
# also keeps a stale reclaim from firing the old clock instead of the latest one.
row.update!(status: :pending, due_at: due_at, message_id: message.id)
end
end
# The wait is measured from when the qualifying event happened, not when this (possibly
# backlogged or retried) listener runs, so a late dispatch still fires on schedule. Mirrors
# the timestamps the episode keys track.
def self.arm_anchor_for(conversation, message)
if message.nil?
conversation.status_changed_at.presence || conversation.created_at
elsif message.incoming?
conversation.waiting_since.presence || message.created_at
else
message.created_at
end
end
# waiting_since is written just after MESSAGE_CREATED dispatches, so it can still be nil when
# an awaiting-agent episode arms. It becomes the starting message's created_at, so use that
# here; the strict fire-time key (episode_key_for) then matches once waiting_since is settled.
def self.arm_episode_key_for(conversation, message)
return episode_key_for(conversation, message) unless message&.incoming? && conversation.waiting_since.blank?
"awaiting_agent:#{microsecond_stamp(message.created_at)}"
end
# Microsecond integer, not a float: epoch seconds carry ~16 significant digits, past float64's
# precision, so an in-memory timestamp (arm time) and its DB-reloaded value (fire time) would
# round to different floats. strftime is exact on both. Sub-second distinguishes rapid episodes.
def self.microsecond_stamp(time)
time&.strftime('%s%6N') || '0'
end
# Episode keys identify one qualifying stretch of conversation state; when the recomputed
# key no longer matches, the episode ended and the pending action is cancelled at fire time.
def self.episode_key_for(conversation, message)
if message.nil?
# Sub-second precision so a resolve→reopen inside one second still ends the episode.
# Integer microseconds (not a float) so an in-memory arm and a DB-reloaded fire agree.
"status:#{microsecond_stamp(conversation.status_changed_at.presence || conversation.created_at)}"
elsif message.incoming?
# waiting_since is cleared on agent/bot reply, so a reply invalidates this episode. Strict
# here: at fire time a nil waiting_since means the agent replied (episode ended).
"awaiting_agent:#{microsecond_stamp(conversation.waiting_since)}"
else
# A new customer message changes the max incoming id, invalidating this episode.
"reply_chase:#{conversation.messages.incoming.maximum(:id) || 0}"
end
end
def self.purge_terminal!
where(status: [statuses[:executed], statuses[:skipped]], updated_at: ...RETENTION_WINDOW.ago)
.in_batches(of: 1000).delete_all
end
# Rows that came due while an account had delayed automations paused would expire the moment
# the sweep reaches them on resume. Reset their clock so pause/resume replays them (still
# subject to the fire-time episode/condition re-checks) instead of silently dropping them.
def self.reschedule_paused(account)
overdue = pending.where(account_id: account.id, due_at: ...DUE_WINDOW.ago)
overdue.find_each { |row| row.update!(due_at: Time.current) }
end
# Atomic claim: only one worker can move a row into processing, so a row re-enqueued by an
# overlapping sweep (or after a stale reclaim) cannot double-execute. Refreshing updated_at
# renews the lock, keeping the row out of the stale window while this worker holds it.
def claim!
with_lock do
next false unless claimable?
update!(status: :processing, updated_at: Time.current)
true
end
end
def episode_current?
self.class.episode_key_for(conversation, message) == episode_key
end
def condition_skipped?
skipped? && skip_reason == CONDITIONS_CHANGED_SKIP
end
def terminal?
executed? || skipped?
end
private
def claimable?
# due_at guard: a reply-chase reschedule can push due_at forward after this row was enqueued;
# such a row must wait for a later sweep instead of firing early.
(pending? && due_at <= Time.current) || (processing? && updated_at < STALE_PROCESSING_TIMEOUT.ago)
end
end
+7
View File
@@ -15,6 +15,7 @@
# priority :integer
# snoozed_until :datetime
# status :integer default("open"), not null
# status_changed_at :datetime
# uuid :uuid not null
# waiting_since :datetime
# created_at :datetime not null
@@ -124,8 +125,10 @@ class Conversation < ApplicationRecord
has_many :notifications, as: :primary_actor, dependent: :destroy_async
has_many :attachments, through: :messages
has_many :reporting_events, dependent: :destroy_async
has_many :automation_rule_pending_executions, dependent: :delete_all
before_save :ensure_snooze_until_reset
before_save :set_status_changed_at
before_create :determine_conversation_status
before_create :ensure_waiting_since
@@ -272,6 +275,10 @@ class Conversation < ApplicationRecord
self.snoozed_until = nil unless snoozed?
end
def set_status_changed_at
self.status_changed_at = Time.current if new_record? || status_changed?
end
def ensure_waiting_since
self.waiting_since = created_at
end
@@ -113,12 +113,14 @@ class Whatsapp::IncomingMessageBaseService
end
def set_conversation
# Scope reuse to the contact across all its contact_inboxes in this inbox: WhatsApp coexistence
# gives one contact multiple source_ids (phone + BSUID), so reopen must not be limited to a single contact_inbox.
conversations = @contact.conversations.where(inbox_id: @inbox.id)
# if lock to single conversation is disabled, we will create a new conversation if previous conversation is resolved
@conversation = if @inbox.lock_to_single_conversation
@contact_inbox.conversations.last
conversations.last
else
@contact_inbox.conversations
.where.not(status: :resolved).last
conversations.where.not(status: :resolved).last
end
return if @conversation
@@ -6,12 +6,10 @@ class Whatsapp::SendOnWhatsappService < Base::SendOnChannelService
end
def perform_reply
should_send_template_message = template_params.present? || !message.conversation.can_reply?
if should_send_template_message
send_template_message
else
send_session_message
end
return send_template_message if template_params.present?
return send_session_message if message.conversation.can_reply?
message.update!(status: :failed, external_error: I18n.t('errors.whatsapp.message_outside_messaging_window'))
end
def send_template_message
@@ -0,0 +1,35 @@
# Resolves the WhatsApp channel for an inbound WhatsApp Cloud webhook. Meta's
# display_phone_number can arrive formatted or in a country-specific variant (e.g. Brazil
# omits the mobile 9, Argentina adds a digit after the country code), so we try the
# raw digits first and then a normalized fallback, accepting only a candidate whose
# phone_number_id matches.
class Whatsapp::WebhookChannelFinderService
def initialize(display_phone_number:, phone_number_id:)
@display_phone_number = display_phone_number
@phone_number_id = phone_number_id
end
def perform
return if digits.blank?
candidates = [
Channel::Whatsapp.find_by(phone_number: "+#{digits}"),
channel_by_normalized_number
]
candidates.compact.find { |channel| channel.provider_config['phone_number_id'] == @phone_number_id }
end
private
def digits
@digits ||= @display_phone_number.to_s.gsub(/[^0-9]/, '')
end
def channel_by_normalized_number
normalizer = Whatsapp::PhoneNumberNormalizationService::NORMALIZERS
.lazy.map(&:new).find { |n| n.handles_country?(digits) }
return unless normalizer
Channel::Whatsapp.find_by(phone_number: "+#{normalizer.normalize(digits)}")
end
end
@@ -7,4 +7,5 @@ json.conditions automation_rule.conditions
json.actions automation_rule.actions
json.created_on automation_rule.created_at.to_i
json.active automation_rule.active?
json.execution_delay automation_rule.execution_delay
json.files automation_rule.file_base_data if automation_rule.files.any?
+6 -1
View File
@@ -265,6 +265,11 @@
enabled: false
column: feature_flags_ext_1
- name: whatsapp_embedded_signup_inbox_creation
display_name: WhatsApp Embedded Signup Inbox Creation
display_name: WhatsApp Embedded Signup Flow
enabled: false
column: feature_flags_ext_1
- name: delayed_automations
display_name: Delayed Automations
enabled: false
chatwoot_internal: true
column: feature_flags_ext_1
+27 -3
View File
@@ -31,10 +31,11 @@ class Rack::Attack
(default_allowed_ips + env_allowed_ips).include?(remote_ip)
end
# Rails would allow requests to paths with extensions, so lets compare against the path with extension stripped
# example /auth & /auth.json would both work
# Rails allows paths with extensions and trailing slashes, so compare against a normalized path.
# For example, /auth, /auth.json, and /auth/ should all use the same throttle.
def path_without_extensions
path[/^[^.]+/]
normalized_path = path[/^[^.]+/]
normalized_path == '/' ? normalized_path : normalized_path.sub(%r{/+\z}, '')
end
end
@@ -188,6 +189,11 @@ class Rack::Attack
throttle('widget?website_token={website_token}&cw_conversation={x-auth-token}', limit: 5, period: 1.hour) do |req|
req.ip if req.path_without_extensions == '/widget' && ActionDispatch::Request.new(req.env).params['cw_conversation'].blank?
end
## Prevent Transcript Bombing on Widget API ###
throttle('api/v1/widget/conversations/transcript', limit: 5, period: 1.hour) do |req|
req.ip if req.path_without_extensions == '/api/v1/widget/conversations/transcript' && req.post?
end
end
##-----------------------------------------------##
@@ -212,6 +218,24 @@ class Rack::Attack
match_data[:account_id] if match_data.present?
end
## Prevent abuse of agent create APIs (per account, covers bulk_create)
throttle('/api/v1/accounts/:account_id/agents POST',
limit: ENV.fetch('RATE_LIMIT_AGENT_CREATE', '100').to_i, period: 1.day) do |req|
next unless req.post?
match_data = %r{\A/api/v1/accounts/(?<account_id>\d+)/agents(?:/bulk_create)?/?\z}.match(req.path_without_extensions)
match_data[:account_id] if match_data.present?
end
## Prevent abuse of agent delete API (per account)
throttle('/api/v1/accounts/:account_id/agents/:id DELETE',
limit: ENV.fetch('RATE_LIMIT_AGENT_DELETE', '50').to_i, period: 1.day) do |req|
next unless req.delete?
match_data = %r{\A/api/v1/accounts/(?<account_id>\d+)/agents/(?<id>\d+)/?\z}.match(req.path_without_extensions)
match_data[:account_id] if match_data.present?
end
## Prevent Abuse of attachment upload APIs ##
throttle('/api/v1/accounts/:account_id/upload', limit: 60, period: 1.hour) do |req|
match_data = %r{/api/v1/accounts/(?<account_id>\d+)/upload}.match(req.path)
+7
View File
@@ -567,3 +567,10 @@
value: 'https://us.cloud.langfuse.com'
locked: false
## ---- End of LLM Observability ---- ##
- name: DISABLE_DELAYED_AUTOMATIONS
display_title: 'Disable delayed automations'
description: 'Emergency stop for delayed automation rules: halts the pending-execution sweep and per-row execution within one tick'
value: false
locked: false
type: boolean
+1
View File
@@ -154,6 +154,7 @@ en:
invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.'
phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.'
phone_number_already_exists: 'Channel already exists for this phone number: %{phone_number}, please contact support if the error persists'
message_outside_messaging_window: 'Message not sent because the WhatsApp 24-hour customer service window is closed and no template parameters were provided. Send an approved template message instead.'
reauthorization:
generic: 'Failed to reauthorize WhatsApp. Please try again.'
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
+1
View File
@@ -76,6 +76,7 @@ Rails.application.routes.draw do
resources :inboxes, only: [:index, :create, :destroy], param: :inbox_id
resources :scenarios
end
resources :agent_sessions, only: [:show]
resources :assistant_responses
resources :message_reports, only: [:create]
resources :bulk_actions, only: [:create]
@@ -0,0 +1,5 @@
class AddExecutionDelayToAutomationRules < ActiveRecord::Migration[7.0]
def change
add_column :automation_rules, :execution_delay, :integer
end
end
@@ -0,0 +1,5 @@
class AddStatusChangedAtToConversations < ActiveRecord::Migration[7.0]
def change
add_column :conversations, :status_changed_at, :datetime
end
end
@@ -0,0 +1,21 @@
class CreateAutomationRulePendingExecutions < ActiveRecord::Migration[7.0]
def change
create_table :automation_rule_pending_executions do |t|
t.references :automation_rule, null: false
t.references :conversation, null: false
t.references :account, null: false
t.bigint :message_id
t.datetime :due_at, null: false
t.string :episode_key, null: false
t.integer :status, null: false, default: 0
t.string :skip_reason
t.timestamps
end
add_index :automation_rule_pending_executions, [:status, :due_at]
add_index :automation_rule_pending_executions,
[:automation_rule_id, :conversation_id, :episode_key],
unique: true, name: 'uniq_automation_pending_execution_episode'
end
end
+20
View File
@@ -277,6 +277,24 @@ ActiveRecord::Schema[7.1].define(version: 2026_07_13_184351) do
t.index ["user_id", "user_type"], name: "user_index"
end
create_table "automation_rule_pending_executions", force: :cascade do |t|
t.bigint "automation_rule_id", null: false
t.bigint "conversation_id", null: false
t.bigint "account_id", null: false
t.bigint "message_id"
t.datetime "due_at", null: false
t.string "episode_key", null: false
t.integer "status", default: 0, null: false
t.string "skip_reason"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id"], name: "index_automation_rule_pending_executions_on_account_id"
t.index ["automation_rule_id", "conversation_id", "episode_key"], name: "uniq_automation_pending_execution_episode", unique: true
t.index ["automation_rule_id"], name: "index_automation_rule_pending_executions_on_automation_rule_id"
t.index ["conversation_id"], name: "index_automation_rule_pending_executions_on_conversation_id"
t.index ["status", "due_at"], name: "index_automation_rule_pending_executions_on_status_and_due_at"
end
create_table "automation_rules", force: :cascade do |t|
t.bigint "account_id", null: false
t.string "name", null: false
@@ -287,6 +305,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_07_13_184351) do
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.boolean "active", default: true, null: false
t.integer "execution_delay"
t.index ["account_id"], name: "index_automation_rules_on_account_id"
end
@@ -788,6 +807,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_07_13_184351) do
t.datetime "waiting_since"
t.text "cached_label_list"
t.bigint "assignee_agent_bot_id"
t.datetime "status_changed_at"
t.index ["account_id", "display_id"], name: "index_conversations_on_account_id_and_display_id", unique: true
t.index ["account_id", "id"], name: "index_conversations_on_id_and_account_id"
t.index ["account_id", "inbox_id", "status", "assignee_id"], name: "conv_acid_inbid_stat_asgnid_idx"
@@ -0,0 +1,25 @@
class Api::V1::Accounts::Captain::AgentSessionsController < Api::V1::Accounts::BaseController
before_action :set_message
before_action :authorize_conversation
def show
@agent_session = Current.account.captain_agent_sessions.find_by(result_type: 'Message', result_id: @message.id)
return head :not_found if @agent_session.blank?
@citations = Current.account.captain_assistant_responses
.where(id: @agent_session.faq_ids)
.includes(:documentable)
@scenario_titles = Captain::Scenario.where(account_id: Current.account.id, id: @agent_session.scenario_ids)
.pluck(:id, :title).to_h
end
private
def set_message
@message = Current.account.messages.find(params[:id])
end
def authorize_conversation
authorize @message.conversation, :show?
end
end
@@ -1,5 +1,4 @@
class Api::V1::Accounts::Captain::AssistantResponsesController < Api::V1::Accounts::BaseController
before_action :current_account
before_action -> { check_authorization(Captain::Assistant) }
before_action :set_current_page, only: [:index]
@@ -1,5 +1,4 @@
class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::BaseController
before_action :current_account
before_action -> { check_authorization(Captain::Assistant) }
before_action :set_assistant, only: [:show, :update, :destroy, :playground, :stats, :summary, :drilldown]
@@ -48,7 +47,8 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
end
def summary
result = cached_or_generated_summary(Captain::AssistantStatsBuilder.new(@assistant, params[:range], params[:timezone_offset]))
window = Captain::AssistantStatsWindow.new(params[:range], params[:timezone_offset])
result = cached_or_generated_summary(window, summary_stats)
if result[:error]
render json: { error: result[:error] }, status: :unprocessable_content
@@ -69,8 +69,8 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
params.permit(:metric, :range, :timezone_offset, :page, :per_page)
end
def cached_or_generated_summary(builder)
cache_key = summary_cache_key(builder.range)
def cached_or_generated_summary(window, stats)
cache_key = summary_cache_key(window.range)
cached = Rails.cache.read(cache_key)
return cached if cached
@@ -78,14 +78,25 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
account: Current.account,
assistant: @assistant,
first_name: Current.user.name.to_s.split.first,
stats: builder.metrics,
period: builder.period
stats: stats,
period: window.period
).perform
# Don't cache transient LLM/config failures, otherwise every reload returns 422 for the next hour.
Rails.cache.write(cache_key, result, expires_in: 1.hour) unless result[:error]
result
end
def summary_stats
params.require(:stats).permit(
conversations_handled: %i[current],
hours_saved: %i[current],
auto_resolution_rate: %i[current trend],
handoff_rate: %i[current trend],
reopen_rate: %i[current trend],
knowledge: %i[coverage approved documents]
).to_h.deep_symbolize_keys
end
def summary_cache_key(range)
"captain_overview_summary/#{@assistant.id}/#{Current.user.id}/#{range}/#{Date.current}"
end
@@ -1,5 +1,4 @@
class Api::V1::Accounts::Captain::BulkActionsController < Api::V1::Accounts::BaseController
before_action :current_account
before_action -> { check_authorization(Captain::Assistant) }
before_action :validate_params
before_action :type_matches?
@@ -1,5 +1,4 @@
class Api::V1::Accounts::Captain::CustomToolsController < Api::V1::Accounts::BaseController
before_action :current_account
before_action :ensure_custom_tools_enabled
before_action -> { check_authorization(Captain::CustomTool) }
before_action :set_custom_tool, only: [:show, :update, :destroy]

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