Compare commits

...
Author SHA1 Message Date
Shivam Mishra 8196936a62 fix: validate bot tokens 2026-06-11 21:35:37 +05:30
Shivam MishraandGitHub b26dea7879 Merge branch 'develop' into feat/read-only-token 2026-06-11 19:38:26 +05:30
Shivam Mishra 85776ec65c fix: inbox jbuilder 2026-06-11 19:37:53 +05:30
Sugar Ray LedesmaandGitHub 2663a8495d refactor(security): rename path_without_extensions in Rack::Attack (#14216)
Fix misspelling 'extentions' and clarify comments. Behavior unchanged:
same path-stripping logic for throttle matching (e.g. /auth and
/auth.json). Also correct 'You may' in remote_ip comment.
2026-06-11 18:36:34 +05:30
Sivin VargheseandGitHub d0d275d962 fix: cannot resize pasted images (#14709) 2026-06-11 16:47:11 +05:30
95d6aecb51 chore: Add security flags to session cookie configuration (#14248)
## Summary
- Adds `httponly` and `secure` flags to session cookie configuration
- Adds RSpec tests for session configuration

## Changes
- `config/initializers/session_store.rb`: add `httponly: true`, `secure:
FORCE_SSL`
- `spec/config/session_store_spec.rb`: tests for all session store
options

Ref #2683 (hardens the session cookie but does not remove it, sessions
are still needed for super_admin dashboard)

---------

Co-authored-by: Adam-Relay <adam@userelay.ai>
Co-authored-by: Adam Berger <adam@adam-berger.com>
Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com>
2026-06-11 16:13:57 +05:30
a901c87ab4 perf: merge 3 conversation COUNT queries in /meta into single query (#13536)
# Pull Request Template

## Description


- Merges 3 separate COUNT queries in
`ConversationFinder#set_count_for_all_conversations` into a single query
using PostgreSQL `COUNT(*) FILTER (WHERE ...)`
- Every call to `/meta` and conversation index fires 3 COUNT queries
against the conversations table — one for "mine", one for "unassigned",
one for "all". These share the same base query and scan the same rows,
but execute as 3 independent round-trips.

from pg_stats

| Query | Calls | Total DB Time | Avg Latency |
|-------|-------|--------------|-------------|
| COUNT unassigned | 4.1M | 141,081 sec | 34ms |
| COUNT all | 4.1M | 132,302 sec | 32ms |
| COUNT mine | 3.7M | 37,637 sec | 10ms |
| **Total** | **~12M** | **311,020 sec** | |

`/meta` alone runs at 2.3K RPM (NewRelic), triggered on every WebSocket
event (conversation created/updated/status changed/assignee changed).



Fixes
https://linear.app/chatwoot/issue/INF-43/make-single-query-to-count-the-assigned-unassigned-and-all-instead-of

## Type of change

- [x] Performance fix

## How Has This Been Tested?

- [x] Verify conversation list shows correct
mine/unassigned/assigned/all counts
- [x] Verify /meta endpoint returns correct counts
- [x] Verify counts update correctly when assigning/unassigning
conversations
- [x] Verify counts with team filter applied
- [x] Monitor DB query count reduction in NewRelic after deploy

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] 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
- [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

---------

Co-authored-by: Sony Mathew <ynos1234@gmail.com>
2026-06-11 16:10:48 +05:30
Sony MathewandGitHub 6d26e7930a fix(contacts): truncate inbound contact names (IMAP sender display names) (#14631)
## Description

Truncates long contact names in `ContactInboxWithContactBuilder` before
persisting them, so inbound IMAP sender display names over the
`contacts.name` 255-character limit no longer raise
`ActiveRecord::RecordInvalid` from `Inboxes::FetchImapEmailsJob`. This
keeps email ingestion moving while preserving the existing contact name
length constraint.

Linear ticket:
[https://linear.app/chatwoot/issue/CW-7263/sentry-active-record-invalid-contact-create-from-imap-fetch-job](https://linear.app/chatwoot/issue/CW-7263/sentry-active-record-invalid-contact-create-from-imap-fetch-job)

## Type of change

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

## How Has This Been Tested?

- [x] `bundle exec rspec
spec/builders/contact_inbox_with_contact_builder_spec.rb`
- [x] `bundle exec rspec spec/mailboxes/imap/imap_mailbox_spec.rb`
- [x] `bundle exec rubocop
app/builders/contact_inbox_with_contact_builder.rb
spec/builders/contact_inbox_with_contact_builder_spec.rb`

## 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
- [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
2026-06-11 15:15:14 +05:30
Tanmay Deep SharmaandGitHub 8d5d02ea97 feat: add per-inbox toggle to disable incoming calls (#14645)
## Description

Adds a per-inbox "Allow incoming calls" toggle for voice-enabled
WhatsApp and Twilio inboxes. When turned off, the setting is persisted
on the channel; actually rejecting inbound calls is handled in a
follow-up PR.

## Type of change

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

## Screenshot
<img width="804" height="384" alt="Screenshot 2026-06-04 at 11 44 07 AM"
src="https://github.com/user-attachments/assets/df8bb026-0387-4031-bcba-6d9a56872eb7"
/>


## 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
2026-06-11 14:22:44 +05:30
Shivam MishraandGitHub e408b5dbbd Merge branch 'develop' into feat/read-only-token 2026-06-11 14:15:39 +05:30
Shivam Mishra c633c35712 feat: hide integration app config when using read only token 2026-06-11 14:14:34 +05:30
ce93ddec78 fix: re-fetch widget conversation status on reconnect (#14683)
When an inbox has **"Allow messages after conversation is resolved"**
disabled, the live-chat widget should hide the reply box once a
conversation is resolved — but users could still send a reply, silently
reopening it. This syncs the widget's conversation status on reconnect
so the reply box hides correctly.

## Closes
- CW-7272

## How to reproduce
1. Disable **Allow messages after conversation is resolved** on an
inbox.
2. Open the widget, then let its websocket drop (background tab / lose
network).
3. While disconnected, get the conversation resolved (e.g. auto-resolve
on inactivity).
4. Reconnect → reply box is still visible and a sent message reopens the
resolved conversation.

## Why
Reply-box hiding is gated on the widget's local status, updated via the
`conversation.status_changed` socket event. If the resolve happens while
disconnected, the event is missed — and on reconnect the widget only
re-synced messages, never the status, so it stayed stale at `open`.

## What changed
- `onReconnect` now also dispatches
`conversationAttributes/getAttributes` to refresh status after a missed
event.
- Added a spec covering the reconnect behavior.

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2026-06-11 14:09:13 +05:30
Sivin VargheseandGitHub 940428c10e fix: preserve markdown links with unsafe href characters in help center content (#14686) 2026-06-11 13:47:08 +05:30
4d3196b02f feat: Show unread count for all conversations (#14627)
## Description

Adds the unread count for All Conversations to the left sidebar. The
unread counts API now returns an `all_count` aggregate based on the same
permission-scoped inbox counts already used for sidebar badges, and the
sidebar renders that backend-provided value on the All Conversations
item.

Fixes
[CW-7240](https://linear.app/chatwoot/issue/CW-7240/unread-count-for-all-conversations)

## Type of change

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality not to work as expected)
- [ ] This change requires a documentation update

## How Has This Been Tested?

- `bundle exec rspec
spec/services/conversations/unread_counts/counter_spec.rb
spec/enterprise/services/conversations/unread_counts/counter_spec.rb
spec/controllers/api/v1/accounts/conversations_controller_spec.rb`
- `pnpm test
app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/actions.spec.js
app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/getters.spec.js
app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/mutations.spec.js`
- `pnpm exec eslint
app/javascript/dashboard/components-next/sidebar/Sidebar.vue
app/javascript/dashboard/store/modules/conversationUnreadCounts.js
app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/actions.spec.js
app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/getters.spec.js
app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/mutations.spec.js`
- `bundle exec rubocop
app/services/conversations/unread_counts/counter.rb
spec/services/conversations/unread_counts/counter_spec.rb
spec/enterprise/services/conversations/unread_counts/counter_spec.rb
spec/controllers/api/v1/accounts/conversations_controller_spec.rb`

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

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-06-11 13:05:52 +05:30
Tanmay Deep SharmaandGitHub c6e5657ee5 fix(call): emit assignee activity message when agent answers a call (#14700)
## Linear Ticket
- https://linear.app/chatwoot/issue/CW-7249/debug-assignment-log

## Description

When an agent answers an inbound WhatsApp call on an unassigned
conversation, the conversation is claimed for that agent — but no
"assigned" activity message or assignee-changed event was emitted, so
the assignment was invisible in the timeline (and notifications/live
assignee panel didn't update).

The claim ran before `update_conversation_call_status`, so that later
`update!` on the same conversation clobbered
`saved_change_to_assignee_id?` before `after_commit` fired. Moving the
claim to be the conversation's final write in the transaction restores
the activity message, the `ASSIGNEE_CHANGED` event, and the live
assignee update.

## Type of change

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

## How Has This Been Tested?

1. Open an unassigned WhatsApp-call conversation.
2. As an agent, answer an inbound call.
3. Before: the conversation is assigned to you, but no "self-assigned"
activity message appears. After: the activity message is created and the
assignee updates live.

## 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
2026-06-11 11:16:41 +05:30
Sony Mathew e9a92a6366 Merge branch 'release/4.14.2' into develop 2026-06-10 22:44:31 +05:30
Sony Mathew bec795f764 Bump version to 4.14.2 2026-06-10 22:43:22 +05:30
Shivam MishraandGitHub 40432499c8 Merge branch 'develop' into feat/read-only-token 2026-06-10 16:39:19 +05:30
Shivam MishraandGitHub e6dfb91fcc refactor: use SafeFetch for website branding page fetch (#14693) 2026-06-10 16:26:24 +05:30
Shivam MishraandGitHub d9c07fe2e9 feat: expose onboarding help center generation status (#14671)
This PR adds a reload-safe onboarding Help Center generation status
path. Previously, generation progress was pushed through ActionCable,
which meant the onboarding UI could lose context after a page reload or
missed websocket event. The new endpoint exposes the current generation
id, raw Redis generation state, and Help Center article/category counts
so clients can recover state by fetching from the backend.

**What changed**

- Added an onboarding Help Center generation status endpoint.
- Persisted `help_center_generation_id` when generation is enqueued.
- Removed Help Center generation ActionCable broadcasts completely.
- Kept generation progress in Redis as the backend source of truth.
- Kept Help Center generation out of the onboarding hot path; the
existing onboarding controller does not start generation yet.

**How to test**

1. Start Help Center generation for an account.
2. Fetch the onboarding generation status endpoint and verify it returns
the generation id, Redis state, and article/category counts.
3. Reload the onboarding UI or client state and fetch again to confirm
progress can be recovered without relying on websocket events.
4. Verify skipped/completed generation states are reflected from Redis.

## Related PRs

- https://github.com/chatwoot/chatwoot/pull/14569
- https://github.com/chatwoot/chatwoot/pull/14568
- https://github.com/chatwoot/chatwoot/pull/14567
- https://github.com/chatwoot/chatwoot/pull/14619
- https://github.com/chatwoot/chatwoot/pull/14565 (Primary onboarding
PR)
2026-06-10 16:24:37 +05:30
f93f2067b6 fix(whatsapp): Drop obsolete WABA scope check broken by Meta embedded signup (#14697)
## Description

Fixes WhatsApp embedded signup failing with "No WABA scope found in
token." Meta recently changed which scopes embedded-signup tokens carry
(whatsapp_business_manage_events instead of
whatsapp_business_management), which tripped a brittle scope-string
check. That check was redundant anyway — PhoneInfoService already
verifies the token's access to the specific WABA by calling its
/phone_numbers endpoint right before it. This removes the obsolete
TokenValidationService and relies on the functional check.

## Type of change

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

## Checklist:

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

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-06-10 13:22:30 +04:00
Shivam MishraandGitHub 3dfb5061e1 refactor: route captain custom tool requests through SafeFetch (#14620)
Captain custom tools previously used their own hand-rolled `Net::HTTP`
request code. This moves them onto `SafeFetch`, the same shared
HTTP-fetching helper already used by webhooks, uploads, avatar imports,
and the Captain page crawler. Behavior for normal tools is unchanged —
they just now share one consistent path for host resolution, timeouts,
response size limits, and redirect handling.

**What changed**
- `HttpTool#execute_http_request` now delegates to `SafeFetch.fetch`
instead of building `Net::HTTP` requests by hand. Auth headers, basic
auth, metadata headers, JSON content-type, and the 1 MB response cap all
map onto `SafeFetch` options.
- Removed ~80 lines of bespoke request/validation plumbing from
`HttpTool`.
- The custom tools `test` endpoint now reads the response body string
directly (the executor returns the body rather than a response object).

**How to test**
1. Enable `custom_tools` (or `captain_integration_v2`) for an account.
2. Create a Captain custom tool pointing at a public HTTPS endpoint
(e.g. a test API).
3. Use the **Test** button in the tool form — you should get a success
result.
4. Run the tool from a Captain conversation and confirm the response is
returned/templated as before.

**Gotchas**
- **Local dev:** `SafeFetch` blocks requests to private/loopback
addresses by default. If you're testing a custom tool against a service
on `localhost` or a private IP during development, set
`SAFE_FETCH_ALLOW_PRIVATE_NETWORK=true` or the request will be rejected.
(This matches how the rest of `SafeFetch` already behaves locally.)
- **Test endpoint status field:** on success the `test` response now
reports `status: 200` rather than the exact 2xx code (201/204/etc.),
because `SafeFetch` signals success-vs-failure rather than exposing the
raw response. The UI only checks the 2xx range, so this is invisible
there — but worth knowing if anything consumes the API directly.
- **Non-2xx responses** still surface as an error (same as before), now
via `SafeFetch::HttpError`.
2026-06-10 14:01:30 +05:30
72a59e4795 fix: update oauth dependencies (#14691)
Updates the locked OAuth dependencies to patched versions so bundle
audit no longer reports the current OAuth advisories.

What changed
- Updated `oauth` from `1.1.0` to `1.1.6` for `GHSA-prq8-7wvh-44qh`.
- Updated `oauth2` from `2.0.9` to `2.0.22` for `GHSA-pp92-crg2-gfv9`.
- Accepted Bundler's required transitive lockfile updates for the
patched OAuth gems.

How to test
1. Run `bundle exec bundle audit update && bundle exec bundle audit
check -v` and confirm no vulnerabilities are reported.
2. Smoke test OAuth-based authentication and email integration flows.

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2026-06-10 12:33:46 +05:30
Sivin VargheseandGitHub cabe9bc733 fix: populate general settings form on hard reload (#14685) 2026-06-10 10:56:17 +05:30
33f7550525 fix(whatsapp): restrict OGG voice recording to WhatsApp Cloud inboxes (#14692)
Recording and sending an audio message from a **Twilio WhatsApp** inbox
failed silently — Twilio rejected the media with delivery error `63019`
("Media failed to download") and the voice note never reached the
customer. This restores audio sending for Twilio WhatsApp (and
360dialog) inboxes.

Closes Regression from #14606

## How to reproduce

1. Open a conversation in a **Twilio WhatsApp** inbox.
2. Record a voice message in the reply box and send it.
3. Before this fix: the message fails to deliver and a
`Webhooks::TwilioDeliveryStatusJob` is enqueued with `ErrorCode: 63019`,
`ErrorMessage: "Media failed to download"`.
4. After this fix: the audio is recorded as MP3 and delivers normally.

## What changed

PR #14606 added WhatsApp **Cloud** voice notes, which require OGG/Opus.
It changed `audioRecordFormat` in `ReplyBox.vue` to return OGG for
`isAWhatsAppChannel` — but that getter is also `true` for Twilio
WhatsApp inboxes. The OGG handling (content-type normalization + the
`voice: true` flag) lives only in `WhatsappCloudService`, so Twilio
could not download/process the remuxed OGG file.

This change scopes OGG to `isAWhatsAppCloudChannel`. Twilio WhatsApp,
360dialog, and Telegram fall back to MP3 exactly as they did before the
PR.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 08:52:34 +04:00
Sivin VargheseandGitHub 59d869d1ed feat: Ability to resize table column width (#14611) 2026-06-09 18:04:48 +05:30
8a3b129292 fix: Disable re-oauth flow for manual whatsapp (#13599)
Restrict the WhatsApp reauthorization flag to channels whose
provider_config source is 'embedded_signup'. Previously the view exposed
resource.channel.reauthorization_required? for all WhatsApp channels;
now it only returns true when (provider_config || {}).to_h['source'] ==
'embedded_signup' && resource.channel.reauthorization_required?. This
prevents showing reauthorization prompts for manual/API-key flows
(non-OAuth) and safely handles nil provider_config.

# Pull Request Template

## Description

Please include a summary of the change and issue(s) fixed. Also, mention
relevant motivation, context, and any dependencies that this change
requires.
Fixes  #13553 

## Type of change

Please delete options that are not relevant.

- [x] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality not to work as expected)
- [ ] This change requires a documentation update

## How Has This Been Tested?

Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.


## 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
- [x] 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

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-06-09 11:12:03 +04:00
af1dfc21f6 fix: include account data in webhook payloads (#12445)
Conversation and inbox webhook payloads now include the account object
(`{ id, name }`) so integrations can reliably identify the account
without depending on nested message data.

## Closes

Closes #11753
Closes #12442

## Why

Message, contact, and contact inbox webhook payloads already expose
`account`. Conversation and inbox webhook payloads were outliers, which
forced webhook consumers to infer account context from nested messages
or other fields that may not always be present.

## What changed

- Adds `account: { id, name }` to conversation webhook payloads.
- Adds webhook-specific inbox payload data with `account: { id, name }`
for inbox created/updated events.
- Keeps non-webhook conversation and inbox push payload behavior
unchanged.

## How to test

- Configure an account webhook for `conversation_created`, trigger a new
conversation, and confirm the webhook payload includes `account.id` and
`account.name`.
- Configure an account webhook for `inbox_created`, create a new inbox,
and confirm the webhook payload includes `account.id` and
`account.name`.
- Configure a webhook agent bot and update a conversation status, then
confirm the bot webhook payload includes the same account object.

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-06-09 12:12:46 +05:30
Shivam MishraandGitHub 72d0e91d3c Merge branch 'develop' into feat/read-only-token 2026-06-08 18:08:12 +05:30
d1c482cb64 fix(email): strip null bytes from inbound mailbox messages (#14546)
Inbound emails containing null bytes could fail while Chatwoot persisted
the incoming message, causing IMAP sync to drop and repeatedly retry the
same malformed email. This strips null bytes at the inbound mailbox
message persistence boundary so the rest of the email can be saved
normally.

Fixes
https://linear.app/chatwoot/issue/CW-7165/argumenterror-string-contains-null-byte-argumenterror
Sentry: https://chatwoot-p3.sentry.io/issues/7405946944/

## Test
- [x] added specs
- [x] manually tested the logic on prod for the inbox(email) which was
throwing the error

## What changed

- Sanitizes null bytes only while building attributes for inbound
mailbox message persistence.
- Uses the sanitized source ID for both duplicate lookup and message
creation.
- Adds regression coverage for the mailbox message creation path.

---------

Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-06-08 17:12:25 +05:30
d8656edc61 fix(facebook): Stop force tagging Messenger replies with MESSAGE_TAG/ACCOUNT_UPDATE (#14329)
Outbound Facebook Messenger replies were always sent with
`messaging_type: MESSAGE_TAG` and a `tag` of either `HUMAN_AGENT` or
`ACCOUNT_UPDATE` (fallback). `ACCOUNT_UPDATE` is reserved by Meta policy
for non-recurring account notifications (password resets, suspicious
activity, account-status changes), so general agent replies were getting
rejected by Facebook with "Invalid parameter" and risked page-level
penalties. After this fix, Facebook treats default replies as standard
`RESPONSE` messages within the 24-hour window, and only attaches
`HUMAN_AGENT` tagging when the operator opts in via
`ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT`.

## Closes

<!-- Add the relevant issue link, e.g. Closes #1234 -->

## How to reproduce

1. Connect a Facebook page inbox.
2. Leave `ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT` disabled (default).
3. From the dashboard, reply to an active Messenger conversation with a
regular/promotional message.
4. Before this PR: the request to Graph API includes
`messaging_type=MESSAGE_TAG&tag=ACCOUNT_UPDATE`. Facebook returns
"Invalid parameter" for content that does not match the `ACCOUNT_UPDATE`
use case, and the message is marked failed in Chatwoot.
5. After this PR: the request omits `messaging_type` and `tag`, so
Facebook treats it as a standard `RESPONSE` and accepts it within the
24-hour customer service window.

## What changed

- `app/services/facebook/send_on_facebook_service.rb` now mirrors the
Instagram service pattern: text and attachment params are built without
`messaging_type`/`tag` by default, and a shared `merge_human_agent_tag`
helper attaches `MESSAGE_TAG`/`HUMAN_AGENT` only when
`ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT` is enabled.
- Removed the unused `sent_first_outgoing_message_after_24_hours?`
helper (dead code with no callers).
- Updated `spec/services/facebook/send_on_facebook_service_spec.rb`:
dropped `ACCOUNT_UPDATE` expectations, added a spec asserting no
`messaging_type`/`tag` is sent by default, and tightened the HUMAN_AGENT
spec to verify both `messaging_type` and `tag`.

## Operator note

Operators who were relying on the prior `ACCOUNT_UPDATE` fallback to
bypass the 24-hour window were already in violation of Meta policy and
likely seeing send failures. They should enable
`ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT` (and request the Human Agent
permission from Meta) for the 7-day extended window

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-06-08 15:28:36 +04:00
e919a2cef5 feat: add contact filter for conversations (#14629)
# Pull Request Template

## Description

Adds a Contact condition to the conversation advanced filter so agents
can search for an existing contact and filter conversations by
`conversations.contact_id`.

Fixes
[CW-7239](https://linear.app/chatwoot/issue/CW-7239/contact-filter-for-conversations)

## Type of change

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality not to work as expected)
- [ ] This change requires a documentation update

## How Has This Been Tested?

- `eval "$(rbenv init -)" && bundle exec rspec
spec/services/conversations/filter_service_spec.rb`
- `pnpm exec vitest --no-watch --no-cache --no-coverage --logHeapUsage
app/javascript/dashboard/components-next/filter/helper/filterHelper.spec.js
app/javascript/dashboard/store/modules/conversations/helpers/specs/filterHelpers.spec.js
app/javascript/dashboard/helper/specs/customViewsHelper.spec.js
app/javascript/dashboard/helper/specs/filterQueryGenerator.spec.js`
- `pnpm eslint`
- `eval "$(rbenv init -)" && bundle exec rubocop
spec/services/conversations/filter_service_spec.rb`

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

---------

Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2026-06-08 16:00:17 +05:30
Sojan JoseandGitHub 7718f2a62c fix: update puma for security advisories (#14670)
Updates Puma to the patched 7.2 line so bundle-audit no longer reports
the high-severity Puma advisories affecting the current lockfile.

Fixes: CVE-2026-47736, CVE-2026-47737
Closes: N/A

## Why

The refreshed ruby-advisory-db flagged Puma 6.4.3 for two high-severity
PROXY Protocol v1 parser advisories. The advisory-recommended fixed
versions are the Puma 7.2 patch line or Puma 8, so this keeps the update
to the smaller fixed line.

## What changed

- Constrains Puma to `~> 7.2`, `>= 7.2.1`
- Updates the lockfile from Puma 6.4.3 to 7.2.1
- Updates nio4r from 2.7.3 to 2.7.5 as the transitive Puma dependency

## Validation

- `bundle exec bundle-audit check` -> `No vulnerabilities found`
- `bundle exec rails runner 'puts "Rails #{Rails.version}; Puma
#{Puma::Const::PUMA_VERSION}; Rack #{Rack.release}"'` -> `Rails 7.1.5.2;
Puma 7.2.1; Rack 3.2.6`
- `bundle exec rails server -p 3051` booted Puma 7.2.1 successfully
- Smoke checked `/`, `/api`, unknown-route 404, and `/cable` WebSocket
upgrade
2026-06-08 15:40:43 +05:30
Aakash BakhleandGitHub 45f4b423ae fix: captain usage for BYOK OpenAI tasks (#14587)
# Pull Request Template

## Description

Fixes:
https://linear.app/chatwoot/issue/CW-7167/label-suggestions-bad-ux

## Type of change

Please delete options that are not relevant.

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

## How Has This Been Tested?

Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.


## 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
2026-06-08 13:37:21 +05:30
Shivam MishraandGitHub 78a6b2457d refactor(onboarding): extract whatsapp and facebook channel connect into reusable composables (#14619)
Extracts the Meta JS SDK logic for the WhatsApp embedded-signup and
Facebook Page connect flows out of their settings components into two
reusable composables. No behavior change — the settings inbox-creation
flows work exactly as before; this just makes the SDK orchestration
reusable (an upcoming onboarding PR consumes them) and adds unit
coverage.

## What changed
- `useWhatsappEmbeddedSignup` — owns the embedded-signup popup (SDK
load, FB.login, and the auth-code / postMessage race).
`WhatsappEmbeddedSignup.vue` now consumes it.
- `useFacebookPageConnect` — owns FB.login (page scopes) + page fetch,
with SDK preload split from login so the popup opens within the click's
activation window. `Facebook.vue` now consumes it.
- Adds unit specs for both composables.

## Related PRs

- https://github.com/chatwoot/chatwoot/pull/14569
- https://github.com/chatwoot/chatwoot/pull/14568
- https://github.com/chatwoot/chatwoot/pull/14567
- https://github.com/chatwoot/chatwoot/pull/14649
- https://github.com/chatwoot/chatwoot/pull/14565 (Primary onboarding
PR)
2026-06-08 13:13:20 +05:30
Shivam MishraandGitHub 73ba0b26e5 fix(instagram): process webhook events after mutex retry exhaustion (#14647)
Instagram webhooks use a Redis mutex to protect the first-message path
for a contact/account pair. When multiple webhook events for the same
Instagram contact arrive at nearly the same time, they can all enter the
“find or create” flow together.

There are two pieces involved:

`ContactInbox` maps the external Instagram scoped user id to a Chatwoot
contact inside an inbox. That side already has a unique `(inbox_id,
source_id)` index and retry handling, so duplicate contact-inbox
creation is mostly protected at the database layer.

`Conversation` creation is more fragile. The message builder looks for
an existing active conversation for the contact/inbox and creates one
when none is found. If two first-message jobs run concurrently, both can
see “no active conversation yet” and both can create a conversation. The
mutex exists to bump those jobs apart long enough for the first one to
create the conversation, so the next one appends to it instead of
creating a duplicate.

In production, the old behavior could turn that race dampener into a
drop path. Once `Webhooks::InstagramEventsJob` exhausted its lock
retries, ActiveJob stopped retrying with
`MutexApplicationJob::LockAcquisitionError`. Since ActiveJob retries are
not FIFO, later jobs could still win the lock while older jobs kept
getting deferred until exhaustion.

## Mutex Application Job Changes

This adds `retry_on_lock_conflict` as a small wrapper around the
existing `retry_on LockAcquisitionError` pattern.

The new API keeps the default behavior intact, but lets jobs explicitly
define what should happen after lock retry exhaustion:

```ruby
retry_on_lock_conflict wait: 1.second,
                       attempts: 3,
                       on_exhaustion: :process_without_lock
```

The fallback receives the original job arguments, so job classes do not
need to reach into ActiveJob internals like `arguments.first`.

## Instagram Fallback

Instagram now processes the webhook payload even after the mutex retry
window is exhausted.

This matches what the mutex was meant to do in the first place: bump
concurrent events apart long enough to avoid duplicate conversation
creation, not permanently block or drop webhook events. If a job cannot
acquire the lock after a few retries, it continues through the normal
Instagram processing path without the mutex.

## Retry And Lock Tuning

The Instagram lock retry window now uses deterministic backoff:

```ruby
retry_on_lock_conflict wait: ->(executions) { executions.seconds },
                       attempts: 3,
                       on_exhaustion: :process_without_lock
```

The lock TTL is also set explicitly:

```ruby
with_lock(key, 3.seconds)
```

This matters because Rails treats `attempts` as total executions, not
retries after the first execution. With a fixed `wait: 1.second` and
`attempts: 3`, the exhaustion handler can run roughly two seconds after
the first lock conflict. That is earlier than a 3-second lock TTL, so
the fallback could process without the lock while the original mutex is
still valid. That would reopen the duplicate-conversation race the lock
is meant to dampen.

Using a proc wait makes the timing predictable and avoids Rails jitter
for this retry path. The first conflict waits about 1 second, the second
waits about 2 seconds, and the final attempt happens around the 3-second
mark. That lines the retry window up with the Redis lock TTL before
falling back to `process_without_lock`.

The protected race window is intentionally small. `ContactInbox`
creation already has database uniqueness protection, while conversation
creation is the softer `find active conversation || create` path. We
only need to give the first job enough time to create the conversation
state that later jobs should reuse.

The mutex still does not preserve message order, and ActiveJob retries
are not FIFO. A longer retry window would mostly add latency for hot
Instagram contacts without making ordering more correct. After the lock
TTL has elapsed, processing without the lock is the better tradeoff than
dead-lettering customer messages.
2026-06-05 14:06:45 +05:30
f9385a31fc fix(api): allow agent bots to read conversations and manage labels (#14655)
Agent bots assigned to a conversation can now fetch that conversation's
details and manage its labels using their bot token. Previously these
two operations returned `Access to this endpoint is not authorized for
bots`, even though the same token worked for sending messages, updating
the conversation, and setting custom attributes.

Fixes
https://linear.app/chatwoot/issue/PLA-174/agent-bot-tokens-cannot-fetch-conversation-details-or-manage-labels

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 11:01:25 +04:00
9d808a18df fix(drops): Resolve first_name for single-word contact and agent names (#13488)
WhatsApp template variables like `{{contact.first_name}}` previously
resolved to an empty string whenever a contact or agent had a
single-word name (e.g. "Petterson"). Because the variable came back
blank, WhatsApp template sends would either fail validation or get stuck
indefinitely in the "Sending" state. With this fix, `first_name` falls
back to the full single-word name, so templates render correctly for
everyone regardless of how many words their name has.

## Closes

- Fixes #13222

## How to reproduce

1. Create a contact with a single-word name (e.g. `Petterson`).
2. Configure a WhatsApp template action that uses
`{{contact.first_name}}`.
3. Send the template message.
4. **Before:** the variable resolves to an empty string and the message
fails / stays in "Sending".
**After:** the variable resolves to `Petterson` and the message sends.

## What changed

- Removed the "name must have 2+ words" guard from `first_name` in
`ContactDrop` and `UserDrop`, so a single-word name is returned (still
capitalized).
- Capitalization behavior introduced in #6758 is preserved; only the
single-word edge case changes.
- `last_name` still returns `nil` for single-word names, which is the
expected behavior.
- Added specs covering single-word names for both `ContactDrop` and
`UserDrop`.

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-05 10:03:24 +04:00
Sivin VargheseandGitHub ec43975f3f chore: use square avatars (#14656) 2026-06-05 11:25:32 +05:30
64c5aeebee feat(help-center): support per-locale portal title, name & header (#14642)
Portals can now override their name, page title and header text per
locale, with a fallback chain of locale override → default locale → base
value. Overrides are stored under portal.config.locale_translations and
validated with a JSON schema.

Editing is exposed as a "Localize content" action in each non-default
locale's menu on the Locale page, and all public-facing portal views
(classic + documentation layouts) render the localized values. The live
chat widget is also loaded in the active portal locale.


<img width="494" height="395" alt="Screenshot 2026-06-03 at 2 48 59 PM"
src="https://github.com/user-attachments/assets/e55a06e8-f20f-4d8a-9352-92fde28a9a19"
/>



https://github.com/user-attachments/assets/f5affbd2-7ad4-415a-b376-57c759fc2aaa

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 06:34:12 -07:00
cd9192f7d1 chore(captain): update Firecrawl to use the v2 API (#14624)
## Description

Migrates Firecrawl from the v1 to the v2 API.
`Captain::Tools::FirecrawlService` now targets `api.firecrawl.dev/v2`,
with the request body updated to match the v2 schema.

> Disclosure: I work at Firecrawl.

Fixes # (n/a)

## Type of change

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

## How Has This Been Tested?

Updated
`spec/enterprise/services/captain/tools/firecrawl_service_spec.rb` to
assert the v2 endpoint and request body.

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

---------

Co-authored-by: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com>
2026-06-03 23:47:49 +05:30
Aakash BakhleandGitHub eaffad12e7 feat(langfuse): propagate observation metadata for evals (#14634)
# Pull Request Template

## Description

We need to pass on trace level attributes down to the spans inside them
like tool calls, observations, etc.
This way, we can filter observations based on trace level attributes.


## Type of change

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

## How Has This Been Tested?

Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.

Attributes added to observation metadata for easy filtering
<img width="1327" height="708" alt="image"
src="https://github.com/user-attachments/assets/8f1d1bf8-cde4-481d-a2c2-7920ad2fc52e"
/>

added a `generation_stage` to differentiate llm_calls that call tools vs
those that generate a `final_response`
<img width="1806" height="968" alt="CleanShot 2026-06-03 at 15 11 09@2x"
src="https://github.com/user-attachments/assets/db1fa8e0-7f2d-404b-a719-27a16d400442"
/>


propagated attributes to tool calls for future use
<img width="903" height="517" alt="image"
src="https://github.com/user-attachments/assets/edc61ce8-93db-465c-a66e-043138e2dc15"
/>



## 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
- [x] Any dependent changes have been merged and published in downstream
modules
2026-06-03 16:45:19 +05:30
Sivin VargheseandGitHub 18ef019cd4 fix: improve article editor typography & nested lists (#14572) 2026-06-03 16:11:32 +05:30
Tanmay Deep SharmaandGitHub ea910227ac feat: add Voice Calls feature card to settings showcase (#14635)
## Linear Ticket 

https://linear.app/chatwoot/issue/CW-7250/add-voice-calls-to-self-hosted-super-admin-feature-list

## Description

Adds a **Voice Calls** card to the Super Admin → Settings → Features
showcase so self-hostedadmins can see at a glance whether voice calling
is available on the installation.

## Type of change

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

## Screenshots?

<img width="1512" height="843" alt="Screenshot 2026-06-03 at 3 15 22 PM"
src="https://github.com/user-attachments/assets/988db14b-fef1-4b72-a55e-c7a8884521dd"
/>


## 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
2026-06-03 16:01:14 +05:30
Tanmay Deep SharmaandGitHub 94ddd98050 chore: update the voice call ringtone (#14636)
## Linear Ticket
https://linear.app/chatwoot/issue/CW-7260/update-the-voice-call-ringtone

## Description

Updates the incoming voice call ringtone. The dashboard call widget now
plays the new tone while an inbound call is unanswered, replacing the
previous bell sound.

## Type of change

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

## 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
- [x] 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
- [x] Any dependent changes have been merged and published in downstream
modules
2026-06-03 16:00:04 +05:30
Tanmay Deep SharmaandGitHub 0e87519ecd feat: add mute button for Twilio calls (#14637)
## Linear Ticket

https://linear.app/chatwoot/issue/CW-7264/add-mute-button-in-twilio-calls

## Description

Adds mute support for Twilio voice calls. Previously the mute button was
shown only for WhatsApp calls (which toggle the local mic track in the
browser), so Twilio calls had no way to mute. The call widget now routes
mute by provider: WhatsApp continues to toggle the local mic track,
while Twilio uses the Voice SDK connection's native `mute()`. The mute
button is now shown for any active call, and unmute works symmetrically.

## Type of change

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

## 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
- [x] I have made corresponding changes to the documentation
- [x] 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
2026-06-03 15:59:23 +05:30
Tanmay Deep SharmaandGitHub de89391031 fix: use hang-up handset icon for reject/end call button (#14640)
## Linear Ticket
https://linear.app/chatwoot/issue/CW-7261/call-window-theme-alignment

## Description

Updates the call window's reject/end button to use the hang-up handset
icon (the same handset as the accept button, rotated) instead of the
phone-with-X, matching the design.

## Type of change

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

## Screenshots

<img width="346" height="135" alt="Screenshot 2026-06-03 at 3 41 10 PM"
src="https://github.com/user-attachments/assets/4d507141-332e-436a-a6ec-516964a31013"
/>


## 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
2026-06-03 15:59:12 +05:30
Sojan JoseandGitHub 8e42307bdc fix: improve email inbox IMAP and SMTP compatibility (#14589)
Fetch IMAP message content using `BODY.PEEK[]` instead of `RFC822` to
avoid provider-specific parser failures while preserving unread state.
This also applies the existing SMTP timeout configuration to custom SMTP
email-channel replies, so provider SMTP responses have enough time to
complete.

Fixes: https://github.com/chatwoot/chatwoot/issues/12762

## Why

Some IMAP providers can return responses for `FETCH RFC822` that Ruby
`net-imap` fails to parse with:

`Net::IMAP::ResponseParseError: unexpected RPAR (expected ATOM or NIL)`

We reproduced this with iCloud IMAP. Authentication, `INBOX` selection,
and header fetches worked, but fetching full message content with
`RFC822` failed before Chatwoot received a `Mail::Message`.

The same mailbox successfully returned full message content when fetched
with `BODY.PEEK[]`.

> During end-to-end iCloud validation, inbound fetch worked after the
IMAP change, but outbound replies through the custom SMTP settings could
still fail with a socket read timeout. The OAuth SMTP path already used
explicit SMTP timeout values; the custom SMTP path was relying on mailer
defaults instead.

## What this change does

- Replaces the full message fetch from `RFC822` to `BODY.PEEK[]`
- Reads the returned message content from `BODY[]`, which is how
`net-imap` exposes the response attribute
- Keeps the existing `BODY.PEEK[HEADER]` header-fetch behavior unchanged
- Applies `SMTP_OPEN_TIMEOUT` and `SMTP_READ_TIMEOUT` to custom SMTP
email-channel replies
- Defaults custom SMTP reply delivery to `open_timeout: 15` and
`read_timeout: 30`
- Updates IMAP service specs for standard and Microsoft IMAP fetch flows
- Updates mailer specs for custom SMTP timeout settings

`BODY.PEEK[]` is preferable here because it fetches the full message
content without marking messages as read.

## Validation

- Configured a local email inbox against iCloud IMAP and SMTP
- Confirmed `FETCH RFC822` reproduces `Net::IMAP::ResponseParseError:
unexpected RPAR (expected ATOM or NIL)`
- Confirmed `BODY[]` and `BODY.PEEK[]` fetch the same mailbox
successfully
- Confirmed Chatwoot imports iCloud messages after the IMAP change
- Sent two outbound replies from the Chatwoot UI through iCloud SMTP
after applying the timeout settings
- Confirmed both UI-created outbound messages were marked `sent`, had
iCloud SMTP `source_id` values, and had no `external_error`
- Ran `bundle exec rspec spec/services/imap/fetch_email_service_spec.rb
spec/services/imap/microsoft_fetch_email_service_spec.rb`
- Ran `bundle exec rspec spec/mailers/conversation_reply_mailer_spec.rb`
2026-06-03 15:56:54 +05:30
1beaa284c6 feat: inline images in website and email channels (#14516)
# Pull Request Template

## Description

This PR adds support for inline image uploads in the reply editor for
Email and Website (chat widget) channels.

Agents can now insert images inline between text and resize them
directly in the editor by dragging the bottom corner, similar to the
help center editor experience.

Image sizes are preserved through markdown using the `cw_image_width`
URL param and render correctly in both outgoing emails and chat widget
messages.

Agents can also paste copied images directly into Email or Website
replies using **Shift+Cmd+V** (Shift+Ctrl+V on Windows/Linux). The image
gets inserted inline at the cursor position and supports resizing just
like uploaded images. Regular **Cmd+V / Ctrl+V** behavior remains
unchanged and continues to add images as attachments, so both inline and
attachment flows are supported.


### Prosemirror repo PR:
https://github.com/chatwoot/prosemirror-schema/pull/48

Fixes
https://linear.app/chatwoot/issue/CW-7133/inline-images-in-live-chat-and-email

https://linear.app/chatwoot/issue/CW-7225/ghsa-8j9w-jppp-xcfc-html-attribute-injection-via-unvalidated-cw-image

## Type of change

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

## How Has This Been Tested?

### Screencast



https://github.com/user-attachments/assets/a928f852-ab15-413a-9d35-6ea69b718ecf

<img width="414" height="654" alt="image"
src="https://github.com/user-attachments/assets/205e0729-8f2d-4cc5-9c55-7696f032eca4"
/>



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

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-06-03 15:05:17 +05:30
Sivin VargheseandGitHub d028cc1984 fix: prevent list marker overflow in messages (#14618) 2026-06-03 14:18:48 +05:30
Sivin VargheseandGitHub 0d59fb4459 fix: validate portal color format (#14632) 2026-06-03 14:18:19 +05:30
7acbe8b3ff fix(whatsapp): truncate location fallback_title to 255 chars to avoid silent message drop (#14517)
## Summary

`Whatsapp::IncomingMessageBaseService#attach_location` builds a
`fallback_title` by concatenating `location['name']` and
`location['address']` with no length cap, then stores it directly into
`Attachment#fallback_title`. `ApplicationRecord` enforces a generic
255-character limit on string columns, so any WhatsApp location whose
`"#{name}, #{address}"` exceeds 255 chars (a common case for Google
Places that include a long full address) raises
`ActiveRecord::RecordInvalid` deep inside the Sidekiq job. The message
and attachment INSERTs are part of the same transaction, so the whole
thing rolls back. Sidekiq retries once; the retry dedup-skips the wamid
silently and exits without an error. **Result: the message is
irrecoverably lost — no row in `messages`, no entry in the UI, no
outgoing webhook, no clue for the operator.**

Confirmed in `v4.13.0`, `v4.14.0`, and `develop` (commit `f33e469`,
2026-05-20). No upstream issue found before opening this PR.

## How to reproduce

1. From WhatsApp, share a Google Place whose `name + ", " + address` is
> 255 chars. The Spanish business address `Gremi de Fusters, 33,
Edificio VIP Asima, Piso 2, Local 2, Norte, 07009 Polígon industrial de
Son Castelló, Illes Balears, España` (132 chars) used as both `name` and
`address` is enough.
2. Sidekiq logs:
   ```
   ERROR ActiveRecord::RecordInvalid: Validation failed:
   Attachments fallback title is too long (maximum is 255 characters)
   ```
3. The `messages` table has no row. The conversation UI shows nothing
for that timestamp.
4. The first retry "Performed" successfully but creates nothing — the
dedup-by-source-id silently swallows the failure.

## Fix

Cap the existing concatenated title at 255 chars via `.first(255)`.
Minimal change, no behavioural difference for any message shorter than
the limit, prevents the silent data loss for any longer ones.

```diff
-    location_name = location['name'] ? "#{location['name']}, #{location['address']}" : ''
+    location_name = (location['name'] ? "#{location['name']}, #{location['address']}" : '').first(255)
```

## Alternatives considered

- **Increase the validation limit on `Attachment#fallback_title`**: more
invasive; would touch other inbound channels and possibly require a DB
column change.
- **Use `name` alone (no concat)**: cleaner semantically (in many real
payloads `name == address`), but changes user-visible behaviour. Left as
a follow-up if desired.
- **Truncate with ellipsis**: cosmetic only; deferred.

This PR is intentionally minimal so it can be merged on its own.

---------

Co-authored-by: Sony Mathew <sony@chatwoot.com>
Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
2026-06-03 12:50:21 +05:30
Shivam MishraandGitHub ca5ef95db7 Merge branch 'develop' into feat/read-only-token 2026-06-03 12:05:54 +05:30
Vinícius FitznerandGitHub b791d75b30 fix(microsoft): prevent OAuth admin consent loop (#13962)
Fixes #9775

## Description

This fixes a repeated admin consent loop in the Microsoft OAuth flow
when connecting a Microsoft email inbox.

Chatwoot was always sending `prompt=consent` in the Microsoft
authorization URL. In the current code path, this parameter is only used
when building the authorization URL and is not required by the callback,
token exchange, token persistence, or refresh flow.

By removing the forced consent prompt, the OAuth flow can proceed
normally without repeatedly sending users back through the admin consent
screen.

## What changed

- removed `prompt: 'consent'` from the Microsoft authorization URL
- added a regression assertion to ensure `prompt` is not included in the
generated URL

## Why this is safe

- `redirect_uri`, `scope`, and `state` remain unchanged
- callback and token exchange flow remain unchanged
- refresh token flow remains unchanged
- no other part of the current Microsoft inbox flow depends on forcing a
consent screen

## Testing

- updated controller spec to assert that the generated authorization URL
does not include `prompt`
2026-06-03 12:05:25 +05:30
Shivam Mishra eedffccf6d feat: withold pubsub token for readonly token 2026-06-02 17:38:43 +05:30
Shivam MishraandGitHub 134eb157cc Merge branch 'develop' into feat/read-only-token 2026-06-02 15:07:53 +05:30
Shivam MishraandGitHub c7c4da35f6 Merge branch 'develop' into feat/read-only-token 2026-06-02 14:33:28 +05:30
Shivam MishraandGitHub 57a5d7e64e Merge branch 'develop' into feat/read-only-token 2026-06-01 13:36:27 +05:30
Shivam Mishra eef3a6fefc feat: block voice conference token from read only token 2026-06-01 13:36:14 +05:30
Shivam MishraandGitHub cb2061f260 Merge branch 'develop' into feat/read-only-token 2026-05-29 19:04:31 +05:30
Shivam Mishra 2e30a69a18 feat: block portal ssl status from read only token 2026-05-29 16:54:20 +05:30
Shivam Mishra 473d1a6c22 feat: hide captain custom tool auth config when using read only token 2026-05-29 16:22:14 +05:30
Shivam Mishra 4efb421800 feat: hide integration hook settings when using read only token 2026-05-29 16:22:13 +05:30
Shivam Mishra 54b86939d2 feat: hide webhook secret when using read only token 2026-05-29 16:18:55 +05:30
Shivam Mishra 49ac1557c1 feat: hide inbox channel provider credentials when using read only token 2026-05-29 16:18:54 +05:30
Shivam Mishra 45bd3f0fc7 feat: hide inbox channel credentials when using read only token 2026-05-29 16:10:18 +05:30
Shivam MishraandGitHub 5db4bb963b Merge branch 'develop' into feat/read-only-token 2026-05-29 14:45:23 +05:30
Shivam Mishra bd780b4cc6 feat: hide agent bot access_token when using read only token 2026-05-27 17:21:53 +05:30
Shivam Mishra 2f607f67c4 refactor: align conversation direct uploads with api token auth chain 2026-05-27 17:18:33 +05:30
Shivam Mishra ae676077ed test: readonly token can access context inbox filter 2026-05-27 14:53:18 +05:30
Shivam MishraandGitHub a31fb0f906 Merge branch 'develop' into feat/read-only-token 2026-05-27 14:52:49 +05:30
Shivam Mishra c0c12f2fbd chore: allow contact_inboxes filter 2026-05-27 14:52:27 +05:30
Shivam Mishra 7333cdd79f fix: scope user access token serialization 2026-05-27 13:50:41 +05:30
Shivam MishraandGitHub b9e83ec2d4 Merge branch 'develop' into feat/read-only-token 2026-05-26 18:55:25 +05:30
Shivam Mishra f3b990997e refactor: make access token generation race-safe 2026-05-26 12:34:34 +05:30
Shivam Mishra f778057b18 refactor: reuse access token scope helper for direct uploads 2026-05-26 12:32:27 +05:30
Shivam Mishra 1987abf9e2 chore: reset access token changes 2026-05-25 16:41:44 +05:30
Shivam Mishra a1c79f90b1 fix: width 2026-05-25 16:31:34 +05:30
Shivam Mishra 8f8cd7b929 fix: width 2026-05-25 16:28:33 +05:30
Shivam Mishra 48d8960539 feat: allow creating fresh token 2026-05-25 16:25:30 +05:30
Shivam Mishra c816e6b330 feat: add token list 2026-05-25 16:24:58 +05:30
Shivam Mishra b0aa51e811 chore: update migration 2026-05-20 17:47:26 +05:30
Shivam Mishra 7c73981ddd test: profile token serialization across auth modes 2026-05-20 13:44:51 +05:30
Shivam Mishra fe944720a8 refactor: tighten read-only access token scope enforcement 2026-05-20 13:38:33 +05:30
Shivam Mishra d2de7bed6c test: token creation 2026-05-20 13:22:08 +05:30
Shivam Mishra 5d8fce92f7 feat: expose read-only access token in profile settings 2026-05-20 13:19:58 +05:30
Shivam Mishra e195f9842f feat: gate write operations behind read-only access token scope 2026-05-20 13:19:49 +05:30
Shivam Mishra b96c3df19f feat: add scoped access token model for read-only API access 2026-05-20 13:19:37 +05:30
245 changed files with 4016 additions and 1250 deletions
+1 -1
View File
@@ -76,7 +76,7 @@ gem 'faraday_middleware-aws-sigv4'
##--- gems for server & infra configuration ---##
gem 'dotenv-rails', '>= 3.0.0'
gem 'foreman'
gem 'puma'
gem 'puma', '~> 7.2', '>= 7.2.1'
gem 'vite_rails'
# metrics on heroku
gem 'barnes'
+34 -23
View File
@@ -136,6 +136,8 @@ GEM
audited (5.4.1)
activerecord (>= 5.0, < 7.7)
activesupport (>= 5.0, < 7.7)
auth-sanitizer (0.2.1)
version_gem (~> 1.1, >= 1.1.10)
aws-actionmailbox-ses (0.1.0)
actionmailbox (>= 7.1.0)
aws-sdk-s3 (~> 1, >= 1.123.0)
@@ -168,7 +170,7 @@ GEM
base64 (0.3.0)
bcrypt (3.1.22)
benchmark (0.4.1)
bigdecimal (3.2.2)
bigdecimal (3.3.1)
bindex (0.8.1)
bootsnap (1.16.0)
msgpack (~> 1.2)
@@ -184,6 +186,7 @@ GEM
bundler (>= 1.2.0, < 3)
thor (~> 1.0)
byebug (11.1.3)
cgi (0.5.1)
childprocess (5.1.0)
logger (~> 1.5)
cld3 (3.7.0)
@@ -312,7 +315,7 @@ GEM
hashie
faraday-multipart (1.2.0)
multipart-post (~> 2.0)
faraday-net_http (3.4.2)
faraday-net_http (3.4.4)
net-http (~> 0.5)
faraday-net_http_persistent (2.1.0)
faraday (~> 2.5)
@@ -435,7 +438,8 @@ GEM
hana (1.3.7)
hash_diff (1.1.1)
hashdiff (1.1.0)
hashie (5.0.0)
hashie (5.1.0)
logger
html2text (0.4.0)
nokogiri (>= 1.0, < 2.0)
http (5.1.1)
@@ -470,7 +474,7 @@ GEM
rails-dom-testing (>= 1, < 3)
railties (>= 4.2.0)
thor (>= 0.14, < 2.0)
json (2.19.5)
json (2.19.8)
json_refs (0.1.8)
hana
json_schemer (0.2.24)
@@ -568,7 +572,7 @@ GEM
ruby2_keywords
msgpack (1.8.0)
multi_json (1.15.0)
multi_xml (0.8.0)
multi_xml (0.9.1)
bigdecimal (>= 3.1, < 5)
multipart-post (2.4.1)
mutex_m (0.3.0)
@@ -593,7 +597,7 @@ GEM
sidekiq
newrelic_rpm (9.6.0)
base64
nio4r (2.7.3)
nio4r (2.7.5)
nokogiri (1.19.3)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
@@ -603,19 +607,26 @@ GEM
racc (~> 1.4)
nokogiri (1.19.3-x86_64-linux-gnu)
racc (~> 1.4)
oauth (1.1.0)
oauth-tty (~> 1.0, >= 1.0.1)
snaky_hash (~> 2.0)
version_gem (~> 1.1)
oauth-tty (1.0.5)
version_gem (~> 1.1, >= 1.1.1)
oauth2 (2.0.9)
faraday (>= 0.17.3, < 3.0)
jwt (>= 1.0, < 3.0)
oauth (1.1.6)
auth-sanitizer (~> 0.2, >= 0.2.1)
base64 (~> 0.1)
cgi
oauth-tty (~> 1.0, >= 1.0.8)
snaky_hash (~> 2.0, >= 2.0.5)
version_gem (~> 1.1, >= 1.1.11)
oauth-tty (1.0.8)
auth-sanitizer (~> 0.1, >= 0.1.3)
cgi
version_gem (~> 1.1, >= 1.1.9)
oauth2 (2.0.22)
auth-sanitizer (~> 0.2, >= 0.2.1)
faraday (>= 0.17.3, < 4.0)
jwt (>= 1.0, < 4.0)
logger (~> 1.2)
multi_xml (~> 0.5)
rack (>= 1.2, < 4)
snaky_hash (~> 2.0)
version_gem (~> 1.1)
snaky_hash (~> 2.0, >= 2.0.5)
version_gem (~> 1.1, >= 1.1.11)
oj (3.16.10)
bigdecimal (>= 3.0)
ostruct (>= 0.2)
@@ -682,7 +693,7 @@ GEM
pry-rails (0.3.9)
pry (>= 0.10.4)
public_suffix (7.0.5)
puma (6.4.3)
puma (7.2.1)
nio4r (~> 2.0)
pundit (2.3.0)
activesupport (>= 3.0.0)
@@ -935,9 +946,9 @@ GEM
gli
hashie
logger
snaky_hash (2.0.1)
hashie
version_gem (~> 1.1, >= 1.1.1)
snaky_hash (2.0.5)
hashie (>= 0.1.0, < 6)
version_gem (>= 1.1.8, < 3)
sorbet-runtime (0.5.11934)
spring (4.1.1)
spring-watcher-listen (2.1.0)
@@ -995,7 +1006,7 @@ GEM
valid_email2 (5.2.6)
activemodel (>= 3.2)
mail (~> 2.5)
version_gem (1.1.4)
version_gem (1.1.11)
vite_rails (3.10.0)
railties (>= 5.1, < 9)
vite_ruby (~> 3.0, >= 3.2.2)
@@ -1132,7 +1143,7 @@ DEPENDENCIES
pgvector
procore-sift
pry-rails
puma
puma (~> 7.2, >= 7.2.1)
pundit
rack-attack (>= 6.7.0)
rack-cors (= 2.0.0)
+1 -1
View File
@@ -1 +1 @@
4.14.1
4.14.2
@@ -50,7 +50,7 @@ class ContactInboxWithContactBuilder
def create_contact
account.contacts.create!(
name: contact_attributes[:name] || ::Haikunator.haikunate(1000),
name: contact_name,
phone_number: contact_attributes[:phone_number],
email: contact_attributes[:email],
identifier: contact_attributes[:identifier],
@@ -59,6 +59,11 @@ class ContactInboxWithContactBuilder
)
end
def contact_name
name = contact_attributes[:name] || ::Haikunator.haikunate(1000)
name.truncate(ApplicationRecord::MAX_STRING_COLUMN_LENGTH, omission: '')
end
def find_contact
contact = find_contact_by_identifier(contact_attributes[:identifier])
contact ||= find_contact_by_email(contact_attributes[:email])
+38
View File
@@ -1,9 +1,28 @@
class Api::BaseController < ApplicationController
include AccessTokenAuthHelper
# POST-shaped endpoints that are semantically reads and should be reachable
# with a read-only token. Keep this list minimal; expand only when a clear
# read-with-complex-body use case appears.
READ_ONLY_POST_ALLOWLIST = %w[
api/v1/accounts/conversations#filter
api/v1/accounts/contacts#filter
api/v1/accounts/contact_inboxes#filter
].freeze
# GET-shaped endpoints that mutate state and must be blocked for read-only
# tokens despite the verb being "safe". Add any new write-on-GET endpoint
# here when it ships.
READ_ONLY_BLOCKED_GET_ACTIONS = %w[
api/v1/accounts/callbacks#register_facebook_page
api/v1/accounts/portals#ssl_status
api/v1/accounts/conference#token
].freeze
respond_to :json
before_action :authenticate_access_token!, if: :authenticate_by_access_token?
before_action :validate_bot_access_token!, if: :authenticate_by_access_token?
before_action :authenticate_user!, unless: :authenticate_by_access_token?
before_action :enforce_read_only_token_scope
private
@@ -11,6 +30,25 @@ class Api::BaseController < ApplicationController
request.headers[:api_access_token].present? || request.headers[:HTTP_API_ACCESS_TOKEN].present?
end
def enforce_read_only_token_scope
return unless @access_token&.scope == 'read_only'
return if read_only_token_action_allowed?
render json: { error: 'This access token is read-only and cannot perform write operations.' },
status: :forbidden
end
def read_only_token_action_allowed?
action_key = "#{params[:controller]}##{params[:action]}"
return READ_ONLY_POST_ALLOWLIST.include?(action_key) unless safe_http_method?
READ_ONLY_BLOCKED_GET_ACTIONS.exclude?(action_key)
end
def safe_http_method?
request.get? || request.head? || request.options?
end
def check_authorization(model = nil)
model ||= controller_name.classify.constantize
@@ -1,5 +1,10 @@
class Api::V1::Accounts::Conversations::DirectUploadsController < ActiveStorage::DirectUploadsController
include EnsureCurrentAccountHelper
include AccessTokenAuthHelper
include RequestExceptionHandler
before_action :authenticate_access_token!
before_action :validate_bot_access_token!
before_action :prevent_read_only_access_token!
before_action :current_account
before_action :conversation
@@ -6,8 +6,7 @@ class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts
{
redirect_uri: "#{base_url}/microsoft/callback",
scope: scope,
state: state,
prompt: 'consent'
state: state
}
)
if redirect_url
@@ -16,6 +16,10 @@ class Api::V1::Accounts::OnboardingsController < Api::V1::Accounts::BaseControll
render 'api/v1/accounts/update', format: :json
end
def help_center_generation
render json: help_center_generation_status
end
private
def finalizing_account_details?
@@ -33,4 +37,15 @@ class Api::V1::Accounts::OnboardingsController < Api::V1::Accounts::BaseControll
def custom_attributes_params
params.permit(:industry, :company_size, :timezone, :referral_source, :user_role, :website)
end
def help_center_generation_status
{
generation_id: nil,
state: nil,
articles_count: 0,
categories_count: 0
}
end
end
Api::V1::Accounts::OnboardingsController.prepend_mod_with('Api::V1::Accounts::OnboardingsController')
@@ -80,10 +80,15 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
:id, :color, :custom_domain, :header_text, :homepage_link,
:name, :page_title, :slug, :archived,
{ config: [:default_locale, :layout, { allowed_locales: [] }, { draft_locales: [] },
{ social_profiles: %i[facebook x instagram linkedin youtube tiktok github whatsapp] }] }
{ social_profiles: %i[facebook x instagram linkedin youtube tiktok github whatsapp] },
{ locale_translations: locale_translation_keys.index_with { %i[name page_title header_text] } }] }
)
end
def locale_translation_keys
params.dig(:portal, :config, :locale_translations)&.keys || []
end
def live_chat_widget_params
permitted_params = params.permit(:inbox_id)
return {} unless permitted_params.key?(:inbox_id)
@@ -39,7 +39,14 @@ class Api::V1::ProfilesController < Api::BaseController
end
def reset_access_token
@user.access_token.regenerate_token
token = AccessToken.create_or_find_by!(owner: @user, scope: 'full')
token.regenerate_token
@user.reload
end
def reset_read_only_access_token
token = AccessToken.create_or_find_by!(owner: @user, scope: 'read_only')
token.regenerate_token
@user.reload
end
@@ -1,8 +1,9 @@
module AccessTokenAuthHelper
BOT_ACCESSIBLE_ENDPOINTS = {
'api/v1/accounts/conversations' => %w[toggle_status toggle_typing_status toggle_priority create update custom_attributes],
'api/v1/accounts/conversations' => %w[show toggle_status toggle_typing_status toggle_priority create update custom_attributes],
'api/v1/accounts/conversations/messages' => ['create'],
'api/v1/accounts/conversations/assignments' => ['create']
'api/v1/accounts/conversations/assignments' => ['create'],
'api/v1/accounts/conversations/labels' => %w[index create]
}.freeze
def ensure_access_token
@@ -36,4 +37,15 @@ module AccessTokenAuthHelper
def agent_bot_accessible?
BOT_ACCESSIBLE_ENDPOINTS.fetch(params[:controller], []).include?(params[:action])
end
# Blocks read-only access tokens from reaching write endpoints that live outside
# Api::BaseController (e.g. controllers inheriting from ActiveStorage). Resolves
# the token directly since these controllers skip the Api::BaseController chain.
def prevent_read_only_access_token!
ensure_access_token
return unless @access_token&.scope == 'read_only'
render json: { error: 'This access token is read-only and cannot perform write operations.' },
status: :forbidden
end
end
@@ -9,7 +9,7 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl
layout 'portal'
def show
@og_image_url = helpers.set_og_image_url('', @portal.header_text)
@og_image_url = helpers.set_og_image_url('', @portal.localized_value('header_text', @locale))
end
def sitemap
+1 -1
View File
@@ -12,7 +12,7 @@ class ContactDrop < BaseDrop
end
def first_name
@obj.try(:name).try(:split).try(:first).try(:capitalize) if @obj.try(:name).try(:split).try(:size) > 1
@obj.try(:name).try(:split).try(:first).try(:capitalize)
end
def last_name
+1 -1
View File
@@ -12,7 +12,7 @@ class UserDrop < BaseDrop
end
def first_name
@obj.try(:name).try(:split).try(:first).try(:capitalize) if @obj.try(:name).try(:split).try(:size).to_i > 1
@obj.try(:name).try(:split).try(:first).try(:capitalize)
end
def last_name
+12 -1
View File
@@ -40,7 +40,7 @@ class ConversationFinder
def perform
set_up
mine_count, unassigned_count, all_count, = set_count_for_all_conversations
mine_count, unassigned_count, all_count = set_count_for_all_conversations
assigned_count = all_count - unassigned_count
filter_by_assignee_type
@@ -184,6 +184,17 @@ class ConversationFinder
end
def set_count_for_all_conversations
return legacy_count_for_all_conversations if @conversations.limit_value || @conversations.offset_value || @conversations.eager_loading?
counts = @conversations.unscope(:order).pick(
Arel.sql("COUNT(*) FILTER (WHERE assignee_id = #{current_user.id})"),
Arel.sql('COUNT(*) FILTER (WHERE assignee_id IS NULL)'),
Arel.sql('COUNT(*)')
)
counts || [0, 0, 0]
end
def legacy_count_for_all_conversations
[
@conversations.assigned_to(current_user).count,
@conversations.unassigned.count,
+6
View File
@@ -34,6 +34,12 @@ disable_branding:
enabled: <%= (ChatwootHub.pricing_plan != 'community') %>
icon: 'icon-sailbot-fill'
enterprise: true
voice_calls:
name: 'Voice Calls'
description: 'Enable voice calling capabilities for your agents and customers.'
enabled: <%= (ChatwootHub.pricing_plan != 'community') %>
icon: 'icon-voice-line'
enterprise: true
# ------- Product Features ------- #
help_center:
+4
View File
@@ -106,4 +106,8 @@ export default {
const urlData = endPoints('resetAccessToken');
return axios.post(urlData.url);
},
resetReadOnlyAccessToken() {
const urlData = endPoints('resetReadOnlyAccessToken');
return axios.post(urlData.url);
},
};
@@ -48,6 +48,12 @@ class TwilioVoiceClient extends EventTarget {
return !!this.activeConnection;
}
setMuted(shouldMute) {
if (!this.activeConnection) return false;
this.activeConnection.mute(shouldMute);
return shouldMute;
}
endClientCall() {
if (this.activeConnection) {
this.activeConnection.disconnect();
@@ -55,6 +55,10 @@ const endPoints = {
resetAccessToken: {
url: '/api/v1/profile/reset_access_token',
},
resetReadOnlyAccessToken: {
url: '/api/v1/profile/reset_read_only_access_token',
},
};
export default page => {
+6
View File
@@ -60,6 +60,12 @@ class Inboxes extends CacheEnabledApiClient {
disableWhatsappCalling(inboxId) {
return axios.post(`${this.url}/${inboxId}/disable_whatsapp_calling`);
}
setInboundCalls(inboxId, enabled) {
return axios.post(`${this.url}/${inboxId}/set_inbound_calls`, {
inbound_calls_enabled: enabled,
});
}
}
export default new Inboxes();
@@ -9,6 +9,10 @@ class OnboardingAPI extends ApiClient {
update(data) {
return axios.patch(this.url, data);
}
getHelpCenterGeneration() {
return axios.get(`${this.url}/help_center_generation`);
}
}
export default new OnboardingAPI();
@@ -46,9 +46,8 @@ const formattedLastActivityAt = computed(() => {
:src="avatarSource"
class="shrink-0"
:name="name"
:size="48"
:size="42"
hide-offline-status
rounded-full
/>
<div class="flex flex-col gap-0.5 flex-1 min-w-0">
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 min-w-0">
@@ -141,7 +141,6 @@ const handleUpdateCompany = async () => {
:src="avatarSource"
:size="72"
:allow-upload="!isAvatarBusy"
rounded-full
hide-offline-status
@upload="handleAvatarUpload"
@delete="handleAvatarDelete"
@@ -124,10 +124,9 @@ const handleAvatarHover = isHovered => {
<Avatar
:name="name"
:src="thumbnail"
:size="48"
:size="42"
:status="availabilityStatus"
hide-offline-status
rounded-full
>
<template v-if="selectable" #overlay="{ size }">
<label
@@ -53,6 +53,9 @@ const localeMenuLabels = computed(() => ({
'publish-locale': t(
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.PUBLISH_LOCALE'
),
'customize-content': t(
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.CUSTOMIZE_CONTENT'
),
delete: t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.DELETE'),
}));
@@ -128,7 +131,7 @@ const handleAction = ({ action, value }) => {
<DropdownMenu
v-if="showDropdownMenu"
:menu-items="localeMenuItems"
class="ltr:right-0 rtl:left-0 mt-1 xl:ltr:left-0 xl:rtl:right-0 top-full z-60 min-w-[150px]"
class="ltr:right-0 rtl:left-0 mt-1 top-full z-60 min-w-[150px]"
@action="handleAction"
/>
</div>
@@ -0,0 +1,98 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Input from 'dashboard/components-next/input/Input.vue';
const props = defineProps({
portal: {
type: Object,
default: () => ({}),
},
});
const { t } = useI18n();
const store = useStore();
const dialogRef = ref(null);
const activeLocale = ref('');
const name = ref('');
const pageTitle = ref('');
const headerText = ref('');
const localeTranslations = computed(
() => props.portal?.config?.locale_translations || {}
);
const openForLocale = localeCode => {
const existing = localeTranslations.value[localeCode] || {};
activeLocale.value = localeCode;
name.value = existing.name || '';
pageTitle.value = existing.page_title || '';
headerText.value = existing.header_text || '';
dialogRef.value?.open();
};
const onConfirm = async () => {
const translations = { ...localeTranslations.value };
const fields = {};
if (name.value.trim()) fields.name = name.value.trim();
if (pageTitle.value.trim()) fields.page_title = pageTitle.value.trim();
if (headerText.value.trim()) fields.header_text = headerText.value.trim();
if (Object.keys(fields).length) {
translations[activeLocale.value] = fields;
} else {
delete translations[activeLocale.value];
}
try {
await store.dispatch('portals/update', {
portalSlug: props.portal?.slug,
config: { locale_translations: translations },
});
dialogRef.value?.close();
useAlert(t('HELP_CENTER.LOCALES_PAGE.CONTENT_DIALOG.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(
error?.message ||
t('HELP_CENTER.LOCALES_PAGE.CONTENT_DIALOG.API.ERROR_MESSAGE')
);
}
};
defineExpose({ openForLocale });
</script>
<template>
<Dialog
ref="dialogRef"
type="edit"
:title="t('HELP_CENTER.LOCALES_PAGE.CONTENT_DIALOG.TITLE')"
:description="t('HELP_CENTER.LOCALES_PAGE.CONTENT_DIALOG.DESCRIPTION')"
@confirm="onConfirm"
>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-1">
<label class="text-sm font-medium text-n-slate-12">
{{ t('HELP_CENTER.LOCALES_PAGE.CONTENT_DIALOG.NAME.LABEL') }}
</label>
<Input v-model="name" :placeholder="portal.name" />
</div>
<div class="flex flex-col gap-1">
<label class="text-sm font-medium text-n-slate-12">
{{ t('HELP_CENTER.LOCALES_PAGE.CONTENT_DIALOG.PAGE_TITLE.LABEL') }}
</label>
<Input v-model="pageTitle" :placeholder="portal.page_title" />
</div>
<div class="flex flex-col gap-1">
<label class="text-sm font-medium text-n-slate-12">
{{ t('HELP_CENTER.LOCALES_PAGE.CONTENT_DIALOG.HEADER_TEXT.LABEL') }}
</label>
<Input v-model="headerText" :placeholder="portal.header_text" />
</div>
</div>
</Dialog>
</template>
@@ -1,5 +1,7 @@
<script setup>
import { ref } from 'vue';
import LocaleCard from 'dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.vue';
import LocaleContentDialog from 'dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleContentDialog.vue';
import { useStore } from 'dashboard/composables/store';
import { useAlert, useTrack } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
@@ -23,6 +25,8 @@ const { t } = useI18n();
const route = useRoute();
const { uiSettings, updateUISettings } = useUISettings();
const contentDialogRef = ref(null);
const isLocaleDefault = code => {
return props.portal?.meta?.default_locale === code;
};
@@ -148,6 +152,8 @@ const handleAction = ({ action }, localeCode) => {
moveLocaleToDraft({ localeCode: localeCode });
} else if (action === 'publish-locale') {
publishLocale({ localeCode: localeCode });
} else if (action === 'customize-content') {
contentDialogRef.value.openForLocale(localeCode);
} else if (action === 'delete') {
deletePortalLocale({ localeCode: localeCode });
}
@@ -167,5 +173,6 @@ const handleAction = ({ action }, localeCode) => {
:category-count="locale.categoriesCount || 0"
@action="handleAction($event, locale.code)"
/>
<LocaleContentDialog ref="contentDialogRef" :portal="portal" />
</ul>
</template>
@@ -182,7 +182,10 @@ const MIN_SEARCH_LENGTH = 2;
export const createContactSearcher = () => {
let controller = null;
return async (query, { skipMinLength = false } = {}) => {
return async (
query,
{ skipMinLength = false, reachableOnly = true } = {}
) => {
const trimmed = typeof query === 'string' ? query.trim() : '';
controller?.abort();
@@ -199,6 +202,8 @@ export const createContactSearcher = () => {
} = await ContactAPI.search(trimmed, 1, 'name', '', { signal });
const camelCasedPayload = camelcaseKeys(payload, { deep: true });
if (!reachableOnly) return camelCasedPayload || [];
// Filter contacts that have either phone_number or email
const filteredPayload = camelCasedPayload?.filter(
contact => contact.phoneNumber || contact.email
@@ -197,9 +197,9 @@ const channelIcon = computed(() => {
? $t('CONVERSATION.VOICE_WIDGET.END_CALL')
: $t('CONVERSATION.VOICE_WIDGET.REJECT_CALL')
"
icon="i-ph-phone-x-bold"
icon="i-ph-phone-bold"
ruby
class="!rounded-full rotate-[134deg]"
class="!rounded-full rotate-[135deg]"
@click="isOngoing ? $emit('end') : $emit('reject')"
/>
</div>
@@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router';
import { useStore } from 'vuex';
import { useCallSession } from 'dashboard/composables/useCallSession';
import { setWhatsappCallMuted } from 'dashboard/composables/useWhatsappCallSession';
import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import { VOICE_CALL_PROVIDERS } from 'dashboard/helper/inbox';
import { VOICE_CALL_DIRECTION } from 'dashboard/components-next/message/constants';
@@ -11,7 +12,7 @@ import WindowVisibilityHelper from 'dashboard/helper/AudioAlerts/WindowVisibilit
import CallCard from 'dashboard/components-next/call/CallCard.vue';
import countriesList from 'shared/constants/countries.js';
const RINGTONE_URL = '/audio/dashboard/bell.mp3';
const RINGTONE_URL = '/audio/dashboard/ringtone.mp3';
const route = useRoute();
const router = useRouter();
@@ -29,8 +30,8 @@ const {
formattedCallDuration,
} = useCallSession();
// Mute is currently WhatsApp-only — Twilio calls are mediated server-side and
// don't expose a mic track on the browser side.
// Mute routes by provider: WhatsApp toggles the local mic track, Twilio uses
// the Voice SDK connection's native mute. Both surface the same button.
const isMuted = ref(false);
const isWhatsappActive = computed(
() => activeCall.value?.provider === VOICE_CALL_PROVIDERS.WHATSAPP
@@ -63,7 +64,11 @@ const stackedCardState = call =>
const toggleMute = () => {
isMuted.value = !isMuted.value;
setWhatsappCallMuted(isMuted.value);
if (isWhatsappActive.value) {
setWhatsappCallMuted(isMuted.value);
} else {
TwilioVoiceClient.setMuted(isMuted.value);
}
};
watch(hasActiveCall, active => {
@@ -256,7 +261,7 @@ onBeforeUnmount(stopRingtone);
:call-info="getCallInfo(activeCall || primaryIncomingCall)"
:duration="hasActiveCall ? formattedCallDuration : ''"
:is-muted="isMuted"
:show-mute="hasActiveCall && isWhatsappActive"
:show-mute="hasActiveCall"
@accept="handleJoinCall(primaryIncomingCall)"
@reject="rejectIncomingCall(primaryIncomingCall?.callSid)"
@dismiss="dismissCall(primaryIncomingCall?.callSid)"
@@ -1,6 +1,7 @@
<script setup>
import { computed, h, watch, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { debounce } from '@chatwoot/utils';
import Button from 'next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import FilterSelect from './inputs/FilterSelect.vue';
@@ -109,6 +110,34 @@ const inputFieldType = computed(() => {
return 'text';
});
const asyncOptions = ref([]);
const isSearching = ref(false);
const lastSearchQuery = ref('');
const performAsyncSearch = async query => {
let results;
try {
results = await currentFilter.value.searchOptions(query);
} catch {
results = [];
}
// skip stale responses — a newer search in this row owns the UI
if (query !== lastSearchQuery.value) return;
// null means another row's search aborted ours, reset instead of staying stuck on the searching state
if (results !== null) asyncOptions.value = results;
isSearching.value = false;
};
const debouncedAsyncSearch = debounce(performAsyncSearch, 300);
const onAsyncSearch = query => {
const hasQuery = !!query.trim();
lastSearchQuery.value = query;
if (!hasQuery) asyncOptions.value = [];
isSearching.value = hasQuery;
debouncedAsyncSearch(query);
};
const resetModelOnAttributeKeyChange = newAttributeKey => {
/**
* Resets the filter values and operator when the attribute key changes. This ensures that
@@ -121,11 +150,16 @@ const resetModelOnAttributeKeyChange = newAttributeKey => {
const newInputType = getInputType(newOperator, filter);
if (newInputType === 'multiSelect') {
values.value = [];
} else if (['searchSelect', 'booleanSelect'].includes(newInputType)) {
} else if (
['searchSelect', 'asyncSearchSelect', 'booleanSelect'].includes(
newInputType
)
) {
values.value = {};
} else {
values.value = '';
}
asyncOptions.value = [];
filterOperator.value = newOperator.value;
};
@@ -185,6 +219,16 @@ defineExpose({ validate, resetValidation });
:options="currentFilter.options"
dropdown-max-height="max-h-64"
/>
<SingleSelect
v-else-if="inputType === 'asyncSearchSelect'"
v-model="values"
async-search
:options="asyncOptions"
:is-searching="isSearching"
:search-placeholder="currentFilter.searchPlaceholder"
dropdown-max-height="max-h-64"
@search="onAsyncSearch"
/>
<SingleSelect
v-else-if="inputType === 'booleanSelect'"
v-model="values"
@@ -7,6 +7,7 @@ export const CONVERSATION_ATTRIBUTES = {
ASSIGNEE_ID: 'assignee_id',
INBOX_ID: 'inbox_id',
TEAM_ID: 'team_id',
CONTACT_ID: 'contact_id',
DISPLAY_ID: 'display_id',
CAMPAIGN_ID: 'campaign_id',
LABELS: 'labels',
@@ -1,9 +1,37 @@
import { ref } from 'vue';
import ContactAPI from 'dashboard/api/contacts';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useConversationFilterContext } from '../provider';
import {
CONVERSATION_ATTRIBUTES,
getCustomAttributeInputType,
buildAttributesFilterTypes,
replaceUnderscoreWithSpace,
} from './filterHelper';
vi.mock('dashboard/api/contacts', () => ({
default: {
search: vi.fn(),
},
}));
vi.mock('dashboard/composables/store.js', () => ({
useMapGetter: vi.fn(),
}));
vi.mock('next/icon/provider', () => ({
useChannelIcon: () => ref('i-test-channel'),
}));
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key, params = {}) => {
if (key === 'FILTER.CONTACT_FALLBACK') return `Contact #${params.id}`;
return key;
},
}),
}));
describe('filterHelper', () => {
describe('getCustomAttributeInputType', () => {
it('returns date for date type', () => {
@@ -135,3 +163,64 @@ describe('filterHelper', () => {
});
});
});
const storeValues = {
'attributes/getConversationAttributes': ref([]),
'labels/getLabels': ref([]),
'agents/getAgents': ref([]),
'inboxes/getInboxes': ref([]),
'teams/getTeams': ref([]),
'campaigns/getAllCampaigns': ref([]),
};
describe('useConversationFilterContext', () => {
beforeEach(() => {
vi.clearAllMocks();
useMapGetter.mockImplementation(key => storeValues[key] || ref([]));
});
it('exposes contact as an async searchable conversation filter', () => {
const { filterTypes } = useConversationFilterContext();
const contactFilter = filterTypes.value.find(
filter => filter.attributeKey === CONVERSATION_ATTRIBUTES.CONTACT_ID
);
expect(contactFilter).toMatchObject({
attributeKey: 'contact_id',
label: 'FILTER.ATTRIBUTES.CONTACT',
inputType: 'asyncSearchSelect',
dataType: 'number',
attributeModel: 'standard',
});
expect(
contactFilter.filterOperators.map(operator => operator.value)
).toEqual(['equal_to', 'not_equal_to']);
});
it('uses the existing contact search API for contact filter options', async () => {
ContactAPI.search.mockResolvedValue({
data: {
payload: [
{ id: 1, name: 'Jane Doe' },
{ id: 2, email: 'alex@example.com' },
{ id: 3 },
],
},
});
const { filterTypes } = useConversationFilterContext();
const contactFilter = filterTypes.value.find(
filter => filter.attributeKey === CONVERSATION_ATTRIBUTES.CONTACT_ID
);
const options = await contactFilter.searchOptions('jane');
expect(ContactAPI.search).toHaveBeenCalledWith('jane', 1, 'name', '', {
signal: expect.any(AbortSignal),
});
expect(options).toEqual([
{ id: 1, name: 'Jane Doe' },
{ id: 2, name: 'alex@example.com' },
{ id: 3, name: 'Contact #3' },
]);
});
});
@@ -11,6 +11,8 @@ import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue';
const {
options,
asyncSearch,
isSearching,
disableSearch,
disableDeselect,
placeholderIcon,
@@ -23,6 +25,14 @@ const {
type: Array,
required: true,
},
asyncSearch: {
type: Boolean,
default: false,
},
isSearching: {
type: Boolean,
default: false,
},
disableSearch: {
type: Boolean,
default: false,
@@ -53,6 +63,12 @@ const {
},
});
const emit = defineEmits(['search']);
// the input is re-inserted on every dropdown open (v-if),
// where the native autofocus attribute is ignored so focus it via a directive instead
const vFocus = { mounted: el => el.focus() };
const { t } = useI18n();
const selected = defineModel({
type: Object,
@@ -60,7 +76,9 @@ const selected = defineModel({
});
const searchTerm = ref('');
const searchResults = computed(() => {
if (asyncSearch) return options;
if (!options) return [];
return picoSearch(options, searchTerm.value, ['name']);
});
@@ -77,7 +95,11 @@ const selectedItem = computed(() => {
if (!optionToSearch) return null;
// extract the selected item from the options array
// this ensures that options like icon is also included
return options.find(option => option.id === optionToSearch.id);
return (
options.find(option => option.id === optionToSearch.id) ||
// async options may not include the selected option, fall back to it
(asyncSearch && optionToSearch.id !== undefined ? optionToSearch : null)
);
});
const toggleSelected = option => {
@@ -131,13 +153,19 @@ const toggleSelected = option => {
<Icon class="absolute size-4 left-2 top-2" icon="i-lucide-search" />
<input
v-model="searchTerm"
autofocus
v-focus
class="p-1.5 pl-8 text-n-slate-11 bg-n-alpha-1 rounded-lg w-full"
:placeholder="searchPlaceholder || t('COMBOBOX.SEARCH_PLACEHOLDER')"
@input="emit('search', $event.target.value)"
/>
</div>
<DropdownSection :height="dropdownMaxHeight">
<template v-if="searchResults.length">
<template v-if="isSearching">
<DropdownItem disabled>
{{ t('DROPDOWN_MENU.SEARCHING') }}
</DropdownItem>
</template>
<template v-else-if="searchResults.length">
<DropdownItem
v-for="option in searchResults"
:key="option.id"
@@ -3,6 +3,7 @@ import { useI18n } from 'vue-i18n';
import { useOperators } from './operators';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useChannelIcon } from 'next/icon/provider';
import { createContactSearcher } from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper';
import {
buildAttributesFilterTypes,
CONVERSATION_ATTRIBUTES,
@@ -30,7 +31,7 @@ import languages from 'dashboard/components/widgets/conversation/advancedFilterI
* @property {string} value - This is a proxy for the attribute key used in FilterSelect
* @property {string} attributeName - The attribute name used to display on the UI
* @property {string} label - This is a proxy for the attribute name used in FilterSelect
* @property {'multiSelect'|'searchSelect'|'plainText'|'date'|'booleanSelect'} inputType - The input type for the attribute
* @property {'multiSelect'|'searchSelect'|'asyncSearchSelect'|'plainText'|'date'|'booleanSelect'} inputType - The input type for the attribute
* @property {FilterOption[]} [options] - The options available for the attribute if it is a multiSelect or singleSelect type
* @property {'text'|'number'} dataType
* @property {FilterOperator[]} filterOperators - The operators available for the attribute
@@ -68,6 +69,30 @@ export function useConversationFilterContext() {
getOperatorTypes,
} = useOperators();
const searchContacts = createContactSearcher();
const contactOptionName = contact =>
contact.name ||
contact.email ||
contact.phoneNumber ||
contact.identifier ||
t('FILTER.CONTACT_FALLBACK', { id: contact.id });
const searchContactOptions = async query => {
const contacts = await searchContacts(query, {
skipMinLength: true,
reachableOnly: false,
});
// null means the request was aborted (a newer search is in-flight)
if (contacts === null) return null;
return contacts.map(contact => ({
id: contact.id,
name: contactOptionName(contact),
}));
};
/**
* @type {import('vue').ComputedRef<FilterType[]>}
*/
@@ -158,6 +183,18 @@ export function useConversationFilterContext() {
filterOperators: presenceOperators.value,
attributeModel: 'standard',
},
{
attributeKey: CONVERSATION_ATTRIBUTES.CONTACT_ID,
value: CONVERSATION_ATTRIBUTES.CONTACT_ID,
attributeName: t('FILTER.ATTRIBUTES.CONTACT'),
label: t('FILTER.ATTRIBUTES.CONTACT'),
inputType: 'asyncSearchSelect',
searchOptions: searchContactOptions,
searchPlaceholder: t('FILTER.CONTACT_SEARCH_PLACEHOLDER'),
dataType: 'number',
filterOperators: equalityOperators.value,
attributeModel: 'standard',
},
{
attributeKey: CONVERSATION_ATTRIBUTES.DISPLAY_ID,
value: CONVERSATION_ATTRIBUTES.DISPLAY_ID,
@@ -175,6 +175,9 @@ useEventListener(document, 'touchend', onResizeEnd);
const inboxes = useMapGetter('inboxes/getInboxes');
const labels = useMapGetter('labels/getLabelsOnSidebar');
const allUnreadCount = useMapGetter(
'conversationUnreadCounts/getAllUnreadCount'
);
const getInboxUnreadCount = useMapGetter(
'conversationUnreadCounts/getInboxUnreadCount'
);
@@ -297,6 +300,7 @@ const menuItems = computed(() => {
{
name: 'All',
label: t('SIDEBAR.ALL_CONVERSATIONS'),
badgeCount: allUnreadCount.value,
activeOn: ['inbox_conversation'],
to: accountScopedRoute('home'),
},
@@ -145,7 +145,6 @@ const allowedMenuItems = computed(() => {
:src="currentUser.avatar_url"
:status="currentUserAvailability"
class="flex-shrink-0"
rounded-full
/>
<div v-if="!isCollapsed" class="min-w-0">
<div class="text-sm font-medium leading-4 truncate text-n-slate-12">
@@ -151,6 +151,9 @@ const activeFolder = computed(() => {
return undefined;
});
const getContact = useMapGetter('contacts/getContact');
const folderContactId = useMapGetter('customViews/getActiveFolderContactId');
const activeFolderName = computed(() => {
return activeFolder.value?.name;
});
@@ -456,6 +459,7 @@ function setParamsForEditFolderModal() {
inboxes: inboxesList.value,
labels: labels.value,
campaigns: campaigns.value,
contacts: [getContact.value(folderContactId.value)],
languages: languages,
countries: countries,
priority: [
@@ -32,7 +32,6 @@ import {
CONVERSATION_EVENTS,
CAPTAIN_EVENTS,
} from 'dashboard/helper/AnalyticsHelper/events';
import { MESSAGE_EDITOR_IMAGE_RESIZES } from 'dashboard/constants/editor';
import {
messageSchema,
@@ -43,6 +42,7 @@ import {
MessageMarkdownSerializer,
EditorState,
Selection,
imageResizeView,
} from '@chatwoot/prosemirror-schema';
import {
suggestionsPlugin,
@@ -57,7 +57,6 @@ import {
insertAtCursor,
removeSignature as removeSignatureHelper,
scrollCursorIntoView,
setURLWithQueryAndSize,
getFormattingForEditor,
getSelectionCoords,
calculateMenuPosition,
@@ -72,6 +71,7 @@ import {
import { createTypingIndicator } from '@chatwoot/utils';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { uploadFile } from 'dashboard/helper/uploadHelper';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
const props = defineProps({
modelValue: { type: String, default: '' },
@@ -93,7 +93,6 @@ const props = defineProps({
channelType: { type: String, default: '' },
conversationId: { type: Number, default: null },
medium: { type: String, default: '' },
showImageResizeToolbar: { type: Boolean, default: false }, // A kill switch to show or hide the image toolbar
focusOnMount: { type: Boolean, default: true },
});
@@ -119,6 +118,14 @@ const TYPING_INDICATOR_IDLE_TIME = 4000;
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
const DEFAULT_FORMATTING = 'Context::Default';
const PRIVATE_NOTE_FORMATTING = 'Context::PrivateNote';
const MESSAGE_SIGNATURE_FORMATTING = 'Context::MessageSignature';
const INLINE_IMAGE_PASTE_TYPES = [
'image/png',
'image/jpeg',
'image/jpg',
'image/gif',
'image/webp',
];
const effectiveChannelType = computed(() =>
getEffectiveChannelType(props.channelType, props.medium)
@@ -192,12 +199,8 @@ const cannedSearchTerm = ref('');
const variableSearchTerm = ref('');
const emojiSearchTerm = ref('');
const range = ref(null);
const isImageNodeSelected = ref(false);
const toolbarPosition = ref({ top: 0, left: 0 });
const selectedImageNode = ref(null);
const isTextSelected = ref(false); // Tracks text selection and prevents unnecessary re-renders on mouse selection
const showSelectionMenu = ref(false);
const sizes = MESSAGE_EDITOR_IMAGE_RESIZES;
// element ref
const editorRoot = useTemplateRef('editorRoot');
@@ -475,16 +478,6 @@ function removeSignature() {
reloadState(content);
}
function setToolbarPosition() {
const editorRect = editorRoot.value.getBoundingClientRect();
const rect = selectedImageNode.value.getBoundingClientRect();
toolbarPosition.value = {
top: `${rect.top - editorRect.top - 30}px`,
left: `${rect.left - editorRect.left - 4}px`,
};
}
function setMenubarPosition({ selection } = {}) {
const wrapper = editorRoot.value;
if (!selection || !wrapper) return;
@@ -520,30 +513,6 @@ function checkSelection(editorState) {
if (hasSelection) setMenubarPosition(editorState);
}
function setURLWithQueryAndImageSize(size) {
if (!props.showImageResizeToolbar) {
return;
}
setURLWithQueryAndSize(selectedImageNode.value, size, editorView);
isImageNodeSelected.value = false;
}
function isEditorMouseFocusedOnAnImage() {
if (!props.showImageResizeToolbar) {
return;
}
selectedImageNode.value = document.querySelector(
'img.ProseMirror-selectednode'
);
if (selectedImageNode.value) {
isImageNodeSelected.value = !!selectedImageNode.value;
// Get the position of the selected node
setToolbarPosition();
} else {
isImageNodeSelected.value = false;
}
}
function emitOnChange() {
emit('input', contentFromEditor());
emit('update:modelValue', contentFromEditor());
@@ -563,21 +532,6 @@ function toggleSignatureInEditor(signatureEnabled) {
emitOnChange();
}
function updateImgToolbarOnDelete() {
// check if the selected node is present or not on keyup
// this is needed because the user can select an image and then delete it
// in that case, the selected node will be null and we need to hide the toolbar
// otherwise, the toolbar will be visible even when the image is deleted and cause some errors
if (selectedImageNode.value) {
const hasImgSelectedNode = document.querySelector(
'img.ProseMirror-selectednode'
);
if (!hasImgSelectedNode) {
isImageNodeSelected.value = false;
}
}
}
function isEnterToSendEnabled() {
return isEditorHotKeyEnabled('enter');
}
@@ -586,17 +540,6 @@ function isCmdPlusEnterToSendEnabled() {
return isEditorHotKeyEnabled('cmd_enter');
}
useKeyboardEvents({
'Alt+KeyP': {
action: focusEditorInputField,
allowOnFocusedInput: false,
},
'Alt+KeyL': {
action: focusEditorInputField,
allowOnFocusedInput: false,
},
});
function onImageInsertInEditor(fileUrl) {
const { tr } = editorView.state;
@@ -617,7 +560,11 @@ async function uploadImageToStorage(file) {
onImageInsertInEditor(fileUrl);
}
useAlert(
t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.IMAGE_UPLOAD_SUCCESS')
props.channelType === MESSAGE_SIGNATURE_FORMATTING
? t(
'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.IMAGE_UPLOAD_SUCCESS'
)
: t('CONVERSATION.REPLYBOX.IMAGE_UPLOAD_SUCCESS')
);
} catch (error) {
useAlert(
@@ -626,8 +573,8 @@ async function uploadImageToStorage(file) {
}
}
function onFileChange() {
const file = imageUpload.value.files[0];
function uploadImageIfWithinSizeLimit(file) {
if (!file) return;
if (checkFileSizeLimit(file, MAXIMUM_FILE_UPLOAD_SIZE)) {
uploadImageToStorage(file);
} else {
@@ -640,10 +587,61 @@ function onFileChange() {
)
);
}
imageUpload.value = '';
}
function onFileChange() {
const input = imageUpload.value;
uploadImageIfWithinSizeLimit(input.files[0]);
input.value = '';
}
const allowsInlineImagePaste = computed(
() =>
!props.isPrivate &&
(props.channelType === INBOX_TYPES.EMAIL ||
props.channelType === INBOX_TYPES.WEB)
);
// Shift+Cmd/Ctrl+V on email/website: upload a clipboard image inline. This
// gesture's native paste event carries no image, so clipboard.read() is the
// only way to get the bytes. No preventDefault: text still pastes natively.
async function pasteInlineImageFromClipboard() {
if (!editorView?.hasFocus()) return;
if (!allowsInlineImagePaste.value || !navigator.clipboard?.read) return;
try {
const items = await navigator.clipboard.read();
const imageItem = items.find(item =>
item.types.some(type => INLINE_IMAGE_PASTE_TYPES.includes(type))
);
if (!imageItem) return;
const imageType = imageItem.types.find(type =>
INLINE_IMAGE_PASTE_TYPES.includes(type)
);
const blob = await imageItem.getType(imageType);
uploadImageIfWithinSizeLimit(
new File([blob], 'pasted-image', { type: imageType })
);
} catch (error) {
// clipboard-read denied/unfocused (NotAllowedError): image can't be read.
// Text paste is unaffected — ProseMirror handles it from the native event.
}
}
useKeyboardEvents({
'Alt+KeyP': {
action: focusEditorInputField,
allowOnFocusedInput: false,
},
'Alt+KeyL': {
action: focusEditorInputField,
allowOnFocusedInput: false,
},
'$mod+Shift+KeyV': {
action: pasteInlineImageFromClipboard,
allowOnFocusedInput: true,
},
});
function handleLineBreakWhenEnterToSendEnabled(event) {
if (
hasPressedEnterAndNotCmdOrShift(event) &&
@@ -736,6 +734,9 @@ function createEditorView() {
editorView = new EditorView(editor.value, {
state: state,
editable: () => !props.disabled,
nodeViews: {
image: imageResizeView,
},
dispatchTransaction: tx => {
state = state.apply(tx);
editorView.updateState(state);
@@ -748,12 +749,10 @@ function createEditorView() {
keyup: () => {
if (!props.disabled) {
typingIndicator.start();
updateImgToolbarOnDelete();
}
},
keydown: (view, event) => !props.disabled && onKeydown(event),
focus: () => !props.disabled && emit('focus'),
click: () => !props.disabled && isEditorMouseFocusedOnAnImage(),
blur: () => {
if (props.disabled) return;
typingIndicator.stop();
@@ -918,23 +917,6 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
@change="onFileChange"
/>
<div ref="editor" />
<div
v-show="isImageNodeSelected && showImageResizeToolbar"
class="absolute shadow-md rounded-[6px] flex gap-1 py-1 px-1 bg-n-solid-3 outline outline-1 outline-n-weak text-n-slate-12"
:style="{
top: toolbarPosition.top,
left: toolbarPosition.left,
}"
>
<button
v-for="size in sizes"
:key="size.name"
class="text-xs font-medium rounded-[4px] outline outline-1 outline-n-strong px-1.5 py-0.5 hover:bg-n-slate-5"
@click="setURLWithQueryAndImageSize(size)"
>
{{ size.name }}
</button>
</div>
<slot name="footer" />
</div>
</template>
@@ -997,10 +979,6 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
@apply text-n-slate-11;
}
}
ol li {
@apply list-item list-decimal;
}
}
}
@@ -124,7 +124,6 @@ const copyConversationId = async () => {
:size="32"
:status="currentContact.availability_status"
hide-offline-status
rounded-full
/>
<div
class="flex flex-col items-start min-w-0 ml-2 overflow-hidden rtl:ml-0 rtl:mr-2"
@@ -375,10 +375,10 @@ export default {
return `draft-${this.conversationIdByRoute}-${this.replyType}`;
},
audioRecordFormat() {
if (this.isAWhatsAppChannel) {
if (this.isAWhatsAppCloudChannel) {
return AUDIO_FORMATS.OGG;
}
if (this.isATelegramChannel) {
if (this.isAWhatsAppChannel || this.isATelegramChannel) {
return AUDIO_FORMATS.MP3;
}
if (this.isAPIInbox) {
@@ -46,6 +46,14 @@ const filterTypes = [
filterOperators: OPERATOR_TYPES_2,
attributeModel: 'standard',
},
{
attributeKey: 'contact_id',
attributeI18nKey: 'CONTACT',
inputType: 'search_select',
dataType: 'number',
filterOperators: OPERATOR_TYPES_1,
attributeModel: 'standard',
},
{
attributeKey: 'display_id',
attributeI18nKey: 'CONVERSATION_IDENTIFIER',
@@ -133,6 +141,10 @@ export const filterAttributeGroups = [
key: 'team_id',
i18nKey: 'TEAM_NAME',
},
{
key: 'contact_id',
i18nKey: 'CONTACT',
},
{
key: 'display_id',
i18nKey: 'CONVERSATION_IDENTIFIER',
@@ -0,0 +1,148 @@
import { useFacebookPageConnect } from '../useFacebookPageConnect';
import { useMapGetter } from 'dashboard/composables/store';
import ChannelApi from 'dashboard/api/channels';
import { setupFacebookSdk } from 'dashboard/routes/dashboard/settings/inbox/channels/whatsapp/utils';
vi.mock('dashboard/composables/store', () => ({ useMapGetter: vi.fn() }));
vi.mock('dashboard/api/channels', () => ({
default: { fetchFacebookPages: vi.fn() },
}));
vi.mock(
'dashboard/routes/dashboard/settings/inbox/channels/whatsapp/utils',
() => ({ setupFacebookSdk: vi.fn() })
);
const flushPromises = () =>
new Promise(resolve => {
setTimeout(resolve, 0);
});
const createDeferred = () => {
let resolve;
let reject;
const promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};
const ACCOUNT_ID = 42;
const PAGES = [
{ id: 'p1', name: 'Page One', access_token: 'page-token-1' },
{ id: 'p2', name: 'Page Two', access_token: 'page-token-2', exists: true },
];
const pagesResponse = {
data: { data: { page_details: PAGES, user_access_token: 'long-token' } },
};
// FB.login invokes its callback with the given response.
const stubLogin = response => {
window.FB = { login: vi.fn(callback => callback(response)) };
};
const connected = {
status: 'connected',
authResponse: { accessToken: 'user-token' },
};
describe('useFacebookPageConnect', () => {
beforeEach(() => {
vi.clearAllMocks();
window.chatwootConfig = { fbAppId: 'fb-app', fbApiVersion: 'v22.0' };
useMapGetter.mockReturnValue({ value: ACCOUNT_ID });
setupFacebookSdk.mockResolvedValue();
ChannelApi.fetchFacebookPages.mockResolvedValue(pagesResponse);
stubLogin(connected);
});
it('resolves the user token and pages on a connected login', async () => {
const { loginAndFetchPages } = useFacebookPageConnect();
await expect(loginAndFetchPages()).resolves.toEqual({
userAccessToken: 'long-token',
pages: PAGES,
});
expect(setupFacebookSdk).toHaveBeenCalledWith('fb-app', 'v22.0');
expect(ChannelApi.fetchFacebookPages).toHaveBeenCalledWith(
'user-token',
ACCOUNT_ID
);
expect(window.FB.login).toHaveBeenCalledWith(expect.any(Function), {
scope: expect.stringContaining('pages_show_list'),
});
});
it('resolves null when the user is not authorized', async () => {
stubLogin({ status: 'not_authorized' });
const { loginAndFetchPages } = useFacebookPageConnect();
await expect(loginAndFetchPages()).resolves.toBeNull();
expect(ChannelApi.fetchFacebookPages).not.toHaveBeenCalled();
});
it('resolves null on an unknown login status', async () => {
stubLogin({ status: 'unknown' });
const { loginAndFetchPages } = useFacebookPageConnect();
await expect(loginAndFetchPages()).resolves.toBeNull();
});
it('rejects when fetching pages fails', async () => {
ChannelApi.fetchFacebookPages.mockRejectedValue(new Error('fetch failed'));
const { loginAndFetchPages } = useFacebookPageConnect();
await expect(loginAndFetchPages()).rejects.toThrow('fetch failed');
});
it('rejects when the SDK fails to load', async () => {
const error = new Error('script load failed');
error.name = 'ScriptLoaderError';
setupFacebookSdk.mockRejectedValue(error);
const { loginAndFetchPages } = useFacebookPageConnect();
await expect(loginAndFetchPages()).rejects.toThrow('script load failed');
});
it('ignores a second call while a run is in flight', async () => {
const pending = createDeferred();
ChannelApi.fetchFacebookPages.mockReturnValue(pending.promise);
const { loginAndFetchPages } = useFacebookPageConnect();
const first = loginAndFetchPages();
const second = loginAndFetchPages();
await expect(second).resolves.toBeNull();
pending.resolve(pagesResponse);
await first;
expect(window.FB.login).toHaveBeenCalledTimes(1);
});
it('toggles isAuthenticating across a run', async () => {
const pending = createDeferred();
ChannelApi.fetchFacebookPages.mockReturnValue(pending.promise);
const { isAuthenticating, loginAndFetchPages } = useFacebookPageConnect();
expect(isAuthenticating.value).toBe(false);
const result = loginAndFetchPages();
await flushPromises();
expect(isAuthenticating.value).toBe(true);
pending.resolve(pagesResponse);
await result;
expect(isAuthenticating.value).toBe(false);
});
it('preloads the SDK once and reuses it for login', async () => {
const { preloadSdk, loginAndFetchPages } = useFacebookPageConnect();
preloadSdk();
preloadSdk();
await loginAndFetchPages();
expect(setupFacebookSdk).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,208 @@
import { useWhatsappEmbeddedSignup } from '../useWhatsappEmbeddedSignup';
import {
setupFacebookSdk,
initWhatsAppEmbeddedSignup,
createMessageHandler,
} from 'dashboard/routes/dashboard/settings/inbox/channels/whatsapp/utils';
vi.mock(
'dashboard/routes/dashboard/settings/inbox/channels/whatsapp/utils',
() => ({
setupFacebookSdk: vi.fn(),
initWhatsAppEmbeddedSignup: vi.fn(),
createMessageHandler: vi.fn(),
isValidBusinessData: vi.fn(data =>
Boolean(data && data.business_id && data.waba_id)
),
})
);
const flushPromises = () =>
new Promise(resolve => {
setTimeout(resolve, 0);
});
const createDeferred = () => {
let resolve;
let reject;
const promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};
const VALID_BUSINESS = {
business_id: 'biz-1',
waba_id: 'waba-1',
phone_number_id: 'phone-1',
};
describe('useWhatsappEmbeddedSignup', () => {
// The mocked createMessageHandler captures the callback the composable
// registers, so tests can simulate Meta's WA_EMBEDDED_SIGNUP postMessages
// directly without the window-event + origin plumbing (that is covered by
// the utils' own tests).
let signupCallback;
let registeredListener;
const emit = data => signupCallback(data);
beforeEach(() => {
vi.clearAllMocks();
window.chatwootConfig = {
whatsappAppId: 'app-id',
whatsappConfigurationId: 'config-id',
whatsappApiVersion: 'v22.0',
};
setupFacebookSdk.mockResolvedValue();
createMessageHandler.mockImplementation(callback => {
signupCallback = callback;
registeredListener = () => {};
return registeredListener;
});
});
it('resolves credentials when the auth code arrives before the business data', async () => {
initWhatsAppEmbeddedSignup.mockResolvedValue('auth-code');
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
const result = runEmbeddedSignup();
await flushPromises(); // SDK setup + FB.login resolve the code first
emit({ event: 'FINISH', data: VALID_BUSINESS });
await expect(result).resolves.toEqual({
code: 'auth-code',
business_id: 'biz-1',
waba_id: 'waba-1',
phone_number_id: 'phone-1',
});
expect(setupFacebookSdk).toHaveBeenCalledWith('app-id', 'v22.0');
expect(initWhatsAppEmbeddedSignup).toHaveBeenCalledWith('config-id');
});
it('resolves credentials when the business data arrives before the auth code', async () => {
const code = createDeferred();
initWhatsAppEmbeddedSignup.mockReturnValue(code.promise);
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
const result = runEmbeddedSignup();
// Business data lands first, while FB.login is still pending.
emit({
event: 'FINISH_WHATSAPP_BUSINESS_APP_ONBOARDING',
data: VALID_BUSINESS,
});
code.resolve('late-code');
await expect(result).resolves.toEqual({
code: 'late-code',
business_id: 'biz-1',
waba_id: 'waba-1',
phone_number_id: 'phone-1',
});
});
it('defaults phone_number_id to an empty string when absent', async () => {
initWhatsAppEmbeddedSignup.mockResolvedValue('auth-code');
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
const result = runEmbeddedSignup();
await flushPromises();
emit({
event: 'FINISH',
data: { business_id: 'biz-1', waba_id: 'waba-1' },
});
await expect(result).resolves.toMatchObject({ phone_number_id: '' });
});
it('resolves null when FB.login is cancelled', async () => {
initWhatsAppEmbeddedSignup.mockRejectedValue(new Error('Login cancelled'));
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
await expect(runEmbeddedSignup()).resolves.toBeNull();
});
it('resolves null on a CANCEL event', async () => {
initWhatsAppEmbeddedSignup.mockReturnValue(createDeferred().promise);
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
const result = runEmbeddedSignup();
emit({ event: 'CANCEL' });
await expect(result).resolves.toBeNull();
});
it('rejects with the Meta error message on an error event', async () => {
initWhatsAppEmbeddedSignup.mockReturnValue(createDeferred().promise);
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
const result = runEmbeddedSignup();
emit({ event: 'error', error_message: 'WABA not eligible' });
await expect(result).rejects.toThrow('WABA not eligible');
});
it('rejects when the business data is invalid', async () => {
initWhatsAppEmbeddedSignup.mockReturnValue(createDeferred().promise);
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
const result = runEmbeddedSignup();
emit({ event: 'FINISH', data: { business_id: 'biz-1' } }); // no waba_id
await expect(result).rejects.toThrow('Invalid business data');
});
it('rejects when the SDK or login fails for a non-cancel reason', async () => {
initWhatsAppEmbeddedSignup.mockRejectedValue(new Error('popup blocked'));
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
await expect(runEmbeddedSignup()).rejects.toThrow('popup blocked');
});
it('ignores a second call while a run is in flight', async () => {
initWhatsAppEmbeddedSignup.mockResolvedValue('auth-code');
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
const first = runEmbeddedSignup();
const second = runEmbeddedSignup();
await expect(second).resolves.toBeNull();
// Let the first run finish so it doesn't leak into the next test.
await flushPromises();
emit({ event: 'FINISH', data: VALID_BUSINESS });
await first;
expect(setupFacebookSdk).toHaveBeenCalledTimes(1);
});
it('toggles isAuthenticating and removes the listener once settled', async () => {
initWhatsAppEmbeddedSignup.mockResolvedValue('auth-code');
const removeSpy = vi.spyOn(window, 'removeEventListener');
const { isAuthenticating, runEmbeddedSignup } = useWhatsappEmbeddedSignup();
expect(isAuthenticating.value).toBe(false);
const result = runEmbeddedSignup();
expect(isAuthenticating.value).toBe(true);
await flushPromises();
emit({ event: 'FINISH', data: VALID_BUSINESS });
await result;
expect(isAuthenticating.value).toBe(false);
expect(removeSpy).toHaveBeenCalledWith('message', registeredListener);
removeSpy.mockRestore();
});
});
@@ -0,0 +1,81 @@
import { ref } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import ChannelApi from 'dashboard/api/channels';
import { setupFacebookSdk } from 'dashboard/routes/dashboard/settings/inbox/channels/whatsapp/utils';
// Page-management + messaging scopes required to list pages and create a
// Channel::FacebookPage inbox (mirrors the standalone settings flow).
const FB_PAGE_SCOPES =
'pages_manage_metadata,business_management,pages_messaging,instagram_basic,pages_show_list,pages_read_engagement,instagram_manage_messages';
// Headless half of the Facebook Page connect flow: load the Meta SDK, run
// FB.login for page scopes, and fetch the user's pages. The caller owns the
// page-picker UI and the channel creation, because choosing a page is an
// interactive step (a user can manage several pages).
//
// Split into preloadSdk() + loginAndFetchPages() for popup safety: FB.login
// opens a popup and needs the click's transient activation. Preloading the SDK
// when the picker opens means the click-time `await` resolves within that
// activation window; a cold load resolves on the script's `load` task seconds
// later, after activation has expired, and the popup gets blocked.
export function useFacebookPageConnect() {
const accountId = useMapGetter('getCurrentAccountId');
const isAuthenticating = ref(false);
let sdkSetupPromise = null;
// Idempotent — call this when the picker UI opens. A failed load clears the
// cache so a later attempt can retry instead of being stuck on a rejection.
const preloadSdk = () => {
if (!sdkSetupPromise) {
sdkSetupPromise = setupFacebookSdk(
window.chatwootConfig?.fbAppId,
window.chatwootConfig?.fbApiVersion
).catch(error => {
sdkSetupPromise = null;
throw error;
});
}
return sdkSetupPromise;
};
// FB.login never rejects; resolve the user access token on success and null
// for any other status (closed popup, not_authorized, unknown).
const login = () =>
new Promise(resolve => {
window.FB.login(
response => {
resolve(
response.status === 'connected'
? response.authResponse?.accessToken || null
: null
);
},
{ scope: FB_PAGE_SCOPES }
);
});
// Resolves { userAccessToken, pages } on success, null when the user cancels,
// and rejects on SDK-load or page-fetch failure (the caller maps it to UI).
const loginAndFetchPages = async () => {
if (isAuthenticating.value) return null;
isAuthenticating.value = true;
try {
await preloadSdk();
const token = await login();
if (!token) return null;
const response = await ChannelApi.fetchFacebookPages(
token,
accountId.value
);
const { page_details: pages, user_access_token: userAccessToken } =
response.data.data;
return { userAccessToken, pages };
} finally {
isAuthenticating.value = false;
}
};
return { isAuthenticating, preloadSdk, loginAndFetchPages };
}
@@ -0,0 +1,96 @@
import { ref } from 'vue';
import {
setupFacebookSdk,
initWhatsAppEmbeddedSignup,
createMessageHandler,
isValidBusinessData,
} from 'dashboard/routes/dashboard/settings/inbox/channels/whatsapp/utils';
// Drives Meta's WhatsApp embedded-signup popup (Facebook JS SDK). FB.login()
// resolves an auth `code` while the WABA identifiers (waba_id, phone_number_id)
// arrive separately over a postMessage event — order isn't guaranteed, so we
// hold both and resolve once both are present.
//
// `runEmbeddedSignup` returns the signup credentials; the caller exchanges them
// for an inbox via `inboxes/createWhatsAppEmbeddedSignup` and owns its own UX
// (alerts, navigation, etc). Resolves `null` when the user cancels the popup;
// rejects on SDK load or signup errors. The window listener is scoped to a
// single run, so this is safe to call from anywhere without lifecycle wiring.
export function useWhatsappEmbeddedSignup() {
const isAuthenticating = ref(false);
const runEmbeddedSignup = () => {
if (isAuthenticating.value) return Promise.resolve(null);
isAuthenticating.value = true;
return new Promise((resolve, reject) => {
let authCode = null;
let businessData = null;
let settled = false;
let messageHandler;
const settle = (fn, value) => {
if (settled) return;
settled = true;
window.removeEventListener('message', messageHandler);
isAuthenticating.value = false;
fn(value);
};
// Both the auth code and the business data arrive asynchronously and in
// no fixed order; only resolve once we're holding both.
const resolveIfReady = () => {
if (!authCode || !businessData) return;
settle(resolve, {
code: authCode,
business_id: businessData.business_id,
waba_id: businessData.waba_id,
phone_number_id: businessData.phone_number_id || '',
});
};
messageHandler = createMessageHandler(data => {
if (
data.event === 'FINISH' ||
data.event === 'FINISH_WHATSAPP_BUSINESS_APP_ONBOARDING'
) {
if (!isValidBusinessData(data.data)) {
settle(reject, new Error('Invalid business data'));
return;
}
businessData = data.data;
resolveIfReady();
} else if (data.event === 'CANCEL') {
settle(resolve, null);
} else if (data.event === 'error') {
settle(reject, new Error(data.error_message || 'Signup error'));
}
});
window.addEventListener('message', messageHandler);
(async () => {
try {
await setupFacebookSdk(
window.chatwootConfig?.whatsappAppId,
window.chatwootConfig?.whatsappApiVersion
);
authCode = await initWhatsAppEmbeddedSignup(
window.chatwootConfig?.whatsappConfigurationId
);
resolveIfReady();
} catch (error) {
// FB.login() rejects with 'Login cancelled' when the user dismisses
// the popup — treat it as a cancel rather than an error.
if (error.message === 'Login cancelled') {
settle(resolve, null);
} else {
settle(reject, error);
}
}
})();
});
};
return { isAuthenticating, runEmbeddedSignup };
}
+2 -20
View File
@@ -14,6 +14,7 @@ export const FORMATTING = {
'link',
'bulletList',
'orderedList',
'imageUpload',
'undo',
'redo',
],
@@ -30,6 +31,7 @@ export const FORMATTING = {
'strike',
'bulletList',
'orderedList',
'imageUpload',
'undo',
'redo',
],
@@ -263,23 +265,3 @@ export const MARKDOWN_PATTERNS = [
],
},
];
// Editor image resize options for Message Editor
export const MESSAGE_EDITOR_IMAGE_RESIZES = [
{
name: 'Small',
height: '24px',
},
{
name: 'Medium',
height: '48px',
},
{
name: 'Large',
height: '72px',
},
{
name: 'Original Size',
height: 'auto',
},
];
@@ -37,6 +37,13 @@ export const getValuesName = (values, list, idKey, nameKey) => {
};
};
const getValuesForContact = (values, contacts) => ({
id: values[0],
name:
contacts?.find(contact => contact.id === values[0])?.name ||
`Contact #${values[0]}`,
});
export const getValuesForStatus = values => {
return values.map(value => ({ id: value, name: value }));
};
@@ -84,6 +91,7 @@ export const getValuesForFilter = (filter, params) => {
campaigns,
labels,
priority,
contacts,
} = params;
switch (attribute_key) {
case 'status':
@@ -94,6 +102,8 @@ export const getValuesForFilter = (filter, params) => {
return getValuesName(values, inboxes, 'id', 'name');
case 'team_id':
return getValuesName(values, teams, 'id', 'name');
case 'contact_id':
return getValuesForContact(values, contacts);
case 'campaign_id':
return getValuesName(values, campaigns, 'id', 'title');
case 'labels':
@@ -379,31 +379,6 @@ export const findNodeToInsertImage = (editorState, fileUrl) => {
};
};
/**
* Set URL with query and size.
*
* @param {Object} selectedImageNode - The current selected node.
* @param {Object} size - The size to set.
* @param {Object} editorView - The editor view.
*/
export function setURLWithQueryAndSize(selectedImageNode, size, editorView) {
if (selectedImageNode) {
// Create and apply the transaction
const tr = editorView.state.tr.setNodeMarkup(
editorView.state.selection.from,
null,
{
src: selectedImageNode.src,
height: size.height,
}
);
if (tr.docChanged) {
editorView.dispatch(tr);
}
}
}
/**
* Strips unsupported markdown formatting from content based on the editor schema.
* This ensures canned responses with rich formatting can be inserted into channels
@@ -20,6 +20,9 @@ const FEATURE_HELP_URLS = {
billing: 'https://chwt.app/pricing',
saml: 'https://chwt.app/hc/saml',
captain_billing: 'https://chwt.app/hc/captain_billing',
access_token_api:
'https://developers.chatwoot.com/api-reference/introduction',
chatwoot_cli: 'https://developers.chatwoot.com/cli',
};
export function getHelpUrlForFeature(featureName) {
@@ -159,6 +159,13 @@ export const LOCALE_MENU_ITEMS = {
value: 'publish',
icon: 'i-lucide-eye',
},
customizeContent: {
label:
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.CUSTOMIZE_CONTENT',
action: 'customize-content',
value: 'customize-content',
icon: 'i-lucide-pencil',
},
delete: {
label: 'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.DELETE',
action: 'delete',
@@ -172,20 +179,28 @@ const disableLocaleMenuItems = menuItems =>
export const buildLocaleMenuItems = ({ isDefault, isDraft }) => {
if (isDefault) {
return disableLocaleMenuItems([
LOCALE_MENU_ITEMS.makeDefault,
LOCALE_MENU_ITEMS.moveToDraft,
LOCALE_MENU_ITEMS.delete,
]);
return [
...disableLocaleMenuItems([
LOCALE_MENU_ITEMS.makeDefault,
LOCALE_MENU_ITEMS.moveToDraft,
]),
LOCALE_MENU_ITEMS.customizeContent,
...disableLocaleMenuItems([LOCALE_MENU_ITEMS.delete]),
];
}
if (isDraft) {
return [LOCALE_MENU_ITEMS.publishLocale, LOCALE_MENU_ITEMS.delete];
return [
LOCALE_MENU_ITEMS.publishLocale,
LOCALE_MENU_ITEMS.customizeContent,
LOCALE_MENU_ITEMS.delete,
];
}
return [
LOCALE_MENU_ITEMS.makeDefault,
LOCALE_MENU_ITEMS.moveToDraft,
LOCALE_MENU_ITEMS.customizeContent,
LOCALE_MENU_ITEMS.delete,
];
};
@@ -273,6 +273,41 @@ describe('customViewsHelper', () => {
};
expect(generateValuesForEditCustomViews(filter, params)).toEqual('1');
});
it('returns contact name for contact filters when contact is available', () => {
const filter = {
attribute_key: 'contact_id',
filter_operator: 'equal_to',
values: [123],
};
const params = {
contacts: [{ id: 123, name: 'John Doe' }],
filterTypes: advancedFilterTypes,
allCustomAttributes: [],
};
expect(generateValuesForEditCustomViews(filter, params)).toEqual({
id: 123,
name: 'John Doe',
});
});
it('returns fallback contact display value when contact is not available', () => {
const filter = {
attribute_key: 'contact_id',
filter_operator: 'equal_to',
values: [123],
};
const params = {
filterTypes: advancedFilterTypes,
allCustomAttributes: [],
};
expect(generateValuesForEditCustomViews(filter, params)).toEqual({
id: 123,
name: 'Contact #123',
});
});
});
describe('#generateCustomAttributesInputType', () => {
@@ -16,7 +16,6 @@ import {
insertAtCursor,
removeSignature,
replaceSignature,
setURLWithQueryAndSize,
stripInlineBase64Images,
stripUnsupportedFormatting,
stripUnsupportedMarkdown,
@@ -653,71 +652,6 @@ describe('findNodeToInsertImage', () => {
});
});
describe('setURLWithQueryAndSize', () => {
let selectedNode;
let editorView;
beforeEach(() => {
selectedNode = {
setAttribute: vi.fn(),
};
const tr = {
setNodeMarkup: vi.fn().mockReturnValue({
docChanged: true,
}),
};
const state = {
selection: { from: 0 },
tr,
};
editorView = {
state,
dispatch: vi.fn(),
};
});
it('updates the URL with the given size and updates the editor view', () => {
const size = { height: '20px' };
setURLWithQueryAndSize(selectedNode, size, editorView);
// Check if the editor view is updated
expect(editorView.dispatch).toHaveBeenCalledTimes(1);
});
it('updates the URL with the given size and updates the editor view with original size', () => {
const size = { height: 'auto' };
setURLWithQueryAndSize(selectedNode, size, editorView);
// Check if the editor view is updated
expect(editorView.dispatch).toHaveBeenCalledTimes(1);
});
it('does not update the editor view if the document has not changed', () => {
editorView.state.tr.setNodeMarkup = vi.fn().mockReturnValue({
docChanged: false,
});
const size = { height: '20px' };
setURLWithQueryAndSize(selectedNode, size, editorView);
// Check if the editor view dispatch was not called
expect(editorView.dispatch).not.toHaveBeenCalled();
});
it('does not perform any operations if selectedNode is not provided', () => {
setURLWithQueryAndSize(null, { height: '20px' }, editorView);
// Ensure the dispatch method wasn't called
expect(editorView.dispatch).not.toHaveBeenCalled();
});
});
describe('getContentNode', () => {
let mockEditorView;
@@ -64,4 +64,25 @@ describe('#filterQueryGenerator', () => {
filterQueryGenerator(testData).payload.every(i => Array.isArray(i.values))
).toBe(true);
});
it('serializes a selected contact object to contact id', () => {
const result = filterQueryGenerator([
{
attribute_key: 'contact_id',
filter_operator: 'equal_to',
values: { id: 123, name: 'Jane Doe' },
query_operator: 'and',
},
]);
expect(result).toMatchObject({
payload: [
{
attribute_key: 'contact_id',
filter_operator: 'equal_to',
values: [123],
},
],
});
});
});
@@ -74,37 +74,40 @@ describe('PortalHelper', () => {
});
describe('buildLocaleMenuItems', () => {
it('returns disabled actions for the default locale', () => {
it('disables other actions but keeps customize enabled for the default locale', () => {
const items = buildLocaleMenuItems({ isDefault: true, isDraft: false });
const customize = items.find(item => item.action === 'customize-content');
expect(customize).toBeTruthy();
expect(customize.disabled).toBeFalsy();
expect(
buildLocaleMenuItems({
isDefault: true,
isDraft: false,
})
).toEqual(
expect.arrayContaining([
expect.objectContaining({ action: 'change-default', disabled: true }),
expect.objectContaining({ action: 'move-to-draft', disabled: true }),
expect.objectContaining({ action: 'delete', disabled: true }),
])
);
items
.filter(item => item.action !== 'customize-content')
.every(item => item.disabled)
).toBe(true);
});
it('returns publish and delete actions for draft locales', () => {
it('returns publish, customize, and delete actions for draft locales', () => {
expect(
buildLocaleMenuItems({
isDefault: false,
isDraft: true,
}).map(({ action }) => action)
).toEqual(['publish-locale', 'delete']);
).toEqual(['publish-locale', 'customize-content', 'delete']);
});
it('returns default, draft, and delete actions for live locales', () => {
it('returns default, draft, customize, and delete actions for live locales', () => {
expect(
buildLocaleMenuItems({
isDefault: false,
isDraft: false,
}).map(({ action }) => action)
).toEqual(['change-default', 'move-to-draft', 'delete']);
).toEqual([
'change-default',
'move-to-draft',
'customize-content',
'delete',
]);
});
});
});
@@ -19,6 +19,8 @@
"OR": "OR"
},
"INPUT_PLACEHOLDER": "Enter value",
"CONTACT_SEARCH_PLACEHOLDER": "Search contacts",
"CONTACT_FALLBACK": "Contact #{id}",
"OPERATOR_LABELS": {
"equal_to": "Equal to",
"not_equal_to": "Not equal to",
@@ -49,6 +51,7 @@
"ASSIGNEE_NAME": "Assignee name",
"INBOX_NAME": "Inbox name",
"TEAM_NAME": "Team name",
"CONTACT": "Contact",
"CONVERSATION_IDENTIFIER": "Conversation identifier",
"CAMPAIGN_NAME": "Campaign name",
"LABELS": "Labels",
@@ -233,6 +233,7 @@
"TIP_AUDIORECORDER_ERROR": "Could not open the audio",
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
"DRAG_DROP": "Drag and drop here to attach",
"IMAGE_UPLOAD_SUCCESS": "Image uploaded successfully",
"START_AUDIO_RECORDING": "Start audio recording",
"STOP_AUDIO_RECORDING": "Stop audio recording",
"COPILOT_THINKING": "Copilot is thinking",
@@ -700,9 +700,27 @@
"MAKE_DEFAULT": "Make default",
"MOVE_TO_DRAFT": "Move to draft",
"PUBLISH_LOCALE": "Publish locale",
"CUSTOMIZE_CONTENT": "Localize content",
"DELETE": "Delete"
}
},
"CONTENT_DIALOG": {
"TITLE": "Localize content",
"DESCRIPTION": "Set values specific to this locale. Anything left blank falls back to the default locale.",
"NAME": {
"LABEL": "Name"
},
"PAGE_TITLE": {
"LABEL": "Page title"
},
"HEADER_TEXT": {
"LABEL": "Header text"
},
"API": {
"SUCCESS_MESSAGE": "Locale content updated successfully",
"ERROR_MESSAGE": "Unable to update locale content. Try again."
}
},
"ADD_LOCALE_DIALOG": {
"TITLE": "Add a new locale",
"DESCRIPTION": "Select the language in which this article will be written. This will be added to your list of translations, and you can add more later.",
@@ -653,6 +653,10 @@
},
"CREDENTIALS": {
"DESCRIPTION": "Voice calling requires Twilio API Key credentials. These are used to generate tokens for agent voice connections."
},
"INBOUND": {
"LABEL": "Allow incoming calls",
"DESCRIPTION": "Let customers call this number. When turned off, incoming calls are declined automatically — agents aren't notified and no conversation is created. Agents can still place outgoing calls."
}
},
"WHATSAPP_CALLING": {
@@ -91,10 +91,33 @@
"NOTE": "This token can be used if you are building an API based integration",
"COPY": "Copy",
"RESET": "Reset",
"GENERATE": "Generate",
"CREATE": "Create token",
"CONFIRM_RESET": "Are you sure?",
"CONFIRM_HINT": "Click again to confirm",
"TABLE": {
"TOKEN": "Token",
"PERMISSION": "Permission"
},
"EMPTY": {
"TITLE": "No tokens yet",
"DESCRIPTION": "Create your first access token to start building integrations against the API."
},
"SCOPES": {
"FULL_LABEL": "Full access",
"READ_ONLY_LABEL": "Read only"
},
"LINKS": {
"API_REFERENCE": "API Reference",
"CLI": "Chatwoot CLI"
},
"RESET_SUCCESS": "Access token regenerated successfully",
"RESET_ERROR": "Unable to regenerate access token. Please try again"
"RESET_ERROR": "Unable to regenerate access token. Please try again",
"READ_ONLY_TITLE": "Read-only Access Token",
"READ_ONLY_NOTE": "Use this token for integrations that only need to read data. It cannot create, update, or delete anything.",
"READ_ONLY_GENERATE_SUCCESS": "Read-only access token generated successfully",
"READ_ONLY_RESET_SUCCESS": "Read-only access token regenerated successfully",
"READ_ONLY_RESET_ERROR": "Unable to regenerate read-only access token. Please try again"
},
"AUDIO_NOTIFICATIONS_SECTION": {
"TITLE": "Audio Alerts",
@@ -188,7 +188,6 @@ export default {
:status="contact.availability_status"
:size="48"
hide-offline-status
rounded-full
/>
</div>
@@ -94,8 +94,18 @@ export default {
return this.getAccount(this.accountId) || {};
},
},
watch: {
'currentAccount.id'(id) {
if (id) {
this.initializeAccount();
}
},
},
mounted() {
this.initializeAccount();
// Account already in the store (navigated in): seed immediately.
if (this.currentAccount.id) {
this.initializeAccount();
}
},
methods: {
async initializeAccount() {
@@ -8,7 +8,7 @@ import SectionLayout from './SectionLayout.vue';
const { t } = useI18n();
const { currentAccount } = useAccount();
const getAccountId = computed(() => currentAccount.value.id.toString());
const getAccountId = computed(() => currentAccount.value?.id?.toString());
</script>
<template>
@@ -45,6 +45,7 @@ const { t } = useI18n();
>
<slot name="description">{{ description }}</slot>
</p>
<slot name="links" />
</div>
<div class="col-span-1">
<slot name="headerActions" />
@@ -1,20 +1,16 @@
<script>
/* eslint-env browser */
/* global FB */
import { useVuelidate } from '@vuelidate/core';
import { useAlert } from 'dashboard/composables';
import { useAccount } from 'dashboard/composables/useAccount';
import { useFacebookPageConnect } from 'dashboard/composables/useFacebookPageConnect';
import { required } from '@vuelidate/validators';
import LoadingState from 'dashboard/components/widgets/LoadingState.vue';
import ChannelApi from '../../../../../api/channels';
import PageHeader from '../../SettingsSubPageHeader.vue';
import router from '../../../../index';
import { useBranding } from 'shared/composables/useBranding';
import NextButton from 'dashboard/components-next/button/Button.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import { loadScript } from 'dashboard/helper/DOMHelpers';
import * as Sentry from '@sentry/vue';
export default {
@@ -25,11 +21,12 @@ export default {
ComboBox,
},
setup() {
const { accountId } = useAccount();
const { replaceInstallationName } = useBranding();
const { preloadSdk, loginAndFetchPages } = useFacebookPageConnect();
return {
accountId,
replaceInstallationName,
preloadSdk,
loginAndFetchPages,
v$: useVuelidate(),
};
},
@@ -37,9 +34,7 @@ export default {
return {
isCreating: false,
hasError: false,
omniauth_token: '',
user_access_token: '',
channel: 'facebook',
selectedPage: { name: null, id: null },
pageName: '',
pageList: [],
@@ -78,24 +73,29 @@ export default {
},
mounted() {
window.fbAsyncInit = this.runFBInit;
// Warm the SDK so the login click opens its popup within the gesture's
// activation window (see useFacebookPageConnect).
this.preloadSdk();
},
methods: {
async startLogin() {
this.hasLoginStarted = true;
try {
// this will load the SDK in a promise, and resolve it when the sdk is loaded
// in case the SDK is already present, it will resolve immediately
await this.loadFBsdk();
this.runFBInit(); // run init anyway, `tryFBlogin` won't wait for `fbAsyncInit` otherwise.
this.tryFBlogin(); // make an attempt to login
const result = await this.loginAndFetchPages();
if (!result) {
// Cancelled popup / not authorized — surface a generic auth error.
this.hasError = true;
this.errorStateMessage = this.$t('INBOX_MGMT.DETAILS.ERROR_FB_AUTH');
this.errorStateDescription = '';
return;
}
this.pageList = result.pages;
this.user_access_token = result.userAccessToken;
} catch (error) {
if (error.name === 'ScriptLoaderError') {
// if the error was related to script loading, we show a toast
useAlert(this.$t('INBOX_MGMT.DETAILS.ERROR_FB_LOADING'));
} else {
// if the error was anything else, we capture it and show a toast
Sentry.captureException(error);
useAlert(this.$t('INBOX_MGMT.DETAILS.ERROR_FB_AUTH'));
}
@@ -114,81 +114,6 @@ export default {
this.v$.selectedPage.$touch();
},
initChannelAuth(channel) {
if (channel === 'facebook') {
this.loadFBsdk();
}
},
runFBInit() {
FB.init({
appId: window.chatwootConfig.fbAppId,
xfbml: true,
version: window.chatwootConfig.fbApiVersion,
status: true,
});
window.fbSDKLoaded = true;
FB.AppEvents.logPageView();
},
async loadFBsdk() {
return loadScript('https://connect.facebook.net/en_US/sdk.js', {
id: 'facebook-jssdk',
});
},
tryFBlogin() {
FB.login(
response => {
this.hasError = false;
if (response.status === 'connected') {
this.fetchPages(response.authResponse.accessToken);
} else if (response.status === 'not_authorized') {
// eslint-disable-next-line no-console
console.error('FACEBOOK AUTH ERROR', response);
this.hasError = true;
// The person is logged into Facebook, but not your app.
this.errorStateMessage = this.$t(
'INBOX_MGMT.DETAILS.ERROR_FB_UNAUTHORIZED'
);
this.errorStateDescription = this.$t(
'INBOX_MGMT.DETAILS.ERROR_FB_UNAUTHORIZED_HELP'
);
} else {
// eslint-disable-next-line no-console
console.error('FACEBOOK AUTH ERROR', response);
this.hasError = true;
// The person is not logged into Facebook, so we're not sure if
// they are logged into this app or not.
this.errorStateMessage = this.$t(
'INBOX_MGMT.DETAILS.ERROR_FB_AUTH'
);
this.errorStateDescription = '';
}
},
{
scope:
'pages_manage_metadata,business_management,pages_messaging,instagram_basic,pages_show_list,pages_read_engagement,instagram_manage_messages',
}
);
},
async fetchPages(_token) {
try {
const response = await ChannelApi.fetchFacebookPages(
_token,
this.accountId
);
const {
data: { data },
} = response;
this.pageList = data.page_details;
this.user_access_token = data.user_access_token;
} catch (error) {
// Ignore error
}
},
channelParams() {
return {
user_access_token: this.user_access_token,
@@ -1,21 +1,16 @@
<script setup>
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { ref, computed } from 'vue';
import { useStore } from 'vuex';
import { useRouter } from 'vue-router';
import { useI18n, I18nT } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { useWhatsappEmbeddedSignup } from 'dashboard/composables/useWhatsappEmbeddedSignup';
import Icon from 'next/icon/Icon.vue';
import NextButton from 'next/button/Button.vue';
import LoadingState from 'dashboard/components/widgets/LoadingState.vue';
import InboxesAPI from 'dashboard/api/inboxes';
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
import globalConstants from 'dashboard/constants/globals.js';
import {
setupFacebookSdk,
initWhatsAppEmbeddedSignup,
createMessageHandler,
isValidBusinessData,
} from './whatsapp/utils';
const props = defineProps({
enableCallingOnComplete: {
@@ -27,15 +22,10 @@ const props = defineProps({
const store = useStore();
const router = useRouter();
const { t } = useI18n();
const { isAuthenticating, runEmbeddedSignup } = useWhatsappEmbeddedSignup();
// State
const fbSdkLoaded = ref(false);
const isProcessing = ref(false);
const processingMessage = ref('');
const authCodeReceived = ref(false);
const authCode = ref(null);
const businessData = ref(null);
const isAuthenticating = ref(false);
const benefits = computed(() => [
{
@@ -54,25 +44,6 @@ const benefits = computed(() => [
const showLoader = computed(() => isAuthenticating.value || isProcessing.value);
// Error handling
const handleSignupError = data => {
isProcessing.value = false;
authCodeReceived.value = false;
isAuthenticating.value = false;
const errorMessage =
data.error ||
data.message ||
t('INBOX_MGMT.ADD.WHATSAPP.API.ERROR_MESSAGE');
useAlert(errorMessage);
};
const handleSignupCancellation = () => {
isProcessing.value = false;
authCodeReceived.value = false;
isAuthenticating.value = false;
};
const enableCallingForInbox = async inboxId => {
try {
await InboxesAPI.enableWhatsappCalling(inboxId);
@@ -86,14 +57,12 @@ const enableCallingForInbox = async inboxId => {
const handleSignupSuccess = async inboxData => {
if (inboxData && inboxData.id) {
if (props.enableCallingOnComplete) {
isProcessing.value = true;
processingMessage.value = t(
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.ENABLING_CALLING'
);
await enableCallingForInbox(inboxData.id);
}
isProcessing.value = false;
isAuthenticating.value = false;
useAlert(t('INBOX_MGMT.FINISH.MESSAGE'));
router.replace({
name: 'settings_inboxes_add_agents',
@@ -104,7 +73,6 @@ const handleSignupSuccess = async inboxData => {
});
} else {
isProcessing.value = false;
isAuthenticating.value = false;
useAlert(t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SUCCESS_FALLBACK'));
router.replace({
name: 'settings_inbox_list',
@@ -112,12 +80,21 @@ const handleSignupSuccess = async inboxData => {
}
};
// Signup flow
const completeSignupFlow = async businessDataParam => {
if (!authCodeReceived.value || !authCode.value) {
handleSignupError({
error: t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.AUTH_NOT_COMPLETED'),
});
const launchEmbeddedSignup = async () => {
let credentials;
try {
credentials = await runEmbeddedSignup();
} catch (error) {
useAlert(
error.message ||
t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SDK_LOAD_ERROR')
);
return;
}
// Resolves null when the user dismisses the Meta popup.
if (!credentials) {
useAlert(t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.CANCELLED'));
return;
}
@@ -125,130 +102,20 @@ const completeSignupFlow = async businessDataParam => {
processingMessage.value = t(
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.PROCESSING'
);
try {
const params = {
code: authCode.value,
business_id: businessDataParam.business_id,
waba_id: businessDataParam.waba_id,
phone_number_id: businessDataParam?.phone_number_id || '',
};
const responseData = await store.dispatch(
const inboxData = await store.dispatch(
'inboxes/createWhatsAppEmbeddedSignup',
params
credentials
);
authCode.value = null;
handleSignupSuccess(responseData);
await handleSignupSuccess(inboxData);
} catch (error) {
const errorMessage =
isProcessing.value = false;
useAlert(
parseAPIErrorResponse(error) ||
t('INBOX_MGMT.ADD.WHATSAPP.API.ERROR_MESSAGE');
handleSignupError({ error: errorMessage });
t('INBOX_MGMT.ADD.WHATSAPP.API.ERROR_MESSAGE')
);
}
};
// Message handling
const handleEmbeddedSignupData = async data => {
if (
data.event === 'FINISH' ||
data.event === 'FINISH_WHATSAPP_BUSINESS_APP_ONBOARDING'
) {
const businessDataLocal = data.data;
if (isValidBusinessData(businessDataLocal)) {
businessData.value = businessDataLocal;
if (authCodeReceived.value && authCode.value) {
await completeSignupFlow(businessDataLocal);
} else {
processingMessage.value = t(
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.WAITING_FOR_AUTH'
);
}
} else {
handleSignupError({
error: t(
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.INVALID_BUSINESS_DATA'
),
});
}
} else if (data.event === 'CANCEL') {
handleSignupCancellation();
} else if (data.event === 'error') {
handleSignupError({
error:
data.error_message ||
t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SIGNUP_ERROR'),
error_id: data.error_id,
session_id: data.session_id,
});
}
};
const handleSignupMessage = createMessageHandler(handleEmbeddedSignupData);
const launchEmbeddedSignup = async () => {
try {
isAuthenticating.value = true;
processingMessage.value = t(
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.AUTH_PROCESSING'
);
await setupFacebookSdk(
window.chatwootConfig?.whatsappAppId,
window.chatwootConfig?.whatsappApiVersion
);
fbSdkLoaded.value = true;
const code = await initWhatsAppEmbeddedSignup(
window.chatwootConfig?.whatsappConfigurationId
);
authCode.value = code;
authCodeReceived.value = true;
processingMessage.value = t(
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.WAITING_FOR_BUSINESS_INFO'
);
if (businessData.value) {
completeSignupFlow(businessData.value);
}
} catch (error) {
if (error.message === 'Login cancelled') {
isProcessing.value = false;
isAuthenticating.value = false;
useAlert(t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.CANCELLED'));
} else {
handleSignupError({
error:
error.message ||
t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SDK_LOAD_ERROR'),
});
}
}
};
// Lifecycle
const setupMessageListener = () => {
window.addEventListener('message', handleSignupMessage);
};
const cleanupMessageListener = () => {
window.removeEventListener('message', handleSignupMessage);
};
const initialize = () => {
setupMessageListener();
};
onMounted(() => {
initialize();
});
onBeforeUnmount(() => {
cleanupMessageListener();
});
</script>
<template>
@@ -1,9 +1,11 @@
<script>
import { useAlert } from 'dashboard/composables';
import InboxesAPI from 'dashboard/api/inboxes';
import SettingsFieldSection from 'dashboard/components-next/Settings/SettingsFieldSection.vue';
import SettingsToggleSection from 'dashboard/components-next/Settings/SettingsToggleSection.vue';
import NextInput from 'dashboard/components-next/input/Input.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
export default {
components: {
@@ -11,6 +13,7 @@ export default {
SettingsToggleSection,
NextInput,
NextButton,
Spinner,
},
props: {
inbox: {
@@ -21,9 +24,11 @@ export default {
data() {
return {
voiceEnabled: this.inbox.voice_enabled || false,
inboundCallsEnabled: this.inbox.inbound_calls_enabled !== false,
apiKeySid: this.inbox.api_key_sid || '',
apiKeySecret: '',
isUpdating: false,
isTogglingInbound: false,
};
},
computed: {
@@ -62,8 +67,27 @@ export default {
'inbox.api_key_sid'(val) {
this.apiKeySid = val || '';
},
'inbox.inbound_calls_enabled'(val) {
this.inboundCallsEnabled = val !== false;
},
},
methods: {
async handleInboundToggle(newValue) {
if (this.isTogglingInbound) return;
const previousValue = this.inboundCallsEnabled;
this.inboundCallsEnabled = newValue;
this.isTogglingInbound = true;
try {
await InboxesAPI.setInboundCalls(this.inbox.id, newValue);
await this.$store.dispatch('inboxes/get', this.inbox.id);
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
} catch (_) {
this.inboundCallsEnabled = previousValue;
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
} finally {
this.isTogglingInbound = false;
}
},
async updateVoiceSettings() {
this.isUpdating = true;
try {
@@ -123,6 +147,24 @@ export default {
/>
</div>
<div
v-if="inbox.voice_enabled"
class="relative"
:class="{ 'pointer-events-none opacity-60': isTogglingInbound }"
>
<SettingsToggleSection
:model-value="inboundCallsEnabled"
:header="$t('INBOX_MGMT.VOICE_CONFIGURATION.INBOUND.LABEL')"
:description="$t('INBOX_MGMT.VOICE_CONFIGURATION.INBOUND.DESCRIPTION')"
:hide-toggle="isTogglingInbound"
@update:model-value="handleInboundToggle"
>
<template v-if="isTogglingInbound" #hiddenToggle>
<Spinner class="size-4 text-n-slate-11" />
</template>
</SettingsToggleSection>
</div>
<div v-if="inbox.voice_enabled && inbox.voice_call_webhook_url">
<SettingsFieldSection
:label="$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_VOICE_URL_TITLE')"
@@ -24,10 +24,13 @@ export default {
data() {
return {
callingEnabled: this.inbox.provider_config?.calling_enabled || false,
inboundCallsEnabled:
this.inbox.provider_config?.inbound_calls_enabled !== false,
permissionRequestBody:
this.inbox.provider_config?.call_permission_request_body || '',
isUpdating: false,
isTogglingCalling: false,
isTogglingInbound: false,
};
},
computed: {
@@ -44,8 +47,27 @@ export default {
'inbox.provider_config.call_permission_request_body'(val) {
this.permissionRequestBody = val || '';
},
'inbox.provider_config.inbound_calls_enabled'(val) {
this.inboundCallsEnabled = val !== false;
},
},
methods: {
async handleInboundToggle(newValue) {
if (this.isTogglingInbound) return;
const previousValue = this.inboundCallsEnabled;
this.inboundCallsEnabled = newValue;
this.isTogglingInbound = true;
try {
await InboxesAPI.setInboundCalls(this.inbox.id, newValue);
await this.$store.dispatch('inboxes/get', this.inbox.id);
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
} catch (_) {
this.inboundCallsEnabled = previousValue;
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
} finally {
this.isTogglingInbound = false;
}
},
async handleCallingToggle(newValue) {
if (this.isTogglingCalling) return;
const previousValue = this.callingEnabled;
@@ -117,6 +139,25 @@ export default {
</div>
<template v-if="callingEnabled">
<div
class="relative"
:class="{ 'pointer-events-none opacity-60': isTogglingInbound }"
>
<SettingsToggleSection
:model-value="inboundCallsEnabled"
:header="$t('INBOX_MGMT.VOICE_CONFIGURATION.INBOUND.LABEL')"
:description="
$t('INBOX_MGMT.VOICE_CONFIGURATION.INBOUND.DESCRIPTION')
"
:hide-toggle="isTogglingInbound"
@update:model-value="handleInboundToggle"
>
<template v-if="isTogglingInbound" #hiddenToggle>
<Spinner class="size-4 text-n-slate-11" />
</template>
</SettingsToggleSection>
</div>
<SettingsFieldSection
v-if="phoneNumber"
:label="$t('INBOX_MGMT.WHATSAPP_CALLING.PHONE_NUMBER.LABEL')"
@@ -8,6 +8,10 @@ import { clearCookiesOnLogout } from 'dashboard/store/utils/api.js';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
import { parseBoolean } from '@chatwoot/utils';
import { OnClickOutside } from '@vueuse/components';
import { getHelpUrlForFeature } from 'dashboard/helper/featureHelper';
import NextButton from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import UserProfilePicture from './UserProfilePicture.vue';
import UserBasicDetails from './UserBasicDetails.vue';
import MessageSignature from './MessageSignature.vue';
@@ -18,7 +22,7 @@ import NotificationPreferences from './NotificationPreferences.vue';
import AudioNotifications from './AudioNotifications.vue';
import SectionLayout from '../account/components/SectionLayout.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import AccessToken from './AccessToken.vue';
import TokenList from './TokenList.vue';
import MfaSettingsCard from './MfaSettingsCard.vue';
import Policy from 'dashboard/components/policy.vue';
import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue';
@@ -40,9 +44,12 @@ export default {
ChangePassword,
NotificationPreferences,
AudioNotifications,
AccessToken,
TokenList,
MfaSettingsCard,
BaseSettingsHeader,
OnClickOutside,
NextButton,
DropdownMenu,
},
setup() {
const { isEditorHotKeyEnabled, updateUISettings } = useUISettings();
@@ -92,6 +99,7 @@ export default {
],
notificationPermissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
audioNotificationPermissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
isCreateTokenMenuOpen: false,
};
},
computed: {
@@ -103,6 +111,41 @@ export default {
isMfaEnabled() {
return parseBoolean(window.chatwootConfig?.isMfaEnabled);
},
accessTokens() {
return [
{ scope: 'full', value: this.currentUser.access_token },
{ scope: 'read_only', value: this.currentUser.read_only_access_token },
].filter(token => token.value);
},
apiReferenceUrl() {
return getHelpUrlForFeature('access_token_api');
},
cliUrl() {
return getHelpUrlForFeature('chatwoot_cli');
},
createTokenMenuItems() {
// Each user holds at most one token per scope, so only offer scopes
// that don't have a token yet.
const presentScopes = this.accessTokens.map(token => token.scope);
return [
{
label: this.$t(
'PROFILE_SETTINGS.FORM.ACCESS_TOKEN.SCOPES.FULL_LABEL'
),
icon: 'i-lucide-shield-check',
value: 'full',
},
{
label: this.$t(
'PROFILE_SETTINGS.FORM.ACCESS_TOKEN.SCOPES.READ_ONLY_LABEL'
),
icon: 'i-lucide-eye',
value: 'read_only',
},
]
.filter(item => !presentScopes.includes(item.value))
.map(item => ({ ...item, action: 'create' }));
},
},
mounted() {
if (this.currentUserId) {
@@ -192,6 +235,17 @@ export default {
await copyTextToClipboard(value);
useAlert(this.$t('COMPONENTS.CODE.COPY_SUCCESSFUL'));
},
onCreateToken({ value }) {
this.isCreateTokenMenuOpen = false;
this.resetToken(value);
},
async resetToken(scope) {
if (scope === 'read_only') {
await this.resetReadOnlyAccessToken();
} else {
await this.resetAccessToken();
}
},
async resetAccessToken() {
const success = await this.$store.dispatch('resetAccessToken');
if (success) {
@@ -200,6 +254,25 @@ export default {
useAlert(this.$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.RESET_ERROR'));
}
},
async resetReadOnlyAccessToken() {
const hadToken = Boolean(this.currentUser.read_only_access_token);
const success = await this.$store.dispatch('resetReadOnlyAccessToken');
if (success && hadToken) {
useAlert(
this.$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.READ_ONLY_RESET_SUCCESS')
);
} else if (success) {
useAlert(
this.$t(
'PROFILE_SETTINGS.FORM.ACCESS_TOKEN.READ_ONLY_GENERATE_SUCCESS'
)
);
} else {
useAlert(
this.$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.READ_ONLY_RESET_ERROR')
);
}
},
},
};
</script>
@@ -334,10 +407,52 @@ export default {
replaceInstallationName($t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.NOTE'))
"
>
<AccessToken
:value="currentUser.access_token"
@on-copy="onCopyToken"
@on-reset="resetAccessToken"
<template #links>
<div class="flex items-center gap-2 mt-2 text-xs">
<a
:href="apiReferenceUrl"
target="_blank"
rel="noopener noreferrer"
class="text-body-main text-n-blue-11 hover:underline"
>
{{ $t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.LINKS.API_REFERENCE') }}
</a>
<span class="w-px h-3 bg-n-slate-6" aria-hidden="true" />
<a
:href="cliUrl"
target="_blank"
rel="noopener noreferrer"
class="text-body-main text-n-blue-11 hover:underline"
>
{{ $t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.LINKS.CLI') }}
</a>
</div>
</template>
<template v-if="createTokenMenuItems.length" #headerActions>
<OnClickOutside @trigger="isCreateTokenMenuOpen = false">
<div class="relative flex justify-end">
<NextButton
:label="$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.CREATE')"
icon="i-lucide-plus"
size="sm"
blue
solid
class="rounded-lg"
@click="isCreateTokenMenuOpen = !isCreateTokenMenuOpen"
/>
<DropdownMenu
v-if="isCreateTokenMenuOpen"
:menu-items="createTokenMenuItems"
class="ltr:right-0 rtl:left-0 top-10"
@action="onCreateToken"
/>
</div>
</OnClickOutside>
</template>
<TokenList
:tokens="accessTokens"
@copy="onCopyToken"
@reset="resetToken"
/>
</SectionLayout>
</div>
@@ -48,7 +48,6 @@ const updateSignature = () => {
:placeholder="$t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE.PLACEHOLDER')"
channel-type="Context::MessageSignature"
:enable-suggestions="false"
show-image-resize-toolbar
/>
<div>
<NextButton
@@ -0,0 +1,141 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import NextButton from 'dashboard/components-next/button/Button.vue';
import ConfirmButton from 'dashboard/components-next/button/ConfirmButton.vue';
defineProps({
tokens: {
// [{ scope: 'full' | 'read_only', value: String }]
type: Array,
default: () => [],
},
});
const emit = defineEmits(['copy', 'reset']);
const { t } = useI18n();
const tk = key => t(`PROFILE_SETTINGS.FORM.ACCESS_TOKEN.${key}`);
const SCOPE_META = {
full: {
label: () => tk('SCOPES.FULL_LABEL'),
icon: 'i-lucide-shield-check',
badgeClass: 'bg-n-teal-3 text-n-teal-11',
},
read_only: {
label: () => tk('SCOPES.READ_ONLY_LABEL'),
icon: 'i-lucide-eye',
badgeClass: 'bg-n-alpha-2 text-n-slate-11 outline outline-1 outline-n-weak',
},
};
// track reveal state per scope
const revealedScopes = ref([]);
const isRevealed = scope => revealedScopes.value.includes(scope);
const toggleReveal = scope => {
revealedScopes.value = isRevealed(scope)
? revealedScopes.value.filter(s => s !== scope)
: [...revealedScopes.value, scope];
};
const maskToken = value =>
`${value.slice(0, 4)}${'•'.repeat(20)}${value.slice(-4)}`;
</script>
<template>
<div class="overflow-hidden rounded-xl outline outline-1 outline-n-weak">
<div
v-if="!tokens.length"
class="flex flex-col items-center justify-center gap-1 px-4 py-12 text-center"
>
<span
class="grid mb-2 rounded-lg size-10 place-items-center bg-n-alpha-1 text-n-slate-11"
>
<span class="i-lucide-key-round size-5" />
</span>
<p class="text-sm font-medium text-n-slate-12">
{{ tk('EMPTY.TITLE') }}
</p>
<p class="max-w-xs text-sm text-n-slate-11">
{{ tk('EMPTY.DESCRIPTION') }}
</p>
</div>
<table v-else class="min-w-full text-sm border-collapse">
<thead>
<tr class="bg-n-alpha-1 text-n-slate-11">
<th
class="px-4 py-2.5 font-medium text-start text-xs uppercase tracking-wide"
>
{{ tk('TABLE.TOKEN') }}
</th>
<th
class="px-4 py-2.5 font-medium text-start text-xs uppercase tracking-wide"
>
{{ tk('TABLE.PERMISSION') }}
</th>
<th class="px-4 py-2.5 w-40" />
</tr>
</thead>
<tbody class="divide-y divide-n-weak">
<tr
v-for="token in tokens"
:key="token.scope"
class="border-t border-n-weak"
>
<td class="px-4 py-3">
<div class="flex items-center gap-1.5 min-w-0">
<code
class="inline-block min-w-56 px-2 py-1 font-mono text-xs rounded-md bg-n-alpha-1 text-n-slate-12 outline outline-1 outline-n-weak truncate align-middle"
>
{{
isRevealed(token.scope) ? token.value : maskToken(token.value)
}}
</code>
<NextButton
:icon="
isRevealed(token.scope) ? 'i-lucide-eye-off' : 'i-lucide-eye'
"
size="xs"
slate
ghost
class="shrink-0"
@click="toggleReveal(token.scope)"
/>
<NextButton
icon="i-lucide-copy"
size="xs"
slate
ghost
class="shrink-0"
@click="emit('copy', token.value)"
/>
</div>
</td>
<td class="px-4 py-3">
<span
class="inline-flex items-center gap-1.5 px-2 py-0.5 text-xs font-medium rounded-full"
:class="SCOPE_META[token.scope].badgeClass"
>
<span :class="SCOPE_META[token.scope].icon" class="size-3" />
{{ SCOPE_META[token.scope].label() }}
</span>
</td>
<td class="px-4 py-3 text-end">
<ConfirmButton
:label="tk('RESET')"
:confirm-label="tk('CONFIRM_RESET')"
color="slate"
confirm-color="ruby"
variant="outline"
size="xs"
icon="i-lucide-key-round"
@click="emit('reset', token.scope)"
/>
</td>
</tr>
</tbody>
</table>
</div>
</template>
@@ -233,6 +233,16 @@ export const actions = {
}
},
resetReadOnlyAccessToken: async ({ commit }) => {
try {
const response = await authAPI.resetReadOnlyAccessToken();
commit(types.SET_CURRENT_USER, response.data);
return true;
} catch (error) {
return false;
}
},
resendConfirmation: async () => {
try {
await authAPI.resendConfirmation();
@@ -2,15 +2,21 @@ import ConversationAPI from '../../api/conversations';
import types from '../mutation-types';
export const state = {
allCount: 0,
inboxes: {},
labels: {},
teams: {},
};
const normalizeCount = count => {
const parsedCount = Number(count);
return Number.isFinite(parsedCount) && parsedCount > 0 ? parsedCount : 0;
};
const normalizeCounts = counts => {
return Object.entries(counts || {}).reduce((result, [id, count]) => {
const parsedCount = Number(count);
if (Number.isFinite(parsedCount) && parsedCount > 0) {
const parsedCount = normalizeCount(count);
if (parsedCount > 0) {
result[String(id)] = parsedCount;
}
@@ -19,6 +25,9 @@ const normalizeCounts = counts => {
};
export const getters = {
getAllUnreadCount($state) {
return $state.allCount;
},
getInboxUnreadCount: $state => inboxId => {
return $state.inboxes[String(inboxId)] || 0;
},
@@ -55,6 +64,7 @@ export const actions = {
export const mutations = {
[types.SET_CONVERSATION_UNREAD_COUNTS]($state, payload = {}) {
$state.allCount = normalizeCount(payload.all_count);
$state.inboxes = normalizeCounts(payload.inboxes);
$state.labels = normalizeCounts(payload.labels);
$state.teams = normalizeCounts(payload.teams);
@@ -73,6 +73,12 @@ const getValueFromConversation = (conversation, attributeKey) => {
return conversation.display_id || conversation.id;
case 'assignee_id':
return conversation.meta?.assignee?.id;
case 'contact_id':
return (
conversation.meta?.sender?.id ||
conversation.contact?.id ||
conversation.contact_id
);
case 'inbox_id':
return conversation.inbox_id;
case 'team_id':
@@ -244,6 +244,32 @@ describe('filterHelpers', () => {
expect(matchesFilters(conversation, filters)).toBe(false);
});
it('should match conversation with equal_to operator for contact_id', () => {
const conversation = { meta: { sender: { id: 42 } } };
const filters = [
{
attribute_key: 'contact_id',
filter_operator: 'equal_to',
values: { id: 42, name: 'Jane Doe' },
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(true);
});
it('should match conversation with saved contact_id filter values', () => {
const conversation = { meta: { sender: { id: 42 } } };
const filters = [
{
attribute_key: 'contact_id',
filter_operator: 'equal_to',
values: [42],
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(true);
});
// Standard attribute tests - priority
it('should match conversation with equal_to operator for priority', () => {
const conversation = { priority: 'urgent' };
@@ -15,6 +15,12 @@ const FILTER_KEYS = {
[VIEW_TYPES.CONTACT]: VIEW_TYPES.CONTACT,
};
// a folder's contact_id filter stores only the id, extract it so the
// contact can be fetched and its name shown in the edit folder modal
const getFolderContactId = folder =>
folder?.query?.payload?.find(filter => filter.attribute_key === 'contact_id')
?.values?.[0];
export const state = {
[VIEW_TYPES.CONVERSATION]: {
records: [],
@@ -47,6 +53,9 @@ export const getters = {
getActiveConversationFolder(_state) {
return _state.activeConversationFolder;
},
getActiveFolderContactId(_state) {
return getFolderContactId(_state.activeConversationFolder);
},
};
export const actions = {
@@ -104,8 +113,11 @@ export const actions = {
commit(types.SET_CUSTOM_VIEW_UI_FLAG, { isDeleting: false });
}
},
setActiveConversationFolder({ commit }, data) {
setActiveConversationFolder({ commit, dispatch }, data) {
commit(types.SET_ACTIVE_CONVERSATION_FOLDER, data);
// prefetch the contact of a contact filter so the UI can show its name
const contactId = getFolderContactId(data);
if (contactId) dispatch('contacts/show', { id: contactId }, { root: true });
},
};
@@ -15,6 +15,7 @@ describe('#actions', () => {
describe('#get', () => {
it('commits unread counts when API is successful', async () => {
const payload = {
all_count: 2,
inboxes: { 1: '2' },
labels: { 3: 4 },
teams: { 5: 6 },
@@ -3,6 +3,7 @@ import { getters } from '../../conversationUnreadCounts';
describe('#getters', () => {
it('returns inbox unread count by id', () => {
const state = {
allCount: 0,
inboxes: { 1: 2 },
labels: {},
teams: {},
@@ -15,6 +16,7 @@ describe('#getters', () => {
it('returns label unread count by id', () => {
const state = {
allCount: 0,
inboxes: {},
labels: { 3: 4 },
teams: {},
@@ -27,6 +29,7 @@ describe('#getters', () => {
it('returns team unread count by id', () => {
const state = {
allCount: 0,
inboxes: {},
labels: {},
teams: { 5: 6 },
@@ -37,8 +40,20 @@ describe('#getters', () => {
expect(getters.getTeamUnreadCount(state)(6)).toBe(0);
});
it('returns all unread count', () => {
const state = {
allCount: 7,
inboxes: {},
labels: {},
teams: {},
};
expect(getters.getAllUnreadCount(state)).toBe(7);
});
it('returns unread count maps', () => {
const state = {
allCount: 0,
inboxes: { 1: 2 },
labels: { 3: 4 },
teams: { 5: 6 },
@@ -4,9 +4,10 @@ import { mutations } from '../../conversationUnreadCounts';
describe('#mutations', () => {
describe('#SET_CONVERSATION_UNREAD_COUNTS', () => {
it('normalizes unread count payload', () => {
const state = { inboxes: {}, labels: {}, teams: {} };
const state = { allCount: 0, inboxes: {}, labels: {}, teams: {} };
mutations[types.SET_CONVERSATION_UNREAD_COUNTS](state, {
all_count: '3',
inboxes: {
1: '2',
2: 0,
@@ -23,6 +24,7 @@ describe('#mutations', () => {
});
expect(state).toEqual({
allCount: 3,
inboxes: { 1: 2 },
labels: { 4: 5 },
teams: { 6: 7 },
@@ -31,6 +33,7 @@ describe('#mutations', () => {
it('clears counts when payload is empty', () => {
const state = {
allCount: 2,
inboxes: { 1: 2 },
labels: { 4: 5 },
teams: { 6: 7 },
@@ -39,10 +42,21 @@ describe('#mutations', () => {
mutations[types.SET_CONVERSATION_UNREAD_COUNTS](state, {});
expect(state).toEqual({
allCount: 0,
inboxes: {},
labels: {},
teams: {},
});
});
it('normalizes invalid aggregate counts to zero', () => {
const state = { allCount: 2, inboxes: {}, labels: {}, teams: {} };
mutations[types.SET_CONVERSATION_UNREAD_COUNTS](state, {
all_count: 'invalid',
});
expect(state.allCount).toBe(0);
});
});
});
@@ -1,7 +1,11 @@
import axios from 'axios';
import { actions } from '../../customViews';
import * as types from '../../../mutation-types';
import { customViewList, updateCustomViewList } from './fixtures';
import { actions } from '../../customViews';
import {
contactFilterView,
customViewList,
updateCustomViewList,
} from './fixtures';
const commit = vi.fn();
global.axios = axios;
@@ -106,5 +110,27 @@ describe('#actions', () => {
[types.default.SET_ACTIVE_CONVERSATION_FOLDER, customViewList[0]],
]);
});
it('prefetches the contact of a contact filter', async () => {
const dispatch = vi.fn();
await actions.setActiveConversationFolder(
{ commit, dispatch },
contactFilterView
);
expect(dispatch).toHaveBeenCalledWith(
'contacts/show',
{ id: 42 },
{ root: true }
);
});
it('does not prefetch without a contact filter', async () => {
const dispatch = vi.fn();
await actions.setActiveConversationFolder(
{ commit, dispatch },
customViewList[0]
);
expect(dispatch).not.toHaveBeenCalled();
});
});
});
@@ -15,6 +15,21 @@ export const contactViewList = [
},
];
export const contactFilterView = {
name: 'Contact view',
filter_type: 0,
query: {
payload: [
{
attribute_key: 'contact_id',
filter_operator: 'equal_to',
values: [42],
query_operator: null,
},
],
},
};
export const customViewList = [
{
name: 'Custom view',
@@ -1,5 +1,5 @@
import { getters } from '../../customViews';
import { contactViewList, customViewList } from './fixtures';
import { contactFilterView, contactViewList, customViewList } from './fixtures';
describe('#getters', () => {
it('getCustomViewsByFilterType', () => {
@@ -43,4 +43,20 @@ describe('#getters', () => {
customViewList[0]
);
});
it('getActiveFolderContactId', () => {
expect(
getters.getActiveFolderContactId({
activeConversationFolder: contactFilterView,
})
).toEqual(42);
});
it('getActiveFolderContactId returns undefined without a contact filter', () => {
expect(
getters.getActiveFolderContactId({
activeConversationFolder: customViewList[0],
})
).toBeUndefined();
});
});
@@ -1,28 +1,37 @@
import MarkdownIt from 'markdown-it';
import mila from 'markdown-it-link-attributes';
import mentionPlugin from './markdownIt/link';
import MarkdownIt from 'markdown-it';
const setImageHeight = inlineToken => {
const setImageSizing = inlineToken => {
const imgSrc = inlineToken.attrGet('src');
if (!imgSrc) return;
const url = new URL(imgSrc);
const width = url.searchParams.get('cw_image_width');
if (width) {
inlineToken.attrSet(
'style',
`width: ${width}; max-width: 100%; height: auto;`
);
return;
}
const height = url.searchParams.get('cw_image_height');
if (!height) return;
inlineToken.attrSet('style', `height: ${height};`);
if (height) inlineToken.attrSet('style', `height: ${height};`);
};
const processInlineToken = blockToken => {
blockToken.children.forEach(inlineToken => {
if (inlineToken.type === 'image') {
setImageHeight(inlineToken);
setImageSizing(inlineToken);
}
});
};
const imgResizeManager = md => {
// Custom rule for image resize in markdown
// If the image url has a query param cw_image_height, then add a style attribute to the image
md.core.ruler.after('inline', 'add-image-height', state => {
// If the image URL carries a cw_image_width or cw_image_height query param,
// add an inline style attribute so the rendered <img> respects the agent's
// resize choice. Width takes precedence (HC drag-resize); height is kept for
// legacy messages and the message-signature use case.
md.core.ruler.after('inline', 'add-image-sizing', state => {
state.tokens.forEach(blockToken => {
if (blockToken.type === 'inline') {
processInlineToken(blockToken);
@@ -54,6 +63,13 @@ const createMarkdownInstance = (linkify = true) => {
});
};
// Help center article tables persist column widths as an internal
// `<!--cw-colwidths:...-->` comment before the table. It exists only for the
// editor's markdown round-trip and must never surface as text — markdown-it runs
// with `html: false`, which would otherwise escape it into a visible comment in
// rendered/plain output (e.g. dashboard search snippets). Strip it on the way in.
const COLWIDTHS_MARKER_REGEX = /<!--cw-colwidths:[\d,]+-->\r?\n?/g;
const TWITTER_USERNAME_REGEX = /(^|[^@\w])@(\w{1,15})\b/g;
const TWITTER_USERNAME_REPLACEMENT = '$1[@$2](http://twitter.com/$2)';
const TWITTER_HASH_REGEX = /(^|\s)#(\w+)/g;
@@ -66,7 +82,7 @@ class MessageFormatter {
isAPrivateNote = false,
linkify = true
) {
this.message = message || '';
this.message = (message || '').replace(COLWIDTHS_MARKER_REGEX, '');
this.isAPrivateNote = isAPrivateNote;
this.isATweet = isATweet;
this.linkify = linkify;
@@ -126,6 +126,16 @@ describe('#MessageFormatter', () => {
});
});
describe('help center table colwidth marker', () => {
it('strips the internal colwidths marker from rendered output', () => {
const message =
'<!--cw-colwidths:120,200-->\n| A | B |\n| --- | --- |\n| 1 | 2 |';
const formatter = new MessageFormatter(message);
expect(formatter.formattedMessage).not.toContain('cw-colwidths');
expect(formatter.plainText).not.toContain('cw-colwidths');
});
});
describe('#sanitize', () => {
it('sanitizes markup and removes all unnecessary elements', () => {
const message =
+15 -4
View File
@@ -34,13 +34,24 @@ body {
.message-content {
ul {
list-style: disc;
@apply ltr:pl-3 rtl:pr-3;
@apply list-disc list-inside;
}
ol {
list-style: decimal;
@apply ltr:pl-4 rtl:pr-4;
@apply list-decimal list-inside;
}
li {
padding-inline-start: 1.5em;
text-indent: -1.5em;
> p:first-child {
@apply inline;
}
> * {
text-indent: 0;
}
}
}
@@ -36,6 +36,9 @@ class ActionCableConnector extends BaseActionCableConnector {
onReconnect = () => {
this.syncLatestMessages();
// Re-fetch conversation attributes so a status change (e.g. auto-resolve)
// that happened while disconnected is reflected, keeping the reply box state correct.
this.app.$store.dispatch('conversationAttributes/getAttributes');
};
setLastMessageId = () => {
@@ -0,0 +1,53 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
import ActionCableConnector from '../actionCable';
vi.mock('@rails/actioncable', () => ({
createConsumer: () => ({
subscriptions: { create: () => ({}) },
disconnect: vi.fn(),
}),
}));
describe('Widget ActionCableConnector', () => {
let app;
let mockDispatch;
let connector;
beforeEach(() => {
vi.useFakeTimers();
mockDispatch = vi.fn();
app = {
$store: {
dispatch: mockDispatch,
getters: {
getCurrentAccountId: 1,
getCurrentUserID: 1,
},
},
};
connector = new ActionCableConnector(app, 'test-token');
mockDispatch.mockClear();
});
afterEach(() => {
vi.clearAllMocks();
vi.useRealTimers();
});
it('registers the conversation.status_changed event handler', () => {
expect(connector.events['conversation.status_changed']).toBe(
connector.onStatusChange
);
});
it('re-fetches conversation attributes on reconnect so a status change missed while disconnected is reflected', () => {
connector.onReconnect();
expect(mockDispatch).toHaveBeenCalledWith(
'conversation/syncLatestMessages'
);
expect(mockDispatch).toHaveBeenCalledWith(
'conversationAttributes/getAttributes'
);
});
});
+13
View File
@@ -14,6 +14,19 @@
class MutexApplicationJob < ApplicationJob
class LockAcquisitionError < StandardError; end
def self.retry_on_lock_conflict(wait:, attempts:, on_exhaustion: :raise)
retry_on LockAcquisitionError, wait: wait, attempts: attempts do |job, error|
raise error if on_exhaustion == :raise
job.public_send(on_exhaustion, *job.arguments)
end
end
# Redis::LockManager#unlock is not owner-checked. If a job runs past the TTL,
# Redis can expire the key, a newer job can acquire it, and the older job can
# then delete the newer job's lock on unlock. Current mutex users treat locks as
# short race dampeners, so this is acceptable for now. Future iterations should
# move Redis::LockManager to token-checked unlocks.
def with_lock(lock_key, timeout = Redis::LockManager::LOCK_TIMEOUT)
lock_manager = Redis::LockManager.new
+18 -2
View File
@@ -1,6 +1,14 @@
class Webhooks::InstagramEventsJob < MutexApplicationJob
queue_as :default
retry_on LockAcquisitionError, wait: 1.second, attempts: 8
# This lock is only a short race dampener for first-message conversation creation.
# ContactInbox creation is already protected by a unique index, but conversation
# lookup is `find active conversation || create`, so concurrent first messages from
# the same IG contact can create duplicate conversations.
#
# ActiveJob retries are not FIFO, so a longer retry window does not preserve message
# order. Use deterministic backoff so the final attempt happens after the 3s lock TTL,
# then process without the lock instead of dropping the webhook.
retry_on_lock_conflict wait: ->(executions) { executions.seconds }, attempts: 3, on_exhaustion: :process_without_lock
# @return [Array] We will support further events like reaction or seen in future
SUPPORTED_EVENTS = [:message, :read].freeze
@@ -9,11 +17,19 @@ class Webhooks::InstagramEventsJob < MutexApplicationJob
@entries = entries
key = format(::Redis::Alfred::IG_MESSAGE_MUTEX, sender_id: contact_instagram_id, ig_account_id: ig_account_id)
with_lock(key) do
# Keep the lock TTL just long enough for the first job to fetch profile data and
# create the contact/conversation. A longer TTL would add user-visible latency for
# hot contacts without giving us ordering guarantees.
with_lock(key, 3.seconds) do
process_entries(entries)
end
end
def process_without_lock(entries)
Rails.logger.warn("[#{self.class.name}] Processing without lock after lock retry exhaustion")
process_entries(entries)
end
# https://developers.facebook.com/docs/messenger-platform/instagram/features/webhook
def process_entries(entries)
entries.each do |entry|
+6 -1
View File
@@ -126,12 +126,17 @@ class Webhooks::WhatsappEventsJob < MutexApplicationJob
def channel_is_inactive?(channel)
return true if channel.blank?
return true if channel.reauthorization_required?
# Only skip for embedded signup when reauth is required; manual flow uses API keys and should still receive webhooks
return true if channel.reauthorization_required? && embedded_signup_channel?(channel)
return true unless channel.account.active?
false
end
def embedded_signup_channel?(channel)
(channel.provider_config || {}).to_h['source'] == 'embedded_signup'
end
def find_channel_by_url_param(params)
return unless params[:phone_number]
+2 -2
View File
@@ -68,7 +68,7 @@ class WebhookListener < BaseListener
def inbox_created(event)
inbox, account = extract_inbox_and_account(event)
inbox_webhook_data = Inbox::EventDataPresenter.new(inbox).push_data
inbox_webhook_data = Inbox::EventDataPresenter.new(inbox).webhook_data
payload = inbox_webhook_data.merge(event: __method__.to_s)
deliver_account_webhooks(payload, account)
end
@@ -78,7 +78,7 @@ class WebhookListener < BaseListener
changed_attributes = extract_changed_attributes(event)
return if changed_attributes.blank?
inbox_webhook_data = Inbox::EventDataPresenter.new(inbox).push_data
inbox_webhook_data = Inbox::EventDataPresenter.new(inbox).webhook_data
payload = inbox_webhook_data.merge(event: __method__.to_s, changed_attributes: changed_attributes)
deliver_account_webhooks(payload, account)
end
+12 -7
View File
@@ -65,11 +65,10 @@ class Imap::ImapMailbox
end
def in_reply_to
@processed_mail.in_reply_to
sanitize_mailbox_value(@processed_mail.in_reply_to)
end
def find_conversation_by_references
references = Array.wrap(@inbound_mail.references)
references.each do |message_id|
match = FALLBACK_CONVERSATION_PATTERN.match(message_id)
@@ -80,8 +79,6 @@ class Imap::ImapMailbox
def find_message_by_references
message_to_return = nil
references = Array.wrap(@inbound_mail.references)
references.each do |message_id|
message = @inbox.messages.find_by(source_id: message_id)
message_to_return = message if message.present?
@@ -100,7 +97,7 @@ class Imap::ImapMailbox
source: 'email',
in_reply_to: in_reply_to,
auto_reply: @processed_mail.auto_reply?,
mail_subject: @processed_mail.subject,
mail_subject: sanitize_mailbox_value(@processed_mail.subject),
initiated_at: {
timestamp: Time.now.utc
}
@@ -110,7 +107,7 @@ class Imap::ImapMailbox
end
def find_or_create_contact
@contact = @inbox.contacts.from_email(@processed_mail.original_sender)
@contact = @inbox.contacts.from_email(original_sender_email)
if @contact.present?
@contact_inbox = ContactInbox.find_by(inbox: @inbox, contact: @contact)
else
@@ -119,6 +116,14 @@ class Imap::ImapMailbox
end
def identify_contact_name
processed_mail.sender_name || processed_mail.from.first.split('@').first
sanitize_mailbox_value(processed_mail.sender_name || processed_mail.from.first.split('@').first)
end
def original_sender_email
sanitize_mailbox_value(@processed_mail.original_sender)
end
def references
sanitize_mailbox_value(Array.wrap(@inbound_mail.references))
end
end
+11 -19
View File
@@ -1,27 +1,16 @@
module MailboxHelper
include MailboxInlineAttachmentHelper
include MailboxSanitizer
include ::FileTypeHelper
private
def create_message
Rails.logger.info "[MailboxHelper] Creating message #{processed_mail.message_id}"
return if @conversation.messages.find_by(source_id: processed_mail.message_id).present?
source_id = sanitize_mailbox_value(processed_mail.message_id)
return if @conversation.messages.find_by(source_id: source_id).present?
@message = @conversation.messages.create!(
account_id: @conversation.account_id,
sender: @conversation.contact,
content: mail_content&.truncate(150_000),
inbox_id: @conversation.inbox_id,
message_type: 'incoming',
content_type: 'incoming_email',
source_id: processed_mail.message_id,
content_attributes: {
email: processed_mail.serialized_data,
cc_email: processed_mail.cc,
bcc_email: processed_mail.bcc
}
)
@message = @conversation.messages.create!(sanitized_message_attributes(source_id))
end
def add_attachments_to_message
@@ -101,13 +90,16 @@ module MailboxHelper
end
def create_contact
sender_email = sanitize_mailbox_value(processed_mail.original_sender)
message_id = sanitize_mailbox_value(processed_mail.message_id)
@contact_inbox = ::ContactInboxWithContactBuilder.new(
source_id: processed_mail.original_sender,
source_id: sender_email,
inbox: @inbox,
contact_attributes: {
name: identify_contact_name,
email: processed_mail.original_sender,
additional_attributes: { source_id: "email:#{processed_mail.message_id}" }
name: sanitize_mailbox_value(identify_contact_name),
email: sender_email,
additional_attributes: { source_id: "email:#{message_id}" }
}
).perform
+34
View File
@@ -0,0 +1,34 @@
module MailboxSanitizer
NULL_BYTE = "\u0000".freeze
private
def sanitized_message_attributes(source_id)
{
account_id: @conversation.account_id,
sender: @conversation.contact,
content: sanitize_mailbox_value(mail_content)&.truncate(150_000),
inbox_id: @conversation.inbox_id,
message_type: 'incoming',
content_type: 'incoming_email',
source_id: source_id,
content_attributes: sanitized_content_attributes
}
end
def sanitized_content_attributes
sanitize_mailbox_value(
email: processed_mail.serialized_data,
cc_email: processed_mail.cc,
bcc_email: processed_mail.bcc
)
end
def sanitize_mailbox_value(value)
return value.delete(NULL_BYTE) if value.is_a?(String)
return value.map { |item| sanitize_mailbox_value(item) } if value.is_a?(Array)
return value.transform_values { |item| sanitize_mailbox_value(item) } if value.is_a?(Hash)
value
end
end
@@ -54,8 +54,7 @@ module ConversationReplyMailerHelper
tls: false,
enable_starttls_auto: true,
openssl_verify_mode: 'none',
open_timeout: 15,
read_timeout: 15,
**smtp_timeout_settings,
authentication: 'xoauth2'
}
end
@@ -72,6 +71,7 @@ module ConversationReplyMailerHelper
tls: @channel.smtp_enable_ssl_tls,
enable_starttls_auto: @channel.smtp_enable_starttls_auto,
openssl_verify_mode: @channel.smtp_openssl_verify_mode,
**smtp_timeout_settings,
authentication: @channel.smtp_authentication
}
@@ -79,6 +79,13 @@ module ConversationReplyMailerHelper
@options[:delivery_method_options] = smtp_settings
end
def smtp_timeout_settings
{
open_timeout: ENV['SMTP_OPEN_TIMEOUT'].presence || 15,
read_timeout: ENV['SMTP_READ_TIMEOUT'].presence || 30
}.transform_values(&:to_i)
end
def email_smtp_enabled?
@inbox.inbox_type == 'Email' && @channel.smtp_enabled
end
+9
View File
@@ -4,6 +4,7 @@
#
# id :bigint not null, primary key
# owner_type :string
# scope :string default("full"), not null
# token :string
# created_at :datetime not null
# updated_at :datetime not null
@@ -11,11 +12,19 @@
#
# Indexes
#
# index_access_tokens_on_owner_and_scope (owner_type,owner_id,scope) UNIQUE
# index_access_tokens_on_owner_type_and_owner_id (owner_type,owner_id)
# index_access_tokens_on_token (token) UNIQUE
#
class AccessToken < ApplicationRecord
# Schema-wise this table can hold any number of tokens per owner, but we
# intentionally cap user-facing tokens at two: one `full` and one `read_only`.
# The User model's has_one associations + auto-create callbacks enforce the
# 1+1 contract; this validation only constrains the allowed scope values.
SCOPES = %w[full read_only].freeze
has_secure_token :token
belongs_to :owner, polymorphic: true
validates :scope, inclusion: { in: SCOPES }
end
+4 -1
View File
@@ -1,4 +1,7 @@
class ApplicationRecord < ActiveRecord::Base
MAX_STRING_COLUMN_LENGTH = 255
MAX_TEXT_COLUMN_LENGTH = 20_000
include Events::Types
self.abstract_class = true
@@ -37,7 +40,7 @@ class ApplicationRecord < ActiveRecord::Base
end
def validate_content_length(column)
max_length = column.type == :text ? 20_000 : 255
max_length = column.type == :text ? MAX_TEXT_COLUMN_LENGTH : MAX_STRING_COLUMN_LENGTH
return if self[column.name].nil? || self[column.name].length <= max_length
errors.add(column.name.to_sym, "is too long (maximum is #{max_length} characters)")
+6
View File
@@ -12,6 +12,7 @@
# medium :integer default("sms")
# messaging_service_sid :string
# phone_number :string
# provider_config :jsonb
# twiml_app_sid :string
# voice_enabled :boolean default(FALSE), not null
# created_at :datetime not null
@@ -54,6 +55,11 @@ class Channel::TwilioSms < ApplicationRecord
medium == 'sms' ? 'Twilio SMS' : 'Whatsapp'
end
# Mutes only the incoming side of calling; default on, so only an explicit false disables inbound.
def inbound_calls_enabled?
provider_config['inbound_calls_enabled'] != false
end
def send_message(to:, body:, media_url: nil)
params = send_message_from.merge(to: to, body: body)
params[:media_url] = media_url if media_url.present?
+5
View File
@@ -49,6 +49,11 @@ class Channel::Whatsapp < ApplicationRecord
account.feature_enabled?('channel_voice')
end
# Mutes only the incoming side of calling; default on, so only an explicit false disables inbound.
def inbound_calls_enabled?
provider_config['inbound_calls_enabled'] != false
end
# Whether this inbox can do WhatsApp calling at all. Meta's Calling API is
# reachable by any whatsapp_cloud inbox, so 360dialog inboxes can't be toggled
# on even though calling_enabled would persist.
+7 -2
View File
@@ -1,11 +1,16 @@
module AccessTokenable
extend ActiveSupport::Concern
# AccessToken is polymorphic and the table allows many rows per owner. By
# convention we expose at most two per User (one `full`, one `read_only`);
# AgentBot and PlatformApp keep their single `full` token. The scoped
# has_one below is what makes `owner.access_token` deterministic.
included do
has_one :access_token, as: :owner, dependent: :destroy_async
has_one :access_token, -> { where(scope: 'full') },
as: :owner, class_name: 'AccessToken', inverse_of: :owner, dependent: :destroy_async
after_create :create_access_token
end
def create_access_token
AccessToken.create!(owner: self)
AccessToken.create!(owner: self, scope: 'full')
end
end

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