Compare commits

..
Author SHA1 Message Date
aakashb95 add3b98e07 fix(captain): bypass Firecrawl cache during sync 2026-06-03 15:52:59 +05:30
tomsideguide 508631a8ca chore(captain): migrate Firecrawl service to v2 API
Update Captain::Tools::FirecrawlService to target api.firecrawl.dev/v2.
Rename crawl params for v2: maxDepth -> maxDiscoveryDepth,
ignoreSitemap:false -> sitemap:'include', and send webhook as an object.
2026-06-02 16:43:04 +01:00
37eed5de1e feat(whatsapp): Add support for voice messages (#14606)
> Reopened from #13613, now from a personal fork
(`gabrieljablonski/chatwoot`) so maintainers can push edits —
organization-owned forks don't support "Allow edits from maintainers".
The previous PR is closed in favor of this one; same commits, same diff.

## Description

This PR adds support for sending voice messages (voice notes) through
the WhatsApp Cloud API. When agents record audio in Chatwoot, it is now
transcoded in the browser from WebM/Opus to OGG/Opus and sent with the
`voice: true` flag, so it appears as a native voice note bubble on
WhatsApp — not as a file/document attachment.

Closes #13283

**Key Changes:**
- Added `webmOpusToOgg.js` — a pure JS EBML parser + OGG page builder
that remuxes browser-recorded WebM/Opus audio into OGG/Opus entirely
client-side, with no server-side dependencies.
- Updated `AudioRecorder.vue` to use an explicit `mimeType` hint, proper
resource cleanup, and an `AUDIO_EXTENSION_MAP` for correct file
extensions.
- Renamed `mp3ConversionUtils.js` → `audioConversionUtils.js` and added
OGG conversion support via the new remuxer.
- Updated `ReplyBox.vue` to request OGG format for WhatsApp channels,
pass `isVoiceMessage` per-attachment, and handle recording errors with a
user-facing alert.
- Updated `MessageBuilder` to read the `is_voice_message` param and
persist it in attachment metadata.
- Updated `WhatsappCloudService` to:
- Normalize `audio/opus` → `audio/ogg` content type on ActiveStorage
blobs (works around Marcel gem re-detection).
- Send the `voice: true` flag when the attachment is a voice message
with `audio/ogg` content type.
  - Use WhatsApp Cloud API `v24.0` for the attachment endpoint.
- Added `AUDIO_CONVERSION_FAILED` i18n key.

**How it works:**
1. The browser records audio as WebM/Opus (Chrome/Firefox default).
2. `audioConversionUtils.js` remuxes it to OGG/Opus using the pure-JS
`webmOpusToOgg` remuxer — no server transcoding needed.
3. The OGG file is uploaded with `is_voice_message: true` in the form
payload.
4. `MessageBuilder` persists `is_voice_message` in the attachment's
`meta` hash.
5. `WhatsappCloudService` normalizes the blob content type if needed,
then sends the attachment with `voice: true` so WhatsApp renders it as a
voice note.

## Type of change

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

## How Has This Been Tested?

1. Record a voice message in a WhatsApp Cloud conversation.
2. Verify the audio is transcoded to OGG (check file extension in the
attachment preview).
3. Verify the message arrives on WhatsApp as a voice note bubble (not a
document/file).
4. Send an image or document attachment and verify it still works as
before (no `voice` flag).
5. Send a regular (non-voice) audio file and verify it arrives without
the voice flag.

---------

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>
2026-06-02 15:33:32 +04:00
170b64d1f1 chore: upgrade to vite 6 (#14363)
Upgrades the frontend toolchain to Vite 6 and tidies up the build config
along the way. Behavior is unchanged for end users; this is dev/build
infra.

## What changed
- `vite` 5.4 → 6.4, `@vitejs/plugin-vue` → 5.2, `vite-plugin-ruby` → 5.2
(with matching `vite_rails`/`vite_ruby` gem bumps).
- Dropped the `vite-node` 2.0.1 pnpm override — no longer needed now
that vitest 3 runs on Vite 6 directly.
- Split the single `vite.config.ts` into:
- `vite.config.ts` (app), `vite.lib.config.ts` (SDK), `vite.shared.ts`
(aliases / Vue options), `vitest.config.ts` (tests).
- `pnpm build:sdk` now selects the SDK config explicitly instead of
branching on `BUILD_MODE=library`. SDK output path is unchanged
(`public/packs/js/sdk.js`).

No changes needed to Docker images, deployment scripts, or CI — Node 24
and pnpm 10 are already past Vite 6's floor, and the rake
`assets:precompile` hook still drives the SDK build via `pnpm`.

## How to test
- `pnpm dev` and verify the dashboard, widget, and survey routes load
and HMR works.
- Load a Chatwoot site widget on a test page and confirm `sdk.js` is
served and the widget mounts.
- `RAILS_ENV=production bundle exec rake assets:precompile` and confirm
`public/packs/js/sdk.js` plus the rest of the manifest are produced.
- `pnpm test` for the JS suite.

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
2026-06-02 17:01:37 +05:30
95cb3a7ad8 feat(onboarding): honor return_to hint in Instagram OAuth callback (#14568)
When connecting an Instagram inbox during onboarding, the OAuth flow
used to drop users in inbox settings, breaking onboarding. The OAuth
start endpoint now accepts an optional `return_to=onboarding` hint,
carried tamper-proof inside the signed state (a claim on the Instagram
JWT), and the callback uses it to return the user to the onboarding
inbox-setup screen. Without the hint, behavior is unchanged.

This is the backend half only; the frontend that sends
`return_to=onboarding` ships separately.

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-06-02 15:28:08 +05:30
ecd9c26c8c feat: Implemented search results page functionality (#11086)
# Pull Request Template

## Description
Implemented search results page functionality. Now you can press "Enter"
to search by term and display results in a results page. Also now you
can link to /hc/{account}/en/search?query=XXXXXX to view search results
for XXXXXX query.

fixes: https://github.com/chatwoot/chatwoot/issues/10945

## Screenshots

Classic layout search results:
<img width="3840" height="2160" alt="classic-results"
src="https://github.com/user-attachments/assets/3bbb3272-33ca-4eb4-b80a-76ed77442088"
/>

Classic layout pagination:
<img width="3840" height="2160" alt="classic-page-two"
src="https://github.com/user-attachments/assets/062b09d3-7c58-4d3b-8611-b94375e7db51"
/>

Classic layout empty search:
<img width="3840" height="2160" alt="no-results"
src="https://github.com/user-attachments/assets/c5e3f47a-cd9a-4e14-ae92-ccba00c89e98"
/>

Documentation layout search results:
<img width="3840" height="2160" alt="documentation-results"
src="https://github.com/user-attachments/assets/9e45d8d9-c975-4589-b6c6-3bc7bb3c588e"
/>

Documentation layout dark theme:
<img width="3840" height="2160" alt="documentation-dark"
src="https://github.com/user-attachments/assets/cdb6ed63-4241-4b32-9f79-7d92ed479fc8"
/>

Plain embedded dark layout:
<img width="3840" height="2160" alt="plain-embedded-dark"
src="https://github.com/user-attachments/assets/7deb02b9-9f24-48fb-8979-a2ecd7002c05"
/>

---------

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
Co-authored-by: Pranav <pranav@chatwoot.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Vinay Keerthi <11478411+stonecharioteer@users.noreply.github.com>
2026-06-02 15:19:23 +05:30
Sony MathewandGitHub 88e2661ca6 feat(conversations): remove unread count feature flag (CW-7237) (#14610)
## Description

Make conversation unread counts always available at runtime by removing
account feature checks from the API endpoint, unread-count listener,
notifier, and ActionCable broadcast path.

Update the dashboard to fetch sidebar unread counts for the active
account without checking FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS, and
remove the now-unused store clear action that only supported the
disabled state.

Keep the feature entry in config/features.yml to preserve flag bit
order, but mark it enabled and deprecated so fresh installs default to
the always-on behavior while feature-management UI hides it.

Leave existing installation default rows untouched; no migration is
included, so upgraded installs may still store the old flag value but
runtime behavior no longer depends on it.

Update specs around the new always-on contract and remove obsolete
disabled-feature assertions.

Fixes # CW-7237

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

Update specs around the new always-on contract and remove obsolete
disabled-feature assertions. Ran specs locally for the changes.


## 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
2026-06-02 14:35:37 +05:30
Sojan JoseandGitHub 33dea83716 docs: document message attachment uploads (#14600)
Updates the Create New Message API documentation to explain how to send
file attachments with multipart form data.

Closes #10472
Closes #12672

## Why

The endpoint already accepts attachment uploads, but the published API
docs only described the JSON request body. That made it unclear that
clients need to use multipart form data and send files through the
`attachments[]` field.

## What changed

- Adds `multipart/form-data` as a documented request body option for
Create New Message.
- Documents the `attachments` binary array and form encoding used by
`attachments[]`.
- Adds a multipart cURL request example for a message with an
attachment.
- Regenerates the Swagger JSON artifacts.

## Screenshots

Create New Message API docs with the multipart cURL sample and
`multipart/form-data` request sample selected:

<img width="1702" height="1083"
alt="create-message-attachment-docs-multipart"
src="https://github.com/user-attachments/assets/5fef9082-6eb9-494d-91d1-8dc0b7880212"
/>

## How to test

1. Open `/swagger`.
2. Navigate to Application -> Messages -> Create New Message.
3. Confirm the endpoint documents both `application/json` and
`multipart/form-data`, including the attachment payload schema.
2026-06-02 14:33:02 +05:30
4c6a345d60 feat(onboarding): honor return hint in email OAuth callback (#14567)
When connecting a Gmail or Outlook inbox during onboarding, the OAuth
flow used to drop users in inbox settings, breaking onboarding. The
OAuth start endpoint now accepts an optional `return_to=onboarding`
hint, carried tamper-proof inside the signed `state`, and the callback
uses it to return the user to the onboarding inbox-setup screen. Without
the hint, behavior is unchanged.

This is the backend half only; the frontend that sends
`return_to=onboarding` ships separately.

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-06-02 14:21:11 +05:30
Shivam MishraandGitHub 04ac9d3780 fix: use UPN for imap_login on Microsoft OAuth callback (#14522)
Outgoing email on a Microsoft email inbox was failing with `535 5.7.3
Authentication unsuccessful` immediately after the user
re-authenticated, while incoming (IMAP) continued to work. The root
cause is in our OAuth callback: we persist the id_token's `email` claim
into `channel.imap_login`, but Microsoft's SMTP AUTH (XOAUTH2) validates
the username against the access token's **UPN**, not the mailbox's
primary SMTP address or aliases. When those diverge — common in tenants
that use one domain for sign-in identities and another for mailbox
addresses — SMTP rejects every send.

This PR makes `OauthCallbackController#update_channel` prefer
`preferred_username` (v2.0) / `upn` (v1.0) from the id_token over
`email`, with `email` as the fallback so Google flows are unchanged.

## What changed

- `app/controllers/oauth_callback_controller.rb` — extract
`imap_login_identity` (default: `users_data['email']`, same as before).
`update_channel` now calls it instead of inlining the email claim. No
behavioural change in the base controller.
- `app/controllers/microsoft/callbacks_controller.rb` — override
`imap_login_identity` to return `preferred_username || upn || super`.
Provider-specific knowledge stays in the provider subclass; Google's
flow is literally untouched.
- `spec/controllers/microsoft/callbacks_controller_spec.rb` — adds one
example that reproduces the divergent shape (`email: <alias>`,
`preferred_username: <upn>`) and asserts `imap_login` lands on the UPN
while `channel.email` stays on the mailbox alias.

`channel.email` and `find_channel_by_email` still key on the id_token's
`email` claim everywhere, so customer-facing From identity and
reconnect-by-email matching are unchanged.

Behavioural matrix:

| Provider / shape | `imap_login` before | `imap_login` after |

|-----------------------------------------------------------------|---------------------|--------------------|
| Microsoft, `upn == email` (common case) | email | UPN (= email, same
string) |
| Microsoft, `upn != email` (e.g. UPN on one domain, mailbox alias on
another) | alias (broken) | UPN (works) |
| Google | email | email (no change, base impl used) |

## Why this happens (Microsoft side, for context)

Two facts that together produce the bug — one is documented, the other
we verified empirically because Microsoft's docs don't address it:

1. **`email` and `upn` are different claims and can legitimately
diverge.** In Entra, the UPN is the sign-in identity; the id_token's
`email` claim is the user's mailbox property (which can be a proxy
address). For v2.0 tokens (which is what we use —
`/common/oauth2/v2.0/token` in `MicrosoftConcern`), the documented
"username to sign in as" claim is `preferred_username`. See [ID token
claims
reference](https://learn.microsoft.com/en-us/entra/identity-platform/id-token-claims-reference).
So `users_data['email']` was the wrong source for `imap_login`; tenants
where UPN == primary SMTP happened to mask the bug.

2. **Exchange's XOAUTH2 SMTP rejects aliases in the `user=` field, even
though IMAP accepts them.** This asymmetry is **not** in [Microsoft's
canonical XOAUTH2
doc](https://learn.microsoft.com/en-us/exchange/client-developer/legacy-protocols/how-to-authenticate-an-imap-pop-smtp-application-by-using-oauth)
— the doc treats the SASL `user=` field uniformly across IMAP, POP, and
SMTP, with only a shared-mailbox carve-out spelled out. We confirmed the
SMTP-strict behaviour empirically by running an XOAUTH2 AUTH probe with
the same access token: `user=<alias>` returned `535 5.7.3`, `user=<UPN>`
returned `235`. Same token, only the `user=` field differed. That's what
tied the symptom to claim-shape.

Hence the patch: read the documented "sign-in name" claim and persist
that, not the customer-facing mailbox address.

## How to reproduce (before the fix)

1. Set up a Microsoft email inbox in a tenant where the user's UPN
domain differs from the user's primary SMTP / mailbox domain (e.g. UPN
`user@tenant-a.example`, mailbox `User@tenant-b.example` where
`tenant-b.example` is a proxy address on the same mailbox).
2. Connect or re-authenticate the inbox through the standard flow.
3. Inspect the channel:
   ```ruby
ch.imap_login # => "User@tenant-b.example" (alias — wrong)
   token = ch.provider_config['access_token']
JSON.parse(Base64.urlsafe_decode64(token.split('.')[1].then { |s| s +
'=' * (-s.length % 4) }))['upn']
   # => "user@tenant-a.example"  (UPN — what SMTP actually needs)
   ```
4. Send a reply. SMTP returns `535 5.7.3 Authentication unsuccessful`.
Incoming IMAP continues to work fine.

After the fix, `imap_login` is set to the UPN at callback time and SMTP
succeeds.

## Notes for review

- The id_token decoding path (`users_data`) already exists and is
trusted by the rest of the callback — no new attack surface.
- Scoped to Microsoft on purpose. A provider-agnostic fallback chain in
the base controller would also have worked (Google id_tokens don't carry
`preferred_username` / `upn`, so it'd land on email anyway), but keeping
Google's code path identical to today removes any risk of an unintended
interaction with whatever a future Google update might add to its
id_token. Pattern matches the existing `find_channel_by_email` override
Google already has.
- The [id_token claims
reference](https://learn.microsoft.com/en-us/entra/identity-platform/id-token-claims-reference)
warns that `preferred_username` is mutable and "can't be used to make
authorization decisions." That warning targets apps using the claim as a
stable cross-session identifier for app-level authz — we're not. We use
it as the SASL `user=` string for SMTP AUTH, validated by Microsoft
against the same token it just issued. The claim's mutability matters
only if a tenant admin renames a UPN between re-auths, in which case the
stored `imap_login` goes stale and SMTP 535s — but the pre-patch code
has the identical mutability characteristic on the `email` claim (rename
a mailbox's primary SMTP and you get the same stale-then-535). The patch
doesn't enlarge that failure surface; it shrinks it, because
UPN-vs-alias divergence is now handled rather than always-broken.
- Related but out of scope: SMTP failures don't trigger
`channel.authorization_error!` today. `ExceptionList::SMTP_EXCEPTIONS`
(`lib/exception_list.rb`) only contains `Net::SMTPSyntaxError`;
`Net::SMTPAuthenticationError` bubbles unhandled, and
`ApplicationMailer#handle_smtp_exceptions` only logs. So a 535 — whether
from this bug or any other identity drift — never prompts re-auth in the
UI, unlike the IMAP path (`fetch_imap_emails_job` catches
`OAuth2::Error` and calls `authorization_error!`). Worth a separate PR;
flagging here so we don't pretend stale-identity SMTP errors are
self-healing.
- The alternative I considered — decoding the access token's `upn`
directly — is more correct in principle but relies on Microsoft access
tokens being JWTs, which is undocumented behaviour for the
`outlook.office.com` audience and contradicts the [docs
guidance](https://learn.microsoft.com/en-us/entra/identity-platform/access-tokens)
that access tokens are opaque to clients.
- **Not addressed here, flagging for follow-up:** existing channels that
were re-authenticated before this fix still hold the alias in
`imap_login` and will keep 535ing until the user re-auths again. A
one-shot rake task could decode the stored access tokens and reconcile,
but fixing forward via natural re-auth is lower-risk; let me know if you
want the backfill.
- Also out of scope: `app/mailers/conversation_reply_mailer_helper.rb`
reads `provider_config['access_token']` raw without going through
`Microsoft::RefreshOauthTokenService`, while the IMAP path refreshes.
That's a real asymmetry but unrelated to this bug (the token here was
minutes-old) and worth its own PR.
2026-06-02 13:26:30 +05:30
1f6203d558 feat(onboarding): honor return_to hint in TikTok OAuth callback (#14569)
When connecting a TikTok inbox during onboarding, the OAuth flow used to
drop users in inbox settings, breaking onboarding. The OAuth start
endpoint now accepts an optional `return_to=onboarding` hint, carried
tamper-proof inside the signed `state` (a claim on TikTok's signed JWT),
and the callback uses it to return the user to the onboarding
inbox-setup screen. Without the hint, behavior is unchanged.

This is the backend half only; the frontend that sends
`return_to=onboarding` ships separately.

## What changed
- `Tiktok::IntegrationHelper`: the signed JWT carries an optional
`return_to` claim, added only when present (a request without it is
byte-identical to before); added `tiktok_token_return_to` to read it;
`decode_token` now returns the full payload and `verify_tiktok_token`
derives the account id from it.
- `Tiktok::AuthorizationsController#create` passes `params[:return_to]`
into the token.
- `Tiktok::CallbacksController` redirects to the onboarding inbox-setup
screen when `return_to == 'onboarding'`, before the normal
settings/agents redirect.
- Added the `app_onboarding_inbox_setup` route (shared with the sibling
Gmail/Outlook and Instagram PRs — keep a single copy on merge to avoid a
duplicate route name).

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-06-02 13:24:46 +05:30
3eed8905cc fix(facebook): render shared links as fallback attachments (#14554)
Fixes Facebook fallback and shared-post attachments so they render as
clickable links in conversations.

Closes:
- https://github.com/chatwoot/chatwoot/issues/4767
- https://github.com/chatwoot/chatwoot/issues/5327

Why:
Facebook can send shared links as `fallback` attachments with a
top-level `url`, and shared posts as `share` attachments with the URL
under `payload.url`. The current flow either misses the nested URL or
treats `share` as downloadable media, so these messages do not render
correctly.

What changed:
- Store Facebook fallback URLs from either `attachment.url` or
`attachment.payload.url`.
- Treat Facebook `share` attachments as fallback link attachments
instead of downloading them as files.
- Render fallback attachments in the next message bubble UI as clickable
links.

How to test:
1. Connect a Facebook inbox.
2. Send a shared link to the page.
3. Send/share a Facebook post to the page.
4. Open the conversation in Chatwoot.
5. Confirm both messages appear as clickable link bubbles.

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-06-01 17:08:00 +05:30
Sojan JoseandGitHub f27bbef73b feat: show processing status for one-off campaigns (#14592)
## Summary

One-off SMS and WhatsApp campaigns now show a `Processing` state while
the audience send is in progress. The campaign moves to `Completed`
after processing finishes, and already-processing campaigns are skipped
by the scheduler to avoid duplicate sends.

## Closes

- [CW-6037: feat: Introduce an in-progress status for
campaigns](https://linear.app/chatwoot/issue/CW-6037/feat-introduce-an-in-progress-status-for-campaigns)

## Screenshot

SMS campaign card showing the new `Processing` status.

<img width="3840" height="2160" alt="framed-campaign-processing-status"
src="https://github.com/user-attachments/assets/de7913b5-65fb-4121-9034-24a568eb0382"
/>

## What changed

- Added `processing` as a campaign status.
- Mark one-off campaigns as `processing` under a row lock before the
send service runs.
- Complete SMS, Twilio SMS, and WhatsApp one-off campaigns after
audience processing finishes.
- Keep campaigns in `processing` if an unexpected service error escapes,
so the scheduler does not automatically resend the audience.
- Added the `Processing` label for SMS and WhatsApp campaign cards.

## Known operational behavior

If a worker is interrupted or an unexpected service error escapes after
a campaign is marked `processing`, the campaign can remain in
`processing`. This is intentional for now to avoid automatic
full-audience resends. Installation admins can decide whether to mark
the campaign completed or restart it manually from the Rails console
after checking what was sent.

## How to test

- Create a one-off SMS or WhatsApp campaign scheduled for now.
- Run the scheduled job or trigger the campaign job.
- Confirm the campaign card shows `Processing` while the audience is
being processed. For small audiences, refresh during processing or use a
larger audience so the state is observable.
- Confirm the campaign moves to `Completed` after audience processing
finishes.
- Confirm an already-processing campaign is not enqueued again by the
scheduled job.
2026-06-01 16:47:17 +05:30
ed3059c1aa docs: Document API inbox webhook URL (#14593)
Documents the existing API inbox webhook_url request field and expands
inbox create/update request docs with channel-specific schemas and
examples.

Closes #14591

## Why
API inboxes already accept channel.webhook_url through Channel::Api
editable attributes, but the OpenAPI request schemas did not document
the field. The previous mixed channel object also made it hard to see
which fields belong to each supported channel type.

## What changed
- Added named channel payload schemas for supported inbox create channel
types.
- Added channel-specific create request samples in this order: Website
inbox, API channel, Email channel, LINE channel, Telegram channel,
WhatsApp channel, SMS channel.
- Added common inbox fields such as greeting_enabled, greeting_message,
enable_auto_assignment, working_hours_enabled, and timezone to request
samples.
- Kept website-only flags such as allow_messages_after_resolved and
enable_email_collect only in website inbox samples.
- Annotated inbox request field descriptions with channel availability
where Swagger UI cannot dynamically filter fields.
- Added channel-specific update settings schemas and request samples for
editable channel attributes.
- Documented channel.webhook_url for API inbox create/update payloads.
- Rebuilt generated Swagger JSON and tag-group specs.

## Screenshots

**Website inbox request sample**

Shows the request sample selector set to Website inbox, including
website-only fields such as enable_email_collect and
allow_messages_after_resolved.

<img width="3840" height="2160" alt="Website inbox request sample"
src="https://github.com/user-attachments/assets/ad130f04-b336-4cc3-aba1-e934b646d447"
/>

**API channel request sample**

Shows the selector switched to API channel, with API-specific fields
such as webhook_url, hmac_mandatory, and additional_attributes.

<img width="3840" height="2160" alt="API channel request sample"
src="https://github.com/user-attachments/assets/09484816-1d47-490f-9ab4-195835ed5cd3"
/>

**Expanded channel schema**

Shows the request-body schema with channel expanded, including the type
selector and channel-specific fields under it.

<img width="3840" height="2160" alt="Expanded channel schema"
src="https://github.com/user-attachments/assets/f6c4878e-ac34-40fe-8be3-5913f27cbdd8"
/>

## Validation
- bundle exec rake swagger:build
- bundle exec rspec spec/swagger/openapi_spec.rb
- Rendered the local Swagger page and verified the request sample
dropdown order and API channel payload.

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-06-01 12:37:07 +04:00
Sojan JoseandGitHub 9d591b8f46 feat: enable companies for Business cloud plans (#14598)
Enable the Companies feature automatically for Chatwoot Cloud accounts
on the Business plan and higher.

## Closes
None.

## How to test
Upgrade or reconcile a Cloud account on Business or Enterprise and
verify Companies is available. Reconcile a Startups account and verify
Companies remains disabled.

## What changed
- Added companies to the Business plan feature set, which Enterprise
inherits through the existing hierarchy.
- Added billing specs that assert Companies is disabled for Startups and
enabled for Business and Enterprise.
2026-06-01 14:02:03 +05:30
Sojan JoseandGitHub 1afcd36dee fix(contacts): align contact export permissions (#14601)
Allows contact managers to export and import contacts from the Contacts
page while keeping plain agents blocked. The contacts action menu now
mirrors backend permissions for both export and import.

## Closes

- https://linear.app/chatwoot/issue/CW-4438/contact-export-is-broken

## What changed

- Allows Enterprise custom roles with `contact_manage` to pass
`ContactPolicy#export?` and `ContactPolicy#import?`.
- Shows Export and Import to admins and contact managers only.
- Adds Enterprise policy coverage for contact export and import.

## Screenshots

Admin: Export and Import are available.

<img width="3840" height="2160" alt="Admin contact actions with Export
and Import visible"
src="https://github.com/user-attachments/assets/2b2cdaf2-ca8f-470d-be34-31cba68b9dce"
/>

Contact manager: Export and Import are available.

<img width="3840" height="2160" alt="Contact manager contact actions
with Export and Import visible"
src="https://github.com/user-attachments/assets/48fc038b-2e78-4d0c-ba17-a5965641bd88"
/>

Regular agent: Export and Import are hidden.

<img width="3840" height="2160" alt="Regular agent contact actions with
Export and Import hidden"
src="https://github.com/user-attachments/assets/a63b5731-743a-4223-8dab-ce58383067fe"
/>

## How to test

- Sign in as an administrator and open Contacts; the action menu shows
Export and Import.
- Sign in as a custom-role user with `contact_manage`; the action menu
shows Export and Import.
- Sign in as a plain agent; Export and Import are not available and both
APIs remain unauthorized.
2026-06-01 13:58:57 +05:30
Shivam MishraandGitHub a3ffb48a47 refactor(onboarding): use separate onboarding controller (#14507)
Depends on: https://github.com/chatwoot/chatwoot/pull/14370

This PR creates a new onboarding controller, this allows more control
that the default account update API. Allowing us to spin tasks and
update details required specifically during the onboarding flow
2026-06-01 13:34:11 +05:30
128 changed files with 8768 additions and 1196 deletions
+5 -3
View File
@@ -996,11 +996,13 @@ GEM
activemodel (>= 3.2)
mail (~> 2.5)
version_gem (1.1.4)
vite_rails (3.0.17)
railties (>= 5.1, < 8)
vite_rails (3.10.0)
railties (>= 5.1, < 9)
vite_ruby (~> 3.0, >= 3.2.2)
vite_ruby (3.8.0)
vite_ruby (3.10.2)
dry-cli (>= 0.7, < 2)
logger (~> 1.6)
mutex_m
rack-proxy (~> 0.6, >= 0.6.1)
zeitwerk (~> 2.2)
warden (1.2.9)
@@ -92,10 +92,18 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
def fallback_params(attachment)
{
fallback_title: attachment['title'],
external_url: attachment['url']
external_url: attachment['url'] || attachment.dig('payload', 'url')
}
end
# Facebook shared posts point to page URLs, not downloadable media URLs.
# Keep this Facebook-only so Messenger/Instagram share attachments still use the parent media handling.
def normalize_file_type(type)
return :fallback if type.to_sym == :share
super
end
def conversation_params
{
account_id: @inbox.account_id,
+17 -7
View File
@@ -13,6 +13,7 @@ class Messages::MessageBuilder
@account = conversation.account
@message_type = params[:message_type] || 'outgoing'
@attachments = params[:attachments]
@is_voice_message = ActiveModel::Type::Boolean.new.cast(params[:is_voice_message])
@automation_rule = content_attributes&.dig(:automation_rule_id)
return unless params.instance_of?(ActionController::Parameters)
@@ -56,16 +57,25 @@ class Messages::MessageBuilder
file: uploaded_attachment
)
attachment.file_type = if uploaded_attachment.is_a?(String)
file_type_by_signed_id(
uploaded_attachment
)
else
file_type(uploaded_attachment&.content_type)
end
attachment.file_type = attachment_file_type(uploaded_attachment)
tag_voice_message(attachment)
end
end
def attachment_file_type(uploaded_attachment)
if uploaded_attachment.is_a?(String)
file_type_by_signed_id(uploaded_attachment)
else
file_type(uploaded_attachment&.content_type)
end
end
def tag_voice_message(attachment)
return unless @is_voice_message && attachment.file_type == 'audio'
attachment.meta = (attachment.meta || {}).merge('is_voice_message' => true)
end
def process_emails
return unless @conversation.inbox&.inbox_type == 'Email'
@@ -1,16 +1,6 @@
class Api::V1::Accounts::Conversations::UnreadCountsController < Api::V1::Accounts::BaseController
before_action :ensure_unread_counts_enabled
def index
counts = ::Conversations::UnreadCounts::Counter.new(account: Current.account, user: Current.user).perform
render json: { payload: counts }
end
private
def ensure_unread_counts_enabled
return if Current.account.feature_enabled?('conversation_unread_counts')
render json: { error: I18n.t('errors.conversations.unread_counts.feature_not_enabled') }, status: :forbidden
end
end
@@ -11,7 +11,7 @@ class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts
enable_fb_login: '0',
force_authentication: '1',
response_type: 'code',
state: generate_instagram_token(Current.account.id)
state: generate_instagram_token(Current.account.id, params[:return_to])
}
)
if redirect_url
@@ -6,7 +6,8 @@ class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts
{
redirect_uri: "#{base_url}/microsoft/callback",
scope: scope,
state: state
state: state,
prompt: 'consent'
}
)
if redirect_url
@@ -8,7 +8,15 @@ class Api::V1::Accounts::OauthAuthorizationController < Api::V1::Accounts::BaseC
end
def state
Current.account.to_sgid(expires_in: 15.minutes).to_s
# The sgid purpose doubles as a return hint: onboarding tags it so the callback
# can route the user back to inbox setup. The purpose is part of the signed
# payload (tamper-proof), and a non-onboarding request keeps the default
# purpose, leaving callers like Notion byte-identical.
Current.account.to_sgid(expires_in: 15.minutes, for: state_purpose).to_s
end
def state_purpose
params[:return_to] == 'onboarding' ? 'onboarding' : 'default'
end
def base_url
@@ -0,0 +1,36 @@
class Api::V1::Accounts::OnboardingsController < Api::V1::Accounts::BaseController
before_action :check_admin_authorization?
def update
@account = Current.account
finalize = finalizing_account_details?
@account.assign_attributes(account_params)
@account.custom_attributes.merge!(custom_attributes_params)
@account.custom_attributes.delete('onboarding_step') if finalize
@account.save!
# TODO: re-enable when the help center generation UI is ready to surface progress
# Onboarding::HelpCenterCreationService.new(@account, Current.user).perform if finalize && website.present?
render 'api/v1/accounts/update', format: :json
end
private
def finalizing_account_details?
@account.custom_attributes['onboarding_step'] == 'account_details'
end
def website
custom_attributes_params[:website]
end
def account_params
params.permit(:name, :locale)
end
def custom_attributes_params
params.permit(:industry, :company_size, :timezone, :referral_source, :user_role, :website)
end
end
@@ -3,7 +3,7 @@ class Api::V1::Accounts::Tiktok::AuthorizationsController < Api::V1::Accounts::O
def create
redirect_url = Tiktok::AuthClient.authorize_url(
state: generate_tiktok_token(Current.account.id)
state: generate_tiktok_token(Current.account.id, params[:return_to])
)
if redirect_url
@@ -58,7 +58,6 @@ class Api::V1::AccountsController < Api::BaseController
@account.assign_attributes(account_params.slice(:name, :locale, :domain, :support_email))
@account.custom_attributes.merge!(custom_attributes_params)
@account.settings.merge!(settings_params)
@account.custom_attributes.delete('onboarding_step') if @account.custom_attributes['onboarding_step'] == 'account_details'
@account.custom_attributes['onboarding_step'] = 'invite_team' if @account.custom_attributes['onboarding_step'] == 'account_update'
@account.save!
end
@@ -28,6 +28,8 @@ class Instagram::CallbacksController < ApplicationController
@long_lived_token_response = exchange_for_long_lived_token(@response.token)
inbox, already_exists = find_or_create_inbox
return redirect_to app_onboarding_inbox_setup_url(account_id: account_id) if return_to == 'onboarding'
if already_exists
redirect_to app_instagram_inbox_settings_url(account_id: account_id, inbox_id: inbox.id)
else
@@ -149,6 +151,10 @@ class Instagram::CallbacksController < ApplicationController
verify_instagram_token(params[:state])
end
def return_to
instagram_token_return_to(params[:state])
end
def oauth_code
params[:code]
end
@@ -14,4 +14,11 @@ class Microsoft::CallbacksController < OauthCallbackController
def imap_address
'outlook.office365.com'
end
# Exchange Online's SMTP AUTH (XOAUTH2) rejects proxy addresses in the SASL `user=` field;
# it must match the token's UPN. `preferred_username` is the documented v2.0 claim;
# `upn` is the v1.0 fallback.
def imap_login_identity
users_data['preferred_username'] || users_data['upn'] || super
end
end
+25 -2
View File
@@ -16,6 +16,8 @@ class OauthCallbackController < ApplicationController
def handle_response
inbox, already_exists = find_or_create_inbox
return redirect_to app_onboarding_inbox_setup_url(account_id: account.id) if return_to == 'onboarding'
if already_exists
redirect_to app_email_inbox_settings_url(account_id: account.id, inbox_id: inbox.id)
else
@@ -44,7 +46,7 @@ class OauthCallbackController < ApplicationController
def update_channel(channel_email)
channel_email.update!({
imap_login: users_data['email'], imap_address: imap_address,
imap_login: imap_login_identity, imap_address: imap_address,
imap_port: '993', imap_enabled: true,
provider: provider_name,
provider_config: {
@@ -55,6 +57,13 @@ class OauthCallbackController < ApplicationController
})
end
# Identity used as the IMAP/SMTP login (SASL XOAUTH2 `user=` field). Defaults to the
# id_token's email claim; providers override when their server requires a different
# claim (e.g. Microsoft SMTP requires UPN).
def imap_login_identity
users_data['email']
end
def provider_name
raise NotImplementedError
end
@@ -81,10 +90,19 @@ class OauthCallbackController < ApplicationController
decoded_token[0]
end
# The sgid purpose carries the onboarding return hint (see
# OauthAuthorizationController#state). Try the onboarding purpose first — a match
# both resolves the account and records the return target — then fall back to the
# default purpose used by every other caller.
def account_from_signed_id
raise ActionController::BadRequest, 'Missing state variable' if params[:state].blank?
account = GlobalID::Locator.locate_signed(params[:state])
if (account = GlobalID::Locator.locate_signed(params[:state], for: 'onboarding'))
@return_to = 'onboarding'
else
account = GlobalID::Locator.locate_signed(params[:state])
end
raise 'Invalid or expired state' if account.nil?
account
@@ -94,6 +112,11 @@ class OauthCallbackController < ApplicationController
@account ||= account_from_signed_id
end
def return_to
account # resolving the sgid records which purpose matched
@return_to
end
# Fallback name, for when name field is missing from users_data
def fallback_name
users_data['email'].split('@').first.parameterize.titleize
@@ -0,0 +1,31 @@
class Public::Api::V1::Portals::SearchController < Public::Api::V1::Portals::BaseController
before_action :ensure_custom_domain_request, only: [:index]
before_action :portal
before_action :set_portal_layout
before_action :set_view_variant
before_action :ensure_portal_feature_enabled
layout 'portal'
def index
@query = params[:query].to_s.strip
@articles = @portal.articles.published.includes(:category).where(locale: params[:locale])
search_articles
@articles = @articles.page(params[:page]).per(10)
end
private
def search_articles
@articles = @query.present? ? @articles.search(search_params) : @articles.none
end
def search_params
params.permit(:query, :locale, :sort, :status, :page).tap do |permitted|
permitted[:query] = @query
end
end
end
Public::Api::V1::Portals::SearchController.prepend_mod_with('Public::Api::V1::Portals::SearchController')
@@ -20,6 +20,8 @@ class Tiktok::CallbacksController < ApplicationController
def process_successful_authorization
inbox, already_exists = find_or_create_inbox
return redirect_to app_onboarding_inbox_setup_url(account_id: account_id) if return_to == 'onboarding'
if already_exists
redirect_to app_tiktok_inbox_settings_url(account_id: account_id, inbox_id: inbox.id)
else
@@ -127,6 +129,10 @@ class Tiktok::CallbacksController < ApplicationController
@account_id ||= verify_tiktok_token(params[:state])
end
def return_to
tiktok_token_return_to(params[:state])
end
def account
@account ||= Account.find(account_id)
end
+16 -9
View File
@@ -4,21 +4,21 @@ module Instagram::IntegrationHelper
# Generates a signed JWT token for Instagram integration
#
# @param account_id [Integer] The account ID to encode in the token
# @param return_to [String, nil] Optional onboarding return hint
# @return [String, nil] The encoded JWT token or nil if client secret is missing
def generate_instagram_token(account_id)
def generate_instagram_token(account_id, return_to = nil)
return if client_secret.blank?
JWT.encode(token_payload(account_id), client_secret, 'HS256')
JWT.encode(token_payload(account_id, return_to), client_secret, 'HS256')
rescue StandardError => e
Rails.logger.error("Failed to generate Instagram token: #{e.message}")
nil
end
def token_payload(account_id)
{
sub: account_id,
iat: Time.current.to_i
}
def token_payload(account_id, return_to = nil)
payload = { sub: account_id, iat: Time.current.to_i }
payload[:return_to] = return_to if return_to.present?
payload
end
# Verifies and decodes a Instagram JWT token
@@ -28,7 +28,14 @@ module Instagram::IntegrationHelper
def verify_instagram_token(token)
return if token.blank? || client_secret.blank?
decode_token(token, client_secret)
decode_token(token, client_secret)&.dig('sub')
end
# Reads the onboarding return hint from a Instagram JWT token, if present.
def instagram_token_return_to(token)
return if token.blank? || client_secret.blank?
decode_token(token, client_secret)&.dig('return_to')
end
private
@@ -41,7 +48,7 @@ module Instagram::IntegrationHelper
JWT.decode(token, secret, true, {
algorithm: 'HS256',
verify_expiration: true
}).first['sub']
}).first
rescue StandardError => e
Rails.logger.error("Unexpected error verifying Instagram token: #{e.message}")
nil
+10 -3
View File
@@ -45,9 +45,16 @@ module PortalHelper
theme.present? && theme != 'system' ? "?theme=#{theme}" : ''
end
def portal_query_string(theme, is_plain_layout_enabled)
query_params = {}
query_params[:theme] = theme if theme.present? && theme != 'system'
query_params[:show_plain_layout] = true if is_plain_layout_enabled
query_params.present? ? "?#{query_params.to_query}" : ''
end
def generate_home_link(portal_slug, portal_locale, theme, is_plain_layout_enabled)
if is_plain_layout_enabled
"/hc/#{portal_slug}/#{portal_locale}#{theme_query_string(theme)}"
"/hc/#{portal_slug}/#{portal_locale}#{portal_query_string(theme, is_plain_layout_enabled)}"
else
"/hc/#{portal_slug}/#{portal_locale}"
end
@@ -61,7 +68,7 @@ module PortalHelper
is_plain_layout_enabled = params[:is_plain_layout_enabled]
if is_plain_layout_enabled
"/hc/#{portal_slug}/#{category_locale}/categories/#{category_slug}#{theme_query_string(theme)}"
"/hc/#{portal_slug}/#{category_locale}/categories/#{category_slug}#{portal_query_string(theme, is_plain_layout_enabled)}"
else
"/hc/#{portal_slug}/#{category_locale}/categories/#{category_slug}"
end
@@ -69,7 +76,7 @@ module PortalHelper
def generate_article_link(portal_slug, article_slug, theme, is_plain_layout_enabled)
if is_plain_layout_enabled
"/hc/#{portal_slug}/articles/#{article_slug}#{theme_query_string(theme)}"
"/hc/#{portal_slug}/articles/#{article_slug}#{portal_query_string(theme, is_plain_layout_enabled)}"
else
"/hc/#{portal_slug}/articles/#{article_slug}"
end
+16 -9
View File
@@ -2,11 +2,12 @@ module Tiktok::IntegrationHelper
# Generates a signed JWT token for Tiktok integration
#
# @param account_id [Integer] The account ID to encode in the token
# @param return_to [String, nil] Optional onboarding return hint
# @return [String, nil] The encoded JWT token or nil if client secret is missing
def generate_tiktok_token(account_id)
def generate_tiktok_token(account_id, return_to = nil)
return if client_secret.blank?
JWT.encode(token_payload(account_id), client_secret, 'HS256')
JWT.encode(token_payload(account_id, return_to), client_secret, 'HS256')
rescue StandardError => e
Rails.logger.error("Failed to generate TikTok token: #{e.message}")
nil
@@ -19,7 +20,14 @@ module Tiktok::IntegrationHelper
def verify_tiktok_token(token)
return if token.blank? || client_secret.blank?
decode_token(token, client_secret)
decode_token(token, client_secret)&.dig('sub')
end
# Reads the onboarding return hint from a Tiktok JWT token, if present.
def tiktok_token_return_to(token)
return if token.blank? || client_secret.blank?
decode_token(token, client_secret)&.dig('return_to')
end
private
@@ -28,18 +36,17 @@ module Tiktok::IntegrationHelper
@client_secret ||= GlobalConfigService.load('TIKTOK_APP_SECRET', nil)
end
def token_payload(account_id)
{
sub: account_id,
iat: Time.current.to_i
}
def token_payload(account_id, return_to = nil)
payload = { sub: account_id, iat: Time.current.to_i }
payload[:return_to] = return_to if return_to.present?
payload
end
def decode_token(token, secret)
JWT.decode(token, secret, true, {
algorithm: 'HS256',
verify_expiration: true
}).first['sub']
}).first
rescue StandardError => e
Rails.logger.error("Unexpected error verifying Tiktok token: #{e.message}")
nil
@@ -12,6 +12,7 @@ export const buildCreatePayload = ({
bccEmails = '',
toEmails = '',
templateParams,
isVoiceMessage = false,
}) => {
let payload;
if (files && files.length !== 0) {
@@ -33,6 +34,9 @@ export const buildCreatePayload = ({
if (contentAttributes) {
payload.append('content_attributes', JSON.stringify(contentAttributes));
}
if (isVoiceMessage) {
payload.append('is_voice_message', true);
}
} else {
payload = {
content: message,
@@ -64,6 +68,7 @@ class MessageApi extends ApiClient {
bccEmails = '',
toEmails = '',
templateParams,
isVoiceMessage = false,
}) {
return axios({
method: 'post',
@@ -78,6 +83,7 @@ class MessageApi extends ApiClient {
bccEmails,
toEmails,
templateParams,
isVoiceMessage,
}),
});
}
@@ -0,0 +1,14 @@
/* global axios */
import ApiClient from './ApiClient';
class OnboardingAPI extends ApiClient {
constructor() {
super('onboarding', { accountScoped: true });
}
update(data) {
return axios.patch(this.url, data);
}
}
export default new OnboardingAPI();
@@ -83,5 +83,29 @@ describe('#ConversationAPI', () => {
template_params: undefined,
});
});
it('appends is_voice_message when isVoiceMessage is true', () => {
const formPayload = buildCreatePayload({
message: 'voice message',
echoId: 42,
isPrivate: false,
files: [new Blob(['audio-data'], { type: 'audio/ogg' })],
isVoiceMessage: true,
});
expect(formPayload).toBeInstanceOf(FormData);
expect(formPayload.get('is_voice_message')).toEqual('true');
});
it('does not append is_voice_message when isVoiceMessage is false', () => {
const formPayload = buildCreatePayload({
message: 'regular audio',
echoId: 43,
isPrivate: false,
files: [new Blob(['audio-data'], { type: 'audio/ogg' })],
isVoiceMessage: false,
});
expect(formPayload).toBeInstanceOf(FormData);
expect(formPayload.get('is_voice_message')).toBeNull();
});
});
});
@@ -49,6 +49,7 @@ const emit = defineEmits(['edit', 'delete']);
const { t } = useI18n();
const STATUS_COMPLETED = 'completed';
const STATUS_PROCESSING = 'processing';
const { formatMessage } = useMessageFormatter();
@@ -68,9 +69,15 @@ const campaignStatus = computed(() => {
: t('CAMPAIGN.LIVE_CHAT.CARD.STATUS.DISABLED');
}
return props.status === STATUS_COMPLETED
? t('CAMPAIGN.SMS.CARD.STATUS.COMPLETED')
: t('CAMPAIGN.SMS.CARD.STATUS.SCHEDULED');
if (props.status === STATUS_COMPLETED) {
return t('CAMPAIGN.SMS.CARD.STATUS.COMPLETED');
}
if (props.status === STATUS_PROCESSING) {
return t('CAMPAIGN.SMS.CARD.STATUS.PROCESSING');
}
return t('CAMPAIGN.SMS.CARD.STATUS.SCHEDULED');
});
const inboxName = computed(() => props.inbox?.name || '');
@@ -1,34 +1,48 @@
<script setup>
import { ref } from 'vue';
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import { usePolicy } from 'dashboard/composables/usePolicy';
const emit = defineEmits(['add', 'import', 'export']);
const { t } = useI18n();
const { checkPermissions } = usePolicy();
const contactMenuItems = [
const contactMenuItems = computed(() => [
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.ADD_CONTACT'),
action: 'add',
value: 'add',
icon: 'i-lucide-plus',
},
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.EXPORT_CONTACT'),
action: 'export',
value: 'export',
icon: 'i-lucide-upload',
},
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.IMPORT_CONTACT'),
action: 'import',
value: 'import',
icon: 'i-lucide-download',
},
];
...(checkPermissions(['administrator', 'contact_manage'])
? [
{
label: t(
'CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.EXPORT_CONTACT'
),
action: 'export',
value: 'export',
icon: 'i-lucide-upload',
},
]
: []),
...(checkPermissions(['administrator', 'contact_manage'])
? [
{
label: t(
'CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.IMPORT_CONTACT'
),
action: 'import',
value: 'import',
icon: 'i-lucide-download',
},
]
: []),
]);
const showActionsDropdown = ref(false);
const handleContactAction = ({ action }) => {
@@ -31,6 +31,7 @@ import FileBubble from './bubbles/File.vue';
import AudioBubble from './bubbles/Audio.vue';
import VideoBubble from './bubbles/Video.vue';
import EmbedBubble from './bubbles/Embed.vue';
import FallbackBubble from './bubbles/Fallback.vue';
import InstagramStoryBubble from './bubbles/InstagramStory.vue';
import EmailBubble from './bubbles/Email/Index.vue';
import UnsupportedBubble from './bubbles/Unsupported.vue';
@@ -328,6 +329,8 @@ const componentToRender = computed(() => {
if (Array.isArray(props.attachments) && props.attachments.length === 1) {
const fileType = props.attachments[0].fileType;
if (fileType === ATTACHMENT_TYPES.FALLBACK) return FallbackBubble;
if (!props.content) {
if (fileType === ATTACHMENT_TYPES.IMAGE) return ImageBubble;
if (fileType === ATTACHMENT_TYPES.FILE) return FileBubble;
@@ -0,0 +1,37 @@
<script setup>
import { computed } from 'vue';
import BaseBubble from './Base.vue';
import FormattedContent from './Text/FormattedContent.vue';
import { useMessageContext } from '../provider.js';
const { attachments, content } = useMessageContext();
const attachment = computed(() => attachments.value?.[0] || {});
const url = computed(
() => attachment.value.dataUrl || attachment.value.data_url
);
const title = computed(
() =>
attachment.value.fallbackTitle ||
attachment.value.fallback_title ||
url.value
);
</script>
<template>
<BaseBubble class="p-3" data-bubble-name="fallback">
<FormattedContent v-if="content" :content="content" class="mb-2" />
<a
v-if="url"
:href="url"
target="_blank"
rel="noopener noreferrer"
class="block max-w-[320px] truncate text-sm text-n-brand underline"
>
{{ title }}
</a>
<span v-else class="text-sm text-n-slate-11">
{{ title }}
</span>
</BaseBubble>
</template>
@@ -61,21 +61,9 @@ const hasAdvancedAssignment = computed(() => {
);
});
const hasConversationUnreadCounts = computed(() => {
return isFeatureEnabledonAccount.value(
accountId.value,
FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS
);
});
const fetchConversationUnreadCounts = ([currentAccountId, isEnabled]) => {
const fetchConversationUnreadCounts = currentAccountId => {
if (!currentAccountId) return;
if (!isEnabled) {
store.dispatch('conversationUnreadCounts/clear');
return;
}
store.dispatch('conversationUnreadCounts/get');
};
@@ -200,7 +188,7 @@ onMounted(() => {
store.dispatch('customViews/get', 'contact');
});
watch([accountId, hasConversationUnreadCounts], fetchConversationUnreadCounts, {
watch(accountId, fetchConversationUnreadCounts, {
immediate: true,
});
@@ -14,11 +14,11 @@ const props = defineProps({
const emit = defineEmits(['removeAttachment']);
const nonRecordedAudioAttachments = computed(() => {
return props.attachments.filter(attachment => !attachment?.isRecordedAudio);
return props.attachments.filter(attachment => !attachment?.isVoiceMessage);
});
const recordedAudioAttachments = computed(() =>
props.attachments.filter(attachment => attachment.isRecordedAudio)
props.attachments.filter(attachment => attachment.isVoiceMessage)
);
const onRemoveAttachment = itemIndex => {
@@ -4,7 +4,7 @@ import { ref, onMounted, onUnmounted } from 'vue';
import WaveSurfer from 'wavesurfer.js';
import RecordPlugin from 'wavesurfer.js/dist/plugins/record.js';
import { format, intervalToDuration } from 'date-fns';
import { convertAudio } from './utils/mp3ConversionUtils';
import { convertAudio } from './utils/audioConversionUtils';
const props = defineProps({
audioRecordFormat: {
@@ -18,6 +18,7 @@ const emit = defineEmits([
'finishRecord',
'pause',
'play',
'recordError',
]);
const waveformContainer = ref(null);
@@ -26,6 +27,7 @@ const record = ref(null);
const isRecording = ref(false);
const isPlaying = ref(false);
const hasRecording = ref(false);
const recordedAudioUrl = ref(null);
const formatTimeProgress = time => {
const duration = intervalToDuration({ start: 0, end: time });
@@ -35,6 +37,28 @@ const formatTimeProgress = time => {
);
};
const AUDIO_EXTENSION_MAP = {
'audio/ogg': 'ogg',
'audio/mp3': 'mp3',
'audio/mpeg': 'mp3',
'audio/wav': 'wav',
'audio/webm': 'webm',
};
const getRecordPluginOptions = audioFormat => {
const options = {
scrollingWaveform: true,
renderRecordedAudio: false,
};
if (
audioFormat === 'audio/ogg' &&
MediaRecorder.isTypeSupported('audio/ogg;codecs=opus')
) {
options.mimeType = 'audio/ogg;codecs=opus';
}
return options;
};
const initWaveSurfer = () => {
wavesurfer.value = WaveSurfer.create({
container: waveformContainer.value,
@@ -45,10 +69,7 @@ const initWaveSurfer = () => {
barGap: 1,
barRadius: 2,
plugins: [
RecordPlugin.create({
scrollingWaveform: true,
renderRecordedAudio: false,
}),
RecordPlugin.create(getRecordPluginOptions(props.audioRecordFormat)),
],
});
@@ -62,21 +83,34 @@ const initWaveSurfer = () => {
});
record.value.on('record-end', async blob => {
const audioUrl = URL.createObjectURL(blob);
const audioBlob = await convertAudio(blob, props.audioRecordFormat);
const fileName = `${getUuid()}.mp3`;
const file = new File([audioBlob], fileName, {
type: props.audioRecordFormat,
});
wavesurfer.value.load(audioUrl);
emit('finishRecord', {
name: file.name,
type: file.type,
size: file.size,
file,
});
hasRecording.value = true;
isRecording.value = false;
try {
const audioBlob = await convertAudio(blob, props.audioRecordFormat);
// Use the converted blob's actual type, which may differ from the
// requested format when the browser can't produce it (e.g. Safari falls
// back to MP3 instead of OGG). This keeps the filename, content type, and
// voice-note flag consistent with the real bytes.
const audioType = audioBlob.type || props.audioRecordFormat;
const ext = AUDIO_EXTENSION_MAP[audioType] || 'mp3';
const fileName = `${getUuid()}.${ext}`;
const file = new File([audioBlob], fileName, {
type: audioType,
});
if (recordedAudioUrl.value) URL.revokeObjectURL(recordedAudioUrl.value);
recordedAudioUrl.value = URL.createObjectURL(audioBlob);
wavesurfer.value.load(recordedAudioUrl.value);
emit('finishRecord', {
name: file.name,
type: file.type,
size: file.size,
file,
});
hasRecording.value = true;
isRecording.value = false;
} catch (error) {
isRecording.value = false;
hasRecording.value = false;
emit('recordError', { error });
}
});
record.value.on('record-progress', time => {
@@ -109,6 +143,10 @@ onMounted(() => {
});
onUnmounted(() => {
if (recordedAudioUrl.value) {
URL.revokeObjectURL(recordedAudioUrl.value);
recordedAudioUrl.value = null;
}
if (wavesurfer.value) {
wavesurfer.value.destroy();
}
@@ -1,5 +1,7 @@
import lamejs from '@breezystack/lamejs';
import { remuxWebmToOgg } from './webmOpusToOgg';
const writeString = (view, offset, string) => {
// eslint-disable-next-line no-plusplus
for (let i = 0; i < string.length; i++) {
@@ -139,6 +141,20 @@ export const convertAudio = async (inputBlob, outputFormat, bitrate = 128) => {
audio = await convertToWav(inputBlob);
} else if (outputFormat === 'audio/mp3') {
audio = await convertToMp3(inputBlob, bitrate);
} else if (outputFormat === 'audio/ogg') {
const inputType = inputBlob.type.split(';')[0].trim();
if (inputType === 'audio/webm' || inputType === 'video/webm') {
audio = await remuxWebmToOgg(inputBlob);
} else if (inputType === 'audio/ogg') {
audio = inputBlob;
} else {
// Browsers that record neither WebM nor OGG (e.g. Safari records
// audio/mp4) cannot produce OGG/Opus. Fall back to MP3 so the recording
// still sends as a regular audio message instead of failing. The caller
// keys the voice-note flag off the returned blob type, so an MP3 result
// is never mislabeled as an OGG/Opus voice note.
audio = await convertToMp3(inputBlob, bitrate);
}
} else {
throw new Error('Unsupported output format');
}
@@ -0,0 +1,457 @@
/* eslint-disable no-bitwise */
/**
* WebM/Opus → OGG/Opus remuxer
*
* Chrome's MediaRecorder produces WebM containers even when
* `audio/ogg;codecs=opus` is requested. WhatsApp Cloud API requires
* proper OGG/Opus files for voice messages.
*
* This module extracts raw Opus packets from the WebM (EBML) container
* and repackages them into a valid OGG bitstream. The audio data itself
* is never re-encoded — only the container format changes.
*
* References:
* EBML (container for WebM): RFC 8794 — https://www.rfc-editor.org/rfc/rfc8794
* Matroska/WebM elements: https://www.matroska.org/technical/elements.html
* OGG bitstream framing: RFC 3533 — https://www.rfc-editor.org/rfc/rfc3533
* Opus codec: RFC 6716 — https://www.rfc-editor.org/rfc/rfc6716
* Opus in OGG (OpusHead/Tags): RFC 7845 — https://www.rfc-editor.org/rfc/rfc7845
*/
// ======================== EBML / WebM parser ========================
const EBML_IDS = {
Segment: 0x18538067,
SegmentInfo: 0x1549a966,
Tracks: 0x1654ae6b,
TrackEntry: 0xae,
CodecPrivate: 0x63a2,
Audio: 0xe1,
SamplingFrequency: 0xb5,
Channels: 0x9f,
Cluster: 0x1f43b675,
Timecode: 0xe7,
SimpleBlock: 0xa3,
BlockGroup: 0xa0,
Block: 0xa1,
};
const MASTER_ELEMENTS = new Set([
0x1a45dfa3, // EBML header
EBML_IDS.Segment,
EBML_IDS.SegmentInfo,
EBML_IDS.Tracks,
EBML_IDS.TrackEntry,
EBML_IDS.Audio,
EBML_IDS.Cluster,
EBML_IDS.BlockGroup,
]);
/** Read an EBML variable-length integer (data size). */
function readVint(data, pos) {
if (pos >= data.length) return null;
const first = data[pos];
if (first === 0) return null;
let len = 1;
let mask = 0x80;
while (len <= 8 && !(first & mask)) {
len += 1;
mask >>= 1;
}
if (len > 8 || pos + len > data.length) return null;
let value = first & (mask - 1);
for (let i = 1; i < len; i += 1) {
value = value * 256 + data[pos + i];
}
return { value, length: len };
}
/** Read an EBML element ID (leading marker bits are kept). */
function readElementId(data, pos) {
if (pos >= data.length) return null;
const first = data[pos];
if (first === 0) return null;
let len = 1;
let mask = 0x80;
while (len <= 4 && !(first & mask)) {
len += 1;
mask >>= 1;
}
if (len > 4 || pos + len > data.length) return null;
let id = first;
for (let i = 1; i < len; i += 1) {
id = id * 256 + data[pos + i];
}
return { id, length: len };
}
function readUintBE(data, offset, length) {
let v = 0;
for (let i = 0; i < length; i += 1) v = v * 256 + data[offset + i];
return v;
}
function readFloatBE(data, offset, length) {
if (length !== 4 && length !== 8) return NaN;
const buf = new ArrayBuffer(length);
const u8 = new Uint8Array(buf);
for (let i = 0; i < length; i += 1) u8[i] = data[offset + i];
const view = new DataView(buf);
return length === 4 ? view.getFloat32(0) : view.getFloat64(0);
}
/** Extract the raw Opus frame from a SimpleBlock / Block element. */
function extractFrameFromBlock(data, offset, end) {
const trackVint = readVint(data, offset);
if (!trackVint) return null;
let pos = offset + trackVint.length;
// int16 relative timecode (big-endian, signed) skip
pos += 2;
// Flags byte skip. Lacing (Xiph/EBML/fixed-size) is NOT supported;
// this assumes single-frame blocks as produced by MediaRecorder.
const flags = data[pos];
const lacingBits = (flags >> 1) & 0x03;
if (lacingBits !== 0) {
// eslint-disable-next-line no-console
console.warn(
'webmOpusToOgg: laced SimpleBlock detected (unsupported), frame may be invalid'
);
}
pos += 1;
if (pos >= end) return null;
return data.slice(pos, end);
}
/**
* Walk the EBML tree and collect metadata + Opus frames.
* We only descend into master elements and only extract the fields we need.
*/
function parseWebM(buffer) {
const data = new Uint8Array(buffer);
const result = {
channels: 1,
sampleRate: 48000,
codecPrivate: null,
frames: [],
};
function walk(start, end) {
let pos = start;
while (pos < end) {
const idRes = readElementId(data, pos);
if (!idRes) break;
pos += idRes.length;
const sizeRes = readVint(data, pos);
if (!sizeRes) break;
pos += sizeRes.length;
// Handle "unknown size" (all-ones VINT) by treating it as the rest of the parent
// Use Math.pow instead of bit-shift to avoid 32-bit overflow for 5+ byte VINTs
const maxVint = 2 ** (7 * sizeRes.length) - 1;
const elEnd =
sizeRes.value === maxVint ? end : Math.min(pos + sizeRes.value, end);
if (MASTER_ELEMENTS.has(idRes.id)) {
walk(pos, elEnd);
} else {
switch (idRes.id) {
case EBML_IDS.Channels:
result.channels = readUintBE(data, pos, sizeRes.value);
break;
case EBML_IDS.SamplingFrequency:
result.sampleRate = readFloatBE(data, pos, sizeRes.value);
break;
case EBML_IDS.CodecPrivate:
result.codecPrivate = data.slice(pos, elEnd);
break;
case EBML_IDS.SimpleBlock:
case EBML_IDS.Block: {
const frame = extractFrameFromBlock(data, pos, elEnd);
if (frame && frame.length > 0) result.frames.push(frame);
break;
}
default:
break;
}
}
pos = elEnd;
}
}
walk(0, data.length);
return result;
}
// ======================== OGG writer ========================
/** OGG CRC-32 table (polynomial 0x04C11DB7). */
const CRC_TABLE = (() => {
const t = new Uint32Array(256);
for (let i = 0; i < 256; i += 1) {
let c = i << 24;
for (let j = 0; j < 8; j += 1) {
c = ((c << 1) ^ (c & 0x80000000 ? 0x04c11db7 : 0)) >>> 0;
}
t[i] = c;
}
return t;
})();
function oggCrc32(bytes) {
let crc = 0;
for (let i = 0; i < bytes.length; i += 1) {
crc = (CRC_TABLE[((crc >>> 24) ^ bytes[i]) & 0xff] ^ (crc << 8)) >>> 0;
}
return crc;
}
/**
* Build one OGG page.
*
* @param {number} headerType 0x02 = BOS, 0x04 = EOS, 0x00 = normal
* @param {number} granulePosition 48 kHz sample count
* @param {number} serialNumber logical stream id
* @param {number} pageSeq page sequence counter
* @param {Uint8Array[]} packets one or more complete Opus packets
*/
function createOggPage(
headerType,
granulePosition,
serialNumber,
pageSeq,
packets
) {
// Build the lacing / segment table
const segTable = [];
let dataLen = 0;
packets.forEach(pkt => {
let rem = pkt.length;
while (rem >= 255) {
segTable.push(255);
rem -= 255;
}
segTable.push(rem); // final segment (0 when pkt.length is a multiple of 255)
dataLen += pkt.length;
});
const hdrLen = 27 + segTable.length;
const page = new Uint8Array(hdrLen + dataLen);
const dv = new DataView(page.buffer);
// Capture pattern
page.set([0x4f, 0x67, 0x67, 0x53]); // "OggS"
page[4] = 0; // version
page[5] = headerType;
// Granule position (int64 LE)
dv.setUint32(6, granulePosition & 0xffffffff, true);
dv.setUint32(
10,
Math.floor(granulePosition / 0x100000000) & 0xffffffff,
true
);
dv.setUint32(14, serialNumber, true); // serial
dv.setUint32(18, pageSeq, true); // page sequence
dv.setUint32(22, 0, true); // CRC placeholder
page[26] = segTable.length;
for (let i = 0; i < segTable.length; i += 1) page[27 + i] = segTable[i];
let off = hdrLen;
packets.forEach(pkt => {
page.set(pkt, off);
off += pkt.length;
});
// Fill in the CRC
dv.setUint32(22, oggCrc32(page), true);
return page;
}
// ======================== Opus helpers ========================
/** Lookup table: frame duration in ms for each Opus TOC config index (0-31). */
const OPUS_FRAME_MS = [
10,
20,
40,
60, // 0-3 SILK NB
10,
20,
40,
60, // 4-7 SILK MB
10,
20,
40,
60, // 8-11 SILK WB
10,
20, // 12-13 Hybrid SWB
10,
20, // 14-15 Hybrid FB
2.5,
5,
10,
20, // 16-19 CELT NB
2.5,
5,
10,
20, // 20-23 CELT WB
2.5,
5,
10,
20, // 24-27 CELT SWB
2.5,
5,
10,
20, // 28-31 CELT FB
];
/** Return the total number of 48 kHz PCM samples represented by an Opus packet. */
function opusPacketSamples(pkt) {
if (!pkt || pkt.length === 0) return 960; // default 20 ms
const toc = pkt[0];
const config = (toc >> 3) & 0x1f;
const code = toc & 0x03;
const samplesPerFrame = ((OPUS_FRAME_MS[config] || 20) * 48000) / 1000;
let frameCount;
if (code <= 1) frameCount = code + 1;
else if (code === 2) frameCount = 2;
else frameCount = pkt.length >= 2 ? pkt[1] & 0x3f : 1;
return samplesPerFrame * frameCount;
}
function buildOpusHead(channels, sampleRate, preSkip) {
const buf = new Uint8Array(19);
const dv = new DataView(buf.buffer);
buf.set(new TextEncoder().encode('OpusHead'));
buf[8] = 1; // version
buf[9] = channels;
dv.setUint16(10, preSkip, true);
dv.setUint32(12, sampleRate, true);
dv.setInt16(16, 0, true); // output gain
buf[18] = 0; // channel mapping family
return buf;
}
function buildOpusTags() {
const vendor = new TextEncoder().encode('chatwoot');
const buf = new Uint8Array(8 + 4 + vendor.length + 4);
const dv = new DataView(buf.buffer);
buf.set(new TextEncoder().encode('OpusTags'));
dv.setUint32(8, vendor.length, true);
buf.set(vendor, 12);
dv.setUint32(12 + vendor.length, 0, true); // 0 user comments
return buf;
}
// ======================== Public API ========================
const MAX_FRAMES_PER_PAGE = 50; // ~1 s at 20 ms/frame
const MAX_SEGMENTS_PER_PAGE = 255;
/**
* Remux a WebM/Opus blob into an OGG/Opus blob.
* If the input is already OGG (starts with "OggS"), it is returned as-is.
*
* @param {Blob} webmBlob
* @returns {Promise<Blob>} OGG/Opus blob
*/
export async function remuxWebmToOgg(webmBlob) {
const buffer = await webmBlob.arrayBuffer();
const bytes = new Uint8Array(buffer);
// Already OGG? Return unchanged.
if (
bytes.length >= 4 &&
bytes[0] === 0x4f &&
bytes[1] === 0x67 &&
bytes[2] === 0x67 &&
bytes[3] === 0x53
) {
return webmBlob;
}
const { channels, sampleRate, codecPrivate, frames } = parseWebM(buffer);
if (frames.length === 0) {
throw new Error('No Opus frames found in WebM input');
}
// Extract pre-skip from the WebM CodecPrivate (which IS the OpusHead)
let preSkip = 312;
if (codecPrivate && codecPrivate.length >= 12) {
const magic = new TextDecoder().decode(codecPrivate.slice(0, 8));
if (magic === 'OpusHead') {
preSkip = new DataView(
codecPrivate.buffer,
codecPrivate.byteOffset,
codecPrivate.length
).getUint16(10, true);
}
}
const serial = (Math.random() * 0x100000000) >>> 0;
let pageSeq = 0;
const pages = [];
// Page 0 OpusHead (BOS)
pages.push(
createOggPage(0x02, 0, serial, pageSeq, [
buildOpusHead(channels, sampleRate, preSkip),
])
);
pageSeq += 1;
// Page 1 OpusTags
pages.push(createOggPage(0x00, 0, serial, pageSeq, [buildOpusTags()]));
pageSeq += 1;
// Audio pages
let granule = 0;
let idx = 0;
while (idx < frames.length) {
const packets = [];
let segs = 0;
while (idx < frames.length && packets.length < MAX_FRAMES_PER_PAGE) {
const pkt = frames[idx];
// createOggPage always appends a terminating lacing value, so a packet
// spans floor(len/255)+1 segments (including the extra 0 when len is an
// exact multiple of 255). Math.ceil would undercount those cases.
const pktSegs = Math.floor(pkt.length / 255) + 1;
if (segs + pktSegs > MAX_SEGMENTS_PER_PAGE && packets.length > 0) break;
packets.push(pkt);
segs += pktSegs;
granule += opusPacketSamples(pkt);
idx += 1;
}
const isLast = idx >= frames.length;
pages.push(
createOggPage(isLast ? 0x04 : 0x00, granule, serial, pageSeq, packets)
);
pageSeq += 1;
}
// Concatenate pages into a single buffer
const total = pages.reduce((s, p) => s + p.length, 0);
const out = new Uint8Array(total);
let off = 0;
pages.forEach(p => {
out.set(p, off);
off += p.length;
});
return new Blob([out], { type: 'audio/ogg' });
}
@@ -375,7 +375,10 @@ export default {
return `draft-${this.conversationIdByRoute}-${this.replyType}`;
},
audioRecordFormat() {
if (this.isAWhatsAppChannel || this.isATelegramChannel) {
if (this.isAWhatsAppChannel) {
return AUDIO_FORMATS.OGG;
}
if (this.isATelegramChannel) {
return AUDIO_FORMATS.MP3;
}
if (this.isAPIInbox) {
@@ -1008,14 +1011,18 @@ export default {
onFinishRecorder(file) {
this.recordingAudioState = 'stopped';
this.hasRecordedAudio = true;
// Added a new key isRecordedAudio to the file to find it's and recorded audio
// Added a new key isVoiceMessage to the file to identify recorded audio
// Because to filter and show only non recorded audio and other attachments
const autoRecordedFile = {
...file,
isRecordedAudio: true,
isVoiceMessage: true,
};
return file && this.onFileUpload(autoRecordedFile);
},
onRecordError() {
this.toggleAudioRecorder();
useAlert(this.$t('CONVERSATION.REPLYBOX.AUDIO_CONVERSION_FAILED'));
},
toggleTyping(status) {
const conversationId = this.currentChat.id;
const isPrivate = this.isPrivate;
@@ -1042,7 +1049,7 @@ export default {
isPrivate: this.isPrivate,
thumb: reader.result,
blobSignedId: blob ? blob.signed_id : undefined,
isRecordedAudio: file?.isRecordedAudio || false,
isVoiceMessage: file?.isVoiceMessage || false,
});
};
},
@@ -1078,6 +1085,7 @@ export default {
private: false,
message: caption,
sender: this.sender,
isVoiceMessage: attachment.isVoiceMessage || false,
};
attachmentPayload = this.setReplyToInPayload(attachmentPayload);
@@ -1127,6 +1135,9 @@ export default {
this.attachedFiles.forEach(attachment => {
if (this.globalConfig.directUploadsEnabled) {
messagePayload.files.push(attachment.blobSignedId);
if (attachment.isVoiceMessage) {
messagePayload.isVoiceMessage = true;
}
} else {
messagePayload.files.push(attachment.resource.file);
}
@@ -1215,7 +1226,7 @@ export default {
this.hasRecordedAudio = false;
// Only clear the recorded audio when we click toggle button.
this.attachedFiles = this.attachedFiles.filter(
file => !file?.isRecordedAudio
file => !file?.isVoiceMessage
);
},
toggleEditorSize() {
@@ -1293,6 +1304,7 @@ export default {
:audio-record-format="audioRecordFormat"
@recorder-progress-changed="onRecordProgressChanged"
@finish-record="onFinishRecorder"
@record-error="onRecordError"
@play="recordingAudioState = 'playing'"
@pause="recordingAudioState = 'paused'"
/>
@@ -10,10 +10,12 @@ vi.mock('shared/helpers/mitt', () => ({
vi.mock('dashboard/helper/AnalyticsHelper/index', async importOriginal => {
const actual = await importOriginal();
actual.default = {
track: vi.fn(),
return {
...actual,
default: {
track: vi.fn(),
},
};
return actual;
});
describe('useTrack', () => {
@@ -16,10 +16,12 @@ vi.mock('vue-i18n');
vi.mock('dashboard/api/captain/tasks');
vi.mock('dashboard/helper/AnalyticsHelper/index', async importOriginal => {
const actual = await importOriginal();
actual.default = {
track: vi.fn(),
return {
...actual,
default: {
track: vi.fn(),
},
};
return actual;
});
vi.mock('dashboard/helper/AnalyticsHelper/events', () => ({
CAPTAIN_EVENTS: {
@@ -52,6 +52,10 @@ export function useAccount() {
});
};
const finishOnboarding = async data => {
await store.dispatch('accounts/finishOnboarding', data);
};
return {
accountId,
route,
@@ -61,5 +65,6 @@ export function useAccount() {
isCloudFeatureEnabled,
isOnChatwootCloud,
updateAccount,
finishOnboarding,
};
}
-1
View File
@@ -46,7 +46,6 @@ export const FEATURE_FLAGS = {
COMPANIES: 'companies',
ADVANCED_SEARCH: 'advanced_search',
CONVERSATION_REQUIRED_ATTRIBUTES: 'conversation_required_attributes',
CONVERSATION_UNREAD_COUNTS: 'conversation_unread_counts',
};
export const PREMIUM_FEATURES = [
@@ -13,7 +13,6 @@ import {
} from 'dashboard/composables/useWhatsappCallSession';
import { VOICE_CALL_PROVIDERS } from 'dashboard/helper/inbox';
import { VOICE_CALL_DIRECTION } from 'dashboard/components-next/message/constants';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
const { isImpersonating } = useImpersonation();
const UNREAD_COUNTS_REFETCH_THROTTLE_MS = 5000;
@@ -172,23 +171,10 @@ class ActionCableConnector extends BaseActionCableConnector {
};
fetchConversationUnreadCounts = () => {
if (!this.isConversationUnreadCountsEnabled()) return;
this.lastUnreadCountsFetchAt = Date.now();
this.app.$store.dispatch('conversationUnreadCounts/get');
};
isConversationUnreadCountsEnabled = () => {
const accountId = this.app.$store.getters.getCurrentAccountId;
const isFeatureEnabled =
this.app.$store.getters['accounts/isFeatureEnabledonAccount'];
return isFeatureEnabled?.(
accountId,
FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS
);
};
onTypingOn = ({ conversation, user }) => {
const conversationId = conversation.id;
@@ -30,7 +30,6 @@ describe('ActionCableConnector - Copilot Tests', () => {
dispatch: mockDispatch,
getters: {
getCurrentAccountId: 1,
'accounts/isFeatureEnabledonAccount': vi.fn(() => true),
},
},
};
@@ -89,21 +88,6 @@ describe('ActionCableConnector - Copilot Tests', () => {
expect(mockDispatch).toHaveBeenCalledWith('conversationUnreadCounts/get');
});
it('does not refetch unread counts when unread count feature is disabled', () => {
store.$store.getters[
'accounts/isFeatureEnabledonAccount'
].mockReturnValue(false);
actionCable.onReceived({
event: 'conversation.unread_count_changed',
data: { account_id: 1 },
});
expect(mockDispatch).not.toHaveBeenCalledWith(
'conversationUnreadCounts/get'
);
});
it('should throttle unread count refetches for repeated events', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
@@ -88,6 +88,7 @@
},
"CARD": {
"STATUS": {
"PROCESSING": "Processing",
"COMPLETED": "Completed",
"SCHEDULED": "Scheduled"
},
@@ -146,6 +147,7 @@
},
"CARD": {
"STATUS": {
"PROCESSING": "Processing",
"COMPLETED": "Completed",
"SCHEDULED": "Scheduled"
},
@@ -231,6 +231,7 @@
"TIP_AUDIORECORDER_ICON": "Record audio",
"TIP_AUDIORECORDER_PERMISSION": "Allow access to audio",
"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",
"START_AUDIO_RECORDING": "Start audio recording",
"STOP_AUDIO_RECORDING": "Stop audio recording",
@@ -28,7 +28,7 @@ import {
const { t } = useI18n();
const router = useRouter();
const store = useStore();
const { accountId, currentAccount, updateAccount } = useAccount();
const { accountId, currentAccount, finishOnboarding } = useAccount();
const { enabledLanguages } = useConfig();
const currentUser = useMapGetter('getCurrentUser');
@@ -195,6 +195,12 @@ const handleWebsiteEnter = () => {
websiteInput.value?.blur();
};
const normalizeWebsiteUrl = raw => {
const trimmed = (raw || '').trim();
if (!trimmed) return '';
return /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
};
const handleSubmit = async () => {
// Block submit while enrichment is still running so users can't bypass
// the form with empty values — the controller would otherwise clear
@@ -211,9 +217,27 @@ const handleSubmit = async () => {
return;
}
// Detect which enrichable fields the user actually edited *before*
// normalizing — otherwise an untouched auto-filled domain
// (acme.com -> https://acme.com) compares unequal against the raw snapshot
// and gets falsely reported as changed, skewing onboarding telemetry.
const init = initialValues.value;
const enrichableFields = {
website: website.value,
company_size: companySize.value,
industry: industry.value,
};
const fieldsChanged = Object.entries(enrichableFields)
.filter(([key, val]) => val !== init[key])
.map(([key]) => key);
// Persist with a scheme so downstream consumers (Firecrawl, portal
// homepage_link) get a fully-qualified URL regardless of what the user typed.
website.value = normalizeWebsiteUrl(website.value);
isSubmitting.value = true;
try {
await updateAccount({
await finishOnboarding({
name: accountName.value,
locale: locale.value,
website: website.value,
@@ -224,20 +248,11 @@ const handleSubmit = async () => {
user_role: userRole.value,
});
const init = initialValues.value;
const enrichableFields = {
website: website.value,
company_size: companySize.value,
industry: industry.value,
};
useTrack(ONBOARDING_EVENTS.ACCOUNT_DETAILS_COMPLETED, {
has_enriched_data: Boolean(
currentAccount.value?.custom_attributes?.brand_info
),
fields_changed: Object.entries(enrichableFields)
.filter(([key, val]) => val !== init[key])
.map(([key]) => key),
fields_changed: fieldsChanged,
user_role: userRole.value,
company_size: companySize.value,
industry: industry.value,
@@ -1,6 +1,7 @@
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
import * as types from '../mutation-types';
import AccountAPI from '../../api/account';
import OnboardingAPI from '../../api/onboarding';
import { differenceInDays } from 'date-fns';
import EnterpriseAccountAPI from '../../api/enterprise/account';
import { throwErrorMessage } from '../utils/api';
@@ -83,6 +84,15 @@ export const actions = {
throw new Error(error);
}
},
finishOnboarding: async ({ commit }, payload) => {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: true });
try {
const response = await OnboardingAPI.update(payload);
commit(types.default.EDIT_ACCOUNT, response.data);
} finally {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: false });
}
},
delete: async ({ commit }, { id }) => {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: true });
try {
@@ -48,9 +48,6 @@ export const actions = {
// Ignore errors so the sidebar can continue rendering without badges.
}
},
clear({ commit }) {
commit(types.SET_CONVERSATION_UNREAD_COUNTS, {});
},
};
export const mutations = {
@@ -39,15 +39,4 @@ describe('#actions', () => {
expect(commit).not.toHaveBeenCalled();
});
});
describe('#clear', () => {
it('clears unread counts', () => {
actions.clear({ commit });
expect(commit).toHaveBeenCalledWith(
types.SET_CONVERSATION_UNREAD_COUNTS,
{}
);
});
});
});
+3 -3
View File
@@ -1,9 +1,9 @@
import Rails from '@rails/ujs';
import Turbolinks from 'turbolinks';
import { Turbo } from '@hotwired/turbo-rails';
import '../portal/application.scss';
import { InitializationHelpers } from '../portal/portalHelpers';
Rails.start();
Turbolinks.start();
Turbo.start();
document.addEventListener('turbolinks:load', InitializationHelpers.onLoad);
document.addEventListener('turbo:load', InitializationHelpers.onLoad);
+1 -1
View File
@@ -55,6 +55,6 @@ body {
}
}
.turbolinks-progress-bar {
.turbo-progress-bar {
background-color: var(--dynamic-portal-color);
}
@@ -138,21 +138,40 @@ export default {
this.isLoading = false;
}
},
handleSubmit() {
const query = this.normalizedSearchTerm;
if (!query) return;
const searchParams = new URLSearchParams({ query });
const { theme, isPlainLayoutEnabled } = window.portalConfig;
if (theme) searchParams.set('theme', theme);
if (isPlainLayoutEnabled === 'true') {
searchParams.set('show_plain_layout', 'true');
}
window.location.href = `/hc/${this.portalSlug}/${this.localeCode}/search?${searchParams.toString()}`;
},
},
};
</script>
<template>
<div v-on-clickaway="closeSearch" class="relative w-full max-w-5xl my-4">
<PublicSearchInput
ref="searchInput"
:search-term="searchTerm"
:search-placeholder="searchTranslations.searchPlaceholder"
:size="size"
:kbd="kbdLabel"
@update:search-term="onUpdateSearchTerm"
@focus="openSearch"
/>
<form @submit.prevent="handleSubmit">
<PublicSearchInput
ref="searchInput"
:search-term="searchTerm"
:search-placeholder="searchTranslations.searchPlaceholder"
:size="size"
:kbd="kbdLabel"
@update:search-term="onUpdateSearchTerm"
@focus="openSearch"
/>
<button type="submit" class="sr-only">
{{ searchTranslations.submit }}
</button>
</form>
<div
v-if="shouldShowSearchBox"
class="absolute w-full top-14"
@@ -112,8 +112,8 @@ export default {
<p class="py-1 px-3" :class="getClassName(element)">
<a
:href="`#${element.slug}`"
data-turbolinks="false"
class="font-medium text-sm cursor-pointer"
data-turbo="false"
class="font-medium text-sm tracking-[0.28px] cursor-pointer"
:class="elementTextStyles(element)"
>
{{ element.title }}
+2 -3
View File
@@ -24,10 +24,9 @@ export const getHeadingsfromTheArticle = () => {
permalink.className = 'permalink text-slate-600 ml-3';
permalink.href = `#${slug}`;
permalink.title = headingText;
permalink.dataset.turbolinks = 'false';
permalink.dataset.turbo = 'false';
permalink.textContent = '#';
element.appendChild(permalink);
rows.push({
slug,
title: headingText,
@@ -188,7 +187,7 @@ export const InitializationHelpers = {
const a = document.createElement('a');
a.href = window.location.hash;
a['data-turbolinks'] = false;
a['data-turbo'] = false;
a.click();
}
},
+1 -1
View File
@@ -92,7 +92,7 @@ class ActionCableListener < BaseListener
def conversation_unread_count_changed(event)
account, inbox_members = ::Conversations::UnreadCounts::BroadcastScope.new(event).perform
return if account.blank? || !account.feature_enabled?('conversation_unread_counts')
return if account.blank?
tokens = user_tokens(account, inbox_members)
-1
View File
@@ -109,7 +109,6 @@ class Account < ApplicationRecord
before_validation :validate_limit_keys
after_create_commit :notify_creation
after_update_commit :clear_unread_conversation_counts_cache, if: :saved_change_to_feature_conversation_unread_counts?
after_destroy :remove_account_sequences
def agents
+4
View File
@@ -55,10 +55,14 @@ class Article < ApplicationRecord
before_validation :ensure_article_slug
before_validation :ensure_locale_in_article
# Slugs that collide with help center routes (e.g. /hc/:slug/:locale/search)
RESERVED_SLUGS = %w[search articles categories].freeze
validates :account_id, presence: true
validates :author_id, presence: true
validates :title, presence: true
validates :content, presence: true, if: :published?
validates :slug, exclusion: { in: RESERVED_SLUGS }
# ensuring that the position is always set correctly
before_create :add_position_to_article
+17 -3
View File
@@ -47,7 +47,7 @@ class Campaign < ApplicationRecord
enum campaign_type: { ongoing: 0, one_off: 1 }
# TODO : enabled attribute is unneccessary . lets move that to the campaign status with additional statuses like draft, disabled etc.
enum campaign_status: { active: 0, completed: 1 }
enum campaign_status: { active: 0, completed: 1, processing: 2 }
has_many :conversations, dependent: :nullify, autosave: true
@@ -56,13 +56,27 @@ class Campaign < ApplicationRecord
def trigger!
return unless one_off?
return if completed?
return unless feature_enabled?
return unless mark_processing!
execute_campaign
end
private
def feature_enabled?
inbox.inbox_type != 'Whatsapp' || account.feature_enabled?(:whatsapp_campaign)
end
def mark_processing!
# Multiple scheduler jobs can pick the same active campaign; lock before flipping status to avoid duplicate sends.
with_lock do
next if completed? || processing?
processing!
end
end
def execute_campaign
case inbox.inbox_type
when 'Twilio SMS'
@@ -70,7 +84,7 @@ class Campaign < ApplicationRecord
when 'Sms'
Sms::OneoffSmsCampaignService.new(campaign: self).perform
when 'Whatsapp'
Whatsapp::OneoffCampaignService.new(campaign: self).perform if account.feature_enabled?(:whatsapp_campaign)
Whatsapp::OneoffCampaignService.new(campaign: self).perform
end
end
+2
View File
@@ -51,3 +51,5 @@ class ContactPolicy < ApplicationPolicy
@account_user.administrator?
end
end
ContactPolicy.prepend_mod_with('ContactPolicy')
@@ -4,7 +4,6 @@ class Conversations::UnreadCounts::Listener < BaseListener
def message_created(event)
message, = extract_message_and_account(event)
return unless message.incoming?
return unless message.account.feature_enabled?('conversation_unread_counts')
refresh(message.conversation)
end
@@ -36,7 +35,7 @@ class Conversations::UnreadCounts::Listener < BaseListener
return if conversation_data.blank?
account = Account.find_by(id: conversation_data[:account_id])
return unless account&.feature_enabled?('conversation_unread_counts')
return if account.blank?
return unless remove_deleted_conversation(account, conversation_data)
Rails.configuration.dispatcher.dispatch(CONVERSATION_UNREAD_COUNT_CHANGED, Time.zone.now, conversation_data: conversation_data.to_h)
@@ -9,8 +9,6 @@ class Conversations::UnreadCounts::Notifier
end
def perform
return false unless conversation.account.feature_enabled?('conversation_unread_counts')
return false unless ::Conversations::UnreadCounts::Refresher.new(conversation, changed_attributes: changed_attributes).perform
Rails.configuration.dispatcher.dispatch(CONVERSATION_UNREAD_COUNT_CHANGED, Time.zone.now, conversation: conversation)
@@ -5,12 +5,10 @@ class Sms::OneoffSmsCampaignService
raise "Invalid campaign #{campaign.id}" if campaign.inbox.inbox_type != 'Sms' || !campaign.one_off?
raise 'Completed Campaign' if campaign.completed?
# marks campaign completed so that other jobs won't pick it up
campaign.completed!
audience_label_ids = campaign.audience.select { |audience| audience['type'] == 'Label' }.pluck('id')
audience_labels = campaign.account.labels.where(id: audience_label_ids).pluck(:title)
process_audience(audience_labels)
campaign.completed!
end
private
@@ -5,12 +5,10 @@ class Twilio::OneoffSmsCampaignService
raise "Invalid campaign #{campaign.id}" if campaign.inbox.inbox_type != 'Twilio SMS' || !campaign.one_off?
raise 'Completed Campaign' if campaign.completed?
# marks campaign completed so that other jobs won't pick it up
campaign.completed!
audience_label_ids = campaign.audience.select { |audience| audience['type'] == 'Label' }.pluck('id')
audience_labels = campaign.account.labels.where(id: audience_label_ids).pluck(:title)
process_audience(audience_labels)
campaign.completed!
end
private
@@ -3,9 +3,8 @@ class Whatsapp::OneoffCampaignService
def perform
validate_campaign!
# marks campaign completed so that other jobs won't pick it up
campaign.completed!
process_audience(extract_audience_labels)
campaign.completed!
end
private
@@ -90,8 +90,8 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
end
# TODO: See if we can unify the API versions and for both paths and make it consistent with out facebook app API versions
def phone_id_path
"#{api_base_path}/v13.0/#{whatsapp_channel.provider_config['phone_number_id']}"
def phone_id_path(version = 'v13.0')
"#{api_base_path}/#{version}/#{whatsapp_channel.provider_config['phone_number_id']}"
end
def business_account_path
@@ -116,14 +116,11 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
def send_attachment_message(phone_number, message)
attachment = message.attachments.first
normalize_opus_content_type(attachment)
type = %w[image audio video].include?(attachment.file_type) ? attachment.file_type : 'document'
type_content = {
'link': attachment.download_url
}
type_content['caption'] = message.outgoing_content unless %w[audio sticker].include?(type)
type_content['filename'] = attachment.file.filename if type == 'document'
type_content = build_attachment_content(type, attachment, message)
response = HTTParty.post(
"#{phone_id_path}/messages",
"#{phone_id_path('v24.0')}/messages",
headers: api_headers,
body: {
:messaging_product => 'whatsapp',
@@ -142,6 +139,33 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
response.parsed_response&.dig('error', 'message')
end
def voice_message?(type, attachment)
type == 'audio' && attachment.meta&.dig('is_voice_message') && attachment.file.content_type == 'audio/ogg'
end
# Marcel gem may re-detect OGG/Opus files as audio/opus after ActiveStorage
# blob attachment, but WhatsApp Cloud API requires audio/ogg content type
# for voice messages. Normalize so the download URL serves the correct
# Content-Type header. No-op when the frontend already uploads as audio/ogg.
def normalize_opus_content_type(attachment)
return unless attachment.file.attached?
blob = attachment.file.blob
return unless blob.content_type == 'audio/opus'
return if blob.update(content_type: 'audio/ogg')
Rails.logger.error("Failed to normalize blob #{blob.id} content_type from audio/opus to audio/ogg")
end
def build_attachment_content(type, attachment, message)
type_content = { 'link' => attachment.download_url }
type_content['caption'] = message.outgoing_content unless %w[audio sticker].include?(type)
type_content['filename'] = attachment.file.filename if type == 'document'
type_content['voice'] = true if voice_message?(type, attachment)
type_content
end
def template_body_parameters(template_info)
template_body = {
name: template_info[:name],
+1 -1
View File
@@ -1,6 +1,6 @@
<meta charset="utf-8">
<meta name="viewport" content="initial-scale=1">
<meta name="turbolinks-cache-control" content="no-cache">
<meta name="turbo-cache-control" content="no-cache">
<meta name="viewport" content="width=device-width, initial-scale=1">
<%= vite_client_tag %>
@@ -82,6 +82,7 @@ html.light {
emptyPlaceholder: '<%= I18n.t('public_portal.search.empty_placeholder') %>',
loadingPlaceholder: '<%= I18n.t('public_portal.search.loading_placeholder') %>',
resultsTitle: '<%= I18n.t('public_portal.search.results_title') %>',
submit: '<%= I18n.t('public_portal.search.submit') %>',
},
isPlainLayoutEnabled: '<%= @is_plain_layout_enabled %>',
tocHeader: '<%= I18n.t('public_portal.toc_header') %>'
@@ -11,9 +11,9 @@
<%= I18n.t('public_portal.hero.sub_title') %>
</p>
<form class="mt-8 group/herosearch relative z-30" onsubmit="return false">
<div class="mt-8 group/herosearch relative z-30">
<div id="search-wrap-hero" class="block w-full"></div>
</form>
</div>
<% if popular_topics.any? %>
<div class="mt-5 flex items-center gap-2 flex-wrap text-sm">
@@ -55,7 +55,7 @@
<% portal.public_locale_codes.each do |code| %>
<% is_current = code == locale %>
<a href="/hc/<%= portal.slug %>/<%= code %>/"
data-turbolinks="false"
data-turbo="false"
class="flex items-center gap-2.5 px-2.5 py-2 text-sm rounded-md transition <%= is_current ? 'bg-n-portal-soft text-n-portal' : 'text-n-slate-11 hover:bg-n-alpha-2 hover:text-n-slate-12' %>">
<span class="text-xs font-semibold uppercase min-w-9 text-center text-n-slate-11 bg-n-slate-2 border border-solid border-n-weak rounded px-1.5 py-0.5 flex-shrink-0"><%= code %></span>
<span class="flex-1 truncate <%= is_current ? 'font-medium' : '' %>"><%= language_name(code) %></span>
@@ -0,0 +1,20 @@
<%# locals: (input_class:) %>
<form class="w-full my-4" action="<%= request.path %>" method="GET" data-search-form>
<input type="hidden" name="locale" value="<%= params[:locale] %>">
<% if @theme_from_params.present? %>
<input type="hidden" name="theme" value="<%= @theme_from_params %>">
<% end %>
<% if @is_plain_layout_enabled %>
<input type="hidden" name="show_plain_layout" value="true">
<% end %>
<input type="text"
name="query"
value="<%= @query %>"
placeholder="<%= I18n.t('public_portal.search.search_placeholder') %>"
data-search-input
autofocus
class="<%= input_class %>">
<button type="submit" class="sr-only">
<%= I18n.t('public_portal.search.submit') %>
</button>
</form>
@@ -0,0 +1,33 @@
<script>
(function() {
const searchInputs = document.querySelectorAll('[data-search-input]');
searchInputs.forEach(function(input) {
let debounceTimer;
if (input.value.length > 0) {
setTimeout(function() {
input.setSelectionRange(input.value.length, input.value.length);
}, 0);
}
input.addEventListener('input', function() {
const form = input.closest('[data-search-form]');
const query = input.value.trim();
clearTimeout(debounceTimer);
debounceTimer = setTimeout(function() {
const currentQuery = new URLSearchParams(window.location.search).get('query') || '';
if (query !== currentQuery) {
form.requestSubmit();
}
}, 500);
});
input.closest('[data-search-form]').addEventListener('submit', function() {
clearTimeout(debounceTimer);
});
});
})();
</script>
@@ -0,0 +1,64 @@
<% content_for :head do %>
<title><%= I18n.t('public_portal.search.results_for', query: @query) %> | <%= @portal.name %></title>
<% end %>
<div class="px-6 md:px-10 py-8 md:py-10">
<% pagination_params = params.permit(:query, :locale, :theme, :show_plain_layout) %>
<nav class="flex items-center gap-2 text-sm text-n-slate-11 mb-8 flex-wrap">
<a
href="<%= public_portal_locale_path(@portal.slug, @locale) %>"
class="inline-flex items-center gap-1.5 hover:text-n-slate-12 transition"
>
<span class="i-lucide-house size-3.5" aria-hidden="true"></span>
<%= I18n.t('public_portal.common.home') %>
</a>
<span class="i-lucide-chevron-right size-3 text-n-slate-9" aria-hidden="true"></span>
<span class="text-n-slate-12 font-medium truncate"><%= I18n.t('public_portal.search.results') %></span>
</nav>
<div class="mb-8">
<h1 class="text-3xl md:text-4xl leading-snug font-620 tracking-tight text-n-slate-12 text-balance">
<%= I18n.t('public_portal.search.results_for', query: @query) %>
</h1>
<%= render 'public/api/v1/portals/search/form',
input_class: 'w-full px-4 py-3 border border-n-weak rounded-lg bg-n-alpha-1 text-n-slate-12 placeholder-n-slate-10 focus:outline-none focus:ring-2 focus:ring-n-portal focus:border-transparent' %>
</div>
<%= render 'public/api/v1/portals/search/search_handler' %>
<% if @articles.empty? %>
<%= render 'public/api/v1/portals/documentation_layout/empty_state',
message: I18n.t('public_portal.search.no_results', query: @query) %>
<% else %>
<p class="mb-6 text-sm text-n-slate-11">
<%= I18n.t('public_portal.search.found_results', count: @articles.total_count) %>
</p>
<div class="grid grid-cols-1 gap-4">
<% @articles.each do |article| %>
<%= render 'public/api/v1/portals/documentation_layout/article_card',
portal: @portal,
article: article %>
<% end %>
</div>
<% if @articles.respond_to?(:total_pages) && @articles.total_pages > 1 %>
<div class="flex justify-center mt-6">
<nav class="inline-flex">
<% if @articles.prev_page %>
<a href="<%= url_for(pagination_params.merge(page: @articles.prev_page)) %>" class="px-3 py-2 border border-n-weak rounded-l-md text-sm font-medium text-n-slate-11 hover:bg-n-alpha-2">
<%= I18n.t('public_portal.common.previous') %>
</a>
<% end %>
<% if @articles.next_page %>
<a href="<%= url_for(pagination_params.merge(page: @articles.next_page)) %>" class="px-3 py-2 border border-n-weak rounded-r-md text-sm font-medium text-n-slate-11 hover:bg-n-alpha-2">
<%= I18n.t('public_portal.common.next') %>
</a>
<% end %>
</nav>
</div>
<% end %>
<% end %>
</div>
@@ -0,0 +1,118 @@
<% content_for :head do %>
<title><%= I18n.t('public_portal.search.results_for', query: @query) %> | <%= @portal.name %></title>
<% end %>
<% search_input_class = 'w-full px-4 py-3 border border-slate-200 dark:border-slate-700 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 placeholder-slate-500 dark:placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent' %>
<% if !@is_plain_layout_enabled %>
<div id="portal-bg" class="bg-white dark:bg-slate-900 shadow-inner">
<div id="portal-bg-gradient" class="pt-8 pb-8 md:pt-14 md:pb-6">
<div class="max-w-5xl px-4 md:px-8 mx-auto flex flex-col">
<div class="flex flex-row items-center gap-px mb-6">
<a class="text-slate-500 dark:text-slate-200 text-sm gap-1 hover:cursor-pointer hover:underline leading-8 font-semibold"
href="<%= generate_home_link(@portal.slug, params[:locale], @theme_from_params, @is_plain_layout_enabled) %>">
<%= I18n.t('public_portal.common.home') %>
</a>
<span class="w-4 h-4 [&>svg]:w-3 [&>svg]:h-3 flex items-center justify-center text-xs text-slate-500 dark:text-slate-300">
<%= render partial: 'icons/chevron-right' %>
</span>
<span class="text-sm font-semibold text-slate-800 dark:text-slate-100">
<%= I18n.t('public_portal.search.results') %>
</span>
</div>
<h1 class="text-3xl font-semibold leading-normal md:tracking-normal md:text-4xl text-slate-900 dark:text-white">
<%= I18n.t('public_portal.search.results_for', query: @query) %>
</h1>
<%= render 'public/api/v1/portals/search/form', input_class: search_input_class %>
</div>
</div>
</div>
<% else %>
<div class="max-w-5xl px-4 md:px-8 mx-auto flex flex-col py-4">
<div class="flex flex-row items-center gap-px mb-6">
<a class="text-slate-500 dark:text-slate-200 text-sm gap-1 hover:cursor-pointer hover:underline leading-8 font-semibold"
href="<%= generate_home_link(@portal.slug, params[:locale], @theme_from_params, @is_plain_layout_enabled) %>">
<%= I18n.t('public_portal.common.home') %>
</a>
<span class="w-4 h-4 [&>svg]:w-3 [&>svg]:h-3 flex items-center justify-center text-xs text-slate-500 dark:text-slate-300">
<%= render partial: 'icons/chevron-right' %>
</span>
<span class="text-sm font-semibold text-slate-800 dark:text-slate-100">
<%= I18n.t('public_portal.search.results') %>
</span>
</div>
<h1 class="text-3xl font-semibold leading-normal md:tracking-normal md:text-4xl text-slate-900 dark:text-white">
<%= I18n.t('public_portal.search.results_for', query: @query) %>
</h1>
<%= render 'public/api/v1/portals/search/form', input_class: search_input_class %>
</div>
<% end %>
<% pagination_params = params.permit(:query, :locale, :theme, :show_plain_layout) %>
<%= render 'public/api/v1/portals/search/search_handler' %>
<section class="max-w-5xl w-full mx-auto px-4 md:px-8 py-6 flex flex-col items-center justify-center flex-grow">
<div class="w-full flex flex-col gap-6 flex-grow">
<% if @articles.empty? %>
<div class="h-full flex items-center justify-center bg-slate-50 dark:bg-slate-800 rounded-xl py-6">
<p class="text-sm text-slate-500"><%= I18n.t('public_portal.search.no_results', query: @query) %></p>
</div>
<% else %>
<p class="text-sm text-slate-600 dark:text-slate-400">
<%= I18n.t('public_portal.search.found_results', count: @articles.total_count) %>
</p>
<% @articles.each do |article| %>
<div class="border border-solid border-slate-100 dark:border-slate-800 rounded-lg">
<a class="p-4 text-slate-800 dark:text-slate-50 flex justify-between content-center hover:cursor-pointer"
href="<%= generate_article_link(@portal.slug, article.slug, @theme_from_params, @is_plain_layout_enabled) %>">
<div class="flex flex-col gap-5">
<div class="flex flex-col gap-1">
<h3 class="text-lg text-slate-900 tracking-[0.28px] dark:text-slate-50 font-semibold">
<%= article.title %>
</h3>
<p class="text-base font-normal text-slate-600 dark:text-slate-200 line-clamp-2 break-all">
<%= render_category_content(article.content) %>
</p>
</div>
<div class="flex flex-row items-center gap-2">
<% if article.category.present? %>
<span class="text-sm text-slate-600 dark:text-slate-400 font-medium">
<%= article.category.name %>
</span>
<span class="text-slate-600 dark:text-slate-400"></span>
<% end %>
<span class="text-sm text-slate-600 dark:text-slate-400 font-medium">
<%= I18n.t('public_portal.common.last_updated_on', last_updated_on: article.updated_at.strftime("%b %d, %Y")) %>
</span>
</div>
</div>
</a>
</div>
<% end %>
<% if @articles.respond_to?(:total_pages) && @articles.total_pages > 1 %>
<div class="flex justify-center mt-6">
<nav class="inline-flex">
<% if @articles.prev_page %>
<a href="<%= url_for(pagination_params.merge(page: @articles.prev_page)) %>" class="px-3 py-2 border border-slate-200 dark:border-slate-700 rounded-l-md text-sm font-medium text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800">
<%= I18n.t('public_portal.common.previous') %>
</a>
<% end %>
<% if @articles.next_page %>
<a href="<%= url_for(pagination_params.merge(page: @articles.next_page)) %>" class="px-3 py-2 border border-slate-200 dark:border-slate-700 rounded-r-md text-sm font-medium text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800">
<%= I18n.t('public_portal.common.next') %>
</a>
<% end %>
</nav>
</div>
<% end %>
<% end %>
</div>
</section>
+2 -1
View File
@@ -19,8 +19,9 @@
help_url: https://chwt.app/hc/fb
- name: conversation_unread_counts
display_name: Conversation Unread Counts
enabled: false
enabled: true
chatwoot_internal: true
deprecated: true
- name: ip_lookup
display_name: IP Lookup
enabled: false
+9 -3
View File
@@ -81,9 +81,6 @@ en:
saml:
feature_not_enabled: SAML feature not enabled for this account
sso_not_enabled: SAML SSO is not enabled for this installation
conversations:
unread_counts:
feature_not_enabled: Conversation unread counts feature not enabled for this account
data_import:
data_type:
invalid: Invalid data type
@@ -430,6 +427,13 @@ en:
empty_placeholder: No results found.
loading_placeholder: Searching...
results_title: Search results
results: Search Results
results_for: "Search Results for '%{query}'"
no_results: "No results found for '%{query}'"
found_results:
one: Found 1 result
other: 'Found %{count} results'
submit: Search
toc_header: 'On this page'
sidebar:
help_center: Help Center
@@ -461,6 +465,8 @@ en:
others: others
by: By
no_articles: There are no articles here
previous: Previous
next: Next
article_actions:
label: Open in
view_markdown: View as Markdown
+3
View File
@@ -29,6 +29,7 @@ Rails.application.routes.draw do
get '/app/accounts/:account_id/settings/inboxes/:inbox_id', to: 'dashboard#index', as: 'app_instagram_inbox_settings'
get '/app/accounts/:account_id/settings/inboxes/:inbox_id', to: 'dashboard#index', as: 'app_tiktok_inbox_settings'
get '/app/accounts/:account_id/settings/inboxes/:inbox_id', to: 'dashboard#index', as: 'app_email_inbox_settings'
get '/app/accounts/:account_id/onboarding/inbox-setup', to: 'dashboard#index', as: 'app_onboarding_inbox_setup'
resource :widget, only: [:show]
namespace :survey do
@@ -54,6 +55,7 @@ Rails.application.routes.draw do
resource :contact_merge, only: [:create]
end
resource :bulk_actions, only: [:create]
resource :onboarding, only: [:update]
resources :agents, only: [:index, :create, :update, :destroy] do
post :bulk_create, on: :collection
end
@@ -588,6 +590,7 @@ Rails.application.routes.draw do
get 'hc/:slug', to: 'public/api/v1/portals#show'
get 'hc/:slug/sitemap.xml', to: 'public/api/v1/portals#sitemap'
get 'hc/:slug/:locale', to: 'public/api/v1/portals#show', as: :public_portal_locale
get 'hc/:slug/:locale/search', to: 'public/api/v1/portals/search#index', as: :portal_search
get 'hc/:slug/:locale/articles', to: 'public/api/v1/portals/articles#index'
get 'hc/:slug/:locale/categories', to: 'public/api/v1/portals/categories#index'
get 'hc/:slug/:locale/categories/:category_slug', to: 'public/api/v1/portals/categories#show', as: :public_portal_category
@@ -0,0 +1,9 @@
module Enterprise::Public::Api::V1::Portals::SearchController
private
def search_articles
return super if @query.blank? || !@portal.account.feature_enabled?('help_center_embedding_search')
@articles = @articles.vector_search(search_params.merge(account_id: @portal.account_id, limit: nil))
end
end
@@ -26,13 +26,15 @@ module Enterprise::Concerns::Article
# if using add the filter block to the below query
# .filter { |ae| ae.neighbor_distance <= distance_threshold }
article_ids = ArticleEmbedding.where(article_id: filtered_article_ids)
.nearest_neighbors(:embedding, embedding, distance: 'cosine')
.limit(5)
.pluck(:article_id)
limit = params.key?(:limit) ? params[:limit] : 5
article_embeddings = ArticleEmbedding.where(article_id: filtered_article_ids)
.nearest_neighbors(:embedding, embedding, distance: 'cosine')
article_embeddings = article_embeddings.limit(limit) if limit.present?
article_ids = article_embeddings.pluck(:article_id)
# Fetch the articles by the IDs obtained from the nearest neighbors search
where(id: article_ids)
where(id: article_ids).in_order_of(:id, article_ids)
end
end
@@ -0,0 +1,9 @@
module Enterprise::ContactPolicy
def export?
@account_user.custom_role&.permissions&.include?('contact_manage') || super
end
def import?
@account_user.custom_role&.permissions&.include?('contact_manage') || super
end
end
@@ -1,5 +1,5 @@
class Captain::Tools::FirecrawlService
BASE_URL = 'https://api.firecrawl.dev/v1'.freeze
BASE_URL = 'https://api.firecrawl.dev/v2'.freeze
FIRECRAWL_EXCLUDE_TAGS = %w[iframe .sidebar .cookie-banner [role=navigation] [role=banner] [role=contentinfo]].freeze
def self.configured?
@@ -35,10 +35,10 @@ class Captain::Tools::FirecrawlService
def crawl_payload(url, webhook_url, crawl_limit)
{
url: url,
maxDepth: 50,
ignoreSitemap: false,
maxDiscoveryDepth: 50,
sitemap: 'include',
limit: crawl_limit,
webhook: webhook_url,
webhook: { url: webhook_url },
scrapeOptions: scrape_options
}.to_json
end
@@ -50,6 +50,7 @@ class Captain::Tools::FirecrawlService
def scrape_options
{
onlyMainContent: true,
maxAge: 0,
formats: ['markdown'],
excludeTags: FIRECRAWL_EXCLUDE_TAGS
}
@@ -19,7 +19,15 @@ class Enterprise::Billing::ReconcilePlanFeaturesService
channel_voice
].freeze
BUSINESS_PLAN_FEATURES = %w[sla custom_roles csat_review_notes conversation_required_attributes advanced_assignment custom_tools].freeze
BUSINESS_PLAN_FEATURES = %w[
sla
custom_roles
csat_review_notes
conversation_required_attributes
advanced_assignment
custom_tools
companies
].freeze
ENTERPRISE_PLAN_FEATURES = %w[audit_logs disable_branding saml].freeze
PREMIUM_PLAN_FEATURES = (STARTUP_PLAN_FEATURES + BUSINESS_PLAN_FEATURES + ENTERPRISE_PLAN_FEATURES).freeze
+6 -7
View File
@@ -12,7 +12,7 @@
"start:test": "RAILS_ENV=test foreman start -f ./Procfile.test",
"dev": "overmind start -f ./Procfile.dev",
"ruby:prettier": "bundle exec rubocop -a",
"build:sdk": "BUILD_MODE=library vite build",
"build:sdk": "vite build --config vite.lib.config.ts",
"prepare": "husky install",
"size": "size-limit",
"story:dev": "histoire dev",
@@ -40,6 +40,7 @@
"@formkit/vue": "^1.7.2",
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
"@highlightjs/vue-plugin": "^2.1.0",
"@hotwired/turbo-rails": "^8.0.13",
"@iconify-json/fluent": "^1.2.32",
"@iconify-json/material-symbols": "^1.2.10",
"@lk77/vue3-color": "^3.0.6",
@@ -52,7 +53,7 @@
"@tailwindcss/typography": "^0.5.19",
"@tanstack/vue-table": "^8.20.5",
"@twilio/voice-sdk": "^2.12.4",
"@vitejs/plugin-vue": "^5.1.4",
"@vitejs/plugin-vue": "^5.2.4",
"@vue/compiler-sfc": "^3.5.8",
"@vuelidate/core": "^2.0.3",
"@vuelidate/validators": "^2.0.4",
@@ -92,7 +93,6 @@
"snakecase-keys": "^8.0.1",
"timezone-phone-codes": "^0.0.2",
"tinykeys": "^3.0.0",
"turbolinks": "^5.2.0",
"urlpattern-polyfill": "^10.0.0",
"video.js": "7.21.1",
"videojs-record": "4.5.0",
@@ -146,8 +146,8 @@
"prosemirror-model": "^1.22.3",
"size-limit": "^8.2.4",
"tailwindcss": "^3.4.19",
"vite": "^5.4.21",
"vite-plugin-ruby": "^5.0.0",
"vite": "6.4.2",
"vite-plugin-ruby": "^5.2.1",
"vitest": "3.0.5"
},
"engines": {
@@ -161,8 +161,7 @@
},
"pnpm": {
"overrides": {
"vite-node": "2.0.1",
"vite": "5.4.21",
"vite": "6.4.2",
"vitest": "3.0.5",
"minimatch@<4": "3.1.5",
"minimatch@>=9.0.0 <9.0.7": "9.0.9",
+456 -196
View File
File diff suppressed because it is too large Load Diff
@@ -140,6 +140,45 @@ describe Messages::Facebook::MessageBuilder do
end
end
[
{
source_id: 'm_fallback_test',
attachment: { type: 'fallback', title: 'Shared link', url: 'https://www.example.com/shared-link' },
title: 'Shared link',
url: 'https://www.example.com/shared-link'
},
{
source_id: 'm_share_test',
attachment: { type: 'share', title: 'Shared Facebook post', payload: { url: 'https://www.facebook.com/example/posts/123' } },
title: 'Shared Facebook post',
url: 'https://www.facebook.com/example/posts/123'
}
].each do |message_data|
it "stores #{message_data[:attachment][:type]} attachments as fallback links" do
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
{ first_name: 'Jane', last_name: 'Dae', profile_pic: 'https://chatwoot-assets.local/sample.png' }.with_indifferent_access
)
expect(Down).not_to receive(:download)
message_object = {
messaging: {
sender: { id: '3383290475046708' },
recipient: { id: facebook_channel.page_id },
message: { mid: message_data[:source_id], attachments: [message_data[:attachment]] }
}
}.to_json
message = Integrations::Facebook::MessageParser.new(message_object)
described_class.new(message, facebook_channel.inbox).perform
attachment = facebook_channel.inbox.messages.find_by(source_id: message_data[:source_id]).attachments.first
expect(attachment.file_type).to eq('fallback')
expect(attachment.fallback_title).to eq(message_data[:title])
expect(attachment.external_url).to eq(message_data[:url])
end
end
context 'when lock to single conversation' do
subject(:mocked_message_builder) do
described_class.new(mocked_incoming_fb_text_message, facebook_channel.inbox).perform
@@ -149,6 +149,35 @@ describe Messages::MessageBuilder do
end
end
context 'when is_voice_message is true' do
let(:params) do
ActionController::Parameters.new({
content: 'test',
attachments: [Rack::Test::UploadedFile.new('spec/assets/sample.ogg', 'audio/ogg')],
is_voice_message: true
})
end
it 'sets is_voice_message in attachment meta' do
message = message_builder
expect(message.attachments.first.meta).to include('is_voice_message' => true)
end
end
context 'when is_voice_message is not provided' do
let(:params) do
ActionController::Parameters.new({
content: 'test',
attachments: [Rack::Test::UploadedFile.new('spec/assets/avatar.png', 'image/png')]
})
end
it 'does not set is_voice_message in attachment meta' do
message = message_builder
expect(message.attachments.first.meta).not_to include('is_voice_message')
end
end
context 'when email channel messages' do
let!(:channel_email) { create(:channel_email, account: account) }
let(:inbox_member) { create(:inbox_member, inbox: channel_email.inbox) }
@@ -126,47 +126,32 @@ RSpec.describe 'Conversations API', type: :request do
Conversations::UnreadCounts::Store.clear_account!(account.id)
end
context 'when conversation unread counts feature is enabled' do
before do
account.enable_features!(:conversation_unread_counts)
end
it 'returns unread conversation counts scoped to the signed-in user' do
create_unread_conversation(account: account, inbox: visible_inbox, labels: [label.title])
create_unread_conversation(account: account, inbox: hidden_inbox, labels: [label.title])
it 'returns unread conversation counts scoped to the signed-in user' do
create_unread_conversation(account: account, inbox: visible_inbox, labels: [label.title])
create_unread_conversation(account: account, inbox: hidden_inbox, labels: [label.title])
get "/api/v1/accounts/#{account.id}/conversations/unread_counts",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(response.parsed_body['payload']).to eq(
'inboxes' => { visible_inbox.id.to_s => 1 },
'labels' => { label.id.to_s => 1 },
'teams' => {}
)
end
it 'returns unread team conversation counts scoped to the signed-in user' do
create_unread_conversation(account: account, inbox: visible_inbox, team: team)
create_unread_conversation(account: account, inbox: hidden_inbox, team: team)
get "/api/v1/accounts/#{account.id}/conversations/unread_counts",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(response.parsed_body['payload']['teams']).to eq(team.id.to_s => 1)
end
end
it 'returns forbidden when conversation unread counts feature is disabled' do
get "/api/v1/accounts/#{account.id}/conversations/unread_counts",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:forbidden)
expect(response.parsed_body['error']).to eq('Conversation unread counts feature not enabled for this account')
expect(response).to have_http_status(:success)
expect(response.parsed_body['payload']).to eq(
'inboxes' => { visible_inbox.id.to_s => 1 },
'labels' => { label.id.to_s => 1 },
'teams' => {}
)
end
it 'returns unread team conversation counts scoped to the signed-in user' do
create_unread_conversation(account: account, inbox: visible_inbox, team: team)
create_unread_conversation(account: account, inbox: hidden_inbox, team: team)
get "/api/v1/accounts/#{account.id}/conversations/unread_counts",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(response.parsed_body['payload']['teams']).to eq(team.id.to_s => 1)
end
end
end
@@ -848,7 +833,6 @@ RSpec.describe 'Conversations API', type: :request do
end
it 'refreshes unread count cache when conversation is marked read' do
account.enable_features!(:conversation_unread_counts)
conversation.update!(agent_last_seen_at: 1.hour.ago)
create(:message, account: account, inbox: conversation.inbox, conversation: conversation, message_type: :incoming, created_at: 5.minutes.ago)
Conversations::UnreadCounts::Builder.new(account).build_base!
@@ -936,7 +920,6 @@ RSpec.describe 'Conversations API', type: :request do
end
it 'refreshes unread count cache when conversation is marked unread' do
account.enable_features!(:conversation_unread_counts)
conversation.update!(agent_last_seen_at: 1.minute.from_now, assignee_last_seen_at: 1.minute.from_now)
Conversations::UnreadCounts::Builder.new(account).build_base!
@@ -43,7 +43,6 @@ RSpec.describe 'Microsoft Authorization API', type: :request do
]
expect(params['scope']).to eq(expected_scope)
expect(params['redirect_uri']).to eq(["#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/microsoft/callback"])
expect(url).not_to match(/(?:\?|&)prompt=/)
# Validate state parameter exists and can be decoded back to the account
expect(params['state']).to be_present
@@ -0,0 +1,114 @@
require 'rails_helper'
RSpec.describe 'Onboarding API', type: :request do
let(:account) { create(:account, domain: 'example.com') }
let(:admin) { create(:user, account: account, role: :administrator) }
describe 'PATCH /api/v1/accounts/{account.id}/onboarding' do
context 'when unauthenticated' do
it 'returns unauthorized' do
patch "/api/v1/accounts/#{account.id}/onboarding", params: { website: 'acme.com' }, as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when authenticated as an agent (non-admin)' do
let(:agent) { create(:user, account: account, role: :agent) }
it 'returns unauthorized and does not change the account' do
patch "/api/v1/accounts/#{account.id}/onboarding",
params: { name: 'Hijacked', website: 'attacker.com' },
headers: agent.create_new_auth_token, as: :json
expect(response).to have_http_status(:unauthorized)
expect(account.reload.name).not_to eq('Hijacked')
end
it 'does not create a help center portal' do
account.update!(custom_attributes: { 'onboarding_step' => 'account_details' })
expect do
patch "/api/v1/accounts/#{account.id}/onboarding",
params: { website: 'attacker.com' },
headers: agent.create_new_auth_token, as: :json
end.not_to change(account.portals, :count)
end
end
context 'when finalizing account_details' do
before { account.update!(custom_attributes: { 'onboarding_step' => 'account_details' }) }
it 'saves name and locale' do
patch "/api/v1/accounts/#{account.id}/onboarding",
params: { name: 'Acme Inc', locale: 'fr' },
headers: admin.create_new_auth_token, as: :json
expect(response).to have_http_status(:success)
expect(account.reload.name).to eq('Acme Inc')
expect(account.locale).to eq('fr')
end
it 'merges custom_attributes' do
patch "/api/v1/accounts/#{account.id}/onboarding",
params: { website: 'acme.com', industry: 'tech', company_size: '10-50' },
headers: admin.create_new_auth_token, as: :json
attrs = account.reload.custom_attributes
expect(attrs['website']).to eq('acme.com')
expect(attrs['industry']).to eq('tech')
expect(attrs['company_size']).to eq('10-50')
end
it 'clears onboarding_step' do
patch "/api/v1/accounts/#{account.id}/onboarding",
params: { website: 'acme.com' },
headers: admin.create_new_auth_token, as: :json
expect(account.reload.custom_attributes).not_to have_key('onboarding_step')
end
it 'invokes HelpCenterCreationService when website is present', skip: 'help center generation wiring disabled until UI is ready' do
service = instance_double(Onboarding::HelpCenterCreationService, perform: nil)
allow(Onboarding::HelpCenterCreationService).to receive(:new).and_return(service)
patch "/api/v1/accounts/#{account.id}/onboarding",
params: { website: 'acme.com' },
headers: admin.create_new_auth_token, as: :json
expect(Onboarding::HelpCenterCreationService).to have_received(:new) do |arg_account, arg_user|
expect(arg_account.id).to eq(account.id)
expect(arg_user.id).to eq(admin.id)
end
expect(service).to have_received(:perform)
end
it 'does not create a help center portal when website is blank' do
expect do
patch "/api/v1/accounts/#{account.id}/onboarding",
params: { name: 'Acme Inc' },
headers: admin.create_new_auth_token, as: :json
end.not_to change(account.portals, :count)
end
end
context 'when onboarding_step is not account_details' do
before { account.update!(custom_attributes: { 'onboarding_step' => 'invite_team' }) }
it 'does not clear onboarding_step' do
patch "/api/v1/accounts/#{account.id}/onboarding",
params: { website: 'acme.com' },
headers: admin.create_new_auth_token, as: :json
expect(account.reload.custom_attributes['onboarding_step']).to eq('invite_team')
end
it 'does not create a help center portal' do
expect do
patch "/api/v1/accounts/#{account.id}/onboarding",
params: { website: 'acme.com' },
headers: admin.create_new_auth_token, as: :json
end.not_to change(account.portals, :count)
end
end
end
end
@@ -302,16 +302,6 @@ RSpec.describe 'Accounts API', type: :request do
expect(account.reload.custom_attributes['onboarding_step']).to eq('invite_team')
end
it 'clears onboarding step when current value is account_details' do
account.update(custom_attributes: { onboarding_step: 'account_details' })
patch "/api/v1/accounts/#{account.id}",
params: params,
headers: admin.create_new_auth_token,
as: :json
expect(account.reload.custom_attributes).not_to have_key('onboarding_step')
end
it 'will not update onboarding step if onboarding step is not present in account custom attributes' do
patch "/api/v1/accounts/#{account.id}",
params: params,
@@ -34,6 +34,25 @@ RSpec.describe 'Microsoft::CallbacksController', type: :request do
expect(inbox.channel.imap_address).to eq 'outlook.office365.com'
end
it 'sets imap_login from preferred_username when the id_token carries a UPN that differs from email' do
upn = 'testaccount@primary-domain.example'
mailbox = 'TestAccount@mailbox-domain.example'
response_body = {
id_token: JWT.encode({ email: mailbox, preferred_username: upn, name: 'test' }, nil, 'none'),
access_token: SecureRandom.hex(10), token_type: 'Bearer', refresh_token: SecureRandom.hex(10)
}
stub_request(:post, 'https://login.microsoftonline.com/common/oauth2/v2.0/token')
.with(body: { 'code' => code, 'grant_type' => 'authorization_code',
'redirect_uri' => "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/microsoft/callback" })
.to_return(status: 200, body: response_body.to_json, headers: { 'Content-Type' => 'application/json' })
get microsoft_callback_url, params: { code: code, state: state }
channel = account.inboxes.last.channel
expect(channel.imap_login).to eq upn
expect(channel.email).to eq mailbox
end
it 'creates updates inbox channel config if inbox exists and authentication is successful' do
inbox = create(:channel_email, account: account, email: email)&.inbox
expect(inbox.channel.provider_config).to eq({})
@@ -0,0 +1,26 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Enterprise::ContactPolicy', type: :policy do
subject(:contact_policy) { ContactPolicy }
let(:account) { create(:account) }
let(:contact) { create(:contact, account: account) }
let(:custom_role) { create(:custom_role, account: account, permissions: ['contact_manage']) }
let(:agent) { create(:user) }
let(:account_user) { create(:account_user, user: agent, account: account, role: :agent, custom_role: custom_role) }
let(:agent_context) { { user: agent, account: account, account_user: account_user } }
permissions :export? do
context 'when agent has contact_manage permission' do
it { expect(contact_policy).to permit(agent_context, contact) }
end
end
permissions :import? do
context 'when agent has contact_manage permission' do
it { expect(contact_policy).to permit(agent_context, contact) }
end
end
end
@@ -53,12 +53,13 @@ RSpec.describe Captain::Tools::FirecrawlService do
let(:expected_payload) do
{
url: url,
maxDepth: 50,
ignoreSitemap: false,
maxDiscoveryDepth: 50,
sitemap: 'include',
limit: crawl_limit,
webhook: webhook_url,
webhook: { url: webhook_url },
scrapeOptions: {
onlyMainContent: true,
maxAge: 0,
formats: ['markdown'],
excludeTags: Captain::Tools::FirecrawlService::FIRECRAWL_EXCLUDE_TAGS
}
@@ -74,7 +75,7 @@ RSpec.describe Captain::Tools::FirecrawlService do
context 'when the API call is successful' do
before do
stub_request(:post, 'https://api.firecrawl.dev/v1/crawl')
stub_request(:post, 'https://api.firecrawl.dev/v2/crawl')
.with(
body: expected_payload,
headers: expected_headers
@@ -85,7 +86,7 @@ RSpec.describe Captain::Tools::FirecrawlService do
it 'makes a POST request with correct parameters' do
service.perform(url, webhook_url, crawl_limit)
expect(WebMock).to have_requested(:post, 'https://api.firecrawl.dev/v1/crawl')
expect(WebMock).to have_requested(:post, 'https://api.firecrawl.dev/v2/crawl')
.with(
body: expected_payload,
headers: expected_headers
@@ -95,7 +96,7 @@ RSpec.describe Captain::Tools::FirecrawlService do
it 'uses default crawl limit when not specified' do
default_payload = expected_payload.gsub(crawl_limit.to_s, '10')
stub_request(:post, 'https://api.firecrawl.dev/v1/crawl')
stub_request(:post, 'https://api.firecrawl.dev/v2/crawl')
.with(
body: default_payload,
headers: expected_headers
@@ -104,7 +105,7 @@ RSpec.describe Captain::Tools::FirecrawlService do
service.perform(url, webhook_url)
expect(WebMock).to have_requested(:post, 'https://api.firecrawl.dev/v1/crawl')
expect(WebMock).to have_requested(:post, 'https://api.firecrawl.dev/v2/crawl')
.with(
body: default_payload,
headers: expected_headers
@@ -114,7 +115,7 @@ RSpec.describe Captain::Tools::FirecrawlService do
context 'when the API call fails' do
before do
stub_request(:post, 'https://api.firecrawl.dev/v1/crawl')
stub_request(:post, 'https://api.firecrawl.dev/v2/crawl')
.to_raise(StandardError.new('Connection failed'))
end
@@ -126,14 +127,14 @@ RSpec.describe Captain::Tools::FirecrawlService do
context 'when the API returns an error response' do
before do
stub_request(:post, 'https://api.firecrawl.dev/v1/crawl')
stub_request(:post, 'https://api.firecrawl.dev/v2/crawl')
.to_return(status: 422, body: '{"error": "Invalid URL"}')
end
it 'makes the request but does not raise an error' do
expect { service.perform(url, webhook_url, crawl_limit) }.not_to raise_error
expect(WebMock).to have_requested(:post, 'https://api.firecrawl.dev/v1/crawl')
expect(WebMock).to have_requested(:post, 'https://api.firecrawl.dev/v2/crawl')
.with(
body: expected_payload,
headers: expected_headers
+6 -6
View File
@@ -140,7 +140,7 @@ describe PortalHelper do
context 'when theme is not present' do
it 'returns the correct link' do
expect(helper.generate_home_link('portal_slug', 'en', nil, true)).to eq(
'/hc/portal_slug/en'
'/hc/portal_slug/en?show_plain_layout=true'
)
end
end
@@ -148,7 +148,7 @@ describe PortalHelper do
context 'when theme is present and plain layout is enabled' do
it 'returns the correct link' do
expect(helper.generate_home_link('portal_slug', 'en', 'dark', true)).to eq(
'/hc/portal_slug/en?theme=dark'
'/hc/portal_slug/en?show_plain_layout=true&theme=dark'
)
end
end
@@ -172,7 +172,7 @@ describe PortalHelper do
theme: nil,
is_plain_layout_enabled: true
)).to eq(
'/hc/portal_slug/en/categories/category_slug'
'/hc/portal_slug/en/categories/category_slug?show_plain_layout=true'
)
end
end
@@ -186,7 +186,7 @@ describe PortalHelper do
theme: 'dark',
is_plain_layout_enabled: true
)).to eq(
'/hc/portal_slug/en/categories/category_slug?theme=dark'
'/hc/portal_slug/en/categories/category_slug?show_plain_layout=true&theme=dark'
)
end
end
@@ -210,7 +210,7 @@ describe PortalHelper do
context 'when theme is not present' do
it 'returns the correct link' do
expect(helper.generate_article_link('portal_slug', 'article_slug', nil, true)).to eq(
'/hc/portal_slug/articles/article_slug'
'/hc/portal_slug/articles/article_slug?show_plain_layout=true'
)
end
end
@@ -218,7 +218,7 @@ describe PortalHelper do
context 'when theme is present and plain layout is enabled' do
it 'returns the correct link' do
expect(helper.generate_article_link('portal_slug', 'article_slug', 'dark', true)).to eq(
'/hc/portal_slug/articles/article_slug?theme=dark'
'/hc/portal_slug/articles/article_slug?show_plain_layout=true&theme=dark'
)
end
end
@@ -40,5 +40,13 @@ RSpec.describe TriggerScheduledItemsJob do
expect(Campaigns::TriggerOneoffCampaignJob).to receive(:perform_later).with(campaign).once
described_class.perform_now
end
it 'does not trigger campaigns that are already processing' do
create(:campaign, inbox: twilio_inbox, account: account, campaign_status: :processing)
expect(Campaigns::TriggerOneoffCampaignJob).not_to receive(:perform_later)
described_class.perform_now
end
end
end
@@ -237,10 +237,6 @@ describe ActionCableListener do
let!(:agent_without_inbox_access) { create(:user, account: account, role: :agent) }
let!(:event) { Events::Base.new(event_name, Time.zone.now, conversation: conversation) }
before do
account.enable_features!(:conversation_unread_counts)
end
it 'sends a lightweight refresh event to inbox agents and admins' do
expect(conversation.inbox.reload.inbox_members.count).to eq(1)
@@ -265,14 +261,6 @@ describe ActionCableListener do
listener.conversation_unread_count_changed(event)
end
it 'does not broadcast when conversation unread counts feature is disabled' do
account.disable_features!(:conversation_unread_counts)
expect(ActionCableBroadcastJob).not_to receive(:perform_later)
listener.conversation_unread_count_changed(event)
end
it 'supports deleted conversation data' do
event = Events::Base.new(
event_name,
-38
View File
@@ -50,44 +50,6 @@ RSpec.describe Account do
end
end
describe 'conversation unread counts feature flag' do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let(:store) { Conversations::UnreadCounts::Store }
let(:inbox_key) { store.inbox_key(account.id, inbox.id) }
after do
store.clear_account!(account.id)
end
it 'clears unread count cache when the feature is enabled' do
build_unread_count_cache
account.enable_features!(:conversation_unread_counts)
expect(store.base_ready?(account.id)).to be(false)
expect(store.assignment_ready?(account.id)).to be(false)
expect(store.counts_for_keys([inbox_key])).to eq(inbox_key => 0)
end
it 'clears unread count cache when the feature is disabled' do
account.enable_features!(:conversation_unread_counts)
build_unread_count_cache
account.disable_features!(:conversation_unread_counts)
expect(store.base_ready?(account.id)).to be(false)
expect(store.assignment_ready?(account.id)).to be(false)
expect(store.counts_for_keys([inbox_key])).to eq(inbox_key => 0)
end
def build_unread_count_cache
store.mark_base_ready!(account.id)
store.mark_assignment_ready!(account.id)
store.add_base_membership(account_id: account.id, inbox_id: inbox.id, label_ids: [], conversation_id: 1)
end
end
describe 'inbound_email_domain' do
let(:account) { create(:account) }
+9
View File
@@ -20,6 +20,15 @@ RSpec.describe Article do
expect(article).not_to be_valid
expect(article.errors[:content]).to include("can't be blank")
end
it 'rejects reserved slugs that collide with help center routes' do
Article::RESERVED_SLUGS.each do |reserved_slug|
article = build(:article, portal_id: portal_1.id, author_id: user.id, category_id: category_1.id,
title: reserved_slug, slug: reserved_slug, content: 'content')
expect(article).not_to be_valid
expect(article.errors[:slug]).to include('is reserved')
end
end
end
describe 'associations' do
+48
View File
@@ -83,6 +83,38 @@ RSpec.describe Campaign do
campaign.save!
campaign.trigger!
end
it 'marks the campaign as processing before triggering the service' do
campaign.save!
sms_service = double
expect(Twilio::OneoffSmsCampaignService).to receive(:new).with(campaign: campaign).and_return(sms_service)
expect(sms_service).to receive(:perform) do
expect(campaign.reload.processing?).to be true
end
campaign.trigger!
end
it 'does not trigger a processing campaign again' do
campaign.save!
campaign.processing!
expect(Twilio::OneoffSmsCampaignService).not_to receive(:new)
campaign.trigger!
end
it 'keeps the campaign processing when triggering fails' do
campaign.save!
sms_service = double
expect(Twilio::OneoffSmsCampaignService).to receive(:new).with(campaign: campaign).and_return(sms_service)
expect(sms_service).to receive(:perform).and_raise(StandardError, 'provider error')
expect { campaign.trigger! }.to raise_error(StandardError, 'provider error')
expect(campaign.reload.processing?).to be true
end
end
context 'when SMS campaign' do
@@ -107,6 +139,22 @@ RSpec.describe Campaign do
end
end
context 'when WhatsApp campaign feature is disabled' do
let(:account) { create(:account) }
let(:whatsapp_channel) do
create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud', validate_provider_config: false, sync_templates: false)
end
let(:campaign) { create(:campaign, account: account, inbox: whatsapp_channel.inbox) }
it 'does not mark the campaign as processing' do
expect(Whatsapp::OneoffCampaignService).not_to receive(:new)
campaign.trigger!
expect(campaign.reload.active?).to be true
end
end
context 'when Website campaign' do
let(:campaign) { build(:campaign) }
@@ -11,7 +11,6 @@ RSpec.describe Conversations::UnreadCounts::Listener do
end
it 'refreshes unread counts when an incoming message is created' do
account.enable_features!(:conversation_unread_counts)
message = create(:message, account: account, inbox: conversation.inbox, conversation: conversation, message_type: :incoming)
event = Events::Base.new('message.created', Time.zone.now, message: message)
@@ -30,17 +29,6 @@ RSpec.describe Conversations::UnreadCounts::Listener do
expect(Conversations::UnreadCounts::Notifier).not_to have_received(:new)
end
it 'ignores incoming message creation when conversation unread counts are disabled' do
message = create(:message, account: account, inbox: conversation.inbox, conversation: conversation, message_type: :incoming)
event = Events::Base.new('message.created', Time.zone.now, message: message)
expect(message).not_to receive(:conversation)
listener.message_created(event)
expect(Conversations::UnreadCounts::Notifier).not_to have_received(:new)
end
it 'refreshes unread counts when conversation status changes' do
changed_attributes = { 'status' => %w[open resolved] }
event = Events::Base.new('conversation.status_changed', Time.zone.now, conversation: conversation, changed_attributes: changed_attributes)
@@ -90,7 +78,6 @@ RSpec.describe Conversations::UnreadCounts::Listener do
end
it 'removes unread count memberships when a conversation is deleted' do
account.enable_features!(:conversation_unread_counts)
label = create(:label, account: account)
team = create(:team, account: account)
assignee = create(:user, account: account)
@@ -6,7 +6,6 @@ RSpec.describe Conversations::UnreadCounts::Notifier do
let(:refresh_result) { true }
before do
conversation.account.enable_features!(:conversation_unread_counts)
allow(Conversations::UnreadCounts::Refresher).to receive(:new).and_return(refresher)
allow(Rails.configuration.dispatcher).to receive(:dispatch)
end
@@ -30,19 +29,4 @@ RSpec.describe Conversations::UnreadCounts::Notifier do
expect(Rails.configuration.dispatcher).not_to have_received(:dispatch)
end
end
context 'when conversation unread counts feature is disabled' do
before do
conversation.account.disable_features!(:conversation_unread_counts)
allow(Conversations::UnreadCounts::Store).to receive(:clear_account!)
end
it 'does not refresh, clear cache, or dispatch unread count changed event' do
described_class.new(conversation).perform
expect(Conversations::UnreadCounts::Refresher).not_to have_received(:new)
expect(Conversations::UnreadCounts::Store).not_to have_received(:clear_account!)
expect(Rails.configuration.dispatcher).not_to have_received(:dispatch)
end
end
end
@@ -45,6 +45,19 @@ describe Sms::OneoffSmsCampaignService do
expect(campaign.reload.completed?).to be true
end
it 'marks the campaign completed after processing the audience' do
contact = create(:contact, :with_phone_number, account: account)
contact.update_labels([label1.title])
expect(sms_channel).to receive(:send_text_message) do
expect(campaign.reload.completed?).to be false
end
sms_campaign_service.perform
expect(campaign.reload.completed?).to be true
end
it 'uses liquid template service to process campaign message' do
contact = create(:contact, :with_phone_number, account: account)
contact.update_labels([label1.title])
@@ -61,6 +61,24 @@ describe Twilio::OneoffSmsCampaignService do
expect(campaign.reload.completed?).to be true
end
it 'marks the campaign completed after processing the audience' do
contact = create(:contact, :with_phone_number, account: account)
contact.update_labels([label1.title])
expect(twilio_messages).to receive(:create).with(
body: campaign.message,
messaging_service_sid: twilio_sms.messaging_service_sid,
to: contact.phone_number,
status_callback: 'http://localhost:3000/twilio/delivery_status'
) do
expect(campaign.reload.completed?).to be false
end
sms_campaign_service.perform
expect(campaign.reload.completed?).to be true
end
it 'uses liquid template service to process campaign message' do
contact = create(:contact, :with_phone_number, account: account)
contact.update_labels([label1.title])
@@ -82,6 +82,19 @@ describe Whatsapp::OneoffCampaignService do
expect(campaign.reload.completed?).to be true
end
it 'marks the campaign completed after processing the audience' do
contact = create(:contact, :with_phone_number, account: account)
contact.update_labels([label1.title])
expect(whatsapp_channel).to receive(:send_template) do
expect(campaign.reload.completed?).to be false
end
described_class.new(campaign: campaign).perform
expect(campaign.reload.completed?).to be true
end
it 'processes contacts with matching labels' do
contact_with_label1, contact_with_label2, contact_with_both_labels =
create_list(:contact, 3, :with_phone_number, account: account)
@@ -60,7 +60,7 @@ describe Whatsapp::Providers::WhatsappCloudService do
attachment = message.attachments.new(account_id: message.account_id, file_type: :image)
attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
stub_request(:post, 'https://graph.facebook.com/v13.0/123456789/messages')
stub_request(:post, 'https://graph.facebook.com/v24.0/123456789/messages')
.with(
body: hash_including({
messaging_product: 'whatsapp',
@@ -79,7 +79,7 @@ describe Whatsapp::Providers::WhatsappCloudService do
# ref: https://github.com/bblimke/webmock/issues/900
# reason for Webmock::API.hash_including
stub_request(:post, 'https://graph.facebook.com/v13.0/123456789/messages')
stub_request(:post, 'https://graph.facebook.com/v24.0/123456789/messages')
.with(
body: hash_including({
messaging_product: 'whatsapp',
@@ -91,6 +91,41 @@ describe Whatsapp::Providers::WhatsappCloudService do
.to_return(status: 200, body: whatsapp_response.to_json, headers: response_headers)
expect(service.send_message('+123456789', message)).to eq 'message_id'
end
it 'calls message endpoints for audio voice message with voice flag' do
attachment = message.attachments.new(account_id: message.account_id, file_type: :audio, meta: { 'is_voice_message' => true })
attachment.file.attach(io: Rails.root.join('spec/assets/sample.ogg').open, filename: 'voice.ogg', content_type: 'audio/ogg')
stub_request(:post, 'https://graph.facebook.com/v24.0/123456789/messages')
.with(
body: hash_including({
messaging_product: 'whatsapp',
to: '+123456789',
type: 'audio',
audio: WebMock::API.hash_including({ link: anything, voice: true })
})
)
.to_return(status: 200, body: whatsapp_response.to_json, headers: response_headers)
expect(service.send_message('+123456789', message)).to eq 'message_id'
end
it 'calls message endpoints for regular audio attachment without voice flag' do
attachment = message.attachments.new(account_id: message.account_id, file_type: :audio)
attachment.file.attach(io: Rails.root.join('spec/assets/sample.ogg').open, filename: 'audio.ogg', content_type: 'audio/ogg')
stub_request(:post, 'https://graph.facebook.com/v24.0/123456789/messages')
.with(
body: hash_including({
messaging_product: 'whatsapp',
to: '+123456789',
type: 'audio'
})
)
.to_return(status: 200, body: whatsapp_response.to_json, headers: response_headers)
result = service.send_message('+123456789', message)
expect(result).to eq 'message_id'
end
end
end
+28
View File
@@ -145,6 +145,34 @@ inbox_create_payload:
$ref: ./request/inbox/create_payload.yml
inbox_update_payload:
$ref: ./request/inbox/update_payload.yml
inbox_create_web_widget_channel_payload:
$ref: ./request/inbox/channels/create_web_widget_channel_payload.yml
inbox_create_api_channel_payload:
$ref: ./request/inbox/channels/create_api_channel_payload.yml
inbox_create_email_channel_payload:
$ref: ./request/inbox/channels/create_email_channel_payload.yml
inbox_create_line_channel_payload:
$ref: ./request/inbox/channels/create_line_channel_payload.yml
inbox_create_telegram_channel_payload:
$ref: ./request/inbox/channels/create_telegram_channel_payload.yml
inbox_create_whatsapp_channel_payload:
$ref: ./request/inbox/channels/create_whatsapp_channel_payload.yml
inbox_create_sms_channel_payload:
$ref: ./request/inbox/channels/create_sms_channel_payload.yml
inbox_update_web_widget_channel_payload:
$ref: ./request/inbox/channels/update_web_widget_channel_payload.yml
inbox_update_api_channel_payload:
$ref: ./request/inbox/channels/update_api_channel_payload.yml
inbox_update_email_channel_payload:
$ref: ./request/inbox/channels/update_email_channel_payload.yml
inbox_update_line_channel_payload:
$ref: ./request/inbox/channels/update_line_channel_payload.yml
inbox_update_telegram_channel_payload:
$ref: ./request/inbox/channels/update_telegram_channel_payload.yml
inbox_update_whatsapp_channel_payload:
$ref: ./request/inbox/channels/update_whatsapp_channel_payload.yml
inbox_update_sms_channel_payload:
$ref: ./request/inbox/channels/update_sms_channel_payload.yml
# Team
team_create_update_payload:
@@ -0,0 +1,22 @@
type: object
title: API channel
required:
- type
properties:
type:
type: string
enum: ['api']
example: api
webhook_url:
type: string
description: Webhook URL for API channel inbox callbacks
example: 'https://example.com/webhook'
hmac_mandatory:
type: boolean
description: Require HMAC verification for incoming API channel messages
example: false
additional_attributes:
type: object
description: Additional attributes stored on contacts created through the API channel
example:
source: mobile_app

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